Claude Code + Opus 4.6 パワーで書いた記事です。Cloudflare Containers は日本から使うとレイテンシがどうしても 400 ms くらいはあるので今後に期待という話。


Ruri v3 は名古屋大学の研究グループが開発した日本語特化の embedding モデルです。37M パラメータの最小モデル (ruri-v3-30m) でも JMTEB 平均 74.51 と、OpenAI text-embedding-3-large (73.97) を上回り、1B 超の PLaMo-Embedding-1B (76.10) に迫る性能を持っています。パラメータ効率がかなり高く、小さなモデルでも実用的な精度が出るので、リソースの限られたサーバレス環境と相性が良さそうだなと思い試してみました。

さらに、sirasagi62 氏が Ruri v3 の ONNX 版を公開してくれたことで、Python 以外のランタイム(ブラウザ、Node.js、Rust など)からも推論が可能になりました。今回のデモはこの ONNX 版 (sirasagi62/ruri-v3-30m-ONNX) を利用しています。

実行環境には、2025 年にオープンベータになった Cloudflare Containers を選びました。Workers の制約を超えてネイティブバイナリを動かせる新しいコンテナ実行環境で、まだ情報が少ないので記録を残しておきます。

推論が試せるデモはこちら:

構成図

構成図

Cloudflare Containers について

Cloudflare Containers は 2025 年にオープンベータになった Docker コンテナ実行環境です。Workers の Durable Object として管理され、Workers 単体の制約(メモリ 128MB、ネイティブバイナリ不可)を超えた処理が可能になります。利用には Workers Paid plan($5/月)が必要。

Workers と Containers の比較:

WorkersContainers
メモリ128MB256MiB〜
ランタイムV8 (JS/WASM)Linux/amd64 (任意のバイナリ)
状態リクエスト間で揮発Durable Object ライフサイクルで永続
起動~0ms (cold start なし)2〜数秒
用途軽量プロキシ、APIML 推論、重い計算、ネイティブ依存

料金

vCPU・メモリ・ディスクは 10ms 単位の従量課金で、無料枠もあります(料金の詳細)。

リソース無料枠 (月)超過料金
メモリ25 GiB-hours$0.0000025 / GiB-sec
vCPU375 vCPU-minutes$0.000020 / vCPU-sec
ディスク200 GB-hours$0.00000007 / GB-sec

sleepAfter でアイドル時にスリープさせれば、課金はリクエスト処理中のみに限定できます。

インスタンスタイプ

タイプvCPUメモリディスク
lite1/16256 MiB2 GB
basic1/41 GiB4 GB
standard-11/24 GiB8 GB
standard-216 GiB12 GB
standard-328 GiB16 GB
standard-4412 GiB20 GB

今回のデモでは最小の lite を使っています。

Worker ↔ Container の接続

Containers は Durable Object として動作するので、Worker から Durable Object を取得する形で接続します。

wrangler.toml:

[[containers]]
class_name = "RuriContainer"
image = "./container/Dockerfile"
max_instances = 1

[[durable_objects.bindings]]
name = "RURI_CONTAINER"
class_name = "RuriContainer"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["RuriContainer"]

Worker 側 (TypeScript):

import { Container, getContainer } from "@cloudflare/containers";

export class RuriContainer extends Container {
  defaultPort = 8080;     // コンテナ内のリッスンポート
  sleepAfter = "10m";     // 10 分非アクティブでスリープ → コスト節約

  override onStart(): void { console.log("[container] Started"); }
  override onStop(): void { console.log("[container] Stopped"); }
  override onError(error: unknown): void { console.error("[container] Error:", error); }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    if (!url.pathname.startsWith("/api/")) {
      return env.ASSETS.fetch(request);  // 静的アセット配信
    }
    // Container へプロキシ
    const container = getContainer(env.RURI_CONTAINER, "singleton");
    const response = await container.fetch(request);
    return response;
  },
};

getContainer(binding, name) は内部で binding.idFromName(name)binding.get(id) を呼ぶラッパーです。同じ name なら同じインスタンスに到達し、異なる name なら別のインスタンスが起動します。今回は固定名 "singleton" を渡して全リクエストを同一コンテナに集約しています。

ステートレスなワークロードで複数インスタンスに分散させたい場合は、getRandom が用意されています。内部で instance-0, instance-1, … のような名前を使い分け、ランダムにインスタンスを選択します。

Cloudflare Containers を試す

Cold Start

Cloudflare のドキュメントには cold start が “often in the 2-3 second range” とありますが、image サイズに依存します。実際に試したところ、1.58GB の image で ~15s、94.5MB まで削減してようやく 2.3s でした(詳細は後述の Image サイズの推移 を参照)。

