⚙️

Rust + Pulumi で Lambdalith な Axum をデプロイ

に公開

TL;DR

  • Rust で Axum を利用した API サーバーを作成し、TypeScript & Pulumi で Lambdalith をデプロイする手順を紹介

はじめに

Rust & Lambda の連携については、Lambda Web Adaptercargo-lamba によるデプロイ方法は下記のような記事で紹介されているのですが、Rust & Pulumi (TypeScript) の組み合わせで実施している記事はなかったので紹介します。
また、デプロイする成果物のビルドについては、シンプルな開発環境の構築のため Docker を利用しない方法を取ろうと思います。

https://qiita.com/nokonoko_1203/items/257fdc93cbf7352a3fce

https://zenn.dev/taroyamada5963/articles/b36963d2f14e87

この記事は、以前書いた本において Hono で Lambdalith を作成した章の Rust 版の内容となります。

https://zenn.dev/utcarnivaldayo/books/pulumi-lambadlith/viewer/rest-api

もう、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 を叩けるまでを扱います。

  1. devbox を利用して、TypeScript & Pulumi および Rust の開発環境を整備すること
  2. Rust で Axum を利用して、Web サーバーを作成すること
  3. Axum をホストする Lambda 関数を Pulumi でデプロイすること

基本的な Rust の文法や AWS アカウントの扱いなどの基礎については、前提知識とさせてください。

Pulumi プロジェクト作成

本節では、Pulumi プロジェクトのセットアップについて記述します。Pulumi は Rust 言語で対応していないことから、プラットフォームソースコードには Pulumi & TypeScript を利用します。

また、Pulumi の事前知識については、拙著で恐縮ですが下記を参照してください。

https://zenn.dev/utcarnivaldayo/books/pulumi-lambadlith/viewer/create-pulumi-project

Devbox による開発ツールのインストール

Jetify Devbox を利用して、各種必要なツールを独立した環境にインストールを行います。
python venv のような使用感で、開発環境のセットアップができるので気に入っています。
devboxのインストール方法は下記です。

bash
curl -fsSL https://get.jetify.com/devbox | bash

devbox の初期化を行います。

bash
devbox init

生成された devbox.jsonpackages設定を下記のように修正します。

devbox.json
{
    "$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.jsonpackages に指定したツールがインストールされます。

bash
devbox shell
# devbox 環境から抜ける時は exit

以降のコマンド実施は、devbox 環境下で実施されることを前提とします。

Pulumi State 保存用の S3 バケット作成とログイン

Pulumi のインフラリソースの状態をホストするための、S3 バケットを作成し、Pulumi でそのバケットにログインします。(Pulumi では、DIY バックエンドと呼ばれます。)

bash
# 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 プロジェクトの作成を行います。

bash
# プロジェクトルートで実施
pulumi new aws-typescript --force

コマンドのインタラクティブオプションについては、下記のログを参照してください。

Pulumi プロジェクトの作成ログ
stdohoge
(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 とするため下記のようなプロジェクト構成となります。

tree
.
├── 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 から作成します。

https://www.toptal.com/developers/gitignore?templates=node,rust,macos,dotenv,windows,visualstudiocode

.devboxおよびbinディレクトリについては、gitignore.io が生成する内容に含まれないため、手動で追記します。

bash
# プロジェクトルートで実施
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 として設定してます。

https://editorconfig.org/

.editorconfig
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 関数の作成の範囲では利用しません。)

package.json
{
  "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 パッケージを更新します。

bash
pnpm i
パッケージ更新ログ
stdout
(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 スタックの設定ファイルに記述します。

Pulumi.dev.yaml
 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 を連携して切り替えるツールを作成しています。
詳細は下記の記事を参照してください。

https://zenn.dev/utcarnivaldayo/articles/2025-10-21-awspp

S3 バケットを使って Pulumi のデプロイテスト

Pulumi の一通りの設定が完了したので、プロジェクトテンプレートに含まれている "my-bucket" という名前の S3 バケットを作成してみます。

index.tsファイルを下記のように修正します。

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 バケットの作成をデプロイします。

bash
pulumi up
pulumi up のログ
stdout
(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 バケットを削除してしまいます。

bash
pulumi destroy
pulumi destroy のログ
stdout
(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 のツールチェーンを導入します。

bash
rustup default stable
Rust ツールチェーン導入ログ
stdout
(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)
stdout
(devbox) hoge@hoge otlp-endpoint-lambda-rust % cargo version
cargo 1.90.0 (840b83a10 2025-07-30)

api というプロジェクト名で、Rust のプロジェクトを作成します。

bash
# プロジェクトルート
mkdir api
cd api && cargo init

api プロジェクトの作成後は次のようなディレクトリ構成になります。

tree
.
├── 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 クレートを導入します。

Cargo.toml
[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 でホストするところまでを一気に実装します。

main.rs
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 の連携については、下記の記事を参考にしました。

https://ysuzuki19.github.io/post/rust-axum-utoipa-memo

下記で、Axum サーバーを実行します。

bash
cargo run
Rust でホットリロードをしたい場合

Rust でホットリロードをしたい場合

下記の記事で紹介されている通り、Rust では、cargo-watch が変更検知リロードのツールとして長らく利用されてきましたが、メンテナンスが終了し、bacon の利用を推奨されています。

https://zenn.dev/yuuki0206/articles/f06390cba19736

  • インストール
bash
cargo install --locked bacon
  • 変更自動検知しつつ実行
bash
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 すると簡単な挨拶文にして返してくれます。

main.rs
...

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 を使ったスキーマ作成の詳細はこちらの記事に記載しています。

https://zenn.dev/utcarnivaldayo/articles/2025-10-20-utoipa-builder

axum の API リクエストバリデーション

utoipa によって、リクエストボディである GreetCotent のメンバーに文字列長制限などの制約を定義していますが、zod-openapiのようなバリデーションの機能は、utoipa は提供していません。
このため、axum に統合されたバリデーターバックエンドを利用するためのクレートとして、axum_vaildが提供されています。
このクレートでは、Rust ではよく知られたバリデータであるvalidatorgardeを利用して、API リクエストの検証を行うことができます。

https://docs.rs/axum-valid/latest/axum_valid/

しかし、今回はシンプルなバリデーションであるため、採用していません。

忘れずに、greetエンドポイントを hello_routerに追加します。

main.rs
 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つのツールがよく知られています。

https://github.com/rust-cross/cargo-zigbuild

https://github.com/cross-rs/cross

今回は、サーバーレスであるというコンセプト上、Docker less なシンプルな開発環境にしたいので、cargo-zigbuild を利用することにします。
cargo-zigbuild は下記のコマンドでインストールします。

bash
cargo install cargo-zigbuild --locked

また、今回の Lambda ランタイム が arm64 とする場合の、ツールチェインを rustup で追加します。

bash
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を利用しています。)

https://aws.amazon.com/jp/builders-flash/202301/lambda-web-adapter/

https://github.com/awslabs/aws-lambda-rust-runtime

こちらも、クロスプラットフォームビルド の時と同じ理由で、lambda_httpクレートを利用する方針をとります。
また、コンテナレジストリにECRを利用することが必須となり、金銭的コストが増えるリスクが高くなるため lambda_httpクレートを利用する方針をとります。

lambda_httpクレートを利用して、下記の用に Cargo.toml を修正します。
feature = "lambda"は独自に設定した feature flag で lambdaフラグがオンの場合のみ lambda とのアダプターを利用するような処理を利用します。また、lambda_httpクレートはビルド時に、feature flag にlambdaが指定された時ビルドに含まれるようになります。

Cargo.toml
 [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 環境の処理を切り替えることができるようになります。

main.rs
...
+#[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 を利用したクロスビルドは次のコマンドで実行します。

bash
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点です。

  1. runtimeaws.lambda.Runtime.CustomAL2023 を指定
  2. handler(バイナリ名)は bootstrapにリネームすること

https://zenn.dev/utcarnivaldayo/books/pulumi-lambadlith/viewer/rest-api#lambda-と関連リソースの作成

lambda.ts
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 ファイルを下記で上書きします。

index.ts
import "./api/aws/lambda.ts";
export {
  API_LAMBDA_FUNCTION_URL,
  API_LAMBDA_ROLE_ARN,
} from "./api/aws/lambda.ts";

エディタ上でallowImportingTsExtensionsnoEmmit関連のエラーが出た場合は、tsconfig.json で compilerOptions に下記を追加すると恐らく改善されます。

tsconfig.json
+"allowImportingTsExtensions": true,
+"noEmit": true,

上記の設定が完了したらデプロイを行います。

bash
pulumi up
pulumi up のログ
stdout
(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