返回 AICAP-180
B8 · Day 77真 API + 流式 + Docker

多阶段 Docker

B8 这一段把 agent runner 从「脚本」推向「真服务」。

阶段: B8 · 真 API + 流式 + Docker(Day 71-80) 标签: #docker #multi-stage #distroless #layer-cache

今日导引(由浅入深)

B8 这一段把 agent runner 从「脚本」推向「真服务」。 前几天打通了 HTTP 边界(Day 71)、SSE(Day 72)、模型流式(Day 73)、typed 错误(Day 74)、eval 接 API(Day 75)、双模型横评(Day 76)。

代码层面跑通之后,要让别人 / CI / 云能复现地跑起来,就得封装成镜像——这是 B8→B9(部署)的桥。 今天学多阶段构建:把「编译时需要的一大堆 devDeps」和「运行时只需要的产物」彻底分层,再叠 distroless 基镜与 layer 缓存顺序,把镜像压小、攻击面缩小。

今天的「最小可判定产出」是用 Dockerfile.mcp 跑一次 docker builddocker images 读出首版体积数字——这是 Day 78 瘦身的基线锚点。

1. 机理精读

多阶段构建(multi-stage build)的核心是「构建产物 ≠ 运行依赖」。 一个 Node 服务,编译 / 装包阶段要 pnpm、tsc/tsx、全套 devDependencies; 但真正 docker run 起来时,这些大半都是死重。

多阶段把 Dockerfile 切成几个 FROM ... AS <stage>: 在 deps / builder 阶段装全套、编译; 在 runtime 阶段只 COPY --from=deps 把需要的产物搬过来。 最终镜像只含 runtime 阶段的内容,devDeps 的体积和攻击面留在了不进入最终镜像的中间层——它们只是「构建脚手架」,build 完即弃。

distroless 基础镜像把攻击面再削一层。 distroless 镜像(Google 维护)不含 shell、包管理器、coreutils——只有语言运行时和你的应用。 好处有二:

  • 体积小——少了一整套 OS 用户态工具。
  • 安全——攻击者拿到 RCE 也没有 sh / apt / curl 可用,横向移动被掐死。

代价是调试难(进不去 shell),但生产 runtime 本就不该让人 exec 进去戳。

layer 缓存顺序决定增量构建快慢。 Docker 按层缓存,某层的输入没变就复用缓存。 黄金法则:变化频率低的放前面,高的放后面

所以「先 COPY lockfile 装依赖、后 COPY 源码」—— 依赖清单几周才动一次,依赖安装层能长期命中缓存; 源码天天改,放最后,改源码只重建最后几层。

反过来先 copy 源码再装依赖,每改一行代码都得重装全部依赖,CI 慢到崩——这是新手最常见的 Dockerfile 反模式。

与相邻概念的边界。 多阶段解决「最终镜像别装编译期垃圾」; distroless 解决「runtime 基镜别带攻击面」; BuildKit 缓存挂载(--mount=type=cache)解决「跨构建复用包管理器缓存」。 三者正交、可叠加,明天(Day 78)的 <300MB 目标正是三者合力 + Next standalone + 严格 .dockerignore

2. 推导 / 手算 / 代码走读

代码走读 Dockerfile.mcp(仓库已有,跑 MCP server,无需 API key):

  1. FROM node:20-slim AS base: 基镜选 node:20-slim(非 distroless 但已是 slim 变体),设 PNPM_HOME=/pnpmPATH=$PNPM_HOME:$PATHRUN corepack enable 启用 pnpm,WORKDIR /app。这是后续两阶段的共同底座。
  2. FROM base AS deps: 先 COPY package.json pnpm-lock.yaml ./,再 RUN pnpm install --frozen-lockfile—— 只 copy lockfile 再装依赖,正是 layer 缓存的黄金顺序; --frozen-lockfile 保证可复现(lockfile 不一致直接失败,不偷偷改版本)。
  3. FROM base AS runtimeCOPY --from=deps /app/node_modules ./node_modules 把上一阶段装好的依赖搬过来(关键的「多阶段」动作), 再 copy package.json / pnpm-lock.yaml / tsconfig.jsonsrcscripts
  4. 运行配置ENV PORT=8765EXPOSE 8765CMD ["pnpm", "mcp:serve"]。 注释明说 "tsx (devDependency) executes the TS entrypoint directly; the MCP server is stateless HTTP."—— 当前用 tsx 直跑 TS,无单独编译阶段(明天瘦身可换 standalone 输出)。
  5. 顶部注释给出确切命令: build:docker build -f Dockerfile.mcp -t momoweb3-mcp . run:docker run -p 8765:8765 -e MCP_AUTH_SECRET=dev-secret momoweb3-mcp (省略 MCP_AUTH_SECRET 即关掉 OAuth gate,本地 dev 用——见 B7 的授权层)。

