terraform でリソースのリージョン変更時の嵌りどころ

AWS の SES でオレゴンリージョンを使っていたものを、東京リージョンに切り換えるために terraform で変更しようとして嵌ったときのメモ。

この記事を書いたときの terraform のバージョンは 1.4.6 です。書くだけ書いて寝かせていたらいつの間にか最新は 1.6.2 とかになってました。ので最新版で同じ結果になるかは判りません(tfstate のバージョンが変わらない限りこの結果も変わらないだろうのでたぶん変わってないと思います)。

プロバイダのリージョンだけ変更すると旧リソースが取り残される

例えば次のように東京リージョンに SSM パラメータを作成しているとします(SSM パラメータなのに特に意味はありません。サッとすぐ作って試せるリソースなら何でもよかった)。

provider "aws" {
  region = "ap-northeast-1"
  alias  = "hoge"
}

resource "aws_ssm_parameter" "a" {
  provider = aws.hoge
  name     = "/test/a"
  type     = "String"
  value    = "aa"
}

この状態でリージョンだけ ap-northeast-3 に変更して plan すると、期待する結果としては a の replace (あるいは古いリージョンでの delete と新しいリージョンでの create)ですが、実際はそうはならずに新しいリージョンでの create のみになります。

aws_ssm_parameter.a: Refreshing state... [id=/test/a]

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_ssm_parameter.a will be created
  + resource "aws_ssm_parameter" "a" {
      + arn            = (known after apply)
      + data_type      = (known after apply)
      + id             = (known after apply)
      + insecure_value = (known after apply)
      + key_id         = (known after apply)
      + name           = "/test/a"
      + tags_all       = (known after apply)
      + tier           = (known after apply)
      + type           = "String"
      + value          = (sensitive value)
      + version        = (known after apply)
    }

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

ので、apply すると古いリージョンのリソースは terraform 管理外となって残ったまま、新しいリージョンのリソースが作成されます。

原因

terraform は tfstate の中でリソースごとにプロバイダ名は記録されているものの、そのプロバイダのリージョンなどのパラメータまでは保持していません。

$ cat terraform.tfstate
      // ...snip...
      "mode": "managed",
      "type": "aws_ssm_parameter",
      "name": "a",
      "provider": "provider[\"registry.terraform.io/hashicorp/aws\"].hoge",
      // ...snip...

そのためプロバイダのリージョンだけ書き換えて plan すると、tfstate に記録されている作成済リソースは新しいリージョンから探されて、見つからないために terraform 外で削除されたものとして tfstate からスルッと抜け落ちて(plan だけなら tfstate 自体は更新されない)、そして新しいリージョンで作成されようとします。

plan の結果だけだとそこまで読み取ることできず、単に新しいリソースが作成されるだけに見えるので、知らずのうちに古いリソースが取り残されてしまうことがありそうです。

apply する前に plan -refresh-only すれば tfstate から削除されることはわかる=何かがおかしいと気づくことはできます。

Note: Objects have changed outside of Terraform

Terraform detected the following changes made outside of Terraform since the last "terraform apply" which may have affected this plan:

  # aws_ssm_parameter.a has been deleted
  - resource "aws_ssm_parameter" "a" {
      - arn       = "arn:aws:ssm:ap-northeast-1:096446238848:parameter/test/a" -> null
      - data_type = "text" -> null
      - id        = "/test/a" -> null
      - name      = "/test/a" -> null
      - tags_all  = {} -> null
      - tier      = "Standard" -> null
      - type      = "String" -> null
      - value     = (sensitive value) -> null
      - version   = 1 -> null
    }

-refresh-only 未指定のときにも同じ内容を表示してほしい気もしますが・・refresh による tfstate の差分と実際に apply される差分が一緒に表示されるとそれはそれで判りにくいでしょうか・・うーん。

リソース識別子はそのままで新旧プロバイダを両方記載 → ダメ

ではどうすればよいかというと、tfstate にプロバイダ名は記録されているわけなので、そのプロバイダ名は元のリージョンのまま残しつつ、新しくプロバイダを追記してリソースに適用すればよいかと思いましたが・・・ダメでした、結果は↑と変わりありません。

provider "aws" {
  region = "ap-northeast-1"
  alias  = "hoge" // そのまま残す
}

provider "aws" {
  region = "ap-northeast-3"
  alias  = "fuga" // 新しいリージョンは別名で追加
}

resource "aws_ssm_parameter" "a" {
  provider = aws.fuga // 新しいリージョンのプロバイダを指定
  name     = "/test/a"
  type     = "String"
  value    = "aa"
}

次のような流れで delete/create されるかと思いましたが、そんなことはありません。

  1. プロバイダの指定が異なるため delete/create が必要
  2. 既存リソースの検索と削除は tfstate に記録されている aws.hoge で行われる
  3. 新規リソースの作成は tf で指定されている aws.fuga で行われる

よく考えてみれば、これで delete/create になってしまうと、例えばリージョンは変わらないのだけれども何かしらの事情(リファクタリングとか)でプロバイダのエイリアス名を変更したときまで不必要に replace されてしまうのでダメですね・・うーん。

provider "aws" {
  region = "ap-northeast-1"
  alias  = "hoge1"
}

provider "aws" {
  region = "ap-northeast-1"
  alias  = "hoge2"
}

resource "aws_ssm_parameter" "a" {
  provider = aws.hoge2 // aws.hoge1 から aws.hoge2 に変更しただけで replace して欲しくはない
  name     = "/test/a"
  type     = "String"
  value    = "aa"
}

リソース識別子を変える

次のようにリソース識別子を変えれば期待通り delete/create されます。

provider "aws" {
  region = "ap-northeast-1"
  alias  = "hoge" // そのまま残す
}

provider "aws" {
  region = "ap-northeast-3"
  alias  = "fuga" // 新しいリージョンは別名で追加
}

resource "aws_ssm_parameter" "a_new" { // リソース識別子を変える
  provider = aws.fuga  // 新しいリージョンのプロバイダを指定
  name     = "/test/a"
  type     = "String"
  value    = "aa"
}
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
  - destroy

Terraform will perform the following actions:

  # aws_ssm_parameter.a will be destroyed
  # (because aws_ssm_parameter.a is not in configuration)
  - resource "aws_ssm_parameter" "a" {
      - arn       = "arn:aws:ssm:ap-northeast-1:096446238848:parameter/test/a" -> null
      - data_type = "text" -> null
      - id        = "/test/a" -> null
      - name      = "/test/a" -> null
      - tags      = {} -> null
      - tags_all  = {} -> null
      - tier      = "Standard" -> null
      - type      = "String" -> null
      - value     = (sensitive value) -> null
      - version   = 1 -> null
    }

  # aws_ssm_parameter.a_new will be created
  + resource "aws_ssm_parameter" "a_new" {
      + arn            = (known after apply)
      + data_type      = (known after apply)
      + id             = (known after apply)
      + insecure_value = (known after apply)
      + key_id         = (known after apply)
      + name           = "/test/a"
      + tags_all       = (known after apply)
      + tier           = (known after apply)
      + type           = "String"
      + value          = (sensitive value)
      + version        = (known after apply)
    }

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

リソース識別子を戻したければ、一度この内容で apply した後に moved を追記のうえで apply -refresh-only ですね。

moved {
  from = aws_ssm_parameter.a_new
  to   = aws_ssm_parameter.a
}

リソースを削除して作り直す

泥臭いですが、リージョン変更の対象となるリソースを削除したうえでプロバイダのリージョンを書き換えて作り直す、とかでも良いです。

# リージョンを変えたいリソースを削除
terraform destroy --target=module.ses

# プロバイダのリージョンを書き換え
vim provider.tf

# tfstate からしれっと消えるリソースが存在しないことを確認
terraform plan -refresh-only

# リソースを作成
terraform apply

検証系などでリージョン移行→戻しを繰り返して試行錯誤するときなどはこの方が簡単で良さそうですね。

SNS トピックが含まれるとエラー

最初に記載した、プロバイダ定義のリージョンを変更して plan/apply するケースで、対象のリソースに SNS トピックが含まれていると、delete とか create とか以前に plan の時点で terraform がエラーでコケます。

Error: reading SNS Topic (arn:aws:sns:us-west-2:999999999999:test): InvalidParameter: Invalid parameter: TopicArn

sns:GetTopicAttributes の TopicArn で、実際に対象としているリージョン(AWS_REGION 環境変数や --region オプションなど)と、指定したトピックの ARN に含まれるリージョンが異なっている場合に、NotFound ではなく InvalidParameter エラーとなるために、terraform はリソースが見つからなかったのではなく、API 呼び出しそのものが失敗したと判断されるため、です。

例えば次のように --region オプションのリージョンと arn のリージョンとが一致していればトピック名が適当でも NotFound となりますが、

aws --region us-west-2 sns get-topic-attributes --topic-arn arn:aws:sns:us-west-2:999999999999:xxx
#=> An error occurred (NotFound) when calling the GetTopicAttributes operation: Topic does not exist

リージョンの部分が異なっていると InvalidParameter となります。

aws --region ap-northeast-1 sns get-topic-attributes --topic-arn arn:aws:sns:us-west-2:999999999999:xxx
#=> An error occurred (InvalidParameter) when calling the GetTopicAttributes operation: Invalid parameter: TopicArn

他は確認していませんがリソースの arn を指定する API は基本的にそうなっていると思います。

変に NotFound になってしまうと terraform 管理外のリソースが取り残されて面倒なことになるので、InvalidParameter でエラーになるのはむしろ親切ですね。実際に SES のリージョンを変更しようとしたときは関連する SNS トピックでこのエラーが発生したために、terraform 管理外のリソースが取り残されるという面倒は避けることができました。

さいごに

本番系で切り替えるときはオレゴンリージョンと東京リージョンの両方で SES を設定して両方を利用可能としたうえで、段階を経て順次切り替えたためこのような問題は起こりえませんが、検証系で何度か切り替えたり戻したりして検証したいときに terraform apply の一発だけでやろうとするときは注意が必要そうです。