crontab 脚本白天跑得好好的,凌晨 3 点就不跑了——cron 踩坑 5 连击
本文目录
先说结论
第一条:cron 的执行环境是极简的——PATH 只有 /usr/bin:/bin,没有你终端里的任何 env,所有命令要么写全路径,要么脚本开头 source /etc/profile。
第二条:cron 的时间是系统时间——系统是 UTC 还是本地时区直接影响你的 0 3 * * *到底是凌晨 3 点还是上午 11 点,生产必须先确认。
第三条:cron 任务默认不记日志,任务输出丢进 /dev/null 或 mail 到 /var/spool/mail/。生产必须显式重定向日志 + 用 flock 防并发 + 失败告警。
先把这段讲顺;面试官追问时,再展开下面的原理和细节。
详细讲解
某短剧出海公司遇到过这么一个诡异 case:数据同步脚本在终端里 bash run.sh 跑得好好的,放到 crontab 里 0 3 * * * 就是不出结果。运维查了半天,最后发现是 PATH 的问题——cron 的 shell 只有最基础的 PATH,找不到 hive 命令,脚本一开始就退了,但因为没配日志,这个失败静默得没有任何痕迹。第二天值班被业务叫起来,查了两个小时才定位。
cron 表面看是一个'按时间跑脚本'的简单工具,实际上有太多细节坑——这道题问出来能筛掉一大片只在本地跑过 crontab 的候选人。
坑 1:PATH 不是你终端那个 PATH
这是我见过最高频的 cron 问题,没有之一。
# 终端里你的 PATH 是这样的
echo $PATH
# /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:
# /home/wood/.local/bin:/opt/hive/bin:/opt/spark/bin:...
# cron 里的 PATH 是这样的(很多系统默认)
# /usr/bin:/bin
# 你 ~/.bashrc 里的 export PATH 通通不生效
# 验证方法:写一个 cron 任务打印环境
* * * * * env > /tmp/cron_env.txt
# 等一分钟看 /tmp/cron_env.txt,就会看到 cron 的真实环境
bash - 三种正确写法
# 方案 A:脚本开头 source 环境,最省心
#!/bin/bash
. /etc/profile
. ~/.bashrc
hive -e '...'
# 方案 B:在 crontab 里设置 PATH(顶部一行)
# crontab -e
PATH=/usr/local/bin:/usr/bin:/bin:/opt/hive/bin:/opt/spark/bin
0 3 * * * /data/job/run.sh
# 方案 C:脚本里全部用绝对路径(最死板也最可靠)
#!/bin/bash
/opt/hive/bin/hive -e '...'
/usr/local/bin/mysql -h... -e '...'
我的选择:方案 A + 方案 C 混用。脚本开头必 source /etc/profile,把基础环境建立好;涉及到版本敏感的工具(比如 Java、Python、Hive)用绝对路径,避免走到错误的版本。千万不要只靠 crontab 里的 PATH=——这个写法很容易被人忽略漏改。
坑 2:时区——0 3 * * * 到底是几点
这个坑只出现过一次,但那次是直接打穿全公司的。某短剧出海公司有一批海外服务器默认装的是 UTC 时区,国内同事按照'凌晨 3 点跑'的思路写了 0 3 * * *,结果实际是北京时间上午 11 点——一个跑全表 OVERWRITE 的脚本在业务高峰期执行,MySQL 主库直接被拖垮。
# 先确认系统时区
date
# Tue Apr 15 03:00:00 UTC 2026 ← UTC 时区
# Tue Apr 15 11:00:00 CST 2026 ← 本地时区
timedatectl # 更详细
# Time zone: Asia/Shanghai (CST, +0800)
# 改时区(一次性)
timedatectl set-timezone Asia/Shanghai
systemctl restart cron # cron 需要重启才能读新时区
# 或者在 crontab 里单独给某个任务指定时区(某些 cron 实现支持)
CRON_TZ=Asia/Shanghai
0 3 * * * /data/job/run.sh
# CRON_TZ 是 vixie-cron 4.x+ 的扩展,不是所有 cron 都支持
血泪建议:永远不要混着用时区。要么全 UTC 要么全本地时区,并且在脚本里用 date +%Z 打印出来。多时区团队可以强制 UTC + 脚本内转换,避免'不同机器不同时间'的悲剧。
坑 3:% 要转义 / 日志要显式重定向
cron 里的 % 是特殊字符——表示换行!很多人写 date +%Y%m%d 到 crontab 里,结果任务直接跑不起来,因为 % 后面的内容被 cron 当成'多行命令'的第二行了。
# ❌ 错误:% 没转义
0 3 * * * hive -e 'INSERT OVERWRITE ... partition(dt=$(date +%Y%m%d))'
# cron 会把 %Y%m%d 切断,hive 收到的是 'partition(dt=$(date +'
# ✅ 正确:% 必须用 \ 转义,或者整个命令放进脚本
0 3 * * * hive -e 'INSERT OVERWRITE ... partition(dt=$(date +\%Y\%m\%d))'
# ✅ 更好的方法:不在 crontab 写业务,全走 wrapper 脚本
0 3 * * * /data/job/run.sh >> /data/logs/run.log 2>&1
# ↑ >> 追加日志 + 2>&1 合并 stderr 到 stdout,这是生产最低配
# 不加日志重定向会怎样?
# cron 把 stdout/stderr 当成'邮件'发给用户,存到 /var/spool/mail/
# 日积月累几个 G,你根本不知道
黄金法则:crontab 里只写一行——一个脚本路径 + 日志重定向,业务逻辑全部放进脚本。这能避免 % 转义、引号嵌套、空格转义等一堆复杂问题。
坑 4:前一次没跑完,后一次又启动——flock 防并发
一个 5 分钟跑一次的同步任务,某天因为源库慢,一次跑了 8 分钟——第二次已经启动,两个进程同时写同一张临时表,数据直接错乱。这坑太常见了。
# ❌ 裸跑:cron 不管你上一次有没有结束
*/5 * * * * /data/job/sync.sh
# ✅ 方法一:flock 在 crontab 一行搞定
*/5 * * * * /usr/bin/flock -n /tmp/sync.lock /data/job/sync.sh
# -n = 非阻塞,拿不到锁就直接退出;拿到就执行,退出时自动释放
# ✅ 方法二:flock 在脚本内部(更灵活,可以自定义超时告警)
#!/bin/bash
LOCK=/tmp/sync.lock
exec 9> "$LOCK"
if ! flock -n 9; then
echo "[WARN] sync 已有实例在跑,本次跳过"
# 如果连续跳过 > 3 次,告警
exit 0
fi
# 正常业务逻辑
为什么 flock 比 PID 文件更可靠:PID 文件的死循环问题是——脚本被 kill -9 时来不及删 PID 文件,下次启动发现 PID 存在就永远不跑了。flock 是内核级锁,进程无论怎么死,锁都会自动释放。生产我只用 flock。
坑 5:失败没告警——你以为它在跑,其实它已经死了一周
cron 任务最可怕的不是失败,是静默失败。上面四个坑合起来常常导致这个结果:脚本 PATH 缺了命令一启动就退出,日志没配,邮件你不看,任务'每天 0 点准时跑一次然后秒退',你一周都发现不了,直到业务找你。
bash - 生产标配 wrapper
#!/bin/bash
# 所有生产 cron 任务都应从这个模板出发
set -euo pipefail
. /etc/profile
export PATH=/usr/local/bin:/usr/bin:/bin:/opt/hive/bin:/opt/spark/bin
export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64
JOB=$(basename "$0" .sh)
DT=$(date +%F)
LOG=/data/logs/${JOB}_${DT}.log
LOCK=/tmp/${JOB}.lock
exec 9> "$LOCK"
flock -n 9 || { echo "already running" >> "$LOG"; exit 0; }
exec > >(tee -a "$LOG") 2>&1
fail() {
local code=$?
[ $code -ne 0 ] && curl -sS -X POST "$ALERT_WEBHOOK" \
-d "{\"text\":\"[FAIL] $JOB exit=$code ts=$(date +%s)\"}"
exit $code
}
trap fail EXIT
echo "[START] $JOB $(date)"
# ===== 业务 =====
hive -e 'INSERT OVERWRITE ...'
echo "[DONE] $JOB $(date)"
再加一层保险:用 healthchecks.io 或自建心跳服务,脚本结束时 curl 一下 https://hc.example.com/ping/
cron 5 坑速查表
| 坑', '现象', '根因', '修法 |
|---|
| PATH 缺命令', '脚本立即退出,无输出', 'cron 只继承 /usr/bin:/bin', '脚本头 . /etc/profile |
| 时区错', '跑的时间不对', '系统是 UTC/本地不一致', 'timedatectl 改时区 |
| % 没转义', '任务语法错误不执行', 'cron 把 % 当换行', '转成 \% 或封装脚本 |
| 并发重跑', '数据错乱', '上次没跑完下次启动', 'flock -n |
| 静默失败', '一周没数据才发现', 'stdout 丢到邮件', '显式 >> log + 告警 |
面试高频追问
追问一:crontab 的 6 个字段分别是什么?* / , - 怎么读?
5 个时间字段 + 命令:分 时 日 月 周 命令。星号是'每',逗号是'或'(1,5,15 是 1 或 5 或 15),横杠是'范围'(1-5 是 1 到 5),斜杠是'步长'(*/5 是每 5 个单位)。常见组合比如每 5 分钟是 */5 * * * *,每天凌晨 3 点是 0 3 * * *,工作日早 9 点是 0 9 * * 1-5。一个不常见但有用的:@reboot 代表开机后跑一次,@daily 是每天 0 点跑一次。
追问二:cron 的 anacron 有什么区别?什么时候该用 anacron?
cron 在机器关机/休眠时错过的任务不会补跑——比如笔记本电脑开机才发现'昨天凌晨 3 点的备份任务没跑',cron 会直接跳过。anacron 就是为这种'非 24 小时运行'的机器设计的:它记录每个任务上一次执行的日期,下一次机器开机后检查是否已经超过预定间隔,如果是就补跑。anacron 在笔记本、PC、测试机上用得多,生产服务器 24 小时不关机,用纯 cron 就够了。
追问三:生产上有 1000 台机器每天凌晨 0 点要跑同一个脚本,你会怎么做?
三种做法按复杂度递增:一是在 crontab 里加随机抖动,写成 0 0 * * * sleep $((RANDOM % 600)) && /data/job/run.sh,让 1000 台机器错峰 10 分钟启动,避免同时打后端服务;二是用统一的调度系统(Airflow、DolphinScheduler),中心节点负责调度,每台机器 agent 被动执行,这样能看到全局状态和重试;三是 K8s 的 CronJob,调度 + 重启策略 + 资源隔离都给你搞定。小规模用 crontab + 抖动,中规模用 Airflow,规模化部署一定上 K8s CronJob。
实战踩坑:那次凌晨 3 点的诡异静默
某短剧出海公司那次 PATH 事故的完整复盘:同事在终端 source 了 .bashrc 能找到 hive,但 crontab 里不继承 .bashrc,所以 hive 命令 not found,脚本 127 退出。退出不报错是因为脚本开头没写 set -e,中间没有错误检查。stdout/stderr 没重定向,默认发到了 root 邮箱——一个没人看的 /var/spool/mail/root。
发现这个问题的方式有点尴尬:业务反馈'昨天报表是空的',我们一查才发现 cron 日志从上周一就没再写过。然后我做了几件事:第一,所有 cron 任务统一封装 run.sh 模板(和 topic 11 里的那个一致);第二,Grafana 上加了一个 dashboard,用 Prometheus node-exporter 的 job 状态 + 文件 mtime 监控凌晨批跑的产出文件,超时就告警;第三,周例会复盘环节里加了一项'凌晨任务昨晚是否都正常',强制每天看一眼。
bash - 新增监控脚本
#!/bin/bash
# /data/monitor/check_batch_jobs.sh,每早 8 点跑
set -euo pipefail
DT=$(date +%F)
JOBS=(ods_sync dwd_build dws_agg ads_export)
for job in "${JOBS[@]}"; do
LOG=/data/logs/${job}_${DT}.log
if [ ! -f "$LOG" ]; then
curl -sS -X POST "$ALERT" -d "{\"text\":\"[MISS] $job 今日无日志\"}"
continue
fi
if ! grep -q '\[DONE\]' "$LOG"; then
curl -sS -X POST "$ALERT" -d "{\"text\":\"[FAIL] $job 未完成\"}"
fi
done
效果:这个 check 脚本上线后,cron 静默失败的发现时间从平均 2-3 天缩短到 T+1 早上 8 点,业务再也没因为'凌晨任务昨晚没跑'被投诉过。这种'监控别人监控不到的'才是运维的核心价值。
总结
cron 不是简单的'按时跑脚本',它有一套完整的坑要避:PATH 要显式处理、时区要先确认、% 要转义、并发要 flock、失败要告警 + 心跳。面试时你能把这 5 个坑一口气讲出来,基本就是'真正在生产维护过定时任务'的铁证。下一次凌晨被叫起来排 cron 问题时,这套知识会替你省两个小时。
下一篇预告:ssh 登陆卡 30 秒才出密码提示——UseDNS / GSSAPI / ~/.ssh/config 完整调优。
你踩过的最'静默'的 cron 事故是什么?评论区聊聊,让大家都长个心眼。
关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长