AxumとRustでREST APIを構築する:型安全なサービス、SQLxデータベース統合、Dockerデプロイメント

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

「それなりに速い」APIの問題点

Node.jsやPythonのAPIを本番環境で長年保守してきて、デプロイを恐れ始める瞬間というものを知っている。undefined is not a functionによるランタイムパニック、高負荷時にしか表面化しないデータベースクエリエラー、800MBを超えて膨張するDockerイメージ。それぞれは致命的ではないが、合わさると「良い日でも半分しか信頼できない」システムができあがる。

それがバックエンドサービスにRustを選ぶきっかけになった。パフォーマンスの数字のためではなく(それも本物だが)、コードがコンパイルを通れば特定のクラスのバグが存在しえないという保証のためだ。tokiohyperの上に構築されたAxumは、Webハンドラーについての思考モデルにフィットするAPIで、その保証をHTTPサービスにもたらしてくれる。

これは、AxumとSQLxを使ってREST APIをゼロから構築して学んだことだ。本当に重要なこと、つまずいたポイント、そして今日から始める同僚に伝えたいことをまとめた。

まず理解すべきコアコンセプト

Axumのエクストラクターモデル

Axumの設計はエクストラクターを中心に構築されている。エクストラクターとは、HTTPリクエストからデータを取り出す型のことだ。パスパラメータ、JSONボディ、クエリ文字列、ヘッダー——すべて関数引数で受け取り、すべてコンパイル時に検証される。CreateUser型のJSONボディを受け取るハンドラーを宣言すると、Axumはサーバー起動前にその型を検証する。型の不一致はコンパイルエラーになる。500レスポンスではなく。

SQLx:コンパイル時SQL検証

SQLxは驚くべきことをしてくれる。コンパイル時に実際のデータベースに対してSQLクエリをチェックするのだ。sqlx::query_as!マクロはcargo build時に開発用データベースに接続し、カラム名、型、クエリの正確性を検証する。スキーマのカラム名を変更してクエリの更新を忘れると、ビルドが失敗する。このような素早いフィードバックのおかげで、本番アラートに何度も救われてきた。

Towerミドルウェアスタック

AxumはコンポーザブルなミドルウェアライブラリであるTowerの上に構築されている。CORS、レート制限、トレーシング、圧縮——これらはすべてTowerのレイヤーだ。AxumのハンドラーがTowerのサービスに過ぎないと知っていれば、フレームワークの魔法に頼らず、検査やテストが可能な形でビヘイビアをクリーンに組み合わせることができる。

実践:ユーザーAPIをゼロから構築する

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

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-tls", "uuid", "chrono"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"

データベーススキーマ

-- migrations/0001_create_users.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  username TEXT NOT NULL UNIQUE,
  email TEXT NOT NULL UNIQUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

マイグレーションの実行:sqlx migrate run

アプリケーションの状態とモデル

// src/main.rs
use axum::{
    extract::{Path, State},
    http::StatusCode,
    response::IntoResponse,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;

#[derive(Clone)]
struct AppState {
    db: PgPool,
}

#[derive(Serialize, sqlx::FromRow)]
struct User {
    id: Uuid,
    username: String,
    email: String,
    created_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Deserialize)]
struct CreateUser {
    username: String,
    email: String,
}

ハンドラーの実装

