在宝塔上部署 Django 项目

这篇讲什么

  • 这篇原来叫《在宝塔下使用 uwsgi 上线 Django 项目》,现在把范围放大:宝塔面板 + uWSGI 的完整流程仍然保留(它是很多人唯一会用的面板),但把默认推荐换成 Gunicorn + systemd + Nginx——uWSGI 已经不是官方推荐路线,配置和排错都更费劲。
  • 面板部分只校对对错、不做实机复现(那是你自己的服务器,我不去动);Gunicorn/systemd/Nginx 这三步全部在 WSL2 上真跑过,实测输出贴在下面。
  • 实测环境:WSL2 Ubuntu 26.04.1 LTS(systemd 已启用)+ Python 3.14.4 + Django 5.2.19 LTS + Gunicorn 26.2.0 + Uvicorn 0.54.0 + Nginx 1.28.3 + MySQL 9.7.4。
  • Docker / docker-compose 部分按官方文档写可复制步骤,本机没有容器运行时,未验证。

四种部署形态怎么选

形态 谁管进程 谁服务静态 上手难度 适合
宝塔面板 + uWSGI 面板/start.sh Nginx(面板配) 最低,全程点鼠标 只有一台 VPS、不想碰命令行
Gunicorn + systemd + Nginx systemd Nginx 中(三个配置文件) 默认推荐,官方文档也点名 Gunicorn
Uvicorn + systemd + Nginx systemd Nginx 中 用到异步视图 / WebSocket
Docker Compose 容器 + restart 策略 Nginx 容器 中高(要学镜像与卷) 多环境一致、CI/CD 交付

一句话原则:能用 systemd 就别用 start.sh + daemonize,进程守护、开机自启、日志收集、崩溃重启这四件事 systemd 都做得更可靠。

宝塔面板 + uWSGI 上线

部署准备

  1. 购买服务器和备案域名(这里以 CentOS 8.x 为例)。
  2. 在服务器上安装宝塔面板以及 Nginx、MySQL、Python 项目管理等组件。
  3. 准备要上线的 Django 项目,先在本地确认 python manage.py check 无错、依赖能装齐。

添加站点

  1. 添加站点

    添加站点

  2. 上传项目

    上传项目-1
    上传项目-2

部署项目

  1. 安装你需要的 Python 和 pip 版本

    宝塔的「Python 项目管理」里可以直接装指定版本的 Python;命令行方式用系统的包管理器或 pyenv,官方口径见 Python 官方安装文档 与发行版文档(CentOS 用 dnf,Ubuntu 用 apt)。

  2. 修改 settings.py

    settings.py
    1
    2
    3
    4
    5
    # SECURITY WARNING: don't run with debug turned on in production!
    DEBUG = False

    # 老笔记这里写的是 ['*'],生产上不推荐:等于放弃 Host 头校验
    ALLOWED_HOSTS = ["你的域名", "www.你的域名", "服务器公网IP"]

    ['*'] 是开发偷懒写法。DEBUG=False 时 Django 会校验 Host 头,不在名单里直接 400(实测见《Django-生产环境要注意哪些事项》1.2),写死域名才是正解。

  3. 放行相应端口

    • 放行你需要的端口:Nginx 的 80/443、SSH 端口。不要为了测试把 Django 的 8000、MySQL 的 3306、Redis 的 6379 对公网开放。老笔记里写的「Redis 的 27017 端口」是笔误,27017 是 MongoDB,Redis 默认端口是 6379。

    • 面板放行之后,还要去云厂商控制台的安全组放行同样的端口,两边都通才算通。

      放行端口

    • 临时验证可以在项目目录跑 python manage.py runserver 0.0.0.0:8000(老笔记写的是 0:8000,效果相同但可读性差),本地用 ip:8000 访问;不通就回去查安全组。验证完必须 Ctrl+C 关掉,runserver 不是生产服务器。

  4. 添加 uwsgi.ini 配置文件

    官方文档:Django 如何部署到 uWSGI。在项目目录新建 uwsgi.ini:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    [uwsgi]
    ; 用 uwsgi 协议对接 Nginx,只监听回环,别写 0.0.0.0
    socket=127.0.0.1:8000
    ; http=127.0.0.1:8000 ; 只有不用 Nginx 直连时才用 http
    chdir=/www/wwwroot/你的域名/你的项目/
    wsgi-file=shop/wsgi.py
    processes=4
    threads=2
    master=true
    pidfile=uwsgi.pid
    daemonize=uwsgi.log

    参数说明:

    • socket:Nginx 用 uwsgi_pass 对接时用的协议端口;http 是让 uWSGI 直接当 HTTP 服务器。
    • chdir:项目目录(manage.py 所在层)。
    • wsgi-file:wsgi.py 相对 chdir 的路径。
    • processes / threads:进程数与每进程线程数。
    • master:是否启用管理进程,写 true(老笔记写 True,大小写不属于文档约定写法)。
    • pidfile:主进程 pid 的存放文件,--stop 要用它。
    • daemonize:守护进程化并把日志写到该文件。
    • 老笔记把 socket 写成 0.0.0.0:8000,等于把 uWSGI 协议端口挂到公网,任何人都能直接打后端、绕过 Nginx 的限流与 TLS,务必改成 127.0.0.1 或 unix socket。
  5. 添加 uWSGI 控制命令

    1
    2
    3
    4
    5
    # start.sh
    uwsgi -d --ini uwsgi.ini

    # stop.sh
    uwsgi --stop uwsgi.pid

    权限用 chmod 750 start.sh stop.sh 就够,不要用 chmod 777(老笔记里是 777),否则任何本机账号都能改脚本内容。

  6. 添加依赖文件

    • 项目目录下新建 requirements.txt(老笔记两处拼成了 requiremens.txt),列出运行依赖。
    • 本地用 pip freeze > requirements.txt 生成,但生成后要手工删掉与部署无关的包,并按需锁版本。
  7. 迁移静态文件

    settings.py
    1
    2
    3
    STATIC_URL = "/static/"
    STATIC_ROOT = "/www/wwwroot/你的域名/static/"
    STATICFILES_DIRS = [os.path.join(BASE_DIR, "static")]

    STATIC_ROOT 不能出现在 STATICFILES_DIRS 里,否则 collectstatic 会直接报错拒绝执行(官方约束)。

    1
    $ python manage.py collectstatic --noinput

    不必再单独写 collectstatic.sh 并 chmod 777,把它做成面板的计划任务或发布脚本的一步即可。

  8. 配置 Nginx

    • 进入站点配置界面

      进入站点配置界面

    • 配置 Nginx 图示

      配置Nginx

    • 在最上边添加负载均衡模块

      1
      2
      3
      4
      5
      upstream project {
      server 10.11.0.1:8000 down;
      server 10.11.0.2:8000 backup;
      server 127.0.0.1:8000 weight=1 max_fails=1 fail_timeout=300s;
      }
      • project:负载均衡组的名字,后面 uwsgi_pass project; 直接引用。
      • 调度算法:weight(加权轮询)、ip_hash(会话保持)、least_conn(最少连接,Nginx 自带)、fair / url_hash(第三方模块提供)。各算法的实测分流结果见《Django-应用水平扩展》第四节。
      • down:该节点暂时不参与分发。
      • backup:其它节点都不可用时才启用。
      • max_fails / fail_timeout:连续失败多少次、暂停多久,属于被动健康检查。
      • 注意:算法为 ip_hash 时,节点状态不能配 weight 和 backup。
      • 说明:worker_rlimit_nofile 触顶时新连接会报 502;log_format 定义好格式后要用 access_log 指定落盘路径(这两条来自官方文档,未在本机复现)。
    • 配置 location

      1
      2
      3
      4
      5
      location / {
      include uwsgi_params;
      uwsgi_pass project;
      uwsgi_param SERVER_NAME $server_name;
      }

      用 socket(uWSGI 协议)就必须是 uwsgi_pass;如果 uWSGI 侧开的是 http,这里要改成 proxy_pass,两者混用会 502。

    • 配置静态目录

      1
      2
      3
      location /static {
      alias /www/wwwroot/你的域名/static/;
      }
    • 注释宝塔 Nginx 配置

      注释宝塔Nginx配置

      注意:修改完记得保存,并先 nginx -t 再重载。

    • 重载 Nginx 配置

      重载Nginx配置

  9. 添加 SSL

    面板「SSL」页可以一键申请 Let’s Encrypt 证书并强制 HTTPS(面板会写跳转规则)。证书相关原理与 certbot 命令行方式见《Django-生产环境中的安全》第三节。

    添加SSL证书

  10. 启动项目

    1
    2
    3
    4
    5
    $ pip install -r requirements.txt
    $ pip install uwsgi
    $ chmod 750 start.sh stop.sh
    $ ./start.sh
    $ ps -aux | grep uwsgi
  11. 测试项目

    本地访问你的域名即可;如果 502,按「uWSGI 是否在跑 → nginx -t → 错误日志 → 静态目录权限」的顺序查。

三、推荐路线:Gunicorn + systemd + Nginx(全部实测)

