Dados no Jupyter Notebooks com o Kubeflow na OLX Brasil

Vem descobrir como como nosso time utiliza notebooks Jupyter para o desenvolvimento dos nossos produtos

José Hisse
Grupo OLX Tech
13 min readAug 1, 2022

--

Somos do time de MLOps da tribo de Big Data e um de nossos objetivos é criar soluções que dão autonomia para que nossos colaboradores possam experimentar e explorar nossos dados com segurança, eficiência e performance. Como possuímos diferentes perfis de usuários, procuramos uma solução que abrangesse grande parte dos casos de uso mapeados nos nossos variados times.

Vamos mostrar as soluções anteriores que estavam presentes em cada business units (BU), focando em suas vantagens de uso. Por fim, passaremos pela stack que suporta todo esse ecossistema, assim como as ferramentas que nos auxiliaram a alcançar nosso objetivo de prover uma solução unificada, robusta e que permita o suporte a entrega de valor dos nossos colaboradores, imersos na cultura data driven.

Photo by UX Indonesia on Unsplash

Este artigo contém termos técnicos relacionados ao Kubernetes, AWS e ao Kubeflow. Alguns trechos podem ter sido omitidos dos snippets de código, porém não comprometem o entendimento da solução.

Como surgiu a necessidade de outra solução

Em novembro de 2020 ocorreu a junção de duas grandes empresas, a OLX e o ZAP Imóveis, surgindo assim a OLX Brasil. Até então, cada empresa tinha suas próprias soluções técnicas, criando a necessidade de se iniciar um processo de unificação de toda a stack de dados. Esse processo se iniciou com as ferramentas de consulta ao datalake, passando pelo processo de ingestão, integração de metastores, visualização de dados e, como não poderia ser diferente, com a principal ferramenta que suporta nossos analistas e cientistas de dados, os Jupyter Notebooks.

Até então, na OLX, havia uma ferramenta desenvolvida internamente no qual o usuário tinha a liberdade de instanciar máquinas EC2, que já vinham com o Jupyter pré-instalado. Desta maneira, cada usuário tinha uma instância com seu ambiente próprio, onde o uso não interferia nos demais usuários. No ZAP Imóveis, tínhamos o JupyterHub comum a todos os usuários que assim desejassem usar.

Em ambos os casos, o objetivo principal era possibilitar um ambiente para análise e experimentação com os dados, além do treinamento de modelos para soluções nos diversos produtos das empresas. Esses objetivos nos guiaram na implementação e desenvolvimento da solução.

Solução comum e novos desafios

Dado os dois cenários apresentados, nosso objetivo era ter uma ferramenta comum, que atendesse a todas funcionalidades requeridas pelos usuários, além de proporcionar um ambiente que encorajasse novos usuários a trabalhar com dados. Chegamos a alguns pontos que não poderiam faltar e listaremos a seguir:

  • Autonomia para o usuário: o colaborador deve ser capaz de ter autonomia em suas tarefas, instanciando os recursos necessários para executar suas tarefas a hora que precisar.
  • Isolamento de recursos: cada colaborador deve ter sua instância e ambiente separado dos demais. Desta forma, evitaríamos disputa de recursos computacionais, como memória e CPU.
  • Suporte ao uso do Spark: os usuários do JupyterHub tinham a disposição o uso do Spark e queríamos manter essa feature na nova ferramenta. Desta forma, aumentaríamos as opções disponíveis para os demais usuários.
  • Eficiência de custo: as ferramentas anteriores tinham pontos extremos nesse quesito. Na ferramenta desenvolvida internamente é criada uma instância para cada notebook desejado, por outro lado, o Jupytherhub residia em apenas um pod no kubernetes, tendo os recursos da instância compartilhados com todos os notebooks ativos.
  • Kubernetes: deve haver a possibilidade de compartilhar recursos sem que esse compartilhamento afetasse as demais instâncias. Esse ponto é complementar ao apontado logo acima.
  • Suporte a GPU: os cientistas de dados devem ter a opção de utilizar máquinas com suporte a GPU para treinar seus modelos que tenham este pré-requisito.
  • Flexibilidade de recursos: cada usuário tem um perfil diferente de uso de recursos de acordo com seu caso de uso. Por exemplo, tendo alguns casos de notebooks para análises de dados e outros casos para desenvolvimento de modelos de Machine Learning. A flexibilidade na escolha dos recursos que serão utilizados é de fundamental importância para a eficiência de custos e o uso do Kubernetes funciona muito bem neste caso.

