最近我們有個在 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 這份文件。
由於 Azure App Service 是一套 fully managed (完全託管) 的 PaaS (Platform as a Service) 架構,其背後的基礎建設部分通常不需要開發者擔心,因為那是平台該負的責任。不過,當你的應用程式開始用到越來越大量的資源,還是會面臨資源不足的情況,這時就需要去瞭解「限制」在哪裡,要如何「避開」這些限制,或是在既有的限制底下利用更多的資源,這些就是相對專業的領域了!
這些「限制」對口袋夠深的客戶來說,都是沒問題的,一台 Instance 不夠那就兩台,兩台不夠就三台,總能突破限制的,因為 Azure App Service 擁有絕佳的橫向延展(Scale-out)能力。
在全球的 Azure App Service 部署架構下,採用了 Deployment Stamps pattern (部署戳記模式) 來進行部署,他把多台伺服器封裝成一個所謂的 scale unit (延展單元),或稱為 Stamp (戳記),全球的 Azure App Service 都採行這樣的架構進行部署。以下是 Global Distribution of App Service Scale Units 的示意圖:
這裡的 Scale unit 就是一個 Scale out 的基本單位,每個 unit 大概會有上千台 VM 之多!詳見 What Is an App Service Scale Unit?
其實 Scale unit (Stamp
) 對 Azure 用戶是不可見的,這不是 Azure 用戶該知道的內部資訊,就算知道也改不動,因為整個 App Service 架構都是由 Azure 資料中心統一並全自動化管理的。不過,我還是發現了可以得知目前 App Service Plan 所在的 Stamp 的方式,請見以下圖示:
-
進入 App Service Plan 的 Overview 頁面
-
點擊右上角的 JSON View
連結
-
接著你可以從 mdmId
屬性,得知你目前機器身處的 Stamp Id
由於一個 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 文章說明。
問題描述
這裡我就說明一下我發現問題的過程,好讓大家能夠更好的理解為什麼會有這些問題。
我們用 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 連線問題! 😆
其實我在 Application Insights 分析錯誤的時候,也發現應用程式連接 Azure SQL Database 也時不時有 Connection Timeout 的狀況,當時很難理解為何有這種狀況發生。
在我嘗試過了每個建議,基本上都無法改善這個狀況,而且我們在本機執行都是沒問題的,所以我用邏輯推理判定,這個問題就是 PaaS 平台造成的問題。
理解 SNAT 的運作原理
我最後還是開了一個 Azure Support Ticket 請求原廠協助,大約經歷了一週多的討論,問題漸漸收斂到是 SNAT Port Exhaustion
造成的!
這裡我還是解釋一下 SNAT 的運作原理,以下是 ChatGPT AI 的回答:
NAT (Network Address Translation) 是「網路位置轉譯」的簡寫,它是一種用於轉換網路封包中的來源 IP 地址的技術。在像網際網路這樣的分散式網路環境中,每個設備都有一個唯一的 IP 地址,用於在網路上唯一地識別這個設備。當您連接到網際網路時,您的設備也會分配一個 IP 地址,用於在網路上識別您的設備。
NAT 的目的是改變網路封包中的源 IP 地址,這樣就可以將多個設備隱藏在一個網路上。例如,如果您的網絡中有多個設備,但它們都共用一個 IP 地址,則 SNAT 就會在傳送數據時將源 IP 地址更改為共用的 IP 地址,這樣就可以讓這些設備看起來像是一個整體。
NAT 是一種常見的網路技術,它可以用於各種不同的用途,例如保護隱私、改善網路性能和網路安全。但是,由於 SNAT 會更改封包中的源 IP 地址,因此它也可能會導致一些問題,例如限制了網路運作的透明性和可追溯性。因此,在使用 SNAT 時,您需要謹慎考慮它是否適用於您的網絡需求。
我請 ChatGPT 用三歲小孩聽的懂的話再說一次:
當有多個電腦連接到同一個網路時,SNAT 可以幫助它們隱藏起來,讓它們看起來像只有一台電腦。這樣可以讓它們的安全性更高,並且可以更好地管理它們。
好吧,聽不懂的人,還是聽不懂,對吧? 😆
用我的話稍微摘要一下,希望不熟網管的人可以更好的理解:
在一個 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
,那麼網路封包會這樣轉譯:
- 從
10.0.0.1:8080
連到 8.8.8.8:53
時,IP 會先流經 Router (路由器),而路由器就會啟動 SNAT 機制。
- 路由器會將
10.0.0.1:8080
轉換為 1.1.1.1:55839
,並記錄到 NAT 對應表 (NAT Translation Table)。
- 路由器此時會透過
1.1.1.1:55839
發出連線到 8.8.8.8:53
(外部網路)。
- 封包送達
8.8.8.8:53
之後,就會把回應的內容傳送回 1.1.1.1:55839
。
- 此時路由器會透過 NAT 對應表 (NAT Translation Table) 得知他是對應到
10.0.0.1:8080
。
- 因此
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 文件所提到的,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 套件來建立 ConnectionMultiplexer
連線,但是該類別卻用 Scoped
註冊 DI 容器,這導致每個 HTTP Request 都會個別建立 Redis 連線,然後就立刻斷線!
這裡補充一個小知識,當 TCP/IP 斷線後,會有 4 分鐘的 TIME_WAIT
狀態,所以這段時間 SNAT Ports 並不會釋放,所以當網站流量過大時,很容易就爆掉了!🔥
解決方法當然就是,當你要連接到遠端的任何服務,只要是相同的 IP/Port 要進行連線,都要盡可能的共用連線,避免 SNAT Port Exhaustion
問題發生:
-
全站共用 Redis 連線 (ConnectionMultiplexer
)
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();
}
}
-
控制 SQL Server Connection Pooling 的數量 (預設 MaxPoolSize
為 100
條連線)
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;
-
使用 EF Core 2.0 推出的 DbContext pooling 機制
services.AddDbContextPool<BloggingContext>(
options => options.UseSqlServer(connectionString));
此作法會共用 DbContext 物件,可以更好的控制 SQL Server Connection Pool 的連線數。
-
控制 HttpClient 連線數量
在 ASP.NET Core 中使用 IHttpClientFactory 發出 HTTP 要求
如果你需要透過 HttpClient 不斷的呼叫相同目的 IP/Port 的服務,才有可能遇到 SNAT Port Exhaustion
的問題。
追蹤 App Service 目前的連線狀況
如果遇到連線異常的狀況,可以透過以下步驟檢查是否為 SNAT Port Exhaustion
的問題!
-
在 Azure Portal 開啟 App Service,點擊 Diagnose and solve problems 功能,然後搜尋 SNAT
就可以找到相關診斷功能。
-
只要你看到 All SNAT connections were successful.
就代表你在 24 小時內,沒有 SNAT Port Exhaustion
的問題。
總結
我們需要瞭解 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 參數,可以多少做點參考。
相關連結