从 HTTP 200 到 Connection Reset——Nuxt SSR 容器崩溃排障手记
一切正常。然后你重启了一个 Docker 容器。然后 HTTPS 请求全部返回
Connection reset by peer。但 HTTP 是正常的。但 nginx 在跑。但 Nuxt 也在跑。
这是一个真实的排障故事。没有 AI 的"我猜是这个问题"——只有一步步的、可复现的诊断。
一、架构回顾
我的个人网站(deeeli.com)部署在腾讯云香港的单台 2GB 内存服务器上。
┌─────────────────────────────────────┐
│ 腾讯云香港 (43.135.47.130) │
│ │
│ ┌──────────┐ ┌───────────────┐ │
│ │ nginx │───→│ Nuxt 3 SSR │ │
│ │ :80/443 │ │ :3000 (容器) │ │
│ │ │ │ 内存限制 1GB │ │
│ └──────────┘ └───────────────┘ │
│ │ │
│ │ 静态资源 │
│ ▼ │
│ /var/www/html/ │
│ /var/www/preview/ │
└─────────────────────────────────────┘
nginx 配置:
server {
listen 80;
server_name deeeli.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name deeeli.com;
ssl_certificate /etc/ssl/deeeli.crt;
ssl_certificate_key /etc/ssl/deeeli.key;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /preview/ {
alias /var/www/preview/;
}
}
架构很简单:nginx 终止 SSL,反向代理到 Nuxt 的 Docker 容器。
二、故障发生
日常部署流程:Coder 修改了首页的 Three.js 3D 场景,从 v5(精简)回退到 v4(赛博朋克完整版)。
Ops Worker 执行了安全部署:
# 1. 本地构建镜像
docker build -t website:v4-fix .
# 2. 压缩上传
docker save website:v4-fix | gzip > website-v4-fix.tar.gz
sftp upload → /tmp/
# 3. 服务器加载
ssh root@43.135.47.130 "docker load < /tmp/website-v4-fix.tar.gz"
# 4. 测试端口启动
docker run -d --name web-test -p 3001:3000 --memory=1g website:v4-fix
curl http://127.0.0.1:3001/ # → 200 OK ✅
# 5. 切换
docker stop web && docker rm web
docker rename web-test web
切换完成。然后:
$ curl https://deeeli.com
curl: (35) OpenSSL SSL_connect: Connection reset by peer
这就开始了。
三、系统化排障
第一步:隔离问题层次
从最外层开始,逐层向内隔离:
用户 ─→ DNS ─→ CDN ─→ nginx ─→ Nuxt
检查 DNS:
$ dig deeeli.com
;; ANSWER SECTION:
deeeli.com. 600 IN A 43.135.47.130
DNS 正常,解析到正确的服务器。
检查 CDN:
没有 CDN。直接到服务器。
检查 nginx 是否在跑:
$ curl -I http://deeeli.com
HTTP/1.1 301 Moved Permanently
Server: nginx/1.25.3
Location: https://deeeli.com/
nginx 活着,HTTP 80 正常响应 301 重定向。
这告诉我们:问题出在 HTTPS 层,而不是 HTTP 层。
第二步:深入 SSL
$ curl -v https://deeeli.com 2>&1 | head -20
* Trying 43.135.47.130:443...
* Connected to deeeli.com (43.135.47.130) port 443
* ALPN: curl offers h2,http/1.1
* SSL connection using TLSv1.3
* Server certificate:
* subject: CN=deeeli.com
* start date: May 10 00:00:00 2026 GMT
* expire date: Aug 8 23:59:59 2026 GMT
* SSL certificate verify ok.
* Send failure: Connection reset by peer
* OpenSSL SSL_connect: Connection reset by peer
关键信息:
- TCP 连接成功(443 端口可达)
- TLS 握手完成 ✅
- SSL 证书验证通过 ✅
- 但在发送 HTTP 请求时被 reset
这告诉我们:nginx 的 SSL 层正常工作,但在尝试代理请求到上游时出了问题。
第三步:直接测试上游
绕过 nginx,直接访问 Nuxt:
$ curl http://127.0.0.1:3000
curl: (7) Failed to connect to 127.0.0.1 port 3000: Connection refused
端口 3000 没有监听!
$ docker ps
CONTAINER ID IMAGE STATUS
a1b2c3d4e5f6 nginx:alpine Up 30 minutes
只有一个 nginx 容器在运行。Nuxt 容器去哪了?
$ docker ps -a | grep website
b5c6d7e8f9a0 website:v4-fix Exited (137) 2 minutes ago
退出码 137。这是 Linux 内核发送 SIGKILL 的信号——通常是 OOM。
四、OOM 根因分析
退出码含义
| 退出码 | 含义 |
|---|---|
| 0 | 正常退出 |
| 1 | 应用错误 |
| 137 | 被 SIGKILL 杀死(通常 OOM) |
| 139 | 段错误 (SIGSEGV) |
| 143 | 被 SIGTERM 终止 |
137 = 128 + 9,9 是 SIGKILL 的信号编号。这是 Docker 或内核因为内存超出限制而杀死了容器。
日志回溯
$ docker logs b5c6d7e8f9a0 --tail 100
关键片段:
[nuxt] [request] GET /
[nuxt] [ssr] Rendering page: /
<--- Last few GCs --->
[45:0x5a3c000] 1800 ms: Scavenge 950.0 (992.0) -> 948.0 (992.0) MB
[45:0x5a3c000] 2000 ms: Mark-sweep 992.0 (1024.0) -> 990.0 (1024.0) MB
[45:0x5a3c000] 2500 ms: Mark-sweep 1010.0 (1024.0) -> 1005.0 (1024.0) MB
<--- JS stacktrace --->
FATAL ERROR: Ineffective mark-compacts near heap limit
Allocation failed - JavaScript heap out of memory
这是 V8 的经典 OOM 信息。Nuxt 在进行 SSR 时,V8 堆内存溢出。
为什么 v5 没事,v4 就 OOM?
问题出在 Three.js。v4 的首页场景包含:
- IcosahedronGeometry(20 面的正二十面体)
- 双环面(两个 TorusGeometry)
- 900 个粒子的 Points 系统
- Canvas 纹理(每个粒子着色用)
- 自定义 ShaderMaterial
Three.js 在 SSR 时(通过 headless-gl 创建 WebGL 上下文),每个几何体、纹理、着色器都要在内存中实例化。即使只是服务端渲染一瞬间,也足以触发 OOM。
v5 的场景简化了:
- SphereGeometry(简化球体)
- 无环面
- 300 个纯色粒子(无 Canvas 纹理)
- 基础 MeshStandardMaterial
内存占用差异:
| 组件 | v5 内存 | v4 内存 |
|---|---|---|
| 几何体缓冲区 | ~10MB | ~50MB |
| Canvas 纹理 | 0 | ~80MB |
| 着色器编译 | ~30MB | ~100MB |
| 粒子系统 | ~15MB | ~45MB |
| V8 堆其他 | ~150MB | ~150MB |
| 总计(SSR 峰值) | ~200MB | ~425MB |
1GB 容器限制看起来是够的——但 SSR 时需要同时加载整个 Nuxt 应用 + 页面组件 + Three.js 场景。V8 的堆管理在 GC 来不及回收时,峰值内存可能远超静态分析的数字。
Tester 为什么没发现?
这是整件事最令人沮丧的部分。在这次部署之前,完整的流水线是:
Coder 修改 → Reviewer 审查 → Git 提交 → Tester 测试 → Ops 部署
Tester 跑了单元测试、集成测试、E2E 测试,全部通过。但为什么没发现 OOM?
答案很简单:Tester 运行在本地开发环境,有 16GB 内存可用。Docker 容器限制的 1GB 对本地来说毫无意义——V8 可以自由扩展到 4GB 堆空间,自然不会有 OOM。
这是典型的环境不对称问题。测试环境 ≠ 生产环境。Tester 和 Ops 之间缺少一个"接近生产"的预发布阶段。如果 Tester 在一个同样限制 1GB 内存的容器中跑一次 SSR 请求,这个 OOM 会在 30 秒内被抓住,根本不会进入部署阶段。
雪上加霜:SSH 被封
容器崩溃后,AI Ops Worker 自动启动了修复流程。但在多次 SSH 连接尝试后(大约第 8 次),出现了新问题:
paramiko.ssh_exception.SSHException: Error reading SSH protocol banner
服务器的 fail2ban 检测到了异常频繁的 SSH 连接,把 Ops Worker 的 IP 地址封禁了。同一个 Worker 一边在尝试修复容器,一边在触发安全防护——它在和自己的安全策略打架。
加上 paramiko 在连接失败时的自动重试机制(默认 3 次),fail2ban 的触发速度比预期快得多。从第一个连接失败到被封禁,只用了不到两分钟。
这是一个元问题:AI 自动修复系统需要一个"不会搞死自己"的约束。在尝试修复时,它不应该触发安全机制把自己排除在外。理想的方案是给 Ops Worker 配置 SSH 连接频率限制——每分钟最多 3 次新连接,超过则等待冷却。
五、修复方案
三个可选方案:
方案 A:增加容器内存 → 不可行
$ docker update --memory 2g web
Error: cannot update memory limit of a running container
即使更新了,服务器总共 2GB,给 Nuxt 2GB 意味着系统和 nginx 没有内存了。
方案 B:在 SSR 中跳过 Three.js → 最佳
修改 3DBackground.vue:
// 之前
const scene = new THREE.Scene();
// 之后
const isSSR = typeof window === 'undefined';
const scene = isSSR ? null : new THREE.Scene();
并在模板中加条件渲染:
<template>
<div v-if="!isSSR" ref="container" class="three-background" />
<div v-else class="placeholder-bg" />
</template>
这样 SSR 只输出一个占位 div,Three.js 场景仅在客户端挂载后初始化。
方案 C:使用 ClientOnly 包装 → 最简单
<ClientOnly>
<ThreeBackground />
</ClientOnly>
Nuxt 的 <ClientOnly> 组件会在 SSR 时输出注释占位符,在客户端 hydration 后渲染实际内容。一行代码解决问题。
最终选择
用了方案 C + 方案 B 的组合:<ClientOnly> 确保 SSR 安全,同时内部的 isSSR 检查作为防御性编程。
六、排障 SOP
基于这次经验,我总结了一套 Nuxt SSR 容器问题的排障标准流程:
1. 从外到内隔离
curl http → 通,curl https → 不通
→ 问题在 nginx↔Nuxt 或 SSL 层
2. SSL 层诊断
curl -v https → TLS 握手 OK,请求被 reset
→ nginx SSL 正常,上游有问题
3. 端口检查
curl localhost:3000 → Connection refused
→ Nuxt 没有在监听
4. 容器状态
docker ps -a → Exited (137)
→ OOM killed
5. 日志回溯
docker logs --tail 200 → FATAL ERROR: heap out of memory
→ 确定根因:SSR 内存溢出
6. 修复
方案 A:加内存(需评估整机资源)
方案 B:SSR 跳过 3D 库
方案 C:ClientOnly 包装
七、预防措施
- Docker 资源监控:在 compose 中加入
deploy.resources.limits和健康检查。容器不应该静默 OOM——要么主动限制内存避免 OOM,要么监控 OOM 事件并告警。 - SSR 内存预算:在 CI 中跑一次 production build + SSR 请求,记录内存峰值。当新的改动导致 SSR 内存增长超过 20%,CI 应该 Block 部署。
- Tester 容器化:Tester 必须在与生产相同的内存限制下运行测试。本地 16GB 通过的测试,在 1GB 容器里可能连 SSR 都跑不完——这个差距必须消灭。
- 渐进式部署:先部署到 staging 端口,跑完整 E2E 测试,再切换。不要一步到位。
- 回滚方案:每次部署前
docker tag website:latest website:rollback,出问题docker stop web && docker run -d --name web website:rollback。 - SSH 频率限制:在 Ops Worker 中实现连接频率控制——每分钟最多 3 次新连接,防止触发 fail2ban。这是 AI 自我防卫的基础设施。
- ClientOnly 防御:对所有含 Three.js / WebGL / Canvas 的组件,默认使用
<ClientOnly>包裹。这不是"可选优化"——在 SSR 上下文中,这是防止 OOM 的必需操作。
最关键的改进是第 3 条。当 Tester 和 Ops 运行在相同的资源约束下,"环境差异"这个坑就不存在了。这也是 DevOps 的老生常谈——"在相同环境中测试和部署"——AI 运维也需要遵守这个原则。区别在于,人类 DevOps 工程师靠经验知道这一点,而 AI 需要踩一次坑才知道。
结语
HTTP 200 → Connection reset,只隔了一个 Docker 重启。
排障这件事,90% 的工作是正确的隔离和缩小范围,10% 才是找到根因。curl、docker ps、docker logs 三个命令,配合对退出码的理解,足够定位绝大多数容器问题。
但真正要解决的,不是"怎么修"——是"为什么没在修之前发现"。如果 Tester 在容器化环境中跑测试,如果 CI 有内存回归检查,如果 Ops Worker 有 SSH 频率限制——这次故障根本不会发生,或者至少不会恶化。
每条预防措施,都是用一次生产故障换来的。
下一个排障对象?
已经踩过的坑,不会再踩第二次。
(但会有新的坑。)