华为云 CodeArts:从手工部署到流水线自动发布的改造实录
一、手工上线的隐形成本
先算一笔账。
我们的上线清单是这样的:12 个步骤,写在文档里,每次上线对着勾。
- 本地确认
git status干净 - 拉最新代码
- 创建虚拟环境、装依赖
- 跑单元测试
- 手动改配置文件的数据库地址
- 打包上传
- SSH 上服务器,备份旧代码
- 停服务
- 解压覆盖
- 执行数据库迁移
- 启服务
- 看日志确认正常
表面上每次 40 分钟。但真实成本不止这些:
心理成本。 12 步里任何一步出错,都要判断"现在处于什么状态、要不要回退"。第 9 步失败和第 10 步失败,处理方式完全不同。这种状态判断消耗的注意力,远超过操作本身。
频率成本。 因为上线有成本,大家本能地"攒一波再发"。结果每次上线包含 30 个变更,一旦出问题,排查范围是 30 个改动,定位难度指数级上升。
一致性成本。 第 5 步"手动改配置"是所有事故的高发区。有一次改完忘了改回来,本地测试环境连到了生产库。
那次"忘了跑数据库迁移"的事故,是压垮骆驼的最后一根稻草:新代码上线了,新字段没建,所有涉及新字段的页面全报 500。而迁移脚本就躺在仓库里,第 10 步被跳过了——因为当时急着发版。
结论很清楚:靠清单和纪律保证一致性,一定会失败。 一致性必须由机器保证。
二、流水线的本质:把清单变成不可跳过的代码
CI/CD 流水线做的事情,本质就一句:把上线清单写成代码,让机器执行,且无法跳步。
CodeArts 的流水线由三类元素组成:
| 元素 | 作用 | 我们能填什么 |
|---|---|---|
| 阶段(Stage) | 逻辑分组 | 构建、测试、部署 |
| 任务(Task) | 具体动作 | Shell 脚本、构建、部署、代码检查 |
| 参数与变量 | 环境差异 | 测试/生产环境地址、镜像版本号 |
分阶段不是为了好看,是为了失败时能停下。构建失败就不该进测试,测试失败就不该进部署。这个"停"的能力,是流水线比手工清单强的最关键一点。
三、五个阶段的完整设计
我们的流水线分了五个阶段,每个阶段的职责和失败处理都不一样:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 1.代码检查│──▶│ 2.构建镜像│──▶│ 3.自动化测试│──▶│ 4.部署测试 │──▶│ 5.部署生产│
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
静态扫描 产出镜像 单元+集成 验证通过 需人工审批
阻断严重问题 打唯一标签 覆盖率门槛 冒烟测试 灰度+观测
3.1 阶段一:代码检查
这一阶段的价值常被低估。它能在代码还没进入构建时就拦下问题:语法错误、明显的安全漏洞(硬编码密钥)、圈复杂度超标。
# 概念示意:代码检查任务的关键配置
检查规则集: 通用编码规范 + 安全规范
阻断条件:
- 严重级别问题数 > 0 # 一个严重问题就阻断
- 新增代码圈复杂度 > 15
注意"新增代码"这个限定词很重要——历史代码可能有几百个警告,如果对所有代码都卡阈值,流水线永远是红的,团队很快就会集体无视告警。只卡新增部分,既能防止恶化,又不至于无法推进。
3.2 阶段二:构建与镜像
# 构建脚本(流水线内执行)
set -e # 任何一步失败立即退出,不要吞错误
VERSION=$(git describe --tags --always) # 用 git 信息做版本号
echo "building version: ${VERSION}"
docker build \
--build-arg BUILD_VERSION="${VERSION}" \
-t "${SWR_HOST}/${ORG}/myapp:${VERSION}" .
docker push "${SWR_HOST}/${ORG}/myapp:${VERSION}"
# 把版本号传给下游阶段
echo "${VERSION}" > version.txt
set -e 是这里的关键。默认情况下 Shell 脚本遇到错误会继续往下执行,docker build 失败了还会去 docker push 一个不存在的镜像,最后报一个完全不相干的错。每个 CI 脚本都应该以 set -e 开头。
版本号用 git describe 而不是时间戳,好处是能从镜像名反查到具体提交。
3.3 阶段三:自动化测试
set -e
pip install -r requirements.txt
pip install pytest pytest-cov
# --cov-fail-under 让覆盖率不达标直接失败
pytest tests/ \
--cov=app \
--cov-report=term \
--cov-fail-under=70 \
-x # 遇到第一个失败就停,不要跑完所有用例浪费时间
覆盖率门槛(70%)这个数字要慎重设。设太高(90%)会逼着大家写无意义的断言去凑数;设太低(30%)起不到保护作用。建议从当前实际覆盖率往上加 5 个点,逐步提升。
同时要区分两类测试:
| 类型 | 跑在哪 | 耗时 | 依赖 |
|---|---|---|---|
| 单元测试 | 阶段三 | 秒级 | 无外部依赖,全 mock |
| 集成测试 | 阶段四 | 分钟级 | 需要真实数据库、缓存 |
把慢测试挪到部署后是个重要设计:单元测试在构建阶段快速给出反馈,集成测试在测试环境里验证真实链路。混在一起会让每次提交都等 10 分钟。
3.4 阶段四:部署测试环境 + 冒烟
set -e
VERSION=$(cat version.txt)
# 更新镜像版本(CCE 场景)
kubectl set image deployment/myapp \
myapp="${SWR_HOST}/${ORG}/myapp:${VERSION}" \
--namespace=test
# 等滚动更新完成,最多等 5 分钟
kubectl rollout status deployment/myapp \
--namespace=test --timeout=300s
# 冒烟测试:验证核心接口真的能用
curl -sf "https://test.example.com/healthz" || exit 1
curl -sf "https://test.example.com/api/version" | grep "${VERSION}" || exit 1
冒烟测试里那句 grep "${VERSION}" 特别有用。它验证的不只是"服务活着",而是"跑的是我这次要发布的那个版本"。少了这一步,可能出现流水线全绿但线上还是旧版本的情况——部署命令执行了,但因为镜像标签没变,K8s 认为无需更新,什么都没做。
3.5 阶段五:部署生产(带审批)
生产部署一定要人工审批卡一道。这不是不信任自动化,而是自动化无法判断的两种情况:
- 现在是业务高峰期吗?
- 有没有正在进行的运营活动不能被打断?
审批通过后走灰度:
# 第一步:先更新 1 个实例(共 3 个)
kubectl set image deployment/myapp \
myapp="${SWR_HOST}/${ORG}/myapp:${VERSION}" \
--namespace=prod
kubectl rollout pause deployment/myapp --namespace=prod
# 观察 5 分钟:看错误率和延迟
sleep 300
ERROR_RATE=$(query_error_rate)
if [ "${ERROR_RATE}" -gt 1 ]; then
kubectl rollout undo deployment/myapp --namespace=prod
exit 1
fi
# 第二步:确认正常,继续滚动
kubectl rollout resume deployment/myapp --namespace=prod
rollout pause + 指标判断 + resume/undo 这三步,构成了一个最小可用的自动灰度。关键在于指标判断放在流水线里而不是靠人看监控——人看监控容易"再等等看",机器不会。
四、四个必须踩过才知道的坑
4.1 密钥不能写进流水线配置
流水线的 YAML 是要提交到仓库的。把数据库密码、AK/SK 写进去,等于把密钥公开发布。
正确做法是用平台的凭据管理功能:密钥存成加密凭据,流水线里只引用凭据 ID,运行时注入成环境变量。
更彻底的做法是根本不用长期密钥——部署步骤通过委托获取临时凭证,凭证几分钟就过期。
4.2 数据库迁移必须在部署前跑,且要能重复执行
那次事故的根因就是顺序错了。正确的顺序是:
① 跑迁移(向后兼容,即旧代码也能在新表结构上运行)
② 部署新代码
③ 确认正常后,再清理旧字段
这叫扩展-收缩模式。核心是第①步的迁移必须"新旧代码都能用":加字段可以,改字段名不行(要分两步:先加新字段双写,再删旧字段)。
迁移脚本还必须幂等——重复执行不报错。因为流水线重试是常态,跑第二遍就报"表已存在"的迁移,会让重试机制失效。
4.3 流水线会掩盖"环境差异"
自动化之后有个新问题:测试环境全绿,生产还是炸。原因往往是两个环境的差异没有体现在配置里——测试环境 4 核,生产 2 核;测试环境数据库版本 8.0,生产 5.7。
解法是把所有环境差异显式化成变量,并且在流水线里对关键变量做校验。比如部署前断言"生产环境的数据库版本必须是 8.0",不满足就拒绝部署。
4.4 别让流水线变成"黑盒"
上线是自动化了,但出问题时没人知道流水线到底做了什么。
必须做的两件事:一是每个阶段都输出结构化日志(做什么、耗时多少、结果如何),二是构建产物永久可追溯——给定一个线上运行的镜像版本号,能反查到它是哪次提交、哪个流水线运行、跑了哪些测试。
做不到这两点,自动化就从"减少工作量"变成了"减少可见性"。
五、一个意想不到的结果
流水线上线三个月后,我们对比了前后数据:
| 指标 | 手工时代 | 流水线时代 |
|---|---|---|
| 单次上线耗时 | 40 分钟 | 12 分钟(且无需人工盯) |
| 上线频率 | 每周 1 次 | 每天 1~2 次 |
| 上线事故率 | 约 1/5 次 | 约 1/20 次 |
| 平均回滚耗时 | 25 分钟 | 90 秒 |
| 变更批量 | 平均 30 个改动 | 平均 3 个改动 |
最值得注意的不是"耗时降低",而是变更批量从 30 个降到 3 个。
这是自动化真正改变的地方:当上线变得廉价,团队就会小步走。小步走带来的直接收益是故障定位范围缩小一个数量级——出问题时只需要检查 3 个改动,而不是 30 个。事故率下降 4 倍,主要功劳在这里,而不在自动化本身有多聪明。
这形成了一个正循环:上线越容易 → 每次改动越少 → 出问题越少 → 越敢频繁上线。
六、常见问题 Q&A
Q1:小项目有必要上流水线吗?
看上线频率。一周发不了一次的项目,手工部署够用。但只要到了"一周多次"或者"多人协作",流水线的价值就立刻显现——因为手工部署的风险和频率成正比,而流水线的成本是固定的。
Q2:流水线跑一次要 10 分钟,太慢了怎么办?
先分层:单元测试必须快(2 分钟内),慢测试挪到部署后。再并行:不相关的检查任务并发跑。最后优化缓存:依赖包、Docker 层都要缓存。我们优化完从 11 分钟降到 4 分钟,主要靠依赖缓存。
Q3:怎么防止有人绕过流水线直接手工上线?
技术上堵不住(有服务器权限就能登)。真正的办法是让合规路径比违规路径更省事:流水线 12 分钟全自动,手工部署要 40 分钟还担风险,人自然会选前者。另外给生产环境配置审计日志,让手工操作留痕。
Q4:灰度发布发现有问题,怎么快速回滚?
kubectl rollout undo 或把镜像标签改回上个版本,几十秒完成。前提是镜像必须带唯一版本号(不能用 latest),否则无法回到"确定的那个旧版本"。
Q5:数据库迁移失败了怎么办?
这是最难的场景。所以迁移要遵循两条纪律:一是只做可逆的变更(加字段可逆,删字段不可逆,删字段要单独排期),二是上线前在测试环境用生产数据量演练一遍。真遇到不可逆的迁移失败,只能靠备份恢复,代价很高。
Q6:流水线要不要跑在 K8s 里?
CodeArts 自带执行机,中小规模够用。当并发流水线多、需要特殊环境(GPU、大内存)时,可以接入自管执行集群。我们的做法是:普通构建用平台执行机,需要 GPU 的测试任务放到 CCE 的临时节点上跑完即销毁。
七、总结
流水线的价值,很多人理解成"省时间"。但省下的 28 分钟其实不是重点。
重点在三个隐性收益:一致性(每次都是同一套动作,不依赖谁记得哪一步)、可见性(失败在哪一步、什么原因、谁触发的,全有记录)、心理安全感(不用担心忘记某一步,于是敢改小步)。
第三条最容易被忽视,但影响最大。手工部署时代,工程师不是不知道该小步走,而是"每发一次都这么麻烦,不如攒一攒"。自动化解除了这个约束,工程实践才可能往更好的方向走。
所以判断一条流水线做得好不好,别看它跑得多快,看一个指标:团队的上线频率有没有提高。如果没有,说明自动化只是把手工包装了一遍,没有真正降低上线的门槛。
- 点赞
- 收藏
- 关注作者
评论(0)