👾

Terraform Cloud の代わりに S3 ネイティブロック + GitHub Actions OIDC を試してみた話

に公開

はじめに

これまで Terraform Cloud を使って state 管理と CI/CD を回してきました。が、最近マイクロサービス化を検討するなかで、こんな悩みが出てきました。

  • コスト問題: マイクロサービスごとに workspace を切っていくと、リソース数に応じて Terraform Cloud のコストがじわじわ効いてきます
  • デプロイ経路がバラバラ問題: アプリは AWS SAM、インフラは Terraform Cloud。リリースのたびに 2 つの仕組みを意識するのが地味につらいです

そこで、

  • state は AWS S3 (Terraform v1.10 から入ったネイティブロック機能) で管理
  • 認証は GitHub Actions OIDC で AWS にフェデレーション(アクセスキー不要)
  • デプロイ経路は GitHub Actions に統一

という構成を試してみたら、わりとシンプルかつ快適に動いてくれたので、検証の流れをまとめます。

詰まったポイントもそこそこあるので、同じ構成を考えてる方の参考になればうれしいです。

全体構成

ざっくり図にするとこんな感じです。

                        +--------------------+
                        |  GitHub Repository |
                        |  (terraform code)  |
                        +---------+----------+
                                  |
                       push / PR  v
                        +---------+----------+
                        |   GitHub Actions   |
                        |  (plan / apply)    |
                        +---------+----------+
                                  |
                            OIDC  v   AssumeRole
                        +---------+----------+
                        |  IAM Role          |
                        | github-actions-tf  |
                        +---------+----------+
                                  |
                  +---------------+---------------+
                  |                               |
                  v                               v
        +-----------------+              +-----------------+
        |  S3 (tfstate)   |              |  AWS Resources  |
        |  + native lock  |              |  (env per key)  |
        +-----------------+              +-----------------+

ディレクトリ構成はこう切りました。

.
├── .github/workflows/terraform.yml
├── bootstrap/                    # state 管理リソース自身を作る (state はローカル管理)
│   ├── main.tf
│   ├── variables.tf
│   └── outputs.tf
├── terraform/
│   └── environments/
│       ├── prod/
│       ├── stg/
│       └── dev/
└── .terraform-version

ポイントは bootstrap だけ別ディレクトリで、しかも state をローカルで持つ ところです。

考えてみれば当たり前で、「state 保管用の S3 バケット」を作る Terraform が、その作りたいバケットを backend に指定するのは無理ですよね。鶏と卵問題なので、bootstrap だけは特別扱いです。

1. bootstrap で土台を作る

bootstrap で作るのは以下のリソースです。

  • S3 バケット (tfstate 用)
    • バージョニング有効 / SSE-S3 暗号化 / Public Access Block / 90 日で旧バージョン削除
  • IAM Role: github-actions-terraform
    • GitHub OIDC で AssumeRole される
  • IAM Policy 2 つ: tfstate 用と、実リソース管理用

OIDC Provider もここで作ろうとしたんですが、ここで 1 回目のつまずき に当たりました。詳細は後述します。

backend は意図的に書かず、ローカルの terraform.tfstate で運用します。

terraform {
  required_version = ">= 1.10"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

各環境用の state は、すべて 1 つの S3 バケットに「環境名/terraform.tfstate」のキーで突っ込む設計にしました。

s3://<bucket>/dev/terraform.tfstate
s3://<bucket>/stg/terraform.tfstate
s3://<bucket>/prod/terraform.tfstate

ローカルから 1 回だけ apply します。

$ aws sts get-caller-identity --profile <my-sso-profile>
{
    "Account": "xxxxxxxxxxxx",
    "Arn": "arn:aws:sts::xxxxxxxxxxxx:assumed-role/.../<your-user>"
}

$ cd bootstrap/
$ terraform init
$ terraform plan -out=tfplan
...
Plan: 11 to add, 0 to change, 0 to destroy.

$ terraform apply tfplan
...
Apply complete! Resources: 10 added, 0 changed, 0 destroyed.

(11 のうち 10 しか作られていないのは、後述の OIDC Provider 問題で resourcedata に切り替えたためです)

2. 各環境の backend を S3 に向ける

bootstrap で作った S3 バケットを backend に指定します。Terraform v1.10 から入った ネイティブロック を使うので、DynamoDB の lock table はいりません。use_lockfile = true が決め手です。

terraform {
  required_version = ">= 1.10"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket       = "<tfstate-bucket-name>"
    key          = "dev/terraform.tfstate"
    region       = "ap-northeast-1"
    encrypt      = true
    use_lockfile = true  # ← これが v1.10 の新機能
  }
}

