| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295 |
- [
- {
- "id": "01",
- "title": "镜像、容器与进程生命周期",
- "summary": "亲眼看到程序结束、停止重启和创建新容器时,日志与临时文件的不同变化。",
- "group": "镜像与构建",
- "objectives": [
- "确认所有命令指向本机练习环境",
- "观察运行结束与停止后的状态",
- "比较旧容器和新容器的临时文件"
- ],
- "concepts": [
- {
- "title": "容器运行的是进程",
- "body": "用“观察持续运行的主进程”中观察到的结果回顾。镜像是文件系统和启动配置;容器是基于镜像创建的进程及其隔离环境。主进程退出,容器便停止,Docker 不会因为容器存在就持续执行应用。容器不是需要手工登录安装软件的长期虚拟机。"
- },
- {
- "title": "停止与删除不同",
- "body": "用“停止、重启再查看文件”中观察到的结果回顾。stop 发送停止信号并等待,start 重用原容器,rm 删除已停止容器的可写层。重新 run 创建另一份容器。镜像和显式命名卷有独立生命周期,重建 API 容器不应删除数据库卷。"
- },
- {
- "title": "本模块练习边界",
- "body": "用“核对 Docker 目标”中观察到的结果回顾。使用 Docker Desktop 的 Linux 容器模式或独立 Linux 练习机,先完成 API+数据库模块的 shop-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",
- "expected": "context 明确指向本机;OSType 输出 linux;Compose 显示 v2 或更高的插件版本;client/server 都能连接。",
- "explanation": "context 是 Docker 命令当前操作的目标。先核对目标,后续同名容器的操作才不会落到另一台服务器。"
- },
- {
- "title": "运行一次性 Python 进程",
- "body": "运行下面的一次性 Python 命令,等待它结束并查看终端。该容器不挂载数据库卷或宿主目录,可在本机练习目标运行。",
- "code": "docker run --rm python:3.11-slim python -c \"import sys; print(sys.version)\"",
- "lang": "bash",
- "expected": "打印 Python 3.11 的版本后返回终端,命令成功结束;不会留下一直运行的该临时容器。",
- "explanation": "容器跟随启动程序的生命周期。print 完成后 Python 退出,--rm 再清理这一个已退出容器。"
- },
- {
- "title": "观察持续运行的主进程",
- "body": "创建专用生命周期容器,查看它的日志和进程。这里只写容器内临时文件,接下来可以安全删除该练习容器。Windows PowerShell 可原样使用双引号内的 Python 代码。",
- "code": "docker run -d --name shop-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 shop-lifecycle\ndocker top shop-lifecycle\ndocker inspect shop-lifecycle --format '{{.State.Status}}'",
- "lang": "bash",
- "expected": "日志出现 ready,top 能看到 Python,inspect 在 300 秒内显示 running。",
- "explanation": "这里增加了持续等待的主进程,因而容器保持运行;-u 使 ready 及时进入日志,而不是留在输出缓冲。"
- },
- {
- "title": "停止、重启再查看文件",
- "body": "依次停止、重启 shop-lifecycle,并读取 probe.txt。观察后再次停止,只删除这个明确命名的练习容器,不执行全局 prune。",
- "code": "docker stop --time 5 shop-lifecycle\ndocker start shop-lifecycle\ndocker exec shop-lifecycle cat /tmp/probe.txt\ndocker stop --time 5 shop-lifecycle\ndocker rm shop-lifecycle",
- "lang": "bash",
- "expected": "cat 输出 container layer,最后 rm 返回 shop-lifecycle;若容器已因 300 秒到期退出,先重新启动再检查。",
- "explanation": "stop 不删除原文件层,start 重新执行命令。本例启动脚本还会重写 probe,因此单凭这次 cat 不能证明所有文件都一直未变。"
- },
- {
- "title": "比较新容器的文件层",
- "body": "用相同 Python 镜像创建另一个临时容器,检查前一个容器写过的 /tmp/probe.txt 是否存在。先预测 True 或 False,再运行。",
- "code": "docker run --rm python:3.11-slim python -c \"from pathlib import Path; print(Path('/tmp/probe.txt').exists())\"",
- "lang": "bash",
- "expected": "新容器打印 False。",
- "explanation": "两个容器各有自己的临时可写层。第一个写入 /tmp 的文件没有修改 python 镜像;需要保留的业务数据应另用持久卷。"
- }
- ],
- "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 和容器名定位。"
- ]
- },
- "intro": {
- "scenario": "商品服务在本机能运行了,现在先观察一个程序放进容器后,为什么有时一启动就结束。",
- "startingPoint": [
- "已完成 API 与数据库模块,保留 shop-service 源码;本课用临时 Python 程序认识运行方式,暂不启动商品库。",
- "在本机专用 Docker Desktop Linux 模式或 Linux 练习机操作,第 1 步先验证实际目标。"
- ],
- "outcome": "亲眼看到程序结束、停止重启和创建新容器时,日志与临时文件的不同变化。"
- }
- },
- {
- "id": "02",
- "title": "为 FastAPI 编写非 Root Dockerfile",
- "summary": "构建出包含应用和迁移文件的镜像,实际验证它以 UID 10001 运行。",
- "group": "镜像与构建",
- "objectives": [
- "把依赖清单变成可安装的固定版本文件",
- "检查本机配置没有进入镜像",
- "运行镜像并确认用户和依赖可用"
- ],
- "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": "用“新建 Dockerfile”中观察到的结果回顾。JSON 数组形式 CMD 直接运行应用进程,停止信号可以到达 Uvicorn;不要用 shell 后台启动加无限 sleep。生产镜像不启用 --reload,日志写 stdout/stderr,数据库迁移独立运行。"
- }
- ],
- "steps": [
- {
- "title": "建立目标平台锁定依赖",
- "body": "在 shop-service 根目录新建 requirements.in,填入下列包;把已完成业务实际使用的额外依赖一并加入。此处先保存清单,下面会在 Linux 容器生成安装用的锁文件。",
- "code": "fastapi>=0.121,<1\nuvicorn[standard]>=0.30,<1\npydantic-settings>=2,<3\nSQLAlchemy>=2.0,<2.1\nasyncmy>=0.2,<0.3\nPyMySQL[rsa]>=1.1,<2\nalembic>=1.13,<2\nhttpx>=0.27,<1\ncelery[redis]>=5.4,<6",
- "lang": "text",
- "filename": "requirements.in",
- "expected": "项目根目录出现 requirements.in,列出本应用实际需要的包;此时只有版本范围,还不能执行 --require-hashes 安装。",
- "explanation": "直接依赖清单表达需要什么,后续生成的锁会进一步固定具体版本、间接依赖和文件哈希。"
- },
- {
- "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",
- "expected": "根目录出现 .dockerignore,.env、secrets、备份和虚拟环境都在排除列表中。",
- "explanation": "docker build 最后的点指定当前目录可供读取;忽略规则防止本机秘密和无关文件随构建上下文发送。"
- },
- {
- "title": "在目标 Linux 环境生成依赖锁",
- "body": "保持在 shop-service 根目录,使用 Bash/WSL 执行下面命令。它仅把当前项目目录挂载到临时工具容器并在这里写 requirements-lock.txt;PowerShell 将 source=\"$(pwd)\" 改为 source=\"${PWD}\"。不挂载宿主其他目录。生成后打开锁文件检查版本与哈希,再继续构建。",
- "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",
- "expected": "当前目录出现非空 requirements-lock.txt,其中直接和间接依赖都有固定版本及 --hash=sha256:...;生成命令正常退出。",
- "explanation": "requirements.in 是版本范围,pip-tools 解析出本次可安装的具体集合;哈希让后续安装核对包文件。临时工具容器结束后,锁文件通过挂载保留在项目目录。"
- },
- {
- "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 shop && useradd --uid 10001 --gid shop --no-create-home --shell /usr/sbin/nologin shop\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",
- "expected": "Dockerfile 与 app、迁移和锁文件位于同一根目录,最后使用 USER 10001:10001 与 JSON 数组 CMD。",
- "explanation": "COPY 按相对构建目录取文件;先安装依赖再切低权限用户。CMD 数组直接启动 Uvicorn,让停止信号到达服务进程。"
- },
- {
- "title": "构建并核对镜像用户",
- "body": "确认本课前面生成了 requirements-lock.txt,再在 shop-service 根目录执行构建与两个检查命令。命令直接运行 Python,不会连接数据库。",
- "code": "docker build -t shop-service:lab-01 .\ndocker run --rm shop-service:lab-01 python -c \"import os; print(os.getuid())\"\ndocker image inspect shop-service:lab-01 --format '{{.Config.User}} {{json .Config.Cmd}}'",
- "lang": "bash",
- "expected": "构建完成后用户命令输出 10001,inspect 显示用户 10001:10001 和 Uvicorn 参数数组。",
- "explanation": "镜像配置的默认用户已影响真实进程。这里用 Python 验证身份,不导入业务应用,所以无需先连接数据库。"
- },
- {
- "title": "检查应用所需依赖和迁移文件",
- "body": "在刚构建的 shop-service:lab-01 镜像里逐一导入数据库驱动并检查迁移路径。本课终点是可验证的应用镜像;第 04—06 课再接通数据库并启动完整商品接口。",
- "code": "docker run --rm shop-service:lab-01 python -c \"import fastapi, sqlalchemy, asyncmy, pymysql; print(fastapi.__version__, sqlalchemy.__version__); print('mysql drivers import ok')\"\ndocker run --rm shop-service:lab-01 python -c \"from pathlib import Path; print(Path('/app/alembic.ini').exists(), Path('/app/migrations/env.py').exists())\"",
- "lang": "bash",
- "expected": "打印依赖版本、mysql drivers import ok,文件存在性输出 True True。",
- "explanation": "依赖和迁移已在镜像内。容器内的 0.0.0.0 监听是接收网络请求的准备,完整数据库连接将在第 04—06 课验证。"
- }
- ],
- "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": "返回本课“在目标 Linux 环境生成依赖锁”重新生成并检查完整依赖与哈希,不通过去掉哈希选项绕过问题。"
- }
- ],
- "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": [
- "镜像内没有本机配置标记。",
- "容器用户和启动命令可审计。"
- ]
- },
- "intro": {
- "scenario": "同一份商品源码交给另一台机器,还要手工安装许多依赖。本课把运行所需文件一起打包。",
- "startingPoint": [
- "完成第 01 课,docker run 能打印 Python 版本。",
- "在 shop-service 根目录确认 app/main.py、alembic.ini、migrations/env.py 存在;本课会先生成依赖锁再构建,无需跳到下一课。"
- ],
- "outcome": "构建出包含应用和迁移文件的镜像,实际验证它以 UID 10001 运行。"
- }
- },
- {
- "id": "03",
- "title": "分层缓存、多阶段构建与可重复输入",
- "summary": "只改源码时复用依赖安装结果,并记录本次构建实际使用的基础镜像与锁文件。",
- "group": "镜像与构建",
- "objectives": [
- "比较两次构建哪些步骤被复用",
- "取得真实基础镜像摘要并用于构建",
- "留下他人可追溯的构建记录"
- ],
- "concepts": [
- {
- "title": "缓存按层失效",
- "body": "用“构建并观察缓存”中观察到的结果回顾。先 COPY 依赖锁再安装,最后 COPY 源码,业务代码修改通常不会让依赖安装层失效。缓存提升速度,不代表输入自动更新;基础镜像和锁文件更新仍需显式执行与测试。"
- },
- {
- "title": "标签与摘要",
- "body": "用“查询实际基础摘要”中观察到的结果回顾。python:3.11-slim 标签可能随安全更新移动,digest 指向具体镜像内容。使用真实查询到的 digest 可固定基础镜像;固定也意味着必须有主动更新流程,不能永久停留在旧漏洞版本。"
- },
- {
- "title": "多阶段的边界",
- "body": "用“替换 Dockerfile 为多阶段版本”中观察到的结果回顾。builder 创建虚拟环境,runtime 只复制环境与应用代码。二者使用相同基础镜像和架构,避免复制不兼容的本地扩展。哈希锁、基础 digest 和目标平台稳定输入;如果要求字节级相同镜像,还需控制构建时间戳与工具链,不能仅靠 Dockerfile 保证。"
- }
- ],
- "steps": [
- {
- "title": "复现并审阅目标平台依赖锁",
- "body": "本课使用第 02 课已有的 requirements-lock.txt。先保存其当前版本,再在 Bash/WSL 的 shop-service 根目录重跑下列生成命令,比较实际差异;PowerShell 将 source=\"$(pwd)\" 改为 source=\"${PWD}\"。接受并记录版本变动后再用于下一步构建。",
- "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",
- "expected": "根目录重新得到带完整版本和 --hash 的锁文件;与上课文件比较,依赖无更新时应大体一致,有变动时先审阅版本差异。",
- "explanation": "锁在目标 Linux/Python 环境生成,避免把本机平台专用包带过去。重新生成可能选到范围内更新版本,因此差异必须记录。"
- },
- {
- "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",
- "expected": "RepoDigests 输出完整 python@sha256:...,PYTHON_IMAGE 变量验证通过而非空值或占位串。",
- "explanation": "标签可指向更新内容,摘要标识本次实际内容。固定摘要让本次输入可追溯,同时需要主动更新以获得修复。"
- },
- {
- "title": "替换 Dockerfile 为多阶段版本",
- "body": "用本段完整替换第 2 课 Dockerfile。PYTHON_IMAGE 的默认标签供练习,发布构建必须通过 --build-arg 传已记录的 digest;builder 与 runtime 共用同一值。asyncmy需要匹配目标Python和架构的wheel;若目标平台没有wheel,在builder层安装编译所需工具,重新锁定并验证,运行层保留必要运行库。PyMySQL[rsa]支持MySQL 8.4默认的caching_sha2_password认证,不启用旧认证插件。",
- "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 shop && useradd --uid 10001 --gid shop --no-create-home --shell /usr/sbin/nologin shop\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",
- "expected": "新 Dockerfile 分为 builder 与 runtime,二者从同一 PYTHON_IMAGE 开始,runtime 复制 /opt/venv。",
- "explanation": "第一阶段负责安装,第二阶段只带运行环境与应用。相同基础系统和架构减少复制二进制依赖时的不兼容。"
- },
- {
- "title": "构建并观察缓存",
- "body": "在同一 Bash / WSL 终端使用上一阶段取得并验证的 PYTHON_IMAGE;新开终端时重新从已拉取镜像读取摘要。变量为空会在构建前停止。第一次完成后仅修改 app 中一个无副作用注释,再构建,依赖层应显示 CACHED。",
- "code": "docker build --progress=plain --build-arg PYTHON_IMAGE=\"${PYTHON_IMAGE:?set verified base digest}\" -t shop-service:lab-02 .\ndocker history shop-service:lab-02\ndocker image inspect shop-service:lab-02 --format '{{.Id}}'",
- "lang": "bash",
- "expected": "第二次只改 app 注释的构建中,安装依赖那一层显示 CACHED;源码 COPY 层重新执行。",
- "explanation": "Docker 按指令和输入复用层。锁文件先复制而源码后复制,使业务改动通常不触发整套依赖重装。"
- },
- {
- "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",
- "expected": "记录含真实 commit、平台、基础 digest、锁版本和应用 image ID,未执行的远程推送或测试明确标出。",
- "explanation": "能追溯输入才能解释两次发布是否使用同一软件;缓存命中与输入固定仍不等于自动证明字节级构建完全相同。"
- }
- ],
- "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": [
- "能解释缓存结果。",
- "依赖升级产生新的可追溯发布物。"
- ]
- },
- "intro": {
- "scenario": "修改一行商品代码后又安装一遍所有依赖,构建很慢;几天后相同标签还可能得到不同基础文件。",
- "startingPoint": [
- "完成第 02 课,shop-service:lab-01 可打印用户和依赖版本,根目录已有带哈希的 requirements-lock.txt。",
- "使用 Bash/WSL 终端进入同一 shop-service 目录,保存当前锁文件以便比较重新生成的结果。"
- ],
- "outcome": "只改源码时复用依赖安装结果,并记录本次构建实际使用的基础镜像与锁文件。"
- }
- },
- {
- "id": "04",
- "title": "Compose 网络、服务 DNS 与端口",
- "summary": "启动独立数据库和缓存,在 API 所在网络确认名称解析、连接和数据库认证。",
- "group": "运行与资源",
- "objectives": [
- "用一份配置启动同项目依赖",
- "区分容器访问地址与宿主访问地址",
- "分别验证网络到达和账号可登录"
- ],
- "concepts": [
- {
- "title": "localhost 指当前容器",
- "body": "用“从 API 网络位置检测服务 DNS”中观察到的结果回顾。API 中 127.0.0.1 指向 API 自己,不能到 MySQL。Compose 默认网络提供 mysql 和 redis 服务名解析;容器间连接使用 mysql:3306 与 redis:6379,宿主调试使用 127.0.0.1:3307 与 127.0.0.1:6380。"
- },
- {
- "title": "端口发布的方向",
- "body": "用“从 API 网络位置检测服务 DNS”中观察到的结果回顾。ports 的左侧是宿主绑定,右侧是容器监听。容器间通信不依赖宿主端口映射;数据库不需要被公网发布。EXPOSE 是镜像元信息,也不会自动开放宿主端口。"
- },
- {
- "title": "项目隔离",
- "body": "用“新建 compose.yaml”中观察到的结果回顾。Compose project name 区分同机多个练习栈的网络与卷;不要设置固定 container_name,它会阻碍扩容并造成跨项目名字冲突。示例数据库为 shop_lab,app_user 是普通应用角色,mysql 管理账号不进入 API。"
- }
- ],
- "steps": [
- {
- "title": "新建 Compose 专用环境文件",
- "body": "复制下列模板为 .env.compose 并填写两个不同的本机练习密码。文件不提交,后续统一命令显式 --env-file .env.compose;它用于 Compose 插值,不覆盖先前本机 Python 使用的 .env。连接串密码有特殊字符需 URL 编码。",
- "code": "COMPOSE_PROJECT_NAME=shop-lab\nDB_ADMIN_PASSWORD=CHANGE_ADMIN_PASSWORD\nAPP_DB_PASSWORD=CHANGE_APP_PASSWORD\nDATABASE_URL=mysql+asyncmy://app_user:CHANGE_APP_PASSWORD@mysql:3306/shop_lab?charset=utf8mb4\nREDIS_URL=redis://redis:6379/0\nSHOP_IMAGE=shop-service:lab-02",
- "lang": "dotenv",
- "filename": ".env.compose",
- "expected": "本地 .env.compose 已填真实练习值,SHOP_IMAGE 对应已构建镜像;文件未纳入 Git,密码没有写到命令输出。",
- "explanation": "--env-file 指定 Compose 配置替换的来源,和应用自己的 .env 属于不同读取位置。URL 编码用于让密码中的特殊字符不改变连接串结构。"
- },
- {
- "title": "用官方镜像完成首次初始化",
- "body": "先对照下面清单,在 .env.compose 中定位管理员密码、应用密码、库名和卷。首次练习使用本项目的新数据卷;若已有卷,保留它,不通过删卷修改密码,也不要假定编辑环境文件已改变现有账号。",
- "code": "MYSQL_ROOT_PASSWORD → 独立管理员密码\nMYSQL_DATABASE=shop_lab → 仅首次创建练习库\nMYSQL_USER=app_user / MYSQL_PASSWORD → 仅该库的练习账号\n持久卷 mysql_data → /var/lib/mysql\n服务名 mysql:3306 → 容器访问\n127.0.0.1:3307 → 宿主访问\nInnoDB / utf8mb4 / UTC → 全模块约定",
- "lang": "text",
- "filename": "初始化约定",
- "expected": "能在对照表中指出 app_user 只用于 shop_lab,并将容器内 mysql:3306 与宿主 127.0.0.1:3307 分开。",
- "explanation": "MySQL 镜像只对空数据目录执行首次初始化;编辑环境变量不会自动修改已有数据库账号密码。"
- },
- {
- "title": "新建 compose.yaml",
- "body": "本段创建 API、MySQL、Redis 与独立卷;Redis 此时只是连通性练习,不承诺任务持久化。宿主数据库端口与数据库模块一致。API 初次启动可能尚未建表,第 6 课加入迁移和健康依赖。",
- "code": "services:\n api:\n image: ${SHOP_IMAGE:?set SHOP_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 mysql:\n image: mysql:8.4\n command: [\"--character-set-server=utf8mb4\", \"--collation-server=utf8mb4_0900_ai_ci\", \"--default-time-zone=+00:00\"]\n environment:\n MYSQL_ROOT_PASSWORD: ${DB_ADMIN_PASSWORD:?set admin password}\n MYSQL_DATABASE: shop_lab\n MYSQL_USER: app_user\n MYSQL_PASSWORD: ${APP_DB_PASSWORD:?set app password}\n ports: [\"127.0.0.1:3307:3306\"]\n volumes: [\"mysql_data:/var/lib/mysql\"]\n redis:\n image: redis:7.4\n ports: [\"127.0.0.1:6380:6379\"]\nvolumes:\n mysql_data: {}",
- "lang": "yaml",
- "filename": "compose.yaml",
- "expected": "compose.yaml 中三个服务与 mysql_data 卷位置明确;数据库只发布到宿主回环地址,应用尚不要求业务表已存在。",
- "explanation": "同一 Compose 项目提供服务名解析,mysql 这个名字指数据库服务。命名卷把数据目录从数据库容器生命周期中分离。"
- },
- {
- "title": "检查配置并启动依赖",
- "body": "如果上一模块已经占用 3307 或 6380,先确认属于哪个独立练习栈,选择复用同一栈或停止其对应服务,不能直接杀掉未知进程。config --quiet 检查语法,不把展开后的密码配置贴进聊天或日志。",
- "code": "docker compose --env-file .env.compose config --quiet\ndocker compose --env-file .env.compose up -d mysql redis\ndocker compose --env-file .env.compose ps\ndocker compose --env-file .env.compose logs --tail 50 mysql",
- "lang": "bash",
- "expected": "config --quiet 正常时不输出配置;mysql/redis 出现在 ps 中,MySQL 日志最终报告 ready for connections。",
- "explanation": "语法通过只证明配置可解析,日志就绪说明数据库启动走到可连接阶段;仍需下一步从调用方验证网络和账号。"
- },
- {
- "title": "从 API 网络位置检测服务 DNS",
- "body": "run --rm 临时容器共享项目网络,使用 Python socket 验证 DNS 和 TCP 端口。成功只说明网络可达,不能证明账号权限或表迁移完成。 第二条命令交互输入app_user密码,并验证认证及库权限;网络探针成功不代替这一步。",
- "code": "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')\"\ndocker compose --env-file .env.compose exec mysql mysql --protocol=TCP -h127.0.0.1 -uapp_user -p shop_lab -e \"SELECT VERSION(),DATABASE(),CURRENT_USER(); SHOW GRANTS;\"",
- "lang": "bash",
- "expected": "临时 API 容器打印数据库 IP 与 tcp ok,SQL 客户端通过 app_user 认证并成功查询练习库。",
- "explanation": "服务名只在项目网络中解析;每个容器的 localhost 都是自己。TCP 成功与数据库认证成功是不同层的证据。"
- }
- ],
- "checks": [
- "API 容器可解析 mysql/redis,并连接内部 3306/6379。",
- "宿主端口仅绑定 127.0.0.1 的 8000、3307、6380。",
- "app_user 的 rolsuper 为 false;管理账号密码未注入 API。"
- ],
- "pitfalls": [
- {
- "symptom": "API 连接 127.0.0.1:3307 被拒绝",
- "cause": "把宿主访问地址用于容器内部。",
- "fix": "容器DATABASE_URL使用mysql:3306;宿主mysql客户端使用127.0.0.1:3307。"
- },
- {
- "symptom": "修改 APP_DB_PASSWORD 后认证仍失败",
- "cause": "初始化脚本只在空数据目录执行,已有角色密码未改变。",
- "fix": "通过受控 SQL 轮换角色密码并同步应用秘密;不能删除卷来修复认证。"
- }
- ],
- "exercise": {
- "task": "为了让 API 访问数据库,是否必须发布 3307?",
- "hint": "Compose 服务互通走内部网络。",
- "answer": "不需要。删去 mysql 的 ports 后,API 仍能使用 mysql:3306;宿主 mysql客户端 失去直接端口入口,可通过 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": "MySQL 官方容器镜像",
- "url": "https://github.com/docker-library/docs/blob/master/mysql/README.md"
- }
- ],
- "production": {
- "scenario": "同机运行多个后端依赖,地址与端口必须在容器内外保持清楚。",
- "decisions": [
- "应用用 DNS 服务名,不固定容器 IP。",
- "数据库调试端口仅回环绑定,发布环境删去不必要端口。"
- ],
- "failureDrill": "只在练习 .env.compose 把主机名改为不存在名称,观察 DNS 错误后恢复,区分认证失败与网络失败。",
- "acceptance": [
- "能够按 DNS、TCP、认证、SQL 四层定位。",
- "不同 Compose 项目资源有明确命名。"
- ]
- },
- "intro": {
- "scenario": "商品 API、MySQL 和 Redis 都能单独运行,现在需要让它们在同一练习网络互相找到。",
- "startingPoint": [
- "完成第 03 课,已有 shop-service:lab-02 镜像。",
- "进入 shop-service 根目录,确认 8000、3307、6380 是否被其他练习占用;只操作本次 shop-lab 项目。"
- ],
- "outcome": "启动独立数据库和缓存,在 API 所在网络确认名称解析、连接和数据库认证。"
- }
- },
- {
- "id": "05",
- "title": "MySQL 持久卷与可恢复备份",
- "summary": "重建数据库容器后读到原标记,再把备份导入独立空库读到同一标记。",
- "group": "运行与资源",
- "objectives": [
- "定位数据库真实保存位置",
- "保留数据卷重建并读回标记",
- "从备份恢复到独立库并核对内容"
- ],
- "concepts": [
- {
- "title": "卷保存数据库目录",
- "body": "用“创建专用持久化标记并重建容器”中观察到的结果回顾。MySQL 8.4 官方镜像的本课持久路径为 /var/lib/mysql。命名卷由 Docker 管理宿主位置,容器删除不会自动删除命名卷。不要把卷当作备份,宿主磁盘故障和误删仍会影响它。"
- },
- {
- "title": "权限与一致性",
- "body": "用“生成备份并从容器复制”中观察到的结果回顾。命名卷保存/var/lib/mysql,但卷本身不是备份。运行中的数据目录不能随手复制:逻辑备份用一致性mysqldump;物理备份使用支持该MySQL版本的一致性备份工具,并验证恢复与binlog位置。"
- },
- {
- "title": "恢复才验证备份",
- "body": "用“核对恢复并保留证据”中观察到的结果回顾。备份成功退出只能证明生成了文件,不能证明恢复后应用能使用。恢复演练要用另一容器和另一卷,核对表、行数和业务查询;备份保留策略还需异机、加密、访问控制与实际 RPO/RTO。"
- },
- {
- "title": "逻辑备份与binlog恢复分工",
- "body": "用“生成备份并从容器复制”中观察到的结果回顾。SQL转储恢复到快照时刻。更小RPO还需要连续保存binlog、匹配备份位置或GTID,并在独立实例验证时间点恢复;单独每日mysqldump不能满足分钟级数据损失目标。"
- }
- ],
- "steps": [
- {
- "title": "确认卷与数据路径",
- "body": "只读检查 Compose 解析到的数据库挂载。取得容器 id 后可 inspect Mounts;不进入 Docker 内部卷目录手工改权限或编辑文件。",
- "code": "docker compose --env-file .env.compose ps -q mysql\ndocker compose --env-file .env.compose exec mysql mysql -uroot -p -e \"SELECT @@datadir,@@version,@@default_storage_engine;\"\ndocker volume ls --filter label=com.docker.compose.project=shop-lab",
- "lang": "bash",
- "expected": "查询显示 MySQL 8.4、InnoDB 与 /var/lib/mysql,卷列表能找到本项目 mysql_data 对应卷。",
- "explanation": "卷名和数据库 datadir 对上,才知道重建容器应保留哪份存储;卷不是镜像中的某个只读文件。"
- },
- {
- "title": "创建专用持久化标记并重建容器",
- "body": "只在练习库创建volume_probe(InnoDB),重建mysql容器并保留mysql_data。重建后再次用app_user执行SELECT 1,连接和认证都成功后才查询标记;初始化期间连接失败不能证明卷丢失。下面每次-p均交互输入练习密码。",
- "code": "docker compose --env-file .env.compose exec mysql mysql -uapp_user -p shop_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';\"\ndocker compose --env-file .env.compose up -d --force-recreate mysql\ndocker compose --env-file .env.compose exec mysql mysql --protocol=TCP -h127.0.0.1 -uapp_user -p shop_lab -e \"SELECT 1;\"\n# 连接认证成功后继续;否则等待后重试。\ndocker compose --env-file .env.compose exec mysql mysql -uapp_user -p shop_lab -e \"SELECT * FROM volume_probe;\"",
- "lang": "bash",
- "expected": "重建前后都查到 id=1、value=survives recreate;连接恢复后才进行第二次查询。",
- "explanation": "新数据库进程重新挂载同一数据卷,因此记录保留。等待初始化时暂时连不上,和记录真的丢失是两类现象。"
- },
- {
- "title": "生成备份并从容器复制",
- "body": "使用同一8.4镜像中的mysqldump,直接写容器内SQL文件后复制,避免Windows重定向改变编码。--single-transaction对InnoDB取得一致性快照,备份期间禁止并发DDL;它不保证非事务表一致性。--no-tablespaces减少所需权限,--set-gtid-purged=OFF适用于本课独立逻辑恢复,不是构建复制节点的流程。包含触发器、存储过程和事件,备份账号需相应权限,本隔离练习使用root。",
- "code": "mkdir backups\ndocker 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/shop_lab.sql shop_lab\ndocker compose --env-file .env.compose cp mysql:/tmp/shop_lab.sql ./backups/shop_lab.sql\n# 只有mysqldump退出0才使用该文件;文件存在、大小正常不代表恢复已验证。",
- "lang": "bash",
- "expected": "mysqldump 正常退出,宿主 backups 中出现非空 SQL 文件;出错文件不能被标为可用备份。",
- "explanation": "逻辑转储写出重建结构和记录所需 SQL。事务快照适用于 InnoDB,备份期间的 DDL 仍须避免;文件存在只是第一关。"
- },
- {
- "title": "创建独立恢复容器",
- "body": "新建已忽略的.env.restore,只含MYSQL_ROOT_PASSWORD=独立练习密码。恢复容器使用新的命名卷,不发布端口;mysql -p交互输入恢复密码。等待日志报告ready并能执行SELECT 1,再导入到shop_restore。备份未使用--databases,因此不会自动切回源库名。重复练习时使用新的恢复库与卷,不叠加到上次非空库。 导入使用mysql批处理标准输入,不使用--force;出错即停止。仅在此隔离容器中从已有MYSQL_ROOT_PASSWORD环境变量临时提供认证,不把密码展开到命令参数;生产备份恢复用受控客户端选项文件。",
- "code": "docker volume create shop-mysql-restore-data\ndocker run -d --name shop-mysql-restore --env-file .env.restore -e MYSQL_DATABASE=shop_restore -v shop-mysql-restore-data:/var/lib/mysql mysql:8.4 --default-time-zone=+00:00\ndocker logs --tail 30 shop-mysql-restore\ndocker exec -it shop-mysql-restore mysql -uroot -p -e \"SELECT 1;\"\n# 成功后再复制,用批处理标准输入恢复,检查退出码。\ndocker cp ./backups/shop_lab.sql shop-mysql-restore:/tmp/shop_lab.sql\ndocker exec shop-mysql-restore sh -c 'MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\" mysql -uroot --default-character-set=utf8mb4 shop_restore < /tmp/shop_lab.sql'",
- "lang": "bash",
- "expected": "独立恢复容器就绪,空 shop_restore 库导入正常结束;源 shop_lab 和源卷未被替换。",
- "explanation": "独立库把恢复验证与原数据隔开。不使用 --force 可让 SQL 错误及时暴露,避免带着部分导入继续宣称成功。"
- },
- {
- "title": "核对恢复并保留证据",
- "body": "本课尚未执行第06课的应用迁移,因此只核对已创建的 volume_probe,不查询尚不存在的 products 或 alembic_version。恢复标记应为 id=1、value=survives recreate。完成第06课迁移并创建商品后,再重复备份和恢复流程;使用新的恢复数据库或新的专用恢复卷,避免把第二份备份叠加到非空恢复库,再比较 products 数量和 alembic_version。此时才完成业务数据恢复验收。当前先停止恢复容器,保留恢复卷用于核查。",
- "code": "docker exec -it shop-mysql-restore mysql -uroot -p shop_restore -e \"SELECT * FROM volume_probe; SHOW TABLE STATUS LIKE 'volume_probe';\"\ndocker stop shop-mysql-restore\n# 第06课迁移并创建商品后,重新备份到新的空恢复库,再核对:\n# SELECT count(*) FROM products;\n# SELECT version_num FROM alembic_version;",
- "lang": "bash",
- "expected": "恢复库返回同一条 volume_probe,表类型为 InnoDB;停止恢复容器后其卷仍保留。",
- "explanation": "这证明当前标记备份可恢复。完成第 06 课后还需重复商品与迁移版本验收,才能把结论扩大到实际业务数据。"
- }
- ],
- "checks": [
- "重建 mysql 容器后 volume_probe 行仍存在。",
- "mysqldump与mysql导入均退出0,恢复后的volume_probe内容和InnoDB引擎符合预期。",
- "独立恢复库包含正确的 volume_probe,原练习库未被覆盖;业务表恢复在第06课迁移后用新备份补验。"
- ],
- "pitfalls": [
- {
- "symptom": "重建容器后数据全没了",
- "cause": "未挂载持久卷、挂载路径错误,或切换了 Compose project name。",
- "fix": "检查 Mounts 和卷标签,确认实际使用的 mysql_data;不要在未确认旧卷前初始化覆盖或删除。"
- },
- {
- "symptom": "恢复时报 archive 格式错误",
- "cause": "用不支持二进制透明传输的 Shell 重定向生成备份。",
- "fix": "使用mysqldump --result-file在容器内直接生成SQL文件,再docker cp;必须验证命令退出状态并恢复到独立空库。"
- }
- ],
- "exercise": {
- "task": "每天把 MySQLDATA 目录复制到同一宿主另一个文件夹能否替代备份方案?",
- "hint": "分别考虑运行中一致性与宿主故障。",
- "answer": "不能。普通文件复制可能得到不一致数据库,而且宿主故障会同时损坏原件与副本。使用数据库支持的备份协议,并保留异机副本、加密密钥与定期恢复证据。"
- },
- "refs": [
- {
- "label": "MySQL 8.4:mysqldump",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/mysqldump.html"
- },
- {
- "label": "MySQL 8.4:SQL格式备份恢复",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/reloading-sql-format-dumps.html"
- },
- {
- "label": "Docker volumes",
- "url": "https://docs.docker.com/engine/storage/volumes/"
- }
- ],
- "production": {
- "scenario": "镜像升级必须保留数据库,宿主损坏后也应有可恢复副本。",
- "decisions": [
- "容器和数据库卷独立管理。",
- "备份在独立环境恢复后才计入可用备份。"
- ],
- "failureDrill": "重建练习数据库容器,再把备份恢复到另一卷并核对 volume_probe。完成第06课迁移后,另取新备份恢复到新的空恢复库,补验业务行数和迁移版本。",
- "acceptance": [
- "重建不丢数据。",
- "有一次完整备份恢复的实际记录。"
- ]
- },
- "intro": {
- "scenario": "更新 MySQL 容器后数据还在,但误操作删掉记录怎么办?用一条无业务影响的标记验证保留和恢复。",
- "startingPoint": [
- "完成第 04 课,mysql 与 redis 运行,app_user 可登录 shop_lab 并执行 SELECT 1。",
- "本课先用 volume_probe 小表;应用表迁移在第 06 课完成,恢复使用新库和新卷。"
- ],
- "outcome": "重建数据库容器后读到原标记,再把备份导入独立空库读到同一标记。"
- }
- },
- {
- "id": "06",
- "title": "健康检查、启动重试与一次性迁移",
- "summary": "按数据库可用、迁移成功、API 就绪的顺序启动,并观察数据库短暂停止后的恢复。",
- "group": "运行与资源",
- "objectives": [
- "让启动顺序以实际查询成功为依据",
- "核对表结构版本并创建读取商品",
- "比较数据库停止时两个健康端点的响应"
- ],
- "concepts": [
- {
- "title": "running 不是 ready",
- "body": "用“首次启动并验证迁移状态”中观察到的结果回顾。容器进程已创建不代表MySQL完成初始化,也不代表业务表已迁移。数据库探针执行认证后的SELECT 1,应用/ready验证自己的数据库连接;迁移作业再核对表结构和revision。"
- },
- {
- "title": "启动顺序只覆盖启动",
- "body": "用“演练运行时数据库短暂停止”中观察到的结果回顾。depends_on condition 控制 Compose 启动前置条件,不能保证依赖永远在线。运行时数据库重启仍会引发请求失败,连接池需检测失效连接,调用方需按幂等规则重试。"
- },
- {
- "title": "迁移是独立作业",
- "body": "用“新增一次性迁移服务”中观察到的结果回顾。一个 migrate 容器执行 alembic upgrade head,退出码 0 才允许 API 启动。不让多个 API worker 各自执行迁移。迁移失败保留日志与旧应用,不能循环忽略失败继续接流量。"
- }
- ],
- "steps": [
- {
- "title": "增加数据库和 Redis 健康检查",
- "body": "将以下字段分别合并到mysql与redis服务。MySQL探针实际执行SELECT 1,能够检测认证失败;不能用只判断进程存活的ping代替就绪检查。$$延迟到容器Shell展开变量。此处沿用本机练习环境变量,生产应使用受控客户端凭据文件。",
- "code": "# mysql 服务内\nhealthcheck:\n 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']\n interval: 5s\n timeout: 3s\n retries: 20\n start_period: 30s\n\n# redis 服务内\nhealthcheck:\n test: [\"CMD\", \"redis-cli\", \"ping\"]\n interval: 5s\n timeout: 2s\n retries: 5",
- "lang": "yaml",
- "filename": "compose.yaml",
- "expected": "mysql/redis 的配置包含各自的探针命令与有限超时,SQL 探针使用 app_user 执行 SELECT 1。",
- "explanation": "数据库进程存在不代表该账号能查询;探针执行一次真实认证与 SQL,能够识别仅进程检测看不到的错误。"
- },
- {
- "title": "新增一次性迁移服务",
- "body": "合并到 services 层,镜像与 API 完全相同。练习迁移使用 app_user 的 DDL 权限;生产用单独迁移凭据覆盖此服务的 DATABASE_URL,API 保持更小权限。 MySQL DDL通常隐式提交,迁移失败可能已经完成部分结构变更;先核对实际结构与revision,再修复或补偿,不能直接假定整个迁移事务已回滚。",
- "code": "migrate:\n image: ${SHOP_IMAGE:?set SHOP_IMAGE}\n command: [\"python\", \"-m\", \"alembic\", \"upgrade\", \"head\"]\n environment:\n DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL}\n depends_on:\n mysql:\n condition: service_healthy\n restart: \"no\"",
- "lang": "yaml",
- "filename": "compose.yaml",
- "expected": "新增 migrate 服务使用同一 SHOP_IMAGE,只执行 alembic upgrade head,等待 mysql healthy 后运行。",
- "explanation": "迁移负责把代码需要的表结构准备好,是完成后退出的一次性工作;不应由每个 API 副本竞争执行。"
- },
- {
- "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",
- "expected": "api 依赖 migrate 成功退出,自己的探针请求 /ready;urllib 检查无需另装 curl。",
- "explanation": "依赖先后保证首次启动时表准备完成;/ready 继续检查运行中的可服务条件,start_period 只影响状态统计。"
- },
- {
- "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 mysql mysql -uapp_user -p shop_lab -e \"SELECT version_num FROM alembic_version;\"",
- "lang": "bash",
- "expected": "mysql/redis/api 变为 healthy,migrate 为 Exited (0),迁移版本与目标一致,商品可创建后读取。",
- "explanation": "退出码 0 对迁移表示成功,对需持续运行的 API 则要结合角色判断。健康状态与真实商品读写两项都通过才算完成。"
- },
- {
- "title": "演练运行时数据库短暂停止",
- "body": "仅在专用本机栈停止 mysql,此时 /live 应仍为 200、/ready 为 503;重启数据库后连接池 pre_ping 应逐步恢复。Docker 标记 unhealthy 本身不会自动重启容器,restart 策略主要响应进程退出。写入失败不要盲目自动重放,用幂等键确认结果。",
- "code": "docker compose --env-file .env.compose stop mysql\ndocker compose --env-file .env.compose logs --tail 30 api\ndocker compose --env-file .env.compose start mysql\ndocker compose --env-file .env.compose ps",
- "lang": "bash",
- "expected": "数据库停止窗口内 /live 应为 200、/ready 为 503;恢复后 /ready 最终回到 200,原商品可读。",
- "explanation": "存活与依赖就绪被分开了。unhealthy 不是自动重启指令;连接重试能恢复可达性,但失败写入需按幂等语义核实。"
- }
- ],
- "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": "MySQL 8.4:隐式提交",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html"
- }
- ],
- "production": {
- "scenario": "整套服务在新机器首次启动,以及运行期间数据库维护重启。",
- "decisions": [
- "依赖健康、迁移完成和业务就绪分别验证。",
- "运行时重试有限,写请求按幂等协议恢复。"
- ],
- "failureDrill": "停止练习 mysql,观察 /live 与 /ready 分离,恢复后验证原数据和新写入。",
- "acceptance": [
- "共享数据库故障不会触发无意义的无限重启。",
- "启动失败能够定位到依赖、迁移或应用阶段。"
- ]
- },
- "intro": {
- "scenario": "容器列表显示 running,商品查询仍可能因数据库未准备好或表尚未创建而失败。",
- "startingPoint": [
- "完成第 04—05 课,依赖可认证且标记表可恢复。",
- "商品镜像应包含数据库模块最终 Alembic 迁移以及 /live、/ready 路由;在源码中先找到这三个入口。"
- ],
- "outcome": "按数据库可用、迁移成功、API 就绪的顺序启动,并观察数据库短暂停止后的恢复。"
- }
- },
- {
- "id": "07",
- "title": "CPU、内存、进程数与优雅退出",
- "summary": "查看实际资源限制,辨认专用容器的内存超限退出,并记录 API 正常关闭过程。",
- "group": "运行与资源",
- "objectives": [
- "确认配置上限已进入运行容器",
- "用独立容器观察内存不足的证据",
- "测量停止到正常退出所需时间"
- ],
- "concepts": [
- {
- "title": "限制是边界而非性能保证",
- "body": "用“重建并核对运行限制”中观察到的结果回顾。cpus 限制可用 CPU 时间,mem_limit 限制内存,pids_limit 限制线程与进程数量。线程也可能计入 PID 额度。限制过低会导致正常峰值失败,过高会让单服务挤压数据库与宿主;起始值需要用实际负载校准。"
- },
- {
- "title": "SIGTERM 与 SIGKILL",
- "body": "用“验证 API 接收停止信号”中观察到的结果回顾。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",
- "expected": "api 配置出现 1 CPU、512m、128 PID,应用 25 秒关闭预算小于容器 35 秒等待。",
- "explanation": "这些值限制最多占用多少资源,不承诺单位时间能处理多少商品请求;关闭外层预算要给应用清理留余量。"
- },
- {
- "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",
- "expected": "inspect 读取的内存值为 536870912 字节,PID 上限 128,NanoCpus 对应 1 CPU;stats 有实际使用量。",
- "explanation": "配置文件是意图,运行容器的 HostConfig 才是已应用状态。up 能应用配置变化,单独 restart 不重建配置。"
- },
- {
- "title": "在一次性专用容器触发 OOM",
- "body": "此演练不使用 API 容器或数据库卷;仅分配大于 64MB 上限的内存。不要加 --rm,便于查看退出状态,检查后只删除 shop-oom-drill。",
- "code": "docker run --name shop-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 shop-oom-drill --format '{{.State.ExitCode}} {{.State.OOMKilled}}'\ndocker rm shop-oom-drill",
- "lang": "bash",
- "expected": "专用容器通常以 137 退出且 OOMKilled 为 true,随后仅该容器被删除。",
- "explanation": "64 MiB 限制容不下 256 MiB 分配,内核可能终止它。应结合 OOMKilled 判断,不能把所有 137 都归为内存不足。"
- },
- {
- "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",
- "expected": "日志出现开始关闭和 Application shutdown complete,重新 start 后 /ready 恢复成功。",
- "explanation": "SIGTERM 给进程清理机会,预算耗尽后的 SIGKILL 无法被捕获。单副本停机期间连接失败说明入口仍需切流方案。"
- },
- {
- "title": "记录资源与退出验收",
- "body": "创建 resource-budget.md,记录实例/worker 数、连接池乘数、CPU/内存峰值及关闭耗时。任务 worker 的并发进程数和每个进程连接池也算入数据库配额。",
- "code": "API:1 进程/容器;数据库池 5;内存上限 512MiB;关闭预算 25s < 容器35s\nWorker:按第9课单独配置并发;连接数按实际执行模型计算\n宿主:为 MySQL、Redis、Docker 和系统留余量\n证据:实际 stats 峰值、OOM 状态、SIGTERM 关闭耗时",
- "lang": "text",
- "filename": "resource-budget.md",
- "expected": "resource-budget.md 包含实际峰值和退出耗时,数据库连接预算按进程数计算而非只写一个池大小。",
- "explanation": "每个进程拥有自己的连接资源,多开 worker 也会增加下游压力;内存、CPU 和连接配额需一起核算。"
- }
- ],
- "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 不影响数据库卷。",
- "关闭和任务恢复行为有明确记录。"
- ]
- },
- "intro": {
- "scenario": "一次导出可能吃掉大量内存;发布停止 API 时,也需要留时间结束正在处理的短请求。",
- "startingPoint": [
- "完成第 06 课,商品创建读取成功且 /ready 正常。",
- "仍使用本机 shop-lab;OOM 实验另建不挂载任何数据的 shop-oom-drill 容器。"
- ],
- "outcome": "查看实际资源限制,辨认专用容器的内存超限退出,并记录 API 正常关闭过程。"
- }
- },
- {
- "id": "08",
- "title": "配置、Secrets 与容器运行权限",
- "summary": "让 API 从受控文件读取数据库连接配置,并确认根目录写入被拒绝、业务读取仍正常。",
- "group": "安全与交付",
- "objectives": [
- "移除重复配置来源并验证秘密可读",
- "用非 root 用户检查实际挂载权限",
- "区分预期写入拒绝与真正的启动失败"
- ],
- "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",
- "expected": "Settings 保留原连接池字段并新增 secrets_dir;api 中不再同时提供旧 DATABASE_URL 环境值。",
- "explanation": "配置会按优先级选择来源。旧环境值比秘密文件优先,若不移除会误以为刚改的文件没有生效。"
- },
- {
- "title": "建立本机秘密文件",
- "body": "在已忽略的 secrets 目录用编辑器新建 database_url,填写 mysql:3306 的容器连接串。以下为 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",
- "expected": "宿主 secrets 目录为 700,明确的 database_url 文件为 644,连接地址使用容器网络 mysql:3306。",
- "explanation": "目录进入权限与文件读取权限共同起作用:普通宿主用户不能穿过父目录,绑定进容器的文件又可被 UID 10001 读取。"
- },
- {
- "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",
- "expected": "顶层定义文件来源,api 与 migrate 各自声明需要 database_url,其他服务没有被自动授权此秘密。",
- "explanation": "本地 Compose 文件秘密采用绑定挂载,不能仅写 uid/gid/mode 就假定权限已重映射;实际读验证才可靠。"
- },
- {
- "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",
- "expected": "api 配置包含只读根目录、64m 临时目录、移除 capabilities 与禁止提权;数据库卷配置不受影响。",
- "explanation": "应用只需读源码和配置,临时写入局限在 /tmp;这些限制缩小进程可操作范围,并不替代对应用漏洞的修复。"
- },
- {
- "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 shop-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 shop-service:lab-02",
- "lang": "bash",
- "expected": "默认非 root 用户能读秘密但不打印内容;/app 写入报 Read-only file system,/tmp 可写;API 仍可就绪。扫描无工具时记录未执行。",
- "explanation": "同一容器可以允许必要读取和临时写入,同时拒绝源码目录写入。预期拒绝是权限生效的证据,扫描结果仍需对应版本修复。"
- }
- ],
- "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。",
- "秘密没有进入镜像层与共享日志。"
- ]
- },
- "intro": {
- "scenario": "数据库密码需要在启动时提供,而 API 日常只读源码;本课逐一验证哪些文件能读、哪些位置能写。",
- "startingPoint": [
- "完成第 07 课,保留第 03 课真实 PYTHON_IMAGE 摘要和当前 compose.yaml。",
- "使用 Linux/WSL 的受控本机目录,确认镜像用户为 10001;所有秘密只在编辑器中填写。"
- ],
- "outcome": "让 API 从受控文件读取数据库连接配置,并确认根目录写入被拒绝、业务读取仍正常。"
- }
- },
- {
- "id": "09",
- "title": "API、Worker 与队列的 Compose 整合",
- "summary": "在独立 fast/bulk 队列完成真实商品快照导出,并验证暂停与恢复 Worker 后任务仍可完成。",
- "group": "安全与交付",
- "objectives": [
- "复用最终任务工程并接通独立 broker",
- "分别启动执行、派发和恢复进程",
- "按 job_id 核对状态与真实结果文件"
- ],
- "concepts": [
- {
- "title": "同镜像不同职责",
- "body": "用“添加真实 fast Worker 与 bulk Worker”中观察到的结果回顾。API 接收请求并提交任务,worker 消费任务,beat 周期调度;它们可以来自同一代码镜像,但生命周期、并发与资源不同。不把 worker 后台进程塞进 API 容器,也不在每个 API 副本内启动周期调度器。"
- },
- {
- "title": "任务边界",
- "body": "用“按真实业务链路验收与故障恢复”中观察到的结果回顾。复用异步任务模块第 11–16 课最终 tasks_lab 代码:celery_app.py 暴露 app,export_tasks.py 注册 products.export,参数为 job_id。export_jobs/export_outbox 是业务真源,publisher.py 和 recovery.py 目前是单步脚本。不要部署第 8 课的小快照过渡任务,也不要假定旧 api-lab 下载包已有这些文件。"
- },
- {
- "title": "Redis 隔离",
- "body": "用“配置独立认证 broker 与秘密文件”中观察到的结果回顾。本地 Redis /0 与 /1 可以区分 key 空间,不能隔离内存、CPU、故障与驱逐策略。生产缓存与消息代理通常使用独立实例及策略;队列持久化和 ack 机制也不能单独构成端到端任务不丢失保证。"
- }
- ],
- "steps": [
- {
- "title": "复制完整最终任务工程并统一依赖",
- "body": "把异步任务模块完成后的 tasks_lab 目录放入 shop-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",
- "expected": "任务源码完整保留,requirements.in 含实际任务依赖,新锁生成;Dockerfile 明确复制 tasks_lab 与启动包装文件。",
- "explanation": "同一镜像可包含不同角色共享的业务实现,角色由启动命令选择;只复制一个同名 Celery 文件不能构成真实导出链路。"
- },
- {
- "title": "为任务角色建立秘密加载入口",
- "body": "新建deploy/run_role.py。API的SQLAlchemy异步URL使用mysql+asyncmy://;任务的同步PyMySQL适配器接受mysql+pymysql://,均指向mysql:3306/shop_lab。入口从三个只读秘密文件读取环境变量后exec目标进程,不打印值。同步连接的解析、UTC与事务设置继续复用任务模块state_store.py。",
- "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",
- "expected": "run_role.py 有三个秘密文件的映射,空值时报明确异常,成功时以 exec 启动指定命令。",
- "explanation": "包装器只负责装载配置并交出进程,业务仍复用 state_store 的事务规则。同步 PyMySQL 与异步 SQLAlchemy 入口需要各自驱动 URL。"
- },
- {
- "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 为 mysql+pymysql://app_user:URL编码密码@mysql:3306/shop_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",
- "expected": "Compose 有独立 broker 与 broker_data,宿主只用 6384 回环端口,配置与三个 URL 文件均位于受控 secrets 目录。",
- "explanation": "缓存允许丢失和淘汰的数据,与负责交付任务的队列数据要求不同;独立实例让缓存演练不会顺带丢任务。"
- },
- {
- "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: ${SHOP_IMAGE:?set SHOP_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",
- "expected": "worker 与 worker-bulk 分别指定 exports.fast、exports.bulk,节点名不同,共享 artifacts_data 指向任务工作目录的 artifacts。",
- "explanation": "队列选择决定谁领取什么工作,工作目录决定相对结果路径。同机共享卷供不同进程读取结果,跨主机需独立共享存储设计。"
- },
- {
- "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",
- "expected": "存在分别执行 publisher 和 recovery 的服务命令;循环有间隔、异常退避并响应 TERM。",
- "explanation": "派发与恢复都需持续推进,但每轮只做有限工作。Event 负责停止等待,数据库和网络调用仍必须各有自身超时。"
- },
- {
- "title": "初始化任务表、输出权限并构建",
- "body": "将任务课程的 export_jobs/export_outbox/死信/容量/周期等最终DDL纳入本项目Alembic迁移,先在空练习库验证;不要认为products迁移自动创建这些任务表。挂载卷首次创建为root时,使用仅针对artifacts_data挂载点的一次性初始化作业 chown 10001:10001 /app/tasks_lab/artifacts,并在worker启动前完成;不可递归修改宿主未知目录。构建新标签,更新 .env.compose SHOP_IMAGE,再启动并核对注册任务和队列。",
- "code": "docker build -t shop-service:lab-03 .\n# 编辑 .env.compose:SHOP_IMAGE=shop-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",
- "expected": "秘密读取探针成功、迁移退出 0,inspect registered 能找到 products.export,两个 worker 和维护日志无持续启动失败。",
- "explanation": "权限、任务表和注册入口分别验证,防止进程看似在线却无任务或无法写文件;products 表不会自动带出整套任务结构。"
- },
- {
- "title": "按真实业务链路验收与故障恢复",
- "body": "先在products服务创建至少一条练习商品,然后以下命令从真实products表选择一条并调用最终state_store.submit_snapshot;它已包含job/outbox原子写入,不调用export_products.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\n# 以下为Bash/WSL,在容器内用真实transaction()适配器选择商品再提交。\ndocker compose --env-file .env.compose run --rm -T --no-deps worker python /app/deploy/run_role.py python - <<'PY'\nfrom state_store import transaction, submit_snapshot\nwith transaction() as cur:\n cur.execute(\"SELECT id FROM products ORDER BY id LIMIT 1\")\n row = cur.fetchone()\nassert row, \"create a product first\"\nprint(submit_snapshot([row[\"id\"]]))\nPY\ndocker compose --env-file .env.compose start worker worker-bulk\ndocker compose --env-file .env.compose exec mysql mysql -uapp_user -p shop_lab -e \"SELECT id,status,result_ref,attempts FROM export_jobs ORDER BY created_at DESC LIMIT 10;\"\n# 将JOB_ID替换为刚返回的真实UUID,完成后验收结果文件。\ndocker compose --env-file .env.compose exec worker python /app/deploy/run_role.py python verify_terminal.py JOB_ID",
- "lang": "bash",
- "expected": "Worker 暂停时新 job 留在 queued;恢复后数据库终态与 verify_terminal.py 的文件快照验收一致。",
- "explanation": "受理记录与派发意图先落库,工作恢复后可继续执行。Celery 结果缓存不能替代业务终态及文件内容核对。"
- }
- ],
- "checks": [
- "Celery inspect 注册 products.export,fast/bulk worker 只消费对应队列。",
- "MySQL中job/outbox到最终状态与可读取的输出文件一致,不能只凭Celery结果缓存。",
- "缓存redis与认证broker是不同实例;宿主任务端口6384,容器内部broker:6379。"
- ],
- "pitfalls": [
- {
- "symptom": "扩容 API 后周期任务重复发送",
- "cause": "每个 API 容器都启动了 beat 或本地定时器。",
- "fix": "beat 单独部署单实例或使用有领导选举的调度方案,任务仍需幂等。"
- },
- {
- "symptom": "worker 配了更多并发,数据库连接数翻倍",
- "cause": "每个执行进程建立自己的池,遗漏 fork 与连接生命周期。",
- "fix": "按进程模型重新预算,在子进程创建/重置连接资源;不继承已建立连接。"
- }
- ],
- "exercise": {
- "task": "为什么API和同步任务使用不同的MySQL连接URL?",
- "hint": "区分SQLAlchemy方言URL与MySQL驱动DSN。",
- "answer": "SQLAlchemy的mysql+asyncmy://选择异步驱动;任务state_store适配器解析mysql+pymysql://并调用同步PyMySQL。两者可以使用相同库和账号,但驱动入口及调用方式不同,不能把异步SQLAlchemy 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有独立受控循环,业务状态与输出经数据库核对。"
- ]
- },
- "intro": {
- "scenario": "API 已能提供商品数据,现在把完成的商品清单导出任务也放进同一部署,让提交与执行独立运行。",
- "startingPoint": [
- "完成 Docker 第 08 课和异步任务模块第 16 课,确认 tasks_lab 的真实提交、派发、恢复与文件验收流程可运行。",
- "准备完整任务源码及最终表结构;API 仍用已有商品路由,本课通过任务模块命令提交持久导出。"
- ],
- "outcome": "在独立 fast/bulk 队列完成真实商品快照导出,并验证暂停与恢复 Worker 后任务仍可完成。"
- }
- },
- {
- "id": "10",
- "title": "镜像版本、发布回滚与故障验收",
- "summary": "完成一次有版本记录、迁移门槛和业务验证的本机发布及兼容回退演练。",
- "group": "安全与交付",
- "objectives": [
- "保存可准确选择的旧版本及兼容边界",
- "迁移失败时停止后续切换",
- "回退后验证旧数据与新任务均可使用"
- ],
- "concepts": [
- {
- "title": "发布的是已验证镜像",
- "body": "用“构建、标记并本机验收”中观察到的结果回顾。本机 image ID 与仓库 digest 是不同概念;推送后以仓库名@sha256 选择不可变发布物。标签可便于阅读但可能被覆盖,不能只保存 latest。预发布和生产使用同一已验证摘要,避免在每台机器重建出不同依赖。"
- },
- {
- "title": "Compose 的可用性边界",
- "body": "用“验证业务再决定继续或回退”中观察到的结果回顾。单机单副本 Compose 更新通常会有短暂中断,不承诺零停机。需要平滑切换时部署两个版本后由代理切换,或者使用具备滚动与就绪能力的平台;数据库迁移仍须兼容新旧版本。"
- },
- {
- "title": "回滚不等于删数据",
- "body": "用“验证业务再决定继续或回退”中观察到的结果回顾。旧镜像能否回退取决于当前数据库结构是否兼容。新增列通常可保留供旧版忽略,删除/重命名/数据语义改变可能阻止回退。不能为了退应用版本就清空数据库卷或机械执行 downgrade。"
- }
- ],
- "steps": [
- {
- "title": "记录当前可回退版本",
- "body": "发布前保存当前 SHOP_IMAGE、迁移版本、配置版本和必要备份。只记录秘密文件的版本标识或来源,不把内容复制进 release-record.md。",
- "code": "版本:shop-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",
- "expected": "release-record.md 填入真实旧镜像、当前/目标 revision、兼容范围、备份证据和回退触发窗口。",
- "explanation": "能回退的前提是找得到旧软件且旧软件仍理解新数据结构;秘密仅记录来源版本,不记录值。"
- },
- {
- "title": "构建、标记并本机验收",
- "body": "构建使用第 3 课已验证基础 digest。用户拥有并授权的镜像仓库才可执行登录与推送;课件提供命令模板,不实际向外发布。标签中使用真实版本或 commit,不重复覆盖旧发布标签。 以下命令在Bash/WSL运行,PYTHON_IMAGE必须是第03课保存的完整摘要;开启新终端时先恢复已审核的变量,不能静默回退到浮动标签。",
- "code": "docker build --build-arg PYTHON_IMAGE=\"${PYTHON_IMAGE:?set verified base digest}\" -t shop-service:release-01 .\ndocker run --rm shop-service:release-01 python -c \"import fastapi, sqlalchemy, asyncmy, pymysql; print('imports ok')\"\n# 已具备目标仓库权限后,替换 REGISTRY/PROJECT 为真实仓库:\n# docker tag shop-service:release-01 REGISTRY/PROJECT/shop-service:release-01\n# docker push REGISTRY/PROJECT/shop-service:release-01\n# docker buildx imagetools inspect REGISTRY/PROJECT/shop-service:release-01",
- "lang": "bash",
- "expected": "新标签镜像构建成功,导入探针打印 imports ok;可选推送只有具备仓库权限时才继续并保存真实 digest。",
- "explanation": "明确版本便于选回旧发布物;仓库 digest 标识推送后的内容,本地 image ID 不可直接当仓库地址使用。"
- },
- {
- "title": "先运行唯一迁移作业",
- "body": "更新 .env.compose 的 SHOP_IMAGE 为目标版本,先在预发布演练下面命令。发布时 run --rm migrate 执行当前目标镜像迁移;API 仍可维持旧版,前提是迁移经过 expand-contract 兼容审核。迁移失败即停止后续发布。 下列命令在 Bash/WSL 执行,失败门槛会终止本轮发布命令;PowerShell 应逐条执行并检查 $LASTEXITCODE,非 0 不继续。",
- "code": "docker compose --env-file .env.compose run --rm migrate || exit 1\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",
- "expected": "迁移作业必须先退出 0,随后才更新应用角色;出现非 0 时停止,不继续执行下一条 up。",
- "explanation": "表结构兼容是新旧应用交接的门槛。MySQL 的多步迁移不能假定整体回滚,失败后要核对实际结构与 revision。"
- },
- {
- "title": "验证业务再决定继续或回退",
- "body": "检查 /ready、创建读取、旧版本冲突、幂等重试和 真实导出任务;记录实际 HTTP 与数据库结果。失败时先将 SHOP_IMAGE 恢复上一兼容版本,再 up --no-deps api worker,不回退 Redis/MySQL 镜像主版本,也不自动操作数据库迁移。",
- "code": "# 将 .env.compose 的 SHOP_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",
- "expected": "切回已验证兼容镜像后 /ready 为 200,既有商品可读,新导出也能完成,数据库卷仍是原卷。",
- "explanation": "应用回退只选择代码版本,不等于把业务数据倒回过去;删除或重命名字段可能让旧代码不再兼容。"
- },
- {
- "title": "完成隔离故障矩阵",
- "body": "在本机专用栈逐项执行,保存预期与实测。数据库数据使用迁移和备份处理,不以删卷作为回滚。超出本机实测范围的远程仓库、TLS、负载均衡明确标记未验证。",
- "code": "场景 | 观测 | 恢复动作\n错误数据库密码 | readiness失败,认证日志 | 修正秘密并重建API;不删卷\n错误应用镜像 | API启动失败,旧版可用性记录 | 切回兼容镜像\nworker停止 | 队列积压,API仍可提交 | 启动worker,核对任务去重\nOOM演练 | 专用容器OOMKilled | 调整预算并检查宿主余量\n迁移失败 | migrate退出非0 | 停止发布,修复迁移或锁阻塞\n数据库容器重建 | 卷保留,原商品可读 | 等待ready并执行业务探针",
- "lang": "text",
- "expected": "每个场景有实际响应/日志、恢复动作与结果;远程仓库、TLS 等未做的项目明确标未验证。",
- "explanation": "故障矩阵把“预计能恢复”变成可复查证据。不同故障对应不同恢复边界,不能统一靠删卷或重启全部处理。"
- }
- ],
- "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": [
- "构建一次并以明确版本交付。",
- "应用回退与数据库恢复分开决策,保持数据卷。",
- "MySQL的原子DDL保证单条受支持DDL的原子性,不把多条Alembic操作变成可整体回滚事务;发布门槛同时核对实际schema与revision。"
- ],
- "failureDrill": "在本机发布一个故意缺配置的应用版本,确认错误后回到上一镜像并核对既有数据。",
- "acceptance": [
- "有实际回退耗时和业务恢复结果。",
- "未验证的仓库、远程部署和高可用能力明确标记。"
- ]
- },
- "intro": {
- "scenario": "新版本若启动失败,必须准确找回旧版本,还要保证已经保存的商品和导出记录仍能使用。",
- "startingPoint": [
- "完成第 09 课,记录一次真实导出的 job_id 和结果文件,当前栈可完成商品读写。",
- "在本机专用栈准备当前镜像标识、迁移版本和已验证备份;外部镜像仓库操作仅作有权限时的可选步骤。"
- ],
- "outcome": "完成一次有版本记录、迁移门槛和业务验证的本机发布及兼容回退演练。"
- }
- }
- ]
|