python -m venv .venv 之后 pip install 成功、import 也成功,第二天 CI 里却报 ModuleNotFoundError。这类问题几乎不是"环境没建好",而是以为 venv 隔离了它根本不管的东西,或依赖锁没真正落到文件里。这篇只谈边界:venv 挡住了什么,锁文件锁住了什么。
1. venv 隔离了什么,没隔离什么
venv 的本质是三样东西:带 pyvenv.cfg 的目录、独立的 site-packages、指向系统解释器的符号链接。起作用的是解释器启动时发现 sys.prefix != sys.base_prefix,site 模块于是把搜索路径换成 venv 里的那份。
| 对象 | 是否隔离 | 依据 | 典型误判 |
|---|---|---|---|
| 第三方包 | 隔离 | site.getsitepackages() 指向 .venv/lib/python3.11/site-packages |
—— |
| 解释器与标准库 | 不隔离 | sys.base_prefix 仍指向系统 Python |
以为 venv 里能换 3.10 |
全局 CLI(ruff、uv) |
不隔离 | 装在 ~/.local/bin,仍在 PATH 上 |
以为 venv 里没有就没有 |
| 系统包管理器装的包 | 不隔离 | Debian 系注入 /usr/lib/python3/dist-packages |
pip list 里"多余"的包 |
PYTHONPATH / 代理变量 |
不隔离 | venv 不碰 os.environ |
环境外的目录照样能 import |
PATH 上其他 python |
不隔离 | 谁在前用谁 | python 与 python3 分属不同环境 |
import os, site, sys
print("executable :", sys.executable)
print("prefix :", sys.prefix)
print("base_prefix :", sys.base_prefix)
print("in_venv :", sys.prefix != sys.base_prefix)
print("site-packages:", site.getsitepackages())
print("PYTHONPATH :", os.environ.get("PYTHONPATH"))
print("sys.path[:4] :", sys.path[:4])
in_venv 为 True 只说明解释器来自 venv,不说明 pip 装到了哪里。判断"装到哪"唯一可靠的依据是 pip -V 打印的路径,或用 python -m pip list 显式指定解释器。
2. 三种建环境方式的取舍
| 维度 | python -m venv |
uv venv + uv pip |
conda / mamba |
|---|---|---|---|
| 隔离粒度 | 仅 Python 包 | 仅 Python 包 | 包 + 非 Python 库(CUDA、libpq) |
| 解释器版本 | 由创建它的 python 决定 | --python 3.11 可自动下载 |
create -n x python=3.11 |
| 创建开销 | 典型量级:秒级到十几秒 | 典型量级:亚秒级(命中缓存) | 典型量级:十几秒到分钟级 |
| 依赖解析 | pip 回溯,慢 |
内置解析器,明显快 | 遇大依赖图可能很慢 |
| 典型适用 | 老项目、零额外工具 | 新项目、CI 求快 | 数据科学、含二进制依赖 |
选型看依赖:含需编译的 C 库或 CUDA 时 conda 省事,纯 Python 包则 venv/uv 更轻。
3. 激活与不激活:直接调 .venv/bin/python
activate 只做三件事:把 .venv/bin 插到 PATH 最前、设 VIRTUAL_ENV、存旧 PATH 供 deactivate 还原。它是给人用的便利,不是隔离机制。
| 场景 | source .venv/bin/activate |
直接调 .venv/bin/python |
说明 |
|---|---|---|---|
| CI 流水线 | 脆弱 | 推荐 | 非交互 shell、set -e 下常失效 |
systemd ExecStart |
无效 | 必须 | 不读 shell rc,也不认 source |
Docker ENTRYPOINT |
无效 | 必须 | 同上,镜像里可能没 bash |
| cron | 无效 | 必须 | cron 的 PATH 极简 |
subprocess |
继承但易错 | 推荐 | 显式用 sys.executable 最稳 |
import subprocess, sys
# 永远用当前解释器,而不是裸的 "python"
subprocess.run([sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], check=True)
subprocess.run([sys.executable, "-m", "pytest", "-q"], check=True)
print(sys.executable) # 跟着 venv 走;裸 "python" 跟着 PATH 走
sys.executable 跟着 venv 走,"python" 跟着 PATH 走——这就是"本地通过、服务器失败"的经典来源。同理装包统一写 python -m pip install,别写裸 pip install:前者把"装到这个解释器"写死。
4. 依赖锁定的四种产物
| 产物 | 谁生成 | 解决什么 | 不解决什么 |
|---|---|---|---|
手写 requirements.txt |
人 | 声明直接依赖 | 无传递依赖、无精确版本 |
pip freeze > requirements.txt |
机器 | 冻结当前环境全部包 | 混入无关包;无法区分直接/间接 |
requirements.in + pip-compile |
pip-tools |
声明与锁定分离,可再解析 | 需额外工具与编译步骤 |
uv.lock / poetry.lock |
uv / poetry |
全图哈希锁定,含平台标记 | 只有对应工具能消费 |
import subprocess, sys
IN_DEPS = ["fastapi~=0.115", "pydantic>=2.7,<3", "sqlalchemy~=2.0"]
open("requirements.in", "w", encoding="utf-8").write("\n".join(IN_DEPS) + "\n")
# 编译成带哈希的锁
subprocess.run([sys.executable, "-m", "piptools", "compile", "--generate-hashes",
"-o", "requirements.txt", "requirements.in"], check=True)
# 只装锁,不让 pip 重新解析
subprocess.run([sys.executable, "-m", "pip", "install", "--no-deps",
"--require-hashes", "-r", "requirements.txt"], check=True)
pip freeze 的代价很具体:间接拉进来的 six、typing-extensions 全变成顶层需求,日后升 pydantic 时解析器不知道哪些能跟着走。它适合考古式复现旧环境,不适合做长期的锁。
5. ==、~= 与无约束的实际行为
| 写法 | PEP 440 语义 | 实际后果 |
|---|---|---|
pkg |
任意版本 | 每次解析可能不同;依赖打架时随便降级 |
pkg==1.4.2 |
精确 | 可复现,但挡住安全补丁 |
pkg~=1.4.2 |
>=1.4.2, ==1.4.* |
允许补丁级浮动到 1.4.9 |
pkg~=1.4 |
>=1.4, ==1.* |
允许到 1.9,风险较大 |
pkg>=1.4,<2 |
区间 | 表达"兼容 1.x"的常见意图 |
pkg==1.* |
通配 | 等价 >=1,<2,不含补丁约束 |
两个易忽略点:~=1.4.2 与 ~=1.4 不是一回事;预发布默认不参与匹配。
6. --no-deps 与 --require-hashes
| 参数 | 什么时候用 | 副作用 |
|---|---|---|
--no-deps |
锁文件已完整,跳过重复解析 | 锁漏包时不报错,运行期才 ImportError |
--require-hashes |
生产构建、防投毒 | 所有行(含间接依赖)都要 --hash;不能装可编辑包 |
--no-index --find-links |
内网、断网机器 | 需预先把各平台 wheel 下齐 |
import hashlib, pathlib, re
# 自查:锁里的 hash 与本地 wheel 实际内容是否一致
pat = re.compile(r"^([\w.\-]+)==[^ ]+\s+--hash=sha256:([0-9a-f]{64})")
lock = {m[1]: m[2] for m in map(pat.match, pathlib.Path("requirements.txt").read_text().splitlines()) if m}
for name, expected in lock.items():
for whl in pathlib.Path("wheels").glob(f"{name}-*.whl"):
got = hashlib.sha256(whl.read_bytes()).hexdigest()
print(name, "OK" if got == expected else "MISMATCH", whl.name)
7. 私有源与离线配置
# ~/.config/pip/pip.conf (Windows: %APPDATA%\pip\pip.ini)
[global]
index-url = https://pypi.internal.example.com/simple
extra-index-url = https://pypi.org/simple
trusted-host = pypi.internal.example.com
[install]
require-virtualenv = true
| 配置位置 | 优先级 | 适用 |
|---|---|---|
命令行 --index-url |
最高 | 一次性覆盖 |
环境变量 PIP_INDEX_URL |
高 | CI 注入、容器构建参数 |
用户级 pip.conf |
中 | 机器默认值 |
venv 内 pip.conf |
中 | 环境专属源 |
require-virtualenv = true 很值钱:没进 venv 时 pip 直接拒绝安装,从根上砍掉"装到系统 Python"这一类事故。
8. 可复现构建的检查清单
import os, subprocess, sys, difflib, pathlib
def norm(text):
rows = [l.split("--hash")[0].strip() for l in text.splitlines()
if l.strip() and not l.startswith("#")]
return sorted(rows)
subprocess.run([sys.executable, "-m", "pip", "install", "--require-hashes",
"-r", "requirements.txt"], check=True)
frozen = subprocess.run([sys.executable, "-m", "pip", "freeze"],
capture_output=True, text=True, check=True).stdout
diff = list(difflib.unified_diff(norm(frozen),
norm(pathlib.Path("requirements.txt").read_text()), lineterm=""))
print("LOCK MATCH" if not diff else "\n".join(diff))
print("PYTHONPATH:", os.environ.get("PYTHONPATH", "<empty>"))
| 检查项 | 通过标准 |
|---|---|
| 解释器版本 | python -VV 与 CI、生产一致(3.11.x) |
| 锁完整性 | pip install -r 后 pip check 无冲突 |
| 哈希 | 全部行带 --hash,--require-hashes 通过 |
| 无隐式安装 | pip freeze 结果 ⊆ 锁文件 |
无 --user 污染 |
用户 site 目录不在 sys.path |
| Docker 层顺序 | 先拷锁文件装依赖,再拷源码 |
9. 排错:六种典型现场
| 现象 | 根因 | 处理 |
|---|---|---|
| 激活后仍装到全局 | 用了裸 pip install,pip 来自 ~/.local/bin |
一律 python -m pip;开 require-virtualenv |
pip freeze 后无法升级 |
间接依赖也被写成顶层精确版本 | 改用 requirements.in + pip-compile |
锁与 pyproject.toml 不同步 |
手改依赖没重编译 | CI 加 pip-compile --dry-run 或 uv lock --check |
| Docker 缓存每次失效 | COPY . . 写在装依赖之前 |
先拷锁文件装依赖,再拷源码 |
ImportError 但包里有模块 |
--no-deps 装漏子依赖 |
去掉该参数或重编译锁 |
| 换机器行为不同 | 平台条件包没进锁 | 用带环境标记的锁 |
10. 边界:什么时候 venv 本来就不够
| 需求 | 更合适的方案 | 原因 |
|---|---|---|
| 需要非 Python 二进制 | conda / 系统包 / 容器 |
venv 只管 site-packages |
| 完全可复现的环境 | 固定 digest 的镜像 | venv 不含系统库与补丁 |
| 多项目共享包 | uv 硬链接缓存 |
每个 venv 独立装白占磁盘 |
| 切换解释器大版本 | uv python / pyenv |
venv 换不了 Python 版本 |
| 单文件脚本依赖 | PEP 723 内联元数据 + uv run |
为一次性脚本建环境不值得 |
一句话:venv 是"包搜索路径"的开关,不是虚拟机。 把它当成唯一隔离边界,迟早栽在"系统库不同"和"另一个 python 在 PATH 上"。
11. 自测
sys.prefix与sys.base_prefix各代表什么?何时两者相等?- 为什么在 systemd 的
ExecStart里写source .venv/bin/activate无效? pip freeze作为长期锁文件的核心缺陷是什么?pkg~=1.4.2与pkg~=1.4允许的版本范围差别在哪?--require-hashes为什么要求所有行(含间接依赖)都带哈希?- Docker 构建里
COPY requirements.txt与COPY . .的先后顺序为什么影响缓存命中?