Pythonで巨大なログを読むとメモリが足りなくなる原因と対処|read()・readlines()をやめて1行ずつ集計する方法(for line in f・ジェネレータ・gzip・文字化け・改行のない1行)

天色ななみが大きな寸胴鍋から出汁を一杯ずつ丼へ注ぎ、巨大なログを順番に処理する様子を表したイラスト。

こんにちは、おうどんです🍜

新人

ログの集計スクリプト、手元の小さいファイルでは一瞬だったのに、本番の巨大なログで動かしたらパソコンが固まりました

おうどん

最初に何してる?

新人

readlines() で全部読んでから、for で回してます。わかりやすいので

ななみ

わかりやすさと、鍋の大きさは別の話ですね

(会話は説明用の架空の場面です)

うどん屋でたとえます。
寸胴鍋いっぱいのお出汁があります。中に何杯ぶんのうどんが入っているか数えたい。

あなたなら、どうしますか。

A. 鍋の中身を、まず全部、丼に移してから数える
B. お玉で1杯ずつすくって、数えたら戻す

A を選んだ人、丼が足りません。

read() や readlines() でログを全部読むのは、まさに A なんです。しかも Python の丼は、中身より大きい。今回の検証では、55MB のログを readlines() すると、Python が使ったメモリーは 100MB を超えました。

今日の結論を先に書いておきます。

  • 巨大なファイルは for line in f: で1行ずつ読む。read()・readlines()・list(f) で全部読まない
  • 行を集めた list を作らず、数える・足すなど、その場で集計する
  • 文字コードを encoding= で指定する。壊れた行があっても止めたくないなら errors="replace" を付けて、壊れた行を数える
目次

read()とreadlines()は、鍋を丼に全部移す

read() はファイルの中身をまるごと1つの文字列に、readlines() は全部の行を1本ずつの文字列にして list に入れます。どちらも、ファイル全体がメモリーに乗ります。

Python の公式チュートリアルのファイルの読み書きの節には、read() でサイズを省略するとファイル全体を読んで返し、ファイルがマシンのメモリーの2倍の大きさでも、それはあなたの問題です、という趣旨のことがはっきり書いてあります。

読者「公式が、突き放してくる」

突き放しているようで、ちゃんと逃げ道も書いてあります。同じ節に、行を読むときはファイルオブジェクトをループで回せばよく、それはメモリー効率がよく、速く、コードも簡単になる、とあるんです。

実験:55MB・100万行のログを3通りで読んだ

説明用のアクセスログ(100万行、55.4MB)を作り、ステータスコードを数える処理を3通りで書きました。

# 1. read():全部を1つの文字列に
with open(path, encoding="utf-8") as f:
    text = f.read()
for line in text.splitlines():
    counts[line.split()[4]] += 1

# 2. readlines():全部の行を list に
with open(path, encoding="utf-8") as f:
    lines = f.readlines()
for line in lines:
    counts[line.split()[4]] += 1

# 3. for line in f:1行ずつ
with open(path, encoding="utf-8") as f:
    for line in f:
        counts[line.split()[4]] += 1

ログの中身はこんな形です(IP は説明用のアドレス)。

2026-10-25T00:00:00 198.51.100.197 GET /login 200 222ms
2026-10-25T00:00:00 198.51.100.163 GET /menu/tempura 200 881ms
2026-10-25T00:00:00 198.51.100.9 GET /menu/tanuki 200 437ms

3つの読み方を、それぞれ別のプロセスで1つずつ動かしました。tracemalloc(Python が確保したメモリーを追跡する標準ライブラリ)のピークと、Windows の API で取ったプロセス全体のピーク(ワーキングセット)を出しています。

read      {'200': 899878, '404': 60091, '500': 40031}  tracemalloc_peak=157.0MB  peak_working_set=274.5MB  8.31s
readlines {'200': 899878, '404': 60091, '500': 40031}  tracemalloc_peak=102.6MB  peak_working_set=220.3MB  10.31s
for       {'200': 899878, '404': 60091, '500': 40031}  tracemalloc_peak=0.0MB  peak_working_set=15.2MB  11.60s

