跳到內容

ch29-containers

取得並執行

這個範例可以獨立 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

說明

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 routes
src/env.ts the hand-written binding type (see below)
container/ Dockerfile + a tiny Node server with ffmpeg
wrangler.jsonc the three-names wiring
Terminal window
npm install
npx wrangler types --env-interface CloudflareBindings
npx wrangler dev --port 9036 # requires a running Docker daemon

A container is always fronted by a Durable Object. There is no container binding type.

ConfigValueMeaning
containers[].class_nameMediaContainerwhich DO class manages the container
durable_objects.bindings[].class_nameMediaContainermust match exactly
durable_objects.bindings[].nameMEDIAwhat appears on env
Terminal window
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:

MisconfigurationResult
DO class_name typoYour Worker depends on the following Durable Objects, which are not exported in your entrypoint file: SomethingElse.
new_classes instead of new_sqlite_classespasses silently
no durable_objects binding at allpasses 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.

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/..." : Forbidden
ERROR: failed to build: failed to solve: node:22-alpine: failed to resolve source metadata

So --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.

error TS2345: Argument of type 'DurableObjectNamespace<undefined>' is not assignable
to 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>;
}

Read from the shipped source of @cloudflare/containers@0.3.7:

const DEFAULT_SLEEP_AFTER = '10m'; // lib/container.js:20
enableInternet = 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();
}
  • onActivityExpired already stops the container. You only leak one if you override it and forget. This example overrides it and calls await super.onActivityExpired().
  • enableInternet defaults to true, not false. The docs agree: “By default, a Container will allow internet access.” Turn it off deliberately and use allowedHosts when handling untrusted input.
  • dev and standard are aliases, not removed. instance_type: "dev" produces WARNING - 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": false

The 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.

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.

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.

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.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.ts

tsconfig.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"]
}