一、结论先给:任务「不执行」其实是五种完全不同的故障

定时任务不执行,在 VPS 上至少有五种根因,症状看起来几乎一样,但修法完全相反。 先把它们分开,后面每一步排查才有意义。
第一种:进程根本没被拉起。 cron 的守护进程在跑,但任务本身没启动。典型原因是 crontab 里的路径写错、脚本没有可执行权限、脚本首行 shebang 缺失或指向不存在的解释器,或者 /etc/cron.d 目录下的文件权限不是 644。
第二种:进程拉起来了,但环境是空的。 这是 VPS 上最隐蔽的一类。cron 的默认环境非常干净,PATH 通常只有 /usr/bin:/bin,没有 /usr/local/bin;HOME 可能不是你以为的那个;JAVA_HOME、NODE_PATH、PYTHONPATH 这些手动装的环境变量一个都没有。结果就是:你在终端里手动跑脚本完全正常,脚本里调 node、python3、某个自装的 ffmpeg,cron 里一律 command not found。
第三种:任务跑了,但跑完就静默退出。 脚本里某一步失败,exit 1 了,但没有重定向、没有日志、也没有告警。系统只会在 /var/log/syslog 或 journalctl 里留下一行 CRON[12345]: (root) CMD (...) ——这行只证明 cron 调用过那个命令,完全不证明脚本执行成功。 很多人看到这一行就以为「任务在跑」,实际上脚本执行完第一行就死了。
第四种:任务在跑,但被锁挡住或互相踩。 上一轮还没跑完,这一轮又起来了,两个进程抢同一个文件、抢同一个数据库连接,结果两边都写坏数据。这类故障的特征是「不是一直不跑,是隔一段时间出一次问题」,且日志里有 PID file exists 之类的提示。
第五种:任务被跑了,但时间不对。 时区问题。VPS 的系统时区是 UTC,而你看日志用的是本地时间(UTC+8),于是日志里显示的 16:00 执行,在你这里是第二天凌晨 0:00。或者反过来,服务器设了 Asia/Shanghai 但容器里是 UTC。这类的典型表现是「任务确实执行了,只是执行的时刻跟你预期差 8 小时」。
这五种要靠下面的七步排查顺序区分开。 顺序很重要:先确认「跑没跑」,再确认「跑成什么样」,最后才谈加固。跳过第一步直接改配置,是这类问题最常见的浪费时间的做法。
贯穿全文的三组数字: 排查顺序 7 步;一个定时任务的计划间隔,至少要是它最坏情况执行时长的 3 倍,否则一定堆积;常驻型定时任务所在机器的内存峰值,不超过总内存的 70%,否则会触发 swap 或 OOM,表现同样是「任务莫名其妙停了」。
二、第一步:确认 cron 到底有没有拉起你的进程

排查的第一件事是拿到证据,而不是猜。 判断「进程有没有被拉起」有三个层次的证据,从弱到强。
证据一:系统日志里的调度记录。 这一层只能证明 cron 调用过命令,不能证明命令执行成功:
# Debian/Ubuntu 系
sudo grep CRON /var/log/syslog | tail -30
# systemd 系(Debian 12+、RHEL 系)
sudo journalctl -u cron -u crond --since "1 hour ago" --no-pager | tail -30
看到 CRON[12345]: (root) CMD (/usr/local/bin/backup.sh) 这样的行,说明调度器发出了调用。 如果连这一行都没有,问题在第 0 层——crontab 根本没加载,或者加载了但语法被拒。
证据二:进程自己留下的痕迹。 这才是真正有用的证据。让脚本第一步就写一个心跳文件:
#!/bin/bash
# 任务第一步:写心跳
echo "$(date '+%F %T') pid=$$ start" >> /var/log/myjob/heartbeat.log
# ... 真正的工作逻辑
echo "$(date '+%F %T') pid=$$ end rc=$?" >> /var/log/myjob/heartbeat.log
然后检查心跳文件的时间戳:
# 距离上一次心跳过去多久(秒)
now=$(date +%s); last=$(stat -c %Y /var/log/myjob/heartbeat.log); echo "距上次心跳 $((now-last)) 秒"
如果心跳文件永远只有一行 start 没有对应的 end,说明脚本执行到一半死了或者被杀了。 如果连 start 都没有,说明进程根本没被拉起——回到证据一,看调度记录里有没有你的命令。
证据三:任务自己写的日志。 见第四节。
这一阶段最常见的三个「假故障」值得单独说:
假故障一:改了 crontab 但没生效。 在某些环境里,crontab -e 保存后需要 sudo systemctl reload cron(Debian 系是 cron,RHEL 系是 crond)。忘了 reload 的话,你会看着自己的 crontab 文件内容完全正确,然后花两小时查脚本本身。
假故障二:crontab 文件末尾缺了换行符。 crontab 的解析器按行处理,如果最后一行没有换行符结尾,某些版本会直接忽略最后一行。 这个坑非常隐蔽:你 cat 出来能看到那行任务,但它就是没跑。
假故障三:写了相对路径。 crontab 的工作目录不是你的家目录,通常是 /root 或 /。所以 python backup.py 找不到文件,而 python /root/scripts/backup.py 正常。 养成习惯:crontab 里一律写绝对路径,一个相对路径都不要写。
顺便验证 cron 服务的状态:
systemctl status cron --no-pager
sudo crontab -l # root 的任务
crontab -l # 当前用户的任务
sudo ls -l /etc/cron.d/ # 系统级任务,注意权限和后缀
/etc/cron.d/ 下的文件和 crontab -e 的格式不一样:前者必须带用户名字段(root /path/to/script),而且文件名必须严格符合 run-parts 的规则(不能有连字符以外的非法字符,通常要求字母数字下划线)。 文件名带点的、或者权限不是 644 的,都会被静默跳过。
三、第二步:环境变量——VPS 上最隐蔽的一类失败

