状态:实测(2026-11-01)。 这是在家用路由器后面的 TT Lab 集群中测得的数据。路由器、集群以及网络配置均未作任何改动,仅使用了读取操作和临时 Pod。 地址和域名已替换为文档示例(192.0.2.0/24、203.0.113.0/24、
app.example.com),时间和次数均为实际值。 编写规则:以实测数据为基础,未能测出的标记为“未实测”,原因未确认的解释标记为“推测”。
目录
1. 从结论开始
我们在路由器后面部署了 Web 服务并绑定了域名。通过手机流量可以正常访问,但在连接同一 Wi-Fi 的笔记本电脑上却无法打开。服务器一切正常, DNS 也没有问题。这就是发夹 NAT 问题。
- 原因是路由器。 局域网内的客户端向公网地址发送的请求需要由路由器再转回局域网内部(发夹效应),如果路由器未进行该处理或仅修改了目的地址,则无法建立连接。
- 发夹 NAT 会同时修改目的地址(DNAT)和源地址(SNAT)。 只有这样,服务器的响应才会经过路由器返回,且路由器能够将地址还原后转交给 客户端。NAT 规范将此称为“必须支持(MUST)”。
- 如果无法更改需要修复的地方,那就更改名称。 最常见的解决方案是分离 DNS,使得仅在局域网内部时,相同的域名指向内部地址。如果是几台设备,通过 hosts 文件也能解决。
- 实测(未修改配置): 从笔记本电脑、集群节点和 Pod 这三个地方访问公网地址时,均连续 3 次出现 5 秒超时;若使用相同的名称直接访问内部地址,则返回 200。在外部四个地点中有三个地点收到了 200。
Kubernetes 内部类似的问题(Pod 请求自己的 Service)将在 第 2 篇中探讨。
2. 问题所在
家庭或小型办公室的路由器通常拥有一个公网地址,并为内部设备分配私有地址。要将外部进入的请求发送到内部服务器,需要配置 端口转发。规则类似于“将发往公网地址 443 端口的请求转发到服务器 192.0.2.10 的 443 端口”。路由器会修改传入数据包的 目的地址, 将其替换为服务器地址。这被称为 DNAT(Destination NAT)。
对于外部访客来说,这就足够了。服务器的响应会沿着默认路由到达路由器,路由器利用记录的连接信息将源地址还原为公网地址并发送给访客。访客发送至公网地址,也从公网地址收到了响应,因此没有问题。
问题在于 访客位于同一局域网内时。当笔记本电脑(192.0.2.20)查找 app.example.com 时,公共 DNS 会告知其公网地址(203.0.113.7)。
笔记本电脑向该地址发送请求,请求到达默认网关即路由器。此时路由器可以采取三种处理方式:
- 什么都不做。 对来自局域网侧的数据包不应用端口转发。路由器虽然将其视为自己的地址并接收,但由于没有在 443 端口上监听,因此会将其丢弃或拒绝。
- 仅修改目的地址(DNAT)。 这是下图所示的情况。
- 同时修改目的地址和源地址(DNAT + SNAT)。 这就是发夹 NAT。
仅修改目的地址时
如果路由器仅将目的地址修改为服务器地址,服务器收到的数据包的源地址就是笔记本电脑的私有地址(192.0.2.20)。在服务器看来,笔记本电脑是 同网段的邻居, 因此它不会将响应发送给路由器,而是直接发送给笔记本电脑。响应数据包的源地址是服务器地址(192.0.2.10)。
笔记本电脑为了与 203.0.113.7 的 443 端口建立连接而发送了 SYN。然而,返回的 SYN-ACK 的源地址却是 192.0.2.10。笔记本电脑的 TCP 无法将此数据包与自己发起的连接进行匹配,因此会将其当作未知数据包处理(通常会发送 RST 进行拒绝)。笔记本电脑最终没有收到期待的响应,在多发送几次 SYN 后因超时而结束。由于路由器的响应没有经过自身,因此也没有机会还原地址。
要点是 请求和响应走的是不同的路径。NAT 必须让一个连接的双向流量都经过自己,才能先修改地址再进行还原。
同时修改源地址时:发夹 NAT
如果路由器在修改目的地址的同时 将源地址也修改为自己的地址 (②),服务器就会认为请求来自路由器,并将响应发送给路由器(③)。路由器根据连接记录还原两个地址,将源地址变为 203.0.113.7 传递给笔记本电脑(④)。笔记本电脑从期待的地址收到了响应,从而成功建立连接。由于数据包进入路由器后像发夹弯一样折返并朝同一侧流出,因此被称为发夹(Hairpin)NAT。环回 NAT(Loopback NAT)和 NAT 反射(NAT Reflection)也是指代该功能的名称。
这也是有代价的。从服务器的角度来看, 局域网内的所有访问看起来都像是来自同一个路由器 。无法通过访问日志区分内网用户,基于源地址的访问限制或请求频率限制会将所有内网用户视为同一个人。由于内网流量需要经过路由器两次,因此也会消耗路由器的吞吐量。
规范要求什么
定义 NAT 行为的 IETF 文档并没有将回环(Hairpinning)设为可选项。
- RFC 4787 (UDP) REQ-9: NAT 必须 支持回环,并且回环转发的数据包的源地址必须是“外部源 IP 地址和端口”(External source IP address and port)。
- RFC 5382 (TCP) 第 7.2 节和 REQ-8: TCP 中也有同样的要求。
为了便于理解,图中将源地址画成了路由器的 私网侧地址(192.0.2.1),但规范要求的形态是,源地址显示为 公网地址和外部使用的端口。无论哪种,响应数据包能回到路由器这一核心点是一样的。在用 Linux 自制路由器时经常使用的“源自私网、目的地为服务器则使用 MASQUERADE”规则,就是将其转换为私网侧地址的方式。在实际的家用路由器中,有些产品与规范不同,不支持回环或者允许将其关闭(这次测试的路由器也符合第 2 节中的 1 或 2。具体属于哪一种将在第 5 节中介绍)。
3. 四种解决方案
如果能够修改路由器,那是最好的,但往往无法做到这一点。解决方法大致有四种,实际中通常会组合使用两种或以上。
| 方法 | 操作 | 什么时候使用 | 缺点 |
|---|---|---|---|
| 分离 DNS | 私有网络内的 DNS 针对相同的名称 内部地址进行响应 | 当公司内部设备与集群较多且直接运营 DNS 服务器时 | 由于内外部响应不同,在确认“DNS 是否正确”时容易混淆。当内部地址变更时,需要修改两处地方。对于像 DoH 这样绕过公司内网 DNS 的设备则不生效 |
| hosts 文件 | 在单个设备上将域名固定为内部地址 | 适用于少数几台设备、情况紧急时 | 需要逐台手动修改且容易遗忘。带着笔记本电脑外出时,在外部将无法访问该域名 |
| 路由器设置 | 开启路由器的发夹式 NAT(NAT 回环)功能 | 当路由器支持该功能且可以修改设置时 | 不同产品的名称和设置位置各不相同,甚至可能根本没有该功能。服务器端看到的内部访问来源会显示为路由器地址 |
| 经由外部 | 内部设备经由外部的代理、隧道和 VPN,再进入公网地址 | 当需要完整测试外部访客所看到的访问路径时 | 速度慢、成本高,且外部设备可能会多故障一个 |
补充几点。
- 拆分 DNS(Split DNS)与 TLS 非常契合。 由于名称保持不变,因此证书名称、SNI 以及 Host 请求头都不会改变。如果直接用 IP 访问内网地址,会导致证书名称不匹配,但拆分 DNS 和 hosts 文件则没有这个问题。如果只想在某个命令中临时替换,可以使用
curl --resolve app.example.com:443:192.0.2.10能够实现类似的效果。 - 拆分 DNS 无法保证“外部一定能通”。 内网能顺畅打开,并不代表外部也能打开。外部的状态必须从外部来查看(第 5 节)。
- 这次没有检查路由器设置。 由于秉持不更改配置的原则,该路由器是否有开启 NAT 环回(Hairpin NAT)的菜单选项以及开启后的具体行为均未进行实际测量。
4. 测量方法
在不修改配置的情况下,在三个地方对比了“使用相同的名称,分别访问公网地址和内网地址”的行为。
| 位置 | 如何解析名称 |
|---|---|
| 笔记本电脑(macOS,私有网络) | hosts 文件将名称固定到了内网地址。公网地址端通过 --resolve 进行强制指定 |
| 集群节点(Ubuntu,私有网络) | hosts 文件中反而记录着公网地址。内网地址端通过 --resolve 进行强制指定 |
| 集群内的临时 Pod | 集群 DNS(CoreDNS)的 hosts 插件将名称解析为内网地址(拆分 DNS)。公网地址端仅尝试建立 TCP 连接 |
命令的形式大致如下。每次尝试均设定了 5 秒的超时限制。
# 공인 주소로 (DNS 를 건너뛰고 공인 주소를 강제, 이름은 그대로)
curl -sv --max-time 5 --resolve app.example.com:443:203.0.113.7 https://app.example.com/
# 내부 주소(게이트웨이)로
curl -sv --max-time 5 --resolve app.example.com:443:192.0.2.10 https://app.example.com/
外部对照是通过从多个国家发送 HTTP 请求的公开检测服务(check-host.net 的 HTTP 检测)在四个地点进行的。
5. 实测结果
| 位置 | 访问公网地址 | 访问内网地址 |
|---|---|---|
| 笔记本电脑 | 3/3 超时(5.00~5.01 秒,在连接阶段卡住)。端口 80 也超时 | 3/3 HTTP 200,连接耗时 2.8~4.0ms,总耗时 23~28ms |
| 集群节点 | 3/3 次超时(5.00 秒)。对遵循 hosts 公网地址的默认访问也超时 | 3/3 HTTP 200,总体 24~31ms |
| 临时 Pod | TCP 连接超时(5.0 秒) | 名称被解析为内部地址, /healthz 200(136ms,因为是第一次请求,包含 TLS 连接) |
| 外部四个位置 | 三个位置 HTTP 200(0.78~1.05 秒),一个位置「Connection reset by peer」 | 不适用 |
呈现出了在外部可以打开、但在内部无法通过公网地址打开的发夹问题(Hairpin)的现象。我们在 2026-10 的故障记录中曾记下「内网发夹被阻止」,到了 2026-11-01 依然如此。外部某一个位置拒绝连接的情况,由于其他三个位置正常,因此推测可能是该位置自身的原因,但未能确认原因(推测)。
我们还测量了以下几点。
- 路由器直接持有公网地址。 从私有网络对公网地址执行 traceroute 时,第一跳就是该公网地址本身;使用 ping 测试时,笔记本上的响应为 0.65~1.5ms,节点上的响应为 0.37~0.50ms,3/3 均成功响应。数据包到达了路由器,且路由器将该地址识别为自己的地址。这意味着其中并没有夹杂运营商双重 NAT(CGNAT)等其他层级。
- 结果是超时,而不是拒绝。 在公网地址的 443 和 80 端口上,既没有收到 RST,也没有收到 ICMP 错误。在第 2 节的三种情况中,「什么都不做(丢弃)」与「只更改目标」对客户端来说,表现出来的都是超时。
- 我们未能分辨出究竟属于哪一种(未实际测量)。 如果是只更改目标的路由器,网关侧应该会收到来自笔记本地址的 SYN。我们查看了网关节点的流量记录(Hubble)和连接跟踪表,但由于即使是成功的内部访问也没有在这两个地方留下 L4 记录(网关作为 L7 代理接收的结构),因此无法用于判断。如果在服务器端
tcpdump,则可以进行分辨,但由于需要管理员权限,这次没有这样做。 - 在节点上测量的「内部地址」并未经过私有网络。 从节点发往网关地址的请求留下了该节点自身的 L7 代理处理的记录。因为 Cilium 直接在节点的宿主机命名空间中处理了前往 LoadBalancer 地址的连接。相反,从笔记本发出的请求则留下了以笔记本地址到达网关地址所在节点的记录。在确认「内部地址可行」时,必须在 集群外部的内网设备上进行测量,才能验证到私有网络区段。
6. TT Lab 发生的事情
如果将其泛化记录,则是这样的。
- 只有在内网无法打开服务域名。 由于外部访客没有遇到问题,起初我们怀疑是内网设备的 DNS 或浏览器缓存。而实际上是路由器没有进行发夹处理。
- 内网测试无法反映外部状态。 当发生外部访问真正中断的故障时,由于在内网中本来就打不开该名称,因此「打不开」并不能算作新信息。反过来,内网中通过内部地址「可以打开」也不能保证外部访问正常。此后,我们将验证分为两条路线:内部通过
--resolve直接访问网关地址,外部通过外部检测服务来查看。 - 针对不同位置采取了不同的解决方法。 在集群内部,通过 CoreDNS 的
hosts插件设置了分离式 DNS(这就是 Pod 中的名称被解析为内部地址的原因)。开发用笔记本则通过 hosts 文件进行固定。路由器的设置保持未动。 - 有一个被遗忘的 hosts 文件。 在某个节点的 hosts 文件中,名称反而被写成了 公网地址,导致从该节点通过域名发起的访问总是失败。hosts 文件充分暴露了「每台设备都需要手动维护,并且容易被遗忘」这一缺点。
如果集群内部通往自身公网地址的道路被阻断,就会出现类似 cert-manager 的 HTTP-01 自检那样「外部可以访问,但在内部验证失败」的情况。这一点在 cert-manager 是如何创建并更换证书的一文中讨论过。
7. 局限性
- 重复次数较少。每个位置测试 3 次,外部四个位置各 1 次。
- 仅测试了一台路由器。未能分辨路由器究竟是「丢弃」还是「只更改目标」(第 5 节),也没有测量是否存在开启发夹功能的设置、开启后的行为,以及开启后服务器看到的源地址(未实际测量)。
- 仅测量了 IPv4。由于服务名称中没有 IPv6 地址,因此未测量 IPv6 的情况(未实际测量)。
- 外部经由路径(代理、隧道、VPN)仅记录在对比表中,未进行测量(未实际测量)。
8. 总结:听到「只有在内部打不开」时
| 检查 | 原因 |
|---|---|
| 先从外部打开(手机流量、外部检测服务) | 外部可以而内部不行,极有可能是发夹问题 |
在内部通过 --resolve 이름:443:내부주소 |
如果此操作可行,说明服务器正常,是路由路径的问题 |
| 检查内部访问公网地址时是超时 | 如果不是拒绝而是超时,说明路由器属于丢弃或只更改目标的情况 |
| 通过名称解决 | 分离式 DNS(多台),hosts(少数几台),若路由器有此功能则将其开启 |
| 被遗忘的 hosts 文件 | 如果只有一台设备的表现不同,首先检查 hosts |
| 检测设备的位置 | 从集群节点测出的「可行」可能并未经过私有网络 |
TT 的话。 如果我寄信给自己小区的地址,结果邮局一看「是同一个小区的啊」然后直接用隔壁大叔的名字把回信塞了过来,我就会把这封陌生人的信件丢掉。邮局必须用邮局的印章把回信盖好寄来我才能收到。要是做不到这一点,在屋里直接喊一声「隔壁房间!」就行了(分离式 DNS)!