本地调试一切正常的 Python 项目,往往在搬上 CentOS 服务器的那一刻开始现原形:ModuleNotFoundError、pip 编译卡死、中文日志变问号、后台进程关掉终端就消失。这套流程我在不同规模的机器上重复过很多遍,从单核 1G 内存的小型主机到 8 核 16G 的生产环境都趟过,踩的坑基本集中在几个固定位置。这篇内容围绕 Linux(Centos) 部署 Python 项目这件事,把从系统自带的 Python 2.7 历史包袱、编译安装解释器、虚拟环境隔离、依赖安装、Gunicorn 与 Nginx 组合,到 systemd 托管进程和上线后排错这一整条链路讲清楚。不管你是第一次把自己写的 Flask 小工具放上服务器,还是接手了一套老项目需要重新梳理部署方式,下面的步骤和判断依据都能直接拿去用。
1. 为什么 CentOS 上部署 Python 项目总卡在环境这一步
1.1 系统自带的 Python 2.7 是个绕不开的历史包袱
CentOS 7 默认带的是 Python 2.7,而且这个解释器不是给用户用的,它被 yum 深度依赖。yum 本身就是一个 Python 程序,它 import 的是系统路径下的那几个库。我见过太多人上来就执行rm -rf /usr/bin/python然后软链到 Python 3,结果 yum 直接崩掉,连修复用的工具都装不了。这个操作在 CentOS 7 上是绝对禁区,记住一条:系统自带的 Python 2.7 你可以不用,但不要删、不要覆盖、不要动它的软链接。
正确的做法是让 Python 3 和系统的 Python 2.7 并存。你要做的所有事情——装依赖、跑虚拟环境、起服务——都用绝对路径或者环境变量指向你自己装的那份 Python 3,跟系统的那份完全隔离。这样做的好处是,yum、firewalld 这些系统组件的脚本不会因为你升级了 Python 而报语法错误。
判断机器上有没有可用的 Python 3,先跑这两条:
python3 -V which python3如果第二条输出的是/usr/bin/python3,说明这个 Python 3 是系统包管理装的(CentOS 7 上一般来自 EPEL 的 python36 包)。它能用,但版本通常偏旧,而且python3 -m venv有时缺 ensurepip 模块。我更倾向自己编译一份放到/usr/local,这样版本可控,编译参数可控,出问题也好定位。
1.2 编译安装还是包管理安装:一次说清取舍
这是新手最容易纠结的地方。两种方式我都用过,结论是看场景,不是看哪个"更高级"。
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| yum/EPEL 安装 | 快、省事、依赖自动解决 | 版本旧,路径固定,缺编译模块 | 内网测试、临时验证 |
| 源码编译安装 | 版本自由,编译选项可控,路径干净 | 耗时长(小机器 10 到 30 分钟),要手动补依赖 | 生产环境、需要指定版本 |
| pyenv 管理 | 多版本切换方便 | 需要额外装,团队协作时要统一 | 一台机器跑多个项目 |
我的默认选择是源码编译,装在/usr/local/python3,然后把/usr/local/python3/bin加到 PATH 里。这么做最直接的好处是,将来要升级或换版本,直接改软链指向新目录就行,旧版本的库和 site-packages 都还在,回滚成本极低。用 yum 装的 Python 3 想换版本,就得处理一堆 RPM 依赖,比较麻烦。
机器规格也很关键。1 核 1G 的机器编译 Python 3.11 大概要二十多分钟,中间如果内存爆了会直接被 OOM Killer 干掉,这时候要么临时加 swap,要么换交叉编译好的包。8 核机器一般两三分钟就跑完,体验完全不一样。所以正式编译之前先free -h看一眼内存,心里有个数。
2. 把 Python 运行环境搭稳的完整动作
2.1 装 Python 3 之前必须先补的编译依赖
源码编译最容易翻车的地方不是 configure,而是编译到一半报一堆fatal error: xxx.h: No such file or directory。这些 .h 文件来自开发库,缺了就会有一堆模块编译不出来,典型后果是装完之后import ssl失败、import lzma失败,然后 pip 因为连不上 HTTPS 源而报 SSL 错误——很多人以为是网络问题,其实根子在编译时缺了 openssl-devel。
所以第一步永远是补齐开发包,这条命令我基本是闭着眼睛敲:
yum install -y gcc gcc-c++ make \ zlib-devel bzip2-devel openssl-devel ncurses-devel \ sqlite-devel readline-devel tk-devel gdbm-devel \ libffi-devel xz-devel wget这个列表里的每一项都对应 Python 的一个内置模块:zlib-devel对应import zlib,openssl-devel对应ssl和hashlib里的加密算法,libffi-devel对应ctypes(很多第三方库用它调用 C 代码,比如 cffi、cryptography),xz-devel对应lzma,sqlite-devel对应sqlite3(Python 自带的 sqlite 模块没它就是空的)。缺哪个,对应的模块就是"没编译进来",运行时才暴露,这时候你已经装完 Python 了,改起来很烦。
有个细节值得提醒:openssl-devel 的版本会影响 Python 能不能用较新的 TLS 协议。CentOS 7 上的 openssl 是 1.0.2 系列,Python 3.10 以后的版本对 openssl 版本有要求,编译时可能提示找不到符合要求的 OpenSSL。这种情况要么降低 Python 版本到 3.9,要么自己再编译一份新版 OpenSSL 并指定--with-openssl=。对小项目来说,选 Python 3.9 或者 3.8 是最省事的路线。
2.2 编译安装 Python 3 的命令链条
依赖补齐之后,下面是完整流程。我以 Python 3.9.18 为例,装在/usr/local/python3:
cd /usr/local/src wget https://www.python.org/ftp/python/3.9.18/Python-3.9.18.tgz tar -xzf Python-3.9.18.tgz cd Python-3.9.18 ./configure \ --prefix=/usr/local/python3 \ --enable-optimizations \ --with-ensurepip=install make -j$(nproc) make altinstall几个参数值得解释一下,因为它们直接影响后面用起来顺不顺。
--prefix决定安装目录,装在/usr/local/python3而不是直接装进/usr/local,是为了将来多版本共存时互不干扰,卸载时直接删目录就行。
--enable-optimizations会做 PGO(Profile Guided Optimization),让解释器跑得快一些,代价是编译时间明显变长,通常翻倍。小机器上如果你等不起可以去掉,性能差距在大多数 Web 项目里感觉不到。
--with-ensurepip=install保证装完之后 pip 是现成的,省得再单独装一遍 pip。
make用-j$(nproc)并行编译,这是最有效的提速方式。make altinstall而不是make install,这一点非常重要:altinstall不会创建python3这个软链,避免覆盖系统已存在的同名命令,是官方推荐的多版本安装方式。
装完之后建软链并验证:
ln -s /usr/local/python3/bin/python3.9 /usr/local/bin/python3 ln -s /usr/local/python3/bin/pip3.9 /usr/local/bin/pip3 python3 -V pip3 -V然后用一条命令验证关键模块都在,这一步能提前拦住 90% 的后续问题:
python3 -c "import ssl, zlib, sqlite3, ctypes, lzma, readline; print(ssl.OPENSSL_VERSION)"如果这条命令没报错并且打印出了 OpenSSL 版本,说明编译质量过关。如果哪个模块报 ModuleNotFoundError,回去补对应的 devel 包再重新编译,别想着绕过,后面一定会在某个依赖上炸掉。
2.3 venv 虚拟环境与 pip 源的配置细节
解释器装好了,但项目不应该直接装在全局环境里。全局装依赖的后果是:两个项目要同一个库的不同版本,直接冲突,而且以后想清理根本没头绪。虚拟环境就是给每个项目一个独立的site-packages。
mkdir -p /data/www/myproject cd /data/www/myproject python3 -m venv venv source venv/bin/activate激活之后命令行前面会出现(venv)提示符。这时候which python应该指向/data/www/myproject/venv/bin/python,如果不是,说明激活没生效,检查是不是在正确的目录下执行。
关于 pip 源,国内环境直接连官方源经常超时或者慢得离谱。设置全局源的方式是写配置文件,比每次加-i参数省事:
mkdir -p ~/.pip cat > ~/.pip/pip.conf <<'EOF' [global] index-url = https://pypi.tuna.tsinghua.edu.cn/simple trusted-host = pypi.tuna.tsinghua.edu.cn timeout = 120 EOFtrusted-host这一行的作用是跳过 HTTPS 证书校验,某些老版本的 pip 加上某些源不写这个会报证书错误。这不是最优安全实践,但在内网或者证书链不全的环境里确实省事,你自己权衡。
还有一个容易忽略的点:如果机器跑的是 Python 3.9,很多时候需要顺便升级一下 venv 里的 pip,因为 Python 自带的老 pip 在处理某些包的元数据时会报错:
python -m pip install --upgrade pip升级 pip 之后再装项目依赖,能避开不少莫名其妙的解析错误。
3. 代码上服务器、依赖安装与目录规划
3.1 上传代码与解压乱码的处理
代码上传最常见的方式是 scp 或者 rsync。scp 简单直接:
scp -r ./myproject root@192.168.1.100:/data/www/但如果是持续更新的项目,rsync 更合适,它能增量同步,只传改动过的文件,还能排除掉venv、__pycache__、.git这些不该传过去的目录:
rsync -avz --exclude 'venv' --exclude '__pycache__' --exclude '.git' \ ./myproject/ root@192.168.1.100:/data/www/myproject/注意源路径后面的斜杠,./myproject/和./myproject在 rsync 里语义不同,前者是"目录里的内容",后者是"目录本身"。加错了会导致目标目录结构多一层嵌套,这是很常见的低级错误。
如果你拿到的代码是 zip 压缩包,解压时中文文件名变成乱码,原因通常是压缩包在 Windows 下用 GBK 编码存储文件名,而 Linux 默认按 UTF-8 解释。处理方式是先用unzip -l看一眼文件名,如果是乱码就指定编码:
unzip -O gbk package.zip如果 unzip 版本太老不支持-O参数,可以用 Python 处理:
python3 -c " import zipfile z = zipfile.ZipFile('package.zip') for i in z.infolist(): i.filename = i.filename.encode('cp437').decode('gbk') z.extract(i, '.') "tar.gz 包一般没这个问题,因为 tar 不做编码转换,原样存原样取。
上传完之后记得改属主。如果服务最终以非 root 用户运行,目录权限要提前规划好,否则后面 systemd 起服务时会报 Permission denied:
chown -R www:www /data/www/myproject chmod -R 755 /data/www/myproject3.2 requirements.txt 依赖安装时的编译陷阱
依赖安装这一步,最耗时的不是下载,是编译。像psycopg2、mysqlclient、lxml、pillow、cryptography这些包,如果官方没有提供对应平台和 Python 版本的预编译 wheel,pip 就会拉源码在本地编译,这时候要装一堆系统库,编译失败的报错信息还特别长,容易看晕。
我的经验是先跑一次,看它到底在编译什么:
pip install -r requirements.txt如果报错里出现pg_config not found,那是 psycopg2 需要 PostgreSQL 的开发包;报mysql_config not found,是 mysqlclient 需要 MySQL 的开发包;报fatal error: libxml/xmlversion.h,是 lxml 需要 libxml2 的开发包。对应的补齐命令:
yum install -y postgresql-devel mysql-devel libxml2-devel libxslt-devel或者用纯 Python 的替代包来规避编译:psycopg2-binary替代psycopg2,mysqlclient换成pymysql。代价是性能略低,换来的是部署省心,中小项目完全够用。这是一个典型的工程取舍,不是技术先进性问题。
另一个大坑是依赖版本不锁。requirements.txt里如果只写flask,今天装是 3.0,半年后重装可能就变成 3.1 了,中间任何不兼容改动都会让你怀疑人生。规范做法是锁死版本,用pip freeze导出:
pip freeze > requirements.txt如果想连子依赖和哈希都锁住,可以用 pip-tools 从requirements.in生成带哈希的requirements.txt,这个在团队协作里更稳。小项目用 freeze 就够了。
3.3 目录结构与权限的规划思路
一个经得起折腾的目录结构大概长这样:
/data/www/myproject/ ├── venv/ 虚拟环境 ├── app/ 业务代码 ├── static/ 静态文件 ├── logs/ 日志目录 ├── .env 环境变量 ├── wsgi.py WSGI 入口 └── requirements.txt把logs独立出来是有原因的:日志文件会不断增长,独立目录方便做按目录的清理和监控,也方便挂载单独的数据盘。生产环境千万别把日志写在项目根目录,时间一长目录乱得没法看。
权限上有个原则值得说清楚:能用普通用户跑就别用 root 跑。Web 服务以 root 运行时,一旦应用有文件上传或命令执行漏洞,攻击者直接拿到系统最高权限,风险完全不成比例。建一个专用用户:
useradd -r -s /sbin/nologin www-r表示系统用户,-s /sbin/nologin表示不给他登录 shell。然后把项目目录属主改成这个用户。8000 以上的端口普通用户就能监听,不存在"非 root 不能开端口"的问题。
4. Gunicorn 加 Nginx 让项目真正对外服务
4.1 WSGI 服务器的选型与启动参数
Python 自带的开发服务器性能差、不稳定,官方文档明确说了不能用于生产。生产环境需要一个 WSGI 服务器把 Python 应用和 HTTP 请求对接起来,主流是 Gunicorn 和 uWSGI 两家。
Gunicorn 的优势是配置简单、文档清晰、跟 Flask/Django 集成顺滑;uWSGI 的性能上限略高、功能更多,但配置项多到让人头大。绝大多数项目和团队,选 Gunicorn 就够了,不用纠结。
装进虚拟环境:
source venv/bin/activate pip install gunicorn启动方式:
gunicorn -w 4 -b 127.0.0.1:8000 wsgi:app这里的参数每一个都影响实际表现:
-b 127.0.0.1:8000只监听本地回环地址,意味着外部不能直接访问 8000 端口,所有请求必须经过 Nginx 转发。这是一种安全设计,避免绕过 Nginx 直接打应用。
-w 4是 worker 进程数。行业里流传的经验公式是CPU 核数 * 2 + 1,因为 Python 有 GIL,单进程同一时刻只有一个线程在跑 Python 字节码,多进程才能吃满多核。但如果是 I/O 密集型(大部分 Web 应用都算),worker 数是够用就行,开太多反而增加内存压力和上下文切换开销。1 核机器开 2 到 3 个,4 核开 8 个左右,然后压测看实际 QPS 调整。
除了进程数,还有几个参数建议加上:
gunicorn -w 4 -b 127.0.0.1:8000 \ --timeout 120 \ --access-logfile /data/www/myproject/logs/access.log \ --error-logfile /data/www/myproject/logs/error.log \ --daemon \ wsgi:app--timeout 120表示单个请求超过 120 秒就杀掉 worker 重启,默认是 30 秒。如果你的接口里有耗时比较长的操作,比如导出大报表、调用外部接口,30 秒会被误杀,这个值要根据业务调。
如果你的应用是异步框架(FastAPI 用 uvicorn,Tornado),那就不该用 Gunicorn 的 sync worker,得用对应的 worker 类型,或者直接用 uvicorn 配 systemd。选错 worker 类型的后果是异步优势全没了,性能还不如同步框架。
4.2 Nginx 反向代理配置该注意什么
Nginx 在这套架构里干三件事:接收 80/443 端口的外部请求、把动态请求转给 Gunicorn、直接返回静态文件。静态文件交给 Nginx 是因为它处理静态资源的效率比 Python 应用高一个数量级,不需要绕一圈 Python 解释器。
安装并配置:
yum install -y nginx systemctl enable nginx站点配置写在/etc/nginx/conf.d/myproject.conf:
server { listen 80; server_name your-domain.com; access_log /var/log/nginx/myproject_access.log; error_log /var/log/nginx/myproject_error.log; client_max_body_size 20m; location /static/ { alias /data/www/myproject/static/; expires 7d; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 120s; } }几个地方容易出问题,一个个说。
client_max_body_size默认只有 1M,超过这个大小的上传请求会被 Nginx 直接挡掉并返回 413,压根到不了应用。文件上传功能一上线就报错的,八成是这个。设成 20m 或按业务需要调整。
proxy_set_header X-Real-IP和X-Forwarded-For是给应用用的。不加这两行,应用里拿到的客户端 IP 永远是127.0.0.1,因为请求是 Nginx 转发的。做访问统计、风控、日志分析的项目必须加上,并且在框架里正确读取。
proxy_read_timeout要跟 Gunicorn 的--timeout保持协调。如果一个 120 秒,另一个 30 秒,会出现"应用还在处理,Nginx 已经返回 504"的情况。两个值要么相等,要么 Nginx 稍微大一点。
location /static/用alias而不是root,这两个指令的路径拼接规则不同。用root时,实际路径会变成root值加上 location 前缀,很容易多一层目录导致 404。用alias才是"这个 URL 前缀直接映射到这个目录"。
配完先测语法再重载,别直接 restart:
nginx -t systemctl reload nginxnginx -t能在不中断服务的情况下检查配置语法错误,这个习惯能省掉不少线上事故。
4.3 防火墙与 SELinux 的隐形拦截
服务起在本地,curl 127.0.0.1:8000 有响应,但外网访问不了,这类问题十有八九是防火墙或者 SELinux 在拦。
CentOS 7 默认用 firewalld。放行 80 和 443:
firewall-cmd --permanent --add-service=http firewall-cmd --permanent --add-service=https firewall-cmd --reload firewall-cmd --list-all注意--permanent和--reload的配合。只加--permanent不 reload,规则不会生效;只加不加--permanent,重启后规则就没了。两个一起加才是正确姿势。
至于 8000 端口,前面说了它只监听 127.0.0.1,不需要放行,也不应该放行。如果确实要临时直连调试,记得调完就关掉。
SELinux 是个更隐蔽的坑。它是 CentOS 默认开启的强制访问控制机制,会限制 Nginx 能不能访问/data/www/下的文件,也会限制进程能不能对外发起网络连接。表现就是权限都对了、防火墙也开了,但就是 403。
先看状态:
getenforce如果输出Enforcing,就有可能是它在拦。查日志:
tail -f /var/log/audit/audit.log | grep denied如果确认是 SELinux 拦的,有两条路。一是调整策略,让 Nginx 能访问自定义目录:
setsebool -P httpd_can_network_connect 1 semanage fcontext -a -t httpd_sys_content_t "/data/www/myproject/static(/.*)?" restorecon -Rv /data/www/myproject/statichttpd_can_network_connect这个布尔值控制的是 Nginx 能否发起对外连接,也就是能不能 proxy_pass 到本地 8000 端口。不开它,Nginx 会报Permission denied while connecting to upstream,日志里看着像网络问题,实际是 SELinux。
二是直接关掉 SELinux。我不推荐,但确实有人在测试环境这么干,改/etc/selinux/config里的SELINUX=disabled然后重启。生产环境关掉它等于放弃了一层重要防护,能调策略就调策略。
5. 用 systemd 托管进程与日志
5.1 为什么 nohup 和 screen 都不该用于生产
很多人部署完的启动方式是这样:
nohup python app.py > app.log 2>&1 &这条命令能用,但问题不少。服务器重启后进程不会自动拉起;进程意外崩溃后没人管,服务就这么挂着;日志和进程的对应关系混乱;查看状态只能靠ps和grep,多实例时更容易搞混。
screen 和 tmux 能保持会话,解决了"关掉终端进程就死"的问题,但它们本质上是给人用的交互工具,不是进程管理方案。服务器重启照样丢,崩溃照样不拉起。
systemd 是 CentOS 7 的官方进程管理方案,它解决的正是这些问题:开机自启、崩溃自动重启、统一日志收集、依赖关系管理、状态查询。既然系统自带,没有理由不用。
5.2 Unit 文件里每个字段到底在管什么
新建/etc/systemd/system/myproject.service:
[Unit] Description=MyProject Gunicorn Service After=network.target [Service] Type=simple User=www Group=www WorkingDirectory=/data/www/myproject Environment="PATH=/data/www/myproject/venv/bin" EnvironmentFile=-/data/www/myproject/.env ExecStart=/data/www/myproject/venv/bin/gunicorn \ -w 4 -b 127.0.0.1:8000 \ --access-logfile /data/www/myproject/logs/access.log \ --error-logfile /data/www/myproject/logs/error.log \ wsgi:app Restart=always RestartSec=5 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target逐项拆解,因为这些字段写错了服务就是起不来。
After=network.target保证网络就绪后再启动服务,否则可能因为网络还没起来导致绑定失败。
User和Group指定运行身份,这里用前面建的 www 用户。这里有个必须注意的细节:WorkingDirectory、日志目录、venv 目录,这些路径 www 用户都得有读写权限。systemd 不会帮你处理权限,权限不对就是Permission denied,而且报错信息比较绕。
Environment="PATH=..."把虚拟环境的 bin 目录加到 PATH 最前面,这样 ExecStart 里即使写简写命令也能找到正确的解释器。加上这行能避掉一类"找不到模块"的怪问题。
EnvironmentFile=-/path/.env用来加载环境变量文件,比如数据库密码、密钥这些不该硬编码在代码里的配置。前面那个-号表示文件不存在也不报错,这个细节在 CI/CD 环境里很实用,因为不同环境可能没有这个文件。
ExecStart里用虚拟环境里的 gunicorn 绝对路径,不用source activate那种写法。systemd 不执行 shell 的激活脚本,写source一定失败。
Restart=always表示无论什么原因退出都重启,配合RestartSec=5表示等 5 秒再拉,避免疯狂重启打满 CPU。如果应用启动就崩,always会导致不停重启,systemctl status里能看到重启次数,这个数字是你判断问题严重性的第一手信息。
配好之后:
systemctl daemon-reload systemctl enable myproject systemctl start myproject systemctl status myprojectdaemon-reload不能省。改了 unit 文件不 reload,systemd 用的还是旧配置,改了等于没改,很多人在这里反复怀疑自己是不是改错了文件。
5.3 日志查看与启动失败的排查路径
systemd 把服务的标准输出和错误都收进了 journal,查看方式:
journalctl -u myproject -f journalctl -u myproject --since "10 minutes ago" journalctl -u myproject -n 100 --no-pager-f是持续跟踪,等价于 tail -f,调试时最常用。
服务起不来的排查顺序,我一般是这么走的:
第一步看状态和退出码:
systemctl status myproject如果显示Active: failed并且有status=203/EXEC,基本是 ExecStart 路径写错,或者文件没有可执行权限。203这个码的含义就是"找不到或无法执行",方向很明确。
如果是status=200/CHDIR,那是 WorkingDirectory 不存在或没权限。
如果是status=1/FAILURE,那就是应用自己启动失败了,得看应用日志,通常是 import 错误、配置缺失、端口被占用。
第二步看应用日志:
tail -n 50 /data/www/myproject/logs/error.logGunicorn 的启动错误都写在这里,包括哪个模块 import 失败、哪个配置项缺失、Python 语法错误在哪一行。
第三步检查端口占用:
ss -lntp | grep 8000如果 8000 已经被别的进程占了,Gunicorn 会报Address already in use。这种情况要么换端口,要么先杀掉占用进程。
一个特别容易漏的点:systemd 启动的服务,环境变量跟你 SSH 登录后的环境变量完全不同。你在终端里echo $PATH看到的东西,systemd 看不到。所以任何依赖环境变量的配置,都必须显式写进 unit 文件或者 EnvironmentFile,不能指望它自动继承。这个问题造成的 bug 特别隐蔽,因为手动跑好好的,一交给 systemd 就挂。
6. 上线前后最容易踩的那些坑
6.1 编码、时区与中文乱码
中文乱码在部署阶段出现的频率极高,根源无非三类:文件编码、locale 设置、数据库连接编码。
Python 3 默认源码是 UTF-8,这点比 Python 2 好很多。但如果代码里有从文件读取的中文,而文件本身存成了 GBK,读出来就是乱码。统一用 UTF-8 保存所有文件是最省事的做法。
locale 影响的是系统层面的字符处理。检查当前设置:
locale如果LANG是空的或者POSIX,中文可能在日志和命令行里显示异常。设置为 UTF-8:
localedef -i zh_CN -f UTF-8 zh_CN.UTF-8 echo 'LANG="zh_CN.UTF-8"' >> /etc/locale.conf source /etc/locale.conf如果只是想让系统按 UTF-8 处理,用en_US.UTF-8也完全可以,跟中文显示没冲突,反而少一些兼容问题。
时区问题更隐蔽。服务器默认可能是 UTC,你的应用记录的时间会比北京时间少 8 小时,跟数据库里的时间对不上,排查起来很费劲。设置时区:
timedatectl set-timezone Asia/Shanghai timedatectl数据库连接层面的编码,MySQL 要在连接串里指定charset=utf8mb4,PostgreSQL 要在建库时指定编码。utf8mb4 比 utf8 多了对四字节字符的支持,比如某些表情符号,如果应用要存这些内容,必须用 utf8mb4,否则插入时报错或者被截断。
6.2 依赖版本冲突与缓存带来的怪问题
依赖问题里最让人抓狂的是"本地好的,服务器上不行"。常见原因是两边 Python 版本不一致。比如本地用 3.11 写的代码用了新语法,服务器上是 3.8,直接语法错误。部署前先在服务器上python3 -V,跟本地核对,这一步花 10 秒能省几小时。
第二个原因是 pip 缓存。pip 会把下载过的 wheel 缓存在~/.cache/pip,如果某个包之前装过一半失败,缓存里可能有损坏的文件,导致后续安装一直报错。清缓存:
pip cache purge或者干脆加--no-cache-dir强制不用缓存:
pip install --no-cache-dir -r requirements.txt第三个原因是虚拟环境没激活就装依赖。看起来像废话,但确实经常发生:SSH 新开一个窗口,忘了source venv/bin/activate,pip install装到了全局环境,然后启动服务时用的是虚拟环境的解释器,自然找不到包。判断方法很简单,which pip看一眼路径对不对,养成这个习惯。
6.3 一份可以直接照着走的上线前检查清单
部署完别急着宣布完成,下面这些项挨个过一遍,能拦住大部分线上问题。
| 检查项 | 命令或方法 | 期望结果 |
|---|---|---|
| Python 版本一致 | python3 -V | 与开发环境一致 |
| 关键模块可用 | python3 -c "import ssl, sqlite3" | 无报错 |
| 虚拟环境正确 | which python | 指向 venv 目录 |
| 依赖完整 | pip check | 无依赖冲突 |
| 服务状态 | systemctl status myproject | active (running) |
| 本地接口 | curl 127.0.0.1:8000/health | 返回 200 |
| 外部访问 | 浏览器或 curl 域名 | 返回正常 |
| 静态资源 | 请求一个图片或 CSS | 正常返回 |
| 防火墙规则 | firewall-cmd --list-all | 已放行 http/https |
| 开机自启 | systemctl is-enabled myproject | enabled |
| 日志正常 | tail -f logs/error.log | 无异常堆栈 |
| 时区正确 | date | 与本地时间一致 |
pip check这个命令值得单独提一下,它会检查已安装包之间的依赖是否满足,能发现"某个库要求的版本被另一个库覆盖了"这类问题,比运行时才报 ImportError 要早得多。
还有一个经常被忽略的检查:重启一次服务器,看服务能不能自动起来。很多人只测了systemctl restart,没测真正的重启。真出故障时机器重启后发现服务没起来,那才是真的被动。选个业务低峰期做一次完整的重启验证,心里才踏实。
关于 Python 3.11 及更高版本,还有个小细节可以用上:-X frozen_modules=off和各种启动优化参数,但这些属于锦上添花,不是部署的必选项,先把主链路跑通再说。
最后分享一个我在长期维护中形成的习惯:把整个部署过程写成一个 shell 脚本,提交到代码仓库里。这份脚本本身就是最准确的部署文档,新人接手时不用问东问西,看脚本就知道这台机器上到底装了什么、放在哪、怎么起。环境变量、目录结构、systemd 配置的模板也一并放进去。踩过的每个坑,最后都会变成脚本里的一行注释,这比任何口头交接都可靠。