DEV Community

Load Balancing — Traffic Distribution

Traffic distribution: vì sao thuật toán chỉ là phần dễ, health check mới quyết định service có sập hay không

Load balancer (LB) đứng trước một pool backend và quyết định request kế tiếp sẽ đi tới instance nào. Về lý thuyết nó chỉ là một router có thuật toán chọn; trong thực tế nó là điểm sống chết của service, vì lỗi phổ biến nhất không phải "thuật toán phân tải chưa tối ưu" mà là LB tiếp tục route traffic vào một backend đã chết — process crash, port còn mở, hoặc app trả 500 mà LB không biết. Kết quả là một phần request (1/N nếu round-robin) đều 5xx trong khi dashboard vẫn xanh vì "backend còn listen". Bài này nói về cơ chế phân tải và cái quan trọng hơn: health check phải sâu tới đâu, và LB tự nó redundant thế nào.

Cơ chế hoạt động

Có hai tầng LB phổ biến. L4 (TCP/UDP) chỉ nhìn 5-tuple (src IP, src port, dst IP, dst port, protocol) và forward gói — nhanh, protocol-agnostic, nhưng không biết đây là HTTP GET hay POST. Ví dụ: AWS NLB, HAProxy ở mode tcp, IPVS, LVS. L7 (HTTP/gRPC) parse request, biết path/header/method, có thể route theo Host, rewrite path, retry idempotent request, terminate TLS — chi phí là thêm latency và CPU per request. Ví dụ: nginx, HAProxy ở mode http, Envoy, AWS ALB.

Thuật toán chọn backend, chọn theo workload:

  • round-robin — luân phiên. Đơn giản, tốt khi request đồng đều về chi phí.
  • weighted round-robin — mỗi backend một weight; instance to hơn nhận nhiều hơn.
  • least-connections — chọn backend đang có ít active connection nhất. Tốt khi request có thời gian xử lý khác nhau nhiều (long-polling, streaming, upload).
  • IP hash / consistent hash — hash một key (client IP, session ID, cache key) rồi map sang backend. Bắt buộc khi cần session affinity hoặc cache locality. Consistent hashing (Ketama, Maglev) hạn chế reshuffle khi thêm/bớt node — chỉ khoảng 1/N key phải remap thay vì gần hết.
  • power-of-two-choices / least-request — chọn ngẫu nhiên 2 backend, lấy cái ít load hơn. Envoy dùng mặc định vì tránh được "herding" của least-connections mà không cần global state.

Một config nginx L7 điển hình, có health check và timeout — không phải toy example:

upstream api_backend {
    least_conn;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
    server 10.0.1.12:8080 max_fails=3 fail_timeout=10s;
    server 10.0.1.13:8080 max_fails=3 fail_timeout=10s backup;
    keepalive 64;
}

server {
    listen 443 ssl http2;
    location / {
        proxy_pass http://api_backend;
        proxy_next_upstream error timeout http_502 http_503 http_504;
        proxy_next_upstream_tries 2;
        proxy_connect_timeout 2s;
        proxy_read_timeout    10s;
        proxy_http_version    1.1;
        proxy_set_header      Connection "";
    }
}
Enter fullscreen mode Exit fullscreen mode

Đây là passive health check (nginx OSS): sau max_fails lỗi trong fail_timeout, backend bị đánh dấu down và bỏ khỏi pool trong fail_timeout giây. HAProxy hoặc nginx Plus có active health check — LB tự chủ động gõ một endpoint (option httpchk GET /healthz trong HAProxy) theo interval, không cần chờ traffic thật để phát hiện chết.

Health check phải phân biệt hai mức: liveness (process còn chạy không) và readiness (process có phục vụ được không — DB reachable, cache warm, migration xong). Route traffic dựa trên readiness. Kubernetes phân tách rõ hai loại; nếu LB chỉ ping TCP port thì đó là liveness giả — port mở không có nghĩa là app hoạt động.

Vấn đề gặp trong production

Failure mode 1: không có health check, hoặc chỉ check TCP. LB thấy port 8080 accept SYN nên coi backend healthy, trong khi app đã deadlock hoặc mất kết nối DB. Cứ 1/N request rơi vào node đó là 500. Client retry thì retry qua cùng LB, có thể lại rơi vào cùng backend nếu sticky session. Đây là kịch bản "route traffic tới backend chết" — LB phân tải thành công 100%, nhưng vào cả xác.

Sai:

upstream api {
    server 10.0.1.11:8080;   # không max_fails, không active check
    server 10.0.1.12:8080;   # backend crash -> nginx vẫn gửi
}
Enter fullscreen mode Exit fullscreen mode

Đúng: bật passive check tối thiểu (như block ở trên), và ở tầng app expose /healthz kiểm tra dependency thật:

// Express readiness endpoint — trả 200 CHỈ khi thực sự phục vụ được
app.get('/healthz', async (req, res) => {
  try {
    await db.query('SELECT 1')                // DB pool alive?
    await redis.ping()                         // cache reachable?
    if (!migrationsDone) throw new Error('warming up')
    res.status(200).send('ok')
  } catch (e) {
    res.status(503).send('not ready')          // LB sẽ pull khỏi pool
  }
})
Enter fullscreen mode Exit fullscreen mode

Rồi cấu hình LB gõ endpoint này (HAProxy option httpchk GET /healthz với http-check expect status 200, hoặc ALB target group health check path /healthz). Kết quả: node mất DB tự động drain khỏi pool trong vài giây thay vì cả đêm.

Failure mode 2: health check quá nông hoặc quá sâu. Nông (chỉ TCP, hoặc /healthz trả 200 bất kể) route vào backend chết như trên. Sâu quá — /healthz check cả chain DB + Redis + downstream service — thì một service downstream flap sẽ làm toàn bộ backend đồng loạt bị mark down, LB không còn node nào healthy, cluster tự sập dù backend vẫn chạy. Đây là "correlated failure": health check gộp dependency chung. Rule thực tế: readiness chỉ check dependency critical trực tiếp, không transitive. Nếu Redis chỉ dùng cache-aside có thể fallback, đừng đưa vào readiness.

Failure mode 3: LB không biết backend restart chậm (slow start). Container mới join pool nhận full traffic ngay khi health check pass, nhưng JVM chưa warm, JIT chưa optimize, cache chưa fill — node mới trả latency cao, error nhiều, có thể oscillate down/up. HAProxy có slowstart để ramp weight từ 0 lên full trong khoảng thời gian cấu hình; nginx Plus và Envoy có tương đương. Không dùng slow-start — mỗi deploy có một đợt lag spike.

Failure mode 4: sticky session + auto-scaling. Bật ip_hash hoặc cookie-based affinity để giữ session ở một backend, rồi scale-out. Node mới join nhưng không nhận traffic mới (hash key đã map hết vào node cũ), trong khi node cũ oversubscribed. Khi scale-in, một loạt session mất. Fix: đưa session state ra ngoài (Redis, DB) để LB tự do rebalance; nếu bắt buộc affinity vì cache locality, dùng consistent hashing thay vì modulo, và chấp nhận phải rehash một phần khi topology đổi.

Failure mode 5: LB là SPOF. Một instance HAProxy/nginx bare-metal đứng trước 20 backend — nếu nó chết, mất tất. Cách xử lý thực tế: hai LB active-passive với keepalived/VRRP chia sẻ virtual IP, hoặc active-active sau một anycast IP / cloud LB (ELB, GCLB) là managed và tự HA qua nhiều AZ. Đừng chỉ redundant backend mà quên chính lớp balancer.

Failure mode 6: retry storm khuếch đại lỗi. LB set proxy_next_upstream error timeout để failover request khi backend lỗi. Nếu tất cả backend đang overloaded và trả 503, LB retry sang backend khác cũng 503, cuối cùng mỗi request gốc thành N request nội bộ — traffic tự nhân N, backend càng chết nhanh. Cách hạn: giới hạn proxy_next_upstream_tries, chỉ retry trên lỗi idempotent (không retry POST), và kết hợp với circuit breaker ở tầng app (Envoy có outlier detection tự eject node lỗi liên tục ra khỏi pool).

Cách debug và monitor

Triệu chứng "LB đang route vào xác": error rate 5xx tăng nhưng chỉ ở tỷ lệ 1/N (N = số backend), latency phân bố bimodal — phần lớn request nhanh, một cụm timeout đúng bằng proxy_read_timeout. Nếu nhìn tổng thể có thể trông như "spike nhẹ", nhưng breakdown per-upstream sẽ lộ ra một backend gánh 100% lỗi.

