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:1455LocalForward 是 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=42839Next.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:LISTENss -tlnp | grep 3000 看所有 TCP 连接lsof -nP -iTCPss -tn 看某进程网络lsof -nP -p <PID>ss -tnp | grep <PID>
三、关键观察:localhost 和 127.0.0.1 居然不等价
抓不到头绪的时候我做了个对照实验,把浏览器里的 URL 换一下:
http://localhost:3000→ERR_CONNECTION_REFUSED
http://127.0.0.1:3000→ 秒开
同一台机器,同一个端口,仅仅是访问名字不同,一个通一个不通。
这一步是整个 debug 过程的转折点。在大多数人的直觉里,
localhost 和 127.0.0.1 是同义词,但事实上它们不是。打开 /etc/hosts 文件可以看到 macOS 默认的映射:127.0.0.1 localhost
::1 localhostlocalhost 这个名字同时映射到两个不同的 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:3000 和 127.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 的诀窍,或许就是练习一种"故意不相信自己第一反应"的能力。
【参考文献】
- RFC 6724 - Default Address Selection for Internet Protocol Version 6 (IPv6): https://datatracker.ietf.org/doc/html/rfc6724
- RFC 8305 - Happy Eyeballs Version 2: https://datatracker.ietf.org/doc/html/rfc8305
- OpenSSH ssh_config manual (LocalForward, ExitOnForwardFailure, ServerAliveInterval): https://man.openbsd.org/ssh_config
- Next.js CLI - next dev: https://nextjs.org/docs/app/api-reference/cli/next
- tmux manual: https://man.openbsd.org/tmux
- Author:盛溪
- URL:https://tangly1024.com/article/%E4%B8%80%E6%AC%A1%20ERR_CONNECTION_REFUSED%20%E7%9A%84%E5%AE%8C%E6%95%B4%E6%8E%92%E6%9F%A5%EF%BC%9ASSH%20%E7%AB%AF%E5%8F%A3%E8%BD%AC%E5%8F%91%E4%B8%8E%20IPv4/IPv6%20%E5%8F%8C%E6%A0%88%E9%99%B7%E9%98%B1
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!




.jpg?table=block&id=26f7c1d5-a1e9-80d7-a52b-e71bb7079501&t=26f7c1d5-a1e9-80d7-a52b-e71bb7079501)



