﻿# 認識 Azure App Service 對外網路連線的 SNAT 限制

最近我們有個在 Azure App Service on Linux 的 Function App 站台出現了一些連線問題，主要問題並不是斷線，而是不時會出現 `Timeout` 問題，但這些問題我們在本機開發時都不會遇到，是部署到 Azure 之後才遇到的問題。我花了好幾天才真正釐清問題的潛在原因，這篇文章我就來說明這個特殊的狀況。

### 簡介 Azure App Service 架構

要理解這個問題之前，必須先瞭解 Azure App Service 的整體架構，由於 Azure App Service 是一個非常優秀的 PaaS 平台，我們也已經用了將近 10 年，但大部分時候都不太需要真的去瞭解他的運作架構，直到你的膝蓋中了一箭！🔥

> 詳細的架構細節建議參考 [Inside the Azure App Service Architecture](https://learn.microsoft.com/en-us/archive/msdn-magazine/2017/february/azure-inside-the-azure-app-service-architecture?WT.mc_id=DT-MVP-4015686) 這份文件。

由於 Azure App Service 是一套 fully managed (完全託管) 的 PaaS (Platform as a Service) 架構，其背後的基礎建設部分通常不需要開發者擔心，因為那是平台該負的責任。不過，當你的應用程式開始用到越來越大量的資源，還是會面臨資源不足的情況，這時就需要去瞭解「限制」在哪裡，要如何「避開」這些限制，或是在既有的限制底下利用更多的資源，這些就是相對專業的領域了！

這些「限制」對口袋夠深的客戶來說，都是沒問題的，一台 Instance 不夠那就兩台，兩台不夠就三台，總能突破限制的，因為 Azure App Service 擁有絕佳的橫向延展(Scale-out)能力。

在全球的 Azure App Service 部署架構下，採用了 [Deployment Stamps pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/deployment-stamp?WT.mc_id=DT-MVP-4015686) (部署戳記模式) 來進行部署，他把多台伺服器封裝成一個所謂的 **scale unit** (延展單元)，或稱為 **Stamp** (戳記)，全球的 Azure App Service 都採行這樣的架構進行部署。以下是 Global Distribution of App Service Scale Units 的示意圖：

![Global Distribution of App Service Scale Units](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/6c89a1fabaf1475aa101-206457514-cbe7f2a5-5edb-4e9b-90f5-dfb513b83902.png)

> 這裡的 **Scale unit** 就是一個 **Scale out** 的基本單位，每個 unit 大概會有上千台 VM 之多！詳見 [What Is an App Service Scale Unit?](https://learn.microsoft.com/en-us/archive/msdn-magazine/2017/february/azure-inside-the-azure-app-service-architecture?WT.mc_id=DT-MVP-4015686#what-is-an-app-service-scale-unit)

其實 **Scale unit** (`Stamp`) 對 Azure 用戶是不可見的，這不是 Azure 用戶該知道的內部資訊，就算知道也改不動，因為整個 App Service 架構都是由 Azure 資料中心統一並全自動化管理的。不過，我還是發現了可以得知目前 App Service Plan 所在的 Stamp 的方式，請見以下圖示：

1.  進入 App Service Plan 的 Overview 頁面
    
2.  點擊右上角的 `JSON View` 連結
    
3.  接著你可以從 `mdmId` 屬性，得知你目前機器身處的 **Stamp Id**
    
    ![image](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/e9b9deb10d1bbc470253-206415896-c7168a72-a81f-429c-92c6-4b664e197441.png)
    

由於一個 **Stamp** 會擁有數千台 VM 資源，所以整個 **Stamp** 內部所有的 VM 都不會有 Public IP 地址，因此所有 VM **對外的連線**一定會經過 SNAT (Source NAT) 來源地址轉換，即便你從 Web App 連線到另一個 Web App 的話，也一樣會透過 SNAT 進行轉換。

因此，這就會有「資源共用」的問題了，因為 SNAT 對外有 `65536` Ports 限制，在網路繁忙的時刻，整個 Scale Unit 有可能耗盡 SNAT Ports，所以需要對每個 Instance 進行資源限制，而 App Service 的預設限制為 `128` 個 SNAT Ports！

另外，所有 App Service 服務也有 TCP 連線數限制，這會根據不同的 **Worker Size** (規格等級) 分別有不同的限制。

-   Small (A1): 上限 `1,920` 條 TCP Connections
-   Medium (A2): 上限 `3,968` 條 TCP Connections
-   Large (A3): 上限 `8,064` 條 TCP Connections
-   Isolated tier (ASE): 上限 `16,000` 條 TCP Connections

> 關於 NAT 的網路知識，請參考 [NAT — SNAT, DNAT, PAT & Port Forwarding](https://medium.com/networks-security/nat-snat-dnat-pat-port-forwarding-b7982fab02cd) 文章說明。

### 問題描述

這裡我就說明一下我發現問題的過程，好讓大家能夠更好的理解為什麼會有這些問題。

我們用 Azure Function V4 設計了一個網站，並且部署到 Azure App Service 之中，由於還在開發測試階段，我們的服務規格都開的很小，只有 `P1v2` 等級，且只有 1 個 Instance 而已。該網站會連接 Azure Cache for Redis 與 Azure SQL Database，還有部分連線會連到 Azure Storage Blob 取得檔案等等。

網站在本機開發測試都非常順利，上架到 Azure App Service on Linux 之後，卻發現服務時不時會有回應很慢的問題。由於我們採用前後端分離的架構，前端是 Angular 框架，後端採用 ASP.NET Core 6 Web API，而網站在瀏覽的時候，測試人員就很明顯的感受到網站時快時慢的問題，但情況似乎沒有很嚴重，因為緩慢回應的狀況過一下子就好了，所以測試人員也不以為意。

當我得知這個問題時，系統已經在測試站跑了一個月左右，這時由於參與測試的人越來越多，這才開始有人提出說，怎麼都修了一個月，網站還這麼慢啊？PM 問了工程師，他們也說不知道為什麼會這樣，在我的電腦很快啊！(這種回應是不是有點熟悉 XD)

這時我開始透過 Application Insights 查看網站運行的 Logs，這才發現不單單是 Azure Cache for Redis 的連線出了問題，主要的錯誤都是在 `RedisTimeoutException` 例外狀況，非常詭異。我為了這個問題，搜尋了無數資料，把所有文章都看完了，意外的精通所有 Redis 連線問題！ 😆

-   [StackExchange.Redis | General purpose redis client](https://stackexchange.github.io/StackExchange.Redis/)
    -   [Timeouts](https://stackexchange.github.io/StackExchange.Redis/Timeouts)
-   [Connection resilience](https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-best-practices-connection?WT.mc_id=DT-MVP-4015686)

其實我在 Application Insights 分析錯誤的時候，也發現應用程式連接 Azure SQL Database 也時不時有 Connection Timeout 的狀況，當時很難理解為何有這種狀況發生。

在我嘗試過了每個建議，基本上都無法改善這個狀況，而且我們在本機執行都是沒問題的，所以我用邏輯推理判定，這個問題就是 PaaS 平台造成的問題。

### 理解 SNAT 的運作原理

我最後還是開了一個 Azure Support Ticket 請求原廠協助，大約經歷了一週多的討論，問題漸漸收斂到是 `SNAT Port Exhaustion` 造成的！

這裡我還是解釋一下 SNAT 的運作原理，以下是 [ChatGPT](https://chat.openai.com/chat) AI 的回答：

> NAT (Network Address Translation) 是「網路位置轉譯」的簡寫，它是一種用於轉換網路封包中的來源 IP 地址的技術。在像網際網路這樣的分散式網路環境中，每個設備都有一個唯一的 IP 地址，用於在網路上唯一地識別這個設備。當您連接到網際網路時，您的設備也會分配一個 IP 地址，用於在網路上識別您的設備。
> 
> NAT 的目的是改變網路封包中的源 IP 地址，這樣就可以將多個設備隱藏在一個網路上。例如，如果您的網絡中有多個設備，但它們都共用一個 IP 地址，則 SNAT 就會在傳送數據時將源 IP 地址更改為共用的 IP 地址，這樣就可以讓這些設備看起來像是一個整體。
> 
> NAT 是一種常見的網路技術，它可以用於各種不同的用途，例如保護隱私、改善網路性能和網路安全。但是，由於 SNAT 會更改封包中的源 IP 地址，因此它也可能會導致一些問題，例如限制了網路運作的透明性和可追溯性。因此，在使用 SNAT 時，您需要謹慎考慮它是否適用於您的網絡需求。

我請 [ChatGPT](https://chat.openai.com/chat) 用三歲小孩聽的懂的話再說一次：

> 當有多個電腦連接到同一個網路時，SNAT 可以幫助它們隱藏起來，讓它們看起來像只有一台電腦。這樣可以讓它們的安全性更高，並且可以更好地管理它們。

![當有多個電腦連接到同一個網路時，SNAT 可以幫助它們隱藏起來，讓它們看起來像只有一台電腦。這樣可以讓它們的安全性更高，並且可以更好地管理它們。](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/9dcc77f22118cead303b-206440004-f40db2af-9153-4308-92d1-e04a76e79832.png)

好吧，聽不懂的人，還是聽不懂，對吧？ 😆

**用我的話稍微摘要一下，希望不熟網管的人可以更好的理解：**

在一個 **Scale unit** (`Stamp`) 有超過 1,000 台 VMs，每個 VM 都有一個內部 IP 地址。這些 VM 要透過 IP 向外建立連線時，會透過 `SNAT` 機制連上網際網路。

假設我們內網有 `10.0.0.1` 與 `10.0.0.2` 兩個 IP 地址，但為了要連上 Internet 的關係，我們一定要有一個 Internet 上可見的 Public IP 地址，假設為 `1.1.1.1`。

因為 IP 連線至少需要有 `1` 個來源 IP 地址、`1` 個來源 Port、`1` 個目的 IP 地址、`1` 個目的 Port 才能建立連線，但我們有 `2` 個內網 IP 地址，卻只有 `1` 個外網 IP 地址。我們假設你想要分別從這兩個內部 IP 連到 `8.8.8.8`，那麼網路封包會這樣轉譯：

1.  從 `10.0.0.1:8080` 連到 `8.8.8.8:53` 時，IP 會先流經 Router (路由器)，而路由器就會啟動 SNAT 機制。
2.  路由器會將 `10.0.0.1:8080` 轉換為 `1.1.1.1:55839`，並記錄到 NAT 對應表 (NAT Translation Table)。
3.  路由器此時會透過 `1.1.1.1:55839` 發出連線到 `8.8.8.8:53` (外部網路)。
4.  封包送達 `8.8.8.8:53` 之後，就會把回應的內容傳送回 `1.1.1.1:55839`。
5.  此時路由器會透過 NAT 對應表 (NAT Translation Table) 得知他是對應到 `10.0.0.1:8080`。
6.  因此 `10.0.0.1:8080` 便可收到從 `8.8.8.8:53` 回傳回來的 IP 封包！

如果路由器對外只有一組公開的 IP 地址，那麼 SNAT 就會有 `65536` Ports 個限制，這份 NAT 對應表 (NAT Translation Table) 其實是會滿的，超出 `65536` 筆資料時，網路就無法建立連線，或是收不到遠端回應的 IP 封包！🔥

> 注意: 只有連接到相同目的 IP/Port 的情況下，才會佔用路由器上的 SNAT Ports。如果你建立連接的對象是不同的 IP 或 Ports 的話，就沒有 `SNAT Port Exhaustion` 問題了！另外，使用 NAT gateway 的情況下，有 64K SNAT Ports 的限制，也夠大了。使用 Private Endpoints 的話，則是完全沒有 SNAT Ports 限制。

當你能夠理解 NAT 對應表 (NAT Translation Table) 有 `65536` 筆資料的限制，就可以知道在 Azure App Service 架構下，每個 **Scale unit** (`Stamp`) 就肯定要設定閥值。如果以 [Troubleshooting intermittent outbound connection errors in Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/troubleshoot-intermittent-outbound-connection-errors?WT.mc_id=DT-MVP-4015686) 文件所提到的，App Service Plan 每個 Instance 限制為 `128` SNAT Ports，如果用 `65536` 除以 `128` 就是 `512` 台 VM，所以假設每個 Stamp 如果只有一個對外公開 IP 地址的話，那麼該 Stamp 內最多就只能有 512 台伺服器！

更進一步研究，發現 App Service Plan 每個 Instance 其實是可以使用超過 128 SNAT Ports 的，但 Azure 僅能保障你使用 128 SNAT Ports 而已，超出的部分，就「有機會」收不到遠端回覆的 IP 封包，也因此會出現 Timeout 狀況！🔥

### 解決問題的方法

因此，我們回顧了一下工程師所寫的程式，發現他們使用 [StackExchange.Redis](https://stackexchange.github.io/StackExchange.Redis/) 套件來建立 `ConnectionMultiplexer` 連線，但是該類別卻用 `Scoped` 註冊 DI 容器，這導致每個 HTTP Request 都會個別建立 Redis 連線，然後就立刻斷線！

> 這裡補充一個小知識，當 TCP/IP 斷線後，會有 4 分鐘的 `TIME_WAIT` 狀態，所以這段時間 SNAT Ports 並不會釋放，所以當網站流量過大時，很容易就爆掉了！🔥

解決方法當然就是，當你要連接到遠端的任何服務，只要是相同的 IP/Port 要進行連線，都要盡可能的共用連線，避免 `SNAT Port Exhaustion` 問題發生：

1.  全站共用 Redis 連線 (`ConnectionMultiplexer`)
    
    ```bash
    private static Lazy<ConnectionMultiplexer> lazyConnection = new Lazy<ConnectionMultiplexer>(() =>
    {
        return ConnectionMultiplexer.Connect(Environment.GetEnvironmentVariable("CacheConnection"));
    });
    
    public static ConnectionMultiplexer _connectionMultiplexer { get { return lazyConnection.Value; } }
    
    public IDatabase _db
    {
        get
        {
            return _connectionMultiplexer.GetDatabase();
        }
    }
    ```
    
2.  控制 [SQL Server Connection Pooling](https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/sql-server-connection-pooling?WT.mc_id=DT-MVP-4015686) 的數量 (預設 `MaxPoolSize` 為 `100` 條連線)
    
    ```csharp
    using System.Data.SqlClient;
    
    // Create a new SqlConnectionStringBuilder instance
    SqlConnectionStringBuilder builder = new SqlConnectionStringBuilder();
    
    // Set the server name and database
    builder.DataSource = "server-name";
    builder.InitialCatalog = "database-name";
    
    // Set the connection pooling settings
    builder.ConnectionPooling = true;
    builder.MaxPoolSize = 100;
    builder.MinPoolSize = 0;
    
    // Get the connection string
    string connectionString = builder.ConnectionString;
    ```
    
3.  使用 EF Core 2.0 推出的 [DbContext pooling](https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-2.0/?WT.mc_id=DT-MVP-4015686#dbcontext-pooling) 機制
    
    ```csharp
    services.AddDbContextPool<BloggingContext>(
        options => options.UseSqlServer(connectionString));
    ```
    
    > 此作法會共用 DbContext 物件，可以更好的控制 SQL Server Connection Pool 的連線數。
    
4.  控制 HttpClient 連線數量
    
    [在 ASP.NET Core 中使用 IHttpClientFactory 發出 HTTP 要求](https://learn.microsoft.com/zh-tw/aspnet/core/fundamentals/http-requests?view=aspnetcore-7.0&WT.mc_id=DT-MVP-4015686)
    
    > 如果你需要透過 HttpClient 不斷的呼叫相同目的 IP/Port 的服務，才有可能遇到 `SNAT Port Exhaustion` 的問題。
    

### 追蹤 App Service 目前的連線狀況

如果遇到連線異常的狀況，可以透過以下步驟檢查是否為 `SNAT Port Exhaustion` 的問題！

1.  在 Azure Portal 開啟 App Service，點擊 Diagnose and solve problems 功能，然後搜尋 `SNAT` 就可以找到相關診斷功能。
    
    ![image](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/3ceb912950486215521b-206450966-7160f22d-d12c-4005-a981-794b1a694669.png)
    
2.  只要你看到 `All SNAT connections were successful.` 就代表你在 24 小時內，沒有 `SNAT Port Exhaustion` 的問題。
    
    ![image](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/8fe5ab3ed6ab14d6ec42-206452524-4fd7da81-e699-4acb-b05e-b6e4d7880e29.png)
    
    ![image](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/96d92d3d7e2f12ef899d-206452592-9ffc2787-2e10-490d-8f30-a4033692a0a3.png)
    

### 總結

我們需要瞭解 Azure App Service 的基礎架構：

```
Stamps (Cluster) -> VM (Instance) -> Web App, Function App, ...
```

理解 Stamps (Cluster) 裡面的 VM (Instance) 因為沒有 Public IPs，所以出去一定要經過 NAT，所以會佔用 SNAT Ports，而且預設一台 Instance (VM) 的上限為 128 個，透過 Scale out 的方式可以放大這個數量限制！

只有連接到相同目的 IP/Port 的情況下，才會佔用路由器上的 SNAT Ports。如果你建立連接的對象是不同的 IP 或 Ports 的話，就沒有 `SNAT Port Exhaustion` 問題了！另外，使用 NAT gateway 的情況下，有 64K SNAT Ports 的限制，也夠大了。使用 Private Endpoints 的話，則是完全沒有 SNAT Ports 限制。

App Service Plan 的 JSON 細部資料中的 `mdmId` 屬性就包含了 Stamp Id 參數，可以多少做點參考。

### 相關連結

-   App Service
    -   [Inside the Azure App Service Architecture](https://learn.microsoft.com/en-us/archive/msdn-magazine/2017/february/azure-inside-the-azure-app-service-architecture?WT.mc_id=DT-MVP-4015686)
    -   [Deployment Stamps pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/deployment-stamp?WT.mc_id=DT-MVP-4015686)
    -   [Troubleshooting intermittent outbound connection errors in Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/troubleshoot-intermittent-outbound-connection-errors?WT.mc_id=DT-MVP-4015686)
-   [Azure Cache for Redis](https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/?WT.mc_id=DT-MVP-4015686)
    -   [Best practices for connection resilience](https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-best-practices-connection?WT.mc_id=DT-MVP-4015686)
    -   [Connectivity troubleshooting](https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-troubleshoot-connectivity?WT.mc_id=DT-MVP-4015686)
    -   [Troubleshoot Azure Cache for Redis latency and timeouts](https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-troubleshoot-timeouts?source=recommendations&WT.mc_id=DT-MVP-4015686)
    -   [Investigating timeout exceptions in StackExchange.Redis for Azure Redis Cache](https://azure.microsoft.com/en-us/blog/investigating-timeout-exceptions-in-stackexchange-redis-for-azure-redis-cache/?WT.mc_id=DT-MVP-4015686)
-   [StackExchange.Redis](https://stackexchange.github.io/StackExchange.Redis/)
    -   [Timeouts](https://stackexchange.github.io/StackExchange.Redis/Timeouts)
    -   [Thread Theft](https://stackexchange.github.io/StackExchange.Redis/ThreadTheft)
    -   [Error: Timeout performing GET {key}, inst: 1, queue: 2, qu=0, qs=2, qc=0, wr=0/0, in=0/0 #83](https://github.com/StackExchange/StackExchange.Redis/issues/83)
    -   [Random timeout issues with Azure Redis #1859](https://github.com/StackExchange/StackExchange.Redis/issues/1859)
-   Networking
    -   [NAT — SNAT, DNAT, PAT & Port Forwarding | by Geeky much! | Networks & Security | Medium](https://medium.com/networks-security/nat-snat-dnat-pat-port-forwarding-b7982fab02cd)
-   .NET
    -   [Runtime configuration options for threading](https://learn.microsoft.com/en-us/dotnet/core/runtime-config/threading?WT.mc_id=DT-MVP-4015686)
