🚢

Sveltekit アプリをCloudflare Containersにデプロイする

に公開

夢のサービス Cloudflare Containers とは

最近 Cloudflare がベータ版で公開したコンテナーサービス。使う側のイメージとしては Cloud Run に近いが、Worker から直接呼び出したり、起動・シャットダウンしたりすることができる、そしてコンテナなので、ランタイム制限がない。

https://developers.cloudflare.com/containers/

何がいいか

Worker は素晴らしいが、やはり制限が多く、痒いところに手が届かない感じだった。現在は一般的な Node の API も使えるようになり、以前よりはかなり使いやすくなっているが、Edge という特性上、メモリ制限や実行時間制限がある。アプリ全体を Cloudflare Containers だけで動かそうとすると、厳しい場面が出てくる。

それを解決してくれるのが Cloudflare Containers だと思っている。重い処理や時間のかかる処理は Container に流し込み、長時間実行して結果をユーザーに返す。それ以外の軽い処理は Worker で完結させることで、開発体験が一段上がったという感覚がある。

sveltekit を cloudflare worker で動かすには

SvelteKit は Web Standard に沿っているため、adapter を使えば簡単に Cloudflare Containers や Pages(現在は非推奨)にデプロイできる。

https://svelte.dev/docs/kit/adapter-cloudflare

今回やりたいことは

すでに 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 から。

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"]
package.json
// 省略
"scripts": {
    "dev:node": "ADAPTER=node vite dev --host 0.0.0.0 --port 8080"
}
// 省略

wrangler.json に設定追加

次は wrangler.jsonc の設定。

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
	}
}

migrationscontainersdurable_objects の設定は必須らしい。また、durable_objects で定義した class_namecontainersclass_namenew_sqlite_classes の名前はすべて一致している必要がある。

ここは example リポジトリを参考にした。
https://github.com/cloudflare/containers-demos/blob/main/http2/wrangler.jsonc

DurableObject.server.ts を定義する

example ではそのまま DurableObject を継承している。
https://github.com/cloudflare/containers-demos/blob/main/http2/src/index.ts

実は @cloudflare/containers というパッケージが用意されており、そちらを使うともう少し良い感じに書けるとのことなので、今回はそれを使う。

https://github.com/cloudflare/containers

lib 配下に定義する。

lib/DurableObject.server.ts
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 の作り方については公式ドキュメントもあるので、一度見ておくとよい。

http://svelte.dev/docs/kit/writing-adapters

adapters/custom-adatper.js
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 を使う。

svelte.config.js
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 も必要になる。

https://www.npmjs.com/package/@sveltejs/adapter-node

custom adapter を一度ビルドしてみる。
ADAPTER=worker vite build を実行し、最終的に Container の定義コードが追加されていれば OK。

https://gyazo.com/daadd03c9bb730d4f3deabbd21112197

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 イメージのダウンロードが始まる。

Image from Gyazo

container にリクエストを投げてみる

まず bindings の型定義をしておく。

src/app.d.ts
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 を作り、そこから呼び出してみる。

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 側で受け取る。

routes/api/dev/container/+server.ts
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');
	}
};

リクエストを投げてみる

起動。
Image from Gyazo

無事に起動していそう。

なぜか 1 回目だけ失敗する(beta 版だからかも)。

Image from Gyazo

ただし Container 自体は起動できている。

Image from Gyazo

なので、もう一度リクエストを投げると、うまくいく。

Image from Gyazo

リクエストを forward できたので、POST も投げてみる。

Image from Gyazo

感想

今回は同じアプリを Container でも実行するアプローチを取ったが、Container という特性上、環境に縛られない点はやはり魅力的だと感じた。Node.js、Bun、Deno など、好きなランタイムや環境を選べる。

ローカル開発はまだワークアラウンドが必要な部分も多いが、今後改善されていくと期待している。Dockerfile についても、ビルドパフォーマンスやイメージサイズ削減の余地はまだあると思う。

Cloud Run もよく使っているが、Cloud Build のリージョンやスペックをそのままにすると、イメージ削減を頑張っても最低 5 分程度はデプロイにかかっている。一方 Cloudflare は、Worker 側に変更がなければスキップされるなど、キャッシュ周りをうまくやってくれている印象がある、デプロイがやや速く感じる(今回の Dockerfile ではおよそ 2 分ほどでビルドできている)

Discussion