@cloudflare/sandbox 中的 DirectoryBackup 类会将一个目录保存为 R2 bucket 中的一个对象,并将其恢复为普通文件。备份可在实例停止或你部署新镜像后恢复。R2 会保留每个备份,直到你删除它。本例中,目录是 /workspace/project 中的项目。
- 带有 Durable Object 的 Worker,该 Durable Object 使用 Durable Object 调度策略 启动 container。要创建一个,请参阅 运行 Linux 命令。
当您运行 wrangler deploy 时,必须在本地运行 Docker。对于大多数人来说,安装 Docker 的最佳方式是按照安装 Docker Desktop 的文档 ↗进行操作。其他工具(如 Colima ↗)也可能有效。
您可以通过在终端中运行 docker info 命令来检查 Docker 是否正常运行。如果 Docker 正在运行,该命令将成功执行。如果 Docker 未运行,docker info 命令将挂起或返回包含 "Cannot connect to the Docker daemon" 消息的错误。
-
安装软件包:
npm i @cloudflare/sandboxyarn add @cloudflare/sandboxpnpm add @cloudflare/sandboxbun add @cloudflare/sandbox -
将 helper 二进制文件复制到镜像中。如果项目没有
Dockerfile,请在项目根目录创建一个:Dockerfiledockerfile FROM node:24-trixie-slim COPY --from=docker.io/cloudflare/sandbox:1.0.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim RUN mkdir /workspace CMD ["sleep", "infinity"]使用与已安装软件包版本匹配的
cloudflare/sandbox标签。将FROM行替换为命令所需的基础镜像。 -
为备份创建 bucket:
npx wrangler r2 bucket create sandbox-backupsyarn wrangler r2 bucket create sandbox-backupspnpm wrangler r2 bucket create sandbox-backups如果 Wrangler 提议将该 bucket 添加到配置中,请回答 no。下一步会添加绑定。
-
在
wrangler.jsonc中添加nodejs_compat标志,将Dockerfile构建为containers条目中的具名镜像,并绑定该 bucket:{ "compatibility_flags": ["nodejs_compat"], "containers": [ { "class_name": "MyContainer", "scheduling_policy": "durable_object", "images": { "workspace": { "dockerfile": "./Dockerfile", }, }, }, ], "r2_buckets": [ { "binding": "BACKUPS", "bucket_name": "sandbox-backups", }, ], }compatibility_flags = [ "nodejs_compat" ] [[containers]] class_name = "MyContainer" scheduling_policy = "durable_object" [containers.images.workspace] dockerfile = "./Dockerfile" [[r2_buckets]] binding = "BACKUPS" bucket_name = "sandbox-backups"然后生成类型:
npx wrangler typesyarn wrangler typespnpm wrangler types -
在
src/index.ts中导出DirectoryBackupGateway,并更新MyContainer类,使其方法启动workspace镜像并创建DirectoryBackup:src/index.jsjs import { DirectoryBackup } from "@cloudflare/sandbox"; import { DurableObject } from "cloudflare:workers"; export { DirectoryBackupGateway } from "@cloudflare/sandbox"; const project = "/workspace/project"; const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; export class MyContainer extends DurableObject { container; backups; constructor(ctx, env) { super(ctx, env); const container = ctx.container; if (!container) { throw new Error("No container is configured"); } this.container = container; this.backups = new DirectoryBackup( container, ctx.exports.DirectoryBackupGateway, { binding: "BACKUPS", prefix: "backups/" }, ); // A restarted Durable Object sets the timeout again. if (container.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } async startSandbox() { if (!this.container.running) { this.container.start({ image: this.container.images.workspace, enableInternet: false, }); await this.container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } } }src/index.tsts import { DirectoryBackup, type DirectoryBackupRecord, } from "@cloudflare/sandbox"; import { DurableObject } from "cloudflare:workers"; export { DirectoryBackupGateway } from "@cloudflare/sandbox"; const project = "/workspace/project"; const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; export class MyContainer extends DurableObject<Env> { private readonly container: Container; private readonly backups: DirectoryBackup; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); const container = ctx.container; if (!container) { throw new Error("No container is configured"); } this.container = container; this.backups = new DirectoryBackup( container, ctx.exports.DirectoryBackupGateway, { binding: "BACKUPS", prefix: "backups/" }, ); // A restarted Durable Object sets the timeout again. if (container.running) { void ctx.blockConcurrencyWhile(() => container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS), ); } } private async startSandbox(): Promise<void> { if (!this.container.running) { this.container.start({ image: this.container.images.workspace, enableInternet: false, }); await this.container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS); } } }container 会通过
DirectoryBackupGateway发送备份,后者将其写入BACKUPSbucket。container 永远不会收到 R2 凭据,并且只能访问正在进行的操作的对象。构造函数会为 Durable Object 创建一个
DirectoryBackup对象,因为只要 Durable Object 在运行,this.ctx.container就会保持为同一个对象。 -
向
MyContainer添加向项目中文件追加一行并返回该文件的方法,以便检查恢复会带回什么:src/index.tsts export class MyContainer extends DurableObject<Env> { // ... async note(line?: string) { await this.startSandbox(); const script = line ? `mkdir -p ${project} && printf "%s\n" "$1" >> ${project}/notes.txt && cat ${project}/notes.txt` : `cat ${project}/notes.txt`; const process = await this.container.exec(["sh", "-c", script, "sh", line ?? ""]); const output = await process.output(); return new TextDecoder().decode(output.stdout); } } -
向
MyContainer添加备份项目并存储备份记录的方法:src/index.tsts export class MyContainer extends DurableObject<Env> { // ... async backup() { await this.startSandbox(); const backup = await this.backups.backup({ dir: project }); await this.ctx.storage.put(`backup:${backup.id}`, backup); return { id: backup.id, size: backup.size }; } }backup()会返回包含对象 ID、大小和 SHA-256 的记录。该软件包不维护备份列表。Durable Object 会存储记录并按 ID 查找,因此请求无法将恢复指向 bucket 中的另一个对象。调用
backup()前请停止写入该目录的命令。备份期间更改的文件可能一部分是旧的、一部分是新的。 -
向
MyContainer添加查找记录并恢复它的方法:src/index.tsts export class MyContainer extends DurableObject<Env> { // ... async restore(id: string) { const backup = await this.ctx.storage.get<DirectoryBackupRecord>( `backup:${id}`, ); if (!backup) { return false; } await this.startSandbox(); await this.backups.restore(backup); return true; } }restore()会替换该目录,并移除备份中没有的文件。在替换任何内容之前,它会对照记录中的 SHA-256 检查对象。当restore()抛出SandboxFileError或SandboxBackupError时,它会保持目录原样。如果对象已不存在,restore()会抛出代码为BACKUP_NOT_FOUND的SandboxBackupError。如果对象与记录不匹配,代码为BACKUP_INTEGRITY。每个错误请参阅 DirectoryBackup 错误。 -
将 Worker 的默认导出替换为写入笔记、备份项目以及恢复备份的路由:
src/index.jsjs export default { async fetch(request, env) { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/(notes|backups)(?:\/([0-9a-f-]{36})\/restore)?$/.exec( url.pathname, ); if (!match) { return new Response("Not found", { status: 404 }); } const [, name, resource, id] = match; const sandbox = env.MY_CONTAINER.getByName(name); if (resource === "notes" && !id) { const line = request.method === "POST" ? await request.text() : undefined; return new Response(await sandbox.note(line)); } if (resource === "backups" && request.method === "POST") { if (!id) { return Response.json(await sandbox.backup(), { status: 201 }); } return (await sandbox.restore(id)) ? new Response(null, { status: 204 }) : new Response("Backup not found", { status: 404 }); } return new Response("Method not allowed", { status: 405 }); }, };src/index.tsts export default { async fetch(request: Request, env: Env): Promise<Response> { const url = new URL(request.url); const match = /^\/sandboxes\/([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\/(notes|backups)(?:\/([0-9a-f-]{36})\/restore)?$/.exec( url.pathname, ); if (!match) { return new Response("Not found", { status: 404 }); } const [, name, resource, id] = match; const sandbox = env.MY_CONTAINER.getByName(name); if (resource === "notes" && !id) { const line = request.method === "POST" ? await request.text() : undefined; return new Response(await sandbox.note(line)); } if (resource === "backups" && request.method === "POST") { if (!id) { return Response.json(await sandbox.backup(), { status: 201 }); } return (await sandbox.restore(id)) ? new Response(null, { status: 204 }) : new Response("Backup not found", { status: 404 }); } return new Response("Method not allowed", { status: 405 }); }, } satisfies ExportedHandler<Env>;备份会保存目录中的每个文件,包括 sandbox 中写入的凭据。请先认证调用方,以免其他人备份或恢复另一个 sandbox 的文件。更多信息请参阅 Sandbox 安全。
-
部署你的 Worker:
npx wrangler deployyarn wrangler deploypnpm wrangler deploy -
在名为
ada的 sandbox 中写入笔记并备份项目。将示例 hostname 替换为 Wrangler 打印的workers.devURL:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/notes --data "Keep this line." curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/backups --request POST备份会返回其 ID 和字节大小,例如
{"id":"<BACKUP_ID>","size":217}。 -
写入第二条笔记,恢复备份,然后读取笔记。将
<BACKUP_ID>替换为响应中的 ID:curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/notes --data "Lose this line." curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/backups/<BACKUP_ID>/restore --request POST curl https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/notes恢复会以
204响应,笔记会回到备份时的状态:Keep this line.
要排除文件,请传入相对于该目录的 .gitignore 语法模式。node_modules/ 会匹配任意深度的同名目录,/build 只匹配顶层:
const backup = await this.backups.backup({
dir: project,
exclude: ["node_modules/", "/build"],
// Also apply the `.gitignore` files in the directory and `.git/info/exclude`,
// without `git` in the image
gitignore: true,
});要删除备份,请删除其对象及其记录:
export class MyContainer extends DurableObject<Env> {
// ...
async deleteBackup(id: string) {
const backup = await this.ctx.storage.get<DirectoryBackupRecord>(
`backup:${id}`,
);
if (backup) {
// `delete()` does not need a running container
await this.backups.delete(backup);
await this.ctx.storage.delete(`backup:${id}`);
}
}
}使 backups/ 下对象过期的 对象生命周期规则 也会删除备份。该规则会留下它们的记录,恢复其中一个备份会抛出 BACKUP_NOT_FOUND。
失败的备份不会留下对象。如果 Durable Object 在备份期间重启,未完成的 multipart upload 会留在 bucket 中,直到 bucket 的默认生命周期规则在七天后中止它。
- DirectoryBackup API:备份保留什么、每个 container 一次一个操作、取消以及每个错误。
- 使用 snapshot 保存并恢复 sandbox:保留整个文件系统而不是一个目录。
- 挂载 R2 bucket:读写其他系统使用的文件。