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

在 sandbox 内外移动文件

最后更新 查看 MarkdownAgent 设置

本指南将上传的文件流式传入 sandbox,用 gzip 压缩,然后将压缩后的文件流式返回给调用方。

@cloudflare/sandbox 中的 Files 类会在正在运行的 Linux 实例中读写文件,exec() 运行命令。Files 不会启动实例,因此 Durable Object 会在每次文件操作前启动它。

前提条件

当您运行 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. 要为 Files 提供其 helper 二进制文件,请在项目根目录创建 Dockerfile。如果已有 Dockerfile,请向其中添加 COPY 行:

    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 标签必须与已安装的软件包版本匹配。不匹配会导致 SandboxProtocolError。将 FROM 行替换为命令所需的基础镜像。

  3. 要构建镜像,请向 wrangler.jsonc 添加 nodejs_compat 标志,并替换 containers 条目:

    {
        "compatibility_flags": ["nodejs_compat"],
        "containers": [
            {
                "class_name": "MyContainer",
                "scheduling_policy": "durable_object",
                "images": {
                    "workspace": {
                        "dockerfile": "./Dockerfile",
                    },
                },
            },
        ],
    }
    compatibility_flags = [ "nodejs_compat" ]
    
    [[containers]]
    class_name = "MyContainer"
    scheduling_policy = "durable_object"
    
    [containers.images.workspace]
    dockerfile = "./Dockerfile"

    workspace 键为镜像命名。你的代码会将其作为 container.images.workspace 启动。

  4. 要在每次文件操作前启动实例,请将 src/index.ts 替换为带有 startSandbox() 方法的 MyContainer 类:

    src/index.jsjs
    import { Files } from "@cloudflare/sandbox";
    import { DurableObject } from "cloudflare:workers";
    
    const workspace = "/workspace";
    const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
    
    export class MyContainer extends DurableObject {
    	container;
    	files;
    
    	constructor(ctx, env) {
    		super(ctx, env);
    		const container = ctx.container;
    
    		if (!container) {
    			throw new Error("No container is configured");
    		}
    
    		this.container = container;
    		this.files = new Files(container);
    
    		// A restarted Durable Object sets the timeout again.
    		if (container.running) {
    			void ctx.blockConcurrencyWhile(() =>
    				container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS),
    			);
    		}
    	}
    
    	// Files does not start an instance, so start one before each operation.
    	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 { Files } from "@cloudflare/sandbox";
    import { DurableObject } from "cloudflare:workers";
    
    const workspace = "/workspace";
    const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000;
    
    export class MyContainer extends DurableObject<Env> {
        private readonly container: Container;
        private readonly files: Files;
    
        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.files = new Files(container);
    
            // A restarted Durable Object sets the timeout again.
            if (container.running) {
                void ctx.blockConcurrencyWhile(() =>
                    container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS),
                );
            }
        }
    
        // Files does not start an instance, so start one before each operation.
        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);
            }
        }
    }

    构造函数会为 Durable Object 创建一个 Files 对象,因为只要 Durable Object 在运行,this.ctx.container 就会保持为同一个对象。startSandbox() 会在没有实例运行时启动 workspace 镜像,并在 start() 之后设置不活动超时。更多信息请参阅 Sandbox 生命周期。

  5. 要写入上传、运行命令并返回输出文件,请向 MyContainer 添加 compress() 方法:

    src/index.tsts
    export class MyContainer extends DurableObject<Env> {
        // ...
    
        async compress(upload: ReadableStream<Uint8Array>): Promise<Response> {
            await this.startSandbox();
            const name = crypto.randomUUID();
    
            await this.files.writeFile(name, upload, { cwd: workspace });
    
            const process = await this.container.exec(["gzip", "--force", name], {
                cwd: workspace,
            });
            const output = await process.output();
    
            if (output.exitCode !== 0) {
                throw new Error(new TextDecoder().decode(output.stderr));
            }
    
            return this.files.readFile(`${name}.gz`, { cwd: workspace });
        }
    }

    writeFile() 会将上传流式写入文件,readFile() 会返回将输出文件流式传回的 Response。这两个方法都不会在 Durable Object 的内存中保存整个文件。output() 只保存 gzip 打印的内容,该输出计入 内存限制。

    Linux 失败(例如磁盘已满)会抛出 SandboxFileError。其 code 保存 Linux 错误名称,例如 ENOSPC。要处理其他错误,请参阅 Files 错误。

  6. 要将上传发送到 compress(),请向 src/index.ts 添加默认导出:

    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])?)\/compress$/.exec(
    				url.pathname,
    			);
    
    		if (!match || request.method !== "POST" || !request.body) {
    			return new Response("Not found", { status: 404 });
    		}
    
    		const sandbox = env.MY_CONTAINER.getByName(match[1]);
    		return sandbox.compress(request.body);
    	},
    };
    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])?)\/compress$/.exec(url.pathname);
    
            if (!match || request.method !== "POST" || !request.body) {
                return new Response("Not found", { status: 404 });
            }
    
            const sandbox = env.MY_CONTAINER.getByName(match[1]);
            return sandbox.compress(request.body);
        },
    } satisfies ExportedHandler<Env>;
  7. 部署你的 Worker:

    npx wrangler deploy
  8. 要测试 Worker,请将文件上传到名为 ada 的 sandbox 并保存响应。将 hostname 替换为 Wrangler 打印的 workers.dev URL:

    printf 'Ada Lovelace\nGrace Hopper\n' | curl \
      https://<YOUR_WORKER>.<YOUR_SUBDOMAIN>.workers.dev/sandboxes/ada/compress \
      --data-binary @- \
      --fail-with-body \
      --output names.txt.gz
  9. 解压响应:

    gunzip --stdout names.txt.gz
    Ada Lovelace
    Grace Hopper

