Node服務怎麼進行Docker映象化?極致優化詳解

2022-10-19 22:00:38

node.js極速入門課程:進入學習

這段時間在開發一個騰訊檔案全品類通用的 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 映象優化前

要是這個映象只需要部署一個範例也就算了,但是這個服務得提供給所有開發同學進行高頻整合並部署環境的(實現高頻整合的方案可參見我的 上一篇文章)。首先,映象體積過大必然會對映象的拉取和更新速度造成影響,整合體驗會變差。其次,專案上線後,同時線上的測試環境範例可能成千上萬,這樣的容器記憶體佔用成本對於任何一個專案都是無法接受的。必須找到優化的辦法解決。

發現問題後,我就開始研究 Docker 的優化方案,準備給我的映象動手術了。

node 專案生產環境優化

首先開刀的是當然是前端最為熟悉的領域,對程式碼本身體積進行優化。之前開發專案時使用了 Typescript,為了圖省事,專案直接使用 tsc 打包生成 es5 後就直接執行起來了。這裡的體積問題主要有兩個,一個是開發環境 ts 原始碼並未處理,並且用於生產環境的 js 程式碼也未經壓縮。

tsc 打包

另一個是參照的 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-gypnode-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
登入後複製

詳情可見:https://github.com/nodejs/docker-node/issues/282

合理規劃 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
登入後複製
  1. 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):DeploymentStatefulSet 為例,簡要比較一下這兩類組織形式,幫助選擇出最適合服務的 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 重新構建一遍,看看優化成果。

docker 映象優化後

可以看到,對於映象體積的優化效果還是不錯的,達到了 10 倍左右。當然,如果專案中不需要如此高版本的 node 支援,還能進一步縮小大約一半的映象體積。

之後映象倉庫會對存放的映象檔案做一次壓縮,以 node14 打包的映象版本最終被壓縮到了 50M 以內。

映象倉庫優化前後

當然,除了看得到的體積資料之外,更重要的優化其實在於,從面向物理機的服務向容器化雲服務在架構設計層面上的轉變。

容器化已經是看得見的未來,作為一名開發人員,要時刻保持對前沿技術的敏感,積極實踐,才能將技術轉化為生產力,為專案的進化做出貢獻。

更多node相關知識,請存取:!

以上就是Node服務怎麼進行Docker映象化?極致優化詳解的詳細內容,更多請關注TW511.COM其它相關文章!