Skip to content

第36章 NAT、防火墙和负载均衡

客户端日志上写着 10.0.0.8:53124 → 203.0.113.20:443,到了服务端日志里却变成了 10.20.0.15:41872 → 10.20.1.9:8443。这两份日志可能都没错:在复杂的网络路径中,中间设备既能悄悄改写 IP 地址和端口,也可能在代理节点直接终结当前的 TCP 连接,然后再向后端发起一条全新的连接。

要想搞懂这些中间设备在捣什么鬼,关键是先画出抓包点(抓包的位置),然后为每一段路径分别记录下对应的四元组、连接建立时间以及连接结束的方式。切记:网络中的“四元组”往往只在特定的观察点才成立。

一、NAT 怎样改写四元组

*NAT(Network Address Translation,网络地址转换)设备的核心是一张动态的地址映射表。以最常见的源 NAT(SNAT)*为例,客户端发出的数据包可能会经历这样的变换:

NAT 是什么?

NAT 是中间设备在转发数据包时改写 IP 地址,并常连同端口一起改写和维护映射状态的机制。它广泛用于私有地址共享公网出口、服务发布和负载均衡。

NAT 会让不同抓包点看到不同四元组;纯转发型 NAT 通常不终止 TCP 字节流,完整 TCP 代理则会形成两条连接。

SNAT 是什么?

SNAT(Source NAT,源地址转换)改写出站报文的源 IP,并常配合源端口转换。返回流量命中映射后,设备再把目标端点还原给内部客户端。

多个内部连接共享公网 IP 时,可用端口数量和映射表容量都会成为资源上限。

text
内网侧:10.0.0.8:53124  → 203.0.113.20:443
公网侧:198.51.100.7:62001 → 203.0.113.20:443

在这个过程中,NAT 设备把源 IP 和源端口替换成了公网的映射值,并同步重新计算更新了 IP 和 TCP 的校验和。当服务端的回包命中这条映射记录时,目标 IP 和端口又会被还原成客户端的真实地址。正是因为加上了端口转换,多个内网设备才能共享同一个公网 IP 上网。

而*目标 NAT(DNAT)*最常用于把公网对外的服务地址映射到内网的某台真实服务器上:

DNAT 是什么?

DNAT(Destination NAT,目标地址转换)改写入站报文的目标 IP 或端口,把公开服务端点映射到内部服务器。响应方向需要依据同一状态反向改写,使客户端继续看到原先访问的公开端点。

排障时应分别记录转换前后的目的地址和设备映射规则。

text
外部观察:客户端 → 203.0.113.20:443
内部观察:客户端 → 10.20.1.9:8443

每一条映射记录通常都会绑定协议类型、内外网 IP、端口、流量方向以及一个存活计时器。至于设备底层具体用哪些字段、靠什么算法来匹配,就要看厂商的实现了。不过,NAT 设备的映射表容量是有限的。如果网络里的新建连接速率极高、或者每条映射记录保留的时间过长,又或者是可用端口范围太小,端口数量和并发表项数很容易成为系统的性能瓶颈。

值得注意的是,当普通的 NAT 设备进行数据包转发和字段改写时,连接两端的机器维护的依然是同一组 TCP 字节流进度。也就是说,虽然你在不同抓包点看到的 IP 和端口变了,但两端的序列号(Sequence Number,Seq)和确认号(Acknowledgment Number,Ack)依然处在同一个逻辑空间里。当然,如果设备本身就自带了完整的 TCP 代理功能(比如某些高级防火墙或负载均衡),那它就会在前后端各自建立独立的序列号空间了。

二、有状态防火墙观察的是自己的连接表

*有状态防火墙(Stateful Firewall)*会根据途经的数据包动态建立连接状态,然后再根据策略放行后续的双向流量。重点是:防火墙的状态仅仅来源于它自己的“局部观察”,它与两端操作系统的 TCP 状态机完全是三个相互独立的视角

有状态防火墙是什么?

有状态防火墙会根据握手方向、协议字段和超时等信息维护自己的连接表,并让后续报文按已建立状态匹配策略。它观察到的是设备所在位置的流量,并不共享端主机内核里的 TCP 状态。

设备表项提前过期后,两端仍可能暂时显示 ESTABLISHED,直到新流量触发丢弃、拒绝或主机超时。

举个最经典的例子:客户端和服务端长时间没有数据交换,双方的主机状态可能依然是 ESTABLISHED。防火墙的空闲计时器到期后会删除状态记录。应用层下一次发送数据时,防火墙可能执行静默丢弃(Drop),也可能返回显式拒绝(Reject),或者按策略重新建立记录。在真正的报错浮出水面之前(例如触发 TCP 重传、Keepalive 超时或应用 I/O 报错),单侧主机的状态可能仍显示 ESTABLISHED

Drop 是什么?

Drop 表示设备丢弃报文且通常不向发送端返回直接错误。发送方只能通过迟迟没有 ACK 或响应来感知问题,因此往往经历重传并最终超时。

静默行为减少了对外信息,也会延长故障反馈时间;设备日志和多点抓包是定位它的关键证据。

Reject 是什么?

Reject 表示设备拒绝流量并主动返回错误信号,例如 TCP RST 或 ICMP 不可达。发送方通常能更快结束等待并把明确错误交给应用。

返回何种信号取决于协议和策略,抓包时还需确认拒绝报文的真实来源与路径。

因此,在诊断这类防火墙问题时,请务必留意并记录以下线索:

  • 客户端发出的首个 SYN 包,在路径的每个抓包点上是不是都能看到?
  • 服务端回的 SYN+ACK 包,能不能顺着原路回到客户端?
  • 是不是特定方向、特定端口,或者特定大小的数据包(报文)总是稳定地消失?
  • 结合防火墙设备的日志,看看命中了哪条规则?执行了什么动作(Action)?会话 ID 和表项存活时间(Age)对不对得上?
  • 连接闲置前后的时间差,是不是恰好碰到了设备的会话回收阈值?
  • 如果收到了连接重置包(RST)或 ICMP 报错,它是从哪个 IP 发出来的?又是在哪个抓包点冒出来的?

防火墙采取“静默丢弃”往往会导致发送方不断触发 TCP 重传并最终超时;而“显式拒绝”通常会立刻返回一个 RST 或 ICMP 不可达消息,让发送方死得“痛快点”。这两种策略的取舍,背后其实是企业在网络安全、监控可观测性,以及让客户端快速恢复之间所做的权衡。

三、四层负载均衡有多种数据路径

四层(L4)负载均衡器主要依据 IP、端口和连接状态挑选后端节点,处理 TCP 或 UDP 层面的流量。常见实现方式包括网络地址转换(NAT)、直接路由(Direct Server Return, DSR)、隧道转发(Tunneling)以及 TCP 代理(TCP Proxy)

L4 负载均衡是什么?

L4 负载均衡以网络层和传输层信息分配连接或数据包,例如目标地址、端口和协议。NAT/DSR 模式可以保留端到端 TCP 序列空间,TCP Proxy 模式会终止并新建连接。

判断具体模式比只看“L4”标签更重要,因为不同数据路径决定抓包中的四元组与 Seq 是否连续。

TCP Proxy 是什么?

TCP Proxy 接受客户端 TCP 连接,再独立连接后端并在两条字节流之间转发数据。两侧拥有各自的握手、Seq/Ack、窗口、重传和关闭时序。

代理可以重新分段、缓冲和限速;连接两侧需要靠代理日志、时间和应用字节关联。

在使用 NAT 或隧道模式时,负载均衡器会让客户端原生的数据包继续导向选定的后端服务器。为了保证同一个 TCP 连接的状态一致性,一旦首个数据包选定了后端,后续的流量都会被固定路由到同一台机器上。至于服务端的返回流量,它可能按原路返回穿过负载均衡器,也可能绕过负载均衡器直接发给客户端,具体的流向必须结合你们的实际网络拓扑来确认。

如果四层负载均衡采用的是“TCP 代理”模式,那它就像个勤恳的中间人。它会先自己跟客户端把连接建好,然后再用自己的身份去连接后端:

text
连接 A:客户端 C  ⇄ 负载均衡器 L 的前端
连接 B:负载均衡器 L 的后端 ⇄ 服务端 S

这里的*前端连接(Front-end Connection)后端连接(Back-end Connection)*是两条彼此独立的连接。