Metric bắt buộc, đo theo per-backend (không phải chỉ tổng):

  • Backend health status — up/down state. HAProxy phơi qua stats socket (echo "show stat" | socat stdio /var/run/haproxy.sock) và trang stats HTTP; Envoy phơi qua /clusters admin endpoint và Prometheus envoy_cluster_membership_healthy; ALB/NLB có CloudWatch metric HealthyHostCountUnHealthyHostCount per target group.
  • Request distribution — request per second per backend. Skew lớn (một node nhận gấp mấy lần các node khác dù cùng weight) là dấu hiệu sticky-session bám cứng, hoặc một node bị mark down mà không ai để ý.
  • Per-upstream latency và error rate — nginx log $upstream_addr, $upstream_status, $upstream_response_time; HAProxy có %si (server IP) và %Tr (response time) trong log format. Không có phần upstream_* trong log thì gần như không debug được LB.
  • Active connections per backend — với least-connections cần metric này để verify LB đang thấy đúng con số; nếu LB nghĩ node A có nhiều conn trong khi thực tế app A idle, có thể là connection leak trong LB hoặc client giữ keepalive không đóng.
  • Health check success rate — flap rate cao (node lên xuống liên tục) tệ hơn hẳn "down thẳng"; thường do check interval quá ngắn hoặc threshold quá nhạy.

Tool để đọc trực tiếp: HAProxy stats page hoặc haproxy_exporter cho Prometheus; nginx stub_status module + nginx-prometheus-exporter; Envoy admin /stats/prometheus; AWS Console — target group — Targets tab để xem lý do unhealthy (thường là "Health checks failed with these codes: [502]"); kubectl describe endpoints <svc> để xem endpoint controller đã eject pod chưa; kubectl get endpointslices cho cluster mới hơn.

Rule phòng ngừa: (1) mọi backend phải expose /healthz khác / — không dùng root làm health check, vì root có thể được cache/rewrite ngoài ý muốn; (2) alert khi bất kỳ backend nào ở trạng thái down quá vài phút; (3) alert khi distribution skew (max/min RPS per backend) vượt ngưỡng trong pool được cho là đồng nhất; (4) trong staging phải kill -9 một backend và verify LB drain đúng — không chỉ đọc config.

Tradeoff

L4 cho throughput cao và latency thấp (không parse payload, không terminate TLS), đổi lại mất khả năng route theo path/header, không retry được idempotent, khó observe. L7 cho routing thông minh, retry, TLS termination, WAF, đổi lại thêm chi phí CPU và một chút latency per request. Chọn theo protocol: gRPC/HTTP nên L7 để hưởng retry và load-aware routing; MySQL/Postgres/Kafka/Redis nên L4 vì L7 không hiểu protocol nội bộ. Health check nông (TCP) rẻ nhưng không phát hiện app dead-but-listening; health check sâu (đầy đủ dependency) bắt được nhiều lỗi hơn nhưng dễ tạo correlated failure khi một dependency phụ flap. Round-robin đơn giản, không cần state; least-connections xử lý tốt workload có phương sai, đổi lại phải track connection count (LB cần thấy chính xác, khó khi có nhiều LB instance không share state). Consistent hashing tối ưu cache locality, đổi lại phân bố không đều bằng round-robin. LB tự nó phân tải và HA cho backend, nhưng chính nó là chokepoint — phải HA bằng redundant LB + VRRP/anycast, nếu không thì chỉ đang dời SPOF từ backend sang LB.

Rule of thumb: L7 cho HTTP-facing, L4 cho protocol khác; least-connections (hoặc power-of-two-choices) mặc định trừ khi có lý do cụ thể chọn khác; active health check trên /readyz chỉ chứa dependency critical trực tiếp; luôn có ít nhất 2 LB instance chia virtual IP hoặc dùng managed LB.

Câu hỏi phỏng vấn

Thiết kế load balancer cho một API service 3 instance. Nên dùng thuật toán nào, health check ra sao, và làm sao để chính LB không thành single point of failure?

Chọn L7 vì đây là HTTP API — cần retry idempotent request, terminate TLS, và log per-upstream để debug. Thuật toán mặc định least-connections (hoặc power-of-two-choices nếu dùng Envoy) vì request API thường có phương sai thời gian xử lý; round-robin chỉ tối ưu khi request đồng đều. Health check phải là active, gõ /readyz theo interval vài giây, threshold vài lần fail liên tiếp trước khi mark down và vài lần pass liên tiếp trước khi mark up (tránh flap). /readyz chỉ trả 200 khi app thật sự phục vụ được — kiểm DB connection pool và cache reachable, nhưng không transitive tới các downstream service không critical, tránh correlated failure kéo cả pool xuống khi một dependency phụ flap. Cấu hình slow-start để node mới ramp weight từ 0 tránh cold-start lag, giới hạn next_upstream_tries và chỉ retry lỗi idempotent để không tạo retry storm. Chính LB phải redundant: hai instance HAProxy/nginx active-passive share virtual IP qua keepalived/VRRP, hoặc dùng cloud managed LB (ALB, GCLB) tự HA qua nhiều AZ. Điểm ăn điểm là chỉ ra hai failure mode production thật: (1) health check quá nông nghĩa là LB vẫn route vào backend đã crash-nhưng-port-mở, biểu hiện là 1/N request 5xx dù dashboard tổng thể nhìn "gần OK"; (2) LB tự nó là chokepoint, redundant backend mà không redundant LB thì chỉ dời SPOF một tầng — phải giải quyết cả hai mới coi là HA.

Hands-on

Dựng một pool 3 backend + 1 HAProxy trên Docker, giết một backend, quan sát LB drain, rồi so sánh với config không có health check để thấy failure mode "route vào xác".

Tạo backend Node.js đơn giản có /healthz điều khiển được:

mkdir -p lb-lab && cd lb-lab
cat > server.js <<'JS'
const http = require('http');
const id = process.env.ID || 'x';
let healthy = true;
http.createServer((req, res) => {
  if (req.url === '/healthz') {
    res.writeHead(healthy ? 200 : 503).end(healthy ? 'ok' : 'down');
  } else if (req.url === '/kill') {
    healthy = false; res.end('marked unhealthy');
  } else if (req.url === '/revive') {
    healthy = true; res.end('back');
  } else {
    res.end('hello from ' + id + '\n');
  }
}).listen(8080, () => console.log('backend ' + id + ' on 8080'));
JS

cat > Dockerfile <<'DOCKER'
FROM node:20-alpine
WORKDIR /app
COPY server.js .
CMD ["node", "server.js"]
DOCKER

docker build -t lb-backend .
Enter fullscreen mode Exit fullscreen mode

Tạo HAProxy config có active health check:

cat > haproxy.cfg <<'CFG'
global
    log stdout format raw local0
defaults
    mode http
    timeout connect 2s
    timeout client  10s
    timeout server  10s
    option httplog
frontend fe
    bind *:80
    default_backend be
backend be
    balance leastconn
    option httpchk GET /healthz
    http-check expect status 200
    default-server inter 2s fall 2 rise 2 slowstart 10s
    server b1 b1:8080 check
    server b2 b2:8080 check
    server b3 b3:8080 check
frontend stats
    bind *:8404
    stats enable
    stats uri /
CFG
Enter fullscreen mode Exit fullscreen mode

Chạy pool:

docker network create lbnet
docker run -d --name b1 --network lbnet -e ID=b1 lb-backend
docker run -d --name b2 --network lbnet -e ID=b2 lb-backend
docker run -d --name b3 --network lbnet -e ID=b3 lb-backend
docker run -d --name lb --network lbnet -p 8080:80 -p 8404:8404 \
  -v "$PWD/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro" \
  haproxy:2.8
Enter fullscreen mode Exit fullscreen mode

Bắn traffic đều và xem phân phối:

for i in $(seq 1 30); do curl -s localhost:8080/; done | sort | uniq -c
# kỳ vọng: xấp xỉ 10 request mỗi backend
Enter fullscreen mode Exit fullscreen mode

Mở stats page ở http://localhost:8404/ — xem cột Status (UP/DOWN), LastChk, Sessions.

Giờ ép một backend "chết ở tầng app" (port vẫn mở, /healthz trả 503):

docker exec b2 wget -qO- http://localhost:8080/kill
# đợi vài giây cho HAProxy fail 2 check liên tiếp
for i in $(seq 1 30); do curl -s localhost:8080/; done | sort | uniq -c
# kỳ vọng: b2 biến mất, b1 và b3 chia đôi traffic; không có 5xx
Enter fullscreen mode Exit fullscreen mode

So sánh với version không có health check: sửa haproxy.cfg bỏ option httpchk, http-check expect, và check ở cuối mỗi dòng server, rồi docker restart lb. Kill lại b2 và bắn traffic:

docker exec b2 wget -qO- http://localhost:8080/kill
for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/; done | sort | uniq -c
# kỳ vọng: ~1/3 request trả 503 (rơi vào b2), 2/3 trả 200
Enter fullscreen mode Exit fullscreen mode

Đây là chính kịch bản "route traffic tới backend chết" — LB phân tải thành công, nhưng vào cả xác. Bật lại health check và verify curl không còn thấy 503 nào.

Cuối cùng, dựng LB HA để thấy tại sao phải redundant chính LB: chạy hai container HAProxy giống nhau (lb1, lb2), đặt một tầng phía trước (một nginx tối giản round-robin giữa lb1:80lb2:80, hoặc trên máy thật là keepalived share virtual IP). Kill lb1, verify traffic vẫn qua lb2. Đó là mô hình tối thiểu để LB không thành SPOF; production nên dùng managed LB (ALB/NLB/GCLB) để không phải tự vận hành lớp này.

Top comments (0)