¿Qué permite Jupyter?

Nuestra historia comenzó con una tarea aparentemente simple. Era necesario establecer herramientas analíticas para especialistas en ciencia de datos y solo analistas de datos. Esta tarea fue dirigida a nosotros por colegas de las divisiones de riesgo minorista y CRM, donde la concentración de especialistas en ciencia de datos es históricamente alta. Los clientes tenían un simple deseo: escribir código en Python, importar bibliotecas avanzadas (xgboost, pytorch, tensorflow, etc.) y ejecutar algoritmos en los datos generados desde el clúster hdfs.



Todo parece ser simple y claro. Pero hubo tantas dificultades que decidimos escribir una publicación al respecto y publicar la solución preparada en GitHub.

Primero, algunos detalles sobre la infraestructura fuente:

  • HDFS Data Warehouse (12 nodos de Oracle Big Data Appliance, distribución Cloudera). En total, el almacén tiene 130 TB de datos de varios sistemas internos del banco; también hay información heterogénea de fuentes externas.
  • Dos servidores de aplicaciones en los que se suponía el despliegue de herramientas analíticas. Vale la pena mencionar que no solo las tareas analíticas avanzadas "giran" en estos servidores, por lo que uno de los requisitos era el uso de herramientas de contenedorización (Docker) para administrar los recursos del servidor, usar varios entornos y configurarlos.

Como el entorno principal para el trabajo de los analistas, decidieron elegir JupyterHub, que de hecho ya se ha convertido en uno de los estándares para trabajar con datos y desarrollar modelos de aprendizaje automático. Lea más sobre esto aquí . En el futuro, ya imaginamos JupyterLab.

Parece que todo es simple: necesitas tomar y configurar un montón de Python + Anaconda + Spark. Instale Jupyter Hub en el servidor de aplicaciones, integre con LDAP, conecte Spark o conéctese a datos en hdfs de cualquier otra manera y siga adelante: ¡cree modelos!
Si profundiza en todos los datos y requisitos de origen, aquí hay una lista más detallada:

  • Ejecución de JupyterHub en Docker (sistema operativo base - Oracle Linux 7)
  • Cloudera CDH cluster 5.15.1 + Spark 2.3.0 con autenticación Kerberos en la configuración de Active Directory + MIT Kerberos dedicado en el clúster (consulte KDC MIT dedicado con clúster con Active Directory ), Oracle Linux 6
  • Integración de Active Directory
  • Autenticación transparente en Hadoop y Spark
  • Soporte para Python 2 y 3
  • Spark 1 y 2 (con la capacidad de usar recursos de clúster para modelos de capacitación y procesamiento de datos en paralelo usando pyspark)
  • Capacidad para limitar los recursos del host
  • Conjunto de biblioteca

Esta publicación está diseñada para profesionales de TI que enfrentan la necesidad de resolver tales problemas.

Descripción de la solución


Lanzamiento en Docker + Cloudera Cluster Integration


No hay nada inusual aquí. Los clientes de productos JupyterHub y Cloudera se instalan en el contenedor (como se ve a continuación) y los archivos de configuración se montan desde la máquina host:

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 


Integración de Active Directory


Para la integración con Active Directory / Kerberos iron y no muy hosts, el estándar en nuestra empresa es el producto PBIS Open . Técnicamente, este producto es un conjunto de servicios que se comunican con Active Directory, con el cual, a su vez, los clientes trabajan a través de sockets de dominio Unix. Este producto se integra con Linux PAM y NSS.

