Ваша ML-модель работает в ноутбуке, а в продакшене — нет. Бывало такое? Именно здесь начинается настоящая инженерная задача: взять эксперимент из Jupyter-ноутбука и превратить его в воспроизводимый, наблюдаемый и масштабируемый пайплайн — от сырых данных до стабильного инференса под реальной нагрузкой. Kubernetes давно стал де-факто стандартом для этой работы: более 70% компаний используют его в продакшене — не как дань хайпу, а как прагматичное решение (особенно если до этого успели сплясать на граблях).

В этой статье разберем, почему K8s выигрывает у альтернатив именно для ML-нагрузок, а также обсудим, какие мифы и анти-паттерны тормозят команды на пути к продакшену. Пройдемся по полному стеку: от подготовки кластера и фиксации данных через DVC до canary-деплоя модели и автоскейлинга GPU-подов. В конце вас ждет взгляд на то, куда движется индустрия: serverless-ML, multi-LLM-ops и edge-развертывания.

Если вы DevOps- или MLOps-инженер, которому приходится запускать обучение и инференс в одном кластере, или R&D-инженер, чьи модели «магически ломаются» при переходе в прод — читать обязательно.

Кстати, привет, Хабр! Меня зовут Сергей Лебедев, я DevOps-инженер в команде HWP (Performance & Hardware) — занимаемся платформенной инфраструктурой продукта Cloud.ru Evolution Stack. Впрочем, эта статья имеет мало отношения к нашей с коллегами основной работе: она выстрадана из практики нашего пет-проекта, а не просто взята из документации.

Статья получилась подробной, поэтому чтобы не читать всё подряд — прыгайте сразу в нужное место, вот небольшой навигатор:

Почему именно Kubernetes (и почему без него уже не выжить)
Пайплайн «от А до Я»: какие кусочки нужны и где они «живут»
Пошаговый Hello-World ML-pipeline на Kubeflow + KServe
Шаг 1 — Подготовка кластера
Шаг 2 — Фиксация данных через DVC
Шаг 3 — ETL-задача на Spark-operator
Шаг 4 — Обучающий Kubeflow-pipeline (TFX)
Шаг 5 — Деплой модели через KServe
Шаг 6 — Автоскейлинг GPU-подов (HPA + кастомные метрики)
Шаг 7 — Canary-rollout + автоматический откат
Шаг 8 — Наблюдаемость и алертинг
Реальные кейсы и типичные боли
Чек-лист готовности к продакшену
Что будет дальше
Вместо заключения

Почему именно Kubernetes (и почему без него уже не выжить)

Контейнеризация решает конкретную боль: модель перестает быть намертво прибитой к одному серверу. Можно мигрировать, можно гонять эксперименты хоть в on-premise, хоть локально, хоть в облаке — среда воспроизводится. Да, виртуализацию никто не отменял, и очень часто кластеры K8S будут развернуты внутри OpenStack \ VmWare \ oVirt и так далее, но это совсем другая история.

Кубер в чем-то похож на магию, например, здесь есть такие заклинания как:
Автоматическое масштабирование (HPA/VPA) в сочетании с кастомными метриками (например, GPU utilization или Spark executor memory), которое позволяет тренировочным и инференс-сервисам динамически захватывать в плен доступное железо без ручного вмешательства.

Единый наблюдательный стек — Prometheus, Grafana, OpenTelemetry и Loki собирают метрики, логи и трассы в одном месте, упрощая отладку, построение SLA-дашбордов и быстрый отклик на инциденты.

Богатая экосистема CRD (Kubeflow, KServe, Spark-operator, Argo, Tekton), которая делает возможным декларативное описание всего жизненного цикла модели — от подготовки данных до предсказаний без кофейной гущи и карт Таро.

Механизмы безопасности (RBAC, NetworkPolicies, Secrets-менеджмент, PodSecurity-Policies) дают строгий контроль доступа и соответствие GDPR, PCI-DSS и другим регулятивным нормам.

Готовность к будущим технологиям: поддержка serverless-ML через Knative и KEDA, мультиоблачные и edge-развертывания, а также LLM-ops. 

Все вместе это дает не просто удобный оркестратор, а платформу, на которой ML-пайплайн можно выстроить один раз, а потом масштабировать вместо переписывания.

Пайплайн «от А до Я»: какие кусочки нужны и где они «живут»