这一节每一步都在 WSL2 上真跑过,输出贴在对应位置。

目录与虚拟环境

1
2
3
4
5
6
/srv/django/probe/               # 项目根
├── manage.py
├── proj/settings/{base,dev,prod}.py
├── proj/wsgi.py proj/asgi.py
├── deploy/gunicorn.conf.py
└── staticfiles/ # collectstatic 输出
1
2
$ python3 -m venv /srv/django/venv
$ /srv/django/venv/bin/pip install -r requirements.txt

Gunicorn 配置文件

deploy/gunicorn.conf.py
1
2
3
4
5
6
7
8
9
10
11
12
bind = "127.0.0.1:8000"
workers = 3
worker_class = "sync"
timeout = 30
graceful_timeout = 20
keepalive = 5
max_requests = 1000
max_requests_jitter = 100
accesslog = "-"
errorlog = "-"
loglevel = "info"
proc_name = "djdeploy"

启动后的实测日志:

1
2
3
4
5
6
[INFO] Starting gunicorn 26.2.0
[INFO] Listening at: http://127.0.0.1:8001 (4206)
[INFO] Booting worker with pid: 4208
[INFO] Booting worker with pid: 4209
[INFO] Booting worker with pid: 4210
[INFO] Control socket listening at /run/user/0/gunicorn.ctl

accesslog = "-" 表示把访问日志写到 stdout,交给 systemd 收集(见 3.4)。

systemd unit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[Unit]
Description=Django deploy probe (gunicorn)
After=network.target mysql.service

[Service]
Type=notify
User=www-data
Group=www-data
WorkingDirectory=/srv/django/probe
Environment="DJANGO_SETTINGS_MODULE=proj.settings.prod"
EnvironmentFile=/etc/djdeploy/env
ExecStart=/srv/django/venv/bin/gunicorn proj.wsgi:application -c deploy/gunicorn.conf.py
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

实测行为:

1
2
3
4
5
systemctl start djdeploy   -> active,Main PID: 4618 (gunicorn)
systemctl status -> Status: "Gunicorn arbiter booted"
curl http://127.0.0.1:8000/ -> 200
kill -9 其中一个 worker -> 服务仍 active,2 秒后进程数回到 4,日志出现新的 Booting worker
systemctl enable djdeploy -> Created symlink .../multi-user.target.wants/djdeploy.service

Type=notify 让 systemd 等 gunicorn 真正就绪才算启动成功;Restart=always 是崩溃自愈的关键;EnvironmentFile 放密钥(权限 600),不要把 SECRET_KEY 写进 unit 文件。

改代码后不中断服务:

1
$ sudo systemctl reload djdeploy      # 内部就是 kill -HUP 主进程

实测 HUP 后主进程 pid 不变、worker 全换:

1
2
3
4
[INFO] Worker exiting (pid: 4208)
[INFO] Worker exiting (pid: 4209)
[INFO] Worker exiting (pid: 4210)
[INFO] Booting worker with pid: 4258

日志交给 journald

1
2
3
4
$ journalctl -u djdeploy --no-pager -n 6
Nov 05 05:54:46 systemd[1]: Started djdeploy.service - Django deploy probe (gunicorn).
Nov 05 05:54:46 gunicorn[4619]: [INFO] Booting worker with pid: 4619
Nov 05 05:54:49 gunicorn[4619]: 127.0.0.1 - - [05/Nov/2026:05:54:49 +0800] "GET / HTTP/1.1" 200 346

访问日志、异常 traceback、systemd 的启停记录都在一处,比 daemonize=uwsgi.log 好查。

Nginx 反代

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
upstream django-app {
server 127.0.0.1:8000 max_fails=3 fail_timeout=30s;
keepalive 32;
}

server {
listen 80;
server_name recruit.example.com;

location /static/ {
alias /srv/django/probe/staticfiles/;
expires 30d;
add_header Cache-Control "public, immutable";
}

location / {
proxy_pass http://django-app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $http_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;
}
}

实测:

1
2
3
/static/site.css -> 200 165B 0.0024s
经 Nginx 访问动态页 -> 200
后端全挂时 -> 502,error.log: no live upstreams while connecting to upstream

从宝塔 + uWSGI 迁过来的注意点

  • uwsgi.ini 里的 processes/threads 换成 workers(Gunicorn 的 sync worker 模型没有线程维度,要线程用 gthread)。
  • Nginx 的 uwsgi_pass + include uwsgi_params 换成 proxy_pass,协议从 uWSGI 变成 HTTP。
  • start.sh/stop.sh 删掉,换成 systemctl start/stop/reload。
  • daemonize 日志文件删掉,改 accesslog="-" 交给 journald。
  • 面板里保留站点、SSL、Nginx 配置,只把「Python 项目」那一层换掉。

