You are currently viewing Monitoring aplikacji .NET

Monitoring aplikacji .NET

Co to takiego?

Domyślamy się, że aplikacja może w jakiś sposób udostępniać informację o swoim stanie. Takie kontrole stanu mogą być użytecznymi mechanizmami zarówno dla aplikacji monolitycznych, jak i mikrousług. W przypadku aplikacji „na produkcji” często pojawia się nawet oczekiwanie, aby „jakoś” monitorować co w kodzie szeleści i czy aby nie piszczy albo gorzej – zgrzyta. Wtedy na ogół patrzymy sobie na logi, albo obrazki na Grafanie. Bardziej zaawansowani generują maile z alertami. Czasem trochę po partyzancku, „aby coś tam było”.

Aż tu pewnego dnia IT zażądało kontroli stanu aplikacji na klastrze. Wymyślili sobie, że skoro Kubernetes pozwala sprawdzić czy aplikacja wciąż działa i jak działa, to chcieliby, żeby backend zaimplementował taką kontrolę stanu. Nie rozpisując sie tu o tym, jakie zaprojektowali działania, kiedy ten stan nie będzie zadowalający, powiem tylko, że rozgorzała dyskusja ile to z tym będzie roboty. Niby rzecz prosta, ale okazało się, że to niecodzienne żądanie IT rozgrzało głowy. Żeby jakoś temat ogarnąć po stronie backendu .NETowego, zrobiłem instrukcję na firmowym conflu, którą tu bez zbędnej zwłoki przytoczę.

Rodzaje kontroli stanu push i pull

Kontrola stanu może przebiegać w dwóch kierunkach: albo kontrolowany system periodycznie raportuje swój stan do systemu monitorującego bez pytania. To nazywa się heartbeat lub push. Działa nawet za NAT czy firewallem, ale brak aktywności może oznaczać zarówno awarię, jak i problem z siecią.

Drugi sposób określany jako pooling lub pull, polega na tym, że aplikacja odpowiada na żądanie systemu monitorującego. Warunek − aplikacja musi być osiągalna sieciowo.

Kubernetes, load balancery mają wbudowany mechanizm badania stanu przez wysłanie żądania do aplikacji – pull. Można odpytać aplikację czy żyje (odpowiada) – liveness probe. Jeśli apka nie odezwie się, to pod zostanie ubity i postawiony na nowo z nadzieją, że to załatwi problem. Wersja zaawansowana to dodatkowa kontrola podsystemów aplikacji – readiness probe. Taki test powinien sprawdzić, czy wszystkie żywotne usługi działają poprawnie (czy baza odpowiada w skończonym czasie, czy odbierane są zdarzenia z Kafki itd).

Kontrola stanu w ASP.NET

Od .NET6 mamy w pełni funkcjonalny mechanizm health check w postaci endpointów http, czyli system pull. Myślę, że lepiej jest pokazać działający kod niż opowiadać albo pokazać zrzut ekranu, dlatego od razu będzie przykład działającej aplikacji. Przykład będzie w .NET10. Wyprodukujemy aplikację, która udostępni dedykowane endpointy. Ich odpytanie uruchomi testy a one zwrócą rezultat użyteczny dla systemu monitorującego.

Struktura aplikacji

Żeby pozostać w trendzie, użyję Clean Architecture. W listingach będą podane przestrzenie nazw namespace. Gdyby ktoś chciał wykonać przykład razem ze mną (do czego zachęcam), to łatwo będzie umiejscowić kod u siebie.

Niezbędne składniki

Przepis na healthchecki jest następujący:

  • Zaimplementuj IHealthCheck dla każedgo testu, jaki ma być uruchomiony
  • Zaimplementuj odpowiednie serwisy w warstwie Infrastructure
  • Zmapuj testy do endpointów
  • Zarejestruj serwisy w DI
  • Skonfiguruj aplikację

Czas na implementację. Na początek utwórz katalog i nową „pustą” aplikację asp.net

mkdir ./healthchecks
cd healthchecks
dotnet new web

Mapowanie

To nie jest pierwszy składnik w przepisie, ale trochę wyjaśnia co i dlaczego za chwilę wykonamy.

Tu dzieją się dwie rzeczy: tworzone są endpointy oraz mapowane są testy. Zapytanie /health/ready uruchomi testy zawierające tag „ready”. Są to testy sprawdzające, czy aplikacja jest gotowa do pracy wraz ze wszystkimi niezbędnymi składnikami. Takich składników jest więcej niż jeden, dlatego szukamy wszystkich otagowanych „ready”. Test liveness probe pod adresem /health/live jest dużo prostrzy i jest jeden. Mapuję go według nazwy „live”.

namespace Infrastructure.HealthChecks.Extensions;

public static class WebApplicationExtensions
{
    public static void MapAppHealthChecks(this WebApplication app)
    {
        app.MapHealthChecks("/health/ready", new HealthCheckOptions
        {
            Predicate = check => check.Tags.Contains("ready")
        });
        app.MapHealthChecks("/health/live", new HealthCheckOptions
        {
            Predicate = check => check.Name == "live"
        });
    }

Implementacja health checków

Na początek potrzebne modele.

namespace healthchecks.Infrastructure.Persistence.DataModels;

public class DbHealthStatus
{
    public HealthStatus Status { get; set; }
    public double ResponseTime { get; set; }
    public string Message { get; set; } = null!;
    public DateTime SystemTime { get; set; }
    public string? UserName { get; set; }
    public string? SessionId { get; set; }
}

public enum HealthStatus
{
    Critical,
    Error,
    Slow,
    Ready
}

Test bazy danych polega na uruchomioniu procedury, która wykona proste obliczenia i zwróci czas, nazwę użytkownika i komunikat, w którym określi swój stan.

Implementacja testu bazy danych (interfejsu IHealthCheck) polega na uruchomieniu przez serwis IDbStatusService procedury składowanej i interpretacji zwróconych danych. Jeśli baza danych jest w pełni sprawna, to żądanie GET: /health/ready zwróci kod 200 i tekst Healthy w body odpowiedzi. Jeśli baza będzie spowolniona, to dostaniemy 200 z tekstem Degraded. W przypadku awarii bazy (w domyśle chwilowej) dostaniemy 503 z opisem Unhealthy. Status 503 to kod błędu transient, czyli takiego, po którym możemy oczekiwać, że za wkrótce samoczynnie ustąpi. Kubernetes może w takim przypadku odciąć takipod od puli sprawnych i spróbować podłączyć po chwili.

using DbStatus = healthchecks.Infrastructure.Persistence.DataModels;

namespace healthchecks.Infrastructure.HealthChecks;

internal class DatabaseHealthCheck(IServiceScopeFactory scopeFactory) : IHealthCheck
{
    public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken ct = default)
    {
        try
        {
            using var scope = scopeFactory.CreateScope();
            var dbStatusService = scope.ServiceProvider.GetRequiredService<IDbStatusService>();
            var result = await dbStatusService.GetStatus(ct);

            return result.Status switch
            {
                DbStatus.HealthStatus.Ready => HealthCheckResult
                    .Healthy("Database connected"),
                DbStatus.HealthStatus.Slow => HealthCheckResult
                    .Degraded("Database connected, but response is slow"),
                _ => HealthCheckResult.Unhealthy("Database check failed"),
            };
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("Database connection failed", ex);
        }
    }
}

Serwis IDbStatusService

Potrzebny jest inrerfejs

namespace healthchecks.Infrastructure.HealthChecks.Interfaces;

public interface IDbStatusService
{
    Task<DbHealthStatus> GetStatus(CancellationToken ct);
}

Nasz DatabaseHealthCheck woła metodę IDbStatusService.GetStatus(). Konkretna implementacja będzie zależna od tego jaką bazę danych testujemy i sposobu w jaki nawiązujemy z nią połączenie.

namespace healthchecks.Infrastructure.Persistence.Services;

public class DbStatusService(ILogger<DbStatusService> logger) : IDbStatusService
{
    public async Task<DbHealthStatus> GetStatus(CancellationToken ct)
    {
        try
        {
            // TODO: Implement actual database health check logic
            var result = new DbHealthStatus 
            {
                Status = HealthStatus.Ready 
            };

            // check your db system time zone and modify this accordingly to get UTC time
            var timeZoneInfoResult = TimeZoneInfo
                .TryFindSystemTimeZoneById("Eastern Standard Time", out var timeZoneInfo);
            if (timeZoneInfoResult)
                result.SystemTime = TimeZoneInfo
                    .ConvertTimeToUtc(result.SystemTime, timeZoneInfo!);

            return result;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Error getting database status");
            throw;
        }
    }
}

W przykładzie w linii 12 zwracam HealthStatus.Ready, ale w rzeczywistości należałoby zmapować odpowiedź uzyskaną z bazy. Jeśli odpowiedzi z bazy w ogóle nie będzie, to już jest obsłużone w bloku catch DatabaseHealthCheck.CheckHealthAsync().

Procedura składowana

Oto procedura składowana. To przykład dla bazy Oracle.Tu widać, że wynik procedury powinien być mapowany na DbHealthStatus W procedurze pobierany jest czas SYSTIMESTAMP, który można porównać z czasem aplikacji i obsłużyć ewentualne różnice (status Error przy różnicy > próg). Liczony jest czas wykonania procedury. W przykładzie 1ms jest uznana za czas normalny, a dłuższy czas przekłada się na Slow. Jest też proste działanie matematyczne. Generalnie chodzi o to, żeby procedura była lekka, ale testowała stan bazy.

CREATE OR REPLACE PROCEDURE READINESS_TEST (

    p_max_response_time IN NUMBER := 1,
    p_cursor OUT SYS_REFCURSOR
)
IS
    v_start_time   TIMESTAMP(6);
    v_end_time     TIMESTAMP(6);
    v_current_time DATE;
    v_systimestamp TIMESTAMP(6);
    v_user_name    VARCHAR2(30);
    v_session_id   VARCHAR2(20);
    v_status       VARCHAR2(20);
    v_message      VARCHAR2(100);
    v_sys_time     VARCHAR2(30);
    v_response_ms  NUMBER;
    v_math_result  NUMBER;
    v_max_time     NUMBER;
    
BEGIN
    v_start_time := SYSTIMESTAMP;
    v_systimestamp := SYSTIMESTAMP;
    v_status := 'INIT';
    v_message := 'Starting';
    v_response_ms := 0;

    v_max_time := NVL(p_max_response_time, 1);
    
    BEGIN
        SELECT SYSDATE INTO v_current_time FROM DUAL;
        SELECT SUBSTR(USER, 1, 20) INTO v_user_name FROM DUAL;
        SELECT SUBSTR(TO_CHAR(SYS_CONTEXT('USERENV', 'SESSIONID')), 1, 15) INTO v_session_id FROM DUAL;
        SELECT POWER(2, 3) INTO v_math_result FROM DUAL;
        
        v_end_time := SYSTIMESTAMP;
        v_response_ms := EXTRACT(DAY FROM (v_end_time - v_start_time)) * 86400000 +
                         EXTRACT(HOUR FROM (v_end_time - v_start_time)) * 3600000 +
                         EXTRACT(MINUTE FROM (v_end_time - v_start_time)) * 60000 +
                         EXTRACT(SECOND FROM (v_end_time - v_start_time)) * 1000;
        
        v_sys_time := TO_CHAR(v_systimestamp, 'YYYY-MM-DD HH24:MI:SS');
        
        IF v_response_ms <= v_max_time THEN
            v_status := 'READY';
            v_message := 'OK';
        ELSE
            v_status := 'SLOW';
            v_message := 'TIMEOUT';
        END IF;
        
    EXCEPTION
        WHEN OTHERS THEN
            v_status := 'ERROR';
            v_message := 'FAILED';
            v_response_ms := -1;
            v_sys_time := TO_CHAR(SYSTIMESTAMP, 'YYYY-MM-DD HH24:MI:SS');
    END;
    
    OPEN p_cursor FOR
        SELECT v_status as Status,
               v_response_ms as ResponseTime,
               v_message as Message,
               v_sys_time as SystemTime,
               v_user_name as UserName,
               v_session_id as SessionId
        FROM DUAL;
        
EXCEPTION
    WHEN OTHERS THEN
        OPEN p_cursor FOR
            SELECT 'CRITICAL' as Status,
                   -1 as ResponseTime,
                   'CRITICAL' as Message,
                   TO_CHAR(SYSTIMESTAMP, 'YYYY-MM-DD HH24:MI:SS') as SystemTime,
                   'UNKNOWN' as UserName,
                   'UNKNOWN' as SessionId
            FROM DUAL;
END READINESS_TEST;

Rejestracja w kontenerze DI

Rejestracja to kolejny punkt przepisu. Do IServiceCollection dodaję serwis bazodanowy i implementację DatabaseHealthCheck. Zwróć uwagęj, że liveness probe jest zdefiniowana jedną linijką nr 9. Jeśli aplikacja „żyje” to na żądanie GET: /health/live zwróci 200-OK, a jeśli nie, to wystąpi timeout.

namespace healthchecks.Infrastructure.HealthChecks.Extensions;

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddAppHealthChecks(this IServiceCollection services)
    {
        services.AddHealthChecks()
            .AddCheck<DatabaseHealthCheck>("database", tags: ["ready"])
            .AddCheck("live", () => HealthCheckResult.Healthy("application"));

        services.AddScoped<IDbStatusService, DbStatusService>();

        return services;
    }
}

Konfiguracja aplikacji

To ostatni punkt przepisu. Wykorzystujemy metody rozszerzające zdefiniowane wcześniej, Usuwamy zawartość Program.cs i wklejamy

var builder = WebApplication.CreateBuilder(args);

// health check services registration
builder.Services.AddAppHealthChecks();
var app = builder.Build();
// endpoint's mapping
WebApplicationExtensions.MapAppHealthChecks(app);
await app.RunAsync();

W launchsettings.json ustaw port dla żądań http na 5000. Ten sam podaj IT, żeby mogli skonfigurować testy po swojej stronie.

{
  "$schema": "https://json.schemastore.org/launchsettings.json",
  "profiles": {
    "http": {
      "launchBrowser": false,
      "applicationUrl": "http://localhost:5000",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

Obliczanie wyników wielu testów

W linii 8 listingu AddAppHealthChecks rejestrowany jest DatabaseHealthCheck o tagu „ready”. Wcześniej napisałem, że readiness probe powinna sprawdzić wszystkie istotne składniki, żeby uznać aplikację za gotową do pracy. I że robimy to przez rejestrację kolejnych checków z tagiem „ready”, np. KafkaHealthCheck:

.AddCheck<KafkaHealthCheck>("messaging", tags: ["ready"])

Co się stanie, jeśli testy zwrócą różne wyniki? Odpowiedź jest krótka: zwyciąża najgorszy. Framework oblicza wynik ostateczny w następujący sposób:

Healthy < Degraded < Unhealthy

I jest to zgodne z tym czego się spodziewamy bo testach.

Test działania

Uruchom aplikację i wstaw do przeglądarki żądanie:

http://localhost:5000/health/ready

W odpowiedzi dostaniesz napis Healthy. W postmanie zobaczysz jeszcze status 200 OK.

Podsumowanie

Artykuł naświetlił tematykę testowania stanu aplikacji. W taki sam sposób możesz badać stan monolitów i mikroserwisów.

Była też okazja do prześledzenia implementacji health check i zbudowania działającej aplikacji demonstracyjnej. Wszyskto zostało objaśnienione. Wiesz też, jak dodawać kolejne testy. Mam nadzieję, że się przyda 🙂

Jak zwykle kod jest na Githubie

Dodaj komentarz