背景
对于业务上需要定时执行的后台任务,我们使用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.minutes
、1.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的日志才能知道任务执行的具体情况。
失败后重新执行
crontab没有重试机制,如果想失败后重新执行,需要在每个业务代码中实现相应的逻辑。作为一个定位于Fault Tolerant的任务调度框架,chronos当然是默认支持重试的。如果任务执行失败,chronos会尝试重新执行,直到成功或者达到最大重试次数。
分布式执行任务
mesos是一个分布式调度框架,chronos任务会被mesos分配到合适的节点上执行。配合重试机制,当任务因某一个节点故障而执行失败后,chronos会在其他节点重新执行该任务。
降低自动化监控的复杂性
任务被放入chronos,其类型和marathon上的服务一样,都属于mesos任务。这样就统一了自动化监控的逻辑,只需要监控所有mesos任务即可,不用为不同类型的任务编写不同的监控逻辑。