O que é permitido pelo Jupyter?

Nossa história começou com uma tarefa aparentemente simples. Era necessário configurar ferramentas analíticas para especialistas em ciência de dados e apenas analistas de dados. Essa tarefa foi endereçada a nós por colegas das divisões de risco de varejo e CRM, onde a concentração de especialistas em ciência de dados é historicamente alta. Os clientes tinham um desejo simples: escrever código Python, importar bibliotecas avançadas (xgboost, pytorch, tensorflow etc.) e executar algoritmos nos dados gerados no cluster hdfs.



Tudo parece ser simples e claro. Mas havia tantas armadilhas que decidimos escrever um post sobre o assunto e publicar a solução pronta no GitHub.

Primeiro, alguns detalhes sobre a infraestrutura de origem:

  • Data Warehouse do HDFS (12 nós do Oracle Big Data Appliance, distribuição Cloudera). No total, o armazém possui 130 TB de dados de vários sistemas internos do banco, além de informações heterogêneas de fontes externas.
  • Dois servidores de aplicativos nos quais se supunha a implantação de ferramentas analíticas. Vale ressaltar que não apenas as tarefas analíticas avançadas estão "girando" nesses servidores, portanto, um dos requisitos era o uso de ferramentas de conteinerização (Docker) para gerenciar recursos do servidor, usar vários ambientes e configurá-los.

Como principal ambiente para o trabalho dos analistas, eles decidiram escolher o JupyterHub, que de fato já se tornou um dos padrões para trabalhar com dados e desenvolver modelos de aprendizado de máquina. Leia mais sobre isso aqui . No futuro, já imaginávamos o JupyterLab.

Parece que tudo é simples: você precisa pegar e configurar um monte de Python + Anaconda + Spark. Instale o Jupyter Hub no servidor de aplicativos, integre-se ao LDAP, conecte o Spark ou conecte-se aos dados hdfs de alguma outra maneira e vá em frente - construa modelos!
Se você se aprofundar em todos os dados e requisitos de origem, aqui está uma lista mais detalhada:

  • Executando o JupyterHub no Docker (SO básico - Oracle Linux 7)
  • Cloudera CDH cluster 5.15.1 + Spark 2.3.0 com autenticação Kerberos na configuração do Active Directory + MIT Kerberos dedicado no cluster (consulte MIT KDC dedicado ao cluster com Active Directory ), Oracle Linux 6
  • Integração com o Active Directory
  • Autenticação transparente no Hadoop e Spark
  • Suporte para Python 2 e 3
  • Spark 1 e 2 (com a capacidade de usar recursos de cluster para modelos de treinamento e paralelizar o processamento de dados usando o pyspark)
  • Capacidade de limitar os recursos do host
  • Conjunto de biblioteca

Esta postagem foi projetada para profissionais de TI que enfrentam a necessidade de resolver esses problemas.

Descrição da solução


Iniciar na integração de cluster do Docker + Cloudera


Não há nada incomum aqui. Os clientes do produto JupyterHub e Cloudera são instalados no contêiner (como - veja abaixo) e os arquivos de configuração são montados na 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 


Integração com o Active Directory


Para integração com o ferro Active Directory / Kerberos e não com muitos hosts, o padrão em nossa empresa é o produto PBIS Open . Tecnicamente, este produto é um conjunto de serviços que se comunicam com o Active Directory, com o qual, por sua vez, os clientes trabalham através de soquetes de domínio unix. Este produto se integra ao Linux PAM e NSS.

