03 镜像构建与优化
相同功能的应用,使用不同Dockerfile构建出的镜像体积可能相差很大。常见原因不是业务代码差异,而是镜像分层和构建缓存的使用方式不同。
理解这两个机制后,Dockerfile不仅可以满足运行需求,还可以进一步优化构建速度、镜像体积和安全性。
一、镜像分层机制
镜像不是一个完整的大文件,而是由多个层(layer)叠加出来的。Dockerfile里的每一条指令,都会生成一个新的层。
示例如下:
FROM ubuntu:24.04 # 第1层:基础Ubuntu系统
RUN apt-get update && apt-get install -y python3 # 第2层:安装Python
COPY requirements.txt ./ # 第3层:复制依赖文件
RUN pip install -r requirements.txt # 第4层:安装pip依赖
COPY src ./src # 第5层:复制源代码构建完成后,镜像层结构可以表示为:
┌─────────────────────┐
│ 第5层:源代码 │ ← 最新变更
├─────────────────────┤
│ 第4层:pip依赖 │
├─────────────────────┤
│ 第3层:requirements │
├─────────────────────┤
│ 第2层:Python运行时 │
├─────────────────────┤
│ 第1层:Ubuntu系统 │ ← 基础层
└─────────────────────┘每一层都是只读的,创建后不可修改。层与层之间是叠加关系,上层如果有同名文件,会覆盖下层看到的文件。
1.1 查看镜像层
docker image history python:3.12-alpine输出会列出每一层的创建命令和大小。需要查看完整命令时,可以加上--no-trunc。
1.2 层的复用
层是按内容寻址的,即相同内容的层只会存储一份。两个镜像如果都基于python:3.12-alpine,就可以共享基础层,不需要重复存储。
这也是Alpine基础镜像受欢迎的原因之一:它本身小,所有基于它的镜像都能一起省空间。
二、构建缓存
Docker构建镜像时,会逐层检查是否存在可用缓存。如果某一层的输入和上次构建完全一致,Docker会复用缓存结果,跳过执行。
因此,第二次构建通常比第一次更快,因为很多层不需要重新执行。
2.1 缓存失效规则
常见的缓存失效来自三种情况:
RUN指令的命令变了——Docker发现命令字符串不同,重新执行COPY或ADD的源文件变了——Docker检查文件内容和属性,有变化就重建- 前一层失效,后续层全部失效——层是链式依赖的,底层变了上层全部重建
第三点最容易被忽略。越靠前的层发生变化,受影响的后续层越多,缓存命中率也就越低。
2.2 优化Dockerfile利用缓存
以下是一个常见但不推荐的写法:
FROM python:3.12-alpine
WORKDIR /app
COPY . . # 把所有文件复制进来
RUN pip install -r requirements.txt # 安装依赖
CMD ["python", "src/main.py"]问题出在COPY . .。它会把所有文件都复制进去,包括源代码。只要源代码发生变化,这一层就会失效,后面的pip install也会重新执行,即使requirements.txt没有变化。
更合适的写法是先单独复制依赖文件:
FROM python:3.12-alpine
WORKDIR /app
COPY requirements.txt ./ # 先只复制依赖文件
RUN pip install -r requirements.txt # 安装依赖
COPY . . # 再复制所有文件
CMD ["python", "src/main.py"]采用这种顺序后,仅修改代码时,COPY requirements.txt这一层不变,pip install可以命中缓存,最后只需要重建COPY . .这一层。
实测效果:
| 场景 | 优化前 | 优化后 |
|---|---|---|
| 首次构建 | 30秒 | 30秒 |
| 改了代码 | 30秒 | 2秒 |
| 改了依赖 | 30秒 | 30秒 |
从示例结果可以看到,合理利用缓存可以显著缩短重复构建时间。
2.3 .dockerignore和缓存
.dockerignore也会影响缓存。如果构建上下文里包含.git目录或node_modules,它们的变化可能导致缓存失效。排除这些内容后,缓存会更加稳定。
三、多阶段构建
以下是一个典型场景。假设需要构建一个Java应用:
FROM eclipse-temurin:21-jdk-jammy
WORKDIR /app
COPY . .
RUN ./mvnw clean install
CMD ["java", "-jar", "target/app.jar"]这样构建出来的镜像可能有880MB。原因是JDK、Maven、构建工具都被打包进最终镜像。但真正运行时,只需要JRE和jar包,编译器不会再被使用。
多阶段构建用于解决这类问题:在一个Dockerfile里分多个阶段,编译在一个阶段,运行在另一个阶段,只把编译产物复制到最终镜像。
3.1 基本语法
# 第一阶段:构建
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /app
COPY . .
RUN ./mvnw clean install
# 第二阶段:运行
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY --from=builder /app/target/*.jar ./app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]关键点如下:
- 第一阶段用
AS builder命名 - 第二阶段用
COPY --from=builder从第一阶段复制文件 - 最终镜像只包含第二阶段的内容
构建结果:
| 方式 | 镜像大小 |
|---|---|
| 单阶段 | 880MB |
| 多阶段 | 428MB |
体积直接减半。 最终镜像只包含JRE和jar包,JDK和Maven停留在构建阶段,不会进入生产镜像。
3.2 Python项目的多阶段构建
Python项目同样可以用多阶段构建:
# 第一阶段:安装编译依赖
FROM python:3.12 AS builder
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# 第二阶段:运行
FROM python:3.12-alpine
WORKDIR /app
COPY --from=builder /install /usr/local
COPY src ./src
USER app
EXPOSE 8080
CMD ["python", "src/main.py"]第一阶段用完整的Python镜像,负责处理那些需要C编译器的包,比如numpy。第二阶段再换成Alpine镜像,只带运行时需要的文件。
3.3 只构建某个阶段
# 只构建builder阶段(调试用)
docker build --target builder -t my-app:debug .不加--target时,Docker默认构建最后一个阶段。
四、镜像安全
4.1 不要用root运行
# 创建用户
RUN addgroup -S app && adduser -S app -G app
# 切换用户
USER app容器默认用root运行。如果容器被攻破,攻击者拿到的也是root权限。切换成非root用户,至少能把破坏范围限制在普通用户权限里。
4.2 只读文件系统
docker run --read-only my-app加上--read-only之后,容器文件系统会变成只读。应用确实需要写入的目录,比如/tmp,可以用tmpfs单独挂载。
4.3 定期重建镜像
镜像是不可变的快照,基础镜像里的安全漏洞不会自动消失。定期使用--pull重建镜像,可以获取基础镜像中的最新安全补丁:
docker build --pull -t my-app:1.0 .五、总结
镜像优化常用手段如下:
| 手段 | 效果 |
|---|---|
| 利用构建缓存 | 加快重复构建速度 |
| 多阶段构建 | 大幅减小镜像体积 |
| Alpine基础镜像 | 减小基础层体积 |
| 非root用户 | 提高安全性 |
| .dockerignore | 减小构建上下文,提高缓存稳定性 |
| 定期重建 | 获取安全补丁 |
镜像优化可以遵循一个基本原则:最终镜像里只放运行应用需要的东西。 编译器、构建工具、源代码、测试文件,这些都不应该进入生产镜像。
当Agent服务需要Redis、数据库、Nginx等多个容器一起工作时,Docker Compose可以用一个YAML文件管理整个应用栈。