CI/CDサービスからCDKでデプロイする際、差分がないはずなのにno changeにならない原因と対策

CI/CDサービスからCDKでデプロイする際に、差分原因となったCDKMetadataについて書きました。対処方法も記載しています。
2022.06.30

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

CX事業本部の佐藤智樹です。

今回はCI/CDサービスからCDKをデプロイする際、差分がないはずなのにno changeにならない場合の原因と対策について紹介します。あくまで1例なので当てはまらない可能性もありますがCI/CDサービスからCDKをデプロイしている多くの方は引っかかる内容かと思うので、心当たりなどあれば是非読んでみてください。また話の流れでCDKが生成するテンプレートに記載されているCDKMetadataについて何が書かれているか紹介します。

先に結論

CDKがテンプレートを生成する際にMetadataを付与しています。CDKMetadataにはスタックで作成するリソースやCDKのバージョン、Node.jsのメジャーからマイナーバージョン番号まで含まれるため意図せぬタイミングでテンプレートの更新が走る可能性があります。対処方法は本記事の最後の方をご確認ください。

調べたきっかけ

いくつかの案件でCDKを使ってインフラをデプロイする際、CI/CDサービス(CircleCI、GitHub Actions、CodeBuildなど)を使っていました。普段スタックを役割に応じて分割してデプロイしているのですが、検証/本番環境アカウントでのデプロイ時のログを見ると何故かインフラの内容を更新していないスタックまでデプロイ処理が走っていることに気づきました。デプロイ時間としても10秒程度の差分確認で no changes で終わるはずが1分程度かかっています。ただ再現実験をしようとしても開発用アカウントで何度も実行してもほぼ再現されず原因特定に困っていました。

たまたま新しい環境でCI/CDサービスのデプロイ時の実行ログやcdk synthによるテンプレート出力で差分を確認したところ、CDKMetadataAnalyticsという項目にたまに差分が発生することが分かりました。なので以降の章ではCDKMetadataについて調査した内容を記載します。

CDKMetadataとは

CDKがCloudFormationのテンプレートを作成する際に付与するメタデータです。以前の弊社のブログでも紹介されております。CDKMetadataがどんな形で出力されるのかは以下の記事も参考になるのでチェックしてみてください。

Metadataの中で今回関連する部分を抜粋すると以下のようになります。

Resources:
  CDKMetadata:
    Type: AWS::CDK::Metadata
    Properties:
      Analytics: v2:deflate64:H4sIAAAAAAAAEzPUMzQw0TNQdEgsL9ZNTsnWT84vStWrDi5JTM7Wcc7PKy4pKk0u0XFOywtKLc4vLUpOBbGBEimZJZn5ebU6efkpqXpZxfplhmZ6hkCDsoozM3WLSvNKMnNT9YIgNAAtXENFZQAAAA==

今回は上記のブログに書かれていないAnalytics項目の詳細について記載します。

Analyticsの中身

CDKの実装を追っていくと以下のソースでAnaliticsを生成していることが分かりました。

metadata-resource.ts

/**
 * Formats a list of construct fully-qualified names (FQNs) and versions into a (possibly compressed) prefix-encoded string.
 *
 * The list of ConstructInfos is logically formatted into:
 * ${version}!${fqn} (e.g., "1.90.0!aws-cdk-lib.Stack")
 * and then all of the construct-versions are grouped with common prefixes together, grouping common parts in '{}' and separating items with ','.
 *
 * Example:
 * [1.90.0!aws-cdk-lib.Stack, 1.90.0!aws-cdk-lib.Construct, 1.90.0!aws-cdk-lib.service.Resource, 0.42.1!aws-cdk-lib-experiments.NewStuff]
 * Becomes:
 * 1.90.0!aws-cdk-lib.{Stack,Construct,service.Resource},0.42.1!aws-cdk-lib-experiments.NewStuff
 *
 * The whole thing is then either included directly as plaintext as:
 * v2:plaintext:{prefixEncodedList}
 * Or is compressed and base64-encoded, and then formatted as:
 * v2:deflate64:{prefixEncodedListCompressedAndEncoded}
 *
 * Exported/visible for ease of testing.
 */
export function formatAnalytics(infos: ConstructInfo[]) {
  const trie = new Trie();
  infos.forEach(info => insertFqnInTrie(`${info.version}!${info.fqn}`, trie));

  const plaintextEncodedConstructs = prefixEncodeTrie(trie);
  const compressedConstructsBuffer = zlib.gzipSync(Buffer.from(plaintextEncodedConstructs));

  // set OS flag to "unknown" in order to ensure we get consistent results across operating systems
  // see https://github.com/aws/aws-cdk/issues/15322
  setGzipOperatingSystemToUnknown(compressedConstructsBuffer);

  const compressedConstructs = compressedConstructsBuffer.toString('base64');
  return `v2:deflate64:${compressedConstructs}`;
}