async fn list_users(
    State(state): State<AppState>,
) -> impl IntoResponse {
    match sqlx::query_as!(
        User,
        "SELECT id, username, email, created_at FROM users ORDER BY created_at DESC"
    )
    .fetch_all(&state.db)
    .await
    {
        Ok(users) => (StatusCode::OK, Json(users)).into_response(),
        Err(e) => {
            tracing::error!("ユーザー一覧の取得に失敗しました: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
        }
    }
}

async fn create_user(
    State(state): State<AppState>,
    Json(payload): Json<CreateUser>,
) -> impl IntoResponse {
    match sqlx::query_as!(
        User,
        "INSERT INTO users (username, email) VALUES ($1, $2)
         RETURNING id, username, email, created_at",
        payload.username,
        payload.email
    )
    .fetch_one(&state.db)
    .await
    {
        Ok(user) => (StatusCode::CREATED, Json(user)).into_response(),
        Err(sqlx::Error::Database(e)) if e.constraint() == Some("users_username_key") => {
            (StatusCode::CONFLICT, "このユーザー名はすでに使用されています").into_response()
        }
        Err(e) => {
            tracing::error!("ユーザーの作成に失敗しました: {}", e);
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
        }
    }
}

async fn get_user(
    State(state): State<AppState>,
    Path(id): Path<Uuid>,
) -> impl IntoResponse {
    match sqlx::query_as!(
        User,
        "SELECT id, username, email, created_at FROM users WHERE id = $1",
        id
    )
    .fetch_optional(&state.db)
    .await
    {
        Ok(Some(user)) => (StatusCode::OK, Json(user)).into_response(),
        Ok(None) => StatusCode::NOT_FOUND.into_response(),
        Err(e) => {
            tracing::error!("ユーザー {} の取得に失敗しました: {}", id, e);
            StatusCode::INTERNAL_SERVER_ERROR.into_response()
        }
    }
}

接続プールを使ったmain関数

#[tokio::main]
async fn main() {
    dotenvy::dotenv().ok();

    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();

    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URLを設定してください");

    let pool = PgPool::connect(&database_url)
        .await
        .expect("データベースへの接続に失敗しました");

    sqlx::migrate!().run(&pool).await
        .expect("マイグレーションの実行に失敗しました");

    let state = AppState { db: pool };

    let app = Router::new()
        .route("/users", get(list_users).post(create_user))
        .route("/users/:id", get(get_user))
        .layer(
            tower_http::cors::CorsLayer::permissive()
        )
        .layer(
            tower_http::trace::TraceLayer::new_for_http()
        )
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    tracing::info!("サーバーがポート3000で起動しました");
    axum::serve(listener, app).await.unwrap();
}

Dockerデプロイメント

以前から知っておきたかったことが一つある:RustのDockerビルドはデフォルトで遅い。マルチステージビルドで依存関係のコンパイルレイヤーをキャッシュすれば、cargo-chefを使うことでインクリメンタルビルドの再ビルド時間を10分以上から60秒以内に短縮できる。

# ステージ1:依存関係のキャッシュ
FROM rust:1.78-slim as chef
RUN cargo install cargo-chef
WORKDIR /app

FROM chef as planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

# ステージ2:ビルド
FROM chef as builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release

# ステージ3:最小限のランタイムイメージ
FROM debian:bookworm-slim as runtime
RUN apt-get update && apt-get install -y \
    libssl3 ca-certificates \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/rust-api .
COPY migrations ./migrations
EXPOSE 3000
CMD ["./rust-api"]

最終イメージのサイズは80〜100MB程度で、V8ランタイム全体を含むnode:18イメージの400〜700MBと比べて大幅に小さい。

# docker-compose.yml
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://postgres:password@db:5432/rustapi
      - RUST_LOG=info
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: rustapi
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
docker compose up --build

APIのテスト

# ユーザーを作成する
curl -X POST http://localhost:3000/users \
  -H 'Content-Type: application/json' \
  -d '{"username": "alice", "email": "[email protected]"}'

# ユーザー一覧を取得する
curl http://localhost:3000/users

# IDで取得する
curl http://localhost:3000/users/<uuid>

実践から学んだ教訓

  • 生のクエリ文字列よりもsqlx::query_as!マクロを使おう。 コンパイル時チェックは即座にその価値を発揮する。cargo buildを実行する前に.envDATABASE_URLを設定しておくこと。
  • ハンドラーはシンプルに保とう。 ハンドラーにビジネスロジックを詰め込むとテストが辛くなる。プールをパラメータとして受け取るサービス関数に切り出せば、サーバーを起動せずにユニットテストが書きやすくなる。
  • データベースの制約エラーを明示的に処理しよう。 e.constraint()でマッチングすることで、ユーザーが重複したメールアドレスで登録しようとしたときに、汎用的な500ではなく意味のある409 Conflictレスポンスを返せる。
  • 構造化されたコンテキストでログを記録しよう。 tracingスパンを使えば、リクエストのライフサイクル内のすべてのログ行にリクエストIDとユーザーIDを付与できる。並行する本番リクエストをデバッグするときに不可欠だ。
  • 開発環境ではスタートアップ時にマイグレーションを実行し、本番環境では別途実行しよう。 main()sqlx::migrate!()を呼び出すのはローカルでは便利だが、本番環境ではマイグレーションの制御をアプリの再起動から分離したほうが良い。
  • 本番リリース前にCorsLayer::permissive()を置き換えよう。 これは任意のオリジン、メソッド、ヘッダーからのリクエストをすべて許可してしまう。本番稼動前にCorsLayer::new().allow_origin([...])で適切に絞り込むこと。

次のステップ

この基盤が動いたら、次の拡張として自然に考えられるのは、JWTによる認証jsonwebtokenクレートはAxumのエクストラクターにミドルウェアとしてきれいに統合できる)、リストエンドポイントへのカーソルベースのページネーション、そしてutoipaによるOpenAPI仕様——ハンドラー型から直接インタラクティブなSwagger UIドキュメントを生成してくれる。

Rustを最初に学んだときに制限的に感じたコンパイル時の保証は、今では新しいサービスでRustを選ぶ主な理由になっている。フィードバックループが変わる——型の不一致や欠落フィールドは開発中に発見され、深夜3時のアラートで知ることはない。同じAPIを2〜3年間保守するチームにとって、これは早い段階でする価値のあるトレードオフだ。

Share: