docker-lessons.md 67 KB

Docker 容器化

术核 AI · 后端工程课件

01 镜像、容器与进程生命周期

从一个临时 Python 容器开始,观察进程退出、停止、重建与数据保留的区别。

本节目标

  • 区分镜像模板与运行中的容器
  • 找到容器进程、日志和退出状态
  • 判断哪些数据应写入卷而非容器可写层

必要概念

容器运行的是进程

镜像是文件系统和启动配置;容器是基于镜像创建的进程及其隔离环境。主进程退出,容器便停止,Docker 不会因为容器存在就持续执行应用。容器不是需要手工登录安装软件的长期虚拟机。

停止与删除不同

stop 发送停止信号并等待,start 重用原容器,rm 删除已停止容器的可写层。重新 run 创建另一份容器。镜像和显式命名卷有独立生命周期,重建 API 容器不应删除数据库卷。

本模块练习边界

使用 Docker Desktop 的 Linux 容器模式或独立 Linux 练习机,先完成 API+数据库模块的 notes-service。所有命令针对本地专用练习项目;不连接线上 Docker context,不挂载 Docker socket 到应用。未完成前置代码时,端点和 worker 不会自动存在。

动手实现

1. 核对 Docker 目标

执行下列只读命令,确认 context 指向本机练习环境,Linux 容器模式,Compose V2 可用。若无 Docker,应先按官方安装文档完成当前系统安装,不执行来源不明的一键脚本。

文件:终端 / 当前课时

docker context show
docker info --format '{{.OSType}}'
docker compose version
docker version

2. 运行一次性 Python 进程

该容器运行完 print 就退出,--rm 清理它自己的可写层。它没有数据库、持久卷或宿主目录挂载,不影响 notes 数据。

文件:终端 / 当前课时

docker run --rm python:3.11-slim python -c "import sys; print(sys.version)"

3. 观察持续运行的主进程

创建专用生命周期容器,查看它的日志和进程。这里只写容器内临时文件,接下来可以安全删除该练习容器。Windows PowerShell 可原样使用双引号内的 Python 代码。

文件:终端 / 当前课时

docker run -d --name notes-lifecycle python:3.11-slim python -u -c "import time; from pathlib import Path; Path('/tmp/probe.txt').write_text('container layer'); print('ready'); time.sleep(300)"
docker logs notes-lifecycle
docker top notes-lifecycle
docker inspect notes-lifecycle --format '{{.State.Status}}'

4. 停止、重启再查看文件

stop 后仍保留原容器文件层,start 重新运行启动命令。观察完再次 stop,并只删除名为 notes-lifecycle 的容器;不要用批量 prune 清理所有项目。

文件:终端 / 当前课时

docker stop --time 5 notes-lifecycle
docker start notes-lifecycle
docker exec notes-lifecycle cat /tmp/probe.txt
docker stop --time 5 notes-lifecycle
docker rm notes-lifecycle

5. 比较新容器的文件层

使用相同镜像创建新容器,它没有前一容器的 probe 文件。预期 False 不代表镜像损坏,而是临时写入没有进入镜像或持久卷。

文件:终端 / 当前课时

docker run --rm python:3.11-slim python -c "from pathlib import Path; print(Path('/tmp/probe.txt').exists())"

验收结果

  • docker info 的 OSType 为 linux,context 明确指向练习环境。
  • 能够观察主进程退出导致容器停止,日志可独立读取。
  • 新容器中没有上一容器临时文件,能说明卷的必要性。

生产约束与故障演练

开发者需要交付能在另一台机器启动的服务,而非一份手工配置记录。

实现决策

  • 以主进程状态判断服务是否运行。
  • 只清理明确命名的练习容器,持久数据单独管理。

隔离环境演练

停止并删除无挂载的生命周期容器,再从同一镜像创建,观察临时文件消失。

恢复验收

  • 能解释进程、可写层、镜像与卷各自的生命周期。
  • 所有操作目标都可通过 context 和容器名定位。

常见错误

docker run 很快结束,以为容器启动失败

原因:启动命令只是一个会完成的脚本。

处理:查看 docker logs 与退出码,服务需要持续监听进程,而非用 tail 掩盖真实服务退出。

进入容器 pip install 后重建又缺包

原因:安装发生在容器临时文件层。

处理:将依赖写进 Dockerfile 与锁定文件,通过重建镜像保留。

扩展练习

镜像没有变化,为何两个容器的文件可能不同?

提示:启动配置相同不代表运行时写入相同。

参考答案:

容器各自拥有可写层和进程状态;环境变量、挂载卷和启动命令也可能不同。把需要复现的软件放进镜像,把需要保留的业务数据放进明确的持久存储。

技术文档


02 为 FastAPI 编写非 Root Dockerfile

把 notes-service 源码与固定依赖构建进镜像,以非 root 用户启动 Uvicorn。

本节目标

  • 编写最小可运行 Dockerfile 和 .dockerignore
  • 用锁定依赖避免不同机器安装漂移
  • 以 exec 形式运行非 root 应用进程

必要概念

构建上下文决定哪些文件可被复制

docker build 的最后一个路径是构建上下文,COPY 只能读取其内容。把 .env、备份、虚拟环境排除可减少构建大小并避免秘密进入镜像;在后续 RUN 删除秘密无法抹去早期镜像层。

运行用户与监听地址

容器内应用使用固定非 root UID,监听 0.0.0.0:8000 才能接收容器网卡流量。宿主发布到 127.0.0.1:8000 限制外部访问。非 root 不等于完整隔离,但能减少意外写入和权限范围。

进程命令

JSON 数组形式 CMD 直接运行应用进程,停止信号可以到达 Uvicorn;不要用 shell 后台启动加无限 sleep。生产镜像不启用 --reload,日志写 stdout/stderr,数据库迁移独立运行。

动手实现

1. 建立目标平台锁定依赖

在 notes-service 新建 requirements.in,采用下列主依赖。已有业务的额外依赖应一起加入。用目标 Linux/Python3.11 的构建环境生成 requirements-lock.txt;不要把 Windows 专有库锁直接用于 Linux。下一课给出容器内生成命令。

文件:requirements.in

fastapi>=0.121,<1
uvicorn[standard]>=0.30,<1
pydantic-settings>=2,<3
SQLAlchemy>=2.0,<2.1
psycopg[binary]>=3,<4
alembic>=1.13,<2
httpx>=0.27,<1
celery[redis]>=5.4,<6

2. 新建忽略文件

Docker 构建不需要本机 .env、虚拟环境、数据库转储和 Git 历史。保留 .env.example 可供文档参考,但本课 Dockerfile 只显式 COPY 应用必需文件。

文件:.dockerignore

.git
.venv
.env
.env.*
secrets/
backups/
*.dump
*.sql.gz
__pycache__/
.pytest_cache/
tests/

3. 新建 Dockerfile

依赖完整 app 目录、alembic.ini、migrations 与生成的 requirements-lock.txt。Python slim 基础版本下一课固定为实际 digest;此处标签用于首次学习构建。COPY 不复制本机配置。

文件:Dockerfile

FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
COPY requirements-lock.txt ./
RUN python -m pip install --no-cache-dir --require-hashes -r requirements-lock.txt     && groupadd --gid 10001 notes     && useradd --uid 10001 --gid notes --no-create-home --shell /usr/sbin/nologin notes
COPY app ./app
COPY alembic.ini ./
COPY migrations ./migrations
USER 10001:10001
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "25"]

4. 构建并核对镜像用户

完成下一课锁文件生成后在项目根目录构建。此检查不需要数据库配置,因为 python -c 直接执行,没有导入 app。用户 id 应为 10001。

文件:终端 / 当前课时

docker build -t notes-service:lab-01 .
docker run --rm notes-service:lab-01 python -c "import os; print(os.getuid())"
docker image inspect notes-service:lab-01 --format '{{.Config.User}} {{json .Config.Cmd}}'

5. 通过练习网络配置启动服务

本课暂不启动完整服务,先验证代码和依赖存在;第 4 课 Compose 会提供 postgres DNS 与 DATABASE_URL。直接运行镜像若缺 DATABASE_URL 应明确失败,这比默认连错库更容易排错。

文件:终端 / 当前课时

docker run --rm notes-service:lab-01 python -c "import fastapi, sqlalchemy, psycopg; print(fastapi.__version__, sqlalchemy.__version__, psycopg.__version__)"
docker run --rm notes-service:lab-01 python -c "from pathlib import Path; print(Path('/app/alembic.ini').exists(), Path('/app/migrations/env.py').exists())"

验收结果

  • 镜像运行用户为 10001,不需要 --privileged。
  • 镜像包含应用与迁移文件,不含 .env 和备份。
  • CMD 直接启动 Uvicorn,未启用 reload;依赖版本能在容器内打印。

生产约束与故障演练

应用镜像交给部署机器运行,需要固定依赖与明确的低权限进程。

实现决策

  • 运行时依赖装在构建阶段,秘密仅运行时注入。
  • 只复制需要的目录,非 root 启动。

隔离环境演练

在构建上下文放一个无敏感内容的 .env 练习标记,构建后确认 /app/.env 不存在。

恢复验收

  • 镜像内没有本机配置标记。
  • 容器用户和启动命令可审计。

常见错误

容器日志显示已启动,宿主访问不到

原因:Uvicorn 只监听容器回环地址 127.0.0.1。

处理:容器内监听 0.0.0.0,宿主端口发布是否公开由 ports 绑定地址控制。

pip --require-hashes 拒绝安装

原因:锁文件不是带完整依赖与哈希的锁文件。

处理:按下一课用 pip-tools 生成,不能去掉哈希选项假装已经固定依赖。

扩展练习

为什么不直接 COPY . /app?

提示:检查构建上下文中的本机配置与数据。

参考答案:

显式 COPY 应用、迁移和锁文件能缩小误带入范围;.dockerignore 再提供过滤。COPY . 并非语法错误,但需要更严格控制上下文,新手项目容易把秘密、缓存和备份带进去。

技术文档


03 分层缓存、多阶段构建与可重复输入

将依赖构建和运行文件分开,固定基础镜像与 Python 包,让构建结果可以追溯。

本节目标

  • 用目标平台生成带哈希依赖锁
  • 理解 COPY 顺序对构建缓存的影响
  • 以多阶段构建移除构建工具并记录镜像摘要

必要概念

缓存按层失效

先 COPY 依赖锁再安装,最后 COPY 源码,业务代码修改通常不会让依赖安装层失效。缓存提升速度,不代表输入自动更新;基础镜像和锁文件更新仍需显式执行与测试。

标签与摘要

python:3.11-slim 标签可能随安全更新移动,digest 指向具体镜像内容。使用真实查询到的 digest 可固定基础镜像;固定也意味着必须有主动更新流程,不能永久停留在旧漏洞版本。

多阶段的边界

builder 创建虚拟环境,runtime 只复制环境与应用代码。二者使用相同基础镜像和架构,避免复制不兼容的本地扩展。哈希锁、基础 digest 和目标平台稳定输入;如果要求字节级相同镜像,还需控制构建时间戳与工具链,不能仅靠 Dockerfile 保证。

动手实现

1. 在一次性 Linux 容器生成锁文件

以下为 Bash/WSL 在 notes-service 根目录执行;PowerShell 使用 ${PWD} 对应绝对路径替换 $(pwd)。pip-tools 安装只发生在临时构建工具容器。生成后审阅锁定变更并纳入代码管理。

文件:终端 / 当前课时

docker run --rm --mount type=bind,source="$(pwd)",target=/src -w /src python:3.11-slim sh -c 'python -m pip install "pip-tools>=7,<8" && python -m piptools compile --generate-hashes --output-file requirements-lock.txt requirements.in'

2. 查询实际基础摘要

执行 pull 后查看 RepoDigests,将输出中的完整 python@sha256:... 记录到本机构建变量 PYTHON_IMAGE。不要复制课件中的占位摘要。多架构构建需明确目标平台并记录选择。

文件:终端 / 当前课时

docker pull python:3.11-slim
docker image inspect python:3.11-slim --format '{{json .RepoDigests}}'
# Bash / WSL:选择并保存刚拉取镜像的完整仓库摘要。
PYTHON_IMAGE=$(docker image inspect python:3.11-slim --format '{{index .RepoDigests 0}}')
case "$PYTHON_IMAGE" in python@sha256:*) export PYTHON_IMAGE ;; *) echo 'unexpected image digest'; exit 1 ;; esac

3. 替换 Dockerfile 为多阶段版本

用本段完整替换第 2 课 Dockerfile。PYTHON_IMAGE 的默认标签供练习,发布构建必须通过 --build-arg 传已记录的 digest;builder 与 runtime 共用同一值。psycopg[binary] 避免本课额外引入编译工具。

文件:Dockerfile

ARG PYTHON_IMAGE=python:3.11-slim
FROM ${PYTHON_IMAGE} AS builder
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /build
RUN python -m venv /opt/venv
COPY requirements-lock.txt ./
RUN /opt/venv/bin/python -m pip install --no-cache-dir --require-hashes -r requirements-lock.txt

FROM ${PYTHON_IMAGE} AS runtime
ENV PATH="/opt/venv/bin:$PATH" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN groupadd --gid 10001 notes     && useradd --uid 10001 --gid notes --no-create-home --shell /usr/sbin/nologin notes
WORKDIR /app
COPY --from=builder /opt/venv /opt/venv
COPY app ./app
COPY alembic.ini ./
COPY migrations ./migrations
USER 10001:10001
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--timeout-graceful-shutdown", "25"]

4. 构建并观察缓存

在同一 Bash / WSL 终端使用上一阶段取得并验证的 PYTHON_IMAGE;新开终端时重新从已拉取镜像读取摘要。变量为空会在构建前停止。第一次完成后仅修改 app 中一个无副作用注释,再构建,依赖层应显示 CACHED。

文件:终端 / 当前课时

docker build --progress=plain --build-arg PYTHON_IMAGE="${PYTHON_IMAGE:?set verified base digest}" -t notes-service:lab-02 .
docker history notes-service:lab-02
docker image inspect notes-service:lab-02 --format '{{.Id}}'

5. 记录构建证据与更新路径

创建 build-record.md,记录下面字段。依赖升级时在分支重新生成锁、构建新镜像并运行数据库测试,再发布;不能在运行容器内 pip install -U 修补并忘记回写镜像。

文件:build-record.md

源码提交:实际 git commit SHA
目标平台:例如 linux/amd64
基础镜像:实际 python@sha256:...
依赖锁:requirements-lock.txt 的版本/摘要
应用镜像:本机 image ID;推送后补仓库 digest
验证结果:执行命令、实际输出、未验证项

验收结果

  • requirements-lock.txt 包含递归依赖的固定版本与哈希,容器中 --require-hashes 安装通过。
  • 只改应用源码时依赖层命中缓存。
  • 构建记录能关联源码、基础镜像、依赖锁与最终镜像。

生产约束与故障演练

同一版本需要在预发布和线上运行相同的软件输入。

实现决策

  • 构建一次,按同一镜像摘要提升到下一个环境。
  • 构建工具留在 builder,运行层只包含运行所需文件。

隔离环境演练

修改源码与依赖锁分别构建,记录哪几层重建;还原后核对镜像关联记录。

恢复验收

  • 能解释缓存结果。
  • 依赖升级产生新的可追溯发布物。

常见错误

复制本机 .venv 后容器启动找不到动态库

原因:宿主系统或架构与容器不一致。

处理:在目标 Linux 基础镜像内创建环境,builder/runtime 保持相同平台与 ABI。

固定基础摘要后长期没有安全更新

原因:把固定输入误当作无需维护。

处理:定期更新摘要和依赖锁,在测试通过后产生新发布版本,保留变更记录。

扩展练习

--no-cache 是否保证获取最新基础镜像?

提示:构建缓存与拉取基础镜像是两件事。

参考答案:

不保证。--pull 会检查标签的新内容;但如果固定 digest,应先审阅并选择新 digest,再更新构建参数。盲目拉最新也不等于通过了应用兼容测试。

技术文档


04 Compose 网络、服务 DNS 与端口

把 API、PostgreSQL 和 Redis 放入同一专用网络,区分容器内地址和宿主调试端口。

本节目标

  • 通过服务名连接 PostgreSQL 与 Redis
  • 绑定本机调试端口并避免冲突
  • 用 Compose 配置检查和容器内探针定位连通性

必要概念

localhost 指当前容器

API 中 127.0.0.1 指向 API 自己,不能到 PostgreSQL。Compose 默认网络提供 postgres 和 redis 服务名解析;容器间连接使用 postgres:5432 与 redis:6379,宿主调试使用 127.0.0.1:5433 与 127.0.0.1:6380。

端口发布的方向

ports 的左侧是宿主绑定,右侧是容器监听。容器间通信不依赖宿主端口映射;数据库不需要被公网发布。EXPOSE 是镜像元信息,也不会自动开放宿主端口。

项目隔离

Compose project name 区分同机多个练习栈的网络与卷;不要设置固定 container_name,它会阻碍扩容并造成跨项目名字冲突。示例数据库为 notes_lab,app_user 是普通应用角色,postgres 管理账号不进入 API。

动手实现

1. 新建 Compose 专用环境文件

复制下列模板为 .env.compose 并填写两个不同的本机练习密码。文件不提交,后续统一命令显式 --env-file .env.compose;它用于 Compose 插值,不覆盖先前本机 Python 使用的 .env。连接串密码有特殊字符需 URL 编码。

文件:.env.compose

COMPOSE_PROJECT_NAME=notes-lab
DB_ADMIN_PASSWORD=CHANGE_ADMIN_PASSWORD
APP_DB_PASSWORD=CHANGE_APP_PASSWORD
DATABASE_URL=postgresql+psycopg://app_user:CHANGE_APP_PASSWORD@postgres:5432/notes_lab
REDIS_URL=redis://redis:6379/0
NOTES_IMAGE=notes-service:lab-02

2. 新建初始化脚本

新建 deploy/init/01-app.sh,保存 LF 换行。postgres 官方镜像在空数据目录首次初始化时执行脚本;后续改环境变量不会自动修改角色密码。psql 变量替换用 SQL 字符串引用处理密码,禁止直接字符串拼 SQL。练习 app_user 拥有 public schema 的 DDL 权限,生产迁移权限下一阶段单独拆分。

文件:deploy/init/01-app.sh

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

3. 新建 compose.yaml

本段创建 API、Postgres、Redis 与独立卷;Redis 此时只是连通性练习,不承诺任务持久化。宿主数据库端口与数据库模块一致。API 初次启动可能尚未建表,第 6 课加入迁移和健康依赖。

文件:compose.yaml

services:
  api:
    image: ${NOTES_IMAGE:?set NOTES_IMAGE}
    environment:
      DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}
      REDIS_URL: ${REDIS_URL:?set REDIS_URL}
    ports: ["127.0.0.1:8000:8000"]
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: notes_lab
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${DB_ADMIN_PASSWORD:?set admin password}
      APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set app password}
    ports: ["127.0.0.1:5433:5432"]
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./deploy/init:/docker-entrypoint-initdb.d:ro
  redis:
    image: redis:7.4
    ports: ["127.0.0.1:6380:6379"]
volumes:
  pg_data:

4. 检查配置并启动依赖

如果上一模块已经占用 5433 或 6380,先确认属于哪个独立练习栈,选择复用同一栈或停止其对应服务,不能直接杀掉未知进程。config --quiet 检查语法,不把展开后的密码配置贴进聊天或日志。

文件:终端 / 当前课时

docker compose --env-file .env.compose config --quiet
docker compose --env-file .env.compose up -d postgres redis
docker compose --env-file .env.compose ps
docker compose --env-file .env.compose logs --tail 50 postgres

5. 从 API 网络位置检测服务 DNS

run --rm 临时容器共享项目网络,使用 Python socket 验证 DNS 和 TCP 端口。成功只说明网络可达,不能证明账号权限或表迁移完成。

文件:终端 / 当前课时

docker compose --env-file .env.compose run --rm --no-deps api python -c "import socket; print(socket.gethostbyname('postgres')); socket.create_connection(('postgres',5432),3).close(); socket.create_connection(('redis',6379),3).close(); print('tcp ok')"
docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SELECT rolname, rolsuper FROM pg_roles WHERE rolname='app_user';"

验收结果

  • API 容器可解析 postgres/redis,并连接内部 5432/6379。
  • 宿主端口仅绑定 127.0.0.1 的 8000、5433、6380。
  • app_user 的 rolsuper 为 false;管理账号密码未注入 API。

生产约束与故障演练

同机运行多个后端依赖,地址与端口必须在容器内外保持清楚。

实现决策

  • 应用用 DNS 服务名,不固定容器 IP。
  • 数据库调试端口仅回环绑定,发布环境删去不必要端口。

隔离环境演练

只在练习 .env.compose 把主机名改为不存在名称,观察 DNS 错误后恢复,区分认证失败与网络失败。

恢复验收

  • 能够按 DNS、TCP、认证、SQL 四层定位。
  • 不同 Compose 项目资源有明确命名。

常见错误

API 连接 127.0.0.1:5433 被拒绝

原因:把宿主访问地址用于容器内部。

处理:容器 DATABASE_URL 使用 postgres:5432;宿主 psql 才使用本机 5433。

修改 APP_DB_PASSWORD 后认证仍失败

原因:初始化脚本只在空数据目录执行,已有角色密码未改变。

处理:通过受控 SQL 轮换角色密码并同步应用秘密;不能删除卷来修复认证。

扩展练习

为了让 API 访问数据库,是否必须发布 5433?

提示:Compose 服务互通走内部网络。

参考答案:

不需要。删去 postgres 的 ports 后,API 仍能使用 postgres:5432;宿主 psql 失去直接端口入口,可通过 docker compose exec 调试。线上通常不发布数据库宿主端口。

技术文档


05 PostgreSQL 持久卷与可恢复备份

验证重建数据库容器后数据仍存在,在独立恢复容器中验证逻辑备份。

本节目标

  • 区分 named volume 与 bind mount 的权限责任
  • 验证容器重建不丢 PostgreSQL 数据
  • 制作并恢复逻辑备份,记录恢复证据

必要概念

卷保存数据库目录

PostgreSQL 16 官方镜像的本课持久路径为 /var/lib/postgresql/data。命名卷由 Docker 管理宿主位置,容器删除不会自动删除命名卷。不要把卷当作备份,宿主磁盘故障和误删仍会影响它。

权限与一致性

宿主 bind mount 需要匹配数据库用户 UID/GID 与权限,直接 chmod 777 不是解决方案。运行中的 PGDATA 不能随手复制成可靠备份;用 pg_dump 做逻辑备份,物理备份需 pg_basebackup/WAL 等一致性协议。

恢复才验证备份

备份成功退出只能证明生成了文件,不能证明恢复后应用能使用。恢复演练要用另一容器和另一卷,核对表、行数和业务查询;备份保留策略还需异机、加密、访问控制与实际 RPO/RTO。

动手实现

1. 确认卷与数据路径

只读检查 Compose 解析到的数据库挂载。取得容器 id 后可 inspect Mounts;不进入 Docker 内部卷目录手工改权限或编辑文件。

文件:终端 / 当前课时

docker compose --env-file .env.compose ps -q postgres
docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SHOW data_directory;"
docker volume ls --filter label=com.docker.compose.project=notes-lab

2. 创建专用持久化标记并重建容器

在练习库创建一张明确命名的验证表,重建仅 postgres 容器,保留 pg_data 卷。执行后查询同一行。正常生产更新仍要评估可用性与数据库版本兼容。 重建后先执行 pg_isready,返回 accepting connections 后再查询标记;尚未就绪时等待后重试,不把首次连接失败当作卷丢失。

文件:终端 / 当前课时

docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "CREATE TABLE IF NOT EXISTS volume_probe (id integer PRIMARY KEY, value text NOT NULL); INSERT INTO volume_probe VALUES (1,'survives recreate') ON CONFLICT (id) DO UPDATE SET value=EXCLUDED.value;"
docker compose --env-file .env.compose up -d --force-recreate postgres
docker compose --env-file .env.compose exec postgres pg_isready -U postgres -d notes_lab
# 上一步返回 accepting connections 后继续;否则等待后重试。
docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SELECT * FROM volume_probe;"

3. 生成备份并从容器复制

先在容器内生成自定义格式备份,再 docker compose cp,避免旧版 Windows PowerShell 重定向二进制流时损坏文件。备份含业务数据,放到已忽略的 backups 目录;不上传到公开资源。

文件:终端 / 当前课时

mkdir backups
docker compose --env-file .env.compose exec postgres pg_dump -U postgres -d notes_lab -Fc --no-owner --no-acl -f /tmp/notes_lab.dump
docker compose --env-file .env.compose cp postgres:/tmp/notes_lab.dump ./backups/notes_lab.dump
docker compose --env-file .env.compose exec postgres pg_restore --list /tmp/notes_lab.dump

4. 创建独立恢复容器

新建 .env.restore,只含 POSTGRES_PASSWORD=独立练习密码,加入 .gitignore。恢复容器不发布端口,使用单独 named volume;docker exec 从同容器 Unix socket 操作。等待日志 ready 后再恢复,不复用业务 pg_data。

文件:终端 / 当前课时

docker volume create notes-restore-data
docker run -d --name notes-restore --env-file .env.restore -e POSTGRES_DB=notes_restore -v notes-restore-data:/var/lib/postgresql/data postgres:16
docker logs --tail 30 notes-restore
docker cp ./backups/notes_lab.dump notes-restore:/tmp/notes_lab.dump
docker exec notes-restore pg_restore -U postgres -d notes_restore --no-owner --no-acl --exit-on-error /tmp/notes_lab.dump

5. 核对恢复并保留证据

本课尚未执行第06课的应用迁移,因此只核对已创建的 volume_probe,不查询尚不存在的 notes 或 alembic_version。恢复标记应为 id=1、value=survives recreate。完成第06课迁移并创建笔记后,再重复备份和恢复流程;使用新的恢复数据库或新的专用恢复卷,避免把第二份备份叠加到非空恢复库,再比较 notes 数量和 alembic_version。此时才完成业务数据恢复验收。当前先停止恢复容器,保留恢复卷用于核查。

文件:终端 / 当前课时

docker exec notes-restore psql -U postgres -d notes_restore -c "SELECT * FROM volume_probe;"
docker stop notes-restore
# 第06课迁移、创建笔记并重新备份到新的隔离恢复库后,再在新恢复库查询:
# SELECT count(*) FROM notes;
# SELECT version_num FROM alembic_version;

验收结果

  • 重建 postgres 容器后 volume_probe 行仍存在。
  • pg_restore --list 可读取备份结构,恢复命令退出为 0。
  • 独立恢复库包含正确的 volume_probe,原练习库未被覆盖;业务表恢复在第06课迁移后用新备份补验。

生产约束与故障演练

镜像升级必须保留数据库,宿主损坏后也应有可恢复副本。

实现决策

  • 容器和数据库卷独立管理。
  • 备份在独立环境恢复后才计入可用备份。

隔离环境演练

重建练习数据库容器,再把备份恢复到另一卷并核对 volume_probe。完成第06课迁移后,另取新备份恢复到新的空恢复库,补验业务行数和迁移版本。

恢复验收

  • 重建不丢数据。
  • 有一次完整备份恢复的实际记录。

常见错误

重建容器后数据全没了

原因:未挂载持久卷、挂载路径错误,或切换了 Compose project name。

处理:检查 Mounts 和卷标签,确认实际使用的 pg_data;不要在未确认旧卷前初始化覆盖或删除。

恢复时报 archive 格式错误

原因:用不支持二进制透明传输的 Shell 重定向生成备份。

处理:在容器内写 -f 文件,再 docker cp;检查备份大小和 pg_restore --list。

扩展练习

每天把 PGDATA 目录复制到同一宿主另一个文件夹能否替代备份方案?

提示:分别考虑运行中一致性与宿主故障。

参考答案:

不能。普通文件复制可能得到不一致数据库,而且宿主故障会同时损坏原件与副本。使用数据库支持的备份协议,并保留异机副本、加密密钥与定期恢复证据。

技术文档


06 健康检查、启动重试与一次性迁移

让 API 等待数据库可用与迁移成功,同时处理运行期间的依赖故障。

本节目标

  • 区分容器运行、健康、应用就绪与迁移完成
  • 用 Compose 条件依赖组织首次启动
  • 为瞬时断连设计有界重试而不重放未知写事务

必要概念

running 不是 ready

容器进程创建后,Postgres 可能仍在初始化,API 可能缺表。pg_isready 证明数据库接受连接,不验证 app_user 凭据或业务表。应用 /ready 执行 SQL,迁移作业另行证明表版本已到目标。

启动顺序只覆盖启动

depends_on condition 控制 Compose 启动前置条件,不能保证依赖永远在线。运行时数据库重启仍会引发请求失败,连接池需检测失效连接,调用方需按幂等规则重试。

迁移是独立作业

一个 migrate 容器执行 alembic upgrade head,退出码 0 才允许 API 启动。不让多个 API worker 各自执行迁移。迁移失败保留日志与旧应用,不能循环忽略失败继续接流量。

动手实现

1. 增加数据库和 Redis 健康检查

把以下字段分别合并到 compose.yaml 的 postgres 与 redis 服务内,不新建同名重复服务。$$ 让变量传入容器 Shell 后展开,避免 Compose 提前替换。

文件:compose.yaml

# postgres 服务内
healthcheck:
  test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
  interval: 5s
  timeout: 3s
  retries: 12
  start_period: 10s

# redis 服务内
healthcheck:
  test: ["CMD", "redis-cli", "ping"]
  interval: 5s
  timeout: 2s
  retries: 5

2. 新增一次性迁移服务

合并到 services 层,镜像与 API 完全相同。练习迁移使用 app_user 的 DDL 权限;生产用单独迁移凭据覆盖此服务的 DATABASE_URL,API 保持更小权限。

文件:compose.yaml

migrate:
  image: ${NOTES_IMAGE:?set NOTES_IMAGE}
  command: ["python", "-m", "alembic", "upgrade", "head"]
  environment:
    DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}
  depends_on:
    postgres:
      condition: service_healthy
  restart: "no"

3. 为 API 添加就绪探针与条件依赖

合并到已有 api 服务。urllib 已在 Python 标准库,无需额外安装 curl。/ready 是前置模块实现的数据库探针;用户自定义应用须先实现同等端点。start_period 不屏蔽真实请求,只影响健康状态统计。

文件:compose.yaml

depends_on:
  migrate:
    condition: service_completed_successfully
healthcheck:
  test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready',timeout=4).read()"]
  interval: 10s
  timeout: 5s
  retries: 3
  start_period: 15s
restart: unless-stopped

4. 首次启动并验证迁移状态

up -d --wait 等待服务达到健康状态,但它不是业务验收;随后创建一条笔记并读取。migrate 是正常退出的作业,其 Exited (0) 不算服务故障。

文件:终端 / 当前课时

docker compose --env-file .env.compose config --quiet
docker compose --env-file .env.compose up -d --wait
docker compose --env-file .env.compose ps -a
docker compose --env-file .env.compose logs --tail 50 migrate
docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SELECT version_num FROM alembic_version;"

5. 演练运行时数据库短暂停止

