# 用 OIDC 讓 GitHub Actions 登入 ECR Public — 解決 429 Data limit exceeded 的問題

> GitHub Actions 的建置從 ECR Public 拉取映像時，出現了 429 Data limit exceeded 錯誤。使用該 job 既有的 OIDC 憑證登入 ECR Public 後，拉取就會計入 AWS 帳戶的配額，進而解決未登入時配額不足的問題。

- Source: https://oharu121.com/zh-tw/blog/github-actions-ecr-public-oidc-429-data-limit-exceeded/
- Published: 2026-10-24T20:16:24+09:00
- Tags: AWS, GitHub Actions, GitHub, Docker

---
## 引言

在我參與的一個專案裡，GitHub Actions 的部署 workflow 在執行 Dockerfile 的第一行、也就是拉取基礎映像的 `FROM` 時就失敗了：

```text
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
```

四天內失敗了四次，中間只成功過一次。這導致合併到 `main` 的變更一直沒有部署到 dev 環境。

這個 workflow 原本是在沒有登入的情況下，從 Amazon ECR Public 拉取基礎映像，因此遇到了配額不足的問題。**用 job 既有的 OIDC 憑證登入 ECR Public 之後，問題就解決了**。現在建置時的拉取會計入 AWS 帳戶自己的配額。

## 為什麼 GitHub Actions 的匿名拉取會配額不足？

**ECR Public 給匿名拉取的配額是固定的，資料傳輸量和拉取速率這兩項上限都不能調高**。Amazon ECR Public 是 AWS 的公開容器登錄服務，位於 `public.ecr.aws`，也提供 `docker/library/python` 這類 Docker 官方映像的鏡像。只要是沒有帶驗證 token 的拉取，都算是匿名拉取。

| | 匿名 | 以 AWS 帳戶登入 |
| --- | --- | --- |
| 免費的網際網路資料傳輸量 | 每月 500 GB，不可調整 | 每月 5 TB |
| 拉取速率 | 每秒 1 次，不可調整 | 每秒 10 次，可調整 |

失敗的那幾次執行，有的印出 `Data limit exceeded`，有的只印出 `429 Too Many Requests`。如果兩項上限回傳的是同一個狀態碼，這兩種輸出就都說得通。

**用掉那 500 GB 的並不是這個 workflow 本身**。一次部署拉取的量遠低於 1 GB，而且從 9 月下旬到 10 月中旬的部署全都成功了。根據 [ECR 的定價頁面](https://aws.amazon.com/ecr/pricing/)，在沒有登入的情況下，能夠拉取的量是依來源 IP 限制的。GitHub 託管的 runner 和其他 GitHub 使用者共用對外 IP 位址，所以這些拉取和其他使用者用的很可能是同一份配額。

登入之後，配額改為依 AWS 帳戶計算，不再和同一個 IP 背後的其他使用者共用。每個帳戶每月 5 TB 以內的網際網路資料傳輸量都免費，而一次部署的拉取量不到 1 GB，完全在這個範圍內。

## 讓部署 job 登入 ECR Public

**部署 job 只要在取得 AWS 憑證的步驟和建置之間新增一個步驟來登入就好**。`aws-actions/amazon-ecr-login` 是 AWS 官方的 action，會把 job 的 AWS 憑證換成登錄服務的 token，再執行 `docker login`。加上 `registry-type: public` 之後，登入的對象就是 `public.ecr.aws`。

```yaml title=".github/workflows/deploy-dev.yml" ins={5-8}
      - 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` 主機的項目，所以這筆登入資訊適用於這個主機上的所有映像，`docker/library/python` 和 `lambda/python` 的基礎映像都包含在內。`cdk deploy` 會自己執行 `docker build`，這個建置讀的也是同一個檔案。CDK 登入私有登錄服務時，新增的是另一個主機的項目，不會動到這一筆。

```json title="~/.docker/config.json（僅示意結構）"
{
  "auths": {
    "public.ecr.aws": { "auth": "…" },
    "123456789012.dkr.ecr.ap-northeast-1.amazonaws.com": { "auth": "…" }
  }
}
```

### 為什麼 job 設定的區域是東京，登入時卻要用 us-east-1？

這個專案的其他部分都在 `ap-northeast-1` 執行，但是 **ECR Public 的 API 只存在於 `us-east-1` 和 `us-west-2`**。AWS 的登錄服務驗證頁面也寫著，驗證請求一律要送到 `us-east-1`。

這個 action 的原始碼裡已經把公開登錄服務的區域寫死成 `us-east-1`，所以在 workflow 裡加這個步驟時，不需要另外設定區域：

```js title="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` 拉取，**只有 token 的請求會送到 `us-east-1`**。`us-east-1` 裡不會建立任何資源，之後的步驟也都維持在 `ap-northeast-1`。

*Figure — LoginFlow: job 用 OIDC 取得的憑證向 us-east-1 換得有效 12 小時的 ECR Public token，接著建置就以已登入的用戶端身分，從 public.ecr.aws 拉取映像。*

### 需要的兩個權限，以及確認組織的 SCP

job assume 的 role 需要兩個權限：`ecr-public:GetAuthorizationToken` 用來取得登錄服務的 token，`sts:GetServiceBearerToken` 用來取得 ECR Public 登錄服務 API 接受的 bearer token。這個 action 的 README 列出的也正是這兩個：

```json title="amazon-ecr-login README 裡的最小 policy"
{
  "Effect": "Allow",
  "Action": [
    "ecr-public:GetAuthorizationToken",
    "sts:GetServiceBearerToken"
  ],
  "Resource": "*"
}
```

service control policy（SCP）是 AWS Organizations 對組織內每個帳戶設下的限制。IAM policy 允許的動作，還要再通過 SCP 才會生效。**即使 IAM 允許，SCP 還是可能拒絕對 `us-east-1` 的呼叫**。最常見的例子，就是只允許核准區域的 SCP。

在修改 workflow 之前，我請 Claude Code 先確認有沒有這種 SCP。`iam simulate-principal-policy` 不用實際執行 job 就能回答這個問題，`AllowedByOrganizations` 欄位就是 SCP 的判定結果：

```bash
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 預設的合成器下，哪個 role 需要哪些權限？

**建立 stack 資源的不是 GitHub 的 role，而是 CloudFormation 的執行 role**。沒有指定合成器（synthesizer）的 stack 會使用 CDK 的 `DefaultStackSynthesizer`，這時 `cdk deploy` 會 assume 名為 `cdk-hnb659fds-*` 的 bootstrap role。CloudFormation 則以 `cdk-hnb659fds-cfn-exec-role-<account>-<region>` 的身分建立所有資源。

| role | 由誰 assume | 需要的權限 |
| --- | --- | --- |
| GitHub 的部署 role | GitHub Actions（透過 OIDC） | 對 `cdk-hnb659fds-*` 的 `sts:AssumeRole`，加上 ECR Public 的兩個動作 |
| `cdk-hnb659fds-cfn-exec-role-…` | CloudFormation | stack 建立的每一項資源所需的權限 |

*Figure — RoleChain: 除了 ECR Public 的兩個動作，GitHub role 只需要能 assume bootstrap role。資源是由 CloudFormation 以 cfn-exec-role 的身分建立的，所以資源的權限要放在 cfn-exec-role 上。*

在這個 AWS 帳戶裡，兩個 role 都有 `AdministratorAccess`，所以權限怎麼分都不會出錯；但只要有人開始縮小 GitHub role 的權限，這個分工就很重要了。如果在 GitHub role 上保留 ECS 或 Lambda 的權限，卻**拿掉 `sts:AssumeRole`，`cdk deploy` 就會失敗**。資源的權限應該放在 `cfn-exec-role` 上，預設的 bootstrap 建立它時就已經附上 `AdministratorAccess`。

## 為 pull request 的建置準備一個只能取得 token 的 role

之前都在談 `main` 部署時遇到 429 的情況，但其實每個 pull request 也都會執行一次映像建置，這個建置之前也是用匿名方式拉取相同的基礎映像，所以**十天內已經因為 429 失敗了 13 次**。

但是部署 role 沒辦法直接拿來給 pull request 的建置使用，因為部署 role 的 trust policy 只接受 `main` 上的執行，本身又擁有 `AdministratorAccess`，要是讓它信任 pull request，任何分支都能拿到這份權限。

我為 pull request 另外建立了一個 role，權限只有 ECR Public 的那兩個動作。由 pull request 觸發的 workflow assume 這個 role 之後，能做的就只有取得拉取公開映像用的 token，而拿不到 `AdministratorAccess` 的權限。

*Figure — TwoRoles: 部署 role 仍然只信任 main。pull request 的建置則 assume 另外建立的 role，這個 role 只允許那兩個動作，用來取得拉取公開映像用的 token。*

### 在 trust policy 裡指定 pull_request 事件

GitHub 的 OIDC token 帶有一個 `sub` 宣告，內容是 repo 名稱和觸發這次執行的原因。如果是 `pull_request` 事件，`sub` 的結尾會是 `:pull_request`。這個 repo 使用 GitHub 不可變的 subject 格式 `owner@owner-id/repo@repo-id`，2026 年 7 月 15 日之後建立的 repo 預設都是這個格式：

```json title="拉取專用 role 的 trust policy" {10-13}
{
  "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` 可以看到 repo 使用的是哪一種格式。對新的 role 執行 `iam simulate-principal-policy`，可以確認它允許那兩個動作，其他的則全部拒絕：

```bash
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，而是匿名拉取

```yaml title=".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. **role 的 ARN 放在 repo 的密鑰裡**，所以 workflow 檔案裡不會出現帳戶 ID。在組織的 repo 裡，只要有寫入權限，任何使用者都能建立密鑰。
2. **Dependabot 的執行會跳過登入**。由 Dependabot 觸發的 workflow 拿不到 GitHub Actions 的密鑰，如果不跳過，因為 `role-to-assume` 會是空值所以步驟就會失敗。最終 Dependabot 的 pull request 仍然是匿名拉取，所以還是有可能會遇到 429。
3. **登入步驟判斷的是取得憑證那個步驟的 `outcome`**。Dependabot 的執行會跳過取得憑證的步驟，這時 `outcome` 是 `skipped`，登入也會跟著跳過，這時建置就會和以前一樣匿名拉取。

## 總結

現在兩個 workflow 都會登入 ECR Public，**拉取改為計入 AWS 帳戶每月 5 TB 的配額，不再和同一個 IP 的其他使用者共用匿名的 500 GB**。

部署 job 沿用了原本的 role，只多加了兩個登入相關的動作。pull request 的建置則 assume 另外新建立的 role，這個 role 信任 `pull_request` 的 subject，而且只能取得拉取用的 token。

只要是從 `public.ecr.aws` 拉取基礎映像的 workflow，加上一個 `amazon-ecr-login` 步驟，再允許 `ecr-public:GetAuthorizationToken` 和 `sts:GetServiceBearerToken` 這兩個動作，就能解決配額不足的問題。同時請為 pull request 的建置準備專用的 role，不要放寬部署 role 的信任範圍。

## 參考連結

- [Amazon ECR Public 的服務配額，包括匿名拉取的 500 GB 上限與拉取速率](https://docs.aws.amazon.com/AmazonECR/latest/public/public-service-quotas.html)
- [Amazon ECR 定價，包括登入後的拉取每月 5 TB，以及匿名拉取依來源 IP 限制](https://aws.amazon.com/ecr/pricing/)
- [Amazon ECR Public 的端點，只有 us-east-1 和 us-west-2](https://docs.aws.amazon.com/general/latest/gr/ecr-public.html)
- [Amazon ECR Public 的登錄服務驗證，說明驗證請求要送到 us-east-1](https://docs.aws.amazon.com/AmazonECR/latest/public/public-registry-auth.html)
- [aws-actions/amazon-ecr-login](https://github.com/aws-actions/amazon-ecr-login)
- [GitHub 的 OIDC 參考文件，包括 pull_request 的 subject 與不可變的 subject 格式](https://docs.github.com/en/actions/reference/security/oidc)
- [在 GitHub Actions 中使用密鑰，包括誰能建立密鑰以及 Dependabot](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets)
