Skip to content
← Todas las publicaciones
· Delta1 Labs LicensingKeyright

Licenciar una app .NET en contenedor cuando la identidad de máquina es efímera

La activación por nodo vincula una licencia a una huella de máquina estable, pero los contenedores obtienen una identidad nueva en cada reinicio, así que la vinculación ingenua agota activaciones. Así se licencia una app .NET en contenedor con Keyright: inyectando una identidad de despliegue estable, o arrendando asientos concurrentes para réplicas escaladas, y fallando en cerrado de forma limpia al arrancar.

El licenciamiento por nodo descansa sobre una suposición cómoda: que una máquina tiene una identidad estable. Calcula el hash de unos cuantos atributos de hardware duraderos, vincula la licencia a esa huella, y una licencia corresponde a una máquina durante toda la vida de la activación. Esa suposición se cumple en una estación de trabajo o en una VM de larga vida. Se desmorona en cuanto pones la app en un contenedor.

Un contenedor no tiene una identidad duradera. Tiene una identidad nueva, acuñada en cada arranque. /etc/machine-id se regenera, la dirección MAC es sintética y se reasigna, el nombre de host es un hash corto aleatorio, y el sistema de ficheros se descarta al salir. Para un cliente de licencias basado en huellas, cada reinicio, cada redespliegue y cada evento de escalado parece una máquina completamente nueva. Vincula de forma ingenua y agotarás tus activaciones en una semana de despliegues normales —o peor, una actualización progresiva desplegará una flota de contenedores que fallen la vinculación por nodo todos a la vez.

Idea clave: en los contenedores, deja de derivar la identidad de máquina del contenedor. O bien vincula la licencia a una identidad estable que inyecte el operador (una variable de entorno o un secreto montado que sobreviva a reinicios y despliegues), o bien —para réplicas elásticas y escaladas horizontalmente— usa licenciamiento concurrente por arriendo, donde cada réplica retira un asiento de corta duración y lo devuelve automáticamente al morir. Ambos permiten validar al arrancar y fallar en cerrado sin quemar activaciones en cada despliegue.

Por qué las huellas del contenedor no sirven

Vale la pena ser preciso sobre por qué los datos de huella habituales te traicionan, porque el fallo es silencioso hasta que tus asientos se han agotado.

  • /etc/machine-id lo rellena el sistema de init en el primer arranque. En un contenedor no hay un primer arranque persistente —el fichero está vacío o se genera de nuevo en la capa de la imagen—, así que o bien cada contenedor comparte un único valor horneado (inútil para distinguir nodos) o cada uno obtiene uno nuevo (inútil para la estabilidad).
  • Las direcciones MAC pertenecen a interfaces virtuales que el runtime crea y destruye con el contenedor. No son la NIC del host y no persisten.
  • El nombre de host adopta por defecto el id truncado del contenedor —distinto en cada docker run y en cada pod planificado por el orquestador.
  • Los números de serie de disco y volumen apuntan a un overlay o a un volumen efímero que se descarta al salir.

El orquestador lo empeora a propósito: todo su trabajo consiste en tratar las instancias como ganado, reprogramándolas y reemplazándolas libremente. Una huella que cambia al reprogramar no identifica una máquina; identifica un tiempo de vida de contenedor, que es exactamente la granularidad equivocada para una licencia.

Los dos diagramas de abajo contrastan lo que ocurre a lo largo de un único despliegue progresivo.

Despliegue progresivo: los pods v1 drenan, los pods v2 arrancanHuella del contenedor (efímera)pod v1-aid=7f3a → asiento 1pod v1-bid=b1c9 → asiento 2pod v2-aid=2e8d → asiento 3pod v2-bid=9a04 → asiento 44 activaciones quemadas por un despliegueKEYRIGHT_DEPLOYMENT_ID inyectado (estable)pod v1-aid=checkout-svcpod v1-bid=checkout-svcpod v2-aid=checkout-svcpod v2-bid=checkout-svc1 identidad, la reactivación es idempotenteA la izquierda, la flota parece cuatro máquinas distintas para el servicio emisor; a la derechaparece un nodo reactivándose, así que no se consume ningún asiento adicional en el despliegue.

Estrategia 1 — vincular a una identidad de despliegue inyectada

El arreglo limpio para un único despliegue lógico (un servicio licenciado, por muchas veces que se reinicie) es dejar de permitir que el SDK adivine la identidad de la máquina y, en su lugar, entregarle una que controle el operador. El operador establece un valor —llamémoslo KEYRIGHT_DEPLOYMENT_ID— que permanece constante entre reinicios y despliegues del mismo servicio. El contenedor lo lee al arrancar y lo pasa al SDK como la identidad de vinculación.

KeyrightOptions expone un MachineId justo para esto. Cuando se establece, el SDK vincula la activación a tu valor y nunca toca la huella del contenedor:

using Delta1Labs.Keyright;

static string RequireDeploymentId()
{
    // Prefer a mounted secret file; fall back to an env var. Never derive
    // this from the container — that is the whole point.
    var path = Environment.GetEnvironmentVariable("KEYRIGHT_DEPLOYMENT_ID_FILE");
    if (!string.IsNullOrEmpty(path) && File.Exists(path))
        return File.ReadAllText(path).Trim();

    var id = Environment.GetEnvironmentVariable("KEYRIGHT_DEPLOYMENT_ID");
    if (string.IsNullOrWhiteSpace(id))
        throw new InvalidOperationException(
            "KEYRIGHT_DEPLOYMENT_ID is not set. A containerized deployment must " +
            "inject a stable license identity; refusing to start unbound.");
    return id.Trim();
}

static readonly KeyrightClient License = KeyrightClient.Initialize(new KeyrightOptions
{
    Product         = "checkout-service",
    PublicKeyBase64 = "MIIBIjANBgkq...",                    // verify-only public key
    ServiceUrl      = "https://keyright.example.com",       // your self-hosted instance
    MachineId       = RequireDeploymentId()                 // the injected stable identity
});

Con MachineId fijado, el flujo de activación es el de siempre —pero ahora es estable:

// Run once at startup, before serving traffic.
LicenseInfo info = await License.ActivateAsync(
    Environment.GetEnvironmentVariable("KEYRIGHT_LICENSE_KEY")!, ct);

if (!info.IsLicensed)
{
    // Fail closed. See "Failing closed cleanly" below.
    logger.LogError("License not active: {Reason}", info.Reason);
    return StartupResult.Unlicensed(info);
}

logger.LogInformation("Licensed: {Tier}, bound to {Id}", info.Tier, info.MachineId);

Como cada contenedor de este despliegue se activa contra el mismo MachineId, Keyright reconoce un reinicio o un reemplazo desplegado como una reactivación de un nodo que ya conoce. Reactivar una identidad ya vinculada es idempotente: refresca el arriendo y no consume ningún asiento adicional. Cien reinicios cuestan un asiento. El arriendo firmado que el SDK cachea se escribe en el sistema de ficheros del contenedor y se descarta de forma natural al salir —lo cual no es problema, porque el siguiente contenedor simplemente se reactiva contra el id estable y recibe un arriendo nuevo.

De dónde debe venir la identidad

El id inyectado debe ser estable y controlado por el operador, no derivado de nada que el runtime regenere. Buenas fuentes: un valor que el operador establece por servicio en su configuración de despliegue, un secreto montado, o la identidad de carga de trabajo estable de tu orquestador (no el nombre del pod —el nombre de la carga de trabajo). Trata la clave de licencia igual: móntala como un secreto, nunca la hornees en la capa de la imagen.

Estrategia 2 — arriendos concurrentes para réplicas escaladas

La Estrategia 1 es correcta cuando «el despliegue» es un nodo licenciado. Es incorrecta para un conjunto de réplicas elástico, porque un único id inyectado haría que veinte réplicas parecieran un nodo —estarías contando de menos, no licenciando con honradez. Cuando realmente ejecutas N réplicas intercambiables y quieres licenciar concurrencia, usa arriendos flotantes en su lugar.

