Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

terraform planの自動化に向けて直面した課題と解決策

はじめに

こんにちは! 技術本部 Bill One Engineering Unit(以下、Bill One EU)の笹島です。

IaC推進チーム(横串チームの1つ)として、CI環境でのTerraform Planの自動化に取り組んできました。 横串チームとは、Bill One EU内の各グループの垣根のない横断チームであり、Bill Oneで抱えている課題を解決するために有志で集まったメンバーによって構成されています。 IaC推進チームとは、文字通りインフラのコード化を推進するチームです。

本記事では、CI環境でセキュアなTerraform Plan自動実行を実現するにあたって直面した課題とその解決策について共有します。 特に、モノレポ環境での複数プロダクト・環境の管理における自動化の課題についても紹介します。

目次

前提

ディレクトリ構成とその役割

Bill Oneではインフラのコード化にTerraformを積極的に採用しています。 Bill OneにおけるStateの適切な管理やディレクトリ構成に関する考え方については、以前のブログ記事を参照ください。 本章では、それらを踏まえた上で実際に各ディレクトリがどういった役割を果たしているのかを掘り下げてみたいと思います。

実際のディレクトリ構成は次のようになっています。

.
├── product_A
│   ├── microservices
│   │   ├── service_A
│   │   │   ├── environments
│   │   │   │   ├── prod
│   │   │   │   │   ├── main.tf
│   │   │   │   │   └── provider.tf
│   │   │   │   ├── stg
│   │   │   │   └── dev
│   │   │   └── modules
│   │   │       ├── cloud_sql.tf
│   │   │       ├── ... (tf files)
│   │   │       └── variables.tf
│   │   ├── service_B
│   │   └── ... (service directories)
│   └── shared_resources
│       ├── resource_A
│       │       ├── environments
│       │       └── modules
│       ├── resource_B
│       └── ... (resource directories)
└── product_B
    ├── microservices
    └── shared_resources

Bill OneのTerraformプロジェクトは複数のプロダクトを横断する形で構成されています。ここでは、product_Aとproduct_Bを例に、どのようにインフラリソースを管理しているかを掘り下げていきます。

Microservices: product_Aとproduct_Bの下に位置するmicroservicesディレクトリは、個々のマイクロサービスに関連するインフラリソースを専門的に扱います。この階層では、各サービスが独自のリソースセットを持ち、それらの管理を独立させることで、効率的な作業分担を実現しています。

Shared Resources: shared_resourcesディレクトリは、product_Aやproduct_Bに共通するインフラリソース、例えばネットワークリソースやIAMを管理しています。これにより、異なるマイクロサービス間でリソースの再利用が可能になり、一貫性と効率性を保ちながらインフラを構築できます。

Environments: environmentsディレクトリではprod(本番環境)、stg(ステージング環境)、dev(開発環境)といった、環境別のTerraform設定が格納されています。これらのサブディレクトリは、実際にTerraformのinit、plan、applyといったコマンドを実行する作業ディレクトリとして機能します。

Modules: modulesディレクトリでは、サービス固有のモジュールや共有リソース用のモジュールを集約することにより、効率的な管理を実現しています。

Workload Identity連携

Workload Identityの基本

Bill OneのサービスはGoogle Cloud上に構成されています。GitHub Actionsを通じたCI/CDプロセスでGoogle Cloudリソースを安全に操作するためには、Workload Identityという機能が欠かせません。 Workload Identityは、Google Cloud上で実行されるアプリケーションのセキュリティを強化するための重要な機能です。この仕組みを使用することで、アプリケーションはGoogle Cloudのサービスやリソースに対して、適切なサービスアカウントを介して安全にアクセスできます。これにより、不正アクセスのリスクを軽減しつつ、アクセス権限を精密に管理できます。

プロジェクト構成とWorkload Identityの役割

Bill Oneのプロジェクトでは、複数の開発・ステージング・本番環境が存在し、それぞれが独立したGoogle Cloudプロジェクトとして構成されています。Workload Identityを各プロジェクトに適用することで、環境ごとにカスタマイズされたセキュリティポリシーを実施し、細かくアクセス制御を行っています。

Workload Identity設定の目的

