Help us understand the problem. What is going on with this article?

Terraformのstate操作をgitにコミットしたくてtfmigrateというツールを書いた

はじめに

Terraform職人のみなさんは無限に「ぼくのかんがえたさいきょうのディレクトリ構成」についてメリデメを議論していますが、未だに銀の弾丸のようなベストプラクティスは見つかっていないようです。なぜでしょう?
それは最適解がサービスの規模、組織の構造、メンバーのスキルなどいろいろな変数に依存しているからです。さらに悩ましいことに、これらの要因は固定ではなく変化するので、ある時点での最適解が時間の経過とともに現状にうまくマッチしなくなり、いわゆる技術的負債になったりします。つらい。

個人的な解釈では、組織の成熟度に合わせてモジュールを細かく切っていく方向に徐々に向かっていくというような傾向があるようには思いますが、組織のフェーズによってうつりかわるものなので、唯一の最適解というものはそもそも存在しないのです。そんなこんなで、最近の私の関心事は、理想と現実がずれてきたときに、どうやったらTerraformのリファクタリングが簡単にできるのだろうかということを考えています。

で、先日 hcleditというツールを書いてみたんだけど 、これを使うとHCLをコマンドで編集できてTerraformのリファクタリングが簡単、やったー、めでたしめでたし。。。だったらよかったんですが、聡明なTerraform職人のみなさんはお気付きの通り、Terraformリファクタリングは単純に既存リソースを作り直すことができないものも多いです。最悪テスト環境ならまぁ作り直しでいいけど、本番環境のリソース(ネットワークとかデータベースとか)は大体簡単に作り直せないやん?リソースの再生成を回避するには *.tfファイルの編集だけではなく、tfstate(Terraformの状態管理ファイル)も調整する必要があります。これはご存知のとおり terraform state mv コマンドを使うとできるのですが、このコマンドはtfstateをデフォルトではその場で書き換えてしまいます。

しかしながらチーム開発をしている場合、 *.tf はgitでバージョン管理し、 tfstateはリモートのbackend(AWS S3とか)に保存するという管理方法が一般的です。つまり、リモートのtfstateをその場で書き換えてしまうと、masterブランチの状態と差分が出てしまいます。できれば避けたいところです。
でもtfファイル変更のレビュー前に、リモートのtfstateを書き換えずに、terraform state mv後のplan差分がなくなることも事前に確認したいのが人情です。もし未確認だとすると、このstate操作が妥当かどうか本人やレビュワーは何を根拠に判断すればよいんでしょうか?厳密に言うと、一応これは頑張れば技術的にはできるので、以前手順をまとめてみたことがあるんですが↓

リモートのtfstateを書き換えずに安全にterraform state mv後のplan差分を確認する手順

一旦リモートのbackend設定をローカルに上書きして無効化したり、かなり手順が煩雑です。人は怠惰な生き物なので、手順がめんどくさいとリファクタリングするのが億劫です。またterraform state mvに限らず、importとかもレビュー前にplan差分がないこと確認したいんだよなぁ〜とか、とくにかくstate操作全般がめんどくさいです。つらい。

なんかよい方法はないかなぁと思い、DBマイグレーションツールみたいに、マイグレーションファイルをgitにコミットできればよいんだよね?ということに気づき、tfmigarteというツールを書いてみました。

https://github.com/minamijoyo/tfmigrate

現状の特徴はこんなかんじです↓

  • GitOpsフレンドリ: terraform state mv/rm/import をHCLで書いてgitにコミットし、plan/applyできます。
  • モノレポ対応: リソースをディレクトリを跨いで他のtfstate移動できます。これはtfstateを分割/結合するようなリファクタリングに便利です。
  • dry-run: リモートのtfstateを更新することなく、一時的なローカルのtfstateに対してマイグレーションを実行し、マイグレーション後にterraform planの差分がなくなるかを事前にチェックできます。

リファクタリング専用というわけではなく、state操作全般をマイグレーションという概念で扱えるようにして、gitにコミットできるようにしようという作戦です。いわゆるDBマイグレーションツールと比較すると、現状マイグレーションの履歴管理機能がまだ未実装で絶賛開発中なのですが、まぁ現状でも十分便利だし、ミニマム動くようになったので紹介します。

※本稿執筆時点のtfmigrateはv0.1.0です。最新の状況についてはREADMEを参照して下さい。

インストール

いくつかの方法でインストールできます。

macOSの場合は、簡単にHomebrew経由でインストールできるようにしてあります。

$ brew install minamijoyo/tfmigrate/tfmigrate

Linuxで動かしたい場合は、ここからビルド済みのバイナリをダウンロードするか

https://github.com/minamijoyo/tfmigrate/releases

それ以外のOSの場合は、ソースコードからビルドして下さい。(Go 1.15以上が必要)

$ git clone https://github.com/minamijoyo/tfmigrate
$ cd tfmigrate/
$ make install
$ tfmigrate --version

使い方

とりあえず --help を付けるとヘルプが出ますが、コマンド自体は今のところplanとapplyしかありません。

$ tfmigrate --help
Usage: tfmigrate [--version] [--help] <command> [<args>]

Available commands are:
    apply    Compute a new state and push it to remote state
    plan     Compute a new state

plan/applyはそれぞれマイグレーションファイルのパスを引数に指定します。

planはいわゆるdry-runモードで、リモートのtfstateを更新することなく、一時的なローカルのtfstateに対してマイグレーションを実行して、マイグレーション後にterraform planの差分がなくなるかを事前にチェックできます。

$ tfmigrate plan --help
Usage: tfmigrate plan <PATH>

Plan computes a new state by applying state migration operations to a temporary state.
It will fail if terraform plan detects any diffs with the new state.

Arguments
  PATH               A path of migration file

applyは、planで新しいtfstateを生成した後、terraform planの差分がなければリモートに更新後のtfstateをpushします。

$ tfmigrate apply --help
Usage: tfmigrate apply <PATH>

Apply computes a new state and pushes it to remote state.
It will fail if terraform plan detects any diffs with the new state.

Arguments
  PATH               A path of migration file

設定

環境変数で若干動作を変更できます。

  • TFMIGRATE_LOG: ログレベルで、有効な値は TRACE, DEBUG, INFO, WARN, ERROR です。デフォルトは INFO です。
  • TFMIGRATE_EXEC_PATH: terraformコマンドを実行するパスで、デフォルトは terraform です。 terraform コマンドのフルパスを指定して、別のバイナリに差し替えることも可能ですが、これはどちらというとラッパースクリプトを差し込んで terraform コマンドの実行方法をカスタマイズすることを意図しています。例えばAWSなどのクラウドのアクセスキーを direnv を使ってディレクトリごとに切り替えている場合には、 direnv exec . terraform と設定すると、 direnv で環境変数を読み込んだ状態で terraform コマンドが実行されます。

マイグレーションファイルの例

マイグレーションファイルはHCLで書きます。YAMLでもいいのに無駄にHCLなのは作者の趣味です。
サンプルで大体雰囲気で理解できると思いますが、厳密なシンタックスの定義はREADMEを参照してください。

state mv

たとえば dir1 で以下2つのコマンドを実行するには

  • terraform state mv aws_security_group.foo aws_security_group.foo2
  • terraform state mv aws_security_group.bar aws_security_group.bar2

こんなかんじで migration ブロックをタイプ state で書きます。ラベルの test のところは任意の文字列で問題ありません。

tfmigrate_test.hcl
migration "state" "test" {
  dir = "dir1"
  actions = [
    "mv aws_security_group.foo aws_security_group.foo2",
    "mv aws_security_group.bar aws_security_group.bar2",
  ]
}

dirtfmigrate コマンドを実行するディレクトリからの相対パスです。モノレポ構成で複数のディレクトリが存在する想定ですが、省略するとデフォルトはカレントディレクトリ . と解釈されます。
actions の中身の文字列は、もっと厳密な型定義をすることも技術的には可能だったのですが、スキーマレスなかんじでただの文字列になってるのは意図的で、厳密に型定義して構造化するよりも素の terraform state コマンドをコピペしやすいようになってる方がマイグレーションファイルを書くのが簡単なのでこうしました。
また単に裏で terraform state mv を実行してるだけなので、リソースのアドレスにモジュールのアドレスを指定すると、モジュールごと移動できます。

