Что позволено Jupyter?

    Наша история началась с, казалось бы, несложной задачи. Нужно было настроить аналитические инструменты для data science специалистов и просто аналитиков данных. С таким заданием к нам обратились коллеги из подразделений розничных рисков и CRM, где исторически высока концентрация data science-специалистов. У заказчиков было простое желание —  писать код на Python, импортировать продвинутые библиотеки (xgboost, pytorch, tensorflow и пр.) и запускать алгоритмы на данных, поднятых с hdfs-кластера.



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

    Для начала немного подробностей про исходную инфраструктуру:

    • Хранилище данных HDFS (12 узлов Oracle Big Data Appliance, дистрибутив Cloudera). Всего в хранилище 130 Тб данных из различных внутренних систем банка, есть также разнородная информация из внешних источников.
    • Два сервера приложений, на которых предполагалось развертывание аналитических инструментов. Стоит упомянуть, что на этих серверах «крутятся» не только задачи продвинутой аналитики, поэтому одним из требований было использование средств контейнеризации (Docker) для управления ресурсами серверов, использования различных окружений и их настройки.

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

    Казалось бы, все просто: надо взять и настроить связку Python+Anaconda+Spark. Установить Jupyter Hub на сервере приложений, осуществить интеграцию с LDAP, подключить Spark или подконнектиться к данным в hdfs каким-либо другим способом и вперед – строить модели!
    Если углубиться во все исходные данные и требования, то вот более подробный список:

    • Запуск JupyterHub в Docker (базовая ОС – Oracle Linux 7)
    • Кластер Cloudera CDH 5.15.1 + Spark 2.3.0 с Kerberos-аутентификацией в конфигурации Active Directory + выделенный MIT Kerberos в кластере (см. Cluster-Dedicated MIT KDC with Active Directory), ОС Oracle Linux 6
    • Интеграция с Active Directory
    • Прозрачная аутентификация в Hadoop и Spark
    • Поддержка Python 2 и 3
    • Spark 1 и 2 (с возможностью использования ресурсов кластера для обучения моделей и распараллеливания обработки данных с помощью pyspark)
    • Возможность ограничивать ресурсы хост-машины
    • Набор библиотек

    Этот пост рассчитан на ИТ-специалистов, столкнувшихся в своей работе с необходимостью решать подобные задачи.

    Описание решения


    Запуск в Docker + интеграция с кластером Cloudera


    Здесь нет ничего необычного. В контейнере установлены JupyterHub и клиенты продуктов Cloudera (как – см. ниже), а конфигурационные файлы подмонтированы с хост-машины:

    start-hub.sh

    VOLUMES="-v/var/run/docker.sock:/var/run/docker.sock:Z 
    -v/var/lib/pbis/.lsassd:/var/lib/pbis/.lsassd:Z -v/var/lib/pbis/.netlogond:/var/lib/pbis/.netlogond:Z 
    -v/var/jupyterhub/home:/home/BANK/:Z -v/u00/:/u00/:Z -v/tmp:/host/tmp:Z 
    -v${CONFIG_DIR}/krb5.conf:/etc/krb5.conf:ro 
    -v${CONFIG_DIR}/hadoop/:/etc/hadoop/conf.cloudera.yarn/:ro 
    -v${CONFIG_DIR}/spark/:/etc/spark/conf.cloudera.spark_on_yarn/:ro 
    -v${CONFIG_DIR}/spark2/:/etc/spark2/conf.cloudera.spark2_on_yarn/:ro 
    -v${CONFIG_DIR}/jupyterhub/:/etc/jupyterhub/:ro"
    
    docker run -p0.0.0.0:8000:8000/tcp ${VOLUMES} -e VOLUMES="${VOLUMES}" 
    -e HOST_HOSTNAME=`hostname -f` dsai1.2


    Интеграция с Active Directory


    Для интеграции с Active Directory / Kerberos железных и не очень хостов стандартом в нашей компании является продукт PBIS Open. Технически данный продукт представляет собой набор сервисов, общающихся с Active Directory, с которыми, в свою очередь, через unix domain сокеты работают клиенты. Данный продукт интегрируется с Linux PAM и NSS.

    Мы применили стандартный для Docker прием – unix domain сокеты сервисов хоста были примонтированы в контейнер (сокеты были найдены опытным путем нехитрыми манипуляциями командой lsof):

    start-hub.sh

    VOLUMES="-v/var/run/docker.sock:/var/run/docker.sock:Z 
    -v/var/lib/pbis/.lsassd:/var/lib/pbis/.lsassd:Z <b>-v/var/lib/pbis/.netlogond:/var/lib/pbis/.netlogond:Z -v/var/jupyterhub/home:/home/BANK/:Z -v/u00/:/u00/:Z -v/tmp:/host/tmp:Z 
    -v${CONFIG_DIR}/krb5.conf:/etc/krb5.conf:ro </b>
    -v${CONFIG_DIR}/hadoop/:/etc/hadoop/conf.cloudera.yarn/:ro 
    -v${CONFIG_DIR}/spark/:/etc/spark/conf.cloudera.spark_on_yarn/:ro 
    -v${CONFIG_DIR}/spark2/:/etc/spark2/conf.cloudera.spark2_on_yarn/:ro 
    -v${CONFIG_DIR}/jupyterhub/:/etc/jupyterhub/:ro"
    docker run -p0.0.0.0:8000:8000/tcp ${VOLUMES} -e VOLUMES="${VOLUMES}" 
    -e HOST_HOSTNAME=`hostname -f` dsai1.2
    

    В свою очередь, внутрь контейнера устанавливаются пакеты PBIS, но без выполнения postinstall секции. Так мы ставим только исполняемые файлы и библиотеки, но не запускаем сервисы внутри контейнера — для нас это лишнее. Команды интеграции с PAM и NSS Linux запускаются вручную.

    Dockerfile:

    # Install PAM itself and standard PAM configuration packages.
    RUN yum install -y pam util-linux \
    # Here we just download PBIS RPM packages then install them omitting scripts.
    # We don't need scripts since they start PBIS services, which are not used - we connect to the host services instead.
    && find /var/yum/localrepo/ -type f -name 'pbis-open*.rpm' | xargs rpm -ivh --noscripts \
    # Enable PBIS PAM integration.
    && domainjoin-cli configure --enable pam \
    # Make pam_loginuid.so module optional (Docker requirement) and add pam_mkhomedir.so to have home directories created automatically.
    && mv /etc/pam.d/login /tmp \
    && awk '{ if ($1 == "session" && $2 == "required" && $3 == "pam_loginuid.so") { print "session optional pam_loginuid.so"; print "session required pam_mkhomedir.so skel=/etc/skel/ umask=0022";} else { print $0; } }' /tmp/login > /etc/pam.d/login \
    && rm /tmp/login \
    # Enable PBIS nss integration.
    && domainjoin-cli configure --enable nsswitch
    

    Получается, что клиенты PBIS контейнера общаются с сервисами PBIS хоста. В JupyterHub применяется PAM-аутентификатор, и при правильно настроенном PBIS на хосте все работает «из коробки».

    Чтобы не пускать в JupyterHub всех пользователей из AD, можно воспользоваться настройкой, ограничивающей пользователей конкретными AD группами.

    config-example/jupyterhub/jupyterhub_config.py

    c.DSAIAuthenticator.group_whitelist = ['COMPANY\\domain^users']
    

    Прозрачная аутентификация в Hadoop и Spark


    При логине в JupyterHub PBIS кэширует Kerberos-тикет пользователя в определенном файле в каталоге /tmp. Для прозрачной аутентификации таким образом достаточно примонтировать каталог /tmp хоста в контейнер и установить переменную KRB5CCNAME в нужное значение (это делается в нашем классе аутентификатора).

    start-hub.sh

    VOLUMES="-v/var/run/docker.sock:/var/run/docker.sock:Z 
    -v/var/lib/pbis/.lsassd:/var/lib/pbis/.lsassd:Z -v/var/lib/pbis/.netlogond:/var/lib/pbis/.netlogond:Z 
    -v/var/jupyterhub/home:/home/BANK/:Z -v/u00/:/u00/:Z -v/tmp:/host/tmp:Z
    -v${CONFIG_DIR}/krb5.conf:/etc/krb5.conf:ro 
    -v${CONFIG_DIR}/hadoop/:/etc/hadoop/conf.cloudera.yarn/:ro 
    -v${CONFIG_DIR}/spark/:/etc/spark/conf.cloudera.spark_on_yarn/:ro 
    -v${CONFIG_DIR}/spark2/:/etc/spark2/conf.cloudera.spark2_on_yarn/:ro 
    -v${CONFIG_DIR}/jupyterhub/:/etc/jupyterhub/:ro"
    docker run -p0.0.0.0:8000:8000/tcp ${VOLUMES} -e VOLUMES="${VOLUMES}" 
    -e HOST_HOSTNAME=`hostname -f` dsai1.2

    assets/jupyterhub/dsai.py

    env['KRB5CCNAME'] = '/host/tmp/krb5cc_%d' %
    pwd.getpwnam(self.user.name).pw_uid

    Благодаря приведенному выше коду пользователь JupyterHub может выполнять команды hdfs из терминала Jupyter и запускать Spark job'ы без дополнительных действий для аутентификации. Монтировать весь каталог /tmp хоста в контейнер небезопасно — эту проблему мы осознаем, но ее решение пока еще в проработке.

    Версии Python 2 и 3


    Здесь, казалось бы, все просто: нужно поставить необходимые версии Python и сынтегрировать их с Jupyter, создав необходимые Kernel. Этот вопрос уже много где освещен. Для управления окружениями Python используется Conda. Почему вся простота лишь кажущаяся, будет ясно из следующего раздела. Пример Kernel для Python 3.6 (этого файла нет в git – все kernel-файлы генерируются кодом):

    /opt/cloudera/parcels/Anaconda-5.3.1-dsai1.0/envs/python3.6.6/share/jupyter/kernels/python3.6.6/kernel.json

    {
        "argv": [      "/opt/cloudera/parcels/Anaconda-5.3.1-dsai1.0/envs/python3.6.6/bin/python",
            "-m",
            "ipykernel_launcher",
            "-f",
           "{connection_file}"
        ],
        "display_name": "Python 3",
        "language": "python"
    }
    

    Spark 1 и 2


    Для интеграции с клиентами SPARK также необходимо создать Kernel’и. Пример Kernel для Python 3.6 и SPARK 2.

    /opt/cloudera/parcels/Anaconda-5.3.1-dsai1.0/envs/python3.6.6/share/jupyter/kernels/python3.6.6-pyspark2/kernel.json

    {
        "argv": [
            "/opt/cloudera/parcels/Anaconda-5.3.1-dsai1.0/envs/python3.6.6/bin/python",
            "-m",
            "ipykernel_launcher",
            "-f",
           "{connection_file}"
        ],
        "display_name": "Python 3 + PySpark 2",
        "language": "python",
        "env": {
            "JAVA_HOME": "/usr/java/default/",
            "SPARK_HOME": "/opt/cloudera/parcels/SPARK2/lib/spark2/",
            "PYTHONSTARTUP": "/opt/cloudera/parcels/SPARK2/lib/spark2/python/pyspark/shell.py",
            "PYTHONPATH": "/opt/cloudera/parcels/SPARK2/lib/spark2/python/:/opt/cloudera/parcels/SPARK2/lib/spark2/python/lib/py4j-0.10.7-src.zip",
            "PYSPARK_PYTHON": "/opt/cloudera/parcels/Anaconda-5.3.1-dsai1.0/envs/python3.6.6/bin/python"
        }
    }
    

    Сразу отметим, что требование иметь поддержку Spark 1 сложилось исторически. Однако, возможно, кто-то столкнется с похожими ограничениями — нельзя, например, установить Spark 2 в кластер. Поэтому опишем здесь подводные камни, которые встретились нам на пути реализации.
    Во-первых, Spark 1.6.1 не работает с Python 3.6. Что интересно, в CDH 5.12.1 это исправили, а вот в 5.15.1 — почему-то нет). Сначала мы хотели решить эту проблему, просто применив соответствующий патч. Однако в дальнейшем от этой идеи пришлось отказаться, так как данный подход требует установки модифицированного Spark в кластер, что для нас оказалось неприемлемо. Выход был найден в создании отдельного окружения Conda с Python 3.5.

    Вторая проблема не позволяет Spark 1 работать внутри Docker. Драйвер Spark открывает определенный порт, по которому с драйвером устанавливает соединение Worker — для этого драйвер посылает ему свой IP-адрес. В случае с Docker Worker пытается соединиться с драйвером по IP контейнера и при использовании network=bridge у него это вполне естественным образом не получается.

    Очевидное решение — посылать не IP контейнера, а IP хоста, что и было реализовано в Spark 2 добавлением соответствующей конфигурационной настройки. Этот патч был творчески переработан и применен к Spark 1. Модифицированный таким образом Spark не нужно ставить на хосты кластера, поэтому проблемы, подобной несовместимости с Python 3.6, не возникает.

    Вне зависимости от версии Spark, для его работоспособности необходимо иметь в кластере те же самые версии Python, что и в контейнере. Для установки Anaconda напрямую в обход Cloudera Manager нам пришлось научиться делать две вещи:

    • собирать свой parcel с Anaconda и всеми нужными окружениями
    • устанавливать его в Docker (для единообразия)

    Сборка parcel Anaconda


    Это оказалось довольно простой задачей. Все, что нужно, это:

    1. Подготовить содержимое parcel, установив нужные версии Anaconda и environment Python
    2. Создать файл(ы) метаданных и положить его в каталог meta
    3. Создать parcel простым tar
    4. Произвести валидацию parcel утилитой от Cloudera

    Подробнее процесс описан на GitHub, там же есть код валидатора. Метаданные мы позаимствовали в официальном парселе Anaconda для Cloudera, творчески их переработав.

    Установка parcel в Docker


    Данная практика оказалась полезна по двум причинам:

    • обеспечение работоспособности Spark – без parcel поставить Anaconda в кластер у нас невозможно
    • Spark 2 распространяется только в виде parcel — можно было бы, конечно, установить его в контейнер просто в виде jar-файлов, но такой подход был отвергнут

    Как бонус в результате решения задач выше мы получили:

    • легкость настройки клиентов Hadoop и Spark — при установке одних и тех же parcel'ей в Docker и в кластер пути на кластере и в контейнере получаются одинаковыми
    • легкость поддержки единообразного окружения в контейнере и в кластере — при обновлении кластера образ Docker просто пересобирается с теми же самыми parcel’ями, что были установлены в кластер.

    Для установки parcel в Docker сначала из RPM-пакетов устанавливается Cloudera Manager. Для собственно установки parcel используется код на Java. Клиент на Java умеет то, что не умеет клиент на Python, поэтому пришлось использовать Java и потерять некоторое единообразие), который вызывает API.

    assets/install-parcels/src/InstallParcels.java

    ParcelsResourceV5 parcels = clusters.getParcelsResource(clusterName);
    for (int i = 1; i < args.length; i += 2) {
        result = installParcel(api, parcels, args[i], args[i + 1], pause);
        if (!result) {
            System.exit(1);
        }    
    }
    

    Ограничение ресурсов хост-машины


    Для управления ресурсами хост-машины используется сочетание DockerSpawner — компонента, запускающего Jupyter конечных пользователей в отдельном Docker-контейнере — и cgroups — механизма управления ресурсами в Linux. DockerSpawner использует Docker API, который позволяет задавать родительскую cgroup для контейнера. В штатном DockerSpawner такой возможности нет, поэтому нами был написан простой код, позволяющий задавать соответствие между сущностями AD и parent cgroup в конфигурации.

    assets/jupyterhub/dsai.py

    def set_extra_host_config(self):
            extra_host_config = {}
            if self.user.name in self.user_cgroup_parent:
                cgroup_parent = self.user_cgroup_parent[self.user.name]
            else:
                pw_name = pwd.getpwnam(self.user.name).pw_name
                group_found = False
                for g in grp.getgrall():
                    if pw_name in g.gr_mem and g.gr_name in self.group_cgroup_parent:
                        cgroup_parent = self.group_cgroup_parent[g.gr_name]
                        group_found = True
                        break
                if not group_found:
                    cgroup_parent = self.cgroup_parent
    extra_host_config['cgroup_parent'] = cgroup_parent
    

    Также была внесена небольшая модификация, которая запускает Jupyter из того же образа, из которого запущен JupyterHub. Таким образом нет необходимости использовать более одного образа.

    assets/jupyterhub/dsai.py

    current_container = None
    host_name = socket.gethostname()
    for container in self.client.containers():
        if container['Id'][0:12] == host_name:
            current_container = container
            break
    self.image = current_container['Image'] 
    

    Что именно запускать в контейнере, Jupyter или JupyterHub, определяется в стартовом скрипте по переменным окружения:

    assets/jupyterhub/dsai.py

    #!/bin/bash
    ANACONDA_PATH="/opt/cloudera/parcels/Anaconda/"
    DEFAULT_ENV=`cat ${ANACONDA_PATH}/envs/default`
    source activate ${DEFAULT_ENV}
    if [ -z "${JUPYTERHUB_CLIENT_ID}" ]; then
        while true; do
            jupyterhub -f /etc/jupyterhub/jupyterhub_config.py
        done
    else
        HOME=`su ${JUPYTERHUB_USER} -c 'echo ~'`
        cd ~
        su ${JUPYTERHUB_USER} -p -c "jupyterhub-singleuser --KernelSpecManager.ensure_native_kernel=False --ip=0.0.0.0"
    fi 
    

    Возможность стартовать Docker-контейнеры Jupyter из Docker-контейнера JupyterHub достигается монтированием сокета демона Docker в контейнер JupyterHub.

    start-hub.sh

    VOLUMES="-<b>v/var/run/docker.sock:/var/run/docker.sock:Z 
    -v/var/lib/pbis/.lsassd:/var/lib/pbis/.lsassd:Z</b> -v/var/lib/pbis/.netlogond:/var/lib/pbis/.netlogond:Z -v/var/jupyterhub/home:/home/BANK/:Z -v/u00/:/u00/:Z -v/tmp:/host/tmp:Z 
    -v${CONFIG_DIR}/krb5.conf:/etc/krb5.conf:ro 
    -v${CONFIG_DIR}/hadoop/:/etc/hadoop/conf.cloudera.yarn/:ro 
    -v${CONFIG_DIR}/spark/:/etc/spark/conf.cloudera.spark_on_yarn/:ro 
    -v${CONFIG_DIR}/spark2/:/etc/spark2/conf.cloudera.spark2_on_yarn/:ro 
    -v${CONFIG_DIR}/jupyterhub/:/etc/jupyterhub/:ro"
    docker run -p0.0.0.0:8000:8000/tcp ${VOLUMES} -e VOLUMES="${VOLUMES}" 
    -e HOST_HOSTNAME=`hostname -f` dsai1.2
    

    В будущем планируется отказаться от этого решения в пользу, например, ssh.

    При использовании DockerSpawner совместно со Spark возникает еще одна проблема: драйвер Spark открывает случайные порты, по которым потом устанавливается соединение извне Worker’ами. Мы можем управлять диапазоном номеров портов, из которых выбираются случайные, задавая эти диапазоны в конфигурации Spark. Однако данные диапазоны должны быть разными для разных пользователей, так как мы не можем запускать контейнеры Jupyter с одними и теми же опубликованными портами. Для решения этой задачи был написан код, который просто генерирует диапазоны портов по id пользователя из БД JupyterHub и запускает Docker-контейнер и Spark с соответствующей конфигурацией:

    assets/jupyterhub/dsai.py

    def set_extra_create_kwargs(self):
            user_spark_driver_port, user_spark_blockmanager_port, user_spark_ui_port, user_spark_max_retries = self.get_spark_ports()
            if user_spark_driver_port == 0 or user_spark_blockmanager_port == 0 or user_spark_ui_port == 0 or user_spark_max_retries == 0:
                return
            ports = {}
            for p in range(user_spark_driver_port, user_spark_driver_port + user_spark_max_retries):
                ports['%d/tcp' % p] = None
            for p in range(user_spark_blockmanager_port, user_spark_blockmanager_port + user_spark_max_retries):
                ports['%d/tcp' % p] = None
            for p in range(user_spark_ui_port, user_spark_ui_port + user_spark_max_retries):
                ports['%d/tcp' % p] = None
    self.extra_create_kwargs = { 'ports' : ports }
    

    Недостатком такого решения является то, что при перезапуске контейнера с JupyterHub все перестает работать по причине потери БД. Поэтому при перезапуске JupyterHub для, например, изменения конфигурации мы не трогаем сам контейнер, а перезапускаем только процесс JupyterHub внутри него.

    restart-hub.sh

    #!/bin/bash
    docker ps | fgrep 'dsai1.2' | fgrep -v 'jupyter-' | awk '{ print $1; }' | while read ID; do docker exec $ID /bin/bash -c "kill \$( cat /root/jupyterhub.pid )"; done
    

    Сами cgroups создаются стандартными средствами Linux, соответствие между сущностями AD и cgroups в конфигурации выглядит так.

    <b>config-example/jupyterhub/jupyterhub_config.py</b>
    c.DSAISpawner.user_cgroup_parent = {
        'bank\\user1'    : '/jupyter-cgroup-1', # user 1
        'bank\\user2'    : '/jupyter-cgroup-1', # user 2
        'bank\\user3'    : '/jupyter-cgroup-2', # user 3
    }
    c.DSAISpawner.cgroup_parent = '/jupyter-cgroup-3'
    

    Код в git


    Наше решение есть в публичном доступе на GitHub: https://github.com/DS-AI/dsai/ (DSAI – Data Science and Artificial Intelligence). Весь код разложен по каталогам с порядковыми номерами — код из каждого следующего каталога может использовать артефакты из предыдущего. Результатом работы кода из последнего каталога будет Docker-образ.

    Каждый каталог содержит файлы:

    • assets.sh – создание нужных для сборки артефактов (загрузка из Интернета либо копирование из каталогов предыдущих шагов)
    • build.sh – сборка
    • clean.sh – очистка нужных для сборки артефактов

    Чтобы полностью пересобрать Docker-образ, необходимо последовательно запустить clean.sh, assets.sh, build.sh из каталогов по их порядковым номерам.

    Для сборки мы используем машину с Linux RedHat 7.4, Docker 17.05.0-ce. На машине 8 ядер, 32Гб RAM и 250ГБ дискового пространства. Настоятельно не рекомендуется использовать для сборки хост с худшими параметрами по RAM и HDD.

    Вот справка по использованным названиям:

    • 01-spark-patched – RPM Spark 1.6.1 с двумя примененными патчами SPARK-4563 и SPARK-19019.
    • 02-validator – валидатор parcel'ей
    • 03-anaconda-dsai-parcel-1.0 – parcel Anaconda с нужными Python (2, 3.5 и 3.6)
    • 04-cloudera-manager-api – библиотеки Cloudera Manager API
    • 05-dsai1.2-offline – финальный образ

    Увы, сборка может падать по причинам, которые нам не удалось поправить (например, при сборке parcel падает tar. В этом случае, как правило, нужно просто перезапустить сборку, однако и это не всегда помогает (к примеру, сборка Spark зависит от внешних ресурсов Cloudera, которые могут перестать быть доступными и т.п.).

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

    Торжественный финал


    Сейчас пользователи успешно используют инструментарий, их количество перевалило за несколько десятков и продолжает расти. В будущем мы планируем попробовать JupyterLab и думаем подключать к кластеру GPU, так как сейчас вычислительных ресурсов двух достаточно мощных серверов приложений уже перестает хватать.
    ВТБ
    63,63
    Компания
    Поделиться публикацией

    Комментарии 0

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

    Самое читаемое