0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Codeと2日間ペアプロしてわかった「機構で縛る」開発の威力

0
Last updated at Posted at 2026-08-14

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日間未配備だった原因の連鎖:

  1. LOOPCAST_PREREG_LIST_TOKENだけ設置しようとしたが、LOOPCAST_PREREG_LIST_URLも必要だった(手順書の漏れ)
  2. shasum -a 256の出力末尾に -が付いていて、ハッシュが64文字ではなく67文字になっていた(手順書のバグ)
  3. 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は、全部その日のうちに作者自身を止めました。それが今回いちばんの成果だと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?