
去年年底我把一个监控服务完整重写了一遍名字就叫 caveman。这名字起得有点随意但很贴合思路像穴居人一样不用花哨的框架不用容器编排不依赖一堆第三方包只用手边最原始的石头和火把事情做成了。这个项目本质上是一个极简的网站可用性监控工具用 Shell 脚本加 SQLite 就能跑起来部署在一台 512MB 的旧服务器上到现在稳如老狗。这篇博文就是这次重构的经验总结从设计思路、完整代码到踩坑记录都有适合正在做运维巡检、个人项目监控或者单纯想给手上小服务加一层保护的朋友参考。1. 项目整体设计为什么叫 caveman1.1 需求背景从一个半夜报警开始最初版本是用 Python 写的搭配 Flask 做一个简单的 Web 页面数据丢给 Redis 缓存再跑到一台容器里用 docker-compose 编排前前后后四个服务。功能确实完整支持实时图表、历史趋势、多用户告警规则。但问题也出在这里某个周日凌晨三点Redis 容器因为内存配额被系统杀掉紧接着 Flask 因为连不上 Redis 抛异常监控数据全部丢失而监控系统本身没人监控。那次事故之后我认真盘了一遍需求其实核心需求只有三条。定期检查一批 URL 是否返回 200 记录响应耗时和状态码 每天生成一份汇总报告异常时通知我这三条需求里没有任何一条需要 Web 界面、实时推送或者历史趋势图。我真正需要的是可靠、省心、能在我睡觉的时候默默干活并且第二天早上用一份清晰报告告诉我一切正常或者哪里挂了。于是有了重写为 caveman 的念头砍掉一切“锦上添花”的功能只留生存必需品。1.2 技术选型Shell SQLite cron 为什么够用选型时我认真比对了三种方案。方案运行依赖内存占用部署复杂度抗故障能力Python Flask RedisPython3、Flask、Redis常驻 400MB需 venv 服务管理Redis 挂了全挂Node.js SQLiteNode14、better-sqlite3常驻 120MB需 npm install崩溃恢复一般Shell curl SQLite3 cronbash、curl、sqlite3运行时峰值不到 10MB复制脚本改 crontab无守护进程可挂最终选 Shell 方案理由很实际这台旧服务器上本身就装有这些工具不需要安装任何新依赖。curl 负责 HTTP 探测sqlite3 负责数据存储cron 负责调度三个 Unix 原语叠加就完成了一个完整的监控闭环。而且这种方式有一个隐性优势进程运行完就退出没有常驻内存不会 OOM不会内存泄漏也不需要 supervisor 或者 systemd 盯着它。每分钟调度一次跑完即走比任何守护进程都干净。1.3 边界意识哪些功能明确不做设计 caveman 时我给自己定了三条禁令这可能是整个项目里最值钱的部分。不做实时告警分钟级检测就是可接受的延迟 不做 Web 可视化面板报告用 Markdown 明文展示 不做多用户权限体系只有一个运行账号这三条禁令帮我挡掉了至少 80% 的复杂度。比如不做实时告警就不需要维护 WebSocket 长连接不做可视化面板就不需要前端资源、路由和认证逻辑不做多用户就不需要考虑数据隔离和权限模型。监控系统的核心价值不是功能多而是该响应的能响应、该记录的不丢。2. 核心细节解析与实操要点2.1 SQLite 表结构一个表搞定全部数据库只有一个文件caveman.db里面只有一张表checks。结构如下CREATE TABLE IF NOT EXISTS checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, code INTEGER, time_ms INTEGER, error TEXT, checked_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) );设计这张表时有几个考虑。url不需要单独建表做外键因为监控对象数量非常固定多建一张表反而增加 JOIN 复杂度。code保存 HTTP 状态码time_ms保存响应耗时error字段留来做异常记录比如 DNS 解析失败、连接超时、TLS 握手失败等这些情况没有状态码只能靠error字段记录。checked_at直接存本地时间字符串默认值用datetime(now, localtime)而不是 UTC便于直接看报告。2.2 curl 探测参数每一个参数都有理由对 URL 发起探测的核心命令是code$(curl -s -o /dev/null -w %{http_code} \ --connect-timeout 5 \ --max-time 10 \ $url 2/tmp/caveman_err) || true三个关键参数的作用--connect-timeout 5TCP 连接超时 5 秒。有些网络环境对不可达 IP 会做黑洞路由不设置这个参数curl 会傻等系统默认的超时时间探测会被拖死。--max-time 10整个请求总超时 10 秒。这个参数同时限制了首字节时间和总下载时间防止目标服务器接受连接但迟迟不响应。-o /dev/null丢弃响应体内容。监控只需要状态码和耗时不需要把页面内容下载到本地。曾经有次监控对象是一个下载站点响应体有几百兆差点把磁盘写满教训深刻。耗时统计同样用 curl 自带能力time_ms$(curl -s -o /dev/null \ --connect-timeout 5 \ --max-time 10 \ -w %{time_total} \ $url 2/dev/null | awk {printf %.0f, $1 * 1000})%{time_total}返回的是秒为单位的浮点数乘以 1000 再取整变成毫秒整数存入数据库。如果你还想观察更细的指标time_connect、time_starttransfer、time_redirect都从同一个字符串里取。2.3 错误分类宁可多记不可漏记监控脚本里把探测结果分成四类每一类对应不同的处理方式分类判定方式处理策略正常HTTP 状态码为 200记录 code 和耗时服务异常状态码为 3xx/4xx/5xx记录 code不记 error网络异常curl 返回非零退出码code 置 NULLerror 记详细原因超时退出码为 28error 记 timeoutcode 置 NULL其中第三类最容易被忽略。很多人只检查状态码忽略了 curl 本身也有退出码。当 DNS 解析失败时$code是空的如果直接写入数据库最后看报告会以为是目标返回了空值。实际上应该做一次判断如果 curl 退出码非零把CURLE_*对应的错误文本记录到 error 字段。判断逻辑如下if [ $code 000 ] || [ -z $code ]; then error_text$(cat /tmp/caveman_err 2/dev/null | tr -d \n) sqlite3 $DB \ INSERT INTO checks (url, code, time_ms, error, checked_at) VALUES ($url, NULL, NULL, $error_text, datetime(now,localtime)); else sqlite3 $DB \ INSERT INTO checks (url, code, time_ms, error, checked_at) VALUES ($url, $code, $time_ms, , datetime(now,localtime)); fi2.4 日志策略只在出问题时写日志很多人写监控脚本喜欢每跑一次就打印一行日志实际上大部分日志都没人看。caveman 的日志文件只有一种内容错误事件。任何一次探测失败、任何一次生成报告时发现的异常才会在caveman.log里追加一行。正常运行时这个文件大小一直不变。日志格式固定为2025-06-14 03:12:07 ERROR https://example.com code000 errorConnection timed out 2025-06-14 03:12:09 ERROR https://api.example.net code504 error这种设计让排查问题时非常舒服直接看一眼日志就知道历史上有哪些时刻出过问题不需要在一堆正常记录里翻找异常。我宁可少记录细节也要保证每条日志都有明确的排查价值。3. 实操过程完整搭建一个 caveman 监控3.1 环境准备一行命令装齐工具以 Debian/Ubuntu 为例基础环境这样准备sudo apt update sudo apt install -y curl sqlite3 cron mkdir -p ~/caveman cd ~/cavemanmacOS 上则不需要安装任何东西这三个工具系统自带。CentOS 系的机器把apt换成yum即可。装完后验证一下版本curl --version | head -1 sqlite3 --version如果sqlite3命令不存在说明系统装的是libsqlite3-0运行库没有安装命令行工具。Debian 系安装sqlite3包即可这个包很轻没有任何额外依赖。3.2 主脚本 check_sites.sh一次探测一个 URL我习惯把 URL 列表单独放在sites.txt文件里一行一个脚本遍历执行。这样加监控对象时不需要改脚本改文本文件就行。sites.txt示例https://blog.example.com https://api.example.com/health https://store.example.com https://docs.example.comcheck_sites.sh的完整实现#!/usr/bin/env bash # caveman - site availability checker # usage: ./check_sites.sh [sites_file] set -uo pipefail BASE_DIR$(cd $(dirname $0) pwd) SITES_FILE${1:-$BASE_DIR/sites.txt} DB$BASE_DIR/caveman.db LOG$BASE_DIR/caveman.log LOCK/tmp/caveman.lock mkdir -p $BASE_DIR # 使用 flock 防止上一次运行未结束时重复执行 exec 9$LOCK if ! flock -n 9; then echo $(date %Y-%m-%d %H:%M:%S) SKIP another instance running $LOG exit 0 fi sqlite3 $DB CREATE TABLE IF NOT EXISTS checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL, code INTEGER, time_ms INTEGER, error TEXT, checked_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) ); while IFS read -r url || [ -n $url ]; do [[ -z $url || $url \#* ]] continue code$(curl -s -o /dev/null \ --connect-timeout 5 \ --max-time 10 \ -w %{http_code} \ $url 2/tmp/caveman_err) || true time_ms$(curl -s -o /dev/null \ --connect-timeout 5 \ --max-time 10 \ -w %{time_total} \ $url 2/dev/null | awk {printf %.0f, $1 * 1000}) if [ $code 000 ] || [ -z $code ]; then err_msg$(tr -d \n /tmp/caveman_err 2/dev/null) sqlite3 $DB \ INSERT INTO checks (url, code, time_ms, error, checked_at) VALUES ($url, NULL, NULL, $err_msg, datetime(now,localtime)); echo $(date %Y-%m-%d %H:%M:%S) ERROR $url code000 error$err_msg $LOG else sqlite3 $DB \ INSERT INTO checks (url, code, time_ms, error, checked_at) VALUES ($url, $code, $time_ms, , datetime(now,localtime)); if [ $code ! 200 ]; then echo $(date %Y-%m-%d %H:%M:%S) ERROR $url code$code $LOG fi fi done $SITES_FILE几个实现细节说明一下。set -uo pipefail没有加-e因为脚本内部就对 curl 错误做了分支处理整体退出码非零反而会影响 cron 的日志判断。flock -n 9是个容易被忽略的点cron 调度如果遇到上轮脚本卡住默认策略是再起一个新进程两个脚本同时写 SQLite 大概率触发锁冲突。加了flock后重复执行会直接跳过并在日志里留下一条 SKIP 记录。3.3 报告生成 generate_report.sh十五行命令出日报每天早上八点我需要看到一份前一天所有探测结果的汇总。报告脚本不需要任何图表库文字加表格就够用。#!/usr/bin/env bash # caveman - daily report generator set -uo pipefail BASE_DIR$(cd $(dirname $0) pwd) DB$BASE_DIR/caveman.db REPORT$BASE_DIR/report.md { echo # Caveman 监控日报 - $(date %Y-%m-%d) echo echo ## 今日异常 sqlite3 $DB SELECT [ || substr(checked_at, 1, 19) || ] || url || code || IFNULL(code, NULL) || error || IFNULL(error, ) FROM checks WHERE date(checked_at) date(now, localtime, -1 day) AND (code ! 200 OR code IS NULL) ORDER BY checked_at DESC; echo echo ## 可用率统计 sqlite3 $DB SELECT url, COUNT(*) AS total, SUM(CASE WHEN code 200 THEN 1 ELSE 0 END) AS ok, ROUND(100.0 * SUM(CASE WHEN code 200 THEN 1 ELSE 0 END) / COUNT(*), 2) AS ok_pct FROM checks WHERE date(checked_at) date(now, localtime, -1 day) GROUP BY url ORDER BY ok_pct ASC; echo echo ## 平均耗时 TOP5 sqlite3 $DB SELECT url, printf(%.0f, AVG(time_ms)) AS avg_ms FROM checks WHERE date(checked_at) date(now, localtime, -1 day) AND time_ms IS NOT NULL GROUP BY url ORDER BY avg_ms DESC LIMIT 5; } $REPORT echo report generated: $REPORT报告里三块内容各有用途。异常列表一眼看出昨天哪些服务出过问题可用率统计给出一个百分比概念比如某个接口昨天是不是只有 99.2% 的稳定度平均耗时 TOP5 帮我发现响应变慢的服务端即使它还没有彻底挂掉。3.4 定时任务 crontab 配置两个关键坑cron 配置如下* * * * * cd /home/user/caveman ./check_sites.sh /dev/null 21 0 8 * * * cd /home/user/caveman ./generate_report.sh /dev/null 21第一个坑是 PATH。cron 执行环境里PATH通常只有/usr/bin:/bin如果脚本里调用sqlite3时没有写全路径会出现手动执行正常、定时任务执行时报command not found的诡异现象。解决方式是在脚本开头显式导出一份 PATHexport PATH/usr/local/bin:/usr/bin:/bin:$PATH第二个坑是工作目录。所有路径都用了BASE_DIR做锚定而不是依赖当前目录。crontab 里的cd /home/user/caveman只是保险真正可靠的是脚本内部用pwd推导出来的根目录。这样即使有人在其他目录下执行脚本也不会把 SQLite 数据库写到别的地方去。3.5 数据保留策略定时清理一个月前的数据SQLite 数据库如果无限增长虽然单条记录很小但一年下来也会有几万条没必要一直留着。我在 crontab 里加了一条周任务每周日凌晨一点清理0 1 * * 0 sqlite3 /home/user/caveman/caveman.db DELETE FROM checks WHERE checked_at datetime(now, localtime, -30 days);注意这里用的是checked_at datetime(now, localtime, -30 days)因为checked_at存储的是本地时间字符串比较时也必须用本地时间函数不能用 UTC。这个错误我踩过一次清理记录时误删了今天的数据因为 UTC 时间和本地时间差了八个小时。4. 常见问题与排查技巧实录4.1 cron 环境差异导致脚本静默失败这是新手最容易踩的坑症状是手动执行脚本一切正常加到 crontab 后完全不工作也不报错。核心原因是 cron 的 shell 环境极其精简PATH里可能找不到你的 Python、Node 或者某个自编译工具的路径脚本第一行#!/usr/bin/env bash如果拆不到 bash就直接失败了。排查方法很简单先给脚本加一行输出环境信息的调试日志。echo PATH$PATH /tmp/caveman_env.log等下一轮 cron 执行完看日志里的 PATH 和手动执行时的 PATH 差多少。修复方式是最前面导出完整 PATH而不是依赖系统默认值。4.2 SQLite 数据库被锁症状是脚本日志里出现database is locked多出现在数据库同一时刻被两个进程写入的场景。caveman 虽然加了 flock 做进程互斥但如果报告脚本也同时只读查询而查询又碰上主脚本写入仍可能触发锁。解决方案是给 sqlite 加 busy timeout让它在遇到锁时等待而不是立刻失败。推荐做法是在调用 sqlite3 命令前设置环境变量export SQLITE_BUSY_TIMEOUT5000或者每次调用时加参数sqlite3 -cmd .timeout 5000 $DB SELECT ...不过在成熟部署里我更推荐直接加 flock 互斥主检测脚本和报告脚本都走同一把锁彻底避免并发写。4.3 curl 返回 000 但不报错000是 curl 在没有得到 HTTP 状态码时填充的特殊值但单独看这个值并不能区分具体失败原因。有的是超时有的是 DNS 解析失败有的是 TLS 握手失败有的是连接被重置。最直接的做法是拿到 error 信息再入库脚本里已经把2/tmp/caveman_err的内容写进 error 字段查询时能精确定位。sqlite3 $DB SELECT url, error, checked_at FROM checks WHERE code IS NULL AND date(checked_at) date(now,localtime) ORDER BY checked_at DESC LIMIT 5;4.4 时区导致的统计错位如果checked_at用 UTC 存储而报告用本地时间做date()过滤就会导致“今天的数据”包含昨天下午的数据。我当时最初用datetime(now)存 UTC按天统计时写了date(checked_at) date(now)结果每天凌晨 0 点到 8 点的记录都算到了前一天。改法有两个方向一是数据入库时就转成本地时间即在datetime(now, localtime)二是在所有查询时都加localtime修饰符比如date(checked_at, localtime)。选第一种因为这样写入的字符串直接可读且后续 SQL 不需要到处处理时区。4.5 报告脚本重复执行如果 cron 某种原因重复触发了报告脚本文件被连续覆盖不会有问题但如果用户手动执行了两次会生成两个报告内容重叠。给报告文件名加上时间戳可以缓解REPORT$BASE_DIR/report_$(date %Y%m%d).md这样每天的报告独立存放便于回看也不会互相干扰。5. 扩展场景同一套方案的更多用法5.1 从网站监控扩展到证书有效期检查有些服务不挂页面但 TLS 证书快过期了浏览器访问会直接红屏警告。利用 openssl 命令行做个配合检查把证书剩余天数写入 SQLite同样能实现提前告警。cert_days$(echo | openssl s_client -servername example.com -connect example.com:443 2/dev/null | openssl x509 -noout -enddate 2/dev/null | cut -d -f2) expire_epoch$(date -d $cert_days %s) now_epoch$(date %s) days_left$(( (expire_epoch - now_epoch) / 86400 ))当days_left小于 30 时记一条 ERROR 日志。这个检查不需要多复杂和主检测脚本放在同一个 crontab 里一天跑一次就够。5.2 把报告推到聊天工具caveman 的报告现在只是一份 Markdown 文件想看的时候手动打开很被动。后来我用一个推送脚本把报告里的异常摘要直接通过 webhook 发到企业微信/钉钉群实现真正的主动通知而且没有引入任何新服务。# push_report.sh yesterday$(date -d -1 day %Y-%m-%d) summary$(sqlite3 $DB SELECT [ || substr(checked_at,1,5) || ] || url || || IFNULL(code, error) FROM checks WHERE date(checked_at) $yesterday AND (code ! 200 OR code IS NULL) LIMIT 5;) curl -s -X POST https://qyapi.weixin.qq.com/cgi-bin/webhook/send?keyYOUR_KEY \ -H Content-Type: application/json \ -d {\msgtype\:\text\,\text\:{\content\:\Caveman 昨日异常\n$summary\}}这里的 HTTP 调用本质还是 curl整个工具链延续了最初的极简原则。5.3 什么时候该放弃极简方案极简不等于万能。如果监控的目标数量超过几百个Shell 脚本遍历文件、顺序探测的性能就会成为瓶颈如果需要复杂的告警路由规则、多级通知、权限隔离如果团队成员需要看一个可视化的实时面板——这些时候你应该去找开源成熟方案比如 Prometheus、Uptime Kuma 或者商业监控服务。caveman 是做“一个人的监控”用的它的边界和价值都在于此。写在最后的体会这次重构给我最深的感受是很多项目里的复杂度并非业务需要而是基础设施的自找麻烦。Redis 容器、Web 管理端、用户系统每多一个组件就多一个半夜三点把你叫醒的理由。caveman 这套方案虽然简陋但它足够稳因为没有任何常驻进程可以崩没有内存可以泄漏没有时钟同步问题本地时间直接入库没有跨语言依赖链。如果你也有类似的小监控需求不妨试试这种“穴居人”思路先写一个能跑通的极简版本等它真正成为瓶颈再谈演化。就我个人经验来说很多时候这个极简版本会一直用得很好远比你预想的时间长。