Python e microserviços: Introdução ao Loafer

by George Y. Kussumoto

Loafer é uma biblioteca open-source feita em python, para facilitar a criação de serviços, em especial aqueles que consomem ou geram eventos (mensagens).

O projeto nasceu como prova de conceito para Olist e hoje é o alicerce de dezenas de micro-serviços em produção, facilitando a manutenção e criação de novos serviços.

Para compreender um pouco melhor sobre nossa arquitetura, o cenário e onde os micro-serviços se encaixam, recomendo a leitura do artigo escrito pelo osantana sobre o funcionamento da plataforma da Olist.

O loafer também serviu para um grande aprendizado, principalmente sobre asyncio e programação assíncrona de modo geral — embora serviços também possam ser escritos com código síncrono, não recomendamos essa prática com o loafer (nesse caso, utiliza-se threads para execução do serviço).

Não é preciso ter conhecimentos profundos sobre asyncio, python ou da AWS para utilizar o loafer, mas também não atrapalha se tiver 😊

Todos os trechos de código estão no github.

Cenário

Uma visão simplificada da arquitetura que vamos precisar:

  • Tópico SNS : notificador de eventos, com zero ou mais filas SQS inscritas
  • Fila SQS : recebe as mensagens do tópico SNS, possui uma dead-letter-queue que retêm as mensagens que não puderam ser processadas na fila principal (maiores detalhes posteriormente)
  • Um serviço que consome mensagens da fila SQS e realiza uma determinada ação (ou gera um novo evento/mensagem). O serviço é dono da fila, ela nunca é compartilhada.

Ambiente de desenvolvimento

Embora seja opcional, é sempre interessante criar um virtualenv e considere o uso de qualquer versão acima de Python 3.5.2 daqui em diante.

Pra refletir com mais fidelidade o funcionamento de uma aplicação, vamos utilizar um clone local da AWS, com os elementos arquiteturais descritos anteriormente usando goaws.

    $ docker pull pafortin/goaws
    $ docker run -d --name goaws -p 4100:4100 pafortin/goaws
    # caso queira apenas parar ou iniciar o processo:
    # docker start goaws
    # docker stop goaws
    # -> resets causam perda de dados/configurações

Para interagir e configurar o “nosso” AWS, vamos utilizar o aws-cli.

    pip install awscli

Se for a primeira vez que tenha instalado o awscli, você pode precisar configurar suas credenciais, elas não precisam necessariamente ser válidas para conseguir manipular o goaws.

Vamos criar um tópico SNS “friend-created”:

    $ aws --endpoint-url <http://localhost:4100> sns create-topic \
      --name friend-created
    {
        "TopicArn": "arn:aws:sns:local:000000000000:friend-created"
    }

E uma fila SQS chamada “foobar-friend-created”:

    $ aws --endpoint-url <http://localhost:4100> sqs create-queue \
      --queue-name foobar-friend-created
    {
        "QueueUrl": "<http://localhost:4100/queue/foobar-friend-created>"
    }

Para simplificar um pouco, não vamos configurar uma dead-letter-queue. O mecanismo faz bastante sentido em um ambiente de produção, mas para testes locais, sempre podemos reenviar uma mensagem manualmente quando necessário.

Finalmente, vamos inscrever a fila “foobar-friend-created” no tópico “friend- created”:

    $ aws --endpoint-url <http://localhost:4100> sns subscribe \
      --topic-arn arn:aws:sns:local:000000000000:friend-created \
      --protocol sqs --notification-endpoint     <http://localhost:4100/queue/foobar-friend-created>
    {
        "SubscriptionArn": "arn:aws:sns:local:000000000000:friend-created:70b25381-80e4-4cce-bf44-65c505ec8d4b"
    }

Montar esse ambiente não é exatamente um requisito obrigatório, mas creio que dê mais credibilidade e segurança durante o desenvolvimento de um serviço. Caso tenha disponibilidade, nada impede o uso de uma conta da AWS diretamente ou injetar manualmente mensagens no serviço sem utilizar qualquer infra- estrutura.

Para concluir nosso ambiente, vamos organizar nosso serviço dentro de um diretório “foobar_friend_service” (módulo) e instalar o loafer:

    $ mkdir -p foobar_friend_service
    $ touch foobar_friend_service/__init__.py
    $ pip install loafer

