第5回:TerraformとGitHub Actionsで構築するインフラCD

※本記事は、技術評論社「Software Design」(2023年8月号)に寄稿した連載記事「Google Cloudで実践するSREプラクティス」からの転載です。発行元からの許可を得て掲載しております。

はじめに

前回はTerraformとGitHub Actionsで実践するインフラCI/CDのCI部分について解説しました。今回はその続きとなるCD部分、デプロイについて扱います。また、運用をよりスケールさせるために検討すべき観点やキャディでの事例についても紹介します。

terraform applyの実行

前回はPull request(PR)に対してterraform planを実行し、どのようなリソース変更が予定されているのかチェックしました。今回は、PRがマージされたらterraform applyを実行し、リソース変更が適用されるようなパイプラインを構築してみましょう。 リスト1はmainブランチへのプッシュをトリガーにterraform applyを実行し、apply結果をPRコメントとして投稿するワークフロー定義です。サンプルを実行するとComputeインスタンスが作成されるので、費用を抑えたい方はサービスアカウントなど無料で作成できる別のリソースに置き換えてください。

▼リスト1 .github/workflows/terraform_ci.yml

name: Terraform Apply
on:
  push: # ①
    branches:
      - main

jobs:
  terraform_apply:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v3
      - uses: hashicorp/setup-terraform@v2
      - uses: google-github-actions/auth@v1
        with:
          workload_identity_provider: projects/(…略…)/providers/github-provider
          service_account: my-github-actions@(…略…).com
      - run: terraform init
        working-directory: ./terraform/environments/dev
      - id: apply
        run: terraform apply -no-color -auto-approve
        working-directory: ./terraform/environments/dev
        continue-on-error: true
      - uses: actions/github-script@v6 # ②
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `Terraform Apply: \`${{ steps.apply.outcome }}\`
            <details><summary>Show apply</summary>

            \`\`\`
            ${{ steps.apply.outputs.stdout }}
            \`\`\`
            </details>`

            const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
              owner: context.repo.owner,
              repo: context.repo.repo,
              commit_sha: context.sha
            });
            const pr_number = data?.[0]?.number;
            if (pr_number) {
              github.rest.issues.createComment({
                issue_number: pr_number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: output
              })
            }

①でPRがマージされ、mainブランチに取り込まれたプッシュイベントをトリガーにワークフローが実行されます。 ②でこのワークフローはmainブランチ上で実行されますが、マージ元のPRにapply結果をコメントで投稿しています。何をしようとして(plan)、結果どうなったか(apply)が1つのPRにまとまり証跡の見通しが良くなります(図1)。

▼図1 apply結果のコメント投稿

複数環境対応

プロダクトを運用するうえで、開発用(dev)・商用(prod)など目的ごとに環境を分離することは非常に重要です。環境を分離することでセキュリティリスクを軽減したり、厳格な権限管理ができたりします。また、開発中に誤って商用環境を操作してしまうといった人的ミスの予防にもつながります。 環境分離の境界はGoogle CloudプロジェクトやVPCネットワークなどさまざまですが、キャディでは環境ごとにプロジェクトを分離しています。1つのプロダクトを1つのIaCリポジトリで複数環境へデプロイする方法について、キャディでの事例をもとに解説します。環境ごとの差分をどのようにTerraformで扱うか、各環境へのデプロイをどのようにGitHub Actionsで実現するのか、それぞれ見ていきましょう。

tfファイルの構成

環境ごとにワーキングディレクトリを作成し、ステートを分離する手法がプラクティス1として知られています。前回の例をもとにしてprod環境用のリソース定義を作成すると、リスト2のようになります。

▼リスト2 terraform/environments/prod/main.tf

terraform {
  backend "gcs" {
    bucket = "my-tfstate-prod-<<SUFFIX>>" # ①
  }
}

provider "google" {
  project = "<<プロジェクトID>>"
  region  = "asia-northeast1"
  zone    = "asia-northeast1-b"
}

module "vm" { # ②
  source = "../../modules/vm"
  name   = "my-vm-prod"
}

①で、tfstateを保管するバックエンドのバケットについても、プロジェクトごとに分離すると構成がシンプルになります。一方で、tfstateを1つのバケットに集約して厳格に集中管理したいというケースも考えられますので、自分たちに合った方法を選択しましょう。 ②で、ワーキングディレクトリをただ分割してしまうと、環境ごとに似たような内容のコードが増え冗長になります。共通化や再利用が可能なリソース定義はモジュール化し、各環境からはモジュールとして利用することで、コードの記述量が減りメンテナンスしやすくなります。 インスタンス名やマシンタイプなど、環境ごとに異なるパラメータはモジュールの変数として定義し、外部から変更可能な余地を与えます。

ワークフロー定義

prod 環境へterraform apply を実行するワークフローはリスト3のようになります。dev環境向けのワークフローから変更がない箇所は省略しています。

▼リスト3 .github/workflows/terraform_apply_prod.yml

name: Terraform Apply Prod
on:
  push:
    branches:
      - production # ①

jobs:
  terraform_apply:
    # 略
    steps:
      # 略
      - uses: google-github-actions/auth@v1
        with: # ②
          workload_identity_provider: projects/(…略…)/providers/github-provider
          service_account: my-github-actions@(…略…).com
      - run: terraform init
        working-directory: ./terraform/environments/prod # ③
      - id: apply
        run: terraform apply -no-color -auto-approve
        working-directory: ./terraform/environments/prod
        continue-on-error: true
      - uses: actions/github-script@v6
        # 略

①は適用するタイミングをdev環境とずらすために、ワークフローのトリガーはproductionブランチへのマージとしています。ブランチ戦略の詳細については後述します。 ②はdev環境とprod環境でプロジェクトが異なる場合、prod環境用の値に置き換えます。 Workload Identity連携、サービスアカウントの作成については前回を参照してください。 ③はterraformを実行する際にはprod用のワーキングディレクトリを指定します。 これでmainブランチをマージするとdev環境向けの変更が、productionブランチをマージするとprod環境向けの変更がそれぞれ適用されるようになりました。 なお、terraform planを実行するterraform_ci.ymlについても修正内容は同じです。 ここまでのファイル構成は図2のとおりです。

▼図2 プロジェクトIaCリポジトリの構成

.
├── .github
│   └── workflows
│       ├── terraform_apply.yml
│       ├── terraform_apply_prod.yml
│       ├── terraform_ci.yml
│       └── terraform_ci_prod.yml
└── terraform
    ├── environments
    │   ├── dev
    │   │   └── main.tf
    │   └── prod
    │       └── main.tf
    └── modules
        └── vm
            └── main.tf

リポジトリのブランチ戦略

プロダクト運用では、開発環境で検証したあとに商用など後続環境へのデプロイが行われます。ワーキングディレクトリ分離により各環境を管理している場合、mainブランチ1本だけではデプロイサイクルの管理が難しくなります。 デプロイサイクルをずらすために、人間が環境ごとにPRを作成し、個別に適用するという手間が発生してしまいます。また、全環境から参照されているモジュールを変更した場合、dev環境だけ先に適用するといったタイミングの調整難度はより高くなります。 この解決策の1つとして、環境ごとにブランチを分離し、ワークフローの実行タイミングをずらす方法があります。mainブランチが変更されたらdev環境へデプロイし、productionブランチが変更されたらprod環境へデプロイするという具合です。具体的な運用サイクルは図3のようになります。

▼図3 ブランチ戦略

この運用では、修正した環境(terraform/environments/*)に関わらず、mainブランチに対してPRを作成します。 PRをトリガーに terraform plan が実行され、マージするとdev環境に対してterraform applyが実行されます。 このとき、prod環境への適用はまだ行われていません。 dev環境で動作確認を行い問題ないことを確認したら、productionブランチに対してmainブランチの変更を含んだPRを作成します。単純な場合には「base: production, compare: main」としてPRを作成します。ここでもPRをトリガーにterraform planが実行され、マージすると今度はprod環境に対してterraform applyが実行されます(図4)。

▼図4 prod環境へ適用するPR

[Column] Terraform Workspace

複数環境を管理する別の手段として、Terraform Workspace2があります。 Workspaceは、ステートを管理する1つのバックエンド上で複数の独立したステートを保持できる機能です。本稿で解説したワーキングディレクトリ分割の方法と比べて、コードの記述量は少なくなります。 しかし、Workspaceは次のユースケースを想定した機能となっています。

・バックエンドや認証方法が変わらない環境での利用 ・環境を複製し、一時的な検証用途としての利用

環境ごとにtfstate用のバケットを分離している場合や、リソース定義に違いのある場合には、ワーキングディレクトリによる分離のほうが管理は容易です。「開発環境は費用を抑えるためにデータベースは1インスタンスだけだが、商用環境ではリードレプリカ用のインスタンスを追加で構築する」といった環境差分にも容易に対応できます。 一方でコードの記述量は増えてしまうので、ユースケース3を確認し、自分たちに合った管理方法を選択しましょう。

ワークフローの統合

これまでは、簡単のためにCI/CDのワークフローを個別の環境ごとに作成してきました。ここではワークフローterraform_apply.yml を例に、よりDRYに記述する方法について解説します。 terraform_apply.yml, terraform_apply_prod.yml を1つのワークフローに統合してみましょう。

環境ごとに変わる値は次のとおりです。

・トリガーとなるベースブランチ ・Google Cloud認証用のパラメータ ・Terraformのワーキングディレクト

これらのうち、google-github-actions/authの入力パラメータ、Terraformのワーキングディレクトリについては、ベースブランチ名によって値を切り替えられれば良さそうです。 また今までworkload_identity_provider, service_accountはハードコードしていましたが、GitHub Actionsシークレットも活用してみましょう。GitHub Actionsシークレットは、機密性の高いデータを管理するための機能です。 ワークフローからは環境変数として参照できます。 図5のように環境名のプレフィックスを付け、Workload Identityプロバイダとサービスアカウントをシークレットに登録します。

▼図5 Actionsシークレット

環境差分を吸収した、統合後のワークフローはリスト4のようになります。

▼リスト4 .github/workflows/terraform_apply.yml

name: Terraform Apply
on:
  push: # ①
    branches:
      - main
      - production

jobs:
  terraform_apply:
    # 略
    steps:
      - uses: actions/checkout@v3
      - uses: hashicorp/setup-terraform@v2
      - id: get_env # ②
        shell: bash
        run: |
            case ${{ github.ref_name }} in
              production )
                echo 'env=prod' >> $GITHUB_OUTPUT
                echo 'upper_case_env=PROD' >> $GITHUB_OUTPUT
                ;;
              * )
                echo 'env=dev' >> $GITHUB_OUTPUT
                echo 'upper_case_env=DEV' >> $GITHUB_OUTPUT
                ;;
            esac
      - uses: google-github-actions/auth@v1
        with: # ③
          workload_identity_provider: ${{ secrets[format('{0}_GCP_WI_PROVIDER', steps.get_env.outputs.upper_case_env)] }}
          service_account: ${{ secrets[format('{0}_GCP_WI_SERVICE_ACCOUNT', steps.get_env.outputs.upper_case_env)] }}
      - run: terraform init
        working-directory: ./terraform/environments/${{ steps.get_env.outputs.env }} # ④
      - id: apply
        run: terraform apply -no-color -auto-approve
        working-directory: ./terraform/environments/${{ steps.get_env.outputs.env }}
        continue-on-error: true

①はmainまたはproductionブランチへのプッシュイベントをトリガーに、このワークフローが実行されます。 ②はブランチ名を参照し、対応する環境名をステップの出力パラメータとして設定します。 ③は環境名プレフィクスを追加した文字列(DEV_GCP_WI_PROVIDER)を作成し、シークレット(secrets.DEV_GCP_WI_PROVIDER)を参照します。 ④では環境名に対応したワーキングディレクトリを指定します。 これで、CDのワークフローファイルを1つに統合できました。今後ステージング環境など環境が追加された場合にも数行の修正で対応できます。CIのワークフローを修正する際には{{ github.ref_name }}${{ github.base_ref }}に置き換えてください。 今回はGitHub Actionsシークレットを取り扱いましたが、GitHubの契約プランによってはEnvironmentsのシークレット機能が利用できます。GitHubのEnvironmentsはデプロイ先の環境ごとに、ブランチ保護ルールやシークレット、変数を管理できます。これによりmainブランチではGCP_WI_PROVIDER=AAA 、productionブランチでは GCP_WI_PROVIDER=BBB といった値の切り替えが容易に実現できます。

[Column] Matrix strategyの活用

今回紹介したブランチ戦略では、各環境に対応するブランチへのPRやプッシュをトリガーにCI/CDが実行されます。この場合、prod環境に対するCI (terraform plan) を実行するには、一度mainブランチへマージしなければなりません。 しかし、より早く間違いを検知するために、mainブランチへのPR上でdev/prod両環境に対してCIを実行したくなります。この課題はMatrix strategy4を活用することで解決できます。 Matrix strategyは、dev/prodなどのバリエーションを変数で定義し、その値ごとにジョブを複数実行できる機能です。たとえば、前回紹介したterraform_ci.ymlではリストAのように修正します。

▼リストA .github/workflows/terraform_ci.yml

jobs:
  terraform_ci:
    # 略
    strategy: # ①
      matrix:
        environment: [main, production]
    steps:
      # 略
      - id: get_env
        shell: bash
        run: |
          case ${{ matrix.environment }} in # ②
            production )
        # 略

①で並列実行のための変数を定義します。ここでは各環境に対応するベースブランチ名を与えています。 ②でブランチ名から環境名を解決する get_env ステップ内で、 ${{ github.base_ref }} の代わりにマトリックスの値 ${{ matrix.environment }} を与えます。 これで、mainブランチに対してPRが作成された際に、dev/prod各環境に対するterraform planを確認できます。

組織アカウントの横断管理

企業としてGoogle Cloudを利用している場合、組織リソース配下で複数プロダクト、プロジェクトを管理することになります。しかし、管理下の全プロジェクトに対して前回の事前準備で触れた作業を実施するのは非常に手間がかかり、運用がスケールしません。 この課題に関するキャディでの取り組み事例を簡単に紹介します。 キャディでは組織リソースに対してもIaCを行い、プロジェクトの横断的な構成管理やガバナンス強化を実施しています。IaC用のGitHubリポジトリは責務ごとに分離していますが、おもに2種のリポジトリから構成されます。 一つは本稿で解説してきた、プロダクトにひも付くIaCリポジトリです(以下product-repo)。 product-repoはプロダクトごとに作成され、対応するGoogle Cloudプロジェクトに関連するリソースを管理します。 そしてもう一つは組織全体を管理するIaCリポジトリです(以下org-repo)。org-repoでは横断的に設定したい項目や、フォルダに対するIAM設定を管理しています。 具体的には、org-repoで次のようなリソースを管理しています。

・tfstate用のStorageバケット作成 ・GitHub ActionsでTerraformを実行するためのセットアップ作業 ・Workload Identityプール、プロバイダ作成 ・サービスアカウント作成 ・ product-repoにGitHub Actionsシークレット登録5 ・組織ポリシーの管理 ・セキュリティ基盤向けのLogging転送設定

新規プロダクトが作成された際には、product-repoとプロジェクトの対応関係をorg-repoに追加します。org-repo上のワークフローによって、GitHub ActionsでTerraformを実行するための各種セットアップが行われます。セットアップを自動化することで、product-repoのCI/CD環境をすばやく開発者へ提供できます(図6)。 このような取り組みを通してキャディでは運用のスケーラビリティ向上を目指しています。

▼図6 横断管理のアーキテクチャ

おわりに

前回から2回にわたりTerraformとGitHub Actionsを組み合わせたIaCのCI/CDパイプラインについて紹介しました。手作業によるミスをなくしつつ、安全にすばやくリリースするためにIaCとCI/CDは欠かせない要素です。本稿がみなさんの運用負荷を下げるヒントになれば幸いです。 次回はRenovateを用いたライブラリの自動更新について紹介します。