# 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,应先按官方安装文档完成当前系统安装,不执行来源不明的一键脚本。 文件:终端 / 当前课时 ````bash docker context show docker info --format '{{.OSType}}' docker compose version docker version ```` #### 2. 运行一次性 Python 进程 该容器运行完 print 就退出,--rm 清理它自己的可写层。它没有数据库、持久卷或宿主目录挂载,不影响 notes 数据。 文件:终端 / 当前课时 ````bash docker run --rm python:3.11-slim python -c "import sys; print(sys.version)" ```` #### 3. 观察持续运行的主进程 创建专用生命周期容器,查看它的日志和进程。这里只写容器内临时文件,接下来可以安全删除该练习容器。Windows PowerShell 可原样使用双引号内的 Python 代码。 文件:终端 / 当前课时 ````bash 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 清理所有项目。 文件:终端 / 当前课时 ````bash 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 不代表镜像损坏,而是临时写入没有进入镜像或持久卷。 文件:终端 / 当前课时 ````bash 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 与锁定文件,通过重建镜像保留。 ### 扩展练习 镜像没有变化,为何两个容器的文件可能不同? 提示:启动配置相同不代表运行时写入相同。 参考答案: 容器各自拥有可写层和进程状态;环境变量、挂载卷和启动命令也可能不同。把需要复现的软件放进镜像,把需要保留的业务数据放进明确的持久存储。 ### 技术文档 - [Docker:什么是容器](https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-a-container/) - [Docker:镜像](https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-an-image/) - [Docker:安装](https://docs.docker.com/engine/install/) --- ## 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 ````text fastapi>=0.121,<1 uvicorn[standard]>=0.30,<1 pydantic-settings>=2,<3 SQLAlchemy>=2.0,<2.1 asyncmy>=0.2,<0.3 PyMySQL[rsa]>=1.1,<2 alembic>=1.13,<2 httpx>=0.27,<1 celery[redis]>=5.4,<6 ```` #### 2. 新建忽略文件 Docker 构建不需要本机 .env、虚拟环境、数据库转储和 Git 历史。保留 .env.example 可供文档参考,但本课 Dockerfile 只显式 COPY 应用必需文件。 文件:.dockerignore ````text .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 ````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。 文件:终端 / 当前课时 ````bash 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 会提供 mysql DNS 与 DATABASE_URL。直接运行镜像若缺 DATABASE_URL 应明确失败,这比默认连错库更容易排错。 文件:终端 / 当前课时 ````bash docker run --rm notes-service:lab-01 python -c "import fastapi, sqlalchemy, asyncmy, pymysql; print(fastapi.__version__, sqlalchemy.__version__); print('mysql drivers import ok')" 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 . 并非语法错误,但需要更严格控制上下文,新手项目容易把秘密、缓存和备份带进去。 ### 技术文档 - [Docker:Dockerfile 参考](https://docs.docker.com/reference/dockerfile/) - [Docker:构建上下文与忽略文件](https://docs.docker.com/build/concepts/context/) - [pip:安全可重复安装](https://pip.pypa.io/en/stable/topics/secure-installs/) --- ## 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 安装只发生在临时构建工具容器。生成后审阅锁定变更并纳入代码管理。 文件:终端 / 当前课时 ````bash 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。不要复制课件中的占位摘要。多架构构建需明确目标平台并记录选择。 文件:终端 / 当前课时 ````bash 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 共用同一值。asyncmy需要匹配目标Python和架构的wheel;若目标平台没有wheel,在builder层安装编译所需工具,重新锁定并验证,运行层保留必要运行库。PyMySQL[rsa]支持MySQL 8.4默认的caching_sha2_password认证,不启用旧认证插件。 文件:Dockerfile ````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。 文件:终端 / 当前课时 ````bash 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 ````text 源码提交:实际 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,再更新构建参数。盲目拉最新也不等于通过了应用兼容测试。 ### 技术文档 - [Docker:多阶段构建](https://docs.docker.com/build/building/multi-stage/) - [Docker:构建缓存](https://docs.docker.com/build/cache/) - [Docker:固定基础镜像](https://docs.docker.com/build/building/best-practices/#pin-base-image-versions) - [pip-tools:依赖锁](https://pip-tools.readthedocs.io/en/stable/) --- ## 04 Compose 网络、服务 DNS 与端口 把 API、MySQL 和 Redis 放入同一专用网络,区分容器内地址和宿主调试端口。 ### 本节目标 - 通过服务名连接 MySQL 与 Redis - 绑定本机调试端口并避免冲突 - 用 Compose 配置检查和容器内探针定位连通性 ### 必要概念 #### localhost 指当前容器 API 中 127.0.0.1 指向 API 自己,不能到 MySQL。Compose 默认网络提供 mysql 和 redis 服务名解析;容器间连接使用 mysql:3306 与 redis:6379,宿主调试使用 127.0.0.1:3307 与 127.0.0.1:6380。 #### 端口发布的方向 ports 的左侧是宿主绑定,右侧是容器监听。容器间通信不依赖宿主端口映射;数据库不需要被公网发布。EXPOSE 是镜像元信息,也不会自动开放宿主端口。 #### 项目隔离 Compose project name 区分同机多个练习栈的网络与卷;不要设置固定 container_name,它会阻碍扩容并造成跨项目名字冲突。示例数据库为 notes_lab,app_user 是普通应用角色,mysql 管理账号不进入 API。 ### 动手实现 #### 1. 新建 Compose 专用环境文件 复制下列模板为 .env.compose 并填写两个不同的本机练习密码。文件不提交,后续统一命令显式 --env-file .env.compose;它用于 Compose 插值,不覆盖先前本机 Python 使用的 .env。连接串密码有特殊字符需 URL 编码。 文件:.env.compose ````dotenv COMPOSE_PROJECT_NAME=notes-lab DB_ADMIN_PASSWORD=CHANGE_ADMIN_PASSWORD APP_DB_PASSWORD=CHANGE_APP_PASSWORD DATABASE_URL=mysql+asyncmy://app_user:CHANGE_APP_PASSWORD@mysql:3306/notes_lab?charset=utf8mb4 REDIS_URL=redis://redis:6379/0 NOTES_IMAGE=notes-service:lab-02 ```` #### 2. 用官方镜像完成首次初始化 MySQL官方镜像仅在空数据目录初始化MYSQL_DATABASE、MYSQL_USER和MYSQL_PASSWORD。app_user取得notes_lab库内权限,API不使用root。后续修改环境文件不会修改已存在账户的密码;已有卷应通过审核后的ALTER USER修改凭据。这里不再编写自定义建用户脚本,镜像负责转义初始化输入。 文件:初始化约定 ````text MYSQL_ROOT_PASSWORD → 独立管理员密码 MYSQL_DATABASE=notes_lab → 仅首次创建练习库 MYSQL_USER=app_user / MYSQL_PASSWORD → 仅该库的练习账号 持久卷 mysql_data → /var/lib/mysql 服务名 mysql:3306 → 容器访问 127.0.0.1:3307 → 宿主访问 InnoDB / utf8mb4 / UTC → 全模块约定 ```` #### 3. 新建 compose.yaml 本段创建 API、MySQL、Redis 与独立卷;Redis 此时只是连通性练习,不承诺任务持久化。宿主数据库端口与数据库模块一致。API 初次启动可能尚未建表,第 6 课加入迁移和健康依赖。 文件:compose.yaml ````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"] mysql: image: mysql:8.4 command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_0900_ai_ci", "--default-time-zone=+00:00"] environment: MYSQL_ROOT_PASSWORD: ${DB_ADMIN_PASSWORD:?set admin password} MYSQL_DATABASE: notes_lab MYSQL_USER: app_user MYSQL_PASSWORD: ${APP_DB_PASSWORD:?set app password} ports: ["127.0.0.1:3307:3306"] volumes: ["mysql_data:/var/lib/mysql"] redis: image: redis:7.4 ports: ["127.0.0.1:6380:6379"] volumes: mysql_data: {} ```` #### 4. 检查配置并启动依赖 如果上一模块已经占用 3307 或 6380,先确认属于哪个独立练习栈,选择复用同一栈或停止其对应服务,不能直接杀掉未知进程。config --quiet 检查语法,不把展开后的密码配置贴进聊天或日志。 文件:终端 / 当前课时 ````bash docker compose --env-file .env.compose config --quiet docker compose --env-file .env.compose up -d mysql redis docker compose --env-file .env.compose ps docker compose --env-file .env.compose logs --tail 50 mysql ```` #### 5. 从 API 网络位置检测服务 DNS run --rm 临时容器共享项目网络,使用 Python socket 验证 DNS 和 TCP 端口。成功只说明网络可达,不能证明账号权限或表迁移完成。 第二条命令交互输入app_user密码,并验证认证及库权限;网络探针成功不代替这一步。 文件:终端 / 当前课时 ````bash docker compose --env-file .env.compose run --rm --no-deps api python -c "import socket; print(socket.gethostbyname('mysql')); socket.create_connection(('mysql',3306),3).close(); socket.create_connection(('redis',6379),3).close(); print('tcp ok')" docker compose --env-file .env.compose exec mysql mysql --protocol=TCP -h127.0.0.1 -uapp_user -p notes_lab -e "SELECT VERSION(),DATABASE(),CURRENT_USER(); SHOW GRANTS;" ```` ### 验收结果 - API 容器可解析 mysql/redis,并连接内部 3306/6379。 - 宿主端口仅绑定 127.0.0.1 的 8000、3307、6380。 - app_user 的 rolsuper 为 false;管理账号密码未注入 API。 ### 生产约束与故障演练 同机运行多个后端依赖,地址与端口必须在容器内外保持清楚。 #### 实现决策 - 应用用 DNS 服务名,不固定容器 IP。 - 数据库调试端口仅回环绑定,发布环境删去不必要端口。 #### 隔离环境演练 只在练习 .env.compose 把主机名改为不存在名称,观察 DNS 错误后恢复,区分认证失败与网络失败。 #### 恢复验收 - 能够按 DNS、TCP、认证、SQL 四层定位。 - 不同 Compose 项目资源有明确命名。 ### 常见错误 #### API 连接 127.0.0.1:3307 被拒绝 原因:把宿主访问地址用于容器内部。 处理:容器DATABASE_URL使用mysql:3306;宿主mysql客户端使用127.0.0.1:3307。 #### 修改 APP_DB_PASSWORD 后认证仍失败 原因:初始化脚本只在空数据目录执行,已有角色密码未改变。 处理:通过受控 SQL 轮换角色密码并同步应用秘密;不能删除卷来修复认证。 ### 扩展练习 为了让 API 访问数据库,是否必须发布 3307? 提示:Compose 服务互通走内部网络。 参考答案: 不需要。删去 mysql 的 ports 后,API 仍能使用 mysql:3306;宿主 mysql客户端 失去直接端口入口,可通过 docker compose exec 调试。线上通常不发布数据库宿主端口。 ### 技术文档 - [Docker:Compose 网络](https://docs.docker.com/compose/how-tos/networking/) - [Docker:端口发布](https://docs.docker.com/engine/network/port-publishing/) - [MySQL 官方容器镜像](https://github.com/docker-library/docs/blob/master/mysql/README.md) --- ## 05 MySQL 持久卷与可恢复备份 验证重建数据库容器后数据仍存在,在独立恢复容器中验证逻辑备份。 ### 本节目标 - 区分 named volume 与 bind mount 的权限责任 - 验证容器重建不丢 MySQL 数据 - 制作并恢复逻辑备份,记录恢复证据 ### 必要概念 #### 卷保存数据库目录 MySQL 8.4 官方镜像的本课持久路径为 /var/lib/mysql。命名卷由 Docker 管理宿主位置,容器删除不会自动删除命名卷。不要把卷当作备份,宿主磁盘故障和误删仍会影响它。 #### 权限与一致性 命名卷保存/var/lib/mysql,但卷本身不是备份。运行中的数据目录不能随手复制:逻辑备份用一致性mysqldump;物理备份使用支持该MySQL版本的一致性备份工具,并验证恢复与binlog位置。 #### 恢复才验证备份 备份成功退出只能证明生成了文件,不能证明恢复后应用能使用。恢复演练要用另一容器和另一卷,核对表、行数和业务查询;备份保留策略还需异机、加密、访问控制与实际 RPO/RTO。 #### 逻辑备份与binlog恢复分工 SQL转储恢复到快照时刻。更小RPO还需要连续保存binlog、匹配备份位置或GTID,并在独立实例验证时间点恢复;单独每日mysqldump不能满足分钟级数据损失目标。 ### 动手实现 #### 1. 确认卷与数据路径 只读检查 Compose 解析到的数据库挂载。取得容器 id 后可 inspect Mounts;不进入 Docker 内部卷目录手工改权限或编辑文件。 文件:终端 / 当前课时 ````bash docker compose --env-file .env.compose ps -q mysql docker compose --env-file .env.compose exec mysql mysql -uroot -p -e "SELECT @@datadir,@@version,@@default_storage_engine;" docker volume ls --filter label=com.docker.compose.project=notes-lab ```` #### 2. 创建专用持久化标记并重建容器 只在练习库创建volume_probe(InnoDB),重建mysql容器并保留mysql_data。重建后再次用app_user执行SELECT 1,连接和认证都成功后才查询标记;初始化期间连接失败不能证明卷丢失。下面每次-p均交互输入练习密码。 文件:终端 / 当前课时 ````bash docker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e "CREATE TABLE IF NOT EXISTS volume_probe (id INT PRIMARY KEY,value VARCHAR(100) NOT NULL) ENGINE=InnoDB; INSERT INTO volume_probe VALUES (1,'survives recreate') ON DUPLICATE KEY UPDATE value='survives recreate';" docker compose --env-file .env.compose up -d --force-recreate mysql docker compose --env-file .env.compose exec mysql mysql --protocol=TCP -h127.0.0.1 -uapp_user -p notes_lab -e "SELECT 1;" # 连接认证成功后继续;否则等待后重试。 docker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e "SELECT * FROM volume_probe;" ```` #### 3. 生成备份并从容器复制 使用同一8.4镜像中的mysqldump,直接写容器内SQL文件后复制,避免Windows重定向改变编码。--single-transaction对InnoDB取得一致性快照,备份期间禁止并发DDL;它不保证非事务表一致性。--no-tablespaces减少所需权限,--set-gtid-purged=OFF适用于本课独立逻辑恢复,不是构建复制节点的流程。包含触发器、存储过程和事件,备份账号需相应权限,本隔离练习使用root。 文件:终端 / 当前课时 ````bash mkdir backups docker compose --env-file .env.compose exec mysql mysqldump -uroot -p --single-transaction --routines --events --triggers --no-tablespaces --set-gtid-purged=OFF --hex-blob --default-character-set=utf8mb4 --result-file=/tmp/notes_lab.sql notes_lab docker compose --env-file .env.compose cp mysql:/tmp/notes_lab.sql ./backups/notes_lab.sql # 只有mysqldump退出0才使用该文件;文件存在、大小正常不代表恢复已验证。 ```` #### 4. 创建独立恢复容器 新建已忽略的.env.restore,只含MYSQL_ROOT_PASSWORD=独立练习密码。恢复容器使用新的命名卷,不发布端口;mysql -p交互输入恢复密码。等待日志报告ready并能执行SELECT 1,再导入到notes_restore。备份未使用--databases,因此不会自动切回源库名。重复练习时使用新的恢复库与卷,不叠加到上次非空库。 导入使用mysql批处理标准输入,不使用--force;出错即停止。仅在此隔离容器中从已有MYSQL_ROOT_PASSWORD环境变量临时提供认证,不把密码展开到命令参数;生产备份恢复用受控客户端选项文件。 文件:终端 / 当前课时 ````bash docker volume create notes-mysql-restore-data docker run -d --name notes-mysql-restore --env-file .env.restore -e MYSQL_DATABASE=notes_restore -v notes-mysql-restore-data:/var/lib/mysql mysql:8.4 --default-time-zone=+00:00 docker logs --tail 30 notes-mysql-restore docker exec -it notes-mysql-restore mysql -uroot -p -e "SELECT 1;" # 成功后再复制,用批处理标准输入恢复,检查退出码。 docker cp ./backups/notes_lab.sql notes-mysql-restore:/tmp/notes_lab.sql docker exec notes-mysql-restore sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --default-character-set=utf8mb4 notes_restore < /tmp/notes_lab.sql' ```` #### 5. 核对恢复并保留证据 本课尚未执行第06课的应用迁移,因此只核对已创建的 volume_probe,不查询尚不存在的 notes 或 alembic_version。恢复标记应为 id=1、value=survives recreate。完成第06课迁移并创建笔记后,再重复备份和恢复流程;使用新的恢复数据库或新的专用恢复卷,避免把第二份备份叠加到非空恢复库,再比较 notes 数量和 alembic_version。此时才完成业务数据恢复验收。当前先停止恢复容器,保留恢复卷用于核查。 文件:终端 / 当前课时 ````bash docker exec -it notes-mysql-restore mysql -uroot -p notes_restore -e "SELECT * FROM volume_probe; SHOW TABLE STATUS LIKE 'volume_probe';" docker stop notes-mysql-restore # 第06课迁移并创建笔记后,重新备份到新的空恢复库,再核对: # SELECT count(*) FROM notes; # SELECT version_num FROM alembic_version; ```` ### 验收结果 - 重建 mysql 容器后 volume_probe 行仍存在。 - mysqldump与mysql导入均退出0,恢复后的volume_probe内容和InnoDB引擎符合预期。 - 独立恢复库包含正确的 volume_probe,原练习库未被覆盖;业务表恢复在第06课迁移后用新备份补验。 ### 生产约束与故障演练 镜像升级必须保留数据库,宿主损坏后也应有可恢复副本。 #### 实现决策 - 容器和数据库卷独立管理。 - 备份在独立环境恢复后才计入可用备份。 #### 隔离环境演练 重建练习数据库容器,再把备份恢复到另一卷并核对 volume_probe。完成第06课迁移后,另取新备份恢复到新的空恢复库,补验业务行数和迁移版本。 #### 恢复验收 - 重建不丢数据。 - 有一次完整备份恢复的实际记录。 ### 常见错误 #### 重建容器后数据全没了 原因:未挂载持久卷、挂载路径错误,或切换了 Compose project name。 处理:检查 Mounts 和卷标签,确认实际使用的 mysql_data;不要在未确认旧卷前初始化覆盖或删除。 #### 恢复时报 archive 格式错误 原因:用不支持二进制透明传输的 Shell 重定向生成备份。 处理:使用mysqldump --result-file在容器内直接生成SQL文件,再docker cp;必须验证命令退出状态并恢复到独立空库。 ### 扩展练习 每天把 MySQLDATA 目录复制到同一宿主另一个文件夹能否替代备份方案? 提示:分别考虑运行中一致性与宿主故障。 参考答案: 不能。普通文件复制可能得到不一致数据库,而且宿主故障会同时损坏原件与副本。使用数据库支持的备份协议,并保留异机副本、加密密钥与定期恢复证据。 ### 技术文档 - [MySQL 8.4:mysqldump](https://dev.mysql.com/doc/refman/8.4/en/mysqldump.html) - [MySQL 8.4:SQL格式备份恢复](https://dev.mysql.com/doc/refman/8.4/en/reloading-sql-format-dumps.html) - [Docker volumes](https://docs.docker.com/engine/storage/volumes/) --- ## 06 健康检查、启动重试与一次性迁移 让 API 等待数据库可用与迁移成功,同时处理运行期间的依赖故障。 ### 本节目标 - 区分容器运行、健康、应用就绪与迁移完成 - 用 Compose 条件依赖组织首次启动 - 为瞬时断连设计有界重试而不重放未知写事务 ### 必要概念 #### running 不是 ready 容器进程已创建不代表MySQL完成初始化,也不代表业务表已迁移。数据库探针执行认证后的SELECT 1,应用/ready验证自己的数据库连接;迁移作业再核对表结构和revision。 #### 启动顺序只覆盖启动 depends_on condition 控制 Compose 启动前置条件,不能保证依赖永远在线。运行时数据库重启仍会引发请求失败,连接池需检测失效连接,调用方需按幂等规则重试。 #### 迁移是独立作业 一个 migrate 容器执行 alembic upgrade head,退出码 0 才允许 API 启动。不让多个 API worker 各自执行迁移。迁移失败保留日志与旧应用,不能循环忽略失败继续接流量。 ### 动手实现 #### 1. 增加数据库和 Redis 健康检查 将以下字段分别合并到mysql与redis服务。MySQL探针实际执行SELECT 1,能够检测认证失败;不能用只判断进程存活的ping代替就绪检查。$$延迟到容器Shell展开变量。此处沿用本机练习环境变量,生产应使用受控客户端凭据文件。 文件:compose.yaml ````yaml # mysql 服务内 healthcheck: test: ["CMD-SHELL", 'MYSQL_PWD="$${MYSQL_PASSWORD}" mysql --protocol=TCP -h127.0.0.1 -u"$${MYSQL_USER}" --database="$${MYSQL_DATABASE}" --connect-timeout=2 -e "SELECT 1" >/dev/null 2>&1'] interval: 5s timeout: 3s retries: 20 start_period: 30s # redis 服务内 healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 2s retries: 5 ```` #### 2. 新增一次性迁移服务 合并到 services 层,镜像与 API 完全相同。练习迁移使用 app_user 的 DDL 权限;生产用单独迁移凭据覆盖此服务的 DATABASE_URL,API 保持更小权限。 MySQL DDL通常隐式提交,迁移失败可能已经完成部分结构变更;先核对实际结构与revision,再修复或补偿,不能直接假定整个迁移事务已回滚。 文件:compose.yaml ````yaml migrate: image: ${NOTES_IMAGE:?set NOTES_IMAGE} command: ["python", "-m", "alembic", "upgrade", "head"] environment: DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL} depends_on: mysql: condition: service_healthy restart: "no" ```` #### 3. 为 API 添加就绪探针与条件依赖 合并到已有 api 服务。urllib 已在 Python 标准库,无需额外安装 curl。/ready 是前置模块实现的数据库探针;用户自定义应用须先实现同等端点。start_period 不屏蔽真实请求,只影响健康状态统计。 文件:compose.yaml ````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) 不算服务故障。 文件:终端 / 当前课时 ````bash 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 mysql mysql -uapp_user -p notes_lab -e "SELECT version_num FROM alembic_version;" ```` #### 5. 演练运行时数据库短暂停止 仅在专用本机栈停止 mysql,此时 /live 应仍为 200、/ready 为 503;重启数据库后连接池 pre_ping 应逐步恢复。Docker 标记 unhealthy 本身不会自动重启容器,restart 策略主要响应进程退出。写入失败不要盲目自动重放,用幂等键确认结果。 文件:终端 / 当前课时 ````bash docker compose --env-file .env.compose stop mysql docker compose --env-file .env.compose logs --tail 30 api docker compose --env-file .env.compose start mysql docker compose --env-file .env.compose ps ```` ### 验收结果 - 首次启动迁移先成功退出,API 随后 ready。 - 迁移失败时 API 不按成功依赖启动,日志保留真实异常。 - 数据库重启期间 readiness 失败,恢复后可创建读取数据,无隐式无限重试。 ### 生产约束与故障演练 整套服务在新机器首次启动,以及运行期间数据库维护重启。 #### 实现决策 - 依赖健康、迁移完成和业务就绪分别验证。 - 运行时重试有限,写请求按幂等协议恢复。 #### 隔离环境演练 停止练习 mysql,观察 /live 与 /ready 分离,恢复后验证原数据和新写入。 #### 恢复验收 - 共享数据库故障不会触发无意义的无限重启。 - 启动失败能够定位到依赖、迁移或应用阶段。 ### 常见错误 #### depends_on 后仍出现连接拒绝 原因:只写了服务名依赖,没等待健康;或故障发生在启动之后。 处理:首次启动使用 health 条件,运行期间通过连接恢复、超时和调用方幂等重试处理。 #### 容器 unhealthy 却没有自动重启 原因:把健康状态与 restart 策略混为一谈。 处理:明确由部署平台或外部监控处理健康流量与重启;不要让所有实例因共享数据库故障一起重启。 ### 扩展练习 能否把 alembic upgrade head 放进 API CMD 前面,每个副本都执行? 提示:扩容时有多少个进程会同时触发 DDL? 参考答案: 不应。使用单独迁移步骤并保证唯一执行;多个进程同时迁移会竞争锁与版本状态。迁移成功是发布门槛,不是每个请求实例的初始化副作用。 ### 技术文档 - [Docker:启动顺序](https://docs.docker.com/compose/how-tos/startup-order/) - [Docker:健康检查](https://docs.docker.com/reference/dockerfile/#healthcheck) - [MySQL 8.4:隐式提交](https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html) --- ## 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 ````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,不把完整环境配置输出到公共日志。 文件:终端 / 当前课时 ````bash 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。 文件:终端 / 当前课时 ````bash 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 没有自动负载均衡排空能力。 文件:终端 / 当前课时 ````bash 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 ````text API:1 进程/容器;数据库池 5;内存上限 512MiB;关闭预算 25s < 容器35s Worker:按第9课单独配置并发;连接数按实际执行模型计算 宿主:为 MySQL、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 数必须一起预算。 ### 技术文档 - [Docker:资源限制](https://docs.docker.com/engine/containers/resource_constraints/) - [Docker:Compose 服务资源字段](https://docs.docker.com/reference/compose-file/services/) - [Docker:stop](https://docs.docker.com/reference/cli/docker/container/stop/) - [Uvicorn:设置](https://uvicorn.dev/settings/) --- ## 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 ````python 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,填写 mysql:3306 的容器连接串。以下为 Linux / WSL 的本地 file-backed secrets 方案:父目录由当前可信宿主账户持有且权限700,单个绑定文件644,使容器UID10001可读。其他普通宿主账户无法穿过700父目录;有Docker控制权限的账户仍属于受信任管理员。不能仅写uid/gid/mode并假设Compose会重映射文件权限。 文件:终端 / 当前课时 ````bash 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 ````yaml # compose.yaml 顶层 secrets: database_url: file: ./secrets/database_url # api 与 migrate 各自服务内添加 # secrets: # - database_url ```` #### 4. 收紧 API 运行权限 合并到 api 服务并重建镜像以包含新的配置模块。根文件系统只读,临时目录使用有界 tmpfs;app 不写本地持久文件。Celery 需要的调度状态在第 9 课单独处理。 文件:compose.yaml ````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,否则记录未执行原因,不声称扫描通过。 文件:终端 / 当前课时 ````bash 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。 ### 技术文档 - [Docker:Compose Secrets](https://docs.docker.com/compose/how-tos/use-secrets/) - [Docker:构建秘密](https://docs.docker.com/build/building/secrets/) - [Pydantic Settings:Secrets](https://docs.pydantic.dev/latest/concepts/pydantic_settings/#secrets) - [Docker Scout:CVE 分析](https://docs.docker.com/scout/quickstart/) --- ## 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 ````dockerfile COPY tasks_lab ./tasks_lab COPY deploy/run_role.py ./deploy/run_role.py ```` #### 2. 为任务角色建立秘密加载入口 新建deploy/run_role.py。API的SQLAlchemy异步URL使用mysql+asyncmy://;任务的同步PyMySQL适配器接受mysql+pymysql://,均指向mysql:3306/notes_lab。入口从三个只读秘密文件读取环境变量后exec目标进程,不打印值。同步连接的解析、UTC与事务设置继续复用任务模块state_store.py。 文件:deploy/run_role.py ````python 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 为 mysql+pymysql://app_user:URL编码密码@mysql:3306/notes_lab。三个URL文件由task服务读取;broker配置只读挂载,不提交到仓库。 本地file-backed secrets按第8课使用宿主受控父目录700、单秘密文件644,使UID10001可读取实际挂载;启动前用run --no-deps检查可读性且不打印内容。TASK_BROKER_PASSWORD建议本机生成高熵十六进制值,使Redis配置requirepass行无需额外引号转义。 文件:compose.yaml ````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 ````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 ````python 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,再启动并核对注册任务和队列。 文件:终端 / 当前课时 ````bash 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课周期任务完成后单实例部署。 文件:终端 / 当前课时 ````bash docker compose --env-file .env.compose stop worker worker-bulk # 以下为Bash/WSL,在容器内用真实transaction()适配器选择笔记再提交。 docker compose --env-file .env.compose run --rm -T --no-deps worker python /app/deploy/run_role.py python - <<'PY' from state_store import transaction, submit_snapshot with transaction() as cur: cur.execute("SELECT id FROM notes ORDER BY id LIMIT 1") row = cur.fetchone() assert row, "create a note first" print(submit_snapshot([row["id"]])) PY docker compose --env-file .env.compose start worker worker-bulk docker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e "SELECT id,status,result_ref,attempts FROM export_jobs ORDER BY created_at DESC LIMIT 10;" # 将JOB_ID替换为刚返回的真实UUID,完成后验收结果文件。 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 只消费对应队列。 - MySQL中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 与连接生命周期。 处理:按进程模型重新预算,在子进程创建/重置连接资源;不继承已建立连接。 ### 扩展练习 为什么API和同步任务使用不同的MySQL连接URL? 提示:区分SQLAlchemy方言URL与MySQL驱动DSN。 参考答案: SQLAlchemy的mysql+asyncmy://选择异步驱动;任务state_store适配器解析mysql+pymysql://并调用同步PyMySQL。两者可以使用相同库和账号,但驱动入口及调用方式不同,不能把异步SQLAlchemy URL直接交给同步驱动。 ### 技术文档 - [Celery:Worker 管理](https://docs.celeryq.dev/en/stable/userguide/workers.html) - [Celery:Redis 注意事项](https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/redis.html) - [Redis:持久化](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/) --- ## 10 镜像版本、发布回滚与故障验收 用不可混淆的版本标签与镜像摘要发布,保留数据库兼容窗口并验证回退行为。 ### 本节目标 - 记录并部署明确的镜像版本或摘要 - 执行迁移在前、流量切换在后的发布流程 - 区分应用回滚、数据库回滚与数据恢复 ### 必要概念 #### 发布的是已验证镜像 本机 image ID 与仓库 digest 是不同概念;推送后以仓库名@sha256 选择不可变发布物。标签可便于阅读但可能被覆盖,不能只保存 latest。预发布和生产使用同一已验证摘要,避免在每台机器重建出不同依赖。 #### Compose 的可用性边界 单机单副本 Compose 更新通常会有短暂中断,不承诺零停机。需要平滑切换时部署两个版本后由代理切换,或者使用具备滚动与就绪能力的平台;数据库迁移仍须兼容新旧版本。 #### 回滚不等于删数据 旧镜像能否回退取决于当前数据库结构是否兼容。新增列通常可保留供旧版忽略,删除/重命名/数据语义改变可能阻止回退。不能为了退应用版本就清空数据库卷或机械执行 downgrade。 ### 动手实现 #### 1. 记录当前可回退版本 发布前保存当前 NOTES_IMAGE、迁移版本、配置版本和必要备份。只记录秘密文件的版本标识或来源,不把内容复制进 release-record.md。 文件:release-record.md ````text 版本:notes-service:release-2026-09-10-01 或实际仓库 digest 源码提交:实际 SHA 当前迁移 revision:实际 alembic current 目标 revision:待发布版本所需 revision 兼容范围:旧应用可读取/写入的新结构范围 备份与恢复验证:文件标识、时间、恢复证据 回退触发:例如连续5分钟错误率超目标;须先定义观测窗口 ```` #### 2. 构建、标记并本机验收 构建使用第 3 课已验证基础 digest。用户拥有并授权的镜像仓库才可执行登录与推送;课件提供命令模板,不实际向外发布。标签中使用真实版本或 commit,不重复覆盖旧发布标签。 以下命令在Bash/WSL运行,PYTHON_IMAGE必须是第03课保存的完整摘要;开启新终端时先恢复已审核的变量,不能静默回退到浮动标签。 文件:终端 / 当前课时 ````bash 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, asyncmy, pymysql; 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 兼容审核。迁移失败即停止后续发布。 文件:终端 / 当前课时 ````bash 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/MySQL 镜像主版本,也不自动操作数据库迁移。 文件:终端 / 当前课时 ````bash # 将 .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、负载均衡明确标记未验证。 文件:终端 / 当前课时 ````text 场景 | 观测 | 恢复动作 错误数据库密码 | readiness失败,认证日志 | 修正秘密并重建API;不删卷 错误应用镜像 | API启动失败,旧版可用性记录 | 切回兼容镜像 worker停止 | 队列积压,API仍可提交 | 启动worker,核对任务去重 OOM演练 | 专用容器OOMKilled | 调整预算并检查宿主余量 迁移失败 | migrate退出非0 | 停止发布,修复迁移或锁阻塞 数据库容器重建 | 卷保留,原笔记可读 | 等待ready并执行业务探针 ```` ### 验收结果 - 发布记录包含真实镜像标识、迁移版本和回退边界。 - 迁移失败不会继续切换 API 与 worker。 - 回退应用镜像后业务探针通过,数据库卷和已提交数据保留。 ### 生产约束与故障演练 一次发布必须能够定位软件版本,并在已声明兼容范围内回退。 #### 实现决策 - 构建一次并以明确版本交付。 - 应用回退与数据库恢复分开决策,保持数据卷。 - MySQL的原子DDL保证单条受支持DDL的原子性,不把多条Alembic操作变成可整体回滚事务;发布门槛同时核对实际schema与revision。 #### 隔离环境演练 在本机发布一个故意缺配置的应用版本,确认错误后回到上一镜像并核对既有数据。 #### 恢复验收 - 有实际回退耗时和业务恢复结果。 - 未验证的仓库、远程部署和高可用能力明确标记。 ### 常见错误 #### latest 回退后仍运行同一坏版本 原因:标签被覆盖,无法指明旧镜像内容。 处理:保存不可变版本标签与仓库摘要,部署时以明确摘要选择。 #### 应用回退成功但读数据失败 原因:新迁移删除或改变了旧应用依赖的字段。 处理:发布前验证兼容窗口;不可兼容时执行修复版本或已验证恢复方案,不机械 downgrade。 ### 扩展练习 怎样证明“回滚方案可用”,而非只写一句“退回旧版本”? 提示:在同一新数据库结构上运行旧镜像并发请求。 参考答案: 在隔离预发布库先执行新迁移,再部署旧镜像执行读写与幂等探针,记录结果和不可兼容点。若旧版写入破坏新语义,回退窗口应关闭,改用前向修复。 ### 技术文档 - [Docker:镜像标签](https://docs.docker.com/reference/cli/docker/image/tag/) - [Docker:镜像摘要](https://docs.docker.com/dhi/core-concepts/digests/) - [Docker:Compose up](https://docs.docker.com/reference/cli/docker/compose/up/)