NodeJS 後端工程 Docker 打包優化

kiliwalk發表於2019-04-10

最近 NodeJS 後端工程的 Docker 打包優化工作總算告一段落了。其實去年 12 月份就開始試點改造,期間遇到了很難復現的間歇性 socket hang up 問題,不得不延後。上週終於抽出時間全力排查了下,發現是升級 NodeJS 到 6.15.0 後,其有一個 HTTP Keep-alive 連線超時的 Bug。不得不感慨:這小版本升級也要格外小心啊。

回到正題。在確認沒有其他附帶問題後,在試點的基礎上,又增加了一些新的目標。總的目標大概如下:

  • 支援優雅停機,要求 Node 程式能夠接收到 SIGTERM 軟終止訊號
  • 提升打包速度,充分利用 Docker Layer 快取機制,降低 yarn install、node_modules 拷貝等高 IO 動作的執行頻率
  • 保證原始碼安全,不要將原始碼打包到映象裡
  • 儘可能降低最終映象大小,不要包含不必要的檔案(如 node_modules 中的 devDependencies)

下面從各個目標一一介紹下我們的優化實踐之路。

基礎映象設定

由於之前的基礎映象使用的是 FROM node:6,只有 major version,沒有指定 minor version、patch version。當該基礎映象 minor 或 patch 版本更新後,如果本地的映象快取也被清除了,那麼打包就會使用新版本的基礎映象。這也是上面不經意升級到 node 6.15.0 的原因。所以這裡我們限定了基礎映象的全版本:FROM node:6.16.0

我們的產品主要在國內使用,運維人員也都是在國內。為了更方便檢視日誌中的時間、方便程式中的日期計算,把時區調整為北京時區(即東八區):RUN rm /etc/localtime && echo "Asia/Shanghai" > /etc/timezone && dpkg-reconfigure -f noninteractive tzdata。注意,Debian Stretch 版本後需要 rm /etc/localtime,否則時區修改可能無法生效(被替換回原值)。

最後設定映象的工作目錄:WORKDIR /app。這樣,我們新的基礎映象就完成了。

支援優雅停機

優雅停機(Gracefully Shutdown),就是當應用(程式)要被關閉時,首先會被髮送一個軟終止訊號。應用在收到這個訊號後,執行清理工作,然後自行退出。如果在指定的時間內沒有自行退出,則會被強制關閉——這自然就不優雅了。這個軟終止訊號一般就是指 SIGTERM。NodeJS 程式預設會對 SIGTERM 訊號進行響應,執行程式退出。但是預設的監聽程式並不會執行清理工作。我們需要顯式監聽該訊號,並在清理完畢後執行 process.exit(0) 以退出程式。

然而,在 Docker 容器裡實現優雅停機會有一些新的問題需要面對。當使用 docker stop 停止一個容器時,docker 會首先傳送一個 SIGTERM 訊號給容器內的 PID=1 程式,也就是常說的 init 程式。如果 PID=1 程式沒有在規定時間(一般 10 秒)內退出,則 docker 會傳送 SIGKILL 訊號強制退出容器內的所有程式。PID=1 程式比較特殊,在 linux 下,它會忽略所有預設的訊號監聽程式,也就是說收到 SIGTERM 預設不會退出。所以,我們的 PID=1 程式要求能顯式監聽 SIGTERM 並執行後續動作。

然而,當我們使用 shell form 的 ENTRYPOINT 或 CMD 指令時——如 CMD npm run start,Docker 容器會預設啟用一個 Shell 來執行後面的指令。此時 PID=1 程式是 /bin/sh,完整的執行命令是 /bin/sh -c 'npm run start'。當 sh 收到 SIGTERM 訊號時,它自身並不會退出。因為 sh 並沒有顯式監聽 SIGTERM,預設的訊號處理器被忽略了。自然 sh 內部也不會把訊號轉發給子程式。最後只會超時,繼而被 SIGKILL 強制關閉。

