repl.info

terraformのルートモジュールを分割して変更箇所のみplan/applyするためのテクニック2025-01

Terraformのコードが肥大化するに従い、planやapplyの実行時間が長くなる問題がある。本記事では、2025年01月現在で試している方針について紹介する。

大きな方針は以下の2つ。

  1. ルートモジュールを細かく分割する
  2. 変更によって影響を受けるルートモジュールのみplan/applyの対象とする

ルートモジュールを細かく分割する

ルートモジュールは環境(production、stagingなど)で分割し、さらにネットワークやストレージ、アプリケーションのように分割する。これにより、各ルートモジュールの管理するリソースを小さく保つことを目指す。

環境毎にルートモジュールを分割する

この分割はこれまでも行っていた。productionやstaging、devのような環境毎にディレクトリを作成し、リソースを管理する。

env
├── production
└── staging

環境毎のルートモジュールをさらに細かく分割する

環境単位の分割では、リソースが増えるとplanの時間が伸びる。これを解消するため、各環境ディレクトリ内でさらに細かくディレクトリを分割する。分割の単位はまだ試行錯誤中だが、極力ルートモジュール間に依存関係が発生しないように抽象化するのがよいと考えている。

.
├── app_1
│   ├── backend.tf
│   ├── main.tf
│   ├── provider.tf
│   ├── remote_state.tf
│   └── terragrunt.hcl
├── network
│   ├── backend.tf
│   ├── main.tf
│   ├── provider.tf
│   └── terragrunt.hcl
├── parameters
│   ├── backend.tf
│   ├── main.tf
│   ├── provider.tf
│   └── terragrunt.hcl
└── terragrunt.hcl

ルートモジュール間の依存解決

ルートモジュールを分割管理する際、どうしてもルートモジュール間に依存関係が生まれることがある。例えば、ECSサービスを管理するルートモジュールがネットワークを管理するルートモジュールから、terraform_remote_stateを使ってVPCの情報を取得するケースが挙げられる。この時、依存するルートモジュールに変更がある場合、同時にplan/applyを行いたい。これを解決するため、terragruntのdependencyを用いてルートモジュール間の依存関係を定義する。

include {
  path = find_in_parent_folders()
}

dependency "network" {
  config_path = "${dirname(find_in_parent_folders())}/network"
}

依存関係を定義しておくことで、terragrunt経由でplanやapplyを行うと依存関係を解決してplan順序を決めてくれる。また、–terragrunt-include-dir オプションを使うことでplan対象のルートモジュールを絞ることもできる。

% trragrunt run-all plan --terragrunt-include-dir=app_1                                
09:23:20.212 INFO   The stack at . will be processed in the following order for command plan:
Group 1
- Module ./network

Group 2
- Module ./app_1

terragruntの利用

前述のとおり、ルートモジュール間の依存関係を定義し、plan/applyの実行のみにterragruntを使っている。以下のようにterragrunt.hclでルートモジュールのソースを定義することもできるが、現時点では利用していない。

terraform {
  source = "../../../modules/app_1"
}

ルートモジュール内のmain.tfの中で素朴にモジュールを呼び出す方法を採用している。terragruntをどの程度使い続けるか分からないので、依存を極力小さくしておくことが目的である。

module "app_1" {
  source             = "../../../modules/app_1"
  aws_region         = "ap-northeast-1"
  backend_subnet_ids = local.backend_subnet_ids
  env                = "staging"
  vpc_id             = local.vpc_id
}

PullRequestの変更に関係するルートモジュールのみplan/applyの対象とする

素朴に trragrunt run-all plan を実行すると、すべてのルートモジュールに対してplanを行うため、実行に時間がかかる。変更が必要なルートモジュールのみを対象としたい。

まずは変更対象の環境を絞る。これには dorny/paths-filter を使う。これにより、後続のジョブが needs.changed_env.outputs.changes を参照することで変更対象の環境を知ることができる。

jobs:
  changed_env:
    runs-on: ubuntu-24.04
    permissions:
      pull-requests: read
    outputs:
      changes: ${{ steps.filter.outputs.changes }}
    steps:
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            staging:
              - '.github/workflows/apply.yaml'
              - '.github/workflows/plan.yaml'
              - 'terraform/env/staging/**'
              - 'terraform/modules/**'
            production:
              - '.github/workflows/apply.yaml'
              - '.github/workflows/plan.yaml'
              - 'terraform/modules/**'
              - 'terraform/env/production/**'            

