跳至内容
·18 分钟阅读

GitHub Actions CI/CD:真正实现零宕机部署

我的完整 GitHub Actions 配置:并行测试任务、Docker 构建缓存、SSH 部署到 VPS、PM2 reload 零宕机、密钥管理,以及我打磨两年的工作流模式。

分享:X / TwitterLinkedIn

我参与过的每个项目最终都会到达同一个拐点:部署流程变得太痛苦了,根本无法手动操作。你忘了跑测试。你在本地构建了但忘了更新版本号。你 SSH 到生产环境才发现上一个部署的人留了一个过期的 .env 文件。

两年前 GitHub Actions 替我解决了这个问题。并不是第一天就完美——我写的第一个工作流是一个 200 行的 YAML 噩梦,有一半时间会超时,而且完全没有缓存。但通过一次次迭代,我最终得到了一个能可靠部署这个网站、零宕机、四分钟之内完成的方案。

这就是那个工作流,逐节详解。不是文档版本。而是经受住了生产环境考验的版本。

理解核心构建块#

在深入完整流水线之前,你需要对 GitHub Actions 的工作方式有一个清晰的心智模型。如果你用过 Jenkins 或 CircleCI,忘掉你知道的大部分吧。概念有松散的映射关系,但执行模型差异够大,足以让你踩坑。

触发器:工作流何时运行#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # Every Monday at 6 AM UTC
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

四种触发器,各有用途:

  • pushmain 是你的生产部署触发器。代码合并了?发布。
  • pull_request 在每个 PR 上运行 CI 检查。lint、类型检查和测试都放在这里。
  • schedule 是仓库的 cron。我用它来做每周依赖审计扫描和过期缓存清理。
  • workflow_dispatch 在 GitHub UI 中提供一个手动的"部署"按钮,带输入参数。当你需要在没有代码变更的情况下部署 staging 时非常有用——也许你更新了环境变量或需要重新拉取基础 Docker 镜像。

有一个坑经常坑人:pull_request 是在 合并后的提交 上运行的,而不是 PR 分支的 HEAD。这意味着你的 CI 测试的是代码在合并 之后 的样子。这实际上正是你想要的,但当一个绿色的分支在 rebase 后变红时会让人困惑。

Job、Step 和 Runner#

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

Job 默认并行运行。每个 job 获得一个全新的虚拟机("runner")。ubuntu-latest 给你一台配置不错的机器——截至 2026 年是 4 个 vCPU、16 GB 内存。公共仓库免费,私有仓库每月 2000 分钟。

Step 在一个 job 内顺序执行。每个 uses: step 从市场拉取一个可复用的 action。每个 run: step 执行一条 shell 命令。

--frozen-lockfile 标志至关重要。没有它,pnpm install 可能在 CI 中更新你的锁文件,这意味着你测试的不是开发者提交的同一套依赖。我见过这导致的诡异测试失败——本地消失了,因为开发者机器上的锁文件已经是正确的。

环境变量与密钥#

yaml
env:
  NODE_ENV: production
  NEXT_TELEMETRY_DISABLED: 1
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
        run: |
          echo "$SSH_PRIVATE_KEY" > key.pem
          chmod 600 key.pem
          ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"

env: 在工作流级别设置的环境变量是纯文本,在日志中可见。用于非敏感配置:NODE_ENV、遥测标志、功能开关。

密钥${{ secrets.X }})在存储时加密,在日志中脱敏,并且只对同一仓库的工作流可用。在 Settings > Secrets and variables > Actions 中设置。

environment: production 这行很重要。GitHub Environments 允许你将密钥限定到特定的部署目标。你的 staging SSH 密钥和 production SSH 密钥可以都叫 SSH_PRIVATE_KEY,但根据 job 指向的环境持有不同的值。这还解锁了必需的审核者——你可以在手动批准后才进行生产部署。

完整的 CI 流水线#

以下是我如何构建 CI 流水线的前半部分。目标:以最快的速度捕获每一类错误。

yaml
name: CI
 
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
 
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  build:
    name: Build
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .next/
          retention-days: 1

为什么这样设计#

**Lint、typecheck 和 test 并行运行。**它们之间没有依赖关系。类型错误不会阻止 lint 运行,失败的测试也不需要等待类型检查器。在典型运行中,三者在同时执行的情况下 30-60 秒内全部完成。