Docker 推薦我們用 exec form 的 ENTRYPOINT 或 CMD 指令,如 CMD ["npm", "run", "start"]。這樣 PID=1 程式就是 npm 了,不再有 sh 程式了。但繼續用 npm scripts 會不會還有問題?這就依賴 Host 環境了。我們來看一下 npm scripts 的執行原理。以 npm run start 為例,在執行時,首先會起一個 npm 程式。npm 程式會 spawn() 一個 /bin/sh 程式(/bin/sh -c),執行 start script 的內容(通常就是 node xxx.js)。這樣就形成了三個程式構成的程式樹,分別是 npm、sh、node。當 npm 程式收到 SIGTERM 訊號時,它內部已經監聽 SIGTERM,其邏輯就是轉發給子程式,也就是 sh 程式。sh 程式收到訊號後退出,接著 npm 也退出了。但是,剩下的 node 程式並沒有收到訊號,它被忽略了,繼而被 Docker 直接 SIGKILL。看起來完全不行嘛,那為什麼說依賴 Host 環境呢?因為中間這個 sh 程式在 bash 裡(/bin/sh 指向 bash),是有可能不存在的。是不是很神奇?當使用 -c 執行命令時,bash 會判斷是否需要 fork() 當前程式以產生一個新的程式來執行該命令。當 -c 命令不包含複雜的結構,如多個命令連線(&&||)、重定向(>)等情況時,bash 不會 fork() 出新的子程式,而是直接使用 exec() 替換當前程式。而 node:6 Docker 映象所用的 Debian Stretch 作業系統,/bin/sh 預設指向的是 dash,而不是 bash。所以在這裡,我們最好也不要用 npm scripts。

那我們就只剩一個選項了:直接將 node 作為 PID=1 的程式,如 CMD ["node", "dist/server.js"]。雖然說 PID=1 的程式還要處理殭屍程式(Zombie Process),但我們這裡基本上不會有,也就可以不考慮了。

yarn install 優化

這方面最基礎的一個優化就是利用 Docker Layer 快取特性,降低 yarn install 的發生次數。

# 在 package.json、yarn.lock 沒有變化的情況下,後面的 yarn install 會直接複用上次打包的快取結果
COPY package.json yarn.lock
RUN yarn install --frozen-lockfile
複製程式碼

要注意的一個問題是,yarn 會在其他位置建立依賴快取(cache)。可以用 yarn cache clean 來移除快取。不過我們這裡並沒有用,因為後面的改造方式讓我們不需要它了。

我們的工程依賴裡有私有 Git 倉庫,如 "js-util": "git+ssh://git@gitlab.xxx.com:yyy/library/js-util.git#v2"。我們原先的 CI 過程,是在宿主機上先安裝依賴,然後把整個 node_modules 拷貝到 Docker Server 端中進行打包。宿主機有 SSH Key(一般就是 Gitlab Deploy Key,注意不要加密碼,否則無法在 non-interactive shell 下使用),下載私有 Git 倉庫不會有許可權問題,但是就無法利用上述的快取優化了。魚和熊掌不可兼得,那就選中間。如果我們把 SSH Key 也打包到映象裡呢?那就太不安全了。那把它從映象裡又刪除呢?可惜還是有安全隱患——Docker 的 Union FS 機制會導致這些檔案還存在於原來的 Layer 裡。

解決這個問題沒有特別完美的方法。可以嘗試提供一個內網的 SSH Key 線上下載地址,使用一個 RUN 指令完成 wget、ssh-add、yarn install、rm 等一系列操作,保證沒有任何一個 Layer 會留存 SSH Key。而我們這裡採用的是 Multi Stage Build——多階段打包機制。在階段一,複製 SSH Key,獲取 Gitlab 伺服器的公鑰,並執行 yarn install。在階段二,把階段一打包出來的內容複製過來,注意這裡不要複製 SSH Key。

# 構建時需要執行的指令
FROM node:6.16.0 as build
WORKDIR /app
COPY .ssh /root/.ssh/
RUN chmod 600 /root/.ssh/id_rsa && ssh-keyscan gitlab.xxx.com > /root/.ssh/known_hosts
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# 執行時需要執行的指令
FROM node:6.16.0 as runtime
WORKDIR /app
COPY --from=build /app/node_modules /app/node_modules/
複製程式碼

這樣,階段二打包出來的最終映象,就沒有 SSH Key 了。至於階段一的 .ssh 目錄,可以在呼叫 docker build 之前,從 $HOME/.ssh/id_rsa 上覆制到當前目錄,可千萬別上傳到 Git 倉庫哦。

打包速度優化

在充分利用 Docker Layer 快取機制的基礎上,我們需要把那些不容易產生變更的指令放到上面、把不容易產生變更的部分剝離出來。像 WORKDIR、CMD、ENV、還有一些環境配置指令,都可以放到前面。把檔案複製過程中,不容易產生變更的檔案單獨抽離出來,形成一個新的 COPY 指令,儘量避免 COPY . /p/a/t/h/ 這樣的複製方式。說到 COPY,還要注意其跟 Linux cp 命令有一些不一樣的地方。當複製一個目錄時,COPY 是將這個目錄下的所有檔案複製到目標資料夾下,而不是把這個目錄自身複製到目標資料夾中。

原始碼安全

在最終的映象裡,最好不要包括原始碼,而只有 Transpile、Uglify 甚至是 Minify 後的程式碼。我們使用 npm run build 來做這些轉換工作,它會把 src 原始碼目錄,轉換到 dist 目錄。使用上面的多階段打包,只要在第二階段 COPY dist 目錄即可。

映象大小優化

最終打包出來的映象大小,除了基礎映象 node:6.16.0 佔用大部分空間外,剩下的主要就是 node_modules 目錄了——大概有 200-300MB。我們可以考慮把 devDependencies 從 node_modules 中刪除來減少大小。再增加一條指令:RUN yarn install --production 即可。然而我們並沒有這樣做,主要有這兩個原因:

  1. 我們在註冊了 postinstall npm scripts,它依賴一些 devDpendencies
  2. 由於還有 npm run build,它所依賴的 babel 都是 devDpendencies。由於它必須在 COPY 原始碼之後執行,意味著只要原始碼有變化,npm run build 就會被執行。那還在它後面的 yarn install --production 自然也會被再次執行,可能就會影響打包效率了。

上下文目錄優化

docker build -t xxx .,最後的那個 . 就表示上下文目錄位置(. 就是當前目錄)。docker build 是在 go 語言寫的一個本地服務端上執行。所以一開始需要把上下文目錄打包傳送到服務端,然後在服務端內解壓,再執行各個指令,生成最終的映象。這樣我們的上下文目錄就不能太大,不然 IO 吃不消。我們可以用 .dockerignore 檔案來限制上下文目錄只包含哪些檔案。為了得到一個比較通用的 .dockerignore 檔案,我們主要使用排除法規則。排除那些容器執行時不需要的檔案;排除那些不會在多階段打包過程中使用的中間檔案,如 node_modules、dist。示例 .dockerignore 檔案如下:

*
!package.json
!yarn.lock
!src
!bin
!test
!gulpfile.js
!.babel*
!.eslint*
!.nycrc
!.ssh
複製程式碼

最終的 Dockerfile

把上面各個改造結合在一起,我們的 Dockerfile 就出爐啦!還有一些小細節,期待你自己的發現哦。

############################################
#                 構建階段
############################################
FROM node:6.16.0 as build

WORKDIR /app

# 執行 docker build 前需要把 SSH Keys 複製到當前目錄下的 .ssh 中,並在 build 完後刪除
COPY .ssh /root/.ssh/
RUN chmod 600 /root/.ssh/id_rsa && ssh-keyscan gitlab.xxx.com > /root/.ssh/known_hosts

# 在 package.json、yarn.lock 沒有變化的情況下,yarn install 會複用上次的快取結果
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

# 注意使用 .dockerignore 來遮蔽掉不必要的檔案
COPY . ./

RUN npm run lint && npm run build && npm run test


############################################
#        執行時,也即最終的 Image 內容
############################################
FROM node:6.16.0 as runtime

WORKDIR /app

# 第一行,設定時區為北京時區(東八區)
# 第二行,解決 npm log 日誌中摻雜命令列控制符導致日誌解析、匹配困難的問題
RUN rm /etc/localtime && echo "Asia/Shanghai" > /etc/timezone && dpkg-reconfigure -f noninteractive tzdata \
  && npm config set color false

ENV NODE_ENV="production" 

# 不要使用 npm,也不要用 shell form,避免 node 程式無法收到 SIGTERM 訊號。
ENTRYPOINT ["node"]
CMD ["dist/server.js"]

# 執行時需要的檔案
COPY --from=build /app/package.json /app/yarn.lock ./
COPY --from=build /app/node_modules /app/node_modules/
COPY --from=build /app/dist /app/dist/
複製程式碼

相關文章