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

将目录备份到 R2

最后更新 查看 MarkdownAgent 设置

@cloudflare/sandbox 中的 DirectoryBackup 类会将一个目录保存为 R2 bucket 中的一个对象,并将其恢复为普通文件。备份可在实例停止或你部署新镜像后恢复。R2 会保留每个备份,直到你删除它。本例中,目录是 /workspace/project 中的项目。

前提条件

当您运行 wrangler deploy 时,必须在本地运行 Docker。对于大多数人来说,安装 Docker 的最佳方式是按照安装 Docker Desktop 的文档 ↗进行操作。其他工具(如 Colima ↗)也可能有效。

您可以通过在终端中运行 docker info 命令来检查 Docker 是否正常运行。如果 Docker 正在运行,该命令将成功执行。如果 Docker 未运行,docker info 命令将挂起或返回包含 "Cannot connect to the Docker daemon" 消息的错误。

备份并恢复目录

  1. 安装软件包:

    npm i @cloudflare/sandbox
  2. 将 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 行替换为命令所需的基础镜像。

  3. 为备份创建 bucket:

    npx wrangler r2 bucket create sandbox-backups

    如果 Wrangler 提议将该 bucket 添加到配置中,请回答 no。下一步会添加绑定。

  4. 在 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 types
  5. 在 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 发送备份,后者将其写入 BACKUPS bucket。container 永远不会收到 R2 凭据,并且只能访问正在进行的操作的对象。

    构造函数会为 Durable Object 创建一个 DirectoryBackup 对象,因为只要 Durable Object 在运行,this.ctx.container 就会保持为同一个对象。

  6. 向 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);
        }
    }
  7. 向 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() 前请停止写入该目录的命令。备份期间更改的文件可能一部分是旧的、一部分是新的。

  8. 向 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 错误。

  9. 将 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 安全。

  10. 部署你的 Worker:

    npx wrangler deploy
  11. 在名为 ada 的 sandbox 中写入笔记并备份项目。将示例 hostname 替换为 Wrangler 打印的 workers.dev URL:

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

  12. 写入第二条笔记,恢复备份,然后读取笔记。将 <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 只匹配顶层:

src/index.tsts
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,
});

删除备份

要删除备份,请删除其对象及其记录:

src/index.tsts
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 的默认生命周期规则在七天后中止它。

相关资源

这篇文档对您有帮助吗?