スマートフォン・タブレットからインターネットサーバーオペレーション

APPW.jp
 
incremental read / logrotate

Apacheログを、追記された分だけ読む

5サイトぶんのアクセスログを集計したい。でも毎回18,000行を頭から読み直すのは無駄だし、 生ログをそのままDBに入れたら容量が破綻する。追記分だけを拾い、logrotate にも追従し、 攻撃者が送りつける壊れたリクエスト行にも落とされない——そんな収集器の実装記録です。

PHP logrotate inode MongoDB cron

[ § 0 ] 導入

毎回全部読むわけには、いかない

アクセスログの集計を自動化したくなりました。ただ、うちのサーバーはメモリ2GBです。 10分おきに cron を回すとして、毎回ログ全体を読み直すのは無駄が大きい。 かといって生ログを丸ごと MongoDB に入れるのは、容量的に論外です。

要件は2つに絞れました。追記された分だけを読むこと、そして 集計結果だけを保存すること。単純に聞こえますが、実際に組むと ローテーションと壊れた行という2つの落とし穴が待っていました。

[ § 1 ] 設計

何を保存し、何を捨てるか

生ログはサーバーに置いたままにします。DBに入れるのは1時間単位の集計だけ。 これなら1日あたり数KBで収まります。

保存するもの

項目内容
ステータス別件数2xx / 3xx / 403 / 404 / その他4xx / 5xx
404のパス上位30件。攻撃者が何を探しているかの目録になる
403/404の送信元サブネット単位の上位30件
IP別の指標成功率・攻撃パスの種類数など。遮断候補の判定に使う
設計の出どころ

「サーバーは生データではなく答えだけを返す」——これは集計APIの記事で立てた方針そのままです。ログ収集の側でも同じ考え方を使っています。

[ § 2 ] 増分読み

どこまで読んだかを、覚えておく

基本は単純です。読み終わった位置を保存し、次回はそこから読む。

apache-agg.php — 位置を覚えて再開する
$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)に、ファイルごとに持たせます。 集計とは寿命が違うので分けておくのが安全です——古い集計を消しても、読み取り位置は消してはいけません。 消すとログ全体の再パースが走ります。

1行ずつ読む理由。 file()file_get_contents() なら短く書けますが、 ログが数百MBに育ったときにメモリを持っていかれます。fgets のループなら、 ファイルがどれだけ大きくても消費メモリは1行ぶんです。
[ § 3 ] 落とし穴①

ローテーションで、位置は無意味になる

ここが最初の罠でした。logrotate が走ると、access.logaccess.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 より小さいことで気づけます。

apache-agg.php — ローテーションと切り詰めの検知
$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 を確認する」のが地味に効きます。 すでに次のローテーションが走っていて .1 が別物になっている可能性があるからです。 inode が一致しないなら、その残りは諦める——間違ったファイルを読んで二重集計するよりましです
[ § 4 ] 落とし穴②

攻撃者は、正しい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(' ') で分割すると、これらで簡単に壊れます。 リクエスト行はダブルクォートで囲まれていますが、中にエスケープされたクォートが入ることもある

apache-agg.php — 壊れた行に耐える正規表現
// "..." の中は「クォート以外」か「バックスラッシュ+任意の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 が増え始めたら、ログ形式が変わったか、想定外の行が来たということです。

[ § 5 ] 落とし穴③

vhost が無いと、全部「不明」になる

集計はできた。ところがサイト別に割ろうとしたら、全部が同じホストにまとまってしまいました。 標準の combined 形式には、どのバーチャルホストへの要求かが含まれていないためです。

apache2.conf — ログ形式に %v を足す
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 のような形で付くので、解析側で剥がして使います。

記録されるのは ServerName です。 www.iseeit.jp でアクセスされても、vhost の ServerName が iseeit.jp なら ログには iseeit.jp と入ります。一方 PHP 側のログには URL のホスト、つまり www.iseeit.jp が記録されている。同じサイトなのに2つの表記が混在します。 両方を並べて表示するなら、先頭の www. を落として揃える必要があります。
[ § 6 ] 運用

権限と、cron

ログは www-data には読めない

/var/log/apache2/ は通常 root:adm 640。 Webサーバーの実行ユーザーからは読めません。root の cron で回して、集計結果だけを書くのが素直です。 Webから見えるのは集計済みのデータだけになるので、権限の面でもこの形が安全です。

crontab(root)
# 10分ごとに増分を集計
*/10 * * * * /usr/bin/php /home/webm/bin/apache-agg.php >> /var/log/apache-agg.log 2>&1

インデックス

mongosh
use logging
db.HttpAgg.createIndex({ bucket: 1 })
db.ErrAgg.createIndex({ bucket: 1 })

バケットは "2026/07/23 21" のような文字列ですが、 辞書順がそのまま時系列順になるので、範囲検索にインデックスが効きます。

[ § 7 ] 体感デモ

offset だけの実装は、どこで壊れるか

同じログファイルの変化を、2つの実装に並行して食わせます。 左がoffset しか見ない実装、右がinode とサイズも見る実装。 「次へ」で1手ずつ進めてください。

Interactive · logrotate tracking
ローテーション追従シミュレータ
行数を単位にした簡略モデルです。実際のバイトオフセットでも起きることは同じです。
offset だけを見る保存した位置から読むだけ
保存 offset 0
今回読んだ 
累計 0
inode とサイズも見るローテーションと切り詰めを検知
保存 offset 0
今回読んだ 
累計 0
「次へ」を押すと、ログファイルに起きる出来事を1つずつ再現します。

ローテーションの手で、左は 0行 しか読めません。 旧ファイルに残っていた未読分も、新ファイルの行も、まとめて失われます。 さらに切り詰めのあとは保存 offset が実ファイルより大きいまま固定され、以後ずっと何も読めなくなります。 壊れていることに気づく手がかりすら残りません。

[ § 8 ] まとめ

収集器は、静かに壊れる

この手の収集器の怖さは、壊れても例外が飛ばないことです。 ローテーションで取りこぼしても、切り詰めで停止しても、cron は毎回正常終了します。 ログには「0行処理しました」と出るだけ。だから inode を見る、サイズを見る、 落ちた行を数える——壊れたことに気づける仕掛けを先に入れておく必要があります。

この記事のまとめ

  • 生ログはサーバーに置き、DBには時間帯単位の集計だけを入れる。
  • 読み取り位置は別コレクションに保存。集計を消しても位置は消さない
  • ローテーションは inode の変化で検知し、旧ファイルの残り → 新ファイルの順で読む。
  • 切り詰めは size < offset で検知。見落とすと以後ずっと読めなくなる。
  • 攻撃者は壊れたリクエストを送ってくる。パースできなかった行を数える
  • サイト別に割るには LogFormat%v が要る。記録されるのは ServerName

関連記事

『Apacheログを、追記された分だけ読む — 増分読みとローテーション追従』を公開しました。