1. 程式人生 > >美團張志桐:美團 HTTP 服務治理實踐

美團張志桐:美團 HTTP 服務治理實踐

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下載:

美團 HTTP 服務治理實踐