GitHub Actions CI/CD:真正实现零宕机部署
我的完整 GitHub Actions 配置:并行测试任务、Docker 构建缓存、SSH 部署到 VPS、PM2 reload 零宕机、密钥管理,以及我打磨两年的工作流模式。
我参与过的每个项目最终都会到达同一个拐点:部署流程变得太痛苦了,根本无法手动操作。你忘了跑测试。你在本地构建了但忘了更新版本号。你 SSH 到生产环境才发现上一个部署的人留了一个过期的 .env 文件。
两年前 GitHub Actions 替我解决了这个问题。并不是第一天就完美——我写的第一个工作流是一个 200 行的 YAML 噩梦,有一半时间会超时,而且完全没有缓存。但通过一次次迭代,我最终得到了一个能可靠部署这个网站、零宕机、四分钟之内完成的方案。
这就是那个工作流,逐节详解。不是文档版本。而是经受住了生产环境考验的版本。
理解核心构建块#
在深入完整流水线之前,你需要对 GitHub Actions 的工作方式有一个清晰的心智模型。如果你用过 Jenkins 或 CircleCI,忘掉你知道的大部分吧。概念有松散的映射关系,但执行模型差异够大,足以让你踩坑。
触发器:工作流何时运行#
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四种触发器,各有用途:
push到main是你的生产部署触发器。代码合并了?发布。pull_request在每个 PR 上运行 CI 检查。lint、类型检查和测试都放在这里。schedule是仓库的 cron。我用它来做每周依赖审计扫描和过期缓存清理。workflow_dispatch在 GitHub UI 中提供一个手动的"部署"按钮,带输入参数。当你需要在没有代码变更的情况下部署 staging 时非常有用——也许你更新了环境变量或需要重新拉取基础 Docker 镜像。
有一个坑经常坑人:pull_request 是在 合并后的提交 上运行的,而不是 PR 分支的 HEAD。这意味着你的 CI 测试的是代码在合并 之后 的样子。这实际上正是你想要的,但当一个绿色的分支在 rebase 后变红时会让人困惑。
Job、Step 和 Runner#
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 lintJob 默认并行运行。每个 job 获得一个全新的虚拟机("runner")。ubuntu-latest 给你一台配置不错的机器——截至 2026 年是 4 个 vCPU、16 GB 内存。公共仓库免费,私有仓库每月 2000 分钟。
Step 在一个 job 内顺序执行。每个 uses: step 从市场拉取一个可复用的 action。每个 run: step 执行一条 shell 命令。
--frozen-lockfile 标志至关重要。没有它,pnpm install 可能在 CI 中更新你的锁文件,这意味着你测试的不是开发者提交的同一套依赖。我见过这导致的诡异测试失败——本地消失了,因为开发者机器上的锁文件已经是正确的。
环境变量与密钥#
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 流水线的前半部分。目标:以最快的速度捕获每一类错误。
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 上测试),你可能想一次看到所有失败:
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 testfail-fast: false 让两个矩阵分支都完成。如果 Node 22 失败但 Node 20 通过了,你能立即看到这个信息,而不是需要重新运行。
缓存提速#
你能对 CI 速度做出的最大改进就是缓存。在一个中型项目上,冷启动 pnpm install 需要 30-45 秒。有热缓存时,只需 3-5 秒。在四个并行 job 中乘以这个数字,你在每次运行中就节省了两分钟。
pnpm 存储缓存#
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "pnpm"这一行就能缓存 pnpm 存储(~/.local/share/pnpm/store)。命中缓存时,pnpm install --frozen-lockfile 只是从存储中进行硬链接而不是下载。仅此一项就能在重复运行中减少 80% 的安装时间。
如果你需要更多控制——比如你想同时基于操作系统来缓存——直接使用 actions/cache:
- 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 中有自己的构建缓存。在运行之间缓存它意味着增量构建——只有更改的页面和组件会被重新编译。
- 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 策略意味着:
- 精确匹配:相同的依赖且相同的源文件。完全命中缓存,构建几乎瞬间完成。
- 部分匹配(依赖):依赖相同但源码变了。构建只重新编译变更的文件。
- 部分匹配(仅 OS):依赖变了。构建复用它能复用的部分。
我的项目的实际数据:冷构建约 55 秒,缓存构建约 15 秒。减少了 73%。
Docker 层缓存#
Docker 构建是缓存真正发挥巨大作用的地方。一次完整的 Next.js Docker 构建——安装操作系统依赖、复制源码、运行 pnpm install、运行 next build——冷启动需要 3-4 分钟。有层缓存后,只需 30-60 秒。
- 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=maxtype=gha 使用 GitHub Actions 内置的缓存后端。mode=max 缓存所有层,而不仅仅是最终层。这对多阶段构建至关重要,因为中间层(如 pnpm install)是重建成本最高的。
Turborepo 远程缓存#
如果你在使用 Turborepo 的 monorepo 中,远程缓存是变革性的。第一次构建将任务输出上传到缓存。后续构建下载而不是重新计算。
- 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:
# 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 阶段。
构建和推送工作流#
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,有三个原因:
- 认证免费。
GITHUB_TOKEN在每个工作流中自动可用——不需要存储 Docker Hub 凭据。 - **就近访问。**镜像从 CI 运行所在的同一基础设施拉取。CI 中的拉取速度很快。
- **可见性。**镜像在 GitHub UI 中与你的仓库关联。你在 Packages 标签页中就能看到它们。
多平台构建#
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 就会自动拉取正确的架构。
标签策略#
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#
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 ==="部署脚本替代方案#
对于任何超出简单的拉取并重启的操作,我会把逻辑移到服务器上的脚本中,而不是内联到工作流里:
#!/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 命令:
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 模式给你最简单的零宕机路径。
# ecosystem.config.js already has:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 reload(不是 restart)执行滚动重启。它启动新的 worker,等待它们就绪,然后逐个杀掉旧的 worker。在任何时刻都不会出现零个 worker 在服务流量的情况。
--update-env 标志从 ecosystem 配置重新加载环境变量。没有它,即使部署后更改了 .env,旧的环境变量也会持续存在。
在你的工作流中:
- 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 配置:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /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;
}
}切换脚本:
#!/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 可以管理蓝绿切换:
# 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,你得到一个滚动更新——一次一个容器,始终保持服务能力。
用以下命令部署:
docker compose pull
docker compose up -d --remove-orphansDocker Compose 监控健康检查,在通过之前不会将流量路由到新容器。如果健康检查失败,failure_action: rollback 会自动回退到之前的版本。这是在单台 VPS 上最接近 Kubernetes 式滚动部署的方案了。
密钥管理#
密钥管理是那种容易做到"基本正确"但在剩余的边缘情况中可能灾难性出错的事情之一。
GitHub Secrets:基础#
# 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#
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 充当身份提供者,你的云供应商信任它按需签发短期凭据:
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 身份提供者和角色信任策略:
{
"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 部署,生成一个专用的密钥对:
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 打开时部署预览#
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:
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#
- 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 中给你一个部署历史并启用状态徽章:
- 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:
- 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"邮件才能引起注意。
完整的工作流文件#
以下是将所有内容整合到一个生产就绪的工作流中。这非常接近实际部署这个网站所用的工作流。
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 时:
- Lint、类型检查和测试同时启动。三个 runner,三个并行 job。如果任何一个失败,流水线停止。
- Build 只在三者全部通过后运行。它验证应用能编译并产生可用的输出。
- Docker 构建生产镜像并推送到 ghcr.io。多平台,层缓存。
- Deploy SSH 到 VPS,拉取新镜像,启动新容器,健康检查,切换 Nginx,清理。
- 通知无论结果如何都会触发。Slack 收到消息。GitHub Deployments 被更新。如果失败了,告警邮件发出。
当我打开一个 PR 时:
- Lint、类型检查和测试运行。相同的质量门禁。
- Build 运行以验证项目能编译。
- Docker 和 Deploy 被跳过(
if条件将它们限定为仅main分支)。
当我需要紧急部署(跳过测试)时:
- 在 Actions 标签页点击**"Run workflow"**。
- 选择
skip_tests: true。 - Lint 和 typecheck 仍然运行(你不能跳过这些——我不那么信任自己)。
- 测试被跳过,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 连接断开而挂起,你不会想在六个小时后发现它已经耗尽了你的月度分钟配额。
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latest**明智地使用 concurrency。**对于 PR,cancel-in-progress: true 总是对的——没有人关心一个已经被 force-push 覆盖的提交的 CI 结果。对于生产部署,设为 false。你不希望一个紧接着的提交取消一个正在进行中的部署。
**测试你的工作流文件。**使用 act(https://github.com/nektos/act)在本地运行工作流。它无法捕获所有问题(secrets 不可用,runner 环境也不同),但它能在你推送之前捕获 YAML 语法错误和明显的逻辑 bug。
**监控你的 CI 成本。**GitHub Actions 分钟数对公共仓库免费,对私有仓库也很便宜,但会累积。多平台 Docker 构建消耗 2 倍的分钟数(每个平台一份)。矩阵测试策略会乘以你的运行时间。留意账单页面。
最好的 CI/CD 流水线是你信任的那一个。信任来自可靠性、可观测性和渐进改进。从简单的 lint-test-build 流水线开始。需要可复现性时加 Docker。需要自动化时加 SSH 部署。需要信心时加通知。不要在第一天就构建完整的流水线——你会把抽象搞错。
构建你今天需要的流水线,让它随项目一起成长。