工作流排程工具Airflow1.8搭建及使用

wait4friend發表於2018-05-29

編寫目的

最近工作任務需要把原來使用Kettle的ETL流程遷移到Hadoop平臺上,就需要找一個替代Kettle工作流部分的工具。在大資料環境下,常用的無非是Oozie,Airflow或者Azkaban。經過簡單的評估之後,我們選擇了輕量化的Airflow作為我們的工作流工具。

Airflow是一個工作流分配管理系統,通過有向非迴圈圖的方式管理任務流程,設定任務依賴關係和時間排程。Airflow獨立於我們要執行的任務,只需要把任務的名字和執行方式提供給Airflow作為一個task就可以。

安裝流程

本次安裝Airflow 1.8 ,而不是最新版的apache-airflow 1.9,主要原因是1.9版本的所有執行都是基於UTC時間的,這樣導致在配置排程資訊的時候不夠直觀。目前開發中的2.0版本已經可以設定本地時區,但是還沒有公開發布。

系統準備

Python 3.5 :Anaconda 4.2環境

MySQL 5.6 :使用LocalExcutor模式,所有DAG資訊儲存在後端資料庫中。

OS 使用者:etl

建立資料庫

後端使用MySQL資料庫來儲存任務資訊,先在資料庫中建立database和user。如下

create database airflow;

grant all privileges on airflow.* to 'airflow'@'%' identified by 'airflow';

flush privileges;
複製程式碼

環境變數

Airflow在執行過程中會使用全域性環境變數,所以必須先在~/.bash_profile 中增加變數如下

export AIRFLOW_HOME=/home/etl/airflow
複製程式碼

安裝airflow

使用pip安裝airflow以及依賴的資料庫驅動之後,需要進行初始化。這個過程會生成預設的配置檔案ariflow.cfg,後續的配置修改就通過這個檔案進行。

# 預設安裝1.8版本,因為1.9版本的名字變成了apache-airflow
pip install airflow

# 因為需要連線MySQL資料庫,所以需要安裝驅動
pip install airflow[mysql]

# 初始化資料庫,這一步是必須的,否則無法生成預設配置檔案
airflow initdb

# 建立需要的資料夾,否則執行時會報錯找不到預設資料夾
mkdir dags
mkdir logs
複製程式碼

啟用Web許可權

預設情況下airflow的web管理臺是沒有使用者密碼的,在遷移到正式環境之前,我們需要啟用許可權機制。

airflow.cfg中設定如下選項

[webserver]
authenticate = True
auth_backend = airflow.contrib.auth.backends.password_auth
複製程式碼

啟用許可權之後,在第一次登入之前必須手動通過python REPL來設定初始使用者

import airflow
from airflow import models, settings
from airflow.contrib.auth.backends.password_auth import PasswordUser

user = PasswordUser(models.User())
user.username = 'airflow'
user.email = 'airflow@xxx.com'
user.password = 'airflow'

session = settings.Session()
session.add(user)
session.commit()
session.close()

exit()
複製程式碼

修改資料庫連線

因為使用MySQL作為後設資料庫,所以還需要配置資料庫的連線引數。在airflow.cfg中設定如下選項

[core]
executor = LocalExecutor
sql_alchemy_conn = mysql://airflow:airflow@192.168.100.57:3306/airflow?charset=utf8
複製程式碼

更改資料庫連線方式之後,需要重新執行一次初始化操作。

airflow initdb
複製程式碼

其他引數修改

還有一些零散的配置不好歸類,就統一記錄在這裡。

任務成功,失敗或重試後傳送郵件通知的配置

[email]
email_backend = airflow.utils.email.send_email_smtp

[smtp]
smtp_host = smtp.mxhichina.com
smtp_starttls = False
smtp_ssl = False

# Uncomment and set the user/pass settings if you want to use SMTP AUTH
smtp_user = bialert@xxx.com
smtp_password = ******
smtp_port = 25
smtp_mail_from = bialert@xxx.com
複製程式碼

預設情況下,Web介面會把樣例DAG都顯示出來非常混亂。除了在資料庫中刪除樣例DAG之外,也可以通過配置不顯示這部分樣例。

# 不顯示樣例DAG
load_examples = False
複製程式碼

Airflow的catchup機制,會在你啟動一個DAG的時候,把當前時間之前未執行的job依次執行一次。這個好處是可以把遺漏的排程任務進行補足,但是在很多時候我們並不需要這個特性。通過修改配置,可以禁止catchup,如下

[scheduler]
# 避免執行catchup,即避免把當前時間之前未執行的job都執行一次
catchup_by_default = False
複製程式碼

WEB管理

WEB介面

在預設的8080埠頁面上,可以對DAG進行日常操作,包括但不限於啟動,停止,檢視日誌等。介面如下圖

image-20180529152649601

管理指令碼

當前版本Airflow沒有提供關閉指令碼,也沒有提供一個便捷的辦法來徹底刪除DAG。為了方便測試,我寫了一個管理指令碼來處理相關的任務。

指令碼呼叫方式如下

$ ./airflow_util.py -h
usage: airflow_util.py [-h] [-k] [-s] [--clear CLEAR] [--delete DELETE]

optional arguments:
  -h, --help       show this help message and exit
  -k, --kill       關閉Airflow
  -s, --start      啟動Airflow
  --clear CLEAR    刪除歷史日誌
  --delete DELETE  提供需要刪除的DAG ID
複製程式碼

管理指令碼原始碼如下

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import argparse
import pymysql
import subprocess
import time

#連線配置資訊
config = {
     'host':'127.0.0.1',
     'port':3306,
     'user':'airflow',
     'password':'airflow',
     'db':'airflow',
     'charset':'utf8',
     }

# 刪除歷史日誌
def clear_log(num):
    print("Clear logs before {0} days ...".format(num))
    cmd = "find %s -maxdepth 1 -type d -mtime +%d | xargs -i rm -rf {}" 
    subprocess.call(cmd % ('./logs',num), shell=True)
    subprocess.call(cmd % ('./logs/scheduler',num), shell=True)

# 通過殺掉後臺程式來關閉Airflow
def kill_airflow():
    print("Stoping Airflow ...")
    # exclude current file in case the file name contains keyword 'airflow'
    cmd = "ps -ef | grep -Ei 'airflow' | grep -v 'grep' | grep -v '%s' | awk '{print $2}' | xargs -i kill -9 {}" % (__file__.split('/')[-1])
    subprocess.call(cmd, shell=True)

# 啟動Airflow 
def start_airflow():
    kill_airflow()
    time.sleep(3)

    print("Starting Airflow Webserver ...")
    subprocess.call("rm logs/webserver.log", shell=True)
    subprocess.call("nohup airflow webserver >>logs/webserver.log 2>&1 &", shell=True)
    
    print("Starting Airflow Scheduler ...")
    subprocess.call("rm logs/scheduler.log", shell=True)
    subprocess.call("nohup airflow scheduler >>logs/scheduler.log 2>&1 &", shell=True)

# 刪除指定DAG ID在資料庫中的全部資訊。
# PS:因為SubDAG的命名方式為 parent_id.child_id ,所以也會把符合這種規則的SubDAG刪除!
def delete_dag(dag_id):
    # 建立連線
    connection = pymysql.connect(**config)
    cursor = connection.cursor()

    sql="select dag_id from airflow.dag where (dag_id like '{}.%' and is_subdag=1) or dag_id='{}'".format(dag_id, dag_id)
    cursor.execute(sql)
    rs = cursor.fetchall()
    dags = [r[0] for r in rs ] 

    for dag in dags:
        for tab in ["xcom", "task_instance", "sla_miss", "log", "job", "dag_run", "dag_stats", "dag" ]:
            sql="delete from airflow.{} where dag_id='{}'".format(tab, dag)
            print(sql)
            cursor.execute(sql)

    connection.commit()
    connection.close()

#
def main_process():
    parser = argparse.ArgumentParser()

    parser.add_argument("-k", "--kill", help="關閉Airflow", action='store_true')
    parser.add_argument("-s", "--start", help="啟動Airflow", action='store_true')
    parser.add_argument("--clear", help="刪除歷史日誌", type=int)
    parser.add_argument("--delete", help="提供需要刪除的DAG ID")

    args = parser.parse_args()

    if args.kill:
        kill_airflow()
    if args.start:
        start_airflow()
    if args.clear:
        clear_log(args.clear)
    if args.delete:
        delete_dag(args.delete)

if __name__ == '__main__':
    main_process()

複製程式碼

編寫DAG

原生Airflow的工作流通過簡單的python指令碼來進行定義(有一些第三方擴充套件可以實現拖放模式的定義)。

普通DAG

對於task不是特別多的場景,把所有task都定義在同一個py檔案裡面即可。如下,定義了4個task

img

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import airflow
from airflow.models import DAG
from airflow.operators.bash_operator import BashOperator
from datetime import timedelta, datetime

template_caller = "sh /home/etl/jupyter_home/etl_script/spark_scheduler/subdir/caller_spark.sh -m {0} -f {1} "
template_file = '/home/etl/jupyter_home/etl_script/spark_scheduler/subdir/{0}'
default_spark_master = 'spark://192.168.100.51:7077'

#-------------------------------------------------------------------------------
default_args = {
    'owner': '測試',
    'depends_on_past': False,
    'start_date': datetime(2018,4,24,14,0,0),
    'email': ['xxx@xxx.com'],
    'email_on_failure': True,
}

#-------------------------------------------------------------------------------
dag = DAG(
    'demo_spark_normal',
    default_args=default_args,
    description='測試-呼叫Spark',
    schedule_interval='*/20 * * * *')

#-------------------------------------------------------------------------------
# spark operator
cmd = template_caller.format(default_spark_master, template_file.format('hive_rw.ipynb'))
t1 = BashOperator( task_id='spark_hive', bash_command=cmd , dag=dag)

cmd = template_caller.format(default_spark_master, template_file.format('jdbc_rw.ipynb'))
t2 = BashOperator( task_id='spark_jdbc', bash_command=cmd , dag=dag)

cmd = template_caller.format(default_spark_master, template_file.format('csv_relative.py'))
t3 = BashOperator( task_id='spark_csv', bash_command=cmd , dag=dag)

cmd = template_caller.format(default_spark_master, template_file.format('pure_sql.sql'))
t4 = BashOperator( task_id='spark_sql', bash_command=cmd , dag=dag)
#-------------------------------------------------------------------------------
# dependencies
t1 >> t2 >> t4
t1 >> t3 >> t4
複製程式碼

SubDAG

當一個工作流裡面的task過多,UI顯示會比較擁擠,這種場景下可以通過把task分類到不同SubDAG中的辦法來實現。在具體編寫上,又可以分為單一py檔案和多個py檔案的方案。

單一檔案

這種情況下,我們把DAG和SubDAG都寫在一個py檔案裡面。優點是隻有一個檔案易於編寫,缺點是如果task比較多的話,檔案不易管理。

img

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from datetime import datetime, timedelta
from airflow.models import DAG
from airflow.operators.subdag_operator import SubDagOperator
from airflow.operators.bash_operator import BashOperator
from datetime import datetime

PARENT_DAG_NAME = 'atest_04'
#CHILD_DAG_NAME = 'child_dag'

default_args = {
        'owner': '測試',
        'depends_on_past': False,
}

main_dag = DAG(
  dag_id=PARENT_DAG_NAME,
  default_args=default_args,
  description='測試-內嵌SubDAG',
  start_date=datetime(2018,4,21,16,0,0),
  schedule_interval='*/30 * * * *'
)


# Dag is returned by a factory method
def sub_dag(parent_dag_name, child_dag_name, start_date, schedule_interval):
  dag = DAG(
    '%s.%s' % (parent_dag_name, child_dag_name),
    schedule_interval=schedule_interval,
    start_date=start_date,
  )

  t1 = BashOperator(
      task_id='print_{}'.format(child_dag_name),
      bash_command='echo sub key_{}  `date` >> /home/etl/airflow/test.log'.format(child_dag_name),
      dag=dag)

  return dag

#
sub_dag_1 = SubDagOperator(
  subdag=sub_dag(PARENT_DAG_NAME, 'child_01', main_dag.start_date, main_dag.schedule_interval),
  task_id='child_01',
  dag=main_dag,
)

sub_dag_2 = SubDagOperator(
  subdag=sub_dag(PARENT_DAG_NAME, 'child_02', main_dag.start_date, main_dag.schedule_interval),
  task_id='child_02',
  dag=main_dag,
)

#
sub_dag_1 >> sub_dag_2
複製程式碼

多個檔案

當SubDAG比較多的場景下,把DAG檔案儲存在獨立的py檔案中是一種更好的方法。檔案目錄結構如下

img

主檔案如下

PS:因為在airflow中呼叫其他檔案的過程中會出現找不到model的錯誤,所以在主檔案中增加了一句處理路徑的語句。如果有更好的辦法,可以對這個進行替換。

sys.path.append(os.path.abspath(os.path.dirname(__file__)))
複製程式碼
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys, os
sys.path.append(os.path.abspath(os.path.dirname(__file__)))

from datetime import datetime, timedelta
from airflow.models import DAG
from airflow.operators.subdag_operator import SubDagOperator
from datetime import datetime

from sub.subdag import sub_dag

PARENT_DAG_NAME = 'atest_03'
CHILD_DAG_NAME = 'child_dag'

default_args = {
        'owner': '測試',
        'depends_on_past': False,
}

main_dag = DAG(
  dag_id=PARENT_DAG_NAME,
  default_args=default_args,
  description='測試-獨立SubDAG',
  start_date=datetime(2018,4,14,19,0,0),
  schedule_interval='*/10 * * * *',
  catchup=False
)

sub_dag = SubDagOperator(
  subdag=sub_dag(PARENT_DAG_NAME, CHILD_DAG_NAME, main_dag.start_date,
                 main_dag.schedule_interval),
  task_id=CHILD_DAG_NAME,
  dag=main_dag,
)
複製程式碼

子檔案如下

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from airflow.models import DAG
from airflow.operators.bash_operator import BashOperator

# Dag is returned by a factory method
def sub_dag(parent_dag_name, child_dag_name, start_date, schedule_interval):
  dag = DAG(
    '%s.%s' % (parent_dag_name, child_dag_name),
    schedule_interval=schedule_interval,
    start_date=start_date,
  )

  t1 = BashOperator(
      task_id='print_1',
      bash_command='echo sub 1 `date` >> /home/etl/airflow/test.log',
      dag=dag)

  return dag
複製程式碼

Schedule和Trigger

在DAG上,任務的觸發由兩個主要引數定義,start_dateschedule_interval 。一個DAG第一次被觸發的時間點是 start_date + schedule_interval。舉例如下:

start_date         2018-04-20 14:00:00 
schedule_interval  */30 * * * *
複製程式碼

那第一次觸發會在14:30發生,但是執行的是14:00的任務。根據啟用DAG時間的不同,會發生不同的觸發。

  • 如果啟用時間還不到第一次觸發時間(如14:10啟用),那第一次觸發在14:30會準時進行。
  • 如果啟用時間超過第一次觸發(如15:40啟用),那根據catchup=True配置,會發生多次backfill操作,即把所有空缺的部分進行依次觸發,具體就是14:00, 14:30, 15:00這幾次任務。為啥最後一次不是15:30?因為在15:30這個時間點執行的其實是15:00的那一次任務。
  • 如果啟用時間超過第一次觸發(如15:40啟用),那根據catchup=False配置,會把空缺的最後一次進行觸發。

綜上,如果希望每30分鐘觸發一次,並且第一次觸發發生在14:00,那麼設定的start_date就應該是 13:30:00,這樣在14:00的時候,就會觸發第一次任務。

附錄

修改預設日誌級別

在1.8版本中,不能直接通過cfg檔案來配置LOGGING LEVEL,所以採用修改原始碼的方式實現這個功能。

PS:在1.9之後的版本中,據說可以直接進行配置,我沒有測試。

vi /opt/anaconda3/lib/python3.5/site-packages/airflow/settings.py

# 修改此處程式碼,把預設的INFO修改成WARN即可
LOGGING_LEVEL = logging.WARN
複製程式碼

相關文章