NodeJS 服務 Docker 鏡像極致優化指北

来源:https://www.cnblogs.com/zakum/archive/2022/10/14/16791065.html
-Advertisement-
Play Games

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

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

發現問題後,我就開始研究 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的依賴緩存仍然能被利用上了。

由此,我們得到了優化原則:

  1. 最小化處理變更文件,僅變更下一步所需的文件,以儘可能減少構建過程中的緩存失效。

  2. 對於處理文件變更的 ADD 命令、COPY 命令,儘量延遲執行。

構建體積優化

在保證速度的前提下,體積優化也是我們需要去考慮的。這裡我們需要考慮的有三點:

  1. Docker 是以層為單位上傳鏡像倉庫的,這樣也能最大化的利用緩存的能力。因此,執行結果很少變化的命令需要抽出來單獨成層,如上面提到的npm install的例子里,也用到了這方面的思想。

  2. 如果鏡像層數越少,總上傳體積就越小。因此,在命令處於執行鏈尾部,即不會對其他層緩存產生影響的情況下,儘量合併命令,從而減少緩存體積。例如,設置環境變數和清理無用文件的指令,它們的輸出都是不會被使用的,因此可以將這些命令合併為一行 RUN 命令。

RUN set ENV=prod && rm -rf ./trash
  1. Docker cache 的下載也是通過層緩存的方式,因此為了減少鏡像的傳輸下載時間,我們最好使用固定的物理機器來進行構建。例如在流水線中指定專用宿主機,能是的鏡像的準備時間大大減少。

當然,時間和空間的優化從來就沒有兩全其美的辦法,這一點需要我們在設計 Dockerfile 時,對 Docker Layer 層數做出權衡。例如為了時間優化,需要我們拆分文件的複製等操作,而這一點會導致層數增多,略微增加空間。

這裡我的建議是,優先保證構建時間,其次在不影響時間的情況下,儘可能的縮小構建緩存體積。

以 Docker 的思維管理服務

避免使用進程守護

我們編寫傳統的後臺服務時,總是會使用例如 pm2、forever 等等進程守護程式,以保證服務在意外崩潰時能被監測到並自動重啟。但這一點在 Docker 下非但沒有益處,還帶來了額外的不穩定因素。

首先,Docker 本身就是一個流程管理器,因此,進程守護程式提供的崩潰重啟,日誌記錄等等工作 Docker 本身或是基於 Docker 的編排程式(如 kubernetes)就能提供了,無需使用額外應用實現。除此之外,由於守護進程的特性,將不可避免的對於以下的情況產生影響:

  1. 增加進程守護程式會使得占用的記憶體增多,鏡像體積也會相應增大。

  2. 由於守護進程一直能正常運行,服務發生故障時,Docker 自身的重啟策略將不會生效,Docker 日誌里將不會記錄崩潰信息,排障溯源困難。

  3. 由於多了個進程的加入,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的特點可以從如下幾個步驟進行理解:

  1. StatefulSet管理的多個 pod 之間進行部署,更新,刪除操作時能夠按照固定順序依次進行。適用於多服務之間有依賴的情況,如先啟動資料庫服務再開啟查詢服務。

  2. 由於 pod 之間有依賴關係,因此每個 pod 提供的服務必定不同,所以 StatefulSet 管理的 pod 之間沒有負載均衡的能力。

  3. 又因為 pod 提供的服務不同,所以每個 pod 都會有自己獨立的存儲空間,pod 間不共用。

  4. 為了保證 pod 部署更新時順序,必須固定 pod 的名稱,因此不像 Deployment 那樣生成的 pod 名稱後會帶一串隨機數。

  5. 而由於 pod 名稱固定,因此跟 StatefulSet 對接的 Service 中可以直接以 pod 名稱作為訪問功能變數名稱,而不需要提供Cluster IP,因此跟 StatefulSet 對接的 Service 被稱為 Headless Service

通過這裡我們就應該明白,如果在 k8s 上部署的是單個服務,或是多服務間沒有依賴關係,那麼 Deployment 一定是簡單而又效果最佳的選擇,自動調度,自動負載均衡。而如果服務的啟停必須滿足一定順序,或者每一個 pod 所掛載的數據 volume 需要在銷毀後依然存在,那麼建議選擇 StatefulSet

本著如無必要,勿增實體的原則,強烈建議所有運行單個服務工作負載採用 Deployment 作為 Controller。

寫在結尾

一通研究下來,差點把一開始的目標忘了,趕緊將 Docker 重新構建一遍,看看優化成果。

docker 鏡像優化後

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

之後鏡像倉庫會對存放的鏡像文件做一次壓縮,以 node14 打包的鏡像版本最終被壓縮到了 50M 以內。

鏡像倉庫優化前後

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

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

參考資料:

  1. 《Kubernetes in action》--Marko Lukša
  2. Optimizing Docker Images

您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • Redis核心流程 💡 本文分析基於Redis-1.0源碼,核心流程代碼主要分佈在redis.c,ae.c兩個文件中。 Notion版本 1.Redis核心流程中的重要數據結構 struct redisServer { int port; int fd; redisDb *db; aeEventL ...
  • Navicat是一套快速、可靠和全面的資料庫管理工具,專門用於簡化資料庫管理和降低管理成本。Navicat圖形界面直觀,提供簡便的管理方法,設計和操作MySQL、MariaDB、SQL Server、Oracle、PostgreSQL和SQLite的數據。 navicat 查詢界面字體偏小,看的眼睛 ...
  • 資料庫安裝 選擇全新安裝模式繼續安裝 輸入產品秘鑰:這裡使用演示秘鑰進行 接受許可 規則檢測 可以後期再開放防火牆對外埠 選擇需要安裝的功能,想省事可以選擇【全選】 可以安裝JDK,這邊選擇取消 Polybase 查詢服務 使用預設實例 伺服器配置–預設即可 伺服器配置 Reporting Ser ...
  • 設置MySQL 創建資料庫,預設為UTF-8 下載地址:https://downloads.mysql.com/archives/installer/ 安裝 網站上只有 x86 沒有 x64 位,之前下了 x64 的zip 包,配置比較麻煩,其實這個 x86 包裡面,已經包含了 x64的包 我這邊選 ...
  • 邏輯存儲結構 邏輯存儲結構圖 表空間 表空間文件在Linux下存放在 /var/lib/mysql文件中的 xxx.ibd 文件就是表空間文件 表空間文件用來存儲,記錄,索引等數據。 段 段分為,數據段(Leaf node segment) ,索引段(Non-leaf node segment),回 ...
  • 需要工具: 1、apktool:獲取資源文件,提取圖片文件,佈局文件,還有一些XML的資源文件 2、dex2jar:將APK反編譯成Java源碼(將classes.dex轉化為jar文件) 3、**jd-gui:**查看轉換後的jar文件 1、使用apktool獲得資源文件以及xml文件 1.1、下 ...
  • 這裡給大家分享我在網上總結出來的一些知識,希望對大家有所幫助 1.起因 最近有一個需求,需要使用自定義插件,來對接硬體功能,需要配合對手機的許可權進行判斷和提示,併在對接後對本地文件進行操作,這裡給大家分享下我的碰到的問題,廢話不多說,開搞 2.對接自定義插件,製作自定義基座 manifest.jso ...
  • 在 《JS 模塊化》系列開篇中,曾提到前端技術的發展不斷融入很多後端思想,形成前端的“四個現代化”:工程化、模塊化、規範化、流程化。在該系列文章中已詳細介紹了模塊化的發展及四種模塊化規範。本文簡單聊聊規範化中的 git 規範。 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...