migrationブロックは1ファイルに1つだけ書いてください。ファイル名の命名規則は今のところ特にありませんが、将来的に履歴管理機能を追加する時になんらかの制約を追加するかもしれません。(ファイル名でソートして未適用のものを順にapplyしたりする制御を想像すると、ファイル名の先頭に YYYMMDDHHMMSS_ みたいなタイムスタンプのprefixをつけておいた方が無難かもしれません)

ちなみに(あまり知られていないかもしれませんが)HCLの仕様にはJSON記法も含まれているので、こんなかんじでJSONでも書けます。他のプログラムからマイグレーションファイルを自動生成したい場合は、こっちの方が便利かもしれません。

tfmigrate_test.json
{
  "migration": {
    "state": {
      "test": {
        "dir": "dir1",
        "actions": [
          "mv aws_security_group.foo aws_security_group.foo2",
          "mv aws_security_group.bar aws_security_group.bar2"
        ]
      }
    }
  }
}

state rm

rmもできます。

migration "state" "test" {
  dir = "dir1"
  actions = [
    "rm aws_security_group.baz",
  ]
}

state import

importもできます。

migration "state" "test" {
  dir = "dir1"
  actions = [
    "import aws_security_group.qux sg-XXXXX",
  ]
}

完全に余談ですが、 importは terraform state import コマンドではなく terraform import コマンドになってるのに、 migration のブロックが state になってて、mvやrmと並列にあるのは意図的です。 terraform import コマンドは現状tfstateしか操作しませんが、将来のバージョンでは*.tfも生成するつもりが一応あるらしく、今後terraform import に *.tfファイルの生成機能が入った場合でも、tfmigrateの責務としてはtfstateしか操作するつもりがないからです。

multi_state mv

リソースのディレクトリを移動する場合は、 migration ブロックに multi_state タイプを使って from_dirto_dir を指定します。移動の前後でリソース名も変更することもできますが、同じでも構いません。

migration "multi_state" "mv_dir1_dir2" {
  from_dir = "dir1"
  to_dir   = "dir2"
  actions = [
    "mv aws_security_group.foo aws_security_group.foo2",
    "mv aws_security_group.bar aws_security_group.bar2",
  ]
}

仕組み

tfstate操作は発展的なトピックで、自分が何をしているのかを理解せずに使うのは個人的にあんまりおすすめできません。tfmigrateがどうやって動いているのか、その仕組が気になって仕方がない人もいるでしょう。というわけで、そんなあなたのために、実験用に安全に何度でも作って壊せるサンドボックス環境をDockerで準備しておきました。
この環境は、AWSのAPIをlocalstackを使ってモックしているので、クレデンシャル不要で実際には何もリソースを作成しませんが、本物のterraformコマンドとtfmigrateコマンドが動きます。「tfmigrate完全に理解した」と思うまで好きなだけ遊んでください。

とりあえず、tfmigrateのリポジトリをgit cloneしてきて、docker-compose buildしてbashを立ち上げてください。

$ git clone https://github.com/minamijoyo/tfmigrate
$ cd tfmigrate/
$ docker-compose build
$ docker-compose run --rm tfmigrate /bin/bash

バックエンドをモックしたサンプルのtfファイルも準備してあるので、これを使ってterraform initします。

# mkdir -p tmp/dir1 && cd tmp/dir1
# terraform init -from-module=../../test-fixtures/backend_s3/
# cat main.tf

このサンプルは aws_security_group が2つ定義されているだけです。

main.tf
resource "aws_security_group" "foo" {
  name = "foo"
}

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

backendのモックがどうやって実装されてるのか興味がある人は、同じディレクトリにコピーされた config.tf の中身も覗いて見て下さい。

config.tf
terraform {
  # https://www.terraform.io/docs/backends/types/s3.html
  backend "s3" {
    region = "ap-northeast-1"
    bucket = "tfstate-test"
    key    = "test/terraform.tfstate"

    // mock s3 endpoint with localstack
    endpoint                    = "http://localstack:4566"
    access_key                  = "dummy"
    secret_key                  = "dummy"
    skip_credentials_validation = true
    skip_metadata_api_check     = true
    force_path_style            = true
  }
}

# https://www.terraform.io/docs/providers/aws/index.html
# https://www.terraform.io/docs/providers/aws/guides/custom-service-endpoints.html#localstack
provider "aws" {
  region = "ap-northeast-1"

  access_key                  = "dummy"
  secret_key                  = "dummy"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_region_validation      = true
  skip_requesting_account_id  = true
  s3_force_path_style         = true

  // mock endpoints with localstack
  endpoints {
    s3  = "http://localstack:4566"
    ec2 = "http://localstack:4566"
    iam = "http://localstack:4566"
  }
}

※他のリソースタイプを使いたいという人はモックするエンドポイントの設定を増やしてください。ただlocalstackのモック自体はすべてのAPIに対応しているわけではないことに注意。

terraform applyして、リソースがtfstateに書き込まれたことを確認します。

# terraform apply -auto-approve
# terraform state list
aws_security_group.bar
aws_security_group.foo

ちょっとリファクタリングを見立てて、リソースを aws_security_group.foo から aws_security_group.baz にリネームしてみましょう。

# cat << EOF > main.tf
resource "aws_security_group" "baz" {
  name = "foo"
}

resource "aws_security_group" "bar" {
  name = "bar"
}
EOF

当たり前ですが、このままだとリソースが再生成されてしまうのでplan差分がでます。

# terraform plan
(snip.)
Plan: 1 to add, 0 to change, 1 to destroy.

でわ本題のtfmigrateを使ってみましょう。
以下のようなマイグレーションファイルを作成します。

# cat << EOF > tfmigrate_test.hcl
migration "state" "test" {
  actions = [
    "mv aws_security_group.foo aws_security_group.baz",
  ]
}
EOF

作成したマイグレーションファイルを指定して、 tfmigrate plan を実行してみましょう。

# tfmigrate plan tfmigrate_test.hcl
2020/09/22 13:33:23 [INFO] [command] read migration file: tfmigrate_test.hcl
2020/09/22 13:33:23 [INFO] [command] new migrator: &config.StateMigratorConfig{Dir:"", Actions:[]string{"mv aws_security_group.foo aws_security_group.baz"}}
2020/09/22 13:33:23 [INFO] [migrator] start state migrator plan
2020/09/22 13:33:25 [INFO] [migrator@.] terraform version: 0.13.3
2020/09/22 13:33:25 [INFO] [migrator@.] initialize work dir
2020/09/22 13:33:27 [INFO] [migrator@.] get the current remote state
2020/09/22 13:33:29 [INFO] [migrator@.] override backend to local
2020/09/22 13:33:29 [INFO] [executor@.] create an override file
2020/09/22 13:33:29 [INFO] [executor@.] switch backend to local
2020/09/22 13:33:33 [INFO] [migrator@.] compute a new state
2020/09/22 13:33:33 [INFO] [migrator@.] check diffs
2020/09/22 13:33:40 [INFO] [executor@.] remove the override file
2020/09/22 13:33:40 [INFO] [executor@.] switch back to remote
2020/09/22 13:33:45 [INFO] [migrator] state migrator plan success!
# echo $?
0

state migrator plan success! と出力されていれば成功です。
tfmigrate plan はリモートのtfstateを書き換えることなく、一時的なローカルのtfstateで terraform state mv を試して、マイグレーション後のtfstateを使って terraform plan に差分がないことを確認します。もし terraform plan に差分がある場合は、差分を表示して異常終了します。

これ実際に裏でどのようなterraformコマンドが実行されてるか気になりますよね?なりますよね?
というわけでログレベルをDEBUGにしてみましょう。

# TFMIGRATE_LOG=DEBUG tfmigrate plan tfmigrate_test.hcl
2020/09/22 13:34:52 [DEBUG] [main] start: tfmigrate plan tfmigrate_test.hcl
2020/09/22 13:34:52 [DEBUG] [main] tfmigrate version: 0.1.0
2020/09/22 13:34:52 [DEBUG] [command] option: &tfmigrate.MigratorOption{ExecPath:""}
2020/09/22 13:34:52 [INFO] [command] read migration file: tfmigrate_test.hcl
2020/09/22 13:34:52 [DEBUG] [command] parse migration file: "migration \"state\" \"test\" {\n  actions = [\n    \"mv aws_security_group.foo aws_security_group.baz\",\n  ]\n}\n"
2020/09/22 13:34:52 [INFO] [command] new migrator: &config.StateMigratorConfig{Dir:"", Actions:[]string{"mv aws_security_group.foo aws_security_group.baz"}}
2020/09/22 13:34:52 [INFO] [migrator] start state migrator plan
2020/09/22 13:34:54 [DEBUG] [executor@.]$ terraform version
2020/09/22 13:34:54 [INFO] [migrator@.] terraform version: 0.13.3
2020/09/22 13:34:54 [INFO] [migrator@.] initialize work dir
2020/09/22 13:34:57 [DEBUG] [executor@.]$ terraform init -input=false -no-color
2020/09/22 13:34:57 [INFO] [migrator@.] get the current remote state
2020/09/22 13:34:59 [DEBUG] [executor@.]$ terraform state pull
2020/09/22 13:34:59 [INFO] [migrator@.] override backend to local
2020/09/22 13:34:59 [INFO] [executor@.] create an override file
2020/09/22 13:34:59 [INFO] [executor@.] switch backend to local
2020/09/22 13:35:02 [DEBUG] [executor@.]$ terraform init -input=false -no-color -reconfigure
2020/09/22 13:35:02 [INFO] [migrator@.] compute a new state
2020/09/22 13:35:02 [DEBUG] [executor@.]$ terraform state mv -state=/tmp/tmp244406407 -backup=/dev/null aws_security_group.foo aws_security_group.baz
2020/09/22 13:35:02 [INFO] [migrator@.] check diffs
2020/09/22 13:35:10 [DEBUG] [executor@.]$ terraform plan -state=/tmp/tmp494773306 -out=/tmp/tfplan485633105 -input=false -no-color -detailed-exitcode
2020/09/22 13:35:10 [INFO] [executor@.] remove the override file
2020/09/22 13:35:10 [INFO] [executor@.] switch back to remote
2020/09/22 13:35:14 [DEBUG] [executor@.]$ terraform init -input=false -no-color -reconfigure
2020/09/22 13:35:14 [INFO] [migrator] state migrator plan success!

backendをlocalに切り替えるためのoverrideファイルを生成したり、 terraform init -reconfigure してたりするのが見えますね。
また terraform state mv するときに -state=/tmp/tmp244406407 で一時ファイルのtfstateを使ってるのも見えますね。ちなみに次の terraform plan-state=/tmp/tmp494773306 にさっきと違う一時ファイルが渡されてるのが気になる人がいるかもしれませんが、内部的に使用している一時ファイルはterraformコマンドの実行直後にメモリに読み込んで都度削除されています。これはできるだけファイルに状態を持たないようにして、panicで落ちない限りはゴミが残らないようにするための意図的な実装です。

よさそうなら tfmigrate apply を実行してみましょう。

# TFMIGRATE_LOG=DEBUG tfmigrate apply tfmigrate_test.hcl
2020/09/22 13:36:22 [DEBUG] [main] start: tfmigrate apply tfmigrate_test.hcl
2020/09/22 13:36:22 [DEBUG] [main] tfmigrate version: 0.1.0
2020/09/22 13:36:22 [DEBUG] [command] option: &tfmigrate.MigratorOption{ExecPath:""}
2020/09/22 13:36:22 [INFO] [command] read migration file: tfmigrate_test.hcl
2020/09/22 13:36:22 [DEBUG] [command] parse migration file: "migration \"state\" \"test\" {\n  actions = [\n    \"mv aws_security_group.foo aws_security_group.baz\",\n  ]\n}\n"
2020/09/22 13:36:22 [INFO] [command] new migrator: &config.StateMigratorConfig{Dir:"", Actions:[]string{"mv aws_security_group.foo aws_security_group.baz"}}
2020/09/22 13:36:22 [INFO] [migrator] start state migrator plan phase for apply
2020/09/22 13:36:23 [DEBUG] [executor@.]$ terraform version
2020/09/22 13:36:23 [INFO] [migrator@.] terraform version: 0.13.3
2020/09/22 13:36:23 [INFO] [migrator@.] initialize work dir
2020/09/22 13:36:26 [DEBUG] [executor@.]$ terraform init -input=false -no-color
2020/09/22 13:36:26 [INFO] [migrator@.] get the current remote state
2020/09/22 13:36:28 [DEBUG] [executor@.]$ terraform state pull
2020/09/22 13:36:28 [INFO] [migrator@.] override backend to local
2020/09/22 13:36:28 [INFO] [executor@.] create an override file
2020/09/22 13:36:28 [INFO] [executor@.] switch backend to local
2020/09/22 13:36:32 [DEBUG] [executor@.]$ terraform init -input=false -no-color -reconfigure
2020/09/22 13:36:32 [INFO] [migrator@.] compute a new state
2020/09/22 13:36:32 [DEBUG] [executor@.]$ terraform state mv -state=/tmp/tmp553216217 -backup=/dev/null aws_security_group.foo aws_security_group.baz
2020/09/22 13:36:32 [INFO] [migrator@.] check diffs
2020/09/22 13:36:40 [DEBUG] [executor@.]$ terraform plan -state=/tmp/tmp083744356 -out=/tmp/tfplan172551283 -input=false -no-color -detailed-exitcode
2020/09/22 13:36:40 [INFO] [executor@.] remove the override file
2020/09/22 13:36:40 [INFO] [executor@.] switch back to remote
2020/09/22 13:36:44 [DEBUG] [executor@.]$ terraform init -input=false -no-color -reconfigure
2020/09/22 13:36:44 [INFO] [migrator] start state migrator apply phase
2020/09/22 13:36:44 [INFO] [migrator] push the new state to remote
2020/09/22 13:36:46 [DEBUG] [executor@.]$ terraform state push /tmp/tmp424754998
2020/09/22 13:36:46 [INFO] [migrator] state migrator apply success!
# echo $?
0

tfmigrate apply コマンドは 内部的に tfmigrate plan を実行した後に、 terraform plan 差分がなければ terraform state push してリモートのtfstateを更新しています。
terraform plan 差分がある場合は、異常終了します。

念のため最後に terraform plan を実行してみると、plan差分がなくなっていることが確認できます。

# terraform plan
(snip.)
No changes. Infrastructure is up-to-date.

やったね。

まとめ

Terraformのstate操作をgitにコミットしたくてtfmigrateというツールを書きました。
基本的にやってることは 複数のterraformコマンドを組み合わせ実行しているだけで、tfstateの中身を直接書き換えたりしているわけではありません。tfstateを直接編集しちゃうと特定のTerraformバージョンに依存しすぎて、安定的に複数のTerraformバージョンをサポートすることが難しくなるので、tfstateの操作はすべてterraformコマンドを通して実行できる範囲のことしかやってません。つまり全部手動でやろうと思えばできるオペレーションなんですが、めんどくさいので自動化しているだけです。手動で実行するのとの違いは一連のtfstate操作をマイグレーションファイルとして保存できるので、gitにコミットしてtfファイルと同様にバージョン管理に乗せることが可能になったわけです。一言でいうとTerraform state operation as Code的な。

いわゆるDBマイグレーションツールと比較すると、現状マイグレーションの履歴管理機能がまだ未実装です。ここで言ってる履歴管理機能というのは、どのマイグレーションファイルをapply済みかをなんらかの方法で保存しておき、tfmigrate applyするときにマイグレーションファイル名を指定しなくても、未反映のものだけ順にapplyするような機能をイメージしてます。絶賛開発中なんですが、まじめに履歴管理を作ろうとすると当初思ったより実装規模が膨らんできたこともあり、さしあたり現状でも十分便利なので、とりあえず一旦ここまでで共有してみた次第です。

もし気に入ったらスター☆してくれると、今後の開発の励みになります |ω・`)チラッ
https://github.com/minamijoyo/tfmigrate

(2020/11/24追記) tfmigrate v0.2.0で履歴管理機能を追加しました。
https://github.com/minamijoyo/tfmigrate/blob/master/CHANGELOG.md#020-20201118

crowdworks
21世紀の新しいワークスタイルを提供する日本最大級のクラウドソーシング「クラウドワークス」のエンジニアチームです!
https://crowdworks.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away