iT邦幫忙

2025 iThome 鐵人賽

0
Modern Web

前端工程師的 Modern Web 實踐之道系列 第 21

CI/CD 流水線設計:從本地開發到生產部署的自動化之路

  • 分享至 

  • xImage
  •  

系列文章: 前端工程師的 Modern Web 實踐之道 - Day 22
預計閱讀時間: 12 分鐘
難度等級: ⭐⭐⭐⭐☆

🎯 今日目標

在前面的三週中,我們深入探討了現代化的開發工具、核心技術實踐,以及品質與效能最佳化。今天我們將進入第四週的主題:工程化與維護。而 CI/CD (Continuous Integration / Continuous Deployment) 流水線設計,正是現代化工程實踐的基石。

為什麼要關注 CI/CD?

還記得你第一次手動部署前端應用的經歷嗎?在本地執行 npm run build,等待構建完成,然後用 FTP 或 SCP 把檔案上傳到伺服器,接著清除快取、測試功能、發現問題、修復 bug、再次構建上傳...這個過程不僅耗時,更容易出錯。

真實場景中的痛點:

  • 人為錯誤頻發: 手動操作容易漏掉關鍵步驟,例如忘記執行測試、遺漏環境變數設定
  • 部署時間冗長: 每次部署需要 30-60 分鐘,緊急修復變得極度困難
  • 環境不一致: 「在我機器上可以執行」成為最常聽到的藉口
  • 發布風險高: 缺乏回滾機制,一旦部署失敗,影響所有使用者
  • 團隊協作混亂: 多人同時開發時,不知道誰的程式碼已經部署、誰的還在本地

技術發展趨勢:

根據 2024 年 State of DevOps 報告,實施完善 CI/CD 的團隊相比傳統開發模式:

  • 部署頻率提升 200 倍
  • 變更前置時間縮短 2555 倍
  • 平均恢復時間減少 24 倍
  • 變更失敗率降低 3 倍

這些數據清楚地說明:CI/CD 不只是自動化工具,而是現代軟體工程的核心競爭力。

🔍 深度分析:CI/CD 的技術本質

問題背景與現狀

傳統部署流程的困境

讓我們先回顧一個典型的傳統前端部署流程:

# 開發者的典型部署步驟
git pull origin main              # 1. 拉取最新程式碼
npm install                       # 2. 安裝相依套件
npm run lint                      # 3. 執行代碼檢查
npm run test                      # 4. 執行測試
npm run build                     # 5. 構建生產版本
scp -r dist/* user@server:/path   # 6. 上傳到伺服器
ssh user@server                   # 7. 連線到伺服器
cd /path && nginx -s reload       # 8. 重啟服務
# 然後手動測試生產環境...

這個流程的問題在於:

  1. 無法保證一致性: 不同開發者的本地環境可能導致構建結果不同
  2. 缺乏品質閘門: 測試失敗了也能繼續部署
  3. 沒有部署記錄: 無法追蹤是誰、何時、部署了什麼
  4. 回滾困難: 出問題時只能重新構建上一個版本
  5. 無法並行: 一次只能一個人部署

CI/CD 的核心理念

CI/CD 的本質是將軟體交付過程標準化、自動化、可視化:

Continuous Integration (持續整合):

  • 開發者頻繁地(通常每天多次)將程式碼整合到主分支
  • 每次整合都透過自動化構建和測試來驗證
  • 及早發現整合問題,降低修復成本

Continuous Deployment (持續部署):

  • 通過所有測試的程式碼自動部署到生產環境
  • 部署過程標準化、可重複、可追蹤
  • 支援快速回滾和金絲雀部署等進階策略

技術方案深入解析

CI/CD 流水線的核心組成

一個完整的前端 CI/CD 流水線通常包含以下階段:

程式碼提交 → 程式碼檢查 → 自動化測試 → 構建打包 → 部署預發 → 自動化驗證 → 部署生產 → 健康檢查
    ↓          ↓          ↓          ↓          ↓          ↓          ↓          ↓
   Git      ESLint     Jest      Webpack    Staging    E2E Test   Production  Monitoring

讓我們逐一深入了解每個階段:

1. 程式碼檢查階段 (Code Quality)

這個階段的目標是確保程式碼符合團隊規範:

# .github/workflows/ci.yml - GitHub Actions 範例
name: Code Quality Check

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  code-quality:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout 程式碼
        uses: actions/checkout@v4

      - name: 設定 Node.js 環境
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: 安裝相依套件
        run: npm ci

      - name: 執行 ESLint 檢查
        run: npm run lint

      - name: 執行 Prettier 檢查
        run: npm run format:check

      - name: 執行 TypeScript 型別檢查
        run: npm run type-check

      - name: 程式碼複雜度分析
        run: npx plato -r -d reports src

      - name: 上傳分析報告
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: code-quality-report
          path: reports/

關鍵設計原則:

  • 使用 npm ci 而非 npm install 確保可重複性
  • if: always() 確保即使前面步驟失敗也能收集報告
  • 使用快取機制加速構建過程
2. 自動化測試階段 (Automated Testing)

這是 CI/CD 流水線中最重要的品質閘門:

# 測試階段設定
test:
  runs-on: ubuntu-latest
  needs: code-quality

  strategy:
    matrix:
      node-version: [18, 20]
      browser: [chrome, firefox, safari]

  steps:
    - uses: actions/checkout@v4

    - name: 設定 Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: 'npm'

    - name: 安裝相依套件
      run: npm ci

    - name: 執行單元測試
      run: npm run test:unit -- --coverage

    - name: 執行整合測試
      run: npm run test:integration

    - name: 執行 E2E 測試
      run: npm run test:e2e
      env:
        BROWSER: ${{ matrix.browser }}

    - name: 上傳測試覆蓋率報告
      uses: codecov/codecov-action@v4
      with:
        files: ./coverage/coverage-final.json
        flags: unittests
        name: codecov-${{ matrix.node-version }}

    - name: 測試結果摘要
      if: always()
      run: |
        echo "## 測試結果 🧪" >> $GITHUB_STEP_SUMMARY
        echo "- Node.js: ${{ matrix.node-version }}" >> $GITHUB_STEP_SUMMARY
        echo "- Browser: ${{ matrix.browser }}" >> $GITHUB_STEP_SUMMARY
        npm run test:summary >> $GITHUB_STEP_SUMMARY

進階測試策略:

  • 矩陣測試: 在多個 Node.js 版本和瀏覽器上執行測試
  • 平行化: 利用 CI 平台的並行能力加速測試執行
  • 智能快取: 只執行受影響檔案的測試(Jest 的 --onlyChanged)
3. 構建與最佳化階段 (Build & Optimization)
build:
  runs-on: ubuntu-latest
  needs: test

  steps:
    - uses: actions/checkout@v4

    - name: 設定 Node.js
      uses: actions/setup-node@v4
      with:
        node-version: '20'
        cache: 'npm'

    - name: 安裝相依套件
      run: npm ci

    - name: 構建生產版本
      run: npm run build
      env:
        NODE_ENV: production
        # 從 GitHub Secrets 載入環境變數
        VITE_API_URL: ${{ secrets.PRODUCTION_API_URL }}
        VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}

    - name: Bundle 大小分析
      run: |
        npm run build:analyze
        echo "## Bundle 分析結果 📦" >> $GITHUB_STEP_SUMMARY
        du -sh dist/* >> $GITHUB_STEP_SUMMARY

    - name: 檢查 Bundle 大小限制
      run: |
        MAX_SIZE=500000  # 500KB
        ACTUAL_SIZE=$(du -sb dist/assets/*.js | awk '{s+=$1} END {print s}')
        if [ $ACTUAL_SIZE -gt $MAX_SIZE ]; then
          echo "❌ Bundle 大小超過限制: ${ACTUAL_SIZE} > ${MAX_SIZE}"
          exit 1
        fi
        echo "✅ Bundle 大小符合限制: ${ACTUAL_SIZE} <= ${MAX_SIZE}"

    - name: 生成 Source Map
      run: npm run build:sourcemap

    - name: 上傳構建產物
      uses: actions/upload-artifact@v4
      with:
        name: production-build
        path: dist/
        retention-days: 30

    - name: 上傳 Source Map 到 Sentry
      run: |
        npm install -g @sentry/cli
        sentry-cli releases new "${{ github.sha }}"
        sentry-cli releases files "${{ github.sha }}" upload-sourcemaps ./dist
        sentry-cli releases finalize "${{ github.sha }}"
      env:
        SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
        SENTRY_ORG: your-org
        SENTRY_PROJECT: your-project

構建階段的最佳實踐:

  • 環境變數管理: 透過 CI 平台的 Secrets 管理敏感資訊
  • Bundle 大小監控: 設定閾值防止不經意的效能退化
  • Source Map 管理: 上傳到錯誤追蹤平台而非部署到生產環境
4. 部署階段 (Deployment)

這裡我們展示一個採用藍綠部署策略的範例:

deploy:
  runs-on: ubuntu-latest
  needs: build
  if: github.ref == 'refs/heads/main'

  steps:
    - name: 下載構建產物
      uses: actions/download-artifact@v4
      with:
        name: production-build
        path: dist/

    - name: 部署到 Vercel (Preview)
      id: deploy-preview
      uses: amondnet/vercel-action@v25
      with:
        vercel-token: ${{ secrets.VERCEL_TOKEN }}
        vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
        vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
        working-directory: ./dist
        scope: your-team

    - name: 執行煙霧測試
      run: |
        npm run test:smoke -- --url=${{ steps.deploy-preview.outputs.preview-url }}

    - name: 執行 Lighthouse 效能測試
      uses: treosh/lighthouse-ci-action@v10
      with:
        urls: |
          ${{ steps.deploy-preview.outputs.preview-url }}
        budgetPath: ./lighthouse-budget.json
        uploadArtifacts: true

    - name: 部署到生產環境
      if: success()
      uses: amondnet/vercel-action@v25
      with:
        vercel-token: ${{ secrets.VERCEL_TOKEN }}
        vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
        vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
        vercel-args: '--prod'
        working-directory: ./dist

    - name: 通知 Slack
      if: always()
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        text: |
          部署結果: ${{ job.status }}
          提交: ${{ github.event.head_commit.message }}
          作者: ${{ github.event.head_commit.author.name }}
          查看: ${{ steps.deploy-preview.outputs.preview-url }}
        webhook_url: ${{ secrets.SLACK_WEBHOOK }}

部署策略選擇:

  1. 藍綠部署: 維護兩套環境,透過路由切換實現零停機
  2. 金絲雀部署: 先部署到小部分使用者,驗證後逐步擴大
  3. 滾動更新: 逐步替換舊版本實例,適合容器化部署

實戰演練:從零到一建構完整的 CI/CD 流水線

場景設定

假設我們要為一個 React + TypeScript + Vite 的電商前端專案建立完整的 CI/CD 流程。

Step 1: 專案結構準備

my-ecommerce-frontend/
├── .github/
│   └── workflows/
│       ├── ci.yml           # 持續整合
│       ├── cd-staging.yml   # 部署到預發環境
│       └── cd-production.yml # 部署到生產環境
├── .husky/                  # Git hooks
│   ├── pre-commit
│   └── pre-push
├── scripts/
│   ├── deploy.sh
│   └── health-check.sh
├── src/
├── tests/
├── package.json
├── vite.config.ts
└── lighthouse-budget.json

Step 2: 設定 Git Hooks (本地品質閘門)

// package.json
{
  "scripts": {
    "prepare": "husky install",
    "lint": "eslint src --ext .ts,.tsx",
    "format": "prettier --write \"src/**/*.{ts,tsx}\"",
    "format:check": "prettier --check \"src/**/*.{ts,tsx}\"",
    "type-check": "tsc --noEmit",
    "test:unit": "vitest run",
    "test:changed": "vitest related --run",
    "pre-commit": "lint-staged",
    "pre-push": "npm run type-check && npm run test:changed"
  },
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ]
  }
}
# .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

echo "🔍 執行 pre-commit 檢查..."
npm run pre-commit

# 檢查是否有未解決的 merge conflicts
if git diff --cached --name-only | xargs grep -l "^<<<<<<< HEAD"; then
  echo "❌ 發現未解決的 merge conflicts"
  exit 1
fi

echo "✅ Pre-commit 檢查通過"
# .husky/pre-push
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

echo "🧪 執行 pre-push 檢查..."

# 只測試變更的檔案,加快速度
npm run pre-push

if [ $? -ne 0 ]; then
  echo "❌ Pre-push 檢查失敗"
  exit 1
fi

echo "✅ Pre-push 檢查通過"

Step 3: 完整的 CI 設定

# .github/workflows/ci.yml
name: Continuous Integration

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

# 設定並行任務的取消策略
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  NODE_VERSION: '20'
  CACHE_VERSION: 'v1'

jobs:
  # Job 1: 程式碼品質檢查
  quality:
    name: Code Quality
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # 獲取完整歷史以進行差異分析

      - name: 設定 Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: 快取相依套件
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: npm-${{ env.CACHE_VERSION }}-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            npm-${{ env.CACHE_VERSION }}-${{ runner.os }}-

      - name: 安裝相依套件
        run: npm ci --prefer-offline --no-audit

      - name: Lint 檢查
        run: npm run lint -- --max-warnings 0

      - name: 格式化檢查
        run: npm run format:check

      - name: TypeScript 型別檢查
        run: npm run type-check

      - name: 檢查套件漏洞
        run: npm audit --audit-level=high

  # Job 2: 自動化測試
  test:
    name: Test Suite
    runs-on: ubuntu-latest
    needs: quality
    timeout-minutes: 15

    strategy:
      matrix:
        node-version: [18, 20]
      fail-fast: false  # 一個版本失敗不影響其他版本

    steps:
      - uses: actions/checkout@v4

      - name: 設定 Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - name: 安裝相依套件
        run: npm ci --prefer-offline --no-audit

      - name: 執行單元測試
        run: npm run test:unit -- --coverage --reporter=verbose

      - name: 上傳覆蓋率報告
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/coverage-final.json
          flags: unit-tests-node-${{ matrix.node-version }}
          fail_ci_if_error: false

      - name: 生成測試報告
        if: always()
        run: |
          echo "## 測試結果 🧪" >> $GITHUB_STEP_SUMMARY
          echo "Node.js 版本: ${{ matrix.node-version }}" >> $GITHUB_STEP_SUMMARY
          npm run test:summary >> $GITHUB_STEP_SUMMARY

  # Job 3: E2E 測試
  e2e:
    name: E2E Tests
    runs-on: ubuntu-latest
    needs: quality
    timeout-minutes: 20

    steps:
      - uses: actions/checkout@v4

      - name: 設定 Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: 安裝相依套件
        run: npm ci --prefer-offline --no-audit

      - name: 安裝 Playwright 瀏覽器
        run: npx playwright install --with-deps chromium firefox

      - name: 構建應用
        run: npm run build
        env:
          NODE_ENV: production

      - name: 啟動開發伺服器
        run: npm run preview &

      - name: 等待伺服器啟動
        run: npx wait-on http://localhost:4173 --timeout 60000

      - name: 執行 E2E 測試
        run: npm run test:e2e

      - name: 上傳測試截圖
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: e2e-screenshots
          path: tests/e2e/screenshots/
          retention-days: 7

  # Job 4: 構建驗證
  build:
    name: Build Validation
    runs-on: ubuntu-latest
    needs: [quality, test]
    timeout-minutes: 15

    steps:
      - uses: actions/checkout@v4

      - name: 設定 Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'

      - name: 安裝相依套件
        run: npm ci --prefer-offline --no-audit

      - name: 構建生產版本
        run: npm run build
        env:
          NODE_ENV: production

      - name: 分析 Bundle 大小
        run: |
          npm run build:analyze

          # 生成 Bundle 大小報告
          echo "## Bundle 分析 📦" >> $GITHUB_STEP_SUMMARY
          echo "### 主要檔案大小" >> $GITHUB_STEP_SUMMARY
          du -sh dist/assets/*.js | sort -rh | head -10 >> $GITHUB_STEP_SUMMARY

      - name: 檢查 Bundle 大小限制
        run: |
          # 設定各類檔案的大小限制
          MAX_MAIN_JS=300000      # 300KB
          MAX_VENDOR_JS=500000    # 500KB
          MAX_TOTAL_JS=1000000    # 1MB

          MAIN_SIZE=$(find dist/assets -name "index-*.js" -exec du -b {} + | awk '{s+=$1} END {print s}')
          VENDOR_SIZE=$(find dist/assets -name "vendor-*.js" -exec du -b {} + | awk '{s+=$1} END {print s}')
          TOTAL_SIZE=$(find dist/assets -name "*.js" -exec du -b {} + | awk '{s+=$1} END {print s}')

          echo "Main JS: ${MAIN_SIZE} bytes (限制: ${MAX_MAIN_JS})"
          echo "Vendor JS: ${VENDOR_SIZE} bytes (限制: ${MAX_VENDOR_JS})"
          echo "Total JS: ${TOTAL_SIZE} bytes (限制: ${MAX_TOTAL_JS})"

          FAILED=0
          if [ $MAIN_SIZE -gt $MAX_MAIN_JS ]; then
            echo "❌ Main bundle 超過大小限制"
            FAILED=1
          fi
          if [ $VENDOR_SIZE -gt $MAX_VENDOR_JS ]; then
            echo "❌ Vendor bundle 超過大小限制"
            FAILED=1
          fi
          if [ $TOTAL_SIZE -gt $MAX_TOTAL_JS ]; then
            echo "❌ Total bundle 超過大小限制"
            FAILED=1
          fi

          if [ $FAILED -eq 1 ]; then
            exit 1
          fi

          echo "✅ 所有 Bundle 大小符合限制"

      - name: 上傳構建產物
        uses: actions/upload-artifact@v4
        with:
          name: production-build-${{ github.sha }}
          path: dist/
          retention-days: 30

      - name: 儲存 Bundle 大小趨勢
        if: github.ref == 'refs/heads/main'
        run: |
          mkdir -p .bundle-stats
          echo "${{ github.sha }},$(date +%s),${TOTAL_SIZE}" >> .bundle-stats/history.csv
          git add .bundle-stats/history.csv
          git commit -m "chore: update bundle size stats [skip ci]"
          git push

  # Job 5: 安全性掃描
  security:
    name: Security Scan
    runs-on: ubuntu-latest
    needs: quality

    steps:
      - uses: actions/checkout@v4

      - name: 執行 npm audit
        run: npm audit --audit-level=moderate
        continue-on-error: true

      - name: 執行 Snyk 掃描
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high
        continue-on-error: true

      - name: 掃描敏感資訊
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}
          head: HEAD

  # 最終狀態檢查
  ci-success:
    name: CI Status Check
    runs-on: ubuntu-latest
    needs: [quality, test, e2e, build, security]
    if: always()

    steps:
      - name: 檢查所有任務狀態
        run: |
          if [[ "${{ needs.quality.result }}" == "success" && \
                "${{ needs.test.result }}" == "success" && \
                "${{ needs.e2e.result }}" == "success" && \
                "${{ needs.build.result }}" == "success" && \
                "${{ needs.security.result }}" == "success" ]]; then
            echo "✅ 所有 CI 檢查通過"
            exit 0
          else
            echo "❌ CI 檢查失敗"
            exit 1
          fi

Step 4: 部署到預發環境

# .github/workflows/cd-staging.yml
name: Deploy to Staging

on:
  push:
    branches: [develop]
  workflow_dispatch:  # 允許手動觸發

jobs:
  deploy-staging:
    name: Deploy to Staging Environment
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com

    steps:
      - uses: actions/checkout@v4

      - name: 設定 Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: 安裝相依套件
        run: npm ci

      - name: 構建 Staging 版本
        run: npm run build
        env:
          NODE_ENV: staging
          VITE_API_URL: ${{ secrets.STAGING_API_URL }}
          VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
          VITE_ENVIRONMENT: staging

      - name: 部署到 Staging
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--target staging'
          working-directory: ./dist

      - name: 執行煙霧測試
        run: npm run test:smoke -- --url=https://staging.example.com

      - name: 通知部署結果
        if: always()
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          text: |
            🚀 Staging 部署 ${{ job.status }}
            Environment: Staging
            Commit: ${{ github.event.head_commit.message }}
            Author: ${{ github.actor }}
            URL: https://staging.example.com
          webhook_url: ${{ secrets.SLACK_WEBHOOK }}

Step 5: 部署到生產環境(帶審批流程)

# .github/workflows/cd-production.yml
name: Deploy to Production

on:
  push:
    branches: [main]
    tags:
      - 'v*'
  workflow_dispatch:
    inputs:
      version:
        description: '部署版本'
        required: true
        default: 'latest'

jobs:
  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    # 需要通過 GitHub Environment 審批
    environment:
      name: production
      url: https://www.example.com

    steps:
      - uses: actions/checkout@v4

      - name: 設定 Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: 安裝相依套件
        run: npm ci

      - name: 構建生產版本
        run: npm run build
        env:
          NODE_ENV: production
          VITE_API_URL: ${{ secrets.PRODUCTION_API_URL }}
          VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
          VITE_ENVIRONMENT: production

      - name: 生成版本資訊
        run: |
          cat > dist/version.json << EOF
          {
            "version": "${{ github.ref_name }}",
            "commit": "${{ github.sha }}",
            "buildTime": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
            "author": "${{ github.actor }}"
          }
          EOF

      - name: 部署到 Production (金絲雀 10%)
        id: deploy-canary
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod --canary=10'
          working-directory: ./dist

      - name: 監控金絲雀部署指標
        run: |
          echo "⏳ 監控金絲雀部署 10 分鐘..."
          sleep 600

          # 檢查錯誤率
          ERROR_RATE=$(curl -s "${{ secrets.MONITORING_API }}/error-rate?env=production-canary")
          if (( $(echo "$ERROR_RATE > 0.05" | bc -l) )); then
            echo "❌ 錯誤率過高: ${ERROR_RATE}, 回滾部署"
            exit 1
          fi

          # 檢查效能指標
          P95_LATENCY=$(curl -s "${{ secrets.MONITORING_API }}/latency?env=production-canary&percentile=95")
          if (( $(echo "$P95_LATENCY > 1000" | bc -l) )); then
            echo "❌ P95 延遲過高: ${P95_LATENCY}ms, 回滾部署"
            exit 1
          fi

          echo "✅ 金絲雀部署指標正常"

      - name: 逐步擴大部署比例
        run: |
          # 30%
          vercel --prod --canary=30 --token=${{ secrets.VERCEL_TOKEN }}
          sleep 300

          # 50%
          vercel --prod --canary=50 --token=${{ secrets.VERCEL_TOKEN }}
          sleep 300

          # 100% 全量
          vercel --prod --token=${{ secrets.VERCEL_TOKEN }}

      - name: 健康檢查
        run: |
          ./scripts/health-check.sh https://www.example.com

      - name: 建立 Sentry Release
        run: |
          npm install -g @sentry/cli
          sentry-cli releases new "${{ github.sha }}"
          sentry-cli releases set-commits "${{ github.sha }}" --auto
          sentry-cli releases files "${{ github.sha }}" upload-sourcemaps ./dist
          sentry-cli releases finalize "${{ github.sha }}"
          sentry-cli releases deploys "${{ github.sha }}" new -e production
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_ORG: your-org
          SENTRY_PROJECT: your-project

      - name: 通知部署成功
        if: success()
        uses: 8398a7/action-slack@v3
        with:
          status: success
          text: |
            🎉 生產環境部署成功!
            Version: ${{ github.ref_name }}
            Commit: ${{ github.event.head_commit.message }}
            Author: ${{ github.actor }}
            URL: https://www.example.com
          webhook_url: ${{ secrets.SLACK_WEBHOOK }}

      - name: 回滾通知
        if: failure()
        uses: 8398a7/action-slack@v3
        with:
          status: failure
          text: |
            ⚠️ 生產環境部署失敗,已自動回滾
            Version: ${{ github.ref_name }}
            請檢查錯誤日誌並修復問題
          webhook_url: ${{ secrets.SLACK_WEBHOOK }}

進階應用與最佳實踐

1. 智能化的測試策略

不是每次提交都需要執行完整的測試套件。我們可以根據變更範圍智能選擇測試:

# 智能測試選擇
- name: 檢測變更範圍
  id: changes
  uses: dorny/paths-filter@v2
  with:
    filters: |
      frontend:
        - 'src/**'
        - 'package.json'
      backend-integration:
        - 'src/api/**'
      ui-components:
        - 'src/components/**'

- name: 執行前端單元測試
  if: steps.changes.outputs.frontend == 'true'
  run: npm run test:unit

- name: 執行 API 整合測試
  if: steps.changes.outputs.backend-integration == 'true'
  run: npm run test:api

- name: 執行視覺回歸測試
  if: steps.changes.outputs.ui-components == 'true'
  run: npm run test:visual

2. 部署後的自動化驗證

// scripts/post-deployment-check.ts
import axios from 'axios';
import { expect } from '@jest/globals';

interface HealthCheckResult {
  service: string;
  status: 'healthy' | 'degraded' | 'unhealthy';
  latency: number;
  details?: any;
}

async function performHealthChecks(baseUrl: string): Promise<HealthCheckResult[]> {
  const results: HealthCheckResult[] = [];

  // 1. 基本可用性檢查
  try {
    const start = Date.now();
    const response = await axios.get(`${baseUrl}/health`, { timeout: 5000 });
    const latency = Date.now() - start;

    results.push({
      service: 'api-health',
      status: response.status === 200 ? 'healthy' : 'unhealthy',
      latency,
      details: response.data
    });
  } catch (error) {
    results.push({
      service: 'api-health',
      status: 'unhealthy',
      latency: -1,
      details: error.message
    });
  }

  // 2. 關鍵 API 端點檢查
  const criticalEndpoints = [
    '/api/products',
    '/api/cart',
    '/api/user/profile'
  ];

  for (const endpoint of criticalEndpoints) {
    try {
      const start = Date.now();
      const response = await axios.get(`${baseUrl}${endpoint}`, { timeout: 3000 });
      const latency = Date.now() - start;

      results.push({
        service: endpoint,
        status: latency < 1000 ? 'healthy' : 'degraded',
        latency
      });
    } catch (error) {
      results.push({
        service: endpoint,
        status: 'unhealthy',
        latency: -1,
        details: error.message
      });
    }
  }

  // 3. 靜態資源載入檢查
  try {
    const response = await axios.get(`${baseUrl}/version.json`);
    expect(response.data).toHaveProperty('version');
    expect(response.data).toHaveProperty('commit');

    results.push({
      service: 'static-assets',
      status: 'healthy',
      latency: 0,
      details: response.data
    });
  } catch (error) {
    results.push({
      service: 'static-assets',
      status: 'unhealthy',
      latency: -1,
      details: error.message
    });
  }

  return results;
}

async function main() {
  const targetUrl = process.env.TARGET_URL || 'https://www.example.com';

  console.log(`🔍 執行部署後健康檢查: ${targetUrl}`);

  const results = await performHealthChecks(targetUrl);

  // 生成報告
  console.log('\n📊 健康檢查結果:');
  console.log('═'.repeat(60));

  let hasFailures = false;
  for (const result of results) {
    const icon = result.status === 'healthy' ? '✅' :
                 result.status === 'degraded' ? '⚠️' : '❌';

    console.log(`${icon} ${result.service.padEnd(30)} ${result.status.padEnd(10)} ${result.latency}ms`);

    if (result.status === 'unhealthy') {
      hasFailures = true;
      if (result.details) {
        console.log(`   錯誤: ${result.details}`);
      }
    }
  }

  console.log('═'.repeat(60));

  if (hasFailures) {
    console.error('❌ 健康檢查失敗,建議回滾部署');
    process.exit(1);
  } else {
    console.log('✅ 所有健康檢查通過');
    process.exit(0);
  }
}

main().catch(error => {
  console.error('健康檢查執行失敗:', error);
  process.exit(1);
});

3. 自動化回滾機制

// scripts/auto-rollback.ts
import axios from 'axios';

interface DeploymentMetrics {
  errorRate: number;
  p95Latency: number;
  successRate: number;
  activeUsers: number;
}

async function fetchMetrics(environment: string): Promise<DeploymentMetrics> {
  const monitoringAPI = process.env.MONITORING_API!;

  const [errorRate, latency, successRate, activeUsers] = await Promise.all([
    axios.get(`${monitoringAPI}/error-rate?env=${environment}`),
    axios.get(`${monitoringAPI}/latency?env=${environment}&percentile=95`),
    axios.get(`${monitoringAPI}/success-rate?env=${environment}`),
    axios.get(`${monitoringAPI}/active-users?env=${environment}`)
  ]);

  return {
    errorRate: errorRate.data.value,
    p95Latency: latency.data.value,
    successRate: successRate.data.value,
    activeUsers: activeUsers.data.value
  };
}

async function shouldRollback(metrics: DeploymentMetrics): Promise<{
  shouldRollback: boolean;
  reasons: string[];
}> {
  const reasons: string[] = [];

  // 閾值設定
  const thresholds = {
    maxErrorRate: 0.05,       // 5% 錯誤率
    maxP95Latency: 2000,      // 2 秒
    minSuccessRate: 0.95,     // 95% 成功率
  };

  if (metrics.errorRate > thresholds.maxErrorRate) {
    reasons.push(`錯誤率過高: ${(metrics.errorRate * 100).toFixed(2)}% > ${(thresholds.maxErrorRate * 100).toFixed(2)}%`);
  }

  if (metrics.p95Latency > thresholds.maxP95Latency) {
    reasons.push(`P95 延遲過高: ${metrics.p95Latency}ms > ${thresholds.maxP95Latency}ms`);
  }

  if (metrics.successRate < thresholds.minSuccessRate) {
    reasons.push(`成功率過低: ${(metrics.successRate * 100).toFixed(2)}% < ${(thresholds.minSuccessRate * 100).toFixed(2)}%`);
  }

  return {
    shouldRollback: reasons.length > 0,
    reasons
  };
}

async function performRollback(previousVersion: string) {
  console.log(`🔄 執行回滾到版本: ${previousVersion}`);

  // 這裡實作實際的回滾邏輯
  // 可能是透過 Vercel API、Kubernetes、或其他部署平台

  // 範例:使用 Vercel API 回滾
  const vercelToken = process.env.VERCEL_TOKEN!;
  const projectId = process.env.VERCEL_PROJECT_ID!;

  const response = await axios.post(
    `https://api.vercel.com/v9/projects/${projectId}/deployments`,
    {
      target: 'production',
      gitSource: {
        ref: previousVersion,
        type: 'branch'
      }
    },
    {
      headers: {
        'Authorization': `Bearer ${vercelToken}`
      }
    }
  );

  console.log('✅ 回滾完成:', response.data);
}

async function main() {
  const environment = process.env.ENVIRONMENT || 'production';
  const monitoringDuration = parseInt(process.env.MONITORING_DURATION || '600000'); // 10 分鐘
  const checkInterval = 30000; // 每 30 秒檢查一次

  console.log(`🔍 開始監控部署: ${environment}`);
  console.log(`監控時長: ${monitoringDuration / 1000} 秒`);

  const startTime = Date.now();

  while (Date.now() - startTime < monitoringDuration) {
    try {
      const metrics = await fetchMetrics(environment);
      const { shouldRollback, reasons } = await shouldRollback(metrics);

      console.log(`\n📊 當前指標 (${new Date().toISOString()}):`);
      console.log(`   錯誤率: ${(metrics.errorRate * 100).toFixed(2)}%`);
      console.log(`   P95 延遲: ${metrics.p95Latency}ms`);
      console.log(`   成功率: ${(metrics.successRate * 100).toFixed(2)}%`);
      console.log(`   活躍使用者: ${metrics.activeUsers}`);

      if (shouldRollback) {
        console.error('\n❌ 檢測到異常指標:');
        reasons.forEach(reason => console.error(`   - ${reason}`));

        const previousVersion = process.env.PREVIOUS_VERSION || 'HEAD~1';
        await performRollback(previousVersion);

        process.exit(1);
      }

      console.log('✅ 指標正常');

    } catch (error) {
      console.error('監控過程發生錯誤:', error);
    }

    await new Promise(resolve => setTimeout(resolve, checkInterval));
  }

  console.log('\n🎉 監控期結束,部署穩定');
  process.exit(0);
}

main().catch(error => {
  console.error('自動回滾程式執行失敗:', error);
  process.exit(1);
});

4. Feature Flag 整合

現代化的 CI/CD 應該支援特性開關,實現程式碼部署與功能發布的解耦:

// src/utils/feature-flags.ts
interface FeatureFlags {
  enableNewCheckout: boolean;
  enableABTest: boolean;
  enableBetaFeatures: boolean;
}

class FeatureFlagManager {
  private flags: FeatureFlags;
  private environment: string;

  constructor() {
    this.environment = import.meta.env.VITE_ENVIRONMENT || 'production';
    this.flags = this.loadFlags();
  }

  private loadFlags(): FeatureFlags {
    // 從遠端服務載入特性開關設定
    // 可以整合 LaunchDarkly、Optimizely 等服務

    // 本地開發環境預設開啟所有特性
    if (this.environment === 'development') {
      return {
        enableNewCheckout: true,
        enableABTest: true,
        enableBetaFeatures: true
      };
    }

    // 生產環境從 API 載入
    return {
      enableNewCheckout: false,
      enableABTest: true,
      enableBetaFeatures: false
    };
  }

  isEnabled(flag: keyof FeatureFlags): boolean {
    return this.flags[flag] ?? false;
  }

  async refresh() {
    this.flags = this.loadFlags();
  }
}

export const featureFlags = new FeatureFlagManager();

// 使用範例
export function CheckoutButton() {
  const useNewCheckout = featureFlags.isEnabled('enableNewCheckout');

  if (useNewCheckout) {
    return <NewCheckoutFlow />;
  }

  return <LegacyCheckoutFlow />;
}

📋 本日重點回顧

  1. CI/CD 的核心價值: CI/CD 不只是自動化工具,而是確保軟體品質、加速交付流程、降低人為錯誤的系統化工程實踐。完善的 CI/CD 流水線能讓部署頻率提升 200 倍,變更失敗率降低 3 倍。

  2. 流水線設計原則: 好的 CI/CD 流水線應該包含程式碼品質檢查、自動化測試、構建最佳化、部署驗證、健康檢查等完整環節。每個環節都是品質閘門,確保只有符合標準的程式碼才能進入生產環境。

  3. 實戰技術要點:

    • 使用 Git Hooks 在本地進行第一道品質檢查
    • 透過矩陣測試確保跨環境相容性
    • 實施智能測試策略減少不必要的測試執行
    • 採用金絲雀部署和自動回滾機制降低發布風險
    • 整合 Feature Flags 實現程式碼部署與功能發布解耦

🎯 最佳實踐建議

✅ 推薦做法

  • 本地品質保證: 使用 Husky + lint-staged 在提交前進行程式碼檢查,及早發現問題
  • 快速回饋循環: 優先執行快速檢查(lint、type-check),將耗時的 E2E 測試放在後面
  • 智能化測試: 根據程式碼變更範圍選擇性執行相關測試,節省 CI 時間和成本
  • 監控驅動: 設定關鍵指標閾值,透過自動化監控決定是否繼續部署或回滾
  • 漸進式發布: 使用金絲雀部署先驗證小範圍使用者,確認無誤後再全量發布

❌ 避免陷阱

  • 過度複雜的流水線: 不要一開始就建立過於複雜的流程,應該漸進式增加自動化環節
  • 忽略 CI 效能: 超過 10 分鐘的 CI 會嚴重影響開發效率,要持續最佳化執行速度
  • 缺乏回滾計劃: 每次部署都應該有明確的回滾策略,不要等到出問題才想解決方案
  • 環境變數管理混亂: 使用 CI 平台的 Secrets 管理敏感資訊,不要硬編碼在程式碼中
  • 忽略成本控制: 公有雲 CI 服務按使用量計費,要注意最佳化執行時間和並行任務數

🤔 延伸思考

  1. 技術選型思考: GitHub Actions、GitLab CI、Jenkins、CircleCI 各有優劣,如何根據團隊規模、技術棧、成本預算選擇合適的 CI/CD 平台?

  2. Monorepo 挑戰: 在 Monorepo 架構下,如何設計 CI/CD 流水線?如何實現增量構建和智能化測試選擇?可以研究 Nx、Turborepo 等工具的最佳實踐。

  3. 安全性加固: CI/CD 流水線本身也可能成為安全漏洞的入口點。如何防範供應鏈攻擊?如何確保部署流程的安全性?可以考慮引入 Sigstore、SLSA 等供應鏈安全框架。

  4. 實作挑戰: 嘗試為你的專案建立一個完整的 CI/CD 流水線:

    • 設定 Git Hooks 進行本地品質檢查
    • 實作多階段的 CI 流程(lint → test → build)
    • 整合至少一個部署平台(Vercel、Netlify、或自建)
    • 加入部署後的健康檢查和自動回滾機制

上一篇
無障礙設計實踐:讓 Web 應用真正服務每一個使用者
下一篇
容器化部署實踐:Docker 與 Kubernetes 在前端專案中的應用
系列文
前端工程師的 Modern Web 實踐之道22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言