Django 实战:做一个个人博客 · 第 8 / 8 篇(展开目录)

全系列用 Django 5.2 LTS 做一个个人博客:游客看文章,作者登录后写文章,管理员维护分类和账号。Django1—Django8 是文章序号,不是 Django 版本号。

  1. Django1:起步——安装、运行与项目结构
  2. Django2:配置与路由——一个请求在 Django 里怎么走
  3. Django3:模型与迁移——把博客的数据存进数据库
  4. Django4:ORM——查询、更新、统计与事务
  5. Django5:页面——视图、模板、搜索与分页
  6. Django6:表单与后台——让作者在网页上写文章
  7. Django7:登录与权限——谁能改哪篇文章
  8. Django8:上线与维护——测试、部署、备份与发布

下载可运行的示例项目(ZIP)

约 35 分钟 · 本篇较长,分为四部分,可以分次阅读

本篇目标: 让博客从“能在我电脑上跑”变成“能放心地长期运行”。

部分 内容 小节
一、测试 用测试守住草稿和权限规则 1–3
二、部署 容器化、PostgreSQL、Nginx、HTTPS 4–9
三、数据 从 SQLite 搬到 PostgreSQL 10
四、维护 日志、备份、恢复演练、发布 11–15

第一部分:测试

1. 先测什么

优先测试出错后会泄露数据或破坏权限的规则:

  • 草稿不能出现在列表、搜索和详情页;
  • 作者不能编辑或删除别人的文章;
  • 空表单要显示错误;
  • GET 请求不能真的删除数据;
  • 创建文章时标签要一起保存。

这些测试只关心“外部行为和数据结果”,所以以后重构视图或换写法,它们依然有效。

2. 测试代码

示例项目的 blog/tests.py 有 18 个测试(完整文件在示例 ZIP 中)。下面挑出最有代表性的几个,先看测试准备:

from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse

from .models import Category, Post, Tag


class BlogTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        User = get_user_model()
        cls.alice = User.objects.create_user(username="alice", password="TestOnly-Password-42")
        cls.bob = User.objects.create_user(username="bob", password="TestOnly-Password-43")
        cls.category = Category.objects.create(name="技术")
        cls.tag = Tag.objects.create(name="Django")
        cls.public = Post.objects.create(
            title="第一篇公开文章", slug="first-public-post", body="Django 学习记录",
            author=cls.alice, category=cls.category, status=Post.Status.PUBLISHED,
        )
        cls.public.tags.add(cls.tag)
        cls.draft = Post.objects.create(
            title="尚未公开的草稿", slug="private-draft", body="private-draft-marker",
            author=cls.alice,
        )

    def payload(self, **changes):
        data = {
            "title": "通过表单创建的文章", "slug": "new-post", "body": "正文内容",
            "category": self.category.pk, "tags": [self.tag.pk], "status": "draft",
        }
        data.update(changes)
        return data

setUpTestData() 为整个测试类准备一次数据;每个测试运行在独立的事务里,结束后自动回滚,互不影响。测试里的密码只属于测试数据库中的虚构账号。

草稿不泄露:

    def test_public_list_hides_drafts(self):
        response = self.client.get(reverse("blog:list"))
        self.assertContains(response, self.public.title)
        self.assertNotContains(response, self.draft.title)

    def test_search_does_not_leak_drafts(self):
        response = self.client.get(reverse("blog:list"), {"q": "private-draft-marker"})
        self.assertEqual(response.context["page_obj"].paginator.count, 0)

作者不能越权:

    def test_other_author_cannot_edit_or_delete(self):
        self.client.force_login(self.bob)
        for action in ["update", "delete"]:
            url = reverse(f"blog:{action}", args=[self.public.pk])
            self.assertEqual(self.client.get(url).status_code, 404)
            self.assertEqual(self.client.post(url, self.payload()).status_code, 404)
        self.assertTrue(Post.objects.filter(pk=self.public.pk).exists())

    def test_create_sets_current_author_and_saves_tags(self):
        self.client.force_login(self.alice)
        response = self.client.post(reverse("blog:create"), self.payload(author=self.bob.pk))
        self.assertRedirects(response, reverse("blog:mine"))
        post = Post.objects.get(slug="new-post")
        self.assertEqual(post.author, self.alice)
        self.assertEqual(list(post.tags.all()), [self.tag])

第二个测试故意提交了 author=bob,验证服务器会忽略它。

CSRF 真的生效:

    def test_csrf_token_is_required(self):
        client = Client(enforce_csrf_checks=True)
        client.force_login(self.alice)
        self.assertEqual(client.post(reverse("blog:create"), self.payload()).status_code, 403)
        client.get(reverse("blog:create"))
        token = client.cookies["csrftoken"].value
        response = client.post(reverse("blog:create"), self.payload(csrfmiddlewaretoken=token))
        self.assertEqual(response.status_code, 302)

测试客户端默认不检查 CSRF,所以这里要用 Client(enforce_csrf_checks=True)。

N+1 不会悄悄回来:

    def test_related_objects_need_three_queries(self):
        for i in range(4):
            post = Post.objects.create(title=f"关联查询测试 {i}", slug=f"related-{i}", body="正文", author=self.alice, category=self.category, status="published")
            post.tags.add(self.tag)
        from django.core.paginator import Paginator
        posts = Post.objects.filter(status="published").select_related("author", "category").prefetch_related("tags")
        with self.assertNumQueries(3):
            page = Paginator(posts, 10).get_page(1)
            for post in page:
                _ = post.author.username, post.category.name, [tag.name for tag in post.tags.all()]

谁删掉了 select_related,这个测试就会失败。

其余测试覆盖:登录跳转、POST 退出、删除确认、表单验证、HTML 转义、分页容错、删除分类保留文章、数据库拒绝非法状态。

force_login 与真实登录

force_login() 跳过密码校验,直接进入“已登录”状态,适合测业务逻辑。完整文件里另有一个测试真正提交用户名和密码,验证登录流程本身。

3. 运行测试和检查

python manage.py test -v 2
python manage.py check
python manage.py makemigrations --check --dry-run
命令 检查什么 期望结果
test 行为是否正确 末尾显示 OK
check 配置和模型有没有问题 无错误
makemigrations --check 是否忘了生成迁移 No changes detected

测试会自动创建一个独立的测试数据库,不碰你的开发数据。

在 PostgreSQL 上跑测试

Django 需要创建测试数据库的权限。不要为此给生产账号 blog_app 加 CREATEDB 权限,应该准备单独的测试数据库账号。

第二部分:部署上线

部署后的结构(第 1 篇提过):

浏览器 → 外层 Nginx(HTTPS)→ 内层 Nginx → Gunicorn → Django → PostgreSQL
                                 └──── /static/ 静态文件

服务器需要先安装 Docker Engine 和 Compose 插件(官方安装说明)。以下命令都在 manage.py 所在目录执行。

4. 准备环境变量

复制模板并收紧权限:

cp .env.example .env
chmod 600 .env
python -c 'import secrets; print(secrets.token_urlsafe(64))'

最后一条命令生成随机字符串。运行三次,分别填入 Django 密钥、数据库应用密码、数据库管理员密码:

DJANGO_DEBUG=0
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,blog.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://blog.example.com
DJANGO_HTTPS=0
DJANGO_TRUST_PROXY=0
DJANGO_HSTS_SECONDS=0
POSTGRES_DB=blog
POSTGRES_USER=blog_app
POSTGRES_PASSWORD=replace-with-a-random-database-password
POSTGRES_ADMIN_PASSWORD=replace-with-a-different-random-admin-password

把 blog.example.com 换成你的域名,replace-with-... 全部换成生成的随机值。.env 已列入 .gitignore,不会被提交。

.env 只给 Compose 用

直接运行 python manage.py ... 时不会读取 .env。本地开发不需要它。

5. 应用镜像:Dockerfile

FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
    && useradd --create-home --uid 10001 app
COPY --chown=app:app . .
RUN mkdir -p /app/staticfiles && chown app:app /app/staticfiles
USER app
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--access-logfile", "-", "--error-logfile", "-"]
  • 应用以普通用户 app 运行,不用 root。
  • Gunicorn 监听容器内的 8000 端口,日志输出到标准输出,方便 docker compose logs 查看。
  • 2 个 worker 是起点,按服务器内存和流量调整。