GitHub Actionsを使用したCI/CDプロセス中にGoogle Cloudリソースを安全に操作するため、Workload Identityの導入が必要になってきます。これにより、GitHub ActionsのランナーがGoogle Cloudサービスに対して適切なサービスアカウントを用いてアクセスすることが保証され、セキュリティと操作性の向上が実現されます。

直面した課題

本セクションでは、GitHub Actionsを使用してTerraform Planの自動化を図る過程で遭遇した主要な挑戦を2つ取り上げ、それらにどのように対処したかを説明します。今回構築したワークフローの概略図は次のようになります。

ワークフローの概略図

それぞれのジョブは次のような処理を行います。

  • service_filterジョブ、env_filterジョブ
    • コード差分から、terraform planを実行する作業ディレクトリを特定
  • tf_planジョブ
    • terraform planを実行

挑戦1: Terraform Planの自動化のためのディレクトリ探索

今回作成したCIの基本設計

Bill Oneのプロジェクトは、前提でも述べたように複数のサービスとそれぞれの環境を含む複雑な構造を持っています。 このプロジェクト構造の中、CIプロセスを効率化するため、コードの変更があった部分だけを対象にTerraform Planを実行するようにしました。 このアプローチは、CIプロセスのリソース効率を高め、変更の影響範囲を容易に把握できます。

技術的課題: GitHub ActionsのMatrix JobとRunnerの特性

この基本設計を実現するために、GitHub ActionsのMatrix Job機能を活用しました。Matrix Jobは、複数のインプットに基づいて並列処理を実行できる強力な機能です。 しかし、Matrix Jobを導入する過程で、GitHub ActionsのRunnerが独立した環境上で各ジョブを実行するという特性に直面しました。この特性により、1つのジョブで生成されたアウトプットを別のジョブで直接活用するのが困難になるという制約が明らかになりました。 具体的には、並列実行されるジョブ間でのアウトプット共有が直接的にはサポートされていないため、ディレクトリを特定して後続のTerraform Planを実行するジョブへスムーズにつなげるのは一筋縄ではいきませんでした。

具体的な実例

ここからは、実際に直面した課題を実例ベースで説明していきたいと思います。CIの基本設計を基に、terraform planの作業ディレクトリ特定のため二段階のジョブを計画しました。 まずジョブ(service_filterジョブ。以降、親ジョブ)では、GitHub Actionsのdorny/paths-filterアクションを使用して、変更が加えられたenvironmentsmodulesディレクトリの1つ上のレベルを特定します。 このアプローチにより、変更のあったサービスまたはリソースが明確になり、後続のプロセスでの作業範囲を絞り込めます。例えば、product_Amicroservices/service_Aに変更があった場合、親ジョブは次のように設定され、変更されたディレクトリ(この場合はservice_A)を特定します。

service_filter:
  runs-on: ubuntu-latest
  outputs:
    directories: ${{ steps.service_filter.outputs.changes }}
  steps:
    - uses: actions/checkout@v4
    - uses: dorny/paths-filter@v3
      id: service_filter
      with:
        filters: |
          product_A/microservices/service_A:
            - 'product_A/microservices/service_A/**'

この親ジョブで期待されるアウトプットとしては、次のような変更が加えられた複数のサービスやリソースディレクトリが挙げられます。

["product_A/microservices/service_A", "product_A/shared_resources/resource_A"]

二段階目のジョブ(env_filterジョブ。以降、子ジョブ)では、親ジョブで特定されたディレクトリから、 それらの配下のenvironmentsディレクトリ内にどの環境があるか特定します。 このステップは、Terraform Planの実行における作業ディレクトリを明確化するために不可欠です。 environmentsディレクトリには、開発(dev)、ステージング(stg)、本番(prod)といった環境ごとのTerraform設定が格納されており、これらのディレクトリが実際にTerraformのコマンドを実行する場所となります。

子ジョブでは、次のMatrix Jobを用いて、親ジョブから受け取ったディレクトリに対して並列に作業します。このプロセスにより、各環境に応じたTerraform Planを効率的に実行する準備が整います。

env_filter:
  runs-on: ubuntu-latest
  needs: [service_filter]
  strategy:
    matrix:
      directory: ${{ fromJson(needs.service_filter.outputs.directories) }}
  outputs:
    working_directory: # outputs
  steps:
    # ここでの探索処理(省略)

