AWS CDKにおける「cdk.context.json」の必要性

AWS CDKにおける『cdk.context.json』ファイルの必要性を考察してみました。


目次

目次


コンテキストとcdk.context.json

コンテキスト

AWS CDKにおける『コンテキスト』とは、アプリ、スタック、またはコンストラクトに関連付けることのできるキーと値のペアです。

docs.aws.amazon.com


ざっくり言うと、CDKスタックにスタック定義外から情報を付与するようなケースで使うことができます。

例えば、CDKスタックにデプロイ環境情報(dev, stg, prdなど)を外から文字列として与えたい場合、ENVのようなキーのコンテキストを渡すことで、CDK定義の中でその情報を受け取ることができます。


このコンテキスト情報は、cdk.jsonファイルのcontextキーに記述したり、cdk deploycdk synthコマンドに--context (-c)オプションで渡すことも可能です。

npx cdk deploy -c ENV=dev

それをCDKスタック(もしくはappやコンストラクト)内では、以下のように取得することができます。

const env = app.node.tryGetContext("ENV") as string; // dev, stg, prdなど


またAWS CDK本体には機能フラグというもの(フラグをtrueにすることで挙動が変わる、破壊的変更などを伴う機能変更に対して明示的にオプトインすることで変更を反映するための仕組み)が活用されていて、機能フラグの格納場所としてもcdk.jsonファイルが使われます。


cdk.context.json

『cdk.context.json』ファイルとは、合成(synthesize)中に AWS アカウントから取得した値をキャッシュしておくための格納ファイルになります。

例えば、アベイラビリティーゾーン情報やEC2 インスタンスで現在利用可能な Amazon マシンイメージ (AMI) IDなどを、AWSアカウントから動的に取得して格納したりします。


具体的には、CDKのL2 ConstructやStackクラスなどで提供されるcontextメソッド (Lookupメソッドとも)と呼ばれるメソッドを実行した際に、内部でAWS SDKを通じてAWSアカウントに情報を取りにいき、その結果をcdk.context.jsonファイルに格納します。

docs.aws.amazon.com

以下のように、VPCであったりSSMパラメータストアであったり、意外と普段使うケースも多いのではないでしょうか。このメソッドで取得した情報がcdk.context.jsonファイルに自動で記述されます。

const vpc = Vpc.fromLookup(this, "Vpc", {
  vpcId,
});

const parameter = StringParameter.valueFromLookup(this, parameterName);


そして、デプロイまたは合成時、キャッシュであるcdk.context.jsonファイルにその情報があった場合は、AWSアカウントにSDKで情報を取得する処理は走らず、ファイル内の情報を使用するようになっています。


cdk.context.jsonの必要性

さて、ここで本題の、「cdk.context.jsonの必要性」についてお話しします。

もう少し正確に言い換えると、「cdk.context.jsonファイルを、Gitなどのソースコードリポジトリにコミットしておく(ignoreしない)必要があるのか?」というお話をします。


結論から言うと、「必要」、というか「コミットしておいた方が(だいたいのケースで)良い」です。


最初に載せた公式ドキュメントでも、「必要」と書かれています。

Because they're part of your application's state, cdk.json and cdk.context.json must be committed to source control along with the rest of your app's source code. Otherwise, deployments in other environments (for example, a CI pipeline) might produce inconsistent results.


cdk.context.jsonが必要な理由

では、なぜ「cdk.context.jsonファイルを、Gitなどのソースコードリポジトリコミットしておく必要がある」のでしょうか?


理由として、以下の2点が挙げられます。

  • 非決定的な動作(デプロイ)を避けるため
  • デプロイ速度を向上させるため


非決定的な動作(デプロイ)を避ける

非決定的な動作(デプロイ)を避けるとはどういうことでしょうか?


キャッシュ機構がない場合 (cdk.context.jsonファイルがない場合) のお話をします。

例えば、contextメソッドでEC2の最新のAMIを取得するようにしてデプロイしていたとしましょう。

もし、ある日を境に新しいAMIのバージョンがリリースされたとします。CDKでは最新のイメージを取得するように実装しているので、いつの間にかすでにデプロイしているEC2のものと取得したAMIの値が変わってしまい、EC2の置換(再構築)が走ることになってしまいます。


このように、デプロイ実行時のタイミングで構成が変わってしまうような「非決定的」な動作を避けるために、cdk.context.jsonファイルにデプロイしたときのAMI情報をキャッシュしておき、次回以降のデプロイではそのキャッシュ情報を参照することで毎回のデプロイで同じ値を使用し、「決定的」な動作を保証することができるわけです。


また公式ドキュメントのベストプラクティスページに「非決定的な動作を避けるためcdk.context.jsonにコミットする」という項もあるのでぜひご覧ください。

docs.aws.amazon.com


ちなみに、cdk.context.jsonにキャッシュ情報がなくAWSアカウントにLookupしないといけないケースを防ぎたい、つまり、キャッシュがない場合はdeploy, synthをエラーにしたい場合、cdk deploy, cdk synthコマンドのオプションに--lookupsというオプションがあり、これをfalseにすることで、キャッシュにない場合はデプロイをエラーにすることができます。(デフォルトではtrueなので、キャッシュにない場合はSDKで取得しにいく)

これによって「決定的」動作を完全に保証することができます。

--lookups    Perform context lookups (synthesis fails if this is
             disabled and context lookups need to be performed)
                   [boolean] [default: true]


デプロイ速度を向上させる

前者の「非決定的な動作(デプロイ)を避ける」がcdk.context.jsonを説明する上でよく出るお話で、実はこっちの詳しい話は知らない方も多いのではないかと思います。


なぜcdk.context.jsonファイルがあると(コミットしておくと)デプロイ速度が向上するのか?

キャッシュによりSDK呼び出しが発生しないため通信処理を省けることで時間が削減できる、というのももちろん理由ではあります。が、さらに大きな理由があります。


それは、cdk.context.jsonがない、またはcdk.context.jsonに該当する情報がない場合、「合成(synthesize)が 2回(複数回) 走る仕組みになっている」からです。


synthesizeが2回走る」ということは、単純にsynth処理が重いよねって話もそうなのですが、Lambdaで使用するコードやコンテナイメージなどのビルド処理がまた走ってしまうことになります。(コード・イメージ自体のキャッシュ機構の話はここでは置いておく)

これ、結構辛いんです。


具体的に、CDK本家のソースコードを見てみましょう。

以下はsynthesize時に呼ばれるCloudExecutableクラスのdoSynthesizeメソッドです。

