GitHub Actions から OIDC で ECR Public にログインする — 429 Data limit exceeded の解消

GitHub Actions のビルドが ECR Public からの取得で 429 Data limit exceeded エラーになりました。ジョブの既存の OIDC 認証情報で ECR Public にログインすると、取得が AWS アカウントの上限に計上され、匿名での上限不足が解消します。

白いカードに、黒い円から猫のシルエットを抜いたGitHubのInvertocatロゴと黒のロゴタイプ
目次

はじめに

私が関わっているプロジェクトで、GitHub Actions のデプロイ用ワークフローが Dockerfile の 1 行目で失敗するようになりました。ベースイメージを取得する FROM の行です。

ERROR: failed to copy: httpReadSeeker: failed open: unexpected status code https://public.ecr.aws/v2/docker/library/python/manifests/sha256:…: 429 Too Many Requests - Server message: toomanyrequests: Data limit exceeded

4 日間で 4 回失敗し、成功したのはその間の 1 回だけでした。main にマージした変更が dev 環境に届かなくなっていました。

このワークフローは、ベースイメージを Amazon ECR Public からログインせずに取得していたため、匿名の上限に達していました。ジョブの既存の OIDC 認証情報で ECR Public にログインすることで解消しました。これでビルドの取得は AWS アカウント自身の上限に計上されるようになりました。

GitHub Actions からの匿名取得が上限に達する理由

ECR Public は匿名の取得に固定の枠を設けていて、データ転送量とレートのどちらの上限も引き上げられません。Amazon ECR Public は public.ecr.aws にある AWS の公開コンテナレジストリで、docker/library/python のような Docker 公式イメージのミラーも置かれています。認証トークンなしの取得は匿名として扱われます。

匿名AWS アカウントでログイン
インターネットへの無料データ転送月 500 GB、引き上げ不可月 5 TB
取得のレート毎秒 1 回、引き上げ不可毎秒 10 回、引き上げ可

失敗した実行の中には Data limit exceeded を出したものと、429 Too Many Requests だけを出したものがありました。2 つの上限が同じステータスコードを返すと考えれば、どちらの出力も説明がつきます。

500 GB を使い切っていたのは、このワークフローではありません。1 回のデプロイで取得するのは 1 GB を大きく下回る量で、9 月下旬から 10 月中旬までのデプロイはすべて成功していました。ECR の料金ページによると、AWS アカウントを使わない場合のデータ転送は送信元 IP ごとに制限されます。GitHub ホストランナーは送信元アドレスをほかの GitHub 利用者と共有しているため、その利用者たちの取得も同じ枠から差し引かれていた可能性が高いです。

ログインすると、上限は AWS アカウントごとに計算されるようになり、同じ IP の背後にいるほかの利用者と共有しなくなります。インターネットへの転送は 1 アカウントあたり月 5 TB まで無料で、1 回のデプロイの取得量は 1 GB に満たないので、十分に収まります。

デプロイジョブを ECR Public にログインさせる

デプロイジョブは、AWS の認証情報とビルドの間にステップを 1 つ足すだけで直ります。aws-actions/amazon-ecr-login は AWS 公式のアクションで、ジョブの AWS 認証情報をレジストリのトークンに換えて docker login を実行します。registry-type: public を指定すると、ログイン先が public.ecr.aws になります。

.github/workflows/deploy-dev.yml
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ap-northeast-1
- name: Login to Amazon ECR Public
uses: aws-actions/amazon-ecr-login@v2
with:
registry-type: public
- name: deploy
run: make deploy

ログインすると、~/.docker/config.json に public.ecr.aws ホストのエントリが 1 つ書き込まれます。そのため、docker/library/python だけでなく lambda/python のベースイメージも含め、このホストのイメージすべてに効きます。cdk deploy は内部で docker build を実行し、そのビルドも同じファイルを読みます。CDK がプライベートレジストリにログインするときは別のホストのエントリを追加するので、このエントリには影響しません。

~/.docker/config.json(構造のみ)
{
"auths": {
"public.ecr.aws": { "auth": "…" },
"123456789012.dkr.ecr.ap-northeast-1.amazonaws.com": { "auth": "…" }
}
}

