terraformのルートモジュールを分割して変更箇所のみplan/applyするためのテクニック2025-01
Terraformのコードが肥大化するに従い、planやapplyの実行時間が長くなる問題がある。本記事では、2025年01月現在で試している方針について紹介する。
大きな方針は以下の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機能を備えているため、これを導入することで解決できるのではないかと考えている。