Linux crontab 定时任务实战:环境变量、flock 防重叠与故障排查全指南

<h2>被低估的运维工具:crontab</h2>

<p>几乎每个站长都在用 crontab:定时备份、定时清理日志、定时跑采集脚本、定时续证书。它简单到只需要一行配置,但也正因为太简单,很多人从来没认真研究过它,直到某天发现——<strong>脚本在终端里跑得好好的,放进 crontab 就什么都不发生</strong>。</p>

<p>这篇文章把 crontab 从时间语法、环境变量、日志排查到并发控制完整讲一遍,重点放在那些「看起来正常但就是不工作」的坑上。这些坑我几乎每一个都亲自踩过,希望能帮你省几个小时。</p>

<h2>时间字段:五个数字的准确含义</h2>

<pre><code># ┌───────── 分钟 (0-59)

│ ┌─────── 小时 (0-23)

│ │ ┌───── 日 (1-31)

│ │ │ ┌─── 月 (1-12)

│ │ │ │ ┌─ 星期 (0-7,0 和 7 都是周日)

│ │ │ │ │

          • command</code></pre>

<p>几个容易记错的规则:</p>

<ul>
<li><code>/5</code> 表示「每 5 个单位」。<code>/5 </code> 是每 5 分钟,<code>0 /6 </code> 是每 6 小时整点。</li>
<li><strong>日和星期是「或」的关系,不是「且」。</strong><code>0 3 1 * 1</code> 的语义是「每月 1 号 3 点,<em>或者</em>每周一 3 点」,而不是「每月 1 号且是周一的 3 点」。这是最经典的认知偏差,如果你要的是后者,必须在脚本里自己判断日期。</li>
<li>月份的星期几、以及 <code>@reboot</code>、<code>@daily</code>、<code>@weekly</code>、<code>@monthly</code> 这些快捷写法,各版本 cron 支持程度不一(BusyBox 的 crond 就不支持 <code>@reboot</code>)。要稳妥就写全五个字段。</li>
<li>cron 没有「每 90 分钟」这种表达。<code>0,30 /3 </code> 出来的间隔并不均匀,真要精确间隔得靠脚本自己 sleep 循环或者用 systemd timer。</li>
</ul>

<h2>坑一:环境变量完全不同</h2>

<p>这是 crontab 第一大坑。<strong>cron 执行任务时用的不是你登录 shell 的环境,而是一个极其精简的默认环境</strong>:</p>

<ul>
<li><code>PATH</code> 通常只有 <code>/usr/bin:/bin</code>——你装在 <code>/usr/local/bin</code> 的 php、node、docker 全都找不到。</li>
<li>没有加载 <code>.bashrc</code> / <code>.bash_profile</code>,你自定义的 alias、函数、导出的变量一律不存在。</li>
<li><code>SHELL</code> 默认是 <code>/bin/sh</code>,在 Debian/Ubuntu 上这是 dash 而不是 bash,<code>[[ ... ]]</code>、数组、<code>source</code> 这些 bash 语法会直接报错。</li>
<li>没有 <code>LANG</code>/<code>LC_ALL</code>,中文路径和输出会变成乱码,PHP 脚本里 <code>mb_*</code> 相关函数可能行为异常。</li>
</ul>

<p>解决办法是在 crontab 顶部显式声明:</p>

<pre><code>SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LANG=zh_CN.UTF-8
LC_ALL=zh_CN.UTF-8
MAILTO=""

之后的任务都能用上面这套环境

0 3 * /usr/local/bin/php /www/backup.php >> /var/log/backup.log 2>&1</code></pre>

<p><code>MAILTO=""</code> 的作用是关掉 cron 的邮件通知。默认情况下 crontab 会把任务的输出(stdout/stderr)作为邮件发出去,但服务器上多半没装 MTA,结果就是大量邮件堆在 <code>/var/spool/mail</code> 或者干脆静默失败。<strong>把它设为空,然后自己在命令里重定向到日志文件</strong>,这才是可控的做法。</p>

<h2>坑二:命令要用绝对路径,且别忘了重定向</h2>

<p>反例:</p>

<pre><code>0 3 * cd /www && php backup.php</code></pre>

<p>问题有三层:<code>php</code> 可能不在 PATH 里;<code>cd</code> 和 <code>php</code> 之间必须用 <code>&amp;&amp;</code>(这里是有的,但很多人写成 <code>cd /www; php ...</code> 也能跑,容易混淆);最致命的是<strong>没有重定向输出</strong>,脚本报错你完全看不见。</p>

