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/v1alpha1kind: Applicationmetadata:  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/postgresql

SOPS вместо облачных 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.txt

Age 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/v1kind: ExternalSecretmetadata:  name: mlflow-credentials  namespace: mlflowspec:  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: trackingPassword

ExternalSecret может тащить данные из нескольких путей 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/v1kind: ClusterExternalSecretmetadata:  name: tls-wildcardspec:  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: true

MLflow защищён базовой HTTP-аутентификацией через аннотации Nginx Ingress — не через встроенную аутентификацию MLflow (которая платная), а на уровне Ingress:

ingress:  enabled: true  annotations:    nginx.ingress.kubernetes.io/auth-type: basic    nginx.ingress.kubernetes.io/auth-secret: mlflow-basic-auth

GPU-сервер: мета-воркер

Обучение на GPU организовано через очередь задач. Идея в том, что GPU-сервер не знает ничего о конкретных ML-проектах — он просто получает задачу вида «запусти Docker-образ X с командой Y и переменными окружения Z».

Задача описывается четырьмя полями:

# dispatch.py — отправка задачи из JupyterLab или GitLab CItask_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_callback

Env-переменные для 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_ID

CI/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.txt

Trivy сканирует собранный образ на 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.json

DevSecOps: безопасность 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 appuserUSER 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_code

  • http_request_duration_seconds — гистограмма задержки (p50, p95, p99)

  • http_requests_in_progress — количество одновременно обрабатываемых запросов

Prometheus забирает метрики через ServiceMonitor:

apiVersion: monitoring.coreos.com/v1kind: ServiceMonitormetadata:  name: inference  namespace: monitoringspec:  selector:    matchLabels:      app: inference  endpoints:    - port: http      path: /metrics      interval: 15s      timeout: 10s

Grafana-дашборд «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: v1kind: ConfigMapmetadata:  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

Надеюсь был полезен 🙂

Спасибо за внимание!

ссылка на оригинал статьи https://habr.com/ru/articles/1069368/