服务器:Oracle Cloud ARM | 应用:Halo 2.x + PostgreSQL 15 + Nginx | 记录时间:2026.05.21–22

一、问题的诡异之处
我的博客(wuqishi.com)有个很邪门的毛病:每天晚上 20:00 到 23:00 准时卡,但同服务器上的其他网站屁事没有。
服务器是 Oracle Cloud 的 ARM 实例,4 核 24G 内存,跑 Docker Compose。上面除了 Halo,还有几个 Typecho 站点和纯静态页面。白天所有站点都飞快,唯独 Halo 一到晚上就跟中了邪似的——页面加载转圈、后台进不去、偶尔直接报错 ERR_CONNECTION_CLOSED。
更离谱的是,不是一直卡,是断断续续的卡。有时候能打开,点两下又卡死;有时候彻底连不上,过几分钟又好了。这种 "薛定谔的卡顿" 最让人头疼,因为你根本抓不到规律。
一开始我怀疑是 Halo 2.x 基于 Java(Spring Boot)的锅,毕竟 Java 吃内存是出了名的,而 Typecho 是 PHP,随用随释放。但看了眼 Docker 配置,JVM 堆内存已经给了 5G,G1 垃圾回收器也开了,理论上不应该啊。
💡 Tip 1:遇到 "选择性卡顿",先别急着调代码或加配置。
第一步永远是确认问题范围。同服务器其他站点正常 → 排除全局资源问题;只有某个应用卡 → 问题大概率在这个应用自身、它的数据库、或者这个域名独享的流量上。
二、排查的第一步:翻日志,逐层往下挖
2.1 Nginx error.log 的异常
先看 Nginx 的 error.log,发现大量这种报错:
2026/05/21 22:14:57 [error] 1234#1234: *45678 recv() failed (104: Connection reset by peer)
2026/05/21 22:14:57 [error] 1234#1234: *45678 upstream prematurely closed connection而且全部指向同一个路径:
/apis/online-user.zyx2012.cn/v1alpha1/online-ws这是我在 Halo 里装的一个 "在线人数统计" 插件。看起来 Nginx 把请求转发给 Halo 后,Halo 还没返回响应头就直接把连接掐断了。更可怕的是,这些报错是刷屏级别的,一秒钟能蹦出来十几条。
2.2 dmesg 里的两个关键线索
同时我看了 dmesg,发现两个现象:
第一,Docker 容器在频繁重启。
vetha1b2c3d4: entered disabled state
vetha1b2c3d4: left promiscuous mode
docker0: port 2(vetha1b2c3d4) entered disabled state虚拟网卡在不断销毁重建。这意味着 Halo 容器在崩溃 → 被 Docker 自动拉起 → 又崩溃的循环里。
第二,UFW 防火墙在疯狂拦截扫描。
[UFW BLOCK] IN=eth0 OUT= MAC=... SRC=185.191.171.12 DST=... DPT=5432
[UFW BLOCK] IN=eth0 OUT= MAC=... SRC=194.165.16.73 DST=... DPT=5432
满屏的 [UFW BLOCK],各种境外 IP 在扫我的端口:5432(PostgreSQL)、5985(WinRM)、3389(远程桌面)……
等等,5432?我的数据库端口怎么暴露在外网了?
💡 Tip 2:dmesg 是排查系统级问题的利器。
很多人只看应用日志,忽略了内核日志。Docker 容器频繁重启、网卡状态变化、OOM killer 杀进程,这些在 dmesg 里一目了然。建议用 dmesg -T | grep -E "(UFW|veth|docker0)" 快速过滤关键信息。
三、真凶一:数据库裸奔在公网上
我打开 docker-compose.yml 一看,傻眼了:
services:
halodb:
image: postgres:15-alpine
ports:
- "5432:5432"当时为了方便用 DBeaver 连数据库,直接映射到了宿主机。但我忘了 Linux 上一个巨坑:Docker 会绕过 UFW 防火墙。
3.1 Docker 为什么能绕过 UFW?
这里有个很多人不知道的细节:
UFW 本质上是对 iptables 的封装,它在 INPUT 链里插入规则。但 Docker 在启动容器时,会在 iptables 的 DOCKER 链(属于 nat 表和 filter 表)里插入自己的规则,优先级高于 UFW 的 INPUT 链。
具体来说,Docker 会创建这样的 DNAT 规则:
sudo iptables -t nat -L DOCKER -n --line-numbers | grep 5432输出:
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:5432 to:172.18.0.2:5432这条规则的意思是:所有访问宿主机 5432 端口的流量,都会被 DNAT 到 Docker 容器内部的 172.18.0.2:5432。它发生在 UFW 的过滤规则之前,所以 UFW 根本拦不住。
3.2 数据库日志里的暴力破解
看数据库日志更触目惊心:
2026-05-21 22:14:57.123 UTC [12345] FATAL: password authentication failed for user "postgres"
2026-05-21 22:14:57.123 UTC [12345] DETAIL: Role "postgres" does not exist.
2026-05-21 22:14:57.456 UTC [12346] FATAL: password authentication failed for user "postgres"
2026-05-21 22:14:57.789 UTC [12347] FATAL: password authentication failed for user "postgres"黑客在用默认用户名 postgres 暴力破解我的数据库。因为我在 Docker Compose 里把数据库用户名改成了其他,系统压根没有 postgres 这个用户,所以每次破解都是 FATAL 级别报错。
为什么晚上特别卡?
因为晚间是全球扫描器的高峰期(对应欧美白天工作时间),每秒几十次恶意连接。PostgreSQL 的默认配置下,每次连接都要:
Fork 一个后端进程(约 5-10MB 内存)
进行 SSL 握手和认证计算
查询系统表验证用户名
失败后记录 FATAL 日志
这些垃圾请求把 CPU 和 I/O 占满了。我的 Halo 博客想查数据库?排队去吧。
3.3 修复方案
把端口绑定到本地回环:
services:
halodb:
image: postgres:15-alpine
ports:
- "127.0.0.1:5432:5432"
environment:
- POSTGRES_USER=halo
- POSTGRES_PASSWORD=your_strong_password
- POSTGRES_DB=halo改完重启,数据库日志瞬间清净,FATAL 报错彻底消失。
💡 Tip 3:所有敏感服务的 Docker 端口映射,永远写成 127.0.0.1:PORT:PORT。
不要偷懒只写 PORT:PORT,那等于裸奔在公网上。UFW 在 Docker 面前就是纸老虎。如果你确实需要从外网连数据库,用 SSH 隧道或 WireGuard,而不是直接暴露端口。
四、真凶二:Nginx 反代没开长连接
数据库安静了,但网站还是偶尔卡顿。继续看日志,发现 Nginx 的 access.log 里有个规律:每次页面加载,除了主请求,还有一堆 /apis/... 的 API 请求。这些请求全部走的是:
proxy_pass http://127.0.0.1:8090;也就是直连 Halo,没有走 upstream。
问题出在这里:我没配 upstream keepalive。
4.1 为什么短连接会拖垮你?
Nginx 每次反向代理都新建一个 TCP 连接,用完就扔。晚上并发一高,系统里堆积成千上万个 TIME_WAIT 状态的连接。
你可以用 ss 命令验证:
ss -tan state time-wait | wc -l我当时高峰期这个数字飙到了 8000+。
TIME_WAIT 是 TCP 四次挥手的正常状态(主动关闭方等待 2MSL,确保最后的 ACK 被对方收到),但数量太多会:
耗尽临时端口:Linux 默认临时端口范围是 32768–60999,约 28000 个。8000+ TIME_WAIT 意味着可用端口骤减,新的请求连不上,表现为 "转圈"、"白屏"。
占用内存:每个
TIME_WAIT连接在内核里占用约 1–2KB 的 TCP 控制块,积少成多。
4.2 修复方案
在 Nginx 配置顶部定义 upstream:
upstream halo_backend {
server 127.0.0.1:8090;
keepalive 64;
}然后把所有 proxy_pass http://127.0.0.1:8090 改成 proxy_pass http://halo_backend,并在对应的 location 里加上:
proxy_http_version 1.1;
proxy_set_header Connection "";这样 Nginx 和 Halo 之间保持 64 个长连接复用,再也不用频繁三次握手了。
注意两个细节:
proxy_http_version 1.1是必须的,因为 HTTP/1.0 默认不支持 keepalive。
proxy_set_header Connection ""是为了防止 Nginx 把客户端的Connection: close透传给后端,导致后端主动断开长连接。
改完后可以立刻用 ss 验证:
ss -tan state established '( dport = :8090 or sport = :8090 )' | wc -l连接数从之前的几百个飙升到稳定的 64 个左右,而且不再增长。TIME_WAIT 数量也降到了正常水平(几十到一百个)。
💡 Tip 4:用 ss 替代 netstat,它更快更准。
查看某个端口的连接状态分布:
ss -tan state time-wait '( dport = :8090 )' | wc -l
ss -tan state established '( dport = :8090 )' | wc -l如果 TIME_WAIT 超过 5000,就要警惕了。
五、真凶三:WebSocket 插件的重连风暴
现在说回那个 "在线人数统计" 插件。它的原理是前端通过 WebSocket 长连接实时上报在线状态。
5.1 WebSocket 握手为什么失败?
WebSocket 不是普通的 HTTP 请求。它的建立过程是这样的:
客户端发送一个带
Upgrade: websocket和Connection: Upgrade头的 HTTP 请求服务器返回
101 Switching Protocols,之后连接从 HTTP 升级为 WebSocket 全双工通道后续数据通过帧(frame)传输,不再走 HTTP 请求 / 响应模型
问题出在第 2 步:我的 Nginx 没正确转发 Upgrade 头。
Nginx 默认会把 Upgrade 这类非标准头过滤掉(或者当成普通 HTTP 请求处理),导致后端 Halo 收到的是一个普通的 GET 请求,而不是 WebSocket 握手请求。后端以为这只是一个普通的 API 调用,处理完就关闭了连接。
但前端 JS 检测到 WebSocket 断开,会自动重连,于是形成了"连接 → 断开 → 重连" 的死循环。
5.2 日志里的套娃现场
从 access.log 里能清楚看到这个套娃过程:
22:14:57 "GET /apis/online-user.../online-ws HTTP/1.1" 101 197
22:14:59 "GET /apis/online-user.../online-ws HTTP/1.1" 101 169 ← 同一个 IP,2 秒后重连
22:16:04 "GET /apis/online-user.../online-ws HTTP/1.1" 101 0 ← 传输 0 字节,空连接
22:16:48 "GET /apis/online-user.../online-ws HTTP/1.1" 101 143
22:16:56 "GET /apis/online-user.../online-ws HTTP/1.1" 101 143 ← 8 秒后又重连一个访客就能在短时间内发起 3–4 次 WebSocket 握手。晚上如果有几十个访客,加上各种爬虫,Halo 的线程池被这些无效握手占满,正常的页面请求只能排队,等到超时。
Nginx error.log 里的 Connection reset by peer 和 prematurely closed connection 就是这么来的——Halo 被压垮了,直接粗暴地关闭连接自保。
5.3 修复方案
我没有卸载这个插件,因为上面加了长链接后目前暂时没问题,这里做记录:可以在 Nginx 里正确支持 WebSocket。在对应的 location 里加上:
location /apis/online-user.zyx2012.cn/ {
proxy_pass http://halo_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}验证:
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Host: wuqishi.com" -H "Origin: https://wuqishi.com" https://wuqishi.com/apis/online-user.zyx2012.cn/v1alpha1/online-ws返回:
HTTP/2 101 Switching Protocols
upgrade: websocket
connection: upgrade
101 Switching Protocols 就说明握手成功,长连接已建立。浏览器 F12 的 Network 面板里也能看到 WebSocket 连接状态从 (pending) 变成 101,之后变成绿色小圆点,不再频繁断开重连。
目前现在这个插件运行得很稳定,在线人数统计功能完全 正常,没有再出现之前的重连风暴。
💡 Tip 5:排查 WebSocket 问题时,不要只看浏览器控制台的报错。
用 curl 直接模拟握手请求是最快的方式。如果返回 200 OK 而不是 101,说明 Nginx 或后端没正确处理 Upgrade 头。另外注意,HTTP/2 对 WebSocket 的支持比较特殊,Nginx 从 1.25.1 开始才支持 HTTP/2 下的 WebSocket,如果你的 Nginx 版本较老,建议用 HTTP/1.1 测试。
六、第四个隐患:恶意爬虫的骚扰
排查过程中,Nginx access.log 里还有一类请求让我哭笑不得:
185.191.171.12 "GET /wp-content/themes/begin/inc/go.php?url=..." 404
85.208.96.196 "GET /wp-content/themes/begin/inc/go.php?url=..." 404SemrushBot(一个臭名昭著的 SEO 爬虫)在疯狂请求我网站上根本不存在的 WordPress 路径,我早转Typecho了,现在转到Halo了。它以为我还是是 WordPress 站点,在探测 Begin 主题的重定向漏洞(历史漏洞),想拿我的服务器当跳板做黑帽 SEO。
这些请求全部返回 404,但问题是:它们都进了 Halo 后端。
Nginx 匹配不到物理文件,就丢给 @halo_proxy,Halo 不得不为每个 404 请求初始化一次完整的页面渲染,吐出 404 页面。一秒钟十几条,线程池又被浪费了。
6.1 处理方案
我没有在 Nginx 层面拦截这些爬虫,而是选择在 Cloudflare 处理。Cloudflare 的防火墙规则可以基于 User-Agent 直接拦截 SemrushBot、DotBot 等垃圾爬虫,不需要消耗我服务器的任何资源。
这样做的好处是:
垃圾流量在边缘节点就被挡掉了,根本到不了我的服务器
不需要维护 Nginx 里的爬虫黑名单,Cloudflare 的威胁情报库会自动更新
不会污染我的 Nginx 日志,access.log 里只保留真实访客
💡 Tip 6:如果用了 Cloudflare,优先在边缘节点拦截垃圾流量。

表达式代码:
(http.request.uri.path contains "/.env") or
(http.request.uri.path contains "/.git") or
(http.request.uri.path contains "/wp-") or
(http.request.uri.path contains "/xmlrpc.php")Cloudflare 的防火墙规则、Bot Fight Mode、User-Agent 拦截,都比在源站 Nginx 处理更高效。源站的资源应该留给真实用户,而不是浪费在响应爬虫上。如果你的站不是 WordPress,可以直接在 Cloudflare 里拦截所有包含 wp-content、wp-admin 的请求,连 404 都不用返回。
七、其他杂项与待办
7.1 RSS 订阅的慢查询
/rss.xml 每次被请求都要动态生成 115KB 的 XML,而且多个 RSS 聚合器(FreshRSS、Inoreader、AstraHub 等)每隔几分钟就来抓一次。目前暂时没做静态缓存,先放着。
💡 Tip 7:RSS 聚合器的抓取间隔各不相同。
有的 5 分钟一次,有的 30 分钟一次。你可以通过 access.log 分析 User-Agent,找出最频繁的聚合器,针对性地设置缓存时间。另外,Halo 的 RSS 生成是全量文章列表,如果文章很多(几百篇),每次生成都是 O(n) 的遍历,非常耗 CPU。建议考虑分页 RSS 或限制输出数量。
7.2 缩略图代理的 302 风暴
日志里发现大量这种请求:
GET /apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https://cdn.ssslove.com/...&size=m HTTP/2.0" 302 0Halo 的缩略图服务对外链图片做了一次 302 重定向。如果页面里图片多,这一来一回多了很多无谓的请求。后续考虑直接把图片链接换成 CDN 加参数的直链,跳过 Halo 的代理层。
💡 Tip 8:Halo 的缩略图代理适合处理本地上传的图片。
如果是外链图片(比如又拍云、OSS),建议直接在前端用 CDN 的缩略图参数(如 !m 或 ?x-oss-process=image/resize),跳过 Halo 的代理层,减少一次 302 跳转。
7.3 HALO_EXTERNAL_URL 的坑
Docker Compose 里 HALO_EXTERNAL_URL 配成了 http://localhost:8090/,这会导致某些插件在生成回调地址、RSS 链接时用到错误的域名。
应该改成真实域名:
environment:
- HALO_EXTERNAL_URL=https://wuqishi.com/💡 Tip 9:HALO_EXTERNAL_URL 影响的不只是页面链接。
它还会影响:
Open Graph 标签(分享到微信、Twitter 时的预览卡片)
RSS 里的 <link> 和 [atom:link](atom:link) 元素
某些插件的 OAuth 回调地址
邮件通知里的链接
配错了会导致分享出去的文章链接是 http://localhost:8090/...,别人根本打不开。目前我暂未修改。
八、排查思路总结:我的一套方法论
这次排查我走了一些弯路,但也验证了一套有效的方法论:
核心原则:由外到内,先拦垃圾流量,再修内部配置。
如果一开始就去调 JVM 参数、加数据库索引,可能永远找不到真凶。因为根子不在性能不足,而在资源被垃圾请求白白浪费。
九、现在的状态
改完配置、重启服务后,到今天盯着日志看了半小时:
数据库日志:一片安静,偶尔正常的查询交互
Nginx error.log:零报错
Nginx access.log:清净了很多,Cloudflare 已经挡掉了大部分垃圾爬虫
WebSocket 插件:握手返回
101 Switching Protocols,长连接稳定,不再频繁重连,在线人数统计功能正常工作页面加载:iPad、Mac、手机 4G 测试,正常打开。
晚上 22:30 到 23:00 这段原本最卡的时间,现在流畅得跟白天一样。(今晚继续再看看)
十、反思:六个容易被忽视的坑
坑 1:Docker 端口映射默认暴露公网,UFW 拦不住
数据库、Redis、MQ 这类敏感服务一定要绑 127.0.0.1。别信 UFW,信 iptables -t nat -L DOCKER。
坑 2:Nginx 反代理默认是短连接
高并发场景必须开 keepalive,否则 TIME_WAIT 会拖垮你。ss -tan state time-wait | wc -l 超过 5000 就要警惕了。
坑 3:WebSocket 不是"配了就行"
Upgrade 和 Connection 头缺一不可。可以用 curl 模拟握手快速验证,返回 101 才算成功。另外注意 Nginx 版本,HTTP/2 下的 WebSocket 需要 Nginx ≥ 1.25.1。
坑 4:第三方插件要审慎,但也不必一刀切
这次在线人数插件的问题根源不在插件本身,而在 Nginx 配置缺失。修复后插件运行得很稳定,功能完全正常。所以遇到插件相关的问题,先排查基础设施(Nginx、反向代理、防火墙),不要急着卸载。
坑 5:日志是最好的 debugger
这次排查全程没改一行 Java 代码,所有问题都是从日志里抠出来的。养成看日志的习惯,比盲目调参重要得多。
建议给 Nginx 日志加上这两个字段,一眼就能看出是前端慢还是后端慢:
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" '
'rt=$request_time urt=$upstream_response_time';rt(request_time):客户端视角的总耗时urt(upstream_response_time):后端处理耗时
如果 rt 很大但 urt 很小 → 问题在网络或 Nginx;如果 urt 很大 → 问题在后端。
坑 6:不要忽视爬虫的影响
SemrushBot、DotBot 这些商业爬虫非常 aggressive,它们不会遵守你的 robots.txt,而且专门挑 WordPress 漏洞路径扫。如果你的站不是 WP,在 Cloudflare 或 Nginx 层直接拦截,别浪费后端资源。
如果你也在用 Halo 或者类似的 Java 博客系统遇到晚间卡顿,希望这篇记录能给你一些排查思路。有时候问题不在代码多复杂,而在某个配置细节、某个插件、或者某个暴露在公网上的端口。
最后吐槽一句:SemrushBot 是真的执着,从 21:00 到 23:00 不间断地扫了整整两个小时,被 Cloudflare 挡了上百次还在试。这种敬业精神,用在正道上早成架构师了。
写于 2026 年 5 月 22 日凌晨,部分内容资料由 AI 查询后进行整理而成。
Halo 博客晚间卡顿排查记:从"玄学卡顿"到"真凶落网"
本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
赞赏支持
如果觉得文章对你有帮助,可以请作者喝杯咖啡 ☕
评论交流
欢迎留下你的想法