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