2019 年 7 月 6 日,OpenResty 社區聯合又拍雲,舉辦 OpenResty × Open Talk 全國巡迴沙龍·上海站,美團基礎架構部技術專家張志桐在活動上作了《美團 HTTP 服務治理實踐》的分享。html
OpenResty x Open Talk 全國巡迴沙龍是由 OpenResty 社區、又拍雲發起,邀請業內資深的 OpenResty 技術專家,分享 OpenResty 實戰經驗,增進 OpenResty 使用者的交流與學習,推進 OpenResty 開源項目的發展。活動將陸續在深圳、北京、武漢、上海、成都、廣州、杭州等城市巡迴舉辦。前端
首先作下自我介紹,我叫張志桐,畢業於哈爾濱工業大學,2015 年加入美團,目前在美團主要負責 Oceanus 七層負載均衡網關、Mtrace 分佈式鏈路跟蹤系統以及 KMS 密鑰管理服務等。mysql
美團是 Nginx 的老用戶,從創業初期就使用 Nginx,直到 2013 年遷到了阿里的 Tengine,再到今年三四月份,全站服務遷到了 OpenResty 上。從 Tengine 遷到 OpenResty 最根本的緣由是升級困難,隨着 Nginx 的版本迭代愈來愈快,致使 Tengine 很難合到官方 Nginx 最新版本上,可是使用 OpenResty 能夠平滑地升級整個 Nginx 的社區版本。nginx
Oceanus,單詞的含義是海神。它是整個美團接入層的七層負載均衡網關,天天有千億級別的調用量,部署了幾千個服務站點,近萬個註冊應用服務。Oceanus 最核心的功能是提供 HTTP 服務治理功能,主要包括服務的註冊與發現,健康檢查,以及徹底的可視化管理,同時也提供了像 Session 複用、動態 HTTPS、監控、日誌、WAF、反爬蟲、限流等網關功能。sql
這裏補充一個限流方面的小問題,目前美團是經過全局 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 反向代理會遇到幾個問題:緩存
咱們怎麼解決這三個問題?第一個動態的服務註冊,第二個是不須要 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 用的是業內比較成熟的方式:Tengine 提供的 dyups 模塊。它提供一個 dyups API,經過這個 API 添加、刪除、建立服務節點,以後經過一個 worker 處理這一次修改請求,把請求放到了一個共享內存的隊列中,各個 worker 會從這個隊列把此次變動拉取出來在本地生效,而後落到本地的內存中,實現整個步驟。其中,第一次調用時是須要加鎖,而後同步內存中尚未被消費的數據,同步完以後纔會更新操做,保證了數據的串性。
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 加權輪訓的方式保證的穩定的序列去選節點。
如上圖,建立站點能夠直接在 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 是 0100一、0100二、01003。當它上來之後,咱們有一個 Map 映射表,它跟前面的精細化分流不太同樣,而是經過路由表作路由篩選的,前面的多是基於表達式的。假如 01001 的Location 的路由表,它對應 Set ID 是 SET1,那麼就直接在 01001 的用戶請求里加一個 header,這個 header 的名稱就是 SET1,這樣就實現了報文的轉換,也就是北京的用戶在網關層都會新加一個 SET1 標識。以後就能夠複用前面的精細化分流的方案,當遇到 SET1 的請求就轉發到 SET1 的分組,從而實現了前端的單位化的路由方案。
Oceanus 將來主要在配置動態化上作進一步優化,尤爲是 location 動態化,由於經過文件配置 location 的方式,每次 reload 的操做,對線上的集羣仍是有損的。同時但願作到插件的管理動態化,它的熱部署與升級,以及自動化運維。美團線上近千臺機器,作自動化運維是很解放人效的操做,如何去快速搭建一個集羣以及遷移各個集羣的站點,是一個比較關鍵的任務。
演講視頻及PPT下載: