Django 上线最后一公里:Gunicorn 与 systemd 要配好的几件事

作者:程序员白大力 · 2026-09-28 09:14
Django 上线最后一公里:Gunicorn 与 systemd 要配好的几件事

封面

本地开发时 runserver 一跑,一切正常;到了服务器上,很多人还是靠这两招过活——要么继续 runserver,要么 nohup 挂个后台。前者官方文档明确写了不要用于生产,后者在 SSH 断开、进程崩溃、服务器重启这三个时刻必挂无疑。应用代码写完只算走完九十九步,让它在服务器上活着、崩溃能自愈、重启不丢人才是最后一公里。这篇文章把这一公里要配的东西理清楚:Gunicorn 管并发,systemd 管进程,两边的关键配置其实没有几个。

一、先认清两件东西的分工

生产环境跑 Django 的标准分工是:Gunicorn 是应用服务器,负责把请求分给多个进程跑你的代码;systemd 是进程管理器,负责把它拉起来、盯住它、崩了重启。nginx(或 Caddy)再站在前面做 TLS 和静态文件,这一层的选型另一篇讲过,本文不重复。

对照之下,两招「土办法」缺的正是这两层:

维度 runserver nohup 挂后台 Gunicorn + systemd
并发能力 单进程开发用 取决于里面跑什么 多 worker 进程池
崩溃自愈 无 无,挂了就挂了 systemd 自动拉起
开机自启 无 无 enable 一条命令
官方态度 明确禁止用于生产 无人看护 Gunicorn 官方推荐部署方式

Gunicorn 官方部署文档给的方案就是 systemd:写一个 unit 文件,交给系统托管。这不是可选项,是默认答案。

二、Gunicorn:三个参数决定成败

Gunicorn 的默认配置是给开发调试用的:1 个 worker、sync 模式、30 秒超时。直接搬上生产,流量稍大就会排队。真正要动的是三个参数。

**第一个是 worker 数量。**官方文档给的起点公式是 CPU 核数乘二加一:两核机器配 5 个,四核配 9 个。多数请求的时间花在等数据库、等外部接口上,一个 worker 在等的时候别的 worker 还能干活。但这个公式只是起点,真正的上限是内存——每个 worker 是一个完整进程,Django 项目通常每个占 50–150MB,小内存机器上多配几个就可能被 OOM 杀掉。配完看一眼实际内存,超了就减。

**第二个是超时。**这是最容易踩的坑:timeout 默认 30 秒,但它不是「请求最多跑 30 秒」的时间上限,而是 worker 心跳静默超过 30 秒就会被主进程杀掉重启。sync worker 处理请求时会完全占住自己,一个要跑 40 秒的合法慢请求,就能让 worker 静默 40 秒——于是被杀、请求作废、用户拿到 502,日志里一行 WORKER TIMEOUT。

正确做法分两层:把 timeout 调到比最慢的合法请求略大,这是止血;根治是把长任务挪到后台队列,别让 HTTP 请求扛着跑。

**第三个是 worker 类型。**sync 是默认值,一个 worker 同一时刻只处理一个请求;请求大多是等数据库、等接口的 I/O 型时,换 gthread 更划算——每个 worker 内开线程池,同样的进程数扛住几倍并发。两种模式的取舍:

维度 sync gthread
并发模型 每进程一次一个请求 每进程一个线程池
内存 进程多,内存高 进程少,内存省
隔离性 请求之间完全隔离 同进程线程共享状态
适合 CPU 型、追求稳 I/O 型、小内存机器

三个参数合起来,最小可用配置长这样:

# gunicorn.conf.py
import multiprocessing

wsgi_app = "myproject.wsgi:application"
bind = "127.0.0.1:8000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "gthread"
threads = 4
timeout = 120
max_requests = 1000
max_requests_jitter = 100

max_requests 让 worker 每处理一千个请求就换一个新进程,是给潜在内存泄漏上的保险;加 jitter 是为了错开重启时间,别让几个 worker 同一瞬间集体下线。

三、systemd:把进程真正交给系统

Gunicorn 自己不管自己的死活,托管这件事归 systemd。unit 文件的最小版本:

# /etc/systemd/system/gunicorn.service
[Unit]
Description=gunicorn daemon
After=network.target

[Service]
User=appuser
WorkingDirectory=/srv/myproject
EnvironmentFile=/etc/myproject.env
ExecStart=/srv/myproject/venv/bin/gunicorn -c gunicorn.conf.py
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

几个字段值得逐个说清:

■
ExecStart 必须写 venv 里 gunicorn 的完整路径,systemd 不认你 shell 里的激活状态;
■
EnvironmentFile 把密钥放在 unit 文件外面,跟代码、配置分开——密钥管理那篇的结论在这里同样适用;
■
Restart=on-failure 是自愈的关键:异常退出自动拉起,正常停止不打扰;
■
ExecReload 定义了平滑重启的动作:给主进程发 HUP 信号,新 worker 起来、旧 worker 把手头请求做完再退,发版期间用户无感知。

启用并开机自启:

sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn

日志不用自己管,systemd 直接收进 journal:journalctl -u gunicorn -f 实时看,-n 100 看最近一百行。排查问题的第一现场就在这里。

四、踩坑与教训

**坑一:reload 和 restart 用混。**发完新代码跑 systemctl reload,新代码没生效——因为 reload 只重载 Python 代码,不重读 unit 文件。改了 ExecStart 里的参数(比如 worker 数)必须 daemon-reload 加 restart。判断标准很简单:改代码用 reload,改 unit 用 restart。

**坑二:502 排查没有章法。**nginx 报 502 时按固定顺序查三层:先 systemctl status gunicorn 看服务活着没有;再对 socket 或端口的路径和权限——nginx 用户连不上 socket 是高频原因;最后才看应用本身有没有启动报错。从外往里查比瞎猜快得多。

**坑三:指望 Gunicorn 顺便伺候静态文件。**它不做这件事。上线前 collectstatic 收拢静态文件,nginx 直接接管 /static/ 路径;不想起 nginx 的极简场景,WhiteNoise 让应用自己带静态文件服务。漏掉这一步的症状很典型:站点通了,但页面裸奔没有样式。

五、写在最后

这一公里的投入有个特点:一次配好,之后每次发版都是两条命令的事。Gunicorn 三个参数按公式起步、按内存封顶,systemd 一个 unit 文件换来崩溃自愈和开机自启——总共不到二十行配置,换的是半夜不用爬起来拉进程。

如果这篇文章帮你把站点从「nohup 勉强活着」解救出来,欢迎转发给还在这么干的朋友,也欢迎在评论区聊聊你的部署方案。

参考文章

■
《Django 官方文档:部署清单(Deployment checklist)》
■
《Gunicorn 官方文档:Deploying Gunicorn(systemd 部署)》
■
《Gunicorn 官方文档:Design(worker 数量与类型设计)》
■
《Gunicorn 官方文档:Settings(timeout、max-requests 配置项)》
■
《systemd 官方文档:service 单元配置》

#Django #Gunicorn #systemd #PythonWeb #后端部署 #运维实战

本文由 AI 辅助生成并经人工审核发布,内容仅供参考,不构成法律意见。