Licenciar contenedores y servicios .NET con autoescalado usando Keyright
El licenciamiento ligado a la máquina da por hecho que hay una máquina: un equipo estable con una huella a la que asignar un asiento. Un contenedor no tiene nada de eso: la imagen es idéntica en todas las réplicas, el host es efímero y un autoescalador crea y destruye instancias más rápido de lo que cualquier flujo de activación puede seguir. Aquí tienes cómo licenciar un servicio .NET escalado horizontalmente con Keyright: activar una sola vez en el límite del despliegue, enviar una licencia firmada a cada réplica, validar sin conexión sin llamadas por instancia y decidir de antemano si un asiento significa un despliegue o una instancia en ejecución.
Todo SDK de licenciamiento que se ata a una máquina da por hecho que hay una máquina: un host duradero con una huella —un id de CPU, un GUID del registro, una dirección MAC— a la que puedes asignar un asiento y reconocer de nuevo mañana. Esa suposición es la base del node-locking, y es completamente falsa dentro de un contenedor.
Una réplica no es una máquina. Diez réplicas de tu servicio ejecutan la misma imagen, así que una huella ingenua calculada a partir del entorno colisiona y todas parecen un solo host; o peor, la huella se basa en algo propio del contenedor y cada réplica parece una máquina nueva que necesita su propio asiento. El host de debajo es efímero: la huella contra la que activaste esta mañana desaparece tras el despliegue de la tarde. Y un autoescalador crea y destruye instancias más rápido de lo que cualquier negociación de activación puede seguir, así que un flujo de activación por réplica o agota tu recuento de asientos en minutos o deja leases muertos en hosts que ya no existen.
Este artículo trata de cómo licenciar un servicio .NET escalado horizontalmente con Keyright sin que nada de eso ocurra. El movimiento central es dejar de activar réplicas y empezar a activar el despliegue —una sola vez, en un límite que sí es estable— y luego enviar una licencia firmada a cada réplica que cada una verifica sin conexión. Vamos a construir eso, a añadir una variante con latido para cuando de verdad quieras medir instancias y a cablear un proveedor de identidad de máquina personalizado para que controles exactamente qué significa “la misma máquina”.
El límite que sí es estable
Todo el problema se disuelve en cuanto eliges la unidad correcta. Solo hay dos respuestas honestas a “qué es un asiento aquí”, y llevan a cableados distintos:
- Un asiento es un despliegue. Vendes una licencia por entorno de producción. Escala a tres réplicas o a treinta; eso es un detalle de implementación de la infraestructura del cliente, no un evento facturable. Esto es casi siempre lo que quieres para un producto de servidor.
- Un asiento es una instancia en ejecución. Mides de verdad la concurrencia —N instancias pueden ejecutarse a la vez— y quieres que el autoescalado por encima de N se deniegue o consuma de un pool.
Elige deliberadamente. El modo de fallar es elegir la medición por instancia sin querer porque levantaste un flujo de activación de una app de escritorio, donde una instalación equivale a una máquina equivale a un asiento. En un escritorio esa identidad se mantiene años. En un clúster se mantiene lo que dure un pod, que pueden ser noventa segundos.
Activa una vez, en el límite
La activación es el único paso que habla con tu servicio emisor, y es el único paso que no debe ejecutarse dentro de una réplica que atiende peticiones. Ejecútalo una vez —en un pipeline de publicación, un job de inicialización o un contenedor de arranque puntual— y captura la licencia firmada que devuelve.
// bootstrap: runs ONCE per deployment, not per replica.
// Output is a signed lease blob that every replica will verify offline.
var client = new KeyrightClient(new KeyrightOptions
{
LicenseKey = Environment.GetEnvironmentVariable("KEYRIGHT_KEY"),
// Pin identity to the DEPLOYMENT, not the ephemeral host (see below).
MachineIdentity = new DeploymentIdentity("prod-eu-west"),
});
ActivationResult result = await client.ActivateAsync();
if (!result.Succeeded)
{
Console.Error.WriteLine($"activation failed: {result.Status}");
return 1;
}
// Persist the signed lease where replicas can read it read-only.
await File.WriteAllBytesAsync("/license/lease.bin", result.Lease.ToBytes());
return 0;
result.Lease es el artefacto firmado: la carga de licencia más el lease por máquina, firmados con la clave privada de tu servicio emisor y verificables contra la clave pública compilada en tu binario. Lleva su propia ventana de validez (14 días por defecto) y la identidad de despliegue para la que fue acuñado. Nada secreto vive en él que un atacante no pudiera ya leer de una instancia en ejecución, así que distribuirlo como configuración de solo lectura ordinaria está bien: está firmado, no es confidencial.
Verifica en cada réplica, sin conexión
Cada réplica carga esa licencia y la valida localmente. Sin ActivateAsync, sin llamada al servicio emisor, sin estado mutable compartido: solo una verificación de firma contra la clave pública incrustada.
public sealed class LicenseGate : IHostedService
{
private volatile LicenseSnapshot _snapshot = LicenseSnapshot.Denied;
public LicenseSnapshot Current => _snapshot;
public Task StartAsync(CancellationToken ct)
{
var leaseBytes = File.ReadAllBytes("/license/lease.bin");
var client = new KeyrightClient(new KeyrightOptions
{
// Same deployment identity the lease was minted for; the
// offline check confirms the lease matches this identity.
MachineIdentity = new DeploymentIdentity("prod-eu-west"),
LeaseSource = LeaseSource.FromBytes(leaseBytes),
});
LicenseInfo info = client.Validate(); // offline signature verification
_snapshot = info.IsValid
? new LicenseSnapshot(true, info.Edition, client.Entitlements(), info.Status)
: LicenseSnapshot.Denied;
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
Validate() es una operación criptográfica local. Se ejecuta en un pod aislado de red, en un clúster sin conexión y en un arranque en frío desde cero donde el servicio emisor quizá ni siquiera esté accesible, que es justamente el motivo de hacer la activación en el límite. Una réplica que no puede alcanzar nada todavía puede demostrar que posee una licencia válida, porque la prueba vive en la firma, no en una devolución de llamada.
Regístralo una vez y lee el snapshot en todas partes, exactamente como harías con un único gate en una app de escritorio:
builder.Services.AddSingleton<LicenseGate>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<LicenseGate>());
// In a request path — read the cached snapshot, never re-validate inline.
app.MapPost("/export", (LicenseGate gate) =>
gate.Current.Entitlements.IsEnabled("bulk-export")
? Results.Ok(RunExport())
: Results.StatusCode(StatusCodes.Status402PaymentRequired));
Controlar qué significa “la misma máquina”
La pieza que hace que todo esto funcione es el proveedor de identidad de máquina. El predeterminado de Keyright deriva una identidad de las características del host: correcto para un escritorio, equivocado para una réplica. Sobrescríbelo para que tú decidas la granularidad. Para licenciamiento por despliegue, cada réplica debe resolver a la misma identidad, de modo que la licencia acuñada para el despliegue valide en todas ellas:
public sealed class DeploymentIdentity : IMachineIdentityProvider
{
private readonly string _deployment;
public DeploymentIdentity(string deployment) => _deployment = deployment;
// Stable across replicas and restarts; independent of the ephemeral host.
// Source it from a value that is constant for the deployment, e.g. an
// injected environment name, NOT from hostname/CPU/MAC.
public string GetMachineId() =>
Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes($"deployment:{_deployment}")));
}
La regla está en el comentario: deriva el id de algo constante para el despliegue (un nombre de entorno, un id de inquilino que inyectes) y nunca de Dns.GetHostName(), el id del contenedor ni ningún valor por réplica. Si el id varía por réplica, vuelves a la medición por instancia, lo pretendieras o no.
Cuando sí quieres medir instancias
A veces la concurrencia es el producto y quieres que se ejecuten exactamente N instancias. Entonces te apoyas en la vida corta del lease en vez de pelear contra ella. Acuña leases por instancia con validez corta y un latido; una réplica que se reduce simplemente deja de renovar, y su asiento se recupera cuando el lease caduca, sin una llamada explícita de desactivación que un contenedor terminado nunca llegaría a hacer.
var client = new KeyrightClient(new KeyrightOptions
{
LicenseKey = key,
MachineIdentity = new InstanceIdentity(), // distinct per replica, on purpose
LeaseDuration = TimeSpan.FromMinutes(10), // short, so scale-in self-heals
});
// A background heartbeat renews while the instance lives.
// Miss two renewals (e.g. the pod was killed) and the seat frees itself.
_ = Task.Run(async () =>
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(ct))
await client.RefreshAsync(); // the only runtime call to the issuing service
});
Esta es la única topología donde las réplicas hablan con el servicio emisor en tiempo de ejecución, y es un canje deliberado: aceptas una dependencia de renovación a cambio de una contabilidad de concurrencia precisa. Todo lo que el gate sirve sigue leyendo el snapshot en caché; el latido solo intercambia la referencia de ese snapshot en una renovación exitosa, y un fallo de renovación transitorio cabalga sobre la validez restante del lease en vez de denegar el servicio al primer tropiezo: el mismo razonamiento de gracia que aplicarías a cualquier gate sin conexión primero.
Qué llevarte
El licenciamiento en contenedores se tuerce cuando se confunde una réplica con una máquina. Arregla primero la unidad —despliegue o instancia— y el cableado se deduce. Para el caso común, un despliegue es el límite estable: activa una vez ahí, acuña una única licencia firmada y deja que un Validate() sin conexión en cada réplica haga el resto, con un proveedor de identidad de máquina que fije todas las réplicas a una identidad. Reserva los leases por instancia y los latidos para cuando la concurrencia sea de verdad lo que vendes. En ambos casos, las réplicas que atienden peticiones siguen siendo sin conexión primero y el servicio emisor se mantiene fuera de la ruta crítica, que es lo que permite a un servicio escalar de cero a treinta y volver sin tocar tu recuento de asientos ni tu latencia.
Prueba Nebula.NET
Endurece tu código .NET en minutos — empieza con la edición gratuita.