两个流量方向,一台设备
欢迎
大多数架构图只展示单向流量:客户端在顶部,服务器在底部,箭头向下。但现实中的流量是双向的。
入站(Ingress):外部客户端通过此路径访问你的服务。位于网络边缘的反向代理负责终止 TLS 连接、路由请求并执行访问策略。
出站(Egress):你的服务通过此路径访问外部服务。例如调用支付处理商的 API、获取 Webhook 目标或向合作伙伴发送请求。这通常通过带有允许列表的正向代理或 NAT 网关实现。
许多架构最初都由一个节点同时处理入站和出站流量。这种方案起初运行良好,直到它失效的那一天。这种故障模式往往很隐蔽,只有当内部服务数量积累到一定程度时才会显现,并由此揭示了一个关于关注点分离的重要教训。
完成本课后,你将理解:
- 为什么入站(ingress)和出站(egress)代表根本不同的流量模式,具有不同的扩展维度及不同的故障模式
- 发夹 NAT(Hairpin NAT)以及为什么试图连接自身的代理会失败
- 架构的分叉:从一个节点变为两个节点,以及此后每个节点各自独占的职责
- 安全隔离带来的收益:每一侧都可以针对其实际允许的通信对端进行严格的访问控制
- 如何识别你的单节点设计何时已跨越阈值,使得拆分成为必要
为什么不同方向需要不同的工具
同一网络边界上的两种不同工作负载
入站流量特征:
- 由外部方(广大互联网用户)发起
- 流量规模随用户基数增长而扩展
- TLS 终结、请求路由、按来源进行速率限制
- 纵深防御关注点:DDoS 攻击、滥用行为、数据抓取
- 公网 IP 需要接受来自任何人的连接
出站流量特征:
- 由您自己的服务发起(一组已知的、数量有限的客户端)
- 流量规模随您的服务间调用及外部 API 调用模式而扩展
- 在远程端点实施源 IP 白名单(您拥有一个固定的出站 IP,合作伙伴对此予以信任)
- 纵深防御考量:数据外泄、被入侵的内部服务发起出站调用
- 应拒绝来自您自身服务以外任何人的连接
关键不对称性:入站(ingress)接受来自全球各地的流量;出站(egress)仅接受来自您自身服务的流量。将它们部署在同一台机器上,意味着该机器必须同时满足:对全球可达(用于入站)且仅对您自身服务可达(用于出站)。满足其中一项的防火墙规则会与另一项相冲突。
演进路径:一个小型项目可以将两者隐藏在同一个 IP 和同一套工具之后,因为流量规模小且合作伙伴 IP 白名单很短。随着项目增长,这两种角色之间的摩擦会加剧,直到某一天,某种特定的故障模式(发夹 NAT)迫使两者分离。
迫使架构拆分的那个 Bug
一个经过脱敏的故障案例
想象一下在生产环境中真实发生的架构分叉。以下名称已做更改,但其形态与团队在实战中遇到的情况完全一致。
某组织运行一台位于 203.0.113.5 的单一代理服务器。它处理入站流量(用户使用的 443 端口)和出站流量(内部服务调用外部服务时使用的 1080 端口 SOCKS5)。内部服务位于私有子网中,并将所有出站流量路由至 203.0.113.5:1080 上的该 SOCKS5 代理。
托管在同一 203.0.113.5 上的其中一个服务是 api.example.com。公共 DNS 将 api.example.com 解析为 203.0.113.5。
现在,另一个内部服务需要调用 api.example.com。其出站路径如下:
1. 内部服务将 api.example.com 解析为 203.0.113.5
2. 内部服务通过位于 203.0.113.5:1080 的 SOCKS5 出站代理发送请求
3. 代理尝试从自身向 203.0.113.5:443 发起连接
4. 连接被拒绝。 数据包必须离开 NAT 并重新进入同一个 NAT,而大多数网络协议栈会拒绝这种情况。代理无法通过其自身的公网 IP 连接到自己。
这就是发夹 NAT(Hairpin NAT):指数据包离开 NAT 后,需要重新进入同一个 NAT 才能到达其目的地的情况。如果路由层没有特殊的发夹支持,数据包将会丢失。
为何问题暴露较晚
在项目早期,所有内部服务要么通过私有主机名(internal-api.local)与其他内部服务通信,要么根本不会回调其所在组织的公共服务。发夹路径根本不存在。
随后,一个新功能要求服务 A 调用 api.example.com(一个公网主机名)。发夹路径被激活。连接被拒绝。服务中断。
修复方案只是缓解了症状(强制解析器返回 api.example.com 的私有 IP 而非公网 IP)。根本原因:单台机器承担了过多的职责。
架构的分叉
一台机器变两台
干净的解决方案:将代理拆分为两台机器。
入口服务器(公网 IP 203.0.113.5):
- 在 80 和 443 端口运行 Caddy / 反向代理
- 公网 DNS 记录指向此处
- 托管 api.example.com、app.example.com 等域名
出口服务器(不同的公网 IP 203.0.113.99):
- 在 1080 端口运行 SOCKS5 / 正向代理
- 防火墙将传入连接限制为仅允许来自内部子网 IP 地址的连接
- 内部服务通过此地址路由所有出站流量
这带来了什么:
1. 解决了发夹回路(Hairpin)问题。 当内部服务调用 api.example.com 时,出站流量经由 203.0.113.99(出口)路由,然后正常连接到 203.0.113.5(入口,这是一个不同的 IP 地址)。由于这两个 IP 地址位于不同的机器上,NAT 环路消失了。
2. 安全隔离。 出口服务器的防火墙可以锁定为仅允许一小部分内部 IP 地址。入口服务器的防火墙则保持向全球开放。两套独立的规则集,各自清晰地表达一种角色。
3. 独立扩展。 入口带宽随用户数量扩展;出口带宽随内部服务活动扩展。可以升级其中一方而不影响另一方。
4. 故障隔离。 出口配置错误不再会破坏公共网站。针对公共网站的 DDoS 攻击不再会耗尽出口带宽。
5. 更清晰的心智模型。 每台机器只负责一项任务。工程师可以专注于入口问题而无需考虑出口问题,反之亦然。
两个维度,两个容量规划决策
独立扩展
在拆分之前,任一方向的增长都会给同一台机器带来压力。拆分之后,每个方向都拥有独立的资源调配。
入站规模:随用户数量扩展。容量决策位于面向公众的层级(更多反向代理副本、更大的虚拟机、前置 CDN)。带宽预算根据峰值用户流量计算。
出站规模:随内部服务对外部 API 的调用量扩展。通常由 Webhook 投递、支付处理程序调用或第三方数据获取主导。带宽预算根据内部调用模式计算。
故障隔离:针对公共入站的 DDoS 攻击不再消耗出站带宽(那些支付处理程序调用仍会进行)。出站代理崩溃不再导致公共站点宕机(用户仍可访问站点;仅内部出站调用失败)。
不同的 SLO:入站可用性对用户至关重要(可见的站点中断);出站可用性对运维人员至关重要(可能需要更长时间才能检测到的后台故障)。每一侧都可以拥有自己的 SLO。
多个出站服务器
一旦出站角色成为独立的机器,下一个显而易见的举措就是在负载均衡器后面运行多台出站机器以实现高可用性。每个新的内部服务都指向出站主机名(解析到负载均衡池),而不是单个 IP。
与分布式系统的其他部分遵循同样的规律:一旦某一层实现无状态化并拥有独立角色,它就能以极低的成本进行扩展。
新的合作伙伴集成
你的组织按照设计运行入站/出站分离架构。出站服务器拥有一个固定的公网 IP(203.0.113.99),你已将其加入三个现有合作伙伴 API(支付处理器、短信网关、邮件服务商)的白名单。
产品团队希望添加第四个集成:一个 Webhook 投递系统,用于回调全球各地的客户端点。流量预测:每分钟 10,000 次调用,峰值可达 30,000 次。
为不断发展的服务设计网络边界
综合
你已经了解到,为什么入站(ingress)和出站(egress)需要不同的工具,导致实际集群中必须拆分的发夹 NAT(hairpin NAT)故障,以及一旦实施拆分后,独立扩展、安全隔离和故障隔离所带来的收益。
应用以上所有四点。
一家中型 SaaS 公司为他们的用户运行三个产品子域名(app、api、admin),外加四个出站集成(Stripe、Twilio、SendGrid、一个客户 Webhook 系统)。目前,所有流量都通过位于一个公共 IP 后的单一代理机器处理。他们开始收到报告,称当内部服务尝试调用 api.example.com 时,会出现间歇性的发夹(hairpin)故障。他们希望设计一个永久性的解决方案。
本课程接下来的方向
本课程接下来的方向
你现在已经见证了分布式系统中一次最清晰的关注点分离重构:一台机器变成两台,各自拥有明确的职责,并且系统在此过程中顺带获得了扩展性、安全性以及故障隔离的好处。
下一课(cs_distsys_failure_modes_and_blast_radius)将扩展故障隔离的推理。你将阅读一份经过脱敏处理的 DNS-SERVFAIL 事后复盘报告,识别级联故障模式,并撰写以系统而非个人为目标的无责行动项。
配套课程:geometry_of_ingress_egress_separation 将该拆分重构为二分图,并探讨割点、网络分区以及图论对网络边界的启示。
做得很好。继续前进。