🔰

Terraform 環境ごとの管理から共通化へのシフトを考える

2024/10/22に公開

はじめに

Terraformの環境ごとの設定に悩んでいる方に向けて、この記事では、共通化のベストプラクティスを紹介します。

要点だけ知りたい方は、「結論」のセクションをご参照ください。

共通化を考える

Terraformのフォルダ構成のベストプラクティスとして、多くの場合、環境ごとにフォルダを作成し、その中にmain.tfファイルを配置する方法を採用しています。

例えば、以下のようなディレクトリ構成がよく見られます。

ディレクトリ構成の例

- envs
  - develop
    - main.tf
  - staging
    - main.tf
  - production
    - main.tf
- modules
	...

私たちの環境では、まずDevelop環境で各開発者がリソース追加を検証し、問題がなければPRを通じてStagingやProduction環境へ適用するという流れを採用しています。

main.tf共通化の必要性

このプロセスにおいて、モジュールの引数を変更する場合、Develop環境では適用しても、StagingやProduction環境に適用するのを忘れてしまうケースが多く発生していました。

この問題は、CIパイプライン内で各環境に対してvalidateコマンドを実行し、設定の整合性を保つことも可能ですが、毎回の適用漏れや手動管理の負担が残ります。

また、環境間でのリソースの差異はごくわずかで、多くの設定が共通していることがわかりました。

そのため、各環境ごとに個別のmain.tfファイルを用意するのではなく、共通化を進める方針を考えてみることにしました。

main.tfを共通化する

最初は下記のようなディレクトリ構成を考えてました。

共通化する場合のディレクトリ構成

- develop.tfvars
- staging.tfvars
- production.tfvars
- main.tf
- modules
	...

しかし、initコマンド実行後に作成される.terraformフォルダは、コマンドを実行したディレクトリに生成されます。

この.terraformフォルダにはterraform.tfstateファイルも含まれており、ここで参照先のS3バケット情報がキャッシュされており、ここで問題が発生します。

例えば、Develop 環境で init コマンドを実行した後、そのまま Staging 環境で init コマンドを実行すると、同じ .terraform フォルダが残っているため、以下のようなエラーが発生することがあります。

発生するエラーの例

Terraform has detected that the configuration specified for the backend
has changed. Terraform will now check for existing state in the backends.

Initializing modules...
╷
│ Error: Error loading state:Unable to access object "terraform.tfstate" in S3 bucket "bucket-name": operation error S3: HeadObject, https response error StatusCode: 403, RequestID: requestID, HostID: hostID, api error Forbidden: Forbidden
│
│ Terraform failed to load the default state from the "s3" backend.
 State migration cannot occur unless the state can be loaded. Backend
│ modification and state migration has been aborted. The state in both the
│ source and the destination remain unmodified. Please resolve the
│ above error and try again.

このエラーは、terraform init -reconfigureを実行することで解決できますが、この手順は非効率的です。

また、(私の調査では).terraformフォルダの生成場所を指定するオプションは存在しないため、環境ごとにディレクトリを分けて管理する必要があるようです。


Terraform Workspaceを使えば、1つのS3バケットを共有する場合には、環境ごとの状態管理が容易に実現できます。

しかし、今回の構成では、環境ごとに異なるバケットを使用する必要があるため、Workspaceでは適切に対応できません。


解決策として、下記の構成を考えました。

ディレクトリ構成(シンボリックリンクを活用する)

シンボリックリンクを使えば、main.tfの重複を避けることができます。

- envs
  - develop
    - main.tf(symbolic link)
    - terraform.tfvars
  - staging
    - main.tf(symbolic link)
    - terraform.tfvars
  - production
    - main.tf(symbolic link)
    - terraform.tfvars
- main.tf
- modules
	...

この構成では、各環境のmain.tfプロジェクトルートのmain.tfを参照するシンボリックリンクとなるため、一貫した操作が可能になります。

この方法を用いることで、main.tfファイルを環境ごとに重複管理する手間が省けます。

以下のコマンドで、シンボリックリンクを作成します。

cd envs/develop && ln -s ../../main.tf main.tf

このコマンドは、envs/developディレクトリ内に../../main.tfへのリンクを作成します。これにより、開発環境からもプロジェクトルートのmain.tfを簡単に参照できます。

特定の環境のみにリソースを作成する

共通化する場合には、main.tfでは、module単位でリソースをどの環境に作成するかを定義するのが望ましいと考えています。

module内のresource単位で同じことを実装すると、複雑になりやすいため、おすすめしません。

例: module単位で環境を指定する

module "s3" {
  source      = "./modules/s3"
  bucket_name = "example-bucket-${var.environment}"
  count       = var.environment == "develop" ? 1 : 0
}

このように、countパラメータを利用することで、指定した環境でのみリソースを作成できます。

stateファイルを管理する

backendブロックでは、変数を直接使用できないため、動的な環境設定が難しいです。

例: backendブロック

backend "s3" {
  bucket  = ""
  region  = "ap-northeast-1"
  key     = "terraform.tfstate"
  encrypt = true
}

そのため、以下のようにterraform initコマンドのオプションで設定を置き換える必要があります。

terraform init -migrate-state \
-var-file=develop.tfvars \
-backend-config="bucket=bucket_name" \
-backend-config="key=terraform.tfstate" \
-backend-config="region=ap-northeast-1"

Makefileの活用

各環境で共通のmain.tfを使用すると、initコマンドの操作が複雑になります。

誰が操作しても同じ結果になるよう、Makefileにコマンドをまとめることを推奨します。

Makefileの例

PROFILE_PREFIX={ご自身の環境に置き換えてください}
STATE_BUCKET_PREFIX={ご自身の環境に置き換えてください}
VALID_ENVS := develop staging production

check-env:
ifeq ($(env),)
	$(eval env := develop)
endif
	@if ! echo "$(VALID_ENVS)" | grep -q "\b$(env)\b"; then \
		echo "Error: invalid environment '$(env)'. Valid values are: $(VALID_ENVS)"; \
		exit 1; \
	fi

vault-add: check-env # make vault-add env=environment
	aws-vault add ${PROFILE_PREFIX}-${env}

fmt: # make fmt
	terraform fmt -recursive

init: check-env # make init env=environment
	$(eval PROFILE := ${PROFILE_PREFIX}-${env})
	cd envs/${env} && aws-vault exec ${PROFILE} -- terraform init -migrate-state \
	-backend-config="bucket=${STATE_BUCKET_PREFIX}-${env}" \
	-backend-config="key=terraform.tfstate" \
	-backend-config="region=ap-northeast-1"

plan: check-env # make plan env=environment m=module_name
	$(eval PROFILE := ${PROFILE_PREFIX}-${env})
	cd envs/${env} && aws-vault exec ${PROFILE} -- terraform plan $(if $(m),-target=module.$(m))

apply: check-env # make apply env=environment m=module_name
	$(eval PROFILE := ${PROFILE_PREFIX}-${env})
	cd envs/${env} && aws-vault exec ${PROFILE} --no-session -- terraform apply $(if $(m),-target=module.$(m))

結論

この記事では、Terraformを使った各環境での共通ファイル管理と、その効果的な構成方法を紹介しました。

シンプルな構成を目指す場合、シンボリックリンクを活用するのが有効です。

しかし、記事を進める中で、無理に共通化するよりも、必要な部分をモジュール化し、環境ごとの構成を適切に管理するのが理想的だと感じました。

例えば、以下のような形です。

環境共通のmoduleを定義する

以下のように、各環境にmain.tfを用意し、共通のmain.tfをmoduleとして参照する方法が有効です。

ディレクトリ構成

- envs
  - develop
    - main.tf
    - modules
      ...
  - staging
    - main.tf
    - modules
      ...
  - production
    - main.tf
    - modules
      ...
- main.tf
- modules
  ...

Develop環境のmain.tfの例

variable "environment" {
  default = "develop"
}

# ルートに置かれたmain.tfを参照する
module "common" {
  source      = "../.."
  environment = var.environment
}

# 環境ごとのモジュールを定義する
module "s3" {
  source      = "../../modules/s3"
  bucket_name = "example-bucket-${var.environment}"
}

このように、共通のmain.tfを各環境からモジュールとして呼び出す構成にすることで、共通部分と環境ごとの設定を分離しつつ、再利用性を高めることができます。

さらに、各環境固有のモジュールは環境フォルダ配下に配置することで、より柔軟かつ管理しやすい構成が実現できると考えます。

採用情報

e-dashエンジニアチームは現在一緒にはたらく仲間を募集中です!
同じ夢について語り合える仲間と一緒に、環境問題を解決するプロダクトを作りませんか?

Discussion