UPYUN基於ngx_lua的動態服務路由方案
極牛技術實踐分享活動
極牛技術實踐分享系列活動是極牛聯合頂級VC、技術專家,為企業、技術人提供的一種系統的線上技術分享活動。
每期不同的技術主題,和行業專家深度探討,專注解決技術實踐難點,推動技術創新,每週三20點正式開課。歡迎各個機構、企業、行業專家、技術人報名參加。
嘉賓介紹
葉靖
葉靖,UPYUN 平臺開發部系統開發工程師,主要負責 UPYUN 雲處理平臺的設計和開發工作,在 Nginx 和 ngx_lua 模組的開發上有較多經驗。熱衷於參與開源社群分享開源經驗。
Nginx 以其出色的效能和穩定性,被廣泛應用於提供反向代理或負載均衡服務。但是,由於 Nginx 開源版本並未提供動態的 upstream 更新介面,當上遊伺服器叢集需要調整時,只能通過修改 Nginx 的配置檔案,再對 Nginx 進行 reload 操作來使新的配置檔案生效。
Nginx 以其出色的效能和穩定性,被廣泛應用於提供反向代理或負載均衡服務。但是,由於 Nginx 開源版本並未提供動態的 upstream 更新介面,當上遊伺服器叢集需要調整時,只能通過修改 Nginx 的配置檔案,再對 Nginx 進行 reload 操作來使新的配置檔案生效。
Reload 操作雖然不會影響請求,但本質上是一個方便運維的特性,對業務並不是很友好。UPYUN 通過 ngx_lua 在 Nginx 內部實現了一個動態 upstream 更新介面,只需要通過 HTTP 給 Nginx 傳送一個更新指令,就能動態地對 upstream 進行調整,並不需要修改配置檔案或進行 reload 操作,大大減小了 upstream 的維護成本和更新時間,為叢集動態擴容奠定了基礎。
01/更新 Nginx upstream 的流程
在開發過種中,肯定碰到過要更新 nginx upstream 的情況。常規的更新 Nginx upstream 的流程是:
1. 修改 Nginx 配置檔案,增加或刪除相關 upstream 中的 server。
2. 通過 kill -s HUP 傳送 reload 訊號給 nginx 對配置檔案重新進行載入。
Nginx 在收到 reload 訊號後會新起一批 worker 程序並載入新的配置檔案處理新的請求,舊的 worker 程序在處理完當前連線的請求後退出,這樣,新的配置檔案就生效了。
Nginx 的 reload 是不會對請求造成影響的,因為舊的 worker 程序並沒有立即退出,仍然在為舊的連線服務。
這個特性對於有計劃的擴容或升級是非常方便的,但同時也會帶來以下幾個問題:
1. 舊程序完全退出時間不確定。
舊的 worker 程序在收到 reload 訊號後會處於 shutting down 狀態,但是受長連線的影響,無法確定舊程序的準確退出時間。
這會帶來什麼問題呢?比如上游某臺服務掛了,需要立即從叢集摘掉,那麼當你修改完配置並 reload 之後,舊程序還是會產生部分 502。
2. 程序內的快取會失效。
UPYUN 大量採用 ngx_lua 在 Nginx 內部完成一些通用的操作,比如許可權認證、頻率限制、引數檢查等。
這些操作需要的帳號、元資料等資訊會以 LRU 的方式快取在程序內部。如果程序被重啟,這些快取將會失效,對於資料庫將會是一個衝擊。
當然,這個問題是容易解決的,比如將快取設計為兩層,程序內 LRU 一層,共享記憶體裡一層。
這樣當 worker 程序重啟後還可以從共享記憶體取到快取的資料(共享記憶體在 reload 後資料並不會清除),此時只需要做一些反序列化(共享記憶體不能存數結構物件,只能存位元組碼)操作就可以了。
3. 更新指令碼複雜,容易出錯。如果要求更新時服務沒有 downtime, 那麼就需要先在 Nginx 中移除一臺待更新的服務,等這臺服務更新完成後,再把剛剛移掉的那臺加回到 Nginx upstream 列表。如此迴圈直到所有上游服務更新完畢。這種需求通常需要運維指令碼來完成,但是指令碼對檔案格式要求很高,很容易出錯。
對於一個可以由程式自動控制的動態擴容叢集,Nginx 的 reload 是無法滿足要求的。所以,很多公司採用域名替換 upstream 裡的 ip 地址(Nginx 支援 upstream 域名解析),然後再搭建一個內部的 DNS 服務來把 upstream 裡的域名解析到服務地址。
這樣,當叢集調整時,只需要更改 DNS 解析就可以了,不需要對 Nginx 進行 reload 操作,也就不存在 reload 帶來的問題。
UPYUN 並沒有採用 DNS 的方案,主要原因不是因為多了一層解析時間,也不是因為內部 DNS 的維護成本,而是因為 DNS 無法改變 upstream 的埠號。UPYUN 的服務都是基於 Docker 容器封裝的,服務執行的宿主機 IP 和埠都不確定,所以,DNS 的方案無法滿足。
02/UPYUN基於 ngx_lua 的動態服務路由方案
UPYUN 針對以上原因並結合自身需求,開發了基於 ngx_lua 的動態服務路由方案,以下是系統架構圖:
上圖中的 Slardar 即 UPYUN 動態服務路由專案的內部代號,是一個基於 Nginx 和 ngx_lua 模組開發的專案。Consul 是一個開源的分散式配置管理或服務發現數據庫(類似於 etcd、zookeeper)。NSQ 是一個開源的訊息佇列。image, audio, video, zip 等都是基於 Docker 的服務或消費者。
從圖中可以看到,Slardar 的路由分為同步請求和非同步請求兩部分。
非同步請求的處理相對簡單,Slardar 將會在通過引數檢查之後將處理任務放入 NSQ 佇列,接下去的工作就完全交給各種消費都來完成了。
非同步請求的擴容也非常簡單,只需要起新的消費者監聽到 NSQ 佇列就行了,在消費者異常時也可以直接 kill 掉,NSQ 會將沒有 commit 的任務傳送給其它消費者重新消費。
可以看到,在非同步處理時,Slardar 的 upstream 只要填寫 NSQ 的地址就可以了,而 NSQ 擁有很好的效能,一般不需要進行 NSQ 的擴容操作。
接下去我們來介紹 Slardar 對同步請求的處理。
上圖為 Slardar 處理同步請求的流程圖。Slardar 對於同步請求的動態服務路由主要體現在兩部分:
1. 支援新的服務型別不需要更新 Slardar。2. 更新 upstream 列表不需要更新 Slardar。
Slardar 會根據 HTTP 請求頭的 Host 域來區分不同的服務,比如 Host 是 image 就會路由到圖片處理叢集,是 audio 就會路由到音訊處理叢集。
在拿到 Host 之後,Slardar 會從 Consul 載入與 Host 對應的引數檢查程式碼。
如果引數檢查失敗則會直接給下游返回相應的錯誤碼;如果引數檢查正確則根據 Host 找到相應的 upstream 列表,把請求轉發給上游服務並等待處理完成返回響應。
這樣,當需要新增一種新的服務型別時,只需要指定一個新的 Host 名字,並動態地加上這個 Host 對應的 upstream 列表和引數檢查程式碼(可選),Slardar 就會把請求路由到新的服務叢集中去。
與其它負載均衡器不同的是,Slardar 會在接到請求後從外部儲存動態載入 Lua 程式碼做一些引數檢查和改寫等操作。
這樣做的好處是可以對請求做出非常靈活的控制(例如臨時禁掉某個惡意空間或對引數進行 rewrite 等),並且能夠將很多非法請求擋在外面,節省內網流量。
同時也正因為這個特性,Slardar 不適合非常頻繁的 reload 操作,因為當 reload 導致 worker 程序內編譯好的 lua 程式碼失效時,重新從共享記憶體甚至外部的 Consul 載入 lua 程式碼並編譯是非常耗時的操作。
Lua 程式碼的動態載入主要是通過 Lua 的 loadstring 和 setfenv 函式來完成的,有興趣的同學可以參考 Lua 的官方文件。
下面我們來看一下 Slardar 是如何實現動態的 upstream 更新的。以下是 upstream 管理相關的流程圖:
首先,Slardar 會監聽一個管理埠(比如 8080)用於接收指令、檢視一些 Nginx 內部狀態等資訊。當接收到 upstream 更新指令時,Slardar 會讀取更新指令引數中的 upstream 名字和新的服務列表,把這些資訊寫在共享記憶體裡。
為什麼要存在共享記憶體而不直接更新呢?因為收到更新指令的只是 nginx 程序的其中一個 worker,worker 只能更新自身程序的 upstream 列表而其它 worker 程序的列表是無法改變的。
為了解決這個問題,Slardar 在 Nginx 初始化 worker 程序的時候(對應 ngx_lua 的 init_worker_by_lua* 指令)為每個 worker 啟動了一個定時器,該定時器每隔一秒鐘會檢查共享記憶體裡的 upstream 列表是否有變動,如果有則同步到自身程序內。這種做法其實也是 Nginx 各 worker 間常用的通訊方法。
至於 worker 程序如何更新自身的 upstream 列表,有很多種做法。
UPYUN 採用內部的 lua-resty-checkups 模組(即將開源),可以在共享記憶體中維護 upstream 列表和健康狀況等資訊。淘寶開源的 Tengine 也有類似的 C 模組實現方案,這裡不再展開。
至此,我們已經完成了 upstream 的動態更新,但還有一個問題沒有解決:假如 Nginx 被 reload 了,之前通過 HTTP 介面動態更新的列表不是都消失了嗎?
UPYUN 通過兩步解決這個問題:
1. 傳送更新指令給 Slardar 之後,再把新的 upstream 列表儲存到 Consul 進行持久化。這個操作可以由指令發起者呼叫 Consul 提供的 API 來完成。
2. Slardar 初始化(對應 ngx_lua 的 init_by_lua* 指令)的時候從 Consul 載入 upstream 列表,而不是從 Nginx 配置檔案。
熟悉 ngx_lua 的同學可能會發現,在 init_by_lua* 階段(也就是 Nginx master 程序初始化的階段)是沒辦法呼叫 cosocket 的,也就是沒辦法發起非阻塞的 HTTP 請求從 Consul 讀取資料了。
因為那時候連 Nginx 自身的 connection 都沒有初始化完畢。
那麼我們只能退而求其次,通過 luasocket 來發起阻塞的 HTTP 請求來從 Consul 載入 upstream 列表,幸好是在 init 階段,阻塞的呼叫也不會對之後的請求造成任何影響。
值得一提的是,利用 Consul 的 watch 特性,可以監聽 Consul 中某個 upstream 列表,如果有更新,可以觸發一個指令碼自動將新的 upstream 列表更新到 Slardar。這樣我們只要維護 Consul 中的 upstream 列表就可以了。
以上就是 UPYUN 基於 ngx_lua 的動態服務路由方案的實現流程。