Хабрахабр

Как машинное обучение в YouDo катится в продакшен. Лекция в Яндексе

В крупных сервисах решить какую-нибудь задачу с помощью машинного обучения — означает выполнить только часть работы. Встраивать ML-модели не так уж просто, а налаживать вокруг них CI/CD-процессы еще сложнее. На конференции Яндекса «Data & Science: программа по заявкам» руководитель направления data science в компании YouDo Адам Елдаров рассказал о том, как управлять жизненным циклом моделей, настраивать процессы дообучения и переобучения, разрабатывать масштабируемые микросервисы, и о многом другом.

— Начнем с вводных. Есть data scientist, он в Jupyter Notebook пишет какой-то код, делает фиче-инжениринг, кросс-валидацию, тренирует модельки. Скор растет.

Ноутбук, который мы пилили, в продакшен в большинстве случаев не отправить. Но в какой-то момент он понимает: чтобы принести компании business value, он должен заделиверить решение куда-то в продакшен, в некий мифический продакшен, который вызывает у нас кучу проблем. В большинстве случаев надо написать сервис, у которого есть API. И возникает вопрос: как этот код внутри ноутбука отгрузить в некий сервис. Или они общаются через PubSub, через очереди.

Этот процесс надо мониторить. Когда мы делаем рекомендации, нам надо часто обучать модели и переобучать их. Еще это нужно проверять на реальных пользователях через АB-тесты — что мы сделали лучше или как минимум не хуже. При этом надо всегда проверять тестами и сам код, и модели, чтобы в один момент наша модель не сошла с ума и не начала всегда предиктить нолики.

У нас GitLab. Как мы подходим к коду? При этом это отдельный GitLab-проект, Git-контроль версий и модель ветвления GitFlow. Весь наш код распиливается на много маленьких библиотек, которые решают конкретную доменную задачу. И сами тесты, юнит-тесты. Мы используем такие штуки, как pre-commit hooks — чтобы нельзя было закоммитить код, который не удовлетворяет нашим проверкам на стат-тесты. Используем для них подход property based testing.

Это неудобно. Обычно, когда вы пишите тесты, то подразумеваете, что у вас есть тестируемая функция и аргументы, которые вы руками создаете, некие примеры, и то, какие значения возвращает ваша тестируемая функция. В результате у нас куча кода, непокрытого тестами. Код раздувается, многим в принципе лень это писать. Давайте будем делать фазинг, и много раз семплировать из этих распределений все наши аргументы, вызывать с этими аргументами тестируемую функцию и проверять на некие свойства результат выполнения этой функции. Property based testing подразумевает, что все ваши аргументы имеют некое распределение. В результате у нас кода гораздо меньше, и при этом тестов в разы больше.

Это модель ветвления, которая подразумевает, что у вас есть две основные ветки — develop и master, где находится production ready-код, а вся разработка при этом ведется в ветке develop, куда все новые фичи попадают из фиче-бранчей. Что такое GitFlow? Мы потом делаем релиз, из dev перекидываем изменения в master и вешаем на это тег версии нашей библиотеки или сервиса. То есть каждая фича — новый фиче-бранч, при этом фиче-бранч должен быть короткоживущим, а по хорошему — еще и прикрытым через feature toggle.

Срабатывают триггеры, прогоняем тесты, если все окей — можем замержить. Делаем разработку, пилим какую-то фичу, пушим ее на GitLab, создаем merge request из фиче-бранча в дев. Он ревьюит код, и тем самым повышается bus factor. Но мержим не мы, а кто-то из команды. В результате — если кого-то собьет автобус, кто-то уже знает, что он делает. Данный участок кода знают уже два человека.

И если мы релизим, это еще и паблишинг в приватный PyPI-сервер нашего пакета. При этом Continuous integration для библиотек обычно выглядит как тесты на любые изменения.