コンテナ起動中のリクエストには 502/503 が返るので、Worker 側でリトライを入れておくのが無難です。今回は最大 2 回・指数バックオフ(1s, 2s)にしています。

Durable Object とコンテナの配置

現時点で困るのが、Durable Object とコンテナが同一ロケーションに配置されるとは限らない点です。アーキテクチャのドキュメントにも “Durable Objects may be co-located with their associated Container instance, but often are not” と明記されています。

リクエストの経路は Client → Worker (エッジ) → Durable Object → Container で、Durable Object と Container が別リージョンに配置されると、その間の RTT がレイテンシに上乗せされます。今回のデモでも Worker → Container 間で 300〜400ms の RTT が観測されることがありました。

コミュニティでも、Container がヨーロッパではなくオーストラリアに配置され 16 倍のレイテンシが発生した報告があります。現状リージョン指定の手段がないので、外部データベースとの通信が多いワークロードでは要注意。

Ruri v3 をサーバレスで動かす

使用モデルは sirasagi62/ruri-v3-30m-ONNX です。 q8 量子化により FP32 版(140MB)から約 1/4 に削減されています。

Workers では動かなかった

Containers に至る前に、Workers 上での推論を 2 通り試しましたが、どちらも 128MB のメモリ制限に阻まれました。

onnxruntime-web (WASM): JS heap (~68MB) + WASM linear memory (~52MB) の合計が ~120MB に達し、2 回目以降のリクエストで exceededMemory。Workers のメモリ制限は JS heap + WASM linear memory の合算で判定されます。Memory.grow() は縮小不可。

tract-onnx (Rust WASM): JS オーバーヘッドは解消しましたが、モデルロード時のピークメモリが ~134MB に達し isolate が毎リクエスト破棄されました。また tract は ModernBERT の Expand op(動的 shape)を扱えず、モデルの前処理も必要でした。

Workers の 128MB 制限はこのサイズのモデルだとどうにもならないので、Containers に移行しました。Rust + axum(HTTP サーバ)+ ort(ONNX Runtime の Rust バインディング)で組んでいます。

制約Workers (WASM)Containers
メモリ128MB256MiB〜
ランタイムWASM (single-thread, hang detector)Native Linux/amd64
推論エンジンonnxruntime-web or tractONNX Runtime (C++ ネイティブ)

推論パイプライン

3 フェーズに分かれます:

入力テキスト
  → [Phase 1: Tokenize] Unigram トークナイザでトークン ID + attention mask 生成
  → [Phase 2: Inference] ONNX Runtime で sentence_embedding [batch, 256] を取得
  → [Phase 3: Normalize] L2 正規化 → 単位ベクトル化

Phase 1: Tokenize — HuggingFace の tokenizers crate でテキストをトークン ID 列に変換。バッチ内の最大長に合わせて padding されます。

Phase 2: Inference — ONNX Runtime のセッションに input_idsattention_mask を渡します。この ONNX モデルは token_embeddings(トークンごとの embedding)と sentence_embedding(mean pooling 済みの文 embedding)の 2 つを出力します。mean pooling はモデル内部で計算済みなので、sentence_embedding を直接取得します。

let session = Session::builder()
    .with_optimization_level(GraphOptimizationLevel::Level3)
    .with_intra_threads(2)  // lite instance (1/16 vCPU) 向け
    .commit_from_file(model_path)?;

let outputs = session.run(ort::inputs![
    "input_ids" => ids_tensor,          // [batch, seq_len] i64
    "attention_mask" => mask_tensor,     // [batch, seq_len] i64
])?;

let sentence_embedding = outputs["sentence_embedding"]  // [batch, 256]
    .try_extract_array::<f32>()?;

Phase 3: L2 Normalizesentence_embedding を L2 正規化して単位ベクトルにします。これによりコサイン類似度がドット積だけで計算できるようになります。

コサイン類似度の計算:

pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
    dot / (norm_a * norm_b).max(1e-12)
}

サーバアーキテクチャ

モデルのロードはサーバ起動後にバックグラウンドで行い、ロード完了前のリクエストには 503 を返すようにしています。Edge Worker 側でリトライ(指数バックオフ)するので、cold start 時もクライアントは待つだけで済みます。

// サーバ起動: リクエスト受付を即座に開始
let listener = TcpListener::bind("0.0.0.0:8080").await?;
eprintln!("[server] Listening on :8080");

