Monday, May 2, 2022

Como identificar um vazamento de memória em um serviço ASP.NET Core no Azure App Service

O consumo indevido de memória pode acarretar em impactos negativos em um ambiente inteiro.

Em aplicações cloud-native o problema se torna ainda mais grave, pois pagaremos pelos recursos na medida em que eles são provisionados.

Em uma aplicação publicada no Serviço de Aplicativo do Azure este problema pode acarretar em impactos financeiros para sua empresa.

Neste artigo vou demonstrar algumas técnicas para identificar a causa raiz de um vazamento de memória em Serviços de Aplicativo do Azure.

 

O que é um vazamento de memória?

O Garbage Collector é um recurso nativo do CLR (Common Language Runtime) cujo o objetivo é descartar objetos na memória que não são mais necessários para a aplicação. Com isso, na maior parte do tempo, o desenvolvedor não precisa se preocupar em destruir os objetos após seu uso.

Porém existem dois casos comuns em que o Garbage Collector não consegue descartar esses objetos:

  • Quando você tem objetos que ainda são referenciados, mas não são efetivamente utilizados. Uma vez que eles são referenciados, o GC não os coletará e eles permanecerão na memória para sempre. Por exemplo, itens de uma lista estática.
  • Quando você aloca memória não gerenciada e não a libera. O .NET tem muitas classes que alocam memória não gerenciada. Quase tudo que envolve fluxos, gráficos, sistema de arquivos ou chamadas de rede faz isso. Nesses casos, essas classes precisam implementar os finalizadores para liberar esses recursos.

 

Cenário

Observação. Você pode reproduzir todo o cenário apresentado neste artigo clonando o repositório do GitHub MemoryLeakWebApi.

A Web API contém apenas um endpoint acessado através da URL [https://localhost/weatherforecast].

A medida em que requisitamos esse endpoint o consumo de memória do Serviço de Aplicativo vai crescendo de maneira proporcional.

Nosso objetivo é encontrar a causa raiz desse consumo inesperado.

 

Métricas do Serviço de Aplicativo

As métricas do Serviço de Aplicativo são extremamente úteis em diversos cenários, principalmente em um caso de vazamento de memória.

A métrica principal para entender os padrões de consumo de memória é a memory working set.

Para acessa-lá basta entrar no Serviço de Aplicativo através do Portal do Azure, e clicar no menu Metrics, em seguida configuramos a métrica desejada para ver o consumo de memória da aplicação.

Métricas

Através do gráfico observamos que a aplicação tem diversos pico de consumo chegando a ~1,6 GB, após o pico o consumo cai abruptamente.

Esse padrão de consumo indica que a aplicação está consumindo muita memória e o servidor precisa reiniciar o processo diversas vezes.

Memória

Em um cenário produtivo provavelmente a aplicação escalaria mais servidores horizontalmente dependendo de sua configuração.

 

Crash Diagnoser

Para entender a causa raiz deste comportamento, precisaremos capturar no mínimo dois Dump, para fins de comparação.

O ideal é que a captura seja feita durante uma atenuação do consumo de memória, para automatizar essa captura usarei a extensão chamada Crash Diagnoser.

Aprenda a instalar uma extensão em: How to work with extensions in Azure App Service

Para configurar a regra de captura precisaremos navegar até a tela do CrashDiag através do menu Extensions.

crashdiag

Primeiro vamos configurar uma regra para capturar um Dump quando os Private Bytes do processo W3WP atingirem 1400 MB.

Em advanced settings configure a regra para capturar apenas um dump, para não afetar negativamente o desempenho da aplicação.

Faça download do arquivo gerado.

 

WinDbg

Para realizar a análise do Dump usaremos o WinDbg.

Após abrir o arquivo no WinDbg carregaremos a SOS.dll através do comando:

.loadby sos clr

Em seguida precisamos ver quais objetos que estão consumindo mais espaço na memória, faremos isso através do comando:

!dumpheap -stat

O resultado do ultimo comando é uma lista de todos os objetos, agrupados por tipo, e ordenados por tamanho (em Bytes), na ultima linha dessa lista observamos o tipo de objeto que mais consome espaço na memória:

MT                 Count   TotalSize    Class Name
...
00007ffb9cf5c788   234     1046491104   memoryleak.WeatherForecast[]
Total 270782 objects

De toda a memória consumida pela aplicação 234 instâncias da classe memoryleak.WeatherForecast[] é responsável por 1046,49 MB.

Em seguida rodaremos um comando que listará todos os objetos do tipo memoryleak.WeatherForecast[] através do MethodTable localizado no endereço 00007ffb9cf5c788:

!dumpheap -mt 00007ffb9cf5c788

O resultado do comando será uma lista com todos os endereços de memória dos objetos do tipo memoryleak.WeatherForecast[], neste caso são 234 endereços:

Para facilitar a visualização listarei apenas a última linha.

         Address               MT     Size     
000001d2500214d0 00007ffb9cf5c788 33554456

Agora precisamos entender quais são os objetos que estão referenciando este objeto, assim poderemos entender o motivo do GC não estar conseguindo limpa-los.

Faremos isto através do comando:

!gcroot 000001d2500214d0

O resultado deste comando contém todo o grafo de referencias para este objeto:

O resultado deste comando foi ligeiramente modificado para facilitar a visualização dos pontos importantes.

...
    ->  000001CFD8B0FDF8 Microsoft.Extensions.DependencyInjection.ServiceProvider+<>c__DisplayClass23_0
    ->  000001CFD8B0FE10 memoryleak.Services.WeatherForecastService
    ->  000001CFD8B0FE28 System.Collections.Concurrent.ConcurrentBag`1[[memoryleak.WeatherForecast, memoryleak]]
...
    ->  000001D2500214D0 memoryleak.WeatherForecast[]

O objeto memoryleak.Services.WeatherForecastService é um objeto gerenciado pelo container de injeção de dependências Microsoft.Extensions.DependencyInjection, ele mantém referencia a um objeto do tipo ConcurrentBag, que por fim mantém referencia a uma cadeia de objetos até chegar no objeto que estamos analisando.

Agora precisamos entender um pouco melhor o objeto do tipo WeatherForecastService no endereço 000001CFD8B0FE10, para isso executaremos o comando:

!do 000001CFD8B0FE10

O resultado deste comando nos mostra algumas informações sobre essa instância:

Name:        memoryleak.Services.WeatherForecastService
MethodTable: 00007ffb9c43a948
EEClass:     00007ffb9c47e3f8
Tracked Type: false
Size:        24(0x18) bytes
File:        C:\repos\memoryleak\bin\Release\net6.0\memoryleak.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffb9cf58a68  4000006        8 ...ast, memoryleak]]  0 instance 000001cfd8b0fe28 results
00007ffb9c397ad0  4000007        8      System.String[]  0   static 000001cfd8b10340 Summaries

Podemos observar que essa instância possui uma propriedade chamada results cujo o endereço de memória é 000001cfd8b0fe28, este endereço é o mesmo que vimos anteriormente no comando !gcroot.

Agora precisamos olhar o código e analisar a classe WeatherForecastService.

using System.Collections.Concurrent;

namespace memoryleak.Services
{
    public class WeatherForecastService
    {
        public ConcurrentBag<WeatherForecast> results = new ConcurrentBag<WeatherForecast>();

        private static readonly string[] Summaries = new[]
        {
            "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
        };

        public IEnumerable<WeatherForecast> GetWeatherForecasts()
        {
            var randomEnumerable = Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateTime.Now.AddDays(index),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            }).ToList();

            randomEnumerable.ForEach((x) =>
            {
                results.Add(x);
            });

            return randomEnumerable;
        }
    }
}

O método GetWeatherForecasts() esta adicionando itens na propriedade results, porém em nenhum outro ponto do código estão limpando itens desta lista.

Vimos também que a classe WeatherForecastService, estava sendo injetada, portanto se olharmos na classe Program linha 6 veremos o código:

builder.Services.AddSingleton<WeatherForecastService>();

Concluímos assim que o ciclo de vida desta dependência é Singleton.

 

Conclusão

A classe WeatherForecastService é registrada no container de injeção de dependência do .NET Core, e possui o ciclo de vida Singleton, o que significa que esse objeto possui uma única instância que prevalecerá durante todo o ciclo de vida da aplicação.

Essa classe possui uma propriedade chamada results do tipo ConcurrentBag, o método GetWeatherForecasts() adiciona itens sem nenhum critério à propriedade results, e nenhum outro ponto do código remove itens de lá, ou seja toda vez que este método for chamado o consumo de memória vai aumentar e nunca irá diminuir.

Esse cenário é um exemplo fictício portanto a solução mais simples seria remover a propriedade results.

Encontrar um problema de vazamento de memória não é uma tarefa simples, porém com as ferramentas certas você será capaz de rapidamente identificar a causa raiz deste tipo de problema.

Posted at https://sl.advdat.com/3MEw9Zyhttps://sl.advdat.com/3MEw9Zy