极简CI工具Caveman实战:在自有服务器上搭建轻量级自动化构建系统
我第一次看到 “caveman” 这个词被当成一个项目名,第一反应是:这该不会是个恶搞项目吧?结果顺着仓库点进去,发现它是一个正经到让人意外的持续集成工具,而且定位极其明确:simple CI,一个可以跑在你自己的服务器上、配置简单到像穴居人敲石头一样的自动化构建系统。
我前后在几个项目里用过它跑构建、做定时任务、挂 Webhook 自动触发测试,加起来也有半年多,今天想把真实的体验、踩过的坑和完整配置思路整理出来。无论你是一个人的独立开发者,还是三五个人的小团队,只要你手上有多余的服务器、受够了 Jenkins 的插件负担、又不想让整个 CI 流程被某个托管平台绑死,这篇文章应该能帮你省下不少折腾时间。
1. 先聊聊 caveman 到底是什么
1.1 一个被名字耽误的持续集成工具
Caveman 是一个用 Python 写的极简 CI 工具,核心使用方式是:你写一个 YAML 配置文件,描述哪些任务要跑、跑的顺序是什么、什么条件下触发,然后它帮你排队、执行、收集输出和上报结果。
听起来和 Jenkins、GitHub Actions、GitLab CI 没什么区别,但它的气质完全不同。Caveman 的整套设计都在做减法:没有插件市场、没有图形配置界面、没有一堆概念需要消化,你只需要理解 pipeline、task 和 trigger 这三个词,基本就能上手。
我自己的感受是:小项目或者中大型项目的某些特定环节,根本不需要一个重量级 CI 平台。你需要的是“代码推上去之后,自动跑一下测试;每天晚上自动打个包;失败了有人知道”,就这么点事。Caveman 恰好把这点事情做得很朴素很稳。
它适合谁?适合那些对服务器有掌控欲、愿意花二十分钟写一个 YAML、不喜欢在网页上点来点去的人。反过来,如果你需要在大量项目之间做复杂的可视化编排,或者需要企业级的审计、权限矩阵,那它不太适合你。
1.2 它和 Jenkins、GitHub Actions 的根本差异
要理解 Caveman 为什么值得关注,得先看清主流 CI 工具的复杂本质。Jenkins 本身强大,但强大带来的副作用是:你得维护一套 JVM 服务、隔三差五处理插件兼容性、写 Groovy 脚本、还要操心节点管理。GitHub Actions 倒是省心,但你的项目只要不在 GitHub 上,或者你希望构建产物和缓存完全掌握在自己手里,它就有天然的边界。
Caveman 选择了另一条路:把 CI 的调度核心做得足够轻,然后用 Shell 命令解决一切。它不发明新的 DSL,不搞标签系统,不搞分布式调度宇宙。构建环境就是你的服务器,任务就是你在终端里本来就会敲的那些命令。
| 对比维度 | Caveman | Jenkins | GitHub Actions |
|---|---|---|---|
| 部署成本 | Python 库 + 单个服务,分钟级 | JVM 环境 + 插件体系,小时级 | 零部署,但受平台约束 |
| 配置方式 | 一个 YAML 文件 | 界面 + Groovy + XML | YAML + 平台专用语法 |
| 任务粒度 | Shell/Python 命令 | 插件封装好的 Step | Action 和容器组合 |
| 可移植性 | 配置跟着项目走,换机器即跑 | 配置迁移成本较高 | 几乎绑死 GitHub |
| 对服务器的依赖 | 需要自备 | 需要自备 | 不需要,但数据在云端 |
| 适合场景 | 中小规模、自托管、极简需求 | 大规模、复杂权限 | 开源项目、GitHub 全家桶用户 |
这些差异背后其实反映了一个理念:CI 的本质就是“定时或触发地执行一些命令”。Caveman 坚决不偏离这个本质,也算是一种精神洁癖。我挺吃这一套的。
2. 核心配置与任务模型拆解
2.1 Pipeline、Task、Trigger 是怎么配合的
Caveman 的配置模型,我自己的理解是:一个服务里可以定义很多个 pipeline(流水线),每个 pipeline 下面挂着若干 task(任务),每个 task 可以做三件事——跑命令、声明自己依赖谁、设置独立的触发条件。
Pipeline 是一个相对完整的执行单元,比如“构建并测试”;Task 则是单元里的具体步骤,比如“安装依赖”“跑单元测试”“构建镜像”。任务之间通过依赖关系决定执行顺序。这里不需要像某些工具那样去画 DAG 图,你只需要写清楚needs(依赖谁),调度器自然会按顺序跑。
Trigger(触发器)是让我觉得这个工具很灵活的地方。你可以让整个 pipeline 被 Webhook 触发,也可以让某个 task 按 cron 定时执行,两者还互不冲突。这意味着同一个项目里,push 触发的“构建+测试”和每天晚上执行的“自动打包+清理缓存”可以写在同一个配置里,结构非常干净。
类比一下:Pipeline 像一条生产线,Task 是线上不同工位,Trigger 则是“什么时候开工”的指令。你不需要为了定时任务单独搭一台机器,也不需要为了 Webhook 在业务代码里写接口,全都在 YAML 里声明出来。
2.2 一个可以直接上手改的完整配置示例
我不太喜欢引用那种只有三行、毫无参考价值的“hello world”示例,下面这个配置是结合我自己项目的实际需求整理的。它覆盖了依赖安装、测试、失败的邮件通知、晚上的自动备份,一个文件搞定。
pipelines: main: title: "构建并测试主分支" triggers: on_push: branch: main tasks: - name: install cmd: "pip install -r requirements.txt -q" - name: test cmd: "pytest tests/" needs: [install] - name: report cmd: "python scripts/notify.py --status {{ pipeline.status }}" needs: [test] on_failure: true nightly: title: "每日备份与缓存清理" triggers: cron: "0 2 * * *" tasks: - name: backup cmd: "bash scripts/backup.sh" - name: cleanup cmd: "bash scripts/cleanup_cache.sh" needs: [backup]注意看几个关键点:on_push.branch把触发范围限定在主干分支,避免每个人推分支时都跑一遍;needs声明了任务间的顺序;on_failure: true表示即使前任务失败,也能发送报告。{{ pipeline.status }}是模板变量,caveman 会把它替换成实际执行状态,这是我后来在实践里发现的实用小技巧。
对于 Node.js 项目,思路完全一致,只是命令换成 npm。不要把 caveman 的配置理解成新的编排语言,它本质上就是把“你在服务器上手工会跑的命令”搬进 YAML,然后加触发条件和依赖关系。
2.3 命令执行背后的几个关键细节
关于每个 task 中命令到底怎么跑,这里有几个容易踩坑的细节值得单独说。
第一个细节是工作目录。Caveman 默认会在你的项目根目录执行命令,但如果你把配置放在config/caveman.yaml,项目根目录怎么定义就要留意了。建议在项目根目录放配置文件,或者使用绝对路径的脚本,否则新手很容易遇到“命令明明存在,但执行时找不到文件”的诡异问题。
第二个细节是环境变量。Caveman 会继承服务进程的环境变量,所以你在 systemd 或者 docker-compose 里定义的变量,在任务里是能直接读到的。但这同时也意味着,你写在配置里的命令不应该硬编码任何密钥。我的习惯是密钥全部通过环境变量注入,YAML 里只写变量名。
第三个细节是退出码。Caveman 判断任务成不成功,只看退出码是不是 0。如果你在命令里写了多个子命令,最好在关键步骤之间检查退出码,比如:
python scripts/prepare.py && pytest tests/这条命令中,如果 prepare.py 失败,&&后面的 pytest 根本不会执行,整条命令退出码也是非 0 的。这样就能很自然地传递失败状态。反过来,如果用了;连接命令,即使前面失败了后面还是会跑,最后的退出码取决于最后一条命令,很容易掩盖问题。
3. 实操:从零搭起一个能用的 caveman 服务
3.1 安装和启动全流程记录
整个安装过程比我想象中顺利很多。在一台 Ubuntu 22.04 服务器上,只需要确保 Python 3.10 以上可用,然后执行:
pip install caveman安装完成后,建议先写一个最简单的配置文件,确认服务能正常启动,再逐步加内容。启动服务有两种常见方式:前台运行方便看日志,后台运行适合长期使用。
# 前台运行,Ctrl+C 可退出 caveman serve --config .caveman.yaml # 后台运行,日志写入文件 nohup caveman serve --config .caveman.yaml > /var/log/caveman.log 2>&1 &正式部署我建议用 systemd 托管,这样开机自启和崩溃拉起都由系统负责。下面是一个最小可用的 service 文件:
[Unit] Description=Caveman CI service After=network.target [Service] User=deploy WorkingDirectory=/home/deploy/myapp EnvironmentFile=/home/deploy/.env ExecStart=/usr/local/bin/caveman serve --config /home/deploy/myapp/.caveman.yaml Restart=always RestartSec=5 [Install] WantedBy=multi-user.target这里有个细节容易被忽略:EnvironmentFile指定的文件里可以放各种环境变量,caveman 的任务进程会直接继承。这样你在 YAML 配置里就不会出现任何明文密钥,通知 Webhook 的 Token 也全都在环境文件里。
首次启动后,建议先检查两件事:一是日志是否正常输出,二是服务监听的端口是否能访问。默认端口如果被占用,可以通过参数调整,但我是直接用本机回环地址加反向代理,不额外暴露端口,这样更安全也更清爽。
3.2 接入 Git 仓库的 Webhook 自动触发
Caveman 的 Webhook 触发机制很简单:你在配置里声明on_push触发器,它会在服务里开一个 HTTP 端点,然后你在 Git 仓库后端配置一个 Webhook,把请求发到这个端点即可。
以 Gitea 为例,在仓库设置里添加 Webhook,URL 填http://你的服务器地址:端口/ci/trigger,Content Type 选 JSON,触发事件勾选 Push。保存后可以先手动测试一次,观察 caveman 日志里是否收到请求。
我遇到过的一个重要问题是触发安全。如果端点没有校验,别人知道你地址后可以伪造请求反复触发构建,既不安全也浪费资源。所以在生产环境必须打开 token 校验,caveman 通常支持在触发端点要求携带固定参数,配置大致如下:
triggers: on_push: branch: main token: ${CAVEMAN_WEBHOOK_TOKEN}这里的${CAVEMAN_WEBHOOK_TOKEN}会被解析成环境变量值。Git 仓库端 Webhook 配置里,添加一个请求头或参数把同样的 Token 带上,caveman 校验通过才会真正触发任务。
为了验证链路是否通畅,可以先用 curl 模拟一次:
curl -X POST http://127.0.0.1:5000/ci/trigger \ -H "Content-Type: application/json" \ -H "X-Caveman-Token: your-token" \ -d '{"ref": "refs/heads/main"}'如果日志里出现“trigger accepted”之类的信息,说明 Webhook 链路已经打通。这里有一个排错技巧:很多团队发现 Webhook 不触发,问题往往不在 caveman,而在 Git 平台默认不给localhost或内网 IP 发请求,需要在平台设置里允许内部或自定义网络地址。
3.3 把执行结果通知到人
自动化的最后一步是让正确的人在正确的时间知道结果。Caveman 本身把“执行任务”和“上报结果”都当成普通任务处理,所以你完全可以在一个 pipeline 的最后挂一个通知任务,用一段脚本把结果推到办公协作软件里。
我自己的做法是写一个可以复用的通知脚本,核心逻辑就是拼接文本,再通过 Webhook 发出去。它接收两个参数:任务名和状态,其他信息从环境变量读。
#!/usr/bin/env bash # scripts/notify.sh set -euo pipefail TASK_NAME="${1:-unknown}" TASK_STATUS="${2:-unknown}" PROJECT_NAME="${PROJECT_NAME:-my-project}" WEBHOOK_URL="${NOTIFY_WEBHOOK_URL:-}" if [ -z "${WEBHOOK_URL}" ]; then echo "[notify] no webhook url, skip" exit 0 fi curl -s -X POST "${WEBHOOK_URL}" \ -H "Content-Type: application/json" \ -d "{ \"msg_type\": \"text\", \"text\": { \"content\": \"[Caveman] ${PROJECT_NAME} ${TASK_NAME} ${TASK_STATUS}\" } }"在 YAML 里调用时,建议用on_failure和on_success这类钩子做区分。比如:
- name: notify cmd: "bash scripts/notify.sh test failed" on_failure: true needs: [test]这里的关键原则是:通知脚本本身不能影响构建结果。哪怕通知发送失败,也不应该让整个 pipeline 显示为失败。所以脚本里如果通知环节出错,要么用|| true,要么在脚本内部捕获异常并返回 0。我把这个教训写进这里,也是因为曾经踩过一次:通知接口超时,导致整个构建被标记为失败,而真正的测试其实是全部通过的。
3.4 跑定时任务时的常见习惯
定时任务是 Caveman 很常用的能力,比如每天凌晨备份数据库、每周清理构建缓存、定期跑一次完整的回归测试。定时配置写在 pipeline 的triggers.cron里,采用标准的 cron 表达式。
pipelines: weekly_cleanup: triggers: cron: "0 4 * * 1" tasks: - name: cleanup cmd: "bash scripts/cleanup_docker_cache.sh"关于 cron 我有两个建议:第一,尽量避开业务高峰时段,凌晨三四点通常是很好的选择;第二,任务执行时间如果可能超过间隔,需要自行维护一个状态文件,避免同一个任务还没跑完又启动另一轮。Caveman 似乎不会自动处理重叠执行,这是个需要注意的边界问题。
4. 常见问题与排查技巧实录
4.1 任务一直排队不执行
这个现象我遇到不止一次,最典型的场景是:服务正常启动,Webhook 也触发了,但任务状态一直是 pending,迟迟没有转成 running。
排查思路固定顺序:先看 worker 状态。Caveman 的服务进程里,调度器和执行器是配合工作的,执行器其实就是一组 worker。如果 worker 数量设置为 0,或者启动异常,任务就会永远排队。可以这样确认:
caveman status如果显示没有活跃 worker,基本可以确定问题方向。此时检查服务日志,看 worker 初始化时是否报错。比如某些环境缺少必要的执行依赖,或者工作目录没有写权限,都会导致 worker 起不来。
另外,如果任务本身设置了并发限制,例如同一时间只允许一个实例运行,那么上一个任务如果因为某些原因没有正常结束,后续任务就会一直排队。解决办法是手动清理旧的运行状态,或者调整并发参数。
4.2 构建日志为空但任务失败
另一个常见困惑是:任务明明失败了,可点开日志却没看到任何有效内容。这个问题大多出在命令输出没有被正确捕获上,而不是任务真的没有执行。
我遇到过两次这种情况。第一次是因为脚本里有一条命令调用了nohup把子进程放到后台,导致 caveman 认为主命令已经结束,但实际工作还在继续。第二次是脚本中使用了交互式命令,进程等待标准输入直到超时被系统杀掉,日志自然只有一行超时记录。
给个排查建议:在任何复杂脚本开头加一行echo "start at $(date)",在结尾加一行echo "end at $(date)",就能快速定位是没执行、没输出,还是卡住超时。这个习惯成本极低,但排查效率提升非常明显。
4.3 在容器环境中有权限问题
如果你希望任务跑在 Docker 容器里,比如在一个固定镜像中编译项目,那么权限问题是绕不开的。最常见的是 socket 权限错误:
Got permission denied while trying to connect to the Docker daemon socket常规解法是把运行 caveman 的用户加入 docker 用户组:
sudo usermod -aG docker deploy改完用户组以后必须重新登录会话才能生效。如果你通过 systemd 启动服务,在添加到 docker 组后要再执行sudo systemctl restart caveman,让服务进程重新获得新的权限组。
但这引出一个安全方面的思考:如果任务会执行任意代码,那么这些代码其实是以运行用户权限跑的。给用户 docker 权限,等同于给了最高系统权限。所以我后来更推荐在配置层面明确区分哪些任务需要容器环境,哪些任务直接本机执行,容器命令只放在独立 pipeline 中,并且严格限制可访问的仓库。
4.4 Webhook 常见三连坑
Webhook 不触发、重复触发、触发时机不对,这三个问题几乎每家都会踩到。
不触发:先确认端点和 token 是否正确,再确认 Git 平台是否有防火墙规则拦截内网或自定义端口请求。某些托管平台默认禁止 Webhook 发送到非标准端口,这时候需要在平台侧放行。
重复触发:很多 Git 平台在推送 tag、编辑 PR、同步 mirror 等不同场景下都会发不同事件。配置里应该按需声明事件类型,并通过分支名过滤,避免一个简单 push 变成四五次构建。
触发时机不对:比如计划在 merge request 通过后再构建,而不是每次 push 都构建。这类需求请在配置里使用对应的事件类型和判断条件,不要指望“回调之后过滤”。Caveman 的触发配置能完成大部分常见场景,但如果你需要的是一门极其精细的判断逻辑,直接在任务里写一段 Shell 判断往往更直接可控。
4.5 环境变量管理不当导致泄露
最后提醒一个很少人提但非常重要的问题:YAML 配置本身可能被项目成员查看,所以不要在里面写死 Token、密码、密钥。哪怕仓库是私有的,也要当它迟早会泄露来管理。
一个稳妥的习惯是:配置里只写${VAR_NAME},变量值统一放在运行服务的机器的环境文件里。配合 systemd 的EnvironmentFile使用,既方便在不同环境部署,也避免密钥进入版本历史。
如果你需要为不同项目提供不同环境变量,我建议不要共用一个环境文件,而是以项目名为前缀隔离,例如MYAPP_DB_PASSWORD、MYAPP_NOTIFY_TOKEN。这样一个脚本出问题不会影响其他项目,排查范围也小得多。
最后分享一点个人体会
回头看我为什么会坚持用 caveman 这类极简 CI 方案,而不是一直躺在 Jenkins 的舒适区里,本质原因其实是心智负担。重工具的每一个插件、每一条流水线配置、每一次版本升级,都在不断消耗你的注意力,而最终换来的能力在小团队里绝大部分根本用不上。
我在实际操作中的体会是:把 CI 做到最简,反而更能逼着团队把构建脚本本身写好。没有图形界面帮你隐藏过程,没有神秘动作帮你收拾烂摊子,一切都要靠 Shell 脚本和清晰的依赖关系兜底,这恰好是工程能力最能沉淀的地方。
如果你也想尝试,我建议先从一个小项目开始:一个仓库、一个 YAML、三个任务。把构建、测试、通知跑通,运转一两周以后再慢慢加定时任务和容器步骤。等有一天你会发现,你已经完全不关心 CI 平台本身用的是什么,因为配置跟着仓库走、命令就是终端命令,换哪台服务器都能立刻跑起来,这种感觉确实比想象中的还要舒服。