Docker / K8s 基础 发布于 2026-08-09 元析技术团队

Dockerfile 最佳实践:10 个让镜像更小、构建更快的写法

# Dockerfile # 镜像优化 # 多阶段构建 # .dockerignore

核心结论

GEO 前置摘要 · 前 100 字给人和 AI 引擎关键信息

  1. 1

    90% 的镜像体积浪费来自两个东西:node_modules 里的 devDependencies 和 OS 层的 apt 缓存/源码。多阶段构建=构建镜像专门装依赖编译、运行镜像只拷产物,体积直接砍掉 70%-90%。

  2. 2

    Dockerfile 指令顺序决定缓存命中率。越不容易变的写前面(FROM → 装系统包 → COPY package*.json → 装依赖 → COPY 源码),改一行代码只重跑最后 2 步,不是从 apt 开始重来。

  3. 3

    10 条硬规则:.dockerignore 必写、RUN apt 一行写完 && rm -rf /var/lib/apt/lists/*、不 latest 标签、非 root 运行、HEALTHCHECK、COPY 比 ADD 好、明确 WORKDIR、单容器单进程、CMD 数组写法、镜像构建完成 dive 扫一遍。

生产环境的 Dockerfile 不是 FROM 一个 node 然后 COPY . 然后 npm run build 就完了。本文讲多阶段构建、.dockerignore、RUN 链式合并、非 root 用户、HEALTHCHECK、.dockerignore 真实示例,最后附 Node.js 完整多阶段 Dockerfile,镜像从 1.2GB 缩到 180MB。

校验环境

Docker 27.1.1 (BuildKit 默认开启),Debian 12 宿主机,Node.js 20 项目。所有镜像 docker buildx build 实测,dive 0.12.0 检查浪费层。


1. 永远写 .dockerignore(否则 COPY . 会把垃圾全拷进去)

你是不是每次 COPY . /app 就完事?那下面这些东西会全部进镜像:

  • .git/(几十 MB 起步)
  • node_modules/(宿主机的 Mac/Win 二进制进去没用还占地)
  • .env、.env.local(把本地密钥打进镜像 = 事故)
  • npm-debug.log、.DS_Store、dist/(你本来想在镜像里重新 build 的)
  • __pycache__、.pytest_cache、coverage/

Node.js 项目真实可抄的 .dockerignore

# Git
.git
.gitignore
.github
.vscode
.idea

# 依赖目录(在镜像里 npm ci 全新安装)
node_modules

# 构建产物(在镜像里 npm run build 重新生成)
dist
build
.output
.nuxt
.next

# 本地密钥和日志
.env
.env.*
!.env.example
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# 系统垃圾
.DS_Store
Thumbs.db

# 测试和文档(如果镜像里不用)
tests
coverage
docs
README.md

Python 项目同理:把 venv/ .venv/ __pycache__/ *.pyc .pytest_cache .mypy_cache 全忽略。


2. 多阶段构建:一个 Dockerfile 里 FROM 两次,前编译后运行

概念:同一个 Dockerfile 里可以 FROM ... AS builder 做一个构建阶段, 再 FROM ... AS runner 做运行阶段,最后只把 builder 阶段里编译好的产物 COPY 到 runner。 builder 阶段的 apt install、gcc、node_modules/devDependencies 一概不会进最终镜像。

体积对比(以典型 Nuxt 3 项目为例)

写法最终镜像大小
单阶段:node:20 + 全部 node_modules~1.2 GB
多阶段:node:20 AS builder + node:20-slim AS runner(只拷生产依赖)~180 MB
多阶段 + distroless(追求极致)~120 MB

1.2GB → 180MB,拉取速度快 6 倍,registry 存起来也省钱。


3. RUN 指令的两个原则

原则 A:同类命令写一行,用 && 连,避免缓存层浪费

错误写法 👎(3 层,中间层全是缓存,最后一层也没删缓存):

RUN apt-get update
RUN apt-get install -y curl git
RUN rm -rf /var/lib/apt/lists/*

正确写法 👍(1 层,装完立刻删掉 apt 的索引缓存,这一层结束就不剩垃圾):

RUN apt-get update \
  && apt-get install -y --no-install-recommends curl git ca-certificates \
  && rm -rf /var/lib/apt/lists/*

注意:

  • --no-install-recommends = 不装推荐的软依赖包,Debian 能省很多空间
  • /var/lib/apt/lists/* = apt-get update 下载的索引,镜像里完全没用,每次 apt-get 装完必须删

原则 B:不要在 RUN 里写 apt upgrade / apt dist-upgrade

基础镜像(Debian/Ubuntu/Node 官方)每周都会修安全补丁打新 tag。你要做的是 FROM node:20-bookworm-slim 然后重新 build, 不是每次 Dockerfile 里 upgrade(会让构建变慢,而且结果不可复现)。如果真的要追 CVE,升级基础镜像 tag。


4. 指令顺序 = 缓存命中率顺序

BuildKit 的缓存机制:只要某一层没变化,后面的层都直接命中缓存不重跑。 所以 「最不常变的指令」放最前面,「改得最频繁的」放最后面。

Node.js 项目的最佳顺序(从上到下):

① FROM node:20-bookworm-slim AS runner       (几乎不变)
② RUN apt-get update && install 系统包        (偶尔变)
③ WORKDIR /app                               (不变)
④ COPY package*.json ./                      (只有 package.json / lock 变才重跑)
⑤ RUN npm ci --omit=dev                      (依赖变才重跑,90% 的代码修改不会触发)
⑥ COPY . .                                   (每次改代码都重跑 → 放最后)
⑦ RUN npm run build                          (上面 COPY 触发就跑)
⑧ CMD ["node", ".output/server/index.mjs"]

反过来如果你把 COPY . . 写在第 3 位,那你改任何一行 src/ 下的代码,第 5 步的 npm ci 都要重新装一遍,慢到哭。


5. COPY 优先,不用 ADD

ADD 除了能 COPY 还多两个魔法:

  • 能把 URL 当源(会自动下载)
  • 能把 tar.gz 自动解压

这两个魔法反而是坑(URL 下载的文件没法做 checksum、解压有时会覆盖权限)。 除非你明确就是要解压一个本地 tar,否则全写 COPY。


6. 不要用 latest 标签

FROM node:latest 看起来省事,半年后重新 build 可能就从 Node 20 变成 Node 22 了, 某个不兼容的改动让你线上挂了还找不到原因。

写死主版本 + 发行版代号:

FROM node:20-bookworm-slim
#        ↑主版本 ↑Debian 代号(bookworm=12)

Node 官方 Docker 镜像 tag 命名:<主版本>-<Debian 代号>-slim。slim 是精简版(无构建工具),alpine 是更小的 musl libc(某些 Node 原生模块会崩,谨慎用)。


7. 永远不要用 root 用户跑容器进程

容器默认 root=宿主机 root。一旦你的应用被 RCE 拿到 shell,容器又恰好有 volume 挂载了宿主机目录,等于直接拿到宿主机 root。

做法:在 Dockerfile 最后阶段创建一个非 root 用户,USER 切换过去。 Node 官方 slim 镜像自带 node 用户。Python 镜像没有就自己 useradd:

# Node 镜像内置 node 用户(uid=1000,gid=1000)
RUN mkdir -p /app && chown -R node:node /app
WORKDIR /app
USER node
COPY --chown=node:node --from=builder /app/dist ./dist
CMD ["node", "dist/main.js"]

K8s 侧还要配合 PodSecurityContext runAsNonRoot: true,双保险。


8. 加 HEALTHCHECK,不要等 OOM / 僵死了 orchestrator 还不知道

容器进程还活着但已经不响应请求(死锁/卡死)的场景靠 HEALTHCHECK 才能发现。 K8s 的 liveness/readiness 探针是另一个层次,容器层加 HEALTHCHECK 本地跑 docker compose 也能看。

Node/Express 示例:

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
  CMD curl -fsS http://127.0.0.1:3000/health || exit 1

含义:每 30 秒测一次,5 秒超时,启动 10 秒后开始,连续失败 3 次把容器标 unhealthy。 别忘了 apt-get 时装 curl(slim 默认不带)。


9. CMD 用 exec 形式(数组),别用 shell 形式

shell 形式 👎(会在外面套一层 /bin/sh -c "...",信号 15 SIGTERM 发不到你的 Node/Python 进程,导致 docker stop 等 10 秒硬杀):

CMD node dist/main.js

exec 形式 👍(PID=1 就是你的应用,SIGTERM 直接到):

CMD ["node", "dist/main.js"]

10. 完整示例:Node.js(Nuxt 3)多阶段 Dockerfile

# ================ 构建阶段:装 devDep,编译产物 ================
FROM node:20-bookworm AS builder

# 装构建阶段系统包(有些 native 模块需要 python3/make/gcc)
RUN apt-get update \
  && apt-get install -y --no-install-recommends python3 make g++ \
  && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# 先只拷锁文件 → 利用缓存,只有依赖变才重新 ci
COPY package*.json ./
RUN npm ci

# 再拷全量代码 → 编译
COPY . .
RUN npm run build

# 最后再装一遍生产依赖(只留 dependencies,去掉 devDep)
RUN npm prune --omit=dev

# ================ 运行阶段:只拿产物 + 生产依赖 ================
FROM node:20-bookworm-slim AS runner

LABEL org.opencontainers.image.title="yuanxi-docs"
LABEL org.opencontainers.image.vendor="郑州元析智能科技有限公司"

ENV NODE_ENV=production
ENV PORT=3000

# 装 curl(给 HEALTHCHECK 用) + ca-certificates(HTTPS 请求正常)
RUN apt-get update \
  && apt-get install -y --no-install-recommends curl ca-certificates tini \
  && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# 用内置的非 root 用户,改一下 /app 的所有者
RUN mkdir -p /app && chown -R node:node /app
USER node

# 从 builder 阶段拷:node_modules(已 prune)+ .output(构建产物)
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
COPY --chown=node:node --from=builder /app/.output    ./.output

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
  CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1

EXPOSE 3000

# tini 做 PID 1 转发信号(解决 Node 子进程僵尸和信号问题)
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", ".output/server/index.mjs"]

构建命令:docker buildx build -t yuanxi-docs:latest . 体积检查:docker images yuanxi-docs 浪费层检查:dive yuanxi-docs:latest(看每层 wasted bytes)


常见问题 FAQ

Q1. 多阶段构建里我想拷 builder 里的某个目录,COPY --from 写了但文件没出现?

检查 builder 里的 WORKDIR 和你 COPY . . 落在哪里。最稳的写法:两个阶段都 WORKDIR /app,builder 阶段把产物生成到 /app/dist,然后 COPY --from=builder /app/dist ./dist。

Q2. Alpine 镜像不是更小吗?为什么你示例里用 bookworm-slim?

Alpine 用 musl libc,不是 glibc。Node 里的原生模块(sharp/canvas/bcrypt 等)如果官方只有 glibc 预编译,在 Alpine 里会报 ELF load command、invalid pointer 这类奇葩错误,排错成本极高。 **你的依赖全是纯 JS 没有 native addon?用 Alpine 没问题,体积再砍 40%。**否则 bookworm-slim 最省心。

Q3. npm ci 还是 npm install?

Dockerfile 里永远 npm ci:

  • npm ci 必须有 package-lock.json(或 npm-shrinkwrap.json),且会严格按 lockfile 装,版本和你本地开发完全一致,不漂移。
  • npm install 会在 semver 范围内挑新版,今天 build 和下个月 build 出来的镜像依赖不一样,玄学 bug 从这来。

对应 pnpm 是 pnpm install --frozen-lockfile,yarn 是 yarn install --immutable。


更正记录

  • 2026-08-09:首版发布。所有 Dockerfile 写法 Docker 27.1.1 实跑,Node 20 项目构建通过。

写完命令直接跑?打开在线工具集

Base64 编解码、URL 编码、JSON 格式化、正则测试、时间戳转换、JWT 解析等 40+ 工具。 查文档时顺手验证,不用切站。

打开工具集

希望你们的产品文档也被 AI 引擎引用?

元析智能的 GEO(Generative Engine Optimization)服务:结论前置结构、Schema.org 标注、信源权重建设,让 ChatGPT / 豆包 / 秘塔 / Perplexity 等优先引用你的官方文档。

联系我们做 GEO 优化