GitHub Actions × OIDC:認証情報を保持しない安全なAWSデプロイ自動化
|
GitHub Actions CI/CD AWS OIDC S3 CloudFront Terraform
GitHub Actions を使って S3+CloudFront のLP自動デプロイを構築。OIDC認証でアクセスキー不要の安全なCI/CDパイプラインを実現する手順を解説。
はじめに:「GitHub Secretsにアクセスキーを置く」の何が問題か
GitHub ActionsでAWSにデプロイする最初の実装として、多くのエンジニアが次の構成を取る。
# ❌ よく見るが危険なパターン
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ap-northeast-1
これは動く。しかし「セキュリティ上の負債」を抱えたまま運用を続けることになる。
アクセスキー管理の問題構造:
長期トークン (Access Key) の特性:
┌─────────────────────────────────────────────┐
│ 有効期限: なし(手動でrotateするまで永久に有効) │
│ 保存場所: GitHub Secrets(暗号化されているが存在する) │
│ 漏洩リスク: ログ出力・fork・Actions の脆弱性 │
│ 権限剥奪: IAMコンソールで手動削除が必要 │
└─────────────────────────────────────────────┘
実際のインシデントパターン:
1. サードパーティActionのサプライチェーン攻撃
2. デバッグ用printでログにキーが出力
3. リポジトリのforkでSecretsが継承(旧仕様)
4. 退職したメンバーのキーが残存
根本的な問題はキーの「永続性」にある。 OIDC(OpenID Connect)はこの問題を「キーを発行しない」という設計で解決する。
Part 1:OIDCの仕組み ── 一時トークンだけで認証する
OIDCは「GitHubがAWSに対して身元を証明し、AWSが一時的なSTSトークンを発行する」プロトコルだ。
OIDC認証フロー:
1. GitHub Actions がワークフロー実行時に
GitHub の OIDC エンドポイントから
「このJobはリポジトリXのブランチYで動いている」
という署名付きJWTトークンを取得する
2. AWSのIAM OIDC Providerが
そのJWTの署名をGitHubの公開鍵で検証する
3. IAM Roleの trust policy が
「リポジトリX・ブランチYからの要求のみ許可」
という Condition で一致を確認する
4. STSが有効期限付きの一時トークン(最大1時間)を発行する
結果:
✅ アクセスキーの保存: ゼロ
✅ トークンの有効期限: Job終了で自動失効
✅ 権限剥奪: IAM Roleを変更するだけ(キー削除不要)
長期トークンを「保管するセキュリティ」から、一時トークンを「都度発行するセキュリティ」へのパラダイムシフトだ。
Part 2:Terraformで OIDC Provider と IAM Role を構築する
IAMリソースもインフラの一部としてTerraformでコード管理する。
IAM OIDC Provider の作成
# modules/github-oidc/main.tf
# GitHub Actions の OIDC Provider を AWS に登録
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
# GitHub の OIDC エンドポイントのクライアントID
client_id_list = ["sts.amazonaws.com"]
# GitHub OIDC の thumbprint
# 参考: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments
thumbprint_list = [
"6938fd4d98bab03faadb97b34396831e3780aea1",
"1c58a3a8518e8759bf075b76b750d4f2df264fcd"
]
}
デプロイ専用 IAM Role の作成
# modules/github-oidc/main.tf (続き)
variable "github_org" { type = string }
variable "github_repo" { type = string }
variable "allowed_branches" {
type = list(string)
default = ["main"]
description = "デプロイを許可するブランチ一覧"
}
# Trust Policy: どのGitHubリポジトリ・ブランチからの要求を信頼するか
data "aws_iam_policy_document" "github_actions_assume_role" {
statement {
effect = "Allow"
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
# ブランチ単位でデプロイ権限を絞る
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
# "repo:org/repo:ref:refs/heads/main" の形式
values = [
for branch in var.allowed_branches :
"repo:${var.github_org}/${var.github_repo}:ref:refs/heads/${branch}"
]
}
}
}
resource "aws_iam_role" "github_actions_deploy" {
name = "${var.github_repo}-github-actions-deploy"
assume_role_policy = data.aws_iam_policy_document.github_actions_assume_role.json
tags = {
ManagedBy = "terraform"
Purpose = "github-actions-oidc-deploy"
}
}
デプロイに必要な最小権限ポリシーの付与
# S3 + CloudFront デプロイ専用ポリシー(最小権限)
data "aws_iam_policy_document" "deploy_policy" {
# S3: デプロイ対象バケットへの読み書き
statement {
effect = "Allow"
actions = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
]
resources = [
"arn:aws:s3:::${var.s3_bucket_name}",
"arn:aws:s3:::${var.s3_bucket_name}/*"
]
}
# CloudFront: キャッシュ無効化のみ(設定変更は不可)
statement {
effect = "Allow"
actions = [
"cloudfront:CreateInvalidation"
]
resources = [
"arn:aws:cloudfront::${data.aws_caller_identity.current.account_id}:distribution/${var.cloudfront_distribution_id}"
]
}
# Terraform plan/apply に必要な読み取り権限
statement {
effect = "Allow"
actions = [
"s3:GetObject",
"s3:ListBucket"
]
resources = [
"arn:aws:s3:::${var.tfstate_bucket_name}",
"arn:aws:s3:::${var.tfstate_bucket_name}/*"
]
}
}
resource "aws_iam_role_policy" "deploy" {
name = "deploy-policy"
role = aws_iam_role.github_actions_deploy.id
policy = data.aws_iam_policy_document.deploy_policy.json
}
# Terraform apply 用の Role(plan より広い権限が必要)
resource "aws_iam_role_policy_attachment" "terraform_apply" {
role = aws_iam_role.github_actions_deploy.name
policy_arn = "arn:aws:iam::aws:policy/PowerUserAccess"
# Note: 本番では PowerUserAccess より絞った独自ポリシーを推奨
}
output "role_arn" {
value = aws_iam_role.github_actions_deploy.arn
description = "GitHub Actions ワークフローで指定する IAM Role ARN"
}
Part 3:GitHub Actions ワークフローの実装
LP デプロイの全体像
ワークフロー設計:
PR作成 / push
├── [lint-and-test] 構文チェック・テスト
│
├── [terraform-plan] ← main以外のブランチでも実行
│ terraform init
│ terraform plan -out=tfplan
│ → PRにplanの差分をコメント投稿
│
└── (mainブランチマージ後)
├── [terraform-apply] インフラ変更の適用
│ terraform apply tfplan
└── [deploy-frontend] フロントエンドのデプロイ
aws s3 sync ./dist s3://...
aws cloudfront create-invalidation ...
.github/workflows/deploy.yml
name: Deploy LP to AWS
on:
push:
branches: [main]
pull_request:
branches: [main]
# 同一PRで複数のJobが走った場合、古いものをキャンセル
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
AWS_REGION: ap-northeast-1
TF_VERSION: "1.7.0"
permissions:
id-token: write # OIDC トークン取得に必須
contents: read
pull-requests: write # PRへのコメント投稿
jobs:
# ──────────────────────────────
# Job 1: Terraform Plan(PR時)
# ──────────────────────────────
terraform-plan:
name: Terraform Plan
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
# OIDC認証: アクセスキー不要
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
# role-session-nameでどのWorkflowかを識別できる
role-session-name: github-actions-${{ github.run_id }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init
working-directory: terraform/environments/prd
run: terraform init
- name: Terraform Plan
id: plan
working-directory: terraform/environments/prd
run: |
terraform plan -no-color -out=tfplan 2>&1 | tee plan_output.txt
echo "exitcode=$?" >> $GITHUB_OUTPUT
continue-on-error: true
# planの結果をPRにコメント投稿
- name: Comment Plan Result on PR
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const planOutput = fs.readFileSync(
'terraform/environments/prd/plan_output.txt', 'utf8'
);
const maxLength = 65000;
const truncated = planOutput.length > maxLength
? planOutput.substring(0, maxLength) + '\n... (truncated)'
: planOutput;
const body = `## Terraform Plan Result
\`\`\`hcl
${truncated}
\`\`\`
> Plan generated at: ${new Date().toISOString()}
> Commit: ${{ github.sha }}`;
// 既存のbotコメントを更新(重複を防ぐ)
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.data.find(
c => c.user.type === 'Bot' && c.body.includes('Terraform Plan Result')
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Terraform Plan Status Check
if: steps.plan.outputs.exitcode == '1'
run: exit 1
# ──────────────────────────────
# Job 2: Terraform Apply(mainマージ後)
# ──────────────────────────────
terraform-apply:
name: Terraform Apply
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
environment: production # GitHub Environments で承認フローを追加可能
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
role-session-name: github-actions-terraform-apply-${{ github.run_id }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- name: Terraform Init & Apply
working-directory: terraform/environments/prd
run: |
terraform init
terraform apply -auto-approve
# ──────────────────────────────
# Job 3: Frontend Deploy(mainマージ後)
# ──────────────────────────────
deploy-frontend:
name: Deploy Frontend
runs-on: ubuntu-latest
needs: terraform-apply # インフラ更新後にデプロイ
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
role-session-name: github-actions-deploy-frontend-${{ github.run_id }}
- name: Build
run: |
npm ci
npm run build
# --delete: S3から削除されたファイルを同期
# --cache-control: ブラウザキャッシュ戦略
- name: Sync to S3
run: |
# HTMLは短いキャッシュ(内容が変わりやすい)
aws s3 sync ./dist s3://${{ secrets.S3_BUCKET_NAME }} \
--exclude "*" \
--include "*.html" \
--cache-control "public, max-age=0, must-revalidate" \
--delete
# アセットは長いキャッシュ(ハッシュ付きファイル名)
aws s3 sync ./dist s3://${{ secrets.S3_BUCKET_NAME }} \
--exclude "*.html" \
--cache-control "public, max-age=31536000, immutable"
# CloudFront の HTML キャッシュのみ無効化(アセットはハッシュで管理)
- name: Invalidate CloudFront Cache
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*.html" "/index.html"
Part 4:Terraform plan を「差分の可視化」として機能させる
PR上のコメントが実際にどう見えるかを示す。
GitHubのPRに自動投稿されるコメントの例:
## Terraform Plan Result
Terraform will perform the following actions:
# aws_cloudfront_distribution.lp will be updated in-place
~ resource "aws_cloudfront_distribution" "lp" {
~ price_class = "PriceClass_100" -> "PriceClass_All"
}
# aws_s3_bucket_versioning.lp will be updated in-place
~ resource "aws_s3_bucket_versioning" "lp" {
~ versioning_configuration {
~ status = "Suspended" -> "Enabled"
}
}
Plan: 0 to add, 2 to change, 0 to destroy.
> Plan generated at: 2025-01-08T10:30:00.000Z
> Commit: a1b2c3d
このコメントがあることで、インフラの変更をコードレビューと同じプロセスで承認できる。 「このPRをマージするとS3のバージョニングが有効になる」という情報が、コードの変更と同じPRに集約される。
Part 5:セキュリティの多層防御
OIDCは出発点に過ぎない。さらに絞り込むための設計を示す。
ブランチ保護ルール
GitHub リポジトリ設定:
Branch protection rules for "main":
✅ Require a pull request before merging
✅ Require approvals: 1
✅ Require status checks to pass:
- terraform-plan
- lint-and-test
✅ Restrict who can push to matching branches
GitHub Environments による承認フロー
# 本番デプロイに人間の承認を挟む
jobs:
terraform-apply:
environment: production # この1行で承認フローが発動
GitHub Environments "production" の設定:
Required reviewers: [your-github-username]
Wait timer: 0 minutes
Deployment branches: main only
→ マージ後、指定レビュワーが承認するまでJobが待機する
→ 深夜の誤マージでも、朝に確認してからApplyできる
Condition の細かい制御
# プルリクエストからのplan実行も許可(readOnlyロールで)
condition {
test = "StringLike"
variable = "token.actions.githubusercontent.com:sub"
values = [
# mainブランチへのpush(apply用)
"repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main",
# PRのhead(plan用)
"repo:${var.github_org}/${var.github_repo}:pull_request"
]
}
権限分離の設計:
PR(plan実行):
- sub: "repo:org/repo:pull_request"
- 権限: terraform state の read + S3 read のみ
mainブランチ(apply + deploy):
- sub: "repo:org/repo:ref:refs/heads/main"
- 権限: terraform apply + S3 write + CloudFront invalidation
Conclusion:「認証情報の管理」から「信頼関係の設計」へ
アクセスキーの管理は「秘密を守る」セキュリティだ。OIDCは「誰を信頼するか」を設計するセキュリティだ。
アプローチの変化:
アクセスキー方式:
「このキーを持っている者を信頼する」
→ キーが漏れれば誰でも信頼される
→ キーの保管場所がセキュリティの境界
OIDC方式:
「このリポジトリのこのブランチで動いている者を信頼する」
→ コンテキスト(どこで・何が・誰が)で認証する
→ 条件を変えるだけで権限を即時剥奪できる
| 比較軸 | アクセスキー | OIDC |
|---|---|---|
| 有効期限 | なし(永続) | Job終了で自動失効 |
| 漏洩時の影響 | キーが使えれば即悪用 | トークンは短命で影響限定 |
| 権限剥奪 | キーを手動削除 | IAM Roleを変更するだけ |
| 監査ログ | 誰がキーを使ったか不明 | Roleセッション名でJob特定可能 |
| 管理コスト | キーのローテーション必要 | ローテーション不要 |
この設計は「GitHub ActionsというCI/CDツールの設定」ではない。「信頼境界をどこに引くか」というセキュリティアーキテクチャの決断だ。
この記事をシェア