持续交付折腾手记

持续交付上手并不难,难的是稳定跑起来。

下面只记真正影响结果的部分。

背景

这次做持续交付改造,起因很直接:上周五晚上十点半,生产环境部署失败,回滚后再部署又失败,折腾到凌晨一点才搞定。原因很经典:本地开发环境、测试环境、生产环境配置不一致,测试没覆盖到,线下也没跑全流程。

这不是第一次了。之前半年,类似事情发生了四五次。每次都是"这次肯定没问题",然后某个环境就给你出个幺蛾子。

我们之前的模式是这样的:开发本地写完代码,推到远程,手动触发 CI 跑测试,测试过了,手动打包,手动部署到测试环境,手动冒烟测试,然后手动部署到生产环境。这一串"手动",每个环节都可能出问题。而且每次部署都得盯着流程,哪里卡住了还得手动处理。

所以下定决心要把这套流程自动化起来。目标很明确:代码提交后自动跑测试、自动构建、自动部署,出问题自动回滚,整个过程不用人盯着。

CI 部分:从手动到自动化

我们用的是 GitHub Actions,因为代码已经在 GitHub 上,用起来顺手。

最开始的 CI 配置很简陋,就是跑个 npm test:

name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      - run: npm ci
      - run: npm test

这个配置用了半年,问题不少:

第一,测试跑得太慢。每次都 npm ci,下载依赖要花两三分钟。后来改成缓存依赖,加上 npm ci --prefer-offline,时间降到 30 秒内。

第二,测试覆盖率不检查。代码改了没加测试,CI 也能过。后来加上 npm run test:coverage,要求覆盖率不能低于 60%,低于就 fail。

第三,只在 node 18 上跑测试。实际上线上环境不止一个 node 版本,后来改成矩阵测试:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [16.x, 18.x, 20.x]
    steps:
      - uses: actions/checkout@v3
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci --prefer-offline
      - run: npm run test:coverage

第四,失败后通知不及时。CI 失败了没人看,等要部署了才发现。后来加上钉钉通知,失败就发消息。

CD 部分:从手动部署到自动化

CI 搞得差不多后,开始搞 CD。这部分复杂一些,涉及到不同环境的部署。

最开始的想法是:CI 成功后自动部署到测试环境,测试环境冒烟测试通过后,自动部署到生产环境。但这个想法被否定了,因为生产环境不能这么随意。

实际采用的方案是:

  1. main 分支 push 后,自动部署到测试环境
  2. 测试环境验证通过后,手动触发生产环境部署
  3. 生产环境部署失败后,自动回滚到上一个版本

GitHub Actions 配置大概是这样:

name: CD - Test Environment

on:
  push:
    branches:
      - main
  workflow_dispatch:

jobs:
  deploy-test:
    runs-on: ubuntu-latest
    environment:
      name: test
      url: https://test.example.com
    steps:
      - uses: actions/checkout@v3
      - name: Deploy to Test
        run: |
          # 这里可以调用你的部署脚本或服务
          echo "Deploying to test environment"
          # 比如用 SSH + rsync
          rsync -avz --delete \
            --exclude 'node_modules' \
            --exclude '.git' \
            ./ user@test-server:/var/www/app/
          ssh user@test-server 'cd /var/www/app && npm ci --production && npm run build && pm2 restart app'

生产环境部署是手动触发:

name: CD - Production

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to deploy'
        required: true
        default: 'latest'

jobs:
  deploy-prod:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - uses: actions/checkout@v3
      - name: Backup Current Version
        run: |
          # 部署前备份
          ssh user@prod-server 'tar -czf /backup/app-$(date +%Y%m%d%H%M%S).tar.gz /var/www/app'
      - name: Deploy to Production
        run: |
          # 部署新版本
          rsync -avz --delete \
            --exclude 'node_modules' \
            --exclude '.git' \
            ./ user@prod-server:/var/www/app/
          ssh user@prod-server 'cd /var/www/app && npm ci --production && npm run build && pm2 restart app'
      - name: Health Check
        run: |
          # 等待服务启动
          sleep 30
          # 健康检查
          curl -f https://example.com/health || {
            echo "Health check failed, rolling back"
            # 回滚逻辑
            exit 1
          }
      - name: Rollback on Failure
        if: failure()
        run: |
          # 回滚到上一个版本
          ssh user@prod-server 'pm2 restart app' || true

踩过的坑

这套流程跑下来,踩了不少坑。

坑 1:部署到一半网络断了

一开始用 rsync 直接部署,有次网络断了,文件传了一半,线上服务起不来。后来加上本地构建、只传构建产物、原子替换:

# 本地构建
npm run build

# 打包
tar -czf build.tar.gz dist/

# 传到服务器
scp build.tar.gz user@server:/tmp/

# 服务器上解压并替换
ssh user@server << 'EOF'
  cd /var/www
  # 备份当前版本
  cp -r app app.backup
  # 解压新版本
  tar -xzf /tmp/build.tar.gz -C app.new
  # 原子替换
  mv app app.old && mv app.new app
  # 清理
  rm -rf app.old /tmp/build.tar.gz
  # 重启服务
  pm2 restart app
EOF

坑 2:配置文件覆盖

部署时 rsync 用 --delete 参数,会把目标目录里不在源目录的文件删掉。有次把配置文件删了,服务起不来。后来改成配置文件和代码分离,配置文件通过环境变量或专门的配置服务管理。

# 代码中只读环境变量
const apiUrl = process.env.API_URL;

# CI/CD 中注入环境变量
steps:
  - name: Deploy
    run: |
      echo "API_URL=${{ secrets.API_URL }}" >> .env.production
      # 部署...

坑 3:数据库迁移没跑

代码部署了,但数据库迁移脚本没跑,服务报错。后来在部署流程里加上数据库迁移步骤,而且要在服务重启前跑完:

- name: Run Database Migrations
  run: |
    ssh user@server << 'EOF'
      cd /var/www/app
      npx prisma migrate deploy
    EOF
- name: Restart Service
  run: |
    ssh user@server 'pm2 restart app'

坑 4:依赖版本不一致

本地用 npm install,CI 用 npm ci,但 package-lock.json 没提交,导致依赖版本不一致。后来规定必须提交 package-lock.json,并且 CI 里强制用 npm ci

坑 5:部署后内存泄漏

有次部署后,服务正常运行几个小时后内存爆了,因为是渐进式问题,当时没发现。后来加上监控和告警,部署后持续观察 24 小时,有问题就自动回滚。

- name: Post-Deploy Monitoring
  run: |
    for i in {1..24}; do
      sleep 3600  # 每小时检查一次
      curl -f https://example.com/health || {
        echo "Health check failed after deploy"
        exit 1
      }
    done

结果和总结

这次改造花了一个月,从 CI 到 CD。改造完成后,效果很明显:

  • 部署频率从每周一次提升到每天多次
  • 部署成功率从 80% 提升到 99%
  • 每次部署平均耗时从 30 分钟降到 5 分钟
  • 生产环境故障从每月 2-3 次降到几乎为 0

但更重要的是,流程规范化了,风险可控了,大家不用熬夜盯着部署了。

回头看这次改造,有几个感受:

第一,工具选型要务实。我们选 GitHub Actions 是因为代码在 GitHub,不用额外搭建服务。如果代码在 GitLab,那就用 GitLab CI。工具是手段,不是目的。

第二,要从小做起。不要一开始就想搞完美,先从最简单的开始,比如自动跑测试、自动构建,再逐步完善。我们的流程也是一步步加起来的。

第三,要有回滚机制。自动部署一定要有自动回滚,否则一次失败就可能导致长时间不可用。

第四,监控和告警很重要。部署后要持续观察服务状态,有问题及时发现及时处理。

第五,不要完全自动化生产环境。生产环境还是要有一些人工确认的环节,比如手动触发部署、确认版本号等。

持续交付不是终点,是起点。它让发布变得更容易,从而让你更频繁地发布,更快速地反馈,更快速地迭代。这才是持续交付的真正价值。

参考链接

可用性说明:本文发布于 2021 年 1 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。

版权声明: 本文首发于 指尖魔法屋-持续交付折腾手记https://blog.thinkmoon.cn/post/101-continuous-delivery-ci-cd-complete-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!