Тут стоит сделать оговорку: это не пошаговый план для реализации 100% проектов, нет. Как известно, у DevOps-инженера нет цели, есть только путь. Так вот: описанное далее это путь группы инженеров, которые пэт-проекта ради решили прыгнуть в эту бурную реку. Соответственно, тут собрана компиляция, которая сработала для нас, сработает ли для вас? Гарантировать не могу, но потестировать стоит. 

Если смотреть архитектурно, то мы собрали наш пайп из понятных блоков, каждый из которых решает свою задачу и живет в своем уголке кластера. Это удобно, практично и модно-молодежно. Наши уголки проекта оказались такими: 

Сбор и хранение данных. Object storage (MinIO, S3) хранит сырые и обработанные датасеты. DVC плюс Git фиксируют каждый набор данных SHA-хешем, поэтому любой запуск пайплайна точно знает, какие данные использовались, благодаря чему можно откатиться к любой версии датасета и наступить на грабли еще несколько раз.

Препроцессинг (ETL). Spark-operator запускает спарк-джобы в виде SparkApplication. Sidecar-логгер (Fluent Bit) отправляет логи в Loki, чтобы их можно было быстро искать в Grafana. Звучит намного проще и красивее, чем было в итоге. 

Обучение модели. Kubeflow Pipelines оркестрируют шаги: чтение данных, обучение, валидацию, пуш модели. Custom Metrics API экспортирует gpu_utilization через DCGM-Exporter → Prometheus-adapter → HPA, так что поды масштабируются именно по загрузке GPU, а не по CPU.

Деплой (инференс). KServe управляет InferenceService. Canary-rollout плюс Istio/Envoy позволяют плавно переключать трафик на новую версию модели и автоматически откатываться, если SLA падает.

Пошаговый Hello-World ML-pipeline на Kubeflow + KServe

Как и в предыдущей главе: прошу с пониманием относиться к коду и подходу, это же не официальная документация. Если вы остались после этого и хотите почитать дальше (желательно до конца), то давайте приступим.

Требования:

  • Kubernetes ≥ 1.24, kubectl, docker, helm, git, python ≥ 3.9.

  • Доступ к объектному хранилищу (MinIO/S3).

Шаг 1 — Подготовка кластера

Хоть к этому шагу мы пришли не первым, на удивление, но именно с него надо начинать. Для начала мы создаем namespace и занимаемся установкой MiniO через HELM. MiniO был выбран из-за совместимости с S3, своей открытости и легкости в установке. И, конечно, из-за своей совместимости с проектами как локального уровня, так и production-ready. Я не отрицаю, что тут будет куча проектов с GitHub, у которых может быть больше пользы или которыми пользуются в проде именно в вашей компании — можете поделиться в комментариях, буду признателен.

```

kubectl create namespace ml-demo

kubectl config set-context --current --namespace=ml-demo

helm repo add minio https://charts.min.io/

helm install minio minio/minio \

  --namespace ml-demo \

  --set accessKey.password=$MINIO_ROOT_USER \

  --set secretKey.password=$MINIO_ROOT_PASSWORD \

  --set persistence.enabled=false \

  --set mode=standalone

```

Шаг 2 — Фиксация данных через DVC

На этом шаге наша задача решить весьма тривиальную задачу - гарантировать воспроизводимость. А для этого нам надо создать Pod для работы с DVC, а еще надо инициализировать репозиторий, настроить remote на наш MiniO и в конце добавить сырой dataset в нашу хранилку. 

```

kubectl run dvc-cli \

  --image=python:3.11-slim \

  --restart=Never \

  --command -- sleep infinity \

  -n ml-demo

kubectl exec -it dvc-cli -n ml-demo -- bash

```

Внутри pod:

```

pip install "dvc[s3]" git

git init && dvc init

dvc remote add -d minio s3://ml-demo/dvc

dvc remote modify minio endpointurl http://minio.ml-demo.svc.cluster.local:9000

dvc remote modify minio access_key_id $MINIO_ROOT_USER

dvc remote modify minio secret_access_key $MINIO_ROOT_PASSWORD

aws --endpoint-url http://minio.ml-demo.svc.cluster.local:9000 \

  cp ./train.parquet s3://ml-demo/raw/train.parquet

dvc add data/raw/train.parquet

git add data/raw/train.parquet.dvc .gitignore

git commit -m "Add raw training data (DVC tracked)"

```


