昨天在那台 Ubuntu 服务器上装 markdown 和 pyyaml,pip3 install 直接给我脸色看:

error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
    python3-xyz, where xyz is the package you are trying to
    install.

一开始以为是 pip 版本太老,升了没用。其实这跟 pip 版本没关系,是 Debian/Ubuntu 从 23.04 开始搞的 PEP 668。

这报错到底在说什么

系统里有个标记文件:

$ ls -l /usr/lib/python3.12/EXTERNALLY-MANAGED
-rw-r--r-- 1 root root 2490 ... EXTERNALLY-MANAGED

有它在,pip 就认为"这个 Python 的包由系统 apt 管着,你别乱动"。理由不算没道理:apt 装的 python3-requests 和 pip 装的 requests 会互相覆盖,把 apt 自己的依赖搞崩,apt upgrade 时给你报一堆冲突。pip 干脆提前拒绝,省得你手动收拾。

四个方案,我实际怎么选的

方案一:venv,正道。 缺点是这台机器上 python3 -m venv 直接报 ensurepip is not available,因为系统没装 python3-venv 包。而且这台机 PEP 668 管得死,装它也得走 apt,得先 sudo。折腾。

sudo apt update && sudo apt install -y python3-venv
python3 -m venv ~/venv
source ~/venv/bin/activate
pip install markdown pyyaml

venv 目录随便放,激活后 which pip 会指向 ~/venv/bin/pip,跟系统彻底隔离。这是最干净的方案,新项目无脑用。

方案二:pipx,适合命令行工具。 只装给你敲命令用的东西(black、httpie、pre-commit),每个包自带独立环境,不会污染主环境:

pipx install black

它底层也是 venv,但自动帮你建好、你不用管激活。

方案三:--user,装到用户目录。 不需要 sudo,也不需要 venv,包落在 ~/.local/lib/python3.12/site-packages:

pip3 install --user markdown

这个方案很轻,但有两个前提得注意:一是 pip 自己也可能被包管理管住,如果连 pip3 install --user 都报同样的错,就得叠 --break-system-packages;二是 ~/.local/bin 得在 PATH 里,否则装了命令也敲不出来。

方案四:--break-system-packages,我这次用的。 就是把上面那道锁强行撬开:

pip3 install --user --break-system-packages markdown pyyaml

能装上,但心里得有点数:它只是让 pip 不再检查标记文件,包照样往系统 Python 的搜索路径里塞。如果哪天 apt 更新了 python3 本体,这些第三方包可能跟着一起进黑洞。生产环境我不这么干,这台是纯静态博客构建机,除了跑 build.py 没别的 Python 依赖,炸了也就重来一次——所以认了。

顺带一提,如果依赖在 apt 里有现成的,优先 apt:

apt install python3-yaml

Debian 系打包了一堆 Python 包在 python3-xxx 里,版本偏旧但胜在和系统一致,不会打架。

第二个坑:装成功了,import 不到

解决了安装问题,紧接着又撞一个。跑 python3 build.py 报 ModuleNotFoundError: No module named 'yaml'——包明明装上了。

查了一下路径:

$ which -a python3
/usr/local/bin/python3
/usr/bin/python3

$ which -a pip3
/usr/local/bin/pip3

$ python3 -c "import sys; print(sys.executable); print('\n'.join(sys.path))"
/usr/bin/python3
['', '/usr/lib/python312.zip', '/usr/lib/python3.12', '/usr/lib/python3.12/lib-dynload',
 '/usr/local/lib/python3.12/dist-packages', '/usr/lib/python3/dist-packages']

问题清楚了:python3 解析到的是 /usr/bin/python3,但 pip3 是 /usr/local/bin/pip3,它把包装进了 /usr/local/lib/python3.12/site-packages。而系统 python3 的 sys.path 里只有 dist-packages,没有 site-packages。这俩不是一个 Python。

如果你走了 pip install --user,理论上应该装到 ~/.local/lib/python3.12/site-packages,这个目录默认在 sys.path 里,也不在上面的输出中——说明 --user 那次压根没生效,或者 pip 把它降级成了全局安装。

验证办法就一句话:

python3 -c "import sys; print(sys.executable); print(sys.path)"

然后确认 pip 的落点:

pip3 -V                       # 看清 First/Second/Third 行是哪个 python
python3 -m pip -V             # 永远用 -m 形式调用 pip,能避免 90% 的错配

python3 -m pip 这个写法我以前不理解为什么不直接 pip,后来吃了两次亏就懂了:它保证用的是"当前这个 python3",不会因为 PATH 顺序变了就用错解释器。系统重装了、源码编译装了新版 Python、或者 pyenv 切了版本,pip 这个独立入口还指着老版本,-m 就不会。

第五个坑:装是装上了,PATH 里没有

用 --user 装还有个连带问题。包本体进了 ~/.local/lib/python3.12/site-packages,import 是没问题的;但如果装的是一个带命令行入口的包(pipx 那类,或者直接装的 black、httpie),可执行文件落在 ~/.local/bin。

这个目录默认不一定在 PATH 里。装完敲一下:

$ which black
/usr/bin/bash: line 1: black: command not found

别急着说包坏了,先看文件到底在不在:

ls ~/.local/bin/

在的话就是 PATH 问题,两个解法。临时加:

export PATH="$HOME/.local/bin:$PATH"

或者写进 ~/.bashrc 永久生效(记得 source ~/.bashrc)。这台机器上我直接改的是 ~/.profile,因为有些非交互式执行路径不读 .bashrc:

grep -q '.local/bin' ~/.profile || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.profile

我现在的做法

这台机器上:apt 装 python3-yaml(原生),markdown 和 pyyaml 用 pip3 install --user --break-system-packages,装完立刻 python3 -c "import yaml, markdown" 验证一遍。以后在这台机器上写任何 Python 脚本,我都默认先 python3 -m pip install --user,忘了加 -m 就当自己没配好环境。

现在建博客的脚本我已经固定成一个 deploy.sh:

#!/bin/bash
set -e
cd /home/ubuntu/blog-src
python3 build.py
sudo rm -rf /var/www/blog/*
sudo cp -r dist/. /var/www/blog/
sudo chown -R www-data:www-data /var/www/blog

set -e 不能省。以前少了这一行,build.py 抛异常脚本照样往下跑,结果 dist/ 里是上一版残留内容,cp 上去之后线上看着"更新成功了"其实根本没变。静态站点部署最怕这种静默失败——HTTP 200 给你看,内容却是旧的,能排查半小时。

如果你也在 Ubuntu 上被这个报错卡过,先跑 ls /usr/lib/python3.*/EXTERNALLY-MANAGED 确认一下是不是同一个原因,别急着降级 pip——降了也没用。