Python 基础体系 · 第 111/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 容器与 CI:镜像、依赖缓存、测试、制品和供应链

Python 应用进入 CI/CD 后,真正需要交付的不是“源码加一个启动命令”,而是一组可以被验证、复现、追踪和回滚的对象:

源码提交
  │
  ├─ 依赖解析与锁定
  ├─ 测试与静态检查
  ├─ 构建 wheel / sdist
  ├─ 构建容器镜像
  ├─ 生成 SBOM、摘要和证明材料
  └─ 发布、部署、验证、回滚

这里有三个经常被混为一谈的概念:

  • 缓存:为了更快地重新生成结果,可以丢失,丢失后应能重新下载或构建。
  • 制品:某次构建产生的、需要被保存、传递或部署的结果,例如 wheel、sdist、测试报告和镜像。
  • 供应链证据:说明制品由什么源码、什么构建环境、什么依赖和什么流程生成的材料。

一个可靠的流水线必须保证:缓存失效不会改变结果;制品不会被后续任务偷偷重新构建;供应链证据能够指向具体的源码提交和制品摘要。


一、先区分 Python 包、应用依赖和容器镜像

1. Python 包的两个身份

Python 生态中,“包”至少有两个容易混淆的身份:

  1. import package:运行时被 import 的目录或模块。
  2. distribution package:通过索引下载、安装和发布的分发包。

例如:

项目名称:weather-service
导入名称:weather
分发文件:
  weather_service-1.4.0-py3-none-any.whl
  weather_service-1.4.0.tar.gz

weather 是导入路径,weather-service 是项目名称。二者可以相同,也可以不同。

CI 中构建和发布的是 distribution package;容器运行时使用的是安装后的 import package。不要因为本地能执行:

python -m weather

就认为项目已经具备可发布性。发布还需要元数据、版本、依赖声明、许可证文件和正确的分发格式。

2. wheel 与 sdist 在容器中的不同作用

wheel 是安装分发格式,文件扩展名为 .whl,本质上是具有规定名称和目录结构的 ZIP 压缩包。它通常包含已经准备好的 Python 文件、扩展模块和 .dist-info 元数据,安装时不需要重新执行项目构建流程。(packaging.python.org)

sdist 是 source distribution,即源代码分发包。现代 sdist 是 .tar.gz,包含 pyproject.tomlPKG-INFO 和构建所需的源文件;安装者可能需要从它重新构建 wheel。(packaging.python.org)

这一区别直接影响容器构建:

安装 wheel:
  下载 wheel → 校验 → 解包到 site-packages

安装 sdist:
  下载 sdist → 解包 → 安装构建依赖 → 构建 wheel → 安装 wheel

因此,生产镜像通常优先安装已经在 CI 中构建并测试过的 wheel,而不是在最终运行镜像中执行:

pip install .

后者可能在部署阶段重新编译 C 扩展、重新解析依赖,导致“测试过的对象”和“实际运行的对象”并不相同。


二、依赖声明、锁定和安装不是同一件事

1. pyproject.toml 描述允许范围

一个最小项目可以这样声明:

[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"

[project]
name = "weather-service"
version = "1.4.0"
requires-python = ">=3.14"
dependencies = [
    "httpx>=0.28,<0.29",
]

[project.optional-dependencies]
test = [
    "pytest>=8,<9",
]

其中:

requires-python = ">=3.14"

表示项目支持的 Python 范围;

httpx>=0.28,<0.29

表示依赖允许的版本范围;

version = "1.4.0"

表示当前项目分发版本。

这些声明解决的是兼容性约束,不是某次安装必须得到的完整版本集合。例如 httpx>=0.28,<0.29 仍然可能解析出不同的补丁版本。

2. 锁文件描述某次可复现选择

锁文件记录的是一次解析后的结果,通常包括:

  • 包名;
  • 精确版本;
  • 适用的 Python 和平台条件;
  • wheel 或 sdist 文件;
  • 文件大小;
  • SHA-256 等哈希;
  • 某些情况下的来源和构建证明。

PyPA 当前的 pylock.toml 规范定义了标准化的锁文件格式。它要求锁文件使用 pylock.toml 或符合规定模式的名称,并允许记录不同环境、依赖组、wheel、sdist 及其哈希。(packaging.python.org)

可以把依赖安装抽象成一个函数:

E=I(P,L,T,M)E = I(P, L, T, M)

其中:

  • PP:项目依赖声明,例如 pyproject.toml
  • LL:锁定结果;
  • TT:目标解释器和平台,例如 CPython 3.14、Linux x86_64;
  • MM:安装模式,例如生产依赖或测试依赖;
  • EE:最终环境。

只有 PP 而没有 LL 时,安装器还需要进行版本选择:

L=R(P,T,M,index state)L = R(P, T, M, \text{index state})

R 是解析过程,而 index state 可能随时间变化。即使源码没有变化,索引中新发布的兼容版本也可能改变解析结果。

如果存在锁定结果,安装过程更接近:

E=I(L,T,M)E = I(L, T, M)

这时安装器应先判断当前环境是否满足锁文件中的 requires-python 和环境标记,再选择适配当前环境的 wheel,并校验文件大小和哈希。(packaging.python.org)

3. 反例:只固定顶层依赖

下面的文件并没有完整锁定环境:

httpx==0.28.1

因为 httpx 的传递依赖仍可能使用范围约束:

httpcore>=1.0.0,<2
anyio
certifi
idna

如果这些传递依赖没有被同时锁定,两个时间不同的 CI 运行可能得到不同环境。

另一个常见反例是:

RUN pip install -r requirements.txt

requirements.txt 中只有:

httpx>=0.28

这只是把范围约束放进了另一个文件,并没有自动产生可复现安装。


三、Docker 镜像是分层文件系统,不是压缩后的虚拟机

Docker 镜像通常由多个只读层组成,每条主要构建指令会形成或参与形成一个层;容器启动时再叠加一个可写层。层是前序文件系统变化的增量,后层可以覆盖前层的文件。(docs.docker.com)

因此,镜像构建有两个不同目标:

  1. 构建速度:尽可能复用以前的层。
  2. 运行镜像体积和风险:不要把编译器、缓存、源码和凭据带入最终镜像。

这两个目标并不总是相同。把所有东西压缩成一个层,可能减少某些重复文件,但会损失层共享和并行拉取能力;Docker 文档也指出,多阶段构建通常比简单压缩层更适合控制最终镜像内容。(docs.docker.com)

1. 缓存失效的因果链

假设 Dockerfile 为:

FROM python:3.14-slim

WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "-m", "weather"]

源码中任何一个文件发生变化,COPY . . 对应的层就可能失效;该层之后的 pip install 也会重新执行。Docker 的缓存规则是:某一层失效后,依赖该层的后续层也需要重新构建。(docs.docker.com)

更合理的顺序是先复制低频变化的依赖输入,再复制高频变化的源码:

FROM python:3.14-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

WORKDIR /app

COPY pyproject.toml ./
COPY pylock.toml ./

RUN python -m pip install --upgrade pip \
    && python -m pip install --no-cache-dir .

COPY src ./src

CMD ["python", "-m", "weather"]

但这个示例还有一个重要前提:项目必须已经具备可构建的 pyproject.toml,并且安装命令使用的依赖输入确实完整。如果项目源码参与构建元数据计算,例如动态版本、自动生成代码或构建期读取源码,那么只复制 pyproject.toml 可能不足以完成构建。

更稳妥的生产模式是多阶段构建:在 builder 阶段构建 wheel,在 runtime 阶段只安装 wheel。


四、用多阶段构建隔离编译环境和运行环境

下面的结构适合一个使用 src 布局的 Python 3.14 应用:

# syntax=docker/dockerfile:1

FROM python:3.14-slim AS builder

ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_NO_CACHE_DIR=1

WORKDIR /build

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY pyproject.toml ./
COPY src ./src