Build 等待三者全部完成。needs: [lint, typecheck, test] 这行意味着 build job 只有在 lint、typecheck 和 test 全部通过后才开始。构建一个有 lint 错误或类型错误的项目毫无意义。

concurrency 配合 cancel-in-progress: true 可以节省大量时间。如果你连续推了两个提交,第一次 CI 运行会被取消。如果没有这个,过时的运行会消耗你的分钟配额并把检查 UI 弄得一团糟。

if: always() 上传覆盖率报告 意味着即使测试失败你也能获得覆盖率报告。这对调试很有用——你可以看到哪些测试失败了以及它们覆盖了什么。

快速失败 vs. 全部运行#

默认情况下,如果矩阵中的一个 job 失败了,GitHub 会取消其他的。对于 CI,我实际上想要这个行为——如果 lint 失败了,我不关心测试结果。先修 lint。

但对于测试矩阵(比如在 Node 20 和 Node 22 上测试),你可能想一次看到所有失败:

yaml
test:
  strategy:
    fail-fast: false
    matrix:
      node-version: [20, 22]
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: "pnpm"
    - run: pnpm install --frozen-lockfile
    - run: pnpm test

fail-fast: false 让两个矩阵分支都完成。如果 Node 22 失败但 Node 20 通过了,你能立即看到这个信息,而不是需要重新运行。

缓存提速#

你能对 CI 速度做出的最大改进就是缓存。在一个中型项目上,冷启动 pnpm install 需要 30-45 秒。有热缓存时,只需 3-5 秒。在四个并行 job 中乘以这个数字,你在每次运行中就节省了两分钟。

pnpm 存储缓存#

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: "pnpm"

这一行就能缓存 pnpm 存储(~/.local/share/pnpm/store)。命中缓存时,pnpm install --frozen-lockfile 只是从存储中进行硬链接而不是下载。仅此一项就能在重复运行中减少 80% 的安装时间。

如果你需要更多控制——比如你想同时基于操作系统来缓存——直接使用 actions/cache

yaml
- uses: actions/cache@v4
  with:
    path: |
      ~/.local/share/pnpm/store
      node_modules
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

restore-keys 回退很重要。如果 pnpm-lock.yaml 变了(新依赖),精确的 key 不会匹配,但前缀匹配仍然会恢复 大部分 缓存的包。只有差异部分需要下载。

Next.js 构建缓存#

Next.js 在 .next/cache 中有自己的构建缓存。在运行之间缓存它意味着增量构建——只有更改的页面和组件会被重新编译。

yaml
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nextjs-${{ runner.os }}-

这个三级 key 策略意味着:

  1. 精确匹配:相同的依赖且相同的源文件。完全命中缓存,构建几乎瞬间完成。
  2. 部分匹配(依赖):依赖相同但源码变了。构建只重新编译变更的文件。
  3. 部分匹配(仅 OS):依赖变了。构建复用它能复用的部分。

我的项目的实际数据:冷构建约 55 秒,缓存构建约 15 秒。减少了 73%。

Docker 层缓存#

Docker 构建是缓存真正发挥巨大作用的地方。一次完整的 Next.js Docker 构建——安装操作系统依赖、复制源码、运行 pnpm install、运行 next build——冷启动需要 3-4 分钟。有层缓存后,只需 30-60 秒。

yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=gha 使用 GitHub Actions 内置的缓存后端。mode=max 缓存所有层,而不仅仅是最终层。这对多阶段构建至关重要,因为中间层(如 pnpm install)是重建成本最高的。

Turborepo 远程缓存#

如果你在使用 Turborepo 的 monorepo 中,远程缓存是变革性的。第一次构建将任务输出上传到缓存。后续构建下载而不是重新计算。

yaml
- run: pnpm turbo build --remote-only
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

我见过 monorepo CI 时间从 8 分钟降到 90 秒。代价是:它需要 Vercel 账户或自托管的 Turbo 服务器。对于单应用仓库来说有点大材小用。

Docker 构建和推送#

如果你部署到 VPS(或任何服务器),Docker 给你可复现的构建。在 CI 中运行的同一个镜像就是在生产环境中运行的同一个镜像。不再有"在我机器上能跑"的问题,因为机器 就是 镜像。

多阶段 Dockerfile#

在进入工作流之前,这是我用于 Next.js 的 Dockerfile:

dockerfile
# Stage 1: Dependencies
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
 
# Stage 2: Build
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
 
# Stage 3: Production
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

三个阶段,清晰分离。最终镜像大约 150MB,而不是复制所有内容时的约 1.2GB。只有生产产物进入 runner 阶段。

构建和推送工作流#

yaml
name: Build and Push Docker Image
 
on:
  push:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

让我拆解其中的重要决策。

GitHub Container Registry(ghcr.io)#

我使用 ghcr.io 而不是 Docker Hub,有三个原因:

  1. 认证免费。GITHUB_TOKEN 在每个工作流中自动可用——不需要存储 Docker Hub 凭据。
  2. **就近访问。**镜像从 CI 运行所在的同一基础设施拉取。CI 中的拉取速度很快。
  3. **可见性。**镜像在 GitHub UI 中与你的仓库关联。你在 Packages 标签页中就能看到它们。

多平台构建#

yaml
platforms: linux/amd64,linux/arm64

这一行可能为你的构建增加约 90 秒,但很值得。ARM64 镜像可以原生运行在:

  • 使用 Docker Desktop 本地开发的 Apple Silicon Mac(M1/M2/M3/M4)
  • AWS Graviton 实例(比 x86 同类便宜 20-40%)
  • Oracle Cloud 的免费 ARM 层

没有这一行,你的 M 系列 Mac 开发者就要通过 Rosetta 模拟运行 x86 镜像。能跑,但明显更慢,偶尔还会冒出奇怪的架构特定 bug。

QEMU 提供了交叉编译层。Buildx 编排多架构构建并推送 manifest list,这样 Docker 就会自动拉取正确的架构。

标签策略#

yaml
tags: |
  type=sha,prefix=
  type=ref,event=branch
  type=raw,value=latest,enable={{is_default_branch}}

每个镜像获得三个标签:

  • abc1234(commit SHA):不可变的。你总是可以部署一个精确的提交。
  • main(分支名):可变的。指向该分支的最新构建。
  • latest:可变的。只在默认分支上设置。这是你的服务器拉取的。

永远不要在生产环境部署 latest 而不同时记录 SHA。当出问题时,你需要知道 哪个 latest。我把已部署的 SHA 存在服务器上的一个文件中,健康检查端点会读取它。

SSH 部署到 VPS#

这是所有环节汇聚的地方。CI 通过了,Docker 镜像已构建并推送,现在我们需要告诉服务器拉取新镜像并重启。

SSH Action#

