Como funciona a plataforma Olist

osantana
olist
Published in
11 min readJan 24, 2017

--

Como já mencionamos em artigos anteriores a plataforma Olist foi toda reescrita ao longo de 9 meses e foi colocada no ar no primeiro dia de setembro de 2016.

O objetivo deste artigo é detalhar os requisitos que precisávamos atender e o conjunto de premissas para definir a sua arquitetura e seu funcionamento.

Estamos contratando!
Gostaria de fazer parte da equipe de desenvolvimento Olist? Participe do nosso processo seletivo.

Requisitos

Por conta dos problemas enfrentados com a primeira versão do sistema que tínhamos definimos alguns requisitos não-funcionais que precisaríamos atender:

  • Escalabilidade — temos um negócio em rápido crescimento que também possui características de sazonalidade (ex. Black Friday) que exigem uma grande capacidade de escalar o sistema rapidamente.
  • Resiliência — precisamos integrar a nossa plataforma com vários sistemas de terceiros que, eventualmente, podem estar fora do ar. Nosso sistema deve continuar funcionando mesmo em cenários de falhas.
  • Modularidade — temos um roadmap com diversas funcionalidades importantes para serem desenvolvidas nos próximos anos. Precisamos de um sistema modular para garantir sua extensibilidade.
  • Segurança (safety) — precisamos garantir que as informações sejam armazenadas e processadas com total segurança e garantir sua reconstrução total em caso de falhas.

Premissas

Sempre que vamos desenvolver um novo módulo ou serviço para nossa plataforma sempre partimos do pressuposto que:

Não importa se o sistema é interno ou externo, ele eventualmente vai ficar offline, ou quebrar, ou alterar o seu comportamento sem aviso prévio.

Abordagem arquitetural

Existem várias abordagens para arquitetar um sistema mas as mais comumente usadas atualmente são as abordagens monolíticas ou de microserviços (eventualmente chamadas de SOA — Service Oriented Architecture).

A abordagem monolítica não atenderia aos nossos requisitos de Escalabilidade e Modularidade. Eventualmente essa abordagem também dificultaria a implementação de uma plataforma para atender ao requisito de Resiliência.

Microserviços (ou SOA)

Existem vários modos de se implementar a integração entre microserviços e avaliamos 3 delas antes de escolher uma.

Cenário 1 — chamada síncrona entre serviços

A forma mais comum de integrarmos vários serviços é através de requisições síncronas (ex. HTTP REST) entre eles. Cada serviço disponibiliza uma API que é acessada por seus clientes que podem ser outras APIs.

Essa forma de implementar a integração entre serviços obriga que cada serviço cliente conheça e saiba interagir com todas as suas dependências e cuide de todos os cenários de falha possíveis em todas essas interações.

Além dessa desvantagem não podemos garantir o funcionamento de um serviço no caso de uma de suas dependências estiver indisponível. Assim, essa forma de integrar serviços não atenderia aos nossos requisitos de resiliência e segurança.

Impossível garantir resiliência e segurança quando um dos serviços fica offline

Cenário 2 — chamadas assíncronas entre serviços

Na tentativa de resolver um dos problemas do cenário descrito anteriormente poderíamos substituir as chamadas síncronas por chamadas (remotas) assíncronas.

Essa forma de implementar resolve o problema da resiliência mas o problema do acoplamento ainda persiste. Esse acoplamento diminui a modularidade da plataforma e, como dissemos, modularidade é um de nossos requisitos.

Os serviços mais comuns de aRPC (asynchronous remote procedure call) da plataforma Python exigem que a base de código das tasks seja compartilhada entre API e workers. O deployment de uma nova funcionalidade, então, precisa ser feito de forma sincronizada entre os nós do sistema para não termos o risco de perder dados.

Descartamos essa forma de implementar a nossa plataforma e fomos atrás da abordagem de integrá-la através de eventos usando o padrão Publish-Subscribers.

PubSub

Utilizando o padrão PubSub poderíamos ter (nano/micro/mini/…)serviços capazes de realizar (bem) apenas as suas tarefas sem se preocupar com os outros serviços que responderiam aos seus eventos. Os eventos são disparados e trafegam entre os serviços na forma de mensagens.

Os elementos da nossa arquitetura, então, compoem um conjunto bem rico de blocos de construção (building blocks).

Mensagens

As mensagens carregam os dados que precisam transitar entre um serviço e outro. No contexto de uma API REST elas também podem ser chamadas de Resource. As mensagens devem seguir um contrato conhecido por todos os serviços envolvidos na comunicação (publisher and subscribers).

Na nossa plataforma as mensagens geralmente estão envolvidas (envelopadas) com metadados dos serviços usados para transmitir todas elas.

Tópicos

Sempre que precisamos disparar um evento fazemos isso publicando uma mensagem em um tópico. Os tópicos são globais à plataforma e pertencem ao sistema como um todo e não à um serviço específico.

