Como escalar aplicações em Elixir

Cada sistema em produção tem suas particularidades. Por isso, existem diversas maneiras de escalar aplicações em Elixir. Embora não exista uma receita de bolo, neste artigo, você encontra um grupo resumido de ações que irão te ajudar a aumentar a capacidade geral do seu produto digital.
Carlos Grell | 11 de fevereiro de 2021

No meu último artigo, comentamos sobre diversas práticas que são necessárias em um sistema com condições mínimas de escalar em produção. Hoje, entretanto, vou mostrar de forma direta e prática que tipos de medidas você pode adotar para que aplicações em Elixir/Phoenix escalem em produção. Inclusive, eu trouxe vários exemplos.

Note que ‘escalar’ não é um contexto simples, como algo ‘consegue’ ou ‘não consegue’ escalar e ponto. Diversos fatores influenciam na real ‘escalabilidade’ de um sistema. Cada sistema tem seus gargalos e necessidades diferentes. Tendo isso em vista, falarei de um grupo resumido de ações, que não necessariamente abrangem tudo o que é preciso para escalar em todas as situações, mas com certeza vão te ajudar a aumentar a capacidade geral da sua aplicação.

O que não fazer para escalar seu sistema

Alguns dos problemas mais contundentes nas questões performance, segurança e robustez de código da sua aplicação são provenientes de coisas que foram feitas, e não de coisas que você deixou de fazer em termos de desenvolvimento. Portanto, decidi começar por esse grupo, pois a forma mais eficaz de evitar gargalos no seu sistema é evitar ao máximo cometer quaisquer uma das atrocidades a seguir:

ERRO 1. Lógicas de negócio em controllers

Este é, provavelmente, o problema mais comum no quesito arquitetura de código, considerando que Elixir possui elementos na sua sintaxe que lembram a do Ruby. Note que a premissa do Elixir/Phoenix gira em torno de contextos, portanto, é de suma importância que a sua lógica de negócio esteja dentro de tais contextos.

É comum que desenvolvedores vindos do Rails tentem ‘Rubyficar’ algumas lógicas e tentar jogá-las diretamente no controller por assumir que o código Elixir ficaria com um aspecto mais verboso em relação ao Ruby. Em geral, Elixir tende mesmo a ter um pouco mais de boilerplate, isto é, estruturas de código que não necessariamente seriam obrigatórias no Rails ou em outro framework. No Rails, por exemplo, o Active Record já está à disposição desde o início, sem código. No Elixir, por sua vez, é difícil executar funções Repo sem um wrapper próprio. O exemplo abaixo mostra um usuário sendo obtido em Ruby para ser renderizados em uma view:


@users = User.where(name: 'Carlos')

Já em Elixir, a solução mais comum envolvendo contextos envolveria algo como:


# assumindo que há list_users/0 e list_users/1, que recebe uma keyword list de atributos 
users = MyApp.Accounts.list_users(name: 'Carlos')

O erro comum, caso o desenvolvedor tentasse pelo ‘bypass do contexto’ criando lógicas direto no controller, tornaria o código do controller como algo desse tipo:

defmodule MyAppWeb.UserController do
use MyAppWeb, :controller
import Ecto.Query, warn: false
alias MyApp.Accounts.User
alias MyApp.Repo
action_fallback MyAppWeb.FallbackController
def index(conn, %{} = params) do
permitted_params = permitted_filter_params(params)
users =
User
|> where(permitted_params)
|> Repo.all()
render(conn, 'index.json', users: users)
end
defp permitted_filter_params(%{} = params) do
params |> Map.take([:name, :email, :status])
end
end

Note que apesar de não haver mais a necessidade de utilizar contextos, a importação e alias que o contexto fazia tiveram de ser transferidos para o controller. O próximo passo para ‘continuar sem contexto’ seria criar funções públicas no schema, algo parecido com o que as aplicações Ruby ‘out of the box’ costumam fazer. A partir daí, a modularidade e extensibilidade de código na sua aplicação já estariam descendo ladeira abaixo. Portanto, não faça isso!