Utilizamos el método Docker estándar: los sockets de dominio de Unix de los servicios de host se montaron en un contenedor (los sockets se encontraron empíricamente mediante simples manipulaciones con el comando 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 

A su vez, los paquetes PBIS se instalan dentro del contenedor, pero sin ejecutar la sección posterior a la instalación. Por lo tanto, colocamos solo archivos y bibliotecas ejecutables, pero no iniciamos servicios dentro del contenedor; esto es superfluo para nosotros. Los comandos de integración PAM y NSS Linux se ejecutan manualmente.

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 

Resulta que los clientes del contenedor PBIS se comunican con los servicios de host PBIS. JupyterHub utiliza un autenticador PAM, y con PBIS configurado correctamente en el host, todo funciona de forma inmediata.

Para evitar que todos los usuarios de AD ingresen a JupyterHub, puede usar la configuración que restringe a los usuarios a grupos específicos de AD.

config-example / jupyterhub / jupyterhub_config.py

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

Autenticación transparente en Hadoop y Spark


Al iniciar sesión en JupyterHub, PBIS almacena en caché el ticket Kerberos del usuario en un archivo específico en el directorio / tmp. Para una autenticación transparente de esta manera, es suficiente montar el directorio / tmp del host en el contenedor y establecer la variable KRB5CCNAME en el valor deseado (esto se hace en nuestra clase de autenticador).

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 

Gracias al código anterior, el usuario de JupyterHub puede ejecutar comandos hdfs desde el terminal Jupyter y ejecutar trabajos de Spark sin pasos de autenticación adicionales. Montar todo el directorio / tmp del host en el contenedor no es seguro: somos conscientes de este problema, pero su solución aún está en desarrollo.

Python versiones 2 y 3


Parece que aquí todo es simple: necesita instalar las versiones necesarias de Python e integrarlas con Jupyter, creando el Kernel necesario. Este problema ya se ha cubierto en muchos lugares. Conda se usa para administrar entornos Python. Por qué toda la simplicidad solo es aparente quedará claro en la siguiente sección. Ejemplo de kernel para Python 3.6 (este archivo no está en git; todos los archivos del kernel se generan por código):

/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 y 2


Para integrarse con clientes SPARK, también necesita crear Kernels. Ejemplo de kernel para Python 3.6 y 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"   } } 

Solo tenga en cuenta que el requisito de tener soporte para Spark 1 se ha desarrollado históricamente. Sin embargo, es posible que alguien enfrente restricciones similares: no puede, por ejemplo, instalar Spark 2 en un clúster. Por lo tanto, describimos aquí los escollos que encontramos en el camino a la implementación.
Primero, Spark 1.6.1 no funciona con Python 3.6. Curiosamente, en CDH 5.12.1 esto se solucionó, pero en 5.15.1, por alguna razón no). Al principio, queríamos resolver este problema simplemente aplicando el parche apropiado. Sin embargo, en el futuro, esta idea tuvo que ser abandonada, ya que este enfoque requiere la instalación de un Spark modificado en un clúster, lo que era inaceptable para nosotros. La solución se encontró al crear un entorno Conda separado con Python 3.5.

El segundo problema evita que Spark 1 funcione dentro de Docker. El controlador Spark abre un puerto específico a través del cual Worker se conecta al controlador; para esto, el controlador le envía su dirección IP. En el caso de Docker Worker, intenta conectarse al controlador a través de la IP del contenedor, y cuando usa network = bridge no funciona de forma natural.

La solución obvia es enviar no la IP del contenedor, sino la IP del host, que se implementó en Spark 2 agregando las configuraciones de configuración apropiadas. Este parche se rediseñó de manera creativa y se aplicó a Spark 1. No es necesario colocar Spark modificado de esta manera en los hosts del clúster, por lo tanto, no surge un problema similar a la incompatibilidad con Python 3.6.

Independientemente de la versión de Spark, para su funcionalidad es necesario tener las mismas versiones de Python en el clúster que en el contenedor. Para instalar Anaconda directamente sin pasar por Cloudera Manager, tuvimos que aprender a hacer dos cosas:

  • construye tu paquete con Anaconda y todos los entornos correctos
  • instálelo en Docker (por consistencia)

Parcela Asamblea Anaconda


Esto resultó ser una tarea bastante simple. Todo lo que necesitas es:

  1. Prepare el contenido de la parcela instalando las versiones requeridas del entorno Anaconda y Python
  2. Cree archivos de metadatos y colóquelos en el meta directorio
  3. Crear paquete con alquitrán simple
  4. Validar la utilidad de paquetería de Cloudera

El proceso se describe con más detalle en GitHub , también hay un código de validación allí. Pedimos prestados metadatos en el paquete oficial de Anaconda para Cloudera, rediseñándolo creativamente.

Instalar paquete en Docker


Esta práctica ha demostrado ser útil por dos razones:

  • Garantizar la operatividad de Spark: es imposible colocar Anaconda en un clúster sin parcela
  • Spark 2 se distribuye solo en forma de paquete; por supuesto, podría instalarlo en un contenedor solo en forma de archivos jar, pero este enfoque fue rechazado

Como beneficio adicional, como resultado de resolver los problemas anteriores, recibimos:

  • facilidad para configurar clientes Hadoop y Spark: al instalar las mismas parcelas en Docker y en el clúster, las rutas en el clúster y en el contenedor son las mismas
  • facilidad para mantener un entorno uniforme en el contenedor y en el clúster: al actualizar el clúster, la imagen de Docker simplemente se reconstruye con las mismas parcelas que se instalaron en el clúster.

Para instalar el paquete en Docker, Cloudera Manager se instala primero desde los paquetes RPM. Para la instalación real de la parcela, se utiliza el código Java. El cliente en Java sabe lo que el cliente en Python no puede hacer, así que tuve que usar Java y perder algo de uniformidad), que llama a la 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);   } } 

Limitación de recursos del host


