Lazy loaded image
技术分享与前沿技术域认知
Lazy loaded image一次 ERR_CONNECTION_REFUSED 的完整排查:SSH 端口转发与 IPv4/IPv6 双栈陷阱
Words 3846Read Time 10 min
2026-5-12
2026-5-12
slug
一次 ERR_CONNECTION_REFUSED 的完整排查:SSH 端口转发与 IPv4/IPv6 双栈陷阱
type
Post
status
Published
date
May 12, 2026
tags
推荐
文字
思考
summary
category
技术分享与前沿技术域认知
icon
password
那天下午我在终端里跑 npm run dev,远端 Next.js 顺利启动,控制台打出 Ready in 1297ms。本地浏览器输入 localhost:3000 准备开工——页面报错:"localhost 拒绝了我们的连接请求",下面一行小字:ERR_CONNECTION_REFUSED
故事的开始往往看着不复杂。这里没什么神秘的东西:远端有个 Next.js dev server,本地通过 SSH 端口转发访问它,整套链路在过去几周里跑得好好的。但这次不行了,浏览器拒绝连接。
我的第一反应是远端服务挂了。后来的几个小时证明我错得离谱——远端服务从头到尾都好好的,故障藏在四个组件之间的"默认值组合"里,每个组件单独看都正常,组合起来让请求恰好断在最后一跳。
这篇文章记录的不是炫技的 debug,而是一次相当普通的踩坑:从看似的 server down 到最终定位到 IPv4/IPv6 双栈陷阱。问题本身有点冷门但绝不偏门,分布式系统里这种"组件间隐式默认值不匹配"的故障非常典型,掌握一次以后能复用很多场景。

一、先把战场画清楚

我的开发流程长这样:代码在 AWS 上一台远端 EC2 上跑,本地 Mac 通过 SSH 隧道访问它,浏览器看到的 localhost:3000 实际打到的是远端 Next.js。SSH 配置里相关的两行是:
LocalForward 3000 localhost:3000 LocalForward 1455 localhost:1455
LocalForward 是 SSH 客户端的一个功能,含义可以拆成左右两半:左边是本地监听端口——SSH 客户端在我 Mac 上开一个监听口;右边是远端连接目标——SSH 服务器在远端那台机器上发起的连接目标。两边通过已经建立的 SSH 加密通道串起来。
写成一条完整的请求流,整条路径分三段:
  • 第一段:浏览器到本地 SSH 客户端的监听口(同一台机器内的回环连接)
  • 第二段:SSH 隧道穿过公网,端到端加密
  • 第三段:远端 SSH daemon 到本机 Next.js(远端机器内的回环连接)
三段任意一段断,浏览器都会 connection refused。排查的第一原则就是分段确认每段是否在工作。
【插图位置:SSH 端口转发的三段链路示意,标注每段所属的机器和加密范围】类型:技术图解

二、第一阶段:每一段是否还活着

本地这段,用 macOS 的 lsof 命令查谁在监听 3000:
bash
lsof -nP -iTCP:3000 -sTCP:LISTEN
输出:
COMMAND PID USER FD TYPE ... NAME ssh 97766 felix 7u IPv6 ... TCP [::1]:3000 (LISTEN) ssh 97766 felix 8u IPv4 ... TCP 127.0.0.1:3000 (LISTEN)
本地确实有人在监听 3000,是 SSH 进程,PID 97766。但请注意输出里出现了两行——一个绑在 IPv6 的 [::1]:3000,一个绑在 IPv4 的 127.0.0.1:3000同一个 SSH 进程同时绑了两条腿,这是后面剧情里关键的伏笔。
远端这段,先 ssh 上去,再用 Linux 的 ss 命令看:
bash
ssh ec2-host 'ss -tlnp | grep 3000'
输出:
LISTEN 0 511 0.0.0.0:3000 ... next-server (v1), pid=42839
Next.js 进程也活着,PID 42839,监听在 0.0.0.0:3000
到这里看似两端都正常工作。但浏览器就是连不上。
顺便记一下:macOS 上没有 ss 命令——它是 Linux 系统的工具。Mac 上的等价命令是 lsof,更通用但语法不同。两个系统排查端口的姿势对照如下。注意 macOS 也有 netstat,但 BSD 系的参数跟 Linux 完全不一样,混用是 cross-platform debug 的常见坑。
目的
macOS
Linux
看端口监听者
lsof -nP -iTCP:3000 -sTCP:LISTEN
ss -tlnp | grep 3000
看所有 TCP 连接
lsof -nP -iTCP
ss -tn
看某进程网络
lsof -nP -p <PID>
ss -tnp | grep <PID>

三、关键观察:localhost 和 127.0.0.1 居然不等价

抓不到头绪的时候我做了个对照实验,把浏览器里的 URL 换一下:
  • http://localhost:3000ERR_CONNECTION_REFUSED
  • http://127.0.0.1:3000 → 秒开
同一台机器,同一个端口,仅仅是访问名字不同,一个通一个不通。
这一步是整个 debug 过程的转折点。在大多数人的直觉里,localhost127.0.0.1 是同义词,但事实上它们不是。打开 /etc/hosts 文件可以看到 macOS 默认的映射:
127.0.0.1 localhost ::1 localhost
localhost 这个名字同时映射到两个不同的 IP——一个 IPv4,一个 IPv6。程序拿到 localhost 解析时面临一个选择:走 IPv4 还是 IPv6?而 127.0.0.1 是数字 IP,绕过了这个选择,明确锁定 IPv4。
故事到这里其实已经清楚了。但要把它讲明白,得先简单交代一下 IPv4/IPv6 双栈这件事。

四、IPv4/IPv6 双栈:现代系统的"两条腿"

现代操作系统同时支持 IPv4 和 IPv6 两套独立的网络栈。两套地址、两套监听、两套路由表,互不通气。这不是历史遗留,是当前的现实——IPv4 地址池早就分完,IPv6 在数据中心和移动网络里已经成主流。
几个特殊地址值得记住:
IPv4
IPv6
本机回环
127.0.0.1
::1
通配(绑所有接口)
0.0.0.0
::
注意 0.0.0.0只覆盖所有 IPv4 接口的通配符,不包含 IPv6。这是个反直觉的细节——很多人以为"0.0.0.0 等于绑所有",但在双栈语境下它只绑半边。Node.js 的 next dev -H 0.0.0.0 就是这个含义,Next.js 默认 bind 只覆盖 IPv4。
一个 socket 绑在 127.0.0.1:3000 不会接受任何 IPv6 来源的连接;绑在 [::1]:3000 也不接受 IPv4。两个"3000 端口"在内核眼里是两个独立的监听 socket,只是恰好用了同一个端口号。
localhost 在 hosts 文件里同时指向两条腿,让程序"看起来"可以通用,但实际上每次解析时都隐含一次"走哪条腿"的选择。这个选择在不同程序、不同操作系统下表现不一致——而这就是问题的来源。

五、根因:四个组件的默认值不一致

回到我的故障。把整条链路的四个组件挨个查一遍各自的默认行为:
组件
默认行为
偏向哪条腿
Chrome 解析 localhost
按 RFC 6724 地址选择规则
优先 IPv6(::1)
本地 SSH 的 LocalForward 3000(不指定 IP)
双栈监听
同时绑 IPv4 和 IPv6
远端 SSH 解析 target localhost
取决于远端 hosts 文件
视环境而定
Next.js 启动 -H 0.0.0.0
绑 IPv4 通配符
仅 IPv4
每一个默认值单独看都站得住脚——Chrome 偏好 IPv6 是想推动 IPv6 普及,SSH 双栈监听是为了最大化兼容性,Next.js 用 0.0.0.0 是 Node.js 网络层的历史选择。问题是这些默认值在被设计时并不知道彼此存在,组合起来产生了不可见的失败路径。
把请求实际走的路径画出来就清楚了:
Chrome (localhost → ::1) ← 选择走 IPv6 ↓ 本地 SSH 的 [::1]:3000 监听 ← 双栈监听,IPv6 这边接住了 ↓ SSH 通过隧道转发,target = localhost:3000 ↓ 远端 SSH 在远端解析 localhost ← 在双栈系统的不同节点上行为可能不一致 ↓ 连接 Next.js 0.0.0.0:3000 ← Next.js 在 IPv4 上等着
在我那台具体的环境里,整条 v6 路径在某一步就断了——精确机制涉及 OpenSSH 在 dual-stack 下的某些边角行为,难以完全复现归因。但这不是重点。重点是:只要这条路径在任何一处依赖了隐式默认值,整体可靠性就被那一处的不确定性绑架
127.0.0.1:3000 这条路完全不一样。浏览器拿到数字 IP 直接走 IPv4,命中本地 SSH 的 IPv4 监听,通过隧道转发,远端 Next.js 也在 IPv4 上等着——全程没有任何"走哪条腿"的选择,连接稳定成功
这就是分布式系统里特别经典的一个失败模式:故障不在任何一个组件内,而在组件之间默认值的组合处
【插图位置:IPv4/IPv6 双栈下四个组件的默认值矩阵,标出 v6 路径在哪一跳断开】类型:技术图解

六、修复:把每个边界都钉死

修法不是 debug 某个故障点,而是让所有边界都不再依赖默认值
修改后的 SSH config:
diff
  • LocalForward 3000 localhost:3000 + LocalForward 127.0.0.1:3000 127.0.0.1:3000 + ServerAliveInterval 30 + ServerAliveCountMax 3 + ExitOnForwardFailure yes
逐行解释。
LocalForward 左侧 127.0.0.1:3000 —— 让本地 SSH 只在 IPv4 上监听,不再开 IPv6 那条腿。Chrome 即使仍然优先尝试 ::1,会立刻得到连接被拒(IPv6 没人接),然后按 happy-eyeballs 算法 fallback 到 IPv4,连接到真正在监听的 127.0.0.1:3000
LocalForward 右侧 127.0.0.1:3000 —— 让远端 SSH 直接连数字 IP,不再解析 hostname。消除了远端那一侧的 DNS 不确定性。
ServerAliveInterval 30 + ServerAliveCountMax 3 —— SSH 默认对空闲连接没有心跳,时间一长 NAT 设备或防火墙会以为这条连接已经废弃,单方面把它断掉。这两条让 SSH 每 30 秒发一次心跳保活,连续 3 次失败(约 90 秒无响应)才认定断开,能挡掉大部分偶发的隧道掉线。
ExitOnForwardFailure yes —— 这条特别重要。SSH 默认行为是 LocalForward 建不起来时静默跳过——SSH 连接照常成功,但 forward 实际上没建立。结果是你以为连上了,但每次走 forward 都失败,错误信息又非常模糊。这个 flag 让 SSH 在 forward 失败时直接拒绝连接退出,消除"看起来连着但其实没转发"的二义性状态
改完后再用 lsof 看,本地监听变成了单行:
ssh 97904 felix 5u IPv4 ... TCP 127.0.0.1:3000 (LISTEN)
IPv6 那条腿消失了——这是配置生效的标志。localhost:3000127.0.0.1:3000 至此都能正常访问。
【插图位置:修复前后的本地监听对比,以及请求路径的简化】类型:技术图解

七、附带的另一个坑:进程生命周期解耦

