サーバーレス前提の自動配信システムを題材に、「GitHub Actions から AWS へ安全にデプロイする」仕組みを、つまずきやすいポイントごとに解説します。キーレス認証(OIDC)と、稼働中システムを壊さないための「夜間デプロイ」設計が主題です。
対象は AWS + Terraform/OpenTofu + GitHub Actions でデプロイを回している(あるいはこれから組む)チームです。なお、本記事では実アカウント ID やリポジトリ名は伏せ字(<ACCOUNT_ID> など)にしています。
この記事で扱うこと
- GitHub Actions が AWS のアクセスキーを持たずにデプロイできるのはなぜか(OIDC)
- その信頼関係を「最初の 1 回」どう作るか(鶏と卵問題)
- 「マージしたら即デプロイ」をやめて 「即時 / 夜間」を選べるようにした理由
- 無人で
applyを走らせても事故らないための前提と安全装置
1. なぜ「AWS キー」を置かなくてもデプロイできるのか
GitHub Actions から AWS を操作する、と聞くと多くの人がまず「AWS のアクセスキーを GitHub の Secrets に登録しているんでしょ?」と考えます。今回の構成では登録していません。 長期のアクセスキー/シークレットキーはどこにも保管していないのです。
代わりに使うのが GitHub OIDC(OpenID Connect)フェデレーション。実行のたびに発行される、数十分で失効する一時クレデンシャルを使います。
流れ
全体像は下図のとおりです。GitHub が発行する短命の署名トークン(id-token)を AWS STS が検証し、デプロイ用ロールの一時クレデンシャルに引き換えます。
- job 側で
permissions: id-token: writeを付与すると、GitHub が「この run はrepo:<ORG>/<REPO>で動いている」と署名した短命の JWT(id-token)を発行する aws-actions/configure-aws-credentialsがその JWT を AWS STS に提示し、sts:AssumeRoleWithWebIdentityを呼ぶ- AWS STS が IAM の OIDC IdP とロールの信頼ポリシー条件を検証し、デプロイ用ロールの「一時クレデンシャル(数十分で失効)」を発行する
tofu applyがその一時クレデンシャルで AWS を操作する
ワークフロー側はこれだけ
permissions:
id-token: write # ← OIDC トークンを取得する許可(キーレスの鍵)
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::<ACCOUNT_ID>:role/<deploy-role>
aws-region: ap-northeast-1
role-to-assume に書くのはロールの ARN という公開情報であって、クレデンシャルではありません。秘密の文字列を一切置かないのがポイントです。
AWS 側の「信頼ポリシー」
AWS 側には、GitHub の OIDC プロバイダ(https://token.actions.githubusercontent.com)を IAM に登録し、デプロイ用ロールの信頼ポリシーで「どの GitHub から来たトークンを信じるか」を条件で縛ります。
| 条件 (claim) | 値 | 意味 |
|---|---|---|
aud | sts.amazonaws.com | AWS STS 向けのトークンに限定 |
sub(前方一致) | repo:<ORG>/<REPO>:* | このリポジトリからの run のみ assume 可(branch / PR / tag は問わない) |
つまり「AWS キーがあるから動く」のではなく、**「GitHub が “このリポの run だ” と OIDC で証明 → AWS がその証明を信頼して一時クレデンシャルを貸す」**という信頼関係を作り込んでいるわけです。
キー方式と比べて何が嬉しいか
| 観点 | 長期アクセスキー | OIDC(採用) |
|---|---|---|
| 漏洩リスク | Secrets に保管。漏れたら悪用される | 保管する秘密がない |
| 失効 | 手動ローテーションが必要 | 数十分で自動失効 |
| 利用範囲 | キーを持てば誰でも | sub 条件で特定リポのみ |
2. 鶏と卵問題 —「最初の 1 回」だけは手動
ここで素朴な疑問が出ます。
その「デプロイ用ロール」も Terraform で作るんでしょ? じゃあ最初は誰が作るの?
鋭い。GitHub Actions はそのロールを assume して動くのに、ロールがまだ無い初回は assume する先が存在しない — これが鶏と卵問題です。
解決策はシンプルで、OIDC の信頼関係を最初に一度だけ作る apply は、人間が自分の AWS 管理者クレデンシャルでローカル実行する。このために、信頼関係や state 保管用バケットを作る部分を bootstrap という別スタックに分離しておきます。
cd infra/bootstrap
mv backend.tf backend.tf.disabled # 初回は手元のローカル state で
tofu init
tofu apply # ← ここで OIDC IdP + デプロイ用ロール + state バケットが出来る
mv backend.tf.disabled backend.tf
tofu init -migrate-state # state を S3 へ引っ越し
この 1 回が済めば信頼関係が AWS 上に存在するので、以降は手動クレデンシャルを一切使わず、GitHub Actions が OIDC で回します。手動 apply が再び必要になるのは「新しい AWS アカウントへ移設する」「state バケットを作り直す」といった初期化のときだけです。
3. 「マージしたら即デプロイ」を見直した理由
最初の構成は、よくある形でした。
on:
push:
branches: [main]
paths: ['infra/**']
main に infra/** の変更が入ると即 tofu apply。手軽ですが、稼働中システムではこんな不安が出ます。
デプロイで関数が入れ替わる瞬間に、ユーザーがフォーム送信したり、メッセージング連携の友だち追加が走ったらどうなる?
まず安心な事実:1 リクエストは「割れない」
サーバーレス関数のコード差し替えは呼び出し単位でアトミックです。
- 更新の瞬間に実行中の呼び出しは旧コードで最後まで完走する
- 更新後の新規呼び出しは新コードで動く
- 1 回のリクエストが「途中まで旧・途中から新」に割れることはない
なので「フォーム送信が中途半端に処理される」は構造上起きません。
でも、1 つだけ弱い経路があった
我々のシステムには、友だち追加に対して同期的に・数秒以内に応答メッセージを返す経路がありました。これは入れ替え時のコールドスタートに最も弱い。しかも失敗してもプラットフォーム側の再送は、重複防止のため仕様上抑止されます。つまり取りこぼすとリトライが効かない唯一の経路だったのです。
ここから出した結論:
デフォルトのデプロイは、こうした流入が少ない深夜に倒したい。 ただし、すぐ反映して動作確認したい変更は即時でやりたい。
4. 解決策:即時 / 夜間を「都度選べる」ようにする
push トリガーをやめて、起動経路を 2 つに分けました。
on:
schedule:
- cron: '0 17 * * *' # UTC 17:00 = 02:00 JST(夜間バッチ)
workflow_dispatch: # = 即時デプロイ(手動 run)
| 経路 | 起動方法 | いつ使う |
|---|---|---|
| 即時 | gh workflow run deploy.yml(または画面の Run workflow) | マージ後すぐ反映して検証したい変更 |
| 夜間 | 何もしない(毎日 02:00 JST の cron が main の現状を適用) | 急がない変更(デフォルト) |
この設計の良いところ:
- デフォルトが「夜間」に倒れる — 何もしなければ深夜(低トラフィック帯)に反映され、弱い経路への曝露が最小になる
- 取りこぼしゼロ — 即時 run を忘れても、夜間 cron が必ず拾うので「永久に未デプロイ」が起きない
- 即時は意識的な操作になる — ボタンを押す=今の
mainの状態を把握した上でのデプロイ
トレードオフは「即時がワンクリックでなくなる」ことだけ。merge = deploy の手軽さは捨てましたが、安全側のデフォルトを得ました。
5. 無人で apply するなら、plan は事前に人が見ておく
夜間 cron は tofu apply -auto-approve を無人で走らせます。ここで怖いのは、replace や destroy を含む「驚きのある変更」が、誰のレビューも経ずに深夜に適用されてしまうこと。
そこで前提を 1 つ足しました。
applyは「PR の時点でレビュー済みのplanを適用するだけ」にする。
PR を開くと、pull_request トリガーで tofu plan だけを実行し(apply はしない)、結果を PR にコメントするワークフローを追加しました。レビュアーはマージ前にこの plan を見て、replace/destroy が無いかを確認します。
on:
pull_request:
paths: ['infra/**']
# tofu plan -lock=false -no-color の結果を PR コメントに投稿(apply はしない)
planは-lock=false(読み取りのみ)で実行し、夜間 apply の state ロックと競合させない- 同じデプロイ用ロールを OIDC で使う(信頼ポリシーが
repo:<ORG>/<REPO>:*なので PR からも assume 可) - fork からの PR は信頼できないコードに書き込み権限ロールを使わせないよう
ifでガード
これで「夜間の無人 apply = 確認済み変更の適用」となり、安心して放置できます。
おまけ:永続ドリフトは潰しておく
この plan チェックを入れて初めて気づいたのですが、ある SAML 連携リソースが毎回 “1 to change” を出す永続ドリフトを起こしていました。メタデータ URL からサーバー側が派生フィールドを自動展開します。設定ファイルには書いていないため、毎回「消そうとしては再展開される」無限ループになっていました。
無人 apply 時代には、こういう「毎晩出続ける無害な差分」が本物のドリフトを覆い隠す(レビュー疲れ)ので、lifecycle { ignore_changes = [...] } で黙らせました。**「No changes が正常」**という状態を保つのが、無人運用の前提です。
6. 最後に残る制約:Terraform は「今の main 全体」を適用する
1 つだけ、運用で守るルールがあります。
tofu applyは常に 「その時点のmainの全状態」を適用する。 したがって、夜間に回したい変更がmainに乗ったまま別の変更を即時 dispatch すると、延期中の変更も巻き込んで昼間に反映されてしまう。
完全な「PR 単位の分離」は Terraform の性質上できません。対策は運用で、
- インフラ変更の PR は 1 本ずつマージする(state ロックの直列化要件とも一致)
- 夜間に回したい変更の上に、別の変更を即時 dispatch しない
幸い、配信系には冪等性の安全装置(決定的なスケジュール名による重複防止、再送の skip、検索優先のレコード名寄せ)が効いているので、取りこぼしてもユーザーの再操作で安全に収束します。無人運用は「冪等性」と「クリーンな plan」の上に成り立つ、というのが学びでした。
まとめ
| テーマ | 結論 |
|---|---|
| 認証 | OIDC でキーレス。秘密は保管せず、一時クレデンシャルを毎回発行 |
| 初期化 | 信頼関係を作る「最初の 1 回」だけ手動(鶏と卵) |
| 起動 | push 即デプロイをやめ、即時(dispatch) / 夜間(cron) を選択制に |
| 既定 | デフォルト夜間で、入れ替えに弱い経路への曝露を最小化 |
| 安全 | plan は PR で人が確認 → 夜間は確認済み変更の無人適用 |
| 前提 | No changes を保つ(ドリフト除去)+ 1 本ずつマージ |
「キーを置かない」「即デプロイをやめる」――どちらも一見すると手間が増える選択ですが、稼働中システムを安全に回すための、地に足のついたトレードオフでした。