これだけで、plan / apply の最中は S3 上に dev/terraform.tfstate.tflock という小さなファイルが作成され、終了すると消えます。DynamoDB が不要になったので、運用するリソースが純粋に 1 つ減りました。地味にうれしいです。

検証用に、各環境で「タグ確認用の S3 バケット」を 1 個ずつ作るシンプルな構成にしておきます。

resource "aws_s3_bucket" "sample" {
  bucket = "learn-tf-sample-${var.environment}"

  tags = {
    Name    = "learn-tf-sample-${var.environment}"
    Purpose = "smoke-test for tfstate backend and CI/CD"
  }
}

3. GitHub Actions のワークフロー

ここは少し長くなりますが、ポイントになる部分なので順番にコードを見ていきます。

3-1. トリガー

on:
  pull_request:
    paths:
      - "terraform/**"
      - ".github/workflows/terraform.yml"
  push:
    branches:
      - main
    paths:
      - "terraform/**"
      - ".github/workflows/terraform.yml"
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        type: choice
        options: [dev, stg, prod]
      action:
        description: "Action to perform"
        type: choice
        options: [plan, apply]
  • PR: plan のみ実行して PR コメントに差分を投稿します
  • main への push: plan + 承認待ち + apply を 3 環境分まわします
  • 手動 (workflow_dispatch): 環境と action を選んで実行できます