列出、重命名和删除文件

Files 还有其他文件操作方法。每个方法都接受与 writeFile() 和 readFile() 相同的 cwd 选项:

方法 用途
readDirectory() 列出目录条目
stat() 读取路径的类型、大小、所有者和时间戳
mkdir() 创建目录
rename() 重命名或移动文件或目录
remove() 删除文件,或使用 recursive: true 删除目录

压缩文件会留在 /workspace 中,直到实例停止。要更早删除文件,请在调用方读取响应后调用 remove()。

更多信息请参阅 Files API。

接受来自调用方的路径

有些应用允许调用方选择路径,例如文件浏览器。Files 不会限制它打开哪些路径。要将请求限制在一个目录内,请在将每个路径传给 Files 之前检查它。

本例添加 download() 方法,只接受不含 .. 的相对路径,并对缺失文件返回 404:

src/index.jsjs
import { SandboxFileError } from "@cloudflare/sandbox";
import { DurableObject } from "cloudflare:workers";

const workspace = "/workspace";

// Accept only relative paths that stay inside cwd.
function workspacePath(path) {
	if (
		path === "" ||
		path.startsWith("/") ||
		path.includes("\0") ||
		path.split("/").includes("..")
	) {
		return null;
	}

	return path;
}

export class MyContainer extends DurableObject {
	// ...

	async download(path) {
		const relativePath = workspacePath(path);

		if (!relativePath) {
			return new Response("Invalid path", { status: 400 });
		}

		await this.startSandbox();

		try {
			return await this.files.readFile(relativePath, { cwd: workspace });
		} catch (error) {
			if (SandboxFileError.is(error) && error.code === "ENOENT") {
				return new Response("Not found", { status: 404 });
			}

			throw error;
		}
	}
}
src/index.tsts
import { SandboxFileError } from "@cloudflare/sandbox";
import { DurableObject } from "cloudflare:workers";

const workspace = "/workspace";

// Accept only relative paths that stay inside cwd.
function workspacePath(path: string): string | null {
    if (
        path === "" ||
        path.startsWith("/") ||
        path.includes("\0") ||
        path.split("/").includes("..")
    ) {
        return null;
    }

    return path;
}

export class MyContainer extends DurableObject<Env> {
    // ...

    async download(path: string): Promise<Response> {
        const relativePath = workspacePath(path);

        if (!relativePath) {
            return new Response("Invalid path", { status: 400 });
        }

        await this.startSandbox();

        try {
            return await this.files.readFile(relativePath, { cwd: workspace });
        } catch (error) {
            if (SandboxFileError.is(error) && error.code === "ENOENT") {
                return new Response("Not found", { status: 404 });
            }

            throw error;
        }
    }
}

相关资源

这篇文档对您有帮助吗?