前阵子我把这些年攒下来的各种零碎脚本彻底翻新了一遍,收敛成了一个小项目,代号就叫做 caveman。名字的灵感来自“穴居人”这个梗——不搞花活,不堆框架,手里有什么就用什么,以最快的方式把问题解决掉,才是硬道理。caveman 不是一个大型平台,也不是什么新概念的工具,它就是一个只依赖标准库的极简任务执行器,专门用来处理那些“每天都在重复、但又不值得上重型自动化系统”的杂活。
做这件事的起因很实际:我需要给十几台机器统一加定时备份、过期日志清理、数据库导出和简单的健康检查。正经方案当然有,比如 Ansible、SaltStack、或者随便一个带 Web 界面的调度平台,但这些工具加起来的体积、概念和运维成本,对这点活儿来说完全是杀鸡用牛刀。更别提有些机器连装 Python 包都受限制。所以我就想:能不能做一个只有几百行、拷过去就能跑、用 shell 命令就能表达任务的小工具?caveman 就这么来了。
这篇文章我会把我设计和落地 caveman 的整个思考过程、核心实现、完整实操步骤以及踩过的坑都写出来。内容偏工程实践,适合自己维护服务器、想把手动操作变成自动化命令的个人开发者或运维朋友参考,哪怕你之前没写过什么复杂系统,跟着走一遍也能在自己的环境里跑起来。
1. 为什么我要折腾一个叫 caveman 的项目
1.1 从“手动运维”到“自动化焦虑”
先说说这个项目诞生的背景。很长一段时间里,我维护服务器的方式非常原始:备份就是一条 tar 加 scp,清日志就是一条 find 加 rm,数据库导出就是一条 mysqldump,然后把这些命令一条条贴到 cron 里。刚开始机器少,这招完全够用。但机器一多,问题就暴露了:同样的命令在 A 机器和 B 机器上行为不一样,C 机器忘了加排除目录,D 机器的 cron 环境变量没配对,目录空间被备份文件撑爆……每次都要 SSH 上去手动查,特别消耗精力。
于是我想走“正规化”路线,把 Ansible 学起来。结果发现事情变得更复杂:需要在控制机维护 inventory,需要学 YAML 的循环、变量、模板语法,需要理解 handler、role、playbook 的加载顺序,还要在目标机器上处理 Python 版本兼容问题。我花了两三天把整套框架搭起来,最后发现真正跑通的自动化任务,其实还是那几条 shell 命令。Ansible 的优势是配置管理和多机并发分发,但对我来说,大部分需求只是“在固定的时间、在固定的机器上、按顺序执行几条固定命令”。
这种感觉就像你想在墙上挂一幅画,结果先去学了电钻、水平仪和膨胀螺丝的各种型号,最后发现其实一把锤子和一颗钢钉就够了。很多个人场景和中小团队场景,根本不需要一个完整的自动化运维体系,需要的是一个“能把命令可靠地执行完,并且出了问题能快速知道”的小壳子。
1.2 caveman 的核心原则:零依赖、任务即脚本、配置即文档
做这个项目之前,我给自己定了三条死规矩,后来证明这三条规矩救了我很多次。
第一条,零依赖。caveman 只用 Python 标准库,目标机器上只要有一个能跑的 Python 3.6 以上解释器就行。不需要 pip install,不需要虚拟环境,不需要编译。这样带来的好处太明显了:拷一个文件过去就能用,完全不用担心目标机器上缺什么库、版本冲突、离线环境装不了包这类问题。有些机器甚至连外网都不通,零依赖意味着这些机器也能被管起来。
第二条,任务即脚本。caveman 不搞那种“用抽象语法树描述你想要的最终状态”的玩法,每个任务本质上就是一段 shell 命令。为什么要这样设计?因为对于备份、清理、导出这类操作,shell 命令本身就是最成熟、最直接的表达方式。tar 怎么打、find 怎么删、mysqldump 怎么导出,这些逻辑已经完全被前人验证过无数次了,我没有任何理由去重新发明一种 DSL 把它们包起来。我还见过一些工具非要把文件复制抽象成copy: src=xxx dest=xxx,结果遇到特殊权限、软链接、增量同步的时候,表达能力反而不如一条 rsync 命令。直接写命令,心智负担为零。
第三条,配置即文档。caveman 的任务配置全部放在 ini 格式的文件里,任务的执行步骤、执行时间、重试次数、日志位置全部明文写在配置里。任何人打开配置文件,三分钟就能看懂这台机器上到底定时跑着什么任务。我一直认为,最好的文档就是一份能直接运行的配置。这也符合“穴居人”的哲学:不要抽象,不要魔法,一切都是能一眼看穿的。
1.3 什么场景适合用 caveman,什么场景不适合
这工具不是万能的,我用了一段时间后总结了一张场景对照表,只有找对了工具的适用范围,才不会用着别扭。
| 场景 | 推荐程度 | 原因 |
|---|---|---|
| 定时备份文件目录 | 强烈推荐 | 一条 tar/rsync 命令就能解决,caveman 负责按时执行和日志记录 |
| 定时清理过期日志与临时文件 | 强烈推荐 | find + delete 是标准做法,加个 keep 参数就能控制保留天数 |
| 数据库定时导出 | 强烈推荐 | 一条 mysqldump 命令,caveman 负责错误重试与结果通知 |
| 多机批量配置管理 | 不推荐 | 需要幂等、变更管理、模板渲染,应该用专业配置管理工具 |
| 有 Web UI 和权限审批需求的自动化平台 | 不推荐 | caveman 故意砍掉了这些功能,复杂场景请用成熟平台 |
| 临时跑一段一次性命令 | 看情况 | 直接命令行执行更快,不需要写配置 |
说白了,caveman 的目标区间就是“一个人或者一个小团队,管着几台到几十台机器,每天要跑的无非是那几类固定任务”。再往上走,服务器分组、灰度发布、配置漂移检测这些需求一出现,你就该换工具了,别硬撑。我自己就曾经试图往里面加“任务依赖 DAG”,加了几天就果断删了,因为真正需要这种复杂性的场景,说明你已经撑破了这个小工具的边界。
2. caveman 的核心设计与实现思路
2.1 整体结构:五个模块各干各的活
caveman 虽然小,但结构上我还是分了五个清晰的模块,这样后续想加功能也不会把代码搅成一团浆糊。整体上它是一个命令行程序,通过caveman run 任务名或者caveman run-all来触发。
- 参数解析模块:负责读命令行参数,比如指定配置文件、指定要运行的任务、指定日志级别。
- 配置解析模块:读入 ini 格式的配置,把任务名、命令、参数结构化。
- 任务执行模块:核心中的核心,负责执行每条命令、掐超时、做重试、收集返回码。
- 日志记录模块:统一写日志文件、打印控制台输出、格式化时间戳。
- 锁与状态模块:防止同一个任务被重复执行,记录上次运行的状态。
一个标准的目录结构大概是这样的:
caveman/ ├── caveman.py # 主程序,所有模块都放这一个文件里 ├── tasks.ini # 任务配置,默认读取这个文件 ├── logs/ # 日志目录,自动创建 │ ├── backup-2025-01-15.log │ └── clean-2025-01-15.log └── states/ # 状态和锁文件目录 └── backup.lock刻意把代码全部放在一个文件里,是有原因的。部署的时候只需要拷贝一个 caveman.py,配合一个 tasks.ini,没有依赖也没有魔法。对于小工具来说,文件越少越不容易出错。很久以后等你真想扩展了,再考虑拆分也不迟。
2.2 任务描述:一条命令加一堆参数
在任务配置这一层,我选择用最朴素的 ini 格式。理由很简单:Python 标准库configparser直接就能解析,绝大部分人看到[backup]、command=xxx马上就懂了。下面是 tasks.ini 的一个真实片段:
[backup] description = 备份网站目录到本地归档 command = tar czf /backup/www-{date}.tar.gz /data/www && find /backup -name "www-*.tar.gz" -mtime +7 -delete schedule = 0 2 * * * timeout = 600 retries = 2 notify = mail我解释一下这个配置里几个字段的设计意图:
command就是你要执行的 shell 命令。注意{date}这个东西,它是 caveman 内置的变量替换,执行前会替换成当天的日期字符串,这样备份文件名永远带日期。schedule是 crontab 风格的时间表达式,caveman 自己不做调度,它的作用是生成一行 crontab 注释提示你该写进系统的 crontab,同时也作为文档保留。timeout是单次命令运行的超时秒数。这个特别重要,没有超时的任务在出问题时可能会挂一整天。retries是失败后的重试次数。我建议只在命令可能因为瞬时原因失败时设置重试,比如网络抖动、锁等待,不要盲目重试那些必然失败的逻辑。notify指定失败后通知方式,caveman 支持调用一个通知脚本来发邮件或者推送到自定义 webhook。
2.3 执行器核心逻辑:超时、重试和退出码
整个工具最核心的部分就是执行器,我在实现时做了几个关键设计。
第一个是进程级别的超时控制。直接用 Python 的subprocess.Popen起一个子进程跑 shell 命令,主进程用一个简单的循环等它结束,同时不停检查timeout到了没有。发现超时就kill掉子进程,并且返回一个专门的错误码。这里还有一个细节:不能只杀进程树的根,要把整个进程组都杀掉。因为 shell 命令经常会派生子进程,比如管道后面的命令、后台任务,只杀父进程容易留下孤儿进程继续跑。
第二个是重试策略。重试前记录失败原因、等待时间,并且最多重试retries次。每次重试之间我加了一个固定间隔,默认 5 秒。这个间隔不能太短,不然某些 IO 拥挤的场景起不到任何作用;也不能太长,否则任务卡住时整体耗时翻倍。5 秒是我测下来在通勤网络和各种存储延迟下比较平衡的值。重试完成后如果还是失败,就把任务标记为 failed,写进状态文件。
第三个是退出码的约定。我规定:0 代表成功,1 代表命令本身失败,2 代表配置解析错误,3 代表并发冲突(锁被占用),4 代表超时被杀。这个约定让我在写 crontab 和监控脚本时能精确区分“是谁的问题”。
伪代码大致是这样:
def run_command(cmd, timeout=300, retries=0): shell = "/bin/bash" for attempt in range(retries + 1): try: proc = subprocess.Popen(cmd, shell=True, executable=shell, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) try: out, err = proc.communicate(timeout=timeout) if proc.returncode == 0: return 0, out, err else: record_failure(f"attempt {attempt+1} failed, rc={proc.returncode}") except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) proc.wait() return 4, b"", b"timeout after {timeout}s" except Exception as e: record_failure(f"unexpected error: {e}") time.sleep(5) return 1, b"", err你可能会问,为什么不直接用subprocess.run(timeout=xxx),一行就搞定了?因为subprocess.run的超时后虽然也会杀掉进程,但它不保证杀掉整个进程组。我在实际使用中遇到过一次 tar 命令超时,然后把进程树的父进程杀了,结果它的子进程、还有管道后面的 gzip 进程还在继续跑,后续任务就出现了数据冲突。改用进程组 +start_new_session=True之后,一次性全部清掉,干净利落。这就是“看起来一步能到位”和“生产环境可靠”的区别。
2.4 锁与状态:不让两个 cron 任务互相掐架
caveman 还需要处理并发问题。crontab 的最小粒度是分钟,但一个备份任务很可能超过一分钟。如果上次任务还没跑完,下次任务又启动了,两个 tar 同时写同一个目录,轻则浪费 IO,重则把备份文件写坏。
解决思路是用一个锁文件来完成互斥。具体来说,每个任务在启动前先尝试独占性地打开states/任务名.lock文件,如果打开失败,说明这个任务已经在运行了,直接以退出码 3 结束;如果打开成功,就在文件里写入当前进程的 PID,然后正常执行。执行完毕以后释放锁。
import fcntl lock_file = open(f"states/{task_name}.lock", "w") try: fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) except BlockingIOError: return 3 # 已有实例在运行这里用fcntl.flock而不是自己满世界找 PID 文件,就是因为 flock 是由操作系统保证原子性的,不会出现“开着开着进程死了锁却忘释放”的问题。进程一退出,内核自动释放锁。即便机器突然断电重启,也不会留下脏锁文件。这个小设计是 caveman 稳定性的基石之一。
日志方面,caveman 把每个任务的输出同时写到一个按日期滚动的日志文件和控制台,日志行统一带时间戳和任务名。出了任何问题,先看日志、再看锁状态,基本上就能定位大部分故障了。
3. 从零到一:caveman 实操全程
3.1 安装部署:拷过去就能用
caveman 的安装可以说是所有工具里最简单的了,因为它压根没有传统意义上的安装过程。你只需要把 caveman.py 复制到目标机器上,加上执行权限,再做一个软链到/usr/local/bin/caveman。
scp caveman.py user@server:/usr/local/bin/caveman ssh user@server 'chmod +x /usr/local/bin/caveman && ln -sf /usr/local/bin/caveman /usr/local/bin/caveman' caveman --version如果目标机器上没有 Python,那就需要先解决解释器问题。绝大多数 Linux 发行版都自带 Python 3,实在没有的用系统包管理器装一下。我建议统一兼容 Python 3.6 到 3.12 的所有版本,代码里不碰任何新语法和实验性特性,就是因为想让它跑在任何旧机器上都毫无压力。
从这个过程你也可以看出 caveman 的设计取向:它不是一个需要被“安装”和“注册”的系统,而更像一个“随身携带的瑞士军刀”。新服务器加入管理,三分钟就搞定,不需要往里面塞 agent,不需要登记主机名。
3.2 第一个任务:让网站备份自动化
部署好之后,先在目标机器上建一个 tasks.ini。第一个要做的任务,往往是给自己写个稳妥的备份方案。
[backup-www] description = 备份 /data/www 目录,保留最近 7 天 command = mkdir -p /backup && tar czf /backup/www-{date}.tar.gz /data/www && find /backup -name "www-*.tar.gz" -mtime +7 -delete timeout = 600 retries = 0然后运行:
caveman run backup-www第一次跑的时候,有一个非常容易踩的坑:命令里面如果出现花括号,configparser会把它当成格式串的占位符,导致解析报错。所以我把{date}保留给 caveman 做变量替换,真正要在命令里用的花括号就得写成双花括号{{}}。这一点我在文档里标红了好几遍,因为我自己就在这里吃过亏。
跑完之后检查一下日志:
caveman log backup-www日志里会出现类似这样的内容:
2025-01-15 02:00:00 [backup-www] start execution, pid=12345 2025-01-15 02:00:04 [backup-www] tar invoked 2025-01-15 02:00:40 [backup-www] command success, rc=0, elapsed=40s看到 rc=0,这个备份任务就算成了。接下来把它写进 crontab,每天凌晨 2 点执行。
0 2 * * * /usr/local/bin/caveman run backup-www >> /var/log/caveman-cron.log 2>&1注意 crontab 里的环境变量非常精简,很多机器的默认 PATH 里并没有/usr/local/bin,所以在 crontab 里我强烈建议写绝对路径。另外,如果命令里用到了程序名,也要确保它们的路径能被找得到,必要时在 tasks.ini 的[default]节设置一个path = /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin,caveman 会在执行命令前通过export PATH=...把环境准备好。
3.3 进阶:用任务链完成“导出、打包、清理”一条龙
单个备份任务只是开胃菜。实际运维里,更常见的需求是一个环节扣一个环节的流程,比如每天凌晨把数据库导出、压缩、加密、传到远端、再清理本地临时文件。caveman 在这点上依然保持朴素:不搞依赖引擎,就用临时文件传递信息。
我设计了一个模式叫做“任务分段”。把流程拆成多个 caveman 任务,每个任务只干一件事,任务和任务之间通过约定好的临时文件路径衔接。比如:
[db-export] command = mkdir -p /tmp/caveman-jobs && mysqldump -u backup --all-databases | gzip > /tmp/caveman-jobs/db-{date}.sql.gz timeout = 3600 retries = 1 [db-upload] command = rclone copy /tmp/caveman-jobs/db-{date}.sql.gz remote:backup/db/ && find /tmp/caveman-jobs -name "*.sql.gz" -mtime +7 -delete timeout = 3600 retries = 2 [db-full] description = 数据库导出并上传 command = caveman run db-export && caveman run db-upload && echo "db-full completed" timeout = 7200 retries = 0看到没有,db-full本身又是一个任务,它的命令就是把另外两个任务串起来。这就是 caveman 处理“任务链”的方式,不引入复杂的 DAG 概念,你只需要先想清楚顺序,然后用 shell 的&&或;把关系表达出来。好处是任何人都能看懂这个流程,坏处是你得自己保证顺序。对于绝大多数固定节奏的运维任务来说,这种显式顺序是完全够用的。
这里有一个特别值得强调的实战经验:如果某个中间命令可能失败,而后续命令不应该继续执行,那么一定要用&&而不是;。;不管前面的命令成不成功都会执行后面的,很容易把一份不完整的备份当成正常结果传到远端。我在写配置的时候,会把这条原则放在任务说明的第一行。
3.4 加一点“智能”:成功静默、失败响铃
caveman 做得最多的任务其实是那些常年无人关注的清理任务。如果每个任务成功都在终端里刷存在感,很快你就会对日志麻木,真正出问题的时候反而会被忽略。所以 caveman 的设计哲学是:默认成功时静默,只有失败才大声。
这个可以通过 notify 字段实现。caveman 支持在任务失败后执行一个自定义脚本,脚本内容可以由你自由发挥。比如我想用钉钉机器人推送:
[clean-tmp] command = find /tmp -type f -atime +3 -delete timeout = 300 retries = 2 notify = /usr/local/bin/ding_notify.sh/usr/local/bin/ding_notify.sh的内容大致是:
#!/bin/bash # $1 任务名, $2 退出码, $3 输出摘要 curl -s -H "Content-Type: application/json" \ -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"[caveman] 任务 $1 失败, 退出码 $2\"}}" \ https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN这个脚本是个通用接口,你可以换成企业微信机器人、Server酱、邮件发送脚本,或者直接写一条日志到单独的流水账里。我自己的用法是:caveman 失败后调用一个发邮件的脚本,简单粗暴,重点在于“有事说事”。
把这一步做完,一套可靠的自动化体系基本上就立住了:机器按点跑,任务按序执行,成功不打扰,失败必响铃,出了问题还有日志可以翻。
4. 踩坑记录与排错速查
4.1 环境差异的坑:cron 的 PATH 和 shell 都不一样
这个坑我敢说是所有跑定时任务的人都会遇到的。在命令行手动执行命令一切正常,一放进 crontab 就报command not found。罪魁祸首是 cron 默认的环境变量太干净了,PATH 只有/usr/bin:/bin,很多装在/usr/local/bin下的工具根本找不到。
解决方式我上面提到了,要不在 crontab 里显式设置环境变量,要不在 tasks.ini 的[default]节里统一设置path。我两种方式都试过,最终更喜欢在配置里设置,因为这样跑 crontab 和手动跑任务的环境是一致的,不会出现“命令行能通、定时跑不通”的差异。
还有一个小坑是默认 shell 的问题。crontab 默认用的是/bin/sh,在某些精简系统上它可能指向 dash 而不是 bash。dash 对数组、[[ ]]、某些通配符的支持都不如 bash 完整。所以 caveman 在执行命令时强制用/bin/bash而不是/bin/sh,并且把这一条写死在执行器里。如果你自己写 crontab 跑任何复杂脚本,也建议在脚本开头加一句#!/bin/bash,别省这行。
4.2 路径和转义的坑:备份文件里面带了个烦人的空格
有一次我发现备份出来的 tar 包解压以后,里面多了一个文件名带空格的目录,原因就是命令里处理文件列表时没有做引用。比如:
tar czf /backup/archive.tar.gz /data/my documents这条命令会被解析成打包/data/my和documents两个路径,而不是你心里想的那个带空格的目录。正确写法是:
tar czf /backup/archive.tar.gz "/data/my documents"在 ini 配置里写这条命令时,外层双引号又和配置格式冲突,所以还需要转义。这些细节非常磨人,但偏偏就是真实运维里最常遇到的问题。我给自己的建议是:涉及路径的命令,一律先ls -l验证一下,别直接上 cron。
另一个高频问题是 find 命令的-exec和通配符混用时,*会被 shell 先展开,结果传给 find 的参数变成了一堆匹配到的文件列表,行为完全不可控。正确做法是把整个 find 命令用引号包起来,或者改用-exec command {} \;的标准形式,确保*由 find 自己解析。
4.3 并发和幂等的坑:备份任务把磁盘塞爆了
既然刚才说过锁机制,这里就讲一个因为“没有幂等设计”而踩出来的事故。某台机器的备份任务配置比较简单粗暴,每次执行都把整个/data打包成一个新的 tar.gz,然后不做清理。原本以为保留 7 天没多大,结果某天数据量突然从 2GB 涨到 50GB,备份文件也跟着线性增长。发现的时候磁盘已经满了,数据库写入直接报错。这一下让我明白一个道理:任何设计来自动跑的任务,都必须考虑两个问题,一个是“重复执行会发生什么”,另一个是“数据会累积多快”。
后来我在所有备份类任务里统一加上保留策略,也就是在打包成功之后,立刻用find /backup -name "*.tar.gz" -mtime +N -delete删除过期归档。这个步骤必须放在&&链的末尾,确保只有新备份成功时才执行清理,不然新备份失败了旧备份又被删掉,就真的什么都没了。
另一个维护性问题:caveman run是支持手动触发的,但它不会自动帮你担心“这个任务十分钟前刚跑过一次”。如果手动执行时上一个任务因为网络问题卡了很久,锁还占着,那你大概率会撞见退出码 3。这时不要直接rm -f states/xxx.lock,这是典型的危险动作。你应该先ps aux | grep 任务名,确认没有相关进程在跑,再手动处理锁。盲目删锁可能让两个进程同时跑起来,局面更难收拾。
4.4 常见问题速查表
最后把我在实际使用里总结出的一系列高频问题整理成了一张表,方便你遇到问题的时候直接照着查。
| 现象 | 可能原因 | 排查命令 | 解决办法 |
|---|---|---|---|
| 命令行跑正常,cron 跑报 command not found | crontab 环境变量 PATH 缺失 | crontab -l查看是否有 PATH 设置 | 在 tasks.ini 里统一设置 path |
| 任务一直跑不完,日志停在某条命令 | 没有设置 timeout,命令挂死 | ps aux | grep 任务名找到进程树 | 补上 timeout 参数,默认设 300-600 秒 |
| 两个任务同时跑,数据文件冲突 | 没有锁机制或锁被人为删掉 | cat states/xxx.lock查看 PID | 加锁配置,处理前确认无残留进程 |
| 备份包越来越大,磁盘空间告急 | 没有清理旧归档 | df -h和du -sh /backup | 在命令链末尾加 find 过期删除 |
| 命令里花括号被解析报错 | 和配置变量语法冲突 | 查看日志是否抛出 configparser 异常 | 需要字面量花括号时写成双花括号 |
| 找不到 redo 日志、任务没执行 | 日志目录未创建,写日志失败 | ls logs/ | 在任务命令里先mkdir -p /var/log/caveman |
| 退出码是 4 且任务被杀 | 超时后自动终止 | 查看日志中的 timeout 记录 | 调整单条命令或把任务拆分成更小的步骤 |
这张表不是万能的,但覆盖了我见过的大多数故障。核心经验是:查问题永远先从日志和状态文件入手,不要凭感觉改配置。caveman 的日志里每次执行都会记录 start、success、failed、timeout 这类关键词,配合grep很快就能把作案时间点定位出来。
用了这么久,我对这套“穴居人”式工具的体会是:很多自动化问题的解法并不是买一个更大更全的系统,而是找一个能贴着你实际干活的工具,把那些最简单的动作可靠地重复一万次。caveman 解决了我 80% 的杂活,剩下 20% 真正复杂的需求我再去上重型平台,两者之间用一个简单的判断标准分开:如果一件事能用三条 shell 命令描述,就别给它上分布式编排框架。后面我还想把远程多机免 agent 模式加进去,让一台控制机通过 SSH 直接调用远端的 caveman,也算是在这个极简基础上又往外走一小步。