Skip to content

Scaling Patterns

Learning Objectives

  • Design horizontally scalable LLM serving architectures
  • Configure load balancing strategies optimized for LLM workloads
  • Implement auto-scaling based on relevant LLM metrics
  • Architect multi-region deployments for global availability and latency
  • Evaluate trade-offs between different scaling approaches

Introduction

Scaling LLM services presents unique challenges compared to traditional web applications. The compute-intensive nature of inference, stateful KV-cache, and variable request durations require specialized scaling strategies. This module covers patterns for scaling LLM services from hundreds to millions of requests per day.

Scaling Reality

A single A100 GPU can handle ~10-50 requests per second depending on the model and request complexity. Scaling to thousands of QPS requires careful architecture design, not just adding more GPUs.

Scaling Architecture Overview

Horizontal Scaling

Horizontal scaling adds more inference instances to handle increased load.

Stateless Serving Design

python
# stateless_serving.py - Stateless LLM serving for horizontal scaling
from dataclasses import dataclass
from typing import Optional, Dict, Any
import hashlib
import redis

@dataclass
class ServingConfig:
    """Configuration for stateless LLM serving."""
    model_name: str
    model_path: str
    tensor_parallel_size: int = 1
    max_batch_size: int = 32
    max_concurrent_requests: int = 100

    # External state stores (for stateless operation)
    redis_url: str = "redis://localhost:6379"
    s3_model_bucket: str = "models"

class StatelessLLMServer:
    """Stateless LLM server that can be horizontally scaled."""

    def __init__(self, config: ServingConfig):
        self.config = config
        self.redis = redis.from_url(config.redis_url)

        # Model loaded at startup, same across all replicas
        self.model = self._load_model()

    def _load_model(self):
        """Load model from shared storage."""
        from vllm import LLM

        return LLM(
            model=self.config.model_path,
            tensor_parallel_size=self.config.tensor_parallel_size,
            trust_remote_code=True,
        )

    def _get_conversation_context(
        self,
        conversation_id: str,
    ) -> Optional[list]:
        """Retrieve conversation context from Redis."""
        context = self.redis.get(f"conv:{conversation_id}")
        if context:
            import json
            return json.loads(context)
        return None

    def _store_conversation_context(
        self,
        conversation_id: str,
        messages: list,
        ttl_seconds: int = 3600,
    ):
        """Store conversation context in Redis."""
        import json
        self.redis.setex(
            f"conv:{conversation_id}",
            ttl_seconds,
            json.dumps(messages),
        )

    async def generate(
        self,
        request_id: str,
        prompt: str,
        conversation_id: Optional[str] = None,
        **kwargs,
    ) -> Dict[str, Any]:
        """Generate response (stateless operation)."""
        from vllm import SamplingParams

        # Build full context if conversation_id provided
        full_prompt = prompt
        if conversation_id:
            context = self._get_conversation_context(conversation_id)
            if context:
                # Reconstruct conversation
                full_prompt = self._build_conversation_prompt(context, prompt)

        # Generate response
        params = SamplingParams(**kwargs)
        outputs = self.model.generate([full_prompt], params)
        response = outputs[0].outputs[0].text

        # Store updated context if conversation
        if conversation_id:
            updated_context = (context or []) + [
                {"role": "user", "content": prompt},
                {"role": "assistant", "content": response},
            ]
            self._store_conversation_context(conversation_id, updated_context)

        return {
            "request_id": request_id,
            "response": response,
            "tokens": {
                "input": len(full_prompt.split()),
                "output": len(response.split()),
            },
        }

    def _build_conversation_prompt(
        self,
        context: list,
        new_message: str,
    ) -> str:
        """Build prompt from conversation history."""
        prompt_parts = []
        for msg in context:
            role = msg["role"]
            content = msg["content"]
            if role == "user":
                prompt_parts.append(f"User: {content}")
            else:
                prompt_parts.append(f"Assistant: {content}")

        prompt_parts.append(f"User: {new_message}")
        prompt_parts.append("Assistant:")

        return "\n".join(prompt_parts)

Kubernetes Deployment

yaml
# kubernetes/llm-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: llm-server
  labels:
    app: llm-server
spec:
  replicas: 3  # Start with 3 replicas
  selector:
    matchLabels:
      app: llm-server
  template:
    metadata:
      labels:
        app: llm-server
    spec:
      nodeSelector:
        accelerator: nvidia-a100
      containers:
        - name: vllm
          image: vllm/vllm-openai:latest
          resources:
            limits:
              nvidia.com/gpu: 4  # 4 GPUs per pod for tensor parallelism
              memory: 200Gi
            requests:
              nvidia.com/gpu: 4
              memory: 180Gi
          ports:
            - containerPort: 8000
          env:
            - name: MODEL_NAME
              value: "meta-llama/Llama-2-70b-chat-hf"
            - name: TENSOR_PARALLEL_SIZE
              value: "4"
            - name: GPU_MEMORY_UTILIZATION
              value: "0.9"
          args:
            - "--model"
            - "$(MODEL_NAME)"
            - "--tensor-parallel-size"
            - "$(TENSOR_PARALLEL_SIZE)"
            - "--gpu-memory-utilization"
            - "$(GPU_MEMORY_UTILIZATION)"
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 120  # Model loading time
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 180
            periodSeconds: 30
      tolerations:
        - key: "nvidia.com/gpu"
          operator: "Exists"
          effect: "NoSchedule"
---
apiVersion: v1
kind: Service
metadata:
  name: llm-server
spec:
  selector:
    app: llm-server
  ports:
    - port: 8000
      targetPort: 8000
  type: ClusterIP

Load Balancing

LLM workloads require specialized load balancing due to variable request durations.

LLM-Aware Load Balancer

python
# load_balancer.py - Custom load balancer for LLM workloads
from dataclasses import dataclass, field
from typing import List, Optional, Dict
import asyncio
import aiohttp
from collections import defaultdict
import time
import random

@dataclass
class BackendServer:
    """Represents an LLM inference backend."""
    url: str
    weight: float = 1.0
    max_concurrent: int = 100

    # Dynamic state
    active_requests: int = 0
    queue_depth: int = 0
    gpu_utilization: float = 0.0
    last_health_check: float = 0.0
    healthy: bool = True
    avg_latency_ms: float = 0.0

@dataclass
class LoadBalancerConfig:
    health_check_interval: float = 10.0
    unhealthy_threshold: int = 3
    healthy_threshold: int = 2
    request_timeout: float = 60.0