Apresentando o Kubeflow

Com os objetivos em mente, realizamos uma pesquisa por ferramentas que pudessem atender nossos requisitos, dentre as ferramentas, o Kubeflow se mostrou a melhor escolha. Com ele, podemos ter todo um ambiente isolado por usuário, com flexibilidade de requisição de recursos, suporte a GPU, suporte a imagens personalizadas do Jupyter e criado para ser executado no Kubernetes.

Figura 1: Criação de um novo notebook

Procuramos uma solução altamente escalável conforme a necessidade dos nossos usuários. Este objetivo é apoiado com o Kubernetes. Conforme os usuários solicitam recursos dinamicamente no Kubeflow, o autoscaler do cluster dimensiona o mesmo para suprir a demanda.

Estamos utilizando o LDAP como método de autenticação e habilitamos o registration flow do Kubeflow, desta forma, quando um novo colaborador logar no sistema, um novo namespace é criado para ele. Desta forma, cada colaborador tem seu próprio ambiente controlado com limites de recursos bem definidos. Quando esse colaborador deixa a empresa seu perfil é desativado no LDAP impedindo assim seu login no kubeflow.

Devido à complexidade dos recursos utilizados pelo Kubeflow, como por exemplo o Istio, resolvemos ter um cluster exclusivo para a aplicações de MLOps. Na próxima seção, veremos mais detalhes sobre como o Kubernetes está configurado.

Pilar da solução

Como toda a stack de dados é apoiada na plataforma da AWS, decidimos realizar o deploy do Kubeflow no AWS EKS, Kubernetes gerenciado da AWS.

Precisamos instalar alguns addons importantes para o funcionamento do Kubeflow, configurando da maneira que atenda nossos requisitos. A seguir vamos listar alguns e fazer uma breve argumentação da sua importância para a stack.

  • Cluster Autoscaler para AWS — Permite que novas máquinas EC2 sejam dimensionadas conforme a quantidade de notebooks aumentem ou diminuem, usando os recursos de forma eficiente, evitando gastos desnecessários.
  • NVIDIA Device Plugin — Executado como um DaemonSet em cada nó do cluster e expondo o número de GPU que cada nó possui, o plugin da NVIDIA permite que o Kubernetes direcione corretamente a aplicação que está solicitando a GPU, para o nó que possui o recurso. Quando escolhemos o número de GPUs que determinado notebook terá (figura 3), é inserido no statefulset do notebook o seguinte request e limit de acordo com o número escolhido:
Figura 2: Request de GPU
Figura 3: Solicitação de GPU
  • Amazon EBS CSI Driver — Habilita o uso de outras classes de armazenamento diferentes da padrão do EKS, a gp2. Esse driver se torna importante, pois possibilita o uso de outras classes mais baratas e com maior performance, como a gp3.
Figura 4: StorageClass gp3 no EKS
  • AWS Node Termination Handler — As máquinas spots da AWS podem sofrer encerramento a qualquer momento em seu ciclo de vida. Quando uma máquina spot é marcada para ser encerrada, ela recebe um aviso com dois minutos de antecedência. O AWS NTH monitora os metadados internos da EC2 e recebendo um aviso de encerramento, ele fará um cordon do nó em questão, evitando que novos recursos sejam alocados aquele nó e em seguida fará um drain do nó, fazendo que recursos já alocados sejam realocados em outros nós. Desta forma evitamos ao máximo a indisponibilidade de algumas aplicações.
  • AWS Load Balancer Controller — Permite gerenciar os Elastic Load Balancers da AWS através do K8s. Com ele podemos associar o load balance ao ingress do Kubeflow.

