夜間に走らせたエージェントが朝になって「失敗1件」とだけ伝えてくる。ところが肝心の「どこで、なぜ落ちたか」は、もうどこにも残っていません。私自身、個人開発で Dolice Labs の4つのブログを毎日オフピークに分散して自動更新していますが、最初に困ったのは失敗の多さではなく、失敗の理由を後から再現できないことでした。
無人で動くエージェントは、誰も見ていない時間帯に動きます。そのため、実行ログは「あとで読む人」のために書く以外に存在意義がありません。ところがログを素直に全部残すと、数週間でディスクが満杯になり、今度はログを書く処理そのものが落ちます。ここでは、ログを溢れさせずに、しかし落ちた理由は確実に追える状態を保つための設計をまとめます。
朝になって「なぜ落ちたのか」が分からない
スケジュール実行の難しさは、エラーが起きた瞬間に立ち会えないことに尽きます。手元で動かしているなら、ターミナルに流れる出力をその場で読めます。けれども深夜2時に動くジョブの出力は、保存していなければ消えていきます。
私の場合、最初は標準出力をそのままファイルへ追記していました。これは数日は機能します。問題は、4サイト分のジョブが毎日それぞれ複数回走るため、生ログが日に数十メガバイトずつ積み上がっていく点でした。週末に確認すると、ランナーのディスクが残り数百メガバイトまで減っていて、新しいジョブが書き込みに失敗していました。
つまり、無人運用のログには相反する二つの要求があります。後から追えるだけの情報量を残すことと、ディスクを一定に保つこと。この二つを同時に満たすには、ログを「いつ消すか」を最初から設計に織り込む必要があります。
ログを「全部残す」と「すぐ消す」の両方が失敗する
両極端はどちらもうまくいきません。
全部残す方式は、容量が単調増加するため、いつか必ずランナーが容量不足で停止します。しかも肥大化したログは検索も遅く、結局読まれません。一方ですぐ消す方式、たとえば「最新の実行結果だけ残す」運用は、失敗を見つけたときには原因ログがもう上書きされています。再現できない失敗は、直しようがありません。
ここで効くのは、ログを一律に扱うのをやめることです。直近の実行は全文を、少し前の実行は要約だけを、十分に古い実行は捨てる。情報の鮮度に応じて残す粒度を変えると、容量を抑えながら「最近落ちた理由」は確実に手元に残せます。これが3層保持の考え方です。
ホット・ウォーム・コールドの3層で保持する
私が落ち着いた構成は、次の3層です。
- ホット層(直近7日・全文): 標準出力・例外スタック・入力サマリを含む完全なレコードを残します。直近の失敗はここから原因を追います。
- ウォーム層(8〜30日・要約のみ): 全文は捨て、1実行を1行の要約レコードに圧縮します。成否・所要時間・処理件数・終了コード・先頭のエラー1行だけを残します。
- コールド層(31日以降): 原則として削除します。傾向分析が必要なら、日次の集計値だけを別の小さなファイルへ退避します。
実数で言えば、私の4サイト運用では生ログが1日あたり合計で30〜50メガバイト出ます。これを7日でホットに留めると、ホット層は常時およそ350メガバイト前後で頭打ちになります。ウォーム要約は1実行あたり200バイト程度なので、30日分でも数メガバイトに収まります。結果として、ログ全体の占有量はおおむね400メガバイト前後で安定し、際限なく増えることがなくなりました。
層の境界は日数ではなく「実行回数」で切る方法もありますが、私は日数で切るほうを推奨します。障害が起きた日付から逆算して探せるため、運用中に直感が働きやすいからです。
失敗ログだけは保持期間を無視して残す
3層保持には、ひとつだけ必ず加えるべき例外があります。失敗したレコードは、保持期間を過ぎてもホット層から落とさないことです。
理由は単純で、たまにしか起きない失敗ほど価値が高いからです。月に一度だけ特定の条件で落ちるジョブは、7日の保持では次に再発する前に証拠が消えてしまいます。原因が分からないまま再発を待つことになり、これは無人運用でいちばん避けたい状況です。
実装としては、圧縮や削除の対象を選ぶ時点で「成功かつ保持期間超過」のレコードだけを落とし、失敗レコードは別枠で長く残します。私は失敗レコードを最大90日まで全文で保持し、それでも増え続ける場合に限って古い順に間引くようにしています。失敗が90日で数十メガバイトを超えることはまずないため、容量への影響はほとんどありません。
// 削除候補の選別: 成功かつ保持期間超過のみを対象にする
type RunRecord = {
schema: number; // スキーマ版(後述)
runId: string;
site: string;
startedAt: string; // ISO8601
ok: boolean;
exitCode: number;
durationMs: number;
itemCount: number;
errorHead?: string; // 失敗時のみ: エラー先頭1行
};
const DAY = 24 * 60 * 60 * 1000;
function isEvictable(r: RunRecord, now: number): boolean {
const ageDays = (now - Date.parse(r.startedAt)) / DAY;
if (!r.ok) {
// 失敗は90日まで保護。年齢だけでは消さない
return ageDays > 90;
}
// 成功は7日でホットから外す対象になる
return ageDays > 7;
}
このひとつのルールがあるだけで、「再発したのに前回のログがない」という事態をかなり防げます。
ログレコードにスキーマ版を持たせる
長期運用でつまずきやすい落とし穴が、ログ形式の変更です。運用を続けると、必ず「あの項目も記録しておけばよかった」という場面が来ます。フィールドを足したり名前を変えたりした瞬間、過去のログを読む集計スクリプトが古い行で例外を投げて止まります。
これを避けるため、各レコードの先頭に schema という整数の版番号を持たせます。読む側は版番号を見て分岐し、知らない版が来ても落とさず、既知の範囲だけ解釈します。
// 読み取り側: 未知のスキーマ版でも壊れず、既知の範囲だけ解釈する
function parseRecord(line: string): RunRecord | null {
let raw: any;
try {
raw = JSON.parse(line);
} catch {
return null; // 壊れた行は黙って飛ばす(部分書き込みの残骸など)
}
const schema = typeof raw.schema === "number" ? raw.schema : 1;
if (schema > 2) {
// 未来のフィールドは無視し、共通項目だけ取り出す
return {
schema, runId: raw.runId, site: raw.site,
startedAt: raw.startedAt, ok: !!raw.ok,
exitCode: raw.exitCode ?? -1,
durationMs: raw.durationMs ?? 0,
itemCount: raw.itemCount ?? 0,
errorHead: raw.errorHead,
};
}
// v1 は itemCount を持たなかった → 既定値で補う
return { itemCount: 0, ...raw, schema };
}
版番号はコストがほぼゼロで、効果が後から効いてきます。スキーマを変えるときに過去ログを一括変換する手間を、丸ごと省けるからです。
圧縮ジョブをひとつ用意する
3層保持は、放っておいても起きません。ホットからウォームへ落とし、ウォームを期限で消す処理を、独立した小さなジョブとして1日1回走らせます。本処理のエージェントにこの後始末を兼ねさせないのが要点です。後始末を本処理に埋め込むと、本処理が落ちた日にログ整理も止まり、いちばんログが要る日にいちばん溜まります。
#!/usr/bin/env bash
# rotate-logs.sh — 1日1回、本処理とは別に走らせる
set -euo pipefail
LOG_DIR="${HOME}/agent-logs"
HOT="${LOG_DIR}/hot.jsonl"
WARM="${LOG_DIR}/warm.jsonl"
TMP="$(mktemp "${LOG_DIR}/rotate.XXXXXX")"
node "${LOG_DIR}/compact.mjs" "$HOT" "$WARM" "$TMP"
# アトミックに差し替える(途中で落ちても元ファイルは無傷)
mv "$TMP" "$HOT"
# ディスク残量を確認し、逼迫していれば追加で間引く
FREE_MB=$(df "$LOG_DIR" --output=avail -m | tail -1 | tr -d ' ')
if [ "${FREE_MB:-0}" -lt 300 ]; then
echo "low disk: ${FREE_MB}MB — running emergency eviction"
node "${LOG_DIR}/evict.mjs" "$WARM"
fi
一時ファイルに書いてから mv で置き換えるのは、圧縮の途中でジョブが落ちても元のホットログを壊さないためです。書き込み中のファイルを直接削っていくと、その最中に中断したときログそのものを失います。後始末のジョブで本体を壊しては本末転倒ですので、ここはアトミックな差し替えを推奨します。
ディスクが逼迫したときの優先削除順
容量がどうしても足りなくなる日は来ます。そのとき何から消すかを、事前に順番として決めておくと、本番運用で慌てて大事なログを消す事故を回避できます。私が採っている優先順は次のとおりです。
- 成功レコードの全文(ホット層)。要約はウォームに残るため、全文を先に捨てます。
- ウォーム層の古い側から、要約レコードを日付の古い順に間引きます。
- それでも足りなければ、失敗レコードのうち90日を超えた最古のものから落とします。
この順番の背骨は「成功の詳細は最初に捨て、失敗の証拠は最後まで守る」という一点です。無人運用で本当に読み返すログは、成功の記録ではなく失敗の記録だからです。逆の順で消してしまうと、ディスクは空いても肝心のときに手がかりが残りません。
なお、削除はファイルを切り詰めるのではなく、新しいファイルへ必要な行だけ書き出して差し替える方式を選びます。理由は圧縮ジョブと同じで、途中で中断しても元のログが残るためです。
運用に組み込む小さな確認
最後に、この仕組みを「書いて終わり」にしないための、毎朝の短い確認を添えます。ウォーム層は1実行1行の要約なので、前夜の成否はそこを見るだけで把握できます。
# 前夜分の実行を成否つきで一覧する(30秒で読める)
node -e '
const fs=require("fs");
const lines=fs.readFileSync(process.env.HOME+"/agent-logs/warm.jsonl","utf8").trim().split("\n");
const since=Date.now()-24*60*60*1000;
for(const l of lines){const r=JSON.parse(l);
if(Date.parse(r.startedAt)<since) continue;
console.log(`${r.ok?"OK ":"NG "} ${r.site} ${r.itemCount}件 ${r.errorHead??""}`);}
'
この一覧を毎朝眺める習慣ができると、失敗が「気づかれないまま消える」ことがほぼなくなります。ログ設計の目的は、容量を節約することではなく、落ちた理由に最短でたどり着けることです。私自身、この3層保持に切り替えてから、原因調査に費やす時間が体感でおよそ50%減りました。
無人運用のログは、未来の自分への引き継ぎ書です。全文をいつまでも抱えるのでもなく、最新だけを残して過去を切り捨てるのでもなく、鮮度に応じて粒度を変えながら、失敗の証拠だけは長く守る。この線引きを最初に決めておくと、ディスクと安心を両立できます。同じように複数のジョブを無人で回している方の参考になれば幸いです。