VisasQ Dev Blog

ビザスク開発ブログ

Terraformのリファクタリング始めました

こんにちは、プラットフォーム開発グループ SREチームの西川 (@taxin_tt) です。
皆さんTerraform使ってますか?

弊社では既存サービスのマイクロサービス化を進めており、GCPベースのインフラはTerraformを利用して整備するようにしています。
一方で、サービス数の増加などに比例してtfファイルのコード量も増えていき、ディレクトリ構成や個別のリソースの定義などマイクロサービスのインフラ整備において負担になる部分があり、昨年末からSREチーム主導でリファクタリングを行っています。

今回は、そのリファクタリングの背景や進め方についてお話しできればと思います。
(本記事は、Terraform v1.3系を前提にしています。)

リファクタリング後のTerraformのディレクトリ構成は下記をベースにしているので、下記の記事も合わせてどうぞ。

tech.visasq.com

リファクタリング前の状況

先述のブログ記事にも記載がありましたが、弊社はTerraform Workspace に近い独自の構成を採用しており、下記のような問題を抱えていました。

  • 変数 (creation という作成フラグ) を用いて開発環境では作成しない・本番環境では作成するといった具合にリソース作成を制御しており、より複雑な条件だと三項演算子の利用が必要になりコードの可読性が悪化
  • Terraform のリソース作成を module として切り出したディレクトリ内で行っていたが、module化する必要がないコードもmodule化されており、適切にmoduleを使えていない

Terraformの最新バージョンへのupgradeなど継続的に管理を行っていた一方で、マイクロサービスの増加に比例してコード量も増えており、GCPリソースの作成など普段のオペレーションで運用負荷が高い状態を解消する必要があると考えました。

また、GCPリソースの作成などのインフラ整備を開発チームメンバーにも対応してもらう上で、学習コストが必要以上に高いディレクトリ構成やコードの状態だったこともあり、よりシンプルな状態にする必要があると考えました。

どのようにリファクタリングを進めるか?

上記のような経緯を踏まえて、ディレクトリ構成などの設計を行い、リファクタリングを実際に進めようとなったのが去年の秋頃の段階でした。
一方で、その頃には本番環境で稼働しているサービスも存在していたので、リファクタリングによるTerraformのコード修正がサービス提供に影響を与えないようにする必要がありました。

リファクタリング手法の検討

リファクタリングによる修正がユーザーへのサービス提供に影響を与えない」ようにするためには、リファクタリング (Terraformのコード変更) によるGCPの各種リソースへの変更は最小限にする必要があります。

しかし、例えばmoduleに定義されていたリソースをmoduleの外側で定義し直すと、Terraformのコード的には変更差分として認識されてしまい、対象のリソースの再作成などの変更が発生します。

# module.foo.aws_security_group.bar
module "foo" {
  name = "foo"
}

resource "aws_security_group" "bar" {
  name = var.name
}

~~~
# aws_security_group.bar
resource "aws_security_group" "bar" {
  name = "foo"
}

これらの変更を最小限に抑えるにはtfstateに対する操作 (修正) が必要になり、チーム内で下記のような手法を検討して、最終的にはtfmigrateを採用しました。

tfmigrateについて

tfmigrateはその名の通り、tfstateに対するmigrationを行うためのツールです。
GitOps-friendlyなツールなのでmigrationの定義を.hclに書き出した上でgit commitして、それを適用することができます。
(より詳細な話はツール開発者のminamijoyoさんが書かれたQiitaの記事が参考になるかと思います。)

qiita.com

tfmigrateの採用にあたっては、下記のような点がポイントとなりました。

  • migration file (HCL file) にtfstateの変更処理 (mv, rm, import) を切り出せるので、リソースを定義しているtf fileの記述量は大きく変わらない
  • tfstateの変更処理 (mv, rm, import)を理解していれば、migration fileの作成に対する学習コストやレビューコストはあまりかからない
  • tfmigrate plan を使ってmigrationのdry-runの結果を確認できるので、tfstateへの変更確認がしやすい

先程のディレクトリ構成を踏まえて、環境ごとに /migrationsディレクトリを用意して、その配下にmigration file自体を作成するようにしています。

├── Makefile
├── backends
│   ├── dev.tfvars
│   ├── prod.tfvars
│   └── stg.tfvars
├── environments
│   ├── prod
|   |   └── migrations
|   |       └── 20221220_mv.hcl
│   │   ├── Makefile
│   │   ├── cloudflare_dns.tf
│   │   ├── data.tf
│   │   ├── gcp_cloud_build.tf
│   │   ├── gcp_cloud_run.tf
│   │   ├── gcp_loadbalancer.tf
│   │   ├── gcp_monitoring.tf
│   │   ├── gcp_secret_manager.tf
│   │   ├── locals.tf
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   ├── terraform.tfvars
│   │   └── variables.tf
...

リファクタリングの流れ

tfmigrateのsample codeでtfmigrateの挙動を確認したり、実際のコードベースを利用して検証を踏まえて利用できる目処が立ったので、実際にリファクタリングを進めていくことになりました。

リファクタリングは、下記のような流れで進めていきました。

  • 既存の.tf filesの修正
    • (e.g. ディレクトリ構成の変更、moduleの再設計、不要なmoduleの削除 etc.)
  • migration fileの作成 → dev環境での検証
  • Staging環境への適用
  • Production環境への適用

moduleの再設計にあたっての既存リソースに対する修正点は、moduleの設計段階でピックアップして、リファクタリングとは別に先行して修正してリファクタリングに伴う変更を最小限にするように心がけています。

一方で、リファクタリングを進めていく上で、いくつか考慮すべき事項がありました。

force = trueの活用

一つは、既存の.tf filesの修正を進めていく中で、tfstateのmigrationとは別で修正した細かい差分 (e.g. resource desciptionの変更、heredocのindentの差分) を許容したいという点です。

この変更自体はサービス影響が発生する差分ではありませんが、moduleの再設計にあたってリファクタリングの変更と一緒に適用できればと考えていました。
つまりこの場合は、tfmigrate applyを実行した時の実リソースとの差分 (= tfstateと実際のリソースというterraform plan/applyを実行した時に出力される差分) を許容する必要があります。

差分が出た時の挙動をtfmigrateのサンプルコードを例にして見ていきます。
例えば、下記のようにmigrationには関係ない差分をリソースに追加した状態でtfmigrate planを実行してみます。

resource "aws_security_group" "baz" {
  name = "foo"
  description = "test-diff" # migrationには関係ない差分
}

resource "aws_security_group" "bar" {
  name = "bar"
}
bash-5.1# tfmigrate plan tfmigrate_test.hcl
2023/02/13 01:11:25 [INFO] [runner] load migration file: tfmigrate_test.hcl
2023/02/13 01:11:25 [INFO] [migrator] start state migrator plan
2023/02/13 01:11:25 [INFO] [migrator@.] terraform version: 1.3.8
2023/02/13 01:11:25 [INFO] [migrator@.] initialize work dir
2023/02/13 01:11:27 [INFO] [migrator@.] get the current remote state
2023/02/13 01:11:28 [INFO] [migrator@.] override backend to local
2023/02/13 01:11:28 [INFO] [executor@.] create an override file
2023/02/13 01:11:28 [INFO] [migrator@.] creating local workspace folder in: terraform.tfstate.d/default
2023/02/13 01:11:28 [INFO] [executor@.] switch backend to local
2023/02/13 01:11:32 [INFO] [migrator@.] compute a new state
2023/02/13 01:11:33 [INFO] [migrator@.] check diffs
2023/02/13 01:11:45 [ERROR] [migrator@.] unexpected diffs
2023/02/13 01:11:45 [INFO] [executor@.] remove the override file
2023/02/13 01:11:45 [INFO] [executor@.] remove the workspace state folder
2023/02/13 01:11:45 [INFO] [executor@.] switch back to remote
terraform plan command returns unexpected diffs: failed to run command (exited 2): terraform plan -state=/tmp/tmp808201260 -out=/tmp/tfplan1357109240 -input=false -no-color -detailed-exitcode
stdout:
aws_security_group.bar: Refreshing state... [id=sg-217af5fc854ad95b3]
aws_security_group.baz: Refreshing state... [id=sg-3a31c203a65c7647c]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # aws_security_group.baz must be replaced
-/+ resource "aws_security_group" "baz" {
      ~ arn                    = "arn:aws:ec2:ap-northeast-1:000000000000:security-group/sg-3a31c203a65c7647c" -> (known after apply)
      ~ description            = "Managed by Terraform" -> "test-diff" # forces replacement
      ~ egress                 = [] -> (known after apply)
      ~ id                     = "sg-3a31c203a65c7647c" -> (known after apply)
      ~ ingress                = [] -> (known after apply)
        name                   = "foo"
      + name_prefix            = (known after apply)
      ~ owner_id               = "000000000000" -> (known after apply)
      - tags                   = {} -> null
      ~ tags_all               = {} -> (known after apply)
      ~ vpc_id                 = "vpc-97d7eeb3" -> (known after apply)
        # (1 unchanged attribute hidden)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: /tmp/tfplan1357109240

To perform exactly these actions, run the following command to apply:
    terraform apply "/tmp/tfplan1357109240"

stderr:

bash-5.1#

tfmigrate planを実行すると、descriptionの部分が想定しない差分として認識されています。

この状態でtfmigrate applyを実行すると下記のようにエラー扱いになり、terraform state listでtfstateを確認すると変更前のリソースの状態 (aws_security_group.bar) になっていることがわかります。

bash-5.1# tfmigrate apply tfmigrate_test.hcl
2023/02/13 01:12:11 [INFO] [runner] load migration file: tfmigrate_test.hcl
2023/02/13 01:12:11 [INFO] [migrator] start state migrator plan phase for apply
2023/02/13 01:12:11 [INFO] [migrator@.] terraform version: 1.3.8
2023/02/13 01:12:11 [INFO] [migrator@.] initialize work dir
2023/02/13 01:12:13 [INFO] [migrator@.] get the current remote state
2023/02/13 01:12:14 [INFO] [migrator@.] override backend to local
2023/02/13 01:12:14 [INFO] [executor@.] create an override file
2023/02/13 01:12:14 [INFO] [migrator@.] creating local workspace folder in: terraform.tfstate.d/default
2023/02/13 01:12:14 [INFO] [executor@.] switch backend to local
2023/02/13 01:12:18 [INFO] [migrator@.] compute a new state
2023/02/13 01:12:19 [INFO] [migrator@.] check diffs
2023/02/13 01:12:31 [ERROR] [migrator@.] unexpected diffs
2023/02/13 01:12:31 [INFO] [executor@.] remove the override file
2023/02/13 01:12:31 [INFO] [executor@.] remove the workspace state folder
2023/02/13 01:12:31 [INFO] [executor@.] switch back to remote
terraform plan command returns unexpected diffs: failed to run command (exited 2): terraform plan -state=/tmp/tmp1822279142 -out=/tmp/tfplan1164404214 -input=false -no-color -detailed-exitcode
stdout:
aws_security_group.baz: Refreshing state... [id=sg-3a31c203a65c7647c]
aws_security_group.bar: Refreshing state... [id=sg-217af5fc854ad95b3]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # aws_security_group.baz must be replaced
-/+ resource "aws_security_group" "baz" {
      ~ arn                    = "arn:aws:ec2:ap-northeast-1:000000000000:security-group/sg-3a31c203a65c7647c" -> (known after apply)
      ~ description            = "Managed by Terraform" -> "test-diff" # forces replacement
      ~ egress                 = [] -> (known after apply)
      ~ id                     = "sg-3a31c203a65c7647c" -> (known after apply)
      ~ ingress                = [] -> (known after apply)
        name                   = "foo"
      + name_prefix            = (known after apply)
      ~ owner_id               = "000000000000" -> (known after apply)
      - tags                   = {} -> null
      ~ tags_all               = {} -> (known after apply)
      ~ vpc_id                 = "vpc-97d7eeb3" -> (known after apply)
        # (1 unchanged attribute hidden)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: /tmp/tfplan1164404214

To perform exactly these actions, run the following command to apply:
    terraform apply "/tmp/tfplan1164404214"

stderr:

bash-5.1# terraform state list
aws_security_group.bar
aws_security_group.foo
bash-5.1#

この場合に、tfmigrateでどのように対応すればいいでしょうか?
リソースの実態との差分を許容した状態でtfstateのmigrationを行うためには、migration file側でforce=true を下記のように記述する必要があります。

migration "state" "test" {
  force = true
  actions = [
    "mv aws_security_group.foo aws_security_group.baz",
  ]
}

記述した状態でtfmigrate applyの結果を確認してみます。

bash-5.1# tfmigrate apply tfmigrate_test.hcl
2023/02/13 01:14:27 [INFO] [runner] load migration file: tfmigrate_test.hcl
2023/02/13 01:14:27 [INFO] [migrator] start state migrator plan phase for apply
2023/02/13 01:14:27 [INFO] [migrator@.] terraform version: 1.3.8
2023/02/13 01:14:27 [INFO] [migrator@.] initialize work dir
2023/02/13 01:14:30 [INFO] [migrator@.] get the current remote state
2023/02/13 01:14:31 [INFO] [migrator@.] override backend to local
2023/02/13 01:14:31 [INFO] [executor@.] create an override file
2023/02/13 01:14:31 [INFO] [migrator@.] creating local workspace folder in: terraform.tfstate.d/default
2023/02/13 01:14:31 [INFO] [executor@.] switch backend to local
2023/02/13 01:14:34 [INFO] [migrator@.] compute a new state
2023/02/13 01:14:35 [INFO] [migrator@.] check diffs
2023/02/13 01:14:47 [INFO] [migrator@.] unexpected diffs, ignoring as force option is true: failed to run command (exited 2): terraform plan -state=/tmp/tmp530922734 -out=/tmp/tfplan2517476659 -input=false -no-color -detailed-exitcode
stdout:
aws_security_group.bar: Refreshing state... [id=sg-217af5fc854ad95b3]
aws_security_group.baz: Refreshing state... [id=sg-3a31c203a65c7647c]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # aws_security_group.baz must be replaced
-/+ resource "aws_security_group" "baz" {
      ~ arn                    = "arn:aws:ec2:ap-northeast-1:000000000000:security-group/sg-3a31c203a65c7647c" -> (known after apply)
      ~ description            = "Managed by Terraform" -> "test-diff" # forces replacement
      ~ egress                 = [] -> (known after apply)
      ~ id                     = "sg-3a31c203a65c7647c" -> (known after apply)
      ~ ingress                = [] -> (known after apply)
        name                   = "foo"
      + name_prefix            = (known after apply)
      ~ owner_id               = "000000000000" -> (known after apply)
      - tags                   = {} -> null
      ~ tags_all               = {} -> (known after apply)
      ~ vpc_id                 = "vpc-97d7eeb3" -> (known after apply)
        # (1 unchanged attribute hidden)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: /tmp/tfplan2517476659

To perform exactly these actions, run the following command to apply:
    terraform apply "/tmp/tfplan2517476659"

stderr:
2023/02/13 01:14:47 [INFO] [executor@.] remove the override file
2023/02/13 01:14:47 [INFO] [executor@.] remove the workspace state folder
2023/02/13 01:14:47 [INFO] [executor@.] switch back to remote
2023/02/13 01:14:50 [INFO] [migrator] start state migrator apply phase
2023/02/13 01:14:50 [INFO] [migrator] push the new state to remote
2023/02/13 01:14:52 [INFO] [migrator] state migrator apply success!
bash-5.1# terraform state list
aws_security_group.bar
aws_security_group.baz

ignoring as force option is trueのようにログが出ている通り、差分がignoreされた上でtfstateに対するmigrationが適用されて、terraform state listの実行結果に aws_security_group.baz が出るようになりました。

この場合はtfstateの変更のみ行ったので、リソースの実態とは差分がある状態です。
そのため、migrationの後にterraform planを実行すると下記のように実際のリソースと比較した時の差分が出ます。

今回のリファクタリングでは、このような差分を (最小限ではありますが) 一部許容しながら進めていました。

bash-5.1# terraform plan
aws_security_group.bar: Refreshing state... [id=sg-217af5fc854ad95b3]
aws_security_group.baz: Refreshing state... [id=sg-3a31c203a65c7647c]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # aws_security_group.baz must be replaced
-/+ resource "aws_security_group" "baz" {
      ~ arn                    = "arn:aws:ec2:ap-northeast-1:000000000000:security-group/sg-3a31c203a65c7647c" -> (known after apply)
      ~ description            = "Managed by Terraform" -> "test-diff" # forces replacement
      ~ egress                 = [] -> (known after apply)
      ~ id                     = "sg-3a31c203a65c7647c" -> (known after apply)
      ~ ingress                = [] -> (known after apply)
        name                   = "foo"
      + name_prefix            = (known after apply)
      ~ owner_id               = "000000000000" -> (known after apply)
      - tags                   = {} -> null
      ~ tags_all               = {} -> (known after apply)
      ~ vpc_id                 = "vpc-97d7eeb3" -> (known after apply)
        # (1 unchanged attribute hidden)
    }

Plan: 1 to add, 0 to change, 1 to destroy.

─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.
bash-5.1#

リファクタリング完了までのリードタイム

今回のリファクタリング自体は、GitHub repository内のディレクトリ構造の大幅な変更も含むものでした。そのため、下記のような考慮が必要になりました。

  • 既存のディレクトリ構成を前提としていたPull requestがあると、ディレクトリ構成の変更をstaging branchにmergeした後に当該の変更をmergeするとconflictを起こす
  • そのため、リファクタリングとは別にリソースの作成・修正が発生したら、それを取り込む必要がある

そのため、ディレクトリ構成も含めた各種変更を開発ブランチで修正し切った段階で一気に変更適用を進めるために、リリースブロックの期間を確保して各環境分のリファクタリングを行いました。

リファクタリングを行ってどうなったか?

記載したような考慮点を踏まえてSREチーム主導で行い、一部のTerraformのコード群 (GitHub repository) に関してはサービス影響を出すこともなくリファクタリングを実施できました。

ディレクトリ構成含めて大幅なリファクタリングを行ったことで、下記のようなメリットがありました。

  • コードの可読性の改善
  • コードが dev / stg / prodの3環境構成に依存しなくなった
  • Terraformのリファクタリングに関する手法に対する理解度が深まった

Terraformのコードの可読性の改善に関しては当初から見込んでいたものですが、実際に他のメンバーからリソース作成の心理的なハードルが下がったというフィードバックをもらいました。

リソースの作成を変数 (creation という作成フラグ) と三項演算子の利用によって複雑に制御をしていて環境の複製が気軽にできない状況でしたが、リファクタリング後の構成ではそのような複雑さが解消されて例えばsandboxのような環境を複製して用意することも容易になりました。

また、リファクタリングのPull requestのレビューには、Embedded SRE / Core SRE含めたSREチーム全員に入ってもらったことで、リファクタリングに対する理解度があがり、今後Embedded SREや開発チーム主導で個別でリファクタリングを進めるにあたってもベースとなる経験が提供できたと思います。

最後に

現在では、Terraformのコードを管理しているGitHub repositoryのうち1/3近くの対応が完了しており、残りに関しても対応が進んでいる状況です。

CI/CDの導入やTerraform / Terraform providerのバージョンアップの省力化など課題はまだまだありますが、引き続きより良いTerraformを利用した開発体験を目指して改善を行っていきたいと思います。

このようなIaCの改善やSREとしての活動に興味があれば、ぜひSREチームのメンバーとお話ししましょう!

SRE / 株式会社ビザスク