Prenda-me Se For Capaz - Vazamentos de Memória

Dec 01 2022
Uma retrospectiva sobre um vazamento de memória
Introdução Vazamentos de memória são uma daquelas coisas que, quando acontecem, podem realmente deixá-lo no fundo do poço. Diagnosticá-los parece uma tarefa desafiadora no início.
Engenheiros da Elli versus vazamento de memória (ilustração de Jane Kim)

Introdução

Vazamentos de memória são uma daquelas coisas que, quando acontecem, podem realmente levá-lo ao fundo do poço. Diagnosticá-los parece uma tarefa desafiadora no início. Eles exigem um mergulho profundo nas ferramentas e componentes dos quais seu serviço depende. Este exame detalhado não apenas aprofunda sua compreensão do cenário de serviço, mas também fornece uma visão de como as coisas funcionam sob o capô. Embora assustadores à primeira vista, os vazamentos de memória são essencialmente uma bênção disfarçada.

Na Elli, fazemos o possível para minimizar a dívida técnica ao mínimo. No entanto, incidentes ainda acontecem, e nossa abordagem é aprender e compartilhar conhecimento resolvendo tais problemas.

Então, este artigo visa fazer exatamente isso. Nesta postagem, mostramos nossa abordagem para identificar um vazamento de memória e compartilhamos nossos aprendizados ao longo do caminho.

Contexto

Antes de nos aprofundarmos no reparo do vazamento de memória, precisamos de algum contexto sobre a infraestrutura de Elli e onde o vazamento de memória ocorreu em primeiro lugar.

Elli, entre outras coisas, é Operadora de Ponto de Carregamento. Somos responsáveis ​​por conectar estações de carregamento (CSs) ao nosso back-end e controlá-los via protocolo OCPP . Portanto, nossos clientes podem carregar seus VEs em estações públicas ou privadas. Os CSs são conectados aos nossos sistemas via WebSockets. Quando se trata de autenticação, oferecemos suporte a conexões via TLS ou TLS mútuo (mTLS). Durante o TLS, um CS verificará nosso certificado de servidor e garantirá que ele se conecte a um back-end Elli. Com o mTLS, também verificamos se o CS possui um certificado de cliente emitido por nós.

Do lado da conectividade, um servidor escrito em Node.js, é responsável por cuidar da lógica UPGRADE de HTTP para WebSockets e manter o estado das conexões. Ele é implantado em um cluster Kubernetes e gerenciado por um horizontal Pod Autoscaler (HPA). Idealmente, o HPA segue a carga de tráfego e dimensiona os pods para cima ou para baixo de acordo.

Mantemos dezenas de milhares de conexões TCP persistentes e de longa duração das estações de carregamento simultaneamente . Isso introduz complexidade e difere significativamente dos serviços RESTful típicos. Uma métrica de proxy que rastreia a carga é a utilização de memória, pois reflete o número de conexões estabelecidas e a lógica do aplicativo não requer muitos cálculos. Nossos pods têm vida longa e o escalonamento via memória nos levou à observação de que o número de pods está aumentando lentamente para um número constante de conexões. Para encurtar a história, detectamos um vazamento de memória.

Avaliação impactante

Diante de qualquer tipo de problema de produção, a equipe de engenharia da Elli avalia imediatamente as implicações desse incidente em nossos clientes e nos negócios. Então, ao descobrir esse vazamento de memória, fizemos a seguinte avaliação:

O aplicativo está vazando memória em questão de dias. Isso significa que, sem receber nenhum tráfego adicional, nossa infraestrutura continua crescendo.

Quando um pod não consegue lidar com tráfego adicional, graças à sondagem de prontidão do Kubernetes, ele para de receber tráfego adicional, mas continua atendendo às conexões estabelecidas. Um pod que serviria conexões X poderia acabar servindo apenas uma fração de suas capacidades devido ao vazamento, sem causar nenhuma interrupção no lado do cliente. Isso significa que podemos absorver prontamente o impacto simplesmente girando mais pods.

A investigação

Agora, para o mergulho técnico real no vazamento de memória.

