Node 生产 Dockerfile 里到底该有什么,不该有什么
生成一个 Dockerfile 很容易。生成一个构建快、用非 root 用户运行、镜像体积小的 Dockerfile,是另一回事。网上大多数模板把容易的部分做对了,却跳过了生产里真正关键的部分。下面这几个决定,才真正影响 Node 服务的构建时间、镜像体积和运行时安全。
Node 应用的 Dockerfile 很短。难点不在于写出来,而在于写一个不浪费磁盘、不慢慢重建、不以 root 运行的版本。人们常把 Docker 的痛苦,比如构建慢、镜像大、层缓存失效,归咎于 Docker 本身,其实大多来自文件开头那几个决定。本文讲这几个决定以及它们为什么重要。
有意识地选基础镜像
node:20 镜像方便但很大。它基于 Debian,自带一整套运行时多半用不到的构建工具链。node:20-alpine 小得多,是 Node 服务的合理默认,前提是你不依赖 glibc 专有的原生模块。
问题出在原生模块上。sharp、bcrypt 以及任何基于 node-gyp 的包,可能需要 Alpine 默认不带的建设工具。标准解法是多阶段构建:在完整的 node:20 镜像里编译原生模块,再把结果拷进更小的运行时镜像。如果在 Alpine 上遇到莫名其妙的段错误,先查 glibc 与 musl 的差异,而不是查你的代码。
别用 latest。它会在你脚下变动。固定到主版本,CI 里要可复现就固定到补丁版本。
依赖和源码分开缓存
构建时间上最常见的错误,是把 package.json 和 src/ 放在同一层拷贝。每次改代码都会让依赖层失效,于是 Docker 每次构建都重装一遍。
解法是每个合格 Node Dockerfile 都会用的两步拷贝:
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
按这个顺序,只要 lockfile 没变,npm ci 那层就被缓存。一行代码改动只会重建最后的 COPY . . 层,耗时几秒。把这个写错,会把 20 秒的重建变成 3 分钟。构建慢的时候,这是第一个要查的地方。
镜像里用 npm ci 而不是 npm install。npm ci 读 lockfile,先删 node_modules,再安装精确的那棵树。它比 npm install 更快、更可复现,后者允许解析出一棵新树。
多阶段构建让镜像变小
Node 服务运行时需要依赖,但不需要编译工具链、source map,也不需要构建时用到的 dev 依赖。多阶段构建把构建环境和运行时环境分开。
构建阶段安装全部依赖(含 devDependencies),需要的话编译 TypeScript。运行时阶段只拷贝 node_modules(生产依赖)、编译产物和 package.json。结果常常是单阶段构建一半的体积,并且不附带 typescript、eslint 或测试框架。
如果用 Prisma,它的引擎二进制是平台相关的。要在构建阶段为运行时镜像的架构构建它,否则容器启动时会报找不到引擎。本站 Docker 工具专门带了一个 Prisma 选项,就是因为这里容易出错。
NODE_ENV 陷阱
NODE_ENV=production 会做两件让人意外的事。它让 Express、Koa 等框架跳过慢速的开发期检查,这是你想要的。它也会让 npm install 跳过 devDependencies,这在构建阶段有时不是你想要的,因为构建阶段需要 TypeScript 和测试运行器。
解法是只在最终的运行时阶段设置 NODE_ENV=production,或者在想排除 dev 依赖时显式用 npm ci --omit=dev。在构建阶段全局设了 NODE_ENV=production 然后纳闷 tsc 怎么没了,是一种常见又让人困惑的失败。
用非 root 用户运行
默认情况下,Node 镜像以 root 运行。如果你的应用有允许写文件的漏洞,攻击者就在容器内以 root 身份写文件。建一个专用用户并切换过去,是一次性的设置,能消除一整类提权风险。
RUN addgroup -S app && adduser -S app -G app
USER app
大多数编排器也能在平台层面用 runAsNonRoot 策略强制这点,但在 Dockerfile 里设好,意味着镜像默认就是安全的,即使在没强制的平台上也是。
docker-compose 用于开发,不用于生产
Compose 在本地开发很棒:一条命令把应用、Postgres、Redis 一起拉起来。但一个照搬生产的 compose 文件会招来麻烦。生产很少只跑一个节点,很少用本地文件系统存状态,也很少用同样的方式应用配置。
让 compose 留在开发。生产用同一个镜像,但通过编排器自己的机制来配置:环境变量、secrets、健康检查、重启策略。如果你的生产方案是“在一台大服务器上 docker-compose up”,那你有一个 compose 解决不了的扩展性和可靠性问题。
合理的开发 compose 文件定义应用、数据库,也许还有缓存,并给数据库加 volume 让数据能在重启后保留。它不定义生产副本、负载均衡或滚动发布,那些属于别处。
发布前该检查什么
构建镜像后跑 docker image inspect。看体积。如果一个 Node 服务镜像没有特殊原因却超过 400 MB,通常是基础镜像或缺少多阶段构建。跑 docker history <image> 看哪层大。依赖层大是正常的;源码层大往往说明 node_modules 是从宿主拷进去的,而不是在镜像里装的。
运行容器,确认它以非 root 用户启动。确认健康检查通过。确认改一行代码能触发快速重建,这才是层缓存是否设对的真正检验。
本站 Docker 工具生成的 Dockerfile 和 compose 文件已经把这些决定内置进去,你可以从一个合理基线出发,再针对自己的应用调整,而不必从通用模板重写。
主要参考资料
用于核对本文技术细节的标准与官方文档。
在本地生成 Dockerfile 和 compose 文件
本站的 Docker 工具为 Node 生成 Dockerfile 和 docker-compose.yml,可选 Postgres、Redis 和 Prisma 支持,并按下面的决定调好。全部在浏览器里运行,不上传任何内容。
打开 Docker 工具相关文章
继续阅读同一主题领域的实践指南。
在用户之前发现缺失的翻译键和插值参数不匹配
缺失的翻译键会把原始键路径直接渲染给用户,插值参数不匹配会渲染出空串或崩溃。这两者在评审中都容易漏,因为开发者的语言包永远有全部键。本文讲如何结构化比较 locale JSON 文件、找出缺失键,并在发布前抓住参数不匹配。
能真正压测 UI 的 mock 数据(而不是只把页面填满)
大多数 mock 数据是同一行复制十遍、只换个 id。它填满页面,却什么都测不到。本文讲如何生成能压测布局边界、长名字、缺失字段、空状态,以及会破坏格式化代码的日期和数字格式的 mock 数据,并通过字段推断让一个 JSON 样本一步变成贴近真实的数据集。
把 cURL 转成 fetch、Axios 或 Python,别丢了关键请求头
cURL 是 HTTP 调试的通用语,但把 cURL 命令直接粘进应用代码是范畴错误。本文讲转换到 fetch、Axios 和 Python requests 时什么能保留、什么会丢失,以及那些在转换中悄悄改变行为的请求头和 body 编码。