术核 AI · 后端工程课件
从一个临时 Python 容器开始,观察进程退出、停止、重建与数据保留的区别。
镜像是文件系统和启动配置;容器是基于镜像创建的进程及其隔离环境。主进程退出,容器便停止,Docker 不会因为容器存在就持续执行应用。容器不是需要手工登录安装软件的长期虚拟机。
stop 发送停止信号并等待,start 重用原容器,rm 删除已停止容器的可写层。重新 run 创建另一份容器。镜像和显式命名卷有独立生命周期,重建 API 容器不应删除数据库卷。
使用 Docker Desktop 的 Linux 容器模式或独立 Linux 练习机,先完成 API+数据库模块的 notes-service。所有命令针对本地专用练习项目;不连接线上 Docker context,不挂载 Docker socket 到应用。未完成前置代码时,端点和 worker 不会自动存在。
执行下列只读命令,确认 context 指向本机练习环境,Linux 容器模式,Compose V2 可用。若无 Docker,应先按官方安装文档完成当前系统安装,不执行来源不明的一键脚本。
文件:终端 / 当前课时
docker context show
docker info --format '{{.OSType}}'
docker compose version
docker version
该容器运行完 print 就退出,--rm 清理它自己的可写层。它没有数据库、持久卷或宿主目录挂载,不影响 notes 数据。
文件:终端 / 当前课时
docker run --rm python:3.11-slim python -c "import sys; print(sys.version)"
创建专用生命周期容器,查看它的日志和进程。这里只写容器内临时文件,接下来可以安全删除该练习容器。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}}'
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
使用相同镜像创建新容器,它没有前一容器的 probe 文件。预期 False 不代表镜像损坏,而是临时写入没有进入镜像或持久卷。
文件:终端 / 当前课时
docker run --rm python:3.11-slim python -c "from pathlib import Path; print(Path('/tmp/probe.txt').exists())"
开发者需要交付能在另一台机器启动的服务,而非一份手工配置记录。
停止并删除无挂载的生命周期容器,再从同一镜像创建,观察临时文件消失。
原因:启动命令只是一个会完成的脚本。
处理:查看 docker logs 与退出码,服务需要持续监听进程,而非用 tail 掩盖真实服务退出。
原因:安装发生在容器临时文件层。
处理:将依赖写进 Dockerfile 与锁定文件,通过重建镜像保留。
镜像没有变化,为何两个容器的文件可能不同?
提示:启动配置相同不代表运行时写入相同。
参考答案:
容器各自拥有可写层和进程状态;环境变量、挂载卷和启动命令也可能不同。把需要复现的软件放进镜像,把需要保留的业务数据放进明确的持久存储。
把 notes-service 源码与固定依赖构建进镜像,以非 root 用户启动 Uvicorn。
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,数据库迁移独立运行。
在 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
Docker 构建不需要本机 .env、虚拟环境、数据库转储和 Git 历史。保留 .env.example 可供文档参考,但本课 Dockerfile 只显式 COPY 应用必需文件。
文件:.dockerignore
.git
.venv
.env
.env.*
secrets/
backups/
*.dump
*.sql.gz
__pycache__/
.pytest_cache/
tests/
依赖完整 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"]
完成下一课锁文件生成后在项目根目录构建。此检查不需要数据库配置,因为 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}}'
本课暂不启动完整服务,先验证代码和依赖存在;第 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())"
应用镜像交给部署机器运行,需要固定依赖与明确的低权限进程。
在构建上下文放一个无敏感内容的 .env 练习标记,构建后确认 /app/.env 不存在。
原因:Uvicorn 只监听容器回环地址 127.0.0.1。
处理:容器内监听 0.0.0.0,宿主端口发布是否公开由 ports 绑定地址控制。
原因:锁文件不是带完整依赖与哈希的锁文件。
处理:按下一课用 pip-tools 生成,不能去掉哈希选项假装已经固定依赖。
为什么不直接 COPY . /app?
提示:检查构建上下文中的本机配置与数据。
参考答案:
显式 COPY 应用、迁移和锁文件能缩小误带入范围;.dockerignore 再提供过滤。COPY . 并非语法错误,但需要更严格控制上下文,新手项目容易把秘密、缓存和备份带进去。
将依赖构建和运行文件分开,固定基础镜像与 Python 包,让构建结果可以追溯。
先 COPY 依赖锁再安装,最后 COPY 源码,业务代码修改通常不会让依赖安装层失效。缓存提升速度,不代表输入自动更新;基础镜像和锁文件更新仍需显式执行与测试。
python:3.11-slim 标签可能随安全更新移动,digest 指向具体镜像内容。使用真实查询到的 digest 可固定基础镜像;固定也意味着必须有主动更新流程,不能永久停留在旧漏洞版本。
builder 创建虚拟环境,runtime 只复制环境与应用代码。二者使用相同基础镜像和架构,避免复制不兼容的本地扩展。哈希锁、基础 digest 和目标平台稳定输入;如果要求字节级相同镜像,还需控制构建时间戳与工具链,不能仅靠 Dockerfile 保证。
以下为 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'
执行 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
用本段完整替换第 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"]
在同一 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}}'
创建 build-record.md,记录下面字段。依赖升级时在分支重新生成锁、构建新镜像并运行数据库测试,再发布;不能在运行容器内 pip install -U 修补并忘记回写镜像。
文件:build-record.md
源码提交:实际 git commit SHA
目标平台:例如 linux/amd64
基础镜像:实际 python@sha256:...
依赖锁:requirements-lock.txt 的版本/摘要
应用镜像:本机 image ID;推送后补仓库 digest
验证结果:执行命令、实际输出、未验证项
同一版本需要在预发布和线上运行相同的软件输入。
修改源码与依赖锁分别构建,记录哪几层重建;还原后核对镜像关联记录。
原因:宿主系统或架构与容器不一致。
处理:在目标 Linux 基础镜像内创建环境,builder/runtime 保持相同平台与 ABI。
原因:把固定输入误当作无需维护。
处理:定期更新摘要和依赖锁,在测试通过后产生新发布版本,保留变更记录。
--no-cache 是否保证获取最新基础镜像?
提示:构建缓存与拉取基础镜像是两件事。
参考答案:
不保证。--pull 会检查标签的新内容;但如果固定 digest,应先审阅并选择新 digest,再更新构建参数。盲目拉最新也不等于通过了应用兼容测试。
把 API、PostgreSQL 和 Redis 放入同一专用网络,区分容器内地址和宿主调试端口。
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。
复制下列模板为 .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
新建 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
本段创建 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:
如果上一模块已经占用 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
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';"
同机运行多个后端依赖,地址与端口必须在容器内外保持清楚。
只在练习 .env.compose 把主机名改为不存在名称,观察 DNS 错误后恢复,区分认证失败与网络失败。
原因:把宿主访问地址用于容器内部。
处理:容器 DATABASE_URL 使用 postgres:5432;宿主 psql 才使用本机 5433。
原因:初始化脚本只在空数据目录执行,已有角色密码未改变。
处理:通过受控 SQL 轮换角色密码并同步应用秘密;不能删除卷来修复认证。
为了让 API 访问数据库,是否必须发布 5433?
提示:Compose 服务互通走内部网络。
参考答案:
不需要。删去 postgres 的 ports 后,API 仍能使用 postgres:5432;宿主 psql 失去直接端口入口,可通过 docker compose exec 调试。线上通常不发布数据库宿主端口。
验证重建数据库容器后数据仍存在,在独立恢复容器中验证逻辑备份。
PostgreSQL 16 官方镜像的本课持久路径为 /var/lib/postgresql/data。命名卷由 Docker 管理宿主位置,容器删除不会自动删除命名卷。不要把卷当作备份,宿主磁盘故障和误删仍会影响它。
宿主 bind mount 需要匹配数据库用户 UID/GID 与权限,直接 chmod 777 不是解决方案。运行中的 PGDATA 不能随手复制成可靠备份;用 pg_dump 做逻辑备份,物理备份需 pg_basebackup/WAL 等一致性协议。
备份成功退出只能证明生成了文件,不能证明恢复后应用能使用。恢复演练要用另一容器和另一卷,核对表、行数和业务查询;备份保留策略还需异机、加密、访问控制与实际 RPO/RTO。
只读检查 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
在练习库创建一张明确命名的验证表,重建仅 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;"
先在容器内生成自定义格式备份,再 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
新建 .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
本课尚未执行第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;
镜像升级必须保留数据库,宿主损坏后也应有可恢复副本。
重建练习数据库容器,再把备份恢复到另一卷并核对 volume_probe。完成第06课迁移后,另取新备份恢复到新的空恢复库,补验业务行数和迁移版本。
原因:未挂载持久卷、挂载路径错误,或切换了 Compose project name。
处理:检查 Mounts 和卷标签,确认实际使用的 pg_data;不要在未确认旧卷前初始化覆盖或删除。
原因:用不支持二进制透明传输的 Shell 重定向生成备份。
处理:在容器内写 -f 文件,再 docker cp;检查备份大小和 pg_restore --list。
每天把 PGDATA 目录复制到同一宿主另一个文件夹能否替代备份方案?
提示:分别考虑运行中一致性与宿主故障。
参考答案:
不能。普通文件复制可能得到不一致数据库,而且宿主故障会同时损坏原件与副本。使用数据库支持的备份协议,并保留异机副本、加密密钥与定期恢复证据。
让 API 等待数据库可用与迁移成功,同时处理运行期间的依赖故障。
容器进程创建后,Postgres 可能仍在初始化,API 可能缺表。pg_isready 证明数据库接受连接,不验证 app_user 凭据或业务表。应用 /ready 执行 SQL,迁移作业另行证明表版本已到目标。
depends_on condition 控制 Compose 启动前置条件,不能保证依赖永远在线。运行时数据库重启仍会引发请求失败,连接池需检测失效连接,调用方需按幂等规则重试。
一个 migrate 容器执行 alembic upgrade head,退出码 0 才允许 API 启动。不让多个 API worker 各自执行迁移。迁移失败保留日志与旧应用,不能循环忽略失败继续接流量。
把以下字段分别合并到 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
合并到 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"
合并到已有 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
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;"
仅在专用本机栈停止 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
整套服务在新机器首次启动,以及运行期间数据库维护重启。
停止练习 postgres,观察 /live 与 /ready 分离,恢复后验证原数据和新写入。
原因:只写了服务名依赖,没等待健康;或故障发生在启动之后。
处理:首次启动使用 health 条件,运行期间通过连接恢复、超时和调用方幂等重试处理。
原因:把健康状态与 restart 策略混为一谈。
处理:明确由部署平台或外部监控处理健康流量与重启;不要让所有实例因共享数据库故障一起重启。
能否把 alembic upgrade head 放进 API CMD 前面,每个副本都执行?
提示:扩容时有多少个进程会同时触发 DDL?
参考答案:
不应。使用单独迁移步骤并保证唯一执行;多个进程同时迁移会竞争锁与版本状态。迁移成功是发布门槛,不是每个请求实例的初始化副作用。
给容器设置资源边界,观察 OOM 与 SIGTERM,确保在途请求和任务有明确退出行为。
cpus 限制可用 CPU 时间,mem_limit 限制内存,pids_limit 限制线程与进程数量。线程也可能计入 PID 额度。限制过低会导致正常峰值失败,过高会让单服务挤压数据库与宿主;起始值需要用实际负载校准。
docker stop 通常先发 SIGTERM,等待 stop_grace_period 后再 SIGKILL。SIGTERM 允许应用停止接新请求并完成在途工作;SIGKILL 无法执行 finally。Uvicorn 的优雅关闭预算必须小于容器宽限期。
HTTP 排空和队列任务退出是两套行为。Celery worker 收到 TERM 可进行 warm shutdown,但容器最终可能强杀长任务。任务必须幂等,确认、可见性超时和恢复策略与异步任务模块一致,不能把 SIGTERM 当作任务绝不丢失的保证。
将这些字段合并到 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"
应用配置变化需 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}}'
此演练不使用 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
在另一个终端持续请求 /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
创建 resource-budget.md,记录实例/worker 数、连接池乘数、CPU/内存峰值及关闭耗时。任务 worker 的并发进程数和每个进程连接池也算入数据库配额。
文件:resource-budget.md
API:1 进程/容器;数据库池 5;内存上限 512MiB;关闭预算 25s < 容器35s
Worker:按第9课单独配置并发;连接数按实际执行模型计算
宿主:为 Postgres、Redis、Docker 和系统留余量
证据:实际 stats 峰值、OOM 状态、SIGTERM 关闭耗时
单个大请求或长任务不能耗尽宿主全部资源,部署停止也不能无限等待。
使用无挂载专用容器触发 OOM,再对练习 API 执行 SIGTERM,分别记录原因与恢复。
原因:137 也可能由其他 SIGKILL 来源导致。
处理:同时检查 OOMKilled、宿主内核日志、容器事件与部署停止超时。
原因:任务在完成业务后、确认前被强杀,队列再次投递。
处理:按任务幂等与业务唯一约束处理重投,调整任务时限和退出预算;不要为了避免重复提前确认并忽略丢失风险。
每容器 4 个 API worker 是否一定比 1 个更快?
提示:同时计算 CPU、内存与数据库连接。
参考答案:
不一定。4 个进程增加内存和连接池总数,受 1 CPU 限制时可能只增加调度开销。用实际等待与计算比例评估;容器水平扩容和 worker 数必须一起预算。
将连接秘密从 Compose 环境输出中移走,收紧应用可写目录与 Linux capabilities,并建立依赖扫描流程。
环境变量方便但常出现在 inspect、诊断和进程信息中。Compose secrets 将文件挂载到 /run/secrets,应用必须主动读取;环境变量 NAME_FILE 不是通用自动功能,只有支持它的程序会处理。
本地 secrets 通常来自宿主文件挂载,不等同 Swarm 的加密分发。宿主源文件和备份仍需权限控制,拥有 Docker daemon 管理权限的人可访问容器与挂载。应用不用 Docker socket,不把日常用户随意加入 docker 组。
镜像扫描报告依赖和系统包漏洞,是定位输入,不是应用已安全的证明。确认运行路径与受影响版本,更新基础 digest/锁文件、重建并回归。不要在运行容器里删除库来“清零”扫描数字。
替换 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()
在已忽略的 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
# 此处只调整明确的单个绑定文件;不把父目录改成公开可进入。
在 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
合并到 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
先重建镜像,随后用 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
诊断日志和镜像交付不应携带数据库密码,应用应只有必需的文件写入权限。
在本机尝试向 /app 写入,预期拒绝;仍能读取秘密并执行 /ready。
原因:应用没有实现读取该变量指向文件的逻辑。
处理:采用本课 Pydantic secrets_dir 文件名 database_url,或显式实现并测试 _FILE 读取。
原因:程序尝试写字节码、缓存、PID 或调度文件。
处理:禁用字节码并为必要临时写入配置 tmpfs;持久调度状态使用单独卷,不把整个根文件系统改回可写。
把 .env 复制进镜像后下一层删除,是否已清除秘密?
提示:镜像保留每层的文件变更。
参考答案:
没有。旧层仍可能恢复内容。轮换已泄露秘密,重新构建不含该层的新镜像,检查镜像仓库与构建日志中的副本;正常构建使用运行时秘密或 BuildKit secret mount。
使用同一镜像运行不同进程角色,把任务执行、调度和 HTTP 服务分开部署。
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 /0 与 /1 可以区分 key 空间,不能隔离内存、CPU、故障与驱逐策略。生产缓存与消息代理通常使用独立实例及策略;队列持久化和 ack 机制也不能单独构成端到端任务不丢失保证。
把异步任务模块完成后的 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
新建 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:])
新增 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}
新增 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: {}
新建 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)
将任务课程的 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
先在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
API 快速返回任务已提交,worker 可以独立扩缩容和更新。
在独立练习栈暂停broker后提交持久job,核对job/outbox保留;恢复broker和publisher后worker完成且同一业务结果不被重复执行覆盖。
原因:每个 API 容器都启动了 beat 或本地定时器。
处理:beat 单独部署单实例或使用有领导选举的调度方案,任务仍需幂等。
原因:每个执行进程建立自己的池,遗漏 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,端口和账号相同但协议前缀不同。
用不可混淆的版本标签与镜像摘要发布,保留数据库兼容窗口并验证回退行为。
本机 image ID 与仓库 digest 是不同概念;推送后以仓库名@sha256 选择不可变发布物。标签可便于阅读但可能被覆盖,不能只保存 latest。预发布和生产使用同一已验证摘要,避免在每台机器重建出不同依赖。
单机单副本 Compose 更新通常会有短暂中断,不承诺零停机。需要平滑切换时部署两个版本后由代理切换,或者使用具备滚动与就绪能力的平台;数据库迁移仍须兼容新旧版本。
旧镜像能否回退取决于当前数据库结构是否兼容。新增列通常可保留供旧版忽略,删除/重命名/数据语义改变可能阻止回退。不能为了退应用版本就清空数据库卷或机械执行 downgrade。
发布前保存当前 NOTES_IMAGE、迁移版本、配置版本和必要备份。只记录秘密文件的版本标识或来源,不把内容复制进 release-record.md。
文件:release-record.md
版本:notes-service:release-2026-09-10-01 或实际仓库 digest
源码提交:实际 SHA
当前迁移 revision:实际 alembic current
目标 revision:待发布版本所需 revision
兼容范围:旧应用可读取/写入的新结构范围
备份与恢复验证:文件标识、时间、恢复证据
回退触发:例如连续5分钟错误率超目标;须先定义观测窗口
构建使用第 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
更新 .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
检查 /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)"
在本机专用栈逐项执行,保存预期与实测。数据库数据使用迁移和备份处理,不以删卷作为回滚。超出本机实测范围的远程仓库、TLS、负载均衡明确标记未验证。
文件:终端 / 当前课时
场景 | 观测 | 恢复动作
错误数据库密码 | readiness失败,认证日志 | 修正秘密并重建API;不删卷
错误应用镜像 | API启动失败,旧版可用性记录 | 切回兼容镜像
worker停止 | 队列积压,API仍可提交 | 启动worker,核对任务去重
OOM演练 | 专用容器OOMKilled | 调整预算并检查宿主余量
迁移失败 | migrate退出非0 | 停止发布,修复迁移或锁阻塞
数据库容器重建 | 卷保留,原笔记可读 | 等待ready并执行业务探针
一次发布必须能够定位软件版本,并在已声明兼容范围内回退。
在本机发布一个故意缺配置的应用版本,确认错误后回到上一镜像并核对既有数据。
原因:标签被覆盖,无法指明旧镜像内容。
处理:保存不可变版本标签与仓库摘要,部署时以明确摘要选择。
原因:新迁移删除或改变了旧应用依赖的字段。
处理:发布前验证兼容窗口;不可兼容时执行修复版本或已验证恢复方案,不机械 downgrade。
怎样证明“回滚方案可用”,而非只写一句“退回旧版本”?
提示:在同一新数据库结构上运行旧镜像并发请求。
参考答案:
在隔离预发布库先执行新迁移,再部署旧镜像执行读写与幂等探针,记录结果和不可兼容点。若旧版写入破坏新语义,回退窗口应关闭,改用前向修复。