Resqueで複数サイトにまたがるクローリングを最適化しよう

どうも。バックエンドエンジニアの吉田です。 前回は1サイトをクロールする際の最適化戦略としてRedisベースの分散ロック機構を使った実例を紹介しました。 前回の記事:Redis::DistMutex – 時限付き分散ロックで効率良くサイトクロールをしよう 今回は複数サイトに対する処理をResqueを使って最適化した事例を紹介したいと思います。 ※ランダムにキューをlistenする話の予定でしたが、話がとっ散らかるので主題を変更しました。 主なキーワードとしては、「Resqueのキュー分割」、「Rubyでクラス定義を動的生成」といった感じです。

おさらい

前回使った図を使います。 f:id:vasilyjp:20160303212400j:plain iQONのクローラーは、提携サイトの商品一覧から商品ページのURLを取得し、ページをダウンロードする処理(fetchフェーズ)を必要な数だけWorkerプロセスとして起動しておき、Resqueを使って処理をしています。ダウンロードが終わったら順次解析する処理(processフェーズ)にResque経由で処理を引き継ぎます。これはクローラーによくある設計です。 今回のお題は、このダウンロード処理を複数サイトに対して並列に良い感じに処理させるための最適化事例とその実装例です。

キューの分割

このような設計にした場合、ページのダウンロード処理は複数のWorkerに分散されますが、複数サイトを同時にクロールした場合、enqueueされるURLの順序は予測できません。 このダウンロード処理用のキューが1つしかないと、キューは順番にdequeueされるので1サイトのURLが連続する可能性があります。1サイトに対するHTTPリクエストは時限付きMutexによって処理スピードが制限されるため、他ドメインの処理がブロックされてしまいます。 サイトごとにキューを用意すれば、キューの順序にかかわらず1サイトの処理が他サイトの処理をブロックすることもありません。 キューの状態を図にするとこんな感じです。 f:id:vasilyjp:20160303212519p:plain ただし、Resqueの場合はキューの指定をクラス変数によって定義する前提であるため、クロール対象数だけその定義を用意しなくてはなりません。dequeして得られたURLをダウンロードする処理はサイトに依らず共通であるのに、クラス定義を複数用意するのは非効率ですし、Workerの起動もサイト数分指定しなくてはいけません。 問題の解決策として、動的にクラス定義を生成する手法を採用しました。

動的にWorkerクラスを生成する

ダウンロード処理をするクラステンプレートを用意して、それを設定ファイルに定義されたサイトのリストをもとに複数のクラス定義を動的生成します。 Workerを起動するときは定義されたサイトIDから全部のクラス定義を生成すればよく、enqueするときも同様の戦略で問題解決できます。 実際のコードを見たほうが理解しやすいでしょう。 iQONではサイトをdomainと読んでいるため、コードもそれに倣います。

クラス設計概観

f:id:vasilyjp:20160303212603p:plain 実際のクラステンプレートは以下のように実装しています。

サイト別定義を動的生成するための抽象クラス定義

module Crawler::Workers
  class DomainSpecificWorker
    def self.domain_id=(domain_id)
      name = "#{instance_variable_get(:@queue)}-#{domain_id}" 
      instance_variable_set(:@queue, name.to_sym) 
      instance_variable_set(:@domain_id, domain_id)
    end 
    def self.domainify(domain_id)
      klass_name = "#{name.split('::').last}_#{domain_id}" unless Crawler.const_defined?
      klass_name Crawler.const_set(klass_name.to_sym, clone)
      klass = Crawler.const_get(klass_name.to_sym) 
      klass.domain_id = domain_id 
    end 
    Crawler.const_get(klass_name.to_sym)
    end
  end 
end

ページをダウンロードするWorkerクラスの定義

module Crawler::Workers 
  class FetchPageWorker < DomainSpecificWorker 
    @queue = :fetch_page def self.perform(params)    # 実際のダウンロード処理は別クラスに定義して、それを呼び出すだけ
  end 
end

※今見ると、DomainSpecificWorkerはモジュール化してMix-inしたほうがRubyらしい感じもしますね…

Before(静的にクラス定義を用意する場合)

enqueする時のコード例

args = {domain_id: 1, url: 'http://...'} 
Resque.enqueue(FetchPageWorker1, args)

Workerの起動スクリプト

domain_ids = config.get(:domain_ids) # ドメインごとのクラス定義を読み出し
queues = domain_ids.map { |domain_id| Crawler::Workers.const_get("FetchPageWorker#{domain_id}") } # ランダムに並び替えた優先順でワーカーを起動 
worker = Resque::Worker.new(*queues.shuffle) 
worker.work

After(動的にクラス定義を用意する場合)

enqueする時のコード例

args = {domain_id: 1, url: 'http://...'} 
Resque.enqueue(FetchPageWorker.domainify(domain_id), args) 

Workerの起動スクリプト

domain_ids = config.get(:domain_ids) # ドメインごとのクラス定義を動的生成 
queues = domain_ids.map { |domain_id| FetchPageWorker.domainify(domain_id) } # ランダムに並び替えた優先順でワーカーを起動 
worker = Resque::Worker.new(*queues.shuffle) 
worker.work 

このようにWorker.domainify(domain_id)をコールすると、そのサイト固有のWorkerクラス定義を生成できるので、コードもシンプルに保つことができます。 さらにWorkerの起動スクリプト中で'queues.shuffle'することで、スクリプト実行の度に毎回優先順位の異なるプロセスが起動され、複数サイトの処理ももっとバランスよく並列に処理することができます。

まとめ

サイトごとのキューを用意することでクローラー全体として並列度をキープ&処理効率の最適化ができます。さらに、ResqueのWorkerクラス定義を動的生成することで、実処理が共通なクラス定義を複数用意するという実装コスト・管理コストを下げ、コードの簡潔性も保つことができました。 前回はiQONのサービス規模も交えながらの事例紹介でしたが、今回はRubyやResqueの活用方法にフォーカスした事例紹介をしてみました。 ちなみに、VASILYではエンジニア向けのセミナーを近日中に開催を予定しています。。 VASILYというスタートアップがどういう会社なのか、どんなサービスをつくっているのか、技術的にどんな事に取り組んでいるのかということをお話する予定です。 日程・内容が確定しだいお知らせしますので、興味のある方はぜひご参加ください。