ch29-containers
在 GitHub 上檢視·8 個檔案·10.3 KB
取得並執行
這個範例可以獨立 clone 執行,不依賴其他章節。
npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch29-containers
cd ch29-containers
npm install可用指令
npm run dev # wrangler dev
npm run deploy # wrangler deploy
npm run types # wrangler types --env-interface CloudflareBindings說明
ch29 — Containers on Workers
Section titled “ch29 — Containers on Workers”Workers Paid only. Every Containers docs page carries that banner.
Verified with wrangler 4.118.0 and @cloudflare/containers 0.3.7.
src/index.ts the Container subclass + Worker routessrc/env.ts the hand-written binding type (see below)container/ Dockerfile + a tiny Node server with ffmpegwrangler.jsonc the three-names wiringnpm installnpx wrangler types --env-interface CloudflareBindingsnpx wrangler dev --port 9036 # requires a running Docker daemonThe three names
Section titled “The three names”A container is always fronted by a Durable Object. There is no container binding type.
| Config | Value | Meaning |
|---|---|---|
containers[].class_name | MediaContainer | which DO class manages the container |
durable_objects.bindings[].class_name | MediaContainer | must match exactly |
durable_objects.bindings[].name | MEDIA | what appears on env |
npx wrangler types && grep MEDIA worker-configuration.d.ts# MEDIA: DurableObjectNamespace /* MediaContainer */;What wrangler catches, and what it doesn’t
Section titled “What wrangler catches, and what it doesn’t”Measured with wrangler deploy --dry-run:
| Misconfiguration | Result |
|---|---|
DO class_name typo | ✅ Your Worker depends on the following Durable Objects, which are not exported in your entrypoint file: SomethingElse. |
new_classes instead of new_sqlite_classes | ❌ passes silently |
no durable_objects binding at all | ❌ passes silently |
no migrations block | ⚠️ warns, but recommends the declarative exports map (ch14), not migrations |
Only the first is caught. The other two produce a green build and a broken Worker.
--dry-run builds the image
Section titled “--dry-run builds the image”Undocumented, and it will bite CI. With image pointing at a local Dockerfile,
wrangler deploy --dry-run runs a real docker build:
#2 ERROR: failed to do request: Head "https://registry-1.docker.io/..." : ForbiddenERROR: failed to build: failed to solve: node:22-alpine: failed to resolve source metadataSo --dry-run needs a Docker daemon and registry access. Point image at a
prebuilt URI (registry.cloudflare.com/...) to validate config without a build.
--dry-run is not mentioned anywhere in the Containers docs.
Types don’t flow — for the third time
Section titled “Types don’t flow — for the third time”error TS2345: Argument of type 'DurableObjectNamespace<undefined>' is not assignableto parameter of type 'DurableObjectNamespace<Container<Env>>'.getContainer() wants a typed namespace; wrangler types puts the class name
in a comment. Same fix as chapters 18 and 26 — src/env.ts:
export interface AppEnv extends Omit<CloudflareBindings, "MEDIA"> { MEDIA: DurableObjectNamespace<MediaContainer>;}Corrections to widely-repeated claims
Section titled “Corrections to widely-repeated claims”Read from the shipped source of @cloudflare/containers@0.3.7:
const DEFAULT_SLEEP_AFTER = '10m'; // lib/container.js:20enableInternet = true; // lib/container.js:325
async onActivityExpired() { // lib/container.js:748 console.log('Activity expired, signalling container to stop'); if (!this.container.running) return; await this.stop();}onActivityExpiredalready stops the container. You only leak one if you override it and forget. This example overrides it and callsawait super.onActivityExpired().enableInternetdefaults totrue, not false. The docs agree: “By default, a Container will allow internet access.” Turn it off deliberately and useallowedHostswhen handling untrusted input.devandstandardare aliases, not removed.instance_type: "dev"producesWARNING - The "dev" instance_type has been renamed to "lite" and will be removed in a subsequent version.
Two places the schema and the docs disagree
Section titled “Two places the schema and the docs disagree”// config-schema.json, ssh.enabled"default": falseThe SSH docs page says “It defaults to true.” Write it explicitly either way.
// config-schema.json, instance_type description"Customers on an enterprise plan have the additional option to set custom limits."The 2026-01-05 changelog says custom instance types are “available to all users.” The bundled schema description is stale.
Cost model, in one line
Section titled “Cost model, in one line”CPU is billed on active usage; memory and disk are billed on the
provisioned size of the instance type, for as long as the container is
awake. So an idle container still costs money — sleepAfter is a billing knob,
not just a lifecycle one. This example sets "2m" rather than the "10m"
default for that reason.
Container → Worker with no SDK
Section titled “Container → Worker with no SDK”The outbound-traffic API (documented at
/containers/platform-details/outbound-traffic/, not on the Container class
page) lets the container reach your bindings over plain HTTP:
static outboundByHost = { "my.kv": async (request, env) => { const key = new URL(request.url).pathname.slice(1); return new Response(await env.KV.get(key)); },};Inside the container: curl http://my.kv/some-key. No SDK or client library
is required inside the container — which is what makes lifting an existing
Docker service onto Workers practical.
Shape of the work: Queue → Container
Section titled “Shape of the work: Queue → Container”Cold starts are “often in the 1-3 second range”. Do not put a container on the request path. Enqueue (ch19), and let the consumer fan out to containers keyed by tenant so consecutive jobs hit a warm instance:
const stub = getContainer(env.MEDIA, msg.body.tenantId);Use loadBalance(ns, n) instead when the work is stateless and you want spread
rather than affinity.
原始碼
wrangler.jsoncpackage.jsonsrc/env.tssrc/index.tscontainer/Dockerfilecontainer/server.mjs.gitignoretsconfig.json
wrangler.jsonc
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ch29-containers",
"main": "src/index.ts",
"compatibility_date": "2026-07-24",
"observability": { "enabled": true },
// A container is ALWAYS fronted by a Durable Object.
"containers": [
{
"class_name": "MediaContainer", // must match a DO class below
"image": "./container/Dockerfile",
"instance_type": "basic",
"max_instances": 5
}
],
// The DO binding. NOTE: what appears on `env` is `name`, NOT `class_name`.
"durable_objects": {
"bindings": [{ "name": "MEDIA", "class_name": "MediaContainer" }]
},
// Container-backed DOs must use new_sqlite_classes.
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["MediaContainer"] }]
}package.json
{
"name": "ch29-containers",
"private": true,
"type": "module",
"scripts": { "dev": "wrangler dev", "deploy": "wrangler deploy", "types": "wrangler types --env-interface CloudflareBindings" },
"dependencies": { "@cloudflare/containers": "^0.3.7" },
"devDependencies": { "wrangler": "^4.118.0", "typescript": "^5.9.2" }
}src/env.ts
import type { MediaContainer } from "./index";
/**
* `wrangler types` emits:
* MEDIA: DurableObjectNamespace /* MediaContainer *\/;
* -- the class name is a COMMENT, not a type parameter. So
* `getContainer(env.MEDIA, key)` fails to typecheck, because the helper wants
* a `DurableObjectNamespace<Container<Env>>`.
*
* This is the same "types do not flow" problem as chapters 18 and 26, and it
* has the same fix: re-declare the binding by hand.
*/
export interface AppEnv extends Omit<CloudflareBindings, "MEDIA"> {
MEDIA: DurableObjectNamespace<MediaContainer>;
}src/index.ts
import { Container, getContainer, getRandom, loadBalance } from "@cloudflare/containers";
import type { AppEnv } from "./env";
const t = async (fn: () => unknown): Promise<Record<string, unknown>> => {
const started = Date.now();
try {
const v = await fn();
return { ok: v === undefined ? "(undefined)" : v, ms: Date.now() - started };
} catch (e) {
return {
threwName: (e as Error).name,
threw: String((e as Error).message ?? e).slice(0, 300),
ms: Date.now() - started,
};
}
};
/**
* A container is ALWAYS fronted by a Durable Object. This class is both:
* - the DO that `env.MEDIA` points at, and
* - the `class_name` that `containers[0]` in wrangler.jsonc refers to.
*
* The three names in wrangler.jsonc are easy to confuse:
* containers[].class_name -> MediaContainer (this class)
* durable_objects.bindings[].class_name -> MediaContainer (must match)
* durable_objects.bindings[].name -> MEDIA (appears on env)
*/
export class MediaContainer extends Container<CloudflareBindings> {
// Must match EXPOSE / the listening port inside the image.
defaultPort = 8080;
// The library default is "10m" (DEFAULT_SLEEP_AFTER in the shipped source).
sleepAfter = "2m";
envVars = {
CH29_ROLE: "media",
CH29_BUILT_FOR: "chapter 29",
};
// The library default is `true` (enableInternet = true in the shipped
// source; the docs agree: "By default, a Container will allow internet
// access"). Setting it false is deny-by-default egress -- worth doing
// deliberately for anything processing untrusted input.
enableInternet = false;
// With enableInternet = false, this is the allowlist that punches through.
allowedHosts = ["r2.cloudflarestorage.com"];
override onStart(): void {
console.log(`[container] start id=${this.ctx.id.toString().slice(0, 8)}`);
}
override onStop(params: { exitCode: number; reason: string }): void {
console.log(`[container] stop exitCode=${params.exitCode} reason=${params.reason}`);
}
/**
* Widely mis-stated: the BASE implementation already calls `this.stop()`.
* From the shipped source:
*
* async onActivityExpired() {
* console.log('Activity expired, signalling container to stop');
* if (!this.container.running) return;
* await this.stop();
* }
*
* You only leak a container if you OVERRIDE this and forget to stop it.
* Note the super call below.
*/
override async onActivityExpired(): Promise<void> {
console.log("[container] activity expired; draining before stop");
await super.onActivityExpired();
}
override onError(error: unknown): unknown {
console.error("[container] error", error);
return error;
}
/** A plain RPC method, callable from the Worker without an HTTP hop. */
async describe(): Promise<unknown> {
return {
defaultPort: this.defaultPort,
sleepAfter: this.sleepAfter,
enableInternet: this.enableInternet,
envVarKeys: Object.keys(this.envVars ?? {}),
state: await this.getState(),
};
}
}
export default {
async fetch(request: Request, env: AppEnv): Promise<Response> {
const url = new URL(request.url);
const q = url.searchParams;
switch (url.pathname) {
case "/shape": {
const ns = env.MEDIA;
return Response.json({
bindingIsDurableObjectNamespace: typeof ns.idFromName === "function",
nsProto: Object.getOwnPropertyNames(Object.getPrototypeOf(ns)),
// Container extends DurableObject -- a container is not its own
// binding type. `env.MEDIA` is a DurableObjectNamespace.
containerHelpers: {
getContainer: typeof getContainer,
getRandom: typeof getRandom,
loadBalance: typeof loadBalance,
},
});
}
// One container per key. Same key -> same instance -> warm process.
case "/info": {
const stub = getContainer(env.MEDIA, q.get("key") ?? "default");
return Response.json(
await t(async () => {
const res = await stub.fetch(new Request("http://container/info"));
return await res.json();
}),
);
}
case "/ffmpeg": {
const stub = getContainer(env.MEDIA, q.get("key") ?? "default");
return Response.json(
await t(async () => {
const res = await stub.fetch(new Request("http://container/ffmpeg"));
return await res.json();
}),
);
}
case "/transcode": {
const stub = getContainer(env.MEDIA, q.get("key") ?? "default");
const s = q.get("s") ?? "2";
return Response.json(
await t(async () => {
const res = await stub.fetch(new Request(`http://container/transcode?s=${s}`));
return await res.json();
}),
);
}
// RPC straight to the DO -- no HTTP hop into the container.
case "/describe": {
const stub = getContainer(env.MEDIA, q.get("key") ?? "default");
return Response.json(await t(() => stub.describe()));
}
// Spread work over N instances instead of pinning to one.
case "/balanced": {
return Response.json(
await t(async () => {
const stub = await loadBalance(env.MEDIA, Number(q.get("n") ?? 3));
const res = await stub.fetch(new Request("http://container/info"));
return await res.json();
}),
);
}
default:
return new Response(
"/shape /info?key= /ffmpeg /transcode?s=2 /describe /balanced?n=3\n",
{ status: 404 },
);
}
},
} satisfies ExportedHandler<AppEnv>;container/Dockerfile
# A minimal HTTP server. No framework, no dependencies beyond Node itself,
# so the image stays small and the startup cost is honest.
FROM node:22-alpine
# ffmpeg is the classic reason to reach for a container: a native binary that
# cannot exist inside a V8 isolate.
RUN apk add --no-cache ffmpeg
WORKDIR /app
COPY server.mjs .
# The port here must match `defaultPort` in the Container subclass.
EXPOSE 8080
CMD ["node", "server.mjs"]container/server.mjs
import { createServer } from "node:http";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const exec = promisify(execFile);
const startedAt = Date.now();
let requests = 0;
const server = createServer(async (req, res) => {
requests++;
const url = new URL(req.url, "http://localhost");
res.setHeader("content-type", "application/json");
if (url.pathname === "/health") {
res.end(JSON.stringify({ ok: true }));
return;
}
if (url.pathname === "/info") {
res.end(
JSON.stringify({
// Proves this is a real Linux process, not an isolate.
node: process.version,
platform: process.platform,
arch: process.arch,
pid: process.pid,
uptimeMs: Date.now() - startedAt,
requestsServedByThisInstance: requests,
cpus: (await import("node:os")).cpus().length,
totalMemMB: Math.round((await import("node:os")).totalmem() / 1048576),
// Env vars injected by the Worker via `envVars`.
env: Object.fromEntries(
Object.entries(process.env).filter(([k]) => k.startsWith("CH29_")),
),
}),
);
return;
}
if (url.pathname === "/ffmpeg") {
try {
const { stdout } = await exec("ffmpeg", ["-version"]);
res.end(JSON.stringify({ version: stdout.split("\n")[0] }));
} catch (e) {
res.statusCode = 500;
res.end(JSON.stringify({ error: String(e).slice(0, 200) }));
}
return;
}
// The actual work: synthesise a tone and transcode it. Pure CPU, no network.
if (url.pathname === "/transcode") {
const seconds = Number(url.searchParams.get("s") ?? 2);
const t0 = Date.now();
try {
await exec("ffmpeg", [
"-f", "lavfi", "-i", `sine=frequency=440:duration=${seconds}`,
"-c:a", "libmp3lame", "-y", "/tmp/out.mp3",
]);
const { size } = await (await import("node:fs/promises")).stat("/tmp/out.mp3");
res.end(JSON.stringify({ seconds, bytes: size, ms: Date.now() - t0 }));
} catch (e) {
res.statusCode = 500;
res.end(JSON.stringify({ error: String(e).slice(0, 300) }));
}
return;
}
res.statusCode = 404;
res.end(JSON.stringify({ routes: ["/health", "/info", "/ffmpeg", "/transcode?s=2"] }));
});
server.listen(8080, () => console.log("ch29 container listening on 8080"));.gitignore
node_modules/
.wrangler/
worker-configuration.d.tstsconfig.json
{
"compilerOptions": {
"target": "esnext", "lib": ["esnext"], "module": "esnext",
"moduleResolution": "bundler", "types": ["./worker-configuration.d.ts"],
"strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
},
"include": ["src/**/*.ts", "worker-configuration.d.ts"]
}