NodeJS Docker 打包優化

NodeJS Docker 打包優化


最近 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>

那我們就只剩一個選項了:直接將 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://[email protected]: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>
  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/


分享到:


相關文章: