
В последнее время я часто работал с разными ML-проектами в GitLab. В каждом был свой .gitlab-ci.yml, своя обвязка вокруг MLFlow, своя регистрация и валидация модели. Со временем я понял, что MLOps-пайплайн во всех проектах очень похож, а при работе с новыми копипаста размножается быстрее кроликов. Ну и тут уже хочешь не хочешь, но идея сделать общий шаблон напрашивается. Однако будем честны, обычный template для CI — это круто, но хочется чего-то гибкого, декларативного и красивого. Для достижения этих целей GitLab уже давно предлагает переходить на CI/CD компоненты. В результате я хотел видеть 10 строк YAML, которые будут выдавать полноценный пайплайн с валидацией данных, обучением, quality gates и регистрацией модели.
И спустя месяц я добился желаемого. В этой статье покажу, как устроен компонент, на какие грабли наступал по пути, и как подключить всё это в ваш проект.
Проблема: копипаста между ML-проектами
Я думаю, этот сценарий знаком многим. Создаёте свой первый ML-проект, пишете CI, который включает:
подготовку данных,
обучение модели с логированием в MLflow,
проверку метрик,
регистрацию в Model Registry.
Выглядит отлично, всё работает, и проект дальше развивается, но спустя время появляется новый. Вы копируете конфиг, меняете пути, подправляете пороги. Потом ещё один, Вы повторяете то же самое, и так пока клавиша ctrl не сотрётся в ноль. Где-то на пятом проекте Вы уже не помните, в каком именно конфиге до этого чинили баг с передачей MLFLOW_RUN_ID между джобами.
Чтобы понять масштаб боли, вот как выглядел типичный .gitlab-ci.yml до компонента (сокращённо, но идею передаёт):
stages: [validate, train, evaluate, register] validate-data: stage: validate image: python:3.12 script: - pip install pandas great_expectations - python scripts/validate.py --data data/train.csv --check-nulls --threshold 0.05 artifacts: paths: [validation_report.json] train-model: stage: train image: python:3.12 variables: MLFLOW_TRACKING_URI: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/ml/mlflow" script: - pip install mlflow scikit-learn pandas - python scripts/train.py --data data/train.csv - echo "MLFLOW_RUN_ID=$(cat run_id.txt)" >> train.env artifacts: reports: dotenv: train.env paths: [model/, metrics.json] evaluate-model: stage: evaluate image: python:3.12 needs: [{job: train-model, artifacts: true}] variables: MLFLOW_TRACKING_URI: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/ml/mlflow" script: - pip install mlflow - python scripts/evaluate.py --run-id $MLFLOW_RUN_ID --threshold 0.85 - echo "EVAL_PASSED=$(cat eval_result.txt)" >> evaluate.env artifacts: reports: dotenv: evaluate.env register-model: stage: register image: python:3.12 needs: [{job: train-model, artifacts: true}, {job: evaluate-model, artifacts: true}] rules: - if: $EVAL_PASSED == "true" variables: MLFLOW_TRACKING_URI: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/ml/mlflow" script: - pip install mlflow - python scripts/register.py --run-id $MLFLOW_RUN_ID --model-name my-model
Выглядит как отличный инкубатор для багов, переходящих из проекта в проект. А ведь здесь ещё не включены DVC, retry, кэширование pip, обработка ошибок и т. п., которые сделают наш конфиг ещё более громоздким. В реальности каждый проект наращивал свои костыли, и к каждому .gitlab-ci.yml нужно было дописывать парочку вспомогательных скриптов, каждый из которых имел собственную реализацию auto_configure_mlflow, свой парсинг аргументов, свои баги.
Я хотел сделать всё красиво, поэтому очевидным выбором стали CI/CD Components, добавленные в GitLab с версии 17.0. Компоненты — это переиспользуемые шаблоны, но с версионированием и гибким управлением через входящие аргументы (inputs). И мне кажется, что конечный результат стоил приложенных усилий.
10 строк, которые заменяют всё
Для начала предлагаю взглянуть на результат — .gitlab-ci.yml проекта, который использует MLOps компонент:
stages: [validate, train, evaluate, register] include: - component: gitlab.com/netOpyr/gitlab-mlops-component/full-pipeline@1.0.0 inputs: model_name: wine-classifier training_script: scripts/train.py training_args: '--data data/train.csv --test-data data/test.csv' data_path: data/train.csv framework: sklearn metric_name: accuracy min_threshold: '0.85'
И из этих 10 строк мы получаем 4 джобы:
validate ──> train ──> evaluate ──> register │ │ │ │ schema MLflow accuracy Model Registry nulls autolog >= 0.85 (если eval прошёл) drift метрики vs prod
Магия? Нет, только набор шаблонов и Python скрипт, аккуратно упакованный в Docker. GitLab сам при парсинге .gitlab-ci.yml разворачивает шаблоны, а скрипт делает всю грязную работу.
Насущные scripts/validate.py, scripts/evaluate.py, scripts/register.py и всё, что с ними связано, теперь живут в компоненте. Вам остаётся только самое интересное — скрипт обучения.
Что происходит на каждом шаге
validate — проверка данных до обучения
Самая недооценённая часть ML-пайплайна. Я неоднократно запускал обучение на битых данных и после 20 минут ожидания получал NaN в метриках. Validate же отловит все проблемы до начала обучения.
В первую очередь проверяется схема, нас интересует, все ли колонки на месте. Для работы достаточно указать expected_columns: 'feature1,feature2,target' при добавлении компонента. И теперь пайплайн упадёт на этапе проверки с понятным сообщением, если кто-то поменял название столбца.
Далее смотрим на пустые значения. Здесь скрипт считает долю null в каждом столбце и сравнивает с порогом, который по умолчанию задан как 5%. Если условие не выполнено, то, как и для остальных ошибок, будет чёткое сообщение в логах.
Для проектов, в которых нужно следить за дрейфом, есть интеграция с Evidently. Для работы включаем фичу enable_drift: true и указываем путь к референсным данным. После сравнения validate подготовит HTML-отчёт и зафейлит джобу, если дрейф выше заданного порога.
Также компонент поддерживает работу с Great Expectations. Достаточно указать expectation_suite: path/to/suite.json для кастомных правил. Если же нужно ещё более гибкое описание правил проверок, то Python-скрипт с функцией run_checks(df) отлично подтянется компонентом custom_expectations_script: scripts/custom_checks.py. Скрипт загрузит модуль, вызовет run_checks() с DataFrame и включит результаты в общий отчёт. Пример:
def run_checks(df): results = [] # Проверяем, что целевая переменная содержит ровно 3 класса unique_targets = set(df["target"].unique()) results.append({ "name": "target_classes", "passed": unique_targets == {0, 1, 2}, "message": f"Expected {{0,1,2}}, got {unique_targets}" }) # Проверяем диапазон значений out_of_range = ((df["alcohol"] < 5) | (df["alcohol"] > 20)).sum() results.append({ "name": "alcohol_range", "passed": out_of_range == 0, "message": f"{out_of_range} values out of range" }) return results
В результате, если что-то пойдёт не так, то пайплайн упадёт сразу, сэкономив Ваши нервы и время.
train — обучение с автотрекингом
Самая интересная часть! Здесь компонент оборачивает Ваш скрипт обучения в MLFlow-сессию.
Предлагаю чуть подробнее рассмотреть логику работы этого этапа. Компонент поддерживает интеграцию с MLFlow, интегрированным в GitLab, поэтому компонент начинает с вытягивания MLFLOW_TRACKING_URI из переменных CI, далее создаёт эксперимент и run для него, также включает автологинг для Вашего фреймворка (sklearn, PyTorch, TensorFlow, XGBoost, LightGBM), чтобы уменьшить возможное взаимодействие с MLFlow API.
Следующий этап — запуск вашего скрипта обучения. Компонент передаёт переменную MLFLOW_RUN_ID через переменную окружения, по ней Вы можете логировать, что пожелаете, в результате всё попадёт в один MLFlow run. После отработки переменные MLFLOW_RUN_ID и MLFLOW_EXPERIMENT_ID запишутся в dotenv-артефакт для downstream-джоб, метрики сохранятся в metrics.txt, потом GitLab отобразит их прямо в Merge Request.
Для того чтобы при работе скрипт не сыпал шумными MLFlow-предупреждениями и получил MLFLOW_RUN_ID как подпроцесс, запуск происходит через обёртку на runpy.run_path(). Ваш скрипт при этом видит себя как main.
При этом никакого особого API от Вашего скрипта не требуется, это обычный Python-файл, который подхватывает MLFLOW_RUN_ID из окружения и логирует в run. Пример взаимодействия с компонентом Вашего скрипта:
run_id = os.getenv("MLFLOW_RUN_ID") if run_id: with mlflow.start_run(run_id=run_id): mlflow.log_param("n_estimators", 200) mlflow.log_metric("accuracy", acc) mlflow.sklearn.log_model(pipeline, "model")
Но если Вам достаточно метрик, которые автолог подхватит сам, то явное логирование можно не указывать. Например, для sklearn autolog залогирует все гиперпараметры, метрики кросс-валидации и даже модель.
Также из приятного, выше упоминал, что компонент на этом этапе генерирует metrics.txt в формате GitLab Metrics Reports. И при открытии Merge Request метрики отобразятся рядом с тестами. На мой взгляд, очень приятная мелочь, когда хочется понять, что именно поменялось в модели.
evaluate — quality gates
Важный этап, включающий решение, является ли модель достойной прода. Компонент берёт MLFLOW_RUN_ID из артефактов train, подтягивает метрики из MLFlow и прогоняет их через абсолютный и относительный пороги.
Ворота 1: абсолютный порог. Сравниваем метрики с заданными пороговыми значениями. Например, если условие accuracy >= 0.85 не выполнилось, то модель не будет зарегистрирована. Также для loss метрик в inputs можно найти параметр higher_is_better: false, который позволяет инвертировать проверку и заменить >= на <=.
Ворота 2: сравнение с продом (опционально). Для подключения необходимо указать compare_with_production: true. Компонент ищет модель с алиасом champion или production в Model Registry, тянет её метрики из MLFlow и считает в процентах, насколько новая модель лучше. И если улучшение меньше заданного порога improvement_threshold, то модель до прода не дойдёт.
В результате работы evaluate этапа мы получаем:
MLOPS_EVALUATION_PASSED=true/falseв dotenv,MLOPS_CURRENT_METRIC— текущее значение метрики,evaluation_report.json— полный отчёт с деталями по каждому gate,metrics.txt.
register — регистрация в Model Registry
Если evaluate прошёл успешно, то модель регистрируется в GitLab Model Registry. При регистрации подтягиваются метаданные: алиас (staging по умолчанию), commit SHA, pipeline ID, метрики из MLflow. Также если MLFlow API недоступен, вы всё равно увидите accuracy прямо в Model Registry, так как метрики дублируются в теги версии.
Алиасы в данном случае выступают в качестве указателей на одну из версий staging, champion, production. Компонент поддерживает все планы GitLab от Free до Ultimate. На Premium/Ultimate компонент стучится в set_registered_model_alias(), а если это Free версия, то вызов просто тихо зафейлится, сохранив информацию в тегах.
Под капотом
Отлично, основной воркфлоу понятен. Но как оно работает изнутри? Три апостола компонента:
CI/CD Components:
Как уже говорилось выше, компоненты — это шаблоны на стероидах. Компонент состоит из spec: (входные параметры) и тела (определение джоб). Работа с входными параметрами идёт через $[[ inputs.x ]]. Важно, что inputs подставляют значения на этапе парсинга пайплайна, они не являются shell-переменными.
Пример spec: для train компонента:
spec: inputs: training_script: type: string description: 'Path to the Python training script.' framework: default: 'auto' options: ['auto', 'sklearn', 'pytorch', 'tensorflow', 'xgboost', 'lightgbm', 'none'] image_suffix: default: 'sklearn' options: ['sklearn', 'pytorch', 'tensorflow', 'boosting', 'pytorch-gpu', 'tensorflow-gpu']
Здесь options: — механизм валидации на уровне GitLab.
dotenv-артефакты:
Механизм GitLab, который позволяет передавать переменные между джобами. Например, train создаёт файл mlops_train.env:
MLFLOW_RUN_ID=abc123def456 MLFLOW_EXPERIMENT_ID=42 MLOPS_MODEL_NAME=wine-classifier
Далее GitLab тянет этот файл и добавляет переменные в downstream-джобы. В результате evaluate видит MLFLOW_RUN_ID без лишних усилий с нашей стороны.
Итоговый датафлоу:
train → mlops_train.env (MLFLOW_RUN_ID) → evaluate → mlops_evaluate.env (MLOPS_EVALUATION_PASSED) → register
Python CLI (gitlab-mlops):
Общий скрипт, включающий в себя все 4 этапа, как отдельные подкоманды: validate, train, evaluate, register. Как уже упоминалось выше, большим плюс является самостоятельность скрипта, он сам настраивает MLflow tracking URI из CI-переменных GitLab:
def auto_configure_mlflow(): if not os.getenv("MLFLOW_TRACKING_URI"): api_url = os.getenv("CI_API_V4_URL", "") project_id = os.getenv("CI_PROJECT_ID", "") if api_url and project_id: uri = f"{api_url}/projects/{project_id}/ml/mlflow" os.environ["MLFLOW_TRACKING_URI"] = uri
Если же MLFLOW_TRACKING_URI уже определён в переменных окружения, то скрипт оставит всё как есть. Важно отметить, что логика работы с токеном отличается: для начала необходимо создать access токен в проекте и выдать ему права на работу с api, далее занести в CI/CD-переменные MLOPS_ACCESS_TOKEN, во время работы скрипт его подтянет.
Пример: от нуля до работающего пайплайна
Пришло время перейти от теории к практике. Соберём простой проект-пример и настроим для него компонент. Обучать будем бессмертной классике — классификация вин на три сорта. В качестве фреймворка выступает sklearn.
Полный проект: gitlab.com/netOpyr/mlops-component-example
Структура проекта
mlops-component-example/ ├── .gitlab-ci.yml # пайплайн (компонент + prepare job) ├── scripts/ │ ├── prepare_data.py # генерация train/test CSV из sklearn │ └── train.py # RandomForest + MLflow логирование └── data/
Не красота ли? Всего три файла, ничего лишнего. Всё необходимое теперь живёт внутри компонента.
Скрипт обучения
# Строим пайплайн pipeline = Pipeline([ ("scaler", StandardScaler()), ("clf", RandomForestClassifier( n_estimators=args.n_estimators, max_depth=args.max_depth, random_state=42, )), ]) # Кросс-валидация cv_scores = cross_val_score(pipeline, X_train, y_train, cv=5) print(f"CV Accuracy: {cv_scores.mean():.4f} +/- {cv_scores.std():.4f}") pipeline.fit(X_train, y_train) # Логируем в MLflow (run_id пришёл от компонента) run_id = os.getenv("MLFLOW_RUN_ID") if run_id: with mlflow.start_run(run_id=run_id): mlflow.log_param("n_estimators", args.n_estimators) mlflow.log_param("max_depth", args.max_depth) mlflow.log_metric("accuracy", cv_scores.mean()) mlflow.sklearn.log_model(pipeline, "model")
Важный момент: здесь нет ничего специфичного, что заставит Вас пользоваться компонентом, и не позволит слезть с него. Это обычный Python скрипт, переменная MLFLOW_RUN_ID — единственное, что связывает его с компонентом. Этот скрипт также отлично отработает хоть локально, хоть в Jupyter.
Конфиг пайплайна
stages: [prepare, validate, train, evaluate, register] # Генерация данных prepare-data: stage: prepare image: python:3.12-slim script: - pip install pandas scikit-learn --quiet - python scripts/prepare_data.py artifacts: paths: [data/] # Остальные этапы выполняет компонент include: - component: $CI_SERVER_FQDN/netOpyr/gitlab-mlops-component/full-pipeline@1.0.0 inputs: model_name: wine-classifier training_script: scripts/train.py training_args: '--data data/train.csv --test-data data/test.csv' data_path: data/train.csv framework: sklearn metric_name: accuracy min_threshold: '0.85'
Здесь от нас требуется только подготовить данные для обучения, закинуть их в csv и потом в артефакты. Всё остальное компонент сделает сам.
Что происходит при пуше
prepare-dataсоздаётdata/train.csvиdata/test.csv,validateпроверяетdata/train.csv,trainзапускаетscripts/train.py, логирует всё в MLflow,evaluateберёт accuracy из MLflow, сравнивает с порогом 0.85,Если accuracy >= 0.85, то
registerсоздаёт версию в Model Registry.
После успешного пайплайна метрики можно будет найти в Analyze > Model experiments.
Можете форкнуть этот проект и попробовать сами. Главное, как я уже говорил выше, не забудьте создать access токен с доступом к api и добавить его в CI/CD переменную MLOPS_ACCESS_TOKEN.
DVC: данные под контролем
При прочтении у вас, скорее всего, возник вопрос, а что делать, если данные хранятся в S3. На этот случай я добавил интеграцию с dvc:
include: - component: .../train@1.0.0 inputs: training_script: scripts/train.py model_name: my-model dvc_enabled: true dvc_remote: minio dvc_files: 'data/train.csv.dvc data/test.csv.dvc' dvc_push: true dvc_push_paths: 'model/'
С подключённым dvc компонент перед обучением ставил dvc через pip и тянет данные. Далее валидация, обучение, а после dvc add model/ и dvc push , в результате обученная модель отправляется обратно.
Креды настраиваются через CI/CD переменные: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT_URL.
Единственный нюанс — dvc каждый раз ставится заново, так как не всем он нужен, а весит прилично. Поэтому при необходимости вы можете собрать свой образ и указать его при включении компонента.
Когда 10 строк мало: отдельные компоненты
«Пайплайн на 10 строк» звучит круто и выглядит красиво, но когда проект становится больше и более требовательным к MLOps, вы можете перейти на раздельное подключение джоб:
stages: [validate, train, evaluate, register] include: - component: .../validate@1.0.0 inputs: data_path: data/train.csv check_nulls: true null_threshold: '0.03' enable_drift: true reference_data_path: data/reference.csv drift_threshold: '0.1' - component: .../train@1.0.0 inputs: training_script: scripts/train.py model_name: my-model image_suffix: pytorch-gpu framework: pytorch tags: ["gpu"] - component: .../evaluate@1.0.0 inputs: model_name: my-model metric_name: val_loss min_threshold: '0.1' higher_is_better: false image_suffix: pytorch-gpu - component: .../register@1.0.0 inputs: model_name: my-model alias: staging
Здесь уже поинтереснее. Используется GPU-раннер, есть проверка на дрейф и loss метрики. В итоге получаем отличный конструктор, с которым каждый может играть, как пожелает.
А зачем такие усложнения? Возможно, Вы захотите добавить условий на выполнение конкретных этапов или указать отдельный GPU-раннер для train, где-то, может, нужны свои образы. Гибкая настройка в данном случае позволит вам выжать всё по максимуму из компонента.
Несколько моделей в одном пайплайне
Также нельзя пройти мимо возможности параллельного обучения на основе компонента. Используя параметр as, вы можете дать уникальные имена джобам и прогонять сразу несколько моделей:
include: # RandomForest - component: .../train@1.0.0 inputs: as: train-rf training_script: train_rf.py model_name: model-rf - component: .../evaluate@1.0.0 inputs: as: eval-rf model_name: model-rf min_threshold: '0.85' needs_job: train-rf # XGBoost - component: .../train@1.0.0 inputs: as: train-xgb training_script: train_xgb.py model_name: model-xgb image_suffix: boosting framework: xgboost - component: .../evaluate@1.0.0 inputs: as: eval-xgb model_name: model-xgb min_threshold: '0.85' image_suffix: boosting needs_job: train-xgb
Docker-образы и фреймворки
Здесь я выделил отдельный образ под каждый фреймворк. Фреймворк выбирается через image_suffix:
Суффикс |
Фреймворки |
GPU |
|---|---|---|
|
scikit-learn, matplotlib |
Нет |
|
XGBoost, LightGBM, scikit-learn |
Нет |
|
PyTorch (CPU) |
Нет |
|
PyTorch + CUDA 12.4 |
Да |
|
TensorFlow (CPU) |
Нет |
|
TensorFlow + CUDA 12.4 |
Да |
Все образы включают: Python 3.12, MLflow, pandas и скрипт. Собирал всё слоями: base → фреймворк. Если Вам нужны дополнительные зависимости, то можете либо указать requirements_file: requirements.txt, тогда перед началом работы всё необходимое подтянется, либо просто собрать свой образ и указать его в переменной image_registry_base.
Как попробовать
Тут могу предложить вам 2 пути:
Форкнуть пример mlops-component-example, который я разбирал выше, и поиграться с ним.
Добавить в свой проект. Тут вам нужно будет положить в директорию scripts ваш скрипт обучения и, следуя README компонента, настроить его под себя.
Что дальше
Я опубликовал компонент в GitLab CI/CD Catalog, так что подключайте его по тегу latest и не забудьте о реализации MLOps-пайплайна. Если нашли баг или не хватает функциональности, буду ждать ваши issue или MR.
В ближайшем будущем хочу добавить: сборку с BuiltKit, retry для нестабильных MLFlow-запросов и GitLab Environments.
Ссылки
Компонент: gitlab.com/netOpyr/gitlab-mlops-component
GitLab CI/CD Components: docs.gitlab.com/ci/components
GitLab Model Registry: docs.gitlab.com/ee/user/project/ml/model_registry
© 2026 ООО «МТ ФИНАНС»