中文 English

740 万份报错、瘫痪近 20 小时:微软全球大宕机复盘——为什么“健康检查”自己把自己拉黑,甚至连修都不敢重启?

发布时间: 2026-11-14 · 阅读量 --
微软 Microsoft 故障排查 Troubleshooting 运维 网络 Network 架构 Architecture 安全 Security 避坑指南 自动化 Automation Windows 11 Ubuntu 26.04 macOS 27

先给结论:如果你昨天上午一打开电脑发现 Outlook 死活收不到邮件、Teams 日历变成一片空白、Copilot 直接断链,甚至连 GoDaddy 和 Gmail 都莫名其妙跟着退信——你既没有被黑客定向攻击,也不是公司 Wi-Fi 坏了,更不要手抖去重装 Office 或删除本地邮箱缓存文件。

北京时间 2026 年 11 月 13 日凌晨起(UTC 时间 11 月 12 日傍晚),微软旗舰办公云 Microsoft 365 遭遇了堪称年度最黑暗的 20 多小时。起初只是美洲区 Exchange Online 陆续报出无法收发邮件(事件编号 EX1550543),紧接着故障如同多米诺骨牌般跨洲蔓延并升级为全球跨套件重大事件(MO1550821):从美国众议院被迫提前休会、英国 NHS 公立医院切回纸笔病历,到日本企业重启传真机、澳大利亚情报与司法系统卡顿,整个地球顺着日出时区“排着队上班即断网”。更让一线 SRE 和普通打工人崩溃的是——这场看似“重启一下就能好”的宕机,竟然足足拉锯了近 20 个小时,连微软自家状态页都一度弹出 504 Gateway Timeout。

很多人至今还一头雾水:明明 Ping 和 TCP 443 端口都在毫秒级正常回应,为什么邮箱网页一到安全握手就原地卡死?为什么微软工程团队半夜已经恢复了一批服务器,过几分钟又集体崩溃?普通用户和企业 IT 在这种全球级云底座瘫痪时,究竟该干什么、绝对不能干什么?

原创全景封面图:2026 年 11 月微软全球大宕机关键指标与控制面/数据面分裂实测

图 1:本文自制全景概览图。740 万+ 报错规模、近 20 小时持续震荡与“门面层存活、邮箱 TLS 假死”的核心技术分裂一览。

一、事情是怎么开始的:当微软自己的状态页也挂出了 504

对于云端工程师来说,世上最令人心跳骤停的画面,莫过于你想打开云厂商的“健康状态页(Service Status)”确认是不是只有自己出事时,结果发现连状态页本身都在对你报错。

北京时间 2026 年 11 月 13 日凌晨 00:17 至 01:54(UTC 11 月 12 日 16:17–17:54),北美东海岸还在下午工作时段,大量企业员工突然发现桌面版 Outlook 卡死在“正在更新文件夹”,网页端 OWA 和手机端 EAS 同步全面失效。微软在管理中心火速开出工单 EX1550543,最初的研判仅是“美洲区部分用户无法通过多种连接方式访问 Exchange Online 邮箱”。

然而仅仅半小时后(UTC 18:07–18:26),故障边界彻底失控并升级为全球性重大事件 MO1550821。几乎同一时间,外部管理员尝试刷新官方公开状态门户 status.cloud.microsoft 时,甚至迎面撞上了 Azure Front Door 抛出的 504 Gateway Timeout——这不仅让大量中小企业误以为“全网都塌了”,更让监控大屏失去了第一手权威信源,只能依赖后台流出的 Service Health 文本更新与第三方观测平台苦苦追踪。

真实网页截图:微软官方 status.cloud.microsoft 服务降级详情页

图 2:实际抓取的 status.cloud.microsoft 状态页面截图,展开详情后明确标示出 Service degradation(服务降级)与多工作负载异常。

二、有多惨烈:740 万份报错与“跟着太阳崩”的全球早高峰

如果说以往的云端小波动只是让运维人员少睡两个小时,那 2026 年 11 月这一夜的数据曲线,直接把全年互联网故障记录拉到了一个恐怖的新量级。

根据网络测速与众包故障观测权威平台 Ookla 旗下 Downdetector 公布的最新统计,截止 UTC 11 月 13 日 16:00,全球用户围绕此次微软宕机提交的故障报告累计飙升至 740 万份以上(报告文中同时以“more than 7 million”概括)。这是什么概念?它直接达到 2026 年 2 月 YouTube 大宕机(约 280 万份)的 2.5 倍以上,几乎把今年 1 月 Verizon(250 万份)与 6 月 Meta(170 万份)甩在身后。

更反常的是持续时间。绝大多数互联网大厂的核心故障,往往在 1 至 4 小时内通过回滚变更即可快速消退——过去一年里连 Cloudflare(2025 年 11 月)和 Verizon(2026 年 1 月)的“超长待机”事故,高位活跃期也不过 9 小时左右。而微软这一次,报告量维持在“最繁忙小时峰值五分之一以上”的持续高压煎熬期,竟然横跨了接近 20 个小时(网络遥测公司 Kentik 分析师 Doug Madory 也从全网流量侧监测到了持续约 18 小时的显著跌落)。

真实数据截图:Ookla Downdetector 统计的 2026 年各大全球宕机规模对比(微软 740 万份断层第一)

图 3:Ookla Downdetector 真实统计图表——此次微软 365 故障累计 740 万份报错,断层式碾压过去 12 个月的 YouTube、Verizon 与 Meta 大宕机。

为什么会拖这么久、而且呈现出一波未平一波又起的诡异波形?秘密在于地球自转带来的“早高峰轮流撞墙”。

Downdetector 监测到全球至少有 90 多个国家和地区单区涌入超过 1,000 份集中报错。当北京时间深夜北美地区刚刚陷入泥潭时,亚太和欧洲还在睡梦中;等清晨太阳依次照亮日本(当地时间约上午 9:00)、新加坡(约 9:30)、印度(约 10:15)以及西欧与英国(约 8:30–9:15),成百上千万商业用户刚一掀开笔记本电脑登录桌面,海量的登录刷新与邮件同步请求就像潮水般砸向尚未喘过气的 Exchange 服务器,生生砸出一浪又一浪早高峰波峰。

真实数据截图:Ookla 统计此次微软宕机高达约 20 小时的持久震荡时长对比图

图 4:Ookla 真实对比图表——此次故障高位持续时间接近 20 小时,比此前的“最长纪录”直接翻了一倍有余。

甚至连无辜的第三方服务也被卷进了这场混乱的“甩锅风暴”。因为微软邮箱拒收和连接超时,世界各地无数发信方在 Gmail 里收到退信后,本能以为是自己的邮箱坏了;大量原本依赖 GoDaddy 转售微软 365 企业邮箱的中小公司老板疯狂投诉 GoDaddy;而开不成 Teams 会议的团队集体涌向 Zoom 和 RingCentral,生生把这几家同行平台也“挤”出了惊人的异常报道曲线:

真实数据截图:GoDaddy 61x、Zoom 52x、Gmail 46x 等关联第三方服务的连带溢出峰值

图 5:Ookla 真实统计截图——“神仙打架、全网误报”:GoDaddy(61x)、Zoom(52x)、Gmail(46x)在此次微软宕机期间录得惊人的关联报错飙升。

而在虚拟世界的流量图表之外,现实社会承受的冲击同样触目惊心。微软 365 在全球拥有超过 4.3 亿至 4.5 亿活跃付费商业席位,当这套神经中枢连夜哑火,超过 70 个国家的现代无纸化机构瞬间被迫退回上世纪的工作方式:

真实网页截图:维基百科 2026 Microsoft outage 词条记录的全球波及国家与机构清单

图 6:维基百科 2026 Microsoft outage 词条实时截图,完整记录了从 EX1550543、MO1550821 到多国医疗、议会、司法与企业退回传真纸笔的历史现场。

三、24 小时复盘时间线:明明是邮件故障,怎么拖垮了 15 个核心套件?

很多技术之外的读者都奇怪:不就是个收发邮件的 Outlook 出问题吗?为什么 Teams 日历打不开、Copilot 罢工、连 Purview 合规审计、Defender 安全链接甚至 Windows 365 云电脑都能一起陪葬?

