Pythonで数GBのログファイルを処理:mmapを使ってRAM不足を回避する方法

Python tutorial - IT technology blog
Python tutorial - IT technology blog

問題:ログファイルがRAMの「破壊神」になるとき

キャリアの初期に、一見単純そうなタスクを任されたことがあります。それは、システムログファイルをスキャンしてエラーリクエスト(ステータス500)を探すというものでした。私はいつもの癖で、data = open('access.log').read() というコードを書きました。

結果は悲惨なものでした。その日のログファイルが12GBもあったため、8GBのRAMを搭載したサーバーは力尽きてしまいました。Pythonは全内容をメモリにロードしようとし、古典的な MemoryError を引き起こしたのです。たとえ for line in file を使ったとしても、複雑な検索が必要だったり、特定の場所を頻繁にシーク(seek)したりする場合、速度は極端に遅くなります。

その救世主となる解決策が mmap (Memory-mapped file support) です。この技術により、OSはファイルの内容を仮想アドレス空間に直接マッピングできます。ファイルをRAMにロードする代わりに、Pythonはファイルをディスク上の巨大なバイト配列として扱います。これにより、物理メモリとほぼ同等の速度でアクセスが可能になります。

クイックスタート:1GBのファイル内検索を数秒で完了させる

サーバーをダウンさせることなく、特定の文字列を素早く検索する必要がある場合は、まずこのコードを試してみてください。その効果は一目録然です。

import mmap
import os

def quick_search(file_path, search_str):
    if not os.path.exists(file_path) or os.path.getsize(file_path) == 0:
        return
    
    with open(file_path, "r+b") as f:
        # ファイルを仮想メモリにマッピング
        with mmap.mmap(f.fileno(), length=0, access=mmap.ACCESS_READ) as mm:
            # 文字列を検索(bytesに変換する必要あり)
            pos = mm.find(search_str.encode())
            if pos != -1:
                print(f"見つかりました。位置: {pos}")
                mm.seek(pos)
                print(f"内容: {mm.read(100).decode(errors='ignore')}")
            else:
                print("見つかりませんでした!")

# 実際:1GBのログファイルからのエラー検索は通常0.1秒未満で終わります
# quick_search('huge_server_log.log', 'ERROR_001')

この方法はファイルをRAMにロードしないため、非常に高速に動作します。findread を呼び出した際、OSが必要なデータ部分だけをロードする(ページング)処理を自動で行ってくれます。

なぜmmapは従来の方法より優れているのか?

1. ゼロコピー(Zero-copy)メカニズム

通常、データはディスクからカーネルバッファを経由し、その後Pythonのユーザー空間へとコピーされます。このプロセスでは、各層間でのデータコピーにリソースを消費します。mmap を使えば、データは直接マッピングされます。まだRAM上にないデータ領域にアクセスしたとき、OSはそのページ(page)だけをロードします。これにより、冗長なデータコピーが排除されます。

2. OSのページキャッシュの活用

OSは、私たちが書くコードよりもはるかに賢くメモリを管理します。mmap を使用すると、OSは頻繁にアクセスされるファイルの部分を自動的にキャッシュに保持します。システムがメモリ不足になると、OSは手動の介入なしに古いページを自動的に解放します。

3. 柔軟なランダムアクセス(Random Access)

for line in file を使うと、最初から最後まで順番に読み込む必要があります。途中のセクションに戻りたい場合は、seek() を使ってディスクから読み直さなければなりません。mmap を使えば、ファイルを巨大な文字列(string)のように扱えます。mm[100:500] のようにスライスしたり、rfind() で逆方向に検索したりすることが、ほぼゼロの遅延で行えます。

応用テクニック:巨大データに対する正規表現(Regex)

mmap の最大の強みの一つは、re (Regular Expression) ライブラリとの親和性です。5GBのファイルに対しても、各行を一時変数に読み込むことなく、直接正規表現を実行できます。

import mmap
import re

def find_errors_with_regex(file_path):
    # HTTP 5xxエラーを検索するパターン
    pattern = re.compile(rb'HTTP/1.1" 5\d{2}')
    
    with open(file_path, "rb") as f:
        with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
            # re.finditerはmmapオブジェクト上で直接動作する
            for match in pattern.finditer(mm):
                start_line = mm.rfind(b'\n', 0, match.start()) + 1
                end_line = mm.find(b'\n', match.end())
                print(f"ログ行: {mm[start_line:end_line].decode()}")

私の実務経験では、mmapfinditer を組み合わせることで、500,000行のログ処理が通常の for ループよりも70%速くなりました。これは、日次の自動化スクリプトを実行する際に非常に大きな意味を持ちます。

重要な注意点(「血の教訓」)

強力な mmap ですが、アプリのクラッシュを避けるために覚えておくべき独自のルールがあります。

  • ファイルオープンモード: Windowsでは、別のプロセスが書き込み用に開いているファイルをマップすることはできません。常にモード(読み取りは rb、書き込みは r+b)を確認してください。
  • バイナリ形式: mmap は常にバイト(bytes)を扱います。検索キーワードは .encode() し、取得した結果は .decode() する必要があります。
  • 空のファイル: 0バイトのファイルをマップしようとすると、Pythonは ValueError をスローします。実行前に必ずファイルサイズを確認してください。
  • 32ビットの制限: 32ビット版のPythonでは、2GB〜4GBを超えるファイルをマップできません。64ビット版Pythonであれば、100GBのファイルをマップしてもスムーズに動作することを確認済みです。

まとめ

mmap を使用することは、単に速度を向上させるだけではありません。リソース枯渇による予期せぬシステムダウンからシステムを守ることにも繋がります。RAMの占有によってスクリプトがOSに「キル」されるのを待つのではなく、mmap を使ってよりプロフェッショナルにデータを管理しましょう。皆さんのコードの最適化が成功することを願っています!

Share: