diff --git a/Dockerfile b/Dockerfile index 2385042..6f78cd8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,4 +32,8 @@ COPY --from=build --chown=1654:1654 /out/ ./ USER 1654:1654 EXPOSE 8080/tcp EXPOSE 9050/udp +# The chiseled image has no shell or curl; the probe re-enters the server +# assembly in --health-probe mode and reports through the exit code. +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD ["dotnet", "FinalFactory.Rendezvous.Server.dll", "--health-probe"] ENTRYPOINT ["dotnet", "FinalFactory.Rendezvous.Server.dll"] diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml index 0bb39d8..0e12c43 100644 --- a/deploy/compose/compose.yaml +++ b/deploy/compose/compose.yaml @@ -24,6 +24,12 @@ services: hard: 4096 stop_grace_period: 40s restart: unless-stopped + healthcheck: + test: ["CMD", "dotnet", "FinalFactory.Rendezvous.Server.dll", "--health-probe"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s environment: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_HTTP_PORTS: "8080" diff --git a/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj b/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj index 71b563b..6b76999 100644 --- a/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj +++ b/src/FinalFactory.Rendezvous.Server/FinalFactory.Rendezvous.Server.csproj @@ -57,11 +57,13 @@ + + diff --git a/src/FinalFactory.Rendezvous.Server/Http/RootEndpoints.cs b/src/FinalFactory.Rendezvous.Server/Http/RootEndpoints.cs new file mode 100644 index 0000000..5bdbb50 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Http/RootEndpoints.cs @@ -0,0 +1,27 @@ +using FinalFactory.Rendezvous.Contracts; +using FinalFactory.Rendezvous.Server.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace FinalFactory.Rendezvous.Server.Http; + +internal static class RootEndpoints +{ + public static IEndpointRouteBuilder MapRootEndpoint( + this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/", ServeRoot).ExcludeFromDescription(); + return endpoints; + } + + private static IResult ServeRoot( + [FromServices] IOptions diagnostics) => + diagnostics.Value.Enabled + ? Results.Redirect("/diagnostics") + : Results.Json(new + { + contractVersion = ContractLimits.ContractVersion, + service = "rendezvous", + health = "/health/live", + }); +} diff --git a/src/FinalFactory.Rendezvous.Server/Observability/HealthProbe.cs b/src/FinalFactory.Rendezvous.Server/Observability/HealthProbe.cs new file mode 100644 index 0000000..c0feff4 --- /dev/null +++ b/src/FinalFactory.Rendezvous.Server/Observability/HealthProbe.cs @@ -0,0 +1,34 @@ +namespace FinalFactory.Rendezvous.Server.Observability; + +/// +/// Container healthcheck entry mode (--health-probe). The chiseled +/// runtime image ships no shell or curl, so the probe re-enters the server +/// assembly, queries the local liveness endpoint, and reports via exit code. +/// +internal static class HealthProbe +{ + public const string Argument = "--health-probe"; + + public static async Task RunAsync() + { + string configuredPorts = + Environment.GetEnvironmentVariable("ASPNETCORE_HTTP_PORTS") ?? "8080"; + string port = configuredPorts + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .FirstOrDefault() ?? "8080"; + using HttpClient client = new() { Timeout = TimeSpan.FromSeconds(4) }; + try + { + using HttpResponseMessage response = await client.GetAsync( + new Uri($"http://127.0.0.1:{port}/health/live")); + return response.IsSuccessStatusCode ? 0 : 1; + } + catch (Exception exception) + when (exception is HttpRequestException + or TaskCanceledException + or UriFormatException) + { + return 1; + } + } +} diff --git a/src/FinalFactory.Rendezvous.Server/Program.cs b/src/FinalFactory.Rendezvous.Server/Program.cs index 35bdf45..715cb26 100644 --- a/src/FinalFactory.Rendezvous.Server/Program.cs +++ b/src/FinalFactory.Rendezvous.Server/Program.cs @@ -16,6 +16,11 @@ using FinalFactory.Rendezvous.Server.Transport; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.OpenApi; +if (args.Contains(HealthProbe.Argument, StringComparer.Ordinal)) +{ + return await HealthProbe.RunAsync(); +} + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Logging.AddFilter( "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware", @@ -378,6 +383,7 @@ app.UseMiddleware(); app.UseExceptionHandler(); app.UseMiddleware(); app.MapOpenApi(); +app.MapRootEndpoint(); app.MapRendezvousContractEndpoints(); app.MapOperatorEndpoints(); app.MapRendezvousHealthEndpoints(); @@ -385,6 +391,7 @@ app.MapDiagnosticDashboardEndpoints(); app.MapPrometheusMetricsEndpoint(metricsOptions, metricsCredential); await app.RunAsync(); +return 0; /// /// Entry point marker used by integration-test hosts.