全栈独立产品 CI/CD 复盘:从手动部署到自动化流水线
全栈独立产品 CI/CD 复盘:从手动部署到自动化流水线
一、独立产品的部署之痛:当"一行命令"变成"十步操作"
独立产品在 MVP 阶段,部署通常是这样的:本地npm run build→ scp 上传到服务器 → ssh 登录 →pm2 restart。整个过程不超过 3 分钟,看起来很高效。但当产品开始增长——前端从单页变成多模块、后端从单服务变成微服务、同时需要对接多个第三方 API——这 3 分钟的部署变成了 30 分钟的心智负担:
- 前端构建(还记得切换 node 版本吗?)
- 后端构建(Docker 镜像还是裸进程?)
- 数据库迁移(这次 migration 是幂等的吗?)
- 环境变量更新(新加了一个 API Key,服务器的
.env改了吗?) - Nginx 配置更新(新增了一个子路由)
- CDN 缓存刷新(改了 CSS 文件名,CDN 上旧文件删了吗?)
- 检查生产环境是否正常(万一刚才的 migration 把表锁了呢?)
步骤越多,遗漏的可能越大。独立产品的 CI/CD 不是为了"自动化"而自动化,而是为了将部署操作从"依赖人的记忆力"转变为"依赖流程的确定性"。
二、阶段一:GitHub Actions 的最小可行流水线
2.1 单文件搞定前端 + 后端的自动化
独立产品的 CI/CD 不需要 Kubernetes、Terraform、Helm Charts 这些重型基础设施。一条 GitHub Actions Workflow 文件可以覆盖 90% 的需求:
# .github/workflows/deploy.yml # 全栈独立产品的 CI/CD 流水线 name: Deploy on: push: branches: [main] pull_request: branches: [main] env: NODE_VERSION: '20' PNPM_VERSION: '9' DOCKER_REGISTRY: ghcr.io jobs: # ───────────────────────────────────────────── # Job 1: 代码质量检查(Lint + Type Check + Test) # ───────────────────────────────────────────── quality: runs-on: ubuntu-latest outputs: frontend-changed: ${{ steps.changes.outputs.frontend }} backend-changed: ${{ steps.changes.outputs.backend }} steps: - uses: actions/checkout@v4 - name: Detect changes id: changes uses: dorny/paths-filter@v3 with: filters: | frontend: - 'packages/frontend/**' backend: - 'packages/backend/**' - uses: pnpm/action-setup@v4 with: version: ${{ env.PNPM_VERSION }} - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: 'pnpm' - run: pnpm install --frozen-lockfile # 前端:ESLint + TypeScript 类型检查 + 单元测试 - name: Frontend quality if: steps.changes.outputs.frontend == 'true' run: | pnpm --filter frontend lint pnpm --filter frontend type-check pnpm --filter frontend test --coverage # 后端:ESLint + 单元测试 - name: Backend quality if: steps.changes.outputs.backend == 'true' run: | pnpm --filter backend lint pnpm --filter backend test --coverage # ───────────────────────────────────────────── # Job 2: 构建 Docker 镜像 # ───────────────────────────────────────────── build: needs: quality if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest strategy: matrix: service: [frontend, backend] outputs: image-tag: ${{ steps.meta.outputs.version }} steps: - uses: actions/checkout@v4 - name: Docker meta id: meta uses: docker/metadata-action@v5 with: images: ${{ env.DOCKER_REGISTRY }}/${{ github.repository }}/${{ matrix.service }} tags: | type=sha,prefix=,format=short type=ref,event=branch type=semver,pattern={{version}} - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.DOCKER_REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v6 with: context: . file: ./packages/${{ matrix.service }}/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max build-args: | BUILDKIT_INLINE_CACHE=1 # ───────────────────────────────────────────── # Job 3: 数据库迁移(仅限后端变更时) # ───────────────────────────────────────────── migrate: needs: build if: needs.quality.outputs.backend-changed == 'true' runs-on: ubuntu-latest environment: production steps: - name: Run database migration uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /opt/app docker compose run --rm backend pnpm prisma migrate deploy echo "Migration completed" # ───────────────────────────────────────────── # Job 4: 部署到生产环境 # ───────────────────────────────────────────── deploy: needs: [build, migrate] if: needs.migrate.result == 'success' || needs.migrate.result == 'skipped' runs-on: ubuntu-latest environment: name: production url: https://app.example.com steps: - name: Deploy via SSH uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /opt/app # 拉取最新 compose 配置 git pull origin main # 拉取最新镜像 docker compose pull # 滚动更新:启动新容器,等待健康检查通过,再停旧容器 docker compose up -d --remove-orphans # 清理旧镜像 docker image prune -f --filter "until=24h" # ───────────────────────────────────────────── # Job 5: 部署后健康检查 # ───────────────────────────────────────────── health-check: needs: deploy runs-on: ubuntu-latest steps: - name: Wait for services run: sleep 10 - name: Health check frontend run: | STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://app.example.com/api/health) if [ "$STATUS" != "200" ]; then echo "Frontend health check failed: HTTP $STATUS" exit 1 fi - name: Health check backend run: | STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://api.example.com/health) if [ "$STATUS" != "200" ]; then echo "Backend health check failed: HTTP $STATUS" exit 1 fi - name: Notify on failure if: failure() uses: slackapi/slack-github-action@v2 with: webhook: ${{ secrets.SLACK_WEBHOOK }} webhook-type: incoming-webhook payload: | { "text": ":x: 部署失败!\n仓库: ${{ github.repository }}\n提交: ${{ github.sha }}\n查看: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" }2.2 流水线设计的核心决策
这条流水线有几个关键设计决策值得展开:
- 条件构建(Conditional Build):通过
dorny/paths-filter检测变更文件路径,只有变更涉及的服务才触发对应的构建和部署。避免每次提交都全量重新构建所有服务。 - matrix strategy 并行构建:前后端镜像在同一个 Job 中使用
strategy.matrix并行构建,缩短整体构建时间。 - 数据库迁移独立 Job:将 migration 从部署流程中剥离为独立 Job。原因:migration 如果失败(如 PostgreSQL 不可用),部署应被阻断。如果 migration 和部署混在一个 SSH 脚本中执行,可能迁移失败但服务已启动。
- Docker 镜像缓存:
cache-from: type=gha和cache-to: type=gha,mode=max利用 GitHub Actions 的 Actions Cache 缓存 Docker 构建层,第二次及以后的构建时间缩短 60%~80%。 - 健康检查独立 Job:部署完成不是终点,服务真正可用才算成功。部署后等待 10 秒让容器启动,然后分别验证前后端健康检查端点。如果失败,通过 Slack 推送告警。
三、阶段二:多环境管理——Staging 与 Production 的分流
3.1 为什么需要 Staging 环境
当独立产品有了第一批付费用户后,直接推到 production 的风险急剧增加。Staging 环境的价值在于:在推送真实用户之前,用一个与生产环境完全相同配置的环境来验证部署的完整性。
Staging 和 Production 的差异应该仅在于:
- 数据库连接(独立的 staging 数据库)
- API Key(staging 使用沙箱密钥)
- 域名(
staging.example.comvsapp.example.com) - 日志级别(staging 可以更详细)
其余配置——Docker 镜像版本、环境变量结构、Nginx 配置——应当完全一致。如果 staging 和生产不一致,那 staging 的验证就失去了意义。
3.2 环境配置管理
使用 GitHub Actions 的 Environment 机制管理多套配置:
# Staging 自动部署(main 分支合并后自动触发) deploy-staging: needs: build runs-on: ubuntu-latest environment: name: staging url: https://staging.example.com steps: - name: Deploy to staging # ... SSH 到 staging 服务器 # Production 手动审批后部署 deploy-production: needs: deploy-staging runs-on: ubuntu-latest environment: name: production url: https://app.example.com steps: - name: Deploy to production # ... SSH 到 production 服务器GitHub Actions 的 Environment 提供了三项关键保护:
- 审批门(Approval Gate):production 环境可以配置为需要人工审批才能继续。
- 环境专属密钥:同一个密钥名称(如
SSH_PRIVATE_KEY)在 staging 和 production 中对应不同的值。 - 部署历史:每次 deployment 都有记录,包括谁审批的、什么时候部署的、关联哪个 commit。
四、阶段三:灰度发布与自动回滚
4.1 灰度发布的成本权衡
对于独立产品,完整的金丝雀发布(10% → 30% → 50% → 100% 逐步切流量)往往过于复杂。一个实用的替代方案是"先切 10%,观察 5 分钟,再全量":
# .github/workflows/deploy.yml 中的灰度部署 deploy-canary: needs: build runs-on: ubuntu-latest steps: - name: Deploy canary (10% traffic) uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SSH_HOST }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /opt/app # 启动新版本容器(不同端口),Nginx 配置 10% 流量指向新版本 docker compose -f docker-compose.canary.yml up -d # 更新 Nginx 分流规则 cp nginx/canary.conf /etc/nginx/conf.d/app.conf nginx -s reload - name: Wait and monitor canary run: | sleep 300 # 等待 5 分钟 # 检查 error rate ERROR_RATE=$(curl -s https://app.example.com/api/metrics/error-rate?window=5m | jq '.rate') if (( $(echo "$ERROR_RATE > 0.01" | bc -l) )); then echo "Error rate too high: $ERROR_RATE, rolling back..." exit 1 fi - name: Rollback canary on failure if: failure() uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SSH_HOST }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /opt/app cp nginx/main.conf /etc/nginx/conf.d/app.conf nginx -s reload docker compose -f docker-compose.canary.yml down deploy-full: needs: deploy-canary runs-on: ubuntu-latest steps: - name: Full deployment uses: appleboy/ssh-action@v1 with: host: ${{ secrets.SSH_HOST }} key: ${{ secrets.SSH_PRIVATE_KEY }} script: | cd /opt/app docker compose up -d cp nginx/main.conf /etc/nginx/conf.d/app.conf nginx -s reload docker compose -f docker-compose.canary.yml down4.2 自动回滚的条件
回滚的条件需要是可自动判定的,而非依赖人工观察。可用的自动判断指标:
- 健康检查端点返回非 200。
- 错误率(5xx 占比)超过 1%。
- 请求延时 P95 超过 500ms(相对于基线的 2x 偏离)。
在 GitHub Actions 中,如果灰度部署后的监控 Job 返回非零退出码,流水线自动进入回滚步骤。不需要人工判断——在凌晨 3 点,也没有人能做出可靠的判断。
五、总结
独立产品的 CI/CD 不需要过度工程化。核心思路是用一条 GitHub Actions Workflow 解决 90% 的需求,其余 10% 的复杂场景(多地域部署、蓝绿发布)等真正需要时再引入。
关键决策:
- 条件构建减少不必要的构建和部署耗时。
- 数据库迁移独立 Job,失败时阻断后续部署。
- Staging 环境+审批门是保护生产环境的最低成本方案。
- 灰度 + 自动回滚把"靠人盯着上线"变成"靠指标自动判定"。
流水线设计的最终目标不是"看起来很先进",而是"你可以在周六下午 3 点放心地推一个版本到生产环境,然后关掉电脑去喝咖啡。"