在 Nginx 裡面,每個 worker 程式都是平等的。但是有些時候,我們需要給它們分配不同的角色,這時候就需要實現程式間通訊的功能。
輪詢
一種簡單粗暴但卻被普遍使用的方案,就是每個程式劃分屬於自己的 list 型別的 shdict key,每隔一段時間檢視是否有新訊息。這種方式優點在於實現簡單,缺點在於難以保證實時性。當然對於絕大多數需要程式間通訊的場景,每 0.1 起一個 timer 來處理新增訊息已經足夠了。畢竟 0.1 秒的延遲不算長,每秒起 10 個 timer 開銷也不大,應付一般的通訊量綽綽有餘。
redis外援
要是你覺得輪詢很搓,或者在你的環境下,輪詢確實很搓,也可以考慮下引入外部依賴來改善實時性。比如在本地起一個 redis,監聽 unix socket,然後每個程式通過 Pub/Sub 或者 stream 型別釋出/獲取最新的訊息。這種方案實現起來也簡單,實時性和效能也足夠好,只是需要引入個 redis 服務。
ngx_lua_ipc
如果你是個極簡主義者,對引入外部依賴深惡痛絕,希望什麼東西都能在 Nginx 裡面實現的話,ngx_lua_ipc 是一個被廣泛使用的選擇。
ngx_lua_ipc
是一個第三方 Nginx C 模組,提供了一些 Lua API,可供在 OpenResty 程式碼裡完成程式間通訊(IPC)的操作。
它會在 Nginx 的 init 階段建立 worker process + helper process 對 pipe fd。每對 fd 有一個作為 read fd,負責接收資料,另一個作為 write fd,用於傳送資料。當 Nginx 建立 worker 程式時,每個 worker 程式都會繼承這些 pipe fd,於是就能通過它們來實現程式間通訊。感興趣的讀者可以 man 7 pipe
一下,瞭解基於 pipe 的程式間通訊是怎麼實現的。
當然 ngx_lua_ipc
還需要把 pipe 的 read fd 通過 ngx_connection_t
接入到 Nginx 的事件迴圈機制中,具體實現位於 ipc_channel_setup_conn
:
c = ngx_get_connection(chan->pipe[conn_type == IPC_CONN_READ ? 0 : 1], cycle->log);
c->data = data;
if(conn_type == IPC_CONN_READ) {
c->read->handler = event_handler;
c->read->log = cycle->log;
c->write->handler = NULL;
ngx_add_event(c->read, NGX_READ_EVENT, 0);
chan->read_conn=c;
}
else if(conn_type == IPC_CONN_WRITE) {
c->read->handler = NULL;
c->write->log = cycle->log;
c->write->handler = ipc_write_handler;
chan->write_conn=c;
}
else {
return NGX_ERROR;
}
return NGX_OK;
write fd 是由 Lua 程式碼操作的,所以不需要加入到 Nginx 的事件迴圈機制中。
有一點有趣的細節,pipe fd 只有在寫入資料小於 PIPE_BUF
時才會保證寫操作的原子性。如果一條訊息超過 PIPE_BUF
(在 Linux 上大於 4K),那麼它的寫入就不是原子的,可能寫入前面 PIPE_BUF
之後,有另一個 worker 也正巧給同一個程式寫入訊息。
為了避免不同 worker 程式的訊息串在一起,ngx_lua_ipc
定義了一個 packet 概念。每個 packet 都不會大於 PIPE_BUF
,同時有一個 header 來保證單個訊息分割成多個 packet 之後能夠被重新打包回來。
在接收端,為了能在收到訊息之後執行對應的 Lua handler,ngx_lua_ipc
使用了 ngx.timer.at
來執行一個函式,這個函式會根據訊息型別分發到對應的 handler 上。這樣有個問題,就是訊息是否能完成投遞,取決於 ngx.timer.at
能否被執行。而 ngx.timer.at
是否被執行受限於兩個因素:
- 如果
lua_max_pending_timer
不夠大,ngx.timer.at
可能無法建立 timer - 如果
lua_max_running_timer
不夠大,或者沒有足夠的資源執行 timer,ngx.timer.at
建立的 timer 可能無法執行。
事實上,如果 timer 無法執行(訊息無法投遞),現階段的 OpenResty 可能不會記錄錯誤日誌。我之前提過一個記錄錯誤日誌的 PR:https://github.com/openresty/...,不過一直沒有合併。
所以嚴格意義上, ngx_lua_ipc
並不能保證訊息能夠被投遞,也不能在訊息投遞失敗時報錯。不過這個鍋得讓 ngx.timer.at
來背。
ngx_lua_ipc
能不能不用 ngx.timer.at
那一套呢?這個就需要從 lua-nginx-module 裡複製一大段程式碼,並偶爾同步一下。複製貼上乃 Nginx C 模組開發的奧義。
動態監聽 unix socket
上面的方法中,除了 Redis 外援法,如果不在應用程式碼里加日誌,要想在外部檢視訊息投遞的過程,只能依靠 gdb/systemtap/bcc 這些大招。如果走網路連線,就能使用平民技術,如 tcpdump,來追蹤訊息的流動。當然如果是 unix socket,還需要臨時搞個 TCP proxy 整一下,不過操作難度較前面的大招們已經大大降低了。
那有沒有辦法讓 IPC 走網路,但又不需要藉助外部依賴呢?
回想起 Redis 外援法,之所以我們不能直接走 Nginx 的網路請求,是因為 Nginx 裡面每個 worker 程式是平等的,你不知道你的請求會落到哪個程式上,而請求 Redis 就沒這個問題。那我們能不能讓不同的 worker 程式動態監聽不同的 unix socket?
答案是肯定的。我們可以實現類似於這樣的介面:
ln = ngx.socket.listen(...)
sock = ln.accept()
sock:read(...)
曾經有人提過類似的 PR:https://github.com/openresty/...,我自己也在公司專案裡實現過差不多的東西。宣告下,不要用這個方法做 IPC。上面的實現有個致命的問題,就是 ln 和後面建立的所有的 sock,都是在同一個 Nginx 請求裡面的。
我曾經寫過,在一個 Nginx 請求裡做太多的事情,會有資源分配上的問題:https://segmentfault.com/a/11...
後面隨著 IPC 的次數的增加,這種問題會越發明顯。
要想解決這個問題,我們可以把每個 sock 放到獨立的 fake request 裡面跑,就像這樣:
ln = ngx.socket.listen(...)
-- 類似於 ngx.timer.at 的處理風格
ln.register_handler(function(sock)
sock:read(...)
end)
但是還有個問題。如果用 worker id 作為被監聽的 unix socket 的 ID, 由於這個 unix socket 是在 worker 程式裡動態監聽的,而在 Nginx reload 或 binary upgrade 的情況下,多個 worker 程式會有同樣的 worker id,嘗試監聽同樣的 unix socket,導致地址被佔用的錯誤。解決方法就是改用 PID 作為被監聽的 unix socket 的 ID,然後在首次傳送時初始化 PID 到 worker id 的對映。如果有支援在 reload 時正常傳送訊息的需求,還要記錄新舊兩組 worker,比如:
1111 => old worker ID 1
1123 => new worker ID 2
每個 worker 分配不同的 unix socket
還有一種更為巧妙的,藉助不同 worker 不同 unix socket 來實現程式間通訊的方法。這種方法是如此地巧妙,我只恨不是我想出來的。該方法可以淘汰掉上面動態監聽 unix socket 的方案。
我們可以在 Nginx 配置檔案裡面宣告,listen unix:xxx.sock use_as_ipc_blah_blah
。然後修改 Nginx,讓它在看到 use_as_ipc_blah_blah
差不多這樣的一個標記時,讓特定的程式監聽特定的 unix sock,比如 xxx_1.sock
、xxx_2.sock
等。
它跟動態監聽 unix socket 方法比起來,實現更為簡單,所以也更為可靠。當然要想保證在 reload 或者 binary upgrade 時投遞訊息到正確的 worker,記得用 PID 而不是 worker id 來作為區分字尾,並維護好兩者間的對映。
這個方法是由 datavisor 的同行提出來的,估計最近會開源出來。