DeepAgents 使用 Docker 沙箱执行 Shell 命令:从 0 到生产部署

一、为什么需要 Docker 沙箱

DeepAgents 的 execute 工具只有在后端实现 SandboxBackendProtocol 时才会真正执行 Shell 命令。

项目里默认使用的 FilesystemBackend 只负责文件读写,不支持执行命令。所以当 Skill 里出现下面这类指令时,Agent 无法真正运行脚本:

python skills/bank-asset-allocation-report/scripts/generate_report.py ...

直接换成 LocalShellBackend 虽然简单,但它会在宿主机上执行任意命令,存在安全风险,不适合作为 Web API 的生产方案。更稳妥的做法是使用 Docker 沙箱,把命令执行、文件读写都隔离到容器里。

二、DeepAgents 沙箱实现原理

DeepAgents 已经提供了 BaseSandbox,它基于两个核心能力实现了大部分文件操作:

  • execute():在沙箱里执行 Shell 命令
  • upload_files():把文件传入沙箱
  • download_files():从沙箱取回文件
  • id:沙箱唯一标识

lsreadwriteeditgrepglob 这些方法都由 BaseSandbox 自动实现,不需要我们重写。

三、Docker 镜像准备

新建 app/docker/Dockerfile

FROM python:3.12-slim

RUN apt-get update \
    && apt-get install -y --no-install-recommends grep \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /workspace

# 如果沙箱里要运行项目脚本,就安装项目依赖
RUN pip install --no-cache-dir reportlab python-docx markdown openpyxl pypdf

# 容器必须有一个长驻进程,否则启动后会立即退出
CMD ["python3", "-c", "import time; time.sleep(1e9)"]

构建镜像:

docker build -t deepagents-sandbox:latest -f app/docker/Dockerfile .

说明:

  • python:3.12-slim 和项目要求一致。项目 pyproject.toml 要求 >=3.12,<3.13,本地 .venv 也是 Python 3.12。
  • CMD 里的 time.sleep(1e9) 大约 31.7 年,对开发环境来说等于常驻,而且不占用 CPU。
  • 如果容器启动后显示 Exited (0),多半是启动命令是裸的 python3,Python 解释器没有输入就退出了。

四、实现 DockerSandbox

这里推荐使用 Docker CLI 实现,不依赖 Python 的 docker 包。

这样做有两个好处:

  1. 避免项目根目录 docker/ 文件夹和 pip 包 docker 重名。
  2. 不需要额外安装 Python SDK,生产环境只需要 Docker CLI。

新建 app/docker/docker_sandbox.py

from __future__ import annotations

import io
import subprocess
import tarfile
import time
import uuid

from deepagents.backends.protocol import (
    ExecuteResponse,
    FileDownloadResponse,
    FileUploadResponse,
)
from deepagents.backends.sandbox import BaseSandbox


class DockerSandbox(BaseSandbox):
    def __init__(
        self,
        *,
        image: str = "python:3.12-slim",
        workdir: str = "/workspace",
        timeout: int = 120,
        volumes: dict | None = None,
    ) -> None:
        self._workdir = workdir
        self._timeout = timeout
        name = f"deepagents-{uuid.uuid4().hex[:8]}"

        cmd = ["docker", "run", "-d", "--name", name, "--workdir", workdir]
        for host_path, cfg in (volumes or {}).items():
            cmd += ["-v", f"{host_path}:{cfg['bind']}:{cfg.get('mode', 'rw')}"]

        cmd += [image, "python3", "-c", "import time; time.sleep(1e9)"]

        proc = subprocess.run(cmd, capture_output=True, text=True)
        if proc.returncode != 0:
            raise RuntimeError(f"docker run failed: {proc.stderr}")

        self._container_id = proc.stdout.strip()

    @property
    def id(self) -> str:
        return self._container_id[:12]

    def execute(self, command: str, *, timeout: int | None = None) -> ExecuteResponse:
        proc = subprocess.run(
            [
                "docker", "exec", "-w", self._workdir, self._container_id,
                "/bin/sh", "-c", command,
            ],
            capture_output=True,
            encoding="utf-8",
            errors="replace",
            timeout=timeout if timeout is not None else self._timeout,
        )
        return ExecuteResponse(
            output=proc.stdout + proc.stderr,
            exit_code=proc.returncode,
        )

    def upload_files(self, files: list[tuple[str, bytes]]) -> list[FileUploadResponse]:
        responses = []
        for path, content in files:
            if not path.startswith("/") or ".." in path:
                responses.append(FileUploadResponse(path=path, error="invalid_path"))
                continue

            buf = io.BytesIO()
            with tarfile.open(fileobj=buf, mode="w") as tar:
                info = tarfile.TarInfo(path.lstrip("/"))
                info.size = len(content)
                info.mtime = int(time.time())
                tar.addfile(info, io.BytesIO(content))

            proc = subprocess.run(
                ["docker", "cp", "-", f"{self._container_id}:/"],
                input=buf.getvalue(),
                capture_output=True,
            )
            if proc.returncode == 0:
                responses.append(FileUploadResponse(path=path))
            else:
                responses.append(
                    FileUploadResponse(
                        path=path,
                        error=proc.stderr.decode(errors="replace").strip() or "upload_failed",
                    )
                )
        return responses

    def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
        responses = []
        for path in paths:
            if not path.startswith("/") or ".." in path:
                responses.append(FileDownloadResponse(path=path, error="invalid_path"))
                continue

            proc = subprocess.run(
                ["docker", "cp", f"{self._container_id}:{path}", "-"],
                capture_output=True,
            )
            if proc.returncode != 0:
                err = proc.stderr.decode(errors="replace")
                error = "file_not_found" if "not found" in err.lower() or "no such" in err.lower() else err.strip()[:200]
                responses.append(FileDownloadResponse(path=path, error=error))
                continue

            try:
                with tarfile.open(fileobj=io.BytesIO(proc.stdout), mode="r:*") as tar:
                    member = tar.next()
                    if member is None or member.isdir():
                        responses.append(FileDownloadResponse(path=path, error="is_directory"))
                        continue
                    content = tar.extractfile(member).read()
                responses.append(FileDownloadResponse(path=path, content=content))
            except Exception as exc:
                responses.append(FileDownloadResponse(path=path, error=str(exc)[:200]))
        return responses

核心实现说明:

  • docker run -d 创建长驻容器。
  • docker exec 在容器里执行命令。
  • docker cp - 容器:/ 从标准输入接收 tar 包并解压到容器,实现文件上传。
  • docker cp 容器:路径 - 把容器文件打包成 tar 输出到标准输出,实现文件下载。

五、接入主 Agent

修改 app/agent/main_agent.py

from app.docker.docker_sandbox import DockerSandbox

project_root_path = Path(__file__).parents[1].resolve()
(project_root_path / "output").mkdir(parents=True, exist_ok=True)

file_backend = DockerSandbox(
    image="deepagents-sandbox:latest",
    workdir="/workspace",
    timeout=120,
    volumes={
        str(project_root_path): {"bind": "/workspace", "mode": "ro"},
        str(project_root_path / "output"): {"bind": "/workspace/output", "mode": "rw"},
    },
)

这里把 app/ 只读挂载到容器的 /workspace,把 output/ 单独以可写方式挂载,这样:

  • Agent 能在容器里读取 skills/
  • 生成的文件会写到 /workspace/output/session_xxx
  • 宿主机通过 app/output/session_xxx 直接看到结果。

六、验证

先验证基础命令:

docker run -dit --name sandbox-test deepagents-sandbox:latest python3 -c "import time; time.sleep(1e9)"
docker exec sandbox-test python3 --version

再验证 Python 后端:

cd D:/AI Learning/financesearch-agents
.venv\Scripts\python -B -c "
from app.docker.docker_sandbox import DockerSandbox

s = DockerSandbox(
    image='deepagents-sandbox:latest',
    workdir='/workspace',
    volumes={
        'D:/AI Learning/financesearch-agents/app': {'bind': '/workspace', 'mode': 'ro'},
        'D:/AI Learning/financesearch-agents/app/output': {'bind': '/workspace/output', 'mode': 'rw'},
    },
)
print(s.execute('python3 --version'))
s.write('/workspace/output/test.txt', 'hello')
print(s.read('/workspace/output/test.txt'))
"

