はじめに
TerraformのCI/CDを構築し、普段アプリケーション開発で行っているフローを踏襲することで、Terraformのコードの品質を担保することができます。
この記事では、チームで開発することを想定して構築したTerraformの実践的なCI/CDについて書こうと思います。
構築する際は以下のことを考慮しました。
- コードの変更があったディレクトリでのみplanを実行する
- tfsec, tflintなどでコードのチェックを行う
- プルリクエストでplan結果やチェックで発生したエラーを確認できるようにする
- プラグインをキャッシュして高速化する
Githubに今回構築するCI/CDを含む簡単なサンプルプロジェクトをあげているので、こちらも参考にしてください。
Terraformのディレクトリ構成については前回投稿したこちらの記事を参照してください。
前提
- 環境
開発環境(dev)と本番環境(prod)の二つを構築 - 運用フロー
ブランチ戦略はgit-flowに近い形にしています。
mainブランチからdevelopブランチを作成し、developブランチからfeatureブランチを作成し、開発はfeatureブランチで行います。
開発時のフローは以下のようなものを想定しています。
①featureブランチで実装
②featureブランチからdevelopブランチにプルリクを作成
③開発環境に対してplanを実行
④問題がなければプルリクをマージ
⑤開発環境に対してapplyを実行
⑥developブランチからmainブランチにプルリクを作成
⑦本番環境に対してplanを実行
⑧問題がなければプルリクをマージ
⑨本番環境に対してapplyを実行
使用するツール
ツール名 | 概要 |
---|---|
Terragrunt | Terragruntは、Terraformの設定を簡素化し、共有可能なコードモジュールを再利用するためのツールです。Terraformのベストプラクティスをサポートします。 |
tfsec | tfsecは、Terraformのセキュリティ設定をスキャンして、潜在的なセキュリティリスクを見つけるための静的解析ツールです。 |
tflint | tflintは、Terraformコードの潜在的な問題を検出するためのコードチェッカーです。シンタックスとベストプラクティスに対してチェックを行います。 |
reviewdog | reviewdogは、コードレビュー時に静的解析ツールの出力をフィードバックするツールです。多くの言語とツールに対応しています。 |
tfcmt | tfcmt は terraform plan, apply の結果を GitHub の Pull Request にコメントとして通知する CLI ツールです。 |
aqua | aquaは、CLI ツールを YAML でバージョン管理できるツールです。 |
planワークフロー
planワークフローの全体はこちらから確認してください
name: Terraform plan
on:
pull_request:
branches:
- main
- develop
types:
- opened
- synchronize
env:
AWS_REGION: ${{ vars.AWS_REGION }}
SYSTEM: ${{ vars.SYSTEM }}
DEV_AWS_ACCOUNT_ID: ${{ secrets.DEV_AWS_ACCOUNT_ID }}
PROD_AWS_ACCOUNT_ID: ${{ secrets.PROD_AWS_ACCOUNT_ID }}
permissions:
contents: read
id-token: write
pull-requests: write
actions: read
jobs:
notify-started:
uses: ./.github/workflows/_notify_started.yml
secrets: inherit
setup:
runs-on: ubuntu-latest
outputs:
modules_changed_dirs: ${{ steps.modules_changes.outputs.changes }}
envs_changed_dirs: ${{ steps.filter_changed_envs_dirs.outputs.envs_changed_dirs}}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Get changed modules dirs
uses: dorny/paths-filter@v2
id: modules_changes
with:
filters: .github/modules-path-filter.yml
- name: Get changed envs dirs
uses: dorny/paths-filter@v2
id: envs_changes
with:
filters: .github/envs-path-filter.yml
- name: Filter changed envs dirs
id: filter_changed_envs_dirs
run: |
dirs=${{ toJSON(steps.envs_changes.outputs.changes) }}
if [ ${{ github.base_ref }} == 'main' ]; then
env_type='prod'
elif [ ${{ github.base_ref }} == 'develop' ]; then
env_type='dev'
else
echo "Unsupported base_ref: ${{ github.base_ref }}" >&2
exit 1
fi
env_changed_dirs=$( echo "${dirs}" | jq '.[]' | grep $env_type | jq -sc )
echo "envs_changed_dirs=${env_changed_dirs}" >> $GITHUB_OUTPUT
modules-ci:
needs: setup
if: needs.setup.outputs.modules_changed_dirs != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
changed_dir: ${{ fromJson(needs.setup.outputs.modules_changed_dirs) }}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup aqua
uses: aquaproj/aqua-installer@v2.1.2
with:
aqua_version: v2.9.0
aqua_opts: ""
- name: Set env vars for dev
if: github.base_ref == 'develop'
run: |
echo "ENVIRONMENT=dev" >> $GITHUB_ENV
echo "AWS_ACCOUNT_ID=$DEV_AWS_ACCOUNT_ID" >> $GITHUB_ENV
- name: Set env vars for prod
if: github.base_ref == 'main'
run: |
echo "ENVIRONMENT=prod" >> $GITHUB_ENV
echo "AWS_ACCOUNT_ID=$PROD_AWS_ACCOUNT_ID" >> $GITHUB_ENV
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1-node16
with:
role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.SYSTEM }}-${{ env.ENVIRONMENT }}-github-actions-terraform-role
role-session-name: ${{ env.SYSTEM }}-${{ env.ENVIRONMENT }}-github-actions-terraform-session
aws-region: ${{ env.AWS_REGION }}
- name: TFlint
working-directory: modules/${{ matrix.changed_dir }}
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tflint --config $GITHUB_WORKSPACE/.tflint.hcl --init
tflint --config $GITHUB_WORKSPACE/.tflint.hcl --format=checkstyle | \
reviewdog -f=checkstyle \
-name="tflint" \
-reporter=github-pr-review \
-filter-mode=nofilter \
-fail-on-error \
- name: Check Terraform fmt
working-directory: modules/${{ matrix.changed_dir }}
run: terraform fmt -check
envs-ci:
needs: setup
if: needs.setup.outputs.envs_changed_dirs != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
changed_dir: ${{ fromJson(needs.setup.outputs.envs_changed_dirs) }}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup aqua
uses: aquaproj/aqua-installer@v2.1.2
with:
aqua_version: v2.9.0
aqua_opts: ""
- name: Set env vars for dev
if: github.base_ref == 'develop'
run: |
echo "ENVIRONMENT=dev" >> $GITHUB_ENV
echo "AWS_ACCOUNT_ID=$DEV_AWS_ACCOUNT_ID" >> $GITHUB_ENV
- name: Set env vars for prod
if: github.base_ref == 'main'
run: |
echo "ENVIRONMENT=prod" >> $GITHUB_ENV
echo "AWS_ACCOUNT_ID=$PROD_AWS_ACCOUNT_ID" >> $GITHUB_ENV
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1-node16
with:
role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.SYSTEM }}-${{ env.ENVIRONMENT }}-github-actions-terraform-role
role-session-name: ${{ env.SYSTEM }}-${{ env.ENVIRONMENT }}-github-actions-terraform-session
aws-region: ${{ env.AWS_REGION }}
- name: Config Terraform plugin cache
run: |
echo 'plugin_cache_dir="$HOME/.terraform.d/plugin-cache"' >~/.terraformrc
mkdir --parents ~/.terraform.d/plugin-cache
- name: Cache Terraform Plugins
uses: actions/cache@v3
with:
path: ~/.terraform.d/plugin-cache
key: ${{ runner.os }}-terraform-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: |
${{ runner.os }}-terraform-
- name: Terragrunt init
working-directory: envs/${{ matrix.changed_dir }}
run: |
terragrunt init --terragrunt-non-interactive
- name: TFsec
working-directory: envs/${{ matrix.changed_dir }}
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tfsec --format=checkstyle | \
reviewdog -f=checkstyle \
-name="tfsec" \
-reporter=github-pr-review \
-filter-mode=nofilter \
-fail-on-error \
- name: TFlint
working-directory: envs/${{ matrix.changed_dir }}
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tflint --config $GITHUB_WORKSPACE/.tflint.hcl --init
tflint --config $GITHUB_WORKSPACE/.tflint.hcl --format=checkstyle | \
reviewdog -f=checkstyle \
-name="tflint" \
-reporter=github-pr-review \
-filter-mode=nofilter \
-fail-on-error \
- name: Check terragrunt fmt
working-directory: envs/${{ matrix.changed_dir }}
run: terragrunt fmt -check
- name: Terragrunt validate
working-directory: envs/${{ matrix.changed_dir }}
run: terragrunt validate
- name: Terragrunt plan
working-directory: envs/${{ matrix.changed_dir }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
terragrunt plan --terragrunt-tfpath $GITHUB_WORKSPACE/.github/scripts/tfwrapper.sh
notify-finished:
if: always()
needs: [modules-ci, envs-ci]
uses: ./.github/workflows/_notify_finished.yml
secrets: inherit
上から順に重要そうなポイントを説明していきます。
トリガー条件
on:
pull_request:
branches:
- main
- develop
types:
- opened
- synchronize
ワークフローのトリガーはmain, developブランチに対してプルリクが開かれた時と、プルリクが更新された時に設定しています。
setupジョブでコードの変更があったディレクトリの一覧を取得
setup:
runs-on: ubuntu-latest
outputs:
modules_changed_dirs: ${{ steps.modules_changes.outputs.changes }}
envs_changed_dirs: ${{ steps.filter_changed_envs_dirs.outputs.envs_changed_dirs}}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Get changed modules dirs
uses: dorny/paths-filter@v2
id: modules_changes
with:
filters: .github/modules-path-filter.yml
- name: Get changed envs dirs
uses: dorny/paths-filter@v2
id: envs_changes
with:
filters: .github/envs-path-filter.yml
- name: Filter changed envs dirs
id: filter_changed_envs_dirs
run: |
dirs=${{ toJSON(steps.envs_changes.outputs.changes) }}
if [ ${{ github.base_ref }} == 'main' ]; then
env_type='prod'
elif [ ${{ github.base_ref }} == 'develop' ]; then
env_type='dev'
else
echo "Unsupported base_ref: ${{ github.base_ref }}" >&2
exit 1
fi
env_changed_dirs=$( echo "${dirs}" | jq '.[]' | grep $env_type | jq -sc )
echo "envs_changed_dirs=${env_changed_dirs}" >> $GITHUB_OUTPUT
setupジョブの中でdorny/paths-filterというアクションを使用して、変更が加えられたディレクトリを取得します。
あらかじめ対象のファイルと、そのファイルに対するラベルを定義しておくことで、対象のファイルに差分が発生したラベルの一覧を取得することができます。
また、envs配下のディレクトリに関しては、自身のディレクトリだけでなく、依存するモジュールも対象に加えるようにしています。これによってモジュールだけが変更された場合でも適切にplanが実行されるようになります。
envs配下の設定ファイル
dev/app:
- 'envs/dev/app/**'
- 'modules/ecr/**'
- 'modules/ecs/**'
- 'modules/cognito_user_pool/**'
- 'modules/s3/**'
- 'modules/ses/**'
- 'modules/lambda/**'
prod/app:
- 'envs/prod/app/**'
- 'modules/ecr/**'
- 'modules/ecs/**'
- 'modules/cognito_user_pool/**'
- 'modules/s3/**'
- 'modules/ses/**'
- 'modules/lambda/**'
dev/cicd:
- 'envs/dev/cicd/**'
prod/cicd:
- 'envs/prod/cicd/**'
dev/log:
- 'envs/dev/log/**'
- 'modules/s3/**'
- 'modules/athena/**'
- 'modules/lambda/**'
prod/log:
- 'envs/prod/log/**'
- 'modules/s3/**'
- 'modules/athena/**'
- 'modules/lambda/**'
dev/network:
- 'envs/dev/network/**'
- 'modules/vpc/**'
prod/network:
- 'envs/prod/network/**'
- 'modules/vpc/**'
dev/operation:
- 'envs/dev/operation/**'
- 'modules/cloudtrail/**'
- 'modules/config/**'
prod/operation:
- 'envs/prod/operation/**'
- 'modules/cloudtrail/**'
- 'modules/config/**'
dev/routing:
- 'envs/dev/routing/**'
- 'modules/acm/**'
- 'modules/cloudfront/**'
- 'modules/elb/**'
- 'modules/waf/**'
prod/routing:
- 'envs/prod/routing/**'
- 'modules/acm/**'
- 'modules/cloudfront/**'
- 'modules/elb/**'
- 'modules/waf/**'
dev/security:
- 'envs/dev/security/**'
- 'modules/guardduty/**'
- 'modules/securityhub/**'
prod/security:
- 'envs/prod/security/**'
- 'modules/lambda/**'
- 'modules/guardduty/**'
- 'modules/securityhub/**'
dev/storage:
- 'envs/dev/storage/**'
- 'modules/rds_aurora/**'
prod/storage:
- 'envs/prod/storage/**'
- 'modules/rds_aurora/**'
modulesの設定ファイル
acm:
- 'modules/acm/**'
athena:
- 'modules/athena/**'
cloudfront:
- 'modules/cloudfront/**'
cloudtrail:
- 'modules/cloudtrail/**'
cognito_user_pool:
- 'modules/cognito_user_pool/**'
config:
- 'modules/config/**'
ecr:
- 'modules/ecr/**'
ecs:
- 'modules/ecs/**'
elb:
- 'modules/elb/**'
guardduty:
- 'modules/guardduty/**'
lambda:
- 'modules/lambda/**'
rds_aurora:
- 'modules/rds_aurora/**'
s3:
- 'modules/s3/**'
securityhub:
- 'modules/securityhub/**'
ses:
- 'modules/ses/**'
vpc:
- 'modules/vpc/**'
waf:
- 'modules/waf/**'
setupジョブでは差分が発生したディレクトリの一覧をアウトプットとして設定しており、のちのジョブでmatrixのインプットとして使用します。
modulesのCI
modules-ciジョブでモジュールに対するCIを実行します。
- matricsを設定
modules-ci:
needs: setup
if: needs.setup.outputs.modules_changed_dirs != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
changed_dir: ${{ fromJson(needs.setup.outputs.modules_changed_dirs) }}
setupジョブのアウトプットで設定した、差分が発生したモジュールディレクトリの一覧からmatrixを設定します。
これによって差分が発生したモジュール全てで並列に処理を実行することができます。
また、fail-fast: falseを指定することで、あるジョブが失敗しても他のジョブはキャンセルされずに最後まで実行されるようになります。
- aquaを使用してCLIツールをセットアップ
- name: Setup aqua
uses: aquaproj/aqua-installer@v2.1.2
with:
aqua_version: v2.9.0
aqua_opts: ""
aquaのアクションを使用して、設定ファイルに記述しているツールをインストールします。
設定ファイルは以下の通りです。
---
# aqua - Declarative CLI Version Manager
# https://aquaproj.github.io/
# checksum:
# # https://aquaproj.github.io/docs/reference/checksum/
# enabled: true
# require_checksum: true
# supported_envs:
# - all
registries:
- type: standard
ref: v4.23.0 # renovate: depName=aquaproj/aqua-registry
packages:
- name: aquasecurity/tfsec@v1.28.1
- name: hashicorp/terraform@v1.5.2
- name: gruntwork-io/terragrunt@v0.48.0
- name: terraform-linters/tflint@v0.47.0
- name: reviewdog/reviewdog@v0.14.2
- name: suzuki-shunsuke/tfcmt@v4.4.2
- name: direnv/direnv@v2.32.3
- tflintの実行
- name: TFlint
working-directory: modules/${{ matrix.changed_dir }}
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tflint --config $GITHUB_WORKSPACE/.tflint.hcl --init
tflint --config $GITHUB_WORKSPACE/.tflint.hcl --format=checkstyle | \
reviewdog -f=checkstyle \
-name="tflint" \
-reporter=github-pr-review \
-filter-mode=nofilter \
-fail-on-error
tflintを実行して、問題が発生した場合はreviewdogによってレビューコメントをつけるようにしています。
envs配下のCIとplan
envs-ciジョブでenvs配下のCIを行ったのち、planを実行します。
modulesのCIと共通の処理の説明は省きます。
- プラグインのキャッシュの設定
- name: Config Terraform plugin cache
run: |
echo 'plugin_cache_dir="$HOME/.terraform.d/plugin-cache"' >~/.terraformrc
mkdir --parents ~/.terraform.d/plugin-cache
- name: Cache Terraform Plugins
uses: actions/cache@v3
with:
path: ~/.terraform.d/plugin-cache
key: ${{ runner.os }}-terraform-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: |
${{ runner.os }}-terraform-
プラグインのキャッシュの設定を行うことで、initを実行するたびに毎回プラグインをインストールする必要がなくなるので、実行時間の短縮になります。
今回のようにモジュールを細かく分割していると、より恩恵を受けやすくなります。
キャッシュについて詳しくは下記の公式ドキュメントを参考にしてください。
- tfsecとtflintの実行
- name: TFsec
working-directory: envs/${{ matrix.changed_dir }}
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tfsec --format=checkstyle | \
reviewdog -f=checkstyle \
-name="tfsec" \
-reporter=github-pr-review \
-filter-mode=nofilter \
-fail-on-error
- name: TFlint
working-directory: envs/${{ matrix.changed_dir }}
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tflint --config $GITHUB_WORKSPACE/.tflint.hcl --init
tflint --config $GITHUB_WORKSPACE/.tflint.hcl --format=checkstyle | \
reviewdog -f=checkstyle \
-name="tflint" \
-reporter=github-pr-review \
-filter-mode=nofilter \
-fail-on-error
tflintと同じ要領でtfsecも実行します。
tfsecは使用しているモジュールの中身もチェックしてくれますが、tflintは現在のモジュールしかチェックしないため、モジュールのCIでもtflintを実行する必要がありました。
- planを実行し、結果をプルリクにコメント
- name: Terragrunt plan
working-directory: envs/${{ matrix.changed_dir }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
terragrunt plan --terragrunt-tfpath $GITHUB_WORKSPACE/.github/scripts/tfwrapper.sh
#!/bin/bash
set -euo pipefail
# コマンドの種類を取得(例: apply, plan, fmt...)
type=$(echo "$@" | awk '{print $1}')
# 実行しているディレクトリ名を取得
current_dir=$(pwd | sed 's/.*\///g')
if [ "$type" == "plan" ]; then
# planのときは-patchオプションを付ける
# 実行ディレクトリ名をターゲットとして指定
tfcmt -var "target:${current_dir}" plan -patch -- terraform "$@"
elif [ "$type" == "apply" ]; then
tfcmt -var "target:${current_dir}" apply -- terraform "$@"
else
terraform "$@"
fi
terragrunt planを実行し、tfcmtを使用して結果をプルリクエストにコメントします。
terragruntとtfcmtを組み合わせて使う方法は過去の投稿で説明しているので、詳細はそちらを参照ください。
applyワークフロー
applyワークフローの全体はこちらから確認してください
name: Terraform apply
on:
pull_request:
branches:
- develop
- main
types:
- closed
env:
AWS_REGION: ${{ vars.AWS_REGION }}
SYSTEM: ${{ vars.SYSTEM }}
DEV_AWS_ACCOUNT_ID: ${{ secrets.DEV_AWS_ACCOUNT_ID }}
PROD_AWS_ACCOUNT_ID: ${{ secrets.PROD_AWS_ACCOUNT_ID }}
permissions:
contents: read
id-token: write
pull-requests: write
actions: read
jobs:
notify-started:
if: github.event.pull_request.merged == true
uses: ./.github/workflows/_notify_started.yml
secrets: inherit
apply:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Set env vars for each env
run: |
if [ ${{ github.ref_name }} == 'main' ]; then
echo "ENVIRONMENT=prod" >> $GITHUB_ENV
echo "AWS_ACCOUNT_ID=${{ env.PROD_AWS_ACCOUNT_ID }}" >> $GITHUB_ENV
elif [ ${{ github.ref_name }} == 'develop' ]; then
echo "ENVIRONMENT=dev" >> $GITHUB_ENV
echo "AWS_ACCOUNT_ID=${{ env.DEV_AWS_ACCOUNT_ID }}" >> $GITHUB_ENV
fi
- name: Checkout
uses: actions/checkout@v3
- name: Setup aqua
uses: aquaproj/aqua-installer@v2.1.2
with:
aqua_version: v2.9.0
aqua_opts: ""
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1-node16
with:
role-to-assume: arn:aws:iam::${{ env.AWS_ACCOUNT_ID }}:role/${{ env.SYSTEM }}-${{ env.ENVIRONMENT }}-github-actions-terraform-role
role-session-name: ${{ env.SYSTEM }}-${{ env.ENVIRONMENT }}-github-actions-terraform-session
aws-region: ${{ env.AWS_REGION }}
- name: Config Terraform plugin cache
run: |
echo 'plugin_cache_dir="$HOME/.terraform.d/plugin-cache"' >~/.terraformrc
mkdir --parents ~/.terraform.d/plugin-cache
- name: Cache Terraform Plugins
uses: actions/cache@v3
with:
path: |
~/.terraform.d/plugin-cache
key: ${{ runner.os }}-terraform-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: |
${{ runner.os }}-terraform-
- name: Terragrunt run-all init
working-directory: envs/${{ env.ENVIRONMENT }}
run: |
terragrunt run-all init --terragrunt-non-interactive
- name: Terragrunt run-all apply
working-directory: envs/${{ env.ENVIRONMENT }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
terragrunt run-all apply --terragrunt-non-interactive --terragrunt-tfpath $GITHUB_WORKSPACE/.github/scripts/tfwrapper.sh
notify-finished:
needs: apply
if: always()
uses: ./.github/workflows/_notify_finished.yml
secrets: inherit
トリガー条件
on:
pull_request:
branches:
- develop
- main
types:
- closed
applyのワークフローのトリガーはmain, developブランチに対するプルリクが閉じられた時に設定しています。
「if: github.event.pull_request.merged == true」と組み合わせることで、マージされた時のみ処理を実行するように設定できます。
applyの実行
- name: Terragrunt run-all apply
working-directory: envs/${{ env.ENVIRONMENT }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
terragrunt run-all apply --terragrunt-non-interactive --terragrunt-tfpath $GITHUB_WORKSPACE/.github/scripts/tfwrapper.sh
applyはrun-allコマンドで一括で実行します。
--terragrunt-non-interactiveをつけることでインタラクティブなY/Nの確認がなくなります。
まとめ
ここまでTerraformのCI/CDを紹介してきましたが、実は自分自身まだこのCI/CDをちゃんと運用したことはありません。
今後、実際に使っていきながらアップデートしていきたいと思っています。
参考