Outras ferramentas essenciais do nosso cluster serão descritas a seguir. Com elas, monitoramos a saúde de todo o ecossistema que envolve a solução escolhida.

O papel da observabilidade

Sabemos que a disciplina de observabilidade é de fundamental importância para monitorar a saúde do cluster e das aplicações que o compõem. Decidimos focar em métricas e logs, onde usamos o Prometheus para métricas e o Grafana Loki para logs, além do Grafana para visualização dos indicadores.

Com o Prometheus coletamos as métricas do cluster como um todo, consolidando os dados em um dashboard no Grafana com métricas como:

  • quantidade de notebooks instanciados;
  • quantidade de nós por node group;
  • quantidade de volumes por usuário;
  • quantidade de executores do Spark;
  • uso de memória e cpu por notebook;
  • uso de memória e cpu por Spark Executor;
  • tipos de imagens que cada notebook está utilizando;
  • owner de cada namespace do kubeflow;
  • logs das aplicações.
Figura 5: Parte do dashboard de operações do Kubeflow.
Figura 6: Parte do dashboard de operações do Kubeflow.

Na coleta de logs dos containers utilizamos o Loki em conjunto com o Promtail. O Promtail é executado como um DaemonSet que coleta os logs das aplicações e as envia para o Loki, ou seja, em todo nó do cluster terá um Promtail sendo executado, coletando e enviando os logs. A partir do momento que os logs estão no Loki eles poderão consultados através do Grafana, como a seguir:

Figura 7: Logs do Loki com visualização no Grafana.

Imagens dos notebooks

O Kubeflow possui como padrão diversos tipos de imagens para criação de notebook. Atualmente, por questões de segurança e personalização, criamos imagens baseadas no JupyterLab. As três diferentes tipos de imagens que disponibilizamos para os times são detalhadas a seguir:

  • Base: JupyterLab; Kernels em Python e R; Possibilidade de criar ambientes através do conda e pipenv; Libs internas da OLX Brasil referentes a autenticação, governança e acesso aos dados do Data Lake.
  • Tensorflow: Imagem base + Nvidia Cuda + Tensorflow Libs, possibilidade no treinamento de modelos com utilização em pods com GPU.
  • Spark: Imagem base + Apache Spark 3.x.x

Gerando e alterando recursos dinamicamente com o Kyverno

O Kyverno tem como objetivo implementar políticas como código. Com ele é possível validar, alterar e gerar novos recursos do Kubernetes através de regras escritas da mesma forma que escrevemos nossos recursos do Kubernetes, por arquivos yaml.

Ele é executado por meio do admission controllers. Assim que um recurso do k8s sofre uma ação de criação, deleção, update ou qualquer outra, ele passa por uma sequência de etapas até a concretização dessa ação. Entre esses passos estão o mutating admission controller e o validating admission controller que enviam as requisições para o Kyverno para que de acordo com as regras definidas ele possa agir com base na definição do recurso recebido.

Na seções seguintes explicaremos como ele vai ser importante no uso do Spark pelos Jupyter Notebooks.

Análise de big data com Spark

Algumas análises exploratórias e desenvolvimento de modelos de Machine Learning não são possíveis de serem realizadas via Python/Pandas devido ao grande volume de dados que temos aqui na OLX Brasil, então se faz necessário a utilização do Apache Spark para atender a demanda.

A título de curiosidade seguem alguns números referente ao Data Lake da OLX Brasil:

  • Volume total em torno de 2 Petabytes;
  • Queries que duram acima de 1 minuto giram em torno de 45 mil por mês;
  • Volume escaneado por mês, mais de 10 Petabytes.

Para a utilização do Spark em um cluster do Kubeflow tivemos que enfrentar alguns desafios ou tivemos alguns questionamentos:

  • Como será realizada a comunicação entre os pods executores e o pod driver?
  • Como definimos dinamicamente a nossa configuração padrão no arquivo spark-defaults.conf dos pods executores que serão instanciados durante a sessão do Spark?
  • Como instanciar os pods executores em nós do mesmo node group que o pod driver?

Para responder a essas perguntas nossa solução precisaria ser dinâmica, por conta das questões de pod name, namespace, node group e ao mesmo tempo transparente para o usuário final, foi onde o Kyverno entrou na jogada.

