terraform importしてplan差分がない=環境が再現できているといつから錯覚していた？

# はじめに

先日、Terraform v1.5.0がリリースされました :tada: 

https://www.hashicorp.com/blog/terraform-1-5-brings-config-driven-import-and-checks

v1.5の目玉はなんと言っても `import` ブロックと `terraform plan -generate-config-out` によるtfファイルの生成ですよね〜。これで既存のリソースもimportし放題だと巷で話題です。

ところで、Terraformの特徴として、「インフラをコード化することで環境が再現できる」などと一般的に謳われています。また、Terraformには「既存リソースをimportする機能」があります。別にそれぞれ単独では間違ってはないのですが、これらを組み合わせて、「既存リソースをimportしてplan差分が出なければ元の環境を再現できる」と言えるのでしょうか？

残念ながら現実にはそうとも言い切れません。なんとなく経験上わかってる人もいるとは思いますが、意外と気づいてない人も多そうな気がしたので、ちょっと実験してみましょう。

# 環境

検証に使用したTerraformのバージョンは、検証時点の最新版のTerraform v1.5.0です。

```
# terraform -v
Terraform v1.5.0
on linux_amd64
+ provider registry.terraform.io/hashicorp/aws v5.3.0
```

また、検証のログをカジュアルにコピペしても実害ないように、AWSのモックであるlocalstackで実行したログを貼ってますが、この事象自体は本物のAWS環境でも起きる問題ですし、そもそもTerraformの仕組み的にAWSに限らず他のプロバイダでも起きる問題です。ただの一例です。

# 実験
## 事前準備
ここでは例として、name属性にfooとbarを指定した2つのaws_security_groupのリソースを作ってみます。

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

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

```
# terraform apply -auto-approve

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with
the following symbols:
  + create

Terraform will perform the following actions:

  # aws_security_group.bar will be created
  + resource "aws_security_group" "bar" {
      + arn                    = (known after apply)
      + description            = "Managed by Terraform"
      + egress                 = (known after apply)
      + id                     = (known after apply)
      + ingress                = (known after apply)
      + name                   = "bar"
      + name_prefix            = (known after apply)
      + owner_id               = (known after apply)
      + revoke_rules_on_delete = false
      + tags_all               = (known after apply)
      + vpc_id                 = (known after apply)
    }

  # aws_security_group.foo will be created
  + resource "aws_security_group" "foo" {
      + arn                    = (known after apply)
      + description            = "Managed by Terraform"
      + egress                 = (known after apply)
      + id                     = (known after apply)
      + ingress                = (known after apply)
      + name                   = "foo"
      + name_prefix            = (known after apply)
      + owner_id               = (known after apply)
      + revoke_rules_on_delete = false
      + tags_all               = (known after apply)
      + vpc_id                 = (known after apply)
    }

Plan: 2 to add, 0 to change, 0 to destroy.
aws_security_group.bar: Creating...
aws_security_group.bar: Creation complete after 6s [id=sg-6c44c1c79799ce7b2]
aws_security_group.foo: Creating...
aws_security_group.foo: Creation complete after 0s [id=sg-23ffbb130d22cec58]

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
```

```
# terraform state list
aws_security_group.bar
aws_security_group.foo
```

リソースが作成されました。

## state rm & importしてみる

fooの方だけ一旦tfstateから消して、これをimportしてみます。

```
# terraform state rm aws_security_group.foo
Removed aws_security_group.foo
Successfully removed 1 resource instance(s).

# terraform state list
aws_security_group.bar
```

せっかくなのでv1.5の新機能importブロックでimportしてみましょう。importに必要なidはリソース作成時のapplyのログに出てるので、各自で読み替えてください。

```main.tf
import {
  id = "sg-23ffbb130d22cec58"
  to = aws_security_group.foo
}

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

aws_security_group.fooのresourceブロックの代わりに、importブロックを書いて、 `plan -generate-config-out` でresourceブロックを生成します。

```
# terraform plan -generate-config-out=generated.tf
aws_security_group.bar: Refreshing state... [id=sg-6c44c1c79799ce7b2]
aws_security_group.foo: Preparing import... [id=sg-23ffbb130d22cec58]
aws_security_group.foo: Refreshing state... [id=sg-23ffbb130d22cec58]

Terraform will perform the following actions:

  # aws_security_group.foo will be imported
  # (config will be generated)
    resource "aws_security_group" "foo" {
        arn         = "arn:aws:ec2:ap-northeast-1:000000000000:security-group/sg-23ffbb130d22cec58"
        description = "Managed by Terraform"
        egress      = []
        id          = "sg-23ffbb130d22cec58"
        ingress     = []
        name        = "foo"
        owner_id    = "000000000000"
        tags        = {}
        tags_all    = {}
        vpc_id      = "vpc-5d80b3ff"
    }

Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
╷
│ Warning: Config generation is experimental
│
│ Generating configuration during import is currently experimental, and the generated configuration format may change
│ in future versions.
╵

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

Terraform has generated configuration and written it to generated.tf. Please review the configuration and edit it as
necessary before adding it to version control.

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.
```

以下のtfファイルが生成されました。

```generated.tf
# __generated__ by Terraform
# Please review these resources and move them into your main configuration files.

# __generated__ by Terraform from "sg-23ffbb130d22cec58"
resource "aws_security_group" "foo" {
  description            = "Managed by Terraform"
  egress                 = []
  ingress                = []
  name                   = "foo"
  name_prefix            = null
  revoke_rules_on_delete = null
  tags                   = {}
  tags_all               = {}
  vpc_id                 = "vpc-5d80b3ff"
}
```

属性値が `[]`, `null`, `{}` のようにゼロ値になっているものは若干冗長な感じがしますよね。
どれがRequiredな属性なのかはまぁちゃんと調べれば分かるんですが、どうせ足りなければバリデーションエラーで怒られるので、一旦全部コメントアウトしてみましょう。

```generated.tf
# __generated__ by Terraform
# Please review these resources and move them into your main configuration files.

# __generated__ by Terraform from "sg-23ffbb130d22cec58"
resource "aws_security_group" "foo" {
  # description = "Managed by Terraform"
  # egress                 = []
  # ingress                = []
  # name = "foo"
  # name_prefix            = null
  # revoke_rules_on_delete = null
  # tags                   = {}
  # tags_all               = {}
  # vpc_id                 = "vpc-5d80b3ff"
}
```

こんなかんじで、一旦planを実行してみます。

```
# terraform plan
aws_security_group.foo: Preparing import... [id=sg-23ffbb130d22cec58]
aws_security_group.bar: Refreshing state... [id=sg-6c44c1c79799ce7b2]
aws_security_group.foo: Refreshing state... [id=sg-23ffbb130d22cec58]

Terraform will perform the following actions:

  # aws_security_group.foo will be imported
    resource "aws_security_group" "foo" {
        arn         = "arn:aws:ec2:ap-northeast-1:000000000000:security-group/sg-23ffbb130d22cec58"
        description = "Managed by Terraform"
        egress      = []
        id          = "sg-23ffbb130d22cec58"
        ingress     = []
        name        = "foo"
        owner_id    = "000000000000"
        tags        = {}
        tags_all    = {}
        vpc_id      = "vpc-5d80b3ff"
    }

Plan: 1 to import, 0 to add, 0 to change, 0 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.
```

おや、 最初に作った時に指定した `name = "foo"` の指定が漏れてますが、plan差分なしでimportできそうに見えます。
なんとなく嫌な予感がしつつ、このままimportしてみます。

```
# terraform apply -auto-approve
aws_security_group.foo: Preparing import... [id=sg-23ffbb130d22cec58]
aws_security_group.foo: Refreshing state... [id=sg-23ffbb130d22cec58]
aws_security_group.bar: Refreshing state... [id=sg-6c44c1c79799ce7b2]

Terraform will perform the following actions:

  # aws_security_group.foo will be imported
    resource "aws_security_group" "foo" {
        arn         = "arn:aws:ec2:ap-northeast-1:000000000000:security-group/sg-23ffbb130d22cec58"
        description = "Managed by Terraform"
        egress      = []
        id          = "sg-23ffbb130d22cec58"
        ingress     = []
        name        = "foo"
        owner_id    = "000000000000"
        tags        = {}
        tags_all    = {}
        vpc_id      = "vpc-5d80b3ff"
    }

Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
aws_security_group.foo: Importing... [id=sg-23ffbb130d22cec58]
aws_security_group.foo: Import complete [id=sg-23ffbb130d22cec58]

