terraform movedブロックを使った安全なコードへのリファクタリング

アソビューAdvent Calendar 2022の6日目の記事です。

こんにちは!
アソビュー!でサーバーサイドエンジニア兼 Embedded SREを担当している山野です。

弊社ではプロダクト開発チームとインフラ全体の管理を担当するSREチームが存在しますが、アソビューが保有している多様なサービスに対してより個別最適化されたインフラ管理・運用等を行うため、一部のプロダクト開発チームメンバーで構成されたEmbedded SREという体制を導入しています。

Embedded SREについてはこのアドベントカレンダーの別の記事で詳しくご紹介しますので是非ご覧ください!

今回はEmbedded SREの取り組みの中から、terraform resourceブロックの実装によって意図しないデプロイの発生を未然に防ぐため、movedブロックを利用したコード改善を行なった件について書きたいと思います。

この記事で説明しないこと

  • terraformの概要・コマンド・基本構文

今回問題になったこと

弊社ではterraformによるインフラリソースをコード管理を行なっていますが、一部の構成ファイル(以降 tfファイルと記載)の中には、前述の通りインシデントを招く恐れのあるコードが存在しました。

以下がそのコード例となります。

main.tf

terraform {
    required_version = "~> 1.0"

    required_providers {
      aws = {
        source = "hashicorp/aws"
        version = "~> 4.41.0"
      }
    }
}

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

module "ssm" {
    source = "./modules/ssm"
    application = "demo-app"
    environment = "development" 
    parameters = [
        "DATABASE_USERNAME",
        "DATABASE_PASSWORD",
        "APP_API_KEY",
        "APP_API_SECRET",
    ]
}

module/ssm/ssm.tf

variable "application" {
  type = string
}

variable "environment" {
  type = string
}

variable "parameters" {
  type = list(string)
}

resource "aws_ssm_parameter" "secret_parameter" {
  count = to(var.parameters)
  name = join("/", ["", var.application, var.environment, var.parameters[count.index]])
  type = "SecureString"
  value = "dummy"
  key_id = "alias/aws/ssm"

  lifecycle {
    ignore_changes = [
      value
    ]
  }
}

このコードはAWS Systems Managerの パラメータストアリソースを作成するコードです。
この実装ではパラメータストアのキーの情報を配列型でmain.tfファイルからssm.tfファイルに渡し、ssm.tfファイルresourceブロック > countによるループ処理で受け取った配列要素分のリソース作成を行います。

実際にこのコードを実行すると以下の4つのパラメータが作成されます。

パラメータストア
AWS コンソール - パラメータストア

terraformはリソース作成が実行される度にtfstateファイルというファイルに作成したリソース情報を記録していますが、 resourceブロック内でcountを利用してリソースを作成した場合、
該当のリソース名(リソースを一意に表す名称)はindex番号tfstateファイルに記録されます。
実際リソース作成後にterraform state list コマンドを実行すると各リソースがindex番号で記録されていることが分かります。

module.ssm.aws_ssm_parameter.secret_parameter[0]
module.ssm.aws_ssm_parameter.secret_parameter[1]
module.ssm.aws_ssm_parameter.secret_parameter[2]
module.ssm.aws_ssm_parameter.secret_parameter[3]

ここで問題になるのがcountでループ処理する配列に対して要素の途中追加や削除変更を行った場合です。

terraformはterraform plan / applyを実行した際に、前述のtfstateファイルtfファイルの内容を比較し、その変更差分を検知する仕組みとなっています。
配列の内容を変更してしまうと要素に紐づくindex番号は当然ながら変更されます。
これによりterraformがtfファイルtfstateファイルの変更差分比較を行った際に、同じ要素にも関わらずリソース名(=index番号)が異なるため、リソースに変更が入ったと判断 -> 作成済みのリソースを意図せず削除・再度新規リソースを引き起こす恐れがあります。

以下は実際に上記の問題を再現した例です。
main.tfファイル > module "ssm" > parameters配列にの DATABASE_PASSWORDAPP_API_KEYの間にDATABASE2_USERNAMEDATABASE2_PASSWORDを追加しました。

main.tf

module "ssm" {
    source = "./modules/ssm"
    application = "demo-app"
    environment = "development" 
    parameters = [
        "DATABASE_USERNAME",
        "DATABASE_PASSWORD",
        "APP_API_KEY",
        "APP_API_SECRET",
    ]
}

