zcbot/deploy/sandbox/Dockerfile

185 lines
10 KiB
Docker

# Per-user sandbox base image (DESIGN §7.5).
# 长驻 per-user 容器,工具调用走 `docker exec --user 1000:1000 --workdir /workspace/<wd> setsid <cmd>`(Step 3 接入)。
#
# 启动协议:tini(PID 1,reap 僵尸) → init.sh(root,跑 iptables 配 blocklist) → sleep infinity。
# `docker exec` 进来的进程由 --user flag 决定身份 ── 应用层(DockerExecutor)硬编 --user 1000,
# 不继承 PID 1 的 root 上下文。
#
# 构建上下文 = repo 根(用 -f 指定 Dockerfile 路径):
# docker build -f deploy/sandbox/Dockerfile -t zcbot-sandbox \
# --build-arg HOST_UID=$(id -u zcbot) --build-arg HOST_GID=$(id -g zcbot) .
FROM python:3.12-slim
# apt 源可配(同 pip / npm 同款,境内访问 deb.debian.org 慢):
# --build-arg APT_MIRROR=https://mirrors.cloud.tencent.com # 腾讯云内网
# --build-arg APT_MIRROR=https://mirrors.aliyun.com # 阿里云
# 默 Debian 官方源;只替 host 前缀,后面 `/debian` / `/debian-security` 不动
# (mirror 站镜像结构与官方一致)
ARG APT_MIRROR=
RUN if [ -n "${APT_MIRROR}" ]; then \
sed -i \
-e "s|http://deb.debian.org|${APT_MIRROR}|g" \
-e "s|https://deb.debian.org|${APT_MIRROR}|g" \
-e "s|http://security.debian.org|${APT_MIRROR}|g" \
-e "s|https://security.debian.org|${APT_MIRROR}|g" \
/etc/apt/sources.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \
fi && \
printf 'Acquire::Retries "5";\nAcquire::http::Pipeline-Depth "0";\nAcquire::http::No-Cache "true";\n' \
> /etc/apt/apt.conf.d/80-zcbot-retries
# - iptables / ip6tables: init.sh 配 blocklist 需要(NET_ADMIN cap 在 docker run 处加)
# - iproute2: ip 命令(调试 / 排查)
# - netbase: /etc/protocols /etc/services(curl / 多数网络库依赖)
# - ca-certificates: HTTPS 出网(经 proxy 也要,Step 4)
# - tini: PID 1 信号转发 + 僵尸 reaper(setsid 包出的进程组结束时 PID 1 兜底回收)
RUN apt-get update && apt-get install -y --no-install-recommends \
iptables iproute2 netbase ca-certificates tini \
&& rm -rf /var/lib/apt/lists/*
# 容器内非 root 用户的 uid/gid 必须与 host 上 `zcbot` 用户对齐(bind mount 保 host owner;
# 错配会导致 exec 进来后 write /workspace 时 EACCES)。build-arg 默认 1000,部署时按
# `id -u zcbot` 实际值显式传(详 RUN.md "sandbox 部署"段)。
ARG HOST_UID=1000
ARG HOST_GID=1000
RUN groupadd -g ${HOST_GID} zcbot && useradd -u ${HOST_UID} -g ${HOST_GID} -m -s /bin/bash zcbot
# 装全套 requirements ── 模型在 run_python 里写的脚本可能用 fastapi / sqlalchemy / litellm
# 等,装齐免 "ModuleNotFoundError" 摩擦。镜像偏大(~1G)是接受成本。
#
# pip 源可配(境内访问 files.pythonhosted.org 慢 / ReadTimeout):
# --build-arg PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/ # 腾讯云内网
# --build-arg PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/ # 阿里云
# --build-arg PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple/ # 清华
# 默 PyPI 官方;timeout 拉到 60s 兜底抖动。
ARG PIP_INDEX_URL=https://pypi.org/simple/
ARG PIP_TRUSTED_HOST=
COPY requirements.txt /tmp/requirements.txt
# 剔除标 `[host-only]` 的行(如 pilk:语音解码只在 host web 进程用,容器内 run_python
# 用不到;它无 py3.12 预编译 wheel,slim 镜像没 gcc 会把整个 build 编崩)
RUN grep -v '\[host-only\]' /tmp/requirements.txt > /tmp/requirements.sandbox.txt \
&& pip install --no-cache-dir \
--index-url ${PIP_INDEX_URL} \
${PIP_TRUSTED_HOST:+--trusted-host ${PIP_TRUSTED_HOST}} \
--timeout 60 \
-r /tmp/requirements.sandbox.txt \
&& rm /tmp/requirements.txt /tmp/requirements.sandbox.txt
# 持久化 pip 源到 /etc/pip.conf ── 让运行时模型用 `pip install foo` 也走 mirror,
# 不只 build 时。zcbot user / root 都吃这个 global 配置。
RUN printf '[global]\nindex-url = %s\ntimeout = 60\n%s\n' \
"${PIP_INDEX_URL}" \
"${PIP_TRUSTED_HOST:+trusted-host = ${PIP_TRUSTED_HOST}}" \
> /etc/pip.conf
# Node + mermaid-cli + Chromium ── proposal / patent skill 渲 mermaid 图必备
# 镜像膨胀约 +400MB,接受成本(ASCII fallback 出 docx 没图不能用)
# Debian 自带 nodejs + chromium,够新;不走 NodeSource repo 减一步外网
#
# CHROMIUM_REFRESH:本层 cache-bust 旋钮。apt 拿的是 build 时源里的最新点版本,而
# 点版本可能自带致命 bug(2026-07 实锤:150.0.7871.46 启动即 int3 自杀、零日志,
# .100 已修 ── 完整排查见 RUN.md 故障兜底"chromium 秒崩"条)。chromium 出问题时
# 改这个日期重建,只刷新本层及之后,上面的 pip 大层不动。
ARG CHROMIUM_REFRESH=2026-07-11
RUN echo "chromium refresh: ${CHROMIUM_REFRESH}" \
&& apt-get update && apt-get install -y --no-install-recommends \
chromium nodejs npm \
&& rm -rf /var/lib/apt/lists/*
# 中文字体 + emoji 字体 ── 不装则 matplotlib / mermaid(chromium) / render_icon 出的
# PNG 里中文 / emoji 全是方块(豆腐块 □)。装三套:
# - fonts-noto-cjk: 出版级字形,matplotlib 出版图 / mermaid 节点首选(+~330MB)
# - fonts-wqy-microhei: 兜底,匹配 style.py 候选 'WenQuanYi Micro Hei'
# + render_icon.py 引用的 wqy-microhei.ttc 路径(+~5MB)
# - fonts-noto-color-emoji: 模型常在 mermaid 节点标签前缀 emoji 图标(🌐🔥🛡 等),
# 缺此字体则 chromium 渲染时每个 emoji 都成 □,满图豆腐块(+~10MB)。chromium
# 支持 COLR/CBDT 彩色 emoji,fontconfig fallback 到它即可正常出图标。
# fc-cache 刷 fontconfig 索引 ── chromium 经 fontconfig 选字必需;matplotlib 走自家
# font_manager 扫 /usr/share/fonts,运行时首次用图自动建缓存,无需在此处理。
RUN apt-get update && apt-get install -y --no-install-recommends \
fonts-noto-cjk fonts-wqy-microhei fonts-noto-color-emoji fontconfig \
&& fc-cache -f \
&& rm -rf /var/lib/apt/lists/*
# npm 源可配(同 pip 一样,境内访问 registry.npmjs.org 慢):
# --build-arg NPM_REGISTRY=https://mirrors.cloud.tencent.com/npm/ # 腾讯云
# --build-arg NPM_REGISTRY=https://registry.npmmirror.com/ # 阿里
ARG NPM_REGISTRY=https://registry.npmjs.org/
# Puppeteer 用容器内已装的 chromium 而非自带下载(省 ~300MB + 避免下载失败)
ENV PUPPETEER_SKIP_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
# fetch-retries 默认只有 2,境内 mirror 偶发 ECONNRESET(2026-07-08 腾讯源拉 cytoscape
# 断流实测)→ 拉到 5 次 + 放宽重试窗口,单次抖动不再崩整个 build
RUN npm config set registry ${NPM_REGISTRY} \
&& npm config set fetch-retries 5 \
&& npm config set fetch-retry-maxtimeout 120000 \
&& npm install -g @mermaid-js/mermaid-cli@latest \
&& npm cache clean --force
# 持久化 npm 源到 /etc/npmrc ── 让运行时模型用 `npm install bar` 也走 mirror,
# 不只 build 时。zcbot user 跑 npm 也吃这个 global 配置(优先级:proj > user > global)。
RUN printf 'registry=%s\n' "${NPM_REGISTRY}" > /etc/npmrc
# 容器内 puppeteer 启动 chromium 必备:no-sandbox(容器已 hardening 不需要 chromium 自家
# sandbox 再叠一层 setuid)、disable-setuid-sandbox(同上)、disable-dev-shm-usage
# (容器 /dev/shm 默 64MB 不够 chromium,让它走 /tmp)
RUN mkdir -p /sandbox && cat > /sandbox/puppeteer-config.json <<'EOF'
{
"executablePath": "/usr/bin/chromium",
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
}
EOF
# mmdc wrapper ── 容器 --cap-drop=ALL 下 chromium 自家 setuid sandbox 起不来,必须
# --no-sandbox(+ --disable-dev-shm-usage),这些在上面的 /sandbox/puppeteer-config.json 里。
# 但 mmdc **不读任何 env 自动加载**该 config(只认 -p/--puppeteerConfigFile);模型裸调
# `mmdc -i x.md -o x.png` 会因缺 --no-sandbox 直接跪 → 然后反复试 mermaid.ink 等在线 API
# (容器 internal network 禁外网,死路),实测一条对话这么烧掉 ~120k token。wrapper 在调用方
# 没显式传 -p 时自动注入这份 config,让裸调一次成;已显式 -p 则尊重不覆盖。proposal 的
# render_diagrams.py 等走 `which mmdc` 的脚本同样透明受益(原靠 MERMAID_PUPPETEER_CONFIG
# env,已删 ── wrapper 兜底,不再依赖那个谁都不读的 env)。
RUN mv /usr/local/bin/mmdc /usr/local/bin/mmdc.real \
&& cat > /usr/local/bin/mmdc <<'EOF'
#!/bin/sh
for a in "$@"; do
case "$a" in
-p|--puppeteerConfigFile|--puppeteerConfigFile=*)
exec /usr/local/bin/mmdc.real "$@" ;;
esac
done
exec /usr/local/bin/mmdc.real -p /sandbox/puppeteer-config.json "$@"
EOF
RUN chmod +x /usr/local/bin/mmdc
# build 时渲染 canary ── 用整条真实链路(node + mmdc wrapper + puppeteer config +
# chromium + CJK 字体)渲一张最小图,chromium 起不来 / 字体缺失则 **build 当场失败**,
# 而不是上线后静默挂(2026-07 事故:坏点版本 150.0.7871.46 烤进镜像,线上 26 次
# launch fail + 67 次 timeout、烧掉数十万 token 才被发现)。带中文节点顺带验字体链。
RUN printf 'flowchart TB\n A[数据准备]-->B[模型训练]\n' > /tmp/canary.mmd \
&& timeout 120 mmdc -i /tmp/canary.mmd -o /tmp/canary.png \
&& test -s /tmp/canary.png \
&& rm -f /tmp/canary.mmd /tmp/canary.png
# 基础诊断工具:file(magic bytes 探测文件真实类型)── 模型验产物的高频习惯动作,
# slim 基底不带(toolfail 巡检实测 7 天 13 个任务撞 `command not found`)。
# 独立小层放 pip/chromium 大层之后,加包不打穿上面的 cache。
RUN apt-get update && apt-get install -y --no-install-recommends file \
&& rm -rf /var/lib/apt/lists/*
# fs 工具进容器(§7.5 #6,2026-05-26 修正)── tool_runner.py 在容器内通过
# `python /sandbox/tool_runner.py <name>` 调用 tools/fs.py 的 Tool 子类,read/write/
# edit/glob/grep 全在容器内执行,物理边界替代代码护栏。tools/ 目录与 host 同步
# (build 时 COPY,不挂 mount ── 容器内代码不应跟随 host repo 修改重启)。
COPY tools/ /sandbox/tools/
COPY core/sandbox/tool_runner.py /sandbox/tool_runner.py
COPY deploy/sandbox/init.sh /init.sh
RUN chmod +x /init.sh
# 默认 cwd /workspace ── 但每次 `docker exec --workdir /workspace/<wd>` 会覆盖
WORKDIR /workspace
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/init.sh"]