📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

How do load balancers work? Compare layer 4 and layer 7 load balancing, explain health checks, algorithms, sticky sessions and connection draining, and why gRPC traffic can end up on only one pod.

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.

Senior SystemDesign Advanced grpc health-checks high-availability kubernetes load-balancer networking

🎯 Key points

  • A load balancer spreads connections or requests across healthy backends and hides individual instances behind one address
  • Layer 4 balances TCP/UDP connections by IP and port; it never sees HTTP, so it is fast and protocol-agnostic but cannot route by path, header or cookie
  • Layer 7 terminates TLS and HTTP, so it can route by host, path or header, retry idempotent requests, and balance each request independently
  • HTTP/2 and gRPC multiplex many calls over one long-lived connection; an L4 balancer assigns that whole connection to one backend, so every call from that client lands on the same pod and new pods added by scale-out receive nothing
  • Fix gRPC imbalance with an L7 proxy that balances per request (Envoy, YARP, a service mesh) or with client-side round_robin over a headless Service, plus MaxConnectionAge so clients reconnect
  • Health checks matter: readiness removes a pod from rotation, liveness restarts it; drain with a preStop delay and HostOptions.ShutdownTimeout so in-flight requests finish
  • Algorithms include round robin, least connections, least outstanding requests and consistent hashing; sticky sessions trade even load for affinity
  • Global load balancing uses DNS-based routing or anycast in front of regional L4/L7 balancers

⏱ 30-second answer

A load balancer spreads traffic across healthy instances.

  • Layer 4 (TCP/UDP: Azure Load Balancer, AWS NLB, Kubernetes Services via kube-proxy) picks a backend per connection using IPs and ports. Fast, protocol-agnostic, no HTTP awareness.
  • Layer 7 (HTTP: App Gateway, ALB, Front Door, ingress controllers, Envoy, YARP) picks per request, can route by path/host/header, terminate TLS, retry and add headers.

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.

🗣 Full answer (senior)

L4 vs L7

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

Algorithms

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 checks

  • Readiness (/health/ready): can this instance take traffic now? Checks critical dependencies lightly. Fail it during startup and shutdown.
  • Liveness (/health/live): is the process stuck? Should not check dependencies, or a database outage restarts every pod.
  • Load balancers mark backends unhealthy after N failures and healthy after M successes.

Production example: gRPC on one 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.

🔍 What the interviewer is checking

A system design question on a core building block of every scalable service.

Green flags

  • Clear L4 vs L7 distinction and when to use each.
  • Knows algorithms and their trade-offs.
  • Separates readiness and liveness; explains graceful draining.
  • Explains HTTP/2/gRPC connection pinning and how to fix it.

Red flags

  • Relies on sticky sessions for state.
  • Liveness probe checks the database.
  • No idea why one pod gets all gRPC traffic.

📚 Learn in depth

1. Where load balancers sit

            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

2. Connection vs request balancing

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.

3. Sticky sessions

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).

4. Graceful shutdown and draining

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.

5. Global load balancing

  • DNS-based (Traffic Manager, Route 53): returns different IPs by health, latency or geography; failover is limited by TTLs and client caching.
  • Anycast/edge (Front Door, CloudFront, Cloudflare): one IP announced worldwide; the edge routes to the best healthy origin with fast failover.

6. Try it yourself

  1. Deploy 3 replicas of a gRPC service; call it from one client with one channel and watch per-pod CPU or request counts.
  2. Switch to a headless Service with dns:/// and round_robin; repeat.
  3. Remove the preStop hook and run a rolling update under load with k6 or bombardier; count 5xx; then add it back.

💻 Code

// 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 }

⚠ Common mistakes

  • 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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions L7 load balancers adding headers Why: Checks forwarded headers handling
    How to answer
    • A forward proxy sits in front of clients (corporate egress proxy): clients send requests through it to the internet. For HTTPS it tunnels with CONNECT. HttpClient picks it up from system settings or HTTP_PROXY/HTTPS_PROXY/NO_PROXY.
    • A reverse proxy sits in front of servers (nginx, Envoy, YARP, App Gateway, ingress): it terminates TLS, routes, load balances, caches and adds headers.

    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.

  2. When: Candidate mentions global load balancing Why: Moves to full network path design
    How to answer

    Layered from outside in:

    1. DNS with low TTL for the public name.
    2. Global edge / CDN + WAF (Front Door, CloudFront + AWS WAF, Cloudflare): anycast entry, TLS termination close to users, caching, DDoS and OWASP rules, rate limiting.
    3. Regional L7 gateway / ingress (App Gateway, ALB, ingress controller): routing, re-encrypted TLS, health probes; only accepts traffic from the edge.
    4. App tier in private subnets (AKS/ECS/App Service with VNet integration): no public IPs, NSGs/security groups and NetworkPolicies limit east-west traffic.
    5. Data tier via private endpoints (SQL, storage, Key Vault) with private DNS zones; public access disabled.
    6. Controlled egress through a NAT gateway or firewall with allow-lists.

    Plus observability at every hop (flow logs, edge logs, traces with traceparent), and automated certificates and network rules as code.

  3. When: Candidate is unclear on HTTP/2 multiplexing Why: Checks HTTP version knowledge
    How to answer

    HTTP is a request/response protocol: method + path + headers (+ body) in, status + headers (+ body) out.

    • Methods: 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.
    • Status codes: 2xx success, 3xx redirect/not modified, 4xx client error (400, 401 vs 403, 404, 409, 422, 429), 5xx server error (500, and 502/503/504 usually from proxies or gateways).
    • Headers: Content-Type, Accept, Authorization, Cache-Control, ETag/If-None-Match, Retry-After, traceparent.
    • Versions: HTTP/1.1 is text with one request at a time per connection; HTTP/2 is binary with multiplexed streams and header compression on one TCP connection; HTTP/3 does the same over QUIC/UDP, removing TCP head-of-line blocking.

🛠 Real-world problems: how you handle them

All gRPC traffic landing on one pod
Situation
[Pricing gRPC service] on .NET [10] with [6] pods behind a Kubernetes ClusterIP Service, called by [4] client services.
Signals
One pod at [90%] CPU and high latency, other pods near idle; HPA scaled out but new pods received no traffic.
Investigation
Per-pod request metrics showed traffic proportional to the number of client connections, not calls. Each client used one `GrpcChannel` (one HTTP/2 connection) assigned by kube-proxy to a single pod.
Resolution
Created a headless Service and switched clients to `dns:///` addresses with the round_robin policy, so each client connects to every pod and spreads calls.
Prevention
Platform guideline for gRPC (client-side LB or mesh), per-pod load dashboards with an imbalance alert, load test after scale-out.

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.

↩ Usually asked after

More in Networking Basics

Last updated 23 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me