Lizenzstatus als Health-Check: eine Keyright-Lizenz ohne Neustartschleifen auf Healthy, Degraded und Unhealthy abbilden
Ein headless .NET-Dienst hat keinen Info-Dialog, in dem er seine Lizenz anzeigen könnte, also ist der einzige Ort, an dem Operatoren den Lizenzstatus je zu sehen bekommen, das Monitoring-Dashboard – und das heißt, die Lizenz muss zum Health-Check werden. Die naive Variante verdrahtet die Lizenz direkt mit der Readiness-Probe, ein abgelaufener Schlüssel liefert plötzlich 503, der Orchestrator nimmt die Instanz aus der Rotation und killt sie anschließend, der Ersatz kommt mit demselben abgelaufenen Schlüssel hoch, und Sie haben aus einem Abrechnungsereignis eine Neustartschleife gebaut. Dieser Beitrag liest, was die drei HealthStatus-Werte für einen Orchestrator tatsächlich bedeuten, bildet den LicenseStatus von Keyright so darauf ab, dass ein gültiger, aber bald ablaufender Schlüssel Degraded statt Unhealthy ist, hält den Lizenzstatus vollständig von der Liveness-Probe fern, damit ein Ablauf nie einen Kill auslöst, cacht die Validierung, damit eine sekündliche Probe nicht jedes Mal eine Signatur neu verifiziert, und stellt die Details, die Operatoren brauchen, über das Daten-Dictionary des Health-Checks bereit. Er endet mit dem Tag-Layout, das den Lizenz-Endpoint von Readiness und Liveness trennt, und dem Test, der beweist, dass ein abgelaufener Schlüssel degradiert, ohne den Prozess mitzureißen.
Ein headless Dienst – ein Worker, ein gRPC-Backend, ein Host für geplante Jobs – hat keinen Ort, an dem er Ihnen seine Lizenz zeigen könnte. Kein Info-Dialog, keine Statusleiste, kein Einstellungsbildschirm. Der einzige Ort, an dem ein Operator je bemerken wird, dass der Schlüssel letzte Nacht abgelaufen ist, ist das Monitoring-Dashboard, und das heißt: Die Lizenz muss zum Health-Check werden. Die Falle ist, dass die naheliegende Verdrahtung – Lizenz in die Readiness-Probe – aus einem Abrechnungsereignis einen Crash-Loop macht. Dieser Beitrag baut die Variante, die das nicht tut.
Es geht darum, eine Keyright-Lizenz so auf das Health-Modell von ASP.NET Core abzubilden, dass gültig grün ist, bald ablaufend gelb ist und weiter bedient, und abgelaufen rot und laut ist – ohne je eine verfallene Lizenz einen Neustart auslösen zu lassen.
Drei Status, und was ein Orchestrator mit jedem davon macht
Health-Checks in ASP.NET Core haben genau drei Ergebnisse, und der gesamte Entwurf hängt daran, alle drei zu nutzen statt der zwei, zu denen die meisten greifen:
public enum HealthStatus { Unhealthy = 0, Degraded = 1, Healthy = 2 }
Die Reporting-Middleware bildet sie für Sie auf HTTP ab: Healthy und Degraded liefern beide 200, und nur Unhealthy liefert 503. Ein Orchestrator oder Load Balancer, der den Readiness-Endpoint liest, nimmt eine Instanz bei 503 aus der Rotation und lässt sie bei 200 drin. Degraded ist also der Status, mit dem ein Check sagen kann: „Etwas stimmt nicht, aber bedien weiter“ – und genau das ist die Form einer Lizenz innerhalb ihres Kulanzfensters. Reduzieren Sie das Modell auf Healthy/Unhealthy, und Sie sind gezwungen, ein Kulanzfenster entweder grün zu nennen (niemand sieht die Uhr ablaufen) oder rot (eine noch berechtigte Instanz wird aus der Rotation genommen). Beides ist falsch; Degraded ist die richtige Antwort, und sie liegt direkt vor Ihnen.
Die Abbildung
Keyrights Validate() liefert ein LicenseInfo, dessen Status ein LicenseStatus ist. Der Health-Check ist die Übersetzungstabelle von diesem Enum nach HealthStatus:
using Microsoft.Extensions.Diagnostics.HealthChecks;
public sealed class LicenseHealthCheck(LicenseSnapshot snapshot) : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context, CancellationToken ct = default)
{
LicenseInfo info = snapshot.Current; // cached; see below — no crypto on this path
var data = new Dictionary<string, object>
{
["status"] = info.Status.ToString(),
["licensee"] = info.Licensee ?? "(none)",
["expiryUtc"] = info.ExpiryUtc?.ToString("O") ?? "(n/a)",
["daysRemaining"] = info.DaysRemaining,
};
HealthCheckResult result = info.Status switch
{
LicenseStatus.Valid when info.IsExpiringSoon
=> HealthCheckResult.Degraded(
$"License valid, expires in {info.DaysRemaining} day(s).", data: data),
LicenseStatus.Valid
=> HealthCheckResult.Healthy("License valid.", data),
// Still entitled, serving on a cached lease inside the grace window.
LicenseStatus.Valid or LicenseStatus.Expired when snapshot.InGraceWindow
=> HealthCheckResult.Degraded(
"Running on cached lease inside grace window.", data: data),
_ => HealthCheckResult.Unhealthy(
$"License not valid: {info.Status}. {info.Message}", data: data),
};
return Task.FromResult(result);
}
}
Expired, Revoked, SignatureInvalid, MachineMismatch, ClockTampered und NoLicense fallen alle in den _-Zweig und kommen als Unhealthy heraus – der Dienst läuft unlizenziert, und der Verantwortliche muss es jetzt wissen. Die beiden Degraded-Zweige sind der Sinn der ganzen Übung: ein Schlüssel, der gültig ist, aber in seinem Verlängerungsfenster liegt, und ein Schlüssel, der verfallen ist, aber auf einem zwischengespeicherten Lease noch innerhalb eines Kulanzfensters liegt. Beide halten den Dienst in der Rotation, und beide färben das Dashboard gelb.
Weg von der Liveness – die Neustartschleife
Hier ist der Fehler, den die Verdrahtung mit der Readiness-Probe erzeugt. Liveness beantwortet „hängt dieser Prozess, Neustart?“. Readiness beantwortet „soll diese Instanz Traffic annehmen?“. Legen Sie die Lizenz auf die Liveness, und die Abfolge lautet:
- Der Schlüssel läuft um Mitternacht ab. Der Liveness-Check wird
Unhealthyund liefert 503. - Der Orchestrator killt den Container.
- Der Ersatz startet, liest dieselbe Lizenz von derselben Stelle, und der Liveness-Check wird erneut
Unhealthy. - Weiter bei 2.
Sie haben einen Crash-Loop gebaut, dessen Ursache ein Abrechnungsereignis ist, und die Neustarts begraben die eine Log-Zeile, die es Ihnen verraten hätte. Ein Neustart kann einen hängenden Prozess beheben; eine Lizenz verlängern kann er nicht. Die Lizenzprüfung gehört also höchstens auf die Readiness, vorzugsweise auf einen eigenen Endpoint, und nie auf die Liveness. Registrieren Sie die Checks mit Tags und trennen Sie die Endpoints nach Tag:
builder.Services
.AddSingleton<LicenseSnapshot>() // the cached snapshot (next section)
.AddHealthChecks()
.AddCheck<LicenseHealthCheck>("license", tags: ["license"])
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live", "ready"]);
var app = builder.Build();
// Liveness: nothing that a restart cannot fix. No license here.
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live"),
});
// Readiness: the license may ride along, but a Degraded grace window stays 200.
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("ready"),
});
// A dedicated endpoint monitoring scrapes for the amber/red license signal.
app.MapHealthChecks("/health/license", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("license"),
ResponseWriter = WriteLicenseJson, // emit the data bag, below
});
Behandelt Ihre Plattform Readiness strikt als 200 oder raus, lassen Sie "license" wie gezeigt aus dem ready-Prädikat heraus und lassen Sie allein /health/license sie tragen, damit ein Kulanzfenster – das Degraded/200 ist – nie das sein kann, was eine noch berechtigte Instanz abzieht. Sie entscheiden, was degradierter Traffic bedeutet; Sie lassen das nicht eine Probe entscheiden, indem sie die Maschine killt.
Der Snapshot: nicht bei jeder Probe die Kryptografie neu verifizieren
Eine Readiness-Probe kann jede Sekunde feuern. Der Lizenzstatus ändert sich höchstens einmal am Tag. Bei jeder Probe eine Signatur zu validieren, ist verschwendete Arbeit und verleitet Sie, schlimmer noch, dazu, einen Netzwerkaufruf auf den Probe-Pfad zu legen. Packen Sie den Client in einen memoisierten Snapshot, der sich in langsamer Kadenz aktualisiert, damit der Health-Check ein Feld liest:
public sealed class LicenseSnapshot
{
static readonly KeyrightClient License = KeyrightClient.Initialize(new KeyrightOptions
{
Product = "acme-worker",
PublicKeyBase64 = AcmeKeys.LicensePublicKey, // public key only; safe to ship
ServiceUrl = "https://licensing.acme.example",
});
readonly object _gate = new();
LicenseInfo _info = License.Validate();
DateTime _asOf = DateTime.UtcNow;
public LicenseInfo Current
{
get
{
lock (_gate)
{
if (DateTime.UtcNow - _asOf > TimeSpan.FromSeconds(60))
{
_info = License.Validate(); // crypto + any phone-home here, off the probe
_asOf = DateTime.UtcNow;
}
return _info;
}
}
}
public bool InGraceWindow => _info.Status == LicenseStatus.Valid && _info.IsExpiringSoon
|| (_info.Status == LicenseStatus.Expired && _info.DaysRemaining > -7);
}
Jetzt ist CheckHealthAsync ein Lesevorgang in konstanter Zeit auf _info, die Probe blockiert nie auf dem Netzwerk, und die einmal pro Minute stattfindende Aktualisierung ist der Ort, an dem eine Lease-Verlängerung oder ein Phone-Home im Hintergrund passiert – außerhalb des Probe-Pfads, was der andere Grund ist, warum der Check nie auf der Liveness liegen darf. (KeyrightClient cacht ein verifiziertes Ergebnis bereits intern; der Snapshot ergänzt die Aktualisierungskadenz und die eine Stelle, an der man es liest.)
Was Operatoren tatsächlich lesen
Das data-Dictionary auf jedem HealthCheckResult ist die Nutzlast, die das Ganze nützlich macht, statt nur ein farbiger Punkt zu sein. Geben Sie es vom Lizenz-Endpoint als JSON aus:
static Task WriteLicenseJson(HttpContext ctx, HealthReport report)
{
ctx.Response.ContentType = "application/json";
var entry = report.Entries["license"];
var payload = new
{
status = entry.Status.ToString(), // Healthy | Degraded | Unhealthy
detail = entry.Description,
data = entry.Data, // licensee, expiryUtc, daysRemaining
};
return ctx.Response.WriteAsJsonAsync(payload);
}
Ziehen Sie daysRemaining in Ihre Metriken, und Sie bekommen einen Alarm, der drei Wochen bevor ein Perpetual-plus-Wartung-Schlüssel verfällt feuert, nicht drei Wochen danach. Behalten Sie die Felder licensee und status; legen Sie den rohen Lizenzschlüssel, die Signatur oder irgendetwas, das ein Verlängerungshinweis tragen würde, nicht auf einen Endpoint, der womöglich weniger geschützt ist als die App selbst.
Testen Sie das Kulanzfenster, nicht nur gültig und abgelaufen
Der Test, auf den es ankommt, ist der, den der Entwurf mit der Readiness-Probe nicht bestanden hätte: Ein abgelaufener, aber noch in der Kulanz befindlicher Schlüssel muss Degraded zurückkommen und weiter bedienen, und ein hart abgelaufener Schlüssel muss Unhealthy zurückkommen, ohne dass der Liveness-Endpoint je etwas davon merkt.
[Fact]
public async Task Expiring_key_is_degraded_and_stays_in_rotation()
{
var snapshot = FakeSnapshot.With(status: LicenseStatus.Valid,
expiryUtc: DateTime.UtcNow.AddDays(5)); // IsExpiringSoon
var check = new LicenseHealthCheck(snapshot);
var result = await check.CheckHealthAsync(new HealthCheckContext());
Assert.Equal(HealthStatus.Degraded, result.Status); // amber, not red
// Degraded is HTTP 200 → the instance is NOT pulled from rotation.
}
[Fact]
public async Task Revoked_key_is_unhealthy()
{
var snapshot = FakeSnapshot.With(status: LicenseStatus.Revoked);
var result = await new LicenseHealthCheck(snapshot)
.CheckHealthAsync(new HealthCheckContext());
Assert.Equal(HealthStatus.Unhealthy, result.Status); // 503 on the license endpoint
}
Paaren Sie die beiden mit einer Assertion, dass das "live"-Prädikat den "license"-Check nie auflöst, damit ein künftiges Refactoring die Lizenz nicht stillschweigend auf die Liveness verschieben und den Crash-Loop neu bauen kann. Lassen Sie das Ganze gegen einen gefälschten Ausstellungsdienst laufen, wenn Sie auch den Aktualisierungs-Roundtrip abgedeckt haben wollen.
Das Fazit
Die Lizenz eines headless Dienstes ist nur je auf dem Dashboard sichtbar, also muss sie ein Health-Check sein – aber die drei HealthStatus-Stufen gibt es aus gutem Grund, und der Lizenzstatus nutzt alle drei. Valid ist Healthy und 200; gültig-aber-bald-ablaufend oder verfallen-aber-in-der-Kulanz ist Degraded und weiterhin 200, sodass die Instanz weiter bedient, während das Dashboard auf Gelb geht; alles andere ist Unhealthy und 503. Halten Sie den Check von der Liveness fern, damit ein Abrechnungsereignis nie eine Neustartschleife auslösen kann, lesen Sie ihn aus einem memoisierten Snapshot, damit eine sekündliche Probe nie Kryptografie neu ausführt, und geben Sie daysRemaining aus, damit der Alarm feuert, bevor der Schlüssel verfällt, nicht danach. Die Lizenz hört auf, eine unsichtbare Zeitbombe in einem Dienst ohne UI zu sein, und wird zu einer weiteren farbigen Linie, die Ops ohnehin schon zu beobachten wissen.
Nebula.NET testen
Härten Sie Ihren .NET-Code in wenigen Minuten — starten Sie mit der kostenlosen Edition.