仅在专用本机栈停止 postgres,此时 /live 应仍为 200、/ready 为 503;重启数据库后连接池 pre_ping 应逐步恢复。Docker 标记 unhealthy 本身不会自动重启容器,restart 策略主要响应进程退出。写入失败不要盲目自动重放,用幂等键确认结果。

文件:终端 / 当前课时

docker compose --env-file .env.compose stop postgres
docker compose --env-file .env.compose logs --tail 30 api
docker compose --env-file .env.compose start postgres
docker compose --env-file .env.compose ps

验收结果

  • 首次启动迁移先成功退出,API 随后 ready。
  • 迁移失败时 API 不按成功依赖启动,日志保留真实异常。
  • 数据库重启期间 readiness 失败,恢复后可创建读取数据,无隐式无限重试。

生产约束与故障演练

整套服务在新机器首次启动,以及运行期间数据库维护重启。

实现决策

  • 依赖健康、迁移完成和业务就绪分别验证。
  • 运行时重试有限,写请求按幂等协议恢复。

隔离环境演练

停止练习 postgres,观察 /live 与 /ready 分离,恢复后验证原数据和新写入。

恢复验收

  • 共享数据库故障不会触发无意义的无限重启。
  • 启动失败能够定位到依赖、迁移或应用阶段。

常见错误

depends_on 后仍出现连接拒绝

原因:只写了服务名依赖,没等待健康;或故障发生在启动之后。

处理:首次启动使用 health 条件,运行期间通过连接恢复、超时和调用方幂等重试处理。

容器 unhealthy 却没有自动重启

原因:把健康状态与 restart 策略混为一谈。

处理:明确由部署平台或外部监控处理健康流量与重启;不要让所有实例因共享数据库故障一起重启。

扩展练习

能否把 alembic upgrade head 放进 API CMD 前面,每个副本都执行?

提示:扩容时有多少个进程会同时触发 DDL?

参考答案:

不应。使用单独迁移步骤并保证唯一执行;多个进程同时迁移会竞争锁与版本状态。迁移成功是发布门槛,不是每个请求实例的初始化副作用。

技术文档


07 CPU、内存、进程数与优雅退出

给容器设置资源边界,观察 OOM 与 SIGTERM,确保在途请求和任务有明确退出行为。

本节目标

  • 设置并验证容器 CPU、内存与 PID 上限
  • 识别 OOMKilled 和普通应用退出
  • 协调停止宽限期、HTTP 排空与任务重投

必要概念

限制是边界而非性能保证

cpus 限制可用 CPU 时间,mem_limit 限制内存,pids_limit 限制线程与进程数量。线程也可能计入 PID 额度。限制过低会导致正常峰值失败,过高会让单服务挤压数据库与宿主;起始值需要用实际负载校准。

SIGTERM 与 SIGKILL

docker stop 通常先发 SIGTERM,等待 stop_grace_period 后再 SIGKILL。SIGTERM 允许应用停止接新请求并完成在途工作;SIGKILL 无法执行 finally。Uvicorn 的优雅关闭预算必须小于容器宽限期。

退出与任务一致性

HTTP 排空和队列任务退出是两套行为。Celery worker 收到 TERM 可进行 warm shutdown,但容器最终可能强杀长任务。任务必须幂等,确认、可见性超时和恢复策略与异步任务模块一致,不能把 SIGTERM 当作任务绝不丢失的保证。

动手实现

1. 为 API 配置资源上限

将这些字段合并到 api 服务。示例 1 CPU/512MB/128 PID 是练习起点,不是生产容量结论;Dockerfile Uvicorn graceful timeout 已为 25 秒,容器给予 35 秒。

文件:compose.yaml

cpus: 1.0
mem_limit: 512m
pids_limit: 128
init: true
stop_grace_period: 35s
logging:
  driver: local
  options:
    max-size: "10m"
    max-file: "3"

2. 重建并核对运行限制

应用配置变化需 up 重建容器,不是只 restart。stats 观察实际内存和 CPU;inspect 以 API 容器 id 读取 HostConfig,不把完整环境配置输出到公共日志。

文件:终端 / 当前课时

docker compose --env-file .env.compose up -d api
docker compose --env-file .env.compose stats --no-stream api
docker compose --env-file .env.compose ps -q api
# 将下面 id 替换为刚取得的 API 容器 id
docker inspect API_CONTAINER_ID --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}} {{.HostConfig.PidsLimit}}'

3. 在一次性专用容器触发 OOM

此演练不使用 API 容器或数据库卷;仅分配大于 64MB 上限的内存。不要加 --rm,便于查看退出状态,检查后只删除 notes-oom-drill。

文件:终端 / 当前课时

docker run --name notes-oom-drill --memory=64m --memory-swap=64m --pids-limit=32 python:3.11-slim python -c "x=bytearray(256*1024*1024); print(len(x))"
docker inspect notes-oom-drill --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
docker rm notes-oom-drill

4. 验证 API 接收停止信号

在另一个终端持续请求 /ready,再 stop api,观察日志中应用开始关闭与 shutdown complete。正常短请求应完成,关闭后客户端连接失败属于流量需要移走的阶段。单容器 Compose 没有自动负载均衡排空能力。

文件:终端 / 当前课时

docker compose --env-file .env.compose stop api
docker compose --env-file .env.compose logs --tail 50 api
docker compose --env-file .env.compose start api

5. 记录资源与退出验收

创建 resource-budget.md,记录实例/worker 数、连接池乘数、CPU/内存峰值及关闭耗时。任务 worker 的并发进程数和每个进程连接池也算入数据库配额。

文件:resource-budget.md

API:1 进程/容器;数据库池 5;内存上限 512MiB;关闭预算 25s < 容器35s
Worker:按第9课单独配置并发;连接数按实际执行模型计算
宿主:为 Postgres、Redis、Docker 和系统留余量
证据:实际 stats 峰值、OOM 状态、SIGTERM 关闭耗时

验收结果

  • 运行容器实际 HostConfig 包含预期内存、CPU、PID 限制。
  • 一次性 OOM 容器能够通过 OOMKilled 区分资源杀死与普通异常。
  • API 在宽限期内完成关闭,没有依赖 shell 后台进程转发信号。

生产约束与故障演练

单个大请求或长任务不能耗尽宿主全部资源,部署停止也不能无限等待。

实现决策

  • 资源值先设上限再压测校准。
  • 容器停止预算大于应用优雅退出预算。

隔离环境演练

使用无挂载专用容器触发 OOM,再对练习 API 执行 SIGTERM,分别记录原因与恢复。

恢复验收

  • OOM 不影响数据库卷。
  • 关闭和任务恢复行为有明确记录。

常见错误

退出码 137 就断言一定 OOM

原因:137 也可能由其他 SIGKILL 来源导致。

处理:同时检查 OOMKilled、宿主内核日志、容器事件与部署停止超时。

容器停止后任务重复执行

原因:任务在完成业务后、确认前被强杀,队列再次投递。

处理:按任务幂等与业务唯一约束处理重投,调整任务时限和退出预算;不要为了避免重复提前确认并忽略丢失风险。

扩展练习

每容器 4 个 API worker 是否一定比 1 个更快?

提示:同时计算 CPU、内存与数据库连接。

参考答案:

不一定。4 个进程增加内存和连接池总数,受 1 CPU 限制时可能只增加调度开销。用实际等待与计算比例评估;容器水平扩容和 worker 数必须一起预算。

技术文档


08 配置、Secrets 与容器运行权限

将连接秘密从 Compose 环境输出中移走,收紧应用可写目录与 Linux capabilities,并建立依赖扫描流程。

本节目标

  • 通过文件向应用注入秘密并验证权限
  • 限制运行容器的写入与提权能力
  • 读取扫描结果并形成可验证的修复步骤

必要概念

环境变量与秘密文件

环境变量方便但常出现在 inspect、诊断和进程信息中。Compose secrets 将文件挂载到 /run/secrets,应用必须主动读取;环境变量 NAME_FILE 不是通用自动功能,只有支持它的程序会处理。

本地 Compose 的边界

本地 secrets 通常来自宿主文件挂载,不等同 Swarm 的加密分发。宿主源文件和备份仍需权限控制,拥有 Docker daemon 管理权限的人可访问容器与挂载。应用不用 Docker socket,不把日常用户随意加入 docker 组。

扫描与修复

镜像扫描报告依赖和系统包漏洞,是定位输入,不是应用已安全的证明。确认运行路径与受影响版本,更新基础 digest/锁文件、重建并回归。不要在运行容器里删除库来“清零”扫描数字。

动手实现

1. 让配置支持文件秘密

替换 app/config.py 中 Settings 的 database_url 声明并扩展 model_config,下面是该部分的完整写法;保留此前连接池字段。secret 目录中的 database_url 文件提供值,环境变量同名值优先,因此从 api 服务移除旧 DATABASE_URL environment 项。

文件:app/config.py

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore", secrets_dir="/run/secrets")
    database_url: str = Field(min_length=1)
    db_pool_size: int = Field(default=5, ge=1, le=50)
    db_max_overflow: int = Field(default=0, ge=0, le=50)
    db_pool_timeout: float = Field(default=2, gt=0, le=30)

settings = Settings()

2. 建立本机秘密文件

在已忽略的 secrets 目录用编辑器新建 database_url,填写 postgres:5432 的容器连接串。以下为 Linux / WSL 的本地 file-backed secrets 方案:父目录由当前可信宿主账户持有且权限700,单个绑定文件644,使容器UID10001可读。其他普通宿主账户无法穿过700父目录;有Docker控制权限的账户仍属于受信任管理员。不能仅写uid/gid/mode并假设Compose会重映射文件权限。

文件:终端 / 当前课时

mkdir -p secrets
# 用编辑器创建 secrets/database_url,填实际练习连接串。
chmod 700 secrets
chmod 644 secrets/database_url
# 此处只调整明确的单个绑定文件;不把父目录改成公开可进入。

3. 挂载秘密并检查实际权限

在 compose.yaml 顶层新增 secrets;在 api/migrate 的服务中挂载 secrets: [database_url] 并移除 DATABASE_URL 环境值。file-backed secrets实际采用绑定文件权限,不能依赖Compose的uid/gid/mode映射。后续任务URL和broker配置也放在同一受控目录,逐一授予容器读取,并在启动正式进程前用临时容器验证。

文件:compose.yaml

# compose.yaml 顶层
secrets:
  database_url:
    file: ./secrets/database_url

# api 与 migrate 各自服务内添加
# secrets:
#   - database_url

4. 收紧 API 运行权限

合并到 api 服务并重建镜像以包含新的配置模块。根文件系统只读,临时目录使用有界 tmpfs;app 不写本地持久文件。Celery 需要的调度状态在第 9 课单独处理。

文件:compose.yaml

read_only: true
tmpfs:
  - /tmp:size=64m,mode=1777
cap_drop: [ALL]
security_opt:
  - no-new-privileges:true

5. 验证只读、秘密和依赖扫描

先重建镜像,随后用 command 覆盖应用启动命令,只读取秘密文件并输出布尔值。该检查使用镜像默认非root UID10001;成功后才启动API,避免先因Settings读取失败退出再尝试exec。只读写入检查应报Read-only file system,是本步骤预期结果。Docker Scout若已安装授权可执行cves,否则记录未执行原因,不声称扫描通过。

文件:终端 / 当前课时

docker build --build-arg PYTHON_IMAGE="${PYTHON_IMAGE:?set verified base digest}" -t notes-service:lab-02 .
docker compose --env-file .env.compose run --rm --no-deps api python -c "from pathlib import Path; assert Path('/run/secrets/database_url').read_text().strip(); print('secret readable')"
# 仅当前置检查成功后继续;失败时先修正文件所有者/权限和挂载。
docker compose --env-file .env.compose up -d api
docker compose --env-file .env.compose exec api python -c "from pathlib import Path; Path('/app/should_fail').write_text('x')"
docker scout cves notes-service:lab-02

验收结果

  • API 配置可读取挂载秘密,检查时未打印连接串。
  • /app 写入失败,/tmp 可以写入且有容量上限。
  • 扫描结果或未执行原因有记录,修复通过基础镜像/锁文件重建完成。

生产约束与故障演练

诊断日志和镜像交付不应携带数据库密码,应用应只有必需的文件写入权限。

实现决策

  • 秘密只在运行时挂载,宿主源文件访问受控。
  • 非 root、只读根文件系统和去除 capabilities 一起验证。

隔离环境演练

在本机尝试向 /app 写入,预期拒绝;仍能读取秘密并执行 /ready。

恢复验收

  • 权限收紧不破坏正常 API。
  • 秘密没有进入镜像层与共享日志。

常见错误

加了 DATABASE_URL_FILE 但应用仍缺配置

原因:应用没有实现读取该变量指向文件的逻辑。

处理:采用本课 Pydantic secrets_dir 文件名 database_url,或显式实现并测试 _FILE 读取。

只读 rootfs 后应用启动失败

原因:程序尝试写字节码、缓存、PID 或调度文件。

处理:禁用字节码并为必要临时写入配置 tmpfs;持久调度状态使用单独卷,不把整个根文件系统改回可写。

扩展练习

把 .env 复制进镜像后下一层删除,是否已清除秘密?

提示:镜像保留每层的文件变更。

参考答案:

没有。旧层仍可能恢复内容。轮换已泄露秘密,重新构建不含该层的新镜像,检查镜像仓库与构建日志中的副本;正常构建使用运行时秘密或 BuildKit secret mount。

技术文档


09 API、Worker 与队列的 Compose 整合

使用同一镜像运行不同进程角色,把任务执行、调度和 HTTP 服务分开部署。

本节目标

  • 部署异步任务模块真实 tasks_lab 工程与 exports.fast/bulk 队列
  • 为 API、worker、publisher、recovery 分别配置进程与资源
  • 隔离缓存 Redis 与带认证的任务 broker,并验证持久业务状态

必要概念

同镜像不同职责

API 接收请求并提交任务,worker 消费任务,beat 周期调度;它们可以来自同一代码镜像,但生命周期、并发与资源不同。不把 worker 后台进程塞进 API 容器,也不在每个 API 副本内启动周期调度器。

任务边界

复用异步任务模块第 11–16 课最终 tasks_lab 代码:celery_app.py 暴露 app,export_tasks.py 注册 notes.export,参数为 job_id。export_jobs/export_outbox 是业务真源,publisher.py 和 recovery.py 目前是单步脚本。不要部署第 8 课的小快照过渡任务,也不要假定旧 api-lab 下载包已有这些文件。

Redis 隔离

本地 Redis /0 与 /1 可以区分 key 空间,不能隔离内存、CPU、故障与驱逐策略。生产缓存与消息代理通常使用独立实例及策略;队列持久化和 ack 机制也不能单独构成端到端任务不丢失保证。

动手实现

1. 复制完整最终任务工程并统一依赖

把异步任务模块完成后的 tasks_lab 目录放入 notes-service 根目录,保留 celery_app.py、export_tasks.py、state_store.py、publisher.py、recovery.py 及其全部实际依赖模块。把其 requirements-tasks-lock.txt 所需包合并进 requirements.in,重新生成目标平台哈希锁。Dockerfile 在 USER 前加入以下 COPY;不要用本课创建一个同名的假业务 worker 替代原任务。

文件:Dockerfile

COPY tasks_lab ./tasks_lab
COPY deploy/run_role.py ./deploy/run_role.py

2. 为任务角色建立秘密加载入口

新建 deploy/run_role.py。API 的 SQLAlchemy URL 用 postgresql+psycopg://,任务模块直接 psycopg.connect 使用 postgresql://,两者分别保存。入口从三个只读秘密文件读入实际环境变量再 exec 目标进程,不输出秘密;读取 /run/secrets/task_database_url、celery_broker_url、celery_result_backend。

