uv 替换 pip+poetry 实测:Python 工程化提速 20 倍的配置与踩坑

3次阅读
没有评论

共计 3037 个字符,预计需要花费 8 分钟才能阅读完成。

上周给一个跑了三年的 FastAPI 项目做重构,最拖时间的不是业务代码,而是「改一行依赖、等两分钟装包」这个循环。CI 上跑一次 poetry install 平均 3 分半,本地换台机器重新拉环境基本能摸鱼一个下午。忍了很久之后我把整条依赖链迁到了 uv,现在的数字是:CI 安装依赖 8 秒,本地冷启动装环境 3 秒出头。这篇把配置、Docker 构建、CI 缓存和踩过的坑一次性摊开写清楚。

一、核心背景与痛点拆解

先说我为什么非动这次刀。旧项目用的是 poetry 1.8 + 自建 Nexus 源,问题集中在三块:

1. 解析慢得反人类。 依赖树里带了 pandas、pydantic、sqlalchemy、celery 一大串,poetry 走一遍 SAT 求解要 30 秒以上,遇到版本冲突回退时更夸张,有一次卡了 4 分钟最后告诉我解析失败。

2. 环境态和声明态容易脱节。poetry add 会同时改 pyproject 和 lock,但同事在容器里直接 pip install xxx 的临时包不会进 lock,CI 和本地环境就悄悄分叉了,最后上线才炸。

3. CI 缓存命中率低。poetry 的虚拟环境路径不好拷,缓存 key 又只能按 lock 文件的 md5 打,稍微改个 dev 依赖整层缓存全废。

uv 是 Astral(写 ruff 那帮人)用 Rust 重写的 Python 包管理器,定位很贼:它同时替代 pip、pip-tools、pipx、poetry、virtualenv、pyenv 这一串工具。核心卖点是三件事:Rust 实现的 PubGrub 解析器、全局硬链接缓存(同一个 wheel 在磁盘上只存一份)、以及跨平台的 uv.lock。

跨平台 lock 这点我要单独夸一句。poetry 的 lock 里 marker 处理经常有毛病,我在 Mac 上 lock 完推到 Linux CI 上偶尔会报 SolverProblemError。uv.lock 会把所有平台 / 架构的解析结果都写进去,Mac 上生成的 lock 在 Linux 上 --frozen 安装完全一致,这个体验差别是质变。

二、关键配置与实战避坑

2.1 安装与项目初始化

# 单文件安装,不需要预先有 Python
curl -LsSf https://astral.sh/uv/install.sh | sh

# 新项目
uv init nassky-api && cd nassky-api
uv python pin 3.12          # 写进 .python-version

# 老项目迁移:有 pyproject.toml 就直接 lock
uv lock
uv sync

注意 uv python pin 干的是 pyenv 的活,它会真的去下载一个独立的 CPython 解释器放进 uv 的缓存目录,不污染系统 Python。服务器上我通常配 UV_PYTHON_INSTALL_MIRROR 指向国内镜像,不然下载 30MB 的解释器能等半分钟。

2.2 pyproject.toml 关键片段

[project]
name = "nassky-api"
version = "0.3.1"
requires-python = ">=3.11"
dependencies = [
  "fastapi>=0.115",
  "uvicorn[standard]>=0.32",
  "pydantic>=2.9",
  "sqlalchemy[asyncio]>=2.0.36",
]

# PEP 735 依赖组,uv add --dev 会自动写到这里
[dependency-groups]
dev = ["pytest>=8.3", "ruff>=0.8", "httpx>=0.27"]

# 国内源,default = true 表示替换 PyPI 作为主索引
[[tool.uv.index]]
name = "tuna"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
default = true

日常增删依赖只需要 uv add httpx / uv remove httpx,它会同步改 pyproject.toml、重算 lock、更新 .venv 三件套,一条命令到位。要跑命令用 uv run pytest -q,它会自动检测 .venv 是否需要 sync,比每次手动 activate 省心。

2.3 Docker 多阶段构建(这里有坑)

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

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_PYTHON_DOWNLOADS=0

WORKDIR /app
COPY pyproject.toml uv.lock ./

# 先只装依赖,充分利用层缓存
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-install-project --no-dev

COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev

ENV PATH="/app/.venv/bin:$PATH"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

坑一:UV_LINK_MODE=copy 必须加。uv 默认用硬链接把全局缓存里的包链到 .venv,但 Docker 的 --mount=type=cache 是独立挂载点,跨设备硬链接直接报 failed to hardlink files; falling back to full copy,然后你还得在日志里翻半天才知道它没生效。加上这个环境变量就老实拷贝,虽然多占点镜像层,但行为可预期。

坑二:--no-install-project 那一步别省。 很多人图省事直接把整个项目 COPY 进去再 sync,结果改一行业务代码,依赖层缓存全废,每次构建都要重装 40 个包。拆成「先 COPY 声明文件装依赖 → 再 COPY 源码」这两步,日常改代码的构建时间从 50 秒掉到 6 秒。

坑三:uv sync 会删掉你没声明的包。 这是我从 poetry 迁过来最不适应的一点。uv sync 的目标是「让 .venv 精确等于 lock 文件」,任何不在 lock 里的包都会被移除。我在调试时手动 uv pip install 了一个调试工具,下一次 uv run 触发 sync,那包没了,还以为自己失忆。要临时装东西就用 uv run --with rich python xxx.py,它走的是临时叠加环境,不碰项目 .venv。

坑四:uv pip install 和 uv add 是两套逻辑。 前者只是「兼容 pip 的一次性安装」,不写 pyproject、不更新 lock;后者才是项目依赖管理。上线脚本里如果有人混用 uv pip install -r requirements.txt,lock 会悄悄漂移,CI 校验 uv lock --check 就能把它揪出来。

2.4 CI 缓存配置

- uses: astral-sh/setup-uv@v5
  with:
    enable-cache: true
    cache-dependency-glob: "uv.lock"
    python-version: "3.12"

- run: uv sync --frozen --no-dev
- run: uv run --no-sync pytest -q

cache-dependency-glob 认准 uv.lock 就行,因为 lock 里已经包含了所有平台和 Python 版本的解析结果。加 --frozen 是硬

正文完
 0
评论(没有评论)