Dyego Maas - Blog

Consultor em IA Generativa e Arquiteto de Software

Como implementar Health Checks para sua aplicação usando as extensões do ASP.NET Core 3.0

Como implementar Health Checks para sua aplicação usando as extensões do ASP.NET Core 3.0

Aprenda a implementar Health Checks com as extensões do ASP.NET Core 3.0. Muito útil para configurar liveness probes do Kubernetes!

7 min de leitura

O ASP.NET Core 3.0 fornece uma forma bastante prática de implementar um endpoint para checar a saúde de uma aplicação.

Um endpoint de Health Check bem implementado pode nos ajudar de inúmeras formas a manter uma aplicação rodando. Podemos usá-los em conjunto com o liveness probes do Kubernetes, por exemplo, para que ele possa verificar a saúde de um serviço e assim poder reiniciá-lo caso as coisas dêem errado. Ferramentas de monitoramento também podem fazer uso desses endpoints para gerar alertas e estatísticas.

O pacote Microsoft.Extensions.Diagnostics.HealthChecks traz uma abstração simples de implementar e colocar para funcionar na aplicação. Trata-se da interface IHealthCheck.

public class RecursoImportanteHealthCheck : IHealthCheck
{
  public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = new CancellationToken())
  {
      // checagem da saúde do recurso
  }
  //...
}

Como podemos ver no snippet acima, o método CheckHealthAsync recebe um objeto que representa o contexto da checagem de saúde da aplicação, do tipo HealthCheckContext, e espera um resultado do tipo HealthCheckResult.

Vamos nos ater primeiro ao retorno, que será mais comumente usado. Existem três status possíveis para a checagem:

  • Healthy
  • Degraded
  • Unhealthy

O status Healthy dispensa explicações, já que tudo está OK. Já o status Unhealthy indica uma falha e pode inda detalhar a natureza dos erros identificados:

HealthCheckResult.Unhealthy(
  description: "detalhamento do problema",
  exception: e,
  data: new Dictionary<string, object>() {
      {"chave1", "valor1"},
      {"chave2", "valor2"},
  }
);

Como todos os argumentos são opcionais, podemos montar uma resposta dessas simplesmente assim:

HealthCheckResult.Unhealthy();

O status Degraded funciona exatamente da mesma forma. A diferença está no seu significado: o recurso está respondendo, mas a qualidade do serviço está abaixo do esperado, está degradada.

Implementando um IHealthCheck

Vamos tomar por exemplo uma integração com um serviço externo do qual nossa aplicação depende. A implementação de uma validação dessas pode ser bastante simples, como esta a seguir, que toma a decisão com base numa requisição GET ao serviço externo:

public class ServicoExternoHealthCheck : IHealthCheck
{
  public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = new CancellationToken())
  {
      var result = await new HttpClient().GetAsync("http://servicoX.com/api", cancellationToken);
      if (result.IsSuccessStatusCode)
      {
          return await Task.FromResult(HealthCheckResult.Healthy());
      }
      return await Task.FromResult(HealthCheckResult.Unhealthy());
  }
}

Um caso de uso mais elaborado poderia decidir se a saúde do serviço está degradada de acordo com o tempo de resposta. Para isso, podemos utilizar a classe Stopwatch como timer e assim tomar uma decisão:

public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = new CancellationToken())
{
  try
  {
      var stopwatch = Stopwatch.StartNew();
      var resultado = await new HttpClient().GetAsync("http://servicoX.com/api", cancellationToken);
      stopwatch.Stop();

      var healthCheckResult = (resultado.IsSuccessStatusCode, stopwatch.Elapsed) switch
      {
          (true, var duracao) when duracao <= TimeSpan.FromSeconds(5) => HealthCheckResult.Healthy(),
          (true, _) => HealthCheckResult.Degraded(),
          (false, _) => HealthCheckResult.Unhealthy(),
      };
      return await Task.FromResult(healthCheckResult);
  }
  catch (Exception excecao)
  {
      return await Task.FromResult(new HealthCheckResult(context.Registration.FailureStatus, exception: excecao));
  }
}

O exemplo acima e bem próximo de uma implementação que fiz no trabalho e possui alguns detalhes interessantes para analisarmos. O primeiro ponto é o uso da sintaxe de pattern matching do C# 8 para selecionar o resultado da checagem com base em dois parâmetros: 1) o sucesso ou insucesso na consulta ao serviço externo, e 2) o tempo de resposta do serviço.

var healthCheckResult = (result.IsSuccessStatusCode, stopwatch.Elapsed) switch
{
  (true, var tempoResposta) when tempoResposta <= TimeSpan.FromSeconds(5) => HealthCheckResult.Healthy(),
  (true, _) => HealthCheckResult.Degraded(),
  (false, _) => HealthCheckResult.Unhealthy(),
};

Esta é uma forma bem compacta de tratar todos os casos. Gosto dessa sintaxe porque fica muito fácil de identificar as combinações de entradas que levam a cada saída. Num post futuro, abordarei o pattern matching do C# em mais detalhes.

Seguindo no exemplo, se obtivermos sucesso na requisição (o primeiro parâmetro de cada tupla) e o tempo de resposta for de até 5 segundos, então consideramos que o serviço está saudável e retornamos HealthCheckResult.Healthy().

