maybe daily dev notes

私の開発日誌

Difyのコード実行機能で任意のPythonライブラリを使う

LLMアプリ開発プラットフォームのDifyでは、ワークフローのコードブロックでPythonコードを実行できます。 この記事では、このコード内でboto3やnumpyなど任意のライブラリを呼び出す方法をまとめます。セルフホストのDify向けです。

Difyコードブロックの例

Difyのコード実行の仕組み

前提知識として、Difyのコード実行の仕組みを簡単におさらいします。

Difyでは、PythonやNode.jsのコードをDify Sandboxという独自のサンドボックス内で実行します。

Dify、特にSaaS版では、ユーザーがDifyのサーバー上で悪意のあるコードを実行する可能性があるため、こうしたセキュリティ対策が必要となります。対策がない場合、例えばDifyのサーバーから重要な情報を窃取したり、Difyサーバーのネットワーク・AWS IAM権限 (あれば) を悪用したりといったリスクが考えられます。

そうしたリスクへの対策として開発されたのがDify Sandboxで、このサンドボックス内ではいくつかの制限が課されています。例えば本記事に関連する部分では、以下の制限があります:

より詳細は、こちらのブログをご覧ください。

dify.ai

一方で、上記の対策があるために、多くのPythonライブラリはそのままでは動作しません。動かすには、いくつかのワークアラウンドが必要です。

それでは、本題の任意のPythonライブラリを使う方法を紹介します。

任意のPythonライブラリを使う方法

ステップバイステップで説明します。セルフホスト版のDifyを想定しています。なお、下の方に色々すっ飛ばして楽する手順もあります。

1. requirements.txtに必要なライブラリを追加

Dify Sandboxコンテナでは、/dependencies/python-requirements.txt にファイルを配置することで、追加のPythonライブラリをインストール可能です。

requirements.txtの書き方はこちらにあります: Requirements File Format

基本的にはライブラリの名前を並べれば良いです:

# requirements.txt

# ライブラリの名前をそのまま書けばOK
boto3

# バージョン固定したい場合はこう
numpy == 2.1.0

このファイルをsandboxコンテナに含めるには、例えばカスタムのDockerfileを作るのが楽でしょう:

FROM langgenius/dify-sandbox
COPY ./requirements.txt /dependencies/python-requirements.txt

2. 必要なシステムコールを許可する

上記だけでデプロイしてコードを実行した場合、多くの場合 operation not permitted というエラーが表示されると思います。これは、ライブラリが必要とするシステムコールがDify Sandboxに許可されていないことを意味します。

この問題に対する正攻法は、ライブラリが必要とするシステムコールを特定し、そのシステムコールのリスクを理解したうえでリスクを許容できるのであれば、そのシステムコールホワイトリストに追加することです。詳細な手順はこちらのFAQに書かれています (2024/8現在)。

しかし、その作業は面倒で、とにかく制限を取っ払いたいだけだという場合もあるでしょう。そのようなときは、すべてのシステムコールの番号を許可リストに追加することができます。

すべての番号はどう網羅できるでしょうか?こちらのStackoverflowの回答を見ると、アーキテクチャLinuxバージョンにより差はあるものの、現在はおよそ400〜500個弱のシステムコールがあり、0から連番を振られているようです。Dify Sandboxでは存在しないシステムコールを指定しても問題ないので、雑に500番まで許可すれば良いでしょう。

Dify Sandboxのホワイトリストを変更するための最も簡単な方法は、環境変数 ALLOWED_SYSCALLS を利用することです。この変数はカンマ区切りのシステムコール番号をリストを期待するので、ALLOWED_SYSCALLS=0,1,2,3,...,499,500と渡します。

AWS CDKを使えば、下記のように簡単に書くことが出来ますね。

  environment: {
    ALLOWED_SYSCALLS: Array(500).fill(0).map((_, i) => i).join(',')
  }

3. 必要なshared libraryをサンドボックス内にコピーする

2でより多くのライブラリは動作するようになるはずですが、一部のライブラリではまだ以下のようなエラーが発生することがあります。

ImportError: libexpat.so.1: cannot open shared object file

libxxx.so ファイルが存在しないというエラーです。先述の通りDify Sandboxのサンドボックスではrootディレクトリが変更され、/var/sandox/sandbox-python 以下のディレクトリがrootとなります 。この新しいroot配下に必要なshared library (soファイル) が存在しない場合、上記のエラーが発生します。

こちらの問題の正攻法は、必要なファイルを特定して、config.yamlPYTHON_LIB_PATH 環境変数でそのファイルのパスを指定することです。詳細な手順は同じくこちらのFAQに書かれています。指定されたパスは、初期化時に本来のrootから /var/sandox/sandbox-python にコピーされます。

こちらもファイルを一つ一つ指定するのは面倒なこともあるでしょう。そのような場合は、ディレクトリ単位で指定できます。Dify Sandboxコンテナの場合、多くの shared library は /usr/lib/x86_64-linux-gnu ディレクトリにあるようです。

環境変数を使うとデフォルトのパスが上書きされてしまうため、それらを含めるように環境変数を指定しましょう。(例: PYTHON_LIB_PATH="/usr/local/lib/python3.10,/usr/lib/python3.10,/usr/lib/python3,/usr/lib/x86_64-linux-gnu,certs/ca-certificates.crt,/etc/nsswitch.conf,/etc/hosts,/etc/resolv.conf,/run/systemd/resolve/stub-resolv.conf,/run/resolvconf/reslvconf/resolv.conf")

いくつかライブラリを試した限りでは、上記のディレクトリを加えるだけでもエラーはなくなりました。もちろんこれだけでは不足している場合もあると思われるので、そのときは都度必要なディレクトリ・ファイルを追加してください。

1〜3まで実施すると、boto3やnumpyなどは(軽く確認した限り)無事動くようになりました。

簡単に設定する

上記は少し大変、そもそもDifyのセルフホスト自体が大変ですね。

私の公開している dify-on-aws-cdk プロジェクトでは、DifyをAWS上にセルフホストした上で、上記の設定が簡単にできます。

github.com

変更箇所は以下の2点です:

1. bin/cdk.tsallowAnySyscalls を追加

new DifyOnAwsStack(app, 'DifyOnAwsStack', {
  ...
  difySandboxImageTag: 'main',
  allowAnySyscalls: true,   // これを追加!
});

※ 現状はdifySandboxImageTag: 'main' も必要です。未リリースのパッチがあるため。

2. sandbox-python-requirements.txtPythonライブラリを追加

lib/constructs/dify-services/docker/sandbox-python-requirements.txt に必要なPythonライブラリを追加します。

これでデプロイすれば、上記の設定が完了した状態になります。

なお、python_lib_path については api.ts で設定しています。こちらも適宜追加してください。

そもそも制限を回避して良いですか?

冒頭でDify Sandboxの意義を説明しましたが、上記のワークアラウンドを適用することで、一部のセキュリティ対策が事実上無効化されてしまうことになります。これは許容できるでしょうか?

いつものように、答えはケースバイケースとなります。本来Dify Sandboxが防ぎたいリスクは悪意のあるコードを実行される点にあるので、それを考慮しなくて良いケースでは大きなリスクはないと考えることもできるかもしれません。例えばDifyを自分専用で使う場合や、信頼できる社内メンバーのみに提供する場合などです。

Dify自体はマルチテナントのSaaSを提供しているため、悪意あるコードを実行されるリスクは必ず対処する必要があるのでしょう。

まとめ

セルフホスト版のDifyのコード実行機能で任意のPythonライブラリを利用する方法を紹介しました。

記事の途中で紹介した dify-on-aws-cdk については、先日のJAWS CDK支部でも話す機会をいただけたので、ぜひご覧ください!

speakerdeck.com

最後に今月のもなちゃんです。

Amazon S3で分散ロックを実装する

先日Amazon S3でconditional write機能がリリースされました。本記事では、この機能を用いた分散ロックについて検討します。

aws.amazon.com

分散ロックとは

分散ロック (distributed lock) とは、分散環境で排他制御を実現するために必要な機構です。実現できることはロックですが、分散環境から利用できることが特徴です *1

実装はRedisを利用したものが有名ですが、AWSネイティブな実装としてはDynamoDBを利用することも多いでしょう。(実装例: DynamoDBLockClient, Powertools for Lambda)

分散ロックは強い整合性を持つ条件付き書き込みが可能なストレージがあれば、実現することが出来ます。

// 分散ロックの擬似コード
結果 = 条件を満たしたら書き込み(共通のキー)
if (結果 == 成功) {
  // ロックが取得できたのでメインの処理を実行
  メイン処理

  // メイン処理が終わったらロックを解放する
  ロックの解放
} else {
  // ロックを取得できなかった。再試行や終了などする
}

S3のconditional writeも強い整合性を持つため、分散ロックを実装できます。「条件を満たしたら」の条件は、「同じキーのオブジェクトが存在しなければ」という条件になります。

AWS SDK for JavaScriptによる実装

それでは、S3による分散ロック実装例をTypeScriptで見てみましょう。以下は100個のタスクがロックを取り合う例です:

import { S3 } from '@aws-sdk/client-s3';
import { setTimeout } from 'timers/promises';

const s3 = new S3();
const key = '.lock';
const bucket = process.env.BUCKET;

const task = async (id: number) => {
  while (true) {
    // 各タスクでタイミングをバラつかせる
    await setTimeout(Math.random() * 500 + 500);
    try {
      // ロックの取得を試みる
      await s3.putObject({
        Bucket: bucket,
        Key: key,
        IfNoneMatch: '*',
        Body: '\n',
      });
    } catch (e) {
      // ロックの取得に失敗。再試行する
      continue;
    }

    // ロック取得に成功
    console.log(`acquired lock ${id}`);
    // メイン処理 (ここでは仮にsleepするだけ)
    await setTimeout(2000);

    // ロックを解放する
    console.log(`releasing lock ${id}`);
    await s3.deleteObject({
      Bucket: bucket,
      Key: key,
    });
  }
};

// 上記タスクを100個起動
new Array(100).fill(0).forEach((_, i) => {
  task(i);
});

すべてのタスクは同じオブジェクトキー (ここでは .lock) をロックのオブジェクトとして利用します。これにより、全体でひとつのロックを取り合う形となります。

putObjectIfNoneMatch: '*' を指定することで、オブジェクトが存在しない場合は作成、存在すればエラーとなります。強い整合性を持つ書き込みのため、同時にリクエストが発生した場合、ただ1つのリクエストだけが成功することが保証されています。

ロックを取得できたタスクは .lock という空オブジェクトをS3バケット上に作成し、メイン処理を実行後、そのオブジェクトをバケットから削除してロックを解放します。

実行すると

実際に実行すると、各タスクがロックを取り合いつつ、排他制御ができている様子が観察できます。

acquired lock 3
releasing lock 3
acquired lock 8
releasing lock 8
acquired lock 65
releasing lock 65
acquired lock 54
releasing lock 54
acquired lock 38
releasing lock 38
acquired lock 77
releasing lock 77
...

ちなみに、ロックの取得に失敗した場合は下記のエラーが得られるようです:

PreconditionFailed: At least one of the pre-conditions you specified did not hold
...
{
  '$fault': 'client',
  '$metadata': {
    httpStatusCode: 412,
    requestId: 'REDACTED',
    extendedRequestId: 'REDACTED,
    cfId: undefined,
    attempts: 1,
    totalRetryDelay: 0
  },
  Code: 'PreconditionFailed',
  Condition: 'If-None-Match',
  RequestId: 'REDACTED',
  HostId: 'REDACTED'
}

この例では各タスクがすべて同じプロセス内にいるため分散ロックの必要すらないわけですが、概観をつかむことはできますね。

実用性を考える

ここまでで、S3を利用した分散ロックを実装できることがわかりました。追加の観点から、実用性を考えてみましょう。なお私は分散処理の専門家ではないため、間違っていたら教えて下さい🙇

ロックの期限は?

多くの分散ロックの実装では、ロックに期限 (expiry) を設定できます。これにより、ロックを取得した処理が何らかの原因でロックの解放に失敗したときも、設定した期限以降は再び他の処理がロックを取得することができます。

