😸

cdk.context.json が CDK 的にどう扱われているか調べてみた - part1

2024/07/27に公開

発端はCDKトーーク! という CDK のフリートーク企画です。トークテーマとなるような質問や話題を募集してみたことろ、ありがたいことに以下のような事前質問をいただきました。

運用の話なんですが、cdk.context.jsonの扱いをどうしているのか気になってます。自チームではパラメータ格納などに積極的に使っておらず、cdk deploy後に更新される厄介者という感じです。いっそignoreするかって話になってるので、その是非と他の人やお二方がどう認識しているのか伺いたいです!

私自身はというと、今やってるプロジェクトがちょうど cdk.context.json をコミットしない運用で回してて、さほど困っていませんでした。公式ドキュメントに書いてあるような話は知識としてはうっすら履修していたのですが、現時点では実務上の大きな支障がなかったため特に気にしていなかったのです。

事前質問いただいたものに関しては最低限ドキュメントくらいはきっちり予習しないとな〜!などと考えておりましたら、翌日光の速さで k.goto さんの調査・考察記事が出ました。

https://go-to-k.hatenablog.com/entry/cdk-context-json

この速さと深さを目の当たりにして、私は「アカン、行動の水準が違うわ」と思いました。

触発されて、私も自分の切り口で何かしらまとめてみるか〜〜と思ったのでこの記事を書くことにしました。

(追記) part2 書きました。

https://zenn.dev/hassaku63/articles/3bf6d3a02481a8

(追記) part3 書きました

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

この記事では何を書いたのか

「cdk.context.jsonの扱いをどうしているのか」という問いに対する私の立場を最初に表明します。残りは基本的に CDK 内部の実装を調べてみる内容になっています。

この記事では CDK ユーザーとしての視点ではなく、CDK 自身が cdk.context.json を内部的にどう扱っているのかについて調べてみます。CDK 内部での扱いがわかれば、きっとユーザーとしての扱い方のヒントにもなるでしょう。多分。

また、これまであまり OSS のコードを読むということをしたことがないエンジニアの人に向けて、他人(私)がコードリーディングする様子を見てもらえば多少は参考になるかな?と目論んでいる部分も、多少あります。

ある程度ソースコードを把握したうえでの自分なりの回答を用意したいと思いました。そのために私が何をどう調べたのか、調査ログ的なものをメモったのがこの記事です。コードを読んで抽出した情報だけ見るなら、途中の調査ログ的なセクションは読み飛ばすとよいです。

筆者は cdk.context.json をどうしているか

最初に、この記事を書く発端となった slido でのご質問に対する CDK ユーザーとしての 私の立場を書いておきます。

冒頭に書いたように、今私が手掛けているプロジェクトでは cdk.context.json をコミットしていません。デプロイは CodePipeline + CodeBuild で構成されたパイプラインにまかせており、当該ファイルが生成されても破棄する実装になっています。意図的にそうしているわけじゃなく、単に何もケアしてないから結果的にそうなってる、というだけなんですが・・・。ただ、現状はそれで大きな支障はなく、今後も当面はコミットする必要はないと考えています。

一応、コミット不要と判断する理由はあります。以下のような状況を踏まえて、実務に大きな支障がないと考えたためです。

  • そもそも、今扱っている CDK プロジェクトを cdk synth しても cdk.context.json ファイルは生成されない
    • cdk.context.json ファイルは synth 時に無条件に生成されるわけではない(後述の「わかったことまとめ」にも書きます)
  • fromLookup 系の機能を使った実装を含んでいない
    • 例えば「最新のAMI」のような、CDK プロジェクトの外部の環境に依存して変動しうる値がない
    • 詳細な条件はまだ未調査だが、このようなケースでは cdk.context.json が生成されない場合がある
  • 1プロジェクトあたりの CDK App の規模がさほど大きくない
  • デプロイ頻度がそれほど高くない
  • 本番へのデプロイ時間が延びたとしても、実務上そこまで困らない
  • デプロイの設定値を管理する手段として Context を使っていない
    • デプロイ先の環境を指定するキーとしての利用のみ (ex: cdk deploy -c env=prod)
    • 実際の設定値は parameter.ts のような別ファイルに環境ごとに定義し、Context のキーで参照先を切り替え
  • main マージする前の CI プロセス、およびデプロイ時の前処理に snapshot testing を組み込んでおり、実装者の意図に反した差分が発生した場合はマージやデプロイの実行前に露見する

こういう前提条件なら cdk.context.json の取り扱いをケアしなくても当面は大きな問題にならないだろう、なのでコミットしなくて良い(しても良いけど)、というのが本記事を執筆し始めた時点での私の見解です。

余談1: コミットする以外の cdk.context.json の管理方法

コミットして管理する方法もよいと思いますが、私個人は terraform の tfstate のような運用もアリだと思います。つまり、別のストレージに書き出しておいて、デプロイ時にロードしデプロイ完了後に最新版を書き出す、という管理方法です。ストレージの追加リソースが必要な点はいまいちですが、GitHub Actions のようなサービスにデプロイを任せる実装をしているのなら、この管理方法は割と気楽に実装・廃棄ができるアプローチだと思います。

cdk.context.json は環境ごとの固有のステートを保持したファイルと見なすこともできます。共有リソースとして運用している環境 (ここでは AWS アカウントとリージョンのペアを意図してます)に関する情報を専用の共有ストレージに預けておくアイデアは CDK に限らず割と普通にある話だと思います。

ただ、これはあくまで私個人の意見ですが、いくらプライベートレポジトリであったとしても「環境固有の情報をコミットすること」には忌避感があり、できるだけ避けたいと考えています。

本番で長く稼働するプロダクトであれば、環境ごとの設定値をきっちりバージョン管理しておくことに意義が生じます。だから、原理主義的に「環境固有の情報をコミットするな!」と主張する気はありません。固有環境に関する情報を git で管理しておくことが有用なケースは多いと思います。

ただ、そうしたプロジェクトの特定環境に固有な情報を git で管理するのは必要最小限であった方が望ましかろう、というのが私の感触です。その視点において、cdk.context.json のことは当落線上を行ったり来たりするくらいのポジションとして見ています。

余談2: 最終的な出力の具象値が予想できるように書きたい

個人的には公式ドキュメントで例示されたような「最新のAMI」のようなロジックはデプロイの再現性を妨げる要因になりやすいので、使わずに済むなら使いたくないなぁと考えています。

コードの字面を見たときに、最終的に生成される CloudFormation が予測可能であることを大事にしたいと考えています。今の文脈で言うなら、実行するタイミング次第で結果が変わってしまうという振る舞いを私は「予測不可能」な性質と捉えています。このケースでは、どういう値が出てくるかは「最新のAMI」と回答できますが、具象値は事前に予想できません。

(上記と同じ背景で、必要以上に 凝ったロジックや自前の Construct を作らず平易に書こうぜ、という考え方をしています)

前提事項

この記事では、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

また、一応私の CDK コードベースに対する知識量についても補足しておきます。私は機能改修的な意味でのコントリビューション実績がありません。ただ、挫折はしたものの読み書きに挑戦していた時期はあって、多少なり CDK レポジトリのソースコードは把握している状態からのスタートになります。そのときの経験から、内部的なディレクトリ構造はふわっと把握しています。

内部構造に関係する話として、以下のような要素を認識していました。

  • packages/ 以下
    • aws-cdk は CLI の実装
    • aws-cdk-lib は CDK コア部分 + 各種 AWS サービスの L2 実装などなど
  • Cloud Assembly という形式があり、synth の成果物にあたる

Cloud Assembly の説明は以下のドキュメントを参照ください。

https://docs.aws.amazon.com/cdk/api/v2/docs/cloud-assembly-schema-readme.html

冒頭に以下のような記載があり、synth の成果物の形式やぞと説明されています。

The Cloud Assembly is the output of the synthesis operation.

やったことを雑多に書く - part1

やった順でセクションを区切っていきます。

意味のなかった寄り道脇道もあるのですが、そういうものは省いたり省かなかったりしています。

また、深く読めなかった/読まなかった箇所もあるため、100%の正確性は保証しかねます。そのへんは免責事項としてご承知いただけると。

情報量として意味ありそうな部分だけ読むなら「わかったことまとめ」までジャンプしてください。

概念のおさらいのためにドキュメントを読む

https://docs.aws.amazon.com/cdk/v2/guide/context.html

特に、 cdk.context.json があって嬉しいケースの説明として述べられている AMI の話を確認しました。

コンテキストキャッシュのない次のシナリオを想像してみてください。Amazon EC2 インスタンスの AMI として「最新の Amazon Linux」を指定し、この AMI の新しいバージョンがリリースされたとします。次に、次に CDK スタックをデプロイするときに、既にデプロイされているインスタンスが古い (「間違った」) AMI を使用しているため、アップグレードする必要があります。アップグレードすると、既存のすべてのインスタンスが新しいインスタンスに置き換えられ、予期しない望ましくない可能性があります。

代わりに、CDK はアカウントの使用可能な AMIs をプロジェクトの cdk.context.json ファイルに記録し、保存された値を将来の合成オペレーションに使用します。これにより、AMIs のリストは変更の潜在的なソースではなくなります。また、スタックが常に同じ AWS CloudFormation テンプレートに合成されるようにすることもできます。

なんとなく自分の観測事例を思い出してみる

自分が利用した範囲では cdk.context.json にどんな値が書き込まれていたっけ?と記憶を掘り起こしてみました。で、思い出したのは AZ の情報でした。

観測事例のコードを掘り起こして確認してみると、VPC を L2 で作っている CDK App でした。

const vpc = new ec2.Vpc(this, 'Vpc', {
  maxAzs: 2,
  subnetConfiguration: [
    {
      cidrMask: 24,
      name: 'Public',
      subnetType: ec2.SubnetType.PUBLIC,
    },
    {
      cidrMask: 24,
      name: 'Private',
      subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
    }
  ],
});

これを2つのアカウントの ap-northeast-1 にデプロイした結果、次のような cdk.context.json が出来上がりました。

// cdk.context.json
{
  "availability-zones:account=000011112222:region=ap-northeast-1": [
    "ap-northeast-1a",
    "ap-northeast-1c",
    "ap-northeast-1d"
  ],
  "availability-zones:account=111122223333:region=ap-northeast-1": [
    "ap-northeast-1a",
    "ap-northeast-1c",
    "ap-northeast-1d"
  ]
}

この時点では、まだ AZ の情報がここにあることで何がどうありがたいのかハッキリとは分かっていません。

ただ、ドキュメントが例示している AMI の話から推察するに、おそらく同じ事情なんだろうとぼんやり予想しました。(稀なケースとはいえ、AZ の縮退・廃止計画は実際にあります)。

例示の AMI のケースでは、「最新の」という条件がネックでした。synth の実行時に動的に決定される値がある、ということです。

これを VPC の話に置き換えると、動的な要素というのは AZ の解決であろうと推察ができます。[1]

上記の VPC 定義を持つ CDK App を実際に synth して、出力のテンプレートの Subnet を確認してみました。すると、AZ のプロパティは synth 時に解決され、最終的には実在する AZ の値が入っていることがわかります。

