maybe daily dev notes

私の開発日誌

CDK Tips: 自作コンストラクトをPython向けに公開する

AWS CDK Tipsシリーズです。

AWS CDKのコンストラクトライブラリPython向けに公開する手順のメモです。 最近 upsert-slr というライブラリをPyPIにも公開したので、その時のコードを例に紹介します。

モチベーション

最近のサーベイによれば、CDKのPythonユーザーは23%ほどいるようです。

TS/JS + Pythonで97%を超えるため、npmとPyPIで利用できるようにすれば、ほとんどのユーザーをカバーできることになります。

PyPIへのリリース設定は以下の通りそれほど大変ではないため、やっておいて損はないかもしれません。

手順

1. .projenrc.ts の編集

ライブラリの .projenrc.tspublishToPypi プロパティを、以下を参考に指定します。

const project = new awscdk.AwsCdkConstructLibrary({
  // 中略
  publishToPypi: {
    distName: 'upsert-slr',
    module: 'upsert_slr',
  },
});

distName はPyPIにおけるパッケージ名になります。module はインポートするときの名前です。上記の例だと、以下のようになります。

# インストールコマンド
pip install upsert-slr

# Pythonコードからのインポート文
import * from upsert_slr 

命名に関する慣例や制約はこちらが参考になります。PEP 8 – Style Guide for Python Code | peps.python.org

変更したら、projenを実行して変更を反映します。

yarn projen

2. PyPIAPIトークンを取得

まずはPyPIのユーザー登録をしてください。PyPI · The Python Package Index

登録後 Account settings を開くと、APIトークンを作成するパネルに飛べると思います。

作成するとトークン文字列 (pypi-xxxx) が得られるので、コピーしておきます。 もしコピーし忘れたなどでトークン文字列を失ったときは、削除して作り直しましょう。

3. GitHub actionsの環境変数を設定

CDKコンストラクトのProjenでは、Twineというツールを用いてPyPIにアクセスするようです。

このツールを使うため、GitHubリポジトリのSettings -> Security -> Secrets and variablesから以下の変数を作成します。こちらも参考になります。Using secrets in GitHub Actions - GitHub Docs

  • TWINE_USERNAME: __token__
  • TWINE_PASSWORD: 2で生成したAPIトークンの文字列

これにて設定は完了です!

動作確認

動作確認をするため、適当な変更をしてmainブランチにpushしましょう。自動的にPyPIへのリリースプロセスが走るはずです。

作成されたパッケージは、PyPIのアカウント画面などから確認できます。

この時点で、APIトークンの権限範囲として該当のパッケージにしぼり込めるようになります。 セキュリティを更に強化したい場合は、範囲を制限した上でトークンを作り直し、TWINE_PASSWORD 変数を再設定すると良いでしょう。

10分程度待つと、Construct HubもPythonパッケージを認識してくれます。

それでは!


今月のもなちゃんです。今年2回目の産卵を始めました。

AWS Lambda特化のJavaScriptランタイム「LLRT」を紹介

最近にわかに話題沸騰中のJavaScriptランタイム LLRT を紹介する記事です。

github.com

LLRTとは

LLRT (Low Latency Runtime) は、軽量なJavaScriptランタイムです。サーバーサイド向けのJavaScriptランタイムはNode.js、Deno、Bunなどが有名ですが、それらにまた一つ加わった形になります。主にLambdaでの利用が念頭に置かれているようです。その他必要な情報は README.md にまとまっています。以下は抜粋です。

AWSのソリューションアーキテクト Richard Davison さんにより開発されています。リポジトリAWSGitHub organization (awslabs) で公開されているため、実験的ではありますが、AWS公式のプロジェクトと言って良いでしょう。

ここ5日間ほどでとんでもない勢いでGitHubスター数が伸びており、注目度は高いと言えます。このため、今はほぼ一人で開発されているようですが、より多くの投資を受ける可能性も低くはないかもしれません。

awslabsリポジトリ上位6件のGitHubスター履歴。LLRTの注目度が窺える。

実装の詳細

より踏み込んだ部分を見ていきましょう。

内部のJavaScriptエンジンは、QuickJSというC言語で実装されたエンジンが利用されています。これはFabrice Bellardさんが開発する個人プロジェクトで、ES2020/ES2023準拠 (LLRTは現状ES2020のバージョンを利用) の軽量なエンジンのようです。

このQuickJSをRustから呼び出す rquickjs というライブラリを利用し、LLRTがNode.js特有のAPIをRustで独自実装することで、Node.jsとの互換性を高めています。

もちろん完全な互換性があるわけではないため、Node.jsのプログラムがそのままLLRTで動作しない場合も多いです。互換性を示した表がREADMEにあるため、見てみると良いでしょう。今のところは、最低限AWS SDK for JS v3が動作するところまでの互換性を実現しているように見えます。

今後細かな実装は変わりうるので、上記は今のところのスナップショットとして理解してください。現に今も、QuickJSではなくHermesというエンジンを利用すべきではという議論が進んでいるようです (Issue#110)。 また、Node.js互換というよりは、WinterCG互換を目指すという目標もあるようです (Issue#112)。

LLRTの特徴

名前の通り、特徴は軽量で低レイテンシーなことです。 Node.jsなど他のJavaScriptランタイムよりLambdaのコールドスタートが最大で10倍短くなることが謳われています

実際のデータはこちらのサイトが参考になるでしょう: Lambda Cold Starts analysis

C++, Rustに匹敵するコールドスタートの短さです。Goよりも短いですね。

簡単に比率を表にまとめてみました (zip、2024-02-13のデータ)

Avg Cold start duration Avg Memory Avg duration
LLRT 100 % (35.9 ms) 100 % (23.6 MB) 100 % (1.29 ms)
Rust (al2023) 52 % 56 % 115 %
Go (al2023) 137 % 60 % 121 %
Node.js v20 428 % 268 % 704 %
Bun (al2) 944 % 270 % 21308 %

LLRTの強みが見て取れると思います。普段Node.jsばかりLambdaで使っている身としては、コールドスタートが2桁msで終わるのは驚異的です。

なお、Bunのdurationが異常に長いですが、これはコールドスタート時のみの実行時間なので、不利な比較ではあるのかもしれません。

なぜ速いか

LLRTはいかにしてこの性能を実現できたのでしょうか。また、代償として何が失われているでしょうか。 Rationaleの章を読んでみましょう。

Node.js, Bun, Denoとの大きな違いは、LLRTはランタイムにJITコンパイラの機能を持たないことです。これにより、次の2つのメリットを得られました:

  1. 複雑なJITコンパイルの仕組みを排除することで、システムは単純になり、ランタイムのサイズも小さくなります
  2. JITコンパイルのオーバーヘッドがなくなり、CPU・メモリリソースの消費を低減できます

定性的には理解できる話と思います。実際にこれでどの程度数値が改善したのかは、上で見たとおりです。

しかしながら、これによるデメリットもあります。Limitationsの章で議論されていますが、

JITコンパイル自体による性能改善が効くタスク、例えば同じ処理を何度も実行する大規模なデータ処理や数値計算などでは、性能低下が見込まれる

とのことです。記事の後半で議論しますが、何でも置き換えれば良いという話ではなく、使い所を考える必要があります。

その他の高速化のポイントとして、JavaScriptのライブラリ (主にAWS SDK関連; uuid, fast-xml-parserなど) をRustによるネイティブ実装に置き換えるということもされているようです。

LLRTの使い方

LLRTはLambdaのカスタムランタイムとして利用できます。

LambdaでLLRTを利用するには、LambdaのランタイムとしてOS-only runtime (AL2 or AL2023)を指定し、コードのバンドルにLLRTのバイナリ (bootstrapファイル) を含めることが必要です。バイナリはGitHubのリリースからダウンロードできます。

コードは例えば以下のesbuildコマンドでバンドルすると良いようです。 一部のライブラリ (主にAWS SDK関連) はLLRTランタイムに同梱されているため、バンドルに含める必要がありません。

esbuild index.js --platform=node --target=es2020 --format=esm --bundle --minify --external:@aws-sdk --external:@smithy --external:uuid

上記の手間を省くため、LLRTのLambda関数をデプロイするCDKコンストラクトを公開しました。簡単に使えるはずなので、お試しください。

GitHub - tmokmss/cdk-lambda-llrt: Experiment on LLRT easily with CDK

import { LlrtFunction } from 'cdk-lambda-llrt';

const handler = new LlrtFunction(this, 'Handler', {
    entry: 'lambda/index.ts',
});

ちなみに、ローカル環境でも同様にLLRTでJavaScriptを実行できます。

# release https://github.com/awslabs/llrt/releases から対応するバイナリをダウンロードする
wget https://github.com/awslabs/llrt/releases/download/v0.1.7-beta/llrt-darwin-arm64.zip
unzip llrt-darwin-arm64.zip

./llrt -h # help 表示

./llrt -e "console.log('OK')" # ワンライナーを実行

echo "console.log('OK')" > index.js
./llrt index.js #  ファイルから実行

LLRTの使い所

最後に、LLRTの使い所を考察します。

注意: 以降は個人的な感想を多分に含みますし、特に結論もでていません。

まず考慮のポイントは挙げると、以下でしょうか:

  • 制約
    1. (少なくとも現状は)Node.jsとの互換性は限定されたもので、多くのNode.js向けライブラリは動作しない
  • 強み
    1. コールドスタート時間の短さ
    2. 実行速度の速さ
    3. 1, 2によるユーザー体験の向上
    4. 2によるコスト削減効果
  • 弱み
    1. JITコンパイルがないことによる性能低下の可能性
    2. 互換性を検証するコスト

強み4を活かすには、それなりの規模で実行されている関数が良さそうです。さもなくば、弱み2のコストを上回るほどのメリットは享受できないためです。

また、強み3を考えると、エンドユーザーのリクエストに関わるLambda (API Gatewayのプロキシ先など)が良いでしょう。エンドユーザーと関わりのない部分においては、コールドスタート時間が数百ms程度縮んだところでメリットは大きくないためです。

ただし強み2を考えるなら、サーバーレスのイベント連携で生じがちな、イベントを受け渡し・加工・AWS API呼び出しだけのLambdaにも向いているかもしれません。このような基盤部分で実行時間を削減できると、チリツモで大きな効果が得られる場合もあるためです。その場合は、弱み1を意識して、何度も呼び出されたら結局JIT付きランタイムのほうが速くなる可能性も検証したいところです。

制約1は重要で、LLRTを使うにはLambda特化で薄く実装されたコードが向いてそうです。現時点の対応状況を見る限りでは、いっそAWS SDKのみを利用するコードに用途を絞るべきかもしれません。

そうなると、もし昨今流行りのLambdalithのような実装をしている場合は、部分的に切り出す作業が必要そうですね。 どうせ処理を切り出すのであれば、その部分だけ速い言語 (Rustなど) で書き換えるという選択も視野に入るため、

  1. LLRTに載せ替え: JavaScriptはそのまま使える (pro) が、互換性を検証する必要がある(con)
  2. 速い言語で書き換え: 新しい言語を使う必要がある (con) が、その言語においてはスタンダードな方法に乗っかれる (pro)

という2つの道のPros/consを検討することになるかもしれません。

上記のような点を考慮しながら、既存システムのパフォーマンスを観測し、LLRTという新しい道具を意識して使い所を探せば、見えてくることもあるのではないでしょうか。今後報告されてくるであろう利用事例に注視したいです。

まとめ

LLRTという新しいJavaScriptランタイムを紹介しました。AWS Lambdaユーザーにとっては面白い道具になりえるため、要チェックです。

Aurora Postgres Data APIをあらゆるORMから使う試み

あけましておめでとうございます。冬休みの自由工作レポートを提出します。

はじめに

最近Amazon RDS AuroraでData APIが使えるようになりました。Auroraインスタンスに対してHTTP APISQLクエリを発行できる便利なものです。

この記事では、Data APIをより使いやすくするための方法を検討します (ネタバレ: 目標未完です) 。

Data APIのおさらい

Data APIに関する知識を箇条書きでまとめます。

メリット

  • 踏み台なしにインターネットからクエリ可能 (IAM認証)
    • 不要ならData API自体を無効化できる (デフォルトで無効)
    • IAM認証なので、DB認証情報の管理が不要になるのも嬉しい
  • Data API側でコネクションプールされる
    • Lambdaでもコネクション枯渇の可能性が抑制できる
    • RDS Proxyが不要になり、コスト減の可能性
  • CloudTrailで監査ログを記録可能

デメリット

個人的には踏み台とRDS Proxy不要のメリットが大きいと考え、Data API活用の方法を探ることにしました。

モチベーション

Data APIは独自のHTTPインターフェスを介する必要があるため、通常のORMライブラリなどではそのまま利用できません。このため、一部のライブラリではData API専用のアダプターが開発されています ( 例: kysely-data-api, typeorm-aurora-data-api-driver)。

しかしながら、これらのアダプターは各ライブラリ専用のものであり、他のORMでは利用できません。例えばTypeScriptのORMであるPrismaは、3年以上前からData API対応のissueが存在するものの、未だに実現されていません。

github.com

そもそもData APIアダプタの事例は少ないため、他のORMでも似た状況にあるものは多いと推察されます。

そこで今回はより汎用的な解決策として、下図のアイデアを考えました。

図中のData API Proxyが今回作りたいモノです。これはAuroraクラスタとクライアントの間に存在し、クライアントからのSQLリクエストをData APIに変換し、Data APIとしてAuroraクラスタに送信します。また、Data APIのレスポンスをPostgres/MySQLネイティブの形式に変換し、クライアントに返します。

これにより、クライアント側から見るとあたかも通常のPostgres/MySQLと通信しているように、Aurora Data APIを利用できます。つまり、従来のORMでもアダプターなしにそのまま、AuroraクラスタとData APIで通信できるわけです。便利そうですね。*1

この記事では上記のようなプロキシの実現を目指しました。以下で「プロキシ」と書かれたものは、これを指すことにします。 なお、2023/12現在Data APIはPostgresでのみ利用可能 (serverless v1を除く) なため、以下はPostgresの話に限定します。

Postgresサーバーとして振る舞わせる方法

このプロキシは、クライアントから見ると通常のPostgresサーバーとして振る舞う必要があります。つまり認証やクエリのインターフェースを模擬しなければいけません。

これを実現する方法を調べると、うってつけのフレームワークがありました:

github.com

PgwireはPostgresのインターフェースを実装したフレームワークで、開発者はクエリを受信した際の処理だけ実装すれば、自分だけのPostgresのサーバーを作れるという代物です。例えばこれは、Postgresインターフェースで使えるSQLiteサーバーの実装です: sqlite.rs

これを使えばまさにやりたいことが実現できそうですね。

そう簡単ではなかった

Pgwireのおかげで、シンプルなクエリをプロキシする程度のものは簡単にできました。しかし!実装を進める中でいくつかの課題が見つかり、一部はクリティカルに思えたので、作業を止めています。

以下では見つかった課題を簡単に紹介します。私自身はPostgres素人なので誤りを含む可能性がありますが、ご参考までに。

Extended Queryどうする?

Postgresがクエリを処理するフローには2種類あります: Simple QueryとExtended Queryです (詳細)。

Simple Queryは名前の通り単純なもので、SQLクエリを受け取って結果を返すだけです。 一方、Extended Queryはクエリの処理をいくつかのステップに分解してリクエストすることができます。(Parse, Bind, Execute, Sync + Describe, Close)。

難しいのは、Data APIはSimple Queryのモデルに近い点です。つまり、SQLクエリを投げてリクエストを返すことしかできないため、Extended Queryのリクエストに対しては、何らかの方法で、プロキシ内でレスポンスをでっち上げる必要があります。

実はPgwireはよくできており、Pgwire内でクライアントごとのストアを管理し、Parse/Bindで作成されたSQLクエリをExecute時に取り出してくれる仕組みが存在します (このあたり)。これにより、Pgwire利用者はそれほどExtended queryのフローを意識する必要なく、ただクエリに対する処理を書けば良いのです。

問題はDescribeです。これはクエリを実行する前に、クエリが返す行の型情報を取得するためのメッセージです。現状Data APIではこれに相当するAPIが用意されておらず、Describeメッセージに対する妥当なレスポンスをプロキシ内で作るのが難しいのです。適当な型情報が返せば良いかと試したところ、それでエラーになるクライアントもいるようでした。クエリを一回実行すれば型情報を得られるのですが、冪等でないクエリやINSERTのRETURNINGなどを考えると、そう簡単には実装できなそうです。

こうなるとExtended queryのサポートを諦めるのが現実的に思えますが、今回最も狙っていたORMのPrismaはExtended queryをガッツリ利用しているようでした (実際の挙動を見る限り)。他のORMがどうなのか調べられていないのですが、私が一番使うのがPrismaなのでモチベを失っています。

データ型の変換どうする?

Data APIで返されるレコードは、Data API独自のルールでデータ型が変換されています ( 詳細はこちら )。 例えば、DecimalやTimestampが文字列になるなどです。

プロキシでは、これをPostgresネイティブの型に復元してレスポンスを作る必要があります。元のデータ型はメタデータとして取得できるため、この情報を使えば問題なく復元できそうです。既存のORMアダプター実装 ( rds-data-api-client-library など) が参考になるはず。

全てのデータ型に対応するのは大変そうなので、プロキシの制約として妥協することになるかもしれません。

Transactionどうする?

Data APIは独自のフローでトランザクションを管理するため、透過的に扱うにはプロキシ側で一工夫必要です。基本的にはこのようなもので実現できるでしょう:

  1. BEGIN クエリを受け取ると、 beginTransaction APIを叩き、得られたTransaction IDを保存する
  2. 以降同じクライアントからクエリが来たら、保存したTransaction IDを付加して executeStatement APIを叩く
    • クライアントの識別は、socket_addr を見るのが良さそうです (参考)。ポート番号付きなので、コネクションを識別できる。ただし、ポート番号は使い回されるので、コネクション切断時にうまくクリーンアップする必要あり。
  3. COMMIT クエリを受け取ると、commitTransaction API を叩く
  4. ROLLBACK クエリを受け取ると、 rollbackTransaction API を叩く
    • ROLLBACK クエリ以外にロールバックが必要なシナリオはあるのか、要確認

まだ絵に描いた餅なので、いざ実装すると考慮漏れもあるかもしれません。

Prepared statementどうする?

Prepared statementはパースされたクエリをサーバー側に保持し、同一コネクション内で使い回すことで、同様のクエリの呼び出しを効率化する機能です。副産物としてSQLインジェクション対策にもなるため、例えばPrismaではデフォルトでPrepared statementを利用します。

しかしながら、試した限り、今のところData APIではPrepared statementをうまく使えないようです。(ドキュメントには明記されていないが、DBコネクションの概念がないので、それはそうだと思われる。)

このため、Prepared statementをプロキシで非対応とするのが一つの妥当な選択肢です。

あるいは、PgwireがExtended queryフローでサポートしているように、擬似的にアダプタ内でサポートすることもできるかもしれません。 PREPARE を受け取るとプロキシ内でステートメントと名前とクライアントIDを保持し、EXECUTE を受け取るとステートメントに引数を入れてData APIに投げるようなものです。……と書いてて思いましたが、これをやるためにはSQLクエリのパースが必要なため、Extended queryの方法より難易度が高そうです。あまり現実的ではないかもしれません。

まとめ

まとまりませんが、まとめです。Aurora Data APIを巷のORMで利用可能にするための汎用的な解決策として、Data API Proxyを考えました。しかしながら、いくつかの問題があり、真に汎用的で実用的なものにすることは難しそうです。

Extended queryの話はPostgres限定のため、MySQLだとまた話は少し変わるかもしれません (それでもPrepared statementの話は残るのですが)。Data APIMySQLにも対応したら、改めて見直してみたいと思います。*2

ちなみに作りかけたものはコチラに貼っています

*1:プロキシのインフラ管理が手間ではと思われる方もいるかもしれませんが、心配無用です。プロキシはクライアント間で共有する必要がないため、クライアント側にサイドカーなどとして配置でき、追加のインフラは不要です。

*2:MySQLにも似たようなフレームワークがあることだけは調査済みです https://github.com/jonhoo/msql-srv

TIL: ピックの定理とShoelace formulaすごいぞ!

今年もAdvent of Codeにぼちぼちと参加しています。その中で、ピックの定理とShoelace formulaを知ったので簡単にまとめておきます!

Advent of Codeについてはこちらも↓

qiita.com

ピックの定理とは

すべての頂点が二次元格子点上に存在する多角形の面積を求める公式です。

 \displaystyle
A=i+\frac{1}{2}b-1

ここでAは多角形の面積、iは多角形の内側の格子点数、bは多角形の辺上の格子点数です。

詳細はこちら。 証明は別として非常にわかりやすい公式であり、中学生で習う人もいるようですね。

Shoelace formulaとは

頂点座標列から多角形の面積を求める公式です。こちらは台形の面積を考えれば証明も容易と思います。詳細はこちら

 \displaystyle
A=\frac{1}{2}\sum^n_{i=1}(y_i+y_{i+1})(x_i-x_{i+1})

ここで  P_i=(x_i, y_i) は多角形の i 番目の頂点です。(  1 \le i \le n。また、ループする  P_{n+1}=P_1)。 頂点を巡る順番によっては面積が負の数になるため、実用上は最後に絶対値をとってしまうのが楽です。

日本だと座標法とか、そのまま靴紐の公式とか、ガウスの面積公式などと呼ばれるようです。ま た ガウス か!

どう使えるの?

この2つを組み合わせると、多角形内の格子点数を簡単に求めることができます!

今、多角形の頂点座標の配列は得られているとします。

まずは面積Aを Shoelace formula で求めましょう。これは全頂点座標をループすれば容易に得られます。

次に辺上の格子点数bを数えましょう。これは辺が軸に平行であれば、隣り合う頂点の座標を引き算するだけで求められます。

これらの値A, bを使えば、ピックの定理で多角形内の格子点数iが求まります。

 \displaystyle
i = A - \frac{1}{2}b + 1

簡単ながら強力。すごい!!

こんな多角形でも簡単です。これで動物たちの巣の面積が求まりました!

こんなのでも、変わらず適用できます。これでエルフが溶岩を何リットル保持できるかわかりました!

計算量が頂点数に比例するのも良い点です!どんなに大きな図形でも頂点が少なければ素早く答えが得られます。やった!

注意

  1. これらはSimple polygonでしか使うことができません。つまり、辺が交差する多角形や、穴の空いた多角形では単純に適用できないと思われます。今のところAoCではそうした条件が出題されてないため、その場合の対応方法は調べきれていません。
  2. 軸に平行でない辺がある場合は、辺上の格子点数は一筋縄では求められないはずです。しかしこの場合もまだ出題されてないので (以下略
  3. 頂点が格子点でない場合の一般的な問題は、Point in polygonと呼ばれます。Rayを飛ばして辺と交差する回数を数えるのが基本ですが、計算量は数える点の個数に比例します。このため、今年のDay18 part2のように大きな多角形の場合は、特にピックの定理が活きます。
  4. 初めてはてなTex記法を使いましたが、絶妙にダサくて残念です!

今月のもなちゃん

抱卵も終わり、また元気に飛び回っています。

ゆるキャラを避けたがる

ISUCON13で下四桁賞を拝領した - 結果は43位でした

子供は生まれましたが今年もISUCONに参加しました。4度目くらいです。 前回は予選でそこそこ良い成績を残しそうになったので、今年は期待ですね。

準備

今年は予選がないということで、ISUCON11本線の問題を事前に見ておきました。結果的にたまたま今回もほぼ同じ技術スタック (nginx, MySQL 8) なので超ラッキーでした。

準備では思考停止で適用できるテクニックをいくつか習得しました。これらは基本的にやればスループットが上がるようです (速度と可用性・耐久性などのトレードオフがある中で、速度だけを優先している)。

  • mitigations=off
  • MySQLの設定 (skip-name-resolve=ON, skip-log-bin, innodb_autoinc_lock_mode = 2, performance_schema=OFF, innodb_doublewrite=0)
  • nginxのkeepalive

今回も上記をとりあえず初手で適用しています。時間がないので一つずつは計測していません (良くない) が、おそらくいずれもスコア向上に寄与していると思われます。ISUCON予備校があったとしたら真っ先に教えてそうな手法ですね。

メンツ

今年こそは友達と参加する予定だったのですが、急遽都合合わなくなり、去年に続きソロ参加となりました。やはり寂しいので、来年こそはチームで出たいです。

当日

近所にオアシスルームという施設があるため、子供はこちらに預けました。とても集中できたので、妻と子供と行政にはマジ感謝です。

10:00 - 11:30 定型作業

今年も言語はGoを利用し、初期スコアは3243でした。開始直後は何も考えずにできる(はずの)定型作業を行います。これは以下が含まれます:

  • gitの設定
  • 必要なツール(alp, ptquery-digestなど)のインストール
  • 脳死改善の適用 (上記に加えて、MySQLを2台目に分散など)
  • オブザーバビリティの整備

本来は上記を1時間以内に終わらせる予定でしたが、今年はMySQLがpowerdnsというサービスにも利用されていたため、思いの外手こずりました。DNSなんてRoute 53しか知りませんよね。いろいろググって遂に /etc/powerdns というディレクトリを発見し、無事MySQLの接続先を全て切り替えることができました。

この時点でスコアは4800程度でした。正直この時点ではルールもよく分かってない状態なので、何がなんやらです。

ちなみに自身のUtilizationを高めるため、上記の作業をしながらマニュアルをMS Edgeに読み上げさせて聞くということをやってみました。結果ほぼ耳に入ってこなかったものの、 DNS水攻め (イケボ) というキーワードだけは頭に残ったので、少しは意味あるかもしれません。(その後結局通しで読んだ)

11:30-13:00 インデックスはる・dns改善

とりあえずスロークエリログを見ながら、一通りMySQLにインデックスを作成しました。余談ですが、今年は CREATE TABLE クエリが /initialize APIで発行されないため、インデックスを貼るのもやや面倒でした。一旦 DROP INDEX して CREATE INDEX するというような流れで書きましたが、後々不安定になったため、結局いつもの DROP TABLE IF EXISTS を使う形に書き換えています。(この辺りの所作はもう毎年統一で良くないですかね?) これでスコア11937です。

次に、DNS floodingのせいでかなりのCPUを持っていかれるので、対策に移ります。DNSは今年の新規性で、私も全く詳しくないのでドキドキしました。とりあえずルールも良くわかってないまま、余っている3台目のインスタンスでDNS用のMySQLを動かすことにしました。これにより名前解決成功数は50938まで上がり3倍になりましたが、スコアは一切変動せずで??でした。一旦後回しにして、ルールを読むことにします。

13:30-14:00 iconのキャッシュ

マニュアルを読むと明らかにアイコンの画像取得がキャッシュできることが分かり、そのエンドポイントも多く叩かれていたので、こちらから着手することにしました。

アプリ側で304を返す方針にしたので、やるだけです (といいつつ手こずった)。これでスコアが13031になりました。

ちなみに気付け薬としての魔剤を用意していたのですが、これのせいで競技中めちゃくちゃ頻尿になりました。普段飲まないものを飲むべきではないです。

14:00 - 15:00 dnsdistの導入

後回しにしたDNS問題に戻ります。

Amazon Bedrock Claude (AWS社員は無料なのです) と相談しつつ、ググりつつで、dnsdistなるソフトを導入すればレートリミットを実現できることがわかったので、導入しました。

初めてやる作業のため、案の定手こずって1時間かかりましたが、なんとか導入できました。初見の対応力も私の課題ですが、どうすれば鍛えられるんですかね… dnsdistにより一定QPS以上のリクエストをドロップするようにしましたが、ベンチが落ちがちになってしまいました。ドロップの代わりに一定の遅延を掛けるようにすると、ベンチは通りつつ攻撃がそれなりの負荷で止まるようになりました。優しい攻撃者です。

addAction(MaxQPSIPRule(100, 32, 64, 100, 60), DelayAction(750))

これで名前解決成功数が6050にまで落ち (8分の1程度)、スコア15982になりました。おそらく名前解決数自体がスコアに影響するというよりは、DNSの攻撃に割く余計なCPU負荷が減るため、スコアに直結する処理にCPUを割けるようになるという意味があるのだと思います。この時点でDNSMySQLを動かしている3台目のCPU使用率は100→10%程度にまで落ちています。

15:00-17:20 いろいろと改善する

ここからは細かく記録を残さなくなったのですが、次のことをやりました:

  • NGwordにMySQL全文検索インデックスの導入
    • 早くなるんじゃね?と思い、これを見ながらやってみたら、整合性チェックで落ちるようになりました。理由が分からなすぎましたが、単純なLIKE検索にしたら整合性チェックを通ったので、最終的にはインデックスを使ってません。String.containsな処理をMySQLに投げるという明らかに不合理な実装もあったりしました。
    • (追記) 競技後あらためてドキュメントを読みましたが、IN BOOLEAN MODE では検索文字列に含まれる文字に特別な意味があるものがあります。+,-や空白など。これらがNGワードに含まれていたとしたら、誤動作してもおかしくないなと納得。""で括ってやればよかったかもしれません。
  • 3台目でアプリの処理もする
    • DNS問題が解消されてCPUが余ったので、早めですが3台目もアプリに回すようにしました。ユーザー登録の際にシェルでDNS登録処理をしているので、雑にユーザー登録だけ1台目だけで処理しています。結局最後までMySQLのCPUがボトルネックだったため、アプリ側でCPUは使いきれてないです。
  • UserModelをインメモリキャッシュする
    • あちこちで SELECT * FROM users WHERE id = が見られたので、インメモリキャッシュします。これでN+1も減りました。
  • インメモリキャッシュをさらに
    • UserModelと同様にキャッシュできるもの (イミュータブルなやつ) が多々あったので、概ねすべてキャッシュしました (themeとlivestream)。
  • タグをインメモリ化
    • タグが初期化以降固定値だったので、全てメモリに持つようにしました。

インメモリキャッシュもまた (イミュータブルなものを見つけてしまえば) 作業ゲーでありかつそこそこスコア上がるので、コスパが良いですね。これが簡単にできるのがGoの便利なところでした。とはいえ後半頭を使わない作業に逃げてしまった感もあり、もっと持久力が欲しいです。

これでスコア34985です。

17:20 - 18:00

明らかにやばいエンドポイントであるstatistics系は横目で見ていたのですが、17時になってから20分くらい作業して結局諦めました。あと40分落ち着いて作業できれば、N+1の解消くらいはできたかもしれません。alpや他の人のスコアを見る限り、ここが大きなブレークスルーだったんだろうと思います。実装できずで残念です。GoはISUCONでしか使わない言語なので、瞬発力のなさが課題です。来年こそはGoが手に馴染んでると良いんですが、あまり期待できなそう 🌞

また、去年再起動試験で失格になったので、今年は終盤念入りに確認することにしました。結果として、終了10分前にインメモリキャッシュが3台目で初期化されない (POST /initializeでしか初期化してなかったので) ことに気づき、無事直せました!焦りながらの実装としてはよくやれたと思います。

再起動後のブラウザ確認も大丈夫そうだったので、安心して18時を迎えます。完。

最終的なサーバーの用途は以下のとおりです。

  • 1台目: app, pdns, dnsdist
  • 2台目: MySQL (isupipe)
  • 3台目: MySQL (isudns), app

結果

最終スコアは37898でした。発表まで気づいてなかったのですが下4桁が3900に一番近いチームに対する副賞があり、受賞できました!!本と毛布がもらえるそうです。ありがとうございます。

同時にこれは失格にならなかったことも意味するので、安心しました!

統計系APIは頻出な気がするので、次回出るならもう少し慣れておこうと思います。それでは!

(追記) 本日順位が発表され、694組中43位でした。実装速度・精度が足りてない感があるため、競プロをまたやろうかと検討中… とりあえずはAdvent of Code 2023ですね。

CDK Tips: 2種類のaddDependency

AWS CDK Tips シリーズです。

CDKに addDependency メソッドが2種類あることをご存知でしたか?今日は2つの違いをお伝えします。

addDependency とは

addDependency メソッドは、コンストラクト間の依存関係を明示的に指定するための機能です。AWS CDKにおいては、最終的にCloudFormationリソースの依存関係として扱われます。

これにより、CFnリソースの作成・更新・削除の順序を制御できるようになります。詳細はこちらのドキュメントもご覧ください: DependsOn 属性

ちなみにやや非自明なのですが、 A.addDependency(B) で、AがBに依存していることを意味します。つまり、Bが作成されたあとにAが作成され、またAが削除されたあとにBが削除されるなどの順番になります。

2種類の addDependency

CDKには2種類の addDependency メソッドがあります。まずはそれぞれの概要を紹介します:

1. Node.addDependency

public addDependency(...deps: IDependable[]): void ドキュメント

こちらは constructs.Node クラスに生えているメソッドです。CDKだと、以下のような使い方ができますね。

const bucket1 = new Bucket(stack, 'Bucket1');
const bucket2 = new Bucket(stack, 'Bucket2');

//bucket1はbucket2に依存する
bucket1.node.addDependency(bucket2);

引数の IDependable は全てのコンストラクトが実装するインターフェースのため、コンストラクトをそのまま渡すことができます。

2. CfnResource.addDependency

public addDependency(target: CfnResource): void ドキュメント

一方こちらは aws_cdk_lib.CfnResource クラスに生えたメソッドです。使い方は以下です:

const bucket1Resource = bucket1.node.defaultChild;
const bucket2Resource = bucket2.node.defaultChild;
if (CfnResource.isCfnResource(bucket1Resource) &&
    CfnResource.isCfnResource(bucket2Resource)
) {
    //bucket1はbucket2に依存する
    bucket1Resource.addDependency(bucket2Resource);
}

// 単純にこれでも可 (型安全性は低下)
const bucket1Resource = bucket1.node.defaultChild as CfnResource;
const bucket2Resource = bucket2.node.defaultChild as CfnResource;
bucket1Resource.addDependency(bucket2Resource);

こちらの引数は CfnResource のみです。

で、何が違うの?

2つの違いを一言で表すなら、 N対Nの依存関係か、1対1の依存関係か と言えます。詳しく見ていきましょう。

Node.addDependency(deps) の場合は、そのノード (コンストラクト) の子(孫、ひ孫…)ノードに対して、再帰的に依存関係が追加されます。また、追加先のdepsについても、再帰的に処理されます。つまり、一度の操作でN対Nの依存関係を設定できます。

図解するとこのようです:

一方 CfnResource.addDependency の場合は、呼び出し元と引数の2つのL1コンストラクトのみに作用します。つまり、一度の操作で1対1の依存関係を設定できます。

コンストラクトツリーについてはこちらのドキュメントも読むと良いでしょう: コンストラクト

具体例

具体例として、CDKコードのその出力結果も見てみましょう。

Node.addDependency を用いたコードは以下です:

import * as cdk from 'aws-cdk-lib';
import { Bucket } from 'aws-cdk-lib/aws-s3';

const app = new cdk.App();

const stack = new cdk.Stack(app, 'SampleStack');
const bucket1 = new Bucket(stack, 'Bucket1', {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});
const bucket2 = new Bucket(stack, 'Bucket2', {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});
bucket1.node.addDependency(bucket2);

生成されるCloudFormationテンプレートは以下の通りです。Bucket1 コンストラクト以下の全CFnリソースに、Bucket2 コンストラクト以下の全CFnリソースに対する依存関係が追加されていることが分かります。

{
 "Resources": {
  "Bucket12520700A": {
   "Type": "AWS::S3::Bucket",
   "Properties": {...},
   "DependsOn": [
    "Bucket2AutoDeleteObjectsCustomResourceF4462BA8",
    "Bucket2Policy945B22E3",
    "Bucket25524B414"
   ],
   "UpdateReplacePolicy": "Delete",
   "DeletionPolicy": "Delete",
   "Metadata": {
    "aws:cdk:path": "SampleStack/Bucket1/Resource"
   }
  },
  "Bucket1Policy65042C0B": {
   "Type": "AWS::S3::BucketPolicy",
   "Properties": {...},
   "DependsOn": [
    "Bucket2AutoDeleteObjectsCustomResourceF4462BA8",
    "Bucket2Policy945B22E3",
    "Bucket25524B414"
   ],
   "Metadata": {
    "aws:cdk:path": "SampleStack/Bucket1/Policy/Resource"
   }
  },
  "Bucket1AutoDeleteObjectsCustomResource41848F29": {
   "Type": "Custom::S3AutoDeleteObjects",
   "Properties": {...},
   "DependsOn": [
    "Bucket1Policy65042C0B",
    "Bucket2AutoDeleteObjectsCustomResourceF4462BA8",
    "Bucket2Policy945B22E3",
    "Bucket25524B414"
   ],
   "UpdateReplacePolicy": "Delete",
   "DeletionPolicy": "Delete",
   "Metadata": {
    "aws:cdk:path": "SampleStack/Bucket1/AutoDeleteObjectsCustomResource/Default"
   }
  },
  ...
 }
}

次に、CfnResource.addDependency を用いたコードは以下です:

import * as cdk from 'aws-cdk-lib';
import { Bucket } from 'aws-cdk-lib/aws-s3';

const app = new cdk.App();

const stack = new cdk.Stack(app, 'SampleStack');
const bucket1 = new Bucket(stack, 'Bucket1', {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});
const bucket2 = new Bucket(stack, 'Bucket2', {
  removalPolicy: cdk.RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
});

const bucket1Resource = bucket1.node.defaultChild;
const bucket2Resource = bucket2.node.defaultChild;
if (bucket1Resource && bucket2Resource && 
    cdk.CfnResource.isCfnResource(bucket1Resource) && 
    cdk.CfnResource.isCfnResource(bucket2Resource)
) {
    bucket1Resource.addDependency(bucket2Resource);
}

合成結果は以下です。今度はBucket1とBucket2のL1コンストラクトのみに依存関係が追加されています。

{
 "Resources": {
  "Bucket12520700A": {
   "Type": "AWS::S3::Bucket",
   "Properties": {...},
   "DependsOn": [
    "Bucket25524B414"
   ],
   "UpdateReplacePolicy": "Delete",
   "DeletionPolicy": "Delete",
   "Metadata": {
    "aws:cdk:path": "SampleStack/Bucket1/Resource"
   }
  },
 }
}

これでN対Nと1対1という違いが分かりましたね!

落とし穴に注意!

N対NはN=1にすると1対1と等しいんだから、 CfnResource の方は使いドコロある?と思う人もいるかもしれません。

確かにN=1にする、つまりツリーの末端・葉ノードに対して Node.addDependency を呼び出せば、1対1と同じことができます。

bucket1.node.defaultChild!.node.addDependency(bucket2.node.defaultChild!);
// これは↓と等価?
// const bucket1Resource = bucket1.node.defaultChild as CfnResource;
// const bucket2Resource = bucket2.node.defaultChild as CfnResource;
// bucket1Resource.addDependency(bucket2Resource);

しかし、この方法にはちょっとした落とし穴があります。コンストラクトツリーは開発者が任意のノードから子を追加できることに注意してください。 つまり、上のコードでは bucket1.node.defaultChild!.node は単一のCfnResource (CfnBucket) を指定することを意図していますが、この前提は容易に崩すことができるということです。

new CfnBucket(bucket1.node.defaultChild!, "UnexpectedBucket")

このようなノードが追加された場合、当初1対1の依存関係を意図していたところが、実は2対1になってしまうことになります。結果として意図せぬ循環依存が発生してしまうこともあるでしょう。

厳密に特定のCFnリソース間の依存関係を指定したい場合は、 CfnResource.addDependency の出番ということです。

まとめ

CDKの豆知識を紹介しました。記事を書いたきっかけはAWS CDK本体のこちらのPRをレビューしたことです。慣れた開発者でも意図せぬバグを発生させうる挙動のため、注意して使い分けると良いでしょう。

今月のもなちゃん

実は今月頭に卵を4つ産みまして、今は母の顔で抱卵中です。

抱卵中

ちなみに無精卵のため偽卵に交換済み。卵は今年も食べようか迷っています。参考

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 などでバンドルが不要な場合はまるごとスキップされます。