NodeJS 服務(wù) Docker 鏡像極致優(yōu)化指北
這段時間在開發(fā)一個騰訊文檔全品類通用的 HTML 動態(tài)服務(wù),為了方便各品類接入的生成與部署,也順應(yīng)上云的趨勢,考慮使用 Docker 的方式來固定服務(wù)內(nèi)容,統(tǒng)一進行制品版本的管理。本篇文章就將我在服務(wù) Docker 化的過程中積累起來的優(yōu)化經(jīng)驗分享出來,供大家參考。
以一個例子開頭,大部分剛接觸 Docker 的同學(xué)應(yīng)該都會這樣編寫項目的 Dockerfile,如下所示:
FROM node:14WORKDIR /appCOPY . .# 安裝 npm 依賴RUN npm install# 暴露端口EXPOSE 8000CMD ["npm", "start"]
構(gòu)建,打包,上傳,一氣呵成。然后看下鏡像狀態(tài),臥槽,一個簡單的 node web 服務(wù)體積居然達到了驚人的 1.3 個 G,并且鏡像傳輸與構(gòu)建速度也很慢:

要是這個鏡像只需要部署一個實例也就算了,但是這個服務(wù)得提供給所有開發(fā)同學(xué)進行高頻集成并部署環(huán)境的(實現(xiàn)高頻集成的方案可參見我的 上一篇文章)。首先,鏡像體積過大必然會對鏡像的拉取和更新速度造成影響,集成體驗會變差。其次,項目上線后,同時在線的測試環(huán)境實例可能成千上萬,這樣的容器內(nèi)存占用成本對于任何一個項目都是無法接受的。必須找到優(yōu)化的辦法解決。
發(fā)現(xiàn)問題后,我就開始研究 Docker 的優(yōu)化方案,準(zhǔn)備給我的鏡像動手術(shù)了。
node 項目生產(chǎn)環(huán)境優(yōu)化
首先開刀的是當(dāng)然是前端最為熟悉的領(lǐng)域,對代碼本身體積進行優(yōu)化。之前開發(fā)項目時使用了 Typescript,為了圖省事,項目直接使用 tsc 打包生成 es5 后就直接運行起來了。這里的體積問題主要有兩個,一個是開發(fā)環(huán)境 ts 源碼并未處理,并且用于生產(chǎn)環(huán)境的 js 代碼也未經(jīng)壓縮。

