A jornada de Rust no iFood

Como foi a nossa experiência adotando Rust nas equipes e quais foram seus resultados

Vanessa Gomes

💻 Logistics Delivery | Software Engineer no iFood

Neste texto vamos compartilhar como foi nossa experiência adotando Rust nas nossas equipes. Vamos falar sobre os motivos de escolher essa linguagem, como foi o processo de adoção, assim como compartilhar os nossos resultados.

O iFood hoje está dividido entre várias grandes áreas, entre elas temos ConsumidoresRestaurantes e Logística. Entre as responsabilidades da tribo de Logística, estão integrar com os pedidos que são feitos e aceitos pelos restaurantes, criar rotas, ofertá-las para os entregadores parceiros e então gerenciar o ciclo de vida dessas entregas.

No semestre passado nos deparamos com alguns desafios por aqui. Algumas aplicações de integração da Logística que conversam com as outras tribos estavam muito acopladas ao domínio de pedidos do iFood — podemos simplificar o domínio de pedidos com o “ciclo de vida daquele lanche que você pediu”. Esse ciclo de vida envolve um restaurante confirmar que pode preparar o seu lanche, o tempo de preparo, se o sanduíche está pronto até que ele chegue ao consumidor final.

De forma resumida, um dos fluxos feitos na tribo de Logística consiste em realizar a integração entre “ter um lanche pronto” e ter uma rota para parceiro entregador que vai levar esse lanche até quem fez o pedido. Nós percebemos, então, que nossa integração Logística estava tecnicamente mais acoplada do que o necessário com o “pedido”. Foi dessa forma que entendemos que precisaríamos de um redesenho da nossa arquitetura de integração.

Durante essa análise, também percebemos que somente a escala horizontal não estava fazendo com que nossos serviços tivessem um melhor desempenho. A escala vertical — aumento de recursos como memória e uso de CPU — também estava se fazendo necessária.

Foi então que entendemos que além do redesenho da arquitetura, precisaríamos escrever novos serviços e iniciar a aposentadoria dos serviços legados.

Foi nesse momento que avaliamos o desempenho de uma das nossas aplicações de integração. Essa aplicação é responsável por vários fluxos além de pedido iFood e ciclo de vida de uma entrega. Existem outros fluxos de negócio dentro desse serviço — logo, deixa de lado o conceito de responsabilidade única.

Esse é um serviço escrito em Java e que se comunica com outras aplicações de forma majoritariamente assíncrona, via filas e tópicos. O tempo de processamento de um evento que consideramos como “tempo de resposta” é o tempo de consumir e finalizar tudo que aquele evento deveria disparar — seja salvar em algum tipo de sistema de armazenamento ou publicar em outro canal de comunicação.

Alguns números dessa aplicação durante os picos mais altos de uso:

  • Tempo de resposta para eventos assíncronos por volta de 100ms.
  • Vazão de cerca de 80 mil eventos por minuto.
  • 25 instâncias no ar, usando 1.5 GB de Memória RAM e cerca de 1 unidade de CPU (abstraindo esse dado, considere que temos instâncias em produção com mesmo tamanho e mesma oferta de poder de processamento).
Com uma conta simples, entendemos que uma instância desse serviço em produção consegue, em média, processar cerca de 3000 eventos por minuto com cada instância usando 1 unidade de CPU e 1.5 GB de RAM.

Escolhendo uma nova linguagem

Escolher a tech stack de um serviço pode ser uma tarefa complicada e com muitos vieses — e ainda levar a muitos resultados possíveis. Geralmente não existem razões óbvias e de consenso geral da comunidade de tecnologia. A nossa escolha precisava ser um conjunto de trade-offs que acabariam por ser compensados entre si.

Em geral, a escolha de uma linguagem vai influenciar muito o design do código assim como seu nível de abstração. A linguagem também pode ter um enorme impacto na complexidade de uma base de código a depender da familiaridade do time com aquela escolha.

Dada a missão de construir aplicações resilientes e que fizessem um uso eficiente dos seus recursos, definimos os seguintes objetivos a serem alcançados com a escolha da tech stack:

  • Resiliência: minimizar a quantidade de incidentes que ocorrem nos ambientes de produção. Fazer com que problemas sejam fáceis de serem detectados para que o time consiga focar no desenvolvimento e melhoria da plataforma. A escolha da tecnologia pode ser crucial a depender de como a linguagem oferece tolerância a falhas e funcionalidades para monitoramento.

  • Produtividade do time: a velocidade no curto prazo pode em geral vir em detrimento da manutenção no longo prazo. Esse objetivo diz respeito à habilidade do time de desenvolvimento em entregar novas funcionalidades de forma rápida e que durem no longo prazo, considerando a quantidade de trabalho geralmente gasta em entender e mudar código antigo.

  • Curva de aprendizado: todo ambiente complexo requer uma curva de aprendizado e a escolha de uma linguagem não seria diferente. Isso impacta tanto no aprendizado da sintaxe como também no ecossistema ao redor.
