Как выкатить ML-модель в продакшен: от ноутбука Jupyter до API

Как выкатить ML-модель в продакшен: от ноутбука Jupyter до API

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

Разберу путь от Jupyter до работающего API на учебном примере: классификатор на scikit-learn, Docker, Flask и Kubernetes.

От Jupyter до Python-модуля

В Jupyter удобно исследовать: загрузить данные, обучить модель, посмотреть метрики. Но для продакшена код нужно вынести из ячеек в обычный Python-файл.

# model.py
import joblib
from sklearn.ensemble import RandomForestClassifier

def train_model(X_train, y_train):
    clf = RandomForestClassifier(n_estimators=100, random_state=42)
    clf.fit(X_train, y_train)
    return clf

def save_model(model, path):
    joblib.dump(model, path)

def load_model(path):
    return joblib.load(path)

Обязательно зафиксируйте версии всех зависимостей:

pip freeze > requirements.txt

Это важно. Если в requirements.txt указан scikit-learn без версии, через месяц поставится новая, и сохранённая модель может просто не загрузиться или начать вести себя иначе.

Обучение отдельно, сервис отдельно

Модель лучше обучать отдельным шагом (скриптом или в CI) и сохранять в файл, а в образ класть уже готовый model.pkl. Если обучать модель прямо при сборке образа, каждая правка кода будет запускать обучение заново, а результат станет трудно воспроизвести.

# train.py
import pandas as pd
from model import train_model, save_model

X = pd.read_csv('train_data/X.csv')
y = pd.read_csv('train_data/y.csv').values.ravel()
save_model(train_model(X, y), 'model.pkl')

REST API вокруг модели

Чтобы модель отвечала на HTTP-запросы, оборачиваем её в небольшой сервис:

# api.py
import logging

import pandas as pd
from flask import Flask, request, jsonify
from model import load_model

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

app = Flask(__name__)
model = load_model('model.pkl')

@app.route('/predict', methods=['POST'])
def predict():
    try:
        df = pd.DataFrame([request.get_json()])
        result = model.predict(df)
        logger.info('prediction=%s', result[0])
        return jsonify({'prediction': int(result[0])})
    except Exception:
        logger.exception('predict failed')
        return jsonify({'error': 'bad input'}), 400

@app.route('/health')
def health():
    return jsonify({'status': 'ok'})

Адрес /health нужен, чтобы Kubernetes мог проверять, жив ли сервис. В логах — результат и ошибки. Сами входные данные целиком в лог лучше не писать: в них могут оказаться персональные данные.

Упаковка в Docker

Docker упаковывает код, модель и все зависимости в один образ, и сервис везде работает одинаково. Встроенный сервер Flask подходит только для разработки, поэтому запускаем через gunicorn (добавьте его в requirements.txt).

FROM python:3.12-slim
WORKDIR /app

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

COPY model.py api.py model.pkl ./

USER nobody
EXPOSE 5000
CMD ["gunicorn", "-w", "2", "-b", "0.0.0.0:5000", "api:app"]

Образ собирается слоями. Пока requirements.txt не меняется, Docker берёт установленные библиотеки из кэша, и пересборка после правки кода или новой модели занимает секунды.

Kubernetes: проверки и плавное обновление

Локально хватает docker run. В Kubernetes модель работает в поде среди других сервисов, и платформе нужно объяснить:

  • когда под готов принимать запросы (readinessProbe);
  • когда сервис завис и его пора перезапустить (livenessProbe);
  • как дать ему спокойно доработать при обновлении (preStop и terminationGracePeriodSeconds).
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ml-model
spec:
  replicas: 2
  selector:
    matchLabels:
      app: ml-model
  template:
    metadata:
      labels:
        app: ml-model
    spec:
      terminationGracePeriodSeconds: 30
      containers:
      - name: model
        image: ml-model:1.0
        ports:
        - containerPort: 5000
        readinessProbe:
          httpGet:
            path: /health
            port: 5000
          initialDelaySeconds: 5
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /health
            port: 5000
          initialDelaySeconds: 15
          periodSeconds: 20
        resources:
          requests:
            memory: "512Mi"
            cpu: "500m"
          limits:
            memory: "1Gi"
        lifecycle:
          preStop:
            exec:
              command: ["/bin/sh", "-c", "sleep 5"]