例えばDynamoDBでは、conditional writeの条件に不等号などを利用できるため、ロックの期限を実装可能です。

S3の場合、書き込み時に利用できる条件は現状「オブジェクトがすでに存在するかどうか」のみのため、単純に期限を実装するのは難しそうです。

一案として、S3のライフサイクルルールを使えば、「オブジェクトが作成されてからN日後にオブジェクトを自動で削除する」ことができます。オブジェクトの削除はロックの解放と同義のため、これを使えばロックの期限を実装できると思われます。また、ロックを取得した処理が定期的にPutObjectし直すことで、ハートビートも実装できそうです。しかしながら、期限の設定単位は日毎になる (最短でも1日後) になるので、ユースケースは限られてくるでしょう。

あるいはロックのオブジェクトを削除するワーカーを別途用意し、「オブジェクトの作成日時を見て、期限を超えていたら削除する」という方法も可能かもしれません (要はライフサイクルルールのセルフ実装)。ただし、DeleteObjectのAPI は条件付きの削除などは現状できないため、削除とハートビートのリクエストの競合を完全に回避することは難しいでしょう。実用上は、期限よりも十分短い間隔でハートビートすれば問題にはなりづらいと思われます。(これはライフサイクルルールを使う場合もそう)

さらに別解として、AWS Step Functions (SFn)を使う方法も考えられます。ロック取得・メイン処理・ロック解放を別々のタスクとしてもつSFnステートマシンとして実装し、SFnが正常に動作している限りは必ずロックが解放される前提を置く (期限に頼らない) という方法です。万が一S3やSFnの障害などでロックが解放されなかったときは、手作業などで復旧を行います。

少なくともDynamoDBやRedisよりは期限に関する実装の選択肢が減るので、ここは重要な考慮点となりそうですね。

コストは?

気になるコストも確認しましょう。ドキュメントを読む限りはconditional writeでコストが変わるわけでもないようなので、通常のPUTと同一コストがかかるとします。 この場合は、1000リクエストあたり0.005USD です (us-east-1)。ロック取得失敗したときのコストも同様に掛かるようです (こちらのドキュメントに課金されないエラーコードがまとめられていますが、conditional writeの失敗は含まれないように読めます。)

DynamoDBと比べてどうでしょうか?オンデマンドキャパシティの料金と比べると、強整合の書き込みは2つのWRUを使うので 1000リクエストあたり0.0025USD です (取得失敗時も同様)。S3のちょうど半額となります。

コスト面ではDynamoDBが有利ですが、そもそもが安いので、リクエスト量次第でいずれにせよ許容できるコストに収まることもあるでしょう。

リクエストレートは?

ロックへのリクエストは、どの程度の負荷まで耐えられるでしょうか?

S3の1パーティションに対するPUTの最大リクエストレート3,500RPSです。

DynamoDBだと1パーティションあたり1000 write unit/sが上限です。強整合の書き込みは2unit消費するので、500RPSが上限でしょうか。S3の7分の1程度となるようで、意外なS3の強みが見えました。

いずれも理想的にパーティションが分割されている状況を仮定すれば、ひとつのロックごとにその程度のRPSまで耐えられることになります。 上限はありますが、それほど高いRPSでロックを奪い合うユースケースでなければ、問題にはならないでしょう。

まとめ

S3による分散ロックの実装について検討しました。基本的には引き続きDynamoDBで十分と思いますが、何らかの理由でDynamoDBを使いたくない状況では有効な選択肢になることもあるかもしれません。

*1:しかし、なぜかRDBMS、例えばMySQLによるロックは分散ロックと呼ばれないことが多い気がします。理由は謎です。

Python Lambdaのコールドスタートが遅いときの対処法

AWS Lambdaのコールドスタートはアプリ開発でしばしば悩まされる問題です。この記事では、特にPythonのLambda関数 (コンテナも含む) において、コールドスタートが遅い際の対処方法をいくつか紹介します (注意: 網羅は目指してません)。

第一歩: 計測する

Pythonプログラムのコールドスタートが遅い場合、モジュール群のインポートに時間が掛かっていることが多いと思います。

モジュールのインポートに要する時間は次の方法で計測・可視化できます。

まず、-X importtime オプション付きでPythonプログラムを実行します。Lambda環境そのもので実行する*1のはログの取得が面倒なので、最初はローカル環境で実行して良いでしょう。厳密には異なると思われますが、十分良い近似を出してくれるはずです。

このオプションにより、標準エラー出力に以下のような形式のテキストが出力されます:

import time: self [us] | cumulative | imported package
import time:        86 |         86 |   _io
import time:        16 |         16 |   marshal
import time:       177 |        177 |   posix
...

次のコマンドなどを利用して、上記の出力をファイルに保存しましょう:

python -X importtime main.py 2> prof.txt

importtimeの結果は読みづらいので、別のツールで可視化します。今回はtunaを使います。

github.com

tunaはpipからインストールでき、上記で得られた出力を渡すことで利用できます。

pip install tuna
tuna prof.txt

解析が終わると自動的にウェブブラウザが起動し、結果が可視化されます。Framegraphに似た形式です。

以下はDifyapiで実際に使ってみた例です:

インポートされるモジュールはツリー構造を成します。一番上がrootのモジュールで、下に行くほど親からインポートされる子のモジュールになります。横幅がインポートにかかる時間の長さを示します。

それでは、この結果を元に対処方法を考えていきましょう。

対処方法

基本的には、インポート時間の長いモジュールに対処していくことになります。

大まかには、以下の方法があるでしょう:

  1. モジュールを遅延ロードする
  2. モジュールの初期化処理を変更する
  3. モジュールへの依存をやめる

それぞれ詳細をまとめます:

1. モジュールを遅延ロードする

Pythonのimport文は通常ファイルの頭に書きますが、ローカルスコープに書くこともできます。これにより、import処理の実行がそのスコープに入ったときに遅延されるため、プログラム自体の初期化時間には影響を与えなくなります。

この方法は、初期化後もめったに使われないようなモジュールでは有効です。

例えば以下の vertexai.generative_models は、インポートに1秒以上要している割に、VertexAIを利用するとき以外は不要なモジュールと考えられるため、遅延ロードの効果は大きいでしょう。

一方で頻繁に利用されるモジュールでは、初期化処理の後にすぐにインポート処理が走ることが多いと考えられるため、あまり効果がないことも多いでしょう。ただし、Lambdaでは初期化処理が10秒を超えると初期化が中断・再実行されるという仕様があります (参照)。これを避けるため、遅延ロードにより初期化処理を10秒未満に収めるという方法が有効な場合もあるでしょう。

Pythonにおける遅延ロードの実装パターンはこちらにまとまっていました: Lazy import in Python

上記の記事の要点をまとめます。まず、オリジナルのコードが下記だとします:

import foo

def func():
  foo.bar()

import文を実行時に移動すれば、遅延ロードが実現できます:

def func():
  import foo
  foo.bar()

なお、インポート処理が走るのは最初の一度だけなので、func関数の初回の呼び出し時は遅くなりますが、それ以降はパフォーマンスが下がるということはありません。

importlib を使えば、インポートされたモジュールを変数に格納することもできます。複数のスコープでモジュールを共有したい場合は使えそうです:

from importlib import import_module

def init():
  global foo
  foo = import_module('foo')

# 以下はinitの呼び出し後のみ使える
def funcA():
  foo.barA()

def funcB():
  foo.barB()

ただし、type annotationにおいては直接インポートされたモジュールのみ参照できる (変数は不可) ようで、遅延読み込みされた型を使う方法がなさそうでした。この辺りの議論を見る限り、まだできないような気がしています。Pythonに詳しい方の知見をお待ちしております。

# foo.BarTypeは遅延読み込みできる?
def func() -> foo.BarType:
  foo.bar()

2. モジュールの初期化処理を変更する

モジュールによっては、importされた際に時間のかかる処理を実行するものがあります。極端な例は下記です:

# foo.py
import time
time.sleep(10)

# main.py
import foo # これで10秒待つことに

自作のモジュールであれば、こうした時間のかかる処理を消す・あるいは初期化後に移動することができるか検討すると良いでしょう。

例えば、下図の core.tools.tool_manager のように、子のインポートではなく自分自身で時間が掛かっている場合は、このパターンのはずです。

3. モジュールへの依存をやめる

どうしようもないモジュール (ライブラリ) は、代替手段を考えるのも良いですね。

以降は少し毛色は違いますが、別解として書いておきます。

4. __pycache__ をデプロイパッケージに含める

Pythonはモジュールがインポートされた際に、pyファイルをコンパイルしたバイトコードのキャッシュを __pycache__ フォルダに生成します。これにより、次回実行時には高速な初期化を実現できます。

しかしLambdaでは1つの実行環境では1度しか初期化されないため、__pycache__ の恩恵は受けづらいです。では、事前に生成してパッケージに埋め込んでおけばどうでしょうか?

その方法には落とし穴があります。バイトコードは環境依存のため、ローカル端末で作成した __pycache__ をLambda環境で使えるとは限らないことです。このため、zipデプロイではLambdaパッケージに含めないことが明確に推奨されています*2

We recommend that you don't include __pycache__ folders in your function's deployment package. Python bytecode that's compiled on a build machine with a different architecture or operating system might not be compatible with the Lambda execution environment.

しかし、コンテナLambdaでは話は変わると思われます。コンテナ内で __pycache__ を生成すれば、OSやアーキテクチャの差異は発生しないはずのためです。

実際にDifyで試したところ、コールドスタート時間は60%ほどまで短くなりました (40秒 → 25秒)。効果は抜群です。__pycache__ の恩恵は、ローカルでも .venv を作り直して実行するなどすれば、実感することができるでしょう。

__pycache__ の生成には、compileall を利用できます。以下はDifyにおけるDockerfileの例です:

FROM langgenius/dify-api
RUN python -m compileall -f -j 0 -q ./ || true

compileallは、引数で渡したフォルダに対して再帰的にpyファイルを探してコンパイルします。対象にすべきフォルダは環境により異なる可能性があるので、適宜確認してください。 また、今回は特定のライブラリのコンパイルでエラーが発生することがあったので、|| true でエラーを無視しています。

この方法だと、コード自体は変更不要なのも良い点ですね。

5. Lambda関数を分割する

Lambdalithは一部で流行りの方法ですが、複数の機能を一つのLambda関数にまとめる都合上、インポートするモジュールが増えコールドスタートが長くなりがちです。

私はこれがLambdalithの最も大きな欠点だと考えています。Lambdalithはメリットが多いため積極的に採用すべきだと考えますが、コールドスタートがあまりにも長くなったときは対策が必要です。Lambda関数を分割しましょう。

分割の方針を決めるには、各機能が利用するモジュールを観察し、効果的な境界を見出します。すべての機能で遍く使われるライブラリではなく、一部の機能でのみ利用されるライブラリに注目するのがコツです。

分割の実装自体はそれほど大変ではないことも多いです。例えばFastAPIではルーター機能ごとに定義し、エントリポイントから必要なルーターだけをインポートして使うことができます。これにより、1つのFastAPIアプリケーションを複数に分割することは容易です。

Lambda関数へのルーティングは、Amazon API GatewayやCloudFront (FURLの場合) などを使うと良いでしょう。

6. 何回か起動してみる

Docker Lambdaの場合は、Lambdaサービス側でのイメージキャッシュの持ち方の都合 (参考) で、何度かコールドスタートさせると時間が短くなる可能性があります。

デプロイ後初回のコールドスタートで遅かったからといって、それだけで判断するのは禁物です。複数回のコールドスタートの平均値を見ましょう。また、Productionでのメトリクスも参考にすると良いでしょう。

まとめ

Python Lambdaのコールドスタート時間の解析方法・改善方法を紹介しました。ぜひ試してみてください。 (PythonにもSnapStartがほしくなりますね)

*1:Lambdaで実行する場合は、環境変数 PYTHONPROFILEIMPORTTIME を 1 に設定すると良いです。参考

*2:cdkのPythonFunctionのように、コンテナ内でzipパッケージをバンドルする方法であれば問題はなさそうですが。

登壇Tips: 聴講者に挙手をお願いする上で注意する5つのこと