yaml
deploy:
  name: Deploy to Production
  needs: [build-and-push]
  runs-on: ubuntu-latest
  environment: production
 
  steps:
    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.DEPLOY_HOST }}
        username: ${{ secrets.DEPLOY_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        port: ${{ secrets.SSH_PORT }}
        script_stop: true
        script: |
          set -euo pipefail
 
          APP_DIR="/var/www/akousa.net"
          IMAGE="ghcr.io/${{ github.repository }}:latest"
          DEPLOY_SHA="${{ github.sha }}"
 
          echo "=== Deploying $DEPLOY_SHA ==="
 
          # Pull the latest image
          docker pull "$IMAGE"
 
          # Stop and remove old container
          docker stop akousa-app || true
          docker rm akousa-app || true
 
          # Start new container
          docker run -d \
            --name akousa-app \
            --restart unless-stopped \
            --network host \
            -e NODE_ENV=production \
            -e DATABASE_URL="${DATABASE_URL}" \
            -p 3000:3000 \
            "$IMAGE"
 
          # Wait for health check
          echo "Waiting for health check..."
          for i in $(seq 1 30); do
            if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
              echo "Health check passed on attempt $i"
              break
            fi
            if [ "$i" -eq 30 ]; then
              echo "Health check failed after 30 attempts"
              exit 1
            fi
            sleep 2
          done
 
          # Record deployed SHA
          echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
 
          # Prune old images
          docker image prune -af --filter "until=168h"
 
          echo "=== Deploy complete ==="

部署脚本替代方案#

对于任何超出简单的拉取并重启的操作,我会把逻辑移到服务器上的脚本中,而不是内联到工作流里:

bash
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
 
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
 
log "Starting deployment..."
 
# Login to GHCR
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
 
# Pull with retry
for attempt in 1 2 3; do
  if docker pull "$IMAGE"; then
    log "Image pulled successfully on attempt $attempt"
    break
  fi
  if [ "$attempt" -eq 3 ]; then
    log "ERROR: Failed to pull image after 3 attempts"
    exit 1
  fi
  log "Pull attempt $attempt failed, retrying in 5s..."
  sleep 5
done
 
# Health check function
health_check() {
  local port=$1
  local max_attempts=30
  for i in $(seq 1 $max_attempts); do
    if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  return 1
}
 
# Start new container on alternate port
docker run -d \
  --name akousa-app-new \
  --env-file "$APP_DIR/.env.production" \
  -p 3001:3000 \
  "$IMAGE"
 
# Verify new container is healthy
if ! health_check 3001; then
  log "ERROR: New container failed health check. Rolling back."
  docker stop akousa-app-new || true
  docker rm akousa-app-new || true
  exit 1
fi
 
log "New container healthy. Switching traffic..."
 
# Switch Nginx upstream
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Stop old container
docker stop akousa-app || true
docker rm akousa-app || true
 
# Rename new container
docker rename akousa-app-new akousa-app
 
log "Deployment complete."

工作流就变成了一条 SSH 命令:

yaml
script: |
  cd /var/www/akousa.net && ./deploy.sh

这样做更好,因为:(1)部署逻辑在服务器上有版本控制,(2)你可以通过 SSH 手动运行来调试,(3)你不需要在 YAML 里嵌套 YAML 再嵌套 bash 来转义。

零宕机策略#

"零宕机"听起来像营销用语,但它有精确的含义:在部署期间没有任何请求收到连接拒绝或 502。以下是三种真实的方案,从简单到最健壮。

策略 1:PM2 Cluster 模式 Reload#

如果你直接运行 Node.js(不在 Docker 中),PM2 的 cluster 模式给你最简单的零宕机路径。

bash
# ecosystem.config.js already has:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reload(不是 restart)执行滚动重启。它启动新的 worker,等待它们就绪,然后逐个杀掉旧的 worker。在任何时刻都不会出现零个 worker 在服务流量的情况。

--update-env 标志从 ecosystem 配置重新加载环境变量。没有它,即使部署后更改了 .env,旧的环境变量也会持续存在。

在你的工作流中:

yaml
- name: Deploy and reload PM2
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      cd /var/www/akousa.net
      git pull origin main
      pnpm install --frozen-lockfile
      pnpm build
      pm2 reload ecosystem.config.js --update-env

这就是我用在这个网站上的方案。简单、可靠,宕机时间确确实实是零——我用负载生成器在部署期间以 100 req/s 的速率测试过。没有一个 5xx。

策略 2:Nginx Upstream 蓝绿部署#

对于 Docker 部署,蓝绿部署让你在新旧版本之间有一个干净的分离。

概念是:旧容器("蓝")在端口 3000 运行,新容器("绿")在端口 3001 运行。Nginx 指向蓝色。你启动绿色,验证健康后,将 Nginx 切换到绿色,然后停止蓝色。

Nginx upstream 配置:

nginx
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    server 127.0.0.1:3000;
}
nginx
# /etc/nginx/sites-available/akousa.net
server {
    listen 443 ssl http2;
    server_name akousa.net;
 
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

切换脚本:

bash
#!/bin/bash
set -euo pipefail
 
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
 
if [ "$CURRENT_PORT" = "3000" ]; then
  NEW_PORT=3001
  OLD_PORT=3000
else
  NEW_PORT=3000
  OLD_PORT=3001
fi
 
echo "Current: $OLD_PORT -> New: $NEW_PORT"
 
# Start new container on the alternate port
docker run -d \
  --name "akousa-app-$NEW_PORT" \
  --env-file /var/www/akousa.net/.env.production \
  -p "$NEW_PORT:3000" \
  "ghcr.io/akousa/akousa-net:latest"
 
# Wait for health
for i in $(seq 1 30); do
  if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
    echo "New container healthy on port $NEW_PORT"
    break
  fi
  [ "$i" -eq 30 ] && { echo "Health check failed"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
  sleep 2
done
 
# Switch Nginx
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# Stop old container
sleep 5  # Let in-flight requests complete
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Switched from :$OLD_PORT to :$NEW_PORT"

Nginx reload 之后的 5 秒 sleep 不是偷懒——它是宽限时间。Nginx 的 reload 是优雅的(现有连接保持打开),但一些长轮询连接或流式响应需要时间来完成。

策略 3:带健康检查的 Docker Compose#

对于更结构化的方案,Docker Compose 可以管理蓝绿切换:

yaml
# docker-compose.yml
services:
  app:
    image: ghcr.io/akousa/akousa-net:latest
    restart: unless-stopped
    env_file: .env.production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
    ports:
      - "3000:3000"

order: start-first 是关键行。它的意思是"在停止旧容器之前先启动新容器"。配合 parallelism: 1,你得到一个滚动更新——一次一个容器,始终保持服务能力。

用以下命令部署:

bash
docker compose pull
docker compose up -d --remove-orphans

Docker Compose 监控健康检查,在通过之前不会将流量路由到新容器。如果健康检查失败,failure_action: rollback 会自动回退到之前的版本。这是在单台 VPS 上最接近 Kubernetes 式滚动部署的方案了。

密钥管理#

密钥管理是那种容易做到"基本正确"但在剩余的边缘情况中可能灾难性出错的事情之一。

GitHub Secrets:基础#

yaml
# Set via GitHub UI: Settings > Secrets and variables > Actions
 
steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # The value is masked in logs
      echo "Connecting to database..."
      # This would print "Connecting to ***" in the logs
      echo "Connecting to $DB_URL"

GitHub 会自动从日志输出中脱敏 secret 值。如果你的 secret 是 p@ssw0rd123,任何 step 打印该字符串时,日志会显示 ***。这很好用,但有一个注意点:如果你的 secret 很短(比如 4 位 PIN),GitHub 可能不会脱敏它,因为它可能匹配到无害的字符串。保持 secret 合理的复杂度。

环境范围的 Secrets#

yaml
jobs:
  deploy-staging:
    environment: staging
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = staging.akousa.net
 
  deploy-production:
    environment: production
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = akousa.net

同名 secret,不同环境有不同的值。job 上的 environment 字段决定注入哪一组 secret。

生产环境应该启用必需的审核者。这意味着推送到 main 会触发工作流,CI 自动运行,但部署 job 会暂停等待某人在 GitHub UI 中点击"Approve"。对于个人项目这可能感觉多此一举。但对于有用户的项目,当你第一次不小心合并了有问题的代码时,它就是救命稻草。

OIDC:告别静态凭据#

存储在 GitHub Secrets 中的静态凭据(AWS access key、GCP 服务账户 JSON 文件)是个隐患。它们不会过期,不能限定到特定的工作流运行,如果泄露了你必须手动轮换。

OIDC(OpenID Connect)解决了这个问题。GitHub Actions 充当身份提供者,你的云供应商信任它按需签发短期凭据:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # Required for OIDC
      contents: read
 
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-central-1
 
      - name: Push to ECR
        run: |
          aws ecr get-login-password --region eu-central-1 | \
            docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.com

没有 access key。没有 secret key。configure-aws-credentials action 使用 GitHub 的 OIDC token 从 AWS STS 请求临时 token。该 token 限定到特定的仓库、分支和环境。在工作流运行结束后过期。

在 AWS 端设置这个需要一个 IAM OIDC 身份提供者和角色信任策略:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
        }
      }
    }
  ]
}

