Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/container-instance-backend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

Add a container backend for durable-object-scheduled containers
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ jobs:
- name: assets
workspace: "@example/computer-assets"
path: examples/assets
- name: container
workspace: "@example/computer-container"
path: examples/container
- name: container-legacy
workspace: "@example/computer-container-legacy"
path: examples/container-legacy
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ for setup, build, and test instructions.
The [`examples/`](examples) directory holds runnable consumers of the
public surface. Each is a Worker workspace with its own README.

- [`examples/container-legacy`](examples/container-legacy) — runs `computerd` inside a
container, mounts a workspace, and talks to a Durable Object over
capnweb. A `write` / `read` / `exec` HTTP surface.
- [`examples/container`](examples/container) — runs `computerd` inside a
container the Durable Object schedules itself, mounts a workspace, and
talks to the object over capnweb. A `write` / `read` / `exec` HTTP
surface. The launch names the image and the instance size, because
`scheduling_policy: "durable_object"` moves both out of the config.
- [`examples/container-legacy`](examples/container-legacy) — the same
surface against a container the platform schedules and sizes from the
`containers` block.
- [`examples/worker-shell`](examples/worker-shell) — same HTTP surface as the
container example, but the shell runs [just-bash](https://github.com/vercel-labs/just-bash)
in a Dynamic Worker loaded through `env.LOADER`. No container.
Expand Down
36 changes: 24 additions & 12 deletions docs/01_vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,29 @@ container, not on `WorkspaceOptions`).

```ts
import { Workspace } from "@cloudflare/computer";
import { LegacyContainerBackend } from "@cloudflare/computer/backends/container-legacy";

new Workspace({
storage: ctx.storage,
backends: [
new LegacyContainerBackend({
container: () => this,
workspace: { binding: "ContainerExample", id: ctx.id.toString() },
}),
],
});
import {
ContainerBackend,
withWorkspaceContainer,
} from "@cloudflare/computer/backends/container";
import { DurableObject } from "cloudflare:workers";

class WorkspaceHost extends withWorkspaceContainer(class extends DurableObject<Env> {}) {
readonly backend = new ContainerBackend({
container: () => this,
workspace: { binding: "WorkspaceHost", id: this.ctx.id.toString() },
name: "app",
instance: "standard-2",
});

readonly workspace = new Workspace({
storage: this.ctx.storage,
backends: [this.backend],
});

override fetch(request: Request): Promise<Response> {
return this.backend.handleFetch(request);
}
}
```

`backends` is optional. Omit it to construct a filesystem-only
Expand Down Expand Up @@ -126,7 +138,7 @@ by the in-image `FUSE_MOUNT` env var (`auto` by default; see doc 07).
On Cloudflare Containers `/dev/fuse` is exposed and the real kernel
FUSE backend mounts; under `wrangler dev` it isn't, and `auto` falls
back to the userspace shim. Either way the in-container view is a
live mirror of the DO-side VFS. Earlier revisions of `LegacyContainerBackend`
live mirror of the DO-side VFS. Earlier revisions of the container backend
pinned `DISABLE_FUSE=1`, which produced a degraded mode where:

- The in-container filesystem at `/workspace` is the container's own
Expand Down
81 changes: 39 additions & 42 deletions docs/07_injected_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ npm run build:bin --workspace @cloudflare/computerd
# → artifacts/computerd/computerd-macos-x64
```

`examples/container-legacy/Dockerfile` is the canonical recipe for
staging the binary into a container image.
`examples/container/Dockerfile` is the canonical recipe for staging the
binary into a container image.

## Responsibilities

Expand Down Expand Up @@ -65,7 +65,7 @@ The capnweb bootstrap interface is **`WorkspaceRPC`** (defined in

## Installing into your sandbox image

The canonical recipe is `examples/container-legacy/Dockerfile`:
The canonical recipe is `examples/container/Dockerfile`:

```dockerfile
FROM --platform=linux/amd64 debian:stable-slim
Expand Down Expand Up @@ -118,46 +118,43 @@ Provider-agnostic shape — three steps, in order:

### Cloudflare Containers specifics

`LegacyContainerBackend` (`packages/computer/src/backends/container-legacy/cloudflare-container.ts`)
wires it like this:

1. **Start.** `LegacyWorkspaceContainerAPI.start({ env, enableInternet })`,
which reaches the Cloudflare Containers API — not the
`@cloudflare/sandbox` SDK. There is no process-name registry, no
`startProcess`/`getProcess`, and no `node /app/...` command (the
container's `ENTRYPOINT` runs `computerd` directly). `containerEnv`
pins `PORT=8080` and lets the image's own `FUSE_MOUNT` value
(typically `auto`) win, and the API adds `RPC_CLIENT_SECRET`.

Neither the environment nor the internet flag can be changed on a
running container, so the launch records both and a container found
already running is only adopted when it matches. Otherwise it is
relaunched, which is what keeps a warm pool from handing a workspace
a container configured for something else. A container started
outside this API has no record and is relaunched too.
Cloudflare Computer has one backend for each container scheduling policy.

`ContainerBackend` is the default. It works with containers that the durable
object schedules, configured with `scheduling_policy: "durable_object"` and
an `images` map. Each launch selects an image from
`ctx.container.images`. The backend can also request an `instance` size and
pass options such as `entrypoint`, `labels`, and snapshot settings to
`container.start()`.

`LegacyContainerBackend` works with containers that the platform schedules.
The containers block chooses the image and instance size, so this backend
passes only the environment and internet setting when it starts the
container.

Both backends use the same connection flow:

1. **Start the container.** The image's `ENTRYPOINT` runs `computerd`
directly. The backend sets `PORT=8080`, preserves the image's
`FUSE_MOUNT` setting, and adds `RPC_CLIENT_SECRET`. It records the launch
settings so it can reject or replace a running container with the wrong
configuration.
2. **Wire egress.** `container.interceptOutboundHttp(egressHost, egress)`
routes outbound HTTP from the container at `egressHost` back to a
Worker `Fetcher` the DO controls.
3. **Probe.** `container.getTcpPort(containerPort).fetch("/health", { method: "HEAD" })`,
repeated until it returns `200`.
4. **Invert the WebSocket.** The DO arms an upgrade slot
(`#armUpgrade`) and then `POST`s to `/connect` on the container
(`#postConnect`). The request names the egress base and both paths,
so `computerd` polls `base + health` and then dials `base + api`;
the daemon assembles no paths of its own. Because the egress is
intercepted,
that outbound dial loops back to the DO's `handleFetch()`, which
accepts the upgrade and resolves the in-flight `#pendingUpgrade`.
The capnweb session then runs over that socket. **The WebSocket
carrier is inverted** versus a naive "host dials into container"
model.

Sharp edges actually present in `cloudflare-container.ts`:

- `#armUpgrade` must be set up *before* `#postConnect`, because `computerd`
can dial back before the `POST /connect` response returns.
- The container host records each monitored generation's exit reason. The dead container closes its WebSocket, and `fetchPort()` also short-circuits later requests with a transport error; either path invalidates the matching Workspace handle.
- **Reconnect replaces the whole session.** If the WebSocket dies, `Workspace` invalidates and closes the matching backend handle, then calls `LegacyContainerBackend.connect()` again. The replacement runs the complete start, egress-interception, health, `/connect`, and reverse-WebSocket sequence; the backend never splices a new carrier into the dead capnweb session. Replay-safe sync and process lifecycle operations get one retry. Command spawn is retried only when no request was dispatched.
routes outbound HTTP from the container back to a Worker `Fetcher` owned
by the durable object.
3. **Probe health.** The backend sends `HEAD /health` through the
container's TCP port until `computerd` responds.
4. **Open the WebSocket.** The backend prepares an upgrade slot before it
posts to `/connect`. `computerd` then dials the intercepted egress URL,
which routes the upgrade back to `handleFetch()`. The capnweb session
runs over that WebSocket.

A reconnect creates a complete new session. If the WebSocket closes,
`Workspace` drops the old backend handle and calls the selected backend's
`connect()` method again. The replacement starts or adopts the container,
checks health, and repeats the `/connect` handshake. Sync and process
lifecycle operations get one retry when replay is safe. A command is only
retried when the request was not dispatched.

## Environment variables

Expand Down
26 changes: 15 additions & 11 deletions docs/11_lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ lifetime policy. From the DO's perspective:
`computerd` is a long-lived process. It outlives DO restarts — the
`Container.monitor()` promise resolves only when the container itself
exits, and the backend's `#monitoring` flag drops the cached handle at
that point so the next call rebuilds from scratch (see the container host and backend implementations under `packages/computer/src/backends/container-legacy/`).
that point so the next call rebuilds from scratch. The primary implementation
lives under `packages/computer/src/backends/container/`; the
platform-scheduled variant lives under
`packages/computer/src/backends/container-legacy/`.

When `computerd` runs with its default in-memory store, the two sides
differ: the **container's VFS lasts only as long as the process**,
Expand Down Expand Up @@ -168,11 +171,13 @@ the `close` callback, the session is gone.

### Where capnweb attaches in our code

On the DO side: `newWebSocketRpcSession(ws)` in
`LegacyContainerBackend.connect()` in `packages/computer/src/backends/container-legacy/cloudflare-container.ts`.
This installs `addEventListener("message", ...)` on the accepted
WebSocket, which means **the DO must be alive in memory to receive
frames**. There is no hibernation-aware variant today.
On the durable object side, `ContainerBackend.connect()` calls
`newWebSocketRpcSession(ws)` in
`packages/computer/src/backends/container/container-backend.ts`.
`LegacyContainerBackend` uses the same session setup. This installs
`addEventListener("message", ...)` on the accepted WebSocket, which means
**the durable object must be alive in memory to receive frames**. There is
no hibernation-aware variant today.

On the container side, `acceptWebSocketSession(ws, rpc)` is attached by the inbound upgrade and outbound `/connect` paths in `packages/computerd/src/cli/computerd.ts`. Both attach to a `ws`
package WebSocket and require the `computerd` process to be live.
Expand Down Expand Up @@ -293,11 +298,10 @@ prove no unbounded growth under sustained workloads.

> [!NOTE]
> This section describes a target architecture, not shipped code.
> Today's `LegacyContainerBackend` uses `server.accept()`, which
> is **not** the hibernation API. The DO stays in memory for the
> lifetime of the WebSocket. Enabling hibernation requires changes
> across capnweb and the backend; the work is sketched here so the
> direction is clear.
> Today's container backends use `server.accept()`, which is **not** the
> hibernation API. The durable object stays in memory for the lifetime of
> the WebSocket. Enabling hibernation requires changes across capnweb and
> both backends; the work is sketched here so the direction is clear.

### What hibernation gives us

Expand Down
10 changes: 6 additions & 4 deletions docs/12_worker_backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell";

## When to reach for it

The container backend (`@cloudflare/computer/backends/container-legacy`)
gives you a real Linux environment with arbitrary binaries on
`$PATH`, optional network access, and a full POSIX filesystem. It costs a
container per session and a real roundtrip on every filesystem op.
The primary container backend (`@cloudflare/computer/backends/container`)
gives you a real Linux environment with arbitrary binaries on `$PATH`,
optional network access, and a full POSIX filesystem. It costs a container
per session and a real roundtrip on every filesystem operation. Use
`@cloudflare/computer/backends/container-legacy` instead when the platform
schedules and sizes the container.

The worker backend trades the real environment for a Workers
isolate that boots instantly, scales out cheaply, and has no
Expand Down
35 changes: 21 additions & 14 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ The package ships several entrypoints:
| Entrypoint | Purpose |
| --- | --- |
| `@cloudflare/computer` | The Workspace wrapper, first-class `workspace.runtime`, stub types, the R2 mount, and proxy classes. |
| `@cloudflare/computer/backends/container-legacy` | `LegacyContainerBackend` and `withLegacyWorkspaceContainer`. Pulls in the computerd / capnweb sync plumbing. |
| `@cloudflare/computer/backends/container` | `ContainerBackend` and `withWorkspaceContainer`, for a container the durable object schedules (`scheduling_policy: "durable_object"`). Same sync plumbing; the launch names the image and the instance size. |
| `@cloudflare/computer/backends/container-legacy` | `LegacyContainerBackend` and `withLegacyWorkspaceContainer`, for a container the platform schedules and sizes from the containers block. |
| `@cloudflare/computer/backends/worker-shell` | `WorkerShellBackend` and the bundled just-bash command runtime. |
| `@cloudflare/computer/backends/worker-javascript` | `WorkerJavaScriptBackend`, configured libraries, durable relative imports, `node:fs/promises`, and trusted `ws:git` / `ws:artifacts`. |
| `@cloudflare/computer/git` | Opt-in isomorphic-git glue for working with checkouts inside the workspace. Bundled lazily, with `pako` replaced by Workers `node:zlib`, and kept out of the default `@cloudflare/computer` graph. |
Expand All @@ -58,7 +59,7 @@ Wire types shared with the in-container service live in the sibling package `@cl
### Sandbox container image

The container needs the `computerd` daemon alongside a FUSE runtime. The
simplest pattern, used by [`examples/container-legacy/Dockerfile`](../examples/container-legacy/Dockerfile),
simplest pattern, used by [`examples/container/Dockerfile`](../examples/container/Dockerfile),
copies the prebuilt binary out of the public GHCR image and into a thin
Debian base:

Expand Down Expand Up @@ -87,33 +88,39 @@ To build the binary from source instead, run `npm run build:bin
`artifacts/computerd/computerd-linux-x64`, then `COPY` that into the
image.

`computerd`'s own default port is `45678`; the Cloudflare container backend pins the in-image listener to `8080`, which is what `examples/container-legacy/` uses. See [07. Injected Service](./07_injected_service.md) for the env vars (`PORT`, `MOUNT_POINT`, `FUSE_MOUNT`, `EXEC_LOG_MAX_BYTES`) and the reverse-dial boot sequence.
`computerd`'s own default port is `45678`; the Cloudflare container backend pins the in-image listener to `8080`, which is what [`examples/container`](../examples/container) uses. See [07. Injected Service](./07_injected_service.md) for the env vars (`PORT`, `MOUNT_POINT`, `FUSE_MOUNT`, `EXEC_LOG_MAX_BYTES`) and the reverse-dial boot sequence.

## Example

```ts
import { Workspace } from "@cloudflare/computer";
import {
LegacyContainerBackend,
withLegacyWorkspaceContainer,
} from "@cloudflare/computer/backends/container-legacy";
ContainerBackend,
withWorkspaceContainer,
} from "@cloudflare/computer/backends/container";
import { DurableObject } from "cloudflare:workers";

export class Agent extends withLegacyWorkspaceContainer(class extends DurableObject<Env> {}) {
export class Agent extends withWorkspaceContainer(class extends DurableObject<Env> {}) {
readonly backend = new ContainerBackend({
container: () => this,
workspace: { binding: "Agent", id: this.ctx.id.toString() },
name: "app",
instance: "standard-2",
});

readonly workspace = new Workspace({
storage: this.ctx.storage, // DO storage → VFS lives here
backends: [
new LegacyContainerBackend({
container: () => this,
workspace: { binding: "Agent", id: this.ctx.id.toString() },
}),
],
storage: this.ctx.storage, // Durable Object storage → VFS lives here
backends: [this.backend],
});

async initialize() {
await this.workspace.ready();
await this.workspace.fs.mkdir("/workspace", { recursive: true });
}

override fetch(request: Request): Promise<Response> {
return this.backend.handleFetch(request);
}
}
```

Expand Down
3 changes: 3 additions & 0 deletions examples/container/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.wrangler/
build/
node_modules/
43 changes: 43 additions & 0 deletions examples/container/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Container image for the container example.
#
# Pulls the computerd binary out of the public GHCR image. That image is
# a single layer over `scratch` whose only contents are the SEA
# binary at /usr/local/bin/computerd; we COPY it into a slim debian
# runtime below. The :VERSION tag is rewritten by the changesets
# Version Packages PR through .github/changeset-version.mjs.
#
# computerd mounts a FUSE filesystem at MOUNT_POINT so exec'd commands
# see the same VFS the RPC surface reads and writes. With
# FUSE_MOUNT=auto (below) the same image works in both directions:
# Cloudflare Containers expose /dev/fuse to the workload, so the
# real FUSE backend mounts; `wrangler dev` doesn't, so computerd falls
# back to the userspace shim transparently.


FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.3.1 AS computerd

FROM debian:stable-slim

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
fuse3 libfuse2t64 ca-certificates curl gnupg git \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*

COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd

# computerd's defaults: HTTP+WS on :8080, FUSE mount on MOUNT_POINT.
# FUSE_MOUNT=auto picks real FUSE on Cloudflare Containers (where
# /dev/fuse is exposed) and the userspace shim under wrangler dev.
ENV PORT=8080
ENV MOUNT_POINT=/workspace
ENV FUSE_MOUNT=auto
EXPOSE 8080

ENTRYPOINT ["/usr/local/bin/computerd"]
Loading
Loading