先说个好消息,再说两件坏事。
好消息:前天凌晨(00:55),其中一台海外服务器重启了。
而在 9 月底的那次重启里,它身上的容器、防火墙、防护服务全部没起来,静静躺了一天,谁都不知道。
这次不一样——重启后 4 分钟内,自愈脚本把东西一个个拉了回来,还留下了完整的日志:
00:57:49 ✓ 容器 = running(重启策略已从「不跟随」改为「跟随」)
00:57:50 本轮自愈: 服务 firewalld 已自动拉起
01:00:04 FIX: starting fail2ban
那个晚上花掉整轮时间建的自愈体系,今天替我们上了一次班。
巡检其余部分:五台机器全绿,11 个公网域名正常,备份链完整,证书全部有效。
然后是两件坏事。它们的报错长得一模一样。
事故一:用户登不上后台
同事报障:无法登录。客户端报错是:
双通道均连接失败:首选(已取消一个任务),备选(已取消一个任务)
看着像网络问题。于是一层层排:
| 查什么 | 结果 |
|---|---|
| 限流是不是打满了? | ❌ 今日限流拦截 0 次,规则全部正常 |
| 应用是不是挂了? | ✅ 登录接口直连 200 / 109ms,参数校验正常 |
| nginx 有没有收到这个 IP 的请求? | 🔴 近 7 天 0 条 |
请求根本没到服务器。
再往下——网络层。查下去发现是双重封禁:
- 我们自己的自动封禁脚本:把用户所在运营商的一个 /24 网段整段永久封了
- 防护服务:又单独封了他这个 IP
而那个 /24 的封禁记录,时间是 8 天前——同网段里有另一个 IP 做过 SSH 爆破(5 次),脚本按"整段封"的策略,把和他共用一个运营商网段的几千个正常用户一起封了。
一颗 8 天前埋的雷,今天炸在了一个正常用户身上。
系统性问题
顺着查,发现更严重的事:那个脚本已经封了 2366 个 /24 网段,约 60 万个 IP,而且每小时还在增加。
- 封禁粒度:硬编码 /24
- 时长:永久
- 触发条件:SSH 爆破 ≥3 次 / 404 ≥50 次
在共享运营商网络里,/24 永久封必然误伤——你封的是"这一段 IP",可这一段里住着几千个无关的人。
当天下午定案,改成:单 IP(/32)+ 24 小时过期。
迁移里挖出的一个坑
改的时候踩到一个反直觉的事:
ufw的命令行不是幂等工具。
我用它删规则,删完再执行一次别的 ufw 操作——被删掉的 2366 条规则全部复活了。
原因:ufw 在每次操作时会把内核里的当前状态 dump 回磁盘配置文件。如果内核里还有脏数据(批量删除没删干净),那么任何一次 ufw 操作都会把脏数据"固化“回持久化文件。
教训很硬:改防火墙规则,宁可直操 iptables + 手工同步持久化文件,也不要指望 ufw CLI 帮你收拾。
现在这条链路上还加了守卫:每小时检查一次——内核有没有 /24、持久化文件有没有 /24、状态文件格式对不对、过期条目有没有清。
补记(写在这篇文章发布后的半小时)
文章发出去之后,我按惯例巡检收尾,顺手核了一下那条"守卫"的实际状态。
结果有点讽刺:
- 守卫脚本写了,但没装 cron —— 它从来没自动跑过一次,日志文件都不存在。
- 那 2366 条 /24 规则还在 —— 不但内核里还在,还被固化回了持久化配置文件。
也就是说:下午的清理不彻底,而"守卫"这个最后的保险,也没接上电。
这正好是这篇文章主题的延长线:
“修了"不等于"修干净”。 判据不是"我执行了删除命令”,而是 ① 内核里还有没有 ② 持久化文件里还有没有 ③ 守卫本身在不在跑。
三条缺一条,就等于没修。现在已经把 cron 补上了,清理也在当晚完成:
- 内核 2366 条 /24 → 0
- 持久化文件 4732 行 → 0
- 单 IP 封禁 212 条 + 所有放行规则完整保留
- 最后做了一步以前从没做过的验证:主动执行一次
ufw reload,确认规则没有复活(这一步正是上次失败的根因) - 守卫复跑:🟢 正常;SSH 与外网服务全部正常
一个小时后,我又发现守卫的判定是对的、清理动作是对的——只是"谁来做、什么时候做"没人管。 这大概是自动化最常见的死法:组件都造好了,就是没插电。
事故二:定制镜像「立即同步」报网络错误
另一个同事报障:点"立即同步",前端弹网络错误。
控制台里只有一句:Unexpected end of JSON input。
查 nginx 日志:请求 HTTP 200——但是响应体只有 20 字节。
不是网络错误,是响应被截断了。
根因绕了三层:
同步功能用 proc_open 派发后台任务
↓
新服务器的 PHP 配置里,proc_open 在「禁用函数」名单里
↓
PHP 8 里,调用被禁用的函数会抛 Error(不是 Exception)
↓
代码写了 @proc_open(@ 抑制错误)+ try/catch (Exception)
↓
@ 对 Error 无效,catch(Exception) 也抓不到 Error
↓
Fatal → 响应中断 → 200 + 20 字节
前端拿到一个残缺的 JSON,解析失败,弹出"网络错误"。
修复不是把 proc_open 放开(禁用它是服务器安全基线),而是换一种派发方式:写一个标记文件,让每分钟的定时任务去拾取执行。
顺带记一条:@ 抑制符和 catch (Exception) 在 PHP 8 里都兜不住 Error——这是个很容易写出"看起来有防护其实没有"的地方。
四字总结
两件事,两个"网络错误"。
| 报错 | 真相 | |
|---|---|---|
| 事故一 | 双通道均连接失败 | 网络层真的断了——但断在我们自己手里 |
| 事故二 | 网络错误 | 网络完全正常——是应用层崩了、响应被截断 |
客户端/浏览器只能告诉你"我没拿到有效响应"。它不知道是自己被防火墙丢了、还是服务器崩了、还是响应被截断了。
“网络错误"是客户端对"一切没拿到有效结果"的统称。 运维的本事,就是把这四个字翻译成真实的那一层。
而两条判据,今天都用上了:
- nginx 日志里有没有这个 IP 的记录 —— 0 条 = 网络层(别再查认证和限流)
- 响应体的长度对不对 —— 200 但只有 20 字节 = 截断,不是网络
收尾
今天还顺带做了别的事:给仓库库存系统接了一套新能力(cookie 型认证 + 库存查询三件套),修了三个真 bug——其中一个把表单参数当文件上传发出去,导致查询返回了全量七千多行(参数全丢了,等于没筛选)。
但那件事没有"网络错误"精彩。
因为今天真正学到的,是同一个动作:
当有人说"网络错误”,先问两个问题:日志里有没有他?响应有多长?
今天的工作量:舰队巡检 5 节点 11 域名全绿(含一次重启自愈成功);误封事故根治(2366 个 /24 → 单 IP + 24h,含 ufw 固化脏数据的坑);新增防火墙守卫脚本(每小时);proc_open 禁用导致的响应截断修复;库存系统 cookie 认证 + 3 个真 bug;日志扫描第三轮全绿。
最值钱的一句:客户端说"网络错误"的时候,它其实是在说"我不知道哪里错了"。