使用Chronos执行whenever任务

背景

对于业务上需要定时执行的后台任务,我们使用ruby的whenever模块定义执行时间及其对应的rake任务,然后将所有任务放入一个长期运行的docker容器,由里面的crond服务执行配置的cron任务。

在我们将容器调度框架迁移到mesos+marathon之后,所有容器都需要设置资源配额。执行后台任务的容器闲置时白白占用了配额,而且配额的设置也只能以所有任务中最重的一个为准,所以这种方式造成了较大的资源浪费。

针对这个问题,我们将所有whenever任务拆分为短进程运行在chronos上,每个任务单独配置资源,并且执行完毕立即退出,资源不会闲置。

拆分whenever任务

尽管要改造whenever任务,但我们并不打算抛弃whenever转而在业务层直接定义chronos支持的ISO 8601时间格式 。一是whenever本身提供的DSL语法非常简单直观,对Ruby开发者更加友好;二是服务的运行环境对于业务层来说应该是透明的。我们的做法是增加一个解析模块,在部署的时候将whenever的schedule.rb转换为多个chronos任务。

   # schedule.rb
   every 10.minutes do
     rake "my_job:job1"
   end

   every 1.day, :at => '08:00 am' do
     rake "my_job:job2"
   end

   every 1.month, :at => '1st' do
     rake "my_job:job3"
   end

解析schedule.rb

这个解析器和whenever本身的功能类似,whenever将schedule.rb转换为crontab格式,我们的解析器则将其转化为chronos支持的格式。

由于schedule.rb是ruby代码,所以使用ruby来实现解析器自然也最为方便。只需要实现对应的every()方法以及rake()方法,在every()rake()方法中将解析到的执行时间和执行语句存入实例变量@schedules中即可。

  class Schedule
    attr_reader :schedules
    def initialize
      @schedules = []
    end
  end

  s = Schedule.new
  s.instance_eval File.read(schedule_file)
  s.schedules.map do |schedule|
    gen_chronos_config service, image, schedule
  end

虽然代码逻辑很简单,但依然有几点是需要注意。

间隔时间

我们将形如10.minutes1.day以及1.month的时间表示定义为Fixnum的对应方法,最终其会被转换为秒数,这在rails中相当常见。在every()方法内拿到的间隔时间就是转换之后的秒数,但是对于2592000这个数,它究竟是表示30.days天还是1.month呢?

好在这并不是一个问题,因为whenever也是这样处理的。whenever会将时间解析为尽可能大的单位,因此2592000表示1个月而不是30天。我们在实现上沿用这个逻辑。

  when Whenever.seconds(1, :day)...Whenever.seconds(1, :month)
    day_frequency = (@time / 24 / 60 / 60).round
    timing[0] = @at.is_a?(Time) ? @at.min  : 0
    timing[1] = @at.is_a?(Time) ? @at.hour : @at
    timing[2] = comma_separated_timing(day_frequency, 31, 1)
  when Whenever.seconds(1, :month)...Whenever.seconds(1, :year)
    month_frequency = (@time / 30 / 24 / 60 / 60).round
    timing[0] = @at.is_a?(Time) ? @at.min  : 0
    timing[1] = @at.is_a?(Time) ? @at.hour : 0
    timing[2] = if @at.is_a?(Time)
      day_given? ? @at.day : 1
    else
      @at.zero? ? 1 : @at
    end
    timing[3] = comma_separated_timing(month_frequency, 12, 1)

另外,受crontab的限制,whenever无法表示every(45.days)这样的时间,原因是crontab中日期字段不在0-31之间的话(0 0 */45 * *),最终的执行效果是每天执行一次。如果非要用crontab表示每45天执行一次,应该是这样的:

0 0 1 1,4,7,10 *   my-job.sh
0 0 15 2,5,8,11 *  my-job.sh

大概是没有必要为这种小众需求实现复杂的逻辑,whenever会将45天简化为1个月。在chronos中则没有这个问题,可以放心地使用这样的时间。

开始执行时间

不同于crontab默认总是从零时零分开始计算下次执行时间,chronos的schedule格式需要自己指定起始时间。

  R/2017-09-20T17:30:00Z/PT60M

以斜杠分隔的第二部分即为开始执行时间,第三部分为执行间隔时间,下一次应该什么时候执行都是从第二部分指定的时间开始计算的。为了保证每一次的执行时间都符合预期,这里需要一个绝对时间作为开始执行时间。但又不能简单地设为过去某个时间点,原因和chronos另一个规则有关:当一个任务部署后,chronos会判断其开始执行时间,如果大于当前时间则等到了开始时间再执行任务,小于的话则立即执行一次任务。也就是说,如果开始时间为过去时间,那么任务每次被部署后都会被执行一次。

这里正确的做法大致是:在代码中设置一个过去时间作为基准时间,部署时根据基准时间和间隔时间计算出下次的执行时间。

这个基准时间的计算还是稍稍有些麻烦的:

  • 基准时间对于每个任务都可能不一样,因为every方法中有一个at参数可以指定任务的开始时间,如果基准时间设为绝对时间,那at参数就没有用了。
  • 关于at参数的处理,我们和whenever保持一致,使用chronic模块来解析。如果有指定at参数,使用chronic解析得到一个Time类型,否则就使用一个绝对的BaseTime作为基准时间。
  • chronic得到的Time类型,其实也是动态的。比如解析1:10 am这个at参数,得到的是当天的时间2017-09-21 01:10:00。为了每次部署时都得到一个不变的基准时间,需要将其与BaseTime合并。
  • 合并的策略是,判断间隔时间的最小单位,比间隔时间单位小的单位,从解析at得到的Time中选取,否则从BaseTime中选取。例如every 2.days, at: '1:10 am',基准时间中的时、分、秒,来自at,即01:10:00

有了基准时间,再根据间隔时间就可以计算出下一次的执行时间了。

  BaseTime = (DateTime.new 2016, 1, 1).to_time
  def get_start_time interval, at
    now = Time.now
    at_time = Chronic.parse at

    if at_time
      at_time = merge_time at_time, BaseTime, interval
    else
      at_time = BaseTime
    end

    while at_time <= now
      at_time = add_interval at_time, interval
    end

    at_time
  end

任务名称

whenever中,并不关注任务的名称。但在chronos中,每个rake都被拆分成了独立的任务,所以就需要一个名字来标识每一个任务。我们使用rake方法的参数——rake task name——作为任务名,当然,还要处理特殊字符以及重名的情况。

修改/删除任务

我们将schedule.rb中定义的所有任务拆分然后部署到chronos,如果schedule.rb中某个任务被删除或者改名了,那么旧的任务还保留在chronos中。对于这种情况的处理,我们在部署的时候记录下本次部署的所有任务名,部署完成后遍历chronos上的所有任务,所有不在本次部署中的任务则是需要删除的。

为了叙述上的简单,这里假设chronos上的所有任务都是由一个schedule.rb创建的。实际上我们的chronos上运行着多种类型的任务,我们使用一个前缀作为命名空间来区分不同类型的任务。

其他改进

拆分成多个chronos任务后,每个任务以短进程的方式运行,执行完毕即退出,释放相应资源,而且还能对不同的任务配置不同的配额,大大提高了资源利用率。此外,这次迁移还带来了一些额外的改进。

任务列表可视化

通过chronos的Web UI,可以查看所有任务的上次执行状态,以及下次执行时间,在Mesos Web UI上也可以查看每个失败任务的错误日志。以往的运行模式只能登陆到容器中查看cron的日志才能知道任务执行的具体情况。

使用Chronos执行whenever任务

失败后重新执行

crontab没有重试机制,如果想失败后重新执行,需要在每个业务代码中实现相应的逻辑。作为一个定位于Fault Tolerant的任务调度框架,chronos当然是默认支持重试的。如果任务执行失败,chronos会尝试重新执行,直到成功或者达到最大重试次数。

分布式执行任务

mesos是一个分布式调度框架,chronos任务会被mesos分配到合适的节点上执行。配合重试机制,当任务因某一个节点故障而执行失败后,chronos会在其他节点重新执行该任务。

降低自动化监控的复杂性

任务被放入chronos,其类型和marathon上的服务一样,都属于mesos任务。这样就统一了自动化监控的逻辑,只需要监控所有mesos任务即可,不用为不同类型的任务编写不同的监控逻辑。

上一篇:2018.07.26NOIP模拟 魔法数字(数位dp)


下一篇:as3中的socket的readUTFBytes方法,它的UTF指的是UTF-8,只要服务器采用UTF-8编码,传输和修改中文的问题就能解决