CI が自分自身と競合してレート制限に当たる — S3 をセマフォにして同時ビルドを絞った話

著者: なぎゆー公開日: 2026-04-02最終更新: 2026-07-25読了目安: 約 5 分
カテゴリ:開発スタック
GitHub ActionsCI/CDmonorepoDockerECRS3

モノレポで複数サービスの CI が同時に走ると、それぞれの docker build が Amazon ECR Public(public.ecr.aws)から同じ AWS Lambda Web Adapter を pull して、レート制限(toomanyrequests)に当たる。外から攻撃されたわけでもなく、自分の CI が自分の CI を殴っていた。S3 を共有セマフォにして同時ビルド数を絞り、リトライと組み合わせて解決した話。

はじめに

モノレポで複数サービスを回していると、CI が自分自身と競合するという妙な現象に当たる。外部から攻撃されたわけでも、無料枠を使い切ったわけでもない。自分の CI が、自分の CI の邪魔をしていた。

症状は docker build の失敗で、原因は Amazon ECR Public(public.ecr.aws)のレート制限(toomanyrequests)だった。本記事はその対処として、S3 を共有セマフォにして同時ビルド数を絞った話を書く。

何が起きていたか

前提として、モノレポでは変更のあったサービスだけをデプロイする。ワークフローのトリガーにパス条件を書いておき、services/<サービス名>/** や共有ライブラリが変わったときだけ動かす、というよくある構成だ。

ただ、共有ライブラリを触ると複数サービスのワークフローが一斉に発火する。これ自体は正しい挙動で、依存先が壊れていないか確かめられるという意味では安全装置でもある。問題は、その先で起きることだった。

各サービスの docker build は、それぞれベースイメージやサイドカー的なコンポーネントを同じ public.ecr.aws から pull する。自分の場合、コンテナをそのまま AWS Lambda 上で動かすための AWS Lambda Web Adapter を、各サービスが共通して取ってくる構成になっていた。つまり、同時に走ったビルドの数だけ、同じレジストリへ同じイメージの pull が飛ぶ。

ECR Public には匿名 pull のレート制限がある。数が重なれば当然そこに当たる。こうして、コードは何も壊れていないのに CI だけが赤くなる、という状態が定期的に発生していた。自分の CI 同士が資源を食い合っていたわけだ。

対処①:リトライ(ただし単体では足りない)

まず思いつくのはリトライだ。レート制限は時間が経てば解けるので、失敗したら少し待って再試行すればいい。

docker build をラップするシェルスクリプトを挟み、出力に toomanyrequests が含まれていたら最大 5 回・60 秒待ちで再試行するようにした。レート制限以外のエラー(本当のビルド失敗)で無駄に粘らないよう、メッセージを見て判定するのが小さな要点だ。ビルドが壊れているのに 5 分粘られても困る。

ただ、リトライだけでは根本的には弱い。同時に走っているビルドの数が多いままなら、待って再試行してもまた同じ混雑に突っ込む。混雑そのものを減らさないと、リトライは運任せになる。

対処②:S3 をセマフォにして同時ビルド数を絞る

そこで、同時に走る docker build の数に上限をかけることにした。

厄介なのは、この制御がワークフローをまたぐ必要があることだ。GitHub Actions の同時実行制御は、基本的にワークフローやジョブの単位で「重複を防ぐ」ためのもので、「別々のワークフローを合計 3 本まで」のような横断的な数の制限を素直には表現できない。サービス A のワークフローとサービス B のワークフローは、互いの存在を知らないまま並列に走る。

そこで、全ワークフローから見える共有の場所を用意して、そこを数取り場にすることにした。使ったのは S3 だ(ロック専用のバケットを 1 つ用意し、その中のオブジェクト数を「いま走っているビルドの数」として数える)。

仕組みはごく単純:

  1. ビルド前に、ロック用バケットの中にあるオブジェクトの数を数える。
  2. 上限(自分の設定では 3)未満なら、自分のロックを表すオブジェクトを 1 つ置いて先へ進む。
  3. 上限に達していたら、30 秒待って数え直す。空きが出るまでこれを繰り返す。
  4. ビルドが終わったら、自分のロックを消す。

ロックのキーにはワークフロー名・実行 ID・ジョブ名を組み合わせて、誰が握っているのかが一目で分かるようにした。障害時にバケットを覗けば「今どのワークフローが枠を持っているか」がそのまま読める。

解放は成功・失敗にかかわらず必ず走らせることが重要になる。ここを普通の後続ステップにすると、ビルドが失敗した瞬間にロックが置き去りになり、枠が 1 つずつ減って最終的に全部詰まる。失敗しても必ず実行される設定にしておく。

結果

セマフォとリトライを組み合わせてから、このレート制限でのビルド失敗はほぼ起きなくなった。

効き方が噛み合っているのが良いところで、セマフォが混雑そのものを抑え、リトライがそれでも漏れた分を拾う。どちらか片方だけでは、こうはならなかったと思う。上限 3 という数字は厳密に最適化したものではなく、詰まらず・待たされすぎない体感で置いている。

おまけ:CI 自身の変更でも CI を回す

もう一つ、この構成で気に入っている小さな工夫がある。ワークフローの発火条件に、そのワークフローファイル自身のパスを含めていることだ。

これは事故を踏んでから入れたのではなく、最初から見えていたリスクへの予防だった。CI の設定を直したのに何も起動せず、「直したつもり」のまま次のデプロイまで気づかない——という間抜けな状態があり得る。デプロイの手順を変えたなら、その変更自体でデプロイを一度回してみるのが自然だ。

おわりに

この件で面白かったのは、負荷の出どころが外部ではなく自分だったことだ。モノレポで共有ライブラリを触ると、意図どおり複数のパイプラインが一斉に立ち上がる。その「意図どおり」が、共有の外部リソースから見ると一斉射撃になっていた。

並列化は速さのために入れるものだが、並列の先に共有資源があると、速さは競合に変わる。そして自分のインフラの外側(ECR Public のようなレジストリ)にある資源は、こちらの都合でスケールしてはくれない。だったら、こちら側で流量を絞るしかない。

やったこと自体は「数を数えて、多かったら待つ」だけで、古典的なセマフォそのものだ。特別な仕組みは要らず、全員から見える置き場が一つあれば足りる。CI が自分自身と競合し始めたら、まず共有資源に何本同時に手を伸ばしているかを数えてみるといい。