小团队从零搭建自动化发布流水线——踩坑实录

一步一个脚印一个坑 5天前 ⋅ 11 阅读
ad

我们是一个小团队,产品由 7 个子系统组成,分别是:应用中心(center)、前端监控(monitor)、后端监控(apm)、埋点系统(event)、日志系统(log)、大屏系统(screen)、文件系统(file),每个子系统分前端和后端,共约 14 个独立 Git 仓库。我们是没有运维同学的,遇到的运维问题都是自己硬着头皮上去解决的,久病成良医,渐渐的我们也了解了一些运维知识,但是远没有达到专业的水准,所以想搭建一个好用的自动化发布流水线还是有一些难度。本文记录从"手动发布"到"一键自动化发布"的完整踩坑过程,希望可以给您提供一些灵感。

 


一、背景与痛点

发布前的工作流程是这样的:

  1. 开发人员在本地执行 npm run publish,把打包产物手动推送到总仓库(webfunny_monitor_cluster
  2. SSH 登录服务器,手动重启进程
  3. 每次发版都依赖个人电脑环境,耗时且容易出错

我们的目标:支持 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 的核心挑战不是技术选型,而是细节的魔鬼。把这次踩过的坑提炼成几条原则:

  1. SSH Key 要用 PEM 格式,写入前要过滤 \r
  2. 并行 Job 绝对不能共享有状态的文件,用 Job ID 做命名空间隔离
  3. NFS 挂载目录对 git 不友好,大文件操作优先走本地磁盘中转
  4. 并发推同一 git 分支必须加重试 + 随机退避,否则必然冲突
  5. 本地能跑不代表 CI 能跑,路径、依赖、环境变量差异是重灾区
  6. 合并冲突一定要在合并时解决,留在 main 分支里的冲突标记会在最意想不到的地方炸掉
  7. publish 脚本要健壮,不要硬编码本地路径,目标目录不存在时要创建而不是崩溃

从零到完整流水线大约经历了两周的迭代,每一个坑背后都是对系统更深一层的理解。希望这篇记录对同样在小团队摸索 DevOps 的同学有所帮助。


如果你也在用 GitLab CI + Jenkins 的组合,欢迎在评论区交流踩坑经验。

关于Webfunny

Webfunny专注于前端监控系统,前端埋点系统的研发。 致力于帮助开发者快速定位问题,帮助企业用数据驱动业务,实现业务数据的快速增长。支持H5/Web/PC前端、微信小程序、支付宝小程序、UniApp和Taro等跨平台框架。实时监控前端网页、前端数据分析、错误统计分析监控和BUG预警,第一时间报警,快速修复BUG!支持私有化部署,Docker容器化部署,可支持千万级PV的日活量!

  点赞 0   收藏 0
  • 一步一个脚印一个坑
    共发布144篇文章 获得4个收藏
全部评论: 0