sub 条件至关重要。没有它,任何以某种方式获得你 OIDC 提供者详情的仓库都可能承担该角色。有了它,只有你特定仓库的 main 分支才可以。

GCP 有等效的 Workload Identity Federation 设置。Azure 有联合凭据。如果你的云支持 OIDC,就用它。在 2026 年没有理由存储静态云凭据。

部署 SSH 密钥#

对于通过 SSH 进行的 VPS 部署,生成一个专用的密钥对:

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""

将公钥添加到服务器的 ~/.ssh/authorized_keys 中,并加上限制:

restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy

restrict 前缀禁用端口转发、代理转发、PTY 分配和 X11 转发。command= 前缀意味着这个密钥 只能 执行部署脚本。即使私钥被泄露,攻击者也只能运行你的部署脚本,其他什么都做不了。

将私钥作为 SSH_PRIVATE_KEY 添加到 GitHub Secrets 中。这是我接受的唯一一个静态凭据——带强制命令的 SSH 密钥有非常有限的爆炸半径。

PR 工作流:预览部署#

每个 PR 都值得有一个预览环境。它能捕获单元测试遗漏的视觉 bug,让设计师无需检出代码就能评审,并且大大简化 QA 的工作。

在 PR 打开时部署预览#

yaml
name: Preview Deploy
 
on:
  pull_request:
    types: [opened, synchronize, reopened]
 
jobs:
  preview:
    runs-on: ubuntu-latest
    environment:
      name: preview-${{ github.event.number }}
      url: ${{ steps.deploy.outputs.url }}
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Build preview image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Deploy preview
        id: deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            PORT=$((4000 + PR_NUM))
            IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
 
            docker pull "$IMAGE"
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
 
            docker run -d \
              --name "preview-${PR_NUM}" \
              --restart unless-stopped \
              -e NODE_ENV=preview \
              -p "${PORT}:3000" \
              "$IMAGE"
 
            echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
 
      - name: Comment PR with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
            const body = `### Preview Deployment
 
            | Status | URL |
            |--------|-----|
            | :white_check_mark: Deployed | [${url}](${url}) |
 
            _Last updated: ${new Date().toISOString()}_
            _Commit: \`${{ github.sha }}\`_`;
 
            // Find existing comment
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
 
            const botComment = comments.data.find(c =>
              c.user.type === 'Bot' && c.body.includes('Preview Deployment')
            );
 
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

端口计算(4000 + PR_NUM)是一个务实的 hack。PR #42 获得端口 4042。只要你没有超过几百个打开的 PR,就不会有冲突。一个 Nginx 通配符配置将 pr-*.preview.akousa.net 路由到正确的端口。

PR 关闭时清理#

不清理的预览环境会吃磁盘和内存。添加一个清理 job:

yaml
name: Cleanup Preview
 
on:
  pull_request:
    types: [closed]
 
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Remove preview container
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
            docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
            echo "Preview for PR #${PR_NUM} cleaned up."
 
      - name: Deactivate environment
        uses: actions/github-script@v7
        with:
          script: |
            const deployments = await github.rest.repos.listDeployments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              environment: `preview-${{ github.event.number }}`,
            });
 
            for (const deployment of deployments.data) {
              await github.rest.repos.createDeploymentStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                deployment_id: deployment.id,
                state: 'inactive',
              });
            }

必需的状态检查#

在仓库设置中(Settings > Branches > Branch protection rules),合并前要求以下检查通过:

  • lint — 无 lint 错误
  • typecheck — 无类型错误
  • test — 所有测试通过
  • build — 项目构建成功

如果没有这个,有人 在检查失败的情况下合并 PR。不是恶意的——他们会看到"4 个检查中有 2 个通过"然后以为其他两个还在运行。锁定它。

同时启用**"合并前要求分支是最新的"**。这会在 rebase 到最新 main 后强制重新运行 CI。它能捕获两个 PR 各自通过 CI 但合并后冲突的情况。

通知#

没有人知道的部署是没有人信任的部署。通知闭合了反馈环。

Slack Webhook#

yaml
- name: Notify Slack
  if: always()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "blocks": [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
            }
          },
          {
            "type": "section",
            "fields": [
              {
                "type": "mrkdwn",
                "text": "*Repository:*\n${{ github.repository }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Branch:*\n${{ github.ref_name }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
              },
              {
                "type": "mrkdwn",
                "text": "*Triggered by:*\n${{ github.actor }}"
              }
            ]
          },
          {
            "type": "actions",
            "elements": [
              {
                "type": "button",
                "text": {
                  "type": "plain_text",
                  "text": "View Run"
                },
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
              }
            ]
          }
        ]
      }

if: always() 至关重要。没有它,当部署失败时通知 step 会被跳过——而那恰恰是你最需要它的时候。

GitHub Deployments API#

为了更丰富的部署追踪,使用 GitHub Deployments API。这在仓库 UI 中给你一个部署历史并启用状态徽章:

yaml
- name: Create GitHub Deployment
  id: deployment
  uses: actions/github-script@v7
  with:
    script: |
      const deployment = await github.rest.repos.createDeployment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        ref: context.sha,
        environment: 'production',
        auto_merge: false,
        required_contexts: [],
        description: `Deploying ${context.sha.substring(0, 7)} to production`,
      });
      return deployment.data.id;
 
