1. 程式人生 > 實用技巧 >高效能閘道器設計實踐

高效能閘道器設計實踐

前言

之前的高效能短鏈設計一文頗受大家好評,共被轉載 47 次,受寵若驚,在此感謝大家的認可!在文末簡單提了一下 OpenResty,一些讀者比較感興趣,剛好我們接入層閘道器也是用的 OpenResty,所以希望通過對閘道器設計的介紹來簡單總結一下 OpenResty 的相關知識點,爭取讓大家對 OpenResty 這種高效能 Web 平臺有一個比較全面的瞭解。本文會從以下幾個方面來講解。

  • 閘道器的作用
  • 接入層閘道器架構設計與實現
  • 技術選型
  • OpenResty 原理剖析

閘道器的作用

閘道器作為所有請求的流量入口,主要承擔著安全,限流,熔斷降級,監控,日誌,風控,鑑權等功能,閘道器主要有兩種型別

  • 一種是接入層閘道器(access gateway),主要負責路由,WAF(防止SQL Injection, XSS, 路徑遍歷, 竊取敏感資料,CC攻擊等),限流,日誌,快取等,這一層的閘道器主要承載著將請求路由到各個應用層閘道器的功能

  • 另一種是應用層閘道器,比如現在流行的微服務,各個服務可能是用不同的語言寫的,如 PHP,Java 等,那麼接入層就要將請求路由到相應的應用層叢集,再由相應的應用層閘道器進行鑑權等處理,處理完之後再呼叫相應的微服務進行處理,應用層閘道器也起著路由,超時,重試,熔斷等功能。

目前市面上比較流行的系統架構如下

可以看到接入層閘道器承載著公司的所有流量,對效能有很高的要求,它的設計決定著整個系統的上限。所以我們今天主要談談接入層閘道器的設計。

接入層閘道器架構設計與實現

首先我們要明白接入層閘道器的核心功能是:根據路由規則將請求分發到對應的後端叢集,所以要實現如下幾個功能模型 。

1、 路由:根據請求的 host, url 等規則轉發到指定的上游(相應的後端叢集)
2、 路由策略外掛化:這是閘道器的靈魂所在,路由中會有身份認證,限流限速,安全防護(如 IP 黑名單,refer異常,UA異常,需第一時間拒絕)等規則,這些規則以外掛的形式互相組合起來以便只對某一類的請求生效,每個外掛都即插即用,互不影響,這些外掛應該是動態可配置的,動態生效的(無須重啟服務),為啥要可動態可配置呢,因為每個請求對應的路由邏輯,限流規則,最終請求的後端叢集等規則是不一樣的

如圖示,兩個請求對應的路由規則是不一樣的,它們對應的路由規則(限流,rewrite)等通過各個規則外掛組合在一起,可以看到,光兩個請求 url 的路由規則就有挺多的,如果一個系統大到一定程度,url 會有不少,就會有不少規則,這樣每個請求的規則就必須可配置化動態化,最好能在管理端集中控制,統一下發。

3、後端叢集的動態變更

路由規則的應用是為了確定某一類請求經過這些規則後最終到達哪一個叢集,而我們知道請求肯定是要打到某一臺叢集的 ip 上的,而機器的擴縮容其實是比較常見的,所以必須支援動態變更,總不能我每次上下線機器的時候都要重啟系統讓它生效吧。

4、監控統計,請求量、錯誤率統計等等

這個比較好理解,在接入層作所有流量的請求,錯誤統計,便於打點,告警,分析。

要實現這些需求就必須對我們採用的技術:OpenResty 有比較詳細的瞭解,所以下文會簡單介紹一下 OpenResty 的知識點。

技術選型

有人可能第一眼想到用 Nginx,沒錯,由於 Nginx 採用了 epoll 模型(非阻塞 IO 模型),確實能滿足大多數場景的需求(經過優化 100 w + 的併發數不是問題),但是 Nginx 更適合作為靜態的 Web 伺服器,因為對於 Nginx 來說,如果發生任何變化,都需要修改磁碟上的配置,然後重新載入才能生效,它並沒有提供 API 來控制執行時的行為,而如上文所述,動態化是接入層閘道器非常重要的一個功能。所以經過一番調研,我們選擇了 OpenResty,啥是 OpenResty 呢,來看下官網的定義:

OpenResty® 是一個基於 Nginx 與 Lua 的高效能 Web 平臺,其內部集成了大量精良的 Lua 庫、第三方模組以及大多數的依賴項。用於方便地搭建能夠處理超高併發、擴充套件性極高的動態 Web 應用、Web 服務和動態閘道器。
OpenResty® 的目標是讓你的Web服務直接跑在 Nginx 服務內部,充分利用 Nginx 的非阻塞 I/O 模型,不僅僅對 HTTP 客戶端請求,甚至於對遠端後端諸如 MySQL、PostgreSQL、Memcached 以及 Redis 等都進行一致的高效能響應。

可以簡單理解為,OpenResty = Nginx + Lua, 通過 Lua 擴充套件 Nginx 實現的可伸縮的 Web 平臺
。它利用了 Nginx 的高效能,又在其基礎上添加了 Lua 的指令碼語言來讓 Nginx 也具有了動態的特性。通過 OpenResty 中 lua-Nginx-module 模組中提供的 Lua API,我們可以動態地控制路由、上游、SSL 證書、請求、響應等。甚至可以在不重啟 OpenResty 的前提下,修改業務的處理邏輯,並不侷限於 OpenResty 提供的 Lua API。

關於靜態和動態有一個很合適的類比:如果把 Web 伺服器當做是一個正在高速公路上飛馳的汽車,Nginx 需要停車才能更換輪胎,更換車漆顏色,而 OpenResty 中可以邊跑邊換輪胎,更換車漆,甚至更換髮動機,直接讓普通的汽車變成超跑!

除了以上的動態性,還有兩個特性讓 OpenResty 獨出一格。

1、詳盡的文件和測試用例

作為開源專案,文件和測試毫無疑問是其是否靠譜的關鍵,它的文件非常詳細,作者把每個注意的點都寫在文件上了,多數時候只要看文件即可,每一個測試案例都包含完整的 Nginx 配置和 lua 程式碼。以及測試的輸入資料和預期的輸出資料。

2、同步非阻塞

OpenResty 在誕生之初就支援了協程,並且基於此實現了同步非阻塞的程式設計模型。
畫外音:協程(coroutine)我們可以將它看成一個使用者態的執行緒,只不過這個執行緒是我們自己排程的,而且不同協程的切換不需要陷入核心態,效率比較高。(一般我們說的執行緒是要指核心態執行緒,由核心排程,需要從使用者空間陷入核心空間,相比協程,對效能會有不小的影響)

啥是同步非阻塞呢。假設有以下兩個兩行程式碼:

localres,err=query-mysql(sql)
localvalue,err=query-redis(key)

同步:必須執行完查詢 mysql,才能執行下面的 redis 查詢,如果不等 mysql 執行完成就能執行 redis 則是非同步。

阻塞:假設執行 sql 語句需要 1s,如果在這 1s 內,CPU 只能乾等著不能做其它任何事,那就是阻塞,如果在 sql 執行期間可以做其他事(注意由於是同步的,所以不能執行以下的 redis 查詢),則是非阻塞。

同步關注的是語句的先後執行順序,如果上一個語句必須執行完才能執行下一個語句就是同步,如果不是,就是非同步,阻塞關注的是執行緒是 CPU 是否需要在 IO 期間乾等著,如果在 IO(或其他耗時操作期間)期間可以做其他事,那就是非阻塞,不能動,則是阻塞。

那麼 OpenResty 的工作原理是怎樣的呢,又是如何實現同步非阻塞的呢。

OpenResty 原理剖析

工作原理剖析

由於 OpenResty 基於 Nginx 實現的,我們先來看看 Nginx 的工作原理

Nginx 啟動後,會有一個 master 程序和多個 worker 程序 , master 程序接受管理員的訊號量(如 Nginx -s reload, -s stop)來管理 worker 程序,master 本身並不接收 client 的請求,主要由 worker 程序來接收請求,不同於 apache 的每個請求會佔用一個執行緒,且是同步IO,Nginx 是非同步非阻塞的,每個 worker 可以同時處理的請求數只受限於記憶體大小,這裡就要簡單地瞭解一下 nginx 採用的 epoll 模型:

epoll 採用多路複用模型,即同一時間雖然可能會有多個請求進來, 但只會用一個執行緒去監視,然後哪個請求資料準備好了,就呼叫相應的執行緒去處理,就像圖中所示,同撥開關一樣,同一時間只有一個執行緒在處理, epoll 是基於事件驅動模型的,每個請求進來註冊事件並註冊 callback 回撥函式,等資料准入好了,就呼叫回撥函式進行處理,它是非同步非阻塞的,所以效能很高。

打個簡單的比方,我們都有訂票的經驗,當我們委託酒店訂票時,接待員會先把我們的電話號碼和相關資訊等記下來(註冊事件),結束通話電話後接待員在操作期間我們就可以去做其他事了(非阻塞),當接待員把手續搞好後會主動打電話給我們通知我們票訂好了(回撥)。

worker 程序是從 master fork 出來的,這意味著 worker 程序之間是互相獨立的,這樣不同 worker 程序之間處理併發請求幾乎沒有同步鎖的限制,好處就是一個 worker 程序掛了,不會影響其他程序,我們一般把 worker 數量設定成和 CPU 的個數,這樣可以減少不必要的 CPU 切換,提升效能,每個 worker 都是單執行緒執行的。
那麼 LuaJIT 在 OpenResty 架構中的位置是怎樣的呢。

首先啟動的 master 程序帶有 LuaJIT 的機虛擬,而 worker 程序是從 master 程序 fork 出來的,在 worker 內程序的工作主要由 Lua 協程來完成,也就是說在同一個 worker 內的所有協程,都會共享這個 LuaJIT 虛擬機器,每個 worker 程序裡 lua 的執行也是在這個虛擬機器中完成的。

同一個時間點,worker 程序只能處理一個使用者請求,也就是說只有一個 lua 協程在執行,那為啥 OpenResty 能支援百萬併發請求呢,這就需要了解 Lua 協程與 Nginx 事件機制是如何配合的了。

如圖示,當用 Lua 呼叫查詢 MySQL 或 網路 IO 時,虛擬機器會呼叫 Lua 協程的 yield 把自己掛起,在 Nginx 中註冊回撥,此時 worker 就可以處理另外的請求了(非阻塞),等到 IO 事件處理完了, Nginx 就會呼叫 resume 來喚醒 lua 協程。

事實上,由 OpenResty 提供的所有 API,都是非阻塞的,下文提到的與 MySQL,Redis 等互動,都是非阻塞的,所以效能很高。

OpenResty 請求生命週期

Nginx 的每個請求有 11 個階段,OpenResty 也有11 個 *_by_lua 的指令,如下圖示:

各個階段 *_by_lua 的解釋如下

set_by_lua:設定變數;
rewrite_by_lua:轉發、重定向等;
access_by_lua:准入、許可權等;
content_by_lua:生成返回內容;
header_filter_by_lua:應答頭過濾處理;
body_filter_by_lua:應答體過濾處理;
log_by_lua:日誌記錄。

這樣分階段有啥好處呢,假設你原來的 API 請求都是明文的

#明文協議版本
location/request{
content_by_lua'...';#處理請求
}

現在需要對其加上加密和解密的機制,只需要在 access 階段解密, 在 body filter 階段加密即可,原來 content 的邏輯無需做任務改動,有效實現了程式碼的解藕。

#加密協議版本
location/request{
access_by_lua'...';#請求體解密
content_by_lua'...';#處理請求,不需要關心通訊協議
body_filter_by_lua'...';#應答體加密
}

再比如我們不是要要上文提到閘道器的核心功能之一不是要監控日誌嗎,就可以統一在 log_by_lua 上報日誌,不影響其他階段的邏輯。

worker 間共享資料的利器: shared dict

worker 既然是互相獨立的程序,就需要考慮其共享資料的問題, OpenResty 提供了一種高效的資料結構: shared dict ,可以實現在 worker 間共享資料,shared dict 對外提供了 20 多個 Lua API,都是原子操作的,避免了高併發下的競爭問題。

路由策略外掛化實現

有了以上 OpenResty 點的鋪墊,來看看上文提的閘道器核心功能 「路由策略外掛化」,「後端叢集的動態變更」如何實現

首先針對某個請求的路由策略大概是這樣的

整個外掛化的步驟大致如下

1、每條策略由 url ,action, cluster 等組成,代表請求 url 在打到後端叢集過程中最終經歷了哪些路由規則,這些規則統一在我們的路由管理平臺配置,存在 db 裡。

2、OpenResty 啟動時,在請求的 init 階段 worker 程序會去拉取這些規則,將這些規則編譯成一個個可執行的 lua 函式,這一個個函式就對應了一條條的規則。

需要注意的是為了避免重複去 MySQL 中拉取資料,某個 worker 從 MySQL 拉取完規則(此步需要加鎖,避免所有 worker 都去拉取)或者後端叢集等配置資訊後要將其儲存在 shared dict 中,這樣之後所有的 worker 請求只要從 shared dict 中獲取這些規則,然後將其對映成對應模組的函式即可,如果配置規則有變動呢,配置後臺通過介面通知 OpenResty 重新載入一下即可

經過路由規則確定好每個請求對應要打的後端集群后,就需要根據 upstream 來確定最終打到哪個叢集的哪臺機器上,我們看看如何動態管理叢集。

後端叢集的動態配置

在 Nginx 中配置 upstream 的格式如下

upstreambackend{
serverbackend1.example.comweight=5;
serverbackend2.example.com;
server192.0.0.1backup;
}

以上這個示例是按照權重(weight)來劃分的,6 個請求進來,5個請求打到 backend1.example.com, 1 個請求打到 backend2.example.com,如果這兩臺機器都不可用,就打到 192.0.0.1,這種靜態配置的方式 upstream 的方式確實可行,但我們知道機器的擴縮容有時候比較頻繁,如果每次機器上下線都要手動去改,並且改完之後還要重新去 reload 無疑是不可行的,出錯的概率很大,而且每次配置都要 reload 對效能的損耗也是挺大的,為了解決這個問題,OpenResty 提供了一個 dyups 的模組來解決此問題, 它提供了一個 dyups api,可以動態增,刪,建立 upsteam,所以在 init 階段我們會先去拉取叢集資訊,構建 upstream,之後如果叢集資訊有變動,會通過如下形式呼叫 dyups api 來更新 upstream

--動態配置upstream介面站點
server{
listen127.0.0.1:81;
location/{
dyups_interface;
}
}


--增加upstream:user_backend
curl-d"server10.53.10.191;"127.0.0.1:81/upstream/user_backend

--刪除upstream:user_backend
curl-i-XDELETE127.0.0.1:81/upstream/user_backend

使用 dyups 就解決了動態配置 upstream 的問題

閘道器最終架構設計圖

通過這樣的設計,最終實現了閘道器的配置化,動態化。

總結

閘道器作為承載公司所有流量的入口,對效能有著極高的要求,所以技術選型上還是要慎重,之所以選擇 OpenResty,一是因為它高效能,二是目前也有小米,阿里,騰訊等大公司在用,是久經過市場考驗的,本文通過對閘道器的總結簡要介紹了 OpenResty 的相關知識點,相信大家對其主要功能點應該有所瞭解了,不過 OpenResty 的知識點遠不止以上這些,大家如有興趣,可以參考文末的學習教程深入學習,相信大家會有不少啟發的。

題外話

「碼海」歷史精品文章已經做成電子書了,如有需要歡迎新增我的個人微信,除了發你電子書外,也可以加入我的讀者群,一起探討問題,共同進步!裡面有不少技術總監等大咖,相信不管是職場進階也好,個人困惑也好,都能對你有所幫助哦

巨人的肩膀

  • 談談微服務中的 API 閘道器 https://www.cnblogs.com/savorboard/p/api-gateway.html
  • Openresty動態更新(無reload)TCP Upstream的原理和實現 https://developer.aliyun.com/article/745757
  • http://www.ttlsa.com/Nginx/Nginx-modules-ngx_http_dyups_module/
  • 極客時間 OpenResty 從入門到實戰