Linux服务器如何配置定时任务?Crontab使用方法详解

定时备份、日志清理、证书续期、数据同步、报表生成,这些事情都需要"在固定时间自动跑一次"。Linux 上最传统也最通用的方案是 cron,几乎每台机器都自带,一条配置就能让脚本按时执行。但 cron 也是坑最多的工具之一:脚本手工执行没问题,放进 crontab 就失败,十次有九次是因为环境变量。

这篇文章讲清楚 crontab 的五段时间字段语法、@reboot 这类特殊字符串、环境变量 PATH 陷阱的正确处理方式、输出重定向与日志查看,并给出备份和清理的可直接用实例。最后会介绍 systemd timer 作为现代替代方案,以及如何判断什么时候该换过去。

cron 是怎么工作的

cron 由一个后台守护进程 crond(RHEL 系)或 cron(Debian、Ubuntu)驱动,它每分钟唤醒一次,检查所有 crontab 配置和系统级任务目录,判断当前分钟是否有需要执行的任务,有就 fork 子进程执行。

由此可以得出两个重要推论。第一,cron 的最小粒度是分钟,做不到秒级;需要秒级调度要用循环脚本、systemd timer 或者专门的任务队列。第二,任务执行时的环境与你的登录 shell 完全不同,这是后面所有"脚本手工能跑、cron 里跑不了"问题的根源。

确认服务状态:

# RHEL、Rocky、CentOS
systemctl status crond --no-pager

# Debian、Ubuntu
systemctl status cron --no-pager

# 查看执行记录
# RHEL、Rocky、CentOS
sudo grep CROND /var/log/cron | tail -n 20
# Debian、Ubuntu
sudo grep CRON /var/log/syslog | tail -n 20
# 通用
sudo journalctl -u crond --since '-2 hours' --no-pager
sudo journalctl -u cron --since '-2 hours' --no-pager

每个用户有自己的 crontab,用 crontab -e 编辑的是当前用户的,任务以该用户身份执行。想用 root 身份执行就 sudo crontab -e。不要用 root 身份去编辑普通用户的 crontab,权限混乱会带来隐患。

五个时间字段的语法

一条 crontab 任务的基本格式是五个时间字段加上要执行的命令:

┌───────────── 分钟 (0 - 59)
│ ┌─────────── 小时 (0 - 23)
│ │ ┌───────── 日 (1 - 31)
│ │ │ ┌─────── 月 (1 - 12)
│ │ │ │ ┌───── 星期 (0 - 7,0 和 7 都是周日)
│ │ │ │ │
* * * * *  要执行的命令
字段 范围 说明
分钟 0-59 每小时的第几分钟
小时 0-23 24 小时制
日 1-31 每月第几天
月 1-12 也可用 jan-dec
星期 0-7 0 和 7 都表示周日,也可用 sun-sat

特殊字符:

字符 含义 示例
* 每个可能的值 * * * * * 每分钟
, 列举多个值 0 3,15 * * * 每天 3 点和 15 点
- 范围 0 9-18 * * * 每天 9 到 18 点整
/ 步长 */10 * * * * 每 10 分钟
L、W、# 部分实现支持 非标准,慎用

日和星期的关系是"或"而不是"且"。 这是个高频误解。如果写成 0 0 1 * 1,意思是"每月 1 号或者每周一",两个条件满足其一就执行,不是"1 号且是周一"。很多 cron 实现支持在字段前加 ?,但 GNU crontab 并不支持,写 ? 会报错。

常用示例:

*/5 * * * *   /usr/local/bin/check.sh            # 每 5 分钟
0 * * * *     /usr/local/bin/hourly.sh           # 每小时整点
30 2 * * *    /usr/local/bin/backup.sh           # 每天 2:30
0 3 * * 0     /usr/local/bin/weekly.sh           # 每周日 3:00
0 4 1 * *     /usr/local/bin/monthly.sh          # 每月 1 号 4:00
0 9-18 * * 1-5 /usr/local/bin/workhour.sh        # 工作日 9 到 18 点整点

注意小时字段写 9-18/2 表示 9、11、13、15、17 点,步长和范围可以组合。

特殊字符串:@reboot 等

crontab 支持几个简写,可读性好,也不用记五个字段的顺序:

特殊字符串 等价于 说明
@reboot 无 系统启动时执行一次
@yearly / @annually 0 0 1 1 * 每年 1 月 1 日零点
@monthly 0 0 1 * * 每月 1 日零点
@weekly 0 0 * * 0 每周日零点
@daily / @midnight 0 0 * * * 每天零点
@hourly 0 * * * * 每小时整点

@reboot 很实用,比如启动时拉起某个脚本、初始化目录、注册服务。但它有个重要限制:@reboot 在 cron 守护进程启动时触发,此时网络、磁盘挂载、数据库可能还没就绪。如果脚本依赖这些资源,很可能失败,而且失败后不会重试。

@reboot /usr/local/bin/init-cache.sh

对需要依赖网络或挂载点的场景,@reboot 不是好选择,应该用 systemd 服务,通过 After=network-online.target、RequiresMountsFor= 明确依赖顺序。这是 @reboot 和 systemd 的核心差距之一。

环境变量陷阱:最大的坑

这是全文最重要的一节。cron 执行任务时使用的是一个极简环境,典型情况是:

SHELL=/bin/sh
PATH=/usr/bin:/bin
HOME=/home/user

对比你在终端里的 PATH,通常包含 /usr/local/bin、/usr/sbin、/sbin,以及 Python、Node、Go 的自定义路径。这就是为什么脚本手工能跑、cron 里却报 command not found。

正确做法有四条,按推荐顺序:

一、所有命令写绝对路径。 这是最稳妥的方式:

# 先查出真实路径
which python3 mysqldump rsync tar
command -v node

# 然后写死
0 2 * * * /usr/bin/python3 /opt/scripts/report.py

二、在 crontab 里显式声明 PATH。 写在任务列表之前:

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=ops@example.com

0 2 * * * /opt/scripts/backup.sh

这样该文件里所有任务都继承这个 PATH。

三、在脚本内部设置环境变量。 适合脚本需要特定运行时环境的场景:

#!/bin/bash
export PATH=/usr/local/bin:/usr/bin:/bin
export LANG=C.UTF-8
source /opt/venv/bin/activate
cd /opt/app || exit 1
python3 main.py

四、用 bash -lc 加载登录 shell 环境。 这条能拿到完整的登录环境,但也带来了环境不确定性,一般不作为首选:

0 2 * * * /bin/bash -lc '/opt/scripts/backup.sh'

除了 PATH,还有几个环境相关的问题要注意。cron 的默认 SHELL 是 /bin/sh,如果你的脚本用了 bash 特性([[ ]]、数组、source),必须在脚本首行写 #!/bin/bash 并在 crontab 里设 SHELL=/bin/bash,否则会报奇怪的语法错误。

工作目录问题: cron 执行时的工作目录是当前用户的家目录,不是脚本所在目录。脚本里用到相对路径就会找不到文件。解决办法是在脚本开头 cd 到目标目录,或者全部用绝对路径。

% 符号需要转义: 在 crontab 中 % 表示换行,会被解释为命令的标准输入分隔符。使用 date +%F 时必须写成 date +\%F:

0 2 * * * /opt/scripts/backup.sh > /var/log/backup-$(date +\%F).log 2>&1

这个坑非常常见,表现为文件名只剩一半或者命令行为异常。

权限与 MAILTO: cron 默认会把任务输出通过邮件发给用户,若没配邮件系统,输出就丢了。用 MAILTO="" 可禁用,用 MAILTO=邮箱 指定接收者。排查阶段建议先重定向到文件,别依赖邮件。

输出重定向与日志

每条任务都应该明确处理输出,否则标准输出和错误输出会尝试发邮件,堆积在 /var/spool/mail 里。

# 标准做法:标准输出和错误都追加到日志
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

# 丢弃所有输出(确认任务无需留痕时才用)
*/5 * * * * /opt/scripts/ping.sh >/dev/null 2>&1

# 只保留错误
0 3 * * * /opt/scripts/sync.sh >/dev/null 2>>/var/log/sync-error.log

# 按日期分文件
0 1 * * * /opt/scripts/clean.sh >> /var/log/clean-$(date +\%Y\%m).log 2>&1

建议每个脚本自带时间戳日志,排查时一目了然:

#!/bin/bash
LOG=/var/log/backup.log
{
  echo "===== $(date '+%F %T') 开始 ====="
  /usr/bin/rsync -a --delete /data/ /backup/data/
  echo "退出码: $?"
  echo "===== $(date '+%F %T') 结束 ====="
} >> "$LOG" 2>&1

