Django 和 FastAPI 在本地 runserver 或 uvicorn main:app --reload 跑得好好的,传到 VPS 上第一件事就是撞墙:pip install 报 externally-managed-environment、静态文件全是 404、DEBUG=False 之后页面直接 500、关掉 SSH 服务就没了。
这篇按 Ubuntu 24.04 LTS 走一遍生产部署:Python 版本怎么选、虚拟环境怎么建、Gunicorn 的 WSGI 和 ASGI worker 有什么区别、systemd 怎么托管、Nginx 怎么配 Unix socket 和静态文件、Django 上线前必须改的几个设置、以及发布时怎么不掉线。Flask、Litestar 的做法和这里基本一致。
新手最容易糊涂的地方是:明明 Django 自己就能跑起来,为什么还要装一堆东西?
| 角色 | 谁来做 | 为什么不能省 |
|---|---|---|
| 跑 Python 代码 | Gunicorn / Uvicorn | runserver 是开发服务器,单线程、不处理并发、官方明确说了别用于生产 |
| 进程常驻、崩溃拉起、开机自启 | systemd | 否则关掉终端进程就没了 |
| 对外 80/443、TLS、静态文件 | Nginx 或 Caddy | Python 进程不该用 root 去占低端口,静态文件交给 Nginx 快得多 |
| 证书续期 | certbot 或 Caddy | 忘了续期,网站整站打不开 |
请求路径是这样的:
浏览器 → Nginx(443) → Unix socket → Gunicorn → 你的 Django/FastAPI 代码
↓
/static/ 直接由 Nginx 返回,不进 Python
Python 部署和 Node.js 有个本质区别:Node 一个进程就能吃满事件循环,Python 要靠多进程堆并发。Gunicorn 每个 worker 都是一个独立的 Python 解释器进程,Django 项目一个 worker 常驻 80-150 MB,装了 pandas、Pillow、机器学习库的能到 300 MB 以上。
Gunicorn 官方建议的 worker 数是 (2 × CPU核数) + 1,但在小内存 VPS 上这个公式会把你的机器撑爆。先算内存,再看 CPU:
| 配置 | 建议 worker 数 | 说明 |
|---|---|---|
| 2 vCPU / 2 GB | 2-3 | 按公式该开 5 个,内存不够,老实开 2 个 |
| 2 vCPU / 4 GB | 3-5 | 常规小项目够用 |
| 4 vCPU / 8 GB | 5-9 | 按公式来,留 2 GB 给数据库和系统 |
估算方法很简单:跑起来之后 ps aux | grep gunicorn 看单个 worker 的 RSS,乘以 worker 数,再留 30% 余量给数据库、缓存和系统。
几个真实会踩的点:
- pandas、numpy、Pillow 这类包本身就吃内存,装了之后 worker 常驻内存能翻倍。
- ASGI 应用(FastAPI)不需要那么多 worker。异步 IO 密集型应用 2-4 个 worker 就能撑住很高并发,开多了纯属浪费内存。
- 磁盘要 SSD。虚拟环境装个 Django + DRF + Celery 就是上万个小文件。
选机器时,个人项目和练手可以看 Racknerd,年付便宜;要随时升配、按小时计费用 Vultr;要大内存跑数据分析类应用,Contabo 单价低但磁盘 IO 一般;国内访问要求高的看 搬瓦工 的 CN2 GIA。拿不准就对着 VPS 配置怎么选 那张表过一遍。
Ubuntu 24.04 自带 Python 3.12,不要急着去装最新版。查一下你的框架到底要什么:
- Django 6.1 要求 Python ≥ 3.12 —— 系统自带的正好满足
- FastAPI 0.141 要求 Python ≥ 3.10 —— 同样满足
- Gunicorn 26.x、Uvicorn 0.53 都要求 ≥ 3.10
截至 2026 年 9 月,Python 3.14 是最新稳定版(2025 年 10 月发布,支持到 2030 年 10 月);3.12 支持到 2028 年 10 月;3.10 今年 10 月底就 EOL 了,还在用的赶紧排升级。
除非你确实要用 3.13/3.14 的新特性(比如 free-threading),否则用系统的 3.12 最省事:安全更新跟着 apt 走,不用自己维护编译版本。
sudo apt update && sudo apt upgrade -y
sudo apt install -y python3 python3-venv python3-dev build-essential \
libpq-dev nginx git curl ufw
python3 -V
python3-dev 和 build-essential 别省 —— psycopg、pillow、lxml 这些包在没有预编译 wheel 时要现场编译,缺头文件会报一堆看不懂的 gcc 错误。libpq-dev 是 PostgreSQL 客户端库,用 MySQL 就换成 default-libmysqlclient-dev。
确实需要新版本再装 deadsnakes PPA:
sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt install -y python3.14 python3.14-venv python3.14-dev
这是 Ubuntu 24.04 上最高频的一个坑。直接在系统 Python 上 pip install django,会得到:
error: externally-managed-environment
× This environment is externally managed
这不是 bug。Ubuntu 把系统 Python 标记为"由包管理器托管",防止 pip 装的包覆盖掉 apt 装的依赖,把系统工具搞坏。
正确做法是建虚拟环境,不是加 --break-system-packages。 那个参数字面意思就是"弄坏系统包",真的会弄坏。
sudo useradd -r -m -d /srv/app -s /bin/bash appuser
sudo mkdir -p /srv/app/{releases,shared}
sudo chown -R appuser:appuser /srv/app
sudo -u appuser python3 -m venv /srv/app/shared/venv
sudo -u appuser /srv/app/shared/venv/bin/pip install --upgrade pip
虚拟环境放 shared/ 而不是代码目录里,这样每次发新版本不用重装依赖。
想快很多就用 uv(Rust 写的包管理器,当前版本 0.12.x),装依赖比 pip 快一个数量级,大项目差别很明显:
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv /srv/app/shared/venv --python 3.12
uv pip install --python /srv/app/shared/venv/bin/python -r requirements.txt
uv 完全兼容 requirements.txt,可以先只拿它当 pip 的加速替代,不用改项目结构。
sudo -u appuser git clone [email protected]:your/repo.git /srv/app/releases/20260920-140000
cd /srv/app/releases/20260920-140000
sudo -u appuser /srv/app/shared/venv/bin/pip install -r requirements.txt
sudo -u appuser /srv/app/shared/venv/bin/pip install gunicorn
requirements.txt 里必须锁死版本(django==6.1.1 而不是 django>=6.1)。不锁版本的后果是:今天部署好好的,三个月后重新部署时某个依赖发了个破坏性更新,线上直接起不来,而你根本不知道哪里变了。
用 pip freeze > requirements.txt 生成,或者用 uv pip compile 生成带哈希的锁文件。
这是 Django 和 FastAPI 分叉的地方。
/srv/app/shared/venv/bin/gunicorn \
--workers 3 \
--bind unix:/srv/app/shared/gunicorn.sock \
--timeout 60 \
--access-logfile - \
--error-logfile - \
myproject.wsgi:application
myproject.wsgi:application 里的 myproject 换成你 settings.py 所在的包名。
用 Unix socket 而不是 TCP 端口,理由是少一层网络栈开销,而且不会意外暴露到公网。代价是 Nginx 和 Gunicorn 必须在同一台机器上。
FastAPI 是异步框架,用同步 worker 跑会把所有 async def 的优势全部浪费掉。要换成 Uvicorn worker:
/srv/app/shared/venv/bin/gunicorn \
--workers 3 \
--worker-class uvicorn.workers.UvicornWorker \
--bind unix:/srv/app/shared/gunicorn.sock \
--timeout 60 \
main:app
为什么不直接 uvicorn --workers 3?Uvicorn 自己也能管多进程,但 Gunicorn 的进程管理更成熟:优雅重启、超时杀进程、worker 异常退出后自动补位。只跑一个进程的小服务,直接用 uvicorn 也没问题。
--timeout 这个参数要理解清楚:超过这个秒数没响应完,Gunicorn 会直接杀掉 worker。默认 30 秒。如果你有导出报表、调用大模型这类慢接口,要么调大这个值,要么把慢任务丢给 Celery 异步跑 —— 后者才是正解,把 timeout 调到 300 秒只是把问题藏起来。
/etc/systemd/system/myapp.service:
[Unit]
Description=Django/FastAPI App via Gunicorn
After=network.target postgresql.service
[Service]
Type=notify
User=appuser
Group=www-data
WorkingDirectory=/srv/app/current
EnvironmentFile=/srv/app/shared/.env
ExecStart=/srv/app/shared/venv/bin/gunicorn \
--workers 3 \
--bind unix:/srv/app/shared/gunicorn.sock \
--umask 007 \
--timeout 60 \
--access-logfile - \
--error-logfile - \
myproject.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
RestartSec=3
KillMode=mixed
TimeoutStopSec=30
MemoryMax=1500M
LimitNOFILE=65535
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/app/shared
[Install]
WantedBy=multi-user.target
几个关键点,每一条都对应一类线上故障:
Group=www-data+--umask 007:Nginx 以www-data身份运行,要能读写这个 socket 文件。权限不对的表现就是 502,Nginx 日志里写connect() to unix:/... failed (13: Permission denied)。Type=notify:Gunicorn 支持 sd_notify,systemd 会等到它真正准备好才算启动成功。用Type=simple的话,进程刚 fork 出来就算成功,实际还没监听。ExecReload=kill -s HUP:Gunicorn 收到 HUP 会优雅重载 —— 起新 worker、等老 worker 处理完手上的请求再退出。这是不停机发布的基础。ProtectSystem=strict把文件系统挂只读,所以要写的路径(socket、日志、上传目录)必须列进ReadWritePaths,漏了会以Read-only file system启动失败。After=postgresql.service只保证启动顺序,不保证数据库已经能接受连接。应用层该做连接重试还是得做。
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
起不来先看 journalctl -u myapp -n 50 --no-pager,排查思路见 systemd 服务启动失败怎么办。
upstream app_server {
server unix:/srv/app/shared/gunicorn.sock fail_timeout=0;
}
server {
listen 80;
listen [::]:80;
server_name app.example.com;
client_max_body_size 20m;
access_log /var/log/nginx/app.access.log;
error_log /var/log/nginx/app.error.log warn;
# 静态文件直接由 Nginx 返回,不进 Python
location /static/ {
alias /srv/app/shared/static/;
expires 30d;
access_log off;
add_header Cache-Control "public, immutable";
}
location /media/ {
alias /srv/app/shared/media/;
expires 7d;
}
location / {
proxy_pass http://app_server;
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_redirect off;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
}
}
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
X-Forwarded-Proto 必须设,否则 Django 开了 SECURE_SSL_REDIRECT 之后会陷入无限重定向 —— Django 以为是 HTTP 就跳 HTTPS,Nginx 又转成 HTTP 传给它,来回死循环。这类问题的完整排查见 HTTPS 跳转循环怎么办。
alias 的路径结尾那个斜杠不能少,少了会变成路径拼接错误,表现是静态文件 404 但目录明明存在。
限速、安全响应头这些,可以对着 Nginx 安全加固 补。嫌 Nginx 配置麻烦,直接用 Caddy 两行代替,见 Caddy 反向代理完全指南。
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d app.example.com --redirect --agree-tos -m [email protected]
systemctl list-timers | grep certbot
sudo certbot renew --dry-run
续期失败的排查清单在 Let's Encrypt 证书续期失败怎么办。
本地能跑不代表能上线。下面四条漏一条就是事故:
1. DEBUG = False
这个不改,任何一个报错页面都会把你的 settings、环境变量、SQL 语句原样打印给访问者,包括数据库密码。
2. ALLOWED_HOSTS 要填
DEBUG = False 之后 ALLOWED_HOSTS 为空会导致所有请求返回 400。很多人第一次上线卡在这里。
3. SECRET_KEY 从环境变量读
import os
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
DEBUG = os.environ.get("DJANGO_DEBUG", "0") == "1"
ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "").split(",")
STATIC_ROOT = "/srv/app/shared/static"
STATIC_URL = "/static/"
MEDIA_ROOT = "/srv/app/shared/media"
MEDIA_URL = "/media/"
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
CSRF_TRUSTED_ORIGINS = ["https://app.example.com"]
用 os.environ["..."] 而不是 .get(),少配了直接启动失败,好过带着空密钥跑起来。
4. collectstatic 要跑
sudo -u appuser /srv/app/shared/venv/bin/python manage.py collectstatic --noinput
sudo -u appuser /srv/app/shared/venv/bin/python manage.py migrate
DEBUG = False 之后 Django 不再自己服务静态文件,必须 collectstatic 把它们收集到 STATIC_ROOT,再由 Nginx 返回。"CSS 全丢了、后台管理页面变成纯文本",99% 是这一步没做或者 Nginx 的 alias 路径不对。
sudo -u appuser touch /srv/app/shared/.env
sudo chmod 600 /srv/app/shared/.env
DJANGO_SECRET_KEY=用 python -c "import secrets;print(secrets.token_urlsafe(50))" 生成
DJANGO_DEBUG=0
DJANGO_ALLOWED_HOSTS=app.example.com
DATABASE_URL=postgres://app:[email protected]:5432/appdb
数据库只监听 127.0.0.1,别为了图方便开到公网,做法见 VPS 数据库不要直接暴露公网。数据库本身的部署可以参考 PostgreSQL 18 部署指南,缓存和 Celery broker 用 Redis 8 部署指南。
#!/usr/bin/env bash
set -euo pipefail
APP=/srv/app
VENV=$APP/shared/venv
RELEASE=$APP/releases/$(date +%Y%m%d-%H%M%S)
git clone --depth 1 [email protected]:your/repo.git "$RELEASE"
cd "$RELEASE"
"$VENV/bin/pip" install -r requirements.txt
"$VENV/bin/python" manage.py collectstatic --noinput
"$VENV/bin/python" manage.py migrate --noinput
ln -sfn "$RELEASE" "$APP/current"
sudo systemctl reload myapp
ls -1dt $APP/releases/* | tail -n +6 | xargs -r rm -rf
systemctl reload 触发 Gunicorn 的 HUP 优雅重载,正在处理的请求会处理完再退出,用户无感知。
数据库迁移是这个流程里唯一不能回滚的部分。 软链接切回上个版本只要一秒,但 migrate 改过的表结构不会自动回去。所以删字段、改字段类型这类破坏性迁移,要拆成两次发布:先发兼容新旧两版代码的迁移,确认稳定后再发清理迁移。
set -euo pipefail 别省 —— 没有它,migrate 失败了脚本还会继续把软链接切过去,线上就是 500。
502 Bad Gateway — 先看 sudo systemctl status myapp。三种典型原因:Gunicorn 根本没起来;socket 权限不对(Nginx 日志里的 Permission denied);worker 被 OOM 杀了。完整排查见 502 / 504 Bad Gateway 怎么办。
500 但日志里什么都没有 — 多半是 DEBUG=False 时 Django 把异常吞了。配上 LOGGING 输出到 stderr,systemd 会收进 journal。
DisallowedHost / 400 Bad Request — ALLOWED_HOSTS 没包含你访问用的域名。
CSS 和 JS 全 404 — collectstatic 没跑,或者 Nginx alias 路径结尾少了斜杠。
worker 频繁被杀、日志里有 WORKER TIMEOUT — 有慢接口超过了 --timeout。真正的修法是把慢任务异步化,不是无脑调大超时。
内存慢慢涨到 OOM — Gunicorn 有个实用参数 --max-requests 1000 --max-requests-jitter 100,让 worker 处理够一定请求数就自动重启,能缓解第三方库的内存泄漏。这是止血不是治本,但很管用。加 Swap 也能续命,见 低内存怎么用 ZRAM 和 Swap 优化。
externally-managed-environment — 回到前面那节,建虚拟环境,别加 --break-system-packages。
日志怎么查的整体思路见 VPS 日志怎么看:
sudo journalctl -u myapp -f
sudo tail -f /var/log/nginx/app.error.log
Django 项目十有八九会用到 Celery。它是独立进程,不会跟着 Gunicorn 一起起来,要单独写 unit:
[Unit]
Description=Celery Worker
After=network.target redis-server.service
[Service]
Type=forking
User=appuser
WorkingDirectory=/srv/app/current
EnvironmentFile=/srv/app/shared/.env
ExecStart=/srv/app/shared/venv/bin/celery -A myproject worker \
--detach --loglevel=info --concurrency=2 \
--pidfile=/srv/app/shared/celery.pid
Restart=always
[Install]
WantedBy=multi-user.target
定时任务用 Celery Beat,也是单独一个服务。注意 Beat 只能跑一个实例,起两个会导致每个定时任务执行两遍。
- 备份:数据库和用户上传目录,代码在 git 里不怕。备份要演练过恢复才算数,见 备份恢复演练。
- 媒体文件:用户上传的图片放本机磁盘,迁移和扩容都麻烦,量大了换对象存储,见 MinIO 对象存储部署。
- 流量告警:先配上,别等被限速才发现,见 流量监控和超额预警。
- 性能基线:上线当天记下正常的 TTFB 和内存占用,以后说"变慢了"才有参照,见 网站 TTFB 很高怎么办。
- VPS 部署 Node.js 项目上线 — 同一台机器上还要跑 Node 服务
- VPS 配置怎么选 — worker 数量和内存怎么配平
- VPS 搭建 Dokploy — 不想手写这套,要 git push 自动部署
- VPS 搭建 GitHub Actions 自托管 Runner — 构建放到自己机器上跑
- VPS 服务启动了但外网访问不了 — 端口、防火墙、监听地址排查
