OpenResty 最佳實踐 (2)

網易雲社群發表於2019-03-02

此文已由作者湯曉靜授權網易雲社群釋出。

歡迎訪問網易雲社群,瞭解更多網易技術產品運營經驗。

lua 協程與 nginx 事件機制結合

文章前部分用大量篇幅闡述了 lua 和 nginx 的相關知識,包括 nginx 的程式架構,nginx 的事件迴圈機制,lua 協程,lua 協程如何與 C 實現互動;在瞭解這些知識之後,本節闡述 lua 協程是如何和 nginx 的事件機制協同工作。

從 nginx 的架構和事件驅動機制來看, nginx 的併發處理模型概括為:單 worker + 多連線 + epoll + callback。 即每個 nginx worker 同時處理了大量連線,每個連線對應一個 http 請求,一個 http 請求對應 nignx 中的一個結構體(ngx_http_request_t):

struct ngx_http_request_s {
    uint32_t                          signature;         /* "HTTP" */

    ngx_connection_t                 *connection;    void                            **ctx;    void                            **main_conf;    void                            **srv_conf;    void                            **loc_conf;

    ngx_http_event_handler_pt         read_event_handler;
    ngx_http_event_handler_pt         write_event_handler;

    ....
}複製程式碼

結構體中的核心成員為 ngx_connection_t *connection,其定義如下:

struct ngx_connection_s {    void               *data;
    ngx_event_t        *read;      // epoll 讀事件對應的結構體成員
    ngx_event_t        *write;     // epoll 寫事件對應的結構體成員

    ngx_socket_t        fd;        // tcp 對應的 socket fd

    ngx_recv_pt         recv;
    ngx_send_pt         send;
    ngx_recv_chain_pt   recv_chain;
    ngx_send_chain_pt   send_chain;

    ngx_listening_t    *listening;

    ...
}複製程式碼

從如上結構體可知,每個請求中對應的 ngx_connection_t 中的讀寫事件和 epoll 關聯;nginx epoll 的事件處理核心程式碼如下:

    ...

    events = epoll_wait(ep, event_list, (int) nevents, timer);    for (i = 0; i < events; i++) {
        c = event_list[i].data.ptr;

        instance = (uintptr_t) c & 1;
        c = (ngx_connection_t *) ((uintptr_t) c & (uintptr_t) ~1); // epoll 獲取啟用事件,將事件轉換成 ngx_connection_t

        ...

        rev = c->read;
        rev->handler(rev);

        ...

        wev = c->write;
        wev->handler(ev);

        ...
    }複製程式碼

nginx epoll loop 中呼叫 epoll_wait 獲取 epoll 接管的啟用事件,並通過 c 的指標強轉,得到 ngx_connection_t 獲取對應的連線和連線上對應的讀寫事件的回撥函式,即通過 C 結構體變數成員之間的相關關聯來串聯請求和事件驅動,實現請求的併發處理;這裡其實和高階語言的物件導向的寫法如出一轍,只是模組和成員變數之間的獲取方式的差異。

如果引入 lua 的協程機制,在 lua 程式碼中出現阻塞的時候,主動呼叫 coroutine.yield 將自身掛起,待阻塞操作恢復時,再將掛起的協程呼叫 coroutine.resume 恢復則可以避免在 lua 程式碼中寫回撥;而何時恢復協程可以交由 c 層面的 epoll 機制來實現,則可以實現事件驅動和協程之間的關聯。現在我們只需要考慮,如何將 lua_State 封裝的 lua land 和 C land 中的 epoll 機制融合在一起。

事實上 lua-nginx-module 確實是按照這種方式來處理協程與 nginx 事件驅動之間的關係,lua-nginx-module 為每個 nginx worker 生成了一個 lua_state 虛擬機器,即每個 worker 繫結一個 lua 虛擬機器,當需要 lua 指令碼介入請求處理流程時,基於 worker 繫結的虛擬機器建立 lua_coroutine 來處理邏輯,當阻塞發生、需要掛起時或者處理邏輯完成時掛起自己,等待下次 epoll 排程時再次喚醒協程執行。如下是 rewrite_by_lua 核心程式碼部分:

tatic ngx_int_tngx_http_lua_rewrite_by_chunk(lua_State *L, ngx_http_request_t *r){
    co = ngx_http_lua_new_thread(r, L, &co_ref);

    lua_xmove(L, co, 1);
    ngx_http_lua_get_globals_table(co);
    lua_setfenv(co, -2);

    ngx_http_lua_set_req(co, r);       // 此處設定協程與 ngx_http_request_t 之間的關係

    ...

    rc = ngx_http_lua_run_thread(L, r, ctx, 0);  // 執行 lua 指令碼處理 rewrite 邏輯

    if (rc == NGX_ERROR || rc > NGX_OK) {        return rc;
    }

    ...
}複製程式碼

從上述程式碼片段中我們看到了協程與 ngx 請求之間的繫結關係,那麼只要在 ngx_http_lua_run_thread 函式中(實際上是在 lua 指令碼中)處理何時掛起 lua 的執行即可。大部分時候我們在 lua 中的指令碼工作型別分兩種,一種是基於請求資訊的邏輯改寫,一種是基於 tcp 連線的後端互動。邏輯改寫往往不會發生 io 阻塞,即當前指令碼很快執行完成後回到 C land,不需要掛起再喚醒的流程。而對於方式二,lua-nginx-module 提供了 cosocket api, 它封裝了 tcp api,並且會在合適的時候(coroutine.yield 的呼叫發生在 IO 異常,讀取包體完畢,或者 proxy_buffers 已滿等情形,具體的實現讀者可以參考 ngx_http_lua_socket_tcp.c 原始碼)呼叫 coroutine.yield 方法 。 lua-corotine

綜上所述,結合lua 協程和 nginx 事件驅動機制,使用 OpenResty 可以使用 lua 指令碼方便的擴充套件 nignx 的功能。

OpenResty hooks (程式設計鉤子)

lua-resty-phase

init_by_lua

該階段主要用於預載入一些 lua 模組, 如載入全域性 json 模組:require 'cjson.safe';設定全域性的 lua_share_dict 等,並且可以利用作業系統的 copy-on-write 機制;reload nginx 會重新載入該階段的程式碼。

init_worker_by_lua

該階段可用於為每個 worker 設定獨立的定時器,設定心跳檢查等。

rewrite_by_lua

實際場景中應用最多的一個 hooks 之一,可用於請求重定向相關的邏輯,如改寫 host 頭,改寫請求引數和請求路徑等

access_by_lua

該階段可用於實現訪問控制相關的邏輯,如動態限流、限速,防盜鏈等

content_by_lua

該階段用於生成 http 請求的內容,和 proxy_pass 指令衝突;二者在同一個階段只能用一個。該階段可用於動態的後端互動,如 mysql、redis、kafaka 等;也可用於動態的 http 內容生成,如使用 lua 實現 c 的 slice 功能,完成大檔案的分片切割。

banalce_by_lua

該階段可用於動態的設定 proxy_pass 的上游地址,例如用 lua 實現一個帶監控檢測機制的一致性 hash 輪序後端演算法,根據上游的響應動態設定該地址是否可用。

body_filter_by_lua

用於過濾和加工響應包體,如對 chunk 模式的包體進行 gzip; 也可以根據包體的大小來動態設定 ngx.var.limit_rate.

header_filter_by_lua

調整傳送給 client 端的響應頭,也是最常用的 hooks 之一;比如設定響應的 server 頭,修快取頭 cache-control 等。

log_by_lua

一方面可以設定 nginx 日誌輸出的欄位值,另一方面我們也可以用 cosocket 將日誌資訊傳送到指定的 http server;因響應頭和響應體已傳送給客戶端,該階段的操作不會影響到客戶端的響應速度。

OpenResty 之 lua 編寫常見陷阱

  • elseif,區別於 else if;

  • and & or,不支援問號表示式;lua 中 0 表示 true

  • no continue,lua 中不支援 continue 語法;需要用 if 和 else 語句實現;

  • . & :,lua 中 object.method 和 object:method 行為不同,object:method 為語法糖,會擴充套件成第一個引數為 self

  • forgot return _M,在編寫模組的時候如果最後忘記 return _M, 呼叫時會提示嘗試對 string 呼叫方法的異常

OpenResty 程式設計優化

  • do local statement,儘量使用 local 化的變數宣告,加速變數索引速度的同時避免全域性名稱空間的汙染;

  • do not use blocked api,不要呼叫會阻塞 lua 協程的 api,比如 lua 原生的 socket,會造成 nginx worker block;

  • use ngx.ctx instead of ngx.var,ngx.var 會呼叫 ngx.var 的變數索引系統,比 ngx.ctx 低效很多;

  • decrease table resize,避免 lua table 表的 resize 操作,可以用 luajit 事先宣告指定大小的 table。比如頻繁的 lua 字串相加的 .. 操作,當 lua 預分配記憶體不夠時,會重新動態擴容(和 c++ vector 型別),會造成低效;

  • use lua-resty-core,使用 lua-resty-core api,該部分 api 用 luajit 的 ffi 實現比直接的 C 和 lua 互動高效;

  • use jit support function,少用不可 jit 加速的函式,那些函式不能 jit 支援,可以參看 luajit 文件。

  • ffi,對自己實現的 C 介面,也建議用 ffi 暴露出介面給 lua 呼叫。

nginx 易混易錯配置說明

so_keepalive

用於 listen 中,探測連線保活; 採用TCP連線的C/S模式軟體,連線的雙方在連線空閒狀態時,如果任意一方意外崩潰、當機、網線斷開或路由器故障,另一方無法得知TCP連線已經失效,除非繼續在此連線上傳送資料導致錯誤返回。很多時候,這不是我們需要的。我們希望伺服器端和客戶端都能及時有效地檢測到連線失效,然後優雅地完成一些清理工作並把錯誤報告給使用者。

如何及時有效地檢測到一方的非正常斷開,一直有兩種技術可以運用。一種是由TCP協議層實現的Keepalive,另一種是由應用層自己實現的心跳包。

TCP預設並不開啟Keepalive功能,因為開啟 Keepalive 功能需要消耗額外的寬頻和流量,儘管這微不足道,但在按流量計費的環境下增加了費用,另一方面,Keepalive設定不合理時可能會因為短暫的網路波動而斷開健康的TCP連線。並且,預設的Keepalive超時需要7,200,000 milliseconds,即2小時,探測次數為 5 次。系統預設的 keepalive 配置如下:

net.ipv4.tcpkeepaliveintvl = 75
net.ipv4.tcpkeepaliveprobes = 5
net.ipv4.tcpkeepalivetime = 7200複製程式碼

如果在 listen 的時候不設定 so_keepalive 則使用了系統預設的 keepalive 探測保活機制,需要 2 小時才能清理掉這種異常連線;如果在 listen 指令中加入

so_keepalive=30m::10複製程式碼

可設定如果連線空閒了半個小時後每 75s 探測一次,如果超過 10 次 探測失敗,則釋放該連線。

sendfile/directio

sendfile

copies data between one file descriptor and another. Because this copying is done within the kernel, sendfile() is more efficient than the combination of read(2) and write(2), which would require transferring data to and from user space.

從 Linux 的文件中可以看出,當 nginx 有磁碟快取檔案時候,可以利用 sendfile 特性將磁碟內容直接傳送到網路卡避免了使用者態的讀寫操作。

directio

Enables the use of the O_DIRECT flag (FreeBSD, Linux), the F_NOCACHE flag (macOS), or the directio() function (Solaris), when reading files that are larger than or equal to the specified size. The directive automatically disables (0.7.15) the use of sendfile for a given request

寫檔案時不經過 Linux 的檔案快取系統,不寫 pagecache, 直接寫磁碟扇區。啟用aio時會自動啟用directio, 小於directio定義的大小的檔案則採用 sendfile 進行傳送,超過或等於 directio 定義的大小的檔案,將採用 aio 執行緒池進行傳送,也就是說 aio 和 directio 適合大檔案下載。因為大檔案不適合進入作業系統的 buffers/cache,這樣會浪費記憶體,而且 Linux AIO(非同步磁碟IO) 也要求使用directio的形式。

proxy_request_buffering

控制處理客戶端包體的行為,如果設定為 on, 則 nginx 會接收完 client 的整個包體後處理。如 nginx 作為反向代理服務處理客戶端的上傳操作,則先接收完包體再轉發給上游,這樣上游異常的時候,nginx 可以多次重試上傳,但有個問題是如果包體過大,nginx 端如果負載較重話,會有大量的寫磁碟操作,同時對磁碟的容量也有較高要求。如果設定為 off, 則傳輸變成流式處理,一個 chunk 一個 chunk 傳輸,傳輸出錯更多需要 client 端重試。

proxy_buffer_size

Sets the size of the buffer used for reading the first part of the response received from the proxied server. This part usually contains a small response header. By default, the buffer size is equal to one memory page. This is either 4K or 8K, depending on a platform.

proxy_buffers

Sets the number and size of the buffers used for reading a response from the proxied server, for a single connection. By default, the buffer size is equal to one memory page. This is either 4K or 8K, depending on a platform.

proxy_buffering

Enables or disables buffering of responses from the proxied server.

When buffering is enabled, nginx receives a response from the proxied server as soon as possible, saving it into the buffers set by the proxy_buffer_size and proxy_buffers directives. If the whole response does not fit into memory, a part of it can be saved to a temporary file on the disk. Writing to temporary files is controlled by the proxy_max_temp_file_size and proxy_temp_file_write_size directives.

When buffering is disabled, the response is passed to a client synchronously, immediately as it is received. nginx will not try to read the whole response from the proxied server. The maximum size of the data that nginx can receive from the server at a time is set by the proxy_buffer_size directive.

當 proxy_buffering on 時處理上游的響應可以使用 proxy_buffer_size 和 proxy_buffers 兩個緩衝區;而設定 proxy_buffering off 時,只能使用proxy_buffer_size 一個緩衝區。

proxy_busy_size