実装を見て一旦prefixEncodeTrieの部分を置いておくと、zlib.gzipSync(Buffer.from(plaintextEncodedConstructs))の部分で文字列のバッファ変換とgzip圧縮を実施していることが分かります。またこの結果をbase64形式に変換後、v2:deflate64:に文字列結合されてることが分かります。この結果がCfnテンプレートのAnalytics部分に追加されています。

どんな内容になっているか確認するため、以下のコードで逆変換をかけてみます。元のAnalyticsの内容はこの記事のものです。

参考コード

analyticsDecode.js

const Buffer = require('buffer').Buffer;
const zlib = require('zlib');

/**
 * 解凍
 */
function unzip(value){
    const buffer = Buffer.from(value, 'base64')              // base64 => Bufferに変換
    const result = zlib.unzipSync(buffer)                    // 復号化
    const str = decodeURIComponent(result).toString('utf-8') // デコード
    return str;
}

const str = process.argv[2]
const str2 = unzip(str);
console.log(str2)
$ node analyticsDecode.js H4sIAAAAAAAAEzPUMzQw0TNQdEgsL9ZNTsnWT84vStWrDi5JTM7Wcc7PKy4pKk0u0XFOywtKLc4vLUpOBbGBEimZJZn5ebU6efkpqXpZxfplhmZ6hkCDsoozM3WLSvNKMnNT9YIgNAAtXENFZQAAAA==

1.104.0!@aws-cdk/core.{Stack,Construct,CfnResource,CfnCondition},node.js/v16.1.0!jsii-runtime.Runtime

上記のようにAnalyticsのメタデータには、実行した時のAWS CDKのバージョン番号や構成しているConstruct、実行したNode.jsのメジャー~マイナーバージョンが含まれることが分かります。この結果から想定していないときにCDKのデプロイで更新が実行されるのは、CDKのバージョンを固定してない時やCI/CDサービス上でインストールするNode.jsのバージョンをマイナーまで固定していない場合に生成したメタデータが変化して差分となることが分かります。

対処方法としては、2種類あると思うので以降に記載します。どちらもデメリットはあると思うので検討の上で選択してください。

対処方法①

一番単純な対処方法としては、CDKMetadataをオフにしてしまう方法があります。以下のようにcdk.jsonで除外設定を行うか、cdk deploy実行時に--path-metadata false--no-path-metadataを追加して実行する方法です。

想定されるデメリット

1点目としては、AWSからCDKの利用状況に関する改善アナウンスを受けられなくなる可能性があります。AWS側からはこのCDKMetadataでしかCDKを使っているのか分からないはずなので、CDKMetadataを元にアナウンスなどをしていることが想定されます。CDK v1のメンテナンスモード移行時のv2移行依頼のメールがアカウントまで飛んでいましたがおそらくこちらを参考に送っているのかと思います。 2点目としては、当初はAWS CDKで優先的にL2 Construct化するリソースをCDKMetadataによる決めているという記載があるので、現在も使用されている可能性はあります。最近クローズされたので真相は把握できてませんがわかる方いればご連絡ください。

上記デメリットを許容してこちらを選択する場合は、デメリットを減らすためCI/CDサービス上で実行する時だけ--no-path-metadataを付与するのが良さそうです。

対処方法②

CDKのバージョンとNode.jsのバージョンが含まれるので、バージョンを固定にしてしまう方法があります。CDKはpackage.json/package-lock.jsonで固定化し、Node.jsのバージョンはCodeBuildなら以下のように固定する方法です。

想定されるデメリット

1点目は、バージョン更新による差分が残るので何度もバージョン更新する際は、結局何度も差分が発生することです。更新のたびに差分は出るのでCDの時間が1スタック1分程度遅延します。 2点目は、厳密な固定バージョンの管理が大変なことです。マイナーバージョンの管理をCI/CDサービスに依存できず厳密指定になるので運用でマイナーバージョンの更新について逐一確認する必要があるので、負荷が上がりやすそうです。

こちらは細かく継続更新することを覚悟で実施する必要があるので、①の方が取り組みやすいとは思います。

所感

長年よく分からない挙動で、最近原因が分かってすっきりしたので記事にしました。副題としては「CDKMetadataには何が書かれているのか」というタイトルも考えたんですが、現実的な問題をタイトルにした方が解決する方は多いかと思ったので今回のタイトルにしました。CDK利用者でCI/CDサービスからCDKをデプロイしている場合は、ほぼ確定で1スタック1分かかるところが10秒程度に削減されるのでお試しください。

参考