Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part 46: .NET API + SignalR + Postgres for Acme

"SignalR works on Kubernetes if you set the right session affinity. Otherwise, the websockets break every time the load balancer round-robins."


Why

Acme is a .NET shop. Their stack:

  • ASP.NET Core minimal API for REST endpoints
  • SignalR hub for real-time updates (chat, notifications, dashboards)
  • CloudNativePG Postgres for persistent state
  • Redis as the SignalR backplane (so multiple instances can share connection state)
  • Blazor Server frontend served by the same ASP.NET process
  • Behind nginx-ingress with sticky sessions enabled

The thesis: Acme's stack runs on a k8s-multi HomeLab K8s instance. The interesting part is the ingress configuration — sticky sessions for SignalR — which is a frequent source of "works locally, breaks in cluster" bugs that HomeLab K8s catches.


The SignalR backplane

SignalR by default keeps connection state in process memory. For a single replica, this works. For two or more replicas behind a load balancer, you need a backplane — a shared store that lets the replicas exchange messages. The standard SignalR backplane on .NET is Redis.

// Acme's API Program.cs
builder.Services.AddSignalR()
    .AddStackExchangeRedis(builder.Configuration.GetConnectionString("Redis")!,
        options => { options.Configuration.ChannelPrefix = "acme"; });

The Redis instance is a Bitnami Helm chart in acme-prod:

[Injectable(ServiceLifetime.Singleton)]
public sealed class AcmeRedisHelmReleaseContributor : IHelmReleaseContributor
{
    public string TargetCluster => "acme";

    public void Contribute(KubernetesBundle bundle)
    {
        bundle.HelmReleases.Add(new HelmReleaseSpec
        {
            Name = "acme-redis",
            Namespace = "acme-data",
            Chart = "bitnami/redis",
            Version = "20.6.2",
            RepoUrl = "https://charts.bitnami.com/bitnami",
            Values = new()
            {
                ["architecture"] = "replication",
                ["auth"] = new Dictionary<string, object?>
                {
                    ["enabled"] = true,
                    ["existingSecret"] = "acme-redis-credentials",
                    ["existingSecretPasswordKey"] = "password"
                },
                ["master"] = new Dictionary<string, object?>
                {
                    ["persistence"] = new Dictionary<string, object?>
                    {
                        ["enabled"] = true,
                        ["size"] = "5Gi",
                        ["storageClass"] = "longhorn"
                    }
                },
                ["replica"] = new Dictionary<string, object?>
                {
                    ["replicaCount"] = 1
                },
                ["metrics"] = new Dictionary<string, object?>
                {
                    ["enabled"] = true,
                    ["serviceMonitor"] = new Dictionary<string, object?> { ["enabled"] = true }
                }
            }
        });
    }
}

The Redis Helm release exposes a Service that the Acme API references via the connection string.


Sticky sessions for SignalR

A SignalR client opens a websocket. The websocket needs to land on the same pod across reconnects, otherwise the connection state in the new pod's memory is empty and the client has to re-subscribe to everything. The fix is session affinity at the ingress layer.

For nginx-ingress, the annotation is nginx.ingress.kubernetes.io/session-cookie-name:

public void Contribute(KubernetesBundle bundle)
{
    bundle.Ingresses.Add(new IngressManifest
    {
        Name = "acme-api",
        Namespace = "acme-prod",
        IngressClassName = "nginx",
        Annotations = new()
        {
            ["cert-manager.io/cluster-issuer"] = "homelab-ca",
            // Sticky sessions for SignalR
            ["nginx.ingress.kubernetes.io/affinity"] = "cookie",
            ["nginx.ingress.kubernetes.io/session-cookie-name"] = "ACME_SIGNALR_AFFINITY",
            ["nginx.ingress.kubernetes.io/session-cookie-expires"] = "172800",
            ["nginx.ingress.kubernetes.io/session-cookie-max-age"] = "172800",
            // SignalR uses websockets, which need long timeouts
            ["nginx.ingress.kubernetes.io/proxy-read-timeout"] = "3600",
            ["nginx.ingress.kubernetes.io/proxy-send-timeout"] = "3600"
        },
        Rules = new[]
        {
            new IngressRule
            {
                Host = "api.acme.lab",
                Paths = new[] { new IngressPath { Path = "/", PathType = "Prefix", ServiceName = "acme-api", ServicePort = 80 } }
            }
        },
        Tls = new[] { new IngressTls { Hosts = new[] { "api.acme.lab" }, SecretName = "acme-api-tls" } }
    });
}

Without these annotations, the websocket disconnects every ~60 seconds when nginx round-robins. With them, the websocket sticks to one pod for the lifetime of the session cookie. This is the kind of bug that only shows up when you test in a multi-replica cluster — kind would not catch it because kind has one node and one ingress endpoint.


The Blazor Server piece

Blazor Server is a different beast: it maintains a SignalR-style connection from each browser to a server-side render process. The connection is not a SignalR hub but a "circuit", and it has the same affinity requirements. The same ingress annotations work.

The Blazor pages are served by the same ASP.NET process as the API. There is one Deployment, two replicas, one Service, one Ingress. Session affinity ensures both the API and the Blazor circuits stick to the same pod for a given user.


What this gives you that "kubectl apply" tutorials don't

A typical .NET-on-K8s tutorial gives you kubectl apply -f deployment.yaml. It does not show the sticky-session annotations, the Redis backplane, the long websocket timeouts, the Blazor circuit affinity. Those bugs surface in production only after the first multi-replica deploy and the first user complaint about lost notifications.

Running the full stack on HomeLab K8s gives you, for the same surface area:

  • Sticky sessions tested in dev (because the dev cluster has 3 worker nodes and 2 replicas, so round-robin happens)
  • Redis backplane tested in dev (because Redis is also running in the cluster)
  • Long websocket connections tested in dev (because the ingress timeout is the same as production)
  • The whole stack on the freelancer's laptop without depending on shared cloud infrastructure

The bargain pays back the first time you discover a SignalR affinity bug before it ships, not after.


⬇ Download