(今回の検証環境:Python 3.13.1、Windows、64ビット。計測用のスクリプトの出力から空行を詰めたものです。秒数は tracemalloc を動かしたままの値なので、速さの比較には使えません)

答えの数は3つとも同じ。違うのはメモリーだけです。

読み方tracemalloc のピークプロセスのピーク
read()157.0MB274.5MB
readlines()102.6MB220.3MB
for line in f0.0MB(0.05MB 未満)15.2MB

(上の出力から作った表です)

55MB のファイルを読んだのに、readlines() は 100MB 超、read() は 150MB 超。
read() が一番重いのは、ファイル全体の文字列(約55MB)に加えて、splitlines() が全部の行の list をもう1つ作るからです。丼に移したうえで、小鉢にも全部取り分けている。

新人

うちの本番ログ、10GB あるんですけど

おうどん

丼、20GB くらい用意しようか

用意しないでください。
for line in f なら、ファイルが 55MB でも 10GB でも、持っているのはだいたい「いまの1行」とバッファだけです。

丼が中身より大きくなる理由

Python の文字列は、文字だけでなく、型や長さなどの情報も一緒に持つオブジェクトです。1行ずつ文字列にすると、その分が行数ぶん積み上がります。

import sys

line = "2026-10-25T00:00:00 198.51.100.197 GET /login 200 222ms\n"
print("ASCII の1行:", len(line), "文字 /", sys.getsizeof(line), "バイト")

jp = "2026-10-25 注文 きつねうどん 成功\n"
print("日本語まじりの1行:", len(jp), "文字 /", sys.getsizeof(jp), "バイト")

print("100万行ぶんの list の箱だけ:", sys.getsizeof([None] * 1_000_000), "バイト")
ASCII の1行: 56 文字 / 97 バイト
日本語まじりの1行: 24 文字 / 106 バイト
100万行ぶんの list の箱だけ: 8000056 バイト

56文字の行が、文字列オブジェクトとしては 97 バイト。日本語が混ざると、24文字でも 106 バイトになりました。
さらに list は、行ごとに文字列への参照を持つので、100万行なら箱だけで約 8MB。

ざっくり計算すると、100万行 × 約100バイト + list の 8MB で、105MB 前後(概算です)。readlines() の 102.6MB とほぼ合います。

読者「うどん1玉に、丼と箸と伝票がついてくる」

しかも1玉ごとに。お店なら親切ですが、メモリーの上ではただの重さです。

for line in f は、お玉で1杯ずつすくう

ファイルオブジェクト(open() が返すもの)は、それ自体が「次の1行をください」と言えば1行返してくれる、イテレータ(順番に1つずつ取り出せるもの)です。

io モジュールのドキュメントの readlines() の説明にも、readlines() を呼ばなくても for line in file: でファイルオブジェクトを回せる、と添えられています。

list を作らず、その場で数える

1行ずつ読んでいても、結果を list に貯めたら元の木阿弥です。

with open(path, encoding="utf-8") as f:
    if mode == "list":
        n = len([line for line in f if " 500 " in line])
    else:
        n = sum(1 for line in f if " 500 " in line)
list: 500の行=40031  peak=4.1MB
gen : 500の行=40031  peak=0.0MB

(実行したスクリプトから該当部分を抜き出しています。peak は tracemalloc のピーク)

[ ... ] のリスト内包表記は、条件に合った 40,031 行を list に貯めてから数えるので 4.1MB。
( ... ) のジェネレータ式(必要になったときに1つずつ値を作る式)を sum() に渡すと、貯めずに数えるので 0.0MB でした。

かっこの形が1つ違うだけで、丼を使うか、お玉で済むかが変わります。

読者「じゃあ全部丸かっこにすれば、全部軽くなる?」

print(( "こんにちは" )) は軽くも重くもなりません。丸かっこが効くのは、for を中に書いた式のときだけです。

おうどん

角かっこは丼、丸かっこはお玉。覚えた

新人

波かっこは何ですか

波かっこは辞書や集合です。お玉ではなく、伝票の束。これは次の章でたっぷり出てきます。

🔰 ここまで読めば今日から困らない