Cada réplica adquiere un arriendo de asiento de corta duración al arrancar, lo renueva con un latido mientras se ejecuta, y el arriendo caduca por sí solo si la réplica es eliminada o reducida. Compras un techo de concurrencia; el orquestador puede escalar entre cero y ese techo libremente, y una réplica terminada con SIGKILL nunca deja un asiento atascado.

static readonly KeyrightClient License = KeyrightClient.Initialize(new KeyrightOptions
{
    Product         = "checkout-service",
    PublicKeyBase64 = "MIIBIjANBgkq...",
    ServiceUrl      = "https://keyright.example.com"
    // No MachineId here: each replica is a distinct lease holder, not a bound node.
});

SeatLease lease;
try
{
    // A fresh instance id per replica is fine and expected — the seat is tied
    // to a live lease, not to a durable fingerprint.
    lease = await License.AcquireSeatAsync(
        Environment.GetEnvironmentVariable("KEYRIGHT_LICENSE_KEY")!, ct);
}
catch (SeatsExhaustedException)
{
    // Concurrency ceiling hit. Fail closed: do not report healthy.
    logger.LogError("All concurrent seats are in use; refusing to start.");
    return StartupResult.Unlicensed(reason: "seats-exhausted");
}

// Keep the lease alive for the life of the replica.
var keepAlive = new SeatKeepAlive(License, lease);
hostLifetime.ApplicationStopping.Register(() =>
    License.ReleaseSeatAsync(lease).GetAwaiter().GetResult());   // prompt check-in

El caso del fallo es el que los contenedores reciben gratis aquí: si el pod es terminado por OOMKilled o desalojado, ReleaseSeatAsync nunca se ejecuta, el latido se detiene, y el arriendo alcanza su TTL y se recupera del lado del servidor. Sin asiento atascado, sin activación quemada. (El ciclo de vida del arriendo, el temporizador del latido y el reintento elegante cuando todos los asientos están en uso se cubren en profundidad en asientos concurrentes flotantes.)

Qué estrategia encaja:

Inyectar un id estable (Estrategia 1)Arriendos concurrentes (Estrategia 2)
ModeloUn nodo licenciado, muchos reiniciosN réplicas intercambiables
Vinculado aUna identidad inyectada por el operadorUn arriendo vivo por réplica
Evento de escaladoMismo id → reactivación idempotenteCada réplica toma/devuelve un asiento
Necesita el servicioEn la activación / refresco del arriendoContinuamente, para contar concurrencia
Bueno paraUn servicio singleton o activo-pasivoConjuntos elásticos escalados horizontalmente

Fallar en cerrado de forma limpia

Un contenedor nunca debe empezar a servir tráfico con una licencia sin resolver, y tampoco debe entrar en un bucle de reinicios por un contratiempo de licencia. El SDK de Keyright deja la política en tus manos: Validate() y ActivateAsync() no lanzan excepciones ante fallos ordinarios —una clave que falta, un servicio inalcanzable, un asiento agotado, una licencia revocada—, devuelven un LicenseInfo (o lanzan la excepción tipada SeatsExhaustedException en la ruta de arriendo) que inspeccionas y según el cual actúas. Decide la política una vez, al arrancar, antes de que se sirva la primera petición.

Hay dos estados cerrados sensatos, y deberías elegir deliberadamente:

  1. Degradar a Community Edition. Si tu producto tiene un nivel gratuito, un contenedor sin licencia puede ejecutar el conjunto de funciones de Community Edition. Validate() devuelve un LicenseInfo en la edición Community que porta el motivo; restringe las prestaciones de pago según info.Entitlements y ejecuta.
  2. Rehusar estar sano. Para un producto sin ruta gratuita, no salgas del proceso (eso solo entra en un bucle de reinicios y satura al servicio emisor). En su lugar, arranca el proceso pero reporta no sano en tu sonda de disponibilidad para que el orquestador retenga el tráfico, y registra el motivo a voz en grito. Esto le da al operador una ventana para arreglar la clave inyectada o restaurar la conectividad sin una tormenta de reinicios.
app.MapHealthChecks("/ready", new HealthCheckOptions
{
    Predicate = c => c.Tags.Contains("ready")
});

// A readiness check that reflects the license decision made at startup.
services.AddHealthChecks().AddCheck("license", () =>
    LicenseState.Current.IsLicensed
        ? HealthCheckResult.Healthy($"Licensed: {LicenseState.Current.Tier}")
        : HealthCheckResult.Unhealthy($"Unlicensed: {LicenseState.Current.Reason}"),
    tags: new[] { "ready" });

Restringe la funcionalidad licenciada real de la misma manera en todas partes —comprueba una prestación, nunca un booleano cacheado de una ejecución anterior:

LicenseInfo info = License.Validate();              // offline, no network, never throws
bool canUsePremiumRouting = info.Entitlements.IsEnabled("premium-routing");
long maxThroughput        = info.Entitlements.GetLimit("max-rps", 50);

Conectarlo en la imagen y el fichero compose

La imagen del contenedor queda limpia: ninguna clave de licencia, ninguna identidad horneada. Todo lo relativo a la licencia llega en tiempo de ejecución como entorno y secretos que controla el operador.

# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
WORKDIR /app
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["CheckoutService.csproj", "./"]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=build /app/publish .
# No KEYRIGHT_LICENSE_KEY and no KEYRIGHT_DEPLOYMENT_ID in the image.
# They are injected at run time. Fail closed if the id is missing (see startup code).
ENTRYPOINT ["dotnet", "CheckoutService.dll"]

El servicio de compose es donde la identidad estable y el material de la clave realmente provienen. La clave es un secreto montado; el id de despliegue es una variable de entorno simple, establecida por el operador, que permanece constante entre up, reinicio y redespliegue:

services:
  checkout:
    image: registry.example.com/checkout-service:2.3.0
    environment:
      # Stable across restarts/redeploys — operator-owned, NOT container-derived.
      KEYRIGHT_DEPLOYMENT_ID: "checkout-svc-prod"
      KEYRIGHT_LICENSE_KEY_FILE: "/run/secrets/keyright_key"
      ServiceUrl: "https://keyright.example.com"
    secrets:
      - keyright_key
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8080/ready"]
      interval: 15s
      timeout: 3s
      retries: 5          # readiness reflects the license state; unlicensed = not ready
    deploy:
      replicas: 1         # Strategy 1: one bound node. For Strategy 2, raise replicas
                          # and drop KEYRIGHT_DEPLOYMENT_ID (use leases instead).
secrets:
  keyright_key:
    file: ./secrets/keyright.key

Dos reglas lo hacen robusto. Primera, la identidad y la clave nunca están en la imagen —una imagen se copia, se reetiqueta y se comparte, y cualquier cosa horneada en ella se filtra. Segunda, para la Estrategia 1 el KEYRIGHT_DEPLOYMENT_ID lo establece una vez el operador y se trata como parte de la configuración del despliegue, así que viaja a través de cada reinicio y actualización progresiva sin cambiar. Para la Estrategia 2, lo omites por completo y subes el recuento de réplicas —el modelo de arriendo cuenta la concurrencia en su lugar.

Resumen

Los contenedores rompieron la única suposición sobre la que se construyó la vinculación por nodo —una identidad de máquina estable— y el arreglo es dejar de fingir que el contenedor tiene una. Para un único servicio licenciado, inyecta un KEYRIGHT_DEPLOYMENT_ID estable que posea el operador y vincula a él con KeyrightOptions.MachineId, de modo que los reinicios y los despliegues reactiven el mismo nodo de forma idempotente y nunca quemen un asiento. Para conjuntos de réplicas elásticos, licencia la concurrencia con arriendos flotantes, de modo que cada réplica retire un asiento de corta duración y una réplica terminada lo devuelva por sí sola. En ambos casos, resuelve la licencia al arrancar, mantén la clave y la identidad fuera de la imagen, y falla en cerrado en tu sonda de disponibilidad en lugar de entrar en un bucle de reinicios. El SDK de Keyright.NET te da ambas rutas —activación fijada por MachineId y concurrencia por arriendo— con un LicenseInfo en cerrado en cada punto intermedio, para que un orquestador pueda reprogramar tu app todo el día sin tocar tu recuento de activaciones.

Prueba Nebula.NET

Endurece tu código .NET en minutos — empieza con la edición gratuita.