メモリ・リークのデバッグ

Scrapyでは、リクエスト、レスポンス、アイテムなどのオブジェクトの寿命は有限です。それらは作成され、しばらく使用され、最終的に破棄されます。

これらすべてのオブジェクトの中で、Requestはおそらく最も長い寿命を持つものです。それを処理するまでスケジューラのキューで待機しているためです。 詳細については、 アーキテクチャ概観 を参照してください。

これらのScrapyオブジェクトには(かなり長い)寿命があるため、それらを適切に解放せずにメモリに蓄積してしまい、「メモリ・リーク」と呼ばれるものを引き起こすリスクが常にあります。

メモリリークのデバッグを支援するために、Scrapyは trackref というオブジェクト参照を追跡するための組み込みメカニズムを提供します。また、 muppy というサードパーティ・ライブラリを使用すると、より高度なメモリ・デバッグが可能になります(詳細は以下を参照)。両方のメカニズムは、Telnetコンソール から使用する必要があります。

メモリ・リークの一般的な原因

Scrapy開発者がリクエストで参照されるオブジェクトを渡し(たとえば、 cb_kwargs または meta 属性またはリクエスト・コールバック関数の使用)、そして、事実上、それらの参照されたオブジェクトの寿命をリクエストの寿命に合わせます。これは、Scrapyプロジェクトでのメモリ・リークの最も一般的な原因であり、初心者にとってデバッグが非常に難しいものです。

大きなプロジェクトでは、スパイダーは通常、異なる人々によって作成され、それらのスパイダーの一部は漏出(leak)する可能性があり、したがって、同時に実行されると他の(ちゃんと書かれた)スパイダーに次々に影響を与え、クロール・プロセス全体に影響を与えます。

(以前に割り当てられた)リソースを適切に解放していない場合、作成したカスタムミドルウェア、パイプライン、または拡張機能からもリークが発生する可能性があります。たとえば、spider_opened でリソースを割り当てても、 spider_closed でリソースを解放しないと、プロセスごとに複数のスパイダー を実行している場合に問題が発生する可能性があります。

リクエストが多すぎるのか?

デフォルトでは、Scrapyはリクエスト・キューをメモリに保持します。 Request オブジェクトとRequest属性で参照されるすべてのオブジェクト(例 cb_kwargsmeta )。必ずしもリークではありませんが、これには大量のメモリが必要になる場合があります。 永続ジョブ・キュー を有効にすると、メモリ使用量を制御できます。

trackref を使用したメモリ・リークのデバッグ

trackref は、メモリ・リークの最も一般的なケースをデバッグするためにScrapyが提供するモジュールです。基本的に、すべての、生存中の、リクエスト、レスポンス、アイテム、セレクター・オブジェクトへの参照を追跡します。

telnetコンソールに入り、 print_live_refs() のエイリアスである prefs() 関数を使用して、(上記のクラスの)オブジェクトが現在何個生きているかを検査することができます。

telnet localhost 6023

>>> prefs()
Live References

ExampleSpider                       1   oldest: 15s ago
HtmlResponse                       10   oldest: 1s ago
Selector                            2   oldest: 0s ago
FormRequest                       878   oldest: 7s ago

ご覧のとおり、このレポートには各クラスの最も古いオブジェクトの「年齢」も表示されます。プロセスごとに複数のスパイダーを実行している場合、最も古い要求または応答を調べることで、どのスパイダーがリークしているかを把握できます。 get_oldest() 関数を使用して(telnetコンソールから)各クラスの最も古いオブジェクトを取得できます。

どのオブジェクトが追跡されますか?

trackrefs によって追跡されるオブジェクトはすべてこれらのクラス(およびそのすべてのサブクラス)からのものです:

実例

メモリ・リークの仮定ケースの具体例を見てみましょう。 以下のようなスパイダーがあるとします:

return Request(f"http://www.somenastyspider.com/product.php?pid={product_id}",
               callback=self.parse, cb_kwargs={'referer': response})

この例は、レスポンスの寿命をリクエストの寿命と効果的に結び付けるレスポンス参照をリクエスト内に渡しているため、間違いなくメモリ・リークが発生します。

trackref ツールを使用して、原因を(もちろん、事前に知らないものとして)発見する方法を見てみましょう。

クローラーが数分間実行され、メモリー使用量が大幅に増加したことに気付いたら、telnetコンソールに入り、生存中の参照(Live References)を確認できます:

>>> prefs()
Live References

SomenastySpider                     1   oldest: 15s ago
HtmlResponse                     3890   oldest: 265s ago
Selector                            2   oldest: 0s ago
Request                          3878   oldest: 250s ago

レスポンスはリクエストと比較して比較的短い寿命であるはずなので、非常に多くの生存中のレスポンスが存在するという事実(そしてそれらが非常に古いという事実)は間違いなく疑わしいです。レスポンスの数はリクエストの数と似ているため、何らかの形で結び付けられているように見えます。これで、スパイダーのコードを調べて、リークを生成している厄介な当該コード(リクエスト内でレスポンス参照を渡している)を発見できます。

生存中オブジェクトに関する追加情報が役立つ場合があります:

>>> from scrapy.utils.trackref import get_oldest
>>> r = get_oldest('HtmlResponse')
>>> r.url
'http://www.somenastyspider.com/product.php?pid=123'

