maybe daily dev notes

私の開発日誌

Aurora DSQLをPrismaで使う

次世代のサーバーレスRDBであるAurora DSQLを、TypeScript用ORMのPrismaと一緒に使う話です。DSQLドンドン使っていきたい!

DSQL x Prismaは実現できるのか

そもそも、今のPrismaはDSQLを扱えるでしょうか?

幸いにもDSQLは多くの面でPostgres互換を実現しているため、PrismaからもPostgresデータベースとして接続可能です。schema.prismadatasource.providerpostgresql にすればOKです。

// schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

ちょっとした model を追加してみましたが、正常にマイグレーションやデータ読み書きを実行できることが確認できます。

model User {
  id String @id
}

しかしながら、ひとつ考慮事項がありました。それは、上記の 環境変数 DATABASE_URL をどう渡すか? です。この記事では、主にこの点についてフォーカスします。

DSQLと環境変数 DATABASE_URL の課題

DSQLでは、データベース接続で利用するパスワード文字列として、動的に生成することを前提とした authentication token (以下トークンと呼ぶ) を利用します。以下は動的にトークンを取得してデータベースURLを生成する例です:

import { DsqlSigner } from '@aws-sdk/dsql-signer';

const hostname = 'example.dsql.us-east-1.on.aws';
const signer = new DsqlSigner({
  hostname,
  expiresIn: 24 * 3600 * 7, // 期限は最長1週間
});
const token = await signer.getDbConnectAdminAuthToken();
const url = `postgres://admin:${encodeURIComponent(token)}@${hostname}:5432/postgres`;

一方Prismaでは、パスワード文字列は静的に環境変数として渡されることを(暗に)前提としています。参考: Connection URLs

この2つの食い違いが、DSQL x Prismaの利用体験を損ねる可能性があります。以下では、どのように実装すれば良い感じになるかを見ていきましょう。

実装例の紹介

いくつか考えた・見つけた実装のアイデアを紹介します。

実装例1. 動的にPrismaClientを生成する

動的に環境変数 DATABASE_URL を設定した後、PrismaClientを初期化する方法です。

// prisma.ts
import { DsqlSigner } from '@aws-sdk/dsql-signer';
import { PrismaClient } from '@prisma/client';

const hostname = 'example.dsql.us-east-1.on.aws';

async function generateToken() {
  const signer = new DsqlSigner({
    hostname,
  });
  return await signer.getDbConnectAdminAuthToken();
}

export const getClient = async () => {
  const token = await generateToken();
  process.env.DATABASE_URL = `postgres://admin:${encodeURIComponent(token)}@${hostname}:5432/postgres`; 
  // PrismaClientのコンストラクタ内で、上記環境変数が参照される
  return new PrismaClient();
};

// 呼び出し側
import { getClient } from './prisma';

const main = async () => {
  const prisma = await getClient();
  await prisma.user.findMany();
}

これは最もstraightforwardな方法だと思います。欠点としては、従来の使い方と比べて、以下が追加で必要になる点です:

  1. PrismaClientを非同期関数越しに取得する必要がある
    • 静的な環境変数を使っていたならただの変数として取得できるので、やや使い勝手は変わります
    • 例: await prisma.user.findManyawait (await getClient()).user.findMany など
  2. 再接続の処理が必要になる
    • トークンの期限が切れるとデータベースに新たに接続できなくなります (既存の接続は利用できるようです)
    • このため、期限切れ前にPrismaClientを再度初期化する必要があります
    • なおトークン期限は最長1週間まで指定できるので、AWS Lambdaなど、比較的短命なランタイムでは気にする必要がないかもしれません

参考までに、再接続も意識したコード (getClient 関数のみ抜粋) は以下のようになるでしょう:

let client: PrismaClient | undefined = undefined;
let lastEstablieshedAt = 0;
export const getClient = async () => {
  if (client) {
    // トークンの期限切れより前に更新する (以下は例として1時間)
    if (Date.now() - lastEstablieshedAt < 1 * 3600 * 1000) {
      return client;
    } else {
      await client.$disconnect();
    }
  }
  lastEstablieshedAt = Date.now();
  const token = await generateToken();
  process.env.DATABASE_URL = `postgres://admin:${encodeURIComponent(token)}@${hostname}:5432/postgres`;
  client = new PrismaClient();
  return client;
};

ちなみに、top-level awaitを使えばPrismaClient自体をexportできるため、もう少し使いやすくなります。しかし再接続の実装は難しくなるかもしれません。

// prisma.mts
const getClient = async () => {
  // 省略
  return new PrismaClient();
};

// 呼び出し側はこちらを使う
import { prisma } from './prisma.mts';
export const prisma = await getClient();

実装例2. authentication tokenを環境変数として埋め込む

2つ目は、動的なトークンを静的なものとして扱う方法です。以下のような仕組みを作れば実現できるはずです (未実装)。

トークンを取得するLambdaを作成し、そのLambdaからアプリケーション本体 (これもLambdaにあるとする) の環境変数を書き換えます。 このLambdaをトークンが期限切れするよりも前に定期的に呼び出します。

アプリケーションがECSの場合は、Secrets Managerから動的に環境変数を埋め込めるため、以下のような構成もありでしょう (タスク定義を直接更新しない):

この方法の利点は、Prisma視点ではデータベースURLの文字列が環境変数から得られる静的な文字列となるため、従来と全く同じ使用感を実現できる点です。

一方、欠点は以下が考えられます:

  • Lambdaの環境変数に認証情報を直書きすることを推奨しない組織もある
  • トークンを更新するたびにコールドスタートが生じる
    • せいぜい数時間おきなので、大した影響はないだろうが
  • トークンを更新する仕組みの実装・管理が必要
    • 認証情報のローテーションの仕組みと似たようなもので、一度作ればほぼ管理不要だが、一定の面倒くささはあり
    • CDKコンストラクトなどが再利用可能モジュールがあると嬉しいかも?

とはいえアプリ側の実装が単純になるのはやはり嬉しいものです。

実装例3. PrismaのDBドライバーを node-postgres に差し替える

PostgresのPrismaでは、DBドライバーをPrisma独自のものでなく、node-postgres (pg) に差し替えることができます。node-postgresではパスワードとしてasync関数を指定できるため、素直に今回の処理を実装できます。以下はコード例です:

// schema.prisma
generator client {
  provider = "prisma-client-js"
  // feature flagを有効化
  previewFeatures = ["driverAdapters"]
}

// index.ts
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import { DsqlSigner } from '@aws-sdk/dsql-signer';

const hostname = 'example.dsql.us-east-1.on.aws';

const pool = new Pool({
  host: hostname,
  user: 'admin',
  database: 'postgres',
  port: 5432,
  ssl: true,
  password: async () => {
    const signer = new DsqlSigner({
      hostname,
    });
    return await signer.getDbConnectAdminAuthToken();
  },
});
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
const users = await prisma.user.findMany();

個人的には、この方法が最も単純で美しいように思います。新規接続のたびにpassword関数が呼び出されるため、期限切れの問題も自然と解決されています。

注意点としては、DBドライバーそのものが置き換わるため、Prismaの動作に影響が生じうることです。私自身はPrismaとnode-postgresを合わせて利用したことがないため、どの程度挙動に差異があるのかは不明です。まだpreviewの機能でもあることから、漠然とした不安のある選択肢だとは思います。

実はDSQL以前からあった普遍的な問題なのだ

諸々見てきましたが、実はこの問題、DSQLに限った話ではありません。

例えば以下に挙げる場合において、開発者たちは以前から同じ問題に直面していたはずです:

  • RDSのIAM認証で接続する
  • DB認証情報を環境変数でなく外部ストアから取得する
  • DB認証情報をSecrets Managerなどでローテーションする

Prismaでは3年以上前からこの問題について指摘されていますが、まだ解決はしていないようです。

Prisma特有の困難として、クエリエンジンがRustで書かれているために、JavaScriptでpasswordを取得するasync関数を書かれても、それを実行しづらいという点が挙げられています。最近はPrismaRust部分をTypeScriptに移行するという話もある (全く別の文脈ですが) ので、そこに少し期待したいですね。

We’re addressing this by migrating Prisma’s core logic from Rust to TypeScript and redesigning the ORM to make customization and extension easier.

まとめ

Aurora DSQLとPrismaを併用する上で、特に認証情報 (authentication token)をどう扱うかについて考えました。Prisma特有?の困難は見えましたが、接続さえできれば普通に使えるので、今後サーバーレスPostgresとして活用していければと思います。

なお、検証に用いたコードはこちらに公開しています: aurora-dsql-prisma-example

今月のもなちゃん

引っ越しで内装が様変わりする中、かつての安息の地をディスプレイ下に見出した様子です。

ではまた!

Bedrockで最大出力トークン数を超過したときに生成を継続する実装例

生成AIモデルには出力トークンの上限数があるため、長い文章を生成させようとしたときに、途中で切れてしまう場合があります。 このときに、切れた部分から再生成するコードを紹介します (備忘のため)。

コードはAmazon BedrockのConverse APIを前提とします。

コード例

早速コードの例を紹介します。

TypeScriptのコード例

import { 
  BedrockRuntimeClient, 
  ConverseCommand,
} from '@aws-sdk/client-bedrock-runtime';

const client = new BedrockRuntimeClient({ region: 'us-west-2' });

const converse = async (prompt: string, prefix = '') => {
  const response = await client.send(
    new ConverseCommand({
      modelId: 'anthropic.claude-3-haiku-20240307-v1:0',
      messages: [
        {
          role: 'user',
          content: [
            {
              text: prompt,
            },
          ],
        },
        ...(prefix
          ? [
              {
                role: 'assistant' as const,
                content: [
                  {
                    text: prefix,
                  },
                ],
              },
            ]
          : []),
      ],
    })
  );
  console.log(JSON.stringify(response.usage));
  const responseText = response.output?.message?.content?.[0];
  if (responseText == null) {
    throw new Error('No response from Bedrock');
  }
  if (response.stopReason == 'max_tokens') {
    return converse(userPrompt, prefix + responseText);
  }
  return prefix + responseText;
};

呼び出し側はこのような形です:

const prompt = `can you just echo back my message? Warning: it is very long to test your output capacity. ${randomBytes(400).toString('hex')}`;

const response = await converse(prompt);
console.log(response);

Pythonのコード例

import boto3
from botocore.config import Config

client = boto3.client(
    "bedrock-runtime", config=Config(region_name="us-west-2")
)

def converse(prompt: str, prefix: str = ""):
    messages = [{"role": "user", "content": [{"text": prompt}]}]
    if prefix:
        messages.append({"role": "assistant", "content": [{"text": prefix}]})
    response = client.converse(
        modelId="anthropic.claude-3-haiku-20240307-v1:0",
        messages=messages,
    )

    response_text = (
        response.get("output", {})
        .get("message", {})
        .get("content", [{}])[0]
        .get("text")
    )
    if response_text is None:
        raise Exception("No response from Bedrock")

    print(response["usage"])
    if response["stopReason"] == "max_tokens":
        return converse(prompt, prefix + response_text)

    return prefix + response_text

上記は最も単純な用途における例です。システムプロンプトや画像入力がある場合も同じ仕組みで実現できるので、カスタマイズしてみてください。LLMもClaude以外のものでも利用できます。

仕組み

出力トークン数上限により出力が中断された場合、BedrockのConverse APIはレスポンスの stopReason フィールドmax_tokens を入れて返します。

これを検知したとき、クライアント側からは、これまで生成された文字列を assistant の入力として渡す (例の手法です) ことで、生成を中断された箇所から再開することができます。

上記を再帰呼び出しで書けば、比較的簡単に実装することができます。

注意

この方法の注意点は、Converse API再帰的に呼び出すごとに入力トークン数の課金が発生することです。

仮に与える入力トークン数が10k、欲しい出力トークン数が6k、LLMの出力上限トークン数が4kの場合を考えましょう。このとき、APIは以下のように呼び出されます:

  1. APIコール1回目: 入力トークン数10k → 出力トークン数4k (上限により中断)
  2. APIコール2回目: 入力トークン数10k+4k (前回の出力をappend) → 出力トークン数2k

合計では入力トークン数24k + 出力トークン数6k のコストがかかることになります。効率が良いとは言えないので、可能であれば出力トークン数が上限値以内に収まる使い方をするのが良いでしょう (生成AIに与えるタスクの分割などを検討)。

まれに上限を超えうるという状況で、保険としてこの仕組みを入れておくのはありと思います。

まとめ

Amazon BedrockのConverse APIで、出力トークン数が上限値を超えて生成が中断されてしまった場合の対応方法を紹介しました。

CDK Tips: Step Functionsで別リージョンのAWS APIを呼び出す

AWS CDK Tipsシリーズです。

先日久々にAWS CDKの新機能をマージしてもらえたので、その宣伝をさせてください。Step Functionsで別リージョンのAWS APIを呼び出すためのコンストラクトです。

github.com

TL;DR

以下のコードで、Step FunctionsからクロスリージョンのAWS APIコールを行うSFnタスクを実装できます (CDK v2.148.0〜)。

// 例: us-west-2のS3バケットからgetObjectするSFnタスク
const getObject = new tasks.CallAwsServiceCrossRegion(this, 'GetObject', {
  region: 'us-west-2',  // ここでリージョンを指定
  service: 's3',
  action: 'getObject',
  parameters: {
    Bucket: myBucket.bucketName,
    Key: sfn.JsonPath.stringAt('$.key')
  },
  iamResources: [myBucket.arnForObjects('*')],
});

ドキュメントはこちら: aws-cdk-lib.aws_stepfunctions_tasks module · AWS CDK

モチベーション

AWS Step Functionsでは、ステートマシン内でAWS APIを呼び出す機能が提供されています: AWS SDK Integrations

200以上のAWSサービスとの連携が可能

しかしながら、この機能では、ステートマシンと同一のリージョンのAWS APIを呼ぶことしかできません。

Currently, cross-Region AWS SDK integration and cross-Region AWS resource access aren't available in Step Functions. https://docs.aws.amazon.com/step-functions/latest/dg/concepts-access-cross-acct-resources.html

クロスリージョンでもAWS APIを呼びたいですよね?これを可能にするのが今回紹介する機能です。

使い方

AWS CDKでStep Functionsを使ったことがある方なら簡単です。上記のAWS SDK統合機能は、 CallAwsService コンストラクト を使って定義することができましたね。

クロスリージョンの場合は、CallAwsServiceCrossRegion コンストラクトを使うことができます。

使い方は既存のCallAwsService コンストラクトとほぼ同じですが、追加で region プロパティを指定できます。このプロパティで指定したリージョンのAWS APIが呼び出されます。

コード例は下記です:

import * as sfn from 'aws-cdk-lib/aws-stepfunctions';
import * as tasks from 'aws-cdk-lib/aws-stepfunctions-tasks';

declare const table: ITable;

// 基本的にはCallAwsServiceコンストラクトのAPIを踏襲
const deleteTable = new tasks.CallAwsServiceCrossRegion(this, 'DeleteTable', {
  service: 'dynamodb',
  action: 'deleteTable',
  parameters: {
    TableName: table.tableName,
  },
  iamResources: [table.tableArn],
  // 追加引数: ここでリージョンを指定
  region: 'us-east-2',
});

new sfn.StateMachine(this, 'StateMachine', {
  definition: deleteTable,
});

注意点として、CallAwsService コンストラクトのプロパティと完全な互換性はありません。serviceparametersAWS SDK for JavaScript v3スタイルで指定する必要があるため、適宜APIリファレンスをご参照ください。また、CDK v2.148.0以降でのみ利用できることにもご注意ください。

仕組み

仕組みは非常にシンプルで、Lambda関数 (Node.jsランタイム) を利用してAWS APIを呼び出しています。SDK初期化時のregionプロパティを指定することで、任意リージョンのAPIを呼び出すことができます。

Step Functionsの入力から、Lamba内で必要なAWS SDKのサービスとそのメソッドを動的に選択して、引数付きで呼び出します。

同様の機能をセルフ開発することも難しくはないですが、Lambdaのコード管理などを自前でするのはやや億劫でしょう。このコンストラクトを使うことで、諸々の責務をaws-cdk-libに任せることができ、ユーザーはただCDKのバージョンを最新に保つことだけを意識していれば十分となります。

ユースケース

本機能のユースケースをいくつか紹介します。

レジリエンシーのためのクロスリージョン

リージョンをまたいでAWSサービスを使うというのは、かつては一部のヘビーユーザーだけが踏み入れる秘境でした [要出典]。例えばBLEA FSIでは、主に金融業界に向けてクロスリージョン構成による高レジリエンシーの実現を提案しています

aws.amazon.com

