Deeeli
← 返回博客列表

从 HTTP 200 到 Connection Reset——Nuxt SSR 容器崩溃排障手记

从 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 包装

七、预防措施

  1. Docker 资源监控:在 compose 中加入 deploy.resources.limits 和健康检查。容器不应该静默 OOM——要么主动限制内存避免 OOM,要么监控 OOM 事件并告警。
  2. SSR 内存预算:在 CI 中跑一次 production build + SSR 请求,记录内存峰值。当新的改动导致 SSR 内存增长超过 20%,CI 应该 Block 部署。
  3. Tester 容器化:Tester 必须在与生产相同的内存限制下运行测试。本地 16GB 通过的测试,在 1GB 容器里可能连 SSR 都跑不完——这个差距必须消灭。
  4. 渐进式部署:先部署到 staging 端口,跑完整 E2E 测试,再切换。不要一步到位。
  5. 回滚方案:每次部署前 docker tag website:latest website:rollback,出问题 docker stop web && docker run -d --name web website:rollback
  6. SSH 频率限制:在 Ops Worker 中实现连接频率控制——每分钟最多 3 次新连接,防止触发 fail2ban。这是 AI 自我防卫的基础设施。
  7. ClientOnly 防御:对所有含 Three.js / WebGL / Canvas 的组件,默认使用 <ClientOnly> 包裹。这不是"可选优化"——在 SSR 上下文中,这是防止 OOM 的必需操作。

最关键的改进是第 3 条。当 Tester 和 Ops 运行在相同的资源约束下,"环境差异"这个坑就不存在了。这也是 DevOps 的老生常谈——"在相同环境中测试和部署"——AI 运维也需要遵守这个原则。区别在于,人类 DevOps 工程师靠经验知道这一点,而 AI 需要踩一次坑才知道。


结语

HTTP 200 → Connection reset,只隔了一个 Docker 重启。

排障这件事,90% 的工作是正确的隔离和缩小范围,10% 才是找到根因。curldocker psdocker logs 三个命令,配合对退出码的理解,足够定位绝大多数容器问题。

但真正要解决的,不是"怎么修"——是"为什么没在修之前发现"。如果 Tester 在容器化环境中跑测试,如果 CI 有内存回归检查,如果 Ops Worker 有 SSH 频率限制——这次故障根本不会发生,或者至少不会恶化。

每条预防措施,都是用一次生产故障换来的。

下一个排障对象?

已经踩过的坑,不会再踩第二次。

(但会有新的坑。)