tomoima525's blog

Androidとか技術とかその他気になったことを書いているブログ。世界の秘密はカレーの中にある!サンフランシスコから発信中。

Lambda LayerからRustベースのWebAssemblyを使用する

最近、とあるRustベースのモジュールを、Lambda function(Node.js)のサーバレスプロジェクトで利用できるか調査しました。その際に、 WebAssembly (wasm)をLambda Layerからどう活用するか検証したので、記事にまとめました。

RustベースのWebAssemblyを使用するメリットとして、Rustのパフォーマンスとセキュリティを活用しつつ、TypeScriptからも直接機能にアクセスできるようになることが挙げられます。
一方で、wasmのファイルサイズは比較的大きいので、Lambda function自体に含むとロード時間やメモリ利用量に影響が出ます。そこで、Lambda layerを利用し wasmがあるファイルシステムをマウントして読み込むことで、functionのサイズを削減できます。さらに、他システムでの再利用も可能になります。

最初にコードを見たい方は、このレポジトリを参照ください。

github.com

ちなみに、こちらの記事は下の記事を日本語に翻訳したものです。元記事の方を覗いてみて拍手をいただけるととても嬉しいです!

betterprogramming.pub

では早速説明していきます。

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 として利用するための一連の流れを見ていきます。

  1. wasm-packをローカルマシンにインストールします。
cargo install wasm-pack
  1. fn ディレクトリの下にCargo new wasm-addを使ってRustモジュールを作成します。Rustのコードを記述します。

  2. wasm-pack build -d ../../layers/wasm-add --target nodejs を実行します。これにより、nodejs形式のwasmファイルが生成されます。

  3. 重要な小技:wasm-addディレクトリの中で、rsyncを使って生成されたすべてのファイルを nodejs/node_modules にコピーします。

rsync -av . --exclude='nodejs' ./nodejs/node_modules/

こんな風なディレクトリ構造になりました。


  1. 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",
 });
}
  1. 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
     ],
   },
});
  1. 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 を実行してください。

次に、wasmfetchnode-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 はブラウザで利用することに重きが置かれているのだなと改めて実感しました。