前言
把 Python 项目部署到 Linux 服务器上,最容易混淆的是两个词:「打包」和「部署」。打包指的是把项目变成一个可分发、可安装的产物(源码包或 wheel);部署指的是在目标机器上把它跑起来并维持运行。前者由pyproject.toml和构建后端负责,后者由虚拟环境、进程管理器和反向代理负责。这两件事用的工具完全不同,混在一起讲就会变成一锅粥。
第二个要澄清的是setup.py和pyproject.toml的关系。它们不是同一代东西的两种写法:setup.py是「执行一段代码来告诉 setuptools 怎么构建」的老方式,pyproject.toml是 PEP 518 引入的静态配置文件,PEP 517 又在它里面加了build-backend键来指定构建后端。今天pyproject.toml是推荐入口,setup.py只在没有pyproject.toml、或者pyproject.toml里没写build-backend时,才作为「旧行为」被工具回退执行。
第三个必须点明的版本事实:distutils已在 Python 3.12 中从标准库移除(PEP 632),由第三方包 setuptools 继续提供;而且从 Python 3.12 起,venv创建虚拟环境时不再预装 setuptools,需要用的时候得自己pip install setuptools。Python 2.7 已于 2020 年 1 月 1 日停止维护,本文不使用任何 Python 2 写法。
一、pyproject.toml:PEP 518 与 PEP 517 各管什么
两个 PEP 分工明确:
| PEP | 做了什么 | 在文件里的体现 |
|---|
| PEP 518 | 引入pyproject.toml这个配置文件,规定[build-system]表 | requires键(该表唯一的必填键) |
| PEP 517 | 定义构建后端接口,给[build-system]加一个键 | build-backend键 |
PEP 518 说得很直白:[build-system]表最初只有requires一个键是有效的,而且是必填的,值是一串 PEP 508 依赖,表示执行构建系统需要哪些包。绝大多数依赖 setuptools 的项目,文件就这么几行:
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"PEP 517 补充的规则是:如果pyproject.toml不存在,或者文件里没有build-backend键,那么这个源码树就不算使用本规范,工具应当回退到「直接运行setup.py」的旧行为(或者隐式调用setuptools.build_meta:__legacy__后端)。当build-backend存在时,它优先,源码树遵循该后端规定的格式和约定。
一个完整的项目元数据示例:
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "myapp"
version = "1.0.0"
description = "示例项目"
requires-python = ">=3.9"
dependencies = [
"requests>=2.31",
]
[project.scripts]
myapp = "myapp.cli:main"[project.scripts]里的条目会在安装后生成同名的命令行入口,实现方式是包装一个可调用对象。
二、构建产物:sdist 与 wheel
构建由「前端」发起,常见的是 PyPA 的build工具:
# 装好构建前端(在虚拟环境里执行)
python -m pip install --upgrade build
# 在项目根目录构建,产物落在 dist/
python -m build产出两类文件:
| 类型 | 文件名形态 | 说明 |
|---|
| sdist | myapp-1.0.0.tar.gz | 源码分发包,安装时需要现场构建 |
| wheel | myapp-1.0.0-py3-none-any.whl | 预构建的二进制分发包,安装快,不需要编译器 |
wheel 的文件名里,py3是 Python 标签,none是 ABI 标签,any是平台标签。py3-none-any表示「纯 Python、与平台无关」——绝大多数纯 Python 项目都是这个。如果项目有 C 扩展,平台标签会变成manylinux之类,这种 wheel 不能跨发行版随便拷。
setup.py里那套老命令(python setup.py sdist、python setup.py bdist_wheel、python setup.py install)今天不该再用了,官方路径是python -m build构建、pip install安装。
三、在服务器上部署:虚拟环境 + 安装
Linux 上的常见步骤:
# 1) 上传代码或构建产物,解压到 /opt/myapp
sudo mkdir -p /opt/myapp
sudo chown "$USER" /opt/myapp
# 2) 创建虚拟环境(venv 自 Python 3.5 起是官方推荐做法)
cd /opt/myapp
python3 -m venv .venv
# 3) 激活并升级基础工具
source .venv/bin/activate
python -m pip install --upgrade pip
# 4) 安装项目及其依赖
# 方式 A:直接装当前目录(会自动走 PEP 517 构建)
python -m pip install .
# 方式 B:先构建再装 wheel
python -m pip install build
python -m build
python -m pip install dist/myapp-1.0.0-py3-none-any.whl两个细节:
- 不要用
sudo pip install。系统 Python 的包由发行版包管理器管,用 pip 直接装会和系统包打架,很多新版发行版也会直接拒绝。一切都装在虚拟环境里。 - 脚本里最好用
.venv/bin/python -m pip这种绝对路径调用,而不是依赖source activate——activate只在交互式 shell 里方便,写进 systemd 或 cron 里容易失效。
如果只需要依赖、不需要把项目本身装成包,也可以退一步:
/opt/myapp/.venv/bin/python -m pip install -r requirements.txt四、让它常驻:systemd
前台能跑起来只是第一步,服务器上还需要进程守护、开机自启、崩溃重拉。systemd 是现在 Linux 上的标准做法。在/etc/systemd/system/myapp.service写一个单元文件:
[Unit]
Description=My Python App
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
Environment="PYTHONUNBUFFERED=1"
EnvironmentFile=-/opt/myapp/.env
ExecStart=/opt/myapp/.venv/bin/python -m myapp
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target维护命令:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
journalctl -u myapp -f要点说明:
ExecStart必须写绝对路径,systemd 不做PATH查找,也不读取 shell 的activate结果。Type=simple表示主进程就是那个前台进程;如果程序会自己 fork 到后台,得用Type=forking。EnvironmentFile前面的减号表示文件不存在也不报错,适合放密钥。- 如果要跑多个 worker 的 Web 服务,通常用 gunicorn 之类的 WSGI 服务器作为
ExecStart,再在前面挂 Nginx 做反向代理和 TLS 终止。
五、打包方式的选择
| 场景 | 推荐做法 |
|---|
| 内网单机、代码就是全部 | 上传源码 +venv+pip install -r requirements.txt |
| 需要可分发、可版本化的产物 | python -m build产出 wheel/sdist,分发 wheel |
| 需要命令行入口 | pyproject.toml里配[project.scripts] |
| 依赖里有需要编译的包 | 在有编译环境的机器上构建 wheel,再分发 |
| 目标机不能联网 | 在联网机器pip download -d wheels/,拷过去pip install --no-index --find-links wheels/ |
| 需要完全一致的环境 | 容器镜像(另题)或锁文件 + 固定版本号 |
「锁文件」这个概念值得单独提一句:requirements.txt里写requests和写requests==2.31.0是两个不同级别的可复现性。生产环境应当固定到确切版本,并且最好用pip freeze从已验证过的环境里导出。
实战:从零到常驻的完整流程
# ---- 构建机(有网络、有编译环境)----
cd /path/to/project
python3 -m venv .venv-build
source .venv-build/bin/activate
python -m pip install --upgrade pip build
python -m build
ls dist/
# dist/myapp-1.0.0-py3-none-any.whl
# dist/myapp-1.0.0.tar.gz
# 顺带把依赖也下载下来,便于离线部署
python -m pip download -d wheels/ -r requirements.txt
# ---- 目标服务器 ----
sudo useradd --system --create-home --shell /usr/sbin/nologin myapp
sudo mkdir -p /opt/myapp
sudo chown -R myapp:myapp /opt/myapp
# 上传 dist/ 与 wheels/ 到 /opt/myapp 后:
sudo -u myapp python3 -m venv /opt/myapp/.venv
sudo -u myapp /opt/myapp/.venv/bin/python -m pip install --upgrade pip
sudo -u myapp /opt/myapp/.venv/bin/python -m pip install \
/opt/myapp/dist/myapp-1.0.0-py3-none-any.whl
# 写 systemd 单元并启动(内容见上一节)
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp如果python3 -m venv报错说缺少ensurepip,说明系统没装 venv 组件,在 Debian/Ubuntu 上装python3-venv即可;RHEL 系对应的是python3完整包。
常见坑点
1. 用了 Python 2 或 3.12 之前的distutils导入
❌ 错误写法:
from distutils.core import setup
setup(name="myapp", version="1.0.0")distutils已在 Python 3.12 中从标准库移除(PEP 632),这行直接ModuleNotFoundError。
✅ 正确写法:改用 setuptools +pyproject.toml(setuptools 至今仍提供distutils的兼容实现,但不应再依赖它):
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"2. 在虚拟环境里以为 setuptools 一定在
❌ 错误想法:python -m venv .venv之后就能直接用setuptools。
✅ 正确认识:从 Python 3.12 起,venv不再预装 setuptools,distutils、setuptools、pkg_resources、easy_install默认都不可用;需要时先装:
source .venv/bin/activate
python -m pip install setuptools3. 用sudo pip install装到系统环境
❌ 错误写法:
sudo pip install -r requirements.txt会和发行版包管理器抢文件,升级系统时冲突,很多新发行版也直接禁止。
✅ 正确写法:
python3 -m venv /opt/myapp/.venv
/opt/myapp/.venv/bin/python -m pip install -r requirements.txt4. 把pyproject.toml和setup.py当成一回事
❌ 错误想法:以为写了pyproject.toml就必须删掉setup.py,或者以为两者可以随便混着写。
✅ 正确认识:pyproject.toml的[build-system]由 PEP 518 定义、build-backend由 PEP 517 定义;项目元数据放[project]表。若文件里没有build-backend,工具会回退到运行setup.py的旧行为。迁移时应当把元数据搬到[project],而不是在两边各写一份。
5. 忘记requires是[build-system]表的必填键
❌ 错误写法:
[build-system]
build-backend = "setuptools.build_meta"✅ 正确写法:
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"6. systemd 的ExecStart用相对命令或依赖activate
❌ 错误写法:
ExecStart=python -m myappsystemd 不做PATH查找,也不读 shell 的激活状态,服务会启动失败。
✅ 正确写法:
ExecStart=/opt/myapp/.venv/bin/python -m myapp7. 依赖没有固定版本,两次部署结果不一致
❌ 错误写法:
# requirements.txt
requests
flask✅ 正确写法:固定到确切版本,并从已验证环境导出:
python -m pip freeze > requirements.txt8. 构建产物里混进了不该带的文件
❌ 错误做法:python -m build之后发现dist/里的包带着.venv、测试数据、.env。根源通常是没配排除规则。
✅ 正确做法:在pyproject.toml里声明打包时包含哪些包,并用MANIFEST.in控制 sdist 的文件范围;同时别把虚拟环境建在项目目录里。
总结
| 概念 | 归属 | 关键点 |
|---|
setup.py | 旧方式 | 执行代码来声明构建方式;今天只在回退路径上使用 |
pyproject.toml | PEP 518 / 517 | [build-system]的requires必填;build-backend缺失则回退旧行为 |
| sdist | 构建产物 | 源码包,安装时现场构建 |
| wheel | 构建产物 | 预构建包,纯 Python 项目一般是py3-none-any |
| 构建命令 | 构建前端 | python -m build,产出到dist/ |
| 安装 | 部署 | pip install .或pip install 某.whl,装在虚拟环境里 |
| 虚拟环境 | 部署 | python3 -m venv .venv;3.12 起不再预装 setuptools |
| 常驻运行 | 部署 | systemd 单元,ExecStart用绝对路径 |
| 移除项 | 版本事实 | distutils在 Python 3.12 已移出标准库(PEP 632) |
部署一条龙可以概括为:用pyproject.toml描述项目、用python -m build产出 wheel、在服务器上用venv建隔离环境、用pip install安装、再用 systemd 让它常驻。只要把「打包」和「部署」分开看,并且记住pyproject.toml属于 PEP 518 / 517 这一代而setup.py是回退路径,这套流程就不会乱。