Sveltekit アプリをCloudflare Containersにデプロイする
夢のサービス Cloudflare Containers とは
最近 Cloudflare がベータ版で公開したコンテナーサービス。使う側のイメージとしては Cloud Run に近いが、Worker から直接呼び出したり、起動・シャットダウンしたりすることができる、そしてコンテナなので、ランタイム制限がない。
何がいいか
Worker は素晴らしいが、やはり制限が多く、痒いところに手が届かない感じだった。現在は一般的な Node の API も使えるようになり、以前よりはかなり使いやすくなっているが、Edge という特性上、メモリ制限や実行時間制限がある。アプリ全体を Cloudflare Containers だけで動かそうとすると、厳しい場面が出てくる。
それを解決してくれるのが Cloudflare Containers だと思っている。重い処理や時間のかかる処理は Container に流し込み、長時間実行して結果をユーザーに返す。それ以外の軽い処理は Worker で完結させることで、開発体験が一段上がったという感覚がある。
sveltekit を cloudflare worker で動かすには
SvelteKit は Web Standard に沿っているため、adapter を使えば簡単に Cloudflare Containers や Pages(現在は非推奨)にデプロイできる。
今回やりたいことは
すでに Cloudflare Worker で動いているアプリがあり、特定のエンドポイントにアクセスが来たときに Container を起動し、そこで重い処理を行い、最終的にレスポンスを返す、という構成を想定している。
(実際のところ結局Workerの実行時間には制限があるので、Containerの実行を終わるのを待つとworkerの実行が強制終了させられるので、先にresponse返してちゃって、containerで処理して、その中で更に何かするようなイメージ)
そのため、Worker と Container は同一のコードベースにし、Worker 側は adapter-cloudflare でビルドし、Container 内部では node-adapter でビルドする。
問題
Cloudflare Containers を動かすには Durable Objects を使う必要がある。
SvelteKit を Worker だけで動かすのであれば特に問題はなく、R2 や D1 なども binding でそのまま接続すれば動作する。しかし Durable Object が絡んでくると話が変わる。なぜなら、adapter-cloudflare は Durable Object に対応していないからである。
✘ [ERROR] Your Worker depends on the following Durable Objects, which are not exported in your entrypoint file: Container.
You should export these objects from your entrypoint, .svelte-kit/cloudflare/_worker.js.
どうするか
自前で custom adapter を書くしかないため、adapter-cloudflare を拡張し、自前のラッパー adapter を作ることにする。
もう少し具体的に言うと、DurableObject.server.ts を作成してその中で DurableObject のクラスを定義し、そのファイルだけを単体でトランスパイルし、生成されたコードを SvelteKit のビルド成果物である _worker.js に書き込む。要するにワークアラウンドで対応する。
やっていく
まず、Cloudflare Containers を動かすために必要なものは以下。
- Dockerfile
- wrangler.jsonc(または toml)の設定
Dockerfile 作成
まずは Dockerfile から。
FROM node:24-slim
WORKDIR /app
# まずは依存関係ファイルのみをコピー
COPY package.json package-lock.json ./
# プロジェクト全体のソースコードと設定ファイルをコピー
COPY . .
RUN rm -rf node_modules
# 👆 これを残すか .dockerignore に入れるかのどちらかにしないと、
# esbuild のバイナリーエラーが出る可能性がある(ハマった)
# これにより Docker のレイヤーキャッシュが効率的に機能する
RUN npm ci
ENV ADAPTER=node
# 👆 svelte.config.js 側で環境判定するために定義
ENV PORT=8080
# 一度本番ビルドもしておく
RUN bun run build:node
EXPOSE 8080
CMD ["npm", "run", "dev:node"]
// 省略
"scripts": {
"dev:node": "ADAPTER=node vite dev --host 0.0.0.0 --port 8080"
}
// 省略
wrangler.json に設定追加
次は wrangler.jsonc の設定。
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "http2",
"main": "src/index.ts",
"compatibility_date": "2025-04-03",
"migrations": [
{
"new_sqlite_classes": [
"Container"
],
"tag": "v1"
}
],
"containers": [{
"image": "./Dockerfile",
"class_name": "Container",
"max_instances": 2
}],
"durable_objects": {
"bindings": [
{
"class_name": "Container",
"name": "CONTAINER"
}
]
},
"observability": {
"enabled": true
}
}
migrations、containers、durable_objects の設定は必須らしい。また、durable_objects で定義した class_name、containers の class_name、new_sqlite_classes の名前はすべて一致している必要がある。
ここは example リポジトリを参考にした。
DurableObject.server.ts を定義する
example ではそのまま DurableObject を継承している。
実は @cloudflare/containers というパッケージが用意されており、そちらを使うともう少し良い感じに書けるとのことなので、今回はそれを使う。
lib 配下に定義する。
import { Container as CFContainer } from '@cloudflare/containers';
import type { Env } from '../worker-configuration';
import type { env as svEnv } from '$env/dynamic/private';
import type { env as svPublicEnv } from '$env/dynamic/public';
export class Container extends CFContainer<Env> {
defaultPort = 8080;
sleepAfter: string | number = '10m';
enableInternet = true;
startAndWaitForPorts(
portsOrArgs?: number | number[] | Record<any, any>,
cancellationOptions?: unknown,
startOptions?: unknown
): Promise<void> {
// タイムアウト値を増やした cancellationOptions を設定
const enhancedCancellationOptions = {
instanceGetTimeoutMS: 30000,
portReadyTimeoutMS: 60000,
waitInterval: 500,
...(cancellationOptions ?? {})
};
// @ts-expect-error 型が合わないため一旦無視
return super.startAndWaitForPorts(portsOrArgs, enhancedCancellationOptions, startOptions);
}
constructor(ctx: DurableObjectState<{}>, env: Env & typeof svEnv & typeof svPublicEnv) {
super(ctx, env);
this.envVars = {
...this.envVars
// ここで環境変数をスプレッドするとよい
};
this.entrypoint = env.APP_MODE !== 'development' ? ['node', 'build'] : undefined;
// 実行するエントリーポイントを上書きできるため、
// 実行環境に応じて prod / dev を切り替えられる
}
async fetch(req: Request) {
return super.fetch(req);
}
}
多少設定を上書きしているが、基本的にはそのままでも動くはず。
custom adapter を作る
今回はそこまで複雑なことはしないが、custom adapter の作り方については公式ドキュメントもあるので、一度見ておくとよい。
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { execSync } from 'child_process';
import cloudflareAdapter from '@sveltejs/adapter-cloudflare';
export default function customAdapter(options = {}) {
const baseAdapter = cloudflareAdapter(options);
return {
name: 'custom-cloudflare-adapter',
async adapt(builder) {
await baseAdapter.adapt(builder);
await addDurableObjectDef(builder);
},
supports: baseAdapter.supports,
emulate: baseAdapter.emulate
};
}
async function addDurableObjectDef(builder) {
const __dirname = new URL('.', import.meta.url).pathname;
execSync(
'tsc src/lib/DurableObject.server.ts --outdir ./dist --target esnext --module esnext --noResolve --noCheck'
);
console.log('Built Durable Object definitions');
const dbPath = join(__dirname, '../', 'dist/DurableObject.server.js');
const workerPath = join(builder.getBuildDirectory('cloudflare'), '_worker.js');
try {
let content = readFileSync(workerPath, 'utf-8');
const durableObjectDefs = readFileSync(dbPath, 'utf-8');
content = content + '\n' + durableObjectDefs;
writeFileSync(workerPath, content);
builder.log.info('Added Durable Object exports');
} catch (error) {
throw new Error(`Failed to add hooks import: ${error.message}`);
}
}
やっていることは単純で、説明した通り DurableObject.server.ts を単体でビルドし、その中身を _worker.js に追記しているだけ。
svelte.config.js で custom adapter を使う。
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import customAdapter from './adapters/custom-cloudflare-adapter.js';
import nodeAdapter from '@sveltejs/adapter-node';
const config = {
preprocess: vitePreprocess(),
kit: {
adapter:
process.env.ADAPTER === 'node'
? nodeAdapter({
out: 'build',
precompress: true
})
: customAdapter()
}
};
export default config;
補足:
メインのアプリは Worker ランタイムで動かしつつ、Container の中では同じアプリを Node ランタイムで動かすため、今回は追加で node-adapter も必要になる。
custom adapter を一度ビルドしてみる。
ADAPTER=worker vite build を実行し、最終的に Container の定義コードが追加されていれば OK。
dev サーバーを動かすには
Vite の dev サーバーはまだ Durable Object に対応していないようで、wrangler でしか動かせない。
dev サーバーを動かす場合は wrangler dev を使う必要があるが、wrangler dev では HMR が効かない。そのため、ターミナルを分けて、片方で vite build --watch、もう片方で wrangler dev を起動する必要がある。
ADAPTER=worker npm run build
その後、
npx wrangler dev --port 5173
うまくいくと Docker イメージのダウンロードが始まる。
container にリクエストを投げてみる
まず bindings の型定義をしておく。
import type { Container } from '$lib/DurableObject';
import { KVNamespace } from '@cloudflare/workers-types';
declare global {
namespace App {
interface Locals {}
interface Platform {
env: {
CONTAINER: DurableObjectNamespace<Container>;
};
cf: CfProperties;
context: ExecutionContext;
}
}
}
試しに routes/api/dev/+server.ts を作り、そこから呼び出してみる。
import { json } from '@sveltejs/kit';
export const GET = async ({ platform }) => {
try {
const res = await platform?.env.CONTAINER
.get(platform?.env.CONTAINER.idFromName('unique-name'))
.fetch(
new Request('http://localhost/api/dev/container', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hello: 'world' })
})
);
return res;
} catch (error) {
console.log(error);
return json('error');
}
};
もう一つエンドポイントを作り、こちらは Container 側で受け取る。
import { json } from '@sveltejs/kit';
export const GET = async ({ request }) => {
try {
const body = await request.json();
console.log(body);
return json(body);
} catch (error) {
console.log(error);
return json('error');
}
};
リクエストを投げてみる
無事に起動していそう。
なぜか 1 回目だけ失敗する(beta 版だからかも)。
ただし Container 自体は起動できている。
なので、もう一度リクエストを投げると、うまくいく。
リクエストを forward できたので、POST も投げてみる。
感想
今回は同じアプリを Container でも実行するアプローチを取ったが、Container という特性上、環境に縛られない点はやはり魅力的だと感じた。Node.js、Bun、Deno など、好きなランタイムや環境を選べる。
ローカル開発はまだワークアラウンドが必要な部分も多いが、今後改善されていくと期待している。Dockerfile についても、ビルドパフォーマンスやイメージサイズ削減の余地はまだあると思う。
Cloud Run もよく使っているが、Cloud Build のリージョンやスペックをそのままにすると、イメージ削減を頑張っても最低 5 分程度はデプロイにかかっている。一方 Cloudflare は、Worker 側に変更がなければスキップされるなど、キャッシュ周りをうまくやってくれている印象がある、デプロイがやや速く感じる(今回の Dockerfile ではおよそ 2 分ほどでビルドできている)







Discussion