Utilizamos o método Docker padrão - soquetes de domínio unix de serviços host foram montados em um contêiner (soquetes foram encontrados empiricamente por simples manipulações com o 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 

Por sua vez, os pacotes PBIS são instalados dentro do contêiner, mas sem executar a seção pós-instalação. Portanto, colocamos apenas arquivos e bibliotecas executáveis, mas não iniciamos serviços dentro do contêiner - isso é supérfluo para nós. Os comandos de integração PAM e NSS Linux são executados 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 

Acontece que os clientes do contêiner PBIS se comunicam com os serviços de host PBIS. O JupyterHub usa um autenticador PAM e, com o PBIS configurado corretamente no host, tudo funciona imediatamente.

Para impedir que todos os usuários do AD entrem no JupyterHub, você pode usar a configuração que restringe os usuários a grupos específicos do AD.

exemplo de configuração / jupyterhub / jupyterhub_config.py

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

Autenticação transparente no Hadoop e Spark


Ao efetuar login no JupyterHub, o PBIS armazena em cache o ticket Kerberos do usuário em um arquivo específico no diretório / tmp. Para autenticação transparente dessa maneira, basta montar o diretório / tmp do host no contêiner e definir a variável KRB5CCNAME para o valor desejado (isso é feito em nossa classe 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 

Graças ao código acima, o usuário do JupyterHub pode executar comandos hdfs no terminal Jupyter e executar tarefas Spark sem etapas de autenticação adicionais. A montagem de todo o diretório / tmp do host no contêiner é insegura - estamos cientes desse problema, mas sua solução ainda está em desenvolvimento.

Python versões 2 e 3


Aqui, ao que parece, tudo é simples: você precisa instalar as versões necessárias do Python e integrá-las ao Jupyter, criando o Kernel necessário. Esse problema já foi abordado em muitos lugares. O Conda é usado para gerenciar ambientes Python. Por que toda a simplicidade é apenas aparente ficará claro na próxima seção. Exemplo de kernel para Python 3.6 (este arquivo não está no git - todos os arquivos do kernel são gerados pelo 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 e 2


Para integrar-se aos clientes SPARK, você também precisa criar Kernels. Exemplo de kernel para Python 3.6 e 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"   } } 

Observe que o requisito de ter suporte ao Spark 1 se desenvolveu historicamente. No entanto, é possível que alguém enfrente restrições semelhantes - você não pode, por exemplo, instalar o Spark 2 em um cluster. Portanto, descrevemos aqui as armadilhas que encontramos no caminho da implementação.
Primeiro, o Spark 1.6.1 não funciona com o Python 3.6. Curiosamente, no CDH 5.12.1 isso foi corrigido, mas no 5.15.1 - por algum motivo, não). Inicialmente, queríamos resolver esse problema simplesmente aplicando o patch apropriado. No entanto, no futuro, essa ideia teve que ser abandonada, pois essa abordagem requer a instalação de um Spark modificado em um cluster, o que era inaceitável para nós. A solução foi encontrada na criação de um ambiente Conda separado com o Python 3.5.

O segundo problema impede que o Spark 1 funcione no Docker. O driver Spark abre uma porta específica através da qual o Worker se conecta ao driver - para isso, o driver envia seu endereço IP. No caso do Docker Worker, ele tenta se conectar ao driver via IP do contêiner e, ao usar o network = bridge, não funciona muito naturalmente.

A solução óbvia é enviar não o IP do contêiner, mas o IP do host, que foi implementado no Spark 2 adicionando as definições de configuração apropriadas. Esse patch foi redesenhado de forma criativa e aplicado ao Spark 1. O Spark modificado dessa maneira não precisa ser colocado nos hosts do cluster; portanto, não ocorre um problema semelhante à incompatibilidade com o Python 3.6.

Independentemente da versão do Spark, para sua funcionalidade, é necessário ter as mesmas versões do Python no cluster e no contêiner. Para instalar o Anaconda ignorando diretamente o Cloudera Manager, tivemos que aprender a fazer duas coisas:

  • construa seu pacote com o Anaconda e todos os ambientes certos
  • instale-o no Docker (por consistência)

Parcela de montagem Anaconda


Isso acabou sendo uma tarefa bastante simples. Tudo que você precisa é:

  1. Prepare o conteúdo do pacote instalando as versões necessárias do ambiente Anaconda e Python
  2. Crie arquivo (s) de metadados e coloque-o no meta diretório
  3. Criar parcela com alcatrão simples
  4. Validar utilitário de encomendas da Cloudera

O processo é descrito em mais detalhes no GitHub , também há um código validador lá. Emprestamos metadados no pacote oficial da Anaconda para a Cloudera, reformulando-o de forma criativa.

Instalar parcela no Docker


Essa prática se mostrou útil por dois motivos:

  • garantindo a operacionalidade do Spark - é impossível colocar o Anaconda em um cluster sem encomendas
  • O Spark 2 é distribuído apenas na forma de pacote - você pode, é claro, instalá-lo em um contêiner apenas na forma de arquivos jar, mas essa abordagem foi rejeitada

Como bônus, como resultado da solução dos problemas acima, recebemos:

  • facilidade de configurar clientes Hadoop e Spark - ao instalar os mesmos pacotes no Docker e no cluster, os caminhos no cluster e no contêiner são os mesmos
  • facilidade de manter um ambiente uniforme no contêiner e no cluster - ao atualizar o cluster, a imagem do Docker é simplesmente reconstruída com os mesmos pacotes que foram instalados no cluster.

Para instalar o pacote no Docker, o Cloudera Manager é instalado primeiro a partir dos pacotes RPM. Para a instalação real do pacote, o código Java é usado. O cliente em Java sabe o que o cliente em Python não pode fazer, então tive que usar Java e perder alguma uniformidade), que chama a API.

recursos / 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);   } } 