- name: Deploy
  run: |
    # ... actual deployment steps ...
 
- name: Update deployment status
  if: always()
  uses: actions/github-script@v7
  with:
    script: |
      const deploymentId = ${{ steps.deployment.outputs.result }};
      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: deploymentId,
        state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
        environment_url: 'https://akousa.net',
        log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
        description: '${{ job.status }}' === 'success'
          ? 'Deployment succeeded'
          : 'Deployment failed',
      });

现在你的 GitHub Environments 标签页展示了完整的部署历史:谁部署了什么,什么时候部署的,以及是否成功。

仅失败时发邮件#

对于关键部署,我还会在失败时触发邮件。不是通过 GitHub Actions 内置的邮件(太嘈杂了),而是通过定向的 webhook:

yaml
- name: Alert on failure
  if: failure()
  run: |
    curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d '{
        "subject": "DEPLOY FAILED: ${{ github.repository }}",
        "body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }'

这是我的最后一道防线。Slack 很好但也很嘈杂——人们会静音频道。一封带有运行链接的"DEPLOY FAILED"邮件才能引起注意。

完整的工作流文件#

以下是将所有内容整合到一个生产就绪的工作流中。这非常接近实际部署这个网站所用的工作流。

yaml
name: CI/CD Pipeline
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      skip_tests:
        description: "Skip tests (emergency deploy)"
        required: false
        type: boolean
        default: false
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
env:
  NODE_VERSION: "22"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  # ============================================================
  # CI: Lint, type check, and test in parallel
  # ============================================================
 
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run ESLint
        run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run TypeScript compiler
        run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    if: ${{ !inputs.skip_tests }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run tests with coverage
        run: pnpm test -- --coverage
 
      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  # ============================================================
  # Build: Only after CI passes
  # ============================================================
 
  build:
    name: Build Application
    needs: [lint, typecheck, test]
    if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            nextjs-${{ runner.os }}-
 
      - name: Build Next.js application
        run: pnpm build
 
  # ============================================================
  # Docker: Build and push image (main branch only)
  # ============================================================
 
  docker:
    name: Build Docker Image
    needs: [build]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Set up QEMU for multi-platform builds
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  # ============================================================
  # Deploy: SSH into VPS and update
  # ============================================================
 
  deploy:
    name: Deploy to Production
    needs: [docker]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://akousa.net
 
    steps:
      - name: Create GitHub Deployment
        id: deployment
        uses: actions/github-script@v7
        with:
          script: |
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.sha,
              environment: 'production',
              auto_merge: false,
              required_contexts: [],
              description: `Deploy ${context.sha.substring(0, 7)}`,
            });
            return deployment.data.id;
 
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script_stop: true
          command_timeout: 5m
          script: |
            set -euo pipefail
 
            APP_DIR="/var/www/akousa.net"
            IMAGE="ghcr.io/${{ github.repository }}:latest"
            SHA="${{ github.sha }}"
 
            echo "=== Deploy $SHA started at $(date) ==="
 
            # Pull new image
            docker pull "$IMAGE"
 
            # Run new container on alternate port
            docker run -d \
              --name akousa-app-new \
              --env-file "$APP_DIR/.env.production" \
              -p 3001:3000 \
              "$IMAGE"
 
            # Health check
            echo "Running health check..."
            for i in $(seq 1 30); do
              if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
                echo "Health check passed (attempt $i)"
                break
              fi
              if [ "$i" -eq 30 ]; then
                echo "ERROR: Health check failed"
                docker logs akousa-app-new --tail 50
                docker stop akousa-app-new && docker rm akousa-app-new
                exit 1
              fi
              sleep 2
            done
 
            # Switch traffic
            sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
            sudo nginx -t && sudo nginx -s reload
 
            # Grace period for in-flight requests
            sleep 5
 
            # Stop old container
            docker stop akousa-app || true
            docker rm akousa-app || true
 
            # Rename and reset port
            docker rename akousa-app-new akousa-app
            sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
            # Note: we don't reload Nginx here because the container name changed,
            # not the port. The next deploy will use the correct port.
 
            # Record deployment
            echo "$SHA" > "$APP_DIR/.deployed-sha"
            echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
 
            # Cleanup old images (older than 7 days)
            docker image prune -af --filter "until=168h"
 
            echo "=== Deploy complete at $(date) ==="
 
      - name: Update deployment status
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const deploymentId = ${{ steps.deployment.outputs.result }};
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deploymentId,
              state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
              environment_url: 'https://akousa.net',
              log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
            });
 
      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Actor:*\n${{ github.actor }}"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "View Run" },
                      "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    }
                  ]
                }
              ]
            }
 
      - name: Alert on failure
        if: failure()
        run: |
          curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{
              "subject": "DEPLOY FAILED: ${{ github.repository }}",
              "body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }' || true

流程走读#

当我推送到 main 时:

  1. Lint、类型检查和测试同时启动。三个 runner,三个并行 job。如果任何一个失败,流水线停止。
  2. Build 只在三者全部通过后运行。它验证应用能编译并产生可用的输出。
  3. Docker 构建生产镜像并推送到 ghcr.io。多平台,层缓存。
  4. Deploy SSH 到 VPS,拉取新镜像,启动新容器,健康检查,切换 Nginx,清理。
  5. 通知无论结果如何都会触发。Slack 收到消息。GitHub Deployments 被更新。如果失败了,告警邮件发出。

当我打开一个 PR 时:

  1. Lint、类型检查和测试运行。相同的质量门禁。
  2. Build 运行以验证项目能编译。
  3. Docker 和 Deploy 被跳过if 条件将它们限定为仅 main 分支)。

当我需要紧急部署(跳过测试)时:

  1. 在 Actions 标签页点击**"Run workflow"**。
  2. 选择 skip_tests: true
  3. Lint 和 typecheck 仍然运行(你不能跳过这些——我不那么信任自己)。
  4. 测试被跳过,build 运行,Docker 构建,deploy 执行。

这个工作流已经伴随我两年了。它经历了服务器迁移、Node.js 大版本升级、pnpm 替换 npm,以及这个网站增加了 15 个工具。从推送到生产的总端到端时间:平均 3 分 40 秒。最慢的步骤是多平台 Docker 构建,约 90 秒。其他一切都因缓存而接近瞬间完成。

两年迭代的教训#

最后分享我犯过的错误,这样你就不必重蹈覆辙。

固定你的 action 版本。uses: actions/checkout@v4 可以接受,但对于生产环境,考虑使用 uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11(完整 SHA)。一个被入侵的 action 可能窃取你的密钥。2025 年的 tj-actions/changed-files 事件证明这不是理论上的。

**不要缓存一切。**我曾经直接缓存了 node_modules(而不仅仅是 pnpm store),然后花了两个小时调试一个由过期的 native binding 引起的诡异构建失败。缓存 包管理器存储,而不是安装后的模块。

**设置超时。**每个 job 都应该有 timeout-minutes。默认是 360 分钟(6 小时)。如果你的部署因为 SSH 连接断开而挂起,你不会想在六个小时后发现它已经耗尽了你的月度分钟配额。

yaml
jobs:
  deploy:
    timeout-minutes: 15
    runs-on: ubuntu-latest

**明智地使用 concurrency。**对于 PR,cancel-in-progress: true 总是对的——没有人关心一个已经被 force-push 覆盖的提交的 CI 结果。对于生产部署,设为 false。你不希望一个紧接着的提交取消一个正在进行中的部署。

**测试你的工作流文件。**使用 acthttps://github.com/nektos/act)在本地运行工作流。它无法捕获所有问题(secrets 不可用,runner 环境也不同),但它能在你推送之前捕获 YAML 语法错误和明显的逻辑 bug。

**监控你的 CI 成本。**GitHub Actions 分钟数对公共仓库免费,对私有仓库也很便宜,但会累积。多平台 Docker 构建消耗 2 倍的分钟数(每个平台一份)。矩阵测试策略会乘以你的运行时间。留意账单页面。

最好的 CI/CD 流水线是你信任的那一个。信任来自可靠性、可观测性和渐进改进。从简单的 lint-test-build 流水线开始。需要可复现性时加 Docker。需要自动化时加 SSH 部署。需要信心时加通知。不要在第一天就构建完整的流水线——你会把抽象搞错。

构建你今天需要的流水线,让它随项目一起成长。

相关文章