← Back to Blog

Design Trade-offs in Hot-Reloading Load Balancer Weights

Building high-performance network services, such as load balancers or gateways, often involves solving the "hot reload" problem. Specifically, how do you dynamically adjust backend server weights without restarting the process, dropping active connections, or introducing lock contention?

There are two common architectural approaches:

  1. Centralized Push: A Control Plane actively pushes new configurations to the Data Plane via RPC.
  2. Local Observation: The Data Plane passively monitors local state changes (e.g., the file system).

This post demonstrates the second pattern—hot reloading via file monitoring and atomic swaps—and analyzes the engineering trade-offs behind it.

The Core Challenge: Zero-Blocking Reads

The primary mission of the Data Plane is traffic forwarding. In scenarios handling hundreds of thousands of requests per second, any form of locking (Mutex/RwLock) on the hot path can be a performance killer.

If every request forwarding thread had to acquire a Read Lock to check current weights, a single configuration update thread acquiring a Write Lock would stall all traffic. This results in unacceptable latency spikes.

Our design goal is strict: Configuration reads by Worker Threads must be wait-free or, at the very least, contention-free.

Demonstration (Rust)

To achieve this, we use the arc-swap pattern in Rust (conceptually similar to std::atomic<std::shared_ptr<T>> in C++ or RCU mechanisms).

The core logic involves:

  1. Hot Path: Worker threads hold a snapshot of the atomic pointer. Reading configuration is a single atomic load—extremely cheap.
  2. Cold Path: A background thread constructs the new configuration object. Once ready, it atomically "swaps" the global pointer. The old configuration is automatically deallocated once all references to it are dropped.

Here is a clean-room implementation:

use std::sync::Arc;
use std::thread;
use std::time::Duration;
use arc_swap::ArcSwap;

/// Configuration structure: holds weight and version
#[derive(Debug, Clone, Copy)]
struct WeightConfig {
    pub value: usize,
    pub version: u64,
}

/// State Watcher: holds an atomic reference to the global config
struct StateWatcher {
    // ArcSwap allows atomically replacing the content inside the Arc
    config: Arc<ArcSwap<WeightConfig>>,
}

impl StateWatcher {
    fn new(default_weight: usize) -> Self {
        let initial = WeightConfig { value: default_weight, version: 0 };
        Self {
            config: Arc::new(ArcSwap::from_pointee(initial)),
        }
    }

    /// Hot Path: Get a snapshot of the current config
    /// This is an extremely low-cost operation; no blocking, no waiting for writers
    fn get_current(&self) -> Arc<WeightConfig> {
        self.config.load().clone()
    }

    /// Cold Path: Background monitoring and updating
    fn start_monitor(&self) {
        let config_clone = Arc::clone(&self.config);
        
        thread::spawn(move || {
            let mut current_version = 0;
            loop {
                // Simulation: Polling file system or waiting for Inotify events
                // In production, this would watch a config file on disk
                thread::sleep(Duration::from_millis(500));
                
                // Simulation: A new config has been loaded
                current_version += 1;
                let new_weight = if current_version % 2 == 0 { 50 } else { 200 };
                
                println!("[Monitor] Weight changed -> {} (v{})", new_weight, current_version);
                
                // Key Point: Atomic Swap
                // The store operation is atomic. Readers see either the old value or the new value, never a partial state.
                config_clone.store(Arc::new(WeightConfig {
                    value: new_weight,
                    version: current_version,
                }));
            }
        });
    }
}

fn main() {
    let watcher = Arc::new(StateWatcher::new(100));
    
    // Start background update thread
    watcher.start_monitor();
    
    // Simulate concurrent worker threads
    let mut handles = vec![];
    for id in 1..=3 {
        let w = Arc::clone(&watcher);
        handles.push(thread::spawn(move || {
            for i in 1..=5 {
                // Fetch the latest config snapshot before processing each request
                let cfg = w.get_current();
                println!("  [Worker-{}] Processing req #{} | Weight: {} (v{})", 
                    id, i, cfg.value, cfg.version);
                
                // Simulate business logic processing time
                thread::sleep(Duration::from_millis(300));
            }
        }));
    }
    
    for h in handles {
        let _ = h.join();
    }
}

Deep Dive: Why Local File Monitoring?

This design highlights a distinct architectural choice: Local state synchronization based on files. Why not use a centralized configuration center pushing updates directly?

1. Fault Isolation

With centralized push (e.g., gRPC streams), if the Control Plane goes down or a network partition occurs, new instances of the Data Plane might fail to fetch their initial configuration upon startup. In contrast, a file-based design persists state to the local disk. Even if the Control Plane is completely unreachable, an Agent can write the config to disk, and the Data Plane process can restart and load the "Last Known Good" state. This significantly increases system resilience.

2. Decoupling and Simplicity

This pattern separates "distribution" from "activation."

  • Distribution: Can be handled by tools like Puppet, Ansible, a Sidecar, or Kubernetes ConfigMaps. They just need to update a file.
  • Activation: The application process only cares that the file changed. This separation keeps application logic minimal. It removes the need for complex RPC client libraries and simplifies testing (you can trigger an update manually with vim).

3. The Cost of Consistency

Of course, this comes with trade-offs.

  • Eventual Consistency: Processes on different machines might perceive the file change at slightly different times (depending on polling intervals or FS notification latency). For a brief window, traffic distribution across the cluster might be uneven.
  • I/O Overhead: While reading the config is a memory operation, the background thread must monitor the file system. Frequent polling on many files can generate I/O noise (though inotify/kqueue mitigates this).

Summary

In load balancer design, combining ArcSwap (RCU mechanics) with local file monitoring creates a robust hot-reload mechanism with zero impact on the data plane. We sacrifice millisecond-level global consistency for exceptional read performance and architectural robustness.

This is a classic engineering trade-off: in distributed systems, we often prefer slight state latency over lock contention on the critical path.