次世代バックグラウンドジョブシステム Faktory を試す

リサーチ・アンド・イノベーションの浜田(hamadu)と申します。 いつものお買い物がちょっとお得に、家計簿にもなるポイントアプリ「CODE」のサーバサイド、およびAndroidアプリの開発を担当しています。

CODE ではバックグラウンドジョブシステムとして、Sidekiq を採用しています。その作者、Mike Perham 氏が新しい仕組みを作っていました。その名も Faktory。Sidekiq と違いワーカーが言語に依存せず、また本体はGoで書かれているためスケールするのがウリのようです。まだまだ開発中で、プロダクションで使うには厳しい印象を受けますが、今後Sidekiqを置き換えうるプロジェクトになるのではと思います。

本稿では簡単に仕組みの紹介をして、Rubyでジョブを投げるClientと、ジョブを処理するワーカーをそれぞれ実装してみました。 ソースコードfaktory-ruby-sample にあります。「御託はいいから試したい」という人は README.md に従って手を動してみてください。

仕組み

一般にバックグラウンドジョブシステムは非同期に処理したいお仕事(本稿では ジョブ と表記)を受け付けて管理するサーバと、それらを処理するワーカーから構成されています。ワーカーは一般に複数あり、並列で仕事ができるようになっています。

f:id:r-n-i:20180214163243p:plain

ジョブがClientから飛んでくると、まずタスクキューに溜まります。ワーカーは暇な(処理しているジョブが無い)時、サーバに対してリクエストを投げ、キューに溜まっているジョブを取ります。

優先度、項目別にキューイング

Faktoryは(Sidekiqもですが)ジョブの種類ごとにラベル(優先度)が付けられるようになっています。ワーカーは処理するジョブのラベルを限定できるので、例えば優先して処理したいジョブは複数のワーカーで、そうでもないジョブは暇な時に適当なワーカーで、といったことができます。上の図で言うと Task Queue に当たるものが複数あり、各ワーカーが取得するキューを選べるように設定できるイメージです。

ジョブのリトライ

ジョブの実行に失敗した場合、そのジョブはリトライ用のキューに積まれます。各ジョブごとにリトライできるまでの待機時間が与えられ、しばらく待たないと再実行できません。さらに、この時間は指数的に増えていきます。

Sidekiqと比べて何が良いか

外部に依存しないデータストア

Sidekiq はジョブを管理するデータストアとして Redis に依存しており、プロセスの起動に動いている Redis が必須でした。Faktory は 内部で RocksDB というkey-valueストアを用いていて、本体は1バイナリを走らせるだけで完結します。運用が楽ですね。

言語に依存しないワーカー

Sidekiq は Rubygem として提供されていて、良くも悪くも Ruby に依存していました。Rails との相性はいいのですが、他の言語からだと原則使えないのが残念です。Faktoryのサーバは規定の形式でTCP上で喋るようになっているので、どんな言語でもワーカーが書けます。公式では現在 RubyGo が提供されていますが、他にもサードパーティ製のワーカー が公開されています。

使ってみる

早速使ってみます。今回はRubyで、公式で提供されている gem を使ってバックグラウンドジョブを書いてみます。例としてURLの文字列を引数として受けると、そこへGETリクエストを投げ、結果を標準出力に書き出すものを作ってみます。

Faktoryのインストールと起動

Docker image が提供されている ので、それをまるっと使わせてもらいましょう。

# Docker imageをpullして
$ docker pull contribsys/faktory

# 起動させます。これはデバッグ用で、インメモリDBを使う設定になっています。
# 本番で使うときはジョブを保存するデータのパスを指定します。
$ docker run --rm -it -p 127.0.0.1:7419:7419 -p 127.0.0.1:7420:7420 contribsys/faktory:latest

Faktoryはデフォルトで7419、7420のポートを受け付けます。7419番はサーバとやり取りするAPIの入り口、7420番はWeb UIです。

Web UIを見てみる

ブラウザで localhost:7420 にアクセス。すると、Sidekiq を使ったことがある人には馴染み深い画面が表示されます。

f:id:r-n-i:20180214163339p:plain

サンプルジョブとクライアントを書く

早速ジョブとクライアントを書いてみましょう。今回はRailsのプロジェクトの中に組み込むのではなく、生のRubyで実装してみます。公式gemの faktory_worker_ruby を使います。本稿のサンプル実装は faktory-ruby-sample に置いてあります。

ジョブを書く

まず処理したいジョブを記述します。Rubyの場合、Faktory::Job を include した上で本体である perform(*args) を実装します。*args はジョブ の引数です。Sidekiqを使ったことがあれば馴染み深いでしょう。

# fetch_url_worker.rb
require 'faktory_worker_ruby'
require 'open-uri'

class FetchURLWorker
  include Faktory::Job

  def perform(*args)
    puts "Hello, I am #{jid} with args #{args}"

    url = args[0]
    open(url) do |f|
      puts f.readlines.join("\n")
    end
  end
end

jid はジョブの識別子で、後述するクライアントでジョブを投げる時に生成されます。

ジョブを投げるクライアントを書く

次にジョブを投げるクライアントを実装します。先程書いた FetchURLWorkerrequire して #perform_async もしくは #perform_in を呼ぶだけです。 #perform_async は指定のジョブを即実行します。 #perform_in は指定秒数後にジョブを実行します。

# client.rb
require './fetch_url_worker'

# ジョブを即実行
FetchURLWorker.perform_async('https://r-n-i.jp/')

# 1分後にジョブを実行
FetchURLWorker.perform_in(60, 'https://notexist.r-n-i.jp/')

実行してみる

まずは手元でワーカーを起動します。

$ bundle exec faktory-worker -r fetch_url_worker.rb

すると、Web UIの「実行中」のところにプロセスが現れます。

f:id:r-n-i:20180214163349p:plain

この状態で、クライアントのスクリプトを走らせます。

$ bundle exec ruby client.rb

ジョブが2つ作成され、1つは即実行されます。もう1つはキューにたまった状態になり、Web UIの「予定」のところに入ります。

f:id:r-n-i:20180214163410p:plain

ジョブの失敗

遅延して実行される方のジョブはあえて失敗させてます。(URLが存在しません。) ジョブが失敗すると、「再試行」のキューに入ります。

f:id:r-n-i:20180214163427p:plain

暫く経つと再度走りますが、失敗し続けるはずです。その度に試行回数が増えていき、再実行までの時間が長くなるようになっています。このあたりもSidekiqと同じですね。

まとめ

Sidekiqと同じ使用感で回せそうだ、と分かりました。まだ本番運用するには危ないですが、Rubyではない趣味プロジェクトで人柱になってみるのはありかなぁと思ってます。ちなみに、Faktoryはtech系podcast:The ChangeLog で紹介されていたので知りました。Faktoryの回は こちら。バックグラウンドジョブシステムの設計思想、Amazon SQSとの住み分け、Sidekiqの裏話などが聞けるので興味があればぜひ。

リサーチ・アンド・イノベーションでは、プロダクト開発だけでなく、技術の情報収集にも積極的なエンジニアを募集しています。こちら からご応募お待ちしております。

参考URL