第4章 Dockerfile 编写:亲手搭出可控镜像
4.1 Dockerfile 是什么?
Dockerfile 就是一份“镜像说明书”。你把构建步骤写在里面,Docker 按步骤一层层叠出最终镜像。
为什么非要用它?你想想看,手动 `docker commit` 改个容器,最后镜像多大、里面有什么东西,你根本说不清。我以前刚接触 Docker 时就干过这事——改完容器忘了记录,镜像 2 个 G,查了半天才发现里面塞了一堆编译缓存。从那以后,我坚持所有镜像都用 Dockerfile 构建,版本可控、可复现。
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 下载文件 |
示例:
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)
4.3 一张图理清 Dockerfile 构建流程
下面我用一张 SVG 图给你展示一条典型 Dockerfile 是怎么变成镜像的。这个过程我走了几百遍,闭着眼都能画出来。
4.4 构建顺序与层缓存机制
Docker 构建时,会逐行解析 Dockerfile,如果某层缓存有效就直接跳过。所以指令的排列顺序直接影响构建速度。
- 不变的放前面: 基础依赖、系统工具、package.json
- 常变的放后面: 源代码、配置文件
- 最大程度利用缓存: 每次只重建变化层及后续层
我通常这样写:
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,一眼就能看出哪里可以优化。