日志文件本身也要轮转,否则几个月后会撑爆磁盘。在 /etc/logrotate.d/ 下加一个配置:

/var/log/backup.log {
    daily
    rotate 14
    compress
    missingok
    notifempty
    copytruncate
}

copytruncate 适合无法重新打开日志文件的脚本场景,它会先复制再截断原文件,缺点是可能丢失极小窗口内的写入。

系统级 crontab 与目录

除了用户 crontab,还有几处系统级配置:

位置 用途 格式
/etc/crontab 系统主配置 多一个用户字段
/etc/cron.d/ 软件包放置任务的目录 同 /etc/crontab,需含用户字段
/etc/cron.hourly/ 每小时执行 直接放可执行脚本
/etc/cron.daily/ 等 每天、每周、每月 同上

/etc/crontab 的格式比用户 crontab 多一列用户:

# 分 时 日 月 周  用户           命令
0  2  *  *  *     root          /opt/scripts/backup.sh

放脚本到 /etc/cron.daily/ 时,文件名不能带点号(不能有 .sh 后缀),否则 run-parts 会忽略它;脚本必须有执行权限 chmod +x。

权限控制:

# 只允许指定用户使用 crontab
sudo vi /etc/cron.allow
# 禁止指定用户
sudo vi /etc/cron.deny

cron.allow 存在时只有名单内用户可用,cron.deny 只在 cron.allow 不存在时生效。root 通常不受限制。给不需要定时任务的账号(尤其是服务账号如 nginx、www-data)加入 cron.deny,是减少攻击面的低成本措施。

实战实例

实例一:每天凌晨全量备份网站与数据库,保留七天。

sudo mkdir -p /opt/scripts /var/log/scripts
sudo vi /opt/scripts/backup.sh
#!/bin/bash
set -euo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

DATE=$(date +%F)
BACKUP_DIR=/data/backup
LOG=/var/log/scripts/backup.log
KEEP_DAYS=7

mkdir -p "$BACKUP_DIR"

{
  echo "===== $DATE $(date +%T) 备份开始 ====="

  # 数据库备份,不要把密码写在命令行里,用配置文件
  /usr/bin/mysqldump --defaults-file=/etc/mysql/backup.cnf \
    --single-transaction --quick dbname > "$BACKUP_DIR/db-$DATE.sql"

  # 网站目录增量打包
  /usr/bin/tar -czf "$BACKUP_DIR/www-$DATE.tar.gz" /var/www/html

  # 清理旧备份
  /usr/bin/find "$BACKUP_DIR" -type f -name '*.sql' -mtime +$KEEP_DAYS -print -delete
  /usr/bin/find "$BACKUP_DIR" -type f -name '*.tar.gz' -mtime +$KEEP_DAYS -print -delete

  echo "===== $DATE $(date +%T) 备份完成 ====="
  /usr/bin/df -h "$BACKUP_DIR"
} >> "$LOG" 2>&1
sudo chmod 700 /opt/scripts/backup.sh
sudo /opt/scripts/backup.sh            # 先手工跑一遍验证
sudo crontab -e
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

30 2 * * * /opt/scripts/backup.sh

数据库密码应放在权限 600 的配置文件里(如 /etc/mysql/backup.cnf),而不是命令行参数,因为命令行参数会出现在 ps 输出和历史记录中。

实例二:清理过期日志与临时文件。

SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

# 每天凌晨清理 30 天前的应用日志
0 3 * * * /usr/bin/find /var/log/app -type f -name '*.log' -mtime +30 -print -delete

# 每小时清理 1 小时前的临时上传文件
10 * * * * /usr/bin/find /var/www/uploads/tmp -type f -mmin +60 -print -delete

# 每周一清空一次 PHP 会话文件
0 4 * * 1 /usr/bin/find /var/lib/php/sessions -type f -mtime +1 -print -delete

删除前建议先用 -print 观察几天,确认清单符合预期。

实例三:每五分钟检查服务存活并自动拉起。

#!/bin/bash
if ! /usr/bin/systemctl is-active --quiet nginx; then
    echo "$(date '+%F %T') nginx 未运行,尝试启动" >> /var/log/scripts/watchdog.log
    /usr/bin/systemctl start nginx
