還不懂 Docker? 一文帶你瞭解清楚!

Why Docker?

Docker 容器化技術是當今最重要的基礎設施之一,或者說它已經成爲服務程序 的標準化運行環境。

先不談它相比傳統的虛擬化技術有多少優勢,站在軟件工程角度,筆者認爲,Docker 有兩個重要的意義:

一)「提供一致性的運行環境。讓我們的程序在一致性的環境中運行:不管是開發環境、測試環境、還是生產環境;不管是開發時、構建時、還是運行時」

比如開發時可以使用 Docker Dev Environments, 可以配合 VsCode Remote 開發,從而實現跳槽時或者換設備,可以快速 Setup 自己的開發環境。有興趣的可以看看掘友寫的 Docker 化一個前端基礎開發環境:簡潔高效的選擇

構建時,現在 CI/CD 平臺都是基於 Docker 來提供多樣化的構建環境需求。

運行時,‘巨輪’ K8S 已經是雲時代的重要基礎設施。

「二)標準化的服務程序封裝技術。」

在沒有容器之前,使用不同編程語言或框架編寫的程序,部署和運行的方式千差萬別。比如 Java 會生成 jar 包或者 war 包,運行環境需要預裝指定版本的 JDK…

而現在,容器鏡像成爲了標準的服務程序封裝技術。鏡像中包含了程序以及程序對運行環境的依賴

不管前後端應用都可以使用鏡像的形式進行分發和流通。這應該就是 Docker Logo,那條鯨魚馱着貨運箱的解釋吧:就像我們平時下載、傳遞 Zip 文件一樣, 鏡像是雲時代’通用貨幣’,可以在研發的不同環節、區域中流通。

這種標準化的打包格式、輕量化的運行時,不僅給開發者和運維帶來便利, 也催生了強大的容器管理工具比如 K8S,  「K8S 現在已經是容器和集羣管理的標準。」

「那 Docker 之於前端意義是啥?」

Docker 對前端的意義也很重大。實際上,Docker 的世界裏,並不區分什麼前端、後端,沒有人說只適合後端、不適合前端 … 在運維的眼裏更是如此

爲了照顧那些不太懂 Docker 的開發者,本文會循序漸進、由淺入深地講解。如果你需要 Docker 入門教程,推薦你看看 Docker —— 從入門到實踐

主要分成三個部分:

標準化 CI/CD

市面上有很多 CI/CD 產品,比如 GitLab、Github Action、Jenkins、Zadig… 它們的構建配置、腳本語法差異都挺大,基本上是不能共用的。

比如我們公司前不久引入了 Zadig,原本基於 Jenkinks 的構建配置幾乎需要重新適配。

** 有沒有跨‘平臺’的方式?** 於是,我開始探索將前端 CI/CD 的流程完全集成到 Docker 鏡像構建中去。

從簡單的單元測試開始

我們先從簡單的任務開始。先來寫一個簡單的單元測試:

FROM node:20-slim

# 🔴 pnpm 安裝
RUN corepack enable

# 🔴 拷貝源代碼
COPY . /app
WORKDIR /app

# 🔴 安裝依賴
RUN pnpm install

# 🔴 執行測試
RUN pnpm test

⁉️ corepack?  NodeJS 的包管理碎片化越來越驗證了,以前我們區分 npm、yarn、pnpm, 現在還要繼續分裂版本,pnpm v7、pnpm v8…
NodeJS 官方推出的 Corepack 應該可以救你一命

別忘了 .dockerignore

node_modules
.git
.gitignore
*.md
dist

⁉️ 爲什麼不能遺漏 .dockerignore 呢?

構建運行:

$ docker build . --progress=plain
#1 [internal] load build definition from Dockerfile.for_test
#1 transferring dockerfile: 40B done
#1 DONE 0.0s

#2 [internal] load .dockerignore
#2 transferring context: 34B done
#2 DONE 0.0s

#3 [internal] load metadata for docker.io/library/node:20-slim
#3 DONE 1.5s

#4 [1/6] FROM docker.io/library/node:20-slim@sha256:6eea4330e89a0c6a8106d0bee575d3d9978b51aac16ffe7f6825e78727815631
#4 CACHED

#5 [internal] load build context
#5 transferring context: 227B done
#5 DONE 0.0s

#6 [2/6] RUN corepack enable
#6 DONE 0.2s

#7 [3/6] COPY . /app
#7 DONE 0.0s

#8 [4/6] WORKDIR /app
#8 DONE 0.0s

#9 [5/6] RUN pnpm install
#9 4.878 Lockfile is up to date, resolution step is skipped
#9 4.880 Progress: resolved 1, reused 0, downloaded 0, added 0
#9 4.881 Packages: +1
#9 4.881 +
#9 6.603 Progress: resolved 1, reused 0, downloaded 1, added 0
#9 6.643 Packages are hard linked from the content-addressable store to the virtual store.
#9 6.643   Content-addressable store is at: /root/.local/share/pnpm/store/v3
#9 6.643   Virtual store is at:             node_modules/.pnpm
#9 6.659 
#9 6.659 dependencies:
#9 6.659 + lodash 4.17.21
#9 6.659 
#9 6.661 Done in 2s
#9 7.608 Progress: resolved 1, reused 0, downloaded 1, added 1, done
#9 DONE 7.7s

#10 [6/6] RUN pnpm test
#10 0.497 測試通過
#10 DONE 0.5s

#11 exporting to image
#11 exporting layers
#11 exporting layers 0.2s done
#11 writing image sha256:9d61ce0fd5d96685aa62fb268db37b3dea4cfa1699df73d8d6a7de259c914a8d done
#11 DONE 0.2s

二次運行的結果:

#1 [internal] load build definition from Dockerfile.for_test
#1 transferring dockerfile: 40B done
#1 DONE 0.0s

#2 [internal] load .dockerignore
#2 transferring context: 34B done
#2 DONE 0.0s

#3 [internal] load metadata for docker.io/library/node:20-slim
#3 DONE 3.2s

#4 [1/6] FROM docker.io/library/node:20-slim@sha256:6eea4330e89a0c6a8106d0bee575d3d9978b51aac16ffe7f6825e78727815631
#4 DONE 0.0s

#5 [internal] load build context
#5 transferring context: 227B done
#5 DONE 0.0s

#6 [2/6] RUN corepack enable
#6 CACHED  <- 🔴 緩存了

#7 [4/6] WORKDIR /app
#7 CACHED

#8 [3/6] COPY . /app
#8 CACHED

#9 [5/6] RUN pnpm install
#9 CACHED

#10 [6/6] RUN pnpm test
#10 CACHED <- 🔴 緩存了
...

Docker 鏡像是多層存儲的, 每一層是在前一層的基礎上進行的修改。換句話說,  Dockerfile 文件中的每條指令 (Instruction) 都是在構建新的一層。

Docker 使用了緩存來加速鏡像構建,所以上面執行結果可以看出只要上一層當前層的輸入沒有變動,那麼執行結果就會被緩存下來。

