如何优雅地管理你的定时任务?
在日常开发中,我们经常会使用一些定时任务来辅助完成某些事情。对此,绝大多数人都会选择使用 crontab 来配置定时任务。
不可否认,crontab 的确是管理定时任务的经典利器,但是你是否和我一样,踩过不少 crontab 的坑呢?
下面,我将介绍一下个人认为 crontab 的一些痛点和坑。最终,给出另一种优化的解决方案。
crontab
提到 crontab,这里必须要介绍一下它的配置规则,如下所示。
1
2
3
4
5
6
7.---------------- 分 (0 - 59)
| .------------- 时 (0 - 23)
| | .---------- 日 (1 - 31)
| | | .------- 月 (1 - 12)
| | | | .---- 星期 (0 - 6) (星期日可为0或7)
| | | | |
* * * * * 执行的命令
- 第一列单位为分,表示每时第几分钟,范围为 0-59
- 第二列单位为时,表示每天第几小时,范围为 0-23
- 第三列单位为日,表示每月第几天,范围为 1-31
- 第四列单位为月,表示每年第几月,范围为 1-12
- 第五列单位为星期,表示每星期第几天,范围 0-7,0 与 7 表示星期日,其他分别为星期 1-6
整体而言,crontab 对于不同的单位(除了星期),均支持了三种配置规则:
- 指定时间
- 指定范围
- 指定步长
通过组合这些配置规则,crontab 可以实现非常多的定时配置。
在使用 crontab 很长时间之后,我发现 crontab 还是存在着一些使用痛点的,主要有以下几点,下面分别进行介绍。
输出重定向
默认为情况下,crontab 会将任务输出默认写入到执行用户的邮件中。如果任务有大量输出,则会大量占用磁盘资源,甚至导致系统宕机。
如下所示,我们配置一个输出当前日志的定时任务。
1 | * * * * * date |
我们可以查看当前用户的邮件,如下所示。 1
2
3$ cat /var/mail/$USER
...
Tue Aug 1 22:11:22 CST 2023
关于这个问题,实践经验都是建议采用如下的方式对任务的输出进行重定向。很显然,这对于新手是非常不友好的。
1 | * * * * * date >> /dev/null/ 2>&1 |
环境变量
在实践中,我们可以发现 crontab 的环境变量与控制台的环境变量是存在差异的。因此,经常会出现这样的情景:在控制台中调试完成的任务,在 cron 中执行时,其结果会与预期不相符。事实上,产生这种差异的根本原因就是环境变量。
此外,crontab 中环境变量不会全局共享。因此,当我们配置多个任务时,可能需要为每个任务单独配置环境变量。很显然,这是一个重复而又繁琐的问题。
规则语法
关于 crontab 的规则语法,这是个仁者见仁智者见智的问题。对于老手来说,可能比较简单;对于新人来说,在使用时得去查询各个位置的单位以及不同规则的写法。我觉得 crontab 的规则语法不容易理解的根本原因是缺少语义。如果能优化其规则语法的语义,那就更好不过了。
另一方面,对于某些极客来说,crontab 的规则可能还不够完备。比如:预期一个定时任务从某个时刻开始或停止执行,或者,预期一个任务循环执行 n 次后结束。对于这种规则,crontab 无法一次性满足,只能通过配置多个任务来辅助完成。
运行日志
在实际应用中,我们经常需要借助任务的运行日志来排查问题。此时,我们就需要修改 crontab,将任务的输出重定向至某个文件,从而方便后续进行查看。当任务非常多的时候,我们很难记住每个任务对应的日志文件是哪个。这也是 crontab 的一个痛点。
taskloop
为了解决 crontab 的诸多痛点,我在业余时间开发了一款优化版的定时任务管理器——taskloop。
taskloop 底层运行在 cron 守护进程之上,基于 crontab 配置了最小粒度的调度规则,实现了一个中间层,从而解决了 crontab 的诸多痛点。
命令
taskloop 提供了一系列的命令,实现了一个相对完整(如有缺失,补充实现)的工作流,其主要包含以下这些特性。
环境变量
taskloop env
命令提供了查看、导入、删除环境变量的功能。
如下所示,为环境变量查看的使用示例。 1
2
3
4
5
6
7
8
9
10
11
12$ taskloop env
PATH=/Users/baochuquan/.rvm/gems/ruby-2.6.5/bin:/Users/baochuquan/.rvm/gems/ruby-2.6.5@global/bin:/Users/baochuquan/.rvm/rubies/ruby-2.6.5/bin:/usr/local/texlive/2023basic/bin/universal-darwin:/Users/baochuquan/.nvm/versions/node/v18.16.0/bin:/usr/local/opt/sqlite/bin:/usr/local/sbin:/usr/local/opt/gettext/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/Users/baochuquan/.rvm/bin:/Users/baochuquan/Flutter/bin:/Users/baochuquan/Library/Android/sdk/tools
RUBY_VERSION=ruby-2.6.5
GEM_PATH=/Users/baochuquan/.rvm/gems/ruby-2.6.5:/Users/baochuquan/.rvm/gems/ruby-2.6.5@global
GEM_HOME=/Users/baochuquan/.rvm/gems/ruby-2.6.5
IRBRC=/Users/baochuquan/.rvm/rubies/ruby-2.6.5/.irbrc
NOX_ROOT=/Users/baochuquan/Develop/nox
NOX_NAME=nox
NOX_COMMON=/Users/baochuquan/Develop/nox/common
NOX_CONFIG=/Users/baochuquan/Develop/nox/config
NOX_SCRIPTS=/Users/baochuquan/Develop/nox/scripts
如下所示,为环境变量导入的使用示例。示例中,我导入了两个环境变量
JAVA_HOME
和 GROOVY_HOME
。 1
2
3
4
5
6
7
8$ taskloop env --import=JAVA_HOME,GROOVY_HOME
importing JAVA_HOME ...
JAVA_HOME=/Library/Internet Plug-Ins/JavaAppletPlugin.plugin/Contents/Home
importing GROOVY_HOME ...
GROOVY_HOME=/usr/local/opt/groovy/libexec
import global environment variables complete.
如下所示,为环境变量删除的使用示例。示例中,我删除了
GROOVY_HOME
环境变量。 1
2
3$ taskloop env --remove=GROOVY_HOME
remove global environment variables complete.
经过一系列导入、删除操作之后,我们可以通过 taskloop env
命令来查看导入结果是否正确。
启动/关闭
taskloop 具有一个全局的开关,即启动和关闭的能力。前面我们提到 taskloop 底层是运行在 cron 守护进程之上,对此,启动功能的本质就是将 taskloop 注册至 crontab;关闭功能的本质就是将 taskloop 从 crontab 注销。
如下所示,为启动 taskloop 的使用示例。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19$ taskloop launch
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@ @@@@@ @@@ @@ @@ @@@@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@ @@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@@@@@@ @@@ @@@@@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@@@@ @@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@ @@@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@ @@@ @@ @@ @@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
taskloop has launched successfully.
如下所示,为关闭 taskloop 的使用示例。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20$ taskloop shutdown
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@ @@@@@ @@@ @@ @@ @@@@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@ @@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@@@@@@ @@@ @@@@@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@@@@ @@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@ @@@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@ @@@ @@ @@ @@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
taskloop has shutdown successfully.
byeeeeeeeeeeeeeeeee !
初始化
taskloop 通过读取注册的 Taskfile 来执行所有的任务,Taskfile 中可以定义一系列用户自定义的任务。为了便于使用,taskloop 提供了一个初始化命令,可以自动创建一个 Taskfile 模板,从而供用户进行修改和定制。
如下所示,为初始化的使用示例。taskloop init
方法创建了一个 Taskfile
模板,并定义了所有支持的属性,我们可以自定义任务,包括任务的路径、名称、执行规则等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22$ cd my-job-project
$ taskloop init
$ cat Taskfile
# env to set environment variables which are shared by all tasks defined in the Taskfile. <Optional>
# env "ENV_NAME", "ENV_VALUE"
TaskLoop::Task.new do |t|
t.name = 'TODO: task name. <Required>'
t.path = 'TODO: task job path. For example, t.path = "./Job.sh". <Required>'
t.week = 'TODO: week rule. <Optional>'
t.year = "TODO: year rule. <Optional>"
t.month = "TODO: month rule. <Optional>"
t.day = "TODO: day rule. <Optional>"
t.hour = "TODO: hour rule. <Optional>"
t.minute = "TODO: minute rule. <Optional>"
t.time = "TODO: time list rule. <Optional>"
t.date = "TODO: date list rule. <Optional>"
t.loop = "TODO: loop count. <Optional>"
t.start_point = "TODO: start point boundary rule. <Optional>"
t.end_point = "TODO: end point boundary rule. <Optional>"
end
发布/撤销
当我们完成了对 Taskfile 的定义之后,可以进行发布。发布过程中,taskloop 会检查 Taskfile 中的语法规则,如果不符合将抛出异常,并提示错误;如果符合规则,则完成发布。Taskfile 将正式生效,后续的任务执行将以此为准。
如下所示,为发布的使用示例。 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$ cd my-job-project
$ taskloop deploy
(@&/////%@@@@@@@@@@@@@#
@@&@&////////////////(@@#
/@(///////////////////////%@/
*@////////////////////////////#@,
@&///////////////////////////////@%
@&//////////////(@////@&#/////////(@
@////////////@@ @////@ ,.@////@@
/@//////////&@ ,@@////@@@&. *///@@
,@/////////(@@@ @///&@ /@@///@@
@%////////@@ @@@& @@/@#
@#///////@ ,
@@//////@& @(//@
@@/////%@ @////@
@@/////#@@@////@
&@@@&#((&@@
/&# @///@@
,///,*. @////@
&# %/,@ @@///@
(, .*/&*%/*%///& (@@@ ,*/////*.
%/#@, ////& @ % .& /@%/(/ /#/#@( #@#/&
(/& . .////, &//
&(//@ &//////& @///@
(@%//////#&@@@@@@@@@@%///////@@ @@///////%@@@@@@@@@@&#//////#@#
%@@@@&@@@@@&&@@@@/ /@@@@&&@@@@@&@@@@&
Taskfile deploy success!
当然,在某些情况下,我们需要撤销已经发布的 Taskfile。此时,我们可以执行如下命令进行撤销。
1 | $ cd my-job-project |
任务查看
为了便于查看当前已发布的任务,taskloop 提供了一个命令方便用户进行查询。如下所示,为任务查看的使用示例。
1 | $ taskloop list |
日志查看
为了解决 crontab 的日志查询问题,taskloop 同样提供了一个命令支持查询不同维度的日志,包括:系统日志(即 taskloop 运行日志)、任务日志。
如下所示,为查看系统日志的使用示例。 1
2
3
4
5
6
7
8
9
10$ taskloop log --cron
=============================
Log of cron:
Trigger Time: <2023-08-03 08:24:00 +0800>
Checking: <Task.name: haha, sha1: 637d1f5c6e6d1be22ed907eb3d223d858ca396d8> does not meet the execution rules, taskloop will skip its execution.
Checking: <Task.name: baocq, sha1: 7cc14c1bffcd559180d9906377bfaa41a4f9a980> does not meet the execution rules, taskloop will skip its execution.
Checking: <Task.name: chuquan, sha1: d461e86c07d232ceebcd2d024ea4b4c33d0f7b4b> does not meet the execution rules, taskloop will skip its execution.
=============================
如下所示,为查看任务日志的使用示例。 1
2
3
4
5
6
7
8$ taskloop log --task-name=baocq
=============================
Project of </Users/baochuquan/Github/taskloop>
Log of <Task.name: haha> above:
<Trigger Time: 2023-08-03 08:27:16 +0800>
Test0101
=============================
语法规则
taskloop init
命令会创建一个 Taskfile 文件,我们可以在
Taskfile 文件中自定义不同的任务与规则。这里,taskloop
定义了一套语法规则,我们将基于如下所示的 Taskfile 模板进行介绍。
1 | TaskLoop::Task.new do |t| |
模板中列出了任务支持的所有属性,首先有两个必要属性 name
和 path
。
name
:用于指出任务的名称,同一个 Taskfile 中不能有同名的任务,主要用于日志查询时指定名称。path
用于指出任务的路径,taskloop 会根据此路径加载并执行任务脚本。
模板中的其他属性均为非必要属性,用于描述执行规则。关于执行规则,taskloop 中主要定义如下几种规则。
- 指定时间规则(Specific Rule)
- 指定时间规则用于指定特定的时间值,对应的语法是
at
。 - 支持指定时间规则的属性有
week
、year
、month
、day
、hour
、minute
,其中week
、month
、day
属性需要使用预定义的符号,其余属性可以直接使用数值。- 对于
week
,需要使用星期符号,如::Sun
、:Mon
等。 - 对于
month
,需要使用月份符号,如::Jan
、:Feb
等。 - 对于
day
,需要使用表示月份中第几天的符号,如::day1
,:day2
等。
- 对于
- 示例
t.week = at :Mon, :Sub, :Tue
t.month = at :Feb, :Aug
t.day = at :day2, :day8, :day30, day:31
t.year = at 2023, 2024
t.hour = at 10, 11
t.minute = at 59
- 指定时间规则用于指定特定的时间值,对应的语法是
- 时间范围规则(Scope Rule)
- 时间范围规则包含三种子规则,对应的语法分别是:
before
、between
、after
。before
语法表示在小于等于某个值时执行。between
语法表示在大于等于某个值,且小于等于另一个值时执行。after
语法表示在大于等于某个值时执行。
- 支持时间范围规则的属性有
week
、year
、month
、day
、hour
、minute
。 - 示例
t.year = before 2026
t.week = between :Mon, :Fri
t.hour = after 12
- 时间范围规则包含三种子规则,对应的语法分别是:
- 时间间隔规则(Interval Rule)
- 时间间隔规则用于指定两次任务之间的时间间隔,对应的语法是
interval
。 - 支持时间间隔规则的属性有
year
、month
、day
、hour
、minute
。 - 示例
t.minute = interval 5
t.day = interval 1
- 时间间隔规则用于指定两次任务之间的时间间隔,对应的语法是
- 循环次数规则(Loop Rule)
- 循环次数规则用于指定任务循环的次数,对应的语法是
loop
。 - 支持循环次数规则的属性只有
loop
。 - 示例
t.loop = loop 10
- 循环次数规则用于指定任务循环的次数,对应的语法是
- 时间列表规则(Time List Rule)
- 时间列表规则用于指定任务执行的时间列表,对应的语法是
time
。其与hour
、minute
属性冲突,不能同时使用。 - 支持时间列表规则的属性只有
time
。 - 示例
t.time = time "10:00:00", "7:00:00"
- 时间列表规则用于指定任务执行的时间列表,对应的语法是
- 日期列表规则(Date List Rule)
- 日期列表规则用于指定执行任务的日期列表,对应的语法是
date
。其与year
、month
、day
属性冲突,不能同时使用。 - 支持日期列表规则的属性只有
date
。 - 示例
t.date = date "2023-10-1, "2023-5-1
- 日期列表规则用于指定执行任务的日期列表,对应的语法是
- 执行边界规则(Boundary Rule)
- 执行边界规则包含两种子规则,对应的语法分别是
from
和to
。from
语法表示任务从某一个时刻开始执行,支持的属性只有start_point
。to
语法表示任务在某一时刻之后不在执行,支持的属性只有end_point
。
- 示例
t.start_point = "2023-10-1 10:00:00"
t.end_point = "2023-10-30 23:59:00
- 执行边界规则包含两种子规则,对应的语法分别是
工作流程
taskloop 的工作流程可以分为三个步骤:
- 启动/关闭
- 初始化
- 发布/撤销
启动/关闭步骤是一个全局开关,对应分别有两个命令,如上所述。关于启动,一般只在最开始使用 taskloop 的时候使用启动命令。如果希望停止所有已注册任务的执行,则可以执行关闭命令。
taskloop 建议用户能够使用一个目录统一管理所有的定时任务,当希望为这些定时任务创建定时规则时,可以在目录下执行初始化命令,从而生成一个 Taskfile 文件。之后,即可自定义定时规则。如果用户本地维护了多个目录管理定时任务,则需要在不同的目录下分别执行一次初始化命令,从而完成任务规则自定义。
发布/撤销步骤相对而言会比较频繁,当初始化并自定义 Taskfile 之后,我们就可以执行发布命令,使得 Taskfile 真正在 taskloop 中生效。当然,有时候我们会在发布后发现一些错误,我们可以修改后重新发布,或者为了避免产生副作用,可以执行撤销命令。注意,发布/撤销命令必须在 Taskfile 的同级目录下执行。
总结
本文简单介绍了一下我最近业余时间写的一个定时任务管理工具——taskloop。同时,解释了为什么做这个工具的原因(即解决 crontab 的痛点)。
关于软件 logo,我花了两晚设计了这样一个形象。两个圈组成一个莫比乌斯环,象征着循环。任务执行抽象为海豚跳圈,海豚在两个圈中循环穿越则象征着 taskloop 在永不停止地运行任务。
忘了说,其实写这篇文章的另一个重要目的是为了推广一下我的作品,也希望有兴趣的朋友能够给一些意见,甚至可以一起参与软件的开发和完善。