如何设计稳定性横跨全球的 Cron 服务

2016-10-15 09:48


这篇文章主要来描述下 Google 是如何实现一套可靠的分布式 Cron 服务,服务于内部那些需要绝大多数计算作业定时调度的团队。 在这个系统的实践过程中,我们收获了很多,包括如何设计、如何实现使得它看上去像一个靠谱的基础服务。 在这里,我们来讨论下分布式 Cron 可能会遇到哪些问题,以及如何解决它。

Cron 是 UNIX 中一个常见的工具,用来定期执行一些用户指定的任意任务。我们先来分析下 Cron 的基本原则和它最常见的实现,然后我们来回顾下像 Cron 这样的服务应该如何运行在一个大型的、分布式的环境中,这样即使单机故障也不会对系统可用性造成影响。 我们将会介绍了一个建立在少量机器上的 Cron 系统,然后结合数据中心的调度服务,从而可以在整个数据中心中运行 Cron 任务。

在我们在描述如何运行一个靠谱的分布式 Cron 服务之前,让我们先来从一个 SRE 的角度来回顾下 Cron。

Cron 是一个通用的工具,无论是管理员还是普通用户都可以用它来在系统上运行指定的命令,以及指定何时运行命令,这些指定运行的命令可以是定期垃圾回收,也可以是定期数据分析。 最常见的时间指定格式被称为 crontab,它不仅支持简单的时间周期(如,每天中午一次,每个小时一次),也支持较复杂的时间周期,如每个周六、每个月的第 30 天等等。

Cron 通常只包含一个组件,被称为 crond,它是一个后台守护程序,加载所有需要运行的 cron 定时任务,根据它们接下来的运行时间来进行排序,然后这个守护进程将会等待直到第一个任务开始执行。在这个时刻,crond 将会加载执行这个任务,之后将它放入队列等待下一次运行。

可靠性Reliability

从可靠性的角度来看一个服务,需要有很多注意的地方。

第一,比如 crond,它的故障域本质上来说只是一台机器,如果这个机器没有运行,不论是 cron 调度还是加载的任务都是不可运行的。因此,考虑一个非常简单的分布式的例子 ——— 我们使用两台机器,然后 cron 调度在其中一台机器上运行任务(比如通过 ssh)。然后产生了一个故障域了:调度任务和目标服务器都可能失败。

另外一个需要注意的地方是,即使是 crond 重启(包括服务器重启),上面部署的 crontab 配置也不应该丢失。crond 执行一个任务然后就‘忘记’了这个任务的状态,它并不会尝试去跟踪这个任务的执行状态,包括是否该执行是否已经执行。

anacron 是一个例外,它是 crontab 的一个补充,它尝试运行哪些因为服务器宕机而应该执行却没执行的任务。这仅限于每日或者更小执行频率的任务,但对于在工作站和笔记本电脑上运行维护工作非常有用。通过维护一个包括最后执行时间的配置文件,使得运行这些特殊的任务更加方便。

Cron 的任务和幂等性

Cron 的任务用来执行定期任务,但是除此之外,却很难在进一步知道它们的功能。让我们先把要讨论的主题抛开一边,现在先来就 Cron 任务本身来做下探讨,因为只有理解了 Cron 任务的各种各样的需求,才能知道它是如何影响我们需要的可靠性要求,而这一方面的探讨也将贯穿接下来的文章。

有一些 Cron 任务是幂等性的,这样在某些系统故障的情况下,可以很安全的执行它们多次,比如,垃圾回收。然而有些 Cron 任务却不应该被执行多次,比如某个发送邮件的任务。

还有更复杂的情况,有些 Cron 任务允许因为某些情况而“忘了”运行,而某些 Cron 任务却不能容忍这些,比如,垃圾回收的 Cron 任务每 5 分钟调度一次,即使某一次没有执行也不会有太大的问题,然而,一个月一次的支付薪水的任务,却绝对不允许有失误。

Cron 任务的各种不同的类型使得不可能有一个通用的解决方案,使得它可以应对各种各样的失败。所以,在本文中上面说的那些情况,我们更倾向于错过某一次的运行,而不是运行它们两次或者更多。Cron 任务的所有者应该(也必须)监控着它们的任务,比如返回任务的调用结果,或者单独发送运行的日志给所属者等等,这样,即使跳过了任务的某次执行,也能够很方便的采取对应的补救动作。当任务失败时,我们更倾向于将任务状态置为 “fail closed” 来避免产生系统性的不良状态。

大规模部署 Cron

当从单机到集群部署 Cron 时,需要重新思考如何使 Cron 在这种环境下良好的运行。在对 Google 的 Cron 进行解说之前,让我们先来讨论下单机以及多机之间的区别,以及针对这变化如何设计。

扩展基础架构

常规的 Cron 仅限于单个机器,而大规模部署的 Cron 解决方案不能仅仅绑定到一个单独的机器。假设我们拥有一个 1000 台服务器的数据中心,如果即使是 1/1000 的几率造成服务器不可用都能摧毁我们整个 Cron 服务,这明显不是我们所希望的。

所以,为了解决这个问题,我们必须将服务与机器解耦。这样如果想运行一个服务,那么仅仅需要指定它运行在哪个数据中心即可,剩下的事情就依赖于数据中心的调度系统(当然前提是调度系统也应该是可靠的),调度系统会负责在哪台或者哪些机器上运行服务,以及能够良好的处理机器挂掉这种情况。 那么,如果我们要在数据中心中运行一个任务,也仅仅是发送一条或多条 RPC 给数据中心的调度系统。

然而,这一过程显然并不是瞬时完成的。比如,要检查哪些机器挂掉了(机器健康检查程序挂了怎么办),以及在另外一些机器上重新运行任务(服务依赖重新部署重新调用任务)都是需要花费一定时间的。

将程序转移到另外一个机器上可能意味着损失一些存储在老机器上的一些状态信息(除非也采用动态迁移),重新调度运行的时间间隔也可能超过最小定义的一分钟,所以,我们也必须考虑到上述这两种情况。一个很直接的做法,将状态文件放入分布式文件系统,如 GFS,在任务运行的整个过程中以及重新部署运行任务时,都是用它来记录使用相关状态。 然而,这个解决方案却不能满足我们预期的时效性这个需求,比如,你要运行一个每五分钟跑一次的 Cron 任务,重新部署运行消耗的 1-2 分钟对这个任务来说也是相当大的延迟了。

及时性的需求可能会促使各种热备份技术的使用,这样就能够快速记录状态以及从原有状态快速恢复。

需求扩展

将服务部署在数据中心和单服务器的另一个实质性的区别是,如何规划任务所需要的计算资源,如 CPU 或内存等。

单机服务通常是通过进程来进行资源隔离,虽然现在 Docker 变得越来越普遍,但是使用它来隔离一切目前也不太是很通用的做法,包括限制 crond 以及它所要运行的任务。

大规模部署在数据中心经常使用容器来进行资源隔离。隔离是必要的,因为我们肯定希望数据中心中运行的某个程序不会对其它程序产生不良影响。为了隔离的有效性,在运行前肯定得先预知运行的时候需要哪些资源——包括 Cron 系统本身和要运行的任务。这又会产生一个问题,即如果数据中心暂时没有足够的资源,那么这个任务可能会延迟运行。这就要求我们不仅要监控 Cron 任务加载的情况,也要监控 Cron 任务的全部状态,包括开始加载到终止运行。

现在,我们希望的 Cron 系统已经从单机运行的情况下解耦,如之前描述的那样,我们可能会遇到部分任务运行或加载失败。这时候幸亏任务配置的通用性,在数据中心中运行一个新的 Cron 任务就可以简单的通过 RPC 调用的方式来进行,不过不幸的是,这样我们只能知道 RPC 调用是否成功,却无法具体知道任务失败的具体地方,比如,任务在运行的过程中失败,那么恢复程序还必须将这些中间过程处理好。

在故障方面,数据中心远比一台单一的服务器复杂。Cron 从原来仅仅的一个单机二进制程序,到整个数据中心运行,其期间增加了很多明显或不明显的依赖关系。作为像 Cron 这样的一个基础服务,我们希望得到保证的是,即使在数据中心中运行发生了一些 “Fail”(如,部分机器停电或存储挂掉),服务依然能够保证功能性正常运行。为了提高可靠性,我们应该将数据中心的调度系统部署在不同的物理位置,这样,即使一个或一部分电源挂掉,也能保证至少 Cron 服务不会全部不可用。

Google 的 Cron 是如何建设的

现在让我们来解决这些问题,这样才能在一个大规模的分布式集群中部署可靠的 Cron 服务,然后在着重介绍下 Google 在分布式 Cron 方面的一些经验。

跟踪 Cron 任务的状态

