The Jerusalem PostLondon plane lands in Warsaw-Modlin airport after explosive device reported on boardPunchSokoto pledges stronger security partnershipsDaily MaverickADMINISTRATIVE CHAOS: Athletics SA moves rapidly to implement World Athletics leadership overhaul directiveBollywood HungamaKaran Tacker to begin shooting for Bhay: The Gaurav Tiwari Mystery Season 2 in MarchInquirer EntertainmentKatrina Johnson nixes idea of Miss International ‘archetype’SoompiWoo Davi And Kim Hyun Jin’s Upcoming 2026 KBS Drama Special “Sound:Track” Unveils Intriguing First PosterSky TG24Elezioni Midterm 2026, oggi il voto di metà mandato negli Stati Uniti. DIRETTAZDF heuteAktuelle Pressemitteilungen des ZDFسكاي نيوز عربيةضربات أميركية على قشم.. وطهران تقول إن الاتفاق بعيدNumeramaPrès d’un jeu sur deux sorti sur Steam en octobre 2026 a eu recours à l’IA générativeTagesschauLiveblog zu den Midterms: ++ US-Bürger wählen neuen Kongress ++Il Fatto QuotidianoDietro le quinte de “La porta magica” con Andrea Delogu, tra storie, emozioni e confessioni: “Questo programma mi ha salvata”
The Daily Newsstand · Free, Always
Tuesday, November 3, 2026

Миграция продакшен-контура OpenMetadata с Airflow 2 на Airflow 3: что сломалось и как мы это чинили

Translate

