きっかけは、溜まっていたPRを午後にまとめてマージしたときの、Actionsの画面でした。
自社サイトは Astro でビルドして S3 + CloudFront で配信しています。main に push されるたびに GitHub Actions がビルドして S3 へ同期する、ごく普通の構成です。その画面に、黄色い実行中のアイコンが4つ縦に並んでいました。1本ずつ順番に走るものだと、なんとなく思い込んでいたんですよね。
4つ同時に走ったデプロイは、全部「成功」で終わりました。では本番のバケットには、どのビルドの成果物が残ったのか。正直なところ、その場では答えられませんでした。ワークフローの成否は分かるのに、本番の状態がどれかは分からない。運用を考えると、これはかなり気持ちの悪い状態です。
そこで、手元に残っているものだけで「何が起きていたか」を復元することにしました。GitHub Actions の実行履歴とログ、Git の履歴、それから手元の MinIO コンテナ。本番の S3 には一切触っていません。
295回の実行履歴から「重なり」を数える
まず、対象のワークフローを確認しておきます。本番デプロイの定義はこういう形です(該当部分だけ抜粋しています)。
# .github/workflows/deploy.yml(抜粋)
on:
push:
branches: [main]
# concurrency: の指定は無い ← 今回の主役
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: |
npm ci
npm run build
- name: 本番バケットのバックアップ(prodBackup/)
run: |
aws s3 rm s3://$PREVIEW_BUCKET/prodBackup/ --recursive
aws s3 sync s3://$PROD_BUCKET/ s3://$PREVIEW_BUCKET/prodBackup/
- name: Deploy HTML(キャッシュなし)
run: |
aws s3 sync ./dist s3://$PROD_BUCKET/ --delete \
--cache-control "no-cache, no-store, must-revalidate"
- name: Deploy アセット(長期キャッシュ)
run: |
aws s3 sync ./dist s3://$PROD_BUCKET/ --exclude "*.html" \
--cache-control "public, max-age=31536000, immutable"
- run: aws cloudfront create-invalidation --paths "/*"
concurrency がありません。つまり main への push が続けば、その数だけジョブが同時に走ります。ここまでは定義を読めば分かる話で、問題は「実際にどのくらい重なっていたか」です。
gh run list で取れる実行履歴には、作成時刻と完了時刻が入っています。あるジョブの完了より前に次のジョブが作成されていれば、その2本は重なっていた、と判定できます。
# overlap_audit.py gh run list の JSON から「重なり」を数える
import json, datetime as dt
P = lambda s: dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
runs = sorted(json.load(open("deploy-runs.json")), key=lambda r: r["createdAt"])
pairs = [(a, b) for i, a in enumerate(runs) for b in runs[i + 1:]
if P(b["createdAt"]) < P(a["updatedAt"])] # 前が終わる前に後が始まった
involved = {r["databaseId"] for p in pairs for r in p}
# 連続して重なっている実行を1つの「クラスタ」にまとめる
clusters, cur, end = [], [runs[0]], P(runs[0]["updatedAt"])
for r in runs[1:]:
if P(r["createdAt"]) < end:
cur.append(r); end = max(end, P(r["updatedAt"]))
else:
if len(cur) > 1: clusters.append(cur)
cur, end = [r], P(r["updatedAt"])
if len(cur) > 1: clusters.append(cur)
print(len(runs), len(pairs), len(involved), [len(c) for c in clusters])
# 実行結果:
# 295 52 58 [4, 2, 2, 3, 2, 2, 2, 2, 2, 6, 2, 2, 3, 3, 2, 5, 2, 2, 2, 2, 4, 2]
# 295回中 58回(19.7%)が別の実行と重なり、22クラスタ。最大は6本並走(2026-06-14)
2026年3月26日から8月28日までの295回のうち、58回が他の実行と重なっていました。5回に1回です。1本あたりの所要時間は中央値で58秒なので、1分以内に2回 push されれば重なる計算になります。まとめてマージする日はまさにそれで、6月14日には12:34から12:36の2分間に6本が並走していました。
| 項目 | 実測値 | 補足 |
|---|---|---|
| 本番デプロイの実行回数 | 295回 | 2026-03-26 〜 08-28、すべて push トリガー |
| 所要時間(作成〜完了) | 中央値 58秒 | 最短20秒(初期の失敗)、最長622秒 |
| 他の実行と重なった回数 | 58回(19.7%) | 重なりペアは52組、クラスタは22個 |
| 最大の並走数 | 6本 | 06-14 12:34〜12:36。5本並走が07-19、4本が03-27と08-13 |
| ログが残っている重なり | 39回 / 25隣接ペア | 06-13以降。それ以前はログの保持期限切れで時刻しか追えない |
| s3 sync の位相まで重なったペア | 3組 | 重なり幅は 5.4秒 / 7.6秒 / 2.5秒。ほかに 0.06秒差で連続した組が1つ |
ただ、ジョブが重なっていても、S3 へ書き込む区間まで重なっているとは限りません。ジョブの前半は npm ci とビルドで30秒ほど、次にバックアップで10〜18秒、S3 への同期は最後の3〜11秒です。重なりの実害を見るには、ログを秒単位に戻す必要がありました。
ログを秒単位に戻すと、newsの一覧は1.5秒差だった
GitHub Actions のログは、行ごとにタイムスタンプが付いています。aws s3 sync はアップロードしたオブジェクトを1行ずつ出すので、どのファイルが何時何分何秒に書かれたかを、両方の実行について並べ直せます。
ログが残っている39回のうち、同期の区間まで重なった3組の中で、内容の違う2つのビルドが同じファイルを書いていたのは8月8日の1組でした。7月30日の記事を追加するPRと、8月2日の記事を追加するPRが12秒差でマージされたときのものです。2本のログを時刻順に混ぜると、こうなります。
# 2026-08-08 の2本を1本のタイムラインに戻したもの(A = 古い方 #347、B = 新しい方 #348)
01:14:12.936 A sync 開始(1回目: --delete 付き。A のビルドには 08-02 の記事が無い)
01:14:21.280 B sync 開始(1回目)
01:14:22.865 A news/2026-08-01-.../index.html
01:14:22.925 A news/index.html ← 08-02 の記事が載っていない一覧
01:14:23.016 A sitemap-0.xml
01:14:23.935 A sync 開始(2回目: --exclude "*.html")
01:14:24.145 B news/2026-08-02-.../index.html ← 新しい記事の本体
01:14:24.176 B sitemap-0.xml
01:14:24.471 B news/index.html ← 1.546秒後に正しい一覧で上書き
01:14:25.318 B sync 開始(2回目)
01:14:25.727 A sitemap-0.xml ← 2回目の sync が古い sitemap で上書き
01:14:27.308 B sitemap-0.xml ← さらに 1.6秒後に B が上書きし直す
# A の 1回目 sync: 258 upload / B の 1回目: 47 upload
# 2回目の sync が何かを upload したのは、ログの残る39回の中でこの2本だけ(sitemap-0.xml を1件ずつ)
一覧ページ news/index.html は、古いビルド A が 01:14:22.925 に書き、新しいビルド B が 01:14:24.471 に書きました。差は1.546秒です。順番が逆だったら、公開したばかりの記事は本番の一覧から消え、次のデプロイ(この日は8分後)まで戻らなかった。記事本体のURLは生きているので、誰かが気づく可能性は低い種類の事故です。
もう1つ、sitemap-0.xml の動きが目を引きました。4.3秒の間に4回書かれ、持ち主が3回入れ替わっています。しかも A の「2回目の sync」が古い sitemap を上書きしています。ログが残っている39回の中で、2回目の sync が何かをアップロードした実行はこの2本だけで、ほかの37回では常に0件でした。
理由は s3 sync の判定ルールにあります。s3 sync は、ローカルとバケットでサイズが違うか、ローカルの更新時刻の方が新しいときにアップロードします。A の2回目の sync が始まった時点で、バケットの sitemap-0.xml は B が1秒前に書いたもの。A の手元の sitemap とはサイズが違う。だから A は「更新が必要」と判断して、自分の(古い)sitemap を書き戻したわけです。相手の書き込みが、自分の再アップロードの引き金になる。ピンポンです。
この判定ルールは、別の日のクラスタでも顔を出していました。8月13日13:52にサムネイル追加のPRが4本、15秒間隔でマージされたときのアップロード件数は、順に 266件、3件、266件、5件でした。2本目が3件しか送っていないのは、1本目が直前に全ファイルを書いたので「バケットの方が新しく、サイズも同じ」と判定されて残りを飛ばしたから。3本目が266件に戻るのは、3本目のビルドが1本目のアップロードより後に終わったので、手元の全ファイルが「バケットより新しい」ことになったから。ここまで読めて、ようやく件数の意味が分かりました。
ドキュメントにはこの判定ルールが書いてあります。ただ、同じバケットに2本の sync が同時に向かうときに何が起きるかは、書いてありません。それは自分で確かめるしかない。
手元のMinIOで再現して分かった、危ないのは「後勝ち」ではなかった
本番の S3 で実験するわけにはいかないので、S3 互換の MinIO をローカルに立てて、AWS CLI の --endpoint-url を向けました。手元だけで完結し、課金も外部通信もありません。
確かめたかったのは3つです。古いビルドが後から走ると新しい記事を消すのか。中身が同じでも更新時刻が新しければ送り直すのか。そして、2本の sync が本当に同時に走ったとき、最後に残るのはどちらか。
# 手元の MinIO(127.0.0.1:9000)に対して aws s3 sync の挙動を確かめる
EP="--endpoint-url http://127.0.0.1:9000"
# A = 古いビルド(記事 a だけ)、B = 新しいビルド(記事 a と b、一覧にも b が載る)
# (1) 順番が逆転したケース: B が先に配備され、その後に古い A が --delete 付きで走る
aws $EP s3 sync ./distB s3://prod/ --delete
aws $EP s3 sync ./distA s3://prod/ --delete
# 実行結果:
# delete: s3://prod/news/b/index.html ← 新しい記事が消える
# upload: distA/news/index.html ← 一覧も古いものに戻る(サイズが違うため)
# news/a/index.html と app.css は送られない(バケットの方が新しく、サイズが同じ)
# (2) 中身は同一、更新時刻だけ新しい(cp でコピーしただけ)
cp -r distB distB2 && aws $EP s3 sync ./distB2 s3://prod/ --delete
# 実行結果: 4ファイルすべて upload。バイト列が同じでも mtime が新しければ送り直す
# → 本番で毎回 266件アップロードされていたのはこれ。ビルドのたびに mtime が新しくなる
# (3) 2000ファイルの A と B を 1秒ずらして同時に走らせる(中身は同サイズで別内容)
( aws $EP s3 sync ./bigA s3://prod/ --delete > A.log ) &
sleep 1
( aws $EP s3 sync ./bigB s3://prod/ --delete > B.log ) &
wait
# 実行結果:
# A: 2002 upload(+4.8秒で完了) B: 1617 upload(+5.5秒で完了。B の方が後に終わった)
# バケットに残ったのは A 版 386ファイル / B 版 1614ファイル
(1) と (2) は予想どおりでした。順番が逆転すれば --delete が新しい記事を消し、一覧も古いものに戻る。中身が同じでも毎回全件送る。8月の実ログと整合します。
(3) が、想定外でした。
最初は「後に終わった方が勝つ」つまり最悪でも last-writer-wins だと思っていたんですよね。B の方が0.7秒後に終わっているのだから、最終状態は B のはずだと。ところが残ったファイルの19.3%、386件は A の中身でした。B は2000件のうち383件を送っていない。B が sync を始めた時点で、A はすでに385件ほど書き終えている。B はその385件を「バケットの方が新しくて、サイズも同じ」と判定し、最新版が置いてあるものとして飛ばした。実際に置いてあったのは、古いビルドの成果物です。
つまり危ないのは「古い方が後から上書きする」だけではない。新しい方が、古い方の書き込みを最新と誤認して黙る。 これは順番を運良く守れても起きます。8月13日に2本目が3件しか送らなかったのと同じ仕組みで、あのときは差分の3件がたまたま全部サイズ違いだったから助かっただけです。1文字直しただけの修正のように、サイズが変わらない差分は、古い方の版が本番に残ります。
今思えば、--size-only や --exact-timestamps の存在は知っていたのに、「同時に2本走る」前提でこの判定ルールを読み直したことがありませんでした。
直列化の設計判断(concurrency と queue: max、そしてコスト)
対策は「同時に走らせない」の一択なのですが、やり方には幅があります。運用を考えると、比べておくべきなのはこの4つでした。
| 選択肢 | 重なりへの効き方 | 追加コスト | 引っかかる点 |
|---|---|---|---|
| concurrency + cancel-in-progress: true | 実行中を止めて最新だけ走らせる | 0円 | sync の途中で止めると本番が中途半端になる。デプロイには使わない |
| concurrency + cancel-in-progress: false | 実行中は守り、待ちは1本だけ保持 | 0円 | 待ち枠は1つ。4本続けば中2本は捨てられる(最終状態は正しい) |
| concurrency + queue: max | 最大100本を到着順に直列実行 | 0円(待ち時間は 約1分 x 本数) | 2026年5月に追加された比較的新しい指定。cancel-in-progress: true とは併用不可 |
| ビルドとデプロイの分離(成果物を1つに集約) | デプロイ単位そのものを1本にする | 設計・改修工数 | 本命だが今回の規模には重い。concurrency と併用して段階的に |
まず、費用は論点になりませんでした。重なった58回ぶんの無駄な PUT は 266件 x 58回でも1万5千リクエスト程度、S3 の PUT 単価で10円ほどです。CloudFront の無効化も /* は1パス扱いなので月1,000パスの無料枠に収まっています。Actions の実行時間も295回で合計5時間ほど。どの選択肢を取っても月額はほぼ変わらない。変わるのは「本番にどのビルドが残るか」を説明できるかどうかだけです。
次に、ここが一度引っかかったところなんですが、cancel-in-progress: false だけでは足りません。ドキュメントにはこう書いてあります。同じグループで待てるのは1本で、新しい実行が来ると、待っていた方は置き換えられる。実行中は守られるが、待ち行列は長さ1。8月13日のように4本続くと、1本目が走り、2本目と3本目は待ち枠から押し出されて、4本目だけが残る。最終状態は4本目なので結果は正しいのですが、実行履歴に「キャンセル」が2本並ぶことになり、当日の自分ならまた首をかしげていたと思います。
ぶっちゃけ、これで長いこと運用しているチームは多いはずで、最終状態さえ正しければ困らない場面がほとんどです。ただ、この構成には「バックアップは1世代だけ、しかも取り直しのたびに前の世代を消してから作る」という別の弱点があり、2本目と3本目が黙って消えると、消えた分のバックアップ世代も存在しないことになる。それなら1本ずつ全部走らせて、毎回バックアップも取らせたい。到着順に最大100本まで待たせられる queue: max を選びました。
# .github/workflows/deploy.yml に足す部分
concurrency:
group: production-deploy # main へのデプロイは常に同じグループ
queue: max # 到着順に1本ずつ。cancel-in-progress は書かない(true と併用不可)
これで、同じ日にまとめてマージしても、デプロイは到着順に1本ずつ走ります。待ち時間は1本あたり約1分なので、6本並んでも5分待ち。まとめてマージする運用と相性は悪くありません。
ただ、正直に書いておくと、この2行は「重ならなくする」だけで、根っこは残っています。改善の余地がある点を挙げておきます。1本ずつ走っても、s3 sync の判定が「サイズか更新時刻」であることは変わらないので、サイズの変わらない修正を、バケットより古い更新時刻のファイルで送ると相変わらず飛ばされます(毎回ビルドし直しているので通常は新しくなりますが、キャッシュから復元した成果物を配備する構成に変えた瞬間に踏みます)。バックアップが1世代で、しかも削除してから取り直す設計も手つかずです。HTML 用のはずの1回目の sync が全ファイルを対象にしている件も、以前に別で扱ったのでここでは触れませんが、直っていません。そして本番の状態をビルドの成果物と突き合わせて検証する仕組みは、依然としてありません。ワークフローの「成功」は、本番が正しいことを意味していないままです。
振り返って
「デプロイが成功した」と「本番が意図した状態になっている」は別のことでした。ワークフローの成否はジョブ単体の話で、本番の状態は複数のジョブの書き込み順で決まる。今回はログが残っていたから、1.5秒差や386件という数字まで戻せましたが、ログの保持期限を過ぎた6月13日以前の11クラスタについては、時刻の重なりまでしか追えていません。実害が出た証拠は見つからず、出なかった証拠もありません。
見直したい順番も決まりました。まず concurrency の2行を入れて重なりをなくす。次にバックアップを世代管理に変える。そのうえで、ビルドとデプロイを分けて「配備する成果物は常に1つ」にする。最後に、配備後に本番のオブジェクト一覧と dist を突き合わせる検査を足して、ワークフローの緑を信じなくても済むようにする。
マネージドな仕組みで済むところはそれに寄せるのが私の基本方針ですが、今回の一番の学びは、そのマネージドな仕組み(s3 sync)が「1本ずつ使われる」前提で設計されている、という当たり前のことでした。前提が崩れたときの挙動は、ドキュメントの外側にあります。手元で再現できる範囲は、再現してから信じることにします。