最近 NodeJS 後端工程的 Docker 打包優化工做總算告一段落了。其實去年 12 月份就開始試點改造,期間遇到了很難復現的間歇性 socket hang up
問題,不得不延後。上週終於抽出時間全力排查了下,發現是升級 NodeJS 到 6.15.0 後,其有一個 HTTP Keep-alive 鏈接超時的 Bug。不得不感慨:這小版本升級也要格外當心啊。node
回到正題。在確認沒有其餘附帶問題後,在試點的基礎上,又增長了一些新的目標。總的目標大概以下:linux
下面從各個目標一一介紹下咱們的優化實踐之路。git
因爲以前的基礎鏡像使用的是 FROM node:6
,只有 major version,沒有指定 minor version、patch version。當該基礎鏡像 minor 或 patch 版本更新後,若是本地的鏡像緩存也被清除了,那麼打包就會使用新版本的基礎鏡像。這也是上面不經意升級到 node 6.15.0 的緣由。因此這裏咱們限定了基礎鏡像的全版本:FROM node:6.16.0
。docker
咱們的產品主要在國內使用,運維人員也都是在國內。爲了更方便查看日誌中的時間、方便程序中的日期計算,把時區調整爲北京時區(即東八區):RUN rm /etc/localtime && echo "Asia/Shanghai" > /etc/timezone && dpkg-reconfigure -f noninteractive tzdata
。注意,Debian Stretch 版本後須要 rm /etc/localtime
,不然時區修改可能沒法生效(被替換回原值)。shell
最後設置鏡像的工做目錄:WORKDIR /app
。這樣,咱們新的基礎鏡像就完成了。npm
優雅停機(Gracefully Shutdown),就是當應用(進程)要被關閉時,首先會被髮送一個軟終止信號。應用在收到這個信號後,執行清理工做,而後自行退出。若是在指定的時間內沒有自行退出,則會被強制關閉——這天然就不優雅了。這個軟終止信號通常就是指 SIGTERM。NodeJS 進程默認會對 SIGTERM 信號進行響應,執行進程退出。可是默認的監聽程序並不會執行清理工做。咱們須要顯式監聽該信號,並在清理完畢後執行 process.exit(0)
以退出進程。json
然而,在 Docker 容器裏實現優雅停機會有一些新的問題須要面對。當使用 docker stop
中止一個容器時,docker 會首先發送一個 SIGTERM 信號給容器內的 PID=1 進程,也就是常說的 init 進程。若是 PID=1 進程沒有在規定時間(通常 10 秒)內退出,則 docker 會發送 SIGKILL 信號強制退出容器內的全部進程。PID=1 進程比較特殊,在 linux 下,它會忽略全部默認的信號監聽程序,也就是說收到 SIGTERM 默認不會退出。因此,咱們的 PID=1 進程要求能顯式監聽 SIGTERM 並執行後續動做。gulp
然而,當咱們使用 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),但咱們這裏基本上不會有,也就能夠不考慮了。
這方面最基礎的一個優化就是利用 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
便可。然而咱們並無這樣作,主要有這兩個緣由:
postinstall
npm scripts,它依賴一些 devDpendencies
。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 就出爐啦!還有一些小細節,期待你本身的發現哦。
############################################
# 構建階段
############################################
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/
複製代碼