整个排查过程中我还掉进了一个跟主问题不直接相关、但值得提的坑:远端 Next.js 是用 npm run dev 在 SSH 前台跑的,它的进程父级就是当前的 SSH shell。一旦 SSH 会话因为任何原因变化——网络抖动、shell 退出、tmux session 重置——Next.js 都会被连带杀掉。
排查过程中我有几次以为"远端 Next 挂了",开始重启它,结果发现根本没挂,只是 SSH session 状态变化造成的假象。这是另一种"看起来出问题的地方不是真出问题的地方"。
正确做法是用 tmux 把长跑进程跟 SSH 会话彻底解耦:
bash
ssh ec2-host tmux new -s dev cd /opt/project npm run dev # Ctrl+B 然后 D 退出 tmux,进程继续在跑 exit # 退出 SSH,进程依然不受影响
下次回来 tmux attach -t dev 就能拿回前台日志和控制权。本地 SSH 怎么来怎么去都无关,远端进程稳定运行。
这是 SRE 和平台工程的一个基本架构思维:职责单一、生命周期独立。SSH 会话只负责"我能登上去这台机器",tmux 会话负责"远端进程的存活",二者解耦后任何一边的崩溃都不影响另一边。
把这种思维推广开,就是分布式系统设计里的"故障域隔离"——让不同的依赖关系不要纠缠在一起,每个组件的失败影响半径越小越好。

八、能带走的几个 takeaway

走完这一遍排查,几个比较通用的反思。
错误点不等于根因点。 我看到的报错是 ERR_CONNECTION_REFUSED,出错位置在最后一跳;但根因发生在最初一跳——浏览器的地址族选择。debug 网络问题时容易盯着报错的那一端死磕,但真正要看的是整条路径上最早做出错误选择的环节。
隐式默认值在跨边界处累积失败。 单个组件的默认值通常是合理的——Chrome 偏好 IPv6、SSH 双栈监听、Next.js 用 0.0.0.0——每个决策单独看都站得住脚。但这些决策在被设计时并不知道彼此存在,组合起来就可能产生不可见的失败路径。系统边界处永远要警惕隐式行为
修复二义性比修复故障更有价值。localhost 换成 127.0.0.1、把 LocalForward 3000 换成 LocalForward 127.0.0.1:3000 127.0.0.1:3000,本质都是用显式配置消灭由默认值产生的二义性。这种修复不是绕开问题,而是让问题不可能再发生。Workaround 是绕路,根治是改地形。ExitOnForwardFailure yes 同理——它把"看起来连着但其实没连"这种隐形状态变成"要么明确成功,要么明确失败",消除了状态空间里的灰色地带。
间接层是计算机科学的万能套路。 这次故事里几乎所有的复杂性都来自间接层——hostname 是 IP 的间接层、SSH forward 是网络访问的间接层、tmux 是进程会话的间接层。间接层是好东西,它让系统更灵活、更易于演化;但每加一层就多一个可能出错的位置。理解每一层的语义边界,是网络和系统问题排查的核心能力。
写到这里我还想到一件事。debug 过程中最浪费时间的不是"不知道答案",而是"以为自己知道答案"。我第一眼看到 ERR_CONNECTION_REFUSED 时,脑子里立刻闪过的是"远端服务挂了",然后所有动作都围绕这个假设。事实是远端服务从头到尾好好的,问题在我从来没怀疑过的地方。
debug 的诀窍,或许就是练习一种"故意不相信自己第一反应"的能力。

【参考文献】
  1. RFC 6724 - Default Address Selection for Internet Protocol Version 6 (IPv6): https://datatracker.ietf.org/doc/html/rfc6724
  1. RFC 8305 - Happy Eyeballs Version 2: https://datatracker.ietf.org/doc/html/rfc8305
  1. OpenSSH ssh_config manual (LocalForward, ExitOnForwardFailure, ServerAliveInterval): https://man.openbsd.org/ssh_config
  1. Next.js CLI - next dev: https://nextjs.org/docs/app/api-reference/cli/next
  1. tmux manual: https://man.openbsd.org/tmux
 
 
 
 
 
上一篇
情绪价值的本质:先成为有价值的人
下一篇
一段顶三段:OpenAI Realtime 系列与语音翻译赛道的塌缩