フルスタックRustのWebアプリケーション開発備忘録

ルスタックRustのWebアプリケーション開発備忘録

  • 日本語
  • Rust
  • Leptos
  • axum
  • PostgreSQL
  • Terraform
  • Makefile

#はじめに

「TypeScript書けばいいのに」

フルスタックRustで開発を始めてから、何度この言葉が脳裏をよぎったことか。Next.js使えば3日で終わるものを、なぜ3週間かけてRustで書いているのか。所有権エラーと格闘しながら「これ、本当に正しい選択だったのか?」と自問自答する日々。

でも、気づいたらコンパイルが通った瞬間の快感が忘れられなくなっていた。「コンパイル通ったら大体動く」というRustの安心感。実行時エラーで夜中に叩き起こされる恐怖から解放される喜び。一度この味を知ってしまうと、もう戻れない。

この記事は、そんなRust中毒者の備忘録だ。フロントエンドからバックエンド、インフラまで全部Rustで書いた経験をまとめておく。次に同じことをやるとき(やるのか?)、この記事を見て「あぁ、こうやったな」と思い出せるように。

正直、この記事を読まなくても構築できるようになれば一人前なのだが、私はまだまだ未熟者なので、存分に自分のブログを活用していく所存である。

#この記事で構築するもの

ユーザー管理機能を持つWebアプリケーションを題材に、以下の構成でフルスタックRust開発を解説する。

  • フロントエンド: Leptos(CSR)
  • バックエンド: axum
  • データベース: PostgreSQL + SQLx
  • 認証: JWT
  • インフラ: GCP Cloud Run + Cloudflare Workers
  • IaC: Terraform

「え、Leptosって何?」という人もいるかもしれない。大丈夫、私も最初はそうだった。でも使ってみると、SolidJSに慣れた人なら驚くほどすんなり入れる。Reactしか知らない人でも、Signal(リアクティブな値)とview!マクロ(JSXみたいなやつ)さえ理解すれば、あとは「Rustらしい書き方」を身につけるだけだ。

…「だけ」と言ったが、その「Rustらしい書き方」が一番難しいんだけどね。


#技術スタック概要

今回採用した技術スタックを整理しておく。「なぜその技術を選んだのか」という理由も添えておくが、正直なところ「Rustで書けるから」という理由が8割を占めている。残りの2割は後付けの理屈だ。

#フロントエンド: Leptos 0.8.x(CSR)

Leptosは、RustでWebフロントエンドを書くためのフレームワーク。SolidJSに非常に近い設計思想で、Fine-grained Reactivity(きめ細かいリアクティビティ)を採用している。

ReactがVirtual DOMを使って「コンポーネント全体を再レンダリング」するのに対し、LeptosはSolidJSと同様に「変更があったDOMノードだけを直接更新」する。そのため、Virtual DOMのオーバーヘッドがなく、非常に高速だ。

Leptosには CSR(Client-Side Rendering)と SSR(Server-Side Rendering)の両方のモードがあるが、今回は CSR を採用した。理由は単純で、SSRの設定が面倒だったから。…いや、正確には「バックエンドをaxumで別に立てたかったから」だ。SSRモードにするとLeptosがサーバーも兼ねることになり、axumとの役割分担が曖昧になる。

CSRの場合、LeptosのコードはWASM(WebAssembly)にコンパイルされ、ブラウザ上で動作する。ビルドには trunk というツールを使う。

#バックエンド: axum 0.8.x

axumは、Tokioチームが開発しているWebフレームワーク。Actix-webと並んでRust Webフレームワークの二大巨頭だが、個人的にはaxumの方が好み。理由は、Extractorという仕組みが直感的で使いやすいから。

リクエストから必要なデータを取り出す処理を「Extractor」として抽象化しており、ハンドラーの引数に書くだけで自動的にデータを抽出してくれる。この設計思想が、Rustの型システムと非常に相性が良い。

#データベース: PostgreSQL 17 + SQLx 0.8.x

データベースはPostgreSQLを採用。NoSQLも検討したが、結局RDBMSの安心感には勝てなかった。トランザクションの信頼性、複雑なクエリの柔軟性、そして何より「困ったらSQLで殴れば何とかなる」という安心感。

RustからPostgreSQLにアクセスするには SQLx を使う。SQLxの最大の特徴は コンパイル時クエリ検証 だ。query! マクロを使うと、コンパイル時に実際のデータベースに接続してSQLの文法とスキーマをチェックしてくれる。

つまり、「本番環境でSQL文のtypoに気づく」という悲劇を未然に防げる。これだけでもSQLxを使う価値がある。

#認証: JWT(jsonwebtoken + bcrypt)

認証にはJWT(JSON Web Token)を採用。セッションベースの認証も検討したが、フロントエンドとバックエンドが完全に分離している構成では、JWTの方が扱いやすい。

JWTの実装詳細については、以前書いた記事「Webアプリケーション開発のJWT認証の実装で学んだこと」を参照してほしい。攻撃手法と対策、トークン生成・検証、データベーススキーマなど、かなり詳しくまとめてある。

今回の記事では、axum側での認証ミドルウェア(Extractor)の実装に焦点を当てる。

#インフラ: GCP Cloud Run + Cloudflare Workers

バックエンドは Google Cloud Run にデプロイする。コンテナベースのサーバーレスサービスで、リクエストがないときはインスタンス数を0までスケールダウンできる。個人開発にはありがたいコスト構造だ。

フロントエンドは Cloudflare Workers Assets にデプロイする。WASMにコンパイルされた静的ファイルを、Cloudflareのエッジで配信する。世界中のユーザーに高速にコンテンツを届けられる…と言いたいところだが、正直なところユーザーは私一人なので、その恩恵はあまり感じていない。

#IaC: Terraform

インフラの構成管理には Terraform を使う。「手動でポチポチ設定したら二度と再現できなくなった」という悲劇を何度か経験してから、IaC(Infrastructure as Code)の重要性を痛感した。

Terraformで構成をコード化しておけば、環境の再構築も、別プロジェクトへの流用も楽になる。そして何より「このリソース、いつ誰が何のために作ったんだっけ?」という疑問にコードが答えてくれる。

#開発環境: Docker Compose + Makefile

ローカル開発環境は Docker Compose で構築する。PostgreSQL、バックエンド、フロントエンドの3つのコンテナを一括で起動・停止できる。

開発で頻繁に使うコマンドは Makefile にまとめておく。make dev で開発環境起動、make fmt でコード整形、make lint でClippyチェック。コマンドを覚える必要がなくなり、開発効率が上がる。


#プロジェクト構成(ワークスペース)

フルスタックRustプロジェクトでは、フロントエンド・バックエンド・共有ライブラリなど、複数のクレートを1つのリポジトリで管理することになる。このとき活躍するのが Cargoワークスペース だ。

#ディレクトリ構成

まずは全体のディレクトリ構成を見てみよう。

text
my-fullstack-app/
├── Cargo.toml              # ワークスペースのルート
├── Cargo.lock              # 依存関係のロックファイル
├── frontend/               # Leptosフロントエンド
│   ├── Cargo.toml
│   └── src/
├── backend/                # axumバックエンド
│   ├── Cargo.toml
│   └── src/
├── migrations/             # SQLxマイグレーション
├── docker-compose.yml
├── Makefile
└── terraform/              # インフラコード

シンプルな構成だ。frontend/backend/ がそれぞれ独立したクレートになっており、ルートの Cargo.toml でワークスペースとして束ねている。

#モジュール構成(mod.rs)

Rust ではディレクトリごとにモジュールを分割する。各ディレクトリには mod.rs を配置して、サブモジュールを公開する。これを忘れると「モジュールが見つからない」エラーに悩まされる。

text
backend/src/
├── main.rs                 # エントリーポイント
├── auth.rs                 # 認証ロジック(JWT生成・検証)
├── handlers/               # HTTPハンドラー
│   ├── mod.rs              # ← これが必要!
│   ├── auth.rs
│   └── users.rs
├── models/                 # データモデル
│   ├── mod.rs
│   ├── user.rs
│   └── error.rs
├── middlewares/            # ミドルウェア
│   ├── mod.rs
│   └── cors.rs
└── extractors/             # カスタムExtractor
    ├── mod.rs
    └── auth.rs

mod.rs の中身はこんな感じ。

rust
// backend/src/handlers/mod.rs
pub mod auth;   // handlers/auth.rs を公開
pub mod users;  // handlers/users.rs を公開

// 共通で使う関数やルーターをここで定義することも多い
use axum::Router;
use std::sync::Arc;
use crate::AppState;

pub fn api_router() -> Router<Arc<AppState>> {
    Router::new()
        .nest("/auth", auth::router())
        .nest("/users", users::router())
}
rust
// backend/src/models/mod.rs
mod user;   // models/user.rs(非公開、このモジュール内でのみ使用)
mod error;  // models/error.rs

// 外部に公開するものだけ re-export
pub use user::{User, UserResponse, CreateUserRequest};
pub use error::AppError;

フロントエンドも同様の構成になる。

text
frontend/src/
├── main.rs
├── app.rs                  # ルートコンポーネント
├── components/             # UIコンポーネント
│   ├── mod.rs
│   ├── home_page.rs
│   ├── login_page.rs
│   └── dashboard_page.rs
├── api/                    # APIクライアント
│   ├── mod.rs
│   ├── client.rs
│   ├── auth.rs
│   └── users.rs
└── context/                # Context(状態管理)
    ├── mod.rs
    └── auth.rs
rust
// frontend/src/components/mod.rs
mod home_page;
mod login_page;
mod dashboard_page;
mod not_found_page;

pub use home_page::HomePage;
pub use login_page::LoginPage;
pub use dashboard_page::DashboardPage;
pub use not_found_page::NotFoundPage;
rust
// frontend/src/api/mod.rs
pub mod client;  // client を公開(他のモジュールから api::client::get などでアクセス)
pub mod auth;
pub mod users;
rust
// frontend/src/context/mod.rs
mod auth;

pub use auth::{AuthContext, AuthProvider, use_auth};

Rust Tips: pub modmod + pub use の使い分け

  • pub mod foo;foo モジュール自体を公開。外部から crate::module::foo::SomeType でアクセス
  • mod foo; pub use foo::SomeType; → モジュールは非公開、型だけ再エクスポート。外部から crate::module::SomeType でアクセス

後者の方がAPIが整理されて使いやすい。内部構造を隠蔽できるメリットもある。

#ルートの Cargo.toml

ワークスペースのルートに置く Cargo.toml はこんな感じになる。

toml
[workspace]
resolver = "3"
members = ["frontend", "backend"]

[workspace.dependencies]
# 共通で使う依存関係
serde = "1.0"
serde_json = "1.0"
thiserror = "2.0"
chrono = "0.4"
uuid = "1.18"

# フロントエンド用
leptos = "0.8"
leptos_router = "0.8"
wasm-bindgen = "0.2"
web-sys = "0.3"
reqwasm = "0.5"  # HTTP クライアント(Leptos では gloo-net より安定)

# バックエンド用
axum = "0.8"
tokio = "1.48"
sqlx = "0.8"
jsonwebtoken = "10.1"
bcrypt = "0.18"
tower-http = "0.6"
tracing = "0.1"
tracing-subscriber = "0.3"

ここで重要なのが resolver = "3"[workspace.dependencies] の2つだ。

#resolver = “3” の意味

resolver は Cargo の依存関係解決アルゴリズムのバージョンを指定する。"3" は Rust 2024 エディションで導入された最新のリゾルバで、以下の改善がある。

  • features の統合が賢くなった: 異なるクレートで同じ依存関係を使っているとき、features の統合がより適切に行われる
  • ビルド依存とランタイム依存の分離: build-dependenciesdependencies で同じクレートを使っていても、features が意図せず統合されない

特にフルスタック構成では、フロントエンド(WASM)とバックエンド(ネイティブ)で同じクレートを使うことがある。resolver = "3" にしておくと、それぞれのターゲットに適した features が選択される。

toml
# 例: reqwest をフロントとバックで別々に使う場合
# バックエンドでは TLS 機能が必要、フロントエンドでは不要
# resolver = "3" なら適切に分離してくれる

#[workspace.dependencies] による依存関係の一元管理

[workspace.dependencies] は Rust 1.64 で導入された機能で、ワークスペース全体で使う依存関係のバージョンを一箇所で管理できる。

各クレートの Cargo.toml では、こんな風に参照する。

toml
# backend/Cargo.toml
[package]
name = "backend"
version = "0.1.0"
edition = "2024"

[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
axum = { workspace = true }
tokio = { workspace = true, features = ["full"] }
sqlx = { workspace = true, features = ["runtime-tokio", "postgres"] }

{ workspace = true } と書くだけで、ルートで定義したバージョンが使われる。features を追加したい場合は features = [...] を併記すればいい。

この方式のメリットは明らかだ。

  1. バージョンの一貫性: フロントエンドとバックエンドで同じクレートの異なるバージョンを使ってしまう事故を防げる
  2. 更新の簡略化: バージョンを上げるとき、ルートの Cargo.toml だけ編集すればいい
  3. Cargo.lock の肥大化防止: 同じクレートの複数バージョンが入り込むことを防げる

#Rust Tips: ワークスペースでの依存関係重複回避

ワークスペースを使っていても、油断すると依存関係が重複することがある。例えば、serde1.0.2001.0.210 の両方が入ってしまうケース。

これを防ぐコツは以下の通り。

  1. 定期的に cargo update を実行する: 依存関係を最新に保つ
  2. cargo tree -d で重複を確認する: 重複している依存関係を一覧表示
  3. [patch] セクションで強制的にバージョンを揃える: どうしても揃わないとき
toml
# どうしても揃わないときの最終手段
[patch.crates-io]
some-crate = { version = "1.0.210" }

ただし、[patch] は最終手段であり、できれば使わずに済ませたい。依存クレートのバージョンを上げるか、issue を報告するのが正攻法だ。


#開発環境の整備

「開発環境の構築に3日かかった」という話をよく聞く。私も例外ではない。むしろ、Rustのコンパイル時間と格闘しながら環境を整えるのは、なかなかの苦行だった。

でも、一度整備してしまえば、あとは make dev 一発で全部立ち上がる。この快適さは、最初の苦労に見合う価値がある。

#Docker Compose 構成

ローカル開発環境は Docker Compose で構築する。以下の3つのサービスを定義する。

  1. postgres: PostgreSQLデータベース
  2. backend: axum APIサーバー
  3. frontend: Leptos開発サーバー
yaml
# docker-compose.yml
services:
  postgres:
    image: postgres:17-alpine
    container_name: myapp-postgres
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "127.0.0.1:5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 10s
      timeout: 5s
      retries: 5

  backend:
    build:
      context: .
      dockerfile: backend/Dockerfile.dev
    container_name: myapp-backend
    environment:
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET}
      REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET}
      RUST_LOG: debug
    ports:
      - "${BACKEND_PORT:-3000}:3000"
    volumes:
      - .:/app
      - backend_cache:/app/backend/target
      - cargo_cache_backend:/app/.cargo
    depends_on:
      postgres:
        condition: service_healthy

  frontend:
    build:
      context: .
      dockerfile: frontend/Dockerfile.dev
    container_name: myapp-frontend
    environment:
      API_BASE_URL: http://localhost:${BACKEND_PORT:-3000}
    ports:
      - "${FRONTEND_PORT:-8080}:8080"
    volumes:
      - .:/app
      - frontend_cache:/app/frontend/target
      - cargo_cache_frontend:/app/.cargo

volumes:
  postgres_data:
  backend_cache:
  frontend_cache:
  cargo_cache_backend:
  cargo_cache_frontend:

いくつかポイントを解説する。

ポイント1: ヘルスチェック

PostgreSQLコンテナに healthcheck を設定している。これにより、バックエンドはデータベースが完全に起動してから接続を試みる。depends_on だけでは「コンテナが起動した」ことしか保証されないので、実際に接続可能になるまで待つにはヘルスチェックが必要だ。

ポイント2: ボリュームによるキャッシュ

backend_cachecargo_cache_backend といったボリュームを使って、ビルドキャッシュを永続化している。これがないと、コンテナを再起動するたびに依存クレートのビルドからやり直しになる。Rustのコンパイル時間は長いので、この設定は必須だ。

ポイント3: ポートを127.0.0.1にバインド

PostgreSQLのポートは 127.0.0.1:5432:5432 としている。これにより、ローカルマシン以外からの接続を防いでいる。開発環境とはいえ、セキュリティには気を配りたい。

#開発用 Dockerfile

バックエンドとフロントエンドそれぞれに、開発用の Dockerfile を用意する。

dockerfile
# backend/Dockerfile.dev
FROM rust:1.92-slim-bookworm

WORKDIR /app

