9

如何优雅地管理你的定时任务?

 1 year ago
source link: http://chuquan.me/2023/07/30/introduction-to-taskloop/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

resize,w_800

在日常开发中,我们经常会使用一些定时任务来辅助完成某些事情。对此,绝大多数人都会选择使用 crontab 来配置定时任务。

不可否认,crontab 的确是管理定时任务的经典利器,但是你是否和我一样,踩过不少 crontab 的坑呢?

下面,我将介绍一下个人认为 crontab 的一些痛点和坑。最终,给出另一种优化的解决方案。

crontab

提到 crontab,这里必须要介绍一下它的配置规则,如下所示。

.---------------- 分 (0 - 59)
| .------------- 时 (0 - 23)
| | .---------- 日 (1 - 31)
| | | .------- 月 (1 - 12)
| | | | .---- 星期 (0 - 6) (星期日可为0或7)
| | | | |
* * * * * 执行的命令

crontab 的配置规则可以分为 5 列,其作用分别是:

  • 第一列单位为分,表示每时第几分钟,范围为 0-59
  • 第二列单位为时,表示每天第几小时,范围为 0-23
  • 第三列单位为日,表示每月第几天,范围为 1-31
  • 第四列单位为月,表示每年第几月,范围为 1-12
  • 第五列单位为星期,表示每星期第几天,范围 0-7,0 与 7 表示星期日,其他分别为星期 1-6

整体而言,crontab 对于不同的单位(除了星期),均支持了三种配置规则:

通过组合这些配置规则,crontab 可以实现非常多的定时配置。

在使用 crontab 很长时间之后,我发现 crontab 还是存在着一些使用痛点的,主要有以下几点,下面分别进行介绍。

输出重定向

默认为情况下,crontab 会将任务输出默认写入到执行用户的邮件中。如果任务有大量输出,则会大量占用磁盘资源,甚至导致系统宕机。

如下所示,我们配置一个输出当前日志的定时任务。

* * * * * date

我们可以查看当前用户的邮件,如下所示。

$ cat /var/mail/$USER
...
Tue Aug 1 22:11:22 CST 2023

关于这个问题,实践经验都是建议采用如下的方式对任务的输出进行重定向。很显然,这对于新手是非常不友好的。

* * * * * date >> /dev/null/ 2>&1

在实践中,我们可以发现 crontab 的环境变量与控制台的环境变量是存在差异的。因此,经常会出现这样的情景:在控制台中调试完成的任务,在 cron 中执行时,其结果会与预期不相符。事实上,产生这种差异的根本原因就是环境变量。

此外,crontab 中环境变量不会全局共享。因此,当我们配置多个任务时,可能需要为每个任务单独配置环境变量。很显然,这是一个重复而又繁琐的问题。

关于 crontab 的规则语法,这是个仁者见仁智者见智的问题。对于老手来说,可能比较简单;对于新人来说,在使用时得去查询各个位置的单位以及不同规则的写法。我觉得 crontab 的规则语法不容易理解的根本原因是缺少语义。如果能优化其规则语法的语义,那就更好不过了。

另一方面,对于某些极客来说,crontab 的规则可能还不够完备。比如:预期一个定时任务从某个时刻开始或停止执行,或者,预期一个任务循环执行 n 次后结束。对于这种规则,crontab 无法一次性满足,只能通过配置多个任务来辅助完成。

在实际应用中,我们经常需要借助任务的运行日志来排查问题。此时,我们就需要修改 crontab,将任务的输出重定向至某个文件,从而方便后续进行查看。当任务非常多的时候,我们很难记住每个任务对应的日志文件是哪个。这也是 crontab 的一个痛点。

taskloop

为了解决 crontab 的诸多痛点,我在业余时间开发了一款优化版的定时任务管理器——taskloop

taskloop 底层运行在 cron 守护进程之上,基于 crontab 配置了最小粒度的调度规则,实现了一个中间层,从而解决了 crontab 的诸多痛点。

resize,w_800

taskloop 提供了一系列的命令,实现了一个相对完整(如有缺失,补充实现)的工作流,其主要包含以下这些特性。

taskloop env 命令提供了查看、导入、删除环境变量的功能。

如下所示,为环境变量查看的使用示例。

$ 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_HOMEGROOVY_HOME

$ 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 环境变量。

$ taskloop env --remove=GROOVY_HOME

remove global environment variables complete.

经过一系列导入、删除操作之后,我们可以通过 taskloop env 命令来查看导入结果是否正确。

启动/关闭

taskloop 具有一个全局的开关,即启动和关闭的能力。前面我们提到 taskloop 底层是运行在 cron 守护进程之上,对此,启动功能的本质就是将 taskloop 注册至 crontab;关闭功能的本质就是将 taskloop 从 crontab 注销。

如下所示,为启动 taskloop 的使用示例。

$  taskloop launch

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@ @@@@@ @@@ @@ @@ @@@@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@ @@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@@@@@@ @@@ @@@@@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@@@@ @@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@ @@@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@ @@@ @@ @@ @@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@

taskloop has launched successfully.

如下所示,为关闭 taskloop 的使用示例。

$ taskloop shutdown

@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@ @@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@ @@@@@ @@@ @@ @@ @@@@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@ @@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@@@@@@ @@@ @@@@@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@@@@ @@@ @@@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@ @@@ @@ @@@ @@ @ @@@ @@@@ @@ @@ @@ @@ @@ @@@@@@@@
@@@@@@@@@@@ @@@@ @@@ @@@ @@ @@ @@@ @@@@ @@@ @@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@
@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@

taskloop has shutdown successfully.

byeeeeeeeeeeeeeeeee !

taskloop 通过读取注册的 Taskfile 来执行所有的任务,Taskfile 中可以定义一系列用户自定义的任务。为了便于使用,taskloop 提供了一个初始化命令,可以自动创建一个 Taskfile 模板,从而供用户进行修改和定制。

如下所示,为初始化的使用示例。taskloop init 方法创建了一个 Taskfile 模板,并定义了所有支持的属性,我们可以自定义任务,包括任务的路径、名称、执行规则等。

$ 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 将正式生效,后续的任务执行将以此为准。

如下所示,为发布的使用示例。

$ cd my-job-project
$ taskloop deploy

(@&/////%@@@@@@@@@@@@@#
@@&@&////////////////(@@#
/@(///////////////////////%@/
*@////////////////////////////#@,
@&///////////////////////////////@%
@&//////////////(@////@/////////(@
@////////////@@ @////@ ,.@////@@
/@//////////&@ ,@@////@@@&. *///@@
,@/////////(@@@ @///&@ /@@///@@
@%////////@@ @@@& @@/@#
@#///////@ ,
@@//////@& @(//@
@@/////%@ @////@
@@/////#@@@////@
&@@@((&@@
/ @///@@
,///,*. @////@
%/,@ @@///@
(, .*/&*%/*%///& (@@@ ,*/////*.
%/#@, ////& @ % .& /@%/(/ /#/#@( #@#/&
(/& . .////, &//
&(//@ &//////& @///@
(@%//////#&@@@@@@@@@@%///////@@ @@///////%@@@@@@@@@@//////#@#
%@@@@&@@@@@&&@@@@/ /@@@@&&@@@@@&@@@@&

Taskfile deploy success!

当然,在某些情况下,我们需要撤销已经发布的 Taskfile。此时,我们可以执行如下命令进行撤销。

$ cd my-job-project
$ taskloop undeploy

Taskfile in </Users/baochuquan/Github/taskloop> has been undeployed successfully.

为了便于查看当前已发布的任务,taskloop 提供了一个命令方便用户进行查询。如下所示,为任务查看的使用示例。

$ taskloop list

=============================
Tasks above are defined in Taskfile of </Users/baochuquan/Github/taskloop>
<Task.name: haha, sha1: 637d1f5c6e6d1be22ed907eb3d223d858ca396d8>
t.name = haha
t.path = ./test/Test01.rb
t.year = unit: year; specific: 2023
t.month = unit: month; specific: Aug
t.day = unit: day; default rule
t.hour = unit: hour; default rule
t.minute = unit: minute; scope: between 25, 30
t.loop = unit: loop; default rule
t.start_point = unit: full; default rule
t.end_point = unit: full; default rule
<Task.name: baocq, sha1: 7cc14c1bffcd559180d9906377bfaa41a4f9a980>
t.name = baocq
t.path = ./test/Test02.rb
t.year = unit: year; default rule
t.month = unit: month; default rule
t.day = unit: day; default rule
t.hour = unit: hour; default rule
t.minute = unit: minute; interval: 1
t.loop = unit: loop; loop: 3
t.start_point = unit: full; boundary: start from 2023-7-31 22:31:00
t.end_point = unit: full; boundary: end to 2023-7-31 22:35:00
<Task.name: chuquan, sha1: d461e86c07d232ceebcd2d024ea4b4c33d0f7b4b>
t.name = chuquan
t.path = ./test/Test03.rb
t.year = unit: year; specific: 2023
t.month = unit: month; default rule
t.day = unit: day; default rule
t.hour = unit: hour; scope: after 22
t.minute = unit: minute; interval: 10
t.loop = unit: loop; loop: 1
t.start_point = unit: full; default rule
t.end_point = unit: full; default rule

为了解决 crontab 的日志查询问题,taskloop 同样提供了一个命令支持查询不同维度的日志,包括:系统日志(即 taskloop 运行日志)、任务日志。

如下所示,为查看系统日志的使用示例。

$ 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.
=============================

如下所示,为查看任务日志的使用示例。

$ 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 模板进行介绍。

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

模板中列出了任务支持的所有属性,首先有两个必要属性 namepath

  • name:用于指出任务的名称,同一个 Taskfile 中不能有同名的任务,主要用于日志查询时指定名称。
  • path 用于指出任务的路径,taskloop 会根据此路径加载并执行任务脚本。

模板中的其他属性均为非必要属性,用于描述执行规则。关于执行规则,taskloop 中主要定义如下几种规则。

  • 指定时间规则(Specific Rule)
    • 指定时间规则用于指定特定的时间值,对应的语法是 at
    • 支持指定时间规则的属性有 weekyearmonthdayhourminute,其中 weekmonthday 属性需要使用预定义的符号,其余属性可以直接使用数值。
      • 对于 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)
    • 时间范围规则包含三种子规则,对应的语法分别是:beforebetweenafter
      • before 语法表示在小于等于某个值时执行。
      • between 语法表示在大于等于某个值,且小于等于另一个值时执行。
      • after 语法表示在大于等于某个值时执行。
    • 支持时间范围规则的属性有 weekyearmonthdayhourminute
    • 示例
      • t.year = before 2026
      • t.week = between :Mon, :Fri
      • t.hour = after 12
  • 时间间隔规则(Interval Rule)
    • 时间间隔规则用于指定两次任务之间的时间间隔,对应的语法是 interval
    • 支持时间间隔规则的属性有 yearmonthdayhourminute
    • 示例
      • t.minute = interval 5
      • t.day = interval 1
  • 循环次数规则(Loop Rule)
    • 循环次数规则用于指定任务循环的次数,对应的语法是 loop
    • 支持循环次数规则的属性只有 loop
    • 示例
      • t.loop = loop 10
  • 时间列表规则(Time List Rule)
    • 时间列表规则用于指定任务执行的时间列表,对应的语法是 time。其与 hourminute 属性冲突,不能同时使用。
    • 支持时间列表规则的属性只有 time
    • 示例
      • t.time = time "10:00:00", "7:00:00"
  • 日期列表规则(Date List Rule)
    • 日期列表规则用于指定执行任务的日期列表,对应的语法是 date。其与 yearmonthday 属性冲突,不能同时使用。
    • 支持日期列表规则的属性只有 date
    • 示例
      • t.date = date "2023-10-1, "2023-5-1
  • 执行边界规则(Boundary Rule)
    • 执行边界规则包含两种子规则,对应的语法分别是 fromto
      • 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 在永不停止地运行任务。

忘了说,其实写这篇文章的另一个重要目的是为了推广一下我的作品,也希望有兴趣的朋友能够给一些意见,甚至可以一起参与软件的开发和完善。

  1. taskloop

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK