Ubuntu服务器Python环境管理:venv、pipx与uv对比
在Ubuntu 24.04上运行pip install报错externally-managed-environment?这是PEP 668限制。本文分析如何根据应用场景选择venv、pipx或uv,并提供将虚拟环境与systemd服务集成的正确配置方案,避免破坏系统级Python依赖。
为什么在全新的 Ubuntu 服务器上 pip install 会失败
在服务器上选择 Python venv、pipx 还是 uv,归根结底取决于一个问题:你要安装什么?应用程序的依赖项应存放在应用程序目录内的虚拟环境中。你希望通过名称直接调用的命令行工具应使用 pipx 安装。uv 可以同时完成这两项工作,并增加了锁文件(lockfile),当第二台机器需要构建相同的环境时,这一点至关重要。但它们都不会将包安装到系统 Python 中,因为当前的 Ubuntu 服务器直接禁止这样做。
在 Ubuntu 24.04 上运行 sudo pip install requests,pip 会在下载任何文件之前停止运行。
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.
If you wish to install a non-Debian-packaged Python package,
create a virtual environment using python3 -m venv path/to/venv.
Then use path/to/venv/bin/python and path/to/venv/bin/pip.
If you wish to install a non-Debian packaged Python application,
it may be easiest to use pipx install xyz, which will manage a
virtual environment for you.
note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this behaviour by passing --break-system-packages.这是 PEP 668(Python 增强提案 668,“外部管理环境”)在发挥作用。Debian 和 Ubuntu 会在 /usr/lib/python3.12/EXTERNALLY-MANAGED 处的解释器旁放置一个标记文件,pip 会拒绝向任何带有该标记的解释器中写入内容。
这条规则的存在是为了维护 sys.path 的顺序。apt 将库安装到 /usr/lib/python3/dist-packages 中。以 root 身份针对系统解释器运行的 pip 会写入 /usr/local/lib/python3.12/dist-packages,而 Debian 的打包机制将该目录置于搜索路径的前面。你可以通过 python3 -c 'import sys; print(sys.path)' 自行打印并查看该顺序。因此,对于机器上所有在 /usr/bin/python3 下运行的程序(包括发行版自带的工具),pip 写入的副本都会覆盖 apt 安装的副本。cloud-init 会从该解释器导入 requests、jinja2 和 PyYAML。如果使用 pip 升级其中任何一个包并导致版本不兼容,那么即使你从未触碰过相关程序,它也会在下次启动时报错,且回溯信息中会包含一个你甚至不知道存在于依赖链中的包。由于 apt 仍记录其自身版本为已安装状态,因此系统不会发出任何警告,修复过程将是 sudo apt reinstall python3-requests。
由此得出的规则很简单。系统 Python 属于发行版。不要向其中安装任何内容,不要使用 pip 升级其库,也不要为了消除错误提示而删除 EXTERNALLY-MANAGED 文件。你应该交给 /usr/bin/python3 的唯一任务就是构建虚拟环境。
venv、pipx 与 uv:决策准则
请根据安装对象进行选择,而非根据最近阅读到的工具进行选择。
- 作为服务部署并运行的应用程序(例如 Django 或 Flask 项目):在应用程序目录内使用一个虚拟环境 (venv)。
- 您希望在
PATH上使用的命令行工具(例如ansible或httpie):使用 pipx,它会为每个工具提供独立的运行环境,并在PATH上创建一个链接。 - 需要锁文件 (lockfile)、更快的安装速度或发行版未提供的 Python 版本的项目:使用 uv,它会生成一个普通的 venv 以及一个
uv.lock文件。 - 发行版工具而非您的代码所需要的库:使用
sudo apt install python3-<name>,这是向系统解释器添加任何内容的唯一受支持方式。
pipx 和 uv tool install 的功能相同,因此如果系统中已经安装了 uv,则无需再安装 pipx。您选择的 Web 框架对此没有影响:VPS 上的 Django 和 Flask 的区别在于 requirements.txt 中存放的内容,而非构建环境的方式。下文所有内容均基于 Ubuntu 24.04 及其 Python 3.12 版本,如果您的版本不同,请相应调整路径中的版本号。
构建各应用的虚拟环境 (venv)
Ubuntu 将 venv 模块从基础 Python 包中拆分了出来,因此在精简版镜像上首次尝试时会失败,并明确提示缺失的内容。
The virtual environment was not created successfully because ensurepip is not
available. On Debian/Ubuntu systems, you need to install the python3-venv
package using the following command.
apt install python3.12-venv安装该模块,然后以代码所有者的用户身份创建环境。
sudo apt update
sudo apt install -y python3-venv
sudo install -d -o deploy -g deploy -m 755 /srv/myapp
sudo -u deploy python3 -m venv /srv/myapp/.venv
sudo -u deploy /srv/myapp/.venv/bin/pip install -r /srv/myapp/requirements.txt注意这里没有包含的内容:没有 source,也没有 activate。/srv/myapp/.venv/bin/pip 会安装到该环境中,这是由二进制文件的位置决定的,而非通过 shell 导出的任何变量。在继续操作前请确认这一点。
/srv/myapp/.venv/bin/python -c 'import sys; print(sys.prefix)'该命令会输出 /srv/myapp/.venv。如果输出的是 /usr,说明你正在运行系统解释器,且包被安装到了非预期的位置。
虚拟环境 (venv) 的两个特性决定了后续的操作限制。虚拟环境不可移动,因为 bin/ 中的每个脚本都包含绝对路径的 shebang 行:head -1 /srv/myapp/.venv/bin/pip 会读取 #!/srv/myapp/.venv/bin/python。如果重命名父目录,这些脚本会因 bad interpreter: No such file or directory 而失败。虚拟环境还会锁定创建它的解释器,该解释器记录在 /srv/myapp/.venv/pyvenv.cfg 的 home 行中,且 bin/python3 是指向该二进制文件的符号链接。如果升级发行版导致 python3.12 被移除,符号链接将失去目标,服务会在启动时因 No such file or directory 而崩溃。这两种情况的解决方法相同:删除虚拟环境并根据 requirements.txt 重新构建。重新构建只需几秒钟。切勿在不同机器间复制虚拟环境。
虚拟环境的存放位置与所有权
将虚拟环境放置在 /srv/myapp/.venv 的代码目录旁,并确保每个应用拥有独立的虚拟环境。这样部署时只需处理一个目录,systemd 单元的路径也无需变动,且两个应用不会因共享依赖项的升级而相互干扰。切勿将虚拟环境放置在 Web 服务器直接发布文件的目录下,因为它包含依赖项且通常存有配置文件。
所有权配置至关重要。应由 deploy 用户拥有代码和环境,并仅授予服务账户读取和执行权限。
sudo adduser --system --group --no-create-home myapp
sudo chown -R deploy:myapp /srv/myapp
sudo chmod -R o-rwx /srv/myapp此时服务可以导入依赖项但无法对其进行重写。这意味着 Web 应用中的代码执行漏洞无法在磁盘上静默替换库文件,从而无法在重启后持续存在。关于在系统其余部分应用此逻辑的说明,请参阅 以最小权限用户运行服务。
用于命令行工具的 pipx
pipx 用于安装应用程序,而非库。每个工具都会在 ~/.local/share/pipx/venvs/<name> 下拥有独立的运行环境,且该工具的可执行文件会被链接至 ~/.local/bin,因此两个需要不同版本同一库的工具之间不会产生冲突。
sudo apt update
sudo apt install -y pipx
pipx ensurepath
pipx install httpiepipx ensurepath 通过编辑 shell 启动文件将 ~/.local/bin 添加到 PATH。它无法更改当前已打开的 shell,因此安装后立即执行 http: command not found 通常意味着您尚未注销并重新登录。Ubuntu 默认的 ~/.profile 仅在登录时目录已存在的情况下才会添加 ~/.local/bin,这就是为什么在新账户上首次安装时会遇到此问题,之后便不再出现。
若将 pipx 指向一个库,它会拒绝执行,并显示以以下内容开头的消息:
No apps associated with package requests or its dependencies.这是工具在提示您选错了工具。库应存放在应用程序的 venv 中。
在服务器上,关键细节在于安装位置。普通的 pipx install 会将所有内容置于单个用户的主目录下。以 myapp 身份运行的 systemd 单元无法访问它,root 用户的 cron 任务也无法访问它,且 sudo 也找不到它,因为 /etc/sudoers 中的 secure_path 将 PATH 替换为了固定的列表。对于整台机器都需要使用的工具,请进行全局安装。
sudo pipx install --global ansible
sudo pipx ensurepath --global--global 标志会将环境置于 /opt/pipx,并将可执行文件链接至 /usr/local/bin,该路径位于默认的 PATH 中且在 secure_path 内部。请先使用 pipx --version 检查版本,因为 Ubuntu 24.04 提供的 pipx 版本为 1.4.3,该版本早于 --global,而旧版 pipx 会返回 unrecognized arguments: --global。在该版本上,请手动设置这两个已记录的目录:
sudo env PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install ansible
command -v ansiblecommand -v ansible 应输出 /usr/local/bin/ansible。如果它输出的是 /home 下的路径,说明该工具被安装到了单个用户的账户中,任何服务都无法找到它。
需要锁文件时使用 uv
uv 是 Astral 开发的单个二进制文件,涵盖了 pip、venv 和 pip-tools 的功能,并能下载 Python 解释器。它的运行速度极快,在小型 VPS 上差异明显,且能生成真正的锁文件。
官方安装程序将 uv 和 uvx 放置在 ~/.local/bin 中:
curl -LsSf https://astral.sh/uv/install.sh | sh
uv --version在服务器上通过管道将脚本直接交给 shell 执行时,请务必谨慎。请锁定 URL 中的版本号,并在运行前阅读文件内容:
curl -LsSf https://astral.sh/uv/0.12.3/install.sh -o uv-install.sh
less uv-install.sh
sh uv-install.sh如果已安装 pipx,使用 pipx install uv 也可以。uv 是一个自包含的二进制文件,不依赖任何 Python 环境,因此将其复制到 /usr/local/bin 是让系统所有用户都能使用它的有效方法。
对于包含 pyproject.toml 的项目,工作流包含四个命令,只有最后一个命令需要在服务器上运行。
uv init myapp
uv add flask gunicorn
uv lock
uv sync --frozen --no-devuv lock 会生成 uv.lock,这是一个跨平台的锁文件,包含精确解析后的版本信息,请将其与代码一并提交。uv sync 会在项目根目录下构建 .venv 以匹配该锁文件。在服务器上,--frozen 是关键标志:文档将其定义为使用锁文件中的版本作为唯一事实来源,而不是检查锁文件是否为最新,这正是部署环境所需要的行为。--no-dev 会排除开发依赖组。
现有的 requirements.txt 项目无需转换,因为 uv 支持 pip 的标准:
uv venv /srv/myapp/.venv
uv pip install --python /srv/myapp/.venv/bin/python -r /srv/myapp/requirements.txt最终生成的是一个普通的虚拟环境。.venv/bin/python 的行为与 python3 -m venv 构建的环境完全一致,因此本指南后续内容无需更改。
在服务器上使用 uv 之前,需要了解其一个默认设置。其 python-preference 设置默认为 managed,文档说明该选项会优先选择“由 uv 下载和安装的解释器”,而不是系统现有的解释器。因此,在仅预装 3.12 的服务器上运行 uv venv --python 3.13 会静默地将 3.13 下载到 ~/.local/share/uv/python,而不是报错。这在笔记本电脑上很方便,但在服务器上却会带来意外,因为你的服务现在依赖于一个位于用户主目录下的解释器,而 apt upgrade 永远不会对其进行补丁更新。如果你希望使用发行版自带的解释器,请在 uv.toml 中将 python-preference 设置为 only-system。如果你希望将环境放置在项目根目录之外,UV_PROJECT_ENVIRONMENT 可指定项目虚拟环境使用的目录。
Point systemd at the venv interpreter, not at activate
This is where most Python deployments break, and the cause is a misunderstanding of what activate does.
bin/activate is a shell script. It prepends the venv's bin directory to PATH, sets VIRTUAL_ENV, saves the old values so deactivate can restore them, and changes your prompt. It contains nothing that the interpreter itself reads. Activation is a convenience for a human typing python at a prompt.
What actually selects the environment is which interpreter file you execute. When /srv/myapp/.venv/bin/python starts, Python's site module looks for a pyvenv.cfg file in the directory holding the executable and one level above it. Finding /srv/myapp/.venv/pyvenv.cfg sets sys.prefix to the venv, which puts that venv's site-packages on sys.path. That is the entire mechanism. It needs no environment variable and no shell.
So this unit never starts:
[Service]
ExecStart=source /srv/myapp/.venv/bin/activate && gunicorn app:appmyapp.service: Failed to locate executable source: No such file or directory
myapp.service: Failed at step EXEC spawning source: No such file or directory
myapp.service: Main process exited, code=exited, status=203/EXECExecStart is not a shell command line. systemd executes a program directly, so there is no source builtin, && is handed over as a literal argument, and nothing expands.
And this unit starts, then dies:
[Service]
ExecStart=/usr/bin/python3 /srv/myapp/app.pyModuleNotFoundError: No module named 'flask'/usr/bin/python3 is the system interpreter, and its sys.path has never contained your venv. The same command works in your SSH session only because you had activated the venv there, so the shell resolved python3 through PATH to .venv/bin/python3 instead.
Wrapping the command in /bin/bash -c 'source ... && gunicorn ...' does work. It also puts a shell between systemd and your process for no gain, when one absolute path settles it:
[Unit]
Description=myapp web service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp
Environment=PYTHONUNBUFFERED=1
Environment=PATH=/srv/myapp/.venv/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=/srv/myapp/.venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 app:app
Restart=on-failure
RestartSec=5
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp
journalctl -u myapp -n 50 --no-pagersystemctl status myapp should report active (running) with a Main PID that is your gunicorn process. Anything else, read the journal.
The Environment=PATH= line is not there for ExecStart, which already carries a full path. It is there for the processes your application starts. A service inherits a short default PATH from systemd, so Python code calling subprocess.run(["ffmpeg", ...]), or a management command that shells out to a console script from the venv, will not find what it needs. Putting the venv's bin directory first is the one part of activate that a service genuinely uses. Check what the unit really received with systemctl show -p Environment myapp.
The same rule covers scheduled work. cron runs jobs with a PATH of /usr/bin:/bin, so a crontab line reading python3 /srv/myapp/cleanup.py runs the system interpreter and fails with ModuleNotFoundError at three in the morning, and the error goes to a local mail spool nobody is reading. Write the absolute venv path there too. To get that output in the journal and a record of the last run instead, a systemd service and timer pair takes the same ExecStart line.
Docker 是否取代了这一决策?
容器拥有独立的文件系统,因此该问题只是形式发生了变化,并未消失。在如 python:3.12-slim 这样的官方镜像中,Python 已内置于 /usr/local 且不携带 EXTERNALLY-MANAGED 标记,因此以 root 身份执行 pip install 是添加软件包的预期方式,使用 venv 的意义不大。若改为构建 FROM ubuntu:24.04,则会在镜像内部再次遇到 externally-managed-environment,原因与宿主机相同:这是发行版自带的解释器,且携带了发行版的标记文件。
许多镜像仍在使用 venv,因为它简化了多阶段构建。构建阶段将内容安装到 /opt/venv,运行时阶段仅复制该目录,从而剔除了编译器。激活问题也随之而来。RUN source /opt/venv/bin/activate 行仅影响该构建层的 shell,因此在运行时,容器会从系统解释器启动并抛出 ModuleNotFoundError。请设置 ENV PATH="/opt/venv/bin:$PATH",或为 CMD 提供绝对路径 /opt/venv/bin/gunicorn。这与 systemd 中的错误相同,只是出现在不同的文件中。
因此,容器取代了关于解释器的疑问,因为镜像锁定了解释器及其下的所有内容。但它并未取代锁定(pinning)的问题。基于未锁定 requirements.txt 构建的镜像在下个月解析时会得到不同版本,这意味着镜像标签是可复现的,但生成该镜像的构建过程却并非如此。无论是否使用容器,像 uv.lock 这样的锁文件或完全锁定的需求文件才能弥补这一差距。当单个应用在 systemd 管理下的 VPS 上运行时,容器通常只是将这一决策转移到了 Dockerfile 中,因为 systemd 本身就能重启失败的进程并将输出捕获到日志中。在 VPS 上运行 Docker 的价值在于,当你希望将构建好的镜像本身作为部署对象时,它能发挥作用。
FAQ
我可以直接使用带有 --break-system-packages 的 pip install 吗?
在需要持续运行的服务器上,请勿这样做。该标志的作用正如其名:它移除了保护机制,使 pip 将文件写入 /usr/local/lib/python3.12/dist-packages,该目录在 sys.path 中的优先级高于 apt 目录。此时,你的版本会覆盖所有在 /usr/bin/python3 下运行的系统脚本,而 apt 仍认为其安装的是系统版本,因此在出现故障前,没有任何机制能检测到这种冲突。在每次从零构建的容器镜像中,损害仅限于该镜像,因此在那种场景下是可行的。但在你维护的机器上,请创建 venv。这只需要一条命令。
虚拟环境在服务器上应该存放在哪里?
应存放在应用程序自身的目录内,命名为 /srv/myapp/.venv,由部署用户拥有,服务账号仅拥有读取和执行权限。每个应用程序应保持一个独立的 venv,因为共享 venv 会导致升级第一个应用时破坏第二个应用。创建后不要移动或复制 venv:其 bin/ 目录下的每个脚本都在 shebang 行中写入了绝对路径,因此移动后的 venv 会因 bad interpreter: No such file or directory 而失败。请删除它并从 requirements.txt 重新构建。
为什么我的 systemd 服务会报 ModuleNotFoundError?
因为该单元执行的解释器不是 venv 中的那个。运行 systemctl cat myapp 并阅读 ExecStart。它必须通过绝对路径指向 /srv/myapp/.venv/bin/python,或者指向来自同一个 bin/ 目录的控制台脚本。在单元文件中 source activate 是无效的,因为 ExecStart 不是 shell,且 systemd 会报告 Failed to locate executable source 并伴随 status=203/EXEC。请添加 Environment=PATH=/srv/myapp/.venv/bin:/usr/local/bin:/usr/bin:/bin,这样你的代码启动的任何子进程也能找到 venv 的工具。
我应该用 uv 代替 venv 和 pip 吗?
当你需要锁文件(lockfile)、安装速度慢到影响效率,或者需要发行版未提供的 Python 版本时,请使用 uv。它创建的是普通的 venv,因此 systemd 单元和文件布局不会改变,且 uv sync --frozen 会精确安装锁文件中记录的内容。如果单个应用程序通过 git 部署并带有固定的 requirements.txt,且安装在几秒内完成,那么 python3 -m venv 已经足够,且服务器上少了一个需要维护的二进制文件。