我们是一个小团队,产品由 7 个子系统组成,分别是:应用中心(center)、前端监控(monitor)、后端监控(apm)、埋点系统(event)、日志系统(log)、大屏系统(screen)、文件系统(file),每个子系统分前端和后端,共约 14 个独立 Git 仓库。我们是没有运维同学的,遇到的运维问题都是自己硬着头皮上去解决的,久病成良医,渐渐的我们也了解了一些运维知识,但是远没有达到专业的水准,所以想搭建一个好用的自动化发布流水线还是有一些难度。本文记录从"手动发布"到"一键自动化发布"的完整踩坑过程,希望可以给您提供一些灵感。
一、背景与痛点
发布前的工作流程是这样的:
- 开发人员在本地执行
npm run publish,把打包产物手动推送到总仓库(webfunny_monitor_cluster) - SSH 登录服务器,手动重启进程
- 每次发版都依赖个人电脑环境,耗时且容易出错
我们的目标:支持 dev / test / staging / main(SaaS)/ cloud 五套环境的一键自动化发布。
二、架构设计
最终选定 GitLab CI + Jenkins 混合架构:
Jenkins(手动触发 / 可视化)
↓ API 调用
GitLab CI(构建 / 打包 / 推送产物)
↓ git push
webfunny_monitor_cluster(汇总仓库)
↓ 自动部署
生产服务器
分工:
- Jenkins:手动触发、参数化构建(勾选项目 / 选择环境)、并行监控各子项目状态
- GitLab CI:npm install、webpack 打包、推送产物到总仓库、rsync 部署
为什么不纯用 GitLab CI?因为 GitLab CI 的触发和可视化对非技术人员不友好,Jenkins 的参数化构建界面更直观,适合团队日常使用。
三、踩坑实录
坑 1:SSH 私钥格式错误 —— error in libcrypto
现象
Load key "~/.ssh/id_rsa": error in libcrypto
Permission denied (publickey,password).
私钥文件明明有内容,SSH 却报错说不是 key 文件。
原因
GitLab 新版 ssh-keygen 默认生成 OPENSSH 格式(-----BEGIN OPENSSH PRIVATE KEY-----),而 runner 机器上的旧版 OpenSSH 只认 PEM 格式(-----BEGIN RSA PRIVATE KEY-----)。
解决
生成密钥时强制指定 PEM 格式:
ssh-keygen -t rsa -b 4096 -m PEM -C "gitlab-ci-cluster-push"
另一个细节:在 Windows 上编辑或复制的私钥内容会带 \r\n 换行符,GitLab CI 变量写入文件后可能残留 \r,导致同样的报错。解决方式是写入时过滤:
- tr -d '\r' < "$GIT_PUSH_KEY" > ~/.ssh/id_rsa
坑 2:并行 Job 共享 SSH Key 文件冲突 —— Permission denied
现象
10 个项目并行打包时,部分 job 报错:
Warning: Identity file ~/.ssh/id_rsa not accessible: No such file or directory.
Permission denied (publickey,password).
原因
多个 GitLab CI job 跑在同一台 runner 机器上,共享同一个 ~/.ssh/ 目录。
Job A 在 after_script 里执行 rm -f ~/.ssh/id_rsa,而此时 Job B 还在运行中,git push 找不到 key 文件,鉴权失败。
解决
用 ${CI_JOB_ID} 为每个 job 生成独立的 key 文件名:
before_script:
- tr -d '\r' < "$GIT_PUSH_KEY" > ~/.ssh/id_rsa_${CI_JOB_ID}
- chmod 600 ~/.ssh/id_rsa_${CI_JOB_ID}
- export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/id_rsa_${CI_JOB_ID} -o IdentitiesOnly=yes"
after_script:
- rm -f ~/.ssh/id_rsa_${CI_JOB_ID}
核心原则:凡是会被并行 job 共享的有状态文件,都必须加 job 级别的命名空间隔离。
坑 3:特定 Runner 上 git clone 必现 tmp_pack 报错
现象
fatal: could not open '.git/objects/pack/tmp_pack_AJJFfw' for reading: No such file or directory
fatal: fetch-pack: invalid index-pack output
只在特定 runner(W_FaT39Ln)上必现,其他 runner 正常。磁盘未满,git 版本 2.34.1。
原因
排查后发现该 runner 的 build 目录挂载在 NFS 网络文件系统上。
git clone 时,index-pack 子进程负责把接收到的 pack 数据写入 tmp_pack_XXX 临时文件,写完后立即读取生成索引。NFS 的缓存一致性存在延迟,导致"写完但还没同步",读取时文件对进程"不可见",产生 ENOENT 错误。
解决
先 clone 到本地磁盘 /tmp,再 mv 到 build 目录:
- |
CLUSTER_TMP="/tmp/wmc_${CI_JOB_ID}"
CLUSTER_DST="$CI_PROJECT_DIR/../0_pre_publish/webfunny_monitor_cluster"
for i in 1 2 3; do
rm -rf "$CLUSTER_TMP"
git clone --depth 1 -b cloud \
git@gitlab.webfunny.com:product/webfunny_monitor_cluster.git \
"$CLUSTER_TMP" && \
rm -rf "$CLUSTER_DST" && mv "$CLUSTER_TMP" "$CLUSTER_DST" && break
echo "clone attempt $i failed, retrying..."
sleep 5
done
顺带优化:加 --depth 1 浅克隆减少数据传输;推送前加 git fetch --unshallow 2>/dev/null || true 避免浅克隆 rebase 失败。
坑 4:10 个项目并发推同一分支被 reject
现象
! [rejected] cloud -> cloud (fetch first)
error: failed to push some refs to 'git@gitlab.webfunny.com:...'
hint: Updates were rejected because the remote contains work that you do not have locally.
原因
10 个 job 同时 push 到 cloud 分支。git pull --rebase 和 git push 之间存在竞争窗口——pull 的一瞬间是最新的,但 push 之前另一个 job 已经捷足先登。
解决
push 失败后重新 pull 再 push,加随机退避避免再次碰撞:
- |
for push_retry in 1 2 3 4 5; do
git fetch --unshallow 2>/dev/null || true
git pull --rebase origin cloud
git push origin cloud && break
echo "push attempt $push_retry rejected, retrying..."
sleep $(( RANDOM % 8 + 3 ))
done
RANDOM % 8 + 3 产生 3-10 秒的随机等待,错开多个 job 的重试时机。
坑 5:main 分支遗留未解决的 git 合并冲突
现象
CI 跑到 npm install 直接挂掉:
npm ERR! Merge conflict detected in your package.json.
npm ERR! Please resolve the package.json conflict and retry.
原因
之前 staging merge 到 main 时,package.json 产生了冲突,没有手动解决就直接推上去了。冲突标记(<<<<<<<、=======、>>>>>>>)一直躺在 main 分支里。
本地开发没有暴露是因为 node_modules 有缓存,不需要重新 npm install;但 CI 每次都是全新环境,立刻中招。
解决
手动解决三处冲突(scripts 区域保留新版命令,dependencies 区域合并两侧依赖),commit 推送。
预防措施:可在 CI 加一条简单检查:
- grep -rn "<<<<<<< " . --include="*.json" && exit 1 || true
坑 6:moveDist.js 命令行参数被注释掉,导致 readdirSync("") 崩溃
现象
Error: ENOENT: no such file or directory, scandir
at Object.readdirSync (node:fs:1438:3)
at emptyDir (.../moveDist.js:16:20)
原因
moveDist.js 里,从命令行读取目标路径的那行被注释掉了:
// const targetPath = process.argv[3] ← 被注释掉
const targetPath = "/0_pre_publish/webfunny_monitor" ← 硬编码
而 startCopy() 里的条件判断是 if (targetPath === "webfunny"),硬编码值完全不匹配,最终 target = "",调用 readdirSync("") 时崩溃。
本地不报错是因为本地 npm run publish 调用路径不同,开发者从来没走过这段逻辑。
解决
// 恢复从命令行读取(publish_saas / publish_cloud 均传入 "webfunny")
const targetPath = process.argv[3]
function emptyDir(path) {
// 目标目录不存在时自动创建,而不是崩溃
if (!fs.existsSync(path)) {
fs.mkdirSync(path, { recursive: true })
return
}
const files = fs.readdirSync(path)
// ...
}
教训:publish 脚本不要硬编码本地路径;目标目录不存在时应主动创建,而不是抛出异常。
坑 7:Jenkins 用了 HTTPS,但内部 GitLab 只监听 HTTP
现象
curl: (7) Failed to connect to gitlab.webfunny.com port 443: Connection refused
原因
内部自建的 GitLab 没有配置 HTTPS 证书,只监听 80 端口。Jenkins pipeline 里写的是 https://。
解决
把 Jenkins pipeline 里所有 GitLab API 地址改为 http://:
def gitlabUrl = 'http://gitlab.webfunny.com'
坑 8:GitLab 出现 blocked 状态的"幽灵 Pipeline"
现象
每次推代码到 staging 分支,GitLab 自动创建一条 pipeline,因为没有 job 匹配而显示 blocked,一直挂在列表里,影响美观。
原因
GitLab CI 默认对所有 push 事件创建 pipeline,但 job 的 rules 配置为仅 API 触发,导致 pipeline 创建了却没有任何 job 可以运行,进入 blocked 状态。
解决
用 workflow:rules 在流水线级别控制,直接阻止不需要的 pipeline 创建:
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "api"' # 允许 Jenkins API 触发
- if: '$CI_COMMIT_REF_NAME == "dev"' # 允许 dev 分支自动触发
- if: '$CI_COMMIT_REF_NAME == "test"' # 允许 test 分支自动触发
- when: never # 其他情况不创建 pipeline
四、最终效果
| 场景 | 操作 |
|---|---|
| 开发联调 | Jenkins → webfunny-deploy-dev → 选项目 → 部署到 dev 服务器 |
| 测试验证 | Jenkins → webfunny-deploy-test → 选项目 → 部署到 test 服务器 |
| 预发布 | Jenkins → webfunny-deploy-staging → 选项目 → 打包并部署 staging |
| SaaS 发版 | Jenkins → webfunny-publish-main → 勾选项目 → 并行打包推到 cluster/main |
| Cloud 发版 | Jenkins → webfunny-publish-cloud → 勾选项目 → 并行打包推到 cluster/cloud |
Jenkins 界面支持多选项目(默认勾选常用的 monitor + apm),不需要每次全量发布,有效节省 runner 机器资源。
五、总结与反思
小公司搭 CI/CD 的核心挑战不是技术选型,而是细节的魔鬼。把这次踩过的坑提炼成几条原则:
- SSH Key 要用 PEM 格式,写入前要过滤
\r - 并行 Job 绝对不能共享有状态的文件,用 Job ID 做命名空间隔离
- NFS 挂载目录对 git 不友好,大文件操作优先走本地磁盘中转
- 并发推同一 git 分支必须加重试 + 随机退避,否则必然冲突
- 本地能跑不代表 CI 能跑,路径、依赖、环境变量差异是重灾区
- 合并冲突一定要在合并时解决,留在 main 分支里的冲突标记会在最意想不到的地方炸掉
- publish 脚本要健壮,不要硬编码本地路径,目标目录不存在时要创建而不是崩溃
从零到完整流水线大约经历了两周的迭代,每一个坑背后都是对系统更深一层的理解。希望这篇记录对同样在小团队摸索 DevOps 的同学有所帮助。
如果你也在用 GitLab CI + Jenkins 的组合,欢迎在评论区交流踩坑经验。