ログインだけ us-east-1 で、ジョブは東京のままでよい理由

このプロジェクトはほかのすべてを ap-northeast-1 で動かしていますが、ECR Public の API は us-east-1 と us-west-2 にしかありません。AWS のレジストリ認証のページにも、認証は常に us-east-1 に対して行うよう書かれています。

このアクションのソースコードは、パブリックレジストリのリージョンを us-east-1 に固定しています。そのため、ワークフローにこのステップを追加するときに、リージョンを別途設定する必要はありません。

aws-actions/amazon-ecr-login/index.js
const ecrPublicClient = new ECRPUBLICClient({
// …
// Authenticating to ECR Public auth only works in us-east-1
region: "us-east-1",

イメージの取得元は変わらず public.ecr.aws で、us-east-1 に行くのはトークンの要求だけです。us-east-1 には何も作られず、後続のステップはすべて ap-northeast-1 のままです。

GitHub Actions のジョブを ECR Public にログインさせるap-northeast-1 で動く GitHub Actions のジョブが、OIDC トークンを AWS STS で一時的な認証情報に交換する。その認証情報で us-east-1 の ECR Public API の GetAuthorizationToken を呼ぶと、12時間有効なトークンが返る。docker login がそれを public.ecr.aws 用に保存し、ビルドはログイン済みのクライアントとしてベースイメージを取得する。上限はアカウントの月 5 TB・毎秒 10 回になる。ログインしないと匿名の取得になり、上限は月 500 GB・毎秒 1 回で、429 Too Many Requests で失敗する。GitHub Actions のジョブap-northeast-1AWS STSAssumeRoleWithWebIdentityECR Public APIus-east-1GetAuthorizationTokendocker loginpublic.ecr.awsdocker buildFROM public.ecr.aws/…アカウントの上限月 5 TB毎秒 10 回OIDCトークン一時的な認証情報12時間のトークンログイン済みログインしない場合: 匿名の取得になり、上限は月 500 GB・毎秒 1 回429 Too Many Requests · toomanyrequests: Data limit exceeded
ジョブの OIDC 認証情報で us-east-1 から 12 時間有効な ECR Public のトークンを取得し、ビルドはログイン済みのクライアントとして public.ecr.aws から取得します。

必要な 2 つの権限と、組織の SCP の確認

ロールに必要なのは、レジストリのトークンを発行する ecr-public:GetAuthorizationToken と、ECR Public のレジストリ API が受け付けるベアラートークンを発行する sts:GetServiceBearerToken です。アクションの README に載っているのも、この 2 つだけです。

amazon-ecr-login の README にある最小ポリシー
{
"Effect": "Allow",
"Action": [
"ecr-public:GetAuthorizationToken",
"sts:GetServiceBearerToken"
],
"Resource": "*"
}

サービスコントロールポリシー(SCP)は、AWS Organizations が各アカウントの IAM ポリシーの上からかける制限です。IAM で許可されていても、us-east-1 への呼び出しを SCP が拒否することはありえます。よくあるのは、承認済みのリージョンだけを許可する SCP です。

私はワークフローに手を入れる前に、この呼び出しを拒否する SCP がないか Claude Code に確認させました。iam simulate-principal-policy を使えば、ジョブを実行せずに答えが出ます。AllowedByOrganizations の列が SCP の判定です。

ターミナルウィンドウ
入力
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/YOUR_DEPLOY_ROLE \
--action-names ecr-public:GetAuthorizationToken sts:GetServiceBearerToken \
--context-entries ContextKeyName=aws:RequestedRegion,ContextKeyValues=us-east-1,ContextKeyType=string \
--query 'EvaluationResults[].[EvalActionName,EvalDecision,OrganizationsDecisionDetail.AllowedByOrganizations]' \
--output text
出力
ecr-public:GetAuthorizationToken allowed True
sts:GetServiceBearerToken allowed True

CDK のデフォルトのシンセサイザーで、どのロールに何の権限が要るか

スタックのリソースを作るのは GitHub 用のロールではなく、CloudFormation の実行ロールです。シンセサイザーを指定していないスタックは CDK の DefaultStackSynthesizer を使い、cdk deploy は cdk-hnb659fds-* という名前のブートストラップロールを引き受けます。CloudFormation は、すべてのリソースを cdk-hnb659fds-cfn-exec-role-<account>-<region> の権限で作成します。

ロール引き受けるもの必要な権限
GitHub のデプロイ用ロールGitHub Actions(OIDC 経由)cdk-hnb659fds-* に対する sts:AssumeRole と、ECR Public の 2 つのアクション
cdk-hnb659fds-cfn-exec-role-…CloudFormationスタックが作成するすべてのリソースの権限
cdk deploy でどのロールが何をするかGitHub Actions のジョブが OIDC で GitHub のデプロイ用ロールを引き受けます。このロールには、CDK のブートストラップロール cdk-hnb659fds-deploy-role と cdk-hnb659fds-image-publishing-role を引き受けるための sts:AssumeRole と、ECR Public の 2 つのアクションが必要です。デプロイ用ロールはスタックを CloudFormation に渡し、CloudFormation は cdk-hnb659fds-cfn-exec-role を引き受けて、その権限で ECS や Lambda などのリソースを作成します。リソースの権限を持たせるべきなのは cfn-exec-role で、デフォルトのブートストラップはこれを AdministratorAccess 付きで作成します。GitHub のロールから sts:AssumeRole を外すと、ほかの権限を残していても cdk deploy は失敗します。GitHub Actions のジョブdeploy-dev.ymlGitHub のデプロイ用ロールsts:AssumeRole+ ECR Public の 2 アクションブートストラップロールcdk-hnb659fds-deploy-role…-image-publishing-roleCloudFormationスタックをデプロイスタックのリソースECS, Lambda, …cfn-exec-roleスタックが作成するすべてのリソースの権限AdministratorAccessOIDCsts:AssumeRoleスタックを渡す引き受ける作成GitHub のロールに sts:AssumeRole がないと、ほかの権限を残していても cdk deploy は失敗します
GitHub のロールに必要なのは、ECR Public の 2 つのアクションのほかは、ブートストラップロールを引き受ける権限だけです。リソースは CloudFormation が cfn-exec-role の権限で作成するので、その権限は cfn-exec-role に持たせます。

このアカウントでは両方のロールが AdministratorAccess を持っていたので、何も失敗しませんでした。この区別が効いてくるのは、GitHub 用のロールを絞り込むときです。ECS や Lambda の権限を残したまま sts:AssumeRole を外すと cdk deploy が壊れます。リソースの権限を持たせるべきなのは cfn-exec-role のほうで、デフォルトのブートストラップはこれを AdministratorAccess 付きで作成します。

プルリクエストのビルドには、トークンの取得しかできないロールを割り当てる

ここまでは main へのデプロイで起きた 429 の話でしたが、プルリクエストごとにもイメージのビルドが実行されます。このビルドも同じベースイメージを匿名で取得していたので、10 日間で 13 回、429 で失敗していました。

ただし、デプロイ用ロールはこのビルドには流用できません。デプロイ用ロールの信頼ポリシーが受け入れるのは main での実行だけで、しかも AdministratorAccess を持っているため、プルリクエストを信頼させれば、どのブランチにもその権限を渡すことになります。

私はプルリクエスト用に、ECR Public の 2 つのアクションだけを許可するロールを別に作成しました。プルリクエストからトリガーされたワークフローがこのロールを引き受けても、できるのは公開イメージを取得するためのトークンの取得だけで、AdministratorAccess の権限は得られません。

デプロイ用ロールと取得専用ロールGitHub の OIDC で引き受ける2つの IAM ロール。デプロイ用ロールは sub が :ref:refs/heads/main で終わる場合だけを信頼し、AdministratorAccess を持ち、main への push でデプロイのワークフローが使う。取得専用ロールは sub が :pull_request か :ref:refs/heads/main で終わる場合を信頼し、許可するのは ecr-public:GetAuthorizationToken と sts:GetServiceBearerToken だけで、すべてのプルリクエストでイメージのビルドが使う。Dependabot の実行にはシークレットが渡らないので、ログインを飛ばす。デプロイ用ロール信頼する sub の末尾:ref:refs/heads/main許可AdministratorAccessECR Public の2つのアクションも含む使うワークフローdeploy-dev.yml(main への push)取得専用ロール信頼する sub の末尾:pull_request:ref:refs/heads/main許可ecr-public:GetAuthorizationTokensts:GetServiceBearerToken使うワークフローci-docker.yml(すべてのプルリクエスト)Dependabot の実行はシークレットが渡らないので使わない
デプロイ用ロールは引き続き main だけを信頼します。プルリクエストのビルドは、2 つのトークン用アクションだけを許可する、別に作成したロールを引き受けます。

信頼ポリシーで pull_request イベントを指定する

GitHub の OIDC トークンには、リポジトリと実行のきっかけを表す sub クレームが入っています。pull_request イベントの場合、その末尾は :pull_request です。このリポジトリは GitHub のイミュータブルな subject 形式 owner@owner-id/repo@repo-id を使っています。2026 年 7 月 15 日より後に作られたリポジトリでは、この形式がデフォルトです。

取得専用ロールの信頼ポリシー
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": [
"repo:YOUR_ORG@111111/YOUR_REPO@222222:pull_request",
"repo:YOUR_ORG@111111/YOUR_REPO@222222:ref:refs/heads/main"
]
}
}
}

