🌍

TerraformとAWS CDKのplan(diff)の挙動が違って戸惑った話

2023/03/04に公開

はじめに

こんにちは、Web アプリケーションエンジニアの keyamin です。

自分は IaC がけっこう好きで仕事でも書くことが多く、前職で 1 年弱 Terraform を使っていましたが昨年 11 月に転職してからは AWS CDK を勉強しています。

その際に Terraform の plan コマンドと CDK の diff コマンドの挙動の違い、またドリフトの扱いの違いで戸惑った部分があったので、検証・考察してみました。

TL;DR

  1. Terraform の plan コマンドでは、実行時に state ファイルに記載されているインフラリソースの実際の設定を確認しに行き、ドリフトが発生してたらそれも含めてコードの状態に上書きするような計画を出力する。apply コマンドではそれをそのまま実行する。

  2. CDK の diff コマンドや CloudFormation の変更セットでは、現在の CloudFormation テンプレートと手元で生成されたテンプレートを比較し、その差分を計画として出力する。この際ドリフトは考慮されない。

  3. CDK の deploy コマンドや CloudFormation の変更セットの実行では、基本的には変更セットの内容をそのまま実行する。ただし、変更対象のリソースにドリフトが発生している場合、場合によってはドリフトしているパラメータがコードの状態で上書きされる。

サンプルコード

https://github.com/keyamin/cdk-tf-diff

CDK にはじめて触ったときに体験した不思議な出来事

まずは、Terraform で SQS キューを作成します。
キュー名と可視性タイムアウトを設定しています。

resource "aws_sqs_queue" "sample_queue" {
  name                       = "sample-tf-queue"
  visibility_timeout_seconds = 10
}

これをterraform applyすると、当然ながらキューができます。

apply 結果
$ terraform apply

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_sqs_queue.sample_queue_ will be created
  + resource "aws_sqs_queue" "sample_queue" {
      + arn                               = (known after apply)
      + content_based_deduplication       = false
      + deduplication_scope               = (known after apply)
      + delay_seconds                     = 0
      + fifo_queue                        = false
      + fifo_throughput_limit             = (known after apply)
      + id                                = (known after apply)
      + kms_data_key_reuse_period_seconds = (known after apply)
      + max_message_size                  = 262144
      + message_retention_seconds         = 345600
      + name                              = "sample-tf-queue"
      + name_prefix                       = (known after apply)
      + policy                            = (known after apply)
      + receive_wait_time_seconds         = 0
      + redrive_allow_policy              = (known after apply)
      + redrive_policy                    = (known after apply)
      + sqs_managed_sse_enabled           = (known after apply)
      + tags_all                          = (known after apply)
      + url                               = (known after apply)
      + visibility_timeout_seconds        = 10
    }

Plan: 1 to add, 0 to change, 0 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_sqs_queue.sample_queue: Creating...
aws_sqs_queue.sample_queue: Still creating... [10s elapsed]
aws_sqs_queue.sample_queue: Still creating... [20s elapsed]
aws_sqs_queue.sample_queue: Creation complete after 25s [id=https://sqs.ap-northeast-1.amazonaws.com/xxxxxxxxxxxx/sample-tf-queue]

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

ここで sample-tf-queue の可視性タイムアウトをマネジメントコンソールから 20 秒に変更します。

その後コードは変えないまま再度terraform planすると、マネジメントコンソールからの変更を検出し、可視性タイムアウトをコード側の 10 秒に戻すような結果が出力されました。

plan 結果
$ terraform plan

aws_sqs_queue.sample_queue: Refreshing state... [id=https://sqs.ap-northeast-1.amazonaws.com/xxxxxxxxxxxx/sample-tf-queue]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # aws_sqs_queue.sample_queue will be updated in-place
  ~ resource "aws_sqs_queue" "sample_queue" {
        id                                = "https://sqs.ap-northeast-1.amazonaws.com/xxxxxxxxxxxx/sample-tf-queue"
        name                              = "sample-tf-queue"
        tags                              = {}
      ~ visibility_timeout_seconds        = 20 -> 10
        # (11 unchanged attributes hidden)
    }

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

Terraform に慣れていた自分にとっては、これが当たり前の挙動でした。

CDK でも同じことをやってみます。(検証の都合上 L1 Construct を使用しています)

export class CdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    new sqs.CfnQueue(this, "SampleQueue", {
      queueName: "sample-cdk-queue",
      visibilityTimeout: 10,
    });
  }
}

cdk deployでキュー作成後、Terraform と同じく sample-cdk-queue の可視性タイムアウトをマネジメントコンソールから 20 秒に変更し、cdk planをしてみます。すると...

$ cdk diff

Stack CdkStack
There were no differences

おや?

不安になりつつ一応 deploy もしてみましたが、diff の結果通り、可視性タイムアウトは 20 秒のままでした。

deploy 結果
$ cdk deploy

✨  Synthesis time: 1.97s

CdkStack: building assets...

[0%] start: Building 5b7ea873c4531c4e9be5ad0dbf9e6992b9068ec132dc7053c88088fb9adc6385:current_account-current_region
[100%] success: Built 5b7ea873c4531c4e9be5ad0dbf9e6992b9068ec132dc7053c88088fb9adc6385:current_account-current_region

CdkStack: assets built

CdkStack: deploying... [1/1]
[0%] start: Publishing 5b7ea873c4531c4e9be5ad0dbf9e6992b9068ec132dc7053c88088fb9adc6385:current_account-current_region
[100%] success: Published 5b7ea873c4531c4e9be5ad0dbf9e6992b9068ec132dc7053c88088fb9adc6385:current_account-current_region

 ✨ hotswap deployment skipped - no changes were detected (use --force to override)


 ✅  CdkStack (no changes)

✨  Deployment time: 1.15s

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:xxxxxxxxxxxx:stack/CdkStack/7a894e10-ba67-11ed-96c1-0a72591af2a9

✨  Total time: 3.12s

念の為、cdk synthで出力された CloudFormation テンプレートから変更セットを作成し、それを実行してみましたが結果は同じでした。

この時点で、 Terraform と CDK の挙動が違うことに戸惑いました...

検証 ①

まず、次のような手順で検証を行いました。

  1. 可視性タイムアウトを 10 秒に設定した SQS キューを CDK から作成する
  2. マネジメントコンソールから可視性タイムアウトを 20 秒に変更する
  3. CDK から可視性タイムアウトを 30 秒に変更する

以上を行って diff コマンドを実行したところ、今度は diff ありという結果が表示されましたが、変更前の値はやはりドリフトが考慮されていません。

export class CdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    new sqs.CfnQueue(this, "SampleQueue", {
      queueName: "sample-cdk-queue",
-     visibilityTimeout: 10,
+     visibilityTimeout: 30,
    });
  }
}
$ cdk diff

Stack CdkStack
Resources
[~] AWS::SQS::Queue SampleQueue SampleQueue
 └─ [~] VisibilityTimeout
     ├─ [-] 10
     └─ [+] 30

こちらをデプロイするとキューの可視性タイムアウトはコードの通り 30 秒になっていました。

キュー画像1

ここで、このデプロイに使用された変更セットを見てみます。

「JSON の変更」タブから、変更セットの内容を JSON 形式で確認できます。

マネジメントコンソール上の変更セット画像

JSON の中身
[
  {
    "resourceChange": {
      "logicalResourceId": "SampleQueue",
      "action": "Modify",
      "physicalResourceId": "https://sqs.ap-northeast-1.amazonaws.com/xxxxxxxxxxxx/sample-cdk-queue",
      "resourceType": "AWS::SQS::Queue",
      "replacement": "False",
      "moduleInfo": null,
      "details": [
        {
          "target": {
            "name": "VisibilityTimeout",
            "requiresRecreation": "Never",
            "attribute": "Properties"
          },
          "causingEntity": null,
          "evaluation": "Static",
          "changeSource": "DirectModification"
        }
      ],
      "changeSetId": null,
      "scope": ["Properties"]
    },
    "hookInvocationCount": null,
    "type": "Resource"
  }
]

JSON の内容についての詳細はドキュメントを参照していただきたいのですが、パッと見どうやら sample-cdk-queue の VisibilityTimeout を変更しますよ的な内容になっていそうです。

ここで、冒頭の 1,2 番の仮設を立てました。

CDK(CloudFormation)では現在のテンプレートと新規生成したテンプレートを比較し、その差分を「どのリソースのどのパラメータを変更します」といった変更セットとして生成、そしてそれを実行するのではないか...

Terraform では apply コマンドには--refresh-onlyオプションがあり、現在のインフラの設定を state ファイルに反映させる(ドリフトを現在の設定を正として解消する)ことができます。only と書いてあるからには実際の plan,apply 時にもこのようなことが行われているのでしょう。(ソースコードまでは追えてないです...🙇)

検証 ②

次は、

  1. 先程と同じく可視性タイムアウト 10 秒の SQS キューを作成
  2. マネジメントコンソールから可視性タイムアウトを 20 秒に変更
  3. CDK からキューの配信遅延を 10 秒に変更

という手順を踏んでみます。

先程の変更セットの JSON を見る限りだと変更対象のプロパティ名が記載されていたことから、配信遅延を CDK から変更したところで、可視性タイムアウトはドリフトした値のまま変わらないのではと予想できます。

以下のようにコードを編集して diff,deploy してみます。

export class CdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    new sqs.CfnQueue(this, "SampleQueue", {
      queueName: "sample-cdk-queue",
      visibilityTimeout: 10,
+     delaySeconds: 10,
    });
  }
}

結果は...なんと可視性タイムアウトも CDK コード側の値にリセットされていました。しかも変更セットの JSON を見ても可視性タイムアウトを変更する旨は書いていません。なぜ...

キュー画像2

変更セットの JSON
[
  {
    "resourceChange": {
      "logicalResourceId": "SampleQueue",
      "action": "Modify",
      "physicalResourceId": "https://sqs.ap-northeast-1.amazonaws.com/xxxxxxxxxxxx/sample-cdk-queue",
      "resourceType": "AWS::SQS::Queue",
      "replacement": "False",
      "moduleInfo": null,
      "details": [
        {
          "target": {
            "name": "DelaySeconds",
            "requiresRecreation": "Never",
            "attribute": "Properties"
          },
          "causingEntity": null,
          "evaluation": "Static",
          "changeSource": "DirectModification"
        }
      ],
      "changeSetId": null,
      "scope": ["Properties"]
    },
    "hookInvocationCount": null,
    "type": "Resource"
  }
]

検証 ③

なぜこんな結果になったか考えましたが、AWS CLI のドキュメントを見たところ、どうやら SQS にはキューの可視性タイムアウト単体を編集する API がなさそうでした。(set-queue-attributes)

set-queue-attributes でも指定しなかったパラメータには変更が加えられないため可視性タイムアウトだけを編集することはできるのですが、CloudFormation が内部で PATCH ではなく PUT 的な挙動をしていた場合、配信遅延の変更に巻き込まれて可視性タイムアウトが変わってしまったことも納得できます。

これを確かめるため、次の検証を行いました。

  1. CDK から KMS キーを作成する
  2. CDK から CloudWatch のロググループを作成し、保持期間を 30 日、使用する KMS キーに 1 で作成したキーを指定する
  3. マネジメントコンソールからログの保持期間を 7 日に変更する
  4. CDK からロググループと KMS キーの関連付けを削除する

CloudWatch のロググループでは、パラメータとして関連付ける KMS キーとログの保持期間を設定できます。そして、それらにはそれぞれ個別に変更する API が用意されています。(disassociate-kms-keyput-retention-policy)

なので、これなら片方の変更にもう片方が巻き込まれないのではないか、という検証です。

以下が CDK のコードです。

export class CdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const key = new kms.Key(this, "SampleKey", {
      policy: new iam.PolicyDocument({
        statements: [
          new iam.PolicyStatement({
            actions: ["kms:*"],
            resources: ["*"],
            principals: [new iam.AccountRootPrincipal()],
          }),
          new iam.PolicyStatement({
            actions: [
              "kms:Decrypt*",
              "kms:Encrypt*",
              "kms:ReEncrypt*",
              "kms:GenerateDataKey*",
              "kms:Describe*",
            ],
            resources: ["*"],
            principals: [
              new iam.ServicePrincipal(`logs.${this.region}.amazonaws.com`),
            ],
            conditions: {
              ArnEquals: {
                "kms:EncryptionContext:aws:logs:arn": `arn:aws:logs:${this.region}:${this.account}:log-group:*`,
              },
            },
          }),
        ],
      }),
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    new logs.CfnLogGroup(this, "SampleLogGroup", {
      logGroupName: "sample-cdk-log-group",
      retentionInDays: 30,
      kmsKeyId: key.keyArn, // なんでkey.keyIdじゃないの!!
    });
  }
}

手順通り実行したところ、予想通り、KMS キーの関連付けは削除されましたが、保持期間はマネジメントコンソールで変更した 7 日のままでした。

ロググループ画像

変更セットの JSON
[
  {
    "resourceChange": {
      "logicalResourceId": "SampleLogGroup",
      "action": "Modify",
      "physicalResourceId": "sample-cdk-log-group",
      "resourceType": "AWS::Logs::LogGroup",
      "replacement": "False",
      "moduleInfo": null,
      "details": [
        {
          "target": {
            "name": "KmsKeyId",
            "requiresRecreation": "Never",
            "attribute": "Properties"
          },
          "causingEntity": null,
          "evaluation": "Static",
          "changeSource": "DirectModification"
        }
      ],
      "changeSetId": null,
      "scope": ["Properties"]
    },
    "hookInvocationCount": null,
    "type": "Resource"
  }
]

まとめ

検証結果から自分が推測したのは冒頭で書いたとおりです。

Terraform には指定したリソースの差分を無視できる ignore_changes ブロックがありますが、CDK ではこの挙動ならそもそも必要ないですね。ドリフトを無視し続けても、そのリソースに触らなければ何も起きません。

ただし、ドリフトがあると diff 結果と異なる挙動をすることがあるのは少し嫌だなーと思いました。ECS × CodeDeploy で Blue/Green デプロイするときなど、ドリフトがどうしても発生してしまう場面はあり、例えばリスナー設定をちょっといじった際にリスナーアクションが意図せず変わってしまうのは怖いです。なので Terraform の挙動の方が直感的で自分は好きでした。

あと、ドリフト検出が現時点では CloudFormation からしか行えず、 CDK コマンドから直接できるようになればめちゃくちゃ便利だと思うので、何卒よろしくお願いします 🙏

GitHubで編集を提案

Discussion