那个晚上
那晚我在电脑前等着 Telegram 机器人弹出下一句回复,等了很久,什么动静都没有。
切到其他节点试了一遍,还是没反应,基本排除了本地网络问题。我去敲服务器终端——SSH 直接卡在 banner exchange timeout。意思是 TCP 握手成功了,但机器连读取配置、报出自己主机名那一步都完不成。顺手去刷那台机器上跑的几个网页,有的握手成功但返回 0 字节,有的直接超时,那个 api 子域干脆返回一串空的 000。
机器还活着,ping 得通,但已经处理不了任何请求了。
出问题的是 Azure 的一台 vps,2 vCPU / 1 GB 内存,上面塞了十来个 Docker 容器:博客、评论系统、访问统计、API 中转网关,外加一个 Tailscale 中继。平时它负载很低,CPU 常年空着 80% 以上,唯独那天晚上彻底罢工。
更要命的是,它还掐着我另一台机器的脖子。我的 Telegram 机器人网关其实跑在阿里云上,但网关的出网流量必须走 Azure 这台机器上的加密代理节点。Azure 一卡死,代理节点就断,阿里云上的机器人瞬间成了哑巴——表面上看是 Telegram 不回消息,实际上是底下的流量中转站塌了。
我的第一反应是去后台重启。后来翻日志才明白,那次重启其实生效了,但毫无意义:光等 Docker 把那十来个容器重新加载起来,就足足卡了将近十分钟。这十分钟里磁盘被读得死死的,系统连 fork 一个新进程都做不到,SSH 自然连不上。等它好不容易喘过气把容器全拉起来,重启策略又把同一批吃磁盘的进程带了回来,IO 瞬间再次被打爆。我不信邪又重启了第二次,照样对着黑框干等了八分钟。
当时我觉得估计是容器负载太高,好不容易挤进终端,立刻把平时最吃资源的两个容器 停了。结果再看监控,磁盘 IOPS 纹丝不动。
折腾到熄灯我才看懂真正的原因。而要讲清楚这台机器是怎么走到这一步的,得先倒回今年 3 月,从我买下人生第一台服务器说起。
99 一年那台机器
今年 3 月,看到阿里云 2c2g 的 VPS 只要 99 一年,觉得留着总有用得上的地方,就剁手买了一台。那之前我完全没碰过服务器,看着 2 核 2G 的字面参数还担心会不会卡得没法用,但扫了一眼高配机型离谱的价格,还是老老实实开了机。
最早用服务器的方式极其原始:左边开着 DeepSeek,右边开着终端,问一步敲一步。
我试着部署的第一个项目是自己手搓的个人博客。本地开发时图省事,代码里到处写死了 localhost,在自己电脑上跑得飞起,一传到服务器上直接全线报错。而且当时我对 Nginx 反向代理一窍不通,想走捷径直接装了个成品的宝塔面板,结果掉进了更大的坑里。
宝塔里面的功能实在太杂了,拿着打包好的文件,我连该传到“Java 项目”还是普通的“HTML 站点”都分不清。有些反代配置在面板里还是要手填 JSON 或配置块,经常面板上弹个绿勾提示“配置成功”,结果浏览器打开照样访问不到。最离谱的是配 SSL 证书:当时我连域名都没买,纯靠公网 IP 访问,按理说根本不可能签下证书,但面板上偏偏又能点亮“已开启 SSL”的状态,把我彻底绕晕了。
在无数次 404 和连接拒绝之后,等我终于把反代端口捋顺、第一次在浏览器里通过公网刷出自己写的页面时,哪怕界面简陋得不行,那种“我的代码真正在公网上活过来了”的兴奋感还是让我爽了好几天。
到了 7、8 月份,折腾的东西慢慢深了一点。我把 CPA 从远端 Docker 环境迁到另一台机器上,中间经历了代理配置丢失、认证文件恢复,还撞上过一次极其诡异的 SSH 路由劫持——明明连的是 A 机器的 IP,终端却弹窗报错说主机指纹变成了 B 机器。查了半天才发现,是我 Windows 电脑上开的代理软件启用了 TUN 模式,把本该直连的 SSH 流量劫持到了另一台节点上。
现在回头看,那几个月我只是学会了怎么往服务器里“塞东西”,还完全不懂怎么照顾它的死活。
平静的一周
9 月中旬,我开始试着做一些正经的日常维护。
9 月 13 日,我把 Azure 那台机器从 Ubuntu 22.04 跨版本直接升到了 26.04。
9 月 18 日,我又抓到了一个隐蔽的毛病:有个小服务每隔 10 到 20 秒就会爆一次 CPU 尖峰,单核直接干到 100% 以上。但平时看一分钟粒度的监控图表,这根针完全被平均值抹平了,直到把采样间隔压到 0.2 秒才抓出来现行。最后查出来是 Docker 容器健康检查写得太糙——每隔 10 秒就要冷启动一次完整的命令行工具,光探针自己就要烧掉 2.3 到 4.4 秒的 CPU 时间。改成轻量查询接口后,耗时降到 0.2 秒左右,尖峰直接归零。(提了 issue,开发者采纳了)
同一天,我还顺手把阿里云那台机器里白占着内存的 crashkernel 给关了。这玩意儿对应的内核崩溃转储服务压根没开,却常年划走 256 MB 内存,关掉之后可用物理内存立刻从 1710 MB 涨到了 1863 MB。
那两次排障虽然曲折,但改完立竿见影。看着平整的监控曲线,我当时甚至觉得,运维大概也就这么回事了。
它开始崩了
打脸来得很快。9 月 20 日深夜,Azure 那台 1GB 内存的机器给了我第一次警告。
当晚我在上面编译一个新版本的服务,从晚上十点到凌晨三点,整整五个小时机器都泡在极度的内存压力里——Telegram 不回消息,SSH 敲个回车要等半天。那次它硬扛了过来,没死透。
结果它刚缓过来没几个小时,我自己敲了条命令把它送走了。
我当时觉得卡顿是因为 2 GB 的 swap 空间太小,准备扩到 4 GB。换新 swap 文件之前,我顺手执行了 swapoff 想先把旧的停掉。
那时我完全没意识到 swapoff 在内核里意味着什么:它不是简单地“卸载文件”,而是强制把当前 swap 里所有换出的数据全部读回物理内存,清干净了才允许摘除。当时那台机器总共只有 886 MB 可用物理内存,常驻进程已经占了 540 MB,而 swap 里还躺着 792 MB 的数据等着回读。把 1.3 GB 的数据塞进 886 MB 的内存里,算术上就根本走不通。
终端打印出“正在停用 swap”之后,光标就彻底定住了。
事后翻日志才还原出当时的惨状:内核开始疯狂回收内存,OOM killer 刚杀掉进程腾出一点空间,立刻又被 swapoff 回读的数据塞满;剧烈的换页直接把磁盘响应延迟干到了 400 毫秒,队列深度堆到 230;SSH 连认证都走不完,整机彻底失联,而且完全无法自愈,最后还是我去 Azure 控制台点了强制重启。
更尴尬的是,直到两个多小时后我自己打开监控面板,才发现它早就挂了。因为我装的是 Agent 型监控,探针就跑在那台机器里面,连宿主机都被 IO 锁死了,探针自然也发不出任何报警。
那次事故逼着我往自己的运维手册里记了三条规矩:
- 在没有充足空闲内存的机器上敲
swapoff等于自杀; - Agent 型监控防不住整机卡死,必须配外部主动探测;
- 机器因 IO 崩盘时,
journald往往会比系统先死——那天它的最后一条日志比syslog早了十几分钟,如果只看journalctl,会得出“死机前风平浪静”的错误结论。
一周后的 9 月 28 日下午 14 点 36 分,它第二次挂了。
那天下午的体感是机器“什么都读不出来”,偶尔挤进 SSH,敲个 ls 都要卡半天。我让旁边的 pi agent 抓了一份实时数据,发现是一场莫名其妙的读风暴:一个叫 codex 的进程正在以 37 MB/s 的速度疯狂读盘,旁边的 node 进程又叠了 16 MB/s,加起来 53 MB/s——直接撞穿了这块云盘 50 MB/s 的标称带宽上限。磁盘每秒要处理 3765 次读请求,系统负载(Load Average)从 12.8 一路飙到了 20 以上。
我直接 kill 掉了那个 codex 进程。结果杀完之后,负载不但没降,反而从 21 飙到 26,最后冲到了 28。我又怀疑到了容器头上,一咬牙把整个 Docker 服务停了。结果负载从 25.9 变成 25.4,毫无波澜。
真正的凶手确实是 codex。因为那之前我有大半个月没用过它,当天下午在自己的 Windows 电脑上打开了 Codex 客户端,不知道是不是旧版本的 Bug,客户端一启动就在后台无限发起远程重连。只要本地连接不断,服务器上刚杀掉一批 codex 进程,下一秒就会被重新拉起一批继续扫盘。等我把 Windows 上的客户端关掉(后来重启更新了几次客户端就正常了),服务器负载瞬间从二十几掉回 0.5。把所有容器重新拉起来之后,5 个核心服务跑得稳稳当当,负载也就 0.2 到 0.5。
那是第一次让我意识到,“服务器一卡就怪 Docker”完全是个思维定式。
又过了一周,10 月 5 日,第三次卡顿来了。这次没死透,但极其折磨人——所有网页都在转圈,慢到我反复切网络确认是不是自己的宽带坏了。
当时双核机器的负载干到了 12.55,1 分钟、5 分钟、15 分钟三个指标还在排队往上涨。磁盘 iowait 占了 86% 以上,每秒钟都有 12 到 14 MB 的内存数据在往 swap 里灌。886 MB 的物理内存只剩 11 MB 空闲,4 GB 的 swap 用掉了 1.9 GB,两个 node 进程死死卡在等 swap 换入的状态里。
跟上次被 codex 读爆不同,这次纯粹是因为物理内存不够,冷数据全被压进 swap,每次进程要用就得去慢速磁盘上往回读,整台机器的算力全耗在“等硬盘”上了。
其实那天晚上 21 点 22 分,pi 就已经把诊断结论告诉我了:“HK 那台正在 swap 颠簸打满 IO,就是这个把我卡住的。”
我看了一眼,然后就去忙别的事了。没停服务,没调参数,什么都没改。
因为在当时的我看来,它只是“慢”,又不是“死”——潜意识里总觉得只要还能响应就不算故障,慢点就慢点吧,能凑合用就行。
结果只熬了一周,这笔账就连本带利算到了我头上。
四个月前埋下的那颗雷
现在回到开头那个晚上。
当晚 22 点 07 分,Ubuntu 默认自带的系统自动更新任务准时启动了。这个任务从四个月前我刚开这台机器时就一直留在系统里,平时也就 5 到 25 秒的事,但那天晚上它硬生生跑了 37 分 32 秒。
单看事发时的监控,自动更新就像是唯一的凶手:22 点 20 分,磁盘响应延迟从平时的 12 毫秒暴涨到 3579 毫秒(翻了 290 倍),磁盘队列深度从 8 堆到了 2297,磁盘利用率 94.9%,CPU 的 iowait 高达 90.9%。
最让我百思不得其解的是对比平时的数据:在没卡死的时候,这台机器的 CPU 空闲率常年保持在 84% 到 97%,业务进程根本没吃多少算力;甚至看面板上的内存折线,也还有 72% 算作可回收或未占满的余量,远没到触发 OOM 的地步。为什么平时跑着十来个容器都相安无事,一个小小的自动更新就能把整机彻底锁死?
因为我之前一直看错了重点。自动更新根本不是炸药,它只是一根火柴。真正的炸药,就是我一周前视而不见的 swap 颠簸。
那台机器的 swap 里常年有 600 到 950 MB 的数据在反复换入换出,折算下来每秒要产生 1049 次随机磁盘读写。而 Azure 给这块底层标准盘划定的基线性能上限,只有可怜的 240 IOPS。也就是说,哪怕什么额外操作都不做,单靠内存换页这一项,就已经透支了磁盘标称能力的 4.4 倍。
自动更新任务在那晚做了什么?它不过是想往盘里多写 153 MB 的软件包索引文件而已。但在一条已经被换页请求塞到极限的磁盘队列上,这区区 153 MB 的写入直接成了压垮骆驼的最后一根稻草。
当时故障快照:
- 机器负载高达 6.17,但全系统 502 个进程里,真正处于运行状态(
R)的只有 1 个; - 内核负责内存回收的线程
kswapd0挂着D状态(不可中断睡眠,死死卡在等磁盘响应上); - CPU 的
iowait占了 75% 以上,真正的空闲率只剩 3%。
负载奇高、没有进程在跑计算、kswapd0 卡在 D 状态——这三条凑齐,就是内存换页把磁盘拖垮的标准签名。
事后我挨个查了一遍所有容器累计触发的换页读盘次数(major page faults),抓出了真正的换页大户:一个叫 koharu-suite-worker 的容器累计触发了 160 万次,是旁边数据库容器的 6.7 倍。
让换页不再碰磁盘
病根找准之后,真正的解法比我想象的简单得多:开启 Linux 内核自带的 zswap。
它的逻辑很直接:在物理内存里划出一小块压缩缓存区,当内核需要把冷数据换出时,不再直接去写慢速硬盘,而是先压缩塞进这块内存区里;只有当压缩区也塞满了,才真正落盘。对这台磁盘只有 240 IOPS、但 CPU 常年空着 80% 以上的机器来说,拿一点点闲置的 CPU 算力去换取不碰磁盘,简直是天作之合。
不需要装任何第三方软件,也不用重启,改几个内核参数当场生效。
打开 zswap 之后的效果立竿见影:每秒换出量从 690 页暴跌到 22 页(降了 97%);磁盘 IOPS 从 1014 降到 483;系统负载从 2.74 一路跌回 0.54,内存压缩比稳定在 3.4 : 1 左右。
除此之外,我还顺手清理了系统里几处一直在暗中吸血的配置坑:
第一个坑是 /tmp 目录。Linux 的 /tmp 默认挂载的是 tmpfs(内存文件系统),往里面扔多少东西,就实打实吃掉多少内存。我之前迁移服务时,随手在 /tmp 下留了一个 306 MB 的镜像包和一份数据库备份。在 1 GB 内存的机器上,这三百多兆死文件塞不下,就会被内核反复换出到磁盘 swap,等于我亲手把“临时垃圾”变成了“永久磁盘 IO”。删掉它们之后,swap 占用当场从 948 MB 掉到了 661 MB。
第二个坑也和 /tmp 有关。我的 Tailscale 中继服务从 10 月 10 日起就一直报错找不到证书,起不来。顺着挂载路径一查才发现,我当初把它的证书目录挂在了 /tmp 下面——每次服务器一重启,内存盘清空,证书自然就没了,而真正的持久化证书一直好好地躺在另一个目录里。把挂载点改回去,罢工三天的中继当场复活。
第三个坑是容器的开机自启策略。之前排障时我一刀切把所有容器的自启全关了,后来才老老实实重新梳理了一遍:真正在跑的核心服务必须挂上自启;而那些已经迁走、早就废弃的旧容器,必须把自启关掉甚至彻底清理掉,否则每次重启机器,它们都会跟着起来白白吃掉一截内存和开机 IO。
一些老实的话
文章里写下来的排障脉络,比当天晚上的真实情况体面得多。实际上在那几个小时里,我把能踩的误区全踩了一遍:
- 被虚拟化参数骗了:我一开始查内核参数看到
rotational返回1,真以为微软给我配了块纯机械硬盘,甚至动了掏钱换盘的念头。后来才知道 Azure 的合成虚拟盘在这个字段上一律报1,跟底层物理介质毫无关系,看真实盘型必须去查云平台的元数据接口。 - 把导火索当成了炸药:第一晚复盘时,我把系统自动更新当成了唯一元凶,洋洋洒洒写了一整篇总结,睡醒一觉才反应过来它只是凑巧踩下了扳机。
- 搞混了两层云盘限额:我一度对着虚拟机规格表上的“上限 3750 IOPS”发呆,不明白为什么几百的读写就能卡死。后来才搞清,虚拟机规格上限(3750)和挂载磁盘本身的上限(240)是两道独立的闸门,木桶效应永远由最窄的那块板决定。
- 被思维定式带着跑:其实当晚停掉
umami和waline后发现 IOPS 没降,就已经是一个极其强烈的信号——说明吃磁盘的东西根本不在应用容器层,而在底下的内核里。我当时离真相只有一层窗户纸,但因为满脑子认定“出问题肯定是容器搞的鬼”,硬生生绕了过去。
回头看这大半年,从 3 月份连 Nginx 反代都不懂、对着宝塔面板里错乱的 JSON 和没域名的 SSL 证书抓耳挠腮,把 localhost 原封不动传上服务器;到现在能配合着 pi agent 一层层剥开 iowait、换页率和内核线程状态,最大的变化其实不是背下了多少条 Linux 命令,而是“敢看底层的东西了”。
以前遇到服务器卡死,第一反应永远是重启、杀进程、停 Docker,总觉得内核参数和磁盘队列是很遥远的黑盒;真正被折腾过这四次才明白,所有的卡死都没有玄学,全是实打实的物理边界。一旦学会先把手停下来、去看一眼监控里的客观证据,问题就已经解决一半了。当然,如果非要说现在还有什么心理阴影,那就是我再也不想在 1 GB 内存的机器上直接跑编译了。
最后
跑这种小内存、慢磁盘的廉价 VPS,这是几条经验:
- 在慢盘机器上,swap 不是“免费扩容的内存”,而是持续吸血的磁盘负载。 当看到系统负载(Load)奇高,但
top里真正处于运行态(R)的进程只有一个时,别盯着 CPU 了,直接去查换页和iowait。 - 永远不要在内存吃紧的机器上直接敲
swapoff。 物理内存塞不下换出数据时,这条命令会把整机直接锁死,而且无法自愈。 /tmp是内存盘(tmpfs)。 别往里面塞大体积备份包,更别把需要持久化的证书和配置挂在/tmp下。- Agent 型监控在整机卡死时一定会失声。 只要机器还在意可用性,就必须配一个从外部发起的主动探测(哪怕只是简单的 Uptime 轮询)。
zswap是慢盘小内存机器上性价比最高的救命开关。 开启后记得写进/etc/sysctl.conf或启动脚本做好持久化,否则下次重启就打回原形。
现在,那台 Azure 机器上的 9 个核心容器全都安安稳稳地跑着,系统负载常年停在 0.2 到 0.5,磁盘 IOPS 也回到了基线附近。它依然只有 1 GB 内存,也依然每时每刻都在发生换页——只是从那个晚上起,换页这件事终于不用再去折磨那块可怜的硬盘了。
它以后大概还会在某个深夜整出新活。但至少下一次卡住的时候,我知道该先看哪三个数字,而不是闭着眼睛去点重启了。
喜欢的话,留下你的评论吧~