向上面描述过的那样,我们应该跟踪 Cron 任务的实时状态,这样,即使失败了,我们也更加容易恢复它。而且,这种状态的一致性是至关重要的:相比错误的多运行 10 遍相同的 Cron 任务,我们更能接受的是不去运行它。回想下,很多 Cron 任务,它并不是幂等性的,比如发送通知邮件。

我们有两个选项,将 Cron 任务的数据通通存储在一个靠谱的分布式存储中,或者仅仅保存任务的状态。当我们设计分布式 Cron 服务时,我们采取的是第二种,有如下几个原因:

分布式存储,如 GFS 或 HDFS,往往用来存储大文件(如 网页爬虫程序的输出等),然后我们需要存储的 Cron状态却非常非常小。将如此小的文件存储在这种大型的分布式文件系统上是非常昂贵的,而且考虑到分布式文件系统的延迟,也不是很适合。

像 Cron 服务这种基础服务,它需要的依赖应该是越少越好。这样,即使部分数据中心挂掉,Cron 服务至少也能保证其功能性并持续一段时间。这并不意味着存储应该直接是 Cron 程序的一部分(这本质上是一个实现细节)。Cron 应该是一个能够独立运作的下游系统,以便供用户操作使用。

使用 Paxos

我们部署多个实例的 Cron 服务,然后通过 Paxos 算法来同步这些实例间的状态。

Paxos 算法和它其它的替代算法(如 Zab,Raft 等)在分布式系统中是十分常见的。具体描述 Paxos 不在本文范围内,它的基本作用就是使多个不可靠节点间的状态保持一致,只要大部分 Paxos 组成员可用,那么整个分布式系统,就能作为一个整体处理状态的变化。

分布式 Cron 使用一个独立的主任务,见下图,只有它才能更改共享的状态,也只有它才能加载 Cron 任务。我们这里使用了 Paxos 的一个变体—— Fast Paxos,这里 Fast Paxos 的主节点也是 Cron 服务的主节点。

如果主节点挂掉,Paxos 的健康检查机制会在秒级内快速发现,并选举出一个新的主节点。一旦选举出新的主节点,Cron 服务也就随着选举出了一个新的 Cron 主节点,这个新的 Cron 主节点将会接手前一个主节点留下的所有的未完成的工作。在这里 Cron 的主节点和 Paxos 的主节点是一样的,但是 Cron 的主节点需要处理一下额外的工作而已。快速选举新的主节点的机制可以让我们大致可以容忍一分钟的故障时间。

我们使用 Paxos 算法保持的最重要的一个状态是,哪些 Cron 任务在运行。对于每一个运行的 Cron 任务,我们会将其加载运行的开始以及结束同步给一定数量的节点。

主节点和从节点角色

如上面描述的那样,我们在 Cron 服务中使用 Paxos 并部署,其拥有两个不同的角色,主节点以及从节点。让我们来就每个角色来做具体的描述。

主节点

主节点用来加载 Cron 任务,它有个内部的调度系统,类似于单机的 crond,维护一个任务加载列表,在指定的时间加载任务。

当任务加载的时刻到来,主节点将会 “宣告” 它将会加载这个指定的任务,并且计算这个任务下次的加载时间,就像 crond 的做法一样。当然,就像 crond 那样,一个任务加载后,下一次的加载时间可能人为的改变,这个变化也要同步给从节点。简单的标识 Cron 任务还不够,我们还应该将这个任务与开始执行时间相关联绑定,以避免 Cron 任务在加载时发生歧义(特别是那些高频的任务,如一分钟一次的那些)。这个“通告”通过 Paxos 来进行。下图展示了这一过程。

保持 Paxos 通讯同步非常重要,只有 Paxos 法定数收到了加载通知,这个指定的任务才能被加载执行。Cron 服务需要知道每个任务是否已经启动,这样即使主节点挂掉,也能决定接下来的动作。如果不进行同步,意味着整个 Cron 任务运行在主节点,而从节点无法感知到这一切。如果发生了故障,很有可能这个任务就被再次执行,因为没有节点知道这个任务已经被执行过了。

Cron 任务的完成状态通过 Paxos 通知给其它节点,从而保持同步,这里要注意一点,这里的“完成” 状态并不是表示任务是成功或者失败。我们跟踪 Cron 任务在指定调用时间被执行的情况,我们同样需要处理一点情况是,如果 Cron 服务在加载任务进行执行的过程中失败后怎么办,这点我们在接下来会进行讨论。