另一個是引用的 node_modules 過于臃腫。仍然包含了許多開發(fā)調(diào)試環(huán)境中的 npm 包,如 ts-node,typescript 等等。既然打包成 js 了,這些依賴自然就該去除。
一般來說,由于服務(wù)端代碼不會像前端代碼一樣暴露出去,運行在物理機上的服務(wù)更多考慮的是穩(wěn)定性,也不在乎多一些體積,因此這些地方一般也不會做處理。但是 Docker 化后,由于部署規(guī)模變大,這些問題就非常明顯了,在生產(chǎn)環(huán)境下需要優(yōu)化的。
對于這兩點的優(yōu)化的方式其實我們前端非常熟悉了,不是本文的重點就粗略帶過了。對于第一點,使用 Webpack + babel 降級并壓縮 Typescript 源碼,如果擔(dān)心錯誤排查可以加上 sourcemap,不過對于 docker 鏡像來說有點多余,一會兒會說到。對于第二點,梳理 npm 包的 dependencies 與 devDependencies 依賴,去除不是必要存在于運行時的依賴,方便生產(chǎn)環(huán)境使用 npm install--production 安裝依賴。
優(yōu)化項目鏡像體積
使用盡量精簡的基礎(chǔ)鏡像
我們知道,容器技術(shù)提供的是操作系統(tǒng)級別的進程隔離,Docker 容器本身是一個運行在獨立操作系統(tǒng)下的進程,也就是說,Docker 鏡像需要打包的是一個能夠獨立運行的操作系統(tǒng)級環(huán)境。因此,決定鏡像體積的一個重要因素就顯而易見了:打包進鏡像的 Linux 操作系統(tǒng)的體積。
一般來說,減小依賴的操作系統(tǒng)的大小主要需要考慮從兩個方面下手,第一個是盡可能去除 Linux 下不需要的各類工具庫,如 python,cmake, telnet 等。第二個是選取更輕量級的 Linux 發(fā)行版系統(tǒng)。正規(guī)的官方鏡像應(yīng)該會依據(jù)上述兩個因素對每個發(fā)行版提供閹割版本。
以 node 官方提供的版本 node:14 為例,默認版本中,它的運行基礎(chǔ)環(huán)境是 Ubuntu,是一個大而全的 Linux 發(fā)行版,以保證最大的兼容性。去除了無用工具庫的依賴版本稱為 node:14-slim 版本。而最小的鏡像發(fā)行版稱為 node:14-alpine。Linux alpine 是一個高度精簡,僅包含基本工具的輕量級 Linux 發(fā)行版,本身的 Docker 鏡像只有 4 ~ 5M 大小,因此非常適合制作最小版本的 Docker 鏡像。
在我們的服務(wù)中,由于運行該服務(wù)的依賴是確定的,因此為了盡可能的縮減基礎(chǔ)鏡像的體積,我們選擇 alpine 版本作為生產(chǎn)環(huán)境的基礎(chǔ)鏡像。
分級構(gòu)建
這時候,我們遇到了新的問題。由于 alpine 的基本工具庫過于簡陋,而像 webpack 這樣的打包工具背后可能使用的插件庫極多,構(gòu)建項目時對環(huán)境的依賴較大。并且這些工具庫只有編譯時需要用到,在運行時是可以去除的。對于這種情況,我們可以利用 Docker 的 分級構(gòu)建的特性來解決這一問題。
首先,我們可以在完整版鏡像下進行依賴安裝,并給該任務(wù)設(shè)立一個別名(此處為 build)。
# 安裝完整依賴并構(gòu)建產(chǎn)物FROM node:14 AS buildWORKDIR /appCOPY package*.json /app/RUN ["npm", "install"]COPY . /app/RUN npm run build
之后我們可以啟用另一個鏡像任務(wù)來運行生產(chǎn)環(huán)境,生產(chǎn)的基礎(chǔ)鏡像就可以換成 alpine 版本了。其中編譯完成后的源碼可以通過 --from參數(shù)獲取到處于 build任務(wù)中的文件,移動到此任務(wù)內(nèi)。
FROM node:14-alpine AS releaseWORKDIR /releaseCOPY package*.json /RUN ["npm", "install", "--registry=http://r.tnpm.oa.com", "--production"]# 移入依賴與源碼COPY public /release/publicCOPY --from=build /app/dist /release/dist# 啟動服務(wù)EXPOSE 8000CMD ["node", "./dist/index.js"]
Docker 鏡像的生成規(guī)則是,生成鏡像的結(jié)果僅以最后一個鏡像任務(wù)為準(zhǔn)。因此前面的任務(wù)并不會占用最終鏡像的體積,從而完美解決這一問題。
當(dāng)然,隨著項目越來越復(fù)雜,在運行時仍可能會遇到工具庫報錯,如果曝出問題的工具庫所需依賴不多,我們可以自行補充所需的依賴,這樣的鏡像體積仍然能保持較小的水平。
其中最常見的問題就是對 node-gyp與 node-sass庫的引用。由于這個庫是用來將其他語言編寫的模塊轉(zhuǎn)譯為 node 模塊,因此,我們需要手動增加 g++make python這三個依賴。
# 安裝生產(chǎn)環(huán)境依賴(為兼容 node-gyp 所需環(huán)境需要對 alpine 進行改造)FROM node:14-alpine AS dependenciesRUN apk add --no-cache python make g++COPY package*.json /RUN ["npm", "install", "--registry=http://r.tnpm.oa.com", "--production"]RUN apk del .gyp
詳情可見:https://github.com/nodejs/docker-node/issues/282
合理規(guī)劃 Docker Layer
構(gòu)建速度優(yōu)化
我們知道,Docker 使用 Layer 概念來創(chuàng)建與組織鏡像,Dockerfile 的每條指令都會產(chǎn)生一個新的文件層,每層都包含執(zhí)行命令前后的狀態(tài)之間鏡像的文件系統(tǒng)更改,文件層越多,鏡像體積就越大。而 Docker 使用緩存方式實現(xiàn)了構(gòu)建速度的提升。若 Dockerfile 中某層的語句及依賴未更改,則該層重建時可以直接復(fù)用本地緩存。
如下所示,如果 log 中出現(xiàn) Usingcache字樣時,說明緩存生效了,該層將不會執(zhí)行運算,直接拿原緩存作為該層的輸出結(jié)果。
Step 2/3 : npm install---> Using cache---> efvbf79sd1eb
通過研究 Docker 緩存算法,發(fā)現(xiàn)在 Docker 構(gòu)建過程中,如果某層無法應(yīng)用緩存,則依賴此步的后續(xù)層都不能從緩存加載。例如下面這個例子:
COPY . .RUN npm install
此時如果我們更改了倉庫的任意一個文件,此時因為 npm install層的上層依賴變更了,哪怕依賴沒有進行任何變動,緩存也不會被復(fù)用。
因此,若想盡可能的利用上 npm install層緩存,我們可以把 Dockerfile 改成這樣:
COPY package*.json .RUN npm installCOPY src .
這樣在僅變更源碼時, node_modules的依賴緩存仍然能被利用上了。
由此,我們得到了優(yōu)化原則:
最小化處理變更文件,僅變更下一步所需的文件,以盡可能減少構(gòu)建過程中的緩存失效。
對于處理文件變更的 ADD 命令、COPY 命令,盡量延遲執(zhí)行。
構(gòu)建體積優(yōu)化
在保證速度的前提下,體積優(yōu)化也是我們需要去考慮的。這里我們需要考慮的有三點:
Docker 是以層為單位上傳鏡像倉庫的,這樣也能最大化的利用緩存的能力。因此,執(zhí)行結(jié)果很少變化的命令需要抽出來單獨成層,如上面提到的
npm install的例子里,也用到了這方面的思想。如果鏡像層數(shù)越少,總上傳體積就越小。因此,在命令處于執(zhí)行鏈尾部,即不會對其他層緩存產(chǎn)生影響的情況下,盡量合并命令,從而減少緩存體積。例如,設(shè)置環(huán)境變量和清理無用文件的指令,它們的輸出都是不會被使用的,因此可以將這些命令合并為一行 RUN 命令。
RUN set ENV=prod && rm -rf ./trashDocker cache 的下載也是通過層緩存的方式,因此為了減少鏡像的傳輸下載時間,我們最好使用固定的物理機器來進行構(gòu)建。例如在流水線中指定專用宿主機,能是的鏡像的準(zhǔn)備時間大大減少。
當(dāng)然,時間和空間的優(yōu)化從來就沒有兩全其美的辦法,這一點需要我們在設(shè)計 Dockerfile 時,對 Docker Layer 層數(shù)做出權(quán)衡。例如為了時間優(yōu)化,需要我們拆分文件的復(fù)制等操作,而這一點會導(dǎo)致層數(shù)增多,略微增加空間。
這里我的建議是,優(yōu)先保證構(gòu)建時間,其次在不影響時間的情況下,盡可能的縮小構(gòu)建緩存體積。
以 Docker 的思維管理服務(wù)
避免使用進程守護
我們編寫傳統(tǒng)的后臺服務(wù)時,總是會使用例如 pm2、forever 等等進程守護程序,以保證服務(wù)在意外崩潰時能被監(jiān)測到并自動重啟。但這一點在 Docker 下非但沒有益處,還帶來了額外的不穩(wěn)定因素。
首先,Docker 本身就是一個流程管理器,因此,進程守護程序提供的崩潰重啟,日志記錄等等工作 Docker 本身或是基于 Docker 的編排程序(如 kubernetes)就能提供了,無需使用額外應(yīng)用實現(xiàn)。除此之外,由于守護進程的特性,將不可避免的對于以下的情況產(chǎn)生影響:
增加進程守護程序會使得占用的內(nèi)存增多,鏡像體積也會相應(yīng)增大。
由于守護進程一直能正常運行,服務(wù)發(fā)生故障時,Docker 自身的重啟策略將不會生效,Docker 日志里將不會記錄崩潰信息,排障溯源困難。
由于多了個進程的加入,Docker 提供的 CPU、內(nèi)存等監(jiān)控指標(biāo)將變得不準(zhǔn)確。
因此,盡管 pm2 這樣的進程守護程序提供了能夠適配 Docker 的版本:pm2-runtime,但我仍然不推薦大家使用進程守護程序。
其實這一點其實是源自于我們的固有思想而犯下的錯誤。在服務(wù)上云的過程中,難點其實不僅僅在于寫法與架構(gòu)上的調(diào)整,開發(fā)思路的轉(zhuǎn)變才是最重要的,我們會在上云的過程中更加深刻體會到這一點。
日志的持久化存儲
無論是為了排障還是審計的需要,后臺服務(wù)總是需要日志能力。按照以往的思路,我們將日志分好類后,統(tǒng)一寫入某個目錄下的日志文件即可。但是在 Docker 中,任何本地文件都不是持久化的,會隨著容器的生命周期結(jié)束而銷毀。因此,我們需要將日志的存儲跳出容器之外。
最簡單的做法是利用 DockerManagerVolume,這個特性能繞過容器自身的文件系統(tǒng),直接將數(shù)據(jù)寫到宿主物理機器上。具體用法如下:
docker run -d -it --name=app -v /app/log:/usr/share/log app運行 docker 時,通過-v 參數(shù)為容器綁定 volumes,將宿主機上的 /app/log 目錄(如果沒有會自動創(chuàng)建)掛載到容器的 /usr/share/log 中。這樣服務(wù)在將日志寫入該文件夾時,就能持久化存儲在宿主機上,不隨著 docker 的銷毀而丟失了。
當(dāng)然,當(dāng)部署集群變多后,物理宿主機上的日志也會變得難以管理。此時就需要一個服務(wù)編排系統(tǒng)來統(tǒng)一管理了。從單純管理日志的角度出發(fā),我們可以進行網(wǎng)絡(luò)上報,給到云日志服務(wù)(如騰訊云 CLS)托管?;蛘吒纱鄬⑷萜鬟M行批量管理,例如 Kubernetes這樣的容器編排系統(tǒng),這樣日志作為其中的一個模塊自然也能得到妥善保管了。這樣的方法很多,就不多加贅述了。
k8s 服務(wù)控制器的選擇
鏡像優(yōu)化之外,服務(wù)編排以及控制部署的負載形式對性能的影響也很大。這里以最流行的 Kubernetes的兩種控制器(Controller):Deployment 與 StatefulSet 為例,簡要比較一下這兩類組織形式,幫助選擇出最適合服務(wù)的 Controller。
StatefulSet是 K8S 在 1.5 版本后引入的 Controller,主要特點為:能夠?qū)崿F(xiàn) pod 間的有序部署、更新和銷毀。那么我們的制品是否需要使用 StatefulSet 做 pod 管理呢?官方簡要概括為一句話:
Deployment 用于部署無狀態(tài)服務(wù),StatefulSet 用來部署有狀態(tài)服務(wù)。
這句話十分精確,但不易于理解。那么,什么是無狀態(tài)呢?在我看來, StatefulSet的特點可以從如下幾個步驟進行理解:
StatefulSet管理的多個 pod 之間進行部署,更新,刪除操作時能夠按照固定順序依次進行。適用于多服務(wù)之間有依賴的情況,如先啟動數(shù)據(jù)庫服務(wù)再開啟查詢服務(wù)。由于 pod 之間有依賴關(guān)系,因此每個 pod 提供的服務(wù)必定不同,所以
StatefulSet管理的 pod 之間沒有負載均衡的能力。又因為 pod 提供的服務(wù)不同,所以每個 pod 都會有自己獨立的存儲空間,pod 間不共享。
為了保證 pod 部署更新時順序,必須固定 pod 的名稱,因此不像
Deployment那樣生成的 pod 名稱后會帶一串隨機數(shù)。而由于 pod 名稱固定,因此跟
StatefulSet對接的Service中可以直接以 pod 名稱作為訪問域名,而不需要提供ClusterIP,因此跟StatefulSet對接的Service被稱為HeadlessService。
通過這里我們就應(yīng)該明白,如果在 k8s 上部署的是單個服務(wù),或是多服務(wù)間沒有依賴關(guān)系,那么 Deployment 一定是簡單而又效果最佳的選擇,自動調(diào)度,自動負載均衡。而如果服務(wù)的啟停必須滿足一定順序,或者每一個 pod 所掛載的數(shù)據(jù) volume 需要在銷毀后依然存在,那么建議選擇 StatefulSet。
本著如無必要,勿增實體的原則,強烈建議所有運行單個服務(wù)工作負載采用 Deployment 作為 Controller。
寫在結(jié)尾
一通研究下來,差點把一開始的目標(biāo)忘了,趕緊將 Docker 重新構(gòu)建一遍,看看優(yōu)化成果。

可以看到,對于鏡像體積的優(yōu)化效果還是不錯的,達到了 10 倍左右。當(dāng)然,如果項目中不需要如此高版本的 node 支持,還能進一步縮小大約一半的鏡像體積。
之后鏡像倉庫會對存放的鏡像文件做一次壓縮,以 node14 打包的鏡像版本最終被壓縮到了 50M 以內(nèi)。

當(dāng)然,除了看得到的體積數(shù)據(jù)之外,更重要的優(yōu)化其實在于,從面向物理機的服務(wù)向容器化云服務(wù)在架構(gòu)設(shè)計層面上的轉(zhuǎn)變。
容器化已經(jīng)是看得見的未來,作為一名開發(fā)人員,要時刻保持對前沿技術(shù)的敏感,積極實踐,才能將技術(shù)轉(zhuǎn)化為生產(chǎn)力,為項目的 進化做出貢獻。
參考資料:
《Kubernetes in action》--Marko Luk?a
Optimizing Docker Images

往期推薦



內(nèi)推社群
我組建了一個氛圍特別好的騰訊內(nèi)推社群,如果你對加入騰訊感興趣的話(后續(xù)有計劃也可以),我們可以一起進行面試相關(guān)的答疑、聊聊面試的故事、并且在你準(zhǔn)備好的時候隨時幫你內(nèi)推。下方加 winty 好友回復(fù)「面試」即可。