class LLMLoadBalancer:
    """Custom load balancer optimized for LLM workloads."""

    def __init__(
        self,
        backends: List[BackendServer],
        config: LoadBalancerConfig = LoadBalancerConfig(),
    ):
        self.backends = backends
        self.config = config
        self.request_counts = defaultdict(int)
        self._health_check_task = None

    async def start(self):
        """Start the load balancer."""
        self._health_check_task = asyncio.create_task(
            self._health_check_loop()
        )

    async def stop(self):
        """Stop the load balancer."""
        if self._health_check_task:
            self._health_check_task.cancel()

    def _calculate_score(self, backend: BackendServer) -> float:
        """Calculate load balancing score for a backend.

        Lower score = better candidate.
        """
        if not backend.healthy:
            return float('inf')

        # Factors:
        # 1. Active request ratio (0-1)
        active_ratio = backend.active_requests / backend.max_concurrent

        # 2. Queue depth impact
        queue_factor = backend.queue_depth / 100  # Normalize

        # 3. GPU utilization (prefer 70-80% sweet spot)
        gpu_factor = abs(backend.gpu_utilization - 0.75) / 0.25

        # 4. Weight (inverse - higher weight = lower score)
        weight_factor = 1.0 / backend.weight

        # Combined score
        score = (
            active_ratio * 0.4 +
            queue_factor * 0.3 +
            gpu_factor * 0.2 +
            weight_factor * 0.1
        )

        return score

    def select_backend(
        self,
        session_affinity_key: Optional[str] = None,
    ) -> Optional[BackendServer]:
        """Select the best backend for a request."""
        # Session affinity for conversations
        if session_affinity_key:
            # Hash to consistent backend
            hash_val = hash(session_affinity_key)
            healthy_backends = [b for b in self.backends if b.healthy]
            if healthy_backends:
                return healthy_backends[hash_val % len(healthy_backends)]

        # Score-based selection
        candidates = [
            (self._calculate_score(b), b)
            for b in self.backends
            if b.healthy and b.active_requests < b.max_concurrent
        ]

        if not candidates:
            return None

        # Select best with some randomization for fairness
        candidates.sort(key=lambda x: x[0])

        # Power of two choices: pick best of two random candidates
        if len(candidates) >= 2:
            sample = random.sample(candidates[:min(5, len(candidates))], 2)
            return min(sample, key=lambda x: x[0])[1]

        return candidates[0][1]

    async def forward_request(
        self,
        request_data: Dict,
        session_key: Optional[str] = None,
    ) -> Dict:
        """Forward request to selected backend."""
        backend = self.select_backend(session_key)

        if not backend:
            raise Exception("No healthy backends available")

        backend.active_requests += 1
        start_time = time.time()

        try:
            async with aiohttp.ClientSession() as session:
                async with session.post(
                    f"{backend.url}/generate",
                    json=request_data,
                    timeout=aiohttp.ClientTimeout(
                        total=self.config.request_timeout
                    ),
                ) as response:
                    result = await response.json()

                    # Update latency tracking
                    latency = (time.time() - start_time) * 1000
                    backend.avg_latency_ms = (
                        backend.avg_latency_ms * 0.9 + latency * 0.1
                    )

                    return result

        finally:
            backend.active_requests -= 1

    async def _health_check_loop(self):
        """Periodically check backend health."""
        consecutive_failures = defaultdict(int)
        consecutive_successes = defaultdict(int)

        while True:
            for backend in self.backends:
                try:
                    async with aiohttp.ClientSession() as session:
                        async with session.get(
                            f"{backend.url}/health",
                            timeout=aiohttp.ClientTimeout(total=5),
                        ) as response:
                            if response.status == 200:
                                health_data = await response.json()

                                # Update backend stats
                                backend.queue_depth = health_data.get(
                                    "queue_depth", 0
                                )
                                backend.gpu_utilization = health_data.get(
                                    "gpu_utilization", 0
                                )

                                consecutive_successes[backend.url] += 1
                                consecutive_failures[backend.url] = 0

                                if (
                                    not backend.healthy and
                                    consecutive_successes[backend.url] >=
                                    self.config.healthy_threshold
                                ):
                                    backend.healthy = True
                                    print(f"Backend {backend.url} is now healthy")
                            else:
                                raise Exception(f"Health check returned {response.status}")

                except Exception as e:
                    consecutive_failures[backend.url] += 1
                    consecutive_successes[backend.url] = 0

                    if (
                        backend.healthy and
                        consecutive_failures[backend.url] >=
                        self.config.unhealthy_threshold
                    ):
                        backend.healthy = False
                        print(f"Backend {backend.url} marked unhealthy: {e}")

                backend.last_health_check = time.time()

            await asyncio.sleep(self.config.health_check_interval)

    def get_stats(self) -> Dict:
        """Get load balancer statistics."""
        return {
            "backends": [
                {
                    "url": b.url,
                    "healthy": b.healthy,
                    "active_requests": b.active_requests,
                    "queue_depth": b.queue_depth,
                    "gpu_utilization": b.gpu_utilization,
                    "avg_latency_ms": round(b.avg_latency_ms, 2),
                }
                for b in self.backends
            ],
            "total_active": sum(b.active_requests for b in self.backends),
            "healthy_count": sum(1 for b in self.backends if b.healthy),
        }

Auto-Scaling

KEDA Scaling Configuration

yaml
# kubernetes/keda-scaler.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: llm-server-scaler
spec:
  scaleTargetRef:
    name: llm-server
  minReplicaCount: 2
  maxReplicaCount: 10
  pollingInterval: 15
  cooldownPeriod: 300  # 5 minutes cooldown
  triggers:
    # Scale based on Prometheus metrics
    - type: prometheus
      metadata:
        serverAddress: http://prometheus:9090
        metricName: llm_queue_depth
        threshold: "50"
        query: |
          sum(llm_queue_size) / count(llm_queue_size)

    - type: prometheus
      metadata:
        serverAddress: http://prometheus:9090
        metricName: llm_latency_p95
        threshold: "5"  # 5 seconds
        query: |
          histogram_quantile(0.95,
            sum(rate(llm_request_latency_seconds_bucket[5m])) by (le)
          )

    - type: prometheus
      metadata:
        serverAddress: http://prometheus:9090
        metricName: llm_gpu_utilization
        threshold: "85"
        query: |
          avg(llm_gpu_utilization_percent)
---
# Custom metrics API for HPA
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: llm-server-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: llm-server
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Pods
      pods:
        metric:
          name: llm_queue_depth
        target:
          type: AverageValue
          averageValue: "30"
    - type: Pods
      pods:
        metric:
          name: llm_gpu_utilization
        target:
          type: AverageValue
          averageValue: "75"
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
        - type: Pods
          value: 2
          periodSeconds: 60
        - type: Percent
          value: 50
          periodSeconds: 60
      selectPolicy: Max
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Pods
          value: 1
          periodSeconds: 120
      selectPolicy: Min

Custom Auto-Scaler

python
# autoscaler.py - Custom LLM-aware auto-scaler
from dataclasses import dataclass
from typing import Dict, Optional
import asyncio
from datetime import datetime, timedelta
from kubernetes import client, config

@dataclass
class ScalingPolicy:
    min_replicas: int = 2
    max_replicas: int = 10
    scale_up_threshold_queue: int = 50
    scale_up_threshold_latency_p95: float = 5.0  # seconds
    scale_up_threshold_gpu: float = 85.0
    scale_down_threshold_queue: int = 10
    scale_down_threshold_gpu: float = 40.0
    scale_up_cooldown: int = 60  # seconds
    scale_down_cooldown: int = 300  # seconds
    scale_up_step: int = 2
    scale_down_step: int = 1

class LLMAutoScaler:
    """Custom auto-scaler for LLM workloads."""

    def __init__(
        self,
        deployment_name: str,
        namespace: str,
        policy: ScalingPolicy,
        metrics_client,
    ):
        self.deployment_name = deployment_name
        self.namespace = namespace
        self.policy = policy
        self.metrics = metrics_client

        # Kubernetes client
        config.load_incluster_config()
        self.k8s_apps = client.AppsV1Api()

        # State
        self.last_scale_up = datetime.min
        self.last_scale_down = datetime.min
        self.scaling_history = []

    async def get_current_metrics(self) -> Dict:
        """Fetch current metrics from monitoring system."""
        return {
            "queue_depth": await self.metrics.get_avg_queue_depth(),
            "latency_p95": await self.metrics.get_latency_p95(),
            "gpu_utilization": await self.metrics.get_avg_gpu_utilization(),
            "request_rate": await self.metrics.get_request_rate(),
        }

    def get_current_replicas(self) -> int:
        """Get current replica count."""
        deployment = self.k8s_apps.read_namespaced_deployment(
            self.deployment_name,
            self.namespace,
        )
        return deployment.spec.replicas

    def scale_deployment(self, replicas: int):
        """Scale the deployment to specified replicas."""
        # Clamp to policy limits
        replicas = max(
            self.policy.min_replicas,
            min(self.policy.max_replicas, replicas)
        )

        deployment = self.k8s_apps.read_namespaced_deployment(
            self.deployment_name,
            self.namespace,
        )

        if deployment.spec.replicas == replicas:
            return

        deployment.spec.replicas = replicas
        self.k8s_apps.patch_namespaced_deployment(
            self.deployment_name,
            self.namespace,
            deployment,
        )

        self.scaling_history.append({
            "timestamp": datetime.now(),
            "action": "scale",
            "replicas": replicas,
        })

    def should_scale_up(self, metrics: Dict) -> bool:
        """Determine if we should scale up."""
        # Check cooldown
        if datetime.now() - self.last_scale_up < timedelta(
            seconds=self.policy.scale_up_cooldown
        ):
            return False

        # Check if at max
        if self.get_current_replicas() >= self.policy.max_replicas:
            return False

        # Scale up conditions (any of these triggers scale up)
        conditions = [
            metrics["queue_depth"] > self.policy.scale_up_threshold_queue,
            metrics["latency_p95"] > self.policy.scale_up_threshold_latency_p95,
            metrics["gpu_utilization"] > self.policy.scale_up_threshold_gpu,
        ]

        return any(conditions)

    def should_scale_down(self, metrics: Dict) -> bool:
        """Determine if we should scale down."""
        # Check cooldown
        if datetime.now() - self.last_scale_down < timedelta(
            seconds=self.policy.scale_down_cooldown
        ):
            return False

        # Check if at min
        if self.get_current_replicas() <= self.policy.min_replicas:
            return False

        # Scale down conditions (all must be true)
        conditions = [
            metrics["queue_depth"] < self.policy.scale_down_threshold_queue,
            metrics["gpu_utilization"] < self.policy.scale_down_threshold_gpu,
        ]

        return all(conditions)

    async def run_scaling_loop(self):
        """Main auto-scaling loop."""
        while True:
            try:
                metrics = await self.get_current_metrics()
                current_replicas = self.get_current_replicas()

                if self.should_scale_up(metrics):
                    new_replicas = current_replicas + self.policy.scale_up_step
                    print(f"Scaling up: {current_replicas} -> {new_replicas}")
                    print(f"Metrics: {metrics}")
                    self.scale_deployment(new_replicas)
                    self.last_scale_up = datetime.now()

                elif self.should_scale_down(metrics):
                    new_replicas = current_replicas - self.policy.scale_down_step
                    print(f"Scaling down: {current_replicas} -> {new_replicas}")
                    print(f"Metrics: {metrics}")
                    self.scale_deployment(new_replicas)
                    self.last_scale_down = datetime.now()

            except Exception as e:
                print(f"Scaling loop error: {e}")

            await asyncio.sleep(30)  # Check every 30 seconds

    def get_status(self) -> Dict:
        """Get auto-scaler status."""
        return {
            "current_replicas": self.get_current_replicas(),
            "min_replicas": self.policy.min_replicas,
            "max_replicas": self.policy.max_replicas,
            "last_scale_up": self.last_scale_up.isoformat(),
            "last_scale_down": self.last_scale_down.isoformat(),
            "recent_history": self.scaling_history[-10:],
        }

Multi-Region Architecture

Multi-Region Configuration

python
# multi_region.py - Multi-region LLM deployment management
from dataclasses import dataclass
from typing import List, Dict, Optional
from enum import Enum

class Region(Enum):
    US_EAST = "us-east-1"
    US_WEST = "us-west-2"
    EU_WEST = "eu-west-1"
    ASIA_PACIFIC = "ap-northeast-1"

@dataclass
class RegionConfig:
    region: Region
    endpoint: str
    gpu_capacity: int  # Total GPUs available
    min_replicas: int
    max_replicas: int
    weight: float = 1.0  # Traffic weight
    active: bool = True

@dataclass
class MultiRegionConfig:
    regions: List[RegionConfig]
    failover_threshold_latency_ms: float = 5000
    failover_threshold_error_rate: float = 0.05
    replication_lag_threshold_ms: float = 100

class MultiRegionManager:
    """Manage multi-region LLM deployment."""

    def __init__(self, config: MultiRegionConfig):
        self.config = config
        self.region_health: Dict[Region, Dict] = {}

    def get_routing_weights(self) -> Dict[Region, float]:
        """Calculate traffic routing weights based on health."""
        weights = {}
        total_weight = 0

        for region_config in self.config.regions:
            if not region_config.active:
                continue

            health = self.region_health.get(region_config.region, {})

            # Adjust weight based on health
            weight = region_config.weight

            # Reduce weight for high latency
            latency = health.get("latency_p95_ms", 0)
            if latency > self.config.failover_threshold_latency_ms:
                weight *= 0.1  # Drastically reduce

            # Reduce weight for high error rate
            error_rate = health.get("error_rate", 0)
            if error_rate > self.config.failover_threshold_error_rate:
                weight *= 0.1

            # Reduce weight if near capacity
            utilization = health.get("gpu_utilization", 0)
            if utilization > 90:
                weight *= 0.5

            weights[region_config.region] = weight
            total_weight += weight

        # Normalize
        if total_weight > 0:
            for region in weights:
                weights[region] /= total_weight

        return weights

    def select_region(
        self,
        user_region: Optional[Region] = None,
        session_key: Optional[str] = None,
    ) -> Region:
        """Select optimal region for a request."""
        weights = self.get_routing_weights()

        # Prefer user's local region if healthy
        if user_region and weights.get(user_region, 0) > 0.1:
            return user_region

        # Session affinity
        if session_key:
            # Consistent hash to region
            import hashlib
            hash_val = int(hashlib.md5(session_key.encode()).hexdigest(), 16)
            active_regions = [r for r, w in weights.items() if w > 0]
            if active_regions:
                return active_regions[hash_val % len(active_regions)]

        # Weighted random selection
        import random
        regions = list(weights.keys())
        region_weights = [weights[r] for r in regions]

        if sum(region_weights) > 0:
            return random.choices(regions, weights=region_weights)[0]

        raise Exception("No healthy regions available")

    def update_region_health(
        self,
        region: Region,
        metrics: Dict,
    ):
        """Update health metrics for a region."""
        self.region_health[region] = {
            "latency_p95_ms": metrics.get("latency_p95_ms", 0),
            "error_rate": metrics.get("error_rate", 0),
            "gpu_utilization": metrics.get("gpu_utilization", 0),
            "queue_depth": metrics.get("queue_depth", 0),
            "last_updated": metrics.get("timestamp"),
        }

    def get_global_status(self) -> Dict:
        """Get global deployment status."""
        return {
            "regions": [
                {
                    "region": rc.region.value,
                    "endpoint": rc.endpoint,
                    "active": rc.active,
                    "weight": self.get_routing_weights().get(rc.region, 0),
                    "health": self.region_health.get(rc.region, {}),
                }
                for rc in self.config.regions
            ],
            "total_gpu_capacity": sum(
                rc.gpu_capacity for rc in self.config.regions if rc.active
            ),
        }

# Terraform configuration example for multi-region
TERRAFORM_TEMPLATE = """
# Multi-region LLM deployment with Terraform

module "llm_us_east" {
  source = "./modules/llm-cluster"

  region           = "us-east-1"
  cluster_name     = "llm-us-east"
  node_count       = 4
  gpu_type         = "nvidia-a100"
  gpus_per_node    = 8
  model_name       = "llama-2-70b"

  providers = {
    aws = aws.us_east
  }
}

module "llm_eu_west" {
  source = "./modules/llm-cluster"

  region           = "eu-west-1"
  cluster_name     = "llm-eu-west"
  node_count       = 2
  gpu_type         = "nvidia-a100"
  gpus_per_node    = 8
  model_name       = "llama-2-70b"

  providers = {
    aws = aws.eu_west
  }
}

# Global load balancer
resource "aws_globalaccelerator_accelerator" "llm" {
  name            = "llm-global"
  ip_address_type = "IPV4"
  enabled         = true
}

resource "aws_globalaccelerator_listener" "llm" {
  accelerator_arn = aws_globalaccelerator_accelerator.llm.id
  protocol        = "TCP"

  port_range {
    from_port = 443
    to_port   = 443
  }
}

resource "aws_globalaccelerator_endpoint_group" "us_east" {
  listener_arn = aws_globalaccelerator_listener.llm.id
  endpoint_group_region = "us-east-1"
  traffic_dial_percentage = 50

  endpoint_configuration {
    endpoint_id = module.llm_us_east.nlb_arn
    weight      = 100
  }
}

resource "aws_globalaccelerator_endpoint_group" "eu_west" {
  listener_arn = aws_globalaccelerator_listener.llm.id
  endpoint_group_region = "eu-west-1"
  traffic_dial_percentage = 50

  endpoint_configuration {
    endpoint_id = module.llm_eu_west.nlb_arn
    weight      = 100
  }
}
"""

Interview Q&A

Question 1: How would you design a horizontally scalable LLM serving architecture?

Strong Answer: For horizontal scaling of LLM services, I'd design a stateless architecture with external state management:

Architecture:

Client -> Global LB -> Regional LB -> K8s Service -> LLM Pods (stateless)
                                           |
                         [Redis: sessions, cache] [S3: models]

Key design principles:

  1. Stateless Pods:

    • Model loaded at startup (same across all replicas)
    • Conversation state stored in Redis
    • KV-cache regenerated per request or shared via Redis
  2. Load Balancing:

    • L7 load balancer with session affinity option
    • Custom health checks including GPU utilization
    • Least-connections with queue depth awareness
  3. Auto-scaling:

    • Scale on queue depth (>50 pending requests)
    • Scale on P95 latency (>5 seconds)
    • Scale on GPU utilization (>85%)
    • Minimum 2 replicas for HA
  4. Resource management:

    • GPU node pools with taints/tolerations
    • Resource limits to prevent OOM
    • Pod disruption budgets for rolling updates

Challenges addressed:

  • Long-running requests (30-60s): Graceful shutdown with request draining
  • Cold start: Pre-warming and readiness probes with sufficient delay
  • Memory: Monitor KV-cache growth, implement eviction

Question 2: What load balancing strategy would you use for LLM workloads and why?

Strong Answer: For LLM workloads, I'd use a custom load balancing strategy that combines multiple signals:

Why not simple round-robin:

  • LLM requests have highly variable duration (100ms to 60s)
  • Round-robin can overload servers with multiple long requests
  • Doesn't account for GPU-specific constraints

My recommended approach - Weighted Least Pending:

Score = (active_requests / max_concurrent) * 0.4
      + (queue_depth / 100) * 0.3
      + |gpu_util - 0.75| * 0.2
      + (1 / weight) * 0.1

Why these factors:

  • Active requests ratio: Prevents overload
  • Queue depth: Indicates processing backlog
  • GPU utilization sweet spot (75%): Too low = waste, too high = memory pressure
  • Weight: Allows heterogeneous capacity (A100 vs H100)

Additional considerations:

  1. Session affinity: Use consistent hashing for conversation continuity
  2. Health-aware: Exclude unhealthy backends immediately
  3. Circuit breaker: Reduce traffic to high-error backends
  4. Connection pooling: Reduce TCP overhead for gRPC

Implementation:

  • Envoy/NGINX for L7 with custom Lua/WASM extensions
  • Or dedicated proxy service with Prometheus metrics integration

Question 3: Design an auto-scaling strategy for an LLM service with 10-100x traffic variation.

Strong Answer: For high traffic variation, I'd implement a multi-layer scaling strategy:

Layer 1: Predictive Scaling (Proactive)

  • Analyze historical traffic patterns
  • Pre-scale 15 minutes before predicted peaks
  • Use ML model trained on: time of day, day of week, events

Layer 2: Reactive Scaling (Real-time)

  • Primary metrics: Queue depth, P95 latency
  • Secondary: GPU utilization, error rate
  • Scale up aggressively: 2 pods at a time
  • Scale down conservatively: 1 pod with 5min cooldown

Layer 3: Emergency Scaling (Circuit Breaker)

  • If queue > 500: Trigger emergency scale (max replicas)
  • Enable request shedding for overflow
  • Activate serverless overflow (Modal/Lambda)

Configuration:

yaml
scaling:
  min_replicas: 3
  max_replicas: 50
  scale_up:
    trigger: queue_depth > 30 OR latency_p95 > 3s
    step: 2 pods
    cooldown: 60s
  scale_down:
    trigger: queue_depth < 10 AND gpu_util < 40%
    step: 1 pod
    cooldown: 300s

Cost optimization:

  • Base capacity on reserved instances (10 pods)
  • Burst capacity on spot/preemptible (40 pods)
  • Implement pod priority for graceful preemption

Monitoring:

  • Track scale events correlation with metrics
  • Alert on scaling failures
  • Dashboard showing scaling headroom

Summary

PatternUse CaseComplexityCost Impact
Horizontal ScalingGeneral scalingMediumLinear
Least Connections LBVariable latencyLowNone
Custom LBOptimal routingHighMinor
HPA/KEDAReactive scalingLow-MediumOptimized
Custom Auto-scalerFine-grained controlHighHighly optimized
Multi-RegionGlobal availabilityHighSignificant

Sources