github.com

    while (true) {
      const assembly = await this.props.synthesizer(this.props.sdkProvider, this.props.configuration);

      if (assembly.manifest.missing && assembly.manifest.missing.length > 0) {
        const missingKeys = missingContextKeys(assembly.manifest.missing);

        // ...
        // ...

        if (tryLookup) {
          debug('Some context information is missing. Fetching...');

          await contextproviders.provideContextValues(
            assembly.manifest.missing,
            this.props.configuration.context,
            this.props.sdkProvider);

          // Cache the new context to disk
          await this.props.configuration.saveContext();

          // Execute again
          continue;
        }
      }


まずwhileによるループがあり、その中で最初にsynthesize処理が走ります。

    while (true) {
      const assembly = await this.props.synthesizer(this.props.sdkProvider, this.props.configuration);


そして、コンテキスト情報が欠落していた、つまりcdk.context.jsonに必要なキャッシュがない場合、次のif内の処理に入ります。

      if (assembly.manifest.missing && assembly.manifest.missing.length > 0) {


そしてここが重要なのですが、ここでSDKによるAWSアカウントにコンテキスト情報を取得する処理が走り、コンテキストとしてファイルに保存した上で、continueによってwhileループの最初に戻ります。

          await contextproviders.provideContextValues(
            assembly.manifest.missing,
            this.props.configuration.context,
            this.props.sdkProvider);

          // Cache the new context to disk
          await this.props.configuration.saveContext();

          // Execute again
          continue;


whileによるループの中では最初にsynthesize処理が書いてあるので、また再度synthesize処理が実行される、という挙動になっているわけです。


このように、cdk.context.jsonがない、またはcdk.context.jsonに該当する情報がない場合、「合成(synthesize)が 2回 走る」という仕組みになっているため、デプロイに時間がかかってしまうのです。


cdk.context.jsonの注意点

cdk.context.jsonは必要という話をここまでしましたが、注意点のお話をします。


例えばSSMパラメータストアから動的に値を参照するStringParameter.valueFromLookupメソッドを使用していたとしましょう。

あるタイミングでそのパラメータストアの値を新しくするためにその値を更新したとします。そして次回のCDKデプロイでは、その新しい値をCDKスタックで参照したいとします。

ですが、cdk.context.jsonにキャッシュがある場合、パラメータストアにアクセスする処理が走らないため、今までと同じ、つまり古い値を参照し続けるようになってしまいます。


しかしこのような場合、コンテキスト情報、つまりキャッシュを消すコマンドオプションがcdk contextコマンドには用意されています。

  • 特定のコンテキストをリセットする
npx cdk context --reset [KEY_OR_NUMBER]

## ex) npx cdk context --reset 2
  • すべてのコンテキストをクリアする
npx cdk context --clear


--resetオプションの[KEY_OR_NUMBER]部分ですが、削除したいcontextのキー名または番号を指定します。キー名または番号はcdk context(オプションなし)で確認できます。

$ npx cdk context

Context found in cdk.json:

┌───┬─────────────────────────────────────────────────────────────┬─────────────────────────────────────────────────────────┐
│ # │ Key                                                         │ Value                                                   │
├───┼─────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤
│ 1 │ availability-zones:account=123456789012:region=eu-central-1 │ [ "eu-central-1a", "eu-central-1b", "eu-central-1c" ]   │
├───┼─────────────────────────────────────────────────────────────┼─────────────────────────────────────────────────────────┤
│ 2 │ availability-zones:account=123456789012:region=eu-west-1    │ [ "eu-west-1a", "eu-west-1b", "eu-west-1c" ]            │
└───┴─────────────────────────────────────────────────────────────┴─────────────────────────────────────────────────────────┘


つまり、SSMパラメータのように最新の値を取りたい場合は、デプロイ前にreset/clearでコンテキストをクリアすると良いでしょう。

しかしその場合、cdk.context.jsonファイルをコミットしていたとしてもsynth処理は2回走ってしまうので、デプロイ速度は落ちてしまうという点にも注意しましょう。


cdk.context.jsonをコミットしないという手もありますが、cdk.context.jsonファイルがあり、クリアしなかったコンテキストの情報はキャッシュとして使用してくれる仕組みになっているため、それらの情報をSDKで取得する通信処理が発生しません

そのため、やはりcdk.context.jsonはコミットしておいた方が良いと思います。


コミットしなくても良いケース

毎回のデプロイで、完全にコンテキスト(キャッシュ)をクリアしたい場合です。

これに当てはまるケースは、例えば「VPCなどはスタック内で読み込んでないが、毎回新しい値を取得したいSSMパラメータストアにだけcontextメソッドを使用している」ようなケースです。


しかし今後の開発でcontextメソッドが必要になったとき、cdk.context.jsonファイルをignoreしていることも忘れて、気付かぬうちにデプロイ速度が遅くなっているなどに陥ってしまうなどには注意しましょう。

※かなり細かい話をすると、コミットしていない、つまりcdk.context.jsonファイルがない状態だと、cdk.context.jsonファイルを作成したりする処理が走るので多少のオーバーヘッドはあります。(が、これは無視してよいでしょう。)


最後に

cdk.context.jsonはCDKにおいて意外なキーポイントだということを今回の記事を書く中で気付きました。

ぜひignoreせずコミットしておきましょう。