Миграция пяти сервисов: как это выглядит изнутри
Тикет пришёл от Security/Infrastructure Team. Заголовок размытый — «обновить Python», тело чуть конкретнее: версия, до конца жизни которой остаётся хотя бы год, плюс пара основных зависимостей. В корпорации такие тикеты — рутина: это работа отдельной команды, находить то, на что все остальные годами закрывали глаза. Формулировка почти всегда одинаковая: сервис устарел, Python уже end of life, обновитесь до версии с актуальными security-патчами. В довесок — версии библиотек, версии контейнеров и что-нибудь ещё.
В дашборде эта карточка ничем не отличается от остальных. Но это не фича, и оценивать её как фичу — ошибка.
У обычной фичи скоуп хоть немного предсказуем: его можно очертить и поставить число в карточку. С миграцией так не выйдет, её честнее рассматривать как исследовательский проект. Пока не залезешь внутрь, реалистично оценить объём невозможно. Иногда обновился — и всё заработало сразу, но это везение, а не правило; чаще впереди много ручной работы. Готовых инструментов, которые провели бы через это без боли, в экосистеме Python почти нет — есть разрозненные средства разной полезности: pyupgrade, ruff, ещё пара. И есть LLM, на которую возлагают надежды и о которой ниже отдельно.
Конкретно этот случай был интересен масштабом: под миграцию попадало пять сервисов. Версии Python от 3.8 до 3.9. Пакетные менеджеры всех видов: где-то pip с requirements.txt, где-то poetry, где-то conda. У каждого своя степень зависимости от внешних пакетов и свой способ ими управлять.
Аудит: с чем мы работаем
Любая миграция начинается с аудита. Нужно понять всю картину: какие пакеты тянет проект и — важнее — какие функции из этих пакетов код реально использует. Не «у нас стоит pandas», а «вот эти конкретные вызовы pandas, и именно они под риском».
Идеальный исход аудита скучный: все зависимости живы, аккуратно резолвятся, правишь пару цифр в requirements.txt или poetry.lock, гоняешь тесты — зелено. Но это не миграция, а рутинное обновление. На практике готовиться нужно к другому: даже у живых зависимостей внутри накопились изменения — структурные, механические и, самые неприятные, поведенческие. В худшем случае попадается мёртвая, заброшенная библиотека — и тогда добавляется ещё пласт работы: поиск замены.
В моём случае исход был субоптимальным: pandas с v1 на v2, обновление numpy, fastapi, matplotlib, plotly и разменная мелочёвка. Субоптимальным — потому что pandas v1→v2 это не цифра в lock-файле, а серия поведенческих изменений, которые статический прогон не ловит.
Инструменты
Ответы про изменения лежат в документации, changelog'ах и git-репозиториях. Вопрос в том, можно ли поручить чтение инструменту. Несколько кандидатов есть.
pyupgrade
pyupgrade обновляет синтаксис между версиями Python и экономит время на механической правке однотипных строк. Например, перепишет старое форматирование:
# было
"{} = {}".format(key, value)
# стало после pyupgrade (--py39-plus)
f"{key} = {value}"
Здесь безопасно. Но pyupgrade не понимает код — он приводит строку к виду, который считает правильным, и не учитывает, какие данные через неё проходят и в каком порядке аргументы уходят в обновлённую функцию. Отсюда первая проблема: инструменту наплевать на поведение, а выглядит он надёжным.
ruff
ruff устроен иначе. Он не делает вид, что чинит, — он показывает, где смотреть: собирает по проекту отчёт (какая строка, какой объект, иногда со ссылкой на конкретное правило). Это навигатор по техдолгу, а не слепая автозамена. Местами он знает про конкретные переезды — например, для Airflow 3 есть отдельные правила (AIR301/AIR302 для ломающих изменений, AIR311/AIR312 для рекомендованных). Но это исключение; на большинство миграций такого готового знания нет.
Где LLM полезна, а где нет
Следующий кандидат — LLM-агенты, в моём случае Claude Code, Codex, и экспериментирую с Letta Code и совсем немного с Hermes Agents. Здесь стоит говорить без хайпа.
Промпт уровня «обнови проект до последней версии Python и подтяни зависимости» работает — но только на маленьких проектах, где абстракций мало, переиспользование предсказуемо, а отладка быстрая. Как только у сервиса появляются слои и изоляция между ними, этого промпта мало.
Здесь полезно ввести понятие минимального контекста — порога, ниже которого агент бесполезен, а выше начинает приносить пользу. По опыту в него входят четыре вещи:
- релевантные секции changelog'а (именно секции, не ссылка),
- отчёты статических анализаторов с конкретными строками,
- сам затронутый код вместе с тестами,
- лог падения — трейсбек или диф поведения.
Без любого куска агент сваливается в общие советы или выдумывает. Собрать набор — ручная работа: changelog'и читаются глазами, отчёты гоняются тулами. У меня это сразу занимало 30–40% контекстного окна.
И даже с полным контекстом ни одного агента не удалось заставить строго держаться плана. Изменения выходили неполными, неточными или помечались как применённые, хотя в коде их не было. По сути агент работал как улучшенный статический анализатор с правом на запись. Сверх задачи он норовил переименовать переменную или поменять порядок операций по своему вкусу. Самодеятельность высокая, процесс недетерминированный.
Главное — агент, как и линтер, не отличал поведенческое изменение от синтаксического.
Это самый неприятный класс, потому что код продолжает запускаться. Канонический пример — chained assignment с inplace, который в pandas 2.x уже не делает того, что делал:
# pandas v1 — молча работало, колонка обновлялась
df["foo"].fillna(0, inplace=True)
# pandas v2 — FutureWarning, исходный df НЕ меняется
# корректно теперь так:
df.fillna({"foo": 0}, inplace=True)
# или
df["foo"] = df["foo"].fillna(0)
Это суть Copy-on-Write: цепочечная запись вроде
df["a"][1:3] = 0 уходит во временный объект,
который ведёт себя как копия, и оригинал не
меняется. Агент такой код пропустит — синтаксис
валиден. А тесты тихо отвалятся; или, что хуже,
не отвалятся.
Тот же сорт ловушек — изменения дефолтов. В
pandas 2.0 numeric_only во всех методах-редукциях
стал по умолчанию False, и код, годами молча
отбрасывавший нечисловые колонки, начал падать:
df.mean() # v1: считал по числовым
df.mean(numeric_only=True) # v2: явно указать
И работа с пропусками: появились pd.NA и
nullable-типы, а проверять пропуск правильно теперь
функцией, а не сравнением с конкретным сентинелом:
x is None # хрупко
x is np.nan # хрупко
pd.isna(x) # надёжно: ловит None, NaN и pd.NA
На поведенческих изменениях упираешься в потолок всего инструментария разом. Линтер видит синтаксис, но не поведение. Агент видит чуть больше линтера, но недетерминирован и не отличает «работает» от «запускается». А поведенческое изменение проявляется только в рантайме, на конкретных данных, — и не ловится ничем из перечисленного. С этого момента инструменты заканчиваются и начинается ручная работа: открываешь трейсбек (если повезло) или диф выходных данных (если нет), идёшь в changelog, находишь строку и сводишь старое поведение с новым руками.
Два окружения и тесты
Начав миграцию, оказываешься в неудобном промежуточном состоянии: на старом окружении проект уже не запустишь, на новом — ещё не запустишь, потому что он не собирается из-за синтаксических ошибок, кривых импортов и прочего. Пока переписываешь код, проверить отдельный кусок на работоспособность сложно.
Отсюда два практических правила. Первое: миграция живёт в отдельной git-ветке. Второе: держишь два виртуальных окружения, текущее и целевое, и переключаешься между ними, чтобы сверяться.
Но основная страховка — тесты, НО проходящий тест утверждает лишь эквивалентность выбранному автором эталону, а не корректность — он фиксирует конкретное ожидаемое значение (или коридор допуска) в конечном наборе входных точек, которые кто-то догадался потрогать, и ничего не говорит о поведении между ними. Это оракульная проблема: «корректность» миграции предполагает наличие оракула того, что значит правильный результат, а тесты — это слабый, частичный, написанный человеком оракул, так что если эталон кодирует баг, добросовестная миграция добросовестно его воспроизведёт — и тест останется зелёным поверх ошибки. Численные пайплайны обостряют это до предела: смена версии numpy/BLAS/компилятора меняет порядок суммирования с плавающей точкой и ассоциативность редукций, поэтому результат сдвигается на величину, которую строгий assert_equal отметит как ложный регресс, а мягкий atol/rtol молча замаскирует реальный — и сам параметр допуска есть признание, что у тебя есть догадка о приемлемой дельте, а не спецификация. А самая глубокая зависимость — от того, кто писал набор тестов: тест наследует понимание домена и слепые зоны своего автора и не может содержать знания, которого у того никогда не было, — и именно так график может полгода рендериться «правильно» против зелёного набора и раскрыть истинное поведение лишь тогда, когда миграция его возмутит. Так что тесты действительно ценны — но как детектор изменений, а не детектор корректности; опасный режим — не доверие к тестам, а чтение «совпадает с эталоном» как «корректно», и никакое количество компьюта или бюджета этот разрыв не закрывает, потому что недостающее звено — это оракул, которого в систему никогда не закладывали, а не нехватка масштаба для того, что уже есть. Контракт между кодом и зависимостью нельзя пощупать напрямую, и тесты остаются единственным golden master, который скажет, как прошла миграция. По опыту, ниже 75–80% покрытия миграция превращается в работу вслепую.
Чего реально не хватает — инструмента, который
зафиксировал бы этот контракт в рантайме и выдал
отчёт; в Python его нет. Есть полу-DIY: обернуть
граничный модуль зависимости в unittest.mock с
autospec=True, прогнать тесты на старой версии с
записью всех вызовов через mock_calls, а на новой
проверить, что autospec принимает те же сигнатуры.
Autospec ловит изменения сигнатур в рантайме. Это
можно назвать «контрактом в рантайме», но собранным
руками, без отчёта и без полной картины.
Транзитивные зависимости
Был соблазн делать миграцию по шагам: сначала Python, потом pandas, потом numpy. Это работает только в мире без транзитивных зависимостей. У меня numpy был и прямой зависимостью, и транзитивной — через pandas:
your-service
├── numpy (прямая зависимость)
└── pandas ─> numpy (та же numpy, но через pandas)
Обновление pandas тянуло за собой numpy, поэтому развязать узел «обновлю одно сегодня, другое на следующей неделе» не вышло — обе задачи пришлось решать одновременно. Проекты в GEV были крупными научными и вычислительными, где pandas и numpy делят нагрузку примерно поровну, так что этот узел был не краевым случаем, а центральным.
Когда миграция — это переписывание
Один из пяти сервисов целиком жил на Airflow v2. Его pipeline решал задачу прогноза и построения карты ветров на 365 дней вперёд (экстраполяция), по которой инженеры проектируют турбину. Деталь, которая объясняет цену ошибки: почти каждый ветряк уникален и подгоняется под свою позицию — длина мачты, размер и наклон лопастей, особенности генератора. То есть на выходе pipeline — не просто ответ сервиса, а числа, по которым считают железную конструкцию.
Тем временем у Airflow вышла версия v3 — и здесь
«миграция» перестаёт быть синонимом «апгрейда».
Airflow 3 вышел в апреле 2025-го, ветка 2.x доживает
до 22 апреля 2026-го, так что тикет от Security Team
для таких проектов — вопрос времени. Проблема в том,
что переход с v2 на v3 для многих команд оказывается
не обновлением, а переписыванием. С воркеров убрали
прямой доступ к metadata-базе — таски и кастомные
операторы теперь ходят через REST API; SubDAG убрали
в пользу TaskGroups и Assets; путь API сменился с
/api/v1 на /api/v2; из контекста убрали старые
ключи вроде execution_date (теперь logical_date);
BashOperator и PythonOperator переехали в
отдельный standard provider, который нужно ставить
явно.
Каждый пункт — поведенческое изменение того самого класса. Линтер с правилами AIR301/302 поймает синтаксис, но не скажет, что архитектура исполнения задач теперь другая. Поэтому такой переезд делают не in-place, а поднимают рядом новое окружение и переводят DAG'и по схеме blue/green, держа старое как откат. Те же два окружения, что и в pandas-кейсе, только масштаб другой — обновляется архитектура, а не lock-файл.
На стороне v3 есть и выигрыш: нативное версионирование DAG'ов, data-aware-планирование через Assets, переписанный UI — это упрощает тестирование и сопровождение. Отсюда практический парадокс: техдолг — не самая интересная работа, но вынужденный переезд обычно и приводит проект в более здоровое состояние, до которого он сам, без внешнего тикета, не дошёл бы.
Что остаётся после
Миграцию нельзя смержить и забыть. Перед мержем — зелёный полный прогон на целевом окружении и, по возможности, обкатка на проде в shadow или canary: golden master ловит регрессии только на тех данных, что есть. После мержа техдолг не исчезает, а перезапускается: выйдет новый minor, поедет deprecation-warning, подтянется обновлённый транзитивно numpy — и однажды снова придёт тикет.
Разорвать цикл помогает то, с чего обычно и начинаются такие проблемы, — CI/CD. Прогон на матрице версий, алерты на deprecation-warnings, регулярное обновление зависимостей малыми порциями вместо разового большого переезда. Так долг гасится равномерно и не накапливается, чтобы потом упасть на одного человека.
Миграция заканчивается не тогда, когда код собрался на новой версии, а когда следующая такая же карточка снова становится рутинным обновлением, а не исследовательским проектом.