
Ваша 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. Проверка:
curlhttp://prometheus:9090/api/v1/query?query=nvidia_gpu_utilization.Авто-скейлинг настроен. HPA/VPA настроены и работают. Проверка:
kubectl get hpa -n ml-demo→kubectl 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-сервис, а инфраструктура, которая сама заботится о себе (понимаю, что звучит слишком, но мы к этому идем). Идея проста: чем раньше мы автоматизируем эти процессы, тем быстрее сможем сосредоточиться на том, что действительно имеет ценность. На новых идеях, экспериментах, на улучшении продукта.
Если у вас есть свои практики, кейсы — добро пожаловать в комментарии, давайте делиться опытом, дискутировать и улучшать индустрию вместе.