Для этого мы используем библиотеку Luigi. Далее мы это можем собрать в пайплайны. Есть параметр таски, который параметризует бизнес-логику, которую она выполняет, идентифицирует таску и ее аутпут. Она работает с такой сущностью, как таска, у которой есть аутпут, куда сохраняется артефакт, созданный во время выполнения таски. Когда мы запускаем какую-то таску, все ее зависимости проверяются через проверку ее аутпутов. При этом у таски всегда есть требования, которые выставляют другие таски. Если артефакт отсутствует на каком-то storage, он запускается. Если аутпут существует, наша зависимость не запускается. Тем самым формируется пайплайн, направленный циклический граф.

При этом они идентифицируют артефакт. Все параметры идентифицируют бизнес-логику. Если мы обучаем какую-то модельку, Luigi таска всегда имеет гиперпараметры этой таски, они просачиваются в артефакт, который мы продюссируем, гиперпараметры отражены в названии артефакты. Всегда это дата с какой-то гранулярностью, сенситивностью, либо неделя, день, час, три часа. И весь код пайплайна лежит в проекте сервиса в репозитории, к которому он относится. Тем самым мы по сути версионируем все промежуточные дата-сеты и конечные артефакты, и они не перезаписываются никогда, всегда upend only на storage, и storage выступает HDFS и S3 приватный, который конечные артефакты видит пиклов каких-то, моделей или еще чего-то.

На помощь к на приходит HashiCorp стек, мы используем Terraform для декларирования инфраструктуры в виде кода, Vault для менеджмента секретов, там все пароли, явки к БД. Это надо как-то задеплоить. И также Consul делает health checks ваших нод и ваших сервисов, проверяя их на доступность. Consul — сервис discovery, распределенный key value storage, который можно использовать для конфигурирования.

это система оркестрации, шедулирования ваших сервисов и каких-то batch jobs. И — Nomad.

Есть Luigi пайплайн, мы его запакуем в Docker контейнер, бросаем в Nomad батч или periodic batch job. Как мы это используем? Но если что-то произошло не так, Nomad ретраит это, пока не исчерпает попытки, либо это не закончится успешно. Batch job — это что-то выполнилось, закончилось, и если все удачно — все окей, мы можем вручную затриггерить его снова.

Periodic batch job — это ровно то же самое, только работает по расписанию.

Когда мы деплоим какой-то контейнер в систему оркестрации любую, надо указать, сколько надо памяти этому контейнеру, CPU или памяти. Тут появляется проблема. Если мы превышаем лимит, который мы ему выдали, Docker daemon приходит и убивает Dockers и (нрзб.) [02:26:13] Мы не хотим out of memory ловить постоянно, поэтому нам надо указать все 70 Гб, пиковую нагрузку на память. Если у нас пайплайн, который работает три часа, два часа из этого потребляет 10 Гб оперативной памяти, 1 час — 70 Гб. Но тут проблема, все 70 Гб на три часа будут аллоцированы и недоступны ни одной другой джобе.

Весь наш Luigi пайплайн не запускает в себе какую-то бизнес-логику, он запускает просто набор кубиков в Nomad, так называемые параметризованные джобы. Поэтому мы пошли другим путем. Когда мы делаем библиотеку, мы деплоим через CI весь наш код в виде параметризованных джоб, то есть контейнер с какими-то параметрами. По сути, это аналог Server (нрзб.) [02:26:39] functions, AVS Lambda, кто знает. Все это регистрируется в Nomad, и далее из Luigi пайплайна можем через API дергать все эти Nomad джобы, и при этом Luigi следит за тем, чтобы не запускать одну и ту же таску много раз. Допустим, Lite JBM Classifier, у него есть параметр пути к входным данным для тренировки, гиперпараметры моделей и пути к выходным артефактам.

Есть условные 10 моделей, и мы не хотим каждый раз заново запускать процессинг текста. Допустим, у нас процессинг текста всегда одинаковый. И при этом все это работает распределенно, мы можем гигантский grid search запустить на большом кластере, успевай только железо докидывать. Он запустится всего один раз, и при этом будет готовый результат каждый раз переиспользоваться.