四、异步形态:Uvicorn(实测)

用到异步视图、WebSocket、async 相关能力时,ASGI 入口换成 uvicorn:

1
2
3
4
5
$ uvicorn proj.asgi:application --host 127.0.0.1 --port 8100 --workers 2
INFO: Uvicorn running on http://127.0.0.1:8100 (Press CTRL+C to quit)
INFO: Started parent process [4799]
INFO: Started server process [4802]
INFO: Started server process [4803]

实测并发(/slow/ 固定 0.2s):

1
2
3
4
uvicorn 2 worker   10 个并发墙钟 0.25s
uvicorn 1 worker 10 个并发墙钟 0.29s
gunicorn 3 sync 10 个并发墙钟 0.86s
gunicorn 1 sync 10 个并发墙钟 2.11s

单 worker 也能并发,是因为 Django 的同步视图在 ASGI 下由 asgiref 丢进线程池执行(实测那个进程里有 5 个线程)。两个连带影响:并发上限变成「worker × 线程池」,数据库连接数要重新算;另外 uvicorn 0.54.0 没有 --daemon 和 --pid-file 选项,后台运行请交给 systemd:

1
Error: No such option '--pid-file'. (Did you mean one of: '--env-file', '--ssl-keyfile'?)

也可以两者结合:Gunicorn 主进程管 worker,worker 用 uvicorn 的实现,在 gunicorn.conf.py 里写 worker_class = "uvicorn.workers.UvicornWorker"。

五、容器化部署(官方文档步骤,未在本机验证)

本机没有 Docker 运行时,下面这段没有实测,步骤以官方文档为准,端口和路径按你的项目改。

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
FROM python:3.12-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DJANGO_SETTINGS_MODULE=proj.settings.prod

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN adduser --disabled-password --no-home app && chown -R app:app /app
USER app

EXPOSE 8000
CMD ["gunicorn", "proj.wsgi:application", "-c", "deploy/gunicorn.conf.py"]

PYTHONUNBUFFERED=1 很关键,否则日志会被缓冲,docker logs 半天看不到东西。

docker-compose

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
services:
web:
build: .
env_file: .env
volumes:
- static_data:/app/staticfiles
depends_on:
- db
- redis

nginx:
image: nginx:1.28
ports:
- "80:80"
- "443:443"
volumes:
- ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- static_data:/srv/django/static:ro
depends_on:
- web

db:
image: mysql:8.4
environment:
MYSQL_PASSWORD_FILE: /run/secrets/db_pw
volumes:
- db_data:/var/lib/mysql

redis:
image: redis:8

volumes:
db_data:
static_data:

要点:

  • 静态资源用命名卷在 web 与 nginx 之间共享,web 启动前跑 collectstatic。
  • 迁移放在发布流程里单独执行(docker compose run web python manage.py migrate),不要塞进容器的启动命令,否则多副本会同时迁移。
  • 密钥用 env_file / secrets,不要写进镜像层。
  • 上传目录同样挂卷或换对象存储,否则容器重建即丢。

常见坑与排查顺序

现象 先看什么 实测/依据
502 Bad Gateway 后端进程在不在、nginx -t、错误日志 实测后端全停时 no live upstreams
400 Bad Request ALLOWED_HOSTS 有没有当前 Host 实测不在名单里直接 400
静态 404 collectstatic 跑没跑、alias 路径与权限 实测哈希名 site.b9bcd8def6c5.css
上传文件 413 Nginx client_max_body_size(默认 1m) 与 Django 的两个 *_MAX_MEMORY_SIZE 是不同层
页面时好时坏 多实例是否共享 session/缓存 实测 locmem 每个 worker 一份
时间差 8 小时 是否用数据库 Now() 写时间列 实测 MySQL 会话时区 SYSTEM
改了代码没生效 有没有 reload(uWSGI/Gunicorn 都要重启 worker) 实测 HUP 后 worker 换新 pid
端口暴露风险 socket/bind 是否写了 0.0.0.0 老笔记的 socket=0.0.0.0:8000 是隐患

排查顺序建议固定成四步:进程活着吗 → 端口监听在哪个地址 → 后端直连能不能通 → Nginx 错误日志怎么说。

1
2
3
4
$ systemctl status djdeploy
$ ss -ltnp | grep -E ":8000|:80"
$ curl -i http://127.0.0.1:8000/
$ tail -20 /var/log/nginx/error.log

参考

本文结束 感谢您的阅读
正在加载今日诗词....