
上个月某个凌晨OpenClaw Gateway 的报警群突然热闹起来日志文件里刷满了一行接一行的ECONNREFUSED。当时网关的健康检查已经连续失败三次用户侧开始零星出现 502。我爬起来看日志脑子里冒出来的第一个念头跟很多人遇到家里的 WiFi 路由器死机时一样重启一下也许就好了。你说神奇不神奇苹果 13 Pro 白屏了网上搜到的“自愈方法”十有八九也是先强制重启服务挂了第一反应也总归是重启。可问题是如果这个“重启”靠人来做那半夜爬起来的就是你如果靠脚本和系统机制来做它才配得上叫“自愈”。OpenClaw Gateway 这类自托管网关承担的是请求路由、鉴权、限流和上游转发的职责一旦它因为某种原因连不上数据库、连不上模型服务、或者自身进程假死用户看到的就只是一句冰冷的“连接被拒绝”。ECONNREFUSED 不是世界末日但如果你没有一套自动恢复机制它就是你每个值班夜晚的噩梦。这篇文章不聊高深的架构就讲我从一次线上的ECONNREFUSED故障出发怎么一步步搭出了进程守护、健康检查、应用层重试熔断的三层守护链把“手动重启”变成“自动自愈”。无论你是自托管爱好者、后端开发还是刚接手某个网关服务的运维新手这套思路和代码都能直接抄作业。1. 先搞清楚 OpenClaw Gateway 和 ECONNREFUSED 到底在发生什么1.1 网关的角色与故障现场OpenClaw Gateway 在自托管 AI 服务里扮演的角色很像小区门口的快递中转站。客户端把请求丢给网关网关看看你要调用哪个模型服务检查一下你的 API Key 有没有权限要不要限流然后替你把请求转发到真正的上游。也就是说网关本身不产生大模型算力但它决定了所有请求能不能顺畅进出。这样一个服务最怕的不是上游返回错误码而是网关自己因为某种原因“失联”——进程还在但端口不响应或者进程直接没了客户端连接被操作系统直接拒绝。那次故障的现场是这样的凌晨两点某条定时任务批量调用模型接口大量请求涌进网关。网关在转发到上游供应商时上游服务正在发版端口短暂不可连接。Node.js 应用层的 fetch 请求抛出了ECONNREFUSED而当时的代码只是简单 catch 住错误记了一条日志就返回 502。更糟糕的是失败后的网关没有做任何重试同时因为日志量突然暴增进程自身的堆内存开始攀升最终容器 OOM整个网关进程退出。进程退出后systemd 又因为默认的重启策略没配好干脆就停在那不动了。等到我发现的时候服务已经断了快二十分钟。1.2 ECONNREFUSED 不只是一个“连不上”ECONNREFUSED是 TCP 层连接被对端拒绝时操作系统抛给应用层的错误。在 Node.js 里它的 errno 就是ECONNREFUSED消息一般是connect ECONNREFUSED 127.0.0.1:8080。要注意的是“拒绝”和“超时”完全不是一回事。超时是数据包发出去没人回应可能是网络不通、防火墙丢包拒绝是对方操作系统直接回了 RST 包说明目标端口根本没有进程在监听或者服务监听的地址不对或者防火墙策略主动发了 RST。这个区分直接决定了你的自愈方案怎么设计。根据我排障的经验ECONNREFUSED 最常见的成因就是下面这几种排查时可以直接对照现象原因常见定位方法连本机端口被拒服务进程没起来或监听地址是 127.0.0.1 而非 0.0.0.0ss -tlnp | grep 8080连外部上游被拒上游服务端口未监听、上游发版重启瞬间curl -v https://上游地址偶发出现且自动恢复上游连接池中的 keep-alive 连接被对端关闭复用旧的 socket查看错误发生时对应的 socket 状态容器内出现容器端口未映射、容器健康检查未通过被停止docker ps -a、docker logs防火墙返回 RST安全策略主动拒绝而非目标端口未监听在防火墙日志中检索 RST 记录还有一个很隐蔽的坑Node.js 的 http 模块默认会保持 keep-alive 连接如果上游服务重启旧连接被对端关闭但你这边仍试图复用这个 socket就可能报ECONNREFUSED或者ECONNRESET。这类错误表面上是“连不上”本质是连接池里的僵尸连接在作祟。所以后面的自愈方案里我特意在应用层做了 socket 重建。2. 三层守护链的设计思路让自愈形成闭环2.1 为什么单靠 systemd 远远不够很多人一说到“服务挂了自动重启”第一反应就是 systemd 的Restartalways。这个配置确实能解决“进程崩溃后拉起来”的问题但它解决不了另外两类更隐蔽的故障第一进程还活着但端口不再响应业务逻辑卡死了systemd 看着 PID 还在就认为一切正常第二进程和端口都正常但网关依赖的上游模型服务不可用此时网关虽然能接收请求转发却总是失败对外表现就是大量 502。第一类问题需要健康检查来兜底第二类问题需要应用层自己做重试和熔断。我用白屏自愈来做类比可能更好理解手机突然白屏物理上重启一次系统自检能强制杀掉卡死的进程但如果重启之后系统里某个后台进程每次都会把屏幕弄白那你需要的就不是“重启”而是找出病根。对应到服务上第一层守护解决“进程没了拉起来”第二层守护解决“进程活了但服务没活”第三层守护解决“业务链路里偶发的上游抖动”。这三层各管一段彼此配合故障才不至于传导到用户侧。2.2 三个层次的分工与边界我自己在落地的时候把守护链拆成了下面这样的结构层级守护对象触发条件恢复动作典型手段第一层进程级进程退出、崩溃拉起进程systemd / supervisord / Docker restart policy第二层服务级端口不响应、健康检查失败重启进程或容器健康检查脚本 systemd timer第三层链路级上游连接失败、单次请求出错重试、降级、熔断应用层重试逻辑 熔断器第一层和第二层的界限在于“进程在不在”和“服务能不能真正提供业务能力”。我见过不少团队只配了Restartalways结果某天服务因为死锁假死进程占用 100% CPU端口完全无法建立新连接systemd 愣是没动一下。健康检查脚本就是用来戳破这种“假活”的它定时去请求一个内部健康端点约定好 2 秒内返回 200 就算活着否则就触发一次重启。第三层与前两层最大的区别是前两层只能重启本服务但管不了上游而网关这种中间层服务上游抖动的概率反而更高。应用层的重试和熔断相当于给每一位用户请求都配了一个“小守卫”第一次失败时自动换一条连接再试连续失败到一定次数就果断打开熔断开关避免把打不开的“死店”反复招待给用户。2.3 设计时不可忽略的三个原则第一层到第三层不是简单的叠加而是必须做到“每层都要有独立的故障感知能力”。如果健康检查脚本判断“服务不健康”后只调用systemctl restart而 systemd 又恰好没配好重启参数那第二层的自愈就会失败。所以每一层都不仅要检测故障还要确保自己触发的恢复动作真的有效最好在代码里加上“重启后再次验证”的步骤。第二任何自愈动作都必须是可观测的。守护链在自动处理问题但如果处理完你完全不知道发生过什么那就等于是在裸奔。我当时给每层都加了独立的日志标记第一层重启会记guardian[1] restart by systemd第二层健康检查失败会记guardian[2] healthcheck failed, restarting第三层重试和熔断也有自己的计数指标。这样排障的时候看日志就能知道到底是哪一层救了场。第三自愈的“阈值”必须保守。宁可少自动重启也不能因为误判把正常服务反复杀掉。健康检查失败一次就重启往往会引起雪崩服务明明在慢慢启动中第一次探测超时就触发重启导致永远起不来。这类阈值需要根据服务的正常启动耗时、上游响应耗时来调试我给 OpenClaw Gateway 的默认值是连续失败 3 次才重启每次探测间隔 10 秒这样既能容忍偶发抖动又不会贻误故障处理。3. 三层守护链的落地实操从配置到代码3.1 第一层systemd 守护进程OpenClaw Gateway 是一个 Node.js 服务第一层最直接的做法就是交给 systemd。这里不建议用node server.js前台跑然后靠 nohup 保活那个方案在进程退出后没有任何拉起机制本质上就是手动守护。下面是我在服务器上实际使用的 unit 文件路径是/etc/systemd/system/openclaw-gateway.service[Unit] DescriptionOpenClaw Gateway Service Afternetwork-online.target Wantsnetwork-online.target [Service] Typesimple Useropenclaw Groupopenclaw WorkingDirectory/opt/openclaw-gateway EnvironmentNODE_ENVproduction EnvironmentPORT8080 ExecStart/usr/bin/node /opt/openclaw-gateway/server.js Restartalways RestartSec5 StartLimitIntervalSec60 StartLimitBurst5 TimeoutStopSec20 KillSignalSIGTERM [Install] WantedBymulti-user.target几个关键点解释一下。Restartalways表示无论进程是什么原因退出都自动拉起包括正常退出和异常崩溃如果只想在非正常退出时重启可以用on-failure但网关这种服务我建议无条件重启。RestartSec5是重启前等 5 秒给系统一点缓冲防止疯狂重启。StartLimitIntervalSec60配合StartLimitBurst5意思是 60 秒内最多尝试启动 5 次超过就放弃避免“启动失败死循环”把系统拖垮。sudo systemctl daemon-reload sudo systemctl enable openclaw-gateway sudo systemctl start openclaw-gateway如果你用 Docker 部署systemd 这层就可以换成容器自身的 restart policydocker run -d --name openclaw-gateway \ --restartalways \ -p 8080:8080 \ -e NODE_ENVproduction \ openclaw-gateway:latest--restartalways同样会在容器退出后自动拉起。但注意Docker 的 restart policy 只在 Docker 守护进程存活时生效如果 Docker 服务本身挂了容器也不会被拉起来所以更稳妥的组合是“systemd 守护 docker 容器 Docker 内部 restart policy”。我见过有人只配了--restartalways结果 Docker 服务更新重启后老容器没被自动拉起服务照样断。3.2 第二层健康检查脚本与定时探测有了 systemd 保底之后我开始写第二层的健康检查脚本。它的逻辑很简单每隔一段时间去请求一次网关的健康端点如果连续多次失败就触发一次systemctl restart openclaw-gateway然后等待服务恢复再验证一次。这里我选择了 systemd timer 来做定时调度而不是 cron因为 systemd timer 的好处是可以和服务的依赖关系挂上钩日志也更统一。健康端点我用的是网关内部已有的/healthz这个接口做两件事一是检查进程自身的事件循环是否有响应二是顺便验证一下网关依赖的基础设施数据库连接池是否可用。下面是我写的/opt/openclaw-gateway/scripts/healthcheck.sh#!/usr/bin/env bash set -euo pipefail HEALTH_URLhttp://127.0.0.1:8080/healthz PID_FILE/run/openclaw-gateway.pid LOG_TAGguardian[2] log() { echo $(date %Y-%m-%d %H:%M:%S) $LOG_TAG $* | sudo tee -a /var/log/openclaw-healthcheck.log /dev/null } check_health() { local response_code response_code$(curl -s -o /dev/null -w %{http_code} --max-time 5 $HEALTH_URL || true) if [[ $response_code 200 ]]; then return 0 fi return 1 } if check_health; then exit 0 fi log healthcheck failed, checking again sleep 10 if check_health; then log second check passed, abort restart exit 0 fi log two consecutive failures detected, restarting service sudo systemctl restart openclaw-gateway sleep 15 if check_health; then log service recovered after restart else log service still unhealthy after restart, manual intervention may be required fi这个脚本有几个细节值得展开。第一第一次健康检查失败后不立即重启而是等 10 秒再探测一次这样能过滤掉偶发超时或者服务自动恢复的情形。第二curl 的--max-time 5是硬超时防止健康检查自身卡在等待上。第三系统重启动作放在脚本最后面的判断里尽量避免脚本自身的重复触发把 systemd 的StartLimitIntervalSec顶爆。timer 文件写两个一个.service一个.timer。/etc/systemd/system/openclaw-healthcheck.service里面其实就是上面那个脚本[Unit] DescriptionOpenClaw Gateway Health Check [Service] Typeoneshot ExecStart/opt/openclaw-gateway/scripts/healthcheck.sh/etc/systemd/system/openclaw-healthcheck.timer负责定时触发[Unit] DescriptionRun OpenClaw Gateway health check every 10 seconds [Timer] OnBootSec30 OnUnitActiveSec10 Unitopenclaw-healthcheck.service [Install] WantedBytimers.target启用 timerchmod x /opt/openclaw-gateway/scripts/healthcheck.sh sudo systemctl daemon-reload sudo systemctl enable --now openclaw-healthcheck.timer systemctl list-timers openclaw-healthcheck.timer装完之后要验证一下可以手动 kill 掉网关进程或者故意把健康端点改错观察 10 秒内它是否会被自动拉起来。我的建议是每次调整守护链之后都做一次“故障演练”别等到真出事才发现脚本有个路径拼写错误。3.3 第三层应用层的重试与熔断前两层守护的都是“本服务进程”而 OpenClaw Gateway 作为网关最频繁的故障其实是“上游模型服务不可用”。这个场景就算 systemd 和健康检查脚本都在也解决不了因为网关自身是健康的只是它要转发过去的对象不健康。所以第三层的重点是在应用代码里为所有上游调用加上“重试 退避 熔断”机制。我当时在 Node.js 里封装了一个统一的请求函数核心思路是这样的const RETRYABLE_ERRNOS new Set([ECONNREFUSED, ECONNRESET, ETIMEDOUT, EPIPE]); function isRetryable(err) { if (!err) return false; if (err.code RETRYABLE_ERRNOS.has(err.code)) return true; if (err.cause err.cause.code RETRYABLE_ERRNOS.has(err.cause.code)) return true; return false; } class CircuitBreaker { constructor(options {}) { this.failureThreshold options.failureThreshold || 5; this.cooldownMs options.cooldownMs || 30000; this.failures 0; this.state CLOSED; // CLOSED: 放行, OPEN: 熔断, HALF_OPEN: 试探 this.nextAttemptAt Date.now(); } async call(fn) { if (this.state OPEN) { if (Date.now() this.nextAttemptAt) { const error new Error(circuit breaker is open); error.isCircuitOpen true; throw error; } this.state HALF_OPEN; } try { const result await fn(); this.onSuccess(); return result; } catch (err) { this.onFailure(); throw err; } } onSuccess() { this.failures 0; this.state CLOSED; } onFailure() { this.failures 1; if (this.failures this.failureThreshold) { this.state OPEN; this.nextAttemptAt Date.now() this.cooldownMs; this.failures 0; } } } async function withRetry(requestFn, { retries 3, baseDelayMs 200, maxDelayMs 5000 } {}) { let attempt 0; while (true) { try { return await requestFn(); } catch (err) { attempt 1; if (!isRetryable(err) || attempt retries) { throw err; } const delay Math.min(maxDelayMs, baseDelayMs * Math.pow(2, attempt - 1)); log.warn(retryable failure (${err.code || err.message}), attempt ${attempt}/${retries}, waiting ${delay}ms); await sleep(delay); } } } const breaker new CircuitBreaker({ failureThreshold: 5, cooldownMs: 30000 }); async function callUpstream(requestConfig) { return breaker.call(() withRetry(async () { const controller new AbortController(); const timer setTimeout(() controller.abort(), 15000); try { const response await fetch(requestConfig.url, { method: requestConfig.method || POST, headers: requestConfig.headers || {}, body: requestConfig.body || undefined, signal: controller.signal, }); if (!response.ok) { const error new Error(upstream returned ${response.status}); error.status response.status; throw error; } return await response.json(); } finally { clearTimeout(timer); } }) ); }这段代码有几个关键判断值得细说。isRetryable只对连接级错误做重试HTTP 4xx 比如 401、403 这类请求本身就有问题重试一百次也没用反而会给上游制造垃圾请求。ECONNREFUSED、ECONNRESET、ETIMEDOUT才是典型的“上游临时抖动”值得重试。退避算法用的是指数退避第一次失败等 200ms第二次 400ms第三次 800ms封顶 5 秒这个策略既给了上游恢复的时间也不至于把你的请求积压成雪崩。CircuitBreaker的作用是防止连续失败时所有请求还在不停尝试连接一个已经宕机的上游。状态机的逻辑是连续失败 5 次进入 OPEN之后 30 秒内所有请求直接快速失败30 秒后进入 HALF_OPEN放一个请求过去试探一下如果成功了就关闭熔断恢复放行如果失败了就再次打开熔断。这个设计对于第三方模型 API 偶发故障特别实用比如凌晨那次上游在发版熔断直接就把请求挡在了网关层网关自身也就不会被拖垮。3.4 容器部署场景的补充方案如果你是用 Docker Compose 或者 Kubernetes 部署 OpenClaw Gateway第二层的健康检查脚本可以做得更细。Docker 本身支持healthcheck指令把上面的脚本端点探活挪进去services: gateway: image: openclaw-gateway:latest restart: always healthcheck: test: [CMD, curl, -f, http://localhost:8080/healthz] interval: 10s timeout: 5s retries: 3 start_period: 30s ports: - 8080:8080这里的start_period: 30s特别重要。网关第一次启动可能要加载配置、初始化连接池如果不给一个缓冲期健康检查会误判失败然后容器一直处于 unhealthy 状态。retries: 3配合interval: 10s也就是说从第一次探测失败开始大约 30 秒后才会把容器标记为 unhealthy。这个阈值和前面脚本里的“连续两次失败才重启”思路是一致的核心都是“容忍小抖动避免误杀”。Kubernetes 场景就更简单了用livenessProbe和readinessProbe分别承担第二层和流量摘除的职责livenessProbe: httpGet: path: /healthz port: 8080 initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 3 readinessProbe: httpGet: path: /readyz port: 8080 initialDelaySeconds: 5 periodSeconds: 5K8s 的好处是 kubelet 会自动把不健康的 Pod 杀掉重建同时 service 层也会自动摘掉不健康的端点用户请求不会打到不健康的 Pod 上。这个机制和 systemd 健康检查脚本本质上是同一层只是由平台实现了。4. 实战中踩过的坑与排查技巧4.1 守护链自身的坑比故障本身还多自愈机制如果不测试大概率会在真正的故障面前掉链子。我踩过最典型的几个坑整理出来给大家避雷。第一个是“systemd 重启次数限制被顶爆”。StartLimitIntervalSec60和StartLimitBurst5这个组合意味着 60 秒内最多重启 5 次一旦第 6 次失败systemd 就进入 failed 状态不再自动拉起。这时候如果你的健康检查脚本还在判断“服务不健康就重启”就会一直报start-limit-hit。解决方法是脚本里在重启之前先检查一下服务的活动状态如果是failed就不能盲目 restart得先 reset-failed 再 startif ! systemctl is-active openclaw-gateway --quiet; then systemctl reset-failed openclaw-gateway systemctl start openclaw-gateway fi第二个是“健康检查脚本自身成为单点故障”。curl 命令如果网关在启动阶段返回 000脚本就判定失败触发重启但如果脚本的幂等性做得不好服务还没起来又触发重启就会形成“永远重启失败”的循环。后来我在脚本里加了启动后的等待时间和二次探测情况才稳定下来。第三个是“fetch 的 keep-alive 连接池污染”。这个在前面提到过应用层如果复用被对端关闭的 socket会偶发ECONNREFUSED但你的重试逻辑马上会在新连接上成功。如果日志里看到这种现象不需要恐慌这说明重试已经在起作用了。真正的关注点是这种错误发生的频率有多高如果每一波请求都这样那就说明上游的连接回收策略有问题。第四个是“健康检查端点过于简陋”。只返回 200 不代表服务健康。我遇到过/healthz接口本身只检查进程事件循环结果数据库连接池耗尽时它依然返回 200导致健康检查认为一切正常但实际转发请求已大面积失败。后来我把健康端点升级为轻量级依赖探测检查数据库连接池的可用连接数、缓存服务的 ping 延迟、以及最近 30 秒内请求失败率只有这些全部低于阈值才返回 200。这样第二层守护才能真正反映业务健康状态。4.2 故障排查的正确姿势遇到ECONNREFUSED时不要上来就重启服务先花两分钟做一次快速定位。我习惯用的排查顺序是先看进程在不在再看端口监听状态然后看错误发生在本机还是上游。# 看进程是否存活 systemctl status openclaw-gateway # 看端口监听情况 ss -tlnp | grep 8080 # 看日志最近5分钟内的错误 journalctl -u openclaw-gateway --since 5 minutes ago -n 200 --no-pager # 直接手动探测健康端点观察返回 curl -sv http://127.0.0.1:8080/healthz 21 # 如果是连接外部上游失败手动测上游 curl -sv https://上游域名/v1/models 21 | head -20如果端口在监听健康检查也通过但请求还是报ECONNREFUSED那就把注意力放到应用日志里出现的错误上下文看看是不是连接池里的旧连接问题。如果端口根本没在监听那就是第一层守护失效了优先检查 systemd 是不是进入了failed状态、StartLimitBurst是不是被触发了。在容器环境里把docker logs和docker inspect结合起来看尤其关注容器退出码。退出码 137 表示被 SIGKILL通常是 OOM143 表示被 SIGTERM 优雅关闭如果老是 137就要留意内存上限是不是设太小了。4.3 告警的最终兜底守护链不能取代人三层守护链再完善最终也还是需要告警通知。自愈机制解决问题之后你需要知道问题发生过多频繁、恢复用了多久以及是不是同一个故障反复来。我给 OpenClaw Gateway 接入了两个维度的告警一是守护链动作日志的实时通知二是基于 Prometheus 指标的趋势告警。守护链动作日志的实时通知用的是很简单的办法。脚本里每次触发重启时调用一次 webhook 往群里发消息内容包括服务名、错误原因、当前时间。这样哪怕半夜没人在电脑前手机也能收到通知。Prometheus 那边则采集了三个指标gateway_restart_total、gateway_healthcheck_failed_total、gateway_circuit_breaker_open_total。前两个是爆发式指标第三个是状态式指标。趋势告警比单点告警更可靠如果gateway_restart_total在一小时内飙到 10 次说明问题不只是偶发抖动需要人工介入查根源。值得提醒的是守护链的日志要尽量结构化。我当时用了 JSON 格式每个字段包括guardian_layer、action、target_service、error_code、recovered等后续在日志平台里做筛选特别方便。别小看这个习惯等出了大故障回头看日志时能迅速按层过滤省下的排障时间不是一点半点。5. 三层守护链上线之后的效果与个人心得这套守护链上线两周最直观的变化是同类故障不再需要半夜爬起来。第一次完成后上游供应商又出现过一次短暂发版窗口应用层的重试和熔断直接就把问题消化了用户侧没有任何感知。第二次是网关自身因为内存泄漏 OOMsystemd 在 5 秒内把它拉了起来健康检查脚本确认恢复后才继续放流量整个过程不到一分钟报警群里只有一条守护链发出的恢复消息。如果你也要在自己的自托管服务上搭类似的机制我的建议是先别急着上全套而是慢慢叠加。先从第一层的Restartalways做起观察一两周日志把进程崩溃的规律摸清楚然后加第二层健康检查和定时探测调好阈值做一次故障演练最后再把第三层的重试和熔断补上配合告警通知。每一层都验证稳定了再上下一层就不会出现“守护链互相打架”的尴尬局面。最后再分享一个小技巧把故障演练写进自己的检查清单里。每次调整完守护链我都强制自己 kill 一次服务进程故意让健康检查失败一次然后看着它自己恢复正常。这套“白屏自愈”的方法论其实和人遇到问题一样——先不慌判断病根在哪个层级再对症下药。服务层面多一道守护人就能少熬一个夜。