KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 一个真能跑起来的 Dockerfile:逐行的理由与代价 — keel 龙骨
这一章回答:一份多阶段 Dockerfile 里每一行在决定什么?哪些写法会让构建直接失败,哪些会让镜像带着不需要的东西上线?
这一章回答:一份多阶段 Dockerfile 里每一行在决定什么?哪些写法会让构建直接失败,哪些会让镜像带着不需要的东西上线?
第 01、02 章建立了两个机制:上下文决定传什么,指令顺序决定复用什么。这一章把它们落成一份具体的文件,逐行说明理由,也逐行说明代价。
这里只讲造出一个能跑的镜像。镜像造出来之后怎么管(版本、签名、扫描)、怎么发(部署策略、回滚)属于发布产物视角,指向《构建打包与部署上线》。
现场
一份从别处抄来的多阶段 Dockerfile,在本地开发机上是好的(因为本地有 node_modules 和完整的仓库),推到 CI 上第一次构建就挂了,日志停在第六秒:
Step 6/13 : RUN npm ci
---> Running in 8c2f41...
npm error code EUSAGE
npm error The `npm ci` command can only install with an existing package-lock.json or
npm error npm-shrinkwrap.json with lockfileVersion >= 1.
第一反应通常是"CI 环境有问题"。不是。这个失败和 CI 无关——它是因为仓库里从来没有 package-lock.json,而 npm ci 的设计契约就是"必须有一份锁定清单"。本地之所以"好的",只是因为你从来没在干净目录里跑过它。
一、npm ci 与 npm install 是两个不同的契约
在实验目录(package.json 里有 dependencies 与 devDependencies,但没有 package-lock.json)里执行:
$ npm ci --dry-run
npm error code EUSAGE
npm error
npm error The `npm ci` command can only install with an existing package-lock.json or
npm error npm-shrinkwrap.json with lockfileVersion >= 1. Run an install with npm@5 or
npm error later to generate a package-lock.json file, then try again.
npm error
exit=1
(--dry-run 只是让它不落盘;退出码为 1,构建会在这一步直接中止。测量环境:npm 10.9.7 / Node 22.22.2。)
两个命令的契约差异,是"能不能重复构建"的核心:
npm install |
npm ci |
|
|---|---|---|
| 需要 lockfile | 否,会生成/更新它 | 是,缺了直接失败(EUSAGE,退出码 1) |
package.json 与 lockfile 不一致时 |
按 package.json 解析,并把结果写回 lockfile |
报错退出,不改任何文件 |
对 node_modules 的处理 |
增量调整,可能保留已有内容 | 先删除再全量安装 |
| 解析结果是否确定 | 受时间影响(同一份 ^1.3.0 在不同日子可能解析到不同版本) |
由 lockfile 完全确定 |
| 适合 | 本地开发(要让依赖升级发生) | CI 与镜像构建(要的是可重复) |
这条差异链上有三个工程结论:
package-lock.json必须进版本库,也必须进 Dockerfile 的COPY清单。锁文件缺失不是"风格问题",而是让构建从"确定"退化成"看运气"。- 镜像里用
npm ci,不要用npm install。 镜像的价值之一是"同一份源码在任何人机器上构建出同一个东西",npm install会把这个性质放弃掉。 npm ci报EUSAGE时不要去改 CI 配置。它是在告诉你一个真实缺陷:源码树里少了一份必须被提交的文件。
二、只装生产依赖:用显式的开关
npm ci 默认会装 devDependencies。最终镜像里不需要构建工具,所以运行阶段要排除它们。
npm 10.9.7 的 npm ci --help 里,这个开关的官方形式是:
[--omit <dev|optional|peer> [--omit <dev|optional|peer> ...]]
[--include <prod|dev|optional|peer> [--include <prod|dev|optional|peer> ...]]
也就是 --omit=dev。
这里有一个容易踩的坑:不要用 NODE_ENV=production 来代替它。 NODE_ENV 是给你的应用代码看的环境标记(决定日志级别、是否开调试端点等),它不是一个可靠的"只安装生产依赖"开关——历史上不同 npm 大版本对它的处理方式变过,而 --omit / --include 是显式声明的契约,不随版本变化。判据很简单:要影响包管理器的行为,就用包管理器的开关;ENV 留给应用。
另外注意 --omit 与 --include 是互补的两个方向,不要同时写。
三、基础镜像:node:22 与 node:22-alpine 不是"大小版本"
多阶段 Dockerfile 用的是 node:22-alpine。这个选择有代价,且代价不在体积上:
node:22(Debian 系) |
node:22-alpine |
|
|---|---|---|
| 体积 | 较大 | 较小(这是被宣传的那个好处) |
| C 运行库 | glibc | musl libc |
| 预编译的 native addon | 大多数发布方提供 glibc 预编译包 | 可能没有 musl 版,需要现场编译 |
| 现场编译需要什么 | — | 需要装编译器(python3 / make / g++),否则构建失败 |
| 调试便利性 | 常用工具齐全 | 更少工具;distroless 则连 shell 都没有 |
所以判断方法不是"alpine 更小所以更好",而是两条问题:
- 我的依赖树里有没有 native addon(需要编译或预编译二进制的包,例如图像处理、密码学、数据库驱动)?
- 如果有,它有没有发布 musl 版预编译包?
有 native addon 且没有 musl 预编译时,alpine 会在构建期要求安装编译工具链——这时要么在 builder 阶段加上工具链(可以,因为 phase 分离),要么退回 Debian 系基础镜像。这不是"选个更小的镜像"能绕过去的,它取决于你的依赖树。
(本机 Docker daemon 未运行,这一节的 musl/glibc 差异依据的是基础镜像的官方说明与 libc 的既有事实,不是本次实测。要在你的项目上确认,跑一次 --no-cache 构建即可:如果 alpine 上出现编译报错或 invalid ELF header,就命中了这条。)
关于"更安全的固定方式"——按 digest 而不仅是 tag 固定基础镜像——属于供应链硬化,见《构建打包与部署上线》第 02 章第五节。
四、逐行:每一行在决定什么
以实验文件 Dockerfile.multi 为准(行号即文件行号):
| 行 | 内容 | 这一行在决定 | 代价 / 副作用 |
|---|---|---|---|
| L1 | FROM node:22-alpine AS builder |
给构建阶段起名 builder,后面可被 COPY --from 引用 |
引入 musl 约束(上一节) |
| L3 | WORKDIR /src |
后续相对路径的基准,且不存在时自动创建 | 与运行阶段的 /app 不同路径,跨阶段复制时要写清 |
| L5 | COPY package.json package-lock.json ./ |
只把依赖清单放进这一层 | 缺一不可:漏了 lockfile,L6 立即 EUSAGE |
| L6 | RUN npm ci |
按锁定版本装全部依赖(含 dev) | 指纹只含清单,源码变化不会触发它(第 02 章) |
| L8 | COPY . . |
把上下文里的源码放进构建阶段 | 受 .dockerignore 影响;易变文件会销毁这层缓存 |
| L9 | RUN npm run build |
产出 dist/ |
需要 devDependencies,所以必须在装了全量依赖的阶段 |
| L11 | FROM node:22-alpine AS runtime |
新的阶段起点 | 上面 9 行的一切都不会出现在最终镜像里 |
| L13 | ENV NODE_ENV=production |
给应用代码读的环境标记 | 不改变包管理器行为(第二节) |
| L17 | COPY --from=builder /src/dist ./dist |
唯一允许跨越阶段边界的通道 | 只复制产物,不带源码、不带 devDependencies |
| L18 | COPY package.json package-lock.json ./ |
运行阶段也要清单,用来装生产依赖 | 清单进最终镜像(无密钥,可以接受) |
| L19 | RUN npm ci --omit=dev && npm cache clean --force |
只装生产依赖,并在同一条 RUN 里清掉包缓存 | 清理必须同层,否则体积不减(第 02 章第四节) |
| L21 | USER node |
进程以非 root 运行 | 见下 |
| L23 | EXPOSE 3000 |
文档性质的声明 | 它不发布端口也不改变防火墙;真正的端口映射在 docker run -p 或 compose 的 ports |
| L25–26 | HEALTHCHECK ... CMD node -e "..." |
让编排层知道怎么判断"活着" | 见下节 |
| L28 | STOPSIGNAL SIGTERM |
声明停止时发哪个信号 | 第 06 章 |
| L30 | CMD ["node","dist/index.js"] |
exec 形式的启动命令 | 第 06 章 |
几个容易误读的点:
EXPOSE 不发布端口。 它只写进镜像的 config 作为说明。容器能不能被访问,由 ports(compose)或 -p(docker run)决定。把 EXPOSE 当成"已放行"是常见误判。
USER node 需要 node 这个用户存在。 官方 node 镜像内置了 node 用户,所以可以直接用。但在别的镜像里写一个不存在的用户名会让容器启动失败,需要先用 RUN useradd 建好。
USER 之后,被 COPY 进来的文件属主仍是 root。 如果应用需要写这些目录(例如写日志、写缓存),以 node 身份运行会得到权限错误。处理方式是在 COPY 时指定属主,例如 COPY --chown=node:node --from=builder /src/dist ./dist,而不是回头把 USER 去掉。
五、HEALTHCHECK 的两种形式,与 CMD 的那两行正好形成对照
这一节值得单独说,因为它和 06 章直接相连。
同一份文件里,两条"执行命令"的指令用了不同的形式:
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:3000/healthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
CMD ["node", "dist/index.js"]
HEALTHCHECK ... CMD <字符串>是 shell 形式:命令被交给/bin/sh -c执行。CMD ["...", "..."]是 exec 形式:数组的第一个元素就是被执行的程序,没有中间 shell。
两种形式的契约差异在于:
| exec 形式(JSON 数组) | shell 形式(字符串) | |
|---|---|---|
| 真正被执行的是什么 | 数组第一个元素这个程序 | /bin/sh -c "<字符串>" |
| 变量展开 | 由程序自己处理 | 由 shell 处理($VAR 会被替换) |
| 信号 | 进程直接收到 | 进程是 shell 的子进程(06 章展开) |
依赖 /bin/sh |
不需要 | 需要——distroless 这类没有 shell 的镜像用 shell 形式会直接失败 |
对 HEALTHCHECK 来说,shell 形式是可以接受的:它检查的是退出码,不涉及信号传递。但对 CMD 来说,形式的选择直接决定了停止信号能不能到达你的应用进程——这是 06 章的主题,也是本章那份 Dockerfile 在 L30 特意用 JSON 数组的原因。
HEALTHCHECK 的几个参数各自的含义:
| 参数 | 含义 | 写错会怎样 |
|---|---|---|
--interval |
两次检查之间隔多久 | 太短会放大检查本身的 CPU 开销 |
--timeout |
单次检查最多等多久 | 超过就被判为失败,即使程序"其实快好了" |
--retries |
连续失败几次才标记为 unhealthy | 太小会在启动抖动时误判 |
--start-period |
启动宽限期:这段时间内的失败不计入 --retries |
启动慢的服务必须写这个,否则会在正常启动过程中被反复判死 |
最后一行是实战里最容易漏的一项。一个需要 20 秒预热(加载模型、建连接池)的服务,如果 --interval=10s --retries=3 而没写 --start-period,它会在第 30 秒左右被标记为 unhealthy——而它其实一直正常。然后编排层可能据此重启它,于是它永远起不来。
六、可重复构建:三件事,缺一不可
到这里可以把"同一份源码构建出同一个镜像"这个目标拆开了。它需要三个独立条件同时成立:
| 条件 | 靠什么保证 | 缺了会怎样 |
|---|---|---|
| 上下文确定 | .dockerignore(第 01 章) |
开发机的 node_modules、日志、.env 影响构建结果 |
| 依赖确定 | lockfile 进版本库 + npm ci(本章第一节) |
同一份 package.json 在不同日子装出不同版本 |
| 基础镜像确定 | 按 digest 固定(指向发布课程) | 上游移动 tag,同一份 Dockerfile 产出不同的基础层 |
三条里最容易只做一条的是第二项——因为它在正常路径上不会报错。npm install 能装上东西、能构建出镜像、能跑起来,一切看起来都对,直到某天某台机器上出了一个只在特定依赖版本下才复现的 bug。
生产边界
| 教学替身 | 真实替换点 | 要注意什么 |
|---|---|---|
| 几十行的 Node HTTP 服务 | 你项目真实的应用与依赖树 | 依赖树里有没有 native addon 决定了 alpine 是否可用(第三节) |
本机 npm ci 的 EUSAGE 报错 |
CI 里的构建日志 | 报错文本一致,但 CI 上失败发生在构建的第 6 秒,本地往往更早暴露、更容易被忽略 |
手工固定 --interval/--timeout/--retries |
按服务的真实启动分布校准(读启动耗时的 p95/p99) | --start-period 要覆盖冷启动而不是热启动;冷启动慢的依赖(冷缓存、建连)必须算进去 |
| 示例的两阶段 | 多阶段可以有更多阶段(builder / test / runtime / debug) | 阶段越多,越要明确"哪个是最后阶段"——最终 config 由它决定 |
动手
- 为你手上的项目生成并提交
package-lock.json(若还没有),并把npm ci作为镜像里的安装命令; - 写一份多阶段 Dockerfile:builder 装全量依赖 + 构建,runtime 只复制产物 +
--omit=dev装生产依赖; - 给 runtime 阶段加上
USER(非 root)、HEALTHCHECK(含--start-period)、STOPSIGNAL、exec 形式的CMD; - 判断标准:能逐行说出这一行去掉会发生什么。特别检查这三行——
COPY package.json package-lock.json ./、USER、--start-period; - 完成标志:用
--no-cache完整跑一次构建成功,且能回答"最终镜像里有没有src/目录、有没有 devDependencies、进程的 uid 是什么"。
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
删掉 package-lock.json 后构建 |
构建在哪一步、以什么退出码失败 | npm ci 的 EUSAGE(实测退出码 1)——构建契约被打破,不是 CI 的问题 |
把 npm ci --omit=dev 换成 npm install |
两次构建产出的依赖版本是否一致 | 依赖解析变成非确定,可重复性丧失 |
把 USER 写在 builder 阶段 |
最终镜像里进程的 uid | 最终 config 取自最后一个阶段,写错阶段等于没写 |
给一个需要 20 秒启动的服务去掉 --start-period |
服务是否被反复判为 unhealthy | 健康检查在启动过程中误判,编排层据此重启 → 永远起不来 |
把 CMD 改成 shell 形式(CMD node dist/index.js) |
停止时应用日志里还有没有优雅退出那条记录 | 信号发给了 shell 而不是应用(06 章) |
把 EXPOSE 3000 改成 EXPOSE 9999 后照常运行 |
容器还能不能被访问 | EXPOSE 只是声明,不发布端口 |
自测
npm ci和npm install在"package.json与 lockfile 不一致"时的行为差别是什么?为什么镜像构建必须选前者?- 为什么"运行时只装生产依赖"该用
--omit=dev而不是NODE_ENV=production?这两个东西各自影响谁? - 你的依赖树里有一个只有 glibc 预编译包的 native addon。基础镜像选 alpine 会发生什么?有两个可行的处理方式,分别是什么?
COPY --chown=node:node解决的是什么问题?为什么不要用"去掉USER"来绕过它?HEALTHCHECK少了--start-period时,一个正常但启动慢的服务会经历什么?请描述完整的因果链。- 多阶段构建里,
USER、CMD、HEALTHCHECK写在哪一个阶段才生效?为什么?
现在能解释什么
- 镜像构建的第一个"契约"是 lockfile:
npm ci会因为你没提交它而直接失败,且退出码是 1。 --omit=dev与NODE_ENV是两件事:前者影响包管理器,后者影响应用代码,不要互换。- 基础镜像的选择由依赖树决定(有没有 native addon、有没有 musl 预编译),不是"越小越好"。
- 多阶段的价值是把"构建需要"与"运行需要"物理隔开,而最终镜像的配置(
USER/CMD/HEALTHCHECK/STOPSIGNAL)取自最后一个阶段。 HEALTHCHECK的--start-period是启动慢的服务的必需品,缺了会造成"越重启越起不来"。- 可重复构建需要三个独立条件:上下文确定、依赖确定、基础镜像确定。
到这里,一份能跑的镜像已经造出来了。下一章换到跑这一侧:把多个容器编排起来,以及为什么"写了 depends_on "和"服务真的能连上"是两件事。