运维 | 两台 VPS 的一次夜里手术:从爆炸的负载到失守的端口近期更新
运维 | 两台 VPS 的一次夜里手术:从爆炸的负载到失守的端口
机器不会喊疼。它只会把 load average 一点点推上去,然后在某个你没看它的夜里,把 swap 用光。
写在前面
事情的起点非常随意:我问了一句「看看还有啥可以优化的」。
本来以为只是收几个零散的尾巴,结果一查翻出来一串:一台小规格的机器负载长期在核数的几倍以上,磁盘水位逼近红线,SSH 端口被爆破了一年多(累计数十万次尝试),而其中一台上本该拦住这些的 fail2ban,几个月前就已经悄悄死掉了。
这篇文章记录修复的全过程,也记录我在其中犯的两个错——那两个错比修好的东西更值得写。
一、先是那声听不见的爆炸
问题是在采样里现形的。连续实测了几分钟:
负载一路顶到核数的近十倍
进程列表里挤着几十个 chromium 渲染进程
swap 已经用掉一半一台小机器,负载高到这种程度。看进程列表就明白了:内存里挤着几十个无头浏览器渲染进程,单个就能吃掉将近四分之一的 CPU,swap 已经被用到一半。
链条是这样的:阅读器 → 订阅聚合服务 → 无头浏览器。阅读器订阅了数千个源,其中相当一部分指向自建的订阅聚合服务;而聚合服务的缓存时间只有几分钟,等于没缓存。雪球、豆瓣、Pixiv 这类需要浏览器渲染的路由,每次回源都要拉起一个 chromium。源一多,就是持续的开销。
| 指标 | 处理前 | 处理后 |
|---|---|---|
| 负载 | 核数的近十倍 | 接近空闲 |
| chromium 进程 | 几十个 | 0 |
| 聚合服务请求量 | 每分钟十几次 | 每分钟一两次 |
| 聚合服务内存 | 逼近容器上限 | 砍掉近一半 |
第一刀是把缓存时间拉长数倍,同时把重试次数降下来(豆瓣那几个源常年 403,重试只是雪上加霜)。冷缓存重建时反而更炸了一次——所有源同时回源,负载冲到核数的近十倍——等缓存热起来自己就落回去了。
但那只是止血。
二、我判死源的口径错了
真正让负载掉下来的,是清理死源。而这里我犯了一个错误。
我先按这个条件筛:「有错误 + 超过两周没更新」。筛出一小批,备份、停用、核对——数量对得上,看起来很顺利。
然后我发现,这一批早就被系统自动停用了。我那条 UPDATE 实际上什么都没改变。
原因是阅读器的 last_updated 字段语义:它是「最后一次尝试抓取的时间」,不是「最后一次成功的时间」。已经停用的源不会再被尝试,所以它的 last_updated 永远停在很久以前——我筛出来的恰好全是「因为已停用所以看起来最久没动」的那批,真正的死源一个都没抓到。
正确的口径要往前再走一步:看这个源多久没进过新文章。文章表通过用户文章表与源关联(新版 schema 里 ttrss_entries 没有 feed_id 列,得从 ttrss_user_entries.feed_id 关联 ttrss_entries.id):
with ls as (
select f.id, f.update_interval, coalesce(f.last_error,'') err,
(select max(e.date_entered) from ttrss_user_entries ue
join ttrss_entries e on e.id = ue.ref_id
where ue.feed_id = f.id) last_entry
from ttrss_feeds f)
select count(*) from ls
where update_interval <> -1 and err <> ''
and (last_entry is null or last_entry < now() - interval '60 days');换了这个口径,答案从两位数变成了四位数——真正颗粒无收的源,比第一次筛出来的多了两个数量级。
清完之后,活跃源少了四成左右。停掉的大头是博客类,其次是一整批插画站的路由(那个路由本来就因为缺配置被永久禁用,一直请求一直失败),还有一部分社交媒体源。日常在看的几个分类基本没动。
停用前做了两份东西:一份清单(含 id、标题、地址、分类、最后一次有文章的时间、错误信息),一份一键恢复语句。停用只是把 update_interval 置成 -1,恢复就是改回默认。
教训:判断一件事之前,先问一遍这个字段的时间语义。「命令执行成功」和「事情真的发生了」是两件事。
三、十几 GB 的构建缓存
顺手清了磁盘。最肥的一块是 Docker 的构建缓存,十几 GB,一直躺在一个从没清理过的目录里。
根分区水位 逼近红线 → 一半以下另外两台都被爆破日志撑出了大文件:登录失败记录上百兆、控制面板的 task.log 上百兆(面板早就停了,日志却还在原地长着)。
有意思的是,这一批清理第一次执行超时了——机器太忙,连 docker builder prune 自己都排不上队。改成后台跑、隔一会儿看一次水位,才看着数字一路掉下来。
四、加固之前,先修一条逃生通道
接下来才是这次最需要谨慎的部分。
背景有三个硬约束:
- 云厂商的串口/VNC 控制台进不去——没有带外通道;
- 家庭宽带是动态 IP,白名单随时会失效;
- 每天数万次爆破,SSH 密码登录还开着。
在这种条件下动防火墙和 fail2ban,一旦改错就是「自己被关在门外,而且没有钥匙」。所以我先做了一件跟安全加固无关的事:建逃生通道。
两台服务器装了 Tailscale,组成一个私网。装完之后先不急着改任何配置,而是实测双向 SSH:
主机 A → 主机 B ✅ 登录成功
主机 B → 主机 A ✅ 登录成功(两台机器默认都只信任自己的公钥,双向互通需要把各自的公钥互相授权一份。)
然后让用户的电脑也接进来。这一步比什么都重要——通道只有给「人」用才叫逃生通道,只给运维脚本用没有意义。
这里有个判断值得写下来:我作为 AI 助手是从家庭 IP 走公网登录的,并不在这个私网里。所以公网 SSH 端口不能直接关掉,一关我自己先失联。最终的形态是:
- 人 → 走私网,不依赖 IP 变化,最稳
- 助手 → 走公网端口,靠强制密钥 + fail2ban 兜住
- 两台主机之间 → 私网互通,互为备份
五、fail2ban 的两个坑
第一版配置写完、服务起来、状态里显示「已封禁若干 IP」,一切都像成功了。然后我查了一眼 iptables:
-A INPUT -p tcp -m multiport --dports 22 -j f2b-sshd封的是 22 端口。 而 SSH 早就换到了一个自定义端口上。
原因:iptables-multiport 这个动作的端口取自 action 定义里的默认值(ssh,也就是 22),在 jail 里写 port = 你的端口 是不覆盖它的。所以最终的形态是:拦了一个根本没人监听的端口,真正的攻击流量一路放行。日志里「封禁成功」是真的,防护是假的。
正确写法必须把端口写进 action 本身:
[sshd]
action = iptables-multiport[name=sshd, port=你的端口, protocol=tcp]
enabled = true
port = 你的端口
logpath = /var/log/auth.log
maxretry = 5第二个坑在另一台机器上:那台的 fail2ban 几个月前就 failed 了,服务文件都没了,但它在 iptables 里留下了幽灵链——旧的 f2b-sshd 链和一条 --dports 22 的跳转。新装的配置怎么改都不生效,因为封禁流量走的还是那条旧链。修复办法是先停服务、删掉 INPUT 里的跳转、把残留的链 flush + delete,再启动。
第三个小坑:在那个版本的 fail2ban 里,手工执行 fail2ban-client set sshd banip X 会报 NOT NULL constraint failed: bans.bantime,但 iptables 规则其实已经生效了,解封也一样报错——得手动删规则。
六、让封禁能自己解开
动态 IP + 没有控制台,最怕的其实不是攻击者,是被自己的防护关在门外。所以参数和守护都按「能自愈」来设计:
- 封禁时长按小时计,不是永久
- 失败几次就封,阈值不放太高
- 白名单:本机 + 整个私网段 + 家庭出口 IP
- 外加一个定时守护脚本
守护逻辑很朴素:
凡是历史上用公钥成功登录过的来源 IP,一旦出现在封禁列表里,立刻解封。
这样家庭 IP 换了也能自愈——新 IP 只要成功登录过一次就进信任表;真实爆破的地址照样封着。实测是这么做的:往信任表里塞一个保留地址,手工封掉它,跑一次守护脚本,看它被解封并留下日志。
<时间戳> 解封信任 IP <测试地址>同时把 ufw 收紧到只剩 SSH 和 Web 需要的几个端口,删掉一批放行了却没有任何服务在听的口子(FTP、面板、备用端口那些)。
顺手发现一个隐患:SSH 那个自定义端口根本不在 ufw 里,只有一条手工加的裸 iptables 规则。也就是说,哪天谁 reload 一下 ufw,SSH 就直接断。所以先补一条正规的放行规则,再去删其他规则。
另外记一条容易踩的:Docker 发布的端口会绕过 ufw(走 DOCKER 链),所以改 ufw 不会断网站——但也别指望 ufw 保护容器端口。
七、密码:换掉,而且要验证
这里选了「保留密码登录,但换成足够长的强随机密码」:爆破在数学上不再可能,而人在任何一台设备上(包括只有手机的时候)仍然进得来。
验证分三段,缺一段都不算验过:
| 验证项 | 方法 | 意义 |
|---|---|---|
| 哈希变了 | 对比 /etc/shadow 改动前后的前缀 | 旧密码确实失效了(不需要知道旧密码) |
| 新密码真能登录 | 从另一台机器走私网 SSH 密码登录 | 新密码可用,不是写错了文件 |
| 错误密码被拒 | 用假密码登录,期望 Permission denied | 密码认证链路真的在生效 |
有个细节:测试错误密码时不能用自己正在用的出口 IP。fail2ban 会把它封掉,虽然守护脚本会解,但白白多一个变量。用私网地址做源最干净,因为整段都在白名单里。
测完把用于测试的密码工具立刻卸载,不留东西。
八、最后的状态
| 项目 | 处理前 | 处理后 |
|---|---|---|
| 负载(小规格机器) | 核数的近十倍 | 接近空闲 |
| 磁盘水位 | 逼近红线 | 一半以下 |
| 活跃订阅源 | 数千条 | 减少了四成左右 |
| fail2ban | 一台没装、一台死掉几个月 | 两台启用 + 开机自启 + 自动解封守护 |
| ufw 放行 | 一批空口子 | 只剩 SSH 和 Web 需要的端口 |
| 带外通道 | 无 | 私网互通 + 用户设备接入 |
| SSH 认证 | 弱密码 + 单一密钥 | 强随机密码 + 多把密钥 |
写在最后
这次最大的收获不是把负载压下去——那个数字只是结果。
真正的收获是撞见了两次「看起来做完了,其实没做」:一次是防火墙把封禁规则挂在了一个没人监听的端口上,一次是筛选条件问错了字段、把早就停用的源当成新发现的死源。这两件事有一个共同点:命令全部返回成功,状态全部显示正常。它们无法通过「执行成功」发现,只能靠回头再验一遍。
所以这一整轮下来,我花在验证上的时间比花在操作上的多。三个端口的连通性、两个方向的 SSH、封禁规则落在哪个端口、密码哈希有没有变、站点是不是还正常响应——每一项都单独跑一遍。看起来啰嗦,但这套东西的作用本来就不是「跑起来」,而是「出事的时候还在」。
机器不会喊疼。它只会安静地照着你写的规则执行——包括你写错的那条。
2026 年 10 月,于两台 VPS 之间。全部操作由 Hermes Agent 辅助完成。
最后更新于 2026-10-24 「部分内容存在时效性,如有失效请留言反馈」
除注明外为 袅残烟的冥想盆 原创文章,转载请注明出处。
本作品采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。