從實戰出發,談談 nginx 訊號集

又拍雲發表於2017-11-29

前言

之前工作時候,一臺引流測試機器的一個 ngx_lua 服務突然出現了一些 HTTP/500 響應,從錯誤日誌列印的堆疊來看,是不久前新發布的版本里新增的一個 Lua table 不存在,而有程式碼向其進行索引導致的。這令人百思不得其解,如果是版本回退導致的,那麼為什麼使用這個 Lua table 的程式碼沒有被回退,偏偏定義這個 table 的程式碼被回退了呢?

經過排查發現,當時 nginx 剛剛完成熱更新操作,舊的 master 程式還存在,因為要準備機器重啟,先切掉了引流流量(但有些請求還在),同時系統觸發了 nginx -s stop,這才導致了這個問題。

場景復現

下面我將使用一個原生的 nginx,在我的安裝了 fedora26 的虛擬機器上覆現這個過程,我使用的 nginx 版本是目前最新的 1.13.4

首先啟動 nginx

alex@Fedora26-64: ~/bin_install/nginx
./sbin/nginx
alex@Fedora26-64: ~/bin_install/nginx
ps auxf | grep nginx
alex      6174  0.0  0.0  28876   428 ?        Ss   14:35   0:00 nginx: master process ./sbin/nginx
alex      6175  0.0  0.2  29364  2060 ?        S    14:35   0:00  \_ nginx: worker process

可以看到 master 和 worker 都已經在執行。

接著我們向 master 傳送一個 SIGUSR2 訊號,當 nginx 核心收到這個訊號後,就會觸發熱更新。

alex@Fedora26-64: ~/bin_install/nginx
kill -USR2 6174
alex@Fedora26-64: ~/bin_install/nginx
ps auxf | grep nginx
alex      6174  0.0  0.1  28876  1996 ?        Ss   14:35   0:00 nginx: master process ./sbin/nginx
alex      6175  0.0  0.2  29364  2060 ?        S    14:35   0:00  \_ nginx: worker process
alex      6209  0.0  0.2  28876  2804 ?        S    14:37   0:00  \_ nginx: master process ./sbin/nginx
alex      6213  0.0  0.1  29364  2004 ?        S    14:37   0:00      \_ nginx: worker process

可以看到新的 master 和該 master fork 出來的 worker 已經在執行了,此時我們接著向舊 master 傳送一個 SIGWINCH 訊號,舊 master 收到這個訊號後,會向它的 worker 傳送 SIGQUIT,於是舊 master 的 worker 程式就會退出:

alex@Fedora26-64: ~/bin_install/nginx
kill -WINCH 6174
alex@Fedora26-64: ~/bin_install/nginx
ps auxf | grep nginx
alex      6174  0.0  0.1  28876  1996 ?        Ss   14:35   0:00 nginx: master process ./sbin/nginx
alex      6209  0.0  0.2  28876  2804 ?        S    14:37   0:00  \_ nginx: master process ./sbin/nginx
alex      6213  0.0  0.1  29364  2004 ?        S    14:37   0:00      \_ nginx: worker process

此時只剩下舊的 master,新的 master 和新 master 的 worker 在執行,這和當時線上執行的情況類似。

接著我們使用 stop 命令:

alex@Fedora26-64: ~/bin_install/nginx
./sbin/nginx -s stop
alex@Fedora26-64: ~/bin_install/nginx
ps auxf | grep nginx
alex      6174  0.0  0.1  28876  1996 ?        Ss   14:35   0:00 nginx: master process ./sbin/nginx
alex      6301  0.0  0.2  29364  2124 ?        S    14:49   0:00  \_ nginx: worker process

我們會發現,新的 master 和它的 worker 都已經退出,而舊的 master 還在執行,併產生了 worker 出來。這就是當時線上的情況了。

事實上,這個現象和 nginx 自身的設計有關:當舊的 master 準備產生 fork 新的 master 之前,它會把 nginx.pid 這個檔案重新命名為 nginx.pid.oldbin,然後再由 fork 出來的新的 master 去建立新的 nginx.pid,這個檔案將會記錄新 master 的 pid。nginx 認為熱更新完成之後,舊 master 的使命幾乎已經結束,之後它隨時會退出,因此之後的操作都應該由新 master 接管。當然,在舊 master 沒有退出的情況下通過向新 master 傳送 SIGUSR2 企圖再次熱更新是無效的,新 master 只會忽略掉這個訊號然後繼續它自己的工作。

問題分析

更不巧的是,我們上面提到的這個 Lua table,定義它的 Lua 檔案早在執行 init_by_lua 這個 hook 的時候,就已經被 LuaJIT 載入到記憶體並編譯成位元組碼了,那麼顯然舊的 master 必然沒有這個 Lua table,因為它載入那部分 Lua 程式碼是舊版本的。

而索引該 table 的 Lua 程式碼並沒有在 init_by_lua 的時候使用到,這些程式碼都是在 worker 程式裡被載入起來的,這時候專案目錄裡的程式碼都是最新的,所以 worker 程式載入的都是最新的程式碼,如果這些 worker 程式處理到相關的請求,就會出現 Lua 執行時錯誤,外部表現則是對應的 HTTP 500。

吸收了這個教訓之後,我們需要更加合理地關閉我們的 nginx 服務。 所以一個更加合理的 nginx 服務啟動關閉指令碼是必需的,網上流傳的一些指令碼並沒有對這個現象做處理,我們更應該參考 NGINX 官方提供的指令碼。

stop() {
    echo -n $"Stopping $prog: "
    killproc $prog -QUIT
    retval=$?
    echo
    [ $retval -eq 0 ] && rm -f $lockfile
    return $retval
}

  

這段程式碼引自 NGINX 官方的 /etc/init.d/nginx 。

nginx 訊號集

接下來我們來全面梳理下 nginx 訊號集,這裡不會涉及到原始碼細節,感興趣的同學可以自行閱讀相關原始碼。

我們有兩種方式來向 master 程式傳送訊號,一種是通過 nginx -s signal 來操作,另一種是通過 kill 命令手動傳送。

第一種方式的原理是,產生一個新程式,該程式通過 nginx.pid 檔案得到 master 程式的 pid,然後把對應的訊號傳送到 master,之後退出,這種程式被稱為 signaller。

第二種方式要求我們瞭解 nginx -s signal 到真實訊號的對映。下表是它們的對映關係:

operationsignalreloadSIGHUPreopenSIGUSR1stopSIGTERMquitSIGQUIThot updateSIGUSR2 & SIGWINCH & SIGQUIT

stop vs quit

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

reload

master 程式收到 SIGHUP 後,會重新進行配置檔案解析、共享記憶體申請,等一系列其他的工作,然後產生一批新的 worker 程式,最後向舊的 worker 程式傳送 SIGQUIT 對應的訊息,最終無縫實現了重啟操作。

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 訊號,讓其退出了。

worker 程式如何處理來自 master 的訊號訊息

實際上,master 程式再向 worker 程式通訊,不是使用 kill 函式,而是使用了通過管道實現的 nginx channel,master 程式向管道一端寫入資訊(比如訊號資訊),worker 程式則從另外一端收取資訊,nginx channel 事件,在 worker 程式剛剛起來的時候,就被加入事件排程器中(比如 epoll,kqueue),所以當有資料從 master 發來時,即可被事件排程器通知到。

nginx 這麼設計是有理由的,作為一個優秀的反向代理伺服器,nginx 追求的就是極致的高效能,而 signal handler 會中斷 worker 程式的執行,使得所有的事件都被暫停一個時間視窗,這對效能是有一定損失的。

很多人可能會認為當 master 程式向 worker 程式傳送資訊之後,worker 程式立刻會有對應操作迴應,然而 worker 程式是非常繁忙的,它不斷地處理著網路事件和定時器事件,當呼叫 nginx channel 事件的 handler 之後,nginx 僅僅只是處理了一些標誌位。真正執行這些動作是在一輪事件排程完成之後。所以這之間存在一個時間視窗,尤其是業務複雜且流量巨大的時候,這個視窗就有可能被放大,這也就是為什麼 NGINX 官方提供的日誌切割方案裡要求 sleep 1s 的原因。

當然,我們也可以繞過 master 程式,直接向 worker 程式傳送訊號,worker 可以處理的訊號有

signaleffectSIGINT強制退出SIGTERM強制退出SIGQUIT優雅退出SIGUSR1重新開啟檔案

總結

nginx 訊號操作在日常運維中是最常見的,也是非常重要的,這個環節如果出現失誤則可能造成業務異常,帶來損失。所以理清楚 nginx 訊號集是非常必要的,能幫助我們更好地處理這些工作。

另外,通過這次的經驗教訓和對 nginx 訊號集的認知,我們認為以下幾點是比較重要的:

  • 慎用 nginx -s stop,儘可能使用 nginx -s quit
  • 熱更新之後,如果確定業務沒問題,儘可能讓舊的 master 程式退出
  • 關鍵性的訊號操作完成後,等待一段時間,避免時間視窗的影響
  • 不要直接向 worker 程式傳送訊號

 

推薦閱讀:

啟用 Brotli 壓縮演算法,對比 Gzip 壓縮 CDN 流量再減少 20%

HTTPS 傳輸優化詳解之動態 TLS Record Size

相關文章