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

Amazon Bedrockでもコードレビューしたい!

最近LLMでコードレビューする記事が話題になっていました:

zenn.dev

上記の記事ではCodeRabbitというOSS/SaaSを用いることで、ボットがレビュワーとしてGitHubプルリクのレビューに参加する体験が実現されています。

CodeRabbitのOSS版ではChatGPTのAPIを用いる必要がありますが、色々な事情でAmazon Bedrockのほうが使いやすいという方もいるでしょう。今回はCodeRabbitリポジトリをフォークしてBedrock版を作成したので、使い方を紹介します。

GitHub - tmokmss/bedrock-pr-reviewer: AI-based Pull Request Summarizer and Reviewer with Chat Capabilities.

使い方

使い方はCodeRabbitとほぼ同一ですが、一点Bedrock APIを叩くためのAWS IAMロールのみ追加で設定する必要があります。

IAMロールの設定

OpenID Connectを使うことで、GitHub ActionsからAWSアカウントに対して簡単に認証認可できます。

簡単に構築するためのCloudFormationテンプレートを用意しました oidc-cfn.yamlSubjectClaimFilters には、必ずGitHub Actionsを利用したいGitHubリポジトリを指定してください:

  SubjectClaimFilters:
    Type: CommaDelimitedList
    Default: "repo:tmokmss/bedrock-review-test:*"

CFnテンプレートのデプロイは次のコマンドで実行できます:

aws cloudformation deploy --stack-name STACK_NAME --template-file oidc-cfn.yaml --capabilities CAPABILITY_IAM

デプロイが完了したら、作成されたIAMロール GitHubActionsServiceRole のARNを確認してください。

GitHub Actionsのyamlを作成

次に、レビューを実行したいリポジトリGitHub Actionsのyamlを作成します。雛形は下記です:

name: AI review

permissions:
  contents: read
  pull-requests: write
  id-token: write

on:
  pull_request:
  pull_request_review_comment:
    types: [created]

concurrency:
  group:
    ${{ github.repository }}-${{ github.event.number || github.head_ref ||
    github.sha }}-${{ github.workflow }}-${{ github.event_name ==
    'pull_request_review_comment' && 'pr_comment' || 'pr' }}
  cancel-in-progress: ${{ github.event_name != 'pull_request_review_comment' }}

jobs:
  Run-Bedrock-review:
    runs-on: ubuntu-latest
    steps:
      - name: configure aws credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/YourRole # ここにIAMロールのARNを指定
          role-session-name: gha-session
          aws-region: us-east-1
      - name: PR review
        uses: tmokmss/bedrock-pr-reviewer@main
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          debug: true
          language: ja-JP # ボットが使う言語を指定

先程デプロイしたIAMロールのARNを指定さえすれば、そのままでもとりあえず使えます。リポジトリのルートディレクトリに .github/workflows/review.yaml のパスでyamlを作成・pushしてください。

これでセットアップは完了です。適当なプルリクを作成すると、AIがレビューやプルリクの要約などを実行してくれます。

また、 @reviewbot とメンションを付けつつコード内にコメントを追加・返信すると、適切な返答を返してくれます。このスクショだとやり取りがおぼつかないですが。

移植したばかりで私もまだあまり使えてないので、使い物になるかは未知です。少なくとも簡単に導入はできるので、ぜひ試してみて良し悪しを共有しましょう。それらの知見を活かしつつClaudeに合わせたプロンプトチューニングができると良いですね!

おまけ: ChatGPTアプリをBedrock (Anthropic Claude) に移植する

ついでにChatGPTからBedrock Claudeに移植したときのTipsを紹介します。

基本的に、LLMモデルの移行は極めて簡単です。LLMは (細かいパラメータの違いはあるものの) 総じて文字列(プロンプト)を受け取って文字列を返す関数として使えるので、ライブラリをインストールしてLLMを呼び出す部分のコードを書き換えるだけです。(今回だと差分はこの程度)

ただし、モデルにより良い(結果を返す)プロンプトの傾向は異なるようです。Claudeの場合は、こちらのドキュメントが参考になります。

docs.anthropic.com

XMLタグを利用する点などはChatGPTにはないユニークな点なので、注意したいです。また、Bedrock Claudeは \n\nHuman:{prompt}\n\nAssistant: の形式でプロンプトを入力しないと動作しないため注意しましょう。

また、ChatGPTではプロンプトを入力する際に "role": "system" などと指定することができますが、Claudeにはそのような概念はないようです。CodeRabbitでは出力言語を指定するなどのために使われていました。仕方ないのでClaudeではプロンプトの頭に指示をそのまま配置しましたが、意図通りに動作しているようです。

今回のAIレビュワーに関してはあまりプロンプトチューニングもしていないので、まだまだ改善の余地があるはずです。ぜひフィードバックお待ちしています。

それでは!

CDK Day 2023に登壇しました

先日CDK Day 2023というイベントに登壇しました。 今後本イベントに登壇を考えている人の参考になればと思い、手続きの概要や感想などまとめておきます。

CDK Dayとは

AWS Cloud Development Kit (CDK)やcdktf・cdk8s・projenなどCDK周辺の話に特化した、CDK関連では世界最大規模のカンファレンスです。 2020年から始まり、今年で4度目の開催となります。公式サイトはこちらです:

www.cdkday.com

コロナ禍に始まったためか、初回からオンライン開催が続いているため、世界各地から参加しやすいイベントとなっています。時間帯的にはヨーロッパのタイムゾーンに最も合わせているようです。(APACは前半、USは後半が参加しやすい)

今年は夜9時JSTから開始し、6時間続きます。全体では40程度のセッションがありました。 またYouTube上の視聴者数は全トラック合わせて最大150〜200人弱ほどでした。あれ、CDK Conference Japanのほうが参加者は多いですね 🙄 (日本のCDKコミュニティは良い意味で強すぎる)

登壇内容

私はウェブフロントエンド静的アセットのデプロイ方法について20分間話しました。以前ここにまとめていた内容です。正直小粒な内容だとは自分でも思うのですが、CDK x ウェブフロントエンドの組み合わせが珍しいために採択されたのかもしれません (倍率不明)。

採択されたということは運営的には需要ある話なんだろうと解釈し、デプロイ時値解決などについて好き勝手に喋りました。どれくらいの人に刺さったのかは不明です😇 セッションごとの満足度アンケートがあれば良いのですが、少なくとも今年は無いようでした。

ついでに日本のCDKコミュニティも宣伝しておいた✌ 今回のCDK Dayの視聴者数なども見て思いましたが、日本のCDKコミュニティは世界的に見ても強力だと思います。

当日の資料はこちらです:

speakerdeck.com

録画はこちら:

www.youtube.com

応募〜登壇の流れ

備忘録として、今回の登壇までの流れをまとめます。来年以降の参考になれば幸いです。

1. CfPの提出

登壇するためには、CDK Dayの公式サイトからCall for Paper (CfP) を提出します。CfP募集の旨はTwitterやcdk.devのSlackを見ていれば見逃しづらいでしょう。今年は2023年5月ごろから7月末まで募集期間が設けられていました。

CfPはよくある形式で、タイトル・概要 (公開用)・内容詳細 (審査用) などをまとめて提出します。言語は基本的に英語ですが、今年はスペイン語でも登壇できるようでした。いつか日本語も選択可能になれば良いですね。

2. CfP採択の連絡

募集締切後、半月程経過後に採択の連絡をメールでいただきました。これは開催日からおよそ1ヶ月ほど前の日付なので、準備にもその程度の時間は割けることになります。

3. 発表準備

その後は当日のツールの動作確認のために、運営の方 (よく名前を見るThorstenさんでした!) と一度だけミーティングをします。10分程度で画面共有やマイク・カメラの動作確認をするだけなので、身構える必要はありません。

ちなみに、希望すれば発表準備を支援してくれるメンターを運営からアサインしてくれるようです。私は別に良いかと思い希望しませんでしたが、身近に英語のレビューをしてくれる人はいないし、そもそも育休中で資料レビュー自体誰にも頼みづらいという状況だったので、振り返ればお願いしても良かったなと思っています。似たような境遇の人はぜひ利用してみてください。

また発表時間を決める上では日本在住であることが強く配慮され、21時JSTという一番早い開始時刻に設定していただきました。これはアメリカ西海岸だと朝5時とかなので、かなり融通してくれた結果と思います (2022年は日本の深夜に開かれていたような記憶も…)。 ありがたいかぎりです。

4. 当日

オンライン開催なので、当日は時間になったら発表部屋に接続し、モデレータの流れに任せて話し始めるだけです。YouTubeライブ配信されます。

日本で盛り上がるイベントではないと思ってましたが、𝕏には意外と日本からも見ている人がいました (嬉しい!) しかしオンライン登壇は自分の番が終わったら解散なので、少し寂しさはありますね。いつかフィジカル開催してほしい。

その他Tips

  • 当日の参加が難しい場合は、録画の提出も可能です。実際今年も割と多くの人 (全体の6〜7割くらい?) が録画で済ませているようでした。ただし録画は提出期限が早い (本番の2週間前。これは交渉可能かもしれません) ので、スケジュールにはご注意。録画を放送しつつ、当日はYouTubeのコメント欄で質疑応答に専念するスタイルのスピーカーもいるようです。中には動画編集にめっちゃ力入れてる人もいました。本職YouTuberかもしれない。これは分かりやすいので普通にありですね。
  • LT枠(20分)では基本的に質疑はなし。ただし20分よりも早く終わった場合は、モデレータ次第で質問を振るかもとのことでした。
  • 発表時間は20分±2~3分程度までOK
  • ヨーロッパからの登壇者も多く、英語ネイティブの人は意外と少ない。その意味では日本人英語でもあまり気にしなくて良いかも。

登壇した感想

パブリックなイベントで英語登壇するのは初めてだったので緊張しました。とはいえ発表側は用意した英語を喋るだけでも完結できるので、高い英語力は必要ない印象です。また良い意味で「ちょうどよい」規模感のイベントなので、あまり気を張りすぎることもなく、とはいえ手を抜きすぎることもなく、初めての場としては適しているのではと思いました。

CDK初期メンバーの一人であるrix0rrrさんからcleverと言ってもらえて嬉しかった記念。

元々育休中暇だろうから応募したわけですが、時間があり余っているがゆえに逆にダラダラ準備してしまい妻に迷惑をかけたので、その点は猛省です。効率化大事。

あと、(これは一時的に興奮しているだけかもしれないですが) 一つハードルを越えた感があり、今後は以前よりもかなり(忌避感が8割減くらい)気軽に英語登壇できる気がしています。社内で英語登壇するよりも「超えた」感が強い謎。引き続き機会を見つけていきたいですね!

グローバルに自分の考えを発信する良い機会となるので、興味のある人はぜひCfP提出してみてください。

(追記) 同じく今年日本から登壇された橋本さんもレポートを書かれていました。資料準備などの裏話が面白いので要チェックです。

blog.serverworks.co.jp

今月のもなちゃん

最後にもなちゃん(文鳥)を載せておきます。日課の水浴び後に羽を乾かす躍動感ある姿です。

それではSee you again!

Infrastructure from Code (IfC) ツールまとめ

昨今Infrastructure from Code (IfC)という概念をよく耳にします。先日もAWSのGregor Hohpeが関連する記事を書いていました。

architectelevator.com

この記事では、Infrastructure from Codeとはなにか簡単に紹介し、具体的にどのようなツールがあるか網羅的にまとめます。

Infrastructure from Codeとはなにか

Infrastructure from Code (IfC) とは、その名の通り、Infrastructure as Code (IaC) に関連する概念です。IaCとの根本的な違いは、IaCは開発者がインフラを明示的に意識して構成を記述するのに対し、IfCでは開発者がインフラをできるだけ意識しないよう抽象化を試みていることです。これにより、差別化に繋がらない重労働ができる限り排除された高い開発者体験を目指します。

Infrastructure from CodeにおけるCodeとは、アプリケーションのコードと捉えるのが良いでしょう。インフラはアプリのコードから生成される、というのがfrom Codeの意味するところです。as CodeにおけるCodeとは指すものが違うことに注意してください。

Infrastructure from Codeという言葉の起源は調べた限りは定かでないですが、この記事を見ると、少なくとも2021年には存在した概念のようです。パブリッククラウドの利用が普遍化する中で、頻出のアーキテクチャパターンを徹底的に抽象化する試みと言えるでしょう。

IfCに共通する特徴

今回調べた限り、IfCには以下の共通項があります。インフラを抽象化し、開発者体験を高めるというのがゴールなればこそですね。

  • 高い開発者体験
    • ローカル実行、ホットリロード、デバッガ、REPL、自動計装など
  • 高度に抽象化されたインフラ
    • アプリのコードから、インフラ定義は自動生成される
    • ユーザーはクラウド側の実装を意識する必要がない
    • しばしばマルチクラウド対応を謳っている
  • 特定ユースケースに特化
    • インフラをうまく抽象化するため、あらゆるユースケースに対応するのは難しい
    • REST API、キューワーカー、ウェブアプリ辺りに注力しているものが多い

ここまで読んでピンとこないという方、百聞は一見にしかずですから、IfCを体現するツールたちを具体的に見るのが早いです (次節へ)。

IfCツールの例

それでは具体的なIfCツールを見ていきましょう。芋づる式に見つけていきましたが、番外編まで含めると10近くありました! 以下ではざっくり次の観点でまとめます:

  • 開発元: ツールを開発する会社の概要です
  • 特徴: ツールの特筆すべき点を主観で紹介
  • 言語: プログラミング言語について
    • ツール自体の開発言語: そのツール自体がどの言語で書かれているか。開発者の思想が顕れるポイントです
    • ユーザーが使う言語: ツールのユーザーはどの言語を利用できるか
    • 中間生成物の言語: インフラデプロイのために、どのIaCを生成するか (TerraformやCFnなど)
  • デプロイ先: インフラはどこにデプロイされるか (AWS, Azureなど)
  • ライセンス: ツールがどのライセンス下でコード提供されるか
  • ビジネスモデル: 開発元の会社はいかに利益を得るか
  • その他: その他注目すべき点 (あれば)
  • コード例: 実際にどのようなコードで記述されるか

なお、ツールの順番に大きな意味はありません。

Wing

  • 開発元: Wing Cloud, Inc.
    • AWS CDKの父EladがAWSを辞めて創業したスタートアップ
  • 特徴
    • 専用の言語Winglangを提供することで、最適な開発体験を目指す
    • 同時にJavaScriptCDKなど既存エコシステムには乗っかろうとするしたたかさもあり
  • 言語
    • ツール自体の開発言語: Rust, TypeScript
    • ユーザーが使う言語: Winglang (JavaScriptに似た独自言語)
    • 中間生成物の言語: Terraform
  • デプロイ先: AWS, Azure, Google Cloud (現状はAWSのみ)
  • ライセンス
  • ビジネスモデル
  • その他
    • 最近$20Mの出資を受け勢いづいている様子。
    • Wing社自身は、自分たちがIfCに分類されるとは今のところ明言してない。 しかし体現しているものはIfCに近い。
  • コード例
bring cloud;

let queue = new cloud.Queue(timeout: 2m);
let bucket = new cloud.Bucket();
let counter = new cloud.Counter(initial: 100);

queue.setConsumer(inflight (body: str): str => {
  let next = counter.inc();
  let key = "myfile-${next}.txt";
  bucket.put(key, body);
});

Darklang

  • 開発元: Dark Inc.
  • 特徴
  • 言語
    • ツール自体の開発言語: F# (Rustと迷ったらしい)
    • ユーザーが使う言語: Darklang
    • 中間生成物の言語: n/a (Deploylessというコンセプトがあり、中間生成物は見えない)
  • デプロイ先: Dark社のPaaS
  • ライセンス
  • ビジネスモデル
    • 不明 今はスポンサーを募集中
    • 順当に行けばPaaSの利用料金?
  • その他
    • 今後は生成AIを組み合わせるアプローチ (DarklangAI) にオール・インするらしい
      • こちらはまだ開発中で詳細不明だが、新生Darkとなるのでは
  • コード例
    • IDEのノードエディタでインフラを定義し、Darklangでアプリを実装する (=DarkのIDE上でしか実装できない)
    • Darklang自体はOCamlに似た言語?すごく・・・関数型です・・・

