我原本以为这次维护的剧情很简单:看到 PHP 安全公告,升个级,确认网站还能打开,收工睡觉。
结果顺着 PHP 往下摸,后面站着 Node.js,Node.js 后面套着容器,容器底下又牵出 Go 的编译依赖。等我一路摸到 Docker 引擎的时候,旁边那块只有 30GB 的小硬盘开始报警了:再塞新镜像,它就当场去世给你看。
就这样,一个“应该十来分钟搞定”的小更新,硬生生变成了横跨 10 月 21 日、22 日两天的服务器大扫除。趁着记忆还热乎,把这两天踩的坑、升的级、腾出的 3.9GiB 空间,还有没擦完的屁股记一下。
1. 看着漏洞报告,差点犯了“无脑升级”的病
事情的起因是一份 Web 后端漏洞检索报告,里面密密麻麻列了一堆运行时、框架、反代和数据库的安全问题。
盯着报告看久了,人很容易上头,恨不得把里面提到的所有软件挨个 apt upgrade 和 docker pull 一遍。但冷静下来想想,瞎动手之前得先搞清楚:我这些服务到底是怎么跑的?
手头这两台服务器分工其实很明确:
- 博客服务器:跑着纯静态博客,外加一个用 PHP 写的随机背景图 API。真正要操心的是静态产物、PHP-FPM、Web 入口和同机跑着的其他服务。
- 机器人服务器:在 Ubuntu 上裸装了 1Panel,用它管着 AstrBot、LLBot、PMHQ、OpenResty 和代理等一堆容器。这一摊子就得把宿主机程序、容器镜像和里面的运行时拆开看。
另外像 Umami、PostgreSQL、NGINX 和 OpenSSL 也在待办名单上。这些得按服务单独盯着,不能因为外层升了级,就顺手给里面全打上勾。毕竟漏洞报告只是个引子,真正在机器上跑的版本、配置和请求路径,才决定了这台服务器到底慌不慌。
2. PHP 升完了,但跑着的是新版吗?
先拿万恶之源 PHP 开刀。机器上原本是 8.5.2,这次升到了 8.5.12(另一处 PHP 环境也顺带确认了补丁版本)。
PHP 8.5.12 官方更新记录里列了这次重点关注的两个安全修复。当然,漏洞能不能被利用还得看具体的运行方式和配置,倒也不用自己吓自己,一看到旧版本号就觉得马上要被匿名 RCE 了。
真正容易踩坑的是验证环节。很多人升完级,在终端里敲个 php -v 看到数字变了就觉得完事大吉。但命令行调的是 PHP CLI,网站请求走的是 PHP-FPM,它俩完全可能指着不同的路径,甚至老进程根本没重启。
所以我特地去核对了实际跑着的 PHP-FPM 进程,又测试了随机背景图 API 和全站 HTTPS 访问。确认全链路都在用新版本干活,PHP 这一项才算真正划掉。
擦了把汗,转头看了一眼旁边的服务——好戏才刚开始。
3. 静态博客,真就高枕无忧?
我的博客是用 Astro + Svelte 搓的,构建出纯静态文件后扔给 NGINX。线上压根没有常驻的 Node.js 后端。
这种架构的好处是心安:就算 Node.js 10 月 21 日安全公告里爆出了 Brotli、DNS 和 TLS 的问题,它也威胁不到我根本不存在的“线上 Node 后端”。不过为了构建环境干净,我还是把 CI 和发布流程里的 Node.js 基线从 24.22.0 统一升到了 24.22.1,省得各个配置文件里版本打架。
既然是静态站,真正会跟访客浏览器打交道的只有生成的 HTML 和脚本。以前总觉得“反正文章都是我自己写的,能有什么问题”,这次仔细审了一遍代码,还真拎出两个隐患:
一个是文章页的 JSON-LD 结构化数据。标题、摘要这些元数据序列化之后会直接塞进 HTML 的 <script> 标签里。万一哪天内容里出现了 </script>,光靠标准的 JSON 序列化可拦不住它提前闭合标签。这次专门换成了适配脚本标签的安全序列化方案,老老实实把它锁在数据层里。
另一个是 GitHub 仓库卡片组件。它的参数会直接参与链接和脚本拼装,这次也补上了严格的仓库名格式校验和安全序列化。
说实话,这两处都得有人能篡改我的文章元数据或者组件参数才能触发,排查下来没发现游客能直接控制输入的入口。但修好它们,主要是防着以后自己在模板或内容上翻车。
顺手还干了件事:把部署用的 GitHub Action 锁死到了具体的 Commit SHA,防止上游标签被篡改;同时把安全回归测试塞进了构建流水线。这些改动全都在 PR #19 里合入了。发布时的体检单还算漂亮:
- 类型检查、代码质量、32 个页面构建、27 页搜索索引生成全部通过;
- 新增的 5 项安全测试和 6 项构建产物回归测试 全绿;
- 锁文件依赖审计 0 漏洞告警(当然,这只代表 npm 锁文件里已公开的记录干净,管不到服务器上的 NGINX 和数据库);
- 自建服、Cloudflare 和 Vercel 三端自动部署一次过。
博客这边利索了,接下来轮到容器给我上课。
4. 镜像是新的,不代表里面的零件也是新的
这次排查最让我后背发凉的发现,是 应用版本升到了最新,但官方镜像里塞的运行时还是老的。
最典型的就是 LLBot。我把它从 8.3.0 升到了官方最新的 8.3.2,本来以为完事了,结果进容器里一查,Node 版本还停留在 24.21.0。
合着我只升了个寂寞?
没办法,只能自己动手丰衣足食,基于官方版重新打了一个内置 Node 24.22.1 的本地修补镜像。旁边的 AstrBot 也是同病相怜:应用本身保留在 4.29.1,但我进去把它的 Node 从 24.14.1 拽到了 24.22.1,顺手还把里面的 Starlette 框架升到了 1.0.1(Starlette 官方公告提到异常 Host 可能导致路由路径判断不一致,虽然得看应用具体怎么写鉴权,但升了总比留着强)。
除了 Node,Go 编译的二进制文件也是个重灾区。Go 标准库出了安全更新,光在宿主机上装个新版 Go 是没用的,已经编译好的程序不会自动变异。
所以我把这套逻辑彻底理了一遍,两天里在这台机器上动过的刀子都在这儿了:
| 组件 | 升级前状态 | 这次怎么处理的 |
|---|---|---|
| LLBot | 应用 8.3.0 | 升到官方 8.3.2,并自制镜像把内部 Node 补到 24.22.1 |
| AstrBot | 应用 4.29.1 (Node 24.14.1) | 保持 4.29.1,内部 Node 升至 24.22.1,Starlette 升至 1.0.1 |
| PMHQ | 9.0.3 | 直接更到 9.0.6 |
| sing-box 服务端 | 应用 1.14.3 | 保持 1.14.3 不变,拉源码用 Go 1.26.10 重新编译(客户端没动) |
| 1Panel | 2.1.8 | 升到 2.4.1,核对确认官方二进制已用上 Go 1.27.3 |
| Docker Engine | 29.4.0 | 升到 29.9.2,底层为 Go 1.26.10 |
| containerd | 原默认版本 | 同步升到 2.4.2,底层为 Go 1.27.3 |
像 Go 这批修复(参考 Go 发布历史 和 1Panel 2.4.1 发行说明),核心就是去查正在跑的二进制到底是用哪个版本的 Go 编译出来的。
这波操作下来,算是把“看表面版本号”的习惯彻底改成了“掀开盖子看底盘”。不过自己搓本地镜像也留了个包袱:以后要是直接切回上游官方镜像,得记得先进去看一眼他们把 Node 升上去没有,免得一拉新镜像,又把老漏洞请了回来。
5. 别光看绿灯,拉出来溜溜
升完这一大串组件,要是截个版本号的图就去发朋友圈宣布胜利,多少有点自欺欺人。另外得老实交待:这次维护是有短暂停机的——提前做好了备份,但升 1Panel 时重启了面板,升 Docker 时容器也全都跟着歇了一会儿,算不上什么“丝滑零停机”。
等服务全拉起来后,我挨个跑了一遍真实测试:
- 网站与反代:HTTPS 访问正常,背景图 API 吐图正常(当然,这只说明链路通了,反代配置的深度审计还得另算)。
- AstrBot 与 Python 依赖:用正常和故意构造的异常 Host 去敲受保护接口,全都老老实实返回未授权;Python 依赖兼容性检查和回归测试也都过了。
- 代理链路:重编译后的 sing-box 服务端,实测 HTTPS 代理请求正常。
- Docker 与机器人容器:容器清单核对无误,LLBot 和 PMHQ 的健康检查都是绿的。
但这里有个特别容易踩的盲区:容器健康检查通过,不代表机器人真的能收发 QQ 消息了。因为重启折腾之后,QQ 还得重新扫码登录,真正的端到端消息收发验证还挂在待办上。
6. 漏洞还没修完,30GB 硬盘先喊救命了
机器人服务器就挂了一块 30GB 的硬盘。
前面又是拉新镜像、又是自己编译、又是构建本地修补镜像,缓存和临时文件疯长。折腾到一半我敲了下 df -h,差点一口气没上来:可用空间只剩 2.7GiB 了。
剧本瞬间从“网络安全保卫战”变成了“抢救硬盘大作战”。再不腾地方,下一次更新连镜像都解压不开。
清理分了两步走。第一步先把这次折腾出来的临时文件和构建缓存全扬了;第二步去排摸历史旧镜像。注意,不能看哪个容器没在跑就闭着眼删镜像,有些停止状态的容器身上还挂着引用呢。
仔细核对完所有容器(包括 5 个运行中和 4 个停止的容器)的引用关系后,我精准送走了三个彻底没人要的远古镜像:
linyuchen/llbot:7.11.4linyuchen/llbot:7.12.2linyuchen/pmhq:7.3.2
回滚备份、业务数据卷和系统日志全都完好保留。两轮扫除下来,一共腾出了大概 3.9GiB 的空间,可用空间回血到 6.6GiB,磁盘使用率降回 77%。虽然依然不算富裕,但起码不用一边敲命令一边提心吊胆了。
7. 还没打完的仗:留下的待办清单
写到这儿好像挺圆满,但翻开记事本,其实还有一屁股债没还完。安全维护最忌讳的就是“前排修了七八个,后排漏掉两三个,对外宣称全搞定”。
截至 10 月 22 日,这几个坑还排着队等我填:
- Umami (3.0.3) —— 头号优先级:官方安全公告爆出了令牌混淆导致的授权缺陷,存在被匿名读取统计数据的风险,这个必须尽快升。另外它容器里的 Node.js、Next.js 和 sharp 版本也挺老了,虽然还没证实有直接的 RCE 路径,但也得一并处理。
- PostgreSQL (15.17):得升到 15.19。这次重点核对的两个代码执行漏洞(CVE-2026-14669 和 CVE-2026-16239)前提都是攻击者已经拿到了数据库权限,暂时没那么火烧眉毛,但其它修复还是得做好备份后抓紧升上去。
- NGINX (1.26.3) 与 OpenSSL:我开了 HTTP/3,还得结合发行版补丁、构建及运行时的 OpenSSL 版本做一次彻底更新。顺带一提,之前看报告时差点自己吓自己——NGINX 官方安全列表里那个 HTTP/3 释放后使用漏洞(CVE-2026-42530)影响的是 1.31.0–1.31.1,我的 1.26.3 并不在这个坑里。纠正归纠正,常规更新还是不能省。
- OpenResty 与管理入口收紧:OpenResty 等组件还没轮到更新;另外最小权限部署、管理后台访问限制和端口隔离还得继续往紧里拧。
8. 日志里妖魔鬼怪很多,但先别自己吓自己
这几天翻日志的时候,不出意外看到了满屏的互联网底噪扫描,还有不少专门冲着 Server Actions 来的畸形请求。
刚看完漏洞报告的人,看到这种日志最容易脑补出一整部黑客帝国。但客观来说,“攻击请求打到了门口”和“门已经被撬开了”完全是两码事。
在现有的日志覆盖面和文件基线范围内,我仔仔细细查了一圈,没有发现任何被成功入侵的实锤证据。当然,受限于历史日志的完整度,我也没法拍着胸脯开一张“本服务器自盘古开天辟地以来绝对纯洁”的证明。保持警惕,按部就班把剩下的洞补上,比盯着扫描日志焦虑管用得多。
9. 写在最后
回头看这两天,最大的收获其实不是把表格里的数字改大了几号。
以前更新软件,看到终端提示 Success 就拍拍屁股走人了。这一趟折腾下来,算是被逼出了一套强迫症流水线:升完 PHP 去查实际接活的 FPM;改完静态站去把构建环境、输出产物和服务器入口拆开审;升完容器钻进镜像里看 Node 版本;看到 Go 漏洞去查二进制的编译工具链;甚至连那块只有 30GB 的小硬盘,都在用实际行动教我怎么留好回滚余地。
我本来真的只想花十分钟更新一下 PHP。
结果最后给两台服务器做了场全麻大手术,还顺手从床底扫出了三个早该退休的老镜像。歇口气,明天还得继续去会会 Umami 和数据库。