Ana içeriğe geç
Kesit Python
İleri · Veri mühendisliği

İş akışı orkestrasyonu: cron, DAG ve Airflow

Zamanlanmış işleri cron sözdizimiyle tanımlar, görevleri DAG olarak modeller, graphlib ile sıralar ve döngü yakalarsın; Airflow'un DAG, operator, >>, retries ve XCom kavramlarını tanır, idempotent ve yeniden çalıştırılabilir işler, yeniden deneme, bildirim ve gözlemlenebilirlikle üretim kalitesinde akışlar kurarsın.

Uygulamada aç, 30 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Neden önemli?

Veri boru hattı (pipeline) dersinde tek bir ETL betiği yazdın. Gerçek kurumda ise onlarca iş birbirine bağlıdır: Anadolu Market'te her gece 02:00'de siparişler çekilir, temizlenir, stok tablosu güncellenir, şehir raporu üretilir, yöneticilere e-posta gider. Ilgaz Bank'ta gün sonu mutabakatı, tüm şubelerin işlem dosyaları gelmeden başlayamaz. Bu işleri "doğru sırada, doğru zamanda, hata olursa yeniden deneyerek ve kimse bakmasa da haber vererek" çalıştırma işine orkestrasyon (orchestration) denir. Bu derste önce temelleri (cron, DAG, idempotentlik) saf Python ile kuracak, sonra Airflow ve benzeri araçların bunları nasıl paketlediğini göreceksin.

Cron: ne zaman çalışsın?

Cron, Unix sistemlerinin zamanlayıcısıdır; sözdizimi Airflow, Kubernetes CronJob, GitHub Actions ve bulut zamanlayıcılarında da kullanılır. Bir ifade beş alandan oluşur:

AlanAralıkÖrnekAnlamı
Dakika0-59*/15Her 15 dakikada (0, 15, 30, 45)
Saat0-232Saat 02
Ayın günü1-311,15Ayın 1'i ve 15'i
Ay1-12*Her ay
Haftanın günü0-6 (0 = Pazar)1-5Pazartesi-Cuma
İfadeAnlamı
0 2 * * *Her gün 02:00
30 8 * * 1-5Hafta içi her gün 08:30
*/10 9-18 * * *09:00-18:50 arası her 10 dakikada
0 0 1 * *Her ayın 1'i gece yarısı
0 6 * * 0Her Pazar 06:00
# Linux / macOS: crontab -e
0 2 * * *  cd /opt/anadolu && .venv/bin/python -m etl.gunluk >> /var/log/etl.log 2>&1
# Windows'ta Görev Zamanlayıcı (Task Scheduler) ya da schtasks:
schtasks /create /tn "AnadoluETL" /tr "C:\anadolu\.venv\Scripts\python.exe -m etl.gunluk" /sc daily /st 02:00

Bir alan nasıl açılır? */15 "0'dan başlayıp 15'er adım", 1-5 "aralık", 1,3,5 "liste" demektir; 10-30/10 ise aralık içinde adım. Aşağıdaki kutu bir alanı değer kümesine çeviriyor; alıştırmalarda bunun üzerine "bir sonraki çalışma zamanı" hesaplayıcısı kuracaksın.

def alan_ac(alan, alt, ust):
    degerler = set()
    for parca in alan.split(","):
        aralik, _, adim = parca.partition("/")
        if aralik == "*":
            bas, son = alt, ust
        elif "-" in aralik:
            bas, son = map(int, aralik.split("-"))
        else:
            bas = son = int(aralik)
        degerler.update(range(bas, son + 1, int(adim) if adim else 1))
    return sorted(degerler)

print(alan_ac("*/15", 0, 59))
print(alan_ac("1-5", 0, 6))
print(alan_ac("8,12,18", 0, 23))
print(alan_ac("10-30/10", 0, 59))

Python içinden zamanlama gerekiyorsa iki popüler paket vardır (tarayıcıda çalışmaz, kendi bilgisayarında dene):

