Прямой ответ
ETL (extract — выгрузка из источника, transform — приведение/очистка, load — загрузка в хранилище) в 80% случаев не требует тяжёлых оркестраторов: достаточно скрипта на Python под планировщиком (systemd timers/CI-schedule) и четырёх принципов. Инкрементальность — выгружать изменённое с прошлого запуска (курсор: updated_at/идемпотентный ключ в таблице состояния), а не «всю таблицу каждый раз». Идемпотентность загрузки — повторный прогон за тот же период даёт тот же результат (UPSERT по бизнес-ключу или загрузка партиции «день» с перетиранием — перезапуск после сбоя безопасен — идемпотентность в применении к данным). Проверки качества до публикации — счётчики строк в коридоре ожидаемого, доли null в ключевых полях, свежие даты; провал проверки — алерт и остановка (загрузить мусор в отчётный слой дороже, чем не загрузить). Наблюдаемость — лог прогона (период, счётчики, время) и алерт на непрогон («тишина» = пайплайн умер — healthchecks-паттерн). Инструменты по размеру: pandas для миллионов строк, DuckDB для SQL-трансформаций поверх файлов без сервера (аналитика), parquet как промежуточный формат (форматы); Airflow/оркестратор — когда задач десятки с зависимостями, а не «один скрипт в три часа ночи».
Extract: курсоры, пагинация, честность источника
Выгрузка из API/БД — самая хрупкая часть: источник живёт своей жизнью. Инкрементальный курсор: таблица etl_state (pipeline, last_value) — прошлый запуск сохранил max(updated_at); следующий выгружает WHERE updated_at > last — но с перекрытием (okno 5–10 минут назад: часы источника и ваши расходятся, поздно-закоммиченные транзакции теряются без overlap; дубли от перекрытия убивает идемпотентная загрузка). Удаления в источнике инкрементально не видны («строка исчезла» не выгружается) — периодическая полная сверка (ночной full-прогон раз в неделю или reconcile по счётчикам). Пагинация/лимиты: API с rate limit — батчи с паузами и ретраями на 429 (дисциплина); большие таблицы — keyset-пагинация по ключу, не OFFSET. Снимок на согласованное состояние: выгрузка из живой БД — в одной транзакции/реплике (курсоры в разных таблицах, снятые в разное время, дают расхождения — «заказ есть, позиций нет»); «сырая копия» сохраняется как есть — трансформации потом (сырой слой не пересобирается при изменении логики — источник истины для переигрывания).
Transform и Load: идемпотентность как фундамент
Трансформации — чистая функция «сырые данные → целевая модель»: отдельный модуль с тестами на фикстурах (тестирование ETL-логики — редкость и огромное преимущество при правках), без обращений к источнику внутри (вход — файлы/таблицы сырого слоя — воспроизводимо и отлаживаемо локально). Типовой набор: приведение типов/дат (часовые пояса — datetime-практика), дедупликация по бизнес-ключу (последняя версия побеждает), очистка (паттерны), соединения-справочники (merge-осторожности). Load-стратегии: UPSERT по ключу (INSERT ... ON CONFLICT (key) DO UPDATE — повтор безопасен, частичные обновления сходятся); партиционная перезапись (день/месяц — DELETE WHERE period=X; INSERT в транзакции — переигрывание периода атомарно); append-only для событий (с дедупликацией по id). Публикация атомарна: потребители (отчёты/BI) не должны видеть половину загрузки — транзакция, или загрузка в staging-таблицу и переключение представления (read-слой всегда целен). Формат промежуточных данных — parquet (типизирован, сжат, DuckDB/pandas читают напрямую — CSV теряет типы и молча ломает даты).
Качество данных: проверки, которые ловят мусор
Слой проверок между transform и load — дешёвая страховка от «отчёт поехал, а пайплайн зелёный». Минимальный набор: объём (строк в коридоре ±50% от скользящего среднего — пустая выгрузка и двойная выгрузка ловятся одинаково); полнота ключей (доля null в id/датах/суммах < порога); свежесть (max(date) ≥ ожидаемого периода — «источник перестал обновляться» видно сразу); консистентность (суммы сверяются с контрольным источником/предыдущим периодом в допуске; count(distinct key) = count(*) — дубли не прошли); схема (ожидаемые колонки/типы на месте — источник добавил/переименовал поле — типичный тихий слом). Инструменты: простые assert'ы в коде для старта (прозрачно и тестируемо), pandera/great-expectations при росте числа проверок. Провал — не грузить (или грузить в карантин-схему), алерт с деталями, период помечается незакрытым — переигрывание после фикса источника (идемпотентность делает его тривиальным). Метрики прогонов — в дашборд (Grafana): длительность, счётчики, провалы проверок — деградация источника видна трендом.
Практический сценарий: ночная загрузка заказов из SaaS в свой DWH
Задача: CRM-партнёр отдаёт заказы по REST API (лимит 60 req/min), нужно в своём Postgres к 7 утра для отчётов. Реализация: (1) extract — постраничная выгрузка за вчера с перекрытием 15 минут, курсор в etl_state, сырой JSON в parquet-файлы raw/orders/2026-10-20.parquet (переигрывание возможно без повторных дёрганий API); (2) transform — DuckDB-SQL поверх parquet (джойн со справочником клиентов, дедуп по order_id с последней версией, нормализация сумм) — логика в .sql-файлах с тестами на фикстурах; (3) проверки — строк ≥70% от среднего за неделю, null в customer_id <1%, max(order_date)=вчера; (4) load — UPSERT в orders_dwh по order_id в транзакции; (5) обвязка — systemd timer в 03:00 (планировщик), healthchecks-пинг в конце, лог-сводка (период, выгружено/загружено/дублей/отклонено), алерт на провал проверки и на непрогон к 04:00. Инциденты первого месяца — все пойманы проверками, не отчётами: источник дважды отдал пустую страницу (проверка объёма — переигрывание через час прошло), переименовал поле скидки (проверка схемы — правка маппинга) — ни один мусор не доехал до отчётов. Airflow не понадобился: один пайплайн, ноль зависимостей — cron-класс достаточен; при росте до десятка задач с общими справочниками — миграция на оркестратор планируется отдельно.
FAQ
Когда cron-скриптов уже не хватает и нужен Airflow? Сигналы: DAG-зависимости между задачами (B ждёт A и справочник C), десятки задач с общим расписанием/ретраями, несколько команд с разными правами, backfill-переигрывания периодов стали рутиной; до этого systemd timer + лог-файл дешевле и прозрачнее (оркестратор — сервис, который сам нужно обслуживать).
ELT против ETL — что это меняет? Порядок: ELT грузит сырьё как есть и трансформирует внутри хранилища (SQL в DWH — DuckDB/ClickHouse/облако); выигрывает, когда сырой слой нужен для переигрываний и логика меняется часто; классический ETL (трансформация до загрузки) — когда целевое хранилище слабое/дорогое; гибриды — норма (сырьё в объектное хранилище — MinIO, трансформации SQL-движком).
Как обрабатывать late-arriving данные? Перекрытием курсора (окно опоздания — минуты) + периодическим переигрыванием закрытых периодов (неделя/месяц — идемпотентная загрузка делает его безопасным); отчётность до переигрывания помечается «предварительно» — договорённость с потребителями важнее техники.
Нужны ли streaming-пайплайны (Kafka) вместо батчей? Когда задержка данных — бизнес-требование (фрод, мониторинг цен — минуты важны); для отчётности «к утру» батч проще, дешевле и отлаживаемее (стриминг добавляет exactly-once-заботы, схему-эволюцию, state — Kafka-основы); начинать с батча и лямбда-архитектуры (стрим для быстрого слоя + батч-коррекция) при реальной потребности.
Источники: документация DuckDB, Airflow (для сравнения), практики Astral — ссылки в шапке.