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

要点:

1.2 WORKDIR

WORKDIR /app

注意:

1.3 COPY 和 ADD

COPY package.json ./
COPY src/ ./src/
ADD https://example.com/file.tar.gz /tmp/  # 避免,除非真的需要自动解压

要点:

1.4 RUN

RUN apt-get update && apt-get install -y \
    python3 \
    && rm -rf /var/lib/apt/lists/*

RUN npm install --production

要点:

1.5 CMD 和 ENTRYPOINT

# exec 形式 - 推荐
CMD ["node", "server.js"]
ENTRYPOINT ["node", "server.js"]

# shell 形式 - 不推荐
CMD node server.js
ENTRYPOINT node server.js

要点:

1.6 ENV、ARG、EXPOSE

ENV NODE_ENV=production
ARG VERSION=1.0.0
EXPOSE 3000

要点:

二、构建缓存机制

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 会这样处理:

  1. FROM node:20-alpine → 命中缓存
  2. WORKDIR /app → 命中缓存
  3. COPY package*.json ./ → package.json 没变,命中缓存
  4. RUN npm install → 上面命中,这条也命中,跳过 8 分钟的 npm install
  5. COPY . . → 整个目录 hash 变了,缓存失效,重新执行
  6. 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:

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"]

问题:

  1. 基础镜像 node:20 是 debian,体积 ~900MB
  2. 复制整个目录,node_modules 也会被复制进去(即便后面 install 会覆盖,但浪费了时间和磁盘)
  3. 没有 .dockerignore,.git 目录也被复制进去
  4. devDependencies 全部留在了镜像里
  5. 改了代码必然触发 npm install(因为 . 变了)
  6. 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"] 哪个好?

后者好。

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 的全过程做实战。

参考资料