Prever a curva de aprendizado da tecnologia é extremamente complexo porém crucial para a escolha de uma nova linguagem.
  • Eficiência no uso de recursos: um ambiente que facilite o uso necessário de memória e poder de processamento, tornando assim a escala horizontal mais fácil.

JVM vs Não-JVM

A JVM é um dos ecossistemas mais ricos na indústria. Suporta, praticamente, qualquer integração que se possa imaginar com serviços e recursos do mercado. Possui uma infinidade de ferramentas dedicadas e as linguagens desse ecossistema tem uma presença forte na comunidade de software.

Em geral, “linguagens JVM” conseguem se integrar facilmente com qualquer outra ferramenta ou biblioteca também baseada na JVM. Ou seja, mesmo que uma linguagem não tenha determinada integração, biblioteca ou recurso, é provável que alguma linguagem da família JVM possua.

Isso é um fator muito importante ao considerar a escolha do time. Em adição, o iFood possui um rico ecossistema JVM já construído, testado e em produção.

Java

A linguagem Java mantém sua posição de relevância no ecossistema JVM sendo a mãe de todas as outras linguagens. Possui um ambiente muito rico em bibliotecas e ferramentas de desenvolvimento. Ao seu favor, Java é uma das linguagens mais utilizadas no iFood e conta com muitas pessoas especialistas e bibliotecas customizadas.

Por outro lado, Java em geral é considerada uma linguagem verbosa. Por ser uma linguagem tão vasta, ela favorece o aumento da complexidade na manutenção do seu código em produção.

Mais uma consideração polêmica: Java é uma linguagem orientada a objetos (OO). Não consideramos OO um problema, porém entendemos que muitos conceitos e melhores práticas na produção de código dentro desse paradigma são discutíveis na comunidade de Software. Existem as melhores práticas, mas elas não são um consenso. Ou seja, muito do melhor que se pode extrair do ecossistema fica mais à cargo da disciplina do time de desenvolvimento e menos da segurança que a linguagem oferece.

Alguns exemplos são: erros em tempo de execução silenciosos e inesperados, referências a valores nulos e concorrência checada somente em tempo de execução.

Com esse entendimento, a maior razão para não escrever nossos novos serviços em Java foi a de encontrar uma outra linguagem que ofereça mais segurança ao lidar com erros, mais eficiência no uso de memória e que consiga favorecer a manutenção do código no longo prazo.

Kotlin

Kotlin aparece como uma alternativa dentro do ecossistema JVM. Possui um código que consideramos mais limpo do que Java, mas ainda assim com conceitos de orientação a objetos e as mesmas conclusões relacionadas com Java.

Por outro lado, o ecossistema Kotlin apresenta algumas alternativas interessantes para imutabilidade e aspectos funcionais na linguagem — como o framework Arrow.

Scala

É uma linguagem JVM voltada para Programação Funcional. Ela não é amplamente usada no iFood e como existem outras alternativas que oferecem o mesmo conjunto de funcionalidades, descartamos essa opção.

Clojure

Clojure também é baseada na JVM e é um dialeto da LISP. Hoje, ela representa um nicho específico de linguagens altamente expressivas — onde pessoas desenvolvedoras se aproveitam de um desenvolvimento rápido sem a necessidade de escrever muito código.

Possui imutabilidade por padrão, pouco código considerado boilerplate e é virtualmente livre de sintaxes. No entanto, consideramos a linguagem “exótica”. Avaliamos que a curva de aprendizado seria maior e apresentaria uma mudança de paradigma muito grande para o time de desenvolvimento.

Consideramos que não seria uma boa escolha por ser o primeiro uso no iFood e não sabíamos se poderíamos investir tanto tempo construindo um ecossistema ao redor da linguagem.

Go & Rust

Inicialmente, nós analisamos essas duas linguagens lado a lado porque ambas compartilham de características e funcionalidades similares:

  • As duas são linguagens nativas, isto é, não executam numa máquina virtual como a JVM.
  • Não são orientadas a objetos.
  • São linguagens consideradas de alta performance.