Apply complete! Resources: 1 imported, 0 added, 0 changed, 0 destroyed.
```

plan差分なしでimportできてしまいました。

## リソース再生成

この状態で、fooのリソースを再生成すると何が起きるのでしょうか？ `terraform apply -replace` で作り直してみましょう。
`-replace` になじみがない人向けに補足しておくと、特定のresourceブロックを一旦コメントアウトしてapplyし、戻してapplyでリソースを再生成するのとやってることは実質同じことですが、destroy/createを一撃でできます。

```
# terraform apply -replace=aws_security_group.foo
aws_security_group.foo: Refreshing state... [id=sg-23ffbb130d22cec58]
aws_security_group.bar: Refreshing state... [id=sg-6c44c1c79799ce7b2]

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.foo will be replaced, as requested
-/+ resource "aws_security_group" "foo" {
      ~ arn                    = "arn:aws:ec2:ap-northeast-1:000000000000:security-group/sg-23ffbb130d22cec58" -> (known after apply)
      ~ egress                 = [] -> (known after apply)
      ~ id                     = "sg-23ffbb130d22cec58" -> (known after apply)
      ~ ingress                = [] -> (known after apply)
      ~ name                   = "foo" -> (known after apply)
      + name_prefix            = (known after apply)
      ~ owner_id               = "000000000000" -> (known after apply)
      + revoke_rules_on_delete = false
      - tags                   = {} -> null
      ~ tags_all               = {} -> (known after apply)
      ~ vpc_id                 = "vpc-5d80b3ff" -> (known after apply)
        # (1 unchanged attribute hidden)
    }

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

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_security_group.foo: Destroying... [id=sg-23ffbb130d22cec58]
aws_security_group.foo: Destruction complete after 0s
aws_security_group.foo: Creating...
aws_security_group.foo: Creation complete after 1s [id=sg-ad987d40c176f1729]

Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
```

```
# terraform state list
aws_security_group.bar
aws_security_group.foo
```

リソースが再生成されました。
結果の比較のため、もう一度fooをtfstateから消して、同じようにimportしなおしてみます。

```
# terraform state rm aws_security_group.foo
Removed aws_security_group.foo
Successfully removed 1 resource instance(s).
```

一旦、先ほどのgenerated.tfを退避し、リソース再生成してidが変わってしまっているので、importブロックのidも更新します。

```
# mv generated.tf generated.tf.bk
```

```main.tf
import {
  id = "sg-ad987d40c176f1729"
  to = aws_security_group.foo
}

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

再度、fooのresourceブロックを生成します。

```
# terraform plan -generate-config-out=generated.tf
aws_security_group.foo: Preparing import... [id=sg-ad987d40c176f1729]
aws_security_group.foo: Refreshing state... [id=sg-ad987d40c176f1729]
aws_security_group.bar: Refreshing state... [id=sg-6c44c1c79799ce7b2]

Planning failed. Terraform encountered an error while generating this plan.

╷
│ Warning: Config generation is experimental
│
│ Generating configuration during import is currently experimental, and the generated configuration format may change
│ in future versions.
╵
╷
│ Error: Conflicting configuration arguments
│
│   with aws_security_group.foo,
│   on generated.tf line 4:
│   (source code not available)
│
│ "name": conflicts with name_prefix
╵
╷
│ Error: Conflicting configuration arguments
│
│   with aws_security_group.foo,
│   on generated.tf line 5:
│   (source code not available)
│
│ "name_prefix": conflicts with name
╵
```

今度は `name` と `name_prefix` がコンフリクトしているよというエラーが出ますが、tfファイルは生成されているので、中身をみるとこんなかんじになってました。

```generated.tf
# __generated__ by Terraform
# Please review these resources and move them into your main configuration files.

# __generated__ by Terraform
resource "aws_security_group" "foo" {
  description            = "Managed by Terraform"
  egress                 = []
  ingress                = []
  name                   = "terraform-20230613142830207200000001"
  name_prefix            = "terraform-"
  revoke_rules_on_delete = null
  tags                   = {}
  tags_all               = {}
  vpc_id                 = "vpc-5d80b3ff"
}
```

最初に事前準備で作成したリソース定義と比較すると、 `name=foo` ではなく、 `name=terraform-20230613142830207200000001` となっていることが確認できます。

# 考察

この検証では簡単のため事前準備でもTerraformでリソースを作っていますが、実際のユースケースでは、誰かが画面でポチポチ作ってしまったものを、あとからimportしてTerraformの管理下に持ってくるというようなケースを想定しています。とすると、初期構築の時点でnameが指定されていたのに、importしてplan差分がないことを確認しただけでは、リソースを作りなおすとnameが変わってしまっており、完全には再現できていないとも言えます。

誤解のないように補足しておくと、importは不完全なので使うべきではないと言いたいわけではなく、むしろ手で作ってしまったリソースは可能な限りimportしてカバレッジを上げたほうがよいと考えています。ただimportしてplan差分がないことを確認しただけでは、環境が完全に再現できるわけではないという限界も認識しておくべきです。

もしリソースの再現性を重視するのであれば、実際に再生成してみるのが一番確実です。ただ現実的には気軽に再生成できないリソースが多々あるので、型定義などから机上でなにか言えるとよいのですが、省略した場合のデフォルト値をプロバイダが埋めるのか、その先のクラウドのAPI側が埋めるのか、ゼロ値はどのように解釈されるのか、表記ゆれなど差分を抑止するための独自ロジックの差し込みなどなど、リソースタイプに依存した個別具体的なプロバイダ実装とその先のAPIに依存するので、Optionalな属性は省略すると再現できない可能性があるという以上の一般化が難しそうです。

もちろんOptionalな属性であっても一旦tfstateに書かれたものを、tfファイル側から変更したり削除したりすると通常はplan差分として検出されます。見方を変えると、Optionalな属性を省略するというのは、その設定には興味がないということです。もし他の値になっても気にしないということです。なので、明示的に属性値を設定した範囲では宣言したとおりになっているとも言える。つまり、どの範囲を再現したいのかということを宣言する必要がある。それはそう。

可能な限りの現状の環境の再現性を重視するのであれば、すべてのOptionalな属性も値を埋めておく方向に倒す方が安全で、 `terraform plan -generate-config-out` の出力もそうなってますが、特に意図のないデフォルト値の列挙は維持管理を引き継ぐ人からすると非常に認知負荷が高いです。この例では数個ですが、リソースタイプによっては、数十個の属性を持っているものもありますし、もちろん複数のリソースをimportするとさらに行数が増えます。読む人が都度これはデフォルト値なのかそうでないのか考えながら、パラメータの山に埋もれた値から他のリソースとの関連を読み解くのは一苦労です。可能であれば、importしたタイミングでデフォルト値は削っておけるとあとから読む人が幸せでしょう。

# まとめ
Terraformで既存リソースをimportしてplan差分が出ないことは必ずしも環境の再現性を意味しません。Optionalな属性を省略するというのは、その設定には興味がないということです。値が変わっても気にしないということです。明示的に宣言した範囲において、宣言したとおりになります。どの範囲を再現したいのかが重要です。
Terraformの設定を単なる設定のパラメータシートとするのではなく、設計書として設定値の意図を明確にしましょう。各属性の意味を理解し、たとえデフォルト値であっても明示することに意味があるように感じられるのであれば明示すべきですし、特にこだわりがなくデフォルト値をそのまま受け入れている箇所は省略するとよいでしょう。
とはいえ他人から引き継いで意図がわからないものは一旦ありのままを受け入れるか、可能であればプロバイダ実装やAPI側のデフォルト値を調べて、とくにデフォルト値からいじってなさそうなものは省略しておくとあとの人が幸せです。

なんか当たり前のことを言っているだけのような気もしてきましたが、plan差分がない=環境を再現できていると概念レベルで単純にざっくり認識している人も多そうな気がしたのであえて書いてみました。