😺

CDK を使ってデプロイ前に意図しない "Replacement" を検出したい - part2

2024/08/04に公開

次の記事の続編です。

https://zenn.dev/hassaku63/articles/ff5df216ba0c71

今回のテーマは

どうして ChangeSet の作成なしにリソース更新の "replacement" がわかるのか?

です。

前提事項

この記事で参照する CDK のバージョンは v2.150.0 を前提とします。

https://github.com/aws/aws-cdk/tree/v2.150.0

https://github.com/aws/aws-cdk/tree/v2.150.0

$ git checkout -b v2.150.0 tags/v2.150.0

割と雑に読み飛ばすので不正確な理解に基づく情報を書いている可能性もあります。予めご了承ください & 私の理解が事実と異なりそうな場所があれば、コメント欄などで指摘をいただけると嬉しいです。

やったことを雑多に書く

基本的には自分の調査ログなので見やすくはないです。

diff コマンドの仕様とドキュメントを見ておく

何はなくともまずはドキュメントとリファレンスです。そもそも何と何を比較するコマンドなのか、「比較対象」という観点において関係のありそうなオプションがあるか、のようなことをざっくり確認しておきます。

https://docs.aws.amazon.com/cdk/v2/guide/ref-cli-cmd-diff.html

This command is typically used to compare differences between the current state of stacks in your local CDK app against deployed stacks. However, you can also compare a deployed stack with any local AWS CloudFormation template.

ローカルの CDK App とデプロイ済みの Stack を比較するためのコマンドである、と説明されています。

特に今回のテーマを関連がありそうなのは --change-set に見えました。以下の説明文にあるように、これは比較のために ChangeSet を作成するかどうかを決定するためのオプションです。デフォルトは true なので、オプションなしの cdk diff は手元の CDK App とデプロイ済み Stack を ChangeSet ベースで比較するもの と理解できそうです。

Specify whether to create a change set to analyze resource replacements.

When true, the CDK CLI will create an AWS CloudFormation change set to display the exact changes that will be made to your stack. This output includes whether resources will be updated or replaced. The CDK CLI uses the deploy role instead of the lookup role to perform this action.

When false, a quicker, but less-accurate diff is performed by comparing CloudFormation templates. Any change detected to properties that require resource replacement will be displayed as a resource replacement, even if the change is purely cosmetic, like replacing a resource reference with a hard-coded ARN.

今回興味があるのは ChangeSet を使わない場合 に replacement が検知できる理由を探求することですので、基本的にはこのオプションが false である場合の挙動を中心に見ていくことになります。

他にもちょいちょい差分の出方に関係しそうな引数がありますが、とりあえず今はさらっと名前だけ押さえておくことにします。

CLI の "diff" 実装を追う

packages/aws-cdk/lib/cli.ts に diff コマンドに対応する実質的なロジックが動きます。

https://github.com/aws/aws-cdk/blob/88b1e1eb09cd37057295fced54229edd51ce62a7/packages/aws-cdk/lib/cli.ts#L504-L518

CdkToolkit クラスの diff メソッドがコマンドの実体になります。最後の changeSet オプションの値を評価している部分の前後が注目ポイントになりそうです。実装はこちら。

https://github.com/aws/aws-cdk/blob/88b1e1eb09cd37057295fced54229edd51ce62a7/packages/aws-cdk/lib/cdk-toolkit.ts#L118-L210

Stack 同士の比較なので、冒頭で宣言、代入している stacks を中心に見ていきます。

// public async diff(options: DiffOptions): Promise<number> {
const stacks = await this.selectStacksForDiff(options.stackNames, options.exclusively);

全体的なロジックを見てみると、以下のようなことがわかります。

  • メソッドスコープ内の stacks の変数が手元側の比較対象
    • stacks の中身に CloudAssembly が含まれる[1]
  • templatePath というオプション変数の有無で大きく処理が分岐する
    • templatePath が未指定の場合の分岐にのみ ChangeSet を使ったロジックが存在する

templatePath に対応する CLI オプションは --template です。cdk diff の最も典型的な利用例ではおそらくこの引数は未指定状態であることが多いと思いますので、今回このオプションは指定しなかった場合の挙動で読む範囲を決め打ちします。

templatePath の判定を抜けたあとのロジックは次の1文しかありません。

stream.write(format('\n✨  Number of stacks with differences: %s\n', diffs));

diff の計算からそのフォーマットと表示まで、主要なロジックはすべて if/else の中にあることが推察できます。

その少し手前、else 節の中で diff の表示関数と思われるものを実行しています。

const stackCount =
        options.securityOnly
          ? (numberFromBool(printSecurityDiff(currentTemplate, stack, RequireApproval.Broadening, changeSet)))
          : (printStackDiff(currentTemplate, stack, strict, contextLines, quiet, changeSet, !!resourcesToImport, stream, nestedStacks));

この関数より手前に diff を計算するロジックがあるようには見えないので、実際に diff もここで計算しているのであろうと推察します。

printStackDiff 関数のシグネチャと見比べます。渡しているパラメータはそれぞれ次のような意味になります(今回のテーマに特に関係ありそうなものだけピックアップします)。

  • currentTemplate ... 今デプロイされてる Stack
  • stack ... synth によって生成されたすべての Stack のうちの1つ
  • changeSet ... 今は ChangeSet を使わない場合にだけ興味があるので、値は undefined で固定
  • nestedStacks ... currentTemplate にネストされている Stack

printStackDiff の中を追いかける

packages/aws-cdk/lib/diff.ts

https://github.com/aws/aws-cdk/blob/88b1e1eb09cd37057295fced54229edd51ce62a7/packages/aws-cdk/lib/diff.ts#L17-L100

ざっと眺めてみると、リソース差分に関係ありそうな部分は最初の数行だけに見えます。

  let diff = fullDiff(oldTemplate, newTemplate.template, changeSet, isImport);

  // detect and filter out mangled characters from the diff
  let filteredChangesCount = 0;
  if (diff.differenceCount && !strict) {
    const mangledNewTemplate = JSON.parse(mangleLikeCloudFormation(JSON.stringify(newTemplate.template)));
    const mangledDiff = fullDiff(oldTemplate, mangledNewTemplate, changeSet);
    filteredChangesCount = Math.max(0, diff.differenceCount - mangledDiff.differenceCount);
    if (filteredChangesCount > 0) {
      diff = mangledDiff;
    }
  }

"mangled characters" とかいう見慣れない単語がありますが、どうやら「文字化け」のニュアンスを含むようです。ただ、この分岐は diff 計算の本質的な部分という感じでもないのでスルーしましょう。fullDiff 関数が返す「差分」の中に replacement の情報があることでしょう、たぶん。

fullDiff の実装は以下です。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/%40aws-cdk/cloudformation-diff/lib/diff-template.ts#L37-L70

TemplateDiff というクラスが「差分」の内部表現であることと、 diffTemplate という関数が実質的な計算であるらしいことがわかります。そして、ChangeSet との比較がいわば「後付け」扱いらしいこともわかります。
基本はあくまで手元の実装とデプロイ済みのテンプレートとの比較であって、そこから ChangeSet 情報を使った内容をマージする仕様になっているみたいです[2]

内部実装を読み解くと、diff の計算処理の結果にすでに replacement が含まれていることが読み取れます。諸々追いかけていくと、diffResource という関数がリソースの差分を計算している直接的なロジックであることがわかります。ここで REPLACE という用語が登場します。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/%40aws-cdk/cloudformation-diff/lib/diff/index.ts#L29-L76

loadResourceModel という関数と、_diffProperty という内部関数が重要そうです。

loadResourceModel 関数が返すのはリソースの仕様情報のような何かで、この情報の中にプロパティの更新要件も含まれているようです。さらに、この仕様情報は内部に causesReplacement というプロパティを持っており、その値が 'yes', 'maybe' である場合は replacement の可能性がある変更とみなしています。

ここまでの情報から、どうやら loadResourceModel という関数が返すデータの出どころを追いかけていくことで、本記事のテーマである「どうして ChangeSet の作成なしにリソース更新の "replacement" がわかるのか?」が紐解けそう です。

loadResourceModel 関数が何をロードしているのか追いかける

実装はこちら。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/%40aws-cdk/cloudformation-diff/lib/diff/util.ts#L170-L188

リソースの仕様を定義した何らかのデータベースから、特定のリソースタイプの仕様をクエリしているようです。

関係する型は SpecDatabase, Resource, そして SpecDatabase 型のインスタンスを作り出す loadAwsServiceSpecSync 関数です。それぞれ次のパッケージから import されています。

import { loadAwsServiceSpecSync } from '@aws-cdk/aws-service-spec';
import { Resource, SpecDatabase } from '@aws-cdk/service-spec-types';

これらの実装は今の CDK レポジトリである aws/aws-cdk からは切り離されています。どちらも以下のレポジトリでホスティングされています。

https://github.com/cdklabs/awscdk-service-spec

https://github.com/cdklabs/awscdk-service-spec

ここからは、このレポジトリの内容を見ていくことになります。

cdklabs/awscdk-service-spec のレポジトリを追う

ひとまずは本記事執筆時点の最新コミットである c3dcdef を基準に見ていくことにします。

loadAwsServiceSpecSync 関数がどこからデータを読み込んでいるのかが知りたいわけですが、そのあたりは README ですでに説明されています。

https://github.com/cdklabs/awscdk-service-spec/blob/c3dcdef/README.md

Data Sources というセクションを見てみると、なんだか色々と取り込んでいるらしいことがわかります。先ほどの loadAwsServiceSpecSync 関数が提供する「データベース」の中身はこれらが反映されたものらしいと推測できます。

あらゆるリソースタイプの更新要件の情報を持っている、となればおそらく CloudFormation 関係の仕様を定義したものが情報源でしょう。

CloudFormation Resource Specification というドキュメントが最初に紹介されていて、ここに更新要件に関する情報も載っています。そして、結果的には ResourceSpecification.schema.json というファイル名で service-spec-importer パッケージの手元に置いています。

https://github.com/cdklabs/awscdk-service-spec/blob/c3dcdef83afa9dc0e0a6baa4450c2d908f44d947/packages/%40aws-cdk/service-spec-importers/schemas/ResourceSpecification.schema.json

この仕様を見ていると、 UpdateType というプロパティが更新要件であるらしいことが推測できます。元々の JSON Schema としての仕様では、ここは "Mutable", "Immutable", "Conditional" の3値を取るようです。

https://github.com/cdklabs/awscdk-service-spec/blob/c3dcdef83afa9dc0e0a6baa4450c2d908f44d947/packages/%40aws-cdk/service-spec-importers/schemas/ResourceSpecification.schema.json#L90-L96

また、この情報はソース上では "ResourceSpecification" という語彙で表現され、これをもとに「データベース」を構築しているであろうことがわかります。service-spec-importer パッケージの中で、ちょうどこれに該当するソースコードが存在します。それが importers/import-resource-spec.ts です。このモジュールには UpdateType プロパティを扱うコードが含まれています。

https://github.com/cdklabs/awscdk-service-spec/blob/c3dcdef83afa9dc0e0a6baa4450c2d908f44d947/packages/%40aws-cdk/service-spec-importers/src/importers/import-resource-spec.ts#L101-L106

さっき見た "causesReplacement" という名前もここで登場しています。この実装から、CloudFormation の仕様と CDK 上の実装に以下の対応関係があることが読み取れます。

Cfn Resource Spec (UpdateType) CDK 上の扱い
Mutable ResourceImpact.WILL_UPDATE
Immutable ResourceImpact.WILL_REPLACE
Conditional ResourceImpact.MAY_REPLACE

この時点で、今回のテーマに関しては結論を回答できるだけの情報が出揃いました。つまり、

cdk diff コマンドは、手元のテンプレートとデプロイ済みのテンプレートを比較し、CloudFormation Resource Specification の仕様定義を利用して "replacement" の可能性があるプロパティを検出している

ということです。

ただ、せっかくですのでもうちょっと CLI の出力結果に近い部分も見ていきましょう。

リプレスが発生する/するかもしれないリソースが CLI の出力にどう影響する?

CDK 本家側の実装に戻ります。

diffTemplate 関数

先ほど確認した fullDiff 関数から呼び出されている diffTemplate 関数を見てみます。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/%40aws-cdk/cloudformation-diff/lib/diff-template.ts#L72-L113

前提として、この関数は ChangeSet を評価する比較を行うよりも手前で実行されるロジックです。つまり、ChangeSet を使わない場合の比較ロジックとして見ていくことができます[3]

また、リソースがどう変更されるかを表現する ResourceImpact の定義も見てみます。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/%40aws-cdk/cloudformation-diff/lib/diff/types.ts#L495-L512

ざっくり diffTemplate 関数の実装を眺めてみると、どうやら
"WILL_REPLACE" の場合、つまりリプレスが CloudFormation の仕様上確定している場合は、その変更影響を "伝搬" させるようです。この実装は propagateReplacedReferences 関数を読むとわかりますが、詳細は省きます。要するに「リプレスが確定しているリソースを RefFn:: で参照している箇所があれば、その参照を行っているリソース定義はリプレスの影響を受けるものとしてマークされる」という理解でよいと思います。

printStackDiff 関数の出力の実装を見る

fullDiff の呼び出し元である printStackDiff 関数に戻ります。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/diff.ts#L17-L100

formatDifferences という関数を呼び出している箇所が実際に差分情報を整形しています。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/%40aws-cdk/cloudformation-diff/lib/format.ts#L21-L54

今回の興味はリソースの差分に関する情報だけなので、実質見るべきは Resource を見ている箇所だけです。Formatter クラスの formatResourceDifference メソッドが実質的な Resource セクションの差分表示ロジックになります。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/%40aws-cdk/cloudformation-diff/lib/format.ts#L151-L173

リソースタタイプ自体と、その対象となった具体的なプロパティを指すような表示方法をしています。

リソースタイプ自体に関する表示方法は Formatter クラスの formatImpact メソッドが担っています。
MAY_REPLACE, WILL_REPLACE の場合にそれぞれの色で警告をしてくれています。

  public formatImpact(impact: ResourceImpact) {
    switch (impact) {
      case ResourceImpact.MAY_REPLACE:
        return chalk.italic(chalk.yellow('may be replaced'));
      case ResourceImpact.WILL_REPLACE:
        return chalk.italic(chalk.bold(chalk.red('replace')));
      case ResourceImpact.WILL_DESTROY:
        return chalk.italic(chalk.bold(chalk.red('destroy')));
      case ResourceImpact.WILL_ORPHAN:
        return chalk.italic(chalk.yellow('orphan'));
      case ResourceImpact.WILL_IMPORT:
        return chalk.italic(chalk.blue('import'));
      case ResourceImpact.WILL_UPDATE:
      case ResourceImpact.WILL_CREATE:
      case ResourceImpact.NO_CHANGE:
        return ''; // no extra info is gained here
    }
  }

具体的なプロパティレベルの表示方法は、リソースの差分を表現する ResourceDifference クラスの forEachDifference メソッドと、Formatter クラスの formatTreeDiff メソッドが実装しています。

  public formatTreeDiff(name: string, diff: Difference<any>, last: boolean) {
    let additionalInfo = '';
    if (isPropertyDifference(diff)) {
      if (diff.changeImpact === ResourceImpact.MAY_REPLACE) {
        additionalInfo = ' (may cause replacement)';
      } else if (diff.changeImpact === ResourceImpact.WILL_REPLACE) {
        additionalInfo = ' (requires replacement)';
      }
    }
    this.print(' %s─ %s %s%s', last ? '└' : '├', this.changeTag(diff.oldValue, diff.newValue), name, additionalInfo);
    return this.formatObjectDiff(diff.oldValue, diff.newValue, ` ${last ? ' ' : '│'}`);
  }

リソースタイプだけでなく、対象プロパティ自体にも "(may cause replacement)" や "(requires replacement)" といった追加情報が出るようになっています。具体的にどこのプロパティを変更したせいで "replacement" が発生している/発生する可能性があるのかまでわけるようになっていて、非常に便利です。

ここまでの内容で、更新要求が "replacement" となるようなプロパティの変更が cdk diff コマンドによっていかに検知されるか、そしていかに表示されるか、といった内容がわかりました。

先ほど登場した CloudFormation Resource Spec の表との対応関係を、cdk diff の表示内容に対応付けてみましょう。次のようになります。

Cfn Resource Spec
(UpdateType)
CDK (ResourceImpact) リプレス条件に該当したリソースタイプの追加表示情報 リプレス条件を持ったプロパティ変更の追加表示情報
Mutable ResourceImpact.WILL_UPDATE - -
Immutable ResourceImpact.WILL_REPLACE "replace" (Bold, Red) "(requires replacement)"
Conditional ResourceImpact.MAY_REPLACE "may be replaced" (Italic, Yellow) "(may cause replacement)"

まとめ

cdk diff が "replacement" を検出できる理由は、CloudFormation Resource Specificationで定義されているスキーマを利用しているからだとわかりました。--change-set を false にした場合はテンプレート同士の比較が行われますが、計算したリソース/プロパティ差分にこのスキーマの情報を加味しています。

変更時の仕様は "UpdateType" というプロパティによって規定されており、値が "Immutable" あるいは "Conditional" のプロパティを変更する場合がリソースのリプレスを引き起こしうる対象となります。私たち一般の AWS ユーザーも、このスキーマを活用することで、自前で意図しない "Replacement" を CloudFormation デプロイの前にローカル環境で検出することができます。ただまぁ、積極的に採用したい手段でもないよなというのは正直思いました。大人しく cdk diff コマンドを活用するのが CDK ユーザーの立場としては最良そうな気がしました。

diff コマンドのリファレンスで、 --change-set オプションを無効化した場合の記述に「テンプレート同士の比較を行い、高速だが精度が低い」と説明されている理由もなんとなく把握できました。

When false, a quicker, but less-accurate diff is performed by comparing CloudFormation templates. Any change detected to properties that require resource replacement will be displayed as a resource replacement, even if the change is purely cosmetic, like replacing a resource reference with a hard-coded ARN.

上記の文章が説明しているように、"replacement" なプロパティを変更した場合に、その実態が「参照による ARN 指定をハードコードした値に置き換えた場合」がいわゆる cdk diff の "偽陽性" な例になります。

この例の「変更」はあくまで表面的なものですが、cdk diff を(ChangeSet を作らず)テンプレートベースで比較した場合はこうしたケースが「変更」扱いで検出されてしまいます。これは、CloudFormation のデプロイあるいは ChangeSet の実行を伴わない、あくまで "手元" で解決可能な情報だけを拠り所にした手段であるがゆえに不完全さがある、という話でしょう。

そして、ロジックを読み解くなかで「一体なにが "変更" として検出される仕様なのか?/検出されるべきなのか?」というお題がそもそも難題であることにも気づきました。別リソースへの参照関係も加味すると、その参照内容やその参照方法によって参照先/参照元がどう評価されるのか。組み合わせが多く、かなり難しそうに見えます。これを紐解くには、cdk diff の実例を見ながら検証していくなり、ChangeSet の仕様を深堀りしてみるなりの探求が必要そうです。この調査を進めると、より CloudFormation の、ひいては IaC の中身の理解が進みそうです。非常に歯ごたえのあるテーマがまた生えてきましたね(直近やる予定はありませんが)。

脚注
  1. stacks を初期化する右辺側に当たる selectStacksForDiff 関数は、ざっくり言えば CDK App を実行することで CloudAssmebly を生成する機能です。このへんは別の記事「cdk.context.json が CDK 的にどう扱われているか調べてみた」でも synth コマンドの実態を調査する過程で触れています。 ↩︎

  2. マージするような仕組みである必要があるのかな?最初から手元のテンプレートを使った ChangeSet の比較でいいんじゃないの...と思いましたが、このような実装になっている理由はわかりませんでした。cdk diff の --change-set オプションは 2024/01/31 の v2.125.0 から追加された比較的新しい機能のようですので、既存実装の兼ね合いがあったのかもしれません。このへんは関連するプルリクの議論を追えばもう少し情報が出てくるかもしれませんが、この記事の主旨からは微妙に外れるので読み飛ばしました ↩︎

  3. ChangeSet が絡む部分の実装は一切見ていないので、実際の仕様とは違っているかもしれません ↩︎

Discussion