我眼中的 Nginx(四):是什麼讓你的 Nginx 服務退出這麼慢?

又拍雲發表於2019-03-22
張超:又拍雲系統開發高階工程師,負責又拍雲 CDN 平臺相關元件的更新及維護。Github ID: tokers,活躍於 OpenResty 社群和 Nginx 郵件列表等開源社群,專注於服務端技術的研究;曾為 ngx_lua 貢獻原始碼,在 Nginx、ngx_lua、CDN 效能優化、日誌優化方面有較為深入的研究。

 

筆者曾今在更新 Nginx 服務的過程中發現舊的 Nginx worker 程式退出非常緩慢(舊的 worker 程式始終處在 "is shutting down" 的狀態),對此非常好奇,並對此展開了一些研究,本文將介紹 Nginx worker 程式退出時的準備步驟,延緩退出的原因,並介紹對應的解決辦法。

準備退出

當 worker 程式接收到 master 程式要求它退出的指令後(詳見筆者另一篇文章:談談 Nginx 訊號集),它便會開始為退出做準備。

首先 worker 程式會將正在監聽的套接字從事件分發器(epoll,kqueue 等)中刪除,並將它們關閉,之後它將不再處理連線事件。

接著關閉所有的空閒連線,所謂的空閒連線,指的是當前沒有請求正在使用的連線,例如 Nginx 和後端伺服器維持的長連線,或者 ngx_lua Cosocket 物件底層的長連線。

接著 worker 程式會等待所有定時器過期(ngx_lua 提供給使用者使用的定時器比較特殊,在退出階段,它會提前過期,其他的 Nginx 內部的定時器不會提前過期),並同時處理尚未完成的事件。等事件處理完畢後, worker 程式會呼叫所有模組註冊的 exit_process 鉤子,最後退出。

退出被延緩

瞭解了 worker 程式退出時的準備過程後,我們可以深入分析為什麼有的時候退出如此緩慢。

根據筆者目前的分析,目前有以下兩種情況會延緩 worker 程式的退出:

  • ngx_lua:在提前過期的定時器中使用 Cosocket
  • Nginx http/2 實現上的一個 bug

第一種情況曾有人在 ngx_lua 的 issue 頁面提出過( Cosocket :setkeepalive() in a a premature timer handler blocks Nginx worker from exiting · Issue #1279 · openresty/lua-Nginx-module)[1]。

比如 issue 中的示例程式碼:

ngx.timer.at(100, function ()
-- This blocks Nginx worker from exiting
    local timer_sock = ngx.socket.tcp()
    timer_sock:connect("127.0.0.1", 8080)
    timer_sock:setkeepalive()
end)

當然,這段程式碼省略了一些錯誤處理,但是用以解釋問題已經足夠。這段程式碼註冊了一個定時器,只要這個定時器執行,就會建立一個 Cosocket 物件,然後去連線本機的 8080 埠,然後馬上將這個物件底層的連線置為 keep alive 狀態。

先說 connect 函式,如果和對端的連線不能一次性完成,ngx_lua 會為這次連線操作新增一個定時器,用以判斷連線超時,當然這裡是連線本機的埠,因此幾乎不會出現連線超時(對端異常除外)。

假如這裡所要連線的對端處在公網,而且網路狀況不理想的話,連線超時就有可能發生了,ngx_lua 預設的 Cosocket 連線超時是 60s(lua_socket_connect_timeout),這意味著這個 worker 程式會等待至少 60s,然後再退出。

同樣地,setkeepalive 也會為這條連線設定一個超時時間,預設也是 60s( lua_socket_keepalive_timeout) ,因此 worker 程式也不得不等到這個定時器過期,或者某個時刻對端主動關閉/異常關閉這條連線後,它才能夠退出。

讀者可能會有疑惑,之前講到 worker 程式退出時會主動關閉這些空閒的長連線,那為什麼這個示例還回造成 worker 程式退出那麼慢呢?即使是本機連線,也有可能出現無法一次完成連線( EAGAIN) 的情況,此時當前定時器的 Lua 協程就會被掛起,因此當 worker 程式在關閉所有空閒連線的時候,這個示例裡 setkeepalive 是還沒被執行到的(甚至可能連線也沒有建立完成),所以這條連線在當時不是空閒的。直到後來某個時刻連線建立完成或者超時,當時的 Lua 協程重新得到執行機會,才會為這條連線新增定時器,置為空閒狀態。

另外一個阻礙 worker 程式退出的原因來自於一個 Nginx HTTP/2 模組實現上的缺陷(見 Stale workers not exiting after reload (with HTTP/2 long poll requests))[2]。這個問題在 Nginx/1.11.6 釋出之後就修復了(見 Nginx: 5e95b9fb33b7)[3],1.11.6 之前的版本,如果一個 HTTP/2 協議的客戶端一直在開啟新的流,會導致這條連線上一直有事件在處理(當然會伴隨著建立定時器),這會導致 worker 程式會一直無法退出,直到這條連線斷開。

Nginx 支援透明代理 websocket 連線。在 Nginx/1.13.7 版本以前,如果 worker 程式存在一些 websocket 連線,而且連線上經常有資料傳送,使得連線一直在正常工作的話,即使 worker 程式收到來自 master 的退出指令,它也無法立刻退出,它需要等到這些連線出現異常、超時或者是某一端主動斷開後,才能正常退出。

shutdown timeout

舊 worker 程式不能及時退出,就會一直佔用著系統資源(CPU、記憶體和檔案描述符等),這對系統資源是一種浪費,因此 Nginx/1.11.11 加入了一個新的指令(即 worker_shutdown_timeout,見 Core functionality)[4],允許使用者自定義 shutdown 超時時間,如果一個 worker 在接收到退出的指令後經過 worker_shutdown_timeout 時長後還不能退出,就會被強制退出。

它的實現原理(Nginx: 97c99bb43737)[5]也是通過建立定時器來實現的,一旦定時器過期, 所有連線都會被設定為 close 和 error 狀態(c->error = 1,c->close = 1),這個標誌位事實上意味著 TCP 連線異常,Nginx 設計上對於這種狀態的連線,都會立刻結束對應的所有請求、事件。通過這樣一個標誌位的設定,就達到了強制關閉所有連線、刪除所有定時器的目的,最終及時退出舊的 worker 程式,釋放系統資源。

雖然這個功能早在 Nginx/1.11.11 就加入了,但是沒有完全覆蓋到所有的情況,例如上文所述的 websocket 連線的處理,那部分程式碼並沒有判斷 c->close 和 c->error 的狀態位。所以仍然無法儘快終止這些 websocket 連線。直到 Nginx/1.13.7,這個問題才被修復。所以如果讀者們遇到類似的問題,可以考慮升級 Nginx 至少到 1.13.7 版本。


[1] issue 頁面: 

[2] 缺陷: 

[3] 修復: 

[4] 指令: 

[5] 原理: 

 

《我眼中的 Nginx》系列:

我眼中的 Nginx(三):Nginx 變數和變數插值
我眼中的 Nginx(二):HTTP/2 dynamic table size update
我眼中的 Nginx(一):Nginx 和位運算

相關文章