Topleaked: uma ferramenta para detectar vazamentos de memória

imagem

A história, como costuma acontecer, começou com o fato de um dos serviços no servidor ter caído. Mais precisamente, o processo foi interrompido pelo monitoramento do uso excessivo de memória. O estoque deveria ter sido múltiplo, o que significa que temos um vazamento de memória.
Há um despejo de memória completo com informações de depuração, existem logs, mas não podem ser reproduzidos. Ou o vazamento é incrivelmente lento ou o cenário depende do clima em Marte. Em uma palavra, outro bug que não é reproduzido por testes, mas é encontrado na natureza. Resta a única pista real - um despejo de memória.


Idéia


O serviço original foi escrito em C ++ e Perl, embora isso não desempenhe um papel especial. Tudo descrito abaixo se aplica a quase qualquer idioma.


Nosso processo, a partir da declaração do problema, cabia em algumas centenas de megabytes de RAM e foi concluído por exceder 6 gigabytes. Portanto, a maior parte da memória do processo são objetos vazados e seus dados. Só é necessário descobrir que tipos de objetos estavam mais na memória. Obviamente, não há lista de objetos com informações de tipo no despejo. Rastrear relacionamentos e criar um gráfico, como fazem os coletores de lixo, é praticamente impossível. Mas não precisamos entender esse hash binário, mas calcular quais objetos são mais. Objetos de classes não triviais têm um ponteiro para uma tabela de métodos virtuais e todos os objetos da mesma classe têm o mesmo ponteiro. Quantas vezes um ponteiro para uma classe vtbl é encontrado na memória - muitos objetos dessa classe foram criados.


Além do vtbl, existem outras seqüências que ocorrem com frequência: constantes que inicializam campos, cabeçalhos HTTP em fragmentos de linha, ponteiros para funções.
Se você tiver sorte o suficiente para encontrar um ponteiro, podemos usar o gdb para entender o que ele aponta (a menos que, é claro, haja caracteres de depuração). No caso de dados, você pode tentar examiná-los e entender onde isso é usado. No futuro, observo que acontece isso e outro e, a partir de um fragmento de linha, é bem possível entender o que é essa parte do protocolo e onde é necessário ir além.


A ideia foi espionada e a primeira implementação foi copiada de maneira impudente do stackoverflow. https://stackoverflow.com/questions/7439170/is-there-a-way-to-find-leaked-memory-using-a-core-file


hexdump core.10639 | awk '{printf "%s%s%s%s\n%s%s%s%s\n", $5,$4,$3,$2,$9,$8,$7,$6}' | sort | uniq -c | sort -nr | head 

O script funcionou por cerca de 15 minutos no nosso despejo, retornou várias linhas e ... nada. Nem um único ponteiro, nada de útil.


Resolvido


O desenvolvimento orientado ao Stackoverflow tem suas desvantagens. Você não pode simplesmente copiar o script e esperar que tudo funcione. Nesse script em particular, algum tipo de rearranjo de bytes imediatamente chama a atenção. Também surge a questão: por que permutações por 4. Você não precisa ser um superespecialista para entender que essas permutações dependem da plataforma: ordem de bits e bytes.


Para entender exatamente como procurar, você precisa entender o formato do arquivo de despejo de memória, LITTLE e BIG-endian, ou pode simplesmente reorganizar os bytes nas partes encontradas de diferentes maneiras e fornecer gdb. Oh milagre! Em ordem direta, o byte gdb vê o caractere e diz que é um ponteiro para uma função!


No nosso caso, era um ponteiro para uma das funções de leitura e gravação nos buffers openssl. Para personalizar a entrada e a saída, é utilizada a abordagem do sistema OOP - uma estrutura com um conjunto de ponteiros para funções, que é um tipo de interface ou melhor, vtbl. Essas estruturas com ponteiros acabaram sendo insanamente numerosas. Uma análise detalhada do código responsável por definir essas estruturas e criar buffers nos permitiu encontrar rapidamente o erro. Como se viu, na junção de C ++ e C não havia objetos RAII e, no caso de um erro, o retorno antecipado não deixava a chance de liberar recursos. Ninguém adivinhou carregar o serviço com handshakes ssl incorretos em tempo hábil, por isso eles o perderam. Como discar 6 gigabytes de handshakes ssl incorretos também é interessante, mas como eles dizem, essa é uma história completamente diferente. O problema está resolvido.


topleaked


O script acabou sendo útil, mas ainda apresenta sérias desvantagens para o uso frequente: é muito lento, depende da plataforma; depois, acontece que os arquivos de despejo também têm desvios diferentes, é difícil interpretar os resultados. A tarefa de cavar em um despejo binário não se encaixa bem com o bash, então mudei a linguagem de programação para D. A escolha da linguagem é realmente devido ao desejo egoísta de escrever em sua linguagem favorita. Bem, a racionalização da escolha é a seguinte: velocidade e consumo de memória são críticos, então você precisa de uma linguagem compilada nativa e é banal escrever D mais rapidamente que C ou C ++. Posteriormente no código, será claramente visível. Assim, o projeto de maior destaque nasceu.


Instalação


Não há montagens binárias; portanto, de uma forma ou de outra, você precisará montar o projeto a partir da origem. Isso exigirá o compilador D. Existem três opções: dmd - o compilador de referência, ldc - baseado no llvm e gdc, incluído no gcc, começando na versão 9. Portanto, você pode não precisar instalar nada se tiver o último gcc. Se você instalar, recomendo o ldc, pois ele otimiza melhor. Todos os três podem ser encontrados no site oficial .
O gerenciador de pacotes dub é fornecido com o compilador. Utilizando-o, topleaked é instalado com um comando:


 dub fetch topleaked 

No futuro, usaremos o comando para iniciar:


 dub run topleaked -brelease-nobounds -- <filename> [<options>...] 

Para não repetir a execução de dub e o argumento do compilador brelease-nobounds, você pode baixar as fontes do github e coletar o arquivo executável:


 dub build -brelease-nobounds 

Na raiz da pasta do projeto, aparecerá com a marcação superior.


Use


Vamos pegar um programa C ++ simples com um vazamento de memória.


 #include <iostream> #include <assert.h> #include <unistd.h> class A { size_t val = 12345678910; virtual ~A(){} }; int main() { for (size_t i =0; i < 1000000; i++) { new A(); } std::cout << getpid() << std::endl; sleep(200); } 

Concluímos através do kill -6, e obtemos um despejo de memória. Agora você pode executar topleaked e olhar para os resultados

 ./toleaked -n10 leak.core 

A opção -n é o tamanho do topo que precisamos. Normalmente, valores entre 10 e 200 fazem sentido, dependendo da quantidade de "lixo" existente. O formato de saída padrão é um topo de linha por linha em formato legível por humanos.


 0x0000000000000000 : 1050347 0x0000000000000021 : 1000003 0x00000002dfdc1c3e : 1000000 0x0000558087922d90 : 1000000 0x0000000000000002 : 198 0x0000000000000001 : 180 0x00007f4247c6a000 : 164 0x0000000000000008 : 160 0x00007f4247c5c438 : 153 0xffffffffffffffff : 141 

É pouco útil, exceto pelo fato de podermos ver o número 0x2dfdc1c3e, que também é 12345678910, que ocorre um milhão de vezes. Já pode ser o suficiente, mas quero mais. Para ver os nomes das classes dos objetos vazados, você pode enviar o resultado para o gdb simplesmente redirecionando o fluxo de saída padrão para a entrada do gdb com um arquivo de despejo aberto. -ogdb - opção de mudar o formato para um gdb compreensível.


 $ ./topleaked -n10 -ogdb /home/core/leak.1002.core | gdb leak /home/core/leak.1002.core ...<   gdb  > #0 0x00007f424784e6f4 in __GI___nanosleep (requested_time=requested_time@entry=0x7ffcfffedb50, remaining=remaining@entry=0x7ffcfffedb50) at ../sysdeps/unix/sysv/linux/nanosleep.c:28 28 ../sysdeps/unix/sysv/linux/nanosleep.c: No such file or directory. (gdb) $1 = 1050347 (gdb) 0x0: Cannot access memory at address 0x0 (gdb) No symbol matches 0x0000000000000000. (gdb) $2 = 1000003 (gdb) 0x21: Cannot access memory at address 0x21 (gdb) No symbol matches 0x0000000000000021. (gdb) $3 = 1000000 (gdb) 0x2dfdc1c3e: Cannot access memory at address 0x2dfdc1c3e (gdb) No symbol matches 0x00000002dfdc1c3e. (gdb) $4 = 1000000 (gdb) 0x558087922d90 <_ZTV1A+16>: 0x87721bfa (gdb) vtable for A + 16 in section .data.rel.ro of /home/g.smorkalov/dlang/topleaked/leak (gdb) $5 = 198 (gdb) 0x2: Cannot access memory at address 0x2 (gdb) No symbol matches 0x0000000000000002. (gdb) $6 = 180 (gdb) 0x1: Cannot access memory at address 0x1 (gdb) No symbol matches 0x0000000000000001. (gdb) $7 = 164 (gdb) 0x7f4247c6a000: 0x47ae6000 (gdb) No symbol matches 0x00007f4247c6a000. (gdb) $8 = 160 (gdb) 0x8: Cannot access memory at address 0x8 (gdb) No symbol matches 0x0000000000000008. (gdb) $9 = 153 (gdb) 0x7f4247c5c438 <_ZTVN10__cxxabiv120__si_class_type_infoE+16>: 0x47b79660 (gdb) vtable for __cxxabiv1::__si_class_type_info + 16 in section .data.rel.ro of /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (gdb) $10 = 141 (gdb) 0xffffffffffffffff: Cannot access memory at address 0xffffffffffffffff (gdb) No symbol matches 0xffffffffffffffff. (gdb) quit 

A leitura não é muito simples, mas possível. Linhas do formulário $ 4 = 1.000.000 refletem a posição na parte superior e o número de ocorrências encontradas. Abaixo estão os resultados da execução de x e o símbolo de informações para o valor. Aqui podemos ver que a tabela de A ocorre um milhão de vezes, o que corresponde a um milhão de objetos vazados da classe A.

Para analisar parte do arquivo (se for muito grande), são adicionadas as opções de deslocamento e limite - começando de onde e quantos bytes serão lidos.


Resultado


O utilitário resultante é visivelmente mais rápido que o script. Você ainda precisa esperar, mas não na escala de uma caminhada pelo chá, mas alguns segundos antes que o topo apareça na tela. Estou absolutamente certo de que o algoritmo pode ser significativamente aprimorado e que operações pesadas de entrada e saída podem ser significativamente otimizadas. Mas isso é uma questão de desenvolvimento futuro, agora tudo está funcionando bem.


Graças à opção -ogdb e ao redirecionamento no gdb, obtemos imediatamente nomes e valores, às vezes até números de linha, se tivermos sorte em acessar a função.


A consequência óbvia, mas muito inesperada, da solução frontal foi a plataforma cruzada. Sim, o topleaked não conhece a ordem dos bytes, mas como não analisa o formato do arquivo, mas simplesmente lê o arquivo byte a byte, pode ser usado no Windows ou em qualquer sistema com qualquer formato de despejo de memória. É necessário apenas que os dados sejam alinhados dentro do arquivo.


Linguagem D


Gostaria de anotar separadamente a experiência de desenvolver esse programa em D. A primeira versão de trabalho foi escrita em questão de minutos. Devo dizer que até agora o algoritmo principal leva apenas três linhas:


 auto all = input.sort; ValCount[] res = new ValCount[min(all.length, maxSize)]; return all.group.map!((p) => ValCount(p[0],p[1])) .topNCopy!"a.count>b.count"(res, Yes.sortOutput); 

Tudo graças a intervalos preguiçosos e à presença de algoritmos prontos sobre eles na biblioteca padrão, como group e topN.


Mais tarde, a análise dos argumentos da linha de comando, a formatação da saída e tudo o que é detalhado, mas também escrito rapidamente, cresceram no topo. A menos que a leitura do arquivo tenha sido estranha, fora do estilo geral.


Na versão mais recente no momento, o sinalizador --find apareceu para a pesquisa usual de uma substring, que não está relacionada à frequência. Devido a essa ninharia, o código aumentou notavelmente em tamanho, mas com grandes chances de o recurso ser excluído e o código voltar ao seu estado simples original.


No total, os custos de mão-de-obra são comparáveis ​​às linguagens de script e têm desempenho muito melhor. Potencialmente, você pode trazê-lo ao máximo possível, pois o mesmo código em C e D funcionará da mesma maneira na mesma velocidade.


Indicações e contra-indicações para uso


  • A marcação com topologia é necessária para procurar vazamentos quando há apenas um despejo da memória do processo atual, mas não há como reproduzi-lo no desinfetante.
  • Este não é outro valgrind e não afirma ser uma análise dinâmica.
  • Uma exceção interessante à observação anterior pode ser vazamentos temporários. Ou seja, a memória é liberada, mas é tarde demais (quando o servidor está parado, por exemplo). Em seguida, você pode remover o despejo no momento certo e analisar. Valgrind ou asan, trabalhando no momento em que o processo termina, podem fazer isso pior.
  • Somente modo de 64 bits. O suporte para outros bits e ordem de bytes é adiado para o futuro.

Problemas conhecidos


Durante o teste, foram utilizados arquivos de despejo que foram recebidos enviando um sinal para o processo. Com esses arquivos, tudo funciona bem. Quando um dump é removido, o comando gcore grava alguns outros cabeçalhos ELF e ocorre um deslocamento por um número indefinido de bytes. Ou seja, os valores dos ponteiros não estão alinhados a 8 no arquivo, portanto, resultados sem sentido são obtidos. Para a solução, a opção de deslocamento foi introduzida - para ler o arquivo não primeiro, mas alternado pelos bytes de deslocamento (geralmente 4).
Para resolver isso, pretendo adicionar a leitura do resultado do objdump -s do stdin. Bem, conecte o libelf e analise você mesmo, mas isso matará a “plataforma cruzada”, e o stdout é mais flexível e mais próximo da maneira unix.


Referências


Projeto Github
Compiladores D
Pergunta original sobre stackoverflow

Source: https://habr.com/ru/post/pt485102/


All Articles