// cdk.out/XxxStack.template.json
{
  // ...
  "VpcPublicSubnet1Subnet5C2D37C4": {
   "Type": "AWS::EC2::Subnet",
   "Properties": {
        // ↓ VPC(L2) で明示的に AZ を指定してないが、synth の成果物には具象値が入っている
    "AvailabilityZone": "ap-northeast-1a",
    "CidrBlock": "10.0.0.0/24",
    "MapPublicIpOnLaunch": true,
    // ...
  }
  // ...
}

Lookup 系のメソッドにしてもそうですが、こうした挙動から cdk synth はその実行過程でなんらかの「動的な値」を具象値に解決するためのロジックを含むらしいとわかります(ついでに、synth が内部的に AWS SDK を利用しているらしいことも推察できます)。

なんとなく cdk.context.json のイメージが持てたので、実装を見ていきます。

実装を追いかける(1) - cdk.context.json への書き込みを特定する

cdk.context.json は CDK 側が仕様として規定している値なので、それを定義する定数があるはずです。まずはそこを追いかけてみました。

VS Code の全文検索 (Cmd + Shift + F) で 'cdk.context.json' を探してみます。ノイズになりそうなものを除外できるように include/exclude はよしなに設定してください。例えば拡張子やテスト関係のパスを除外する、などです。検索した結果、packages/aws-cdk/lib/settings.ts に定義がありました。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/settings.ts#L11

packages/aws-cdk は CLI 関係なので、そのモジュールの settings なら CLI の設定情報なんだろう、と推察しました。次はこの変数を追いかけます。

追体験のしやすさを加味して、雑に grep で引っ掛けてみた結果を貼っておきます。余計なファイルタイプやパス、NEW_PROJECT_CONTEXT というノイズな定数がありましたので、これらを除外しています。

$ find packages -type f -name "*.ts" | grep -v '.d.ts' | grep -v 'aws-cdk/test/' | xargs grep -n 'PROJECT_CONTEXT' | grep -v NEW_PROJECT_CONTEXT
grep: packages/@aws-cdk-testing/framework-integ/test/aws-lambda-nodejs/test/whitespace: No such file or directory
grep: path/shim.ts: No such file or directory
packages/aws-cdk/lib/settings.ts:11:export const PROJECT_CONTEXT = 'cdk.context.json';
packages/aws-cdk/lib/settings.ts:115:    this._projectContext = await loadAndLog(PROJECT_CONTEXT);
packages/aws-cdk/lib/settings.ts:154:    await this.projectContext.save(PROJECT_CONTEXT);
packages/aws-cdk/lib/commands/context.ts:6:import { Context, PROJECT_CONFIG, PROJECT_CONTEXT, USER_DEFAULTS } from '../settings';
packages/aws-cdk/lib/commands/context.ts:75:    error('Only context values specified in %s can be reset through the CLI', chalk.blue(PROJECT_CONTEXT));

なんとなく、このファイル名を扱っている場所が絞れてきました。

  • packages/aws-cdk/lib/settings.ts
  • packages/aws-cdk/lib/commands/context.ts

このうち、 packages/aws-cdk/lib/commands/context.ts はパス名から推察するにおそらく CLI の cdk context 関係でしょう。今興味があるのは cdk synth なので読み飛ばします。

ここまでで、私が気にすべきファイルは packages/aws-cdk/lib/settings.ts ぐらいなのではないか?という推測を得ました。このファイルないで実際に PROJECT_CONTEXT を使っているのは Configuration クラスの saveContext/load メソッドの2箇所だけです。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/settings.ts#L109-L146
https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/settings.ts#L109-L146

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/settings.ts#L151-L158
https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/settings.ts#L151-L158

synth した時に色々計算された結果がここの saveContext メソッドで書き込まれているんだろうな・・・と推測しました。このメソッドの実質的な処理は、同モジュール内に定義されている Settings クラスの save メソッドです。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/settings.ts#L375-L379
https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/settings.ts#L375-L379

ここまでで、最終的に cdk.context.json への読み書きを直接行っているらしいクラスとそのメソッドが割り出せました。

次は cdk synth の実装を追いかけてみます。その際、このへんの語彙が登場したら注意して眺めてみよう、と覚えておくことにします。

実装を追いかける(2) - CLI の synth のロジックが呼ばれるまで

CLI のソースを頭から見ていきます。

CLI のパッケージは packages/aws-cdk/ にあります。JavaScript/Node.js の慣習として、実行可能なものは bin/ においておくことが多いです。なので、CLI のエントリポイントらしきファイルはpackages/aws-cdk/bin/cdk.ts ということになります。ここから掘っていきます。

(CLI のサブコマンドやら引数やらを構築している部分など、さほど興味がない箇所の解説は適宜飛ばします)

CLI としてのハンドリングを行っている部分は packages/aws-cdk/lib/cli.ts です。

exec() 関数がいわゆるエントリポイント的なロジックです。内部関数に main() という関数が存在し、これを exec の中で実行しています。CLI ツールとしての実装の本体はおそらくここでしょう。

同モジュールの main 関数を呼び出す手前の処理を見てみると、Configuration クラスの load メソッドを実行している箇所が見つかります。

https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk/lib/cli.ts#L360-L366
https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk/lib/cli.ts#L360-L366

cdk.context.json のローカルキャッシュとしての役目を、ここで見出すことができます。これによって、cdk.context.json の情報を使って Configuration が更新されます。これが後の synth 処理で生きてくるという話なんだろうと推察し、次に進みます。

ローカルキャッシュとしての cdk.context.json の読み出し処理を特定できたところで、次は main の中を眺めてみます。関数内の switch 文で "synth" コマンドの分岐が記述されています。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/cli.ts#L659-L666
https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/cli.ts#L659-L666

どうやら CdkToolkit というクラスの synth メソッドを見れば良いようです。

CdkToolkit というクラスのインスタンス生成は上記コードの少し手前に書かれています。CloudExecutable クラスと cdk.context.json の書き込みに関係する Configuration クラスのインスタンスを渡していることが見て取れます。深堀りするうえで関係しそうなので、このことをうっすら覚えておきます。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/cli.ts#L480-L488

CLI の synth メソッドを読み進めつつ、同クラスのこれらのメンバー変数に触っている部分に注意して掘り進めていくことにします。

実装を追いかける(3) - CLI の synth メソッドを読む

file: packages/aws-cdk/lib/cdk-toolkit.ts

CdkToolkit.synth() メソッド単体では大した行数がありません。どうやら重要そうなのは最初の selectStacksForDiff() メソッドくらいのようです。そして、 selectStacksForDiff() の実体もまだ大した行数はありません。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/cdk-toolkit.ts#L872-L889

Validate 関係はざっと見た感じ本当にただ出力結果のチェックをしているだけのようです。この場で興味があるのは assembly を扱う場所だけだと決め打ちしてさらに掘り続けると、CloudAssembly クラスの doSynthesize メソッドにたどり着きました。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/api/cxapp/cloud-executable.ts#L70-L124
https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/api/cxapp/cloud-executable.ts#L70-L124

なんだか謎に while (true) が出現しました。synth の実質的な処理をループで処理する...???

while の上に書いてあるコメントがこの謎 while の意味を解読するヒントになりそうです。

// **We may need to run the cloud executable multiple times in order to satisfy all missing context**
// (When the executable runs, it will tell us about context it wants to use
// but it missing. We'll then look up the context and run the executable again, and
// again, until it doesn't complain anymore or we've stopped making progress).

1回の synthesize では、context の値がすべて解決できない可能性があるようです。すべての context が解決するまで何回も synthesize を実行する必要があるということです。ここでいう "Context" というのは、広義の「コンテキスト」を指している可能性もあるかもしれませんが、おそらくは我々が CDK を普段遣いするうえでお世話になる Context のことでしょう。

k.goto さんの記事でも言及されていた 「合成(synthesize)が 2回(複数回) 走る仕組みになっている」 という言葉の意味が、ここで繋がります。

さて、ループ内の処理を見ていきます。ループ全体の構造を見ると、以下のことに気づきます。

  • ループの末尾に return があり、順当にここに着くなら1回で抜けられる
  • ループを継続 (continue) する条件が1箇所だけ存在する

ループ継続 (continue) は2つの if 文に当てはまる場合にのみ分岐するようです。それぞれの変数名や前後の処理から解釈すると、おおよそ現在のループで実行した synthesize の結果に "missing context" が発生した場合、ということになるでしょう。"missing context" という用語の詳細な解釈は、いったん今は置いておきます。

while の中で実際に continue しているのが次のブロックになります。

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;
}

