•11 min read

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

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

RustのWebフレームワークは成熟し、その選択は単なる好奇心ではなく、真のアーキテクチャ上の決定事項となっています。このガイドでは、本番環境のREST APIを構築するために実際に必要なもの、すなわちフレームワークの選択、非同期データベースアクセス、エラー伝播、共有アプリケーション状態について、動作するコードを交えながら解説します。

Audio Briefing
0:00 / 0:00

Axum vs Actix-web: どちらを選ぶべきか?

どちらも本番環境に対応しています。本当の違いはアーキテクチャの哲学にあります。

Actix-webはより古く(2017年)、実戦で鍛えられ、TechEmpowerのベンチマークで常に上位を占めています。独自のランタイム(actix-rt)で動作し、内部的にはアクターモデルを使用しています。APIは成熟しており安定しているため、破壊的変更に遭遇することはありません。

Axum(2021年、Tokioチーム製)は、より広範なTokioエコシステムと連携するように設計されています。towerミドルウェアを使用しているため、レートリミッター、トレーシングレイヤー、圧縮など、あらゆるtower::Serviceが直接プラグインできます。APIはより人間工学に基づいており、マクロを使用していません。

機能Axum 0.7Actix-web 4
ランタイムTokioactix-rt (Tokioベース)
ミドルウェアtowerエコシステムactix-webネイティブ
ルーティングマクロフリー、型安全マクロベース、人間工学的
WebSocketsaxum::extract::ws経由ネイティブ、成熟
生のスループット†約450k req/s約490k req/s
エコシステム急速に成長中より大きく、より古い
学習曲線中程度(トレイト)中程度(アクター)

†TechEmpower Round 22、プレーンテキストベンチマーク、シングルスレッド、ベアメタル。DBアクセスを伴う実際のワークロードでも同様の結果になります。

私の推奨: 新しいプロジェクトにはAxumを使用してください。towerミドルウェアエコシステムは比類なく、Tokioネイティブな設計によりトレーシング/可観測性(observability)が容易になります。

Advertisement

プロジェクトのセットアップ

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によって生成)を必要とします。

Advertisement

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.7Rust約180k1.2ms18 MB
Actix-web 4Rust約195k1.1ms20 MB
Hyper (raw)Rust約230k0.9ms12 MB
FastAPI + uvicornPython約22k4.8ms95 MB
Express 4Node.js約38k3.1ms75 MB
Axum + SQLxRust約42k2.8ms22 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はまた、ランタイムパニックやメモリ関連の本番環境でのインシデントのクラス全体を排除します。

最適なエントリーポイントは、チームが期限のプレッシャーなしに借用チェッカーを学ぶ時間がある、レイテンシに敏感な内部サービスです。

こちらもおすすめ

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement