第4章 Dockerfile 编写:亲手搭出可控镜像

4.1 Dockerfile 是什么?

Dockerfile 就是一份“镜像说明书”。你把构建步骤写在里面,Docker 按步骤一层层叠出最终镜像。

为什么非要用它?你想想看,手动 `docker commit` 改个容器,最后镜像多大、里面有什么东西,你根本说不清。我以前刚接触 Docker 时就干过这事——改完容器忘了记录,镜像 2 个 G,查了半天才发现里面塞了一堆编译缓存。从那以后,我坚持所有镜像都用 Dockerfile 构建,版本可控、可复现。

核心思想: 每一行指令生成一个只读层(layer),层叠起来就是镜像。层可以复用,下次构建更快。

4.2 指令逐个拆解

4.2.1 FROM —— 选个干净的底子

所有 Dockerfile 的第一行。指定基础镜像,相当于你盖房子的地基。

FROM node:20-alpine

我建议生产环境尽量用 *-alpine 或 *-slim 版本,体积小、攻击面少。曾经有个同事直接把 ubuntu:latest 当基础镜像,构建出来的镜像 1.5G,部署时带宽吃紧了半小时……嗯,后来全换成了 Alpine 系列。

  • 常用选择: alpine、slim、bullseye、bookworm
  • 多阶段构建: 可以用多个 FROM,每个阶段起别名,最后只拷贝需要的产物。

4.2.2 WORKDIR —— 工作目录别乱飘

指定后续命令的工作目录。如果目录不存在,Docker 会自动创建。

WORKDIR /app

我遇到过不少新人直接在根目录下 COPY,导致 Dockerfile 里全是 /app/src、/app/config 这种硬编码路径,后期维护简直是噩梦。用 WORKDIR 统一管理,后面所有相对路径都基于它,清爽很多。

4.2.3 COPY 与 ADD —— 搬东西的区别

两个都是把本地文件拷贝到镜像里,但 ADD 多了一些自动处理能力。

指令 特点 使用场景
COPY 纯拷贝,不做任何处理 复制代码、配置文件、静态资源
ADD 支持自动解压(tar.gz 等),支持远程 URL 需要解压缩的存档,或从 URL 下载文件
避坑: 能不用 ADD 就不用。自动解压有时会破坏你的目录结构,而且远程 URL 下载会让构建不幂等。我个人习惯:COPY 优先,特殊需求才用 ADD。

示例:

COPY package*.json ./
COPY src/ ./src/
# 如果需要解压离线依赖包
ADD vendor.tar.gz /tmp/

4.2.4 RUN —— 在构建时做“正事”

在镜像构建过程中执行命令,每个 RUN 会创建新层。

RUN npm install --production
RUN apt-get update && apt-get install -y --no-install-recommends \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*

有个经验:每个 RUN 里尽量把所有操作串起来,用 && 连接,最后清理缓存。这样只生成一层,镜像体积能明显减小。我刚带团队时,发现有人写了 5 个 RUN,一层装工具、一层清理、一层装依赖……镜像直接大了 300MB。合并成一个 RUN 后,瘦身效果立竿见影。

4.2.5 CMD 与 ENTRYPOINT —— 容器启动后干点啥

这两个容易混淆,我把它俩放在一起说。

一句话记住: ENTRYPOINT 是“固定指令”,CMD 是“默认参数”。

举个例子:

# 写法一:只有 CMD
CMD ["node", "app.js"]

# 写法二:ENTRYPOINT + CMD
ENTRYPOINT ["node"]
CMD ["app.js"]

写法二的好处——当你 `docker run` 额外加参数时,会覆盖 CMD 部分,但保留 ENTRYPOINT。比如:

docker run my-image --version

实际执行的是 node --version,而不是 node app.js。

我一般把 ENTRYPOINT 写成固定的启动脚本,CMD 写默认参数。这样灵活度最高。

  • Exec 格式(推荐): ["/bin/sh", "-c", "node"]
  • Shell 格式: npm start(自动调用 /bin/sh -c)
注意: Shell 格式会启动 shell 进程,信号传递有问题,生产环境建议用 Exec 格式。

4.3 一张图理清 Dockerfile 构建流程

下面我用一张 SVG 图给你展示一条典型 Dockerfile 是怎么变成镜像的。这个过程我走了几百遍,闭着眼都能画出来。

Dockerfile 构建流程 FROM alpine WORKDIR /app COPY . . RUN npm install CMD ["node"] Layer 5: CMD 指令 Layer 4: RUN npm install Layer 3: COPY . . Layer 2: WORKDIR /app Layer 1: FROM alpine 层缓存复用 如果 FROM 层未变, 后续层不用重新构建

4.4 构建顺序与层缓存机制

Docker 构建时,会逐行解析 Dockerfile,如果某层缓存有效就直接跳过。所以指令的排列顺序直接影响构建速度。

  1. 不变的放前面: 基础依赖、系统工具、package.json
  2. 常变的放后面: 源代码、配置文件
  3. 最大程度利用缓存: 每次只重建变化层及后续层

我通常这样写:

FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]

注意:COPY 时只拷贝 package*.json,这样只要不改 package.json,npm install 这一层就能命中缓存,构建速度飙升。我第一次给组里推广这个模式时,构建时间从 8 分钟降到了 40 秒。

4.5 实战避坑清单

我的 Dockerfile 自检表

  • ✓ 基础镜像是否指定了确切版本标签(不用 latest)
  • ✓ 是否合并了 RUN 命令(减少层数)
  • ✓ 是否清理了临时文件和包管理器缓存
  • ✓ 是否用了 COPY 而不是 ADD(除非需要解压)
  • ✓ 是否有 WORKDIR 统一工作目录
  • ✓ 安全敏感信息是否通过 --secret 传入,而不是写死在 Dockerfile 里

我曾经踩过的坑:

  • 在 Dockerfile 里直接写密码,后来镜像推到了仓库,被扫描工具告警。之后全部改用 docker build --secret 或环境变量注入。
  • CMD ["npm", "start"] 写成 Shell 格式,信号传不到子进程,容器无法优雅关闭。改用 Exec 格式后,kill 信号能正常传递给 Node.js 进程。
  • 基础镜像用 python:3.10 默认是 Debian 版本,构建完 1.2GB。换成 python:3.10-slim 后只有 300MB,性能完全没影响。

4.6 完整示例:一个生产级 Dockerfile

给你一个我最近在项目中实际在用的模板,Java Spring Boot + Node.js 前端,多阶段构建。

# ---- Frontend Build ----
FROM node:20-alpine AS frontend
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci --only=production
COPY frontend/ .
RUN npm run build

# ---- Backend Build ----
FROM maven:3.9-eclipse-temurin-17 AS backend
WORKDIR /app
COPY backend/pom.xml .
RUN mvn dependency:go-offline
COPY backend/ .
RUN mvn package -DskipTests

# ---- Runtime Image ----
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=backend /app/target/*.jar app.jar
COPY --from=frontend /app/dist /app/static
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

你看,每个阶段只保留需要的产物,最终运行镜像里没有编译器、没有源码,干干净净。构建时也充分利用了缓存层。

小技巧: 多阶段构建后,可以用 docker images 检查镜像大小,如果还是太大,检查是不是把构建阶段的缓存带过来了。

4.7 本章小结

Dockerfile 的编写,考验的是你对层、缓存、体积、安全的理解。上边这些指令我用过几千次,说实话,真正写出“高质量”的 Dockerfile 需要经验积累。但只要你记住几条原则:小底子、少分层、善用缓存、多阶段构建,你的镜像就能又快又稳。

别怕出错,多构建几次你就有了手感。下次你看到别人写的 Dockerfile,一眼就能看出哪里可以优化。