
Всем привет! Меня зовут Роза и я MLOps-инженер в Купере. Пока одни учат модели, а другие пытаются их запустить, наша команда строит «мост» между этими мирами — и сегодня под катом расскажу, как мы создавали нашу ML-платформу: от тренировочных стендов до продакшн-инференса, который не падает в пятницу вечером.
Отдельное внимание мы уделим тому, как выстраивать взаимодействие между разными стейкхолдерами платформы — от собственно ML-инженеров до DataOps и Security-инженеров.
Идеальный путь построения ML-сервиса

Представим идеальный путь, который ML-инженер проходит при выводе модели с нуля в полноценный продакшен ML-сервис с помощью стандартных MLOps-практик.
Сначала у ML-инженера есть какой-то код, который нужно превратить в ML-сервис.
В рамках этой статьи не рассматриваем стадию экспериментов и сразу представим, что эксперименты завершены, и модель нужно оформить в регулярный пайплайн с помощью оркестратора.
На этом этапе мы обращаемся к хранилищам данных — Data Platform — и забираем, как правило, большие исторические датасеты из Offline Feature Store. Затем обучаем модель и сохраняем её в хранилище моделей — Model Registry.
Далее ML-инженер создаёт инференс-сервис, который подхватывает сохраненную модель и оборачивает ее в веб-сервис с ручками. На этом же этапе важен доступ к «горячим» данным — обычно это Online Feature Store, где хранятся, например, эмбеддинги пользователей. Их нужно получать очень быстро, поскольку для веб-сервиса критична задержка (latency).
Этот «идеальный» путь хорошо ложится на принцип построения платформ — «кто написал код, тот его и поддерживает»: за весь процесс от идеи до готового продакшен-сервиса отвечает одна ML-команда.
Почему идеальная картинка не работает в реальности
В реальности этот путь спотыкается о различные препятствия.

Первая же проблема — это работа с данными. Как правило, DWH — это большие отдельные платформы со своими особенностями. По этой причине на стадии подготовки данных помимо ML-инженера в процессе начинает участвовать DataOps-инженер, который, например, помогает скачать большой датасет оптимально, без лишнего расхода ресурсов. А в случае с NRT-данными важно правильно настроить Feature Store, чтобы взаимодействие и доступ к данным были безболезненными.

С оркестратором возникает, наверное, самый болезненный этап, который нередко называют "продуктивизацией" — когда из большой пачки скриптов и Jupyter-ноутбуков собирается продакшен пайплайн. Здесь обычно задействованы разные инженеры: DevOps, MLOps, иногда бэкенд-разработчики.
На этапе inference-сервиса всё еще сложнее — появляются целые бэкенд-команды, которые пишут веб-сервисы, разбираются с ML-моделью: как ее забирать, как с ней взаимодействовать, какие входные данные подавать и какие результаты она возвращает.
И, конечно, при деплое сервисов в продакшен появляются security-инженеры, которым важно проверить появляющийся ML-сервис на соответствие требованиям ИБ. Но при этом, как правило, весь ML выглядит как «серая зона», где смешаны данные, ПДн, процессы и доступы и нет никакой прозрачности.
В итоге приходим к проблеме, что во всех этих этапах участвует не только ML-команда, но и другие инженеры, и так происходит для каждого сервиса каждый раз, когда он релизится.
Почему мы вообще считаем это проблемой? Дело в том, что когда падает какой-то из этих компонентов, призываются не ML-команды, а те инженеры, которые как раз сделали ту или иную интеграцию. И все грустят, потому что невозможно настроить нормальный инцидент-менеджмент. Ну, а security грустит, просто потому что все небезопасно.
Причем необязательно этими инженерами должны быть отдельные люди, чаще даже бывает так, что все они консолидируются в том самом ML-инженере, который и строит сервис. По этой причине скорее считаем DataOps, MLOps, DevOps и SecOps ролями, которые могут принимать на себя те или иные инженеры.
Если ML-команд мало, то, скорее всего, эти проблемы бьют не так сильно. Однако, когда масштаб растет, растет количество команд, проектов, дагов и сервисов, то системным решением будет создание ML-платформы — набора унифицированных практик, которые облегчают жизнь командам на каждом из этапов. Давайте теперь посмотрим, что нужно от такой платформы всем нашим инженерам.
Стейкхолдеры ML-платформы
DevOps
Очень часто для DevOps-инженеров все задачи с ML-сервисам сводятся в общем-то к инфраструктуре и CI/CD. Как правило, задачи приходят в духе «поменять какие-то инфраструктурные компоненты у сервисов» или «оптимально использовать ресурсы».
Так зачем здесь платформа? Дело в том, что когда есть, например, 100 сервисов, то чтобы поменять аффинити у подов, нужно зайти в каждый этот сервис и потрогать там манифест. На практике это превращается в большую организационную боль — как для самих DevOps-инженеров, так и для владельцев сервисов.

Вот какие задачи должна помогать решать платформа:
Надо поменять толерейшены/нодселекторы
Надо деплоиться в другой K8S/облако/железо
Надо правильно менеджерить docker-образы
Надо оптимально использовать раннеры для сборки
MLOps

MLOps-инженеры, как правило, хотят стандартизации: без «самодельных велосипедов», когда одна команда пилит свой feature store, другая — свой, и в итоге их десять, и непонятно, что с ними делать.
Нужна шаблонизация всех типов сервисов
Надо перестать писать свои велосипеды
Нужно стандартизированное взаимодействие с другими микросервисами
DataOps
В случае с DataOps-инженерами все касается, естественно, данных. Нужно:
линтить запросы SQL на корректность
стандартизировать передачу различных видов данных
организовать правильное хранение данных ML-сервиса внутри Data Platform и опубликовать их для всех data-пользователей
Security
Все, что небезопасно, нужно сделать безопасным и прозрачным. На практике это означает, что нужно добавить проверки и сканеры безопасности внутри каждого сервиса на уровне CI/CD.
Также важно стандартизировать все ML-процессы со стороны безопасности. Очень часто бывает, что каждый сервис самостоятельно с нуля проводит те или иные интеграции с данными/сервисами/системами, и все это выглядит непрозрачно и не стандартизируется для упрощения процесса для следующих сервисов.
Начнем с DevOps-проблем — и посмотрим на «узкое место» с продуктивизацией.
Великий и могучий KubernetesPodOperator в Airflow
В качестве оркестратора мы выбрали Airflow, он позволяет запускать так называемые даги (DAG) по расписанию. Фактически даги — это пайплайны подготовки модели: подготовка данных, обучение модели и сохранение артефактов.
Типичный даг выглядит так:
with DAG(dag_id = 'train_model_dag',
schedule_interval='30 10 * * *',
...
) as dag:
def train_model(param=None, **kwargs):
do_somethig()
train_model_task = PythonOperator(
task_id='train_model_task',
python_callable=train_model
)
Пользователь описывает свою бизнес-логику и оборачивает ее в таску с PythonOperator
. Дальше Airflow запускает даг (последовательность тасок) по расписанию на своих воркерах. Одна таска == один под.

Когда мы описываем даги таким образом, мы смешиваем среду исполнения с исполняемым кодом, то есть код бизнес-логики смешан с кодом Airflow. Это приводит к тому, что аналогично смешиваются зависимости, а также инфраструктура смешивается с бизнес-частью проекта. На практике это выливается в то, что сервис должен обязательно использовать ограничения окружения Airflow (файл constraints.txt), и почти невозможно изолированно менять инфраструктуру.
Чтобы решить эту проблему, будем использовать оператор KubernetesPodOperator
, который выносит код бизнес-логики в отдельный под и запускает его изолированно от самой таски. Под с таской при этом просто следит за этим бизнес-подом — его статусами и ошибками.

