![云厂商技术支持部门的原话:元数据库删了,我们也恢复不了[特殊字符]](http://pic.xiahunao.cn/yaotu/云厂商技术支持部门的原话:元数据库删了,我们也恢复不了[特殊字符])
「无恃其不来恃吾有以待也。」——《孙子兵法·九变》托管 Airflow 的全部历史活在一个你拿不到连接串的数据库里。一次 MWAA 环境升级之后整个 Airflow 元数据库归零DAG 运行历史、任务实例记录、审计日志、Variables、Connections全部回到出厂状态。我开了 support case 问能不能恢复拿到的回复是这样一段Unfortunately, AWS does not retain accessible snapshots or backups of deleted/replaced MWAA metadata databases. Once the old environment was destroyed via the Terraform replacement, the metadata is unrecoverable. AWS Support does not have direct access to the managed metadata database and cannot perform restores on behalf of customers.翻译过来AWS 不保留已删除或被替换的 MWAA 元数据库的可访问快照旧环境一旦被 Terraform 的替换动作销毁元数据不可恢复AWS Support 没有这个托管数据库的直接访问权限也无法代客户执行恢复。这段话里没有一个词是关于「这一次」的。它描述的是一个长期成立的事实在这个服务模型里你的 DAG 运行历史没有任何 AWS 侧的兜底副本。唯一可能存在的副本是你自己写代码存下来的那一份。于是问题就从这次是谁的错变成了一个更难的问题在一个连不上数据库的托管服务里有备份这件事到底由谁、在哪一层、用什么代码来兜底。如果你读到这里心里想的是我们有备份 DAG——先对着它跑这一条grep-rnis_paused truedags/ plugins/2/dev/null命中了的话这个脚本每跑一次就把整个环境的 DAG 暂停一次。它不是备份工具是迁移工具。你把它挂上定时调度的那天你其实给自己排了一个周期性停机。根因两句话能说完第一节。真正花时间的是后面官方给的两套导出代码为什么不能混用、四个只有读源码才知道的坑、以及生产上到底该怎么排。看完这篇你应该能拿走三件事托管服务托管的是可用性没托管你历史数据的持久性。这两件事在 MWAA 上的分界线正好落在你自己的dags/目录里。AWS 官方给了你两套元数据导出代码行为完全不同一套会暂停全部 DAG一套不会。grep is_paused一秒分辨。用错那套你的「每日备份」就是每日停机。备份文件的保质期由 Airflow 版本决定。task_instance在 2.10 多了executor列log多了try_number列——跨版本恢复不是COPY是列对齐工程。下面是按教学顺序排的不是按我踩坑的顺序。真实的排查是来回折返的你不必跟着折返一遍。文中环境名做了脱敏etl-sit-251/etl-sit-2112对应真实的旧/新环境名版本号、时间戳、报错数量都是真实的。一、事故只有两行 diff但它关掉的是一扇门一次 MWAA 升级Terraform PR 改了两行-airflow_version 2.10.3 airflow_version 2.11.2 -name etl-sit-251 name etl-sit-2112251是 2.5.1 的缩写2112是 2.11.2 的缩写——把当前 Airflow 版本焊进环境名这个命名习惯在这个仓库里跑了好几年看上去只是一次可读性改进。apply 完成控制台AVAILABLELastUpdate: SUCCESS依赖安装日志干净调度器心跳正常。十分钟后 DAG 列表开始变红一类是权限角色不存在另一类查下来精确是195个airflow.exceptions.AirflowException: The access_control mapping for DAG X includes a role named finance-developers, but that role does not existKeyError: 角色不存在Variable 取出来是空字符串。两个症状一个原因这个环境的元数据库是全新的、空的。CloudTrail 里给出了终审证据——不是一次UpdateEnvironment而是一前一后两次调用10:58:46 DeleteEnvironment etl-sit-251 11:26:12 CreateEnvironment etl-sit-2112根因到这里就结束了MWAA 的UpdateEnvironment接口里Name只出现在 URL 路径/environments/{Name}上从不出现在请求体里。改名这个动作在 API 层面不存在所以 Terraform 只能用「删掉旧的、建一个新的」来实现你声明的状态。CloudFormation 文档里那句Update requires: Replacement说的就是这件事。但真正把这件事从「运维失误」变成「架构缺口」的是 AWS Support 回复里的另一段话。Support 在确认根因之后给出的就是开头那段不留余地的答复。它的信息量比根因大得多——它说的不是「你这次没了」而是在这个服务模型里你的 DAG 运行历史、审计日志、Variables、Connections没有任何一个 AWS 侧的兜底副本。唯一可能存在的副本是你自己写代码存下来的那一份。如果你的合规、SLA 统计、审计追溯依赖这些数据——那这份副本不是「运维的 nice to have」它是你系统的一部分而且必须写在dags/里。你看到的信号它真正回答的问题它没有回答的问题AVAILABLE环境现在是否健康是不是原来那个环境LastUpdate: SUCCESS本次操作是否完成这是更新还是替换依赖安装日志干净新环境构建成功旧环境的状态有没有跟过来「AWS 是托管服务」谁负责跑谁负责记得托管服务托管的是「它活着」没托管「它记得」。二、为什么备份 Airflow 元数据是件反常的事先把反常之处摆清楚否则后面所有方案看起来都像过度设计。一个普通的 Postgres 备份长这样控制台开自动快照、pg_dump拉一份、PITR 定个保留期全程不碰应用代码。MWAA 的元数据库上这三条路一条都没有没有 RDS 控制台条目没有 endpoint没有连接串没有 PITR 按钮。官方文档写得很直白——从已有的 MWAA 环境出发没有对元数据库的直接访问。那唯一的入口是什么是你自己的 DAG 进程里那个 sessionfromairflowimportsettingsfromsqlalchemyimporttext sessionsettings.Session()resultsession.execute(text(select dag_id, run_id, state from dag_run))也就是说能访问这个数据库的唯一身份是一个正在这个环境里运行的 Airflow task。这一条直接决定了后面所有设计的形状备份必须是一个 DAG恢复必须是一个 DAG演练必须是一个 DAG而这些 DAG 的生命周期又绑在那个随时可能被替换掉的环境上。这就是我后来管它叫**「托管边界的静默一半」**的东西托管服务把「跑得起来」做成了平滑的抽象——你不需要知道 Aurora 在哪、schema 怎么迁移、心跳谁在看但它把「记得住」留在了抽象之外而且不会在同一个界面上告诉你这条缝在哪。责任分界线从来不画在控制台上它画在 API 参考里那一行没人读的字段列表里。天花板定律一个托管资源的恢复能力上限等于它交到你手上的最低层访问接口。MWAA 交给你的是settings.Session()——所以你的备份能力上限就是你愿意写多少 SQL。三、AWS 给了你两套代码其中一套会让你每天停机一次这是整篇文章里最实用的一段。搜「MWAA metadata backup」你会撞到两份 AWS 官方出品的代码长得很像都在aws-samples下都叫「导出元数据」。它们的行为根本不同。3.1 第一套mwaa_export_data.pystart-stop 用例里的那个这是官方迁移路径的实现。打开源码第一个任务就说明了它的性格# pause all active dags to have consistent and reliable copy of dag history exportsdefpause_dags():sessionsettings.Session()session.execute(text(fupdate dag set is_paused true where dag_id ! {dag_id};))session.commit()session.close()它不是「顺手暂停一下」。DAG 依赖链是back_up_activedags pause_dags [export_data, export_active_dags, export_variable, export_connection] clean_up [activate_dags_on_failure, notify_success]注意activate_dags_on_failure的trigger_ruleone_failed——只有失败时才恢复暂停状态。成功路径上没有 unpause。因为设计意图就是这样导完了你就该去新环境了旧环境本来就该停着。官方文档在这件事上是坦白的「在导出与导入过程中所有其他 DAG 都处于暂停状态。」还有一个更容易被忽略的动作——为了记住「哪些 DAG 本来是开着的」它往被备份的那个库里建表defback_up_activedags():sessionsettings.Session()session.execute(text(fdrop table if exists active_dags;))session.execute(text(fcreate table active_dags as select dag_id from dag where not is_paused and is_active;))drop table if existscreate table as。一个写生产元数据库的备份脚本。对一次性迁移来说这是合理的工程折中对一个每天跑一遍的备份任务来说这是在生产库里反复建删表。3.2 第二套mwaa-drPyPI 包真正的备份工具同样是 AWS 出品aws-samples/mwaa-disaster-recoverymwaa-dr把导出/导入包装成了一个 DAG factory。它的 backup DAG 里setup这一步是这样的defsetup_backup(self,**context):print(Executing the backup workflow setup ...)ifself.storage_typeS3:print(No local file system setup necessary!)return# ...只在 LOCAL_FS 模式下建目录没有 pause。一行都没有。它的 DAG 结构是setup export_tables teardown纯读 写 S3。而且它显式支持被定时defschedule(self)-str:returnVariable.get(DR_BACKUP_SCHEDULE,default_varNone)默认None只能手动触发你设一个DR_BACKUP_SCHEDULE变量它就变成周期任务。这才是一个能挂上调度的东西。普通人的看法两份都是 AWS 官方的元数据导出脚本随便挑一个功能差不多。资深工程师的洞察它们回答的是两个不同的问题。一个回答「我要把历史搬到另一个环境去」另一个回答「我要在不打扰任何人的情况下留一份副本」。前者可以接受停机因为停机本来就是迁移窗口的一部分后者一旦接受停机就自我否定了——一个你不敢在业务高峰随手跑一次的备份等于没有备份因为真正需要它的那一刻你也不敢跑。3.3 一张表分清楚维度mwaa_export_data.py迁移mwaa-drbackup DAG备份是否暂停全部 DAG是update dag set is_paused true否成功后是否自动恢复暂停状态否只在失败时one_failed恢复不涉及是否写生产元数据库是create table active_dags否只读 写 S3能否挂定时调度不应该能DR_BACKUP_SCHEDULEtrigger表显式排除默认包含恢复前是否要求空库不要求COPY进空环境要求配套cleanupDAG适用场景环境改名 / 跨版本迁移 / 蓝绿切换周期性备份 / DR 演练 / 同环境回滚判据一行grep -n is_paused 你的备份脚本。有它是迁移工具没有它才可能是备份工具。四、四个只有读源码才知道的坑这一节是我建议你存下来的部分。官方文档不会讲这些但它们会在你真正需要恢复的那天决定成败。4.1variable.csv和connection.csv里是解密后的明文导出 Variables 的代码是这样的querysession.query(Variable)foryinquery.all():w.writerow({k[0]:y.key,k[1]:y.get_val(),...})Connections 那边是y.get_password()。get_val()和get_password()都是解密后的值。因为必须解密——Fernet key 是 per-environment 的密文搬到新环境解不开。所以这条路走通的代价是你的备份桶从此是一个密码库里面是纯文本 CSV每一行是一个生产系统的凭证。官方文档在这件事上只留了一句 Note说如果你要迁移敏感数据「建议为 S3 桶开启默认加密」。这个措辞相对于实际风险是轻了的。落地时至少要有专用桶KMS CMK不是 SSE-S3加密key policy 只放行 MWAA 执行角色桶策略拒绝非 TLS、拒绝跨账号开 Block Public Access生命周期规则短期过期备份的价值随时间衰减泄露风险不衰减更好的做法是让这两张表根本不值得备份把 Variables/Connections 搬到 Secrets Manager backend元数据库里就没东西可泄露了。mwaa-dr为这种情况专门留了策略开关设成DO_NOTHING就跳过这两张表的恢复4.2active_dags.csv的 header 契约不对称导出端把active_dags表流成 CSV 时用的是csv.writer(...).writerows(chunk)——没有写 header 行。导入端读它的时候cursor.copy_expert(COPY active_dags FROM STDIN WITH (FORMAT CSV, HEADER TRUE),f)HEADER TRUE。PostgreSQL 会把文件第一行当表头丢掉。而文件第一行是一个真实的dag_id。推论迁移完成后active_dags表里会少一个 DAGUPDATE dag SET is_pausedfalse FROM active_dags就不会把它放出来——有一个本来在跑的 DAG在新环境里会一直是 paused而且没有任何报错。这是我读代码得出的结论不是我跑出来的建议你在自己的环境里验一遍# 导出后CSV 有多少行aws s3cps3://$BUCKET/data/active_dags.csv-|wc-l# 导入后新环境里 active_dags 表有多少行差值应该是 0不是 1一个静默少一行的 bug和一个抛异常的 bug代价差了一个数量级。4.3 CSV 的列名在说谎而它恰好还能工作这个最微妙也最值得内行一笑。导出 Connections 时声明的列名是k[conn_id,conn_type,host,schema,login,password,port,extra,is_encrypted,is_extra_encrypted,description]实际写进去的值却是错位的CSV 第 N 列声明的列名实际写入的值3hostdescription4schemahost7portschema8extraport9is_encryptedextra看到这里你会以为找到了一个数据错乱的 bug。但导入端是这样读的rows.append(Connection(row[0],row[1],row[2],row[3],row[4],row[5],row[6],port,row[8]))位置参数。而Connection.__init__的签名恰好是(conn_id, conn_type, description, host, login, password, schema, port, extra)——和实际写入顺序完全对齐。所以 round-trip 是正确的。真正的问题是这个 CSV 文件的契约不是它的列名而是Connection.__init__的参数顺序。列名只存在于导出脚本的源码里而且是错的。谁要是照着列名自己写一个 importer或者拿这个 CSV 去做审计、做 diff、导进别的系统host 和 description 会对调schema 和 port 会错位而且照样不报错。口诀一个只有位置契约、没有 header 的数据文件它的 schema 不在文件里在读它的那段代码里。备份格式如果依赖「有人记得读的顺序」它就已经开始腐烂了。4.4trigger表两个官方工具的意见是相反的mwaa_export_data.py里有一段很少见的、写得很诚实的注释# NOTE: The trigger table is intentionally excluded from export.# Starting in Airflow 2.9.0, trigger.kwargs is Fernet-encrypted with a per-environment key.# Exporting and importing these rows into a different environment causes# cryptography.fernet.InvalidToken errors that crash the triggerer and scheduler.而mwaa-dr的默认备份表清单里trigger在里面variable、connection、slot_pool、log、job、dag_run、trigger、task_instance、task_fail、xcom。两个 AWS 官方工具对同一张表给出了相反的答案而且两个都对——因为它们的恢复目标不同恢复场景Fernet keytrigger能不能带同环境恢复误删数据、回滚、DR 演练回原环境相同能kwargs解得开跨环境恢复改名、蓝绿、跨区 DR不同不能InvalidToken会把 triggerer 和 scheduler 打崩面试拿分点被问到「Airflow 元数据迁移有哪些表不能直接搬」答「trigger 表」只是及格答「trigger.kwargs从 2.9.0 起用 per-environment Fernet key 加密跨环境导入会抛InvalidToken打崩 triggerer而且 trigger 本身是 deferred task 的瞬时状态新环境跑一遍就重建了所以正确做法是排除而不是修复」——这个答案会被记住因为它同时给出了机制、后果和取舍。顺手记住另外几张表的性格都能在源码里对上表会不会跟着走为什么dag、dag_tag、dag_code、serialized_dag不需要scheduler 解析 S3 里的 DAG 文件时自动重建权限/角色相关表不需要但要重建由 IAM 执行角色与 FAB 配置生成那一批access_control报错就是它没跟过来dataset/asset相关表显式排除2.9.2 起自动生成导入会撞主键slot_pool带但排除default_pool新环境自带default_pool导进去会冲突task_instance带但过滤掉未终结状态导出条件是state NOT IN (running,restarting,queued,scheduled,up_for_retry,up_for_reschedule)——正在跑的任务不在备份里导出 DAG 自己的dag_run行特殊处理否则恢复后它会像catchupTrue一样重跑自己最后那两行值得单独说。task_instance的过滤条件意味着你的备份语义是「已终结的历史」不是「此刻的全量状态」。任何一次恢复之后跨越备份时刻正在运行的那批任务需要人工重跑——这是你 RPO 之外的一笔额外账AWS 的 DR 博客也把「中断期间的 DAG run 需要手动重跑」写成了显式步骤。五、官方 runbook 的cd目标已经是 404这一段是时效性最强的也是最能说明「照着文档抄会翻车」的。AWS 官方迁移文档到今天2026-09-30还这样写gitclone https://github.com/aws-samples/amazon-mwaa-examples.gitcdamazon-mwaa-examples/usecases/metadata-migration/{existing-version}-{new-version}/然后让你改export_data.py里的S3_BUCKET上传unpause 那个叫db_export的 DAG。自己验一下curl-s-o/dev/null-w%{http_code}\n\https://raw.githubusercontent.com/aws-samples/amazon-mwaa-examples/main/usecases/metadata-migration/2.5.1-2.10.1/export_data.py# 404usecases/metadata-migration/目录现在只剩两个文件.airflowignore和README.md。README 的全部技术内容是一句话Metadata import and export scripts are now part of the MWAA Disaster Recovery project.脚本搬家了文档没跟上。所以如果你在事故当天照着官方文档执行你会卡在一个不存在的目录上——而那一刻旧环境可能还活着导出窗口正在关闭。更值得注意的是新家的支持矩阵。mwaa-dr当前版本 2.2.0README badge 上的支持版本是MWAA 2.10.3 | 2.10.1 | 2.9.2 | 2.8.1 | 2.7.2 | 2.6.3 | 2.5.1 | 2.4.3没有 2.11。而这次事故的目标版本正是 2.11.2。这不是什么阴谋看一眼 factory 的代码就明白为什么classDRFactory_2_10(DRFactory_2_9):deftask_instance(self,model):returnBaseTable(nametask_instance,columns[...,executor,# New Field...],export_filterstate NOT IN (running, ...),)每个版本的 factory 都是上一个版本的子类重写的只有列发生变化的那几张表task_instance在 2.10 多了executorlog多了try_number。也就是说备份文件的 schema 是和 Airflow 版本一一绑定的。一份 2.10.3 上导出的task_instance.csvCOPY进 2.11.2 的库里要么列数不匹配直接报错要么更糟——列数恰好对上而语义错位。文档的半衰期短于依赖的半衰期。所以判断一条 runbook 能不能用的方式不是「它在官方站点上」而是它引用的每个路径、每个包版本今天还curl得到。在事故里一条过期的 runbook 比没有 runbook 更贵因为它消耗的是你最紧张的那几十分钟。六、那么生产上到底怎么做先说清楚下面这套我们还没上线是事故之后定下的方案写在这里是因为设计上的取舍比代码本身更值得抄。凡是需要实测验证的地方我都标出来了。6.1 第一步不是写备份是缩小备份范围最省事的备份是不需要备份的数据。先给每张表定性再决定写多少代码层内容处置L0 不用备份dag、dag_tag、dag_code、serialized_dag、dataset/assetscheduler 解析 S3 自动重建。备份它们只会增加恢复时的主键冲突L1 不该放在这儿variable、connection搬去 Secrets Manager backend。一次改造同时解决「明文进 S3」和「备份范围过大」两个问题L2 必须备份dag_run、task_instance、task_fail、log、job、slot_pool、xcom这七张表才是审计、SLA 统计、运行历史的真正载体也就是这次事故真正丢掉的东西L3 不要备份跨环境场景下的trigger权限/角色表Fernet key 不通权限应由 IaC 重建而不是从 CSV 恢复——否则你的权限模型会漂移到没人能复现的状态L1 那一行值得多说一句把 Variables/Connections 移出元数据库不只是安全改进它还改变了事故的形状。这次KeyError: 之所以致命是因为业务代码的配置活在元数据库里如果它们活在 Secrets Manager 里同样一次环境替换只会丢历史不会让 DAG 直接崩。6.2 备份 DAG真实可跑的代码# dags/backup_metadata.pyfromairflowimportDAGfrommwaa_dr.v_2_10.dr_factoryimportDRFactory_2_10 factoryDRFactory_2_10(dag_iddr_backup_metadata,path_prefixdata,storage_typeS3,)# 必须赋值给全局变量否则 DAG 扫不到dag:DAGfactory.create_backup_dag()配套三件事缺一不可专用 S3 桶KMS CMK 加密MWAA 执行角色有读写权限Airflow 变量DR_BACKUP_BUCKET 桶名不是 ARNAirflow 变量DR_BACKUP_SCHEDULE 你的 RPO 对应的 cron。不设就只能手动触发——而「等我需要的时候手动跑一下」就是这次事故的完整剧本requirements.txt里加mwaa-dr它依赖smart-open7.0.4。升级到 2.11 时必须自己接一棒mwaa-dr还没有v_2_11factory所以要么继承DRFactory_2_10重写列发生变化的表要么先在 aws-mwaa-local-runner 上把 2.11 的 schema diff 跑出来。这一步没有捷径也别指望文档会告诉你——它连 2.10 的脚本搬家都还没更新。6.3 恢复演练而不是恢复方案mwaa-dr的 restore要求一个空库所以它配了一个cleanupDAG。这意味着「在生产环境里试一下恢复」是个危险动作——正确的演练场是 local runnerfactoryDRFactory_2_10(dag_iddr_restore_metadata,path_prefixdata,storage_typeLOCAL_FS,# 演练用本地文件系统不碰 S3)dag:DAGfactory.create_restore_dag()恢复时那两个策略变量决定了 Variables/Connections 的行为默认是APPENDDR_VARIABLE_RESTORE_STRATEGY/DR_CONNECTION_RESTORE_STRATEGY行为什么时候用DO_NOTHING完全不恢复这两张表已经用 Secrets Manager backend推荐终态APPEND默认只补缺失的条目不覆盖往一个已经配好的新环境里补历史REPLACE用备份覆盖现有条目确定备份比当前环境更权威时演练要验的不是「DAG 跑绿了」是这四条Variables 条数对得上、Connections 能实际连通、dag_run的行数和最近 N 天的历史对得上、UI 里 unpause 状态和事故前一致这一条正好能顺带验证 4.2 里那个 header 推论。6.4 把这次的教训变成一道闸门而不是一条口头规矩事故复盘里最没用的产物是「下次注意」。这次真正要留下的工程产物是一条 CI 检查——任何会替换有状态资源的 plan 都不允许自动合并terraform plan-outtfplan terraform show-jsontfplan|jq-e [ .resource_changes[] | select(.change.actions [delete,create] or .change.actions [create,delete]) | select(.type | test(mwaa_environment|db_instance|rds_cluster|elasticache|msk_cluster)) ] | length 0 ||{echo有状态资源将被替换需要人工签核 导出窗口;exit1;}它不阻止你替换资源——有时候你真的需要。它只是强制「替换」这件事被一个人看见一次。这就是 senior 和 principal 的分界线把只靠口头传承的知识变成一个会失败的检查。6.5 日志组那个命名习惯的黑色幽默顺一句也是唯一的好消息。任务日志在 CloudWatch 的airflow-{环境名}-Task里它和 MWAA 环境是两套独立的存储删环境不删日志。所以这次事故里旧环境的任务输出还在airflow-etl-sit-251-*下面可以用 Logs Insights 捞。但日志组名是从环境名派生的新环境写新组旧组不会被链接进新 UI。官方文档对此有一句加粗的 Important改名意味着历史任务日志在新环境的 Airflow UI 里访问不到。黑色幽默在于如果当初不把版本号焊进环境名日志组和元数据库两样都不会丢。那个为了「一眼看出跑的是哪个版本」而设计的命名规范同时炸掉了两样东西。查了一下这个环境的日志组243、251、306、一次rollback、一次临时变更标记像地层一样排着——过去几年里这一幕演过不止一次只是之前没人回头找过那些历史所以从来没人把它当事故报出来。一个流程不报警不代表它没有代价。有时只是还没人需要被丢掉的那部分状态。综合三种失败模式各自谁兜底把整件事收成一张表。列的是「AWS 替你做了什么」和「剩下的必须你自己做」——这条分界线就是你要写多少代码的答案失败模式AWS 托管的部分留给你的部分原地版本升级名字不变升级前自动快照元数据库、升级组件、db migrate、失败自动回滚最长约 2 小时不可用用 local runner 验 DAG 与requirements.txt兼容性手工改过的 DAG 不会被回滚环境替换名字变了 / 蓝绿 / 跨区无全部旧环境还活着时导出、新环境导入、日志组处置、恢复后验证区域级灾难 / 误删 / 数据损坏多 AZ 容错单 AZ 故障自动恢复周期性备份 跨区复制 SchedulerHeartbeat 告警 定期演练第一行是唯一有安全网的一行而它的唯一条件是别碰Name。立刻可以做的事grep -rn is_paused true dags/。如果你的「备份」脚本里有这行它是迁移工具。现在就把它从任何定时调度上摘下来。aws s3 ls你的备份桶随便下一个connection.csv看一眼。如果里面是明文密码今天就换 KMS CMK 收紧桶策略并把「Variables/Connections 迁到 Secrets Manager」排进 backlog。curl一遍你 runbook 里引用的每个 GitHub 路径和包版本。文档搬家了不会通知你。顺手确认你的目标 Airflow 版本在mwaa-dr的支持矩阵里——如果不在这就是升级工作量的一部分不是升级之后再说的事。把 §6.4 那条jq加进 CI对准你所有有状态资源MWAA、RDS、ElastiCache、MSK。让「替换」这件事必须被一个人看见一次。在 local runner 上跑一次完整的 backup → cleanup → restore然后对四件事Variables 条数、Connections 连通性、dag_run行数、unpause 状态。没演练过的恢复方案和没有恢复方案的区别只在于你事故那天的心理预期。我自己接下来要验的第一件事是 §4.2 那个active_dags.csv少一行的推论——读代码能推出来但我还没在真实环境里数过那两个数字。云厂商替你托管了「它还活着」从没答应替你托管「它还记得」。而这两件事的分界线永远不在控制台上在你有没有把它写成一个会跑的 DAG。