Docker 映像建置

撰寫高效 Dockerfile、多階段建置優化映像大小,並自動推送到 GitLab Container Registry。

Docker 基礎回顧

Docker 是 CI/CD 流程中不可或缺的容器化工具。理解三個核心概念是掌握 Docker 在 CI/CD 中應用的前提:

在 CI/CD 流程中,Docker 扮演三個核心角色:

Docker 映像的核心概念是「層(Layer)」。每一條 Dockerfile 指令都會產生一個新的層,層與層之間可以被快取並重複使用,這是加速 CI 建置效率的關鍵所在。正確理解層的機制,是撰寫高效 Dockerfile 的第一步。當某一層的內容未改變,Docker 就會直接命中快取跳過重建,大幅縮短 Pipeline 執行時間。

Dockerfile 的基本結構包含幾個核心指令:

# Dockerfile 基礎指令速覽
FROM node:20-alpine          # 指定基底映像
WORKDIR /app                 # 設定工作目錄(後續指令的相對路徑基準)
COPY package*.json ./        # 複製檔案進映像
RUN npm ci                   # 在映像中執行指令(每個 RUN 產生一個新層)
ENV NODE_ENV=production      # 設定環境變數
EXPOSE 3000                  # 宣告容器監聽的連接埠(僅作說明用)
USER appuser                 # 切換執行使用者(安全最佳實務)
CMD ["node", "server.js"]    # 容器啟動時執行的預設指令
選擇基底映像時,優先使用 alpine 變體(如 node:20-alpine)可大幅縮小映像體積。alpine 的基底大小僅約 5MB,而標準 Debian 基底約 100MB 以上。若需要更高相容性,可考慮 slim 變體作為折衷方案。生產映像應鎖定具體版本號(如 node:20.11-alpine3.19),避免使用 latest tag 造成不可預期的版本漂移。

多階段建置(Multi-stage Build)最佳化

多階段建置是減少映像大小最有效的手段。透過在一個 Dockerfile 中定義多個 FROM 階段,可以將龐大的建置工具鏈排除在最終映像之外。最終映像只包含應用程式實際執行所需的最小檔案集合。

多階段建置的核心概念:建置階段負責編譯與打包,使用完整的開發工具映像;執行階段只複製最終產物,使用精簡的執行環境映像。兩個階段使用不同的基底映像,Docker 只會將最後一個 FROM 階段輸出為最終映像,前面階段的檔案層完全不會包含在內。
# Node.js 應用的生產級多階段 Dockerfile

# ---- 第一階段:安裝依賴並編譯 ----
FROM node:20-alpine AS builder
WORKDIR /app

# 優先複製 package.json,讓依賴層被快取
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

# ---- 第二階段:精簡的生產執行映像 ----
FROM node:20-alpine AS runner
WORKDIR /app

# 安全最佳實務:建立非 root 使用者
RUN addgroup -g 1001 -S nodejs && \
    adduser -S appuser -u 1001

# 只從建置階段複製必要檔案
COPY --from=builder --chown=appuser:nodejs /app/dist ./dist
COPY --from=builder --chown=appuser:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:nodejs /app/package.json ./

USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/server.js"]
多階段建置通常可將 Node.js 應用映像從 800MB 以上縮減至 150MB 以下。Go 應用甚至可以壓縮至 20MB 以內(使用 scratch 基底映像,即完全空白映像,只複製靜態編譯的二進位檔案)。縮小映像不只節省儲存空間,也能降低攻擊面(Attack Surface),提升安全性。

Go 應用的極致精簡範例:

# Go 應用:使用 scratch 基底達到最小映像
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 靜態編譯,不依賴任何動態函式庫
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server .

# 最終映像:完全空白,只有執行檔
FROM scratch
COPY --from=builder /app/server /server
# 若需要 HTTPS,需複製 CA 憑證
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
ENTRYPOINT ["/server"]
# 結果:映像大小通常 < 15MB

CI 中的 Docker Layer Cache 策略

Layer cache 是加速 CI 建置的最重要手段。當 Dockerfile 的某一層未發生變化時,Docker 會直接使用快取層,跳過重新建置,大幅縮短 Pipeline 執行時間。以下是在 GitLab CI 中充分利用快取的兩種主要策略:

# 策略一:使用 Registry 作為遠端快取後端(推薦,適合多機器 Runner)
build-image:
  stage: build
  image: docker:24.0
  services:
    - docker:24.0-dind
  before_script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
  script:
    - docker buildx build \
        --cache-from type=registry,ref=$CI_REGISTRY_IMAGE:buildcache \
        --cache-to   type=registry,ref=$CI_REGISTRY_IMAGE:buildcache,mode=max \
        --build-arg  BUILDKIT_INLINE_CACHE=1 \
        -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
        -t $CI_REGISTRY_IMAGE:latest \
        --push \
        .

# 策略二:使用 GitLab CI cache 機制(適合單機 Runner)
build-image-local-cache:
  stage: build
  cache:
    key: docker-layer-cache-$CI_COMMIT_REF_SLUG
    paths:
      - .docker-cache/
  script:
    - docker build \
        --cache-from $(cat .docker-cache/tag 2>/dev/null || echo scratch) \
        -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
        .
    - docker save $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA > .docker-cache/image.tar
    - echo "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" > .docker-cache/tag
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
Dockerfile 指令的順序直接影響快取命中率。將變動頻率低的指令(如安裝系統套件、複製 package.json)放在前面,變動頻率高的指令(如 COPY . .)放在最後,可大幅提升快取效率。一旦某一層的快取失效,其後的所有層都必須重新建置——這是撰寫 Dockerfile 時最重要的排序原則。

快取命中率優化的黃金排序:

使用 BuildKit(Docker 18.09+ 預設啟用)可以進一步最佳化快取。BuildKit 支援並行建置多個無依賴的層,並可透過 --mount=type=cache 在多次建置之間持久化套件管理器的下載快取(如 npmpipcargo),避免每次都重新下載相同的套件。

Docker Compose in CI

在 CI 環境中,Docker Compose 常用於啟動整合測試所需的依賴服務(資料庫、快取、訊息佇列等),讓測試在隔離且可重現的環境中執行。使用 healthcheck 搭配 depends_on: condition: service_healthy,可確保服務完全就緒後才開始測試,避免因啟動競態條件(race condition)導致的不穩定測試結果。

# docker-compose.test.yml — 專為 CI 整合測試設計
version: "3.9"
services:
  app:
    build: .
    environment:
      NODE_ENV: test
      DATABASE_URL: postgres://user:pass@db:5432/testdb
      REDIS_URL: redis://cache:6379
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_started

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: testdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user"]
      interval: 5s
      timeout: 3s
      retries: 5

  cache:
    image: redis:7-alpine

---
# .gitlab-ci.yml 中的整合測試 job
integration-test:
  stage: test
  image: docker:24.0
  services:
    - docker:24.0-dind
  script:
    - docker compose -f docker-compose.test.yml up --abort-on-container-exit
    - docker compose -f docker-compose.test.yml down -v
  after_script:
    - docker compose -f docker-compose.test.yml logs app
--abort-on-container-exit 旗標會在任何一個容器退出時停止整個 Compose 服務群,配合應用程式測試完成後主動退出(process.exit()),可讓 CI job 在測試完成後自動結束,無需等待逾時。記得搭配 down -v 清理匿名 Volume,避免測試資料污染下一次執行。

映像安全掃描(Trivy)

在將映像推送到生產環境之前,應透過自動化工具掃描已知的 CVE 漏洞與設定問題。安全掃描應整合到 CI Pipeline 中,作為「品質閘門(Quality Gate)」的一部分——有高危漏洞則自動阻擋合併,而非事後補救。Trivy 是目前最受歡迎的開源容器安全掃描工具,由 Aqua Security 維護,支援映像漏洞掃描、IaC 設定掃描、Secret 掃描等多種功能。

# 在 CI Pipeline 中整合 Trivy 安全掃描
security-scan:
  stage: test
  image: aquasec/trivy:latest
  script:
    # 掃描映像漏洞,HIGH 與 CRITICAL 等級會讓 Pipeline 失敗
    - trivy image \
        --exit-code 1 \
        --severity HIGH,CRITICAL \
        --no-progress \
        --ignore-unfixed \
        $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

    # 掃描 Dockerfile 本身的設定問題(IaC 設定掃描)
    - trivy config \
        --exit-code 1 \
        --severity HIGH,CRITICAL \
        .

    # 輸出 SARIF 格式報告,可上傳至 GitLab Security Dashboard
    - trivy image \
        --format sarif \
        --output trivy-results.sarif \
        $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  artifacts:
    reports:
      container_scanning: trivy-results.sarif
  allow_failure: false   # 有高危漏洞則阻擋部署

# 使用 Snyk 掃描(需要 SNYK_TOKEN,提供更詳細的修復建議)
snyk-scan:
  stage: test
  image: snyk/snyk:docker
  variables:
    SNYK_TOKEN: $SNYK_TOKEN
  script:
    - snyk container test $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
        --severity-threshold=high \
        --file=Dockerfile
    - snyk container test $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
        --sarif-file-output=snyk-container.sarif
  artifacts:
    reports:
      container_scanning: snyk-container.sarif
Trivy 常用旗標與使用技巧:
略過無修復版本的漏洞:加上 --ignore-unfixed 旗標,只報告已有修補版本的漏洞,避免阻塞無法立即修復的已知問題。
忽略特定 CVE:建立 .trivyignore 檔案(格式:每行一個 CVE 編號),列出經評估後可接受的漏洞,Trivy 掃描時會自動略過。
定期排程掃描:除了建置時掃描,應設定排程 Job(每日或每週)重新掃描已在線上運行的映像,以應對新發現的漏洞(映像內容不變但 CVE 資料庫持續更新)。
掃描範圍控制:使用 --vuln-type os,library 分別掃描 OS 套件與應用程式依賴,可更精確地定位問題來源。
建議將安全掃描設為 allow_failure: false,強制所有高危漏洞在合併前被修復。初期若有大量既有漏洞,可先以 .trivyignore 記錄並追蹤,逐步清零,而非一開始就放棄掃描或設為 allow_failure: true

Registry 推送策略與 Tag 命名規範

一套清晰的 tag 命名策略,能讓團隊在回滾或追查問題時快速定位目標映像。Tag 策略需兼顧「可追溯性」(每個映像對應到唯一的程式碼版本)與「方便性」(特定環境能用固定的 tag 名稱拉取最新映像)。

主流 Container Registry 比較:

# 推薦的 Tag 命名策略與推送邏輯
variables:
  # 不可變標籤:以 Commit SHA 作為唯一識別(生產部署應使用此格式)
  TAG_SHA:     $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  # 分支標籤:可被同分支的新 commit 覆蓋(追蹤各分支最新狀態)
  TAG_BRANCH:  $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG
  # 語意化版本:由 git tag(如 v1.2.3)觸發時設定
  TAG_VERSION: $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG

build-and-push:
  stage: build
  script:
    - docker build -t $TAG_SHA .
    # 加上分支標籤(方便 staging 環境追蹤最新版本)
    - docker tag $TAG_SHA $TAG_BRANCH
    - docker push $TAG_SHA
    - docker push $TAG_BRANCH
    # 只有 main branch 才推送 latest 標籤
    - |
      if [ "$CI_COMMIT_BRANCH" = "main" ]; then
        docker tag $TAG_SHA $CI_REGISTRY_IMAGE:latest
        docker push $CI_REGISTRY_IMAGE:latest
      fi
    # 有 git tag 時推送語意化版本標籤(同時推 v1.2.3、1.2、1)
    - |
      if [ -n "$CI_COMMIT_TAG" ]; then
        MAJOR=$(echo $CI_COMMIT_TAG | cut -d. -f1 | tr -d v)
        MINOR=$(echo $CI_COMMIT_TAG | cut -d. -f1-2 | tr -d v)
        docker tag $TAG_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
        docker tag $TAG_SHA $CI_REGISTRY_IMAGE:$MINOR
        docker tag $TAG_SHA $CI_REGISTRY_IMAGE:$MAJOR
        docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG
        docker push $CI_REGISTRY_IMAGE:$MINOR
        docker push $CI_REGISTRY_IMAGE:$MAJOR
      fi

# 推送至 Amazon ECR(需設定 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)
push-to-ecr:
  stage: build
  image: amazon/aws-cli:latest
  script:
    - aws ecr get-login-password --region $AWS_REGION | \
        docker login --username AWS --password-stdin $ECR_REGISTRY
    - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
        $ECR_REGISTRY/$ECR_REPOSITORY:$CI_COMMIT_SHA
    - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$CI_COMMIT_SHA
Tag 命名規範建議:
不可變:sha-abc1234(生產部署應永遠使用此格式,確保每次部署的版本可追溯)
環境追蹤:main:develop(反映各分支最新狀態,方便 staging 自動部署)
版本發布:v1.2.3:1.2:1(語意化版本,方便依賴方鎖版並享受自動接收 patch 更新的便利)
避免使用:latest 作為生產部署的唯一標籤,因為它是可變的,無法保證部署版本的一致性,也難以追溯問題發生時線上跑的確切版本。
映像建置與推送完成後,前往 部署策略詳解 學習如何將映像安全地部署到各環境,包含 Blue/Green、Canary 等進階部署策略。