ISUCON2026 にチーム「復旧お疲れ様でした」として参加しました。最終スコアは 59,430 点、279 位 でした。実装は Go、DB は PostgreSQL です。
人間 1 人と AI エージェント 3 体(Codex・Claude Code・Antigravity)で改善を進めました。ただ、思っていたようには進まず、時間の使い方にも課題が残りました。この記事では、実際に入れた実装と計測した結果をまとめます。認識が間違っているところがあれば、ぜひ教えてください。
時刻つきのスコア記録をもとに、次の 4 つを振り返ります。
- 何をして 3,450 点から 59,430 点まで来たか
- 何で転んだか(DB の OOM と復旧)
- 上位チームとどこで差がついたか
- AI エージェントを 3 体動かして分かったこと
1. 体制と事前準備
| 役割 | 担当 |
|---|---|
| 人間 | 方針の決定、採否の最終判断、エージェント間の調整 |
| Codex | 進行管理、デプロイ、ベンチ、合否の判定、記録 |
| Claude Code | 原因の調査(pprof・EXPLAIN・pg_stat_statements)、難しい実装、手順書づくり |
| Antigravity | 手順書どおりの作業、コードの見直しと提案 |
initialize の申告は、agent・harness を codex,claude-code,antigravity、model を gpt-6-sol,claude-opus-5-5,gemini4-argon にしました。
事前にやっておいたのは次の 3 つです。
- 練習の記録を「型」として書き出す。 ISUCON14 の練習記録を、エージェントが読む指示ファイル(CLAUDE.md)にまとめました。「計測してから変える」「1 回に 1 つ変える → ベンチ → 記録 → その場でコミット」「下がったらすぐ revert」といった進め方のルールも入れています。
- Makefile を本番の接続先に合わせる。 deploy・status・envcheck を 1 コマンドで流せるようにしました。
- タスク管理を Beads(
bd)に寄せる。 エージェントのセッションが切れても、発見や続きの作業が残るようにしました。
2. 問題:isureal(地域をまたぐ写真共有アプリ)
- 毎日の「投稿期間」の通知(SSE)が来たら、写真を投稿します。RealMoji・コメント・Memories(月や日ごとの振り返り)・エクスポートがあります。
- 台は 4 つです。asia1・europe1・america1 がアプリ、asia2 が PostgreSQL です。ルーターが
X-Country-Codeで地域の台に振り分けます。 - 地域間には遅延と帯域制限があります。 europe1・america1 から asia2 の DB までは往復約 100ms でした。asia1 から asia2 は約 1ms です。
- スコアには地域の倍率があります。 asia ×1、europe・america ×1.5 です。
- 画像の URL には署名が付きます。 署名は HMAC-SHA256 を 1000 回重ねる計算です。投稿画像は平均約 515KB の JPEG で、壊れた JPEG は 400 で弾く必要があります。
- 台の種類が違います。asia1・asia2 は c5.large、europe1・america1 は c7i.large でした。後から効いてくる大事な違いです。
3. スコアの推移
| 時刻 | 主な変更 | スコア |
|---|---|---|
| 10:17 | 初期状態 | 3,450 |
| 10:30 | DB 接続(sslmode=disable、アイドル接続を 30 に) |
6,677 |
| 10:47 | セッション認証を台ごとにキャッシュ(他の台の変更は 200ms ごとに DB を見て無効化) | 11,828 |
| 11:37 | 署名の HMAC を毎回作り直さず、Reset で使い回す | 16,440 |
| 11:57〜12:07 | 関係表の逆引きインデックス(19,682)、閲覧者の情報を 5 往復から 1 クエリに(19,891)、友達の友達の検索を OR から UNION に | 24,230 |
| 12:59 | 投稿作成を 1 文にまとめる(往復を削る) | 28,543 |
| 13:19 | europe・america の API を DB に近い台(asia1・asia2)で動かす | 34,258 |
| 13:42〜14:05 | asia2 の OOM で FAIL が 3 回、PostgreSQL の復旧 | 0 → 31,989 |
| 14:36 | 投稿の JPEG 検証を europe1・america1 で済ませてから転送 | 42,724 |
| 15:58 | Memories を europe1・america1 で返す | 48,679 |
| 16:17 | 画像 URL の署名を 30 秒使い回す | 50,856 |
| 16:37 | インデックス漏れ 6 つを追加 | 56,634 |
| 16:39 | 画像の本体を europe1・america1 から返す | 58,498 |
| 17:00 | 再起動試験の後、変更なしで再走 | 59,895 |
| 17:56 | 投稿作成のロック順の修正後、最後の走行 | 59,430 |
4. 効いた施策
4.1 「どこで処理するか」を変える(いちばん効いた)
最初は europe・america のリクエストも、それぞれの台から asia2 の DB を直接見ていました。全 API の応答が 100ms の倍数で、DB を何往復するかがそのまま遅さになっていました。11:51 の計測では、europe のフォロー 2.5 秒、コメント 1.77 秒、投稿 0.84 秒でした。
往復を 1 つずつ削る作業(ログイン 7 → 4 往復、友達申請 約 10 → 5 往復など)も地道に効きました。ただ、決定打は置き場所を変えたことでした。
- 13:08 europe1 の nginx で
/apiを asia1 上の europe 用プロセスへ転送しました。地域をまたぐのは nginx から asia1 への 1 往復だけになり、europe の人数は最大 36 人から 64〜66 人に増えました。 - 13:19 america 用プロセスは asia2(DB と同じ台)に置きました。asia1 に 3 プロセス目を置いたら CPU を取り合って europe が落ちたので、台を分けました。
ところが、これで asia1 と asia2 に処理が集まり、両方とも CPU が 100% に張り付きました。そこで午後は、重い処理の一部を、空いている europe1・america1 へ戻す作業になりました。
- 投稿の JPEG 検証(14:30〜14:36)
- Memories(15:50〜15:58)
- 画像の配信(16:39)
最後に分かった損得の基準は単純です。地元の台で返して得をするのは、DB を 1 往復で済ませられる API だけでした。コードを数えると DB を 2〜4 回続けて往復するフィードを地元に戻したら、Europe の 5 日目の人数が 232 人から 124 人に半減しました(16:43 に反映、16:48 に revert)。
4.2 SHA-NI の有無:署名の計算は台の種類で 7 倍違う
pprof を取ると、asia1 の CPU の半分以上が画像署名の SHA-256 でした。同じ計算でも、SHA-NI 命令のない c5.large は 1 件 1.07ms、ある c7i.large は 0.14ms でした(自分たちの計測)。
やったことは 4 つです。
- HMAC のオブジェクトを使い回す(11:37、+20〜30%)
- 同じ (鍵, 画像, exp) の署名 URL を、同じ秒の間だけ使い回す(11:54)
- 使い回しの幅を「同じ秒」から「30 秒以内」に広げる(16:17、48,679 → 50,856)。120 秒にしたら下がったので戻しました
- 署名の重い Memories を c7i 側で返す(15:50〜)
4.3 インデックス漏れは pg_stat_user_tables で探す
16:00 過ぎ、pg_stat_user_tables で全件走査(seq scan)の回数を見て驚きました。
login_sessions:seq scan 約 38 万回、index scan 0 回users:seq scan 約 19.7 万回(延べ約 10 億行)
セッションの確認と、5 プロセスが 200ms ごとに回すセッションのポーラーが、毎回全件を読んでいました。まず login_sessions(token_hash)・(user_id)・(invalidated_at) と users(updated_at) の 4 つを付けました(この回は 46,493 点)。続けて、投稿作成の副問い合わせ用に posts(author_id, posting_window_id) と posting_windows(local_date) も付けました。6 つ全部入れた回で 56,634 点(それまでの最高 50,856 点から +11%)になりました。
教訓:最初の 1 時間で見るべきでした。 スロークエリの上位には出てこない種類の漏れでした。
4.4 画像を地元の台のキャッシュから返す
Codex にとってもらった計測(#28281)では、america の画像取得は平均 0.50 秒かかっていました。本体(数百 KB)が帯域制限のある回線を毎回通っていたためです。
画像の取得はセッションを使わず、署名と DB の可否判定だけで答えられます。そこで europe1・america1 のローカルで返すようにしました。可否判定は毎回 DB に聞くので、「削除後 5 秒を過ぎたら取れない」という規則は守れます。本体はその台のメモリとディスクから返します。この 1 行の nginx 設定で 58,498 点になりました。
4.5 JPEG 検証の分担
投稿画像の全画素デコードは、c5 で 1 枚 239〜848ms かかりました(負荷により変動)。europe・america の投稿は、地元の c7i で先に検証し、検証済みの印を付けて asia1・asia2 へ転送する形にしました。転送先でのデコードは 0 回になりました。
5. 事故と復旧
13:42〜14:05、asia2 の OOM。 europe・america の投稿を地元で受ける実験の途中で、asia2(メモリ約 4GB)で America 用アプリ(RSS 約 1GB)と PostgreSQL が順に OOM kill されました。
- FAIL が 3 回続きました。原因はそれぞれ違います。
– 1 回目(#14032):America 用アプリの OOM
– 2 回目(#14664):PostgreSQL の OOM(接続が切れ、recovery mode の 500)
– 3 回目(#14878):PostgreSQL が
deactivating (stop-sigterm)のまま固まり、initialize が通らない - SIGQUIT で止めて WAL から復旧し、主要テーブルの件数と envcheck を確かめてから再開しました。
- 以降は America 用アプリに
GOMEMLIMIT=512MiBを付けて、再発を防ぎました。
後で他チームの記事を読むと、asia2 の PostgreSQL の OOM は、確認できた範囲でも 学生 2 位のチーム と 135 位のチーム で起きていました。メモリの少ない DB の台にアプリを相乗りさせるときの落とし穴だったようです。
他にも、次のことを試して戻しました。
- 3 プロセスで CPU を取り合った(13:13)
- 署名の使い回しを 120 秒にした(16:20)
- asia1 で asia 用プロセスの CPUWeight を上げた(16:03)
- フィードと検索を地元で返した(16:43〜16:48)
下がったら 1 回で revert を守れたのは良かった点です。
6. 最後まで分からなかった「asia が 18 人で止まる」問題
午後のほとんどのベンチで、asia の人数が 1 日目の 18 人から増えませんでした。ベンチの出力には「グランプリ: アジア 0 人」という回すらありました。asia1 の CPU の取り合いを疑って優先度を上げましたが、変わりませんでした。
原因が分かったのは 17:47 です。投稿作成のトランザクションが、投稿期間の行に共有ロック(FOR SHARE)を持ったまま、500KB の画像を INSERT していました。そのため、期間を開く UPDATE が待たされ、asia の新しい投稿期間がなかなか開かず、タイムアウトや 409 になっていました。
画像の INSERT をロックを取る前の文に分けて直し、最後の走行(59,430 点)に間に合わせました。同じトランザクションの中なので、409 のときは画像もロールバックされます。後で読んだ 135 位のチームの記事 にも、画像の INSERT をロックの前に移す近い修正がありました。
反省: ベンチの「地域ごとの人数」と「グランプリの人数」を毎回表にして、異常を数字で追えばもっと早く気づけたはずです。CPU の取り合いという仮説を、一次情報(ロック待ち)で確かめずに試してしまいました。
7. 上位チームとの差
参加記を 10 本ほど読んで、20 万点前後のチームと自分たちの差を整理しました。上位のチームは、ほぼ次の 4 つを入れていました。
- データを各地域の台に複製し、読み取りは地元で返す(自分たちは、DB に近い台へ転送するところで止まった)
- 画像の本体を DB の外(ファイル・mmap など)に出す(自分たちはキャッシュ止まり)
- 署名を SIMD で並列に計算し、初期画像の分を事前に計算し、期限を丸める(自分たちは 30 秒の使い回しまで)
- JPEG 検証を、画素を復元しない方式にする
4 は判断が分かれたところです。自分たちは「DecodeConfig にすると中身だけ壊れた JPEG を通してしまうので、仕様違反」と判断して見送りました。
- 優勝チーム は
DecodeConfigを採用していました。 - 学生 2 位のチーム は自分たちと同じ理由で見送り、標準ライブラリを複製した専用の検証器を作っていました(競技後に手元で測って 27.6ms → 1.15ms とのこと)。
「見送る」ではなく「仕様を守ったまま速くする」まで踏み込むべきでした。
全体として、自分たちは「DB 往復を減らす」「重い処理を空いている台へ移す」までを積み上げた形でした。上位のチームは早い段階で「この延長線上に上の点数はない」と見切り、構成そのものを変えていました。
8. AI エージェントを 3 体動かして分かったこと
良かったこと
- 実装・反映・判定を分けられました。「1 回に 1 つ変える → ベンチ → 記録 → コミット」を 8 時間守れたのは、記録係のエージェントがいたからです。
- 戻り先のコミットを常に 1 つ決めておけました。 終盤の「試して、下がったら戻す」を、1 回 3〜4 分で回せました。
- 片付けや再起動試験は手順書にして渡せました。トークンの安いエージェントに任せられました。
気をつけたこと・失敗
- エージェントの「有望な案」には、仕様違反の案や読み違いが混ざっていました。例えば「JPEG 検証を DecodeConfig にすれば一発」「CPUWeight の結果から CPU の取り合いが証明された」(ぶれを読み違えていた)といった案です。pprof・EXPLAIN・pg_stat_statements・ベンチの出力で確かめてから採用しました。
- 計測を外したり戻したりするときに、別のエージェントの未コミットの変更とぶつかりかけました。サーバーを操作する担当は 1 人に絞るべきでした。 10 以上のセッションを同時に動かしたチーム も、途中でデプロイの担当を 1 つに決めたそうです。
- 自分の書いたシェルの
$?が$(hostname)で上書きされ、「ルーターに届いている」と一瞬誤読しました。終了前の確認ほど、コマンドの取り違えに気をつけたいところです。
9. 終盤の運用
- 16:11 に片付けのリハーサルをしました。pprof と pg_stat_statements を外す → 4 台を再起動 → サービスの自動起動とデータの一致を確認 → envcheck → ルーター迂回が無いことを curl で確認(Timeout になれば OK)→ ベンチ、の順です。
- 16:43 と 16:57 に本番の手順を流しました。
- 17:00 にリーダーボードは止まりますが、最終スコアは 18:00 までの最後の走行です。 17:00 以降も粘り、asia の問題を直した版を最後の走行にしました(59,430 点)。点数だけなら 17:00 の 59,895 点のほうが高く、ぶれの範囲の差でした。
10. 次回への持ち越し
- 最初の 1 時間で、台ごとの CPU 特性(SHA-NI など)と、
pg_stat_user_tablesの seq scan を確認する - ベンチの地域別の人数を毎回表にして、伸びない地域を最優先で調べる
- 「地元で返す」を、API ごとの DB 往復回数から事前に見積もる
- 仕様のグレーな箇所は、「見送る」の前に「仕様を守ったまま速くする」方法を考える
- AI エージェントには、サーバー操作の担当を 1 人だけにする。判断材料(一次情報)を必ず添えさせる
おわりに
8 時間、毎回 100ms の往復を数え続けた大会でした。運営のみなさま、楽しい問題をありがとうございました。そして、OOM から復旧してくれた自分たち(とエージェントたち)、お疲れ様でした。