主节点另一个重要的特性是,不管是出于什么原因主节点失去了其主控权,它都必须立马停止同数据中心调度系统的交互。主控权的保持对于访问数据中心应该是互斥了。如果不这样,新旧两个主节点可能会对数据中心的调度系统发起互相矛盾的操作请求。

从节点

从节点实时监控从主节点传来的状态信息,以便在需要的时刻做出积极响应。所有主节点的状态变动信息,都通过 Paxos 传到各个从节点。和主节点类似的是,从节点同样维持一个列表,保存着所有的 Cron 任务。这个列表必须在所有的节点保持一致(当然还是通过 Paxos)。

当接到加载任务的通知后,从节点会将此任务的下次加载时间放入本地任务列表中。这个重要的状态信息变化(这是同步完成的)保证了系统内部 Cron 作业的时间表是一致的。我们跟踪所有有效的加载任务,也就是说,我们跟踪任务何时启动,而不是结束。

如果一个主节点挂掉或者因为某些原因失联(比如,网络异常等),一个从节点有可能被选举成为一个新的主节点。这个选举的过程必须在一分钟内运行,以避免 Cron 任务丢失的情况。一旦被选举为主节点,所有运行的加载任务(或部分失败的),必须被重新验证其有效性。这个可能是一个复杂的过程,在 Cron 服务系统和数据中心的调度系统上都需要执行这样的验证操作,这个过程有必要详细说明。

故障恢复

如上所述,主节点和数据中心的调度系统之间会通过 RPC 来加载一个逻辑 Cron 任务,但是,这一系列的 RPC 调用过程是有可能失败的,所以,我们必须考虑到这种情况,并且处理好。

回想下,每个加载的 Cron 任务会有两个同步点:开始加载以及执行完成。这能够让我们区分开不同的加载任务。即使任务加载只需要调用一次 RPC,但是我们怎么知道 RPC 调用实际真实成功呢?我们知道任务何时开始,但是如果主节点挂了我们就不会知道它何时结束。

为了解决这个问题,所有在外部系统进行的操作,要么其操作是幂等性的(也就是说,我们可以放心的执行它们多次),要么我们必须实时监控它们的状态,以便能清楚的知道何时完成。

这些条件明显增加了限制,实现起来也有一定的难度,但是在分布式环境中这些限制却是保证 Cron 服务准确运行的根本,能够良好的处理可能出现的 “fail”。如果不能妥善处理这些,将会导致 Cron 任务的加载丢失,或者加载多次重复的 Cron 任务。

大多数基础服务在数据中心(比如 Mesos)加载逻辑任务时都会为这些任务命名,这样方便了查看任务的状态,终止任务,或者执行其它的维护操作。解决幂等性的一个合理的解决方案是将执行时间放在名字中 ——这样不会在数据中心的调度系统里造成任务异变操作 —— 然后在将它们分发给 Cron 服务所有的节点。如果 Cron 服务的主节点挂掉,那么新的主节点只需要简单的通过预处理任务名字来查看其对应的状态,然后加载遗漏的任务即可。

注意下,我们在节点间保持内部状态一致的时候,实时监控调度加载任务的时间。同样,我们也需要消除同数据中心调度交互时可能发生的不一致情况,所以这里我们以调度的加载时间为准。比如,有一个短暂但是频繁执行的 Cron 任务,它已经被执行了,但是在准备把情况通告给其它节点时,主节点挂了,并且故障时间持续的特别长——长到这个 Cron 任务都已经成功执行完了。然后新的主节点要查看这个任务的状态,发现它已经被执行完成了,然后尝试加载它。如果包含了这个时间,那么主节点就会知道,这个任务已经被执行过了,就不会重复执行第二次。

在实际实施的过程中,状态监督是一个更加复杂的工作,它的实现过程和细节依赖与其它一些底层的基础服务,然而,上面并没有包括相关系统的实现描述。根据你当前可用的基础设施,你可能需要在冒险重复执行任务和跳过执行任务 之间做出折中选择。

状态保存

使用 Paxos 来同步只是处理状态中遇到的其中一个问题。Paxos 本质上只是通过一个日志来持续记录状态改变,并且随着状态的改变而进行将日志同步。这会产生两个影响:第一,这个日志需要被压缩,防止其无限增长;第二,这个日志本身需要保存在一个地方。

为了避免其无限增长,我们仅仅取状态当前的快照,这样,我们能够快速的重建状态,而不用在根据之前所有状态日志来进行重演。比如,在日志中我们记录一条状态 “计数器加 1”,然后经过了 1000 次迭代后,我们就记录了 1000 条状态日志,但是我们也可以简单的记录一条记录 “将计数器设置为 1000”来做替代。

