使用Django6.1开发博客(14) - 部署与收尾

功能已经比较完整,最后把项目放进真实运行环境。部署要收口开发阶段的假设。DEBUG 要关掉,密钥要交给环境,静态文件要有出口,进程要有健康检查。

https://static.xiongneng.me/deploy-runtime-20260926140000.png

公共请求先到 Nginx,TLS 和静态文件在这里结束;动态请求进入 Gunicorn,再交给 Django。应用数据、附件对象和缓存分开存放,任何一块出问题都不会把所有状态混在一起。

让配置跟着环境走

开发时保留 DEBUG=True 很方便,生产环境必须显式关闭。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import os

SECRET_KEY = os.environ.get(
    "DJANGO_SECRET_KEY",
    "django-insecure-local-development-key",
)
DEBUG = os.environ.get(
    "DJANGO_DEBUG",
    "true",
).lower() in {"1", "true", "yes"}

ALLOWED_HOSTS = [
    host
    for host in os.environ.get(
        "DJANGO_ALLOWED_HOSTS",
        "localhost,127.0.0.1",
    ).split(",")
    if host
]

默认值只服务本地开发。生产容器必须注入强随机 DJANGO_SECRET_KEY,并把真实域名放进 DJANGO_ALLOWED_HOSTS。

继续加入 CSRF 来源、静态根目录和非 DEBUG 下的安全项。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
STATIC_ROOT = BASE_DIR / "staticfiles"

CSRF_TRUSTED_ORIGINS = [
    origin
    for origin in os.environ.get(
        "DJANGO_CSRF_TRUSTED_ORIGINS",
        "",
    ).split(",")
    if origin
]

if not DEBUG:
    SECURE_SSL_REDIRECT = os.environ.get(
        "DJANGO_SECURE_SSL_REDIRECT",
        "true",
    ).lower() == "true"
    SESSION_COOKIE_SECURE = True
    CSRF_COOKIE_SECURE = True
    SECURE_HSTS_SECONDS = 31536000
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    SECURE_HSTS_PRELOAD = True

STATIC_ROOT 是 collectstatic 的目标目录。STATICFILES_DIRS 则指向项目里的源静态目录;前者存收集结果,后者存源文件。

Django 6.1 的 MAILERS 也要随环境变化。开发用 console,生产切换到 SMTP。

1
2
3
4
5
6
7
8
9
MAILERS = {
    "default": {
        "BACKEND": (
            "django.core.mail.backends.smtp.EmailBackend"
            if not DEBUG
            else "django.core.mail.backends.console.EmailBackend"
        ),
    },
}

DJANGO_DEBUG=false 后再执行部署检查,就能看到当前还有哪些安全项没收好。

1
uv run manage.py check --deploy

本项目的检查结果通过。

1
System check identified no issues (0 silenced).

健康检查尽量小

负载均衡和容器平台需要知道进程是否活着。给应用加一个最小接口。

1
2
3
4
from django.http import JsonResponse

def health(request):
    return JsonResponse({"status": "ok"})

根路由这样挂。

1
2
3
4
5
6
from health import health

urlpatterns = [
    path("healthz/", health, name="health"),
    # ...
]

健康接口不要查数据库、Redis 和 OSS。它回答的是 WSGI 进程能否响应请求。如果把这些依赖都塞进去,某个下游短暂变慢时,负载均衡可能把本来还能服务的应用全部摘掉。

测试写成下面这样。

1
2
3
4
5
6
7
8
from django.test import TestCase
from django.urls import reverse

class HealthTests(TestCase):
    def test_healthz_returns_ok(self):
        response = self.client.get(reverse("health"))
        self.assertEqual(response.status_code, 200)
        self.assertEqual(response.json(), {"status": "ok"})

真实浏览器访问 /healthz/,返回 JSON。

https://static.xiongneng.me/deployment-healthz-20260926082320.png

收集静态文件

生产模板里仍然写 {% static %},但静态文件最终要进入 STATIC_ROOT。