# pip install schedule apscheduler
import schedule, time
schedule.every().day.at("02:00").do(gunluk_etl)
schedule.every(15).minutes.do(stok_kontrol)
while True:
    schedule.run_pending()
    time.sleep(30)

from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
zamanlayici = BlockingScheduler(timezone="Europe/Istanbul")
zamanlayici.add_job(gunluk_etl, CronTrigger.from_crontab("0 2 * * *"), max_instances=1)
zamanlayici.start()
Saat dilimiSunucular çoğunlukla UTC çalışır. "02:00" yazdığında hangi saat dilimini kastettiğini her zaman açıkça belirt (Airflow'da pendulum.timezone("Europe/Istanbul"), APScheduler'da timezone=). Aksi halde işin Türkiye saatiyle 05:00'te çalıştığını sonradan fark edersin.

DAG: hangi sırayla çalışsın?

Cron "ne zaman" sorusunu çözer, ama "temizlik bitmeden rapor başlamasın" diyemez. Görevler ve aralarındaki bağımlılıklar bir yönlü döngüsüz çizge (Directed Acyclic Graph, DAG) oluşturur: oklar "önce bu, sonra şu" der, döngü olamaz (A, B'yi; B de A'yı bekliyorsa hiçbiri başlayamaz). Bir DAG'ı geçerli bir sıraya dizmeye topolojik sıralama denir ve Python'da standart kütüphanede hazırdır: graphlib.TopologicalSorter.

from graphlib import TopologicalSorter, CycleError

# görev: {önce bitmesi gerekenler}
akis = {
    "siparis_cek": set(),
    "musteri_cek": set(),
    "temizle": {"siparis_cek", "musteri_cek"},
    "stok_guncelle": {"temizle"},
    "sehir_raporu": {"temizle"},
    "eposta_gonder": {"sehir_raporu", "stok_guncelle"},
}
print(list(TopologicalSorter(akis).static_order()))

# paralel katmanlar: aynı anda çalışabilecek görevler
ts = TopologicalSorter(akis)
ts.prepare()
katman = 1
while ts.is_active():
    hazir = sorted(ts.get_ready())
    print(f"Katman {katman}:", hazir)
    ts.done(*hazir)
    katman += 1

try:
    list(TopologicalSorter({"a": {"b"}, "b": {"c"}, "c": {"a"}}).static_order())
except CycleError as e:
    print("Döngü:", e.args[1])

get_ready() bağımlılıkları bitmiş görevleri verir; done() ile bitenleri bildirdikçe yenileri açılır. Orkestratörlerin çalıştırıcıları (executor) özünde bu döngüyü işçi havuzuyla çalıştırır.

Airflow: endüstri standardı

Apache Airflow, DAG'ları Python koduyla tanımladığın, zamanlayan, çalıştıran, yeniden deneyen ve web arayüzünde izleyen açık kaynak bir platformdur. Temel kavramlar:

KavramNe?
DAGGörevler ve bağımlılıklarının bütünü; schedule, start_date, catchup ayarlarını taşır
OperatorGörev türü: PythonOperator, BashOperator, SQLExecuteQueryOperator, sensörler
TaskOperator'ün DAG içindeki örneği; her çalışmada bir "task instance" olur
a >> bb, a'dan sonra çalışır (a.set_downstream(b))
retries / retry_delayBaşarısız görevin kaç kez, ne aralıkla yeniden deneneceği
XComGörevler arasında küçük veri (dosya yolu, satır sayısı) aktarımı; büyük veri değil
Data interval / logical dateÇalışmanın işlediği zaman dilimi; backfill bununla yapılır
# pip install apache-airflow  (yerelde: airflow standalone)
from datetime import datetime, timedelta
from airflow.decorators import dag, task
from airflow.operators.bash import BashOperator

varsayilan = {"retries": 3, "retry_delay": timedelta(minutes=5),
              "retry_exponential_backoff": True, "email_on_failure": True}

@dag(schedule="0 2 * * *", start_date=datetime(2025, 1, 1), catchup=False,
     default_args=varsayilan, tags=["anadolu-market"])