Сервисы выставляют либо HTTP API, либо общаются через очереди. У нас есть артефакт, надо как-то в виде сервиса это оформить. При этом общение с сервисом, либо наш сервис общается с другими сервисами через HTTP JSON API валидирует JSON схему. В данном примере это HTTP API, самый простой пример. Но не всегда все филды JSON объекта нужны, поэтому происходит валидация consumer driven contracts, валидация этой схемы, общение происходит через pattern circuit breaker, чтобы не позволять нашей распределенной системе выходить из строя из-за каскадных сбоев. У самого сервиса описан всегда JSON объект в документации к его API и схема этого объекта.

При этом Nomad умеет делать так, что есть сервис три хелсчека подряд зафейлил, он может рестартануть сервис, чтобы помочь ему. При этом сервис должен выставлять HTTP health check, чтобы Consul мог прийти и проверить доступность этого сервиса. JSON logging driver используем и Elastics стек, на каждой точке FileBit забирает просто все JSON логи, кидает их в лог стеш, оттуда они в Elastic попадают, в KBan мы можем анализировать. Сервис пишет все свои логи в JSON формате. При этом мы не используем логи для коллекции метрик и построения дашбордов, это неэффективно, мы для этого используем систему энторинга Prometheus, у нас есть процесс для создания из темплейтов для каждого сервиса дашбордов, и мы можем анализировать технические метрики, которые продюссируются сервисом.

Нам на помощь приходит Sentry, это штука для анализа инцидентов. При этом если что-то пошло не так, у нас прилетают алерты, но в большинстве случаев этого недостаточно. И далее есть подробный traceback, есть вся информация о том, в каком окружении был сервис, какой версии, какие функции вызывались какими аргументами и какие переменные в данной области видимости были, с какими значениями. По сути, мы все логи уровня error перехватываем хендлером Sentry и пушим их в Sentry. Все конфиги, все это видно, и это очень помогает быстро понять, что произошло, и устранить ошибку.

Отдельный GitLab проект, код пайплайна, код тестов, сам код сервиса, куча конфигов разных, Nomad, CI-конфиги, документации к API, коммит хуки и прочее. В результате сервис выглядит как-то так.

Если все окей, мы деплоим этот сервис на продакшен. CI, когда мы делаем релиз, делаем следующим образом: билдим контейнер, прогоняем тесты, кидаем на стейджинг кластер, прогоняем там контракт тестирования нашего сервиса, проводим нагрузочное тестирование, чтобы удостоверить, что у нас предикт не слишком медленный и держим нагрузку, которая нам мнется. И тут есть два пути: мы можем задеплоить пайплайн, если periodic batch job, она где-то в фоне работает и продюссирует артефакты, либо ручками триггерим какой-то пайплайн, он обучает какую-то модельку, после этого мы понимаем, что все окей и деплоим сервис.

Я говорил, что в разработке фиче-бранчей есть такая парадигма, как feature toggles. Что еще происходит в этом случае? Мы тогда можем собирать все фичи в релиз-трейны, и даже если фичи недоделанные, то мы их можем деплоить. Фичи по-хорошему надо прикрыть какими-то тогглами, чтобы просто на бою вырубить фичу, если что-то пошло не так. Так как мы все дата-саентисты, мы хотим еще и АВ-тесты сделать. Просто фиче-тоггл будет выключен. Мы хотим это проверить, но при этом АВ-тест менеджится с привязкой к какому-нибудь userID. Допустим, мы LI GBM заменили на CatBoost. Нам здесь надо проверить эти метрики. Feature toggle привязывается к userID, и тем самым проходит АВ-тест.

У нас два кластера продакшена Nomad — один для batch job, другой для сервисов. Все сервисы деплоятся в Nomad.

