スケジュール起動のエージェントが目を覚ました朝、最初に返ってきたのは成功でも失敗でもなく、could not create work tree dir: Permission denied という一行でした。
昨日まで書けていたはずの /tmp/repos に、今日は書けない。
原因はすぐに分かりました。エージェントが動く計算環境は毎回作り直される使い捨てのもので、ディレクトリの所有者がたまたま別ユーザーに割り当たっていたのです。私自身、個人開発で4つのサイトの記事生成を無人で回していますが、この種の「環境が毎回ちがう」前提を甘く見ると、夜間のタスクは静かにゼロ本で終わります。
無人エージェントの信頼性は、賢い本処理ではなく、最初の30秒 で決まります。本稿はその30秒、つまりコールドブート直後の起動と状態回復をどう設計するかという話です。
計算環境は使い捨て、状態は別の場所に置く
最初に頭を切り替える必要があるのは、「同じマシンが昨日の続きを覚えている」という素朴な期待を捨てることです。
スケジュール実行の多くは、実行のたびに新しいサンドボックスを立ち上げます。前回書いたファイルは消え、インストールしたパッケージも残らず、cwd も環境変数も引き継がれません。ここで頼れる状態は、計算環境の外 にあるものだけです。
私の運用では、耐久する状態を次の三層に分けて考えています。
層 置き場所 役割
コード/成果物 Git リモート 記事の本体。push して初めて「やった」ことになる
参照データ・設定 クラウド同期のワークスペース 方針・キーワード・トークン。読み取り専用に近い扱い
実行履歴 日付ごとのログファイル 「今日もう何をしたか」を次回の自分に伝える唯一の手段
この三層がそろっていれば、サンドボックスは何度焼き払われても構いません。エージェントは毎回、外部の真実から自分の状態を組み立て直せます。逆に言えば、この三層のどれかをローカルの一時ディレクトリに頼った瞬間、その情報はコールドブートで失われます。
起動シーケンスを「防御的に」書く
本処理に入る前に、環境を整える短いシーケンスを必ず通します。ここを横着すると、後段の処理が無言で失敗します。
順番に意味があります。(1) ファイルシステムの起動を待つ、(2) 作業ディレクトリを動的に探す、(3) 書き込み可能な作業領域を確保する、(4) 認証情報を取り出す。この4つです。
#!/usr/bin/env bash
set -euo pipefail
# (1) マウントが整うまで待つ — コールドブート直後は数秒間ファイルが見えない
WS = ""
for i in $( seq 1 10 ); do
WS = "$( ls -d /sessions/ * /mnt/Workspace 2> /dev/null | head -1 )"
[ -n " $WS " ] && [ -d " $WS " ] && break
sleep 3
done
[ -n " $WS " ] || { echo "FATAL: workspace not mounted after 30s" ; exit 1 ; }
# (2) 作業ディレクトリの第一候補が書けるか確かめ、ダメなら $HOME へ退避
WORK_BASE = "/tmp/repos"
if ! mkdir -p " $WORK_BASE /.probe" 2> /dev/null ; then
echo "WARN: $WORK_BASE not writable, falling back to \$ HOME/repos"
WORK_BASE = " $HOME /repos"
mkdir -p " $WORK_BASE "
fi
rmdir " $WORK_BASE /.probe" 2> /dev/null || true
# (3) 認証情報は環境変数ではなく同期ストレージから読む(再ブートで消えないため)
TOKEN = "$( grep -A1 '^MySite' " $WS /_tokens/tokens.txt" | tail -1 | tr -d '[:space:]')"
[ -n " $TOKEN " ] || { echo "FATAL: token missing" ; exit 1 ; }
echo "ready: WS= $WS WORK_BASE= $WORK_BASE "
ポイントは、/tmp が書けることを仮定しない ことです。実際に小さなディレクトリを作ってみて、失敗したら静かに $HOME へ逃がします。今回のブート時に私が遭遇した Permission denied は、この .probe テストひとつで吸収できる種類のものでした。
ls -d /sessions/*/mnt/... のようにワイルドカードで探すのも意図的です。セッション名は実行ごとに変わるため、パスをハードコードすると次回には存在しないディレクトリを指してしまいます。動的探索は、環境が変わることへのいちばん安い保険です。
「今日もう同じことをしたか」を冪等に判定する
使い捨て環境のもうひとつの怖さは、再試行です。スケジューラがタイムアウトを誤検知して同じタスクを二度起動したり、前段の失敗から手動で再実行したりすると、エージェントは「初めてのつもり」で同じ仕事を繰り返します。無人運用では、これが重複公開という形で表に出ます。
防ぎ方はシンプルで、本処理の前に耐久ログへ問い合わせる冪等ガードを置きます。私の場合、その日に書いたものは日付ごとのログに必ず残しているので、ログを真実の源として使えます。
import os, sys, datetime, glob
def already_done (log_dir: str , marker: str ) -> bool :
"""当日ログに marker があれば True(=処理済み)。
JST 基準で当日を判定する。素の date は UTC で前日扱いになる事故がある。"""
jst = datetime.timezone(datetime.timedelta( hours = 9 ))
today = datetime.datetime.now(jst).strftime( "%Y-%m- %d " )
for path in glob.glob(os.path.join(log_dir, f " { today } *.txt" )):
with open (path, encoding = "utf-8" ) as f:
if marker in f.read():
return True
return False
if already_done(sys.argv[ 1 ], sys.argv[ 2 ]):
print ( "SKIP: already produced today" )
sys.exit( 0 )
タイムゾーンを明示しているのは趣味ではありません。ログのファイル名を素の date で作ると、サンドボックスの時計が UTC のまま動き、日付境界をまたいだ実行が前日のログを上書きして「やっていないこと」になってしまいます。私はこの取り違えで一度、同じ日のログを二重に作りかけました。冪等ガードの判定キーは、必ず運用しているタイムゾーンで固定してください。
二段にするとさらに堅くなります。ログに加えて、成果物そのものの存在 でも確認します。記事生成なら「同じスラッグの近縁が今日のコミットにあるか」を Git のログで照合する、といった具合です。ログと成果物の二点で一致を見れば、片方が壊れていても重複は止まります。
push して初めて「やった」ことになる
コミットしただけで安心しないでください。使い捨て環境では、ローカルに積んだコミットはサンドボックスごと蒸発します。耐久したのはリモートに到達した分だけです。
そこで、push の成否を「コマンドが返り値ゼロで終わったか」ではなく、ローカルとリモートのコミットハッシュが一致したか で判定します。
git push origin main 2>&1 | tail -3
LOCAL = "$( git rev-parse HEAD)"
REMOTE = "$( git ls-remote origin -h refs/heads/main | cut -f1 )"
if [ " $LOCAL " = " $REMOTE " ]; then
echo "PUSH OK: $LOCAL "
else
echo "PUSH FAILED: local= $LOCAL remote= $REMOTE " ; exit 1
fi
私がこの確認を入れたのは、git commit が identity 未設定で無言失敗し、後続の push が「変更なし」で成功したように見えた経験があるからです。クローン直後の使い捨て環境では user.email も user.name も空です。コミットの前に identity を設定し、最後はハッシュ一致でしか成功を信じない。この二点で、成功の偽装はほぼなくなります。
公式ドキュメントに書かれていない運用知見
ここまでを実際に回して見えてきた、ドキュメントの行間にある勘所をいくつか残します。
これらはどれも、本番運用に乗せて初めて見える落とし穴でした。
第一に、ブート遅延は固定時間で待たない 。sleep 30 のような決め打ちは、速い日には時間を捨て、遅い日には足りません。マウントやプロセスの「準備完了」を条件にしたリトライ(上のループ)にすると、平均待ち時間が縮みます。私の環境では、固定待ちから条件待ちに変えただけで、起動オーバーヘッドが約55%縮み、平均待ち時間は実測で15秒前後から7秒前後になりました。これは無人運用で地味に効く改善です。
第二に、クラウド同期のファイルは「ディスク上にある」とは限らない 。同期ストレージ上のファイルは、初回アクセスで初めて実体がダウンロードされることがあります。cat が一瞬失敗するように見えたら、それは権限ではなく実体化の遅れであることが多く、読み取りツール経由のアクセスで解決します。大量・大容量を一度に触る前に、何を実体化させるのかを意識すると無駄なダウンロードを避けられます。
第三に、一時ファイルは固定名で使い回さない 。/tmp/insert.txt のような固定名は、書き込みに失敗したとき前回の残骸が無言で混ざります。スラッグなどを含むユニーク名で $HOME 配下に置き、書いた直後に中身を grep で検証すると、汚染が紛れ込みません。
第四に、失敗もログに残す 。ゼロ本で終わった夜こそ、なぜ止まったかが翌朝の唯一の手がかりです。成功ログと同じ場所に _FAILED 接尾辞のログを必ず書き、次回の自分が状況を再構成できるようにしておきます。
状況に応じた使い分け
すべての無人タスクに完全な状態回復が要るわけではありません。負荷に見合った設計を選んでください。
運用の性質 推奨する備え
1日数回・副作用が外部公開 三層分離+冪等ガード+ハッシュ一致確認をすべて入れる
1日1回・読み取り中心の集計 ブート待ちとフォールバックパスのみ。冪等は出力先の上書きで足りる
手動トリガー・対話あり 動的探索だけ入れ、状態回復は人間が見ているので簡略化
過剰な防御はそれ自体が複雑性であり、別の障害源になります。導入の優先順位は、次の三つをこの順で考えています。
冪等ガード(重複公開を止める)
push のハッシュ一致確認(空振りを止める)
ブート待ちとフォールバックパス(起動そのものを安定させる)
私はまず冪等ガードとハッシュ一致確認の二つから入れることをおすすめします。この二つは、最も静かで気づきにくい失敗——重複公開と、push したつもりの空振り——を止めてくれるからです。
無人で動く仕組みは、動いているときは誰の目にも触れません。だからこそ、目を覚ました最初の30秒に、まっさらな環境から自分を組み立て直す手順を丁寧に書いておく。それが、朝に静かな成功を積み上げるための、地味で確かな投資だと考えています。
実装の参考になれば幸いです。最後までお読みいただき、ありがとうございました。