最も古いオブジェクトを取得する代わりに、すべてのオブジェクトを反復処理する場合は、 scrapy.utils.trackref.iter_all() 関数を使用できます:

>>> from scrapy.utils.trackref import iter_all
>>> [r.url for r in iter_all('HtmlResponse')]
['http://www.somenastyspider.com/product.php?pid=123',
 'http://www.somenastyspider.com/product.php?pid=584',
...]

スパイダーが多すぎるのか?

プロジェクトで並行して実行されるスパイダーが多すぎる場合、 prefs() の出力は読みにくい場合があります。このため、その関数には、特定のクラス(およびそのすべてのサブクラス)を無視するために使用できる ignore 引数があります。たとえば、以下はスパイダーへの生存中参照を表示しません:

>>> from scrapy.spiders import Spider
>>> prefs(ignore=Spider)

scrapy.utils.trackref モジュール

trackref モジュールで利用可能な関数は以下のとおりです。

class scrapy.utils.trackref.object_ref[ソース]

trackref モジュールを使用して生存中のインスタンスを追跡する場合は、このクラスから継承します。

scrapy.utils.trackref.print_live_refs(class_name, ignore=NoneType)[ソース]

クラス名でグループ化された生存中参照のレポートを出力します。

パラメータ

ignore (type or tuple) -- 指定された場合、指定されたクラス(またはクラスのタプル)からのすべてのオブジェクトは無視されます。

scrapy.utils.trackref.get_oldest(class_name)[ソース]

指定されたクラス名で生きている最も古いオブジェクトを返すか、見つからない場合は None を返します。最初に print_live_refs() を使用して、クラス名ごとに追跡されているすべての生存中のオブジェクトのリストを取得します。

scrapy.utils.trackref.iter_all(class_name)[ソース]

指定されたクラス名で生きているすべてのオブジェクトのイテレータを返します。見つからない場合は None を返します。最初に print_live_refs() を使用して、クラス名ごとに追跡されているすべての生存中のオブジェクトのリストを取得します。

muppyを使ってメモリ・リークをデバッグする

trackref は、メモリ・リークを追跡するための非常に便利なメカニズムを提供しますが、メモリリークを引き起こす可能性が高いオブジェクトのみを追跡します。 ただし、他の(多かれ少なかれ不明瞭な)オブジェクトからメモリ・リークが発生する場合があります。そうした場合に trackref を使用してリークを見つけることができない場合でも、muppyライブラリという別の方法があります。

Pympler からmuppyを使用できます。

pip を使用する場合、次のコマンドでmuppyをインストールできます:

pip install Pympler

muppyを使用してヒープ内で使用可能なすべてのPythonオブジェクトを表示する例を次に示します:

>>> from pympler import muppy
>>> all_objects = muppy.get_objects()
>>> len(all_objects)
28667
>>> from pympler import summary
>>> suml = summary.summarize(all_objects)
>>> summary.print_(suml)
                               types |   # objects |   total size
==================================== | =========== | ============
                         <class 'str |        9822 |      1.10 MB
                        <class 'dict |        1658 |    856.62 KB
                        <class 'type |         436 |    443.60 KB
                        <class 'code |        2974 |    419.56 KB
          <class '_io.BufferedWriter |           2 |    256.34 KB
                         <class 'set |         420 |    159.88 KB
          <class '_io.BufferedReader |           1 |    128.17 KB
          <class 'wrapper_descriptor |        1130 |     88.28 KB
                       <class 'tuple |        1304 |     86.57 KB
                     <class 'weakref |        1013 |     79.14 KB
  <class 'builtin_function_or_method |         958 |     67.36 KB
           <class 'method_descriptor |         865 |     60.82 KB
                 <class 'abc.ABCMeta |          62 |     59.96 KB
                        <class 'list |         446 |     58.52 KB
                         <class 'int |        1425 |     43.20 KB

muppyの詳細については、 muppy documentation を参照してください。

Scrapyではリークしてないのにリークしてるorz

Scrapyプロセスのメモリ使用量は増加するだけで、決して減少しないことに気付く場合があります。 残念ながら、Scrapyもプロジェクトもメモリをリークしていなくても、これは起こり得ます。これは、Pythonの(あまりよくない)既知の問題が原因であり、場合によっては解放されたメモリをオペレーティングシステムに返さないことがあります。 この問題の詳細については以下を参照して下さい。

this paper で詳しく説明されている、エヴァン・ジョーンズによって提案された改善点は、Python 2.5に統合されましたが、これは問題を軽減するだけで、完全には修正しません。 当該箇所を引用すると:

残念ながら、このパッチは、オブジェクトが割り当てられていない場合にのみアリーナを解放できます。これは、断片化が大きな問題であることを意味します。アプリケーションには、すべてのアリーナに散らばった数メガバイトの空きメモリがありますが、どれも解放できません。これは、すべてのメモリ・アロケータで発生する問題です。これを解決する唯一の方法は、メモリ内のオブジェクトを移動できる圧縮ガベージコレクタに移動することです。これには、Pythonインタープリターの大幅な変更が必要になります。

メモリ消費を適切に保つために、ジョブをいくつかの小さなジョブに分割するか、 永続ジョブ・キュー を有効にして、スパイダーを時々停止/開始できます。