Prefira sempre suprimir toda a lógica de negócio para o contexto. No controller ainda ficam, entretanto, coisas como a filtragem dos parâmetros permitidos, o que é de fato responsabilidade de um controller. Seja em um pipe com coleta somente dos parâmetros desejados, ou através da filtragem de parâmetros pelo headers das ações, recomendamos fortemente a prática para evitar surpresas na hora de receber chamadas dos diversos clients que podem vir a acessar sua API. Abaixo temos um exemplo do que seria a versão recomendada desse controller:


defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller
  alias MyApp.Accounts
  action_fallback MyAppWeb.FallbackController
  def index(conn, %{} = params) do
    users = 
      params 
      |> permitted_filter_params()
      |> Accounts.list_users()
    render(conn, 'index.json', users: users)
  end
  defp permitted_filter_params(%{} = params) do
    params |> Map.take([:name, :email, :status])
  end
end

Use e abuse do sistema de módulos que o Elixir possui. Lembre-se, você pode criar sub-módulos dentro de sub-módulos e assim por diante. Na prática isso significa que sempre que um contexto se tornar grande demais, você pode criar uma subpasta com o nome do contexto, e dentro dela sub-módulos, cada um em seu respectivo arquivo .ex, com aquela velha hierarquia ‘Contexto.SubContexto’.

Um exemplo de como separar a lógica em submódulos caso o contexto ‘Accounts’ crescesse demais:


MyApp
MyApp.Accounts
MyApp.Accounts.Configuration

Arquivos my_app/lib/ my_app/lib/accounts.ex my_app/lib/accounts/configuration.ex

Dessa forma, é possível segregar serviços (exemplo: acesso a APIs) e lógicas mais complexas em partes menores sem comprometer a modularidade da sua aplicação, com a vantagem de que você é quem define a hierarquia conforme as necessidades específicas do seu sistema.

Essa abordagem facilita também os testes automatizados, pois cada contexto tem como correspondente o seu contexto_test.exs, em que cada unidade de teste é segregada ao escopo do qual ela é de fato responsável.

Por último, facilita também o Domain Driven Development. Segundo o que recomenda o DDD, toda a lógica ‘utilizável’ de um sistema deve estar mapeada e nada mais. Ou seja, ao não ter, por exemplo, uma função que cria ou deleta um usuário de uma certa forma, você garante que ela não é um efeito colateral do universo do seu sistema.

Logo, para a segurança e bom nível de isolamento do seu sistema, recomenda-se que tais efeitos colaterais sejam evitados sempre que possível, o que costuma ser um tradeoff em relação à produtividade de desenvolvimento e kickstart do projeto, mas que quase sempre vale bem mais a pena do que esta última já há curto-médio prazo.

ERRO 2. Filtragens, Mapeamentos, e outras Iterações envolvendo o módulo Enum

É comum, ao descobrir o quão úteis são as funções do módulo Enum, que se queira filtrar dados utilizando a função Enum.filter/2 para, de certa forma, customizar o resultado do que foi obtido, por exemplo, através de uma função list_*() no contexto.

Dado o nível de otimização da linguagem - graças ao Elixir, Erlang e a BEAM - funções como Enum.filter/2 e Enum.map/2 costumam executar incrivelmente rápido até para 10.000.000+ entradas. Mesmo assim, especialmente caso os dados a serem iterados de forma geral forem muito grandes, é possível que o encadeamento de iterações possa comprometer o desempenho da sua aplicação de forma bem intensa.

Abaixo, um trecho clássico de um ‘Enum hell’:


# ...
permitted_params
 |> Enum.map(&MyApp.SomeUtilModule.function/1)
 |> Enum.filter(&MyApp.SomeUtilModule.some_function/1)
 |> Enum.map(&MyApp.SomeUtilModule.SomeDataConvertingModule.some_other_one/1)
 |> Enum.sort(&MyApp.Oh.my_god/1)
# ...

