运维 | 两台 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 自己都排不上队。改成后台跑、隔一会儿看一次水位,才看着数字一路掉下来。

四、加固之前,先修一条逃生通道

接下来才是这次最需要谨慎的部分。

背景有三个硬约束:

  1. 云厂商的串口/VNC 控制台进不去——没有带外通道;
  2. 家庭宽带是动态 IP,白名单随时会失效;
  3. 每天数万次爆破,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 辅助完成。


致谢:Tiny Tiny RSS、RSSHub、fail2ban、Tailscale、Docker。