1. 定时任务里那条“消失”的 docker exec
半夜收到告警,说某个数据同步任务没跑,登录机器一看,crontab 里那条docker exec命令安安静静躺着,手动敲一遍立刻成功,交给 cron 就石沉大海。这个场景我遇到过不止一次,每次排查下来根因都不完全一样,但套路高度相似。如果你也在用 crontab 调度容器内的命令,比如定时跑备份脚本、定时清理容器内日志、定时触发容器里的数据导出,那这篇内容基本就是为你准备的。
先把结论摆前面:crontab执行docker exec失败,绝大多数情况不是 docker 坏了,也不是命令写错了,而是cron 的运行环境和你的交互式 shell 环境根本不是一回事。你在终端里能跑通,是因为你的 shell 加载了一堆环境变量、PATH、用户组权限、tty 会话;而 cron 启动的是一个极简的、几乎“裸奔”的非交互环境,PATH 短得可怜,没有 tty,用户组可能也没带上。命令本身没问题,是执行它的“土壤”不对。
这篇我会把这个问题拆成几个层面讲透:cron 到底给了命令什么样的运行环境、docker exec在这个环境里会卡在哪几个点、每一类失败怎么定位、怎么改才稳。中间会穿插我自己踩过的坑和一套可复现的排查链路,最后给几套可以直接抄的写法。不管你是刚接触定时任务的新手,还是被这个问题反复折磨的老手,应该都能拿到点能落地的东西。
需要说明的是,下面涉及的具体路径、容器名、脚本名都是示意,你替换成自己环境里的即可;涉及系统层面的操作,建议先在测试机验证再上生产。
2. cron 给你的命令准备了什么样的“简陋房间”
要解决问题,先得理解 cron 是怎么执行任务的。很多人对 cron 的想象是“到点了帮我在终端里敲一下这条命令”,其实完全不是。cron 守护进程在触发任务时,是用一个非常干净的环境去 fork 子进程执行命令的,它不会读你的.bashrc、.bash_profile、.profile,也不会继承你当前登录会话的任何环境变量。这一点是理解所有后续问题的钥匙。
2.1 PATH 被砍到只剩几条默认目录
交互式登录时,你的 PATH 通常包含/usr/local/bin、/usr/bin、/bin、/usr/sbin、/sbin,还可能有一堆通过包管理器或手动配置加进去的路径。而 cron 默认给的 PATH 往往只有/usr/bin:/bin这种极简组合。问题来了:docker这个可执行文件装在哪?很多发行版通过官方脚本安装后落在/usr/bin/docker,那还好;但有些环境装在/usr/local/bin/docker,或者通过 snap 装在/snap/bin/docker,这时候 cron 里直接写docker exec ...就会报command not found——注意,这个报错不会弹到你脸上,它只会写进 cron 的日志或者邮件里,任务就这么无声无息地失败了。
我见过最典型的一次,是某台机器上docker在/usr/local/bin,用户手动执行一切正常,crontab 里那条命令连续一周没生效,查日志才发现是docker: command not found。所以第一件要确认的事永远是:cron 环境里能不能找到 docker 这个命令。
2.2 没有 tty,没有标准输入
docker exec默认行为是分配一个伪终端吗?不是。docker exec不加-t时不分配 tty,不加-i时不保持标准输入打开。在交互式终端里你敲docker exec 容器名 命令,即使没加-i -t,很多命令也能跑,因为容器内进程对 stdin 的处理方式不同。但在 cron 里,标准输入是/dev/null,标准输出和标准错误会被 cron 捕获(通常发邮件或写日志)。如果你的容器内命令依赖 stdin(比如某些交互式确认、读取管道输入),在 cron 里就会直接读到 EOF 然后异常退出。
更隐蔽的是 tty 相关的问题。有些程序在检测到没有 tty 时会改变行为,比如某些 CLI 工具会关闭彩色输出、改变缓冲策略,甚至直接拒绝运行。docker exec本身在无 tty 时一般没问题,但如果容器内跑的是需要终端的程序,就得考虑加-t——不过 cron 环境里加-t又可能因为“the input device is not a TTY”而报错,这是个经典矛盾,后面会专门讲怎么绕。
2.3 用户组和权限:docker 组的坑
docker命令默认需要 root 权限,或者当前用户在docker组里。你在终端里能跑docker exec,说明你的用户已经在 docker 组。但 cron 任务是以哪个用户身份跑的?如果你是用crontab -e编辑的当前用户任务,那 cron 会以该用户身份执行,用户组理论上应该一致。但这里有个细节:用户组信息的加载时机。如果你是在把用户加入 docker 组之后没有重新登录,或者 cron 守护进程是在你加组之前启动的,某些情况下组信息可能不同步。虽然现代系统上 cron 执行时会重新读取用户组,但保险起见,遇到权限类报错时,id命令在 cron 里跑一下看看输出,是最快的确认方式。
2.4 环境变量全线缺失
容器内命令可能依赖某些环境变量,比如数据库连接串、时区、语言编码。你在终端里 export 过的变量,cron 一概不认。docker exec默认也不会把宿主机的环境变量传进容器(除非用-e显式传)。所以如果容器内脚本依赖TZ、LANG之类的变量,cron 触发时可能因为时区不对导致时间计算错误,或者因为编码问题导致中文乱码、文件路径处理异常。这类问题不会让命令直接失败,但会让结果“看起来不对”,排查起来更费劲。
3. 从“手动能跑”到“cron 不跑”的完整排查链路
遇到问题别急着改 crontab,先按链路把根因锁死。我习惯的顺序是:先确认 cron 到底有没有触发、再看命令有没有找到、再看权限、最后看容器内执行结果。每一步都有对应的验证手段,别跳步,跳步容易误判。
3.1 第一步:确认 cron 真的触发了
很多人以为任务没跑,其实是 cron 根本没触发。先看 cron 服务状态,systemctl status cron或systemctl status crond(不同发行版服务名不同)。再看 cron 日志,Debian/Ubuntu 系通常在/var/log/syslog里 grepCRON,RHEL/CentOS 系看/var/log/cron。日志里会明确记录“某用户在某时刻执行了某命令”,如果连这条都没有,说明是 cron 配置本身的问题(比如 crontab 语法错、服务没起、时间表达式写错),跟 docker 无关。
一个容易忽略的点:crontab 文件末尾必须有换行符,某些 cron 实现下最后一行没有换行会被忽略。这个坑我踩过一次,排查了半天以为是 docker 问题,结果是编辑器保存时把最后一行吞了。
3.2 第二步:把 cron 的“黑箱”变成可见输出
cron 任务失败最难受的是没反馈。我的做法是:任何要上生产的 cron 任务,第一版都先把输出重定向到文件。写法很简单,在命令后面加>> /tmp/task.log 2>&1。这样标准输出和标准错误都落到文件里,跑完去看文件就知道发生了什么。等任务稳定了再考虑要不要保留日志。
更进一步,可以在 cron 里先跑一条“环境探测”命令,比如:
* * * * * /usr/bin/env > /tmp/cron_env.txt 2>&1跑一次之后看这个文件,PATH 是什么、有哪些变量、当前用户是谁,一目了然。这比猜要快得多。我一般会把这个探测和正式任务放同一个 crontab 里,先探测再执行,确认环境后再删掉探测行。
3.3 第三步:用绝对路径排除 PATH 干扰
确认 cron 触发了、也有输出了,如果日志里是command not found,那就是 PATH 问题。解决办法有两个层次:一是在 crontab 顶部显式设置 PATH,二是命令里直接用 docker 的绝对路径。我倾向于两个都做,双保险。
先which docker拿到绝对路径,比如/usr/bin/docker,然后 crontab 里写成/usr/bin/docker exec ...。同时在 crontab 顶部加一行PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin,把常用路径补齐。注意 crontab 里的环境变量设置语法和 shell 脚本里一样,但不要写export,直接PATH=...即可。
3.4 第四步:权限与用户组验证
如果日志里是permission denied或者跟 docker socket 相关的报错,基本就是权限问题。在 cron 里跑一条id > /tmp/cron_id.txt 2>&1,看输出里有没有 docker 组。如果没有,说明执行身份不对。这时候要么把任务放到 root 的 crontab 里(sudo crontab -e),要么确认用户组配置正确后重启 cron 服务让它重新加载。生产环境我更倾向用专门的服务账号跑定时任务,权限边界清晰,出问题也好追溯。
3.5 第五步:容器内命令本身的执行环境
前面几步都过了,docker 也找到了、权限也对,但任务还是没达到预期效果,那问题就在容器内。这时候要把排查重心移到容器里:容器内命令依赖的路径、环境变量、工作目录是否和手动执行时一致。docker exec默认的工作目录是容器镜像里定义的工作目录,不一定是脚本期望的目录。可以在命令里显式cd,或者用docker exec -w /path/to/dir指定工作目录。
还有一个高频坑:容器内命令依赖的某些文件是挂载进去的,宿主机上路径变了、挂载没更新,容器内就找不到文件。这类问题手动执行时如果刚好用的是旧路径也可能复现,但容易被忽略。
4. 几类高频失败场景的针对性解法
排查链路走完,根因基本能定位到具体某一类。下面把最常见的几类失败场景拆开讲,每类给出原因分析和可直接用的写法。
4.1 command not found:PATH 与绝对路径
这是出现频率最高的一类。cron 的 PATH 太短,找不到 docker。解法前面提过,核心是绝对路径加 PATH 兜底。但有个细节值得说:如果你用的是docker compose而不是docker exec,docker-compose这个可执行文件的位置更飘,有的在/usr/local/bin,有的在/usr/bin,还有的作为 docker 插件存在(docker compose子命令形式)。这种情况下绝对路径尤其重要,别指望 cron 能猜对。
另外,如果命令里还调用了其他工具,比如gzip、curl、mysql客户端,这些同样受 PATH 影响。一个稳妥的做法是把整个任务写成一个 shell 脚本,脚本开头显式设置 PATH,crontab 里只调用这个脚本的绝对路径。这样环境控制集中在一处,比在 crontab 里堆一长串命令清晰得多。
#!/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin export PATH /usr/bin/docker exec my_container /app/backup.shcrontab 里写:
0 2 * * * /opt/scripts/backup_job.sh >> /var/log/backup_job.log 2>&14.2 permission denied:docker socket 与用户身份
/var/run/docker.sock的权限默认是 root 所有、docker 组可读写。cron 以非 root 用户执行时,如果该用户不在 docker 组,就会 permission denied。验证方法前面说了,cron 里跑id。修复方式:把用户加入 docker 组(usermod -aG docker 用户名),然后重启 cron 服务,确保它拿到最新的组信息。注意单纯重新登录对已经运行的 cron 守护进程不一定生效,重启服务最稳。
如果安全策略不允许普通用户进 docker 组(这其实是更推荐的做法,因为 docker 组权限接近 root),那就把任务放到 root 的 crontab 里,或者用 sudo 配置精细化的免密执行。生产环境我一般用后者:给服务账号配置特定命令的 sudo 权限,crontab 里用sudo /usr/bin/docker exec ...,权限收得更紧。
4.3 “the input device is not a TTY”:-t 参数的两难
这个报错很经典。你在 crontab 里写了docker exec -it 容器名 命令,手动跑没问题,cron 跑就报the input device is not a TTY。原因是-t要求分配伪终端,而 cron 环境没有终端。解法很直接:cron 里不要用-it。如果容器内命令确实需要 stdin,用-i但不加-t;如果完全不需要交互,两个都不加。
但有些容器内程序硬性要求 tty 才肯跑,这时候可以用script命令包一层来伪造终端:
script -qec "/usr/bin/docker exec 容器名 命令" /dev/nullscript会创建一个伪终端来执行命令,-q静默、-e返回子命令退出码、-c指定命令。这个写法能解决大部分“必须要 tty”的顽固场景,代价是多一层进程,退出码传递要留意。我一般只在确实绕不过去时才用,能用-i解决的就别上script。
4.4 容器内脚本找不到文件或环境变量缺失
这类问题表现为命令执行了、退出码也是 0,但结果不对。根因通常是工作目录或环境变量不一致。docker exec支持-w指定工作目录、-e传环境变量,把这两个显式写清楚,能消除大部分不确定性:
/usr/bin/docker exec -w /app -e TZ=Asia/Shanghai -e LANG=C.UTF-8 my_container /app/sync.sh时区和编码这两个变量特别值得显式传,因为容器基础镜像的默认值五花八门,cron 触发时如果容器内脚本做时间计算或处理中文文件名,很容易出偏差。我习惯在任何涉及时间或文本处理的定时任务里都把这两个变量钉死。
4.5 任务“跑了但没跑完”:超时与并发
还有一种情况:cron 日志显示执行了,容器内也确实动了,但任务没完成。常见原因是上一次任务还没结束,下一次又触发了,两个实例打架;或者任务执行时间超过了预期,被外部机制中断。cron 本身不会给任务设超时,但如果任务里有网络请求、数据库锁等待,可能卡住。解决办法是在脚本里加锁,用flock防止并发:
#!/bin/bash exec 9>/var/lock/backup_job.lock flock -n 9 || exit 0 /usr/bin/docker exec my_container /app/backup.shflock -n表示拿不到锁就立即退出,避免任务堆积。这个技巧在定时任务里非常实用,尤其是执行时间不固定的任务。
5. 让定时任务稳如老狗的工程化写法
单条命令调通只是第一步,真正要长期稳定,得把定时任务当工程项目来管。下面这套写法是我这些年沉淀下来的习惯,能挡掉大部分“今天好好的明天就挂”的问题。
5.1 统一入口脚本,crontab 只做调度
crontab 里尽量只写“什么时候执行哪个脚本”,不写具体逻辑。所有环境设置、路径处理、日志重定向、错误处理都收进脚本里。这样做的好处是:crontab 极简不易出错,脚本可以版本管理、可以本地测试、可以复用。一个典型的入口脚本长这样:
#!/bin/bash set -euo pipefail PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin export PATH export TZ=Asia/Shanghai LOG_FILE=/var/log/jobs/data_sync.log exec >> "$LOG_FILE" 2>&1 echo "===== $(date '+%Y-%m-%d %H:%M:%S') job start =====" /usr/bin/docker exec -w /app -e TZ=Asia/Shanghai my_container /app/sync.sh echo "===== $(date '+%Y-%m-%d %H:%M:%S') job end, exit=$? ====="set -euo pipefail让脚本遇到错误立即退出、未定义变量报错、管道中任一环节失败都算失败,避免“错误被吞掉”。日志里打时间戳和退出码,出问题时一眼能看出是哪次执行、结果如何。
5.2 日志与告警:别让失败静悄悄
cron 默认会把输出发邮件,但很多机器没配邮件,输出就丢了。所以显式重定向到日志文件是必须的。更进一步,可以在脚本末尾判断退出码,失败时触发告警(比如调用一个通知脚本)。我一般会在入口脚本里加一段:
if ! /usr/bin/docker exec my_container /app/sync.sh; then /opt/scripts/notify.sh "data_sync failed on $(hostname)" exit 1 fi这样失败既有日志又有通知,不会出现“任务挂了一周没人知道”的情况。通知渠道用团队现有的即可,关键是失败要有人知道。
5.3 用容器健康检查兜底
如果定时任务依赖的容器本身挂了或者不健康,docker exec会直接失败。可以在脚本开头加一个健康检查,容器不健康时先尝试重启或直接告警,而不是让后续命令报一堆看不懂的错:
if ! /usr/bin/docker inspect -f '{{.State.Running}}' my_container | grep -q true; then echo "container not running" exit 1 fi这个检查很轻量,但能把“容器挂了导致任务失败”和“任务本身有问题”区分开,排查时少走弯路。
5.4 时间表达式与执行频率的匹配
最后提一个容易被忽视的点:cron 的时间表达式和执行耗时要匹配。如果任务平均要跑 50 分钟,你设成每小时一次,就会出现任务重叠。前面说的flock能防并发,但更好的做法是评估任务实际耗时,把频率设得合理。另外,cron 的最小粒度是分钟,秒级调度它做不到,别在这上面较劲,真有秒级需求得换调度工具。
还有时区问题:cron 用的是系统时区,容器内可能是另一个时区。如果任务涉及“每天几点”这种语义,务必确认系统时区和业务预期一致,必要时在脚本里做时区转换。我见过因为宿主机时区是 UTC、业务按北京时间算,导致任务每天偏移 8 小时才跑的案例,排查时绕了很久。
6. 我踩过的几个真实坑与经验沉淀
说几个具体经历,都是文档里不会写、但实际会遇到的。
第一个坑是“crontab 最后一行没换行”。某次加任务,编辑器保存时最后一行没带换行符,cron 直接忽略了那一行,任务压根没注册。后来养成习惯,每次改完 crontab 用crontab -l看一眼,确认最后一行在。这个坑的隐蔽性在于,crontab -e保存时不一定报错,但执行时就是没有。
第二个坑是“docker 命令找到了,但容器名解析失败”。容器名在 cron 环境里没问题,问题出在容器重启后名字变了(比如用了自动生成的名称),或者容器根本没起来。后来在脚本里加了容器运行状态检查,这类问题从“任务莫名失败”变成了“日志里明确写容器未运行”,排查时间从小时级降到分钟级。
第三个坑是“环境变量在脚本里 export 了,但 docker exec 没传进去”。容器内脚本读不到宿主机 export 的变量,因为docker exec默认不传递宿主机环境变量。这个认知盲区让我浪费了不少时间,后来养成习惯:容器内需要的变量,一律用-e显式传,不依赖宿主机环境。
第四个坑是“日志文件权限”。脚本以某个用户跑,日志文件之前被 root 创建过,导致追加写入时 permission denied,任务失败但错误写不进日志,形成死循环。解决办法是日志目录和文件权限提前规划好,或者用install -m之类的方式确保权限正确。
这些坑的共同点是:它们都不会在手动执行时暴露,只在 cron 的极简环境里才现形。所以我的经验是,任何要上 cron 的任务,先在测试环境用“模拟 cron 环境”跑一遍——最土的办法就是写个脚本,用env -i清空环境再执行,能提前发现大部分环境依赖问题。
env -i PATH=/usr/bin:/bin /opt/scripts/backup_job.sh这条命令模拟了 cron 的极简环境,跑通了再上 crontab,成功率会高很多。这个习惯帮我省了无数次半夜排查。
最后再分享一个小技巧:如果条件允许,把定时任务的调度从 crontab 迁移到容器编排平台自带的调度能力上(比如在容器内跑一个轻量调度进程,或者用平台的任务编排功能),能天然规避宿主机环境差异的问题。当然这取决于你的基础设施,不是所有场景都适用,但方向值得考虑——让任务在它该在的环境里跑,而不是隔着一层docker exec去够。