2365 字
约 7 分钟
17
S10-X08 · 虚拟环境的隔离边界与依赖锁定

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. 自测

  1. sys.prefix 与 sys.base_prefix 各代表什么?何时两者相等?
  2. 为什么在 systemd 的 ExecStart 里写 source .venv/bin/activate 无效?
  3. pip freeze 作为长期锁文件的核心缺陷是什么?
  4. pkg~=1.4.2 与 pkg~=1.4 允许的版本范围差别在哪?
  5. --require-hashes 为什么要求所有行(含间接依赖)都带哈希?
  6. Docker 构建里 COPY requirements.txt 与 COPY . . 的先后顺序为什么影响缓存命中?
S10-X08 · 虚拟环境的隔离边界与依赖锁定
https://www.clxhxhhr.top/posts/5857/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。