分布式系统设计:批处理模式之作业队列系统

分布式系统设计:批处理模式之作业队列系统

之前的文章讲述了关于可靠的、长时间运行的应用(long-running server applications)的设计模式,本篇介绍批处理的模式。与先前介绍的长时间运行应用所不同的是,批处理的过程预计只能运行很短的时间。例如,通过汇总用户的数据来分析每天或每周的销售情况,或者是转码视频文件等。批处理的特征为用很快的速度来处理大量的数据,其中通过并行的方式来加快处理速度。分布式批处理中最著名的模式就是MapReduce,并且已经逐渐发展为一个行业,但是还有一些可以用于批处理的模式,之后的文章会逐一进行介绍。




批处理作业最简单的形式就是作业队列,在作业队列系统中,有一堆的作业需要执行,每一个作业都完全独立于其他的作业,因此可以独立的进行处理。作业队列系统的目标是:确保每个作业都可以在一定的时间内完成。批处理作业的Worker可以进行动态地扩容和缩容来保证作业的执行,一个通用的作业队列如图1所示。

图1


通用的作业队列系统


作业队列可以很好的展示分布式系统模式所提供的强大功能。作业队列中的大部分逻辑完全独立于正在被处理的作业,并且在很多情况下,作业的交付也可以以独立的方式执行,可以通过图1来思考这一点。同时,它也可以通过一组共享的容器库来提供功能,如图2所示,大部分容器化的作业队列可以在各种各样的用户之间进行共享,可重复使用的系统容器显示为白色,而用户提供的容器显示为灰色。

图2


构建一个基于容器的可重用作业队列需要定义库容器和用户定义的应用程序逻辑之间的接口,在容器化的作业队列中,有两个接口:Source Container Interface,用来提供需要处理的作业流;另外一个是 Worker Container Interface,它知道如何来处理一个作业项。


Source Container Interface

每个作业队列都需要一组需要处理的作业项来进行操作,作业队列有许多不同的作业来源,具体取决于作业队列的特定应用程序,但是一旦作业队列获得一组作业并开始运行,它处理作业的方式就很通用了。因此,我们可以将应用程序特定的 Queue Source 逻辑和通用队列的处理逻辑分开。鉴于之前已经介绍的容器组织模式,这个可以看作是大使模式的一个例子。通用作业队列容器是主要的应用程序容器,特定于应用程序的 Source Container 是代理,它将通用作业队列的请求代理到现实世界的作业队列中去。这个容器组如图3所示。

图3


有趣的是,虽然大使容器是特定于应用程序的,但是也有多种通用的作业队列API。例如,作业来源可能是通过云存储平台的API获得的图片列表、存储在网络存储平台上的文件集合、或者像Kafka或Redis的发布/订阅系统中的队列,在这些情况下,尽管用户选择适合他们场景的特定作业队列大使,但是他们应该重用容器本身的一个通用“库”来实现,这样可以最大限度的减少工作量并最大限度的对代码进行重用。


Worker Container Interface

一旦作业队列管理系统(Manager)获得了特定的作业,它就需要由Worker来进行处理,这是我们通用作业队列中的第二个容器接口。出于几个原因,此容器和接口与前面介绍的 Work Queue Source Interface 略有不同。首先,它是一次性的API:一次调用就可以开始工作,并且在Worker容器的整个生命周期中不会进行其他API调用;其次,Worker容器不在具有作业队列管理系统的容器组内,而是通过容器编排的API启动的,并调度到自己所在的容器组中,这意味着作业队列管理系统必须对Worker容器进行远程调用才能开始工作,这也意味着我们需要更加小心,以防集群中的恶意用户向系统中注入额外的作业。

通过 Work Queue Source API,我们使用一个简单的基于HTTP的API将作业发送回作业队列管理系统,这是因为我们需要重复调用API,因为所有的都是在localhost上运行的,所以安全性并不是问题。通过Worker容器,我们只需要进行一次调用,并且我们希望确保系统中的其他用户不会意外地或恶意地向Worker添加作业。所以,对于Worker容器,我们使用基于文件的API,也就是说,当创建Worker时,它将接收一个名为WORK_ITEM_FILE的环境变量,它将指向本地文件系统容器中的一个文件,其中来自作业项的数据字段已经被写入文件。具体来说,如图4所示,这个API可以通过一个 Kubernetes ConfigMap 对象来实现,该对象可以作为一个文件被装载到一个容器组中。


图4

这个基于文件的API模式对容器来说也更容易实现,通常一个作业队列的Worker只是一个shell脚本,在这种情况下,就没有必要启动 Web 服务器来管理要执行的作业。与 Work Queue Source 的实现一样,大多数的作业容器都是为特定作业队列应用程序构建的专用容器镜像,但也有通用的 Worker 可应用于多种不同的作业队列应用程序。

思考一个例子,该作业队列的Worker从云存储平台下载文件并运行带有该文件的shell脚本作为输入,然后将其输出复制回云存储平台。这样的容器大部分是通用的,但是可以将特定的脚本作为运行时参数提供给它,这样文件处理的大部分工作可以被多个用户/作业队列共享,并且只需要向最终的用户提供文件处理的细节。


共享作业队列的基础结构

鉴于前面介绍的两个容器接口的实现,为了实现我们的可重用作业队列,我们还需要实现什么呢?作业队列的基本算法非常简单:

  1. 通过调用 Source Container Interface 来加载空闲的作业;
  2. 通过查询作业队列的状态来确定哪些作业已经被处理或者正在被处理当中;
  3. 对于这些作业项,生成一些使用 Worker Container Interface 进行处理的作业;
  4. 当其中一个Worker容器完成时,记录下那些已经被完成的作业。


