naoki.dev

記事検索

タイトル・本文・タグから検索します

ホームに戻る
DevOps

GitHub Actions の OIDC で Terraform の state バケットを作る

目次

Terraform の S3 backend を使うと、「state を置く S3 バケットが先に存在していないと terraform init できない」という問題にぶつかります。しかも backend ブロックは変数展開できないので、名前を動的に組み立てることもできません。この記事では、その state バケットを GitHub Actions の OIDC + IAM ロールで手動ワークフローから作る手順を、実際にハマったポイントつきでまとめます。

なぜ手動ワークフローなのか

state バケットは backend の前提なので、Terraform 本体では管理できません(自分の state を置く場所を自分で作れない)。選択肢は主に3つ:

  • 手でコンソールから作る … 再現性が低い
  • ローカル state の bootstrap を別途持つ … state の置き場が増えて煩雑
  • CI から aws s3api で作る … 冪等スクリプトにすれば一発・再現可能 ← これを採用

そして「一度きり・破壊的」な操作なので、PR では走らせず **workflow_dispatch(手動実行)**にします。

前提:backend は変数展開できない

先に釘を刺しておくと、backend ブロックはこう書けません:

# ❌ backend は初期化のごく初期に読まれるため、変数・関数・data を一切使えない
terraform {
  backend "s3" {
    bucket = "${var.project}-tfstate-${data.aws_caller_identity.current.account_id}"
  }
}

なのでバケット名はリテラルで固定し、そのバケットを外部(=今回のワークフロー)で用意します。

terraform {
  backend "s3" {
    bucket       = "your-unique-tfstate-bucket"
    key          = "myapp/dev/terraform.tfstate"
    region       = "ap-northeast-1"
    encrypt      = true
    use_lockfile = true # S3 ネイティブロック(Terraform 1.10+、DynamoDB 不要)
  }
}

key は「バケット内のどのパスに state を書くか」を指定するだけで、事前作成は不要(初回 apply で Terraform が自動生成)。用意すべきはバケットだけです。

OIDC を使う理由

CI から AWS を触るには認証が要ります。長期アクセスキーを GitHub Secrets に置く方式もありますが、GitHub OIDC + IAM ロールなら短期クレデンシャルで済み、鍵の管理・漏洩リスクがありません。今回はこちらを使います。

ただし「CI に権限を与えるための土台(OIDC プロバイダ + ロール)」だけは、AWS 側で一度だけ手動で作る必要があります(CI にはまだ権限がないので CI では作れない)。

1. OIDC プロバイダ

IAM → ID プロバイダ → プロバイダを追加:

  • タイプ: OpenID Connect
  • Provider URL: https://token.actions.githubusercontent.com
  • Audience: sts.amazonaws.com

2. IAM ロール(信頼ポリシー)

このリポジトリの Actions だけが assume できるよう、sub をリポジトリに絞ります:

{
  "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:<OWNER>/<REPO>:*" }
      }
    }
  ]
}

:* は全ブランチ許可。締めたいなら :ref:refs/heads/main:environment:prod に限定します。手動実行するブランチ(例: develop)が許可されているかは要確認ポイントです。

3. 権限(最小権限)

bootstrap 用は、state バケットの作成・設定だけに絞ります:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:CreateBucket",
        "s3:ListBucket",
        "s3:PutBucketVersioning",
        "s3:PutEncryptionConfiguration",
        "s3:PutBucketPublicAccessBlock"
      ],
      "Resource": "arn:aws:s3:::your-unique-tfstate-bucket"
    }
  ]
}

コンソールのロール作成ウィザードの「許可を追加」画面は既存ポリシーを選ぶだけで JSON を貼れません。いったんポリシー無しでロールを作成 → ロールの「インラインポリシーを作成」で JSON を貼るのが迷わず早いです。

作ったロールの ARN を、GitHub の リポジトリ変数 AWS_ROLE_ARN に登録します(後述しますが、Secret ではなく Variable です)。

bootstrap ワークフロー

