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.