0x1.背景

业务中遇到Python镜像使用crontab执行任务不执行的情况,通过一系列定位分析后发现crontab在处理环境变量和crontab任务文件解析存在问题。

环境

  • 镜像: Python:3.7.5
  • Crontab版本: 3.0pl1-127+deb8u2

crontab任务文件

1
* * * * * /usr/local/bin/python job.py >> /tmp/job.log 2>&1

0x2.环境变量

python程序执行时采用os.environ读取环境变量,在调试程序时发现docker镜像打包运行后,程序报错无法读取环境变量值。登陆到k8s环境中手动执行env命令发现是可以获取注入的环境变量,修改python文件打印env环境信息

1
environ({'HOME': '/root', 'LOGNAME': 'root', 'PATH': '/usr/bin:/bin', 'SHELL': '/bin/sh', 'PWD': '/root', 'LC_CTYPE': 'C.UTF-8'})

上述环境是基础的环境变量信息,python程序无法在执行环境获取新增的环境变量。在查找资料过程中发现crontab守护进程会从/etc/environment/etc/default/locale获取环境变量。在Debian环境中采用service的方式运行/usr/sbin/cron, 定位对应的执行脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#cat /etc/init.d/cron

...
parse_environment ()
{
for ENV_FILE in /etc/environment /etc/default/locale; do
[ -r "$ENV_FILE" ] || continue
[ -s "$ENV_FILE" ] || continue

for var in LANG LANGUAGE LC_ALL LC_CTYPE; do
value=`egrep "^${var}=" "$ENV_FILE" | tail -n1 | cut -d= -f2`
[ -n "$value" ] && eval export $var=$value

if [ -n "$value" ] && [ "$ENV_FILE" = /etc/environment ]; then
log_warning_msg "/etc/environment has been deprecated for locale information; use /etc/default/locale for $var=$value instead"
fi
done
done

# Get the timezone set.
if [ -z "$TZ" -a -e /etc/timezone ]; then
TZ=`cat /etc/timezone`
fi
}

# Parse the system's environment
if [ "$READ_ENV" = "yes" ] ; then
parse_environment
fi
...

shell脚本采用加载environment和locale文件将环境变量注入到当前进程环境中,根据crontab在bug#543895描述推荐使用locale文件。

经过上述分析,需要调整Dockerfile相关文件操作;这里采用entrypoint.sh文件注入环境变量

1
env >> /etc/default/locale

0x3.任务解析与权限

crontab任务创建有两种方式,一种是直接使用crontab命令手动编辑写入,另一种是将写好的任务文件复制到/var/spool/cron/crontabs目录中。

排查某个业务问题时,发现crontab任务不能执行(采用文件复制形式);进入POD容器中使用cat命令查看任务文件也是显示正常,但是任务还是不能执行。后来通过crontab查看保存后,任务能正常执行。

回溯两次操作时,发现两个有意思的问题。问题一是crontab命令编辑任务文件保存后,该任务文件的组权限变成root:crontab(原root:root);问题二是crontab命令编辑查看文件后直接保存,文件大小发生改变,此过程无人为更改内容。

问题一的出现是因为crontab命令编辑文件时会设置任务文件的组,Debian镜像环境中只有root用户,用户组列表中是存在crontab组。由于Docker环境中只有root用户执行,不需要调整任务文件的权限;但非Docker环境中,crontab的任务文件一般设置600权限居多;cron守护进程读取文件时会校验权限。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#https://github.com/ushell/learncode/blob/master/crontab/debian/cron-3.0pl1/database.c#L206
...
/*任务文件权限校验*/
if (strcmp(fname, "*system*") && !(pw = getpwnam(uname))) {
/* file doesn't have a user in passwd file.
*/
log_it(fname, getpid(), "ORPHAN", "no passwd entry");
goto next_crontab;
}

if ((crontab_fd = open(tabname, O_RDONLY, 0)) < OK) {
/* crontab not accessible?
*/
log_it(fname, getpid(), "CAN'T OPEN", tabname);
goto next_crontab;
}

if (fstat(crontab_fd, statbuf) < OK) {
log_it(fname, getpid(), "FSTAT FAILED", tabname);
goto next_crontab;
}

/*查询/etc/passwd文件中用户*/
u = find_user(old_db, fname);
if (u != NULL) {
/* if crontab has not changed since we last read it
* in, then we can just use our existing entry.
*/
if (u->mtime == statbuf->st_mtime) {
Debug(DLOAD, (" [no change, using old data]"))
unlink_user(old_db, u);
link_user(new_db, u);
goto next_crontab;
}

/* before we fall through to the code that will reload
* the user, let's deallocate and unlink the user in
* the old database. This is more a point of memory
* efficiency than anything else, since all leftover
* users will be deleted from the old database when
* we finish with the crontab...
*/
Debug(DLOAD, (" [delete old data]"))
unlink_user(old_db, u);
free_user(u);
log_it(fname, getpid(), "RELOAD", tabname);
}
...

问题二的出现比较诡异,文件大小变化但cat查看文件内容没有明显变化。通过xxd命令查看文件改动前后的十六进制信息

  • crontab改动前

    1
    2
    3
    4
    00000000: 3020 3020 2a20 2a20 2a20 2f75 7372 2f6c  0 0 * * * /usr/l
    00000010: 6f63 616c 2f62 696e 2f70 7974 686f 6e20 ocal/bin/python
    //省略...
    000000f0: 2e6c 6f67 2032 3e26 31 .log 2>&1
  • crontab改动后

    1
    2
    3
    4
    00000000: 3020 3020 2a20 2a20 2a20 2f75 7372 2f6c  0 0 * * * /usr/l
    00000010: 6f63 616c 2f62 696e 2f70 7974 686f 6e20 ocal/bin/python
    //省略...
    000000f0: 2e6c 6f67 2032 3e26 310a .log 2>&1.

    注意到文件结尾处的区别,改动后多了0x0a标志信息即文件换行符LF。由于任务文件最后一行没有换行符号导致不能加载该条命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//https://github.com/ushell/learncode/blob/master/crontab/debian/cron-3.0pl1/misc.c#L329
/* get_string(str, max, file, termstr) : like fgets() but
* (1) has terminator string which should include \n
* (2) will always leave room for the null
* (3) uses get_char() so LineNumber will be accurate
* (4) returns EOF or terminating character, whichever
*/
int
get_string(string, size, file, terms)
char *string;
int size;
FILE *file;
char *terms;
{
int ch;

while (EOF != (ch = get_char(file)) && !strchr(terms, ch)) {
if (size > 1) {
*string++ = (char) ch;
size--;
}
}

if (size > 0)
*string = '\0';

return ch;
}

0x4.手工调试

基于Debian系统的crontab没有日志记录,调试代码时需要打开 cron.h文件中DEBUGGING;重新编译后,crontab执行时会打印一些关键的DEBUG信息方便排查。

注意编译后参数-x的值问题

1
2
#示例
./cron -x ext,proc,pars,load #参考cron.h中的DebugFlags

0x5.总结

crontab是Linux环境中最常用的定时任务执行程序,由于操作系统不同crontab的实现方式也不一样。在Docker环境中,基于Debian系统使用的是3.0pl1-127版本,Alpine系统中使用的busybox版本。前者程序比较完善但是不支持日志记录(需要修改源码), 后者比较轻量级支持前后台及日志记录等功能。

由于crontab最新一版的更新是2004年左右,Debian系统打算采用cronie代替目前crontab。业务系统中可以根据其他版本的定时程序代替。

参考

评论