Para administrar los recursos de la máquina host, se utiliza una combinación de DockerSpawner , un componente que ejecuta a los usuarios finales de Jupyter en un contenedor Docker separado, y cgroups , un mecanismo de administración de recursos en Linux. DockerSpawner utiliza la API de Docker, que le permite configurar el cgroup primario para el contenedor. No existe tal posibilidad en el DockerSpawner regular, por lo que escribimos un código simple que nos permite establecer la correspondencia entre las entidades de AD y el cgroup primario en la configuración.

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 

También se ha introducido una pequeña modificación que lanza Jupyter desde la misma imagen desde la que se lanza JupyterHub. Por lo tanto, no hay necesidad de usar más de una imagen.

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'] 

Las variables de entorno determinan exactamente qué ejecutar en el contenedor, Jupyter o 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 

La capacidad de iniciar los contenedores Jupyter Docker desde el contenedor JupyterHub Docker se logra mediante el montaje del zócalo del demonio Docker en el contenedor 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 

En el futuro, se planea abandonar esta decisión en favor de, por ejemplo, ssh.

Cuando se usa DockerSpawner junto con Spark, surge otro problema: el controlador Spark abre puertos aleatorios, a través de los cuales los trabajadores establecen una conexión externa. Podemos controlar el rango de números de puerto de los cuales se seleccionan los aleatorios estableciendo estos rangos en la configuración de Spark. Sin embargo, estos rangos deben ser diferentes para diferentes usuarios, ya que no podemos ejecutar contenedores Jupyter con los mismos puertos publicados. Para resolver este problema, se escribió un código que simplemente genera rangos de puertos por ID de usuario de la base de datos JupyterHub y lanza el contenedor Docker y Spark con la configuración adecuada:

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 } 

La desventaja de esta solución es que cuando reinicia el contenedor con JupyterHub, todo deja de funcionar debido a la pérdida de la base de datos. Por lo tanto, cuando reinicia JupyterHub para, por ejemplo, un cambio de configuración, no tocamos el contenedor en sí, sino que solo reiniciamos el proceso de JupyterHub dentro de él.

reiniciar-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 

Los propios grupos C se crean mediante herramientas estándar de Linux, la correspondencia entre las entidades AD y los grupos c en la configuración se ve así.

 <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' 

Código git


Nuestra solución está disponible públicamente en GitHub: https://github.com/DS-AI/dsai/ (DSAI - Data Science and Artificial Intelligence). Todo el código está organizado en directorios con números de serie: el código de cada directorio posterior puede usar artefactos del anterior. El resultado del código del último directorio será una imagen de Docker.

Cada directorio contiene archivos:

  • assets.sh: creación de artefactos necesarios para el ensamblaje (descarga de Internet o copia de los directorios de los pasos anteriores)
  • build.sh - construir
  • clean.sh: artefactos de limpieza necesarios para el ensamblaje

Para reconstruir completamente la imagen de Docker, es necesario ejecutar clean.sh, assets.sh, build.sh desde los directorios de acuerdo con sus números de serie.

Para el ensamblaje, utilizamos una máquina con Linux RedHat 7.4, Docker 17.05.0-ce. La máquina tiene 8 núcleos, 32 GB de RAM y 250 GB de espacio en disco. Se recomienda encarecidamente que no utilice un host con la peor configuración de RAM y HDD para construirlo.

Aquí está la ayuda para los nombres utilizados:

  • 01-spark-parcheado - RPM Spark 1.6.1 con dos parches aplicados SPARK-4563 y SPARK-19019.
  • 02-validator - validador de paquetes
  • 03-anaconda-dsai-parcel-1.0 - parcela Anaconda con el Python correcto (2, 3.5 y 3.6)
  • 04-cloudera-manager-api - Bibliotecas API de Cloudera Manager
  • 05-dsai1.2-offline - imagen final

Por desgracia, el ensamblaje puede bloquearse por razones que no pudimos reparar (por ejemplo, el alquitrán se cae durante el ensamblaje del paquete. En este caso, por regla general, solo necesita reiniciar el ensamblaje, pero esto no siempre ayuda (por ejemplo, el ensamblaje Spark depende de recursos externos) Cloudera, que puede que ya no esté disponible, etc.).

Otro inconveniente es que el conjunto de paquetes es irreproducible. Dado que las bibliotecas se actualizan constantemente, la repetición del ensamblado puede dar un resultado diferente al anterior.

Gran final


Ahora los usuarios utilizan con éxito las herramientas, su número ha superado varias docenas y continúa creciendo. En el futuro, planeamos probar JupyterLab y estamos pensando en conectar la GPU al clúster, porque ahora los recursos informáticos de dos servidores de aplicaciones bastante potentes ya no son suficientes.

Source: https://habr.com/ru/post/443294/


All Articles