巨大なログで困ったら、まずこの3つです。

  1. read()・readlines()・list(f) をやめて、with open(path, encoding="utf-8") as f: → for line in f: で回す
  2. 行を list に貯めず、Counter で数える・合計を足す、などその場で集計する
  3. 集計の途中で list を作りたくなったら、[ ] を ( ) にできないか考える

最小の形はこれです。

from collections import Counter

counts = Counter()
with open("access.log", encoding="utf-8") as f:
    for line in f:
        counts[line.split()[4]] += 1
print(counts)
おうどん

つまり、丼をやめてお玉。3行で言える

新人

お玉って、うどん屋さんにありましたっけ

あります。お出汁用に。うどんは、すくわずに箸で上げますが、そこはたとえなので見逃してください。

ファイルの文字コードが UTF-8 でないなら、encoding= をその文字コードに合わせます(Windows の既定の文字コードの話はPythonでUnicodeDecodeError: ‘cp932’が出る原因と対処にまとめました)。

読者「これで 10GB も怖くない」

と言いたいところですが、お玉ですくっていても、鍋があふれることはあります。ここからが本番です。

ここから先は中級者向け:1行ずつ読んでもメモリーがあふれる3つの落とし穴

落とし穴1:数えるキーの種類が多すぎる

for line in f にしたのに遅い、重い。そんなときは、集計に使っている辞書や Counter を疑います。

import sys
import tracemalloc
from collections import Counter

path, key = sys.argv[1], sys.argv[2]
tracemalloc.start()
counts = Counter()
with open(path, encoding="utf-8") as f:
    for line in f:
        ts, ip, method, url, status, ms = line.split()
        if key == "status":
            counts[status] += 1
        elif key == "ip":
            counts[ip] += 1
        elif key == "ts_ip_url":
            counts[(ts, ip, url)] += 1
cur, peak = tracemalloc.get_traced_memory()
print(f"key={key:10} 種類={len(counts):>7}  peak={peak/1024/1024:.1f}MB")

同じ100万行のログで、キーを変えて3回動かしました(python t4_cardinality.py access.log status のように、引数でキーを選びます)。

key=status     種類=      3  peak=0.0MB
key=ip         種類=    508  peak=0.1MB
key=ts_ip_url  種類= 998358  peak=257.3MB

ステータス(3種類)や IP(508種類)なら、ほぼゼロ。
でも「時刻+IP+URL」の組み合わせをキーにすると、ほぼ1行に1種類になって 257.3MB。さっきの readlines()(102.6MB)より重くなりました。

新人

1行ずつ読んでるのに、丼より重い伝票の束ができてる

おうどん

1杯ごとに伝票を書いて、全部とっておいてるからね

メモリーを決めるのは、行数ではなく「覚えておくものの数」です。
キーの種類が行数に近いもの(リクエスト ID、時刻つきの組み合わせ、セッション ID など)を数えるなら、次のどれかを考えます。

  • 本当にそのキーで数える必要があるか。時刻を分や時間に丸めれば、種類は一気に減る
  • 上位 N 件だけ欲しいなら、全部を数えずに、後で出てくる heapq で上位だけを持つ
  • それでも全部要るなら、メモリーではなく SQLite などのデータベースや、ファイルに書き出して数える

落とし穴2:改行のない、1行だけの巨大ファイル

for line in f は、改行までを1行として返します。では、改行が1つもないファイルだったら?

JSON を1行に詰めたファイル(13.4MB、改行なし)で試しました。

for     : 行数=1 500の件数=71429  peak=25.6MB

(python t3_oneline.py oneline.json for の出力。peak は tracemalloc のピーク)

行数は 1。13.4MB の1行を丸ごと読んで、ピークは 25.6MB。お玉ですくったつもりが、鍋ごと持ち上げていました。

読者「お玉の柄が、鍋に溶接されてた」

こういうファイルは、行ではなく決まった大きさのかたまりで読みます。f.read(4096) のように、大きさを指定して読めば、一度に読むのはその分だけです。

ただし、ここにもう1つ罠があります。探している文字列が、かたまりの境目をまたぐことがあるんです。

pat = '"status":500'
hits = 0
tail = ""
with open(path, encoding="utf-8") as f:
    while chunk := f.read(4096):
        buf = tail + chunk
        hits += buf.count(pat)
        tail = buf[-(len(pat) - 1):]
naive   : 500の件数=71237  peak=0.0MB
overlap : 500の件数=71429  peak=0.0MB

(naive は hits += chunk.count(pat) だけで、前のかたまりの末尾を持ち越さない版。実行したスクリプトから該当部分を抜き出しています)

かたまりごとに数えるだけ(naive)だと 71,237 件。正しい 71,429 件より 192 件少ない。境目でちぎれた "status":500 を数えそこねました。
探す文字列の長さ − 1 文字だけ、前のかたまりの末尾を次に持ち越すと(overlap)、正しい数になります。

おうどん

お玉の縁に乗ったうどん、2杯に分かれて数えられない問題

新人

どっちのお玉にも、半分しか入ってないから

この持ち越しのやり方は、探す文字列が持ち越した部分の中で重なって数えられない場合(今回の "status":500 のように)に正しく数えられるものです。JSON の中身を本格的に読みたいなら、1行1レコードの形式(JSON Lines)で出力してもらうのが、いちばんの近道です。

落とし穴3:文字化けした1バイトで、98,905行目で止まる

10万行のログの 99,000 行目に、UTF-8 として正しくないバイト(0xff 0xfe)を混ぜました。普通に読むと、こうなります。

n = 0
try:
    with open(path, encoding="utf-8") as f:
        for line in f:
            n += 1
except UnicodeDecodeError as e:
    print("読めた行数:", n)
    print(type(e).__name__ + ":", e)
    print("e.start:", e.start, " 渡されたかたまりの長さ:", len(e.object))
読めた行数: 98905
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 5762: invalid start byte
e.start: 5762  渡されたかたまりの長さ: 8192

(実行したスクリプトから該当部分を抜き出しています。0xff はファイルの先頭から 6,026,882 バイト目にあります)

ポイントが2つあります。

1つ目。壊れているのは 99,000 行目なのに、例外が出たのは 98,905 行まで読んだところ。今回の結果を見ると、テキストモードの open() は、バイトをかたまりで文字に変換してから行に切っているようで、壊れた行より手前で止まることがあります。

2つ目。エラーの position 5762 は、ファイルの先頭からの位置ではありません。e.object(変換しようとしたバイト列)の長さは 8192 で、その中の 5762 番目、という意味でした。「5762 バイト目を見ればいい」と思ってファイルの先頭を探しても、そこには何もありません。

読者「エラーが指さした場所に行ったら、何もない」

住所が、町名なしの番地だけで届いたようなものです。

止めずに最後まで読みたいなら、errors="replace" を付けます。io のドキュメントでは、errors の既定(None)は "strict" と同じで、変換できないと ValueError(UnicodeDecodeError はその仲間)を出す、"replace" は壊れたところに置き換えの印を入れる、と説明されています。

n = bad = 0
with open(path, encoding="utf-8", errors="replace") as f:
    for line in f:
        n += 1
        if "�" in line:
            bad += 1
            print("置き換えた行:", n, ascii(line))
print("全行数:", n, " 置き換えが入った行:", bad)
置き換えた行: 99000 '2026-10-25T09:00:00 192.0.2.10 GET /menu/�� 200 12ms\n'
全行数: 100000  置き換えが入った行: 1

(ascii() で出しているので、置き換えの印 U+FFFD は � と表示されています)

今度は 10 万行を最後まで読み、99,000 行目の2バイトが置き換えの印になりました。
ドキュメントには "ignore" もありますが、データが黙って消えるので、集計では "replace" にして、置き換えが入った行を数えておくほうが安心です。数えておけば、「文字化けが1行あった」と報告できます。

中級者向け:gzip のログは、解凍せずにそのまま読む

ローテーション(古いログを別名にして圧縮すること)されたログは、.gz になっていることが多いですよね。
解凍してから読むと、ディスクにも同じ大きさのファイルが増えます。gzip.open() をテキストモード "rt" で開けば、普通のファイルと同じように1行ずつ読めます。

gzip モジュールのドキュメントでは、モードに "rt" を指定すると、GzipFile を io.TextIOWrapper で包んで、指定した文字コード・エラー処理・改行の扱いで読める、と説明されています(テキストモードは 3.3 から)。

with gzip.open(gz, "rt", encoding="utf-8") as f:
    for line in f:
        n += 1
行数=1000000  peak=0.3MB

(55.4MB のログを圧縮した 6.9MB の access.log.gz を読んだ結果。実行したスクリプトから該当部分を抜き出しています。peak は tracemalloc のピーク)

100万行を読んで、ピークは 0.3MB。圧縮したまま、お玉ですくえました。

新人

真空パックのうどんを、袋のまま茹でられる感じですね

おうどん

袋のまま、1本ずつ出てくる。便利

書き込み途中の .gz は、途中で EOFError

ローテーションの最中や、コピーの途中で切れた .gz を読むとどうなるか。圧縮ファイルの後ろ半分を切り落として試しました。

n = 0
try:
    with gzip.open(os.path.join(d, "broken.log.gz"), "rt", encoding="utf-8") as f:
        for line in f:
            n += 1
except Exception as e:
    print("読めた行数:", n)
    print(type(e).__name__ + ":", e)
読めた行数: 499955
EOFError: Compressed file ended before the end-of-stream marker was reached

前半の 499,955 行は読めて、最後に EOFError でした。
gzip のドキュメントでも、正しくない gzip ファイルに対しては BadGzipFile(3.8 から、OSError の仲間)のほかに、EOFError や zlib.error も出ることがある、とされています。

読者「袋が半分破れてたら、半分は茹でられた、と」

茹でられた半分を「今日の全部です」と出すと、お客さんは怒ります。

ここで怖いのは、例外を握りつぶすと、半分しか数えていない結果がそのまま報告になることです。集計のスクリプトでは、どのファイルを何行目まで読んで止まったかを出して、終了コードを失敗にしましょう(このあとの完成コードがそうなっています)。

ちなみに、cat a.gz b.gz > two.gz のように gzip をつないだファイルは、今回の検証では gzip.open() で両方の中身が最後まで読めました。

['line-a1', 'line-a2', 'line-b1']

(2つの gzip を続けて書いたファイルを gzip.open(..., "rt") で読み、各行の改行を取って list にした出力です)

中級者向け:そのまま使える完成コード

ここまでの対策をまとめた集計スクリプトです。

  • 普通のファイルも .gz も、1行ずつ読む
  • 文字化けは errors="replace" で置き換えて、行数を数える
  • 形の合わない行(区切りの数が違う、数字でない)は飛ばして数える
  • 遅いリクエストは、上位 N 件だけを heapq で持つ
  • 途中で切れたり壊れたりした .gz は、読めたところまで報告して終了コード 1 にする
"""巨大なアクセスログを1行ずつ集計する(.gz もそのまま読める)

使い方: python loganalyze.py access.log access.log.1.gz --top 5 --mem
ログの形式: 時刻 IP メソッド URL ステータス 応答時間ms(空白区切り)
"""
import argparse
import gzip
import heapq
import sys
import tracemalloc
import zlib
from collections import Counter


def open_log(path, encoding):
    # 壊れたバイトは U+FFFD に置き換えて読み進める(あとで数える)
    if path.endswith(".gz"):
        return gzip.open(path, "rt", encoding=encoding, errors="replace")
    return open(path, encoding=encoding, errors="replace")


