Terraform 運用のベストプラクティスについて考える
Terraform によるインフラ管理を振り返る
擦り倒されたネタではありますが、弊社でも Terraform でのインフラ管理についてチーム内で議論 & 運用開始してしばらく経過しましたので、振り返ってみようと思います。
Terraform 導入のきっかけ
私の所属するチームでは管理しているシステム数は多いものの、少し前まで
-
地上波放送システムと連携のためオンプレミスで構築
-
イベント単位で1回きりの使用 または 要件が毎年異なる
-
Lambda や CloudFunctions など単体で動作 かつ 管理はほぼアプリケーションコードのみ
-
本番環境しか存在しない
のようなものが大部分を占めていることもあって Terraform を導入しておらず、インフラのCD は一部で GitHub Actions・CLI・Ansible を使用しているくらいでした。
それがここ最近、オンプレミスからクラウドへの移行や複数環境構築が求められるシステム設計が多くなっており、Terraform 導入に踏み切りました。バージョン管理と再利用性を活かした運用効率 UP が主たる目的です。
せっかく Terraform を導入するということで、今後チーム全員でインフラ管理していけるよう、今のチームにとってのベストプラクティスを定めてみることにしました。
ベストプラクティスはこれ!!
あくまで 「現在のチームにとって最適な運用とは?」 という観点から定めたもので、弊社内で統一されているものですらありませので、個人的な見解が多々含まれます。⚠️
フォルダ構成
. ├── README.md ├── env │ ├── prod │ │ ├── {s3 / gcs}.tfbackend │ │ └── inputs.tfvars │ ├── stg │ │ ├── {s3 / gcs}.tfbackend │ │ └── inputs.tfvars │ └── dev │ ├── {s3 / gcs}.tfbackend │ └── inputs.tfvars ├── modules │ ├── MODULE-1 │ │ ├── README.md │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── provider.tf │ │ └── variables.tf │ └── MODULE-2 │ ├── README.md │ ├── main.tf │ ├── outputs.tf │ ├── provider.tf │ └── variables.tf ├── backend.tf ├── main.tf ├── outputs.tf ├── provider.tf └── variables.tf
としています。
調査しているとフォルダ構成についてはいくつかパターンがありそうで、上記のようにモジュール用ディレクトリと環境用ディレクトリを同階層に配置するパターンと
. ├── MODULE-1 │ ├── prod │ ├── stg │ └── dev └── MODULE-2 ├── prod ├── stg └── dev
のようにモジュールごとにディレクトリを分け、その中に環境ごとのモジュールを作成するという構成が見受けられました。後者は大規模な開発でリソースごとに担当者が分かれている場合は検討の余地があるかもしれませんが、私の所属するチームは少人数なこともあり、各人で全体を管理することの方が圧倒的に多いため採用しませんでした。
そして、もう一点悩んだのが
. ├── README.md ├── env │ ├── prod │ │ ├── {s3 / gcs}.tfbackend │ │ ├── inputs.tfvars │ │ ├── backend.tf │ │ ├── main.tf │ │ ├── outputs.tf │ │ ├── provider.tf │ │ └── variables.tf │ ├── stg │ └── dev └── modules ├── MODULE-1 └── MODULE-2
のように環境ごとにモジュールを作成するかどうかという点です。🤔
Terraform 導入の目的の一つに「再利用性を活かした運用効率 UP」を掲げていましたので、複数環境でも一貫性を保つために環境ごとに統一可能な部分はできる限り統一したく、各環境のディレクトリには backend.tf
(自チームでは {s3 / gcs}.tfbackend
というファイル名にしています) と環境変数を記載した inputs.tfvars
のみとすることにしました。
モジュールの設計
モジュールを再利用できるのは環境間だけではありません。再利用性を最大限活かせるように、異なるプロジェクトでも利用できる汎用性の高い設計を心がけています。👍
実際に作成した AWS IAM ロール作成用のモジュールを例にします。
# /modules/aws-iam/main.tf resource "aws_iam_policy" "iam_policy" { name = var.iam_policy_name path = var.iam_policy_path description = "${var.iam_policy_name} created by Terraform." policy = var.iam_policy_document } resource "aws_iam_role" "iam_role" { name = var.iam_role_name path = var.iam_role_path description = "${var.iam_role_name} created by Terraform." managed_policy_arns = [ aws_iam_policy.iam_policy.arn ] assume_role_policy = file(var.iam_role_file_path) }
# /modules/aws-iam/variables.tf variable "iam_policy_name" { type = string description = "The name of the IAM policy." validation { condition = length(var.iam_policy_name) <= 128 error_message = "The name of the IAM policy must be 128 characters or fewer." } } variable "iam_policy_path" { type = string description = "The path of the IAM policy." default = "/" } variable "iam_policy_document" { type = string description = "The IAM policy document. This is a JSON formatted string." } variable "iam_role_name" { type = string description = "The name of the IAM role." validation { condition = length(var.iam_role_name) <= 64 error_message = "The name of the IAM role must be 64 characters or fewer." } } variable "iam_role_path" { type = string description = "The path of the IAM policy." default = "/" } variable "iam_role_file_path" { type = string description = "The file path of the IAM role policy." } variable "force_detach_policies" { type = bool description = "Specifies to force detaching any policies the role has before deleting it." default = false }
# /modules/aws-iam/outputs.tf output "iam_policy_arn" { value = aws_iam_policy.iam_policy.arn } output "iam_role_arn" { value = aws_iam_role.iam_role.arn }
ご覧いただくとわかる通り、リソースの引数はほぼ全て変数にして大元の main.tf
から値を渡すようにしています。ただし、毎度全ての変数に値を入力するのも面倒なので、おおよそ同じ値となるであろう引数には default 値を設定することで省力化しています。💡
また、汎用性という面ではリソースを細かく区切るべきなのですが、分割しすぎてはせっかくモジュールとしている意味がありませんので、上記のように同時に作成することが多い IAM ポリシーとロールであったり、ロードバランサと証明書の取得であったりはリソースをまとめて一つのモジュールとしています。
その他のポイントとしては、外部ファイルで記載した方が可読性があがるものはルートディレクトリ直下に外出ししたり、terraform apply
時に弾かれないように可能な範囲で変数に validation を書いてあげるようにしています。
さらにもう一歩踏み込んで、条件によってコードブロックが変化するパターンについてもできるだけ再利用できるよう設計しています。
今度は CloudFunctions を例にします。google_cloudfunctions2_function
リソースでは、HTTP トリガーの場合、 event_trigger
ブロックは不要です。他にも環境変数は必要だったり不要だったりします。そこで、下記のように dynamic ブロックと条件分岐を駆使して様々なパターンの関数に対応できるモジュールとしています。
# /modules/gcloud-cloud-functions/main.tf resource "google_cloudfunctions2_function" "cloud_functions" { name = var.function_name location = var.location description = "${var.function_name} created by Terraform" build_config { runtime = var.runtime entry_point = var.entry_point environment_variables = var.build_environment_variable_file_path != "" ? yamldecode(file(var.build_environment_variable_file_path)) : tomap({}) source { storage_source { bucket = var.cloud_functions_bucket_name object = google_storage_bucket_object.cloud_functions_bucket_object.name } } } dynamic "event_trigger" { for_each = var.cloud_functions_trigger == "PUBSUB" ? [1] : [] content { event_type = "google.cloud.pubsub.topic.v1.messagePublished" pubsub_topic = var.pubsub_topic_id retry_policy = var.retry_policy } } service_config { environment_variables = var.runtime_environment_variable_file_path != "" ? yamldecode(file(var.runtime_environment_variable_file_path)) : tomap({}) max_instance_count = var.max_instances min_instance_count = var.min_instances available_memory = var.available_memory timeout_seconds = var.timeout ingress_settings = var.ingress_settings service_account_email = google_service_account.cloud_functions.email dynamic "secret_environment_variables" { for_each = { for index, secret in var.secret_environment_variable_list : index => secret } content { project_id = var.gcloud_project_id key = secret_environment_variables.value.key secret = secret_environment_variables.value.secret version = secret_environment_variables.value.version } } } }
CI / CD
Terraform を導入するからには CI / CD までしっかり作り込みます。一度作ってしまえばいろんなクラウドサービスに対応できるのが最高です。👏
ここでも汎用性の観点から Code Build や Cloud Build でなく GitHub Actions を利用することにしました。
ワークフローは以下の通りで、PullRequest 作成時に tfcmt plan
まで実行し、Merge 時に残りも実行としています。
-
terraform fmt
-
terraform init
-
terraform validate
-
tflint
-
tfcmt plan
( terraform plan 結果を PullRequest にコメント ) -
terraform apply
-
terraform-docs
( README を自動生成 )
環境差異のうち認証で使用する Project ID やサービスアカウントまでは inputs.tfvars
で吸収しきれませんので、 GitHub の Environment variables を併せて活用します。
悩ましい点
ここまでベストプラクティスを定めるとしてきたものの、どうするのがベストなのか悩んでいる部分もあります。
一つ目が data ソースの扱いです。わざわざ data.tf
にまとめるということにはしませんでした。可読性をあげようとすると変数のスコープを短くしたい一方で、あまりに数が多い場合には data.tf
にまとめた方がよさそうだなという思いもあり、現時点ではきちんと定めれていません。
二つ目がシークレット値の取り扱いです。チームで管理する以上、シークレット値はセキュアに共有したいので、Secret Manager に保存するのが定石になります。しかし、Secret Manager から読み込むように実装していても、state ファイルには平文で書き込まれてしまいます。バケットポリシーで権限をきちんと管理していてもどこか不安が残りますね。。。
この二点以外にも悩ましい点はいくつかありますが、納得しないままでは「ベストプラクティスだ!😤」と胸を張って言うことはできませんので、運用しながら最適解を見つけていきたいところです。
おわりに
今回は Terraform を導入するにあたって検討したベストプラクティスを振り返ってみました。
まとめると、 『環境ごとの差異は最小に』『モジュールは再利用できるように設計』『CI / CD はGitHub Actions で』 となります。
Terraform 実行用サービスアカウントの作成や state ファイル用バケットの作成、シークレット値の登録など、Terraform 管理外で手動作成としている部分もあるものの、インフラ管理効率は格段にあがりました。複数環境をまたいだ設計・改修の場合も作業は容易ですし、オンシーズンとオフシーズンがはっきりわかれているようなシステムは都度 terraform destroy
して費用削減できたり、Terraform 導入による恩恵は大きいと感じています。少し見ない間にコンソール画面が変わっていて設定に戸惑うなんてこともありません。
現時点まで運用してきて大きく困っているところはないのでこのまま運用を続けつつ、Terraform 管理できていないシステムを順番に Terraform 管理下に移行したいと思います 💪