Claude Codeと2日間ペアプロしてわかった「機構で縛る」開発の威力
はじめに
動画変換SaaSのバックエンドを、Claude Codeと実際にペアプロして直した2日間のログです。単に「AIに書いてもらった」ではなく、観測→設計→対照確認のサイクルを人間とAIが交互に回し続けた記録です。
扱った問題は多岐にわたります。
- 1.07GBファイルのOOM(out of memory)で42時間止まった変換ジョブ
-
flockで実装した孤児プロセス検出 -
grepコマンドがugrep関数に差し替えられていて「黙って0を返す」問題 - 嘘のコメントが4回の見逃しを生み出した経緯
記事の主眼はコードそのものより設計の判断プロセスです。「なぜそこで止まるのか」「なぜその数字なのか」を記録します。
やったこと(1日目)
Z-64:丸読みを構造から消す
S3互換ストレージへのアップロードスクリプトが、ファイルをread_bytes()で一括ロードしていました。SigV4署名はペイロードのSHA256を要求するため、「ハッシュ計算→送信」の2パスが必要です。
CHUNK = 8 * 1024 * 1024 # 8MB
def put_from_file(env, key, src_path):
with open(src_path, 'rb') as fd:
# 1パス目:同じfdでハッシュを計算
payload_hash = sha256_of_fd(fd)
fd.seek(0) # 🔴 名前で開き直さない(中身が変わっても気づけないため)
# 2パス目:チャンク送信
return _put_chunked(env, key, fd, payload_hash)
def get_to_file(env, key, dest_path):
part = dest_path + '.part'
written = 0
with open(part, 'wb') as out:
for chunk in _get_chunked(env, key):
out.write(chunk)
written += len(chunk)
# Content-Lengthと書けたバイト数を突き合わせる
if written != expected_content_length:
os.unlink(part)
raise ValueError(f"truncated: got {written}, expected {expected_content_length}")
os.replace(part, dest_path) # アトミック置き換え
本番での実測(MemoryMax=900M の cgroup 下):
| 指標 | 旧版(丸読み) | 新版(チャンク) |
|---|---|---|
| RSS(get) | 878MB → OOM | 39.25MB |
| RSS(put) | — | 36.20MB |
| 所要時間 | 失敗 | 94.87秒 |
(個人の感想)「94.87秒かかった」という数字が今日いちばん効きました。もし「N秒超えたら失敗」というタイムアウトで孤児を判定する設計にしていたら、この94秒をどう扱うかで必ず間違えたはずです。
Z-65:時計ではなくflockで孤児を検出する
normalizingステータスのまま42時間止まった変換ジョブ。なぜ時計で測ってはいけないか:
短い閾値 → 生きている変換(94秒かかる)を殺す
長い閾値 → 死んだ行がN時間見えない
→ どちらに倒しても間違う。時計はこの問いに答えられない
flockを使うと「保持者が死んでいるか」を実体で確認できます。
# normalize-worker.sh
exec 8>"$ROWLOCK" # fd 8 を開く
flock -n 8 || exit 0 # 取れなければ他のworkerが担当中 → スキップ
# ここから先、fd 8 は開いたまま
# get(95秒)→ ffmpeg(10分)→ put(95秒)
# どれもfd 8を閉じない = この間ずっとロックが保持される
release_rowlock() { exec 8>&-; } # 全return パスの直前でのみ閉じる
状態を4つに分けたのがポイントです:
状態A: ロックが在り、誰かが保持 → 生きている。触らない
状態B: ロックが在り、誰も保持しない → 検出A(保持者が死んだ)
状態C-1: ロックがそもそも無い(flock導入以前の行)→ 自動で触らない
状態C-2: ロックが無い(flock導入以後の行)→ 検出B(担当なし)
C-1とC-2を分けるために、DBに「flock版の配備時刻マーカー」を入れておきます。
-- migration
ALTER TABLE assets ADD COLUMN normalizing_started_at TIMESTAMPTZ;
INSERT INTO system_markers (key, value) VALUES ('flock_since', NOW());
検査スクリプトはマーカーが読めない場合に走らない設計にします。「マーカーが無いから全部C-2扱い」は、いちばん危ない誤りが既定の挙動になる形です。
やったこと(2日目)
Z-22:4日越しの本番配備
prereg-list.phpが4日間未配備だった原因の連鎖:
-
LOOPCAST_PREREG_LIST_TOKENだけ設置しようとしたが、LOOPCAST_PREREG_LIST_URLも必要だった(手順書の漏れ) -
shasum -a 256の出力末尾に-が付いていて、ハッシュが64文字ではなく67文字になっていた(手順書のバグ) -
Authorizationヘッダがnginx+PHP-FPM経路でPHPに届かない(Xserverの既知の罠)
3番目の切り分けが今日のハイライトです:
# 観測用PHPを一時配置して確認
# 陽性対照(X-Test-Hdr)を同じリクエストに入れる
curl -H "Authorization: Bearer $TOKEN" \
-H "X-Test-Hdr: hello" \
https://example.com/debug-headers.php
結果:
HTTP_X_TEST_HDR: 在る(長さ5) ✅
HTTP_AUTHORIZATION: 無い 🔴
REDIRECT_HTTP_AUTHORIZATION: 無い 🔴
「Authorizationだけが届かない」が観測で確定。解法はX-Prereg-Tokenカスタムヘッダへの変更(.htaccessのSetEnvIfはnginx環境では効かないため試さない)。
YouTube API quotaの実測
liveBroadcasts.listのコストは公式ドキュメントに記載がなく、実測しました。
窓 11:28Z〜11:41Z:
liveness実行 3回 × 2呼び出し/回 = 6呼び出し
Δユニット = 124 − 118 = 6
単価 = 6 ÷ 6 = 1ユニット/回 ✅
副産物:
- N=18なら単価0.33(非整数)→ listCacheが5本を1回に畳んでいる証明
- quota リセットは 16:00 JST(太平洋時間0時)
ハマったポイント
grep がシェル関数に差し替えられていた
Claude Codeが動く環境ではgrepがugrep関数にラップされており、ブラケット式に(を含むパターンを渡すと黙って0を返すという罠がありました。
# 問題のあるパターン
grep -cE "[^a-zA-Z0-9_(]fail([^a-zA-Z0-9_])" verify.sh
# シェル関数grep: 0(嘘) 🔴
# command grep: 12(正しい)
# 対策
command grep -cE "[^a-zA-Z0-9_(]fail([^a-zA-Z0-9_])" verify.sh
同様にfindもbfsラッパーに差し替えられています。スキャンヘルパに以下を入れました:
# scripts/lib/scan.sh
# 常にcommand経由で呼ぶ(差し替えを回避)
_find() { command find "$@"; }
_grep() { command grep "$@"; }
# N/M/Kを必ず出力
# Nが0なら「該当なし」を名乗らずにexit 2
if [[ $N -eq 0 ]]; then
echo "🔴 走査対象が0件:走査が成立していません" >&2
exit 2
fi
発見の経緯: 34コマンドのtype -t結果が全部空になった(zshにはtype -tがない)。型63「全部が同じ答えになったら、まず道具を疑う」を意識して生の出力を見て気づきました。
type=oneshotなのにsystemctl startがブロックする
Z-65の対照テストで、変換中にassetIdが/procに見えるかを確認しようとしました:
systemctl start loopcast-normalize.service &
sleep 2
# /proc/*/cmdline を検索
結果は「0件」。原因はType=oneshotのせいでsystemctl startが完了までブロックしていたため、/procを見始めた時点で変換はすでに終わっていました(9秒の素材だったので)。
# 修正:--no-blockを使う
systemctl start --no-block loopcast-normalize.service
sleep 2
# ここで検索すれば変換中に刺さる
「検査の欠陥だった」で済ませず、鳴るのを見てから②(プロセスが居ない条件)を信用するのが今回のポイントです。
コントラスト比の実測
管理画面のダークテーマで、赤いバッジが読みにくい問題を実測で発見しました:
# contrast_check.py
def contrast_ratio(fg_hex, bg_hex):
def luminance(hex_color):
r, g, b = [int(hex_color[i:i+2], 16) / 255 for i in (1, 3, 5)]
def linearize(c):
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
R, G, B = linearize(r), linearize(g), linearize(b)
return 0.2126 * R + 0.7152 * G + 0.0722 * B
L1 = luminance(fg_hex)
L2 = luminance(bg_hex)
lighter, darker = max(L1, L2), min(L1, L2)
return (lighter + 0.05) / (darker + 0.05)
実測結果:
| 組み合わせ | 比率 | WCAG AA(4.5) |
|---|---|---|
#ef4444(赤文字)/ #991b1b(赤背景) |
3.31 | ❌ |
#fca5a5(明るい赤文字)/ #1c1c1e(黒背景) |
8.42 | ✅ |
「背景と文字で同じ赤系を使う」のが問題でした。塗り色(badge背景)と文字色を分けるトークン設計に変更しました。
学び
「機構で縛る」とはどういうことか
2日間で一番繰り返したフレーズです。「気をつける」「手順書に書く」では4回まで失敗した実績があります($FILESの改行問題、type -tのzsh問題、ugrep問題、/proc問題)。
代わりにやったこと:
# 「該当なし」を名乗れる条件をコードで縛る
if [[ $N -eq 0 ]]; then
echo "🔴 走査対象0件" >&2; exit 2
elif [[ $N -ne $M ]]; then
echo "🔴 読めなかった $((N-M)) 件" >&2; exit 2
fi
echo "✅ 走査対象=$N / 読めた=$M / 該当=$K"
# push前のフックで秘密を走査(許可リストはblob shaで管理)
# .githooks/pre-push
bash scripts/check-history-secrets.sh || exit 1
-- 条件付きUPDATE(確認と変更を1文に)
UPDATE assets
SET status = 'uploaded'
WHERE id = $1 AND status = 'failed'; -- 0件なら状態が違う
対照は「壊していない側」が先
型59として定義された原則:
対照①(陽性): 直した問題が実際に直っている
対照②(陰性): 直した以外の部分が壊れていない ← こちらを先に打つ
特に重要だったのは「Z-65の検出Bは生きた変換を絶対に殺さない」という条件。
# process-orphan-check.sh(抜粋)
# ② を先に確認:保持者が居るなら触らない
if flock -n "$ROWLOCK" true 2>/dev/null; then
: # ロックが取れた → 保持者が居ない → 検出対象
else
continue # ロックが取れない → 保持者が居る → スキップ(対照②が要)
fi
予測を先に書いて外れた方が収穫
2日間で予測3勝2敗でした。2敗の方が価値がありました:
-
enableAutoStop=Trueの配信が1本だと予測 → 実測5本(常設キーの経路で自然に作るとTrueになる実例が4つ揃った) -
actualEndTimeの符号が「止めてからcompleteになる」だと予測 → YouTubeが先にauto-stopで終わらせていた(符号が逆)
予測が外れた時点で「そこが設計の前提になっていた仮定」が可視化されます。
「命令を出したこと」と「状態が変わったこと」は別
# 悪い例(命令を出した=状態が変わったとみなす)
systemctl stop loopcast-normalize.timer
# 良い例(状態で確認する)
systemctl stop loopcast-normalize.timer
systemctl is-active loopcast-normalize.timer # inactive を確認してから先へ
配備後も同様:
# 配備したことと、配備物が正しいことは別
sha256sum /usr/local/bin/loopcast-target-script
# → リポジトリの値と一致するかを確認
今日だけでこのパターンを4回踏み直しました。機構化の方向としては、配備スクリプト自体に「sha256の突き合わせ」を組み込みました。
まとめ
Claude Codeとのペアプロで印象的だったのは、「気づかなかった」より「気づいたが済ませてしまいそうになった」場面の方が多かったことです。
Z-65の対照⑥が落ちたとき、「検査の欠陥だった」で済ませれば5分早く先に進めました。しかし「鳴るのを見てから②を信用する」にこだわった結果、Type=oneshotのブロッキングという根本原因が見つかり、3条件のANDが黙って2条件に縮む欠陥を防げました。
「機構が相手を選ばない」——今週作った走査ヘルパ、push前フック、コントラスト検査、条件付きUPDATEは、全部その日のうちに作者自身を止めました。それが今回いちばんの成果だと思います。