Django 从入门到精通:知行博客实战(6.1) · 第 31 / 36 节

Docker 化与 Compose 编排

吴秀峰 13 次阅读 0 条评论 ↓

学习目标

构建应用镜像,组合 MySQL、Gunicorn、Nginx,区分镜像、容器和数据卷,完成一次可重复的本机生产形态部署。

为什么应用容器不应该保存唯一的数据副本

镜像保存代码和依赖,容器运行进程,卷保存数据库与媒体。重建镜像可以替换代码,但不应删除数据库和用户图片。Compose 将服务网络和启动依赖写成配置;数据库先健康,再运行迁移和静态收集,最后启动 Web。

Dockerfile 的构建阶段安装编译工具生成 wheel,运行阶段只留下必要库和依赖。应用使用普通 uid 10001;一次性 init 服务仅负责卷目录归属。数据库不映射宿主机端口,Web 只供内部 Nginx 访问,对外先绑定 127.0.0.1:8010,避免和本站 8001 冲突。

具体怎么做

安装并启动支持 Compose V2 的 Docker,执行 docker version 和 docker compose version。进入练习根目录,保存下面文件,然后:

cp .env.example .env
python -c "import secrets; print(secrets.token_urlsafe(48))"

把生成结果填入 .env 的 DJANGO_SECRET_KEY;再分别生成数据库应用密码和 root 密码,不复用。密钥至少 50 字符;Linux/macOS 执行 chmod 600 .env。这些值只保存在自己机器中,不提交或贴到讨论区。

docker compose config --quiet
docker compose build web
docker compose up -d --wait db
docker compose run --rm init
docker compose run --rm web python manage.py migrate --noinput
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

构建镜像需要下载基础镜像和依赖,请保证 Docker 能访问相应源。这里不自动导入本站 SQL、复制本站配置或迁移本站数据。若迁移自己的练习数据,用上一章的方案明确执行。

本章新增或替换的完整文件

以下路径均相对于练习项目根目录。替换同名文件的全部内容;未列出的文件保持上一章版本。

.dockerignore

.git
.venv
.env
.env.*
*.sqlite3
media
staticfiles
backups
__pycache__
**/__pycache__
*.pyc
certs

.env.example

# 仅供练习项目使用;复制为 .env 后填写自己的随机密码和密钥。
DJANGO_SECRET_KEY=replace-with-a-random-secret-at-least-50-characters
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
MYSQL_DATABASE=zhixing
MYSQL_USER=zhixing
MYSQL_PASSWORD=replace-with-a-random-database-password
MYSQL_ROOT_PASSWORD=replace-with-a-different-root-password
HTTP_PORT=8010
EMAIL_HOST=
EMAIL_PORT=587
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
DEFAULT_FROM_EMAIL=noreply@example.invalid
TLS_CERT_DIR=./certs

Dockerfile