# 必要なツールをインストール
RUN apt-get update && apt-get install -y \
    pkg-config \
    libssl-dev \
    && rm -rf /var/lib/apt/lists/*

# cargo-watch をインストール(ホットリロード用)
RUN cargo install cargo-watch

# 開発用コマンド
CMD ["cargo", "watch", "-x", "run -p backend", "-w", "backend/src"]
dockerfile
# frontend/Dockerfile.dev
FROM rust:1.92-slim-bookworm

WORKDIR /app

# WASM ターゲットを追加
RUN rustup target add wasm32-unknown-unknown

# trunk をインストール
RUN cargo install trunk

# 開発用コマンド
CMD ["trunk", "serve", "--address", "0.0.0.0", "--port", "8080", "-w", "frontend/src"]

#ホットリロード

開発効率を上げるために、ホットリロードを設定する。

バックエンド: cargo-watch を使う。ソースコードの変更を検知して、自動的に再コンパイル・再起動してくれる。

bash
cargo watch -x "run -p backend" -w backend/src

フロントエンド: trunk には標準でホットリロード機能がある。trunk serve コマンドで起動すると、ソースコードの変更時に自動でWASMをリビルドし、ブラウザをリロードしてくれる。

bash
trunk serve --address 0.0.0.0 --port 8080

ただし、Rustのコンパイルには時間がかかるので、「保存したら即座に反映」とはいかない。バックエンドの小さな変更でも数秒、大きな変更だと十数秒待つことになる。この待ち時間は、Rust開発の宿命として受け入れるしかない。

コーヒーを淹れる時間ができると前向きに捉えよう。

#Makefile 設計

開発で頻繁に使うコマンドは Makefile にまとめる。「あのコマンド、どうやって打つんだっけ?」と悩む時間を減らせる。

makefile
.PHONY: dev down logs clean fmt lint migrate-add migrate-run

# ==========================================
# 開発環境
# ==========================================

# 開発環境を起動
dev:
	docker compose --env-file .env.development up --build

# バックグラウンドで起動
dev-bg:
	docker compose --env-file .env.development up -d --build

# 停止
down:
	docker compose down

# ログ表示
logs:
	docker compose logs -f

# バックエンドのログのみ
logs-backend:
	docker compose logs -f backend

# クリーンアップ(ボリュームも削除)
clean:
	docker compose down -v
	docker system prune -f

# ==========================================
# コード品質
# ==========================================

# コード整形
fmt:
	cargo fmt
	leptosfmt ./frontend/src

# Lint チェック
lint:
	cargo clippy --package backend -- -W warnings
	cargo clippy --package frontend --target wasm32-unknown-unknown -- -W warnings

# ==========================================
# データベース
# ==========================================

# マイグレーション作成
migrate-add:
	@read -p "Migration name: " name; \
	sqlx migrate add -r $$name

# マイグレーション実行
migrate-run:
	sqlx migrate run --database-url ${DATABASE_URL}

# マイグレーション状態確認
migrate-info:
	sqlx migrate info --database-url ${DATABASE_URL}

# ==========================================
# セットアップ
# ==========================================

# 初期セットアップ
setup:
	@echo "🔧 環境ファイルをセットアップ中..."
	@if [ ! -f .env.development ]; then \
		cp .env.example .env.development; \
		echo "✅ .env.development を作成しました"; \
	fi
	@echo "⚠️  .env.development を編集してください"

# WASM ターゲット追加
setup-wasm:
	rustup target add wasm32-unknown-unknown

# ヘルプ
help:
	@echo "使用可能なコマンド:"
	@echo "  make dev          - 開発環境を起動"
	@echo "  make down         - 停止"
	@echo "  make logs         - ログ表示"
	@echo "  make clean        - クリーンアップ"
	@echo "  make fmt          - コード整形"
	@echo "  make lint         - Lint チェック"
	@echo "  make migrate-add  - マイグレーション作成"
	@echo "  make migrate-run  - マイグレーション実行"
	@echo "  make setup        - 初期セットアップ"

Makefile のコマンドは覚えやすい名前をつけることが大事だ。make dev で起動、make down で停止、make fmt で整形。直感的に使えるようにしておくと、脳のリソースを本来の開発に集中できる。

#環境変数の管理

環境変数は .env.development.env.staging.env.production のように環境ごとにファイルを分ける。

bash
# .env.example(テンプレート)
POSTGRES_USER=myapp
POSTGRES_PASSWORD=your_password_here
POSTGRES_DB=myapp_development

DATABASE_URL=postgres://myapp:your_password_here@localhost:5432/myapp_development

BACKEND_PORT=3000
FRONTEND_PORT=8080

# JWT シークレット(本番では 64 文字以上のランダム文字列を使用)
ACCESS_TOKEN_SECRET=your_access_token_secret_here_at_least_64_characters_long_random
REFRESH_TOKEN_SECRET=your_refresh_token_secret_here_at_least_64_characters_long_random

.env.example をリポジトリにコミットし、実際の値が入った .env.development などは .gitignore に追加する。シークレットをうっかりコミットしてしまう事故を防ぐためだ。

bash
# .gitignore
.env.development
.env.staging
.env.production

#バックエンド構築(axum)

いよいよ本題のコード実装に入る。まずはバックエンドから。axumは「シンプルなのにパワフル」という、Rustらしいフレームワークだ。

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

バックエンドの Cargo.toml はこんな感じになる。

toml
# backend/Cargo.toml
[package]
name = "backend"
version = "0.1.0"
edition = "2024"

[dependencies]
# Web フレームワーク
axum = { workspace = true }
axum-extra = { workspace = true, features = ["cookie"] }
tokio = { workspace = true, features = ["full"] }
tower = { workspace = true }
tower-http = { workspace = true, features = ["cors", "trace"] }

# データベース
sqlx = { workspace = true, features = ["runtime-tokio", "postgres", "uuid", "chrono"] }

# 認証
jsonwebtoken = { workspace = true, features = ["rust_crypto"] }
bcrypt = { workspace = true }
sha2 = { workspace = true }

# シリアライゼーション
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

# ユーティリティ
chrono = { workspace = true, features = ["serde"] }
uuid = { workspace = true, features = ["v4", "serde"] }
thiserror = { workspace = true }
anyhow = { workspace = true }

# ロギング
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }

# 環境変数
dotenvy = { workspace = true }

features の指定が多くて「うわっ」となるかもしれないが、Rust ではこれが普通だ。必要な機能だけを有効にすることで、コンパイル時間とバイナリサイズを抑えられる。

#main.rs の基本構成

rust
// backend/src/main.rs
use std::net::SocketAddr;
use std::sync::Arc;

use axum::{Router, routing::get};
use sqlx::postgres::PgPoolOptions;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};

mod handlers;
mod models;
mod auth;
mod middlewares;

// アプリケーション全体で共有する状態
#[derive(Clone)]
pub struct AppState {
    pub db: sqlx::PgPool,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // 環境変数を読み込み
    dotenvy::dotenv().ok();

    // ロギングの初期化
    tracing_subscriber::registry()
        .with(tracing_subscriber::EnvFilter::try_from_default_env()
            .unwrap_or_else(|_| "backend=debug,tower_http=debug".into()))
        .with(tracing_subscriber::fmt::layer())
        .init();

    // データベース接続プールを作成
    let database_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL must be set");

    let pool = PgPoolOptions::new()
        .max_connections(5)
        .connect(&database_url)
        .await?;

    // マイグレーションを実行
    sqlx::migrate!("../migrations")
        .run(&pool)
        .await?;

    tracing::info!("✅ Database connected and migrations applied");

    // JWT シークレットの検証(起動時に弱いシークレットを検出)
    auth::validate_jwt_secrets()?;

    // アプリケーション状態を作成
    let state = Arc::new(AppState { db: pool });

    // ルーターを構築
    let app = Router::new()
        // ヘルスチェック
        .route("/health", get(|| async { "OK" }))
        // API ルート
        .nest("/api", handlers::api_router())
        // 状態を注入
        .with_state(state)
        // ミドルウェア
        .layer(middlewares::cors::cors_layer())
        .layer(TraceLayer::new_for_http());

    // サーバーを起動
    let addr = SocketAddr::from(([0, 0, 0, 0], 3000));
    tracing::info!("🚀 Server listening on {}", addr);

    let listener = tokio::net::TcpListener::bind(addr).await?;
    axum::serve(listener, app).await?;

    Ok(())
}

ポイントをいくつか解説する。

AppState: アプリケーション状態の共有

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

AppState はアプリケーション全体で共有する状態を保持する。データベース接続プール、設定値、キャッシュクライアントなど、ハンドラー間で共有したいものはここに入れる。

Clone を derive しているのは、axum が各リクエストごとに状態をクローンして渡すため。PgPool は内部的に Arc を使っているので、クローンしても実際のプールは共有される。

ルーターの構築

rust
let app = Router::new()
    .route("/health", get(|| async { "OK" }))
    .nest("/api", handlers::api_router())
    .with_state(state)
    .layer(middlewares::cors::cors_layer())
    .layer(TraceLayer::new_for_http());

axum のルーターは メソッドチェーン で構築する。.route() でルートを追加し、.nest() でサブルーターをマウントし、.with_state() で状態を注入し、.layer() でミドルウェアを追加する。

この構築方法は非常に柔軟で、大規模なアプリケーションでも整理しやすい。

#ルーティングとハンドラー

API のルーティングは、機能ごとにモジュールを分けて整理する。

rust
// backend/src/handlers/mod.rs
use axum::Router;
use std::sync::Arc;

use crate::AppState;

mod auth;
mod users;

pub fn api_router() -> Router<Arc<AppState>> {
    Router::new()
        .nest("/auth", auth::router())
        .nest("/users", users::router())
}

各モジュールで個別のルーターを定義する。

rust
// backend/src/handlers/auth.rs
use axum::{Router, routing::post, Json, extract::State};
use std::sync::Arc;

use crate::AppState;
use crate::models::{LoginRequest, LoginResponse, RegisterRequest};

pub fn router() -> Router<Arc<AppState>> {
    Router::new()
        .route("/login", post(login))
        .route("/register", post(register))
        .route("/refresh", post(refresh_token))
        .route("/logout", post(logout))
}

async fn login(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, AppError> {
    // ログイン処理...
    // 具体的な実装(パスワード検証、JWT生成、Cookie設定)は
    // 後述の「認証実装」セクションおよび過去記事を参照
    todo!()
}

async fn register(
    State(state): State<Arc<AppState>>,
    Json(payload): Json<RegisterRequest>,
) -> Result<Json<LoginResponse>, AppError> {
    // 登録処理...
    // パスワードハッシュ化は bcrypt::hash() を使用
    // 具体的な実装は後述の「認証実装」セクションを参照
    todo!()
}

Note: ログイン・登録ハンドラーの完全な実装(パスワードハッシュ化、JWT生成、Cookie設定、アカウントロック処理など)については、過去記事「Webアプリケーション開発のJWT認証の実装で学んだこと」に詳しくまとめてある。ここでは axum のルーティングパターンに焦点を当てている。

#Extractor: リクエストからデータを抽出

axum の真骨頂が Extractor だ。ハンドラーの引数に型を書くだけで、リクエストから自動的にデータを抽出してくれる。

rust
async fn login(
    State(state): State<Arc<AppState>>,  // アプリケーション状態
    Json(payload): Json<LoginRequest>,    // JSON ボディ
) -> Result<Json<LoginResponse>, AppError> {
    // ...
}

よく使う Extractor をまとめておく。

Extractor用途
State<T>アプリケーション状態
Json<T>JSON リクエストボディ
Path<T>URL パスパラメータ
Query<T>クエリパラメータ
Extension<T>ミドルウェアから渡された値
TypedHeader<T>特定のHTTPヘッダー

Extractor は 順序が重要 だ。リクエストボディを消費する Json<T> は、他の Extractor より後に書く必要がある。

rust
// ✅ 正しい順序
async fn handler(
    State(state): State<Arc<AppState>>,
    Path(id): Path<Uuid>,
    Json(body): Json<CreateRequest>,
) -> Result<Json<Response>, AppError> { ... }

// ❌ コンパイルエラー(Json が先にあると Path が取れない)
async fn handler(
    Json(body): Json<CreateRequest>,
    Path(id): Path<Uuid>,
) -> Result<Json<Response>, AppError> { ... }

#エラーハンドリング

axum でのエラーハンドリングは、IntoResponse trait を実装したエラー型を定義するのが一般的だ。

rust
// backend/src/models/error.rs
use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};
use serde_json::json;

#[derive(Debug)]
pub enum AppError {
    // 認証エラー
    Unauthorized,
    InvalidCredentials,
    TokenExpired,

    // バリデーションエラー
    ValidationError(String),

    // リソースエラー
    NotFound,
    Conflict(String),

    // 内部エラー
    InternalError(anyhow::Error),
    DatabaseError(sqlx::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::Unauthorized => {
                (StatusCode::UNAUTHORIZED, "認証が必要です")
            }
            AppError::InvalidCredentials => {
                (StatusCode::UNAUTHORIZED, "メールアドレスまたはパスワードが正しくありません")
            }
            AppError::TokenExpired => {
                (StatusCode::UNAUTHORIZED, "トークンの有効期限が切れています")
            }
            AppError::ValidationError(msg) => {
                (StatusCode::BAD_REQUEST, msg.as_str())
            }
            AppError::NotFound => {
                (StatusCode::NOT_FOUND, "リソースが見つかりません")
            }
            AppError::Conflict(msg) => {
                (StatusCode::CONFLICT, msg.as_str())
            }
            AppError::InternalError(e) => {
                tracing::error!("Internal error: {:?}", e);
                (StatusCode::INTERNAL_SERVER_ERROR, "内部エラーが発生しました")
            }
            AppError::DatabaseError(e) => {
                tracing::error!("Database error: {:?}", e);
                (StatusCode::INTERNAL_SERVER_ERROR, "データベースエラーが発生しました")
            }
        };

        let body = Json(json!({
            "error": message,
        }));

        (status, body).into_response()
    }
}

// sqlx::Error から AppError への変換
impl From<sqlx::Error> for AppError {
    fn from(e: sqlx::Error) -> Self {
        AppError::DatabaseError(e)
    }
}

// anyhow::Error から AppError への変換
impl From<anyhow::Error> for AppError {
    fn from(e: anyhow::Error) -> Self {
        AppError::InternalError(e)
    }
}

From trait を実装しておくと、? 演算子でエラーを伝播できる。

rust
async fn get_user(
    State(state): State<Arc<AppState>>,
    Path(id): Path<Uuid>,
) -> Result<Json<User>, AppError> {
    let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
        .bind(id)
        .fetch_optional(&state.db)
        .await?  // sqlx::Error が自動的に AppError に変換される
        .ok_or(AppError::NotFound)?;

    Ok(Json(user))
}

#CORS 設定

フロントエンドとバックエンドが異なるオリジンで動く場合、CORS の設定が必要だ。

rust
// backend/src/middlewares/cors.rs
use axum::http::{header, Method};
use tower_http::cors::{Any, CorsLayer};

pub fn cors_layer() -> CorsLayer {
    CorsLayer::new()
        .allow_origin([
            "http://localhost:8080".parse().unwrap(),
            "https://myapp.example.com".parse().unwrap(),
        ])
        .allow_methods([
            Method::GET,
            Method::POST,
            Method::PUT,
            Method::DELETE,
            Method::PATCH,
        ])
        .allow_headers([
            header::CONTENT_TYPE,
            header::AUTHORIZATION,
            header::ACCEPT,
        ])
        .allow_credentials(true)
}

allow_credentials(true) を設定する場合、allow_origin には Any を使えない。具体的なオリジンを列挙する必要がある。これはセキュリティ上の制約だ。

#Rust Tips: #[derive] マクロの活用

Rust では #[derive] マクロを使って、構造体にトレイトを自動実装できる。API 開発でよく使うパターンをまとめておく。

rust
// リクエスト用(デシリアライズが必要)
#[derive(Debug, Deserialize)]
pub struct CreateUserRequest {
    pub email: String,
    pub password: String,
    pub name: String,
}

// レスポンス用(シリアライズが必要)
#[derive(Debug, Serialize)]
pub struct UserResponse {
    pub id: Uuid,
    pub email: String,
    pub name: String,
    pub created_at: DateTime<Utc>,
}

// データベースから取得用(SQLx の FromRow)
#[derive(Debug, sqlx::FromRow)]
pub struct User {
    pub id: Uuid,
    pub email: String,
    pub password_hash: String,
    pub name: String,
    pub created_at: DateTime<Utc>,
}

// 状態共有用(Clone が必要)
#[derive(Clone)]
pub struct AppState {
    pub db: sqlx::PgPool,
}

「どの derive をつければいいかわからない」という場合は、コンパイラのエラーメッセージを見ればいい。Rust のコンパイラは親切なので、「この trait が必要だよ」と教えてくれる。


#データベース設計(SQLx + PostgreSQL)

データベース周りは、Web アプリケーションの心臓部だ。SQLx は「コンパイル時にクエリを検証する」という、Rust らしいアプローチでデータベースアクセスを安全にしてくれる。

#SQLx のセットアップ

まず SQLx CLI をインストールする。

bash
cargo install sqlx-cli --no-default-features --features postgres

--no-default-features --features postgres を指定することで、PostgreSQL 用の機能だけをインストールする。MySQL や SQLite を使わないなら、これでビルド時間を短縮できる。

DATABASE_URL の設定

SQLx はコンパイル時にデータベースに接続してクエリを検証する。そのため、DATABASE_URL 環境変数が必要だ。

bash
# .env
DATABASE_URL=postgres://myapp:password@localhost:5432/myapp_development

または、.env ファイルを使わずに直接設定してもいい。

bash
export DATABASE_URL="postgres://myapp:password@localhost:5432/myapp_development"

オフラインモード

「CI 環境でデータベースを起動するのは面倒」という場合は、オフラインモードを使う。

bash
# クエリ情報を JSON ファイルに保存
cargo sqlx prepare --workspace

# これで sqlx-data.json が生成される
# このファイルをリポジトリにコミットしておく

sqlx-data.json があれば、コンパイル時にデータベース接続なしでクエリを検証できる。CI でのビルドが楽になる。

#マイグレーション

SQLx のマイグレーションはシンプルだ。

bash
# マイグレーションディレクトリを作成
sqlx migrate add -r create_users_table

-r オプションをつけると、updown の両方のファイルが生成される。

text
migrations/
├── 20260119000000_create_users_table.up.sql
└── 20260119000000_create_users_table.down.sql

スキーマ定義の例

ユーザー管理アプリを想定したスキーマを定義しよう。

sql
-- migrations/20260119000000_create_users_table.up.sql

-- UUID 拡張を有効化
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- ユーザーテーブル
CREATE TABLE users (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email TEXT UNIQUE NOT NULL,
    password_hash TEXT NOT NULL,
    name TEXT NOT NULL,
    failed_login_attempts INTEGER DEFAULT 0,
    locked_until TIMESTAMP WITH TIME ZONE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);

-- リフレッシュトークンテーブル
CREATE TABLE refresh_tokens (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    token_hash VARCHAR(255) NOT NULL,
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
    revoked BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);

-- インデックス
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash);

-- updated_at を自動更新するトリガー
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER users_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at();
sql
-- migrations/20260119000000_create_users_table.down.sql

DROP TRIGGER IF EXISTS users_updated_at ON users;
DROP FUNCTION IF EXISTS update_updated_at();
DROP TABLE IF EXISTS refresh_tokens;
DROP TABLE IF EXISTS users;

マイグレーションの実行

bash
# マイグレーションを実行
sqlx migrate run

# 状態を確認
sqlx migrate info

# ロールバック(1つ前に戻す)
sqlx migrate revert

アプリケーション起動時に自動でマイグレーションを実行することもできる。

rust
// main.rs 内
sqlx::migrate!("../migrations")
    .run(&pool)
    .await?;

ただし、本番環境では手動でマイグレーションを実行する方が安全だ。予期しないスキーマ変更を防げる。

#CRUD 実装

SQLx でクエリを書く方法は2つある。

  1. 関数版 (query_as::<_, T>()): 実行時にクエリを検証
  2. マクロ版 (query_as!()): コンパイル時にクエリを検証

今回は関数版を使う。理由は後述するが、CI/CD でのデータベース接続や sqlx-data.json の管理コストを避けるためだ。

Create(作成)

rust
pub async fn create_user(
    pool: &PgPool,
    email: &str,
    password_hash: &str,
    name: &str,
) -> Result<User, sqlx::Error> {
    let user = sqlx::query_as::<_, User>(
        r#"
        INSERT INTO users (email, password_hash, name)
        VALUES ($1, $2, $3)
        RETURNING id, email, password_hash, name, failed_login_attempts,
                  locked_until, created_at, updated_at
        "#,
    )
    .bind(email)
    .bind(password_hash)
    .bind(name)
    .fetch_one(pool)
    .await?;

    Ok(user)
}

query_as::<_, User>()_ はデータベースの型を推論させている。.bind() でパラメータをバインドする。

Read(取得)

rust
// 単一取得
pub async fn find_user_by_id(
    pool: &PgPool,
    id: Uuid,
) -> Result<Option<User>, sqlx::Error> {
    let user = sqlx::query_as::<_, User>(
        "SELECT * FROM users WHERE id = $1"
    )
    .bind(id)
    .fetch_optional(pool)
    .await?;

    Ok(user)
}

// メールアドレスで検索
pub async fn find_user_by_email(
    pool: &PgPool,
    email: &str,
) -> Result<Option<User>, sqlx::Error> {
    let user = sqlx::query_as::<_, User>(
        "SELECT * FROM users WHERE email = $1"
    )
    .bind(email)
    .fetch_optional(pool)
    .await?;

    Ok(user)
}

// 一覧取得
pub async fn list_users(
    pool: &PgPool,
    limit: i64,
    offset: i64,
) -> Result<Vec<User>, sqlx::Error> {
    let users = sqlx::query_as::<_, User>(
        "SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2"
    )
    .bind(limit)
    .bind(offset)
    .fetch_all(pool)
    .await?;

    Ok(users)
}

fetch_optional は結果が0件の場合に None を返す。fetch_one は結果が0件だとエラーになるので、「存在しないかもしれない」場合は fetch_optional を使う。

Update(更新)

rust
pub async fn update_user_name(
    pool: &PgPool,
    id: Uuid,
    name: &str,
) -> Result<User, sqlx::Error> {
    let user = sqlx::query_as::<_, User>(
        r#"
        UPDATE users
        SET name = $1
        WHERE id = $2
        RETURNING *
        "#,
    )
    .bind(name)
    .bind(id)
    .fetch_one(pool)
    .await?;

    Ok(user)
}

RETURNING * を使うと、更新後のレコードを取得できる。これはPostgreSQL固有の機能だ。

Delete(削除)

rust
pub async fn delete_user(
    pool: &PgPool,
    id: Uuid,
) -> Result<(), sqlx::Error> {
    sqlx::query("DELETE FROM users WHERE id = $1")
        .bind(id)
        .execute(pool)
        .await?;

    Ok(())
}

execute は影響を受けた行数を返す。削除が成功したかどうかを確認したい場合は、戻り値をチェックする。

#トランザクション

複数のクエリをアトミックに実行したい場合は、トランザクションを使う。

rust
pub async fn create_user_with_token(
    pool: &PgPool,
    email: &str,
    password_hash: &str,
    name: &str,
    refresh_token_hash: &str,
    expires_at: DateTime<Utc>,
) -> Result<(User, Uuid), sqlx::Error> {
    // トランザクションを開始
    let mut tx = pool.begin().await?;

    // ユーザーを作成
    let user = sqlx::query_as::<_, User>(
        r#"
        INSERT INTO users (email, password_hash, name)
        VALUES ($1, $2, $3)
        RETURNING *
        "#,
    )
    .bind(email)
    .bind(password_hash)
    .bind(name)
    .fetch_one(&mut *tx)
    .await?;

    // リフレッシュトークンを作成
    let token_id: Uuid = sqlx::query_scalar(
        r#"
        INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
        VALUES ($1, $2, $3)
        RETURNING id
        "#,
    )
    .bind(user.id)
    .bind(refresh_token_hash)
    .bind(expires_at)
    .fetch_one(&mut *tx)
    .await?;

    // コミット
    tx.commit().await?;

    Ok((user, token_id))
}

&mut *tx という書き方が奇妙に見えるかもしれない。これは Transaction から &mut PgConnection への参照を取得している。SQLx のトランザクション API の都合でこうなっている。

#Rust Tips: FromRow derive と関数版クエリ

関数版 query_as::<_, T>() を使う場合、構造体に #[derive(sqlx::FromRow)] が必要だ。

rust
// データベースのカラムに対応した構造体
#[derive(Debug, sqlx::FromRow)]
pub struct User {
    pub id: Uuid,
    pub email: String,
    pub password_hash: String,
    pub name: String,
    pub failed_login_attempts: i32,
    pub locked_until: Option<DateTime<Utc>>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

しかし、「パスワードハッシュはAPIレスポンスに含めたくない」という場合もある。そんなときは、用途別に構造体を分ける。

rust
// API レスポンス用(パスワードハッシュを含まない)
#[derive(Debug, Serialize)]
pub struct UserResponse {
    pub id: Uuid,
    pub email: String,
    pub name: String,
    pub created_at: DateTime<Utc>,
}

impl From<User> for UserResponse {
    fn from(user: User) -> Self {
        Self {
            id: user.id,
            email: user.email,
            name: user.name,
            created_at: user.created_at,
        }
    }
}

こうすることで、「データベースから取得した User を、そのまま API レスポンスとして返してしまう」という事故を防げる。Rust の型システムが、うっかりミスを防いでくれる。

#関数版 vs マクロ版: なぜ関数版を選んだか

SQLx には query_as!() というマクロ版もある。こちらはコンパイル時にデータベースに接続してクエリを検証してくれる。

rust
// マクロ版(コンパイル時検証あり)
let user = sqlx::query_as!(
    User,
    "SELECT * FROM users WHERE id = $1",
    id
)
.fetch_optional(pool)
.await?;

マクロ版のメリットは絶大だ。

  • SQL の typo がコンパイル時に検出される
  • カラム名と構造体フィールドの不一致がコンパイルエラーになる
  • 型の不一致(i32 vs i64 など)も検出される

しかし、今回は関数版を選んだ。理由は以下の通り。

観点マクロ版関数版
コンパイル時にDB接続が必要必要(または sqlx-data.json不要
sqlx-data.json の管理スキーマ変更のたびに再生成・コミット不要
CI/CD の設定DBを用意するか、オフラインモード設定が必要そのままビルド可能
ビルド時間マクロ展開 + DB検証で長め短い
型安全性コンパイル時に保証実行時に検証

個人開発で「さっさと動くものを作りたい」というフェーズでは、関数版の手軽さを優先した。

将来の自分へ

この記事を読み返しているということは、また新しいプロジェクトを始めようとしているのだろう。次こそはマクロ版を使ってみてくれ。

cargo sqlx prepare --workspace でオフラインモード用の JSON を生成し、CI でのビルドも問題なく動くはずだ。コンパイル時に SQL の typo を検出できる安心感は、長期的なプロジェクトでは確実に価値がある。

「設定が面倒」という理由で関数版を選んだ過去の自分を、少しだけ超えてみてほしい。


#認証実装(JWT)

認証は Web アプリケーションの要だ。JWT(JSON Web Token)を使った認証システムを実装する。

Note JWT 認証の詳細(攻撃手法と対策、トークン生成・検証、セキュリティチェックリストなど)については、以前書いた記事「Webアプリケーション開発のJWT認証の実装で学んだこと」を参照してほしい。

この記事では、axum の 認証ミドルウェア(カスタム Extractor) の実装に焦点を当てる。

#Access Token と Refresh Token の二層構造

JWT 認証では、2種類のトークンを使い分ける。

トークン有効期限保存場所用途
Access Token短期間(15分)HttpOnly CookieAPI リクエストの認証
Refresh Token長期間(7日)HttpOnly Cookie + DBAccess Token の更新

Access Token を短命にすることで、漏洩時のリスクを最小化する。Refresh Token は DB にハッシュ化して保存し、トークンローテーションで使い回しを防ぐ。

#axum 認証ミドルウェア(カスタム Extractor)

ここからが本題。axum の Extractor を使って、認証済みユーザーをハンドラーに注入する仕組みを作る。

AuthUser 構造体

まず、認証済みユーザーを表す構造体を定義する。

rust
// backend/src/models/auth.rs
use serde::{Deserialize, Serialize};
use uuid::Uuid;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,        // ユーザーID
    pub email: String,
    pub exp: i64,           // 有効期限
    pub iat: i64,           // 発行時刻
    pub token_type: String, // "access" or "refresh"
    pub aud: String,        // オーディエンス
}

// 認証済みユーザー(Extractor から取得)
#[derive(Debug, Clone)]
pub struct AuthUser {
    pub id: Uuid,
    pub email: String,
}

FromRequestParts の実装

axum の Extractor は FromRequestParts または FromRequest trait を実装することで作成できる。今回は Cookie からトークンを取得するだけなので、FromRequestParts を使う。

rust
// backend/src/extractors/auth.rs
use axum::{
    async_trait,
    extract::FromRequestParts,
    http::{request::Parts, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use axum_extra::extract::CookieJar;
use serde_json::json;
use uuid::Uuid;

use crate::auth::verify_access_token;
use crate::models::AuthUser;

// 認証エラー
pub struct AuthError {
    message: String,
    status: StatusCode,
}

impl IntoResponse for AuthError {
    fn into_response(self) -> Response {
        let body = Json(json!({
            "error": self.message,
        }));
        (self.status, body).into_response()
    }
}

// AuthUser の Extractor 実装
#[async_trait]
impl<S> FromRequestParts<S> for AuthUser
where
    S: Send + Sync,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        // Cookie から Access Token を取得
        let jar = CookieJar::from_request_parts(parts, state)
            .await
            .map_err(|_| AuthError {
                message: "Cookie の取得に失敗しました".to_string(),
                status: StatusCode::INTERNAL_SERVER_ERROR,
            })?;

        let token = jar
            .get("access_token")
            .map(|c| c.value().to_string())
            .ok_or_else(|| AuthError {
                message: "認証が必要です".to_string(),
                status: StatusCode::UNAUTHORIZED,
            })?;

        // トークンを検証
        let claims = verify_access_token(&token).map_err(|e| {
            tracing::warn!("Token verification failed: {:?}", e);
            AuthError {
                message: "トークンが無効です".to_string(),
                status: StatusCode::UNAUTHORIZED,
            }
        })?;

        // ユーザーID をパース
        let user_id = Uuid::parse_str(&claims.sub).map_err(|_| AuthError {
            message: "無効なユーザーIDです".to_string(),
            status: StatusCode::UNAUTHORIZED,
        })?;

        Ok(AuthUser {
            id: user_id,
            email: claims.email,
        })
    }
}

これで、ハンドラーの引数に AuthUser を書くだけで、自動的に認証チェックが行われる。

rust
// 認証が必要なエンドポイント
async fn get_me(auth_user: AuthUser) -> Json<UserResponse> {
    // auth_user には認証済みユーザーの情報が入っている
    Json(UserResponse {
        id: auth_user.id,
        email: auth_user.email,
        // ...
    })
}

// 認証が不要なエンドポイント(AuthUser を引数に含めない)
async fn health() -> &'static str {
    "OK"
}

#オプショナル認証

「ログインしていれば追加情報を表示、していなければ基本情報のみ」というケースでは、Option<AuthUser> を使う。

rust
async fn get_article(
    auth_user: Option<AuthUser>,
    Path(id): Path<Uuid>,
) -> Json<ArticleResponse> {
    // ログインしていれば「いいね済み」フラグを含める
    let is_liked = if let Some(user) = &auth_user {
        check_user_liked(user.id, id).await
    } else {
        false
    };

    // ...
}

Option<T> の Extractor は、内部の Extractor が失敗した場合に None を返す。これは axum の標準機能だ。

#Extractor の合成

複数の Extractor を組み合わせることもできる。例えば、「認証済みで、かつ管理者である」ことを確認する Extractor を作る。

rust
// 管理者ユーザー
#[derive(Debug, Clone)]
pub struct AdminUser {
    pub id: Uuid,
    pub email: String,
}

#[async_trait]
impl<S> FromRequestParts<S> for AdminUser
where
    S: Send + Sync,
{
    type Rejection = AuthError;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
        // まず AuthUser として認証
        let auth_user = AuthUser::from_request_parts(parts, state).await?;

        // 管理者かどうかをチェック(ここでは簡略化)
        // 実際には DB から権限を確認する
        if !is_admin(&auth_user.email) {
            return Err(AuthError {
                message: "管理者権限が必要です".to_string(),
                status: StatusCode::FORBIDDEN,
            });
        }

        Ok(AdminUser {
            id: auth_user.id,
            email: auth_user.email,
        })
    }
}

fn is_admin(email: &str) -> bool {
    // 実際には DB から確認する
    email.ends_with("@admin.example.com")
}

こうすると、管理者専用エンドポイントでは AdminUser を使うだけでいい。

rust
// 管理者専用エンドポイント
async fn admin_dashboard(admin: AdminUser) -> Json<DashboardResponse> {
    // 管理者のみアクセス可能
    // ...
}

#Rust Tips: Extractor の依存関係

Extractor は他の Extractor に依存できる。上の AdminUser の例では、内部で AuthUser を使っている。

この「合成」のパターンは非常に強力で、認証・認可のロジックを再利用可能な形で整理できる。

rust
// 依存関係の例
// AdminUser → AuthUser → CookieJar

// ハンドラーで使うとき
async fn handler(admin: AdminUser) -> Json<Response> {
    // AdminUser の Extractor が呼ばれる
    // → 内部で AuthUser の Extractor が呼ばれる
    // → 内部で CookieJar の Extractor が呼ばれる
    // ...
}

この仕組みのおかげで、ハンドラーのコードは非常にシンプルになる。認証ロジックは Extractor に隠蔽され、ハンドラーはビジネスロジックに集中できる。


#フロントエンド構築(Leptos)

さて、いよいよフロントエンドだ。Leptos は SolidJS に非常に近い設計で、Fine-grained Reactivity による高速な UI 更新と、Rust の型安全性を両立している。

「SolidJS 知ってるなら Leptos も書けるよ」…と言いたいところだが、所有権と格闘する日々が待っていた。でも、一度理解すると、JavaScript よりも堅牢なコードが書ける喜びがある。

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

Cargo.toml

toml
# frontend/Cargo.toml
[package]
name = "frontend"
version = "0.1.0"
edition = "2024"

[dependencies]
# Leptos コア
leptos = { workspace = true, features = ["csr"] }
leptos_router = { workspace = true, features = ["csr"] }

# HTTP クライアント
reqwasm = { workspace = true }

# シリアライゼーション
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

# WASM
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
web-sys = { workspace = true, features = ["Window", "Document", "Storage"] }

# ユーティリティ
chrono = { workspace = true, features = ["serde", "wasmbind"] }
uuid = { workspace = true, features = ["v4", "serde"] }
thiserror = { workspace = true }

# ロギング
log = { workspace = true }
console_log = { workspace = true }
console_error_panic_hook = { workspace = true }

features = ["csr"] で CSR(Client-Side Rendering)モードを有効化している。

trunk.toml

trunk のビルド設定ファイル。

toml
# frontend/trunk.toml
[build]
target = "index.html"
dist = "dist"

[watch]
watch = ["src", "index.html", "style.css"]
ignore = []

[serve]
address = "0.0.0.0"
port = 8080

index.html

html
<!-- frontend/index.html -->
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My App</title>
    <link data-trunk rel="css" href="style.css">
</head>
<body>
    <link data-trunk rel="rust" data-wasm-opt="z">
</body>
</html>

data-trunk 属性で trunk にリソースの処理方法を指示する。data-wasm-opt="z" で WASM のサイズ最適化を有効にする。

#main.rs とルートコンポーネント

rust
// frontend/src/main.rs
use leptos::prelude::*;

mod app;
mod components;
mod api;
mod context;

fn main() {
    // パニック時のスタックトレースをコンソールに出力
    console_error_panic_hook::set_once();

    // ロギングの初期化
    console_log::init_with_level(log::Level::Debug).unwrap();

    // アプリケーションをマウント
    leptos::mount::mount_to_body(app::App);
}

#コンポーネント設計

Leptos のコンポーネントは、#[component] マクロをつけた関数として定義する。

rust
// frontend/src/app.rs
use leptos::prelude::*;
use leptos_router::components::{Router, Routes, Route};
use leptos_router::path;

use crate::components::{HomePage, LoginPage, DashboardPage, NotFoundPage};
use crate::context::AuthProvider;

#[component]
pub fn App() -> impl IntoView {
    view! {
        <AuthProvider>
            <Router>
                <main class="container">
                    <Routes fallback=|| view! { <NotFoundPage /> }>
                        <Route path=path!("/") view=HomePage />
                        <Route path=path!("/login") view=LoginPage />
                        <Route path=path!("/dashboard") view=DashboardPage />
                    </Routes>
                </main>
            </Router>
        </AuthProvider>
    }
}

view! マクロは JSX に似た構文で UI を記述できる。ただし、これは Rust のマクロなので、構文エラーは Rust コンパイラが検出してくれる。

#Signal による状態管理

Leptos の状態管理の中心は Signal だ。Signal は「リアクティブな値」で、値が変更されると自動的に UI が更新される。

rust
#[component]
fn Counter() -> impl IntoView {
    // Signal を作成(初期値は 0)
    let count = RwSignal::new(0);

    view! {
        <div>
            <p>"Count: " {move || count.get()}</p>
            <button on:click=move |_| count.set(count.get() + 1)>
                "Increment"
            </button>
        </div>
    }
}

{move || count.get()} という書き方に注目。Signal の値を表示するには、クロージャでラップする必要がある。これにより、Leptos はどこで Signal が使われているかを追跡し、自動的に再レンダリングできる。

Signal の種類

種類用途
RwSignal<T>読み書き両方可能な Signal
ReadSignal<T>読み取り専用の Signal
WriteSignal<T>書き込み専用の Signal
Memo<T>依存する Signal から計算された値(キャッシュ付き)
rust
// Memo の例:count が変わったときだけ再計算
let count = RwSignal::new(0);
let doubled = Memo::new(move |_| count.get() * 2);

view! {
    <p>"Count: " {move || count.get()}</p>
    <p>"Doubled: " {move || doubled.get()}</p>
}

#フォームの実装

ログインフォームの例を見てみよう。

rust
// frontend/src/components/login_page.rs
use leptos::prelude::*;
use leptos_router::hooks::use_navigate;

use crate::api::auth::login;
use crate::context::use_auth;

#[component]
pub fn LoginPage() -> impl IntoView {
    let auth = use_auth();
    let navigate = use_navigate();

    // フォームの状態
    let email = RwSignal::new(String::new());
    let password = RwSignal::new(String::new());
    let error = RwSignal::new(None::<String>);
    let loading = RwSignal::new(false);

    // ログイン処理
    let on_submit = move |ev: web_sys::SubmitEvent| {
        ev.prevent_default();

        // ローディング状態に
        loading.set(true);
        error.set(None);

        // 値を取得
        let email_value = email.get();
        let password_value = password.get();

        // 非同期でログイン API を呼び出し
        spawn_local(async move {
            match login(&email_value, &password_value).await {
                Ok(user) => {
                    // 認証状態を更新
                    auth.login(user);
                    // ダッシュボードへリダイレクト
                    navigate("/dashboard", Default::default());
                }
                Err(e) => {
                    error.set(Some(e.to_string()));
                }
            }
            loading.set(false);
        });
    };

    view! {
        <div class="login-page">
            <h1>"ログイン"</h1>

            // エラーメッセージ
            {move || error.get().map(|e| view! {
                <div class="error">{e}</div>
            })}

            <form on:submit=on_submit>
                <div class="form-group">
                    <label for="email">"メールアドレス"</label>
                    <input
                        type="email"
                        id="email"
                        prop:value=move || email.get()
                        on:input=move |ev| email.set(event_target_value(&ev))
                        required
                    />
                </div>

                <div class="form-group">
                    <label for="password">"パスワード"</label>
                    <input
                        type="password"
                        id="password"
                        prop:value=move || password.get()
                        on:input=move |ev| password.set(event_target_value(&ev))
                        required
                    />
                </div>

                <button type="submit" disabled=move || loading.get()>
                    {move || if loading.get() { "ログイン中..." } else { "ログイン" }}
                </button>
            </form>
        </div>
    }
}

いくつかポイントを解説する。

prop:valueon:input

rust
<input
    prop:value=move || email.get()
    on:input=move |ev| email.set(event_target_value(&ev))
/>
  • prop:value: DOM プロパティとして値をバインド
  • on:input: 入力イベントで Signal を更新

これで「双方向バインディング」を実現している。React の useState + onChange に相当するが、重要な違いがある。Reactでは状態が変わるとコンポーネント関数全体が再実行されるが、Leptosでは move || email.get() のクロージャだけが再評価され、その結果を使っているDOMノードだけが更新される。これがFine-grained Reactivityの威力だ。

spawn_local による非同期処理

rust
spawn_local(async move {
    match login(&email_value, &password_value).await {
        // ...
    }
});

WASM 環境では tokio::spawn は使えない。代わりに wasm-bindgen-futuresspawn_local を使う。

#Context による状態共有

コンポーネント間で状態を共有するには、Context を使う。React や SolidJS の Context API と同じ概念だ。

rust
// frontend/src/context/auth.rs
use leptos::prelude::*;

#[derive(Clone, Debug)]
pub struct User {
    pub id: String,
    pub email: String,
    pub name: String,
}

#[derive(Clone)]
pub struct AuthContext {
    pub user: RwSignal<Option<User>>,
}

impl AuthContext {
    pub fn new() -> Self {
        Self {
            user: RwSignal::new(None),
        }
    }

    pub fn login(&self, user: User) {
        self.user.set(Some(user));
    }

    pub fn logout(&self) {
        self.user.set(None);
    }

    pub fn is_authenticated(&self) -> bool {
        self.user.get().is_some()
    }
}

// Context を提供するコンポーネント
#[component]
pub fn AuthProvider(children: Children) -> impl IntoView {
    let auth = AuthContext::new();
    provide_context(auth);

    children()
}

// Context を使用するフック
pub fn use_auth() -> AuthContext {
    expect_context::<AuthContext>()
}

使い方はシンプル。

rust
// ログイン状態に応じて表示を切り替え
#[component]
fn Header() -> impl IntoView {
    let auth = use_auth();

    view! {
        <header>
            {move || {
                if auth.is_authenticated() {
                    view! {
                        <span>"Welcome, " {auth.user.get().map(|u| u.name)}</span>
                        <button on:click=move |_| auth.logout()>"Logout"</button>
                    }.into_any()
                } else {
                    view! {
                        <a href="/login">"Login"</a>
                    }.into_any()
                }
            }}
        </header>
    }
}

#Rust Tips: view! マクロ内での所有権

Leptos を書いていて最も苦労するのが、view! マクロ内での所有権問題だ。

rust
// ❌ これはコンパイルエラー
let name = String::from("Alice");
view! {
    <p>{name}</p>  // name がムーブされる
    <p>{name}</p>  // 2回目で所有権エラー
}

解決策はいくつかある。

1. Clone を使う

rust
let name = String::from("Alice");
view! {
    <p>{name.clone()}</p>
    <p>{name}</p>  // 最後は clone 不要
}

2. Signal を使う

rust
let name = RwSignal::new(String::from("Alice"));
view! {
    <p>{move || name.get()}</p>
    <p>{move || name.get()}</p>  // Signal は何度でも get できる
}

3. StoredValue を使う

頻繁に変更しない値には StoredValue が便利。

rust
let config = StoredValue::new(Config { ... });
view! {
    <p>{config.get_value().name}</p>
    <p>{config.get_value().description}</p>
}

StoredValue は Signal と違い、変更を追跡しない。そのため、値が変わっても UI は更新されない。「一度設定したら変わらない値」に適している。


#フロントエンドとバックエンドの連携

フロントエンドとバックエンドを繋ぐ部分だ。両方 Rust で書いているので、型の共有ができる…と言いたいところだが、実際には WASM とネイティブで微妙に事情が違って、そう簡単にはいかない。

#API クライアントの設計

フロントエンドから API を呼び出すクライアントモジュールを作る。

rust
// frontend/src/api/client.rs
use reqwasm::http::Request;
use serde::{de::DeserializeOwned, Serialize};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum ApiError {
    #[error("Network error: {0}")]
    NetworkError(String),

    #[error("API error: {0}")]
    ApiError(String),

    #[error("Parse error: {0}")]
    ParseError(String),
}

fn api_base_url() -> String {
    // 環境変数またはデフォルト値
    option_env!("API_BASE_URL")
        .unwrap_or("http://localhost:3000")
        .to_string()
}

pub async fn get<T: DeserializeOwned>(path: &str) -> Result<T, ApiError> {
    let url = format!("{}/api{}", api_base_url(), path);

    let response = Request::get(&url)
        .credentials(reqwasm::http::RequestCredentials::Include)  // Cookie を送信
        .send()
        .await
        .map_err(|e| ApiError::NetworkError(e.to_string()))?;

    if !response.ok() {
        let error_text = response.text().await.unwrap_or_default();
        return Err(ApiError::ApiError(error_text));
    }

    response
        .json()
        .await
        .map_err(|e| ApiError::ParseError(e.to_string()))
}

pub async fn post<T: DeserializeOwned, B: Serialize>(
    path: &str,
    body: &B,
) -> Result<T, ApiError> {
    let url = format!("{}/api{}", api_base_url(), path);

    let json_body = serde_json::to_string(body)
        .map_err(|e| ApiError::ParseError(e.to_string()))?;

    let response = Request::post(&url)
        .credentials(reqwasm::http::RequestCredentials::Include)
        .header("Content-Type", "application/json")
        .body(json_body)
        .send()
        .await
        .map_err(|e| ApiError::NetworkError(e.to_string()))?;

    if !response.ok() {
        let error_text = response.text().await.unwrap_or_default();
        return Err(ApiError::ApiError(error_text));
    }

    response
        .json()
        .await
        .map_err(|e| ApiError::ParseError(e.to_string()))
}

credentials(reqwasm::http::RequestCredentials::Include) が重要だ。これにより、Cookie(Access Token)がリクエストに含まれる。

Note: WASM用HTTPクライアントには gloo-net という選択肢もあるが、Leptos との組み合わせで問題が発生することがあったため、reqwasm を使用している。

#API モジュールの整理

機能ごとに API クライアントを分ける。

rust
// frontend/src/api/auth.rs
use serde::{Deserialize, Serialize};

use super::client::{post, ApiError};

#[derive(Debug, Serialize)]
pub struct LoginRequest {
    pub email: String,
    pub password: String,
}

#[derive(Debug, Deserialize)]
pub struct LoginResponse {
    pub user: UserResponse,
}

#[derive(Debug, Clone, Deserialize)]
pub struct UserResponse {
    pub id: String,
    pub email: String,
    pub name: String,
}

pub async fn login(email: &str, password: &str) -> Result<UserResponse, ApiError> {
    let request = LoginRequest {
        email: email.to_string(),
        password: password.to_string(),
    };

    let response: LoginResponse = post("/auth/login", &request).await?;
    Ok(response.user)
}

pub async fn logout() -> Result<(), ApiError> {
    post::<(), _>("/auth/logout", &()).await?;
    Ok(())
}
rust
// frontend/src/api/users.rs
use serde::Deserialize;

use super::client::{get, ApiError};

#[derive(Debug, Clone, Deserialize)]
pub struct User {
    pub id: String,
    pub email: String,
    pub name: String,
    pub created_at: String,
}

pub async fn get_me() -> Result<User, ApiError> {
    get("/users/me").await
}

pub async fn list_users() -> Result<Vec<User>, ApiError> {
    get("/users").await
}

#型の共有(理想と現実)

「フロントエンドとバックエンドで同じ型を使いたい」というのは自然な欲求だ。共有クレートを作れば実現できる…と思いきや、いくつかの壁がある。

問題1: ターゲットの違い

バックエンドはネイティブ、フロントエンドは WASM。同じクレートを使うには、両方のターゲットで動く必要がある。

toml
# shared/Cargo.toml
[dependencies]
serde = { version = "1.0", features = ["derive"] }

# バックエンド専用の依存関係は feature flag で分ける
[features]
backend = ["sqlx"]

[dependencies.sqlx]
version = "0.8"
optional = true

問題2: 実用上のメリット

共有クレートを作っても、実際には「API レスポンスの型」と「DB モデルの型」は異なることが多い。パスワードハッシュは API に含めないし、created_at の形式も違ったりする。

結局のところ、フロントエンドとバックエンドでそれぞれ型を定義する方が、メンテナンスしやすいことが多い。

#エラーハンドリングの統一

API エラーの形式を統一しておくと、フロントエンドでの処理が楽になる。

バックエンドでは統一されたエラーレスポンスを返す。

rust
// バックエンド: 統一されたエラーレスポンス
#[derive(Serialize)]
struct ErrorResponse {
    error: String,
    code: Option<String>,
}

フロントエンドでは、このエラーをパースして表示する。

rust
// frontend/src/api/client.rs
#[derive(Debug, Deserialize)]
struct ErrorBody {
    error: String,
    code: Option<String>,
}

pub async fn post<T: DeserializeOwned, B: Serialize>(
    path: &str,
    body: &B,
) -> Result<T, ApiError> {
    // ... リクエスト送信 ...

    if !response.ok() {
        // エラーレスポンスをパース
        let error_body: ErrorBody = response
            .json()
            .await
            .unwrap_or(ErrorBody {
                error: "Unknown error".to_string(),
                code: None,
            });

        return Err(ApiError::ApiError(error_body.error));
    }

    // ...
}

#認証状態の初期化

ページをリロードしたときに、認証状態を復元する処理も必要だ。

rust
// frontend/src/context/auth.rs
#[component]
pub fn AuthProvider(children: Children) -> impl IntoView {
    let auth = AuthContext::new();
    provide_context(auth.clone());

    // 初期化時に認証状態を確認
    spawn_local({
        let auth = auth.clone();
        async move {
            // /users/me を呼んで認証状態を確認
            match crate::api::users::get_me().await {
                Ok(user) => {
                    auth.user.set(Some(User {
                        id: user.id,
                        email: user.email,
                        name: user.name,
                    }));
                }
                Err(_) => {
                    // 未認証またはトークン切れ
                    auth.user.set(None);
                }
            }
        }
    });

    children()
}

Cookie に保存されたトークンが有効なら、ページリロード後も認証状態が維持される。


#10. インフラ構築(Terraform + GCP)

「フルスタックRust」の旅もいよいよ終盤。ローカルで動くアプリを、実際にインターネット上に公開する。

Infrastructure as Code(IaC)として Terraform を使い、GCP(Google Cloud Platform)にデプロイする構成を紹介する。

#Terraformとは?基礎知識

Rustを書くのは楽しいが、インフラの設定となると途端にわからなくなる。そんな私のために、Terraformの基礎から解説しておく。

Terraformの役割

Terraformは「インフラをコードで定義するツール」だ。GCPのWebコンソールでポチポチ設定する代わりに、設定ファイル(.tf)を書いてコマンドを実行すると、クラウド上にリソースが作られる。

bash
# Terraformの基本コマンド
terraform init      # 初期化(プロバイダーのダウンロード)
terraform plan      # 変更内容のプレビュー(実際には何も変更しない)
terraform apply     # 変更を適用(リソースを作成・更新)
terraform destroy   # すべてのリソースを削除

HCL(HashiCorp Configuration Language)の文法

Terraformの設定ファイルは HCL という独自の言語で書く。Rustとは全く違う文法だが、単純なので覚えることは少ない。

hcl
# コメントは # で始まる

# リソース定義: resource "リソースタイプ" "リソース名" { ... }
resource "google_sql_database_instance" "main" {
  name             = "my-database"      # 文字列は "..." で囲む
  database_version = "POSTGRES_17"
  region           = var.region         # var.xxx で変数を参照

  settings {                            # ネストしたブロック
    tier = "db-f1-micro"
  }
}

# 変数定義: variable "変数名" { ... }
variable "region" {
  description = "デプロイリージョン"    # 説明文
  type        = string                  # 型(string, number, bool, list, map)
  default     = "asia-northeast1"       # デフォルト値
}

# 出力値: output "出力名" { ... }
output "database_connection" {
  value = google_sql_database_instance.main.connection_name
  #       ↑ 他のリソースの属性を参照
}

よく使う構文:

構文意味
var.xxx変数の参照var.region
resource.name.attrリソース属性の参照google_sql_database_instance.main.connection_name
${...}文字列内での式展開"${var.project_id}-db"
for_each = toset([...])同じリソースを複数作成APIを複数有効化するときなど
depends_on = [...]依存関係の明示作成順序を制御
count = NN個のリソースを作成条件付きで count = var.enabled ? 1 : 0

リソースの命名規則

hcl
resource "google_sql_database_instance" "main" {
#        ↑ プロバイダー_サービス_リソース種類   ↑ ローカル名(Terraform内で参照する名前)
  name = "my-project-db-production"
  #      ↑ 実際にGCP上に作られるリソースの名前
}
  • リソースタイプ: google_ で始まるのはGCPのリソース。aws_ ならAWS
  • ローカル名: Terraform内でこのリソースを参照するときの名前(google_sql_database_instance.main
  • name属性: GCPコンソールに表示される実際のリソース名

#10.1 Terraform基本構成

まず、Terraformプロジェクトの基本構成から。

text
terraform/
├── main.tf           # メインの設定
├── variables.tf      # 変数定義
├── outputs.tf        # 出力値
├── cloud_sql.tf      # Cloud SQL設定
├── cloud_run.tf      # Cloud Run設定
├── artifact_registry.tf  # Artifact Registry設定
└── terraform.tfvars  # 変数の値(gitignore推奨)

main.tf

hcl
# terraform/main.tf

# ============================================
# Terraform本体の設定
# ============================================
terraform {
  # Terraformのバージョン制約
  # ">= 1.5.0" は「1.5.0以上」を意味する
  required_version = ">= 1.5.0"

  # 使用するプロバイダー(クラウドサービスとの接続プラグイン)
  required_providers {
    google = {
      source  = "hashicorp/google"  # HashiCorp公式のGCPプロバイダー
      version = "~> 5.0"            # "~> 5.0" は 5.x系の最新を使う(6.0未満)
    }
  }

  # 状態ファイル(terraform.tfstate)の保存先
  # ローカルに保存すると他のPCで作業できないので、GCSに保存する
  backend "gcs" {
    bucket = "my-app-terraform-state"  # 事前にGCSバケットを作成しておく
    prefix = "terraform/state"          # バケット内のパス
  }
  # 注意: このバケットは terraform init 前に手動で作成する必要がある
  # gsutil mb -l asia-northeast1 gs://my-app-terraform-state
}

# ============================================
# GCPプロバイダーの設定
# ============================================
provider "google" {
  project = var.project_id  # 操作対象のGCPプロジェクトID
  region  = var.region      # デフォルトリージョン
  # 認証は gcloud auth application-default login で行う
  # または環境変数 GOOGLE_APPLICATION_CREDENTIALS でサービスアカウントキーを指定
}

# ============================================
# GCP APIの有効化
# ============================================
# GCPでは各サービスを使う前にAPIを有効化する必要がある
# コンソールでポチポチする代わりに、ここで宣言的に管理できる
resource "google_project_service" "services" {
  # for_each で配列の各要素に対してリソースを作成
  # toset() はリストをセット(重複なし集合)に変換
  for_each = toset([
    "run.googleapis.com",              # Cloud Run
    "sqladmin.googleapis.com",         # Cloud SQL Admin
    "artifactregistry.googleapis.com", # Artifact Registry(Dockerイメージ保存)
    "secretmanager.googleapis.com",    # Secret Manager(シークレット管理)
    "compute.googleapis.com",          # Compute Engine(VPCに必要)
    "servicenetworking.googleapis.com", # Service Networking(プライベート接続に必要)
  ])

  project = var.project_id
  service = each.key  # for_each のときは each.key で現在の要素を参照

  # terraform destroy してもAPIを無効化しない
  # 無効化すると既存のリソースに影響する可能性があるため
  disable_on_destroy = false
}

main.tf の解説:

ブロック役割
terraform { }Terraform本体の設定。バージョン制約、プロバイダー、状態ファイルの保存先を定義
provider "google" { }GCPへの接続設定。どのプロジェクト・リージョンを操作するか
resource "google_project_service" { }GCP APIを有効化。Rustで言えばCargo.tomlの依存関係のようなもの

variables.tf

変数を別ファイルに分離することで、環境ごとの値の切り替えが楽になる。

hcl
# terraform/variables.tf

# ============================================
# 必須変数(デフォルト値なし → terraform apply 時に入力必須)
# ============================================
variable "project_id" {
  description = "GCPプロジェクトID(例: my-project-123456)"
  type        = string
  # default がないので必須。terraform.tfvars か -var オプションで指定する
}

variable "environment" {
  description = "環境名(staging/production)"
  type        = string

  # バリデーション: 指定された値以外はエラー
  validation {
    condition     = contains(["staging", "production"], var.environment)
    error_message = "環境名は staging または production のみ"
  }
}

# ============================================
# オプション変数(デフォルト値あり)
# ============================================
variable "region" {
  description = "デプロイリージョン"
  type        = string
  default     = "asia-northeast1"  # 東京リージョン
  # 他の選択肢:
  # - asia-northeast2: 大阪
  # - us-central1: アイオワ(安い)
  # - europe-west1: ベルギー
}

# --------------------------------------------
# データベース設定
# --------------------------------------------
variable "db_tier" {
  description = "Cloud SQLのマシンタイプ"
  type        = string
  default     = "db-f1-micro"
  # 選択肢と目安:
  # - db-f1-micro: 共有CPU, 0.6GB RAM(無料枠、開発用)
  # - db-g1-small: 共有CPU, 1.7GB RAM(小規模本番)
  # - db-custom-1-3840: 1 vCPU, 3.75GB RAM(中規模本番)
  # - db-custom-2-7680: 2 vCPU, 7.5GB RAM(大規模本番)
}

variable "db_name" {
  description = "データベース名"
  type        = string
  default     = "app_db"
}

# --------------------------------------------
# Cloud Run設定
# --------------------------------------------
variable "backend_cpu" {
  description = "バックエンドのCPU数"
  type        = string
  default     = "1"
  # 選択肢: "1", "2", "4", "8"
  # Rustバイナリは軽いので1で十分なことが多い
}

variable "backend_memory" {
  description = "バックエンドのメモリ"
  type        = string
  default     = "512Mi"
  # 選択肢: "128Mi", "256Mi", "512Mi", "1Gi", "2Gi", "4Gi", "8Gi"
  # Rustは省メモリなので512Miで十分動く。Node.jsなら1Gi欲しい
}

terraform.tfvars(変数の値を設定)

hcl
# terraform/terraform.tfvars
# このファイルは .gitignore に追加すること!
# シークレットを含む可能性があるため

project_id  = "my-project-123456"
environment = "production"
region      = "asia-northeast1"

# 本番用の設定(開発時はデフォルト値を使う)
db_tier        = "db-custom-1-3840"
backend_memory = "1Gi"

変数の渡し方(3つの方法)

bash
# 方法1: terraform.tfvars を使う(最も一般的)
# terraform.tfvars が自動で読み込まれる
terraform apply

# 方法2: 別名のファイルを指定
terraform apply -var-file="production.tfvars"

# 方法3: コマンドラインで直接指定
terraform apply -var="project_id=my-project-123456" -var="environment=staging"

# 方法4: 環境変数(TF_VAR_ プレフィックス)
export TF_VAR_project_id="my-project-123456"
terraform apply

#10.2 VPC(Virtual Private Cloud)ネットワーク

Cloud SQLにプライベート接続するには、VPCが必要だ。VPCはクラウド上の仮想ネットワークで、リソース間の通信を制御する。

hcl
# terraform/network.tf

# VPCネットワーク
resource "google_compute_network" "vpc" {
  name                    = "${var.project_id}-vpc"
  auto_create_subnetworks = false  # サブネットは手動で作成

  depends_on = [google_project_service.services]
}

# サブネット(リージョンごとに作成)
resource "google_compute_subnetwork" "subnet" {
  name          = "${var.project_id}-subnet"
  ip_cidr_range = "10.0.0.0/24"  # このサブネットで使うIPアドレス範囲
  region        = var.region
  network       = google_compute_network.vpc.id
}

# プライベートIP範囲(Cloud SQLへのプライベート接続用)
resource "google_compute_global_address" "private_ip_range" {
  name          = "private-ip-range"
  purpose       = "VPC_PEERING"
  address_type  = "INTERNAL"
  prefix_length = 16            # /16 = 65,536個のIPアドレス
  network       = google_compute_network.vpc.id
}

# サービスネットワーキング接続
# これにより、Cloud SQLがVPC内のプライベートIPを持てる
resource "google_service_networking_connection" "private_vpc_connection" {
  network                 = google_compute_network.vpc.id
  service                 = "servicenetworking.googleapis.com"
  reserved_peering_ranges = [google_compute_global_address.private_ip_range.name]

  depends_on = [google_project_service.services]
}

VPCの解説:

リソース役割
google_compute_networkVPC本体。リソース間の通信経路を定義
google_compute_subnetworkサブネット。VPC内のIPアドレス範囲を区切る
google_compute_global_addressCloud SQLに割り当てるプライベートIP範囲を予約
google_service_networking_connectionGCPのマネージドサービス(Cloud SQL)とVPCを接続

なぜVPCが必要か?セキュリティだ。Cloud SQLにパブリックIPを持たせると、インターネット経由で攻撃を受ける可能性がある。プライベートIP接続なら、VPC内からしかアクセスできない。

#10.3 Cloud SQL (PostgreSQL)

PostgreSQLのマネージドサービスとして、Cloud SQLを使う。

hcl
# terraform/cloud_sql.tf

# ============================================
# Cloud SQLインスタンス(PostgreSQLサーバー本体)
# ============================================
resource "google_sql_database_instance" "main" {
  # GCP上のリソース名。変更すると再作成になるので注意
  name             = "${var.project_id}-db-${var.environment}"
  database_version = "POSTGRES_17"  # PostgreSQLのバージョン
  region           = var.region

  settings {
    # マシンタイプ(CPU・メモリ)
    tier = var.db_tier

    # ディスク設定
    disk_size       = 10    # 初期サイズ(GB)
    disk_autoresize = true  # 容量不足時に自動拡張
    disk_type       = "PD_SSD"  # SSD(高速)or PD_HDD(安い)

    # バックアップ設定(本番では必須)
    backup_configuration {
      enabled                        = true   # 自動バックアップを有効化
      point_in_time_recovery_enabled = true   # ポイントインタイムリカバリ(任意の時点に復元可能)
      start_time                     = "03:00"  # バックアップ開始時刻(UTC)

      backup_retention_settings {
        retained_backups = 7       # 保持するバックアップ数
        retention_unit   = "COUNT" # COUNTまたはDAYS
      }
    }

    # ネットワーク設定(セキュリティ重要)
    ip_configuration {
      ipv4_enabled    = false  # パブリックIPを無効化(インターネットから直接アクセス不可)
      private_network = google_compute_network.vpc.id  # VPC経由でのみアクセス可能

      # パブリックIPを有効にする場合は authorized_networks で許可IPを制限すること
      # ipv4_enabled = true
      # authorized_networks {
      #   name  = "office"
      #   value = "203.0.113.0/24"  # 許可するIPアドレス範囲
      # }
    }

    # メンテナンスウィンドウ(自動アップデートの実行時間帯)
    maintenance_window {
      day          = 7  # 1=月曜...7=日曜
      hour         = 3  # UTC 03:00 = JST 12:00(昼休み中に実行)
      update_track = "stable"  # stable or canary(早期アップデート)
    }

    # PostgreSQL設定フラグ
    database_flags {
      name  = "max_connections"
      value = "100"  # 最大同時接続数
    }
  }

  # 削除保護(本番環境では true にして誤削除を防ぐ)
  # terraform destroy で削除したい場合は、先に false に変更してから apply
  deletion_protection = var.environment == "production"

  # VPC接続が完了してから作成
  depends_on = [
    google_project_service.services,
    google_service_networking_connection.private_vpc_connection,
  ]
}

# ============================================
# データベース(PostgreSQLインスタンス内のDB)
# ============================================
resource "google_sql_database" "app" {
  name     = var.db_name  # データベース名(app_db など)
  instance = google_sql_database_instance.main.name
  # 1つのCloud SQLインスタンスに複数のデータベースを作成可能
}

# ============================================
# データベースユーザー
# ============================================
resource "google_sql_user" "app" {
  name     = "app_user"
  instance = google_sql_database_instance.main.name
  password = random_password.db_password.result
  # 注意: postgres ユーザーは使わない(スーパーユーザー権限を持つため危険)
}

# ============================================
# ランダムなパスワード生成
# ============================================
# Terraformの random プロバイダーを使用
# terraform.tfvars に書くのではなく、Terraformに生成させることでセキュリティ向上
resource "random_password" "db_password" {
  length  = 32
  special = false  # Cloud SQLのパスワードに特殊文字を含めると問題が起きることがある
}

# ============================================
# Secret Manager(シークレット管理)
# ============================================
# パスワードを Secret Manager に保存し、Cloud Run から参照する
# 環境変数に直接書くより安全

resource "google_secret_manager_secret" "db_password" {
  secret_id = "db-password-${var.environment}"

  replication {
    auto {}  # Googleに任せる(リージョン間で自動レプリケーション)
  }
}

# シークレットのバージョン(実際の値を保存)
resource "google_secret_manager_secret_version" "db_password" {
  secret      = google_secret_manager_secret.db_password.id
  secret_data = random_password.db_password.result
  # 更新するには新しいバージョンを作成する(イミュータブル)
}

Cloud SQL 設定の解説:

設定意味推奨値
deletion_protection誤削除防止。本番は必ず trueproduction: true, staging: false
ipv4_enabled = falseパブリックIP無効化。セキュリティ向上false(VPC経由接続)
disk_autoresizeディスク自動拡張true(容量不足を防ぐ)
point_in_time_recovery_enabled任意の時点に復元可能true(本番必須)

#10.4 Secret Manager(JWTシークレット)

JWT認証で使う Access Token / Refresh Token のシークレットも Secret Manager で管理する。

hcl
# terraform/secrets.tf

# ============================================
# JWT Access Token シークレット
# ============================================
resource "random_password" "access_token_secret" {
  length  = 64
  special = true
}

resource "google_secret_manager_secret" "access_token_secret" {
  secret_id = "access-token-secret-${var.environment}"

  replication {
    auto {}
  }
}

resource "google_secret_manager_secret_version" "access_token_secret" {
  secret      = google_secret_manager_secret.access_token_secret.id
  secret_data = random_password.access_token_secret.result
}

# ============================================
# JWT Refresh Token シークレット
# ============================================
resource "random_password" "refresh_token_secret" {
  length  = 64
  special = true
}

resource "google_secret_manager_secret" "refresh_token_secret" {
  secret_id = "refresh-token-secret-${var.environment}"

  replication {
    auto {}
  }
}

resource "google_secret_manager_secret_version" "refresh_token_secret" {
  secret      = google_secret_manager_secret.refresh_token_secret.id
  secret_data = random_password.refresh_token_secret.result
}

Note: シークレットは terraform apply 時に自動生成される。値を確認したい場合は:

bash
# GCPコンソールでも確認できるが、CLIでも可能
gcloud secrets versions access latest --secret="access-token-secret-production"

#10.5 Cloud Run

バックエンドのコンテナをCloud Runにデプロイする。

Cloud Runは「コンテナを動かすサーバーレスサービス」だ。Dockerイメージをプッシュすれば、GCPが自動的にサーバーを立ち上げてくれる。リクエストがないときはインスタンス数を0にできるので、個人開発のコスト削減に最適。

Artifact Registry(コンテナレジストリ)

Dockerイメージを保存する場所。DockerHubのGCP版だと思えばいい。

hcl
# terraform/artifact_registry.tf

# Dockerイメージの保存先
resource "google_artifact_registry_repository" "backend" {
  location      = var.region       # イメージを保存するリージョン
  repository_id = "backend"        # リポジトリ名
  format        = "DOCKER"         # Dockerイメージ用
  description   = "Backend container images"

  # イメージのプッシュ先URL:
  # asia-northeast1-docker.pkg.dev/PROJECT_ID/backend/IMAGE_NAME:TAG
}

イメージのプッシュ方法:

bash
# 1. Docker認証を設定(初回のみ)
gcloud auth configure-docker asia-northeast1-docker.pkg.dev

# 2. イメージをビルド
docker build -t asia-northeast1-docker.pkg.dev/my-project/backend/app:v1.0.0 .

# 3. プッシュ
docker push asia-northeast1-docker.pkg.dev/my-project/backend/app:v1.0.0

Cloud Run サービス

hcl
# terraform/cloud_run.tf

# ============================================
# Cloud Run サービス本体
# ============================================
resource "google_cloud_run_v2_service" "backend" {
  name     = "backend-${var.environment}"  # サービス名
  location = var.region

  # template: コンテナの設定(新しいリビジョンを作るたびにこの設定が適用される)
  template {
    # --------------------------------------------
    # スケーリング設定
    # --------------------------------------------
    scaling {
      # 最小インスタンス数
      # 本番: 1(コールドスタートを避ける)
      # ステージング: 0(コスト削減、リクエスト時に起動)
      min_instance_count = var.environment == "production" ? 1 : 0

      # 最大インスタンス数(負荷に応じて自動スケール)
      max_instance_count = 10
    }

    # --------------------------------------------
    # コンテナ設定
    # --------------------------------------------
    containers {
      name  = "backend"
      # Artifact Registryのイメージを指定
      # latest タグは本番では避けるべき(バージョンタグ推奨)
      image = "${var.region}-docker.pkg.dev/${var.project_id}/backend/app:latest"

      # リソース制限(CPU・メモリ)
      resources {
        limits = {
          cpu    = var.backend_cpu     # "1", "2", etc.
          memory = var.backend_memory  # "512Mi", "1Gi", etc.
        }
        # cpu_idle: リクエスト処理中以外はCPUを割り当てない(コスト削減)
        # 欠点: バックグラウンド処理ができない
        cpu_idle = true
      }

      # コンテナがリッスンするポート
      # Cloud Runはこのポートにリクエストを転送する
      # PORT環境変数も自動で設定される
      ports {
        container_port = 8080
      }

      # --------------------------------------------
      # 環境変数(通常の値)
      # --------------------------------------------
      env {
        name  = "RUST_LOG"
        # 三項演算子: 条件 ? true時の値 : false時の値
        value = var.environment == "production" ? "info" : "debug"
      }

      # DATABASE_URL: Cloud SQL Auth Proxy経由で接続
      # Unixソケット形式: postgres://user:pass@/dbname?host=/cloudsql/CONNECTION_NAME
      env {
        name  = "DATABASE_URL"
        value = "postgres://app_user:${random_password.db_password.result}@/${var.db_name}?host=/cloudsql/${google_sql_database_instance.main.connection_name}"
      }

      # --------------------------------------------
      # 環境変数(Secret Managerから取得)
      # --------------------------------------------
      # value ではなく value_source を使うとSecret Managerから取得できる
      # セキュリティ上、シークレットは直接書かない方がいい
      env {
        name = "ACCESS_TOKEN_SECRET"
        value_source {
          secret_key_ref {
            secret  = google_secret_manager_secret.access_token_secret.id
            version = "latest"  # または特定のバージョン番号
          }
        }
      }

      env {
        name = "REFRESH_TOKEN_SECRET"
        value_source {
          secret_key_ref {
            secret  = google_secret_manager_secret.refresh_token_secret.id
            version = "latest"
          }
        }
      }

      # --------------------------------------------
      # ヘルスチェック
      # --------------------------------------------
      # startup_probe: コンテナ起動時のチェック
      # これが成功するまでリクエストを受け付けない
      startup_probe {
        http_get {
          path = "/health"  # axumで実装した /health エンドポイント
        }
        initial_delay_seconds = 5   # 起動後5秒待ってからチェック開始
        period_seconds        = 10  # 10秒ごとにチェック
        failure_threshold     = 3   # 3回失敗でコンテナ再起動
      }

      # liveness_probe: 実行中のヘルスチェック
      # 失敗が続くとコンテナが再起動される
      liveness_probe {
        http_get {
          path = "/health"
        }
        period_seconds = 30
      }

      # --------------------------------------------
      # Cloud SQL接続(Unixソケット)
      # --------------------------------------------
      # /cloudsql ディレクトリにCloud SQL Auth Proxyのソケットがマウントされる
      volume_mounts {
        name       = "cloudsql"
        mount_path = "/cloudsql"
      }
    }

    # Cloud SQL Auth Proxyボリューム
    # これにより、コンテナからCloud SQLにセキュアに接続できる
    volumes {
      name = "cloudsql"
      cloud_sql_instance {
        instances = [google_sql_database_instance.main.connection_name]
        # connection_name: PROJECT_ID:REGION:INSTANCE_NAME 形式
      }
    }

    # このコンテナが使用するサービスアカウント
    service_account = google_service_account.backend.email
  }

  # トラフィック設定
  # 新しいリビジョンにトラフィックを100%向ける
  traffic {
    type    = "TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST"
    percent = 100
    # カナリアリリースしたい場合は、複数の traffic ブロックで割合を指定
  }

  depends_on = [
    google_project_service.services,
    google_sql_database_instance.main,
  ]
}

# ============================================
# IAM(権限管理)
# ============================================
# GCPでは「誰が」「何に」「どんな操作を」できるかをIAMで制御する
# member: 誰(ユーザー、サービスアカウント)
# role: どんな操作ができるか(権限のセット)
# resource: 何に対して

# Cloud Runサービスを公開(認証なしでアクセス可能に)
resource "google_cloud_run_v2_service_iam_member" "public" {
  location = var.region
  name     = google_cloud_run_v2_service.backend.name
  role     = "roles/run.invoker"  # Cloud Runを呼び出す権限
  member   = "allUsers"           # 全員(インターネット上の誰でも)
  # 注意: 認証が必要な内部APIの場合は allUsers を使わない
}

# ============================================
# サービスアカウント
# ============================================
# サービスアカウント = アプリケーション用のGCPアカウント
# 人間のユーザーとは別に、アプリケーションがGCPリソースにアクセスするために使う
resource "google_service_account" "backend" {
  account_id   = "backend-${var.environment}"     # アカウントID(一意)
  display_name = "Backend Service Account"         # 表示名
  # メールアドレスは自動生成: backend-production@PROJECT_ID.iam.gserviceaccount.com
}

# Secret Managerへのアクセス権限を付与
# Cloud RunコンテナがJWTシークレットを読み取れるようにする
resource "google_secret_manager_secret_iam_member" "backend_access" {
  # for_each: 複数のシークレットに同じ権限を付与
  for_each = toset([
    google_secret_manager_secret.access_token_secret.id,
    google_secret_manager_secret.refresh_token_secret.id,
    google_secret_manager_secret.db_password.id,
  ])

  secret_id = each.value
  role      = "roles/secretmanager.secretAccessor"  # シークレットの読み取り権限
  member    = "serviceAccount:${google_service_account.backend.email}"
}

# Cloud SQLへのアクセス権限を付与
resource "google_project_iam_member" "backend_cloudsql" {
  project = var.project_id
  role    = "roles/cloudsql.client"  # Cloud SQLクライアント権限
  member  = "serviceAccount:${google_service_account.backend.email}"
}

IAM用語の整理:

用語説明
Principal(誰)権限を持つ主体ユーザー、サービスアカウント、グループ
Role(何ができる)権限のセットroles/run.invoker, roles/cloudsql.client
Resource(何に対して)操作対象Cloud SQLインスタンス、Secret
memberPrincipalの指定方法user:foo@example.com, serviceAccount:xxx@xxx.iam.gserviceaccount.com

#10.6 プロダクション用Dockerfile

Cloud Runにデプロイするための最適化されたDockerfileを作成する。

dockerfile
# backend/Dockerfile.prod

# ============================================
# Stage 1: cargo-chef によるキャッシュ準備
# ============================================
FROM rust:1.92-bookworm AS chef
RUN cargo install cargo-chef --locked
WORKDIR /app

# ============================================
# Stage 2: 依存関係のレシピ生成
# ============================================
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

# ============================================
# Stage 3: 依存関係のビルド(キャッシュ対象)
# ============================================
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 --bin backend

# ============================================
# Stage 4: 最小限のランタイムイメージ
# ============================================
FROM gcr.io/distroless/cc-debian12:nonroot

WORKDIR /app

# バイナリをコピー
COPY --from=builder /app/target/release/backend /app/backend

# 非rootユーザーで実行(distrolessのnonrootタグで自動設定)
USER nonroot:nonroot

# ポート公開
EXPOSE 8080

# 起動コマンド
ENTRYPOINT ["/app/backend"]

ポイント解説:

  1. cargo-chef: Rustの依存関係をキャッシュする魔法のツール。Cargo.tomlが変わらない限り、依存関係のビルドをスキップできる

  2. distroless: Googleが提供する最小限のコンテナイメージ。シェルすらない(セキュリティ上の利点)

  3. nonrootユーザー: コンテナ内でroot権限を使わない(セキュリティベストプラクティス)

#10.5 Cloudflare Workers(フロントエンド)

フロントエンドは Cloudflare Workers Assets にデプロイする。

toml
# frontend/wrangler.toml
name = "my-app-frontend"
main = "src/lib.rs"
compatibility_date = "2026-01-01"

[assets]
directory = "dist"

Leptosでビルドした静的ファイル(dist/)をWorkersでホスティングする。APIへのリクエストは、別途APIプロキシWorkerを経由させる。

rust
// workers/api-proxy/src/lib.rs
use worker::*;

#[event(fetch)]
async fn main(req: Request, env: Env, _ctx: Context) -> Result<Response> {
    let backend_url = env.var("BACKEND_URL")?.to_string();

    // パスを取得
    let url = req.url()?;
    let path = url.path();

    // /api/* へのリクエストをバックエンドに転送
    if path.starts_with("/api/") {
        let backend_path = path.strip_prefix("/api").unwrap_or(path);
        let target_url = format!("{}{}", backend_url, backend_path);

        // リクエストを転送
        let mut headers = req.headers().clone();

        let mut init = RequestInit::new();
        init.with_method(req.method());
        init.with_headers(headers);

        if let Some(body) = req.bytes().await.ok() {
            if !body.is_empty() {
                init.with_body(Some(body.into()));
            }
        }

        let backend_req = Request::new_with_init(&target_url, &init)?;
        Fetch::Request(backend_req).send().await
    } else {
        Response::error("Not Found", 404)
    }
}

#Rust Tips: Terraformの状態管理

Terraformの状態ファイル(terraform.tfstate)は、GCSバケットに保存することを強く推奨する。

hcl
# 初回のみローカルで状態ファイルを作成後、以下のコマンドでGCSに移行
# terraform init -migrate-state

terraform {
  backend "gcs" {
    bucket = "my-app-terraform-state"
    prefix = "terraform/state"
  }
}

チーム開発では、状態ファイルのロック機能も重要だ。GCSバックエンドは自動的にロックを処理してくれる。


#11. CI/CD(GitHub Actions)

インフラが整ったら、継続的インテグレーション/デプロイ(CI/CD)を設定する。手動デプロイは人間のミスを招くし、何より面倒だ。

#11.1 CIパイプライン

PRがマージされる前に、コードの品質をチェックする。

yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

env:
  CARGO_TERM_COLOR: always
  RUST_BACKTRACE: 1

jobs:
  check:
    name: Check & Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy

      - name: Cache cargo registry
        uses: actions/cache@v4
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-

      - name: Check format
        run: cargo fmt --all -- --check

      - name: Clippy
        run: cargo clippy --all-targets --all-features -- -D warnings

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: check
    steps:
      - uses: actions/checkout@v4

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@stable

      - name: Cache cargo registry
        uses: actions/cache@v4
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-

      - name: Build backend
        run: cargo build --release -p backend

      - name: Install trunk
        run: cargo install trunk --locked

      - name: Add wasm target
        run: rustup target add wasm32-unknown-unknown

      - name: Build frontend
        run: trunk build --release
        working-directory: ./frontend

  test:
    name: Test
    runs-on: ubuntu-latest
    needs: check
    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test_db
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@stable

      - name: Cache cargo registry
        uses: actions/cache@v4
        with:
          path: |
            ~/.cargo/registry
            ~/.cargo/git
            target
          key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
          restore-keys: |
            ${{ runner.os }}-cargo-

      - name: Install sqlx-cli
        run: cargo install sqlx-cli --no-default-features --features postgres

      - name: Run migrations
        run: sqlx migrate run
        working-directory: ./backend
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/test_db

      - name: Run tests
        run: cargo test --all
        env:
          DATABASE_URL: postgres://test:test@localhost:5432/test_db
          ACCESS_TOKEN_SECRET: ${{ secrets.TEST_ACCESS_TOKEN_SECRET }}
          REFRESH_TOKEN_SECRET: ${{ secrets.TEST_REFRESH_TOKEN_SECRET }}

ポイント:

  • needs: check で依存関係を設定。Lintが通らないとビルド・テストは実行されない
  • PostgreSQLはGitHub Actionsのサービスコンテナとして起動
  • キャッシュを使って依存関係のダウンロード時間を短縮

#11.2 CDパイプライン

タグをプッシュすると、自動でデプロイが走る設定。セマンティックバージョニングを採用する。

yaml
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    tags:
      - 'v*'

env:
  PROJECT_ID: my-gcp-project
  REGION: asia-northeast1
  BACKEND_SERVICE: backend
  FRONTEND_WORKER: my-app-frontend

jobs:
  deploy-backend:
    name: Deploy Backend
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write  # Workload Identity Federation用

    steps:
      - uses: actions/checkout@v4

      - name: Get version from tag
        id: version
        run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT

      # GCP認証(Workload Identity Federation推奨)
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}

      - name: Set up Cloud SDK
        uses: google-github-actions/setup-gcloud@v2

      - name: Configure Docker
        run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev

      # Docker イメージのビルドとプッシュ
      - name: Build and push Docker image
        run: |
          docker build \
            -f backend/Dockerfile.prod \
            -t ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.BACKEND_SERVICE }}/app:${{ steps.version.outputs.VERSION }} \
            -t ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.BACKEND_SERVICE }}/app:latest \
            .
          docker push ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.BACKEND_SERVICE }}/app:${{ steps.version.outputs.VERSION }}
          docker push ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.BACKEND_SERVICE }}/app:latest

      # Cloud Runにデプロイ
      - name: Deploy to Cloud Run
        run: |
          gcloud run deploy ${{ env.BACKEND_SERVICE }}-production \
            --image ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.BACKEND_SERVICE }}/app:${{ steps.version.outputs.VERSION }} \
            --region ${{ env.REGION }} \
            --platform managed

  deploy-frontend:
    name: Deploy Frontend
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Rust
        uses: dtolnay/rust-toolchain@stable

      - name: Add wasm target
        run: rustup target add wasm32-unknown-unknown

      - name: Install trunk
        run: cargo install trunk --locked

      - name: Build frontend
        run: trunk build --release
        working-directory: ./frontend

      - name: Deploy to Cloudflare Workers
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          workingDirectory: ./frontend

  create-release:
    name: Create Release
    runs-on: ubuntu-latest
    needs: [deploy-backend, deploy-frontend]
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Generate changelog
        id: changelog
        uses: orhun/git-cliff-action@v3
        with:
          config: cliff.toml
          args: --latest --strip header

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          body: ${{ steps.changelog.outputs.content }}
          draft: false
          prerelease: ${{ contains(github.ref, '-rc') || contains(github.ref, '-beta') }}

#11.3 Workload Identity Federation

Service Account キーのJSONファイルをGitHub Secretsに保存する方法は、セキュリティ上推奨されない。代わりに Workload Identity Federation を使う。

hcl
# terraform/github_actions.tf

# Workload Identity Pool
resource "google_iam_workload_identity_pool" "github" {
  workload_identity_pool_id = "github-pool"
  display_name              = "GitHub Actions Pool"
  description               = "Identity pool for GitHub Actions"
}

# Workload Identity Provider
resource "google_iam_workload_identity_pool_provider" "github" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.github.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-provider"
  display_name                       = "GitHub Provider"

  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.actor"      = "assertion.actor"
    "attribute.repository" = "assertion.repository"
  }

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

# サービスアカウントへの権限付与
resource "google_service_account_iam_member" "github_actions" {
  service_account_id = google_service_account.github_actions.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/your-org/your-repo"
}

# GitHub Actions用サービスアカウント
resource "google_service_account" "github_actions" {
  account_id   = "github-actions"
  display_name = "GitHub Actions"
}

# 必要な権限を付与
resource "google_project_iam_member" "github_actions_roles" {
  for_each = toset([
    "roles/run.developer",
    "roles/artifactregistry.writer",
    "roles/iam.serviceAccountUser",
  ])

  project = var.project_id
  role    = each.value
  member  = "serviceAccount:${google_service_account.github_actions.email}"
}

これにより、JSONキーなしでGitHub ActionsからGCPリソースにアクセスできる。

#Rust Tips: git-cliff による自動Changelog生成

リリースノートを手動で書くのは面倒だし、書き忘れる。git-cliff を使えば、コミットメッセージから自動生成できる。

toml
# cliff.toml
[changelog]
header = """
# Changelog\n
"""
body = """
{% for group, commits in commits | group_by(attribute="group") %}
    ### {{ group | upper_first }}
    {% for commit in commits %}
        - {{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})\
    {% endfor %}
{% endfor %}\n
"""
trim = true

[git]
conventional_commits = true
filter_unconventional = true
commit_parsers = [
    { message = "^feat", group = "Features" },
    { message = "^fix", group = "Bug Fixes" },
    { message = "^doc", group = "Documentation" },
    { message = "^perf", group = "Performance" },
    { message = "^refactor", group = "Refactor" },
    { message = "^style", group = "Styling" },
    { message = "^test", group = "Testing" },
    { message = "^chore", group = "Miscellaneous" },
]

コミットメッセージを Conventional Commits 形式で書いておけば、美しいChangelogが自動生成される。


#12. まとめ

長い旅だった。

フルスタックRustでWebアプリケーションを構築する全行程を駆け足で紹介した。改めて振り返ると、我ながら「なぜこの道を選んだのか」と自問したくなる。

#フルスタックRustの正直な感想

良かった点:

  • 型安全性の恩恵: フロントからバックエンドまで、同じ型システムで守られる安心感は何物にも代えがたい。「このAPIのレスポンス、どんな型だっけ?」と悩む必要がない
  • SQLxのコンパイル時検証: SQLの間違いが実行時ではなくコンパイル時に分かる。「本番でSQLエラー」という悪夢から解放される
  • パフォーマンス: Rustで書いたバックエンドは、同等のNode.jsアプリと比べてメモリ使用量が桁違いに少ない。Cloud Runのコスト削減に直結する
  • WASMのサイズ: Leptosで生成されるWASMは、Reactアプリのバンドルサイズより小さいことも多い

辛かった点:

  • コンパイル時間: cargo build を叩いてコーヒーを淹れに行く習慣ができた。フルビルドは本当に長い
  • エコシステムの未成熟: TypeScriptの世界には「あれやりたい」と思ったらnpmにライブラリがある。Rustは「自分で書くか…」となることが多い
  • 所有権との格闘: 特にLeptosの view! マクロ内でクロージャを書くとき、所有権の問題で何度も壁にぶつかった。StoredValueCallback は救世主
  • 学習コスト: チームメンバーへの布教が難しい。「Rust書けるようになって」は、「新しい言語覚えて」より遥かに重い

#それでもRustを選ぶ理由

正直に言うと、「TypeScript + Next.js」を選んでいたら、開発速度は2〜3倍速かっただろう。

それでもRustを選んだ理由は、「正しく動くコード」を書くことへの執着だ。

Rustのコンパイラは厳しい。本当に厳しい。でも、その厳しさを乗り越えてコンパイルが通ったとき、そのコードは「たぶん動く」ではなく「確実に動く」という確信がある。

ランタイムエラーを踏むたびに「なぜコンパイル時に分からなかったのか」と思うタイプの人間には、Rustは最高の選択肢だ。

#今後の展望

このテンプレートを元に、いくつかの改善を考えている:

  • SSR対応: Leptos 0.8.xはSSRもサポートしている。SEOが重要なアプリでは検討の価値あり
  • gRPC: REST APIではなく、gRPCを使った型安全な通信も面白い。tonic クレートが使える
  • OpenTelemetry: 分散トレーシングを導入して、本番環境のデバッグを楽にする

#最後に

フルスタックRustは「茨の道」だが、その先には確かな報酬がある。

もし同じ道を歩もうとしている人がいるなら、この記事が少しでも役に立てば幸いだ。Rustコンパイラに怒られ続ける日々を、一緒に乗り越えていこう。


参考リンク: