uv 系列(七):CI/CD、Docker 与私有索引——生产级交付

📅 2026/7/23 0:20:43 👁️ 阅读次数 📝 编程学习
uv 系列(七):CI/CD、Docker 与私有索引——生产级交付

核心目标:把本地 uv 工作流可靠迁移到 GitHub Actions 和生产容器,正确使用锁文件、缓存、私有索引和短期凭据,建立可审计的交付链路。

前置知识:已掌握 uv 项目、锁文件、构建发布和 workspace。

文档基线:uv 0.11.x;GitHub Actions、Docker 和索引行为依据 2026-07-21 的 uv 官方文档复核。CI 中应定期升级并重新验证固定版本。


7.1 生产交付的四条底线

本地执行成功不代表能够稳定交付。CI 和容器至少满足:

  1. 输入可追踪:代码提交、Python、uv、锁文件和基础镜像都有明确版本;
  2. 构建可重复:CI 不静默改锁文件,容器不复制本机.venv
  3. 权限最小化:测试 job 没有发布权限,长期 token 不进入镜像和日志;
  4. 缓存可丢弃:删除全部缓存后仍能得到正确结果。

仅加速

仅加速

Git 提交
pyproject.toml + uv.lock

CI 校验
lint / type / test

构建产物
wheel / sdist / image

隔离验证
SBOM / 扫描 / 冒烟

受保护发布
OIDC / 审批

uv 缓存

缓存只是一条虚线。如果移除缓存后构建失败,问题在声明或环境,而不是“缓存配置不够好”。


7.2 GitHub Actions:最小可靠工作流

.github/workflows/ci.yml

name:cion:pull_request:push:branches:[main]permissions:contents:readconcurrency:group:ci-${{github.workflow}}-${{github.ref}}cancel-in-progress:truejobs:test:name:Python ${{matrix.python-version}}/ ${{matrix.os}}runs-on:${{matrix.os}}strategy:fail-fast:falsematrix:os:[ubuntu-latest,windows-latest]python-version:["3.12","3.14"]steps:-name:Check out sourceuses:actions/checkout@v7-name:Install uv and Pythonuses:astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b# v8.1.0with:version:"0.11.30"python-version:${{matrix.python-version}}enable-cache:truecache-dependency-glob:"uv.lock"-name:Verify lockfile and syncrun:uv sync--locked--all-groups--all-extras-name:Check formattingrun:uv run--locked ruff format--check .-name:Lintrun:uv run--locked ruff check .-name:Type checkrun:uv run--locked mypy src-name:Testrun:uv run--locked pytest--cov--cov-report=term-missing-name:Minimize persistent uv cacheif:always()run:uv cache prune--ci

7.2.1 为什么固定 uv 和 Action

version: "0.11.30"防止 Runner 某天自动切换 uv 行为。setup-uv固定到完整提交 SHA,降低 tag 被移动带来的供应链风险。

上例为易读仍使用actions/checkout@v7。高安全仓库应把所有第三方 Action(包括官方 Action)固定到审核过的完整 SHA,并由 Dependabot/Renovate 提交升级 PR。

7.2.2 为什么用--locked

CI 的职责是验证仓库状态,不是替开发者生成新锁文件。若pyproject.tomluv.lock不一致,--locked应立即失败:

uv lock gitdiff--pyproject.toml uv.lock

在本地解决并提交,而不是在 workflow 中执行普通uv lock后继续。

7.2.3 Python 矩阵如何选择

库项目至少测试:

  • requires-python的最低支持版本;
  • 团队默认版本;
  • 当前稳定 Python。

应用项目可以只测试实际部署版本,再额外增加升级预演。矩阵中的3.14是本文时点示例;复制工作流时应按项目真实支持范围调整。


7.3 拆分快速检查与完整矩阵

在每个 OS/Python 组合重复 Ruff 和 Mypy 往往没有收益。大型项目可拆为:

Pull Request

快速检查
Ruff + Mypy + lock check

测试矩阵
Python × OS

合并门禁

main/tag 构建

受保护发布

快速 job:

quality:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v7-uses:astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441bwith:version:"0.11.30"enable-cache:true-run:uv lock--check-run:uv sync--locked--all-groups-run:uv run ruff format--check .-run:uv run ruff check .-run:uv run mypy src

矩阵 job 只执行必要测试。最终分支保护同时要求两个 job 通过。


7.4 Workspace 的 CI

