共计 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 是硬