maybe daily dev notes

私の開発日誌

CDK Tips: cdk synthを高速化する

AWS CDK Tipsシリーズの記事です。

CDKアプリケーションの開発運用が成熟するにつれ、CDKの合成処理 (synthesize)が遅く感じることがあります。 合成処理はCDKのデプロイやdiffのたびに走るため、速ければ速いほど嬉しいものです。

この記事では、合成処理を高速化するための方法をいくつか紹介します*1

1. ts-nodeの型チェックを無効化する

CDKをTypeScriptで記述している場合、実行時の型チェックを無効化することで処理が高速化する場合があります。 このためには、cdk.json を開き、次の設定変更をします ( --transpileOnly オプションを追加):

-   "app": "npx ts-node --prefer-ts-exts bin/cdk.ts",
+   "app": "npx ts-node --transpileOnly --prefer-ts-exts bin/cdk.ts"

アプリの構成によりますが、合成処理が50%以上高速化する例もあるようです。

注意点としては、型チェックが無効化されることで、型エラーが無視されるようになります。実行時エラーになればまだ良いですが、意図しない形で動いてしまうと危険です。このため、エディター上で型チェックをしたり、次のコマンドで適宜 (CIなど) 検証するのも良いでしょう:

npx tsc --noEmit

それ以外は特にデメリットもないので、試してみると良いと思います。

2. 不要なスタックを合成しない

複数の環境 (dev/prodなど) に向けたスタックを bin/hoge.ts に静的に記述している場合 *2cdk synth/deploy など実行時に全スタックの合成処理が走ります。このため、合成時間がスタック数に比例して増加することがあります。

// bin/hoge.ts
const app = new cdk.App();

new CdkStack(app, 'ProdStack', {
  // ...
});

new CdkStack(app, 'DevStack', {
  // ...
});

シングルスタックではなく環境ごとに複数のスタックがある場合は、なおさらです。私も以前環境数 (5) x 環境あたりのスタック数 (10) = 50スタックなどを1アプリ内に作ったことがあります。ここまで増えると、合成するだけでも数分を超える長い時間がかかるものです。

この問題を回避・抑制するためにはいくつかの方法があります。

2.1. App内で条件分岐する

何らかの条件で分岐して、Appに含めるスタックを制御します。例えば以下のコードを使えば、

const app = new cdk.App();

if (process.env.PROD === 'true') {
  new CdkStack(app, 'ProdStack', {
    // ...
  });
}

new CdkStack(app, 'DevStack', {
  // ...
});

環境変数 PROD によりスタックを合成対象に含めるか否か制御できます。

# Devのみ合成
npx cdk synth

# Prodも含めて合成
PROD=true npx cdk synth

注意点としては、依存関係にあるスタックを除去した場合、依存されている側の合成結果が変化することもありえます。このため、含めた場合と含めない場合とで、 スタックの合成結果が同一であることを確認してください。スタック同士が独立している場合は大抵問題ありません。cdk diff などでデプロイ済みのテンプレートと比較するのが手っ取り早いでしょう。

2.2. CDK Appを複数定義する

CDKは通常 cdk.json に定義された app プロパティを見て、実行するAppを決定します。

// cdk.json (cdk init --language=typescript の生成後)
// bin/cdk.ts がAppの定義ファイルとして参照される
{
  "app": "npx ts-node --prefer-ts-exts bin/cdk.ts",

実行するAppは、CDK CLI-a オプションで変更できます:

# cdk.json に記述された app を使う
npx cdk synth

# bin/cdk-prod.ts を使う
npx cdk synth -a "npx ts-node --prefer-ts-exts bin/cdk-prod.ts"

上の例では、cdk-prod.ts に新しくAppを定義し、Prod用のスタックをその中に移動することを想定しています。 コマンドは冗長になりますが、CI/CDなど自動化された環境で使う分には問題にならないでしょう。

注意点としては、異なるApp間でリソースを参照し合うのは困難なため、独立したスタックごとにAppを分けるのが良いでしょう。 依存し合うスタックを異なるAppに配置するのは、CDKの透過的なリソース間参照を使えなくなり不便なため、個人的にはおすすめしません。

なお、AppはあくまでもCDKの世界における論理的な概念です。AWSリソース上は、スタック名が同じであればAppに関わらず同じものとみなされるので、ある程度気軽に試せる変更です。また、方法1との実質的な差はあまりないので、好きな方を選べば良いと思います。

2.3. --exclusively を使う

CDK CLIには --exclusively というオプションがあり、これはある一つのスタックだけ (依存関係を無視) をデプロイするときなどに使えます。

# StackAとStackB が存在し、StackBがStackAに依存している状況を考える

# StackA → StackBの順にデプロイされる
npx cdk deploy StackB

# StackBだけがデプロイされる
npx cdk deploy --exclusively StackB

実はこのオプションには副次的な効果があり、指定された以外のスタックについては、アセットバンドル処理がスキップされます。 バンドル処理の詳細は後述します。

アセットのバンドルは合成処理の一部ですが、CDKアプリによっては合成処理にかかる時間の大半を占める場合があります。つまり、合成処理を大きく短縮できる可能性があります。

exclusively の場合でもglobパターンが使えるため、複数スタックのデプロイは可能です。このとき選択されたスタックたちは、通常通り依存関係を考慮した順番でデプロイされます。

# 名前がProdから始まるスタックのみデプロイ
npx cdk deploy --exclusively Prod\*

# もちろんdiffもできる
npx cdk diff --exclusively Prod\*

方法1や2と比べると、コードの変更もなく使えるので、手軽な選択肢です 。ただしスキップされるのはバンドル処理のみでその他の合成処理は実行されるため、効果は限定的な場合もあると思われます。実際のアプリで効果の程度を確認するのが良いでしょう。

また、意図せず依存関係を無視したデプロイになっていないよう、デプロイされるスタックを cdk diff などでよく確認してください。

3. バンドル処理を減らす

先程言及したバンドル処理について、もう少し深掘ってみましょう。1や2と比べると仕組みがやや複雑で難しい方法になります。

前提知識

そもそも合成処理とは

ここまで合成処理とはなにか明確に定義していませんでした。CDKにおける合成処理は、主に次の2つに大別して考えることができます *3:

  1. アセットのバンドル (後述。NodejsFunctionやPythonFunctionの依存関係解決やビルドなど)
  2. その他の処理 (CFnテンプレートの生成、バンドル不要なアセットの処理など)

1の処理は cdk synth/diff/deploy/watch など、バンドルが必要なコマンドでしか実行されません。2の処理はそれらに加えて、cdk bootstrap/ls/destroy などでも実行されるものとなります。

1の処理がどの程度支配的かは、以下の方法でざっくりと計測できます:

# 2にかかる時間を計測
time npx cdk ls

# 1+2にかかる時間を計測
time npx cdk synth

この時間の差が1にかかる時間の概算値です。これが長い場合は、バンドル処理を見直すことで高速化できる可能性があります。

バンドル処理の基本的な流れ

CDKでは昔からバンドル処理周りを効率化する努力が重ねられてきました。例えばこちらのIssueなどを追ってみると良いでしょう。

基本的には NodejsFunction などのコンストラクトが裏側で良い感じにやってくれて、ユーザーとしてはこの辺りを気にしなくて良いのが望ましいところです。 とはいえ現状そこまで完璧に隠蔽されてない印象で、実際このバンドル処理の仕組みをよく理解することで、合成の速度を最適化できる可能性があります。

さて、バンドル処理*4は概ね下記の流れで実行されます:

  1. (コンテナ上でビルドする場合 ) ビルド環境として使うコンテナイメージをビルド (これとかこれ) *5
  2. CDKアプリ内のアセットの一意性を計算し、重複を排除。同じアセットを複数回バンドルする無駄を防ぐため。
  3. アセットの入力ファイルから、アセットハッシュを計算
  4. ( AssetHashType == SOURCE の場合 ) 同アセットハッシュ値のアセットが cdk.out ディレクトリにあれば、バンドル不要なので終了。なければ次に進む。
  5. バンドル処理を実行 (依存関係のインストールやバンドル、ビルド、トランスパイルなど)
  6. ( AssetHashType == OUTPUT の場合 ) アセットの出力ファイルから、アセットハッシュを計算
  7. アセットハッシュをディレクトリ名に含むアセットを cdk.out ディレクトリに保存 *6

コンストラクトにより細かな違いはありますが、基本的にはこの流れです*7。複数のキャッシュレイヤーにより、無駄な計算を回避したいことがわかります。

この流れを理解していれば、CDKを使っていてふと抱きがちな次の疑問に答えられます。ぜひ考えてみてください:

  • (NodejsFunctionを使う場合) public.ecr.aws/sam/build-nodejs18.x のビルドは何度も走るのに、esbuildのログはそれより少ないのはなぜか
  • (PythonFunctionを使う場合) public.ecr.aws/sam/build-python3.11 のビルドは走るのに、pip install が実行されないことがあるのはなぜか
  • (NodejsFunctionを使う場合) コードに変更がないのに毎度esbuildが実行されるのはなぜか
  • (ECSを使う場合) 複数のコンテナを使っても、同じDockerfileならビルドが一度しか走らないのはなぜか

3.1. アセットハッシュを意識する

アセットハッシュを意識することで、上記キャッシュの効果を向上させられる可能性があります。

AssetHashType はアセットハッシュの計算ロジックを変更するためのパラメータです。詳細はドキュメントを見ると分かりますが、 SOURCEはバンドル処理実行前でも計算でき、 OUTPUTはバンドル処理実行後にのみ計算できることがポイントです。つまり、 AssetHashType == OUTPUT のアセットは入力に変更がなくても必ずバンドル処理を実行されるため、速度観点では好ましくありません。

例えば NodejsFunction や PythonFunction では以下のとおりです :

  • PythonFunction: デフォルト=SOURCE。ハッシュタイプ・ハッシュ値ともに自由に変更可能。
  • NodejsFunction: デフォルト=OUTPUT。これは隠蔽されており変更不可。ただし assetHash オプションで任意のハッシュ文字列を指定可能。

PythonFunction はコードに変化がなければバンドルも走らないのがデフォルトの挙動であり、理想的です。一応、入力のファイルに毎度中身が変わるようなものが含まれていないことに注意しましょう。

NodejsFunction はそうなっておらず、入力のファイルが変わってなくても必ずバンドルが走ります。バンドル処理の時間が十分短い場合は良いのですが、大きなTypeScriptプロジェクトを使っている場合は許容できないかもしれません。改善のためには自分でハッシュ値を適切に計算して渡すか、こちらのissueハッシュ値の計算ロジックが検討されています。

他の用途でS3アセットを使っている場合は、上記の点で改善の余地がないか確認してみると良いでしょう。

3.2. コンテナ上でバンドルしない

バンドル処理は通常コンテナ上で実行されます。これにより、CDK実行環境への依存度が低く、再現性の高いビルドが実現可能です。しかし、コンテナを使う分のオーバーヘッドは無視できません。

NodejsFunctionなら、比較的容易にコンテナ上でバンドル処理を実行しないように変更でき、バンドル処理が速くなります。このためには、esbuild をCDKのpackage.jsonに追加してください。自動的に検知され、Dockerを使わないビルドに切り替わります。

# CDKのルートディレクトリで実行
npm i -D esbuild

2.3のexclusively まで組み合わせると、バンドル不要なアセットは実質無視される (最初のコンテナビルドがなくなる) ため、なかなか速くなります。

ただし、ネイティブのバイナリを含むようなnpmパッケージ (prismaなど) が含まれる場合は注意しましょう。LambdaとCDK実行環境とでバイナリのターゲットOSやアーキテクチャが一致する必要があるためです。クロスプラットフォームな方法もありますが、あまり気にしたくない場合はコンテナ上でビルドするのが楽でしょう。

コンテナビルドに戻したい場合は、 npm uninstall esbuild するか、 forceDockerBundlingtrue に設定します。

PythonFunctionでも同様の機能が提案はされているようです: #18290

3.3. DockerImageFunctionを使う

この記事にも書きましたが、DockerImageFunction なら自分で専用のDockerfileを書けるので、キャッシュ観点で最適なビルドを容易に実現可能です。

例えば npm install などで依存関係をインストールした結果をキャッシュすることも可能になります。一つの実装例はこちら

また、コンテナイメージ(ECR)アセットはS3アセットとは挙動が異なり、最低限必要な場合のみビルド・パブリッシュされます (cdk deploy 実行時かつデプロイ対象スタックで必要なイメージのみ)。このため上記で挙げた --exclusively の利用やAppの分離などを考えずに済むのも良い点かもしれません。

NodejsFunctionなどからの移行はやや大変ですが、試してみても良いでしょう。

3.4. CDKの外でバンドルする

NodejsFunction や PythonFunction は簡単に使えて非常に便利なコンストラクトですが、抽象度が高い分、ユースケースによっては上記のような問題が発生する場合もあります。

バンドル処理は自分で実装し、CDKからは素のFunctionとして定義するのも一つの手でしょう。これならCDKはバンドル処理を実行しない形となるので、パフォーマンス上は理想的です。

# Lambdaのバンドル
npm run bundle

# CDKはバンドルしたファイルをそのまま使ってデプロイ
npx cdk deploy

同様のアプローチで50倍程度の高速化を実現した例もあるようです。実装はやや手間ですが、最適化の一手段として覚えておくと良いでしょう。

その他

その他、関連する豆知識です。

cdk deploy のオプション

cdk deploy コマンドには次のオプションが存在します:

  • --concurrency [number] [default: 1]: スタックをデプロイする際の並列度を指定します。独立したスタックが複数ある場合は効果的でしょう。
  • --asset-parallelism [boolean] [default: true]: アセットをS3/ECRにアップロードする際の並列度を指定します。既定で8なので、あまり気にしなくて良いでしょう。

これらは合成というよりはデプロイに関わるパラメータなのですが、速度改善の一環で紹介しました。特に --concurrency は活躍する場面が稀によくありそうです。

まとめ

CDKの合成処理を速くするための方法をいくつか紹介しました。これもあるよ!ここ違うかもよ!などあればぜひ教えてください。

*1:今回はCDK Pipelinesを使わない構成を想定しています。Stageの中にスタックがある場合は少し話が変わるようなのですが、調べきれてないため。

*2:このスライドにいくつかのよくあるパターンをまとめています。今回はstaticパターンの話。

*3:厳密にはスタックのバリデーションをprintするなど細かな処理はありますが、処理時間への影響は軽微と思われるため、無視します。

*4:実際は一連の流れをアセットのステージングと呼ぶようです。その中でも、通常支配的な時間を占めるのはバンドル処理のため、今回は便宜上そのように呼びます。

*5:これを最初にやる意味ある?と疑問に思う方もいると思います。おそらくご指摘の通りで、ここは最適化の余地がある箇所だと思われます。重複排除するまでビルドを遅延しても良いはず。

*6:このファイル名がCFnデプロイ時に参照されるので、バンドル生成物に変更がなければデプロイをスキップできます。

*7:例えばPythonFunction だと exclusively などでバンドルが不要な場合はまるごとスキップされます。