Klotho

  • 開発元: CloudCompiler, Inc.
    • Riot Gamesの同僚同士が起業?
  • 特徴
    • アプリのインラインコメントでアノテーションすることでインフラを定義する
  • 言語
    • ツール自体の開発言語: Go
    • ユーザーが使う言語: C#, Go, JavaScript, Python, Java
    • 中間生成物の言語: Pulumi
  • デプロイ先: AWS, Azure, Google Cloud (現状はAWSのみ)
  • ライセンス: Apache-2.0
  • ビジネスモデル
    • 追加機能があるKlotho Proを有償提供予定か
  • その他
    • 自動生成のPulumiコードをカスタマイズする仕組みもある Changing Pulumi Parameters
    • 生成AIと対話しながらインフラ定義を生成できるツール (InfraCopilot) を提供予定
  • コード例
/* @klotho::expose {
 *  id = "pet-api"
 *  target = "public"
 *  description = "Exposes the Pet API to the internet"
 * }
 */
app.listen(3000, () => console.log('App listening locally on :3000'));

Ampt

  • 開発元: Ampt Web Services, Inc.
  • 特徴
    • Ampt SDK/CLIというnpmパッケージを用いてJS/TSで開発
  • 言語
    • ツール自体の開発言語: 不明 (Ampt CLIはwasmをNode.jsで動かしている)
    • ユーザーが使う言語: JavaScript/TypeScript
    • 中間生成物の言語: n/a 自分のAWSアカウントにデプロイするわけではない
  • デプロイ先: Ampt社のPaaS
  • ライセンス: ソースは非公開
  • ビジネスモデル
    • まだPrivate betaのため不明。おそらくPaaSの利用料金。
  • その他
  • コード例
import { http } from "@ampt/sdk";

import express from "express";
const expressApp = express();

expressApp.use("/express", (req, res) => {
  res.send("hello express");
});

http.useNodeHandler(expressApp);
http.node.use(expressApp);

Nitric

  • 開発元: Nitric Inc.
    • USの会社だが、メンバーを見るかぎりオーストラリア発のスタートアップと言って良さそう
  • 特徴
    • Nitric SDK/CLIを利用し、多様な言語で開発可能
  • 言語
    • ツール自体の開発言語: Go
    • ユーザーが使う言語: JavaScript/TypeScript, Python, C#, Go, Java
      • gRPCのインターフェースを作れば対応言語を増やせる (各言語はNitric Serverと話すだけ)。jsiiに似た仕組み
    • 中間生成物の言語: Pulumi
  • デプロイ先: AWS, Google Cloud, Azure (いずれもある程度のレベルで対応済み)
  • ライセンス: Apache-2.0
  • ビジネスモデル
    • 不明。これはPaaSもないのでどうするのか
  • その他
    • 実は紆余曲折あって今のIfCなアプローチに辿り着いた模様。この記事は面白い
    • "cloud aware application framework" というのは、よく聞く cloud (vendor) agnostic のもじりだと思われる
      • フレームワークがcloudにawareなので、開発者はcloud agnostic、無知で良い。結局ほぼ同じこと
  • コード例
import { api } from '@nitric/sdk'

const galaxyApi = api('far-away-galaxy-api')

galaxyApi.get('/moon', async ({ req, res }) => {
  res.body = "that's no moon, it's a space station."
})

Encore

  • 開発元: Encore
  • 特徴
    • コメントによるアノテーションでインフラを定義
    • 対応言語はGoのみと割り切っている
    • ローカル開発用のダッシュボードやプレビュー環境 (Encore Cloud) があり、実際にデプロイする前に動作確認できる
  • 言語
    • ツール自体の開発言語: Go
    • ユーザーが使う言語: Go
    • 中間生成物の言語: n/a (AWS SDKなど使い直接インフラリソースを作成する)
  • デプロイ先: AWS, Google Cloud (いずれも同レベルで対応済み)
  • ライセンス: MPL-2.0
  • ビジネスモデル
  • その他
  • コード例
package hello

import (
    "context"
)

//encore:api public path=/hello/:name
func World(ctx context.Context, name string) (*Response, error) {
    msg := "Hello, " + name + "!"
    return &Response{Message: msg}, nil
}

type Response struct {
    Message string
}

Shuttle

  • 開発元: Shuttle
    • UKのスタートアップ
  • 特徴
    • RustのAttributeでインフラを定義
    • 対応言語はRustのみと割り切り
  • 言語
    • ツール自体の開発言語: Rust
    • ユーザーが使う言語: Rust
    • 中間生成物の言語: n/a デプロイはShuttleのサーバー側で実行される
  • デプロイ先: Shuttle管理下のAWSインフラ (今はeu-west-1ロンドンのみ)
  • ライセンス: Apache-2.0
  • ビジネスモデル
  • その他
    • Rust x Dockerの開発体験が悪いため、WASMを使っているとのこと
    • 例によって生成AIと組み合わせたソリューションを提供予定 ShuttleAI
  • コード例
async fn hello_world() -> &'static str {
    "Hello, world!"
}

#[shuttle_runtime::main]
async fn axum() -> shuttle_axum::ShuttleAxum {
    let router = Router::new().route("/hello", get(hello_world));

    Ok(router.into())
}
  • 開発元: Modal Labs
    • ニューヨークのスタートアップ
  • 特徴
  • 言語
    • ツール自体の開発言語: Python
    • ユーザーが使う言語: Python
    • 中間生成物の言語: n/a
  • デプロイ先: Modal社のPaas
  • ライセンス: Apache-2.0
  • ビジネスモデル
    • PaaS
  • その他
    • Modalのインフラにデプロイするためのツールと捉えても良さそう
    • MLインフラ用途なので、Pythonだけで良いと判断したのだろう
  • コード例
from modal import Stub, web_endpoint

stub = Stub()

@stub.function(gpu="A100")
@web_endpoint()
def f():
    return "Hello world!"

ということでIfCツール7つでした。それぞれ個性があって面白いですね。各社のサイトでは既存ツールとの比較などもされているので、気になったものがあれば深掘りしてみると良いでしょう。

また参考までに、各GitHubリポジトリスター数はこちらです。それぞれの登場時期や人気度が見て取れますね。

番外編

以下は本流ではないものの本記事の文脈には沿っているので、いくつかのツールを紹介します (メモ程度です。)

AWS Chalice

2016年頃から存在するAWS公式プロジェクト。当時はIfCという概念はなかったが、アプリのコードからAPI GatewayやLambdaのCloudFormationテンプレートが生成されており、やっていることはたしかにIfC。Python専用。

from chalice import Chalice

app = Chalice(app_name='helloworld')

CITIES_TO_STATE = { 'seattle': 'WA', 'portland': 'OR' }

@app.route('/')
def index():
    return {'hello': 'world'}

@app.route('/cities/{city}')
def state_of_city(city):
    return {'state': CITIES_TO_STATE[city]}

Functionless

サーバーレスのサーバーを意識しないで良いというコンセプトから転じて、FunctionlessはLambda関数 (Function) を意識しないで良い。Step FunctionsやAppSyncをLambda意識することなく書くことができた。すでに公式サイトも消滅しており、放棄された様子。

const getItem = new ExpressStepFunction(stack, "Function", async () => {
  $SFN.waitFor(10);

  const status = await $AWS.DynamoDB.GetItem({
    Table,
    Key: { id: { S: "string" } },
  });

  if (status === "FAILED") {
    throw new Error("Failed");
  }
});

CAIOS - Cloud AI Operating System

"IfC"で調べると古い記事によく出てくる名前。ドキュメントもあるが、詳細はあまり見えてこない (そもそも試せない。) PythonSDKで開発するタイプだと思われる。

System initiative

最近登場したノーコードのインフラ開発ツール。CloudFormationデザイナーをとても強くしたものという印象。動画を見る限りは便利そう。 特にこういうのが好きな人はいると思われる (視覚的に理解するタイプというのか)。

youtu.be

所感

今回調査した上での感想です。

各ツールを俯瞰すると、実はそれぞれのアプローチにはパターンがあり、いくつかの観点で分類できます。

インフラの抽象度 は大きく2つに分かれます。自前のプラットフォームにデプロイするタイプ (Dark, Ampt, Shuttle, Modal) と、ユーザーのクラウド環境にデプロイするタイプ (Wing, Klotho, Nitric, Encore) です。個人的には、前者の方が完全にインフラが隠蔽されており、細かいことを気にしなくて良い (気にすることができない)ので好きです。(Heroku 2.0だというそしりも受けそうですが…) 一方後者は、結局細かいところをカスタマイズしたくなり苦労しないかという不安はあります。抽象化に伴う恒例のつらみですね。また、前者のほうがビジネスとして成立させやすそうです (Terraformの苦労を見ていると…)。

対応言語 にも違いがあり、多様な言語をサポートするタイプ (Klotho, Nitric)、単一の既存言語を使うタイプ (Encore, Shuttle)、独自言語を使うタイプ (Wing, Dark)に大別されます。対応言語が多いほどユーザーは新しく言語を覚える必要がなくなります。一方対応言語が少ないほど開発体験を最適化しやすそうです。また独自言語は最適化の極致ですが、エコシステムの問題を抱える不安はあります。AWS CDKが結局ほとんどTypeScriptで使われていることを考えると、あまり手を広げなくても良いのかなという気もしてきますね。

インフラの定義方法 も、コメントによるアノテーション (Klotho, Encore)、デコレータやアトリビュートを使ったアノテーション (Shuttle, Modal)、SDKの利用 (Ampt, Nitric)などに大別されます。これは結局エディタが補完などをうまくサポートしてくれれば、開発体験に大きな差はでないのかなと想像しました。

また、生成AIと組み合わせる 傾向もありました (Darklang, Klotho, Shuttle)。これは単に新しく勢いがあるものが合体しているだけかもしれません。あるいはIfCでは多くのコードが自動生成されるため、開発者=AIが書く必要があるコードはより少なくなります。AIが生成するコードが少なくてよいほど出力の精度は上がると考えられるため、たしかにIaCなアプローチよりも相性は良いのかもしれません。

という分類をしてみたものの、私自身はこれらのツールが流行るのか、そもそも生き残るのか定かではありません。 個人的にはAWS CDKが好きなこともあり、AWSの構築ならもうオールウェイズCDKで良いでしょうという認識が今も強いです。

とはいえ各IfCツールを俯瞰してみると、共通してインフラを意識させたくないという意図を感じます。たしかに典型的なサーバーレスウェブアプリを作るとき、インフラに創意工夫が必要なことは少ないので、割り切って隠蔽してしまうというアプローチはありかもしれませんね。

ただし、やはり複雑で固有な要件を持つシステムでは採用しづらいのは間違いないと思います。抽象化により失われるものは往々にして柔軟性です。このため、IfCがIaCを取って代わるというよりは、これもまた適材適所で使い分けるツールとなるでしょう。

今週のもなちゃん

最後にもなちゃんの写真です。先月から換羽でナーバスなことが多いですが、たまにリラックスした顔を見せてくれます。

参考資料

AWS Lambda Pythonでsqlite-vssによるベクトル検索を利用する

昨今LLMの台頭により、テキストをベクトル化して類似文書の検索に利用する手法が流行っています。 今回はAWSでこの検索を実現するための一方法として、SQLiteプラグインであるsqlite-vssをAWS Lambda上で使う方法をまとめます。

github.com

意外とハマりどころや特有の考慮事項が多いので、必見です!

アーキテクチャ

LambdaでSQLite?と思った方のため、このアーキテクチャの要点をまとめます。

このアーキテクチャのメリットは、完全なサーバーレスでベクトル検索を実行できる点です。OpenSearchやPostgres (pgvector)、Redisなどのインスタンスを管理する必要はありません。サーバーレスの利点はもはや言うまでもないでしょう。

また、SQLiteを使うため、ベクトルだけでなく他のリレーショナルなデータをあわせて格納できる点も便利でしょう。例えば検索対象のドキュメント群に関するメタデータとembeddingベクトルをあわせて保存するような使い方が想定できます。

一方SQLiteを使う明らかなデメリットは、分散環境下で書き込みを同期することが難しい点です。 それぞれのLambdaはローカルのファイルシステムにある別々のデータベースファイルを参照しているため、書き込みは同期されませんし、そもそもLambdaのライフサイクルに応じて変更はリセットされます。 これはS3やLitestreamといったソリューションによりなんとかできるかもしれませんが、いずれにせよ大変ではあるでしょう。 では、どうすれば楽できるでしょうか?

CQRS的な考え方を使いましょう。データベースから検索するだけのアプリにとっては、そのデータベースは読み込み専用です。上記のアーキテクチャにおいては、LambdaにとってSQLiteデータベースは読み込みのみ・書き込まないという役割の分離ができます。これにより、Lambda側ではデータベースへの書き込み同期という問題を無視できます。

では書き込みはどうするのでしょうか? ドキュメント検索などの用途でベクトルデータベースを用いる場合、データベースは大量のドキュメントをクロールして作成される事が多いでしょう。このクローラーがデータベースを更新する役割です。

データベースが更新されたとき、どのようにLambdaに反映すればよいでしょうか?今回はLambdaのコードパッケージにSQLiteデータベースを同梱する想定なので、DBの更新はLambdaを再デプロイすることで行います。この方法は更新の頻度が高い場合はそれだけコールドスタートが頻発するなど、不都合があるかもしれません。しかし実用上は、この更新処理はインクリメンタルに実行するというよりは、定期的なバッチ処理で十分なことも多いはずです。例えば1日1回のデプロイで更新する程度であれば、大きな問題はないでしょう。

あるいはSQLiteデータベースをS3などに配置し、Lambdaから定期的にプルするという方法も考えられます。しかしこれは明らかに考慮事項が増えるので、一旦は上記の方法がシンプルで良いでしょう。

ということで、SQLiteによるベクトル検索 on Lambdaが実用できそうなことがわかりました。次は実際にデプロイする方法を見ていきます。

Lambdaでsqlite-vssを利用するためのコード

Lambdaでsqlite-vssを利用するためには、以下のポイントが重要です。

sqlite-vssをPythonから呼び出す

sqlite-vssは各言語から簡単に利用できるよう、ライブラリが用意されています。以下はPython向けパッケージをインストールするためのPoetryコマンドです:

poetry add sqlite-vss

このパッケージはSQLiteのエクステンションとしてロードするためのビルド済みバイナリが含まれます。このため一部のプラットフォームでは利用できず、現状はMac (x86_64/arm両方)とLinux (x86_64のみ)サポートされていることに注意してください。また、Ventura未満のバージョンでArm Macを使う場合は、2023/07現在 -allow-prereleases フラグが必要です (参考)。

パッケージをインストールできたら、あとはこちらのドキュメントに従えばPythonからsqlite-vssを利用できます: sqlite-vss with Python | sqlite-vss

Tips: MacSQLiteのエクステンションをロードできるようにする

Macで使う場合、通常の手順でインストールしたPythonではSQLiteのエクステンションをロードできないことがあります。pyenvでPythonをインストールする際にフラグを指定することで、この問題を解消できます (参考):

brew install sqlite
export LDFLAGS="-L/opt/homebrew/opt/sqlite/lib"
export CPPFLAGS="-I/opt/homebrew/opt/sqlite/include"

# Pythonバージョンは任意で設定する (以下は3.10.9の例)
CONFIGURE_OPTS=--enable-loadable-sqlite-extensions pyenv install 3.10.9

Lambda向けのDockerfile

sqlite-vssを利用する場合、コンテナLambdaを利用するのがお勧めです。標準のPythonランタイムでは、依存するライブラリ (glibc, blasなど) が実行環境に存在しないため、sqlite-vssを利用するのが困難です。同様の理由で、Lambda向けのDockerイメージ amazon/aws-lambda-pythonglibcが古いために利用が困難です (参考)。

踏まえると、Dockerfileは以下のようになります:

FROM --platform=linux/amd64 python:3.10

# lapack is required for sqlite-vss
RUN apt-get update && apt-get install -y liblapack-dev \ 
    && apt-get clean && rm -rf /var/lib/apt/lists/*
RUN pip install "poetry==1.5.1"

# Use Lambda web adapter (optional)
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.7.0 /lambda-adapter /opt/extensions/lambda-adapter
ENV PORT=8000

WORKDIR /app
COPY poetry.lock pyproject.toml ./

RUN poetry config virtualenvs.create false \
  && poetry install --only main --no-interaction --no-ansi

// ここでSQLiteデータベースのファイルもコピーする
COPY . ./

CMD ["python", "backend/api.py"]

基本的には普通のPython (w/ poetry)のDockerfileです。何点かポイントがあります:

  • ベースイメージはDocker公式のpythonを利用しています。新しめのイメージなら何でも良いと思います。
  • liblapack-devをインストールしていることに注意してください。これはsqlite-vssの依存関係の1つですが、今回のベースイメージには不足しているため、追加でインストールします。ちなみにyum系なら yum install -y lapack で同様にインストール可能です。
  • コンテナイメージ内にSQLiteデータベースのファイルを含めます。冒頭に説明した通り、データベースを更新する際はイメージをビルドしてLambdaを再デプロイします。
  • Lambda Web Adapterを利用していますが、これは必須ではありません。FastAPIをサクッとコンテナLambdaで動かすには非常に便利なので、ぜひお試しください。

aws.amazon.com

Tips: SQLiteのバージョンを上げる

sqlite-vssではSQLiteのバージョンにより若干使い方が変わります。具体的には、SQLiteが3.41.0未満の場合は、クエリする際のパラメータ指定方法がやや冗長になります (参考)。できればより便利な記法を使える3.41.0以上のSQLiteを使いたいものです。

Pythonimport sqlite3 した際のSQLiteのバージョンはDockerのベースイメージにより決まるため、python:3.10 などのイメージを使う場合は不可抗力的に3.41.0未満になってしまうことがあります。バージョンを変えるためには、 pysqlite3-binary パッケージを導入するのが楽です。これはパッケージ内にSQLiteバイナリを同梱することで、任意のバージョンのSQLiteを使えるようにしたパッケージです。pysqlite3-binaryの0.5.1から、SQLiteが3.41.0以上(3.42.0)になっています。

pysqlite3-binary は現状Linuxでのみ利用できるため、Poetryもplatform指定でaddしましょう:

poetry add pysqlite3-binary --platform linux

pysqlite3-binaryはsqlite3と同じAPIを持つため、以下のようなコードを書けば、Macではsqlite3・Linuxではpysqlite3-binaryを利用するようにできます:

import sqlite_vss
try:
    import pysqlite3 as sqlite3
except ModuleNotFoundError:
    import sqlite3

db = sqlite3.connect("test.db", check_same_thread=False)
db.enable_load_extension(True)
sqlite_vss.load(db)
db.enable_load_extension(False)

ちなみに check_same_thread=False を指定しているのは、FastAPIで簡単にSQLiteを使うための措置です。こうしないとFastAPIのスレッド間で db を共有できず不便でした。Lambdaではリクエストは同時に1件しか処理されないため、特に問題ないでしょう (そもそも読み込みしかしないので、データレースは起きない)。

これにより、任意のDockerイメージで最新のSQLiteを使えるようになりました。

コンテナLambdaのデプロイ

あとは通常の手順でAWSにデプロイするだけです。例えばAWS CDKを使う場合、コードは以下の通りです。Amazon API Gateway HTTP APIから叩けるようにしています。

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { DockerImageCode, DockerImageFunction } from 'aws-cdk-lib/aws-lambda';
import { HttpApi } from '@aws-cdk/aws-apigatewayv2-alpha';
import { HttpLambdaIntegration } from '@aws-cdk/aws-apigatewayv2-integrations-alpha';
import { Platform } from 'aws-cdk-lib/aws-ecr-assets';

export class SqliteVssOnLambdaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const handler = new DockerImageFunction(this, 'Handler', {
      code: DockerImageCode.fromImageAsset('backend', { platform: Platform.LINUX_AMD64 }),
    });

    const api = new HttpApi(this, 'Api');
    const integration = new HttpLambdaIntegration('Integration', handler);
    api.addRoutes({
      path: '/{proxy+}',
      integration,
    });
    new cdk.CfnOutput(this, 'ApiUrl', { value: api.url! });
  }
}

実際のアプリケーションのコードはほぼ含めていませんが、公式ドキュメントを見ればわかるので省いています。

気付き・注意事項

上記のアーキテクチャを構築・運用する上でいくつかの考慮事項があるので、以下に気づいた範囲でまとめます:

Faissについて

sqlite-vssにおいてはベクトル検索のためにFaissというライブラリが使われています。Faissはsqlite-vssを利用する上で完全に抽象化されているわけではないので、理解しておく方が良さそうです。概要はこちらを見るのが分かりやすいです。

Introduction to Facebook AI Similarity Search (Faiss) | Pinecone

ベクトル検索は類似度が高いベクトル同士を見つけることが目的です。単純な方法は全てのベクトルと類似度を計算すれば良いでしょう。この方法は厳密な解が得られ正確であるものの、計算量がベクトルの個数に比例するため、大規模なデータでは使いづらいです。厳密解を近似することで、解の精度が下がることをしつつ、計算量を下げることができる近似アルゴリズム (Approximate Nearest Neighbor, ANN) が存在します。

sqlite-vssでのデフォルト設定では全探査のアルゴリズムのため、厳密解が得られるものの計算量は非効率です (とはいえ10万とか小規模なデータセットなら十分実用的なようです (後述))。 sqlite-vssが利用しているFaissでは、いくつかの代表的なANNアルゴリズムをサポートしています。 アルゴリズムの選定法はFaiss公式Wikiが詳しいです。

Guidelines to choose an index · facebookresearch/faiss Wiki · GitHub

一部の近似アルゴリズムを使う場合は、学習の手順が追加で必要になります。sqlite-vssでは、SQLiteのインターフェースから学習処理を実行できます: operation='training' 利用するデータセットのベクトルの一部をランダムにサンプルし、教師データとして与えるようです。

このスライド も、ベクトル数とアルゴリズムの対応に関して参考になります。少し上で見積もった数字と、概ね一致しています。

また、デフォルトではベクトルをインデックスに入れる際圧縮しないため、インデックスのサイズが大きくなるようです。これはProduct Quantizationという手法で節約できます。これも検索精度とRAMサイズのトレードオフになります。

とはいえ次で考える通り、実用上Lambdaではあまり大規模なデータセットを扱えなさそうなので、検索アルゴリズムはデフォルトのままで良いかもしれません。

ベクトル数の限界について

このアーキテクチャだとどの程度の件数のベクトルまでデータベースに保持できるでしょうか? これを考えるには、1. 検索処理時間 と、2. RAM消費量 を考える必要があります。

検索処理時間は、デフォルトのアルゴリズムだと計算能力に反比例、ベクトル数に比例 (後述)します。1000件程度のベクトル数 (次元数1536) で試したところ、RAM=128MBのLambdaだと約5msで検索できました。仮に50ms以内にレスポンスを得るとした場合、RAM=10GB (最大) のLambdaでは、

10240/128 * 50/5 * 1000 = 0.8e6

ということで80万件程度であれば処理できるようです。Lambdaでは計算能力がRAMサイズに比例します。ちなみにRAM=1GB、500msにしても同じく80万件です。

次にRAM消費量は、デフォルトのアルゴリズムでは1ベクトル当たり 4x(次元数) Bytes 消費します (参考)。 次元数=1536、RAM=10GBとすれば、

10240*1e6 / (4*1536) = 1.67e6

ということで167万件程度が限界になります。

上記は非常にざっくりとした見積もりですが、先程のスライドとも概ね一致している数字です。いずれにせよ数千万以上の規模は難しいですし、1~10万程度であれば十分実用できそうなことが分かります。

コスト効率について

上記の通り、ベクトル件数が多い場合はLambdaに高いRAMサイズ (1~10GB) を割り当てる必要が生じるでしょう。

Lambdaに高いRAMサイズを割り当てる場合、気になるのはI/O待機時間の課金です。例えば外部のLLMに文章のembeddingベクトルを計算させているときは、Lambdaは何も仕事ができません。Lambdaは同じインスタンスで1リクエストしか処理できないため、I/O多重化によるコスト節約が難しいのです。

これを回避するには、ベクトル検索用のLambdaは別のAPIとして切り出し、I/Oが発生しないようにするのがベターです。 こうすれば、RAMサイズの大きなLambdaの利用率を高め、コスト効率を高めることができるでしょう。

Lambdaのコールドスタートについて

より深刻かもしれない問題もあります。sqlite-vssは初回クエリ時にFaissのインデックスを初期化・メモリ上に展開するため、Lambdaのコールドスタート後1発目のクエリ応答時間が長くなります。

例えば上記で試した1000件のベクトル・128MBのRAMの場合、1発目のクエリのみ2秒程度 (通常は約5ms) かかりました。この初期化時間もベクトル数に応じて大きくなるはずなので、より大きなデータセットでは深刻になりえます。

これはLambdaのSnapStartが本命の解決策 (はよ…!) ですが、一旦はタイムアウトしたらクライアント側でリトライするしかないかもしれません。

ベクトル検索を自分で管理することについて

正直なところ、ベクトル検索を自前で管理するのは、必要な知識や経験が多く難しそうです。 特にアルゴリズムの部分はパラメータも多く、この分野に詳しい専門家が不在の状況では最適化は困難に思えます。

これらを考慮すべきなのはOpenSearchなどを使っても同様のようです。(以下の記事は各アルゴリズムの紹介に加えてベンチマークもあり、非常に分かりやすいのでおすすめです。) 最適化の余地があるのは好ましいことでもあり、いい感じにやってくれれば良いんだけどなという思いも捨てきれません。

aws.amazon.com

ベクトルデータベース as a ServiceであるPineconeでは、検索周りのパラメータはもう少し抽象化されておりユーザーはインスタンスサイズを選ぶだけで良いようです。 AWS Marketplaceから利用することもできます。

更に抽象化したければ、Amazon Kendraも使えます。ここまでくれば、もはやユーザーはLLMやベクトルを意識する必要すらありません。良い感じにドキュメント検索をするためのソリューションです。

手段は色々あるので、状況に合わせて最適なものを選択したいですね。

まとめ

Python Lambdaでsqlite-vssによるベクトル検索を実現する方法をまとめました。様々な考慮事項があるので、実際に試しながら検討していくことをおすすめします。

AWS CDKのカスタムリソースをCodeBuildで処理する

AWS CDKを使えば、CloudFormationのカスタムリソースをLambda関数で簡単に定義することができます。 しかしながら、時にLambda関数だけでは都合が悪く、別のコンピュート環境を使いたくなる場合もあるでしょう。

私の作っている deploy-time-build というCDKコンストラクト (詳細はこの記事も参照) でも、最近カスタムリソースのメイン処理をCodeBuildで実行するようにしたので、その時の知見をまとめます。

Why not Lambda?

deploy-time-build では、CFnデプロイ時にカスタムリソースを使ってNode.jsアプリ (主にReactなどフロントエンドアプリを想定) をビルドします。これを実現するため、以前はカスタムリソースハンドラのLambda関数自体の中で npm run build などのコマンドを実行していました。

https://github.com/tmokmss/deploy-time-build/blob/v0.2.3/imgs/architecture.png?raw=true

Lambdaを使う方法は、起動が速いことや構築が簡単というメリットがある一方で、次の問題があることもわかりました:

  • Lambdaのfile descriptor上限数問題: Lambdaではfile descriptor数のハードリミットが1024です。しかしながらnpmは npm ci などの操作時に多くのfile descriptorを開く必要があるようでした。実際にそれなりの規模の package-lock.json をもつプロジェクトで npm ci を実行したところ、too many open files のエラーが出ることを確認しています。これを修正するにはLambdaのハードリミットを上げるか、npmを改善する必要がありますが、前者は現状不可能ですし後者は困難でしょう。
  • Lambdaのストレージは/tmp以外読み込み専用: Lambdaのエフェメラルストレージは、/tmp 以外書き込むことができません。一方で npm はデフォルトで /tmp 以外のストレージにも書き込みを行う (キャッシュなど) ため、追加の設定が必要になります。コレ自体は設定すれば解決する話ですが、npmパッケージの中には特定ディレクトリへの書き込みが必要な場合もあるなど考えると、不安要素ではありました。
  • Lambdaの実行時間15分制限: ビルド環境として使う場合、大きなプロジェクトではビルドが長時間になることもあるでしょう。Lambdaではビルドに15分以上かかるプロジェクトを扱いづらいというのも、汎用ライブラリとしては不安です。
  • Node.jsのバージョンを変えにくい: ビルド環境のNode.jsバージョンはユーザーに自由に選ばせたいところですが、私の試した限りLambdaのNode.js 16環境ではnpmを使えませんでした。このため、現状LambdaがサポートしているNode 14 or 18のみしか対応できず、またビルドスクリプトを両方のランタイムで動作確認するのが大変だという問題もあります。

Why CodeBuild?

上記の背景により、別のコンピュート環境を検討することにしました。満たすべき条件は以下のとおりです:

  • Lambda環境に課せられていた制約 (上記) ができるだけ存在しない
  • Lambdaに近いレベルで手軽に使える (VPCレス、起動速い、時間課金など)

一番に思いつくのがAWS CodeBuildです。VPCレスで使える毎分の従量課金なサーバーレスのコンピュート環境です。

そもそも今回本来の目的はアプリのビルドなので、まさにそのためのサービスとも言えるでしょう。 Lambdaのように実行時間やストレージに制限はなく、Node.jsのバージョンも自由に変更できます。 file descriptor数に関連するドキュメントは見つけられませんでしたが、実際試したところ問題ありませんでした (そもそも大抵のビルド用途には対応できるはずのサービスなので、問題になるとは考えづらい)。

CodeBuildのデメリットとしては (あえて挙げるなら) 以下が考えられます:

  • 最小課金単位時間やベースの料金を比べると、Lambdaよりは利用料金が若干高くなる
    • 1分あたり0.7円程度なので、デプロイ時のビルド用途としては多くの場合許容できるでしょう
  • ビルドをリクエストしてから実際にビルド処理が始まるまでに、ビルド環境のプロビジョニングなどで追加で30秒程度掛かる
    • Lambdaよりは遅いですが、CloudFormationデプロイ中の待ち時間としては許容できるレベルでしょうか

今回は他の手段を考えてもCodeBuildより際立って良いものはないので、デメリットも受け入れて採用することにしました。

変更後のアーキテクチャは下図になります (リリース済み):

https://github.com/tmokmss/deploy-time-build/blob/main/imgs/architecture.png?raw=true

開発中の気付き

上記を実装した際の考慮事項・気付きをつらつらとメモります。似たようなものを作る際は、お役立てください。

CloudFormation カスタムリソースのコールバックは誰が叩くのか

カスタムリソースハンドラーは非同期に実行されるため、処理が終了したときはCloudFormationに通知する必要があります。 Lambdaハンドラが呼びされた時にコールバック先のURLをCloudFormationから渡されるので、そちらにリクエストを送れば良いです。 コールバックを叩かないかぎりCloudFormationスタックのデプロイは完了しません。その場合でも1時間後にタイムアウトしますが、できる限り確実にコールバックを叩けるに越したことはないです。

では、リクエストはどこから送るのが良いでしょうか? Lambda関数単体でカスタムリソースの処理をする場合は、そのLambda内でコールバックを叩けば良いでしょう。 今回はCodeBuildの処理が完了してからコールバックを叩く必要があるので、いくつかの方法が考えられます:

  1. CodeBuildのビルドジョブ内で、ジョブ終了時に叩く
    • 単純
    • これだけではコールバックを叩けない場合があることに注意 (CodeBuildのジョブ起動に失敗するケースなど)
  2. CodeBuildのジョブステータス変化イベント (EventBridge) を連携した先で叩く
    • 堅牢
    • イベントはat least onceで配信されるので、ジョブが完了・失敗した際にコールバックを叩けない可能性は極めて低い (イベント連携先のサービスでFailしない限り)
    • EventBridgeからLambdaを呼んでも良いですし、Step FunctionsでStartBuild APIを同期で呼び出せば、イベントの処理が透過的になり楽
  3. CodeBuildのビルドジョブのステータスをポーリングして、ジョブが完了したら叩く
    • 単純
    • ポーリングのためのリソースがややもったいない
    • 仮にLambdaでポーリングする場合は15分制限に注意したいところ

今回は2を避けることにしました。ライブラリの使用者目線では使うAWSサービスをいたずらに増やさないほうが良いだろうと考えたためです。 使うサービスが増えるほど個々のユーザーのセキュリティポリシーなどに違反する可能性が高まりますしね。 代わりに1を採用しました。今のところ、ジョブ起動に失敗する可能性は極めて稀だと考えられるためです (ユーザーにランタイムを選択する自由度を与えていないため、そのエラーは生じえない)。これでしばらく様子見して、もしコールバックが叩かれずにデプロイがスタックしたぞという報告があれば、対応を考えたいです。

さらなる改善案は1と3のハイブリッドで、StartBuild APIを呼んだあと数分間同じLambda内でジョブのステータスをポーリングし、起動に失敗していたらコールバックを叩くというのは考えています (起動のエラーは数分以内に発生するだろうと想定)。とはいえここまでするなら、2のStep Functionsを使う方向に切り替えるかもしれません。

実装の詳細を隠蔽しておいて良かった

今回LambdaからCodeBuildへと大きく内部実装を変更しましたが、コンストラクトのAPI自体は破壊的変更なしで済みました。 こんなこともあろうかと、APIからは徹底して実装の詳細を隠蔽していたためです (例えばRAMサイズなど、Lambdaに特有のプロパティを設けていない)。

new NodejsBuild(this, 'ExampleBuild', {
    assets: [
        {
            path: 'example-app',
            exclude: ['dist', 'node_modules'],
        },
    ],
    destinationBucket,
    distribution,
    outputSourceDirectory: 'dist',
    buildCommands: ['npm ci', 'npm run build'],
    buildEnvironment: {
        VITE_API_ENDPOINT: api.url,
    },
});

もし破壊的変更が加われば、ライブラリの全ユーザーに単純なバージョンアップだけではない対応を強いることになるかもしれません。 最近はこのライブラリが会社のチーム内で使われることも増えているので、それらの対応工数を減らせたことになり、これは我ながらGJでした。

プロジェクトに固有のCDKコンストラクトを書くときは、カジュアルに実装の詳細を公開することも多いと思います。例えば中身のLambda Functionへのアクセサーを設けたりなど。一方でこうした汎用コンストラクトライブラリを実装する際は、できるだけAPIの抽象度を高く保つことで、今後の内部的な変更を容易にできるかもしれません。

餅は餅屋

元々の実装で、ビルドの実行環境としてLambdaを使っていたことに違和感を覚えた方もいるかもしれません。 その感覚は正しく、私も結果的には最初からCodeBuildを採用すべきだったと思います。Lambdaにビルドさせるのも技術的には可能ですが、結局上記の通り様々な不都合が生じました。

元々、カスタムリソースのハンドラとしてはLambdaを使うのがシンプルだと思い、安直にLambdaでビルドまで済ませようとしたのですが、結局実装としてはより複雑になってしまった印象です。主にはLambda上でnpmコマンドを使うためにいくつかの工夫・ハック () が必要なためです。シンプルさを求めた結果逆に複雑になってしまうという、ありがちなアンチパターンだと思います。

素直に、各サービスを各サービスの得意な方法で使うのが良いでしょう。

今後の展望

これまではLambda上で実行していたためにNode.js以外のビルドに対応することが大変でした。 CodeBuildではビルド環境をより柔軟に構成できるため、より幅広い用途に活用を広げることができると思われます。

とはいえデプロイ時にビルドしたいユースケースはあまり多くない (自分の観測範囲ではフロントエンドの環境変数埋め込みくらい) ように思うので、何かある方はIssueで教えてください。

まとめ

自作コンストラクdeploy-time-build でカスタムリソースの処理にCodeBuildを利用した話をまとめました。参考になれば幸いです。

完璧より簡潔が良い - バグ修正編

最近タイトルの考えに至ることが短期間に2度あったので、改めて書き出してみる。

問題提起

例えば今、何らかのコードのバグ修正を試みている状況だとする。バグの原因は考慮漏れのエッジケースがあったとしよう。 修正のアプローチとして以下2つがあるとしたら、どちらを選ぶかという話。

  1. 完璧: そもそもエッジケースが生じない実装にまるっと書き換える
  2. 簡潔: 考慮されていなかったエッジケースに対する分岐を新たに追加し、バグを回避する

完璧・簡潔というのは私がこの記事で勝手に付けた呼び方なので、他所では通じないことに注意。

結局答えは「場合による」なのだが、この記事ではもう少し解像度を上げることを目指す。

具体例

上の2択だと抽象的すぎるので、一つ具体的なバグで考える。この記事を書くに至ったきっかけの一つ。*1

github.com

これはAWS CDKのバグなのだが、要はある条件の論理和を取る関数があり、その関数に渡す条件の配列の長さが 10n+1 (11, 21など) のときにエラーが起きるというもの。 *2

下にイメージを示す。 Orの引数の配列は上限長が10という制約があるため入力の配列は長さ10ごとに分割されるのだが、元々の実装で「Orの引数の配列は下限長が2」 という制約が見逃されていたためにバグが発生した。

generateOr([1,2,3,4,5,6,7,8,9,10,11]) # 入力
↓
Or([1,2,3,4,5,6,7,8,9,10,11])
# Orの引数の配列は上限長が10という制約があるので、長さ10ごとに分割する
↓
Or([Or([1,2,3,4,5,6,7,8,9,10), Or([11])])
# Orの引数の配列は下限長が2という制約があるので、Or([11])はエラーになる

これに対する修正方針はいくつか考えられるが、以下に代表例を2つ挙げる:

  1. 完璧: 1個余る場合は、その配列だけOrを適用しないようにする
    • Or([Or([1,2,3,4,5,6,7,8,9,10), 11)])
    • 根本原因に対処するアプローチ。その分実装の変更量は増える
  2. 簡潔: 入力の長さが 10n+1 のときは長さ9ごとに分割する
    • Or([Or([1,2,3,4,5,6,7,8,9]), Or([10, 11])])
    • 既存の処理を使いまわしながら少ない実装変更で済むアプローチ

上記の抽象的な話と対比されたい。今回は、こうした選択肢がある場合にどちらが良いかという話をする。

完璧より簡潔が良いこともある

私自身は、これまで基本的には1の完璧アプローチを好んできた。

2の簡潔アプローチは対症療法のようなもので、根本的な解決とならない場合もある (実際上の具体例では、配列長が90n+1の場合に同じ問題が再発する。) また、バグを含むコードというのは不必要に複雑なためにエッジケースが生まれていることもあり、そのような場合は根本から書き直したくなりがちだった。

直感的にも、1のアプローチをより優れたものだと考える人は多いのではないだろうか。

しかしながら、改めて考えると簡潔アプローチにも良い点はある。

簡潔アプローチの良さ

簡潔アプローチの良さとして、下記が思いつく。

正しさ・破壊的変更のないことを確認しやすい

簡潔アプローチではしばしば既存の処理に分岐が追加されるだけなので、基本的には以下を確認すれば実装の正しさが分かる:

  • 分岐の条件は必要十分か
  • 分岐内での処理は正しいか

もう少し一般化できるかもしれないが、上記の具体例で考えればひとまず納得はできるだろう。 少なくとも分岐の条件が適切であれば、破壊的変更が生じることはない。

対して完璧アプローチでコードを大幅に変更すると、従来うまく動いていた部分にもバグが発生する可能性を確認する必要がある。 不意に破壊的変更が生じている可能性も無数に考えられる。結果として、確認範囲は大きく広がる。

簡潔アプローチは修正に問題がないことを確認するのが容易である。

レビューしやすい

上記の理由の副産物として、レビュワーも差分を承認しやすくなるだろう。

レビュワーの心理の一つとして、自分の承認したPRでクリティカルなバグが発生することは防ぎたいものだ。 完璧アプローチは差分の影響範囲が膨大で、バグが発生するかどうか判断するのに多大な労力が必要になることもあるだろう。

特にOSSでは、見ず知らずの開発者からパッチが飛んでくることもある。 面識のない相手であれば、せめて変更を確認しやすいパッチがより好まれるのも不自然ではない。

必要性の薄いコードの追加を避けられる

一般に、システムは機能が多ければ多いほど良いというものではない。 機能が増えるほど、メンテナンスすべきコードの量も増えるためである。 このため、必要性の薄い・不明な機能は必要性が判明するまで実装するのを避けるというのも、一つの合理的な選択と言える。

これを念頭に完璧アプローチを考えると、報告されたバグ修正の「ついでに」他のエッジケースへの対応が含まれることも多い。 これが直ちに悪いこととは言い難く、むしろ気が利いていると判断されることも多いだろう。 一方、誰も文句を言っていないケースに対する修正によりメンテすべきコードを増やしているので、コスパが悪いとも言えるかもしれない。

どちらが好まれるかは状況によるだろうが、一概にバグをすべて修正するのが最適だとは限らないことに注意されたい。


私の考えた限りでは、簡潔アプローチにも上記の利点がある。 個人的な直感では完璧アプローチのほうが優れているような気がしていたが、状況次第では簡潔アプローチを採るのが良いことは普通にあると思われる。

そういえば前職では共通基盤的なサービスを運用しており、ユーザーからは安定稼働することが強く求められた。 当時は自分も保守的なアプローチとして、コード差分が少なくなるような、簡潔アプローチに近い選択をすることが多かったように思う。

人によってはこんなの当たり前だろ?と思うかもしれない。 私自身は最近仕事で新規開発が多く安定性は二の次だったので、別の観点から物事を見直す良いきっかけになった。

コードの正しさを証明するのが難しい件

そもそも完璧アプローチを採ったとして、それをマージさせるのは大変だという話もついでに。

そのようなパッチをマージさせるには、次の方法がある:

1. コードの正しさを証明する

変更後のコードが正しく動くことを客観的に示し、レビュワーを納得させる。これは理想的だが、現実的には以下の通り難しい。

単に「既存の単体テストが全てパスしています」というのではしばしば不十分である。 元々実装されているテストがすべてのケースをカバーしているとは限らず、「単体テストのパス」=「コードが正しい」とは判断できないため。

では単体テストを充実させれば良いのかというと、いずれにせよ無限にある入力パターンをすべてテストすることはできない。 境界値テストなどの手法もあるが、これも一つのヒューリスティクスにすぎないので、それ以外にバグがある可能性は完全には否定できない。 こう考えると、そもそもが悪魔の証明に近いのかもしれない。

この問題を解消するために、形式手法と呼ばれる、コードの正しさを論理的に証明する方法がある。 私は詳しくはないが、これはこれですべての問題をうまく形式化できるのかとか、形式手法のコードを実際の実装と一致するようにメンテし続けられるのかなどの問題は素人考えでも思い当たる。

形式手法の限界については、以下の記事がとても良くまとまっているように見える。上記に思いを馳せた上で以下の記事を読むと、なるほど納得できることが多い。

qiita.com

あるいはハイブリッドな手法として、形式手法的な考え方をうまく図や自然言語で説明できれば、正しさについてより説得力をもたせられる気がする。 ここはまだ自分の中では感覚的なものにとどまっており、あまり言語化できてない。要精進。

2. 問題が報告されたら直ちにロールバックする覚悟でマージする

コードが正しいことを完全に証明するのはコストが高いのは分かったので、ある程度で妥協して、あとは実地試験すれば良いという考え方。 問題があった場合の計画を事前に立てておき、例えばロールバックなどの対応をする。

昨今運用上のベストプラクティスとしてエラーバジェットを定義しましょうとよく言われるが、その目的の一つがこうしたリスクを取れることだろう。 コードの正しさを100%示すのは非常に大変だが、80%で良ければ2割のコストで確認できるかもしれない (パレートの法則的な)。

また、カナリアリリースやBlue / Greenデプロイもそのための方法と言える。誤ったコードをデプロイする際のリスクを低減する手段であるため。 上の具体例に出したAWS CDKのようなライブラリだとまた話は違うが、これも新バージョンのリリース後すぐにアップデートするユーザーは少ないので、ある種そのパイオニアたちがカナリアになっているとも見ることができるだろう。

パッチの作成者自身がオーナーシップを持っているサービス・コードなら、この方法はよく採用されているように思う。 とはいえOSSでは実際に対応するのはメンテナなので、非メンテナがこの方法を提案しても受け入れられ難いかもしれない。

3. 正しいコードを書く人間だという権威を得る

これも現実的にはよくある話だと思う。

ある程度実績を積むことで、「その人のパッチは多くの場合正しい」という権威を得られる場合がある。 これにより、明確に正しさを証明していないコードについても、マージはされやすくなるかもしれない。

これは直感的には非合理 (コードの正しさはコードだけで判断すべき) では?と思ってしまうが、実際はどうか。 確かにコードの正しさを完全に証明する必要がある場合はその通りで、先入観を捨てた確認が不可欠である。

しかし上にも書いたように、実際のところ完全な証明というのはそもそも難しい。 多くの場合、コードの正しい「可能性が高い」ことを確認するのが現実的な妥協点である。 レビューするパッチの作者が実績のある開発者であるときに、正しい可能性が高いと早めに判断して省力化するのは、統計的にも合理的な考えだろう。


なお「絶対に落とせないシステム」というのも世の中はあるようで、その場合2,3の方策は見向きもされないかもしれない。 私も以前人工衛星というミッションクリティカルなシステムの開発に関わっていたので想像はつくが、あらゆる手を尽くして設計・実装の正しさを証明することになるだろう (レビュー、シミュレーション、信頼性工学の手法、品質試験、etc...)。 とはいえコードの正しさを証明することも前述の通り困難であるし、未知の欠陥がある前提で、fail-safe・fault-tolerantな設計にも全力を注ぐのだが。 文字通りの「絶対に落とせないシステム」というのは要件定義を誤っている可能性があり、むしろ落ちても大丈夫なシステムを目指すのがベターではないだろうか。

話は逸れたが、今回タイトルの件について考察を深めることができた。 上の例に挙げたAWS CDKへのパッチが、どちらの方針でマージされるのかにも注目である。

これってバグ修正特有の問題?

バグ修正編と名打ってみたのは、今回は特に既存機能を変更する時に絞った観点で考えたかったため。

一般論として、新機能にバグがあるよりも、既存機能の今まで正常に動いていた部分にバグを増やしてしまう方が深刻度は高い。 前者はユーザーがそもそも使えない機能として使用を回避するだけで済むが、後者は既に使っているユーザーがただちに不利益を被るためである。

このため人は新機能を書くときよりも、バグ修正をするときこそより慎重になり、完璧より簡潔を目指す方針に価値が生まれる。

ただし、似たような話は新機能開発時にも成立する。初めから完璧を目指すと、結果的に様々な不利益が生まれることがある (premature optimizationなど)。これは今回の話とは異なる観点のため、また別の記事でまとめたい。

まとめ

特にまとまらないが、この辺りで締めることにする。3行まとめ:

  • 既存コードの修正においては、必ずしもすべてのバグを完璧に・美しく修正するのが正義ではない
  • 見えているバグのみを愚直に対応するのも合理的な選択である
  • 一つの方針のみを考えて満足しないよう注意したい

最後に、今月のもなちゃんをパチリ。ピジョンという名前なのか、配色が似ているせいか、ベビーカーをいたく気に入ったようだ。

*1:きっかけのもう一つはこのPR。これは元々(私目線では)完璧アプローチのパッチを提出し、結局簡潔アプローチに近い方法で書き直してマージされた。実際のところ私自身元のパッチがあらゆるケースで動くのか自信を持てなかったので、妥当だとは思う。めちゃくちゃ既存コード消せてたんだけどね。

*2:実際はAWS CDKはCloudFormationテンプレートを生成するソリューションなので、CloudFormationの論理和を取る関数 conditionOr を生成するためのCDKの関数ということだが、本筋ではないので簡略化している。