| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098 |
- [
- {
- "id": "01",
- "title": "镜像、容器与进程生命周期",
- "summary": "从一个临时 Python 容器开始,观察进程退出、停止、重建与数据保留的区别。",
- "group": "镜像与构建",
- "objectives": [
- "区分镜像模板与运行中的容器",
- "找到容器进程、日志和退出状态",
- "判断哪些数据应写入卷而非容器可写层"
- ],
- "concepts": [
- {
- "title": "容器运行的是进程",
- "body": "镜像是文件系统和启动配置;容器是基于镜像创建的进程及其隔离环境。主进程退出,容器便停止,Docker 不会因为容器存在就持续执行应用。容器不是需要手工登录安装软件的长期虚拟机。"
- },
- {
- "title": "停止与删除不同",
- "body": "stop 发送停止信号并等待,start 重用原容器,rm 删除已停止容器的可写层。重新 run 创建另一份容器。镜像和显式命名卷有独立生命周期,重建 API 容器不应删除数据库卷。"
- },
- {
- "title": "本模块练习边界",
- "body": "使用 Docker Desktop 的 Linux 容器模式或独立 Linux 练习机,先完成 API+数据库模块的 notes-service。所有命令针对本地专用练习项目;不连接线上 Docker context,不挂载 Docker socket 到应用。未完成前置代码时,端点和 worker 不会自动存在。"
- }
- ],
- "steps": [
- {
- "title": "核对 Docker 目标",
- "body": "执行下列只读命令,确认 context 指向本机练习环境,Linux 容器模式,Compose V2 可用。若无 Docker,应先按官方安装文档完成当前系统安装,不执行来源不明的一键脚本。",
- "code": "docker context show\ndocker info --format '{{.OSType}}'\ndocker compose version\ndocker version",
- "lang": "bash"
- },
- {
- "title": "运行一次性 Python 进程",
- "body": "该容器运行完 print 就退出,--rm 清理它自己的可写层。它没有数据库、持久卷或宿主目录挂载,不影响 notes 数据。",
- "code": "docker run --rm python:3.11-slim python -c \"import sys; print(sys.version)\"",
- "lang": "bash"
- },
- {
- "title": "观察持续运行的主进程",
- "body": "创建专用生命周期容器,查看它的日志和进程。这里只写容器内临时文件,接下来可以安全删除该练习容器。Windows PowerShell 可原样使用双引号内的 Python 代码。",
- "code": "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)\"\ndocker logs notes-lifecycle\ndocker top notes-lifecycle\ndocker inspect notes-lifecycle --format '{{.State.Status}}'",
- "lang": "bash"
- },
- {
- "title": "停止、重启再查看文件",
- "body": "stop 后仍保留原容器文件层,start 重新运行启动命令。观察完再次 stop,并只删除名为 notes-lifecycle 的容器;不要用批量 prune 清理所有项目。",
- "code": "docker stop --time 5 notes-lifecycle\ndocker start notes-lifecycle\ndocker exec notes-lifecycle cat /tmp/probe.txt\ndocker stop --time 5 notes-lifecycle\ndocker rm notes-lifecycle",
- "lang": "bash"
- },
- {
- "title": "比较新容器的文件层",
- "body": "使用相同镜像创建新容器,它没有前一容器的 probe 文件。预期 False 不代表镜像损坏,而是临时写入没有进入镜像或持久卷。",
- "code": "docker run --rm python:3.11-slim python -c \"from pathlib import Path; print(Path('/tmp/probe.txt').exists())\"",
- "lang": "bash"
- }
- ],
- "checks": [
- "docker info 的 OSType 为 linux,context 明确指向练习环境。",
- "能够观察主进程退出导致容器停止,日志可独立读取。",
- "新容器中没有上一容器临时文件,能说明卷的必要性。"
- ],
- "pitfalls": [
- {
- "symptom": "docker run 很快结束,以为容器启动失败",
- "cause": "启动命令只是一个会完成的脚本。",
- "fix": "查看 docker logs 与退出码,服务需要持续监听进程,而非用 tail 掩盖真实服务退出。"
- },
- {
- "symptom": "进入容器 pip install 后重建又缺包",
- "cause": "安装发生在容器临时文件层。",
- "fix": "将依赖写进 Dockerfile 与锁定文件,通过重建镜像保留。"
- }
- ],
- "exercise": {
- "task": "镜像没有变化,为何两个容器的文件可能不同?",
- "hint": "启动配置相同不代表运行时写入相同。",
- "answer": "容器各自拥有可写层和进程状态;环境变量、挂载卷和启动命令也可能不同。把需要复现的软件放进镜像,把需要保留的业务数据放进明确的持久存储。"
- },
- "refs": [
- {
- "label": "Docker:什么是容器",
- "url": "https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-a-container/"
- },
- {
- "label": "Docker:镜像",
- "url": "https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-an-image/"
- },
- {
- "label": "Docker:安装",
- "url": "https://docs.docker.com/engine/install/"
- }
- ],
- "production": {
- "scenario": "开发者需要交付能在另一台机器启动的服务,而非一份手工配置记录。",
- "decisions": [
- "以主进程状态判断服务是否运行。",
- "只清理明确命名的练习容器,持久数据单独管理。"
- ],
- "failureDrill": "停止并删除无挂载的生命周期容器,再从同一镜像创建,观察临时文件消失。",
- "acceptance": [
- "能解释进程、可写层、镜像与卷各自的生命周期。",
- "所有操作目标都可通过 context 和容器名定位。"
- ]
- }
- },
- {
- "id": "02",
- "title": "为 FastAPI 编写非 Root Dockerfile",
- "summary": "把 notes-service 源码与固定依赖构建进镜像,以非 root 用户启动 Uvicorn。",
- "group": "镜像与构建",
- "objectives": [
- "编写最小可运行 Dockerfile 和 .dockerignore",
- "用锁定依赖避免不同机器安装漂移",
- "以 exec 形式运行非 root 应用进程"
- ],
- "concepts": [
- {
- "title": "构建上下文决定哪些文件可被复制",
- "body": "docker build 的最后一个路径是构建上下文,COPY 只能读取其内容。把 .env、备份、虚拟环境排除可减少构建大小并避免秘密进入镜像;在后续 RUN 删除秘密无法抹去早期镜像层。"
- },
- {
- "title": "运行用户与监听地址",
- "body": "容器内应用使用固定非 root UID,监听 0.0.0.0:8000 才能接收容器网卡流量。宿主发布到 127.0.0.1:8000 限制外部访问。非 root 不等于完整隔离,但能减少意外写入和权限范围。"
- },
- {
- "title": "进程命令",
- "body": "JSON 数组形式 CMD 直接运行应用进程,停止信号可以到达 Uvicorn;不要用 shell 后台启动加无限 sleep。生产镜像不启用 --reload,日志写 stdout/stderr,数据库迁移独立运行。"
- }
- ],
- "steps": [
- {
- "title": "建立目标平台锁定依赖",
- "body": "在 notes-service 新建 requirements.in,采用下列主依赖。已有业务的额外依赖应一起加入。用目标 Linux/Python3.11 的构建环境生成 requirements-lock.txt;不要把 Windows 专有库锁直接用于 Linux。下一课给出容器内生成命令。",
- "code": "fastapi>=0.121,<1\nuvicorn[standard]>=0.30,<1\npydantic-settings>=2,<3\nSQLAlchemy>=2.0,<2.1\npsycopg[binary]>=3,<4\nalembic>=1.13,<2\nhttpx>=0.27,<1\ncelery[redis]>=5.4,<6",
- "lang": "text",
- "filename": "requirements.in"
- },
- {
- "title": "新建忽略文件",
- "body": "Docker 构建不需要本机 .env、虚拟环境、数据库转储和 Git 历史。保留 .env.example 可供文档参考,但本课 Dockerfile 只显式 COPY 应用必需文件。",
- "code": ".git\n.venv\n.env\n.env.*\nsecrets/\nbackups/\n*.dump\n*.sql.gz\n__pycache__/\n.pytest_cache/\ntests/",
- "lang": "text",
- "filename": ".dockerignore"
- },
- {
- "title": "新建 Dockerfile",
- "body": "依赖完整 app 目录、alembic.ini、migrations 与生成的 requirements-lock.txt。Python slim 基础版本下一课固定为实际 digest;此处标签用于首次学习构建。COPY 不复制本机配置。",
- "code": "FROM python:3.11-slim\nENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_DISABLE_PIP_VERSION_CHECK=1\nWORKDIR /app\nCOPY requirements-lock.txt ./\nRUN 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\nCOPY app ./app\nCOPY alembic.ini ./\nCOPY migrations ./migrations\nUSER 10001:10001\nEXPOSE 8000\nCMD [\"python\", \"-m\", \"uvicorn\", \"app.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\", \"--timeout-graceful-shutdown\", \"25\"]",
- "lang": "dockerfile",
- "filename": "Dockerfile"
- },
- {
- "title": "构建并核对镜像用户",
- "body": "完成下一课锁文件生成后在项目根目录构建。此检查不需要数据库配置,因为 python -c 直接执行,没有导入 app。用户 id 应为 10001。",
- "code": "docker build -t notes-service:lab-01 .\ndocker run --rm notes-service:lab-01 python -c \"import os; print(os.getuid())\"\ndocker image inspect notes-service:lab-01 --format '{{.Config.User}} {{json .Config.Cmd}}'",
- "lang": "bash"
- },
- {
- "title": "通过练习网络配置启动服务",
- "body": "本课暂不启动完整服务,先验证代码和依赖存在;第 4 课 Compose 会提供 postgres DNS 与 DATABASE_URL。直接运行镜像若缺 DATABASE_URL 应明确失败,这比默认连错库更容易排错。",
- "code": "docker run --rm notes-service:lab-01 python -c \"import fastapi, sqlalchemy, psycopg; print(fastapi.__version__, sqlalchemy.__version__, psycopg.__version__)\"\ndocker run --rm notes-service:lab-01 python -c \"from pathlib import Path; print(Path('/app/alembic.ini').exists(), Path('/app/migrations/env.py').exists())\"",
- "lang": "bash"
- }
- ],
- "checks": [
- "镜像运行用户为 10001,不需要 --privileged。",
- "镜像包含应用与迁移文件,不含 .env 和备份。",
- "CMD 直接启动 Uvicorn,未启用 reload;依赖版本能在容器内打印。"
- ],
- "pitfalls": [
- {
- "symptom": "容器日志显示已启动,宿主访问不到",
- "cause": "Uvicorn 只监听容器回环地址 127.0.0.1。",
- "fix": "容器内监听 0.0.0.0,宿主端口发布是否公开由 ports 绑定地址控制。"
- },
- {
- "symptom": "pip --require-hashes 拒绝安装",
- "cause": "锁文件不是带完整依赖与哈希的锁文件。",
- "fix": "按下一课用 pip-tools 生成,不能去掉哈希选项假装已经固定依赖。"
- }
- ],
- "exercise": {
- "task": "为什么不直接 COPY . /app?",
- "hint": "检查构建上下文中的本机配置与数据。",
- "answer": "显式 COPY 应用、迁移和锁文件能缩小误带入范围;.dockerignore 再提供过滤。COPY . 并非语法错误,但需要更严格控制上下文,新手项目容易把秘密、缓存和备份带进去。"
- },
- "refs": [
- {
- "label": "Docker:Dockerfile 参考",
- "url": "https://docs.docker.com/reference/dockerfile/"
- },
- {
- "label": "Docker:构建上下文与忽略文件",
- "url": "https://docs.docker.com/build/concepts/context/"
- },
- {
- "label": "pip:安全可重复安装",
- "url": "https://pip.pypa.io/en/stable/topics/secure-installs/"
- }
- ],
- "production": {
- "scenario": "应用镜像交给部署机器运行,需要固定依赖与明确的低权限进程。",
- "decisions": [
- "运行时依赖装在构建阶段,秘密仅运行时注入。",
- "只复制需要的目录,非 root 启动。"
- ],
- "failureDrill": "在构建上下文放一个无敏感内容的 .env 练习标记,构建后确认 /app/.env 不存在。",
- "acceptance": [
- "镜像内没有本机配置标记。",
- "容器用户和启动命令可审计。"
- ]
- }
- },
- {
- "id": "03",
- "title": "分层缓存、多阶段构建与可重复输入",
- "summary": "将依赖构建和运行文件分开,固定基础镜像与 Python 包,让构建结果可以追溯。",
- "group": "镜像与构建",
- "objectives": [
- "用目标平台生成带哈希依赖锁",
- "理解 COPY 顺序对构建缓存的影响",
- "以多阶段构建移除构建工具并记录镜像摘要"
- ],
- "concepts": [
- {
- "title": "缓存按层失效",
- "body": "先 COPY 依赖锁再安装,最后 COPY 源码,业务代码修改通常不会让依赖安装层失效。缓存提升速度,不代表输入自动更新;基础镜像和锁文件更新仍需显式执行与测试。"
- },
- {
- "title": "标签与摘要",
- "body": "python:3.11-slim 标签可能随安全更新移动,digest 指向具体镜像内容。使用真实查询到的 digest 可固定基础镜像;固定也意味着必须有主动更新流程,不能永久停留在旧漏洞版本。"
- },
- {
- "title": "多阶段的边界",
- "body": "builder 创建虚拟环境,runtime 只复制环境与应用代码。二者使用相同基础镜像和架构,避免复制不兼容的本地扩展。哈希锁、基础 digest 和目标平台稳定输入;如果要求字节级相同镜像,还需控制构建时间戳与工具链,不能仅靠 Dockerfile 保证。"
- }
- ],
- "steps": [
- {
- "title": "在一次性 Linux 容器生成锁文件",
- "body": "以下为 Bash/WSL 在 notes-service 根目录执行;PowerShell 使用 ${PWD} 对应绝对路径替换 $(pwd)。pip-tools 安装只发生在临时构建工具容器。生成后审阅锁定变更并纳入代码管理。",
- "code": "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'",
- "lang": "bash"
- },
- {
- "title": "查询实际基础摘要",
- "body": "执行 pull 后查看 RepoDigests,将输出中的完整 python@sha256:... 记录到本机构建变量 PYTHON_IMAGE。不要复制课件中的占位摘要。多架构构建需明确目标平台并记录选择。",
- "code": "docker pull python:3.11-slim\ndocker image inspect python:3.11-slim --format '{{json .RepoDigests}}'\n# Bash / WSL:选择并保存刚拉取镜像的完整仓库摘要。\nPYTHON_IMAGE=$(docker image inspect python:3.11-slim --format '{{index .RepoDigests 0}}')\ncase \"$PYTHON_IMAGE\" in python@sha256:*) export PYTHON_IMAGE ;; *) echo 'unexpected image digest'; exit 1 ;; esac",
- "lang": "bash"
- },
- {
- "title": "替换 Dockerfile 为多阶段版本",
- "body": "用本段完整替换第 2 课 Dockerfile。PYTHON_IMAGE 的默认标签供练习,发布构建必须通过 --build-arg 传已记录的 digest;builder 与 runtime 共用同一值。psycopg[binary] 避免本课额外引入编译工具。",
- "code": "ARG PYTHON_IMAGE=python:3.11-slim\nFROM ${PYTHON_IMAGE} AS builder\nENV PIP_DISABLE_PIP_VERSION_CHECK=1\nWORKDIR /build\nRUN python -m venv /opt/venv\nCOPY requirements-lock.txt ./\nRUN /opt/venv/bin/python -m pip install --no-cache-dir --require-hashes -r requirements-lock.txt\n\nFROM ${PYTHON_IMAGE} AS runtime\nENV PATH=\"/opt/venv/bin:$PATH\" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1\nRUN groupadd --gid 10001 notes && useradd --uid 10001 --gid notes --no-create-home --shell /usr/sbin/nologin notes\nWORKDIR /app\nCOPY --from=builder /opt/venv /opt/venv\nCOPY app ./app\nCOPY alembic.ini ./\nCOPY migrations ./migrations\nUSER 10001:10001\nEXPOSE 8000\nCMD [\"python\", \"-m\", \"uvicorn\", \"app.main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\", \"--timeout-graceful-shutdown\", \"25\"]",
- "lang": "dockerfile",
- "filename": "Dockerfile"
- },
- {
- "title": "构建并观察缓存",
- "body": "在同一 Bash / WSL 终端使用上一阶段取得并验证的 PYTHON_IMAGE;新开终端时重新从已拉取镜像读取摘要。变量为空会在构建前停止。第一次完成后仅修改 app 中一个无副作用注释,再构建,依赖层应显示 CACHED。",
- "code": "docker build --progress=plain --build-arg PYTHON_IMAGE=\"${PYTHON_IMAGE:?set verified base digest}\" -t notes-service:lab-02 .\ndocker history notes-service:lab-02\ndocker image inspect notes-service:lab-02 --format '{{.Id}}'",
- "lang": "bash"
- },
- {
- "title": "记录构建证据与更新路径",
- "body": "创建 build-record.md,记录下面字段。依赖升级时在分支重新生成锁、构建新镜像并运行数据库测试,再发布;不能在运行容器内 pip install -U 修补并忘记回写镜像。",
- "code": "源码提交:实际 git commit SHA\n目标平台:例如 linux/amd64\n基础镜像:实际 python@sha256:...\n依赖锁:requirements-lock.txt 的版本/摘要\n应用镜像:本机 image ID;推送后补仓库 digest\n验证结果:执行命令、实际输出、未验证项",
- "lang": "text",
- "filename": "build-record.md"
- }
- ],
- "checks": [
- "requirements-lock.txt 包含递归依赖的固定版本与哈希,容器中 --require-hashes 安装通过。",
- "只改应用源码时依赖层命中缓存。",
- "构建记录能关联源码、基础镜像、依赖锁与最终镜像。"
- ],
- "pitfalls": [
- {
- "symptom": "复制本机 .venv 后容器启动找不到动态库",
- "cause": "宿主系统或架构与容器不一致。",
- "fix": "在目标 Linux 基础镜像内创建环境,builder/runtime 保持相同平台与 ABI。"
- },
- {
- "symptom": "固定基础摘要后长期没有安全更新",
- "cause": "把固定输入误当作无需维护。",
- "fix": "定期更新摘要和依赖锁,在测试通过后产生新发布版本,保留变更记录。"
- }
- ],
- "exercise": {
- "task": "--no-cache 是否保证获取最新基础镜像?",
- "hint": "构建缓存与拉取基础镜像是两件事。",
- "answer": "不保证。--pull 会检查标签的新内容;但如果固定 digest,应先审阅并选择新 digest,再更新构建参数。盲目拉最新也不等于通过了应用兼容测试。"
- },
- "refs": [
- {
- "label": "Docker:多阶段构建",
- "url": "https://docs.docker.com/build/building/multi-stage/"
- },
- {
- "label": "Docker:构建缓存",
- "url": "https://docs.docker.com/build/cache/"
- },
- {
- "label": "Docker:固定基础镜像",
- "url": "https://docs.docker.com/build/building/best-practices/#pin-base-image-versions"
- },
- {
- "label": "pip-tools:依赖锁",
- "url": "https://pip-tools.readthedocs.io/en/stable/"
- }
- ],
- "production": {
- "scenario": "同一版本需要在预发布和线上运行相同的软件输入。",
- "decisions": [
- "构建一次,按同一镜像摘要提升到下一个环境。",
- "构建工具留在 builder,运行层只包含运行所需文件。"
- ],
- "failureDrill": "修改源码与依赖锁分别构建,记录哪几层重建;还原后核对镜像关联记录。",
- "acceptance": [
- "能解释缓存结果。",
- "依赖升级产生新的可追溯发布物。"
- ]
- }
- },
- {
- "id": "04",
- "title": "Compose 网络、服务 DNS 与端口",
- "summary": "把 API、PostgreSQL 和 Redis 放入同一专用网络,区分容器内地址和宿主调试端口。",
- "group": "运行与资源",
- "objectives": [
- "通过服务名连接 PostgreSQL 与 Redis",
- "绑定本机调试端口并避免冲突",
- "用 Compose 配置检查和容器内探针定位连通性"
- ],
- "concepts": [
- {
- "title": "localhost 指当前容器",
- "body": "API 中 127.0.0.1 指向 API 自己,不能到 PostgreSQL。Compose 默认网络提供 postgres 和 redis 服务名解析;容器间连接使用 postgres:5432 与 redis:6379,宿主调试使用 127.0.0.1:5433 与 127.0.0.1:6380。"
- },
- {
- "title": "端口发布的方向",
- "body": "ports 的左侧是宿主绑定,右侧是容器监听。容器间通信不依赖宿主端口映射;数据库不需要被公网发布。EXPOSE 是镜像元信息,也不会自动开放宿主端口。"
- },
- {
- "title": "项目隔离",
- "body": "Compose project name 区分同机多个练习栈的网络与卷;不要设置固定 container_name,它会阻碍扩容并造成跨项目名字冲突。示例数据库为 notes_lab,app_user 是普通应用角色,postgres 管理账号不进入 API。"
- }
- ],
- "steps": [
- {
- "title": "新建 Compose 专用环境文件",
- "body": "复制下列模板为 .env.compose 并填写两个不同的本机练习密码。文件不提交,后续统一命令显式 --env-file .env.compose;它用于 Compose 插值,不覆盖先前本机 Python 使用的 .env。连接串密码有特殊字符需 URL 编码。",
- "code": "COMPOSE_PROJECT_NAME=notes-lab\nDB_ADMIN_PASSWORD=CHANGE_ADMIN_PASSWORD\nAPP_DB_PASSWORD=CHANGE_APP_PASSWORD\nDATABASE_URL=postgresql+psycopg://app_user:CHANGE_APP_PASSWORD@postgres:5432/notes_lab\nREDIS_URL=redis://redis:6379/0\nNOTES_IMAGE=notes-service:lab-02",
- "lang": "dotenv",
- "filename": ".env.compose"
- },
- {
- "title": "新建初始化脚本",
- "body": "新建 deploy/init/01-app.sh,保存 LF 换行。postgres 官方镜像在空数据目录首次初始化时执行脚本;后续改环境变量不会自动修改角色密码。psql 变量替换用 SQL 字符串引用处理密码,禁止直接字符串拼 SQL。练习 app_user 拥有 public schema 的 DDL 权限,生产迁移权限下一阶段单独拆分。",
- "code": "#!/bin/sh\nset -eu\npsql -v ON_ERROR_STOP=1 --username \"$POSTGRES_USER\" --dbname \"$POSTGRES_DB\" --set=app_password=\"$APP_DB_PASSWORD\" <<'SQL'\nCREATE ROLE app_user LOGIN PASSWORD :'app_password';\nGRANT CONNECT ON DATABASE notes_lab TO app_user;\nGRANT USAGE, CREATE ON SCHEMA public TO app_user;\nSQL",
- "lang": "bash",
- "filename": "deploy/init/01-app.sh"
- },
- {
- "title": "新建 compose.yaml",
- "body": "本段创建 API、Postgres、Redis 与独立卷;Redis 此时只是连通性练习,不承诺任务持久化。宿主数据库端口与数据库模块一致。API 初次启动可能尚未建表,第 6 课加入迁移和健康依赖。",
- "code": "services:\n api:\n image: ${NOTES_IMAGE:?set NOTES_IMAGE}\n environment:\n DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}\n REDIS_URL: ${REDIS_URL:?set REDIS_URL}\n ports: [\"127.0.0.1:8000:8000\"]\n postgres:\n image: postgres:16\n environment:\n POSTGRES_DB: notes_lab\n POSTGRES_USER: postgres\n POSTGRES_PASSWORD: ${DB_ADMIN_PASSWORD:?set admin password}\n APP_DB_PASSWORD: ${APP_DB_PASSWORD:?set app password}\n ports: [\"127.0.0.1:5433:5432\"]\n volumes:\n - pg_data:/var/lib/postgresql/data\n - ./deploy/init:/docker-entrypoint-initdb.d:ro\n redis:\n image: redis:7.4\n ports: [\"127.0.0.1:6380:6379\"]\nvolumes:\n pg_data:",
- "lang": "yaml",
- "filename": "compose.yaml"
- },
- {
- "title": "检查配置并启动依赖",
- "body": "如果上一模块已经占用 5433 或 6380,先确认属于哪个独立练习栈,选择复用同一栈或停止其对应服务,不能直接杀掉未知进程。config --quiet 检查语法,不把展开后的密码配置贴进聊天或日志。",
- "code": "docker compose --env-file .env.compose config --quiet\ndocker compose --env-file .env.compose up -d postgres redis\ndocker compose --env-file .env.compose ps\ndocker compose --env-file .env.compose logs --tail 50 postgres",
- "lang": "bash"
- },
- {
- "title": "从 API 网络位置检测服务 DNS",
- "body": "run --rm 临时容器共享项目网络,使用 Python socket 验证 DNS 和 TCP 端口。成功只说明网络可达,不能证明账号权限或表迁移完成。",
- "code": "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')\"\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SELECT rolname, rolsuper FROM pg_roles WHERE rolname='app_user';\"",
- "lang": "bash"
- }
- ],
- "checks": [
- "API 容器可解析 postgres/redis,并连接内部 5432/6379。",
- "宿主端口仅绑定 127.0.0.1 的 8000、5433、6380。",
- "app_user 的 rolsuper 为 false;管理账号密码未注入 API。"
- ],
- "pitfalls": [
- {
- "symptom": "API 连接 127.0.0.1:5433 被拒绝",
- "cause": "把宿主访问地址用于容器内部。",
- "fix": "容器 DATABASE_URL 使用 postgres:5432;宿主 psql 才使用本机 5433。"
- },
- {
- "symptom": "修改 APP_DB_PASSWORD 后认证仍失败",
- "cause": "初始化脚本只在空数据目录执行,已有角色密码未改变。",
- "fix": "通过受控 SQL 轮换角色密码并同步应用秘密;不能删除卷来修复认证。"
- }
- ],
- "exercise": {
- "task": "为了让 API 访问数据库,是否必须发布 5433?",
- "hint": "Compose 服务互通走内部网络。",
- "answer": "不需要。删去 postgres 的 ports 后,API 仍能使用 postgres:5432;宿主 psql 失去直接端口入口,可通过 docker compose exec 调试。线上通常不发布数据库宿主端口。"
- },
- "refs": [
- {
- "label": "Docker:Compose 网络",
- "url": "https://docs.docker.com/compose/how-tos/networking/"
- },
- {
- "label": "Docker:端口发布",
- "url": "https://docs.docker.com/engine/network/port-publishing/"
- },
- {
- "label": "Postgres 官方镜像说明",
- "url": "https://hub.docker.com/_/postgres"
- }
- ],
- "production": {
- "scenario": "同机运行多个后端依赖,地址与端口必须在容器内外保持清楚。",
- "decisions": [
- "应用用 DNS 服务名,不固定容器 IP。",
- "数据库调试端口仅回环绑定,发布环境删去不必要端口。"
- ],
- "failureDrill": "只在练习 .env.compose 把主机名改为不存在名称,观察 DNS 错误后恢复,区分认证失败与网络失败。",
- "acceptance": [
- "能够按 DNS、TCP、认证、SQL 四层定位。",
- "不同 Compose 项目资源有明确命名。"
- ]
- }
- },
- {
- "id": "05",
- "title": "PostgreSQL 持久卷与可恢复备份",
- "summary": "验证重建数据库容器后数据仍存在,在独立恢复容器中验证逻辑备份。",
- "group": "运行与资源",
- "objectives": [
- "区分 named volume 与 bind mount 的权限责任",
- "验证容器重建不丢 PostgreSQL 数据",
- "制作并恢复逻辑备份,记录恢复证据"
- ],
- "concepts": [
- {
- "title": "卷保存数据库目录",
- "body": "PostgreSQL 16 官方镜像的本课持久路径为 /var/lib/postgresql/data。命名卷由 Docker 管理宿主位置,容器删除不会自动删除命名卷。不要把卷当作备份,宿主磁盘故障和误删仍会影响它。"
- },
- {
- "title": "权限与一致性",
- "body": "宿主 bind mount 需要匹配数据库用户 UID/GID 与权限,直接 chmod 777 不是解决方案。运行中的 PGDATA 不能随手复制成可靠备份;用 pg_dump 做逻辑备份,物理备份需 pg_basebackup/WAL 等一致性协议。"
- },
- {
- "title": "恢复才验证备份",
- "body": "备份成功退出只能证明生成了文件,不能证明恢复后应用能使用。恢复演练要用另一容器和另一卷,核对表、行数和业务查询;备份保留策略还需异机、加密、访问控制与实际 RPO/RTO。"
- }
- ],
- "steps": [
- {
- "title": "确认卷与数据路径",
- "body": "只读检查 Compose 解析到的数据库挂载。取得容器 id 后可 inspect Mounts;不进入 Docker 内部卷目录手工改权限或编辑文件。",
- "code": "docker compose --env-file .env.compose ps -q postgres\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SHOW data_directory;\"\ndocker volume ls --filter label=com.docker.compose.project=notes-lab",
- "lang": "bash"
- },
- {
- "title": "创建专用持久化标记并重建容器",
- "body": "在练习库创建一张明确命名的验证表,重建仅 postgres 容器,保留 pg_data 卷。执行后查询同一行。正常生产更新仍要评估可用性与数据库版本兼容。 重建后先执行 pg_isready,返回 accepting connections 后再查询标记;尚未就绪时等待后重试,不把首次连接失败当作卷丢失。",
- "code": "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;\"\ndocker compose --env-file .env.compose up -d --force-recreate postgres\ndocker compose --env-file .env.compose exec postgres pg_isready -U postgres -d notes_lab\n# 上一步返回 accepting connections 后继续;否则等待后重试。\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SELECT * FROM volume_probe;\"",
- "lang": "bash"
- },
- {
- "title": "生成备份并从容器复制",
- "body": "先在容器内生成自定义格式备份,再 docker compose cp,避免旧版 Windows PowerShell 重定向二进制流时损坏文件。备份含业务数据,放到已忽略的 backups 目录;不上传到公开资源。",
- "code": "mkdir backups\ndocker compose --env-file .env.compose exec postgres pg_dump -U postgres -d notes_lab -Fc --no-owner --no-acl -f /tmp/notes_lab.dump\ndocker compose --env-file .env.compose cp postgres:/tmp/notes_lab.dump ./backups/notes_lab.dump\ndocker compose --env-file .env.compose exec postgres pg_restore --list /tmp/notes_lab.dump",
- "lang": "bash"
- },
- {
- "title": "创建独立恢复容器",
- "body": "新建 .env.restore,只含 POSTGRES_PASSWORD=独立练习密码,加入 .gitignore。恢复容器不发布端口,使用单独 named volume;docker exec 从同容器 Unix socket 操作。等待日志 ready 后再恢复,不复用业务 pg_data。",
- "code": "docker volume create notes-restore-data\ndocker run -d --name notes-restore --env-file .env.restore -e POSTGRES_DB=notes_restore -v notes-restore-data:/var/lib/postgresql/data postgres:16\ndocker logs --tail 30 notes-restore\ndocker cp ./backups/notes_lab.dump notes-restore:/tmp/notes_lab.dump\ndocker exec notes-restore pg_restore -U postgres -d notes_restore --no-owner --no-acl --exit-on-error /tmp/notes_lab.dump",
- "lang": "bash"
- },
- {
- "title": "核对恢复并保留证据",
- "body": "本课尚未执行第06课的应用迁移,因此只核对已创建的 volume_probe,不查询尚不存在的 notes 或 alembic_version。恢复标记应为 id=1、value=survives recreate。完成第06课迁移并创建笔记后,再重复备份和恢复流程;使用新的恢复数据库或新的专用恢复卷,避免把第二份备份叠加到非空恢复库,再比较 notes 数量和 alembic_version。此时才完成业务数据恢复验收。当前先停止恢复容器,保留恢复卷用于核查。",
- "code": "docker exec notes-restore psql -U postgres -d notes_restore -c \"SELECT * FROM volume_probe;\"\ndocker stop notes-restore\n# 第06课迁移、创建笔记并重新备份到新的隔离恢复库后,再在新恢复库查询:\n# SELECT count(*) FROM notes;\n# SELECT version_num FROM alembic_version;",
- "lang": "bash"
- }
- ],
- "checks": [
- "重建 postgres 容器后 volume_probe 行仍存在。",
- "pg_restore --list 可读取备份结构,恢复命令退出为 0。",
- "独立恢复库包含正确的 volume_probe,原练习库未被覆盖;业务表恢复在第06课迁移后用新备份补验。"
- ],
- "pitfalls": [
- {
- "symptom": "重建容器后数据全没了",
- "cause": "未挂载持久卷、挂载路径错误,或切换了 Compose project name。",
- "fix": "检查 Mounts 和卷标签,确认实际使用的 pg_data;不要在未确认旧卷前初始化覆盖或删除。"
- },
- {
- "symptom": "恢复时报 archive 格式错误",
- "cause": "用不支持二进制透明传输的 Shell 重定向生成备份。",
- "fix": "在容器内写 -f 文件,再 docker cp;检查备份大小和 pg_restore --list。"
- }
- ],
- "exercise": {
- "task": "每天把 PGDATA 目录复制到同一宿主另一个文件夹能否替代备份方案?",
- "hint": "分别考虑运行中一致性与宿主故障。",
- "answer": "不能。普通文件复制可能得到不一致数据库,而且宿主故障会同时损坏原件与副本。使用数据库支持的备份协议,并保留异机副本、加密密钥与定期恢复证据。"
- },
- "refs": [
- {
- "label": "Docker:Volumes",
- "url": "https://docs.docker.com/engine/storage/volumes/"
- },
- {
- "label": "PostgreSQL 16:pg_dump",
- "url": "https://www.postgresql.org/docs/16/app-pgdump.html"
- },
- {
- "label": "PostgreSQL 16:pg_restore",
- "url": "https://www.postgresql.org/docs/16/app-pgrestore.html"
- }
- ],
- "production": {
- "scenario": "镜像升级必须保留数据库,宿主损坏后也应有可恢复副本。",
- "decisions": [
- "容器和数据库卷独立管理。",
- "备份在独立环境恢复后才计入可用备份。"
- ],
- "failureDrill": "重建练习数据库容器,再把备份恢复到另一卷并核对 volume_probe。完成第06课迁移后,另取新备份恢复到新的空恢复库,补验业务行数和迁移版本。",
- "acceptance": [
- "重建不丢数据。",
- "有一次完整备份恢复的实际记录。"
- ]
- }
- },
- {
- "id": "06",
- "title": "健康检查、启动重试与一次性迁移",
- "summary": "让 API 等待数据库可用与迁移成功,同时处理运行期间的依赖故障。",
- "group": "运行与资源",
- "objectives": [
- "区分容器运行、健康、应用就绪与迁移完成",
- "用 Compose 条件依赖组织首次启动",
- "为瞬时断连设计有界重试而不重放未知写事务"
- ],
- "concepts": [
- {
- "title": "running 不是 ready",
- "body": "容器进程创建后,Postgres 可能仍在初始化,API 可能缺表。pg_isready 证明数据库接受连接,不验证 app_user 凭据或业务表。应用 /ready 执行 SQL,迁移作业另行证明表版本已到目标。"
- },
- {
- "title": "启动顺序只覆盖启动",
- "body": "depends_on condition 控制 Compose 启动前置条件,不能保证依赖永远在线。运行时数据库重启仍会引发请求失败,连接池需检测失效连接,调用方需按幂等规则重试。"
- },
- {
- "title": "迁移是独立作业",
- "body": "一个 migrate 容器执行 alembic upgrade head,退出码 0 才允许 API 启动。不让多个 API worker 各自执行迁移。迁移失败保留日志与旧应用,不能循环忽略失败继续接流量。"
- }
- ],
- "steps": [
- {
- "title": "增加数据库和 Redis 健康检查",
- "body": "把以下字段分别合并到 compose.yaml 的 postgres 与 redis 服务内,不新建同名重复服务。$$ 让变量传入容器 Shell 后展开,避免 Compose 提前替换。",
- "code": "# postgres 服务内\nhealthcheck:\n test: [\"CMD-SHELL\", \"pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB\"]\n interval: 5s\n timeout: 3s\n retries: 12\n start_period: 10s\n\n# redis 服务内\nhealthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n timeout: 2s\n retries: 5",
- "lang": "yaml",
- "filename": "compose.yaml"
- },
- {
- "title": "新增一次性迁移服务",
- "body": "合并到 services 层,镜像与 API 完全相同。练习迁移使用 app_user 的 DDL 权限;生产用单独迁移凭据覆盖此服务的 DATABASE_URL,API 保持更小权限。",
- "code": "migrate:\n image: ${NOTES_IMAGE:?set NOTES_IMAGE}\n command: [\"python\", \"-m\", \"alembic\", \"upgrade\", \"head\"]\n environment:\n DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}\n depends_on:\n postgres:\n condition: service_healthy\n restart: \"no\"",
- "lang": "yaml",
- "filename": "compose.yaml"
- },
- {
- "title": "为 API 添加就绪探针与条件依赖",
- "body": "合并到已有 api 服务。urllib 已在 Python 标准库,无需额外安装 curl。/ready 是前置模块实现的数据库探针;用户自定义应用须先实现同等端点。start_period 不屏蔽真实请求,只影响健康状态统计。",
- "code": "depends_on:\n migrate:\n condition: service_completed_successfully\nhealthcheck:\n test: [\"CMD\", \"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/ready',timeout=4).read()\"]\n interval: 10s\n timeout: 5s\n retries: 3\n start_period: 15s\nrestart: unless-stopped",
- "lang": "yaml",
- "filename": "compose.yaml"
- },
- {
- "title": "首次启动并验证迁移状态",
- "body": "up -d --wait 等待服务达到健康状态,但它不是业务验收;随后创建一条笔记并读取。migrate 是正常退出的作业,其 Exited (0) 不算服务故障。",
- "code": "docker compose --env-file .env.compose config --quiet\ndocker compose --env-file .env.compose up -d --wait\ndocker compose --env-file .env.compose ps -a\ndocker compose --env-file .env.compose logs --tail 50 migrate\ndocker compose --env-file .env.compose exec postgres psql -U postgres -d notes_lab -c \"SELECT version_num FROM alembic_version;\"",
- "lang": "bash"
- },
- {
- "title": "演练运行时数据库短暂停止",
- "body": "仅在专用本机栈停止 postgres,此时 /live 应仍为 200、/ready 为 503;重启数据库后连接池 pre_ping 应逐步恢复。Docker 标记 unhealthy 本身不会自动重启容器,restart 策略主要响应进程退出。写入失败不要盲目自动重放,用幂等键确认结果。",
- "code": "docker compose --env-file .env.compose stop postgres\ndocker compose --env-file .env.compose logs --tail 30 api\ndocker compose --env-file .env.compose start postgres\ndocker compose --env-file .env.compose ps",
- "lang": "bash"
- }
- ],
- "checks": [
- "首次启动迁移先成功退出,API 随后 ready。",
- "迁移失败时 API 不按成功依赖启动,日志保留真实异常。",
- "数据库重启期间 readiness 失败,恢复后可创建读取数据,无隐式无限重试。"
- ],
- "pitfalls": [
- {
- "symptom": "depends_on 后仍出现连接拒绝",
- "cause": "只写了服务名依赖,没等待健康;或故障发生在启动之后。",
- "fix": "首次启动使用 health 条件,运行期间通过连接恢复、超时和调用方幂等重试处理。"
- },
- {
- "symptom": "容器 unhealthy 却没有自动重启",
- "cause": "把健康状态与 restart 策略混为一谈。",
- "fix": "明确由部署平台或外部监控处理健康流量与重启;不要让所有实例因共享数据库故障一起重启。"
- }
- ],
- "exercise": {
- "task": "能否把 alembic upgrade head 放进 API CMD 前面,每个副本都执行?",
- "hint": "扩容时有多少个进程会同时触发 DDL?",
- "answer": "不应。使用单独迁移步骤并保证唯一执行;多个进程同时迁移会竞争锁与版本状态。迁移成功是发布门槛,不是每个请求实例的初始化副作用。"
- },
- "refs": [
- {
- "label": "Docker:启动顺序",
- "url": "https://docs.docker.com/compose/how-tos/startup-order/"
- },
- {
- "label": "Docker:健康检查",
- "url": "https://docs.docker.com/reference/dockerfile/#healthcheck"
- },
- {
- "label": "PostgreSQL 16:pg_isready",
- "url": "https://www.postgresql.org/docs/16/app-pg-isready.html"
- }
- ],
- "production": {
- "scenario": "整套服务在新机器首次启动,以及运行期间数据库维护重启。",
- "decisions": [
- "依赖健康、迁移完成和业务就绪分别验证。",
- "运行时重试有限,写请求按幂等协议恢复。"
- ],
- "failureDrill": "停止练习 postgres,观察 /live 与 /ready 分离,恢复后验证原数据和新写入。",
- "acceptance": [
- "共享数据库故障不会触发无意义的无限重启。",
- "启动失败能够定位到依赖、迁移或应用阶段。"
- ]
- }
- },
- {
- "id": "07",
- "title": "CPU、内存、进程数与优雅退出",
- "summary": "给容器设置资源边界,观察 OOM 与 SIGTERM,确保在途请求和任务有明确退出行为。",
- "group": "运行与资源",
- "objectives": [
- "设置并验证容器 CPU、内存与 PID 上限",
- "识别 OOMKilled 和普通应用退出",
- "协调停止宽限期、HTTP 排空与任务重投"
- ],
- "concepts": [
- {
- "title": "限制是边界而非性能保证",
- "body": "cpus 限制可用 CPU 时间,mem_limit 限制内存,pids_limit 限制线程与进程数量。线程也可能计入 PID 额度。限制过低会导致正常峰值失败,过高会让单服务挤压数据库与宿主;起始值需要用实际负载校准。"
- },
- {
- "title": "SIGTERM 与 SIGKILL",
- "body": "docker stop 通常先发 SIGTERM,等待 stop_grace_period 后再 SIGKILL。SIGTERM 允许应用停止接新请求并完成在途工作;SIGKILL 无法执行 finally。Uvicorn 的优雅关闭预算必须小于容器宽限期。"
- },
- {
- "title": "退出与任务一致性",
- "body": "HTTP 排空和队列任务退出是两套行为。Celery worker 收到 TERM 可进行 warm shutdown,但容器最终可能强杀长任务。任务必须幂等,确认、可见性超时和恢复策略与异步任务模块一致,不能把 SIGTERM 当作任务绝不丢失的保证。"
- }
- ],
- "steps": [
- {
- "title": "为 API 配置资源上限",
- "body": "将这些字段合并到 api 服务。示例 1 CPU/512MB/128 PID 是练习起点,不是生产容量结论;Dockerfile Uvicorn graceful timeout 已为 25 秒,容器给予 35 秒。",
- "code": "cpus: 1.0\nmem_limit: 512m\npids_limit: 128\ninit: true\nstop_grace_period: 35s\nlogging:\n driver: local\n options:\n max-size: \"10m\"\n max-file: \"3\"",
- "lang": "yaml",
- "filename": "compose.yaml"
- },
- {
- "title": "重建并核对运行限制",
- "body": "应用配置变化需 up 重建容器,不是只 restart。stats 观察实际内存和 CPU;inspect 以 API 容器 id 读取 HostConfig,不把完整环境配置输出到公共日志。",
- "code": "docker compose --env-file .env.compose up -d api\ndocker compose --env-file .env.compose stats --no-stream api\ndocker compose --env-file .env.compose ps -q api\n# 将下面 id 替换为刚取得的 API 容器 id\ndocker inspect API_CONTAINER_ID --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}} {{.HostConfig.PidsLimit}}'",
- "lang": "bash"
- },
- {
- "title": "在一次性专用容器触发 OOM",
- "body": "此演练不使用 API 容器或数据库卷;仅分配大于 64MB 上限的内存。不要加 --rm,便于查看退出状态,检查后只删除 notes-oom-drill。",
- "code": "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))\"\ndocker inspect notes-oom-drill --format '{{.State.ExitCode}} {{.State.OOMKilled}}'\ndocker rm notes-oom-drill",
- "lang": "bash"
- },
- {
- "title": "验证 API 接收停止信号",
- "body": "在另一个终端持续请求 /ready,再 stop api,观察日志中应用开始关闭与 shutdown complete。正常短请求应完成,关闭后客户端连接失败属于流量需要移走的阶段。单容器 Compose 没有自动负载均衡排空能力。",
- "code": "docker compose --env-file .env.compose stop api\ndocker compose --env-file .env.compose logs --tail 50 api\ndocker compose --env-file .env.compose start api",
- "lang": "bash"
- },
- {
- "title": "记录资源与退出验收",
- "body": "创建 resource-budget.md,记录实例/worker 数、连接池乘数、CPU/内存峰值及关闭耗时。任务 worker 的并发进程数和每个进程连接池也算入数据库配额。",
- "code": "API:1 进程/容器;数据库池 5;内存上限 512MiB;关闭预算 25s < 容器35s\nWorker:按第9课单独配置并发;连接数按实际执行模型计算\n宿主:为 Postgres、Redis、Docker 和系统留余量\n证据:实际 stats 峰值、OOM 状态、SIGTERM 关闭耗时",
- "lang": "text",
- "filename": "resource-budget.md"
- }
- ],
- "checks": [
- "运行容器实际 HostConfig 包含预期内存、CPU、PID 限制。",
- "一次性 OOM 容器能够通过 OOMKilled 区分资源杀死与普通异常。",
- "API 在宽限期内完成关闭,没有依赖 shell 后台进程转发信号。"
- ],
- "pitfalls": [
- {
- "symptom": "退出码 137 就断言一定 OOM",
- "cause": "137 也可能由其他 SIGKILL 来源导致。",
- "fix": "同时检查 OOMKilled、宿主内核日志、容器事件与部署停止超时。"
- },
- {
- "symptom": "容器停止后任务重复执行",
- "cause": "任务在完成业务后、确认前被强杀,队列再次投递。",
- "fix": "按任务幂等与业务唯一约束处理重投,调整任务时限和退出预算;不要为了避免重复提前确认并忽略丢失风险。"
- }
- ],
- "exercise": {
- "task": "每容器 4 个 API worker 是否一定比 1 个更快?",
- "hint": "同时计算 CPU、内存与数据库连接。",
- "answer": "不一定。4 个进程增加内存和连接池总数,受 1 CPU 限制时可能只增加调度开销。用实际等待与计算比例评估;容器水平扩容和 worker 数必须一起预算。"
- },
- "refs": [
- {
- "label": "Docker:资源限制",
- "url": "https://docs.docker.com/engine/containers/resource_constraints/"
- },
- {
- "label": "Docker:Compose 服务资源字段",
- "url": "https://docs.docker.com/reference/compose-file/services/"
- },
- {
- "label": "Docker:stop",
- "url": "https://docs.docker.com/reference/cli/docker/container/stop/"
- },
- {
- "label": "Uvicorn:设置",
- "url": "https://uvicorn.dev/settings/"
- }
- ],
- "production": {
- "scenario": "单个大请求或长任务不能耗尽宿主全部资源,部署停止也不能无限等待。",
- "decisions": [
- "资源值先设上限再压测校准。",
- "容器停止预算大于应用优雅退出预算。"
- ],
- "failureDrill": "使用无挂载专用容器触发 OOM,再对练习 API 执行 SIGTERM,分别记录原因与恢复。",
- "acceptance": [
- "OOM 不影响数据库卷。",
- "关闭和任务恢复行为有明确记录。"
- ]
- }
- },
- {
- "id": "08",
- "title": "配置、Secrets 与容器运行权限",
- "summary": "将连接秘密从 Compose 环境输出中移走,收紧应用可写目录与 Linux capabilities,并建立依赖扫描流程。",
- "group": "安全与交付",
- "objectives": [
- "通过文件向应用注入秘密并验证权限",
- "限制运行容器的写入与提权能力",
- "读取扫描结果并形成可验证的修复步骤"
- ],
- "concepts": [
- {
- "title": "环境变量与秘密文件",
- "body": "环境变量方便但常出现在 inspect、诊断和进程信息中。Compose secrets 将文件挂载到 /run/secrets,应用必须主动读取;环境变量 NAME_FILE 不是通用自动功能,只有支持它的程序会处理。"
- },
- {
- "title": "本地 Compose 的边界",
- "body": "本地 secrets 通常来自宿主文件挂载,不等同 Swarm 的加密分发。宿主源文件和备份仍需权限控制,拥有 Docker daemon 管理权限的人可访问容器与挂载。应用不用 Docker socket,不把日常用户随意加入 docker 组。"
- },
- {
- "title": "扫描与修复",
- "body": "镜像扫描报告依赖和系统包漏洞,是定位输入,不是应用已安全的证明。确认运行路径与受影响版本,更新基础 digest/锁文件、重建并回归。不要在运行容器里删除库来“清零”扫描数字。"
- }
- ],
- "steps": [
- {
- "title": "让配置支持文件秘密",
- "body": "替换 app/config.py 中 Settings 的 database_url 声明并扩展 model_config,下面是该部分的完整写法;保留此前连接池字段。secret 目录中的 database_url 文件提供值,环境变量同名值优先,因此从 api 服务移除旧 DATABASE_URL environment 项。",
- "code": "from pydantic import Field\nfrom pydantic_settings import BaseSettings, SettingsConfigDict\n\nclass Settings(BaseSettings):\n model_config = SettingsConfigDict(env_file=\".env\", extra=\"ignore\", secrets_dir=\"/run/secrets\")\n database_url: str = Field(min_length=1)\n db_pool_size: int = Field(default=5, ge=1, le=50)\n db_max_overflow: int = Field(default=0, ge=0, le=50)\n db_pool_timeout: float = Field(default=2, gt=0, le=30)\n\nsettings = Settings()",
- "lang": "python",
- "filename": "app/config.py"
- },
- {
- "title": "建立本机秘密文件",
- "body": "在已忽略的 secrets 目录用编辑器新建 database_url,填写 postgres:5432 的容器连接串。以下为 Linux / WSL 的本地 file-backed secrets 方案:父目录由当前可信宿主账户持有且权限700,单个绑定文件644,使容器UID10001可读。其他普通宿主账户无法穿过700父目录;有Docker控制权限的账户仍属于受信任管理员。不能仅写uid/gid/mode并假设Compose会重映射文件权限。",
- "code": "mkdir -p secrets\n# 用编辑器创建 secrets/database_url,填实际练习连接串。\nchmod 700 secrets\nchmod 644 secrets/database_url\n# 此处只调整明确的单个绑定文件;不把父目录改成公开可进入。",
- "lang": "bash"
- },
- {
- "title": "挂载秘密并检查实际权限",
- "body": "在 compose.yaml 顶层新增 secrets;在 api/migrate 的服务中挂载 secrets: [database_url] 并移除 DATABASE_URL 环境值。file-backed secrets实际采用绑定文件权限,不能依赖Compose的uid/gid/mode映射。后续任务URL和broker配置也放在同一受控目录,逐一授予容器读取,并在启动正式进程前用临时容器验证。",
- "code": "# compose.yaml 顶层\nsecrets:\n database_url:\n file: ./secrets/database_url\n\n# api 与 migrate 各自服务内添加\n# secrets:\n# - database_url",
- "lang": "yaml",
- "filename": "compose.yaml"
- },
- {
- "title": "收紧 API 运行权限",
- "body": "合并到 api 服务并重建镜像以包含新的配置模块。根文件系统只读,临时目录使用有界 tmpfs;app 不写本地持久文件。Celery 需要的调度状态在第 9 课单独处理。",
- "code": "read_only: true\ntmpfs:\n - /tmp:size=64m,mode=1777\ncap_drop: [ALL]\nsecurity_opt:\n - no-new-privileges:true",
- "lang": "yaml",
- "filename": "compose.yaml"
- },
- {
- "title": "验证只读、秘密和依赖扫描",
- "body": "先重建镜像,随后用 command 覆盖应用启动命令,只读取秘密文件并输出布尔值。该检查使用镜像默认非root UID10001;成功后才启动API,避免先因Settings读取失败退出再尝试exec。只读写入检查应报Read-only file system,是本步骤预期结果。Docker Scout若已安装授权可执行cves,否则记录未执行原因,不声称扫描通过。",
- "code": "docker build --build-arg PYTHON_IMAGE=\"${PYTHON_IMAGE:?set verified base digest}\" -t notes-service:lab-02 .\ndocker 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')\"\n# 仅当前置检查成功后继续;失败时先修正文件所有者/权限和挂载。\ndocker compose --env-file .env.compose up -d api\ndocker compose --env-file .env.compose exec api python -c \"from pathlib import Path; Path('/app/should_fail').write_text('x')\"\ndocker scout cves notes-service:lab-02",
- "lang": "bash"
- }
- ],
- "checks": [
- "API 配置可读取挂载秘密,检查时未打印连接串。",
- "/app 写入失败,/tmp 可以写入且有容量上限。",
- "扫描结果或未执行原因有记录,修复通过基础镜像/锁文件重建完成。"
- ],
- "pitfalls": [
- {
- "symptom": "加了 DATABASE_URL_FILE 但应用仍缺配置",
- "cause": "应用没有实现读取该变量指向文件的逻辑。",
- "fix": "采用本课 Pydantic secrets_dir 文件名 database_url,或显式实现并测试 _FILE 读取。"
- },
- {
- "symptom": "只读 rootfs 后应用启动失败",
- "cause": "程序尝试写字节码、缓存、PID 或调度文件。",
- "fix": "禁用字节码并为必要临时写入配置 tmpfs;持久调度状态使用单独卷,不把整个根文件系统改回可写。"
- }
- ],
- "exercise": {
- "task": "把 .env 复制进镜像后下一层删除,是否已清除秘密?",
- "hint": "镜像保留每层的文件变更。",
- "answer": "没有。旧层仍可能恢复内容。轮换已泄露秘密,重新构建不含该层的新镜像,检查镜像仓库与构建日志中的副本;正常构建使用运行时秘密或 BuildKit secret mount。"
- },
- "refs": [
- {
- "label": "Docker:Compose Secrets",
- "url": "https://docs.docker.com/compose/how-tos/use-secrets/"
- },
- {
- "label": "Docker:构建秘密",
- "url": "https://docs.docker.com/build/building/secrets/"
- },
- {
- "label": "Pydantic Settings:Secrets",
- "url": "https://docs.pydantic.dev/latest/concepts/pydantic_settings/#secrets"
- },
- {
- "label": "Docker Scout:CVE 分析",
- "url": "https://docs.docker.com/scout/quickstart/"
- }
- ],
- "production": {
- "scenario": "诊断日志和镜像交付不应携带数据库密码,应用应只有必需的文件写入权限。",
- "decisions": [
- "秘密只在运行时挂载,宿主源文件访问受控。",
- "非 root、只读根文件系统和去除 capabilities 一起验证。"
- ],
- "failureDrill": "在本机尝试向 /app 写入,预期拒绝;仍能读取秘密并执行 /ready。",
- "acceptance": [
- "权限收紧不破坏正常 API。",
- "秘密没有进入镜像层与共享日志。"
- ]
- }
- },
- {
- "id": "09",
- "title": "API、Worker 与队列的 Compose 整合",
- "summary": "使用同一镜像运行不同进程角色,把任务执行、调度和 HTTP 服务分开部署。",
- "group": "安全与交付",
- "objectives": [
- "部署异步任务模块真实 tasks_lab 工程与 exports.fast/bulk 队列",
- "为 API、worker、publisher、recovery 分别配置进程与资源",
- "隔离缓存 Redis 与带认证的任务 broker,并验证持久业务状态"
- ],
- "concepts": [
- {
- "title": "同镜像不同职责",
- "body": "API 接收请求并提交任务,worker 消费任务,beat 周期调度;它们可以来自同一代码镜像,但生命周期、并发与资源不同。不把 worker 后台进程塞进 API 容器,也不在每个 API 副本内启动周期调度器。"
- },
- {
- "title": "任务边界",
- "body": "复用异步任务模块第 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 下载包已有这些文件。"
- },
- {
- "title": "Redis 隔离",
- "body": "本地 Redis /0 与 /1 可以区分 key 空间,不能隔离内存、CPU、故障与驱逐策略。生产缓存与消息代理通常使用独立实例及策略;队列持久化和 ack 机制也不能单独构成端到端任务不丢失保证。"
- }
- ],
- "steps": [
- {
- "title": "复制完整最终任务工程并统一依赖",
- "body": "把异步任务模块完成后的 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 替代原任务。",
- "code": "COPY tasks_lab ./tasks_lab\nCOPY deploy/run_role.py ./deploy/run_role.py",
- "lang": "dockerfile",
- "filename": "Dockerfile"
- },
- {
- "title": "为任务角色建立秘密加载入口",
- "body": "新建 deploy/run_role.py。API 的 SQLAlchemy URL 用 postgresql+psycopg://,任务模块直接 psycopg.connect 使用 postgresql://,两者分别保存。入口从三个只读秘密文件读入实际环境变量再 exec 目标进程,不输出秘密;读取 /run/secrets/task_database_url、celery_broker_url、celery_result_backend。",
- "code": "import os\nimport sys\nfrom pathlib import Path\n\nfor name, variable in {\n \"task_database_url\": \"DATABASE_URL\",\n \"celery_broker_url\": \"CELERY_BROKER_URL\",\n \"celery_result_backend\": \"CELERY_RESULT_BACKEND\",\n}.items():\n value = Path(\"/run/secrets\", name).read_text().strip()\n if not value:\n raise RuntimeError(f\"empty secret: {name}\")\n os.environ[variable] = value\nif len(sys.argv) < 2:\n raise RuntimeError(\"missing process command\")\nos.execvp(sys.argv[1], sys.argv[1:])",
- "lang": "python",
- "filename": "deploy/run_role.py"
- },
- {
- "title": "配置独立认证 broker 与秘密文件",
- "body": "新增 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行无需额外引号转义。",
- "code": "# services 中新增;本机调试端口仅回环开放\nbroker:\n image: redis:7.4\n command: [\"redis-server\", \"/run/secrets/task_broker_conf\"]\n ports: [\"127.0.0.1:6384:6379\"]\n secrets: [task_broker_conf]\n volumes: [\"broker_data:/data\"]\n mem_limit: 256m\n restart: unless-stopped\n\n# 顶层 volumes 新增 broker_data: {}\n# 顶层 secrets 新增以下条目(与已有 database_url 并列)\n# task_broker_conf: {file: ./secrets/task_broker_conf}\n# task_database_url: {file: ./secrets/task_database_url}\n# celery_broker_url: {file: ./secrets/celery_broker_url}\n# celery_result_backend: {file: ./secrets/celery_result_backend}",
- "lang": "yaml",
- "filename": "compose.yaml"
- },
- {
- "title": "添加真实 fast Worker 与 bulk Worker",
- "body": "新增 worker 服务,worker-bulk 使用相同字段,仅 command 的队列改 exports.bulk、节点名改 bulk@%h。任务模块使用相对 Path(\"artifacts\"),工作目录下挂载 /app/tasks_lab/artifacts 命名卷并初始化给UID10001;同机共享输出供恢复/下载使用,跨宿主生产改对象存储。API仅提供笔记接口时不伪称已实现任务提交HTTP端点,先用任务模块 submit/publisher 流程验收。",
- "code": "worker:\n image: ${NOTES_IMAGE:?set NOTES_IMAGE}\n working_dir: /app/tasks_lab\n 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\"]\n secrets: [task_database_url, celery_broker_url, celery_result_backend]\n volumes: [\"artifacts_data:/app/tasks_lab/artifacts\"]\n depends_on:\n broker: {condition: service_started}\n migrate: {condition: service_completed_successfully}\n cpus: 1.0\n mem_limit: 512m\n pids_limit: 128\n init: true\n stop_grace_period: 75s\n restart: unless-stopped\n read_only: true\n tmpfs: [\"/tmp:size=64m,mode=1777\"]\n cap_drop: [ALL]\n security_opt: [\"no-new-privileges:true\"]\n\n# 顶层 volumes 新增 artifacts_data: {}",
- "lang": "yaml",
- "filename": "compose.yaml"
- },
- {
- "title": "包装有界 publisher 与 recovery 循环",
- "body": "新建 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。",
- "code": "import logging\nimport signal\nimport sys\nfrom threading import Event\nfrom publisher import publish_one\nfrom recovery import recover_batch\n\nstop = Event()\nsignal.signal(signal.SIGTERM, lambda *_: stop.set())\nsignal.signal(signal.SIGINT, lambda *_: stop.set())\nfunctions = {\"publisher\": (publish_one, 0.5), \"recovery\": (recover_batch, 5)}\noperation, interval = functions[sys.argv[1]]\nwhile not stop.is_set():\n try:\n operation()\n except Exception as exc:\n logging.error(\"task maintenance failed: %s\", type(exc).__name__)\n if stop.wait(5):\n break\n stop.wait(interval)",
- "lang": "python",
- "filename": "tasks_lab/run_loops.py"
- },
- {
- "title": "初始化任务表、输出权限并构建",
- "body": "将任务课程的 export_jobs/export_outbox/死信/容量/周期等最终DDL纳入本项目Alembic迁移,先在空练习库验证;不要认为notes迁移自动创建这些任务表。挂载卷首次创建为root时,使用仅针对artifacts_data挂载点的一次性初始化作业 chown 10001:10001 /app/tasks_lab/artifacts,并在worker启动前完成;不可递归修改宿主未知目录。构建新标签,更新 .env.compose NOTES_IMAGE,再启动并核对注册任务和队列。",
- "code": "docker build -t notes-service:lab-03 .\n# 编辑 .env.compose:NOTES_IMAGE=notes-service:lab-03\ndocker 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')\"\ndocker compose --env-file .env.compose run --rm --user 0 --cap-add CHOWN --entrypoint sh worker -c 'chown 10001:10001 /app/tasks_lab/artifacts'\ndocker compose --env-file .env.compose run --rm migrate\ndocker compose --env-file .env.compose up -d worker worker-bulk publisher recovery\ndocker compose --env-file .env.compose exec worker python /app/deploy/run_role.py python -m celery -A celery_app:app inspect registered\ndocker compose --env-file .env.compose logs --tail 50 worker worker-bulk publisher recovery",
- "lang": "bash"
- },
- {
- "title": "按真实业务链路验收与故障恢复",
- "body": "先在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课周期任务完成后单实例部署。",
- "code": "docker compose --env-file .env.compose stop worker worker-bulk\ndocker 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']]))\"\ndocker compose --env-file .env.compose start worker worker-bulk\ndocker 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;\"\n# 用真实返回的UUID替换JOB_ID;任务完成后验证artifact\ndocker compose --env-file .env.compose exec worker python /app/deploy/run_role.py python verify_terminal.py JOB_ID",
- "lang": "bash"
- }
- ],
- "checks": [
- "Celery inspect 注册 notes.export,fast/bulk worker 只消费对应队列。",
- "PostgreSQL中job/outbox到最终状态与可读取的输出文件一致,不能只凭Celery结果缓存。",
- "缓存redis与认证broker是不同实例;宿主任务端口6384,容器内部broker:6379。"
- ],
- "pitfalls": [
- {
- "symptom": "扩容 API 后周期任务重复发送",
- "cause": "每个 API 容器都启动了 beat 或本地定时器。",
- "fix": "beat 单独部署单实例或使用有领导选举的调度方案,任务仍需幂等。"
- },
- {
- "symptom": "worker 配了更多并发,数据库连接数翻倍",
- "cause": "每个执行进程建立自己的池,遗漏 fork 与连接生命周期。",
- "fix": "按进程模型重新预算,在子进程创建/重置连接资源;不继承已建立连接。"
- }
- ],
- "exercise": {
- "task": "为什么不能把postgresql+psycopg://连接串原样交给任务模块的psycopg.connect?",
- "hint": "区分SQLAlchemy方言URL与PostgreSQL驱动DSN。",
- "answer": "API使用SQLAlchemy URL;任务state_store直接使用psycopg.connect,需要postgresql://或libpq键值DSN。因此配置分成API database_url与task_database_url,端口和账号相同但协议前缀不同。"
- },
- "refs": [
- {
- "label": "Celery:Worker 管理",
- "url": "https://docs.celeryq.dev/en/stable/userguide/workers.html"
- },
- {
- "label": "Celery:Redis 注意事项",
- "url": "https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/redis.html"
- },
- {
- "label": "Redis:持久化",
- "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/"
- }
- ],
- "production": {
- "scenario": "API 快速返回任务已提交,worker 可以独立扩缩容和更新。",
- "decisions": [
- "任务执行资源与 API 分配分开。",
- "队列状态重要时建立持久业务记录与可恢复投递流程。"
- ],
- "failureDrill": "在独立练习栈暂停broker后提交持久job,核对job/outbox保留;恢复broker和publisher后worker完成且同一业务结果不被重复执行覆盖。",
- "acceptance": [
- "真实tasks_lab入口、队列、环境变量和文件路径一致。",
- "publisher/recovery有独立受控循环,业务状态与输出经数据库核对。"
- ]
- }
- },
- {
- "id": "10",
- "title": "镜像版本、发布回滚与故障验收",
- "summary": "用不可混淆的版本标签与镜像摘要发布,保留数据库兼容窗口并验证回退行为。",
- "group": "安全与交付",
- "objectives": [
- "记录并部署明确的镜像版本或摘要",
- "执行迁移在前、流量切换在后的发布流程",
- "区分应用回滚、数据库回滚与数据恢复"
- ],
- "concepts": [
- {
- "title": "发布的是已验证镜像",
- "body": "本机 image ID 与仓库 digest 是不同概念;推送后以仓库名@sha256 选择不可变发布物。标签可便于阅读但可能被覆盖,不能只保存 latest。预发布和生产使用同一已验证摘要,避免在每台机器重建出不同依赖。"
- },
- {
- "title": "Compose 的可用性边界",
- "body": "单机单副本 Compose 更新通常会有短暂中断,不承诺零停机。需要平滑切换时部署两个版本后由代理切换,或者使用具备滚动与就绪能力的平台;数据库迁移仍须兼容新旧版本。"
- },
- {
- "title": "回滚不等于删数据",
- "body": "旧镜像能否回退取决于当前数据库结构是否兼容。新增列通常可保留供旧版忽略,删除/重命名/数据语义改变可能阻止回退。不能为了退应用版本就清空数据库卷或机械执行 downgrade。"
- }
- ],
- "steps": [
- {
- "title": "记录当前可回退版本",
- "body": "发布前保存当前 NOTES_IMAGE、迁移版本、配置版本和必要备份。只记录秘密文件的版本标识或来源,不把内容复制进 release-record.md。",
- "code": "版本:notes-service:release-2026-09-10-01 或实际仓库 digest\n源码提交:实际 SHA\n当前迁移 revision:实际 alembic current\n目标 revision:待发布版本所需 revision\n兼容范围:旧应用可读取/写入的新结构范围\n备份与恢复验证:文件标识、时间、恢复证据\n回退触发:例如连续5分钟错误率超目标;须先定义观测窗口",
- "lang": "text",
- "filename": "release-record.md"
- },
- {
- "title": "构建、标记并本机验收",
- "body": "构建使用第 3 课已验证基础 digest。用户拥有并授权的镜像仓库才可执行登录与推送;课件提供命令模板,不实际向外发布。标签中使用真实版本或 commit,不重复覆盖旧发布标签。 以下命令在Bash/WSL运行,PYTHON_IMAGE必须是第03课保存的完整摘要;开启新终端时先恢复已审核的变量,不能静默回退到浮动标签。",
- "code": "docker build --build-arg PYTHON_IMAGE=\"${PYTHON_IMAGE:?set verified base digest}\" -t notes-service:release-01 .\ndocker run --rm notes-service:release-01 python -c \"import fastapi, sqlalchemy, psycopg; print('imports ok')\"\n# 已具备目标仓库权限后,替换 REGISTRY/PROJECT 为真实仓库:\n# docker tag notes-service:release-01 REGISTRY/PROJECT/notes-service:release-01\n# docker push REGISTRY/PROJECT/notes-service:release-01\n# docker buildx imagetools inspect REGISTRY/PROJECT/notes-service:release-01",
- "lang": "bash"
- },
- {
- "title": "先运行唯一迁移作业",
- "body": "更新 .env.compose 的 NOTES_IMAGE 为目标版本,先在预发布演练下面命令。发布时 run --rm migrate 执行当前目标镜像迁移;API 仍可维持旧版,前提是迁移经过 expand-contract 兼容审核。迁移失败即停止后续发布。",
- "code": "docker compose --env-file .env.compose run --rm migrate\ndocker compose --env-file .env.compose up -d --no-deps api worker worker-bulk publisher recovery\ndocker compose --env-file .env.compose ps\ndocker compose --env-file .env.compose logs --tail 100 api worker worker-bulk publisher recovery",
- "lang": "bash"
- },
- {
- "title": "验证业务再决定继续或回退",
- "body": "检查 /ready、创建读取、旧版本冲突、幂等重试和 真实导出任务;记录实际 HTTP 与数据库结果。失败时先将 NOTES_IMAGE 恢复上一兼容版本,再 up --no-deps api worker,不回退 Redis/Postgres 镜像主版本,也不自动操作数据库迁移。",
- "code": "# 将 .env.compose 的 NOTES_IMAGE 恢复到已记录的上一兼容版本后:\ndocker compose --env-file .env.compose up -d --no-deps api worker worker-bulk publisher recovery\ndocker 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)\"",
- "lang": "bash"
- },
- {
- "title": "完成隔离故障矩阵",
- "body": "在本机专用栈逐项执行,保存预期与实测。数据库数据使用迁移和备份处理,不以删卷作为回滚。超出本机实测范围的远程仓库、TLS、负载均衡明确标记未验证。",
- "code": "场景 | 观测 | 恢复动作\n错误数据库密码 | readiness失败,认证日志 | 修正秘密并重建API;不删卷\n错误应用镜像 | API启动失败,旧版可用性记录 | 切回兼容镜像\nworker停止 | 队列积压,API仍可提交 | 启动worker,核对任务去重\nOOM演练 | 专用容器OOMKilled | 调整预算并检查宿主余量\n迁移失败 | migrate退出非0 | 停止发布,修复迁移或锁阻塞\n数据库容器重建 | 卷保留,原笔记可读 | 等待ready并执行业务探针",
- "lang": "text"
- }
- ],
- "checks": [
- "发布记录包含真实镜像标识、迁移版本和回退边界。",
- "迁移失败不会继续切换 API 与 worker。",
- "回退应用镜像后业务探针通过,数据库卷和已提交数据保留。"
- ],
- "pitfalls": [
- {
- "symptom": "latest 回退后仍运行同一坏版本",
- "cause": "标签被覆盖,无法指明旧镜像内容。",
- "fix": "保存不可变版本标签与仓库摘要,部署时以明确摘要选择。"
- },
- {
- "symptom": "应用回退成功但读数据失败",
- "cause": "新迁移删除或改变了旧应用依赖的字段。",
- "fix": "发布前验证兼容窗口;不可兼容时执行修复版本或已验证恢复方案,不机械 downgrade。"
- }
- ],
- "exercise": {
- "task": "怎样证明“回滚方案可用”,而非只写一句“退回旧版本”?",
- "hint": "在同一新数据库结构上运行旧镜像并发请求。",
- "answer": "在隔离预发布库先执行新迁移,再部署旧镜像执行读写与幂等探针,记录结果和不可兼容点。若旧版写入破坏新语义,回退窗口应关闭,改用前向修复。"
- },
- "refs": [
- {
- "label": "Docker:镜像标签",
- "url": "https://docs.docker.com/reference/cli/docker/image/tag/"
- },
- {
- "label": "Docker:镜像摘要",
- "url": "https://docs.docker.com/dhi/core-concepts/digests/"
- },
- {
- "label": "Docker:Compose up",
- "url": "https://docs.docker.com/reference/cli/docker/compose/up/"
- }
- ],
- "production": {
- "scenario": "一次发布必须能够定位软件版本,并在已声明兼容范围内回退。",
- "decisions": [
- "构建一次并以明确版本交付。",
- "应用回退与数据库恢复分开决策,保持数据卷。"
- ],
- "failureDrill": "在本机发布一个故意缺配置的应用版本,确认错误后回到上一镜像并核对既有数据。",
- "acceptance": [
- "有实际回退耗时和业务恢复结果。",
- "未验证的仓库、远程部署和高可用能力明确标记。"
- ]
- }
- }
- ]
|