pathsterraform/** と workflow ファイル自身に絞っているので、README や bootstrap だけ変えたときには走りません。CI 時間とコストの無駄打ちを避けるための小さな工夫です。

3-2. 権限と環境変数

permissions:
  id-token: write       # OIDC で JWT を発行するために必須
  contents: read        # ソースの fetch
  pull-requests: write  # plan の結果を PR コメントとして書く

env:
  AWS_REGION: ap-northeast-1
  AWS_ROLE_ARN: arn:aws:iam::xxxxxxxxxxxx:role/github-actions-terraform

id-token: write が抜けると OIDC AssumeRole が失敗するので、ここは忘れないようにしたいポイントです。AWS_ROLE_ARN は bootstrap で作った IAM Role を指定します。

3-3. plan ジョブ(ジョブ定義)

plan:
  name: Plan (${{ matrix.environment }})
  if: github.event_name != 'workflow_dispatch' || github.event.inputs.action == 'plan'
  runs-on: ubuntu-latest
  strategy:
    fail-fast: false
    matrix:
      environment: ${{ fromJSON(github.event_name == 'workflow_dispatch' && format('["{0}"]', github.event.inputs.environment) || '["dev","stg","prod"]') }}
  defaults:
    run:
      working-directory: terraform/environments/${{ matrix.environment }}
  • matrix で 3 環境を並列実行: fail-fast: false にしてあるので、どこか 1 つコケても他の環境はそのまま走ります。「全環境の差分を一度に見たい」という用途に合わせています。
  • workflow_dispatch のときは指定 1 環境のみ: 三項演算子で配列をその場で組み立てるという、ちょっとしたテクニックです。
  • defaults.run.working-directory: 各ステップで毎回 cd terraform/environments/... を書かなくて済むので、ステップが見通しやすくなります。

3-4. plan ジョブ(ステップ)

steps:
  - uses: actions/checkout@v4

  - name: Configure AWS credentials (OIDC)
    uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: ${{ env.AWS_ROLE_ARN }}
      aws-region: ${{ env.AWS_REGION }}

  - name: Read .terraform-version
    id: tfversion
    run: echo "version=$(cat .terraform-version)" >> "$GITHUB_OUTPUT"
    working-directory: .

  - name: Setup Terraform
    uses: hashicorp/setup-terraform@v3
    with:
      terraform_version: ${{ steps.tfversion.outputs.version }}
      terraform_wrapper: true

  - name: Terraform fmt
    id: fmt
    run: terraform fmt -check -recursive
    working-directory: .

  - name: Terraform init
    id: init
    run: terraform init -input=false

  - name: Terraform validate
    id: validate
    run: terraform validate -no-color

  - name: Terraform plan
    id: plan
    run: terraform plan -no-color -input=false -out=tfplan
    continue-on-error: true

ポイントは 3 つ:

  • .terraform-version を読んで Terraform のバージョンを動的に決めている: hashicorp/setup-terraformterraform_version を省略すると latest を取りに行くので、リポジトリの .terraform-versioncat した値を渡すようにしています。これでローカル(tfenv)と CI のバージョンを一元管理できます。
  • terraform fmt -check -recursiveworking-directory: .: フォーマットチェックだけリポジトリ全体に対して走らせたいので、defaults で設定したディレクトリを上書きしています。
  • continue-on-error: true を plan に付けている: plan が失敗してもジョブをすぐ止めず、次のステップ(PR コメント)で原因を投稿してから止めたい、という意図です。最後に「plan が失敗したらジョブも失敗扱い」のステップを別途置いて締めています。

3-5. plan の結果を PR コメントに投稿する

actions/github-script を使って、plan 結果を PR コメントとして書きます。同じ環境のコメントが既にあれば更新、なければ新規作成という挙動なので、何度 push しても 3 環境分のコメントが 1 つずつにまとまります。

- name: Comment plan on PR
  if: github.event_name == 'pull_request'
  uses: actions/github-script@v7
  env:
    PLAN_STDOUT: "${{ steps.plan.outputs.stdout }}"
  with:
    script: |
      const env = "${{ matrix.environment }}";
      const fmt = "${{ steps.fmt.outcome }}";
      const init = "${{ steps.init.outcome }}";
      const validate = "${{ steps.validate.outcome }}";
      const plan = "${{ steps.plan.outcome }}";
      const planOutput = (process.env.PLAN_STDOUT || "").slice(0, 60000);

      const body = `### Terraform Plan: \`${env}\`

      | Step | Result |
      | --- | --- |
      | fmt | \`${fmt}\` |
      | init | \`${init}\` |
      | validate | \`${validate}\` |
      | plan | \`${plan}\` |

      <details><summary>Show plan</summary>
      \`\`\`hcl
      ${planOutput}
      \`\`\`
      </details>

      *Triggered by @${{ github.actor }} on \`${{ github.sha }}\`*`;

      const { data: comments } = await github.rest.issues.listComments({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: context.issue.number,
      });
      const marker = `### Terraform Plan: \`${env}\``;
      const existing = comments.find(c => c.body && c.body.startsWith(marker));
      if (existing) {
        await github.rest.issues.updateComment({
          owner: context.repo.owner, repo: context.repo.repo,
          comment_id: existing.id, body,
        });
      } else {
        await github.rest.issues.createComment({
          owner: context.repo.owner, repo: context.repo.repo,
          issue_number: context.issue.number, body,
        });
      }

- name: Fail if plan errored
  if: steps.plan.outcome == 'failure'
  run: exit 1

地味だけど効くポイント:

  • PLAN_STDOUTenv 経由で渡している: plan 出力にはバッククォートやドル記号が混じることがあって、テンプレート文字列に直接埋め込むとエスケープがバグります。process.env.PLAN_STDOUT で受け取るとそういう事故が起きないので安全です。
  • 60000 文字で slice: GitHub コメントの上限が約 65,536 文字なので、それを超えないようにカットしておきます。
  • マーカー文字列で既存コメントを検索: ### Terraform Plan: \${env}`` を先頭に置くことで、環境ごとに 1 つのコメントに集約できます。PR を何度 push し直してもコメントが増殖しません。

3-6. apply ジョブ

apply:
  name: Apply (${{ matrix.environment }})
  needs: plan
  if: |
    (github.event_name == 'push' && github.ref == 'refs/heads/main') ||
    (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'apply')
  runs-on: ubuntu-latest
  strategy:
    fail-fast: true
    max-parallel: 1
    matrix:
      environment: ${{ fromJSON(...) }}
  environment: ${{ matrix.environment }}   # ← ここで Required reviewers 待ちになる
  defaults:
    run:
      working-directory: terraform/environments/${{ matrix.environment }}

このジョブの設計上のキモは:

  • environment: ${{ matrix.environment }}: GitHub Environments の保護ルールがここで効きます。各環境のジョブが開始する前に「Required reviewers の承認待ち」状態になります。
  • fail-fast: true + max-parallel: 1: dev → stg → prod の順に直列で apply。途中でコケたら以降は止まります。「本番に問題のある変更を持ち込まない」ためのガードです。
  • needs: plan: plan ジョブが全部成功してから apply ジョブを起動。

ステップは plan よりシンプルです。

steps:
  - uses: actions/checkout@v4

  - name: Configure AWS credentials (OIDC)
    uses: aws-actions/configure-aws-credentials@v4
    with:
      role-to-assume: ${{ env.AWS_ROLE_ARN }}
      aws-region: ${{ env.AWS_REGION }}

  - name: Read .terraform-version
    id: tfversion
    run: echo "version=$(cat .terraform-version)" >> "$GITHUB_OUTPUT"
    working-directory: .

  - name: Setup Terraform
    uses: hashicorp/setup-terraform@v3
    with:
      terraform_version: ${{ steps.tfversion.outputs.version }}
      terraform_wrapper: false

  - name: Terraform init
    run: terraform init -input=false

  # tfplan を artifact 経由で受け渡さず、apply ジョブ内で再 plan + apply
  # (理由は後述「つまずき (3)」参照)
  - name: Terraform apply
    run: terraform apply -input=false -auto-approve

承認 → 起動 → 数十秒で完了、という流れで、Plan ジョブと Apply ジョブの間に stale エラーが入る余地をなくしています(詳しい経緯は後述します)。

3-7. GitHub Environments の保護ルール

apply 直前に承認待ちで止まるのは、workflow 側ではなく GitHub Environments の機能で実現しています。GitHub の Web UI から、各環境ごとに設定します。

Settings → Environments → New environment (dev / stg / prod)
  - Required reviewers: 自分(または他のメンバー)
  - Deployment branches: Selected branches → main のみ

これでマージしたあとも「reviewer が承認ボタンを押すまで apply は走らない」状態になります。Terraform 側で何かをする必要がなく、GitHub の機能だけで承認フローが組めるのは楽です。

4. ここからつまずきポイント

ここまで書くとサラッと動きそうですが、現実には 3 回くらいコケました。よくあるやつから、ハマりやすいやつまで、共有しておきます。

つまずき (1): OIDC Provider はすでに存在していた

bootstrap で

resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [...]
}

と書いて apply したら、こうなりました。

Error: creating IAM OIDC Provider: EntityAlreadyExists:
  Provider with url https://token.actions.githubusercontent.com already exists.

これは AWS アカウントごとに 1 つしか作れない共有リソースなんですよね。他のプロジェクトで先に作っていたようです。

対応: resource で作るのではなく、data で参照する形に変えました。

data "aws_iam_openid_connect_provider" "github" {
  url = "https://token.actions.githubusercontent.com"
}

# 参照側
data "aws_iam_policy_document" "github_actions_assume_role" {
  statement {
    ...
    principals {
      type        = "Federated"
      identifiers = [data.aws_iam_openid_connect_provider.github.arn]
    }
    ...
  }
}

こうしておくと、bootstrap を destroy しても OIDC Provider 自体は消えないので、他プロジェクトに影響を与えません。共有リソースは「所有しない」設計、大事です。

つまずき (2): S3 の IAM アクション名のトラップ

最小権限で行くぞ、と意気込んで以下のように書きました。

actions = [
  "s3:CreateBucket",
  "s3:DeleteBucket",
  "s3:GetBucket*",
  "s3:PutBucket*",
  "s3:ListBucket*",
  ...
]

しかしバケットを作って apply するとこうなりました。

Error: reading S3 Bucket (learn-tf-sample-dev) accelerate configuration:
  AccessDenied: User: arn:aws:sts::xxxxxxxxxxxx:assumed-role/github-actions-terraform/GitHubActions
  is not authorized to perform: s3:GetAccelerateConfiguration on resource: "arn:aws:s3:::learn-tf-sample-dev"

s3:GetAccelerateConfiguration ... Bucket がついていない S3 IAM アクションがあるんです。
これに限らず、S3 の IAM アクション名は地味に統一感がなくて、Get/PutBucket* ワイルドカードでは取りこぼします。

対応: アクションを 1 個 1 個列挙するのは現実的じゃないので、ARN を厳密に絞ったうえで s3:* を許可 に変更しました。

statement {
  sid     = "ManagePrefixedBuckets"
  effect  = "Allow"
  actions = ["s3:*"]
  resources = [
    "arn:aws:s3:::learn-tf-sample-*",
    "arn:aws:s3:::learn-tf-sample-*/*",
  ]
}

s3:* って強すぎない?」と思うかもしれませんが、リソース ARN を learn-tf-sample-* プレフィックスに固定しているので、このバケット以外には何も触れません。これは十分に 最小権限 だと思っています(action だけが「最小」なのではなく、resource × action の組み合わせで考えるのがミソ)。

つまずき (3): "Saved plan is stale" 連発問題

IAM ポリシーを直して、これでいけるだろうと思って main にマージ → 承認 → apply、で、また失敗しました。今度はこれです。

Error: Saved plan is stale

The given plan file can no longer be applied because the state was changed
by another operation after the plan was created.

最初は「state が壊れたのか?」と思って state ファイルを覗いたんですが、

$ aws s3 cp s3://<tfstate-bucket>/dev/terraform.tfstate /tmp/dev.tfstate
$ jq '.serial, .resources' /tmp/dev.tfstate
2
[]

state は serial=2 で空でした。原因は「state にゴーストレコードが残っていた」と「ワークフローの構造そのもの」の 2 つです。

ゴーストレコード問題

最初の apply 失敗(つまずき 2)で、実は S3 バケットの作成自体は成功していて、その後の状態確認で IAM がコケた、というパターンだったんです。Terraform はバケットを作った時点で state に記録しています。

私がそのあと手動でバケットを aws s3 rb してしまったので、「state には記録あり、実体なし」のゴーストレコードができていました。

対応はシンプルです:

$ terraform state rm aws_s3_bucket.sample
Removed aws_s3_bucket.sample
Successfully removed 1 resource instance(s).

state からだけ削除して、次回 plan で「リソース未作成」として正しく扱えるようにします。

ワークフローの構造問題

ゴーストレコードを直してもまだ stale が出ました。これがなかなか厄介でした。

元のワークフローは

  • Plan ジョブ: terraform plan -out=tfplan → artifact に upload
  • Apply ジョブ (承認後): artifact から download → terraform apply tfplan

という、よくある「plan を artifact 経由で受け渡す」構造でした。

これが落とし穴で、Plan ジョブと Apply ジョブが別 runner で動くため、Terraform が tfplan に埋め込んだ state スナップショットのハッシュと、Apply ジョブから見た現在の state のハッシュが微妙にずれることがあるみたいなんですよね。

特に native lock を使っていると、plan / apply のたびに lock の取得・解放が走り、その影響もありそうです。承認待ちの時間が長いほど、stale になりやすい印象でした。

対応: artifact 経由で plan を渡すのをやめて、Apply ジョブ内で再度 plan + apply を一気に実行するように変更しました。

- name: Terraform apply
  # 旧: terraform apply -input=false -auto-approve tfplan
  # 新: 引数を渡さず、ジョブ内で plan+apply を一発で
  run: terraform apply -input=false -auto-approve

これで Plan と Apply の間で state がずれる余地がなくなり、stale エラーが消えました。

「でも、レビューで見た plan と実際 apply される内容がズレないの?」というのはたしかにあって、これは GitHub Environments の承認待ちが入る分、ちょっとだけリスクがあります。ただ、承認 → apply の時間はだいたい数分以内なので、その間に手動で何か変えていなければ実害はほぼないはず・・・。

5. 動作確認: PR → plan コメント → マージ → apply

ここまでで構成は固まったので、実際の使い勝手を確認します。

feature ブランチで小さく変更

dev のサンプルバケットにタグを 1 つ追加します。

   tags = {
-    Name    = local.sample_bucket_name
-    Purpose = "smoke-test for tfstate backend and CI/CD"
+    Name      = local.sample_bucket_name
+    Purpose   = "smoke-test for tfstate backend and CI/CD"
+    PRFlowTag = "added-to-verify-pr-plan-comment"
   }

これを push して PR を作成します。

PR コメントに plan 結果が自動投稿される

GitHub Actions が走って、こんな感じで PR にコメントを残してくれます(3 環境分まとめて)。

dev は 1 to change、stg / prod は No changes. Your infrastructure matches the configuration. と、それぞれの状態に応じて表示されます。レビュアーはこのコメントを見て差分をチェックすればよさそうです。

マージしたら承認待ち → apply

PR をマージすると、main 用のワークフローが起動します。Plan が全環境通って、Apply ジョブが各環境ごとに「Required reviewers の承認待ち」状態になります。


GitHub の Actions 画面で「Review pending deployments」のボタンが出るので、内容を確認して dev → stg → prod の順に承認していきます。承認したジョブから順次 apply が走り、こうなります。

実 AWS の dev バケットでタグが追加されているか確認:

$ aws s3api get-bucket-tagging --bucket learn-tf-sample-dev
{
    "TagSet": [
        { "Key": "Project",     "Value": "learn-tf" },
        { "Key": "Environment", "Value": "dev" },
        { "Key": "Purpose",     "Value": "smoke-test for tfstate backend and CI/CD" },
        { "Key": "ManagedBy",   "Value": "terraform" },
        { "Key": "PRFlowTag",   "Value": "added-to-verify-pr-plan-comment" }, 追加された
        { "Key": "Name",        "Value": "learn-tf-sample-dev" }
    ]
}

stg / prod は変化なしでした。PR で意図したとおりに dev だけ更新されています。

やってみて感じたこと

  • コスト: Terraform Cloud で workspace を増やすたびに気にしていた料金が、S3 + IAM だけで済むのでほぼゼロに近づきました(月数十円のレベル)。
  • デプロイ経路の統一: SAM のデプロイも GitHub Actions に乗せれば、リポジトリの中で完結します。これが地味に一番うれしいかもしれません。「リリース手順書」が短くなります。
  • OIDC は本当に楽: アクセスキー管理から解放されるのは、想像以上にメリットが大きいです。Secrets に何も置かなくていい清々しさがあります。
  • native lock いい感じです: DynamoDB を別途用意せずに済むのは、構成がシンプルになって良かったです。Terraform v1.10 以降ならぜひ。
  • つまずきは PR/コミット履歴に残しておくと吉: 「なぜ s3:* で広くしてるんだっけ?」みたいな疑問が将来出るので、コミットメッセージや README に経緯を残しました。

まとめ

  • state は S3 + native lock(DynamoDB 不要)
  • 認証は GitHub Actions OIDC(アクセスキー不要)
  • 承認は GitHub Environments の Required reviewers(環境ごとに人を分けられる)
  • PR で plan、main で apply、承認は環境保護で

という構成で、Terraform Cloud の代わりとしてもふつうに回せそうな手応えがありました。マイクロサービス時代の「workspace 数 × 料金」を気にしなくていいのは、長期的にかなり効いてきそうです。

詰まりやすいポイントは:

  1. OIDC Provider はアカウントに 1 つだけ → 既存を data で参照
  2. S3 IAM アクション名はワイルドカードで取りこぼす → ARN を絞って s3:*
  3. plan を job 間で受け渡すと stale エラー → apply job 内で plan+apply

このあたりだけ気をつけておけば、大きくハマることはなさそうです。同じような構成を検討してる方の参考になればうれしいです!

レスキューナウテックブログ

Discussion