Docker 多阶段构建:把生产镜像从 1.2GB 瘦身到 80MB

一个刚写好的 Python 服务,用朴素方式构建出来的镜像动辄 1GB 以上:完整的构建工具链、编译器、测试依赖、缓存文件全部留在镜像里。多阶段构建要解决的就是这个问题——用不同的阶段承担不同职责,只把运行时真正需要的东西带进最终镜像。

先把问题暴露出来

单阶段构建的 Dockerfile 通常是这样:

FROM python:3.12
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "main.py"]

这里有几个问题。基础镜像 python:3.12 本身接近 1GB,包含完整的 Debian 系统与编译工具;COPY . . 把本地目录里的 .git、测试文件、临时数据全部复制进去;所有依赖安装在系统环境,无法与构建期依赖隔离。更糟的是,任何一次代码改动都会让依赖安装层的缓存失效,构建时间被白白浪费。

多阶段构建的写法

FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY main.py ./
RUN useradd -m appuser
USER appuser
CMD ["python", "main.py"]

第一阶段负责安装依赖,产物放在 /install 目录;第二阶段只从第一阶段复制这个目录,编译中间产物、pip 缓存、构建工具全部留在被丢弃的阶段里。最终镜像里只剩下运行时的 Python 与依赖,体积自然大幅下降。顺带把运行用户改成非 root,也是一个成本极低的安全加固。

选择合适的基础镜像

基础镜像的选择对体积影响最大。常见的三个方向:

  • slim 变体:去掉文档与编译工具,通常 120MB 左右,兼容性最好,绝大多数场景够用。
  • alpine:基于 musl libc,体积最小,但某些依赖需要重新编译,偶有二进制兼容问题。
  • distroless:只包含应用与运行时,没有 shell 和包管理器,攻击面最小,代价是调试不便。

如果项目里有需要源码编译的依赖,比如 psycopg2、lxml,可以在第一阶段使用带编译工具的镜像,把 wheel 产出后带到最终阶段,这样既不用担心兼容性,也不用把编译器带进生产镜像。

四个立刻见效的小技巧

第一,写 .dockerignore,把 .git、__pycache__、venv、日志文件排除掉,避免无谓的上下文传输与层膨胀。第二,注意层的缓存顺序:先复制依赖清单再安装依赖,最后才复制源码,这样改业务代码不会触发依赖重装。第三,合并 RUN 指令并及时清理包管理器缓存,减少无用层数。第四,pip 安装时加上 --no-cache-dir,避免在镜像里留下 wheel 缓存。

验证瘦身效果

每次调整 Dockerfile 之后,用 docker images 查看镜像体积的变化,必要时再用 docker history 逐层排查到底哪一层在膨胀。养成这个习惯很有必要,因为镜像体积往往随着依赖的增加悄悄回涨,定期检查才能及时发现。如果某次构建意外变大,优先怀疑是不是有新依赖被装进了最终阶段,或者某一层忘了清理缓存。

效果对比

方案镜像体积构建耗时说明
单阶段 python:3.12约 1.2GB包含全部工具链与缓存
slim + 多阶段约 180MB略慢通用的稳妥选择
alpine + 多阶段约 80MB需注意二进制兼容

体积变小带来的收益不只是省磁盘:镜像推送和拉取更快,CI 流水线的等待时间更短,容器冷启动更快,同时因为少了一堆用不到的工具,攻击面也随之缩小。给镜像瘦身,通常是投入产出比很高的一项工程改造,值得在项目早期就做掉。