リポジトリがどちらの形式を使っているかは、gh api repos/<owner>/<repo>/actions/oidc/customization/sub で確認できます。新しいロールに対して iam simulate-principal-policy を実行すれば、2 つのアクションだけが許可され、それ以外は拒否されることがわかります。

ターミナルウィンドウ
入力
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/YOUR_PULL_ONLY_ROLE \
--action-names ecr-public:GetAuthorizationToken sts:GetServiceBearerToken ecr:GetAuthorizationToken s3:ListAllMyBuckets \
--query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output text
出力
ecr-public:GetAuthorizationToken allowed
sts:GetServiceBearerToken allowed
ecr:GetAuthorizationToken implicitDeny
s3:ListAllMyBuckets implicitDeny

Dependabot は ECR Public にログインせず、匿名で取得する

.github/workflows/ci-docker.yml
permissions:
id-token: write
contents: read
# …
- name: AWS credentials for the ECR Public login
id: aws
if: github.actor != 'dependabot[bot]'
continue-on-error: true
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ secrets.AWS_ECR_PUBLIC_ROLE_ARN }}
aws-region: us-east-1
- name: Login to Amazon ECR Public
if: steps.aws.outcome == 'success'
continue-on-error: true
uses: aws-actions/amazon-ecr-login@v2
with:
registry-type: public
  1. ロールの ARN はリポジトリのシークレットに置いています。そのため、ワークフローのファイルにはアカウント ID が出てきません。組織のリポジトリでは、write 権限を持つユーザーならシークレットを作成できます。
  2. Dependabot の実行ではログインを飛ばします。Dependabot がトリガーしたワークフローには GitHub が Actions のシークレットを渡さないため、role-to-assume が空になり、ステップが失敗します。Dependabot のプルリクエストは引き続き匿名で取得するので、429 に当たる可能性は残ります。
  3. ログインのステップは認証情報のステップの outcome を見て判定します。Dependabot の実行では認証情報のステップがスキップされて outcome が skipped になるので、ログインもスキップされ、ビルドは以前と同じく匿名で取得します。

まとめ

どちらのワークフローも ECR Public にログインするようになりました。そのため、取得は同じ IP のほかの利用者と共有する匿名の 500 GB ではなく、AWS アカウントの月 5 TB に計上されます。

デプロイジョブは既存のロールに IAM のアクションを 2 つ足して使い回しています。プルリクエストのビルドは別のロールを引き受けます。このロールは pull_request の subject を信頼し、取得用トークンの発行しかできません。

public.ecr.aws からビルドするワークフローなら、amazon-ecr-login のステップを 1 つと、ecr-public:GetAuthorizationToken と sts:GetServiceBearerToken の 2 つのアクションで同じ結果になります。プルリクエストのビルドには、デプロイ用ロールの信頼を広げるのではなく専用のロールを用意してください。

参考リンク

この記事をシェア