Зелёный пайплайн ничего не доказывает. Семь способов, которыми quality gate пропускает брак в прод

if roc_auc < 0.85: exit 1 — примерно так выглядит quality gate почти в каждом ML-пайплайне, который мне попадался. В обычном CI то же самое — количество проблем в Sonar и код выхода тестов.
Я видел, как приложение проходило весь пайплайн, выкатывалось в прод, и через 2 часа бизнес срочно требовал отката из-за неработоспособности важного функционала.
Зелёная галочка не значит «модель хорошая» — это знают, в общем-то, все. Только пайплайны почему-то до сих пор собирают так, будто никто об этом не слышал. Гейт в таком виде похож на суд, который выносит оправдательный приговор по показаниям одного свидетеля. И никто не спрашивает, откуда свидетель взял свою цифру.
Дальше семь способов, которыми гейт пропускает брак в прод. Там будет и утечка между train и test, и старый добрый || true в .gitlab-ci.yml. По каждому посмотрим реальный кейс с цифрами.
Что такое гейт без маркетинга
Этап, на котором пара метрик сравнивается с порогами, и на выходе «да» или «нет». Всё.
В CI-мире стандартный пример — это Sonar way в SonarQube. Он проверяет новый код по четырём условиям: ноль новых проблем, все новые security hotspots просмотрены, покрытие ≥ 80%, дублирование ≤ 3%. Плюс пайплайн обычно требует, чтобы все тесты были зелёные.
В MLOps часто можно увидеть так:
evaluate: stage: evaluate script: - python evaluate.py --model model.pkl --data test.parquet - python -c "import json,sys; m=json.load(open('metrics.json')); sys.exit(0 if m['roc_auc']>=0.85 else 1)"
Одна метрика, один датасет, один порог. Дальше модель регистрируется в MLflow и едет в прод. Если вдуматься, то звучит не очень убедительно для выкатки в продовое окружение. Ведь проверить-то мы хотели совсем другое:
делает ли код то, что должен;
выучила ли модель сигнал, а не какой-нибудь артефакт данных;
будет ли она работать в проде.
В одно число это не влезает, поэтому мы считаем прокси и надеемся, что прокси не врёт. Врёт, и по крайней мере семью способами. Начну с самого тупого.
1. Гейт, который ничего не блокирует
CI держится на одном-единственном числе — коде выхода. 0 значит зелёный, остальное — красный. Способов сделать так, чтобы красный ни на что не влиял, полно:
Как | Что происходит |
|---|---|
| Джоба красная, но MR всё равно мержится |
| Ошибка проглатывается, код выхода 0 |
| Пайплайн не запускается; если включено «Пропущенные пайплайны считаются успешными», MR мержится |
Кривые | Джоба проверки просто не создаётся |
| Точечные исключения, которые никто не ревьюит |
sonar-scanner без | Сканер отправил отчёт и вышел с 0. На сервере гейт красный, джоба зелёная |
Мейнтейнер мержит мимо гейта | Защита ветки это разрешает |
2. Модель видела ответы
Утечка данных в контексте ML — это когда информация о правильном ответе просачивается в обучение. Модель, по сути, подсматривает в ответы, а гейт видит отличную метрику и радуется.
Массовость этого явления была посчитана Капуром и Нараянаном из Принстона (Patterns, 2023). Они прошлись по обзорам в 17 областях науки (среди них медицина и компьютерная безопасность) и насчитали минимум 294 статьи с утечками. Потом реестр продолжили вести, и к 2024 году там было уже 648 статей из 30 областей. Очевидно, проблема системная, пара неаккуратных аспирантов столько не наберёт.
Больше всего мне у них нравится пример с прогнозированием гражданских войн. Несколько статей заявляли, что сложные ML-модели сильно обходят логистическую регрессию. Когда стали разбираться, утечка нашлась в каждой статье с таким заявлением. После исправления преимущество пропало, и логрегрессия справилась ничуть не хуже.
Их классификация утечек, если переложить на то, что встречается в коде:
Тип | Как это выглядит в коде |
|---|---|
Нет тестового набора | Метрику считают на обучающих данных |
Препроцессинг до разбиения |
|
Отбор признаков до разбиения | Признаки выбирали по всем данным, тест тоже |
Дубликаты в train и test | Один и тот же объект в обеих частях |
Недопустимый признак | Признака ещё нет в момент прогноза |
Временная утечка | Учимся на будущем, предсказываем прошлое |
Зависимые выборки | Несколько записей одного пациента или устройства в обеих частях |
Смещённый тест | Тест взят не из того распределения, про которое выводы |
Гейт тестовому набору верит на слово. Если разбиение кривое, гейт проглотит что угодно.
Что с этим делать? Проверку разбиения надо превратить в автотест: пересечение ключей между train и test, доля дубликатов, доступен ли каждый признак на момент прогноза. Стратегия разбиения должна жить в коде, а не в голове у дата-сайентиста. Капур и Нараянан сами предлагают model info sheets, обязательное описание, как сделано разбиение и откуда взялся каждый признак.
3. Тест из другой реальности
Бывает, что разбиение честное, а тестовый набор всё равно ничего не доказывает.
В 2021-м в Nature Machine Intelligence вышел систематический обзор моделей, которые ставили COVID-19 по рентгену и КТ грудной клетки (Roberts et al.). Авторы нашли 2212 статей и подробно разобрали 62. Вывод: ни одна из 62 моделей не годилась для клиники. Ни одна.
Возникает вопрос, почему и что случилось.
«датасеты-Франкенштейны»: данные, склеенные из нескольких открытых источников, без документации и с риском, что одни и те же снимки попали и в train, и в test;
в 29 из 37 статей по глубокому обучению не было внешней валидации. И в 30 из 37 никто не проверял устойчивость.
Примерно в это же время ДеГрейв, Янизек и Ли выпустили статью с очень говорящим названием «AI for radiographic COVID-19 detection selects shortcuts over signal». Модели смотрели не на признаки болезни, а на всё постороннее: текстовые метки на снимке, положение пациента, особенности конкретного аппарата. По сути, модель научилась отлично угадывать, из какой больницы снимок.
Это shortcut learning: модель находит самый дешёвый способ угадать метку. В тесте подсказка есть, и метрики шикарные. В проде её нет, и качество падает. И гейт тут бессилен в принципе, потому что смотрит ровно на ту цифру, которую подсказка и нарисовала.
В индустрии всё то же самое, только скучнее. Кредитный скоринг выучил, что заявки из одного канала почти всегда одобряют. У антифрода самый сильный признак — ночное время в created_at.
4. Не та метрика
Допустим, разбиение честное и тест чистый. Можно всё равно выбрать не ту метрику или не тот порог, и гейт снова пропустит мусор.
Мой любимый пример тут — Epic Sepsis Model. Это проприетарная модель раннего предупреждения о сепсисе, её развернули в сотнях больниц США. Разработчик заявлял AUC 0,76—0,83. Потом Wong et al. провели независимую внешнюю валидацию на данных Michigan Medicine (JAMA Internal Medicine, 2021):
Показатель | Значение |
|---|---|
Госпитализаций в выборке | 38 455 |
Случаев сепсиса | 2 552 (7 %) |
Реальный AUC | 0,63 (заявляли 0,76—0,83) |
Пропущено пациентов с сепсисом | 67 % |
Доля госпитализаций с алертом | 18 % |
Две трети случаев модель пропустила, а при этом заваливала персонал алертами. Готовый рецепт alert fatigue.
Что тут пошло не так, если смотреть глазами гейта:
Валидация была внутренняя, а не внешняя: метрика на данных разработчика на другую популяцию не переносится.
AUC вместо метрик на рабочем пороге. AUC усредняет по всем порогам, а решение принимается на одном, и precision с recall надо смотреть именно там.
Операционную нагрузку тоже никто не считал, а ведь от числа алертов на один настоящий случай зависит, будет ли кто-то вообще на них реагировать.
Ну и калибровка. Модель может неплохо ранжировать и при этом выдавать бессмысленные вероятности.
5. Одинаковый балл, разные модели
Эту работу в спорах про гейты почему-то почти не вспоминают. Д'Амур и др. из Google (JMLR 2022) описали underspecification, то есть недоопределённость. Пайплайн обучения недоопределён, если он может выдать кучу моделей с одинаковым качеством на тесте, но с очень разным поведением в реальном мире.
Поменяли random seed и получили модель с теми же метриками, но с другой устойчивостью к сдвигу распределения. И это не какой-то один домен: авторы показали эффект в компьютерном зрении, медицинской визуализации, NLP, прогнозировании клинических рисков и медицинской геномике.
Для гейта вывод неприятный. Порог по одной метрике не отличит модель, которая выучила сигнал, от модели, которая выучила совпадение. Обе проходят одинаково, а в проде сломается одна.
Порогом это не лечится. Лечится другими проверками:
стресс-тестами (сдвиг распределения, шум, аугментации);
поведенческими тестами в духе CheckList: инвариантность, направленные ожидания, минимальная функциональность;
метриками по срезам;
обязательным сравнением с простым бейзлайном.
6. Вердикт протухает
Гейт оценивает прошлое, а мир после проверки никуда не останавливается.
Google Flu Trends оценивал активность гриппа по поисковым запросам. С августа 2011 года он завышал оценку в 100 неделях из 108. В сезоне 2012—2013 его оценка доли обращений к врачу с гриппоподобными симптомами оказалась больше чем вдвое выше данных CDC. А пандемию H1N1 в 2009-м он вообще пропустил.
Lazer et al. в Science (2014) называют две причины. Первая: big data hubris, вера в то, что больших данных достаточно. Признаки отбирали из 50 миллионов запросов под 1152 точки данных, а это прямая дорога к переобучению. Среди отобранных, например, оказались запросы про школьный баскетбол. Вторая: динамика самого алгоритма, поисковик постоянно менялся, вместе с ним менялось поведение пользователей, а значит, и входные признаки.
Второй случай: Zillow Offers. Zillow покупала дома по алгоритмической оценке и в ноябре 2021 года это направление закрыла: списание запасов примерно на 304 млн долларов за квартал и сокращение около 25% сотрудников. Средняя ошибка оценки не говорила ничего о хвостах распределения ошибок. Ничего об асимметрии (переплатить хуже, чем недоплатить). И ничего об ошибке отбора: компания закрывала сделки как раз там, где модель завышала цену.
Так что даже идеальная офлайн-оценка имеет срок годности. Нужен мониторинг дрейфа и регулярная переоценка на свежих данных, а для моделей, от которых зависят деньги, — бэктест с учётом отбора и хвостов, а не средней ошибки.
7. Проверяли одно, задеплоили другое
Тут ML и обычный CI сходятся.
Training-serving skew: при обучении признаки считает один код (ноутбук, pandas), в проде — другой (Java-сервис, другой источник, другая задержка). Метрика была честной, просто про другой объект. Лечится общим кодом преобразований и feature store.
Бывает ещё грубее: в прод уезжает вообще не тот артефакт, который тестировали. Гейт проверял коммит или ноутбук, а в прод попал артефакт, и ссылка на него обычно изменяемая. Docker-тег вроде :latest или :1.4 можно перезаписать, неизменяем только дайджест sha256:.... Зависимости без lock-файла на каждой сборке приезжают «свежими». Веса модели лежат в одном месте и подменяются прямо там.
На этом горели очень опытные команды.
xz-utils (CVE-2024-3094). Полезная нагрузка лежала в репозитории у всех на виду, внутри бинарных тестовых файлов .xz. А изменённый build-to-host.m4, который её вытаскивал и встраивал в сборку, был только в релизных tar-архивах, в Git его не было. Ревьюеры читали Git, собиралось всё из tar-архива. Нашли бэкдор случайно: вход по SSH стал примерно на 500 мс медленнее, и sshd подозрительно ел CPU.
SolarWinds / SUNSPOT. Имплант в среде сборки подменял исходник прямо во время компиляции. Сначала сверял MD5 оригинала, чтобы сборка не сломалась, потом возвращал оригинал на место. Код в репозитории всё это время был чистый.
tj-actions/changed-files (март 2025). Атакующий получил токен и перевесил версионные теги популярного GitHub Action на один вредоносный коммит, который выводил секреты раннера в логи сборки. Action использовали больше 23 000 репозиториев. Те, кто закреплял его по SHA коммита, не пострадали.
Бонус: гейт на входе был, гейта на деплое не было
Тут обычно возражают: «CrowdStrike и Cloudflare — это про деплой, а не про quality gate». Именно поэтому они здесь. У обоих был гейт на входе, но не было гейта на самом развёртывании. А это тоже гейт.
CrowdStrike, 19 июля 2024. У них был Content Validator, вполне настоящий гейт для обновлений контента. Тип шаблона определял 21 поле ввода, а код сенсора передавал только 20. Валидатор исходил из 21 и ни разу не проверил, сколько на самом деле передаёт сенсор. В мартовских стресс-тестах и в первых боевых экземплярах в 21-м поле стоял wildcard, чтения за границей массива не было, и ошибка спала с февраля. 19 июля пришёл экземпляр с конкретным значением в 21-м поле. Итог: чтение за границей массива и синий экран примерно на 8,5 млн Windows-машин (оценка Microsoft). В исправления вошли проверка числа полей и поэтапная раскатка по кольцам с канарейками.
Cloudflare, 18 ноября 2025. Поменяли права доступа в ClickHouse, запрос начал возвращать дубликаты строк. Файл признаков Bot Management вылез за жёстко зашитый лимит в 200 признаков (обычно их около 60). Прокси на Rust упал в панику на unwrap(). Основной сбой шёл около трех часов, полностью восстановились почти за шесть. Cloudflare сами сформулировали вывод: конфиги, которые генерируются внутри, надо проверять так же строго, как пользовательский ввод. По-моему, это лучший совет в одну строку для всех, кто гоняет через пайплайн YAML, фича-флаги или справочники.
И для полноты Knight Capital, 2012. Новый код не доехал до одного сервера из восьми, а флаг, которому поменяли назначение, «разбудил» на этой машине мёртвый код 2003 года. Второй человек деплой не проверял. За 45 минут компания потеряла больше 460 млн долларов. Гейт на код тут вообще ни при чём, не было гейта на сам процесс деплоя.
Гудхарт и немного цифр про CI
Когда метрика становится целью, она перестаёт быть хорошей метрикой.
Это про любой порог. Поставьте покрытие 80%, и получите тесты без единого assert. Поставьте «0 новых проблем», и получите NOSONAR через строчку и целые папки, исключённые из анализа. Поставьте порог по AUC, и люди будут перекраивать разбиение, пока цифра не сойдётся.
На что, по-моему, стоит обращать внимание:
Покрытие слабо связано с тем, насколько тесты ловят баги. Иноземцева и Холмс (ICSE 2014) взяли 5 Java-проектов размером до 724K строк и сгенерировали 31 000 тестовых наборов, эффективность мерили мутационным тестированием. Если учесть размер набора, корреляция покрытия и эффективности падает до низкой или умеренной, и «более строгие» критерии покрытия не помогают. У Фаулера есть короче: покрытие помогает найти непротестированный код, но ничего не говорит о качестве тестов.
Тесты надо тестировать мутациями. В Google встроили мутационное тестирование в код-ревью: мутируются только изменённые строки, максимум одна мутация на строку. На 72 425 диффах сервис выдал около 150 000 находок, и из тех мутантов, по которым разработчики оставили отзыв, около 80% назвали полезными (Петрович и др., IEEE TSE 2021). Инструменты: PIT, Stryker, mutmut, cargo-mutants.
SAST пропускает много. В работе с ISSTA 2022 шесть статических анализаторов C/C++ прогнали на 27 реальных программах с 192 известными уязвимостями. По отдельности инструменты пропустили от 47% до 80%. Вместе пропустили 30—69%, но число помеченных функций выросло на 15 процентных пунктов.
Шум убивает доверие. В Google нашли порог: если «эффективных ложных срабатываний» больше 10%, инструментом перестают пользоваться. Там же пример с FindBugs: на специальной неделе исправлений инженеры разобрали 3954 предупреждения и исправили только 16%.
Флаки. По данным Google, флакает около 1,5% запусков тестов, почти 16% тестов хоть иногда ведут себя нестабильно, а 84% переходов из «прошёл» в «упал» приходятся на флаки. Когда большинство красных ничего не значат, люди привыкают жать Retry. А потом дописывают
allow_failure.
И контекст 2025 года ставки только поднимает. По данным DORA, ИИ в работе используют 90% инженеров, и рост его использования связан одновременно с ростом пропускной способности и с большей нестабильностью релизов. Через гейт идёт больше кода, на каждую строку внимания меньше.
Как судить честно
Помните модель швейцарского сыра Джеймса Ризона? Каждый слой защиты — это ломтик с дырками, и авария случается, когда дырки совпали. Надёжность даёт число независимых слоев, а не строгость какого-то одного. Три сканера на одной базе CVE считаются за один слой.
до мержа до деплоя после деплоя
[разбиение, тесты, ] → [бейзлайны, срезы, ] → [shadow, канарейка,]
[SAST, мутации ] [калибровка, дайджест ] [дрейф, откат ] дыры: утечка, дыры: skew, дыры: медленный флаки, подавления изменяемые теги дрейф, нет владельца
Выводы
Никакого приговора гейт не выносит. Он даёт одно доказательство, и оно ровно настолько полезно, насколько мы понимаем, что именно оно меряет и как его подделать.
Всё он не поймает: тестирование показывает наличие ошибок, а не их отсутствие. Проблема не в этом. Проблема в том, что зелёная галочка притворяется гарантией, и думать становится необязательно. Epic Sepsis Model раскатили в сотни больниц, потому что AUC хороший. 62 модели для диагностики COVID опубликовали, потому что метрики отличные. Content Validator в CrowdStrike отработал ровно так, как его задумали.
Так что вопрос к любому вашему гейту, по-моему, один: когда он в последний раз реально что-то остановил, и что было потом? Если ответа нет, у вас не судья, а штамп.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.