2019 年 7 月 6 日,OpenResty 社區聯合又拍雲,舉辦 OpenResty × Open Talk 全國巡回沙龍·上海站,美團基礎架構部技術專家張志桐在活動上做了《美團 HTTP 服務治理實踐》的分享。
OpenResty x Open Talk 全國巡回沙龍是由 OpenResty 社區、又拍雲發起,邀請業內資深的 OpenResty 技術專家,分享 OpenResty 實戰經驗,增進 OpenResty 使用者的交流與學習,推動 OpenResty 開源項目的發展。活動將陸續在深圳、北京、武漢、上海、成都、廣州、杭州等城市巡回舉辦。
首先做下自我介紹,我叫張志桐,畢業於哈爾濱工業大學,2015 年加入美團,目前在美團主要負責 Oceanus 七層負載均衡網關、Mtrace 分布式鏈路跟蹤系統以及 KMS 密鑰管理服務等。
美團是 Nginx 的老用戶,從創業初期就使用 Nginx,直到 2013 年遷到了阿里的 Tengine,再到今年三四月份,全站服務遷到了 OpenResty 上。從 Tengine 遷到 OpenResty 最根本的原因是升級困難,隨着 Nginx 的版本迭代越來越快,導致 Tengine 很難合到官方 Nginx 最新版本上,但是使用 OpenResty 可以平滑地升級整個 Nginx 的社區版本。
Oceanus 美團七層負載均衡網關
Oceanus,單詞的含義是海神。它是整個美團接入層的七層負載均衡網關,每天有千億級別的調用量,部署了幾千個服務站點,近萬個注冊應用服務。Oceanus 最核心的功能是提供 HTTP 服務治理功能,主要包括服務的注冊與發現,健康檢查,以及完全的可視化管理,同時也提供了像 Session 復用、動態 HTTPS、監控、日志、WAF、反爬蟲、限流等網關功能。
這里補充一個限流方面的小問題,目前美團是通過全局 Redis Cluster 來實現的,也簡單的做了一些優化,實現了完全基於 OpenResty 的 Redis Cluster,因為官方的 OpenResty 版本只支持單實例的 Redis 調用。同時我們不是每次請求都會去做 Redis Incr 的操作,每次會設置一個閾值,設置越大,本機加的代價就越小,因為不需要遠程調用了,但出現的誤差也會對應增大。基本的思路就是本地加一個步長,定期的把步長同步到 Redis Cluster 上來實現集群限流的功能。
上圖是當前 Oceanus 的系統架構,底層的引擎核心是基於 OpenResty 的。在每個 OpenResty 節點上會部署了一個 Agent 的進程,主要是為了做邏輯的解耦,我們不希望整個 Nginx 或者是 OpenResty 上有過重的邏輯和請求無關,於是把很多的邏輯都下沉到 Agent 上,實現與 OpenResty 的解耦,比如用 MNS 拉取服務列表,再通過 Agent 灌入到 OpenResty。站點管理,落地文件配置,統一由前端管理平台 Tethys 進行管理,之后會實時落地到 mysql 里,Agent 通過 mysql 的同步,再落地到本地到 Server block 文件,通過 reload 方式實現站點的重新加載。右邊是 Oceanus 體系之外的模塊,第一個是 MNS,是公司內部統一的命名服務。另一個 Scanner,主要負責的是健康檢查。
Nginx 配置反向代理
如上圖配置 Nginx 反向代理會遇到幾個問題:
- 寫死的服務地址,IP 不能變,每次變更需要改文件。
- 每次變化需要 reload。
- 文件化的配置容易出問題。
我們怎么解決這三個問題?第一個動態的服務注冊,第二個是不需要 reload 動態配置生效,第三個文件化配置變成一個結構化管理。
服務注冊
服務注冊目前是基於美團內部的 MNS 統一命名服務,上圖是整個服務注冊的前端界面。它后端還是依托如 ETCD、ZK 服務注冊的基礎組件,主要用於緩存服務的信息,實現批量拉取、注冊服務功能,可以根據 Nginx 集群選擇拉取與這一類集群相關的所有站點信息,同時通過推拉結合的方式保證數據實時和准確。並定期的把所有數據都拉到本地,依靠 ZK 的 watcher 方式來保證數據的實時到達。
健康檢查
Nginx 主動健康檢查有一些開源模塊,但這些主動的健康檢查會遇到一些問題。假設有一個站點 http://xxx.meituan.com,配在 upstream 里做健康檢查,每個 proxy 的服務器的每個 worker 都會定期向后端服務發起健康檢查。假如每秒檢查一次,整個 Nginx 集群數量是 100,每個單機實例上部署了 32 個 worker,健康檢查的請求 QPS 就是 100×32,而實際服務器每天的 QPS 不到 10,加上健康檢查機制就變成 3000 多了。所以我們摒棄了在內部主動去做健康檢查的方式,選擇了 Scanner 去做周期性健康檢查。此外, Scanner 支持自定義心跳,可以檢查端口是否通暢、HTTP 的 url 是否准確,並且支持快慢線程的隔離。
動態 upstream
美團實現動態 upstream 用的是業內比較成熟的方式:Tengine 提供的 dyups 模塊。它提供一個 dyups API,通過這個 API 添加、刪除、創建服務節點,之后通過一個 worker 處理這一次修改請求,把請求放到了一個共享內存的隊列中,各個 worker 會從這個隊列把這次變更拉取出來在本地生效,然后落到本地的內存中,實現整個步驟。其中,第一次調用時是需要加鎖,然后同步內存中還沒有被消費的數據,同步完之后才會更新操作,保證了數據的串性。
dyups 存在的一些問題:
1.持久化
最大的問題是內存生效,因為它走的是本地 worker 進程內部的內存,所以下一次 reload 時,整個服務列表會丟失。我們的解決方案是通過本地 Agent 來托管這個節點的更新和文件落地。當 Agent 定期感知到服務列表變化時,首先把本地生成的 upstream 文件更新,之后再去調用 dyups API,把這一次變更的節點實時同步到內存中,實現了服務節點不僅落地到本地文件做持久化存儲,同時還灌入到了 Nginx worker 內存中來保證服務的實施。
其中需要注意的是 reload 調用 dyups API 並發的問題。假如出現一種特殊的場景,Agent 感知到服務節點變化時,還沒來得及落地 upstream 文件,這時候 Nginx 出現了一次 reload,更新的還是舊的 upstream 文件。此時 dyups API 調用過來,通知需要更新服務節點,更新服務節點之后會把更新的信息放到共享內存中,類似於一個接收器,每一個 worker 拿到更新之后才會把消息刪除掉。這里可能出現一個問題,當 reload 的時候,出現了六個 worker 進程,有可能這一次更新被舊的 worker 進程拿掉了,導致新的 worker 沒有更新,進而導致了新的 worker 里有部分是更新成功,有部分是更新不成功的。
我們目前是把 Nginx 所有的 reload、start、stop 包括一些灌入的節點都統一交給 Agent 進行處理,保障了 reload 和 dyups API 調用的串行化。
2.流量傾斜
每台機器同一時刻更新節點,初始序列是一樣的,導致流量傾斜。比如線上有 100 個服務節點,每 25 個節點一個機房,當灌入節點時順序是一致的。從最開始選節點,第一個選的節點都是一樣的,導致一次請求篩選的節點都是請求列表里的第一個,所以同一時刻所有的流量都到了同一台后端機器上。
我們的解決方案是在 Nginx 內部加權輪訓時的初始化節點,做了內部的 random,來保證每個 worker 選的第一個節點都是隨機化的節點,而不是根據原來的動態 upstream 加權輪訓的方式保證的穩定的序列去選節點。
Nginx 結構化配置管理
如上圖,創建站點可以直接在 Oceanus 平台上配置,提交后相當於建立了一個 Nginx 的 server 配置。同時支持導入功能,Nginx server 的配置文件可以實時導入,落到集群的機器上。
匹配規則
建完站點之后,可以直接配置映射規則,左側是的 location,右側對應的 pool 在美團內部是 appkey,每個服務都有一個名字。之后會通過一些校驗規則來驗證配置的規則從 location 到 appkey 是否合法,或者是否超出預期。 當 location 配置規則非常復雜,中間出現一些正則時,作為一名業務 RD 在平台上配置規則時是很容易出問題,因為你不知道配置的規則是否正確,是否真的把原來想引流的流量導到了 appkey 上,還是把錯誤地把不該導入這個服務的請求導到了 appkey 上。因此需要做很多的前置校驗,目前美團內部使用的校驗規則是模擬生成已有路徑下的正則匹配的 url,用於測試哪些流量到了新部署的 appkey上做校驗。這種校驗也是有一定的不足,比如配置了很多正則匹配的方式,我們模擬出來的 url 其實不足以覆蓋所有的正則 url ,會導致校驗不准確。目前我們的規划是獲取到所有的后端服務,比如 Java 的服務,后面會有 Controller,Controller 上有指定業務的 url,我們可以針對業務的 url 去離線的日志里篩選出來它們歷史上每個路徑下匹配真實的 url,用真實的 url 做一次回放,看是否匹配到了應該匹配的服務上去。
指令配置與流量統計
我們也支持所有的 Nginx 上的指令配置,包括設置 Header、設置超時、rewrite、自定義指令等,或者我們封裝好的一些指令。 同時也支持一些服務的性能統計,比如說 QPS,HTTPS QPS,以及服務內部的 4XX、5XX。
負載均衡方案迭代歷程
精細化分流
精細化分流項目的背景是美團在線上的一些需求,比如在線上希望實現對某一個地域的用戶做灰度的新功能特性更新,或者按百分比引流線上的流量,以及對固定流量的特征,選擇讓它落到固定后端的服務器上,保證這一部分的用戶和其他的用戶的物理隔離。
舉個例子,上圖右邊是三台服務器都是服務 A,把其中兩台服務器作為一個分組 group-G,Agent 獲取到這個服務信息后,會把它實時落地到 upstream 文件里。如果是 group-G ,可以落到Upstream A_GR_G 的 upstream 文件中;如果是 upstream A,就和普通的服務一樣落地好,3 個 server 同時落到一個服務上。此時前端有用戶 ID 的請求進來,需要選擇一種分流的策略,比如希望用戶的 ID 的 mod100 如果等於 1 的請求,路由到灰度的分組 groupG 上,通過這種策略的計算,把 1001 用戶請求路由到 upstream A-GR-G 服務上,然后剩下的其他的用戶都通過策略的篩選,路由到服務 A 上 。
精細化分流具體實現的邏輯,首先在一個 worker 進程嵌入 timer,它會定期拉取策略配置,同時 DB 配置結構化寫入共享內存的雙 buffer,worker數據請時候,會從共享內存中讀取策略進行匹配。策略匹配的粒度是 Host+Location+appkey,策略分為公共策略和私有策略,公共策略是整個全網都需要采用的一個策略,私有策略是可以針對自己的服務做一些定制化。
當請求來臨的時候,獲取請求的上下文,通過 Host+Location 來查找它需要使用的策略集合,如果是匹配公共策略就直接生效,如果是私有策略就會按 appkey 查找策略。以上圖為例,請求來了之后,獲取到請求的上下文,之后通過請求上下文里的 Host+Location 去找相應的策略集合,然后可能找到了左下角的策略集合。
分流轉發的過程是在 rewrite 階段觸發的,請求進入到 rewrite 階段以后會解析策略數據,實時獲取請求來源中的參數,通過參數和表達式渲染成表達式串:
if (ngx.var.xxx % 1000 = 1) ups = ups + target_group;
通過執行這段命令,看是否命中分流策略,如果命中則改寫路由的 ups 到指定的 ups group,否則不對 upstream 做修改。
泳道
微服務框架下服務個數多、調用鏈路較長,其中一個服務出問題會影響到整條鏈路。舉個的例子,QA 提測往往需要該條鏈路上的多個服務配套測試,甚至是同時測試一個服務的多個演進版本,測試的科學性是不完善的,為了解決線下 QA 實現穩定的並發測試,我們提出了泳道的概念。
如上圖,有兩個 QA。第一個 QA 可以建立屬於自己的泳道 1,第二個 QA 可以建立屬於自己的泳道 2。QA 1 測試的功能在 B、C、D 服務上,它只需要建立一個有關於這次測試特性的 B、C、D 的服務,就可以復用原來的骨干鏈路。比如骨干鏈路的請求通過泳道的域名進來,首先會路由到骨干鏈的 A 服務上,之后他會直接把這次請求轉發給泳道 1 上的 B、C、D 服務,之后 D 服務因為沒有部署和他不相干的服務,所以它又會回到骨干鏈路的 E 服務和 F 服務。
QA2 測試的功能主要是集中在 A 和 B 服務,它只需要單獨部署一個 A 和 B 服務相關於本次測試特性服務就可以了。當請求進來,在泳道 2 上 A、B 服務流經結束,就會回到主干鏈路 C、D、E 和 F 服務上,從而實現並發測試的效果,同時保證了骨干鏈路的穩定,因為這個過程中骨干鏈路是一直沒有動的,唯一動的是要測試的那部分的內容。
同時多泳道並存可以保證多服務和多版本的並行測試,並做錯誤的隔離,極大的提高了的服務上線的流程。
泳道的實現基於精細化分流就很簡單了。例如給服務 A 一個標簽,它屬於泳道 S,用同樣的原理可以把它落地成 upstream A-SL-S,同時把泳道 IP 放到 upstream 里面,此時 A 服務上里沒有泳道的機器。美團內部一般使用通過服務鏡像的方式做服務的測試,通過 Docker 直接創建泳道的鏈路,自動化生成一個泳道的域名,通過測試域名訪問就會直接把請求轉發到泳道域名上。實現方案就是通過 Lua 泳道模塊判斷 Host 的命名規則和 Header 里是否有泳道,從而判斷是否需要轉發到后端的 upstream 節點上。
單元化
隨着公司規模的不斷擴大,我們實現了第三套的負載均衡方案——單元化。首先先介紹一些問題,你的服務是否真的做到了水平的擴展?你的服務是否真的做到了物理隔離?
舉個例子,如上圖,一條業務線上有兩套集群,服務 A 和服務 B,同時下面有數據庫,數據庫做了分庫分表,並且服務也是分布式服務,它到底是不是一個水平擴展的服務呢?
服務集群 A 和 B 的服務節點都有 N 個,當在服務集群 B 加一個節點時,所有服務集群 A 的節點都會與服務集群 B 中新加的節點建立一條連接,做長連接的連接池。長連接的資源其實是不可水平擴展的,因為每加一台機器,承受的長連接的數量都是 N。同理這個問題最嚴重的是在 DB 上,DB 的主庫一般都是單點的,即使分了庫,所有的寫請求都會放到主庫上,它的長連接其實是受限的,你如何怎么保證它的長連接一直在一個可控的范圍內呢?
另一個問題是任意節點有異常都可能影響所有的用戶,服務集群 B 的 N 節點出現問題,此時服務集群 A 里的所有請求,都有可能轉發給 B 集群的 N 服務節點,也就是說任意一個用戶的請求都可能會受到影響。所以看似你做的整個的分布式的系統能做到水平擴展,但其實不是這樣。
為了解決上面的問題,我們提出了單位化的操作。按用戶的流量特征把所有的請求都框到一個服務單元內,通常服務單元都是按地域划分的。此時每個單元內的服務是互相分布式調用的,但是跨單元的服務之間是沒有關系的。原來服務集群 A 里的服務節點對服務集群 B 里的每一個節點都建立連接,變成了只針對自己服務單元內的服務做長連接,這樣連接數量就降到原來的 N 分之一。同時用戶的流量會在某個單元內做閉環,實現了完全的隔離。當然現實中單元化還有一些前提,比如說 DB 的數據分布,如果 DB 不能按單元划分,那單位化還是實現不了。
Oceanus 網關層實現單元化的路由,復用了報文轉換的功能模塊,支持根據某個Header或者Get參數來修改、刪除、新加 Header 或者 Get 參數。
如上圖的例子,假如從 App 端上來的請求,會帶有地域特征,北京的用戶可能帶的 Location ID 是 01001、01002、01003。當它上來以后,我們有一個 Map 映射表,它跟前面的精細化分流不太一樣,而是通過路由表做路由篩選的,前面的可能是基於表達式的。假如 01001 的Location 的路由表,它對應 Set ID 是 SET1,那么就直接在 01001 的用戶請求里加一個 header,這個 header 的名稱就是 SET1,這樣就實現了報文的轉換,也就是北京的用戶在網關層都會新加一個 SET1 標識。之后就可以復用前面的精細化分流的方案,當遇到 SET1 的請求就轉發到 SET1 的分組,從而實現了前端的單位化的路由方案。
未來規划
Oceanus 未來主要在配置動態化上做進一步優化,尤其是 location 動態化,因為通過文件配置 location 的方式,每次 reload 的操作,對線上的集群還是有損的。同時希望做到插件的管理動態化,它的熱部署與升級,以及自動化運維。美團線上近千台機器,做自動化運維是很解放人效的操作,如何去快速搭建一個集群以及遷移各個集群的站點,是一個比較關鍵的任務。
演講視頻及PPT下載: