第一次在一台 CentOS 7 服务器上部署 Django 项目时,我天真地以为流程就是把代码传上去、装个 Django、然后访问 IP 就能看到页面。后来真到了生产环境才发现,CentOS 7 默认自带的 Python 2.7 根本不满足 Django 新版本的要求;就算你费劲装了 Python3,直接用开发服务器跑生产流量也撑不住并发;就算你用 uWSGI 把应用进程拉起来了,公网请求也不会直接打到 Django 身上,前面必须有一层 Nginx 做反向代理和静态文件处理。这套标准链路——CentOS 7 + Python3 + virtualenv + Django + uWSGI + Nginx,每个环节都有它的理由,也有一堆文档里没写清楚的坑。
这篇文章我会按照我自己真实部署的顺序,把完整流程走一遍,从技术选型、Python3 编译安装、虚拟环境配置、Django 生产环境调整,到 uWSGI 托管、Nginx 反向代理,最后是你一定会遇到的几个坑和排查思路。适合刚接触 Linux 服务器、第一次把 Django 项目放到公网的开发者,也适合部署过一次但总被 502、静态文件 404 折腾的同学。照着操作,基本能让你从裸机走到线上可用。
1. 部署前的技术选型:为什么这套组合一个都不能少
1.1 每个组件解决什么问题
先说清楚这套链路里每个角色的定位。你可以把整个请求过程想象成一个餐厅:用户是客人,Nginx 是门口负责接待和送菜的服务员,uWSGI 是后厨的厨师团队,Django 是厨师手里的菜谱,virtualenv 是独立的厨房——避免不同菜品互相串味。
- Nginx:对外接收 HTTP 请求,处理静态文件(图片、CSS、JS)并且转发动态请求给后端的 Python 进程。它是真正监听 80 端口的人。
- uWSGI:一个 Python 应用服务器。Django 项目本身跑在 uWSGI 进程里,它把 Nginx 转发过来的请求转换成 Django 能处理的 WSGI 调用,并且负责多进程、多线程、进程崩溃后的重启。
- Django:业务逻辑框架,处理 URL 路由、视图、数据库操作、模板渲染等。
- virtualenv / venv:隔离 Python 依赖环境。生产服务器上往往有多个项目,每个项目依赖的 Django 版本、第三方包版本可能完全不同,不隔离就会出现"升级一个项目把另一个项目搞挂"的惨剧。
- Python3:Django 3.x 之后只支持 Python3,CentOS 7 系统自带的 Python2.7 已经完全无法满足,所以必须自己装一个新版本的 Python3。
哪个都不能省。有人会问:能不能让 Nginx 直接proxy_pass到runserver的 8000 端口?短时间测试可以,但runserver是单进程开发服务器,没有做并发优化,一旦有几个人同时访问,响应会非常慢甚至直接卡死。有人也会问:能不能不要 Nginx,直接让 uWSGI 监听公网端口?也可以,但你就得自己处理静态文件的性能优化、请求头日志、限流、HTTP 层面的安全设置,而这些正是 Nginx 最擅长的事。这套组合不是老古董,而是这么多年沉淀下来的最稳架构。
1.2 版本搭配参考
在开始动手之前,先给出一套我在 CentOS 7 上实测稳定的版本搭配,建议新同学直接照抄,不要盲目追新:
| 组件 | 推荐版本 | 理由 |
|---|---|---|
| 操作系统 | CentOS 7.9 | 内核稳定,长期维护 |
| Python | 3.8.x | 兼容性好,编译最顺;3.10+ 在 CentOS 7 上容易遇到 OpenSSL 版本过老的坑 |
| Django | 4.2 LTS | 支持 Python3.8,且是长期支持版本 |
| uWSGI | 2.0.x | 稳定,pip 直接安装 |
| Nginx | 1.20+(EPEL 仓库) | 功能足够,配置简单 |
这里特别提醒:CentOS 7 自带的 OpenSSL 是 1.0.2 版本,Python 3.10 以上编译时对 OpenSSL 的要求是 1.1.1+,否则会出现_ssl模块导入失败、pip 无法连接 HTTPS 源等问题。如果你非要编译新版本 Python,就得先折腾第三方 OpenSSL,非常痛苦。所以老老实实用 Python 3.8,省事又稳。
还有一个版本选择逻辑:Django 4.2 是当前和 Python3.8 搭配最平衡的 LTS 版本,既有新特性又不至于要求太新的 Python。Django 5.x 需要 Python 3.10+,在 CentOS 7 的默认环境下并不合适。
2. Python3 环境准备:CentOS 7 上最耗时的一环
2.1 为什么不能直接用 yum 装 Python3
很多第一次用 CentOS 7 的人会执行yum install python3,然后发现提示找不到软件包。这是因为 CentOS 7 的默认 yum 源里根本没有 Python3,只有 Python2.7。你可以安装 EPEL 扩展源,然后从里面装上 Python 3.6,但 3.6 这个版本太老,Django 4.x 已经不再支持它,而且 EPEL 里的 Python3 不带完整的开发头文件和 pip,后续装依赖还会遇到各种奇怪问题。
所以更干净、可控的做法是手动编译安装 Python。编译安装听起来麻烦,但其实步骤固定,只要把依赖装齐,整个过程十几分钟,比用不完整的源包踩坑要舒服得多。
编译安装的另一个好处是:你可以决定把 Python 装到哪个目录,也可以同时保留多个 Python 版本互不影响,需要用哪个就用哪个,升级也只是切换一下路径。
2.2 编译安装 Python3.8 的完整步骤
首先升级系统并安装编译工具链和依赖库。这一步很关键,缺少任何一项,后面都会以奇怪的报错还给你。
yum update -y yum groupinstall "Development Tools" -y yum install -y openssl-devel libffi-devel bzip2-devel zlib-devel readline-devel sqlite-devel逐个说一下这些依赖是干嘛的:openssl-devel提供 Pythonssl模块编译所需,不装它,pip 连 HTTPS 的软件源都无法访问;libffi-devel给ctypes模块用,很多包会依赖;zlib-devel是压缩模块的依赖,pip 解压安装包时要用;sqlite-devel让 Python 能带上sqlite3模块,Django 默认数据库如果是 SQLite 就离不开它。
接着下载源码并编译。以 Python 3.8.18 为例:
cd /usr/src wget https://www.python.org/ftp/python/3.8.18/Python-3.8.18.tgz tar xzf Python-3.8.18.tgz cd Python-3.8.18 ./configure --enable-optimizations --with-ssl make -j $(nproc) make altinstall--enable-optimizations会启用 PGO 优化,也就是用编译器对 Python 自身做性能调优,编译时间长一些,但跑起来速度更好。--with-ssl确保上面装的 OpenSSL 开发库会被编进去。make -j $(nproc)用上服务器所有 CPU 核心并行编译,能明显缩短时间。make altinstall而不是make install是刻意为之:altinstall只安装python3.8命令,不创建python3符号链接,更不会碰系统自带的/usr/bin/python。这样系统原有的 Python2.7 和依赖它的 yum 工具完全不受影响。
装好之后验证一下:
/usr/local/bin/python3.8 -V看到Python 3.8.18就说明编译安装成功。注意,后续使用尽量都用完整路径/usr/local/bin/python3.8,或者把它加进 PATH。
2.3 用 virtualenv 隔离项目环境
Python3 原生自带venv模块,标题里提到的virtualenv在 Python3 时代可以直接用python3 -m venv替代,效果等价。如果你习惯virtualenv命令,也可以先pip install virtualenv,然后使用virtualenv venv。我自己的习惯是用官方venv,少装一个第三方工具。
先规划项目的部署目录。我一般放在/opt下面,因为/opt就是用来放第三方应用和独立服务的:
mkdir -p /opt/myblog cd /opt/myblog /usr/local/bin/python3.8 -m venv venv这里的第二个venv是虚拟环境目录名,创建后结构大概是venv/bin、venv/lib、venv/include。激活环境:
source /opt/myblog/venv/bin/activate python -V如果提示符前面出现了(venv),说明已经进入虚拟环境。之后所有pip install都会装到这个独立环境里,不会污染系统。退出环境用deactivate。
激活之后顺手把pip升级一下,再安装 Django 和 uWSGI 就很干净了:
pip install --upgrade pip3. Django 项目部署前的配置调整
3.1 settings.py 里必须改的三个地方
项目代码上传到服务器之前,先在本地或者测试机把settings.py调整成生产模式。我见过太多人明明部署步骤全对,结果访问时要么 400 Bad Request,要么直接暴露内部配置,基本都是栽在这几个参数上。
第一个是DEBUG。生产环境必须设为False,否则一旦页面报错,浏览器会展示完整的堆栈信息、环境变量、文件路径,这些信息就是给攻击者递刀子。 第二个是ALLOWED_HOSTS。当DEBUG=False时,Django 只允许访问已在这里登记的域名或 IP。很多人部署完打开网站看到一排 "Bad Request (400)",就是因为忘了改它。至少要填入你的服务器 IP 和解析过来的域名:
ALLOWED_HOSTS = ['your-domain.com', '123.123.123.123']第三是STATIC_ROOT。这是 Django 收集所有静态文件后统一存放的目录,需要在settings.py里明确指定:
import os BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) STATIC_ROOT = os.path.join(BASE_DIR, 'static') STATIC_URL = '/static/'如果你有用户上传的图片或附件,还要配置:
MEDIA_ROOT = os.path.join(BASE_DIR, 'media') MEDIA_URL = '/media/'顺带一个进阶建议:把SECRET_KEY改成从环境变量读取,而不是写死在源码里。这样代码仓库泄露也不至于直接暴露核心密钥。可以这样改:
import os SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'dev-only-not-usable-key')3.2 依赖清单与上传前的本地验证
在项目的本地开发环境里,执行pip freeze > requirements.txt导出依赖清单,然后只保留项目真正用到的关键包,避免一不小心把开发工具也带上服务器。一个典型的requirements.txt可能是:
Django==4.2.16 uWSGI==2.0.25.1 mysqlclient==2.2.4 Pillow==10.4.0把项目代码上传到/opt/myblog之后,进入虚拟环境安装依赖:
source /opt/myblog/venv/bin/activate cd /opt/myblog pip install -r requirements.txt然后依次执行数据库迁移和静态文件收集:
python manage.py migrate python manage.py collectstatic --noinputcollectstatic会把 Django admin 自带的前端文件、你项目里各处 App 的静态文件全部复制到STATIC_ROOT指定的目录。这个步骤一定不能省,否则后面 Nginx 打开页面会发现管理员后台完全没样式。
做完全部检查之后,可以先在虚拟环境里用 runserver 跑一下确认代码在服务器上正常工作:
python manage.py runserver 0.0.0.0:8000注意这只是验证代码,不是生产运行方式,验证完马上停掉。
4. uWSGI 配置:让 Django 应用从开发模式切换到生产模式
4.1 安装 uWSGI 的两种方式
uWSGI 是一个 Python 应用服务器,它在 Django 和 Nginx 之间负责把请求翻译成 Django 能处理的形式。安装方式有两种:直接在部署机器的虚拟环境里用 pip 安装,或者 yum 安装系统包。我强烈建议用 pip 装在虚拟环境里,原因很简单:版本更新及时,且不会污染系统 Python。
source /opt/myblog/venv/bin/activate pip install uwsgi如果你用的是 yum 装的系统 Python3,编译 uWSGI 时可能会报缺失Python.h的错误,那是因为没装python3-devel。但我们用源码编译的 Python3.8 自带开发头文件,一般不会遇到这个坑。
4.2 写一个能直接用的 uWSGI 配置文件
比起把一长串参数写在命令行里,我更推荐写一个uwsgi.ini配置文件,放在项目根目录/opt/myblog/uwsgi.ini。这样每次启动、调试、查看进程都对着同一个文件,管理方便很多:
[uwsgi] project = myblog base = /opt/myblog chdir = %(base) module = %(project).wsgi:application virtualenv = %(base)/venv master = true processes = 4 threads = 2 vacuum = true socket = /run/uwsgi/myblog.sock chown-socket = nginx:nginx chmod-socket = 660 die-on-term = true logto = /var/log/uwsgi/myblog.log几个参数逐个解释:
chdir:告诉 uWSGI 项目所在目录,也相当于运行时要切换到的工作目录。module:指向 Django 项目里的 WSGI 入口。如果你的项目目录名是myblog,那么入口就是myblog.wsgi:application,其中myblog.wsgi对应项目里的myblog/wsgi.py文件,application是那个文件里定义的应用对象。virtualenv:指定虚拟环境目录,uWSGI 加载项目时就会使用这个环境里的 Python 和包。master = true:开启主进程管理模式,主进程负责管理子进程,子进程死掉后自动拉起。processes:子进程数。小内存服务器(1G)设 4 个就够了,CPU 核数多可以按2 * CPU核心数 + 1的公式估算,同时也要考虑数据库连接数,进程越多连接池压力越大。threads:每个进程里的线程数。线程数不是越高越好,受 Python GIL 限制,太多反而可能拖慢性能。socket:这里用的是 Unix socket 文件而不是 HTTP 端口,专门让 Nginx 通过uwsgi协议通信,性能比走 HTTP 转发更好。chown-socket和chmod-socket:把 socket 文件的属主改成nginx,权限设为 660,这一步直接决定了 Nginx 能不能连上 uWSGI,是最常见的 502 报错根源之一。die-on-term = true:当 systemd 发来终止信号时让 uWSGI 真正退出,而不是进入优雅等待,配合 systemd 托管时很重要。logto:日志输出路径,排查问题基本靠它。
如果你还不放心,可以先临时用 HTTP 模式验证 uWSGI 本身能正常加载 Django:
cd /opt/myblog source venv/bin/activate uwsgi --http 127.0.0.1:8001 --module myblog.wsgi:application --virtualenv /opt/myblog/venv然后另一个终端执行curl http://127.0.0.1:8001/,如果能返回内容,说明 uWSGI 和 Django 这一层是通的。
记得先建日志目录,否则 uWSGI 启动会报找不到/var/log/uwsgi/:
mkdir -p /var/log/uwsgi4.3 用 systemd 让 uWSGI 开机自启
测试没问题后,写一个 systemd 服务文件让 uWSGI 常驻后台,并在服务器重启后自动拉起。创建/etc/systemd/system/myblog-uwsgi.service:
[Unit] Description=uWSGI instance for myblog After=network.target [Service] RuntimeDirectory=uwsgi ExecStart=/opt/myblog/venv/bin/uwsgi --ini /opt/myblog/uwsgi.ini Restart=always KillSignal=SIGQUIT Type=simple User=nginx Group=nginx [Install] WantedBy=multi-user.target几个关键点:
RuntimeDirectory=uwsgi:systemd 会自动创建/run/uwsgi目录,并把属主设置成运行用户,避免了你自己手动 mkdir 和授权的问题,非常省心。User=nginx:uWSGI 以 nginx 用户的身份运行,这样它创建的 socket 文件天然属于 nginx,配合配置里的chown-socket就没有权限冲突。- 千万不要在 uWSGI 配置里再加
daemonize = true了。systemd 需要进程在前台运行以便跟踪状态,如果你既托管给 systemd 又让 uWSGI 自己 daemonize,服务状态会非常混乱,经常出现 "started 后立即挂掉" 的假象。
配置文件写好之后执行:
systemctl daemon-reload systemctl enable myblog-uwsgi systemctl start myblog-uwsgi systemctl status myblog-uwsgi状态显示active (running)之后,再检查 socket 文件和日志:
ls -l /run/uwsgi/myblog.sock tail -f /var/log/uwsgi/myblog.log看到WSGI app 0 ... ready之类的日志,就说明这层已经起来了。
5. Nginx 反向代理:对外服务的最后一公里
5.1 Nginx 安装与站点配置
CentOS 7 的默认 yum 源里是没有 Nginx 的,需要先装 EPEL 扩展源:
yum install -y epel-release yum install -y nginx systemctl enable nginx安装好之后,新建站点配置文件/etc/nginx/conf.d/myblog.conf:
server { listen 80; server_name your-domain.com; location /static/ { alias /opt/myblog/static/; expires 7d; access_log off; } location /media/ { alias /opt/myblog/media/; } location / { include uwsgi_params; uwsgi_pass unix:/run/uwsgi/myblog.sock; uwsgi_read_timeout 60; } }这份配置的请求流程是:外部请求到达 80 端口 -> 如果 URL 以/static/开头,Nginx 直接从磁盘返回静态文件,不进入 Python 进程 -> 其他请求通过uwsgi_pass传给 uWSGI 的 Unix socket -> uWSGI 交给 Django 处理。
为什么用uwsgi_pass而不是proxy_pass?因为 uWSGI 和 Nginx 之间有一个专用的 uwsgi 协议,比 HTTP 协议更轻量,少了无谓的 HTTP 头解析,这也是 "Nginx + uWSGI + Django" 的标准姿势。include uwsgi_params会自动引入一堆标准参数,把客户端真实 IP、Host 等传递给 uWSGI。
修改后先检查语法再重载:
nginx -t systemctl reload nginx5.2 静态文件处理:alias 和 root 的区别
静态文件配置里有一个新手最容易踩的坑:alias和root混用。alias的意思是"把 URL 中匹配到 location 的前缀替换成指定目录",而root是"把整个请求 URL 拼到指定目录后面"。
举例来说:
location /static/ { alias /opt/myblog/static/; }访问/static/css/style.css时,Nginx 会找/opt/myblog/static/css/style.css,这是我们想要的结果。如果误用了root:
location /static/ { root /opt/myblog/static; }Nginx 会去找/opt/myblog/static/static/css/style.css,结果必然 404。所以配置静态目录时,优先用alias,并且注意目录末尾的斜杠,少了斜杠同样会拼错路径。
media目录同理,用户上传的图片通过/media/直接让 Nginx 返回,Django 只负责在业务逻辑中处理和保存这些文件,实际读取压力都交给 Nginx 这块高效的静态文件引擎。
配置完 Nginx 之后,操作系统防火墙也要放行 HTTP 端口:
firewall-cmd --permanent --add-service=http firewall-cmd --reload如果你用的是云服务器,还要记得在云控制台的安全组里放行 80 端口,这一步遗漏了的话,服务器内部 Nginx 明明正常,外部访问仍然超时。
6. 上线验证与踩坑实录:哪些坑我替你踩过了
6.1 上线后的验证清单
整套部署完成后,不要急着关终端,按下面的顺序做一轮验证:
- 查看两个服务状态:
systemctl status nginx和systemctl status myblog-uwsgi,确保都在运行。 - 本机请求测试:
curl -I http://127.0.0.1/,看到200 OK说明链路基本通了。 - 直接测 uWSGI socket:
curl --unix-socket /run/uwsgi/myblog.sock http://localhost/,如果返回内容,说明 uWSGI 层正常,Nginx 连不上就是 Nginx 配置或权限问题。 - 浏览器用域名访问首页,并顺手打开一个静态资源地址,比如直接访问
/static/admin/css/base.css,确认样式加载正常。 - 测试一个需要写数据库的页面,比如注册或者发表文章,确认数据库连接和迁移都没问题。
- 最后检查一遍日志,
/var/log/nginx/error.log和/var/log/uwsgi/myblog.log里有没有报错记录。
如果哪一步断了,不要慌,往下看最常见的几个坑,绝大多数问题都能在下面对上号。
6.2 高频报错与排查链路
1. 502 Bad Gateway,Nginx 连不上 uWSGI
这是最经典的报错。先执行curl --unix-socket /run/uwsgi/myblog.sock http://localhost/,如果提示连接失败,说明 uWSGI 没在运行或者 socket 文件不存在,去看 uWSGI 日志。如果 socket 存在但 Nginx 依然 502,那就是权限问题,检查ls -l /run/uwsgi/myblog.sock,确保属主是 nginx、权限不低于 660。这时候对配置里的chown-socket和chmod-socket做调整即可。
还有一个 CentOS 7 特有的拦截者:SELinux。如果你排除了服务状态、socket 权限所有可能仍然 502,可以临时执行setenforce 0试试。如果关闭后马上恢复正常,说明 SELinux 阻止了 Nginx 访问这个 socket 或目录。长期解决可以给 Nginx 放行,或者对项目目录打上正确的 SELinux 标签。不建议为了省事直接永久关闭 SELinux,生产环境里 SELinux 是重要防线,但这一步确实是新手最容易忽略的隐形杀手。
2. ALLOWED_HOSTS 没改导致的 400 Bad Request
登录 Django admin 时看到一堆 "400 Bad Request",十有八九是ALLOWED_HOSTS里没有包含当前访问的域名或 IP。修改后重启 uWSGI 即可。
3. 静态文件全部 404
如果页面能打开但所有样式、图片都挂了,先确认是否执行过collectstatic,再确认STATIC_ROOT目录是否真实存在,最后检查 Nginx 配置里alias路径是否写对了。用curl -I http://127.0.0.1/static/admin/css/base.css就能快速判断是 Nginx 没找到文件还是路径拼接错误。
4. uWSGI 启动失败且没有任何日志
uWSGI 配置里写了logto = /var/log/uwsgi/myblog.log,但系统里可能根本不存在/var/log/uwsgi/目录,uWSGI 会因无法打开日志文件而拒绝启动。提前mkdir -p /var/log/uwsgi就能解决。同理,/run/uwsgi这个目录在 systemd 模式下由RuntimeDirectory自动创建,但如果你手动用uwsgi --ini调试时没创建目录,也要先建好。
5. systemd 配了 daemonize 导致服务状态不对
如果你在uwsgi.ini里保留了daemonize = true,systemd 会认为主进程已经退出,然后不停地尝试重启服务,状态栏会显示activating (auto-restart),非常折磨人。解决办法就是删掉daemonize,让 uWSGI 以前台模式运行,由 systemd 来管理生命周期。
还记得多进程下的数据库连接数问题吗,进程数乘线程数就是最多可能的并发连接线程数,如果服务器上数据库max_connections默认值较小,而你又把processes调得非常高,高峰期可能直接把数据库连接池打满。这种情况下不需要强行调大数据库上限,合理控制 uWSGI 的processes数量往往是更稳健的做法。
我自己部署这整套链路踩得最多的坑,其实不是某个命令不会写,而是"每一层单独测试都正常,连起来就出问题"。所以最后给你一个非常实用的建议:先让 uWSGI 单独跑起来 curl 通,再让 Nginx 单独配好静态文件访问,最后才把 Nginx 和 uWSGI 通过 socket 串起来。分层验证,逐层排查,比对着 502 瞎猜效率高得多。整套流程跑通一遍之后,记得把命令和配置整理成一份 checklist,下一次部署新项目时从裸机到上线,半小时就能搞定。