| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254 |
- [
- {
- "id": "01",
- "group": "主机与进程",
- "title": "SSH、账户与服务目录",
- "summary": "用新的密钥会话安全登录 deploy,并验证运行账号只能写指定结果目录。",
- "objectives": [
- "核对目标机器和主机指纹",
- "建立管理账号与无交互登录的运行账号",
- "保留原会话验证新登录与目录权限"
- ],
- "concepts": [
- {
- "title": "练习环境必须明确",
- "body": "结合“确认系统与目标”的实际检查回顾。本模块只在自己控制、可从控制台恢复的独立 Ubuntu 24.04 VM 执行。示例主机 shop-vm、IP 与域名均需替换成真实练习目标;不在已有生产主机套用整段账户、防火墙和服务配置。先保存VM快照并确认带外控制台可用。"
- },
- {
- "title": "两个账号的职责",
- "body": "结合“在虚拟机建立管理和运行账户”的实际检查回顾。deploy 用 SSH 登录并执行经过核对的 sudo 操作,shop 只运行应用,shell 为 nologin,不保存 SSH 私钥。应用被利用后不应具备替换代码、修改 systemd 单元或读取其他服务秘密的权限。"
- },
- {
- "title": "目录所有权",
- "body": "结合“建立明确目录边界”的实际检查回顾。/srv/shop-service/releases 保存不可由运行用户修改的版本;current 指向当前版本;shared 保存需要跨版本保留的导出文件。代码、配置和业务输出分开授权,不能对整个 /srv 递归 chmod 777。"
- }
- ],
- "steps": [
- {
- "title": "确认系统与目标",
- "body": "通过虚拟机控制台或已验证的管理 SSH 会话执行。记录 hostname、Ubuntu 版本和 SSH 主机公钥指纹,再从本机连接时对照。不要用 StrictHostKeyChecking=no 隐藏主机变化。",
- "code": "hostnamectl\ncat /etc/os-release\nsudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub\nwhoami",
- "lang": "bash",
- "expected": "hostname、Ubuntu 版本、当前账号与预期 VM 一致,并记录到服务器的 ED25519 主机公钥指纹。",
- "explanation": "主机指纹用于在后续 SSH 连接时识别远端身份;登录成功本身不能证明连到了想操作的那台机器。"
- },
- {
- "title": "在本机生成独立登录密钥",
- "body": "在自己的电脑执行,并使用口令保护私钥。路径为本次练习新文件,若已存在不要覆盖。将 .pub 公钥交给VM管理员安装,私钥不复制到服务器。",
- "code": "ssh-keygen -t ed25519 -f ~/.ssh/shop_lab_ed25519 -C shop-lab\n# 核对指纹后连接;VM_IP替换成实际练习地址\n# 完成下一步deploy账户与authorized_keys配置后,才执行:\n# ssh -i ~/.ssh/shop_lab_ed25519 deploy@VM_IP",
- "lang": "bash",
- "expected": "本机出现新的 shop_lab_ed25519 私钥与 .pub 公钥,已有同名文件时工具会提示而应停止覆盖。",
- "explanation": "客户端私钥证明登录者身份,服务器只需要公钥。它与第一步的服务器主机密钥解决的是两个不同方向的身份核对。"
- },
- {
- "title": "在虚拟机建立管理和运行账户",
- "body": "使用现有管理账号执行,adduser deploy 会交互设置管理账号。用编辑器将刚生成的公钥内容写入 /home/deploy/.ssh/authorized_keys,再设置文件权限;不要粘贴私钥。shop 无交互登录权限。",
- "code": "sudo adduser deploy\nsudo usermod -aG sudo deploy\nsudo install -d -o deploy -g deploy -m 700 /home/deploy/.ssh\nsudoedit /home/deploy/.ssh/authorized_keys\nsudo chown deploy:deploy /home/deploy/.ssh/authorized_keys\nsudo chmod 600 /home/deploy/.ssh/authorized_keys\nsudo useradd --system --user-group --home-dir /srv/shop-service --shell /usr/sbin/nologin shop",
- "lang": "bash",
- "expected": "deploy 拥有自己的 authorized_keys 且权限为 600;shop 账号无交互登录,两个账号用途可分别指出。",
- "explanation": "deploy 用于管理发布,shop 只运行程序。把可登录管理员与服务身份分开,可以限制程序出错后的权限范围。"
- },
- {
- "title": "建立明确目录边界",
- "body": "依次建立下面的发布与共享目录,随后用 ls -ld 核对所有者与权限。只按列出的目标赋权,不把项目下全部文件设为可执行;后续源码放入 releases,导出结果放入 shared/artifacts。",
- "code": "sudo install -d -o root -g shop -m 750 /srv/shop-service\nsudo install -d -o root -g shop -m 750 /srv/shop-service/releases\nsudo install -d -o root -g shop -m 750 /srv/shop-service/shared\nsudo install -d -o shop -g shop -m 750 /srv/shop-service/shared/artifacts\nsudo -u shop test -w /srv/shop-service/shared/artifacts\nsudo -u shop test ! -w /srv/shop-service/releases",
- "lang": "bash",
- "expected": "目录所有者与模式符合清单:releases 由管理员写,shared/artifacts 由 shop 写。",
- "explanation": "程序可以读取版本目录却不能改写自身发布文件;只有导出结果目录开放写权限,避免把整个项目设为可写。"
- },
- {
- "title": "验证第二个会话再收紧 SSH",
- "body": "保留原管理会话和控制台,从本机新开 deploy 密钥会话并运行 sudo -v。成功后才考虑在 /etc/ssh/sshd_config.d/60-shop-lab.conf 写入下面配置。先 sshd -t,再 reload;已有配置和云初始化可能覆盖行为,sshd -T 检查实际值。 第二个会话使用 ssh -i ~/.ssh/shop_lab_ed25519 deploy@VM_IP;账户和公钥在前一步已配置好。",
- "code": "# /etc/ssh/sshd_config.d/60-shop-lab.conf\nPubkeyAuthentication yes\nPasswordAuthentication no\nPermitRootLogin no\n\n# 保存后在原管理会话执行\n# sudo sshd -t\n# sudo systemctl reload ssh\n# sudo sshd -T | rg 'passwordauthentication|permitrootlogin'",
- "lang": "text",
- "filename": "/etc/ssh/sshd_config.d/60-shop-lab.conf",
- "expected": "第二个 deploy 密钥会话能执行 sudo -v;sshd -t 无报错,sshd -T 的实际值符合预期。",
- "explanation": "先证明新登录路径可用,再禁用旧方式,防止把唯一入口锁死。配置片段存在不代表最终生效值相同,需看实际展开结果。"
- }
- ],
- "checks": [
- "从新终端能用独立密钥登录 deploy 并执行 sudo -v。",
- "products 不能交互登录,不能修改 releases,但可写 shared/exports。",
- "SSH 配置先校验再 reload,保留原会话和控制台恢复路径。"
- ],
- "pitfalls": [
- {
- "symptom": "修改 SSH 后新连接失败",
- "cause": "公钥权限错误、配置顺序覆盖或改动未经新会话验证。",
- "fix": "使用保留会话或控制台恢复该练习配置,检查 auth 日志与 sshd -T,再用新终端验证。"
- },
- {
- "symptom": "服务账号能改 Dockerfile 或 systemd 单元",
- "cause": "代码和系统配置错误归运行用户所有。",
- "fix": "将发布物与单元归 root 管理,只允许 products 写特定输出目录。"
- }
- ],
- "exercise": {
- "task": "为什么不能让 Web 服务直接使用 deploy 账号运行?",
- "hint": "deploy 具备管理能力与登录凭据。",
- "answer": "应用漏洞会扩大到发布权限或 sudo 能力。独立 shop 账号将运行权限限制在服务需要的配置、网络和输出目录,deploy 只用于受控部署。"
- },
- "refs": [
- {
- "label": "Ubuntu:OpenSSH",
- "url": "https://ubuntu.com/server/docs/how-to/security/openssh-server/"
- },
- {
- "label": "OpenSSH:ssh-keygen",
- "url": "https://man.openbsd.org/ssh-keygen"
- },
- {
- "label": "Ubuntu:用户管理",
- "url": "https://documentation.ubuntu.com/server/how-to/security/user-management/"
- }
- ],
- "production": {
- "scenario": "服务长期运行在远程主机,必须保留可靠管理入口并限制应用写权限。",
- "decisions": [
- "管理账号与服务账号分离。",
- "SSH变更先验证新会话,再关闭旧入口。"
- ],
- "failureDrill": "在隔离VM故意给一份测试authorized_keys设置错误权限,观察新会话失败,用保留会话修复;不对真实唯一入口试错。",
- "acceptance": [
- "密钥指纹与权限有记录。",
- "账户错误可通过预留入口恢复。"
- ]
- },
- "intro": {
- "scenario": "商品服务要从开发电脑搬到一台练习服务器。先解决“连的是哪台机器、谁能修改程序、谁来运行程序”。",
- "startingPoint": [
- "准备独立 Ubuntu 24.04 LTS 练习 VM,保留虚拟机控制台及可用的管理员会话。",
- "已完成商品 API、数据库和任务工程;本课先建登录与目录边界,不修改生产 SSH 或唯一远程入口。"
- ],
- "outcome": "用新的密钥会话安全登录 deploy,并验证运行账号只能写指定结果目录。"
- }
- },
- {
- "id": "02",
- "group": "主机与进程",
- "title": "监听地址、DNS 与防火墙",
- "summary": "留下经验证的 SSH 与网页入口,并确认数据库端口仅用于本机访问。",
- "objectives": [
- "查明真实监听地址与 SSH 端口",
- "设置可自动回退的练习防火墙规则",
- "用第二个会话核对规则后取消回退"
- ],
- "concepts": [
- {
- "title": "网络路径有多层",
- "body": "结合“核对当前监听与路由”的实际检查回顾。客户端先解析DNS,连接公网或私网IP,经云安全组/路由到主机防火墙,再到监听端口。服务没监听时放开防火墙没有作用;DNS解析正确也不能证明端口可达。"
- },
- {
- "title": "公开端口最小化",
- "body": "结合“核对Docker与云侧规则”的实际检查回顾。Nginx对外提供80/443,API只监听127.0.0.1:8000;MySQL与Redis继续使用本机回环调试端口或受控私网。Docker发布端口可能经过不同于普通INPUT的转发规则,不能假定UFW会拦截所有Docker ports。"
- },
- {
- "title": "防止锁死",
- "body": "结合“用新会话验证后取消回退”的实际检查回顾。SSH端口可能不是22,来源IP也可能经过VPN或跳板机。启用UFW前读取实际SSH监听、添加保留规则、准备限时自动回退,并保留当前会话和控制台。关闭旧会话不是验证新规则的方法。"
- }
- ],
- "steps": [
- {
- "title": "核对当前监听与路由",
- "body": "在VM执行只读命令;没有rg则用grep。识别真实SSH端口与监听地址,后续规则使用实际值,不盲目套22。",
- "code": "sudo ss -lntp\nip -brief address\nip route\nsudo ufw status verbose",
- "lang": "bash",
- "expected": "ss 显示实际 SSH 端口与绑定地址,ip route 有出站路由,UFW 当前规则与状态被记录。",
- "explanation": "监听表示程序接收哪个地址的连接,防火墙决定哪些流量被放行;两者都要核对,规则放行不会自动启动服务。"
- },
- {
- "title": "检查域名解析与本机端点",
- "body": "尚无域名时用VM IP或本机Host头完成后续HTTP课程。shop.example.com为文档占位,不能注册证书。DNS A与AAAA都应指向能提供服务的地址,错误AAAA可能导致部分用户访问失败。",
- "code": "getent ahosts shop.example.com\n# API启动后再执行:\ncurl --connect-timeout 2 --max-time 5 -i http://127.0.0.1:8000/ready",
- "lang": "bash",
- "expected": "真实域名时解析结果与 VM 地址一致;还未部署 API 时记录端点检查待执行,不将连接拒绝判为本课失败。",
- "explanation": "DNS 只把名字变成地址,不能证明端口上有应用;AAAA 指向错误 IPv6 地址也会造成部分访问异常。"
- },
- {
- "title": "先添加SSH和HTTP规则",
- "body": "仅在已确认SSH为22且有控制台的独立VM执行。若使用非22端口,修改下列规则。已有UFW规则不要 reset;先阅读并保留其他必要访问。自动回退任务3分钟后关闭UFW,只用于这次隔离练习。",
- "code": "sudo ufw allow 22/tcp comment 'keep verified SSH'\nsudo ufw allow 80/tcp comment 'HTTP and ACME'\nsudo ufw allow 443/tcp comment 'HTTPS'\nsudo systemd-run --unit=shop-firewall-rollback --on-active=3m /usr/sbin/ufw disable\nsudo ufw default deny incoming\nsudo ufw default allow outgoing\nsudo ufw enable",
- "lang": "bash",
- "expected": "允许规则先包含已验证 SSH 与 80/443,回退 timer 已创建后才启用收紧策略。",
- "explanation": "防火墙误配会切断管理入口。延迟回退给验证留出恢复机会;默认拒绝入站仍保留显式允许的端口。"
- },
- {
- "title": "用新会话验证后取消回退",
- "body": "保持旧会话,在本机另开SSH并执行一个只读命令。成功后检查UFW状态,取消尚未执行的回退timer;若失败,不继续收紧规则,使用保留会话或等待自动回退。",
- "code": "# 新SSH会话成功后执行\nsudo ufw status numbered\nsudo systemctl stop shop-firewall-rollback.timer\nsudo systemctl status shop-firewall-rollback.timer --no-pager",
- "lang": "bash",
- "expected": "另一个新 SSH 会话能实际执行命令,规则符合预期,取消后回退 timer 显示 inactive。",
- "explanation": "旧会话可能因已建立连接仍畅通,不能替代新连接测试;确认新连接可用后才取消保护。"
- },
- {
- "title": "核对Docker与云侧规则",
- "body": "若依赖运行在Docker,docker ps检查端口只能为127.0.0.1:3307、缓存6380、任务broker6384。云安全组按本练习仅开放已验证SSH和80/443;此课不代替在真实云账号创建规则。",
- "code": "docker ps --format 'table {{.Names}}\t{{.Ports}}'\nsudo ss -lntp\n# 从另一台获准练习客户端检测VM的80/443;数据库端口不应对公网开放。",
- "lang": "bash",
- "expected": "数据库与缓存/broker 发布地址是 127.0.0.1,云侧仅保留已授权入口;未做外部测试时明确标记。",
- "explanation": "Docker 发布端口、主机防火墙与云安全组属于不同层。只看 UFW 不能证明容器端口没有额外暴露。"
- }
- ],
- "checks": [
- "新SSH会话在UFW启用后成功,回退timer已确认取消。",
- "API/数据库/Redis监听地址与外部公开端口列表一致。",
- "DNS、TCP、HTTP失败可以分层描述,未对数据库开放公网入口。"
- ],
- "pitfalls": [
- {
- "symptom": "本机curl成功,外部浏览器超时",
- "cause": "云安全组、主机防火墙或监听绑定地址阻断。",
- "fix": "按路径逐层检查,不同时关闭全部安全规则掩盖问题。"
- },
- {
- "symptom": "UFW拒绝端口但Docker服务仍能被外部访问",
- "cause": "Docker发布端口涉及NAT/转发规则。",
- "fix": "优先取消公开发布或绑定回环,按Docker官方防火墙集成文档核对实际路径。"
- }
- ],
- "exercise": {
- "task": "域名同时有A和AAAA记录,只检查IPv4是否足够?",
- "hint": "客户端可能优先选择IPv6。",
- "answer": "不够。需要验证两种地址都可到达正确服务;未配置IPv6服务时移除错误AAAA,不能让部分用户随机遇到超时。"
- },
- "refs": [
- {
- "label": "Ubuntu:防火墙",
- "url": "https://ubuntu.com/server/docs/security-firewall/"
- },
- {
- "label": "Docker:防火墙与UFW",
- "url": "https://docs.docker.com/engine/network/packet-filtering-firewalls/"
- },
- {
- "label": "systemd-run:临时任务(Ubuntu 24.04)",
- "url": "https://manpages.ubuntu.com/manpages/noble/man1/systemd-run.1.html"
- }
- ],
- "production": {
- "scenario": "只公开HTTPS入口,数据库与队列保留在内网或本机。",
- "decisions": [
- "防火墙变更保留已验证SSH路径与自动回退。",
- "主机和云网络规则分别核对,Docker端口单独检查。"
- ],
- "failureDrill": "在隔离VM启用规则并从新会话验证;若配置错误,使用定时回退或控制台恢复。",
- "acceptance": [
- "没有失去管理入口。",
- "公开端口清单符合服务架构。"
- ]
- },
- "intro": {
- "scenario": "商品 API 将通过网页访问,但数据库不能一起暴露出去;修改防火墙前也必须确保自己还能登录。",
- "startingPoint": [
- "完成第 01 课,新 deploy 密钥会话能执行 sudo -v,原管理会话和控制台仍可用。",
- "在同一独立 VM 做只读端口检查;实际 SSH 端口以查询结果为准,不能直接假定是 22。"
- ],
- "outcome": "留下经验证的 SSH 与网页入口,并确认数据库端口仅用于本机访问。"
- }
- },
- {
- "id": "03",
- "group": "主机与进程",
- "title": "Python 环境、配置与最小权限",
- "summary": "以 shop 身份导入应用依赖,读取指定发布目录,并确认秘密和导出目录权限。",
- "objectives": [
- "安装目标机器自己的 Python 运行环境",
- "把完整发布物放入不可覆盖的 r001 目录",
- "核对两种驱动配置和可写结果路径"
- ],
- "concepts": [
- {
- "title": "Ubuntu默认Python版本",
- "body": "结合“安装Ubuntu提供的运行依赖”的实际检查回顾。Ubuntu24.04通常提供Python3.12,满足课程Python3.11+要求;不要为了匹配旧命令替换系统/usr/bin/python3。若必须精确3.11,使用经过组织批准的运行时或Docker3.11镜像,并分别生成目标版本依赖锁。"
- },
- {
- "title": "部署输入完整",
- "body": "结合“部署已验证发布物”的实际检查回顾。发布物要包含app、迁移、锁文件和最终tasks_lab,而不是旧api-lab下载包。任务模块的DDL需纳入迁移并在空库验证,任务入口为tasks_lab/celery_app.py中的app,队列为exports.fast/bulk。"
- },
- {
- "title": "环境文件不是Shell脚本",
- "body": "结合“建立两个专用环境文件”的实际检查回顾。systemd EnvironmentFile读取KEY=value,不执行命令或source。API使用mysql+asyncmy://;任务state_store解析mysql+pymysql://后建立同步连接。秘密文件由root管理,运行组只读,诊断时不输出全文。"
- }
- ],
- "steps": [
- {
- "title": "安装Ubuntu提供的运行依赖",
- "body": "在独立Ubuntu VM安装应用运行依赖和交互SQL客户端;不在系统site-packages安装应用。发行版客户端版本可能与MySQL8.4不同,交互查询先检查兼容性;第09课备份明确使用mysql:8.4工具镜像,不假定Ubuntu默认安装的是8.4。",
- "code": "sudo apt update\nsudo apt install -y python3 python3-venv nginx default-mysql-client curl rsync\npython3 --version",
- "lang": "bash",
- "expected": "apt 安装完成,python3 --version 显示目标 VM 的实际版本,python3-venv 与客户端工具可用。",
- "explanation": "发行版 Python 用于创建专用环境,应用依赖安装到项目 venv。MySQL 客户端版本与服务器版本仍需分别核对。"
- },
- {
- "title": "部署已验证发布物",
- "body": "将本地完成的shop-service源码包上传到deploy自己的staging目录,先校验文件清单与摘要。不要以root解压不明tar。下列命令假定~/staging/shop-service已是经检查的目录;r001是本次练习明确版本,不能覆盖已有目录。",
- "code": "sudo install -d -o root -g shop -m 750 /srv/shop-service/releases/r001\nsudo rsync -a --exclude=.env --exclude='.env.*' --exclude=.venv --exclude=secrets --exclude=backups ~/staging/shop-service/ /srv/shop-service/releases/r001/\nsudo chown -R root:shop /srv/shop-service/releases/r001\nsudo chmod -R u=rwX,g=rX,o= /srv/shop-service/releases/r001\nsudo python3 -m venv /srv/shop-service/releases/r001/.venv\nsudo /srv/shop-service/releases/r001/.venv/bin/python -m pip install --require-hashes -r /srv/shop-service/releases/r001/requirements-lock.txt",
- "lang": "bash",
- "expected": "r001 目录中有完整 app、任务源码、迁移和锁文件,.venv 建成,未复制 .env、secrets 或本机旧虚拟环境。",
- "explanation": "发布目录是本次软件的固定集合,venv 必须在兼容的目标平台生成;复制本机环境可能带入错误解释器与二进制扩展。"
- },
- {
- "title": "建立两个专用环境文件",
- "body": "用sudoedit创建下列文件,填VM实际依赖地址与URL编码密码。若沿用Docker练习依赖:MySQL宿主3307,缓存6380,任务broker6384;若迁移到受控私网数据库按实际地址改,绝不能在主机进程中使用Docker服务名mysql/broker。",
- "code": "# /etc/shop-service.env(API)\nDATABASE_URL=mysql+asyncmy://app_user:ENCODED_PASSWORD@127.0.0.1:3307/shop_lab?charset=utf8mb4\nDB_POOL_SIZE=5\nDB_MAX_OVERFLOW=0\nDB_POOL_TIMEOUT=2\n\n# /etc/shop-tasks.env(tasks_lab)\nDATABASE_URL=mysql+pymysql://app_user:ENCODED_PASSWORD@127.0.0.1:3307/shop_lab?charset=utf8mb4\nCELERY_BROKER_URL=redis://:ENCODED_TASK_BROKER_PASSWORD@127.0.0.1:6384/0\nCELERY_RESULT_BACKEND=redis://:ENCODED_TASK_BROKER_PASSWORD@127.0.0.1:6384/1",
- "lang": "dotenv",
- "expected": "API 文件使用 mysql+asyncmy,任务文件使用 mysql+pymysql;主机进程连接 127.0.0.1:3307 和任务 broker 6384。",
- "explanation": "主机进程不在 Compose 网络里,无法直接借用 mysql/broker 服务名;两个数据库 URL 选择的是不同调用方式的驱动。"
- },
- {
- "title": "限制配置和输出权限",
- "body": "先确认文件是刚编辑的两个目标,再设置所有权。任务工程默认相对artifacts目录改为指向shared/artifacts的软链;已有目录若包含结果先核对并迁移,不能直接rm -rf。新发布物应不包含旧导出文件。",
- "code": "sudo chown root:shop /etc/shop-service.env /etc/shop-tasks.env\nsudo chmod 640 /etc/shop-service.env /etc/shop-tasks.env\nsudo ln -s /srv/shop-service/shared/artifacts /srv/shop-service/releases/r001/tasks_lab/artifacts\nsudo ln -s releases/r001 /srv/shop-service/current\nsudo -u shop test -r /etc/shop-service.env\nsudo -u shop test ! -w /srv/shop-service/releases/r001/app/main.py\nsudo -u shop test -w /srv/shop-service/shared/artifacts",
- "lang": "bash",
- "expected": "配置文件为 root:shop 与 640,current 指到 r001,任务 artifacts 指到 shared/artifacts,shop 可写结果但不能改发布源码。",
- "explanation": "软链把易变的结果位置接到每次发布目录,使切换源码版本仍使用同一份结果文件;路径每一级都需可进入。"
- },
- {
- "title": "检查环境与导入",
- "body": "不source秘密文件到共享交互终端。下面只验证包和文件路径;带环境启动由下一课systemd读取。若依赖锁与Python版本不兼容,返回目标Linux/Python版本重新生成并验证锁,不去掉固定版本强行安装。",
- "code": "sudo -u shop /srv/shop-service/current/.venv/bin/python -c \"import sys, fastapi, asyncmy, pymysql; print(sys.version); print(fastapi.__version__); print('mysql drivers import ok')\"\nsudo -u shop readlink -f /srv/shop-service/current\nsudo namei -l /srv/shop-service/current/app/main.py",
- "lang": "bash",
- "expected": "以 shop 身份打印 Python/FastAPI 版本与 mysql drivers import ok,current 解析为实际 r001,路径权限可追到每一级。",
- "explanation": "管理员能导入不等于服务用户能导入。这个探针使用真实运行身份检查代码与权限,带配置的运行由下一课系统进程管理读取。"
- }
- ],
- "checks": [
- "API与任务环境文件使用不同DSN前缀,宿主端口与实际依赖一致。",
- "products可读配置但不能写发布代码,导出路径指向shared。",
- "venv来自当前VM支持的Python,锁定依赖安装和导入成功。"
- ],
- "pitfalls": [
- {
- "symptom": "任务连接URL解析失败或加载了错误驱动",
- "cause": "把API的异步驱动URL原样复制给任务的同步PyMySQL适配器。",
- "fix": "API使用mysql+asyncmy://,任务使用mysql+pymysql://;核对URL编码、3307宿主端口与实际环境来源。"
- },
- {
- "symptom": "systemd找不到包但登录终端能导入",
- "cause": "服务未使用venv解释器,或发布路径不一致。",
- "fix": "ExecStart指定绝对venv Python路径,不依赖交互shell激活。"
- }
- ],
- "exercise": {
- "task": "为什么不能在服务器执行sudo pip install -U覆盖系统Python依赖?",
- "hint": "Ubuntu工具也可能依赖系统Python。",
- "answer": "这样会污染系统包管理和其他应用。每个发布目录使用独立venv,升级形成新发布物,系统Python由发行版包管理维护。"
- },
- "refs": [
- {
- "label": "Python:venv",
- "url": "https://docs.python.org/3/library/venv.html"
- },
- {
- "label": "Ubuntu:Python开发环境",
- "url": "https://documentation.ubuntu.com/ubuntu-for-developers/howto/python-setup/"
- },
- {
- "label": "systemd:EnvironmentFile(Ubuntu 24.04)",
- "url": "https://manpages.ubuntu.com/manpages/noble/man5/systemd.exec.5.html"
- }
- ],
- "production": {
- "scenario": "服务器重启后进程需要用确定的解释器、代码和秘密恢复。",
- "decisions": [
- "发布目录不可由服务修改。",
- "API与任务配置分开,秘密不进入源码包。"
- ],
- "failureDrill": "用products账号尝试写代码文件应失败,写shared/exports应成功;误配DSN在隔离环境修复后再启动。",
- "acceptance": [
- "部署输入和权限边界可复现。",
- "无全局pip安装和明文秘密日志。"
- ]
- },
- "intro": {
- "scenario": "服务器能登录了,现在把商品代码、依赖、配置和结果文件放到各自明确的位置。",
- "startingPoint": [
- "完成第 01—02 课,/srv/shop-service 目录和 shop 账号存在。",
- "准备已验证完整 shop-service 与 tasks_lab 发布目录,以及适配目标 Linux/Python 的依赖锁;MySQL 和独立任务 broker 地址已核对。"
- ],
- "outcome": "以 shop 身份导入应用依赖,读取指定发布目录,并确认秘密和导出目录权限。"
- }
- },
- {
- "id": "04",
- "group": "主机与进程",
- "title": "systemd 管理 API、Worker 与维护进程",
- "summary": "由系统分别管理 API、两个 Worker、派发与恢复进程,并以业务探针验证启动。",
- "objectives": [
- "为每个真实进程写独立启动定义",
- "检查配置和退出预算后按序启动",
- "确认商品查询与一次导出完成后再开启自启动"
- ],
- "concepts": [
- {
- "title": "进程监督",
- "body": "结合“验证配置并按顺序启动”的实际检查回顾。systemd知道主进程PID、退出码和重启次数,可以开机启动并收集日志。Type=simple服务应前台运行,不nohup、不后台&。Wants network-online不是数据库一定可用,应用仍需有界连接超时。"
- },
- {
- "title": "真实任务入口",
- "body": "结合“为publisher/recovery创建独立模板”的实际检查回顾。worker工作目录为current/tasks_lab,命令python -m celery -A celery_app:app;fast/bulk分别订阅exports.fast与exports.bulk。publisher.py/recovery.py本身只执行一次,需要Docker模块提供的run_loops.py或同等有界循环,不能将一次性脚本伪称常驻服务。"
- },
- {
- "title": "重启与资源",
- "body": "结合“验证配置并按顺序启动”的实际检查回顾。Restart=on-failure适合异常退出重启,StartLimit限制崩溃循环。MemoryMax、CPUQuota、TasksMax是cgroup边界,不是性能承诺。Celery warm shutdown可能等待当前任务,TimeoutStopSec应覆盖已定义的任务最大时长,仍需恢复租约处理强杀。"
- }
- ],
- "steps": [
- {
- "title": "新建API服务单元",
- "body": "用 sudoedit 保存为 /etc/systemd/system/shop-api.service,确认 current 指向第 03 课的完整发布物。保持 API 只监听回环、只信任本机 Nginx 的代理头;不要把数据库迁移加入每次启动的 ExecStartPre。",
- "code": "[Unit]\nDescription=shop-service API\nWants=network-online.target\nAfter=network-online.target\nStartLimitIntervalSec=60\nStartLimitBurst=5\n\n[Service]\nType=simple\nUser=shop\nGroup=shop\nWorkingDirectory=/srv/shop-service/current\nEnvironmentFile=/etc/shop-service.env\nEnvironment=PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1\nExecStart=/srv/shop-service/current/.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --proxy-headers --forwarded-allow-ips=127.0.0.1 --timeout-graceful-shutdown 25\nRestart=on-failure\nRestartSec=3\nTimeoutStopSec=35\nKillSignal=SIGTERM\nMemoryMax=512M\nCPUQuota=100%\nTasksMax=128\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nUMask=0027\n\n[Install]\nWantedBy=multi-user.target",
- "lang": "ini",
- "filename": "/etc/systemd/system/shop-api.service",
- "expected": "shop-api.service 文件指向 current/.venv,User=shop,API 监听 127.0.0.1:8000,迁移不在每次启动前重复执行。",
- "explanation": "服务单元把身份、目录、环境和命令一起固定,终端退出后系统仍能管理进程;迁移具有独立生命周期。"
- },
- {
- "title": "新建fast/bulk Worker模板",
- "body": "保存shop-worker@.service,实例参数fast和bulk映射两个既定队列。共享输出需要ReadWritePaths;先验证共享目录存在。实际任务上限若超过60秒,应先调整任务协议与退出预算,不照抄75秒。",
- "code": "[Unit]\nDescription=shop-service %i export worker\nWants=network-online.target\nAfter=network-online.target\nStartLimitIntervalSec=60\nStartLimitBurst=5\n\n[Service]\nType=simple\nUser=shop\nGroup=shop\nWorkingDirectory=/srv/shop-service/current/tasks_lab\nEnvironmentFile=/etc/shop-tasks.env\nEnvironment=PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1\nExecStart=/srv/shop-service/current/.venv/bin/python -m celery -A celery_app:app worker -l INFO -Q exports.%i --concurrency=1 -n %i@%%h\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=75\nKillSignal=SIGTERM\nKillMode=mixed\nMemoryMax=768M\nCPUQuota=100%\nTasksMax=128\nNoNewPrivileges=true\nPrivateTmp=true\nProtectSystem=strict\nProtectHome=true\nReadWritePaths=/srv/shop-service/shared/artifacts\nUMask=0027\n\n[Install]\nWantedBy=multi-user.target",
- "lang": "ini",
- "filename": "/etc/systemd/system/shop-worker@.service",
- "expected": "worker 模板用 %i 区分 fast/bulk,队列与节点名对应实例,共享 artifacts 写入路径被显式允许。",
- "explanation": "模板减少同类进程配置重复,实际实例仍有独立进程、队列和资源开销;停止预算应覆盖任务实际退出规则。"
- },
- {
- "title": "为publisher/recovery创建独立模板",
- "body": "需要Docker第9课已给出的tasks_lab/run_loops.py,或复制那份受控循环到发布物。模板传publisher/recovery选择单步函数;不把维护任务与worker塞入同进程。EnvironmentFile仍为tasks专用,日志仅记录异常类别与关联id。",
- "code": "[Unit]\nDescription=shop-service %i loop\nWants=network-online.target\nAfter=network-online.target\nStartLimitIntervalSec=60\nStartLimitBurst=5\n\n[Service]\nType=simple\nUser=shop\nGroup=shop\nWorkingDirectory=/srv/shop-service/current/tasks_lab\nEnvironmentFile=/etc/shop-tasks.env\nEnvironment=PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1\nExecStart=/srv/shop-service/current/.venv/bin/python run_loops.py %i\nRestart=on-failure\nRestartSec=5\nTimeoutStopSec=20\nMemoryMax=256M\nTasksMax=64\nNoNewPrivileges=true\nProtectSystem=strict\nProtectHome=true\nPrivateTmp=true\nUMask=0027\n\n[Install]\nWantedBy=multi-user.target",
- "lang": "ini",
- "filename": "/etc/systemd/system/shop-maintenance@.service",
- "expected": "maintenance 模板分别传 publisher 和 recovery 给 run_loops.py,未把它们作为 Celery worker 启动。",
- "explanation": "派发和恢复是持续调用单步业务函数的维护角色,独立单元便于分别查看失败、停止和重启。"
- },
- {
- "title": "可选新增单实例beat",
- "body": "只有完成任务模块周期任务后才启用。沿用Worker单元但Description改为shop-service beat、ExecStart替换如下、移除原ReadWritePaths并加StateDirectory=shop-service。保存独立shop-beat.service,只运行一个实例;本地调度文件不是分布式领导选举。",
- "code": "ExecStart=/srv/shop-service/current/.venv/bin/python -m celery -A celery_app:app beat -l INFO --schedule=/var/lib/shop-service/celerybeat-schedule\nStateDirectory=shop-service\nReadWritePaths=/var/lib/shop-service",
- "lang": "ini",
- "filename": "/etc/systemd/system/shop-beat.service",
- "expected": "已完成周期任务时,beat 单独使用 /var/lib/shop-service 保存调度状态且只启用一份;否则标记本步不适用。",
- "explanation": "每个 API 或 Worker 都开定时器会重复触发;本地调度文件只保存该实例状态,不提供跨机器选主。"
- },
- {
- "title": "验证配置并按顺序启动",
- "body": "先完成数据库模块和任务模块最终DDL迁移,数据库及认证broker必须可达。systemd-analyze verify可发现字段/路径问题,不能代替真实数据库验收;readiness与真实任务完成后再enable开机启动。",
- "code": "sudo systemd-analyze verify /etc/systemd/system/shop-api.service /etc/systemd/system/shop-worker@.service /etc/systemd/system/shop-maintenance@.service\nsudo systemctl daemon-reload\nsudo systemctl start shop-api shop-worker@fast shop-worker@bulk shop-maintenance@publisher shop-maintenance@recovery\nsudo systemctl status shop-api shop-worker@fast --no-pager\ncurl --fail --max-time 5 http://127.0.0.1:8000/ready\nsudo systemctl enable shop-api shop-worker@fast shop-worker@bulk shop-maintenance@publisher shop-maintenance@recovery",
- "lang": "bash",
- "expected": "verify 无阻断错误,所需单元 active,/ready 成功,真实导出得到可读取的文件;随后才设置 enable。",
- "explanation": "配置语法验证不能证明数据库或 broker 可达;进程存活也不能证明任务链路完整,因此需要分层验收。"
- }
- ],
- "checks": [
- "API和各任务进程有独立unit、PID与日志,退出原因可查询。",
- "Celery节点名展开正确,fast/bulk只消费对应队列。",
- "SIGTERM后进程在预算内退出,超过预算的业务任务按租约恢复协议处理。"
- ],
- "pitfalls": [
- {
- "symptom": "systemd启动立即报找不到celery_app",
- "cause": "WorkingDirectory未指向tasks_lab或源文件未部署。",
- "fix": "核对最终任务工程路径及绝对venv,不能靠登录终端PYTHONPATH临时修复。"
- },
- {
- "symptom": "节点名出现错误或systemd拒绝ExecStart",
- "cause": "systemd把%h当成自身specifier展开。",
- "fix": "传给Celery的%h需在unit中写%%h;%i保留用于systemd实例名。"
- }
- ],
- "exercise": {
- "task": "数据库短暂故障时要不要让API进程退出,以便systemd不断重启?",
- "hint": "所有实例依赖同一数据库时会发生什么?",
- "answer": "通常不应把readiness失败直接变成所有进程退出。API保持可观测并返回503,修复依赖后恢复;只有进程自身无法继续工作才退出。重启策略设置节流,避免掩盖真实故障。"
- },
- "refs": [
- {
- "label": "systemd:service(Ubuntu 24.04)",
- "url": "https://manpages.ubuntu.com/manpages/noble/man5/systemd.service.5.html"
- },
- {
- "label": "systemd:资源控制(Ubuntu 24.04)",
- "url": "https://manpages.ubuntu.com/manpages/noble/man5/systemd.resource-control.5.html"
- },
- {
- "label": "Celery:Worker关闭",
- "url": "https://docs.celeryq.dev/en/stable/userguide/workers.html"
- },
- {
- "label": "Uvicorn:部署",
- "url": "https://uvicorn.dev/deployment/"
- }
- ],
- "production": {
- "scenario": "主机重启与应用崩溃后,各角色需要按各自预算恢复运行。",
- "decisions": [
- "监督前台主进程,迁移独立执行。",
- "任务维护循环和调度器与worker分开,beat单实例。"
- ],
- "failureDrill": "在独立VM正常stop/start API与一组worker,核对日志、任务状态和恢复时间。",
- "acceptance": [
- "开机启动可验证。",
- "重启不会无界循环或静默丢失业务状态。"
- ]
- },
- "intro": {
- "scenario": "手动启动商品 API 后退出终端,服务也可能结束;导出还需要多个持续运行的角色。",
- "startingPoint": [
- "完成第 03 课,shop 身份能运行 current/.venv/bin/python,API/任务配置文件存在且权限正确。",
- "数据库已应用商品和任务最终迁移,认证 broker 可达,tasks_lab/run_loops.py 已按 Docker 第 09 课准备。"
- ],
- "outcome": "由系统分别管理 API、两个 Worker、派发与恢复进程,并以业务探针验证启动。"
- }
- },
- {
- "id": "05",
- "group": "代理与发布",
- "title": "Nginx HTTP 入口与 TLS",
- "summary": "从 Nginx 入口读到商品服务,区分上游连接错误与证书错误,并在条件具备时验证续期。",
- "objectives": [
- "把本机 API 接到指定 HTTP 站点",
- "加载前先检查配置并验证入口",
- "用隔离停机现象定位代理与应用的边界"
- ],
- "concepts": [
- {
- "title": "代理负责公网连接",
- "body": "结合“校验再加载站点”的实际检查回顾。Nginx终止客户端连接,将HTTP转发到127.0.0.1:8000。API不直接公开端口,转发Host与真实协议;只有可信代理可以影响应用识别的客户端地址与HTTPS状态。"
- },
- {
- "title": "错误发生的层级",
- "body": "结合“演练上游失败并确认日志”的实际检查回顾。502通常表示上游连接或协议失败,504通常表示等待上游超时,413来自请求体限制。应结合Nginx error log与API request_id判断,不能看到所有5xx都重启数据库。"
- },
- {
- "title": "证书需要真实控制权",
- "body": "结合“具备真实域名后申请证书”的实际检查回顾。shop.example.com是占位名,不能申请证书。只有持有实际域名、DNS正确指向练习主机且验证路径可达时,才使用Certbot完成ACME。没有这些条件时先用HTTP本机探针学习代理,不关闭客户端TLS校验伪造完成。"
- }
- ],
- "steps": [
- {
- "title": "新建站点配置",
- "body": "保存/etc/nginx/sites-available/shop-service。在独立VM确认没有同名站点或80端口冲突;server_name先用文档占位配合本机Host验证,正式申请证书前替换为自己持有的域名。",
- "code": "server {\n listen 80;\n server_name shop.example.com;\n client_max_body_size 2m;\n access_log /var/log/nginx/shop-access.log;\n error_log /var/log/nginx/shop-error.log warn;\n\n location / {\n proxy_pass http://127.0.0.1:8000;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_connect_timeout 3s;\n proxy_read_timeout 30s;\n proxy_send_timeout 30s;\n }\n}",
- "lang": "nginx",
- "filename": "/etc/nginx/sites-available/shop-service",
- "expected": "站点文件包含 server_name 和指向 127.0.0.1:8000 的 proxy_pass,独立访问/错误日志路径明确。",
- "explanation": "Nginx 接收网页入口连接,再向只在本机监听的 API 转发;应用端口无需同时对外开放。"
- },
- {
- "title": "校验再加载站点",
- "body": "只新建本次站点软链,不删除现有默认或其他用户站点。nginx -t失败时不reload,修复配置后重试。用Host头选中本次server块,验证/ready与/products。",
- "code": "sudo ln -s /etc/nginx/sites-available/shop-service /etc/nginx/sites-enabled/shop-service\nsudo nginx -t\nsudo systemctl reload nginx\ncurl --fail --max-time 5 -H 'Host: shop.example.com' http://127.0.0.1/ready\nsudo tail -n 30 /var/log/nginx/shop-error.log",
- "lang": "bash",
- "expected": "nginx -t 成功后重载,带 Host: shop.example.com 的 /ready 请求成功,错误日志无持续上游连接失败。",
- "explanation": "Host 选择对应站点;直接访问 IP 可能落到别的默认站点。语法不通过时重载无助于修复错误配置。"
- },
- {
- "title": "具备真实域名后申请证书",
- "body": "此步骤需要实际域名控制权和公网验证条件,本课不替用户注册域名或申请证书。按Certbot官网Ubuntu/Nginx当前安装说明准备工具,替换YOUR_DOMAIN后执行;先验证DNS与HTTP访问。签发会联系外部CA并修改站点配置,应在自己的练习域名操作。",
- "code": "# 前提已完成:真实域名DNS/80端口验证、Certbot安装、Nginx配置校验\nsudo certbot --nginx -d YOUR_DOMAIN\nsudo nginx -t\nsudo certbot certificates",
- "lang": "bash",
- "expected": "满足域名条件时 Certbot 列出该真实域名证书且 nginx -t 通过;无域名则本步记录未执行。",
- "explanation": "证书签发需要证明域名控制权,文档中的 example.com 不能用于本次授权;签发工具会与外部 CA 通信。"
- },
- {
- "title": "验证TLS与续期",
- "body": "使用真实域名而不是curl -k检查证书主机名、链与有效期。检查系统中的续期timer,执行dry-run验证续期流程;测试必须保留ACME挑战访问或对应DNS验证能力。",
- "code": "curl --fail --max-time 10 https://YOUR_DOMAIN/ready\nsudo certbot renew --dry-run\nsystemctl list-timers --all | grep -E 'certbot|snap.certbot'",
- "lang": "bash",
- "expected": "真实 HTTPS 请求通过证书校验,renew --dry-run 成功,并找到实际续期 timer;否则记录失败阶段。",
- "explanation": "当前证书可用与未来自动续期是两次不同验证,关闭验证的 curl -k 会掩盖主机名或证书链问题。"
- },
- {
- "title": "演练上游失败并确认日志",
- "body": "仅在隔离VM停API,Nginx应返回网关错误,同时error log报告上游连接失败;启动API后恢复。不要把异常SQL、密码或Authorization记录进Nginx自定义日志。",
- "code": "sudo systemctl stop shop-api\ncurl --max-time 5 -i -H 'Host: shop.example.com' http://127.0.0.1/ready\nsudo tail -n 20 /var/log/nginx/shop-error.log\nsudo systemctl start shop-api",
- "lang": "bash",
- "expected": "停 API 时入口返回网关错误,日志指向上游连接失败;恢复 API 后同一入口探针成功。",
- "explanation": "代理还能响应说明入口进程在运行,失败发生在到上游的连接;这与域名解析失败或 TLS 握手失败不同。"
- }
- ],
- "checks": [
- "HTTP代理能到达回环API,nginx -t通过。",
- "真实域名条件具备时,HTTPS无需跳过校验且续期dry-run成功;否则明确未配置TLS。",
- "停API产生可定位的502类上游错误,恢复后/ready成功。"
- ],
- "pitfalls": [
- {
- "symptom": "Nginx默认欢迎页而非API",
- "cause": "请求Host没有匹配server_name,或站点链接未加载。",
- "fix": "用nginx -T在可信终端定位server块,检查Host和软链;不要随意删除所有默认配置。"
- },
- {
- "symptom": "证书首次申请成功,数月后过期",
- "cause": "续期任务未运行或验证路径被防火墙/代理改动阻断。",
- "fix": "检查timer、续期日志与dry-run,证书剩余天数加入监控。"
- }
- ],
- "exercise": {
- "task": "把client_max_body_size调到1GB能否解决所有上传需求?",
- "hint": "应用、代理、磁盘和超时仍有各自限制。",
- "answer": "不能。应按业务定义大小、流式处理和对象存储上传方案,避免单请求在API内存中聚集大文件;代理限制必须与应用校验和资源预算一致。"
- },
- "refs": [
- {
- "label": "Nginx:反向代理",
- "url": "https://nginx.org/en/docs/http/ngx_http_proxy_module.html"
- },
- {
- "label": "Nginx:HTTPS配置",
- "url": "https://nginx.org/en/docs/http/configuring_https_servers.html"
- },
- {
- "label": "Certbot:Nginx安装与续期",
- "url": "https://certbot.eff.org/instructions?ws=nginx&os=snap"
- }
- ],
- "production": {
- "scenario": "用户通过HTTPS访问API,证书和代理错误必须持续可观测。",
- "decisions": [
- "应用只信任本机代理,公网由Nginx统一入口。",
- "证书申请与续期都验证,缺域名条件时明确未完成项。",
- "本例商品接口用于隔离练习,尚无完整用户身份与数据归属校验。TLS只加密传输;投入真实业务前必须补齐身份、操作权限、数据归属和审计,并验证未授权请求无法读写。"
- ],
- "failureDrill": "停止练习API观察Nginx错误,再恢复;对自己域名执行续期dry-run并记录结果。",
- "acceptance": [
- "代理错误能定位到上游层。",
- "TLS状态不靠忽略证书错误通过。"
- ]
- },
- "intro": {
- "scenario": "API 只在服务器回环地址上监听,现在给商品查询增加网页入口;有自己的域名时再验证 HTTPS。",
- "startingPoint": [
- "完成第 04 课,VM 内 curl http://127.0.0.1:8000/ready 成功,Nginx 已安装。",
- "先在独立 VM 用 Host 头验证 HTTP;证书步骤需要自己控制的真实域名和验证条件,没有时明确停在 HTTP 验收。"
- ],
- "outcome": "从 Nginx 入口读到商品服务,区分上游连接错误与证书错误,并在条件具备时验证续期。"
- }
- },
- {
- "id": "06",
- "group": "代理与发布",
- "title": "SSE、WebSocket 与代理超时",
- "summary": "让代理后的事件逐条到达、WebSocket 完成升级,并记录断线后能恢复到哪里。",
- "objectives": [
- "先确认应用端点本身工作",
- "按两类连接配置各自代理规则",
- "对比逐条到达、升级响应和停止后的连接行为"
- ],
- "concepts": [
- {
- "title": "SSE是持续HTTP响应",
- "body": "结合“给SSE路径增加独立location”的实际检查回顾。服务端不断发送text/event-stream,若代理缓冲,用户会很晚才看到一整块内容。proxy_buffering off影响响应,proxy_request_buffering影响请求上传,二者不能互相替代。心跳间隔应小于经过的各层空闲超时。"
- },
- {
- "title": "WebSocket需要显式升级",
- "body": "结合“给WebSocket路径增加升级配置”的实际检查回顾。Upgrade和Connection是逐跳头,反向代理需显式传递。上游返回101后转为双向隧道。HTTP的普通30秒响应预算不适用于长连接,但无限超时会隐藏失联客户端,仍需ping/pong和断线重连。"
- },
- {
- "title": "就绪与已建立连接",
- "body": "结合“验证逐条到达与断线恢复”的实际检查回顾。从负载均衡摘除实例通常只阻止新连接,已有SSE/WS不会自动搬到另一进程。发布需要排空窗口、合理的连接寿命和客户端重连/续传。不要在长流期间持续占用数据库事务。"
- }
- ],
- "steps": [
- {
- "title": "明确本课端点前置",
- "body": "API+数据库模块本身没有SSE/WS。先将API服务开发模块完成的流式与WebSocket示例路由整合到shop-service,确认实际路径;下文使用/stream与/ws作为本次教学适配路径,若原代码不同需同时改代理和客户端,不能声称下载包自动有端点。",
- "code": "# 先绕过Nginx验证实际应用端点;服务必须已经整合这些路由\ncurl -N --max-time 10 http://127.0.0.1:8000/stream\n# WebSocket用API模块已有客户端连接 ws://127.0.0.1:8000/ws",
- "lang": "bash",
- "expected": "直连 /stream 能收到多条事件,API 课客户端直连 /ws 可收发;404 表示路由未整合或路径不一致,应先停在此步修正。",
- "explanation": "代理只能转发已有能力,不能凭新增 location 生成流式路由;先绕过代理建立基线才能定位后续差异。"
- },
- {
- "title": "在http上下文新增Upgrade映射",
- "body": "先确认 /etc/nginx/nginx.conf 的 http 块已包含 /etc/nginx/conf.d/*.conf,再在下面指定文件保存 map。不要额外套一层 http,保存后运行 nginx -t。",
- "code": "map $http_upgrade $connection_upgrade {\n default upgrade;\n '' close;\n}",
- "lang": "nginx",
- "filename": "/etc/nginx/conf.d/shop-upgrade.conf",
- "expected": "Upgrade 映射文件位于 http 已包含的 conf.d 目录,nginx -t 无 map directive is not allowed here 错误。",
- "explanation": "map 在 HTTP 上下文定义变量,随后 WebSocket location 使用它选择升级或关闭的连接头。"
- },
- {
- "title": "给SSE路径增加独立location",
- "body": "将此location添加到本次shop-service的server块,和已有location /并列。代理重写规则保持路径原样;示例读取空闲超时75s需配合应用每15–25秒心跳,实际周期以API模块实现为准。",
- "code": "location /stream {\n proxy_pass http://127.0.0.1:8000;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_buffering off;\n proxy_cache off;\n proxy_read_timeout 75s;\n proxy_send_timeout 75s;\n gzip off;\n}",
- "lang": "nginx",
- "filename": "/etc/nginx/sites-available/shop-service",
- "expected": "/stream 的 location 与根路径并列,关闭代理缓冲,读取空闲预算长于实际心跳间隔。",
- "explanation": "持续 HTTP 响应需要逐段传递;缓冲会把已到达代理的片段留住,心跳则帮助在业务暂时无数据时维持连接。"
- },
- {
- "title": "给WebSocket路径增加升级配置",
- "body": "同样添加到server块。TLS站点客户端使用wss://。proxy_read_timeout衡量两次读取之间的间隔,不是整条连接绝对存活时间;应用层需要心跳和会话过期规则。",
- "code": "location /ws {\n proxy_pass http://127.0.0.1:8000;\n proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection $connection_upgrade;\n proxy_set_header Host $host;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_read_timeout 75s;\n proxy_send_timeout 75s;\n}",
- "lang": "nginx",
- "filename": "/etc/nginx/sites-available/shop-service",
- "expected": "/ws 配置显式传递 Upgrade 与 Connection,使用 HTTP/1.1;HTTPS 页面客户端使用 wss 地址。",
- "explanation": "WebSocket 起初是 HTTP 请求,需要成功升级后才能双向传帧。空闲读取超时限制的是数据间隔,并非总连接寿命。"
- },
- {
- "title": "验证逐条到达与断线恢复",
- "body": "先nginx -t再reload;curl -N避免客户端输出缓冲混淆。使用API课已有WS客户端确认101和双向消息,然后在隔离VM重启API,记录客户端如何重连。SSE若没有event id/续传实现,应明确重连不能恢复已漏事件。",
- "code": "sudo nginx -t\nsudo systemctl reload nginx\ncurl -N --max-time 20 -H 'Host: shop.example.com' http://127.0.0.1/stream\n# 另一个终端观察本次站点日志与客户端重连\nsudo journalctl -u shop-api -f",
- "lang": "bash",
- "expected": "代理后的事件按次到达,WS 客户端观察到 101 和双向消息;重启 API 后记录真实断开、重连与漏事件情况。",
- "explanation": "重连只重建传输连接。没有事件编号与续传实现时,连接恢复并不证明中断期间的进度消息已经补齐。"
- }
- ],
- "checks": [
- "SSE通过Nginx仍逐条到达,未被缓冲成结束时一次输出。",
- "WebSocket成功返回101并双向传输,TLS环境使用wss。",
- "重启时长连接会断开且客户端有明确恢复行为,未宣称无损切换。"
- ],
- "pitfalls": [
- {
- "symptom": "服务端日志逐条输出,浏览器最后才看到全部内容",
- "cause": "Nginx或其他代理/客户端缓冲。",
- "fix": "逐层绕过代理对比,设置proxy_buffering off并用curl -N;检查上游应用是否正确发送SSE边界。"
- },
- {
- "symptom": "WS连接约60秒断开",
- "cause": "代理空闲超时与应用心跳不匹配。",
- "fix": "测量实际心跳周期,协调Nginx和前置负载均衡超时,不能只无限延长一层。"
- }
- ],
- "exercise": {
- "task": "readiness变为503后,已有WebSocket是否自动迁移到健康实例?",
- "hint": "TCP连接状态绑定原进程。",
- "answer": "不会。需要客户端断线重连到健康实例;若要续接会话,状态和事件序号必须保存在可共享存储中,而非只在单进程内存。"
- },
- "refs": [
- {
- "label": "Nginx:WebSocket代理",
- "url": "https://nginx.org/en/docs/http/websocket.html"
- },
- {
- "label": "Nginx:响应缓冲",
- "url": "https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering"
- },
- {
- "label": "Nginx:读取超时",
- "url": "https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout"
- }
- ],
- "production": {
- "scenario": "Agent流式结果和交互会话经过反向代理后仍应及时、可恢复地传输。",
- "decisions": [
- "SSE与WS按各自协议配置,不套普通短请求超时。",
- "客户端恢复协议与代理排空共同设计。"
- ],
- "failureDrill": "在隔离VM通过代理建立流和WS,再重启API,记录最后收到事件、断线时间与重连结果。",
- "acceptance": [
- "逐条传输有实际观测。",
- "没有把连接重连等同业务状态自动恢复。"
- ]
- },
- "intro": {
- "scenario": "商品导出进度在直连时逐条出现,经过代理却可能攒成一批,聊天式双向演示也可能无法握手。",
- "startingPoint": [
- "完成第 05 课,Nginx HTTP 入口与 API 直连均正常。",
- "将 API 模块已完成的流式和 WebSocket 演示整合到本次应用,并先确认实际 /stream、/ws 路径;基础商品工程不会自动包含它们。"
- ],
- "outcome": "让代理后的事件逐条到达、WebSocket 完成升级,并记录断线后能恢复到哪里。"
- }
- },
- {
- "id": "07",
- "group": "代理与发布",
- "title": "发布目录、原子切换与兼容回退",
- "summary": "准备独立 r002,迁移成功后切换进程,再通过商品和导出探针验证回退。",
- "objectives": [
- "检查新发布目录完整且权限正确",
- "在迁移成功后切换运行版本",
- "回退时保留数据并验证旧代码仍可读写"
- ],
- "concepts": [
- {
- "title": "目录切换不替换运行进程",
- "body": "结合“原子替换current并重启”的实际检查回顾。current软链原子替换只影响之后打开的路径,已经运行的Python仍持有旧代码、工作目录和内存。必须明确重启API与各任务进程,不能以软链已更新推断服务已更新。"
- },
- {
- "title": "迁移先兼容",
- "body": "结合“用一次性systemd作业运行迁移”的实际检查回顾。新迁移在切换前运行,旧进程仍可能访问数据库,所以结构必须向后兼容。任务名和消息参数也属于跨版本协议:旧消息可能在新worker上执行,新消息可能被旧worker拿到,升级时必须保留兼容或分版本队列。"
- },
- {
- "title": "可用性选择",
- "body": "结合“升级到两端口切换时先验证候选”的实际检查回顾。单API实例systemctl restart有短暂中断。本课先完成可回退的单实例发布;要平滑切换可在8001启动候选实例、验收后改Nginx upstream并reload,再排空旧实例。长连接仍需要重连协议,不能称为绝对零中断。"
- }
- ],
- "steps": [
- {
- "title": "准备r002完整目录",
- "body": "按第3课创建新目录r002、复制已验证源码、建立venv并安装目标锁,创建artifacts软链。不要覆盖r001,也不要在current目录边写代码边处理请求。下面只检查新目录满足条件。 deploy不加入运行组,路径检查通过sudo -u shop执行,保持发布权限与运行身份分开。",
- "code": "sudo -u shop test -f /srv/shop-service/releases/r002/app/main.py\nsudo -u shop test -f /srv/shop-service/releases/r002/tasks_lab/celery_app.py\nsudo -u shop test -x /srv/shop-service/releases/r002/.venv/bin/python\nsudo -u shop readlink -f /srv/shop-service/current",
- "lang": "bash",
- "expected": "shop 身份能读新 app/main.py 与任务入口、执行新 venv,r002 的 artifacts 指到共享结果目录,r001 保持可用。",
- "explanation": "预先完成独立目录避免请求读到半份上传代码;目录存在只是起点,运行身份的路径可达性还需实际确认。"
- },
- {
- "title": "用一次性systemd作业运行迁移",
- "body": "仅对专用练习数据库执行已审核迁移;EnvironmentFile使用API SQLAlchemy URL,迁移账号若已分离应改为独立/etc/shop-migrate.env。systemd-run --wait会报告进程结果;失败不继续切换。先保存当前revision与备份记录。 MySQL DDL隐式提交,失败后检查已经应用的语句和实际结构,保留兼容窗口;不得直接重复整份迁移或认为失败即零变更。",
- "code": "sudo systemd-run --unit=shop-migrate-r002 --wait --collect --property=User=shop --property=Group=shop --property=WorkingDirectory=/srv/shop-service/releases/r002 --property=EnvironmentFile=/etc/shop-service.env /srv/shop-service/releases/r002/.venv/bin/python -m alembic upgrade head",
- "lang": "bash",
- "expected": "一次性迁移报告成功退出,目标 revision 与实际 schema 对应;失败时不进入切换步骤。",
- "explanation": "迁移先为新旧版本准备兼容结构。MySQL DDL 可能已部分提交,退出失败不能直接推断数据库没有改变。"
- },
- {
- "title": "原子替换current并重启",
- "body": "在/srv/shop-service同一文件系统内先创建临时软链,再mv -T替换current。先检查current确实是软链且指向已记录的r001,禁止覆盖普通目录。root拥有该目录,运行用户不能劫持切换目标。",
- "code": "sudo -u shop test -L /srv/shop-service/current\nsudo -u shop readlink -f /srv/shop-service/current\nsudo ln -s releases/r002 /srv/shop-service/current.next\nsudo mv -Tf /srv/shop-service/current.next /srv/shop-service/current\nsudo systemctl restart shop-api shop-worker@fast shop-worker@bulk shop-maintenance@publisher shop-maintenance@recovery\ncurl --fail --max-time 5 http://127.0.0.1:8000/ready",
- "lang": "bash",
- "expected": "current 原本是 r001 软链,替换后解析到 r002;重启后 API 与全部任务角色使用新代码。",
- "explanation": "同文件系统的软链替换避免路径短暂指向半成品,但已运行进程仍持有旧代码,所以必须另外重启或切换流量。"
- },
- {
- "title": "验证新版本并演练应用回退",
- "body": "使用真实创建读取、版本冲突和持久导出链路验收。若触发预先定义的错误率/延迟阈值且数据库仍兼容r001,则用同样原子软链方式切回r001并重启全部角色。不自动downgrade数据库,不删除新数据。",
- "code": "# 仅在兼容性已验证且current为软链时回退\nsudo ln -s releases/r001 /srv/shop-service/current.rollback\nsudo mv -Tf /srv/shop-service/current.rollback /srv/shop-service/current\nsudo systemctl restart shop-api shop-worker@fast shop-worker@bulk shop-maintenance@publisher shop-maintenance@recovery\ncurl --fail --max-time 5 http://127.0.0.1:8000/ready",
- "lang": "bash",
- "expected": "切回 r001 后商品读写、版本冲突和导出验收通过,已有新数据仍保留;不兼容时停止机械回退。",
- "explanation": "回退应用只改变程序版本,数据库仍是迁移后的结构;兼容性测试决定能否安全使用旧程序。"
- },
- {
- "title": "升级到两端口切换时先验证候选",
- "body": "复制API unit为shop-api-candidate,WorkingDirectory与venv绝对固定到r002,端口改8001。先直接访问8001/ready与业务探针,再把Nginx proxy_pass由8000改8001,nginx -t后reload。观察旧连接排空再停止旧服务;这一步是可选演练,不用current软链同时控制两个版本。",
- "code": "curl --fail --max-time 5 http://127.0.0.1:8001/ready\nsudo nginx -t\nsudo systemctl reload nginx\n# 确认流量已切换并等待已定义排空窗口后,停止旧shop-api。",
- "lang": "bash",
- "expected": "可选候选实例在 8001 直接就绪且业务探针成功后,代理切到 8001;旧连接排空后才停 8000。",
- "explanation": "候选版固定目录与端口使两个版本可并存;仅替换共同 current 软链无法独立控制各自版本。"
- }
- ],
- "checks": [
- "r001与r002是独立完整发布物,current切换前后路径可核对。",
- "迁移失败停止发布,应用回退只在数据库兼容范围内执行。",
- "实际进程已重启或代理已切换,版本验证不只检查软链。"
- ],
- "pitfalls": [
- {
- "symptom": "current已变但请求仍返回旧行为",
- "cause": "旧Python进程没有退出,仍持有旧代码。",
- "fix": "检查systemd MainPID与启动时间,执行受控重启或候选端口切换。"
- },
- {
- "symptom": "旧worker无法读取新消息参数",
- "cause": "任务协议变更未考虑队列中的存量消息。",
- "fix": "保持稳定任务名与兼容参数,必要时用新版本队列并先排空旧队列。"
- }
- ],
- "exercise": {
- "task": "新版本删除了一列,发现问题后还能直接切r001吗?",
- "hint": "旧代码是否仍读取或写入该列?",
- "answer": "通常不能保证。应采用expand-contract保留回退窗口;已执行不兼容删除时优先前向修复,或走经验证的数据恢复方案并评估数据损失,不把软链切回当完整回滚。"
- },
- "refs": [
- {
- "label": "Alembic:迁移命令",
- "url": "https://alembic.sqlalchemy.org/en/latest/tutorial.html"
- },
- {
- "label": "Nginx:控制与reload",
- "url": "https://nginx.org/en/docs/control.html"
- },
- {
- "label": "systemd-run:一次性作业(Ubuntu 24.04)",
- "url": "https://manpages.ubuntu.com/manpages/noble/man1/systemd-run.1.html"
- }
- ],
- "production": {
- "scenario": "发布不仅换代码,还要协调数据库、队列协议和已有连接。",
- "decisions": [
- "每版独立目录与依赖,迁移先兼容。",
- "记录可回退版本和明确触发条件。"
- ],
- "failureDrill": "在隔离VM让r002故意缺一个必要配置,观察失败后按兼容路径切回r001并验证数据。",
- "acceptance": [
- "恢复耗时与业务验证有记录。",
- "不兼容数据库变更明确阻止机械回退。"
- ]
- },
- "intro": {
- "scenario": "r002 已准备发布,运行中的 r001 仍在处理商品请求;需要可核对地切换,并保留兼容回退路径。",
- "startingPoint": [
- "完成第 04—06 课,r001 服务和入口经过验收;保留当前版本、迁移版本及备份证据。",
- "当前路径必须是指向 releases/r001 的软链;在隔离 VM 发布,数据库迁移已验证兼容旧代码。"
- ],
- "outcome": "准备独立 r002,迁移成功后切换进程,再通过商品和导出探针验证回退。"
- }
- },
- {
- "id": "08",
- "group": "运维与验收",
- "title": "journald、日志轮转与可执行告警",
- "summary": "从日志定位一次事件,限制日志占用,并用暂停派发验证可执行的告警处理流程。",
- "objectives": [
- "按时间和角色检索日志",
- "验证日志保留与磁盘预算",
- "把积压阈值连到实际检查和恢复动作"
- ],
- "concepts": [
- {
- "title": "日志回答一次事件",
- "body": "结合“按服务与时间查日志”的实际检查回顾。API记录request_id、方法、路由、状态码与耗时,任务记录job_id、阶段、attempt与异常类别。数据库密码、Authorization、商品描述和完整提示词不进普通日志。关联ID连接各进程信息,不靠长堆栈猜同一次请求。"
- },
- {
- "title": "指标回答是否持续异常",
- "body": "结合“定义服务指标与告警动作”的实际检查回顾。错误率、延迟分位数、数据库池等待、最老outbox年龄和队列等待时间要看时间窗口。只看worker存活会漏掉队列不消费、publisher停滞和结果文件失效。告警需阈值、持续时间、负责人和runbook。"
- },
- {
- "title": "日志本身会占资源",
- "body": "结合“加载并检查Nginx轮转”的实际检查回顾。无限日志可填满磁盘导致数据库写入失败。journald设容量和保留期,Nginx使用发行版logrotate配置并核对新文件路径是否匹配;不要直接截断数据库或正在写入的日志文件。"
- }
- ],
- "steps": [
- {
- "title": "按服务与时间查日志",
- "body": "这些命令只读。先定位首次异常时间和部署版本,再扩大范围;生产收集日志时脱敏并限制导出范围,不直接输出EnvironmentFile。",
- "code": "sudo journalctl -u shop-api --since '15 minutes ago' --no-pager\nsudo journalctl -u shop-worker@fast -u shop-maintenance@publisher --since today --no-pager\nsudo systemctl show shop-api -p MainPID -p NRestarts -p ExecMainStatus\nsudo journalctl --disk-usage",
- "lang": "bash",
- "expected": "能按首次异常时间找到 API/Worker/派发日志,MainPID、NRestarts 和退出状态有明确实际值。",
- "explanation": "按服务和时间缩小范围可建立事件顺序;重启次数是排查线索,不能替代具体异常与用户影响证据。"
- },
- {
- "title": "配置journald容量",
- "body": "先执行 sudo install -d /etc/systemd/journald.conf.d,再用 sudoedit 保存下面的 drop-in 文件。值为独立练习机起点,保留期与空间应按实际故障调查需求调整。",
- "code": "[Journal]\nStorage=persistent\nSystemMaxUse=500M\nSystemKeepFree=1G\nMaxRetentionSec=14day",
- "lang": "ini",
- "filename": "/etc/systemd/journald.conf.d/shop-limits.conf",
- "expected": "journald drop-in 含持久存储、500M 使用预算、1G 保留空间与 14 天保留上限。",
- "explanation": "日志也是磁盘使用者,容量和保留期互相约束;本机保留不能替代整机故障时所需的异机日志副本。"
- },
- {
- "title": "加载并检查Nginx轮转",
- "body": "上一步配置目录和文件已建立,执行下面命令加载并检查。确认 Nginx 既有轮转规则覆盖 shop-access/error,不为同一文件重复增加规则;logrotate -d 只做检查。",
- "code": "sudo systemctl restart systemd-journald\nsudo journalctl --disk-usage\nsudo cat /etc/logrotate.d/nginx\nsudo logrotate -d /etc/logrotate.conf\ndf -h /var /srv",
- "lang": "bash",
- "expected": "重启后能读取 journal 占用,Nginx 既有规则覆盖本课日志,logrotate -d 无重复匹配错误。",
- "explanation": "调试模式检查轮转计划而不执行删除或压缩;重复规则可能让同一日志被错误处理。"
- },
- {
- "title": "定义服务指标与告警动作",
- "body": "把下列初始规则保存monitoring-runbook.md并按实际SLO调整。探针应在独立监控主机执行,避免本机整体宕机时连监控一起消失;课件没有配置真实通知渠道,不能声称告警已接通。",
- "code": "API就绪:/ready 连续3次失败(间隔10s)→ 检查依赖/发布,暂停继续放量\nHTTP错误率:5分钟窗口5xx超过业务阈值 → 按request_id查首次异常\n队列:最老queued/outbox等待超过任务SLO → 检查publisher、broker与worker\n磁盘:剩余空间低于容量预算,且持续下降 → 排查日志/备份,禁止先删数据库文件\n证书:剩余天数低于续期排障窗口 → 查看Certbot定时任务与验证路径\n告警必须包含:环境、版本、首次时间、当前值、负责人、runbook链接",
- "lang": "text",
- "filename": "monitoring-runbook.md",
- "expected": "runbook 每条阈值都有观测窗口、检查对象和负责人;通知未接入时明确注明手工执行。",
- "explanation": "告警需要从“数值异常”连接到“下一步能做什么”。在同一机器监控自己无法覆盖整机不可用。"
- },
- {
- "title": "用一次故障验证告警流程",
- "body": "隔离VM暂停publisher,提交一个持久导出任务,等待超过练习阈值,手工执行监控SQL观察最老未发事件。恢复publisher后应完成投递,记录从发现到恢复用时;真实通知渠道尚未接通则明确标记为手工演练。",
- "code": "sudo systemctl stop shop-maintenance@publisher\n# 按任务模块提交一个新job,并查询export_outbox最老未发送时间\nsudo systemctl start shop-maintenance@publisher\nsudo journalctl -u shop-maintenance@publisher --since '5 minutes ago' --no-pager",
- "lang": "bash",
- "expected": "暂停 publisher 后待发送事件年龄增长,恢复后逐渐派发完成,记录实际发现和恢复用时。",
- "explanation": "API 仍可受理并不保证任务已交给 Worker;最老待发送年龄能发现消息进入 broker 之前的积压。"
- }
- ],
- "checks": [
- "能够按request_id/job_id定位跨进程阶段,不打印业务商品描述与凭据。",
- "journald容量可查询,Nginx日志匹配一个有效轮转规则。",
- "至少一个故障从指标触发到恢复完成演练,未接通通知的部分明确说明。"
- ],
- "pitfalls": [
- {
- "symptom": "接口变慢但没有报错日志",
- "cause": "日志只覆盖异常,未记录耗时和排队。",
- "fix": "加入请求/任务阶段耗时指标,区分数据库等待、执行与队列延迟。"
- },
- {
- "symptom": "磁盘满后直接删除大文件仍未释放空间",
- "cause": "文件可能仍由进程持有,或误删了重要数据。",
- "fix": "先用lsof等确认持有者和文件类型,按服务支持方式轮转;不能删除MySQLDATA文件排空间。"
- }
- ],
- "exercise": {
- "task": "worker进程active就说明任务系统健康吗?",
- "hint": "任务经过哪些独立组件?",
- "answer": "不说明。要同时检查outbox最老年龄、broker连通、队列等待、任务成功率和输出可读性;publisher停掉时worker仍可以一直active。"
- },
- "refs": [
- {
- "label": "systemd:journalctl(Ubuntu 24.04)",
- "url": "https://manpages.ubuntu.com/manpages/noble/man1/journalctl.1.html"
- },
- {
- "label": "systemd:journald配置(Ubuntu 24.04)",
- "url": "https://manpages.ubuntu.com/manpages/noble/man5/journald.conf.5.html"
- },
- {
- "label": "Nginx:日志模块",
- "url": "https://nginx.org/en/docs/http/ngx_http_log_module.html"
- }
- ],
- "production": {
- "scenario": "故障必须在磁盘耗尽或用户集中反馈前被发现,并能关联到具体发布与任务。",
- "decisions": [
- "告警对应可执行排查步骤。",
- "日志容量和敏感字段有明确限制。"
- ],
- "failureDrill": "暂停练习publisher形成积压,检查最老待发年龄,恢复后核对任务完成与指标下降。",
- "acceptance": [
- "告警条件对应真实业务影响。",
- "发现、定位、恢复耗时都有记录。"
- ]
- },
- "intro": {
- "scenario": "服务能运行,但用户说刚才导出卡住了;需要找到当时哪个进程异常,也不能让日志占满磁盘。",
- "startingPoint": [
- "完成第 04—07 课,systemd 管理 API、Worker 与维护进程,Nginx 站点有独立日志。",
- "在隔离 VM 查询真实服务名和最近一次正常导出的 job_id;本课通知渠道尚未接入时只做手工演练。"
- ],
- "outcome": "从日志定位一次事件,限制日志占用,并用暂停派发验证可执行的告警处理流程。"
- }
- },
- {
- "id": "09",
- "group": "运维与验收",
- "title": "数据库备份、异机加密与恢复目标",
- "summary": "在独立恢复环境读回商品、迁移版本和任务记录,并计算实际恢复时间与可能丢失窗口。",
- "objectives": [
- "用匹配 MySQL 版本的工具生成转储",
- "让备份端只持有加密公钥",
- "恢复后同时核对数据库与文件引用"
- ],
- "concepts": [
- {
- "title": "一致性快照与DDL窗口",
- "body": "结合“生成备份并检查可读目录”的实际检查回顾。mysqldump --single-transaction从InnoDB一致性快照读取数据;非事务表和并发DDL需要其他协调措施,成功退出不是恢复能力的证明。"
- },
- {
- "title": "RPO取决于快照和binlog保留",
- "body": "结合“核对恢复内容并计算差距”的实际检查回顾。每日逻辑转储无法满足分钟级RPO。需要快照对应的位置或GTID,以及完整、连续、异地保存的binlog,再在隔离实例演练指定时间恢复。"
- },
- {
- "title": "恢复包含数据库和外部结果",
- "body": "结合“核对恢复内容并计算差距”的实际检查回顾。恢复SQL后还要核对任务result_ref对应的artifacts或对象存储。恢复环境不得连接真实broker并意外执行旧任务;账号、权限、事件调度器与外发都应单独验收。"
- }
- ],
- "steps": [
- {
- "title": "为练习备份建立目录与凭据文件",
- "body": "使用root专用目录保存备份。用sudoedit建立/etc/shop-backup.cnf,内容是MySQL客户端[client]配置,权限600;密码按MySQL选项文件规则加引号与转义,不使用URL编码。备份账号需具备SELECT、SHOW VIEW、TRIGGER等权限;包含routines/events时另核对对应权限。备份客户端使用8.4镜像,Docker命令由管理员执行。",
- "code": "sudo install -d -o root -g root -m 700 /var/backups/shop-service\nsudoedit /etc/shop-backup.cnf\n# 文件内容(用编辑器填写真实值;不复制秘密到终端输出):\n# [client]\n# host=127.0.0.1\n# port=3307\n# protocol=TCP\n# user=BACKUP_USER\n# password=\"ACTUAL_PASSWORD\"\n# default-character-set=utf8mb4\nsudo chown root:root /etc/shop-backup.cnf\nsudo chmod 600 /etc/shop-backup.cnf",
- "lang": "bash",
- "expected": "备份目录为 root 专用 700,客户端选项文件为 600,真实账号和地址在文件里且未打印密码。",
- "explanation": "MySQL 客户端选项文件的密码语法不同于 URL;受控文件让备份程序读取凭据,而无需将它展开到命令行。"
- },
- {
- "title": "生成备份并检查可读目录",
- "body": "以下仅用于独立Linux VM,工具容器用host网络访问配置中的本机3307;它不启动数据库服务。--defaults-extra-file必须紧跟程序名。--single-transaction要求业务表均为InnoDB,备份期间不要执行DDL。示例不修改GTID集合;创建复制节点另按GTID一致性流程处理。固定文件名用于本次演练,已存在时换新编号;失败文件不能覆盖唯一可用备份。",
- "code": "sudo docker run --rm mysql:8.4 mysqldump --version\nsudo docker run --rm --network host --mount type=bind,source=/etc/shop-backup.cnf,target=/run/secrets/client.cnf,readonly --mount type=bind,source=/var/backups/shop-service,target=/backup mysql:8.4 mysqldump --defaults-extra-file=/run/secrets/client.cnf --single-transaction --routines --events --triggers --no-tablespaces --set-gtid-purged=OFF --hex-blob --default-character-set=utf8mb4 --result-file=/backup/shop-lab-01.sql shop_lab\n# 仅退出0后记录摘要;摘要不能代替实际恢复。\nsudo sha256sum /var/backups/shop-service/shop-lab-01.sql",
- "lang": "bash",
- "expected": "工具打印 MySQL 8.4 版本,转储正常退出,生成的本轮 SQL 文件非空并留下摘要。",
- "explanation": "匹配版本减少工具行为差异;InnoDB 的一致性快照依赖无并发 DDL,成功退出仍需要之后真实导入验证。"
- },
- {
- "title": "使用接收方公钥加密",
- "body": "本步骤示例使用age,先按官方文档安装受信任版本。在恢复端生成并保管age私钥,备份VM仅保存接收方公钥;AGE_RECIPIENT替换真实公钥。此课不实际向外部发送数据,只有自己拥有且已获准的备份主机/存储才执行传输。",
- "code": "# 备份VM,仅持有接收方公钥\nsudo age -r AGE_RECIPIENT -o /var/backups/shop-service/shop-lab-01.sql.age /var/backups/shop-service/shop-lab-01.sql\nsudo sha256sum /var/backups/shop-service/shop-lab-01.sql.age\n# 使用已验证主机密钥的独立备份目标;替换主机、账号、路径后再执行\n# scp shop-lab-01.sql.age backup@AUTHORIZED_BACKUP_HOST:/approved/shop-service/",
- "lang": "bash",
- "expected": "生成 .sql.age 密文与对应摘要;只有已验证公钥可用于加密,备份 VM 不持有恢复私钥。",
- "explanation": "备份端能生成密文却不能自行解密,恢复能力取决于独立保存的私钥;哈希检查文件完整性而不提供解密能力。"
- },
- {
- "title": "在独立恢复环境解密与恢复",
- "body": "只在独立恢复VM和空shop_restore库操作。先解密并与原SQL摘要核对;在恢复主机准备mysql8.4客户端及权限600的/secure/restore.cnf([client]内填恢复主机/端口/管理员/密码)。未指定--databases的转储不会自动切换源库。记录导入时间和退出状态,恢复API使用独立broker并禁用真实外发。",
- "code": "age -d -i /secure/recovery-age-key.txt -o shop-lab-01-restored.sql shop-lab-01.sql.age\nsha256sum shop-lab-01-restored.sql\nmysql --version\nmysql --defaults-extra-file=/secure/restore.cnf -e \"CREATE DATABASE shop_restore CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;\"\ndate -u\nmysql --defaults-extra-file=/secure/restore.cnf --default-character-set=utf8mb4 shop_restore < shop-lab-01-restored.sql\ndate -u",
- "lang": "bash",
- "expected": "解密后的 SQL 摘要与原文件一致,导入新的空 shop_restore 正常结束,恢复应用与真实队列/外发保持隔离。",
- "explanation": "恢复验证应与原环境分开,防止旧任务重放产生重复效果;导入未带 --databases 的转储可明确选择目标库。"
- },
- {
- "title": "核对恢复内容并计算差距",
- "body": "在恢复库执行下列SQL,再将隔离API指向恢复库完成读取与约束验收。不要让恢复API连接生产队列并重新执行旧任务;禁用外发或使用独立broker,处理已完成/未完成任务的恢复语义。",
- "code": "SELECT DATABASE(),@@version,@@session.time_zone;\nSELECT count(*) FROM products;\nSELECT version_num FROM alembic_version;\nSELECT status, count(*) FROM export_jobs GROUP BY status;\n-- 另行核对shared/artifacts备份或对象存储中的result_ref文件是否存在。",
- "lang": "sql",
- "expected": "商品数量、迁移版本、任务状态分布与恢复点记录相符,已完成任务的 result_ref 文件可读,并记录耗时与快照时间差。",
- "explanation": "RPO 是可能丢失的数据时间窗口,RTO 是恢复服务所需时间;只有数据库而没有结果文件仍不能完成下载服务。"
- }
- ],
- "checks": [
- "备份文件目录可读,解密后摘要与原始dump一致。",
- "恢复发生在独立shop_restore,原数据库未覆盖。",
- "记录实际恢复耗时、备份时间点及任务输出核验,能判断是否满足目标RPO/RTO。"
- ],
- "pitfalls": [
- {
- "symptom": "备份在但恢复时没有解密私钥",
- "cause": "密钥只存在已损坏的原主机。",
- "fix": "把恢复密钥放在独立受控保管位置并演练访问,备份机只需公钥。"
- },
- {
- "symptom": "恢复数据库后任务重复发送邮件或调用外部接口",
- "cause": "恢复环境连上生产broker或仍启用外发调度。",
- "fix": "恢复环境使用独立依赖并禁用外发,先核对任务状态和恢复策略,再受控恢复业务。"
- }
- ],
- "exercise": {
- "task": "每天备份一次,能否承诺最多丢5分钟数据?",
- "hint": "最后成功备份到故障发生之间可能多长?",
- "answer": "不能。要达到分钟级RPO,应设计并验证连续binlog归档与时间点恢复或其他受控方案,同时监控归档延迟;逻辑备份仍可作为补充,但目标必须有实际恢复证据。"
- },
- "refs": [
- {
- "label": "age官方用法",
- "url": "https://github.com/FiloSottile/age"
- },
- {
- "label": "MySQL 8.4:备份方法",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/backup-methods.html"
- },
- {
- "label": "MySQL 8.4:binlog时间点恢复",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/point-in-time-recovery.html"
- },
- {
- "label": "MySQL 8.4:选项文件",
- "url": "https://dev.mysql.com/doc/refman/8.4/en/option-files.html"
- }
- ],
- "production": {
- "scenario": "主机或数据库损坏后,需要在业务可接受时间内恢复数据且避免重复外部副作用。",
- "decisions": [
- "备份异机加密,密钥另行保管。",
- "恢复库和队列隔离,业务验收计入RTO。",
- "binlog保留期与备份周期、恢复速度配套;--set-gtid-purged=OFF的独立逻辑恢复不直接充当复制节点初始化流程。"
- ],
- "failureDrill": "在独立恢复环境取回、解密、恢复一份练习备份,验证行数、约束、任务状态与输出文件。",
- "acceptance": [
- "备份可恢复而非仅存在。",
- "恢复不连接生产外发渠道。"
- ]
- },
- "intro": {
- "scenario": "数据库机器完全丢失时,本机卷和本机备份可能一起失去;先验证一份加密备份能在独立环境恢复。",
- "startingPoint": [
- "完成 Docker 第 05 课及 Linux 第 03—08 课,已有商品与持久任务记录、结果文件。",
- "准备独立空恢复库与恢复端密钥;只向自己拥有且已获准的备份目标传输,未提供目标时本课不执行外发。"
- ],
- "outcome": "在独立恢复环境读回商品、迁移版本和任务记录,并计算实际恢复时间与可能丢失窗口。"
- }
- },
- {
- "id": "10",
- "group": "运维与验收",
- "title": "完整发布、回退与故障 Runbook",
- "summary": "交付一份包含发布、业务验收、限定故障与恢复证据的运行手册。",
- "objectives": [
- "用真实商品和导出任务检验完整链路",
- "逐个故障观察并恢复到已知基线",
- "记录已实测范围和仍未验证的部署能力"
- ],
- "concepts": [
- {
- "title": "验收从用户路径出发",
- "body": "结合“执行数据库与任务业务验收”的实际检查回顾。/live只证明进程回应,/ready验证部分依赖,真正的验收要创建、读取、条件更新以及提交持久任务并读取结果。每项记录环境、版本、时间、预期与实际,不能把课件命令当作已执行证据。"
- },
- {
- "title": "恢复先找边界",
- "body": "结合“逐个注入隔离故障并恢复”的实际检查回顾。API代码错误可回退兼容版本;数据库锁等待需找阻塞事务;磁盘不足要处理明确的数据来源;队列积压先查publisher与worker。没有证据时重启所有服务可能放大故障并丢失现场。"
- },
- {
- "title": "进程与Docker部署差异",
- "body": "结合“对比Docker部署同一验收”的实际检查回顾。systemd模式检查unit、绝对venv和主机端口;Docker模式检查镜像digest、Compose服务DNS、容器资源和卷。二者共享迁移、备份、幂等、代理与告警要求,不能因容器化省略数据库验收。"
- }
- ],
- "steps": [
- {
- "title": "建立发布检查单",
- "body": "新建/srv/shop-service/shared/release-runbook.md供管理员维护,运行用户无需写此文件。填入真实版本、依赖地址和负责人,不在文档写秘密。",
- "code": "发布前:目标VM/域名核对;当前版本与revision记录;备份恢复证据;兼容窗口\n发布准备:新目录/镜像验证;锁定依赖;配置权限;迁移SQL审核\n执行:唯一迁移成功 → 启动候选/切换版本 → 业务探针 → 观察窗口\n验收:API CRUD/version409;幂等重试;job/outbox/worker/结果文件一致\n回退:触发条件、旧版本、数据库兼容限制、任务协议限制、恢复负责人\n证据:命令结果、日志时间、指标曲线、未验证项目",
- "lang": "text",
- "filename": "release-runbook.md",
- "expected": "管理员维护的 runbook 填入实际环境、当前/目标版本、迁移、备份和负责人,不含秘密值。",
- "explanation": "发布记录先确定边界与成功条件,才能判断后续异常是否应停止放量、回退或前向修复。"
- },
- {
- "title": "执行不带秘密的基础探针",
- "body": "在VM检查服务和磁盘,客户端检查真正入口。HTTPS仅在已配置自己域名时执行;未配置证书则通过本机HTTP Host探针,不使用-k冒充TLS成功。",
- "code": "sudo systemctl is-active shop-api shop-worker@fast shop-worker@bulk shop-maintenance@publisher shop-maintenance@recovery nginx\ncurl --fail --max-time 5 http://127.0.0.1:8000/ready\ncurl --fail --max-time 10 https://YOUR_DOMAIN/ready\ndf -h /srv /var\nsudo journalctl -u shop-api --since '5 minutes ago' --no-pager",
- "lang": "bash",
- "expected": "所选路线服务 active,直连及实际入口 /ready 成功,磁盘有余量;没有真实域名时用已配置 HTTP 入口并注明。",
- "explanation": "直连、入口和磁盘分别覆盖应用、代理以及基础容量;单一 200 响应不能替代这些不同层检查。"
- },
- {
- "title": "执行数据库与任务业务验收",
- "body": "API+数据库最终脚本先验证创建、PATCH version与409;任务侧使用以下一次性systemd作业从真实products表选一条并调用最终state_store.submit_snapshot。该函数包含job/outbox原子写入,不手工再delay。输出job_id后等worker完成,再在同一工作目录和环境运行verify_terminal.py JOB_ID,检查artifact与固定payload一致。",
- "code": "# Bash:选真实商品并提交,配置由systemd读取,不source秘密文件。\nsudo systemd-run --unit=shop-submit-probe --wait --collect --pipe --property=User=shop --property=Group=shop --property=WorkingDirectory=/srv/shop-service/current/tasks_lab --property=EnvironmentFile=/etc/shop-tasks.env /srv/shop-service/current/.venv/bin/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\n# 替换为上一步真实JOB_ID,等待任务完成后核对结果。\nsudo systemd-run --unit=shop-verify-probe --wait --collect --pipe --property=User=shop --property=Group=shop --property=WorkingDirectory=/srv/shop-service/current/tasks_lab --property=EnvironmentFile=/etc/shop-tasks.env /srv/shop-service/current/.venv/bin/python verify_terminal.py JOB_ID",
- "lang": "bash",
- "expected": "商品 CRUD 与冲突探针符合预期,提交返回真实 job_id,verify_terminal.py 核对成功并能读取同一固定快照文件。",
- "explanation": "受理、执行和结果三个阶段分别留下持久证据,避免把一次 202 或 Worker 在线当作整条链路完成。"
- },
- {
- "title": "逐个注入隔离故障并恢复",
- "body": "一次只注入一个故障,先记录预期,完成恢复后再下一个。不在生产或唯一远程管理入口执行;SSH/UFW练习必须保留控制台和回退timer。",
- "code": "故障 | 首先检查 | 预期恢复\nAPI停止 | systemctl status / Nginx上游日志 | 启动已验证版本,/ready恢复\n错误DB凭据 | API异常类别 / 脱敏配置来源 | 修正配置并重启对应进程\npublisher停止 | 最老outbox年龄 / unit状态 | 启动publisher,任务完成\nworker超时退出 | job租约 / attempts / recovery日志 | 过期恢复,幂等处理\n新版本不兼容 | revision与旧版兼容表 | 停止机械回退,前向修复或恢复计划\n备份恢复 | 独立恢复库 / 解密与结果引用 | 满足已测RPO/RTO",
- "lang": "text",
- "expected": "每次只出现一种受控异常,恢复后重新通过基础与业务探针,再进入下一个案例。",
- "explanation": "一次改变一个因素才能把现象与动作关联;恢复命令失败或目标不明确时应停止注入而非继续加压。"
- },
- {
- "title": "对比Docker部署同一验收",
- "body": "若选择Docker路线,API仍由Nginx代理回环发布端口;用下列只读命令替代unit检查。不得同时让systemd API与Compose API争抢8000,先明确使用哪条运行主线。最终报告列出已实机验证和未验证项目。",
- "code": "docker compose --env-file .env.compose ps -a\ndocker compose --env-file .env.compose logs --tail 100 api worker worker-bulk publisher recovery\ndocker compose --env-file .env.compose images\ndocker compose --env-file .env.compose stats --no-stream",
- "lang": "bash",
- "expected": "Docker 路线可读出对应角色、镜像版本和使用量;同一 8000 端口没有另一套 systemd API 争用。",
- "explanation": "进程监督入口不同,商品与任务验收条件仍相同;最终结论必须注明实际选择并验证了哪条运行路线。"
- }
- ],
- "checks": [
- "有一次完整发布与兼容回退的实际执行记录,而不只是计划。",
- "API与任务验收核对数据库最终状态和输出文件,失败后恢复验证完成。",
- "未进行的远程部署、证书、异机备份或告警通知明确标记未验证。"
- ],
- "pitfalls": [
- {
- "symptom": "所有组件进程都active但用户任务一直queued",
- "cause": "存活检查未覆盖业务链路与积压年龄。",
- "fix": "核对job/outbox、publisher投递、队列路由和worker注册任务,最后验证result_ref。"
- },
- {
- "symptom": "为了快速恢复反复重启所有组件",
- "cause": "未区分故障层,重启破坏现场并触发重复任务。",
- "fix": "一次验证一个假设,先记录错误、版本和状态,再执行最小恢复动作。"
- }
- ],
- "exercise": {
- "task": "向同事交付部署方案时,至少需要哪些可审查证据?",
- "hint": "让对方不依赖你的终端历史也能复现。",
- "answer": "提供版本/镜像标识、依赖锁、配置项名称与权限、迁移状态、探针结果、任务最终状态、日志时间、回退边界和恢复耗时。秘密只给受控存储位置,不复制值。未执行的步骤标为待验证。"
- },
- "refs": [
- {
- "label": "Ubuntu Server文档",
- "url": "https://documentation.ubuntu.com/server/"
- },
- {
- "label": "systemd:systemctl(Ubuntu 24.04)",
- "url": "https://manpages.ubuntu.com/manpages/noble/man1/systemctl.1.html"
- },
- {
- "label": "Docker:生产Compose",
- "url": "https://docs.docker.com/compose/how-tos/production/"
- }
- ],
- "production": {
- "scenario": "学员需要独立发布一个可排错、可回退、可恢复的后端服务。",
- "decisions": [
- "验收覆盖用户路径与持久状态。",
- "故障按层定位,恢复动作最小且有证据。"
- ],
- "failureDrill": "在专用VM完成API停止、publisher停止和兼容版本回退三项,记录发现、恢复时间与数据结果;剩余矩阵逐项标记。",
- "acceptance": [
- "有可交付runbook和执行证据。",
- "没有把未实机验证的命令称为已部署成功。"
- ]
- },
- "intro": {
- "scenario": "把此前的登录、配置、进程、入口和备份串起来,完成一次别人能照着复核的商品服务发布。",
- "startingPoint": [
- "完成第 01—09 课,已选择 Linux/systemd 或 Docker 一条实际运行主线。",
- "确认当前商品读写、任务导出和备份恢复都有实际记录;保留控制台、当前版本和明确隔离目标。"
- ],
- "outcome": "交付一份包含发布、业务验收、限定故障与恢复证据的运行手册。"
- }
- }
- ]
|