// モデルロード: バックグラウンドで非同期実行
tokio::task::spawn_blocking(move || {
    let start = Instant::now();
    match EmbeddingEngine::load(&model_path, &tokenizer_path) {
        Ok(engine) => {
            let load_ms = start.elapsed().as_secs_f64() * 1000.0;
            bg_state.engine.set(Arc::new(engine));
            bg_state.ready.store(true, Ordering::Release);
            eprintln!("[startup] Model ready in {load_ms:.1}ms");
        }
        Err(e) => std::process::exit(1),
    }
});

axum::serve(listener, app).await?;

Dockerfile (3-stage multi-stage build)

# Stage 1: HuggingFace からモデルをダウンロード
FROM debian:bookworm-slim AS model-fetcher
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /model
RUN curl -fSL -o tokenizer.json \
      "https://huggingface.co/sirasagi62/ruri-v3-30m-ONNX/resolve/main/tokenizer.json" && \
    mkdir -p onnx && \
    curl -fSL -o onnx/model_quantized.onnx \
      "https://huggingface.co/sirasagi62/ruri-v3-30m-ONNX/resolve/main/onnx/model_quantized.onnx"

# Stage 2: Rust バイナリをビルド
FROM ubuntu:24.04 AS rust-builder
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
    curl cmake build-essential pkg-config libssl-dev ca-certificates && rm -rf /var/lib/apt/lists/*
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
    sh -s -- -y --default-toolchain 1.94.0
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /app
# 依存キャッシュ: dummy main で先にビルド
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo 'fn main() {}' > src/main.rs && \
    cargo build --release 2>/dev/null || true && rm -rf src
# 本番ビルド
COPY src/ ./src/
RUN touch src/main.rs && cargo build --release && strip target/release/ruri-container

# Stage 3: 最小ランタイム
FROM gcr.io/distroless/cc-debian13:nonroot
COPY --from=rust-builder /app/target/release/ruri-container /ruri-container
COPY --from=model-fetcher /model /app/model/
ENV MODEL_PATH=/app/model/onnx/model_quantized.onnx
ENV TOKENIZER_PATH=/app/model/tokenizer.json
EXPOSE 8080
ENTRYPOINT ["/ruri-container"]

ポイント:

  • Stage 1 (model-fetcher): ビルドキャッシュが効くため、コード変更時にモデル再ダウンロードが発生しない
  • Stage 2 (rust-builder): dummy main による依存キャッシュパターン。Cargo.toml が変わらなければ依存クレートのビルドをスキップ
  • Stage 3 (runtime): distroless/cc-debian13:nonroot — glibc 2.41 + libstdc++ のみの最小ランタイム。ort のプリビルドバイナリが glibc ≥ 2.38 を要求するため Debian 12 系 (glibc 2.36) は不可

Image サイズの推移

Node.js + @huggingface/transformers で始めた初版は 1.58GB とかなり大きかったのですが、不要な依存の削除と Rust への移行で 94.5MB まで削れました。cold start も image サイズにほぼ比例して改善しています。

バージョンImageCold Start備考
Node.js 初版1.58GB~15s@huggingface/transformers が CUDA provider・onnxruntime-node を重複バンドル
Node.js (pruned)386MB4.4s不要ファイル削除 (CUDA, WASM, source maps 等)
Rust + Ubuntu148MB4.0s
Rust + distroless94.5MB2.3s

最終 image の内訳:

コンポーネントサイズ
distroless/cc-debian13 base~33MB
Rust binary (stripped)23MB
Model (q8 ONNX + tokenizer)42.5MB
合計94.5MB

類似度の計算例

デモで実際に計算した結果です。意味が近いペアでちゃんと高い類似度が出てますね。

テキスト 1テキスト 2類似度
猫が好きねこが大好き0.964
東京タワー東京スカイツリー0.935
猫が好きプログラミングを学ぶ0.772
りんごりんご1.000

transformers.js との tokenizer 差異

Rust 移行後に Node.js(transformers.js)版と embedding を比較したところ、同一入力に対してやや異なる埋め込みが出力されていました。transformers.js の LlamaTokenizer の問題のようだったので、 huggingface/transformers.js#1612 で報告しています。

おわりに

Ruri v3 は 37M パラメータで OpenAI text-embedding-3-large を上回る性能があり、sirasagi62 氏の ONNX 版のおかげで Rust からも手軽に推論できます。

Cloudflare Containers はまだオープンベータですが、Workers との連携は少ないコードで書けて、lite インスタンス(1/16 vCPU, 256MiB)でも推論 3〜5ms と十分な性能が出ました。ただ、Durable Object とコンテナが別リージョンに配置されて RTT が数百 ms 上乗せされる問題など、プロダクション利用にはまだ粗い部分もあります。今後の改善に期待。