歡迎光臨
每天分享高質量文章

實現真正優雅的容器應用

行程的優雅退出(Gracefully Exiting) 看似是個不足為奇的小事,一般情況下只要捕獲 SIGTERM 等退出信號,執行完必要的工作再退出行程就好了,但是放到容器環境里,會有些意想不到的問題。本文簡單探討在容器內實現優雅退出會碰到的一系列連環坑。
首先宣告一點,這裡說的優雅可不是什麼 elegant,作為一個小碼農,不敢妄自評判什麼是優雅,翻譯成平穩可能更合適,但我們還是使用慣常翻譯。
什麼是優雅退出

 

先來介紹一下優雅退出的定義以及簡單的實現,對這部分比較熟悉的同學可以跳過。
在服務器上運行的程式難免遇到需要退出的情況,比如發佈新版本需要退出舊版本行程,機器資源不夠需要遷移到另外一臺主機上運行。在收到退出信號那一刻難免會有沒處理完的任務,為了避免造成資料丟失,或是客戶端的請求意外終止造成不好的體驗,就需要把手頭上剩下的事情處理完再退出。這個過程被稱為優雅退出(Graceful exiting)。
以一個普通的 Nodejs 程式為例,要實現優雅退出很簡單,node 等程式通常有個預設的退出信號 handler,我們在代碼里替換這個 handler,手動捕獲退出的信號(通常是Docker 或其他行程管理程式發出的 SIGTERM,或是在 terminal 里按下ctrl + c 發出的 SIGINT)。
  1. const handler = async () => {
  2.  console.log('Start cleanup')
  3.  await doCleanUpWork()
  4.  console.log('Exiting')
  5.  process.exit(0)  
  6. }
  7. process.on('SIGTERM', handler)
  8. process.on('SIGINT', handler)

 

實際測試的時候需要讓這段代碼一直運行,否則還等不到退出信號,程式自己就執行完退出了。比如在生產環境上通常有個 web 服務持續運行,為了測試簡單,我們加一個迴圈任務。
  1. // app.js
  2. async function doCleanUpWork() {
  3.  return new Promise((resolve) => {
  4.    setTimeout(resolve, 3000)
  5.  })
  6. }
  7. const handler = async () => {
  8.  console.log('Start cleanup')
  9.  await doCleanUpWork()
  10.  console.log('Exiting')
  11.  process.exit(0)  
  12. }
  13. process.on('SIGTERM', handler)
  14. process.on('SIGINT', handler)
  15. // keep running
  16. setInterval(() => {
  17.  console.log('Working')
  18. }, 1000)

 

上面這段代碼在命令列里執行:node app.js然後按下 ctrl + c 會觀察到過了 3 秒之後程式退出,輸出:
  1. Working
  2. Working
  3. ^CStart cleanup
  4. Working
  5. Working
  6. Working
  7. Exiting

 

容器內行程的生命周期

 

為了把上面這個實現了優雅退出的程式放到容器里運行,我們先瞭解一下容器的運作機制。
在一個容器啟動的時候,CMD 或者 ENTRYPOINT 里定義的命令會作為容器的主行程(main process)啟動,pid 為 1,一旦這個主行程退出了,容器也會被銷毀,容器內其他行程會被 kernel 直接 kill。
到這裡應該能想到,如果應用行程是被某個其他行程啟動的,可能等不到執行完 doCleanUpWork 裡面的任務,容器的主行程退出了就會被強行中止。
UNIX 系統有個規定,父行程應該等待子行程結束並收集其退出的狀態碼。大部分程式也是遵守這個約定的。所以理想情況下我們用 npm 或者其他程式來啟動的程式,在 npm 收到退出命令時,會轉發信號給子行程並等待其退出,然後自己才會退出,從而終止容器的運行。
容器中的 NPM 程式

 

我們看看實際情況,還是以一個 Nodejs 應用舉例,一個常見的做法是把啟動命令寫到 npm script 里,然後這個 npm script 作為 Docker 鏡像的 COMMAND,容器啟動會把 npm 作為 pid=1 的行程啟動,然後 npm script 其實是啟動一個 shell 行程,執行 script 里定義的命令。這樣行程樹會是這樣:
  1. npm
  2. \__ sh
  3.      \__ node

 

當 docker stop 被執行,首先 npm 行程會收到 SIGTERM 信號,然後 把 SIGTERM 轉發給 sh 行程並等待其退出。然後 sh 行程的行為有些出乎意料了,它沒有轉發信號給 node 行程,然後自己直接退出了!
奇怪的 shell

 

sh 作為操作系統一個重要組件,為什麼會不遵守 UNIX 行程的規範呢,其實不止 sh 這個 shell 程式,所有其他的 shell 程式,bash,zsh 都是這樣設計的。
一個原因是我們經常需要用 shell 來啟動程式,有時候會是後臺行程,如果 shell 退出會導致子行程全部退出,應該會是個大麻煩。
實際上 shell 程式除了不轉發 signals,還有個更可氣的特性是不響應退出信號。這在日常使用中不是問題,因為 kernel 會為每個行程加上預設的 signal handler,例外的是 pid=1 的行程,被 kernel 當作一個 init 角色,不會給他加上預設的 handler,可如果在容器中啟動 shell,占據了 pid=1 的位置,這個容器就無法正常退出了,只能等 Docker 引擎在超時後強行殺死行程。
所以我們平常碰到 docker stop 一個容器很慢,很有可能是因為這個容器的啟動程式是一個 shell 腳本,或者定義 Dockerfile 的啟動命令不是 json 陣列的 exec 形式 CMD [“executable”,”param1″,”param2″] ,而是 CMD command param1 param2 這種 shell form。
關於 shell 的這兩個費解行為有一段 bash 原始碼中的註釋作為參考:
  1. /* Ignore interrupts while waiting for a job run without job control
  2.    to finish. We don't want the shell to exit if an interrupt is
  3.    received, only if one of the jobs run is killed via SIGINT.
  4.  ...

 

可是既然 shell 不轉發退出信號,我們平常在命令列終端中執行程式之後,按下 ctrl + c 就能退出程式又是什麼原理呢。這就需要具體到 session 和控制終端(terminal)的概念了。
終端系統簡介(shell + terminal + tty)

 

為了更好的解釋 shell 的行為,這裡簡單介紹一下 shell 以及整個終端系統的結構,對這部分比較熟悉的也可以跳過。
shell 和終端(terminal)是兩個經常一起使用並且很容易被混淆的概念。我們日常使用的 shell 工具,比如 bash 或者 zsh,其實是一個腳本解釋器,而連接鍵盤(輸入)和顯示器(輸出)設備的是終端(terminal),比如耳熟能詳的 putty,iTerm,都是遠程終端工具。而終端通過 tty 系統與行程打交道。
在 Linux 系統中,當一個 shell 被啟動的時候,同時也創建了一個 session,這個 session 下的所有行程共用這個終端(terminal),同時 tty 系統會為這個終端創建一個虛擬 tty 設備用於將終端命令轉發給 tty 系統。實際上 ctrl +c是控制 tty 的特殊信號,收到這個控制字串,tty 會向這個終端的所有前臺行程發送 SIGINT 中斷信號,而 shell 本身不響應信號,所以按下 ctrl + c 只會退出在 shell 中啟動的前臺行程。
通過 ps 命令查看行程的 session 和 系結的 tty 設備:
  1.  PID  PPID TT        SESS COMMAND
  2.    1     0 pts/0        1 bash
  3.    8     1 pts/0        1 npm
  4.   24     8 pts/0        1  \_ sh
  5.   25    24 pts/0        1      \_ node

 

關於終端和 tty 的概念不再贅述,這裡有篇很詳細的文章(https://segmentfault.com/a/1190000009082089)可以作為擴展閱讀。
多種啟動方案

 

既然使用 npm 通過 sh 啟動程式會產生上面行程收不到退出信號的問題,就需要嘗試一下其他方案了。
dumb-int + npm

 

正好有個開源專案看似是為解決這個問題而誕生的:dumb-init
他聲稱,通過 dumb-init 作為容器的主行程,在收到退出信號的時候,會將退出信號轉發給行程組所有行程。
於是修改啟動命令為 CMD: [“dumb-init”, “–“, “npm”, “run”, “start”]
這個時候可以看到容器內的行程樹結構如下:
  1. dumb-init
  2. \__ npm
  3.    \__ sh
  4.          \__ node

 

退出容器後看到的日誌是:
  1. Working
  2. Working
  3. Start cleanup

 

可以看到 node 行程順利的收到了 SIGTERM 退出信號。
可是等等……後面應該還有 Exiting 這行輸出才對呀,看起來雖然收到了信號,但是退出前的清理任務並沒有被執行完。查看 dumb-init 原始碼發現,這個程式在直接子行程退出後,自己也會退出,他的假設是每個子行程應該等待自己的子行程退出,可是前面我們已經知道這個假設在 shell 行程上並不成立。
所以 dumb-init 要真正起作用,除了轉發信號,他還應該等待所有子行程退出才能自己退出。也確實看到有個沒被合併的 PR 在處理這件事情。
所以在這個 PR 被合併之前,dumb-init 還不能解決我們碰到的問題。除非讓 shell 行程從我們的行程樹中消失,這點應該還是可以做到的。
消除行程樹中的 shell 行程

 

一個辦法是我們可以修改啟動命令為:[“dumb-init”, “–“, “node”, “app”],可是這樣和我們直接把 node 行程作為容器的主行程差別不大了。不使用 npm script 或者 shell 腳本定義一些複雜的啟動命令就會比較麻煩了。
當然還有另外一個方案,要消除中間的 sh 行程,我們可以修改 npm script:node app => exec node app,多加了一個 exec ,shell 就會在當前行程中執行 node 行程,node 啟動後原本的 shell 行程就會消失了。啟動後的行程樹是這樣的:
  1. npm
  2. \__ node

 

The exec() family of functions replaces the current process image with a new process image.
總結

 

到這裡我們在容器里要實現優雅退出的坑已經算是踩完了,也知道要達到目的應該怎麼做了。不過要時刻避免在行程樹中產生 shell 行程未免是個讓人睡不好覺的事情。而比較熱門的 dumb-init 專案作為一個 init 程式也沒能達到幫助我們解決問題的目的。所以理想的解決方案是什麼樣的呢,我的設想是 dumb-init 已經實現的部分,再加上等待容器中所有行程退出,如果還有其他要求就是占用體積和記憶體盡可能的少,因為他需要添加到每個應用鏡像中啟動。
對比 dumb-init 和一些其他的方案。
  • dumb-init:不會等待所有子行程退出

  • docker –init 引數:不會給所有子行程轉發信號,也不會等待子行程退出

  • baseimage 專案:提供一個 ubuntu 基礎鏡像,內含一個 init 程式,使用 Python 編寫,會轉發信號和等待子行程退出,但是體積比較龐大。

  • smell-baron 專案:一個 c 寫的小程式,體積小,會轉發 signals 和等待所有子行程退出

smell-baron 這個小程式基本滿足我們的所有要求,只不過這個冷門的專案可能還需要更多的驗證。
原文鏈接:https://zhuanlan.zhihu.com/p/54151728

赞(0)

分享創造快樂