Наш ingestion-контур — это сервис на базе Apache Airflow, который крутит пайплайны загрузки метаданных в OpenMetadata. Поверх стандартного Airflow у нас живёт плагин openmetadata-managed-apis (через него UI OpenMetadata деплоит и дёргает DAG'и), свои утилиты логирования и свой Docker-образ со строго зафиксированными зависимостями.

Когда мы решили переезжать с Airflow 2 на Airflow 3, мы прочитали официальный migration guide и думали, что основная работа — переименовать параметры и импорты. Спойлер: переименование заняло один вечер, а вся миграция — четыре месяца, несколько десятков коммитов и одну полную смену архитектурного решения на ходу. Ниже — честный разбор по следам нашей git-истории.

Сначала хронология: это была не миграция, а жизнь на двух мажорах

Если восстановить события по коммитам, картина выглядит так.

Новый репозиторий под тройку мы завели в начале марта — и с первого же коммита положили в него constraints-файлы для обеих линеек: и для Airflow 3.1.x, и для старого доброго 2.10.5. Страховка с нулевого дня, и, как показала практика, не лишняя.

Дальше две недели шла интенсивная работа в ветке airflow-3: даги, плагин, логирование, типы. Но в конце марта — после того как «миграция уже почти закончена» — в develop влилась параллельная ветка airflow-2.11 с фиксами для старой линии. Продакшен всё ещё жил на двойке, и его надо было обслуживать.

Последствия этого мерджа обнаружились в начале апреля: рефакторинг дагов под новый API пришлось делать заново, потому что develop «отъехал» к дагам в старом стиле. Та же самая правка, что была сделана в середине марта, во второй раз прошла по тем же файлам в начале апреля. Отдельно весной мы продолжали латать сборку двойки: откаты транзитивных зависимостей и свежие constraints для 2.11.x датированы апрелем — когда, казалось бы, мы уже «на тройке».

Окончательная точка ставится только в июле: именно тогда в pins-файле зафиксировали конкретную версию Airflow 3.1.8. Четыре месяца между первым миграционным коммитом и моментом «всё, мы переехали».

Главный урок хронологии: миграция мажорной версии оркестратора в живом контуре — это не событие, а режим параллельной поддержки двух версий. Планируйте его как отдельный этап с собственным бюджетом времени.

Слой 1. DAG'и: простая механика, которую пришлось делать дважды

Механическая часть действительно простая и хорошо описана в официальном гайде: импорты публичного API переехали в airflow.sdk, стандартные операторы — в провайдера standard, schedule_interval стал schedule, DummyOperator окончательно заменён на EmptyOperator. Один вечер - один коммит по одиннадцати файлам с дагами.

Подводных камней тут несколько

Первый — семантика расписаний. У нас были даги с @once, которые по факту использовались только для ручных запусков. В Airflow 3 правильный ответ для такого сценария — schedule=None. Мелочь, но, если не пройтись по всем дагам глазами, поведение после миграции будет отличаться.

Второй камень — организационный, и он дороже первого. Когда в ветку миграции вливаются изменения из старой линии, все правки нового API по затронутым файлам теряются, и никто не замечает этого до первого прогона. Мы повторяли рефакторинг дагов дважды. Если живёте на двух мажорах, либо мерджите старую линию «с призмой» нового API, либо сразу после каждого такого мерджа гоняйте компиляцию дагов в CI.

Слой 2. Плагин openmetadata-managed-apis: от monkey-patch к нормальной архитектуре

Самая болезненная часть. Плагин openmetadata-managed-apis — дитя эпохи Airflow 2: Flask-blueprint'ы, FAB-view, обращение к cached_app. В Airflow 3 архитектура перевернулась: веб-сервер и API-сервер разнесены, Flask-обвязка стала legacy-слоем, cached_app исчез.

Первым всплыл health check. Наш мониторинг и сам сервер OpenMetadata ходят на /health в корне веб-сервера. В Airflow 3 на этом пути уже висит штатный обработчик, который регистрируется раньше нашего blueprint'а и отвечает не в том формате, который ждёт OpenMetadata. Первая реакция была классической: поднять свой blueprint и monkey-patch'нуть таблицу роутов Flask после регистрации всех обработчиков. Патч заработал не сразу — первый вариант срабатывал не во всех сценариях запуска, пришлось переписывать на другой хук жизненного цикла Flask-приложения. Два коммита подряд с сообщениями «monkey-patch для health» и «monkey-patch: исправление патча» красноречиво описывают тот вечер.

Но хак остался хаком: он зависел от внутренностей Flask и от порядка регистрации роутов, а значит, мог сломаться от любого обновления. Поэтому через пару недель мы сменили подход: вместо борьбы с роутингом изнутри подняли лёгкий Flask-прокси перед API-сервером Airflow. Прокси слушает публичный порт, отдаёт корректный /health в ожидаемом формате и прозрачно перекладывает остальные запросы на API-сервер, который теперь живёт на внутреннем порту. Решение перестало зависеть от приватных деталей фреймворка и стало поддерживаемым.

Отдельная работа — совместимость кода плагина: в переходный период один и тот же код должен был работать на обоих мажорах, поэтому внутри появились ветвления «для двойки — так, для тройки — иначе». Это некрасиво, но позволяет выкатывать фиксы на обе линии, пока миграция не завершена.

Вывод по слою: если у вас есть кастомные плагины на Flask/FAB — это главная точка риска всей миграции. Начинайте с них, а не с дагов, и закладывайте время не на «починить», а на «перепроектировать».

Слой 3. Логирование: детектор «мы внутри Airflow» перестал работать

У нас есть утилита, которая определяет, исполняется ли код внутри Airflow-рантайма, и если нет — применяет свою конфигурацию логирования для локальных запусков и тестов. Детектор был написан в эпоху Airflow 2 и опирался на характерный набор переменных окружения, которые планировщик выставляет задачам.

В Airflow 3 задачи исполняются через Task SDK в отдельных сабпроцессах, и набор переменных окружения там другой: часть маркеров, на которые мы опирались, просто не доезжает до процесса задачи. Детектор честно решал «мы не в Airflow», применял свою конфигурацию логирования и этим отрывал штатные хендлеры задач Airflow.

Симптом заслуживает отдельного абзаца: таски в UI зелёные, всё «работает», а файлы логов попыток пустые. Ничего не упало — просто нечего читать. На диагностику ушло заметно больше времени, чем на фикс, который свёлся к ослаблению эвристики: достаточно пары общих признаков окружения вместо требования конкретных переменных. Заодно мы перешли с root-логгера на именованные логгеры и завели отдельные файловые хендлеры для крупных интеграций — в новой схеме сообщения именованных логгеров перестали попадать туда, куда мы привыкли.

Вывод по слою: любой код, завязанный на переменные окружения Airflow, — кандидат на ревизию. Task SDK изменил контракт «что видит задача» молча. И первым делом после миграции откройте файлы логов попыток: пустые файлы при зелёных тасках — это диагноз.

Слой 4. Типизация и зависимости: скучная, но обязательная часть

В CI у нас стоит mypy, и переезд на новый публичный API устроил нам серию из десятка коммитов с красноречивым названием «фикс типов». Самое интересное — пакет настроек, который в рантайме наполняется из отдельного конфигурационного модуля: mypy про эту магию не знает и засыпал нас ошибками при каждом обращении к настройкам. Решили стабом .pyi рядом с пакетом — дёшево и честно.

Финальный босс — зависимости. Мы собираем свой образ и ведём собственные constraints-файлы поверх официальных. Весенние коммиты рисуют классический dependency hell в высоком разрешении: конфликт версий на сборке образа, откат jinja2, откат h11 и PyJWT, удаление зависимости от давно устаревшего pkg_resources. Причём латать приходилось обе линейки: constraints для 2.11.x под разные версии Python добавлялись уже после того, как основная работа по тройке была сделана.

Лечится всё это только методично: собрать образ, поймать конфликт, зафиксировать версию, повторить. Два практических вывода. Во-первых, constraints-файл должен соответствовать одновременно версии Airflow и версии Python — несоответствие любого из двух даёт конфликты в самых неожиданных местах. Во-вторых, транзитивные зависимости, которые «и так работали», после мажорного апгрейда перестают работать молча — фиксируйте их явно.

Чек-лист для тех, кому предстоит

  • Планируйте параллельный период. Между «начали миграцию» и «зафиксировали версию тройки» у нас прошло четыре месяца жизни на двух мажорах. Это норма для живого контура, а не признак провала.

  • DAG'и: новый публичный API из airflow.sdk, операторы из провайдера standard, schedule вместо schedule_interval, schedule=None для ручных запусков. И компиляция дагов в CI после каждого мерджа из старой линии.

  • Плагины: всё, что завязано на Flask/FAB, — отдельный эпик и первый приоритет. Monkey-patch пригоден как временная мера, но целевое решение не должно зависеть от внутренностей фреймворка.

  • Окружение: перепроверьте код, который смотрит на переменные окружения Airflow, — Task SDK прокидывает другой набор.

  • Логи: после первого прогона сразу смотрите в файлы логов попыток. Зелёные таски с пустыми логами — сигнатура оторванных хендлеров.

  • Типы и CI: mypy сломается, стабы для runtime-магии подготовьте заранее.

  • Зависимости: constraints под связку «версия Airflow + версия Python», явная фиксация транзитивных пакетов. Откатывать — нормально.

Выводы

Миграция заняла у нас четыре месяца и несколько десятков коммитов, при этом «официальная» часть работы уложилась бы в день. Всё остальное время съели вещи, которых нет в migration guide: поведение плагина на стыке Flask и нового API-сервера, невидимые изменения в окружении задач Task SDK, пустые логи и двойная жизнь репозитория.

Если бы мы начали заново, изменили бы две вещи. Первое: начали бы не с дагов, а с плагина и логирования — то есть с мест, где риск молчаливой поломки максимален. Второе: сразу договорились бы о правилах мерджа между старой и новой линией, чтобы не делать один и тот же рефакторинг дважды.

P.S. Про иронию судьбы

Пока готовилась эта статья, вышел OpenMetadata 2.0 — и в релизных заметках прямым текстом сказано: Airflow как внутренний оркестратор объявлен deprecated уже в версии 2.1 в пользу нативного Kubernetes-оркестратора (сам он появился ещё в 1.12). Пайплайны ingestion теперь умеют исполняться как Kubernetes Jobs вообще без Airflow: переключаешь pipelineServiceClientConfig.type с airflow на k8s, пересоздаёшь пайплайны и живёшь без веб-сервера, шедулера и всего того, о чём была эта статья.

Внешние установки Airflow, как у нас, формально не затронуты, но вектор развития платформы очевиден. То есть мы четыре месяца мигрировали с Airflow 2 на Airflow 3, чтобы теперь начать готовиться к жизни без Airflow….

Утешает одно: почти всё сделанное — переписанные пайплайны, отказ от привязки к внутренностям фреймворка, честно зафиксированные зависимости — это вклад в переносимость, который пригодится и в мире Kubernetes Jobs. Так что, похоже, у этой статьи будет продолжение. Возможно, уже про переезд на K8s-оркестратор.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.