エンジニア向けのイベントで登壇するとき、会場の人に向けて選択式の質問をし、当てはまる項目に挙手させること (以下会場アンケートと呼びます) があると思います。下図が一例です。

私が最近失敗した質問です😓 改善点はどこでしょうか?

会場アンケートはうまく使えば会場と一体感が生まれ盛り上がる方法ですが、思っていたより手が上がらず落ち込んだという登壇者もいるかもしれません (私です😇)。この記事では、失敗から得られた教訓として、会場アンケートする際に気をつけたいことをまとめます。

なお、私自身下記の内容に確証があるわけでもなく、推察が多分に含まれます。個々人の気持ちに関わる部分であり、一人で考えても仕方ないとも思うので、ぜひご意見ください(←こないやつ)!

1. 選択肢は漏れのないようにする

提示する選択肢は、会場にいる全員が一度は手を挙げられるように用意するのが良さそうです。

自分が聴講者だとして、登壇者に質問をされたのに当てはまる選択肢がなく手を挙げられなかったら、仲間外れにされたような気持ちになるかもしれません。インクルーシブネスの観点で、このような状況は避けるべきでしょう。

また、特に発表の序盤では、聴講者の全員が登壇者と何らかインタラクションすることで、両者とも緊張感がほぐれる効果が期待できそうですね。

2. 1つ目の選択肢は、多くの人が手を挙げそうなものに

ファーストペンギンという言葉がありますが、人が手を挙げてない中で自分だけが手を挙げるのは避けたいものです。

気軽に手を挙げられる雰囲気を作るため、回答の1つ目の選択肢は、会場の3〜4割以上は手が挙がると見込めるものを配置するのが良さそうです。1つ目でたくさん手が挙がれば、それ以降のよりマイナーな選択肢でも、多少手を挙げやすくなるんじゃないでしょうか。

3. ネガティブな印象を与えうる選択肢にはフォローを

その選択肢自体にネガティブなイメージが含まれる場合は、手を挙げる事自体を恥ずかしがる人がいるのはもっともです。

このような場合は、登壇者の配慮が一層必要になると思われます。例えば「意外と多いんじゃないかと思いますが」とか「私も実はこれに当たるんですが」とか前置きするのはどうでしょうか。

あるいは場合によっては、そもそも選択肢として含めない対応も必要かもしれません。「漏れのないように」という話と矛盾しますが、ケースバイケースの判断は必要ですね。

4. そもそも回答することが難しくないか検討する

回答することが難しい質問を投げかけている可能性がないか、事前に検討しましょう。

質問を受けた際、答えをはっきりとは知らない・答えが一つに定まらないなどの理由で「何とも答えられない…」と思うことはしばしばあると思います。会場アンケートにおいてこれをやってしまうと、登壇者と聴講者の間に壁が出現し、大きな距離感を生み出してしまいます。

回避するためには、資料準備の段階で周りの人に質問を素振りしておくのが良さそうです。それらの反応を見て回答が難しそうな場合は、1. 回答前に考える時間を設ける 2. 回答を確認する方法を提示する 3. 質問を変える といった対応が考えられるでしょう。

なお、2. 最初の選択肢は、多くの人が手を挙げそうなものにする を実践していれば、もし最初の選択肢であまりにも手が挙がらない場合 (想定外の事態!) に、その場で軌道修正ができそうですね。

5. 質問の意図を明らかにする

意味のないことをしたがる人は少ないです。ゆえに、聴講者は答える意義の分からない質問に何度も答えてくれるわけではないと思われます。

質問する前には意図を十分に説明し、回答が得られたらそれをその場で有効に活用すると良さそうです。

これは特に複数個の質問するときは重要だと思います。1つ目の質問の回答が有効活用されてなければ、その後の質問は答える意欲を失うのが人の心理だと思います。 私はあまり実践できてなかったように思うので、反省しています。

その場で出た結果に対して当意即妙なコメントをするのはなかなか難しいですから、想定される回答分布のいくつかのパターンについて、気の利いたコメントを先に考えておくのも必要かもしれません!

三者の意見

ちなみに、Claudeさんに聞いたらこんな感じでした。

エンジニア向けのイベントで選択式の質問をする際、登壇者が気をつけるべきことは以下のようなことが考えられます。

  1. 質問の内容:
    • 参加者にとって興味深く、関連性のある質問を用意する
    • 質問が明確で簡潔であり、誤解を招かないようにする
  2. 選択肢の設計:
    • 選択肢が適切な数(3~5個程度)になるよう設計する
    • 選択肢が互いに排他的で、重複がないようにする
    • 選択肢の表現が中立的で、バイアスがかからないようにする
  3. 参加者への呼びかけ:
    • 参加者全体に向けて明確に質問を伝える
    • 参加者が挙手しやすい環境を作る(照明、空間の使い方など)
    • 参加者の反応を適切に確認し、次の進行に活かす
  4. 回答の扱い:
    • 参加者の反応を丁寧に確認し、理解を深める
    • 回答の傾向を分析し、それを議論に活かす
    • 参加者の意見を尊重し、議論を建設的に進める
  5. 全体の流れ:
    • 質問を適切なタイミングで行い、イベントの流れを乱さない
    • 質問の目的を明確にし、参加者の理解を促す

いや、先に聞いておけば良かったな!なお > 参加者が挙手しやすい環境を作る(照明、空間の使い方など)

ゲリラ豪雨、雷鳴、うっ頭が…!*1

まとめ

この前の教訓を忘れないために、記事化してみました。割と根拠なしに書いているので、他の方の意見も聞きながら自身の感覚を較正していければと思います。登壇はエンジニアリングとは別の工夫の余地が色々あり楽しいので、引き続き頑張っていきたいです。

尺余りもなちゃん

*1:内輪ネタすみません。前回の発表会場は高層ビルの21階だったのですが、私の発表中に豪雨が降り始め、背景で雷鳴と雷光が威圧的な演出をしてくれるという事故があったのです。

Amazon OpenSearch Serviceのfine-grained access control機能をCDKで管理する

Amazon OpenSearch Serviceでは、ドメインに対するアクセス制御の手段の一つとして、Fine-grained access control (きめ細かいアクセス制御、以下FGAC) が利用できます。 この記事では、FGACの設定をAWS CDKから管理する方法を紹介します。

