工作流系统的设计

2022-07-19 15:11:06 浏览数 (2)

几年前曾经写过一点点对于缓存框架设计的体会,这大半年和工作流系统打交道颇为丰富,因此想总结一点关于工作流系统的设计。

首先,明确工作流(workflow)系统的定义。维基百科上有极其简单的介绍。我记得以前在文章里面说过,作为大公司里面的小 team,为了做一些有趣的东西,从而更好的招人,通常有几个众人皆知的突破口:比如一个更符合业务需求的 storage,再比如一个自定义的工作流系统。在 Amazon 内部,我接触过好多个 workflow,而且大多以 Amazon SWF 为原型(当时学习的时候还写了一点体会,link 1 和 link 2),于是宏观上看,60% 的东西是一样的,大同小异;但是也有很多重要的元素大不相同,而它们被放到一起比较也是常事。几次折腾之后,我也慢慢在思考,如何去设计一个工作流系统,其中都有哪些重要的需要考虑到的方面。

Scalability

基本上随便设计什么基础设施,扩展性都是重要的考虑内容。作为 workflow 来讲,基本上工作节点的水平扩展是考量扩展性的最重要标志。既然工作节点可以水平扩展,那么这就意味着任务(task)必须是以 pull 的方式由工作节点主动去获取,而不是由 push 的方式从调度节点来分配(曾经非常简单地比较过 pull 和 push,但其实二者差异远不止文中内容之浅显)。任务的分配上,需要考虑这样的事情:如果有多个工作节点尝试来 pull 任务,该分配给谁?具体来说,比如这样的例子:如果每一个 task 节点允许同时执行 5 个任务,而现在可同时执行的总任务数只有 5 个,总共的 task 节点也有 5 个,最理想的状态应当是这 5 个被均匀分配到这 5 个节点去,但是采用简单的 pull 机制并不能保证这一点,有可能这 5 个任务全部跑到一台机器上去了,因为这并不超过一个节点可同时执行任务数量的上限。

另一方面,通常来讲,所有任务都应当是 idempotent 的,即可以重复提交执行,执行若干次和执行一次的结果是一样的。工作节点的任务执行可以在任意一步发生错误,随着节点数量的增加,这样的错误更多地成为一种常态,而不是 “异常”。工作节点的健康状态需要由某种方式来维系和通知,最典型和廉价有效的方式就是 “心跳”,我曾经写过一篇文章详细介绍一种心跳系统的设计,感兴趣的话,欢迎移步阅读。

功能性解耦

  • 资源管理和任务管理解耦。这一点我只在少数 workflow 里面见到。任务管理几乎是所有 workflow 都具备的,但是单独的资源管理则不是。举例来说,我可以写一个 task 去执行 EMR 上的任务,你也可以写一个 task 去 EMR 上执行,EMR 的执行管理逻辑,可以以代码的方式被我们共用——但是这种架构下,你的 task 和我的 task 很难安全高效地共享同一个 EMR 资源,无论是资源的创建、销毁,状态的查询,还是 throttling,都变得很麻烦。类似的例子还有,数据库的共享,打印机的共享,甚至另外一个工作流系统的共享。当有开销较大的资源,我们经常需要 workflow 层面被统一管理起来,管理一份或者几份资源,但是共享给数量众多的 task。
  • 业务逻辑和调度逻辑解耦。这基本上在所有 workflow 里面都具备,调度逻辑是业务无关的,也是相对来说 “死” 的东西,管理工作流的状态,和每个 task 的成功失败。但是业务逻辑则是组成 workflow 和其中的 task“活生生” 的血肉。我还没有见过哪个 workflow 把业务代码和调度逻辑写到一起。
  • 状态查询和调度系统的解耦。一个完善的工作流系统,调度只能是其中核心的一个方面,如果没有一个好的状态查询系统,维护的工作量将是巨大的。而这二者,必须解耦开。举例来说,工作流和任务执行的状态,必然是持久化在某种存储介质中,比如关系数据库,比如 NoSQL 的数据库,比如磁盘日志文件等等。这个时候,调度系统可以说是这些信息写入存储系统的最主要来源,而这些信息的读取,则可能从调度系统读取,也可能从状态查询系统读取。这个存储的格式或者说 schema,必须相对稳定。这个存储的一致性和可用性,将是整个系统一致性和可用性的核心组成部分。
  • 决策系统和执行系统解耦。决策系统用于决定某个任务是否满足条件并开始该执行,它是整个工作流系统的大脑;执行系统则是具体的一个个任务,它是整个工作流系统的骨肉。
  • 事件系统和监听系统解耦。涉及这个的工作流只占少数。很多工作流系统都有内部的事件系统,比如某个 task 分配给某个节点了,某个 task 执行失败了等等,但是这样事件的监听系统,却没有独立出来,导致后续针对特殊事件要执行特定逻辑变得困难。

同步与异步任务

事实上,当考虑到了独立的资源管理功能,异步和同步任务的划分就变得自然而然。

  • 有很多任务是需要在当前的工作节点上执行的。比如需要在工作节点上下载一个文件,然后经过处理以后写到数据库里去,这些任务消耗大量的内存和 CPU,需要分配独立的专属的线程去完成,是同步任务。
  • 还有一些任务,工作节点并非实际的工作执行者,而是针对某一个资源系统的客户端,只负责提交任务到该系统内,并且负责管理和监控。比如打印任务,向打印机提交打印请求,然后只需要不断地向打印机查询任务的状态,以及根据需要作出删除任务和重新提交等操作即可。这些任务通常不需要长期占有线程,一个线程可以在一个周期内处理多个任务。它们是异步任务。另外,举一个特例,工作流的嵌套,即工作流调用子工作流,那么对于子工作流状态的查询这个行为来说,必然是异步任务。异步任务就涉及到事件的通知和监听机制,后文有提到。

分布式锁

在某些情况下,分布式锁变成一个必选项。比如前面提到的资源管理。有许多资源是要求操作是独占的,换言之,不支持两个操作并发调用,期间可能出现不可以预料的问题;另一方面,一个节点在对资源进行操作时,它需要和别的节点进行协作,从而两个工作节点的操作是有序和正确的,不至于发生冲突。

举个例子来说,工作节点 A 要查询当前 EMR 的状态,如果已经空闲 10 分钟,就要执行操作结束掉这个 EMR 资源;而工作节点 B 则查询该 EMR 的状态,如果没有被结束掉,就要往上面提交新的计算任务。这时候,如果没有分布式锁的协作,问题就来了,可能 B 节点先查询发现 EMR 状态还活着,就这这一瞬间,A 节点结束了它,可是 B 不知道,接着提交了一个计算任务到这个已经结束了的(死了的)EMR 资源上,于是这个提交的计算任务必然执行失败了。

有很多分布式锁的实现方式,简单的有强一致性的存储系统,当然也有更高效的实现,比如一些专门的分布式锁系统。

功能的可扩展性

之前讲到了性能架构上的可扩展性,在功能层面亦然。

  • 自定义任务。这是几乎所有工作流系统都会考虑的事情,这也是业务逻辑和调度逻辑解耦的必然。因为工作流系统设计的时候,必然没法预知所有的任务类型,用户是可以定义自己的执行逻辑的。
  • 自定义资源。有了资源管理,就有自定义资源的必要。
  • 自定义事件监听。事件管理通常在工作流系统中是很容易被忽视的内容,比如我希望在某一个 task 超时的时候发送一个特殊的消息通知我,这就需要给这个事件监听提供扩展的可能性。
  • 运行时的工作流任务执行条件。通常 workflow 都会有一个定义如何执行的文件(meta file),但是有一些执行的参数和条件,是在运行时才能够确定的,甚至依赖于上一步执行的结果,或者需要执行一些逻辑才能得到。

可用性和可靠性

大多数 workflow,都采用了去中心节点的设计,保证不存在任何单点故障问题。所有的子系统都是。也保证在业务压力增加的情况下,标志着可用性的 latency 在预期范围之内。其它的内容不展开,介绍这方面的文章到处都是。

生命周期管理

这里既指 workflow 一次执行的生命周期管理,也指单个 task 的生命周期管理。

谈论这些必然涉及到这样几个问题:

  • workflow definition 和 workflow 的分离,task definition 和 task execution 的分离。其中 definition 定义执行的逻辑,而 execution 才真正和执行的环境、时间、参数等等相关。逻辑通常可以只有一份(但这也不一定,要看 workflow 是否支持多版本,后文有提到),但是 execution 随着重试的发生,会保存多份。
  • workflow 重试时,参数变化的处理。有些参数的变化,是不会影响已完成任务的,但是有的参数则不是。
  • workflow 重试时,对于已完成任务的处理。有的情况我们希望已完成任务也要重新执行,而又的情况我们则希望这些已完成任务被跳过。
  • task 的重试次数,以及重试时 back off 的策略。比如第一次重试需要等 5 分钟,第二次重试需要等 10 分钟,最多重试 2 次。
  • 如何礼貌地结束工作节点上的任务执行。在很多情况下我们不得不中断并结束某个节点上的任务执行,比如这个工作节点需要重启,这并不能算作业务代码导致的任务执行失败,而更像是一种 “resource termination”。这种情况下,任务通常需要被分配到另外活着的节点去,而这里有牵涉到这个 reallocation 的策略,前面已经提到过。
  • 任务的权重。或者叫做优先级,这项功能我只在少数 workflow 中看到。在考虑到资源分配时,某些更重要任务可具备更高优先级,而无关紧要的任务失败甚至可以不影响 workflow 的状态。

任务 DAG 的设计和表达

这是 workflow 执行的流程图,也是所有 task 之间依赖关系的表述。我见过多种表达方式的,有 XML 的,也有 JSON 的,还有一些不知名的自己定义的格式的。有些 workflow 的定义可以以一个图形化工具来协助完成这个流程图。这个 DSL 的设计,一定程度上决定了 workflow 的使用是不是能够易于理解。另外提一句,这里提到的这个可选的图形化工具,毕竟只是一个辅助,它不是 workflow 的核心(你可以说这个 DSL 是核心的一部分,但这个帮助完成的工具显然不是)——我见过一个团队,workflow 整体设计得不怎么样,跑起来一堆问题,但是这个工具花了大量的时间精力去修缮,本末倒置。

另外,workflow 的状态和执行情况,还有对其的归档和管理,也需要一个整合工具来协助。这方面几乎所有 workflow 都具备,通常都是网页工具,以及命令行工具。

输入输出的管理

这也是一个 nice-to-have 的东西,对于每一个 task,都存在 input 和 output,它们可以完全交给用户自己来实现,比如用户把它们存储到文件里面,或者写到数据库里面,而 workflow 根本不管,每个 task 内部自己去读取相应的用户文件即可。但是更好的方法是,对于一些常用和简单的 input、output,是可以随着 execution 一起持久化到 workflow 和 task 的状态里面去的。这样也便于 workflow 的 definition 里面,放置一些根据前一步 task 执行结果来决策后续执行的表达式。

另外,还有一个稍微冷门的 use case,就是 input 和 output 的管理。通常 workflow 是重复执行的,而每次执行的 input 和 output 的数据规模往往是很多人关心的内容。关于这部分,我还没有见到任何一个 workflow 提供这样的功能。许多用户自己写工具和脚本来获取这样的信息。

独立的 metrics 和日志系统

对于 metrics,核心的内容也无非节点的健康状况、CPU、内存,task 执行时间分布,失败率等等几项。有些情况下用户还希望自行扩展。

关于日志,则主要指的是归档和合并。归档,指的是历史日志不丢失,或者在一定时间内不丢失,过期日志可以被覆写,从而不引起磁盘容量的问题;而合并,指的是日志能被以更统一的视角进行查询和浏览,出了问题不至于到每台机器上去手动查找。缺少这个功能,有时候会很麻烦。在工作中我遇到过一个资源被异常终止的问题,为了找到那个终止资源的节点,我查阅了几十个节点的日志,痛苦不堪。

版本控制和平滑部署

把这两个放一起是因为,代码升级是不可避免且经常要发生的。为了保证平滑部署,显然通常情况下,节点上的代码不能同时更新,需要一部分一部分进行。比如,先终止 50% 的节点,部署代码后,激活并确保成功,再进行剩下那 50% 的节点。但是在这期间存在新老代码并存的问题,这通常会带来很多奇形怪状的问题。对于这种问题,我见过这样两个解决方式:

  • 一个是全部节点同时部署,这种情况下所有节点全部失活,有可能出现因为这个失活导致的 task 超时,甚至导致 workflow 执行失败。但是 workflow 的生命周期由单独的调度系统管理,因此除去超时外多数情况不受影响。
  • 还有一种是一部分一部分部署,最平滑,但是这种情况下需要管理多版本共存问题,也对代码质量提出了新的要求——向后兼容。

无论选用哪一种,这种方式实现起来相对简单,但是也有不少问题,比如这种情况下,外部资源怎么处理?例如在外部 EMR 资源上执行 Spark 任务,但是已经有老代码被放到 EMR 上去执行了,这时候工作节点更新,这些 EMR 上正在执行的任务怎样处理?是作废还是保留,如果保留的话这些执行可还是依仗着老代码的,其结果的后续处理是否会和刚部署的新代码产生冲突。再比如对于 workflow 有定义上的改变(比如 DAG 的改变),对于现有的 execution,应当怎样处理,是更新还是保持原样(通常都是保持原样,因为更新带来的复杂问题非常多)。

最后,这幅图以一个典型实现简要地表述了这些组件和它们之间的关系:

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《四火的唠叨》

0 人点赞