写出体积小、构建快、运行安全的 Dockerfile——把前五篇的理论落地为工程实践
前置回顾
第 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 对每条指令做缓存检查。规则非常机械:
| 指令类型 | 缓存键(Cache Key) | 命中条件 |
|---|---|---|
FROM | 镜像名:tag 的 digest | digest 完全一致 |
RUN | 指令字符串本身 | 字符串一字不差(包括空格) |
COPY / ADD | 指令字符串 + 源文件的 内容哈希 | 指令相同 + 文件内容没变 |
关键约束:缓存是逐层链式的
一旦某层缓存未命中,该层及之后所有层的缓存全部失效——即使后续指令本身没变。这是 Docker 设计上的保守策略:它假定每一层的结果依赖于前一层的状态。
这是 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
# 不写入镜像层,但每次构建都能从本地缓存目录获取包
在第 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 工具链
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 | ~77MB | glibc + apt + bash + coreutils + ... | 通用场景,需要 apt 装东西 |
debian:bookworm-slim | ~40MB | glibc + apt(精简版) | 想要 apt 但尽量小 |
alpine:3.19 | ~7MB | musl libc + apk + busybox | 追求体积极限 |
gcr.io/distroless/static | ~2MB | 只有 CA 证书 + /tmp 等空目录 | 静态编译的 Go 程序 |
scratch | 0MB | 完全空白 | 静态编译且不需要 CA 证书或自带证书 |
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 静态编译程序直接 scratch 或 distroless/static。Alpine 适合你对兼容性做过充分测试后的场景。
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
回顾第 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 = "不定时炸弹"
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 后,逐条检查:
| 类别 | 检查项 | 原理 |
|---|---|---|
| 体积 | 是否用了多阶段构建来排除编译工具链? | 最终镜像不含 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 |
| 安全:非 root | USER 指令切换到专属用户,限制容器逃逸后的影响范围 |
| 安全:密钥 | 用 --mount=type=secret 传密钥,不要 COPY——密钥进入镜像层就永远在里面 |
| 安全:锁定版本 | 基础镜像和包都用精确版本号,不用 latest——保证可复现、可审计 |
COPY . /app 放在 RUN npm install 前面,改一行源码后构建两次,记录时间;交换顺序后重复,对比差异docker image history <image> 查看镜像的每一层及其大小,找出体积最大的层,思考能否优化USER 指令的 Dockerfile,用 docker run --rm -it <image> whoami 验证容器进程确实不是 root.dockerignore 文件,对比加前后的 "Sending build context" 大小FROM scratch 只能运行静态编译的二进制?如果你尝试在 scratch 镜像里运行一个依赖 glibc 的动态链接二进制,会发生什么?(提示:ldd /path/to/binary)