1. はじめに
こんにちは、SWEのあかりです。
今回のテーマは、Terraformコードのリファクタリングについてです。先日、通知プラットフォーム(=社内の複数プロダクトに対して共通の通知機能を提供するマイクロサービス)のTerraformコードを改善したので、その際に行ったことや駆使したテクニックについて紹介します。
本ブログを読んで得られることは以下の3つです。
- デプロイ環境の分離方法をワークスペースからファイルレイアウト1に切り替えた背景とその際に行ったこと
- TerraformコードをDRYにするために採用した設計とその選定理由
- 修正したコードを適用する際に実際のインフラリソースが再作成されないよう、movedブロックを駆使したこと
2. リファクタリング前のTerraformコードが抱えていた課題とその解決方針
リファクタリング前のTerraformコードが抱えていた課題は主に以下の2点です。
- ワークスペースによってデプロイ環境が切り替えられていたこと
- 繰り返し現れるリソース構成も含めて、全てのリソースが一つずつ定義されていたこと
なぜこれらが課題であるのか、そして、これをどのような方針で解決することにしたのか順に説明します。
課題1:ワークスペースによるデプロイ環境の切り替え
これは、ワークスペースの不適切な利用場面としてよく知られているものです。公式ドキュメントでも、When Not to Use Multiple Workspacesという項目が用意されており、デプロイ環境の分離ではワークスペースを使わないことが推奨されています。この主な理由は以下の2点です2。
- 異なるデプロイ環境間で認証とアクセス権限を共有してしまうから:ワークスペースによりデプロイ環境を切り替えた場合、ステートファイルを管理するバックエンドが同じになります。つまり、デプロイ環境が異なるにも関わらず、認証とアクセス権限を共有してしまうことになります。
- Terraform管理しているデプロイ環境がコードからは把握しにくくなるから:Terraformコードからは利用中のワークスペースを閲覧することはできません。利用中のワークスペースを確認するには、
terraform workspace
コマンドを実行して確認するか、あるいは、CDのスクリプトから推測したりステートファイルを管理するバックエンドを直接確認するしかありません。
一つ目について補足すると、クラウドインフラにAWSを利用していて、かつ、デプロイ環境ごとにアカウントを分けている3場合、これは強い懸念にはなりません。というのも、デプロイ環境ごとにアカウントが異なるため、必然的にS3バケット(=バックエンド)も異なり、認証とアクセス権限も分かれるからです。また、同一のAWSアカウントに複数のデプロイ環境が存在していたとしても、やはり強い懸念にはなりません。というのも、IAMポリシーはS3バケットのパス単位で権限制御できるからです。ただし、もちろん権限管理が複雑になるデメリットはあります。
弊社の場合は、主なクラウドインフラとしてAWSを利用しており、かつ、本番環境と非本番環境とでアカウントが分かれているため、一つ目に記述した懸念は強くありませんでした。ですが、二つ目の理由により、デプロイ環境の一覧性は良くないですし、また、一般的なプラクティスに反していると認知負荷も高くなってしまうので、今回はこれを変更することにしました。
Terraformの環境分離の方法としてよく知られているプラクティスはファイルレイアウトによってデプロイ環境を分離する方法です。よって、環境分離の方法をワークスペースを利用したものからファイルレイアウトを利用したものへと修正することにしました。
ファイルレイアウトによる環境分離とは、以下のように環境ごとにディレクトリを用意し、それぞれの環境で異なるバックエンドを設定することです。こうすることで、環境ごとに異なる認証情報とアクセス権限が設定しやすくなり、かつ、Terraform管理しているデプロイ環境の一覧性も高められます。
. └── envs ├── develop │ ├── backend.tf │ ├── main.tf │ └── provider.tf ├── local │ ├── backend.tf │ ├── main.tf │ └── provider.tf ├── production │ ├── backend.tf │ ├── main.tf │ └── provider.tf └── staging ├── backend.tf ├── main.tf └── provider.tf
しかし、これだけだと別の問題が生じてしまいます。というのも、各デプロイ環境はほぼ同様の構成となるはずであり、つまり、各環境ディレクトリ内のmain.tf
はほぼ同じになるからです。main.tf
内で定義しているリソースが少ないうちは問題になりませんが、定義するリソースが多くなってくると、環境間でのインフラ構成差分を把握することが難しくなります。そこで、Terraformモジュールを利用します。Terraformモジュールとは、再利用可能なインフラ構成を一つのまとまりとしてテンプレート化する機能です。通知プラットフォームでは、以下のようにenvs
ディレクトリと同じ階層にmodules
ディレクトリを用意し、この中にモジュールを定義するようにしました。そして、modules
ディレクトリ配下に定義したモジュールを各環境ディレクトリのmain.tf
から呼び出すようにしました。
. ├── envs : 省略 └── modules ├── pubsubs │ ├── cloudwatch.tf │ ├── sns.tf │ ├── sqs.tf │ └── variables.tf : 省略
課題2:繰り返し現れるリソース構成も含めて、全てのリソースが一つずつ定義されていたこと
これはあえて説明する必要はないかもしれません。エンジニアの多くは繰り返し現れる構成を見ると、それらをひとまとまりにしてイテレーティブに呼び出したくなると思います。そうしないと、コードの可読性が落ち、必要以上に認知負荷が高くなってしまうからです。通知プラットフォームでは、例えば、下図のようなSNSトピック + SQSキュー + CloudWatchアラームの構成が繰り返し現れていました。
リファクタリング前の通知プラットフォームのTerraformコードは、これらの繰り返し現れる構成も含めて全てのリソースが一つずつ定義されていました。というのも、このマイクロサービスは社内にTerraformのノウハウが十分蓄積されていない時期に立ち上げた経緯があるからです。そのため、Terraformコードとしては改善の余地が多々あり、その一つとして、上記のような繰り返し現れる構成が全て一つずつ定義されている問題がありました。よって、これをDRYにし、Terraformコードの可読性を向上させたいという動機がチーム内にありました。
この課題解決方法としては、次の2通りが考えられました。
- SNSトピック + SQSキュー + CloudWatchアラームを一つのモジュールとして定義し、各環境ディレクトリの
main.tf
からこのモジュールを繰り返しインスタンス化する。 - アプリケーションが必要とするPub/Subメッセージングを実現するインフラリソース全てを一つのモジュールとして定義し、そのモジュールの中でSNSトピック + SQSキュー + CloudWatchアラームの構成を連鎖的に繰り返しインスタンス化する。
1番目の方法と2番目の方法のコード例を以下に記載しておきます。文章によるイメージが難しい場合は、適宜展開してコードを確認してください。
# envs/production/main.tf locals { pubsub_names = ["notify-email", "notify-push", "notify-desktop"] } module "pubsubs" { source = "../../modules/pubsub" for_each = toset(local.pubsub_names) name = "notification-production-#{each.value}" }
# modules/pubsub/variables.tf variable "name" { type = string }
# modules/pubsub/sqs.tf resource "aws_sqs_queue" "main" { name = var.name # 省略 }
# modules/pubsub/sns.tf resource "aws_sns_topic" "main" { name = var.name # 省略 } resource "aws_sns_topic_subscription" "main" { protocol = "sqs" topic_arn = aws_sns_topic.main.arn endpoint = aws_sqs_queue.main.arn }
# envs/production/main.tf module "pubsubs" { source = "../../modules/pubsubs" prefix = "notification-production" }
# modules/pubsubs/local.tf locals { pubsubs = ["notify-email", "notify-push", "notify-desktop"] }
# modules/pubsubs/variables.tf variable "prefix" { type = string }
# modules/pubsubs/sqs.tf resource "aws_sqs_queue" "main" { for_each = toset(local.pubsubs) name = "${var.prefix}-${each.value}" # 省略 }
# modules/pubsubs/sns.tf resource "aws_sns_topic" "main" { for_each = toset(local.pubsubs) name = "${var.prefix}-${each.value}" # 省略 } resource "aws_sns_topic_subscription" "main" { for_each = toset(local.pubsubs) protocol = "sqs" topic_arn = aws_sns_topic.main[each.value].arn endpoint = aws_sqs_queue.main[each.value].arn }
今回は、デプロイ環境差分の理解のしやすさを重視して、2番目の方法を採用しました。というのも、2番目の方法だとmain.tf
では単にモジュールを呼び出すだけとなり、かつ、そのモジュールに渡す値は環境差分のある設定のみとなるため、デプロイ環境差分が把握しやすくなるからです。1番目の方法の場合、各環境ディレクトリのmain.tf
にイテレーティブにモジュールをインスタンス化するロジックが必要になるので、その分だけ環境差分の可視性が落ちると考えました。
3. いざ、リファクタリング!
2章では、既存のTerraformコードが抱えていた課題とその解決方針を説明しました。実際にリファクタリングを行う際には、Terraformコードとステートファイルを変更しつつも、実際のインフラリソースは変更されないように注意する必要があります。これを踏まえて、この章では、それぞれの課題を解決した際に注意したことや駆使したテクニックについて紹介します。
3-1. ワークスペースによる環境分離からファイルレイアウトによる環境分離への切り替え
これは大きな変更のように思われますが、今回の場合は気にするべき点は多くありませんでした。というのも、このリファクタリングを行う動機が環境間の認証・権限分離ではないため、バックエンドの変更は行わないからです。つまり、必要な作業はステートファイルの分離方法をワークスペースからファイルレイアウトに切り替えることだけです。ですので、この切り替えを行うにあたって注意したことは、ステートファイルのパスがリファクタリング前後で同じになることのみでした。
例えば、ワークスペースで環境分離していた際に、バックエンドの設定を以下のように記述していたとします。
terraform { backend "s3" { bucket = "terraform" workspace_key_prefix = "state/notification" key = "terraform.tfstate" } }
そして、staging環境のインフラ構成をstaging
というワークスペースで管理していた場合、ステートファイルはterraform
というS3バケットのstate/notification/terraform.tfstate
に保存されます。というのも、ステートファイルの保存先は/workspace_key_prefix/workspace_name/key
になるからです。
ファイルレイアウトによる環境分離でも同じ場所にステートファイルを保存するために、バックエンドの設定を以下のように記述しました。
terraform { backend "s3" { bucket = "terraform" key = "state/notification/staging/terraform.tfstate" } }
このようにして、ステートファイルの保存先を変更しないまま、ワークスペースによる環境分離からファイルレイアウトによる環境分離への切り替えを行いました。
3-2. 一つ一つ定義されたリソースをDRYに
一つ一つ定義されたリソースをモジュールやfor_eachとcount式を駆使してDRYにすることこそ、今回のリファクタリングの中で最も根気の要るタスクでした。というのも、実際のインフラリソースを変更せずにTerraformコードのみを修正するには、ほぼ全てのリソースに対してmovedブロックを定義する必要があったからです。つまり、リファクタリング前後でterraform plan
の実行差分がなくなるように、ほぼ全てのリソースに対してmovedブロックを定義しました。
movedブロックが必要となる理由は、これを定義しない場合、Terraformはほぼ全てのリソースを再作成しようとするからです。この挙動を理解するためには、次の2点を前提として押さえる必要があります。
terraform plan/apply
コマンドはステートファイルとTerraformコードとの差分を見て、どのリソースを作成・削除・変更するべきか判断している。- Terraformコードに定義されたリソースとステートファイルに記述されたリソースとのマッピングは、モジュール名・リソースタイプ・リソース名・インデックスを通して行われている4。
上記を踏まえて、例えば、以下のようにSNSトピックのリソース名のみをbefore
からafter
に変更する場合を考えてみます。
-- resource "aws_sns_topic" "before" { ++ resource "aws_sns_topic" "after" { name = "hoge" # 省略 }
この状態でterraform plan
を実行した場合、Terraformはまず現在のステートファイルを確認し、Terraformコードとの差分を調べます。すると、Terraformはステートファイルに記述されているリソース名before
のSNSトピックがTerraformコードには存在しないこと、そして、ステートファイルには存在しないリソース名after
のSNSトピックがTerraformコードには存在することに気づきます。その結果、Terraformはリソース名before
のSNSトピックが削除され、リソース名after
のSNSトピックが追加されたと判断するのです。
したがって、以下のようにmovedブロックを定義して、リソース名がbefore
からafter
に変更されているのだと、Terraformに教える必要があります。
moved { from = aws_sns_topic.before to = aws_sns_topic.after }
今回は、モジュールやfor_each・count式を駆使して、ほぼ全てのリソース定義がDRYになるように整理しました。ですので、節の冒頭で説明したように、ほぼ全てのリソースに対してmovedブロックを定義して、実際のインフラが再作成されないように配慮する必要があったのです。
ちなみに、公式ドキュメントでは全てのmovedブロックの履歴を残しておくことが推奨されています。しかし、今回は通知プラットフォームの全環境に修正後のコードを適用した後で、全てのmovedブロックを削除しました。というのも、今回リファクタリングしたコードの利用は通知プラットフォームだけで閉じているからです。リファクタリング完了後に残しておく理由がなかったため、無用な認知負荷をなくすべく全て削除することにした次第です。
4. まとめ
この記事では、社内の通知プラットフォームのTerraformコードに対して実際に行ったリファクタリングを元に、その必要性と駆使したテクニックについて説明しました。これからTerraformコードをリファクタリングしていく方々、そして、今からインフラをTerraform管理しようという方々にとって、何かヒントになることがあれば幸いです。
5. 最後に
アンドパッドでは、「幸せを築く人を、幸せに。」というミッションの実現のため、一緒に働く仲間を大募集しています。 チーム一丸となって良いプロダクトを作りたい!と思われる方はぜひぜひご応募ください! engineer.andpad.co.jp
- 詳解 Terraform 第3版に記載されている呼称を引用しています。↩
- 詳解 Terraform 第3版の3章「Terraformステートを管理する」を参考に記述しています。↩
- AWSのベストプラクティスでは、環境ごとにアカウントを分けることが推奨されています。↩
- これが明記されている公式ドキュメントは見つけられていません。ですが、公式ドキュメントのResource Addressingと実際のステートファイルの中身を踏まえると、ステートファイルとTerraformコードのリソースマッピングにはモジュール名・リソースタイプ・リソース名・インデックスの4つが利用されているのだと筆者は考えています。↩