這段時間在開發一個騰訊文檔全品類通用的 HTML 動態服務,為了方便各品類接入的生成與部署,也順應上雲的趨勢,考慮使用 Docker 的方式來固定服務內容,統一進行製品版本的管理。本篇文章就將我在服務 Docker 化的過程中積累起來的優化經驗分享出來,供大家參考。 以一個例子開頭,大部分剛接觸 D ...
這段時間在開發一個騰訊文檔全品類通用的 HTML 動態服務,為了方便各品類接入的生成與部署,也順應上雲的趨勢,考慮使用 Docker 的方式來固定服務內容,統一進行製品版本的管理。本篇文章就將我在服務 Docker 化的過程中積累起來的優化經驗分享出來,供大家參考。
以一個例子開頭,大部分剛接觸 Docker 的同學應該都會這樣編寫項目的 Dockerfile,如下所示:
FROM node:14
WORKDIR /app
COPY . .
# 安裝 npm 依賴
RUN npm install
# 暴露埠
EXPOSE 8000
CMD ["npm", "start"]
構建,打包,上傳,一氣呵成。然後看下鏡像狀態,卧槽,一個簡單的 node web 服務體積居然達到了驚人的 1.3 個 G,並且鏡像傳輸與構建速度也很慢:
要是這個鏡像只需要部署一個實例也就算了,但是這個服務得提供給所有開發同學進行高頻集成並部署環境的(實現高頻集成的方案可參見我的 上一篇文章)。首先,鏡像體積過大必然會對鏡像的拉取和更新速度造成影響,集成體驗會變差。其次,項目上線後,同時線上的測試環境實例可能成千上萬,這樣的容器記憶體占用成本對於任何一個項目都是無法接受的。必須找到優化的辦法解決。
發現問題後,我就開始研究 Docker 的優化方案,準備給我的鏡像動手術了。
node 項目生產環境優化
首先開刀的是當然是前端最為熟悉的領域,對代碼本身體積進行優化。之前開發項目時使用了 Typescript,為了圖省事,項目直接使用 tsc 打包生成 es5 後就直接運行起來了。這裡的體積問題主要有兩個,一個是開發環境 ts 源碼並未處理,並且用於生產環境的 js 代碼也未經壓縮。
另一個是引用的 node_modules 過於臃腫。仍然包含了許多開發調試環境中的 npm 包,如 ts-node,typescript 等等。既然打包成 js 了,這些依賴自然就該去除。
一般來說,由於服務端代碼不會像前端代碼一樣暴露出去,運行在物理機上的服務更多考慮的是穩定性,也不在乎多一些體積,因此這些地方一般也不會做處理。但是 Docker 化後,由於部署規模變大,這些問題就非常明顯了,在生產環境下需要優化的。
對於這兩點的優化的方式其實我們前端非常熟悉了,不是本文的重點就粗略帶過了。對於第一點,使用 Webpack + babel 降級並壓縮 Typescript 源碼,如果擔心錯誤排查可以加上 sourcemap,不過對於 docker 鏡像來說有點多餘,一會兒會說到。對於第二點,梳理 npm 包的 dependencies 與 devDependencies 依賴,去除不是必要存在於運行時的依賴,方便生產環境使用 npm install --production
安裝依賴。
優化項目鏡像體積
使用儘量精簡的基礎鏡像
我們知道,容器技術提供的是操作系統級別的進程隔離,Docker 容器本身是一個運行在獨立操作系統下的進程,也就是說,Docker 鏡像需要打包的是一個能夠獨立運行的操作系統級環境。因此,決定鏡像體積的一個重要因素就顯而易見了:打包進鏡像的 Linux 操作系統的體積。
一般來說,減小依賴的操作系統的大小主要需要考慮從兩個方面下手,第一個是儘可能去除 Linux 下不需要的各類工具庫,如 python,cmake, telnet 等。第二個是選取更輕量級的 Linux 發行版系統。正規的官方鏡像應該會依據上述兩個因素對每個發行版提供閹割版本。
以 node 官方提供的版本 node:14 為例,預設版本中,它的運行基礎環境是 Ubuntu,是一個大而全的 Linux 發行版,以保證最大的相容性。去除了無用工具庫的依賴版本稱為 node:14-slim 版本。而最小的鏡像發行版稱為 node:14-alpine。Linux alpine 是一個高度精簡,僅包含基本工具的輕量級 Linux 發行版,本身的 Docker 鏡像只有 4~5M 大小,因此非常適合製作最小版本的 Docker 鏡像。
在我們的服務中,由於運行該服務的依賴是確定的,因此為了儘可能的縮減基礎鏡像的體積,我們選擇 alpine 版本作為生產環境的基礎鏡像。
分級構建
這時候,我們遇到了新的問題。由於 alpine 的基本工具庫過於簡陋,而像 webpack 這樣的打包工具背後可能使用的插件庫極多,構建項目時對環境的依賴較大。並且這些工具庫只有編譯時需要用到,在運行時是可以去除的。對於這種情況,我們可以利用 Docker 的分級構建
的特性來解決這一問題。
首先,我們可以在完整版鏡像下進行依賴安裝,並給該任務設立一個別名(此處為build
)。
# 安裝完整依賴並構建產物
FROM node:14 AS build
WORKDIR /app
COPY package*.json /app/
RUN ["npm", "install"]
COPY . /app/
RUN npm run build
之後我們可以啟用另一個鏡像任務來運行生產環境,生產的基礎鏡像就可以換成 alpine 版本了。其中編譯完成後的源碼可以通過--from
參數獲取到處於build
任務中的文件,移動到此任務內。
FROM node:14-alpine AS release
WORKDIR /release
COPY package*.json /
RUN ["npm", "install", "--registry=http://r.tnpm.oa.com", "--production"]
# 移入依賴與源碼
COPY public /release/public
COPY --from=build /app/dist /release/dist
# 啟動服務
EXPOSE 8000
CMD ["node", "./dist/index.js"]
Docker 鏡像的生成規則是,生成鏡像的結果僅以最後一個鏡像任務為準。因此前面的任務並不會占用最終鏡像的體積,從而完美解決這一問題。
當然,隨著項目越來越複雜,在運行時仍可能會遇到工具庫報錯,如果曝出問題的工具庫所需依賴不多,我們可以自行補充所需的依賴,這樣的鏡像體積仍然能保持較小的水平。
其中最常見的問題就是對node-gyp
與node-sass
庫的引用。由於這個庫是用來將其他語言編寫的模塊轉譯為 node 模塊,因此,我們需要手動增加g++ make python
這三個依賴。
# 安裝生產環境依賴(為相容 node-gyp 所需環境需要對 alpine 進行改造)
FROM node:14-alpine AS dependencies
RUN apk add --no-cache python make g++
COPY package*.json /
RUN ["npm", "install", "--registry=http://r.tnpm.oa.com", "--production"]
RUN apk del .gyp
合理規劃 Docker Layer
構建速度優化
我們知道,Docker 使用 Layer 概念來創建與組織鏡像,Dockerfile 的每條指令都會產生一個新的文件層,每層都包含執行命令前後的狀態之間鏡像的文件系統更改,文件層越多,鏡像體積就越大。而 Docker 使用緩存方式實現了構建速度的提升。若 Dockerfile 中某層的語句及依賴未更改,則該層重建時可以直接復用本地緩存。
如下所示,如果 log 中出現Using cache
字樣時,說明緩存生效了,該層將不會執行運算,直接拿原緩存作為該層的輸出結果。
Step 2/3 : npm install
---> Using cache
---> efvbf79sd1eb
通過研究 Docker 緩存演算法,發現在 Docker 構建過程中,如果某層無法應用緩存,則依賴此步的後續層都不能從緩存載入。例如下麵這個例子:
COPY . .
RUN npm install
此時如果我們更改了倉庫的任意一個文件,此時因為npm install
層的上層依賴變更了,哪怕依賴沒有進行任何變動,緩存也不會被覆用。
因此,若想儘可能的利用上npm install
層緩存,我們可以把 Dockerfile 改成這樣:
COPY package*.json .
RUN npm install
COPY src .
這樣在僅變更源碼時,node_modules
的依賴緩存仍然能被利用上了。
由此,我們得到了優化原則:
-
最小化處理變更文件,僅變更下一步所需的文件,以儘可能減少構建過程中的緩存失效。
-
對於處理文件變更的 ADD 命令、COPY 命令,儘量延遲執行。
構建體積優化
在保證速度的前提下,體積優化也是我們需要去考慮的。這裡我們需要考慮的有三點:
-
Docker 是以層為單位上傳鏡像倉庫的,這樣也能最大化的利用緩存的能力。因此,執行結果很少變化的命令需要抽出來單獨成層,如上面提到的
npm install
的例子里,也用到了這方面的思想。 -
如果鏡像層數越少,總上傳體積就越小。因此,在命令處於執行鏈尾部,即不會對其他層緩存產生影響的情況下,儘量合併命令,從而減少緩存體積。例如,設置環境變數和清理無用文件的指令,它們的輸出都是不會被使用的,因此可以將這些命令合併為一行 RUN 命令。
RUN set ENV=prod && rm -rf ./trash
- Docker cache 的下載也是通過層緩存的方式,因此為了減少鏡像的傳輸下載時間,我們最好使用固定的物理機器來進行構建。例如在流水線中指定專用宿主機,能是的鏡像的準備時間大大減少。
當然,時間和空間的優化從來就沒有兩全其美的辦法,這一點需要我們在設計 Dockerfile 時,對 Docker Layer 層數做出權衡。例如為了時間優化,需要我們拆分文件的複製等操作,而這一點會導致層數增多,略微增加空間。
這裡我的建議是,優先保證構建時間,其次在不影響時間的情況下,儘可能的縮小構建緩存體積。
以 Docker 的思維管理服務
避免使用進程守護
我們編寫傳統的後臺服務時,總是會使用例如 pm2、forever 等等進程守護程式,以保證服務在意外崩潰時能被監測到並自動重啟。但這一點在 Docker 下非但沒有益處,還帶來了額外的不穩定因素。
首先,Docker 本身就是一個流程管理器,因此,進程守護程式提供的崩潰重啟,日誌記錄等等工作 Docker 本身或是基於 Docker 的編排程式(如 kubernetes)就能提供了,無需使用額外應用實現。除此之外,由於守護進程的特性,將不可避免的對於以下的情況產生影響:
-
增加進程守護程式會使得占用的記憶體增多,鏡像體積也會相應增大。
-
由於守護進程一直能正常運行,服務發生故障時,Docker 自身的重啟策略將不會生效,Docker 日誌里將不會記錄崩潰信息,排障溯源困難。
-
由於多了個進程的加入,Docker 提供的 CPU、記憶體等監控指標將變得不准確。
因此,儘管 pm2 這樣的進程守護程式提供了能夠適配 Docker 的版本:pm2-runtime
,但我仍然不推薦大家使用進程守護程式。
其實這一點其實是源自於我們的固有思想而犯下的錯誤。在服務上雲的過程中,難點其實不僅僅在於寫法與架構上的調整,開發思路的轉變才是最重要的,我們會在上雲的過程中更加深刻體會到這一點。
日誌的持久化存儲
無論是為了排障還是審計的需要,後臺服務總是需要日誌能力。按照以往的思路,我們將日誌分好類後,統一寫入某個目錄下的日誌文件即可。但是在 Docker 中,任何本地文件都不是持久化的,會隨著容器的生命周期結束而銷毀。因此,我們需要將日誌的存儲跳出容器之外。
最簡單的做法是利用 Docker Manager Volume
,這個特性能繞過容器自身的文件系統,直接將數據寫到宿主物理機器上。具體用法如下:
docker run -d -it --name=app -v /app/log:/usr/share/log app
運行 docker 時,通過-v 參數為容器綁定 volumes,將宿主機上的 /app/log
目錄(如果沒有會自動創建)掛載到容器的 /usr/share/log
中。這樣服務在將日誌寫入該文件夾時,就能持久化存儲在宿主機上,不隨著 docker 的銷毀而丟失了。
當然,當部署集群變多後,物理宿主機上的日誌也會變得難以管理。此時就需要一個服務編排系統來統一管理了。從單純管理日誌的角度出發,我們可以進行網路上報,給到雲日誌服務(如騰訊雲 CLS)托管。或者乾脆將容器進行批量管理,例如Kubernetes
這樣的容器編排系統,這樣日誌作為其中的一個模塊自然也能得到妥善保管了。這樣的方法很多,就不多加贅述了。
k8s 服務控制器的選擇
鏡像優化之外,服務編排以及控制部署的負載形式對性能的影響也很大。這裡以最流行的Kubernetes
的兩種控制器(Controller):Deployment
與 StatefulSet
為例,簡要比較一下這兩類組織形式,幫助選擇出最適合服務的 Controller。
StatefulSet
是 K8S 在 1.5 版本後引入的 Controller,主要特點為:能夠實現 pod 間的有序部署、更新和銷毀。那麼我們的製品是否需要使用 StatefulSet
做 pod 管理呢?官方簡要概括為一句話:
Deployment 用於部署無狀態服務,StatefulSet 用來部署有狀態服務。
這句話十分精確,但不易於理解。那麼,什麼是無狀態呢?在我看來,StatefulSet
的特點可以從如下幾個步驟進行理解:
-
StatefulSet
管理的多個 pod 之間進行部署,更新,刪除操作時能夠按照固定順序依次進行。適用於多服務之間有依賴的情況,如先啟動資料庫服務再開啟查詢服務。 -
由於 pod 之間有依賴關係,因此每個 pod 提供的服務必定不同,所以
StatefulSet
管理的 pod 之間沒有負載均衡的能力。 -
又因為 pod 提供的服務不同,所以每個 pod 都會有自己獨立的存儲空間,pod 間不共用。
-
為了保證 pod 部署更新時順序,必須固定 pod 的名稱,因此不像
Deployment
那樣生成的 pod 名稱後會帶一串隨機數。 -
而由於 pod 名稱固定,因此跟
StatefulSet
對接的Service
中可以直接以 pod 名稱作為訪問功能變數名稱,而不需要提供Cluster IP
,因此跟StatefulSet
對接的Service
被稱為Headless Service
。
通過這裡我們就應該明白,如果在 k8s 上部署的是單個服務,或是多服務間沒有依賴關係,那麼 Deployment
一定是簡單而又效果最佳的選擇,自動調度,自動負載均衡。而如果服務的啟停必須滿足一定順序,或者每一個 pod 所掛載的數據 volume 需要在銷毀後依然存在,那麼建議選擇 StatefulSet
。
本著如無必要,勿增實體的原則,強烈建議所有運行單個服務工作負載採用 Deployment
作為 Controller。
寫在結尾
一通研究下來,差點把一開始的目標忘了,趕緊將 Docker 重新構建一遍,看看優化成果。
可以看到,對於鏡像體積的優化效果還是不錯的,達到了 10 倍左右。當然,如果項目中不需要如此高版本的 node 支持,還能進一步縮小大約一半的鏡像體積。
之後鏡像倉庫會對存放的鏡像文件做一次壓縮,以 node14 打包的鏡像版本最終被壓縮到了 50M 以內。
當然,除了看得到的體積數據之外,更重要的優化其實在於,從面向物理機的服務向容器化雲服務在架構設計層面上的轉變。
容器化已經是看得見的未來,作為一名開發人員,要時刻保持對前沿技術的敏感,積極實踐,才能將技術轉化為生產力,為項目的進化做出貢獻。
參考資料:
- 《Kubernetes in action》--Marko Lukša
- Optimizing Docker Images