Pin each browser to the pod holding its state with nginx Ingress affinity: cookie and raise the proxy WebSocket timeouts — because Streamlit and Panel keep the map viewport, filters, and cached GeoDataFrames in per-pod memory, so a request routed to any other pod loses the session.

Jump to heading Why this matters

Streamlit and Panel are stateful servers wearing a web-app costume. When a user opens a dashboard, the framework builds a session object in the memory of one specific pod — the current map bounds, the selected CRS, the sidebar filter values, and often a cached multi-megabyte GeoDataFrame scoped to that session. None of that lives in a shared store by default. Put three replicas behind a plain Kubernetes Service and the round-robin load balancer sends the user’s next interaction to a pod that has never seen them: the map snaps back to its initial extent, or the WebSocket reconnect lands on the wrong pod and the dashboard hangs. Sticky sessions are therefore not an optimisation for these frameworks; they are a correctness requirement, and they interact tightly with the autoscaling described in Kubernetes autoscaling & orchestration and the state model in Session State Patterns.

Round-robin routing versus cookie affinity for a stateful dashboardTwo scenarios. Without affinity, a browser's first request lands on pod A which builds session state, but the second request is round-robined to pod B which has no state, so the map resets. With cookie affinity, the ingress issues a route cookie on the first request to pod A, and reads it on every later request to route the browser back to pod A, keeping its session state reachable.WITHOUT AFFINITY — STATE LOSTbrowserpod A · has statepod B · emptyreq 1 → builds statereq 2 → round-robin, map resetsWITH COOKIE AFFINITY — STATE PRESERVEDbrowsernginx IngressSet-Cookie: spatial_routepod A · has statepod B · emptyreq 1 + every later reqcookie routes browser back to pod A

Jump to heading Prerequisites

  • A dashboard deployed on Kubernetes with ingress-nginx terminating traffic, following Kubernetes autoscaling & orchestration.
  • Kubernetes 1.27+ and an ingress-nginx controller (cookie affinity and WebSocket upgrade support are built in).
  • A Streamlit (8501) or Panel (5006) Service already resolving to your pods.

Jump to heading Step-by-step solution

Cookie affinity is the correct primitive because it identifies each browser, not each source IP. On the first response the controller sets a route cookie; on every subsequent request it reads that cookie and forwards to the same pod. Add the annotations to the Ingress:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: spatial-dashboard
  annotations:
    nginx.ingress.kubernetes.io/affinity: "cookie"
    nginx.ingress.kubernetes.io/affinity-mode: "persistent"
    nginx.ingress.kubernetes.io/session-cookie-name: "spatial_route"
    nginx.ingress.kubernetes.io/session-cookie-max-age: "3600"
    nginx.ingress.kubernetes.io/session-cookie-samesite: "Lax"
spec:
  ingressClassName: nginx
  rules:
    - host: dashboard.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: spatial-dashboard
                port:
                  number: 80

affinity-mode: persistent keeps a user bound to their pod even when the backend endpoint list changes during a scale event — the default balanced mode may rebalance cookies when pods are added, which is exactly the reshuffle you want to avoid for stateful sessions. The max-age should comfortably exceed a typical working session.

Jump to heading Step 2 — Preserve the WebSocket upgrade

Sticky routing is useless if the long-lived WebSocket is cut. Streamlit and Panel push every interaction over a single WebSocket that stays open for the whole session, but nginx closes idle proxied connections after 60 seconds by default and will drop a socket that sits quiet while a user reads a map. Raise the timeouts:

yaml
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-http-version: "1.1"

ingress-nginx passes the Upgrade and Connection headers automatically once HTTP/1.1 is in use, so no manual header configuration is needed — but confirm no upstream proxy or cloud load balancer in front of the ingress strips them. A quick check from the browser devtools network tab: the /_stcore/stream request (Streamlit) or /ws request (Panel) should show status 101 Switching Protocols, not 200 or 400.

Jump to heading Step 3 — Weigh the Service ClientIP alternative

If you cannot terminate an HTTP-aware ingress — for example a bare LoadBalancer Service in front of the pods — Kubernetes offers a coarser fallback: sessionAffinity: ClientIP, which routes by source IP.

yaml
apiVersion: v1
kind: Service
metadata:
  name: spatial-dashboard