下面这张自制 24 小时全球时间线图(双标注 UTC 与北京时间),配合微软服务健康后台流出的真实告警日志,把整个级联雪崩的脉络还原得清清楚楚。

原创时间线图表:2026 年 11 月 12–13 日微软全球大宕机 24 小时演进与早高峰波次

图 7:本文原创梳理的 24 小时双时区事故时间线。从美洲首发 EX1550543,到 MO1550821 跨区接力引爆亚太与欧洲早高峰,再到微软用 48 小时队列宽限期稳住大盘。

真相是,在微软 365 庞大的现代云办公版图里,Exchange Online 根本不只是一个邮箱软件,它是整个生态隐形的“日程、消息与合规总底座(Substrate)”。翻看 ENow 监控平台完整留存的 MO1550821 官方服务健康日志,你会惊讶地看到一张长长的一级连带受害清单(共 15+ 核心服务):

真实网页截图:ENow 留存的微软状态页 504 错误与 MO1550821 官方 Service Health 监控日志

图 8:ENow 监控档案真实截图——上方可见 status.cloud.microsoft 在危机高峰期弹出的 504 Azure Front Door 报错,下方为企业管理员视角下的 EX1550543 / MO1550821 多组件降级日志。

四、小学生也能听懂的大比方:为什么“误杀”加“踩踏”让工程师差点不敢重启?

先别急着往底层协议钻。其实这次让微软全球 SRE 焦头烂额了整整一夜的技术死结,剥掉复杂的云原生外壳,用生活里最常见的两个画面,连小学高年级的孩子也能立刻明白七八成。

原创生活化比喻图解:高速公路收费站“假故障闭闸+踩踏”与“5分钟路牌+锁死安检门”

图 9:本文自制生活化图解——用“高速收费站连环闭闸与重启踩踏”理解微软服务器的恢复死锁,用“5 分钟路牌 vs 10 秒锥桶 + 门卫放行却锁安检门”理解这次古怪的 DNS 与 TLS 故障现象。

比喻一:高速收费站的“误杀闭闸”与“开门瞬间被挤踩死”

想象一条全国最繁忙的 10 车道高速公路主收费站,平时 10 个收费窗口同时开闸,车流稳稳当当。

某天夜间,运营方给路面做了一次日常调线变更,谁知道几个月前一次扩建留下的一个“体检传感器暗病”突然被激活了。这个出 Bug 的体检仪开始瞎报警,一口气误判其中 7 个好端端的收费口“生病罢工”,自动啪啪拉下闸杆把这 7 条通道全踢出了工作队伍!

结果可想而知:原本 10 条路消化 100% 的车流,一秒钟之内全部恶狠狠地挤进了剩下的 3 个窗口。这 3 个窗口本来满负荷就能跑,现在要硬扛 3 倍多的高峰车流,里面的安检员瞬间喘不过气、大脑当场死机(对应微软公告中所说的:大量机器下线导致剩余集群过载,处理“安全连接”的核心工作进程彻底不堪重负、原地卡死)。

更要命的在后面。修车队赶到现场,好不容易把其中 1 个窗口重置清理完准备重新开闸放行。按照常识,开了新窗口应该越来越通畅对吧?错!因为在通道外面,整条高速公路上早就排起了几公里长、急不可耐的货车(出站路由队列 + 全球几亿台疯狂自动重连的电脑)。新闸门刚拉开 1 厘米,积压的巨量洪水就劈头盖脸冲进来,瞬间把这个刚重启好的好窗口再次压垮,卡死在既收不了费、也退不出来的“僵死状态(Stuck in a Bad State)”!

这就是分布式系统里最让人头皮发麻的“惊群效应(Thundering Herd)”——微软业务应用与平台执行副总裁 Charles Lamanna 在接受媒体采访时就亲口指出,重连和重启时的流量踩踏(thundering herd)让常规恢复手段频频受挫,微软团队最后只能先“硬切隔离”外部狂暴车流、紧急空降一批全新的“临时收费亭(大规模扩容新虚拟机)”,再用极慢的“单台小步放行+体检验证”才把雪球按住。

比喻二:5 分钟大路牌、10 秒小锥桶,以及“保安招手放进院,大厅安检门却彻底焊死”

除了服务端崩溃,这次还有一个让全球无数网管抓破头皮的灵异现象:同一个办公室里,坐在左边的同事忽然就能收邮件了,右边的同事怎么刷新都是白屏转圈;更诡异的是,网管拿工具测试微软服务器 443 端口“通不通”,结果明明显示 5 毫秒秒回正常,一打开网页却彻底卡死!

用收费站的大白话来说,这里藏着两个日常原理:

第一,为什么同桌之间“同一分钟有人好、有人坏”?因为去微软邮箱要看两层指路牌:高层大路牌(DNS 里的 CNAME 记录)有效时间足足有 300 秒(5 分钟),决定把你发往哪个大区总闸;而落地到门口哪条具体车道的雪糕筒(DNS 的 A 记录地址池)有效期只有短短 10 秒钟,池子里轮换着 20 到 29 个服务器入口。如果你的电脑、路由器或者公司本地 DNS 刚好还记着 10 秒前那条“坏车道”的老位置,你的车就会一头扎进死胡同;而隔壁同事缓存正好过期、重新抽签抽中了一条刚扩容好的绿灯车道,于是就神奇“复活”了。

第二,为什么网络工具测 443 端口明明绿灯,网页却死活不动?这就好比大门保安(操作系统内核 TCP 协议栈)还清醒地站在岗亭里——你的车一按喇叭(发 TCP SYN),保安在 3 到 13 毫秒之内就微笑点头递给你一张进门小票(回 SYN-ACK)。于是所有老式监控工具都以为“服务器活得好好的!”可当你真正把车开进院子、摇下车窗准备过加密验票口(发出 TLS ClientHello)时,发现里面负责扫码核验的工作人员全员昏迷了——你递过去的证件(握手包)永远等不到回音,最终只能生生熬到超时失败。

五、硬核技术剖析:官方日志与 11 大区独立实测,精准还原雪崩机理

讲清了直觉,我们把视角切回真实的一手工程证据。综合微软正式官方声明、ENow 归档的 Service Health 逐字记录,以及故障约 T+18 小时窗口期独立研究者通过 Globalping 11 个全球区域节点 + 新加坡自治系统(AS45102) 完成的几百次底层协议实测,这次事故堪称一次教科书级的“控制面健康 vs 数据面半死锁”病理切片。

原创技术架构图:EX1550543 至 MO1550821 诱因、误杀、TLS 进程卡死、惊群与全球级联因果链

图 10:本文构建的技术因果链全景架构图。清晰划分出隐藏诱因、自动健康检查自残、门面层/身份层与邮箱数据面的真实实测分裂,以及四阶段恢复破局术。

1. 控制面与边缘层纹丝不动,证明“不是光缆,也不是 BGP 断网”

每次大型云服务集体掉线,社交平台第一时间总少不了“是不是海底光缆被挖断了”“是不是 BGP 路由劫持”的阴谋论,甚至还有人猜测是不是 AI Agent 自动化误操作惹的祸。

但外部独立实测数据直接一锤定音:在故障最惨烈的 T+18 小时左右,全球 11 个探测大区采集的数据显示出惊人的层间分裂:

与此同时,微软执行副总裁 Charles Lamanna 也亲自出面辟谣定性,给出了极为罕见的坦诚说明:

"We are deeply sorry for the disruption this incident has caused our customers and partners... This was a technical issue not related to cyber or AI / agents. The incident was introduced by an issue during an earlier network capacity upgrade that surfaced following a recent network change... We will provide a full post-incident report once our review is complete."

翻译过来核心信息只有两句:不是黑客攻击,也和 AI / Agent 没有半毛钱关系;病根是早先一次网络容量升级时埋下的技术缺陷,在 11 月 12 日近期例行网络配置变更后被正式触发。

真实网页截图:GitHub 独立外部观察者在 T+18h 记录的 11 大区与新加坡 AS45102 协议栈实测档案

图 11:独立外部观测项目在 GitHub 公开发布的 T+18 小时实测证据——边沿 0.0% 报错、登录与 Teams 11/11 区全部正常,唯独邮箱前端池 70%–72% 的对外地址在 443 端口完全哑火。

2. 真正的死亡现场:70% 前端失联、92% TLS 假死与 80–100 倍延迟剧烈抖动

那么病灶到底精确卡在 Exchange Online 的哪一层代码里?如果把微软内部 Service Health 原话与外部原始 Socket 状态机数据拼接到一起,严丝合缝得令人发指:

真实网页截图:TechCrunch 对此次微软邮件崩溃演变为全球云危机的深度报道

图 12:TechCrunch 实时报道截图——拆解邮件故障如何在 20 小时内演变成席卷议会、医院与跨国企业的全球云危机,以及 Charles Lamanna 关于“非网攻、非 AI、惊群复苏难题”的核心表态。

为了更直观理解“为什么这次拖得比 2024 年 CrowdStrike 还难受、和今年 9 月微软那次又有什么区别”,我做了一张横向硬核对比表:

对比维度 2026 年 11 月 M365 全球大宕机 (本次) 2026 年 9 月微软云故障 2024 年 7 月 CrowdStrike 蓝屏风暴
直接导火索 早期网络扩容遗留缺陷 + 近期配置变更激活健康检查误杀 核心身份认证服务(Auth)配置变更错误 Windows 内核级驱动(Channel File 291)异常更新
瘫痪平面 邮箱数据面与安全连接进程(TLS 握手假死);认证与边缘网关健康 身份控制面(全球登录与 Token 签发大面积受阻) 客户本地终端操作系统内核直接 BSOD 蓝屏无法启动
恢复最大拦路虎 惊群效应 + 出站路由反扑引发“Bad State”死锁(需逐区微步扩容验证,耗时近 20 小时) 配置回滚传播生效即可较快恢复 海量终端无法远程管理,必须管理员物理进安全模式手动删驱动文件
普通人客户端正确动作 原地等待、别删 OST、恢复后刷一次本地 DNS,留足 48 小时队列送达窗口 等待身份层恢复后重新刷新 Token 即可 必须本地进 WinRE / 安全模式或重启 15 次等待补丁

六、到底怎么解决:客户端 60 秒自检止损 + 三大操作系统全自动排查脚本

先说一句大实话,也是很多教程为了博眼球不敢直接挑明的技术底线:

凡是微软云后端服务器 TLS 进程卡死导致的全球级上游故障,没有任何神仙客户端脚本能够“替微软把 Exchange 服务器修好”。任何号称“跑个脚本改个 Hosts 就能让全球宕机瞬间起死回生”的教程,都是彻头彻尾的伪科学。

但是,在真实的企业运维与个人办公现场,我们手中依然握着三件极度高价值、立刻能省下几小时无用功与灾难性误操作的工程抓手:

  1. 60 秒定性鉴伪(避免内耗与破坏性操作):在全网瘫痪的混乱期,第一时间用程序化分层探测证明“问题在微软上游邮箱 TLS,而不在本地公司内网、防火墙或单机配置”。当全球 90% 的人在社区抱怨“删了 Outlook 配置文件还是白屏”(见图 13 微软官方问答区惨状)时,你用脚本 3 秒定案,严令全公司禁止卸载 Office、禁止删除 50GB 的本地 .ost 离线数据文件、禁止脚本死循环重连(防止自己变成制造“惊群效应”的帮凶);
  2. 恢复期的“黄金甩包术”(清陈旧 300s/10s DNS 缓存 + 清理易失 WebView 锁死态):别忘了前文提到的双层 TTL 机制!当微软工程团队第 18–24 小时陆续完成区域限流、扩容与分批恢复后,为什么你的电脑依然连着报错?很大概率是你的 OS DNS 解析器或新版 Outlook/Teams 的 PWA/WebView 缓存还死死抱住崩溃节点 Pool 不放。安全刷掉本地 DNS 缓存并备份清空易失前端缓存(绝对不碰你的真实邮件存档),往往能让恢复提前半小时到一个小时落地;
  3. 为下一次大宕机做好带外应急与队列心理预期:由于微软已经将积压邮件延迟重试期临时拓宽至 48 小时,切勿在 2 小时内没见回信就反复重发大附件邮件把通道堵上加堵;重要协同通知切换非微软信道的独立手机/备用中继。

真实网页截图:Microsoft Learn 官方社区大量用户在宕机期间提交 Outlook 无法登录与收信报错

图 13:Microsoft Learn Q&A 真实求助现场——没有分层定位概念的管理员与用户往往误以为单机出毛病,在论坛里反复排查本地客户端甚至盲目重构邮箱配置,结果越折腾越惨。

为了让大家在 Windows 11、Ubuntu 26.04、macOS 27 任意环境下都能 零第三方依赖(不需要装 pip、npm、brew、jq) 一键跑起精确到 TCP 与 TLS 握手毫秒级差值的全链路排查,我把完整诊断与恢复助手统一封装成了支持 人类交互模式 与 AI Agent / 监控自动化模式(–auto –json) 的跨平台生产级脚本。

隐私安全说明:全部脚本设计严守脱敏原则,仅计算解析结果池的匿名短哈希(如 pool-795e344a)和记录条数,绝不输出任何完整 IP 地址或内网主机/计算机名。

原创实战方案图:60 秒止损决策树、三大操作系统双模式零依赖脚本架构与企业抗毁升级路线

图 14:本文构建的云端大宕机 60 秒止损决策树、三端双模式脚本引擎与企业抗毁升级路线图。

1. Windows 11 一键诊断与恢复脚本(零依赖 PowerShell 原生 .NET 版)

将以下内容保存为 m365_outage_triage_windows11.ps1。无需任何额外软件,直接调用 Windows 11 内置 System.Net.Sockets.TcpClient 与 SslStream 完成端到端 TLS 1.3 握手检验,自动识别“认证正常但邮箱 TLS 假死”特征,并在交互确认后安全刷新系统 DNS 与易失的 New Outlook WebView2 缓存(全程跳过 OST/PST)。

<#
.SYNOPSIS
    Microsoft 365 Outage Triage & Post-Recovery Client Helper for Windows 11 (Zero 3rd-Party Dependencies).
.DESCRIPTION
    Supports Interactive Human mode (default) and unattended AI Agent mode (-Auto -Json).
    Separates local ISP/DNS issues from upstream Mailbox TLS stalls, inspects DNS TTLs,
    safely flushes Windows DNS cache, and optionally clears volatile WebView2/OneAuth caches
    without touching OST/PST archives or printing full IPs / computer names.
#>
[CmdletBinding()]
param(
    [switch]$Auto,
    [switch]$Json,
    [switch]$FlushDns,
    [switch]$ClearClientCache,
    [string]$Domain = "",
    [int]$TimeoutMs = 5000
)

$ErrorActionPreference = "Stop"

$Targets = @(
    @{ Layer = "Edge_FrontDoor"; Host = "www.microsoft.com"; Path = "/" },
    @{ Layer = "Identity_Auth";  Host = "login.microsoftonline.com"; Path = "/common/v2.0/.well-known/openid-configuration" },
    @{ Layer = "Graph_API";      Host = "graph.microsoft.com"; Path = "/v1.0/`$metadata" },
    @{ Layer = "Teams_Core";     Host = "teams.microsoft.com"; Path = "/" },
    @{ Layer = "Mailbox_EXO";    Host = "outlook.office365.com"; Path = "/owa/healthcheck.htm" },
    @{ Layer = "Mailbox_Web";    Host = "outlook.office.com"; Path = "/mail/" },
    @{ Layer = "Service_Health"; Host = "status.cloud.microsoft"; Path = "/" }
)

function Get-MaskedPoolHash {
    param([string[]]$Addresses)
    if (-not $Addresses -or $Addresses.Count -eq 0) { return "none" }
    $sorted = ($Addresses | Sort-Object -Unique) -join ","
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $bytes = [System.Text.Encoding]::UTF8.GetBytes($sorted)
        $hash = ($sha.ComputeHash($bytes) | ForEach-Object { $_.ToString("x2") }) -join ""
        return "pool-" + $hash.Substring(0, 8)
    } finally {
        $sha.Dispose()
    }
}

function Test-EndpointLayer {
    param([hashtable]$Target, [int]$TimeoutMs)
    $hostName = $Target.Host
    $layer = $Target.Layer
    $cnameChain = @()
    $ttlSec = $null
    $resolvedCount = 0
    $poolHash = "none"
    $tcpMs = $null
    $tlsMs = $null
    $tlsProto = $null
    $httpCode = $null
    $state = "UNKNOWN"
    $detail = ""

    try {
        if (Get-Command Resolve-DnsName -ErrorAction SilentlyContinue) {
            $dnsRecs = Resolve-DnsName -Name $hostName -Type A -DnsOnly -ErrorAction SilentlyContinue
            if ($dnsRecs) {
                $cnameChain = @($dnsRecs | Where-Object { $_.QueryType -eq "CNAME" } | ForEach-Object { $_.NameHost })
                $aRecs = @($dnsRecs | Where-Object { $_.QueryType -eq "A" })
                $resolvedCount = $aRecs.Count
                if ($dnsRecs.Count -gt 0) {
                    $ttlSec = ($dnsRecs | Select-Object -ExpandProperty TTL | Measure-Object -Minimum).Minimum
                }
                if ($aRecs.Count -gt 0) {
                    $poolHash = Get-MaskedPoolHash -Addresses ($aRecs | ForEach-Object { $_.IPAddress })
                }
            }
        }
        if ($resolvedCount -eq 0) {
            $ips = [System.Net.Dns]::GetHostAddresses($hostName)
            $resolvedCount = $ips.Count
            $poolHash = Get-MaskedPoolHash -Addresses ($ips | ForEach-Object { $_.IPAddressToString })
        }
    } catch {
        return [ordered]@{
            layer = $layer; host = $hostName; cname_chain = $cnameChain; ttl_seconds = $ttlSec
            resolved_record_count = 0; address_pool_hash = "none"; tcp_443_ms = $null
            tls_handshake_ms = $null; tls_version = $null; http_status = $null
            state = "DNS_FAIL"; detail = "DNS resolution failed"
        }
    }

    if ($resolvedCount -eq 0) {
        return [ordered]@{
            layer = $layer; host = $hostName; cname_chain = $cnameChain; ttl_seconds = $ttlSec
            resolved_record_count = 0; address_pool_hash = "none"; tcp_443_ms = $null
            tls_handshake_ms = $null; tls_version = $null; http_status = $null
            state = "DNS_FAIL"; detail = "Zero A/AAAA records returned"
        }
    }

    $tcp = $null; $ssl = $null
    try {
        $tcp = New-Object System.Net.Sockets.TcpClient
        $swTcp = [System.Diagnostics.Stopwatch]::StartNew()
        $arTcp = $tcp.BeginConnect($hostName, 443, $null, $null)
        if (-not $arTcp.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
            $swTcp.Stop(); $tcp.Close()
            return [ordered]@{
                layer = $layer; host = $hostName; cname_chain = $cnameChain; ttl_seconds = $ttlSec
                resolved_record_count = $resolvedCount; address_pool_hash = $poolHash
                tcp_443_ms = [math]::Round($swTcp.Elapsed.TotalMilliseconds, 1)
                tls_handshake_ms = $null; tls_version = $null; http_status = $null
                state = "TCP_TIMEOUT"; detail = "TCP 443 SYN timed out"
            }
        }
        $tcp.EndConnect($arTcp); $swTcp.Stop()
        $tcpMs = [math]::Round($swTcp.Elapsed.TotalMilliseconds, 1)

        $stream = $tcp.GetStream()
        $stream.ReadTimeout = $TimeoutMs; $stream.WriteTimeout = $TimeoutMs
        $ssl = New-Object System.Net.Security.SslStream($stream, $false)
        $swTls = [System.Diagnostics.Stopwatch]::StartNew()
        $authTask = $ssl.AuthenticateAsClientAsync($hostName)
        if (-not $authTask.Wait($TimeoutMs)) {
            $swTls.Stop()
            return [ordered]@{
                layer = $layer; host = $hostName; cname_chain = $cnameChain; ttl_seconds = $ttlSec
                resolved_record_count = $resolvedCount; address_pool_hash = $poolHash
                tcp_443_ms = $tcpMs; tls_handshake_ms = [math]::Round($swTls.Elapsed.TotalMilliseconds, 1)
                tls_version = $null; http_status = $null
                state = "TLS_STALL_OR_DEGRADED"; detail = "TCP open but TLS ClientHello stalled"
            }
        }
        $swTls.Stop()
        $tlsMs = [math]::Round($swTls.Elapsed.TotalMilliseconds, 1)
        $tlsProto = $ssl.SslProtocol.ToString()

        $req = "HEAD $($Target.Path) HTTP/1.1`r`nHost: $hostName`r`nConnection: close`r`nUser-Agent: M365-Triage-Win11/1.0`r`n`r`n"
        $reqBytes = [System.Text.Encoding]::ASCII.GetBytes($req)
        $ssl.Write($reqBytes, 0, $reqBytes.Length); $ssl.Flush()
        $reader = New-Object System.IO.StreamReader($ssl)
        $statusLine = $reader.ReadLine()
        if ($statusLine -match "HTTP/\S+\s+(\d{3})") { $httpCode = [int]$Matches[1] }

        if ($httpCode -ge 500 -or $tlsMs -gt 2000) {
            $state = "TLS_STALL_OR_DEGRADED"; $detail = "High TLS latency or HTTP 5xx ($httpCode)"
        } else {
            $state = "HEALTHY"; $detail = "OK"
        }
    } catch {
        $state = if ($tcpMs) { "TLS_STALL_OR_DEGRADED" } else { "TCP_TIMEOUT" }
        $detail = "Connection exception during handshake/HTTP probe"
    } finally {
        if ($ssl) { try { $ssl.Dispose() } catch {} }
        if ($tcp) { try { $tcp.Dispose() } catch {} }
    }

    return [ordered]@{
        layer = $layer; host = $hostName; cname_chain = $cnameChain; ttl_seconds = $ttlSec
        resolved_record_count = $resolvedCount; address_pool_hash = $poolHash
        tcp_443_ms = $tcpMs; tls_handshake_ms = $tlsMs; tls_version = $tlsProto
        http_status = $httpCode; state = $state; detail = $detail
    }
}

$results = @()
foreach ($t in $Targets) { $results += Test-EndpointLayer -Target $t -TimeoutMs $TimeoutMs }

$domainInfo = $null
if (-not $Auto -and -not $Domain) {
    $inputDomain = Read-Host "Optional: Enter custom mail domain for MX check (press Enter to skip)"
    if ($inputDomain) { $Domain = $inputDomain.Trim() }
}
if ($Domain) {
    try {
        $mx = Resolve-DnsName -Name $Domain -Type MX -ErrorAction SilentlyContinue | Sort-Object Preference
        $domainInfo = [ordered]@{
            domain = $Domain
            mx_hosts = @($mx | ForEach-Object { $_.NameExchange })
            mx_uses_exo = [bool]($mx | Where-Object { $_.NameExchange -like "*.mail.protection.outlook.com*" -or $_.NameExchange -like "*.mx.microsoft*" })
        }
    } catch { $domainInfo = [ordered]@{ domain = $Domain; mx_hosts = @(); mx_uses_exo = $false } }
}

$authOk = ($results | Where-Object { $_.layer -eq "Identity_Auth" }).state -eq "HEALTHY"
$edgeOk = ($results | Where-Object { $_.layer -eq "Edge_FrontDoor" }).state -eq "HEALTHY"
$exoState = ($results | Where-Object { $_.layer -eq "Mailbox_EXO" }).state
$webState = ($results | Where-Object { $_.layer -eq "Mailbox_Web" }).state
$allDnsFail = (@($results | Where-Object { $_.state -eq "DNS_FAIL" }).Count -eq $results.Count)

$signature = "NORMAL"; $exitCode = 0
if ($allDnsFail) { $signature = "LOCAL_DNS_OR_NETWORK_DOWN"; $exitCode = 3 }
elseif ($authOk -and $edgeOk -and (($exoState -ne "HEALTHY") -or ($webState -ne "HEALTHY"))) {
    $signature = "M365_MAILBOX_TLS_CASCADE"; $exitCode = 2
} elseif (@($results | Where-Object { $_.state -ne "HEALTHY" }).Count -gt 0) {
    $signature = "PARTIAL_EDGE_OR_WORKLOAD_DEGRADATION"; $exitCode = 2
}

$actions = @(); $doFlushDns = $FlushDns.IsPresent; $doClearCache = $ClearClientCache.IsPresent

if (-not $Auto) {
    Write-Host "`n=== Microsoft 365 Outage Triage (Windows 11) ===" -ForegroundColor Cyan
    $results | Select-Object layer, host, ttl_seconds, resolved_record_count, tcp_443_ms, tls_handshake_ms, http_status, state | Format-Table -AutoSize
    Write-Host "Diagnosis Signature: $signature" -ForegroundColor Yellow
    if (-not $doFlushDns) {
        $ans = Read-Host "Flush Windows DNS cache (Clear-DnsClientCache) to drop stale CNAME/A records? [y/N]"
        if ($ans -match "^[Yy]$") { $doFlushDns = $true }
    }
    if (-not $doClearCache) {
        $ans2 = Read-Host "Backup & clear volatile New Outlook WebView2 / OneAuth temp cache (preserves OST/PST mail)? [y/N]"
        if ($ans2 -match "^[Yy]$") { $doClearCache = $true }
    }
}

if ($doFlushDns) {
    try {
        if (Get-Command Clear-DnsClientCache -ErrorAction SilentlyContinue) { Clear-DnsClientCache } else { & ipconfig /flushdns | Out-Null }
        $actions += "flushed_windows_dns_cache"
    } catch { $actions += "dns_flush_failed" }
}

if ($doClearCache) {
    $stamp = (Get-Date).ToString("yyyyMMdd-HHmmss")
    foreach ($p in @("$env:LOCALAPPDATA\Microsoft\Olk\EBWebView\Default\Service Worker\CacheStorage", "$env:LOCALAPPDATA\Microsoft\OneAuth\cache")) {
        if (Test-Path $p) {
            try { Move-Item -Path $p -Destination ($p + ".bak-" + $stamp) -Force; $actions += ("backed_up_volatile_cache:" + (Split-Path $p -Leaf)) }
            catch { $actions += ("skipped_locked_cache:" + (Split-Path $p -Leaf)) }
        }
    }
}

$payload = [ordered]@{
    schema_version = "1.0"
    timestamp_utc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
    os_platform = "Windows 11"
    diagnosis_signature = $signature
    checks = $results
    domain_mx_audit = $domainInfo
    actions_taken = $actions
    recommended_next_steps = @(
        "If Auth/Edge are HEALTHY while Mailbox_EXO shows TLS_STALL_OR_DEGRADED, do NOT reinstall Office or recreate OST profiles.",
        "Avoid hammering F5 during recovery; wait 5-10 minutes or flush stale 300s CNAME DNS cache once status recovers.",
        "Wait up to 48 hours for deferred SMTP queue delivery before declaring messages lost."
    )
}

if ($Json -or $Auto) { $payload | ConvertTo-Json -Depth 6 }
else {
    Write-Host ("Actions completed: " + ($(if ($actions.Count) { $actions -join ", " } else { "none" }))) -ForegroundColor Green
    $payload.recommended_next_steps | ForEach-Object { Write-Host ("  - " + $_) }
}
exit $exitCode

Windows 11 两种运行方式(人类人工执行 vs Agent 自动配置):

2. Ubuntu 26.04 一键诊断与恢复脚本(零依赖 Bash + 原生 python3 stdlib 版)

将以下内容保存为 m365_outage_triage_ubuntu2604.sh 并执行 chmod +x。完全利用 Ubuntu 26.04 预装的 python3 标准库与 systemd-resolved (resolvectl),无需安装任何第三方软件包:

#!/usr/bin/env bash
# Microsoft 365 Outage Triage & Post-Recovery Client Helper for Ubuntu 26.04
# Zero 3rd-party dependencies (uses OS python3 stdlib & resolvectl).
set -euo pipefail

AUTO=0; JSON_OUT=0; FLUSH_DNS=0; CLEAR_CACHE=0; DOMAIN=""; TIMEOUT_SEC=5

while [[ $# -gt 0 ]]; do
  case "$1" in
    --auto) AUTO=1; JSON_OUT=1; shift ;;
    --json) JSON_OUT=1; shift ;;
    --flush-dns) FLUSH_DNS=1; shift ;;
    --clear-client-cache) CLEAR_CACHE=1; shift ;;
    --domain) DOMAIN="${2:-}"; shift 2 ;;
    --timeout) TIMEOUT_SEC="${2:-5}"; shift 2 ;;
    -h|--help)
      echo "Usage: $0 [--auto] [--json] [--flush-dns] [--clear-client-cache] [--domain example.com]"
      exit 0 ;;
    *) echo "Unknown argument: $1" >&2; exit 1 ;;
  esac
done

if [[ $AUTO -eq 0 && -z "$DOMAIN" && -t 0 ]]; then
  read -r -p "Optional: Enter custom mail domain for MX check (Enter to skip): " DOMAIN || true
fi

PROBE_JSON="$(python3 - "$TIMEOUT_SEC" "$DOMAIN" <<'PYEOF'
import hashlib, json, shutil, socket, ssl, subprocess, sys, time
from datetime import datetime, timezone

timeout = float(sys.argv[1])
domain = sys.argv[2].strip() if len(sys.argv) > 2 else ""

targets = [
    ("Edge_FrontDoor", "www.microsoft.com", "/"),
    ("Identity_Auth", "login.microsoftonline.com", "/common/v2.0/.well-known/openid-configuration"),
    ("Graph_API", "graph.microsoft.com", "/v1.0/$metadata"),
    ("Teams_Core", "teams.microsoft.com", "/"),
    ("Mailbox_EXO", "outlook.office365.com", "/owa/healthcheck.htm"),
    ("Mailbox_Web", "outlook.office.com", "/mail/"),
    ("Service_Health", "status.cloud.microsoft", "/"),
]

def get_dns_meta(host):
    cnames, ttl, ips = [], None, []
    if shutil.which("resolvectl"):
        try:
            out = subprocess.check_output(["resolvectl", "query", "--legend=no", host], timeout=timeout, text=True, stderr=subprocess.DEVNULL)
            for line in out.splitlines():
                if "looks like" in line or "authenticated" in line: continue
                for tok in line.split():
                    if ("." in tok) and (tok.endswith("microsoft.com") or tok.endswith("cloud.microsoft")):
                        clean = tok.rstrip(".,:")
                        if clean != host and clean not in cnames: cnames.append(clean)
        except Exception: pass
    try:
        infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
        ips = sorted({item[4][0] for item in infos})
    except Exception: ips = []
    pool_hash = "pool-" + hashlib.sha256(",".join(ips).encode()).hexdigest()[:8] if ips else "none"
    return cnames, ttl, len(ips), pool_hash

checks = []
for layer, host, path in targets:
    cnames, ttl, rec_cnt, pool_hash = get_dns_meta(host)
    if rec_cnt == 0:
        checks.append({
            "layer": layer, "host": host, "cname_chain": cnames, "ttl_seconds": ttl,
            "resolved_record_count": 0, "address_pool_hash": "none", "tcp_443_ms": None,
            "tls_handshake_ms": None, "tls_version": None, "http_status": None,
            "state": "DNS_FAIL", "detail": "Zero A/AAAA records returned"
        })
        continue

    tcp_ms = tls_ms = tls_ver = http_status = None
    sock = ssock = None
    try:
        t0 = time.monotonic()
        sock = socket.create_connection((host, 443), timeout=timeout)
        tcp_ms = round((time.monotonic() - t0) * 1000.0, 1)
        ctx = ssl.create_default_context()
        t1 = time.monotonic()
        ssock = ctx.wrap_socket(sock, server_hostname=host)
        tls_ms = round((time.monotonic() - t1) * 1000.0, 1)
        tls_ver = ssock.version()
        req = f"HEAD {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: M365-Triage-Ubuntu2604/1.0\r\n\r\n"
        ssock.settimeout(timeout); ssock.sendall(req.encode("ascii"))
        head = ssock.recv(256).decode("latin1", errors="ignore")
        parts = (head.splitlines()[0] if head else "").split()
        if len(parts) >= 2 and parts[1].isdigit(): http_status = int(parts[1])
        if (http_status and http_status >= 500) or (tls_ms and tls_ms > 2000.0):
            state, detail = "TLS_STALL_OR_DEGRADED", f"High TLS latency or HTTP {http_status}"
        else:
            state, detail = "HEALTHY", "OK"
    except ssl.SSLWantReadError:
        state, detail = "TLS_STALL_OR_DEGRADED", "TLS ClientHello stalled"
    except TimeoutError:
        state = "TCP_TIMEOUT" if tcp_ms is None else "TLS_STALL_OR_DEGRADED"
        detail = "TCP 443 SYN timed out" if tcp_ms is None else "TCP SYN-ACK succeeded; TLS handshake stalled"
    except Exception as exc:
        state = "TLS_STALL_OR_DEGRADED" if tcp_ms is not None else "TCP_TIMEOUT"
        detail = f"Socket error: {type(exc).__name__}"
    finally:
        for s in (ssock, sock):
            if s:
                try: s.close()
                except Exception: pass

    checks.append({
        "layer": layer, "host": host, "cname_chain": cnames, "ttl_seconds": ttl,
        "resolved_record_count": rec_cnt, "address_pool_hash": pool_hash,
        "tcp_443_ms": tcp_ms, "tls_handshake_ms": tls_ms, "tls_version": tls_ver,
        "http_status": http_status, "state": state, "detail": detail
    })

domain_audit = None
if domain and shutil.which("resolvectl"):
    try:
        out = subprocess.check_output(["resolvectl", "query", "--type=MX", "--legend=no", domain], timeout=timeout, text=True, stderr=subprocess.DEVNULL)
        mx_hosts = [ln.strip().split()[-1].rstrip(".") for ln in out.splitlines() if ln.strip()]
        domain_audit = {"domain": domain, "mx_hosts": mx_hosts, "mx_uses_exo": any("outlook.com" in m or "mx.microsoft" in m for m in mx_hosts)}
    except Exception: domain_audit = {"domain": domain, "mx_hosts": [], "mx_uses_exo": False}

by_layer = {c["layer"]: c["state"] for c in checks}
if all(c["state"] == "DNS_FAIL" for c in checks): sig, code = "LOCAL_DNS_OR_NETWORK_DOWN", 3
elif by_layer.get("Identity_Auth") == "HEALTHY" and by_layer.get("Edge_FrontDoor") == "HEALTHY" and (by_layer.get("Mailbox_EXO") != "HEALTHY" or by_layer.get("Mailbox_Web") != "HEALTHY"):
    sig, code = "M365_MAILBOX_TLS_CASCADE", 2
elif any(c["state"] != "HEALTHY" for c in checks): sig, code = "PARTIAL_EDGE_OR_WORKLOAD_DEGRADATION", 2
else: sig, code = "NORMAL", 0

print(json.dumps({
    "schema_version": "1.0", "timestamp_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
    "os_platform": "Ubuntu 26.04", "diagnosis_signature": sig, "exit_code": code,
    "checks": checks, "domain_mx_audit": domain_audit
}))
PYEOF
)"

if [[ $AUTO -eq 0 && -t 0 ]]; then
  echo -e "\n=== Microsoft 365 Outage Triage (Ubuntu 26.04) ==="
  python3 - "$PROBE_JSON" <<'PYEOF'
import json, sys
data = json.loads(sys.argv[1])
fmt = "{:<16} {:<26} {:<5} {:<8} {:<8} {:<5} {}"
print(fmt.format("LAYER", "HOST", "RECS", "TCP(ms)", "TLS(ms)", "HTTP", "STATE"))
print("-" * 88)
for c in data["checks"]:
    print(fmt.format(c["layer"], c["host"], str(c["resolved_record_count"]),
        str(c["tcp_443_ms"] if c["tcp_443_ms"] is not None else "-"),
        str(c["tls_handshake_ms"] if c["tls_handshake_ms"] is not None else "-"),
        str(c["http_status"] if c["http_status"] is not None else "-"), c["state"]))
print(f"\nDiagnosis Signature: {data['diagnosis_signature']}")
PYEOF
  if [[ $FLUSH_DNS -eq 0 ]]; then
    read -r -p "Flush systemd-resolved DNS cache (resolvectl flush-caches)? [y/N]: " ans_dns || true
    [[ "$ans_dns" =~ ^[Yy]$ ]] && FLUSH_DNS=1
  fi
  if [[ $CLEAR_CACHE -eq 0 ]]; then
    read -r -p "Backup & clear volatile browser/PWA Outlook/Teams ServiceWorker cache? [y/N]: " ans_cache || true
    [[ "$ans_cache" =~ ^[Yy]$ ]] && CLEAR_CACHE=1
  fi
fi

ACTIONS=()
if [[ $FLUSH_DNS -eq 1 ]] && command -v resolvectl >/dev/null 2>&1; then
  if resolvectl flush-caches 2>/dev/null || sudo -n resolvectl flush-caches 2>/dev/null; then
    ACTIONS+=("flushed_systemd_resolved_cache")
  else
    ACTIONS+=("dns_flush_requires_sudo")
  fi
fi

if [[ $CLEAR_CACHE -eq 1 ]]; then
  STAMP="$(date +%Y%m%d-%H%M%S)"
  for dir in "$HOME/.config/Microsoft/Microsoft Teams/Service Worker/CacheStorage" "$HOME/.config/google-chrome/Default/Service Worker/CacheStorage" "$HOME/.cache/microsoft-edge/Default/Cache"; do
    [[ -e "$dir" ]] && mv "$dir" "${dir}.bak-${STAMP}" 2>/dev/null && ACTIONS+=("backed_up_volatile_cache:$(basename "$dir")") || true
  done
fi

FINAL_OUT="$(python3 - "$PROBE_JSON" "${ACTIONS[*]:-}" <<'PYEOF'
import json, sys
data = json.loads(sys.argv[1]); code = data.pop("exit_code", 0)
data["actions_taken"] = [a for a in sys.argv[2].split() if a]
data["recommended_next_steps"] = [
    "If Identity_Auth and Edge_FrontDoor are HEALTHY while Mailbox_EXO shows TLS_STALL_OR_DEGRADED, do NOT churn local mail configs.",
    "Avoid rapid manual retries during recovery; flush stale 300s CNAME caches once Microsoft mitigates.",
    "Expect up to 48 hours for deferred SMTP queues to clear completely."
]
print(json.dumps({"code": code, "payload": data}))
PYEOF
)"

EXIT_CODE="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["code"])' "$FINAL_OUT")"
if [[ $JSON_OUT -eq 1 ]]; then
  python3 -c 'import json,sys; print(json.dumps(json.loads(sys.argv[1])["payload"], indent=2))' "$FINAL_OUT"
else
  echo "Actions completed: ${ACTIONS[*]:-none}"
fi
exit "$EXIT_CODE"

Ubuntu 26.04 两种运行方式:

3. macOS 27 一键诊断与恢复脚本(零依赖 Zsh + dig + dscacheutil 版)

将以下内容保存为 m365_outage_triage_macos27.zsh。以下脚本已在真实的 macOS 环境完成语法与实机运行验证:

#!/usr/bin/env zsh
# Microsoft 365 Outage Triage & Post-Recovery Client Helper for macOS 27
# Zero 3rd-party dependencies (native macOS dig / python3 / dscacheutil).
set -euo pipefail

AUTO=0; JSON_OUT=0; FLUSH_DNS=0; CLEAR_CACHE=0; DOMAIN=""; TIMEOUT_SEC=5

while [[ $# -gt 0 ]]; do
  case "$1" in
    --auto) AUTO=1; JSON_OUT=1; shift ;;
    --json) JSON_OUT=1; shift ;;
    --flush-dns) FLUSH_DNS=1; shift ;;
    --clear-client-cache) CLEAR_CACHE=1; shift ;;
    --domain) DOMAIN="${2:-}"; shift 2 ;;
    --timeout) TIMEOUT_SEC="${2:-5}"; shift 2 ;;
    -h|--help)
      echo "Usage: $0 [--auto] [--json] [--flush-dns] [--clear-client-cache] [--domain example.com]"
      exit 0 ;;
    *) echo "Unknown argument: $1" >&2; exit 1 ;;
  esac
done

if [[ $AUTO -eq 0 && -z "$DOMAIN" && -t 0 ]]; then
  printf "Optional: Enter custom mail domain for MX check (Enter to skip): "
  read -r DOMAIN || true
fi

PROBE_JSON="$(python3 - "$TIMEOUT_SEC" "$DOMAIN" <<'PYEOF'
import hashlib, json, shutil, socket, ssl, subprocess, sys, time
from datetime import datetime, timezone

timeout = float(sys.argv[1])
domain = sys.argv[2].strip() if len(sys.argv) > 2 else ""

targets = [
    ("Edge_FrontDoor", "www.microsoft.com", "/"),
    ("Identity_Auth", "login.microsoftonline.com", "/common/v2.0/.well-known/openid-configuration"),
    ("Graph_API", "graph.microsoft.com", "/v1.0/$metadata"),
    ("Teams_Core", "teams.microsoft.com", "/"),
    ("Mailbox_EXO", "outlook.office365.com", "/owa/healthcheck.htm"),
    ("Mailbox_Web", "outlook.office.com", "/mail/"),
    ("Service_Health", "status.cloud.microsoft", "/"),
]

def get_dns_meta(host):
    cnames, ttls = [], []
    if shutil.which("dig"):
        try:
            out = subprocess.check_output(["dig", "+noall", "+answer", host, "A"], timeout=timeout, text=True, stderr=subprocess.DEVNULL)
            for line in out.splitlines():
                cols = line.split()
                if len(cols) >= 5:
                    try: ttls.append(int(cols[1]))
                    except ValueError: pass
                    val = cols[4].rstrip(".")
                    if cols[3] == "CNAME" and val not in cnames: cnames.append(val)
        except Exception: pass
    try:
        infos = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
        ips = sorted({item[4][0] for item in infos})
    except Exception: ips = []
    pool_hash = "pool-" + hashlib.sha256(",".join(ips).encode()).hexdigest()[:8] if ips else "none"
    return cnames, (min(ttls) if ttls else None), len(ips), pool_hash

checks = []
for layer, host, path in targets:
    cnames, ttl, rec_cnt, pool_hash = get_dns_meta(host)
    if rec_cnt == 0:
        checks.append({
            "layer": layer, "host": host, "cname_chain": cnames, "ttl_seconds": ttl,
            "resolved_record_count": 0, "address_pool_hash": "none", "tcp_443_ms": None,
            "tls_handshake_ms": None, "tls_version": None, "http_status": None,
            "state": "DNS_FAIL", "detail": "Zero A/AAAA records returned"
        })
        continue

    tcp_ms = tls_ms = tls_ver = http_status = None
    sock = ssock = None
    try:
        t0 = time.monotonic()
        sock = socket.create_connection((host, 443), timeout=timeout)
        tcp_ms = round((time.monotonic() - t0) * 1000.0, 1)
        ctx = ssl.create_default_context()
        t1 = time.monotonic()
        ssock = ctx.wrap_socket(sock, server_hostname=host)
        tls_ms = round((time.monotonic() - t1) * 1000.0, 1)
        tls_ver = ssock.version()
        req = f"HEAD {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: M365-Triage-macOS27/1.0\r\n\r\n"
        ssock.settimeout(timeout); ssock.sendall(req.encode("ascii"))
        head = ssock.recv(256).decode("latin1", errors="ignore")
        parts = (head.splitlines()[0] if head else "").split()
        if len(parts) >= 2 and parts[1].isdigit(): http_status = int(parts[1])
        if (http_status and http_status >= 500) or (tls_ms and tls_ms > 2000.0):
            state, detail = "TLS_STALL_OR_DEGRADED", f"High TLS latency or HTTP {http_status}"
        else:
            state, detail = "HEALTHY", "OK"
    except ssl.SSLWantReadError:
        state, detail = "TLS_STALL_OR_DEGRADED", "TLS ClientHello stalled"
    except TimeoutError:
        state = "TCP_TIMEOUT" if tcp_ms is None else "TLS_STALL_OR_DEGRADED"
        detail = "TCP 443 SYN timed out" if tcp_ms is None else "TCP SYN-ACK succeeded; TLS handshake stalled"
    except Exception as exc:
        state = "TLS_STALL_OR_DEGRADED" if tcp_ms is not None else "TCP_TIMEOUT"
        detail = f"Socket error: {type(exc).__name__}"
    finally:
        for s in (ssock, sock):
            if s:
                try: s.close()
                except Exception: pass

    checks.append({
        "layer": layer, "host": host, "cname_chain": cnames, "ttl_seconds": ttl,
        "resolved_record_count": rec_cnt, "address_pool_hash": pool_hash,
        "tcp_443_ms": tcp_ms, "tls_handshake_ms": tls_ms, "tls_version": tls_ver,
        "http_status": http_status, "state": state, "detail": detail
    })

domain_audit = None
if domain and shutil.which("dig"):
    try:
        out = subprocess.check_output(["dig", "+short", domain, "MX"], timeout=timeout, text=True, stderr=subprocess.DEVNULL)
        mx_hosts = [ln.split()[-1].rstrip(".") for ln in out.splitlines() if ln.strip()]
        domain_audit = {"domain": domain, "mx_hosts": mx_hosts, "mx_uses_exo": any("outlook.com" in m or "mx.microsoft" in m for m in mx_hosts)}
    except Exception: domain_audit = {"domain": domain, "mx_hosts": [], "mx_uses_exo": False}

by_layer = {c["layer"]: c["state"] for c in checks}
if all(c["state"] == "DNS_FAIL" for c in checks): sig, code = "LOCAL_DNS_OR_NETWORK_DOWN", 3
elif by_layer.get("Identity_Auth") == "HEALTHY" and by_layer.get("Edge_FrontDoor") == "HEALTHY" and (by_layer.get("Mailbox_EXO") != "HEALTHY" or by_layer.get("Mailbox_Web") != "HEALTHY"):
    sig, code = "M365_MAILBOX_TLS_CASCADE", 2
elif any(c["state"] != "HEALTHY" for c in checks): sig, code = "PARTIAL_EDGE_OR_WORKLOAD_DEGRADATION", 2
else: sig, code = "NORMAL", 0

print(json.dumps({
    "schema_version": "1.0", "timestamp_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
    "os_platform": "macOS 27", "diagnosis_signature": sig, "exit_code": code,
    "checks": checks, "domain_mx_audit": domain_audit
}))
PYEOF
)"

if [[ $AUTO -eq 0 && -t 0 ]]; then
  echo -e "\n=== Microsoft 365 Outage Triage (macOS 27) ==="
  python3 - "$PROBE_JSON" <<'PYEOF'
import json, sys
data = json.loads(sys.argv[1])
fmt = "{:<16} {:<26} {:<5} {:<5} {:<8} {:<8} {:<5} {}"
print(fmt.format("LAYER", "HOST", "TTL", "RECS", "TCP(ms)", "TLS(ms)", "HTTP", "STATE"))
print("-" * 92)
for c in data["checks"]:
    print(fmt.format(c["layer"], c["host"],
        str(c["ttl_seconds"] if c["ttl_seconds"] is not None else "-"),
        str(c["resolved_record_count"]),
        str(c["tcp_443_ms"] if c["tcp_443_ms"] is not None else "-"),
        str(c["tls_handshake_ms"] if c["tls_handshake_ms"] is not None else "-"),
        str(c["http_status"] if c["http_status"] is not None else "-"), c["state"]))
print(f"\nDiagnosis Signature: {data['diagnosis_signature']}")
PYEOF
  if [[ $FLUSH_DNS -eq 0 ]]; then
    printf "Flush macOS DNS cache (dscacheutil + mDNSResponder)? [y/N]: "
    read -r ans_dns || true
    [[ "$ans_dns" =~ ^[Yy]$ ]] && FLUSH_DNS=1
  fi
  if [[ $CLEAR_CACHE -eq 0 ]]; then
    printf "Backup & clear volatile Outlook/Teams WebView cache (preserves local database/mail)? [y/N]: "
    read -r ans_cache || true
    [[ "$ans_cache" =~ ^[Yy]$ ]] && CLEAR_CACHE=1
  fi
fi

ACTIONS=()
if [[ $FLUSH_DNS -eq 1 ]]; then
  dscacheutil -flushcache 2>/dev/null || true
  if sudo -n killall -HUP mDNSResponder 2>/dev/null; then
    ACTIONS+=("flushed_macos_dns_and_mdnsresponder")
  else
    ACTIONS+=("flushed_dscacheutil_only_mdnsresponder_needs_sudo")
  fi
fi

if [[ $CLEAR_CACHE -eq 1 ]]; then
  STAMP="$(date +%Y%m%d-%H%M%S)"
  for dir in "$HOME/Library/Containers/com.microsoft.Outlook/Data/Library/Caches/WebKit" "$HOME/Library/Containers/com.microsoft.teams2/Data/Library/Caches"; do
    [[ -e "$dir" ]] && mv "$dir" "${dir}.bak-${STAMP}" 2>/dev/null && ACTIONS+=("backed_up_volatile_cache:$(basename "$dir")") || true
  done
fi

FINAL_OUT="$(python3 - "$PROBE_JSON" "${ACTIONS[*]:-}" <<'PYEOF'
import json, sys
data = json.loads(sys.argv[1]); code = data.pop("exit_code", 0)
data["actions_taken"] = [a for a in sys.argv[2].split() if a]
data["recommended_next_steps"] = [
    "If Identity_Auth and Edge_FrontDoor are HEALTHY while Mailbox_EXO shows TLS_STALL_OR_DEGRADED, do NOT delete Outlook local profile.",
    "Do not spam reconnect/reload during Microsoft's rolling restarts; flush stale 300s CNAME caches once service recovers.",
    "Allow up to 48 hours for delayed SMTP queues to drain cleanly."
]
print(json.dumps({"code": code, "payload": data}))
PYEOF
)"

EXIT_CODE="$(python3 -c 'import json,sys; print(json.loads(sys.argv[1])["code"])' "$FINAL_OUT")"
if [[ $JSON_OUT -eq 1 ]]; then
  python3 -c 'import json,sys; print(json.dumps(json.loads(sys.argv[1])["payload"], indent=2))' "$FINAL_OUT"
else
  echo "Actions completed: ${ACTIONS[*]:-none}"
fi
exit "$EXIT_CODE"

macOS 27 两种运行方式与实机验证截图:

真实终端执行截图:macOS 27 下人类交互模式与 AI Agent JSON 双模式零敏感信息输出实录

图 15:m365_outage_triage_macos27.zsh 真实运行输出截图——左侧为人类交互模式分层体检表,右侧为 AI Agent 自动巡检输出的可机读脱敏 JSON(零完整 IP 与主机名暴露)。

七、面向下一场大宕机:企业 IT 真正值得落地的 4 条抗毁底线

每当巨型云服务瘫痪落幕,最怕发生的事就是“两天后风平浪静,大家接着裸奔”。经历过这轮长达 20 小时的“无纸化倒车”冲击,任何强依赖 Microsoft 365 的团队,至少应当在下周一早会前对齐四条底线:

  1. 把应急告警链彻底搬出 M365(Out-of-Band Alerting):就像本次英国与美国机构遇到的尴尬——当 Exchange 底座和 Teams 日历同步失效,所有走 Office 365 SMTP 告警的监控剧本全成了哑巴。高优先级监控与值班呼救必须走独立的带外 SMS、独立邮件中继或物理异构推送渠道。
  2. 给关键岗配备冷备份的“48 小时离线作战包”:无论是医院手术排班表、外勤调度表还是重大故障升级通讯录,每天凌晨定时自动导出极轻量的只读离线副本(PDF / 本地日历静态快照)。一旦云端再现“收费站集体卡死”,前线手里至少还有一张能按图索骥的纸条。
  3. 核心基础设施监控必须淘汰“只看 TCP Ping”:如本文实测所揭示,当 TLS 握手工作进程窒息时,操作系统的 TCP 443 内核依然在毫秒级笑脸相迎。任何关乎核心 SaaS 的合成探测(Synthetic Probe),都必须跑完 完整 TLS 1.3 握手 + HTTP 语义层探测 才能算绿灯。
  4. 认真区分“单账户风险”与“云底座单点耦合风险”:很多公司以为买了最高级的微软企业版许可、开了异地租户就算高可用了——实际上同在 Azure/Exchange 同一套底座网络与配置变更流下的组件依然同呼吸共命运。核心交易或审批通知邮件,应当配置备用的非微软第三方认证发信中继(SPF/DKIM 提前写好预授权)。

八、高频 Q&A:大家最关心的 7 个尖锐问题

Q1:昨天那么大规模的微软瘫痪,到底是不是黑客组织搞的网络攻击,或者最近大火的 AI Agent 自动改配置搞崩的?

不是。 微软业务应用与平台执行副总裁 Charles Lamanna 在恢复说明中明确排除了两种猜测("not related to cyber or AI / agents")。真实原因是早先一次网络容量升级留下的底层缺陷,被 11 月 12 日近期一次常规网络变更意外引爆,继而引发健康检查组件大面积误判摘除节点和剩余服务器过载。

Q2:为什么我同事今天一早邮箱就能发信了,我这边 Outlook 却一直报错到下午?

最主要有两个技术原因:第一,微软为了避免之前经历过的“惊群踩踏”,采取了按区域、小批次重启加新扩容虚拟机分流的策略,不同租户所在的邮箱后端集群与路由通道并非同时恢复;第二,DNS 解析层的 CNAME(300 秒)与 A 记录轮询池(10 秒,约 20–29 个入口)可能导致你电脑或本地路由器的缓存依然粘在尚未彻底切走的健康边缘或坏节点上。运行本文的清理脚本刷新一次本地 DNS 即可快速消除缓存粘连。

Q3:在断网的十几个小时里,别人发给我的重要合同邮件和回复是不是永远丢了?

绝大部分不会丢,但需要耐心等待队列释放。 常规 SMTP 邮件投递协议本来就有延迟重试机制;更关键的是,微软已在本次事故中特意宣布将排队邮件的重试超时窗口延长至 48 小时。部分外部发件人(例如 Gmail 用户)在最严峻的峰值期确实收到了临时弹回提醒或延迟告警,建议对超过 48 小时仍无音讯的重要邮件由发件方确认退信回执并补发一次即可。

Q4:Teams 当时为什么一边能发文字消息,一边日历空白、约不了会议,甚至连 Copilot 也掉线?

因为 Teams 的文字/音视频信令通道与日历/合规模块解耦程度不同。Teams 边缘入口与底层登录(login.microsoftonline.com)以及 Graph API 边缘本身在这次事件中存活良好,但 Teams 日历、会议排期、Information Barriers 以及大量 Microsoft 365 Copilot 上下文查询高度依赖后端 Exchange Substrate 存储与安全连接通道,底层一卡,上层日历与 AI 调用就会连环报错。

Q5:IT 管理员在这类大宕机刚爆发时,最致命的错误操作是什么?

两大自杀式操作:一是批量给全员发通知教大家“删除本地 Outlook 配置文件、重新新建账号同步或重装 Office”(这会毁掉本地离线缓存,并在全网恢复那一刻制造灾难级的本地 OST 重下载洪峰);二是自己写死循环脚本每 1 秒狂发请求测试(进一步加剧服务端刚重启节点的 Thundering Herd 惊群过载)。

Q6:为什么我只是普通 Gmail 或 GoDaddy 用户,昨天却也感觉邮件异常?

正如图 5 的 Downdetector 实测统计所揭示:首先 GoDaddy 是 Microsoft 365 企业邮箱的巨型代理分销商,大量 GoDaddy 品牌域名邮箱后端其实跑在微软集群上;其次 Gmail 自身其实很健康,但因为全网数亿发给 Outlook 和企业 365 用户的信件不断滞留弹回,大量发件人误以为 Gmail 发信出故障而蜂拥上报,造就了 46 倍的“关联误报峰值”。

Q7:既然微软说会出完整的事后审查报告(Post-Incident Review, PIR),最值得普通工程师蹲守看哪一点?

重点关注三处改进承诺:第一,自动健康检查(Health-Check)机制如何增加“防自残熔断阈值(Bulkhead / Circuit Breaker)”,避免自身故障一口气把 70% 的好机器全部踢下线;第二,安全连接(TLS)工作进程在高并发排队下为何缺乏优雅降级与反压保护,以至于出现“内核 TCP 秒通、应用态全死”;第三,如何在灾难恢复阶段从网关源头做好分批流控,彻底根除恢复即踩踏的“Bad State”死锁。关注 margrop-blog,待微软正式公开详尽技术 PIR 后我们也会继续跟进深度拆解。

评论

使用 GitHub 登录后即可评论,中英文版本共用同一讨论。 在 GitHub 上参与讨论

本文阅读量 --