上記の実装サンプルでは、クロスリージョンのフェイルオーバーを自動化するために、Step Functionsを活用しています。この中でクロスリージョンのAPIコールが必要でした (各リージョンの持つパラメータの書き換えなど)。Route53 Application Recovery Controllerの呼び出しにも、冗長化されたエンドポイントの指定やAPIコールのために*1、今回の仕組みが役立っています。*2

Bedrockのためのクロスリージョン

今日では、クロスリージョン設計はレジリエンシーの文脈だけには留まりません。特にAmazon Bedrockでは、USリージョンが機能追加の早さやクォータなどの面で優遇されがちなため、クロスリージョンの実装がありふれたものになりつつあります。(例: アプリの主要部は東京・Bedrock関連のみオレゴンなど)

Bedrockにおける「おま国」の様子

例えばbedrock-claude-chatでは、東京のステートマシンから、オレゴンのBedrock Knowledge Baseへのデータ投入を開始するために、本機能を利用しています

その他にも、意外と身近に応用先があるかもしれません。見つけたらぜひご活用ください。

まとめ

AWS CDKを使ってStep FunctionsでのクロスリージョンAWS APIコールを簡単に実装する方法を紹介しました。 コード管理もaws-cdk-libの責務と見なせるため、Lambdaを使うとは言えど運用負荷は軽めと思います。 用途があればぜひ試してみてください。

今月のもなちゃん

余白を大事にするタイプのもなちゃんです。

お腹の羽毛の分かれ目に入りたいですね。

*1:複数のリージョンに分散された専用のエンドポイントを、いずれか指定して呼び出す必要があります。AWSの中では非常に独特なサービスだと思います。

*2:実装のタイミングの都合で、今回紹介したコンストラクト自体は利用していないのですが、同じ仕組みを使っています。

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-self-hosted-on-aws プロジェクトでは、DifyをAWS上にセルフホストした上で、上記の設定が簡単にできます。

github.com

このプロジェクトを使えば、必要な設定箇所は以下の2点のみです:

1. bin/cdk.tsallowAnySyscalls を追加

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

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()

※ なお、最後に紹介するLambda SnapStartを利用する場合は、遅延ロードの効果が下がる可能性があります。十分に検証することをおすすめします。

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ファイルを探してコンパイルします。対象にすべきフォルダは環境により異なる可能性があるので、適宜確認してください (-q フラグを外すと、処理対象のファイルパスが出力されます。) また、今回は特定のライブラリのコンパイルでエラーが発生することがあったので、|| true でエラーを無視しています。

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

5. Lambda関数を分割する

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

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

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

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

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

6. 何回か起動してみる

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

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

7. Lambda SnapStartの利用

2024年11月、ついにLambda PythonランタイムがSnapStartに対応しました!この機能を使うことで、大幅にコールドスタートを削減できる可能性があります。

注意点は下記です:

  • コンテナランタイムでは使えないので、Pythonランタイムに書き換える必要があります
    • 一例として、bedrock-claude-chatでSnapStartを導入したときのコード差分はこちらです: feat: enable Lambda SnapStart for API handler
      • 元々あったCDKの DockerImageFunctionPythonFunction に書き換えています
    • Pythonランタイムの制約のため、以下の場合は書き換えに一苦労する可能性があります:
      • パッケージサイズが250MBを超えている
      • yumやaptなどでインストールが必要な、共有ライブラリに依存する
  • PythonのSnapStartは多少のコストが掛かります: Lambda Pricing
    • キャッシュした容量と、キャッシュで取得した容量のそれぞれに課金されます
    • 一例として、bedrock-claude-chatを小規模に利用したときのSnapStartコストは、毎月5ドル程度です
  • 初期化フェーズの完了後状態をキャッシュするため、上で紹介したモジュールの遅延ロードとは相性が良くないと思われます
    • SnapStartを利用する際は、単純に起動時にモジュールをインポートする方が良いでしょう
  • SnapStartを有効化するために既存実装の変更が必要な場合もあります。詳細はこちらのドキュメントをご参照ください: Compatibility considerations
    • 例: 初期化フェーズでネットワーク接続の確立や乱数の生成、期限付き認証情報の取得などを行っている場合

まとめ

Python Lambdaのコールドスタート時間の解析方法・改善方法を紹介しました。ぜひ試してみてください。

*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階だったのですが、私の発表中に豪雨が降り始め、背景で雷鳴と雷光が威圧的な演出をしてくれるという事故があったのです。