<p>正确的写法:</p>

<pre><code>0 3 * cd /www &amp;&amp; /usr/bin/php /www/backup.php >> /var/log/backup.log 2>&1</code></pre>

<p>几个细节:<code>2&gt;&amp;1</code> 必须写在重定向目标的后面,顺序反了只会把 stdout 重定向而 stderr 还是走邮件;<code>&gt;&gt;</code> 是追加、<code>&gt;</code> 是覆盖,备份类脚本用追加更安全;日志文件记得配 logrotate,否则一年下来能涨到几个 G。</p>

<p>如果你不想在每个任务后面都写一长串重定向,可以在 crontab 顶部做全局重定向(部分 cron 实现支持):</p>

<pre><code># 把所有任务输出统一写到一个带日期的日志
0 3 * /www/backup.sh >> /var/log/cron-$(date +%Y%m%d).log 2>&1</code></pre>

<p>注意这里的 <code>%</code>。<strong>百分号在 crontab 里有特殊含义</strong>——它会被解释成换行符,命令里所有从第一个未转义 <code>%</code> 开始的内容都会作为标准输入传给命令。想用字面量的 <code>%</code>(比如 <code>date +%Y%m%d</code>),就必须写成 <code>%</code>。这个坑极其隐蔽,很多人看到「date 命令输出为空」找了半天才反应过来。</p>

<h2>坑三:任务重叠执行</h2>

<p>假设你配了 <code>/5 *</code> 跑一个数据同步脚本,正常情况下 30 秒跑完。但某个周末源站挂了,脚本卡在超时等待上,5 分钟没结束,第二个实例又启动了。两个实例同时写同一份数据 → 数据损坏;同时拉同一个远端 → 被封 IP。</p>

<p>标准解法是用 <code>flock</code> 加文件锁,只允许一个实例运行:</p>

<pre><code>/5 * /usr/bin/flock -n /var/lock/sync.lock -c '/www/sync.sh >> /var/log/sync.log 2>&1'</code></pre>

<p><code>-n</code> 表示「拿不到锁就立刻退出,不等待」,这正是我们要的:上一轮还在跑,这一轮直接跳过。如果希望排队等上一轮结束再跑,去掉 <code>-n</code> 即可(但不建议,容易堆积)。</p>

<p>另一个场景是「任务必须在上一次结束后再等固定时间」,用 flock 就有点别扭,此时可以在脚本内部用 <code>while true; do ...; sleep 300; done</code> 常驻进程,然后用系统的 supervisor/systemd 管理它,而不是交给 cron。</p>

<h2>坑四:cron 不认你的时区</h2>

<p>服务器和你本地不在同一时区,是最容易造成「数据对不上」的原因。查看系统时区:</p>

<pre><code>timedatetime
date

ls -l /etc/localtime
cat /etc/timezone</code></pre>

<p>如果服务器是 UTC 而你想要北京时间 8 点执行,要么把系统时区改掉(<code>timedatectl set-timezone Asia/Shanghai</code>),要么在 crontab 里减 8 小时(<code>0 0 *</code> 表示 UTC 0 点 = 北京 8 点)。<strong>推荐改系统时区</strong>——把两个时区混着写在配置里,几个月后你自己都看不懂。</p>

<p>注意 Debian/Ubuntu 上的 crond 会读取 <code>/etc/timezone</code>,改完之后必须重启 cron 服务才生效:<code>systemctl restart cron</code>。</p>

<h2>坑五:Docker 容器里的 cron 不工作</h2>

<p>容器里的 crontab 有几个特殊之处:</p>

<ul>
<li><strong>cron 默认不写日志</strong>。很多基础镜像编译时省略了 syslog,crond 的日志直接丢失。排查时先跑 <code>cron -f</code> 前台运行看输出。</li>
<li><strong>容器时区默认 UTC</strong>。必须挂载 <code>-v /etc/localtime:/etc/localtime:ro</code> 或设置 <code>TZ=Asia/Shanghai</code>,否则任务在北京时间凌晨 3 点不跑。</li>
<li><strong>容器重启后 crond 不会自动启动</strong>。如果你的镜像 ENTRYPOINT 是应用的启动脚本,必须在里面拉起 crond,或者用 s6-overlay、supervisord 这类进程管理器。</li>
<li><strong>环境变量不会自动继承</strong>。宿主机的环境变量需要通过 <code>docker -e</code> 或 <code>env_file</code> 显式传入。</li>
</ul>

