Это минимальный на мой взгляд набор приложений, чтобы показать путь от голого кластера до полностью автоматизированной платформы и хочу рассказать о нём так, как это интересно 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/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/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: 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/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: 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 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_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 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_code

  • http_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: 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: 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

Надеюсь был полезен :-)

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