规模化运行Apache Airflow的经验之谈
Apache Airflow是一个协调平台,可以实现工作流的开发、调度和监控。在Shopify,我们已经在生产中运行了两年多的Airflow,用于各种工作流程,包括数据提取、机器学习模型训练、Apache Iceberg表维护和DBT驱动的数据建模。在写作时,我们目前正在Kubernetes上运行Airflow 2.2,使用Celery执行器和MySQL 8。
Shopify的Airflow架构
在过去的两年里,Shopify对Airflow的使用规模急剧扩大。在我们最大的环境中,我们运行了超过10,000个DAG,代表了大量的工作负载。这个环境平均有400多个任务在运行,每天有超过150,000次运行。随着Shopify内部采用率的提高,我们的Airflow部署所产生的负载也会增加。由于这种快速增长,我们遇到了一些挑战,包括文件访问速度慢,对DAG(有向无环图)能力的控制不足,不规则的流量水平,以及工作负载之间的资源争夺,仅此而已。
下面我们将分享一些我们学到的经验和我们建立的解决方案,以便大规模地运行Airflow。
1.使用云存储时,文件访问可能很慢
快速的文件访问对Airflow环境的性能和完整性至关重要。一个明确的文件访问策略可以确保调度器能够快速处理DAG文件,并保持作业的最新状态。
Airflow通过反复扫描和重新梳理配置的DAG目录中的所有文件,保持其工作流程的内部表述的最新性。这些文件必须经常扫描,以保持每个工作负载的磁盘真理源和其数据库内表示之间的一致性。这意味着DAG目录的内容必须在单一环境中的所有调度器和工作者之间保持一致(Airflow提出了一些实现这一目标的方法)。
在Shopify,我们使用谷歌云存储(GCS)来存储DAG。我们最初部署Airflow时,利用GCSFuse在单一的Airflow环境中的所有工作器和调度器中维护一套一致的文件。然而,在规模上,这被证明是一个性能瓶颈,因为每个文件的读取都会引起对GCS的请求。读取量特别大,因为环境中的每个pod都必须单独挂载桶。
经过一些实验,我们发现,通过在Kubernetes集群中运行一个NFS(网络文件系统)服务器,可以极大地提高Airflow环境的性能。然后,我们将这个NFS服务器作为一个多读多写的卷装入工人和调度器的pod。我们写了一个自定义脚本,将这个卷的状态与GCS同步,这样用户只需要在上传或管理DAG时与GCS互动。这个脚本在同一个集群中的一个单独的pod中运行。这也允许我们有条件地只同步一个给定桶的DAG子集,或者甚至根据环境的配置将多个桶的DAG同步到一个文件系统中(后面会有更多介绍)。
总的来说,这为我们提供了快速的文件访问,作为一个稳定的外部真实来源,同时保持我们在Airflow内快速添加或修改DAG文件的能力。此外,我们可以使用谷歌云平台的IAM(识别和访问管理)功能来控制哪些用户能够上传文件到特定的环境。例如,我们允许用户将DAG直接上传到暂存环境,但将生产环境的上传限制在我们的持续部署流程。
在大规模运行Airflow时,确保快速文件访问的另一个考虑因素是你的文件处理性能。Airflow是高度可配置的,它提供了多种方法来调整后台文件处理(如排序模式、并行性和超时)。这使得你可以根据需求优化环境,以实现交互式DAG开发或调度器性能。
2.元数据量的增加会降低Airflow的运行性能
在一个正常规模的Airflow部署中,元数据量导致的性能下降不会是一个问题,至少在连续运行的头几年内是如此。
然而,在规模上,元数据开始快速积累。一段时间后,这可能开始对数据库产生额外的负载。这在Web UI的加载时间中很明显,在Airflow的升级过程中更是如此,在此期间,迁移可能需要几个小时。
经过一些试验和错误,我们确定了28天的元数据保留策略,并实施了一个简单的DAG,在PythonOperator中使用ORM(对象关系映射)查询,从任何包含历史数据(DagRuns、TaskInstances、Logs、TaskRetries等)的表中删除行。我们把时间定在28天,因为这为我们管理事件和跟踪历史工作表现提供了足够的历史,同时把数据库中的数据量保持在一个合理的水平。
不幸的是,这意味着在我们的环境中,不支持Airflow中那些依赖持久作业历史的功能(例如,长期运行的回填)。这对我们来说不是一个问题,但它可能会导致问题,这取决于你的保留期和Airflow的使用。
作为自定义DAG的另一种方法,Airflow最近增加了对db clean 命令的支持,可以用来删除旧的元数据。这个命令在Airflow 2.3版本中可用。
3.DAGs可能很难与用户和团队联系起来
在多租户环境下运行Airflow时(尤其是在大型组织),能够将DAG追溯到个人或团队是非常重要的。为什么?因为如果一个作业失败,抛出错误或干扰其他工作负载,我们管理员可以迅速联系到相应的用户。
如果所有的DAG都是直接从一个资源库部署的,我们可以简单地使用git blame 来追踪工作的所有者。然而,由于我们允许用户从自己的项目中部署工作负载(甚至在部署时动态生成作业),这就变得更加困难。
为了方便追踪DAG的来源,我们引入了一个Airflow命名空间的注册表,我们把它称为Airflow环境的清单文件。
清单 文件是一个YAML文件,用户必须为他们的DAG注册一个命名空间。在这个文件中,他们将包括作业的所有者和源github仓库(甚至是源GCS桶)的信息,以及为他们的DAG定义一些基本限制。我们为每个环境维护一个单独的清单,并将其与DAG一起上传到GCS。
4.DAG作者有很大的权力
通过允许用户直接编写和上传DAG到共享环境,我们赋予他们很大的权力。由于Airflow是我们数据平台的核心组成部分,它与许多不同的系统相联系,因此工作具有广泛的访问权限。虽然我们信任我们的用户,但我们仍然希望对他们在特定的Airflow环境中能做什么和不能做什么保持一定程度的控制。这一点在规模上尤其重要,因为要让Airflow管理员在所有作业进入生产之前对其进行审查是不可行的。
为了创建一些基本的护栏,我们实施了一个DAG策略,从之前提到的Airflow清单中读取配置,并通过引发AirflowClusterPolicyViolation ,拒绝不符合其命名空间约束的DAG。
根据清单文件的内容,该策略将对DAG文件应用一些基本限制,例如:
- DAG ID 必须以现有名称空间的名称为前缀,为所有权。
- DAG 中的任务必须只向指定的 celery 队列发出任务。-稍后再谈这个问题。
- DAG中的任务只能在指定的池中运行阻止一个工作负荷占用另一个工作负荷的能力。
- 该DAG中的任何KubernetesPodOperators必须只在指定的命名空间阻止对其他命名空间的秘密的访问。
- DAG中的任务只能在指定的外部kubernetes集群集合中启动pod
这个策略可以扩展到执行其他规则(例如,只允许一组有限的操作者),甚至可以突变任务以符合某种规范(例如,为DAG中的所有任务添加特定命名空间的执行超时)。
下面是一个简化的例子,演示如何创建一个DAG策略,该策略读取先前共享的*清单文件,*并实现上述控制中的前三项。
这些验证为我们提供了足够的可追溯性,同时也创建了一些基本的控制,减少了DAG相互干扰的能力。
5.确保一致的负载分配是很困难的
对你的DAG的计划间隔使用一个绝对的间隔是非常诱人的--简单地将DAG设置为每timedelta(hours=1) ,你可以走开,安全地知道你的DAG将大约每小时运行一次。然而,这可能会导致规模上的问题。
当用户合并大量自动生成的DAG,或者写一个Python文件,在解析时生成许多DAG,所有的DAGRuns将在同一时间被创建。这将产生大量的流量,可能会使Airflow调度器以及作业所使用的任何外部服务或基础设施(例如Trino集群)超载。
在一个schedule_interval ,所有这些工作将在同一时间再次运行,从而导致流量的再次激增。最终,这可能导致资源利用率不理想,执行时间增加。
虽然基于crontab的时间表不会导致这些类型的激增,但它们也有自己的问题。人类偏向于人类可读的时间表,因此倾向于创建在每小时的顶部、每小时、每晚的午夜运行的作业,等等。有时,这有一个有效的特定应用的理由(例如,每晚午夜我们想提取前一天的数据),但我们经常发现用户只是想在一个固定的时间间隔内运行他们的作业。允许用户直接指定自己的crontabs会导致流量的爆发,从而影响SLO,并给外部系统带来不平衡的负载。
作为这两个问题的解决方案,我们对所有自动生成的DAG(代表了我们绝大多数的工作流程)使用一个确定性的随机时间表间隔。这通常是基于一个常数种子的哈希值,如dag_id 。
下面的片段提供了一个简单的函数例子,该函数生成确定性的、随机的crontabs,产生恒定的时间表间隔。不幸的是,这限制了可能的间隔范围,因为不是所有的间隔都可以用一个crontab来表示。我们没有发现这种有限的时间表间隔的选择是有局限性的,在我们真的需要每五小时运行一个作业的情况下,我们只是接受每天会有一个四小时的间隔。
由于我们的随机时间表的实施,我们能够大大平滑负载。下图显示了在我们最大的单一Airflow环境中,每10分钟完成的任务数。
在我们的生产气流环境中每10分钟执行的任务数
6.有许多资源争夺点
在Airflow中,有很多可能的资源争夺点,而且很容易通过一系列实验性的配置变化,最终追寻瓶颈。其中一些资源冲突可以在Airflow内部处理,而另一些可能需要一些基础设施的改变。以下是我们在Shopify的Airflow中处理资源争夺的几种方式。
池子
减少资源争夺的一种方法是使用Airflow池。池是用来限制一组特定任务的并发性的。这对于减少流量突发引起的中断非常有用。虽然池子是执行任务隔离的有用工具,但由于只有管理员可以通过Web UI编辑池子,因此在管理上是一个挑战。
我们编写了一个自定义的DAG,通过一些简单的ORM查询,将我们环境中的池与Kubernetes配置图中指定的状态同步。这让我们可以在管理Airflow部署配置的同时管理池子,并允许用户通过审查的拉动请求来更新池子,而不需要高等级的访问。
优先级权重
Priority_weight允许你为一个给定的任务分配一个更高的优先级。具有较高优先级的任务将浮动到堆的顶部,被首先安排。虽然不是资源争夺的直接解决方案,但priority_weight ,可以确保延迟敏感的关键任务在低优先级任务之前运行。然而,鉴于priority_weight 是一个任意的尺度,如果不与所有其他任务进行比较,就很难确定一个任务的实际优先级。我们利用这一点来确保我们的基本Airflow监控DAG(它发出简单的指标并为一些警报提供动力)总是尽可能及时地运行。
还值得注意的是,默认情况下,一个任务在做调度决策时使用的有效priority_weight ,是其自身和所有下游任务的权重之和。这意味着,大DAG中的上游任务往往比小DAG中的任务更受青睐。因此,使用priority_weight ,需要对环境中运行的其他DAG有一些了解。
Celery队列和孤立的工作者
如果你需要你的任务在不同的环境中执行(例如,依赖不同的python库,密集型任务有更高的资源允许量,或者不同的访问级别),你可以创建额外的队列,一个作业的子集提交任务。然后,单独的工作集可以被配置为从单独的队列中提取。可以使用运算符中的queue 参数将任务分配给一个单独的队列。要启动一个从不同队列运行任务的工作者,你可以使用以下命令。
bashAirflow celery worker –queues <list of queues>
这可以帮助确保敏感或高优先级的工作负载有足够的资源,因为它们不会与其他工作负载竞争工作者的能力。
池、优先级权重和队列的任何组合在减少资源争夺方面都是有用的。虽然池允许限制单个工作负载内的并发性,但priority_weight ,可用于使单个任务以比其他任务更低的延迟运行。如果你需要更多的灵活性,工作者隔离提供了对任务执行环境的细粒度控制。
重要的是要记住,并不是所有的资源都可以在Airflow中仔细分配--调度器吞吐量、数据库容量和Kubernetes IP空间都是有限的资源,如果不创建隔离环境,就不能在每个工作负载的基础上进行限制。
继续前进...
以如此高的吞吐量运行Airflow需要考虑很多因素,任何解决方案的组合都是有用的。我们已经学到了很多,我们希望你能记住这些教训,并在你自己的Airflow基础设施和工具中应用我们的一些解决方案。
总结一下我们的主要收获:
- GCS和NFS的组合可以实现高性能和易于使用的文件管理。
- 元数据保留策略可以减少Airflow的性能下降。
- 一个集中的元数据存储库可以用来跟踪DAG的来源和所有权。
- DAG策略对于执行作业的标准和限制是非常好的。
- 标准化的计划生成可以减少或消除流量的突发。
- Airflow提供了多种机制来管理资源争夺。
我们的下一步是什么?我们目前正致力于在单一环境中应用Airflow的扩展原则,因为我们正在探索将我们的工作负载分割到多个环境。这将使我们的平台更具弹性,使我们能够根据工作负载的具体要求对每个单独的Airflow实例进行微调,并减少任何一个Airflow部署的范围。
有关于大规模实施Airflow的问题吗?你可以联系Apache Airflow Slack社区中的任何一位作者。
在过去的9个月里,Megan一直在Shopify的数据平台团队工作,她一直致力于提高Airflow和Trino的用户体验。Megan位于加拿大多伦多,她喜欢任何户外活动,尤其是骑自行车和徒步旅行。