fi
*/5 * * * * /opt/scripts/watchdog.sh

不过更好的做法是用 systemd 的 Restart=on-failure,它响应更快、有重启频率控制,也有完整的日志。cron 轮询有最多五分钟的延迟,作为兜底可以,作为主力不合适。

systemd timer:现代替代方案

systemd timer 由一对文件组成:一个 .timer 定义时间,一个 .service 定义任务。

sudo vi /etc/systemd/system/backup.service
[Unit]
Description=每日数据备份
After=network-online.target

[Service]
Type=oneshot
User=root
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/opt/scripts/backup.sh
sudo vi /etc/systemd/system/backup.timer
[Unit]
Description=每天 2:30 执行备份

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers --all
journalctl -u backup.service --since today --no-pager

几个关键参数:OnCalendar 用 年-月-日 时:分:秒 格式,支持 Mon、*:0/15 这类写法;Persistent=true 表示如果机器在计划时间关机了,启动后会补执行一次,这是 cron 做不到的;RandomizedDelaySec 让执行时间随机延后,避免多台机器同时执行同一任务造成存储和网络尖峰,这个特性在批量部署时非常有用。

两者的对比:

维度 crontab systemd timer
语法 五字段,学习成本低 需写两个 unit 文件
执行环境 极简,需自行处理 可用 Environment= 明确声明
日志 靠重定向,分散 统一进 journald,journalctl -u 查看
错过补执行 不支持 Persistent=true 支持
依赖顺序 不支持 After=、Requires= 精确控制
资源限制 无 支持 CPUQuota、MemoryMax
错峰执行 需自己实现 RandomizedDelaySec 内置
秒级精度 不支持 支持 AccuracySec 到秒

选型建议:简单的一次性脚本用 crontab 足够,它零配置成本;需要可靠执行、补执行、日志统一收集、资源限制或依赖控制的场景(尤其是备份、证书续期这类重要任务),用 systemd timer 更合适。迁移成本不高,值得在关键任务上先换过去。

常见问题

脚本手工执行正常,放进 crontab 就不执行,怎么办?

按顺序查这五点:命令是否用了绝对路径;PATH 是否在 crontab 里声明;脚本是否有执行权限且首行 shebang 正确;% 是否转义为 \%;工作目录是否依赖相对路径。最快的诊断办法是在任务里加一条 * * * * * env > /tmp/cron-env.txt,对比它与登录 shell 的环境差异。

crontab 里的任务没跑,怎么确认?

先看服务是否运行 systemctl status crond,再看日志 grep CRON /var/log/cron 或 journalctl -u cron。日志里出现 CMD 行说明任务已触发,没有则是时间字段写错。确认时间字段可以用在线的 cron 表达式校验工具先验证一遍。

任务执行了但输出找不到?

默认 cron 会尝试发邮件。如果没配邮件服务,输出就丢了。给任务加上 >> /var/log/xxx.log 2>&1,或者在 crontab 顶部设 MAILTO="" 关闭邮件并用文件记录。

能不能用 crontab 做秒级任务?

不能,cron 最小粒度是分钟。变通办法是脚本内循环执行十五次每次睡四秒,但这很脆弱。真正需要秒级精度应该用 systemd timer 的 AccuracySec=1s,或者在应用内做调度。

多个人共用一个服务器,怎么防止互相覆盖 crontab?

crontab -e 编辑的是用户自己的配置,天然隔离。需要共享的任务用 root 身份放到 /etc/cron.d/ 下的独立文件里,文件名按用途命名,不要都往 /etc/crontab 里塞。修改前备份,改动记入变更记录。

总结

  • crontab 格式为五个时间字段加命令,日和星期是"或"关系,/ 表示步长,这些细节最容易被写错。
  • 环境变量是 cron 最大的坑:命令一律写绝对路径,或在 crontab 顶部显式声明 PATH 和 SHELL,脚本内用绝对路径并 cd 到目标目录。
  • % 在 crontab 中必须转义为 \%,否则 date +%F 这类命令会产生意外结果。
  • 每个任务都要重定向输出到日志文件,并为日志配置 logrotate,避免输出丢失或撑爆磁盘。
  • 重要任务(备份、证书续期、数据同步)建议迁移到 systemd timer,用 Persistent=true 补执行、RandomizedDelaySec 错峰、journalctl -u 统一查日志。