正式发布前,建议固定依赖版本(生成锁定文件)和镜像标签,并保留上一版镜像用于回退。

6. 编排服务:compose.yaml

name: django-blog-tutorial

services:
  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env}
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${POSTGRES_ADMIN_PASSWORD:?set POSTGRES_ADMIN_PASSWORD in .env}
      POSTGRES_APP_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env}
      POSTGRES_APP_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./deploy/init-db.sh:/docker-entrypoint-initdb.d/10-app.sh:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 5s
      retries: 12

  web:
    build: .
    restart: unless-stopped
    environment:
      DJANGO_DEBUG: "0"
      DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?set DJANGO_SECRET_KEY in .env}
      DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-127.0.0.1,localhost}
      DJANGO_CSRF_TRUSTED_ORIGINS: ${DJANGO_CSRF_TRUSTED_ORIGINS:-}
      DJANGO_HTTPS: ${DJANGO_HTTPS:-0}
      DJANGO_TRUST_PROXY: ${DJANGO_TRUST_PROXY:-0}
      DJANGO_HSTS_SECONDS: ${DJANGO_HSTS_SECONDS:-0}
      DB_ENGINE: postgres
      POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env}
      POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
      POSTGRES_HOST: db
      POSTGRES_PORT: "5432"
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - static_data:/app/staticfiles

  nginx:
    image: nginx:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:8088:80"
    volumes:
      - ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - static_data:/static:ro
    depends_on:
      - web

volumes:
  postgres_data:
  static_data:

四个值得注意的设计:

  1. 数据库不对外暴露端口,只有同一网络里的 web 能连。
  2. **网站只绑定 127.0.0.1:8088**,只能从本机访问,由外层代理接入公网。
  3. 数据和静态文件放在命名卷里,删除容器不会丢数据。
  4. web 等数据库健康检查通过后才启动——只写 depends_on 不等于数据库已经能接受连接。

数据库首次初始化时运行 deploy/init-db.sh,创建权限较小的应用账号:

#!/bin/sh
set -eu
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" \
  --set=app_user="$POSTGRES_APP_USER" \
  --set=app_password="$POSTGRES_APP_PASSWORD" \
  --set=db_name="$POSTGRES_DB" <<'SQL'
CREATE ROLE :"app_user" LOGIN PASSWORD :'app_password';
GRANT CONNECT ON DATABASE :"db_name" TO :"app_user";
GRANT USAGE, CREATE ON SCHEMA public TO :"app_user";
SQL

Django 用的 blog_app 不是超级用户,只能在这个库里建表和读写。

改 .env 里的密码不会改数据库

初始化脚本只在数据卷第一次创建时执行。之后修改 .env 里的数据库密码,还要进数据库用 ALTER ROLE 实际修改。

7. 内层 Nginx

deploy/nginx.conf:

# 内层仅监听容器网络;宿主机只发布 127.0.0.1:8088。
# 接入 HTTPS 后,只有可信外层代理能访问它,并覆盖 X-Forwarded-Proto。
map $http_x_forwarded_proto $original_scheme {
    default $scheme;
    https https;
}