验证完删除测试容器:

docker rm -f sandbox-test

七、Windows Docker Desktop 常见问题

1. 容器启动后立即变成 Exited (0)

原因通常是启动命令是裸的 python3

IMAGE                    COMMAND     STATUS
deepagents-sandbox:latest "python3"  Exited (0)

Python 解释器启动后没有 stdin 输入,会立即退出。

解决:

  • Dockerfile 里加 CMD ["python3", "-c", "import time; time.sleep(1e9)"]
  • 手动运行时使用 docker run -dit ... python3 -c "import time; time.sleep(1e9)"
  • Docker Desktop 需要处于 Linux 容器模式。

2. import docker 指向了项目自己的 docker 目录

如果项目根目录有 docker/ 文件夹,Python 的 import docker 会优先解析到这个目录:

<module 'docker' (namespace) from ['D:\\AI Learning\\financesearch-agents\\docker']>

这不是 pip 包 docker,所以没有 from_env() 方法。

不重命名目录的解决方案就是本文使用的方案:不 import docker,全部改用 Docker CLI。

如果一定要用 Python SDK,则需要把项目根目录的 docker/ 改名,例如 infra/,否则会一直冲突。

3. docker.from_env 需要加载 .env 吗

docker.from_env() 读取的是进程环境变量,不是 .env 文件。

  • 本机 Docker Desktop 默认不需要任何环境变量。
  • 远程 Docker 时,需要在启动进程前设置 DOCKER_HOSTDOCKER_TLS_VERIFYDOCKER_CERT_PATH 等变量。
  • 如果这些变量写在 .env 里,需要先调用 load_dotenv(),再执行 docker.from_env()

4. Python 版本不一致有影响吗

本地虚拟环境是 Python 3.12.13,项目锁定 Python 3.12,因此 Docker 镜像使用 python:3.12-slim 是正确选择。

真正需要注意的不是版本,而是平台:

  • Docker 镜像是 Debian Linux,本地是 Windows。
  • 脚本里的路径和 Shell 命令要按照 Linux 书写。
  • 如果容器里要运行项目脚本,必须在 Dockerfile 里安装脚本依赖。

八、生产部署建议

1. 每个会话使用独立容器

当前 DockerSandbox 每次实例化都会创建新容器。Web 服务里不要让所有用户共用一个容器,否则文件会互相污染。

建议每个 session_id 对应一个容器,任务结束后:

docker stop <container_id>
docker rm <container_id>

2. 限制挂载权限

生产环境不要直接把整个项目目录以可写方式挂载进容器。

推荐只挂载必要目录:

  • skills/ 只读挂载。
  • output/ 可写挂载。
  • 数据库配置、密钥、上传文件不要放进容器。

3. 远程 Docker

部署到远程 Docker 时,在服务启动前设置:

DOCKER_HOST=tcp://192.168.1.10:2375

或者:

DOCKER_HOST=ssh://user@192.168.1.10

使用 Docker CLI 方案时,docker rundocker execdocker cp 都会读取同一个环境变量,不会出现连接不一致的问题。

4. 镜像版本固定

生产环境不要使用 latest,建议固定到具体版本:

docker build -t registry.example.com/deepagents-sandbox:2026.08.10 .
docker push registry.example.com/deepagents-sandbox:2026.08.10

5. 安全控制

  • 不要接受不可信用户的任意 Shell 命令。
  • 有需要时配合 interrupt_on={"execute": True} 做人工审批。
  • 容器内不要安装不必要的网络工具和调试工具。
  • 定期清理退出状态的历史容器和未使用的镜像。

九、总结

这套方案的核心思路是:

  1. BaseSandbox 继承 DeepAgents 的沙箱接口。
  2. 只实现 executeupload_filesdownload_filesid
  3. 使用 Docker CLI 替代 Python SDK,避免项目目录重名问题。
  4. 通过只读挂载和独立容器实现生产环境的基本隔离。

这样既能让 Agent 真正执行 Skill 里的脚本,又比直接在宿主机上执行命令安全得多。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