🚀

Loafer

Os componentes principais do loafer são:

  • handler : processa a mensagem
  • message-translator : faz adequação da mensagem para que um handler faça o processamento
  • provider : busca/recebe mensagens de uma determinada fonte (no caso, uma fila SQS)
  • route : combinação de um handler , message-translator e provider
  • manager : gerencia uma ou mais routes

Pode parecer que há muitos elementos para gerenciar, mas na prática é bem trivial: o “ handler ” é o código que será implementado, o restante é praticamente configuração e boilerplate.

Vamos exemplificar a implementação de um serviço simples, iremos processar uma mensagem (json) que possui as informações abaixo:

  • id : identificador do recurso
  • username : nome de um usuário
  • github_url : url do usuário no github
    {
        "id": "test-1",
        "username": "georgeyk",
        "github_url": ""
    }

Caso um usuário não tenha configurado o “github_url”, nosso serviço deve buscar essa informação e adicionar no registro. A implementação deve ficar parecida com (“foobar_friend_service/handlers.py”):

gist

Tente ignorar todas as possíveis melhorias no código, o método importante da classe é async def handle(self, message, *args). Todo handler precisa ter uma assinatura semelhante; o *args contêm alguns metadados e eles podem variar bastante, inclusive vir vazio, então se houver alternativa, não confie nas informações que vierem ali (mas podem ser úteis para auditoria).

A message é um dicionário python comum e reflete o json enviado ao evento. Um handler obrigatoriamente precisa retornar um boolean ou algo que possa ser avaliado com bool(). O retorno é utilizado para confirmar o processamento da mensagem:

True: mensagem processada com sucesso, ela será confirmada e removida da fila SQS

False: mensagem não processada com sucesso, ela ficará na fila SQS ou será movida para a dead-letter-queue

Qualquer desvio do fluxo será tratado do mesmo modo como se houvesse um return False, nunca removemos nada da fila SQS de forma implícita. Cuidado com retornos dúbios para não remover uma mensagem acidentalmente.

Agora precisamos definir nossas rotas (“foobar_friend_service/routes.py”):

gist

Perceba que nossa rota define uma instância de FoobarFriendCreatedHandler como handler , alternativamente poderíamos passar uma função/ coroutine também; usando classes convencionamos que o entrypoint é sempre o método handle().

Outro detalhe sobre a configuração das rotas é que o SNSQueueRoute automaticamente define um message-translator apropriado para mensagens que fazem o caminho SNS ▶️ SQS. A mensagem vem “envelopada” de diferentes modos dependendo de quem a colocou na fila.

O provider_options só é necessário porque precisamos apontar para nosso ambiente local, removendo essa customização, a biblioteca boto automaticamente apontará para os endpoints corretos da amazon.

Por fim, criamos o ponto de partida para o nosso serviço (“foobar_friend_service/run.py”):

gist

Se os deuses dos tutorais permitirem 🙏, podemos executar nosso serviço:

    $ python -m foobar_friend_service.run
    iniciando serviço ...

Ué ~ nada acontece feijoada ~ ?

Para o nosso serviço trabalhar de verdade, precisamos criar um evento no SNS:

    $ aws --endpoint-url <http://localhost:4100> sns publish \
      --topic-arn arn:aws:sns:local:000000000000:friend-created \
      --message file://test_user.json
    {
        "MessageId": "e2d086a8-7cbc-473b-b50e-2923976db4d4"
    }

Nosso serviço deve ter impresso o response de uma requisição bem sucedida:

    {'args': {},
     'data': '{"id": "test-1", "github_url": "<https://github.com/georgeyk>"}',
     'files': {},
     'form': {},
     'headers': {'Accept': '*/*',
                 'Accept-Encoding': 'gzip, deflate',
                 'Connection': 'close',
                 'Content-Length': '61',
                 'Content-Type': 'application/json',
                 'Host': 'httpbin.org',
                 'User-Agent': 'Python/3.5 aiohttp/2.2.5'},
     'json': {'github_url': 'https://github.com/georgeyk', 'id': 'test-1'},
     'origin': '...',
     'url': 'http://httpbin.org/patch'}

Para finalizar, use Control-C ou mate o processo, o padrão é que o serviço fique “ouvindo” a fila indefinidamente.

Uma dica final sobre a implementação, lembre sempre que é apenas código python. Parece óbvio, mas é para reforçar que tentamos não impor ou restringir o modo como um serviço é desenvolvido, o que temos aqui é apenas um exemplo, nada além disso.

Se você não fez o setup do goaws por exemplo, uma validação local seria parecida com:

gist

Pronto! Terminamos nosso serviço 👊

Lembrando novamente que os códigos de exemplo estão no github , caso queira aprofundar um pouco mais, também temos uma documentação inicial.

Erros e monitoramento

Nosso serviço é bem simples e praticamente não possui regras de negócio sofisticadas, mas é bem próximo de um serviço real e em produção.

A ausência de tratamento de erros por exemplo, é intencional. No contexto do nosso serviço e arquitetura, é importante notar que a infra-estrutura e serviços de monitoramento são partes integrantes da aplicação.

Sempre que um erro ( exception) não tratado interromper o fluxo de execução do serviço, temos o seguinte comportamento:

  • o handler não irá confirmar o processamento da mensagem (isto é, o return True não será alcançado fora do fluxo esperado)
  • toda fila SQS possui um “visibility timeout” (por padrão, 30 segundos) e após o tempo expirar, a mensagem voltará a ficar disponível ao serviço (ou seja, um mecanismo de retry )
  • as retentivas irão ocorrer até a mensagem ser confirmada pelo handler ou então, após atingir um “maximum receives” a mensagem será encaminhada para uma dead-letter-queue

Vamos levantar algumas situações fora do fluxo esperado de execução e seu reflexo na confirmação da mensagem:

  • indisponibilidade de serviços : caso alguma API pare de responder corretamente, a mensagem não deve ser confirmada (HTTP 5xx, timeout, etc)
  • informações inválidas na mensagem : caso alguma “coisa” alimente a fila SQS com mensagens contendo valores inválidos, o serviço dispara uma requisição inválida e a mensagem não será confirmada (HTTP 4xx)
  • chaves inválidas na mensagem: mensagem não confirmada (via KeyErrorpor exemplo)
  • mais de uma instância do serviço processando a mesma mensagem : se uma das instâncias confirmar a mensagem, a mensagem sairá da fila SQS independente da confirmação das outras instâncias; do contrário, a mensagem ficará no ciclo de retentiva como descrito anteriormente

O último cenário levantado é o único que não é um erro, mas uma característica do SQS. Recomenda-se que todo serviço seja idempotente, mas existem situações em que essa característica pode ser desconsiderada (ou não implementada), pois não existe um efeito colateral grave.

A responsabilidade de implementar algum mecanismo de idempotência geralmente é delegada para o componente que possuir maior conhecimento das regras de negócio. Depende bastante do domínio e da arquitetura, usualmente o serviço não faz isso, apenas “trabalha junto” com as regras definidas externamente.

Nos demais cenários, caso um serviço de monitoramento esteja configurado (como o sentry), ele também deve capturar esses “erros” de execução. Existem alguns casos em que não precisamos desse report , uma vez que o comportamento de retentiva é esperado (erros HTTP 5xx por exemplo). Para tal, basta tratar essa situação e com um return False no handler, indicamos que não queremos confirmar a mensagem (deixo como exercício).

E as outras situações que o serviço de monitoramento capturar?

É bug (ou feature?)! 😄 🐛

Cenas do próximo capítulo…

A introdução ficou um pouco maior que o planejado, mas acredito que dê subsídio suficiente para quem quiser experimentar a criar os primeiros serviços utilizando o loafer e compreender os motivos que levaram a criação da biblioteca.

Como dito anteriormente, o loafer é um projeto de código aberto (contribuições são bem vindas 😃) e que ainda tem bastante para amadurecer. Acredito que seja um passo adiante para facilitar a criação de serviços em uma arquitetura orientada à eventos, embora ainda tenha restrições e vários pontos de melhoria.

Futuramente, vamos abordar casos de uso mais complexos, comparar com outras soluções e discutir vantagens e desvantagens da biblioteca. Maiores informações também podem ser obtidas na documentação do projeto.

Caso tenha se interessado ou tenha dúvidas e sugestões, não deixe de enviar um feedback.

✌️

Originalmente publicado no medium.