uv 系列(七):CI/CD、Docker 与私有索引——生产级交付
核心目标:把本地 uv 工作流可靠迁移到 GitHub Actions 和生产容器,正确使用锁文件、缓存、私有索引和短期凭据,建立可审计的交付链路。
前置知识:已掌握 uv 项目、锁文件、构建发布和 workspace。
文档基线:uv 0.11.x;GitHub Actions、Docker 和索引行为依据 2026-07-21 的 uv 官方文档复核。CI 中应定期升级并重新验证固定版本。
7.1 生产交付的四条底线
本地执行成功不代表能够稳定交付。CI 和容器至少满足:
- 输入可追踪:代码提交、Python、uv、锁文件和基础镜像都有明确版本;
- 构建可重复:CI 不静默改锁文件,容器不复制本机
.venv; - 权限最小化:测试 job 没有发布权限,长期 token 不进入镜像和日志;
- 缓存可丢弃:删除全部缓存后仍能得到正确结果。
缓存只是一条虚线。如果移除缓存后构建失败,问题在声明或环境,而不是“缓存配置不够好”。
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--ci7.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.toml与uv.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 往往没有收益。大型项目可拆为:
快速 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 构建秘密
私有索引凭据不要用ARG或ENV烘焙进镜像层。使用 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.jsonSBOM 应与具体提交、锁文件和构建产物关联。它列出组件,不自动判断漏洞是否可利用。
7.14.3 发布证明
PyPI Trusted Publishing、容器 provenance 和签名各自解决不同问题:身份、构建来源和产物完整性。生产流程应保存:
- Git commit/tag;
- CI run ID;
uv.lock哈希;- wheel/sdist/image digest;
- SBOM 和扫描结果;
- 发布环境审批记录。
7.15 常见故障
CI 本地通过但 Runner 失败
按顺序检查:
- CI Python 是否在
requires-python范围内; - 是否提交了最新
uv.lock; - 本地是否依赖未声明的全局包;
- 目标平台是否有兼容 wheel 或编译工具;
- 私有索引和凭据是否只在本机配置;
- 删除缓存后是否仍失败。
Docker 每次都重新安装依赖
确认COPY . /app没有发生在依赖层之前。先复制pyproject.toml、uv.lock和构建元数据,再执行--no-install-project。
容器出现跨文件系统链接警告
在 BuildKit cache mount 场景设置:
ENV UV_LINK_MODE=copyWorkspace 早期依赖层报锁文件过期
早期层缺少成员元数据,使用--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
