Linux 中的 Cron:历史、用途和设备

Linux 中的 Cron:历史、用途和设备

经典写道,欢乐时光不看。 在那些狂野的时代,既没有程序员,也没有 Unix,但今天程序员肯定知道:cron 将代替他们来记录时间。

命令行实用程序对我来说既是一个弱点又是一件苦差事。 sed、awk、wc、cut 等老程序每天都在我们的服务器上通过脚本运行。 其中许多是为 cron(源自 70 年代的调度程序)设计的任务。

很长一段时间我对 cron 的使用都很肤浅,没有深入探讨细节,但是有一天,当我运行脚本时遇到错误时,我决定彻底研究一下。 这篇文章就是这样出现的,在写这篇文章的过程中,我熟悉了 POSIX crontab、流行 Linux 发行版中的主要 cron 选项以及其中一些选项的结构。

您使用 Linux 并运行 cron 任务吗? 您对 Unix 中的系统应用程序架构感兴趣吗? 然后我们就上路了!

内容

物种起源

在所有操作系统中,周期性执行用户或系统程序显然是必要的。 因此,程序员很早就意识到需要能够让他们集中规划和执行任务的服务。

类 Unix 操作系统的起源可以追溯到上世纪 7 年代贝尔实验室开发的 Unix 版本 70,其中包括著名的 Ken Thompson。 Unix 7 版本还包含 cron,这是一项定期运行超级用户任务的服务。

典型的现代 cron 是一个简单的程序,但原始版本的操作算法更简单:服务每分钟唤醒一次,从单个文件(/etc/lib/crontab)读取包含任务的表并执行超级用户那些当前应该执行的任务。

随后,所有类 Unix 操作系统都提供了这个简单而有用的服务的改进版本。

1992 年,对 crontab 格式的概括描述以及该实用程序操作的基本原理被纳入类 Unix 操作系统的主要标准 - POSIX - 中,因此 cron 从事实上的标准变成了法律上的标准。

1987年,Paul Vixie在调查了Unix用户对cron的愿望后,发布了守护进程的另一个版本,纠正了传统cron的一些问题并扩展了表文件的语法。

到了 Vixie cron 的第三个版本开始满足 POSIX 要求,此外,该程序拥有自由许可证,或者更确切地说根本没有许可证,除了自述文件中的愿望:作者不提供保证,作者姓名无法删除,程序只能与源代码一起出售。 事实证明,这些要求与当时流行的自由软件的原则是兼容的,因此 90 年代初出现的一些主要 Linux 发行版都将 Vixie cron 作为其系统之一,并且至今仍在开发中。

特别是,Red Hat和SUSE开发了Vixie cron的一个分支-cronie,而Debian和Ubuntu则使用带有许多补丁的Vixie cron的原始版本。

让我们首先熟悉 POSIX 中描述的用户实用程序 crontab,然后我们将了解 Vixie cron 中提供的语法扩展以及 Vixie cron 变体在流行 Linux 发行版中的使用。 最后,锦上添花的是对 cron 守护进程设备的分析。

POSIX 定时任务

如果说原来的 cron 总是为超级用户工作,那么现代的调度程序通常会处理普通用户的任务,这更加安全和方便。

Cron 作为两个程序的集合提供:持续运行的 cron 守护进程和用户可用的 crontab 实用程序。 后者允许您编辑特定于系统中每个用户的任务表,而守护程序则从用户和系统表启动任务。

В POSIX标准 守护进程的行为没有以任何方式描述,只有用户程序被形式化 crontab中。 当然,启动用户任务的机制的存在是隐含的,但没有详细描述。

通过调用 crontab 实用程序,您可以执行四件事:在编辑器中编辑用户的任务表、从文件加载该表、显示当前任务表以及清除任务表。 crontab 实用程序如何工作的示例:

crontab -e # редактировать таблицу задач
crontab -l # показать таблицу задач
crontab -r # удалить таблицу задач
crontab path/to/file.crontab # загрузить таблицу задач из файла

打电话的时候 crontab -e 将使用标准环境变量中指定的编辑器 EDITOR.

任务本身按以下格式描述:

# строки-комментарии игнорируются
#
# задача, выполняемая ежеминутно
* * * * * /path/to/exec -a -b -c
# задача, выполняемая на 10-й минуте каждого часа
10 * * * * /path/to/exec -a -b -c
# задача, выполняемая на 10-й минуте второго часа каждого дня и использующая перенаправление стандартного потока вывода
10 2 * * * /path/to/exec -a -b -c > /tmp/cron-job-output.log

记录的前五个字段:分钟 [1..60]、小时 [0..23]、一月中的天数 [1..31]、月数 [1..12]、一周中的天数 [0..6]。 .0],其中 XNUMX 是星期日。 最后第六个字段是将由标准命令解释器执行的一行。

在前五个字段中,可以用逗号分隔列出值:

# задача, выполняемая в первую и десятую минуты каждого часа
1,10 * * * * /path/to/exec -a -b -c

或者用连字符:

# задача, выполняемая в каждую из первых десяти минут каждого часа
0-9 * * * * /path/to/exec -a -b -c

在 POSIX 中,用户对任务调度的访问由 cron.allow 和 cron.deny 文件控制,这两个文件分别列出了有权访问 crontab 的用户和无权访问该程序的用户。 该标准不以任何方式规范这些文件的位置。

根据标准,至少必须将四个环境变量传递给启动的程序:

  1. HOME - 用户的主目录。
  2. LOGNAME — 用户登录。
  3. PATH 是您可以找到标准系统实用程序的路径。
  4. SHELL — 使用的命令解释器的路径。

值得注意的是,POSIX 没有说明这些变量的值来自哪里。

畅销书 - Vixie cron 3.0pl1

流行的 cron 变体的共同祖先是 Vixie cron 3.0pl1,于 1992 年在 comp.sources.unix 邮件列表中引入。 我们将更详细地考虑该版本的主要功能。

Vixie cron 有两个程序(cron 和 crontab)。 与往常一样,守护进程负责从系统任务表和个人用户任务表中读取和运行任务,而 crontab 实用程序负责编辑用户表。

任务表和配置文件

超级用户任务表位于/etc/crontab 中。 系统表的语法与 Vixie cron 的语法相对应,但其中的第六列表示代表任务启动的用户名:

# Запускается ежеминутно от пользователя vlad
* * * * * vlad /path/to/exec

常规用户任务表位于 /var/cron/tabs/username 中并使用相同的语法。 当您以用户身份运行 crontab 实用程序时,这些是被编辑的文件。

有权访问 crontab 的用户列表在 /var/cron/allow 和 /var/cron/deny 文件中进行管理,您只需在其中单独的行中输入用户名即可。

扩展语法

与 POSIX crontab 相比,Paul Vixey 的解决方案包含对该实用程序任务表语法的一些非常有用的修改。

新的表语法已可用:例如,您可以按名称指定一周或几个月的日期(周一、周二等):

# Запускается ежеминутно по понедельникам и вторникам в январе
* * * Jan Mon,Tue /path/to/exec

您可以指定启动任务的步骤:

# Запускается с шагом в две минуты
*/2 * * * Mon,Tue /path/to/exec

步骤和间隔可以混合:

# Запускается с шагом в две минуты в первых десять минут каждого часа
0-10/2 * * * * /path/to/exec

支持常用语法的直观替代方案(重新启动、每年、每年、每月、每周、每天、午夜、每小时):

# Запускается после перезагрузки системы
@reboot /exec/on/reboot
# Запускается раз в день
@daily /exec/daily
# Запускается раз в час
@hourly /exec/daily

任务执行环境

Vixie cron 允许您更改运行应用程序的环境。

环境变量 USER、LOGNAME 和 HOME 不是简单地由守护进程提供,而是从文件中获取 passwd文件。 PATH 变量设置为“/usr/bin:/bin”,SHELL 变量设置为“/bin/sh”。 除了LOGNAME之外的所有变量的值都可以在用户表中更改。

cron 本身使用一些环境变量(最显着的是 SHELL 和 HOME)来运行任务。 使用 bash 而不是标准 sh 来运行自定义任务可能如下所示:

SHELL=/bin/bash
HOME=/tmp/
# exec будет запущен bash-ем в /tmp/
* * * * * /path/to/exec

最终,表中定义的所有环境变量(由 cron 使用或进程需要)都将传递给正在运行的任务。

要编辑文件,crontab 使用 VISUAL 或 EDITOR 环境变量中指定的编辑器。 如果运行 crontab 的环境没有定义这些变量,则使用“/usr/ucb/vi”(ucb 可能是加州大学伯克利分校)。

Debian 和 Ubuntu 上的 cron

Debian 及其衍生发行版的开发者已经发布了 高度修改版本 Vixie cron 版本 3.0pl1。 表文件的语法没有差异;对于用户来说,它是相同的 Vixie cron。 最大的新功能:支持 系统日志, SELinux的 и PAM.

不太明显但切实的变化包括配置文件和任务表的位置。

Debian 中的用户表位于 /var/spool/cron/crontabs 目录中,系统表仍然存在 - 在 /etc/crontab 中。 Debian 软件包特定的任务表放置在 /etc/cron.d 中,cron 守护进程会自动从其中读取它们。 用户访问控制由 /etc/cron.allow 和 /etc/cron.deny 文件控制。

默认 shell 仍然是 /bin/sh,在 Debian 中它是一个小型的 POSIX 兼容 shell 短跑,在不读取任何配置的情况下启动(在非交互模式下)。

最新版本的 Debian 中的 Cron 本身是通过 systemd 启动的,启动配置可以在 /lib/systemd/system/cron.service 中查看。 服务配置没有什么特别的;任何更微妙的任务管理都可以通过直接在每个用户的 crontab 中声明的环境变量来完成。

RedHat、Fedora 和 CentOS 上的 cronie

克罗妮 — Vixie cron 版本 4.1 的分支。 与 Debian 一样,语法没有改变,但添加了对 PAM 和 SELinux、在集群中工作、使用 inotify 跟踪文件和其他功能的支持。

默认配置位于通常的位置:系统表位于 /etc/crontab 中,软件包将其表放在 /etc/cron.d 中,用户表位于 /var/spool/cron/crontabs 中。

该守护进程在systemd控制下运行,服务配置为/lib/systemd/system/crond.service。

在类似 Red Hat 的发行版上,启动时默认使用 /bin/sh,这是标准的 bash。 需要注意的是,当通过 /bin/sh 运行 cron 作业时,bash shell 以 POSIX 兼容模式启动,并且不读取任何附加配置,以非交互模式运行。

SLES 和 openSUSE 中的 cronie

德国发行版 SLES 及其衍生版本 openSUSE 使用相同的 cronie。 这里的守护进程也是在systemd下启动的,服务配置位于/usr/lib/systemd/system/cron.service。 配置:/etc/crontab、/etc/cron.d、/var/spool/cron/tabs。 /bin/sh 与在 POSIX 兼容的非交互模式下运行的 bash 相同。

Vixie cron 设备

与 Vixie cron 相比,现代 cron 的后代并没有发生根本性的变化,但仍然获得了理解程序原理不需要的新功能。 其中许多扩展设计不佳,并且使代码混乱。 Paul Vixey 的原始 cron 源代码读起来很愉快。

因此,我决定使用两个开发分支通用的 cron 程序示例来分析 cron 设备 - Vixie cron 3.0pl1。 我将通过删除使阅读变得复杂的 ifdef 并省略次要细节来简化示例。

恶魔的工作可以分为几个阶段:

  1. 程序初始化。
  2. 收集并更新要运行的任务列表。
  3. 主 cron 循环正在运行。
  4. 开始一个任务。

让我们按顺序看一下它们。

初始化

启动时,在检查进程参数后,cron 会安装 SIGCHLD 和 SIGHUP 信号处理程序。 第一个创建有关子进程终止的日志条目,第二个关闭日志文件的文件描述符:

signal(SIGCHLD, sigchld_handler);
signal(SIGHUP, sighup_handler);

cron 守护进程始终在系统上单独运行,仅作为超级用户并从主 cron 目录运行。 以下调用使用守护进程的 PID 创建一个锁定文件,确保用户正确并将当前目录更改为主目录:

acquire_daemonlock(0);
set_cron_uid();
set_cron_cwd();

设置默认路径,启动进程时将使用该路径:

setenv("PATH", _PATH_DEFPATH, 1);

然后该进程被“守护进程”:它通过调用 fork 创建该进程的子副本并在子进程中创建一个新会话(调用setsid)。 不再需要父进程,它退出:

switch (fork()) {
case -1:
    /* критическая ошибка и завершение работы */
    exit(0);
break;
case 0:
    /* дочерний процесс */
    (void) setsid();
break;
default:
    /* родительский процесс завершает работу */
    _exit(0);
}

父进程的终止会释放锁定文件上的锁定。 另外,需要将文件中的PID更新为child。 之后,任务数据库被填充:

/* повторный захват лока */
acquire_daemonlock(0);

/* Заполнение БД  */
database.head = NULL;
database.tail = NULL;
database.mtime = (time_t) 0;
load_database(&database);

然后 cron 进入主工作周期。 但在此之前,值得看一下加载任务列表。

收集并更新任务列表

load_database 函数负责加载任务列表。 它检查主系统 crontab 和包含用户文件的目录。 如果文件和目录没有改变,则不会重新读取任务列表。 否则,就会开始形成新的任务列表。

加载具有特殊文件名和表名的系统文件:

/* если файл системной таблицы изменился, перечитываем */
if (syscron_stat.st_mtime) {
    process_crontab("root", "*system*",
    SYSCRONTAB, &syscron_stat,
    &new_db, old_db);
}

循环加载用户表:

while (NULL != (dp = readdir(dir))) {
    char    fname[MAXNAMLEN+1],
            tabname[MAXNAMLEN+1];
    /* читать файлы с точкой не надо*/
    if (dp->d_name[0] == '.')
            continue;
    (void) strcpy(fname, dp->d_name);
    sprintf(tabname, CRON_TAB(fname));
    process_crontab(fname, fname, tabname,
                    &statbuf, &new_db, old_db);
}

之后旧数据库将替换为新数据库。

在上面的示例中,process_crontab 函数调用验证是否存在与表文件名匹配的用户(除非它是超级用户),然后调用 load_user。 后者已经逐行读取文件本身:

while ((status = load_env(envstr, file)) >= OK) {
    switch (status) {
    case ERR:
        free_user(u);
        u = NULL;
        goto done;
    case FALSE:
        e = load_entry(file, NULL, pw, envp);
        if (e) {
            e->next = u->crontab;
            u->crontab = e;
        }
        break;
    case TRUE:
        envp = env_set(envp, envstr);
        break;
    }
}

在这里,要么使用 load_env / env_set 函数设置环境变量(VAR=value 形式的行),要么使用 load_entry 函数读取任务描述(* * * * * /path/to/exec)。

load_entry返回的条目实体是我们的任务,它被放置在任务的通用列表中。 该函数本身对时间格式进行了详细的解析,但我们更感兴趣的是环境变量和任务启动参数的形成:

/* пользователь и группа для запуска задачи берутся из passwd*/
e->uid = pw->pw_uid;
e->gid = pw->pw_gid;

/* шелл по умолчанию (/bin/sh), если пользователь не указал другое */
e->envp = env_copy(envp);
if (!env_get("SHELL", e->envp)) {
    sprintf(envstr, "SHELL=%s", _PATH_BSHELL);
    e->envp = env_set(e->envp, envstr);
}
/* домашняя директория */
if (!env_get("HOME", e->envp)) {
    sprintf(envstr, "HOME=%s", pw->pw_dir);
    e->envp = env_set(e->envp, envstr);
}
/* путь для поиска программ */
if (!env_get("PATH", e->envp)) {
    sprintf(envstr, "PATH=%s", _PATH_DEFPATH);
    e->envp = env_set(e->envp, envstr);
}
/* имя пользовтеля всегда из passwd */
sprintf(envstr, "%s=%s", "LOGNAME", pw->pw_name);
e->envp = env_set(e->envp, envstr);

主循环处理当前任务列表。

主循环

Unix 版本 7 中的原始 cron 工作起来非常简单:它循环重新读取配置,以超级用户身份启动当前分钟的任务,然后休眠直到下一分钟开始。 这种在旧机器上的简单方法需要太多资源。

SysV 中提出了一个替代版本,其中守护进程进入休眠状态,直到定义任务的最近一分钟,或者 30 分钟。 这种模式下重新读取配置和检查任务消耗的资源较少,但快速更新任务列表变得不方便。

Vixie cron 恢复每分钟检查一次任务列表,幸运的是,到了 80 年代末,标准 Unix 机器上的资源显着增加:

/* первичная загрузка задач */
load_database(&database);
/* запустить задачи, поставленные к выполнению после перезагрузки системы */
run_reboot_jobs(&database);
/* сделать TargetTime началом ближайшей минуты */
cron_sync();
while (TRUE) {
    /* выполнить задачи, после чего спать до TargetTime с поправкой на время, потраченное на задачи */
    cron_sleep();

    /* перечитать конфигурацию */
    load_database(&database);

    /* собрать задачи для данной минуты */
    cron_tick(&database);

    /* перевести TargetTime на начало следующей минуты */
    TargetTime += 60;
}

cron_sleep函数直接参与执行任务,调用job_runqueue(枚举并运行任务)和do_command(运行每个单独的任务)函数。 最后一个函数值得更详细地研究。

运行任务

do_command 函数以良好的 Unix 风格执行,即它执行 fork 来异步执行任务。 父进程继续启动任务,子进程准备任务流程:

switch (fork()) {
case -1:
    /*не смогли выполнить fork */
    break;
case 0:
    /* дочерний процесс: на всякий случай еще раз пробуем захватить главный лок */
    acquire_daemonlock(1);
    /* переходим к формированию процесса задачи */
    child_process(e, u);
    /* по завершению дочерний процесс заканчивает работу */
    _exit(OK_EXIT);
    break;
default:
    /* родительский процесс продолжает работу */
    break;
}

child_process 中有相当多的逻辑:它接收标准输出和错误流到自身,然后将其发送到邮件(如果任务表中指定了 MAILTO 环境变量),最后等待 main任务完成的过程。

任务进程由另一个fork形成:

switch (vfork()) {
case -1:
    /* при ошибки сразу завершается работа */
    exit(ERROR_EXIT);
case 0:
    /* процесс-внук формирует новую сессию, терминал и т.д.
     */
    (void) setsid();

    /*
     * дальше многословная настройка вывода процесса, опустим для краткости
     */

    /* смена директории, пользователя и группы пользователя,
     * то есть процесс больше не суперпользовательский
     */
    setgid(e->gid);
    setuid(e->uid);
    chdir(env_get("HOME", e->envp));

    /* запуск самой команды
     */
    {
        /* переменная окружения SHELL указывает на интерпретатор для запуска */
        char    *shell = env_get("SHELL", e->envp);

        /* процесс запускается без передачи окружения родительского процесса,
         * то есть именно так, как описано в таблице задач пользователя  */
        execle(shell, shell, "-c", e->cmd, (char *)0, e->envp);

        /* ошибка — и процесс на запустился? завершение работы */
        perror("execl");
        _exit(ERROR_EXIT);
    }
    break;
default:
    /* сам процесс продолжает работу: ждет завершения работы и вывода */
    break;
}

这基本上就是 cron 的全部内容。 我省略了一些有趣的细节,例如,远程用户的统计,但我概述了主要内容。

后记

Cron 是一个非常简单且有用的程序,它继承了 Unix 世界的最佳传统。 她没有做任何额外的事情,但几十年来她一直出色地完成自己的工作。 读完Ubuntu自带版本的代码只花了不到一个小时,而且我玩得很开心! 我希望我能够与你分享。

我不了解你的情况,但我有点难过地意识到,现代编程有过于复杂和过于抽象的倾向,长期以来一直不利于这种简单性。

cron 有许多现代替代方案:systemd-timers 允许您组织具有依赖关系的复杂系统,fcron 允许您更灵活地调节任务的资源消耗。 但就我个人而言,最简单的 crontab 对我来说总是足够的。

简而言之,热爱 Unix,使用简单的程序,并且不要忘记阅读适用于您的平台的 mana!

来源: habr.com

添加评论