Теперь любой запуск пайплайна знает, какие данные использовались, и можно откатиться к любой версии датасета и получать граблями снова и снова, но это уже совсем другая история.

Шаг 3 — ETL-задача на Spark-operator

Дальше будет много кода, но ниже — манифест SparkApplication, в котором мы указываем образ, ресурсы драйвера и исполнителей, добавляем sidecar с Fluent Bit для логов и включаем heartbeat. А нужно нам это для того, чтобы исполнители не терялись.

```

apiVersion: sparkoperator.k8s.io/v1beta2

kind: SparkApplication

metadata:

  name: preprocess-job

  namespace: ml-demo

spec:

  type: Python

  mode: cluster

  image: myrepo/spark-preprocess:latest

  mainApplicationFile: local:///opt/spark/app/etl.py

  sparkConf:

    spark.sql.executor.heartbeatInterval: 30s

    spark.network.timeout: 600s

  driver:

    cores: 2

    memory: 4g

    serviceAccount: spark

    volumeMounts:

      - name: logs

        mountPath: /var/log

  executor:

    cores: 4

    instances: 3

    memory: 8g

    volumeMounts:

      - name: logs

        mountPath: /var/log/spark

  volumes:

    - name: logs

      emptyDir: {}

  sidecars:

    - name: fluent-bit

      image: fluent/fluent-bit:1.9

      args: ["-c", "/fluent-bit/etc/fluent-bit.conf"]

      volumeMounts:

        - name: logs

          mountPath: /var/log/spark

```

Пришло время лицезреть прекрасный ETL-скрипт. В наших изысканиях мы использовали DVC, чтобы получить S3-URL артефакта, а затем читали файл через boto3. На наш взгляд, это простейшая трансформация для того, чтобы отбросить строки с нулевой ценой и записать результат обратно в MinIO.

```

from pyspark.sql import SparkSession

import dvc.api

import boto3

import tempfile

def main():

    spark = SparkSession.builder.appName("ETL").getOrCreate()

    # Получаем S3-URL из DVC и читаем через boto3

    s3_url = dvc.api.get_url("data/raw/train.parquet",

                             repo="https://github.com/yourorg/ml-demo",

                             rev="master")

    # s3_url вида s3://ml-demo/raw/train.parquet

    bucket, key = s3_url.replace("s3://", "").split("/", 1)

    s3 = boto3.client("s3",

                      endpoint_url="http://minio.ml-demo.svc.cluster.local:9000",

                      aws_access_key_id=$MINIO_ROOT_USER,

                      aws_secret_access_key=$MINIO_ROOT_PASSWORD)

    with tempfile.NamedTemporaryFile(suffix=".parquet") as f:

        s3.download_file(bucket, key, f.name)

        df = spark.read.parquet(f.name)

    df = df.filter(df["price"] > 0)

    df.write.mode("overwrite").parquet("s3://ml-demo/processed/train.parquet")

    spark.stop()

if name == "__main__":

    main()

```

Dockerfile для Spark-ETL:

```

FROM bitnami/spark:3.5.0

COPY etl.py /opt/spark/app/etl.py

RUN pip install --no-cache-dir dvc[s3] boto3

```

Собираем и пушим образ:

```

docker build -t myrepo/spark-preprocess:latest ./spark-preprocess

docker push myrepo/spark-preprocess:latest

```

Запуск ETL-задачи:

```

kubectl apply -f spark-etl.yaml

kubectl wait --for=condition=complete sparkapplication/preprocess-job \

  -n ml-demo --timeout=300s

```

Возможно тут возник вопрос: а почему именно Spark? А я вам отвечу, мол, Spark умеет обрабатывать большие объемы данных параллельно, а SparkApplication-CRD делает запуск Spark-кластера в Kubernetes полностью декларативным, что целиком и полностью нас устраивает и решает наши задачи.

Шаг 4 — Обучающий Kubeflow-pipeline (TFX)

Вот как-то так выглядят наши файлы проекта:

```

ml-demo/

├─ pipeline.py

├─ trainer_module.py

├─ Dockerfile

└─ requirements.txt

```

ВНИМАНИЕ:
trainer_module.py (TensorFlow 2.x).
Обратите внимание: CsvExampleGen возвращает CSV, поэтому input_fn должен читать CSV-файлы, а не TFRecord. Это как раз тот момент когда команда потратила время / силы / веру и прочее, а оказалось дело в одном единственном нюансе который по стечению обстоятельств мы просто не заметили или не хотели замечать.

```

import tensorflow as tf

from tfx import v1 as tfx

def inputfn(file_pattern, batch_size=32):

    return tf.data.experimental.make_csv_dataset(

        file_pattern,

        batch_size=batch_size,

        label_name='target',

        num_epochs=1,

        shuffle=True)

def run_fn(fn_args):

    train_dataset = inputfn(fn_args.train_files)

    eval_dataset = inputfn(fn_args.eval_files)

    model = tf.keras.Sequential([

        tf.keras.layers.Dense(64, activation='relu'),

        tf.keras.layers.Dense(1, activation='sigmoid')

    ])

    model.compile(optimizer='adam',

                  loss='binary_crossentropy',

                  metrics=['accuracy'])

    model.fit(train_dataset,

              epochs=5,

              validation_data=eval_dataset)

    model.save(fn_args.serving_model_dir, save_format='tf')

```

Dockerfile (образ для Trainer):

```

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY trainer_module.py .

ENTRYPOINT ["python", "-m", "trainer_module"]

```

requirements.txt:

```

tensorflow==2.15

tfx==1.13

dvc[s3]==3.0

boto3

```

Собираем и пушим образ:

```

docker build -t myrepo/tfx-trainer:latest .

docker push myrepo/tfx-trainer:latest

```

И вот он, его величество пайплайн (pipeline.py). Мы явно тут передаем образ тренера через custom_config, чтобы TFX использовал нужный нам образ.

```

import os

from tfx import v1 as tfx

from tfx.orchestration.kubeflow import kubeflow_dag_runner

def create_pipeline(pipeline_name: str,

                    pipeline_root: str,

                    data_uri: str,

                    trainer_image: str,

                    model_bucket: str):

    example_gen = tfx.components.CsvExampleGen(input_base=data_uri)

    trainer = tfx.components.Trainer(

        module_file=os.path.abspath('trainer_module.py'),

        custom_config={'trainer_image': trainer_image},

        examples=example_gen.outputs['examples'],

        train_args=tfx.proto.TrainArgs(num_steps=5000),

        eval_args=tfx.proto.EvalArgs(num_steps=1000)

    )

    evaluator = tfx.components.Evaluator(

        examples=example_gen.outputs['examples'],

        model=trainer.outputs['model']

    )

    pusher = tfx.components.Pusher(

        model=trainer.outputs['model'],

        push_destination=tfx.proto.PushDestination(

            filesystem=tfx.proto.PushDestination.Filesystem(

                base_directory=model_bucket

            )

        )

    )

    return tfx.dsl.Pipeline(

        pipeline_name=pipeline_name,

        pipeline_root=pipeline_root,

        components=[example_gen, trainer, evaluator, pusher],

        enable_cache=True,

        metadata_connection_config=tfx.orchestration.metadata.sqlite_metadata_connection_config(

            os.path.join(pipeline_root, 'metadata.db')

        )

    )

if name == '__main__':

    pipeline_def = create_pipeline(

        pipeline_name='demo-pipeline',

        pipeline_root='s3://ml-demo/pipelines/demo',

        data_uri='s3://ml-demo/processed',

        trainer_image='myrepo/tfx-trainer:latest',

        model_bucket='s3://ml-demo/models/demo'

    )

    kubeflow_dag_runner.KubeflowDagRunner().run(pipeline_def)

```

Запуск пайплайна весьма классический:

```

kubectl config set-context --current --namespace=ml-demo

python pipeline.py

```

А вот что происходит:

  • ExampleGen читает наш s3 s3://ml-demo/processed/train.parquet.

  • Trainer берет образ myrepo/tfx-trainer:latest, обучает модель и сохраняет ее в s3://ml-demo/models/demo/<timestamp>/. Таким образом мы видим разные модели по времени и можем легко ими манипулировать.

  • Evaluator проверяет, что метрика accuracy ≥ 0.78 (Пример, понятное дело что тут может отличаться и это просто псевдо-код).

  • Pusher копирует артефакт именно туда, откуда KServe будет его брать в свои цепкие лапы.

