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
ListLabelinstance 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
.jsonproject 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
ConfigMapvolumes 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 |