貯め始めると、いつか止まる
Apacheログを追記分だけ集計する仕組みを作り、 PHPログと合わせて MongoDB に貯めています。生ログは置かず集計だけなので、1日あたりは大した量ではありません。
ただ、止まらずに増え続けることに変わりはない。 2GB のサーバーで、いずれ限界が来ます。
そこで削除の仕組みを作ろうとして、手が止まりました。 どのコレクションを、どこまで消してよいのか。 容量の大きいものから消せばいい、という話ではありませんでした。
3種類のデータ、3つの寿命
| コレクション | 中身 | 大きさ | 消せるか |
|---|---|---|---|
| Conns | PHPログの生レコード | 大 | 消せる |
| HttpAgg | Apacheの時間帯集計 | 中 | 消せる |
| ErrAgg | error.log の時間帯集計 | 小 | 消せる |
| HttpState | ファイルの読み取り位置 | 数レコード | 絶対に消せない |
前の3つは、古いほど価値が下がります。 30日前の「何時に404が何件だったか」を見ることは、まずありません。
問題は4つ目です。HttpState は各ログファイルをどこまで読んだかを保存しています。
レコードは数件。容量としては無視できる大きさです。
消すと、何が起きるか
読み取り位置を消した場合
HttpState が空なら、収集器は「まだ何も読んでいない」と判断します。
offset = 0 から読み直し、すでに集計済みの行をもう一度処理します。
バケットは $inc で加算するので、その期間の数字が倍になる。
集計を消した場合
容量は減りますが、ベースラインが短くなります。 変化検知は直近と過去を比べる仕組みなので、 過去が無ければ比較そのものが成立しません。
そして、戻せない
いちばん怖いのはこれです。削除は取り消せません。 バックアップを取っていなければ、その期間は永久に見えなくなる。 「あとで見るかもしれない」を考え始めると何も消せなくなりますが、 消したあとで欲しくなっても遅いのは事実です。
何日残すかを、どう決めるか
容量から決めたくなります。「まだ余裕があるから90日」というように。 これはやめました。いつか必ず破綻するからです。 余裕がなくなった時点で、慌てて短くすることになる。
代わりに使う場面から逆算しました。
| 使う場面 | 必要な期間 |
|---|---|
| 変化検知のベースライン | 7日 |
| 時系列グラフ | 最長30日 |
| 月次の振り返り | 30日 |
| それ以上前を見る場面 | 思いつかなかった |
結果、30日に決めました。月次で傾向を振り返れる最小単位です。
削除スクリプトに入れた4つ
① 既定は、確認だけ
# 引数なし → 何件消えるかを表示するだけ php log-purge.php # --run を付けて初めて削除される php log-purge.php --run
消す操作を既定にしない。これが一番効きます。
手で叩けば自然と確認になり、--run を打つのは意識的な行為になる。
cron には --run を書きますが、それは一度書いたら見直す機会が少ないので、
手動実行のほうを安全側に倒しておくのが合理的だと考えました。
② 最小日数のガード
const MIN_DAYS = 3; if ($days < MIN_DAYS) { fwrite(STDERR, "[ERR] 保持日数が短すぎます..."); exit(1); }
--days=0 や --days=1 を弾きます。
ゼロを打ち込めば全部消える——そういう入口を塞いでおく。
③ 1回あたりの上限
const BATCH_LIMIT = 200000; if ($old > BATCH_LIMIT) { echo "削除対象 {$old} 件は上限を超えています。\n" . "複数回に分けて実行してください。\n"; continue; }
初回実行では、数百万件が対象になることがあります。 それを一度に消すとDBが応答しなくなる。 2GB のサーバーでは現実的な危険です。上限を超えたら止めて、分割を促します。
④ 消すものを、列挙する
const TARGETS = [ ['Conns', 'created_at'], ['HttpAgg', 'bucket'], ['ErrAgg', 'bucket'], ]; # HttpState は含めない
文字列の日時で、範囲を切る
日時は文字列で持っています。"2026/08/09 14:23:01"、
バケットは "2026/08/09 14"。
収集の回で決めた形式です。
文字列で範囲比較ができるのは、ゼロ埋めの固定長だからです。
"2026/07/09 23" < "2026/07/10 00" < "2026/08/09 14" ↑ 文字列として比較しても、時系列として正しい # だから $lt で範囲削除できる { bucket: { $lt: "2026/07/10 00" } }
"2026/8/9" のようにゼロ埋めしないと、
"2026/8/9" > "2026/12/1" という比較になってしまう。
文字列で日時を持つなら、ゼロ埋めは必須条件です。
なお集計側は bucket、生ログ側は created_at と
フィールド名も形式も違うので、対象ごとに指定できるようにしています(§4の④の表)。
ダッシュボードにも載せる
cron に任せきりだと、いつ何が消えたのか分からなくなります。 保守タブを作り、コレクションごとの状況を見えるようにしました。
| 表示項目 | 意味 |
|---|---|
| 総数 | いま何件あるか |
| 削除対象 | 基準日時より古いもの |
| 最古 / 最新 | 実際に持っている期間 |
| 基準日時 | この時刻より前が消える |
二段階の確認
押した瞬間には消えません。件数を見せてから、もう一度確認を求めます。 「3,442件を削除します。よろしいですか」と数字が出れば、 想定と違うときに気づけます。
GETでは消せない
# 件数の表示 → GET GET /log-api.php?tab=retention&days=30 # 実際の削除 → POST のみ POST /log-api.php action=purge&days=30
GETで削除できると、URLを踏んだだけで消えます。 ブラウザの先読み、リンクのプリフェッチ、クローラ—— 意図しない実行の経路はいくらでもあります。
保持日数を、動かしてみる
スライダーを動かすと、何がどれだけ消えるかと 何が失われるかが同時に見えます。
短くするほど容量は減りますが、失われる機能も増えます。 7日を切るとベースラインが取れなくなり、3日未満では安全装置が作動して実行できません。 容量と機能のどちらを取るかではなく、使う場面から決める—— §3で書いたとおりです。
間違えたとき、どちらへ倒れるか
削除の仕組みを作って分かったのは、設計の良し悪しは失敗したときに出るということでした。
既定を確認にすれば、うっかり実行しても何も起きません。 対象を列挙すれば、追加を忘れても消えないだけで済みます。 間違えたときに、安全な側へ倒れるか—— 正しく動くことより、そちらを先に考えるべき仕組みでした。
この記事のまとめ
- 消すデータより、消してはいけないデータを先に決める。
- 大きさではなく役割で判断する。読み取り位置は数レコードだが最重要。
- 保持期間は容量ではなく使う場面から逆算。うちは30日になった。
- 安全装置は4つ:既定は確認・最小日数・件数上限・対象の列挙。
- 「これ以外を消す」ではなく「これを消す」。追加を忘れても消えないほうへ倒す。
- 文字列の日時で範囲を切れるのはゼロ埋め固定長だから。桁が揃わないと破綻する。
- GETで削除できるようにしない。URLを踏んだだけで消える経路を作らない。