[小ネタ]AWS Lambda for Rustの開発を少し便利にする

[小ネタ]AWS Lambda for Rustの開発を少し便利にする

2026.05.18

Introduction

最近はcargo lambda + cargo-lambda-cdk で LambdaをRustで実装することが楽になりました。
本記事では比較的最近Cargoに入った機能を 使い、Lambda開発を少し便利にする方法について紹介します。

前提のリポジトリ構成は以下です。
1 つの Cargo package に複数の [[bin]] を定義する形式にしておくと、後述の CARGO_BIN_EXE_<name> が効くので、本記事ではこれを採用します。

my-lambdas/                         ← Cargo workspace
├── Cargo.toml
├── .cargo/
│   ├── config.toml
│   └── lambda-build.toml           ← Lambda 用の共通ビルド設定
├── crates/
│   └── shared/                     ← 全 Lambda が使う共有クレート
├── lambdas/
│   └── sample/                     ← 1 package + [[bin]] × 3
│       ├── Cargo.toml
│       ├── src/
│       │   ├── lib.rs
│       │   └── bin/
│       │       ├── sample-api.rs
│       │       ├── sample-worker.rs
│       │       └── sample-extension.rs
│       └── tests/
│           └── with_extension.rs   ← integration test で使う
└── cdk/
    ├── bin/app.ts
    └── lib/api-stack.ts

本記事で試してみる機能は以下です。

  • cargo publish のマルチパッケージ公開 → 共有 crate 群を 1 コマンドで push
  • build.build-dir 設定 → 中間生成物と最終バイナリを分離し CI cache を絞る
  • --target host-tuple → unit test の host triple ハードコード回避
  • CARGO_CFG_DEBUG_ASSERTIONSbuild.rs に公開 → profile 名に依存せず dev / release 系譜を判定
  • 設定の include キー → Lambda 共通の .cargo/config.toml を切り出す
  • TOML v1.1 in Cargo.toml[[bin]] 多数時の可読性
  • CARGO_BIN_EXE_<name> を test runtime に公開 → Extension [[bin]] を integration test から spawn

Environment

  • OS : macOS 26.4
  • Rust : 1.95.0
  • cargo lambda : 1.9.1
  • cargo lambda cdk : 0.0.36

Setup

必要な環境のセットアップをしておきます。
cargo-lambda を Homebrew か Cargo でインストールします。

% brew install cargo-lambda/tap/cargo-lambda
# or
# cargo install cargo-lambda

Lambda の最小実装は以下。

# Cargo.toml の dependencies
[dependencies]
lambda_runtime = { version = "0.14", features = ["tracing"] }
serde_json     = "1"
tokio          = { version = "1", features = ["macros"] }
use lambda_runtime::{run, service_fn, tracing, Error, LambdaEvent};
use serde_json::{json, Value};

#[tokio::main]
async fn main() -> Result<(), Error> {
    tracing::init_default_subscriber();
    run(service_fn(handler)).await
}

async fn handler(event: LambdaEvent<Value>) -> Result<Value, Error> {
    let (payload, _ctx) = event.into_parts();
    let name = payload["name"].as_str().unwrap_or("world");
    Ok(json!({ "message": format!("Hello, {name}!") }))
}

tracing::init_default_subscriber()lambda_runtime::tracing の関数なので、
use lambda_runtime::tracing; でインポートしておく必要があります。

CDK でデプロイするために cargo-lambda-cdk を使います。
デフォルトランタイムは provided.al2023 を指定。

import { Duration } from 'aws-cdk-lib';
import { Architecture } from 'aws-cdk-lib/aws-lambda';
import { RustFunction } from 'cargo-lambda-cdk';

new RustFunction(this, 'SampleApi', {
  manifestPath: 'lambdas/sample',     // Cargo.toml のあるディレクトリ
  binaryName:   'sample-api',         // [[bin]] 名で指定
  architecture: Architecture.ARM_64,
  memorySize: 256,
  timeout: Duration.seconds(10),
  environment: { RUST_LOG: 'info' },
});

Try

では、Rust で AWS Lambda を実装して CDK で管理するにあたって
便利な記述方法をいくつか試してみます。

config.toml の include

.cargo/config.tomlinclude キーを使用すると、
Lambda 共通設定を切り出すことができます。

以下のように、include のパスは
「このファイルの親ディレクトリ起点」で解決されます。

# .cargo/config.toml から見たパスは
# "lambda-build.toml"(.cargo/lambda-build.toml ではない)
include = ["lambda-build.toml"]

[build]
# 他の workspace 共通設定(rustflags など)
# .cargo/lambda-build.toml
[profile.release-lambda]
inherits      = "release"
opt-level     = "z"
lto           = "fat"
codegen-units = 1
panic         = "abort"
strip         = "symbols"
debug         = false

[env]
LAMBDA_BUILD_CHANNEL = "stable"

これにより、Lambda 共通のプロファイル定義が .cargo/lambda-build.toml に集約されます。
新しい Lambda が増えたとしても、各 Lambda 側の Cargo.toml
何も書かなくても同じ設定となります。

上記のような記述だと、cargo lambda build --profile release-lambda
全 Lambda が同じプロファイルを共有します。

以前(〜1.93)は共通設定を切り出す手段がなかったので、
workspace ルートの Cargo.toml.cargo/config.toml に全部書くか
各 Lambda にそれぞれ記述するしかありませんでした。

# Before: 各 Lambda の Cargo.toml に毎回コピペ
[profile.release-lambda]
inherits      = "release"
opt-level     = "z"
lto           = "fat"
# ... × Lambda 数だけ繰り返し

cargo-lambda-cdk は内部で cargo lambda build を実行するだけなので、
include 設定はそのまま読まれます。
Docker bundling 時もリポジトリルートが mount されるため、
CDK 側で何かする必要はありません。

build.build-dir で CI cache を絞る

以下のように設定を記述することで、ビルド時の中間生成ファイル(incremental, deps)と
最終成果ファイルを物理パスで分離できます。

# .cargo/config.toml
[build]
build-dir  = "/tmp/cargo-build"   # 中間 .o / deps / incremental
target-dir = "target"             # 最終成果ファイル(cargo-lambda はここを参照)
パス 内容
/tmp/cargo-build/ rustc が出力する .rlib, .o, incremental
target/release/<bin> リンク後のローカルビルド成果物
target/lambda/<bin>/bootstrap cargo-lambda が生成する Lambda 用 ELF

例えば、CI で使う場合、 /tmp/cargo-build を捨てて、
target/ だけを cache できます。
また、cargo-lambda-cdkassetHashType: OUTPUT がデフォルトで
出力 ELF のハッシュで差分判定するため、中間を毎回作り直しても
最終的な ELF が同じならデプロイはスキップされます。

以前は中間ファイルと最終ファイルが target/ 1 ディレクトリに同居しており、
target/ ごと cache するしかありませんでしたが、
build.build-dir で無駄なく cache することが可能になります。

--target host-tuple で host triple のハードコードを避ける

Lambda 向けの workspace では、普段のビルド先を aarch64-unknown-linux-gnu に寄せつつ、
unit test やローカル確認だけは host で動かしたい場面があります。

以前は host triple を明示するために、以下のように rustc の出力を shell で差し込む必要がありました。

% cargo test --target "$(rustc -vV | sed -n 's/^host: //p')"

Rust 1.91 以降は --target host-tuple をそのまま渡せます。

% cargo test --target host-tuple

host-tuple は実行中の host triple に置き換えられるため、
Apple Silicon なら aarch64-apple-darwin、Intel macOS なら x86_64-apple-darwin のように展開されます。
ローカル OS 依存の integration test や extension の起動テストを、Lambda 用 target と切り分けたい場合に使いやすいです。

integration test の runtime から CARGO_BIN_EXE_<name> を取れる

CARGO_BIN_EXE_<name> 環境変数は、
「同じ package 内で [[bin]] として定義したバイナリの絶対パス」
です。
たとえば、integration test 時にこのパスを使って
Command::new(path).spawn() すれば、
そのテストで実際にビルドされた最新のバイナリを子プロセスとして起動できます。

以前(〜1.93)までは env!("CARGO_BIN_EXE_sample-extension")(コンパイル時のマクロ)でしか取れませんでした。
現状(1.94〜)は std::env::var("CARGO_BIN_EXE_sample-extension") で、
マクロでも実行時でも OK です。

# lambdas/sample/Cargo.toml
[package]
name    = "sample"
version = "0.1.0"

[[bin]]
name = "sample-api"
path = "src/bin/sample-api.rs"

