アトラシエの開発ブログ

株式会社アトラシエのブログです

sidekiqを使用する際に注意したい覚え書き

Railsで非同期処理を行う際にデファクトになりつつあるSidekiqですが、実際の運用ノウハウや少し踏み込んだトラブルシューティングは意外とまだウェブ上にリソースが不足しているという印象があります。そこでいくつか、基本的なことから少し踏み込んだ話まで、いくつか紹介したいと思います。

Sidekiqの導入・運用

Sidekiqの導入にはRedisが必要であるということはよく説明されるのですが、もう少し正確に言うとRedisをデータ保存先としてSidekiqというプロセスがスレッドベースで動きます。したがって正常にSidekiqが動くためには通常のRailsのプロセス(unicornやpassenger)のほかに、redis-serverのプロセス、Sidekiqのプロセスが動き続けていることが必要になります。(厳密に言えばSidekiqだけ動けばいいならRailsプロセスは不要です)

redis-serverが死んでいるとSidekiqはエラーを起こしますが、Sidekiqが死んでいてもredisに待機状態のジョブが積まれるので、あとからSidekiqプロセスを立ち上げれば積んでいるJobを消化していきます。 Sidekiqのプロセスは/app/jobs/app/workersのコードを変更したら再起動する必要があるなど、頻繁に停止・起動を繰り返します。 redisの死活監視やデータ容量などを気にしたくない場合はRedisToGoという選択肢もあります。

redistogo.com

ただ、RedisToGoはわりと高価なのと、Sidekiqに必要なデータサイズは大したことがないので小さいサービスであればアプリケーションサーバの一つをredisサーバ併用にしてもいいかと思います。

デプロイ

qiita.com

この記事で解説されていますが、Sidekiqの更新反映はけっこうたいへんです。また、Sidekiqがなんらかの事情で死んでしまった時に手元から起動させるということもあるので、Capistranoの導入はほぼマストな気がします。 この記事にあるように、capistranoでリリースしたとしてもSidekiqのワーカーが完了してから停止するわけではないので、ワーカーが途中で中断した際にもう一度実行しても同じ結果が得られるように(処理の冪等性が保証されるように)ジョブをデザインしなければいけないでしょう。

具体的には、メールを送る程度のジョブなら意識する必要もないでしょうが、かなりの時間をかけて集計してレコードを作っていくようなジョブの場合、ジョブの先頭で仮にすでに該当レコードが全部ないし一部存在するときは全て削除する、というようなことです。 ジョブはリトライしても同じ結果が得られるようにしましょう

ダッシュボードについて

f:id:attracie:20151018190216p:plain

Sidekiqにはダッシュボードがついてきます。待機状態というのは先ほど言ったプロセスに渡っていないRedisに積まれたジョブ、ビジーが現在実行中のジョブで処理完了・失敗に分類されていきます。(個人的には並びが逆のほうがわかりやすい気がします)

ビジーになっている現在実行中のジョブはスレッドベースで並列処理されますが、config/sidekiq.ymlで定義されたconcurrencyの数を超えることは(シングルプロセスの場合は)ありません。

※未検証ですがマルチプロセスだとプロセス数×concurrencyの数が上限になるかもしれません

queue(待機状態)はdefaultのほか、独自に定義して増やすことができます。 queueで分類することで溜まったジョブをまとめて消したり、処理の優先順位付けができます。

最初できるかなと思ったのですが、queueごとで処理のスレッド数を割り当てることは(標準では)できないようです。つまりconcurrencyが25のとき、5をheavy_task、20をdefaultに割り当てるというような使い方です。

ライブラリがあるので、もう少し込み入ったqueueの使い方をしたいときはこのあたりが必要になります。

github.com

まぁとはいえ、標準でサポートされていなければ実際なくてもなんとかなる機能な気もするので、私はこのgemの導入を見送りました。

connection切れ、楽観的ロック

重い処理を行うJobの場合、ActiveRecord絡みで注意することがあります。 一つはActiveRecordのconnectionが接続が切れた時です。

d.hatena.ne.jp

重い集計処理をしてからインサートする場合 インサート直前でActiveRecord::Base.clear_active_connections!を呼ぶと直ると思います。

と書いてあるように、

result = Misc.too_large_calculate #とても時間がかかる
Result.create!(result) # 接続が切れている

こういうコードは途中ActiveRecordのコネクションが切れるので失敗します。claer_active_connections!でもいいですが、

  def perform
    result = Misc.too_large_calculate #とても時間がかかる
    with_connection { Result.create!(result) }
  end

  def with_connection
    ActiveRecord::Base.connection_pool.with_connection do
      yield
    end
  end

このようにコネクションを指定することで回避できます。

次に楽観的ロックについてですが、

user = User.find(id)
result = Misc.too_large_calculate
user.update(result)

このようなコードは計算時間中にuserが別のプロセスによって更新されてupdated_atが書き換わると、問答無用にrollbackされます。なので最新状態のモデルを取得すればOKです。

user = User.find(id)
result = Misc.too_large_calculate
with_connection { user.reload.update(result) }

メモリの問題

sidekiqで画像をImageMagickで変更・保存する処理を実行していると、このようなエラーが頻出しました。

error Cannot allocate memory - identify

これはまだ原因がはっきりわかっていないのですが、この記事に書いてある問題に関係があるようです。

adamniedzielski.github.io

先ほどのエラーももとをたどるとMiniMagickがバッククオートでidentifyを呼んでいる箇所が怪しいので、どうやらSidekiqのスレッドからさらに子プロセスを発生されるようなコードを書くとかなりメモリを食う、ということかなと思っています。(このあたりはUNIXシステムコールやメモリについてしっかり理解しているほうが原因が分かりそうですね)

これはチューニングで解決できそうですが、とりあえずはメモリが4GBくらいのサーバでconcurrencyを3くらいに抑えて動かせば落ちないで済んだので、それで乗り切っています。

追記

さすがに4GBで3並列しかできないのはおかしいなと思っていろいろ見たところ、MiniMagickにはよりメモリ効率の良いposix-spawnによるプロセス生成が可能でした。

github.com

def execute(command)
      stdout, stderr, status =
        MiniMagick.logger.debug(command.join(" ")) do
          Timeout.timeout(MiniMagick.timeout) do
            send("execute_#{MiniMagick.shell_api.gsub("-", "_")}", *command)
          end
        end

      [stdout, stderr, status.exitstatus]
    rescue Errno::ENOENT, IOError
      ["", "executable not found: \"#{command.first}\"", 127]
    end

設定でshell_apiopen3posix-spawnで切り替え可能です。(標準はopen3です) posix-spawnはgemが必要なのでこれをGemfileでロードする(require: falseでOK)だけでメモリ効率が上がります。

システムコールに詳しくないので、なぜposix-spawnのほうがメモリ効率が良いのか、効率がいいならもともと標準でこちらを使っていないのはなぜなのかはよくわかりません。が、

https://github.com/minimagick/minimagick#errnoenomem

Errno::ENOMEM

It can happen that, when dealing with very large images, the process runs out of memory, and Errno::ENOMEM is raised in your code. In that case try installing the posix-spawn gem, and tell MiniMagick to use it when executing shell commands.

MiniMagick.configure do |config| config.shell_api = "posix-spawn" end

とあるように、Railsのプロセスが画像処理を行うと標準でRailsプロセスと同じだけのメモリを消費する子プロセスが生まれるので、Rails側でメモリに載せた画像やPDFのサイズが大きいとメモリがシビアに圧迫されるようです。

パスの問題

Sidekiqでconvertのようなコマンドが見つからないというエラーが出て、サーバに入ってconvertを叩いてみると問題なく見つかる、というようなエラーがあります。これはbashrcのパスや環境変数をSidekiqが共有していないために発生します。 対策として、bashrcの環境変数管理はやめて、capistranoでセットするようにしましょう。

paths = [
  '/usr/local/bin',
  '/usr/local/rvm/gems/ruby-2.1.1/bin',
  '/usr/local/rvm/gems/ruby-2.1.1@global/bin',
  '/usr/local/rvm/rubies/ruby-2.1.1/bin',
]

set :default_env, {
  path: "#{paths.join(':')}:$PATH",
  ld_library_path: '/usr/local/lib',
  ldflags: "'-L/usr/local/lib -Wl,-rpath,/usr/local/lib'"
}

デバッグ

以上のように、RailsのコードとSidekiqのコードではけっこう勝手が違う部分があるのでそれを意識しないと頻繁にエンバグします。 そこでsidekiqのワーカーの状況をRailsのモデル等にメモすることがオススメです。

例えばJob側のコードで

def perform
  Model.reload.update(status: "[#{current_time}] start")
  ...
  Model.reload.update(status: "[#{current_time}] end page #{n + 1} / #{length}")
  ...
rescue => e
  Model.reload.update(status: "[#{current_itme}] error : #{e.message}")
end

def current_time
   Time.current.strftime("%Y/%m/%d %H:%M:%S")
end

というふうにこまめに処理の状況を更新して、

f:id:attracie:20151018195851p:plain f:id:attracie:20151018195856p:plain

管理画面でこのように表示させるだけでぐっとわかりやすくなります。

まとめと雑感

以上みたように割りとSidekiqの導入はともかく、運用はめんどくさいことが多いです。私見ですがスタートアップだと本当に非同期処理じゃないといけないのか、何かうまく工夫してSidekiqを使わないで済む方法はないか考えたほうがいいことが多い気がします。