RUN python -m pip install --upgrade pip build \
    && python -m build --wheel --outdir /dist \
    && python -m pip install --no-deps /dist/*.whl


FROM python:3.14-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"

WORKDIR /app

COPY --from=builder /opt/venv /opt/venv

RUN useradd --create-home --uid 10001 appuser

USER appuser

CMD ["python", "-m", "weather"]

执行过程分为四步:

  1. builder 创建虚拟环境;
  2. python -m build --wheel 生成 wheel;
  3. pip install --no-deps /dist/*.whl 安装当前项目;
  4. runtime 只复制安装后的虚拟环境,不复制构建工具和源码。

这里的 --no-deps 不是“忽略依赖”,而是避免在第二次安装项目时重新解析依赖。实际依赖应在 builder 阶段由锁文件或明确的依赖安装步骤完成。若没有安装依赖,应用启动时会出现:

ModuleNotFoundError: No module named 'httpx'

所以完整版本可以改为:

FROM python:3.14-slim AS builder

ENV PATH="/opt/venv/bin:$PATH" \
    PIP_DISABLE_PIP_VERSION_CHECK=1 \
    PIP_NO_CACHE_DIR=1

WORKDIR /build

RUN python -m venv /opt/venv

COPY pyproject.toml ./
COPY pylock.toml ./

RUN python -m pip install --upgrade pip

# 具体锁文件安装命令取决于所选安装器。
# 如果使用 requirements.txt 导出物,应由 CI 生成并校验该文件。
COPY requirements.lock.txt ./
RUN python -m pip install --require-hashes \
        -r requirements.lock.txt

COPY src ./src

RUN python -m pip install --no-deps \
        --no-cache-dir \
        --no-index \
        --find-links=/dist \
        . || python -m pip install --no-deps .

RUN python -m build --wheel --outdir /dist

实际工程中不应把“失败后换一种安装方式”的命令直接作为默认方案,因为它会掩盖错误来源。更清晰的流程是:

CI 先生成并验证 wheel
  ↓
镜像构建阶段复制 wheel
  ↓
runtime 阶段 --no-deps 安装该 wheel

例如:

FROM python:3.14-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1

WORKDIR /app

COPY dist/weather_service-1.4.0-py3-none-any.whl /tmp/app.whl

RUN python -m pip install \
        --no-cache-dir \
        --no-deps \
        /tmp/app.whl \
    && rm /tmp/app.whl

CMD ["python", "-m", "weather"]

这样,容器中的应用分发包就是 CI 产出的那个文件,而不是镜像构建时重新生成的另一个文件。


五、依赖缓存有三层,不能互相替代

Python CI 中至少存在三类缓存:

1. 索引或代理缓存
   保存从包索引下载的 wheel / sdist

2. 包管理器下载缓存
   例如 pip 的 HTTP 和 wheel 缓存

3. Docker BuildKit 缓存
   保存 Dockerfile 指令对应的构建层

它们的失效条件不同:

缓存 主要保存对象 典型失效条件
索引代理缓存 包文件和索引响应 TTL、清理策略、上游变化
pip 缓存 下载文件和构建结果 缓存目录被清理、缓存键变化
Docker 层缓存 指令产生的文件系统层 指令或输入文件变化
CI 缓存 runner 上指定路径 缓存键变化、淘汰、权限范围变化

1. Docker 层缓存

Docker 文档建议将不常变化的依赖文件提前复制,将频繁变化的源码放到后面;这样修改源码时不会触发依赖安装层重建。(docs.docker.com)

COPY pyproject.toml pylock.toml ./
RUN python -m pip install ...
COPY src ./src

缓存键的逻辑可以表示为:

Klayer=H(Dockerfile instruction,COPY inputs,base image)K_{\text{layer}} = H(\text{Dockerfile instruction}, \text{COPY inputs}, \text{base image})

其中 HH 是哈希函数。如果 src/app.py 改变,而依赖输入没有改变,则依赖安装层的输入仍然可以保持不变。

2. BuildKit cache mount

层缓存失效后,包管理器仍可能重新下载全部依赖。BuildKit 的 cache mount 可以把包管理器缓存目录持久化到构建缓存中:

# syntax=docker/dockerfile:1

FROM python:3.14-slim

WORKDIR /app

COPY requirements.lock.txt ./

RUN --mount=type=cache,target=/root/.cache/pip \
    python -m pip install -r requirements.lock.txt

这里要区分:

  • RUN 层缓存命中:整个安装步骤不执行;
  • cache mount 命中:安装步骤执行,但已有包文件可以从缓存目录复用。

因此,层缓存命中通常更快;cache mount 则在层缓存失效时降低重新下载成本。Docker 官方文档说明,cache mount 是跨构建累积的持久缓存位置,尤其适合包管理器下载目录。(docs.docker.com)

3. CI 依赖缓存

以 GitHub Actions 为例,依赖缓存通常按键恢复:先查找精确键,再查找部分匹配或恢复键;缓存内容不能原地修改,新的内容需要使用新的键保存。(docs.github.com)

一个通用的缓存键应至少包含:

操作系统
Python 版本
架构
安装器或包管理器版本
锁文件哈希

形式化表示:

Kci=OSPythonarchinstallerH(lock file)K_{\text{ci}} = \text{OS} \Vert \text{Python} \Vert \text{arch} \Vert \text{installer} \Vert H(\text{lock file})

如果只使用:

python-dependencies

那么 Python 3.13、Python 3.14、Linux 和 Windows 可能错误共享同一个缓存。

缓存只能加速流程,不能成为正确性的前提。每个 job 都必须能够在缓存完全为空时重新下载依赖并成功完成。


六、缓存与制品必须严格分开

缓存和制品的区别可以用一个判定问题表达:

删除它之后,系统应该“重新生成”,还是应该“找回原对象”?

  • pip 下载目录删除:重新下载即可,这是缓存。
  • Docker 构建缓存删除:重新构建即可,这是缓存。
  • weather_service-1.4.0-py3-none-any.whl 删除:需要找回本次构建产物,这是制品。
  • 测试报告删除:可能影响审计和故障分析,这是应保存的构建输出。

GitHub 文档也明确区分了两者:缓存用于跨运行复用依赖和中间结果,任务应能在缓存不存在时重新生成;artifact 用于保存构建输出、日志或在不同 job 之间传递文件。(docs.github.com)

CI 中不要这样做:

job build:
  构建 wheel

job image:
  不下载 build job 的 wheel
  再执行 python -m build

这会产生两个不同的构建事件:

wheel₁:build job 生成
wheel₂:image job 重新生成

即使它们文件名相同,内容也可能不同。正确的数据流是:

build job
  ├─ dist/*.whl
  └─ dist/*.tar.gz
       │
       └─ artifact upload
              │
              ▼
image job
  ├─ artifact download
  ├─ 校验 SHA-256
  └─ COPY wheel 到镜像

七、测试的对象不是“源码”,而是交付路径

Python 项目至少需要测试四种状态:

  1. 源码工作树:测试开发环境中的导入和行为;
  2. 安装后的环境:验证包元数据、依赖和入口点;
  3. 构建出的 wheel:验证分发包内容;
  4. 最终容器:验证运行时文件、用户、启动命令和配置。

只测试源码存在一个典型漏洞:

src/weather/__init__.py

在源码目录中可以被导入,但构建 wheel 时可能因为包发现配置错误而没有被打包。

1. 构建并检查分发包

可以使用 PyPA 生态的构建工具:

python3.14 -m venv .venv
. .venv/bin/activate

python -m pip install --upgrade pip build twine
python -m build

find dist -maxdepth 1 -type f -print
python -m twine check dist/*

预期结果类似:

dist/weather_service-1.4.0-py3-none-any.whl
dist/weather_service-1.4.0.tar.gz
Checking dist/weather_service-1.4.0-py3-none-any.whl: PASSED
Checking dist/weather_service-1.4.0.tar.gz: PASSED

wheel 文件名中的兼容性标签包含 Python、ABI 和平台信息,例如:

weather_service-1.4.0-cp314-cp314-manylinux_2_17_x86_64.whl

其中:

  • cp314:CPython 3.14;
  • 第二个 cp314:对应 ABI;
  • manylinux_2_17_x86_64:目标平台;
  • py3-none-any:纯 Python、无特定 ABI、跨平台。

wheel 规范规定了这种文件名结构,并要求 .dist-info 中至少包含 METADATAWHEELRECORDRECORD 记录安装文件及其安全哈希。(packaging.python.org)

2. 从 wheel 安装后再测试

rm -rf /tmp/test-env
python3.14 -m venv /tmp/test-env
. /tmp/test-env/bin/activate

python -m pip install dist/*.whl
python -c "import weather; print(weather.__file__)"
python -m pytest -q

如果项目自身的测试依赖没有打进生产 wheel,就应额外安装测试组:

python -m pip install ".[test]"
python -m pytest -q

更严格的测试方式是将测试代码放在项目外部,确保测试不会意外从当前工作树导入源码:

mkdir -p /tmp/package-check
cp -r tests /tmp/package-check/
cd /tmp/package-check
python -m pytest -q

3. 容器测试

镜像构建完成后,至少应验证:

docker build -t weather-service:test .
docker run --rm weather-service:test \
    python -c "import weather; print('import ok')"

如果服务使用 HTTP 接口,则运行容器并执行健康检查:

docker run -d --rm \
    --name weather-test \
    -p 18080:8080 \
    weather-service:test

curl --fail http://127.0.0.1:18080/health
docker logs weather-test
docker stop weather-test

这一步验证的是最终镜像,而不是 builder 阶段的虚拟环境。常见失败包括:

ModuleNotFoundError

说明运行镜像缺少依赖;

PermissionError

说明切换到非 root 用户后,应用需要写入的目录没有权限;

Connection refused

说明服务没有监听预期端口,或启动命令、绑定地址与容器编排配置不一致。


八、一个可审计的 CI 阶段划分

一个不依赖具体 CI 平台的流水线可以拆为:

flowchart LR
    A[提交源码] --> B[依赖解析与锁定校验]
    B --> C[静态检查与单元测试]
    C --> D[构建 wheel / sdist]
    D --> E[安装 wheel 后测试]
    E --> F[上传 Python 制品]
    F --> G[下载同一制品]
    G --> H[构建容器镜像]
    H --> I[容器启动与集成测试]
    I --> J[生成 SBOM 与证明材料]
    J --> K[签名并推送镜像]
    K --> L[部署与运行时验证]

每个阶段的输入和输出应明确:

阶段 输入 输出
依赖解析 pyproject.toml、锁文件 可安装依赖集合
测试 源码、依赖环境 测试报告
构建 源码、构建后端 wheel、sdist
包测试 wheel、测试依赖 安装验证结果
镜像构建 基础镜像、wheel 镜像 manifest digest
安全检查 镜像、依赖元数据 SBOM、漏洞报告
发布 已验证镜像 registry 中的不可变摘要
部署 镜像摘要、配置 运行中的版本

关键点是:下游 job 应下载上游产生的 wheel,而不是重新执行构建;部署应引用镜像 digest,而不是仅引用可变标签。

不稳定引用:
weather-service:latest

稳定引用:
weather-service@sha256:<manifest-digest>

标签适合人类查找,digest 适合机器确认对象身份。


九、供应链安全:从“能安装”到“能证明”

1. 哈希验证证明了什么

如果依赖文件中写有:

httpx==0.28.1 \
    --hash=sha256:<expected-hash>

安装器可以计算下载文件的 SHA-256:

d=SHA256(downloaded bytes)d = \operatorname{SHA256}(\text{downloaded bytes})

只有:

d=dexpectedd = d_{\text{expected}}

安装才继续。

这能防止下载内容在传输或镜像代理过程中被替换,但不能单独证明:

  • 包作者是谁;
  • 构建环境是否可信;
  • 源码是否来自预期提交;
  • 包中是否存在恶意代码。

哈希解决的是对象完整性,不是完整的来源证明。

2. wheel 内部的 RECORD 也不是签名

wheel 的 RECORD 为文件提供哈希记录,安装器可以据此检查压缩包中的文件是否被修改。规范要求除 RECORD 本身外,文件应记录安全哈希,且不允许使用 MD5 或 SHA-1;安装时如果文件缺失、未记录或哈希不匹配,安装应失败。(packaging.python.org)

RECORD 位于 wheel 内部。如果攻击者可以整体替换 wheel 和其中的 RECORD,内部哈希无法证明 wheel 来自谁。因此还需要外部信任机制,例如:

  • HTTPS 和可信索引;
  • 私有索引访问控制;
  • 包文件哈希锁定;
  • 发布签名;
  • CI 身份证明;
  • 构建 provenance;
  • 镜像签名和部署侧验证。

3. provenance 与 SBOM

SBOM 是 Software Bill of Materials,即软件物料清单,描述镜像或分发包中包含哪些组件。

provenance 是构建来源证明,描述制品由哪个源码、哪个工作流、什么构建参数生成。

PyPA 的索引托管证明规范定义了与发布者、工作流和证明对象关联的数据结构;pylock.toml 也允许记录包文件对应的证明身份。(packaging.python.org)

它们解决的问题不同:

SBOM:
  “这个镜像里有什么?”

provenance:
  “这个镜像是怎么来的?”

签名:
  “谁为这个对象的身份作保证?”

漏洞扫描:
  “其中的组件是否匹配已知风险?”

不要把漏洞扫描结果当作安全证明。扫描通过只说明当前规则集没有发现问题,不代表制品来源可信,也不代表没有未知漏洞。


十、基础镜像和依赖镜像的双重信任边界

容器供应链至少包含两条链:

基础镜像链:
Python 运行时 → Debian/Alpine 基础层 → 系统库

Python 依赖链:
PyPI/私有索引 → wheel/sdist → 安装环境

因此,锁定 Python 依赖但使用浮动基础镜像,仍然不能得到完全可复现的运行环境:

FROM python:3.14-slim

这个标签可能在未来指向不同的基础镜像构建结果。生产发布更适合使用经过验证的 digest:

FROM python:3.14-slim@sha256:<verified-digest>

这里的 digest 应由组织的镜像更新流程维护,而不是手工从不明来源复制。更新基础镜像时,应重新执行:

  1. 镜像构建;
  2. 包安装验证;
  3. 单元和集成测试;
  4. SBOM 生成;
  5. 漏洞扫描;
  6. 灰度部署。

基础镜像更新不是单纯的“换一个标签”,因为它可能改变 OpenSSL、glibc、系统 CA 证书、动态链接库和系统用户行为。


十一、CI 缓存的攻击面

缓存不仅影响速度,也可能影响安全。

以共享 CI 缓存为例,如果低信任分支可以写入一个高信任工作流随后读取的缓存,就可能出现缓存投毒:

不可信 Pull Request
  ↓ 写入伪造缓存
可信主分支工作流
  ↓ 恢复缓存并执行其中脚本
潜在代码执行或凭据泄露

GitHub 文档特别提醒,不应把令牌、凭据等敏感信息放入缓存;恢复出的缓存内容应视为不可信输入,缓存投毒可能导致代码执行。(docs.github.com)

具体到 Python,应避免缓存:

~/.pypirc
带 token 的 pip 配置
私有索引凭据文件
云平台认证文件
包含密钥的构建目录

如果必须访问私有索引,应优先使用 CI 平台的短期凭据和临时配置,并确保日志不会打印完整命令行参数:

python -m pip install \
  --index-url "https://${PIP_USER}:${PIP_TOKEN}@packages.example.invalid/simple" \
  -r requirements.lock.txt

更安全的实现通常是将认证写入短生命周期配置文件,并在步骤结束后删除,而不是将令牌长期保存在缓存目录中。


十二、故障诊断应先判断对象身份,再判断构建逻辑

1. “本地可以,CI 不可以”

先比较以下信息:

python --version
python -m pip --version
python -c "import sys, platform; print(sys.version); print(platform.platform())"
python -m pip debug --verbose

重点不是只看 Python 主版本,还要看:

  • CPython 还是其他实现;
  • x86_64 还是 ARM64;
  • glibc 还是 musl;
  • wheel 是否存在匹配标签;
  • 是否退回到 sdist;
  • 构建工具版本是否不同。

如果目标环境没有匹配 wheel,安装器可能选择 sdist 并进入本地编译路径。此时错误通常表现为:

error: command 'gcc' failed
fatal error: Python.h: No such file or directory

这不是“pip 随机失败”,而是当前平台没有可直接安装的二进制分发包,或构建环境缺少编译依赖。

2. “镜像很慢,但缓存看起来存在”

分别检查三件事:

docker buildx du
docker history weather-service:test
python -m pip cache dir

如果 Docker 层命中但 pip 仍然下载,说明可能是:

  • 安装步骤的层被源码复制顺序破坏;
  • pip 缓存没有挂载到构建阶段;
  • CI runner 没有恢复 BuildKit 外部缓存;
  • 锁文件变化导致正确失效;
  • 使用了不同的 Python、平台或安装器版本。

Docker 官方文档指出,CI 环境通常缺少跨运行持久化,因此外部 BuildKit 缓存对减少镜像构建时间尤其重要;外部缓存可以通过 registry 等后端导出和导入。(docs.docker.com)

3. “测试通过,但生产启动失败”

这种故障通常发生在测试对象不完整:

测试对象:源码工作树
生产对象:最终镜像

诊断顺序应是:

docker inspect weather-service:test
docker run --rm weather-service:test python -m pip list
docker run --rm weather-service:test python -c "import weather"
docker run --rm weather-service:test id

分别确认:

  • 启动命令是什么;
  • 依赖是否真的安装;
  • 导入路径是否存在;
  • 当前用户是否符合预期。

十三、与生产交付的连接:镜像只是版本载体

镜像解决的是文件系统和运行时依赖的一致性,但不自动解决生产运行问题。

同一个镜像仍然需要明确:

  • 进程模型:单进程、多个 worker,还是由平台管理副本;
  • 容量模型:CPU、内存、连接数和队列长度;
  • 配置注入:环境变量、文件、密钥管理;
  • 数据库迁移:先扩展兼容,再切换应用,再收缩旧结构;
  • 灰度发布:先让少量流量进入新镜像;
  • 回滚:回到旧镜像 digest,而不是重新构建旧源码。

尤其要区分代码回滚数据库回滚。代码可以通过镜像 digest 快速切换,但数据库迁移可能已经改变持久化状态。因此生产发布应尽量采用向后兼容的迁移顺序:

1. 增加兼容字段或表
2. 部署能同时读写新旧结构的应用
3. 回填数据
4. 切换读取路径
5. 删除旧结构

如果第一步和最后一步被合并成不可逆迁移,那么应用镜像即使能回滚,旧版本也可能无法读取新数据库。


十四、一个可执行的最小验收流程

在本地或 CI 中,可以按以下顺序验收:

set -eux

python3.14 -m venv .venv
. .venv/bin/activate

python -m pip install --upgrade pip build
python -m build

python -m pip install ".[test]"
python -m pytest -q

python -m twine check dist/*
sha256sum dist/*

docker build \
  --tag weather-service:test \
  .

docker run --rm weather-service:test \
  python -c "import weather; print('package import ok')"

这个流程的逻辑是:

  1. 用明确的 Python 3.14 创建隔离环境;
  2. 构建 wheel 和 sdist;
  3. 在安装环境中运行测试;
  4. 检查分发包元数据;
  5. 保存制品摘要;
  6. 构建最终镜像;
  7. 在最终镜像中再次验证导入。

如果生产需要严格可复现,则还应把以下对象固定并保存:

源码提交 SHA
pyproject.toml
锁文件
构建后端版本
Python 3.14 基础镜像 digest
wheel SHA-256
sdist SHA-256
容器镜像 manifest digest
SBOM
provenance
签名或验证结果
测试报告

最终,Python 容器与 CI 的核心不是“写一个 Dockerfile,再加几条测试命令”,而是建立一条可追踪的数据流:

源码
  → 依赖选择
  → 已验证的 wheel / sdist
  → 已验证的容器镜像
  → 带摘要和证明的发布对象
  → 按 digest 部署的运行实例

当缓存失效时,流程应该只是变慢;当某个制品损坏时,流程应该拒绝发布;当生产出现问题时,系统应该能够根据镜像 digest、wheel 哈希和源码提交准确定位并回滚。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。