Dockerfile 最佳实践

写出体积小、构建快、运行安全的 Dockerfile——把前五篇的理论落地为工程实践

阶段一 · 06 · 返回阶段大纲

前置回顾

第 04 篇讲过:Docker 镜像的每一层是只读且不可变的,后面的层只能"遮住"前面(whiteout),不能删除前面的数据。第 05 篇讲过:每条 RUN/COPY/ADD 产生一层。这两个原理是 Dockerfile 优化的全部理论基础——一切技巧都在于"让不想要的数据不要进入任何层"

回顾:每条指令都是一层

第 05 篇说过,Dockerfile 中每条会修改文件系统的指令都产生一个新层。先把这个认知用代码钉死:

FROM ubuntu:22.04          # Layer 1: 基础镜像的层(来自 Registry)
COPY package.json /app/    # Layer 2: 复制依赖清单
RUN apt-get update          # Layer 3: 更新包索引(产生大量缓存文件)⚠️
RUN apt-get install -y gcc  # Layer 4: 安装编译器
COPY src/ /app/src/        # Layer 5: 复制源代码
RUN gcc -o /app/binary ...  # Layer 6: 编译 → 产生中间产物 .o 文件 ⚠️

# 最终镜像 = Layer1~6 的全部叠加
# 即使 Layer 6 编译完了不再需要 gcc 和 .o 文件
# 它们已经写死在 Layer 3、4、6 里了——无法删除!

这就是第 04 篇思考题的答案在 Dockerfile 维度的投射:一旦数据进入一层,该层被提交后就永远留在镜像里。后面删除它只是加 whiteout,空间不会释放。优化 Dockerfile 的核心思路就是让不必要的数据不进入任何一层。

缓存优化

Docker 怎么判断缓存是否命中

构建时,Docker 对每条指令做缓存检查。规则非常机械:

指令类型缓存键(Cache Key)命中条件
FROM镜像名:tag 的 digestdigest 完全一致
RUN指令字符串本身字符串一字不差(包括空格)
COPY / ADD指令字符串 + 源文件的 内容哈希指令相同 + 文件内容没变

关键约束:缓存是逐层链式的

一旦某层缓存未命中,该层及之后所有层的缓存全部失效——即使后续指令本身没变。这是 Docker 设计上的保守策略:它假定每一层的结果依赖于前一层的状态。

最重要的优化:COPY 的顺序策略

这是 Dockerfile 优化中最简单、收益最大的一条规则:把最不常变的东西先 COPY,最常变的东西最后 COPY

# ❌ 差:每次改一行源代码,package.json 之后的层缓存全失效
FROM node:18
COPY . /app/              # ← 源码里任何文件变了,这层就失效
RUN npm install           # ← 缓存失效!又要跑一遍 npm install(2 分钟)
CMD ["node", "/app/index.js"]

# ✅ 好:package.json 很少变,npm install 的缓存可以被反复命中
FROM node:18
COPY package*.json /app/  # ← 依赖清单几个月不变,这层缓存长期有效
RUN cd /app && npm install # ← 缓存命中!跳过(省 2 分钟)
COPY src/ /app/src/       # ← 只有源码变了这层才 rebuild
CMD ["node", "/app/index.js"]

构建时间对比(在已有缓存的前提下改一行源码):

❌ COPY . /app/ → RUN npm install 全量跑 → 2 分钟
✅ COPY src/ 重建(1s)→ npm install 缓存命中(跳过)→ 1 秒

每次构建省 119 秒,一天构建 20 次 → 省 40 分钟。

"不常变的东西先 COPY"本质上是在利用第 04 篇的分层共享机制。lnmp install 产生的 node_modules/ 那一层被单独提交为 Layer,只要 package.json 不变,这层的 sha256 就不变。Docker 检测到缓存命中后直接复用这层——就像两个镜像共享同一个基础层一样。

缓存破坏与绕过

有时候你需要让某条指令的缓存失效。比如 apt-get update 的结果会过时,你希望定期重跑。这时可以主动破坏缓存:

# 破坏单个指令的缓存——用 --build-arg 注入一个时间戳
docker build --build-arg CACHE_BUST=$(date +%s) -t myapp .

