Spark 101: Introdução ao framework de processamento de dados distribuídos
Entenda como funciona uma das plataformas mais populares de Big Data
Olá, pessoal! Bem vindos a mais um post no blog. Dessa vez vamos falar de um assunto proveniente do universo da engenharia de dados, o Apache Spark.
Esse é o primeiro de uma série de posts focados em engenharia de dados, então fica ligado que vem coisa boa por aí!
Introdução: O problema do Big Data
Segundo a Forbes, cerca de 2,5 quintilhões de bytes de dados são gerados todos os dias. No entanto, esse número é projetado para aumentar nos anos seguintes.
Nesse contexto, diversas ferramentas surgiram para lidar com o problema da quantidade massiva de dados. Cada uma dessas ferramentas possui vantagens e desvantagens, que determinam como as empresas podem decidir empregá-las.
Caso queira ler mais sobre Big Data, recomendo esse outro artigo do blog.
O livro Spark — The definitve Guide, uma das maiores referências no assunto, define a ferramenta da seguinte forma:
O Apache Spark é uma engine de computação unificada e um conjunto de bibliotecas para processamento de dados paralelos em clusters de computadores.
Uma das grandes vantagens do Spark é ser capaz de trabalhar de forma distribuída. Isso significa que se tratando de conjuntos de dados muito grandes ou quando a entrada de novos dados acontece de forma muito rápida, pode se tornar demais para um único computador. É aqui que entra a computação distribuída. Em vez de tentar processar um enorme conjunto de dados, essas tarefas são divididas entre diversas máquinas que estão em constante comunicação entre si. Em um sistema de computação distribuído, cada computador individual é chamado de nó (node) e a coleção de todos eles é chamada de cluster.
Muito da popularidade do Spark se deve aos fatores listados abaixo:
- Suporta diferentes linguagens de programação como Java, Python, Scala e R.
- Possibilita streaming de dados em tempo real.
- É possível rodar em uma única máquina assim como em grandes clusters de computadores.
- Suporta SQL.
- Possui bibliotecas para criar aplicações com Machine Learning, MLlib.
- Realiza processamento de dados em grafos com GraphX.
- É uma ferramenta open-source.
O Spark possui diferentes componentes e todos eles são integrados no chamado Spark Core, que é a parte da aplicação que possui as funções básicas disponibilizadas pelo framework.
Arquitetura do spark
A arquitetura de uma aplicação Spark é constituída por três partes principais:
- Driver Program: é o responsável por orquestrar a execução do processamento de dados.
- Cluster Manager: componente responsável por gerenciar as diversas máquinas em um cluster. Só é necessário se o spark for executado de forma distribuída.
- Workers Nodes: são as máquinas que executam as tarefas de um programa. Se o spark for executado de forma local na sua máquina, ela irá desempenhar tanto papel de Driver Program como de Workes. Esse modo de execução do spark é chamado de Standalone. Também existe o modo chamado Client mode, onde o Driver Program é executado na mesma máquina onde o programa foi iniciado. E por último existe o modo Cluster, que faz com que o Driver Program rode em qualquer máquina em um cluster.
Existe também o Spark Context, que faz a ponte entre o Driver Program e os executores (executors, presentes nos Workers Nodes) das tarefas. É no Spark Context que alguns parâmetros importantes são definidos, como por exemplo a quantidade de memória disponível para executar determinado programa. Também é importante mencionar que quando se faz um programa spark essa operação é traduzida para um plano de execução lógico, chamado de Lineage, que podia ser uma sequência como carregar os dados, aplicar um filtro e retornar o dados. Em seguida, o spark cria um objeto chamado Dag Scheduler, que armazena a sequência presente no Lineage. Com essas informações, o Spark divide as tarefas entre os diferentes Workers para serem executadas.
Além da arquitetura em si, existem outros conceitos que são fundamentais e estão presentes em toda aplicação que faz uso do Spark.
O primeiro deles são os RDD (Resilient Distributed Dataset), que se trata basicamente de uma coleção de objetos particionada em várias máquinas do cluster e que podem ser processados em paralelo. Ou seja, essa é a estrutura responsável por carregar os dados e estes por sua vez, são particionados em várias máquinas. É importante mencionar que RDDs são objetos imutáveis, uma vez criados não são passíveis de edição.
Outro conceito relevante no Spark são as operações disponíveis nos RDDs e são essencialmente duas, ações e transformações. Essa última carrega o conceito de lazy evaluation, que significa que elas não são processadas no momento que o comando é executado. Por exemplo, ao filtrar os dados ou criar uma nova coluna, essas operações serão adicionadas ao Lineage e somente serão executadas quando uma operação de ação for encontrada no programa. Ações são procedimentos como mostrar, contar os dados entre outras. Nessa explicação é válido destacar que quando falamos em criar uma nova coluna, isso significa criar um novo RDD como resultado da operação de adição de coluna em um RDD existente, pois como mencionado antes, esses objetos são imutáveis.
O componente do Spark responsável por mapear todas as ações e transformações em um programa é chamado de Dag (Direct Acyclic Graph), que está presente dentro do Driver Program, no diagrama de arquitetura do Spark. O Dag também mapeia todas as tasks e stages necessárias para rodar o código. Uma vez que as tasks de uma determinada stage são executadas, o Spark não precisa voltar para a mesma stage, para executar algo novamente, isso é devido a capacidade do Spark em salvar resultados em memória.
Spark vs Hadoop
No universo da engenharia de dados muito se fala das comparações entre o Spark e outra famosa ferramenta, o Hadoop. Muitas dessas discussões acabam colocando um contra o outro e isso acaba criando algumas confusões. Na realidade o que acontece é que eles se complementam.
Por conta do Hadoop também ser um framework de processamento distribuído, é natural que exista comparações. Mas o Hadoop é composto por diversos componentes, entre eles o MapReduce, que é a ferramenta que desempenha processamento dados com o mesmo objetivo do Spark. A seguir um breve comparativo entre os dois:
Hadoop MapReduce:
- Armazena o resultado das operações parciais e finais em disco.
- Paradigma de desenvolvimento Map Reduce.
Apache Spark:
- Armazena o resultado das operações parciais em memória e finais em disco (esse último configurável).
- Paradigma de desenvolvimento Map Reduce entre outros.
Com isso, temos que o Spark apresenta vantagem no quesito velocidade de processamento. No entanto, o Spark não vem com um sistema de arquivos distribuídos nativo, ou seja, ele precisa ser integrado a algo que já existe. E é nesse ponto que entra sua dependência com o Hadoop. Em muitos casos o Spark usa o sistema de arquivos Hadoop HDFS (Hadoop Distributed File System). Portando, o Spark substitui o Hadoop MapReduce, mas é complementar ao HDFS.
Voltando aos RDDs
Esse post é dedicado a teoria sobre o Spark mas vamos tentar usar um exemplo sem usar código. Em publicações futuras usaremos as linguagens Python e Scala junto com o Spark. Portando, por hora, não se preocupe com a sintaxe do comando usado como exemplo.
Tomemos como exemplo um RDD chamado Funcionarios, com 100 milhões de registros de uma empresa com operação em 4 estados do Brasil. Vamos analisar o seguinte comando, onde seu plano de execução lógica é ilustrado na próxima imagem.
Funcionarios.Map(…).Filter(…).ReduceByKey(…).Collect()
A primeira coisa a se notar é que trabalhando em um ambiente distribuído, temos que as partições (os estados RJ, CE, AC e MT) estão armazenadas em 4 máquinas diferentes no cluster. A primeira função usada no comando de exemplo é a Map(). Ela poderia ser usada para criar uma nova coluna chamada Idade. Perceba que como os dados estão divididos e com essa operação sendo baseada em outra coluna no RDD, digamos data de nascimento, cada máquina pode criar a coluna Idade de forma independente. Ou seja, não é necessário juntar dados de diferentes partições para criar uma coluna Idade. Com isso, esse processamento é feito de forma paralela. Lembre do conceito de lazy evaluation, uma vez que a função Map() é do tipo transformação. Então esse comando só será executado quando encontrar uma operação de ação.
Seguindo no comando em frente no comando de exemplo, queremos agora fazer um filtro de idade para valores maiores que que 30 anos. Para isso vamos usar a função Filter() e com isso, mais essa etapa é adicionada ao nosso Lineage. Note novamente que o Spark não precisa consultar outras partições para esse caso, visto que só é necessário que o programa faça o filtro nos dados que existem em suas respectivas máquinas. E como a função Filter() também é do tipo transformação, assim como a Map() no parágrafo anterior, essa etapa ainda não será processada pelo Spark.
A próxima etapa do comando é um agrupamento, onde usaremos a função ReduceByKey para dividir os dados em intervalos de idades, independente do estado. Perceba que agora, como a operação independe do estado, ou seja da partição, é preciso que haja uma troca de informações entre as máquinas. Com isso, a alternativa que o Spark encontra para manter o processamento de forma distribuída é separar os cálculos de intervalos nas diferentes máquinas, como é mostrado na figura do plano de execução lógica, onde a primeira fica com o intervalo de 30 a 33, a segunda de 34 a 37 e assim por diante. Essa troca de dados entre partições é chamada de Shuffle. E assim como as outras duas funções anteriores, a ReduceByKey() também é uma função de transformação. Note também que a medida que aplicamos as operações, nosso conjunto de dados foi reduzido de 100 milhões de registros para um tamanho bem inferior.
Como última etapa, usamos a função Collect() para mostrar o resultado. Portando, como essa função é do tipo ação, o Spark começará a processar todas as funções anteriores até conseguir mostrar a contagem final. Somente agora o Driver Program receberá os dados, que nesse caso, seria apenas o resultado da contagem, ilustrado na imagem do plano de execução lógica pelo gráfico no canto direito. Podemos acessar o Linage dessa operação toda com o seguinte comando: Funcionarios.todebugstring.
Agora vamos discutir sobre o como o Spark irá executar esse mesmo exemplo no contexto de mais baixo nível. Para isso vamos falar sobre o plano de execução físico, o qual não é necessariamente igual ao plano de execução lógica. Antes de analisar o exemplo, precisamos entender os diferentes tipo de relacionamentos existentes em cada etapa da operação. As duas classificações são as seguintes:
- Narrow dependency: a partição filha depende de todos os dados da partição pai, mesmo que a partição pai entregue todos os seus dados para outra partição filha. No exemplo, simbolizada pela imagem da seta.
- Wide dependency: a partição filha depende de uma parte de cada partição pai. No exemplo, simbolizada pela imagem das múltiplas setas.
No nosso exemplo, a primeira e a segunda etapa (Map e Filter) da nossa operação possuem um relacionamento Narrow, pois a partição filha depende de todos os dados de sua partição pai. Já na terceira etapa (ReduceByKey) possui um relacionamento de Wide, pois precisa de dados de diferentes partições pai para fazer a contagem. Tecnicamente dizemos que precisamos de uma operação Shuffle para montar os dados dessa partição. A partir desse ponto (Wide dependency), o Spark precisa quebrar a operação em Stages diferentes. Isso é denominado Tasks Boundary. Dessa forma, cada Stage terá seu próprio conjunto de tarefas, onde a primeira possui quatro tarefas sendo que cada uma delas é responsável de processar as etapas 1 e 2 (Map e Filter) de cada partição. A Stage correspondente ao ReduceByKey também possui quatro tarefas, cada uma delas será responsável por toda operação Shuffle em suas respectivas partições. Essa divisão constitui o pipeline de execução do programa e dará origem ao plano físico de execução. Nessa etapa o Spark vai tentar otimizar ao máximo a execução dos programas criando tarefas responsáveis por executar diferentes partes da operação, mudando a ordem das etapas, se necessário, e agrupando-as quando for possível.
Uso de memória no Spark
Muito da popularidade do Spark é por conta da sua capacidade de fazer uso do chamado In Memory Computing. No nosso contexto, isso significa salvar os RDDs em memória para otimizar o tempo de execução das tarefas. No entanto, por padrão, o Spark remove da memória os objetos cujas operações já foram finalizadas.
Nesse ponto, existe um detalhe se destacar. O Spark mantém em cache os resultados parciais de cada stage da aplicação e isso ajuda na execução de pipelines complexos. pois, como vimos no exemplo, as stages são quebradas sempre que elas stages encontram um relacionamento do tipo Wide.
Na prática isso significa que, tomando como exemplo o que usamos, executamos uma primeira vez e pouco momentos depois rodamos novamente o comando. O que acontece é que o spark mantém em cache o resultado da primeira stage e precisará executar de novo somente a stage com relacionamento mais complexo, a do tipo Wide. Esse comportamento faz com o que Spark consiga executar operações de forma mais rápida.
Também é preciso mencionar que é possível alterar a forma com que o Spark grava objetivos. Isso é feito usando o comando persist. As principais opções são as seguintes:
- Memory_only: Todos os dados do RDD são salvos em memória, a parte excedente será recalculada sempre que necessário.
- Disk_only: Todos os dados do RDD são salvos em disco.
- Memory_and_disk: Salva todo o RDD na memória e o excedente em disco.
Sempre que a memória estiver cheia, o Spark irá apagar os RDDs mais antigos para liberar espaço na máquina. Esse processo é conhecido como Least Recent Used.
Tolerância à falha
Vimos que o Spark suporta o In Memory Computing, o que ajuda a executar as tarefas de forma bem mais rápida do que outras ferramentas que precisam buscar dados em disco, como o Hadoop MapReduce. E também ele é capaz de distribuir o processamento entre vários Worker Nodes. Mas o que acontece se um desse Workers parar de funcionar? Esse problema é conhecido como tolerância à falha e isto é um ponto importante para a construção de soluções escaláveis e confiáveis.
O Hadoop MapReduce lida com esse problema salvando em disco o resultado de cada etapa da operação e replica esse resultado em diferentes Nodes. Portanto, quando um Node cair, basta solicitar os dados dele para outra máquina do cluster.
Como o Spark armazena os dados em memória RAM, não é possível replicar esses dados em outro Node. Para solucionar esse problema, o Spark com com a capacidade do Lineage para armazenar os passos para execução de um programa. Com isso, caso um Node caia, basta que, através do uso do Lineage, solicitar que outra máquina calcule as mesma etapas que o Node anterior calcularia.
Cluster Manager
O componente chamado Cluster Manager é o responsável por gerenciar os recursos em ordem de executar um programa. Esse elemento do Spark também possui sua própria abstração para representar o driver e seus worker nodes, conforme mostrado na figura abaixo.
É preciso destacar que esses componentes são diferentes do Driver Program do Spark. Esse último está ligado ao processo de rodar a aplicação, enquanto que o driver do Cluster Manager faz o papel de gerenciar as máquinas físicas do cluster.
Quando uma aplicação é submetida, o Spark Context solicita recursos ao Cluster Manager para executar o programa. Dependendo de como a aplicação foi configurada, os recursos serão alocados para rodar o Driver Program e as suas tarefas.
Para ilustrar como os componentes da aplicação Spark se conectam aos do Cluster Manager, vamos observar a próxima imagem, que ilustra uma aplicação rodando em cluster mode.
Podemos ver que Cluster Manager possui o mapeamento do master node, onda roda seu próprio driver, e dos worker nodes. Quando a aplicação Spark é submetida, o driver manager aloca recursos para executar o Driver Program em um dos worker node e depois irá alocar recursos para alocar as devidas tarefas em outros worker nodes.
No modo de execução Client Mode (imagem abaixo), por sua vez, a figura do Cluster Manager continua igual, ele ainda é responsável por mapear os worker nodes do cluster, porém, nesse modo de execução a aplicação Spark é submetida por meio de uma máquina fora do cluster, chamada de Edge Node. Note que nesse caso, o Cluster Manager aloca recursos para rodar uma tarefa por node mas não aloca recursos para rodar o Driver Program. Isso é devido ao fato que nesse modo de execução, o Driver Program roda na mesma máquina em que a aplicação foi iniciada. Enquanto a aplicação estiver rodando, os workers nodes podem se comunicar para trocar mensagens e dados (operações de Shuffle). Quando a aplicação terminar, o driver do Cluster Manager recebe notificação de sucesso ou falha.
Conclusão
Nesse artigo vimos em detalhes como o Spark funciona por baixo dos panos. Ter noção dessas particularidades é muito útil na hora do desenvolvimento do código, pois permite ao engenheiro de dados desenvolver aplicações melhores.
Nos próximos artigos entraremos em como utilizar o Spark na prática! Espero que tenha gostado :)
Referências
- Curso Big Data — Processando dados com Spark Dataframe + Scala, do professor Weslley Moura, disponível na plataforma Udemy.
- Introdução ao Apache Spark, devmedia.
- Big Data Analysis: Spark and Hadoop, Toward Data Science.
- An Introduction to Apache, PySpark and Dataframe Transformations, Toward Data Science.
- Introduction to Data Analysis with Spark, O’Reilly.