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.