前端连接与后端连接是什么?

前端连接连接客户端与代理入口,后端连接连接代理与目标服务。它们可以拥有不同协议版本、TLS 状态、生命周期和复用关系。

一个前端连接可能对应多个后端连接,也可能复用已有后端池连接;应用 Request ID 和代理连接映射比帧号更适合关联两侧。

在这种模式下,这是两条截然不同的 TCP 连接。它们各自拥有独立的三次握手过程、初始序列号(ISN)、接收窗口(Window)、拥塞控制(Congestion Control)状态、重传计时器以及四次挥手的关闭流程。负载均衡器可以在中间对数据流进行重新分段(Segment)、缓冲(Buffer)或者限速,如果一端的连接断了,它还可以根据策略决定怎么去关闭另一端。因此,你抓包时绝不能把连接 A 的帧号和 Seq 直接套用到连接 B 上;想要对齐两端的数据,靠谱的依据只能是应用层发送的字节内容、时间窗口,或者业务请求 ID。

四、七层代理为什么天然形成两条连接

七层(L7)代理会理解 HTTP、数据库协议或自定义业务协议。它在代理端终结客户端 TCP 连接,把字节流解析成应用层消息,再通过后端连接发送出去;连向后端时还常使用后端连接池(Connection Pool)。这会形成灵活的前后端映射关系:

L7 代理是什么?

L7 代理解析应用层协议并按域名、路径、首部、方法或业务字段执行路由、鉴权、缓存和改写。它必须成为应用协议端点,因此客户端与代理、代理与后端是独立连接。

常见例子包括 HTTP 反向代理、API 网关和服务网格 sidecar。

后端连接池是什么?

后端连接池保存代理到服务实例的可复用连接。它能减少建连成本,也让客户端连接与后端连接形成多对多、按请求变化的关系。

池大小、等待时间、空闲回收和失效检测都会影响请求延迟与故障表现。

  • 多个客户端连接可能依次排队复用同一条后端连接;
  • 同一个客户端发来的多个并发请求,可能被打散分发到了不同的后端连接上;
  • 代理可能像个缓存区一样,等客户端把整个完整的请求都发完了,才不紧不慢地向后端写入;
  • 代理甚至可能直接命中缓存,把数据返回给客户端,压根就没有向后端发起任何网络请求。

所以,在七层代理的场景下,前后两端的 TCP 分段(Segment)数量、序列号与确认号(Seq/Ack)、窗口大小、重传行为以及发送 FIN 断开连接的时间,完全是各自独立的。当客户端收到一个代理侧发来的 TCP ACK 确认包时,它的含义仅仅是“代理主机的 TCP 栈把这段字节收下了”,绝不代表后端真实的业务应用已经处理完了请求。后端到底有没有处理成功,只有靠应用层的响应报文、代理日志以及后端日志来交叉证明。

一个典型且常见的排障时间线如下:

text
00.000 C→L  完成握手
00.006 C→L  请求字节到达,L 的 TCP 确认
00.010 L→S  从连接池取得连接并发送请求
00.035 S→L  返回响应
00.038 L→C  写入客户端连接

在这个过程中,L 和 S 之间那 28 ms 的代理内部处理与后端耗时,只能通过排查负载均衡器和后端服务器的证据才能看清。如果只在客户端抓包,你看到的只是一副风平浪静的假象:请求发出去很快就被 L 确认了,然后过了一小会儿,响应数据自然而然地传了回来。

五、源地址传递与信任边界

既然中间隔了代理,后端在独立的网络连接上通常只能看到代理机器的源 IP。如果业务应用强依赖原始客户端的 IP 地址,可以采用经认证网络路径传递的元数据:例如 HTTP 标准的 Forwarded 字段、业界常用的 X-Forwarded-For,或者是在连接刚建立时发送的 PROXY protocol 元数据。无论哪种方案,都必须在架构上明确设定好“可信代理白名单”、字段的覆盖规则以及解析的长度上限。

Forwarded 字段是什么?

Forwarded 是标准化 HTTP 首部,可携带代理观察到的原始客户端地址、协议和主机等参数,并能形成多跳条目链。

它来自应用层元数据,只有受信代理清理外部同名字段并重新写入时才适合用于安全决策。