最可靠的基线是全量安装和测试:

-name:Sync all workspace membersrun:uv sync--locked--all-packages--all-groups-name:Test all membersrun:uv run--all-packages pytest-name:Build all publishable membersrun:uv build--all-packages--clear--no-sources

为防止共享环境掩盖未声明依赖,还应为重要成员增加隔离 job:

-name:Test weather-core as a package targetrun:|uv sync --locked --package weather-core uv run --package weather-core pytest packages/weather-core/tests

大仓库按变更范围优化时,必须包含反向依赖。核心库变化不能只测试核心库自身。


7.5 正确缓存 uv

setup-uv内置缓存:

-uses:astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441bwith:version:"0.11.30"enable-cache:truecache-dependency-glob:"uv.lock"

7.5.1 缓存键包含什么

通常至少包含:

  • 操作系统和架构;
  • uv 缓存格式相关信息;
  • uv.lock哈希;
  • 必要时包含 Python 版本或构建工具输入。

不要缓存整个.venv作为跨 Runner 复用策略。虚拟环境含绝对路径、解释器引用和平台二进制;缓存 uv 下载/构建产物,再用锁文件快速重建环境更稳妥。

7.5.2uv cache prune --ci

CI 结束时:

uv cache prune--ci

它针对 CI 缓存保留更值得复用的本地构建 wheel,清理可快速重新下载的预构建 wheel 和展开的源码分发物。是否能加速取决于项目依赖,不应脱离测量机械添加。

7.5.3 Self-hosted Runner

自托管 Runner 的缓存不会随 job 销毁,可能无限增长。应:

  • 为 Runner 配置明确UV_CACHE_DIR
  • 定期执行uv cache prune
  • 监控磁盘和 inode;
  • 不让不同信任级别的仓库共享可写缓存;
  • 严禁手工修改缓存内部文件。

7.6 发布 Job 的权限隔离

测试 job 不需要id-token: write。发布 job 应独立,并依赖构建验证:

publish:if:startsWith(github.ref,'refs/tags/v')needs:[quality,test]runs-on:ubuntu-latestenvironment:pypipermissions:contents:readid-token:writesteps:-uses:actions/checkout@v7-uses:astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441bwith:version:"0.11.30"-run:uv build--clear--no-sources-run:uv publish--trusted-publishing always

进一步改进:

  • 受保护 environment 需要审批;
  • tag 版本必须等于pyproject.toml版本;
  • 构建一次,验证后发布同一组不可变产物;
  • 不在发布 job 临时修改版本或锁文件;
  • 使用 OIDC Trusted Publishing,避免长期 PyPI token。

7.7 Docker 中安装 uv

官方提供仅包含 uv 二进制的 distroless 镜像。常见做法:

FROM python:3.12-slim-trixie COPY --from=ghcr.io/astral-sh/uv:0.11.30 /uv /uvx /bin/

生产环境优先固定镜像 digest:

COPY --from=ghcr.io/astral-sh/uv@sha256:<审核过的摘要> /uv /uvx /bin/

不要复制文档中的示例摘要后长期不更新;应从组织信任的镜像仓库获取、验证并由自动化升级。


7.8 单包项目的生产 Dockerfile

# syntax=docker/dockerfile:1.7 ARG PYTHON_IMAGE=python:3.12-slim-trixie ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.30 FROM ${UV_IMAGE} AS uv-bin FROM ${PYTHON_IMAGE} AS builder COPY --from=uv-bin /uv /uvx /bin/ ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy WORKDIR /app # 依赖层:源码变化不会使其失效 COPY pyproject.toml uv.lock README.md ./ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-dev --no-install-project # 项目层 COPY src ./src RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-dev --no-editable FROM ${PYTHON_IMAGE} AS runtime RUN groupadd --system app \ && useradd --system --gid app --home-dir /app app WORKDIR /app COPY --from=builder /app/.venv /app/.venv ENV PATH="/app/.venv/bin:$PATH" \ PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 USER app ENTRYPOINT ["weather"] CMD ["Shanghai"]

7.8.1 为什么分两次同步

第一次只复制依赖声明和锁文件:

uv sync --locked --no-dev --no-install-project

它安装传递依赖,不安装频繁变化的当前项目。源码复制后第二次同步安装项目。业务代码变化不会使整个依赖层失效。

7.8.2 为什么使用UV_LINK_MODE=copy

BuildKit cache mount 与目标.venv可能位于不同文件系统,硬链接不可用。设置copy可避免链接警告,并确保最终镜像层不依赖已卸载的 cache mount。

7.8.3 为什么最终镜像不包含 uv

运行时只需要.venv中的 Python 和入口脚本。将 uv 留在 builder 可缩小攻击面。若生产运维确实需要uv run,可以复制 uv,但要明确理由。


7.9.dockerignore不可省略

.git/ .github/ .venv/ __pycache__/ .pytest_cache/ .mypy_cache/ .ruff_cache/ dist/ build/ .env .env.* *.pem *.key

.venv必须排除:本机环境不可移植,而且可能覆盖容器刚创建的 Linux 环境。敏感文件同时应从 Git 和构建上下文排除;.dockerignore不是秘密管理系统,只是最后一道防线。


7.10 Workspace 的 Docker 分层

早期依赖层如果只看到根pyproject.toml,uv 无法验证锁文件是否与所有成员一致。因此官方建议:

# syntax=docker/dockerfile:1.7 FROM python:3.12-slim-trixie AS builder COPY --from=ghcr.io/astral-sh/uv:0.11.30 /uv /uvx /bin/ ENV UV_LINK_MODE=copy \ UV_COMPILE_BYTECODE=1 WORKDIR /app # 此阶段没有成员 pyproject.toml,跳过新鲜度检查和成员安装 RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ uv sync --frozen --no-dev --no-install-workspace COPY . /app # 看到完整 workspace 后必须严格校验 RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-dev --no-editable --package weather-cli

这里早期使用--frozen不是因为它更严格,而是因为缺少成员元数据,无法执行完整新鲜度检查。复制完整仓库后,最终--locked必须成功。


7.11 容器安全与可重复性

7.11.1 基础镜像

  • 固定 Python 小版本或 digest;
  • 定期重建以获取系统安全更新;
  • slim 镜像可能缺编译器和共享库,构建扩展时使用 builder;
  • Alpine 使用 musl,不应假设 manylinux wheel 可直接复用;
  • 选择镜像前检查目标依赖是否提供对应 wheel。

7.11.2 非 root 运行

最终镜像创建专用用户并USER app。若程序需要写目录,应明确创建并授权,而不是把整个/app设为 777。

7.11.3 字节码

UV_COMPILE_BYTECODE=1可减少首次启动编译成本,但会增加构建时间和镜像体积。短生命周期 CLI 未必收益明显,Web 服务和无服务器冷启动场景应测量后决定。

7.11.4 构建秘密

私有索引凭据不要用ARGENV烘焙进镜像层。使用 BuildKit secret mount:

RUN --mount=type=secret,id=uv_index_password \ UV_INDEX_INTERNAL_PASSWORD="$(cat /run/secrets/uv_index_password)" \ uv sync --locked --no-dev

真实项目还需提供用户名或 credential provider。构建日志不得回显秘密。


7.12 私有索引配置

[[tool.uv.index]] name = "internal" url = "https://packages.example.com/simple" explicit = true authenticate = "always" [tool.uv.sources] company-weather-sdk = { index = "internal" }

7.12.1explicit = true

只有通过[tool.uv.sources]显式绑定的包才能从该索引安装。这样不会因为添加私有索引,就让所有公共依赖都从私有源搜索。

7.12.2 默认first-index

uv 默认对一个包停在第一个包含它的索引,并只在该索引的候选版本中解析。这与 pip 常见的合并候选行为不同,目的是降低 dependency confusion 风险。

不要为了“版本更新”随意启用:

uv sync--index-strategy unsafe-best-match

它会合并多个索引候选,更接近 pip,但显著扩大同名恶意包风险。优先修复索引顺序、同步代理或显式包绑定。

7.12.3 凭据环境变量

索引名internal对应:

$env:UV_INDEX_INTERNAL_USERNAME ="ci-user"$env:UV_INDEX_INTERNAL_PASSWORD ="<secret>"uv sync--locked

名称中的非字母数字会转换为下划线并大写。例如internal-proxy对应UV_INDEX_INTERNAL_PROXY_PASSWORD

authenticate = "always"适合那些未认证请求会被重定向到公共页面、因而不会返回标准 401 的索引;它要求 uv 在请求前主动寻找凭据。


7.13 企业网络、证书与离线环境

7.13.1 代理

使用组织标准的HTTPS_PROXY/HTTP_PROXY配置,并确认代理不会破坏包哈希和 TLS 验证。CI Secret 中的代理凭据同样不能打印。

7.13.2 企业 CA

优先让 Runner/容器信任组织 CA。uv 支持使用平台证书存储,但不要把--allow-insecure-host当作长期修复;它会降低 TLS 保护。

7.13.3 离线 wheelhouse

可把审核过的 wheel 放在 flat index:

[[tool.uv.index]] name = "offline" url = "./wheelhouse" format = "flat" explicit = true

离线交付必须覆盖目标平台、Python ABI 和所有传递依赖。只在联网开发机下载一次并不等于完成离线验证。


7.14 供应链控制

7.14.1 时间冷却

[tool.uv] exclude-newer = "7 days"

冷却期能避免立即采用刚上传的发行物,为社区和安全系统留出观察时间。它会降低更新速度,安全补丁需要例外流程。

7.14.2 SBOM

uv export--format cyclonedx1.5--output-file sbom.json

SBOM 应与具体提交、锁文件和构建产物关联。它列出组件,不自动判断漏洞是否可利用。

7.14.3 发布证明

PyPI Trusted Publishing、容器 provenance 和签名各自解决不同问题:身份、构建来源和产物完整性。生产流程应保存:

  • Git commit/tag;
  • CI run ID;
  • uv.lock哈希;
  • wheel/sdist/image digest;
  • SBOM 和扫描结果;
  • 发布环境审批记录。

7.15 常见故障

CI 本地通过但 Runner 失败

按顺序检查:

  1. CI Python 是否在requires-python范围内;
  2. 是否提交了最新uv.lock
  3. 本地是否依赖未声明的全局包;
  4. 目标平台是否有兼容 wheel 或编译工具;
  5. 私有索引和凭据是否只在本机配置;
  6. 删除缓存后是否仍失败。

Docker 每次都重新安装依赖

确认COPY . /app没有发生在依赖层之前。先复制pyproject.tomluv.lock和构建元数据,再执行--no-install-project

容器出现跨文件系统链接警告

在 BuildKit cache mount 场景设置:

ENV UV_LINK_MODE=copy

Workspace 早期依赖层报锁文件过期

早期层缺少成员元数据,使用--frozen --no-install-workspace;复制完整 workspace 后必须执行--locked

私有包解析到了公共 PyPI

使用explicit = true[tool.uv.sources]将包绑定到命名索引,检查索引优先级,不要用unsafe-best-match掩盖配置问题。

私有索引持续 401/403

检查环境变量名称转换、token 权限、索引 URL 是否以/simple结尾、代理和 CA;需要主动认证的索引设置authenticate = "always"


7.16 生产验收清单

CI

  • uv、Python 和第三方 Action 版本固定且有升级流程。
  • uv sync --locked在空缓存 Runner 上成功。
  • 最低支持 Python 和生产 Python 都有测试。
  • 测试 job 只有只读权限,发布权限位于独立受保护 job。
  • 缓存键包含锁文件,删除缓存不影响正确性。

Docker

  • .dockerignore排除.venv、Git、缓存和秘密。
  • 依赖层与源码层分开。
  • 最终镜像以非 root 用户运行。
  • 基础镜像和 uv 镜像固定版本/digest。
  • 私有索引秘密通过 secret mount 提供,不进入镜像历史。
  • 从最终镜像执行健康检查或 CLI 冒烟测试。

供应链

  • 内部包显式绑定私有索引。
  • 保持默认first-index,例外经过安全评审。
  • 发布优先使用 OIDC,没有长期 PyPI token。
  • 产物、SBOM、commit 和 CI run 能互相追踪。
  • 构建产物经过漏洞、许可证和秘密扫描。

7.17 本篇小结

生产级 uv 流程的重点不是“CI 里也能运行uv sync”,而是把锁文件当作不可变输入、把缓存当作可丢弃加速层、把测试与发布权限分离,并保证 Docker 最终镜像只包含运行必需内容。私有索引的explicit绑定和默认first-index则为 Python 依赖供应链提供了重要边界。

下一篇将给出从 pip/pip-tools、Poetry、PDM 和 Pipenv 迁移的分阶段方案,并建立覆盖解释器、解析、构建、网络和缓存的系统排障方法。

官方参考

  • Using uv in GitHub Actions
  • Using uv in Docker
  • Package indexes
  • Caching
  • PyPI Trusted Publishers