# 沙箱部署指南 —— 从 0 跑通 `code_exec`

> 配套实现文档：[SANDBOX-SKILL-IMPL.md](SANDBOX-SKILL-IMPL.md)
> 设计文档：[design-big-lobster-code-sandbox-2026-04-22.md](../../big-lobster-doc/prd/design-big-lobster-code-sandbox-2026-04-22.md)

本文档回答三个问题：
1. 谁负责构建沙箱镜像？
2. 怎么从 0 把一台节点配到 `code_exec` 可用？
3. 跑不起来怎么排查？

---

## 0. 架构概览

```
┌─ 宿主机（Linux 生产 / Windows 开发） ─────────────────┐
│                                                      │
│   Docker daemon  ◄── 运维负责装和启停                 │
│        │                                             │
│        └── lobster-sandbox:py3.11-office-v3          │
│              ← 运维跑一次 build.sh / build.ps1 构建   │
│                                                      │
│   Tomcat + zm-ai-server.war                          │
│        │                                             │
│        └── 每次 code_exec 时 `docker run`             │
│              起一次性容器，跑 Python，退出销毁         │
│                                                      │
└──────────────────────────────────────────────────────┘
```

**职责划分**：

| 谁 | 做什么 |
|---|---|
| 运维 | 装 Docker daemon；跑 `build.sh`/`build.ps1` 构建镜像；部署 whitelist 文件；启 Tomcat |
| Tomcat/Java | `docker run` 起容器；启动时做 `docker image inspect` 自检；不构建镜像、不管 daemon |

**不会** 出现的行为：Java 后台自动 `docker build`。镜像要运维手工构建。

---

## 1. 首次部署

### 1.1 宿主机装 Docker

**Linux（生产）**：

```bash
# CentOS / RHEL / 国产 UOS / 麒麟
sudo yum install -y docker
sudo systemctl enable --now docker

# Ubuntu / Debian
sudo apt install -y docker.io
sudo systemctl enable --now docker

# 把 Tomcat 进程用户加入 docker 组，使其能不经 sudo 跑 docker
sudo usermod -aG docker <tomcat-user>
```

**Windows（开发 / 测试）**：装 Docker Desktop 并启动（等到右下角图标变绿）。

验证：
```bash
docker version
docker ps
```

### 1.2 构建沙箱镜像

**Linux / macOS / Git Bash**：
```bash
cd D:/work/lobster/zm-ai-server/web/WEB-INF/lobster/deploy/sandbox
bash build.sh
```

**Windows 原生**（PowerShell 或 CMD）：
```powershell
cd D:\work\lobster\zm-ai-server\web\WEB-INF\lobster\deploy\sandbox
.\build.ps1
# 或：
.\build.cmd
```

构建脚本做什么：
1. `docker build` 产 `lobster-sandbox:py3.11-office-v3`（含 python + pandoc + libreoffice + nodejs + web-artifacts 预构建模板）
2. 跑一次镜像抓实际装了啥，输出 `out/installed-packages.json` 供白名单校验用

**首次构建约 10–15 分钟**（拉 Debian/pip/npm 源），产出 **~2GB** 镜像。需要**公网**或已配好的内网源。

环境变量覆盖（可选）：
```bash
IMAGE_NAME=lobster-sandbox IMAGE_TAG=py3.11-office-v3 bash build.sh
```

PowerShell：
```powershell
$env:IMAGE_TAG = 'py3.11-office-v3'; .\build.ps1
```

### 1.3 验证镜像

```bash
docker image inspect lobster-sandbox:py3.11-office-v3 | head -3

# 跑一次 entry script 自检 Python 环境
docker run --rm lobster-sandbox:py3.11-office-v3 -c "import docx, openpyxl, pptx, pypdf; print('ok')"

# 验外部工具
docker run --rm --entrypoint bash lobster-sandbox:py3.11-office-v3 -c \
  "soffice --version; pandoc --version | head -1; node --version; pnpm --version"
```

预期输出：`ok` / `LibreOffice 7.x / 24.x` / `pandoc 3.x` / `v20.x` / `9.15.0`。

### 1.4 部署白名单文件

仓库里已经内置了一份静态基线 [`web/WEB-INF/lobster/sandbox-installed-packages.json`](../web/WEB-INF/lobster/sandbox-installed-packages.json)，与 Dockerfile 的 pip/npm pin 同步。**首次部署可直接用**，WAR 打包自动带上。

每次改 Dockerfile 后重走：
```bash
# 跑完 build.sh/build.ps1 后
cp web/WEB-INF/lobster/deploy/sandbox/out/installed-packages.json \
   web/WEB-INF/lobster/sandbox-installed-packages.json
# 然后重新 mvn package 打 WAR
```

路径由 `LobsterConfig.sandboxInstalledPackagesFile` 控制（默认 `WEB-INF/lobster/sandbox-installed-packages.json`），可在 `lobster.xml` 覆盖：
```xml
<sandboxInstalledPackagesFile>/etc/lobster/installed-packages.json</sandboxInstalledPackagesFile>
```

**fail-closed** 默认开启：找不到文件时，带 `pythonPackages` 的 bundle 上传会被拒。开发期可关：
```xml
<sandboxRequireInstalledPackagesFile>false</sandboxRequireInstalledPackagesFile>
```

### 1.5 建 host 工作目录

Tomcat 进程要能写这两个目录：

```bash
# Linux
sudo mkdir -p /srv/sandbox /var/cache/lobster/skill-bundles
sudo chown -R <tomcat-user>:<tomcat-group> /srv/sandbox /var/cache/lobster/skill-bundles
sudo chmod 0777 /srv/sandbox
# /srv/sandbox 必须宽松权限 —— 容器内 uid 10001 要能写 outputs/work

# Windows（Docker Desktop 会自动处理权限）
mkdir d:\lobster\sandbox d:\lobster\skill-bundles
```

默认值在 [`LobsterConfig.java`](../src/com/gzzm/lobster/config/LobsterConfig.java)：
- Linux: `/srv/sandbox` + `/var/cache/lobster/skill-bundles`
- Windows: `d:/lobster/sandbox` + `d:/lobster/skill-bundles`

可在 `lobster.xml` 覆盖：
```xml
<sandboxWorkDir>/opt/lobster/sandbox</sandboxWorkDir>
<sandboxBundleCacheDir>/opt/lobster/skill-bundles</sandboxBundleCacheDir>
```

### 1.5.1 可选：slot 预热池

默认执行模型仍然是每次 `code_exec` 创建一个一次性容器，执行后删除。若生产节点上
`docker create` 冷启动成本明显，可以开启 slot 池：

```xml
<sandboxPoolEnabled>true</sandboxPoolEnabled>
<sandboxPoolSize>4</sandboxPoolSize>
<sandboxPoolRoot>/srv/sandbox-pool</sandboxPoolRoot>
```

slot 池的约束：

- 每个 slot 有固定 `inputs/outputs/work` 目录和一个预创建容器，同一时刻只服务一个任务。
- Tomcat 启动时会异步预热默认 Python / no-skill 容器，初始预热不会独占 slot 队列；后续任务结束后按本次 profile 继续后台预热。
- 任务开始前清空该 slot 的数据目录，重新写 `manifest.json`、输入文件和 `/work/entry.py|entry.js`。
- 任务执行结束、产物回落后，旧容器会被 `docker rm -f` 删除，并在后台按本次执行 profile 重建一个干净容器。
- 若下次任务的 language / skill mount / 镜像资源 profile 不同，或预热容器已被外部删除/状态不是 `created`，会丢弃旧预热容器并按新 profile 创建。
- 不使用 `docker exec` 在一个运行中容器里并发塞多个用户任务。

slot 池仍然保留容器级隔离：用户代码不会共享同一个正在运行的容器；池只减少请求到来时等待
`docker create` 的概率。`sandboxPoolSize` 应小于或等于节点可承受的沙箱并发数。

### 1.6 部署 WAR + 启 Tomcat

```bash
mvn -DskipTests package
cp target/zm-ai-server.war $CATALINA_BASE/webapps/
$CATALINA_BASE/bin/startup.sh
```

---

## 2. 启动自检

重启 Tomcat 后，查 `catalina.out` 或 zmeg 框架日志，**确认 4 行都在**：

```
[BuiltinToolRegistrar] ok: code_exec (CodeExecTool)
[BuiltinToolRegistrar] in-memory registration done — total tools: 20+
[LobsterBootstrap] sandbox image ready: lobster-sandbox:py3.11-office-v3
[SystemSkillLoader] done — inserted=N, updated=0, skipped=0
```

---

## 3. 最小验收（docx 闭环）

### 3.1 确认系统 skill 加载

```
GET /ai/api/admin/skills/list
```
应至少能看到：
- 沙箱资源型：`sys_docx` / `sys_xlsx` / `sys_pptx` / `sys_pdf` / `sys_web-artifacts-builder` / `sys_canvas-design`
- 政务办公沙箱资源型：`sys_meeting-minutes` / `sys_data-cleaning-report` / `sys_statistical-briefing` / `sys_materials-assembly` / `sys_budget-analysis`
- 纯指导型：`sys_code-exec-guide` / `sys_doc-coauthoring` / `sys_frontend-design` / `sys_gov-writing` / `sys_policy-brief` / `sys_inspection-checklist` / `sys_procurement-review-assist` / `sys_public-disclosure`

完整清单见 [`docs/SYSTEM-SKILLS.md`](SYSTEM-SKILLS.md)。

加载和执行语义：

- `list_skills` 只返回薄索引：`skillId/name/description/runtimeKind`。
- 命中后必须调用 `use_skill(skillId)` 读取完整 `SKILL.md`。
- 沙箱资源型 skill 要先 `use_skill`，再通过 `code_exec(activated_skill=skillId)` 挂载到 `/skill/<skillId>/`。
- 用户上传文件通过 `input_refs` 进入 `/inputs`，真实路径以 `/inputs/manifest.json` 的 `path` 字段为准。
- 业务 JSON spec 和中间文件写 `/work`；最终交付物写 `/outputs`。
- 重启时内置系统 skill 以代码目录为准同步：新增/修改会 upsert，目录中已删除的 `scope=system` 且 `skillId` 以 `sys_` 开头的 skill 会自动置为 `enabled=false`，重新加入目录后会再次启用。管理端创建的 `sk_` skill 不参与文件系统对账。

### 3.2 跑一次 code_exec

在对话里说：
```
用 code_exec 跑一段 Python，用 python-docx 生成 hello.docx 到 /outputs
```

### 3.3 查审计 + 产物

```sql
-- 审计：确认 code_exec 被调过、脱敏后的 detail
SELECT action_type, result, detail_json, create_time
FROM AI_TOOL_AUDIT
WHERE action_type='tool.code_exec'
ORDER BY create_time DESC LIMIT 1;

-- 产物：应能看到 format='docx' 的 artifact
SELECT artifact_id, title, format, content_size
FROM AI_ARTIFACT
WHERE source_run_id='<上一步的 runId>';
```

下载 artifact bytes 用 Word / WPS 打开应正常（不是乱码）。跑通即 P0 §10 验收清单"功能"栏全绿。

---

## 4. 常见故障排查

### 4.1 启动日志里缺 `sandbox image ready`

**症状**：
```
[LobsterBootstrap] ⚠ sandbox image NOT found: lobster-sandbox:py3.11-office-v3
  ... Raw error: Error response from daemon: No such image: ...
```

**三种可能**，按出现概率从高到低排：

**(a) Docker Desktop 索引错乱**（Windows 最常见）—— `docker images` 能看到镜像，但 `docker image inspect <name:tag>` 返 "No such image"。build 刚结束时或 Docker Desktop 重启后偶发。

```powershell
REM 验证两条命令结果不一致就是这种情况
docker images | findstr lobster
docker image inspect lobster-sandbox:py3.11-office-v3 --format "{{.Id}}"

REM 修：用 image ID 重新打 tag（build.sh / build.ps1 自 2026-04-24 起已自动做这步）
docker tag <image_id> lobster-sandbox:py3.11-office-v3
```

**(b) 镜像没构建或 tag 不对**：
```bash
docker images | grep lobster
# 没有 → 回到 §1.2 构建
# tag 不一致 → 改 lobster.xml 里的 <sandboxImage>，或重新 build 对齐 tag
```

**(c) Tomcat 进程的 docker context 与 cmd 不一致**（少见，多见于 Windows Services 装的 Tomcat）：
```
IDE Run Configuration → Environment variables 加：
DOCKER_HOST=npipe:////./pipe/dockerDesktopLinuxEngine
```

### 4.2 启动日志里缺 `docker daemon not reachable`

**症状**：
```
[LobsterBootstrap] ⚠ docker daemon not reachable via 'docker' ...
```
**原因**：Docker daemon 没起，或 Tomcat 用户无权限。

**处理**：
```bash
# Linux
sudo systemctl status docker
sudo systemctl start docker
groups <tomcat-user>                       # 应包含 docker
# Windows：启动 Docker Desktop
```

### 4.3 `code_exec` 被调但失败：`sandbox.docker_create`

**症状**：`AI_TOOL_AUDIT.detail_json.error_code = sandbox.docker_create`

**原因**：Tomcat 进程无法起 docker 子进程。

**处理**：
- 确认 Tomcat 用户在 docker 组：`id -nG <tomcat-user>`
- 确认 `docker run --rm hello-world` 以 Tomcat 用户身份能跑
- 检查 `/srv/sandbox` 权限：`ls -ld /srv/sandbox` 应 `drwxrwxrwx` 或 owner 是 Tomcat 用户

### 4.4 `code_exec` 被调但失败：`sandbox.docker_start` / 容器启动秒退

**症状**：容器创建成功但 `docker start -a` 退码非零，stdout/stderr 都空。

**原因**：通常是 bind mount 权限 —— host 目录 owner ≠ uid 10001，容器无法写。

**处理**：
```bash
ls -ld /srv/sandbox/<runId>/outputs      # 看 owner/mode
chmod 0777 /srv/sandbox                  # 根目录放宽
# SandboxService 会对每个 runRoot 下的 outputs/inputs/work 自动 chmod 0777
```

### 4.5 产物 docx 打开是乱码

**原因**：Java 后端走了 String 路径把二进制当 UTF-8 处理。

**处理**：确认代码是最新的—— `ArtifactService.createBinary` / `ContentStore.writeBinary` / `save_to_oa` 的 `writeNewBytes` 全链路应走字节。如果用旧 WAR 上线，重新 `mvn package` + 部署。

### 4.6 LLM 说"沙箱环境暂不可用"但审计表没有 `tool.code_exec` 记录

**原因**：LLM 根本没真的调到后端——多半是 adapter 层 native_tool_calling 配置问题。

**处理**：
```sql
-- 查 LLM profile 是否开启 native tool calling
SELECT model_id, native_tool_calling, endpoint FROM AI_MODEL_PROFILE WHERE enabled=1;

-- 查 LLM 调用日志的 raw 响应
SELECT request_id, raw_response_ref FROM AI_MODEL_CALL_LOG ORDER BY create_time DESC LIMIT 1;
-- raw_response_ref 指向 ContentStore，可读原始响应看 tool_calls 是否真被生成
```

### 4.7 Bundle 上传被拒 `bundle.whitelist_missing`

**原因**：`sandbox-installed-packages.json` 找不到，fail-closed 生效。

**处理**：把仓库里的 `web/WEB-INF/lobster/sandbox-installed-packages.json` 确认进了 WAR；或者 lobster.xml 指定绝对路径；或者开发期：`<sandboxRequireInstalledPackagesFile>false</sandboxRequireInstalledPackagesFile>`。

---

## 5. 每次代码更新

大多数情况：**不重建镜像**。

```bash
cd D:/work/lobster/zm-ai-server
mvn -DskipTests package
cp target/zm-ai-server.war $CATALINA_BASE/webapps/
$CATALINA_BASE/bin/shutdown.sh && $CATALINA_BASE/bin/startup.sh
```

需要重建镜像的场景：

| 改了什么 | 操作 |
|---|---|
| Java 代码 / `src/**/*.java` | 只重打 WAR |
| `web/WEB-INF/lobster/skills/<name>/SKILL.md`（纯指导型 skill） | 只重打 WAR（SystemSkillLoader 下次启动自动识别） |
| `web/WEB-INF/lobster/deploy/skills/<name>/**`（沙箱资源型 skill） | 只重打 WAR（运行时通过 `fs://` 指向目录，`code_exec` 按需 bind mount 到 `/skill/<skillId>/`） |
| `web/WEB-INF/lobster/deploy/sandbox/Dockerfile` | bump `IMAGE_TAG`（如 `v3`）→ 重跑 `build.sh`/`build.ps1` → 改 `lobster.xml` 里 `<sandboxImage>` 对齐 → 重打 WAR |
| `web/WEB-INF/lobster/deploy/sandbox/web-artifacts-init` / `web-artifacts-bundle` | 同上（脚本 COPY 进镜像） |

---

## 6. 无公网生产环境

生产节点没公网时，在**有公网的 build 机**上构建完，用 `docker save / load` 搬运：

```bash
# 有网机器
cd web/WEB-INF/lobster/deploy/sandbox
bash build.sh
docker save lobster-sandbox:py3.11-office-v3 | gzip > /tmp/lobster-sandbox.tar.gz

# 搬运
scp /tmp/lobster-sandbox.tar.gz prod-node:/tmp/
scp out/installed-packages.json prod-node:/tmp/

# 生产机器
gunzip -c /tmp/lobster-sandbox.tar.gz | docker load
docker image inspect lobster-sandbox:py3.11-office-v3     # 确认载入
sudo cp /tmp/installed-packages.json \
     $CATALINA_BASE/webapps/zm-ai-server/WEB-INF/lobster/sandbox-installed-packages.json
```

---

## 7. 卸载 / 清理

```bash
# 停 Tomcat
$CATALINA_BASE/bin/shutdown.sh

# 清残留容器（正常情况每个 run 结束都自动 docker rm -f）
docker ps -a | grep lobster-sbx | awk '{print $1}' | xargs -r docker rm -f

# 清 host 临时目录
rm -rf /srv/sandbox/*

# 清 skill bundle 解压缓存（SkillAssetService LRU 也会自动清）
rm -rf /var/cache/lobster/skill-bundles/*

# 删镜像（最后）
docker rmi lobster-sandbox:py3.11-office-v3
```

---

## 附录 A：lobster.xml 沙箱相关配置全量

```xml
<?xml version="1.0" encoding="UTF-8"?>
<config>
  <managedBean class="com.gzzm.lobster.config.LobsterConfig">
    <!-- 镜像与 docker CLI -->
    <sandboxImage>lobster-sandbox:py3.11-office-v3</sandboxImage>
    <sandboxDockerBin>docker</sandboxDockerBin>

    <!-- 资源上限 -->
    <sandboxDefaultTimeoutSec>30</sandboxDefaultTimeoutSec>
    <sandboxMaxTimeoutSec>120</sandboxMaxTimeoutSec>
    <sandboxMemoryMb>512</sandboxMemoryMb>
    <sandboxCpus>0.5</sandboxCpus>
    <sandboxPidsLimit>128</sandboxPidsLimit>
    <sandboxOutputMaxBytes>52428800</sandboxOutputMaxBytes>
    <sandboxCodeMaxBytes>20480</sandboxCodeMaxBytes>
    <sandboxRatePerMinute>10</sandboxRatePerMinute>

    <!-- 输入 staging 上限 -->
    <sandboxInputMaxBytes>20971520</sandboxInputMaxBytes>
    <sandboxInputTotalMaxBytes>104857600</sandboxInputTotalMaxBytes>
    <sandboxInputMaxFiles>20</sandboxInputMaxFiles>

    <!-- 目录 -->
    <sandboxWorkDir>/srv/sandbox</sandboxWorkDir>
    <sandboxPoolEnabled>false</sandboxPoolEnabled>
    <sandboxPoolSize>4</sandboxPoolSize>
    <sandboxPoolRoot>/srv/sandbox-pool</sandboxPoolRoot>
    <sandboxBundleCacheDir>/var/cache/lobster/skill-bundles</sandboxBundleCacheDir>
    <sandboxBundleCacheMaxEntries>64</sandboxBundleCacheMaxEntries>
    <sandboxBundleCacheMaxBytes>2147483648</sandboxBundleCacheMaxBytes>

    <!-- 用户 -->
    <sandboxUid>10001</sandboxUid>

    <!-- 系统 skill 扫描根（相对 webapp 根 / catalina.base / cwd 依次尝试）-->
    <systemSandboxSkillDir>WEB-INF/lobster/deploy/skills</systemSandboxSkillDir>
    <systemGuidanceSkillDir>WEB-INF/lobster/skills</systemGuidanceSkillDir>

    <!-- 包白名单 -->
    <sandboxInstalledPackagesFile>WEB-INF/lobster/sandbox-installed-packages.json</sandboxInstalledPackagesFile>
    <sandboxRequireInstalledPackagesFile>true</sandboxRequireInstalledPackagesFile>
  </managedBean>
</config>
```

---

## 附录 B：真实场景下的验收检查单（生产节点上机）

- [ ] `docker version` 正常；Tomcat 用户 `docker run hello-world` 成功
- [ ] `docker image inspect lobster-sandbox:py3.11-office-v3` 返 JSON
- [ ] `ls $CATALINA_BASE/webapps/zm-ai-server/WEB-INF/lobster/sandbox-installed-packages.json` 存在
- [ ] `ls -ld /srv/sandbox` 权限允许 Tomcat 用户 + uid 10001 写
- [ ] Tomcat 启动日志 4 行关键信号齐全（§2）
- [ ] `GET /ai/api/admin/skills/list` 返 ≥ 19 个 `sys_*` skill，且包含 `runtimeKind`
- [ ] 对话里触发一次 code_exec，`AI_TOOL_AUDIT` 有对应记录，`AI_ARTIFACT` 有 docx 产物
- [ ] `save_to_oa` 新建到 OA stub / 真实 OA，下载后 SHA256 与原 artifact 一致
- [ ] 取消测试：起一个 30s sleep 任务，发 cancel，1 秒内容器被 kill
- [ ] 安全测试（可选）：`--network none` 下 socket 连接失败；`/etc/passwd` 写失败；60MB 输出被拒

十条全绿即 P0 可收。
