Terraformのベストプラクティスをググってみると、みなさん正解を見つけようと試行錯誤されているようですが例にもれず僕もそうでした。
僕は1年半の間に4つのプロジェクトでTerraformに触れてきましたが、その中で自分なりにベストなディレクトリ構成や手法がいくつか見えてきたので、現時点での集大成としてメモっておきます。
オレオレベストプラクティス
今まで書いた記事を紹介しながらベストプラクティスをまとめていき、最後にベストプラクティスを実装したサンプルコードでインフラを構築してみます。
サンプルコードはこちらのGitHubをご覧ください。
AWSはマルチアカウントで運用する
ひとつのAWSアカウント=ひとつの環境 として運用します。
ひとつのAWSアカウントに他のプロジェクトや、たとえ同じプロジェクトであっても開発、本番環境を混在せず構築するため、Terraformのバグやオペレーションミスなどのリスクを最小限に抑えられます。
また論理的に環境を区別する煩わしさからも解放されます。(例えばリソースの名前にdevやprodのようなプレフィックスをつけるなど)
以下の関連記事を参考にしてください。
Terragruntを使う
TerraformのラッパーツールであるTerragruntを使うと後述するモジュール分割が楽になります。
以下の関連記事を参考にしてください。
tfenvとtgenvを使う
tfenvとtgenvを使うことでバージョンの異なるTerraformやTerragruntを共存させることが可能になります。
以下の関連記事を参考にしてください。
モジュールでリソースを分割する
Terraformのモジュールという機能をつかってAWSリソースの管理の単位を分割します。コンパクトに運用することでリスク分散になったりTerraformの実行時間を短くできたりします。
以下の関連記事を参考にしてください。
環境差分は環境毎の設定ファイルと条件式(if分岐)で吸収する
EC2のサーバスペックであったり、開発環境だけIP制限をかけるなど、同じプロジェクトのインフラでも開発、本番環境の差異は必ずでてきます。
この差異を吸収する手法はいくつかあるのですが、個人的には環境毎の差分だけを抽出した変数をまとめた設定ファイルとTerraformの条件式(if分岐)を駆使して運用するのが良いと感じました。
実は以前にこちらの記事を書いたときは
if分岐などで差分を吸収するためのロジックを書きまくって、かえって可読性が悪くなりメンテが大変になるのは避けたい。
と考えていました。
しかし実際運用していくなかで条件式を使用せずに、呼び出すルートモジュール側のみで環境差異を吸収しようとすると(ほぼ同じモジュールを呼び出すので)重複コードが多くなり運用がつらくなりそうと、初期段階で感じたため考えを改めました。
実際には分岐条件が必要な環境差異は少なく(多かったらそもそも検証目的である開発環境になり得ない)特に問題なく運用できています。
Terraformのバックエンドはリモート管理する
Terraformの管理下にあるリソースの状態情報は、デフォルトではローカルファイルとして保存されます。
これをリモート環境で管理することにより、排他ロックを含む共有が可能となります。また状態情報へ適切なアクセス権限を与えることで、よりセキュアになります。
以下の関連記事を参考にしてください。
AWS Vaultを使う
Terraformとは直接関係ないですが、AWS Vaultを使うことでローカルPC内のAWS認証情報をより安全に運用することができるようになります。
以下の関連記事を参考にしてください。
機密情報をsopsとシークレットマネージャーやSSMパラメーターストアで管理する
DBパスワードや外部サービスのトークンが書かれたテキストファイルをsopsを使って暗号化します。更にそれをシークレットマネージャーやSSMパラメーターストアでセキュアに管理します。
以下の関連記事を参考にしてください。
サンプルコードの説明
これまでに上げたポイントを実装してみたものがこちらのサンプルコードになります。このコードでは最終的にECSが構築されます。
以下のようなディレクトリ構成になっています。
$ tree -a terraform-terragrunt-my-best-practice
terraform-terragrunt-my-best-practice
├── cdk
│ └── terraform_backend
│ ├── ...
... ...
│
└── terragrunt
├── .gitignore
├── .terraform-version
├── .terragrunt-version
├── conf.d
│ ├── common.hcl
│ ├── dev.hcl
│ ├── dev_secrets.yml
│ ├── prod.hcl
│ └── prod_secrets.yml
├── exec.d
│ ├── ecs
│ │ └── terragrunt.hcl
│ ├── kms
│ │ └── terragrunt.hcl
│ ├── ssm
│ │ └── terragrunt.hcl
│ └── vpc
│ └── terragrunt.hcl
├── modules
│ ├── ecs
│ │ ├── data.tf
│ │ ├── outputs.tf
│ │ ├── policy
│ │ │ ├── ecs-exec.json
│ │ │ └── get-sercrets.json
│ │ ├── resource_alb.tf
│ │ ├── resource_ecs.tf
│ │ ├── resource_iam_ecs_task.tf
│ │ ├── resource_iam_ecs_task_execution.tf
│ │ └── variables.tf
│ ├── kms
│ │ ├── data.tf
│ │ ├── outputs.tf
│ │ ├── policy
│ │ │ └── kms.json
│ │ └── resource_main.tf
│ ├── ssm
│ │ ├── outputs.tf
│ │ ├── resource_parameter.tf
│ │ └── variables.tf
│ └── vpc
│ ├── outputs.tf
│ ├── resource_sg.tf
│ ├── resource_vpc.tf
│ └── variables.tf
└── terragrunt.hcl
$
環境変数TF_ENV に開発環境はdev, 本番環境はprod を指定してCDKやTerragruntを実行する前提で作っています。
cdkディレクトリ
今回はCDKでTerraformのバックエンドを作成します。
S3バケットはグローバルで一意でなければならないため、環境TF_ENVをそのままS3バケット名に利用しています。
Terragruntディレクトリ
Terragrunt, Terraformのソースコードをこのディレクトリ配下に格納しました。
modulesディレクトリ
terraformモジュールを配置しています。今回は以下の4つに分けました。
- sopsで利用するKMS
- 機密データを登録するSSMパラメータストア
- そのシークレットを環境変数とするECS
- ECSを動かすVPC
exec.dディレクトリ
この配下でTerragruntを実行します。
各リソースディレクトリ配下にterragrunt.hclを配置しており、find_in_parent_folders関数で上位ディレクトリにあるterragrunt.hclを読み込んでいます。
conf.dディレクトリ
[dev|prod].yml に環境毎に異なる変数を設定します。
[dev|prod]_secrets.yml に環境毎に異なる機密データを設定します。
common.hcl は環境共通の変数を設定します。
terragrunt.hcl
Terragruntのメインとなる設定ファイルです。
path_relative_to_include関数でexec.dディレクトリ配下のディレクトリ名をモジュール名として抽出しています。
module-name = "${trimprefix(path_relative_to_include(),"exec.d/")}"
前述のexec.d配下でterragruntコマンドを実行するため、find_in_parent_folders関数でルートに配置してあるterragrunt.hcl直下のconf.dディレクトリ配下の設定ファイル群を読み込みます。
common-vars = read_terragrunt_config(find_in_parent_folders("conf.d/common.hcl"))
env-vars = read_terragrunt_config(find_in_parent_folders("conf.d/${local.env}.hcl"))
secrets = try(yamldecode(sops_decrypt_file(find_in_parent_folders("conf.d/${local.env}_secrets.yml"))),{})
サンプルコードの実行
前提
僕はaws関連のコマンドを実行するときはAWS Vaultを通して、2段階認証(MFA)制限のスイッチロールで環境を切替えています。
また環境変数のTF_ENVも指定する必要があるため実際は以下のようになります。
$ export TF_ENV=dev && aws-vault exec demo-${TF_ENV} -- aws s3 ls
$ export TF_ENV=dev && aws-vault exec demo-${TF_ENV} -- cdk deploy
$ export TF_ENV=dev && aws-vault exec demo-${TF_ENV} -- terragrunt apply
運用するときはエイリアスを設定することをオススメします。
なお本記事上では便宜上、aws s3 ls などの最後のコマンドのみを記載していますのでご留意ください。
サンプルコードのダウンロード
$ git clone git@github.com:zoo200/blog.git
...
$ cd blog/terraform-terragrunt-my-best-practice/
$
CDKでTerraformバックエンドの作成
以下のようにterraform_backend_stack.pyのORGANIZATIONを任意の名前に変更した後、CDKコマンドを実行してください。
$ cd cdk/terraform_backend
$ vi terraform_backend/terraform_backend_stack.py
...
# 適宜変更
ORGANIZATION = 'zoo200'
...
$
$ python -m venv .venv
$ source .venv/bin/activate
(.venv) $
(.venv) $ python -m pip install -r requirements.txt
...
(.venv) $ cdk deploy
...
(.venv) $ deactivate
$
Terraform, Terragruntのインストール
$ cd ../../terragrunt
$ tfenv install
...
$ tgenv install
...
$
以下のようにterragrunt.hclのorganizationをCDKで指定したときと同じ名前に変更します。
$ vi terragrunt.hcl
locals {
# 適宜変更
organization="zoo200"
...
TerragruntでKMSキーの作成
まずsopsで利用するKMSキーを作成します。最後にKMSキーのARNが出力されるので、これをメモっておきます。
$ cd exec.d/kms/
$ terragrunt apply
...
Outputs:
kms-demo-arn = "arn:aws:kms:ap-northeast-1:123456789012:key/45b57xxx-yyy-zzz"
$
sopsでシークレットの暗号化
sopsコマンドで前述のKMSキーのARNを指定し、機密情報が記載されているファイルを暗号化します。
$ cd ../../conf.d
$ sops -e -i -k arn:aws:kms:ap-northeast-1:123456789012:key/45b57xxx-yyy-zzz ${TF_ENV}_secrets.yml
$
Terragruntで全リソースを一括作成
Terragruntのrun-allコマンドで一気にTerraformの全モジュールを実行します。最終的に作成されたALBのドメイン名が出力されるので、これをメモっておきます。
$ terragrunt run-all apply
...
Are you sure you want to run 'terragrunt apply' in each folder of the stack described above? (y/n) y -> yを入力
...
Outputs:
alb-demo-domain = "demo-123456789.ap-northeast-1.elb.amazonaws.com"
$
ALBへの疎通確認
modules/ecs/resource_alb.tf のこの部分に環境に応じての条件式(if分岐)を入れています。開発環境のみALBへアクセスするときトークンもどきの文字列が必要となります。
そのため普通にアクセスした場合は以下のようにエラーとなります。
$ curl demo-123456789.ap-northeast-1.elb.amazonaws.com
Forbidden%
$
エラーを回避するには以下のようにSSMパラメータストアに保存されている値を確認して、それをcurlヘッダに設定してアクセスしてください。
$ aws ssm get-parameter --with-decryption --name /secrets/demo-token
{
"Parameter": {
...
"Value": "4vELmxxxxx",
...
}
}
$
$ curl -H 'Demo-Token:4vELmxxxxx' demo-123456789.ap-northeast-1.elb.amazonaws.com
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...
$
今回は以上です〜ノシ