なぜFGACを使うか

はじめに、FGACを知らない方・使っていない方のために、モチベーションを整理します。すでにご存知の方は読み飛ばしてください。

FGACを使わない場合、OpenSearch Serviceでは以下の方法でアクセス制御することができます。

  • リソースベースのポリシー (ドメインアクセスポリシーとも)
    • ドメインに対して1つだけ付与できるポリシーです
  • アイデンティティベースのポリシ
    • 利用側のIAMロール/ユーザーに付与するポリシーです

用意されているIAMのアクションは下表の通りであり、この程度の粒度で権限を設定できます。IAMポリシーのリソースや条件で対象のインデックスや接続元IPアドレスを絞り込むことも可能です。

FGACを利用しない場合の権限管理の粒度 doc

この方法に加えて、FGACでは次の機能が実現できます (公式ドキュメントの受け売りです):

IAMポリシーによる制御よりも柔軟なため、FGACを使いたいユースケースも少なくないのではないでしょうか。OpenSearch Serviceの運用ベストプラクティスにおいても、FGACはドメインをセキュアにする手段として明示的に推奨されています。

FGACを設定する際の課題

しかしながら、FGACを設定する上で課題があります。それはFGACの設定はCloudFormation(CFn)やCDKで公式にサポートされていないことです。このため、従来は次のような方法で設定していたかと思います:

  1. OpenSearchダッシュボードからGUIで設定する
  2. OpenSearchREST APIで設定する

1は自動化が困難なことや手順書の管理作業が生じることが難点です。2は自動化できる点で悪くないですが、VPC内にドメインがある場合はアクセスのため考えることが増えたり、またCDKで自動命名されたIAM Role名を引き回したりといった手間も考えられます。

これらの課題を解消するために、現在は次の方法でCDKからFGACを管理可能です。

CDKから管理する方法

opensearch-rest-resourcesというライブラリを使えば、CDKからFGACの設定を管理することができるようになります。

例えば、あるIAMロールに権限を与えたい場合は、下記のコードで実現可能です:

import { IVpc } from 'aws-cdk-lib/aws-ec2';
import { IRole } from 'aws-cdk-lib/aws-iam';
import { Domain } from 'aws-cdk-lib/aws-opensearchservice';
import { OpenSearchRole, OpenSearchRoleMapping } from 'opensearch-rest-resources';

declare const vpc: IVpc; // OpenSearch Domainが属するVPC (Domainがnon-VPCな場合は省略可)
declare const backendRole: IRole; // 権限を与えたいIAMロール
declare const domain: Domain; // 対象のOpenSearch Domain

// OpenSearch FGACのロールを作成
const role = new OpenSearchRole(this, 'Role', {
    vpc,
    domain,
    roleName: 'Role1',
    payload: {
        // Roleの設定: 以下の仕様に従う
        // https://opensearch.org/docs/latest/security/access-control/api/#create-role
        clusterPermissions: ['indices:data/write/bulk'],
        indexPermissions: [
            {
                indexPatterns: ['*'],
                allowedActions: ['read', 'write', 'index', 'create_index'],
            },
        ],
    }
});

// RoleMappingで上記のロールとIAMロールを紐づける
const roleMapping = new OpenSearchRoleMapping(this, 'RoleMapping', {
    vpc,
    domain,
    roleName: role.roleName,
    payload: {
        backendRoles: [backendRole.roleArn],
    },
    // RemovalPolicyも通常のCFnリソースと同様に利用できる
    removalPolicy: RemovalPolicy.RETAIN_ON_UPDATE_OR_DELETE,
});

他のCFnリソースと同様、設定を書き換えてデプロイすれば反映されますし、削除すればRemovalPolicyに沿って処理されます。またpayload プロパティには型がついているので、安全に設定できます。結構便利ではないでしょうか?

これまで構築が面倒なのでFGACを敬遠していたという方、ぜひお試しください。

現状の制約・注意事項

本コンストラクトライブラリを使う上での現時点での注意点をまとめます。大抵のユースケースにおいては問題にならないはずです。

OpenSearch Domain L2コンストラクトの利用が必要

APIを単純化するために、対象のドメインL2コンストラクトの利用を必須としています。新規にドメインを構築する方は皆大抵L2を使うだろうと思い、このようにしていますが、問題がある場合はぜひ教えて下さい。

Masterユーザーの認証方法

MasterユーザーはBasic認証 (not IAM)で、ユーザー名とパスワードがSecrets Managerに保存されている必要があります。Domain L2コンストラクトでは、以下のように設定すれば良いです。

const domain = new Domain(this, 'Domain', {
  // 他のプロパティは省略
  fineGrainedAccessControl: {
    masterUserName: 'master-user',  // これでOK
  },
});

これも巷のよくあるユースケースがどんな感じか不明なので、単純化のため一旦こうしています。例えばAuroraだと似た方法 (固定のユーザー名・パスワードの利用) は一般的だと思いますが、OpenSearchだとどうなのか、IAM認証もほしい方はIssueをください。

ドメイン側の設定は利用側の責任

FGACを使うためにはいくつかドメイン側に設定の必要があります。こちらのドキュメントが詳しいです。例えば下記が挙げられます:

  • トラフィックについてHTTPSを利用
  • Encryption of data at restの有効化
  • node-to-node encryptionの有効化
  • Domain access policyの設定

これらの設定は利用側の責任としています。このコンストラクト側では今のところバリデーションなどもしていません (これもどれくらい必要な機能なのか判断つかなかったため。)

ドメインのCDK実装例は、exampleをご覧ください。

対応リソース

本ライブラリでは、FGACに必要なリソースであるRole / RoleMapping / Userをすべて管理することができます。

将来的には、ライブラリ名が示すように、OpenSearchのそれ以外のRESTリソース (インデックスやML Connectorなど) も管理できるようにすることを目指しています。作成/更新(PUT)・削除(DELETE)のRESTエンドポイントが利用可能なリソースであれば、追加の対応は容易なはずです。もし何か必要なリソースがあれば、GitHub Issueにリクエストをください。