走读结论:当前 Dockerfile.mcp 是 base→deps→runtime 三段, 但 runtime 仍从 deps 搬整套 node_modules含 devDeps),尚未做 prod-only 裁剪—— 这正是 Day 78 瘦身的优化目标,今天先量首版体积、确立基线。

3. 今日实战

  1. 确认前置件就位:Dockerfile.mcp + .dockerignore(✅ 已构建,build 是手动 docker step)。
  2. 本机跑:docker build -f Dockerfile.mcp -t momoweb3-mcp .(首次会装全套依赖,注意观察各层缓存命中情况)。
  3. docker images 读出 momoweb3-mcp 的首版体积(MB),记入 worklog 作瘦身前基线。
  4. 改一行源码后重 build,验证只重建最后几层(layer 缓存命中验证)。
  5. 可选 docker run -p 8765:8765 momoweb3-mcp 起容器验证 pnpm mcp:serve 能拉起(无 MCP_AUTH_SECRET 即跳过 OAuth gate)。

4. 今日实测 / 产出

  • Dockerfile.mcp.dockerignore 已构建(artifact 在仓库)。
  • 首版镜像体积数字(MB)为 外部动作 / 手动 docker step——需在本机跑 docker build 后读数。
  • CI 未自动构建镜像(诚实标注:仓库侧只有 Dockerfile,云 / CI 自动 build 链路是 B9 待接)。

5. 常见误区 / 陷阱

  1. 缓存顺序写反: 先 COPY . . 再装依赖,改一行源码就触发全量重装,CI 时间炸裂。务必先 copy lockfile 装依赖、后 copy 源码。
  2. 把 devDeps 漏进 runtime 层: 本仓当前 Dockerfile.mcp 就从 deps 搬了整套 node_modules(含 devDeps)——首版体积偏大的主因,留给 Day 78 用 --prod 裁。
  3. 直接用 node:latest 全量基镜: 数 GB、攻击面大。最少用 slim,能 distroless 更好。
  4. 忘了 .dockerignore: 不排除 node_modules / .git / docs 会把构建上下文撑爆,并可能把本地 node_modules 误打进镜像。

6. 学习资源(每条带 YYYY-MM)

  • Docker 官方文档 — Multi-stage builds(Docker, 2025-11 持续更新版)。
  • Distroless 项目(GoogleContainerTools/distroless, 2025-10)——distroless/nodejs 运行时镜像与最小攻击面理念。
  • Docker BuildKit — Cache mounts(--mount=type=cache)文档(Docker, 2025-09)。
  • Next.js — Output File Tracing / output: 'standalone'(Vercel, 2026-01)——明天瘦身的关键。
  • 本仓 artifact:Dockerfile.mcp.dockerignore(2026-06,AICAP-180 B8)。

SOTA检查 (2026-06 更新)

  • 多阶段 + distroless 仍是镜像瘦身 SOTA。Node 场景推荐 standalone 输出 + distroless/nodejs 运行时。
  • BuildKit 缓存挂载为当前推荐做法(跨构建复用 pnpm / npm 缓存)。
  • 过时黑名单:直接 node:latest 全量基镜(数 GB、攻击面大);缓存顺序写反;devDeps 进 runtime 层。
  • 下次复查点:Day 78 量化瘦身效果(目标 <300MB);BuildKit / distroless 版本执行当周核对。

衔接

  • 昨天:Day 76 — 流式接 Qwen3 对比(双模型横评,代码层跑通选型矩阵)
  • 今天:把跑通的服务用多阶段 Dockerfile 封装,量首版镜像体积——可复现部署的第一步
  • 明天:Day 78 — 镜像瘦身 <300MB(叠 prod-only / standalone / .dockerignore,把首版体积压下来)