Go 

Go é uma linguagem altamente opinativa: define muito bem como os programas devem ser escritos e não dá muito espaço para fugir do comportamento projetado. Além disso, um dos valores mais importantes é a simplicidade da sintaxe. Portanto, Go favorece uma curva de aprendizado mais suave com pouca coisa no meio do caminho para que o time de desenvolvimento consiga rapidamente aprender as principais funcionalidades e comece a construir suas aplicações.

Por outro lado, Go não tem um conjunto muito grande de funcionalidades, o que pode levar o programa a não ser muito expressivo. Alguns aspectos que nosso time considerou importantes e que ficam de fora de Go são:

  • Suporte padrão para imutabilidade.
  • Generics e iterators.
  • Suporte à programação declarativa.
  • Tratamento de erros extensivo.
  • Modelo computacional depende muito de componentes básicos (como laços do tipo for ifs) para operações mais complexas de iterações — como Maps e Filters.

Apesar disso, Go possui um ecossistema bem desenvolvido, já tinha sido adotada em alguns casos dentro do iFood e possui uma comunidade pública ativa.

Rust

Rust apresenta vários conceitos de outras linguagens de programação funcional de alto nível, mantendo uma sintaxe familiar com o mundo de C. O resultado disso foi uma linguagem muito expressiva e que possui um sistema de tipos bem completo — que pode potencializar um número mais baixo de erros em produção.

Entre as desvantagens de Rust, algumas características que poderiam nos fazer falta:

O ecossistema não é muito maduro. Apesar de o “básico” para construir aplicações backend estar disponível, nem todos as ferramentas mais populares possuem SDKs disponíveis ou amigáveis.
A curva de aprendizado aparentava ser mais íngreme. Tivemos essa impressão principalmente pelo conjunto de funcionalidades “padrão” multi-paradigma.

Por que Rust?

Entre os aspectos positivos da linguagem, tivemos um conjunto nativo de funcionalidades bastante extenso apresentando e que enxergamos como suas vantagens:

  • Boa semântica para tratamento de erros.
  • Sistema de tipos forte e completo, assim como um forte sistema de inferência de tipos — o que acabou a aproximando de outras linguagens funcionais.
  • Funcionalidades nativas para lidar com iterators com uma abordagem mais funcional.
  • Uso de memória seguro e eficiente. Não existe a necessidade de um coletor de lixo (garbage collector). Em Rust, sempre após a execução de um escopo, quando um dado não é mais utilizado ele é desalocado e esse espaço é liberado.
  • Compilador inteligente e amigável. As mensagens de erro são muito descritivas e o compilador já confere aspectos que em outras linguagens só são checadas durante o tempo de execução: problemas de condições de corrida, acesso à dados que não estão mais disponíveis na memória, inferência de tipos incorreta ou erros que não são capturados e causam situações de pânico.
  • Concorrência segura. A linguagem não permite de forma explícita que múltiplos escopos manipulem o mesmo dado na memória. Para que isso aconteça, Rust oferece um sistema de ponteiros que previne acesso a dados inválidos. Caso isso ocorra, o código pode não compilar forçando quem desenvolve o programa a lidar com a possibilidade do erro explicitamente.
  • Imutabilidade por padrão. O programa deve explicitamente declarar variáveis mutáveis e gerenciar o ciclo de vida daqueles dados.

Documentação extensa e amigável: Rust possui uma das melhores documentações entre as linguagens que analisamos.

Diante desses aspectos e ainda assim assumindo que a curva de aprendizado não seria tão fácil no início, o time decidiu escolher Rust. Pareceu ser um bom preço a se pagar dados os resultados que poderíamos atingir com esses novos serviços.

Por fim, uma outra equipe no iFood tinha recentemente desenvolvido uma aplicação em Rust. Ou seja, havia outras pessoas no nosso barco também.

Adotando Rust

Quando decidimos adotar Rust como a linguagem principal para o desenvolvimento de novas aplicações dentro do nosso domínio, não havia nenhuma pessoa no time com experiência sólida na linguagem. A equipe que formamos inicialmente era bem madura, sendo composta por pessoas com um background bem diverso em termos de linguagem de programação — Java principalmente, mas também Clojure, Haskell, F# e Javascript.

Como algumas pessoas tinham um pouco de familiaridade com a linguagem, nossas primeiras tarefas acabaram gravitando naturalmente para elas enquanto os demais membros do time, com mais tempo de casa, se dividiam nas tarefas de manutenção dos sistemas legados, além do planejamento da nova arquitetura.

