CDK による差分検出の話と、cdk diff の紹介

記事タイトルとURLをコピーする

こんにちは。自称ソフトウェアエンジニアの橋本 (@hassaku_63)です

この記事では cdk diff コマンドによる差分検出の話をします。

前フリとして「差分検出」って嬉しいよね?という話と、diff コマンド以外の「差分検出」の話にも触れます。

CloudFormation の概念はわかるがソフトウェア開発や CDK については初学者、といった読者層に向ける意識で書きました。ただ、それだけだと私が書いてて面白くないので、ChangeSet 関係の少しだけニッチな仕様ネタも追加しました。

Abstract

差分検出ってうれしいよね?という前フリの話をします。そして、CDK では Snapshot testing と本記事のテーマである cdk diff が「差分検出」に相当する仕組みとして用意されていて、それぞれざっくりどういったモチベで使えるものなのかを簡単におさらいします。

後半部分では cdk diff が何をしてくれる機能なのかを説明しつつ、ちょっとだけコアな話もします。

前提事項

本記事で取り上げる AWS CDK のバージョンは v2.150.0 を前提としています。

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

関連する CDK の実装をすべて理解して裏取りした内容ではなく、正確性を欠く記述が含まれる可能性があります。予めご承知ください。

また、CDK App をデプロイするまでの基本的なことについてはすでに理解しているものとして説明を省略します。例えば「合成 (synthesize)」や「CDK App」「Construct」といった概念はなんとなくでも押さえていることを前提として本記事は書かれています。

はじめに

私たち IT の分野でものづくりをやっていると、「開発者が意図していない・予期していないこと」を事前に検出できることが如何にありがたいか、実感することは多いと思います。

大前提として、作ったモノに対する不確定要素や不安要素はできるだけ開発サイクルの手前のフェーズで検証でき、潰せる方が良いです。「デプロイしなきゃ期待通り動くかどうかわからん」みたいな話は、少ないに越したことはない。これは、私たちが作ったモノを実際に利用するユーザーに提供する価値(あるいは損失)そのものに影響する話でもありますし、作る側の立場としての利益もあります。試行錯誤のサイクルがより軽く・早く・深く回せるようになることで、結果的に「正しいものを正しく作る」ことに繋がります。それが顧客への提供価値の最大化に寄与します。ユーザー要求を叶える機能をより早く作る...というだけの話だけではなく、様々な要因によって起こりうる価値の毀損を未然防止するといった観点もここには含まれます。

プラマイどちらの要素もケアすることで最終的に私たちが作るモノの提供価値を最大化したいわけで、多くの「手段」の最終目的はここに繋がります。この目的に寄与するためのアプローチや具体的手段はそれこそ数多の分野にいくつもの考え方や技術、実装が存在するわけですが、この記事で述べる「差分検出」もここに関連を持つ要素技術のひとつと捉えることができます。

デプロイする前に、自分ないしチームメイトが行った開発により生じる「差分」が見えていれば、その変更内容が自らにとって望ましいものかどうかを評価できます。IaC (あるいは CloudFormation) の領域で言えば、例えば既存リソースに対する破壊的変更はサービスの動作影響を来す可能性があるため事前に把握しておきたいですし、アプリケーション開発と同様にできるだけ多くのことを開発フェーズの「手前側」で試行錯誤できると嬉しいわけです。こういったことがデプロイする前の段階である程度見通せるとしたら、それはハッピーですよね*1

CDK における差分検出

私が認識する限りでは、CDK において「差分検出」に分類できる仕組みは2つあります。

  • Snapshot testing
  • cdk diff

どちらも「実際にデプロイする前に」検知できる手段として機能する点が共通しています。

Snapshot testing は、主にソースの内部構造の改善や、IaC 絡みの依存関係の更新をする場合に役立つものです。特定のシーンで役立つ回帰テストとして見ることができます。

想定シーンを2つ挙げます。まず1点目。メジャーな IaC ツール・サービスは、自身を利用するために何らかの追加の依存関係を必要とします。コミュニティが開発したプラグイン/プロバイダを導入している場合はそれらにも依存する場合があるでしょう。依存先の IaC ツール自身あるいはライブラリのバージョンアップがあった場合に、利用者側は実装を変えていないのにデプロイされる結果が変わっている、といったケースが起こりえます。おそらく、多くのユーザーは「それは困る」と思うはずです。

もう1点。インフラ定義のソースコードをモジュール分割するやり方を採用しているチームは多くあると思います。生産性や保守性を向上するためにもモジュール分割は大変重要です。しかし、なんらかの理由でモジュール構造の見直しの必要に迫られる場合もあります。こうしたケースでは「デプロイされる最終結果は変えたくないが、内部的な構造は変更したい」という要求が生じます。

Snapshot testing は、こういったシーンに対処するための道具として利用できます。開発者は意図しないデグレの危険性をより安全に、かつ素早く検証できるようになります。

生成したスナップショットファイルはコミットしておき、CI プロセスに組み込むことをお勧めします。プルリクエストで一緒にレビューするようにしましょう。デプロイリソースを変更する場合は開発者自身がスナップショットを更新してプルリクエストを出します。前述したようなシーン、すなわちデプロイする成果物を変えずに内部構造を改善したい場合は、既存のスナップショットをそのまま使います。意図しない変更の大部分はスナップショット差分という形で露呈しますので、開発者自身とレビュアーが検証しやすくなります。

Snapshot testing については以下の公式ドキュメントの Snapshot tests のセクションが詳しいので、そちらもご確認ください。

docs.aws.amazon.com

もう一方、この記事の主題である cdk diff についても述べます。

Snapshot testing は特定シーンにおける回帰テストとしての役割を持てると述べましたが、こちらは少しモチベが異なります。cdk diff は「リソースを意図的に変更したい」場合に起きる、開発者が予期していない変更に気づくための仕組みと言えます。今から自分が作ろうとしているモノが具体的に何であり、どのようなリソースが追加/変更/削除されるのか把握しておく必要があります。おそらく、これが重要であることは言うまでもないと思います*2

Snapshot testing と cdk diff のどちらも「開発者が意図しない・予期しない変更」を拾いあげるための仕組みですが、特に CDK のようなリソース定義を抽象化する思想を持ったツールを使う場合はこうした仕組みの重要性が高まると考えます。抽象度を上げたツールであるということは、開発者にとっては生産性向上を享受できるメリットであると同時に、ローレベル(この場合は CloudFormation あるいは実際に作られる AWS リソースの詳細)で何が起きているのかを隠蔽する仕組みでもあるためです。どのようなツールにもメリットの裏返しがあるよねという話です。*3

本題 (cdk diff の話)

ようやく cdk diff の話に入ります。

docs.aws.amazon.com

cdk diff は何と何を比較するのか

cdk diff は「手元の CDK App の合成 (synth) 結果」と「デプロイ済みの CloudFormation Stack」の差分を計算するツールです。

比較相手である「デプロイ済みの CloudFormation Stack」というのは、次の2つのいずれかを指します。

  1. CloudFormation テンプレート同士の比較
  2. ChangeSet を用いた比較 (default) *4

v2 が出た当初は後者がサポートされておらず、1のテンプレートベースの比較がデフォルトでした。 v2.119.0 からは ChangeSet を用いた比較がサポートされ、こちらの挙動がデフォルトになりました。--no-change-set というオプションを明示することで、ChangeSet を作らず、それ以前までの仕様でテンプレートファイル同士の比較を行えます。この比較方法にはいわゆる「偽陽性」の問題がありましたが、ChangeSet の作成を必要としない分より高速に動作するのが利点です。

ちなみに、 v2.119.0 のリリース日は 2024/01/11 で、この記事の執筆時点から見て比較的最近です (CHANGELOG / Pull Request #28336) 。

このあたりは AWSJ 高野さん (@konokenj) によるアウトプットでも紹介されています。p.20, 21 をご確認ください*5

speakerdeck.com

AWS Infrastructure as Code の新機能を総まとめ! 2023.1-2024.5 - Speaker Deck

cdk diff を使ってみる

さて、実際に cdk diff を利用してみましょう。

まずは、次のような実装の Stack を用意し、適当な CDK App に入れてデプロイしてみます。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as iam from 'aws-cdk-lib/aws-iam';
 
export class CdkDetectReplacementStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    
    const role = new iam.Role(this, 'Role', {
      roleName: 'my-role-1',
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')],
    });
    
    role.addToPolicy(new iam.PolicyStatement({
      actions: ['s3:GetObject'],
      resources: ['arn:aws:s3:::my-bucket/*'],
    }));
  }
}

デプロイしたあと、ソースコード中の IAM Role の RoleName を変更してみましょう。このプロパティは Update requires = "Replacement" となるプロパティです。つまり、このプロパティを更新すると Role リソースは再作成されます。

RoleName を変更したあとのソースは次の通りです。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as iam from 'aws-cdk-lib/aws-iam';
 
export class CdkDetectReplacementStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    
    const role = new iam.Role(this, 'Role', {
      roleName: 'my-role-2', // change 'my-role-1' -> 'my-role-2'
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')],
    });
    
    role.addToPolicy(new iam.PolicyStatement({
      actions: ['s3:GetObject'],
      resources: ['arn:aws:s3:::my-bucket/*'],
    }));
  }
}

まずは、オプションなしのデフォルトの挙動を見てみます。ChangeSet の作成有無による違いに着目するので、diff の実行時間もついでに計測してみます*6

# デフォルトの挙動(ChangeSet の作成あり)
$ time npx cdk diff  
Stack CdkDetectReplacementStack
Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)
Resources
[~] AWS::IAM::Role Role Role1ABCC5F0 replace
 └─ [~] RoleName (requires replacement)
     ├─ [-] my-role-1
     └─ [+] my-role-2


✨  Number of stacks with differences: 1

npx cdk diff  4.75s user 0.59s system 46% cpu 11.384 total

RoleName が "requires replacement" であることが示されており、その影響で IAM Role も "replace" されることが示されています。親切ですね。

次に、--no-change-set を付けて実行してみます。

# ChangeSet の作成をしない場合の挙動
$ time npx cdk diff --no-change-set
Stack CdkDetectReplacementStack
Resources
[~] AWS::IAM::Role Role Role1ABCC5F0 replace
 └─ [~] RoleName (requires replacement)
     ├─ [-] my-role-1
     └─ [+] my-role-2


✨  Number of stacks with differences: 1

npx cdk diff --no-change-set  4.38s user 0.45s system 137% cpu 3.529 total

この場合も、RoleName が "requires replacement" であることが示されています。IAM Role の "replace" についても同様に検知されていることがわかります。

さて、ここで注目して欲しいのが --no-change-set の場合でも Replace が検出されている理由 です。

単なる JSON 同士の比較ならこんなことはできません。なぜ ChangeSet も作らずに Replace を引き起こす変更がわかるのでしょうか?

先に答えを説明してしまうと、これは AWS 公式が CloudFormation のリソースタイプおよびプロパティの仕様を宣言した JSON ファイルを公開しており、CDK は diff コマンドの内部でそのスキーマを参照して diff の詳細を計算するから、です。

CloudFormation リソースの仕様は、CloudFormation resource specification で公開されているものを使っています。

docs.aws.amazon.com

RoleName の定義を抜粋すると、次のように書かれています。UpdateType と書かれている部分が Replace の判定に関わっている部分です。

        "RoleName": {
          "Documentation": "http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-role.html#cfn-iam-role-rolename",
          "UpdateType": "Immutable",
          "Required": false,
          "PrimitiveType": "String"
        },

JSON ファイルの詳細は興味ある方がご自身でご覧いただくとして、UpdateType の値が "Immutable" あるいは "Conditional" の場合がリソースの置換を伴う(可能性がある)ものとして判断できるということになります。cdk diff (with --no-change-set) はテンプレート同士の比較を行い、その後内部的にこのスキーマ情報から取り込んだ定義情報に照らしてテンプレート上の差分が実際にどのような影響を伴うのかを判別し、ユーザーに提示します。

ChangeSet を使わずとも Replace を検知できるのはかなり有用な特徴に見えます。実際、それで十分な場合も多いでしょう。が、さきほども述べたようにこの機能には「偽陽性」の問題があります。

ChangeSet を使わずに差分比較する場合の「偽陽性」問題

結論から述べると、テンプレート同士を比較する仕組みである以上、原理的に「偽陽性」の問題は避けようがありません。より正確な差分が欲しければ cdk diff のデフォルト挙動に任せて、ChangeSet も併用した差分比較を行いましょう。

まずは、具体的に「偽陽性」とは何を指しているのかを確認してみます。

v2.119.0 の CHANGELOG を見てみると、次のように書かれています。

