Docker 最佳实践

会写 Dockerfile 和写出生产级 Dockerfile,中间差的就是这一章。前面学的都是"能跑",这里讲的是"跑得好"——镜像又小又快又安全,可观测、可复现、可回滚。把这些原则融进肌肉记忆,你的 Dockerfile 就比 90% 的人专业。

1. 多阶段构建:镜像瘦身的杀手锏

这是单个最重要的优化手段。思路很简单:编译和运行分开,最终镜像只放产物:

# 多阶段构建(multi-stage build):镜像瘦身的杀手锏

# 思路:用"构建阶段"编译代码,只把产物复制到"运行阶段"
# 最终镜像只有运行所需文件,不含编译器和源码

# === 阶段 1:构建(用完整镜像,装编译工具)===
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build            # 产出 dist/ 目录

# === 阶段 2:运行(用 alpine,只放产物)===
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev        # 只装生产依赖
COPY --from=builder /app/dist ./dist    # 从构建阶段拷产物
EXPOSE 3000
CMD ["node", "dist/main.js"]

# 效果对比:
#   单阶段(node:20)         : 1.1GB
#   单阶段(node:20-alpine)   : 380MB
#   多阶段(alpine + 产物)   : 180MB  ← 瘦了 6 倍

# 通用场景:
#   Go/Rust     :构建用完整镜像,运行用 alpine 甚至 scratch
#   Java        :构建用 maven/gradle,运行用 jre-slim
#   前端        :构建用 node+webpack,运行用 nginx 静态服务

原理:构建阶段用完整镜像(装编译器、源码、依赖),产出二进制或 dist 目录;运行阶段从构建阶段只 COPY 产物到精简镜像里。最终镜像不含编译器、不含源码,体积锐减。Go/Rust 这种静态编译语言效果最夸张——配上 scratch 空镜像,能做出几 MB 的镜像:

# 极端案例:Go 静态二进制 + scratch → 几 MB 的镜像

# === 构建阶段 ===
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 静态编译(不依赖 glibc),关掉 CGO
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app main.go

# === 运行阶段:scratch 是空镜像(0 字节)===
FROM scratch
COPY --from=builder /app /app
# scratch 没有 CA 证书,想用 HTTPS 要手动加
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/app"]

# 最终镜像只有二进制(几 MB)+ 证书
# docker images
# myapp    latest   8.5MB   ← 极致

多阶段构建的额外好处是安全:源码、构建工具、中间产物不进最终镜像,即使容器被攻破,攻击者也看不到源码。

2. 选对基础镜像

选型直接决定镜像大小和兼容性,牢记优先级:

避免用完整默认镜像(node:20python:3.12)上生产——它们装了完整编译工具链和系统工具,几百 MB 到 1GB,既慢又增加攻击面。

3. 合并 RUN、清理缓存

每条 RUN 产生一层,层数越多镜像越大。装包后清缓存同样重要:

具体写法见前面 Dockerfile 章的"RUN 合并"小节。这条规则配合多阶段构建,镜像能再瘦一圈。

4. .dockerignore:必配

没配就 COPY . . 是新手大忌——node_modules.git、构建产物、.env 全进镜像。一个项目级 .dockerignore 模板:

# 完整的 .dockerignore(几乎所有项目都该有)

# 依赖目录(主机和容器架构不一致,装了反而出错)
node_modules
bower_components
vendor

# 构建产物(应该在镜像里构建,不复制主机的)
dist
build
target
*.o
*.class

# 版本控制和 IDE
.git
.gitignore
.svn
.vscode
.idea
*.swp

# 环境与密钥(绝不进镜像)
.env
.env.local
*.pem
*.key
credentials.json
secrets/

# 文档和测试(运行时不需要)
*.md
docs/
tests/
__tests__/
coverage/
.eslintcache

# Docker 自己的文件
Dockerfile
docker-compose*.yml
.dockerignore

# 日志和临时文件
*.log
tmp/
.cache/

# 系统文件
.DS_Store
Thumbs.db

好处三连:加速构建(少传文件)、减小镜像(不复制无关内容)、避免泄密(密钥不进镜像)。每个项目都该有。

5. 固定版本,拒绝 latest

# 镜像标签策略:绝不只用 latest

# ❌ 错误:用 latest
FROM node:latest
docker pull myapp:latest

# 问题:
#   1. 今天和明天的 latest 可能是不同内容(无法复现)
#   2. 出问题无法回滚到上个版本
#   3. 不同环境(测试/生产)拉到的"latest"可能不一致

# ✅ 正确:固定具体版本
FROM node:20.11.0-alpine3.19
docker pull myapp:1.4.2

# 推荐的标签命名规范:
docker tag myapp yourname/myapp:1.4.2
docker tag myapp yourname/myapp:1.4.2-20240115   # 带日期
docker tag myapp yourname/myapp:1.4.2-gita1b2c3d # 带 git 短 hash
docker tag myapp yourname/myapp:1.4.2-${BUILD_ID} # CI 构建号

# 同时维护多个标签(指向同一镜像)
docker tag yourname/myapp:1.4.2 yourname/myapp:1.4   # 大版本
docker tag yourname/myapp:1.4.2 yourname/myapp:latest # 给"最新"留个入口
# 用户拉 1.4 拿到 1.4.2,拉 latest 也拿 1.4.2,但生产应固定 1.4.2

这是生产事故的高频原因。某天 latest 镜像悄悄升了大版本,你的应用因为 breaking change 挂了,却找不到原因。固定具体版本(node:20.11.0-alpine3.19)让构建可复现——半年后再构建同一个 Dockerfile,产出几乎一样的镜像。

6. 安全加固

# 安全加固:让镜像"被攻破也损失最小"

# 1. 不以 root 运行(默认容器内是 root,被攻破等于拿到 root 权限)
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app
USER app                         # 后续指令和运行时都用 app 用户

# 2. 别在镜像里放密钥(等于公开!)
# ❌ 错误:构建时用 ARG 传密钥,docker history 能看到
ARG DATABASE_PASSWORD=secret
RUN psql -c "CREATE USER app PASSWORD $DATABASE_PASSWORD"
# 上面这条指令的字符串会永久留在镜像层里,任何人 docker history 都能看到

# ✅ 正确:运行时通过环境变量 / secrets 注入
docker run -e DATABASE_PASSWORD=secret myapp
# 或用 Docker secrets(Swarm/K8s 里的安全机制)

# 3. 用 .dockerignore 排除敏感文件
# .env
# .git
# *.pem
# credentials.json

# 4. 及时更新基础镜像(修漏洞)
docker pull node:20-alpine       # 拉最新 patch 版本
# 或固定具体版本 + 定期检查 CVE

核心原则:假设镜像会被攻破,把损失降到最低。三条最关键:

7. 健康检查与日志

生产容器必须"可观测"——能自动检测健康、能查到日志:

# 健康检查与日志:让容器"可观测"

# HEALTHCHECK:让 Docker 自动判断容器是否健康
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget -q --spider http://localhost:3000/health || exit 1

# 健康状态:
#   starting  :启动中(start-period 内)
#   healthy   :健康
#   unhealthy :连续 retries 次失败
# docker ps 的 STATUS 列会显示 (unhealthy)
# 配合 --restart unless-stopped 或编排工具自动重启不健康容器

# 日志策略:容器应用必须把日志写到 stdout / stderr
# ❌ 错误:写到容器内文件
const log = fs.createWriteStream('/var/log/app.log')

# ✅ 正确:console.log / process.stdout
console.log('user logged in', userId)
console.error('database error', err)

# 原因:
#   1. docker logs 能看到(否则看不到)
#   2. Docker 自动收集、轮转、可转发到 ELK/Loki
#   3. 容器删了日志不会丢(已收集到外部)

# 配置日志轮转(防磁盘爆满)
# /etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

日志写 stdout 是容器世界的硬约定。容器是临时的,随时可能被删除/重建,日志写文件会随容器消失;写 stdout 则由 Docker 收集、轮转、转发到日志系统(ELK、Loki、CloudWatch),持久且可查询。HEALTHCHECK 让编排工具(K8s、Swarm)能判断容器是否真正"活着",而不是仅仅进程还在。

8. 镜像扫描与上线 checklist

# 生产镜像上线前 checklist

# 体积
docker images myapp                          # < 300MB 算合理
docker history myapp --no-trunc              # 找出大层

# 安全
docker scout cves myapp                      # 官方漏洞扫描
trivy image myapp                            # 第三方扫描工具
# 任何 HIGH/CRITICAL 漏洞都要处理

# 配置
docker run --rm myapp whoami                 # 应该不是 root
docker run --rm myapp env | grep PASSWORD    # 应该没有密钥
docker inspect myapp | grep -i healthcheck   # 应该有健康检查

# 性能
docker run --rm -it --memory=256m myapp      # 限制内存能跑
# 多次启动耗时 < 5s(容器应该轻量快速)

# 可重现
docker build --no-cache -t myapp:repro .     # 无缓存构建成功
docker run --rm myapp:repro --version        # 版本号正确

CI/CD 流水线里应该自动跑漏洞扫描,任何 HIGH/CRITICAL 漏洞阻断部署。Docker 官方的 docker scout 和开源的 trivy 都不错。基础镜像也要定期更新——新 CVE 每月都出,半年前的 alpine 可能已经有一堆已知漏洞。

9. 缓存友好的 Dockerfile(回顾)

构建速度也是生产力的关键。让 Dockerfile缓存友好的三条原则(详见 Dockerfile 章):

这样改一行代码,前面所有层都命中缓存,构建从几分钟变几秒。BuildKit(DOCKER_BUILDKIT=1)还能做更聪明的缓存(跨机器共享缓存层),CI 加速效果显著。

小结

把本系列 10 篇串起来,Docker 的完整学习路径是:

掌握 Docker,你就拥有了"环境一致"的超能力——开发、测试、生产跑的是同一份镜像,"在我电脑上能跑"不再是借口。它是通往云原生、DevOps、Kubernetes 世界的第一张门票。下一篇该学什么?Kubernetes(容器编排)、Helm(K8s 包管理)、ArgoCD(GitOps)都在等你。

← 上一篇 Docker 镜像仓库

返回 Docker 教程目录

✈️💬