module "ssm" {
    source = "./modules/ssm"
    application = "demo-app"
    environment = "development" 
    parameters = [
        "DATABASE_USERNAME",
        "DATABASE_PASSWORD",
        "DATABASE2_USERNAME",  # 途中追加
        "DATABASE2_PASSWORD",  # 途中追加
        "APP_API_KEY",
        "APP_API_SECRET",
    ]
}

terraform plan を実行すると元々index番号 2にあたるAPP_API_KEYDATABASE2_USERNAME変更され、更にAPP_API_KEYはindex番号 4として新しくリソース作成対象になっていることが分かります。

# module.ssm.aws_ssm_parameter.secret_parameter[2] must be replaced
-/+ resource "aws_ssm_parameter" "secret_parameter" {
      ~ arn            = "arn:aws:ssm:ap-northeast-1:xxxxx:parameter/demo-app/development/APP_API_KEY" -> (known after apply)
      ~ data_type      = "text" -> (known after apply)
      ~ id             = "/demo-app/development/APP_API_KEY" -> (known after apply)
      + insecure_value = (known after apply)
      ~ name           = "/demo-app/development/APP_API_KEY" -> "/demo-app/development/DATABASE2_USERNAME" # forces replacement
      - tags           = {} -> null
      ~ tags_all       = {} -> (known after apply)
      ~ tier           = "Standard" -> (known after apply)
      ~ value          = (sensitive value)
      ~ version        = 2 -> (known after apply)
        # (2 unchanged attributes hidden)
    }
:省略
# module.ssm.aws_ssm_parameter.secret_parameter[4] will be created
  + resource "aws_ssm_parameter" "secret_parameter" {
      + arn            = (known after apply)
      + data_type      = (known after apply)
      + id             = (known after apply)
      + insecure_value = (known after apply)
      + key_id         = "alias/aws/ssm"
      + name           = "/demo-app/development/APP_API_KEY"
      + tags_all       = (known after apply)
      + tier           = (known after apply)
      + type           = "SecureString"
      + value          = (sensitive value)
      + version        = (known after apply)
    }

今回対応したこと

まずリソース名をindex番号管理ではなく、
別の特定(固定)の一位の値を設定するように修正しました。
今回はSSMに登録するパラメータキー名をリソース名となるよう修正しました。

以下が修正例です。
countではなくfor_eachを用いることで上記を実現しています。

resource "aws_ssm_parameter" "secret_parameter" {
  for_each = toset(var.parameters)
  name = join("/", ["", var.application, var.environment, each.value])
  type = "SecureString"
  value = "dummy"
  key_id = "alias/aws/ssm"

  lifecycle {
    ignore_changes = [
      value
    ]
  }
}

ただしこの修正だけでは問題があります。
元々リソース名をindex番号としていたものを特定名称に変更するため、
terraformは既存のリソースが削除され別のリソースを新規追加されたと判断します。

以下はterraform planを実行した結果です。
作成済みのDATABASE_USERNAMEリソースを削除し、再度作成しようとしていることが分かります。

# module.ssm.aws_ssm_parameter.secret_parameter[0] will be destroyed
  # (because resource does not use count)
  - resource "aws_ssm_parameter" "secret_parameter" {
      - arn       = "arn:aws:ssm:ap-northeast-1:xxxxxxx:parameter/demo-app/development/DATABASE_USERNAME" -> null
      - data_type = "text" -> null
      - id        = "/demo-app/development/DATABASE_USERNAME" -> null
      - key_id    = "alias/aws/ssm" -> null
      - name      = "/demo-app/development/DATABASE_USERNAME" -> null
      - tags      = {} -> null
      - tags_all  = {} -> null
      - tier      = "Standard" -> null
      - type      = "SecureString" -> null
      - value     = (sensitive value)
      - version   = 2 -> null
    }

:省略
# module.ssm.aws_ssm_parameter.secret_parameter["DATABASE_USERNAME"] will be created
  + resource "aws_ssm_parameter" "secret_parameter" {
      + arn            = (known after apply)
      + data_type      = (known after apply)
      + id             = (known after apply)
      + insecure_value = (known after apply)
      + key_id         = "alias/aws/ssm"
      + name           = "/demo-app/development/DATABASE_USERNAME"
      + tags_all       = (known after apply)
      + tier           = (known after apply)
      + type           = "SecureString"
      + value          = (sensitive value)
      + version        = (known after apply)
    }

この事象を回避するためにmovedブロックを利用しました。
movedブロックはterraform バージョン1.1.0から追加された機能で、リソース名を明示的に変更できる機能です。

実装方法としては以下の通り変更したいリソース名をfrom toで指定するのみです。

moved {
  from = {変更前のリソース名}
  to = {変更後のリソース名}
}

以下が実際のコード例です。
main.tf

# : 省略

module "ssm" {
    source = "./modules/ssm"
    application = "demo-app"
    environment = "development" 
    parameters = [
        "DATABASE_USERNAME",
        "DATABASE_PASSWORD",
        "APP_API_KEY",
        "APP_API_SECRET",
    ]
}

## moved ブロックを追加
moved {
  from = module.ssm.aws_ssm_parameter.secret_parameter[0]
  to = module.ssm.aws_ssm_parameter.secret_parameter["DATABASE_USERNAME"]
}

moved {
  from = module.ssm.aws_ssm_parameter.secret_parameter[1]
  to = module.ssm.aws_ssm_parameter.secret_parameter["DATABASE_PASSWORD"]
}

moved {
  from = module.ssm.aws_ssm_parameter.secret_parameter[2]
  to = module.ssm.aws_ssm_parameter.secret_parameter["APP_API_KEY"]
}

moved {
  from = module.ssm.aws_ssm_parameter.secret_parameter[3]
  to = module.ssm.aws_ssm_parameter.secret_parameter["APP_API_SECRET"]
}

これでterraform planを実行すると以下の通りリソースの変更なしと判断されます。

module.ssm.aws_ssm_parameter.secret_parameter["APP_API_KEY"]: Refreshing state... [id=/demo-app/development/APP_API_KEY]
module.ssm.aws_ssm_parameter.secret_parameter["APP_API_SECRET"]: Refreshing state... [id=/demo-app/development/APP_API_SECRET]
module.ssm.aws_ssm_parameter.secret_parameter["DATABASE_USERNAME"]: Refreshing state... [id=/demo-app/development/DATABASE_USERNAME]
module.ssm.aws_ssm_parameter.secret_parameter["DATABASE_PASSWORD"]: Refreshing state... [id=/demo-app/development/DATABASE_PASSWORD]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and
found no differences, so no changes are needed.

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

またterraform apply実行後にterraform state listを実行すると、 リソース名が正常に変更されていることが分かります。

module.ssm.aws_ssm_parameter.secret_parameter["APP_API_KEY"]
module.ssm.aws_ssm_parameter.secret_parameter["APP_API_SECRET"]
module.ssm.aws_ssm_parameter.secret_parameter["DATABASE_PASSWORD"]
module.ssm.aws_ssm_parameter.secret_parameter["DATABASE_USERNAME"]

これを踏まえてfor_eachでループ処理をする配列(main.tfファイル > module "ssm" > parameters配列)にDATABASE2_USERNAMEDATABASE2_PASSWORDを追加しterraform planを実行してみました。

リストに追加された2要素のみ差分として検知され、既存のリソースへの影響が発生しないことが分かります。

  # module.ssm.aws_ssm_parameter.secret_parameter["DATABASE2_PASSWORD"] will be created
  + resource "aws_ssm_parameter" "secret_parameter" {
      + arn            = (known after apply)
      + data_type      = (known after apply)
      + id             = (known after apply)
      + insecure_value = (known after apply)
      + key_id         = "alias/aws/ssm"
      + name           = "/demo-app/development/DATABASE2_PASSWORD"
      + tags_all       = (known after apply)
      + tier           = (known after apply)
      + type           = "SecureString"
      + value          = (sensitive value)
      + version        = (known after apply)
    }

  # module.ssm.aws_ssm_parameter.secret_parameter["DATABASE2_USERNAME"] will be created
  + resource "aws_ssm_parameter" "secret_parameter" {
      + arn            = (known after apply)
      + data_type      = (known after apply)
      + id             = (known after apply)
      + insecure_value = (known after apply)
      + key_id         = "alias/aws/ssm"
      + name           = "/demo-app/development/DATABASE2_USERNAME"
      + tags_all       = (known after apply)
      + tier           = (known after apply)
      + type           = "SecureString"
      + value          = (sensitive value)
      + version        = (known after apply)
    }

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

まとめ

以上がterraformのmovedブロックを使った安全なコードへのリファクタリング対応です。
今回出てきたcountは前述の通り思わぬ副作用を生むので、 理由がない限り利用は避けた方が無難です。

最後に

アソビューでの開発についてもっと知りたい方はこちらをぜひご覧ください! カジュアル面談もありますので、少しでもご興味があればご応募ください!

www.asoview.com

参考

Resources - Configuration Language | Terraform | HashiCorp Developer The count Meta-Argument - Configuration Language | Terraform | HashiCorp Developer The for_each Meta-Argument - Configuration Language | Terraform | HashiCorp Developer Refactoring | Terraform | HashiCorp Developer