如何更有效地利用 cache 加速 pip installs
结论
不要把一百多个依赖拆成一百条 RUN pip install。可以启用 BuildKit,将 pip 的下载缓存和 wheel 目录挂载为持久缓存。即使某次 RUN 因编译失败而没有生成 Docker layer,已经下载的文件和构建好的 wheels 仍能在下次构建时复用。
如果还想减少对已成功处理 packages 的重复安装,可以先用 pip wheel 构建全部依赖,成功后再统一安装。这样,即使编译失败,正式安装也尚未开始。
Docker layer cache 为什么无法解决这个问题
Docker 的普通构建缓存以 Dockerfile 指令为单位。只有整条指令执行成功,生成的 filesystem layer 才能供后续构建使用。
例如:
RUN pip3 install apache-airflow
只要有一个依赖编译失败,整条 RUN 就会失败。Docker 不会保存指令执行到一半时的容器文件系统,因此无法从第 71 个 package 接着安装。
把每个依赖分别写成一条 RUN,确实能生成更多可缓存层,但也会引入一些问题:
- 间接依赖需要手动维护,容易遗漏或出现版本冲突。
- pip 的 dependency resolver 无法在同一次操作中统一选择版本。
- 依赖变化后,很多 layer 仍可能失效。
- Dockerfile 会变得冗长,镜像历史中也会出现大量安装步骤。
pip 的缓存机制不同。它会保存下载的 archives、wheels,以及部分本地构建产生的 wheels。通过 BuildKit 的 cache mount,这些文件可以跨越失败的构建继续保留。
使用 BuildKit cache mount
Dockerfile 可以写成:
# syntax=docker/dockerfile:1
FROM python:3.11-alpine
RUN apk add --no-cache \
build-base \
python3-dev
COPY requirements.txt /tmp/requirements.txt
RUN --mount=type=cache,id=pip-cache,target=/root/.cache/pip,sharing=locked \
python -m pip install -r /tmp/requirements.txt
root 用户运行 pip 时,默认缓存目录是 /root/.cache/pip。即使上一次 pip install 失败,重新构建时,pip 通常仍能复用此前下载或缓存的文件。
这条命令中不要使用:
--no-cache-dir
这个选项会禁止 pip 使用自身缓存,与这里的目的相反。
如果当前环境没有默认启用 BuildKit,可以在构建时显式开启:
DOCKER_BUILDKIT=1 docker build -t my-image .
docker buildx build 同样支持 cache mount。
先构建 wheels,再安装
挂载 pip cache 可以减少重复下载,但如果 pip install 失败,已经安装到当前 layer 的 packages 仍会丢失。为了减少这部分重复工作,可以先生成所有依赖的 wheel,确认全部成功后再安装。
以下示例同时缓存下载内容和 wheel 产物:
# syntax=docker/dockerfile:1
FROM python:3.11-alpine AS builder
RUN apk add --no-cache \
build-base \
python3-dev
COPY requirements.lock /tmp/requirements.lock
RUN python -m venv /opt/venv
RUN --mount=type=cache,id=pip-cache,target=/root/.cache/pip,sharing=locked \
--mount=type=cache,id=pip-wheelhouse,target=/wheelhouse,sharing=locked \
/opt/venv/bin/python -m pip wheel \
--wheel-dir=/wheelhouse \
-r /tmp/requirements.lock \
&& /opt/venv/bin/python -m pip install \
--no-index \
--find-links=/wheelhouse \
-r /tmp/requirements.lock
FROM python:3.11-alpine
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:${PATH}"
CMD ["python"]
构建过程分为两步:
pip wheel下载依赖,并为所有需要编译的依赖构建 wheels。- 全部 wheels 准备完成后,
pip install才会从/wheelhouse安装。
假设第 71 个 package 编译失败,本次构建会在这里停止,但 cache mount 中已有的下载文件和 wheels 会保留下来。补齐缺少的 Alpine build dependency 后重新构建,前面生成的 wheels 通常可以直接复用。
多阶段构建也能避免将 gcc、headers 等构建工具带进最终镜像。
分开复制依赖文件和应用代码
先复制依赖文件,再复制应用源码:
COPY requirements.lock /tmp/requirements.lock
RUN --mount=type=cache,target=/root/.cache/pip \
python -m pip install -r /tmp/requirements.lock
COPY . /app
这样,修改业务代码不会导致依赖安装层失效。只有 requirements.lock 变化后,Docker 才需要重新执行安装步骤。
直接依赖和间接依赖的版本都建议固定。锁定版本后,即使 wheel cache 中存在多个版本,pip 也能按照文件中的约束选出确定的结果。具体使用 requirements.txt、constraints file 还是其他 lock file,应沿用项目现有的依赖管理方式。
Alpine 环境的注意事项
build-base 和 python3-dev 只是常用的编译依赖,无法解决所有安装错误。部分 Python packages 还需要:
- 对应系统库的
-devpackage - Rust toolchain
- Fortran compiler
- PostgreSQL、MariaDB、OpenSSL、libffi 或 XML 相关 headers
- 最终运行环境中仍需保留的 shared libraries
缺少哪些依赖,应根据失败 package 的构建日志判断,不要一次加入大量无关的 Alpine packages。
Alpine 使用 musl libc。有些项目没有为对应的 Python 版本和平台发布预编译 wheel,因此 Alpine 比基于 glibc 的镜像更容易触发源码编译。如果镜像大小不是首要限制,可以考虑改用项目支持较好的 Debian slim 基础镜像。有时这样就能直接使用官方 wheels,减少编译和排错工作。是否适合更换,仍取决于目标 package、Python 版本和部署环境。
BuildKit cache mount 是 builder 的缓存,不属于最终镜像,也不会自动同步到其他构建机器。CI 使用临时 builder 时,还要另外配置构建缓存的导入和导出。
备注:内容仅供参考。
