Image Collector extensionでのDelayed Jobの使い方の歴史

Image Collector extensionのバックエンドはRuby on Railsで書かれたサーバアプリケーションです。Webページ上にある画像群について、ユーザが何らかのクラウドストレージにアップロードすることをポップアップウィンドウから選択したとき、拡張機能からは画像のURL群がサーバアプリケーションに送信されます。URLで示された画像ファイルをクラウドストレージにアップロードする処理は、拡張機能がやっているのではなく、僕の自宅で動いているサーバがやっていることです。

幸いにも現在週に14,000ものユーザが使っている拡張機能となりました。画像のアップロード要求もそれにつれて多くなってきて、サーバの処理に不都合が出る、最悪仮想VMが落ちてしまう、という状況にも度々なりました。何回か試行錯誤を行って、現在ではだいぶ安定稼働するようになりました。

ここでは、どんな修正をしてきたかを紹介してみます。

最初の処理内容

まず最初にどんな実装を行ったかを説明しましょう。拡張機能からは、以下のリクエストが送られてきます。括弧内の数字は、送られてくる個数です。

  • 対象のWebページのURL(1)

  • 対象のWebページのtitle要素の文字列(1)

  • 画像のURL(1…n)

  • セッションを特定するためのID

これらを受け取ったサーバでは、以下の順でクラウドストレージに画像をアップロードしていきます。これらの処理手順は、クラウドストレージの種別問わず一緒です。

    1. タイトル文字列を名前とするフォルダをクラウドストレージ内に作成する。
    1. 画像のURLから画像ファイルを作業用ファイルにダウンロードする。
    1. ダウンロードされたファイルをクラウドストレージにアップロードする。
    1. 2と3をURLの数だけ繰り返す。

上記全てをAjaxリクエストの受信から結果の送信の間で一気に同期処理として全て行うことは、言うまでもなく避けるべきです。画像ファイルのダウンロード時間とクラウドストレージへのアップロード時間は、明らかに時間がかかる処理になり、それが数十のURLとなればなおさらです。拡張機能へはすぐにレスポンスを返し、非同期、つまりバッチ処理として上記を処理すべきでしょう。Dropbox、Google Drive、そしてSkyDriveのどれもが、何かファイルが登録されればユーザに通知がいきます。そのため、上記の処理をある程度時間をかけてできるわけです。

非同期処理の実装のために、過去にちょっと使ったことがあった「 Delayed Job」を使うことにしました。これは、超手軽に非同期処理を行える強力なライブラリです。例えば、以下のように「.delay.」を挟むだけで、対象のメソッドを非同期で実行できるようになります。

def foo
  self.delay.bar #ここでブロックされない
  # do something...
end

def bar
  # この中は非同期で実行される
end

非同期呼び出しの登録自体は、データベースに書き込まれます。つまり、「.delay.」によって行われる処理は、データベースにあるdelayed_jobs表に行をINSERTすることのみです。実際に登録されたメソッド呼び出しは、あらかじめ常駐しているdelayed_jobデーモンプロセスによって実行されます。このデーモンプロセスは、複数常駐させておくことが可能です。一定時間ごとにデータベースに登録があるかどうかを監視し、もしあればそのメソッド呼び出しを行います。完了すると、delayed_jobs表から対象行を消します。監視間隔も指定可能です。

まず考えたことは、「非同期で実行されるタスクをできるだけ細かくしておいて、Delayed Jobで設定可能な各種パラメータを調整することで、負荷が一定になるようにすること」です。つまり、擬似的なコードで示すと、以下のような感じになります。

class UploadController < ApplicationController
  def upload
    # register_task()を非同期実行
    CloudStorageUploader.delay.register_task(title, urls)
  end
end

class CloudStorageUploader
  def self.register_task(title, urls)
    # create_folder()を非同期実行
    self.delay.create_folder(title, urls)
  end

def create_folder(title, urls)
    # titleを名前とするフォルダをクラウドストレージ内に作成
    urls.each do |url|
      # upload_file()を非同期実行
      self.delay.upload_file(folder, url)
    end
  end

def upload_file(folder, url)
    # urlのファイルをダウンロード
    # ダウンロードしたファイルをクラウドストレージのフォルダにアップロード
  end
end

つまり、バッチ処理されるタスクは以下です。

  • register_task() - 画像アップロード処理開始の登録

  • create_folder() - フォルダの作成

  • upload_file() - 画像ファイルのダウンロード&アップロード

upload_file()は、画像ファイル数分、つまりurlsの配列個数分だけdelayed_jobs表に登録されます。フォルダ、ファイル単位に処理が分割されるわけです。これにより、Webページごとの画像ファイル個数に関わらず、全体が均一化されて、あとはDelayed Jobのパラメータを調整しながらキューに入った各タスクを負荷が一定になるように着々消費していけば良い、と考えていました。

問題の発生

ここで僕は大きな問題を見逃していたことになります。これだと、あるタイミングでデータベースに高負荷がかかることになります。どこでしょうか?

Delayed Jobは、タスクのキューイングをデータベースに行追加していくことで実現しています。つまり、create_folder()メソッド内でクラウドストレージにフォルダを作成した後、登録したい画像の個数分だけタスク登録をすることになります。そう、画像の数だけ、INSERT文が発行されるのです。しかも一気に。画像が100個あれば、INSERT文も100回一気に発行されます。僕の実装では、sleepを入れることなくループでupload_file()メソッド呼び出しをdelayにより登録していました。Delayed Jobのデーモンプロセスを4つ動かしていて、例えば100画像登録が同時に複数きた場合、INSERT文が連続して400回発行されるわけです。

MySQLを動かしているサーバは、KVMで作った仮想環境です。そのホストOSのLoad値が、上記の状況が起きるたびに30近くまで跳ね上がってました。CPUは4つに見えているため、常に約7プロセスが待ちになる状態です。悲鳴をあげてる感じですね。MySQLがギブアップし、当時は例外を補足していなかったため、Delayed Jobのデーモンが次々と死んでしまい、DBへのタスク登録が次々と溜まってしまう事態になりました。数万も。

細かく分ければあとで調整できる、という思惑は、その手前でネックを作ってしまったわけです。

施した対策

さて、こうなってしまったからには、何か手を打たなければなりません。取れる選択肢として以下の2つを想像しました。

  • INSERTが集中してしまうことを避けるために、sleepを入れて各INSERTの間隔を時間的にあける。

  • 一つの画像ごとにタスク登録することをやめて、非同期の単位をページごとに粗くする。

どちらも一見良さそうですが、前者は結局Delayed Jobのデーモン数を多くすればINSERTが集中してしまうことに変わりありません。それ以前に、INSERTの発行数が減るわけではないので、実際の画像登録処理全体がまったりとしてしまうという副作用も出てしまいます。

実際に選択したのは、後者の方法です。コードは、以下のようになります。

class UploadController < ApplicationController
  def upload
    # register_task()を非同期実行
    CloudStorageUploader.delay.register_task(title, urls)
  end
end

class CloudStorageUploader
  def self.register_task(title, urls)
    # create_folder()を同期実行
    self.create_folder(title, urls)
  end

def create_folder(title, urls)
    # titleを名前とするフォルダをクラウドストレージ内に作成
    urls.each do |url|
      # upload_file()を同期実行
      self.upload_file(folder, url)
    end
  end

def upload_file(folder, url)
    # urlのファイルをダウンロード
    # ダウンロードしたファイルをクラウドストレージのフォルダにアップロード
  end
end

つまり、バッチ処理されるタスクは、register_task()のみです。並列処理されるのは、これで画像単位ではなくページ単位となりました。INSERTの発行もページ単位となったため激減しています。これでMySQLへの負荷はほとんどなくなったことになります。ただし、Delayed Jobの1デーモンあたりの処理時間は延びます。あるWebページに30画像あった場合は、その30画像のダウンロードとクラウドストレージへのアップロードを1デーモンが担当することになるからです。

画像を数多く持つWebページの処理要求が来た場合、Delayed Jobのデーモンを長時間占有してしまうことになります。これは画像単位にした場合にも絶対的な処理時間は変わりません。ただし、ページ単位にしたことで、嬉しい副作用もあります。画像単位にしてしまうと制御が効かないのですが、ページ単位にしたことで「ページあたりの最大処理時間の制限」がかけられることになります。Delayed Jobの各タスクに対して、設定した時間以内に処理が終わらなかった場合はそのタスクを失敗させるという機能があります。これを使うことで、あまりにも多くの画像を持つページの処理要求が来た場合にも、ユーザからすると「全部やってよ」と思ってしまうかもしれませんが、サーバ資源をある程度フェアにユーザへ提供することができるようになりました。

まとめ

Image Collector extensionのバックエンドサーバは、自作PCを3台使って構築した「貧弱」な環境で現在運用しています。処理を細かく分けてスケールするように、と並列戦略をどうしても考えてしまいましたが、それはサーバ資源を横にスケールさせる(つまり台数を増やせる)環境だったらの話かもしれません。むやみに処理単位を細かくせずに、大きな単位を保った上でその処理時間の制限をかけておく、という「仕様側を犠牲にする」ことは、少ないサーバ資源の有効活用と共に、ある程度均等なサーバリソースのユーザへの提供という観点においては、あながち間違ったアプローチとは言えないのかな、と振り返った今日この頃でした。

このエントリーをはてなブックマークに追加

関連記事

2023年のRemap

Remapにファームウェアビルド機能を追加しました

Google I/O 2023でのウェブ関連のトピック

2022年を振り返って

現在のRemapと今後のRemapについて