workflow_dispatch で手動実行、id-token: write で OIDC トークンを取得、aws s3api を冪等に叩きます。

name: bootstrap-state
 
on:
  workflow_dispatch:
    inputs:
      bucket:
        description: "作成する state バケット名"
        required: true
        default: "your-unique-tfstate-bucket"
      region:
        description: "リージョン"
        required: true
        default: "ap-northeast-1"
 
permissions:
  id-token: write # OIDC でロールを assume するのに必要
  contents: read
 
jobs:
  create-bucket:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
          aws-region: ${{ inputs.region }}
 
      - name: Create state bucket (idempotent)
        env:
          BUCKET: ${{ inputs.bucket }}
          REGION: ${{ inputs.region }}
        run: |
          set -euo pipefail
          if aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null; then
            echo "bucket already exists: $BUCKET"
          else
            aws s3api create-bucket --bucket "$BUCKET" --region "$REGION" \
              --create-bucket-configuration LocationConstraint="$REGION"
          fi
 
      - name: Enable versioning / encryption / block public access
        env:
          BUCKET: ${{ inputs.bucket }}
        run: |
          set -euo pipefail
          aws s3api put-bucket-versioning --bucket "$BUCKET" \
            --versioning-configuration Status=Enabled
          aws s3api put-bucket-encryption --bucket "$BUCKET" \
            --server-side-encryption-configuration \
            '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
          aws s3api put-public-access-block --bucket "$BUCKET" \
            --public-access-block-configuration \
            BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

head-bucket で存在チェックしているので、再実行してもエラーになりません。versioning を有効化しておけば state の履歴から復旧もできます。

なお workflow_dispatch の「Run workflow」ボタンは、ワークフローがデフォルトブランチに存在して初めて表示されます。feature ブランチに置いただけでは出ないので、develop / main にマージしてから実行します。

ハマりどころ:role-to-assume が空で「Credentials could not be loaded」

実行したら、configure-aws-credentials がこう言って 12 回リトライして落ちました:

Retry validateCredentials: attempt 1 of 12 failed: Credentials could not be loaded,
please check your action inputs: Could not load credentials from any providers.

ログの with: を見ると原因が一目瞭然でした:

Run aws-actions/configure-aws-credentials@v6
  with:
    aws-region: ap-northeast-1
    audience: sts.amazonaws.com
    # ← role-to-assume が無い!

role-to-assume が空だと、OIDC で assume せずに他の認証プロバイダを探し、何も無くて失敗します。原因は単純で、ロール ARN を「Secret」に登録していたこと。ワークフローは ${{ vars.AWS_ROLE_ARN }}(= Variable)を参照しているので、Secret に入れても空のままだったのです。

ロール ARN はアカウントID + ロール名で、秘密情報ではありません。リポジトリ変数(Variables タブ)に登録するのが正解です:

gh variable set AWS_ROLE_ARN --body "arn:aws:iam::<ACCOUNT_ID>:role/gha-bootstrap-role"
gh secret delete AWS_ROLE_ARN   # 紛らわしい旧 Secret は削除(任意)

登録し直して Re-run すると、with:role-to-assume: arn:... が入り、OIDC で無事 assume されました。

教訓: ${{ vars.X }} は Variables、${{ secrets.X }} は Secrets。参照側と登録先が食い違うと、エラーではなく「空文字」になるので気づきにくい。ログの with: を見れば一発で分かります。

まとめ

  • Terraform の S3 backend は バケット事前作成が必須(鶏卵問題)。backend は変数展開できないので名前はリテラル固定。
  • state バケットは GitHub OIDC + IAM ロールで、workflow_dispatch の冪等ワークフローから作ると再現性が高い。
  • OIDC プロバイダ + ロール(信頼ポリシー / 最小権限)だけは AWS 側で一度だけ手動
  • ロール ARN は Secret ではなく Variablevarssecrets の取り違えは「空文字」になって静かに失敗するので、ログの with: を確認する。

一度整えば、新しいアカウント・環境でも「Run workflow」一発で state バケットを用意できます。

関連記事