X-Forwarded-For 是什么?

X-Forwarded-For 是广泛使用的非标准 HTTP 首部,通常以逗号分隔记录客户端与代理地址链。不同产品的追加、覆盖和解析规则可能不同。

应用应从受信代理边界按明确规则解析,直接相信客户端任意提供的值会产生地址伪造风险。

PROXY protocol 是什么?

PROXY protocol 在真实应用数据之前发送一段文本或二进制元数据,把原始源/目标地址传给后端。它工作在 HTTP 之下,因此也适用于 TLS、SMTP、数据库等 TCP 服务。

监听端必须明确启用相同版本并设置长度与超时限制;协议错配会让后端把元数据当成应用内容。

这里需要明确信任边界(Trust Boundary):客户端可以自行填写普通 HTTP 首部,代理必须在受信入口删除或覆盖外部传入的同名字段,再写入经过验证的真实来源信息。PROXY protocol 位于真实应用数据之前,服务端监听端口也必须配置成支持同一种协议;配置错配时,后端会把这段元数据当成应用层请求而报错。

信任边界是什么?

信任边界是系统开始接受某类身份、来源或数据为可信之前必须完成验证和清洗的位置。跨过边界的数据可以进入更高权限决策,因此代理白名单、字段覆盖和认证都应在这里完成。

网络内网本身并不自动构成可信证明,边界应由可验证路径、身份和配置共同定义。

为了排障方便,建议在日志中同时保存:直接对端的物理 IP(即当前 Socket 看到的 IP)、由可信代理透传进来的原始客户端 IP、全局请求 ID(Trace ID)、代理路由节点 ID、前端连接 ID、后端连接 ID 以及精确的时间戳。直接对端的 IP 永远来自当前的 Socket,而那条“来源溯源链”字段则高度依赖你们的受信配置。

六、空闲超时需要沿整条路径对齐

一条 TCP 连接能活多久,同时受制于客户端、NAT、防火墙、负载均衡器、代理的连接池、服务端以及应用层协议的控制。假设这条网络路径上的最短*空闲超时(Idle Timeout)*是 60 秒,而应用每隔 5 分钟才发送一次消息,中间设备的连接表项会在两次消息之间被回收。

Idle Timeout 是什么?

Idle Timeout 是连接在没有符合条件的流量或活动时可保持的最长时间。应用、NAT、防火墙、负载均衡和连接池都可能拥有各自的空闲计时器。

路径中最短阈值往往先触发;心跳周期需要低于可控最短阈值并留出抖动和重传余量。

所以在配置网络参数时,不妨先列出这样一张排查表拉齐配置:

层次示例参数负责发现或处理的事件
客户端应用请求截止时间、心跳周期请求迟迟无结果、业务会话健康
TCP 主机Keepalive、User Timeout空闲连接路径、已发送数据长期无确认
NAT / 防火墙状态表空闲时间中间设备资源回收
L4/L7 设备前端与后端空闲时间两侧连接和连接池回收
服务端应用读取、空闲与总超时慢连接、闲置会话、过载保护

原则上,心跳周期通常要预留出网络抖动与报文重传的时间余量,并且必须低于整条路径中最短的可控空闲阈值。实际的阈值需要查阅设备配置清单或进行受控测试来确认。关于如何彻底搞清楚 TCP Keepalive、应用层心跳和业务健康检查覆盖的层次边界,第37章会继续深入比较。

七、Windows 实验:同时观察代理两侧

我们来做个动手实验。所有操作都绑定在本地的回环地址(Loopback)上,不影响外部网络。首先,把下面这段极简的 TCP 代理代码保存为临时脚本 proxy36.py

python
import socket
import threading

FRONT = ("127.0.0.1", 3636)
BACK = ("127.0.0.1", 3637)

def relay(source, target, label):
    total = 0
    try:
        while True:
            data = source.recv(65536)
            if not data:
                break
            target.sendall(data)
            total += len(data)
    finally:
        print(f"{label}: {total} bytes")
        try:
            target.shutdown(socket.SHUT_WR)
        except OSError:
            pass

with socket.socket() as listener:
    listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    listener.bind(FRONT)
    listener.listen(1)
    print("front listening", listener.getsockname())
    client, client_addr = listener.accept()
    with client, socket.create_connection(BACK, timeout=5) as backend:
        print("front", client.getsockname(), client.getpeername())
        print("back ", backend.getsockname(), backend.getpeername())
        t1 = threading.Thread(target=relay, args=(client, backend, "C->P->S"))
        t2 = threading.Thread(target=relay, args=(backend, client, "S->P->C"))
        t1.start(); t2.start(); t1.join(); t2.join()

接着,打开 Wireshark,选择捕获 Npcap Loopback Adapter 网卡,填入如下显示过滤器:

text
tcp.port == 3636 || tcp.port == 3637

随后,在三个独立的 PowerShell 终端里依次运行下面这几个命令:

powershell
python -m http.server 3637 --bind 127.0.0.1
python .\proxy36.py
curl.exe --http1.1 http://127.0.0.1:3636/

趁着连接还在建立中,赶快在第四个终端里执行这行命令,记录下此时的网络状态:

powershell
Get-NetTCPConnection |
  Where-Object { $_.LocalPort -in 3636,3637 -or $_.RemotePort -in 3636,3637 } |
  Format-Table LocalAddress,LocalPort,RemoteAddress,RemotePort,State,OwningProcess

如果一切顺利,你应该能在 Wireshark 里清晰地看到这些预期证据:两组截然不同的三次握手过程、两个独立的四元组,以及它们各自拥有的、互不干扰的相对 Seq/Ack 序列。同时,相同的一串 HTTP 请求字节,一前一后分别流经了 3636 和 3637 端口。那个简单的 Python 代理控制台也会打印出两侧的 Socket 地址和转发的字节总数。排障时,你可以利用 Wireshark 的 tcp.stream 功能,分别去追踪这两条被代理切分开的独立连接。

八、Linux 扩展与排障报告

如果你在 Linux 服务器上做同样的实验,可以用下面这几个命令来配合观察:

bash
ss -tnp '( sport = :3636 or dport = :3636 or sport = :3637 or dport = :3637 )'
sudo tcpdump -ni lo '(tcp port 3636 or tcp port 3637)'
sudo conntrack -L -p tcp 2>/dev/null

注意,跑 conntrack 需要相应的 Netfilter 内核模块、对应工具以及系统权限。通过它打印出来的“原始方向”(Original Direction)和“回复方向”(Reply Direction),你能极其直观地理解 NAT 和防火墙底层的状态表是怎么工作的。强烈建议你仅在自有实验虚拟机中观察这些状态。

最后总结一下,如果你在生产环境中排查中间设备问题,一份能复查的靠谱排障报告至少得包含这些核心要素:网络拓扑图、每个抓包点的位置说明、每一段切割后独立的四元组、设备当下的工作模式、前后端分别关联的连接 ID、关键节点上的空闲存活时间、出问题的关键报文帧号(Frame Number)、贯穿全链路的应用层请求 ID(Trace ID)、核心设备的系统日志,以及你目前仍待验证的网络路径假设。

理解检查

  1. 在经过源 NAT 转换前后,四元组中的哪些字段一定会发生变化?
  2. 为什么即便通信的两端用 netstat 看都还是稳稳的 ESTABLISHED 状态,中间的防火墙却可能早把这条会话的表项给“干掉”了?
  3. 如果用 Wireshark 抓包观察,基于 NAT 转发的 L4 负载均衡和基于 TCP 代理的 L4 负载均衡,在 Seq/Ack 序列号的表现上有什么根本性的差异?
  4. 既然七层代理把前后端切成了两个完全独立的 TCP 连接,你在排障时应该靠哪些证据把这两个截然不同的 tcp.stream 关联到一起?
  5. 后端业务应用应该采取什么安全策略,才能放心地使用代理服务器透传过来的客户端真实 IP 地址?
  6. 在设计复杂的网络应用架构时,应用层心跳的发送周期应该如何跟网络链路上“最短空闲回收时间”打好配合?

延伸阅读


上一章:第35章 IPv4、IPv6、MTU 和分片 · 所属篇:第七篇 · 下一章:第37章 TCP Keepalive 与应用层心跳 · 教程总览

Licensed under CC BY-NC-SA 4.0.