Para evitar que isso se tornasse um padrão recorrente e garantir que esse conhecimento pudesse ser transferido homogeneamente para toda a equipe, decidimos que iríamos priorizar o trabalho em pares sempre que possível, conforme novas demandas de Rust fossem surgindo.

Ao praticar o pareamento num contexto similar ao nosso, é comum o problema de uma pessoa inexperiente tentar guiar outra — o que em geral torna as sessões de pareamento bem cansativas e, em última instância, leva ao abandono da prática. Para que isso não ocorresse, procuramos parear pessoas mais à frente na sua jornada de aprendizado com aquelas que ainda estavam no seu primeiro contato. Outro fator importante foi flexibilizar a nossa abordagem conforme o time amadurecia, de forma que trabalhar em pares ou em grupo poderia ocorrer de maneira mais ou menos pontual conforme as pessoas fossem adquirindo mais independência. Optamos assim por não tornar essa prática obrigatória e deixar o time ter autonomia para definir sua melhor rotina para desenvolvimento.

Além de pareamento e programação em grupo, é prática estabelecida em nossos times dedicar um turno na semana exclusivamente para desenvolvimento pessoal, onde o trabalho em atividades do quadro de tarefas pode ser suspenso e reuniões são opcionais. Cada pessoa também conta com um orçamento dedicado ao auto desenvolvimento, podendo consumi-lo pagando por cursos, livros e quando possível viagens para conferências e workshops.

Temos alguns canais dedicados a falar sobre Rust no iFood, mas além disso contamos com uma reunião semanal onde as pessoas do time trocam experiências, dividem aprendizados e tiram dúvidas umas com as outras. Como somos um time 100% remoto, momentos como esse são importantes pra mantermos nosso vínculo como equipe, além de ser estimulante poder compartilhar com os colegas aquilo que estamos aprendendo.

Desafios

Ainda falando sobre aprendizado, entender o modelo de memória da linguagem foi o nosso primeiro grande desafio. Apesar de Rust não possuir formalmente um modelo de memória, o mecanismo de borrow checking do compilador nos força a pensar constantemente no tempo de vida e no uso de cada variável que declaramos.

Temas como monomorfização, dynamic dispatch, as diferenças entre heap e stack etc. estavam um pouco distantes da nossa realidade e por isso foi necessário um esforço intencional nos nossos estudos para não só entender o racional por trás das sugestões do compilador, mas também voltarmos a programar na velocidade a qual estávamos habituados.

Outro desafio foi a carência em termos de ferramentas de infraestrutura. Para que nossas aplicações se tornassem aderentes aos padrões de qualidade estabelecidos pela engenharia do iFood, tivemos que desenvolver algumas soluções customizadas. Como por exemplo:

  • Logs: existe uma certa variedade de ferramentas de logs já desenvolvidas pela comunidade, mas em geral são bem diferentes do estilo a que estamos habituados com a JVM. Não encontramos algo como o Logback que pudesse ser configurado com um XML na raiz da aplicação. Hoje estamos bem satisfeitos com a crate tracing, mas foi necessário uma camada de código extra desenvolvida por nós pra que tudo ficasse no formato que queríamos sem que isso contaminasse a arquitetura das aplicações.

  • Monitoramento: o New Relic é uma das principais ferramentas que utilizamos pra monitorar nossas aplicações. Infelizmente eles não oferecem nenhum cliente oficial pra Rust e por isso tivemos que adotar uma solução desenvolvida pela comunidade — bindings em cima do SDK em C oficial. Enfrentamos alguns problemas de estabilidade mais outros de usabilidade e por isso decidimos manter um fork internamente pra atender as especificidades da nossa demanda. Estamos evoluindo a nossa própria camada de instrumentação desenvolvida em cima da crate tracing que até então vem nos atendendo bem, mas de qualquer forma foi necessário um investimento do time.

  • Kafka: muito parecido com o que aconteceu com as ferramentas de monitoramento, também precisamos desenvolver uma solução própria, a partir de bibliotecas open source, pra fornecer recursos de comunicação assíncrona às aplicações. Hoje dispomos de uma biblioteca com implementações próprias para produtores e consumidores Kafka compatíveis com Avro e JSON.

