在Azure App Service开发中,出站连接耗尽和SNAT端口耗尽是两个最容易混淆也最容易引发线上故障的问题 吾圈机器人
在Azure App Service开发中,出站连接耗尽和SNAT端口耗尽是两个最容易混淆也最容易引发线上故障的问题。很多开发者只停留在概念理解层面,遇到真实故障时依然分不清到底是哪类资源耗尽。本文我们通过四个可落地的.NET实验,亲手把两种故障场景复现出来,同时验证对应的优化方案效果。
先理清两个核心概念
在动手实验前,我们先把两个容易混淆的概念区分开:
出站连接(Outbound Connection):是App Service的Worker实例本地的TCP连接资源,受实例规格限制,比如B1/S1/P1规格上限为1920个。耗尽时通常抛出
SocketException连接失败异常。SNAT端口:是出站负载均衡器在公网侧分配的源端口,每个实例默认预分配128个,只有访问公网端点时才会占用。耗尽时通常表现为连接超时,需要等待四分钟左右端口回收才能恢复。
我们把整个演练拆成四个实验,分别演示「连接耗尽」「连接优化」「SNAT耗尽」「SNAT优化」四种场景。
实验1:连接耗尽——每次请求新建HttpClient
我们先来复现最常见的错误写法导致的连接耗尽:每个请求都新建HttpClient实例,不做复用也不释放。这种写法会导致每个请求都会创建新的HttpHandler和独立的连接池,短时间高并发场景下,Worker实例上的TCP连接会迅速堆积耗尽。
实验的核心代码如下:
// 错误示例:每次请求都新建HttpClient,导致handler和socket持续累积
app.MapGet("/api/demo/connection-bad", async (int count, int concurrency, string? url) =>
{
return await Runner.RunAsync(count, concurrency, async _ =>
{
var client = new HttpClient(); // 每次请求都新建实例
using var resp = await client.GetAsync(url);
resp.EnsureSuccessStatusCode();
});
});
当并发量足够大时,我们很快就能看到预期的异常:
HttpRequestException: A connection attempt failed because the connected party did not properly respond
after a period of time, or established connection failed because connected host has failed to respond.
(blog.example.com:443)
--> SocketException: A connection attempt failed because the connected party did not properly respond
after a period of time, or established connection failed because connected host has failed to respond.
这个异常就是典型的出站TCP连接资源耗尽,此时查看实例的连接数统计,会发现已经达到当前规格的上限。
实验2:连接优化——用IHttpClientFactory复用连接
解决连接耗尽问题,官方推荐的最优方案就是使用IHttpClientFactory管理HttpClient生命周期,通过连接池复用TCP连接,限制同一目标的最大连接数。
优化后的核心配置和调用代码如下:
// 正确示例:在DI中注册一次,复用连接池
builder.Services.AddHttpClient("pooled", c => c.Timeout = TimeSpan.FromSeconds(30))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2), // 定期刷新连接,解决DNS漂移问题
MaxConnectionsPerServer = 20 // 限制单个目标的最大物理连接数,避免堆积
});
app.MapGet("/api/demo/connection-good", async (int count, int concurrency, string? url, IHttpClientFactory factory) =>
{
var client = factory.CreateClient("pooled"); // 从工厂获取复用实例
return await Runner.RunAsync(count, concurrency, async _ =>
{
using var resp = await client.GetAsync(url);
resp.EnsureSuccessStatusCode();
});
});
同样使用和实验1相同的并发参数压测,我们会发现:TCP连接数始终稳定在我们配置的MaxConnectionsPerServer附近,不会持续上涨,也不会再抛出SocketException连接耗尽异常,验证了复用方案的有效性。
实验3:SNAT耗尽——关闭连接池强制新建连接
连接优化解决了Worker本地的TCP连接耗尽问题,但我们依然可以通过特殊配置复现SNAT端口耗尽。核心思路就是强制关闭连接池,每次请求都主动要求关闭连接,这样每次请求都会占用一个新的SNAT端口,短时间就能把默认128个预分配端口耗尽。
我们需要做两个配置调整:一是把PooledConnectionLifetime设为0禁用连接池,二是在请求头中添加Connection: close,要求服务端主动关闭连接,此时每次请求都会新建TCP连接,对应占用一个新的SNAT端口。
核心代码如下:
// SNAT耗尽复现代码:禁用连接池 + 每次请求关闭连接
builder.Services.AddHttpClient("snat-bad", c => c.Timeout = TimeSpan.FromSeconds(30))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.Zero // 禁用连接池复用
});
app.MapGet("/api/demo/snat-bad", async (int count, int concurrency, string? url, IHttpClientFactory factory) =>
{
var client = factory.CreateClient("snat-bad");
return await Runner.RunAsync(count, concurrency, async _ =>
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.ConnectionClose = true; // 强制要求关闭连接
using var resp = await client.SendAsync(request);
resp.EnsureSuccessStatusCode();
});
});
当请求量超过128之后,新增请求就会进入排队等待,表现就是大量请求超时,因为SNAT端口全部被占用,需要等待已关闭连接的端口被负载均衡回收(默认回收等待时间是4分钟)才能继续处理新请求,这就是典型的SNAT端口耗尽故障。
实验4:SNAT优化——复用连接控制并发数
解决SNAT耗尽问题的核心思路和连接优化一致:复用TCP连接,控制并发连接数不超过默认128的预分配上限。我们只需要开启连接池复用,并且把MaxConnectionsPerServer设置为不超过128即可。
优化后的核心配置代码:
// SNAT优化方案:单例复用连接 + 控制最大连接数不超过128
builder.Services.AddHttpClient("snat-good", c => c.Timeout = TimeSpan.FromSeconds(30))
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 100 // 控制最大连接数小于128,预留余量
});
同样用相同并发压测,会发现即使请求量远大于128,SNAT端口占用数始终稳定在100以内,不会超过预分配上限,也不会出现大量连接超时的问题,优化效果非常明显。
实验总结与最佳实践
通过这四个实验,我们可以总结出App Service出站连接的最佳实践:
永远不要每次请求新建
HttpClient,优先使用IHttpClientFactory做实例管理和连接复用,避免本地TCP连接耗尽。对于访问同一公网端点的场景,一定要通过
MaxConnectionsPerServer限制最大连接数,控制在128以内,避免SNAT端口耗尽。配置
PooledConnectionLifetime定期刷新连接,解决长时间运行后的DNS漂移问题。如果业务确实需要大量并发访问外部公网端点,可以通过使用NAT Gateway扩展SNAT端口到64000个,或者对Azure内部服务改用私有端点/服务端点,直接跳过SNAT端口限制。
自己动手复现一遍故障场景,比只看概念理解要深刻得多,遇到线上故障时就能快速区分是连接耗尽还是SNAT耗尽,第一时间定位问题并解决。