VPS 上定时任务不执行,最容易让人误判。
同一条命令你在 SSH 里手动执行没问题,放进 crontab 后就是不跑;脚本单独运行正常,凌晨自动备份却没有文件;systemctl status 看起来也没红,但业务侧就是没收到结果。多数情况下,这不是“Linux 定时任务玄学”,而是执行环境和你手动登录时完全不同。
这篇按排查顺序讲:先确认 cron 或 systemd timer 是否真的在跑,再看日志、用户、路径、权限、环境变量和时区,最后再处理脚本本身的幂等和告警。
不要一开始就改 crontab。先把问题分成两类:
- 完全没触发:日志里没有任务执行记录,通常是 cron 服务、语法、用户或 timer 没启用的问题。
- 触发了但失败:日志里有记录,但脚本报错、权限不足、命令找不到、输出文件没生成。
- 执行了但结果不符合预期:比如备份文件是空的、同步到对象存储失败、重启脚本重复跑。
先看系统日志会省很多时间。如果你还不熟悉 journalctl 和服务日志,可以先看这篇:VPS 日志怎么看(journalctl、systemctl、Nginx、应用日志)。
Debian / Ubuntu 上通常是 cron,RHEL / CentOS / Rocky 上通常是 crond。
systemctl status cron
systemctl status crond
如果服务没启动:
sudo systemctl enable --now cron
或者:
sudo systemctl enable --now crond
再看最近日志:
journalctl -u cron --since "2 hours ago"
journalctl -u crond --since "2 hours ago"
如果系统里没有对应服务,先确认 cron 包有没有安装:
dpkg -l | grep cron
rpm -qa | grep cron
有些极简 VPS 镜像不会预装 cron,尤其是容器化镜像或非常干净的 cloud image。不要默认以为它一定存在。
这是最常见的坑之一。
你用普通用户执行:
crontab -e
修改的是当前用户的计划任务。你用 root 执行:
sudo crontab -e
修改的是 root 的计划任务。两者不是同一个文件。
检查当前用户任务:
crontab -l
检查 root 任务:
sudo crontab -l
如果脚本需要访问 /root/.ssh、读取只有 root 可读的备份目录、重启服务,通常要放在 root crontab 或改成 systemd timer。反过来,如果脚本依赖某个普通用户的项目目录、Python virtualenv、Node 版本管理器,就不要随手放到 root crontab 里。
先加一个只写日志的任务,不要直接调复杂脚本:
* * * * * date >> /tmp/cron-test.log 2>&1
等 1-2 分钟后检查:
cat /tmp/cron-test.log
如果这个都没有输出,问题在 cron 服务、crontab 文件或系统时间上。
如果这个能输出,而你的脚本不能跑,问题大概率在脚本路径、权限、环境变量或运行用户。
测试完成后记得删掉这条任务,避免长期写 /tmp。
用户 crontab 是 5 个时间字段加命令:
分 时 日 月 周 命令
例如每天凌晨 3:10 执行:
10 3 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
但 /etc/crontab 和 /etc/cron.d/ 里的文件多一个“用户”字段:
10 3 * * * root /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
很多人把用户 crontab 的写法复制到 /etc/cron.d/,或者反过来复制,就会导致任务不按预期执行。
还要注意几点:
- crontab 文件末尾最好保留换行。
/etc/cron.d/里的文件名不要带奇怪后缀,权限通常用644。- 命令里的
%在 cron 中有特殊含义,日期格式如date +%F需要写成date +\%F。
你在 SSH 里能执行:
node app.js
python script.py
rclone sync ...
不代表 cron 里也能找到这些命令。cron 的环境很干净,PATH 往往只有很短一段。
先查命令完整路径:
which node
which python3
which rclone
which mysqldump
在 crontab 里写绝对路径:
10 3 * * * /usr/bin/python3 /opt/jobs/cleanup.py >> /var/log/cleanup.log 2>&1
或者在 crontab 顶部显式设置 PATH:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
如果你使用 nvm、pyenv、conda 这类用户级环境,cron 默认不会加载它们。更稳的做法是写一个 wrapper 脚本,在脚本里加载环境,再执行真实命令。
手动执行时,你可能站在项目目录里:
cd /var/www/app
npm run task
cron 执行时的工作目录不一定是这里。脚本里如果用了相对路径,例如 ./backup、.env、logs/app.log,就很容易失败。
建议把任务写成:
15 2 * * * cd /var/www/app && /usr/bin/npm run task >> /var/log/app-task.log 2>&1
如果脚本重要,最好在脚本开头固定目录:
#!/usr/bin/env bash
set -euo pipefail
cd /var/www/app
然后 crontab 调这个脚本:
15 2 * * * /usr/local/bin/app-task.sh >> /var/log/app-task.log 2>&1
脚本没有执行权限时,cron 日志里可能只留下一句 Permission denied。
检查:
ls -l /usr/local/bin/app-task.sh
修复:
sudo chmod +x /usr/local/bin/app-task.sh
如果任务要写入备份目录、日志目录或上传临时文件,也要确认运行用户有权限:
sudo -u www-data /usr/local/bin/app-task.sh
sudo -u backup /usr/local/bin/backup.sh
用目标用户手动跑一遍,比你用 root 跑一遍更有参考价值。
没有日志的定时任务,排查成本会翻倍。
至少这样写:
30 1 * * * /usr/local/bin/backup.sh >> /var/log/backup-cron.log 2>&1
如果日志会长期增长,配合 logrotate,或者让脚本自己按日期写日志。不要让定时任务安静失败。
也可以在脚本开头加:
set -euo pipefail
这样变量未定义、管道失败、命令失败时会更早暴露。只是线上脚本加这行前要先测试,避免把原本容忍失败的步骤变成硬失败。
VPS 镜像默认时区经常是 UTC。你以为每天凌晨 2 点执行,实际是北京时间上午 10 点执行。
检查时区:
timedatectl
如果需要改成上海时区:
sudo timedatectl set-timezone Asia/Shanghai
但我更建议:生产任务统一按 UTC 规划,文档里写清楚换算。这样迁移到不同地区 VPS 时不容易混乱。
如果你的任务和证书续期、备份窗口、数据库低峰期有关,时区尤其重要。证书续期可参考:VPS 上 Let’s Encrypt 证书续期失败怎么办。
如果你用的是 systemd timer,不要只看 .timer。
查看 timer:
systemctl list-timers --all
systemctl status my-job.timer
查看实际执行的 service:
systemctl status my-job.service
journalctl -u my-job.service --since "24 hours ago"
一个 timer 只是负责触发,真正执行的是同名或指定的 service。常见问题包括:
- 只启用了
.service,没有启用.timer。 .timer触发了,但.service执行失败。OnCalendar写错,下一次触发时间不是你以为的时间。- service 里没有设置
WorkingDirectory,导致相对路径失效。 - service 运行用户和手动测试用户不同。
一个更稳的 service 示例:
[Unit]
Description=Daily app cleanup job
[Service]
Type=oneshot
User=www-data
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/php artisan app:cleanup
对应 timer:
[Unit]
Description=Run app cleanup daily
[Timer]
OnCalendar=*-*-* 03:20:00
Persistent=true
[Install]
WantedBy=timers.target
启用:
sudo systemctl daemon-reload
sudo systemctl enable --now app-cleanup.timer
Persistent=true 的作用是:机器关机错过执行时间后,下次开机会补跑一次。对备份、清理、同步任务很有用,但对“发通知”“扣费”“批量重启”这类任务要谨慎,避免恢复后集中触发。
定时任务最怕两种情况:一次没跑,或者同时跑两次。
例如备份脚本本来 20 分钟结束,某天数据库变大跑了 90 分钟,而 cron 每小时触发一次,就会出现两个备份进程互相抢磁盘和网络。
可以用 flock 加锁:
0 * * * * /usr/bin/flock -n /tmp/backup.lock /usr/local/bin/backup.sh >> /var/log/backup-cron.log 2>&1
如果上一次还没结束,下一次会直接跳过。
对于数据库备份、对象存储同步、批量压缩,最好都加锁。备份策略本身可以参考:VPS 自动备份方案。
如果你的任务跑在 Docker 容器里,宿主机 cron 和容器内 cron 是两套东西。
常见误区:
- 在宿主机
crontab -l看不到容器内任务。 - 容器重启后,容器内 cron 没有启动。
- 容器镜像没有 cron 包。
- 任务写入容器临时文件系统,容器重建后文件丢失。
- 容器内时区和宿主机不一致。
更推荐的做法是:
- 简单任务:宿主机 cron 调
docker exec。 - 应用框架任务:用框架自带 scheduler,但确保进程常驻。
- 可观测性要求高的任务:用 systemd timer 或独立 worker,而不是塞进一个临时容器。
遇到“定时任务没执行”,按这个顺序查:
systemctl status cron或systemctl status crond。crontab -l和sudo crontab -l,确认用户没搞错。- 加
* * * * * date >> /tmp/cron-test.log 2>&1做最小触发测试。 - 检查 cron 日志或
journalctl -u cron。 - 把命令改成绝对路径。
- 明确
cd /path/to/project或脚本内设置工作目录。 - 检查脚本可执行权限和目标用户权限。
- 把输出写到日志:
>> /var/log/job.log 2>&1。 - 检查
timedatectl和任务计划时间。 - 如果是 systemd timer,同时检查
.timer和.service。 - 长任务加
flock,避免重叠执行。
VPS 定时任务不执行,真正的问题通常不在“时间表达式”,而在运行环境:用户不同、PATH 不同、目录不同、权限不同、时区不同。
先用最小任务证明调度器能触发,再把真实命令拆成“路径、用户、目录、权限、日志”几个点逐个确认。只要日志可见、路径写绝对、用户明确,大多数 cron 和 systemd timer 问题都能很快定位。