Оттуда мы их можем забрать. Все свои бизнес-ивенты они пушат в Kafka. Мы можем подписываться на HDFS какими-то сервисами, делать какую-то реалтайм-аналитику, и при этом мы все сгребаем в ClickHouse и строим дашборды, чтобы анализировать все бизнес-ивенты для наших сервисов. По сути, это лямба-архитектура. Мы можем анализировать АВ-тесты, что угодно.

Мы только ручками затриггерили какой-то пайплайн, он обучил нам новую модельку. А если мы не меняли код, не надо использовать feature toggles. Мы просто меняем в конфиге Nomad-путь к модельке, делаем релиз нового сервиса, и тут нам на помощь приходит парадигма Canary Deployment, она доступна в Nomad из коробки. У нас есть к ней новый путь.

Мы говорим, что хотим три канарейки — деплоятся еще три реплики новых версий, не вырубая старые. У нас есть текущая версия сервиса в трех инстансах. Часть трафика попадает на новые версии сервисов. В результате трафик начинает сплититься на две части. В результате мы можем в реальном времени анализировать метрики. Все сервисы пушат все свои бизнес-ивенты в Kafka.

Деплой, Nomad пройдет, аккуратно выключит все старые версии и заскейлит новые. Если все окей, то мы можем сказать, что все окей.

Такая схема не работает, потому что трафик балансируется через round-robin. Эта модель плоха тем, что если нам надо привязывать роутинг версий по какой-то сущности, User Item. Поэтому мы пошли следующим путем и распилили сервис на две части.

Клиент общается по HTTP со слоем Gateway, вся логика выбора версий и балансировки трафика находится в Gateway. Это слой Gateway и слой workers. Допустим, нам в предикте в запросе приходит userID, который нам надо обогатить какой-то информацией. При этом все I/O Bound-задачи, которые нужны для выполнения предикта, тоже находятся в Gateway. В результате все это происходит в Gateway. Мы должны дернуть другие микросервисы и забрать всю инфу, фичи или базы. Входные данные и выходные данные. Он общается с workers, которые находятся только в модельке, и делает одну вещь — предикт.

Как это нивелировать? Но так как мы распили наш сервис на две части, появился overhead из-за удаленного сетевого вызова. Можно использовать мультиплексию и сжатие. На помощь приходит JRPС-фреймворк от Google, RPC от Google, который работает поверх HTTP2. Это строго типизированный бинарный протокол, который имеет быструю сериализацию и десериализацию. JPRC использует протобаф.

Допустим, мы не можем держать какое-то количество открытых HTTP-коннектов. В результате мы также имеем возможность независимо скейлить Gateway и worker. У нас слишком медленный предикт, не успеваем держать нагрузку — окей, скейлим workers. Окей, скейлим Gateway. В Gateway, так как реализована вся логика балансировки трафика, он может ходить во внешние микросервисы и забирать у них всю статистику по каждой версии, а также принимать решение о том, как балансировать трафик. Этот подход очень хорошо ложится на многоруких бандитов. Допустим — с помощью Thompson Sampling.

А что если есть моделька рекомендаций, которая во время обучения уже успевает устареть, и нам надо их постоянно переобучать? Все окей, модели как-то обучались, мы их в конфиге Nomad прописывали. При этом в конце своей работы пайплайн кладет путь новой модели в Consul. Все делается так же: через periodic batch jobs продюссируется какой-то артефакт — допустим, каждые три часа. Nomad умеет темплейтировать конфиги. Это key value storage, которое используется для конфигурирования. Он следит за изменениями и, как только появляется новый путь — решает, что можно пойти двумя путями. Пусть будет переменная окружения на основе значений key value storage Consul. Либо он рендерит новый конфиг и сообщает о нем сервису. Он скачивает по новой ссылке сам артефакт, кладет контейнер сервиса в Docker c помощью volume и перезагружает — причем делает все это так, чтобы не было даунтайма, то есть потихоньку, поштучно. На этом все, спасибо. Либо сам сервис его детектит — и внутри себя может самостоятельно, вживую проапдейтить свою модельку.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть