Hotdry.
systems

破解 Python 单仓库依赖地狱:uv + Dagger 的增量解析与纯净构建

针对 100M+ 行代码 Python 单仓库,不用 Bazel,通过 uv workspaces 多解析组、Dagger 解析 lockfile 实现增量依赖、纯净 Docker 构建与远程缓存,提升 CI 到秒级反馈。

在 Python 单仓库(monorepo)中,依赖地狱(dependency hell)是常见痛点:全局 lockfile 导致版本冲突,传统构建 COPY 整个 repo 浪费时间,CI 管道动辄小时级,尤其规模达 100M+ 行代码时。解决方案是通过增量依赖解析、纯净构建(hermetic builds)和远程缓存,实现 Bazel-like 效果而不依赖 Bazel。本文聚焦 uv + Dagger 组合,提供可落地参数和清单。

避免依赖地狱:多解析组(Multiple Resolves/Workspaces)

传统单 lockfile 强制所有包共享依赖,升级 Django 从 3 到 4 需全局重锁。改为多个解析组,按栈分隔,如 web-django3.lockdata-pandas2.lock

使用 uv workspaces:

  • pyproject.toml 定义 workspace.members = ["projects/*"]
  • 每个子包 pyproject.toml 指定依赖,uv add --package lib-two lib-one 自动更新根 uv.lock
  • 规则:库固定一组,跨组用 API(HTTP/gRPC)而非直接 import,避免运行时冲突。

参数:

  • 组数 ≤10,避免碎片化(监控:每个组包数 >50)。
  • 迁移:渐进,将服务移入新组,兼容层桥接旧版。

Pants 等工具类似,支持 resolve="web-app" 参数化 targets。此策略确保增量升级,无全局阻塞。

纯净构建:固定工具链与沙箱

纯净构建要求输入确定、输出可复现、无网络 / 主机依赖。

uv + Docker 实现:

  • 固定 Python:ARG PYTHON_VERSION=3.12.8,用 slim 镜像。
  • 预装 uv:COPY --from=ghcr.io/astral-sh/uv:0.5.27 /uv /bin/uv
  • 环境:UV_FROZEN=1 UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy,禁用网络 pip。
  • 多阶段 Dockerfile:
    FROM python:${PYTHON_VERSION}-slim AS base
    # apt + uv install
    FROM base AS deps-dev
    COPY pyproject.toml uv.lock ./
    RUN uv sync --no-install-workspace --only-group dev --package $PACKAGE
    FROM deps-dev AS final
    # 后续 copy sources + uv sync --inexact --package $PACKAGE
    
  • 沙箱:BuildKit cache mount --mount=type=cache,target=/root/.cache/uv,容器内禁 HOME/system pkgs。

参数:

  • Cache TTL:7 天,hit rate 阈值 85%。
  • 工具 pin:ruff 0.5+、pyright、pytest 固定在 dev group。

结果:相同输入必相同输出,cache 命中率 >90%。

增量依赖解析:解析 uv.lock 动态 copy

核心创新:不 COPY 全 repo,利用 uv.lock 推断 transitive deps,只 copy 受影响 sources。

Dagger(Python SDK)解析:

  1. uv.lock,提取 [manifest].members 和 packages deps。
  2. 递归找项目 deps:local_projects = {project}; find_deps(package_name)
  3. 生成 sources_map:{pkg: source.editable path}
  4. Container.with_directory (f"/src/{path}", root_dir.directory (path)) 只 copy 这些。

示例 Dagger 函数(简化):

async def get_project_sources_map(self, uv_lock: File, project: str) -> dict:
    uv_lock_dict = tomli.loads(await uv_lock.contents())
    # 递归 deps + map paths
    return project_sources_map

def copy_source_code(self, container, root_dir, sources_map):
    for pkg, path in sources_map.items():
        container.with_directory(f"/src/{path}", root_dir.directory(path))

CI 管道:

  1. Checkout + bootstrap uv/Dagger。
  2. dagger call build-project --project my-service:先 deps-dev stage(third-party only),后 dynamic copy + uv sync --inexact
  3. 测试 /lint:dagger call pytest --project my-service,复用 container。

针对 100M LOC:细粒度 targets(per package/test),change-based:git diff → affected targets(Dagger 可扩展 --changed-since=main)。

参数:

  • Parallelism:cores * 2(Dagger/BuildKit)。
  • Cache key:sources digest + lock contents + PACKAGE。
  • 回滚:若 cache miss >20%,fallback 全 build。

远程缓存与 CI 规模化

Dagger/BuildKit 原生远程缓存:

  • Local:~/.dagger/cache。
  • Remote:Dagger Cloud 或 S3,key=content-addressed digest。
  • CI(GitHub Actions/Argo):ephemeral workers,拉取 cache,push outputs(wheels、coverage)。

清单:

方面 参数 / 阈值 监控点
Deps 组数 <10,包 / 组> 50 冲突率 < 5%
Builds UV_FROZEN=1,cache mount Hit>85%,build<30s/pkg
Incremental Parse lock<1s,copy<10s Affected<5% total
CI Remote exec,changed-only PR<5min,hit>80%

风险:深 deps 链导致 copy 多(限深度 < 5,回滚 split);lock 解析失败(fallback static deps)。

此方案在 Dagster 等实践验证,CI 从小时降秒,适用于 100M+ LOC。

资料来源: [1] Daniel Gafni, "Cracking the Python Monorepo" (https://gafni.dev/blog/cracking-the-python-monorepo/):uv+Dagger 核心实现。 [2] Pantsbuild, "Multiple lockfiles in Python repos" (https://www.pantsbuild.org/blog/2022/05/25/multiple-lockfiles-python):resolves 概念借鉴。

查看归档