AWS 2026年7月21日

GitHub ActionsのAWSキーを0本にしたら、PRプレビューだけが静かに落ちた話

XECIN IAMOIDCGitHub Actions技術検証

以前、シークレット管理の記事を書いたときに「OIDCにはまだ移行していない」と正直に書きました。あれから数か月、キーのローテーション時期が近づいてきて、いよいよ手をつけることにしました。

きっかけは高尚な理由ではありません。年に一度のローテーション作業が、単純に面倒だったからです。

IAMユーザーのアクセスキーを再発行して、GitHubのSecretsを書き換えて、デプロイが通ることを確認して、古いキーを無効化する。作業自体は30分程度なんですが、「前回どのキーをどこに置いたか」を思い出すところから始まるのが毎回つらい。運用を考えると、そもそもローテーションが要らない状態にしたほうが安い、という判断です。

まず現状を数えるところから

移行の前に、長期キーが何箇所に散っているかを数えました。結果は5箇所です。

  • deploy.yml(本番デプロイ)に4ステップ分
  • preview.yml(PRプレビュー)に1ステップ分

本番デプロイのワークフローは、S3バックアップ・HTML同期・アセット同期・CloudFrontのキャッシュパージという4つのステップに分かれていて、それぞれのステップで同じ環境変数を書いています。

# 移行前の deploy.yml(抜粋)。同じキーが4回登場する
- name: Deploy HTML(キャッシュなし)
  run: |
    aws s3 sync ./dist s3://${{ secrets.PROD_BUCKET }}/ --delete \
      --include "*.html" \
      --cache-control "no-cache, no-store, must-revalidate"
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    AWS_DEFAULT_REGION: ap-northeast-1

- name: CloudFront キャッシュパージ
  run: |
    aws cloudfront create-invalidation \
      --distribution-id ${{ secrets.CF_DISTRIBUTION_ID }} --paths "/*"
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    AWS_DEFAULT_REGION: ap-northeast-1

数えていて気になったのは箇所の多さより、本番デプロイとPRプレビューが同じIAMユーザーの同じキーを使っていることでした。プレビュー環境は文字通り誰かのPRのコードをビルドして実行する場所です。そこに本番バケットを消せる権限を持ったキーが降りてくる構図は、あらためて文字にすると気持ちのいいものではありません。

このときは「OIDC移行のついで」くらいに思っていました。今思えば、ここが今回の作業の本題でした。

ロールは1本か、2本か

設計の入口で迷ったのは、引き受けるIAMロールを1本にするか複数に分けるかです。

ロール1本のほうが管理は明らかに楽です。信頼ポリシーも権限ポリシーも1つで済むし、ワークフロー側もロールARNを1つ持てばいい。一方でそれは、いま長期キーで起きている「本番とプレビューが同じ権限」という構図をそのまま持ち込むだけでもあります。

結論としては2本に分けました。判断の理由は、OIDCにすると分けるコストがほぼゼロになるからです。IAMユーザーだった頃は、ロールを分ける=キーを2組管理するという意味だったので、正直そこまでやる気になれなかった。ロールなら発行物が増えないので、分けない理由のほうが弱くなります。

マネージドの仕組みに乗り換えるときは、単に置き換えるだけでなく「乗り換えたから安くなる設計判断」を一緒に拾うようにしています。今回はそれが権限分割でした。

移行手順そのものは短い

AWS側でやることは、OIDCプロバイダの登録と、ロールの信頼ポリシー設定の2つだけです。

# 1. OIDC プロバイダを登録(アカウントに1つあればいい)
aws iam create-open-id-connect-provider \
  --url "https://token.actions.githubusercontent.com" \
  --client-id-list "sts.amazonaws.com"

# 2. 本番デプロイ用ロールの信頼ポリシー
cat > trust-prod.json <<'JSON'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {
      "Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
    },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
      },
      "StringLike": {
        "token.actions.githubusercontent.com:sub": "repo:hiroki2288-x/xecin-site:ref:refs/heads/main"
      }
    }
  }]
}
JSON

# 3. ロールを作成
aws iam create-role \
  --role-name xecin-site-deploy-prod \
  --assume-role-policy-document file://trust-prod.json

ここで一番大事なのは sub の条件です。ドキュメントを読むと当たり前のことが書いてあるだけに見えるんですが、この条件を書き忘れると、世界中のどのリポジトリのワークフローからでもこのロールを引き受けられる状態になります。ロールARNは秘密ではないので、隠しているつもりでも守りにはなりません。

audsub で書き方を変えているのも意図的です。audsts.amazonaws.com 固定なので StringEqualssub は将来的にブランチやenvironmentのパターンを増やす可能性があるので StringLike にしています。ここは好みが分かれるところですが、固定値をワイルドカード比較にしておく理由はないと考えています。

想定外だったこと:プレビューだけが落ちた

本番デプロイは一発で通りました。長期キーを消して、mainにマージして、S3が更新されてCloudFrontのパージも走る。ここまでは気持ちよかったんです。

問題は次のPRを出したときでした。プレビュー環境へのデプロイステップが Not authorized to perform sts:AssumeRoleWithWebIdentity で落ちました。

原因を切り分けるまでに少し時間を使いました。プレビュー用のロールにも同じ発想で ref:refs/heads/... の条件を書いていたのですが、pull_request トリガーで走るワークフローの sub は、ブランチ名の形をしていません。実際に発行されるトークンの sub はトリガーによってこう変わります。

トリガーsub の値絞り込みの効き方
push(main)repo:OWNER/REPO:ref:refs/heads/mainブランチ単位で厳密に絞れる。本番向き
pull_requestrepo:OWNER/REPO:pull_requestPR単位・ブランチ単位では絞れない。権限側で絞る前提
タグ pushrepo:OWNER/REPO:ref:refs/tags/v1.0.0リリース用ロールを分けるなら使える

つまり、プレビュー用ロールの信頼ポリシーはこう書くのが正解でした。

{
  "Condition": {
    "StringEquals": {
      "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
      "token.actions.githubusercontent.com:sub": "repo:hiroki2288-x/xecin-site:pull_request"
    }
  }
}

ここで学びだったのは、sub の絞り込みは本番とPRで粒度がそもそも違うという点です。本番はブランチまで特定できるので信頼ポリシー側で絞りきれる。PRは「このリポジトリのPRである」ところまでしか言えないので、残りは権限ポリシー側で絞るしかない。ロールを1本で通そうとしていたら、この差に気づかないまま repo:OWNER/REPO:* のような広い条件で妥協していたはずです。

結果的に、ロールを2本に分けた判断がここで効きました。設計判断が正しかったというより、分けていたから落ちて気づけたという順序です。

ワークフロー側の差分

ワークフローの修正はシンプルですが、1点だけ忘れやすい設定があります。

permissions:
  id-token: write   # これがないと OIDC トークンが発行されない
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/xecin-site-deploy-prod
          aws-region: ap-northeast-1

      # 以降のステップから env のキー指定が丸ごと消える
      - name: Deploy HTML(キャッシュなし)
        run: |
          aws s3 sync ./dist s3://${{ secrets.PROD_BUCKET }}/ --delete \
            --include "*.html" \
            --cache-control "no-cache, no-store, must-revalidate"

permissionsid-token: write を書かないとトークンが発行されず、原因が分かりにくいエラーになります。ジョブ単位でも書けますが、うちはワークフロー先頭にまとめました。

副次的な効果として、各ステップの env ブロックが丸ごと消えて deploy.yml が20行ほど短くなりました。同じ3行を4回書いていたわけなので、当然といえば当然です。

プレビュー側の権限を絞る

sub で絞りきれない分、プレビュー用ロールの権限ポリシーは狭くしました。

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
    "Resource": [
      "arn:aws:s3:::<PREVIEW_BUCKET>",
      "arn:aws:s3:::<PREVIEW_BUCKET>/pr-*"
    ]
  }]
}

プレビュー用ロールからは、本番バケットにもCloudFrontにも一切触れません。長期キーを共用していた頃は、プレビューのステップが aws s3 rm s3://本番バケット --recursive を実行できる状態だったわけで、そこを潰せたのが今回いちばん実利のあった部分だと考えています。

ただ、このポリシーはまだ改善の余地がありますpr-* というprefixで絞ってはいるものの、同じプレビューバケットには本番のバックアップを置く prodBackup/ があります。prefixが一致しないので現状は触れませんが、「バケットを分ける」ほうが構造としては正しい。それと、ここまでのIAMリソースはCLIとマネジメントコンソールの手作業で作っていて、コード管理できていません。手順を記事に書ける程度には再現性がありますが、次に同じことをやるときにまた手で作るのは避けたいところです。

コストと運用の変化

数字で整理すると、こうなりました。

項目移行前移行後
長期クレデンシャル2本(IAMユーザー1つ分)0本
ワークフロー内のキー記述5箇所(deploy 4 / preview 1)0箇所
認証情報の有効期限無期限(手動ローテーション)ジョブ実行中のみ
プレビューの権限本番と同一(本番削除まで可能)プレビューバケットの pr-* のみ
年間の運用作業ローテーション 年1回 30分+事故時の緊急対応なし

AWS側の課金は増えていません。OIDCプロバイダもIAMロールも無料なので、金額としてのコストはゼロで、消えたのは運用の手数と「キーが漏れたらどうするか」という宿題のほうです。移行作業そのものは、ハマった時間を含めて2時間ほどでした。

これから

ロールを分けたことで、次に何をすべきかが見えやすくなりました。

prodBackup/ をプレビューバケットに同居させている構成は、権限の境界を引きにくくしているので分離したい。IAMリソースの手作業も、この規模ならCloudFormationの小さなテンプレート1枚で足りるはずなので、そのうち書きます。

それと、今回いちばん怖かったのは移行の失敗そのものではなく、信頼ポリシーの条件を1行書き忘れるだけで穴が開くという性質のほうでした。CI/CDの認証まわりは、動いていることと安全であることが見た目でほとんど区別できません。動いた時点で満足せず、subaud が意図どおり効いているかを別リポジトリから試す、くらいの確認は次回から手順に入れておこうと思います。