TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

发夹 NAT 实测 1:仅在局域网内打不开我们的域名

状态:实测(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 问题。

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)。 笔记本电脑向该地址发送请求,请求到达默认网关即路由器。此时路由器可以采取三种处理方式:

  1. 什么都不做。 对来自局域网侧的数据包不应用端口转发。路由器虽然将其视为自己的地址并接收,但由于没有在 443 端口上监听,因此会将其丢弃或拒绝。
  2. 仅修改目的地址(DNAT)。 这是下图所示的情况。
  3. 同时修改目的地址和源地址(DNAT + SNAT)。 这就是发夹 NAT。

仅修改目的地址时

헤어핀 NAT 가 없을 때: 답이 다른 주소에서 온다 같은 사설망 안에서 서비스의 공인 주소로 접속하는 경우 클라이언트 192.0.2.20 공유기 안 192.0.2.1 밖 203.0.113.7 서버 192.0.2.10 192.0.2.20 → 203.0.113.7 ① 공인 주소로 보낸다 192.0.2.20 → 192.0.2.10 ② 목적지만 바꾼다(DNAT) 192.0.2.10 → 192.0.2.20 ③ 같은 망이라 공유기 없이 바로 답한다 클라이언트는 203.0.113.7 의 답을 기다린다 192.0.2.10 에서 온 답은 모르는 연결이라 버린다 연결이 맺어지지 않고 시간 초과로 끝난다
图:没有发夹 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

헤어핀 NAT: 출발지도 바꿔 같은 길로 돌아오게 공유기가 목적지(DNAT)와 출발지(SNAT)를 함께 바꾼다 클라이언트 192.0.2.20 공유기 안 192.0.2.1 밖 203.0.113.7 서버 192.0.2.10 192.0.2.20 → 203.0.113.7 ① 공인 주소로 보낸다 192.0.2.1 → 192.0.2.10 ② 도착과 출발을 모두 바꾼다 192.0.2.10 → 192.0.2.1 ③ 답이 공유기로 돌아온다 203.0.113.7 → 192.0.2.20 ④ 되돌려 바꿔 전달한다 클라이언트는 기다리던 주소에서 답을 받는다 대가: 서버에는 사내 접속이 모두 공유기 주소로 보인다 규격(RFC 4787·5382)은 출발지를 공인 주소로 보이게 하라고 한다
图:发夹 NAT:路由器同时修改目的地址和源地址,使服务器的响应返回给路由器,路由器还原地址后转发给客户端

如果路由器在修改目的地址的同时 将源地址也修改为自己的地址 (②),服务器就会认为请求来自路由器,并将响应发送给路由器(③)。路由器根据连接记录还原两个地址,将源地址变为 203.0.113.7 传递给笔记本电脑(④)。笔记本电脑从期待的地址收到了响应,从而成功建立连接。由于数据包进入路由器后像发夹弯一样折返并朝同一侧流出,因此被称为发夹(Hairpin)NAT。环回 NAT(Loopback NAT)和 NAT 反射(NAT Reflection)也是指代该功能的名称。

这也是有代价的。从服务器的角度来看, 局域网内的所有访问看起来都像是来自同一个路由器 。无法通过访问日志区分内网用户,基于源地址的访问限制或请求频率限制会将所有内网用户视为同一个人。由于内网流量需要经过路由器两次,因此也会消耗路由器的吞吐量。

规范要求什么

定义 NAT 行为的 IETF 文档并没有将回环(Hairpinning)设为可选项。

为了便于理解,图中将源地址画成了路由器的 私网侧地址(192.0.2.1),但规范要求的形态是,源地址显示为 公网地址和外部使用的端口。无论哪种,响应数据包能回到路由器这一核心点是一样的。在用 Linux 自制路由器时经常使用的“源自私网、目的地为服务器则使用 MASQUERADE”规则,就是将其转换为私网侧地址的方式。在实际的家用路由器中,有些产品与规范不同,不支持回环或者允许将其关闭(这次测试的路由器也符合第 2 节中的 1 或 2。具体属于哪一种将在第 5 节中介绍)。

3. 四种解决方案

如果能够修改路由器,那是最好的,但往往无法做到这一点。解决方法大致有四种,实际中通常会组合使用两种或以上。

方法 操作 什么时候使用 缺点
分离 DNS 私有网络内的 DNS 针对相同的名称 内部地址进行响应 当公司内部设备与集群较多且直接运营 DNS 服务器时 由于内外部响应不同,在确认“DNS 是否正确”时容易混淆。当内部地址变更时,需要修改两处地方。对于像 DoH 这样绕过公司内网 DNS 的设备则不生效
hosts 文件 在单个设备上将域名固定为内部地址 适用于少数几台设备、情况紧急时 需要逐台手动修改且容易遗忘。带着笔记本电脑外出时,在外部将无法访问该域名
路由器设置 开启路由器的发夹式 NAT(NAT 回环)功能 当路由器支持该功能且可以修改设置时 不同产品的名称和设置位置各不相同,甚至可能根本没有该功能。服务器端看到的内部访问来源会显示为路由器地址
经由外部 内部设备经由外部的代理、隧道和 VPN,再进入公网地址 当需要完整测试外部访客所看到的访问路径时 速度慢、成本高,且外部设备可能会多故障一个

补充几点。

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 依然如此。外部某一个位置拒绝连接的情况,由于其他三个位置正常,因此推测可能是该位置自身的原因,但未能确认原因(推测)。

我们还测量了以下几点。

6. TT Lab 发生的事情

如果将其泛化记录,则是这样的。

  1. 只有在内网无法打开服务域名。 由于外部访客没有遇到问题,起初我们怀疑是内网设备的 DNS 或浏览器缓存。而实际上是路由器没有进行发夹处理。
  2. 内网测试无法反映外部状态。 当发生外部访问真正中断的故障时,由于在内网中本来就打不开该名称,因此「打不开」并不能算作新信息。反过来,内网中通过内部地址「可以打开」也不能保证外部访问正常。此后,我们将验证分为两条路线:内部通过 --resolve 直接访问网关地址,外部通过外部检测服务来查看。
  3. 针对不同位置采取了不同的解决方法。 在集群内部,通过 CoreDNS 的 hosts 插件设置了分离式 DNS(这就是 Pod 中的名称被解析为内部地址的原因)。开发用笔记本则通过 hosts 文件进行固定。路由器的设置保持未动。
  4. 有一个被遗忘的 hosts 文件。 在某个节点的 hosts 文件中,名称反而被写成了 公网地址,导致从该节点通过域名发起的访问总是失败。hosts 文件充分暴露了「每台设备都需要手动维护,并且容易被遗忘」这一缺点。

如果集群内部通往自身公网地址的道路被阻断,就会出现类似 cert-manager 的 HTTP-01 自检那样「外部可以访问,但在内部验证失败」的情况。这一点在 cert-manager 是如何创建并更换证书的一文中讨论过。

7. 局限性

8. 总结:听到「只有在内部打不开」时

检查 原因
先从外部打开(手机流量、外部检测服务) 外部可以而内部不行,极有可能是发夹问题
在内部通过 --resolve 이름:443:내부주소 如果此操作可行,说明服务器正常,是路由路径的问题
检查内部访问公网地址时是超时 如果不是拒绝而是超时,说明路由器属于丢弃或只更改目标的情况
通过名称解决 分离式 DNS(多台),hosts(少数几台),若路由器有此功能则将其开启
被遗忘的 hosts 文件 如果只有一台设备的表现不同,首先检查 hosts
检测设备的位置 从集群节点测出的「可行」可能并未经过私有网络

TT 的话。 如果我寄信给自己小区的地址,结果邮局一看「是同一个小区的啊」然后直接用隔壁大叔的名字把回信塞了过来,我就会把这封陌生人的信件丢掉。邮局必须用邮局的印章把回信盖好寄来我才能收到。要是做不到这一点,在屋里直接喊一声「隔壁房间!」就行了(分离式 DNS)!

登录后即可点赞

评论

还没有评论。

登录后即可发表评论