那是个再普通不过的周一上午,我改了一个下单接口的字段名,在本地跑通了,测试接口也过了,就用 scp 把编译好的包丢到了生产机上,重启服务,前后不到五分钟。我端着咖啡水等着看监控大盘,结果等来的是客户在群里发的消息:下单页面转圈,加载了十几秒最后报错。查日志发现接口直接抛了 NullPointerException,整个下单链路挂了,那天下午的订单量掉了将近七成。
后来复盘,我犯的是一个特别基础的错误我根本没有一条能自动发布的流水线。所有的上线动作都是手动完成的:手动改代码、手动编译、手动传包、手动重启。我之所以敢这么干,是因为过去半年只出过一次小问题,那次我改完五分钟后自己发现了。但这次不一样,改错的字段名藏在三个调用方之一的深处,测试环境的调用方根本没被触发。
如果你也在自己动手传包、手动重启,想把发版这件事交给流水线,这两家可以先看看:狗云DOGYUN(高性价比云服务器,机型齐全,适合拿一台跑 Jenkins 构建节点)、RackNerd(全球 VPS,Windows 与 Linux 机型可选,测试环境和生产环境分开采购很方便)。机器先备好,剩下的坑下面慢慢说。

手动发版最大的问题不是慢,而是不可追溯。我出事故那天翻日志,问自己"线上现在跑的是哪个版本",答不上来。包传上去是 14:32,服务重启是 14:33,但这个包是从哪次 commit 编译出来的、带了哪个本地改动、跟昨天线上跑的是不是同一份代码,全靠我脑子里的记忆。
我后来做的第一件事就是搭了一条最简陋的 Jenkins 流水线:一个 Git 仓库、一个 Jenkinsfile、三个 stagebuild、test、deploy。代码 push 上去,流水线自动跑,构建完打成一个带 commit hash 的镜像或者包,deploy 阶段通过 SSH 推送到目标机并重启服务。整个过程从 push 到线上生效,三分半钟,而我只需要点一下按钮或者推一个 tag。
这里的关键收益并不是"省了几分钟",而是"每次上线的代码都有唯一编号"。出事时我能立刻回答:线上跑的是 abc123,这次问题就是 abc123 引入的,回滚就是回到 abc122。

流水线搭起来之后,我们一度觉得万事大吉。构建在 Jenkins 机上跑,测试在 Jenkins 机上跑,部署到生产机上。但很快又出了一次问题:一个依赖库升级之后,测试环境一切正常,生产启动直接报错。
查下来是两个原因:一是构建机用的是 Ubuntu 22.04,生产机是 CentOS 7,glibc 版本不同,同一个二进制在生产机上跑到某个系统调用就崩;二是 Jenkins 机上装了一堆全局的 Python 包,构建时依赖的是这些全局包,而不是项目 requirements 文件里声明的版本。也就是说,流水线跑通了,但它跑通的环境跟真正上线的环境不是同一个。
正确的做法是让构建产物与环境解耦。容器是最省事的方案:CI 里 build 出一个镜像,测试阶段起一个容器跑,部署阶段把同一个镜像推到生产环境,中间不重新编译。如果暂时上不了容器,至少要做到依赖锁定Java 用 mvn dependency:go-offline 锁定依赖树,Python 把所有依赖写进 requirements.txt 并在纯净虚拟环境里安装,Node 提交 package-lock.json 并用 npm ci 而不是 npm install。
这个坑我是被同事发现的。我们把生产数据库的密码写在部署脚本的一个变量里,脚本进了 Git 仓库。仓库是内网的私有仓库,权限也做了限制,所以一直没出事。但审计的时候被问到"如果哪天这个仓库权限被误放开会怎样",我一身冷汗脚本里还有云厂商的 API 密钥、对象存储的 AccessKey。
改法不复杂但要花心思。凭据统一放到 Jenkins 的 Credentials 里,加密存储,构建时通过 withCredentials 注入成环境变量,脚本里只读变量不写死值。更重要的是要配合 git 的历史清理已经提交过的密钥即使改了文件,仓库历史里还留着,必须让运维去重写历史或者直接吊销那把密钥。吊销永远比清理历史重要,因为你不知道历史有没有已经被别人 clone 过。
还有一个配套的权限原则:Jenkins 的执行账号不要用 root。单独建一个 deploy 用户,只给它目标目录的写权限和重启服务的权限。流水线被误配置也不会直接掀翻整台机器。

流水线跑通之后,发版变成了"点一下按钮,5 分钟后所有人拿到新版本"。这听起来很爽,实际上是把风险从"程序员手动操作失误"变成了"一条流水线全量生效"。一次配置写错,影响面就是 100%。
灰度发布解决的就是这个。实现上有几个层次,从轻到重:
第一层,流量比例。用 Nginx 的 split_clients 或者 OpenResty 的 balancer 按比例分流,比如先给 5% 的用户走新版本,其余仍然走旧版本,观察十几分钟再看逐步放大。这是最容易上手的,也最实用。我现在固定用 5% 起步,盯住错误率和订单成功率两个指标。
第二层,按用户粘性。用 cookie 或者 header 标记"我这次是被选中看到新版本的人",这样同一个用户在整个灰度周期里看到的版本是稳定的,不会出现刷新一次界面就变了的情况。
第三层,独立环境。新版本先部署到 staging 环境,用真实流量回放或者合成流量压一遍,这一层过滤掉的 bug 最多。
灰度期间一定要准备好"一键回退"的操作路径。流量比例从 5% 调到 0% 就应该能在几十秒内完成,而不是去登机器上手动改配置。
以前我们遇到线上问题,第一反应是"赶紧改代码修好",很多时候修完更糟在出问题的版本上又叠了一层补丁,故障时间被拉长到几个小时。
流水线给了我们一个更省事的选择:回滚到上一个已知良好的版本。这件事要做到不难,前提是每次上线都保留上一个版本的产物,而且回滚是"换个版本号重新部署",而不是"从 SVN 里翻代码重新编译"。最省事的做法是发布的时候保留最近五个版本的包,回滚就是软链或者环境变量指回上一个目录,重启服务,两分钟内完成。
数据库的变更要单独小心。代码可以回滚,已经执行的 SQL 变更是回不去的。所以像删字段、改字段类型这类操作,都要用"先加不删、灰度观察、确认稳定再删"的方式拆成多次发布,别想着一个版本里把表结构全改完。

总结一下,踩完这五个坑之后,我把它整理成了一份落地清单,团队新人照着做基本不会再翻车:
1. 代码进仓库之前过 lint 和单元测试,流水线跑不过不许合并;
2. 构建产物统一打成镜像或固定依赖的包,测试和部署用同一份产物;
3. 凭据全部走 Jenkins Credentials,构建机不保存任何明文密钥,执行账号非 root;
4. 部署分灰度:5% 起步,按指标观察后逐步放量,异常时比例归零即回滚;
5. 保留最近五个版本的产物,回滚操作在五分钟内能完成;
6. 数据库变更独立发布,跟应用发布拆开,先加不删。
这套东西搭起来大概花了我两个周末,配置本身并不复杂,难点在于把"手动"变成"自动"这件事会牵动团队所有人的习惯,需要一点点推。
不过回头看,这笔账很划算。流水线搭好之后,我们的发布频率从一周一次提到了两三次,而线上故障的数量反而下降了因为每一个版本在被推到全量之前,都已经在流水线里被自动验证过了。手动发版的问题从来不是慢,而是把人的注意力当成了唯一的质量保障。流水线做的事情很简单:把那些每次都该做、但总有人会忘的步骤,交给机器去做。