[[bin]]
name = "sample-worker"
path = "src/bin/sample-worker.rs"

[[bin]]
name = "sample-extension"
path = "src/bin/sample-extension.rs"
// lambdas/sample/tests/with_extension.rs
//
// 同一 package 内の [[bin]] = sample-extension のパスが
// integration test の runtime env として入ってくる

#[tokio::test]
async fn handler_calls_extension_logging_endpoint() {
    let extension_path = std::env::var("CARGO_BIN_EXE_sample-extension")
        .expect("sample-extension bin not built");

    let mut child = std::process::Command::new(extension_path)
        .env("AWS_LAMBDA_RUNTIME_API", "127.0.0.1:9001")
        .spawn()
        .expect("extension launch");

    // sample crate の lib.rs に置いた共通ハンドラロジックを直接呼び、
    // Extension 経由ログが期待通り収集されることを assert
    // ...

    let _ = child.kill();
}
バージョン CARGO_BIN_EXE_<name> の取得方法
〜1.93 integration test の コンパイル時env!() で取得
1.94〜 上記に加えて、integration test / benchmark の実行時にも std::env::var() で取得可能

build.rs から CARGO_CFG_DEBUG_ASSERTIONS を読んでメタデータを設定

以前は build.rs から debug assertion が有効か直接取る方法がなく、
PROFILE 環境変数か OPT_LEVEL を使うしかなかったのですが、
1.93 以降は CARGO_CFG_DEBUG_ASSERTIONS が直接読めるので、
profile 名に依存せず「dev 系譜か release 系譜か」が取れます。
たとえば、CloudWatch Logs 上で「いまどのコミット・どの系統の Lambda が動いているか」がすぐわかります。

// lambdas/sample/build.rs
// package 共通で 3 つの [[bin]] すべてに env が伝播する
use std::env;

fn main() {
    let debug = env::var("CARGO_CFG_DEBUG_ASSERTIONS").is_ok();
    let profile = env::var("PROFILE").unwrap_or_default();
    let git_sha = std::process::Command::new("git")
        .args(["rev-parse", "--short", "HEAD"])
        .output()
        .ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .unwrap_or_else(|| "unknown".into());

    println!("cargo:rustc-env=BUILD_PROFILE={profile}");
    println!("cargo:rustc-env=BUILD_DEBUG_ASSERTIONS={debug}");
    println!("cargo:rustc-env=BUILD_GIT_SHA={git_sha}");
    println!("cargo:rerun-if-changed=.git/HEAD");
}
// lambdas/sample/src/bin/sample-api.rs
use lambda_runtime::{run, service_fn, tracing, Error};

const BUILD_PROFILE: &str = env!("BUILD_PROFILE");
const BUILD_DEBUG: &str   = env!("BUILD_DEBUG_ASSERTIONS");
const BUILD_GIT_SHA: &str = env!("BUILD_GIT_SHA");

#[tokio::main]
async fn main() -> Result<(), Error> {
    tracing::init_default_subscriber();
    tracing::info!(
        profile = BUILD_PROFILE,
        debug   = BUILD_DEBUG,
        git_sha = BUILD_GIT_SHA,
        "lambda boot"
    );
    run(service_fn(handler)).await
}

TOML v1.1 で Lambda マルチ [[bin]]

Rust 1.94 から TOML v1.1(インラインテーブル内で改行)が使えるので、
Cargo.toml が読みやすくなります。

[[bin]]
name = "sample-api"
path = "src/bin/sample-api.rs"
required-features = ["http"]

[package.metadata.lambda.deploy]
memory  = 256
timeout = 10
env     = {
    RUST_LOG                = "info",
    POWERTOOLS_SERVICE_NAME = "sample-api",
    OTEL_TRACES_SAMPLER     = "always_on",
}

MSRV は 1.94 に上がりますが、cargo publish 時には正規化された TOML として書き直されるため、
公開後の Cargo.toml は古いパーサとも互換性があります。

というわけで、現状 CDK(cargo-lambda-cdk)で Lambda をデプロイしている限り、
Cargo.toml は TOML v1.0 互換構文で書く必要があります。

cargo publish のマルチパッケージ公開

以前は cargo publish が 1 回 = 1 パッケージだったので、以下のように
自分で publish するパッケージを順番に実行してました。