後続のジョブで needs.changed_env.outputs.changes をmatrixに渡すことで、変更対象の環境のみplanを行うことができる。

jobs:
  changed_env:
    runs-on: ubuntu-24.04
    permissions:
      pull-requests: read
    outputs:
      changes: ${{ steps.filter.outputs.changes }}
    steps:
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            staging:
              - '.github/workflows/apply.yaml'
              - '.github/workflows/plan.yaml'
              - 'terraform/env/staging/**'
              - 'terraform/modules/**'
            local:
              - '.github/workflows/apply.yaml'
              - '.github/workflows/plan.yaml'
              - 'terraform/modules/**'
              - 'terraform/env/local/**'            
  plan:
    needs: changed_env
    strategy:
      matrix:
        env: ${{ fromJSON(needs.changed_env.outputs.changes) }}
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - name: Plan
        uses: gruntwork-io/terragrunt-action@v2
        with:
          tf_version: "1.9.8"
          tg_version: "v0.69.6"
          tg_dir: "terraform/env/${{ matrix.env }}/"
          tg_command: "run-all plan"

ただし、このままだとplan対象の環境は最小限にできるが、ルートモジュールはすべてが対象となる。次に、変更すべきルートモジュールのみplan対象としていく。

変更すべきルートモジュールの抽出には https://github.com/hashicorp/terraform-config-inspect を使う。terraform-config-inspectはモジュールに関するメタデータをソースコードから取得するためのライブラリ・CLIツールである。これを使うことで何のモジュールを呼び出しているか取得できる。

% terraform-config-inspect --json . | jq ".module_calls"
{
  "app_1": {
    "name": "app_1",
    "source": "../../../modules/app_1",
    "pos": {
      "filename": "main.tf",
      "line": 6
    }
  }
}

terraform-config-inspectを使って再帰的に呼び出しているモジュールを取得することで、ルートモジュールが依存するモジュール一覧を取得できる。PullRequestの差分とモジュール一覧を比較することで、どのroot moduleでplanすればいいかを算出できる。ロジックをカスタムアクションとして実装し、ワークフローから呼び出すこととした。terragrunt run-all planに渡すオプションをカスタムアクションからアウトプットし、それを後段のステップで使う。これにより、最小限のplanを実現できる。

name: list target
description: list target

inputs:
  base_branch:
    description: "base branch"
    required: true
  base_dir:
    description: "base directory"
    required: true
  search_path:
    description: "search path"
    required: true

outputs:
  targets:
    description: "list of target files"
    value: ${{ steps.list-target.outputs.targets }}
  terragrunt_flags:
    description: "list of terragrunt flags"
    value: ${{ steps.list-target.outputs.terragrunt_flags }}

runs:
  using: composite
  steps:
    - uses: actions/setup-node@v4
      with:
        node-version: 22.11.0
    - run: npm install
      shell: bash
      working-directory: ./.github/actions/list-target
    - run: npx tsx ./src/index.ts
      id: list-target
      shell: bash
      working-directory: ./.github/actions/list-target
      env:
        INPUT_BASE_DIR: ${{ inputs.base_dir }}
        INPUT_SEARCH_PATH: ${{inputs.search_path }}
        INPUT_BASE_BRANCH: ${{inputs.base_branch }}

Index.tsは以下の通り。

import * as core from "@actions/core";
import * as exec from "@actions/exec";
import * as fs from "fs";
import * as path from "path";

const getChangedFiles = async (baseDir: string, baseBranch: string) => {
    await exec.getExecOutput("git", [
        "fetch",
        "--depth=1",
        "origin",
    ]);
    const out = await exec.getExecOutput("git", [
        "diff",
        "--name-only",
        `origin/${baseBranch}`,
    ]);
    return out.stdout.split('\\n').filter(file => file != '').map(file => path.join(baseDir, file))
}

const getModuleCalls = async (dir: string) => {
    const calls = []
    const outInspect = await exec.getExecOutput("terraform-config-inspect", [
        "--json",
        dir,
    ], {silent: true});

    const moduleCalls = JSON.parse(outInspect.stdout).module_calls
    for (const key in moduleCalls) {
        const dependencies = await getModuleCalls(path.join(dir, moduleCalls[key].source))
        for (const dependency of dependencies) {
            calls.push(dependency)
        }
        calls.push(path.join(dir, moduleCalls[key].source))
    }
    return removeDuplicates(calls)
}

