maybe daily dev notes

私の開発日誌

AWS CDKでSOCIインデックスをデプロイする

TL;DR;

以下のコードでCDKからSOCIインデックスをビルドし、ECRにプッシュすることができます。

# 依存関係のインストール
npm install deploy-time-build
import { SociIndexBuild } from 'deploy-time-build;

const asset = new DockerImageAsset(this, 'Image', { directory: 'example-image' });
// DockerImageAssetに対応するSOCIインデックスをビルド・プッシュ
SociIndexBuild.fromDockerImageAsset(this, 'Index', asset);

はじめに

先日AWS FargateでSeekable OCI (SOCI)が利用できるようになりました。これにより、ECSタスクの起動時間を高速化することができます。

こちらのポストによれば、特にサイズの大きなイメージで効果を発揮するようです。 (141MBで25%、1GBで75%の高速化)

この機能を利用するためには、対象のコンテナイメージに対応するSOCIインデックスをビルドし、同じECRリポジトリにプッシュする必要があります。 このために主に次の方法が用意されており、それぞれ以下の特徴があります *1

  1. soci-snapshotter CLIの利用
    • ✅ シンプルなインターフェースのCLIで、他のパイプラインに組み込みやすい
    • Linuxのみ対応、containerd が必要など、実行環境を選ぶ
  2. cfn-ecr-aws-soci-index-builder ソリューションの利用
    • ✅ CloudFormation一発で構築可能
    • ❌ 非同期にインデックスがプッシュされるため、タスク実行時にはまだインデックスが存在しない状況が起こりえる
    • ❌ Lambda上でインデックスをビルドするため、扱えるコンテナイメージサイズに上限がある

ということで、どちらも微妙に面倒なんですよね。巷のベンチマーク結果だけ見て、うちは(試すのも大変だし)良いかと見送った人もいるんじゃないでしょうか。

さて、AWS CDKではコンテナイメージをCDKデプロイ時にビルドする慣習があります。 コンテナイメージと同様に、CDKからSOCIインデックスをデプロイできると便利そうですよね。 この記事では、そのための機能を作った話や使い方をまとめます。

作ったもの

deploy-time-build というコンストラクトライブラリを開発しました。これにより、CDKのデプロイ中にSOCIインデックスをビルド・プッシュできるようになります。

github.com

使い方

イメージのタグとECRリポジトリを指定することで、イメージに対応するインデックスをビルドし、同リポジトリにプッシュします。

以下は someRepository という名前のECRリポジトリの中の someTag タグがついたイメージに対して、インデックスを付加する例です:

import { Repository } from 'aws-cdk-lib/aws-ecr';
import { SociIndexBuild } from 'deploy-time-build;

new SociIndexBuild(this, 'Index', {
    imageTag: 'someTag', 
    repository: Repository.fromRepositoryName(this, "Repo", "someRepository") 
});

実用的には、CDKの DockerImageAsset に対してインデックスを付加したい場合が多いと思います。これも簡単に書けて、以下は example-image ディレクトリのDockerfileをビルドしつつ、そのイメージのインデックスを付加する例です:

import { DockerImageAsset } from 'aws-cdk-lib/aws-ecr-assets';

const asset = new DockerImageAsset(this, 'Image', { directory: 'example-image' });
SociIndexBuild.fromDockerImageAsset(this, 'Index', asset);

CDKのECSモジュールからコンテナイメージを参照する際は、AssetImage クラスを使うことも多いと思います。その場合は、先に定義した DockerImageAsset から AssetImage 作成すると良いでしょう:

import { AssetImage } from 'aws-cdk-lib/aws-ecs';
const assetImage = AssetImage.fromDockerImageAsset(asset);

SOCIインデックスがプッシュされてない状態では、ECSサービスをデプロイしたくない場合もあると思います。その場合は、ECSサービスのリソースに対する依存関係を設定すれば良いです:

import { FargateService } from 'aws-cdk-lib/aws-ecs';
const service = new FargateService(/* 略 */);
const index = SociIndexBuild.fromDockerImageAsset(/* 略 */);

// indexをデプロイした後にECSサービスをデプロイする
service.node.defaultChild!.node.addDependency(index);

Python CDKユーザーの人も同様に使えます:

pip install deploy-time-build

from deploy_time_build import SociIndexBuild

asset = DockerImageAsset(self, "Image", directory="example-image")
SociIndexBuild(self, "Index", image_tag=asset.asset_hash, repository=asset.repository)
SociIndexBuild.from_docker_image_asset(self, "Index2", asset)

上記が基本的な使い方となります。仕組みが気になる方は、次をご覧ください。

仕組み

CDKデプロイ時にCodeBuildプロジェクトのジョブを開始し、そのジョブ内でインデックスをビルド・プッシュしています。

CodeBuild上でSOCIインデックスをビルドするためにスタンドアロンなツールがほしかったので、soci-wrapperというCLIを作成・利用しています。これは上で紹介した cfn-ecr-aws-soci-index-builder のコードを流用しているため、生成物も同一になります。 このCLIの背景についてはこちらのIssueも参考になると思います: Ability to run soci create command in CodeBuild #760

理想的にはCDK CLIに同機能が組み込まれていると良いでしょう。(コンテナイメージ自体のビルドはローカルで行うため。)しかしながら、現状soci-snapshotterがLinux上でしか動かないことを考えると、Linux/Windows/Macで同様に動作する必要があるCDK CLIでは難しそうに思います。とりあえずIssueだけは立てています: core: push SOCI index when publishing docker image assets #26413

まとめ

AWS CDKから簡単にSOCIインデックスをデプロイするためのCDKコンストラクトライブラリを紹介しました。

昨日のJAWSコンテナ支部でSOCIインデックスが話題に出ていたので、半年前書きかけた記事を完成させられました。ありがとうございます!

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つ産みまして、今は母の顔で抱卵中です。

抱卵中

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