MLOps для DevOps-инженера: как построить платформу машинного обучения в закрытом контуре
Это минимальный на мой взгляд набор приложений, чтобы показать путь от голого кластера до полностью автоматизированной платформы и хочу рассказать о нём так, как это интересно DevOps-инженеру.
Зачем DevOps-инженеру MLOps?
По данным отраслевых исследований, до 80% ML-проектов так и остаются в статусе «эксперимент». Модели обучают в Jupyter-ноутбуках, но до продакшена они доходят редко — потому что нет внятных процессов, как их воспроизводить, тестировать и выкатывать.
Корень проблемы — разрыв между двумя культурами. Дата-сайентист мыслит экспериментом: подобрал параметры, погонял тесты, получил приемлимые на его взгляд результаты — работа сделана. DevOps-инженер мыслит сервисом: код в Git, тесты в CI, сборка, деплой, мониторинг, алерты.
Теперь давайте сюда добавим стейк-холдеров из бизнес-составляющей, что мы получим?
Приходит владелец продукта к devops-инженеру и говорит: вот у нас появился ML-инженер - сделай так, чтобы он мог работать!
Да простят меня коллеги, но Дата-сайентисты - люди творческие (хоть и математики в основном) - им сложно даже иногда объяснить, что такое git, как все положить в docker и причем здесь devsecops (это все из личного опыта, не более - на всех не распространяется!)?
Из личного примера знаю, что если дать им просто сервер с GPU и поставить туда Jupyter - в итоговой сборке приложения с моделью внутри окажется и весь код git репозитория, включая ключи и пароли. И Дата-сайентист тоже не может объяснить devops инженеру, что ему надо.
Как оценить проделанную работу Дата-сайентиста? Как понять, что он чем-то занят? - нужно каким-то образом фиксировать его работу - наборы данных, обученные модели и т.д.
Когда эти двое пытаются запустить ML-сервис без общего процесса — получается боль.
MLOps — это как раз тот клей, который соединяет два мира. На практике это означает, что все те практики, которые DevOps-инженер считает базовыми — версионирование, CI/CD, контейнеризация, мониторинг, секреты — распространяются ещё и на данные и модели. А это чуть сложнее, чем кажется на первый взгляд.
Чем ML-пайплайн отличается от обычного CI/CD
В классической разработке артефакт — это код, который ведёт себя детерминированно. В ML поведение модели зависит от трёх вещей: кода, данных и гиперпараметров. Поэтому:
Данные — артефакт первого класса. Недостаточно закоммитить код в Git. Нужно версионировать датасеты, причём так, чтобы по хешу коммита можно было воспроизвести любой эксперимент. Git для этого не подходит — датасеты весят гигабайты. Мы использовали DVC (Data Version Control), который хранит в Git только лёгкие .dvc-указатели, а сами файлы — в S3-совместимом хранилище MinIO.
Эксперименты вместо детерминированных сборок. В CI/CD сборка либо зелёная, либо красная. В ML нужно сравнивать десятки запусков с разными алгоритмами и гиперпараметрами, чтобы выбрать лучший. Для этого нужен трекинг экспериментов — мы взяли MLflow с PostgreSQL в качестве бэкенда.
Дрейф модели. Самая неприятная особенность ML в продакшене: модель может деградировать без единого изменения в коде. Просто потому, что распределение входных данных со временем меняется (concept drift). Поэтому жизненный цикл ML-модели — циклический, а не линейный. Модель нужно мониторить и переобучать.
Model Registry — аналог Container Registry. Чтобы выкатить модель в prod, её нужно где-то хранить, версионировать и уметь быстро откатить. MLflow Model Registry с алиасами (@champion, @challenger) решает эту задачу. Сервис приложения всегда грузит модель по алиасу @champion — чтобы сменить версию, достаточно переназначить алиас, не меняя код (тут конечно зависит от вашего рабочего флоу).
Если перевести на язык DevOps: CI/CD в ML — это пайплайн, в котором артефактами являются не только Docker-образы, но и обученные модели, а стадия деплоя обновляет не только код, но и версию модели через Model Registry.
Архитектура: что и зачем мы развернули
Платформа спроектирована для работы в закрытом контуре: приватный GitLab, приватный Docker Registry, внутренний Ingress, никаких облачных сервисов. Все компоненты — open-source. Ну и напомню, что речь идет о минимальном наборе (прототип!) - при увеличении "объемов" архитектуру, конечно, стоит пересматривать.
Два контура
Кластер разделён на два физических контура:
Kubernetes-кластер (serving & platform). Здесь крутится всё, что не требует GPU: хранение данных, трекинг экспериментов, секреты, мониторинг, а главное — сервис инференса, который отвечает на запросы пользователей.
GPU-сервер (training node, вне K8s). Отдельная машина с GPU NVIDIA, не входит в кластер. Используется только для обучения моделей — через Docker-контейнеры с nvidia-container-toolkit. Почему вне кластера? GPU-железо дорогое, и привязывать его к K8s-ноде означает, что оно не может шариться между проектами. А так — один GPU-сервер обслуживает обучение, пока кластер занят своим делом.
Технологический стек
Компонент | Технология | Зачем |
|---|---|---|
Оркестрация | Kubernetes | Базовая платформа |
GitOps / CD | ArgoCD | Декларативный деплой из Git |
CI | GitLab CI/CD | Тесты, сборка, сканирование |
Хранилище | MinIO (S3) | Данные и артефакты MLflow |
Трекинг ML | MLflow + PostgreSQL | Эксперименты + Model Registry |
ML-приложение | FastAPI | REST API предсказаний |
Версионирование данных | DVC | Версии датасетов |
Среда экспериментов | JupyterLab | Интерактивная разработка |
Обучение | Celery + Redis | Очередь GPU-задач |
Секреты | OpenBao(Vault) + ESO | Централизованное хранение |
SAST | Bandit | Анализ Python-кода |
Сканирование образов | Trivy | CVE в контейнерах |
Мониторинг | Prometheus + Grafana | Метрики API |
Связь между контурами — через сеть: GPU-сервер ходит в MLflow, MinIO и Redis через Ingress/LoadBalancer. С точки зрения безопасности это плюс: компрометация GPU-сервера не даёт доступа к управляющим компонентам k8s.
Теперь — почему мы не взяли популярные инструменты.
Kubeflow. Стандарт де-факто для MLOps на K8s, но он проектировался под облака. Развернуть Kubeflow в закрытом контуре — тоже можно, но для старта может быть сложно из-за непонимания необходимого набора модульных микросервисов. Плюс он тащит за собой CRD, которые усложняют отладку. Для прототипа — избыточно.
Airflow. Отличный оркестратор, но его модель «DAG по расписанию» плохо ложится на событийную природу ML-экспериментов. Дата-сайентист хочет запустить обучение «прямо сейчас» из ноутбука, а не ждать ближайшего scheduled run. Мы использовали Celery + Redis как более легковесную альтернативу.
HashiCorp Vault. Проблема в лицензии: после перехода HashiCorp на BSL (Business Source License) использование Vault в production стало рискованным. Мы взяли OpenBao — открытый форк под MPL 2.0, полностью совместимый по API.
GitOps: Apps-of-Apps
GitOps-репозиторий организован по принципу Apps-of-Apps. Корневой ArgoCD Application управляет набором дочерних Applications — по одному на компонент. Порядок развёртывания задаётся sync-wave - позволяет избежать ситуаций, когда приложение хочет запуститься, а секреты из волта еще не подтянулись.
Структура репозитория:
gitops/
├── apps/ # ArgoCD Applications (по одному на компонент)
├── charts/ # Завендоренные Helm-чарты (mlflow, postgresql, redis, ...)
├── helm-values/ # Helm values каждого компонента
├── manifests/ # Нативные K8s-манифесты (inference, monitoring)
├── secrets/ # ExternalSecret-манифесты (OpenBao → K8s)
├── namespaces/ # Namespace с TLS-лейблами
├── bootstrap/ # root-app.yaml (применяется один раз вручную)
└── docker/sops-cmp/ # Dockerfile для ArgoCD CMP sidecar (SOPS)Каждый ArgoCD Application выглядит однотипно. Например, MLflow:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: mlflow
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "3"
spec:
project: default
sources:
- repoURL: https://code.appsec-team.ru/mlops/mlops-platform.git
path: charts/mlflow
targetRevision: main
helm:
valueFiles:
- $values/helm-values/mlflow-values.yaml
- repoURL: https://code.appsec-team.ru/mlops/mlops-platform.git
targetRevision: main
ref: values
destination:
server: https://kubernetes.default.svc
namespace: mlflow
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=trueЧарт скопирован прямо в Git-репозиторий (gitops/charts/mlflow/) — никаких внешних Helm-репозиториев. В закрытом контуре это единственно надёжный вариант: не нужно зеркалировать чарты, нет риска, что внешний репозиторий станет недоступен, и все изменения чарта проходят code review наравне с values. Два источника (sources) в одном Application — фича ArgoCD 2.6+ — ссылаются на один и тот же Git-репозиторий, просто на разные пути в нём.
Закрытый контур: главные боли и решения
Самая сложная часть работы в изолированной среде — не архитектура, а логистика артефактов. Всё, что в облаке доступно по щелчку, здесь нужно сначала затащить внутрь периметра.
Приватный Docker Registry
Все образы проходят через приватный docker registry, например, Harbor. Helm-чарты часто ссылаются на публичные образы (docker.io, quay.io) — их нужно перекладывать в приватный registry и править values.
image:
registry: registry.appsec-team.ru
repository: public/bitnami/postgresqlSOPS вместо облачных KMS
В облаке секреты GitOps-репозитория шифруются через KMS. В закрытом контуре мы используем SOPS с ключами age. ArgoCD расшифровывает их через CMP sidecar — кастомный плагин, который вызывает sops --decrypt перед применением манифеста.
# argocd-values.yaml (фрагмент)
controller:
extraContainers:
- name: sops-cmp
image: registry.appsec-team.ru/public/argocd-sops-cmp:3.12.2
command: [/var/run/argocd/argocd-cmp-server]
volumeMounts:
- name: sops-age-key
mountPath: /root/.config/sops/age/keys.txt
subPath: keys.txtAge key монтируется как Secret и никогда не попадает в Git. Манифесты с секретами коммитятся в зашифрованном виде (.enc.yaml).
OpenBao вместо Vault
OpenBao — форк Vault под лицензией MPL 2.0, полностью совместимый по API. Развёрнут в режиме HA с тремя репликами в K8s. Все секреты приложений хранятся в KV-хранилище с иерархией по компонентам.
External Secrets Operator (ESO) автоматически синхронизирует значения из OpenBao в K8s Secrets. Манифест ExternalSecret описывает mapping:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mlflow-credentials
namespace: mlflow
spec:
refreshInterval: 5m
secretStoreRef:
name: cluster-secret-store
kind: ClusterSecretStore
target:
name: mlflow-credentials
data:
- secretKey: MLFLOW_TRACKING_USERNAME
remoteRef:
key: secret/mlflow
property: trackingUsername
- secretKey: MLFLOW_TRACKING_PASSWORD
remoteRef:
key: secret/mlflow
property: trackingPasswordExternalSecret может тащить данные из нескольких путей KV в один Kubernetes Secret. Мы использовали это, когда понадобилось передать MLFLOW_TRACKING_USERNAME и MLFLOW_TRACKING_PASSWORD в singleuser-поды JupyterLab из того же секрета, что используется для MLflow и inference.
Сеть и TLS
Все сервисы публикуются через nginx Ingress Controller (класс nginx-internal) с единым wildcard TLS-сертификатом. Балансировщик — MetalLB с внутренним IP (наружу ничего не торчит). Сертификат автоматически распространяется по всем namespace через ClusterExternalSecret:
apiVersion: external-secrets.io/v1
kind: ClusterExternalSecret
metadata:
name: tls-wildcard
spec:
externalSecretName: tls-wildcard
namespaceSelectors:
- matchLabels:
mlops.platform/tls: "true"
externalSecretSpec:
# ... тянет tls.crt + tls.key из OpenBaoДостаточно добавить лейбл mlops.platform/tls: "true" на новый namespace — и сертификат появится там автоматически.
Некоторые компоненты: что важно знать
MLflow: почему не SQLite
Стандартная установка MLflow по умолчанию использует SQLite. Этого хватает ровно до момента, когда вы запускаете больше одного воркера. Gunicorn с несколькими worker-процессами + SQLite :memory: = каждый воркер со своей изолированной БД. Результат: RESOURCE_DOES_NOT_EXIST при попытке записать параметр — run создался в одном воркере, а log_param пришёл в другой.
Решение: PostgreSQL как backend store. В community-charts/mlflow 1.8.1 это делается через existingDatabaseSecret:
backendStore:
existingDatabaseSecret: mlflow-db-credentials
existingDatabaseSecretKey: DATABASE_URL
postgres:
enabled: trueMLflow защищён базовой HTTP-аутентификацией через аннотации Nginx Ingress — не через встроенную аутентификацию MLflow (которая платная), а на уровне Ingress:
ingress:
enabled: true
annotations:
nginx.ingress.kubernetes.io/auth-type: basic
nginx.ingress.kubernetes.io/auth-secret: mlflow-basic-authGPU-сервер: мета-воркер
Обучение на GPU организовано через очередь задач. Идея в том, что GPU-сервер не знает ничего о конкретных ML-проектах — он просто получает задачу вида «запусти Docker-образ X с командой Y и переменными окружения Z».
Задача описывается четырьмя полями:
# dispatch.py — отправка задачи из JupyterLab или GitLab CI
task_data = {
"project": "churn-model",
"image": "registry.appsec-team.ru/mlops/churn-worker:latest",
"command": "python src/train.py --algorithm xgboost --n_estimators 200",
"env": {
"MLFLOW_TRACKING_URI": "https://mlflow.service.appsec-team.ru",
"AWS_ACCESS_KEY_ID": "...",
},
}
result = celery_app.send_task("train_model", args=[task_data], queue="train")Мета-воркер на GPU-сервере принимает задачу из Redis, делает docker pull образа и запускает контейнер:
# meta-worker — Celery task handler
@app.task(name="train_model", bind=True)
def train_model(self, task_data):
project = task_data["project"]
image = task_data["image"]
command = task_data["command"]
env = task_data.get("env", {})
client = docker.from_env()
container = client.containers.run(
image=image,
command=command,
environment=env,
runtime="nvidia",
device_requests=[docker.types.DeviceRequest(count=-1, capabilities=[["gpu"]])],
remove=True,
detach=False,
)
return {"status": "completed", "exit_code": container.wait()["StatusCode"]}Docker Socket Proxy — почему это важно
Мета-воркер подключается к Docker-демону GPU-сервера. Если дать ему прямой доступ к /var/run/docker.sock, это эквивалентно root-доступу к хосту: процесс внутри контейнера может запускать произвольные контейнеры, монтировать файловую систему хоста и так далее.
Решение: прокси linuxserver/socket-proxy, который встаёт между воркером и сокетом Docker и разрешает только минимально необходимые операции:
# docker-compose.yml на GPU-сервере
services:
socket-proxy:
image: lscr.io/linuxserver/socket-proxy
environment:
- IMAGES=1 # docker pull
- CONTAINERS=1 # docker run, docker logs
- POST=1 # docker wait
# всё остальное — 0 (заблокировано)
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
worker:
image: registry.appsec-team.ru/mlops/meta-worker:latest
environment:
- DOCKER_HOST=tcp://socket-proxy:2375 # не /var/run/docker.sock!Это реализует принцип минимальных привилегий: даже при компрометации воркера атакующий не получит доступ к управлению всей инфраструктурой хоста, а только к тем операциям Docker API, которые разрешены прокси.
MinIO: два Ingress'а — не баг, а фича
MinIO поднимает два HTTP-сервера на разных портах: S3 API на 9000 и веб-консоль на 9001. Соответственно, нужно два Ingress:
minio.service.appsec-team.ru→ порт 9000 (S3 API для DVC, MLflow, boto3)s3.service.appsec-team.ru→ порт 9001 (веб-консоль для администратора)
Почему нельзя обойтись одним? Если открыть S3 API в браузере, MinIO автоматически редиректит на порт 9001 (консоль). Но Ingress не проксирует этот порт на том же домене — страница будет пустой. Разные домены для API и консоли — осознанное решение.
JupyterLab: OAuth и проброс секретов
JupyterHub развёрнут через Helm-чарт jupyterhub/jupyterhub (Zero to JupyterHub). Аутентификация — через Keycloak SSO с GenericOAuthenticator:
hub:
config:
JupyterHub:
authenticator_class: generic-oauth
GenericOAuthenticator:
client_id: jupyterlab
client_secret: "..." # из OpenBao через existingSecret
authorize_url: https://keycloak.service.appsec-team.ru/realms/mlops/protocol/openid-connect/auth
token_url: https://keycloak.service.appsec-team.ru/realms/mlops/protocol/openid-connect/token
userdata_url: https://keycloak.service.appsec-team.ru/realms/mlops/protocol/openid-connect/userinfo
oauth_callback_url: https://jupyter.service.appsec-team.ru/hub/oauth_callbackEnv-переменные для singleuser-подов (MLflow credentials, MinIO keys) пробрасываются через ExternalSecret. Важно: extraEnv в z2jh-чарте принимает dict-формат, а не K8s-список:
singleuser:
extraEnv:
MLFLOW_TRACKING_URI: "https://mlflow.service.appsec-team.ru"
MLFLOW_TRACKING_USERNAME:
valueFrom:
secretKeyRef:
name: jupyterlab-credentials
key: MLFLOW_TRACKING_USERNAME
AWS_ACCESS_KEY_ID:
valueFrom:
secretKeyRef:
name: jupyterlab-credentials
key: AWS_ACCESS_KEY_IDCI/CD: от git push до production
Конвейер описан в .gitlab-ci.yml и запускается при каждом push в main.
Сборка образов с кешированием
Сборка идёт через docker buildx на DinD-раннере. Кеш слоёв пишется в реестр образов — это критично для образа с PyTorch, который без кеша собирается 15 минут:
build:api:
stage: build
image: docker:24-dind
script:
- docker buildx build
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE/cache:api
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE/cache:api,mode=max
-f Dockerfile.api
-t $CI_REGISTRY_IMAGE/churn-api:$CI_COMMIT_SHORT_SHA
--push .Ключевой момент — порядок инструкций в Dockerfile. Сначала копируются файлы зависимостей и ставится pip install, потом копируется изменяемый код:
# Сначала зависимости (меняются редко → слой кешируется)
COPY requirements.api.txt .
RUN pip install --no-cache-dir -r requirements.api.txt
# Потом код (меняется часто → инвалидирует только последний слой)
COPY src/ ./src/При таком порядке правка .py-файла не инвалидирует слои с установленными пакетами — и время сборки падает с 3 минут до 20–40 секунд.
Стадии безопасности
Bandit проверяет Python-код на типичные уязвимости (опасные функции, потенциальные инъекции, слабая криптография) на уровне medium и выше:
sast:bandit:
stage: sast
script:
- pip install bandit
- bandit -r src/ -ll -f txt -o bandit-report.txt
artifacts:
paths:
- bandit-report.txtTrivy сканирует собранный образ на CVE в пакетах ОС и Python-зависимостях:
scan:trivy:
stage: scan
image: aquasec/trivy:latest
script:
- trivy image --severity HIGH,CRITICAL
--ignore-unfixed
$CI_REGISTRY_IMAGE/churn-api:$CI_COMMIT_SHORT_SHA
- trivy image --severity HIGH,CRITICAL
--format template --template "@contrib/gitlab.tpl"
-o trivy-report.json
$CI_REGISTRY_IMAGE/churn-api:$CI_COMMIT_SHORT_SHA
artifacts:
paths:
- trivy-report.jsonDevSecOps: безопасность ML-конвейера
В ML-системах безопасность часто отходит на второй план — «лишь бы модель работала». Мы попытались исправить этот перекос.
Два уровня секретов
Первый уровень — SOPS + age для GitOps-credentials. ArgoCD-секреты (токены, ключи) шифруются перед коммитом и расшифровываются CMP-сайдкаром ArgoCD в рантайме. Они никогда не появляются в открытом виде в Git.
Второй уровень — OpenBao + External Secrets Operator для всех остальных секретов (пароли БД, ключи MinIO, токены сервисов). Манифесты ExternalSecret лежат в Git открыто (они лишь описывают, откуда взять значение), а сами значения живут в OpenBao.
Безопасность контейнера
Образ API собирается от непривилегированного пользователя:
RUN useradd --create-home --uid 1000 appuser
USER appuserВ Kubernetes-деплойменте задан строгий SecurityContext:
securityContext:
runAsNonRoot: true
runAsUser: 1000
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefaultДля временных файлов uvicorn монтируется emptyDir volume на /tmp.
Что осталось за скобками
Auto-Unseal OpenBao. В прототипе при перезапуске OpenBao требуется ручной unseal (три ключа из пяти). Для production нужно настроить Auto-Unseal через Transit Seal или облачный KMS — но в закрытом контуре облачного KMS нет, поэтому остаётся разворачивать отдельный Transit-кластер или мириться с ручным unseal.
Защита модели. Модель хранится в MinIO как pickle-файл. Pickle может содержать произвольный код — это известный вектор атаки. В прототипе мы полагаемся на то, что модель сохраняется доверенным кодом в доверенном окружении, но для production стоит рассмотреть safer-форматы (ONNX, MLflow pyfunc с проверкой подписи).
Мониторинг: не только latency
FastAPI-сервис экспортирует метрики через prometheus-fastapi-instrumentator на эндпоинте /metrics. Библиотека автоматически собирает:
http_requests_total— счётчик запросов с разбивкой по handler, method, status_codehttp_request_duration_seconds— гистограмма задержки (p50, p95, p99)http_requests_in_progress— количество одновременно обрабатываемых запросов
Prometheus забирает метрики через ServiceMonitor:
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: inference
namespace: monitoring
spec:
selector:
matchLabels:
app: inference
endpoints:
- port: http
path: /metrics
interval: 15s
timeout: 10sGrafana-дашборд «MLOps — Inference Service» содержит восемь панелей:
Stat-панели: RPS, Error Rate 5xx (пороги 1%/5%), Latency p99 (пороги 100ms/500ms), Total Requests (1h)
Timeseries-панели: Latency Percentiles (p50/p95/p99), Request Rate by Status Code (2xx/4xx/5xx), RPS по эндпоинтам, Memory Usage
Дашборд хранится в GitOps-репозитории как ConfigMap и применяется ArgoCD:
apiVersion: v1
kind: ConfigMap
metadata:
name: inference-dashboard
labels:
grafana_dashboard: "1"
data:
dashboard.json: |
# ... JSON дашборда ...Что НЕ вошло в прототип
Мониторинг дрейфа данных (concept drift) и ML-метрик (точность модели на живых данных) — самая важная часть MLOps-мониторинга, которая осталась за рамками прототипа. Для production нужно отслеживать распределение входных признаков и бить тревогу, когда оно начинает отличаться от тренировочного.
С чего начать: минимальный MLOps
Если вы DevOps-инженер и хотите построить MLOps-платформу в своей компании, вот минимальный набор, с которого стоит начать:
Шаг 1: GitOps-репозиторий + ArgoCD. Без этого всё остальное будет ручным бардаком. Настройте Apps-of-Apps, разбейте компоненты по sync-wave, настройте SOPS для секретов.
Шаг 2: MLflow + PostgreSQL + MinIO. Это ядро платформы: трекинг экспериментов, хранение артефактов, Model Registry. Всё остальное — либо обвес, либо интеграция.
Шаг 3: JupyterLab. Дата-сайентистам нужно где-то работать. Настройте SSO, пробросьте credentials в env, установите MLflow SDK и DVC в образ singleuser.
Шаг 4: GitLab CI с базовыми стадиями. Тесты → Bandit → сборка образа → Trivy → деплой через ArgoCD. Стадию обучения можно добавить позже.
Что можно отложить:
GPU-сервер и мета-воркер — если модели обучаются быстро на CPU, очередь задач не нужна
OpenBao — для начала можно использовать sealed secrets или age-зашифрованные секреты в Git (уже настроено на шаге 1)
Prometheus/Grafana — можно подключить позже, когда сервис начнёт получать реальный трафик
Keycloak SSO — для прототипа хватит встроенной basic-auth JupyterHub
Итоги
Что получилось в результате:
15 приложений в ArgoCD — вся платформа управляется декларативно из Git
2 точки входа в обучение — JupyterLab и GitLab CI, обе ведут в одну очередь Redis
0 секретов в коде — всё в OpenBao или зашифровано SOPS
Полный цикл от git push до production
Инференс: модель загружается один раз при старте пода, дальше работает из памяти
Платформа не идеальна — это прототип, и я сознательно оставил за скобками мониторинг дрейфа данных, Auto-Unseal OpenBao, A/B-тестирование моделей и оркестрацию параллельных обучений. Но она показывает главное: MLOps — это не магия и не отдельная вселенная со своими законами. Это просто DevOps-практики, распространённые на артефакты ML-жизненного цикла.
Если вы DevOps-инженер, вы уже знаете 80% того, что нужно для построения MLOps-платформы. Остальные 20% — это специфика ML: Model Registry, версионирование данных, очередь GPU-задач. С этим уже можно разбираться по мере внедрения.
Ну и для тех, кто долистал, ссылка на мои изыскания:
https://gitverse.ru/vsb2007/mlops-platform
Надеюсь был полезен :-)
Спасибо за внимание!