Всем привет! Меня зовут Роза и я MLOps-инженер. В этой статье расскажу, как построить CI/CD-пайплайн для ML-приложений с нуля, поэтапно и без боли. Ну почти :)

Я работаю в Купере — сервисе доставки из магазинов и ресторанов, где занимаюсь разработкой ML-платформы. Наши ML-сервисы проникают во все бизнес-процессы работы приложения, начиная от рекомендации бананов в корзине и заканчивая прогнозом того, через сколько приедет ваш курьер.

Немного цифр: у нас 9 ML-команд, 78 сервисов, 41 человек и почти четыре сотни DAG’ов в Airflow, которые ежедневно и даже ежеминутно переобучают ML-модели.

Раньше очень часто работа DS-инженера заканчивалась на подготовке кода модели в Jupyter-ноутбуке, а дальше его подхватывали команды разработки и доводили до продакшена. У такого подхода есть минусы. Например, если произойдёт инцидент, непонятно кто ответственен за сервис  — команда разработки или авторы ML-модели?

К счастью, культура разработки меняется: теперь ML-инженер — это специалист, который разрабатывает свой ML-сервис на всем пути от общения с бизнесом до продакшена. Этот подход хорошо описывает принцип «you build it, you run it»: кто построил модель, тот её и запускает. Как раз в этом здорово помогает CI/CD.

С чего начинаем

Все пайплайны и оптимизации ниже описаны для GitLab CI/CD, но их  достаточно легко перенести на другие фреймворки типа Jenkins. Для экспериментов были доступны 48 GitLab раннеров (4 CPU, 8Gb RAM каждый). В качестве сборщика зависимостей был выбран poetry из-за его гибкости и функциональности. Также все образы собираются с официального образа Python для репрезентативности, но на практике обычно в зеркалах компании собирают обогащённый образ, где не только Python, но и poetry, и всякие удобные тулкиты для работы.  

├── my_project
│   ├── cli
│   │   ├── ...
│   ├── my_module
│   │   ├── ...
│   ├── my_second_module
│   │   ├── __init__.py
│   │   └── print_hello.py
│   └── __init__.py
├── notebooks
│   └── my_report.ipynb
├── tests
│   └── __init__.py
├── .dockerignore
├── .gitignore
├── .gitlab-ci.yml
├── Dockerfile
├── Makefile
├── README.md
├── lint.toml
├── poetry.lock
└── pyproject.toml

Для примера возьмём типичный ML-проект. В нем есть отдельные директории для исходного кода, тестов и Jupyter-ноутбуков. Из важных «системных» файлов можно отметить .gitlab-ci.yaml — в нем как раз будет описан наш CI/CD, а также Dockerfile для сборки образа и Makefile (к нему вернёмся ниже). В файлах poetry.lock и pyproject.toml описаны зависимости проекта.

Что касается зависимостей, соберём небольшой набор из популярных ML-библиотек.

[tool.poetry.dependencies]
 python = "^3.11"
 catboost = "^1.2.5"
 click = "8.1.7"
 clickhouse-driver = "^0.2.7"
 clickhouse-sqlalchemy = "^0.3.1"
 lightgbm = "^4.3.0"
 loguru = "0.7.2"
 numpy = "^1.26.4"
 pandas = "^2.2.2"
 polars = "^0.20.25"
 prophet = "^1.1.5"
 protobuf = "^5.26.1"
 pyarrow = "^16.0.0"
 pymysql = "^1.1.0"
 requests = "^2.31.0"
 scikit-learn = "^1.4.2"
 sqlalchemy = "^2.0.30»

 # Tests and Linters
 jupyter = "1.0.0"
 mypy = "1.7.1"
 pytest = "7.4.3"
 pytest-cov = "4.1.0"

Базовый пайплайн, с которого мы начнём, выглядит просто и минималистично. Сначала собирается образ, затем в нем запускаются линтеры и тесты и дальше происходит деплой:

В образе копируем код из раннера, указываем poetry создать окружение в текущей директории. Далее устанавливаем сам poetry и затем уже зависимости проекта. Команда в CMD вызывает напрямую скрипт из проекта.

FROM your/company/hub/python:3.11

ENV POETRY_VIRTUALENVS_CREATE=true \
    POETRY_VIRTUALENVS_IN_PROJECT=true
WORKDIR /app
COPY . .

RUN pip install --no-cache-dir poetry==1.8.1 \
    && poetry install --no-root

CMD [ "poetry", "run", "python", "my_project/my_second_module/print_hello.py" ]

Проблема #1: ML-инженер ждёт вечность, пока соберётся пайплайн

Первая же проблема, с которой сталкивается ML-инженер в описанном выше простеньком пайплайне — это время его работы. Оно составляет 4 минуты 25 секунд.

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

Как будем измерять успех?

Чтобы понимать, насколько успешно идет оптимизация, нужны метрики. Для начала это скорость (наши 4,5 минуты) и вес образов (около 3 ГБ), для которого стоит сразу обозначить нижнюю границу. 

Если базовый образ Python (не с тегом slim) весит около 1 ГБ и зависимости в распакованном виде весят около 1,2 ГБ, то предел, до которого можно обезжирить образ — это где-то 2,2 ГБ. На эту нижнюю границу и будем ориентироваться.

Но вернёмся к основной цели — бусту скорости, и попробуем ускорить сборку зависимостей. 

Решение

Python-окружение всегда состоит из двух частей: это само приложение и его зависимости:

Код при этом меняется очень часто (его исправляет и коммитит разработчик), а зависимости пересобираются очень редко. Что можно сделать с тем, что переиспользуется очень часто, но меняется очень редко? Конечно, кэшировать!

В GitLab CI/CD есть удобная фича из коробки — оператор cache:, который позволяет заархивировать папку с кэшем в zip в каком-нибудь хранилище типа S3 бакета, после чего просто передавать этот архив другим джобам в пайплайне (подробнее можно прочитать тут). При этом можно задать набор файлов, изменение которых инвалидирует кэш. Давайте попробуем воспользоваться этим оператором и добавим новую джобу кэширования зависимостей перед сборкой образа: 

build:deps:
   image: your/company/hub/python:3.11
   stage: build
   script:
 	- pip install --no-cache-dir poetry==1.8.1
 	- poetry config virtualenvs.in-project true
 	- poetry install --no-root
   cache:
 	key:
   	files:
      - "poetry.lock"
      - "pyproject.toml"	
	paths:
   	  - .venv/
   needs: [ ]

Фактически в джобе делается всё то же самое, что раньше мы делали в докер-образе. Мы все так же говорим рoetry сделать окружение внутри папки и установить зависимости. Здесь важно отметить, что инвалидация кэша происходит тогда, когда мы меняем pyproject.toml или poetry.lock, то есть те места, в которых эти зависимости описываются.

FROM your/company/hub/python:3.11

WORKDIR /app

COPY . .

CMD [ ".venv/bin/python", 
      "my_project/my_second_module/print_hello.py" ]

Как изменится сборка образа? Теперь она станет совсем минималистичной: -  в ней останется только базовый образ Python и копирование кода. Однако мы помним, что в папке .venv в самой директории с кодом находится Python-окружение с зависимостями, поэтому COPY . . нам ещё и окружение заодно скопирует в образ. И вместо того, чтобы запускать скрипт через poetry run для CMD, мы будем делать это напрямую через ванильный Python, так как poetry в образе уже не будет. 

Что это даст? На логе этой джобы видно, что при попытке установки зависимостей ничего не устанавливается, потому что кэш уже подключён к окружению, то есть все зависимости уже находятся в окружении. Профит!

Вот так будет выглядеть пайплайн с новой джобой кэширования зависимостей:

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

За счёт того, что теперь зависимости кэшируются, а линтеры и тесты запускаются параллельно сборке, время сократилось до 3 минут 40 секунд. Причём, несмотря на то, что целью оптимизации была скорость, в процессе получилось также уменьшить вес образов до той самой нижней границы в 2 ГБ. Это произошло, потому что в образе теперь остались только наше окружение и исходный код, когда раньше там хранился кэш poetry, сам poetry и еще кэш pip.

Проблема #2: хотим, чтобы контейнер с кодом был ридонли

Зачем вообще нужны иммутабельные контейнеры? Чтобы код в нем всегда совпадал с кодом в репозитории и чтобы никто не смог зайти и поменять этот код в рантайме через простой exec

Решение

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

Для этого нам нужно перейти к мультистайдийной сборке, где в первой стадии собирается сам whl через poetry build, а во второй — он устанавливается в образ.  

FROM your/company/hub/python:3.11 AS builder

WORKDIR /app
COPY pyproject.toml .
COPY my_project my_project

RUN pip install --no-cache-dir poetry==1.8.1 \
 	&& poetry build

FROM your/company/hub/python:3.11 AS app-image

WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"

COPY .venv .venv
COPY --from=builder /app/dist /app/dist

RUN python -m pip install --no-deps -v dist/*.whl \
  	&& rm -rf dist

CMD [ "my_project", "--help" ]

poetry build сохранит whl в директории ./dist (по дефолту), который потом копируется через COPY --from в финальный образ. А дальше необходимо просто установить его через старый-добрый pip в уже существующее окружение из прошлого шага. Не забываем удалить ./dist, чтобы в образе не было лишнего.

Важно отметить, что здесь явно копируется .venv (кэш с зависимостями) с раннера — это необходимо, так как мы теперь не копируем всю директорию с проектом (через COPY . .).

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

По метрикам у нас появился новый критерий — это безопасность. Сделали контейнер ридонли. Что интересно — сократилось время пайплайна, до 2,5 минут! Это значительно, если сравнивать его с предыдущей версией и тем более с пятью минутами на старте. Что же так заметно ускорило сборку? 

Все дело в том самом явном копировании COPY .venv .venv. Теперь мы копируем только конкретную директорию, а как мы помним, меняется она очень редко. За счёт этого при последующих коммитах Docker закэширует этот слой в образе, и наш гигабайт зависимостей будет реально копироваться только тогда, когда они поменяются. Отсюда такая значительная экономия времени.

Проблема #3: ИБ просит удалить Jupyter в продакшен-образе

Теперь давайте посмотрим на наш CI/CD с другой стороны. В какой-то момент к вам приходит ИБ (информационная безопасность) и требует убрать Jupyter из зависимостей.

Казалось бы, что плохого в Jupyter? Все мы любим и знаем его, но если чуть внимательнее посмотреть, то окажется, что этот метапакет очень давно не обновлялся (аж с 2015 года на момент статьи, хотя уже вышла более свежая версия) и тянет за собой уязвимости. Поэтому Jupyter для сканеров безопасности — как красная тряпка для быка. 

Однако «голый» Jupyter (тот самый метапакет jupyter) скорее всего почти никто не использует: есть просто Jupyter Notebook (notebook), Jupyter Lab (jupyterlab) и кластерный Jupyter Hub (jupyterhub). Мы долго пытались понять, откуда в зависимостях протекает именно этот пакет. И оказалось, что у VS Code (IDE, которой у нас в компании пользуются почти все ML-щики) есть плагин, который упрощает работу с Jupyter, и именно он тянет этот метапакет.

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

Кто-то может спросить: а зачем вообще нужен Jupyter? Он позволяет запускать интерактивную среду для экспериментов, при этом часто с ним устанавливают какие-то дополнительные плагины, например, библиотеку tqdm. Ещё ML-инженеры очень любят рисовать графики, и, конечно же, логировать всё это в MLflow. А объединяет все эти действия то, что они относятся не к продакшену, а к этапу разработки.

Поэтому на самом деле проблема шире! Она звучит так — в продакшн образе не должно быть ничего лишнего. Не должно быть тех фреймворков и тулкитов, которые нужны именно для работы на стадии экспериментов. 

Решение

Что мы можем с этим сделать? Поделить наши зависимости на основные и для разработки, а ещё отдельно собирать тестовый образ. 

На самом деле группировать зависимости достаточно просто: в poetry эта фича идёт из коробки

…                                      …       
[tool.poetry.dependencies]             [tool.poetry.dependencies]                             
python = "^3.11"                       python = "^3.11"                   
catboost = "^1.2.5"                    catboost = "^1.2.5"                      
click = "8.1.7"                        click = "8.1.7"                  
…                             ==>      …    
                                          
# Tests and Linters                    [tool.poetry.group.dev.dependencies]                      
jupyter = "1.0.0"                      jupyter = "1.0.0"                    
mypy = "1.7.1"                         mypy = "1.7.1"                 
pytest = "7.4.3"                       pytest = "7.4.3"                   
pytest-cov = "4.1.0”                   pytest-cov = "4.1.0”                       
…                                      …    

Есть основные зависимости (main), и мы сделаем ещё одну группу и обозначим её dev для зависимостей на этапе разработки. Теперь можем ставить их обе по отдельности. В соответствии с группами также удваивается и количество кэшей: первый только для основных зависимостей, второй — для всех зависимостей (и основных, и dev).

build:deps:main:
  image: your/company/hub/python:3.11
  stage: build
  script:
    - …
    - poetry install --no-root --only main
  cache:
    key:
      prefix: main
      files:
        - "poetry.lock"
        - "pyproject.toml"
    paths:
      - .venv/


build:deps:dev:
  image: your/company/hub/python:3.11
  stage: build
  script:
    - …
    - poetry install --no-root
  cache:
    key:
      prefix: dev
      files:
        - "poetry.lock"
        - "pyproject.toml"
    paths:
      - .venv/

Заодно добавим префиксы в название архива (строки 9 и 25), чтобы можно было видеть и различать, какой из них мы подкладываем в следующие джобы.

Снова смотрим на пайплайн: в процесс добавилась одна параллельная джоба со сборкой dev-зависимостей.

В итоге мы избавились от уязвимых пакетов, потому что теперь кэш с dev-зависимостями подкладывается только в линтеры и тесты, где он нужен. 

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

Например, у нашей команды был кейс, когда мы эту фичу активно использовали при построении платформенного шаблона ML-сервиса. Были созданы четыре группы. Первые две — это как раз те зависимости, которые идут от платформы и которые пользователь не должен трогать. Они всегда должны быть, чтобы сервис работал. И аналогично есть две группы зависимостей для самих пользователей — в них он может прописывать нужные ему библиотеки, которые не покрываются платформой. Очень удобно!

Все ещё проблема #3: что делать, если нужен тестовый образ?

До этого мы сделали только кэш, который существовал исключительно в рамках пайплайнов в CI/CD. Теперь давайте разберёмся, что делать, если нам всё-таки нужен дополнительный образ с dev-зависимостями. 

Это может быть необходимо, например, когда пользователь хочет сверху дополнительные слои в образе. У нас был кейс, когда платформой поставляется базовый образ с минимально необходимым сервису тулкитом, и при этом есть возможность пользователям добавить свои слои, например, с какими-то фреймворками для дебага. 

Решение

Что мы можем с этим всем сделать? Добавляем просто сборку ещё одного образа, ровно такого же, который у нас уже есть, с тем же Dockerfile, но с одним отличием: мы передаём ему не main-зависимости, а dev и main. Таким образом, мы делаем «тестовый» образ, в котором есть основное окружение и ещё dev-зависимости.

По метрикам у нас ничего не поменяется, так как мы добавляем только параллельную джобу в пайплайн (скорее здесь все упирается в производительность вашего Container Registry):

В итоге, у нас все ещё один Dockerfile, но с него билдится два образа. Теперь попробуем оптимизировать общую часть, а именно сборку whl, чтобы не выполнять её два раза. Вынесем её в отдельную джобу и облегчим немного наш докер: эта сборка будет запускаться только один раз. 

Чуть-чуть схитрим и вынесем сборку whl в уже существующую джобу сборки кэша зависимостей. Более правильно сделать отдельную джобу, но таким образом мы сэкономим время на накладные расходы при выполнении. 

build:deps:main:
   image: your/company/hub/python:3.11
   stage: build
   script:
 	- …
 	- poetry build  # собирает whl в dist/
   cache:
 	key:
   	prefix: main
   	files:
     	- "poetry.lock"
     	- "pyproject.toml"
 	paths:
   	- .venv/
  artifacts:
 	paths:
   	- dist/*.whl

Для этого воспользуемся оператором artifact: в Gitlab, который позволит нам выгрузить whl на сервер Gitlab и аналогично кэшу передать его в следующие джобы (подробнее можно почитать тут). 

Таким образом наш Dockerfile стал короче:

ARG PYTHON_VERSION="3.11"
FROM your/company/hub/python:3.11

WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
ENV VIRTUAL_ENV="/app/.venv"

COPY .venv .venv
COPY dist dist

RUN python -m pip install --no-deps -v dist/*.whl \
    && rm -rf dist

CMD [ "my_project", "--help" ]

В нем изчезла первая стадия, и вместо нее мы копируем с раннера директорию ./dist, в которой лежит наш артефакт в виде whl пакета.

Внесла ли эта оптимизация изменения? 

На самом деле небольшие — всего на 15 секунд уменьшили наше время. Однако мы получили кастомный образ для тестирования, плюс возможность гибко собирать и настраивать больше дополнительных образов. Также мы добились, что наш whl собирается для всех образов всего один раз. 

Проблема #4: несколько ML-инженеров обновляют версию в своих MR (или не обновляют)

Очень часто случается так, что несколько ML-инженеров, работая в одном проекте, в своих мердж-реквестах меняют версию в pyproject.toml. И также часто случается так, что они эту версию не меняют. Почему и то, и то – проблема?

Без обновления версии приложение никак не версионируется, в нем всегда одна и та же версия, несмотря на то, что оно изменяется. А если версию команда всё-таки обновляет – это делается руками. Такой подход приводит к тому, что в реквестах происходят конфликты, которые требуют ручного вмешательства. Другими словами: если один инженер сумел свой реквест задеплоить, то второму придётся этот локальный конфликт как-то решать. 

А усугубляет проблему то, что у нас есть на самом деле несколько версий: 

  • версия в pyproject.toml

  • версия в самом репозитории (git tag)

  • версия приложения, с которой оно публикуется в репозиторий (PyPi или внутреннее зеркало типа Nexus)

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

Решение

В этом нам поможет опен-сорс проект gitlab-semantic-versioning. Это достаточно простой Python-скрипт, который позволяет автоматически обновлять git тег проекта согласно semver нотации. Разработчик проставляет в реквесте соответствующий лейбл версии, которую он хочет обновить (major, minor или patch), а скрипт берёт текущий тег, обновляет его и пушит в репозиторий.

Таким образом мы решили две проблемы из трёх: теперь версии обновляются автоматически. Осталось git тег пролить в pyproject.toml. Решение этой проблемы автоматически прольет нам эту же версию и в Nexus, потому что poetry при публикации как раз использует версию из pyproject.toml.

Как это сделать?

Сначала добавим основную джобу с бампом версии: возьмём готовый образ с указанными выше скриптом и сохраним полученный git тег в виде артефакта (оператор dotenv:, подробнее тут). Теперь добавим вторую джобу, которая проливает этот тег в poetry через poetry version и заодно коммитит это изменение в репозиторий.

version:bump:
  stage: version
  image: your/company/hub/gitlab-semantic-versioning:1.1.0
  script:
    - printf "GIT_TAG=" > bump.env
    - python3 /version-update/version-update.py >> bump.env
   …
   artifacts:
    reports:
      dotenv: bump.env

version:publish:
  stage: version
  image: your/company/hub/git/image:latest
  script:
    - ...
    - git checkout -b $CI_DEFAULT_BRANCH
    - poetry version -vv "$GIT_TAG"
    - git add -A
    - git commit -m "Version $GIT_TAG"
    - git push origin
  needs:
    - "version:bump"  # takes $GIT_TAG from artifact

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

Проблема #5: ML-инженер не хочет ждать CI/CD, чтобы прогнать линтеры и тесты

Мы настроили супер-пупер CI/CD пайплайн, в котором оптимизировали самые узкие бутылочные горлышки и добавили много фичей. Давайте теперь вернёмся к нашему ML-инженеру. 

Стандартный флоу его работы состоит в том, что он пишет какой-то код, пушит его в репозитории и с тревогой смотрит на пайплайн этого CI/CD, не окрасилось ли там что-то красным. Если всё зелёное, значит, всё супер и можно спокойно выкатывать это на ревью и деплоить свой реквест. 

Но если что-то окрасилось красным, значит что-то упало и нужно править код. А это опять пушить, опять ждать 2–3 минуты, опять смотреть, снова править, пушить, ждать, и так по кругу, много раз, пока все не будет зелёным. 

Две-три минуты — это терпимо, если проблема решается сразу. Но когда ошибка сложная и фидбек на исправления в коде приходится ждать несколько раз, процесс ожидания может здорово утомлять. 

Решение

Что можно сделать? Можно и дальше пробовать оптимизировать пайплайны, но хочется, чтобы инженер мог ещё до коммита понимать, проходит ли его код линтеры и тесты. Этого можно добиться, добавив в репозиторий старый-добрый Makefile.

.EXPORT_ALL_VARIABLES:

show-version:
     poetry version
tests:
     echo "Run tests."
     poetry run pytest tests
mypy:
     echo "Run mypy checks."
     poetry run mypy --config-file ../lint.toml

lint: mypy  ## Start all linters

Обычно Makefile — это что-то из мира C/C++. Но и здесь его очень удобно применять, потому что он решает несколько проблем. 

Первое — в нём мы можем имитировать весь CI/CD. Если прописать в нем линтеры, тесты, сборку нужного нам окружения, чтобы проверить какую-то фичу, то все эти команды будут абсолютно идентичны нашему CI/CD, то есть они будут запускаться локально. 

Вторая проблема, которую решает Makefile — унификация локальной работы с ML-приложением. Если раньше каждая команда могла придумать свой велосипед, а потом в ступоре сказать: «Ой, у меня локально всё работает, а что-то все пайплайны красные, помогите!», то с Makefile такой проблемы нет. Все используют один и тот же Makefile, в нём запускаются какие-то стандартные линтеры (для всех одинаковые), всё становится более унифицированным и приятным для поддержки. 

Ну и неочевидная плюшка: если к вам в команду приходит новичок, ему не нужно шерстить Confluence и README проектов, искать команды в попытках запустить приложение. Все это в удобной форме хранится в Makefile.

Ну и финальные результаты наших метрик:

А что насчёт деплоя?

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

Чтобы обучать модель на регулярной основе, ML-инженер должен прогнать свои в даги в Airflow или аналогичном оркестраторе. Также ему часто нужно распределённо посчитать какой-нибудь большой датасет или сделать сэмпл данных, например, через Spark или Trino. Или даже запустить свои dbt-модели для обработки данных. Ну и конечно для воспроизводимости нужно логировать свои эксперименты с моделями в MLflow. А если это онлайн-сервис, то добавляется получение фичей из online фичастора.

Решение

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

Мы пошли по пути стейджинг-окружения on demand, где под on demand я подразумеваю какую-нибудь зелёную кнопку в GitLab, которую ML-инженер нажимает, и у него в Kubernetes разворачивается неймспейс, в котором есть все эти инструменты. 

Таким образом весь флоу его работы от и до поднимается в рамках одного неймспейса: поднимается маленький Airflow, к нему рядом Jupyter, чтобы он мог легко и быстро данные считать, тут же Spark или spark-operator, MLflow для экспериментов. И в этом же неймспейсе и само приложение.

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

Такой подход ведет к тому, что когда мы выкатываем сервис на продакшн, ML-инженер уверен, что его код в идентичной среде будет работать ожидаемо. Конечно, не всегда можно построить окружение, один в один повторяющее условия продакшена. Однако по возможности нужно повторить большую часть инфраструктурных элементов, чтобы в будущем было меньше головной боли с тем, как же проверить, почему локально все летает, а в продакшене падает.

Итого 

Коротко подведём итог и зафиксируем решения, которые мы исследовали в статье. 

  • собирать зависимости только тогда, когда они меняются;

  • ставить python-приложение через whl;

  • делать продакшен-образ чистым и легковесным;

  • делать сквозное автоматическое версионирование;

  • дать возможность ML-инженерам работать локально as is в CI/CD;

  • дать возможность ML-инженерам на всех этапах разработки быстро тестировать свою работу.

Все эти «рецепты» наша команда нашла и сформулировала «в бою», и будем надеяться, что они помогут кому-то еще облегчить жизнь ML-инженерам :)

Псс, подписывайся на tg-канал ML-команды Купера ML Доставляет.

Комментарии (4)


  1. neshkeev
    26.09.2024 10:35
    +2

    Отличная статья, Роза, настройка пайплайнов для MLOps - достаточно творческая задача, полезно почерпнуть интересные подходы из твоего решения, которые, что очень ценно, были опробованы на практике.


    1. rosemorozenkova Автор
      26.09.2024 10:35
      +1

      Спасибо!)


  1. NAI
    26.09.2024 10:35

    Хорошая статья.

    Единственное, непонятно, почему линты не проводить до билда, нулевым стейджом? Вы же проверяете _только_ свой код, чужие зависимости не нужны. Контейнер с pylint\flake8 статичен\редко обновляется. Инженеры сразу получают результат.

    Ну и всякие gitleaks тоже обычно в нулевой стейдж пихают, чтобы контейнер с паролем не упал в реджестри


    1. rosemorozenkova Автор
      26.09.2024 10:35
      +2

      Спасибо!)

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

      • некоторым линтерам нужно окружение проекта (типа mypy)

      • в линтерах у нас так же запускается часть сканеров безопасности, которым тоже нужны зависимости

      В итоге линтеры мы стали поставлять вместе с окружением сервиса. То есть они описаны в платформенных дев-зависимостях в pyproject.toml проекта и обновляются платформой у каждого отдельного проекта.

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