Сам оператор мы при этом переопределим и сделаем свою «прослойку». Она нужна для большей гибкости и кастомизации запуска бизнес-пода.
# импортим оригинальный оператор
from airflow.providers.cncf.kubernetes.operators.pod import KubernetesPodOperator
# определяем класс
class KuperK8sPodOperator(KubernetesPodOperator):
def __init__(self, *args: t.Any, **kwargs: t.Any):
. . .
# у него основной метод execute
def execute(self, context: Context) -> t.Any:
. . .
# метрики
metrics.inc_dag_started_info(task_instance=task_instance, project=project)
# Sentry
sentry_dsn = self.get_sentry_dsn()
# переменные среды для всех дагов
self.update_env_with_vars(
"PROMETHEUS_PUSH_GATEWAY_DSN": settings.PROMETHEUS_DSN,
. . .
В собственном операторе основной метод — execute(...)
. Именно в него можно добавлять различные интеграции — например, сбор метрик для всех дагов, инициализацию Sentry, проброс переменных, одинаковых для всех дагов, и так далее.
Теперь давайте посмотрим, как выглядит использование оператора для пользователя, который пишет даг.
# описываем сам оператор
task = KuperK8sPodOperator(
# метаинфа про саму таску типа task_id и прочее
# имадж и CMD к нему
image="hub/my_project/ml_automatching_encoder:1.23"
cmds=["ml_automatching_encoder", "train"],
# лейблы
labels={"uuid": str(uuid.uuid4()),
"project": "ml-content"},
# ресурсы
resources=custom_mem_resources(request_memory=50, limit_memory=50, request_cpu=4, limit_cpu=4),
# толера и селекторы
tolerations=[
k8s.V1Toleration(key=”node-role.kuper.tech/airflow-gpu”, operator="Exists", effect="NoSchedule"),
],
node_selector={"node-role.kuper.tech/airflow": "ml-gpu"},
. . .
)
Фактически задача пользователя сводится к тому, чтобы передать в оператор образ с бизнес-логикой, который будет использоваться для отдельного пода, и входную точку в этот образ.
Также оператор принимает спеку пода (толера, ресурсы, лейблы и так далее) — все то, что мы обычно прописываем, когда запускаем деплоймент в Kubernetes. Но откуда ML-инженер должен знать, например, аффинити для GPU-нод? Или pod template? Это не его зона ответственности.
Чтобы упростить жизнь инженерам, упакуем все эти значения в переменные среды —тогда они будут проставляться на уровне самого Airflow, а забирать их будет уже наш оператор. Тогда пользователю надо указать только флажок «используй GPU» (или аналогичные):
from dags_sdk.operators.k8s import gpu_tolerations
task = KuperK8sPodOperator(
. . .
tolerations=gpu_tolerations
. . .
)
Давайте теперь посмотрим на то, как происходит работа с репозиториями:

За инфраструктурную часть деплоя кластера Airflow отвечают DevOps-команда. Она поддерживает чарты и зависимости Airflow, пишет CI/CD по разворачиванию кластера в Kubernetes.
Пользователи (все, кто пишет даги) владеют только своими сервисами, где собирается образ бизнес-пода с его зависимостями, который полностью независим и изолирован от Airflow.
Шаблон ML-сервиса
На этом этапе появляется логичный вопрос: если ML-инженеру надо собрать бизнес-код, то где и как это делать? Здесь появляется потенциальная «точка велосипедов» — каждая команда может организовывать и собирать код по-своему, и в итоге в компании образуется несколько решений одной и той же проблемы.
Чтобы этого не допустить, нужна унификация. Ее можно добиться следующими шагами:
шаблонизация git-репозиториев сервисов
унификация CI/CD
своевременные обновления
Шаблонизация git-репозиториев
Задать структуру репозитория можно разными способами:
GitLab template или аналоги — механизм, когда проектируется буквально шаблон, из которого создаются новые репозитории
Cookiecutter — тулза для генерации репозиториев на основе jinja
свое решение
В Купере уже давно существует своя большая платформа для разработки микросервисов, в которой проблема шаблонизации решается собственным командным инструментом ecom-cli. Он позволяет создать репозиторий сервиса на основе git-шаблона (аналогично cookiecutter):
ecom-cli \
service create \
https://.../paas/ml/demo \
--engine=airflow \
--version x.y.z
Сам git-шаблон зашивается в указание engine
, он представляет собой обычный репозиторий, в котором есть т.н. плейсхолдеры — имя проекта, описание и так далее — то, что должно поменяться для конкретного сервиса. Соответственно, при создании сервиса эти плейсхолдеры заменяются — например, my_project
меняется на реальное название проекта demo
.
Как выглядит шаблон-репозиторий? Нужно два окружения:
с дагами — здесь окружение Airflow и даги, которые запускают KubernetesPodOperator;
с бизнес-кодом — здесь бизнес-окружение с нужными зависимостями, независимо и ничего не знает про Airflow, то, что запускается внутри KubernetesPodOperator.
.
├── .dockerignore
├── .DS_Store
├── .git
├── .gitignore
├── .gitlab
├── .gitlab-ci.yml
├── .idea
├── CODEOWNERS
├── configs
├── dags
├── docs
├── lint.toml
├── Makefile
├── project
└── scripts
Оба окружения — полноценные Python-окружения с Poetry. Пользователю остается описать только сам бизнес-код и даги, а остальное делает платформа.
Обновления сервисов на основе шаблона
Когда у нас уже есть несколько сервисов, сам шаблон продолжает эволюционировать: появляются новые фичи и даже может меняться структура репозитория. Их нужно как-то доставлять в существующие репозитории.
Чтобы решить эту проблему, мы сделали дополнительный механизм миграций, который очень похож на миграции в базах данных.

Чтобы обновить сервис, мы смотрим версию шаблона, из которой он был сгенерирован или обновлен в последний раз (она прописывается в самом сервисе в отдельном файле). Затем берем diff между этой версией и последней версией шаблона. А дальше через git patch
накатываем этот diff поверх сервиса. Таким образом, все новые коммиты в шаблоне просто накатываются поверх текущего сервиса.
Это отлично работает, когда пользователи не меняют ничего в платформенных файлах и когда платформа не трогает ничего в пользовательских. Но, к сожалению, не все релизы шаблона удовлетворяют этим условиям. Чтобы не сломать пользователю репозиторий и решить конфликты максимально безболезненно для пользователей, мы дополнительно внедрили в этот инструмент те самые миграции:

Фактически миграция представляет собой python-скрипт, в котором описан apply и rollback. В apply прописываются действия миграции (например, поменять название переменной среды в пользовательском файле), а в rollback — откат этого действия (например, вернуть старое значение).
Когда происходит обновление, теперь мы не просто накатываем весь diff сразу, а делаем это «покоммитно» — накатываем коммит и его миграцию (если она есть). С одной стороны, это удобно, потому что можно гранулярно описать, что делать с каждым файлом, с другой — занимает время, так как помимо релиза шаблона нужно сделать еще и миграцию.
Также важно оставить места в платформенных файлах, которые позволяют пользователям кастомизировать репозиторий (помимо бизнес-кода и дагов):
1) в Makefile
можно добавлять собственные таргеты через дополнительный файл make-usr.mk
:
.PHONY: user-def-target
user-def-target:
echo "var from . env file: ${TEST_VAR}"
2) Аналогично в .gitlab-ci.yml
можно добавить собственные джобы через инклуд пользовательского файла .gitlab-ci-usr.yml
:
include:
- local: '.gitlab-ci-usr.yaml'
Это особенно бывает полезным, когда команда, например, хочет запускать собственные линтеры в репозитории.
CI/CD ML-сервиса
Это, наверное, одна из самых важных частей сервиса, так как с CI/CD разработчики сталкиваются ежедневно.
Так как у нас теперь есть шаблон и есть обновления, то в него легко внедрить CI/CD и предотвратить создание собственных велосипедов. Теперь CI/CD можно отдать в ответственность DevOps-команде — они могут разрабатывать его согласно собственным best practices и не бояться, что его кто-то сломает извне. Также благодаря механизму обновлений, можно легко раскатывать новые фичи на все сервисы на шаблоне.
Мы добавили в CI/CD пайплайны «плюшки», с помощью которых можно мотивировать пользователей вообще перейти на платформу и шаблон. Вот они:
сборка, линтеры, тестирование
кеширование зависимостей в Python+poetry
скип сборки, если код не менялся
автоверсионирование сервиса
автоверсионирование дагов
Более того — можно добавить в шаблон удобные инструменты для локальной работы. Например, таргеты в Makefile, которые поднимают локальный minikube, Airflow и импортят в него даги или запускают бизнес-код в docker-контейнере.
Организация репозиториев
В Airflow часто используют большие монорепозитории, в которых в кучу свалены все даги всех проектов команд. О том, как мы распилили наш монорепозиторий в Купере, можно посмотреть здесь.
В ML-платформе один сервис — это изолированная бизнес-единица. Как правило, команды сами решают, как бить свои проекты и даги на сервисы. В итоге получается много ML-сервисов с дагами и все еще один кластер Airflow.

Для деплоя дага в Airflow нужно лишь скопировать его скрипт в т. н. директорию dags_folder
в хранилище дагов внутри пода Airflow шедулера. В качестве хранилища мы используем S3 бакет. И если в случае монорепозитория копировалась вся директория с дагами из этого репозитория, то в случае наших микросервисов копироваться будет только директория с дагами конкретного сервиса.
Таким образом, изоляция дагов раньше была на уровне директорий проектов в монорепозитории, теперь она будет на уровне директорий в S3 бакете. Для Airflow ничего не меняется: он как обрабатывал S3 и находил даги, так и продолжает — только теперь сервисы разведены по папкам.
Вернемся к стейкхолдерам
К этому моменту мы закрыли потребности двух стейкхолдеров — MLOps (стандартизация) и DevOps (разделение ответственности и CI/CD).
Вернемся еще раз к Security-инженерам. Поскольку у нас есть CI/CD и единая входная точка во все сервисы, эта точка становится входной и для ИБ. Теперь ИБ может легко добавлять свои security-гейты в наш CI/CD — отдельными пайплайнами или напрямую в существующие. Преимущество в том, что всё автоматически раскатывается на все сервисы: не нужно проходить по командам и просить каждую вручную добавлять проверки.

Для работы с данными мы внедрили dbt — инструмент для управления SQL-запросами. Фактически из SQL-запроса получается DBT-модель, а из модели — Airflow даг. Внутри шаблона есть отдельная директория с dbt-проектом и в CI/CD — отдельная джоба для генерации дага из dbt-модели.
Также остается проблема записи данных самим сервисом. В шаблон мы добавили возможность поднимать собственную базу данных, как у обычных микросервисов (не путать с базой, которую использует сам Airflow).
Эта база поднимается для каждого сервиса (в нашем случае автоматически на стейджах и черех архкомитет на продакшене) и CI/CD автоматически подставляет все нужные креды в поды сервиса. Также уже в шаблоне подготовлен клиент для записи данных. Более того, эту БД можно подключить к дата-платформе (например, к федерации в Trino), и не только ML-щики могут использовать эти данные, а, например, еще аналитики (забирать те же эмбеддинги и использовать их в аналитических витринах).

А что насчет инференс-сервисов?
До этого момента мы говорили только про сервисы с дагами для подготовки данных и обучения моделей. Но помимо офлайн-пайплайнов есть еще инференс-сервисы — веб-сервисы, которые эксплуатируют ML-модели в бою.
На самом деле после подготовки и адопшена шаблона для дагов, сделать всё то же самое для инференс-сервисов оказалось несложно.
Использовали те же компоненты и блоки CI/CD для сборки и деплоя.
Подготовили второй шаблон, аналогичный первому, с новым engine для ecom-cli.
Механизм обновлений тот же, но теперь работает для обоих шаблонов.
Алерты, SLO, работа БД — прикрутили те же фичи.
Отличие шаблона инференс-сервиса состоит в том, что в нем одно окружение, а не два. Это логично, так как это веб-сервис и для него нужно собрать один образ.
Концептуализация ML-сервисов среди всех сервисов внутри компании
Часто бывает так, что ML-сервисы — это такая «серая» зона, в которой все крутится по своим порядкам. С одной стороны, понятно, почему так происходит — не всегда есть стандарты разработки ML-сервисов аналогично тем же микросервисам, и поэтому они развиваются хаотично. С другой стороны, это приводит к тому, что все процессы и регламенты для микросервисов перестают работать или не адаптированы для ML-сервисов. И от этого страдают все — как сами ML-щики (им приходится самостоятельно «протаптывать» все процессы), так и стейкхолдеры (им приходится много времени тратить, чтобы разобраться, что происходит).
Чтобы сделать «серую» зону прозрачной, мы уже предварительно подготовили почву — есть шаблоны, стандартизация CI/CD, два типа сервисов (даги и инференс), обновления. Но если раньше у нас был один монорепозиторий или у каждой команды была своя кучка репозиториев, то теперь мы их стандартизовали — и есть очень большая куча однотипных репозиториев. Всё еще непонятно, кто какими сервисами владеет, какая у них критичность и как они связаны между собой и внешним миром.
Чтобы решить эту проблему, мы воспользовались инструментами большого PaaS в Купере, а именно карточкой сервиса и картой сервисов.
Что такое карточка сервиса
Карточка сервиса — это простой toml-файл определенной структуры. В нем описывается метаинформация о сервисе, его взаимодействиях с другими сервисами и инфраструктуре, которая ему нужна. Эта карточка формируется в момент генерации сервиса и заполняется вручную его владельцами.
# метаинфа о сервисе
name = "autocat-images"
description = "Offers autocategorization"
engine = "airflow"
repository = "https://.../paas/ml/autocat-images"
type = "IT"
tier = 3
# метаинфа о команде
[team]
owner_team = "ml-content"
channel = "https://.../channels/dev-ml-content"
maintainer_team = "devops-team"
BO = "ivan.ivanych@kuper.ru"
# метаинфа об инфре
[clickhouse]
required = false
[s3]
required = true
. . .
# зависимости от других сервисов
[dependencies]
[[dependencies.services]]
name = "storefront"
open_api = ["admin"]
repository = "https://.../paas/content/storefront"
Важной секцией являются зависимости — в ней мы указываем все сервисы, от которых зависит данный сервис. Причем эта связь на уровне контракта — нужно указать тип контракта (REST, gRPC, Kafka) и его имя.
Затем все эти карточки автоматически парсятся в большой платформе и публикуются в сервис Huginn. Здесь уже эта карточка отображается в красивом UI и строится карта всех сервисов Купера, включая наши сервисы:

Контракты
Для того, чтобы стандартизовать способы взаимодействия ML с внешним миром, добавим в оба типа сервиса контракты.
Для Airflow-сервиса добавляем контракты чтения, например, для чтения из Kafka, а также контракты для походов в другие сервисы. Обратную связь не добавляем: DAG — не веб-сервис, из него по контракту, как правило, ничего не заберешь.
Для инференс-сервиса, так как это веб-сервис, добавляем единый контракт ручек Open Inference Protocol как стандартный протокол выдачи предсказаний (его приняли многие MLOps-инструменты).
Помимо нормального взаимодействия между сервисами, появляется observability: все карточки также публикуются в CMDB, и в нем виден каждый сервис, контактные лица, инфраструктура (бакеты, БД, внешние зависимости). Это удобно при переездах и для автоматических реестров — сервис становится «видимым на радарах» компании.
Биг пикчер

Оркестратор — Airflow; offline-сервисы деплоим в кластер по нашим шаблонам. Артефакты и эксперименты — в ClearML (под капотом S3). Для inference-сервисов используем KServe и те же шаблоны деплоя в Kubernetes. С данными работаем через отдельную дата-платформу: для офлайн-хранилища — ClickHouse, Greenplum, Trino (федерация); для Online Feature Store — Feast на Redis.
Выводы
При проектировании нужно сразу закладывать ML-инфраструктуру: прятать от ML-инженеров всё, что им не нужно трогать — в библиотеки, шаблоны, платформенные слои.
Платформа должна ускорять процессы, а не усложнять их: новый процесс оправдан, если реально улучшает жизнь.
Важно регулярно общаться с ML-инженерами (источник фич) и с другими стейкхолдерами — DevOps, ИБ, DataOps: воспринимать их требования как поводы для полезных улучшений.
Разделять среду исполнения и исполняемый код.
Стандартизировать ML-сервисы там, где это ускоряет разработку и улучшает lead time.
Применять best-practice микросервисной архитектуры в ML (адаптируя их под специфику), чтобы ML не оставался «хаотичной зоной».
И разделять ответственность ролей: иногда один и тот же человек играет роли DataOps/MLOps/DevOps, но границы ответственности всё равно должны быть обозначены.