最近、とあるRustベースのモジュールを、Lambda function(Node.js)のサーバレスプロジェクトで利用できるか調査しました。その際に、 WebAssembly (wasm)をLambda Layerからどう活用するか検証したので、記事にまとめました。
RustベースのWebAssemblyを使用するメリットとして、Rustのパフォーマンスとセキュリティを活用しつつ、TypeScriptからも直接機能にアクセスできるようになることが挙げられます。
一方で、wasmのファイルサイズは比較的大きいので、Lambda function自体に含むとロード時間やメモリ利用量に影響が出ます。そこで、Lambda layerを利用し wasmがあるファイルシステムをマウントして読み込むことで、functionのサイズを削減できます。さらに、他システムでの再利用も可能になります。
最初にコードを見たい方は、このレポジトリを参照ください。
ちなみに、こちらの記事は下の記事を日本語に翻訳したものです。元記事の方を覗いてみて拍手をいただけるととても嬉しいです!
では早速説明していきます。
Lambda function + wasmにおけるチャレンジ
Lambda function で TypeScript を使用する場合、 CDK モジュールである aws_lambda_nodejs を利用します。
多くのブログやチュートリアルでは、レイヤー内のファイルにアクセスするために /opt/nodejs/{layerModule}
を使用することを推奨しています。が、この方法では Lambda functionのサイズが大幅に増加することがあります。 内部的に esbundle が使われるため、CDKデプロイ時にはLambda layerからのすべてのファイルが1つのindex.jsにバンドルされるためです。
さらに、通常 wasm を利用する場合 .wasm
ファイルとブリッジする JavaScript ファイル(wasm.js)をセットで含める必要があります。これは、wasm.js ファイルが、__dirname
を使用して同じディレクトリからwasmファイルにアクセスするためです。
// wasm.js 内部における wasmへのアクセス const path = require('path').join(__dirname, 'wasm_add_bg.wasm'); const bytes = require('fs').readFileSync(path);
目標は、Lambda layerを利用することで、Lambda functionに .wasm
ファイルやブリッジJSファイルを含めないことになります。
最小限の実装
プロジェクトがyarn berryをベースにしていると仮定して、まず最小限の実装を見ていきましょう。
ディレクトリ構造
ディレクトリ構造は以下のようになります:
fn
にはRustモジュールが含まれています。layers
には生成されたwasmファイルが含まれています。functions
には、Lambda layerからの関数を使用するLambda function が含まれています。- package.jsonには workspace の設定が含まれている必要があります。
"workspaces": [ "functions/", "layers/" ]
実装手順
では、Rustからwasmを生成し、 Lambda layer として利用するための一連の流れを見ていきます。
- wasm-packをローカルマシンにインストールします。
cargo install wasm-pack
fn
ディレクトリの下にCargo new wasm-addを使ってRustモジュールを作成します。Rustのコードを記述します。wasm-pack build -d ../../layers/wasm-add --target nodejs
を実行します。これにより、nodejs形式のwasmファイルが生成されます。重要な小技:wasm-addディレクトリの中で、rsyncを使って生成されたすべてのファイルを
nodejs/node_modules
にコピーします。
rsync -av . --exclude='nodejs' ./nodejs/node_modules/
こんな風なディレクトリ構造になりました。
lib
の下にlayer-stack.ts
を作成します。Lambda layerでは、ファイルは/opt/nodejs/node_modules/wasm-add
の下に格納されます。実は Lambda function は、/opt/nodejs/node_modules/
を含むNODE_PATH
に対しアクセスできます。すなわち、Step 4で設定したディレクトリ配置により、Lambda function にバンドルせずにwasmファイルにアクセスできるわけです。
export class LayerStack extends Construct { public readonly layer: lambda.LayerVersion; constructor(scope: Construct, id: string) { super(scope, id); this.layer = new lambda.LayerVersion(this, "Layer", { code: lambda.Code.fromAsset( path.join(`${__dirname}/..`, "layers/wasm-add"), ), compatibleRuntimes: [lambda.Runtime.NODEJS_16_X], description: "A layer with wasm", }); }
cdk-stack.ts
でLambda functionを設定します。このLambda function からwasm-add
を除外することに注意してください。
const layerStack = new LayerStack(this, "LayerStack"); new lambda_nodejs.NodejsFunction(this, "Add Function", { description: "Add two numbers", runtime: lambda.Runtime.NODEJS_16_X, handler: "handler", entry: path.join( `${__dirname}/../`, "functions", "simple-function/index.ts", ), layers: [layerStack.layer], bundling: { externalModules: [ "aws-sdk", // Use the 'aws-sdk' available in the Lambda runtime "wasm-add", // exclude as it exists in lambda layer ], }, });
yarn cdk deploy
でデプロイします!Lambda function にはwasmファイルが含まれていないことがわかります。
もっと複雑なシナリオ
RustベースのWebAssemblyをLambda Layersから使う基本的な理解ができたので、wasmとLambda間で非同期通信をブリッジする、より複雑なシナリオを見ていきます。
今回はSlackのWebhookを実装します。これにはSlack APIに対するPostリクエストが必要です。非同期通信は wasm-bindgen-futures
を使って処理できます。依存関係に追加するために cargo add wasm-bindgen-futures
を実行します。
JsValue
を使ってJS側で結果を受け取ります。
use wasm_bindgen::prelude::*; #[wasm_bindgen] pub async fn send_slack_message(webhook_url: &str, message: &str) -> Result<JsValue, JsValue> { let payload = format!(r#"{}"#, message); let client = Client::new(); let res = client .post(webhook_url) .header("Content-Type", "application/json") .body(payload) .send() .await .map_err(|e: Error| JsValue::from_str(&e.to_string()))?; if !res.status().is_success() { return Err(JsValue::from_str(&format!( "Slack API returned code: {} and Message:{}", res.status(), res.text().await.unwrap() ))); } Ok(JsValue::from_str("Slack API Success")) }
このRustコードを前の例と同じようにコンパイルします。生成されたコードが増えるのがわかります。また、生成されたコードで fetch
が使用されていることにきづくでしょう。wasm-packは、https://rustwasm.github.io/wasm-pack/book/prerequisites/considerations.html で述べられているように、fetch polyfill が必要なファイルを生成します。
このJSファイルをNode.jsで使用するには、node-fetch
を含める必要があります。現時点ではLambda layerでのCommonJSのみがサポートされているため、yarn add node-fetch@2
を実行してください。
次に、wasmfetch
を node-fetch
に置き換えます。生成されたwasm jsファイルに以下のコードを挿入します。今回は、slack_notif.js
です。
const fetch = require('node-fetch'); global.fetch = fetch; global.Headers = fetch.Headers; global.Request = fetch.Request; global.Response = fetch.Response;
もう一度、すべてのファイルを /layers/slack-notif/nodejs/node_modules
にコピーします。
Lambda関数は次のようになります。wasm関数を使うには、単純に"slack-notif"をインポートするだけで済みます。
import { send_slack_message } from "slack-notif"; const webhookUrl = process.env.SLACK_WEBHOOK_URL as string; const composeMessage = ({ title, message, }: { title: string; message: string; }) => ... }); export const handler = async (event: any) => { const { message, title } = event; const result = await send_slack_message( webhookUrl, composeMessage({ title, message }), ); console.log({ result }); return {}; };
CDKは以下のようになります。
import { send_slack_message } from "slack-notif"; const webhookUrl = process.env.SLACK_WEBHOOK_URL as string; const composeMessage = ({ title, message, }: { title: string; message: string; }) => ... }); export const handler = async (event: any) => { const { message, title } = event; const result = await send_slack_message( webhookUrl, composeMessage({ title, message }), ); console.log({ result }); return {}; };
デプロイしてテストしましょう!
ボーナス:MakeFileでプロセスを自動化する
手順で説明したコード生成プロセスとfetchポリフィルに必要なインポートをすべて挿入するMakefileを作成しました。ファイルはこちらです:
https://github.com/tomoima525/rust-wasm-lambda/blob/main/Makefile
まとめ
複雑なシナリオでもわかるとおり、Lambda layer からRustベースのWebAssemblyを適切に利用するためにいくつかの回避策が必要です。今回は少々エキストリームなユースケースではあるのですが、実際のところ、Node.js へのサポートはそれほど手厚くなく、WebAssembly はブラウザで利用することに重きが置かれているのだなと改めて実感しました。