Всем привет! Меня зовут Роза и я 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 Доставляет.