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 与镜像构建(要的是可重复)

这条差异链上有三个工程结论:

  1. package-lock.json 必须进版本库,也必须进 Dockerfile 的 COPY 清单。锁文件缺失不是"风格问题",而是让构建从"确定"退化成"看运气"。
  2. 镜像里用 npm ci,不要用 npm install。 镜像的价值之一是"同一份源码在任何人机器上构建出同一个东西",npm install 会把这个性质放弃掉。
  3. 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 更小所以更好",而是两条问题:

  1. 我的依赖树里有没有 native addon(需要编译或预编译二进制的包,例如图像处理、密码学、数据库驱动)?
  2. 如果有,它有没有发布 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"]

两种形式的契约差异在于:

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 由它决定

动手

  1. 为你手上的项目生成并提交 package-lock.json(若还没有),并把 npm ci 作为镜像里的安装命令;
  2. 写一份多阶段 Dockerfile:builder 装全量依赖 + 构建,runtime 只复制产物 + --omit=dev 装生产依赖;
  3. 给 runtime 阶段加上 USER(非 root)、HEALTHCHECK(含 --start-period)、STOPSIGNAL、exec 形式的 CMD;
  4. 判断标准:能逐行说出这一行去掉会发生什么。特别检查这三行——COPY package.json package-lock.json ./、USER、--start-period;
  5. 完成标志:用 --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 只是声明,不发布端口

自测

  1. npm ci 和 npm install 在"package.json 与 lockfile 不一致"时的行为差别是什么?为什么镜像构建必须选前者?
  2. 为什么"运行时只装生产依赖"该用 --omit=dev 而不是 NODE_ENV=production?这两个东西各自影响谁?
  3. 你的依赖树里有一个只有 glibc 预编译包的 native addon。基础镜像选 alpine 会发生什么?有两个可行的处理方式,分别是什么?
  4. COPY --chown=node:node 解决的是什么问题?为什么不要用"去掉 USER"来绕过它?
  5. HEALTHCHECK 少了 --start-period 时,一个正常但启动慢的服务会经历什么?请描述完整的因果链。
  6. 多阶段构建里,USER、CMD、HEALTHCHECK 写在哪一个阶段才生效?为什么?

现在能解释什么

到这里,一份能跑的镜像已经造出来了。下一章换到跑这一侧:把多个容器编排起来,以及为什么"写了 depends_on "和"服务真的能连上"是两件事。

进入 keel 阅读