L4 vs L7 load balancing, balancing algorithms, health checks and readiness, session affinity, graceful shutdown and draining, global load balancing, and long-lived HTTP/2 connection imbalance.
A load balancer spreads traffic across healthy instances.
Health checks remove unhealthy instances (in Kubernetes: readiness for traffic, liveness for restarts). Algorithms: round robin, least connections/requests, hashing. Sticky sessions pin a user to one instance, which hurts scaling and failover. Connection draining stops new traffic to an instance before shutdown so in-flight requests finish.
gRPC uses long-lived HTTP/2 connections, so an L4 balancer balances connections, not calls: one client connection carries all its calls to one pod. Use L7 balancing (mesh/Envoy) or client-side load balancing.
| Layer 4 | Layer 7 | |
|---|---|---|
| Sees | IPs, ports, TCP/UDP | Full HTTP: method, path, headers, cookies |
| Balances | Connections | Requests |
| TLS | Passes through (or terminates in some products) | Usually terminates |
| Features | Very fast, any protocol | Path/host routing, retries, header rewrite, WAF, auth offload |
| Examples | Azure LB, AWS NLB, kube-proxy Services | App Gateway, ALB, Front Door, nginx/Envoy ingress, YARP |
| Algorithm | Good for | Watch out for |
|---|---|---|
| Round robin | Similar requests and instances | Uneven request cost |
| Least connections / least requests | Variable request duration | Needs accurate counts |
| Power of two choices / EWMA | Large fleets, latency-aware | More complex |
| Hash (IP, header, consistent hashing) | Cache locality, stickiness | Hot keys, reshuffle on scale |
/health/ready): can this instance take traffic now? Checks critical dependencies lightly. Fail it during startup and shutdown./health/live): is the process stuck? Should not check dependencies, or a database outage restarts every pod.A .NET gRPC service had 6 pods behind a Kubernetes ClusterIP Service. One pod ran at 90% CPU while the rest idled. Clients created one GrpcChannel each, which opened one HTTP/2 connection, which kube-proxy (L4) assigned to one pod. All calls multiplexed over that connection. We switched to client-side load balancing in Grpc.Net.Client (headless Service + dns:/// address + round_robin policy), which keeps a connection per pod and balances calls. Load evened out across pods immediately. A service mesh with L7 HTTP/2 balancing would have worked too.
A system design question on a core building block of every scalable service.
Green flags
Red flags
DNS / anycast global LB (Front Door, Route 53, Traffic Manager)
│
┌──────────┴──────────┐
Region A Region B
L7 gateway/ingress L7 gateway/ingress
│ │
L4 Service (kube-proxy) L4 Service
┌────┼────┐ ┌────┼────┐
pod pod pod pod pod pod
L4, HTTP/1.1 with keep-alive:
client conn 1 ──► pod A (all requests on conn 1 → A)
client conn 2 ──► pod B
... many clients, many connections → roughly even
L4, HTTP/2 / gRPC:
client conn 1 (1,000 concurrent calls) ──► pod A ← hot
pod B, pod C idle
L7 HTTP/2 aware (Envoy, Linkerd, YARP):
client ──► proxy ── call 1 ──► A, call 2 ──► B, call 3 ──► C
The same imbalance appears after scale-out: new pods get no traffic until clients open new connections. PooledConnectionLifetime and gRPC client-side balancing help.
Affinity (cookie or source IP) pins users to instances. Costs: uneven load, lost sessions when an instance dies, slow scale-in. Prefer stateless instances with state in Redis or a database. Exceptions: SignalR with long polling or server-sent events across multiple servers needs stickiness (or Azure SignalR Service / a backplane).
Kubernetes deletes pod
├─ endpoint removed from Service (eventually consistent, takes a moment)
├─ preStop hook runs (e.g. sleep 5–10 s so LBs stop sending traffic)
└─ SIGTERM → .NET host stops accepting, finishes in-flight requests
(HostOptions.ShutdownTimeout, default 30 s)
terminationGracePeriodSeconds must exceed preStop + shutdown time
Without this, rolling deployments produce a burst of 502/503s.
dns:/// and round_robin; repeat.k6 or bombardier; count 5xx; then add it back.// gRPC client-side load balancing across pods (Grpc.Net.Client)
// Requires a headless Kubernetes Service (clusterIP: None) so DNS returns all pod IPs.
var channel = GrpcChannel.ForAddress("dns:///pricing-grpc.pricing.svc.cluster.local:8080", new GrpcChannelOptions
{
Credentials = ChannelCredentials.Insecure, // TLS terminated by mesh in this example
ServiceConfig = new ServiceConfig { LoadBalancingConfigs = { new RoundRobinConfig() } },
HttpHandler = new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true,
PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan,
KeepAlivePingDelay = TimeSpan.FromSeconds(60),
KeepAlivePingTimeout = TimeSpan.FromSeconds(30)
}
});
var client = new Pricing.PricingClient(channel);
// Separate readiness and liveness for load balancers and Kubernetes
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
.AddSqlServer(builder.Configuration.GetConnectionString("Orders")!, tags: ["ready"]); // AspNetCore.HealthChecks.SqlServer
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(25));
app.MapHealthChecks("/health/live", new() { Predicate = r => r.Tags.Contains("live") });
app.MapHealthChecks("/health/ready", new() { Predicate = r => r.Tags.Contains("ready") });
# Pod spec: drain before shutdown
spec:
terminationGracePeriodSeconds: 40
containers:
- name: orders-api
lifecycle:
preStop:
exec: { command: ["sleep", "10"] } # let endpoints and LBs stop routing first
readinessProbe: { httpGet: { path: /health/ready, port: 8080 }, periodSeconds: 5 }
livenessProbe: { httpGet: { path: /health/live, port: 8080 }, periodSeconds: 10 }
Mistake: Putting a gRPC service behind a plain Kubernetes ClusterIP Service and expecting even load.
Why it is wrong: kube-proxy balances at L4 per connection. A gRPC client opens one HTTP/2 connection and multiplexes every call over it, so one pod takes all of that client's traffic.
Instead: Use an L7 proxy or mesh that balances per request, or client-side round_robin with a headless Service, and set MaxConnectionAge on the server so connections are recycled.
Mistake: Scaling out and assuming the new pods share the load straight away. Why it is wrong: Existing long-lived connections (HTTP/2, WebSockets, database pools) stay pinned to the old pods; only new connections reach the new ones. Instead: Recycle connections periodically, balance per request at L7, and watch per-pod request rates after a scale event.
Mistake: Using the liveness probe to signal that a dependency is down. Why it is wrong: A failing liveness probe restarts the pod, so a database blip turns into a restart storm across every replica. Instead: Use readiness to leave rotation and keep liveness for "this process is stuck".
Mistake: Relying on sticky sessions to hold user state. Why it is wrong: When that instance dies or is drained, the session is lost, and stickiness skews load towards older instances. Instead: Keep state in a shared store (Redis, a database, the Data Protection key ring) and use affinity only where the protocol needs it, such as SignalR without the Azure SignalR Service or a backplane.
Mistake: Shutting down as soon as SIGTERM arrives.
Why it is wrong: The balancer may still route to the pod for a few seconds after the endpoint is removed, so requests fail with 502/503.
Instead: Add a short preStop sleep, then let the host drain within HostOptions.ShutdownTimeout.
L4 versus L7 at a glance
| Aspect | Layer 4 | Layer 7 |
|---|---|---|
| Unit balanced | TCP/UDP connection | Individual HTTP request |
| Sees | IPs and ports | Host, path, headers, cookies |
| TLS | Passed through | Usually terminated |
| Cost and latency | Very low | Higher (parsing, TLS) |
| HTTP/2 and gRPC | One connection pinned to one backend | Calls spread across backends |
| After scale-out | Only new connections reach new pods | New requests reach new pods immediately |
| Examples | Azure Load Balancer, AWS NLB, kube-proxy | Azure Application Gateway, AWS ALB, Envoy, YARP, NGINX |
When affinity is legitimate
| Case | Why stickiness is used | Better long-term option |
|---|---|---|
| SignalR with several servers and no backplane | The negotiate and connect requests must reach the same server | Azure SignalR Service or a Redis backplane |
| Legacy in-memory session state | State lives on one server | Distributed cache for session |
| Cache locality (consistent hashing) | Keeps a key's data warm on one node | Fine as is, but plan for rebalancing when nodes change |
A senior answer explains why gRPC imbalance happens (connection-level balancing plus multiplexing), not only the fix. It also treats draining, readiness and connection recycling as part of load balancing, because most real outages come from those edges rather than the algorithm.
CONNECT. HttpClient picks it up from system settings or HTTP_PROXY/HTTPS_PROXY/NO_PROXY.Behind a reverse proxy, the app sees the proxy's IP and often plain http. The proxy passes the originals in X-Forwarded-For and X-Forwarded-Proto, but ASP.NET Core only applies them via the ForwardedHeaders middleware, and by default only trusts proxies on loopback. So UseHttpsRedirection sees http, redirects to https, the proxy forwards as http again: a loop.
Fix: UseForwardedHeaders first, configured with the proxy's IPs or network in KnownProxies/known networks. Do not trust all sources, or clients can spoof their IP and scheme.
Layered from outside in:
Plus observability at every hop (flow logs, edge logs, traces with traceparent), and automated certificates and network rules as code.
HTTP is a request/response protocol: method + path + headers (+ body) in, status + headers (+ body) out.
GET/HEAD/OPTIONS are safe; GET, PUT, DELETE are idempotent (repeating has the same effect); POST and usually PATCH are not, so retries need an idempotency key.Content-Type, Accept, Authorization, Cache-Control, ETag/If-None-Match, Retry-After, traceparent.How to tell it (STAR)
**S:** Our [pricing gRPC service] had one pod at [90%] CPU while five idled, and scaling out did nothing. **T:** I had to spread the load before [a sale]. **A:** I realised the Kubernetes Service balances connections, and each client sent all its calls over one HTTP/2 connection to one pod. I moved to a headless Service with gRPC client-side round-robin so clients connect to every pod. **R:** Load spread evenly across pods within minutes, p99 dropped [60%], and autoscaling started working.
Last updated 23 Oct 2026.