saveContext という見覚えのあるメソッドが出てきました。要するに、ここまでの情報をもって、私たちが実装した CDK App の中に "missing context" を生じるような内容があった場合は synth が2回以上走る、と言えそうです。

では、"missing context" とやらは一体なんなのか、どうしてそんなものが発生するのかを見ていきます。

CloudExecutable の "props.synthesizer" を追う

"missing context" なるモノの正体を追うためには、 "assembly" とそれを生成したロジックの中身を追う必要があります。

doSynthesize の中にある以下の記述を掘り進めれば、おそらく内部的には Stack synthesize を記述した場所にたどり着くはずであろうと推察しました。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/api/cxapp/cloud-executable.ts#L77-L80

このクラス (CloudExecutable) の props.synthesizer というプロパティ(関数型)を呼び出していて、この関数が assembly を生成しています。

https://github.com/aws/aws-cdk/blob/v2.150.0/packages/aws-cdk/lib/api/cxapp/cloud-executable.ts#L11-L14

具象値ではないため、具体的な実装への定義ジャンプはできません。どこかで実装に当たる(値としての)関数を指定しているはずなので、それを特定します。

処理を遡っていくと、CdkToolkit クラスをインスタンス化する際に CloudExecutable クラスののインスタンスを与えている場所がありました(該当箇所はすでに「実装を追いかける(2)」の末尾で紹介しています)。packages/aws-cdk/lib/cli.ts の exec 関数の実装まで戻ります。

さらにもう少したどってみると、実質的に assembly を生成している関数は以下の execProgram() 関数であるらしいとわかります。

https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk/lib/api/cxapp/exec.ts#L22-L127
https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk/lib/api/cxapp/exec.ts#L22-L127

CloudExecutable クラスの doSynthsize では、この関数の戻り値を assembly という変数に格納して、assembly.manifest.missing というプロパティを参照して、ループの継続条件の判定に利用していることになります。これを念頭に起きつつ読み進めます。

execProgram 関数はサブプロセスを作って実行しています。ここでは、私たちが synth や deploy の際に指定する "app" を実行しているようだとわかります。

「"app" を実行している」と言われてもイメージしづらいかもしれませんが、これは普段私たちが cdk synthcdk deploy を行うときに実行している、以下のようなコマンドのことです。

// 例
$ npx cdk -a "npx ts-node bin/app.ts"

-a--app の短縮形です。人によってはこのオプションを意識する機会はないかもしれませんが、CDK 的には App の指定は必須です。これは、 CDK CLI の init コマンドが生成する cdk.json の雛形に app のデフォルト値が指定されている からです。つまり、同一プロジェクトで複数の CDK App を扱う必要がない場合は、CDK ユーザーはこのオプションを意識する必要がないのです。

// "cdk init" によって生成される cdk.json の中身
{
  // ↓synth や deploy などで app 引数を指定しなかった場合に採用されるデフォルト値がこれ。
  "app": "npx ts-node --prefer-ts-exts bin/my-app.ts",
  "watch": {
    // ...
  },
  "context": {
    "@aws-cdk/aws-lambda:recognizeLayerVersion": true,
    // ...
  },
  // ...
}

見てわかるように、CDK の App クラスを呼び出しているモジュールを ts-node で実行しています。

結局、 "assembly" そして "missing context" を発生させる原因は CDK App の「実行」であることがわかりました。次は、このプログラムの実行によって何が起きるのかを掘り下げていきます。

わかったことまとめ - part1

CLI としての synth が何をしているかが、だいたい見えました。サブプロセスを実行している部分が実質的な "synth" 処理なんだろうということまでは判明しました。ただ、ここから先は cdk synth コマンドが実行されているプロセスの外の世界です。TypeScript という単一言語の中で見ても呼び出し関係が切れていますので、いったんここまでで話を区切ることにします。

ローカルキャッシュとしての cdk.context.json の扱いが、ここまででなんとなくわかりました。

ここまでの成果として抽出できそうな話は、だいたい以下のようなところでしょう。

  • synth コマンドのコアである「合成(synthesize)」の処理は、複数回実行される可能性がある
  • 「合成」を実行する前にローカルキャッシュ (cdk.context.json) をロードしている
  • 「合成」の結果、"missing context" なるモノが発生してしまった場合に複数回の「合成」が実行される
  • 「合成」が複数回実行される条件分岐の中で、ローカルキャッシュ (cdk.context.json) への書き出しが行われている

どうやら、ローカルキャッシュ (cdk.context.json) の存在が "missing context" の発生有無に影響しているらしい、ということが推察できそうです。私はすでにローカルキャッシュという文脈があることを理解していますので、 cdk.context.json は「合成」処理で必要とされる、なんらかの「未解決の値」の解決を助けるための情報源としての役割があるらしい、ということが推理できます。

ここまでのコードリーディングの成果を CDK ユーザー向けの言葉で言い換えると、デプロイ (あるいは synth) 実行時に生成される cdk.context.json をそのまま次回デプロイ以降にも残しておけば、「synth が複数回走ってしまう」ケースを抑制できる可能性がある、と推測できそうです。

本記事のまとめ

ここから先は CDK App の synth メソッドを眺めていくことになります。が、しかしすでに超長文なので別記事にします。続編は以下。

https://zenn.dev/hassaku63/articles/3bf6d3a02481a8

ところで、冒頭でこの記事に次のようなモチベがあることを述べました。

これまであまり OSS のコードを読むということをしたことがないエンジニアの人に向けて、他人(私)がコードリーディングする様子を見てもらえば多少は参考になるかな?と目論んでいる

このテーマについて少しフォローして、part1 を締めようと思います。

CDK のコードを読んで得られた、我流コードリーディングのコツ

私自身まださほど「読める」側じゃないので、話半分に見ていただけたら。

私なりの経験から何かアドバイスするとしたら、ある程度大きなコードベースを読む場合のコツは「まずはドキュメントを見る」こと、そして「いかに読まないか」であるように思います。

1点目の話は、まずはそのツールが扱う問題領域の知識を得ようという趣旨です。まぁ、ここは直感的に同意しやすい話かと思います。コードベースにはモジュール階層やクラス、関数、変数など、様々な場所で「名前」が登場するわけですが、事前にドキュメントなどでおさらいしておくことでコードリーディング中の脳内マップの構築が捗ります。

2点目の話は、迷子にならないための考え方です。「調べ物の最中に別の不明点が出てそちらの調べ物に突入してしまう(以降無限ループ)」みたいな話です。

例えばモジュールのディレクトリ構造やファイル名、変数名や関数名などから情報を推測して、その推測をもとに仮説を立てます。現在の読解テーマに対してさほど重要じゃなさそうなら該当箇所はいったん読み飛ばしますし、他にも前後関係含めて全体像を把握したい場面であれば目の前の実装はある程度情報を読み取ったらその時点の「推測」を仮置きしておいてとりあえず続きを読み進めるようにしています。私の感覚では、ある程度大きなコードベースではこの読み方ができないとしんどいです。

この記事でやってきたコードリーディングにおける具体例を挙げると、例えば以下のようなことです。

  • CloudExecutable に関する知識は、コードを見ずともドキュメントの情報である程度推測できた
  • packages/ 以下のディレクトリ構造や CLI が提供するサブコマンドの知識を使って、コードを全文検索した結果を読み手の判断で絞り込むことができた
  • packages/ 以下のディレクトリ構造の知識と JavaScript/Node.js の慣習的なディレクトリ構造の知識から、CLI 実装のエントリポイントとなるファイルを特定した

このように、目の前にある実装以外の知識を動員したり、今読解したいテーマに沿って取捨選択して(読んでる最中は案外忘れがちです)、そのコードを詳しく読む/ざっくり読む/読み飛ばす、どのくらい濃く接すると良さそうか判断していました。

読み飛ばした場所は、先々で必要になれば適宜思い出して戻ってくるようにしています。私自身は一度に多くのことを記憶しておくのがあまり得意ではないので、このようにして「できるだけ読まずに先に進む」作戦でコードを読むようにしています。

...

さて、ここまではあくまで「読む場合」に限定した話でしたが、これは実務としてコードの読み書きをする際にも大いに役立ちます。特に2点目の話が大事です。

少ない文字数で読み手に確度の高い「推測」を与えられるなら、おそらくそのコードは読みやすく理解しやすい場合が多いでしょう。良いコードを読み、そのコードを読むときに自分がどのような情報をもとにコードの振る舞いや各機能の責務を理解したのか、それを覚えておいてください。自分がコードを書くとき、もしくは社内のコードをレビューするとき、一体何が自分たちのコードをわかりやすい/わかりづらいものにさせているかが、少しだけわかるようになるはずです。そして、それを適正に判断するには読む側に立つご自身にもある程度「読み方」の型が身についている必要があります。

コードの読み書きは文章能力に喩えられることが多いかと思いますが、感覚的には私も似ていると思います。良いコードを書く能力を身につけたいなら、良いコードと悪いコードを隔てなくたくさん読み、体験することが大事です。よほどソフトウェア開発に熟達した組織でもなければ、社内のコードベースだけで「良いコード」に触れる機会は限定的でしょう。だからこそ、世の中の OSS を読んでみましょう。自社で使っている OSS ツールに興味を持つのも良いでしょう。ところで、AWS CDK っていうプロダクトが割とおすすめですよ。

脚注
  1. VPC L2 の典型的な使い方では、ユーザーは具体的に AZ の値を指定する必要がありません。しかし、CloudFormation 的にはどの AZ なのかを具体的に指定する必要があります(例えば AWS::EC2::Subnet)。CloudFormation 的には組み込み関数を使って AZ のハードコードを避ける実装も可能ですが、いったんそれは置いておきます ↩︎

Discussion