[译] 解密 Airbnb 的数据流编程神器:Airflow 中的技巧和陷阱

前言

Airbnb的数据工程师 Maxime Beauchemin 激动地表示道:Airflow 是一个我们正在用的工作流调度器,现在的版本已经更新到1.6.1了,并且引入了一些列调度引擎的改革。我们喜欢它是因为它写代码太容易了,也便于调试和维护。我们也喜欢全都用他来写代码,而不是像xml那样的配置文件用来描述DAG。更不用说,我们显然不用再学习太多东西。

任务隔离

在一个分布式环境中,宕机是时有发生的。Airflow通过自动重启任务来适应这一变化。到目前为止一切安好。当我们有一系列你想去重置状态的任务时,你就会发现这个功能简直是救世主。为了解决这个问题,我们的策略是建立子DAG。这个子DAG任务将自动重试自己的那一部分,因此,如果你以子DAG设置任务为永不重试,那么凭借子DAG操作你就可以得到整个DAG成败的结果。如果这个重置是DAG的第一个任务设置子DAG的策略就会非常有效,对于有一个相对复杂的依赖关系结构设置子DAG是非常棒的做法。注意到子DAG操作任务不会正确地标记失败任务,除非你从GitHub用了最新版本的Airflow。解决这个问题的另外一个策略是使用重试柄:

def make_spooq_exporter(table, schema, task_id, dag):
     return SpooqExportOperator(
        jdbc_url=('jdbc:mysql://%s/%s?user=user&password=pasta'
                    % (TARGET_DB_HOST,TARGET_DB_NAME)),
        target_table=table,
        hive_table='%s.%s' % (schema, table),
        dag=dag,
        on_retry_callback=truncate_db,
        task_id=task_id)
    
def truncate_db(context):
    hook = MySqlHook('clean_db_export')
    hook.run(
        'truncate `%s`'%context['task_instance'].task.target_table,
        autocommit=False,
        parameters=None)

这样你的重试柄就可以将任务隔离,每次执行某个特定的任务。

代码定义任务

这在执行一个特定的可重复的任务时非常管用。用代码来定义工作流是这个系统最强大之处是你可以以编码的方式产生DAG。这在在没有人工干预的情况下自动接入新的数据源的时候非常有用。

我们借助现有的日志目录将检查HDFS日志融入DAG,并且在每次融入这些数据的时候在每个目录下产生一个任务。示例代码如下:

lognames = list(
    hdfs.list_filenames(conf.get('incoming_log_path'), full_path=False))


for logname in lognames:
    # TODO 使用适当的正则表达式来过滤掉不良日志名,使得Airflow 能用符合特定的字符找出相应任务的名字
    if logname not in excluded_logs and '%' not in logname and '@' not in logname:

        ingest = LogIngesterOperator(
            # 因为log_name以作为unicode返回值,所以需要用str()包装task_id
            task_id=str('ingest_%s' % logname),
            db=conf.get('hive_db'),
            logname=logname,
            on_success_callback=datadog_api.check_data_lag,
            dag=dp_dag
        )

        ingest.set_upstream(transfer_from_incoming)
        ingest.set_downstream(transform_hive)

今日事,今日毕

在每天结束的时候执行每日任务,而不是在当天工作开始的时候去执行这些任务。你不能将子DAG放在DAG文件夹下,换句话说除非你保管一类DAG,否则你不可以将子DAG放在自己的模块中。

子DAG与主DAG不能嵌套

或者更具体地说就是,虽然你也可以将子DAG放在DAG文件夹下,但是接着子DAG将先主DAG一样运行自己的调度。这里是一个两个DAG的例子(假设他们同时在DAG文件夹下,也就是所谓的差DAG)这里的子DAG将在主DAG中通过调度器被单独调度。

from airflow.models import DAG
from airflow.operators import PythonOperator, SubDagOperator
from bad_dags.subdag import hive_dag
from datetime import timedelta, datetime

main_dag = DAG(
    dag_id='main_dag',
    schedule_interval=timedelta(hours=1),
    start_date=datetime(2015, 9, 18, 21)
)

# 显然,这单独执行不起作用
transform_hive = SubDagOperator(
    subdag=hive_dag,
    task_id='hive_transform',
    dag=main_dag,
    trigger_rule=TriggerRule.ALL_DONE
)
from airflow.models import DAG
from airflow.operators import HiveOperator
from datetime import timedelta, datetime

# 这将通过子DAG操作符被作为像是自己的调度任务中那样运行。
hive_dag = DAG('main_dag.hive_transform',
          # 注意到这里的重复迭代
           schedule_interval=timedelta(hours=1),
           start_date=datetime(2015, 9, 18, 21))

hive_transform = HiveOperator(task_id='flatten_tables',
                              hql=send_charge_hql,
                              dag=dag)

除非你真的想这个子DAG被主DAG调度。

我们通过使用工厂函数解决这个问题。这是一个优势那就是 主DAG可以传递一些必要的参数到子DAG,因此他们在调度的时候其他参数也自动赋值了。当你的主DAG发生变化时,我们不需要去跟踪参数。

在下面的例子中,假设DAG是所谓的好DAG:

from airflow.models import DAG
from airflow.operators import PythonOperator, SubDagOperator
from good_dags.subdag import hive_dag
from datetime import timedelta, datetime

main_dag = DAG(
    dag_id='main_dag',
    schedule_interval=timedelta(hours=1),
    start_date=datetime(2015, 9, 18, 21)
)

# 显然,这单独执行不起作用
transform_hive = SubDagOperator(
    subdag=hive_dag(main_dag.start_date, main_dag.schedule_interval),
    task_id='hive_transform',
    dag=main_dag,
    trigger_rule=TriggerRule.ALL_DONE
)
from airflow.models import DAG
from airflow.operators import HiveOperator

# 对调度程序来说,没有Dag的顶层模块就不起作用了
def hive_dag(start_date, schedule_interval):
  # you might like to make the name a parameter too
  dag = DAG('main_dag.hive_transform',
            # 注意这里的设置
            schedule_interval=schedule_interval,
            start_date=start_date)

  hive_transform = HiveOperator(task_id='flatten_tables',
                                hql=send_charge_hql,
                                dag=dag)
  return dag

使用工厂类使得子DAG在保障调度器从开始运行时就可维护就更强。

另一种模式是将主DAG和子DAG之间的共享设为默认参数,然后传递到工厂函数中去,(感谢 Maxime 的建议)。

子DAG也必须有个可用调度

即使子DAG作为其父DAG的一部分被触发子DAG也必须有一个调度,如果他们的调度是设成None,这个子DAG操作符将不会触发任何任务。

更糟糕的是,如果你对子DAG被禁用,接着你又去运行子DAG操作,而且还没运行完,那么以后你的子DAG就再也运行不起来了。

这将快速导致你的主DAG同时运行的任务数量一下就达到上限(默认一次写入是16个)并且这将导致调度器形同虚设。

这两个例子都是缘起子DAG操作符被当做了回填工作。这里可以看到这个

什么是DagRun:迟到的礼物

Airflow1.6的最大更新是引入了DagRun。现在,任务调度实例是由DagRun对象来创建的。

相应地,如果你想跑一个DAG而不是回填工作,你可能就需要用到DagRun。

你可以在代码里写一些airflow trigger_dag命令,或者也可以通过DagRun页面来操作。

这个巨大的优势就是调度器的行为可以被很好的理解,就像它可以遍历DagRun一样,基于正在运行的DagRun来调度任务实例。

这个服务器现在可以向我们显示每一个DagRun的状态,并且将任务实例的状态与之关联。

DagRun是怎样被调度的

新的模型也提供了一个控制调度器的方法。下一个DagRun会基于数据库里上一个DagRun的实例来调度。
除了服务峰值的例外之外,大多数实例是处于运行还是结束状态都不会影响整体任务的运行。
这意味着如果你想返回一个在现有和历史上不连续集合的部分DagRun ,你可以简单删掉这个DagRun任务实例,并且设置DagRun的状态为正在运行。

调度器应该经常重启

按照我们的经验,一个需要占用很长时间运行的调度器至少是个最终没有安排任务的CeleryExcecutor。很不幸,我们仍然不知道具体的原因。不过庆幸的是,Airflow 内建了一个以num_runs形式作标记的权宜之计。它为调度器确认了许多迭代器来在它退出之前确保执行这个循环。我们运行了10个迭代,Airbnb一般运行5个。注意到这里如果用LocalExecutor将会引发一些问题。我们现在使用chef来重启executor;我们正计划转移到supervisor上来自动重启。

操作符的依赖于依赖包

这个airflow.operators包有一些魔法,它让我们只能使用正确导入的操作符。这意味着如果你没有安装必要的依赖,你的操作符就会失效。

这是所有的 Fork! (现在)

Airflow 是正在快速迭代中,而且不只是Airbnb自己在做贡献。Airflow将会继续演化,而我也将写更多有关Airflow的技巧供大家学习使用。

如果你也对解决这些问题感兴趣,那就加入我们吧!

参考资料

推荐阅读

原作者:Marcin Tustin 翻译:Harry Zhu
英文原文地址:Airflow: Tips, Tricks, and Pitfalls

作为分享主义者(sharism),本人所有互联网发布的图文均遵从CC版权,转载请保留作者信息并注明作者 Harry Zhu 的 FinanceR专栏:https://segmentfault.com/blog/harryprince,如果涉及源代码请注明GitHub地址:https://github.com/harryprince。微信号: harryzhustudio
商业使用请联系作者。


FinanceR
循环写作,持续更新,形成闭环,贵在坚持
2.2k 声望
2.2k 粉丝
0 条评论
推荐阅读
[译] 层次时间序列预测法
大多数关于时间序列预测的文章都侧重于特定的聚合程度。但是,当我们能够深入分析聚合的数据,以便在更细粒度的层次上观察同一个序列时,挑战就出现了。在这种情况下,我们往往会发现,对较低水平的预测与总体预...

HarryZhu阅读 2.7k

封面图
Ubuntu20.04 从源代码编译安装 python3.10
Ubuntu 22.04 Release DateUbuntu 22.04 Jammy Jellyfish is scheduled for release on April 21, 2022If you’re ready to use Ubuntu 22.04 Jammy Jellyfish, you can either upgrade your current Ubuntu syste...

ponponon1阅读 4k评论 1

日常Python 代码片段整理
1、简单的 HTTP Web 服务器 {代码...} 2、单行循环List {代码...} 3、更新字典 {代码...} 4、拆分多行字符串 {代码...} 5、跟踪列表中元素的频率 {代码...} 6、不使用 Pandas 读取 CSV 文件 {代码...} 7、将列表...

墨城2阅读 340

Unicode 正则表达式(qbit)
前言本文根据《精通正则表达式》和 Unicode Regular Expressions 整理。本文的示例默认以 Python3 为实现语言,用到 Python3 的 re 模块或 regex 库。基本的 Unicode 属性分类 {代码...} 基本的 Unicode 子属性Le...

qbit阅读 4.4k

Python + Sqlalchemy 对数据库的批量插入或更新(Upsert)
由于不同数据库对这种 upsert 的实现机制不同,Sqlalchemy 也就不再试图做一致性的封装了,而是提供了各自的方言 API,具体到 Mysql,就是给 insert statement ,增加了 on_duplicate_key_update 方法。

songofhawk1阅读 2.1k评论 4

封面图
打脸了兄弟们,Go1.20 arena 来了!
大家好,我是煎鱼。大概半年前,我写过一篇文章《Go 要违背初心吗?新提案:手动管理内存》。有兴趣了深入解的同学,可以再回顾一下。当时我们还想着 Go 团队应该不会接纳,至少不会那么快:懒得翻也可以看我再次...

煎鱼1阅读 3.3k

uwsgi 注意事项
http 和 http-socket 选项是完全不同的。第一个生成一个额外的进程,转发请求到一系列的worker (将它想象为一种形式的盾牌,与apache或者nginx同级),而第二个设置worker为原生使用http协议。

zed2015阅读 2.2k

2.2k 声望
2.2k 粉丝
宣传栏