When buffering of responses from the proxied server is enabled, limits the total size of buffers that can be busy sending a response to the client while the response is not yet fully read. In the meantime, the rest of the buffers can be used for reading the response and, if needed, buffering part of the response to a temporary file. By default, size is limited by the size of two buffers set by the proxy_buffer_size and proxy_buffers directives.

當接收上游的響應傳送給 client 端時,也需要一個快取區,即傳送給客戶端而未確認的部分,這個 buffer 也是從 proxy_buffers 中分配,該指令限定能從 proxy_buffers 中分配的大小。

keepalive

該指令可作用於 nginx.conf 和 upstream 的 server 中;當作用於 nginx.conf 中時,表示作為 http server 端回覆客戶端響應後,不關閉該連線,讓該連線保持 ESTAB 狀態,即 keepalive。 當該指令作用於 upstrem 塊中時,表示傳送給上游的 http 請求加入 connection: keepalive, 讓服務端保活該連線。值得注意的是服務端和客戶端均需要設定 keepalive 才能實現長連線。 同時 keepalive指令需要和 如下兩個指令配合使用:

keepalive_requests 100;keepalive_timeout 65;複製程式碼

keepalive_requests 表示一個長連線可以複用的次數,keepalive_timeout 表示長連線在空閒多久後可以關閉。 keepalive_timeout 如果設定過大會造成 nginx 服務端 ESTAB 狀態的連線數增多。

nginx 維護與更新

nginx 訊號集和 nginx 操作之間的對應關係如下:

nginx operationsignal
reloadSIGHUP
reloadSIGUSR1
stopSIGTERM
quitSIGQUIT
hot updateSIGUSR2 & SIGWINCH & SIGQUIT

stop vs quit

stop 傳送 SIGTERM 訊號,表示要求強制退出,quit 傳送 SIGQUIT,表示優雅地退出。 具體區別在於,worker 程式在收到 SIGQUIT 訊息(注意不是直接傳送訊號,所以這裡用訊息替代)後,會關閉監聽的套接字,關閉當前空閒的連線(可以被搶佔的連線),然後提前處理所有的定時器事件,最後退出。沒有特殊情況,都應該使用 quit 而不是 stop。

reload

master 程式收到 SIGHUP 後,會重新進行配置檔案解析、共享記憶體申請,等一系列其他的工作,然後產生一批新的 worker 程式,最後向舊的 worker 程式傳送 SIGQUIT 對應的訊息,最終無縫實現了重啟操作。 再 master 程式重新解析配置檔案過程中,如果解析失敗則會回滾使用原來的配置檔案,即 reload 失敗,此時工作的還是老的 worker。

reopen

master 程式收到 SIGUSR1 後,會重新開啟所有已經開啟的檔案(比如日誌),然後向每個 worker 程式傳送 SIGUSR1 資訊,worker 程式收到訊號後,會執行同樣的操作。reopen 可用於日誌切割,比如 nginx 官方就提供了一個方案:

 $ mv access.log access.log.0
 $ kill -USR1 `cat master.nginx.pid`
 $ sleep 1
 $ gzip access.log.0    # do something with access.log.0複製程式碼

這裡 sleep 1 是必須的,因為在 master 程式向 worker 程式傳送 SIGUSR1 訊息到 worker 程式真正重新開啟 access.log 之間,有一段時間視窗,此時 worker 程式還是向檔案 access.log.0 裡寫入日誌的。通過 sleep 1s,保證了 access.log.0 日誌資訊的完整性(如果沒有 sleep 而直接進行壓縮,很有可能出現日誌丟失的情況)。

hot update

某些時候我們需要進行二進位制熱更新,nginx 在設計的時候就包含了這種功能,不過無法通過 nginx 提供的命令列完成,我們需要手動傳送訊號。

首先需要給當前的 master 程式傳送 SIGUSR2,之後 master 會重新命名 nginx.pid 到 nginx.pid.oldbin,然後 fork 一個新的程式,新程式會通過 execve 這個系統呼叫,使用新的 nginx ELF 檔案替換當前的程式映像,成為新的 master 程式。新 master 程式起來之後,就會進行配置檔案解析等操作,然後 fork 出新的 worker 程式開始工作。

接著我們向舊的 master 傳送 SIGWINCH 訊號,然後舊的 master 程式則會向它的 worker 程式傳送 SIGQUIT 資訊,從而使得 worker 程式退出。向 master 程式傳送 SIGWINCH 和 SIGQUIT 都會使得 worker 程式退出,但是前者不會使得 master 程式也退出。

最後,如果我們覺得舊的 master 程式使命完成,就可以向它傳送 SIGQUIT 訊號,讓其退出了。

引用


免費體驗雲安全(易盾)內容安全、驗證碼等服務


更多網易技術、產品、運營經驗分享請點選




相關文章:
【推薦】 Kylin儲存和查詢的分片問題


相關文章