def parse(lines, stats):
    """1行ずつ分解して返すジェネレータ。形の合わない行は数えて飛ばす"""
    for lineno, line in enumerate(lines, 1):
        stats["lines"] += 1
        if "�" in line:
            stats["bad_encoding"] += 1
        parts = line.split()
        if len(parts) != 6 or not parts[5].endswith("ms"):
            stats["broken"] += 1
            continue
        try:
            ms = int(parts[5][:-2])
        except ValueError:
            stats["broken"] += 1
            continue
        yield lineno, parts[3], parts[4], ms


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("paths", nargs="+")
    ap.add_argument("--encoding", default="utf-8")
    ap.add_argument("--top", type=int, default=5)
    ap.add_argument("--mem", action="store_true", help="tracemalloc のピークを表示する")
    args = ap.parse_args()

    if args.mem:
        tracemalloc.start()
    status = Counter()
    errors_by_url = Counter()   # 種類が少ないキー(URL)だけを数える
    slow = []                   # 遅い順の上位 N 件だけを持つヒープ
    failed = False

    for path in args.paths:
        stats = Counter()
        try:
            with open_log(path, args.encoding) as f:
                for lineno, url, st, ms in parse(f, stats):
                    status[st] += 1
                    if st.startswith("5"):
                        errors_by_url[url] += 1
                    item = (ms, path, lineno, url)
                    if len(slow) < args.top:
                        heapq.heappush(slow, item)
                    elif item > slow[0]:
                        heapq.heapreplace(slow, item)
        except (OSError, EOFError, zlib.error) as e:
            # 途中で切れたり壊れたりした .gz は、読めたところまでで報告して失敗扱いにする
            failed = True
            print(f"[NG] {path}: {stats['lines']} 行目まで読んで中断 ({type(e).__name__}: {e})")
        print(f"[{path}] 行数={stats['lines']} 形が不正={stats['broken']} 文字化け={stats['bad_encoding']}")

    print("ステータス:", dict(sorted(status.items())))
    print("5xx が多い URL:", errors_by_url.most_common(3))
    print(f"遅い上位 {args.top} 件:")
    for ms, path, lineno, url in sorted(slow, reverse=True):
        print(f"  {ms}ms  {url}  ({path}:{lineno})")
    if args.mem:
        print(f"tracemalloc ピーク: {tracemalloc.get_traced_memory()[1] / 1024 / 1024:.1f}MB")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())
新人

長いです

長いのは、壊れたログへの気づかいの分です。本体の集計は、for の中の数行だけ。

UnicodeDecodeError は errors="replace" で出なくなるので、except には入れていません。BadGzipFile は OSError の仲間なので OSError で受け、zlib.error は別に受けています。

動かした結果1:わざと壊した7行のログ

正常な行のほかに、CRLF(Windows の改行)の行、ローテーションの区切りの行、0xff を含む行、応答時間がない行、応答時間が数字でない行、最後に改行のない行を入れた sample.log です。

$ python loganalyze.py data/sample.log --top 3
[data/sample.log] 行数=7 形が不正=3 文字化け=1
ステータス: {'200': 3, '500': 1}
5xx が多い URL: [('/menu/kitsune', 1)]
遅い上位 3 件:
  905ms  /menu/tempura  (data/sample.log:7)
  812ms  /menu/kitsune  (data/sample.log:2)
  40ms  /menu/�  (data/sample.log:4)
exit=0

(exit= の行は、実行に使ったラッパーが終了コードを表示したものです)

形が不正な3行(区切りの行・応答時間なし・abcms)は飛ばされ、文字化けの行は置き換えの印 � つきで集計に残りました。改行のない最後の行(905ms)も、CRLF の行(812ms)も読めています。

文字化けした行を集計に入れるか外すかは、目的しだいです。外したいなら、parse() の中で bad_encoding を数えたあとに continue を足してください。

動かした結果2:100万行のログと、その .gz

$ python loganalyze.py data/access.log data/access.log.gz --top 3 --mem
[data/access.log] 行数=1000000 形が不正=0 文字化け=0
[data/access.log.gz] 行数=1000000 形が不正=0 文字化け=0
ステータス: {'200': 1799756, '404': 120182, '500': 80062}
5xx が多い URL: [('/menu/tempura', 11582), ('/login', 11488), ('/order', 11486)]
遅い上位 3 件:
  900ms  /menu/tanuki  (data/access.log.gz:999109)
  900ms  /order  (data/access.log.gz:998817)
  900ms  /menu/tempura  (data/access.log.gz:998664)
exit=0
tracemalloc ピーク: 0.3MB

(1つの実行の出力を、途中で2つのブロックに分けています)

200万行(同じ中身を2回)を読んで、ピークは 0.3MB。
遅い上位が全部 900ms で .gz のほうばかりなのは、同じ ms のときは (ms, path, lineno, url) のタプルの2番目以降で大小を比べるからです。"data/access.log.gz" のほうが文字列として大きいので残りました。同点の扱いを変えたいなら、タプルの並びを変えます。