文件:deploy/run_role.py

import os
import sys
from pathlib import Path

for name, variable in {
    "task_database_url": "DATABASE_URL",
    "celery_broker_url": "CELERY_BROKER_URL",
    "celery_result_backend": "CELERY_RESULT_BACKEND",
}.items():
    value = Path("/run/secrets", name).read_text().strip()
    if not value:
        raise RuntimeError(f"empty secret: {name}")
    os.environ[variable] = value
if len(sys.argv) < 2:
    raise RuntimeError("missing process command")
os.execvp(sys.argv[1], sys.argv[1:])

3. 配置独立认证 broker 与秘密文件

新增 broker 服务,不复用缓存 redis。宿主6384为任务专用,6380–6383已由Redis课程其他隔离实例使用。用强随机 TASK_BROKER_PASSWORD 在编辑器生成秘密文件:task_broker_conf 包含 requirepass、appendonly yes、appendfsync everysec、maxmemory 192mb、maxmemory-policy noeviction、dir /data;celery_broker_url 为 redis://:URL编码密码@broker:6379/0,celery_result_backend 为同地址/1;task_database_url 为 postgresql://app_user:URL编码密码@postgres:5432/notes_lab。三个URL文件由task服务读取;broker配置只读挂载,不提交到仓库。 本地file-backed secrets按第8课使用宿主受控父目录700、单秘密文件644,使UID10001可读取实际挂载;启动前用run --no-deps检查可读性且不打印内容。TASK_BROKER_PASSWORD建议本机生成高熵十六进制值,使Redis配置requirepass行无需额外引号转义。

文件:compose.yaml

# services 中新增;本机调试端口仅回环开放
broker:
  image: redis:7.4
  command: ["redis-server", "/run/secrets/task_broker_conf"]
  ports: ["127.0.0.1:6384:6379"]
  secrets: [task_broker_conf]
  volumes: ["broker_data:/data"]
  mem_limit: 256m
  restart: unless-stopped

# 顶层 volumes 新增 broker_data: {}
# 顶层 secrets 新增以下条目(与已有 database_url 并列)
# task_broker_conf: {file: ./secrets/task_broker_conf}
# task_database_url: {file: ./secrets/task_database_url}
# celery_broker_url: {file: ./secrets/celery_broker_url}
# celery_result_backend: {file: ./secrets/celery_result_backend}

4. 添加真实 fast Worker 与 bulk Worker

新增 worker 服务,worker-bulk 使用相同字段,仅 command 的队列改 exports.bulk、节点名改 bulk@%h。任务模块使用相对 Path("artifacts"),工作目录下挂载 /app/tasks_lab/artifacts 命名卷并初始化给UID10001;同机共享输出供恢复/下载使用,跨宿主生产改对象存储。API仅提供笔记接口时不伪称已实现任务提交HTTP端点,先用任务模块 submit/publisher 流程验收。

文件:compose.yaml

worker:
  image: ${NOTES_IMAGE:?set NOTES_IMAGE}
  working_dir: /app/tasks_lab
  command: ["python", "/app/deploy/run_role.py", "python", "-m", "celery", "-A", "celery_app:app", "worker", "-l", "INFO", "-Q", "exports.fast", "--concurrency=1", "-n", "fast@%h"]
  secrets: [task_database_url, celery_broker_url, celery_result_backend]
  volumes: ["artifacts_data:/app/tasks_lab/artifacts"]
  depends_on:
    broker: {condition: service_started}
    migrate: {condition: service_completed_successfully}
  cpus: 1.0
  mem_limit: 512m
  pids_limit: 128
  init: true
  stop_grace_period: 75s
  restart: unless-stopped
  read_only: true
  tmpfs: ["/tmp:size=64m,mode=1777"]
  cap_drop: [ALL]
  security_opt: ["no-new-privileges:true"]

# 顶层 volumes 新增 artifacts_data: {}

5. 包装有界 publisher 与 recovery 循环

新建 tasks_lab/run_loops.py。publisher 每次 publish_one() 最多发送一条,recovery 每次 recover_batch()最多50条,循环设置固定间隔并响应TERM;函数自身数据库/网络超时仍需保持任务模块的约束,不能靠事件替代I/O超时。出现异常记录类别并退避。分别创建publisher/recovery服务,沿用worker环境、秘密和工作目录,command 改成 python /app/deploy/run_role.py python run_loops.py publisher 或 recovery;它们不启动 Celery worker。

文件:tasks_lab/run_loops.py

import logging
import signal
import sys
from threading import Event
from publisher import publish_one
from recovery import recover_batch

stop = Event()
signal.signal(signal.SIGTERM, lambda *_: stop.set())
signal.signal(signal.SIGINT, lambda *_: stop.set())
functions = {"publisher": (publish_one, 0.5), "recovery": (recover_batch, 5)}
operation, interval = functions[sys.argv[1]]
while not stop.is_set():
    try:
        operation()
    except Exception as exc:
        logging.error("task maintenance failed: %s", type(exc).__name__)
        if stop.wait(5):
            break
    stop.wait(interval)

6. 初始化任务表、输出权限并构建

将任务课程的 export_jobs/export_outbox/死信/容量/周期等最终DDL纳入本项目Alembic迁移,先在空练习库验证;不要认为notes迁移自动创建这些任务表。挂载卷首次创建为root时,使用仅针对artifacts_data挂载点的一次性初始化作业 chown 10001:10001 /app/tasks_lab/artifacts,并在worker启动前完成;不可递归修改宿主未知目录。构建新标签,更新 .env.compose NOTES_IMAGE,再启动并核对注册任务和队列。

文件:终端 / 当前课时

docker build -t notes-service:lab-03 .
# 编辑 .env.compose:NOTES_IMAGE=notes-service:lab-03
docker compose --env-file .env.compose run --rm --no-deps worker python -c "from pathlib import Path; names=['task_database_url','celery_broker_url','celery_result_backend']; assert all(Path('/run/secrets',n).read_text().strip() for n in names); print('secrets readable')"
docker compose --env-file .env.compose run --rm --user 0 --cap-add CHOWN --entrypoint sh worker -c 'chown 10001:10001 /app/tasks_lab/artifacts'
docker compose --env-file .env.compose run --rm migrate
docker compose --env-file .env.compose up -d worker worker-bulk publisher recovery
docker compose --env-file .env.compose exec worker python /app/deploy/run_role.py python -m celery -A celery_app:app inspect registered
docker compose --env-file .env.compose logs --tail 50 worker worker-bulk publisher recovery

7. 按真实业务链路验收与故障恢复

先在notes服务创建至少一条练习笔记,然后以下命令从真实notes表选择一条并调用最终state_store.submit_snapshot;它已包含job/outbox原子写入,不调用export_notes.delay。记录返回job_id。publisher将事件路由到exports.fast/bulk,worker写artifacts,最终运行任务第16课verify_terminal.py核对文件与固定快照。暂停worker后提交应queued,恢复后完成。beat仅在第14课周期任务完成后单实例部署。

文件:终端 / 当前课时

docker compose --env-file .env.compose stop worker worker-bulk
docker compose --env-file .env.compose run --rm --no-deps worker python /app/deploy/run_role.py python -c "from state_store import connect,submit_snapshot; conn=connect(); row=conn.execute('SELECT id FROM notes ORDER BY id LIMIT 1').fetchone(); conn.close(); assert row, 'create a note first'; print(submit_snapshot([row['id']]))"
docker compose --env-file .env.compose start worker worker-bulk
docker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c "SELECT id,status,result_ref,attempts FROM export_jobs ORDER BY created_at DESC LIMIT 10;"
# 用真实返回的UUID替换JOB_ID;任务完成后验证artifact
docker compose --env-file .env.compose exec worker python /app/deploy/run_role.py python verify_terminal.py JOB_ID

验收结果

  • Celery inspect 注册 notes.export,fast/bulk worker 只消费对应队列。
  • PostgreSQL中job/outbox到最终状态与可读取的输出文件一致,不能只凭Celery结果缓存。
  • 缓存redis与认证broker是不同实例;宿主任务端口6384,容器内部broker:6379。

生产约束与故障演练

API 快速返回任务已提交,worker 可以独立扩缩容和更新。

实现决策

  • 任务执行资源与 API 分配分开。
  • 队列状态重要时建立持久业务记录与可恢复投递流程。

隔离环境演练

在独立练习栈暂停broker后提交持久job,核对job/outbox保留;恢复broker和publisher后worker完成且同一业务结果不被重复执行覆盖。

恢复验收

  • 真实tasks_lab入口、队列、环境变量和文件路径一致。
  • publisher/recovery有独立受控循环,业务状态与输出经数据库核对。

常见错误

扩容 API 后周期任务重复发送

原因:每个 API 容器都启动了 beat 或本地定时器。

处理:beat 单独部署单实例或使用有领导选举的调度方案,任务仍需幂等。

worker 配了更多并发,数据库连接数翻倍

原因:每个执行进程建立自己的池,遗漏 fork 与连接生命周期。

处理:按进程模型重新预算,在子进程创建/重置连接资源;不继承已建立连接。

扩展练习

为什么不能把postgresql+psycopg://连接串原样交给任务模块的psycopg.connect?

提示:区分SQLAlchemy方言URL与PostgreSQL驱动DSN。

参考答案:

API使用SQLAlchemy URL;任务state_store直接使用psycopg.connect,需要postgresql://或libpq键值DSN。因此配置分成API database_url与task_database_url,端口和账号相同但协议前缀不同。

技术文档


10 镜像版本、发布回滚与故障验收

用不可混淆的版本标签与镜像摘要发布,保留数据库兼容窗口并验证回退行为。

本节目标

  • 记录并部署明确的镜像版本或摘要
  • 执行迁移在前、流量切换在后的发布流程
  • 区分应用回滚、数据库回滚与数据恢复

必要概念

发布的是已验证镜像

本机 image ID 与仓库 digest 是不同概念;推送后以仓库名@sha256 选择不可变发布物。标签可便于阅读但可能被覆盖,不能只保存 latest。预发布和生产使用同一已验证摘要,避免在每台机器重建出不同依赖。

Compose 的可用性边界

单机单副本 Compose 更新通常会有短暂中断,不承诺零停机。需要平滑切换时部署两个版本后由代理切换,或者使用具备滚动与就绪能力的平台;数据库迁移仍须兼容新旧版本。

回滚不等于删数据

旧镜像能否回退取决于当前数据库结构是否兼容。新增列通常可保留供旧版忽略,删除/重命名/数据语义改变可能阻止回退。不能为了退应用版本就清空数据库卷或机械执行 downgrade。

动手实现

1. 记录当前可回退版本

发布前保存当前 NOTES_IMAGE、迁移版本、配置版本和必要备份。只记录秘密文件的版本标识或来源,不把内容复制进 release-record.md。

文件:release-record.md

版本:notes-service:release-2026-09-10-01 或实际仓库 digest
源码提交:实际 SHA
当前迁移 revision:实际 alembic current
目标 revision:待发布版本所需 revision
兼容范围:旧应用可读取/写入的新结构范围
备份与恢复验证:文件标识、时间、恢复证据
回退触发:例如连续5分钟错误率超目标;须先定义观测窗口

2. 构建、标记并本机验收

构建使用第 3 课已验证基础 digest。用户拥有并授权的镜像仓库才可执行登录与推送;课件提供命令模板,不实际向外发布。标签中使用真实版本或 commit,不重复覆盖旧发布标签。 以下命令在Bash/WSL运行,PYTHON_IMAGE必须是第03课保存的完整摘要;开启新终端时先恢复已审核的变量,不能静默回退到浮动标签。

文件:终端 / 当前课时

docker build --build-arg PYTHON_IMAGE="${PYTHON_IMAGE:?set verified base digest}" -t notes-service:release-01 .
docker run --rm notes-service:release-01 python -c "import fastapi, sqlalchemy, psycopg; print('imports ok')"
# 已具备目标仓库权限后,替换 REGISTRY/PROJECT 为真实仓库:
# docker tag notes-service:release-01 REGISTRY/PROJECT/notes-service:release-01
# docker push REGISTRY/PROJECT/notes-service:release-01
# docker buildx imagetools inspect REGISTRY/PROJECT/notes-service:release-01

3. 先运行唯一迁移作业

更新 .env.compose 的 NOTES_IMAGE 为目标版本,先在预发布演练下面命令。发布时 run --rm migrate 执行当前目标镜像迁移;API 仍可维持旧版,前提是迁移经过 expand-contract 兼容审核。迁移失败即停止后续发布。

文件:终端 / 当前课时

docker compose --env-file .env.compose run --rm migrate
docker compose --env-file .env.compose up -d --no-deps api worker worker-bulk publisher recovery
docker compose --env-file .env.compose ps
docker compose --env-file .env.compose logs --tail 100 api worker worker-bulk publisher recovery

4. 验证业务再决定继续或回退

检查 /ready、创建读取、旧版本冲突、幂等重试和 真实导出任务;记录实际 HTTP 与数据库结果。失败时先将 NOTES_IMAGE 恢复上一兼容版本,再 up --no-deps api worker,不回退 Redis/Postgres 镜像主版本,也不自动操作数据库迁移。

文件:终端 / 当前课时

# 将 .env.compose 的 NOTES_IMAGE 恢复到已记录的上一兼容版本后:
docker compose --env-file .env.compose up -d --no-deps api worker worker-bulk publisher recovery
docker compose --env-file .env.compose exec api python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8000/ready',timeout=4).status)"

5. 完成隔离故障矩阵

在本机专用栈逐项执行,保存预期与实测。数据库数据使用迁移和备份处理,不以删卷作为回滚。超出本机实测范围的远程仓库、TLS、负载均衡明确标记未验证。

文件:终端 / 当前课时

场景 | 观测 | 恢复动作
错误数据库密码 | readiness失败,认证日志 | 修正秘密并重建API;不删卷
错误应用镜像 | API启动失败,旧版可用性记录 | 切回兼容镜像
worker停止 | 队列积压,API仍可提交 | 启动worker,核对任务去重
OOM演练 | 专用容器OOMKilled | 调整预算并检查宿主余量
迁移失败 | migrate退出非0 | 停止发布,修复迁移或锁阻塞
数据库容器重建 | 卷保留,原笔记可读 | 等待ready并执行业务探针

验收结果

  • 发布记录包含真实镜像标识、迁移版本和回退边界。
  • 迁移失败不会继续切换 API 与 worker。
  • 回退应用镜像后业务探针通过,数据库卷和已提交数据保留。

生产约束与故障演练

一次发布必须能够定位软件版本,并在已声明兼容范围内回退。

实现决策

  • 构建一次并以明确版本交付。
  • 应用回退与数据库恢复分开决策,保持数据卷。

隔离环境演练

在本机发布一个故意缺配置的应用版本,确认错误后回到上一镜像并核对既有数据。

恢复验收

  • 有实际回退耗时和业务恢复结果。
  • 未验证的仓库、远程部署和高可用能力明确标记。

常见错误

latest 回退后仍运行同一坏版本

原因:标签被覆盖,无法指明旧镜像内容。

处理:保存不可变版本标签与仓库摘要,部署时以明确摘要选择。

应用回退成功但读数据失败

原因:新迁移删除或改变了旧应用依赖的字段。

处理:发布前验证兼容窗口;不可兼容时执行修复版本或已验证恢复方案,不机械 downgrade。

扩展练习

怎样证明“回滚方案可用”,而非只写一句“退回旧版本”?

提示:在同一新数据库结构上运行旧镜像并发请求。

参考答案:

在隔离预发布库先执行新迁移,再部署旧镜像执行读写与幂等探针,记录结果和不可兼容点。若旧版写入破坏新语义,回退窗口应关闭,改用前向修复。

技术文档