毎回全部読むわけには、いかない
アクセスログの集計を自動化したくなりました。ただ、うちのサーバーはメモリ2GBです。 10分おきに cron を回すとして、毎回ログ全体を読み直すのは無駄が大きい。 かといって生ログを丸ごと MongoDB に入れるのは、容量的に論外です。
要件は2つに絞れました。追記された分だけを読むこと、そして 集計結果だけを保存すること。単純に聞こえますが、実際に組むと ローテーションと壊れた行という2つの落とし穴が待っていました。
何を保存し、何を捨てるか
生ログはサーバーに置いたままにします。DBに入れるのは1時間単位の集計だけ。 これなら1日あたり数KBで収まります。
保存するもの
| 項目 | 内容 |
|---|---|
| ステータス別件数 | 2xx / 3xx / 403 / 404 / その他4xx / 5xx |
| 404のパス | 上位30件。攻撃者が何を探しているかの目録になる |
| 403/404の送信元 | サブネット単位の上位30件 |
| IP別の指標 | 成功率・攻撃パスの種類数など。遮断候補の判定に使う |
「サーバーは生データではなく答えだけを返す」——これは集計APIの記事で立てた方針そのままです。ログ収集の側でも同じ考え方を使っています。
どこまで読んだかを、覚えておく
基本は単純です。読み終わった位置を保存し、次回はそこから読む。
$fh = fopen($path, 'r'); fseek($fh, $savedOffset); // 前回の続きから while (($line = fgets($fh)) !== false) { // 1行ずつ処理する。ファイル全体をメモリに載せない $lines[] = $line; if (count($lines) >= MAX_LINES) break; // 暴走防止 } $newOffset = ftell($fh); // 今回どこまで読んだか fclose($fh);
位置は集計データとは別のコレクション(HttpState)に、ファイルごとに持たせます。
集計とは寿命が違うので分けておくのが安全です——古い集計を消しても、読み取り位置は消してはいけません。
消すとログ全体の再パースが走ります。
file() や file_get_contents() なら短く書けますが、
ログが数百MBに育ったときにメモリを持っていかれます。fgets のループなら、
ファイルがどれだけ大きくても消費メモリは1行ぶんです。
ローテーションで、位置は無意味になる
ここが最初の罠でした。logrotate が走ると、access.log は
access.log.1 に移され、新しい access.log が0バイトから始まります。
このとき、保存してある offset はまったく無意味な数字になります。
新しいファイルは小さいので、その位置は EOF のはるか先。
fseek しても何も読めません。
inode を見る
ファイル名が同じでも、ローテーション後は別のファイルです。
これを見分けるのが inode 番号。stat() で取れます。
$ stat -c '%i %s' access.log 1310745 2847213 ← inode と サイズ # logrotate 後 $ stat -c '%i %s' access.log 1310802 4096 ← inode が変わっている
取りこぼさない順序
inode の変化を見つけたら、いきなり新しいファイルを読んではいけません。 旧ファイルにまだ読んでいない行が残っている可能性があります。 順序はこうです。
① 旧ファイル(.1)を保存位置から末尾まで読む → ② 新ファイルを先頭から読む
切り詰めも拾う
> access.log のように中身だけ消された場合、inode は変わりません。
このときは サイズが保存 offset より小さいことで気づけます。
$st = stat($path); $inode = (int)$st['ino']; $size = (int)$st['size']; $rotated = ($savedInode !== 0 && $inode !== $savedInode); $truncated = (!$rotated && $size < $savedOffset); if ($rotated) { // 旧ファイルの残りを先に拾う(inode が一致することを確認してから) $old = $path . '.1'; if (is_readable($old)) { $ost = stat($old); if ($ost && (int)$ost['ino'] === $savedInode) { $fh = fopen($old, 'r'); fseek($fh, $savedOffset); while (($l = fgets($fh)) !== false) { $lines[] = $l; } fclose($fh); } } $savedOffset = 0; // 新ファイルは先頭から } elseif ($truncated) { $savedOffset = 0; }
.1 が別物になっている可能性があるからです。
inode が一致しないなら、その残りは諦める——間違ったファイルを読んで二重集計するよりましです。
攻撃者は、正しいHTTPを送ってこない
解析器を書くとき、つい「Apacheのログは整形されている」と思い込みます。実際には違いました。
# TLSハンドシェイクを平文ポートに投げてきた 192.0.2.77 - - [23/Jul/2026:15:02:10 +0900] "\x16\x03\x01" 400 0 "-" "-" # リクエスト行が空 192.0.2.79 - - [23/Jul/2026:15:05:00 +0900] "-" 408 0 "-" "-" # パスに空白が混ざっている 192.0.2.78 - - [23/Jul/2026:15:03:00 +0900] "GET /path with space HTTP/1.1" 404 209 "-" "-" # バイト数が "-" 192.0.2.81 - - [23/Jul/2026:15:07:00 +0900] "GET / HTTP/1.1" 200 -
素朴に explode(' ') で分割すると、これらで簡単に壊れます。
リクエスト行はダブルクォートで囲まれていますが、中にエスケープされたクォートが入ることもある。
// "..." の中は「クォート以外」か「バックスラッシュ+任意の1文字」の繰り返し const LINE_RE = '/^(\S+) \S+ \S+ \[([^\]]+)\] "((?:[^"\\\\]|\\\\.)*)" (\d{3}) (\S+)' . '(?: "((?:[^"\\\\]|\\\\.)*)" "((?:[^"\\\\]|\\\\.)*)")?/'; // リクエスト行の分解も、壊れていることを前提に書く $path = '-'; $parts = explode(' ', $req); if (count($parts) >= 3) { // メソッドとプロトコルを除いた中間をパスとみなす(空白入りに対応) $path = implode(' ', array_slice($parts, 1, count($parts) - 2)); } elseif (count($parts) === 2) { $path = $parts[1]; } elseif ($req !== '') { $path = $req; // 壊れた行はそのまま記録 }
落ちた行を、数える
いちばん大事なのはここかもしれません。パースできなかった行を捨てずに数える。 黙って捨てると、解析器が壊れていることに永久に気づけません。
$ sudo php apache-agg.php 2026/07/23 21:52:04 lines=18273 unparsed=0 buckets=22 143ms
18,273行を143ミリ秒で処理し、解析できなかった行はゼロ。
この unparsed が増え始めたら、ログ形式が変わったか、想定外の行が来たということです。
vhost が無いと、全部「不明」になる
集計はできた。ところがサイト別に割ろうとしたら、全部が同じホストにまとまってしまいました。
標準の combined 形式には、どのバーチャルホストへの要求かが含まれていないためです。
LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined # 各 vhost の CustomLog をこの形式に CustomLog ${APACHE_LOG_DIR}/access.log vhost_combined
これで行頭に iseeit.jp:443 のような形で付くので、解析側で剥がして使います。
www.iseeit.jp でアクセスされても、vhost の ServerName が iseeit.jp なら
ログには iseeit.jp と入ります。一方 PHP 側のログには URL のホスト、つまり
www.iseeit.jp が記録されている。同じサイトなのに2つの表記が混在します。
両方を並べて表示するなら、先頭の www. を落として揃える必要があります。
権限と、cron
ログは www-data には読めない
/var/log/apache2/ は通常 root:adm 640。
Webサーバーの実行ユーザーからは読めません。root の cron で回して、集計結果だけを書くのが素直です。
Webから見えるのは集計済みのデータだけになるので、権限の面でもこの形が安全です。
# 10分ごとに増分を集計
*/10 * * * * /usr/bin/php /home/webm/bin/apache-agg.php >> /var/log/apache-agg.log 2>&1
インデックス
use logging db.HttpAgg.createIndex({ bucket: 1 }) db.ErrAgg.createIndex({ bucket: 1 })
バケットは "2026/07/23 21" のような文字列ですが、
辞書順がそのまま時系列順になるので、範囲検索にインデックスが効きます。
offset だけの実装は、どこで壊れるか
同じログファイルの変化を、2つの実装に並行して食わせます。 左がoffset しか見ない実装、右がinode とサイズも見る実装。 「次へ」で1手ずつ進めてください。
ローテーションの手で、左は 0行 しか読めません。 旧ファイルに残っていた未読分も、新ファイルの行も、まとめて失われます。 さらに切り詰めのあとは保存 offset が実ファイルより大きいまま固定され、以後ずっと何も読めなくなります。 壊れていることに気づく手がかりすら残りません。
収集器は、静かに壊れる
この手の収集器の怖さは、壊れても例外が飛ばないことです。 ローテーションで取りこぼしても、切り詰めで停止しても、cron は毎回正常終了します。 ログには「0行処理しました」と出るだけ。だから inode を見る、サイズを見る、 落ちた行を数える——壊れたことに気づける仕掛けを先に入れておく必要があります。
この記事のまとめ
- 生ログはサーバーに置き、DBには時間帯単位の集計だけを入れる。
- 読み取り位置は別コレクションに保存。集計を消しても位置は消さない。
- ローテーションは inode の変化で検知し、旧ファイルの残り → 新ファイルの順で読む。
- 切り詰めは size < offset で検知。見落とすと以後ずっと読めなくなる。
- 攻撃者は壊れたリクエストを送ってくる。パースできなかった行を数える。
- サイト別に割るには
LogFormatに%vが要る。記録されるのは ServerName。
集めたApacheログで何が見えるようになったか。PHPが書くログには 403 も 404 も残りません。 アプリ側のログだけを見ていたとき、自分が何を見落としていたのか——その構造の話です。