Table of Contents

Scalable and stateless report generation in Kubernetes

This page describes how to leverage the stateless design of List & Label Cross Platform (LLCP) to build highly concurrent, horizontally scalable report generation services running inside Kubernetes clusters.


Why LLCP fits a stateless containerized architecture

LLCP is built around a headless, stateless execution model:

  • No per-instance state persists between requests. Each ListLabel instance is self-contained. Report definitions are read from JSON files; no data is cached across requests inside the engine.
  • No platform-specific graphics APIs. Rendering relies on SkiaSharp, which has no UI or windowing dependencies, making it safe to run in any Linux container.
  • JSON report files are read-only at runtime. Multiple instances can read the same .json project files concurrently without coordination.
  • Output is written to streams or files. The engine produces output independently and has no shared mutable state that spans requests. These properties allow Kubernetes to scale LLCP-based services horizontally with standard mechanisms: multiple pods processing requests independently, without shared memory or sticky sessions.

The ListLabel instance lifecycle

ListLabel is not thread-safe and should be created per request. It implements IDisposable; always dispose after use.

Do this — one instance per request:

// Inside an ASP.NET Core controller or minimal API handler
app.MapPost("/reports/generate", async (ReportRequest request, IDataService dataService) =>
{
    using ListLabel listLabel = new ListLabel
    {
        LicensingInfo = "...",
        DataSource = dataService.GetDataSource(request.DataId),
        AutoProjectFile = request.ProjectFile
    };
    string outputPath = Path.GetTempFileName() + ".pdf";
    ExportConfiguration config = new ExportConfiguration(
        LlExportTarget.Pdf,
        outputPath,
        request.ProjectFile);
    listLabel.Export(config);
    byte[] result = await File.ReadAllBytesAsync(outputPath);
    File.Delete(outputPath);
    return Results.File(result, "application/pdf");
});
Warning

Do not register ListLabel as a singleton or a scoped service shared across multiple concurrent requests. The class maintains per-render state during export and is not designed for concurrent access from multiple threads.

Tip

Register data services, configuration, and licensing values via dependency injection at the appropriate scope. Only ListLabel itself should be instantiated per request.


Handling CPU-bound exports asynchronously

Report rendering is CPU-intensive. Wrapping the synchronous Export call in Task.Run frees the ASP.NET Core thread pool for accepting additional requests while rendering proceeds on a worker thread:

app.MapPost("/reports/generate", async (ReportRequest request, IDataService dataService) =>
{
    byte[] result = await Task.Run(() =>
    {
        using ListLabel listLabel = new ListLabel
        {
            LicensingInfo = "...",
            DataSource = dataService.GetDataSource(request.DataId),
            AutoProjectFile = request.ProjectFile
        };
        using MemoryStream output = new MemoryStream();
        ExportConfiguration config = new ExportConfiguration(
            LlExportTarget.Pdf,
            output,
            request.ProjectFile);
        listLabel.Export(config);
        return output.ToArray();
    });
    return Results.File(result, "application/pdf");
});
Note

ExportConfiguration accepts a Stream as the output target, which avoids writing temporary files to disk. This is preferred in containers where writable storage may be limited or ephemeral.


Concurrency and throughput

Because each request creates its own ListLabel instance and uses independent output streams, concurrent report generation requires no explicit locking. Throughput scales with:

  • Pod count: Kubernetes can run multiple replicas, each handling requests independently.
  • CPU allocation: Report rendering is CPU-bound. Allocate sufficient CPU resources per pod; under-allocation causes queuing and higher latencies.
  • Memory per request: Each render holds the report DOM, data, and rendered output in memory. A single complex PDF may use 100–400 MB under peak conditions. Size pods accordingly and account for concurrency level.
Scaling dimension Mechanism
More concurrent requests Increase pod replica count (Horizontal Pod Autoscaler)
Faster individual renders Increase CPU limits per pod
Larger data sets per report Increase memory limits per pod

Kubernetes Deployment configuration

A minimal Deployment for an LLCP-based API service:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: report-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: report-service
  template:
    metadata:
      labels:
        app: report-service
    spec:
      containers:
        - name: report-service
          image: myregistry/report-service:latest
          ports:
            - containerPort: 8080
          env:
            - name: LL_LICENSING_INFO
              valueFrom:
                secretKeyRef:
                  name: ll-license
                  key: licenseKey
          resources:
            requests:
              cpu: "1"
              memory: "512Mi"
            limits:
              cpu: "2"
              memory: "1Gi"
          livenessProbe:
            httpGet:
              path: /healthz/live
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 15
          readinessProbe:
            httpGet:
              path: /healthz/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
