…или рассказ о self service на JupyterHub для дата саентистов
Всем привет, сегодня я расскажу о том, как мы переехали на наш велосипед в виде JupyterHub, и он оказался удобным. У нас в компании работают ~20 дата саентистов и в своей работе они используют множество Open Source-инструментов: Airflow, Hadoop, Hive, Spark и т.д. Но в данной статье речь пойдет исключительно о JupyterHub, точнее говоря о боли, которая преследовала администраторов, и как мы успешно ее побороли.
Почему мы выбрали JupyterHub
JupyterHub — это тот же Jupyter, только ставится он на отдельный сервер и работает как клиент-серверное веб-приложение.
Преимущества тут очевидны:
Вам не нужно беспокоиться об установке Jupyter’а и его окружения;
Не тратятся локальные ресурсы на вычисления;
Серверные мощности обычно выше локальных.
Но есть и недостатки:
Ресурсы сервера делятся на всех пользователей. По сути кто первый – того и тапки;
Одна среда на всех: вы будете пользоваться только тем ПО, которое установлено на сервере.
Обновление через боль. Установка нового ПО или обновление существующего требует согласования со всеми пользователями JupyterHub’а.
Просто представьте насколько задача усложнится, если на ваших серверах нет интернета. А политика безопасности настолько забюрократизирована, что процедура установки ПО будет съедать всё ваше время.
В игру вступает Kernel
Частично вышеописанные проблемы решаются с помощью kernel’ов — виртуальных сред (venv). Вы устанавливаете в них необходимые пакеты, затем переносите их на JupyterHub, после чего данное ядро становится доступным для выбора в интерфейсе лэптопа. А весь код, написанный на лэптопе, будет работать именно в этом окружении.
Но на практике оказалось, что kernel’ы оказались еще бо́льшей бедой: их также необходимо поддерживать и регулярно обновлять, плюс со временем они обрастали зависимостями и legacy-кодом. А до бесконечности создавать новые kernel’ы невозможно.
Все это вызывало негатив со всех сторон: дата саентисты не могли получить своевременный доступ к нужному ПО. А администраторам приходилось постоянно что-то досогласовывать, устанавливать и переустанавливать. Так продолжать мы не могли, поэтому мы решили оптимизировать работу дата саентистов.
Что придумали
Чтобы избавить саентистов (и админов) от боли, мы поставили перед собой следующие цели:
Установить/обновить любое ПО можно без привлечения администраторов;
Установить/обновить ПО можно в любой момент;
Установленное одним пользователем ПО не должно влиять на работу остальных пользователей;
Установленное ПО не должно негативно влиять на сервер в целом.
После исследования мы решили использовать связку Jupyterhub + Docker, а kernel’ы собирать в GitLab CICD, чтобы затем доставлять их на сервера Jupyterhub.
Схема работы
Схема работы следующая: для каждого пользователя в GitLab создана отдельная папка и, когда пользователю необходимо создать новый kernel, он:
Создает в своей папке новый проект (папку);
Создает файл requirements.json и описывает в нем:
2.1 Название kernel’а
2.2 Имя docker-образа (скачивается из DockerHub’а, либо с нашего локального репозитория, где хранятся наши кастомные образы).
2.3 Python-библиотеки для установки и их версии
В случае необходимости редактирует Dockerfile;
Запускает CICD-процесс, в котором:
4.1 Собирается ядро;
4.2 Выполняются команды из Dockerfile.
4.3 Устанавливаются библиотеки из requirements.json.
4.4 Ядро копируется на сервер JupyterHub.
Новое ядро сразу становится доступными для работы в JupyterHub’е. А в случае необходимости дата саентист самостоятельно правит параметры своего ядра и пересобирает его.
Что это нам дало
Теперь большая часть работы выполняется дата саентистами без привлечения администраторов. После 10 тысяч сборок kernel’ов мы сэкономили массу времени на процедурах согласований и самой установке.
Эффективность обоих сторон увеличилась, а админы привлекаются крайне редко —только для решения сложных вопросов. Цели 1 и 2 выполнены.
Доступ в интернет для скачивания библиотек мы реализовали посредством прокси-сервера, с которого разрешено обращаться только к репозиторию pip. Все ПО работает исключительно внутри контейнера. Что бы там не произошло — это никак не повлияет на работу других пользователей. Так мы закрыли вопрос с целями 3 и 4.
А теперь пара технических моментов:
Пример конфига ядра
kernel.json
{
"argv": [
"/usr/bin/docker",
"run",
"--network=host",
"--rm",
"-v",
"{connection_file}:/connection-spec",
"-v",
"/home/anikishin/work:/root/work",
"************/docker/registry/anikishin_dataflow:latest",
"python",
"-m",
"ipykernel_launcher",
"-f",
"/connection-spec"
],
"display_name": "anikishin_dataflow",
"language": "python",
"env": {}
}
Использование --network=host объясняется тем, что во время работы pyspark на машине открывается случайный порт и кластер Hadoop должен иметь доступ к клиенту.
Пример сборки ядра
$ LOGIN=`echo "${GITLAB_USER_LOGIN}" | awk '{print tolower($0)}'`
$ echo -e "export PATH_TO_KERNEL=/${LOGIN}/${KERNEL}\nLOGIN=${LOGIN}\nKERNEL=${KERNEL}" >.env
$ source ./.env
$ PYTHON_VERSION=`/bin/python3 ${CI_PROJECT_DIR}/parser.py ${CI_PROJECT_DIR}/${PATH_TO_KERNEL}/requirements.json python_version`
$ sed "s/PYTHON_VERSION/${PYTHON_VERSION}/g" ${CI_PROJECT_DIR}/${PATH_TO_KERNEL}/Dockerfile
FROM python:3.8-slim
WORKDIR /root/work
COPY requirements.txt /tmp/requirements.txt
RUN pip install --upgrade -r /tmp/requirements.txt$ sed -i "s/PYTHON_VERSION/${PYTHON_VERSION}/g" ${CI_PROJECT_DIR}/${PATH_TO_KERNEL}/Dockerfile
$ /bin/python3 ${CI_PROJECT_DIR}/parser.py ${CI_PROJECT_DIR}/${PATH_TO_KERNEL}/requirements.json libs > ${CI_PROJECT_DIR}/${PATH_TO_KERNEL}/requirements.txt
$ echo "IMAGE_NAME=`/bin/python3 ${CI_PROJECT_DIR}/parser.py ${CI_PROJECT_DIR}/${PATH_TO_KERNEL}/requirements.json image_name`" >> ./.env
$ echo "IMAGE_VERSION=`/bin/python3 ${CI_PROJECT_DIR}/parser.py ${CI_PROJECT_DIR}/${PATH_TO_KERNEL}/requirements.json image_version`" >> ./.env
$ source ./.env
$ docker build --no-cache -t ************:5005/docker/registry/${LOGIN}_${IMAGE_NAME}:${IMAGE_VERSION} ${CI_PROJECT_DIR}/${PATH_TO_KERNEL}/
Step 1/4 : FROM python:3.8-slim
3.8-slim: Pulling from library/python
42c077c10790: Already exists
f63e77b7563a: Pulling fs layer
5215613c2da8: Pulling fs layer
9ca2d4523a14: Pulling fs layer
e97cee5830c4: Pulling fs layer
e97cee5830c4: Waiting
9ca2d4523a14: Verifying Checksum
9ca2d4523a14: Download complete
f63e77b7563a: Verifying Checksum
f63e77b7563a: Download complete
5215613c2da8: Verifying Checksum
5215613c2da8: Download complete
f63e77b7563a: Pull complete
5215613c2da8: Pull complete
9ca2d4523a14: Pull complete
e97cee5830c4: Verifying Checksum
e97cee5830c4: Download complete
e97cee5830c4: Pull complete
Digest: sha256:0e07cc072353e6b10de910d8acffa020a42467112ae6610aa90d6a3c56a74911
Status: Downloaded newer image for python:3.8-slim
---> 61c56c60bb49
Step 2/4 : WORKDIR /root/work
---> Running in 4baf6a21fb37
Removing intermediate container 4baf6a21fb37
---> 0f5165f4c567
Step 3/4 : COPY requirements.txt /tmp/requirements.txt
---> 40490bed96d2
Step 4/4 : RUN pip install --upgrade -r /tmp/requirements.txt
---> Running in a79389decbc4
Collecting ipykernel
Downloading ipykernel-6.13.1-py3-none-any.whl (133 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 133.2/133.2 KB 1.4 MB/s eta 0:00:00
Collecting ipython
Downloading ipython-8.4.0-py3-none-any.whl (750 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 750.8/750.8 KB 7.4 MB/s eta 0:00:00
Collecting numpy
Downloading numpy-1.22.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (16.9 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 16.9/16.9 MB 50.1 MB/s eta 0:00:00
Collecting psutil
Downloading psutil-5.9.1-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl (284 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 284.7/284.7 KB 24.1 MB/s eta 0:00:00
Collecting tornado>=6.1
Downloading tornado-6.1-cp38-cp38-manylinux2010_x86_64.whl (427 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 427.5/427.5 KB 38.0 MB/s eta 0:00:00
Collecting packaging
Downloading packaging-21.3-py3-none-any.whl (40 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 40.8/40.8 KB 5.0 MB/s eta 0:00:00
Collecting matplotlib-inline>=0.1
Downloading matplotlib_inline-0.1.3-py3-none-any.whl (8.2 kB)
Collecting nest-asyncio
Downloading nest_asyncio-1.5.5-py3-none-any.whl (5.2 kB)
Collecting debugpy>=1.0
Downloading debugpy-1.6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl (1.8 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.8/1.8 MB 71.2 MB/s eta 0:00:00
Collecting traitlets>=5.1.0
Downloading traitlets-5.2.2.post1-py3-none-any.whl (106 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 106.8/106.8 KB 18.8 MB/s eta 0:00:00
Collecting jupyter-client>=6.1.12
Downloading jupyter_client-7.3.3-py3-none-any.whl (131 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 132.0/132.0 KB 18.9 MB/s eta 0:00:00
Collecting pygments>=2.4.0
Downloading Pygments-2.12.0-py3-none-any.whl (1.1 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.1/1.1 MB 68.7 MB/s eta 0:00:00
Collecting backcall
Downloading backcall-0.2.0-py2.py3-none-any.whl (11 kB)
Collecting pickleshare
Downloading pickleshare-0.7.5-py2.py3-none-any.whl (6.9 kB)
Collecting prompt-toolkit!=3.0.0,!=3.0.1,<3.1.0,>=2.0.0
Downloading prompt_toolkit-3.0.29-py3-none-any.whl (381 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 381.5/381.5 KB 41.8 MB/s eta 0:00:00
Collecting pexpect>4.3
Downloading pexpect-4.8.0-py2.py3-none-any.whl (59 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 59.0/59.0 KB 12.4 MB/s eta 0:00:00
Collecting decorator
Downloading decorator-5.1.1-py3-none-any.whl (9.1 kB)
Collecting jedi>=0.16
Downloading jedi-0.18.1-py2.py3-none-any.whl (1.6 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.6/1.6 MB 82.1 MB/s eta 0:00:00
Requirement already satisfied: setuptools>=18.5 in /usr/local/lib/python3.8/site-packages (from ipython->-r /tmp/requirements.txt (line 2)) (57.5.0)
Collecting stack-data
Downloading stack_data-0.2.0-py3-none-any.whl (21 kB)
Collecting parso<0.9.0,>=0.8.0
Downloading parso-0.8.3-py2.py3-none-any.whl (100 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100.8/100.8 KB 23.1 MB/s eta 0:00:00
Collecting python-dateutil>=2.8.2
Downloading python_dateutil-2.8.2-py2.py3-none-any.whl (247 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 247.7/247.7 KB 36.0 MB/s eta 0:00:00
Collecting jupyter-core>=4.9.2
Downloading jupyter_core-4.10.0-py3-none-any.whl (87 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 87.3/87.3 KB 21.7 MB/s eta 0:00:00
Collecting entrypoints
Downloading entrypoints-0.4-py3-none-any.whl (5.3 kB)
Collecting pyzmq>=23.0
Downloading pyzmq-23.1.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl (1.1 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.1/1.1 MB 68.4 MB/s eta 0:00:00
Collecting ptyprocess>=0.5
Downloading ptyprocess-0.7.0-py2.py3-none-any.whl (13 kB)
Collecting wcwidth
Downloading wcwidth-0.2.5-py2.py3-none-any.whl (30 kB)
Collecting pyparsing!=3.0.5,>=2.0.2
Downloading pyparsing-3.0.9-py3-none-any.whl (98 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 98.3/98.3 KB 19.7 MB/s eta 0:00:00
Collecting executing
Downloading executing-0.8.3-py2.py3-none-any.whl (16 kB)
Collecting asttokens
Downloading asttokens-2.0.5-py2.py3-none-any.whl (20 kB)
Collecting pure-eval
Downloading pure_eval-0.2.2-py3-none-any.whl (11 kB)
Collecting six>=1.5
Downloading six-1.16.0-py2.py3-none-any.whl (11 kB)
Installing collected packages: wcwidth, pure-eval, ptyprocess, pickleshare, executing, backcall, traitlets, tornado, six, pyzmq, pyparsing, pygments, psutil, prompt-toolkit, pexpect, parso, numpy, nest-asyncio, entrypoints, decorator, debugpy, python-dateutil, packaging, matplotlib-inline, jupyter-core, jedi, asttokens, stack-data, jupyter-client, ipython, ipykernel
Successfully installed asttokens-2.0.5 backcall-0.2.0 debugpy-1.6.0 decorator-5.1.1 entrypoints-0.4 executing-0.8.3 ipykernel-6.13.1 ipython-8.4.0 jedi-0.18.1 jupyter-client-7.3.3 jupyter-core-4.10.0 matplotlib-inline-0.1.3 nest-asyncio-1.5.5 numpy-1.22.4 packaging-21.3 parso-0.8.3 pexpect-4.8.0 pickleshare-0.7.5 prompt-toolkit-3.0.29 psutil-5.9.1 ptyprocess-0.7.0 pure-eval-0.2.2 pygments-2.12.0 pyparsing-3.0.9 python-dateutil-2.8.2 pyzmq-23.1.0 six-1.16.0 stack-data-0.2.0 tornado-6.1 traitlets-5.2.2.post1 wcwidth-0.2.5
WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
WARNING: You are using pip version 22.0.4; however, version 22.1.2 is available.
You should consider upgrading via the '/usr/local/bin/python -m pip install --upgrade pip' command.
Removing intermediate container a79389decbc4
---> 942cfb92669c
Successfully built 942cfb92669c
Successfully tagged ************:5005/docker/registry/anikishin_proj3:latest
$ docker push ************:5005/docker/registry/${LOGIN}_${IMAGE_NAME}:${IMAGE_VERSION}
The push refers to repository [************:5005/docker/registry/anikishin_proj3]
ca238036b879: Preparing
5083b2b128f1: Preparing
92487648c84b: Preparing
9df5b2f53554: Preparing
590db2877d9d: Preparing
3d5419adeeb6: Preparing
2c9f341968bc: Preparing
ad6562704f37: Preparing
2c9f341968bc: Waiting
3d5419adeeb6: Waiting
ad6562704f37: Waiting
92487648c84b: Pushed
5083b2b128f1: Pushed
590db2877d9d: Pushed
2c9f341968bc: Pushed
9df5b2f53554: Pushed
3d5419adeeb6: Pushed
ca238036b879: Pushed
latest: digest: sha256:bc36a9bcc6be914a9b7f8ee6ea6c940409f32c57a528c521651442235309239a size: 1996
Running after_script
00:00
Saving cache
00:00
Uploading artifacts for successful job
00:00
Uploading artifacts...
Runtime platform arch=amd64 os=linux pid=27359 revision=c5874a4b version=12.10.2
./.env: found 1 matching files
Uploading artifacts to coordinator... ok id=38302 responseStatus=201 Created token=************
Job succeeded
Какие проблемы могут возникнуть
У нас все получилось не сразу. По пути возникали проблемы, из-за которых мы не смогли быстро перейти на новую схему:
1. Нет онбординга
Понадобилось некоторое время на обучение дата саентистов работе с докер-образами;
Изначально у коллег не было понимания, что данные в контейнере не сохранятся если их не писать в специальную директорию. Плюс ваши дата саентисты должны знать, что простого pip install может оказаться недостаточно: в контейнере должны быть установлены дополнительные зависимости, если этого требует Python-модуль.
Решение: сделайте инструкцию, проводите онбординг новых сотрудников, помогайте в случае проблем со сборкой кернелов.
2. Сохранность данных
Поскольку kernel’ы работают в докер-образах, то их перезагрузка приводит к потере всех сохраненных данных.
Решение: создайте отдельную общую папку на сервере, которая монтируется к докер-образу и в которую сохраняются все необходимые дата саентисту артефакты.
3. Ограничение ресурсов
Без лимитов и рычагов один дата саентист может навалить такую нагрузку, что другие не смогут нормально работать:
Это один контейнер с kernel.
И так может случиться не один раз
Решение: мониторьте нагрузку по CPU\RAM. Когда получаете алерт определяйте кто грузит машину и идите наказывать виновника попросите коллегу сбавить обороты.
4. Версионность
Как мы пришли к тегу #latest: изначально мы планировали версионировать все создаваемые докер-образы. Но дата саентисты стали делать такое множество версий своих образов, что в результате место в Docker registry быстро закончилось.
Решение: используйте версионность только для продуктивных процессов.
Планы на развитие
Резюмируя: мы получили современное решение, дата саентисты больше не дергают админов, они рады что могут самостоятельно править свои kernel в любое время, не прибегая к помощи со стороны.
Сейчас мы думаем о том, как реализовать удобное и гибкое ограничение ресурсов для контейнеров. Если у вас есть идеи как это сделать — напишите в комментариях.