Aqui explicamos as ferramentas e métodos que usamos para descobrir a fonte por trás do vazamento de memória, o que esperávamos ver em nosso experimento e o que realmente observamos. Incluímos links para os recursos que usamos em nossa investigação para sua referência.

Uma cartilha rápida para a memória JS

As variáveis ​​em JavaScript (e na maioria das outras linguagens de programação) são armazenadas em dois locais: pilha e heap. Uma pilha geralmente é uma região contínua de memória alocando contexto local para cada função em execução. Heap é uma região muito maior armazenando tudo alocado dinamicamente. Essa separação é útil para tornar a execução mais segura contra corrupção (a pilha fica mais protegida) e mais rápida (sem necessidade de coleta de lixo dinâmica dos quadros da pilha, alocação rápida de novos quadros).

Apenas os tipos primitivos passados ​​por valor (Number, Boolean, referências a objetos) são armazenados na pilha. Todo o resto é alocado dinamicamente do pool compartilhado de memória chamado heap. Em JavaScript, você não precisa se preocupar em desalocar objetos dentro do heap. O coletor de lixo os libera sempre que ninguém os está referenciando. Obviamente, criar um grande número de objetos prejudica o desempenho (alguém precisa manter toda a contabilidade), além de causar fragmentação da memória.

Fonte:https://glebbahmutov.com/blog/javascript-stack-size/

Tirando um instantâneo de heap de um pod de produção | Capturas Instantâneas e Criação de Perfil

Expectativas

Coletamos instantâneos de heap regulares de nosso aplicativo para ver um acúmulo de objetos ao longo do tempo. Devido à natureza do aplicativo, principalmente mantendo conexões WebSocket, esperávamos que os objetos TLSSocket correspondessem ao número de conexões no aplicativo. Nossa hipótese é que, quando uma estação é desconectada, o objeto ainda é referenciado de alguma forma. A coleta de lixo funciona limpando objetos inacessíveis; portanto, nesse caso, os objetos seriam deixados intactos.

Resultados

Obter um despejo de heap de um pod 90% utilizado resultou no intervalo de 100 MB. Cada pod solicita cerca de 1,5 GB de RAM e o heap era inferior a 10% da memória alocada. Isso parecia suspeito…

Onde estava o resto da memória alocada? No entanto, continuamos a análise. Tirar três instantâneos em intervalos e observar a mudança na memória ao longo do tempo não revelou nada. Não notamos acúmulo de objetos nem problemas com a coleta de lixo. O despejo de heap parecia bastante saudável.

Imagem 1: Capturar instantâneos de pilha por meio de ferramentas de desenvolvimento do Chrome de um pod de produção. O número de objetos TLSSocket se alinha com a conexão do pod atual, ao contrário dos resultados esperados.

Os objetos TLSSocket correspondiam ao estado do aplicativo. Voltando à primeira observação, o heapdump é uma ordem de grandeza menor que a utilização de memória. Pensamos: Isso não pode estar certo. Estamos procurando no lugar errado. Precisamos dar um passo atrás.”

Além disso, traçamos o perfil do aplicativo por meio do Cloud Profiler oferecido pelo GCP. Estávamos interessados ​​em ver como os objetos são alocados com o passar do tempo e potencialmente identificar o vazamento de memória.

Obter um despejo de heap bloqueia o thread principal e pode potencialmente matar o aplicativo, ao contrário disso, o criador de perfil pode ser mantido em produção com pouca sobrecarga.

O Cloud Profiler é uma ferramenta de criação de perfil contínua projetada para aplicativos em execução no Google Cloud. É um perfilador estatístico ou de amostragem com baixa sobrecarga e é adequado para ambientes de produção.

Embora o criador de perfil tenha contribuído para nossa compreensão dos inquilinos do heap, ele ainda não nos deu nenhuma pista sobre a investigação. Pelo contrário, nos afastou de ir na direção certa.

Alerta de spoiler: o criador de perfil, no entanto, nos forneceu informações bastante valiosas durante um incidente na produção em que identificamos e consertamos um vazamento de memória agressivo, mas isso é uma história para outra hora.

Estatísticas de uso de memória

Precisávamos de mais informações sobre o uso da memória. Criamos painéis para todas as métricas que process.memoryUsage() tinha a oferecer.

O heapTotal e o heapUsed referem-se ao uso de memória do V8.

O externo refere-se ao uso de memória de objetos C++ vinculados a objetos JavaScript gerenciados pelo V8.

O rss, Resident Set Size , é a quantidade de espaço ocupado no dispositivo de memória principal (ou seja, um subconjunto da memória total alocada) para o processo, incluindo todos os objetos e códigos C++ e JavaScript.

O arrayBuffers refere-se à memória alocada para ArrayBuffers e SharedArrayBuffers , incluindo todos os Buffers Node.js . Isso também está incluído no valor externo. Quando o Node.js é usado como uma biblioteca incorporada, esse valor pode ser 0 porque as alocações para ArrayBuffers podem não ser rastreadas nesse caso.

Imagem 2: Uma visualização do conteúdo RSS. Não existe um modelo oficial atualizado da memória do V8, pois ela muda com bastante frequência. Este é o nosso melhor esforço para descrever o que está sob o RSS para que possamos ter uma imagem mais clara dos possíveis componentes de memória que vazam memória. Se você quiser saber mais sobre o coletor de lixo, sugerimos https://v8.dev/blog/trash-talk. Obrigado a @mlippautz pelo esclarecimento.

Como vimos anteriormente, estávamos obtendo instantâneos de heap de aproximadamente 100 MB de um contêiner que tinha mais de 1 GB de utilização de memória. Onde está o restante da memória alocada? Vamos dar uma olhada.

Imagem 3: Utilização de memória por pod (95º percentil). Ele cresce com o tempo. Nada de novo aqui, estamos cientes do vazamento de memória.
Imagem 4: O número de conexões por pod ao longo do tempo (95º percentil); os pods estão lidando com cada vez menos conexões.
Imagem 5: Heap usou memória (95º percentil). O heap está alinhado com o tamanho dos instantâneos que coletamos e é estável ao longo do tempo.
Imagem 6: Memória externa (95º percentil): pequena em tamanho e estável.
Figura 7: Utilização de memória e tamanho do conjunto residente (RSS) (95º percentil). Existe uma correlação - o RSS está seguindo o padrão.

O que sabemos até agora? O RSS está crescendo, o heap e o externo estão estáveis ​​o que nos leva ao stack. Isso pode significar um método que é chamado e nunca sai, levando a um estouro de pilha. No entanto, a pilha não pode ter centenas de MBs. Neste ponto, já testamos em um ambiente de não produção com vários milhares de estações, mas não obtivemos nenhum resultado.

alocador de memória

Durante o brainstorming, consideramos a fragmentação da memória: blocos de memória são alocados de forma não sequencial, levando a pequenos blocos de memória que não podem ser usados ​​para novas alocações. No nosso caso, o aplicativo é de execução longa e faz muita alocação e liberação. A fragmentação da memória é uma preocupação válida nesses casos. Pesquisas extensas no Google nos levaram a um problema no GitHub , onde o pessoal enfrentou a mesma situação que nós. O mesmo padrão de vazamento de memória foi observado e alinhado com nossa hipótese.

Decidimos testar um alocador de memória diferente e mudamos de musl para jemalloc . Não encontramos resultados significativos. Nesse ponto, sabíamos que precisávamos fazer uma pausa. Tivemos que repensar totalmente a abordagem.

Será que o vazamento só aparece em conexões mTLS?

Durante nossos primeiros testes, tentamos reproduzir o problema em um ambiente de não produção, mas não tivemos sorte. Fizemos testes de carga com milhares de estações simulando diferentes cenários, conectando/desconectando estações por dias, mas eles não produziram resultados significativos. No entanto, começamos a ter uma suspeita crescente de que havia algo que perdemos durante a execução desses testes.