É recomendado, portanto, evitar iterar em excesso dessa forma, e se preciso, deve-se transferir lógicas de filtragem e ordenação para o Contexto, fazendo o que for possível em termos de processamento por meio do Repo e das Ecto Queries.

Uma outra dica é, se precisar fazer mapeamentos, tentar fazê-los em listas já paginadas pelo esquema de paginação da sua aplicação. Dessa forma, você terá que iterar sempre por uma massa de dados muito menor.

ERRO 3. Deploy On-Premise

Atualmente, a maioria dos serviços cloud estão a preços até que acessíveis em custo de infra-estrutura, tendo em vista o quão amplos são os benefícios - e mais importante ainda, a ausência ou diminuição de certos riscos - é pouco provável que infra-estrutura on-premise, isto é, uma máquina física específica da qual você ou a sua empresa tem o controle, seja mais indicada do que uma infra-estrutura totalmente em cloud.

Os motivos são inúmeros, backups automáticos e auto-gerenciados, infra-estrutura escalável com o deslizar de algumas poucas opções na configuração das máquinas, entre outros. Sem mencionar que, pelo menos no futuro previsível, os dados podem ser considerados sempre tão seguros ou mais seguros em cloud do que on-premise, onde só o seu time vai zelar pela integridade e segurança da infra-estrutura e, portanto, a chance de cometer algum deslize que exponha alguma falha tende a ser maior.

Além disso, na situação on-premise, o processo de deploy tende a depender muito mais de automatizações que seu time terá de construir, caso ele venha a ser automatizado, do que de ferramentas pré-existentes e consolidadas no mercado.

O que fazer para escalar em código:

1. Aplicações Umbrella

Ao longo do tempo a criação de módulos, APIs diferentes, versões de APIs, entre outros elementos no seu sistema podem ficar imensos. Além do mais, você muito provavelmente não vai querer misturar seus controllers com seu domínio.

É aí que entram as aplicações Umbrella. Você já começa gerando uma separação inicial entre seu domínio e server de uma maneira simples, metódica e organizada. Além de que, se preciso, é possível fazer o deploy de cada uma delas separadamente e até em máquinas diferentes.

Uma observação importante: se for necessário utilizar configurações diferentes em cada aplicação para a mesma dependência, ou utilizar versões diferentes de dependências por aplicações, por exemplo, provavelmente o seu código entre aplicações já é diferente o suficiente para que você tenha que ter projetos separados (serviços diferentes), e não apenas um projeto Umbrella.

Fugir, por exemplo, do padrão Mono-Repo que a Umbrella sugere significa ter que lidar com uma grande lógica de negócio para controle das entidades, o que costuma dizer que seu sistema também já estourou o que seria saudável para uma aplicação monolítica.

Para mais informações sobre projetos Umbrella, algumas referências:

  • Elixir Lang; Dependencies and Umbrella Projects

  • Elixir School; Umbrella Projects

2. Utilizar paginação

É de se imaginar que quando estamos falando em escalar um sistema, estejamos contando com um número grande de usuários, posts, mensagens, contas, ou quaisquer outras entidades que uma aplicação possua.

Portanto, é impossível esperar que os requests no seu sistema transitem de uma forma com que um endpoint da sua API, que pode ser json, html, ou outros, traga todos os registros da forma simples como se trabalha users = Accounts.list users() , seguido de um render de todos esses usuários.

Caso isso aconteça com um sistema que tenha qualquer número generoso de usuários, essa resposta não chegará, e ainda terá consumido tempo e poder de processamento que não serão convertidos em nada.

Seja via Paginator, Scrivener, Websockets ou qualquer outro método, você deve administrar com que a resposta que chegue até o seu client seja sempre em fatias de algo que se possa servir em tempo hábil, ou seja, paginada, sempre à medida que o client precisa e, até mesmo consegue, receber.

Mesmo o seu próprio front-end precisaria conseguir lidar com a resposta paginada, já que uma listagem imensa e sem tratamento algum no seu arquivo .html.ex provavelmente sofrerá da mesma consequência.

3. Aplicar métodos para transitar menos dados

Transitar menos dados é sempre importante na hora de escalar um sistema, já que representa basicamente economia de recurso e portanto com o mesmo número finito de recursos, seu sistema consegue atender mais requisições, em menos tempo.

Nesse quesito, vale qualquer método. A primeira e mais básica é a compressão gzip, que no Elixir + Phoenix pode ser habilitada em cerca de algumas poucas linhas de código.

Para habilitar a compressão gzip em todos os seus assets estáticos, edite o seu lib/my_app_web/endpoint.ex, e mude o parâmetro gzip de false para true:


plug Plug.Static,
  at: '/',    
  from: :my_app_web,    
  gzip: true,    
  only: ~w(css fonts images js) # + outros

E para também comprimir todos os bodies dos requests do seu servidor, você pode adicionar mais essa configuração ao seu config/prod/secrets.exs :


config :my_app_web, MyAppWeb.Endpoint,
  http: [port: {:system, 'PORT'}, compress: true]
  # ... outras configurações que seu server possa ter

Além disso, existem diversos outros métodos além do que seria possível cobrir com detalhes em um único artigo, porém muito eficazes, como tráfego de dados fora das chamadas HTTP REST convencionais.

Em GraphQL, é possível filtrar o que se quer obter num response - por exemplo, na exibição de dados cadastrais de um usuário - somente ao que for pertinente para os requests desejados. Pode não parecer tão eficaz, mas em algumas situações é a diferença entre ter ou não ter um gargalo de performance. Outras técnicas, como a utilização de bibliotecas RPC, como gRPC, que permitem a comunicação via RPC, que apesar de trafegar na rede como uma chamada HTTP/2, permite o interfaceamento de diversos meios de comunicação RPC de maneira multiplataforma.

Através do RPC, seu client e/ou front-end e seu servidor podem se comunicar via chamadas de funções já conhecidas entre eles, e dessa forma, os dados trafegados podem ser reduzidos somente ao que for estritamente necessário.

4. ID não sequencial

Essa dica não é tão relacionada à melhoria de performance do sistema - em alguns casos o que ocorre é até um pouco o contrário. Utilizar strings não sequenciais, como UUIDv4, no passado já trouxe inclusive problemas de performance para os bancos de dados.

Atualmente as diferenças de performance, apesar de existentes, costumam ser na maioria das vezes irrisórias em relação ao uso do ID sequencial comum. Dados que estão em tabelas relacionais como PostgreSQL - mas que possuem tantas rows que possam causar problemas de performance notáveis com UUID (10M+) - provavelmente não deveriam estar num banco relacional, de qualquer maneira, e sim em algum big data storage.

Mas, por que utilizar IDs não sequenciais? Qual a vantagem?

A primeira é que isso adiciona uma camada leve (porém extra) de segurança no seu servidor. Ao não ter o identificador dos seus recursos com valor previsível, você não dá aos seus clientes nenhuma noção da sua operação interna, tal como número de usuários, número de administradores, número de posts, número de credenciais ou quaisquer outros recursos. Seu cliente nunca vai saber se foi o décimo a ser criado caso seu id seja algo como ‘2c9695b7-f899-4849-9d20-203a65e44474’.

Por meio do UUID, ganha-se também uma proteção adicional contra acesso indevido a recursos. Ainda que essa não deva ser jamais a sua única medida de segurança - deve haver um mecanismo real de permissionamento que cuide de validar se um certo usuário ou client tem o direito de visualizar/interagir com certo dado - esse ofuscamento adicional pode salvar a pele do seu sistema agindo como uma última barreira em caso de um permissionamento indevido.

Caso seu sistema seja distribuído, como banco multi-shard, por exemplo, é possível até ganhar performance utilizando UUIDs, pois os mesmos permitem que vários pontos do seu sistema insiram registros na mesma base sem contato entre si, mesmo que ela seja guardada fisicamente em diversos locais diferentes. Dessa forma, cada um desses nós pode ir jogando UUIDs gerados sem a troca desses valores, não requerendo nenhuma coordenação extra para gerenciar tais inserções.

