跳转到内容
本站为社区维护的非官方中文翻译,与 Cloudflare, Inc. 无关联。本站不提供账户登录,请勿在任何非官方站点输入您的 Cloudflare 账户信息。官方文档:developers.cloudflare.com

迁移备份

最后更新 查看 MarkdownAgent 设置

将 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。

替换备份调用

  1. 如果 Worker 尚未使用该包,请安装它:

    npm i @cloudflare/sandbox
  2. 如果 Dockerfile 尚未复制 helper 二进制,请将其复制到镜像中:

    Dockerfiledockerfile
    COPY --from=docker.io/cloudflare/sandbox:1.0.1 /usr/local/bin/sandbox-shim /usr/local/bin/sandbox-shim

    使用与已安装包版本匹配的 cloudflare/sandbox 标签。

  3. 保留名为 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 中,容器永远不会收到备份用的存储桶凭据。

  4. 将这些声明添加到 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;
  5. 向 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()。

  6. 替换 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 字段仅用于显示。

  7. 替换 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 中,恢复会挂载到该路径上,因此这样的进程会看到已恢复的文件。

  8. 部署 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 创建的备份

0.12 将每个备份作为存储桶中的 backups/<ID>/data.sqsh 和 backups/<ID>/meta.json 存储,预签名上传和 localBucket: true 都是如此。部署 1.0 后这些 R2 对象仍留在存储桶中,你的代码存储的 0.12 备份对象也仍在原处。要保留备份,请在代码第一次恢复时转换它:将 SquashFS 镜像解压到目录中,然后用 DirectoryBackup 备份该目录。

  1. 将 squashfs-tools 添加到镜像:

    Dockerfiledockerfile
    RUN apt-get update && apt-get install -y --no-install-recommends squashfs-tools && rm -rf /var/lib/apt/lists/*
  2. 向 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-tools 4.7 及更高版本会报错:FATAL ERROR: Requested memory size too large。对于这些版本,请在 unsquashfs 命令中添加 -mem 64M。Debian 13 安装的是 4.6.1 版本,使用默认设置即可在 lite 上解压。

  3. 在你的代码用 0.12 创建的对象调用 restoreBackup() 的地方,改为调用 convert()。0.12 对象没有 format 字段。convert() 会把文件留在目录中,因此它也完成了恢复。将它返回的记录存储到 0.12 对象的位置。

  4. 存储新记录后,删除两个 0.12 对象:

    src/index.tsts
    await this.env.BACKUP_BUCKET.delete([
        `backups/${old.id}/data.sqsh`,
        `backups/${old.id}/meta.json`,
    ]);

转换后的目录会保留文件、空目录、符号链接、硬链接和权限。修改时间只保留整秒,因为 SquashFS 不存储秒的小数部分。如果 unsquashfs 失败,目录中的文件会保留,再次调用 convert() 会重试转换。

相关资源

这篇文档对您有帮助吗?