为什么我放弃了传统方案,改用 Jenkins
一切始于一次凌晨两点的发布事故
先说说我是怎么走到这一步的。
去年我们团队大概十个人,项目是四个微服务加一个前端应用。当时的发布流程是典型的“传统方案”:开发在本地打包,通过 FTP 上传到跳板机,然后跑一个 shell 脚本重启服务。脚本是一个已经离职的同事写的,没人完全看得懂,出了问题就只能靠人肉排查。
有一天凌晨两点,一个紧急修复发布后服务起不来。我花了四十分钟才发现,是打包时本地 Node 版本和线上的不一致,导致构建产物有差异。那一刻我意识到:手工发布最大的问题不是慢,是不可重复。同一份代码,两个人打出来的包可能不一样。
当时我考虑过三个方案:
- 纯 shell 脚本 + Git hook:改造现有脚本,做成推代码自动部署
- GitLab CI:我们的代码已经在 GitLab 上,看起来顺理成章
- Jenkins:老牌工具,网上都说“重”,我一开始是抗拒的
我先试了方案一,花两天把脚本改成了支持回滚的版本,但每加一个项目就要复制粘贴一遍逻辑,参数硬编码,改一个地方容易漏另一个地方。方案二的 GitLab CI 是托管在内部 GitLab 的 runner 上,当时公司的 GitLab 版本太老,CI 功能受限,申请升级流程走了三周没下文。
于是我带着偏见去试了 Jenkins。三个月后的今天,我把剩下两个方案全部迁到了 Jenkins 上。下面说说具体怎么做的,以及我踩过的坑。
搭建:比我想象中简单,但有两个坑
我们用 Docker 跑 Jenkins,一台 4 核 8G 的虚拟机就够了:
docker run -d --name jenkins \
-p 8080:8080 -p 50000:50000 \
-v jenkins_home:/var/jenkins_home \
-v /var/run/docker.sock:/var/run/docker.sock \
jenkins/jenkins:lts
坑一:挂载 docker.sock 是为了让 Jenkins 容器里能调用宿主机的 Docker 构建镜像。 我一开始没挂,构建到 docker build 那一步直接报 docker: command not found,查了半小时才明白容器里根本没有 Docker。
坑二:容器里的时区默认是 UTC。 我们的构建历史显示的时间和实际差 8 小时,排查“这个包是什么时候打的”时特别混乱。解决方法是在启动时加环境变量:
-e TZ=Asia/Shanghai
装好之后,插件选择上我的建议是:不要装推荐的全部插件。首次进入的“安装推荐插件”会装一大堆你永远用不到的东西(各种 Subversion、TFS 之类的),拖慢启动。我最小化安装了这几样:
- Git / GitLab(代码拉取和 webhook)
- Pipeline(核心,后面细说)
- Blue Ocean(可视化流水线,给团队看构建状态用)
- Credentials Binding(管理密钥)
真正让我留下来的原因:Pipeline as Code
传统方案(包括我最初的 shell 脚本)的致命问题是流程只存在于某个人的脑子里或某台机器上。Jenkins 的 Pipeline 用 Jenkinsfile 把整个流程变成代码,进版本库,谁都能看、能改、能回溯。
这是我们一个后端服务的完整 Jenkinsfile(简化版):
pipeline {
agent any
environment {
IMAGE = "registry.internal.com/order-service"
}
stages {
stage('构建') {
steps {
sh 'mvn clean package -DskipTests'
}
}
stage('测试') {
steps {
sh 'mvn test'
junit 'target/surefire-reports/*.xml'
}
}
stage('镜像') {
steps {
sh "docker build -t ${IMAGE}:${env.BUILD_NUMBER} ."
sh "docker push ${IMAGE}:${env.BUILD_NUMBER}"
}
}
stage('部署到测试环境') {
steps {
sh "ssh deploy@staging 'cd /app && ./deploy.sh ${IMAGE} ${env.BUILD_NUMBER}'"
}
}
}
post {
failure {
dingtalkNotify("构建失败: ${env.JOB_NAME} #${env.BUILD_NUMBER}")
}
}
}
几个月下来,这个文件带来三个实实在在的好处:
- 每次构建产物都有唯一版本号(用
BUILD_NUMBER),回滚就是部署上一个版本号,一条命令的事 - 测试强制通过才能构建镜像,再也没人能跳过测试发布
- 新项目接入只要复制一个 Jenkinsfile,改几个参数,十分钟搞定
凭据管理:告别散落在脚本里的密码
以前我们的部署脚本里有这样一行:
sshpass -p 'MyP@ssw0rd123' ssh deploy@prod-server
密码明文写在脚本里,还在 Git 仓库里躺了两年。Jenkins 的 Credentials 功能把这个彻底解决了:
在 Manage Jenkins → Credentials 里添加一个 "SSH Username with private key" 类型的凭据,ID 设为 deploy-key,然后在 Pipeline 里:
withCredentials([sshUserPrivateKey(credentialsId: 'deploy-key', keyFileVariable: 'SSH_KEY')]) {
sh 'ssh -i $SSH_KEY deploy@prod-server "cd /app && ./deploy.sh"'
}
日志里密码会被自动打码成 ****。我把仓库里所有带明文密码的历史记录都清理掉了,这件事拖了太久,Jenkins 逼着我做完了。
踩坑记录
说几个文档里不会告诉你、但我实打实撞上的问题:
1. 不要把 Jenkins master 当构建机器用。 项目多了以后,构建并发会让 master 卡死。后来我加了两台 agent 节点,master 上设置 executor 数为 0,只负责调度。这一步我拖太久了,期间 master 至少挂了三次。
2. workspace 不清理会撑爆磁盘。 每次 mvn package 留下的 target 目录累积起来,三个月后磁盘满了,所有构建诡异失败。解决:在 pipeline 里加 deleteDir() 或者用 Workspace Cleanup 插件。
3. webhook 配置后不触发构建。 我以为是 GitLab 配置问题,排查半天,其实是 Jenkins 任务的 URL 填错了,且防火墙没放行 GitLab 到 Jenkins 的 8080 端口。防火墙——这个最朴素的原因,我最后才查。
4. 插件升级要谨慎。 有一次我批量升级了所有插件,某个插件新版本和 GitLab 插件不兼容,webhook 全挂了。教训是:升级一次只升一个,升级前用 ThinBackup 插件备份。
实际效果对比
| 指标 | 传统方案 | Jenkins 之后 |
|---|---|---|
| 单次发布时间 | 20-40 分钟,手工 | 6 分钟,自动 |
| 发布频率 | 每周 1-2 次 | 每天 3-5 次 |
| 回滚 | 手工找旧包,靠运气 | 一条命令指定版本号 |
| 排查构建问题 | 翻聊天记录问人 | 看构建日志,全部留痕 |
诚实的评价:Jenkins 不是完美的
说了这么多好话,也得讲讲代价:
- UI 确实老旧,Blue Ocean 好一些但很久没大更新了,和 GitLab CI、GitHub Actions 的界面没法比
- Groovy 语法学习成本不低,团队里两个人至今只会改不会写
- 维护成本真实存在:插件升级、磁盘清理、节点管理,这些活儿每个月大概要花我半天时间
- 如果你是新项目、云端部署,GitHub Actions 或云厂商自带的 CI 可能更省心,没必要上来就自建 Jenkins
我的结论是:Jenkins 最适合的场景是自建机房、多项目复用、需要灵活控制部署流程的团队。我们这种内网环境加十几个人规模的团队,它就是当下最务实的选择。
如果你也在被手工发布折磨,我的建议是:先挑一个最痛的服务,用 Docker 起个 Jenkins,写一个五阶段的流水线跑通全流程。一旦体会到“点一下按钮、看着日志滚动、六分钟后服务上线”的感觉,你就回不去了。