docker.json 80 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103
  1. [
  2. {
  3. "id": "01",
  4. "title": "镜像、容器与进程生命周期",
  5. "summary": "从一个临时 Python 容器开始,观察进程退出、停止、重建与数据保留的区别。",
  6. "group": "镜像与构建",
  7. "objectives": [
  8. "区分镜像模板与运行中的容器",
  9. "找到容器进程、日志和退出状态",
  10. "判断哪些数据应写入卷而非容器可写层"
  11. ],
  12. "concepts": [
  13. {
  14. "title": "容器运行的是进程",
  15. "body": "镜像是文件系统和启动配置;容器是基于镜像创建的进程及其隔离环境。主进程退出,容器便停止,Docker 不会因为容器存在就持续执行应用。容器不是需要手工登录安装软件的长期虚拟机。"
  16. },
  17. {
  18. "title": "停止与删除不同",
  19. "body": "stop 发送停止信号并等待,start 重用原容器,rm 删除已停止容器的可写层。重新 run 创建另一份容器。镜像和显式命名卷有独立生命周期,重建 API 容器不应删除数据库卷。"
  20. },
  21. {
  22. "title": "本模块练习边界",
  23. "body": "使用 Docker Desktop 的 Linux 容器模式或独立 Linux 练习机,先完成 API+数据库模块的 notes-service。所有命令针对本地专用练习项目;不连接线上 Docker context,不挂载 Docker socket 到应用。未完成前置代码时,端点和 worker 不会自动存在。"
  24. }
  25. ],
  26. "steps": [
  27. {
  28. "title": "核对 Docker 目标",
  29. "body": "执行下列只读命令,确认 context 指向本机练习环境,Linux 容器模式,Compose V2 可用。若无 Docker,应先按官方安装文档完成当前系统安装,不执行来源不明的一键脚本。",
  30. "code": "docker context show\ndocker info --format '{{.OSType}}'\ndocker compose version\ndocker version",
  31. "lang": "bash"
  32. },
  33. {
  34. "title": "运行一次性 Python 进程",
  35. "body": "该容器运行完 print 就退出,--rm 清理它自己的可写层。它没有数据库、持久卷或宿主目录挂载,不影响 notes 数据。",
  36. "code": "docker run --rm python:3.11-slim python -c \"import sys; print(sys.version)\"",
  37. "lang": "bash"
  38. },
  39. {
  40. "title": "观察持续运行的主进程",
  41. "body": "创建专用生命周期容器,查看它的日志和进程。这里只写容器内临时文件,接下来可以安全删除该练习容器。Windows PowerShell 可原样使用双引号内的 Python 代码。",
  42. "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}}'",
  43. "lang": "bash"
  44. },
  45. {
  46. "title": "停止、重启再查看文件",
  47. "body": "stop 后仍保留原容器文件层,start 重新运行启动命令。观察完再次 stop,并只删除名为 notes-lifecycle 的容器;不要用批量 prune 清理所有项目。",
  48. "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",
  49. "lang": "bash"
  50. },
  51. {
  52. "title": "比较新容器的文件层",
  53. "body": "使用相同镜像创建新容器,它没有前一容器的 probe 文件。预期 False 不代表镜像损坏,而是临时写入没有进入镜像或持久卷。",
  54. "code": "docker run --rm python:3.11-slim python -c \"from pathlib import Path; print(Path('/tmp/probe.txt').exists())\"",
  55. "lang": "bash"
  56. }
  57. ],
  58. "checks": [
  59. "docker info 的 OSType 为 linux,context 明确指向练习环境。",
  60. "能够观察主进程退出导致容器停止,日志可独立读取。",
  61. "新容器中没有上一容器临时文件,能说明卷的必要性。"
  62. ],
  63. "pitfalls": [
  64. {
  65. "symptom": "docker run 很快结束,以为容器启动失败",
  66. "cause": "启动命令只是一个会完成的脚本。",
  67. "fix": "查看 docker logs 与退出码,服务需要持续监听进程,而非用 tail 掩盖真实服务退出。"
  68. },
  69. {
  70. "symptom": "进入容器 pip install 后重建又缺包",
  71. "cause": "安装发生在容器临时文件层。",
  72. "fix": "将依赖写进 Dockerfile 与锁定文件,通过重建镜像保留。"
  73. }
  74. ],
  75. "exercise": {
  76. "task": "镜像没有变化,为何两个容器的文件可能不同?",
  77. "hint": "启动配置相同不代表运行时写入相同。",
  78. "answer": "容器各自拥有可写层和进程状态;环境变量、挂载卷和启动命令也可能不同。把需要复现的软件放进镜像,把需要保留的业务数据放进明确的持久存储。"
  79. },
  80. "refs": [
  81. {
  82. "label": "Docker:什么是容器",
  83. "url": "https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-a-container/"
  84. },
  85. {
  86. "label": "Docker:镜像",
  87. "url": "https://docs.docker.com/get-started/docker-concepts/the-basics/what-is-an-image/"
  88. },
  89. {
  90. "label": "Docker:安装",
  91. "url": "https://docs.docker.com/engine/install/"
  92. }
  93. ],
  94. "production": {
  95. "scenario": "开发者需要交付能在另一台机器启动的服务,而非一份手工配置记录。",
  96. "decisions": [
  97. "以主进程状态判断服务是否运行。",
  98. "只清理明确命名的练习容器,持久数据单独管理。"
  99. ],
  100. "failureDrill": "停止并删除无挂载的生命周期容器,再从同一镜像创建,观察临时文件消失。",
  101. "acceptance": [
  102. "能解释进程、可写层、镜像与卷各自的生命周期。",
  103. "所有操作目标都可通过 context 和容器名定位。"
  104. ]
  105. }
  106. },
  107. {
  108. "id": "02",
  109. "title": "为 FastAPI 编写非 Root Dockerfile",
  110. "summary": "把 notes-service 源码与固定依赖构建进镜像,以非 root 用户启动 Uvicorn。",
  111. "group": "镜像与构建",
  112. "objectives": [
  113. "编写最小可运行 Dockerfile 和 .dockerignore",
  114. "用锁定依赖避免不同机器安装漂移",
  115. "以 exec 形式运行非 root 应用进程"
  116. ],
  117. "concepts": [
  118. {
  119. "title": "构建上下文决定哪些文件可被复制",
  120. "body": "docker build 的最后一个路径是构建上下文,COPY 只能读取其内容。把 .env、备份、虚拟环境排除可减少构建大小并避免秘密进入镜像;在后续 RUN 删除秘密无法抹去早期镜像层。"
  121. },
  122. {
  123. "title": "运行用户与监听地址",
  124. "body": "容器内应用使用固定非 root UID,监听 0.0.0.0:8000 才能接收容器网卡流量。宿主发布到 127.0.0.1:8000 限制外部访问。非 root 不等于完整隔离,但能减少意外写入和权限范围。"
  125. },
  126. {
  127. "title": "进程命令",
  128. "body": "JSON 数组形式 CMD 直接运行应用进程,停止信号可以到达 Uvicorn;不要用 shell 后台启动加无限 sleep。生产镜像不启用 --reload,日志写 stdout/stderr,数据库迁移独立运行。"
  129. }
  130. ],
  131. "steps": [
  132. {
  133. "title": "建立目标平台锁定依赖",
  134. "body": "在 notes-service 新建 requirements.in,采用下列主依赖。已有业务的额外依赖应一起加入。用目标 Linux/Python3.11 的构建环境生成 requirements-lock.txt;不要把 Windows 专有库锁直接用于 Linux。下一课给出容器内生成命令。",
  135. "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",
  136. "lang": "text",
  137. "filename": "requirements.in"
  138. },
  139. {
  140. "title": "新建忽略文件",
  141. "body": "Docker 构建不需要本机 .env、虚拟环境、数据库转储和 Git 历史。保留 .env.example 可供文档参考,但本课 Dockerfile 只显式 COPY 应用必需文件。",
  142. "code": ".git\n.venv\n.env\n.env.*\nsecrets/\nbackups/\n*.dump\n*.sql.gz\n__pycache__/\n.pytest_cache/\ntests/",
  143. "lang": "text",
  144. "filename": ".dockerignore"
  145. },
  146. {
  147. "title": "新建 Dockerfile",
  148. "body": "依赖完整 app 目录、alembic.ini、migrations 与生成的 requirements-lock.txt。Python slim 基础版本下一课固定为实际 digest;此处标签用于首次学习构建。COPY 不复制本机配置。",
  149. "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\"]",
  150. "lang": "dockerfile",
  151. "filename": "Dockerfile"
  152. },
  153. {
  154. "title": "构建并核对镜像用户",
  155. "body": "完成下一课锁文件生成后在项目根目录构建。此检查不需要数据库配置,因为 python -c 直接执行,没有导入 app。用户 id 应为 10001。",
  156. "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}}'",
  157. "lang": "bash"
  158. },
  159. {
  160. "title": "通过练习网络配置启动服务",
  161. "body": "本课暂不启动完整服务,先验证代码和依赖存在;第 4 课 Compose 会提供 mysql DNS 与 DATABASE_URL。直接运行镜像若缺 DATABASE_URL 应明确失败,这比默认连错库更容易排错。",
  162. "code": "docker run --rm notes-service:lab-01 python -c \"import fastapi, sqlalchemy, asyncmy, pymysql; print(fastapi.__version__, sqlalchemy.__version__); print('mysql drivers import ok')\"\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())\"",
  163. "lang": "bash"
  164. }
  165. ],
  166. "checks": [
  167. "镜像运行用户为 10001,不需要 --privileged。",
  168. "镜像包含应用与迁移文件,不含 .env 和备份。",
  169. "CMD 直接启动 Uvicorn,未启用 reload;依赖版本能在容器内打印。"
  170. ],
  171. "pitfalls": [
  172. {
  173. "symptom": "容器日志显示已启动,宿主访问不到",
  174. "cause": "Uvicorn 只监听容器回环地址 127.0.0.1。",
  175. "fix": "容器内监听 0.0.0.0,宿主端口发布是否公开由 ports 绑定地址控制。"
  176. },
  177. {
  178. "symptom": "pip --require-hashes 拒绝安装",
  179. "cause": "锁文件不是带完整依赖与哈希的锁文件。",
  180. "fix": "按下一课用 pip-tools 生成,不能去掉哈希选项假装已经固定依赖。"
  181. }
  182. ],
  183. "exercise": {
  184. "task": "为什么不直接 COPY . /app?",
  185. "hint": "检查构建上下文中的本机配置与数据。",
  186. "answer": "显式 COPY 应用、迁移和锁文件能缩小误带入范围;.dockerignore 再提供过滤。COPY . 并非语法错误,但需要更严格控制上下文,新手项目容易把秘密、缓存和备份带进去。"
  187. },
  188. "refs": [
  189. {
  190. "label": "Docker:Dockerfile 参考",
  191. "url": "https://docs.docker.com/reference/dockerfile/"
  192. },
  193. {
  194. "label": "Docker:构建上下文与忽略文件",
  195. "url": "https://docs.docker.com/build/concepts/context/"
  196. },
  197. {
  198. "label": "pip:安全可重复安装",
  199. "url": "https://pip.pypa.io/en/stable/topics/secure-installs/"
  200. }
  201. ],
  202. "production": {
  203. "scenario": "应用镜像交给部署机器运行,需要固定依赖与明确的低权限进程。",
  204. "decisions": [
  205. "运行时依赖装在构建阶段,秘密仅运行时注入。",
  206. "只复制需要的目录,非 root 启动。"
  207. ],
  208. "failureDrill": "在构建上下文放一个无敏感内容的 .env 练习标记,构建后确认 /app/.env 不存在。",
  209. "acceptance": [
  210. "镜像内没有本机配置标记。",
  211. "容器用户和启动命令可审计。"
  212. ]
  213. }
  214. },
  215. {
  216. "id": "03",
  217. "title": "分层缓存、多阶段构建与可重复输入",
  218. "summary": "将依赖构建和运行文件分开,固定基础镜像与 Python 包,让构建结果可以追溯。",
  219. "group": "镜像与构建",
  220. "objectives": [
  221. "用目标平台生成带哈希依赖锁",
  222. "理解 COPY 顺序对构建缓存的影响",
  223. "以多阶段构建移除构建工具并记录镜像摘要"
  224. ],
  225. "concepts": [
  226. {
  227. "title": "缓存按层失效",
  228. "body": "先 COPY 依赖锁再安装,最后 COPY 源码,业务代码修改通常不会让依赖安装层失效。缓存提升速度,不代表输入自动更新;基础镜像和锁文件更新仍需显式执行与测试。"
  229. },
  230. {
  231. "title": "标签与摘要",
  232. "body": "python:3.11-slim 标签可能随安全更新移动,digest 指向具体镜像内容。使用真实查询到的 digest 可固定基础镜像;固定也意味着必须有主动更新流程,不能永久停留在旧漏洞版本。"
  233. },
  234. {
  235. "title": "多阶段的边界",
  236. "body": "builder 创建虚拟环境,runtime 只复制环境与应用代码。二者使用相同基础镜像和架构,避免复制不兼容的本地扩展。哈希锁、基础 digest 和目标平台稳定输入;如果要求字节级相同镜像,还需控制构建时间戳与工具链,不能仅靠 Dockerfile 保证。"
  237. }
  238. ],
  239. "steps": [
  240. {
  241. "title": "在一次性 Linux 容器生成锁文件",
  242. "body": "以下为 Bash/WSL 在 notes-service 根目录执行;PowerShell 使用 ${PWD} 对应绝对路径替换 $(pwd)。pip-tools 安装只发生在临时构建工具容器。生成后审阅锁定变更并纳入代码管理。",
  243. "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'",
  244. "lang": "bash"
  245. },
  246. {
  247. "title": "查询实际基础摘要",
  248. "body": "执行 pull 后查看 RepoDigests,将输出中的完整 python@sha256:... 记录到本机构建变量 PYTHON_IMAGE。不要复制课件中的占位摘要。多架构构建需明确目标平台并记录选择。",
  249. "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",
  250. "lang": "bash"
  251. },
  252. {
  253. "title": "替换 Dockerfile 为多阶段版本",
  254. "body": "用本段完整替换第 2 课 Dockerfile。PYTHON_IMAGE 的默认标签供练习,发布构建必须通过 --build-arg 传已记录的 digest;builder 与 runtime 共用同一值。asyncmy需要匹配目标Python和架构的wheel;若目标平台没有wheel,在builder层安装编译所需工具,重新锁定并验证,运行层保留必要运行库。PyMySQL[rsa]支持MySQL 8.4默认的caching_sha2_password认证,不启用旧认证插件。",
  255. "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\"]",
  256. "lang": "dockerfile",
  257. "filename": "Dockerfile"
  258. },
  259. {
  260. "title": "构建并观察缓存",
  261. "body": "在同一 Bash / WSL 终端使用上一阶段取得并验证的 PYTHON_IMAGE;新开终端时重新从已拉取镜像读取摘要。变量为空会在构建前停止。第一次完成后仅修改 app 中一个无副作用注释,再构建,依赖层应显示 CACHED。",
  262. "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}}'",
  263. "lang": "bash"
  264. },
  265. {
  266. "title": "记录构建证据与更新路径",
  267. "body": "创建 build-record.md,记录下面字段。依赖升级时在分支重新生成锁、构建新镜像并运行数据库测试,再发布;不能在运行容器内 pip install -U 修补并忘记回写镜像。",
  268. "code": "源码提交:实际 git commit SHA\n目标平台:例如 linux/amd64\n基础镜像:实际 python@sha256:...\n依赖锁:requirements-lock.txt 的版本/摘要\n应用镜像:本机 image ID;推送后补仓库 digest\n验证结果:执行命令、实际输出、未验证项",
  269. "lang": "text",
  270. "filename": "build-record.md"
  271. }
  272. ],
  273. "checks": [
  274. "requirements-lock.txt 包含递归依赖的固定版本与哈希,容器中 --require-hashes 安装通过。",
  275. "只改应用源码时依赖层命中缓存。",
  276. "构建记录能关联源码、基础镜像、依赖锁与最终镜像。"
  277. ],
  278. "pitfalls": [
  279. {
  280. "symptom": "复制本机 .venv 后容器启动找不到动态库",
  281. "cause": "宿主系统或架构与容器不一致。",
  282. "fix": "在目标 Linux 基础镜像内创建环境,builder/runtime 保持相同平台与 ABI。"
  283. },
  284. {
  285. "symptom": "固定基础摘要后长期没有安全更新",
  286. "cause": "把固定输入误当作无需维护。",
  287. "fix": "定期更新摘要和依赖锁,在测试通过后产生新发布版本,保留变更记录。"
  288. }
  289. ],
  290. "exercise": {
  291. "task": "--no-cache 是否保证获取最新基础镜像?",
  292. "hint": "构建缓存与拉取基础镜像是两件事。",
  293. "answer": "不保证。--pull 会检查标签的新内容;但如果固定 digest,应先审阅并选择新 digest,再更新构建参数。盲目拉最新也不等于通过了应用兼容测试。"
  294. },
  295. "refs": [
  296. {
  297. "label": "Docker:多阶段构建",
  298. "url": "https://docs.docker.com/build/building/multi-stage/"
  299. },
  300. {
  301. "label": "Docker:构建缓存",
  302. "url": "https://docs.docker.com/build/cache/"
  303. },
  304. {
  305. "label": "Docker:固定基础镜像",
  306. "url": "https://docs.docker.com/build/building/best-practices/#pin-base-image-versions"
  307. },
  308. {
  309. "label": "pip-tools:依赖锁",
  310. "url": "https://pip-tools.readthedocs.io/en/stable/"
  311. }
  312. ],
  313. "production": {
  314. "scenario": "同一版本需要在预发布和线上运行相同的软件输入。",
  315. "decisions": [
  316. "构建一次,按同一镜像摘要提升到下一个环境。",
  317. "构建工具留在 builder,运行层只包含运行所需文件。"
  318. ],
  319. "failureDrill": "修改源码与依赖锁分别构建,记录哪几层重建;还原后核对镜像关联记录。",
  320. "acceptance": [
  321. "能解释缓存结果。",
  322. "依赖升级产生新的可追溯发布物。"
  323. ]
  324. }
  325. },
  326. {
  327. "id": "04",
  328. "title": "Compose 网络、服务 DNS 与端口",
  329. "summary": "把 API、MySQL 和 Redis 放入同一专用网络,区分容器内地址和宿主调试端口。",
  330. "group": "运行与资源",
  331. "objectives": [
  332. "通过服务名连接 MySQL 与 Redis",
  333. "绑定本机调试端口并避免冲突",
  334. "用 Compose 配置检查和容器内探针定位连通性"
  335. ],
  336. "concepts": [
  337. {
  338. "title": "localhost 指当前容器",
  339. "body": "API 中 127.0.0.1 指向 API 自己,不能到 MySQL。Compose 默认网络提供 mysql 和 redis 服务名解析;容器间连接使用 mysql:3306 与 redis:6379,宿主调试使用 127.0.0.1:3307 与 127.0.0.1:6380。"
  340. },
  341. {
  342. "title": "端口发布的方向",
  343. "body": "ports 的左侧是宿主绑定,右侧是容器监听。容器间通信不依赖宿主端口映射;数据库不需要被公网发布。EXPOSE 是镜像元信息,也不会自动开放宿主端口。"
  344. },
  345. {
  346. "title": "项目隔离",
  347. "body": "Compose project name 区分同机多个练习栈的网络与卷;不要设置固定 container_name,它会阻碍扩容并造成跨项目名字冲突。示例数据库为 notes_lab,app_user 是普通应用角色,mysql 管理账号不进入 API。"
  348. }
  349. ],
  350. "steps": [
  351. {
  352. "title": "新建 Compose 专用环境文件",
  353. "body": "复制下列模板为 .env.compose 并填写两个不同的本机练习密码。文件不提交,后续统一命令显式 --env-file .env.compose;它用于 Compose 插值,不覆盖先前本机 Python 使用的 .env。连接串密码有特殊字符需 URL 编码。",
  354. "code": "COMPOSE_PROJECT_NAME=notes-lab\nDB_ADMIN_PASSWORD=CHANGE_ADMIN_PASSWORD\nAPP_DB_PASSWORD=CHANGE_APP_PASSWORD\nDATABASE_URL=mysql+asyncmy://app_user:CHANGE_APP_PASSWORD@mysql:3306/notes_lab?charset=utf8mb4\nREDIS_URL=redis://redis:6379/0\nNOTES_IMAGE=notes-service:lab-02",
  355. "lang": "dotenv",
  356. "filename": ".env.compose"
  357. },
  358. {
  359. "title": "用官方镜像完成首次初始化",
  360. "body": "MySQL官方镜像仅在空数据目录初始化MYSQL_DATABASE、MYSQL_USER和MYSQL_PASSWORD。app_user取得notes_lab库内权限,API不使用root。后续修改环境文件不会修改已存在账户的密码;已有卷应通过审核后的ALTER USER修改凭据。这里不再编写自定义建用户脚本,镜像负责转义初始化输入。",
  361. "code": "MYSQL_ROOT_PASSWORD → 独立管理员密码\nMYSQL_DATABASE=notes_lab → 仅首次创建练习库\nMYSQL_USER=app_user / MYSQL_PASSWORD → 仅该库的练习账号\n持久卷 mysql_data → /var/lib/mysql\n服务名 mysql:3306 → 容器访问\n127.0.0.1:3307 → 宿主访问\nInnoDB / utf8mb4 / UTC → 全模块约定",
  362. "lang": "text",
  363. "filename": "初始化约定"
  364. },
  365. {
  366. "title": "新建 compose.yaml",
  367. "body": "本段创建 API、MySQL、Redis 与独立卷;Redis 此时只是连通性练习,不承诺任务持久化。宿主数据库端口与数据库模块一致。API 初次启动可能尚未建表,第 6 课加入迁移和健康依赖。",
  368. "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 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: notes_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: {}",
  369. "lang": "yaml",
  370. "filename": "compose.yaml"
  371. },
  372. {
  373. "title": "检查配置并启动依赖",
  374. "body": "如果上一模块已经占用 3307 或 6380,先确认属于哪个独立练习栈,选择复用同一栈或停止其对应服务,不能直接杀掉未知进程。config --quiet 检查语法,不把展开后的密码配置贴进聊天或日志。",
  375. "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",
  376. "lang": "bash"
  377. },
  378. {
  379. "title": "从 API 网络位置检测服务 DNS",
  380. "body": "run --rm 临时容器共享项目网络,使用 Python socket 验证 DNS 和 TCP 端口。成功只说明网络可达,不能证明账号权限或表迁移完成。 第二条命令交互输入app_user密码,并验证认证及库权限;网络探针成功不代替这一步。",
  381. "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 notes_lab -e \"SELECT VERSION(),DATABASE(),CURRENT_USER(); SHOW GRANTS;\"",
  382. "lang": "bash"
  383. }
  384. ],
  385. "checks": [
  386. "API 容器可解析 mysql/redis,并连接内部 3306/6379。",
  387. "宿主端口仅绑定 127.0.0.1 的 8000、3307、6380。",
  388. "app_user 的 rolsuper 为 false;管理账号密码未注入 API。"
  389. ],
  390. "pitfalls": [
  391. {
  392. "symptom": "API 连接 127.0.0.1:3307 被拒绝",
  393. "cause": "把宿主访问地址用于容器内部。",
  394. "fix": "容器DATABASE_URL使用mysql:3306;宿主mysql客户端使用127.0.0.1:3307。"
  395. },
  396. {
  397. "symptom": "修改 APP_DB_PASSWORD 后认证仍失败",
  398. "cause": "初始化脚本只在空数据目录执行,已有角色密码未改变。",
  399. "fix": "通过受控 SQL 轮换角色密码并同步应用秘密;不能删除卷来修复认证。"
  400. }
  401. ],
  402. "exercise": {
  403. "task": "为了让 API 访问数据库,是否必须发布 3307?",
  404. "hint": "Compose 服务互通走内部网络。",
  405. "answer": "不需要。删去 mysql 的 ports 后,API 仍能使用 mysql:3306;宿主 mysql客户端 失去直接端口入口,可通过 docker compose exec 调试。线上通常不发布数据库宿主端口。"
  406. },
  407. "refs": [
  408. {
  409. "label": "Docker:Compose 网络",
  410. "url": "https://docs.docker.com/compose/how-tos/networking/"
  411. },
  412. {
  413. "label": "Docker:端口发布",
  414. "url": "https://docs.docker.com/engine/network/port-publishing/"
  415. },
  416. {
  417. "label": "MySQL 官方容器镜像",
  418. "url": "https://github.com/docker-library/docs/blob/master/mysql/README.md"
  419. }
  420. ],
  421. "production": {
  422. "scenario": "同机运行多个后端依赖,地址与端口必须在容器内外保持清楚。",
  423. "decisions": [
  424. "应用用 DNS 服务名,不固定容器 IP。",
  425. "数据库调试端口仅回环绑定,发布环境删去不必要端口。"
  426. ],
  427. "failureDrill": "只在练习 .env.compose 把主机名改为不存在名称,观察 DNS 错误后恢复,区分认证失败与网络失败。",
  428. "acceptance": [
  429. "能够按 DNS、TCP、认证、SQL 四层定位。",
  430. "不同 Compose 项目资源有明确命名。"
  431. ]
  432. }
  433. },
  434. {
  435. "id": "05",
  436. "title": "MySQL 持久卷与可恢复备份",
  437. "summary": "验证重建数据库容器后数据仍存在,在独立恢复容器中验证逻辑备份。",
  438. "group": "运行与资源",
  439. "objectives": [
  440. "区分 named volume 与 bind mount 的权限责任",
  441. "验证容器重建不丢 MySQL 数据",
  442. "制作并恢复逻辑备份,记录恢复证据"
  443. ],
  444. "concepts": [
  445. {
  446. "title": "卷保存数据库目录",
  447. "body": "MySQL 8.4 官方镜像的本课持久路径为 /var/lib/mysql。命名卷由 Docker 管理宿主位置,容器删除不会自动删除命名卷。不要把卷当作备份,宿主磁盘故障和误删仍会影响它。"
  448. },
  449. {
  450. "title": "权限与一致性",
  451. "body": "命名卷保存/var/lib/mysql,但卷本身不是备份。运行中的数据目录不能随手复制:逻辑备份用一致性mysqldump;物理备份使用支持该MySQL版本的一致性备份工具,并验证恢复与binlog位置。"
  452. },
  453. {
  454. "title": "恢复才验证备份",
  455. "body": "备份成功退出只能证明生成了文件,不能证明恢复后应用能使用。恢复演练要用另一容器和另一卷,核对表、行数和业务查询;备份保留策略还需异机、加密、访问控制与实际 RPO/RTO。"
  456. },
  457. {
  458. "title": "逻辑备份与binlog恢复分工",
  459. "body": "SQL转储恢复到快照时刻。更小RPO还需要连续保存binlog、匹配备份位置或GTID,并在独立实例验证时间点恢复;单独每日mysqldump不能满足分钟级数据损失目标。"
  460. }
  461. ],
  462. "steps": [
  463. {
  464. "title": "确认卷与数据路径",
  465. "body": "只读检查 Compose 解析到的数据库挂载。取得容器 id 后可 inspect Mounts;不进入 Docker 内部卷目录手工改权限或编辑文件。",
  466. "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=notes-lab",
  467. "lang": "bash"
  468. },
  469. {
  470. "title": "创建专用持久化标记并重建容器",
  471. "body": "只在练习库创建volume_probe(InnoDB),重建mysql容器并保留mysql_data。重建后再次用app_user执行SELECT 1,连接和认证都成功后才查询标记;初始化期间连接失败不能证明卷丢失。下面每次-p均交互输入练习密码。",
  472. "code": "docker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e \"CREATE TABLE IF NOT EXISTS volume_probe (id INT PRIMARY KEY,value VARCHAR(100) NOT NULL) ENGINE=InnoDB; INSERT INTO volume_probe VALUES (1,'survives recreate') ON DUPLICATE KEY UPDATE value='survives recreate';\"\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 notes_lab -e \"SELECT 1;\"\n# 连接认证成功后继续;否则等待后重试。\ndocker compose --env-file .env.compose exec mysql mysql -uapp_user -p notes_lab -e \"SELECT * FROM volume_probe;\"",
  473. "lang": "bash"
  474. },
  475. {
  476. "title": "生成备份并从容器复制",
  477. "body": "使用同一8.4镜像中的mysqldump,直接写容器内SQL文件后复制,避免Windows重定向改变编码。--single-transaction对InnoDB取得一致性快照,备份期间禁止并发DDL;它不保证非事务表一致性。--no-tablespaces减少所需权限,--set-gtid-purged=OFF适用于本课独立逻辑恢复,不是构建复制节点的流程。包含触发器、存储过程和事件,备份账号需相应权限,本隔离练习使用root。",
  478. "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/notes_lab.sql notes_lab\ndocker compose --env-file .env.compose cp mysql:/tmp/notes_lab.sql ./backups/notes_lab.sql\n# 只有mysqldump退出0才使用该文件;文件存在、大小正常不代表恢复已验证。",
  479. "lang": "bash"
  480. },
  481. {
  482. "title": "创建独立恢复容器",
  483. "body": "新建已忽略的.env.restore,只含MYSQL_ROOT_PASSWORD=独立练习密码。恢复容器使用新的命名卷,不发布端口;mysql -p交互输入恢复密码。等待日志报告ready并能执行SELECT 1,再导入到notes_restore。备份未使用--databases,因此不会自动切回源库名。重复练习时使用新的恢复库与卷,不叠加到上次非空库。 导入使用mysql批处理标准输入,不使用--force;出错即停止。仅在此隔离容器中从已有MYSQL_ROOT_PASSWORD环境变量临时提供认证,不把密码展开到命令参数;生产备份恢复用受控客户端选项文件。",
  484. "code": "docker volume create notes-mysql-restore-data\ndocker run -d --name notes-mysql-restore --env-file .env.restore -e MYSQL_DATABASE=notes_restore -v notes-mysql-restore-data:/var/lib/mysql mysql:8.4 --default-time-zone=+00:00\ndocker logs --tail 30 notes-mysql-restore\ndocker exec -it notes-mysql-restore mysql -uroot -p -e \"SELECT 1;\"\n# 成功后再复制,用批处理标准输入恢复,检查退出码。\ndocker cp ./backups/notes_lab.sql notes-mysql-restore:/tmp/notes_lab.sql\ndocker exec notes-mysql-restore sh -c 'MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\" mysql -uroot --default-character-set=utf8mb4 notes_restore < /tmp/notes_lab.sql'",
  485. "lang": "bash"
  486. },
  487. {
  488. "title": "核对恢复并保留证据",
  489. "body": "本课尚未执行第06课的应用迁移,因此只核对已创建的 volume_probe,不查询尚不存在的 notes 或 alembic_version。恢复标记应为 id=1、value=survives recreate。完成第06课迁移并创建笔记后,再重复备份和恢复流程;使用新的恢复数据库或新的专用恢复卷,避免把第二份备份叠加到非空恢复库,再比较 notes 数量和 alembic_version。此时才完成业务数据恢复验收。当前先停止恢复容器,保留恢复卷用于核查。",
  490. "code": "docker exec -it notes-mysql-restore mysql -uroot -p notes_restore -e \"SELECT * FROM volume_probe; SHOW TABLE STATUS LIKE 'volume_probe';\"\ndocker stop notes-mysql-restore\n# 第06课迁移并创建笔记后,重新备份到新的空恢复库,再核对:\n# SELECT count(*) FROM notes;\n# SELECT version_num FROM alembic_version;",
  491. "lang": "bash"
  492. }
  493. ],
  494. "checks": [
  495. "重建 mysql 容器后 volume_probe 行仍存在。",
  496. "mysqldump与mysql导入均退出0,恢复后的volume_probe内容和InnoDB引擎符合预期。",
  497. "独立恢复库包含正确的 volume_probe,原练习库未被覆盖;业务表恢复在第06课迁移后用新备份补验。"
  498. ],
  499. "pitfalls": [
  500. {
  501. "symptom": "重建容器后数据全没了",
  502. "cause": "未挂载持久卷、挂载路径错误,或切换了 Compose project name。",
  503. "fix": "检查 Mounts 和卷标签,确认实际使用的 mysql_data;不要在未确认旧卷前初始化覆盖或删除。"
  504. },
  505. {
  506. "symptom": "恢复时报 archive 格式错误",
  507. "cause": "用不支持二进制透明传输的 Shell 重定向生成备份。",
  508. "fix": "使用mysqldump --result-file在容器内直接生成SQL文件,再docker cp;必须验证命令退出状态并恢复到独立空库。"
  509. }
  510. ],
  511. "exercise": {
  512. "task": "每天把 MySQLDATA 目录复制到同一宿主另一个文件夹能否替代备份方案?",
  513. "hint": "分别考虑运行中一致性与宿主故障。",
  514. "answer": "不能。普通文件复制可能得到不一致数据库,而且宿主故障会同时损坏原件与副本。使用数据库支持的备份协议,并保留异机副本、加密密钥与定期恢复证据。"
  515. },
  516. "refs": [
  517. {
  518. "label": "MySQL 8.4:mysqldump",
  519. "url": "https://dev.mysql.com/doc/refman/8.4/en/mysqldump.html"
  520. },
  521. {
  522. "label": "MySQL 8.4:SQL格式备份恢复",
  523. "url": "https://dev.mysql.com/doc/refman/8.4/en/reloading-sql-format-dumps.html"
  524. },
  525. {
  526. "label": "Docker volumes",
  527. "url": "https://docs.docker.com/engine/storage/volumes/"
  528. }
  529. ],
  530. "production": {
  531. "scenario": "镜像升级必须保留数据库,宿主损坏后也应有可恢复副本。",
  532. "decisions": [
  533. "容器和数据库卷独立管理。",
  534. "备份在独立环境恢复后才计入可用备份。"
  535. ],
  536. "failureDrill": "重建练习数据库容器,再把备份恢复到另一卷并核对 volume_probe。完成第06课迁移后,另取新备份恢复到新的空恢复库,补验业务行数和迁移版本。",
  537. "acceptance": [
  538. "重建不丢数据。",
  539. "有一次完整备份恢复的实际记录。"
  540. ]
  541. }
  542. },
  543. {
  544. "id": "06",
  545. "title": "健康检查、启动重试与一次性迁移",
  546. "summary": "让 API 等待数据库可用与迁移成功,同时处理运行期间的依赖故障。",
  547. "group": "运行与资源",
  548. "objectives": [
  549. "区分容器运行、健康、应用就绪与迁移完成",
  550. "用 Compose 条件依赖组织首次启动",
  551. "为瞬时断连设计有界重试而不重放未知写事务"
  552. ],
  553. "concepts": [
  554. {
  555. "title": "running 不是 ready",
  556. "body": "容器进程已创建不代表MySQL完成初始化,也不代表业务表已迁移。数据库探针执行认证后的SELECT 1,应用/ready验证自己的数据库连接;迁移作业再核对表结构和revision。"
  557. },
  558. {
  559. "title": "启动顺序只覆盖启动",
  560. "body": "depends_on condition 控制 Compose 启动前置条件,不能保证依赖永远在线。运行时数据库重启仍会引发请求失败,连接池需检测失效连接,调用方需按幂等规则重试。"
  561. },
  562. {
  563. "title": "迁移是独立作业",
  564. "body": "一个 migrate 容器执行 alembic upgrade head,退出码 0 才允许 API 启动。不让多个 API worker 各自执行迁移。迁移失败保留日志与旧应用,不能循环忽略失败继续接流量。"
  565. }
  566. ],
  567. "steps": [
  568. {
  569. "title": "增加数据库和 Redis 健康检查",
  570. "body": "将以下字段分别合并到mysql与redis服务。MySQL探针实际执行SELECT 1,能够检测认证失败;不能用只判断进程存活的ping代替就绪检查。$$延迟到容器Shell展开变量。此处沿用本机练习环境变量,生产应使用受控客户端凭据文件。",
  571. "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",
  572. "lang": "yaml",
  573. "filename": "compose.yaml"
  574. },
  575. {
  576. "title": "新增一次性迁移服务",
  577. "body": "合并到 services 层,镜像与 API 完全相同。练习迁移使用 app_user 的 DDL 权限;生产用单独迁移凭据覆盖此服务的 DATABASE_URL,API 保持更小权限。 MySQL DDL通常隐式提交,迁移失败可能已经完成部分结构变更;先核对实际结构与revision,再修复或补偿,不能直接假定整个迁移事务已回滚。",
  578. "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 mysql:\n condition: service_healthy\n restart: \"no\"",
  579. "lang": "yaml",
  580. "filename": "compose.yaml"
  581. },
  582. {
  583. "title": "为 API 添加就绪探针与条件依赖",
  584. "body": "合并到已有 api 服务。urllib 已在 Python 标准库,无需额外安装 curl。/ready 是前置模块实现的数据库探针;用户自定义应用须先实现同等端点。start_period 不屏蔽真实请求,只影响健康状态统计。",
  585. "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",
  586. "lang": "yaml",
  587. "filename": "compose.yaml"
  588. },
  589. {
  590. "title": "首次启动并验证迁移状态",
  591. "body": "up -d --wait 等待服务达到健康状态,但它不是业务验收;随后创建一条笔记并读取。migrate 是正常退出的作业,其 Exited (0) 不算服务故障。",
  592. "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 notes_lab -e \"SELECT version_num FROM alembic_version;\"",
  593. "lang": "bash"
  594. },
  595. {
  596. "title": "演练运行时数据库短暂停止",
  597. "body": "仅在专用本机栈停止 mysql,此时 /live 应仍为 200、/ready 为 503;重启数据库后连接池 pre_ping 应逐步恢复。Docker 标记 unhealthy 本身不会自动重启容器,restart 策略主要响应进程退出。写入失败不要盲目自动重放,用幂等键确认结果。",
  598. "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",
  599. "lang": "bash"
  600. }
  601. ],
  602. "checks": [
  603. "首次启动迁移先成功退出,API 随后 ready。",
  604. "迁移失败时 API 不按成功依赖启动,日志保留真实异常。",
  605. "数据库重启期间 readiness 失败,恢复后可创建读取数据,无隐式无限重试。"
  606. ],
  607. "pitfalls": [
  608. {
  609. "symptom": "depends_on 后仍出现连接拒绝",
  610. "cause": "只写了服务名依赖,没等待健康;或故障发生在启动之后。",
  611. "fix": "首次启动使用 health 条件,运行期间通过连接恢复、超时和调用方幂等重试处理。"
  612. },
  613. {
  614. "symptom": "容器 unhealthy 却没有自动重启",
  615. "cause": "把健康状态与 restart 策略混为一谈。",
  616. "fix": "明确由部署平台或外部监控处理健康流量与重启;不要让所有实例因共享数据库故障一起重启。"
  617. }
  618. ],
  619. "exercise": {
  620. "task": "能否把 alembic upgrade head 放进 API CMD 前面,每个副本都执行?",
  621. "hint": "扩容时有多少个进程会同时触发 DDL?",
  622. "answer": "不应。使用单独迁移步骤并保证唯一执行;多个进程同时迁移会竞争锁与版本状态。迁移成功是发布门槛,不是每个请求实例的初始化副作用。"
  623. },
  624. "refs": [
  625. {
  626. "label": "Docker:启动顺序",
  627. "url": "https://docs.docker.com/compose/how-tos/startup-order/"
  628. },
  629. {
  630. "label": "Docker:健康检查",
  631. "url": "https://docs.docker.com/reference/dockerfile/#healthcheck"
  632. },
  633. {
  634. "label": "MySQL 8.4:隐式提交",
  635. "url": "https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html"
  636. }
  637. ],
  638. "production": {
  639. "scenario": "整套服务在新机器首次启动,以及运行期间数据库维护重启。",
  640. "decisions": [
  641. "依赖健康、迁移完成和业务就绪分别验证。",
  642. "运行时重试有限,写请求按幂等协议恢复。"
  643. ],
  644. "failureDrill": "停止练习 mysql,观察 /live 与 /ready 分离,恢复后验证原数据和新写入。",
  645. "acceptance": [
  646. "共享数据库故障不会触发无意义的无限重启。",
  647. "启动失败能够定位到依赖、迁移或应用阶段。"
  648. ]
  649. }
  650. },
  651. {
  652. "id": "07",
  653. "title": "CPU、内存、进程数与优雅退出",
  654. "summary": "给容器设置资源边界,观察 OOM 与 SIGTERM,确保在途请求和任务有明确退出行为。",
  655. "group": "运行与资源",
  656. "objectives": [
  657. "设置并验证容器 CPU、内存与 PID 上限",
  658. "识别 OOMKilled 和普通应用退出",
  659. "协调停止宽限期、HTTP 排空与任务重投"
  660. ],
  661. "concepts": [
  662. {
  663. "title": "限制是边界而非性能保证",
  664. "body": "cpus 限制可用 CPU 时间,mem_limit 限制内存,pids_limit 限制线程与进程数量。线程也可能计入 PID 额度。限制过低会导致正常峰值失败,过高会让单服务挤压数据库与宿主;起始值需要用实际负载校准。"
  665. },
  666. {
  667. "title": "SIGTERM 与 SIGKILL",
  668. "body": "docker stop 通常先发 SIGTERM,等待 stop_grace_period 后再 SIGKILL。SIGTERM 允许应用停止接新请求并完成在途工作;SIGKILL 无法执行 finally。Uvicorn 的优雅关闭预算必须小于容器宽限期。"
  669. },
  670. {
  671. "title": "退出与任务一致性",
  672. "body": "HTTP 排空和队列任务退出是两套行为。Celery worker 收到 TERM 可进行 warm shutdown,但容器最终可能强杀长任务。任务必须幂等,确认、可见性超时和恢复策略与异步任务模块一致,不能把 SIGTERM 当作任务绝不丢失的保证。"
  673. }
  674. ],
  675. "steps": [
  676. {
  677. "title": "为 API 配置资源上限",
  678. "body": "将这些字段合并到 api 服务。示例 1 CPU/512MB/128 PID 是练习起点,不是生产容量结论;Dockerfile Uvicorn graceful timeout 已为 25 秒,容器给予 35 秒。",
  679. "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\"",
  680. "lang": "yaml",
  681. "filename": "compose.yaml"
  682. },
  683. {
  684. "title": "重建并核对运行限制",
  685. "body": "应用配置变化需 up 重建容器,不是只 restart。stats 观察实际内存和 CPU;inspect 以 API 容器 id 读取 HostConfig,不把完整环境配置输出到公共日志。",
  686. "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}}'",
  687. "lang": "bash"
  688. },
  689. {
  690. "title": "在一次性专用容器触发 OOM",
  691. "body": "此演练不使用 API 容器或数据库卷;仅分配大于 64MB 上限的内存。不要加 --rm,便于查看退出状态,检查后只删除 notes-oom-drill。",
  692. "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",
  693. "lang": "bash"
  694. },
  695. {
  696. "title": "验证 API 接收停止信号",
  697. "body": "在另一个终端持续请求 /ready,再 stop api,观察日志中应用开始关闭与 shutdown complete。正常短请求应完成,关闭后客户端连接失败属于流量需要移走的阶段。单容器 Compose 没有自动负载均衡排空能力。",
  698. "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",
  699. "lang": "bash"
  700. },
  701. {
  702. "title": "记录资源与退出验收",
  703. "body": "创建 resource-budget.md,记录实例/worker 数、连接池乘数、CPU/内存峰值及关闭耗时。任务 worker 的并发进程数和每个进程连接池也算入数据库配额。",
  704. "code": "API:1 进程/容器;数据库池 5;内存上限 512MiB;关闭预算 25s < 容器35s\nWorker:按第9课单独配置并发;连接数按实际执行模型计算\n宿主:为 MySQL、Redis、Docker 和系统留余量\n证据:实际 stats 峰值、OOM 状态、SIGTERM 关闭耗时",
  705. "lang": "text",
  706. "filename": "resource-budget.md"
  707. }
  708. ],
  709. "checks": [
  710. "运行容器实际 HostConfig 包含预期内存、CPU、PID 限制。",
  711. "一次性 OOM 容器能够通过 OOMKilled 区分资源杀死与普通异常。",
  712. "API 在宽限期内完成关闭,没有依赖 shell 后台进程转发信号。"
  713. ],
  714. "pitfalls": [
  715. {
  716. "symptom": "退出码 137 就断言一定 OOM",
  717. "cause": "137 也可能由其他 SIGKILL 来源导致。",
  718. "fix": "同时检查 OOMKilled、宿主内核日志、容器事件与部署停止超时。"
  719. },
  720. {
  721. "symptom": "容器停止后任务重复执行",
  722. "cause": "任务在完成业务后、确认前被强杀,队列再次投递。",
  723. "fix": "按任务幂等与业务唯一约束处理重投,调整任务时限和退出预算;不要为了避免重复提前确认并忽略丢失风险。"
  724. }
  725. ],
  726. "exercise": {
  727. "task": "每容器 4 个 API worker 是否一定比 1 个更快?",
  728. "hint": "同时计算 CPU、内存与数据库连接。",
  729. "answer": "不一定。4 个进程增加内存和连接池总数,受 1 CPU 限制时可能只增加调度开销。用实际等待与计算比例评估;容器水平扩容和 worker 数必须一起预算。"
  730. },
  731. "refs": [
  732. {
  733. "label": "Docker:资源限制",
  734. "url": "https://docs.docker.com/engine/containers/resource_constraints/"
  735. },
  736. {
  737. "label": "Docker:Compose 服务资源字段",
  738. "url": "https://docs.docker.com/reference/compose-file/services/"
  739. },
  740. {
  741. "label": "Docker:stop",
  742. "url": "https://docs.docker.com/reference/cli/docker/container/stop/"
  743. },
  744. {
  745. "label": "Uvicorn:设置",
  746. "url": "https://uvicorn.dev/settings/"
  747. }
  748. ],
  749. "production": {
  750. "scenario": "单个大请求或长任务不能耗尽宿主全部资源,部署停止也不能无限等待。",
  751. "decisions": [
  752. "资源值先设上限再压测校准。",
  753. "容器停止预算大于应用优雅退出预算。"
  754. ],
  755. "failureDrill": "使用无挂载专用容器触发 OOM,再对练习 API 执行 SIGTERM,分别记录原因与恢复。",
  756. "acceptance": [
  757. "OOM 不影响数据库卷。",
  758. "关闭和任务恢复行为有明确记录。"
  759. ]
  760. }
  761. },
  762. {
  763. "id": "08",
  764. "title": "配置、Secrets 与容器运行权限",
  765. "summary": "将连接秘密从 Compose 环境输出中移走,收紧应用可写目录与 Linux capabilities,并建立依赖扫描流程。",
  766. "group": "安全与交付",
  767. "objectives": [
  768. "通过文件向应用注入秘密并验证权限",
  769. "限制运行容器的写入与提权能力",
  770. "读取扫描结果并形成可验证的修复步骤"
  771. ],
  772. "concepts": [
  773. {
  774. "title": "环境变量与秘密文件",
  775. "body": "环境变量方便但常出现在 inspect、诊断和进程信息中。Compose secrets 将文件挂载到 /run/secrets,应用必须主动读取;环境变量 NAME_FILE 不是通用自动功能,只有支持它的程序会处理。"
  776. },
  777. {
  778. "title": "本地 Compose 的边界",
  779. "body": "本地 secrets 通常来自宿主文件挂载,不等同 Swarm 的加密分发。宿主源文件和备份仍需权限控制,拥有 Docker daemon 管理权限的人可访问容器与挂载。应用不用 Docker socket,不把日常用户随意加入 docker 组。"
  780. },
  781. {
  782. "title": "扫描与修复",
  783. "body": "镜像扫描报告依赖和系统包漏洞,是定位输入,不是应用已安全的证明。确认运行路径与受影响版本,更新基础 digest/锁文件、重建并回归。不要在运行容器里删除库来“清零”扫描数字。"
  784. }
  785. ],
  786. "steps": [
  787. {
  788. "title": "让配置支持文件秘密",
  789. "body": "替换 app/config.py 中 Settings 的 database_url 声明并扩展 model_config,下面是该部分的完整写法;保留此前连接池字段。secret 目录中的 database_url 文件提供值,环境变量同名值优先,因此从 api 服务移除旧 DATABASE_URL environment 项。",
  790. "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()",
  791. "lang": "python",
  792. "filename": "app/config.py"
  793. },
  794. {
  795. "title": "建立本机秘密文件",
  796. "body": "在已忽略的 secrets 目录用编辑器新建 database_url,填写 mysql:3306 的容器连接串。以下为 Linux / WSL 的本地 file-backed secrets 方案:父目录由当前可信宿主账户持有且权限700,单个绑定文件644,使容器UID10001可读。其他普通宿主账户无法穿过700父目录;有Docker控制权限的账户仍属于受信任管理员。不能仅写uid/gid/mode并假设Compose会重映射文件权限。",
  797. "code": "mkdir -p secrets\n# 用编辑器创建 secrets/database_url,填实际练习连接串。\nchmod 700 secrets\nchmod 644 secrets/database_url\n# 此处只调整明确的单个绑定文件;不把父目录改成公开可进入。",
  798. "lang": "bash"
  799. },
  800. {
  801. "title": "挂载秘密并检查实际权限",
  802. "body": "在 compose.yaml 顶层新增 secrets;在 api/migrate 的服务中挂载 secrets: [database_url] 并移除 DATABASE_URL 环境值。file-backed secrets实际采用绑定文件权限,不能依赖Compose的uid/gid/mode映射。后续任务URL和broker配置也放在同一受控目录,逐一授予容器读取,并在启动正式进程前用临时容器验证。",
  803. "code": "# compose.yaml 顶层\nsecrets:\n database_url:\n file: ./secrets/database_url\n\n# api 与 migrate 各自服务内添加\n# secrets:\n# - database_url",
  804. "lang": "yaml",
  805. "filename": "compose.yaml"
  806. },
  807. {
  808. "title": "收紧 API 运行权限",
  809. "body": "合并到 api 服务并重建镜像以包含新的配置模块。根文件系统只读,临时目录使用有界 tmpfs;app 不写本地持久文件。Celery 需要的调度状态在第 9 课单独处理。",
  810. "code": "read_only: true\ntmpfs:\n - /tmp:size=64m,mode=1777\ncap_drop: [ALL]\nsecurity_opt:\n - no-new-privileges:true",
  811. "lang": "yaml",
  812. "filename": "compose.yaml"
  813. },
  814. {
  815. "title": "验证只读、秘密和依赖扫描",
  816. "body": "先重建镜像,随后用 command 覆盖应用启动命令,只读取秘密文件并输出布尔值。该检查使用镜像默认非root UID10001;成功后才启动API,避免先因Settings读取失败退出再尝试exec。只读写入检查应报Read-only file system,是本步骤预期结果。Docker Scout若已安装授权可执行cves,否则记录未执行原因,不声称扫描通过。",
  817. "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",
  818. "lang": "bash"
  819. }
  820. ],
  821. "checks": [
  822. "API 配置可读取挂载秘密,检查时未打印连接串。",
  823. "/app 写入失败,/tmp 可以写入且有容量上限。",
  824. "扫描结果或未执行原因有记录,修复通过基础镜像/锁文件重建完成。"
  825. ],
  826. "pitfalls": [
  827. {
  828. "symptom": "加了 DATABASE_URL_FILE 但应用仍缺配置",
  829. "cause": "应用没有实现读取该变量指向文件的逻辑。",
  830. "fix": "采用本课 Pydantic secrets_dir 文件名 database_url,或显式实现并测试 _FILE 读取。"
  831. },
  832. {
  833. "symptom": "只读 rootfs 后应用启动失败",
  834. "cause": "程序尝试写字节码、缓存、PID 或调度文件。",
  835. "fix": "禁用字节码并为必要临时写入配置 tmpfs;持久调度状态使用单独卷,不把整个根文件系统改回可写。"
  836. }
  837. ],
  838. "exercise": {
  839. "task": "把 .env 复制进镜像后下一层删除,是否已清除秘密?",
  840. "hint": "镜像保留每层的文件变更。",
  841. "answer": "没有。旧层仍可能恢复内容。轮换已泄露秘密,重新构建不含该层的新镜像,检查镜像仓库与构建日志中的副本;正常构建使用运行时秘密或 BuildKit secret mount。"
  842. },
  843. "refs": [
  844. {
  845. "label": "Docker:Compose Secrets",
  846. "url": "https://docs.docker.com/compose/how-tos/use-secrets/"
  847. },
  848. {
  849. "label": "Docker:构建秘密",
  850. "url": "https://docs.docker.com/build/building/secrets/"
  851. },
  852. {
  853. "label": "Pydantic Settings:Secrets",
  854. "url": "https://docs.pydantic.dev/latest/concepts/pydantic_settings/#secrets"
  855. },
  856. {
  857. "label": "Docker Scout:CVE 分析",
  858. "url": "https://docs.docker.com/scout/quickstart/"
  859. }
  860. ],
  861. "production": {
  862. "scenario": "诊断日志和镜像交付不应携带数据库密码,应用应只有必需的文件写入权限。",
  863. "decisions": [
  864. "秘密只在运行时挂载,宿主源文件访问受控。",
  865. "非 root、只读根文件系统和去除 capabilities 一起验证。"
  866. ],
  867. "failureDrill": "在本机尝试向 /app 写入,预期拒绝;仍能读取秘密并执行 /ready。",
  868. "acceptance": [
  869. "权限收紧不破坏正常 API。",
  870. "秘密没有进入镜像层与共享日志。"
  871. ]
  872. }
  873. },
  874. {
  875. "id": "09",
  876. "title": "API、Worker 与队列的 Compose 整合",
  877. "summary": "使用同一镜像运行不同进程角色,把任务执行、调度和 HTTP 服务分开部署。",
  878. "group": "安全与交付",
  879. "objectives": [
  880. "部署异步任务模块真实 tasks_lab 工程与 exports.fast/bulk 队列",
  881. "为 API、worker、publisher、recovery 分别配置进程与资源",
  882. "隔离缓存 Redis 与带认证的任务 broker,并验证持久业务状态"
  883. ],
  884. "concepts": [
  885. {
  886. "title": "同镜像不同职责",
  887. "body": "API 接收请求并提交任务,worker 消费任务,beat 周期调度;它们可以来自同一代码镜像,但生命周期、并发与资源不同。不把 worker 后台进程塞进 API 容器,也不在每个 API 副本内启动周期调度器。"
  888. },
  889. {
  890. "title": "任务边界",
  891. "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 下载包已有这些文件。"
  892. },
  893. {
  894. "title": "Redis 隔离",
  895. "body": "本地 Redis /0 与 /1 可以区分 key 空间,不能隔离内存、CPU、故障与驱逐策略。生产缓存与消息代理通常使用独立实例及策略;队列持久化和 ack 机制也不能单独构成端到端任务不丢失保证。"
  896. }
  897. ],
  898. "steps": [
  899. {
  900. "title": "复制完整最终任务工程并统一依赖",
  901. "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 替代原任务。",
  902. "code": "COPY tasks_lab ./tasks_lab\nCOPY deploy/run_role.py ./deploy/run_role.py",
  903. "lang": "dockerfile",
  904. "filename": "Dockerfile"
  905. },
  906. {
  907. "title": "为任务角色建立秘密加载入口",
  908. "body": "新建deploy/run_role.py。API的SQLAlchemy异步URL使用mysql+asyncmy://;任务的同步PyMySQL适配器接受mysql+pymysql://,均指向mysql:3306/notes_lab。入口从三个只读秘密文件读取环境变量后exec目标进程,不打印值。同步连接的解析、UTC与事务设置继续复用任务模块state_store.py。",
  909. "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:])",
  910. "lang": "python",
  911. "filename": "deploy/run_role.py"
  912. },
  913. {
  914. "title": "配置独立认证 broker 与秘密文件",
  915. "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/notes_lab。三个URL文件由task服务读取;broker配置只读挂载,不提交到仓库。 本地file-backed secrets按第8课使用宿主受控父目录700、单秘密文件644,使UID10001可读取实际挂载;启动前用run --no-deps检查可读性且不打印内容。TASK_BROKER_PASSWORD建议本机生成高熵十六进制值,使Redis配置requirepass行无需额外引号转义。",
  916. "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}",
  917. "lang": "yaml",
  918. "filename": "compose.yaml"
  919. },
  920. {
  921. "title": "添加真实 fast Worker 与 bulk Worker",
  922. "body": "新增 worker 服务,worker-bulk 使用相同字段,仅 command 的队列改 exports.bulk、节点名改 bulk@%h。任务模块使用相对 Path(\"artifacts\"),工作目录下挂载 /app/tasks_lab/artifacts 命名卷并初始化给UID10001;同机共享输出供恢复/下载使用,跨宿主生产改对象存储。API仅提供笔记接口时不伪称已实现任务提交HTTP端点,先用任务模块 submit/publisher 流程验收。",
  923. "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: {}",
  924. "lang": "yaml",
  925. "filename": "compose.yaml"
  926. },
  927. {
  928. "title": "包装有界 publisher 与 recovery 循环",
  929. "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。",
  930. "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)",
  931. "lang": "python",
  932. "filename": "tasks_lab/run_loops.py"
  933. },
  934. {
  935. "title": "初始化任务表、输出权限并构建",
  936. "body": "将任务课程的 export_jobs/export_outbox/死信/容量/周期等最终DDL纳入本项目Alembic迁移,先在空练习库验证;不要认为notes迁移自动创建这些任务表。挂载卷首次创建为root时,使用仅针对artifacts_data挂载点的一次性初始化作业 chown 10001:10001 /app/tasks_lab/artifacts,并在worker启动前完成;不可递归修改宿主未知目录。构建新标签,更新 .env.compose NOTES_IMAGE,再启动并核对注册任务和队列。",
  937. "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",
  938. "lang": "bash"
  939. },
  940. {
  941. "title": "按真实业务链路验收与故障恢复",
  942. "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课周期任务完成后单实例部署。",
  943. "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 notes ORDER BY id LIMIT 1\")\n row = cur.fetchone()\nassert row, \"create a note 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 notes_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",
  944. "lang": "bash"
  945. }
  946. ],
  947. "checks": [
  948. "Celery inspect 注册 notes.export,fast/bulk worker 只消费对应队列。",
  949. "MySQL中job/outbox到最终状态与可读取的输出文件一致,不能只凭Celery结果缓存。",
  950. "缓存redis与认证broker是不同实例;宿主任务端口6384,容器内部broker:6379。"
  951. ],
  952. "pitfalls": [
  953. {
  954. "symptom": "扩容 API 后周期任务重复发送",
  955. "cause": "每个 API 容器都启动了 beat 或本地定时器。",
  956. "fix": "beat 单独部署单实例或使用有领导选举的调度方案,任务仍需幂等。"
  957. },
  958. {
  959. "symptom": "worker 配了更多并发,数据库连接数翻倍",
  960. "cause": "每个执行进程建立自己的池,遗漏 fork 与连接生命周期。",
  961. "fix": "按进程模型重新预算,在子进程创建/重置连接资源;不继承已建立连接。"
  962. }
  963. ],
  964. "exercise": {
  965. "task": "为什么API和同步任务使用不同的MySQL连接URL?",
  966. "hint": "区分SQLAlchemy方言URL与MySQL驱动DSN。",
  967. "answer": "SQLAlchemy的mysql+asyncmy://选择异步驱动;任务state_store适配器解析mysql+pymysql://并调用同步PyMySQL。两者可以使用相同库和账号,但驱动入口及调用方式不同,不能把异步SQLAlchemy URL直接交给同步驱动。"
  968. },
  969. "refs": [
  970. {
  971. "label": "Celery:Worker 管理",
  972. "url": "https://docs.celeryq.dev/en/stable/userguide/workers.html"
  973. },
  974. {
  975. "label": "Celery:Redis 注意事项",
  976. "url": "https://docs.celeryq.dev/en/stable/getting-started/backends-and-brokers/redis.html"
  977. },
  978. {
  979. "label": "Redis:持久化",
  980. "url": "https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/"
  981. }
  982. ],
  983. "production": {
  984. "scenario": "API 快速返回任务已提交,worker 可以独立扩缩容和更新。",
  985. "decisions": [
  986. "任务执行资源与 API 分配分开。",
  987. "队列状态重要时建立持久业务记录与可恢复投递流程。"
  988. ],
  989. "failureDrill": "在独立练习栈暂停broker后提交持久job,核对job/outbox保留;恢复broker和publisher后worker完成且同一业务结果不被重复执行覆盖。",
  990. "acceptance": [
  991. "真实tasks_lab入口、队列、环境变量和文件路径一致。",
  992. "publisher/recovery有独立受控循环,业务状态与输出经数据库核对。"
  993. ]
  994. }
  995. },
  996. {
  997. "id": "10",
  998. "title": "镜像版本、发布回滚与故障验收",
  999. "summary": "用不可混淆的版本标签与镜像摘要发布,保留数据库兼容窗口并验证回退行为。",
  1000. "group": "安全与交付",
  1001. "objectives": [
  1002. "记录并部署明确的镜像版本或摘要",
  1003. "执行迁移在前、流量切换在后的发布流程",
  1004. "区分应用回滚、数据库回滚与数据恢复"
  1005. ],
  1006. "concepts": [
  1007. {
  1008. "title": "发布的是已验证镜像",
  1009. "body": "本机 image ID 与仓库 digest 是不同概念;推送后以仓库名@sha256 选择不可变发布物。标签可便于阅读但可能被覆盖,不能只保存 latest。预发布和生产使用同一已验证摘要,避免在每台机器重建出不同依赖。"
  1010. },
  1011. {
  1012. "title": "Compose 的可用性边界",
  1013. "body": "单机单副本 Compose 更新通常会有短暂中断,不承诺零停机。需要平滑切换时部署两个版本后由代理切换,或者使用具备滚动与就绪能力的平台;数据库迁移仍须兼容新旧版本。"
  1014. },
  1015. {
  1016. "title": "回滚不等于删数据",
  1017. "body": "旧镜像能否回退取决于当前数据库结构是否兼容。新增列通常可保留供旧版忽略,删除/重命名/数据语义改变可能阻止回退。不能为了退应用版本就清空数据库卷或机械执行 downgrade。"
  1018. }
  1019. ],
  1020. "steps": [
  1021. {
  1022. "title": "记录当前可回退版本",
  1023. "body": "发布前保存当前 NOTES_IMAGE、迁移版本、配置版本和必要备份。只记录秘密文件的版本标识或来源,不把内容复制进 release-record.md。",
  1024. "code": "版本:notes-service:release-2026-09-10-01 或实际仓库 digest\n源码提交:实际 SHA\n当前迁移 revision:实际 alembic current\n目标 revision:待发布版本所需 revision\n兼容范围:旧应用可读取/写入的新结构范围\n备份与恢复验证:文件标识、时间、恢复证据\n回退触发:例如连续5分钟错误率超目标;须先定义观测窗口",
  1025. "lang": "text",
  1026. "filename": "release-record.md"
  1027. },
  1028. {
  1029. "title": "构建、标记并本机验收",
  1030. "body": "构建使用第 3 课已验证基础 digest。用户拥有并授权的镜像仓库才可执行登录与推送;课件提供命令模板,不实际向外发布。标签中使用真实版本或 commit,不重复覆盖旧发布标签。 以下命令在Bash/WSL运行,PYTHON_IMAGE必须是第03课保存的完整摘要;开启新终端时先恢复已审核的变量,不能静默回退到浮动标签。",
  1031. "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, asyncmy, pymysql; 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",
  1032. "lang": "bash"
  1033. },
  1034. {
  1035. "title": "先运行唯一迁移作业",
  1036. "body": "更新 .env.compose 的 NOTES_IMAGE 为目标版本,先在预发布演练下面命令。发布时 run --rm migrate 执行当前目标镜像迁移;API 仍可维持旧版,前提是迁移经过 expand-contract 兼容审核。迁移失败即停止后续发布。",
  1037. "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",
  1038. "lang": "bash"
  1039. },
  1040. {
  1041. "title": "验证业务再决定继续或回退",
  1042. "body": "检查 /ready、创建读取、旧版本冲突、幂等重试和 真实导出任务;记录实际 HTTP 与数据库结果。失败时先将 NOTES_IMAGE 恢复上一兼容版本,再 up --no-deps api worker,不回退 Redis/MySQL 镜像主版本,也不自动操作数据库迁移。",
  1043. "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)\"",
  1044. "lang": "bash"
  1045. },
  1046. {
  1047. "title": "完成隔离故障矩阵",
  1048. "body": "在本机专用栈逐项执行,保存预期与实测。数据库数据使用迁移和备份处理,不以删卷作为回滚。超出本机实测范围的远程仓库、TLS、负载均衡明确标记未验证。",
  1049. "code": "场景 | 观测 | 恢复动作\n错误数据库密码 | readiness失败,认证日志 | 修正秘密并重建API;不删卷\n错误应用镜像 | API启动失败,旧版可用性记录 | 切回兼容镜像\nworker停止 | 队列积压,API仍可提交 | 启动worker,核对任务去重\nOOM演练 | 专用容器OOMKilled | 调整预算并检查宿主余量\n迁移失败 | migrate退出非0 | 停止发布,修复迁移或锁阻塞\n数据库容器重建 | 卷保留,原笔记可读 | 等待ready并执行业务探针",
  1050. "lang": "text"
  1051. }
  1052. ],
  1053. "checks": [
  1054. "发布记录包含真实镜像标识、迁移版本和回退边界。",
  1055. "迁移失败不会继续切换 API 与 worker。",
  1056. "回退应用镜像后业务探针通过,数据库卷和已提交数据保留。"
  1057. ],
  1058. "pitfalls": [
  1059. {
  1060. "symptom": "latest 回退后仍运行同一坏版本",
  1061. "cause": "标签被覆盖,无法指明旧镜像内容。",
  1062. "fix": "保存不可变版本标签与仓库摘要,部署时以明确摘要选择。"
  1063. },
  1064. {
  1065. "symptom": "应用回退成功但读数据失败",
  1066. "cause": "新迁移删除或改变了旧应用依赖的字段。",
  1067. "fix": "发布前验证兼容窗口;不可兼容时执行修复版本或已验证恢复方案,不机械 downgrade。"
  1068. }
  1069. ],
  1070. "exercise": {
  1071. "task": "怎样证明“回滚方案可用”,而非只写一句“退回旧版本”?",
  1072. "hint": "在同一新数据库结构上运行旧镜像并发请求。",
  1073. "answer": "在隔离预发布库先执行新迁移,再部署旧镜像执行读写与幂等探针,记录结果和不可兼容点。若旧版写入破坏新语义,回退窗口应关闭,改用前向修复。"
  1074. },
  1075. "refs": [
  1076. {
  1077. "label": "Docker:镜像标签",
  1078. "url": "https://docs.docker.com/reference/cli/docker/image/tag/"
  1079. },
  1080. {
  1081. "label": "Docker:镜像摘要",
  1082. "url": "https://docs.docker.com/dhi/core-concepts/digests/"
  1083. },
  1084. {
  1085. "label": "Docker:Compose up",
  1086. "url": "https://docs.docker.com/reference/cli/docker/compose/up/"
  1087. }
  1088. ],
  1089. "production": {
  1090. "scenario": "一次发布必须能够定位软件版本,并在已声明兼容范围内回退。",
  1091. "decisions": [
  1092. "构建一次并以明确版本交付。",
  1093. "应用回退与数据库恢复分开决策,保持数据卷。",
  1094. "MySQL的原子DDL保证单条受支持DDL的原子性,不把多条Alembic操作变成可整体回滚事务;发布门槛同时核对实际schema与revision。"
  1095. ],
  1096. "failureDrill": "在本机发布一个故意缺配置的应用版本,确认错误后回到上一镜像并核对既有数据。",
  1097. "acceptance": [
  1098. "有实际回退耗时和业务恢复结果。",
  1099. "未验证的仓库、远程部署和高可用能力明确标记。"
  1100. ]
  1101. }
  1102. }
  1103. ]