server {
    listen 80;
    server_name _;
    client_max_body_size 2m;

    location /static/ {
        alias /static/;
        expires 1h;
    }

    location / {
        proxy_pass http://web:8000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $original_scheme;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

/static/ 直接从共享卷返回文件,其余请求转给 web:8000(web 是 Compose 里的服务名,容器之间可以用它互相访问)。

8. 第一次启动

docker compose build web
docker compose up -d --wait db
docker compose run --rm web python manage.py migrate
docker compose run --rm web python manage.py collectstatic --noinput
docker compose run --rm web python manage.py createsuperuser
docker compose up -d web nginx
docker compose ps
curl http://127.0.0.1:8088/healthz/

顺序是:构建 → 启动数据库并等它就绪 → 迁移 → 收集静态文件 → 创建管理员 → 启动网站。curl 返回 {"status": "ok"} 即成功;打开 /admin/ 应该有正常样式。

--wait 需要较新的 Compose v2。

9. 接入域名和 HTTPS

先把域名的 DNS 指向服务器,在外层代理上配置证书。如果服务器已经有 Nginx Proxy Manager、Traefik 等统一入口,就在那里新增一个站点,转发到 127.0.0.1:8088。

外层 Nginx 直接装在宿主机上、证书已经签发时,配置示例:

server {
    listen 80;
    server_name blog.example.com;
    return 301 https://blog.example.com$request_uri;
}

server {
    listen 443 ssl;
    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;

    location / {
        proxy_pass http://127.0.0.1:8088;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

外层代理在容器里时

127.0.0.1 指的是代理容器自己,不是宿主机。这种情况要把代理加入共享网络,转发到内层 Nginx 的服务名。

HTTPS 通了之后,修改 .env:

DJANGO_ALLOWED_HOSTS=blog.example.com
DJANGO_CSRF_TRUSTED_ORIGINS=https://blog.example.com
DJANGO_HTTPS=1
DJANGO_TRUST_PROXY=1
DJANGO_HSTS_SECONDS=3600

然后重新加载代理并重建容器(restart 不会读取新的环境变量):

sudo nginx -t
sudo systemctl reload nginx
docker compose up -d --force-recreate web nginx
docker compose run --rm web python manage.py check --deploy
curl -I https://blog.example.com/

check --deploy 会列出生产配置的安全建议,逐项理解后处理。HSTS 先用较短的时间(如 3600 秒)验证,确认证书续期没问题再加长。

第三部分:数据

10. 从 SQLite 搬到 PostgreSQL

切换数据库连接再运行 migrate,只会在新库里建表,不会把 SQLite 里的数据带过去。数据量小的博客可以先导出再导入:

# 当前终端不应设置 PostgreSQL 相关变量;这里明确选择 SQLite。
DB_ENGINE=sqlite python manage.py dumpdata auth.user auth.group blog \
  --natural-foreign --natural-primary --indent 2 --output blog-export.json

# 目标必须是新建且已完成初始化的 PostgreSQL 数据库。
docker compose run --rm web python manage.py migrate
docker compose run --rm -v "$PWD/blog-export.json:/tmp/blog-export.json:ro" \
  web python manage.py loaddata /tmp/blog-export.json
  • 导出前先停止写入;导入前不要在新库里手动创建同名用户或文章。
  • 这会搬运用户、用户组和博客数据;会话和后台操作日志不搬。
  • 导出文件里有用户名和密码哈希,用完后妥善保管或删除。

导入后核对用户数、文章数、标签关系,实际登录并抽查页面。

第四部分:维护

11. 看日志

docker compose ps
docker compose logs --tail 100 web
docker compose logs --tail 100 nginx
docker compose logs --tail 100 db

新建文章时,示例代码会记录 post_id 和 user_id,但不记录密码、Cookie 或完整的请求内容。

现象 从哪里查
连接超时 DNS、防火墙、外层代理
502 Nginx 连不上 web:Gunicorn 是否启动、是否崩溃
400 / DisallowedHost ALLOWED_HOSTS
403 CSRF Cookie、token、HTTPS 协议是否正确传递
404 路由,或对象不满足发布/权限条件
500 web 日志里的异常堆栈

/healthz/ 只说明应用活着——数据库挂了它照样返回 200。监控要区分“进程存活”和“能正常读写数据”。记得配置日志轮转,防止磁盘被日志写满。

12. 备份

SQLite:用备份接口,不要直接复制文件

正在写入的 db.sqlite3 直接复制可能得到损坏的副本。用 Python 自带的备份接口:

from datetime import datetime, timezone
from pathlib import Path
import sqlite3

backup_dir = Path("backups")
backup_dir.mkdir(mode=0o700, exist_ok=True)
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
target = backup_dir / f"blog-{stamp}.sqlite3"

source = sqlite3.connect("file:db.sqlite3?mode=ro", uri=True)
destination = sqlite3.connect(target)
try:
    source.backup(destination)
    result = destination.execute("PRAGMA integrity_check").fetchone()[0]
    if result != "ok":
        raise RuntimeError(result)
finally:
    destination.close()
    source.close()
target.chmod(0o600)
print(target)

以只读方式打开源库:路径写错时会直接报错,而不是悄悄新建一个空库。

PostgreSQL:pg_dump

mkdir -p backups
chmod 700 backups
umask 077
backup_stamp=$(date -u +%Y%m%dT%H%M%SZ)
backup_partial="backups/blog-${backup_stamp}.dump.partial"
backup_final="backups/blog-${backup_stamp}.dump"

if docker compose exec -T db pg_dump -U blog_app -d blog -Fc > "$backup_partial"; then
  mv "$backup_partial" "$backup_final"
  echo "备份完成:$backup_final"
else
  echo "备份失败,保留 .partial 文件供排查;不能把它作为有效备份。" >&2
fi
  • -Fc 生成自定义格式归档,之后用 pg_restore 恢复。
  • 先写 .partial,成功后才改名——避免把失败时产生的空文件当成备份。
  • pg_dump 只备份这一个数据库。.env、证书、镜像版本也要纳入备份计划,并且要有异地副本。

13. 恢复演练

没恢复过的备份不算备份

第一次恢复不要在生产库上做。

创建一个演练用的新库,把备份恢复进去:

docker compose exec db createdb -U postgres -O blog_app blog_restore_check
docker compose exec -T db pg_restore --list < backups/blog-20260922T010000Z.dump
docker compose exec -T db pg_restore \
  -U blog_app -d blog_restore_check --no-owner --no-acl --exit-on-error \
  < backups/blog-20260922T010000Z.dump

docker compose run --rm -e POSTGRES_DB=blog_restore_check web \
  python manage.py shell -c 'from blog.models import Post; print(Post.objects.count())'

把文件名换成你的真实备份。--list 只证明文件可读;真正导入并核对文章、用户、标签数量,才算验证了恢复。每次演练换一个新库名。

14. 发布新版本

下面是单机博客、接受短暂停机的发布流程:

  1. 在开发环境跑完测试,检查新迁移和依赖变化。
  2. 构建新镜像,记下当前版本以便回退。
  3. 暂停写入(或切到维护页),先备份。
  4. 停止旧应用,执行迁移、收集静态文件。
  5. 启动新版本,检查首页、登录、写文章和样式。
docker compose build web
docker compose stop nginx web
docker compose run --rm web python manage.py migrate --plan
docker compose run --rm web python manage.py migrate
docker compose run --rm web python manage.py collectstatic --noinput
docker compose run --rm web python manage.py check --deploy
docker compose up -d --force-recreate web nginx

每一步成功后再执行下一步。迁移失败时不要直接启动新版本。

代码回退 ≠ 数据回退

回退镜像不会恢复被删除的列和数据。破坏性的结构变更最好分几次发布,优先“向前修复”。

15. 常用命令速查

目标 命令
本地启动 python manage.py runserver
创建管理员 python manage.py createsuperuser
生成 / 执行迁移 makemigrations blog / migrate
预览迁移 python manage.py migrate --plan
进入 shell python manage.py shell
运行测试 python manage.py test
检查生产配置 python manage.py check --deploy
收集静态文件 python manage.py collectstatic --noinput
看容器日志 docker compose logs --tail 100 web
清理过期会话 python manage.py clearsessions

小心 docker compose down -v

down 会删除容器但保留数据卷;加上 -v 会连数据库数据一起删除。日常重启不要加 -v。

接下来可以学什么

  • 给文章加版本号,解决两人同时编辑的覆盖问题;
  • 用 PostgreSQL 全文检索替代 icontains 搜索;
  • 用 Django REST Framework 提供 API;
  • 支持图片上传,设计媒体文件的存储和访问规则;
  • 用任务队列处理发邮件等耗时操作。

每次扩展都从业务场景出发:先定义数据、权限和验收标准,再选技术方案。

小练习

1. 部署后后台页面没有样式,按什么顺序排查?
  1. 是否执行了 collectstatic;
  2. nginx 是否挂载了同一个 static_data 卷;
  3. 浏览器开发者工具里 /static/... 请求返回的状态码(404 看路径,502 看 Nginx)。
2. 改了 .env 里的 DJANGO_ALLOWED_HOSTS,执行 docker compose restart web 后没生效,为什么?

restart 重启的是原来的容器,环境变量是创建容器时写入的。要用 docker compose up -d --force-recreate web 重建容器。