虽然这种算法很容易用文字去表达,但实际上,实现起来要复杂一些,幸运的是,Kubernetes 的容器编排包含了许多特性,使其更容易实现。也就是说,Kubernetes包含了一个Job对象,这允许可靠的执行作业队列,作业可以配置为在 Worker 容器上只运行一次或者一直运行直到它成功完成。如果Worker容器设置为运行完成,那么即使集群中的某台机器发生故障,作业最终也会成功运行,这样极大地简化了作业队列的构建,因为编排系统对每个作业的可靠执行负责。


此外,Kubernetes 还为每个Job对象提供注释,使我们能够在每个作业上标记它正在处理的作业项,了解哪些作业项正在被处理中,以及哪些已经成功完成或者失败了。


整体来讲,这意味着我们可以在 Kubernetes 的编排系统层之上实现一个作业队列,而无需使用我们自己的任何存储,这极大地简化了构建作业队列基础结构的任务。


因此,我们使用如下的方式进行作业队列容器的扩容操作:


重复的进行如下操作:

  1. 从 Work Source Container Interface 获得作业项的列表;
  2. 获得为服务此作业队列而创建的所有作业的列表;
  3. 通过对列表中的作业进行区分来获得尚未进行处理的作业项集合;
  4. 对于这些没有处理的作业项,创建新的Job对象,以生成适当的 Worker 容器。



Worker的动态缩放


先前描述的作业队列对于到达作业队列中作业项的处理速度非常快,但是这会导致突发性资源负载被放置到集群的编排系统集群上,如果你有许多不同的工作负载在不同的时间爆发还好,因为这样可以保证基础框架的平均资源利用率,但是如果你没有足够数量的不同负载,通过过多或者过少的方式来缩放作业队列,那么就需要你通过 Over-Provision Resource 的方式来支持这种突发负载的发生,同时当没有那么多作业需要处理时,那些过度分配的资源就会闲置下来,从而造成很大的浪费(资源/金钱)。

为了解决这个问题,可以限制作业队列创建的 Job 对象的总数,这样做自然会限制作业处理的并行性,并因此限制你在特定时间可以使用的最大资源量。然而,这样做会增加在高负载下作业完成的时间,如果负载是突发性的,那还好,因为你可以用冗余(slack)的时间来处理高负载下积压的作业,但是,如果在平时稳定的状态下利用率就很高,那么在高负载下作业队列可能永远无法赶上来,作业完成的时间将会变得越来越长。

当你的作业队列面临这种情况时,你需要让它自己动态调整来增加并行性(相应的增加资源)以便跟上不断进入队列的作业。幸运的是,我们可以使用数学公式来确定何时需要对我们的作业队列进行动态扩容。

思考一个这样的作业队列,平均每分钟会有一个新的作业项到达,每个作业项平均需要30秒才能完成,这样的系统能够跟上它接收到的所有作业,即使大量的作业同时到达并产生了积压,平均而言,作业队列会处理2个到达的作业项,因此它可以逐步完成这些积压的作业。

相反,如果一个作业队列平均每分钟会有一个新的作业项到达,每个作业平均需要1分钟才能完成,那么这个系统是完全平衡的,但它不能很好的处理突发负载,它可以在突发负载的情况下赶上,但是这需要一段时间,并且它没有冗余的时间或能力来处理作业到达速率的持续增长。这可能不是一个理想的运行方式,因为保持一个稳定的系统需要一些安全余量,通过这些安全余量来保持增长和其他作业量的持续增长或者作业处理过程中的意外减速。

最后,考虑一个每分钟都会有一个作业项到达的系统,每个作业项需要2分钟来处理。在这样一个系统中,作业的队列将会无限制的增长,队列中的作业项的延迟也会无限制的增长,并且用户也会变的十分痛苦。

因此,我们可以跟踪我们作业队列中的一些指标,作业项之间的平均时间将为我们提供新作业的到达时间间隔;我们还可以跟踪作业处理的平均时间(不包括在队列中的时间)。要拥有一个稳定的作业队列,我们需要调整资源的数量,以便处理任何作业项的时间少于新作业项的到达时间。如果我们正在并行处理作业,我们也将作业项的处理时间除以并行度,例如,每个作业项处理需要1分钟,但我们能并行处理四个作业项,则处理一个作业项的有效时间为15秒,因此我们可以保持16秒或者更长的到达时间间隔。

这种方法使构建一个自动调整器来对我们作业队列的大小进行动态扩容非常简单,虽然缩小作业队列的规模有一些棘手,但是可以使用相同的数学计算方法以及启发式的方法来维护资源的安全余量。例如,可以减少并行度,直到某个作业项的处理时间为新作业项到达间隔时间的90%。


Multi-Worker Pattern


本书的主题之一是使用容器来封装和重用代码,对于本文描述的作业队列模式也是如此。除了重用容器来驱动作业队列本身的模式之外,还可以重用多个不同的容器来进行Worker的实现。例如,假设你有三种不同类型的作业要在特定的作业队列上执行;例如,你可能想要检测图像中的人脸,用身份标记这些人脸,然后模糊图像中的人脸。你可以编写一个Worker来完成这一整套的任务,但是这将是一个定制的解决方案,下次你想识别别的东西时(如汽车),它仍然不能进行重用,但仍然会产生相同的模糊效果。

为了实现这种代码重用,Multi-Worker模式是前面章节中描述的适配器模式的一个特例。在这种情况下,Multi-Worker模式将不同的Worker容器的集合转换为实现Worker接口的单个统一容器,但将实际作业分配给一组不同的可容用容器,如图5所示。

图5


因为这种代码重用,多个不同Worker容器的组合意味着代码重用的增加,以及设计面向批处理的分布式系统工作量的减少。

发布于 2018-03-17

文章被以下专栏收录