const removeDuplicates = (arr: string[]) => {
    return [...new Set(arr)]
}

const findTargets = async (searchPath: string) => {
    const searchPattern = /\\.tf$/;
    const searchText = 'backend "s3"';
    const directories = new Set<string>();

    const searchFiles = (dir: string) => {
        const files = fs.readdirSync(dir);
        for (const file of files) {
            const fullPath = path.join(dir, file);
            const stat = fs.statSync(fullPath);
            if (stat.isDirectory()) {
                searchFiles(fullPath);
            } else if (searchPattern.test(file)) {
                const content = fs.readFileSync(fullPath, 'utf-8');
                if (content.includes(searchText)) {
                    directories.add(dir);
                }
            }
        }
    };

    searchFiles(searchPath);
    return Array.from(directories).filter(dir => !dir.includes('.terragrunt-cache'));
}

export const main = async () => {
    const baseBranch = core.getInput("base_branch")
    const baseDir = core.getInput("base_dir")
    const searchPath = core.getInput("search_path")

    const targetCandidates = await findTargets(path.join(baseDir, searchPath))
    const changes = await getChangedFiles(baseDir, baseBranch)
    const targets = []

    for (const candidate of targetCandidates) {
        console.log(`Candidate: ${candidate}`)
        const calls = await getModuleCalls(candidate)
        for (const change of changes) {
            const dir = path.dirname(change)
            if (calls.includes(dir)) {
                console.log(`Change ${change} uses module in ${path.basename(candidate)}`)
                targets.push(path.basename(candidate))
            }
        }
    }
    const json = JSON.stringify(targets)
    core.info(`targets: ${json}`);
    core.setOutput("targets", json);

    core.info(`terragrunt_flags: ${targets.map(target => `--terragrunt-include-dir=${target}`).join(' ')}`);
    core.setOutput("terragrunt_flags", targets.map(target => `--terragrunt-include-dir=${target}`).join(' '));
}

main()

ワークフロー内のジョブ定義はこのようになる。

jobs:
  changed_env:
    runs-on: ubuntu-24.04
    permissions:
      pull-requests: read
    outputs:
      changes: ${{ steps.filter.outputs.changes }}
    steps:
      - uses: dorny/paths-filter@v3
        id: filter
        with:
          filters: |
            staging:
              - '.github/workflows/apply.yaml'
              - '.github/workflows/plan.yaml'
              - 'terraform/env/staging/**'
              - 'terraform/modules/**'
            production:
              - '.github/workflows/apply.yaml'
              - '.github/workflows/plan.yaml'
              - 'terraform/modules/**'
              - 'terraform/env/production/**'
  plan:
    needs: changed_env
    strategy:
      matrix:
        env: ${{ fromJSON(needs.changed_env.outputs.changes) }}
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: aquaproj/aqua-installer@v3.1.0
        with:
          aqua_version: v2.42.1
      - uses: ./.github/actions/list-target
        id: list-target
        with:
          base_branch: ${{ github.base_ref }}
          base_dir: ${{ github.workspace }}
          search_path: "terraform/env/${{ matrix.env }}"
      - name: Plan
        working-directory: "terraform/env/${{ matrix.env }}/"
        run: terragrunt run-all plan -no-color -lock=false -out=plan.tfplan ${{ steps.list-target.outputs.terragrunt_flags }}

既存の手法

tfaction

@szkdash による https://github.com/suzuki-shunsuke/tfaction はterraform-config-inspectとterragruntを用いた実行対象の特定機能を持っている。tfactionの利用も検討したが、terragruntの利用方法がtfactionの想定とは異なり動作しなかったため、今回は自前で実装することにした。実装にあたり、tfactionを参考にさせていただいた。ありがとうございます。

tfdir

トラーナによる https://github.com/torana-us/tfdir も、terraform-config-inspectを使った実行対象の特定用ツールである。今回、実装が終わってから存在に気がついた。

課題

この方法だと、PullRequestの変更が影響するルートモジュールしかplanを行わない。そのため、plan対象ではないルートモジュールのResource Driftを検知できないという課題がある。tfactionはDrift Detection機能を備えているため、これを導入することで解決できるのではないかと考えている。