1
uv run manage.py collectstatic --noinput

本阶段收集了 132 个文件,包括项目 CSS 和 Django Admin 静态资源。

1
132 static files copied to '.../source/staticfiles'.

Nginx 直接服务这些文件,不要让 Gunicorn 每次都读 CSS、JS 和图标。

 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
server {
    listen 80;
    server_name blog.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name blog.example.com;

    ssl_certificate     /etc/letsencrypt/live/blog.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/blog.example.com/privkey.pem;

    client_max_body_size 20m;

    location /static/ {
        alias /opt/blog/source/staticfiles/;
        expires 30d;
        access_log off;
    }

    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;
    }
}

client_max_body_size 要大于图片上传限制。否则用户上传大图时,Nginx 会先返回 413,Django 根本没机会给出友好提示。

用 Gunicorn 运行

Gunicorn 加入依赖。

1
uv add gunicorn

本阶段安装的版本如下。

1
gunicorn==26.2.0

启动命令如下。

1
2
3
uv run gunicorn config.wsgi:application \
  --bind 127.0.0.1:8000 \
  --workers 3

Gunicorn 在 Windows 上不能运行,这是正常现象。它依赖 Unix 的 fcntl,部署目标是 Linux 服务器或容器。开发与测试仍在 Windows 上使用 runserver。

进程数不要拍脑袋。小型博客可以从 3 个 worker 开始,观察 CPU、内存和响应时间。worker 太少会排队,太多会挤占 SQLite 或 PostgreSQL 的连接能力。

用容器固定运行环境

source/Dockerfile 使用 uv 和 Python 3.14 基础镜像。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    UV_COMPILE_BYTECODE=1

WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

COPY . .

RUN uv run python manage.py collectstatic --noinput

EXPOSE 8000
CMD ["uv", "run", "--no-sync", "gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "3"]

--frozen 保证容器只用 uv.lock 中锁定的版本,不会在构建时意外解析出新包。构建阶段先复制依赖清单,再复制源码,能更好利用镜像层缓存。

环境变量放进 source/.env.example,真实值写在本机 .env。

1
2
3
4
5
6
DJANGO_DEBUG=false
DJANGO_SECRET_KEY=change-me-with-64-random-characters
DJANGO_ALLOWED_HOSTS=blog.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://blog.example.com
DJANGO_SECURE_SSL_REDIRECT=true
REDIS_URL=redis://127.0.0.1:6379/0

OSS 的五个变量也在里面。密钥不要写进源码,也不要复制进聊天记录。

数据库迁移要分两步看

部署前先检查迁移是否遗漏。

1
uv run manage.py makemigrations --check --dry-run

如果输出 No changes detected,说明模型和迁移一致。

然后在发布窗口执行下面这条命令。

1
uv run manage.py migrate

新迁移要先能兼容旧代码,再发布新代码;删除列、收紧约束这类不可逆变更要更谨慎。可以先把数据库结构准备好,再让新版本上线,旧代码在过渡期仍然能运行。

发布清单

这次收尾检查了五个部分。

1
2
3
4
5
6
7
8
uv lock --check
uv run manage.py check
uv run manage.py makemigrations --check --dry-run
uv run manage.py test
DJANGO_DEBUG=false \
DJANGO_SECRET_KEY=... \
DJANGO_ALLOWED_HOSTS=... \
uv run manage.py check --deploy

最终测试结果如下。

1
2
3
Ran 31 tests in 5.007s

OK

最后再收集静态文件。

1
uv run manage.py collectstatic --noinput

部署配置模板也放进 source/.env.example。真实服务器只复制模板结构,不复制任何真实密钥。

部署后留下什么

开发环境可以犯错,生产环境要少给机会。DEBUG 边界、密钥来源、静态文件出口、健康检查、Gunicorn 启动方式和容器定义都已经明确。数据库、OSS 和 Redis 仍可通过环境变量替换。

源码

GitHub 地址:https://github.com/yidao620c/simpleblog