要让 Python 构建真正可复现,只需要三件事落到位:把 uv.lock 提交进版本库、在 CI 里用 uv lock --check 或 --locked 挡住锁文件漂移、在 Docker 里把依赖层和项目层分开。工作区(workspace)是可选项——它适合多个包共享一套依赖的单仓库,但如果成员之间存在依赖冲突或需要各自独立的虚拟环境,官方明确建议改用路径依赖而不是工作区。本文把这几件事拆成可执行的配置与检查清单。
uv.lock 锁的是什么
uv.lock 是通用锁文件(universal lockfile),记录的是跨平台解析结果——它捕获在所有可能的 Python 标记组合下会被安装的包,包括不同操作系统、架构和 Python 版本。这一点和 requirements.txt 有本质区别:后者通常是「在我这台机器上 pip freeze 出来的结果」,换个平台就可能不成立。
关于它的使用方式,官方项目结构文档有两句话是硬约束:
第一,它是「人类可读的 TOML 文件,但由 uv 管理,不应手工编辑」。看得懂不代表可以改。手工调整某个版本号而不重新解析,会让锁文件与它声称的约束脱节。
第二,它「应当提交进版本控制,以便在不同机器之间获得一致且可复现的安装」。
与之相对,.venv 目录不应该进版本库——uv 通过一个内部 .gitignore 自动排除它。项目环境也不应该手工修改,加依赖用 uv add。
还有一条容易误解的行为值得记住:uv 不会因为上游发布了新版本就认为锁文件过期。锁文件只有在你显式更新时才会变。想升级依赖要主动执行 uv lock --upgrade(全部升级)或 uv lock --upgrade-package <包名>(定点升级)。这个设计是对的——构建的可复现性不该被上游的发版节奏打断——但如果团队里有人以为「CI 会自动拿到最新的安全补丁」,那就是一个危险的误解。
什么时候该用工作区,什么时候不该用
工作区是一组一起管理的包,称为工作区成员。官方文档给出的典型场景是:一个基于 FastAPI 的 Web 应用,加上若干作为独立 Python 包维护和版本化的库,全都放在同一个 Git 仓库里。
关键特征是:每个包有自己的 pyproject.toml,但整个工作区共享同一个 uv.lock。共享锁文件意味着所有成员运行在一套一致的依赖版本上。
不该用工作区的情况,官方说得同样直接:工作区「不适合成员之间存在冲突需求、或希望每个成员拥有独立虚拟环境的场景」。这类情况应该改用路径依赖:
[tool.uv.sources]
bird-feeder = { path = "packages/bird-feeder" }
还有一条容易忽略的约束:工作区强制所有成员使用同一个 requires-python,取的是所有成员声明值的交集。如果你的某个库需要支持 Python 3.9 而主应用要求 3.12,工作区会把整体收窄到交集,这往往不是你想要的。遇到这种情况,拆成独立项目 + 路径依赖更合适。
判断标准可以简化成一句话:成员之间是否愿意接受同一套依赖版本。愿意就用工作区,不愿意就别用。
工作区怎么配置
在根 pyproject.toml 里加 tool.uv.workspace 表即可隐式创建工作区:
[tool.uv.workspace]
members = ["packages/*"]
exclude = ["packages/seeds"]
members 是必填项,exclude 可选,两者都接受 glob 模式。被 members 匹配到(且未被排除)的每个目录都必须包含 pyproject.toml。工作区根目录本身也是一个成员。
成员之间怎么互相依赖
用 tool.uv.sources 加 workspace = true:
[project]
name = "albatross"
dependencies = ["bird-feeder"]
[tool.uv.sources]
bird-feeder = { workspace = true }
工作区成员之间的依赖是可编辑安装的——改了 bird-feeder 的源码,albatross 立刻看到,不需要重新安装。这是工作区相对于「把内部库发到私有 PyPI」最直接的开发体验优势。
另外,工作区根目录的 tool.uv.sources 会应用到所有成员,除非成员在本地覆盖。把公共的源配置放在根,成员只写差异,是比较干净的组织方式。
命令在工作区里的作用范围
这是最容易搞混的部分,记住三条规则:
uv lock一次作用于整个工作区。没有「只锁某一个成员」这回事——锁文件本来就是全局唯一的。uv run和uv sync默认作用于工作区根。- 两者都接受
--package参数,可以从工作区任意目录对特定成员执行命令。
配套还有 --all-packages,用于对全部成员执行同步。
日常写法通常是这样:
uv sync --package api # 只同步 api 成员的依赖
uv run --package api pytest # 在 api 成员上下文里跑测试
uv sync --all-packages # 同步全部成员
CI 里怎么挡住锁文件漂移
这是可复现构建的核心门禁。默认情况下,如果项目元数据变了(加了依赖、改了版本约束),uv 会自动更新锁文件。这在本地开发时很方便,在 CI 里则是灾难——它会悄悄地把「锁文件和 pyproject.toml 不一致」这个问题掩盖过去。
官方锁定与同步文档给出了三个控制标志,语义各不相同:
| 标志 | 行为 | 适用场景 |
|---|---|---|
--locked |
锁文件不是最新时报错,而不是更新它 | CI 构建、发布流水线 |
--frozen |
直接使用现有锁文件,不检查是否最新 | 容器运行时、已知锁文件正确的场景 |
--no-sync |
执行命令时不校验环境是否与锁文件一致 | 环境由外部管理时 |
区别很重要:--locked 会验证并在不一致时失败,--frozen 是跳过验证。用 --frozen 做 CI 门禁等于没有门禁。
另外,uv lock --check 专门用来在 CI 里显式检查锁文件状态,官方说明它「等价于其他命令上的 --locked 标志」。
一个最小可用的 CI 门禁:
uv lock --check # 锁文件与 pyproject.toml 是否一致
uv sync --locked --no-dev # 按锁文件安装,不装开发依赖
uv run --no-sync pytest # 跑测试,不再触发同步
第一条命令是关键。有人提了新依赖但忘记提交更新后的 uv.lock,这一步就会红,而不是让 CI 悄悄用一份临时解析的结果跑绿。
依赖分组与 extras
同步时的选择性安装有一组标志,按用途分开记比较清楚:
--all-extras或--extra <名称>:包含[project.optional-dependencies]里的可选依赖--no-dev:排除dev依赖组--all-groups:包含[dependency-groups]里的所有组--only-dev或--only-group <名称>:只安装指定的组,不安装项目本身
--only-group 有一个实用场景:在 CI 里单独跑 lint 或类型检查时,你只需要工具链,不需要装项目和它的运行时依赖。这能让检查任务的镜像小很多、启动快很多。
还有一个默认行为差异值得留意:uv sync 默认是精确同步,会移除环境里多余的包;uv run 默认是非精确同步,只保证依赖装上、不删多余的。想反过来就用 uv sync --inexact 或 uv run --exact。CI 里应该用精确同步,避免上一次构建的残留影响这一次。
Docker 构建怎么分层
依赖变化的频率远低于业务代码,把它们放在同一层意味着每次改一行代码都要重装全部依赖。官方 Docker 集成文档推荐的做法是用 --no-install-project 把传递依赖的安装单独放进一层:
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 --locked --no-install-project
注意这里只 bind 挂载了 uv.lock 和 pyproject.toml 两个文件,没有 COPY 整个项目。这样只要这两个文件没变,这一层就命中缓存。
完整的两步模式:
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-install-project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked
配套的三个环境变量:
ENV UV_LINK_MODE=copy
ENV UV_COMPILE_BYTECODE=1
ENV UV_NO_DEV=1
UV_LINK_MODE=copy 用于构建缓存与目标目录不在同一文件系统的情况,官方说明它「消除无法硬链接文件的警告」。UV_COMPILE_BYTECODE=1 在生产镜像里预编译字节码,等价于 uv sync --compile-bytecode,用构建期的一点时间换启动期的速度。UV_NO_DEV=1 关掉开发依赖。
工作区场景下还有对应的 --no-install-workspace(跳过所有工作区成员)和 --no-install-package(跳过指定包),分层策略是一样的。
注意所有 uv sync 都带 --locked。镜像构建阶段发现锁文件不一致,应该直接失败,而不是重新解析出一份和 CI 不同的依赖。
导出与合规
有些环境暂时收不掉对 requirements.txt 的依赖,或者需要向安全团队提交物料清单。uv export --format <格式> 可以把 uv.lock 转换成:
requirements.txt——兼容既有工具链pylock.toml——PEP 751 定义的标准锁文件格式- CycloneDX SBOM——软件物料清单
关键在于把导出物当作派生产物而不是真相来源。真相是 uv.lock。如果团队开始手工维护导出的 requirements.txt,两者就会分叉,可复现性也就没了。正确做法是在 CI 里生成导出物,而不是提交它们。
uv 还有一个预览阶段的恶意软件检查能力,通过 audit.malware-check = true 或环境变量 UV_MALWARE_CHECK=1 启用,对照 OSV 公告扫描。因为处于预览阶段,是否纳入强制门禁需要自行评估,但作为附加信号是有价值的。
常见失败模式
「本地能跑,CI 装不上」。 多半是锁文件没提交,或者提交了但不是最新的。加上 uv lock --check 就能立刻定位。
升级依赖后锁文件没变。 这是预期行为,不是 bug。uv 不会因为上游发新版就更新锁文件,必须显式 uv lock --upgrade 或 --upgrade-package。
工作区里某个成员装不上依赖。 先检查 requires-python。工作区取所有成员的交集,某个成员声明了过窄的范围就会连累全体。
手工改了 uv.lock 之后行为诡异。 不要手工改。回退这个文件,从 pyproject.toml 重新解析。
Docker 每次都重装全部依赖。 检查是不是在 uv sync 之前就 COPY . /app 了。那样任何代码改动都会让依赖层失效。
导出的 requirements.txt 和实际环境对不上。 检查导出时用的分组和 extras 标志是否与 uv sync 一致。两条命令用了不同的 --no-dev/--extra 组合就会产生差异。
可复现构建检查清单
uv.lock已提交进版本库,且从不手工编辑.venv未进版本库- CI 第一步执行
uv lock --check,锁文件漂移直接失败 - CI 安装使用
uv sync --locked,不使用--frozen冒充门禁 - 依赖升级通过
uv lock --upgrade-package显式发起,并作为独立提交评审 - 工作区成员的
requires-python交集确认过,且是有意为之 - 成员间依赖用
tool.uv.sources的workspace = true声明,而不是相对路径 hack - Dockerfile 用
--no-install-project拆出依赖层,并设置UV_LINK_MODE、UV_COMPILE_BYTECODE、UV_NO_DEV - 导出的
requirements.txt/pylock.toml/ SBOM 由 CI 生成,不提交进仓库 - 依赖升级与 Python 版本升级分开进行,不在同一个变更里混做
最后一条值得展开。依赖升级和解释器升级同时做,出问题时无法归因——你不知道是新版本的库不兼容,还是解释器本身的行为变化。Python 版本迁移本身有一整套需要单独走的流程,我们在Python 3.15 Beta 4 迁移指南里写过依赖、ABI 与上线门禁的处理方式,配合本文的锁文件门禁使用效果更好。
至于服务本身怎么打包上线、发布状态与缓存怎么协同,用 FastAPI、MongoDB 与 Redis 构建原子发布的双语内容系统里讨论的发布流程可以作为下游参考。
如果你想把 uv lock --check 和依赖审计变成每日定时任务而不是只在 PR 时跑,用一个轻量调度器就够了。Cronova 是单二进制自托管的工作流调度器,用 YAML 定义 DAG,内置 SQLite、Web Console 和 REST API,把定时、依赖和失败通知都放在调度层。
常见问题
uv.lock 要不要提交? 要。官方明确建议提交进版本控制,这是跨机器可复现安装的前提。
能不能手工编辑 uv.lock? 不能。它是人类可读的 TOML,但由 uv 管理,官方明确说不应手工编辑。
--locked 和 --frozen 有什么区别? --locked 在锁文件不是最新时报错;--frozen 直接使用现有锁文件、不做检查。CI 门禁要用 --locked。
新版本发布了,uv 会自动更新锁文件吗? 不会。必须显式执行 uv lock --upgrade 或 uv lock --upgrade-package <包名>。
多个包必须用工作区吗? 不必。成员之间有依赖冲突、或需要各自独立的虚拟环境时,官方建议用路径依赖代替工作区。
工作区能给每个成员单独锁版本吗? 不能。整个工作区共享一个 uv.lock,这正是它保证成员间依赖一致的方式。需要独立锁定就不该用工作区。
能同时保留 requirements.txt 吗? 可以,用 uv export --format requirements.txt 生成。但要把它当成派生产物,由 CI 产出,不要手工维护。