这一节讲的是本文最关键的一步,因为它的特征是「手动跑正常,cron 里失败」,而这个特征会让人怀疑脚本逻辑本身,白白浪费大量时间。
cron 启动进程时,环境变量几乎是从零开始构造的。 具体来说:
PATH 会被重置成系统默认。 Debian 系通常是 /usr/bin:/bin,有些发行版会加上 /usr/sbin:/sbin,但永远不会包含 /usr/local/bin、/usr/local/sbin、/opt/.../bin,也不会包含你用 nvm、pyenv、asdf 这类版本管理器装出来的路径。如果你的 Python、Node、Go 装在 /usr/local/bin,cron 里就找不到。
HOME 可能不对。 如果你用 su - user -c "..." 或者在 crontab 里手动写了 HOME=/root,行为会跟登录 shell 不一致。很多工具(npm、pip、composer、git)依赖 HOME 找缓存和凭据,HOME 不对就会各种「permission denied」或「找不到配置文件」。
语言相关的环境变量全丢。 JAVA_HOME、GOROOT、GOPATH、PYTHONPATH、NODE_PATH、VIRTUAL_ENV、LD_LIBRARY_PATH 这些,一个都没有。这类依赖尤其容易出问题,因为报错信息通常指向别的方向——java -version 报「找不到 libjvm.so」,你第一反应是「Java 装坏了」,实际上是 JAVA_HOME 没设。
破解办法有三种,从推荐到不推荐:
第一种(推荐):在脚本内部自己设环境变量,把所有依赖都写死。 这是最可靠的做法,因为脚本自己就不依赖外部环境:
#!/bin/bash
set -euo pipefail
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export HOME="/root"
export JAVA_HOME="/usr/lib/jvm/java-17-openjdk-amd64"
export LANG="C.UTF-8"
cd /root/scripts
exec /usr/bin/python3 /root/scripts/job.py
注意最后一行用了 exec,它让脚本进程被目标程序替换掉。 这样做的价值在于:cron 会直接跟踪真正的业务进程,不会多出一层 shell,也更容易在 ps 里看清是谁在跑。
方案 B:把环境变量写进 crontab 头部。 可行,但容易被漏掉——你后来加任务的时候忘了加这几行,新任务就又出问题了。如果非要用这个办法,至少写成一行,把所有 export 挤在一起,避免遗漏:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/root
LANG=C.UTF-8
30 3 * * * /root/scripts/backup.sh >> /var/log/myjob/backup.log 2>&1
第三种:用 systemd timer 替代 cron。 systemd 的 service 单元可以写 Environment=、WorkingDirectory=、ExecStart=,环境是显式声明的,还有 Restart=、OnFailure=、日志走 journald。如果你用的是 systemd,并且任务重要,建议直接换 systemd timer。
验证环境是否正确的最快方法,是让脚本把自己的环境 dump 出来:
#!/bin/bash
env | sort > /var/log/myjob/env-$(date +\%Y\%m\%d).txt
id >> /var/log/myjob/env-$(date +\%Y\%m\%d).txt
跑一次,然后手工比较 cron 里 dump 出来的和你终端里的差异。 这比逐条猜测快得多。注意 crontab 里 % 必须转义成 \%,因为 % 在 crontab 里是换行符——脚本里含 % 的命令在 crontab 里会被截断成两行。
这一节给个可以立刻用的验收数字:
验收标准一:脚本用 #!/bin/bash 或 #!/usr/bin/env bash 开头,且脚本第 2 到第 10 行内能看到完整的 export PATH 和 export HOME。 缺任何一条,判定为高风险。
验收标准二:在终端里执行 env -i /bin/bash -c '你的脚本路径' 能跑通。 这个命令模拟的是 cron 的「空环境」状态。如果这条命令失败,你的脚本在 cron 里必然失败,与 cron 本身无关。 这是本节最有价值的一条命令,请务必用它测一遍。
四、第三步:脚本内部失败——看日志,别看 cron 日志

确认进程被拉起之后,下一步是把「脚本执行到哪一步」看清楚。 cron 日志帮不上这个忙,它只记录调度事件。
给脚本加日志有三个层次:
第一层:重定向到文件。 crontab 里必须有 >> 和 2>&1,缺一不可:
30 3 * * * /root/scripts/backup.sh >> /var/log/myjob/backup.log 2>&1
>> 是追加,不是覆盖。 用 > 的话每次跑都清空日志,出了问题只能看到最后一次。2>&1 把标准错误合并进日志,否则错误信息会直接被 cron 投递到本地邮箱,而很多服务器上没人看那个邮箱,于是错误就彻底消失了。 这是「任务跑了但看不到任何输出」最常见的原因。
第二层:在脚本里用 set 明确失败行为:
#!/bin/bash
set -euo pipefail
这三个开关各有含义,排查时非常有用:
set -e 表示任何命令返回非零就立即退出。这能防止「第一步失败了,第七步还在用第一步的空结果继续跑」这种最糟糕的情况——备份脚本的数据库导出失败了,但压缩步骤继续跑,最后生成一个空备份文件,还显示「成功」。
set -u 表示引用未定义变量就报错。 很多「任务突然不跑了」的原因是脚本里写了 $API_TOKEN,而这个变量没配或拼错了。 在交互 shell 里未定义变量展开成空字符串,脚本照跑不误;在 set -u 下会直接报 unbound variable 并退出。
set -o pipefail 表示管道的返回值取所有命令里最差的那个,而不是最后一个。 这一条对定时任务尤其关键,因为定时任务几乎都带管道:curl ... | tar ... | gzip。 没有 pipefail 时,curl 失败了但 tar 成功了,整条管道的返回值是 0,脚本认为一切正常——结果备份文件里是空的,但日志里写着「完成」。
第三层:关键步骤写哨兵文件。 在流程的关键节点写标记,出问题时一眼看出停在哪:
#!/bin/bash
set -euo pipefail
LOG=/var/log/myjob/job.log
step() { echo "[$(date '+%F %T')] STEP: $1" >> "$LOG"; }
step "begin"
mysqldump -u root db > /tmp/db.sql
step "dump done, size=$(stat -c %s /tmp/db.sql)"
gzip -9 /tmp/db.sql
step "gzip done"
rm -f /tmp/db.sql
step "end"
跑完看日志,最后一条 STEP: 就是它停住的地方。 这一招在排查「任务跑到一半就停」的问题上几乎是万能的。
常见报错原文对照表(这几条你在别处搜到的答案往往不靠谱,直接对着改):
command not found —— 基本可以断定是 PATH 问题。 报错形如 /bin/sh: 1: mytool: not found。修法是第一节说的绝对路径或 export PATH。
Permission denied —— 分三种情况。 一是文件没有可执行权限:chmod +x script.sh。二是目录没有执行权限(注意:目录需要 x 权限才能进去,缺了就是 Permission denied,跟文件权限无关)。三是脚本首行 shebang 指向的解释器没权限或不存在,用 head -1 script.sh 确认路径。
No such file or directory 但文件明明在 —— 九成是脚本首行 shebang 写错了,或者文件开头有 Windows 的 BOM 字节。 检查方法:
head -c 32 script.sh | xxd | head -2
如果看到 efbbbf 开头就是 BOM,用 sed -i '1s/^\xEF\xBB\xBF//' script.sh 去掉即可。
Killed —— 内存不够,进程被 OOM killer 干掉了。 查:
dmesg -T | grep -i -E "out of memory|killed process" | tail -20
看到 Killed process 12345 (python3) total-vm:... 就是这种情况。 修法是给任务限内存(systemd 里写 MemoryMax=,或者脚本里用 ulimit -v),或者换更大的机器。第五节会把这件事算成钱。
五、第四步:锁与并发——任务不是不跑,是互相踩

这一节处理的是「任务时好时坏」「数据偶尔损坏」「同一时间跑出两个进程」这类问题。 它的根因是上一轮没跑完,这一轮又起来了。
为什么会重叠?三种情况:
一是任务本身的执行时间超过了计划间隔。 这是最常见的。比如你设的是每 10 分钟一次,但任务实际要跑 15 分钟(数据量涨了、备份变大了),那么从第 15 分钟开始,每一轮都会叠在上一轮上面。 更糟的是,随着数据增长,这个比例会继续恶化,最后变成「永远在跑,永远叠着」。
解法是先量出真实耗时,再定间隔。 量法很简单,在日志里记时间戳:
step "begin at $(date +%s)"
# ... 工作
step "end at $(date +%s), elapsed=$(( $(date +%s) - $START_TS ))s"
然后用这个公式定间隔:
计划间隔 ≥ 最坏情况执行时长 × 3。
为什么是 3 倍而不是 2 倍: 因为执行时间本身有波动(数据量每月增长、邻居机器负载高、磁盘慢),2 倍只是刚好压线,3 倍才能在波动时不重叠。这个 3 倍是本节的核心数字,请直接拿去用。
二是手动执行和定时执行撞上了。 你在终端里手动跑一次忘了删,正好 cron 也到点了。解决办法是把手动执行也纳入同一套锁机制。
三是 cron 表达式写错了。 常见的坑:
*/5 * * * * # 每 5 分钟
5 * * * * # 每小时的第 5 分钟(不是每 5 分钟)
* * * * * # 每分钟
0 */2 * * * # 每 2 小时
*/5 * * * * 和 5/5 * * * * 效果相同,但 5/5 这种写法在某些实现里会被解释成「从第 5 分钟到第 59 分钟」的所有分钟,具体行为取决于 cron 实现。 用 */N 这个写法更安全。六字段写法(秒在最前)属于扩展语法,跨机器不通用。
防重叠的标准做法是文件锁:
#!/bin/bash
set -euo pipefail
LOCK=/var/run/myjob.lock
exec 9>"$LOCK"
if ! flock -n 9; then
echo "[$(date '+%F %T')] 上一次还没跑完,本次跳过" >> /var/log/myjob/job.log
exit 0
fi
# ... 真正的工作
exec 9>"$LOCK" 打开一个文件描述符,flock -n 9 尝试获取非阻塞锁,拿不到就退出。 这个写法的好处是:进程结束或者被 kill 时,内核会自动释放锁,不需要你在代码里写清理逻辑。这比「写 PID 文件、启动时检查 PID 是否存在、结束时删文件」要可靠得多——后者在进程被 kill -9 之后会留下僵尸 PID 文件,导致任务永远无法启动。
如果你用的是 Python,就用 fcntl.flock 做同样的事:打开锁文件后调用 fcntl.flock(f, LOCK_EX | LOCK_NB),捕获 BlockingIOError 就说明上一轮还在跑,直接退出。核心逻辑和 bash 版完全一样,区别只是语言。
如果你用的是 systemd timer,防重叠是内置的,不需要自己写锁: 在 service 单元里加 Type=oneshot 加上 timer 的 AccuracySec,同一类型的 timer 在上一次还没完成时不会重复触发。这是 systemd timer 相对 cron 的一个实质优势。
顺带说一个常见误解:很多人在 crontab 里用 nice 或 ionice 来「防止影响业务」。 这解决的是资源竞争,不是重叠。真正要防的是「同一个任务跑两遍写坏数据」,那个只能用锁。 两件事不能互相替代。
六、第五步:失败重试与告警——让失败变成你能知道的事

一个不告警的定时任务,等于一个没有的任务。 这一节讲怎么让失败在发生后的几分钟内被你发现,而不是三天后看日志时才发现已经连挂了两个月。
先明确一件事:重试策略和告警策略是两回事,必须都做。 重试解决「偶发失败」,告警解决「必然失败」。
重试的三条原则:
原则一:只在可重试的失败上重试。 网络超时、磁盘临时不可写、外部接口 502 这类,重试有意义。配置错误、认证失败、参数不合法这类,重试一万次也是同样的结果,只会刷日志。 区分方式很简单:看错误类型是不是「同样的输入产生了同样的错误」。如果是,重试无意义,应该直接告警。
原则二:退避要指数化,并且有上限。 第一次失败等 1 分钟,第二次等 2 分钟,第三次等 4 分钟……但最多等 30 分钟封顶。没有上限的指数退避,在外部服务长时间挂掉时会让你的任务堆积成一串。
原则三:重试次数要有硬上限,并且记录重试次数。 推荐 3 次。 3 次能覆盖绝大多数偶发抖动,再多只是浪费时间。每次重试都要在日志里写清楚「第几次重试、什么原因、间隔多久」,否则事后你根本不知道它重试过。
实现方式:
用 systemd 的 Restart= 最省事。 在 service 单元里写 Type=oneshot、ExecStart=/usr/local/bin/myjob、Restart=on-failure、RestartSec=30,再配 StartLimitBurst=5 和 StartLimitIntervalSec=300。
RestartSec=30 是固定间隔,StartLimitBurst=5 配合 StartLimitIntervalSec=300 的意思是「300 秒内失败 5 次就放弃,不再重试」。 这个组合比无限制重试健康得多:它既覆盖了偶发失败,又避免了一个必然失败的配置把机器变成日志生成器。
用脚本自己实现重试(适用于纯 cron 环境): 核心是 for i in $(seq 1 3) 循环里调用真实任务,失败就 sleep $(( 2 (i-1) * 60 )) 退避,第 3 次仍失败就调用通知脚本告警并 exit 1。**
注意最后一步:重试耗尽之后必须有一条明确的告警动作。 只重试不告警,等于把「立刻能知道的问题」推迟成「三天后翻日志才发现的问题」。
告警怎么搭才不复杂:
最简单有效的方式是「心跳文件 + 外部探测」。 让任务成功后更新一个时间戳文件,然后在别处(另一台机器、或者免费的外部拨测服务)检查这个时间戳是不是太旧了:
# 在监控端执行:超过 50 分钟没有心跳就告警
now=$(date +%s); last=$(stat -c %Y /var/log/myjob/heartbeat.log)
age=$((now-last))
if [ "$age" -gt 3000 ]; then
echo "任务已 $((age/60)) 分钟无心跳"
fi
这个方法的妙处在于:它检测的是「结果缺失」而不是「错误发生」,所以不管任务是因为崩溃、超时、还是根本没被拉起而失败,都能发现。 这是「任务不执行」这类故障最可靠的检测方式。
其次是用外部监控的 webhook 告警。 任务成功时往一个 HTTP 接口发一个心跳请求,接口侧配置「超过 N 分钟没收到就发邮件/推送到微信/Telegram」。好处是完全不依赖目标机器的网络状态——如果机器整个挂了,机器自己发不出任何告警,但外部服务能发。
最后一条,也是最容易被忽略的一条:告警必须有人看。 如果告警发到一个没人检查的邮箱,效果等于没有。发到你每天真的会打开的地方——手机推送、微信、钉钉、Telegram 都行。
七、时区、资源与并发:三个容易被忽略的运行条件

前面六步解决的是「任务为什么不跑」。这一节解决的是「任务跑了但结果不对」。
时区是最容易出偏差的一个。 三层时区要分清:
第一层是系统时区。 查法:
timedatectl # 显示系统时区、本地时间、UTC 时间
cat /etc/timezone # Debian/Ubuntu
ls -l /etc/localtime # 软链接目标即系统时区
第二层是 cron 自身的时区。 多数实现跟随系统时区,但 Vixie cron 这类老实现支持在 crontab 开头用 CRON_TZ=Asia/Shanghai 单独指定。如果你改了系统时区但 cron 还在按 UTC 调度,日志里的时间就会错位。
第三层是任务脚本里用的时区。 脚本里如果显式调用 TZ=UTC date,或者 Python 里用了 datetime.utcnow(),那么它生成的时间戳是 UTC,而你的日志系统按 UTC+8 解读——差 8 小时。
排查方法是把三个时间放在一起对:
date; date -u; TZ=Asia/Shanghai date
三个数字应该分别是你的本地时间、UTC 时间、和上海时间。 如果第二个数字加上 8 小时等于第三个,说明系统时区是 UTC 而任务里显式用了上海时区(或反之),这就定位了错位在哪一层。
时区错位的典型症状: 「任务确实在跑,只是执行的时刻不对」「每天凌晨 0 点 8 分执行」「数据入库时间比预期早 8 小时」。看到这类描述,第一反应就该是时区,而不是任务逻辑。
资源问题分两个:内存和磁盘。
内存要看的是峰值,不是均值。 定时任务通常是瞬时的高消耗(跑压缩、跑导出、跑批量计算),均值看着很闲,峰值却在 OOM 边缘。 测法:
# 看任务运行期间的内存峰值
/usr/bin/time -v /usr/local/bin/myjob 2>&1 | grep -E "Maximum resident"
输出里的「Maximum resident set size」就是峰值。 判读标准:峰值不应该超过机器总内存的 70%。 超过 70% 说明一旦有并发(比如你同时手动跑了一个),就会触发 swap 甚至 OOM。更稳妥的门槛是 50%——留一半给系统和突发。
同时也要看 swap 有没有在被用:
free -m
vmstat 1 5
vmstat 里 si 和 so 两列(swap in/out)如果持续非零,说明机器已经在换页了。 这时候任务失败往往表现为「莫名其妙变慢然后被杀」,而不是清晰的报错。
磁盘要看的是增长趋势。 定时任务通常会产日志、写临时文件、存备份。最危险的情况不是磁盘已满,而是磁盘满了之后你才发现,因为那同时意味着所有定时任务都停了。 建议:
df -h / # 分区使用率
df -i / # inode 使用率(大量小文件时会先满)
du -sh /var/log/* # 找出哪个日志在涨
一条实用的阈值:给日志目录预留至少 20% 的空闲空间,并且对自定义日志目录单独配置 logrotate。 默认的 logrotate 规则通常只管 /var/log/syslog、/var/log/messages 这些系统日志,你自己写的 /var/log/myjob/*.log 不会自动轮转,会一直涨到把盘撑满。 手动加一条规则:
/var/log/myjob/*.log {
daily
rotate 14
compress
missingok
notifempty
copytruncate
}
copytruncate 很重要:它先复制再清空,而不是重命名。 因为进程持有的是原文件的文件描述符,如果只做 rename,进程会继续往已被改名的旧文件里写,新文件永远是空的。
并发问题:同一台机器上跑了多少个定时任务。 用 crontab -l 数一遍,再看 /etc/cron.d/。很多机器上有几十个任务,全都挤在同一个时间点执行。 比如所有任务都设在 0 3 * * *,那凌晨 3 点整会有几十个进程同时启动,磁盘 IO 和 CPU 瞬间打满,结果就是所有任务一起变慢甚至超时——看起来像「全都坏了」,其实是「撞车了」。
解法是错峰。 做法很简单:把不同任务分散到不同的分钟:
7 3 * * * task-a
23 3 * * * task-b
41 3 * * * task-c
原则是每个任务之间至少隔开 5 到 10 分钟。 这是一条零成本的改进,能解决掉一大类「莫名其妙集体失败」。
八、加固清单:把这些配置一次性做完

前面七节是排查,这节是加固。 加固的意义在于:下一次再出问题时,你要能第一时间区分是「机器的问题」还是「脚本的问题」。 加固做得好,排查就从小时级降到分钟级。
加固项一:所有环境变量显式声明。 写在脚本头部,标准模板:
#!/bin/bash
set -euo pipefail
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export HOME="/root"
export LANG="C.UTF-8"
export TZ="Asia/Shanghai"
cd /root/scripts
「显式声明」包括 TZ。 声明 TZ 能一次性消除时区歧义,是性价比最高的一行。
加固项二:所有路径用绝对路径。 包括脚本路径、配置路径、日志路径、临时目录。临时目录用 mktemp -d 而不是写死 /tmp/xxx:
WORKDIR=$(mktemp -d)
trap 'rm -rf "$WORKDIR"' EXIT
trap ... EXIT 保证无论脚本是正常结束还是被中断,临时目录都会被清理。 不用 trap 的脚本会在磁盘上留下大量孤儿目录,跑上几个月之后,磁盘满的原因就是这个。
加固项三:每个任务都有独立的日志目录和心跳文件。 目录结构建议:
/var/log/myjob/
├── heartbeat.log # 每次执行写一行
├── job.log # 详细日志
├── env.txt # 环境快照(出问题时看)
└── .env # 密钥等配置,权限 600
日志轮转一定要覆盖这个目录,用第七节给的 logrotate 规则。
加固项四:配置和密钥不写死在脚本里。 放在 .env 文件里,权限设成 600,只属于运行任务的这个用户。脚本里用 set -a 配合 . /root/scripts/.env 读入,set +a 关闭自动导出——这样比在脚本里写死 export API_TOKEN=xxx 安全得多。
加固项五:加锁。 用第五节的 flock 写法,保证同一任务不会并发。
加固项六:加重试和告警。 用第六节的重试循环 + 外部心跳告警。
加固项七:验证命令留档。 写一个 doctor.sh,把前面所有检查项串起来:
#!/bin/bash
echo "== 时区 =="; timedatectl | head -3
echo "== cron 状态 =="; systemctl is-active cron
echo "== 磁盘 =="; df -h / /var | tail -3
echo "== 内存 =="; free -m
echo "== swap 使用 =="; vmstat 1 2 | tail -1
echo "== 任务列表 =="; crontab -l
echo "== 心跳年龄 =="
now=$(date +%s); last=$(stat -c %Y /var/log/myjob/heartbeat.log 2>/dev/null || echo 0)
echo "$(( (now-last)/60 )) 分钟"
这三条命令是你排查时的第一个动作。 30 秒内就能排除掉大半粗故障。
加固项八:给任务留出资源余量。 这一节直接决定「选哪台机器」——下一节就来算这笔账。
九、算账:任务可靠性要花多少钱

前面的排查是技术问题,这一节是钱的问题。 一个跑定时任务的 VPS 该配多大、该多少钱月,这个账要算清楚。
先列出公开商品参数库里的真实机型数据(快照时间 2026-11-11 09:33,价格来自渠道库当日快照,以官网当日为准):
| 机型 | CPU | 内存 | 硬盘 | 带宽 | 月流量 | 计费 | 折合月价 | 每 GB 内存 |
|---|---|---|---|---|---|---|---|---|
| RosCloud-01 | 2 核 | 1GB | 20GB | 500Mbps | 1024GB | 月付 | 19.99 美元 | 19.99 美元 |
| RosCloud-02 | 3 核 | 2GB | 40GB | 500Mbps | 2048GB | 月付 | 29.99 美元 | 14.99 美元 |
| RosCloud-03 | 4 核 | 4GB | 80GB | 500Mbps | 3072GB | 月付 | 39.99 美元 | 10.00 美元 |
| RosCloud-04 | 5 核 | 8GB | 160GB | 500Mbps | 4096GB | 月付 | 49.99 美元 | 6.25 美元 |
| RosCloud-05 | 6 核 | 16GB | 240GB | 500Mbps | 5120GB | 月付 | 59.99 美元 | 3.75 美元 |
| RosCloud-06 | 7 核 | 24GB | 320GB | 500Mbps | 6144GB | 月付 | 69.99 美元 | 2.92 美元 |
| LAX-STARTER | 2 核 | 2GB | 40GB | 2000Mbps | 5000GB | 年付 | 16.67 美元 | 8.33 美元 |
| LAX-MICRO | 4 核 | 4GB | 80GB | 4000Mbps | 10000GB | 年付 | 33.33 美元 | 8.33 美元 |
| LAX-MEDIUM | 8 核 | 8GB | 160GB | 10000Mbps | 20000GB | 年付 | 66.67 美元 | 8.33 美元 |
注意 LAX 那一组是年付,表中是折算后的月价(年价除以 12)。 这一点在做账时必须算清楚,否则年付机型会被误当成月付机型来比较。
第一个账:任务跑多久,要多少钱。
定时任务有个特点:它大部分时间在睡觉,只有很小一部分时间在工作。 所以它的成本主要不是「算力」,而是「占着的内存」。
假设你的任务每 15 分钟跑一次,每次跑 30 秒,占用 200MB 内存。 那它一天实际工作 96 次乘 30 秒,等于 48 分钟;一个月按 30 天算是 24 小时。也就是说,一个月里它只用了 24 小时的算力,占一个月 720 小时的 3.3%。
按这个比例算,一个 2GB 机器完全够用。 但如果你买的是 1GB 机器:任务峰值 200MB 加上操作系统和 shell 大约 300MB,总共 500MB,在 1024MB 里占了 49%,已经在第七节说的 70% 红线附近了,而且这里还没算并发重叠时的峰值。
所以第一个结论:一台 1GB 的机器跑定时任务是有风险的,2GB 是稳妥的下限。 这不是因为任务吃内存,而是因为任务和机器上的其他东西共享内存。
第二个账:月付还是年付。
把表里的数字对齐看:
2GB 内存这一档,月付的 RosCloud-02 是 29.99 美元,每 GB 内存 14.99 美元;年付的 LAX-STARTER 折合 16.67 美元,每 GB 内存 8.33 美元。 差价是 13.32 美元/月,一年 159.84 美元。
这个差距的来源有两个,都很实在:
一是年付本身有折扣。 LAX-STARTER 是 200 美元年付,折合 16.67 美元/月;RosCloud-02 是 29.99 美元月付。同类配置年付便宜 44%。
二是两者的带宽和流量差很多。 LAX-STARTER 是 2000Mbps 带宽、5000GB 月流量;RosCloud-02 是 500Mbps、2048GB。如果你的任务需要拉数据(备份要传出去、抓取要下载),流量这一栏才是决定性的。
第三个账:任务失败一次,值多少钱。
这是最容易被忽略、但最能体现可靠性价值的一个账。
假设你的定时任务是一天一次的数据库备份。 按 1GB 机器 19.99 美元/月算,一天的钱是 0.66 美元。 如果备份任务静默失败了两周,你会在第 15 天才发现——这 15 天的备份全部缺失,机器的空转成本是 10 美元。
但真正的损失不是那 10 美元。 如果这期间数据库损坏,你要恢复的是业务数据。恢复成本和机器月租根本不在一个量级。
所以结论是:在「任务失败会造成业务损失」的场景下,机器的月租不是决策依据,「多久能发现失败」才是。 对应的做法就是第六节的心跳告警——把发现时间从 15 天压到 5 分钟,这个价值远高于机器差价。
回到第七节的判据:任务内存峰值不超过总内存的 70%。 用表里的数字算:
| 机器 | 总内存 | 70% 上限 | 任务峰值 200MB 占用 | 任务峰值 500MB 占用 |
|---|---|---|---|---|
| RosCloud-01 | 1GB | 716MB | 28% | 70% |
| RosCloud-02 | 2GB | 1434MB | 14% | 35% |
| RosCloud-03 | 4GB | 2867MB | 7% | 18% |
如果任务峰值是 500MB,1GB 机器正好卡在 70% 红线上,一有并发就爆。2GB 机器是 35%,4GB 是 18%。
从 1GB 到 2GB 是 10 美元/月(19.99 到 29.99),从 2GB 到 4GB 是 10 美元/月(29.99 到 39.99)。 注意 RosCloud 这一组的每档增量都是 10 美元/月,很规整。
所以:如果你要跑的是峰值 500MB 的任务,选 RosCloud-02(29.99 美元/月)刚好;如果要跑峰值 1GB 以上的任务(比如做视频转码、大批量数据导出),直接上 RosCloud-04(49.99 美元/月,8GB)或 RosCloud-05(59.99 美元/月,16GB)。
注意 RosCloud 的每 GB 内存成本从 19.99 一路降到 2.92,8GB 档的 6.25 美元只比 1GB 档的 19.99 美元低不到三分之二,但绝对金额已经进入了「加 10 美元多给 7GB 内存」的区间。 换句话说,小机器的固定成本(系统、数据库、业务进程)占的比例高,所以小内存档的增量性价比反而最差。
按你的情况,下一步该看哪家
上面讲的是判断标准,但多数人最后还是想问一句「那我该买谁」。按你的实际情况对照下面几条,直接去看对应的机型页比继续比价更快:
- 只是开发测试、临时用:按小时或月付,用完就删,别买年付。看 iwebfusion 当前机型与价格
价格与库存以商家页面当日显示为准,下单前建议先确认退款窗口与是否支持按月付费。
十、什么情况下不该用定时任务

这一节是本文的「缺陷」部分,写的都是「不该选定时任务」的场景。 依据只涉及任务性质和资源模型,不涉及任何商家的好坏评价。
不该用定时任务的场景一:需要精确到秒级的调度。 cron 的最小粒度是分钟,而且实际触发时间取决于系统负载,常见抖动在几秒到几十秒。 如果你的业务要求「任务必须在请求到达后 200 毫秒内启动」,那定时任务在架构上就不适用。替代方案:改成事件驱动——用队列消费者、消息订阅或者 Webhook 回调。
不该用定时任务的场景二:任务需要读取自己上一轮的输出,且必须严格串行。 定时任务本质上是「尽力而为地按时启动」,它不保证上一轮完成。 你可以用锁强制串行,但那样如果上一轮卡住,后续任务全部积压。替代方案:用工作流引擎(带状态机、重试、补偿的那种),或者把链条改成事件驱动。
不该用定时任务的场景三:任务的正确性依赖「恰好只执行一次」。 典型的例子是「给用户发一次通知」「扣一次款」。定时任务在重试、超时、机器重启的场景下可能执行多次或零次,所以业务逻辑必须做成幂等的。 做不到幂等,就该用带幂等键的队列,而不是 cron。
不该用定时任务的场景四:机器资源本身不稳定,且任务失败的业务代价很高。 依据是公开事实:1GB 内存的机器(RosCloud-01,19.99 美元/月)在内存峰值 500MB 的任务下已经占 70%,任何并发或突发都会触发 swap 或 OOM。如果你的任务失败一次就损失数据,那就不该把它放在资源紧张的机器上。 替代方案:升到 2GB 档(RosCloud-02,29.99 美元/月),或者把备份任务迁到存储带宽型机型——LAX-Storage-500G 是 4GB 内存、500GB 硬盘、10000Mbps 带宽、10000GB 流量,折合 33.33 美元/月,每 GB 硬盘只要 0.067 美元,这个机型的定位就是「给磁盘和带宽花大钱、内存够用就行」,正好对上备份场景。
不该用定时任务的场景五:任务需要长期运行而不是周期性启动。 如果你的「任务」本质上是个常驻服务(爬虫、消息消费循环),用 cron 每分钟拉起一次是反模式——正确做法是 systemd 的 Restart=always 加上 Type=simple。
不该用定时任务的场景六:你在排查「任务不跑」,但根本原因不在 cron。 具体来说:机器被服务商迁移过(IP 变了、DNS 记录还指着旧 IP)、系统重装后 crontab 被清空、容器重建后 /etc/cron.d 里的文件是临时挂载的(容器里 cron 本身就不可靠,重启即丢)。如果你的任务跑在容器里,用编排平台的定时任务(Kubernetes CronJob、云厂商的定时任务功能)比用容器内的 cron 靠谱得多。
十一、四类人该怎么做与买什么

这一节给具体结论,不写「视情况而定」。 每一类直接给出方案和对应机型。
第一类:个人站长、只有一两个简单任务(比如每天备份数据库、每小时同步一次文章)。 方案:用 cron 就够,不需要 systemd timer,也不需要买专门的机器。 关键配置是三样:脚本头部显式 export PATH、用 env -i /bin/bash -c '脚本路径' 验证空环境能跑、加 >> ... 2>&1 让日志落到文件。
机型选 RosCloud-02(2GB、3 核、40GB 硬盘、500Mbps、2048GB 流量,29.99 美元/月,每 GB 内存 14.99 美元)。 理由很直接:2GB 内存对普通定时任务有充足余量(任务峰值 200MB 只占 14%),40GB 硬盘够放日志和临时文件,29.99 美元是月付里可以随时撤掉的价格。这一类不要买年付——你的需求还在变,而且月付的另一个好处是出问题可以直接换机器,不用担心退款条款。
第二类:做业务系统的团队,任务涉及数据库备份、数据同步、报表生成,失败会造成实际损失。 方案:上 systemd timer,用 Restart=on-failure 配 StartLimitBurst=5,加外部心跳告警,任务用幂等设计。
机型选 LAX-MICRO(4 核、4GB、80GB 硬盘、4000Mbps、10000GB 流量,年付 400 美元,折合 33.33 美元/月,每 GB 内存 8.33 美元)。 理由有三条,都基于公开参数:一是 4GB 内存跑数据库加定时任务,任务峰值 500MB 只占 18%,非常安全;二是 10000GB 月流量对备份上传足够——按每天备份一次、每次 10GB 算,一个月是 300GB,流量有 30 倍余量;三是 4000Mbps 的带宽让备份上传不占用业务带宽。年付的理由是这类场景稳定,33.33 美元/月比同配置的月付 RosCloud-03(39.99 美元)还低 6.66 美元,一年省 79.92 美元。
第三类:跑批处理、ETL、数据加工,任务本身吃 CPU 和内存,运行时间以小时计。 方案:定时任务只负责「拉起」,实际计算放到受控的 worker 上,并且务必配 flock 防止重叠——批处理任务是最容易撞车的一类。内存峰值必须实测出来,用 /usr/bin/time -v 测,再按第七节的 70% 红线核对。
机型选 RosCloud-05(6 核、16GB、240GB、500Mbps、5120GB 流量,59.99 美元/月,每 GB 内存 3.75 美元)。 理由:16GB 内存按 70% 算有 11.2GB 的任务空间,足够跑 1GB 峰值的批处理加上中间数据;6 核能并行跑多个任务分支。 如果批处理需要大量临时磁盘,把任务目录放到 LAX-Storage-500G 那类存储型机器上,每 GB 硬盘 0.067 美元是全表最低的硬盘单价。**
第四类:把定时任务当产品卖(比如提供定时抓取、监控告警这类 SaaS 服务)给外部客户用的。 方案:关键不是机器,是「任务隔离」——每个客户的定时任务要能独立失败、独立重试、独立告警,不能因为一个客户的任务卡住就影响其他人。 实现上要用队列(每个任务一条消息)而不是 cron,cron 只做「投递到队列」这一件事。
机型选 RosCloud-04(5 核、8GB、160GB、500Mbps、4096GB 流量,49.99 美元/月,每 GB 内存 6.25 美元)起步,随着客户数增加升到 RosCloud-06(7 核、24GB、320GB、6144GB 流量,69.99 美元/月,每 GB 内存 2.92 美元)。 理由:每 GB 内存 2.92 美元是这张表里的最低值,也就是说你把客户堆得越密,单位内存越便宜;但外部客户的任务峰值不可控,内存要留够——24GB 档按 70% 算有 16.8GB 的任务空间,能扛住几十个并发任务的峰值叠加。
最后补一句所有类型通用的:不管选哪台,机器上都要装外部监控。 因为「机器挂了」这件事,只有外部探测才能发现——机器自己发不出告警。 这是本文所有配置里唯一一条不能省钱的。
十二、完整排查流程与检查清单

把全文压缩成可以照着走的顺序。
第 1 步:跑 doctor.sh(或第八节的那一串命令),30 秒内拿到时区、cron 状态、磁盘、内存、swap、心跳年龄六个数据。
第 2 步:确认调度记录。 grep CRON /var/log/syslog | tail -30 或 journalctl -u cron。没有记录说明 cron 没加载任务;有记录但没有心跳说明脚本拉起后立刻死了。
第 3 步:确认环境。 执行 env -i /bin/bash -c '/你的脚本路径',这条命令失败就是 PATH/环境问题,跟 cron 无关。
第 4 步:确认锁和重叠。 flock -n 9 检查,或者看日志里有没有「上一轮还在跑」。同时核对计划间隔是否 ≥ 最坏执行时长 × 3。
第 5 步:确认时区。 date; date -u; TZ=Asia/Shanghai date 三个数字对齐。
第 6 步:确认资源。 dmesg -T | grep -i killed 看有没有 OOM;vmstat 1 5 看 swap 有没有在动;df -h 和 df -i 看磁盘和 inode。
第 7 步:改配置再验证。 按第八节的加固清单改完,重启服务,再手动触发一次,确认心跳更新。
最后是把验收标准写成数字,以后每次改动都按这个标准复核:
验收一:任务心跳间隔不超过计划间隔的 1.5 倍。 超了就说明有轮次没跑成功。
验收二:任务内存峰值不超过总内存的 70%。 超了就换机器或者给任务限内存。
验收三:env -i 空环境执行能通过。 这是 cron 环境的真实模拟。
验收四:日志目录剩余空间大于 20%,且 logrotate 规则覆盖自定义目录。
验收五:失败告警的通道是你每天真的会打开的。 不是「有配置」,是「你会看」。
验收六:所有任务的计划时刻两两间隔至少 5 分钟。
把这六条贴在服务器的运维文档里,比任何一篇排查教程都管用——因为下次出问题的人不一定是你,而这份文档能把他的排查时间从小时级压到分钟级。

喜欢
高兴
鬼脸
呵呵
无聊
伤心