あるいは、基盤となる OpenSearchCustomResourceクラス も公開しているので、自分でコンストラクトを作ることもできます。その場合の実装は、他のクラスの実装を参考にしてください (基本的にはエンドポイントを指定して、リクエストのbodyをつくるだけです)。

まとめ

FGACの設定をCDKから管理する方法を紹介しました。ベストプラクティスに沿ったOpenSearch運用を実現するため、ぜひ使ってみてください。

今月のもなちゃん

上半期の換羽と抱卵が終わり、羽が艷やかになりました。

CDK Tips: CDKのインテグレーションテストに救われた話

AWS CDK Tipsシリーズです。

松尾さんの記事に触発されて、今回はCDKにおけるインテグレーションテストの体験談を書きます。なお、本記事では以降integ testという言葉は、CDKのinteg-testsinteg-runnerモジュ―ルで実現される自動テストのことを指すこととします。

Integ testは自動テストの一種で、与えられたCDKのコードをAWS環境へ実際にデプロイするテストを実行します。詳細は以下のブログをご覧ください。

aws.amazon.com

この記事では、integ testを導入して助かった話をします。松尾さんはCDKアプリのお話だったので、私はCDKコンストラクトライブラリでの側面から見ていきます。

背景

きっかけは自作のコンストラクトライブラリ、deploy-time-build に新機能を追加しようとした時のことです。このライブラリは、CDKデプロイ時にフロントエンドアプリをビルドすることで環境変数の注入を楽にするためのものでした (参考: AWS CDKでWebフロントエンドをデプロイする3つの方法)

このライブラリの新機能として、ECSでタスク起動を高速化するSOCIインデックスをビルドする機能を追加することにしました。ライブラリを分離するかは迷いましたが、新しい機能も「デプロイ中にビルドする」という点で元々のコンセプトに合ってますし、部分的に実装を共有できるのは便利なので、同じライブラリにまとめることにしたのです。

今回は、その機能の開発作業中に起きた話です。

リファクタする

2つの機能は大まかな処理の流れが同じです。CloudFormationカスタムリソースのLambda関数から、CodeBuildプロジェクトのビルドを開始します。

このLambda関数のコードは両機能でほぼ同一になるため、共通のコードにすることにしました。

コードが一緒なので、Lambda関数自体も一つにしたくなりますね。今回はSingletonFunctionを使っていたので、uuidlambdaPurpose を両機能で同じにすれば、自ずと関数の実体も一つになります。

元々は以下のコンストラクトです。

  const handler = new SingletonFunction(this, 'CustomResourceHandler', {
    runtime: new Runtime('nodejs18.x', RuntimeFamily.NODEJS),
    code: Code.fromAsset(join(__dirname, '../lambda/trigger-codebuild/dist')),
    handler: 'index.handler',
    uuid: '25648b21-2c40-4f09-aa65-b6bbb0c44659',
    lambdaPurpose: 'NodejsBuildCustomResourceHandler',
    timeout: Duration.minutes(5),
  });

共有するなら lambdaPurpose も汎用的な名前でないと気持ち悪いので、以下のようにリネームしましょう!

  const handler = new SingletonFunction(this, 'CustomResourceHandler', {
    runtime: new Runtime('nodejs18.x', RuntimeFamily.NODEJS),
    code: Code.fromAsset(join(__dirname, '../lambda/trigger-codebuild/dist')),
    handler: 'index.handler',
    uuid: '25648b21-2c40-4f09-aa65-b6bbb0c44659',
-    lambdaPurpose: 'NodejsBuildCustomResourceHandler',
+    lambdaPurpose: 'DeployTimeBuildCustomResourceHandler',
    timeout: Duration.minutes(5),
  });

これで追加のLambda関数を増やさずに済みます。単純で良いですね!

Ship it!

ちょっと待った!

pushする前にinteg testを走らせるんでした。yarn build すれば実行されるようにProjenを設定してあります。今回はスナップショットに変更があるのは明らか*1なので、 --update-on-failed フラグ付きで実行します。

$  yarn integ-runner --update-on-failed

Resources
[-] AWS::IAM::Role NodejsBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c44659ServiceRoleCB01FBE6 destroy
[-] AWS::IAM::Policy NodejsBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c44659ServiceRoleDefaultPolicyCF8879D3 destroy
[-] AWS::Lambda::Function NodejsBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c446591C4101F8 destroy
[+] AWS::IAM::Role DeployTimeBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c44659ServiceRole0880C187 
[+] AWS::IAM::Policy DeployTimeBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c44659ServiceRoleDefaultPolicy80A0FC9E 
[+] AWS::Lambda::Function DeployTimeBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c44659FC29CE6F 
[~] Custom::CDKNodejsBuild ExampleBuild61F1D79B 
 └─ [~] ServiceToken
     └─ [~] .Fn::GetAtt:
         └─ @@ -1,4 +1,4 @@
            [ ] [
            [-]   "NodejsBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c446591C4101F8",
            [+]   "DeployTimeBuildCustomResourceHandler25648b212c404f09aa65b6bbb0c44659FC29CE6F",
            [ ]   "Arn"
            [ ] ]

Snapshot Results: 
Tests:    1 failed, 2 total
Running integration tests for failed tests...

# 中略

NodejsBuildIntegTest | 4/10 | 11:13:17 AM | UPDATE_FAILED        | AWS::CloudFormation::Stack  | NodejsBuildIntegTest The following resource(s) failed to update: [ExampleBuild61F1D79B]. 

Failed resources:
NodejsBuildIntegTest | 11:13:14 AM | UPDATE_FAILED        | Custom::CDKNodejsBuild      | ExampleBuild/Resource/Default (ExampleBuild61F1D79B) Modifying service token is not allowed.

 ❌  NodejsBuildIntegTest failed: Error: The stack named NodejsBuildIntegTest failed to deploy: UPDATE_FAILED (The following resource(s) failed to update: [ExampleBuild61F1D79B]. )

なんと!思いがけずデプロイが失敗しました。気づかずにpushしていれば、ライブラリをアップデートしたユーザーのすべてのデプロイコケてしまうところでしたね。

さて、integ testでなぜこんなエラーが起きたのでしょうか?

前提知識