Шаг 5 — Деплой модели через KServe

Почему именно KServe. На самом деле тут много плюсов и минусов, но конкретно в этой статье и в этой своей работе я выделю следующие плюшки:

  • Стандартный API. KServe реализует протокол TensorFlow Serving /v1/models/<model_name>:predict, поэтому не нужно писать обертку. Это ускоряет интеграцию с существующим кодом, который уже формирует запросы в формате {"instances": [...]}. Хоть зачастую обертка нужна и важна, но мне кажется это большим плюсом в разрезе именно взял из коробки и работает.

  • Управление ресурсами. В inference.yaml мы явно указываем лимиты cpu, memory и nvidia.com/gpu. Это гарантирует, что под получит ровно один GPU и достаточный объем памяти, а также позволяет KServe автоматически поднимать или опускать количество реплик �� зависимости от нагрузки (через HPA). 

  • Хранение артефактов в S3. Параметр storageUri: s3://ml-demo/models/demo/latest/ указывает KServe, где искать сериализованную модель. Мы используем versioned bucket в MinIO, поэтому каждый новый релиз модели просто заменяет содержимое latest/, а KServe автоматически подхватывает обновление без перезапуска кластера.

  • Неймспейс и имя сервиса. metadata.name и metadata.namespace позволяют изолировать сервис и управлять правами доступа через RBAC.

InferenceService:

```

apiVersion: serving.kserve.io/v1beta1

kind: InferenceService

metadata:

  name: demo-model

  namespace: ml-demo

spec:

  predictor:

    tensorflow:

      storageUri: s3://ml-demo/models/demo/latest/

      resources:

        limits:

          cpu: "2"

          memory: "4Gi"

          nvidia.com/gpu: "1"

```

Применяем манифест и ждём готовности:

```

kubectl apply -f inference.yaml

kubectl wait --for=condition=Ready inferenceservice/demo-model \

  -n ml-demo --timeout=180s

```

Первая команда отправляет объект в кластер, вторая — блокирует процесс, пока KServe не создаст поды, не скачает модель из S3 и не объявит сервис готовым. Таймаут в 180 секунд достаточен даже при первом скачивании больших весов (около 200 МБ). Но легко меняется в любую сторону.

Тестовый запрос. Получаем URL и отправляем POST в формате TensorFlow Serving:

```

URL=$(kubectl get inferenceservice demo-model -n ml-demo \

  -o jsonpath='{.status.url}')

curl -X POST -d '{"instances":[[0.5,1.2,3.4]]}' \

  $URL/v1/models/demo-model:predict

```

Ожидаемый ответ:

```

{ "predictions": [[0.732]] }

```

Поле predictions обязательно присутствует, иначе клиент считает запрос неуспешным. Мы проверили, что тип возвращаемого значения (float) совпадает с объявленным в схеме модели, что latency находится в пределах 30–50 мс при нагрузке 100 rps, и это полностью удовлетворяет нашим SLA.

Что мы получили в итоге

Деплой стал скучным — в хорошем смысле. Обновляешь содержимое s3://…/latest/ , выполняешь kubectl apply -f inference.yaml — и KServe автоматически подхватывает новую версию модели и поднимает реплики. Никаких ручных перезапусков и «А ты точно правильный образ указал?»

С масштабированием та же история: объявил лимиты, настроил HPA — и дальше не думаешь об этом. Пришел трафик — появились реплики, спал — ушли. GPU-часы не горят вхолостую.

Про надежность: если под падает по out_of_memory или по любой другой причине, Kubernetes его перезапустит. KServe при этом следит за тем, чтобы хотя бы одна реплика оставалась живой (minReplicas: 1). Для большинства инференс-сервисов этого достаточно, чтобы можно было не просыпаться ночью.

Ну и тестирование — один curl в конце пайплайна позволяет быстро проверить, что сервис работает, и интегрировать проверку в CI-pipeline (например, в GitHub Actions).

Шаг 6 — Автоскейлинг GPU-подов (HPA + кастомные метрики)

DCGM-Exporter (устанавливается вместе с NVIDIA GPU Operator) собирает метрику nvidia_gpu_utilization. Prometheus-adapter делает эту метрику доступной для HPA.

HPA по кастомной метрике:

```

apiVersion: autoscaling/v2

kind: HorizontalPodAutoscaler

metadata:

  name: demo-model-hpa

  namespace: ml-demo

spec:

  scaleTargetRef:

    apiVersion: serving.kserve.io/v1beta1

    kind: InferenceService

    name: demo-model

  minReplicas: 1

  maxReplicas: 10

  metrics:

    - type: External

      external:

        metric:

          name: nvidia_gpu_utilization

          selector:

            matchLabels:

              gpu: "true"

        target:

          type: AverageValue

          averageValue: "60"

```

Применяем:

```

kubectl apply -f hpa.yaml

```

Почему кастомные метрики? Обычный HPA смотрит только на CPU/Memory, а в ML-нагрузках именно GPU-загрузка определяет, нужен ли еще один под.

Шаг 7 — Canary-rollout + автоматический откат

InferenceService с canary:

```

apiVersion: serving.kserve.io/v1beta1

kind: InferenceService

metadata:

  name: demo-model

  namespace: ml-demo

spec:

  predictor:

    tensorflow:

      storageUri: s3://ml-demo/models/demo/v1/

      resources:

        limits:

          cpu: "2"

          memory: "4Gi"

          nvidia.com/gpu: "1"

  canary:

    trafficPercent: 10

    revisionTemplate:

      metadata:

        labels:

          serving.kserve.io/deployment: knative

      spec:

        predictor:

          tensorflow:

            storageUri: s3://ml-demo/models/demo/v2/

            resources:

              limits:

                cpu: "2"

                memory: "4Gi"

                nvidia.com/gpu: "1"

```

Istio VirtualService (автоматический откат, если более 5% запросов к canary-версии падают):

```

apiVersion: networking.istio.io/v1alpha3

kind: VirtualService

metadata:

  name: demo-model

  namespace: ml-demo

spec:

  hosts:

    - demo-model.ml-demo.svc.cluster.local

  http:

    - route:

        - destination:

            host: demo-model.ml-demo.svc.cluster.local

            subset: v2

          weight: 10

        - destination:

            host: demo-model.ml-demo.svc.cluster.local

            subset: v1

          weight: 90

      fault:

        abort:

          httpStatus: 500

          percentage:

            value: 5

```

Применяем:

```

kubectl apply -f canary.yaml

kubectl apply -f virtualservice.yaml

```

Зачем canary? Он позволяет проверять новую модель на небольшом проценте трафика, не рискуя всей системой. Если метрики падают, Istio автоматически откатывает трафик к стабильной версии.

Шаг 8 — Наблюдаемость и алертинг

Prometheus собирает CPU/Memory, nvidia_gpu_utilization и кастомные метрики Spark executor memory. Графики выводим в Grafana (дашборд ML-Pipeline). Grafana Loki хранит логи всех подов (Fluent Bit → Loki), доступные через Explore. OpenTelemetry Collector (как sidecar) собирает трассы от Trainer до KServe и отправляет их в Tempo. Alertmanager следит за SLA-порогами (latency > 200 ms, GPU util > 90%).

Пример правила Alertmanager (latency > 200 ms):

```

groups:

  - name: ml-slo

    rules:

      - alert: InferenceLatencyHigh

        expr: histogram_quantile(0.95,

            sum(rate(kserve_request_duration_seconds_bucket{service="demo-model"}[5m]))

            by (le)) > 0.2

        for: 2m

        labels:

          severity: critical

        annotations:

          summary: "95-percentile latency > 200 ms"

          description: "Inference latency for demo-model exceeds 200 ms for 2 min."

```

Реальные кейсы и типичные боли

В реальных проектах пайплайны ломаются не там, где вы ожидаете. Не в модели, не в коде обучения — а на стыке: между форматом данных и ожиданиями ETL-джоба, между версией библиотеки в образе и версией в окружении, между тем что написано в YAML, и тем что кластер решил сделать с планировщиком. Именно в этих неочевидных точках сосредоточена большая часть реальной отладки. Если упоминать все грабли, по которым мы пробежались, это надо получать абонемент к травматологу делать еще один отдельный Хабр. Но давайте просто перечислим наш личный топчик:

Несовместимость ExampleGen и формата данных. Если ExampleGen настроен на CSV, а _input_fn пытается читать TFRecord — пайплайн падает на первых же шагах. Лечение: явно выбирать CsvExampleGen и писать _input_fn под CSV, либо конвертировать данные в TFRecord и использовать ExampleGen с TFRecord.