Usamos tópicos do serviço SNS da AWS porque sempre damos preferência à serviços gerenciados na escolha de nossas tecnologias (uma equipe pequena é melhor aproveitada desenvolvendo software para nossos clientes do que fazendo isso para nós mesmos). No momento em que estávamos avaliando as ferramentas que usaríamos o Heroku ainda não oferecia o Heroku Kafka. O Heroku Kafka seria a escolha porque tem uma flexibilidade maior para implementar integrações baseadas em mensagens mas mudar isso hoje custaria muito caro e não agregaria valor na mesma proporção.

Filas

Quando um serviço precisa ouvir as mensagens de um tópico ele deve fazê-lo através de uma fila que assina esse tópico.

Diferente dos tópicos, uma fila pertence sempre à um único serviço. Se mais de um serviço precisa ouvir as mensagens de um tópico é só criar mais uma subscription para isso.

Usamos o serviço SQS da AWS para nossas filas. É um sistema extremamente robusto, "infinitamente" escalável e absolutamente seguro. A própria Amazon faz uso desse serviço em sua loja.

As filas SQS não garantem unicidade das mensagens (múltiplos serviços podem receber a mesma mensagem da fila) e a ordem de entrega das mensagens também não é garantida, logo, é necessário que os nossos serviços cuidem desses problemas por conta própria.

APIs

Na nossa plataforma as APIs são as portas de entrada de dados para o sistema. Apesar da sua importância as APIs possuem poucas responsabilidades para funcionar corretamente.

Em nossa plataforma uma API basicamente valida os dados enviados para ela (eventualmente valida status baseado em um workflow), faz a persistência disso em um banco de dados relacional (Heroku PostgreSQL) e então dispara eventos com essas informações publicando elas em um tópico SNS.

A maior parte das requisições para uma API é feita por outro serviço que, por sua vez, está atuando sobre uma mensagem entregue via fila SQS. O problema disso é que, como eu disse, o SQS não garante a unicidade das mensagens.

Se temos N workers processando mensagens de uma fila existe uma chance de que eles disparem requisições duplicadas para nossas APIs.

Antes desse projeto sempre implementávamos um mecanismo de idempotência nos workers que, via mutex lock, garantia que apenas um worker processasse a mensagem.

No projeto adotamos uma solução bem mais simples: assumimos que requests repetidos podem acontecer eventualmente e nesses casos programamos nossas APIs para responderem 304 Not Modified para os requests duplicados. O worker, então, descarta essa mensagem como "já processada".

Para implementar nossas APIs usamos Django REST Framework. Escolhemos ele porque a maior parte dos integrantes do nosso time já o conheciam e não tínhamos tempo para dominar outro framework.

Hoje planejamos trocá-lo porque, apesar de funcionar, ele tem um sério problema de modelagem que causa acoplamento excessivo e pouca clareza sobre as responsabilidades de Views, Serializers e Models.

Existe um tipo específico de API, que chamamos de Webhook, que serve para lidar com requisições externas (ex. novo pedido ou aprovação de pedido). Essas APIs não precisam persistir dados e apenas "traduzem" os dados recebidos para um padrão interno e disparam um evento com esses dados para serem consumidos por algum serviço posteriormente.

Serviços

Em nossa plataforma tudo é serviço mas para facilitar a comunicação convencionamos a chamar de serviços somente os componentes que processam mensagens de uma fila.

A responsabilidade dos serviços na nossa arquitetura é a de pegar as mensagens da fila, processar suas informações, consultar APIs externas, e então enviar esses dados processados para outras APIs ou publicar eles em um tópico.

Temos dois tipos de serviços em nossa plataforma:

  • Dequeuer — são serviços que ouvem uma única fila, processam os dados e, na sequência disparam uma requisição para uma API.
  • Broker — Os brokers são serviços que aglutinam vários dequeuers com propósitos similares. Exemplo: quando integramos com um de nossos canais precisamos fazer requisições para diversos endpoints da API deles dependendo de mensagens publicadas em diversos tópicos. Ao invés de implementarmos um dequeuer para cada fila/endpoint optamos por implementar um broker que roteia essas mensagens.

Usamos um projeto chamado Loafer para implementar esses serviços. O Loafer é um projeto opensource desenvolvido por um de nossos desenvolvedores usando Python Asyncio.

Job

Job é um serviço que é executado em intervalos de tempo pré-definidos. Ele geralmente é executado para buscar dados de algum parceiro (ex. pedidos cancelados, rastreamento de objetos nos Correios, etc). Assim que um job é executado ele busca as informações que precisa, traduz elas para algum formato interno e, então, publica eles em um tópico.

Cliente

Por último temos os clientes das APIs. Temos um client que é usado por nossos clientes (perdão pela redundância) que não tem nenhum mecanismo de persistência (ex. usamos signed cookies para dados de sessão). Todas as informações exibidas para nossos clientes são obtidas via acesso à APIs e todos os dados enviados por eles são encaminhados para as APIs.

Nossos clientes Web são feitos em Django "arroz com feijão" com pouco ou quase nenhum JS (o JS que tem lá foi colocado em um momento de distração minha).

Os clientes Web não são (ou não deveriam ser) mais que meros apresentadores de dados obtidos das APIs.

Nossa aplicação de backoffice também é um cliente das APIs mas, nesse caso, mantemos um pequeno banco de dados com os dados de autenticação, autorização e auditoria para nosso staff.

Agora vamos ver isso tudo funcionando em conjunto quando um de nossos clientes solicita a geração das etiquetas e da lista de postagem dos objetos para entrega. As imagens abaixo mostra uma parte do fluxo na forma de blocos e um resumo do fim do fluxo na forma de uma lista até a sua conclusão.

Primeiramente o nosso cliente seleciona os objetos que deseja enviar e em um botão para gerar as etiquetas de envio.

Essa requisição é enviada para uma de nossas APIs que vai gravar os dados desse envio e disparar um evento de “shipment created”.

O serviço de geração de lista de postagem "ouve" esse tópico (através de uma fila), traduz os dados de envio para o formato de "Lista de Postagem" (conhecida como PLP nos Correios) e faz um POST na nossa API interna que armazena essa Lista de Postagem com todos os detalhes necessário para enviar para os Correios. Assim que essa PLP é criada um evento é disparado.

Se por um acaso algum erro aconteceu durante o acesso à API, não tem problema. Esse erro faz com que a mensagem seja devolvida para a fila para processamento posterior. Se essa mensagem ficar na fila por muito tempo (ou for devolvida muitas vezes para lá) o próprio SQS encaminha ela para uma fila "irmã" chamada dead letter. Monitoramos essas filas e avaliamos as mensagens lá para ver se é algum bug em nosso sistema ou se basta reprocessar todas elas.

Se a PLP está fechada nos Correios o fluxo segue adiante até a geração do PDF com as etiquetas, upload disso para o S3 e, por último, cadastrando o código de rastreio (e as URLs para os PDFs) na nossa API de gerenciamento de envios. A Webapp, então, disponibiliza esses dados para nosso cliente.

Débitos Técnicos

Nem tudo são flores em uma plataforma tão grande desenvolvida em tão pouco tempo. Temos alguns débitos técnicos estruturais que precisarão ser revistos dentro de pouco tempo:

  • Autenticação/Autorização — o nosso maior débito técnico é um serviço centralizado de autenticação e autorização que se tornou um ponto único de falha na nossa plataforma. Se ele sair do ar para tudo.
  • Falta de um gateway de API — atualmente cada API é acessada diretamente em um endpoint do Heroku. Por falta de tempo não implementamos um gateway que pudesse rotear as requisições para as nossas APIs.

Desafios

Também temos alguns desafios que são uma consequência do fato de termos uma abordagem de integração de serviços menos comum.

  • É difícil evoluir ou mudar os contratos. Um contrato pode ser usado por múltiplos serviços e mudar um deles, hoje, obriga a alteração de todos os serviços envolvidos. Ter alguma forma de gerenciar esses contratos de forma sincronizada (sem perder a resiliência) é algo que precisamos buscar.
  • Todos os processos sequenciais obriga a criação de um número muito grande de serviços. Isso aumenta a quantidade de código necessário para solucionar um problema.
  • Para evitar consultas entre as APIs é necessário replicar as informações entre todas elas. Isso causa uma espécie de desnormalização de dados intra-sistemas. Todo dado desnormalizado, eventualmente, sofre com inconsistências. Prevenir ou corrigir essas inconsistências em diversos sistemas é complicado.

Ferramentas e Ambiente

Como vocês podem perceber a nossa arquitetura é bem robusta e modular. Mas a complexidade dela deve ter saltado aos olhos também. Para facilitar nossa vida fizemos algumas coisas:

  • Templates — temos projetos com templates para cada tipo de serviço descrito acima
  • Bibliotecas — temos algumas bibliotecas com funcionalidades necessárias por todos os projetos
  • Ferramentas — temos algumas ferramentas de linha de comando que nos ajudam nas tarefas repetitivas
  • Processo — temos um bom processo de desenvolvimento, integração e deployment que, apoiado por boas ferramentas, tornam nossa vida bem mais fácil.

Também temos um ambiente operacional bem poderoso:

  • Heroku PaaS
  • Heroku PostgreSQL Database
  • Logentries
  • Sentry
  • New Relic

Conclusão

O artigo ficou enorme mas realmente espero ter conseguido detalhar o funcionamento e implementação da nossa plataforma.

Estamos muito felizes com ela e temos certeza que conseguimos atender aos requisitos necessários para levar a Olist para outros patamares de crescimento.

Jabá

Estamos com vagas abertas para nosso time de desenvolvimento. Tem muita coisa legal pra fazer ainda.

--

--

osantana
olist

programação, python, empreendedorismo, startup, motocicletas esportivas, eletrônica, automobilismo, corinthians