先说一个容易被忽视的事实:你的 App Service 应用在访问外部服务时——无论是 Azure SQL、Redis、Storage,还是某个第三方 API——并不是直接从 worker 实例“裸奔”到公网的。很多人会下意识这么认为,但背后的网络路径其实绕了个弯。
App Service 的 worker 实例运行在所谓的 scale unit(或者叫 stamp)内部,这些实例通常没有直接分配的公网 IP。当它们需要访问外部公网 endpoint 时,请求会先经过 stamp 的出站负载均衡器。这个负载均衡器会把 worker 的私网源地址和端口,转换成公网源地址和端口——这个过程,就是 SNAT(Source Network Address Translation)。
这篇文章打算系统性地梳理一下:在 App Service 环境中排查 SNAT 问题时,你需要知道哪些关键点。
- SNAT 到底是怎么工作的?
- SNAT 端口为什么会耗尽?
- 端口是如何分配的?
- 耗尽时有哪些典型症状?
- 以及,应用应该如何优化连接的使用方式?
1: SNAT 是怎么工作的
以一次典型的 TCP 连接为例,出站访问的大致流程是这样的:

负载均衡器会为这次连接维护一条映射记录,举个例子:
| 字段 | 示例 |
|---|---|
| 协议 | TCP |
| Worker 实例地址 | 10.0.5.60:51014 |
| 负载均衡器公网地址 | 13.76.245.72:12481 |
| 外部服务地址 | 52.189.232.180:80 |
这里面有几个关键点需要注意:
- 应用自身感知到的,是自己直接连到了外部服务;
- 而外部服务看到的,是负载均衡器的公网地址在跟它通信;
- 负载均衡器负责在两边之间做地址转换,整个过程对两端都是透明的。
2: SNAT 端口耗尽
SNAT 端口的消耗,本质上和 TCP 五元组密切相关。五元组由这些要素构成:
| 字段 | 含义 |
|---|---|
| Protocol | 协议,例如 TCP |
| Source IP | 源 IP,SNAT 后是负载均衡器公网 IP |
| Source Port | 源端口,也就是 SNAT 端口 |
| Destination IP | 外部目标 IP |
| Destination Port | 外部目标端口 |
那什么情况下最容易把端口消耗掉呢?当多个 TCP 流访问的是同一个目标 IP、同一个目标端口、同一个协议时,它们必须使用不同的源端口来区分。换句话说,高并发地访问同一个外部服务,SNAT 端口会很快被吃光。反过来,如果这些请求分散到不同的目标 IP 或不同端口,五元组本身已经有了区分度,SNAT 端口就有机会被复用。
每个 IP 地址能打开的端口数量是有上限的。如果应用频繁地打开和关闭连接,情况会更严峻——因为 SNAT 端口关闭后并不会立即释放:
| 关闭方式 | SNAT 端口释放时间 |
|---|---|
| 正常 FIN/ACK 关闭 | 约 240 秒后释放 |
| RST 重置 | 约 15 秒后释放 |
| 达到 idle timeout | 按 idle timeout 释放 |
算一笔账:如果一个 Web 应用每秒打开 1 条 HTTP 连接,调用后端服务后正常关闭,那么在 240 秒内,可能累计占用大约 240 个 SNAT 端口。
另一个典型场景是数据库连接池。假设一个繁忙站点的 SQL 连接池大小是 300,并且数据库查询执行得比较慢,那么这些连接可能会持续占用约 300 个 SNAT 端口。
还有一种很常见的情况是队列触发的 Function App。如果压测一开始就把大量消息一次性灌入队列,Function 可能会瞬间启动大量到 Storage 或其他外部服务的连接,很快就把 SNAT 端口耗尽了。
3: SNAT 端口分配算法
为了防止某个站点把整个 stamp 的 SNAT 端口都占光,从而影响到其他站点,Azure Load Balancer 需要对 SNAT 端口做分配控制。目前常见的有两种算法:
| 分配方式 | 端口数量 |
|---|---|
| On-demand 算法 | 每实例基础 160 个,可按需尽力分配更多 |
| 新算法 | 每实例固定预分配 128 个 |
这里的 160 个,可以用一个粗略的容量分摊思路来理解。一个典型的 App Service stamp 可能有 5 个出站 IP,每个 IP 理论上有大约 65536 个端口。如果这些端口要被 stamp 内大约 2000 个实例共享,算一笔简单的账:
5 × 65536 ÷ 2000 ≈ 163.84,取整后就是接近 160 个端口/实例。
4: SNAT 端口耗尽时的症状
当 SNAT 端口真的被耗尽时,应用通常会有这些表现:
- 连接外部 endpoint 变得很慢;
- 请求长时间 pending,迟迟得不到响应;
- 最终出现 socket timeout;
- 如果启用了 Application Insights 的 dependency tracking,会看到外部依赖调用失败。
5: 如何解决 App Service 的 SNAT 端口耗尽
解决问题的总体方向很明确:先想方设法减少不必要的连接占用,再考虑扩展资源。

具体来说,可以从以下几个方面入手:
- 复用连接:不要每次都去 new 一个
HttpClient,这几乎是所有 SNAT 问题的头号元凶; - 使用连接池:无论是数据库连接还是 HTTP 客户端,都应当合理复用;
- 控制连接池大小:连接池不是越大越好,过大的池子会持续占用端口资源;
- 降低重试强度:失败时疯狂重试,只会让端口占用问题雪上加霜;
- 让后端尽快响应:后端的响应时间越慢,连接存活得越久,SNAT 端口也就被占得越久;
- 横向扩容 App Service Plan:SNAT 端口是按实例分配的,实例多了,总可用端口自然也会增加;
- 考虑使用 App Service Environment:ASE 的实例池更小,每个 worker 实例通常能分配到更多的 SNAT 端口;
- 压测要贴近真实流量:负载测试应该以稳定速度投喂数据,而不是一开始就把所有消息一股脑儿灌入队列。
示例代码及优化
下面这段代码,可以帮你复现 SNAT 端口耗尽的问题:
public string Index(string url)
{
var request = HttpWebRequest.Create(url);
request.GetResponse();
return "OK";
}
要解决连接复用问题,一个简单的改进是关闭响应对象:
public string Fin(string url)
{
var request = HttpWebRequest.Create(url);
var response = request.GetResponse();
response.Close();
return "OK";
}
下面这种写法也很容易造成 SNAT 端口泄漏,因为每次调用都会创建新的 HttpClient:
public async Task Client(string url)
{
using (var client = new HttpClient())
{
await client.GetAsync(url);
}
return "OK";
}
改进后的做法是复用同一个 HttpClient:
private static Lazy _client = new Lazy();
public async Task ReuseClient(string url)
{
var client = _client.Value;
await client.GetAsync(url);
return "OK";
}
常见问题(FAQ):
Q:我能自己看到 App Service 的 SNAT 端口分配指标吗?
A:一般情况下,这个指标不直接公开。日常设计时,不要依赖“实际能拿到多少端口”,而应该按每实例 128 个的保守值来控制。
Q:为什么不能直接根据 SNAT 端口指标做自动扩缩容?
A:因为很多 SNAT 问题的根源是连接没有被复用。如果代码持续浪费连接,单纯扩容只是把问题摊开了,并不一定治本。正确的顺序是:先优化连接复用和后端响应,再考虑扩容。
Q:SNAT 耗尽和 TCP Connections 耗尽有什么区别?
A:TCP Connections 是 worker 实例层面的连接计数,而 SNAT 是出站负载均衡器上的公网源端口资源。前者统计的是所有 TCP 连接,后者只和外部网络流量相关。两者有关联,但不能互相替代。
Q:多个 WebJob 共用同一个 App Service Plan,怎么判断谁占用了最多连接?
A:如果没有按进程维度的连接指标,一个可行的办法是把部分 WebJob 移到另一个 App Service Plan,通过隔离法观察问题是否缓解,逐步定位到高连接消耗的任务。
参考资料
SNAT with App Service : https://4lowtherabbit.github.io/blogs/2019/10/SNAT/