# Dockerfile 中:
ARG CACHE_BUST=0
RUN echo "Cache bust: $CACHE_BUST" && apt-get update

# 也可以用 RUN 的 --mount 特性(Docker 18.09+)更精细控制:
RUN --mount=type=cache,target=/var/cache/apt \
    apt-get update && apt-get install -y gcc
# 把 apt 的缓存目录独立成一个可复用的 cache mount
# 不写入镜像层,但每次构建都能从本地缓存目录获取包

多阶段构建(Multi-stage Build)

没有多阶段构建时的痛苦

在第 04 篇我们理解了一个核心约束:层的只读性意味着你不能在 Dockerfile 中"先装一个工具,用完再删"来节省镜像大小——工具本身已经永久留在了它被安装的那一层。

多阶段构建(Docker 17.05+)破解了这个问题。核心思路:在"构建阶段"用完整的编译环境,然后在"运行阶段"只拷贝最终产物,构建阶段的全部工具链和中间文件完全不进入最终镜像

┌─ Stage 1: builder(构建阶段)────┐
│  FROM golang:1.21   ← 800MB      │
│  COPY . /src                     │
│  RUN go build -o /app/binary     │  ← 编译工具链、源码、.o 文件全在这一层
│                                  │     但这些层不会进入最终镜像!
└──────────────┬───────────────────┘
               │ COPY --from=builder /app/binary /app/
               ▼
┌─ Stage 2: runtime(运行阶段)────┐
│  FROM alpine:3.19   ← 7MB       │
│  COPY --from=builder /app/binary │  ← 只拷贝最终编译产物
│  CMD ["/app/binary"]             │
│                                  │
│  最终镜像 ≈ 7MB + 你的 binary    │
│  golang:1.21 的 800MB 完全不进入!│
└──────────────────────────────────┘

语法

# 命名阶段(方便引用)
FROM golang:1.21 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .

# 第二个阶段,从 builder 阶段拷贝产物
FROM alpine:3.19
COPY --from=builder /app/server /app/server
CMD ["/app/server"]

# 也可以从更早的镜像层拷贝(不限于前一个阶段)
COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf

常见多阶段模式

模式一:Go/Rust 静态编译程序

# 构建阶段:完整的 Go 工具链
FROM golang:1.21-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server .

# 运行阶段:只需要放二进制的环境
FROM scratch                          ← 完全空白,0MB
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /server /server
USER 65534                            ← nobody 用户
CMD ["/server"]

# 最终镜像 = 你的二进制 + CA 证书,可能只有 5~15MB

模式二:前端静态资源

# 构建阶段:Node.js + 全部 node_modules
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci                            ← 比 npm install 更快、更可复现
COPY . .
RUN npm run build                     ← 产出 dist/ 目录

# 运行阶段:只需要 nginx + 静态文件
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
# nginx 镜像自带 CMD, 不需要再写

# 最终镜像 ≈ nginx:alpine 的 40MB + 你的静态文件
# Node.js + node_modules 完全不进入最终镜像

模式三:同一个应用的多个二进制

FROM golang:1.21 AS builder
WORKDIR /src
COPY . .
RUN go build -o /bin/api ./cmd/api
RUN go build -o /bin/worker ./cmd/worker

# 从同一个 builder 产出两个镜像
FROM alpine:3.19 AS api
COPY --from=builder /bin/api /app/api
CMD ["/app/api"]

FROM alpine:3.19 AS worker
COPY --from=builder /bin/worker /app/worker
CMD ["/app/worker"]

# 构建时选择目标:
# docker build --target api -t myapp-api .
# docker build --target worker -t myapp-worker .

镜像瘦身

基础镜像到底差多少

基础镜像大小包里有什么适用场景
ubuntu:22.04~77MBglibc + apt + bash + coreutils + ...通用场景,需要 apt 装东西
debian:bookworm-slim~40MBglibc + apt(精简版)想要 apt 但尽量小
alpine:3.19~7MBmusl libc + apk + busybox追求体积极限
gcr.io/distroless/static~2MB只有 CA 证书 + /tmp 等空目录静态编译的 Go 程序
scratch0MB完全空白静态编译且不需要 CA 证书或自带证书

Alpine 的坑——不能只看体积

Alpine 用 musl libc 而非标准 glibc,这会导致几个棘手问题:

问题原因影响
DNS 解析行为不同 musl 的 DNS resolver 比 glibc 更严格,某些边缘场景失败 K8s DNS 偶发超时
性能差异 musl 的 malloc 实现在高并发下不如 glibc 的 ptmalloc 高并发服务可能有 10-20% 性能损失
某些预编译二进制不兼容 许多 Linux 二进制编译时链接了 glibc 的 .so 文件 第三方闭源工具可能直接跑不了
包管理器不同 apk 而非 apt,包名不同,某些冷门包缺失 需要手动查包名或从源码编译

选择建议

如果体积不是极致敏感(比如镜像 < 200MB 就能接受),debian:slim 是更安全的选择——glibc 兼容性最好,apt 生态最全。Go 静态编译程序直接 scratchdistroless/static。Alpine 适合你对兼容性做过充分测试后的场景。

.dockerignore — 别把垃圾送进构建上下文

docker build 先把构建上下文(整个目录)发送给 dockerd,然后 dockerd 从中选取 COPY/ADD 需要的文件。如果上下文里有 node_modules/.git/、构建产物,发送过程本身就又慢又浪费。

# .dockerignore
node_modules/
.git/
*.log
dist/
build/
.env
.DS_Store
*.md                          ← 除非源码需要渲染 README
# 验证 .dockerignore 的效果:
# 构建时看第一行输出:"Sending build context to Docker daemon xxMB"
# 不加 .dockerignore → 可能几百 MB
# 加了之后 → 几 MB

# 查看哪些文件会被 COPY . 包含(调试用)
docker build --no-cache -t test . 2>&1 | head -5

安全实践

不以 root 运行容器

回顾第 02 篇的知识:容器进程在宿主机上就是一个普通进程。如果这个进程以 UID 0(root)运行,一旦 Namespace 隔离有漏洞导致容器逃逸,攻击者就直接拿到了宿主机的 root 权限。即使不逃逸,也有风险——比如挂载了宿主机目录时,容器内的 root 可以读写宿主机上任何文件。

# ❌ 默认以 root 运行——最常见的 Dockerfile 安全问题
FROM node:18-alpine
COPY . /app
CMD ["node", "/app/index.js"]       # ← 这个 node 进程是 root

# ✅ 创建专用用户运行
FROM node:18-alpine
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
COPY --chown=appuser:appgroup . /app
USER appuser                        # ← 从这一行开始,所有 RUN/CMD 都不是 root
CMD ["node", "/app/index.js"]

为什么不用 nobody?

很多教程说 USER nobody,但这不安全——如果多个容器都用 nobody,它们文件系统权限会互相看到。创建每个应用独有的非 root 用户是最佳实践。

敏感信息不要留在层里

回顾第 04 篇的 whiteout 机制:即使你在后面的层删掉了密钥文件,它仍然存在于前面的层中。任何人拿到这个镜像,都可以通过 docker history 和层提取工具拿到历史上任何一层的内容。

# ❌ 绝对不要这样做!
FROM ubuntu:22.04
COPY .ssh/id_rsa /root/.ssh/        # ← 私钥进入了 Layer 2,永久存在!
RUN ssh -i /root/.ssh/id_rsa git clone private-repo /app
RUN rm /root/.ssh/id_rsa            # ← 这只是加 whiteout,Layer 2 里的私钥还在!

# ✅ 用 BuildKit 的 --mount=type=secret(Docker 18.09+)
# syntax=docker/dockerfile:1
FROM ubuntu:22.04
RUN --mount=type=secret,id=ssh_key \
    ssh -i /run/secrets/ssh_key git clone private-repo /app
# secret 只在 RUN 这一行可用,不会进入任何层

# 构建命令:
# docker build --secret id=ssh_key,src=$HOME/.ssh/id_rsa -t myapp .

