OpenStack公共组件oslo之十五——taskflow

        taskflow是oslo中用于为OpenStack项目和其他Python项目实现一个高可用的,易于理解的,声明式的执行工作、任务、流等的库。这个库让任务执行更加容易、一致和可靠。本文将详细介绍taskflow的实现原理与使用方式。

1 taskflow的实现原理

1.1 基本概念

        taskflow库在oslo项目中是一个实现比较复杂的项目,要弄清楚其实现原理,首先需要对其中的相关概念有所了解。所以,本文首先总结了taskflow中常用的一些基本概念,这些概念主要包括如下几个:

  • Atom:Atom类是taskflow的最小单位,taskflow中其他类,包括Task等都需要继承这个类。一个Atom对象是一个命名对象,通过操作输入数据以执行一些促进整个流程发展的动作,或者产生一个处理结果等。它是一个抽象类,提供了两个抽象方法:execute()用于执行一个动作,revert()用于根据execute()执行结果和失败信息还原到任务执行之前的状态;除此之外,还分别为这两个方法提供了pre_execute()/post_execute()、pre_revert()/post_revert()方法用于定义在执行execute或revert操作前后执行的操作。
  • Task:Task类是一个拥有执行和回滚操作的最小工作单元,表示一个任务流中的某一个任务。它是一个继承自Atom类的表示一个任务的父类,开发者可以执行定义一个继承自Task类的任务类,并重写execute()和revert()方法分别表示执行和回滚的操作。
  • Flow:Flow类是一个用来关联所有相关Task类,并规定这些Task类的执行和回滚顺序的抽象类。而oslo中为Flow提供了三种实现方式:graph_flow表示图流,linear_flow表示线性流,unordered_flow表示无序流。关于这三种类型的流实现会在之后进行详细分析。
  • Retry:Retry类也是一个继承自Atom的抽象类,它主要定义了当有错误发生时,如何进行重试操作。其也包含也不同的类型,将会在接下来的部分进行详细介绍。
  • Engine:Engine类是一个表示真正运行Atom对象的抽象类,它的实现类主要用于载入(load)一个Flow对象,然后驱动这个Flow对象的Task对象开始运行。Engine的实现也有多种不同的形式,这也会在接下来的部分进行详细介绍。
        以上的这些基本概念是理解和使用taskflow库的最重要的概念,当然在taskflow中还有一些其他的概念,在这里就不详细介绍了。接下来,将分别从这些基本概念展开介绍oslo项目中taskflow的实现原理。

1.2 Flow的类型

        在介绍taskflow的基本概念时说到Flow类分为三种类型,本小节将详细介绍oslo自定义的Flow的三种类型。oslo自定义的Flow的类型都放在taskflow.patterns包中,在这个包中,定义了三个模块:graph_flow、linear_flow和unordered_flow,在这三个模块中都定义了各自的Flow类。接下来,将分别介绍这三种类型:

  • linear_flow:线性流,该类型的Flow对象将按照Task/Flow加入的顺序来依次执行,按照加入的倒序依次回滚。
  • graph_flow:图流,该类型的Flow对象会按照给加入的Task/Flow显示指定的依赖关系或通过其间的provides/requires属性隐含的依赖关系执行和回滚。
  • unordered_flow:无序流,该类型的Flow对象所加入的Task/Flow会按照任意顺序执行或回滚。
        要弄清楚这三种类型的Flow对象,首先需要了解oslo定义的Flow基类的构成。在oslo定义的Flow基类中,主要包含以下几个重要的属性和方法:
  • name:表示初始化Flow对象时,为其指定的名称,并不能唯一表示一个Flow对象。
  • retry:表示与该Flow对象关联的重试控制器。
  • provides:表示该Flow对象提供的一组符号名称。
  • requires:表示该Flow对象所需要的一组"unsatisfied"符号名称。
  • add(*items):该方法用于为该Flow对象添加一个或一组Task/Flow对象。
  • iter_links():迭代Flow对象的子节点之间的依赖关系链接。例如在迭代一个三元组(A, B, meta)时,就是迭代一个从子节点A(一个Atom对象或一个Subflow)指向子节点B(一个Atom对象或一个Subflow)的链接;换句话说,也就代表了子流B依赖于子流A,或者子流B需要子流A;而meta代表了这个依赖关系链接的元数据,是一个字典。
  • iter_nodes():迭代Flow对象中的所有节点。例如在迭代一个二元组(A, meta)时,A(一个Atom对象或一个Subflow)是当前Flow对象的子流或子任务;meta同样代表了这个链接的元数据,是一个字典。
        以上所介绍的Flow类中的属性和方法都是实现taskflow执行和回滚顺序的关键。上文说到Flow类有三种实现,而在每一种实现的Flow类中,各实现类还定义了一个非常重要的属性:_graph,该属性代表了networkx.Graph类的一种实现。networkx是Python库中专门用于实现图和网络的库,对于这个库在这里我们不做过多介绍,感兴趣的朋友可以参考 networkx库官方文档。为了更好的实现taskflow所需要的图类型,oslo在taskflow.types.graph模块中重新实现了适合taskflow的图类型。重新定义的图类型如下所示:
  • Graph:一个拥有taskflow实用功能的networkx.Graph的子类,主要为taskflow中的图定义了一些工具方法,包括保持图不变形的freeze()方法,将图导出为点格式的export_to_dot()方法(依赖于pydot库),以及将图格式化为一个字符串的pformat()方法等。
  • DiGraph:一个拥有taskflow实用功能的networkx.Graph的子类,代表了一个有向图。
  • OrderedGraph:该类也是一个拥有taskflow实用功能的networkx.Graph的子类,但该类保留了节点、边、迭代顺序和插入顺序(以便迭代顺序可以匹配插入顺序)。
  • OrderedDiGraph:该类也是一个拥有taskflow实用功能的networkx.Graph的子类,代表的是一个有向图,但该类保留了节点、边、迭代顺序和插入顺序(以便迭代顺序可以匹配插入顺序)。
        除了实现了taskflow所需要的Graph子类外,taskflow.types.graph模块还定义了merge_graphs(graph, *graphs, **kwargs)方法将两个或多个图进行合并。
        有了上述图的实现,三种不同类型的Flow类实现便通过上述这些图来具体实现,即在实现add()、requires()、provides()等方法时,通过获取对应图对象的节点或向对应图对象添加节点实现。其中,linear_flow使用的实际上就是OrderedDiGraph对象,graph_flow则使用的是DiGraph对象,unordered_flow使用的是Graph对象。对于这些操作的具体实现,由于不同的Flow类型实现差异很大,且篇幅有限,这里便不一一详解。在此以linear_flow为例解释一下其实现方案。
class Flow(flow.Flow):

    def __init__(self, name, retry=None):
        super(Flow, self).__init__(name, retry)
        self._graph = gr.OrderedDiGraph(name=name)
        self._last_item = self._no_last_item

    def add(self, *items):
        """Adds a given task/tasks/flow/flows to this flow."""
        for item in items:
            if not self._graph.has_node(item):
                self._graph.add_node(item)
                if self._last_item is not self._no_last_item:
                    self._graph.add_edge(self._last_item, item,
                                         attr_dict={flow.LINK_INVARIANT: True})
                self._last_item = item
        return self

    def __len__(self):
        return len(self._graph)

    def __iter__(self):
        for item in self._graph.nodes_iter():
            yield item

    @property
    def requires(self):
        requires = set()
        prior_provides = set()
        if self._retry is not None:
            requires.update(self._retry.requires)
            prior_provides.update(self._retry.provides)
        for item in self:
            requires.update(item.requires - prior_provides)
            prior_provides.update(item.provides)
        return frozenset(requires)

    def iter_nodes(self):
        for (n, n_data) in self._graph.nodes_iter(data=True):
            yield (n, n_data)

    def iter_links(self):
        for (u, v, e_data) in self._graph.edges_iter(data=True):
            yield (u, v, e_data)
        上述代码便是linear_flow的具体实现,可以看到,初始化对象时将OrderedDiGraph对象赋值给_graph属性,在添加Task/Flow,也就是调用add(*items)方法时,首先就是遍历整个有序图,如果图中没有添加给定的Task/Flow对象,则向该图添加一个节点并保存相应的Task/Flow对象。而在调用iter_nodes()和iter_links()方法时,其实就是遍历图的所有节点或所有边。另外,由于linear_flow是按照插入顺序来进行执行和回滚操作的,所以requires和provides属性的设置和遍历不涉及到相关图的遍历;而如果是graph_flow则还需要考虑到requires和provides的相关影响。

1.3 taskflow重试机制

        在1.1节中介绍到,在taskflow执行过程中,如果发生错误,可以通过Retry对象进行重试。Retry是一个抽象类,它继承自Atom类,因此,Retry的子类可以覆写execute()和revert()方法。除此之外,Retry对象还定义了一个on_failure(history, *args, **kwargs)方法,这个方法在Task/Flow执行或回滚发生错误时,通常会使用以前异常的信息(如果这个历史失败信息不可用或未保存,则提供的History对象为空),即一个History对象。而这个History对象是一个为了简化与重试历史内容交互的帮助类,其包含两个重要属性:_failure表示发生的异常,_contents表示相关异常的重试内容。当taskflow执行失败时,on_failure()方法会结合传入的History对象获取重试的策略。

        关于重试的策略,taskflow通过一个枚举类型的Decision定义了三种策略:

  • REVERT:仅回滚失败Flow对象周围或关联的子流Flow对象。该策略在回滚子流Flow对象之前,会首先咨询其父Atom对象以确定父Atom对象是否使用不同的重试策略。该策略允许安全的嵌套具有不同重试策略的Flow对象。如果父Atom对象中没有定义重试策略,则默认只回滚关联子流Flow对象中的Atom对象。当然,你可以通过defer_revert参数改变默认行为,当其设置为True,表示REVERT策略将继承父Atom的策略,如果父Atom对象没有重试策略,则它也将被回滚。
  • REVERT_ALL:不管失败Flow对象的父Atom对象的策略如何,都将回滚整个流程。
  • RETRY:重试该失败的Flow/Task对象。
        根据上述重试策略,taskflow为Retry抽象类又定义了多个实现类,这些实现主要包括如下几种:
  • AlwaysRevert:该类表示当遇到Flow/Task失败时,总是回滚其子流Flow对象。
  • AlwaysRevertAll:该类表示当遇到Flow/Task失败时,总是回滚整个流程。
  • Times:该类表示当遇到Flow/Task失败时,重试指定操作指定的次数,而每次执行则会返回已重试次数。
  • ForEach:该类表示当遇到Flow/Task失败时,会应用给定的一组静态重试策略。该类在执行重试时,从一个数据结构中获取一组重试策略,然后在每一次重试操作后返回集合的下一个策略元素供下次重试使用。
  • ParameterizedForEach:该类表示当遇到Flow/Task失败时,会应用给定的一组静态重试策略。该类在执行重试时,接收来自前驱或存储的一组重试策略作为参数,然后在每一次重试操作后返回集合的下一个策略元素供重试使用。

1.4 Engine的实现

        在文章上述几节中分别从基本概念、Flow的类型和重试策略详细介绍了taskflow提供给开发者实现任务流管理的接口的实现,本小节则将详细介绍taskflow内部实现任务流管理的具体实现方式,即Engine的具体实现。

        上文介绍到taskflow在具体实现Task/Flow管理时,首先定义了一个Engine抽象类,所有实现都需要继承这个抽象类。这个抽象类定义了如下重要属性和方法:

  • notifier:一个通知对象,它会分发与Engine对象中包含的Flow对象相关的事件通知。
  • atom_notifier:一个通知对象,它会分发与Engine对象中包含的Atom对象相关的事件通知。
  • options:相关数据结构传递给Engine对象的选项。
  • storage:Engine对象的存储单元。
  • statistics:Engine对象收集的运行时统计数据字典。当Engine没有运行时,这个值为空;在Engine正在运行时或已经运行之前,它可能会存储一些对正在运行或运行完成时有用的或包含信息的键值对。
  • compile():该方法可以将Engine对象中包含的Flow对象编译成Engine对象内部表示形式。这个内部表示形式就是Engine对象实际用于运行的流的形式。
  • reset():将Engine对象重置为PENDING状态。如果一个Flow以FAILURE、SUCCESS、REVERTED状态结束运行(即调用Engine对象的run()方法之后),或由于某种状态使得其处于某种中间状态,此时可以调用reset()方法进行重置,然后进行重试操作。
  • prepare():在Engine对象编译完所有包含的Flow对象之后,且在Flow运行之前执行该方法,为流程的执行进行一些准备操作。
  • validate():在Engine对象编译完所有包含的Flow对象之后,且在Flow运行之前执行该方法,为流程的执行进行一些验证操作。
  • run():运行Engine对象中的Flow流程。
  • suspend():该方法尝试暂停Engine对象。如果一个Engine对象正在执行某个Atom对象,则执行该方法会将这个Atom对象之后的所有正要运行的工作都暂停,并将这个Engine对象的状态变为暂停状态,以便之后进行恢复操作。
        taskflow在具体实现Engine时,都需要给上述属性和方法重新赋值或进行覆写操作,以实现一个完整的管理流程Flow/Task对象的Engine类。在taskflow中,目前实现了三种策略的Engine类,而在这三种策略中,有两种是面向行为的action_egine类:SerialActionEngine、ParallelActionEngine;另一种是面向多进程的worker_base类:WorkerBaseActionEngine。这三种类型的Engine类的异同点如下所示:
  • SerialActionEngine:这是一个以串行方式运行任务的Engine类,也就是说所有的任务都会在调用engine.run()方法的线程中顺序执行。
  • ParallelActionEngine:这是一个以并行方式运行任务的Engine类,即可以在多个线程中运行Engine对象中的任务。在这种策略中,taskflow定义了对应的多个ParallelThreadTaskExecutor创建运行任务的线程。
  • WorkerBaseActionEngine:这是一个可以将任务调度到不同worker(即进程)中执行的Engine类。
        这三种不同类型的Engine类在初始化对象时,都需要指定对应的后端(backend)实现其对流程的管理。关于这方面内容,由于涉及较广,本人水平有限且实际使用中并不涉及,因此暂不做分析,感兴趣的朋友可以直接阅读源码。

2 taskflow的使用方法

        OpenStack中有很多项目都可以用到taskflow对整个执行流程进行管理,当然我们也可以在自己的Python项目中使用taskflow库管理流程。这里,以cinder项目为例详细介绍taskflow的使用方法。
        使用taskflow时,首先需要根据需要创建表示一个任务Task或流程Flow的类,如cinder在创建硬盘时,定义了如下的Task/Flow类:
from oslo_log import log as logging
from oslo_utils import excutils
import taskflow.engines
from taskflow.patterns import linear_flow

from cinder import exception
from cinder import flow_utils
from cinder.message import api as message_api
from cinder.message import message_field
from cinder import rpc
from cinder import utils
from cinder.volume.flows import common

LOG = logging.getLogger(__name__)

ACTION = 'volume:create'


class ExtractSchedulerSpecTask(flow_utils.CinderTask):
    """Extracts a spec object from a partial and/or incomplete request spec.

    Reversion strategy: N/A
    """

    default_provides = set(['request_spec'])

    def __init__(self, **kwargs):
        super(ExtractSchedulerSpecTask, self).__init__(addons=[ACTION],
                                                       **kwargs)

    def _populate_request_spec(self, volume, snapshot_id, image_id, backup_id):
        # Create the full request spec using the volume object.
        #
        # NOTE(dulek): At this point, a volume can be deleted before it gets
        # scheduled.  If a delete API call is made, the volume gets instantly
        # delete and scheduling will fail when it tries to update the DB entry
        # (with the host) in ScheduleCreateVolumeTask below.
        volume_type_id = volume.volume_type_id
        vol_type = volume.volume_type
        return {
            'volume_id': volume.id,
            'snapshot_id': snapshot_id,
            'image_id': image_id,
            'backup_id': backup_id,
            'volume_properties': {
                'size': utils.as_int(volume.size, quiet=False),
                'availability_zone': volume.availability_zone,
                'volume_type_id': volume_type_id,
            },
            'volume_type': list(dict(vol_type).items()),
        }

    def execute(self, context, request_spec, volume, snapshot_id,
                image_id, backup_id):
        # For RPC version < 1.2 backward compatibility
        if request_spec is None:
            request_spec = self._populate_request_spec(volume,
                                                       snapshot_id, image_id,
                                                       backup_id)
        return {
            'request_spec': request_spec,
        }


class ScheduleCreateVolumeTask(flow_utils.CinderTask):
    """Activates a scheduler driver and handles any subsequent failures.

    Notification strategy: on failure the scheduler rpc notifier will be
    activated and a notification will be emitted indicating what errored,
    the reason, and the request (and misc. other data) that caused the error
    to be triggered.

    Reversion strategy: N/A
    """
    FAILURE_TOPIC = "scheduler.create_volume"

    def __init__(self, driver_api, **kwargs):
        super(ScheduleCreateVolumeTask, self).__init__(addons=[ACTION],
                                                       **kwargs)
        self.driver_api = driver_api
        self.message_api = message_api.API()

    def _handle_failure(self, context, request_spec, cause):
        try:
            self._notify_failure(context, request_spec, cause)
        finally:
            LOG.error("Failed to run task %(name)s: %(cause)s",
                      {'cause': cause, 'name': self.name})

    @utils.if_notifications_enabled
    def _notify_failure(self, context, request_spec, cause):
        """When scheduling fails send out an event that it failed."""
        payload = {
            'request_spec': request_spec,
            'volume_properties': request_spec.get('volume_properties', {}),
            'volume_id': request_spec['volume_id'],
            'state': 'error',
            'method': 'create_volume',
            'reason': cause,
        }
        try:
            rpc.get_notifier('scheduler').error(context, self.FAILURE_TOPIC,
                                                payload)
        except exception.CinderException:
            LOG.exception("Failed notifying on %(topic)s "
                          "payload %(payload)s",
                          {'topic': self.FAILURE_TOPIC, 'payload': payload})

    def execute(self, context, request_spec, filter_properties, volume):
        try:
            self.driver_api.schedule_create_volume(context, request_spec,
                                                   filter_properties)
        except Exception as e:
            self.message_api.create(
                context,
                message_field.Action.SCHEDULE_ALLOCATE_VOLUME,
                resource_uuid=request_spec['volume_id'],
                exception=e)
            # An error happened, notify on the scheduler queue and log that
            # this happened and set the volume to errored out and reraise the
            # error *if* exception caught isn't NoValidBackend. Otherwise *do
            # not* reraise (since what's the point?)
            with excutils.save_and_reraise_exception(
                    reraise=not isinstance(e, exception.NoValidBackend)):
                try:
                    self._handle_failure(context, request_spec, e)
                finally:
                    common.error_out(volume, reason=e)


def get_flow(context, driver_api, request_spec=None,
             filter_properties=None,
             volume=None, snapshot_id=None, image_id=None, backup_id=None):

    """Constructs and returns the scheduler entrypoint flow.

    This flow will do the following:

    1. Inject keys & values for dependent tasks.
    2. Extract a scheduler specification from the provided inputs.
    3. Use provided scheduler driver to select host and pass volume creation
       request further.
    """
    create_what = {
        'context': context,
        'raw_request_spec': request_spec,
        'filter_properties': filter_properties,
        'volume': volume,
        'snapshot_id': snapshot_id,
        'image_id': image_id,
        'backup_id': backup_id,
    }

    flow_name = ACTION.replace(":", "_") + "_scheduler"
    scheduler_flow = linear_flow.Flow(flow_name)

    # This will extract and clean the spec from the starting values.
    scheduler_flow.add(ExtractSchedulerSpecTask(
        rebind={'request_spec': 'raw_request_spec'}))

    # This will activate the desired scheduler driver (and handle any
    # driver related failures appropriately).
    scheduler_flow.add(ScheduleCreateVolumeTask(driver_api))

    # Now load (but do not run) the flow using the provided initial data.
    return taskflow.engines.load(scheduler_flow, store=create_what)
        由于cinder创建硬盘的功能步骤繁多,操作复杂,且容易出错,因此在api和scheduler服务中都使用了taskflow对创建硬盘过程中的多个任务进行了管理,上面的代码是scheduler服务中创建硬盘时定义的两个任务Task类:ExtractSchedulerSpecTask和ScheduleCreateVolumeTask。接着,cinder-scheduler服务定义了get_flow()方法获取一个Engine对象。这个方法中,首先定义了一个linear_flow类型的Flow对象,然后调用Flow对象的add()方法将上述两个Task添加到Flow对象中,接着根据Flow对象加载一个Engine对象用来执行实际的流程操作。
        在执行流程时只需要调用get_flow()方法首先获得一个Engine对象,然后调用这个Engine对象的run()方法即可。
    @objects.Volume.set_workers
    def create_volume(self, context, volume, snapshot_id=None, image_id=None,
                      request_spec=None, filter_properties=None,
                      backup_id=None):
        self._wait_for_scheduler()

        try:
            flow_engine = create_volume.get_flow(context,
                                                 self.driver,
                                                 request_spec,
                                                 filter_properties,
                                                 volume,
                                                 snapshot_id,
                                                 image_id,
                                                 backup_id)
        except Exception:
            msg = _("Failed to create scheduler manager volume flow")
            LOG.exception(msg)
            raise exception.CinderException(msg)

        with flow_utils.DynamicLogListener(flow_engine, logger=LOG):
            flow_engine.run()
        如这个例子中,就是调用cinder-scheduler服务中定义的get_flow()方法获取一个Engine对象flow_engine,然后调用flow_engine.run()方法即可执行定义的流程。

你可能感兴趣的:(OpenStack)