現在你可以隨便更動 src/* 或者 package.json , 再執行構建,會發現,從 COPY 指令那裏重新開始執行了:

# ...

#3 [internal] load metadata for docker.io/library/node:20-slim
#3 DONE 1.3s

#4 [1/6] FROM docker.io/library/node:20-slim@sha256:75404fc5825f24222276501c09944a5bee8ed04517dede5a9934f1654ca84caf
#4 DONE 0.0s

#5 [internal] load build context
#5 transferring context: 525B done
#5 DONE 0.0s

#6 [2/6] RUN corepack enable
#6 CACHED

# 🔴 變更點
#7 [3/6] COPY . /app
#7 DONE 0.0s

#8 [4/6] WORKDIR /app
#8 DONE 0.0s

#9 [5/6] RUN pnpm install
#....

也就是,又從 0 開始進行 pnpm install

緩存處理

前端構建會深度依賴緩存來加速,比如 node_modules、Webpack 的模塊緩存、vite 的 prebundle、Typescript 的 tsBuildInfoFile …

上面從零開始 pnpm install 顯然是無法接受的。每次都是從頭開始,構建的過程會變得很慢。有什麼解決辦法呢?

「解決辦法 1)利用 Docker 分層緩存」

pnpm 依賴的安裝,其實只需要 package.jsonpnpm-lock.yaml 等文件就夠了,那我們是不是可以把 COPY 拆分從兩步?採用動靜分離策略,分離 package.json 和源代碼的變更。畢竟 package.json 的變更頻率要低得多:

FROM node:20-slim

RUN corepack enable
WORKDIR /app

# 拷貝依賴聲明
COPY package.json pnpm-lock.yaml /app/
RUN pnpm install

# 拷貝源代碼
COPY . /app
RUN pnpm test

「解決辦法 2) RUN 掛載緩存」

方案 1 還是有很多缺陷,比如 package.json 只要變動一個字節,都會導致 pnpm 重新安裝。能不能在運行 build 的時候掛載緩存目錄進去?把  node_modules 或者 pnpm store 緩存下來?

Docker build 確實支持掛載 (BuildKit, 需要 Docker 18.09+)。以下是緩存 pnpm 的示例 (來自官方文檔):

FROM node:20-slim

RUN corepack enable
WORKDIR /app

# 拷貝依賴聲明
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
COPY package.json pnpm-lock.yaml /app/
# 掛載緩存
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install

# 拷貝源代碼
COPY . /app
RUN pnpm test

💡你也可以通過設置 DOCKER_BUILDKIT=1 環境變量來啓用 BuildKit

RUN —mount 參數可以指定要掛載的目錄,對應的緩存會存儲在宿主機器中。這樣就解決了 Docker 構建過程的外部緩存問題。

同理其他的緩存,比如 vite、Webpack,也是通過 —mount 掛載。一個 RUN 支持指定多個 —mount

⚠️ 因爲採用掛載形式,這種跨設備會導致 pnpm 回退到拷貝模式 (pnpm store → node_modules),而不是鏈接模式,所以安裝性能會有所損耗。

如果是 npm 通常需要緩存 ~/.npm 目錄

多階段構建

假設我們要在原有單元測試的基礎上,加入編譯任務。並且要求兩個命令支持**「獨立執行」**,比如在代碼 commit 到遠程倉庫時只執行單元測試,發佈時才執行單元測試 + 編譯。

第一種解決辦法就是創建兩個 Dockerfile, 這個方案的缺點就是指令重複 (比如 pnpm 安裝依賴)。另一個缺點就是如果任務之間有依賴或文件交互,那麼整合起來也會比較麻煩。

更好的辦法就是多階段構建(Multi-Stage)。Docker 允許將多個構建步驟整合在一個 Dockerfile 文件中,這個構建步驟之間可以存在依賴關係,也可以進行文件傳遞,還可以更好地利用緩存。

# 🔴 階段 1,安裝依賴
FROM node:20-slim as base

RUN corepack enable
WORKDIR /app

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

# 拷貝依賴聲明
COPY package.json pnpm-lock.yaml /app/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install

# 🔴 階段 2,單元測試
FROM base as test

# 拷貝源代碼
COPY . /app
RUN pnpm test

# 🔴 階段 3,構建
FROM test as build
RUN pnpm build

通過 FROM * as NAME 的形式創建一個階段。FROM 可以指定依賴的其他步驟。

現在我們運行:

$ docker build .

默認會執行最後一個階段。即 build。

如果我們只想跑 test,可以通過 —target 參數指定:

$ docker build --target=test .

我們再來看一個典型的複雜例子,Nextjs 程序構建:

FROM node:19-alpine AS base

# 0. 構建依賴, 爲什麼要分開一步構建依賴呢,這是爲了利用 Docker 的構建緩存
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json .npmrc pnpm-lock.yaml* ./
RUN npm i -g pnpm@7 && pnpm install 

# 1. 第一步構建編譯
FROM base AS builder
WORKDIR /app

# COPY 依賴
COPY --from=deps /app/node_modules /app/node_modules
# COPY 源代碼
COPY . .

# COPY .env.production.sample .env.production
RUN env && ls -a && npm run build

# 2. 第二步,運行
FROM base AS runner

ENV NODE_ENV production

# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

WORKDIR /app

COPY --from=builder --chown=nextjs:nodejs app/public /app/public
COPY --from=builder --chown=nextjs:nodejs app/.next/standalone /app
COPY --from=builder --chown=nextjs:nodejs app/.next/static /app/.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

CMD ["node""server.js"]

多階段構建的另一個好處是隱藏構建的細節:  比如上游構建的過程中傳遞的一些敏感信息、隱藏源代碼等。

在上面的 Next.js 例子中, 最終構建的是 runner,  它從 builder 中拷貝編譯的結果,對最終的鏡像使用者來說,是查看不到 builder 的構建細節和內容的。

構建參數

程序在構建時可能會有一些微調變量,比如調整 Webpack PublicPath、編譯產物的目標平臺、調試開關等等。

在 DockerFile 下可以通過 ARG 指令來聲明構建參數

# 聲明構建參數,支持默認值
ARG DOCKER_USERNAME=library

# 可以在 DockerFile 中作爲 '模板變量' 使用
FROM ${DOCKER_USERNAME}/alpine

# 打印 library
RUN echo ${DOCKER_USERNAME}

# 打印 包含 DOCKER_USERNAME=library 
RUN env

ARGENV 的效果一樣,都是設置**「環境變量」**。不同的是,ARG 所設置是構建時的環境變量,在將來容器運行時是不會存在這些環境變量的。

⚠️  注意,儘量不要在 ARG 放置敏感信息,因爲 docker history  可以看到構建的過程

通過 docker build --build-arg Key=[Value] 設置構建參數:

$ docker build --build-arg BABEL_ENV=test .

# 🔴 或者只指定 KEY, Value 自動獲取
$ docker build --build-arg BABEL_ENV .

怎麼支持更復雜的構建需求?

Dockerfile 中不建議放置複雜的邏輯,而且它語法支持也很有限。如果有複雜的構建需求,更應該通過  Shell 腳本或者 Node 程序來實現。

集成到 CI/CD 平臺

上文,我們探索了使用 Docker 來實現‘跨平臺’(CI/CD) 的構建任務。看起來還不錯,應該能夠滿足我們的需求。

通常這些平臺對 Docker 鏡像構建的支持都是開箱即用的, 如果使用 Dockerfile 方案,我們可以免去一些額外的聲明,比如構建依賴的軟件包、緩存配置、構建腳本等等。

現在只需要關注 Dockerfile 構建, 下圖以 Zadig 爲例。在 Zadig 中,我們只需要告訴 Dockerfile 在哪,其餘的工作 (比如鏡像 tag、鏡像發佈) 都不需要操心:

接入其他構建平臺也是類似的,「我們只需要學習對應平臺如何構建鏡像就行」

標準化部署和運行

上一節, 講到將 Docker 作爲‘跨平臺’的任務執行環境。下一步就是發佈、部署、運行。注意接下內容可能需要你對 K8S 有基本的瞭解。

鏡像發佈就不用展開說了,就和 npm 發佈一樣簡單。本節的重點在於討論,前端‘應用’在容器環境如何對外服務。

目前比較主流的前端應用可以分爲三類:

純靜態資源

估計 80% 以上的前端應用都是純靜態的。

筆者嘗試過多種部署的方式。在我們將前端應用容器化的初期, 有過這樣一種中間的演進形態:

在改造之前我們所有的前端靜態資源都堆在一個靜態資源服務器中 (上圖左側),所有人都有部署權限、所有人都能改 Nginx 配置、目錄混亂。部署方式也是各顯神通,有 Jenkins 自動部署、有 FTP/rsync 手動上傳… 就是一個極其原始的狀態。

在容器化改造的初期,運維把靜態資源服務器轉換成爲了 Nginx 容器,而原本 Nginx 的配置通過配置映射(Config Map)來掛載到容器內部。

前端應用也做了非常簡單的改造, 就是簡單把靜態資源 COPY 到鏡像中:

FROM busybox:latest
COPY dist /data

運行時,前端應用以 Nginx 容器Sidecar 形式存在,在啓動時向共享的 PVC (數據卷) 拷貝靜態資源。

更理想的情況是每個前端應用能夠獨立對外服務, 對鏡像的使用者來說,他應該是開箱即用的、自包含、透明的。

所以我們對部分比較獨立的應用進行了重構:

如上圖, 前端應用基於 nginx 運行,流量會通過 Ingress 來分發到不同的應用,分發的方式通常有域名、請求路徑等等。

這也進一步簡化了運維的工作,運維只需要前端後兩個鏡像就可以將一套系統部署起來。

我們稍微改造一下上文的 Dockerfile 來支持 nginx 部署:

# 🔴 階段 1,安裝依賴
FROM node:20-slim as base

RUN corepack enable
WORKDIR /app

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

# 拷貝依賴聲明
COPY package.json pnpm-lock.yaml /app/
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install

# 🔴 階段 2,單元測試
FROM base as test

# 拷貝源代碼
COPY . /app
RUN pnpm test

# 🔴 階段 3,構建
FROM test as build
RUN pnpm build

# 🔴 階段 4,運行
FROM nginx:stable-alpine as deploy
COPY --from=build /app/dist/ /usr/nginx/wwwroot

# 如果需要自定義 nginx 配置,可以開啓這行
#COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80
CMD ["nginx""-g""daemon off;"]

NodeJS 程序

這個和普通後端服務沒什麼區別,狹義上不屬於前端的範疇,沒有太多可以講的,可以參考上文的 Next.js 示例。

微前端

我在微前端的落地和治理實戰 中簡單介紹過:

我們公司目前採用的是上圖的 Sidecar 模式。每個子應用都是一個 Sidecar,啓動時將自己‘註冊’到基座中,由基座統一對外服務。

好處:基座可以統一管理所有子應用。比如可以實現‘子應用發現’、動態配置替換之類的工作

壞處:依賴 PVC 共享存儲。我們也有遇到部分客戶環境不支持共享 PVC 的。

對於不支持共享 PVC 的場景,我們也會進行回退:

讓每個子應用獨立對外服務,每個子應用都有自己的前綴, Ingress 根據前綴來分發流量。

好處就是子應用可以自己管理自己,升級和流量控制會更加靈活。缺點就是基座無法感知到這些子應用的存在,需要手動配置這些子應用的信息。

如果要更進一步,可以將基座定義爲類似後端 “註冊中心”, 子應用主動向基座註冊,有點後端微服務的味道了。如果真需要複雜到這一步,也沒有必要自己造輪子,複用後端的技術棧不是更香?

除此之外,還有很多手段,比如基座提供發佈服務,子應用調用基座發佈服務,將自己的應用信息、靜態資源提交給基座。

不是銀彈

上面我們介紹了基於 Docker 容器的前端應用部署的各種方式和場景。但這並不是銀彈!前端也不一定非得就要容器化。

很多大廠都有自己成熟的發佈、部署流程和系統平臺,他們需要應付各種複雜的情況,  比如大流量、CDN 同步、熔斷降級、灰度發佈、藍綠髮布,回滾…  那本文講到的各種‘樸素’的技巧,就是一種雕蟲小技

「那它對我們爲什麼有用?」

我們主要做 ToB 業務,容器化的方案可以應付私有化交付、私有化部署需求。開發和運維會面對各種千奇百怪的運行環境、公有云、私有云。但大部分甲方都會提供基礎的 K8S 環境,容器化對我們來說就是一個最簡單且高效的方案。

另外,依託於 K8S 這類強大容器管理平臺,大部分問題都有解決方案,何必造輪子呢?

一些高級話題

「一份基準代碼,多份部署」

12-factors 裏有一個原則:一份基準代碼,多份部署。如果放在容器這個上下文中,就是一個鏡像應該能夠在不同的環境部署,而不需要任何修改。

這對我們做 ToB 的也很重要,如果我們爲一個客戶做一次私有化部署,就要將所有的應用重新構建一遍,這顯然無法接受。

對於後端服務來說,很容易做到,要麼通過環境變量,要麼就從配置中心動態拉取。

而對於前端來說,靜態資源的各種 URL (比如 CDN 鏈接) 和配置可能在構建時就固定下來了。而且我們的代碼不運行在服務端,因此也不能通過環境變量來動態配置。

當然,也有解決辦法:

下面以 Nginx SSI + Vite  爲例, 演示一下 SSI:

vite 配置:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  experimental: {
    renderBuiltUrl(filename) {
      return "<!--# echo var='public_url' -->" + filename
    }

  }

})

<!--# echo var='public_url' --> 是 SSI 的指令語法。這裏使用 Vite 實驗性的 renderBuiltUrl 來配置(因爲直接使用 base 會有問題)。

Dockerfile:

FROM nginx:stable-alpine

COPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf

EXPOSE 80

# 這裏是需要顯式告訴 envsubst 要替換的環境變量,如果有多個環境變量,使用 ',' 分割
# 因爲 nginx 變量的語法和 環境變量相似,如果不顯式設置,envsubst 可能會誤替其他 nginx 變量
CMD (cat /etc/nginx/nginx.conf | envsubst '${PUBLIC_URL}' >/etc/nginx/nginx.conf) && cat /etc/nginx/nginx.conf && nginx -g 'daemon off;'

「nginx 配置文件中無法愉快地引用環境變量」,所以曲線救國, 使用 envsubst 來替換 nginx.conf 中的環境變量佔位符。

Nginx 配置:

# ... 省略

        location / {
            # 開啓 ssi
            ssi on;
            ssi_last_modified on;
            # 支持 html、js、css 等文件
            ssi_types text/html application/javascript text/css;
            # 設置變量,將由 envsubst 替換,格式爲 ${NAME-defaultValue}
            set $public_url "${PUBLIC_URL-/}";
            root /usr/share/nginx/html;
            index index.html index.htm;
        }

# ... 省略

自己試試看吧!

如何做灰度發佈、藍綠髮布…?

在 K8S 環境,有挺多簡單的手段可以實現灰度 (金絲雀發佈) 發佈、藍綠髮布這些功能,比如:

還有很多實現手段,因爲不是本文的重點,就不贅述了。如果大家有更好更簡單的方式也可以評論區交流。

「那如果按照上文講的微前端部署方式,怎麼實現子應用灰度呢?」

這裏不需要用到複雜的流量分發技術,因爲基座自己會收集子應用的信息,那麼只需要在子應用註冊表上做文章就行了。例如:

Untitled

這個思路看起來和後端的服務發現平臺 (比如 Nacos) 很像,後端服務實現灰度基本也是依靠這些平臺來實現的。

總結

回顧一下本文。Docker 發佈已經十年,大家對它應該已經熟悉不過了,它對現代的軟件工程有非常重要的意義。

我在這篇文章中分了兩個維度來討論它, 一是將它作爲一個’跨平臺’的任務運行環境,它讓我們可以在一致的環境中運行單測、構建、發佈等任務;二是講怎麼將前端應用容器化,對齊後端,利用現有的容器管理平臺來實現複雜的部署需求。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/3CXK_WF22s2i7e1mGhXBSQ