持续交付折腾手记
持续交付上手并不难,难的是稳定跑起来。
下面只记真正影响结果的部分。
背景
这次做持续交付改造,起因很直接:上周五晚上十点半,生产环境部署失败,回滚后再部署又失败,折腾到凌晨一点才搞定。原因很经典:本地开发环境、测试环境、生产环境配置不一致,测试没覆盖到,线下也没跑全流程。
这不是第一次了。之前半年,类似事情发生了四五次。每次都是"这次肯定没问题",然后某个环境就给你出个幺蛾子。
我们之前的模式是这样的:开发本地写完代码,推到远程,手动触发 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 成功后自动部署到测试环境,测试环境冒烟测试通过后,自动部署到生产环境。但这个想法被否定了,因为生产环境不能这么随意。
实际采用的方案是:
main分支 push 后,自动部署到测试环境- 测试环境验证通过后,手动触发生产环境部署
- 生产环境部署失败后,自动回滚到上一个版本
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。工具是手段,不是目的。
第二,要从小做起。不要一开始就想搞完美,先从最简单的开始,比如自动跑测试、自动构建,再逐步完善。我们的流程也是一步步加起来的。
第三,要有回滚机制。自动部署一定要有自动回滚,否则一次失败就可能导致长时间不可用。
第四,监控和告警很重要。部署后要持续观察服务状态,有问题及时发现及时处理。
第五,不要完全自动化生产环境。生产环境还是要有一些人工确认的环节,比如手动触发部署、确认版本号等。
持续交付不是终点,是起点。它让发布变得更容易,从而让你更频繁地发布,更快速地反馈,更快速地迭代。这才是持续交付的真正价值。
参考链接
- GitHub Actions Documentation
- Continuous Delivery: Reliable Software Releases through Build, Test, and Deployment Automation
可用性说明:本文发布于 2021 年 1 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-持续交付折腾手记(https://blog.thinkmoon.cn/post/101-continuous-delivery-ci-cd-complete-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。