A utilização do Kyverno em nossa solução permitiu a criação de Cluster Policies para facilitar a geração de recursos dinamicamente no Kubernetes, no momento da criação de um pod Jupyter Notebook e de pods executores.

Vamos explicar como utilizamos o Kyverno para responder/resolver as perguntas anteriores:

  • Como será realizada a comunicação entre os pods executores e o pod driver?

O nome de um pod Notebook é definido pelo usuário no momento da criação no Kubeflow, ex: mlops-spark-test, ze-model-v1. Com a possibilidade de termos inúmeros notebooks(drivers) com nomes diferentes por usuário/namespace temos que garantir que os pods executores possam se comunicar com o pod do driver. Então precisamos criar um recurso do tipo service headless, para que a comunicação com o pod seja possível.

Já que o nome do pod nunca será o mesmo, pois o nome de um pod Notebook é definido pelo usuário no momento de sua criação, então podemos usar o nome do notebook como referência para o nome do service. Ex: mlops-spark-test, ze-model-v1.

Com o ClusterPolicy do Kyverno abaixo podemos entender um pouco como nossa regra vai funcionar.

Figura 8: Kyverno policy para criar Service para o Spark

Primeiro verificamos se o webhook de admissão recebeu uma requisição de create de um recurso do StatefulSet, sendo que o StatefulSet deve estar no namespace com a label app.kubernetes.io/part-of: "kubeflow-profile" que indica que é um profile do Kubeflow, em seguida verificamos se a imagem do notebook corresponde a imagem do Spark. Depois que todos esses requisitos são verificados, então o service será criado. Todos os atributos definidos entre chaves duplas {{…}} representa atributos vindos do webhook de admissão, que podemos reaproveitar os valores.

Agora, por conta da utilização do Istio em nosso cluster nós precisamos também excluir as portas 37371 e 6060 do controle do tráfego do pod driver, faremos isso através de uma annotation.

Figura 9: Kyverno policy para adicionar uma annotation
  • Como definimos dinamicamente a nossa configuração padrão no arquivo spark-defaults.conf dos pods executores que serão instanciados durante a sessão do Spark?

Com o service criado e apontando para o pod driver é preciso definir algumas configurações no arquivo spark-defaults.conf que será usado com valores padrões para as sessões do Spark.

Segue um snippet do ConfigMap onde os arquivos spark-defaults.conf e hive-site.xml serão montados nos volumes dos executores através de um novo ClusterPolicy do Kyverno:

Figura 10: Kyverno policy para o ConfigMap para o Spark

Destaque para as configurações que são dinâmicas e são definidas a partir da criação do service da etapa anterior.

spark-defaults.conf: Configurações padrões do Spark.

  • spark.driver.host: Service criado na etapa anterior pelo Kyverno.
  • spark.kubernetes.driver.pod.name: Nome do StatefulSet(Pod Notebook/Spark Driver)
  • spark.kubernetes.namespace: Namespace onde os executores serão instanciados, mesmo namespace do driver.
  • spark.kubernetes.executor.podNamePrefix: Prefixo do nome dos executores.
  • spark.kubernetes.executor.podTemplateFile: Arquivo onde são especificados o toleration e affinity para os executores, falaremos mais na próxima seção.
  • spark.kubernetes.executor.annotation.sidecar.istio.io/inject: Para evitar que o sidecar do do Istio(Envoy) seja injetado nos executores.

hive-site.xml: Configuração para o Hive Metastore na utilização do SparkSQL.

Além desta regra outra é necessária para montar o volume no pod instanciado. Nesta, quando o pod associado ao StatefulSet é iniciado, a ClusterPolicy modifica o recurso adicionando as configurações necessárias.

As configurações do arquivo spark-defaults.conf podem ser sobrescritas antes da inicialização de uma sessão do Spark utilizando o SparkConf, por exemplo:

Figura 11: Configurações de recursos do Spark no Kubernetes