spec:
  selector:
    app: spatial-dashboard
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 10800     # 3h affinity window
  ports:
    - port: 80
      targetPort: 8501

Understand the trade-off before reaching for it. ClientIP keys on the source address the Service sees, so every user behind a shared corporate NAT, VPN egress, or cloud load balancer that hides the real client IP collapses onto a single pod, destroying load balancing exactly when you have many users. It also cannot distinguish two browser tabs from the same machine. Prefer cookie affinity whenever an ingress is in the path; treat ClientIP as a last resort for direct-Service topologies.

Jump to heading Step 4 — Reconcile affinity with scale-down

Affinity guarantees a user reaches their pod — it does nothing if the autoscaler deletes that pod. Close the gap with the graceful-termination trio so a pinned pod finishes its sessions before exiting:

yaml
    spec:
      terminationGracePeriodSeconds: 90
      containers:
        - name: dashboard
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 60"]

With this in place, when the HPA (see tuning HPA for bursty spatial dashboard sessions) scales down and selects a pod, the preStop sleep lets its bound WebSocket sessions wind down before SIGTERM arrives, and the HPA’s own scale-down stabilization window ensures the removal was warranted in the first place.

Jump to heading Verification

Confirm the route cookie is issued and honoured:

bash
# first request sets the affinity cookie
curl -s -I https://dashboard.example.com/ | grep -i set-cookie
# set-cookie: spatial_route=1a2b3c...; Path=/; HttpOnly; Max-Age=3600; SameSite=Lax

# reuse the cookie and confirm the same backend responds each time
COOKIE="spatial_route=1a2b3c..."
for i in 1 2 3; do
  curl -s -H "Cookie: $COOKIE" https://dashboard.example.com/_stcore/health -w "%{http_code} " -o /dev/null
done
# 200 200 200  — all served, session-consistent

Then verify the WebSocket upgrade end to end: open the dashboard, interact with a filter, wait two minutes idle (past nginx’s old 60-second default), and interact again. The map must respond without a reconnect flash, proving both the raised read timeout and the affinity are holding.

Jump to heading Edge cases and gotchas

  • ClientIP breaks behind shared NAT: a whole office behind one egress IP pins to a single pod under sessionAffinity: ClientIP, so nine of ten pods sit idle while one is overloaded. This is the primary reason to prefer cookie affinity — it identifies the browser, not the network.
  • Affinity versus scale-down: a sticky cookie will keep sending a user to a pod that the autoscaler is about to terminate. Without the preStop drain and grace period from Step 4, affinity actively concentrates soon-to-be-dropped traffic. Affinity and graceful termination must be configured together, never one without the other.
  • Affinity timeout expiry mid-session: if session-cookie-max-age or the ClientIP timeoutSeconds is shorter than a real working session, the binding lapses and the next request is rebalanced to an empty pod. Set the affinity window longer than your longest expected session (three hours is a safe ceiling for analyst dashboards) and treat state loss on timeout as a signal the window is too short.

Jump to heading FAQ

Why do Streamlit and Panel dashboards need sticky sessions on Kubernetes?

Streamlit and Panel keep each user’s session state — map viewport, filter selections, cached GeoDataFrames — in the memory of the specific pod that first served them, not in a shared store. Without affinity, the Service load-balances the next request to a different pod that has none of that state, so the map resets or the WebSocket fails to reconnect. Cookie affinity on the Ingress pins the browser to its pod so the state stays reachable for the whole session.

Should I use Ingress cookie affinity or Service sessionAffinity ClientIP?

Prefer Ingress cookie affinity. It identifies each browser individually with a cookie, so two users behind the same corporate NAT are still pinned to different pods and load balancing works. Service sessionAffinity: ClientIP keys on source IP, which collapses every user behind a shared NAT or egress load balancer onto one pod. Use ClientIP only when you cannot terminate an HTTP-aware ingress in front of the pods.

Does session affinity keep working during autoscaling scale-down?

Affinity keeps a user on their pod, but it cannot save a session if the autoscaler terminates that pod. Pair affinity with a preStop drain hook and a terminationGracePeriodSeconds long enough for open sessions to finish, plus an HPA scaleDown stabilization window. Together they ensure a pinned pod is only removed after its sessions wind down rather than mid-map.


Back to Kubernetes Autoscaling & Orchestration

Related