Rust + Pulumi で Lambdalith な Axum をデプロイ
TL;DR
- Rust で Axum を利用した API サーバーを作成し、TypeScript & Pulumi で Lambdalith をデプロイする手順を紹介
はじめに
Rust & Lambda の連携については、Lambda Web Adapter や cargo-lamba によるデプロイ方法は下記のような記事で紹介されているのですが、Rust & Pulumi (TypeScript) の組み合わせで実施している記事はなかったので紹介します。
また、デプロイする成果物のビルドについては、シンプルな開発環境の構築のため Docker を利用しない方法を取ろうと思います。
この記事は、以前書いた本において Hono で Lambdalith を作成した章の Rust 版の内容となります。
もう、TS だけでいい。と啖呵を切って何故 Rust を使うのか?
拙著の帯に「もう、TS だけでいい。」と書いたにも関わらず、なぜ今回 Rustを選択するのか。この点について、実際の案件経験を踏まえて説明します。

まず、前提として本で紹介した、クライアント(React)・サーバー(Hono)・プラットフォームコード(Pulumi)の3つ領域を TypeScript で統一する「もう、TS だけでいい」フレームワークは、実際に私のアサインした案件で実施され、開発上も大きな問題がなく納品まで完了しています。このように、まだまだ実績が少ないながらも、実例のあるフレームワークになっています。
しかしながら、その案件を通してこの 「もう、TS だけでいい」フレームワークの弱点というものが見えてきたので、3つの観点を挙げます。
1. TypeScript の限界
TypeScript の構造的型システム・パッケージ作成の煩雑さのために、ドメイン駆動設計と相性が悪いと感じました。
- ドメインモデルを定義する際、 Always-Valid を実現するために、構造的型システムの制約をクリアするため Branded Type の対応をせざるを得ないこと
- ドメイン駆動設計のレイヤー毎にパッケージを作成する場合、package.json 上で type.tsファイルへのパスを通す必要があることや、tsconfig.json の調整が必要など、煩雑な作業が多いこと
- 数値型
numberの表現力が弱いため、数値計算などのニーズに応えるのが難しいこと
2. デプロイメントの課題
バイナリ実行ファイル形式で配布することが難しいと感じました。顧客の環境で、必ずしも Node.js 等の JavaScript ランタイムを利用できるとは限りません。
- 案件時には、vercel/pkg を利用して解決しましたが、2023 年には public archive となっており、最新の Node.js ランタイムはすでに利用できないこと
- Node.js 自体で、Single executable applications の機能も登場しているが、まだ開発状態であること
3. 開発言語統一による認知負荷低減への疑念
開発言語の統一は、自分の担当業務の隣接領域を理解しようと試みる精神的ハードルを下げる効果や、パッケージ管理のエコシステムのノウハウを使い回せるため保守性が上がる効果は一定ありそうでした。
一方で、クライアント・サーバー・インフラの3領域は開発する対象の関心毎やそれを解決するフレームワークがそもそも異なるため、認知負荷を下げるという点ではあまり貢献していないように感じました。
- クライアントとサーバーでフレームワーク(React や Hono など)が異なるため、開発言語を統一したからといってプルリクエストのレビュー等で必ずしも認知負荷が下がったと感じなかった
解決策
これらの課題を解決するため、以下の条件を満たす開発言語が必要だと思われます。
- 構造的型システムでない静的型付け
- クロスプラットフォームなバイナリコンパイル対応
Go, C# などの条件を満足するいくつかの言語が候補として挙がりますが、私は Rust を選択することにしました。それは上記の条件を満足するかつ次の理由のためです。
- メモリ安全性が高く(特に CPU バウンドな)パーフォマンスに優れている(lambda 関数においては、メモリサイズと実行時間によって、課金額が決定されるため早いは正義)
- パッケージマネージャの Cargo が優れており、自作パッケージ(クレート)の作成が容易でかつ、feature flag 機能により、細やかな機能の切り替えにも対応している
- Result や Option を採用しているため、エラーハンドリングにも型システムを利用可能なため関数型ドメイン駆動設計に向いている
- 既存の TypeScript で作成された Web クランアントの実装を流用しつつ、クロスプラットフォームなネイティブアプリを作成可能な Tauri を利用可能(CLI であれば cargo-zigbuild や cross などで、クロスビルドが可能)
正直なところ、開発言語の選定におけるの客観性が上記の理由のみでは不足していることは重々承知なのですが、今回は検証も兼ねて、バックエンド開発に TypeScript ではなく Rust を採用することにします。
本記事で扱うこと
本記事では、開発環境を構築して、Lambda Function URL で Axum の API を叩けるまでを扱います。
- devbox を利用して、TypeScript & Pulumi および Rust の開発環境を整備すること
- Rust で Axum を利用して、Web サーバーを作成すること
- Axum をホストする Lambda 関数を Pulumi でデプロイすること
基本的な Rust の文法や AWS アカウントの扱いなどの基礎については、前提知識とさせてください。
Pulumi プロジェクト作成
本節では、Pulumi プロジェクトのセットアップについて記述します。Pulumi は Rust 言語で対応していないことから、プラットフォームソースコードには Pulumi & TypeScript を利用します。
また、Pulumi の事前知識については、拙著で恐縮ですが下記を参照してください。
Devbox による開発ツールのインストール
Jetify Devbox を利用して、各種必要なツールを独立した環境にインストールを行います。
python venv のような使用感で、開発環境のセットアップができるので気に入っています。
devboxのインストール方法は下記です。
curl -fsSL https://get.jetify.com/devbox | bash
devbox の初期化を行います。
devbox init
生成された devbox.json のpackages設定を下記のように修正します。
{
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json",
"packages": {
"awscli2": "latest",
"fzf": "latest",
"yq": "latest",
"nodejs": "24",
"pnpm": "latest",
"pulumi": "latest",
"pulumiPackages.pulumi-language-nodejs": "latest",
"rustup": "latest",
"zig": "latest",
"libiconv": "latest"
},
"shell": {
"init_hook": [
"echo 'Welcome to devbox!' > /dev/null"
],
"scripts": {
"test": [
"echo \"Error: no test specified\" && exit 1"
]
}
}
}
導入するツール群について説明します。
- awscli2: AWS SSO を利用するため
- fzf: AWS Profile の切り替えを便利にするため
- yq: yaml ファイル解析のため
- nodejs: TypeScript で Pulumi の記述を行うため
- pnpm: Node.js のパッケージマネージャ。Pulumi Resitory のパケッケージを利用するため
- pulumi: Pulumi のデプロイをするため
- pulumiPackages.pulumi-language-nodejs: pulumi の Node.js 言語ホスト
- rustup: Rust 言語のツールチェーンインストーラー兼マネージャー
- zig: Rust のクロスプラットフォームビルドツール cargo-zigbuild の利用のため
- libiconv: Devbox の Rust インストールテンプレートの依存関係のため
ツールが導入された環境にログインします。
ログイン時に、devbox.json の packages に指定したツールがインストールされます。
devbox shell
# devbox 環境から抜ける時は exit
以降のコマンド実施は、devbox 環境下で実施されることを前提とします。
Pulumi State 保存用の S3 バケット作成とログイン
Pulumi のインフラリソースの状態をホストするための、S3 バケットを作成し、Pulumi でそのバケットにログインします。(Pulumi では、DIY バックエンドと呼ばれます。)
# AWS アカウントの管理に Identity Center を利用してることを想定します
export AWS_PROFILE=<YOUR_PROFILE>
aws sso login --profile $AWS_PROFILE
aws s3 mb s3://dev-otlp-endpoint-lambda-rust-<YOUR AWS ACCOUNT ID>
pulumi login s3://dev-otlp-endpoint-lambda-rust-<YOUR AWS ACCOUNT ID>
aws-typescript テンプレートを利用したプロジェクト作成
AWS Lambda のデプロイを行うために Pulumi を利用した、プラットフォームソースコードを作成します。
今回は、Rust で Web サーバーを開発すること以外の要件は特にないため、プラットフォームソースコードの開発言語に指定はないですが、通常 Web アプリケーションを作成する場合は、クライアントで TypeScript を選定することが一般に多いと思われるため、プラットフォームソースコードも TypeScript を利用することにします。
AWS + TypeScript のテンプレートを利用して、Pulumi プロジェクトの作成を行います。
# プロジェクトルートで実施
pulumi new aws-typescript --force
コマンドのインタラクティブオプションについては、下記のログを参照してください。
Pulumi プロジェクトの作成ログ
(devbox) hoge@hoge otlp-endpoint-lambda-rust % pulumi new aws-typescript --force
This command will walk you through creating a new Pulumi project.
Enter a value or leave blank to accept the (default), and press <ENTER>.
Press ^C at any time to quit.
Project name (otlp-endpoint-lambda-rust):
Project description (A minimal AWS TypeScript Pulumi prog
Created project 'otlp-endpoint-lambda-rust'
Stack name (dev):
Enter your passphrase to protect config/secrets:
Re-enter your passphrase to confirm:
Created stack 'dev'
warning: using pulumi-language-nodejs from $PATH at /Users/hoge/workspace/otlp-endpoint-lambda-rust/.devbox/nix/profile/default/bin/pulumi-language-nodejs
The package manager to use for installing dependencies pnpm
The AWS region to deploy into (aws:region) (us-east-1): ap-northeast-1
Saved config
warning: using pulumi-language-nodejs from $PATH at /Users/hoge/workspace/otlp-endpoint-lambda-rust/.devbox/nix/profile/default/bin/pulumi-language-nodejs
Installing dependencies...
╭──────────────────────────────────────────╮
│ │
│ Update available! 10.18.2 → 10.18.3. │
│ Changelog: https://pnpm.io/v/10.18.3 │
│ To update, run: pnpm add -g pnpm │
│ │
╰──────────────────────────────────────────╯
Packages: +354
Progress: resolved 354, reused 272, downloaded 82, added 354, done
dependencies:
+ @pulumi/aws 7.8.0
+ @pulumi/awsx 3.0.0
+ @pulumi/pulumi 3.203.0
devDependencies:
+ @types/node 18.19.130 (24.8.1 is available)
+ typescript 5.9.3
╭ Warning ─────────────────────────────────────────────────────────────────────╮
│ │
│ Ignored build scripts: @pulumi/docker, @pulumi/docker-build, protobufjs. │
│ Run "pnpm approve-builds" to pick which dependencies should be allowed │
│ to run scripts. │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Done in 11s using pnpm v10.18.2
Finished installing dependencies
Your new project is ready to go! ✨
To perform an initial deployment, run `pulumi up`
warning: A new version of Pulumi is available. To upgrade from version '3.192.0' to '3.203.0', visit https://pulumi.com/docs/install/ for manual instructions and release notes.
今回はスタック名を dev とするため下記のようなプロジェクト構成となります。
.
├── Pulumi.dev.yaml
├── Pulumi.yaml
├── README.md
├── devbox.json
├── devbox.lock
├── index.ts
├── package.json
├── pnpm-lock.yaml
└── tsconfig.json
.gitignore ファイルの作成
.gitignore ファイルの作成
.gitignore ファイルが --forceオプションで更新されてしまうため、gitignore.io から作成します。
.devboxおよびbinディレクトリについては、gitignore.io が生成する内容に含まれないため、手動で追記します。
# プロジェクトルートで実施
curl https://www.toptal.com/developers/gitignore/api/node,rust,macos,dotenv,windows,visualstudiocode > .gitignore
echo ".devbox" >> .gitignore
echo "bin" >> .gitignore
.editorconfig ファイルの作成
.editorconfig ファイルの作成
エディタによらないファイルフォーマットの一貫性の統一のため、editorconfig を利用します。
本記事では、アプリケーションコードは複雑になりがちであることを考慮して、Rust のインデントを広めの 4 として設定してます。
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
indent_style = space
indent_size = 2
[*.tsv]
indent_style = tab
[*.md]
indent_size = 2
trim_trailing_whitespace = false
[*.rs]
indent_size = 4
package.json の更新
AWS Lambda にデプロイするバイナリをビルドするために、command パッケージを導入します。
aws-nativeパッケージは、awsパッケージでまだサポートされていない機能などを利用する場合に利用するので、追加しておくと便利です。(今回の Lambda 関数の作成の範囲では利用しません。)
{
"name": "otlp-endpoint-lambda-rust",
"main": "index.ts",
"type": "module",
"devDependencies": {
"@types/node": "^24",
"typescript": "^5"
},
"dependencies": {
"@pulumi/aws": "^7",
"@pulumi/aws-native": "^1",
"@pulumi/command": "^1",
"@pulumi/pulumi": "^3"
}
}
上記の内容で、npm パッケージを更新します。
pnpm i
パッケージ更新ログ
(devbox) hogee@hoge otlp-endpoint-lambda-rust % pnpm i
Packages: +4 -84
++++------------------------------------------------------------------------------------
Progress: resolved 274, reused 270, downloaded 4, added 4, done
dependencies:
+ @pulumi/aws-native 1.37.0
- @pulumi/awsx 3.0.0
+ @pulumi/command 1.1.3
devDependencies:
- @types/node 18.19.130
+ @types/node 24.8.1
╭ Warning ───────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Ignored build scripts: @pulumi/aws-native, @pulumi/command. │
│ Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts. │
│ │
╰────────────────────────────────────────────────────────────────────────────────────────────╯
Done in 7.7s using pnpm v10.18.2
Pulumi.dev.yaml の更新
AWS Profile と DIY バックエンドの情報を dev スタックの設定ファイルに記述します。
encryptionsalt: v1:/ABCDEFGHI=:v1:ABCDEFG/ABCDEFG:ABCDEFG+ABCDEFG/ABCD==
config:
aws:region: ap-northeast-1
+ aws:profile: <YOUR AWS PROFLE>
+ aws-native:region: ap-northeast-1
+ aws-native:profile: <YOUR AWS PROFLE>
+ backend:url: s3://dev-otlp-endpoint-lambda-rust-<YOUR AWS ACCOUNT ID>
AWS Profile を記述しておくことで、スタックと AWS Profile の紐付けを管理できます。
backend:url については必須ではありませんが、どのエンドポイントで Pulumi State が保存されているかの情報を明示的に残しておくと運用上便利です。
awspp コマンドによるスタックと AWS Profile 一括切り替え
Pulumi スタックと AWS Profile を連携して切り替えるツールを作成しています。
詳細は下記の記事を参照してください。
S3 バケットを使って Pulumi のデプロイテスト
Pulumi の一通りの設定が完了したので、プロジェクトテンプレートに含まれている "my-bucket" という名前の S3 バケットを作成してみます。
index.tsファイルを下記のように修正します。
-import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
-import * as awsx from "@pulumi/awsx";
// Create an AWS resource (S3 Bucket)
const bucket = new aws.s3.Bucket("my-bucket");
// Export the name of the bucket
export const bucketName = bucket.id;
下記のupコマンドで、S3 バケットの作成をデプロイします。
pulumi up
pulumi up のログ
(devbox) hoge@hoge otlp-endpoint-lambda-rust % pulumi up
Enter your passphrase to unlock config/secrets
(set PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE to remember):
Enter your passphrase to unlock config/secrets
Previewing update (dev):
Type Name Plan Info
+ pulumi:pulumi:Stack otlp-endpoint-lambda-rust-dev create 1 warning
+ └─ aws:s3:Bucket my-bucket create
Diagnostics:
pulumi:pulumi:Stack (otlp-endpoint-lambda-rust-dev):
warning: using pulumi-language-nodejs from $PATH at /Users/hoge/workspace/otlp-endpoint-lambda-rust/.devbox/nix/profile/default/bin/pulumi-language-nodejs
Outputs:
bucketName: [unknown]
Resources:
+ 2 to create
Do you want to perform this update? yes
Updating (dev):
Type Name Status Info
+ pulumi:pulumi:Stack otlp-endpoint-lambda-rust-dev created (4s) 1 warning
+ └─ aws:s3:Bucket my-bucket created (1s)
Diagnostics:
pulumi:pulumi:Stack (otlp-endpoint-lambda-rust-dev):
warning: using pulumi-language-nodejs from $PATH at /Users/hoge/workspace/otlp-endpoint-lambda-rust/.devbox/nix/profile/default/bin/pulumi-language-nodejs
Outputs:
bucketName: "my-bucket-34da95b"
Resources:
+ 2 created
Duration: 6s
Pulumi でのデプロイができていることが確認できたら、今回の検証では利用しない S3 バケットを削除してしまいます。
pulumi destroy
pulumi destroy のログ
(devbox) hoge@hoge otlp-endpoint-lambda-rust % pulumi destroy
Enter your passphrase to unlock config/secrets
(set PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE to remember):
Enter your passphrase to unlock config/secrets
Previewing destroy (dev):
Type Name Plan Info
- pulumi:pulumi:Stack otlp-endpoint-lambda-rust-dev delete 1 warning
- └─ aws:s3:Bucket my-bucket delete
Diagnostics:
pulumi:pulumi:Stack (otlp-endpoint-lambda-rust-dev):
warning: using pulumi-language-nodejs from $PATH at /Users/hoge/workspace/otlp-endpoint-lambda-rust/.devbox/nix/profile/default/bin/pulumi-language-nodejs
Outputs:
- bucketName: "my-bucket-34da95b"
Resources:
- 2 to delete
Do you want to perform this destroy? yes
Destroying (dev):
Type Name Status Info
- pulumi:pulumi:Stack otlp-endpoint-lambda-rust-dev deleted (0.16s) 1 warning
- └─ aws:s3:Bucket my-bucket deleted (1s)
Diagnostics:
pulumi:pulumi:Stack (otlp-endpoint-lambda-rust-dev):
warning: using pulumi-language-nodejs from $PATH at /Users/hoge/workspace/otlp-endpoint-lambda-rust/.devbox/nix/profile/default/bin/pulumi-language-nodejs
Outputs:
- bucketName: "my-bucket-34da95b"
Resources:
- 2 deleted
Duration: 3s
以上で、Pulumi プロジェクトのセットアップは完了です。
Axum サーバー作成
アプリケーションソースコードは、前述の通り関数型ドメイン駆動設計に向いた Rust を利用し、Axum を利用した Web サーバーを作成していきます。
Rust ツールチェーンのインストール
Devbox で導入した rustup コマンドを利用して最新の Rust のツールチェーンを導入します。
rustup default stable
Rust ツールチェーン導入ログ
(devbox) hoge@hoge otlp-endpoint-lambda-rust % rustup default stable
info: syncing channel updates for 'stable-aarch64-apple-darwin'
info: latest update on 2025-09-18, rust version 1.90.0 (1159e78c4 2025-09-14)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-std'
27.7 MiB / 27.7 MiB (100 %) 20.1 MiB/s in 1s
info: downloading component 'rustc'
60.8 MiB / 60.8 MiB (100 %) 20.1 MiB/s in 3s
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
20.5 MiB / 20.5 MiB (100 %) 8.3 MiB/s in 2s
info: installing component 'rust-std'
27.7 MiB / 27.7 MiB (100 %) 25.6 MiB/s in 1s
info: installing component 'rustc'
60.8 MiB / 60.8 MiB (100 %) 26.8 MiB/s in 2s
info: installing component 'rustfmt'
info: default toolchain set to 'stable-aarch64-apple-darwin'
stable-aarch64-apple-darwin installed - rustc 1.90.0 (1159e78c4 2025-09-14)
(devbox) hoge@hoge otlp-endpoint-lambda-rust % cargo version
cargo 1.90.0 (840b83a10 2025-07-30)
api というプロジェクト名で、Rust のプロジェクトを作成します。
# プロジェクトルート
mkdir api
cd api && cargo init
api プロジェクトの作成後は次のようなディレクトリ構成になります。
.
├── Pulumi.dev.yaml
├── Pulumi.yaml
├── README.md
├── api
│ ├── Cargo.lock
│ ├── Cargo.toml
│ └── src
│ └── main.rs
├── devbox.json
├── devbox.lock
├── index.ts
├── package.json
├── pnpm-lock.yaml
└── tsconfig.json
Cargo.toml の更新
Axum に加え、Rust における OpenAPI による開発をサポートする utoipa クレートを導入します。
[package]
name = "api"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["macros"] }
tower-http = { version = "0.6", features = ["trace", "cors"] }
serde = { version = "1", features = ["derive"] }
anyhow = "1"
utoipa = { version = "5", features = ["yaml", "non_strict_integers"] }
utoipa-axum = "0.2"
utoipa-scalar = { version = "0.3", features = ["axum"] }
utoipa は OpenAPI ドキュメントをコードベースから作成するためのクレートです。
UI は Swagger UI の代わりに良さげだった Scalar を採用しています。
Hello, World
下記のコードで、Hello, World の API エンドポイントを作成しつつ、OpenAPI ドキュメントを作成し、その UI を Scalar でホストするところまでを一気に実装します。
const HELLO_TAG: &str = "hello";
#[utoipa::path(
get,
path = "/hello",
responses(
(status = 200, body = String),
),
tags = [ HELLO_TAG ]
)]
async fn hello() -> &'static str {
"Hello, World!"
}
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(info(
title = "otlp-endpoint-lambda-rust",
version = env!("CARGO_PKG_VERSION"),
description = "sample api",
))]
struct ApiDocs;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
const BASE_PATH: &str = "/api";
let api_major_version: usize = env!("CARGO_PKG_VERSION")
.split('.')
.next()
.unwrap()
.parse()
.unwrap();
use utoipa_axum::router::OpenApiRouter;
let hello_router: OpenApiRouter = OpenApiRouter::new().routes(utoipa_axum::routes!(hello));
let api_base_path = format!("{}/v{}", BASE_PATH, api_major_version);
let (api_router, api_docs) = OpenApiRouter::with_openapi(ApiDocs::openapi())
.nest(
api_base_path.as_str(),
hello_router,
)
.split_for_parts();
use utoipa_scalar::{Scalar, Servable};
let app_router = axum::Router::new()
.merge(api_router)
.merge(Scalar::with_url(
format!("{}/docs", BASE_PATH),
api_docs,
))
.layer(tower_http::cors::CorsLayer::permissive());
use tokio::net::TcpListener;
let listener: TcpListener = TcpListener::bind("localhost:3030").await.unwrap();
axum::serve(listener, app_router).await.unwrap();
Ok(())
}
utoipa-axum の連携については、下記の記事を参考にしました。
下記で、Axum サーバーを実行します。
cargo run
Rust でホットリロードをしたい場合
Rust でホットリロードをしたい場合
下記の記事で紹介されている通り、Rust では、cargo-watch が変更検知リロードのツールとして長らく利用されてきましたが、メンテナンスが終了し、bacon の利用を推奨されています。
- インストール
cargo install --locked bacon
- 変更自動検知しつつ実行
bacon run-long
localhost:3030/api/docsをブラウザで叩くと、下記のような Scalar のページが表示されます。(ページ右上の Configure から Layout を Classic に設定した時の表示)

Scalar でホストした OpenAPI ドキュメント
Test Requst ボタンを押すことで、従来の Swagger UI と同様に、API リクエストのテストを行うことができます。

Scalar で API テスト
POST エンドポイントの作成
POST エンドポイントの例として、greetエンドポイントを作成してみます。
greetエンドポイントは送信者とメッセージ内容を POST すると簡単な挨拶文にして返してくれます。
...
use serde::{Deserialize, Serialize};
use utoipa::{
ToSchema,
openapi::{Object, ObjectBuilder},
};
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct GreetContent {
#[schema(schema_with = Self::person_schema)]
pub person: String,
#[schema(schema_with = Self::message_schema)]
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize,)]
enum GreetContentError {
InvalidPerson,
InvalidMessage,
}
impl GreetContent {
const DESCRIPTION: &'static str = "挨拶の内容";
const PERSON_TITLE: &'static str = "名前";
const PERSON_DESCRIPTION: &'static str = "挨拶する人の名前";
const PERSON_EXAMPLE: &'static str = "山田太郎";
const PERSON_MIN_LENGTH: usize = 1;
const PERSON_MAX_LENGTH: usize = 20;
const MESSAGE_TITLE: &'static str = "メッセージ";
const MESSAGE_DESCRIPTION: &'static str = "やぁ <挨拶する人の名前> の挨拶に続く簡単なメッセージ";
const MESSAGE_EXAMPLE: &'static str = "お元気ですか?";
const MESSAGE_MIN_LENGTH: usize = 3;
const MESSAGE_MAX_LENGTH: usize = 32;
fn try_new(person: String, message: String) -> Result<Self, GreetContentError> {
if person.len() < Self::PERSON_MIN_LENGTH || person.len() > Self::PERSON_MAX_LENGTH {
return Err(GreetContentError::InvalidPerson);
}
if message.len() < Self::MESSAGE_MIN_LENGTH || message.len() > Self::MESSAGE_MAX_LENGTH {
return Err(GreetContentError::InvalidMessage);
}
Ok(Self { person, message })
}
fn person_schema() -> Object {
ObjectBuilder::new()
.title(Some(GreetContent::PERSON_TITLE))
.description(Some(GreetContent::PERSON_DESCRIPTION))
.examples(Some(GreetContent::PERSON_EXAMPLE))
.min_length(Some(GreetContent::PERSON_MIN_LENGTH))
.max_length(Some(GreetContent::PERSON_MAX_LENGTH))
.build()
}
fn message_schema() -> Object {
ObjectBuilder::new()
.title(Some(GreetContent::MESSAGE_TITLE))
.description(Some(GreetContent::MESSAGE_DESCRIPTION))
.examples(Some(GreetContent::MESSAGE_EXAMPLE))
.min_length(Some(GreetContent::MESSAGE_MIN_LENGTH))
.max_length(Some(GreetContent::MESSAGE_MAX_LENGTH))
.build()
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct GreetResponse {
greeting: String,
}
impl GreetResponse {
fn create_greeting(greet_content: &GreetContent) -> Self {
// 重い処理であることをシミュレート
use std::{thread, time};
const HEAVY_LOGIC: u64 = 1000;// [ms]
thread::sleep(time::Duration::from_millis(HEAVY_LOGIC));
Self {
greeting: format!("やぁ {}, {}", greet_content.person, greet_content.message),
}
}
}
use axum::{Json, http::StatusCode};
#[utoipa::path(
post,
path = "/greet",
request_body(
description = GreetContent::DESCRIPTION,
content_type = "application/json",
content = GreetContent,
),
responses(
(
status = StatusCode::OK,
body = GreetContent
),
(
status = StatusCode::BAD_REQUEST,
body = String
)
),
tags = [ HELLO_TAG ]
)]
async fn greet(
Json(payload): Json<GreetContent>,
) -> Result<(StatusCode, Json<GreetResponse>), (StatusCode, String)> {
match GreetContent::try_new(payload.person, payload.message) {
Err(GreetContentError::InvalidPerson) => {
return Err((
StatusCode::BAD_REQUEST,
format!(
"person length must be between {} and {}",
GreetContent::PERSON_MIN_LENGTH,
GreetContent::PERSON_MAX_LENGTH
),
));
}
Err(GreetContentError::InvalidMessage) => {
return Err((
StatusCode::BAD_REQUEST,
format!(
"message length must be between {} and {}",
GreetContent::MESSAGE_MIN_LENGTH,
GreetContent::MESSAGE_MAX_LENGTH
),
));
}
Ok(valid_payload) => {
Ok((
StatusCode::OK,
Json(GreetResponse::create_greeting(&valid_payload)),
))
},
}
}
...
GreetContent のような ObjectBuilder と schema_with を使ったスキーマ作成の詳細はこちらの記事に記載しています。
axum の API リクエストバリデーション
utoipa によって、リクエストボディである GreetCotent のメンバーに文字列長制限などの制約を定義していますが、zod-openapiのようなバリデーションの機能は、utoipa は提供していません。
このため、axum に統合されたバリデーターバックエンドを利用するためのクレートとして、axum_vaildが提供されています。
このクレートでは、Rust ではよく知られたバリデータであるvalidatorやgardeを利用して、API リクエストの検証を行うことができます。
しかし、今回はシンプルなバリデーションであるため、採用していません。
忘れずに、greetエンドポイントを hello_routerに追加します。
let hello_router: OpenApiRouter = OpenApiRouter::new()
- .routes(utoipa_axum::routes!(hello));
+ .routes(utoipa_axum::routes!(hello))
+ .routes(utoipa_axum::routes!(greet));
Scalar 上で、greet API のテストを行い、200 のレスポンスが帰ってくれば問題ありません。
以上で、Axum サーバー作成は終了となります。
Lambda にデプロイ
Axum サーバーについて、最低限の POST と GET メソッドは実装ができましたので、Axum Lambdalith のデプロイについて、Pulumi のプラットフォームコードを作成していきます。
クロスプラットフォームビルド
Rust の Lambda にデプロイする場合は Lambda ランタイムには Amazon Linux 2023 を利用し、CPU は x86_64 か arm64 の選択となるため、クロスプラットフォームビルドが必要となります。
Rust のクロスプラットフォームビルドには私の知る限り、リンカーに zig 言語を利用した cargo-zigbuild と Docker を利用した cross の2つのツールがよく知られています。
今回は、サーバーレスであるというコンセプト上、Docker less なシンプルな開発環境にしたいので、cargo-zigbuild を利用することにします。
cargo-zigbuild は下記のコマンドでインストールします。
cargo install cargo-zigbuild --locked
また、今回の Lambda ランタイム が arm64 とする場合の、ツールチェインを rustup で追加します。
rustup target add aarch64-unknown-linux-musl
Axum サーバーを Lambda で動作させる
Lambda で Web API サーバを動作させる場合、入出力のインターフェースが通常とは異なるため、単純にデプロイしても動作しません。
このため、Lambda で Web API サーバ を利用する場合は、Lambda のインターフェースにアダプターが必要になります。
Rust では、このアダプターを実現する方法として、Docker を利用した Lambda Web Adapter と、aws-lambda-rust-runtimeを内部的に利用したlambda_httpクレートを利用する2つの方法が知られています。(cargo-lambdaも内部的に、aws-lambda-rust-runtimeを利用しています。)
こちらも、クロスプラットフォームビルド の時と同じ理由で、lambda_httpクレートを利用する方針をとります。
また、コンテナレジストリにECRを利用することが必須となり、金銭的コストが増えるリスクが高くなるため lambda_httpクレートを利用する方針をとります。
lambda_httpクレートを利用して、下記の用に Cargo.toml を修正します。
feature = "lambda"は独自に設定した feature flag で lambdaフラグがオンの場合のみ lambda とのアダプターを利用するような処理を利用します。また、lambda_httpクレートはビルド時に、feature flag にlambdaが指定された時ビルドに含まれるようになります。
[package]
name = "api"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["macros"] }
tower-http = { version = "0.6", features = ["trace", "cors"] }
serde = { version = "1", features = ["derive"] }
anyhow = "1"
utoipa = { version = "5", features = ["yaml"] }
utoipa-axum = "0.2"
utoipa-scalar = { version = "0.3", features = ["axum"] }
+[dependencies.lambda_http]
+version = "0.17"
+optional = true
+default-features = false
+features = [ "apigw_http" ]
+
+[features]
+lambda = ["lambda_http"]
axum::serverは、Lambda 環境では、lambda_http::runを利用します。
先程、Cargo.toml に追加した、feature flag の "lambda" をビルド時に指定することで、ネイティブ環境向けと Lambda 環境の処理を切り替えることができるようになります。
...
+#[cfg(not(feature = "lambda"))]
+{
use tokio::net::TcpListener;
let listener: TcpListener = TcpListener::bind("localhost:3030").await.unwrap();
axum::serve(listener, app_router).await.unwrap();
+}
+#[cfg(feature = "lambda")]
+{
+ lambda_http::run(app_router).await.unwrap();
+}
...
Lambda 向けのクロスビルドをテスト
cargo-zigbuild を利用したクロスビルドは次のコマンドで実行します。
cargo zigbuild --release --target aarch64-unknown-linux-musl --features lambda
ビルド済みのバイナリは下記のディレクトリに生成されます。
api/target/aarch64-unknown-linux-musl/release/api
Lambda 関数の作成
Lambda 環境向けのバイナリ作成できたため、Lambda Function URL をもつ Lambda 関数のリソース作成を行います。
Pulumi における Lambda 関数リソースの作成は下記の本で紹介した Hono(TypeScript) をデプロイした例とほとんど同じとなるため、詳細は下記のページに譲ることにします。
Rust 特有の注意点は下記2点です。
-
runtimeにaws.lambda.Runtime.CustomAL2023を指定 -
handler(バイナリ名)はbootstrapにリネームすること
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as fs from "node:fs";
import { local } from "@pulumi/command";
const NAME_PREFIX: string = `${pulumi.getStack()}-${pulumi.getProject()}`;
export const apiLambdaId: string = `${NAME_PREFIX}-api-lamdba`;
const apiLambdaRoleId = `${apiLambdaId}-role`;
const apiLambdaRole = new aws.iam.Role(apiLambdaRoleId, {
assumeRolePolicy: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Action: "sts:AssumeRole",
Effect: "Allow",
Principal: {
Service: "lambda.amazonaws.com",
},
},
],
}),
managedPolicyArns: [],
name: apiLambdaRoleId,
tags: {
Name: apiLambdaRoleId,
Project: pulumi.getProject(),
Stack: pulumi.getStack(),
Environment: pulumi.getStack(),
ManagedBy: "pulumi",
},
});
const lambdaBasicExecutionPolicyAttachment = new aws.iam.RolePolicyAttachment(
`${apiLambdaId}-basic-execution-policy-attachment`,
{
role: apiLambdaRole.name,
policyArn: "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
},
);
const API_DIR = "api";
const BIN_PATH = `./${API_DIR}/bin`;
const BUILD_COMMAND = `
cargo zigbuild --release --target aarch64-unknown-linux-musl --features lambda || exit 1
mkdir -p bin || exit 1
cp ./target/aarch64-unknown-linux-musl/release/api ./bin/bootstrap || exit 1
`;
const apiBuildCommand = new local.Command(`${apiLambdaId}-build`, {
create: BUILD_COMMAND,
dir: `./${API_DIR}`,
triggers: [
new pulumi.asset.FileArchive(`./${API_DIR}/src`),
new pulumi.asset.FileAsset(`./${API_DIR}/Cargo.toml`),
],
environment: {
PULUMI_STACK: pulumi.getStack(),
}
});
export const apiLambda = new aws.lambda.Function(apiLambdaId, {
architectures: ["arm64"],
environment: {
variables: {
TZ: "Asia/Tokyo",
},
},
code: fs.existsSync(BIN_PATH) ? apiBuildCommand.stdout.apply((_) => {
return new pulumi.asset.FileArchive(BIN_PATH);
}) : local.runOutput({
command: BUILD_COMMAND,
dir: `./${API_DIR}`,
}).apply(_ => {
return new pulumi.asset.FileArchive(BIN_PATH);
}),
ephemeralStorage: {
size: 512,
},
memorySize: 256,
handler: "bootstrap",
loggingConfig: {
applicationLogLevel: "INFO",
logFormat: "JSON",
logGroup: `/aws/lambda/${apiLambdaId}`,
systemLogLevel: "WARN",
},
name: apiLambdaId,
packageType: "Zip",
role: apiLambdaRole.arn,
runtime: aws.lambda.Runtime.CustomAL2023,
timeout: 10,
tags: {
Name: apiLambdaId,
Project: pulumi.getProject(),
Stack: pulumi.getStack(),
Environment: pulumi.getStack(),
ManagedBy: "pulumi",
},
});
export const apiLambdaUrl = new aws.lambda.FunctionUrl(`${apiLambdaId}-url`, {
authorizationType: "NONE",
functionName: apiLambda.name,
invokeMode: "BUFFERED",
});
export const API_LAMBDA_FUNCTION_URL = apiLambdaUrl.functionUrl.apply((url: string) => {
// NOTE: url の末尾の / を消す
return url.replace(/\/$/, '');
});
export const API_LAMBDA_ROLE_ARN = pulumi.interpolate`${apiLambdaRole.arn}`;
忘れずに Pulumi の index.ts ファイルを下記で上書きします。
import "./api/aws/lambda.ts";
export {
API_LAMBDA_FUNCTION_URL,
API_LAMBDA_ROLE_ARN,
} from "./api/aws/lambda.ts";
エディタ上でallowImportingTsExtensionsやnoEmmit関連のエラーが出た場合は、tsconfig.json で compilerOptions に下記を追加すると恐らく改善されます。
+"allowImportingTsExtensions": true,
+"noEmit": true,
上記の設定が完了したらデプロイを行います。
pulumi up
pulumi up のログ
(devbox) hoge@hoge otlp-endpoint-lambda-rust % pulumi up
Enter your passphrase to unlock config/secrets
(set PULUMI_CONFIG_PASSPHRASE or PULUMI_CONFIG_PASSPHRASE_FILE to remember):
Enter your passphrase to unlock config/secrets
Previewing update (dev):
Type Name
+ pulumi:pulumi:Stack otlp-endpoint-lambda-rust-dev
+ ├─ aws:iam:Role dev-otlp-endpoint-lambda-rust-api-lamdba-ro
+ ├─ command:local:Command dev-otlp-endpoint-lambda-rust-api-lamdba-bu
+ ├─ aws:iam:RolePolicyAttachment dev-otlp-endpoint-lambda-rust-api-lamdba-ba
+ ├─ aws:lambda:Function dev-otlp-endpoint-lambda-rust-api-lamdba
+ └─ aws:lambda:FunctionUrl dev-otlp-endpoint-lambda-rust-api-lamdba-ur
Diagnostics:
pulumi:pulumi:Stack (otlp-endpoint-lambda-rust-dev):
warning: using pulumi-language-nodejs from $PATH at /Users/hoge/workspace/otlp-endpoint-lambda-rust/.devbox/nix/profile/default/bin/pulumi-language-nodejs
Outputs:
API_LAMBDA_FUNCTION_URL: [unknown]
API_LAMBDA_ROLE_ARN : [unknown]
Resources:
+ 6 to create
Do you want to perform this update? yes
Updating (dev):
Type Name
+ pulumi:pulumi:Stack otlp-endpoint-lambda-rust-dev
+ ├─ aws:iam:Role dev-otlp-endpoint-lambda-rust-api-lamdba-ro
+ ├─ command:local:Command dev-otlp-endpoint-lambda-rust-api-lamdba-bu
+ ├─ aws:iam:RolePolicyAttachment dev-otlp-endpoint-lambda-rust-api-lamdba-ba
+ ├─ aws:lambda:Function dev-otlp-endpoint-lambda-rust-api-lamdba
+ └─ aws:lambda:FunctionUrl dev-otlp-endpoint-lambda-rust-api-lamdba-ur
Diagnostics:
pulumi:pulumi:Stack (otlp-endpoint-lambda-rust-dev):
warning: using pulumi-language-nodejs from $PATH at /Users/hoge/workspace/otlp-endpoint-lambda-rust/.devbox/nix/profile/default/bin/pulumi-language-nodejs
Outputs:
API_LAMBDA_FUNCTION_URL: "https://bzkxk4mkhzqyv3s6oe5flqlfxa0nlhgd.lambda-url.ap-northeast-1.on.aws"
API_LAMBDA_ROLE_ARN : "arn:aws:iam::123456789012:role/dev-otlp-endpoint-lambda-rust-api-lamdba-role"
Resources:
+ 6 created
Duration: 26s
Lambda 上での動作テスト
pulumi のログの Outputs: API_LAMBDA_FUNCTION_URL に表示されたエンドポイントに /api/docsを追加した URL で lambda 上の Scalar にアクセスします。

greet エンドポイントが Scalar 上に反映されている

greet エンドポイントの動作テスト
画像のように、OK で帰ってきていれば問題なく lamdba 上で Axum が動作しています。
まとめ
本記事では、Pulumi & TypeScript + Rust を利用して、Axum を Lambda 関数にデプロイしました。
この記事を通して、Lambda 関数でも Rust を利用する人が増えると良いなと考えています。
続編として、Rust & Lambda 上では Application Signal を直接扱うことはできないため OTLP エンドポイントを利用したモニタリングについて扱った記事を書く予定です。
Discussion