Skip to content

03 镜像构建与优化

相同功能的应用,使用不同Dockerfile构建出的镜像体积可能相差很大。常见原因不是业务代码差异,而是镜像分层和构建缓存的使用方式不同。

理解这两个机制后,Dockerfile不仅可以满足运行需求,还可以进一步优化构建速度、镜像体积和安全性。

一、镜像分层机制

镜像不是一个完整的大文件,而是由多个层(layer)叠加出来的。Dockerfile里的每一条指令,都会生成一个新的层。

示例如下:

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 查看镜像层

bash
docker image history python:3.12-alpine

输出会列出每一层的创建命令和大小。需要查看完整命令时,可以加上--no-trunc

1.2 层的复用

层是按内容寻址的,即相同内容的层只会存储一份。两个镜像如果都基于python:3.12-alpine,就可以共享基础层,不需要重复存储。

这也是Alpine基础镜像受欢迎的原因之一:它本身小,所有基于它的镜像都能一起省空间。

二、构建缓存

Docker构建镜像时,会逐层检查是否存在可用缓存。如果某一层的输入和上次构建完全一致,Docker会复用缓存结果,跳过执行。

因此,第二次构建通常比第一次更快,因为很多层不需要重新执行。

2.1 缓存失效规则

常见的缓存失效来自三种情况:

  1. RUN指令的命令变了——Docker发现命令字符串不同,重新执行
  2. COPYADD的源文件变了——Docker检查文件内容和属性,有变化就重建
  3. 前一层失效,后续层全部失效——层是链式依赖的,底层变了上层全部重建

第三点最容易被忽略。越靠前的层发生变化,受影响的后续层越多,缓存命中率也就越低。

2.2 优化Dockerfile利用缓存

以下是一个常见但不推荐的写法:

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没有变化。

更合适的写法是先单独复制依赖文件:

dockerfile
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应用:

dockerfile
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 基本语法

dockerfile
# 第一阶段:构建
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项目同样可以用多阶段构建:

dockerfile
# 第一阶段:安装编译依赖
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 只构建某个阶段

bash
# 只构建builder阶段(调试用)
docker build --target builder -t my-app:debug .

不加--target时,Docker默认构建最后一个阶段。

四、镜像安全

4.1 不要用root运行

dockerfile
# 创建用户
RUN addgroup -S app && adduser -S app -G app

# 切换用户
USER app

容器默认用root运行。如果容器被攻破,攻击者拿到的也是root权限。切换成非root用户,至少能把破坏范围限制在普通用户权限里。

4.2 只读文件系统

bash
docker run --read-only my-app

加上--read-only之后,容器文件系统会变成只读。应用确实需要写入的目录,比如/tmp,可以用tmpfs单独挂载。

4.3 定期重建镜像

镜像是不可变的快照,基础镜像里的安全漏洞不会自动消失。定期使用--pull重建镜像,可以获取基础镜像中的最新安全补丁:

bash
docker build --pull -t my-app:1.0 .

五、总结

镜像优化常用手段如下:

手段效果
利用构建缓存加快重复构建速度
多阶段构建大幅减小镜像体积
Alpine基础镜像减小基础层体积
非root用户提高安全性
.dockerignore减小构建上下文,提高缓存稳定性
定期重建获取安全补丁

镜像优化可以遵循一个基本原则:最终镜像里只放运行应用需要的东西。 编译器、构建工具、源代码、测试文件,这些都不应该进入生产镜像。

当Agent服务需要Redis、数据库、Nginx等多个容器一起工作时,Docker Compose可以用一个YAML文件管理整个应用栈。