Dockerfile 构建原理 + 镜像分层优化
核心定义 Dockerfile 是 Docker 镜像的声明式构建文件。Docker 按顺序执行 Dockerfile 指令,将每一步产生的文件系统变化保存为镜像层,并通过层缓存、联合文件系统和内容寻址机制实现镜像复用与快速构建。
1. Dockerfile 构建的基本流程
Docker 构建镜像时,核心流程如下:
- 读取当前构建上下文(build context)。
- 解析 Dockerfile 指令。
- 按顺序执行每条指令。
- 对产生文件系统变化的指令生成新的镜像层。
- 复用未变化的缓存层。
- 将最终层与镜像元数据组合成一个镜像。
Dockerfile
↓
构建上下文 build context
↓
逐条执行指令
↓
生成或复用镜像层
↓
得到最终镜像
构建结果并不是一个单独的大文件,而是一组只读镜像层加上镜像配置元数据。容器运行时会在这些只读层之上增加一个可写容器层。详见 Docker 联合文件系统。
2. 构建上下文与 .dockerignore
2.1. 构建上下文是什么
执行以下命令时,最后的 . 表示构建上下文目录:
docker build -t myapp:latest .
Docker 会将构建上下文发送给构建引擎,Dockerfile 中的 COPY、ADD 只能访问构建上下文内的文件。
构建上下文风险 如果上下文目录过大,构建会变慢;如果包含密钥、日志、依赖缓存等无关文件,还可能被误打入镜像层。
2.2. .dockerignore 的作用
.dockerignore 用于排除不需要进入构建上下文的文件。
.git/
node_modules/
*.log
.env
README.md
target/
dist/
常见作用:
- 减少构建上下文大小。
- 避免无关文件影响缓存命中。
- 防止密钥、日志、临时文件进入镜像。
- 减少镜像层中的无效内容。
3. Dockerfile 指令与镜像层
3.1. 常见指令分类
| 指令 | 是否常产生文件层 | 主要作用 |
|---|---|---|
FROM | 是 | 指定基础镜像,作为第一批只读层 |
RUN | 是 | 在构建阶段执行命令,生成新的文件系统层 |
COPY | 是 | 从构建上下文复制文件到镜像 |
ADD | 是 | 复制文件,额外支持自动解压和远程 URL |
ENV | 通常否 | 设置环境变量,写入镜像配置 |
WORKDIR | 通常否 | 设置工作目录,必要时创建目录 |
CMD | 否 | 设置容器默认启动命令或参数 |
ENTRYPOINT | 否 | 设置容器固定入口 |
EXPOSE | 否 | 声明容器监听端口 |
VOLUME | 否 | 声明挂载点 |
是否生成文件层取决于指令是否改变文件系统。RUN、COPY、ADD 通常最容易增加镜像层和镜像体积。
3.2. RUN 指令为什么会形成层
每条 RUN 指令都会在临时容器中执行命令,并将执行后的文件系统差异提交成新层。
FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y nginx
RUN rm -rf /var/lib/apt/lists/*
上面写法的问题是:清理动作发生在后续层,前面层中产生的缓存文件仍可能保留在镜像历史层中。更推荐写成一条 RUN:
FROM ubuntu:22.04
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx \
&& rm -rf /var/lib/apt/lists/*
这样安装和清理发生在同一层中,可以减少最终镜像体积。
4. 构建缓存原理
4.1. 缓存命中的基本条件
Docker 构建时会从上到下检查每条指令是否可以复用缓存。缓存命中通常依赖以下条件:
- 当前指令文本未变化。
- 前面的层未失效。
COPY或ADD涉及的文件内容未变化。- 构建参数、基础镜像等输入未发生影响缓存的变化。
只要某一层缓存失效,后续层通常都需要重新构建。
第 1 层命中缓存
第 2 层命中缓存
第 3 层发生变化
第 4 层以及后续层重新构建
4.2. 指令顺序对缓存的影响
Dockerfile 应遵循一个原则:
缓存优化原则 越稳定、越少变化的指令越靠前;越频繁变化的源代码、配置文件越靠后。
低效写法:
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "server.js"]
问题是:只要任意源代码文件变化,COPY . . 就会导致后面的 npm ci 缓存失效。
优化写法:
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
优化后,只有依赖描述文件变化时才重新安装依赖;普通业务代码变化不会让依赖安装层失效。
5. 镜像分层优化方法
5.1. 合并相关 RUN 指令
多个相关操作应尽量放在同一个 RUN 中完成,尤其是安装与清理。
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
这样可以避免包管理器缓存、临时文件残留在历史层中。
5.2. 减少不必要的 COPY
COPY . . 简单但风险较高,容易把无关文件复制进镜像。更好的做法是:
- 使用
.dockerignore排除无关内容。 - 优先复制依赖清单,再复制业务代码。
- 只复制运行时需要的文件。
COPY app.py requirements.txt /app/
5.3. 使用更小的基础镜像
基础镜像越大,最终镜像通常越大。常见选择:
| 基础镜像 | 特点 | 适用场景 |
|---|---|---|
ubuntu / debian | 兼容性好,体积较大 | 依赖复杂、排障便利性优先 |
alpine | 体积小,使用 musl libc | 依赖简单、追求小体积 |
distroless | 只包含运行时必要文件 | 生产运行镜像、安全收敛 |
scratch | 空镜像 | 静态编译二进制 |
Alpine 不是万能选择 Alpine 使用 musl libc,某些依赖 glibc 的程序可能存在兼容性问题。选择基础镜像时需要平衡体积、兼容性和排障成本。
5.4. 使用多阶段构建
多阶段构建用于将“编译环境”和“运行环境”分离,避免把编译器、源码、构建缓存带入最终镜像。
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /out/app /app/app
CMD ["/app/app"]
最终镜像只包含运行二进制和必要运行环境,不包含 Go 编译器、源码和构建缓存。
5.5. 避免在镜像中保存敏感信息
禁止将以下内容写入镜像层:
.env文件- SSH 私钥
- 云厂商 AK/SK
- 数据库密码
- Token、证书私钥
- 临时调试文件
即使后续层删除了敏感文件,历史层中仍可能保留。敏感配置应通过环境变量、密钥管理系统、Kubernetes Secret 或运行时挂载注入。
6. CMD 与 ENTRYPOINT 的区别
6.1. CMD
CMD 定义容器默认启动命令或默认参数,容易被 docker run 后面的命令覆盖。
CMD ["nginx", "-g", "daemon off;"]
如果执行:
docker run image_name ls -l
ls -l 会覆盖原来的 CMD。
6.2. ENTRYPOINT
ENTRYPOINT 定义容器固定入口,docker run 后面的参数通常会作为入口命令的参数。
ENTRYPOINT ["/start.sh"]
CMD ["default"]
执行:
docker run image_name abc
实际效果类似:
/start.sh abc
6.3. 组合使用场景
常见模式是:
ENTRYPOINT固定主程序。CMD提供默认参数。
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
CMD ["--spring.profiles.active=prod"]
这样既能固定启动入口,又允许运行时覆盖默认参数。
7. ADD 与 COPY 的选择
推荐原则:优先使用 COPY,只有明确需要 ADD 的额外能力时才使用 ADD。
| 指令 | 能力 | 推荐使用场景 |
|---|---|---|
COPY | 复制本地文件或目录 | 大多数文件复制场景 |
ADD | 支持本地压缩包自动解压、远程 URL | 明确需要自动解压时 |
不建议使用 ADD 下载远程文件。远程下载更推荐在 RUN 中使用 curl 或 wget,并校验哈希值。
8. 常见低效写法与优化示例
8.1. 包管理器缓存未清理
低效写法:
RUN apt-get update
RUN apt-get install -y curl
优化写法:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
8.2. 过早复制全部源码
低效写法:
COPY . .
RUN pip install -r requirements.txt
优化写法:
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
8.3. 镜像中包含构建产物和源码
低效写法:
FROM maven:3.9-eclipse-temurin-17
WORKDIR /src
COPY . .
RUN mvn package
CMD ["java", "-jar", "target/app.jar"]
优化方向:使用多阶段构建,只把最终 jar 包复制进运行镜像。
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /src
COPY pom.xml .
COPY src ./src
RUN mvn -DskipTests package
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /src/target/app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]
9. 排查与验证命令
9.1. 查看镜像历史层
docker history image_name:tag
用于观察每一层由哪条指令产生,以及大致体积。
9.2. 查看镜像详细信息
docker image inspect image_name:tag
可以查看镜像的 Config、RootFS、环境变量、入口命令等信息。
9.3. 无缓存构建
docker build --no-cache -t image_name:tag .
用于确认构建过程是否依赖旧缓存,但会牺牲构建速度。
9.4. 指定 Dockerfile 构建
docker build -f Dockerfile.prod -t image_name:prod .
适用于同一项目存在多个 Dockerfile 的场景。
10. 最佳实践清单
- 使用
.dockerignore排除无关文件和敏感文件。 - 稳定指令放前面,频繁变化的
COPY放后面。 - 依赖安装和缓存清理放在同一个
RUN中。 - 优先使用
COPY,谨慎使用ADD。 - 使用多阶段构建分离构建环境和运行环境。
- 不在镜像层中写入密钥、Token、密码和证书私钥。
- 使用合适的基础镜像,平衡体积、兼容性和排障成本。
- 通过
docker history检查异常大层。 - 运行时数据使用 volume 或 bind mount,而不是写入容器层。
11. 面试回答模板
标准回答 Dockerfile 构建镜像时,会按照指令顺序逐层执行。
FROM引入基础镜像层,RUN、COPY、ADD等会产生新的文件系统层,CMD、ENTRYPOINT、ENV等主要写入镜像配置。Docker 构建缓存按顺序命中,前面某一层失效后,后续层通常都需要重新构建。因此 Dockerfile 优化的核心是:稳定指令靠前、易变文件靠后,使用.dockerignore减少构建上下文,合并安装与清理命令,避免敏感信息进入镜像层,并通过多阶段构建只保留运行时需要的文件。这样可以减少镜像体积、提升构建速度,并降低安全风险。
关联文档
- Docker 联合文件系统:理解镜像层、容器层和写时复制机制。
- Docker 三种挂载:理解运行时数据为什么不应保存在容器层。
- 容器技术:容器镜像、运行时和隔离机制的整体入口。