如果日志丢失,我们也仅仅丢失当前状态的一个快照而已。快照其实是最临界的状态 —— 如果丢失了快照,我们基本上就得从头开始了,因为我们丢失了上一次快照与丢失快照期间所有的内部状态。从另一方面说,丢失日志,也意味着,将 Cron 服务拉回到有记录的上一次快照所标示的地方。

我们有两个主要选择来保存数据: 存储在外部的一个可用的分布式存储服务中,或者,在内部一个系统来存储 Cron 服务的状态。当我们设计系统时,这两点都需要考虑。

我们将 Paxos 日志存储在 Cron 服务节点所在服务器本地的磁盘中。默认的三个节点意味着,我们有三份日志的副本。我们同样也将快照存储在服务器本身,然而,因为其本身是非常重要的,我们也将它在分布式存储服务中做了备份,这样,即使小概率的三个节点机器都故障了,也能够服务恢复。

我们并没有将日志本身存储在分布式存储中,因为我们觉得,丢失日志也仅仅代表最近的一些状态丢失,这个我们其实是可以接受的。而将其存储在分布式存储中会带来一定的性能损失,因为它本身在不断的小字节写入不适用与分布式存储的使用场景。同时三台服务器全故障的概率太小,但是一旦这种情况发生了,我们也能自动的从快照中恢复,也仅仅损失从上次快照到故障点的这部分而已。当然,就像设计 Cron 服务本身一样,如何权衡,也要根据自己的基础设施情况来决定。

将日志和快照存本地,以及快照在分布式存储备份,这样,即使一个新的节点启动,也能够通过网络从其它已经运行的节点处获取这些信息。这意味着,启动节点与服务器本身并没有任何关系,重新安排一个新的服务器(比如重启)来担当某个节点的角色 其本质上也是影响服务的可靠性的问题之一。

运行一个大型的 Cron

还有一些其它的、小型的,但是同样有趣的一些情况或能影响部署一个大型的 Cron 服务。传统的 Cron 规模很小:最多包含数十个 Cron 任务。然而,如果在一个数据中心的超过千台服务器来运行 Cron 服务,那么你就会遇到各种各样的问题。

一个比较大的问题是,分布式系统常常要面临的一个经典问题:惊群问题,在 Cron 服务的使用中会造成大量的尖峰情况。当要配置一个每天执行的 Cron 任务,大多数人第一时间想到的是在半夜执行,然后它们就这么配置了。如果一个 Cron 任务在一台机器上执行,那没有问题,但是如果你的任务是执行一个涉及数千 worker 的 mapreduce 任务,或者,有 30 个不同的团队在数据中心中要配置这样的一个每天运行的任务,那么我们就必须要扩展下 crontab 的格式了。

传统的 crontab,用户通过定义“分钟”,“小时”,“每月(或每周)第几天”,“月数”来指定 cron 任务运行的时间,或者通过星号(*)来代表每个对应的值。如,每天凌晨运行,它的 crontab 格式为0 0 * * *,代表每天的 0 点 0 分运行。我们在此基础之上还推出了问号()这个符号,它标示,在这个对应的时间轴上,任何时间都可以,Cron 服务就会自由选择合适的值,在指定的时间段内随机选择对应的值,这样使任务运行更均衡。如 0 ? * * *,表示每天 0-23 点钟,随机一个小时的 0 分来运行这个任务。

尽管加了这项变化,由 Cron 任务所造成的 load 值仍然有明显的尖峰,下图表示了 Google 中 cron 任务加载的数量。尖峰值往往表示那些需要固定频率在指定时间运行的任务。

总结

Cron 服务作为 UNIX 的基础服务已经有接近 10 年。当前整个行业都朝着大型分布式系统演化,那时,表示硬件的最小单位将会是数据中心,那么大量的技术栈需要对应改变,Cron 也不会是例外。仔细审视下 Cron 服务所需要的服务特性,以及 Cron 任务的需求,都会推动我们来进行新的设计。

基于 Google 的解决方案,我们已经讨论了 Cron 服务在一个分布式系统中对应的约束和可能的设计。这个解决方案需要在分布式环境中的强一致性保证,它的实现核心是通过 Paxos 这样一种通用的算法,在一个不可靠的环境中达成最终一致。使用 Paxos,正确对大规模环境下 Cron 任务失败情况的分析,以及分布式的环境的使用,共同造就了在 Google 内部使用的健壮的 Cron 服务。