Badaj swoje API, czyli healthcheck
Spis treści
- Wstęp
- Czym jest healthcheck?
- Konfiguracja healthcheck
- Jak działa ten mechanizm?
- Badanie zdrowia standardowych serwisów
- Sprawdzenie bazy danych
- Jak zrobić niestandardowe sprawdzenie
- Jak pokazywać wynik healthcheck w niestandardowy sposób
- Zabezpieczanie healthcheck – uwierzytelnianie
- Kilka różnych końcówek – filtrowanie
- Dodanie healthcheck do Swaggera
Wstęp
Czy Twoja webówka działa? A jaką masz pewność? Musiałbyś co chwilę klikać i sprawdzać. Ale są też inne, lepsze metody. Możesz na przykład posłużyć się rozwiązaniem chmurowym, które cyklicznie będzie badać stan Twojej aplikacji. Co więcej, jest opcja, że nawet wyśle Ci maila albo SMSa, jeśli coś będzie nie tak.
Ten artykuł nie opowiada jednak o chmurowej części rozwiązania (jeśli chcesz taki materiał, daj znać w komentarzu), a o aplikacyjnej części. Czyli o healthcheck.
Czym jest Healthcheck?
Healthcheck jest sprawdzeniem stanu Twojej aplikacji. Czy działa wszystko ok, ewentualnie co nie działa. I oczywiście mógłbyś napisać sobie własny kontroler z odpowiednimi endpointami, w których to wszystko sprawdzasz, ale w .NET mamy już taki mechanizm w standardzie. I działa całkiem przyzwoicie.
Po co to właściwie?
Dzisiaj utrzymanie niezawodności i ciągłości działania aplikacji jest priorytetem. Stworzenie skutecznego mechanizmu healthcheck pozwala na szybką reakcję w razie wystąpienia jakiś problemów z jednym z kluczowych elementów systemu.
Jak już pisałem wcześniej, można to nawet spiąć z chmurą i spodziewać się maila albo nawet SMS gdy tylko coś niedobrego zacznie się dziać w Twojej aplikacji.
Przykładowa apka
Do tego artykułu stworzyłem przykładową aplikację, którą możesz pobrać z GitHuba. Po jej pobraniu, koniecznie uruchom migracje Entity Framework.
Niech nasza aplikacja zwraca różne dane pogodowe. Jeśli chodzi o prognozy, będą pobierane z zewnętrznego serwisu, a jeśli chodzi o dane archiwalne (prognoza z przeszłości), będą pobierane z naszej bazy danych.
Zewnętrzny serwis to oczywiście jakiś mock, który będzie udawał połączenie z zewnętrznym API.
Konfiguracja healthcheck
Podstawowa konfiguracja jest zabójczo prosta, bo sprowadza się tylko do rejestracji odpowiednich serwisów i dodania middleware. Czyli mamy coś takiego:
builder.Services.AddControllers();
//healthcheck
builder.Services.AddHealthChecks();
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapHealthChecks("/_health");
app.MapControllers();
app.Run();
Jeśli chodzi o linię 4 – to dodajemy do dependency injection serwisy do sprawdzenia stanu zdrowia. Co się tyczy linii 13, to mapujemy te healthchecki do konkretnego endpointa. W parametrze przekazujemy, pod jakim adresem ma być ten healthcheck. W tej sytuacji to będzie https://localhost:pppp/_health
, gdzie pppp
to oczywiście numer portu na środowisku lokalnym.
Teraz jeśli uruchomimy aplikację i sprawdzimy ten healthcheck, dostaniemy zwrotkę ze StatusCode 200 OK
i wartością:
Healthy
Przyznasz jednak, że takie sprawdzenie niewiele nam daje. No właśnie, domyślny mechanizm właściwie niczego nie sprawdza. Jeśli aplikacja chodzi, to zawsze zwróci Healthy
. A my chcemy sprawdzić przynajmniej dwie rzeczy:
- czy działa połączenie z bazą danych
- czy działa połączenie z zewnętrznym serwisem
Możemy wszystko napisać ręcznie, ale jest lepsza metoda. Nudesy…eeee Nugetsy 😉 O tym za chwilę.
Jak działa mechanizm healthcheck?
Metoda AddHealthChecks
zwraca nam interfejs IHealthChecksBuilder
, dodając jednocześnie domyślny serwis do sprawdzenia HealthChecków, który wszystkim zarządza. I tak naprawdę możemy sobie stworzyć listę healthchecków, jakie chcemy mieć. To wszystko sprowadza się do dodania do tego buildera klasy, która implementuje odpowiedni interfejs (o tym też będzie za chwilę).
Potem ten domyślny serwis bierze sobie te wszystkie klasy, tworzy je i wywołuje po kolei metodę sprawdzającą. Ot, cała magia. Dzięki czemu możemy tworzyć sobie właściwie nieograniczone sprawdzenia stanu zdrowia apki.
Badanie standardowych serwisów
Jeśli wejdziesz sobie do managera nugetów i zaczniesz wpisywać AspNetCore.Healthchecks
, oczom Twym ukaże się całkiem pokaźna lista z już oprogramowanymi sprawdzeniami do konkretnych serwisów:
To nie są w prawdzie oficjalne Microsoftowe paczki, jednak społeczność która za tym stoi, to (w momencie pisania artykułu) ponad 150 osób. Jeśli używasz jakiegoś standardowego serwisu, to jest duża szansa, że sprawdzenie healthcheka do niego już istnieje.
Sprawdzanie bazy danych
Oczywiście możemy sobie sprawdzić różne bazy danych, w tym MSSQL
, Postgre
, MySQL
, Redis
itd. – używając bibliotek z powyższej listy. Możemy też użyć oficjalnej paczki Microsoft.Extensions.Diagnostics.Healthcheck
, która umożliwia testowanie całego kontekstu bazy danych (EFCore). A jak używać tych wszystkich bibliotek?
Metoda AddHealthChecks
zwraca nam interfejs IHealthChecksBuilder
i wszystkie rozszerzenia jakie mamy dostępne są rozszerzeniami właśnie tego interfejsu. A prostymi słowami:
//healthcheck
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>(); //rozszerzenie z Microsoft.Extensions.Diagnostics.Healthcheck
W taki sposób możemy dodać sprawdzenie, czy baza danych działa. Domyślnie, sprawdzane jest połączenie z bazą danych za pomocą metody dbContext.Database.CanConnectAsync(cancellationToken);
Jednak niech nie zwiedzie Cię ta pozorna prostota. Jeśli chodzi o bazę MSSQL, to ta metoda faktycznie próbuje połączyć się z bazą danych, a potem wysyła zapytanie SELECT 1
.
Oczywiście można zrobić więcej – wystarczy dodać jakieś parametry, np.:
//healthcheck
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>(customTestQuery: async (ctx, token) =>
{
await ctx.WeatherArchives.CountAsync();
return true;
});
W tym momencie domyślne sprawdzenie zostanie zamienione na nasze. Czyli podczas sprawdzenia stanu zdrowia bazy danych, baza zostanie odpytana o ilość rekordów w tabeli WeatherArchives
– którą mamy zdefiniowaną w naszym AppDbContext
. Parametr CustomTestQuery
to po prostu funkcja, która przyjmuje w parametrze nasz kontekst bazy danych i CancellationToken
, a zwraca jakiś bool.
I co najważniejsze – ten kod wystarczy. Nie trzeba tutaj stosować żadnych try..catch’y, ponieważ cała nasza funkcja i tak jest wywoływana w kontekście try..catch
. Więc jeśli wystąpi jakiś exception
, mechanizm healthcheck zwróci nam odpowiednią informację.
Niemniej jednak przy standardowych zastosowaniach, standardowy mechanizm sprawdzania bazy danych jest w zupełności wystarczający.
Sprawdzanie niestandardowe
Jednak nasz przykładowy serwis ForecastService
, który ma imitować klienta jakiegoś zewnętrznego API, jest niestandardowym serwisem i nie znajdziemy biblioteki dla niego. Mechanizm HealthCheck pozwala jednak na napisanie własnego HealthChecka – dokładnie w taki sam sposób w jaki powstają te biblioteki wyżej pokazane.
Utworzenie klasy do sprawdzenia zdrowia
W pierwszej kolejności musimy utworzyć klasę, która implementuje interfejs IHealthCheck
. Interfejs ma tylko jedną metodę, którą musimy napisać.
Teraz załóżmy, że nasz serwis, który udaje klienta API do pobierania prognozy pogody wygląda tak:
public class ForecastService(RandomHelper _randomHelper)
{
public async Task<WeatherData> GetForecastFor(string city, DateOnly date)
{
await Task.Delay(500);
return new WeatherData
{
City = city,
Date = date,
TemperatureC = _randomHelper.GetRandomTemperature()
};
}
public async Task<bool> IsServiceHealthy()
{
await Task.Delay(500);
return true;
}
Czyli sprawdzenie stanu zdrowia tego serwisu będzie wymagało tylko wywołania metody IsServiceHealthy
, którą nam daje nasz oszukany klient. A jak to zrobić? No oczywiście w klasie implementującej IHealthCheck
:
public class ForecastServiceHealthCheck(ForecastService _forecastService) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var result = await _forecastService.IsServiceHealthy();
if (result)
return HealthCheckResult.Healthy();
else
return HealthCheckResult.Unhealthy();
}
}
W metodzie CheckHealthAsync
musimy teraz zwrócić HealthCheckResult
– rezultat, który mówi, czy testowany podsystem jest zdrowy, czy nie. Domyślne stany Healthy
i Unhealthy
zazwyczaj wystarczą.
Oczywiście w klasie implementującej IHealthCheck
możesz zrobić dowolny kod. Jeśli masz faktyczne zewnętrzne API, do którego się łączysz, możesz mieć tutaj po prostu HttpClienta, za pomocą którego wyślesz jakiś request.
I tak jak pisałem wcześniej – takich klas możesz sobie utworzyć tyle, ile potrzebujesz.
A jak ją zarejestrować? Też cholernie prosto:
//healthcheck
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>()
.AddCheck<ForecastServiceHealthCheck>("Forecast service");
Czyli wywołujemy metodę AddCheck
. W parametrze generycznym przekazujemy typ klasy, w której zaimplementowaliśmy sprawdzenie, a w parametrze Name
przekazujemy jakąś nazwę dla tego sprawdzenia. W tym wypadku: "Forecast service"
, bo sprawdzamy działanie właśnie tego serwisu.
Pokazywanie większej ilości informacji
W tym momencie, jeśli strzelimy na końcówkę z healthcheckiem dostaniemy odpowiedź w formie czystego stringa – Healthy
albo Unhealthy
. Ale możemy to zmienić w dość łatwy sposób. Najprościej pobrać sobie Nugeta: AspNetCore.Healthchecks.UI.Client
i podczas dodawania healthchecków do middleware dodać opcje:
app.MapHealthChecks("/_health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse //UIResponseWriter pochodzi z ww. Nugeta
});
I teraz dostaniemy dużo więcej informacji. Np. przy działającej aplikacji:
{
"status": "Healthy",
"totalDuration": "00:00:08.0064214",
"entries": {
"AppDbContext": {
"data": {},
"duration": "00:00:06.9029003",
"status": "Healthy",
"tags": []
},
"Forecast service": {
"data": {},
"duration": "00:00:00.5291383",
"status": "Healthy",
"tags": []
}
}
}
Zwróć uwagę, że otrzymujemy główny status apki i statusy poszczególnych serwisów, które sprawdzamy. AppDbContext to oczywiście sprawdzenie bazy danych. A Forecast service – to jest to, co sami pisaliśmy. Przy błędzie możemy uzyskać coś takiego:
{
"status": "Unhealthy",
"totalDuration": "00:00:00.5979899",
"entries": {
"AppDbContext": {
"data": {},
"duration": "00:00:00.0768822",
"status": "Healthy",
"tags": []
},
"Forecast service": {
"data": {},
"duration": "00:00:00.5204673",
"status": "Unhealthy",
"tags": []
}
}
}
Tutaj nie zadziałał serwis do prognoz.
Generalnie właściwość ResponseWriter
przy mapowaniu tych healthchecków daje nam opcje takiego stworzenia odpowiedzi jaką chcemy. Jeśli ta domyślna z Nugeta daje za mało info albo trochę za dużo, sami możemy coś pokombinować, np.:
app.MapHealthChecks("/_health", new HealthCheckOptions
{
ResponseWriter = async (httpContext, healthReport) =>
{
await httpContext.Response.WriteAsJsonAsync(healthReport);
}
});
ResponseWriter
to po prostu funkcja, która dostaje w parametrze HttpContext
i HealthReport
, a zwraca Task
. Jej zadaniem jest wypisanie do responsa tego, co chcemy zobaczyć w odpowiedzi na ten endpoint.
Więc możemy sobie tutaj skonstruować odpowiedź jaka nam się tylko podoba. Możemy np. napisać sobie funkcje, która zwróci nam informacje o wersji albo co sobie tam wymyślimy.
Dodatkowe możliwości
Jeśli przyjrzysz się metodzie AddCheck
z IHealthCheckBuilder
, zobaczysz że ma ona dodatkowe parametry, które możesz przekazać. Wszystkie parametry trafią później do HealthCheckStatus
– parametr w metodzie, w której tworzysz sprawdzenie – jak robiliśmy wyżej z ForecastServiceHealthCheck
.
Dodatkowo możesz umieścić tam np. timeout. Mechanizm healthcheck mierzy czas wykonania każdego sprawdzenia. Jeśli przekażesz timeout i ten czas zostanie przekroczony, no to też dostaniesz odpowiednią informację.
Jeśli chodzi o listę tags
, to możesz sobie wrzucić tam jakieś dodatkowe informacje, które są Ci potrzebne. O tym będzie jeszcze niżej.
Zabezpieczenie healthchecka – uwierzytelnianie
Zastanów się, czy każdy powinien mieć dostęp do Twojego healthchecka. Być może po drugiej stronie siedzi gdzieś ciemny typ, który próbuje hackować Twój system i zastanawia się jak po różnych krokach wygląda healthcheck. Jeśli dojdziesz do wniosku, że tylko niektóre osoby (maszyny) powinny mieć do tego dostęp, łatwo to ogarnąć.
W momencie, w którym mapujesz końcówkę healthchecka możesz dodać zabezpieczenia:
app.MapHealthChecks("/_health")
.RequireHost("localhost");
Po takiej konfiguracji, będzie można dobić się do healthchecka tylko z domeny localhost. Próba dojścia z innej da po prostu zwrotkę 404 Not Found
. Możesz też pokombinować mocniej. Np. wymusić konkretny port z dowolnego hosta:
app.MapHealthChecks("/_health")
.RequireHost("*:5001");
Takich metod Require*
mamy kilka, których możemy używać do różnych ograniczeń.
RequireCors
– będzie wymagało odpowiedniej polityki CORS. O CORSach pisałem tutaj,RequireAuthorization
– będzie wymagało uwierzytelnionego użytkownika. Jak to zrobisz, to już jest Twoja sprawa. W tej metodzie możesz podać politykę autoryzacyjną, możesz też role, schematy. Jeśli nie podasz niczego, będzie użyty domyślny schematy uwierzytelniania,RequireRateLimiting
– da Ci rate limiting na tej końcówce 🙂 Po krótce, jest to mechanizm, który pozwala uderzyć w dane miejsce nie częściej niż ileś tam razy. Czyli np. możesz sobie ustawić raz na minutę.
Te ograniczenia możesz ze sobą również łączyć. Nic nie stoi na przeszkodzie, żeby mógł dobić się tylko uwierzytelniony użytkownik z konkretnego hosta i to nie częściej niż co jakiś czas:
app.MapHealthChecks("/_health")
.RequireAuthorization()
.RequireHost("localhost:5101")
.RequireRateLimiting(...);
Filtrowanie healthchecków i kilka końcówek
Z jakiegoś powodu możesz chcieć uruchamiać tylko niektóre sprawdzenia. Domyślny mechanizm uruchamia wszystkie zarejestrowane HealthChecki
. Ale możesz stworzyć filtrować te serwisy i pozwalać na uruchamianie tylko niektórych. Ponadto, możesz mieć więcej końcówek dla różnych sprawdzeń. Na przykład:
app.MapHealthChecks("/_health", new HealthCheckOptions
{
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
app.MapHealthChecks("/_health_db", new HealthCheckOptions
{
Predicate = healthCheck => healthCheck.Tags.Contains("db")
});
Tutaj stworzyłem dwie końcówki:
_health
– sprawdzi wszystkie zarejestrowane healthchecki i zwróci rezultat w JSON (tak jak pokazywałem wyżej)_health_db
– sprawdzi tylko te healthchecki, które zwróciPredicate
– w tym wypadku te, które mają w swoich tagach słowo"db"
. I rezultat będzie zwrócony w standardowy sposób, czyli dostaniesz tylko informację Healthy lub Unhealthy (w tej końcówce nie posługujemy sięResponseWriterem
).
A skąd ten filtr ma wiedzieć, że tag „db” oznacza bazę danych? Na szczęście to nie jest żadna magia i sam musisz zadbać o to, żeby do odpowiednich serwisów dodać odpowiednie tagi. Robisz to podczas ich rejestracji, np. tak:
//healthcheck
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>(tags: new string[] { "db" })
.AddCheck<ForecastServiceHealthCheck>("Forecast service");
Zwróć uwagę, jak w trzeciej linijce dodałem tagi do serwisu badającego bazę danych.
Dodanie endpointa do Swaggera
Skoro to czytasz, to zapewne zauważyłeś, że tak stworzony endpoint dla healthcheck nie jest dodawany do Swaggera. I przy obecnej technologii, gdzie możemy używać Postmana i requestów prosto z VisualStudio (plik *.http) nie widzę w tym większego sensu, ale się da. Wystarczy stworzyć i zarejestrować swój własny DocumentFilter
.
IDocumentFilter
to interfejs, który dostarcza informacji o dodatkowych operacjach. Standardowo Swagger szuka po prostu kontrolerów i akcji w nich i na tej podstawie (używając refleksji) tworzy swoją dokumentację. Oczywiście można mu dodać operacje, które nie są obsługiwane przez kontrolery. Wystarczy zaimplementować ten interfejs IDocumentFilter
:
public class HealthCheckDocumentFilter : IDocumentFilter
{
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
{
var healthCheckOp = CreateHealthcheckOperation("_health", true);
var dbHealthCheckOp = CreateHealthcheckOperation("_health_db", false);
var healthPathItem = new OpenApiPathItem();
healthPathItem.AddOperation(OperationType.Get, healthCheckOp);
var dbHealthCheckPathItem = new OpenApiPathItem();
dbHealthCheckPathItem.AddOperation(OperationType.Get, dbHealthCheckOp);
swaggerDoc.Paths.Add("/_health", healthPathItem);
swaggerDoc.Paths.Add("/_health_db", dbHealthCheckPathItem);
}
private OpenApiOperation CreateHealthcheckOperation(string endpoint, bool returnsJson)
{
var result = new OpenApiOperation();
result.OperationId = $"{endpoint}OperationId";
var mediaType = returnsJson ? "application/json" : "text/plain";
var objType = returnsJson ? "object" : "string";
var schema = new OpenApiSchema
{
Type = objType
};
var response = new OpenApiResponse
{
Description = "Success"
};
response.Content.Add(mediaType, new OpenApiMediaType { Schema = schema });
result.Responses.Add("200", response);
return result;
}
}
No i musimy go zarejestrować podczas rejestracji Swaggera:
builder.Services.AddSwaggerGen(o =>
{
o.DocumentFilter<HealthCheckDocumentFilter>();
});
Nie będę omawiał tego kodu, bo nie ma nic wspólnego z healthcheckiem, tylko z dodawaniem operacji do Swaggera. Jest dość prosty i intuicyjny. Po prostu musimy dodać konkretne operacje (OpenApiOperation
) do konkretnych endpointów (swaggerDoc.Paths.Add
) i tyle. A każda operacja może składać się z różnych opisów, zwrotek itd. To wszystko co tutaj podasz, będzie potem widoczne w odpowiednich opisach na stronie Twojej dokumentacji.
To tyle na dzisiaj. Dzięki za przeczytanie tego artykułu. Jeśli czegoś nie rozumiesz lub znalazłeś jakiś błąd, koniecznie daj znać w komentarzu. Jeśli chciałbyś jakąś dodatkową wiedzę na temat healthchecków, to też daj znać. No i koniecznie podziel się tekstem z osobami, którym się przyda 🙂