Terraformでmoduleを使わずに複数環境を構築する
はじめに
Terraformを使って複数の環境を扱う代表的な方法として、環境ごとにディレクトリを分けつつ、そこから共通のmoduleを呼び出すというものがあります。
本記事ではこれとは異なり、moduleを使わずに複数の環境を取り扱うファイル構成例と、運用して感じている利点について紹介します。
なお、Terraformで取り扱う対象としてAWSを前提とした記述が各所に登場します。ご了承ください。
動作環境
- Terraform v1.5.3
- AWS Provider v5.9.0
moduleを使って複数環境を扱うファイル構成例
moduleを使わない構成を紹介する前に、まずmoduleを使う構成例を簡単に解説します。
ファイル構成は概ね以下の通りになるかと思います。
-- <project-name>/
-- envs/
-- dev/
-- backend.tf
-- providers.tf
-- versions.tf
-- main.tf # ここから各moduleを呼ぶ
-- stg/
-- backend.tf
-- providers.tf
-- versions.tf
-- main.tf # ここから各moduleを呼ぶ
-- prod/
-- backend.tf
-- providers.tf
-- versions.tf
-- main.tf # ここから各moduleを呼ぶ
-- modules/
-- <module-name>/
-- main.tf # 各種resourceを定義
-- variables.tf
-- outputs.tf
-- README.md
-- (other modules/)
module内に各種resourceを定義しておき、環境別のmain.tf
からそれらmoduleを呼び出します。
こうした構成が利用されている背景としては以下が挙げられると思います。
- moduleを利用することでコードがDRYになるとともに、意図しない環境間の差異を無くせる
- ローカルから手作業でterraformコマンドを実行するケースにおいて、workspacesを使った場合と比較して、誤って別の環境でコマンドを実行してしまうといった事故が起こりにくい(環境ごとに作業ディレクトリが分かれるため)
moduleを使わずに複数環境を扱うファイル構成例
それでは、ここからはmoduleを使わずに複数環境を取り扱うファイル構成例を紹介します。いくつか空行がありますが、見やすくするために入れているだけで、構成そのものに対する何かの意味はありません。
-- terraform.sh # {env}.tfbackendや{env}.tfvarsを読み込むラッパースクリプト
-- <project-name>/
-- backend.tf # backendブロックを記述
-- dev.tfbackend # backendの具体的な設定値を管理
-- stg.tfbackend # 同上
-- prod.tfbackend # 同上
-- variables.tf # variableの定義を管理
-- dev.tfvars # variableの値を管理(Git管理しても問題の無いもののみ)
-- stg.tfvars # 同上
-- prod.tfvars # 同上
-- terraform.sh -> ../terraform.sh # Symbolick link
-- providers.tf
-- versions.tf
-- vpc.tf # 各種resource定義
-- ecs.tf # 同上
-- ecs_iam.tf # 同上
-- (other tfs) # 同上
-- <component-name>/ # tfstateを分けたいもの(詳細後述)があれば別ディレクトリにする
-- (some files) # ファイル構成の考え方は上位ディレクトリと同じ
-- (other components/)
環境切り替えの実現方法
このファイル構成において、ディレクトリを分けずにどのように環境を切り替えるのかを解説します。
まず、backend.tf
には、tfstate保存先としてS3を使うことのみを定義します。
terraform {
backend "s3" {}
}
そして、tfstate保存先の具体的なS3のバケット名やオブジェクト名、その他backendとして必要な情報は{env}.tfbackend
に定義します。
bucket = "example-dev-terraform-state"
key = "example.tfstate"
encrypt = true
profile = "example-dev" # 使用するAWS Profile名は各開発者間で揃える前提
region = "ap-northeast-1"
dynamodb_table = "terraform-state-lock"
backendに関しては以上です。
環境ごとの値の使い分けに関しては、variablesを利用します。例えば、env
という変数を定義して利用する場合は以下のようになります。
variable "env" {
type = string
}
# 略
env = "dev"
# 略
なお、Terraform向けの標準的な.gitignore
では、*.tfvars
がGit管理対象外となるように設定されています。そのため、使用する{env}.tfvars
に関してはGit管理対象となるようにします。
# 略
*.tfvars
*.tfvars.json
+ !dev.tfvars
+ !stg.tfvars
+ !prod.tfvars
# 略
以上のような準備を行なった上で、terraform initコマンド
実行時に-backend-config
オプションで対応する{env}.tfbackend
を読み込ませます。これによりbackendが切り替わります。
terraform init -backend-config=dev.tfbackend
さらにterraform plan
などのコマンド実行時には-var-file
オプションで対応する{env}.tfvars
を読み込ませます。
terraform plan -var-file=dev.tfvars
このようにすることで、環境別ディレクトリとmoduleを使うことなく、複数の環境を取り扱うことができます。
複数環境を楽に安全に切り替えるためのラッパースクリプト(terraform.sh)
ただし、毎回-backend-config
と-var-file
を正しく指定するのは手間ですし、ミスも起こり得るため、複数環境を楽に安全に切り替えるための仕組みはあった方が良いと思います。
今回はTerraformコマンドをラップする50行程度のスクリプトを用意します。
スクリプトの詳細
#!/bin/bash
set -euo pipefail
function usage() {
cat <<EOF
Usage: [TF_SKIP_INIT=boolean ] $0 [-help] <env> <command> [args]
env : environment [stg/prd/...]
command : terraform command [plan/apply/state/...]
args : subcommand [e.g. state "mv"] and terraform command options (see : terraform <command> -help)
TF_SKIP_INIT : skip "terraform init"
EOF
}
### =========================== Main ========================== ###
if [ "$1" = '-h' ] || [ "$1" = '-help' ] ; then
usage
exit 0
fi
# <command>以降が無い時はエラーとする
if [ $# -lt 2 ] ; then
echo -e "[ERROR] Invalid parameters\n"
usage
exit 128
fi
TF_ENV=$1
TF_COMMAND=$2
TF_ARGS=${@:3}
# set -uしているので、${TF_SKIP_INIT-false}とすることで、${TF_SKIP_INIT}が未定義の場合でもエラーとならずfalseとして扱われるようにしている
if [ "${TF_SKIP_INIT-false}" = true ] ; then
echo "[INFO] Skip init..."
else
if [ "${TF_COMMAND}" = 'init' ] ; then
# shellcheck disable=SC2086
terraform init \
-backend-config="${TF_ENV}.tfbackend" \
-reconfigure \
${TF_ARGS} # ./terraform.sh <env> init [args] が実行された時の [args] は、init用向けに指定されたものと解釈し、ここで展開する
exit 0 # ./terraform.sh <env> init が実行された時はここで終了させる
else
terraform init \
-backend-config="${TF_ENV}.tfbackend" \
-reconfigure # Do you want to copy existing state to the new backend? を非表示にするため
fi
fi
# -var-fileオプションの無いコマンドに-var-fileを指定するとエラーになる場合があるので処理を分岐させる
case $TF_COMMAND in
apply | console | destroy | import | plan | refresh)
# shellcheck disable=SC2086
# "${TF_ARGS}"が推奨だが複数引数を指定した時にエラーになるのでダブルクォートは外す
terraform "${TF_COMMAND}" -var-file="${TF_ENV}.tfvars" ${TF_ARGS};;
*)
# shellcheck disable=SC2086
terraform "${TF_COMMAND}" ${TF_ARGS};;
esac
このスクリプトでは、第一引数に環境名を取り、例えばdev環境でterraform plan
を行いたい場合は、以下のように実行します。
./terraform.sh dev plan
すると、最初にterraform init -backend-config=dev.tfbackend
を実行し、続けてterraform plan -var-file=dev.tfvars
を実行します。
実行結果は以下のようになります。
$ ./terraform.sh dev plan
Initializing the backend...
# 略
Terraform has been successfully initialized!
# 略
No changes. Your infrastructure matches the configuration.
# 略
もしも環境名を省略したり、不正な値を指定したりすると、スクリプトは停止するため安全です。
$ ./terraform.sh plan
[ERROR] Invalid parameters
$ ./terraform.sh qa plan
Initializing the backend...
╷
│ Error: Failed to read file
│
│ The file "qa.tfbackend" could not be read.
╵
terraform init
だけを行うこともできます。
./terraform.sh dev init
-target
などの各種オプションも指定できます。
./terraform.sh dev plan -target aws_iam_role.foo
state
コマンドに対するmv
などの各種サブコマンドも使用できます。
./terraform.sh dev state mv aws_iam_role.old aws_iam_role.new
毎回初期処理としてterraform init
が走るのが煩わしいと感じる場合は、スキップもできます。
TF_SKIP_INIT=true ./terraform.sh dev plan
なお、Terraformの運用として、ローカルからのplanやapplyを禁止し、本記事の最後に触れている自動plan/applyサービス(Terraform CloudやAtlantis)だけを使ってplanやapplyを行うようにするのであれば、このラッパースクリプトは用意しなくても問題はありません。
moduleを使わないことによって感じる利点
今回紹介した、moduleを使わずに環境を分ける構成を1年以上運用してみて、以下の利点を感じています。
- 読むべき/書くべきファイル数やコード量が少なく、Terraform初学者やチームの新規参入者がキャッチアップしやすい
- moduleを設計すること自体が難易度の高い行為であり、そこに労力をかけずに済む
- terraform consoleをどのresourceに対しても使える
利点1: 読むべき/書くべきファイル数やコード量が少なく、Terraform初学者やチームの新規参入者がキャッチアップしやすい
moduleを使って環境を分ける構成は多くの開発現場で採用され、浸透しているかと思います。
一方で、moduleを使うことで付随する、以下のような要素に関してはこれといったスタンダードが無く、各開発現場によって様々である気がします。
- moduleの粒度(再利用性をどの程度持たせて運用しているか)
- moduleのvariablesやoutputsの命名規則やその用途
- moduleのvariablesやoutputsの型にobjectを採用しているか否か
- moduleからmoduleを呼び出すことを許容しているか否か
moduleを使わない場合、既存コードを読んだり変更したりする際に上記の要素が絡むことが無いため、その分だけTerraform初学者やチームの新規参入者がキャッチアップしやすくなると感じています。
利点2: moduleを設計すること自体が難易度の高い行為であり、そこに労力をかけずに済む
そもそも、使いやすく保守性の高いmoduleを設計すること自体が非常に難しく、高いスキルが求められる行為だと考えています。
Terraform Registoryで公開されているmoduleはOSSとしてメンテナンスされていることもあり洗練されて使いやすいものが多いですが、それに近いものを自作するには相当な設計力が必要だと思います。
組織でTerraformを運用する上で、moduleを自作することには手を出さずにその分の労力を他のことに使う、というのも1つの選択肢になるかと思います(なお、moduleの設計にチャレンジし、その運用経験から学び、設計力を高めていこうとすることを否定するつもりはありません)。
利点3: terraform consoleをどのresourceに対しても使える
terraform console
はあまり多用するようなコマンドではなく、存在すら知らない人も多いと思うので、これは本当に些細な利点です。
terraform console
では、module内のresourceを見ることができません。
> module.example.aws_iam_role.this
╷
│ Error: Unsupported attribute
│
│ on <console-input> line 1:
│ (source code not available)
│
│ This object does not have an attribute named "aws_iam_role".
╵
参照するには、moduleからoutputを生やして、そちらを参照する必要があります。moduleを使っていなければ、そうした考慮が不要となります。
その他補足
DRY化の工夫(for_eachの利用)
moduleを利用する理由として、コードをDRYにしたり、組織のポリシー等に沿った設定を強制したりするという点が挙げられるかと思います。
例えば、S3バケットはその設定が様々なresource typeに分かれており、バケットごとに設定を変えないのであれば、各resource typeを1つのmoduleにまとめ、それを呼び出すようにすることは非常に有用です。
ただ、これもmoduleを使わずにDRYにすることは可能です。以下はS3バケット関連のresourceの定義をDRYにした例です。locals内の配列に要素を1つ足せば、同様の設定のS3バケットを新規作成できます。
locals {
aws_s3_bucket = {
private = [
"alb_access_logs",
"athena_integration_cloudformation_templates",
"athena_query_results",
"s3_batch_operations_reports",
"session_manager_logs",
"vpc_flow_logs",
]
}
}
resource "aws_s3_bucket" "private" {
for_each = toset(local.aws_s3_bucket.private)
# Terraformリソース名はスネークケースで統一しているが
# AWSリソース名はケバブケースで統一するルールとしているので変換する
# (そもそもS3バケット名にはアンダースコアは使用不可)
bucket = format("${var.project_name}-${var.env}-%s", replace(each.key, "_", "-"))
}
resource "aws_s3_bucket_ownership_controls" "private" {
for_each = toset(local.aws_s3_bucket.private)
bucket = aws_s3_bucket.private[each.key].id
rule {
object_ownership = "BucketOwnerEnforced"
}
}
resource "aws_s3_bucket_public_access_block" "private" {
for_each = toset(local.aws_s3_bucket.private)
bucket = aws_s3_bucket.private[each.key].id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_server_side_encryption_configuration" "private" {
for_each = toset(local.aws_s3_bucket.private)
bucket = aws_s3_bucket.private[each.key].id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_versioning" "private" {
for_each = toset(local.aws_s3_bucket.private)
bucket = aws_s3_bucket.private[each.key].id
versioning_configuration {
status = "Enabled"
}
}
上記の例では、S3バケット名以外の設定は全て共通のため、S3バケット名(を構成する文字列)のみを配列とすることで実現できています(ただし、for_eachへの入力時にtoset関数を使う必要あり)。
もしも、全体としてはfor_eachを使ってDRYにしつつも、resource単位で一部設定を変えたい箇所があれば、localを配列ではなくobjectにすることで実現できます。
以下は、S3バケットポリシーに関してはバケット単位で異なる設定にする場合の例です。
locals {
aws_s3_bucket = {
private = {
alb_access_logs = {
policy = jsonencode(
{
# 略
}
)
}
athena_integration_cloudformation_templates = {
policy = jsonencode(
{
# 略
}
)
}
}
}
}
resource "aws_s3_bucket" "private" {
for_each = local.aws_s3_bucket.private
bucket = format("${var.project_name}-${var.env}-%s", replace(each.key, "_", "-"))
}
# 略
resource "aws_s3_bucket_policy" "private" {
for_each = local.aws_s3_bucket.private
bucket = aws_s3_bucket.private[each.key].id
policy = each.value["policy"] # each.value.policyと記述してもOK
}
特定の環境にだけresourceを作りたい場合
countの利用
特定の環境にだけresourceを作りたい場合、対象が少量の場合はcountの利用を検討します。以下は、コストを抑えるためにprod環境のみNATゲートウェイをマルチAZ化する例です。
resource "aws_eip" "nat_c" {
count = var.env == "prod" ? 1 : 0
domain = "vpc"
tags = {
Name = "${var.project_name}-${var.env}-nat-c"
}
}
resource "aws_nat_gateway" "c" {
count = var.env == "prod" ? 1 : 0
allocation_id = aws_eip.nat_c[0].id
subnet_id = aws_subnet.public_c.id
tags = {
Name = "${var.project_name}-${var.env}-c"
}
}
配下に別ディレクトリとして切り出し(コンポーネント化)
ただし、より多くのresourceを特定の環境だけに作りたい場合はcountを多用するとコードの可読性が落ちる懸念があります。
そうしたケースでは、それらresource群をコンポーネントと捉え、配下に別ディレクトリとして切り出します。そして、作成したい環境に関してのみ{env}.tfbackend
と{env}.tfvars
を配置するようにします。
以下はBIツールを稼働させるためのresourceを、dev環境では作成せず、stg環境とprod環境でのみ作成する場合の例です。dev環境は作成しないので、dev.tfbackend
とdev.tfvars
は配置していません。
-- <project-name>/
-- (some files)
-- bi/ # 新たにbiディレクトリを作成
-- backend.tf
-- stg.tfbackend
-- prod.tfbackend
-- variables.tf
-- stg.tfvars
-- prod.tfvars
-- terraform.sh -> ../../terraform.sh # Symbolick link
-- providers.tf
-- versions.tf
-- alb.tf
-- route53.tf
-- ecs.tf
-- (other tfs)
-- (other components/)
サブネットなどは上位のディレクトリでresource定義されているので、下位のbiディレクトリからはdata sourceとして参照するようにします。
data "aws_subnet" "public_a" {
filter {
name = "tag:Name"
values = ["${var.project_name}-${var.env}-public-a"]
}
}
data "aws_subnet" "public_c" {
filter {
name = "tag:Name"
values = ["${var.project_name}-${var.env}-public-c"]
}
}
resource "aws_lb" "this" {
# 略
subnets = [
data.aws_subnet.public_a.id,
data.aws_subnet.public_c.id,
]
# 略
なお、循環参照を避けるため、上位のディレクトリで下位のディレクトリのresourceを参照することはしないようにします。
それでもmoduleを使うケースは?
これまでmoduleを使わないアプローチを一通り説明してきましたが、例外的にmoduleを使うケースがあります。
Terraform Registoryの公開module
1つ目は、Terraform Registoryで公開されているmoduleを使うケースです。
例えばAWS Chatbotを作成する場合は、CloudFormationテンプレートをラップした以下のmoduleを使った方が楽なので利用させてもらっています。
その他、Atlantisというセルフホスト側の自動plan/applyサービスの構築にもmoduleを使っています。
全AWSアカウント共通のセキュリティ設定のmodule
2つ目は、全AWSアカウントで共通のセキュリティ設定などを入れたい場合です。
例えば「アカウントレベルのS3ブロックパブリックアクセス」や「EBSのデフォルト暗号化」等の設定はmodule化し、各AWSアカウントを管理するディレクトリからは共通でこの自作moduleを呼ぶようにしています。
# 略
resource "aws_s3_account_public_access_block" "this" {
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# 略
# 略
resource "aws_ebs_encryption_by_default" "this" {
enabled = true
}
# 略
module "basic_security_global_services" {
source = "../modules/basic_security_global_services"
}
module "basic_security_regional_services" {
providers = {
aws = aws.ap-northeast-1
}
source = "../modules/basic_security_regional_services"
}
module "basic_security_regional_services_us_east_1" {
providers = {
aws = aws.us-east-1
}
source = "../modules/basic_security_regional_services"
}
自動plan/applyサービスの利用について
今回紹介したファイル構成において自動でplan/applyを実行してくれるサービスを利用する場合の設定例について解説します。
代表的なサービスとして、Terraform CloudとAtlantisを取り上げます。
各サービスの運用経験のある方向けの解説としており、個別サービスの概要や仕様については触れません。ご了承ください。
Terraform Cloud
Terraform Cloudでは対象のリポジトリ、ディレクトリを指定したWorkspaceを、環境ごとに作成してください。
例えば、同一のリポジトリ、ディレクトリを対象とするWorkspaceとして、以下を作成します。
- example-dev
- example-stg
- example-prod
{env}.tfvars
に記述していた変数は各WorkspaceのVariablesにTerraform変数として設定し、各tfvarsのファイルは削除するようにしてください。
PRを作成すると、それぞれのWorkspaceで問題なくplanが自動実行されます。
コミットをプッシュする前にローカルのコードでplanを行いたい場合もあると思いますが、そうしたケースに備えてcloud blockを以下のように記述しておきます。
terraform {
cloud {
hostname = "app.terraform.io"
organization = "my-org"
# nameは指定しない
}
}
通常はnameに対象のWorkspace名を指定すると思いますが、今回は1ディレクトリで複数のWorkspaceを扱うため、nameは指定しません。
そして、plan実行時に変数TF_WORKSPACE
で対象のWorkspace名を指定するようにします。これにより、任意のWorkspaceでplanを実行できます。
TF_WORKSPACE=example-dev terraform plan
Atlantis
Atlantisでは、Atlantisサーバー側に配置するrepos.yaml
で、workflowを以下のようにカスタマイズします。
repos:
- id: /.*/
# 略
allowed_workflows: [dev, stg, prod]
allow_custom_workflows: false
# 略
workflows:
dev:
plan:
steps:
- init:
extra_args: ["-backend-config=dev.tfbackend", "-reconfigure"]
- plan:
extra_args: [-var-file=dev.tfvars]
stg:
plan:
steps:
- init:
extra_args: ["-backend-config=stg.tfbackend", "-reconfigure"]
- plan:
extra_args: [-var-file=stg.tfvars]
prod:
plan:
steps:
- init:
extra_args: ["-backend-config=prod.tfbackend", "-reconfigure"]
- plan:
extra_args: [-var-file=prod.tfvars]
そして、Terraformコードを管理するリポジトリ側に配置するatlantis.yaml
では、複数のprojectを定義し、それぞれ同一のdirを指定し、workflowは各環境に対応するものを指定してください。
version: 3
# 略
projects:
- name: example-dev
dir: example
workflow: dev
- name: example-stg
dir: example
workflow: stg
- name: example-prod
dir: example
workflow: prod
これにより、atlantis plan
時に、環境に合わせた適切なbackendや変数の値が使用されます。
終わりに
以上、Terraformでmoduleを使わずに複数環境を構築する方法の解説でした。
今後新規にTerraformを導入するプロジェクトがあれば、今回のようなファイル構成についても検討の俎上に載せてもらえたら幸いです。
Discussion
本記事、参考にさせていただいております。
Terraform 1.5.7,MacbookPro(M1)環境においてラッパースクリプトを利用したところ、
引数が3未満(2つまで)にした際に
の結果にて動作しなかったため、以下の変更を施したところ、正常動作を確認しました。
ご指摘ありがとうございます🙇
記事公開にあたり、スクリプトを普段運用しているものから一部変更したことでデグレが生じてしまったようです。
ご提示いただいた修正内容で正常動作することを確認しました。
なお、記事中のスクリプトに関しては私が普段運用しているものと同内容に戻しました。
返信ありがとうございます。
差替いただいたスクリプトでも動作確認できました。