# Before: 依存順を手で並べ、反映待ち sleep を挟む
cargo publish -p shared-types --registry internal
sleep 30                                            # registry 反映待ち
cargo publish -p shared-tracing --registry internal
sleep 30
cargo publish -p shared-aws --registry internal

現在(1.90〜)は Cargo がワークスペース内依存の DAG を見て publish 順を自動計算してくれるようになりました。
なので、共有 crate を新しく足しても publish 用スクリプトはそのままで OK です。

% cargo publish --workspace -p shared-types -p shared-tracing -p shared-aws

Lambda Using Cargo and Rust

Lambda が機能ごとに増えていくケースで使えそうな機能についていくつか紹介します。

[workspace.lints] で複数 Lambda の lint 集約

# ワークスペースルート Cargo.toml
[workspace.lints.rust]
unsafe_code = "forbid"

[workspace.lints.clippy]
all      = "warn"
pedantic = "warn"
# 各 Lambda クレート Cargo.toml
[lints]
workspace = true

ルート 1 箇所で全ての Lambda に同じ lint 設定が効きます。
workspace = true の設定は忘れないようにしましょう。
設定キー一覧は Cargo book — the lints table を参照。

#[global_allocator] の差し替え(mimalloc / jemalloc)

Rust のデフォルト allocator は OS 標準の malloc(Linux なら glibc malloc、macOS なら libmalloc)です。
処理内容によっては、特化した alloc に差し替えると速くなる場合があります。
差し替え候補として、mimalloc と jemalloc がよく使用されます。
特徴は以下。

allocator 開発元 特徴
mimalloc Microsoft Research 小〜中サイズの allocation が多い ワークロードに強い。バイナリサイズが小さく、依存も軽い
jemalloc (tikv-jemallocator) Facebook → TiKV 移管版 長時間動くプロセスでフラグメンテーションが少ない。大規模サーバ系で採用実績が多い

mimalloc を使う場合は以下のように記述します。

[dependencies]
mimalloc = { version = "0.1", default-features = false }
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

jemalloc の場合は以下。

[dependencies]
tikv-jemallocator = "0.6"
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;

Lambda は短命プロセスかつ alloc パターンも様々なので malloc のままで十分なケースも多いですが、
場合によっては効果が出るかもしれないので必ず効果を検証しましょう。

cargo bloat で肥大化している crate や関数を可視化

開発が進むとでてくる肥大化問題。
cargo-bloat を使うことで [profile.release-lambda] をさわる前に、
依存ツリーで修正するか、コード生成で修正するか判別できます。
詳細は cargo-bloat README 参照。

% cargo install cargo-bloat
% cargo bloat --release --bin sample-api -n 30     # 関数別 top 30
% cargo bloat --release --bin sample-api --crates  # crate 別

PGO(Profile-Guided Optimization)

PGO(Profile-Guided Optimization)は、
プログラムを実際に動かして、どの分岐がよく通るか・どの関数がネックかを計測し、
その情報をコンパイラに渡して最適化するテクニックです。

以下のように実施します。

  1. 計測用バイナリをビルド(-C profile-generate)— 実行時に分岐・関数呼び出しの統計を記録するように計装される
  2. 実ワークロードを流す — 計測用バイナリを動かして profile データを集める
  3. 最適化バイナリをビルド(-C profile-use)— 2 で集めた profile を元に、よく通る分岐をインライン化したりホットコードを近くに置いたりして最適化
% rustup component add llvm-tools-preview
% cargo install cargo-pgo

% cargo pgo instrument build -- --bin sample-api  # ① 計測用バイナリ
% cargo pgo run        -- --bin sample-api        # ② 実ワークロードを流す
% cargo pgo optimize  build -- --bin sample-api   # ③ 最適化バイナリ

これを使用すると、実ワークロードに沿った最適化が効く場合があります

PGO は計測したワークロードに最適化されるため、
本番のリクエスト分布と乖離すると逆効果になるので注意してください。

Summary

Lambda + CDK 開発を最近の Rust / Cargo で便利にできそうな機能を一通り試してみました。

これまで少し面倒だった所を綺麗に書き直せるようになった改善が多めです。
それでも Lambda が増え続けるリポジトリほど積み上がりが効くので、新しい Lambda リポジトリを作るときや既存リポジトリを整理するときに、刺さりそうな機能から少しずつ拾ってみるのがちょうどよいかなと思います。

References

この記事をシェアする

関連記事