Что здесь происходит:

  • readinessProbe — Kubernetes проверяет /health каждые 10 секунд. Если под не ответит 3 раза подряд (значение по умолчанию), на него перестанут отправлять запросы.
  • livenessProbe — если сервис завис и долго не отвечает, Kubernetes перезапустит контейнер.
  • preStop со sleep 5 — при остановке пода Kubernetes сначала убирает его из балансировки, но на это нужно пару секунд. Пауза даёт время, чтобы новые запросы перестали приходить, а текущие успели завершиться. Потом контейнер получает сигнал на завершение. Если он не остановился за terminationGracePeriodSeconds (отсчёт идёт с начала остановки, вместе с паузой), его завершают принудительно.

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

Учёт версий в MLflow

Когда моделей и экспериментов становится много, вести версии вручную тяжело. Здесь помогает MLflow: он хранит историю запусков, параметры, метрики и сами модели.

import mlflow
from model import train_model

with mlflow.start_run():
    mlflow.log_param('n_estimators', 100)
    clf = train_model(X_train, y_train)

    mlflow.log_metric('train_accuracy', clf.score(X_train, y_train))
    mlflow.log_metric('val_accuracy', clf.score(X_val, y_val))

    mlflow.sklearn.log_model(clf, name='model')

Потом нужную модель можно загрузить прямо из MLflow по идентификатору запуска:

model = mlflow.sklearn.load_model('runs:/<run_id>/model')

У себя я развернул MLflow с PostgreSQL на учебной платформе. Так видно, какая комбинация параметров работает лучше, где метрика просела и какую модель выкатывать.

Проверка перед выкаткой

Прежде чем отправлять модель в Kubernetes, проверьте:

  1. Данные. Входные данные в продакшене отличаются от обучающих: другие форматы, пропуски, новые значения. Об этом часто забывают, и модель выдаёт чепуху.
  2. Ошибки ввода. Если API получит данные в неправильном формате, он должен вернуть понятную ошибку, а не упасть.
  3. Скорость. На мощном ноутбуке предсказание занимает 0,1 секунды, а на скромном сервере может занять секунду и больше. Хватит ли ресурсов на нужное число запросов?
  4. Логи. Какие запросы приходили и какие были ошибки. Без логов остаётся только гадать.

Когда модель ломается

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

Самое быстрое решение — откат:

kubectl rollout undo deployment/ml-model

Kubernetes вернёт предыдущую версию образа, а вместе с ней и предыдущую модель, ведь она лежит внутри образа.

Чтобы заметить проблему вовремя, стоит собирать метрики в Prometheus: число запросов, долю ошибок и время ответа. Если ошибок стало больше, это сразу видно на графике и приходит алерт.

Итоги

Путь от Jupyter к продакшену: Python-модуль → отдельное обучение → API → Docker → Kubernetes. На каждом шаге свои подводные камни:

  • не зафиксировали версии в requirements.txt — модель не загрузится или поведёт себя иначе;
  • нет проверок /health — Kubernetes не узнает, что сервис завис;
  • нет preStop — запросы обрываются при обновлении;
  • нет логов и метрик — непонятно, почему модель ошибается.

Когда задача сложнее одного классификатора, добавляются MLflow для версий, Prometheus для метрик и отдельное хранилище для больших моделей.

Нужна помощь с выкаткой модели или инфраструктурой для MLOps — напишите, обсудим.

Нужен Telegram-бот для вашего дела?

Расскажите задачу — отвечу в течение дня, предложу решение и точную цену.

Написать в Telegram