caveman:一个基于SSH的极简远程任务执行与回滚工具 “caveman”是我电脑里躺着的一个命令全名也懒得起就叫 caveman翻成中文就是“穴居人”。我拿它干了快两年活儿从个人网站到几台线上业务机器都在用。说白了它就是一个极简的远程任务执行工具一条命令通过 SSH 把你本地的脚本原样丢到远程机器上跑支持幂等、支持回滚除此之外什么依赖都没有。我起这个名字就是想提醒自己能用一块石头解决的问题别总想着开一台挖掘机过去。这事儿得从头说。有段时间我负责几个小项目的部署和运维机器三五台每次改动无非是更新配置、重启服务、同步静态文件。按理说这活儿用 ssh 加 scp 半天就能干完但我当时被“上容器”的大潮裹挟给这套小得可怜的基础设施搭了 Docker、弄了 CI、写了一堆 compose 文件。结果呢为了跑一个定时备份脚本我得先保证所有机器上的 Docker 版本一致再去处理 registry 权限最后还得维护一整套镜像构建流程。一个十几行 bash 就能搞定的任务被我生生弄成了一个周更项目。折腾了大半年我终于承认不是每个场景都需要一套完整基建很多日常任务需要的只是“能重复、可回滚地执行一组命令”而已。caveman 就是在这个反思底下长出来的。它的核心价值在于把复杂度从框架挪回脚本本身。你写的还是 bash、python 或任何你本来就熟悉的脚本caveman 只负责三件事把脚本送到指定机器、以可控方式执行、在出事时按快照还原。没有 agent、没有服务端、不需要任何语言运行时。适合谁呢适合那些常年跟三五台到三五十台 Linux 机器打交道的人适合被 Ansible 的语法和 Kubernetes 的概念折腾得头大的同学也适合刚入门想弄明白“部署”这件事到底在做什么的运维新手。我后面会拿一个真实场景完整带你走一遍从安装到回滚的全过程。1. 整体设计思路为什么是“穴居人”而不是“基建狂魔”1.1 解决问题的方式要匹配问题的规模先聊聊设计初衷因为这才是这个工具最有意思的地方。我观察到身边很多工程团队有个通病手里的锤子太大看什么都是钉子。明明只有三四台机器、改动频率一周不到一次非要上配置中心、上容器编排结果维护成本比业务系统本身还高。caveman 的立足点恰恰反着来它假设你的基础设施规模比较小、任务复杂度可以靠人的判断搞定所以你需要的不是一套抽象模型而是一组足够稳、足够透明的执行原语。具体场景是这样的。我们有个应用需要定时从第三方拉数据再把处理结果同步到三台 Web 服务器上。这个任务的特点是机器数量变化不大、逻辑相对稳定、但对执行结果要求明确。用 Ansible 也不是不行但为了三台机器维护一份 YAML 清单、学习它那套 module 和 inventory 语法我觉得成本收益不划算。更关键的是团队里不是每个人都熟悉 Ansible但每个人都看得懂 bash 脚本。caveman 把决策权还给了最接近问题的人你想在远程干什么就把那段逻辑写成一个脚本它负责让这段逻辑安全落地。这个选择背后其实是一个特别朴素的判断——工具链的复杂度应该跟被管理对象的复杂度成比例。就像你去野营带一把瑞士军刀就够了非要把整套木工坊搬进后备箱除了累没别的用。caveman 的设计哲学就是“瑞士军刀”功能边界很清楚解决远程分发和回滚不越界去管服务发现、负载均衡、日志采集那些事交给真正专业的系统去做。1.2 技术选型为什么是 SSH、rsync 和裸脚本确定“要做极简工具”之后技术栈几乎是被逼出来的。远程执行命令最可靠、最通用的就是 SSH它在任何 Linux 发行版上都是标配同步文件rsync 有增量传输、断点续传、权限保留这些特性比 scp 稳妥得多而执行逻辑用什么脚本都可以因为 caveman 只把文件传输过去然后调用解释器bash、python、ruby 都行。这三样东西组合起来刚好覆盖远程运维的绝大部分场景。你可能会问为什么不干脆用 Ansible其实 Ansible 的设计思路也是 agentless、走 SSH这一点我非常认同。但它的抽象层太厚了。YAML 里写copy、template、service相当于把 shell 命令重新发明了一遍。一旦遇到官方模块没有覆盖的场景你还是得落回到command模块去跑原生命令这一下子就失去了抽象的意义。而且 Ansible 的幂等性依赖模块各自的实现很多时候你以为它幂等实际跑起来并不是那么回事。caveman 不承诺自动幂等它只提供一个机制脚本里可以声明自己的校验逻辑执行前会先做“计划”检查。这样一来幂等不是框架强加给你的而是你明确写出来的。另一个关键取舍是不做服务端。很多自动化工具需要在受管机器上装 agent通过中心服务器下发任务。这种架构适合成千上万台机器的场景因为中心化带来的是统一视图和审计能力。但对小规模场景agent 本身就是负担你得负责它的升级、认证、容错。caveman 选择“用完即走”执行完不会在远程留任何常驻进程。这带来一个额外好处受管机器上没有任何额外的安全暴露面入侵风险小很多。下面的表是我当时综合对比后得出的结论方案依赖学习成本幂等控制回滚能力适用规模纯 SSH/SCP无低手动手动1~10 台caveman仅 SSH/rsync低脚本内置内置快照1~50 台AnsiblePython/依赖库中模块内建需自行设计10~1000 台Docker/K8s大量高平台内建需额外配置100 台以上我并不是说 Ansible 或容器不好它们在大规模、复杂拓扑下确实有效率优势。但如果你想清楚自己的规模再选型就会发现很多“先进”工具是被想象中的规模逼出来的而不是被实际需求逼出来的。2. 核心细节与关键实现caveman 到底是怎么工作的2.1 目录骨架和任务定义caveman 本身是单个 bash 脚本安装就是把脚本放到PATH下面。真正的内容组织在项目目录里通常叫.caveman/里面分几个部分.caveman/ ├── targets # 目标主机配置每行一个支持 host alias ├── plan/ # 执行前的检查脚本按顺序执行 ├── tasks/ # 实际要跑的执行脚本按顺序执行 ├── files/ # 需要同步到远程的静态文件 └── backups/ # 回滚时生成的快照目录自动生成targets 文件的格式很简单每行一个远程目标用userhost的形式后面可以跟端口和标签web1: root192.168.1.11:22 web2: root192.168.1.12:22之所以用这么朴素的形式是因为我实在受够了复杂的 inventory 格式。机器多了以后你可以生成这个文件机器少时手写也只要几秒钟。关键信息只有“用哪个用户访问哪台机器”其他一切都可以靠约定。tasks 和 plan 都只是可执行脚本的集合。执行之前caveman 会把整个.caveman/目录的tasks/和plan/部分 rsync 到远程机器的临时目录里然后在每一台目标机器上依次运行。plan 和 tasks 的关键区别在于plan 的输出被当作“预检结论”如果任何 plan 脚本返回非零退出码整个任务会终止不会动真正的状态。2.2 三条核心命令plan、apply、rollback日常使用就三条命令记忆成本几乎为零。caveman plan是干跑模式。它把 tasks 脚本传到远程但不执行而是先跑 plan 目录里所有的检查脚本。比如你要更新 Nginx 配置plan 里可以写一个“检查当前配置文件是否跟将要下发的一致”的脚本一致就直接跳过不一致就提示这次变更会影响哪些机器。这个设计借鉴了 Terraform 的计划概念但实现起来更简单粗暴——就是普通 shell 的退出码。caveman apply是真正执行变更。它在执行任务之前会自动把远程涉及的目录压成一个带时间戳的 tar 包存到远程机器的~/caveman_backups/下再把文件名记录到本地的一个仓库文件里。这样一来你不需要手动指定要备份什么它默认对所有将要变更内容所在的目录做快照。这种“先备份后动手”的思路帮我躲过好几次灾。caveman rollback则是读取最近一次 apply 的快照清单把备份包推回远程并解压覆盖。由于备份是按目录做的只要你 tasks 里没有主动删除备份目录回滚就能恢复到执行前那一刻的状态。这里有个细节回滚不会自动重启服务因为我不知道某个目录覆盖后是否需要 reload 进程所以只做文件层的还原重启动作还是交给你自己来省得我猜错。每一条命令都会在本地输出一个结构化的日志目标机器、执行脚本、退出码、耗时。我不做集中式的日志收集因为这些记录存在执行者的机器上就够了真出事需要复盘时翻自己终端比去查某个中心服务更直接。2.3 安全与权限处理的几个原则安全这块我吃过亏所以单独列一节。第一个原则是永远不要在配置里写密码。caveman 只依赖 SSH keytargets 文件里写的用户必须已经在远程配置好密钥登录。如果你的环境不允许 root 直接 SSH那就写一个普通用户然后在 tasks 里通过 sudo 执行特权命令。注意caveman 不会帮你保管 sudo 密码它假设你已经配好了NOPASSWD的 sudoers 规则或者你要执行的任务根本不需要提权。第二个原则是临时目录的清理。脚本传输之后会在远程的/tmp的随机目录下执行执行完成之后立即删除整个目录。这样即便某台机器被入侵历史脚本也不会长期留在上面。有人可能觉得这增加了排查难度但我的观点恰恰相反需要排查时本地留存的项目目录和备份清单才是完整证据链远程残留的脚本副本反而容易造成混淆。第三个原则是脚本的内容要“诚实”。caveman 不做沙箱也不拦截危险命令你在 tasks 里写了rm -rf /它就会真的执行。这类工具本来就不该给粗心的人用它默认使用者的判断力在线。但作为一个自我保护机制它会在 apply 执行前把每个任务文件打印出来让你确认自己到底要跑什么。我见过不少事故都是“昨天写的迁移脚本今天忘了内容”所以这种强制展示还是很有价值的。3. 实操演示用 caveman 给两台 Web 服务器更新 Nginx 配置3.1 场景描述与准备工作前面讲了很多设计上的考虑现在进入实际操练环节。我这里假设一个最有代表性的场景你有两台 Nginx 服务器需要更新站点配置并重载服务。这个任务非常小小到不值得劳动容器平台但又不能全手动——因为要在两台机器上执行一样的变更并保证一致性。准备工作分三步走。第一步确保每台机器上都有你需要的用户和 SSH 公钥能免密登录。第二步本地装好 rsync这个在主流发行版上基本都有。第三步把 caveman 脚本放到/usr/local/bin/下加上执行权限。安装完顺手验证一下版本$ caveman version caveman 0.4.1我们这个项目的目录就叫site-config在本地建好基础结构。为了让计划看得更清楚我特意设计了一个稍微讲究一点的配置一台机器是主节点一台是备用节点两边配置略有差异但用一个环境变量去区分而不是维护两套完全不同的文件。3.2 编写 targets、plan 和 tasks先定义目标机器。我习惯在 target 行里写注释说明这台机器是干嘛的方便后来的人理解# 主 Nginx 节点 web1: deployer192.168.1.11:22 # 备用 Nginx 节点 web2: deployer192.168.1.12:22然后是 plan 检查。这个检查干的事是看一下当前线上配置和本地将要下发的配置是不是已经一致如果一致这次 apply 就没必要重新覆盖省去无意义的 reload。判断方式是做一次 sha256 校验。#!/usr/bin/env bash # plan/check_nginx_conf.sh EXPECTED_HASH$(sha256sum files/nginx.conf | awk {print $1}) REMOTE_HASH$(ssh ${CAVEMAN_TARGET} sha256sum /etc/nginx/nginx.conf | awk {print $1}) if [ $EXPECTED_HASH $REMOTE_HASH ]; then echo nginx.conf 一致跳过更新 exit 42 fi exit 0我在这里做了一点“不走寻常路”的设计用退出码 42 表示“无需变更”。caveman 遇到 42 会把这台机器的后续 tasks 全部标记为 skipped不再执行。为什么不用 0 呢因为 0 会被任务调度系统当作“检查通过”这样我们还会继续往下跑 apply还要再确认一遍比较啰嗦。42 这个奇特的数字在脚本里不会意外出现所以拿来做语义标记很安全。接下来说明 tasks。更新 Nginx 配置的脚本包含三步备份旧配置其实 apply 会自动备份但我额外留一手把配置目录完整复制一份、下发新文件、测试语法并重载#!/usr/bin/env bash # tasks/update_nginx.sh set -euo pipefail # 1. 额外备份配置目录 sudo cp -r /etc/nginx /etc/nginx.bak.$(date %s) # 2. 同步新的主配置文件 sudo tee /etc/nginx/nginx.conf /dev/null files/nginx.conf # 3. 检查 Nginx 配置文件语法 sudo nginx -t # 4. 重新加载配置 sudo systemctl reload nginx echo Nginx 配置已更新并完成重载这里用set -euo pipefail属于我的个人习惯任何一步出错脚本立即退出不会带着半更新状态往下走。nginx -t是一个关键的闸门如果语法有问题根本走不到 reload 那一步。等下你会看到这个闸门在后面演示回滚时救了场。3.3 执行 plan 和 apply 的现场记录先跑干跑模式$ caveman plan [web1] plan/check_nginx_conf.sh 通过 [web1] 结论nginx.conf 不一致需要更新 [web2] plan/check_nginx_conf.sh 通过 [web2] 结论nginx.conf 不一致需要更新看起来干净利落。现在正式执行$ caveman apply [web1] 开始备份 /etc/nginx - ~/caveman_backups/nginx.conf.pre.20240521-153012.tar.gz [web1] tasks/update_nginx.sh 执行成功 (3.2s) [web2] 开始备份 /etc/nginx - ~/caveman_backups/nginx.conf.pre.20240521-153105.tar.gz [web2] tasks/update_nginx.sh 执行成功 (3.1s)注意 apply 的输出有两层信息一层是“自动备份已完成”另一层是“任务脚本成功”。自动备份的行为是这样的caveman 检测到 tasks 里有脚本会写/etc/nginx这个目录它通过静态分析脚本中出现的路径来实现虽然粗糙但对我自己的脚本足够用于是先把整个目录压成 tar 包。这个方法不完美但多数情况下足够可靠。你要是发现自己写的脚本里有动态拼接路径的情况静态分析可能识别不到那就用我们前面讲的额外备份兜底。验证环节我不能省略。线上服务改完必须确认真的生效$ ssh deployer192.168.1.11 systemctl status nginx --no-pager | head -5; curl -sI http://127.0.0.1 | head -1看到active (running)和HTTP/1.1 200 OK我才会长舒一口气。这类验证我建议写进自己的 checklist而不是依赖工具去自动做因为业务健康的标准千差万别只有你最清楚。3.4 回滚把“手滑”恢复原状演示一下 rollback这也是我觉得这个工具最有价值的功能之一。假设刚才新下发的 Nginx 配置在低流量时段没问题但高峰时段突然发现连接数爆表怀疑是配置里的worker_processes和keepalive参数调坏了。这时候回滚命令很简单$ caveman rollback [web1] 准备使用备份 nginx.conf.pre.20240521-153012.tar.gz 进行恢复 [web1] tar 解压完成 [web1] 提示请检查服务状态 [web2] 准备使用备份 nginx.conf.pre.20240521-153105.tar.gz 进行恢复 [web2] tar 解压完成 [web2] 提示请检查服务状态回滚后自己 reload 一下服务确认恢复了旧配置。整个过程大概一分钟而且由于备份包就是应用执行前生成的你不需要去 git 历史里翻旧版本也不需要重新拉代码构建。这也是我坚持“先备份后执行”的原因与其事后想办法不如事前留好退路。4. 常见问题与排查技巧实录4.1 高频问题速查表使用这么长时间我把踩过的坑整理成了一张速查表先放出来症状可能原因解决办法Connection timed out目标机器防火墙或 SSH 服务未启动先手动 ssh 测试确认基本连通性Permission denied (publickey)目标用户没有配置公钥检查~/.ssh/authorized_keys和本地私钥路径执行脚本时报command not found远程机器的 PATH 跟本地不一致脚本里写明解释器绝对路径如#!/usr/bin/env bash变量值多出\r本地文件是 CRLF 换行在项目根目录加.gitattributes或直接sed -i s/\r$//部分机器成功部分失败机器环境差异比如 Nginx 路径不同在 plan 阶段就检测环境差异尽早暴露备份文件没有生成静态分析没识别到脚本中的目标路径tasks 里尽早把路径用变量的形式声明便于分析这张表看着简单每一条背后都有真实教训。比如 CRLF 那个坑是我有一次在 Windows 上编辑了backup.sh结果到 Linux 机器上所有命令都莫名奇妙带了个\r报错信息又长又乱。排查到深夜才发现是换行符问题。所以我在项目入口加了一个检查脚本发现远程文件有 CRLF 就直接拒绝执行。4.2 一次真实的“半成功”事故复盘具体讲一个让我印象深刻的案例。某次给一台业务机器加定时任务tasks 脚本里写了一个crontab -l | grep -v xxx | crontab -意图是移除一个旧任务。执行时apply显示成功但我第二天发现定时任务还在。排查后发现远程机器的crontab命令路径是/usr/bin/crontab而我脚本里通过 SSH 执行时的 PATH 被压缩了指向了一个不存在的地方命令实际上没有执行但因为脚本没做结果判断退出了 0。这个问题其实不是 caveman 的锅而是脚本写得不够健壮。后来我在自己的脚本规范里加了一条铁律所有关键命令行都必须显式判断退出码或者直接把set -e放到脚本顶部。刚才演示的update_nginx.sh就是这么写的。caveman 本身只在任务脚本退出非零时报告失败它不会替每个命令检查返回结果——这恰好又回到了它的极简原则框架不替你猜意图逻辑要写在脚本里。另一个经验是输出问题。caveman 在远程执行时会把 stdout 和 stderr 分开处理避免把正常的日志和报错混在一起。有些时候脚本里某个命令会往 stderr 写一堆警告如果没被正确处理你会在本地看到大段的红色输出产生“是不是挂了”的错觉。建议在脚本头部统一加21或者让重要信息走 stdout排查起来会清爽很多。4.3 三个让我省下大把时间的技巧最后分享三个小技巧都是常规文档里不会写的内容。技巧一利用 SSH 的连接复用加速批量执行。caveman 对每一台机器的多个脚本都复用同一个 SSH 连接但跨机器的连接还是要重新握手。为了加速我会在~/.ssh/config里给常用机器设置 ControlMasterHost 192.168.1.* ControlMaster auto ControlPath ~/.ssh/controlmasters/%r%h:%p ControlPersist 10m这个配置的效果是第一次连接建立后后续连接直接复用整体执行时间能缩短一半以上。尤其是当你给几十台机器跑同一个任务时这个优化是质的差别。技巧二在 tasks 里显式声明锁。如果你担心同一时间两个同事对同一台机器执行 apply可以在 tasks 开头放一个简单的锁逻辑LOCK_FILE/tmp/caveman_$(basename $0).lock exec 9$LOCK_FILE flock -n 9 || { echo 已有任务在运行退出; exit 1; }flock 是 util-linux 自带的工具绝大多数 Linux 都支持。这行逻辑不花什么成本但能避免“两个人同时改配置互相覆盖”的乌龙。技巧三把“决算”也写进 plan。每次跑任务之前我会让 plan 脚本顺带检查磁盘剩余空间。因为很多事故的根因是磁盘被写满尤其是日志类服务。plan 脚本里加上一句SPACE$(df / | awk NR2 {print $5} | tr -d %) if [ $SPACE -gt 90 ]; then echo 磁盘使用率超过 90%拒绝执行 exit 1 fi这种检查看似简单但其实很多成熟的编排工具都没有默认提供因为平台不知道该看哪个分区的用量。而你自己最清楚哪些目录是关键路径所以这类检查本来就是写在脚本里的活不该重新引入一个工具去完成。我个人在这些实践中最大的体会是一个好的工具不会替你思考但它能让你思考的结果被可靠地执行。caveman 就是这样它不预测我所有可能犯的错但它在关键位置预留了“先检查、再行动、可回退”的骨架。我每次写完一套 tasks都会多看两遍 plan 和备份逻辑因为我知道只要这两块扎实apply 出任何意外都有人在后面兜底。这套流程你可能不会一字不差地照搬但这种“极简优先逻辑下放到脚本”的思路我觉得值得带进每一个跟服务器打交道的日子里。