Потеря исполнителей в Spark. Без heartbeat и таймаутов Spark может терять исполнителей, что приводит к провалу джоба. Лечение: добавить spark.sql.executor.heartbeatInterval и spark.network.timeout, а также мониторить метрики исполнителей.

Неправильные пути в DVC. dvc.api.open для удаленных S3-путей часто возвращает URL, а не файловый объект. Лечение: использовать dvc.api.get_url и читать через boto3/s3fs, как показано выше.

HPA без кастомных метрик. Масштабирование по CPU не отражает реальную нагрузку на GPU. Лечение: включить DCGM-Exporter, настроить Prometheus-adapter и масштабироваться по nvidia_gpu_utilization.

Отсутствие canary и автоматического отката. Релизы, где «всё сразу» приводят к инцидентам. Лечение: использовать canary-trafficPercent и Istio VirtualService с правилом отката по проценту ошибок.

Слабая наблюдаемость. Без метрик, логов и трасс сложно понять, где именно деградирует качество или растет latency. Лечение: единый стек (Prometheus, Grafana, Loki, OpenTelemetry, Alertmanager) и явные SLO.

Чек-лист готовности к продакшену

Ниже — практический чек-лист. Если всё выполнено, можно считать, на наш скромный взгляд, что пайплайн готов к продакшену.

  • Контейнеры репродуцируемы. Dockerfile фиксирует версии, сборка воспроизводима. Проверка: docker build --no-cache . && docker images | grep <sha>.

  • Метаданные в MLMD. Pipeline-run и артефакты сохраняются. Проверка: kubectl get mlpipelineruns -n ml-demo.

  • GPU мониторятся. DCGM-Exporter + Prometheus. Проверка: curl http://prometheus:9090/api/v1/query?query=nvidia_gpu_utilization.

  • Авто-скейлинг настроен. HPA/VPA настроены и работают. Проверка: kubectl get hpa -n ml-demokubectl describe hpa demo-model-hpa.

  • Canary-rollout активирован. KServe настроен на частичный трафик. Проверка: kubectl get inferenceservice demo-model -o yaml → поле canary.trafficPercent.

  • CI/CD автоматизирован. Пайплайн запускается автоматически при git push (Tekton/Argo).

  • Безопасность проверена. OPA-политики и секреты не попадают в логи. Проверка: opa test + kubectl logs … | grep SECRET.

  • Нагрузочные тесты пройдены. Locust/k6 подтверждают SLA < 200 ms на 99%. Проверка: k6 run load-test.js.

  • Rollback-план отработан. Откат работает без ошибок. Проверка: kubectl rollout undo inferenceservice/demo-model.

  • Документация актуализирована. Новый член команды может развернуть пайплайн за 30 минут, не тревожа старожилов проекта.

Что будет дальше

Serverless-ML. Knative и KEDA позволяют запускать инференс без постоянно работающих подов, экономя ресурсы и упрощая операции. Это особенно актуально для bursty-нагрузок и LLM-сервисов.

Multi-LLM-ops. Появляется потребность управлять множеством моделей (разные версии, разные поставщики), их совместимостью, политиками доступа и стоимостью. Ожидается рост инструментов для маршрутизации запросов, A/B-тестирования и контроля качества.

Edge-развертывания. Модели «уходят» ближе к данным: IoT, мобильные устройства, локальные узлы. Kubernetes-подходы адаптируются под ограниченные ресурсы, офлайн-режимы и строгие требования к задержке.

Вместо заключения

Мой честный совет: не пытайтесь построить «идеальный» пайплайн за один спринт. Начните с минимального жизнеспособного (MVP) — один шаг, один CRD, один кусочек в мониторинг. Затем, шаг за шагом добавляйте автоскейлинг, canary rollout, serverless-слой. В итоге получится не просто работающий ML-сервис, а инфраструктура, которая сама заботится о себе (понимаю, что звучит слишком, но мы к этому идем). Идея проста: чем раньше мы автоматизируем эти процессы, тем быстрее сможем сосредоточиться на том, что действительно имеет ценность. На новых идеях, экспериментах, на улучшении продукта. 

Если у вас есть свои практики, кейсы — добро пожаловать в комментарии, давайте делиться опытом, дискутировать и улучшать индустрию вместе.