《AI Agent 工程实战》16.1 TS 落点三:Python + Node 双栈镜像瘦身

本节是 TS 落点三的上半场:把 ResearchCopilot 打成 Python + Node 双栈镜像。用多阶段构建分别产出 TS 前端产物与 Python 依赖,只把产物拷进运行时镜像;再叠加 .dockerignore、--no-cache-dir、slim 基础镜像与 digest 固定四个瘦身杠杆。Dockerfile 在本机真构建,贴出镜像体积、分层与容器内验证的真实输出。

16.1 TS 落点三:Python + Node 双栈镜像瘦身

前面十五章,ResearchCopilot 的每一块都跑在本机裸环境里:Python 3.14 的 venv、node file.ts 直跑的 TS、uvicorn 起的进程。可一旦要交付给别人,这套「在我机器上能跑」的环境就散了。把服务装进容器是唯一能钉死环境的办法。而我们的服务天生是双栈的——Python 后端 + Node 侧构建的 TS 产物——所以镜像也得是双栈的。

本节把 ResearchCopilot 推进到可交付形态的第一步:写一份多阶段构建的 Dockerfile,把 Python 依赖与 TS 前端产物分阶段构建、只把产物拷进运行时镜像,再用四个杠杆把体积压下来。所有命令在本机真跑,输出照贴。

16.1.1 为什么 Agent 服务需要双栈镜像

ResearchCopilot 的代码分成两半,运行环境完全不同:

部分语言运行时需要什么构建时需要什么
API / worker / 引擎Python 3.14CPython + 依赖包pip、编译器(装 C 扩展时)
前端 / 共享事件协议客户端TypeScript只要编译后的 .jsnode、npm、tsc
事件协议 schemaTS 源无tsc 产出 .d.ts + .js

关键观察是:Node 只在「构建」时需要,运行时一个字节都不需要。 前端那几十行 TS 编译成 client.js 后,浏览器或静态服务器直接就能跑,Python 运行时根本不认识 node。同理,pip 装依赖时用到的编译器和 pip 缓存,运行时也不需要。

如果把这些都塞进同一个镜像,你交付的就有一份几百 MB 的 Node 运行时和一个装满缓存的 pip 目录,它们永远不参与运行,只是让镜像变胖、拉取变慢、攻击面变大。多阶段构建解决的正是这件事。

16.1.2 三个阶段:builder 各建各的,runtime 只收产物

Dockerfile 的核心思想是**「每个阶段只干一件事,最后只搬走产物」**。ResearchCopilot 用三个阶段:

# syntax=docker/dockerfile:1

# ---------- 阶段 1:构建 TypeScript 前端产物 ----------
FROM node:26-slim AS ts-builder
WORKDIR /build
COPY frontend/package.json frontend/tsconfig.json ./
RUN npm install --no-audit --no-fund
COPY frontend/src ./src
RUN npx tsc

# ---------- 阶段 2:Python 依赖装进独立虚拟环境 ----------
FROM python:3.14-slim AS py-builder
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# ---------- 阶段 3:运行时,只带 venv 与产物 ----------
FROM python:3.14-slim AS runtime
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"
WORKDIR /app
COPY --from=py-builder /opt/venv /opt/venv
COPY app ./app
COPY --from=ts-builder /build/dist ./static
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

三个 FROM 就是三个阶段,各自独立:ts-builder 从一个 Node 镜像起步、跑完 tsc 就功成身退;py-builder 把依赖装进 /opt/venv;只有最后的 runtime 会被打成最终镜像,它用 COPY --from=<阶段名> 精确地只搬两样东西——/opt/venv 和 /build/dist。

有几个细节值得说清:

  • COPY frontend/package.json 先于 COPY frontend/src。Docker 的层缓存按指令逐条比对:只要 package.json 没变,npm install 那层就命中缓存;改了源码只重跑 npx tsc。这是「先拷贝依赖清单、再拷贝源码」的经典顺序。
  • 依赖装进 venv 而不是系统 Python。--prefix 方式装出来的包不在 sys.path 上,容易踩坑;venv 是官方推荐、路径干净的做法,整个 /opt/venv 原样拷过去就能用。
  • 运行时镜像和 builder 用同一个基础镜像(python:3.14-slim)。venv 里的解释器符号链接指向的是同一套 CPython,版本必须对齐,否则拷过去跑不起来。

16.1.3 四个瘦身杠杆

多阶段只是第一刀。把体积压到最小,还要叠四个杠杆,它们各管一段:

杠杆做法省掉什么
多阶段构建builder 与 runtime 分开,只 COPY --from 产物Node 运行时、编译器、pip/npm 缓存
不落缓存pip install --no-cache-dirpip 的 wheel 缓存目录
.dockerignore排除 node_modules、dist、__pycache__、.git、tests构建上下文体积、误拷进镜像的杂物
slim/alpine + digest基础镜像选 -slim 或 -alpine,并按 digest 固定完整发行版的编译工具链,以及「今天拉的镜像和明天不一样」

.dockerignore 是四个里最容易被忽略、却最该先做的。它同时管两件事:一是构建上下文的大小(docker build 会先把整个目录发给 daemon,含 node_modules 时能到几百 MB),二是防止把开发期杂物拷进镜像。ResearchCopilot 的 .dockerignore:

**/node_modules
**/dist
**/__pycache__
**/*.pyc
.venv
venv
.git
.gitignore
*.md
tests/

这份文件生效后,本机构建上下文只有 15.36 kB——因为 .git、node_modules、__pycache__ 全被挡在外面了。同一份代码,没有 .dockerignore 时上下文可能是它的上千倍。

slim 与 alpine 的取舍则要看清代价:python:3.14-alpine 只有 77.5 MB,比 python:3.14-slim 的 206 MB 小得多,但 alpine 用的是 musl libc,装带 C 扩展的包(psycopg、numpy)时常要现编译、轮子也不全。本项目依赖含 C 扩展,所以选 -slim 换稳定性;纯 Python 服务才值得上 alpine。

16.1.4 latest 是坑:为什么要固定 digest

FROM python:3.14-slim 里的 -slim 已经比 python:3.14 稳,但它仍然会漂——上游只要重发一个补丁版,3.14-slim 指向的镜像就变了。这一点在本机实测里就露了头:宿主机 Homebrew 的 Python 是 3.14.6,而 python:3.14-slim 镜像里 python -c "import sys; print(sys.version)" 打出来的是 3.14.8。同一条 3.14-slim,在不同时间、不同机器上可能是 3.14.7、3.14.8……

latest 更糟:它连「大版本」都不保证,某天上游把 latest 指向 4.x,你的构建就悄悄换了底座,而且不可复现——同一个 Dockerfile 昨天构建成功、今天构建失败,谁也说不清差在哪。

解法是按 digest 固定,digest 是镜像内容的 sha256,指向的字节永远不变:

FROM python:3.14-slim@sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 AS runtime

本机两个基础镜像的 digest(docker image inspect 实测)是:

镜像RepoDigest
python:3.14-slimsha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170
node:26-slimsha256:193fe51b64e77981119c98c2002c9e32a70e2f006fb4d25068ce0558998917f0

工程上的折中做法是:开发期用 tag(可读、好升级),CI/生产用 digest(可复现),并配一个定期「bump digest」的自动化 PR。既不让底座偷偷漂,也不至于手动维护一串哈希。

16.1.5 真跑:构建、体积与容器内验证

先说明一件事:BRIEF 里提到用 docker build --check 做 Dockerfile 静态检查,但本机的 docker CLI 没有安装 buildx 插件,该命令直接报错:

$ docker build --check .
unknown flag: --check
Usage:  docker build [OPTIONS] PATH | URL | -

--check 是 buildx 提供的功能,本机没装(docker buildx 也是 unknown command)。所以这里改用 legacy builder 做一次完整的真实构建——它比静态检查更强,因为它真的把每个阶段跑了一遍。

先看本机现有的镜像清单(docker images 实测,节选):

$ docker images --format '{{.Repository}}:{{.Tag}}  {{.Size}}'
node:26-slim        378MB
node:22.23.3-slim   349MB
python:3.14-slim    206MB
python:3.14-alpine  77.5MB
python:3.12-slim    205MB
postgres:17-alpine  415MB
redis:7-alpine      58.7MB

注意 python:3.14-slim 与 node:26-slim 本来不在本地清单里,是本节为演示先 docker pull 下来的;postgres:17-alpine、redis:7-alpine 本地已有(下一节的 compose 会用到)。读者第一次构建时同样会经历一次拉取。

真实构建(节选关键步骤):

$ docker build -t researchcopilot:0.1.0 .
Sending build context to Docker daemon  15.36kB
Step 1/19 : FROM node:26-slim AS ts-builder
Step 4/19 : RUN npm install --no-audit --no-fund
Step 6/19 : RUN npx tsc
Step 7/19 : FROM python:3.14-slim AS py-builder
Step 11/19 : RUN pip install --no-cache-dir -r requirements.txt
Step 12/19 : FROM python:3.14-slim AS runtime
Step 15/19 : COPY --from=py-builder /opt/venv /opt/venv
Step 17/19 : COPY --from=ts-builder /build/dist ./static
Step 19/19 : CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
Successfully tagged researchcopilot:0.1.0

构建成功。把同一条命令再跑一次,就能看到层缓存的威力——凡是「输入没变」的步骤都直接复用,不再重跑:

$ docker build -t researchcopilot:0.1.0 .   # 第二次构建
Step 1/19 : FROM node:26-slim AS ts-builder
 ---> 193fe51b64e7
Step 2/19 : WORKDIR /build
 ---> Using cache
 ---> 4c2eb27b9fbe
Step 3/19 : COPY frontend/package.json frontend/tsconfig.json ./
 ---> Using cache
 ---> d2ea2f146200
Step 4/19 : RUN npm install --no-audit --no-fund
 ---> Using cache
 ---> 4ac3122fe194

Step 4 的 npm install 直接 Using cache,一秒都没重跑。这正是「依赖清单先于源码拷贝」的回报:只要 package.json / requirements.txt 没变,npm install / pip install 这两层永远命中缓存;你改 app/main.py 只会从 COPY app 往后重建。镜像构建的快慢,八成取决于你有没有把「变得慢的层」排在「变得快的层」前面。

看最终镜像的体积和分层(docker history 实测):

$ docker images --format '{{.Repository}}:{{.Tag}}  {{.Size}}' | grep researchcopilot
researchcopilot:0.1.0  246MB

$ docker history researchcopilot:0.1.0 --format '{{.Size}}\t{{.CreatedBy}}'
33.1MB   COPY dir:... in /opt/venv
20.5kB   COPY dir:... in ./app
16.4kB   COPY dir:... in ./static

逐行读:运行时镜像 = 基础镜像 206 MB + venv 33.1 MB + 应用 20.5 kB + TS 产物 16.4 kB。那 378 MB 的 node:26-slim、npm install 拉下的 node_modules、pip 的下载缓存——一层都没进来。为验证这一点,直接在运行时容器里找 node:

$ docker run --rm researchcopilot:0.1.0 sh -c 'command -v node || echo "node: 不存在"'
node: 不存在
$ docker run --rm researchcopilot:0.1.0 sh -c 'command -v npm || echo "npm: 不存在"'
npm: 不存在

最后确认这个镜像真能跑,而不是「构建成功但一启动就崩」:

$ docker run -d --name rc16 -p 18000:8000 researchcopilot:0.1.0
$ curl -s http://127.0.0.1:18000/healthz
{"status":"ok"}
$ docker run --rm researchcopilot:0.1.0 ls -1 /app/static
client.js

/healthz 返回 {"status":"ok"},说明 venv 里的 fastapi、uvicorn 都能正常 import;/app/static/client.js 存在,说明 ts-builder 的 tsc 产物确实被搬了进来。从 Node 构建到 Python 运行,双栈在这一个镜像里合流了。

16.1.6 验收清单

  • 多阶段构建:builder 阶段跑 npm install / pip install,runtime 只 COPY --from 产物。
  • 依赖清单先于源码拷贝(COPY requirements.txt 在 COPY app 之前),缓存才能命中。
  • pip install --no-cache-dir;.dockerignore 排除 node_modules / dist / __pycache__ / .git。
  • 基础镜像选 -slim(本项目依赖有 C 扩展,不用 alpine)。
  • 生产镜像按 digest 固定,不用 latest。
  • 容器内 /healthz 通、/app/static 有 TS 产物、运行时无 node。
  • 构建上下文足够小(本机 15.36 kB),.git / node_modules 没被打包进去。
  • 第二次构建时 npm install / pip install 命中缓存(日志里是 Using cache)。

真实环境里会怎样:本机是 arm64(Apple Silicon),构建出的镜像也是 arm64;部署到 x86 服务器时要么在 CI 里用 --platform linux/amd64 交叉构建,要么用 buildx 的 --platform 一次出多架构镜像。另外真实项目的 node_modules 常有几百 MB,多阶段的收益比本节这个小 demo 大得多——本机最终镜像 246 MB 里有 206 MB 是基础镜像本身,留给「瘦身」的空间只有那 40 MB 的应用层,所以「换更小的基础镜像」往往比「优化应用层」更值钱。这一节把镜像做出来了,但一个镜像跑不起来一个服务——服务还需要 Postgres、Redis 这些邻居。下一节用 compose 把它们编排起来。

阅读导航:上一节:15.3 审计与合规:不可篡改审计日志 · 下一节:16.2 本地编排:compose 起 API/worker/postgres/redis 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距