cli: cdk diff falsely reports resource replacements on trivial template changes (#28336) (10ed194)

公式ドキュメント--change-set オプションにも、似たようなことが記載されています。

Whenfalse, 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.

見た目上の変更...例えば Ref などの参照が ARN のハードコードに置き換わっただけのようなケースでも "Replace" 扱いで検出されてしまうよ、と述べています。これが「偽陽性」の内訳です。一体これはどういうことでしょう。次の節で確認してみましょう。

(余談)テンプレートベースの比較方法では検出できて、ChangeSet には検出できないもの、というのもあります。 その一例は、後ろの節で例示している CloudFormation Parameter の差分です。ChangeSet はあくまでデプロイされるリソースがどう変化するかを検出するものです。そのため、Parameter の差分は ChangeSet では直接検出できません。cdk diff は、ChangeSet のオプション指定値に関係なくテンプレートベースの比較は必ず行う仕様になっています。そのため、こうした ChangeSet 単独では拾えない差分も拾ってくれるようになっています。

「見た目上の変更」の扱い

前節で触れたケースを実際に発生させてみます。

しかし、その前に CloudFormation の ChangeSet としては前述のようなケースがどのように扱われているか、先に示した方がわかりやすいでしょう。幸いにも先ほどご紹介した高野さんによるアウトプットや、CloudFormation のアップデート情報から答えは見つかります。

AWS Infrastructure as Code の新機能を総まとめ! 2023.1-2024.5 - Speaker Deck (p.25) より、

!Ref!GetAtt での参照や SSM Parameter Store, Secrets Manager の動的参照も解決した上で差分を計算する

とあります。スライドが参照しているドキュメントは以下のリンクです。

aws.amazon.com

つまり、このような動的な参照の解決を行った結果を評価して差がないなら、ChangeSet としては「差分なし」となる、ということです。Ref などで参照していた部分を別の表現、たとえば ARN ハードコードの表現に置き換えたとしても、最終的な解決後の値が同一であれば ChangeSet は差分を検出しません。

このことを念頭におきつつ、CDK で実験してみましょう。

「見た目上の変更」が cdk diff ではどう見えるか

先ほど挙げた例をそのまま使ってみましょう。最初の実装は RoleName を文字列でハードコードしていましたので、これを CloudFormation Parameter に置き換えてみます。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as iam from 'aws-cdk-lib/aws-iam';

export class CdkDetectReplacementStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    
    const roleNameParam = new cdk.CfnParameter(this, 'RoleName', {
      type: 'String',
      default: 'my-role-1',
    });
    
    const role = new iam.Role(this, 'Role', {
      roleName: roleNameParam.valueAsString, // use reference to cfn-parameter
      assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
      managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')],
    });
    
    role.addToPolicy(new iam.PolicyStatement({
      actions: ['s3:GetObject'],
      resources: ['arn:aws:s3:::my-bucket/*'],
    }));
  }
}

見ての通り、"my-role-1" という値自体は変わっていません。これを cdk diff で見てみます。

まずはオプションなし(ChangeSet あり)の挙動から。

$ time npx cdk diff                
Stack CdkDetectReplacementStack
Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)
Parameters
[+] Parameter RoleName RoleName: {"Type":"String","Default":"my-role-1"}


✨  Number of stacks with differences: 1

npx cdk diff  4.60s user 0.50s system 53% cpu 9.614 total

Parameter の変更だけが検出されています。次は --no-change-set の場合を見てみましょう。

$ time npx cdk diff --no-change-set
Stack CdkDetectReplacementStack
Parameters
[+] Parameter RoleName RoleName: {"Type":"String","Default":"my-role-1"}

Resources
[~] AWS::IAM::Role Role Role1ABCC5F0 replace
 └─ [~] RoleName (requires replacement)
     └─ @@ -1,1 +1,3 @@
        [-] "my-role-1"
        [+] {
        [+]   "Ref": "RoleName"
        [+] }


✨  Number of stacks with differences: 1

npx cdk diff --no-change-set  4.45s user 0.51s system 129% cpu 3.841 total

RoleName に対する変更が "requires replacement" として検知されています。しかし、先ほど ChangeSet の仕様を示した通り、解決後の値が変わらないため結果的にこの Stack のデプロイは IAM Role の置換を伴いません。すなわち cdk diff (--no-change-set) の結果は「偽陽性」である、ということになります。

ちなみに、cdk deploy でもこの挙動を確かめる手段があります。deploy コマンドには --method というオプションがあり、値に "prepare-change-set" を指定することで実際にデプロイはせず ChangeSet の作成だけを行えます。

cdk diff の出力が確認できればこの記事の主旨としては十分なのですが、せっかくなのでこちらも試してみましょう。

「見た目上の変更」が、"cdk deploy --method prepare-change-set" ではどう見えるか

前節で述べたように、デプロイコマンドの --method オプションを "prepare-change-set" に指定することで実際のデプロイを行わず ChangeSet だけを作ることができます。

docs.aws.amazon.com

prepare-change-setCreate change set but don’t perform deployment. This is useful if you have external tools that will inspect the change set or if you have an approval process for change sets.

RoleName の指定を CloudFormation Parameter に置き換え、かつ実際の値が変化しないように Stack を定義した場合の cdk deploy --method prepare-change-set は次のような結果になります。

$ time npx cdk deploy --method prepare-change-set

✨  Synthesis time: 2.63s

CdkDetectReplacementStack: deploying... [1/1]
CdkDetectReplacementStack: creating CloudFormation changeset...
Changeset arn:aws:cloudformation:ap-northeast-1:000011112222:changeSet/cdk-deploy-change-set/bd045057-4052-4902-8f43-bf5a64df8750 created and waiting in review for manual execution (--no-execute)

 ✅  CdkDetectReplacementStack

✨  Deployment time: 5.88s

Stack ARN:
arn:aws:cloudformation:ap-northeast-1:000011112222:stack/CdkDetectReplacementStack/48af8430-4f68-11ef-bc9a-064145fc3c2d

✨  Total time: 8.51s


npx cdk deploy --method prepare-change-set  4.62s user 0.53s system 50% cpu 10.110 total

マネジメントコンソールから ChangeSet を見てみましょう。

cdk deploy --method prepare-change-set の実行結果

Parameter の定義を増やしましたが、値はハードコードの初期実装から変わっていないので ChangeSet では差分として検出されていないことがわかります。

まとめ

次のようなことがわかりました。

  • cdk diff は内部的に CloudFormation のリソース定義の仕様情報を参照しており、差分情報を補完するために利用する
    • CloudFormation のリソース仕様を見ているおかげで、ChangeSet を使わない差分比較でも Replace を引き起こす変更は検知可能である
    • v2.150.0 時点では ChangeSet を作成する挙動がデフォルトであるため、偽陽性の問題を許容できる場合は --no-change-set を指定すると高速になる
  • CloudFormation の ChangeSet は賢いし、CDK も追随して賢くなっている

基本はデフォルトの cdk diff の挙動に任せておくのが安牌でしょう。ただ、何度も実行していて待ち時間が気になりだしたら、そのときは --no-change-set の指定を検討すると良いでしょう。ただ、あくまでもテンプレート同士をスキーマ情報に照らして評価しただけの結果であるため、この記事で述べたような「偽陽性」の可能性があることは留意しておきましょう。

また、これは cdk diff や ChangeSet の範疇からは外れることですが、こうした「差分」をチェックできたからといって その差分が正常に適用できるとは限らない ことにも注意してください。

ChangeSet のドキュメントでも、このことはハッキリと明示されています。

Important Change sets don't indicate whether CloudFormation will successfully update a stack. For example, a change set doesn't check if you will surpass an account limit, if you're updating a resource that doesn't support updates, or if you have insufficient permissions to modify a resource, all of which can cause a stack update to fail. If an update fails, CloudFormation attempts to roll back your resources to their original state. Updating stacks using change sets - AWS CloudFormation

「どのような差分が発生したのか」と「ある差分が正常に適用できる内容かどうか」という問題は別物であるということは、意識しておいてください。

「ChangeSet は確認して問題なさそうだから、デプロイも成功するだろう」という目算は誤りです。文法として正しくとも意味的には誤りである、というケースだってありえます。ぶっつけで本番デプロイはしないように、くれぐれもよろしくお願いします。

余談

手前味噌ですが、この記事を書くにあたって CDK の内部実装を調査したログを個人の Zenn アカウントでまとめて記事にしてます。私個人の調査ログという側面が強い、かつ自分専用 or CDK マニアな物好きに向けて書いてる記事なので、よっぽどお時間があればこちらものぞいてみてください。

zenn.dev

zenn.dev

*1:細々した修正を何度もデプロイして待ったり、一つ一つ手作業でデプロイ結果を確認したりするのはしんどいですよね

*2:cdk diff と非常に近い位置付け(というか一部被っている)である、CloudFormation の "ChangeSet" についても同様のことが言えます。CDK 初心者からするとこちらの方がイメージが湧く方も多いかと思います

*3:とはいえ、少ない記述量で多くのことができ、かつ非常に柔軟性の高いバリデーションを実現できる CDK は非常に良いツールであると私は信じています

*4:より正確には、このデフォルト動作はテンプレート同士の比較に加えて ChangeSet「も」 併用した比較を行います

*5:CDK に注目している皆さんは高野さんのアカウントやスライドを是非ウォッチしてみてください。めちゃくちゃ精力的に、幅広い対象者に向けて、そしてディープに CDK を紹介するアウトプットを多数出されています。少なくとも CDK に関しては、間違いなく 2024 年時点における国内トップランナーのお一人だと思います

*6:実行時間に関してはただの参考値として見てください。Stack 全体や発生差分のボリュームが小さいですし、そもそも試行回数1回の結果では有意差は判断できません。Stack や差分の全体ボリュームが増えてくれば、もうちょっと顕著に差が出ると思います

橋本 拓弥(記事一覧)

マネージドサービス部

内製開発中心にやってます。普段はサーバーレス関連や CDK を触ることが多いです