在 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.lock、data-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)解析:
- 读
uv.lock,提取[manifest].members和 packages deps。 - 递归找项目 deps:
local_projects = {project}; find_deps(package_name)。 - 生成 sources_map:
{pkg: source.editable path}。 - 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 管道:
- Checkout + bootstrap uv/Dagger。
dagger call build-project --project my-service:先 deps-dev stage(third-party only),后 dynamic copy +uv sync --inexact。- 测试 /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 概念借鉴。