<pre><code># 在一个基于 debian 的容器里安装并启动 cron
RUN apt-get update &amp;&amp; apt-get install -y cron tzdata \

&amp;amp;&amp;amp; ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

crontab 文件放到 /etc/cron.d/ 下,注意最后必须有空行且指定用户

COPY myjobs /etc/cron.d/myjobs
RUN chmod 0644 /etc/cron.d/myjobs

entrypoint 里同时拉起 cron 和主进程

CMD cron &amp;&amp; exec /usr/sbin/nginx -g 'daemon off;'</code></pre>

<p><code>/etc/cron.d/</code> 下的文件格式和 <code>crontab -e</code> 略有不同:<strong>必须在命令前多写一个用户名</strong>。写成 <code>/5 * root /www/sync.sh</code>,否则任务不会被执行。</p>

<h2>排查套路:任务到底跑没跑?</h2>

<p>按下面这个顺序查,基本都能定位:</p>

<pre><code># 1. cron 服务在跑吗
systemctl status cron
ps aux | grep -w [c]ron

2. 看 cron 自己的日志(Debian/Ubuntu 默认进 syslog)

grep CRON /var/log/syslog | tail -50
journalctl -u cron --since "1 hour ago"

3. 看任务是否被调度到了

grep "cmd" /var/log/syslog | grep CRON | tail -20

正常会看到:CRON[12345]: (root) CMD (/www/sync.sh)

4. 看任务自己的日志(这就是为什么必须重定向)

tail -f /var/log/sync.log

5. 检查 crontab 语法错误

crontab -l

有语法错误时 syslog 里会有 "Error: bad minute" 之类的提示</code></pre>

<p>如果 syslog 里<strong>完全没有 CRON 记录</strong>,说明 cron 服务本身没起来,或者任务根本没被加载。这时候检查:<code>crontab -l</code> 是否有内容、<code>/etc/cron.d/</code> 下文件权限是否是 0644 且属主 root、以及 crontab 文件结尾是否有换行(<strong>最后一行没有换行符,cron 会忽略这一行</strong>——这是极其隐蔽的坑)。</p>

<p>如果 CRON 记录有、命令也执行了,但「什么都没发生」,那就回到坑一和坑二:环境变量不对或者重定向没写,导致脚本在一开始就退出了,而错误信息全被 cron 吞掉。此时最快的确认方法是在命令里加 <code>env > /tmp/cron-env.txt</code> 或者 <code>whoami > /tmp/cron-who.txt</code>,看看 cron 到底看到了什么。</p>

<h2>几条实践经验</h2>

<ul>
<li><strong>所有定时任务都写成独立脚本再调度</strong>,不要把复杂逻辑塞在 crontab 那一行里。脚本可以进 Git、可以做版本管理、可以单独测试。</li>
<li><strong>加日志、加日志、加日志。</strong>定时任务是无人值守的,没有日志就等于没有可观测性。</li>
<li><strong>给任务配监控</strong>:最简单的做法是脚本跑完后 <code>touch</code> 一个时间戳文件,再用另一个 cron 检查这个文件是否在 24 小时内被更新过,超时就发通知。这能抓出「脚本静默失败」这类最讨厌的问题。</li>
<li><strong>错开执行时间</strong>。别让五个任务都写在 <code>0 3 *</code>,那会造成瞬间的 IO 和 CPU 尖峰。分散到 <code>0 3</code>、<code>15 3</code>、<code>30 3</code> 更温和。</li>
<li><strong>备份你的 crontab</strong>:<code>crontab -l > ~/crontab-backup-$(date +%F).txt</code>,重装系统时你会感谢自己。</li>
</ul>

<h2>小结</h2>

<p>crontab 的坑几乎全部集中在「环境不一致」这一件事上。记住三个关键词:<strong>绝对路径、显式环境变量、输出重定向</strong>。再加上 flock 防重叠、注意 <code>%</code> 的转义、注意时区和行尾换行,你就能避开 95% 的定时任务故障。剩下的 5%,靠日志和 <code>env</code> 对比来定位,也不难。</p>

<div style="margin:20px 0;padding:10px 0;border-top:1px solid #eee;border-bottom:1px solid #eee">
<ins class="adsbygoogle" style="display:block;text-align:center" data-ad-layout="in-article" data-ad-format="fluid" data-ad-client="ca-pub-1561091167355374" data-ad-slot="8963568440"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>

Last modification:September 20th, 2026 at 12:24 pm

Leave a Comment