おうどん

同点なら、あとから来たお客さんを優先する店

新人

並んでた人、怒りますよ

動かした結果3:途中で切れた .gz

$ python loganalyze.py data/broken.log.gz --top 1
[NG] data/broken.log.gz: 499955 行目まで読んで中断 (EOFError: Compressed file ended before the end-of-stream marker was reached)
[data/broken.log.gz] 行数=499955 形が不正=0 文字化け=0
ステータス: {'200': 449895, '404': 30139, '500': 19921}
5xx が多い URL: [('/menu/curry', 2912), ('/menu/tanuki', 2883), ('/login', 2857)]
遅い上位 1 件:
  900ms  /menu/tanuki  (data/broken.log.gz:498952)
exit=1

[NG] の行と、終了コード 1。半分しか読めなかったことが、ちゃんと外から見えます。
定期実行に組み込むなら、終了コードで失敗を検知する仕組みにつなげてください(終了コードの基本は【おうどんの基礎講座】終了コードってなに?へ)。

運用前のチェックリスト

  • read()・readlines()・list(f) を使っていない
  • 集計のキーの種類が、行数に近くならない(時刻つき・ID つきのキーを数えていない)
  • encoding= を指定している。errors="replace" を付けたなら、置き換えた行を数えて報告している
  • .gz の途中切れ・破損(EOFError・BadGzipFile・zlib.error)を握りつぶさず、終了コードに出している
  • 改行のない巨大な1行がありうるなら、行ではなく大きさを決めて読み、境目をまたぐ検索に備えている
  • 上位 N 件は、全部を並べ替えずに heapq で持っている

ここから先は上級者向け。読み飛ばしてもOKです:お玉の中身と、計り方の話

テキストモードは、8192 のかたまりで変換していた

落とし穴3の実験で、e.object の長さは 8192 でした。今回の検証環境(Python 3.13.1)では、テキストモードの open() は、バイトを 8192 のかたまりでデコーダーに渡していた、ということです。

io のドキュメントにある io.DEFAULT_BUFFER_SIZE は、バッファ付き I/O クラスの既定のバッファの大きさで、open() は可能ならファイルの blksize(os.stat() で得られる値)を使う、と説明されています。ただ、テキストの変換に使うかたまりの大きさがどう決まるかまでは、公式ドキュメントで確かめられなかったので、8192 は「今回の検証で観測した値」としておきます。版や環境で変わる前提で読んでください。

読者「お玉の大きさ、勝手に決まってた」

お玉の大きさは、お店(Python)の備品です。こちらで決められるのは、すくったものをどこに置くかだけ。

for で回している最中に tell() は使えない

進み具合を出そうとして、ループの中で f.tell()(いまの位置を返すメソッド)を呼ぶと、テキストモードではこうなります。

import sys

path = sys.argv[1]

with open(path, encoding="utf-8") as f:
    for i, line in enumerate(f, 1):
        if i == 3:
            try:
                print("テキストモードで tell():", f.tell())
            except OSError as e:
                print("テキストモードで tell():", type(e).__name__ + ":", e)
            break

with open(path, "rb") as f:
    offset = 0
    for i, raw in enumerate(f, 1):
        offset += len(raw)
        if i == 3:
            print("バイナリモードで tell():", f.tell(), " 自分で足した位置:", offset)
            break
テキストモードで tell(): OSError: telling position disabled by next() call
バイナリモードで tell(): 179  自分で足した位置: 179

テキストモードで for で回している最中の tell() は、OSError: telling position disabled by next() call で断られました。
バイナリモード "rb" なら、tell() も、自分で足した len(raw) も 179 で一致しました。

途中から再開できる集計(どこまで読んだかを記録しておく作り)にするなら、バイナリモードで読んで、行ごとに raw.decode("utf-8", errors="replace") で文字にし、バイトの位置を自分で数えるのが素直です。

おうどん

何杯目まですくったか、鍋に聞いたら怒られた

新人

すくってる最中は話しかけないで、ってことですね

tracemalloc とプロセスのピークは、別のものを計っている

最初の実験で、for line in f の tracemalloc のピークは 0.0MB、プロセスのピークは 15.2MB でした。

tracemalloc のドキュメントは、このモジュールを Python が確保したメモリーブロックを追跡するデバッグ用ツールと説明していて、tracemalloc を開始する前に確保されたブロックはスナップショットに入らない、とも書いています。
つまり、インタープリター自体の大きさや、トレースを始める前に読み込んだもの、計測のしくみそのものの分は、tracemalloc の数字には出てきません。プロセスのピークは OS から見た使用量なので、そういう分も含みます。

なので、使い分けはこうです。

  • どの処理が Python のオブジェクトを溜めているかを比べたい → tracemalloc
  • サーバーのメモリーに収まるかを見たい → OS から見たプロセスの使用量(Windows のタスクマネージャー、Linux なら ps の RSS など)

tracemalloc を動かしていると処理が遅くなる点にも注意です。最初の実験の秒数を速さの比較に使わなかったのは、このためです。

上位 N 件は、全部並べずにヒープで持つ

完成コードで、遅いリクエストの上位を heapq で持ったのは、全部の行を list にして sorted() すると、また丼に戻ってしまうからです。

heapq のドキュメントによると、ヒープは heap[0] が常にいちばん小さい要素です。heapreplace() はいちばん小さい要素を取り出して新しい要素を入れる操作で、heappop() と heappush() を続けるより効率がよく、大きさを固定したヒープに向いている、とされています。
上位 N 件のヒープなら、新しい行が heap[0](いまの N 位)より大きいときだけ入れ替える。持っているのは常に N 件です。

1回で済む集計なら heapq.nlargest(n, iterable) も使えます。ドキュメントでは sorted(iterable, key=key, reverse=True)[:n] と同じ結果で、n が小さいときに向いている、とされています。完成コードで nlargest を使わなかったのは、ステータスの集計や 5xx の集計と同じ1回のループの中で、上位も一緒に持ちたかったからです。nlargest にジェネレータを渡すと、そのジェネレータを最後まで読み切ってしまいます。

読者「1回すくったお玉の中身で、3つの集計を同時にする」

おうどん

1杯で、数を数えて、具を確かめて、熱さを測る

熱さは測らなくていいです。

それでも本当にメモリーが足りなくなると

メモリーが足りなくなったときに Python が出す例外が MemoryError です。組み込み例外のドキュメントでは、操作でメモリーが足りなくなったが、オブジェクトを消せばまだ回復できるかもしれない状況で出る、と説明されています。同時に、C の malloc() の仕組みの都合で、インタープリターが完全には回復できないこともある、とも書かれています。

今回の検証では、MemoryError が出るまでメモリーを使い切る実験はしていません。実際のサーバーでは、例外になる前にスワップ(メモリーの代わりにディスクを使うこと)で極端に遅くなったり、OS が別の手段でプロセスを止めたりすることもあります(OS と設定しだいです)。
MemoryError を except で受けて逃げ切ろうとするより、最初から鍋をお玉ですくう書き方にするほうが確実です。

冒頭の新人さんに、もう一度

冒頭では、readlines() で全部読んでパソコンが固まっていました。
今なら、こう直せます。

新人

readlines() をやめて for line in f にしました。Counter のキーは時刻つきじゃなくて URL にして、gz もそのまま読めます

ななみ

途中で切れた gz を失敗として出すところまで入っていますね。見事です

おうどん

おうどんも、昔は鍋ごと丼に移す派でした

新人

今は?

今は、丼を洗うのがめんどうなので、お玉派です。動機はともかく、結果は正しい。

巨大なファイルは、全部を持たずに、すくっては数えて、覚えておくものを小さくする。
ファイルの大きさではなく、覚えておくものの数がメモリーを決めます。

まずは手元の集計スクリプトで、readlines() を1か所だけ for line in f に変えてみてください🍜

参考資料

Python のドキュメントをもとにした、2026年10月時点の内容です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

おうどん|癒しと創作をたのしむ雑食クリエイター
写経アプリやクレイセラピー、優しい和風デザインがすき。
ZARDと刀剣と文字に癒されて、今はアプリ作ってます。

コメント

コメントする

目次