DevOps · Deployment Strategies
Session Affinity, Sticky Routing, and Version Overlap
How affinity keeps a client on one instance, why it biases a canary sample, and what has to be true of the session payload and its keys for a pinned session to survive promotion.
A fleet of interchangeable instances is the premise of every zero-downtime deployment technique. Session affinity (pinning a client to the instance that first served it) is a direct contradiction of that premise, and it persists because moving session state out of the instances is work, and because affinity appears to be free.
Affinity is not free when versions change. It changes who sees the new version, it changes what happens to a user at promotion, and it imposes constraints on the session payload that are easy to miss until the payload is written by one version and read by another.
Where the pin is actually implemented
Affinity can be imposed at several layers, and the mechanism determines how strong it is.
The weakest form is layer-4 address affinity: the router hashes part of the client address and always assigns that hash to the same backend. nginx’s ip_hash in an upstream block is this form, and it is preserved across a configuration reload as long as the set of backends is unchanged. It breaks for clients behind carrier-grade NAT, which present a small number of addresses on behalf of many users, and for mobile clients whose address changes mid-session; both cases produce load concentration on one instance while others idle.
The common load-balancer form is cookie-based: the balancer inserts its own cookie in the first response and reads it from then on, directing the session to the instance named in the cookie. This survives address changes and is stable across a defined session duration. It requires the balancer to be in the response path (which it is for HTTP) and it fails open when the cookie is absent, which is the behaviour that makes a first request a fresh assignment.
The strongest form is application-level affinity through a signed cookie that carries the session identity, with the session itself stored elsewhere. In that design nothing is pinned: the cookie identifies a record in a shared store, and any instance can serve any request. That is what “stateless service” means in practice, and it is the only design in which deployment technique and session handling are genuinely independent.
Why affinity corrupts a canary measurement
When routing weights and session affinity are both active, they interact through the assignment order. A request without an existing pin is assigned by weight, so new sessions split in the intended ratio. A request with a pin bypasses the weight entirely and returns to the instance that served the earlier request. The canary therefore receives a share of new sessions, and the stable version receives all the existing ones — which can be the overwhelming majority of requests, because long sessions generate many requests each.
The visible symptom is a canary that receives a plausible fraction of overall requests but a very different composition. Established users with accumulated session state, long-lived shopping carts, and partially completed workflows stay on the old version, while first-time and returning-after-expiry users go to the new one. If the new version’s changes are exactly in the flows that experienced users exercise, the canary can pass while the change is broken for the population that matters. Any metric comparison between versions is then a comparison of two different populations, which is the classic confounder; the sample-size arithmetic in rollout analysis and decision rules assumes comparable groups and does not fix this.
The countermeasure is to make the pin a function of the version too — that is, to invalidate affinity at each rollout step so that requests are reassigned by the current weights. The cost is that every step moves users between versions, which is exactly what affinity is meant to prevent, so the choice is between a biased measurement and a disruptive rollout. Systems that have externalised their session state do not face the trade-off, because the affinity exists only to avoid re-fetching state, not to preserve it.
What a pinned user experiences at promotion
Affinity also determines what happens at the moment the old version is removed. If the pin points at an instance that no longer exists, the balancer falls back to a fresh assignment: the next request goes to the new version, and the user’s session is either re-established from a shared store or lost. If the pin is encoded in the balancer’s own cookie and the balancer is reconfigured rather than replaced, the cookie may still name a backend by index or by address, and a stale value can route to an instance that is terminating or already gone.
The failure mode is a user whose session state lived in the retired instance’s memory. Immediately after promotion they are authenticated in the browser’s view (a cookie is present) but unknown to the new instance, which either treats them as anonymous or rejects the request. The observable pattern is a burst of 401 or 403 responses, or of redirects to a login page, coinciding exactly with a cutover rather than with any code defect. That burst is invisible to per-instance health signals, since every instance is behaving correctly in isolation; only an authentication-level metric attributes it.
Deployments that keep a shared session store avoid the burst but inherit a different one: the store is now read by two versions simultaneously, which is a schema-compatibility problem on a different datastore. Adding a field to a serialised session object is safe in one direction; removing or renaming one makes the new version’s sessions unreadable by the old version, which matters if a rollback happens while sessions written by the new version are still live. Sessions written during the canary window outlive the canary, so a rollback must tolerate them — the general form of that constraint is in rollback boundaries and irreversibility.
Making the session value version-neutral
Where session state cannot be removed from the request path, it can at least be made additive. The rules that follow from the overlap are the same as for the database schema in expand and contract migration, applied to a key-value record.
Sessions must be read with the assumption that fields are missing, because sessions created before the deploy have only the old fields. Writers must preserve fields they do not understand instead of deserialising into a fixed struct and re-serialising, since a round trip through an older model silently discards the new version’s fields for every session it touches. Deleting a field from the model is a contract-phase operation and should follow the removal of the last version that wrote it, not precede it.
Where the session is stored in a cookie rather than on the server, the payload is exposed to size limits and to the client’s willingness to return it. Cookies are bounded in practice by client and proxy limits on header size, so only a small identifier or a compact signed blob fits; the signature also imposes a key-distribution requirement. Every instance that validates a signature must hold the same verification key, and if that key rotates, instances running the old version must still accept tokens signed with the old key during the overlap. TLS session tickets have the same property for a related reason: tickets issued under one key set cannot be resumed under another, so a fleet that changes ticket keys at cutover turns a healthy overlap into a burst of full handshakes and increases the CPU cost of the first seconds of the new fleet, as noted in blue-green cutover mechanics.
Affinity as a scoped exception
A workable compromise is to scope affinity to the requests that need it. Long polling, file uploads, and WebSocket-style connections genuinely benefit from a stable backend, and they can be routed by a rule that matches their paths, leaving the ordinary stateless request traffic free to be split by weight. The scoped rule keeps the deployment technique intact for the bulk of traffic, and it keeps the exceptions enumerable, which matters because each exception is a place where a version change can strand a user. The alternative, applying affinity globally because two endpoints need it, gives up the flexibility of the entire rollout for the benefit of a small fraction of requests.
References
- RFC 6265: HTTP State Management Mechanism — IETF
- nginx Documentation: ngx_http_upstream_module — nginx project
- Consistent Hashing and Random Trees: Distributed Caching Protocols for Relieving Hot Spots on the World Wide Web — Karger, Lehman, Leighton, Panigrahy, Levine, Lewin — STOC 1997
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3 — IETF