Tip

Always set both requests and limits for CPU and memory. Report rendering is bursty — without limits, a single pod can consume all node resources and destabilize other workloads.


Horizontal Pod Autoscaler

Use HPA to scale the number of pods based on CPU utilization:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: report-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: report-service
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
Tip

For queue-based workloads (e.g., background report batch jobs), consider scaling on custom metrics such as queue depth rather than CPU, using KEDA or a similar Kubernetes event-driven autoscaler.


Health checks

Implement liveness and readiness endpoints to integrate correctly with Kubernetes pod lifecycle management. ASP.NET Core example:

builder.Services.AddHealthChecks();
app.MapHealthChecks("/healthz/live");
app.MapHealthChecks("/healthz/ready");
Probe Purpose
livenessProbe Restarts the pod if the process is stuck or unresponsive
readinessProbe Removes the pod from the load balancer while it is warming up or overloaded
Tip

If your service loads fonts or report project files on startup, mark the pod as not ready until initialization completes. Use readinessProbe with an appropriate initialDelaySeconds value.


Managing fonts and report files

LLCP resolves fonts and project files from the container's file system. Fonts

  • Include required fonts in the container image or embed them into the project file (see the Docker setup guides).
  • Kubernetes ConfigMap volumes or init containers can inject additional fonts at pod startup.
  • All pods in the Deployment should use identical images to ensure consistent rendering output.

Report project files

Approach When to use
Embed in container image Small number of stable templates, rebuilt with application releases
Mount as ConfigMap or Secret Small templates that change independently of the application
Mount a shared volume (ReadOnlyMany) Large template libraries shared across many pods
Serve from object storage (S3, Azure Blob) Dynamic templates fetched per request
Note

JSON project files are read-only at runtime. Multiple pods can safely read from the same mounted volume simultaneously without coordination.


Secrets and licensing

Store the LLCP license key in a Kubernetes Secret rather than embedding it in the image or environment configuration files:

kubectl create secret generic ll-license --from-literal=licenseKey="your-license-key"

Inject it as an environment variable and read it at startup:

string licensingInfo = builder.Configuration["LL_LICENSING_INFO"]
    ?? throw new InvalidOperationException("LL_LICENSING_INFO is not configured.");
Warning

Do not set LicensingInfo to a hardcoded string in source code. Use environment variables or a secrets manager (Vault, AWS Secrets Manager, Azure Key Vault) and inject the value at runtime.


Data provider considerations

For containerized deployments, choose a data provider that connects to an external data store rather than a local file: Recommended approaches

  • Use connection-based providers (SqlConnectionDataProvider, MySqlConnectionDataProvider) pointing to an externally managed database service.
  • Do not use file-based providers (Access, SQLite) that rely on a writable local path, unless the path is backed by a persistent volume.
  • Open database connections per request. Most ADO.NET providers use connection pooling internally; the pool is maintained per process (pod), not shared across pods.
Tip

When using EF Core or Dapper as an intermediate layer, pass the IDbConnection or DbContext into the data provider for the duration of the request, then dispose with the request scope.


Graceful shutdown

Kubernetes sends SIGTERM before terminating a pod. Configure ASP.NET Core to stop accepting new requests and allow in-flight renders to complete:

builder.Services.Configure<HostOptions>(options =>
{
    // Allow up to 30 seconds for in-progress renders to finish.
    options.ShutdownTimeout = TimeSpan.FromSeconds(30);
});

Set a matching terminationGracePeriodSeconds in the pod spec (default is 30 seconds):

spec:
  terminationGracePeriodSeconds: 45
Note

Set terminationGracePeriodSeconds slightly higher than ShutdownTimeout so Kubernetes does not force-kill the process while the application is still completing renders.


Summary of key design rules

Rule Reason
Create one ListLabel per request The class is not thread-safe
Dispose ListLabel after each export Releases render resources promptly
Use Task.Run for export calls Keeps the HTTP thread pool responsive
Write output to MemoryStream or a temp path Avoids persistent disk I/O inside containers
Set Kubernetes resource requests and limits Prevents CPU and memory starvation under load
Store license key in a Kubernetes Secret Avoids exposing credentials in images or config files
Mount report files as read-only Enables safe sharing across pod replicas