def gunluk_satis():
    @task
    def siparis_cek(ds=None) -> str:          # ds: işlenen gün (YYYY-MM-DD)
        yol = f"/data/ham/siparis_{ds}.csv"
        # ... API'den çek, dosyaya yaz ...
        return yol                            # XCom ile sonraki göreve gider

    @task
    def temizle(yol: str) -> int:
        # ... oku, doğrula, temiz tabloya yaz ...
        return 1250                           # satır sayısı

    @task
    def rapor(satir: int):
        print(f"{satir} satır işlendi")

    yedek = BashOperator(task_id="yedekle", bash_command="pg_dump anadolu > /yedek/{{ ds }}.sql")
    satir = temizle(siparis_cek())
    satir >> [rapor(satir), yedek]

gunluk_satis()

TaskFlow API'de (@task) bir görevin dönüş değerini diğerine argüman olarak vermek hem bağımlılığı hem XCom aktarımını kendiliğinden kurar. {{ ds }} Jinja şablonudur; Airflow onu çalışmanın tarihiyle doldurur. catchup=True olsaydı start_date'ten bugüne kadar kaçırılan tüm günler tek tek çalıştırılırdı (backfill).

AraçYaklaşımNe zaman?
cron + betikZaman tabanlı, bağımlılık yokBirkaç bağımsız iş
AirflowGörev odaklı DAG, zengin operator ekosistemiKurumsal, çok ekipli veri platformu
PrefectSıradan Python fonksiyonlarına @flow/@task; dinamik akış kolayPython ağırlıklı ekip, hızlı başlangıç
DagsterVarlık (asset) odaklı: "hangi tablo hangi tablodan üretilir"Veri soy ağacı ve kalite önemliyse
Celery / RQGörev kuyruğu (zamanlayıcı değil)Web uygulamasından arka plan işi

İdempotentlik ve yeniden çalıştırma

Orkestrasyonun altın kuralı: bir görevi aynı girdiyle iki kez çalıştırmak, bir kez çalıştırmakla aynı sonucu vermelidir (idempotent). Çünkü görevler yeniden denenir, elle yeniden başlatılır, backfill edilir. "Satırları tabloya ekle" idempotent değildir: ikinci çalışmada her satır iki kez olur. "Bu günün bölümünü (partition) sil, sonra yaz" ya da "anahtar varsa güncelle" (upsert) idempotenttir.

import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE gunluk_satis (tarih TEXT, sehir TEXT, ciro REAL)")

def yukle(tarih, satirlar):
    with db:   # işlem (transaction): ya hepsi ya hiçbiri
        db.execute("DELETE FROM gunluk_satis WHERE tarih = ?", (tarih,))
        db.executemany("INSERT INTO gunluk_satis VALUES (?, ?, ?)",
                       [(tarih, s, c) for s, c in satirlar])

veri = [("İzmir", 1500.0), ("Ankara", 900.0)]
yukle("2025-03-01", veri)
yukle("2025-03-01", veri)          # yeniden deneme: kopya oluşmaz
yukle("2025-03-02", [("İzmir", 700.0)])
print(db.execute("SELECT tarih, COUNT(*), SUM(ciro) FROM gunluk_satis GROUP BY tarih").fetchall())

Diğer ilkeler: görev işlediği zaman dilimini parametre olarak alsın (datetime.now() değil; yoksa dünün işini bugün yeniden çalıştıramazsın), çıktı dosyaları tarihli olsun, yan etkiler (e-posta, ödeme) tekrar gönderimi önleyen bir anahtarla korunsun.

Yeniden deneme, hata bildirimi ve gözlemlenebilirlik

Ağ kopması, kilitli tablo, geçici 503 gibi geçici hatalar yeniden denenir; bekleme süresi her denemede katlanır (üstel geri çekilme, exponential backoff) ki zaten zorlanan sisteme yük bindirilmesin. Kalıcı hatalar (bozuk veri, yanlış parola) yeniden denenmez; hemen başarısız olup haber verilir.

import functools

def yeniden_dene(deneme=3, ilk_bekleme=1.0, gecici=(ConnectionError, TimeoutError), uyu=None):
    def dekorator(f):
        @functools.wraps(f)
        def sarmal(*args, **kwargs):
            bekleme = ilk_bekleme
            for i in range(1, deneme + 1):
                try:
                    return f(*args, **kwargs)
                except gecici as e:
                    if i == deneme:
                        raise
                    print(f"  {f.__name__} deneme {i} başarısız ({e}); {bekleme} sn sonra tekrar")
                    (uyu or (lambda s: None))(bekleme)
                    bekleme *= 2
        return sarmal
    return dekorator

sayac = {"n": 0}

@yeniden_dene(deneme=4)
def siparis_cek():
    sayac["n"] += 1
    if sayac["n"] < 3:
        raise ConnectionError("API yanıt vermedi")
    return 120

print("Çekilen satır:", siparis_cek())

Haber verme katmanları: görev başarısız olduğunda (Airflow'da on_failure_callback) Slack/Teams/e-posta mesajı; iş hiç başlamazsa sessiz kalmamak için "ölü adam anahtarı" (dead man's switch: iş her başarıyla bittiğinde bir izleme servisine sinyal gönderir, sinyal gelmezse alarm çalar); SLA ("rapor 07:00'ye kadar hazır olmalı") ihlalinde uyarı.

Gözlemlenebilirlik (observability) için her görev en azından şunları yapılandırılmış log (JSON satırı) olarak yazmalı: görev adı, çalışma kimliği, işlenen tarih, başlangıç-bitiş, süre, okunan/yazılan satır sayısı, sonuç. Bu sayılar metrik sistemine (Prometheus, Datadog, CloudWatch) gider; "dün 1250 satır geldi, bugün 3" gibi sessiz veri sorunları ancak böyle yakalanır.

import json, time

def calistir_ve_logla(ad, f, tarih):
    bas = time.perf_counter()
    kayit = {"gorev": ad, "tarih": tarih}
    try:
        kayit["satir"] = f(tarih)
        kayit["durum"] = "basarili"
    except Exception as e:
        kayit.update(durum="basarisiz", hata=f"{type(e).__name__}: {e}")
    kayit["sure_ms"] = round((time.perf_counter() - bas) * 1000)
    print(json.dumps({k: v for k, v in kayit.items() if k != "sure_ms"}, ensure_ascii=False))
    return kayit

calistir_ve_logla("temizle", lambda t: 1250, "2025-03-01")
calistir_ve_logla("rapor", lambda t: 1 / 0, "2025-03-01")
Küçük başlaİki üç işin varsa cron + iyi loglanan, idempotent betikler yeter. Bağımlılıklar, yeniden denemeler ve izleme ihtiyacı arttıkça bir orkestratöre geç. Araç değişse de bu dersteki ilkeler (DAG, idempotentlik, geçici/kalıcı hata ayrımı, gözlemlenebilirlik) aynı kalır.
Gerçek işteVeri mühendisliği ekiplerinde en çok zaman alan iş yeni akış yazmak değil, gece 03:00'te düşen akışı anlamaktır. Bu yüzden kod incelemesinde (code review) şu sorular sorulur: Bu görev iki kez çalışırsa ne olur? Dünün verisini yeniden işleyebilir miyim? Hata olunca kime, hangi bilgiyle haber gidiyor? Görev ne kadar sürüyor, kaç satır işliyor ve bunu nereden görüyorum? Bu sorulara cevabı olan bir akış, "çalışıyor" olandan çok daha değerlidir.
Derinleş

Derste TaskFlow'a kısa bir örnekle girdin ve backfill'in ne olduğunu gördün. Bu bölüm Airflow'un en çok kafa karıştıran üç konusunu açıyor: bir çalışmanın hangi zaman dilimini işlediği (data interval), catchup ile backfill arasındaki fark ve saat yerine veri güncellenince tetiklenen zamanlama. Sonunda aynı akışı Prefect ve Dagster'da da göreceksin.

Data interval: çalışma, dilimin sonunda başlar

