個人開発で回している自動化は、その多くを深夜のスケジュール実行に任せています。その結果を朝いちばんに確認するのが、ここ数年の習慣です。
その朝は、4本のジョブが全部同じところで止まっていました。最初の1本だけが認証エラー。残り3本は、起動直後から一度も通っていない。
手で同じコマンドを叩くと、あっさり通ります。再実行しても通る。ログを遡ると、最初の1本が踏んだのは一過性の401でした。ネットワークの瞬断か、その時刻だけの何かか。そこまでは想定内です。
腑に落ちなかったのは、その1本の失敗が、無関係なはずの他の3本を巻き込んでいたことでした。
リリースノートの1行が、手を止めさせた
8月8日から9日にかけての Claude Code の更新に、こういう修正が入っています。
一時的な 401 をきっかけに、長期の CLAUDE_CODE_OAUTH_TOKEN が保存済みログインの短命トークンへ置き換わり、再起動するまでヘッドレスセッションが壊れ続ける問題が修正されました。
読んだ瞬間に、あの朝のログと形が重なりました。修正されたのですから、この経路については追わなくてよいはずです。
それでも手が止まったのは、これが**特定のバグというより、失敗の「形」**に見えたからでした。長期の資格情報と短命の資格情報が、同じ置き場所を共有している。片方を更新する経路が、もう片方を黙って上書きできる。その構造は、自分が組んだ自動化の中にもいくつか残っているはずです。
そこで、Claude Code の内部実装を推測するのではなく、この失敗の形だけを取り出した最小モデルを書いて、被害の広がり方を数字にしてみることにしました。以下の実測値はすべてその自作モデルのもので、Anthropic の実装を再現したものではありません。測っているのは「共有された可変の資格情報ストアが持つ性質」そのものです。
計測環境は Linux 6.8.0 / Python 3.10.12 / 4 vCPU のサンドボックスです。
失敗の形を、最小構成で組む
必要な部品は3つだけでした。資格情報を置く1つのファイル。それを読んで使う複数のワーカープロセス。そして、401 を踏んだときに走るリフレッシュ経路。
資格情報には最初から src フィールドを持たせています。あとでガードを実装するときに使いますが、まずはただ記録されるだけの値です。
# store.py — 資格情報ストアの最小実装
import json, os, time, fcntl, tempfile
def write_naive(path, obj):
"""アトミックでない書き込み。実際のクラッシュ窓を再現するため2回に分けて書く"""
with open(path, "w") as f:
s = json.dumps(obj)
half = len(s) // 2
f.write(s[:half]); f.flush()
time.sleep(0.0008) # この隙間に他プロセスが読むと壊れた JSON を掴む
f.write(s[half:]); f.flush()
def read_naive(path):
with open(path) as f:
raw = f.read()
return json.loads(raw) # 途中の状態を読むと ValueError
def write_atomic(path, obj):
"""同一ディレクトリに書いてから rename。POSIX では rename が原子的"""
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path))
with os.fdopen(fd, "w") as f:
json.dump(obj, f); f.flush(); os.fsync(f.fileno())
os.replace(tmp, path)
def with_lock(path, fn):
"""flock による排他。ロックファイルは資格情報本体とは別に持つ"""
with open(path + ".lock", "a+") as lf:
fcntl.flock(lf, fcntl.LOCK_EX)
try:
return fn()
finally:
fcntl.flock(lf, fcntl.LOCK_UN)
ワーカー側は、現実のヘッドレスセッションに寄せて 一度読んだ資格情報をプロセス内に保持する ようにしました。毎回ファイルを読み直すプロセスは、そもそも実装として不自然です。
def worker(wid, q):
path = os.path.join(BASE, "credentials.json")
cached = None; used_session = 0; poisoned_at = None
for it in range(ITERS):
if cached is None:
try:
cached = with_lock(path, lambda: read_naive(path))
except Exception:
torn += 1; time.sleep(0.001); continue
if cached.get("kind") != "long_lived":
used_session += 1
if poisoned_at is None:
poisoned_at = it # 何イテレーション目で汚染されたか
if random.random() < P401: # 一時的な401 → リフレッシュ経路へ
new = {"token": session_token(wid), "kind": "session", "src": "saved_login"}
with_lock(path, lambda: write_atomic(path, new))
cached = None # 書き戻したので次は読み直す
time.sleep(0.0005)
kind が long_lived のまま最後まで走れば健全。途中で session に変わったら、そのワーカーは運用者が意図していない資格情報で動き続けていることになります。
16並列で回して、最初の数字を見る
401 の発生確率を 2% にして、16ワーカー × 200イテレーションを回しました。
| 方式 | 破損読み取り | 汚染されたワーカー | 短命トークンでの実行回数 | 最終状態 |
| 素朴な読み書き | 25 | 16 / 16 | 2,919 | session / saved_login |
| flock + アトミック書き込み | 0 | 16 / 16 | 2,874 | session / saved_login |
ここが、この検証でいちばん考えさせられた結果でした。
flock を入れると、壊れた JSON を掴む事故は 25 件から 0 件になります。それなのに、汚染されたワーカーの数は 16/16 のまま1つも減りません。
私自身、ロックを足せば数字が半分くらいには減るだろうと予想していました。実際には、ロックが直したのは「読み取りの途中経過が見えてしまう」という表示上の壊れ方だけです。最後に書いた人が勝つという意味的な上書きは、ロックの内側でむしろ整然と実行されます。
しかも厄介なのは、破損読み取りが 0 になったことで、唯一目に見えていた異常のサインが消える点です。ロックを足したあとのログはきれいになります。壊れていないように見えるログが出るようになるだけで、走っているトークンは間違ったままです。
401 の頻度を変えてもう一度回すと、稀さが守ってくれないことも見えました。
| 401 の発生確率 | 破損読み取り | 汚染されたワーカー | 短命トークンでの実行回数 |
| 0.1% | 2 | 7 / 16 | 1,295 |
| 0.5% | 3 | 12 / 16 | 1,740 |
| 2% | 18 | 16 / 16 | 2,920 |
| 10% | 256 | 16 / 16 | 3,160 |
1000回に1回しか起きない事象で、16のうち7つが汚染される。破損読み取りはたった2件です。監視で拾える異常の量と、実際の被害の量が、まったく釣り合っていません。
401 を「1回だけ」注入する
確率で回すと、どの失敗がどの被害を生んだのか切り分けられません。そこで、24ワーカーのうち0番だけが、20イテレーション目に1回だけ401を踏むという決定的な条件に変えました。ほかの誰も401を踏みません。
現実のフリートに寄せて、30イテレーションごとに新しいプロセスが立ち上がる(=資格情報を読み直す)想定も入れています。
INJECT_AT = 20 # worker 0 が1回だけ踏む一時的401
if wid == 0 and it == INJECT_AT:
new = {"token": "st_x", "kind": "session", "src": "saved_login"}
with_lock(path, lambda: write_atomic(path, new))
cached = None
if it % 30 == 29: # 新規プロセスの起動に相当する読み直し
cached = None
結果です。
| 条件 | 注入した401 | 汚染されたワーカー | 最初の汚染 | 最後の汚染 | 不正な資格情報での実行回数 |
| ガードなし | 1 | 24 / 24 | 21回目 | 30回目 | 2,889 |
| 出所ガードあり | 1 | 0 / 24 | — | — | 0 |
たった1回の一過性エラーが、9イテレーションの間に24プロセス全部へ伝わりました。伝播したのは失敗そのものではありません。**失敗に対する「善意の後始末」**です。
あの朝のログで、無関係なはずの3本が巻き込まれていた理由が、これでようやく腑に落ちました。1本目は本当に一過性の失敗をしただけ。残り3本を止めたのは、1本目が親切に書き戻した回復用の資格情報でした。
資格情報に「出所」を持たせる
修正の方向は、ロックでも再試行でもありませんでした。書き戻す前に、今そこにあるものがどこから来たのかを見るだけです。
def refresh_with_provenance(path, new_token):
"""出所ガード付きのリフレッシュ。
env 由来の長期資格情報は、いかなる回復経路からも上書きしない。"""
def guarded():
cur = read_naive(path)
if cur.get("src") == "env" and cur.get("kind") == "long_lived":
# 運用者が明示的に渡した資格情報。回復用トークンは隣に置く
write_atomic(path + ".session", new_token)
return "sidecar"
write_atomic(path, new_token)
return "replaced"
return with_lock(path, guarded)
やっていることは、if 文が1つ増えただけです。それでも性質が変わります。書き込み権限を持つ経路が、書き込んでよい対象を自分で判断できるようになるからです。
ここで大事なのは、短命トークンを捨てないことでした。捨ててしまうと、本当にリフレッシュが必要な場面で回復できません。長期資格情報の隣(.session サイドカー)に置いて、解決順序を「env の長期 → サイドカーの短命」と決めておけば、両方が共存できます。
実測では、ガードあり・24ワーカー・401を1回注入で、汚染 0、不正な資格情報での実行 0 でした。16ワーカー × 200イテレーション × 401率2% の条件でも同じく 0 です。
src に入れる値は、解決経路そのものを書きます。私は次の4つに分けました。
| src の値 | 意味 | 上書き可否 |
env | 環境変数で運用者が明示的に渡した | 不可(サイドカーへ) |
mount | 読み取り専用でマウントされた | 不可(そもそも書けない) |
saved_login | 対話ログインで保存された | 可 |
refresh | リフレッシュ経路が発行した | 可 |
読み取り専用マウントを使えるなら、それがいちばん確実です。ファイルシステムが if 文の代わりをしてくれます。ただしコンテナ前提の話なので、素のマシンで回している自動化には出所フィールドのほうが現実的でした。
隔離するなら、どこまで隔離するか
もう1つの解は、そもそも共有をやめることです。セッションごとに設定ディレクトリを分ければ、誰も誰かの資格情報を上書きできません。
ただ、設定ディレクトリはトークンだけではありません。セッション履歴やキャッシュも同居しています。手元の構成を模して 2.89 MB のディレクトリを作り、丸ごと複製した場合と、資格情報だけ実体を持ち残りを symlink にした場合を比べました。
| 方式 | 8セッション | 32セッション | 1セッションあたり | 32セッションのディスク |
cp -a で丸ごと複製 | 38.2 ms | 151.1 ms | 4.7 ms / 2.9 MB | 92.5 MB |
| 資格情報のみ実体・残りは symlink | 1.3 ms | 4.9 ms | 0.2 ms / 4.5 kB | 142.4 kB |
時間で 24 倍、ディスクで 650 倍の差になりました。
import os, shutil
def isolated_config(src, dst):
"""資格情報だけ実体を持ち、残りは共有元へ symlink する"""
os.makedirs(dst, exist_ok=True)
for entry in os.listdir(src):
if entry == "credentials.json":
shutil.copy2(os.path.join(src, entry), os.path.join(dst, entry))
else:
os.symlink(os.path.abspath(os.path.join(src, entry)),
os.path.join(dst, entry))
return dst
書き込みが起きるファイルだけを実体化する、という切り分けです。読み取りしかしない履歴やキャッシュまで複製する理由はありません。32セッションでも 142 kB なら、セッションごとの隔離を常時オンにしておけます。
隔離と出所ガードは、どちらか一方ではなく重ねるのが良いと考えています。隔離は「同じ場所を共有しない」という予防。出所ガードは「共有せざるを得ない場所で、書いてよいかを判断する」という予防。ヘッドレス実行では設定ディレクトリを共有せざるを得ない場面がまだ残ります。同じ発想でサンドボックス側の秘密の扱いを整理した話は、サンドボックス資格情報マスキングの差し替えが効く境界にまとめています。
再読み込みを速くしても、予防にはならなかった
ここで、もう1つ確かめたいことがありました。
「プロセス内にキャッシュしているのが悪いなら、こまめに読み直せばいいのでは」という発想です。壊れた資格情報を掴んでも、すぐ読み直せば直った値を拾えるはずです。
10イテレーション目に汚染を注入し、40イテレーション目に運用者がファイルを手で直す、という条件で、キャッシュの再読み込み間隔だけを変えて回しました。16ワーカー × 120イテレーションです。
| 再読み込み間隔 | 不正な資格情報での実行回数 | うち修復後 | 修復後もまだ壊れているワーカー |
| 再読み込みしない | 30 | 1 | 1 / 16 |
| 30イテレーションごと | 480 | 301 | 16 / 16 |
| 5イテレーションごと | 500 | 36 | 8 / 16 |
予想と逆の結果が2つ出ました。
1つめ。間隔を 30 から 5 へ、6分の1に縮めても、被害の総量は減りません(480 → 500。むしろ微増)。縮めたぶん汚染を早く拾ってしまうので、掴んでいる時間の合計はほとんど変わらないのです。減ったのは修復後の残り(301 → 36)だけでした。つまり再読み込み間隔は予防のパラメータではなく、復旧速度のパラメータです。
2つめ。いちばん被害が小さいのは「再読み込みしない」でした(30回・1ワーカー)。読み直さないプロセスは、汚染そのものを受け取りません。起動時に掴んだ正しい資格情報を持ったまま最後まで走り切ります。
ただしこれを設計指針にはできません。「読み直さないから安全」は、起動タイミングがたまたま汚染前だったという運任せに乗っているだけです。汚染後に起動したプロセスは、間違った資格情報を最後まで抱え続けます。実際、この条件で唯一壊れ続けたのは、自分で汚染を書き込んだ0番でした。リリースノートにあった「再起動するまで壊れ続ける」は、まさにこの性質のことです。
結論として、キャッシュ戦略をどうチューニングしても予防にはなりません。予防できたのは出所ガード(0件)と隔離(0件)だけでした。
起動時カナリア — 24マイクロ秒で現在地を確定させる
最後に足したのは、実行のたびに「今どのトークンで動くのか」をログへ1行残すだけの仕掛けです。
import json, hashlib
def credential_canary(path):
"""トークン本体は絶対に出さず、指紋と出所だけを返す"""
o = json.load(open(path))
fp = hashlib.sha256(o["token"].encode()).hexdigest()[:12]
return {"fingerprint": fp, "kind": o.get("kind"), "src": o.get("src")}
# 実行前に呼んで、期待値と違ったら走らせない
c = credential_canary(CRED_PATH)
if c["kind"] != "long_lived" or c["src"] != "env":
raise SystemExit(f"credential drifted: {c}") # 例: eb031743498b / long_lived / env
5,000回計測して、中央値 24.1 マイクロ秒、p95 26.9 マイクロ秒、p99 53.7 マイクロ秒でした。1,000回起動しても合計 24.1 ミリ秒です。参考までに、flock の取得と解放だけでも中央値 16.5 マイクロ秒かかっています。カナリアの実質的な上乗せは、ロック1回とほぼ同じ桁でした。
指紋は先頭12桁だけを残します。トークンそのものはログにもエラーメッセージにも出しません。それでも「昨日と今日で違うトークンが使われた」ことは指紋の変化で確実にわかります。
この検証を通していちばん実感したのは、資格情報の障害は、値が正しいか間違っているかではなく、いつの間にか別のものに入れ替わることで起きるということでした。値の正しさは検証できます。入れ替わりは、記録していなければ検証すらできません。
明日の実行から入れるもの
この検証には限界があります。単一ホストのプロセス間だけを見たモデルで、ネットワークも実際の認可サーバも含みません。ファイルシステムをまたぐ場合や、コンテナのボリューム共有を挟んだ場合は、また別の測り直しが要ります。
そのうえで、今夜の実行から手を入れられる順序を、効果の大きかった順に並べます。
- 書き戻す経路に
src の判定を足す。実装は if 文1つ、計測上のコストは 24 マイクロ秒。この場合は他の対策を待つ理由がないので、先に入れることを推奨します。私の自動化では、これだけで1回の一過性エラーが24プロセスへ伝播する経路が閉じました。
- 起動時カナリアをログに1行足す。指紋・
kind・src の3つだけ。予防はできませんが、入れ替わりが起きたことを事後に確定できるようになります。
- 書き込みが起きるファイルだけをセッションごとに実体化する。32セッションで 142 kB なら常時オンにできます。設定ディレクトリを丸ごと複製する必要はありません。
隔離もカナリアも、1番のあとで構いません。順番として先に効いたのは、壊す権限を持っている経路に、壊してよいかを問わせることでした。
同じところで朝を無駄にした方の役に立てば嬉しいです。お読みいただきありがとうございました。