# ✅ 或者用多阶段构建:在 builder 阶段做私密操作,最终镜像只有产物

锁定版本,不要依赖 latest

# ❌ latest = "不定时炸弹"
FROM node:latest                    # 今天可能是 18,下个月可能变成 22
RUN apt-get install -y curl         # curl 的版本取决于当前 apt 仓库

# ✅ 精确锁定
FROM node:18.17.1-alpine3.18        # 三方都锁死:Node 版本 + 变体 + Alpine 版本
RUN apt-get update && \
    apt-get install -y curl=7.88.1-10+deb12u5 && \   # 锁定包版本
    rm -rf /var/lib/apt/lists/*

Dockerfile 自查清单

写完一个 Dockerfile 后,逐条检查:

类别检查项原理
体积 是否用了多阶段构建来排除编译工具链?最终镜像不含 builder 阶段的层(05 篇)
同一个 RUN 内是否合并了安装 + 清理?临时文件不进入已提交的层(04 篇 whiteout)
是否选了合适的基础镜像(scratch/distroless/alpine/debian-slim)?基础镜像越大 → CVE 越多,攻击面越大
缓存 COPY package*.json 是否在 COPY src/ 之前?不常变的文件先 COPY,常变的靠后(缓存链规则)
是否有 .dockerignore 排除 node_modules/.git/减小构建上下文,避免 COPY 包含无用文件导致缓存失效
RUN 指令是否按稳定程度从高到低排列?不常变的在前(如 apt install),常变的在后(如代码编译)
安全 是否以非 root 用户运行(USER 指令)?限制容器逃逸后的影响范围(02 篇 User NS)
敏感信息是否通过 --mount=type=secret 而非 COPY 传入?密钥不进入任何镜像层
基础镜像和系统包的版本是否精确锁定(不用 latest)?可复现构建;避免上游变动导致的意外故障

小结

所有 Dockerfile 最佳实践的底层逻辑都源自两个基本原理:

① 层是不可变的(04 篇)→ 删掉的东西还在下层 → 不想要的数据不要让它产生 → 合并 RUN、多阶段构建

② 层是缓存的最小单位(05 篇)→ 一条指令变了,后续缓存全灭 → 按稳定程度排序 → 不常变的放前面

本篇要点回顾

要点一句话概括
COPY 顺序依赖清单(package.json)在前,源码在后——让 npm install 的缓存可以被反复命中
多阶段构建builder 阶段放完整工具链,runtime 阶段只 COPY --from=builder 最终产物
镜像瘦身scratch (0MB) → distroless (~2MB) → alpine (~7MB) → debian-slim (~40MB) → ubuntu (~77MB),越往右越通用但越重
Alpine 的坑musl libc 替代 glibc——DNS 行为不同、性能差异、二进制不兼容
.dockerignore排除 node_modules/.git 等垃圾,构建上下文从几百 MB → 几 MB
安全:非 rootUSER 指令切换到专属用户,限制容器逃逸后的影响范围
安全:密钥--mount=type=secret 传密钥,不要 COPY——密钥进入镜像层就永远在里面
安全:锁定版本基础镜像和包都用精确版本号,不用 latest——保证可复现、可审计

动手练习

  1. 写一个最简单 Go HTTP 服务(或任何你熟悉的语言),先用一个单阶段的 Dockerfile 构建,记录镜像大小;再改为多阶段构建,对比最终镜像体积
  2. 对一个已有的 Dockerfile,故意把 COPY . /app 放在 RUN npm install 前面,改一行源码后构建两次,记录时间;交换顺序后重复,对比差异
  3. docker image history <image> 查看镜像的每一层及其大小,找出体积最大的层,思考能否优化
  4. 写一个使用 USER 指令的 Dockerfile,用 docker run --rm -it <image> whoami 验证容器进程确实不是 root
  5. 创建一个 .dockerignore 文件,对比加前后的 "Sending build context" 大小
  6. 思考题:为什么 FROM scratch 只能运行静态编译的二进制?如果你尝试在 scratch 镜像里运行一个依赖 glibc 的动态链接二进制,会发生什么?(提示:ldd /path/to/binary