しかし、今回採用した設計ではMatrix Jobの制約により、子ジョブで動的に特定された作業ディレクトリの情報を後続のジョブで直接活用することが技術的に困難となり、プロセスの自動化において障壁となりました。

解決策: 直列実行によるアプローチ

この問題に対処するため、Matrix Jobを用いた並列処理からシェルスクリプトを利用した直列処理へと方針を転換しました。この選択の背景には、次の理由があります。

  1. 直感的な操作性と理解の容易さ: シェルスクリプトは開発者にとって馴染みが深く、動作原理を直感的に理解しやすいため、タスクの実行、デバッグ、メンテナンスが容易になります。
  2. 実行時間の効率性: 事前評価で親ジョブから渡されるアウトプット量が多くないと予想されたため、直列実行でもMatrix Jobを用いた並列実行と比較して実行時間に大きな差が出ないと判断しました。
  3. 実装のシンプルさ: 外部ツールやサービスへの依存なしに、GitHub Actionsの既存環境とシェルスクリプトのみで要件を満たすことが可能であるため、実装がシンプルになります。

これらの理由に基づき、次のシェルスクリプトを用いたジョブを作成しました。 親ジョブで特定したコード差分が発生しているディレクトリを基準に、Terraform Planを実行する適切なディレクトリを動的に探索します。

env_filter:
  runs-on: ubuntu-latest
  needs: [service_filter]
  outputs:
    working_directory: ${{ steps.env_filter.outputs.working_directory }}
  steps:
    - uses: actions/checkout@v4
    - name: Filter Working Directory
      id: env_filter
      env:
        DIRECTORIES: ${{ needs.service_filter.outputs.directories }}
      run: |
        # 初期化: 結果を格納する配列
        result_array=()

        # 親ジョブから渡されたディレクトリリストを元にループ処理
        for dir in $(echo "$DIRECTORIES" | jq -r '.[]'); do
            # .terraform.lock.hcl ファイルを含むディレクトリを探索し、Terraformを実行するディレクトリとして特定
            apply_dirs=$(find "$dir" -name '.terraform.lock.hcl' -exec dirname {} \; | sort -u)

            # 特定したディレクトリを結果配列に追加
            for apply_dir in $apply_dirs; do
                result_array+=("$apply_dir")
            done
        done

        # 結果配列をJSON形式に変換(GitHub ActionsのMatrixで使用するため)
        result_json=$(echo -n "${result_array[@]}" | jq -R -s -c 'split(" ")')

        # 結果をGitHub Actionsのアウトプットとして設定
        echo "working_directory=$result_json" >> "$GITHUB_OUTPUT"

挑戦2: Workload Identityを用いたプロジェクト特定と認証

基本設計とプロジェクトの構造

Bill Oneのプロジェクトは、開発・ステージング・本番環境と複数の環境を持つproduct_Aproduct_Bがそれぞれ独立したGoogle Cloudプロジェクトに対応しています。 これらの環境は、個別のWorkload Identity設定によってセキュリティポリシーが管理されており、 CIプロセス内でTerraform Planを実行するにはそれぞれの環境へ適切にアクセスすることが求められます。

技術的課題: 動的なプロジェクト特定と認証の複雑性

前述のプロジェクト構造とCIプロセスの設計を踏まえると、CIプロセス内で動的にGoogle Cloudプロジェクトを特定し、適切な認証を行う必要がありました。 具体的には、子ジョブで得られる次のようなディレクトリパスからプロダクトと環境を解析し、Google Cloudプロジェクトを特定し適切な認証を行なっていくことが1つの試練となりました。

["product_A/microservices/service_A/environments/dev", "product_A/microservices/service_A/environments/stg", "product_A/microservices/service_A/environments/prod"]

解決策: ディレクトリ構成のルール化とシェルスクリプトによる自動化

この課題に対処するため、次のようなディレクトリ構成のルール化を明文化しました。これにより、CIプロセス内でGoogle Cloudプロジェクトを動的に特定するための基盤を整えました。

<プロダクト>/microservices or shared_resources/<コンポーネント名>/environments or module/<環境名>/(以降は自由)

この構成を基に、子ジョブで特定されたディレクトリからプロダクトと環境を解析し、 google-github-actions/authアクションを使用してWorkload Identity Poolへのアクセスと認証を自動化するシェルスクリプトを導入しました。 このプロセスを実装したコードは次のようになります。

tf_plan:
  name: terraform plan
  runs-on: ubuntu-latest
  needs: [service_filter, env_filter]
  strategy:
    matrix:
      directory: ${{ fromJson(needs.env_filter.outputs.directory) }}
  permissions:
    contents: read
    id-token: write
    pull-requests: write
  steps:
    - name: Clone Repo
      uses: actions/checkout@v4

    - name: Check Environment
      id: check_env
      run: |
        # matrix.directoryの例: product_A/microservices/service_A/environments/dev
        path="${{ matrix.directory }}"

        product_segment=$(echo "$path" | awk -F '/' '{print $1}')
        environment_segment=$(echo "$path" | awk -F '/' '{print $5}')
        project="${product_segment}-${environment_segment}"

        echo "environment=$environment_segment" >> "$GITHUB_OUTPUT"
        echo "project=$project" >> "$GITHUB_OUTPUT"

    - name: Authenticate to Google Cloud
      uses: google-github-actions/auth@v2
      env:
        PROJECT_NUM: ${{ fromJson('{ "product_A-dev": "xxxx", "product_A-stg": "yyyy", ... }')[steps.check_env.outputs.project] }}
      with:
        workload_identity_provider: "projects/${{ env.PROJECT_NUM }}/locations/global/workloadIdentityPools/workflow-oidc-pool/providers/workflow-oidc-provider"
        service_account: "github-actions-for-terraform@${{ steps.check_env.outputs.project }}.iam.gserviceaccount.com"

このアプローチは、自動化実現だけでなく、開発チームがプロジェクト構造を容易に把握し、新たなサービスや機能の追加時に一貫性を維持する助けになっていると思います。

Terraform Planの実行部分

挑戦1と2を通じて、Terraform Planを効率的に実行するためのディレクトリ特定と必要な認証のステップを構築しました。 最後にそれらのプロセスを活かし、今回のCI構築のゴールである、Terraform Planの結果をプルリクエストで確認できるプロセスを実装します。 このプロセスの中心となるのがtfcmt、Terraformの出力をGitHubのコメントとして投稿するCLIツールです。 このツールを用いて、コードの変更差分に関係するTerraform Planの結果を可視化し、レビューの効率性を向上させました。

GitHub上にはTerraform Planの結果は次のように表示されます。

GitHub上にterraform planの結果を表示

また、今回作成したジョブは次のようになります。

tf_plan:
  # 省略: 挑戦2の解決策参照
  strategy:
    matrix:
      directory: ${{ fromJson(needs.env_filter.outputs.directory) }}
  steps:
    - name: Clone Repo
      uses: actions/checkout@v4

    - name: Check Environment
      # 挑戦2の解決策参照

    - name: Authenticate to Google Cloud
      # 挑戦2の解決策参照

    - name: Setup Terraform
      uses: hashicorp/setup-terraform@v3
      with:
        terraform_version: # version設定

    - name: Terraform Init
      working-directory: ${{ matrix.directory }}
      run: |
        terraform init

    - name: Install tfcmt
      env:
        TFCMT_VERSION: # version設定
      run: |
        wget "https://github.com/suzuki-shunsuke/tfcmt/releases/download/${{ env.TFCMT_VERSION }}/tfcmt_linux_amd64.tar.gz" -O /tmp/tfcmt.tar.gz
        tar xzf /tmp/tfcmt.tar.gz -C /tmp
        mv /tmp/tfcmt /usr/local/bin

    - name: Post Terraform Plan to PR
      working-directory: ${{ matrix.directory }}
      run: |
        tfcmt -var "target:${{ matrix.directory }}" --config $(git rev-parse --show-toplevel)/.github/tfcmt.yml plan -patch -- terraform plan

おわりに

今回は、モノレポ環境において、terraform planの結果を確認するCIを構築する過程で直面した課題を共有してきました。 今後は、applyの自動化を含んだCIのさらなる拡充を行なっていければと考えています。

最後までお読みいただきありがとうございました!この記事が何らかの形で参考になれば幸いです。



20240312182329

© Sansan, Inc.