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.14 | CPython + 依赖包 | pip、编译器(装 C 扩展时) |
| 前端 / 共享事件协议客户端 | TypeScript | 只要编译后的 .js | node、npm、tsc |
| 事件协议 schema | TS 源 | 无 | 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-dir | pip 的 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-slim | sha256:a2b82f3c48559aa0a8446d9af49826b6e2b2016f4cd2afabfe6013ec53729170 |
node:26-slim | sha256: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 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。