FROM python:3.14-slim-bookworm AS builder
WORKDIR /build
RUN apt-get update && apt-get install -y --no-install-recommends build-essential pkg-config default-libmysqlclient-dev && rm -rf /var/lib/apt/lists/*
COPY requirements*.txt ./
RUN pip wheel --wheel-dir /wheels -r requirements-prod.txt

FROM python:3.14-slim-bookworm
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libmariadb3 && rm -rf /var/lib/apt/lists/*     && groupadd --gid 10001 app && useradd --uid 10001 --gid 10001 --no-create-home app
COPY --from=builder /wheels /wheels
COPY requirements*.txt ./
RUN pip install --no-index --find-links /wheels -r requirements-prod.txt && rm -rf /wheels
COPY --chown=10001:10001 . .
RUN mkdir -p media staticfiles && chown app:app media staticfiles
USER 10001:10001
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--threads", "2", "--timeout", "60", "--access-logfile", "-"]

compose.yaml

name: zhixing-tutorial
services:
  db:
    image: mysql:8.4
    environment:
      MYSQL_DATABASE: ${MYSQL_DATABASE:?请设置 MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER:?请设置 MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD:?请设置 MYSQL_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?请设置 MYSQL_ROOT_PASSWORD}
    command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
    volumes: ["database:/var/lib/mysql"]
    healthcheck:
      test: ["CMD-SHELL", "MYSQL_PWD=$$MYSQL_PASSWORD mysql -h127.0.0.1 -u$$MYSQL_USER $$MYSQL_DATABASE -e 'SELECT 1' >/dev/null 2>&1"]
      interval: 5s
      timeout: 5s
      retries: 40
    restart: unless-stopped
  init:
    image: zhixing-blog:6.1
    user: "0:0"
    entrypoint: ["sh", "-c", "chown 10001:10001 /app/media /app/staticfiles"]
    volumes: ["media:/app/media", "static:/app/staticfiles"]
  web:
    image: zhixing-blog:6.1
    build: .
    environment:
      DJANGO_SETTINGS_MODULE: config.production
      DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?请设置 DJANGO_SECRET_KEY}
      DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1}
      DB_ENGINE: mysql
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
      MYSQL_HOST: db
      EMAIL_HOST: ${EMAIL_HOST:-}
      EMAIL_PORT: ${EMAIL_PORT:-587}
      EMAIL_HOST_USER: ${EMAIL_HOST_USER:-}
      EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-}
      DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-noreply@example.invalid}
    volumes: ["media:/app/media", "static:/app/staticfiles"]
    depends_on:
      db: {condition: service_healthy}
      init: {condition: service_completed_successfully}
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/', timeout=5)"]
      interval: 15s
      timeout: 8s
      retries: 10
    restart: unless-stopped
  nginx:
    image: nginx:stable-alpine
    ports: ["127.0.0.1:${HTTP_PORT:-8010}:80"]
    volumes:
      - ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - static:/app/staticfiles:ro
      - media:/app/media:ro
    depends_on:
      web: {condition: service_healthy}
    restart: unless-stopped
volumes:
  database:
  media:
  static:

config/production.py

import os
os.environ["DJANGO_DEBUG"] = "0"
from .settings import *
if os.environ.get("DB_ENGINE") != "mysql":
    raise ImproperlyConfigured("本教程的生产配置要求独立 MySQL 数据库")
DEBUG = False

deploy/nginx.conf

limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
server {
    listen 80;
    server_name localhost;
    client_max_body_size 3m;
    add_header X-Content-Type-Options nosniff always;
    location /static/ { alias /app/staticfiles/; expires 7d; }
    location /media/ {
        alias /app/media/;
        add_header X-Content-Type-Options nosniff always;
        add_header Content-Security-Policy "default-src 'none'; sandbox" always;
    }
    location ~ /\. { deny all; }
    location /accounts/ {
        limit_req zone=auth burst=10 nodelay;
        limit_req_status 429;
        proxy_pass http://web:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    location / {
        proxy_pass http://web:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 65s;
    }
}

requirements-prod.txt

-r requirements-mysql.txt
gunicorn==26.2.0

如何确认成功

访问 http://127.0.0.1:8010/、/admin/、/health/,页面和 CSS 都正常。第一次生产形态数据库可能为空,这是正常状态;使用后台建立分类和文章。seed_demo 会拒绝 DEBUG=False 环境,防止误把演示数据写入运营库。

docker compose logs --tail=80 web nginx
docker compose exec -T web python manage.py migrate --check

练习:上传图片,执行 docker compose up -d --force-recreate web,确认文章和图片仍在。解释为什么不能用 docker compose down -v 作为日常重启命令:-v 会删除命名卷。

常见问题

web 健康但首页出错? 健康检查只验证数据库连接,必须先执行迁移。权限 denied? 检查 init 是否成功,不要用 chmod 777 绕过问题。.env 在 Python 中自动加载了吗? 没有,Compose 将其中选定变量注入容器。新依赖未生效? 需要重新 build,单纯 restart 不会重建镜像。

官方参考

把学习连起来

登录后可保存阅读位置、标记章节完成,并在其他设备继续学习。

登录并保存学习进度 →
联系站长

微信:VIPdjango。技术问题建议先在 社区讨论 中交流,方便更多人参与和查阅。

章节讨论 0