Frontend Labs
⋯

Day 48 — TCP Load Balancer (L4)

Learning Objectives

  • Assemble a complete L4 TCP load balancer from the parts built this week
  • Handle backend failure mid-connection gracefully
  • Understand the difference between L4 and L7 load balancing

Key Concepts

L4 vs L7 Load Balancing:
  L4 (TCP/UDP):
    - Decision at connection time (by IP:port or IP hash)
    - No HTTP parsing — passes bytes blindly
    - Faster (no decryption, no HTTP parsing)
    - Cannot route by URL path, host, or cookie
    - Used for: raw TCP, non-HTTP protocols, TLS passthrough

  L7 (HTTP):
    - Decision per-request (by URL, header, cookie)
    - Can terminate TLS, modify headers, compress, cache
    - Slightly slower (must parse HTTP)
    - Used for: APIs, web applications, content routing

  When to use L4:
    - Non-HTTP protocols (MySQL, Redis, gRPC with TLS passthrough)
    - Very high throughput (>1Gbps)
    - You need TLS passthrough (don't want the LB to see plaintext)

Exercise: Complete L4 TCP Load Balancer

package main

import (
    "fmt"
    "io"
    "net"
    "sync"
    "sync/atomic"
    "time"
)

type LB struct {
    listener  net.Listener
    backends  []*LBBackend
    balancer  Balancer
    active    atomic.Int64
    total     atomic.Int64
    rejected  atomic.Int64
    wg        sync.WaitGroup
}

type LBBackend struct {
    *BackendHealth
    *DrainableBackend
    pool *Pool
}

func NewLB(addr string, backends []string) (*LB, error) {
    ln, err := net.Listen("tcp", addr)
    if err != nil { return nil, err }

    lb := &LB{
        listener: ln,
        balancer: &LeastConnections{},
    }

    for _, bAddr := range backends {
        lb.backends = append(lb.backends, &LBBackend{
            BackendHealth:    &BackendHealth{addr: bAddr, state: Unknown},
            DrainableBackend: &DrainableBackend{Addr: bAddr},
            pool:             NewPool(20, 30*time.Second),
        })
    }

    return lb, nil
}

func (lb *LB) healthyBackends() []*Backend {
    var result []*Backend
    for _, b := range lb.backends {
        b.BackendHealth.mu.RLock()
        state := b.BackendHealth.state
        drainState := b.DrainableBackend.State()
        b.BackendHealth.mu.RUnlock()
        if state == Healthy && drainState == StateActive {
            result = append(result, &Backend{
                Addr:        b.Addr,
                ActiveConns: b.DrainableBackend.inFlight, // reuse counter
            })
        }
    }
    return result
}

func (lb *LB) Serve() {
    for {
        conn, err := lb.listener.Accept()
        if err != nil { return }

        // Pick a backend
        available := lb.healthyBackends()
        picked := lb.balancer.Pick(available)
        if picked == nil {
            conn.Close()
            lb.rejected.Add(1)
            continue
        }

        lb.active.Add(1)
        lb.total.Add(1)
        lb.wg.Add(1)
        go lb.proxy(conn, picked.Addr)
    }
}

func (lb *LB) proxy(client net.Conn, backendAddr string) {
    defer lb.wg.Done()
    defer lb.active.Add(-1)
    defer client.Close()

    upstream, err := net.DialTimeout("tcp", backendAddr, 5*time.Second)
    if err != nil {
        fmt.Printf("[LB] backend %s unavailable: %v\n", backendAddr, err)
        return
    }
    defer upstream.Close()

    // Set idle timeouts
    client.SetDeadline(time.Now().Add(5 * time.Minute))
    upstream.SetDeadline(time.Now().Add(5 * time.Minute))

    var once sync.Once
    closeAll := func() {
        once.Do(func() {
            client.Close()
            upstream.Close()
        })
    }

    var wg sync.WaitGroup
    wg.Add(2)

    // Bidirectional copy
    go func() {
        defer wg.Done()
        defer closeAll()
        io.Copy(upstream, client)
    }()
    go func() {
        defer wg.Done()
        defer closeAll()
        io.Copy(client, upstream)
    }()

    wg.Wait()
}

func main() {
    lb, err := NewLB(":8080", []string{
        "127.0.0.1:8081",
        "127.0.0.1:8082",
        "127.0.0.1:8083",
    })
    if err != nil { fmt.Println(err); return }

    fmt.Println("L4 Load Balancer on :8080")
    lb.Serve()
}

Real-World Context

This is the core of HAProxy in TCP mode, AWS NLB, and Cloudflare Spectrum. The bidirectional io.Copy with sync.Once close is a subtle pattern: when one side closes, you want to immediately close the other side (not leave it hanging). The sync.Once prevents double-close panics.


Generated from week7.md · 8 Oct 2026 16:36