Um problema mais recente, e que parece não ser específico da nossa experiência no iFood, é a incompatibilidade entre runtimes assíncronos. A implementação que adotamos, Tokio, não é compatível — em algumas de suas versões, principalmente as anteriores à 1.0.0 — com alguns dos recursos de execução assíncrona fornecidos pela linguagem. Isso nos força a garantir que as nossas dependências que também dependem do Tokio estejam todas sob um conjunto de versões compatível. Como hoje temos uma forte dependência no Actix, um framework para desenvolvimento de aplicações Web que depende do Tokio pré 1.0.0, ainda precisamos desse malabarismo pra garantir a compatibilidade entre runtimes. É um problema que estamos em vias de resolver, mas de qualquer forma foi algo que não conseguimos prever quando adotamos a linguagem e nos deu um certo trabalho.

Por fim, temos a integração com a AWS. Nossas aplicações precisam interagir com uma série de serviços da nuvem pra que consigam desempenhar bem o seu papel dentro do nosso domínio: fazer a ponte entre as origens de um pedido e o seu destino através do domínio de entregas.

Dá para imaginar que pra isso acontecer a nossa infraestrutura de nuvem desempenha um papel muito importante. Dito isso, é importante ressaltar que a AWS não fornece um SDK oficial para Rust. Existe sim um projeto oficial em desenvolvimento mas que ainda está em alpha, portanto sem previsão de lançamento num futuro próximo. A alternativa que encontramos foi a crate rusoto, que apesar de não ser desenvolvida pela própria AWS, é fruto do trabalho incrível de pessoas muito dedicadas e capazes dentro da comunidade de código aberto. Tem nos atendido muito bem, mas existe sempre o risco envolvido em ser um produto não oficial: compatibilidade de funcionalidades e suporte dedicado sendo alguns dos mais relevantes. É um ponto de atenção caso sua organização dependa de algo muito específico da AWS ou se suporte técnico for algum requisito contratual do qual não se possa abrir mão.

Resultados Até Aqui

Apesar de todos os pontos mencionados na seção de desafios, estamos bastante satisfeitos com a nossa decisão em adotar Rust. O que chamou nossa atenção — logo de cara — e nos passou aquela sensação de que estamos seguindo no caminho correto foi o quão bem as aplicações se comportaram no primeiro teste de carga que fizemos.

Quando iniciamos o desenvolvimento, não era a nossa prioridade otimizar as transações para o melhor uso de recursos computacionais, ou atingir o melhor tempo de resposta. Primeiro, nós queríamos entregar o MVP. Foi então que nos deparamos com esses resultados:

Nossos serviços apresentam um desempenho muito superior e com um consumo de recursos muito abaixo do atual
95% das nossas transações são processadas abaixo de 40ms
Vazão de cerca de 30 mil eventos por minuto com 4 instâncias no ar

Com tudo isso, Rust se mostrou como a escolha correta quando atingimos resultados tão bons. A qualidade das aplicações também não se reflete apenas na performance. Apesar do alto volume de transações, nossas aplicações têm se comportado muito bem e com baixa ocorrência de incidentes, que quando surgem costumam se concentrar em problemas ou de infraestrutura ou de bugs em contratos mal modelados — o que é passível de ocorrer em praticamente qualquer linguagem.

Além dos números, hoje temos uma equipe com sentimento de realização por entregar serviços de alta eficiência. Uma coisa que nos estimula bastante, também, é quando nos deparamos com situações outliers:

Processamento de um evento que durou menos de 1ms

Cada conquista e resultado que percebemos nos deixa felizes e motivados por alcançarem resultados rápidos enquanto aprendem uma nova tecnologia e entregam valor para o iFood.

Futuro

A jornada de adoção de Rust no iFood ainda está no começo. Nossos planos são ambiciosos e para atingir os objetivos que traçamos ainda temos muita coisa para construir.

Nossas demandas aumentando só reforçam a necessidade de expandir nossas equipes ainda mais e continuar investindo nos processos de onboarding. Melhorar as nossas dinâmicas de trabalho é um aprendizado constante e algo que levamos muito a sério.

Com a escala das aplicações, novos requisitos vão surgindo para as nossas bibliotecas de infraestrutura que ainda não estão 100% no nível de maturidade que desejamos. Um dos nossos objetivos de médio prazo é reforçar o investimento em ferramental para superar os níveis de produtividade comparado a outras linguagens estabelecidas no iFood.

Também temos o desejo de ter mais participação junto à comunidade brasileira de Rust e expandir nosso impacto para além do iFood. Nossa intenção é estimular a troca de experiência com outros players do mercado e fomentar a adoção da linguagem.

Esse conteúdo foi útil para você?
SimNão

Publicações relacionadas