Dockerfile 编写规范与缓存优化
Dockerfile 编写规范与缓存优化
写在前面
上一篇讲了 Docker 的四大核心对象,这一篇开始写 Dockerfile。Dockerfile 看起来简单——FROM、COPY、RUN 谁不会写?但同一个项目,有人写出来的 Dockerfile 改一行代码就要重新 npm install 8 分钟,有人写出来的改完代码 5 秒就重建好。这个差距,主要来自对构建缓存的理解。
本篇会拿公司一个真实 Node 后端项目的 Dockerfile 改写前后做对比,目标:改动一行代码,只触发这一行的构建。
一、Dockerfile 基础指令回顾
先快速过一遍最常用的指令。如果已经很熟,可以跳过这一节直接看「实战改写」。
1.1 FROM
FROM ubuntu:22.04
FROM node:20-alpine
FROM golang:1.22 AS builder
要点:
- 尽量选官方镜像作为基础镜像
- 优先选 alpine / distroless 这类精简镜像
- 多阶段构建时用
AS 别名给阶段命名
1.2 WORKDIR
WORKDIR /app
注意:
- 不要写
RUN cd /app && ...,每次RUN都会开新 shell WORKDIR会自动创建不存在的目录
1.3 COPY 和 ADD
COPY package.json ./
COPY src/ ./src/
ADD https://example.com/file.tar.gz /tmp/ # 避免,除非真的需要自动解压
要点:
COPY优于ADD(ADD会自动解压、还会支持 URL,这些"魔法"行为容易出问题)COPY会保留文件元数据(权限、时间),但不包括用户和组
1.4 RUN
RUN apt-get update && apt-get install -y \
python3 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install --production
要点:
- 多条命令用
&&串成一条 RUN,否则会产生多个层 - apt-get install 之后清掉列表,否则镜像会大几百 MB
- 复杂的 RUN 写多行用反斜杠换行,加上注释,后期维护会感谢自己
1.5 CMD 和 ENTRYPOINT
# exec 形式 - 推荐
CMD ["node", "server.js"]
ENTRYPOINT ["node", "server.js"]
# shell 形式 - 不推荐
CMD node server.js
ENTRYPOINT node server.js
要点:
CMD可以被docker run的命令行参数覆盖,ENTRYPOINT不行- 二者至少要有一个,否则容器启动后立刻退出
- 必须用 exec 形式,shell 形式 PID 1 是
/bin/sh,信号转发会出问题(docker stop发的 SIGTERM 会被 sh 吞掉)
1.6 ENV、ARG、EXPOSE
ENV NODE_ENV=production
ARG VERSION=1.0.0
EXPOSE 3000
要点:
ENV在容器运行时也生效,ARG只在构建时生效- 敏感信息用
ARG而不是ENV(虽然都能从镜像 history 里看到,但ARG至少不会留在运行时环境里) EXPOSE只是文档作用,真正暴露端口还得靠docker run -p
二、构建缓存机制
2.1 缓存的命中规则
Docker 的构建缓存按层(Layer)粒度工作,每一层都对应 Dockerfile 里的一条指令。命中缓存的条件:
- 指令字符串完全一致
- 对应的构建上下文里,该指令依赖的文件没有变化
看一个例子:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
这个 Dockerfile 有 6 层,假设我改了 src/index.js 一个字符。Docker 会这样处理:
FROM node:20-alpine→ 命中缓存WORKDIR /app→ 命中缓存COPY package*.json ./→ package.json 没变,命中缓存RUN npm install→ 上面命中,这条也命中,跳过 8 分钟的 npm installCOPY . .→ 整个目录 hash 变了,缓存失效,重新执行RUN npm run build→ 上面失效,重新执行
这就是顺序的意义:把不容易变的层放前面,把容易变的层放后面。
2.2 常见的缓存失效陷阱
陷阱 1:依赖文件被覆盖
# 错误:任何 .js 文件改动都会导致 npm install 重跑
COPY . .
RUN npm install
修复:把 package.json 单独复制,跑完 install 再复制源码(前面已展示过)。
陷阱 2:apt-get update 缓存失败
# 错误:基础镜像更新后,这条会因为 mirror 不一致而缓存失效
RUN apt-get update
RUN apt-get install -y python3
修复:把 update 和 install 写到同一行:
RUN apt-get update && apt-get install -y \
python3 \
&& rm -rf /var/lib/apt/lists/*
陷阱 3:构建参数变了但不影响结果
# BUILD_TIME 每次构建都不同,缓存一定失效
ARG BUILD_TIME
RUN echo $BUILD_TIME > /build-time.txt
如果这条 RUN 的产物其实可以被缓存(比如只用来调试),可以放在最后,或者用别的策略。
陷阱 4:COPY . . 复制了不该复制的东西
如果项目根目录有 node_modules/,COPY . . 会把整个 node_modules 复制进去(可能几十 GB)。一定要配 .dockerignore:
# .dockerignore
node_modules
.git
.env
*.log
dist
coverage
.vscode
.idea
.DS_Store
三、多阶段构建
3.1 为什么需要多阶段
Node 项目构建时需要 node_modules,运行时只需要编译产物 + production 依赖。一个典型的多阶段构建:
# 第一阶段:构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 第二阶段:运行时(只带产物)
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public ./public
USER node
EXPOSE 3000
CMD ["node", "dist/main.js"]
这个 Dockerfile:
- builder 阶段完整装 devDependencies,跑构建
- runner 阶段只装 production 依赖,只复制构建产物
- 最终镜像里完全没有源代码、没有 devDependencies、没有构建工具
3.2 Go 项目的多阶段构建
Go 项目是多阶段构建最适合的场景,因为编译需要完整的 Go 工具链,运行时只需要一个静态二进制:
# 构建阶段
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o app .
# 运行阶段 - 极简
FROM scratch
COPY --from=builder /app/app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
ENTRYPOINT ["/app"]
用 FROM scratch 的话,最终镜像只有 ~10MB,连 shell 都没有。Go 是最适合这种极简主义的语言,因为它能编译成完全静态的二进制。
3.3 多阶段构建的进阶用法
共享阶段
多个目标可以共享同一个构建阶段:
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM deps AS dev
COPY . .
CMD ["npm", "run", "dev"]
FROM deps AS prod
COPY . .
RUN npm run build
ENV NODE_ENV=production
CMD ["node", "dist/main.js"]
构建时指定:
docker build --target dev -t myapp:dev .
docker build --target prod -t myapp:prod .
跨平台拷贝
# 从外部镜像拷贝文件
COPY --from=some-image:latest /usr/local/bin/tool /usr/local/bin/tool
# 从构建上下文外的 stage 拷贝
COPY --from=ghcr.io/some/builder:v1.0 /out/binary /usr/local/bin/binary
四、真实改写:公司 Node 后端项目
4.1 原始版本(实习生写的)
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
问题:
- 基础镜像
node:20是 debian,体积 ~900MB - 复制整个目录,
node_modules也会被复制进去(即便后面 install 会覆盖,但浪费了时间和磁盘) - 没有
.dockerignore,.git目录也被复制进去 - devDependencies 全部留在了镜像里
- 改了代码必然触发
npm install(因为 . 变了) npm install用了npm而不是npm ci,速度慢、行为不一致
实测构建时间:8 分 12 秒。改一行代码触发完整重建。
4.2 改写后的版本
# syntax=docker/dockerfile:1.7
# ----- 依赖缓存层 -----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
# ----- 构建层 -----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ----- 运行层 -----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000
# 用非 root 用户
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
COPY package.json package-lock.json ./
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER nodejs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
CMD ["node", "dist/main.js"]
配套的 .dockerignore:
node_modules
dist
.git
.env
.env.*
*.log
coverage
.vscode
.idea
.DS_Store
Dockerfile
docker-compose*.yml
README.md
4.3 改写效果对比
| 指标 | 改写前 | 改写后 | 提升 |
|---|---|---|---|
| 基础镜像体积 | 900MB | 130MB | -85% |
| 最终镜像体积 | 1.2GB | 165MB | -86% |
| 首次构建时间 | 8 分 12 秒 | 2 分 47 秒 | -66% |
| 改一行代码重建 | 8 分 12 秒 | 14 秒 | -97% |
| devDependencies 残留 | 是 | 否 | - |
| 以 root 运行 | 是 | 否 | - |
4.4 几个关键决策的解释
Q:为什么 builder 阶段不直接装 devDependencies 跑构建?
A:可以,但会重复装一遍。deps 阶段装好后,builder 直接 --from=deps 复用 node_modules,可以利用 Docker 的镜像缓存——只要 package.json 没变,deps 层就不会重建。
Q:为什么 runner 不直接 --from=builder 拿 node_modules?
A:可以,但 builder 里既有 devDeps 又有 prodDeps。明确再 --from=deps 一次保证只装生产依赖。
Q:HEALTHCHECK 真的有用吗?
A:有。Docker Swarm、Kubernetes 都会用 HEALTHCHECK 决定容器是否健康。比单纯看进程是否存在准多了——Node 进程可能死锁但还活着。
Q:为什么用 node:20-alpine 而不是 node:20-slim?
A:alpine 体积更小(130MB vs 200MB),但 musl libc 和 glibc 不完全兼容,某些原生模块(native addon)会编译失败。我们项目用的库都是纯 JS,没问题。如果是 node-gyp 重的项目,建议用 node:20-slim 或者提前 npm rebuild。
五、常见疑问与解惑
5.1 该用 alpine 还是 distroless 还是 scratch?
| 镜像 | 体积 | 兼容性 | 调试难度 | 适用场景 |
|---|---|---|---|---|
| alpine | ~5MB | 中(musl libc) | 低(有 shell) | 大多数场景首选 |
| distroless | ~20MB | 高(gcompat) | 中(无 shell) | 生产部署、安全敏感场景 |
| scratch | 0 | 自行保证 | 极高(无任何工具) | Go 静态二进制 |
经验法则:Go/Rust → scratch;Node/Python → distroless 或 alpine;调试频繁 → alpine。
5.2 CMD ["npm", "start"] 和 CMD ["node", "dist/main.js"] 哪个好?
后者好。
- 前者多一层 shell 包裹,信号转发容易出问题
- 后者 Node 是 PID 1,
docker stop发的 SIGTERM 直接到 Node 进程,优雅退出
npm scripts 适合开发,不适合生产镜像。
5.3 如何调试一个跑不起来的镜像?
# 用一个临时 shell 容器覆盖默认入口
docker run --rm -it --entrypoint sh myapp:tag
# 或者在镜像里装 bash(仅调试用)
docker run --rm -it myapp:tag sh
如果镜像用了 scratch 或 distroless,上面方法无效,只能在前一阶段调试,或者挂卷进去。
六、本篇小结
| 原则 | 含义 |
|---|---|
| 依赖缓存前置 | COPY package.json 在 COPY . . 之前 |
| 多阶段构建 | 编译环境和运行环境分离 |
| 基础镜像选小 | alpine > slim > full |
| 层合并 | 多个 RUN 用 && 合并,清缓存 |
| 非 root 运行 | USER 指令指定非 root 用户 |
| exec 形式 CMD | 不用 shell 形式,避免 PID 1 问题 |
| HEALTHCHECK | 让编排系统能感知应用真实健康状态 |
下一篇会讲镜像分层原理与体积瘦身,把"层"这件事的来龙去脉讲透,会拿公司一个 1.8GB 的 Java 服务镜像改写到 180MB 的全过程做实战。