AxumとActix-web: Rustで本番環境向けREST APIを構築する

Table of Contents
RustのWebフレームワークは成熟し、その選択は単なる好奇心ではなく、真のアーキテクチャ上の決定事項となっています。このガイドでは、本番環境のREST APIを構築するために実際に必要なもの、すなわちフレームワークの選択、非同期データベースアクセス、エラー伝播、共有アプリケーション状態について、動作するコードを交えながら解説します。
Axum vs Actix-web: どちらを選ぶべきか?
どちらも本番環境に対応しています。本当の違いはアーキテクチャの哲学にあります。
Actix-webはより古く(2017年)、実戦で鍛えられ、TechEmpowerのベンチマークで常に上位を占めています。独自のランタイム(actix-rt)で動作し、内部的にはアクターモデルを使用しています。APIは成熟しており安定しているため、破壊的変更に遭遇することはありません。
Axum(2021年、Tokioチーム製)は、より広範なTokioエコシステムと連携するように設計されています。towerミドルウェアを使用しているため、レートリミッター、トレーシングレイヤー、圧縮など、あらゆるtower::Serviceが直接プラグインできます。APIはより人間工学に基づいており、マクロを使用していません。
| 機能 | Axum 0.7 | Actix-web 4 |
|---|---|---|
| ランタイム | Tokio | actix-rt (Tokioベース) |
| ミドルウェア | towerエコシステム | actix-webネイティブ |
| ルーティング | マクロフリー、型安全 | マクロベース、人間工学的 |
| WebSockets | axum::extract::ws経由 | ネイティブ、成熟 |
| 生のスループット† | 約450k req/s | 約490k req/s |
| エコシステム | 急速に成長中 | より大きく、より古い |
| 学習曲線 | 中程度(トレイト) | 中程度(アクター) |
†TechEmpower Round 22、プレーンテキストベンチマーク、シングルスレッド、ベアメタル。DBアクセスを伴う実際のワークロードでも同様の結果になります。
私の推奨: 新しいプロジェクトにはAxumを使用してください。towerミドルウェアエコシステムは比類なく、Tokioネイティブな設計によりトレーシング/可観測性(observability)が容易になります。
プロジェクトのセットアップ
cargo new rust-api && cd rust-api
# Cargo.toml
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.7", features = ["postgres", "runtime-tokio-native-tls", "uuid", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
anyhow = "1"
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "serde"] }
dotenv = "0.15"
共有アプリケーション状態
Axumにおける最も重要なアーキテクチャ上の決定は、ハンドラー間で状態をどのように共有するかです。パターンは、.with_state()を介して渡されるArc<AppState>です。
// src/state.rs
use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
pub config: AppConfig,
}
#[derive(Clone)]
pub struct AppConfig {
pub jwt_secret: String,
pub allowed_origins: Vec<String>,
}
// src/main.rs
use axum::{Router, middleware};
use sqlx::postgres::PgPoolOptions;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use std::sync::Arc;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenv::dotenv().ok();
tracing_subscriber::fmt()
.with_env_filter("rust_api=debug,tower_http=debug")
.init();
let database_url = std::env::var("DATABASE_URL")?;
let pool = PgPoolOptions::new()
.max_connections(20)
.connect(&database_url)
.await?;
// Run migrations at startup
sqlx::migrate!("./migrations").run(&pool).await?;
let state = Arc::new(AppState {
db: pool,
config: AppConfig {
jwt_secret: std::env::var("JWT_SECRET")?,
allowed_origins: vec!["https://yourdomain.com".to_string()],
},
});
let app = Router::new()
.nest("/api/v1", api_router())
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive()) // tighten in production
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
tracing::info!("Listening on {}", listener.local_addr()?);
axum::serve(listener, app).await?;
Ok(())
}
注: Arc<AppState>はCloneを実装しているため、Axumはプールをディープコピーすることなく(プール自体は接続のプールであり、単一の接続ではない)、各リクエストに対してクローンを作成できます。
SQLxによる非同期データベースアクセス
SQLxは、ORMなしでコンパイル時チェックされるSQLを提供します。sqlx::query_as!マクロは、コンパイル時に実際のデータベーススキーマに対してクエリを検証します。
// src/models/post.rs
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
use chrono::{DateTime, Utc};
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct Post {
pub id: Uuid,
pub title: String,
pub content: String,
pub author_id: Uuid,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePost {
pub title: String,
pub content: String,
}
// src/db/posts.rs
use sqlx::PgPool;
use uuid::Uuid;
use crate::models::post::{Post, CreatePost};
use crate::error::AppError;
pub async fn get_posts(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<Post>, AppError> {
let posts = sqlx::query_as!(
Post,
r#"
SELECT id, title, content, author_id, created_at, updated_at
FROM posts
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#,
limit,
offset
)
.fetch_all(pool)
.await?;
Ok(posts)
}
pub async fn create_post(
pool: &PgPool,
author_id: Uuid,
payload: CreatePost,
) -> Result<Post, AppError> {
let post = sqlx::query_as!(
Post,
r#"
INSERT INTO posts (id, title, content, author_id, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
RETURNING *
"#,
Uuid::new_v4(),
payload.title,
payload.content,
author_id,
)
.fetch_one(pool)
.await?;
Ok(post)
}
!マクロのサフィックス(query_as!)は、cargo build中に実行中のデータベース、またはCIでのオフラインビルド用に記録された.sqlx/ディレクトリ(cargo sqlx prepareによって生成)を必要とします。
thiserrorによるエラーハンドリング
慣用的なRustのアプローチは、独自のエラーenumを定義し、Axum用にIntoResponseを実装することです。
// src/error.rs
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Not found: {0}")]
NotFound(String),
#[error("Unauthorized")]
Unauthorized,
#[error("Validation error: {0}")]
Validation(String),
#[error("Internal error")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()),
AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
AppError::Database(e) => {
tracing::error!("Database error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Database error".to_string())
}
AppError::Internal(e) => {
tracing::error!("Internal error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error".to_string())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
このパターンは、すべてのハンドラーがResult<impl IntoResponse, AppError>を返すことができ、エラーが自動的に適切なHTTPレスポンスにマッピングされることを意味します。
ルートハンドラー
// src/handlers/posts.rs
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use std::sync::Arc;
use uuid::Uuid;
use crate::{db, error::AppError, models::post::CreatePost, state::AppState};
#[derive(Deserialize)]
pub struct Pagination {
pub limit: Option<i64>,
pub offset: Option<i64>,
}
pub async fn list_posts(
State(state): State<Arc<AppState>>,
Query(pagination): Query<Pagination>,
) -> Result<Json<Vec<crate::models::post::Post>>, AppError> {
let limit = pagination.limit.unwrap_or(20).min(100);
let offset = pagination.offset.unwrap_or(0);
let posts = db::posts::get_posts(&state.db, limit, offset).await?;
Ok(Json(posts))
}
pub async fn create_post(
State(state): State<Arc<AppState>>,
// In production, extract author_id from JWT claims
Json(payload): Json<CreatePost>,
) -> Result<(StatusCode, Json<crate::models::post::Post>), AppError> {
if payload.title.trim().is_empty() {
return Err(AppError::Validation("Title cannot be empty".to_string()));
}
if payload.title.len() > 200 {
return Err(AppError::Validation("Title too long (max 200 chars)".to_string()));
}
let author_id = Uuid::new_v4(); // Replace with JWT extractor
let post = db::posts::create_post(&state.db, author_id, payload).await?;
Ok((StatusCode::CREATED, Json(post)))
}
pub fn api_router() -> Router<Arc<AppState>> {
Router::new()
.route("/posts", axum::routing::get(list_posts).post(create_post))
.route("/posts/:id", axum::routing::get(get_post_by_id))
}
実際のスループット数値
これらは、PostgresをバックエンドとするシンプルなJSONエンドポイントに対してwrk -t4 -c100 -d30sを実行した、4コアのテストVM(8 vCPU、16 GB RAM)からの測定値です。
| フレームワーク | 言語 | Req/s (JSON) | p99レイテンシ | メモリ |
|---|---|---|---|---|
| Axum 0.7 | Rust | 約180k | 1.2ms | 18 MB |
| Actix-web 4 | Rust | 約195k | 1.1ms | 20 MB |
| Hyper (raw) | Rust | 約230k | 0.9ms | 12 MB |
| FastAPI + uvicorn | Python | 約22k | 4.8ms | 95 MB |
| Express 4 | Node.js | 約38k | 3.1ms | 75 MB |
| Axum + SQLx | Rust | 約42k | 2.8ms | 22 MB |
「With SQLx」の行は実際のDBラウンドトリップを表しており、Postgresがボトルネックであり、Rustではありません。とはいえ、控えめなVMで実際のPostgresクエリを伴う42k req/sは、同じハードウェアでのFastAPIの22kと比較して並外れたものです。
学習曲線を乗り越える
所有権システムは、ほとんどの人が次の2つの点でつまずきます。
1. .awaitをまたぐ借用: 多くの非同期コンテキストでは、awaitポイントをまたいで参照を保持することはできません。解決策: awaitの前に必要なものをクローンするか、より短い借用スコープに再構築します。
2. 状態の共有: どこでもMutex<SomeState>を使いたくなる誘惑があります。以下を推奨します。
- 読み込みが多い共有データ(あなたの
AppState)にはArc<T> Mutex<HashMap>の代わりに、並行ハッシュマップにはDashMap(dashmapクレートから)- メッセージパッシングパターンにはチャネル(
tokio::sync::mpsc)
コンパイル時間: 開発中の迅速なフィードバックにはcargo checkを使用してください。バイナリが必要な場合にのみcargo buildを使用します。最初のクリーンビルドは遅いですが、インクリメンタルリビルドは高速です。
デプロイ
Dockerの場合、マルチステージビルドにより約50 MBのイメージが得られます。
# Build stage
FROM rust:1.78-slim as builder
WORKDIR /app
COPY . .
RUN cargo build --release
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/rust-api /usr/local/bin/rust-api
RUN useradd -m appuser
USER appuser
EXPOSE 3000
CMD ["rust-api"]
マネージドデプロイの場合、Shuttleを使用すると、DockerfileなしでAxumアプリをcargo shuttle deployでデプロイでき、Postgresが自動的にプロビジョニングされます。
Web開発にRustを使う価値はあるか?
ほとんどのCRUD APIの場合: おそらくまだありません。エコシステムは小さく、オンボーディングは難しく、Python/Nodeの方が迅速にリリースできます。
高トラフィックAPI、バックグラウンドワーカー、WebSocketサーバー、または大規模なコンピューティングに費用をかけているサービスの場合 — はい、価値があります。Node.jsと比較して5〜8倍のメモリ削減は現実的であり、Kubernetesポッドでは通常メモリがボトルネックになります。Rustはまた、ランタイムパニックやメモリ関連の本番環境でのインシデントのクラス全体を排除します。
最適なエントリーポイントは、チームが期限のプレッシャーなしに借用チェッカーを学ぶ時間がある、レイテンシに敏感な内部サービスです。
こちらもおすすめ
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

ブラウザを超えたWebAssembly: 高性能マイクロサービスの構築
Wasmtime、WasmEdge、Spinを使ってWebAssemblyをサーバーサイドで活用し、ネイティブに近い速度で言語に依存せず、機能がサンドボックス化されたマイクロサービスを構築する方法を、Dockerコンテナとの実測ベンチマークを交えて解説します。
Read more
ハイパーグロースのための最新データベースシャーディング戦略
水平パーティショニング、レンジキーとコンシステントハッシュキー、クロスシャード結合、分散トランザクション(2PCとSaga)、Vitess、Citusなど、最新のデータベースシャーディングアーキテクチャを習得しましょう。
Read morePostgreSQLのVACUUMとインデックス肥大化:検知、軽減、そして自動チューニング
PostgreSQLのテーブルとインデックスの肥大化を診断・解消します。自動バキュームのチューニング方法、pg_repackによるゼロダウンタイムでの再構築、MVCCの可視性マップまでを解説します。
Read more