Não consideramos que nossas estações podem se conectar via TLS ou mTLS. Nosso primeiro teste incluiu estações TLS, mas não mTLS, e a razão para isso é simples: não conseguimos criar facilmente estações mTLS e os respectivos certificados de cliente. Um incidente recente nos motivou a minimizar o raio de explosão e dividir as responsabilidades do aplicativo para que cada implantação lidasse com o tráfego TLS e mTLS separadamente. Eureca! O vazamento de memória aparece apenas em nossos pods mTLS, enquanto no TLS a memória é estável.

Para onde vamos daqui?

Decidimos que há duas opções: (1) Passar para nossos próximos suspeitos — uma biblioteca que lida com todas as tarefas da Infraestrutura de chave pública, bem como uma possível recursão em algum lugar nesse caminho de código, (2) ou viver com ela até retrabalharmos totalmente nosso serviço.

Durante a investigação do vazamento de memória, muitos tópicos imprevistos foram trazidos à nossa atenção relacionados ao serviço afetado. Levando em consideração o vazamento de memória e tudo mais que descobrimos, decidimos melhorar nosso cenário de serviço e dividir as responsabilidades do serviço . O fluxo de autenticação e autorização de CS, entre outros, seria delegado ao novo serviço e usaríamos as ferramentas certas para lidar com tarefas de PKI.

Resumo

Melhorar nosso escalonamento revelou que temos um vazamento de memória que poderia ter passado despercebido por tempo indeterminado. Priorizar o cliente e avaliar o impacto do vazamento foi o primeiro e mais importante. Só então conseguimos definir o ritmo de nossa investigação, pois percebemos que não havia impacto no cliente. Começamos com o local mais óbvio para procurar ao diagnosticar um vazamento de memória — a pilha. A análise da pilha, no entanto, nos mostrou que estávamos olhando para o lugar errado. Mais pistas eram necessárias e a API de processo do V8 nos deu exatamente isso. Nos primeiros resultados que obtivemos, o vazamento de memória apareceu no RSS. Por fim, analisando toda a informação recolhida, suspeitamos de fragmentação da memória.

Alterar o alocador de memória não melhorou a situação. Em vez disso, mudar nossa abordagem e dividir a carga de trabalho entre TLS e mTLS nos ajudou a restringir o caminho do código afetado.

Quais foram os resultados finais de nossa investigação?

Nossos planos para melhorar a escalabilidade, além de abordar o vazamento de memória, nos fizeram decidir dividir o serviço e escrever um novo para cuidar do fluxo de conectividade do CS separadamente dos outros específicos do CS.

Consertamos nosso vazamento de memória?

O tempo dirá, mas eu diria que a investigação foi muito mais do que isso. A experiência de investigar o vazamento nos ajudou a crescer como desenvolvedores e nosso serviço a adotar uma arquitetura mais resiliente e escalável.

Pontos-chave e aprendizados

  • Embora os desafios de engenharia unam as pessoas; jogamos pingue-pongue sobre ideias com engenheiros de fora de nossa equipe.
  • Nos deu motivação para repensar o serviço, o que nos levou a uma arquitetura mais escalável.
  • Se dói, requer sua atenção; não o ignore.
  • https://nodejs.org/en/docs/guides/diagnostics/memory/using-heap-snapshot/
  • Habilite a depuração remota para um pod por meio do encaminhamento de porta:https://kubernetes.io/docs/tasks/access-application-cluster/port-forward-access-application-cluster/
  • https://developers.google.com/cast/docs/debugging/remote_debugger
Siga-nos no Medium! (ilustração de Jane Kim)

Se você estiver interessado em saber mais sobre como trabalhamos, assine o blog Elli Medium e visite o site da nossa empresa em elli.eco ! Vejo você na próxima vez!

Sobre o autor

Thanos Amoutzias é um Engenheiro de Software, ele desenvolve o Sistema de Gerenciamento de Estação de Carga da Elli e dirige tópicos de SRE. Ele é apaixonado por construir serviços confiáveis ​​e entregar produtos impactantes. Você pode encontrá-lo no LinkedIn e no ️.

Créditos: Obrigado a todos os meus colegas que revisaram e deram feedback sobre o artigo!