Integ testを実行するinteg-runnerは、以下の順でCloudFormationに対して実際にAPIを発行して動作を検証します:

  1. 変更前の古いスナップショットを使ってCloudFormationスタックをデプロイ
  2. 変更後のテンプレートを同じスタック名でデプロイして、スタックを更新
  3. スタックを削除
  4. 1-3でエラーがなければローカルのスナップショットを更新し、成功を返す

1→2で更新の挙動を検証していることが今回のポイントでした。これにより、既存ユーザーがライブラリをアップグレードした時のシナリオを模擬して、予期せぬリグレッションを可能な限り検知することができます。

失敗の理由

さて、先のinteg testで発生したエラーも UPDATE_FAILED とあり、2のスタック更新時に起きたエラーです。エラーの本文は以下です:

Modifying service token is not allowed.

Service tokenとはなんでしょうか?これはCloudFormationのカスタムリソースに必要なプロパティです:

ServiceToken The service token that was given to the template developer by the service provider to access the service, such as an Amazon SNS topic ARN or Lambda function ARN. The service token must be from the same Region in which you are creating the stack.

Updates aren't supported.

要はカスタムリソースのハンドラとして呼び出すLambda関数のARNですね。ドキュメントにこの値の「更新はサポートされていない」と明記されていました。発生したエラーも、この制約によるものだと分かりました。

つまり、カスタムリソースは一度作成した後は、裏のLambda関数のARNを変更できないことになります (コード自体の変更は可)。これはなかなか未然に知ることは難しい仕様ですし、「古いスタックをデプロイして新しいスタックで更新する」という特殊なシナリオを検証しないかぎりは、気付けない問題ですね。

integ-testを導入しておいて良かった!

まとめ

ということで、integ-test導入しておいて助かった話でした。ちなみにエラー自体の解消は、既存のLambdaはそのままに、新たに別のLambdaを作る方針で解消しました。

integ testはaws-cdk-lib本体でフル活用されていることもあり、コンストラクトライブラリ開発には特に相性が良い手段だと思います。

来月開催のCDK Conference Japan 2024では、このようなライブラリ開発者向けの話をたくさんできればと思いますので、ぜひご視聴ください!

補足

というきれいな話だけで終わらせても良いですが、上記integ testに対する所感も少し書いておきます。

CDKのinteg testに対してよく耳にする評価は、実行時間が長すぎるために開発イテレーションに組み込むことが難しいという指摘です。これは全くその通りです。テスト実行の度にリソースの作成・削除が必要になるため、例えばAuroraやOpenSearchなど "重たい" リソースが含まれるシステムの場合は、相応に時間がかかります。

Integ testはこの特性を理解したうえで利用するのが良いと思います。私自身がライブラリ開発で利用する際は、integ testとは別に開発用の環境をデプロイし、開発中の高速なイテレーションはそちらの環境で回しています (例えばカスタムリソースハンドラーのデバッグなど)。integ testの実行は、十分に実装に自信が持てた段階で、仕上げとして実行するイメージです。

Integ testが成功すればその時点でリリースできますし、もし途中で失敗した場合は、(やれやれと思いながら)再度開発環境でのイテレーションに戻ります。ここで失敗するのはたいてい考慮漏れがあったときなので、良いことではあるのですが。ちなみに、--parallel-regions オプションでテストをデプロイするリージョンを変更できるので、前回のテスト終了を待たずに次のテストを開始する、という小技も使えます (テストのclean upに時間がかかる場合)。

例えばopensearch-rest-resourcesはinteg testの実行に1回あたり1時間強かかる地獄のような構成なのですが、上記の方法でなんとかやれています。integ testがなければ見落としていたであろう破壊的な変更を未然に防ぐことができたことも実際にあったので、まだメリットのほうが大きいと判断しています。なお、テストシナリオの実装はLambda関数の中身に寄せています。IntegTest.assertions を駆使して書くこともできそうですが、そのコード自体のデバッグイテレーションが大変なので、そちらはできるだけ単純にしています。

// Lambdaを実行し正常終了することだけを確認
const assertion = integ.assertions.invokeFunction({
  functionName: stack.testHandler.functionName,
});
assertion.expect(ExpectedResult.objectLike({ StatusCode: 200 }));

このLambda関数自体のデバッグはinteg testを使わずにできるため、integ testの実行時間に振り回されることがなくなるのは嬉しい点です。(しかしinteg testの機能を活用できてない点で理想的でもないので、Issueで改善要望を出しても良さそうですね。)

一方CDKアプリ (ライブラリでなく、運用されているシステムそのもののことです) の場合は、integ testとは異なるアプローチも可能と考えます。

CDKアプリでは、大抵の場合dev, int, stagingといった、検証用の環境を用意しているのではないでしょうか。であれば、本来integ testで検証される 1. 新テンプレートでのスタック更新 や、2. E2Eテストシナリオの実行 はinteg-runnerを使わずとも、そちらの環境でカバーできます。共用の環境にデプロイするまで検証できないことにはなりますが、例えばdev環境へのデプロイは未検証でも許容できるといった場合であれば、十分実用可能な方策だと思います。(もちろん、スナップショットテストなど安価なテストはより早い段階で成功している前提です。)

あるいは個々の開発者が専有のAWS環境を持つという方針も考えられます。これなら個々人の開発中自由にデプロイ・検証できて便利ですが、ワークロードによってはコストが見合わなくなるかもしれません。その意味では、必要なときだけデプロイするinteg testがコスト的には優位でしょう。開発体験と勘案して選択するのが良いと思います。

上記まとめると、integ testに対する個人的な考えは以下のとおりです:

  • CDKライブラリ開発では導入する方が良いことが多いと思われる。ただし開発体験を犠牲にしないよう柔軟に取り入れる。
  • CDKアプリでは他にも同様の目的を実現できる手段はあるので、それらと比べながら総合的に方針を判断する 。

補足というには少々長くなりましたが、以上です。

*1:integ testはスナップショットテストも兼ねるので、リファクタによりCFnテンプレートが変化しないことも容易に検証可能です。今回は変化があることが事前に分かっているので、その手順を飛ばしています。

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インデックスが話題に出ていたので、半年前書きかけた記事を完成させられました。ありがとうございます!