Aprendendo a estruturar um cluster Hadoop com Docker

Gabriel B.
Data Arena
Published in
11 min readApr 7, 2021

DISCLOSURE: a ideia aqui é usar o Docker para estruturar um cluster em nossas máquinas localmente para aprender como o HDFS e demais componentes interagem, e não fazer deploys de aplicações para ambientes de produção, ok? :)

Logo oficial do Docker

Bem, existem uma série de tecnologias gerenciadas que nos permitem ter clusters na nuvem que, basicamente, funcionam. Nada de configurações [muito] chatas, liberação de portas nos nós para eles se comunicarem, instalação de componentes, e por aí vai.

Mas e se, simplesmente pelo espírito hacker (ou por necessidade de entender um cluster on-premises em alguma situação) se pudesse construir essa estrutura distribuída de uma maneira mais fácil?

Bem, duas opções. A primeira: separar algum computador velho e ligar em sua máquina principal via rede; a segunda: simular uma arquitetura distribuída mas dentro da mesma máquina.

Sim, podemos fazer isso com containers, mais especificamente com o Docker Compose.

Da documentação:

Compose is a tool for defining and running multi-container Docker applications. With Compose, you use a YAML file to configure your application’s services. Then, with a single command, you create and start all the services from your configuration.

Obs: assume-se que tenha algum conhecimento já sobre o que é o Docker de maneira geral e como ele opera. A documentação oficial é um bom ponto de início.

Então, tendo esta caixa de ferramentas chamada Docker, material prévio para consulta e mais um pouco de paciência na manga, podemos falar das tecnicalidades.

O ponto de partida para o que acabou sendo usado aqui foi o post de Amine Lemaizi que acabei adaptando, resultando no que está nesse repositório do Github.

Então, acabou-se por definir o que seria instalado nessa stack. Aqui existem algumas modificações em relação ao trabalho mencionado acima:

  • HDFS
  • Spark
  • Hive
  • Zookeeper
  • Kafka
  • Dask
  • Modo cluster ou interativo (com alguns ajustes)
  • Jupyter
  • Algumas bibliotecas Python

Na etapa seguinte, a questão foi definir qual seria a arquitetura simulada em termos de quantidade de nodes. Já adianto: inicialmente havia pensando em usar três nodes workers e um master, mas isso começou a se mostrar custoso demais, então reduzi a pedida dos recursos em cada node, e deixei com dois workers e um master (lembrando que minha máquina tem 16GB de RAM, caso tenha 8GB o melhor seria deixar com apenas um worker, e menos que 8GB talvez não fosse possível executar o experimento).

Arquitetura do cluster simulado — imagem criada pelo autor

A seguir, pode-se ver a hierarquia do projeto, e logo em seguida alguns pontos são explicados aproveitando-a. Havendo a intenção de replicar o funcionamento deste projeto, um bom ponto de partida é replicar esta estrutura, pois todas as cópias dos arquivos dentro dos Dockerfiles leva em conta essa disposição:

  • Diretório docker

Note que dentro deste diretório existem três subdiretórios: spark-base, spark-master e spark-worker. Cada um deles é responsável por uma imagem Docker. Isso acontece pois caso seja necessário alterar a versão de algum componente ou biblioteca, isso será feito apenas na imagem spark-base, sendo que as outras imagens “herdam” esta. Dessa forma, garantimos que todos os nodes do cluster tenham as mesmas versões dos componentes necessários — creio que uma das maiores dores da vida real — e fazemos as configurações mais específicas de master e workers nas imagens de cada um.

  • Diretório docker/spark-base/config/

Aqui temos vários subdiretórios que contemplam as configurações de cada componente que será instalado no cluster. No fim do dia, é o próprio Dockerfile que fica como responsável por colocar cada arquivo de configuração destes no seu diretório correto dentro do container. Note que dentro dos diretórios do spark-master e spark-worker temos outras configurações que contemplam especificidades de cada tipo de node.

  • Diretório user_data/

Este diretório é definido como um bind mount dentro do Docker, dessa maneira habilita-se uma maneira fácil de enviar arquivos entre o host e máquinas virtuais para se fazer alguns experimentos.

  • Arquivo docker-compose.yml

Aqui é onde se define a estrutura do cluster com as imagens Docker que serão usadas, as portas mapeadas entre host e container, dentre outras coisas. Será este o arquivo lido quando formos rodar o cluster com o comando docker-compose up.

  • Arquivo Makefile

O objetivo desse arquivo nesse projeto em específico é, ao menos inicialmente, servir como um facilitador para fazer o build das imagens Docker. Rodando o comando make build você já terá as três imagens prontas em sua máquina para rodar (mas o make pode facilitar muitas outras coisas se você for alguém com imaginação :D)

Publicado por aw3somerPers0n no Imgur :)

Agora, podemos ir um pouco mais ao detalhe das configurações de alguns dos componentes, lembrando que na hierarquia do repositório estes estão dentro do diretório config. Abaixo, quando se comentar sobre diretórios, se estará comentando sobre aqueles presentes nos nodes do cluster (i.e. dentro dos containers). Para os casos em que configurações específicas para master e workers ocorrerem, se referenciará aqui (podendo sempre se orientar pelo projeto no GitHub). O foco inicial será em Hadoop, Yarn, MapReduce, Hive e Spark, com demais componentes sendo abordados em um próximo artigo.

  • Hadoop

Os arquivos abaixo ficam no diretório /usr/hadoop-${HADOOP_VERSION}/etc/hadoop/ em que ${HADOOP_VERSION} representa a versão instalada do Hadoop, configurada em nosso Dockerfile spark-base como uma variável de ambiente.

O arquivo core-site.xml, em sua property fs.default.name define, na prática, o “endereço” do master. Aqui, ele assume o nome de spark-master pois o Docker, internamente, converte esses nomes (atribuídos no docker-compose.yml) para os IPs atribuídos internamente por ele.

Já no hdfs-site.xml definimos os diretórios (importante! diretórios do sistema local de cada node, e não do sistema distribuído) que o nameNode e os dataNodes irão guardar seus metadados. No caso do nameNode, é onde ele terá a referência de quais blocos de dados estarão em cada node e das alterações no HDFS, por exemplo, e os dataNodes terão lá seu status em relação ao cluster como um todo.

Outra coisa, o dfs.replication é igual ao número de nodes workers que temos, que nesse caso é 2! Caso opte por mudar a quantidade de nodes, lembre-se de alterar aqui.

O arquivo masters, presente apenas no node master, define o endereço do node em que o secondaryNameNode estará rodando. Aqui nós não temos um node apenas para isso, então apontamos para o próprio node master que definimos, mas isso na vida real não seria recomendado. O secondaryNameNode atua justamente para fazer um merge periódico dos arquivos presentes no diretório definido em dfs.namenode.name.dirdo *sistema de arquivos local* do nameNome . Os arquivos em questão são a fsimage e o edits log, pois estes tendem a ficar grandes demais no nameNode, já que registram todas as alterações que o HDFS vai sofrendo.

Já o arquivo slaves , também presente apenas no node master, que está no diretório $HADOOP_HOME/conf/, é um helper para os helper scripts (pois é) que ficam em $HADOOP_HOME/sbin/, permitindo que estes executem comandos em diversos hosts de uma única vez. Sua estrutura define um hostname ou endereço IP para cada node worker por linha. Aqui $HADOOP_HOME também é configurada como uma variável de ambiente do Dockerfile spark-base.

Como usamos os hostnames dos containers do Docker, assumimos que estes valores (tanto do masters como do slaves) serão convertidos em IPs. Dessa forma, os scripts conseguem localizar os nodes workers quando executados.

  • MapReduce

No mapred-site.xml, que também fica em /usr/hadoop-${HADOOP_VERSION}/etc/hadoop/ temos algumas configurações definindo alocações de recursos para o MapReduce e para o YARN. Estas configurações trabalham lado a lado com as definidas no arquivo a seguir, junto a maiores detalhes.

  • Yarn

O yarn-site.xml também é da turma que fica em /usr/hadoop-${HADOOP_VERSION}/etc/hadoop/. Ele define alocações de recursos para o YARN. O melhor local que encontrei uma explicação boa sobre heurísticas para se usar foi na Cloudera/HortonWorks. Com base nesse material, fiz uma tabela-resumo interativa que você pode copiar, também, caso sirva para ajudar nestes cálculos em experimentações.

Tabela-resumo com as heurísticas da Cloudera/HortonWorks. Obs: os referidos containers aqui, são containers do YARN e não do Docker.
  • Spark

Neste arquivo temos algumas configurações do Spark. Reconheço que não usei nenhuma heurística além de tentativa e erro, vendo o que funcionava até chegar nos valores funcionais para spark.driver.memory, spark.executor.memory e spark.yarn.am.memory, visto que ele concorre pelos [poucos] recursos juntos com os outros componentes e não temos exatamente um cluster “de verdade” em mãos.

Além disso, aqui se define o spark.master como o yarn (pois ele pode rodar também com o Mesos, Kubernetes ou ainda em modo standalone).

  • Hive

Para o Hive, temos que configurar o metastore (mais sobre isso mais adiante), e definir algumas configurações dentro do arquivo hive-site.xml de maneira diferente no master e nos workers, de forma que os nodes consigam se comunicar e disparar os jobs adequadamente. Primeiro, falaremos do master.

No hive-site.xml presente no node master, definimos que o metastore será um MariaDB que estará rodando no próprio node (por isso o endereçamento de ConnectionURL como jdbc:mysql://localhost/metastore e a propertyhive.metastore.uris apontando também para thrift://localhost:9083).

Além isso, definimos que usaremos o usuário hive no MariaDB e que a senha configurada lá é password, e também fazemos um bind do HiveServer2 para o localhost, dentre outros parâmetros.

Idealmente, o metastore estaria em outro node, mas para simplicidade mantivemos também no próprio master aqui.

Já para o hive-site.xml nos workers temos um cenário um pouco diferente.

Aqui, apontamos a property hive.metastore.uris para o spark-master, e não para localhost, como no node master. Também definimos o diretório com os jars auxiliares para o Hive. Isso é necessário quando queremos adicionar alguma funcionalidade third party ao Hive. Um pouco sobre este processo aqui.

Falaremos mais da configuração do metastore quando tratarmos sobre o arquivo que será o entrypoint para o Docker, ao fim da próxima sessão.

Agora, no Docker…

Vamos começar, passo a passo, pela estrutura do Dockerfile base. Aqui, nossa imagem já começa a ter uma estrutura mais complexa do que o trabalho original de Lemaizi, comentado lá no início.

Mas vamos começar simples:

Aqui começamos apenas declarando a imagem Docker que servirá como nosso ponto de partida. Aqui começamos simplesmente com o Java na versão 8 (que é a versão base para muito do que será instalado aqui adiante).

E então fazemos toda uma série de declarações de variáveis de ambiente para os diversos componentes que teremos aqui. No final, temos as adições aos PATHs, para facilitar que os componentes (e nós) localizem os executáveis nos seus diretórios.
Neste artigo, não se entra muito em detalhes sobre o Kafka e Zookeeper, mas pretendo falar mais sobre eles futuramente.

E, então, vamos para a parte mais divertida: a instalação de tudo! Primeiro copiamos alguns requirements para o Python, os instalamos, e na sequência vamos adicionando todos os componentes, descompactando e movendo para os diretórios de destino.

Note que em diversos pontos acabamos por usar as variáveis de ambiente com as versões declaradas lá no topo do arquivo para facilitar a manutenção do código em todas as chamadas para baixar os componentes. O Kafka, por exemplo, acaba incluindo também a versão do Scala no nome do arquivo que baixamos (pois ele é escrito nessa linguagem).

Então geramos keys para os nodes poderem se comunicar por ssh, enviamos seu conteúdo para o arquivo ~/.ssh/authorized_keys, copiamos o arquivo config para dentro do diretório .ssh e também adicionamos as permissões de read e write tanto para ~/.ssh/authorized_keys, quanto para /root/.ssh/config. Veja abaixo:

Por fim, copiamos todos os arquivos da hierarquia do repositório para dentro dos diretórios que escolhemos nos containers, expomos as portas para que os componentes possam interagir entre os containers (e com o host no caso das WebUIs; também definimos o entrypoint como sendo o arquivo bootstrap.sh.

O bootstrap.sh se encarregará de subir todos os serviços, como por exemplo o HDFS, o metastore do Hive, e todos os demais. Assim, sempre que terminarmos de subir a estrutura com os containers, teremos um cluster funcional.

Um pequeno capítulo à parte sobre o bootstrap, ou como subir todos os serviços…

O bootstrap.sh, no fim das contas, contém tudo que é necessário para subir os componentes que devem executar para se ter um cluster funcional, depois de transferir todos os arquivos para dentro dos containers, nos locais certos e com as versões certas — mantendo cuidado com o dependency hell.

Então de início, o namenode é formatado (ato necessário para habilitar seu uso ao executar pela primeira vez), o ssh iniciado, e os serviços necessários, tanto para master, como para workers, executados.

No master:

Executamos os scripts para iniciar o HDFS e o Yarn, configuramos o Zookeeper, Kafka e Jupyter e iniciamos o MariaDB (o metastore do Hive). Neste último ponto, pode-se notar que primeiro se inicia o serviço com service mysql start*, depois, se disparam alguns comandos para o banco de dados em que basicamente se define a estrutura dele — usando um script dentro do diretório do Hive para auxiliar, vide bloco a partir da linha 39 ao lado. Neste mesmo bloco, também note que se configura o usuário como hive e a senha como password, nos mesmos valores comentados nas sessões anteriores. Obviamente que em ambiente de produção deixar a senha plaintext não seria uma boa prática aqui.

Ao final, nas linhas 62 e 63, o serviço do metastore do Hive é iniciado, além do HiveServer2.

Nos workers:

Dos serviços que estão sendo abordados aqui, basicamente pode-se mencionar que o datanode e o nodemanager estão sendo iniciados nos workers, vide linhas 76e 77 ao lado. Perceba também que se aproveitam as variáveis de ambiente configuradas originalmente no Dockerfile.

Ao final:

Apenas induzimos um loop infinito para evitar que o Docker mate os containers.

* Uma nota: o serviço do MariaDB também é chamado de mysql nesse contexto, provavelmente por conta da versão usada. Em outras situações, um service mariadb start pode funcionar.

Agora, no Makefile…

No Makefile temos apenas um target chamado build, que definimos também como sendo do tipo phony no topo do arquivo. Basicamente, um target do make é sempre um arquivo por padrão. A marcação de um phony target serve para indicar quando o comando não deve se referenciar a um arquivo, evitando ambiguidades. Mais sobre isso no StackOverflow.

Os prefixos @ antes de cada comando impedem que os comandos sejam ligados ao stdout — e portanto, exibidos no shell — deixando espaço para coisas mais importantes :).

Agora, no docker-compose, subindo o cluster!

O Docker Compose acaba atuando como um facilitador. Podemos adicionar todas as especificações em um arquivo .yml, incluindo as imagens Docker, os hostnames de containers, volumes e portas que serão mapeados em host e containers, variáveis de ambiente, as redes e até mesmo a dependência entre containers (de forma que ele só vai criar um depois que o(s) outro(s) estiver(em) em execução).

Tendo tudo configurado em mãos e estando no diretório no qual os arquivos estão, restam apenas dois passos: o primeiro é fazer um make build. Isso fará o build das imagens docker constituídas nos Dockerfiles localmente; o segundo é fazer um docker-compose up, que ele se encarregará de fazer o resto.

O cluster está rodando: acessando as interfaces dos componentes

Agora, tendo tudo configurado e — espera-se — rodando, podemos finalmente acessar as UIs dos componentes para verificar que os serviços estão de pé! No seu browser, insira os valores abaixo para cada um deles:

  • Jupyter: 10.5.0.2:8888
  • ResourceManager: 10.5.0.2:8088
  • NameNode: 10.5.0.2:11070
  • HiveServer2: 10.5.0.2:11002

Obs: Caso tenha WSL2 na sua máquina, acesse simplesmente com localhost:<porta>

Tendo tudo ocorrido conforme o esperado, poderá visualizar as interfaces web desses componentes! É isso por hoje, mas não pare por aqui… :P

Obrigado pela leitura! Lembrando, caso queira acessar o repositório com os componentes desse artigo, acesse por aqui.

--

--

Gabriel B.
Data Arena

Data Professional and tech enthusiast. Forever Student.