Já caso obtivermos sucesso, mas o tempo de resposta for superior a 5 segundos, então consideramos que está lento demais e retornamos HealthCheckResult.Degraded().

Se, no entanto, o status code retornado não for de sucesso, retornamos que não está saudável com HealthCheckResult.Unhealthy().

Além disso, temos um tratamento de exceção no código completo mais acima. Ele é totalmente opcional. Caso a checagem atire uma exceção, a API saberá que o serviço está Unhealthy e vai retornar uma resposta de acordo.

Ainda assim, o HealthCheckResult é construído de uma forma um pouco diferente aqui, usando o próprio construtor da classe:

try
{
  // ...
}
catch (Exception excecao)
{
  return await Task.FromResult(new HealthCheckResult(context.Registration.FailureStatus, exception: excecao));
}

Note que o primeiro argumento que passamos é o status de falha do contexto. A propriedade FailureStatus terá o valor padrão Unhealthy, a não ser que informado outro valor ao registrar este HealthCheck. E esta é uma oportunidade para explorarmos como é feito o registro do ServicoExternoHealthCheck.

Registrando um endpoint de Health Check

A configuração dos health checks é bastante simples e é feita em duas etapas. A primeira trata de registrar nossas implementações da interface IHealthCheck. Isto é feito no método ConfigureServices da classe Startup.

Startup.cs
public void ConfigureServices(IServiceCollection services)
{
  services.AddHealthChecks()
      .AddCheck<ServicoExternoHealthCheck>("ServicoTerceiro");
  // ...
}

Neste momento, podemos fazer algumas configurações adicionais para personalizar nosso health check. Por exemplo, podemos definir qual será o status quando ocorrer uma falha na verificação, como vimos um pouco acima.

services.AddHealthChecks()
  .AddCheck<ServicoExternoHealthCheck>("ServicoTerceiro", failureStatus: HealthStatus.Unhealthy);

Outra opção é passar uma coleção de tags para serem utilizadas durante a verificação de saúde do serviço:

services.AddHealthChecks()
  .AddCheck<ServicoExternoHealthCheck>("teste", tags: new [] {"feature1", "feature2"});

Por fim, na segunda etapa da configuração, precisamos associar um endpoint para consulta. Isto é feito no método Configure, ainda na classe Startup:

Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
  app.UseEndpoints(endpoints =>
  {
      // ...
      endpoints.MapHealthChecks("/health");
  });
  // ...
}

E estamos prontos para testar! Basta subir a aplicação e fazer uma requisição GET no endpoint /health. No caso de o status ser Healthy, note que a resposta tem Status HTTP 200 (OK):

Chamada do endpoint /health com status 200 e corpo contendo a palavra Healthy

Para o Degraded é a mesma coisa:

Chamada do endpoint /health com status 200 e corpo contendo a palavra Degraded

Já quando o serviço retorna Unhealthy, o Status HTTP retornado é o 503 (Serviço Indisponível):

Chamada do endpoint /health com status 503 e corpo contendo a palavra Unhealthy

Como modificar o comportamento do Health Check

Ao registrar o endpoint de Health Check, também é possível informar um objeto do tipo HealthCheckOptions.

Uma das opções mais interessantes que ele fornece é poder mudar o HTTP Status Code retornado para cada um dos três status de saúde (Healthy, Degraded e Unhealthy).

Se quisermos retornar um status 599 para Unhealthy, por exemplo, basta fazer assim:

app.UseEndpoints(endpoints =>
{
  var healthCheckOptions = new HealthCheckOptions();
  healthCheckOptions.ResultStatusCodes[HealthStatus.Unhealthy] = 599;
  endpoints.MapHealthChecks("/health", healthCheckOptions);
});

Outra opção, é fornecer um filtro que decide dinamicamente quais Health Checks serão executados:

app.UseEndpoints(endpoints =>
{
  var healthCheckOptions = new HealthCheckOptions();
  // este exemplo não é lá muito realista :)
  healthCheckOptions.Predicate = registration => registration.Name.StartsWith("M");
  endpoints.MapHealthChecks("/health", healthCheckOptions);
});

Também podemos customizar a resposta. Abaixo, temos um exemplo de configuração para retornar um JSON, mas poderíamos facilmente montar uma página de relatório HTML:

app.UseEndpoints(endpoints =>
{
  var healthCheckOptions = new HealthCheckOptions();
  healthCheckOptions.ResponseWriter = async (context, report) =>
  {
      context.Response.ContentType = "application/json";

      var result = JsonConvert.SerializeObject(new
      {
          status = report.Status.ToString(),
          errors = report.Entries
              .Select(e => new
              {
                  key = e.Key,
                  value = e.Value.Status.ToString()
              })
      });
      await context.Response.WriteAsync(result);
  };
  endpoints.MapHealthChecks("/health", healthCheckOptions);
});

Felizmente, a biblioteca AspNetCore.HealthChecks.UI já faz o trabalho de gerar um dashboard de acompanhamento. Não vou entrar em detalhes da configuração dessa ferramenta neste artigo, mas é bem simples.