Vale ressaltar que você não precisa aplicar UUID em necessariamente todas as entidades do seu sistema, somente naquelas que precisam dos recursos que o UUID oferece - na maior parte das vezes, aquelas cujos IDs o usuário ou client da sua API precisará utilizar para interagir. Lembrando que IDs foram feitos para desenvolvedores e máquinas, enquanto que slugs foram feitos para pessoas comuns. Caso um usuário final tenha que interagir com algum recurso passando ID, é provável que nesse caso seja mais útil utilizar um slug único, isto é, uma string que identifica aquele recurso de forma amigável ao usuário.

Para utilizar por padrão UUIDv4 em suas aplicações em Elixir, você vai precisar seguir os seguintes passos:

Modifique os geradores de schemas para gerarem o boilerplate de UUID por padrão, no seu arquivo de config geral ./config/config.exs:

#./lib/my_app/my_context/my_entity.ex
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema 'my_entities' do
# ...
end

Para as migrations, entretanto, é necessário que cada migration de entidade que terá a chave primária como UUID sempre fique no formato abaixo, o que diz que seu id é do tipo binary_id, ou seja, uma string:

def change do
create table(:users, primary_key: false) do
add :id, :binary_id, primary_key: true
timestamps()
end
end

A partir daí, sua entidade já estará pronta para utilizar e ser referenciada através de seu id não incremental.

5. Delegar cálculos complexos

Apesar de todos os esforços, algumas tarefas são pesadas demais para um único sistema ter de lidar com elas sozinho. Algumas conversões, como de arquivos de vídeo por exemplo, podem se tornar demais para sua aplicação monolítica.

Procure criar serviços separados sempre que precisar, mas sem necessariamente ter que adotar nenhuma arquitetura estelar com dezenas de microsserviços ou qualquer coisa do tipo. A ideia aqui é equilibrar carga sem inundar seu sistema de complexidade.

Sempre que uma tarefa for mais intensiva e constantemente demandada, exporte para um serviço, como alguma aplicação menor, até mesmo em outras linguagens como node.js - é muito comum servidores node.js express para microsserviços. Ou, mais escalável ainda, alguma função serverless (exemplo: Lambda na AWS, GCF no Google, entre outros). Por meio de funções serverless, não importa quantas solicitações simultâneas seu serviço tiver, aparecerá uma nova instância da função para dar conta do recado.

##O que fazer para escalar em infra:

1. Tarefas assíncronas

Como já comentei em um artigo anterior, é crucial que qualquer sistema web relevante tenha, ao ir para a produção, algum mecanismo de lidar com tarefas e jobs assíncronos.

Seu servidor não deve ficar parado até que um e-mail de boas-vindas seja disparado ou até que uma certa integração seja processada após o processo de onboarding dos seus usuários, por exemplo.

Toda operação não instantânea que pode ser deixada para um momento assíncrono deve ser adiada. Isso transfere o seu processamento para quando seu sistema pode. Dessa forma, você preenche tempo que seria considerado idle time fazendo processamentos relevantes, mas que foram desafogados dos momentos de necessidade em que eles originalmente estavam.

Existem várias ferramentas no Elixir que podem ser utilizadas para gerenciar processamento assíncrono, tais como Exq + Redis e outros. A própria arquitetura OTP e GenServer do Elixir permitem com que seu sistema gerencie tarefas assíncronas até mesmo sem bibliotecas adicionais.

2. Cache, APM, CDN, WAF, entre outros

Esta lista inclui ferramentas que podem ser necessárias em algumas (ou várias) circunstâncias na hora de escalar seu sistema. Como seria impossível falar sobre cada uma delas com detalhes em apenas um artigo, e além disso todas estão disponíveis como serviços SaaS nas plataformas de serviços cloud, vou passar rapidamente por cada uma delas:

Cache - Pode vir a ser recomendado especialmente se o sistema precisa de acesso rápido e/ou repetitivo a certos dados que podem demorar a ser obtidos. Caches em memória como redis e memcached estão disponíveis em todas as plataformas cloud como serviço.

APM - O Application Performance Monitoring também está disponível em diversos serviços SaaS por aí, como Data Dog, Elastic APM ou New Relic, e pode te ajudar a apontar diversos gargalos de performance ao longo do seu sistema. Você pode monitorar pontos onde os requests demoram mais tempo, geram mais erros ou demandam mais recursos, criar alertas para certas situações de stress e gerenciar eventos diversos customizáveis, entre outros recursos que podem te ajudar a otimizar (e muito) o seu sistema.

CDN - Content Delivery Networks podem ser extremamente úteis na hora de escalar o seu sistema, especialmente falando de servir conteúdo (assets, vídeos, imagens) de forma rápida em escala global. A contratação de um serviço de CDN permite instalar points of presence (PoPs) em diversas áreas onde sua empresa tem operação, permitindo que seu conteúdo chegue ao usuário final muito mais rápido.

WAF - Web Application Firewalls têm a função de tornar sua aplicação mais segura e menos suscetível a interação com clients com intenções maliciosas. Por exemplo: eventos como ataques DDoS, criações automatizadas com o objetivo de criar spam ou de explorar falhas dentro do sistema, entre outros ataques que com certeza sua aplicação vai sofrer, caso não haja nenhuma medida de segurança em vigor. Por meio de um WAF (que pode ser contratado por serviços como CloudFlare, Google Cloud, AWS, entre outros), você pode definir políticas que definem o que é uma solicitação ‘legítima’ e o que deve ser bloqueado, tornando sua aplicação muito mais resiliente.

3. Scaling Vertical

Scaling Vertical tem a ver com escalar dinamicamente o número de nós (máquinas físicas ou virtuais) que hospedam seu servidor.

Em topologias como Kubernetes, por exemplo, pode-se configurar para que os nós sejam escalados automaticamente entre um limite mínimo e máximo, como no exemplo:

Você pode configurar o auto-scaling vertical dos nós do seu cluster Kubernetes, por exemplo, e até mesmo as configurações de processamento desses nós dinamicamente, editando as configurações do cluster, como na imagem abaixo:

4. Scaling Horizontal

O Scaling Horizontal, tão útil quanto o Scaling Vertical, envolve por sua vez expandir as instâncias do seu sistema em mais uma ‘frente’ além do seu número de máquinas físicas: o número de instâncias do seu container, pod ou nomenclatura utilizada pela sua infra-estrutura.

O próprio Kubernetes tem um serviço conhecido como HPA: Horizontal Pod Autoscaler. Esse tipo de serviço muda a estrutura e número de réplicas dos pods da sua aplicação em resposta a uma medição constante do consumo de CPU e de memória, ou em resposta a métricas customizadas processadas dentro do Kubernetes.

É possível configurar o Horizontal Pod Autoscaler no GKE (Google Kubernetes Engine) de acordo com a métrica que você precisa no seu deployment em alguns poucos passos, seguindo a documentação "Como configurar o escalonamento automático de pod horizontal".

Conclusão

Vimos no decorrer deste artigo que quando se fala em escalar aplicações em Elixir, realmente não há receita de bolo: as frentes de cuidado necessárias a proporcionar escalabilidade em um determinado sistema são várias.

Dessa forma, é importante não apenas verificar se um checklist fixo de otimizações está sendo cumprido, mas sempre observar de forma crítica por possíveis sub-otimizações e gargalos de performance, para que você e seu time tomem as medidas certas sobre o que seu produto precisa para escalar naquele momento.

Por fim, espero ter te ajudado na criação de sistemas em Elixir mais escaláveis. Até o próximo artigo!

Carlos Grell
Software Engineer | Fascinado por tudo que envolve ciência, lógica e conhecimento. Criptografia, user interfaces, eletrônica digital, Internet das Coisas. Fã de código limpo e reutilizável.