Limitação de recursos do host


Para gerenciar os recursos da máquina host, é usada uma combinação do DockerSpawner - um componente que executa os usuários finais do Jupyter em um contêiner do Docker separado - e cgroups - um mecanismo de gerenciamento de recursos no Linux. O DockerSpawner usa a API do Docker, que permite definir o cgroup pai para o contêiner. Não existe essa possibilidade no DockerSpawner comum, portanto, escrevemos um código simples que permite definir a correspondência entre as entidades do AD e o cgroup pai na configuração.

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 

Também foi introduzida uma pequena modificação que inicia o Jupyter a partir da mesma imagem da qual o JupyterHub é iniciado. Portanto, não há necessidade de usar mais de uma imagem.

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

O que exatamente executar no contêiner, Jupyter ou JupyterHub, é determinado no script de inicialização por variáveis ​​de ambiente:

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 

A capacidade de iniciar contêineres Jupyter Docker a partir do contêiner JupyterHub Docker é alcançada montando o soquete daemon Docker no contêiner 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 

No futuro, está planejado abandonar essa decisão em favor de, por exemplo, ssh.

Ao usar o DockerSpawner em conjunto com o Spark, surge outro problema: o driver Spark abre portas aleatórias, através das quais os Trabalhadores estabelecem uma conexão externa. Podemos controlar o intervalo de números de porta dos quais os aleatórios são selecionados, definindo esses intervalos na configuração do Spark. No entanto, esses intervalos devem ser diferentes para usuários diferentes, pois não podemos executar contêineres Jupyter com as mesmas portas publicadas. Para resolver esse problema, foi escrito um código que simplesmente gera intervalos de portas por ID do usuário no banco de dados JupyterHub e inicia o contêiner Docker e o Spark com a configuração apropriada:

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 } 

A desvantagem dessa solução é que, quando você reinicia o contêiner com o JupyterHub, tudo para de funcionar devido à perda do banco de dados. Portanto, quando você reinicia o JupyterHub para, por exemplo, uma alteração na configuração, não tocamos no contêiner em si, mas apenas reiniciamos o processo JupyterHub dentro dele.

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 

Os próprios cgroups são criados por ferramentas padrão do Linux; a correspondência entre entidades do AD e cgroups na configuração é semelhante a essa.

 <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


Nossa solução está disponível publicamente no GitHub: https://github.com/DS-AI/dsai/ (DSAI - Data Science e Inteligência Artificial). Todo o código é organizado em diretórios com números de série - o código de cada diretório subsequente pode usar artefatos do anterior. O resultado do código do último diretório será uma imagem do Docker.

Cada diretório contém arquivos:

  • assets.sh - criação de artefatos necessários para montagem (download da Internet ou cópia dos diretórios das etapas anteriores)
  • build.sh - compilação
  • clean.sh - artefatos de limpeza necessários para montagem

Para reconstruir completamente a imagem do Docker, é necessário executar clean.sh, assets.sh, build.sh nos diretórios de acordo com seus números de série.

Para montagem, usamos uma máquina com Linux RedHat 7.4, Docker 17.05.0-ce. A máquina possui 8 núcleos, 32 GB de RAM e 250 GB de espaço em disco. É altamente recomendável que você não use um host com as piores configurações de RAM e HDD para construí-lo.

Aqui está a ajuda para os nomes usados:

  • 01-spark-patched - RPM Spark 1.6.1 com dois patches aplicados SPARK-4563 e SPARK-19019.
  • 02-validator - validador de encomendas
  • 03-anaconda-dsai-parcel-1.0 - parcel Anaconda com o Python certo (2, 3.5 e 3.6)
  • 04-cloudera-manager-api - Bibliotecas de API do Cloudera Manager
  • 05-dsai1.2-offline - imagem final

Infelizmente, a montagem pode falhar por motivos que não conseguimos corrigir (por exemplo, o tar é descartado durante a montagem da parcela. Nesse caso, como regra, você só precisa reiniciar a montagem, mas isso nem sempre ajuda (por exemplo, a montagem do Spark depende de recursos externos) Cloudera, que pode não estar mais disponível etc.).

Outra desvantagem é que o conjunto de parcelas é irreprodutível. Como as bibliotecas são constantemente atualizadas, a repetição da montagem pode gerar um resultado diferente do anterior.

Grand finale


Agora, os usuários estão usando com sucesso as ferramentas, seu número excedeu várias dúzias e continua a crescer. No futuro, planejamos experimentar o JupyterLab e estamos pensando em conectar a GPU ao cluster, porque agora os recursos de computação de dois servidores de aplicativos bastante poderosos não são mais suficientes.

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


All Articles