Airflow'da günlük bir DAG, "1 Ekim" verisini 1 Ekim'de değil, gün bittikten sonra (2 Ekim 00:00'dan sonra) işler; çünkü ancak o zaman günün verisi tamamdır. Her çalışma bir zaman dilimi taşır: data_interval_start (ve onun eşi logical_date, şablonda {{ ds }}) ile data_interval_end. Görevlerin "şimdi"ye değil bu dilime bakması, backfill'in ve yeniden çalıştırmanın doğru sonuç vermesinin tek yoludur.

Çalışmadata_interval_start (ds)data_interval_endGerçekte başladığı an
Günlük, 1. çalışma2024-10-01 00:002024-10-02 00:002024-10-02 00:00 sonrası
Günlük, 2. çalışma2024-10-02 00:002024-10-03 00:002024-10-03 00:00 sonrası

Dilim yarı açıktır: başlangıç dahil, bitiş hariç. SQL'de bunun karşılığı zaman >= :bas AND zaman < :bit'tir; BETWEEN iki ucu da dahil eder ve gece yarısındaki kayıtları iki güne birden yazar.

import veri
from datetime import date, timedelta
conn = veri.baglan("lezzet_kurye", tablolar=["lk_orders"])

def gunluk_siparis(ds: date) -> int:           # görev: yalnız kendi dilimine bakar
    bas, bit = ds.isoformat(), (ds + timedelta(days=1)).isoformat()
    return conn.execute("SELECT COUNT(*) FROM lk_orders WHERE placed_at >= ? AND placed_at < ?",
                        (bas, bit)).fetchone()[0]

ds = date(2024, 10, 1)                         # backfill: 1-7 Ekim
while ds <= date(2024, 10, 7):
    print(ds, gunluk_siparis(ds))
    ds += timedelta(days=1)

TaskFlow derinlemesine

from airflow.decorators import dag, task      # Airflow 3: from airflow.sdk import dag, task
from datetime import datetime

@dag(schedule="@daily", start_date=datetime(2024, 10, 1), catchup=False,
     max_active_runs=1, default_args={"retries": 2})
def lezzet_gunluk():
    @task
    def subeler() -> list[str]:
        return ["izmir", "ankara", "istanbul"]

    @task
    def cek(sube: str, data_interval_start=None) -> str:   # bağlam argümanı adla enjekte edilir
        ds = data_interval_start.strftime("%Y-%m-%d")
        return f"s3://ham/{sube}/{ds}.parquet"              # XCom'a veri değil YOL koy

    @task(multiple_outputs=True)
    def ozet(yollar: list[str]) -> dict:
        return {"dosya": len(yollar), "durum": "tamam"}

    yollar = cek.expand(sube=subeler())       # dinamik eşleme: her şube için bir görev örneği
    ozet(yollar)

lezzet_gunluk()

.expand() dinamik görev eşlemesidir (dynamic task mapping): şube sayısı çalışma anında belirlenir ve her biri ayrı, ayrı yeniden denenebilen bir görev olur. multiple_outputs=True sözlüğün her anahtarını ayrı XCom yapar. max_active_runs=1 aynı DAG'ın iki gününün aynı anda çalışmasını engeller.

catchup mı, backfill mi?

catchup=TrueBackfill
Kim başlatır?Zamanlayıcı, kendiliğindenSen, komutla
Hangi dilimler?start_date'ten bugüne kadar çalışmamış tüm dilimlerVerdiğin aralık (-s ile -e)
Ne zaman tehlikeli?Eski start_date ile yeni DAG açınca yüzlerce çalışma kuyruğa girerGörev idempotent değilse veri ikilenir
airflow dags backfill lezzet_gunluk -s 2024-10-01 -e 2024-10-07       # Airflow 2
airflow backfill create --dag-id lezzet_gunluk \
    --from-date 2024-10-01 --to-date 2024-10-07 --max-active-runs 2   # Airflow 3

Pratik kural: yeni DAG'ları catchup=False ile aç, geçmişi gerekiyorsa bilinçli bir backfill komutuyla ve max_active_runs sınırıyla doldur. depends_on_past=True bir günün görevi, bir önceki günün aynı görevi başarılı olmadan başlamasın der; kümülatif tablolarda (dünkü bakiye + bugünkü hareket) gereklidir.

Veri farkındalıklı zamanlama: dataset ve asset

"Her gün 02:00'de çalış" yerine "siparisler tablosu güncellenince çalış" demek, üreticisi gecikince tüketicinin boş veriyle çalışmasını önler. Airflow 2.4+ bunu Dataset, Airflow 3 Asset adıyla yapar: üretici görev outlets ile neyi güncellediğini bildirir, tüketici DAG schedule=[...] ile bekler. Listedeki tüm varlıklar son çalışmadan beri güncellenince tetiklenir.

from airflow.sdk import Asset, dag, task
siparisler = Asset("s3://ambar/siparisler")
kuryeler = Asset("s3://ambar/kuryeler")

@dag(schedule="@hourly")
def uretici():
    @task(outlets=[siparisler])
    def yaz(): ...
    yaz()

@dag(schedule=[siparisler, kuryeler])        # ikisi de güncellenince
def kurye_performans():
    ...

Aynı akış Prefect ve Dagster'da

# Prefect: sıradan Python, @flow ve @task
from prefect import flow, task
@task(retries=2)
def cek(sube: str, gun: str) -> str: ...
@flow
def lezzet_gunluk(gun: str):
    yollar = cek.map(["izmir", "ankara"], gun)

# Dagster: varlık (asset) odaklı; "hangi tablo neyden üretilir"
from dagster import asset, DailyPartitionsDefinition
gunluk = DailyPartitionsDefinition(start_date="2024-10-01")
@asset(partitions_def=gunluk)
def ham_siparis(context): ...
@asset(partitions_def=gunluk)
def gunluk_ozet(ham_siparis): ...           # bağımlılık parametre adından çıkar

Prefect'te zamanlama ve dağıtım akıştan ayrıdır, dinamik akışlar doğaldır. Dagster'da birincil kavram görev değil tablodur; bölümler (partitions) backfill'i "şu günlerin bölümlerini yeniden üret" düğmesine indirir ve soy ağacı (lineage) kendiliğinden çizilir.

Gerçek işteOrkestrasyon hatalarının çoğu araçtan değil zaman kavramından çıkar: "şimdi"ye bakan görev, BETWEEN ile çift sayılan gece yarısı, saat dilimi olmayan start_date, idempotent olmayan yükleme. Mülakatta "Dünün işini yeniden çalıştırırsan ne olur?" sorusuna net cevap verebilmek, Airflow'un bütün menülerini bilmekten değerlidir.
Uzmanlaş

Sensörler ve olay tabanlı tetikleme

Her şey saatle başlamaz. "Şube dosyaları S3'e düşünce başla" gibi durumlar için sensörler (FileSensor, S3KeySensor) ya da Airflow'un veri kümesi (dataset/asset) tabanlı zamanlaması kullanılır: üretici DAG bir tabloyu güncellediğinde tüketici DAG kendiliğinden tetiklenir. Sensörleri "reschedule" modunda çalıştır ki beklerken işçi yuvası (worker slot) işgal etmesin.

Eşzamanlılık ve kaynak yönetimi

max_active_runs aynı DAG'ın üst üste binmesini, havuzlar (pools) aynı veritabanına aynı anda bağlanan görev sayısını sınırlar. Uzun işlerin üst üste binmesi (bir önceki çalışma bitmeden yenisinin başlaması) idempotent olmayan işlerde veri bozulmasının en sık nedenidir; APScheduler'da max_instances=1 bunun karşılığıdır.

Veri sözleşmeleri ve kalite kapıları

Akışın ortasına kalite kapısı koy: satır sayısı beklenen aralıkta mı, zorunlu sütunlarda boş değer var mı, toplam ciro dünkünden %90 düşük mü? Kapı başarısızsa akış durur ve kötü veri raporlara ulaşmaz. Great Expectations, Soda ya da dbt testleri bu iş için kullanılır.

Alıştırmalara geç (30)