将 0.12 的 createBackup() 和 restoreBackup() 替换为基于 DirectoryBackup、名称相同的 Durable Object 方法。Worker 使用相同选项调用它们,备份保留 0.12 的生存时间。DirectoryBackup 将目录作为压缩归档保存到同一个 BACKUP_BUCKET 绑定,并作为普通文件恢复。0.12 保存 SquashFS 镜像并将其挂载到目录上。0.12 创建的备份会在你第一次恢复时转换为 1.0 备份。
要保存整个文件系统而不是一个目录,请使用 Container 快照。更多信息请参阅 使用快照保存并恢复 sandbox。
你的 1.0 类已替换 0.12 Sandbox 类。本页中它是 MySandbox,具有 替换 Sandbox 类 中的 container getter 以及 ensureRunning() 和 startContainer() 方法。
本页替换 createBackup()、restoreBackup()、它们的选项以及备份错误。DirectoryBackup 通过 this.ctx.exports 到达 R2,在 compatibility date 2025-11-17 之前它是 undefined,除非你添加 enable_ctx_exports compatibility flag。
-
如果 Worker 尚未使用该包,请安装它:
npm i @cloudflare/sandboxyarn add @cloudflare/sandboxpnpm add @cloudflare/sandboxbun add @cloudflare/sandbox -
如果
Dockerfile尚未复制 helper 二进制,请将其复制到镜像中:Dockerfiledockerfile COPY --from=docker.io/cloudflare/sandbox:1.0.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim使用与已安装包版本匹配的
cloudflare/sandbox标签。 -
保留名为
BACKUP_BUCKET的 R2 绑定,并将其指向 0.12 写入的存储桶。使用预签名上传时,那就是BACKUP_BUCKET_NAME中的存储桶:{ "r2_buckets": [ { "binding": "BACKUP_BUCKET", "bucket_name": "my-sandbox-backups", }, ], }[[r2_buckets]] binding = "BACKUP_BUCKET" bucket_name = "my-sandbox-backups"删除 0.12 类之后,从变量中移除
BACKUP_BUCKET_NAME和BACKUP_BUCKET_ENDPOINT,并从密钥中移除R2_ACCESS_KEY_ID和R2_SECRET_ACCESS_KEY,除非存储桶挂载仍在使用它们。在 1.0 中,容器永远不会收到备份用的存储桶凭据。 -
将这些声明添加到
src/index.ts的顶层:src/index.tsts import { DirectoryBackup, type DirectoryBackupRecord, } from "@cloudflare/sandbox"; // Gives the Durable Object this.ctx.exports.DirectoryBackupGateway, // which moves each backup between the container and the bucket export { DirectoryBackupGateway } from "@cloudflare/sandbox"; // 0.12 kept a backup for three days unless you set ttl. const DEFAULT_TTL_SECONDS = 3 * 24 * 60 * 60; // The record your code stores in place of the 0.12 backup object. type Backup = DirectoryBackupRecord & { expiresAt: number }; // Restores read the expiry time from this object, because a caller // can change the expiresAt field of a record. const metaKey = (id: string) => `backups/${id}.meta.json`; // Like 0.12, refuse a backup that expires within a minute. const EXPIRY_MARGIN_MS = 60 * 1000; -
向
MySandbox添加DirectoryBackup字段:src/index.tsts export class MySandbox extends DurableObject<Env> { // ... private readonly backups = new DirectoryBackup( this.container, this.ctx.exports.DirectoryBackupGateway, { binding: "BACKUP_BUCKET", prefix: "backups/" }, ); }只要 Durable Object 在运行,
this.ctx.container就是同一个对象,因此一个DirectoryBackup对象可为 Durable Object 启动的每个容器服务。如果
startContainer()用interceptAllOutboundHttp()注册 catch-all,如 迁移出站规则 所述,请在startContainer()的try块中、在 catch-all 之前调用await this.backups.intercept()。否则 catch-all 会接收来自容器的备份流量,备份和恢复会以BACKUP_TRANSFER失败。已经带着 catch-all 运行的容器需要重启。请参阅intercept()。 -
替换
createBackup()。在 0.12 中,Worker 在 sandbox 上调用它:src/index.ts (0.12)ts const sandbox = getSandbox(env.Sandbox, "ada"); const backup = await sandbox.createBackup({ dir: "/workspace/project", excludes: ["node_modules"], ttl: 24 * 60 * 60, });在 1.0 中,向
MySandbox添加同名同选项的方法:src/index.ts (1.0)ts export class MySandbox extends DurableObject<Env> { // ... async createBackup(options: { dir: string; name?: string; excludes?: string[]; gitignore?: boolean; ttl?: number; }): Promise<Backup> { await this.ensureRunning(); const { excludes, ttl = DEFAULT_TTL_SECONDS, ...rest } = options; const backup = await this.backups.backup({ ...rest, // 0.12 matched each pattern at any depth in the directory. exclude: excludes?.map((pattern) => `**/${pattern}`), }); const expiresAt = Date.now() + ttl * 1000; await this.env.BACKUP_BUCKET.put( metaKey(backup.id), JSON.stringify({ expiresAt }), ); return { ...backup, expiresAt }; } }Worker 像以前一样在
getByName()返回的 stub 上调用它。它返回包含备份 ID、目录、大小、SHA-256 和过期时间的记录。将其存储在你的代码存储 0.12 备份对象的位置。记录允许调用者恢复该备份,因此请将记录放在 sandbox 中代码无法触及的位置。createBackup()还会将过期时间写入存储桶中的backups/<ID>.meta.json,就像 0.12 写入meta.json一样。记录的expiresAt字段仅用于显示。 -
替换
restoreBackup():src/index.tsts export class MySandbox extends DurableObject<Env> { // ... async restoreBackup(backup: DirectoryBackupRecord) { const meta = await this.env.BACKUP_BUCKET.get(metaKey(backup.id)); if (!meta) { throw new Error(`Backup ${backup.id} was not found`); } const { expiresAt } = await meta.json<{ expiresAt: number }>(); if (Date.now() + EXPIRY_MARGIN_MS > expiresAt) { throw new Error(`Backup ${backup.id} has expired`); } await this.ensureRunning(); await this.backups.restore(backup); return { success: true, dir: backup.dir, id: backup.id }; } }restoreBackup()从存储桶读取过期时间,而不是从记录读取,因此调用者无法通过更改expiresAt来恢复过期备份。来自另一个 sandbox 的记录也可以恢复,与 0.12 一样,restore()会对照记录中的 SHA-256 检查对象。restore()用备份中的文件替换该目录,因此备份未包含的文件(例如node_modules)在恢复后会消失。在 0.12 中,恢复的目录是随容器结束的挂载。在 1.0 中,文件留在容器文件系统中。工作目录位于该目录内的进程,在
restore()之后仍会保留旧的、已删除的目录。以cwd: "/workspace/project"启动的服务器就是这样的进程。恢复后请重启该进程,或让它按绝对路径打开文件。在 0.12 中,恢复会挂载到该路径上,因此这样的进程会看到已恢复的文件。 -
部署 Worker,然后通过 Worker 现有路由备份并恢复一个目录。
createBackup()返回类似这样的记录:{ "id": "52046bdb-afb2-4f38-a14c-70d48a45a56f", "dir": "/workspace/project", "size": 445, "sha256": "31ffdbb8a126e88fce0ff3b273a7db8aae1c933ba446118cf2319d8e015ebf91", "format": "tar+zstd/1", "expiresAt": 1790465279938 }使用该记录的
restoreBackup()返回{"success":true,"dir":"/workspace/project","id":"52046bdb-afb2-4f38-a14c-70d48a45a56f"}。存储的过期时间已过的备份会抛出Backup <ID> has expired,即使记录有更晚的expiresAt。
每个 0.12 名称都有这些替换项之一:
| 0.12 | 1.0 |
|---|---|
dir |
dir。0.12 只接受 /workspace、/home、/tmp、/var/tmp 和 /app 下的目录。DirectoryBackup 接受任何绝对路径,因此当请求选择目录时,请在代码中检查它。 |
name |
name,存储在记录和对象的自定义元数据中 |
ttl |
backups/<ID>.meta.json 中的过期时间。与 0.12 一样,过期备份会留在存储桶中,直到你用 this.backups.delete(backup) 删除它。同时删除其 .meta.json 对象。 |
excludes |
exclude 使用 .gitignore 语法。createBackup() 会为每个模式添加 **/ 前缀,因为包含 / 的 .gitignore 模式(例如 dist/cache)只会从目录顶层开始匹配。 |
gitignore |
gitignore。它应用目录内的 .gitignore 文件和 .git/info/exclude,镜像不需要 git。 |
localBucket, multipart, compression |
移除它们。每个备份都分片通过 R2 绑定。 |
BackupNotFoundError |
当 .meta.json 对象缺失时,restoreBackup() 会抛出的错误;或者当备份对象缺失时,抛出代码为 BACKUP_NOT_FOUND 的 SandboxBackupError |
BackupExpiredError |
你的 restoreBackup() 抛出的错误 |
InvalidBackupConfigError |
TypeError |
BackupCreateError, BackupRestoreError |
Linux 错误(例如目录不存在)为 SandboxFileError,或代码为 BACKUP_TRANSFER 或 BACKUP_INTEGRITY 的 SandboxBackupError |
有关每个错误,请参阅 DirectoryBackup 错误。有关备份保留的内容,请参阅 备份包含的内容。
0.12 将每个备份作为存储桶中的 backups/<ID>/data.sqsh 和 backups/<ID>/meta.json 存储,预签名上传和 localBucket: true 都是如此。部署 1.0 后这些 R2 对象仍留在存储桶中,你的代码存储的 0.12 备份对象也仍在原处。要保留备份,请在代码第一次恢复时转换它:将 SquashFS 镜像解压到目录中,然后用 DirectoryBackup 备份该目录。
-
将
squashfs-tools添加到镜像:Dockerfiledockerfile RUN apt-get update && apt-get install -y --no-install-recommends squashfs-tools && rm -rf /var/lib/apt/lists/* -
向
MySandbox添加转换 0.12 备份对象的方法:src/index.tsts // 0.12 restored only backups with a UUID, into one of these directories. const BACKUP_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const BACKUP_ROOTS = ["/workspace", "/home", "/tmp", "/var/tmp", "/app"]; function checkOldBackup(old: { id: string; dir: string }) { const segments = old.dir.split("/").slice(1); const inRoot = BACKUP_ROOTS.some( (root) => old.dir === root || old.dir.startsWith(`${root}/`), ); if ( !BACKUP_ID.test(old.id) || !inRoot || segments.some((part) => part === "" || part === "." || part === "..") || old.dir.includes("\0") ) { throw new TypeError(`Invalid 0.12 backup ${old.id} for ${old.dir}`); } } export class MySandbox extends DurableObject<Env> { // ... async convert(old: { id: string; dir: string }): Promise<Backup> { checkOldBackup(old); const meta = await this.env.BACKUP_BUCKET.get(`backups/${old.id}/meta.json`); const image = await this.env.BACKUP_BUCKET.get(`backups/${old.id}/data.sqsh`); if (!meta || !image) { throw new Error(`Backup ${old.id} was not found`); } const { createdAt, ttl } = await meta.json<{ createdAt: string; ttl: number }>(); const expiresAt = Date.parse(createdAt) + ttl * 1000; if (Date.now() + EXPIRY_MARGIN_MS > expiresAt) { await image.body.cancel(); throw new Error(`Backup ${old.id} has expired`); } await this.ensureRunning(); // Refuses a path through a symbolic link. unsquashfs needs the whole // image on disk, so it goes to /var/tmp first. The files replace the // directory only after they are all extracted. const script = '[ "$(realpath -m -- "$2")" = "$2" ] || exit 2;' + ' mkdir -p -- "$(dirname -- "$2")" && rm -rf -- "$2.convert" &&' + ' cat > "$1" && unsquashfs -no-progress -d "$2.convert" "$1" > /dev/null &&' + ' rm -rf -- "$2" && mv -- "$2.convert" "$2";' + ' code=$?; rm -rf -- "$1" "$2.convert"; exit $code'; const process = await this.container.exec( ["sh", "-c", script, "sh", `/var/tmp/${old.id}.sqsh`, old.dir], { stdin: image.body }, ); const output = await process.output(); if (output.exitCode !== 0) { throw new Error(new TextDecoder().decode(output.stderr)); } const backup = await this.backups.backup({ dir: old.dir }); await this.env.BACKUP_BUCKET.put( metaKey(backup.id), JSON.stringify({ expiresAt }), ); return { ...backup, expiresAt }; } }convert()会删除并替换old.dir,因此会像 0.12 在恢复前那样先检查备份。ID 必须是 UUID,目录必须是 0.12 接受的某个目录下的绝对路径,且路径段不得为空、.或..。sandbox 中的代码可以用符号链接替换目录,而rm -rf会跟随路径中的链接,因此如果路径解析到其他位置,脚本会以代码2退出。请从代码存储的 0.12 备份对象中获取old,不要从请求中获取。转换后的备份会在自己的
.meta.json对象中保留 0.12 为原备份设置的过期时间。容器需要同时为镜像、解压后的文件和目录中已有文件预留磁盘空间。在lite实例上,Alpine 3.23 中的squashfs-tools4.7 及更高版本会报错:FATAL ERROR: Requested memory size too large。对于这些版本,请在unsquashfs命令中添加-mem 64M。Debian 13 安装的是 4.6.1 版本,使用默认设置即可在lite上解压。 -
在你的代码用 0.12 创建的对象调用
restoreBackup()的地方,改为调用convert()。0.12 对象没有format字段。convert()会把文件留在目录中,因此它也完成了恢复。将它返回的记录存储到 0.12 对象的位置。 -
存储新记录后,删除两个 0.12 对象:
src/index.tsts await this.env.BACKUP_BUCKET.delete([ `backups/${old.id}/data.sqsh`, `backups/${old.id}/meta.json`, ]);
转换后的目录会保留文件、空目录、符号链接、硬链接和权限。修改时间只保留整秒,因为 SquashFS 不存储秒的小数部分。如果 unsquashfs 失败,目录中的文件会保留,再次调用 convert() 会重试转换。