攜程定制化路由代理網關實現
一、背景
攜程軟負載產品(SLB)基于Nginx實現,為攜程幾乎所有HTTP請求流量提供負載均衡服務,目前每天處理千億次請求。最開始時,SLB的主要職責是管理業務的HTTP路由和實現反向代理,取代更傳統的硬件負載為業務集群提供應用層負載均衡。
近幾年,隨著業務增長,以及多機房容災、混合云部署等背景,逐漸衍生出了多機房容災,跨區域流量調度等定制化需求,一個請求需要基于不同條件在機房間轉發。傳統的反向代理功能已經無法滿足業務需求,如何在現有Nginx技術棧上支持越來越多的定制化路由需求,管理路由配置,并快速迭代功能成為了我們面臨的問題。
二、流量鏈路
SLB需要處理攜程內網和外網所有的HTTP流量,SLB為業務應用提供HTTP層負載均衡能力,管理維護所有域名訪問入口和業務應用集群的關聯關系。在早期,公司整體的流量模式比較單一,SLB的職責也較為單一,主要作為HTTP等協議的反向代理。舉一個最簡單的Nginx樣例配置,我們將foo.bar.com/hello的請求轉發到后端一個三臺機器組成的固定集群上,這也是早期SLB大量配置的模式,將HTTP請求轉發到固定的某組機器。
server {
listen 80;
server_name foo.bar.com
location ~* ^/hello {
proxy_pass http://backend_499;
}
}
upsstream backend_499 {
server 10.10.10.1:80 weight=5 max_fails=0 fail_timeout=30;
server 10.10.10.2:80 weight=5 max_fails=0 fail_timeout=30;
server 10.10.10.3:80 weight=5 max_fails=0 fail_timeout=30;
}
后來隨著公司業務的增長以及對可用性更高的要求,有了混合云部署、異地容災等場景后,公司業務的流量鏈路也更加復雜,業務也提出了很多定制化場景。
2.1 多機房容災
攜程各業務線需要在私有云,公有云的多個機房部署,日常情況由各個機房部署的機器共同分擔處理業務流量。如果有機房大面積故障,需要從路由摘除整個故障機房,使外部流量導向正常機房。另外當流量高峰時,支持將高峰流量向容量相對更多的公有云機房泄洪。容災是業務高可用的重要保障,需要SLB在全局、應用和服務層面,有機房間流量動態分配切換的能力。
2.2 多元化需求
不同業務和場景往往關心請求中不同的維度和數據,比如有些場景往往希望同一個用戶的請求鏈路能夠保持一致,實現set化的場景,而另一些場景可能更關心如何分流給不同的后端應用和不同的機房。功能上除了流量調度,也有請求標記,請求響應數據采集等需求。我們希望能有一個通用的方案來實現這些定制化的需求。
上述復雜場景和需求,顯然是無法通過樸素的反向代理模式和Nginx靜態配置實現的,這些需求推動SLB向集成API網關功能的產品前進。
三、面臨的問題
在SLB著手實現和落地這些需求的過程中,我們碰到了一些困難和痛點,總結下來有這幾方面。
3.1 路由能力
Nginx原生API提供的路由和請求處理能力都比較有限,像一些比較復雜的條件路由,即需要根據運行時的情況動態選擇路由目標或對請求做一些處理,Nginx很難以優雅的方式支持。
3.2 動態更新
Nginx的配置更新需要一次reload操作,reload操作過程中master進程會用新配置fork出新的worker進程,這是一個非常耗資源的操作。Nginx和客戶端服務端的連接都需要重建,進程創建和加載配置本身也需要消耗資源,并且對于一些高頻請求的業務,reload會導致有請求失敗的情況。而在實際場景里,配置變更又是一個高頻操作,業務的一次灰度切流就需要好幾次有損變更,我們需要尋求一種沒有reload的變更生效方式。
dyups也是一種常見的動態更新方式,我們也通過這種方式來動態更新upstream。但實踐過程中我們發現,大量dyups實際也會阻塞Nginx進程,我們需要更優雅的方式。
3.3 集群管理
SLB在部署上,需要涵蓋并對齊公司所有網絡環境和機房,線上目前有上百套集群,我們的一些版本迭代需要消耗非常大的人力去做發布、版本對齊和兼容等工作。實際部署上,不同集群的職責也有差異,我們希望這些功能切面的升級更新可以獨立升級、動態生效。另外,每次路由邏輯的更新,路由的切換,需要能以灰度的方式進行,避免帶來災難性后果。
3.4 Nginx Lua
某種程度上,為了進一步拓展SLB的路由功能,我們引入了OpenResty的Nginx Lua模塊來嘗試解決這個問題。OpenResty開源的Nginx Lua模塊為Nginx嵌入了LuaJIT,提供了豐富的指令和API,包括但不限于:
1)讀寫請求(header, url, body),實現一些請求標記,rewrite等功能
2)請求轉發,實現自定義的路由邏輯,不再局限于固定的upstream和server
3)共享內存,Nginx進程間共享數據
4)網絡編程,實現一些旁路請求
我們通過Lua腳本在Nginx處理請求的各種階段,執行自定義的腳本,實現不同的功能。涵蓋了nginx初始化、請求處理、請求轉發、響應和日志等非常完整的請求和響應流程,并且在很多公司都有實際應用。
在SLB早期方案中我們使用Nginx Lua實現一些相對固定的邏輯如集群間流量灰度。業務可以配置某個路徑的請求在新老集群間的流量比例,或指定某個集群路由,舉個例子:
server {
location /foo/bar {
content_by_lua_block {
# 取隨機數
local r = math.random()
if (ngx.var.flag == "group_1" or ngx.var.flag == "group_2" ) then
ngx.exec("@" .. ngx.var.flag) # 指定訪問某個集群
elseif (r >= 0.00 and r < 0.20) then # 一定比例到一個集群
ngx.exec("@group_1")
elseif (r >= 0.20 and r <= 1.00) then # 一定比例到另一個集群
ngx.exec("@group_2")
end
}
}
location @group_1 {
content_by_lua_block {
ngx.say("I am group_1")
}
}
location @group_2 {
content_by_lua_block {
ngx.say("I am group_2")
}
}
}
Nginx Lua一定程度上解決了傳統反向代理路由能力的問題,但在動態更新上仍然存在問題:變更仍然需要reload生效,一次業務灰度從0到100需要經歷多次reload。
四、解決方案
4.1 核心:邏輯和數據
一個較為理想的方案來解決傳統路由動態更新所需的reload和它帶來的開銷問題:在Nginx中將數據和邏輯進行隔離。比如我們可以直接在Nginx配置中引用一個Lua文件,由Lua文件提供一個固定的方法入口,讀取內存中的數據進行計算和轉發。通過這種方式,流量調度邏輯是相對固定的,動態和高頻變更的部分在數據模型,這樣我們就能避免reload,業務就可以進行高頻操作和修改。
server {
location /foo/bar {
content_by_lua_block {
local foo = require("foo.lua") # lua 庫
local destination = foo.bar(data) # 根據數據模型執行
ngx.exec(destination) # 轉發
}
}
location @somewhere {
server xxxx;
}
}
-- foo.lua
function bar(data)
return some_func(data)
end
內存數據可以是基于不同路由場景自定義的數據結構,來描述期望的配置、關聯關系、路由目標等。比如上述的集群間流量灰度,如果要實現流量根據不同比例轉發到兩個集群,這個結構可以類似于:
{
"groups": [
"group_1": {"weight": 20},
"group_2": {"weight": 80}
]
}
類似的,如果要實現根據請求不同條件轉發:
{
"groups": [
"group_1": {"header": "foo"},
"group_2": {"header": "bar"}
]
}
對于每個流量調度場景,我們只要定義流量調度邏輯和相應的數據模型,就能比較方便的實現我們需要的功能。
4.2 整體架構
方案整體上分為三個職責不同的模塊:
1)API模塊:作為控制面集群的核心組件,API模塊基于Java開發,它負責管理Lua文件和數據模型的生命周期。這包括對Lua文件和數據模型的創建、更新、刪除和持久化等操作,支持灰度下發。此外,API模塊還負責集群配置的管理,包括節點的注冊和發現、路由配置等。
2)Agent模塊:Agent模塊是連接控制面和數據面的橋梁,承擔著數據的加工和傳遞的任務。它負責將控制面下發的Lua文件和數據模型持久化到本地存儲,以便在數據面進行實時的路由計算和流量處理。Agent模塊還負責與管理層的API模塊進行通信,下發最新的Lua腳本文件和數據模型的更新,并將其應用到本地存儲中,以確保數據面能夠及時獲取最新的路由規則。
3)Nginx & Lua模塊:作為數據面集群的核心組件,Nginx & Lua模塊承擔著實際的請求處理和路由邏輯的執行。Nginx Lua提供了靈活的編程能力,使得我們可以根據業務需求定制化地處理請求和路由流量。Lua腳本可以訪問實時更新的數據模型,根據其中的信息進行動態的路由計算,并將請求轉發到預期的目標。這種動態路由的能力使得系統能夠根據實時的業務需求和環境變化來靈活地調整流量分配。
4.3 數據模型生命周期
在方案實踐中比較重要且復雜的地方是路由邏輯依賴的數據模型如何更新和生效。我們采用類似于Nginx配置文件的工作和生效方式,把數據模型存儲在磁盤,而不同之處在于我們旁路將數據模型從磁盤讀取到內存,運行時從內存讀取模型,這樣就不再依賴reload生效。
具體來說,Nginx是一個多進程的應用,會存在多個worker進程處理請求,進程間天然存在內存隔離。在worker的生命周期里,在每個worker初始化時先加載數據到內存,運行時動態同步更新內存數據。為了把更新推送到每個worker進程,我們基于Nginx Lua提供的共享內存功能,在共享內存中緩存每個數據模型的最新版本,而worker進程通過一個timer來不斷輪詢共享內存,發現版本有更新后從磁盤讀取數據模型,加載到內存,對于每個場景可以在Lua中自定義具體的數據加載邏輯。
相比于worker直接或旁路輪詢數據接口,共享內存最小化了請求和解析數據對運行時的影響。另外Agent把每個數據模型都持久化到磁盤解決了進程重啟時內存數據丟失的問題,worker進程可以在初始化時主動從磁盤重新讀取數據。這樣即使我們的Nginx由于一些意外重啟或者退出(發布,OOM,機器重啟等),至少還能以磁盤上持久化的數據繼續工作,并且不依賴其他組件(API、Agent)的存活。
4.4 實踐和落地情況
在實踐中我們注意到,Lua或者說數據面應用的處理邏輯應盡可能簡單,但對于運維和開發同學來說,一般都是希望有全局視角的數據來掌控全局。
所以在數據結構設計上,我們一般會進行拆分:API和運維人員及其他系統的交互通常采用全局數據,會有多個層級。而在和Agent中間層交互時主動加工數據模型,去掉運行時不需要的部分,如其他集群的配置,被關閉的配置等;到了真正處理請求的數據面,往往我們只希望從一個扁平化的數據模型以類似key-value的形式讀取。比方說一個應用在多數據中心的流量分配,在全局視角來看類似于:
{
"app_1": {
"idc_1": {"target": "group1"},
"idc_2": {"target": "group2"},
"idc_3": {"target": "group3"}
}
}
對于某個IDC集群的數據面來說,Lua中讀取的理想形式是盡可能平面化的Table:
{
"app_1": "group1"
}
避免了運行時Lua做過多的數據解析邏輯,是較為合理的職責分配方式。
目前我們通過該方案實現了應用和服務粒度的流量分配,使得業務應用能夠在多個數據中心提供服務。同時,該方案還提供了全局及應用粒度的故障降級功能,以增強業務的可用性,經過多次機房故障演練驗證,切換可以在秒級生效。除了實現復雜的請求路由功能之外,我們通過Lua也落地了請求響應數據采集,不同渠道流量標記等旁路功能。
五、結語
流量鏈路隨著公司技術和業務的拓展,走向多元、動態和定制化的發展方向,不斷對負載均衡和網關產品提出新的場景和需求。定制化路由解決方案解決了傳統反向代理在路由能力和動態更新上的問題,降低了大規模集群部署下的研發和迭代成本,幫助SLB產品從傳統的反向代理中間件向綜合性的API網關逐漸靠攏。方案對不同場景有良好的擴展性和可靠性,未來也會有更多的流量調度模式通過統一方案快速落地交付。
在產品技術棧上,不同的編程語言和技術各有所長,我們希望能使不同技術達成優勢互補,像Lua的動態,Nginx的性能。需要我們持續在這些技術上不斷學習和投入,加深我們對產品技術棧的理解,揚長避短。
在未來,如何更好的利用現有技術棧,方便Java開發的同學能快速上手Lua開發,如何優化Lua責任鏈和異常處理,優化Lua的內存管理,還有進一步努力和探索的空間。