Como parte da governança do acesso aos dados do Data Lake da OLX Brasil, é necessário que o usuário se identifique fornecendo suas credenciais temporárias da AWS, obtendo assim a permissão somente nas tabelas em que possui acesso. Essas configurações não são padrões e cada usuário deve fornecer suas credenciais.

Figura 12: Configurações de acesso a AWS a partir do Spark

A imagem a seguir mostra em alto nível a arquitetura final dos notebooks Spark e seus executores.

Figura 13: Diagrama em alto nível do funcionamento do Spark no Kubernetes com o Jupyter

Ao encerrar um Notebook todos os recursos criados dinamicamente pelo Kyverno a partir do driver são removidos devido a referência ownerReferences na definição dos recursos, Pods(executores), Service e ConfigMap.

Como fazemos a distribuição de custos

Na organização como um todo existe a necessidade de separar os custos por time, o que gera uma complexidade a mais em algumas arquiteturas. Podemos perceber pela figura X que cada time tem seu node group separado, essa forma de separação divisão trás alguns desafios.

Cada time tem seu node group no EKS com as tags de identificação bem definidas. Inicialmente todo node group que tem a finalidade de executar notebooks tem seu valor inicial desejado como 0 (zero). Isso significa que se determinado time não está utilizando nenhum notebook, ele não vai ter custos relacionados a instâncias ociosas.

Para que o colaborador associe o seu notebook ao node group do seu time, utilizamos alguns recursos de tolerations e node affinity. Primeiro, todos os node groups que tem como função executar notebooks possui uma taint especifica para isso, evitando assim que outros pods não utilizem aqueles recursos específicos. Não menos importante, utilizamos a affinity para associar o notebook ao node group do time.

Figura 14: Seção de affinity e tolerations da página de criação de notebook do Kubeflow

As configurações dessa página podem ser feitas diretamente no yaml spawner_ui_config.yaml do jupyter-web-app. Vamos mostrar um pequeno trecho de como ficaria a configuração do exemplo mostrado acima.

Figura 15: Configurações de tolerations e affinity do Jupyter Web App do Kubeflow

Precisamos também garantir que os executores do spark sejam instanciados no mesmo node group do driver, que no nosso caso é o próprio notebook. Para isso utilizamos o mesmo toleration e affinity do notebook e criamos um ConfigMap através de uma Cluster Policy do Kyverno (spark-create-default-conf-configmap) apresentada na seção anterior:

Figura 16: Configurações de tolerations e affinity no ClusterPolicy para os executores do Spark

Conclusão

A solução apresentada atende aos objetivos da OLX Brasil definidos na introdução, ter um ambiente personalizado aos nossos usuários para análise de grandes volumes de dados, experimentação e treinamento de modelos de machine learning. Ela serve como ponto de entrada para o desenvolvimento dos nossos produtos de dados, onde eles podem ser desenvolvidos em um ambiente seguro, escalável e eficiente.

Para o futuro pensamos em avaliar o Karpenter para o gerenciamento do autoscaling, com isso deixaríamos de usar Cluster Autoscaler. A hipótese é que, com o Karpenter, melhoraríamos a gerência e aproveitamento dos recursos, uma vez que poderíamos instanciar nós com recursos próximos aos solicitados pelo usuário.

Toda a solução apresentada ainda está em processo de amadurecimento, porém já podemos ver os produtos de dados sendo desenvolvidos, experimentados e entregues com o apoio dela. Na OLX Brasil temos a oportunidade de experimentar, inovar e aprender com nossos erros a cada dia, em um ambiente seguro, propício para aprendizagem contínua.

Todos os componentes apresentados foram implementados ou desenvolvidos ou pensado pelos membros dos times de MLOps e de Data Engineering da OLX Brasil ao qual fazem parte José Hisse, Denilson Limoeiro, Daniel Faller, Douglas Luquett, Victor Macedo, Michel Arruda, Luiz Baraldo, Rodolpho Garrido, Fabrício Previtali, Diana Barros e César Bruschetta.

Se você ficou interessado em todo esse ecossistema, em como ele funciona e gostaria de nos ajudar a evoluir a plataforma, confira nossas vagas na OLX Brasil.

--

--