Build CoreOrdered learning track

Round Robin, Least Connections, IP Hash, and Generic Hash

Learn NGINX In Action - Part 047

Deep dive into NGINX HTTP load balancing algorithms: round robin, least connections, IP hash, generic hash, and the operational consequences of choosing each method.

12 min read2250 words
PrevNext
Lesson 47105 lesson track20–57 Build Core
#nginx#load-balancing#reverse-proxy#upstream+1 more

Part 047 — Round Robin, Least Connections, IP Hash, and Generic Hash

Load balancing algorithm adalah policy pemilihan upstream.

Ia bukan health check. Ia bukan autoscaler. Ia bukan circuit breaker. Ia bukan jaminan fairness bisnis. Ia hanya menjawab satu pertanyaan kecil tetapi sangat kritis:

Untuk request ini, server upstream mana yang dipilih?

Di production, pertanyaan kecil ini punya konsekuensi besar:

  • apakah long request menumpuk di satu backend,
  • apakah session state pecah,
  • apakah tenant besar membuat cache shard panas,
  • apakah backend baru langsung dibanjiri traffic,
  • apakah retry menggandakan write,
  • apakah satu node lambat menyebabkan tail latency seluruh cluster naik.

Top 1% engineer tidak memilih algoritma load balancing karena “umumnya pakai ini”. Ia memilih berdasarkan state model, latency distribution, connection lifetime, key distribution, retry policy, dan failure recovery behavior.

Dokumentasi resmi NGINX menyebut round-robin sebagai default weighted method; least_conn memilih server dengan active connection paling sedikit dengan mempertimbangkan weight; ip_hash memakai client IP sebagai key; dan hash memakai key buatan user, dengan opsi consistent untuk mengurangi remapping ketika upstream berubah.


1. Mental Model: Load Balancer sebagai Function

Secara konseptual, sebuah NGINX upstream bisa dilihat seperti function:

selected_server = f(request, upstream_state, algorithm_config)

Komponen inputnya:

request:
  uri
  method
  headers
  cookies
  remote address
  variables

upstream_state:
  list of servers
  weight
  active connections
  failed/unavailable status
  keepalive state
  worker-local/shared state

algorithm_config:
  round-robin | least_conn | ip_hash | hash key [consistent]

Yang sering salah dipahami: load balancer tidak selalu punya pengetahuan penuh tentang “load” aktual aplikasi.

NGINX bisa tahu beberapa sinyal lokal:

  • berapa active upstream connection,
  • apakah connect/read/send gagal,
  • apakah server sedang ditandai failed secara passive,
  • weight tiap server,
  • key hash tertentu.

Tetapi NGINX tidak otomatis tahu:

  • CPU backend,
  • GC pause JVM,
  • queue di thread pool aplikasi,
  • saturation database downstream,
  • tenant mana yang membuat query mahal,
  • logical operation mana yang idempotent,
  • memory pressure di runtime backend.

Karena itu, algoritma load balancing harus diperlakukan sebagai approximation, bukan oracle.


2. Upstream Group Anatomy

Basic upstream:

upstream app_backend {
    server 10.0.10.11:8080;
    server 10.0.10.12:8080;
    server 10.0.10.13:8080;
}

server {
    listen 443 ssl;
    server_name api.example.com;

    location / {
        proxy_pass http://app_backend;
    }
}

Tanpa directive algoritma eksplisit, NGINX memakai weighted round-robin.

Dengan parameter server:

upstream app_backend {
    server 10.0.10.11:8080 weight=5 max_fails=3 fail_timeout=10s;
    server 10.0.10.12:8080 weight=3 max_fails=3 fail_timeout=10s;
    server 10.0.10.13:8080 weight=2 max_fails=3 fail_timeout=10s;
}

Beberapa parameter yang akan sering muncul:

ParameterFungsiHal yang sering disalahpahami
weightRasio preferensi pemilihan serverBukan limit traffic absolut.
max_failsJumlah failure dalam window fail_timeout untuk menandai server unavailableFailure dihitung berdasarkan kondisi yang dianggap gagal oleh *_next_upstream.
fail_timeoutWindow failure sekaligus durasi server dianggap unavailablePassive health check hanya terjadi dari traffic nyata.
backupServer dipakai saat primary unavailableTidak bisa dipakai bersama beberapa metode seperti hash, ip_hash, dan random.
downServer dianggap permanently unavailableBerguna untuk menjaga hash ring/slot ketika remove sementara.

3. Algorithm 1 — Round Robin

Round robin adalah default.

upstream app_backend {
    server 10.0.10.11:8080;
    server 10.0.10.12:8080;
    server 10.0.10.13:8080;
}

Dengan weight:

upstream app_backend {
    server 10.0.10.11:8080 weight=3;
    server 10.0.10.12:8080 weight=1;
    server 10.0.10.13:8080 weight=1;
}

Secara mental:

weighted sequence = [A, A, A, B, C]
request 1 -> A
request 2 -> A
request 3 -> A
request 4 -> B
request 5 -> C
request 6 -> A
...

Ini bukan implementasi internal persis yang perlu kamu hafal. Yang penting adalah efeknya: server dengan weight lebih besar akan menerima proporsi request lebih besar.

Kapan round robin cocok?

Gunakan round robin ketika:

  • aplikasi stateless,
  • semua instance relatif homogen,
  • request duration relatif seragam,
  • tidak butuh client affinity,
  • backend tidak punya per-client state,
  • traffic cukup besar sehingga distribusi rasio menjadi stabil.

Contoh cocok:

GET /products
GET /public/articles
GET /health-data/reference
POST /auth/token  # jika backend stateless dan dependency seragam

Kapan round robin buruk?

Round robin buruk ketika request duration sangat bervariasi.

Bayangkan 3 backend:

A: menangani request 30 detik
B: menangani request 50 ms
C: menangani request 50 ms

Round robin tetap mengirim request baru ke A berdasarkan urutan/weight, walaupun A sedang sibuk dengan request panjang. Ia tidak tahu bahwa A sedang lambat kecuali failure/timeout terjadi.

Akibatnya:

  • long request bisa membuat satu backend punya concurrency jauh lebih besar,
  • tail latency naik,
  • backend tertentu mengalami queue buildup,
  • sistem tampak “balanced by count” tapi tidak “balanced by work”.

Trap: request count fairness bukan load fairness

Dua server menerima jumlah request sama:

A: 100 requests x 10 ms  = 1 second work
B: 100 requests x 2 sec  = 200 seconds work

Secara request count terlihat adil. Secara work tidak adil.

Karena itu, round robin aman hanya jika request cost relatif homogen atau sistem punya mekanisme lain yang mencegah cost skew.


4. Algorithm 2 — Least Connections

least_conn memilih server dengan active connection paling sedikit, sambil tetap mempertimbangkan weight.

upstream app_backend {
    least_conn;

    server 10.0.10.11:8080;
    server 10.0.10.12:8080;
    server 10.0.10.13:8080;
}

Dengan weight:

upstream app_backend {
    least_conn;

    server 10.0.10.11:8080 weight=4;
    server 10.0.10.12:8080 weight=2;
    server 10.0.10.13:8080 weight=1;
}

Mental model:

choose server with smallest active_connection_count adjusted by weight

Kapan least_conn cocok?

Gunakan least_conn ketika:

  • request duration bervariasi,
  • ada long polling,
  • ada WebSocket-like upstream connection pattern,
  • ada SSE endpoint,
  • ada endpoint report/export yang lama,
  • backend cost tidak seragam tapi active connection cukup merepresentasikan work.

Contoh:

upstream report_backend {
    least_conn;

    server 10.0.20.11:8080;
    server 10.0.20.12:8080;
    server 10.0.20.13:8080;
}

server {
    location /reports/export/ {
        proxy_pass http://report_backend;
        proxy_read_timeout 300s;
    }
}

Kenapa least_conn sering lebih masuk akal untuk real apps?

Real apps jarang punya request cost seragam.

GET /profile          20 ms
GET /orders           80 ms
GET /orders/search    500 ms
POST /reports/export  30 s
GET /stream           minutes

Round robin hanya melihat urutan. Least connections setidaknya melihat server mana yang masih sibuk.

Trap: active connection bukan CPU load

least_conn juga approximation.

Satu active connection bisa murah:

GET /static-metadata -> waiting cache -> 3 ms CPU

Satu active connection bisa mahal:

POST /rules/evaluate -> CPU-heavy decision engine -> 2 sec CPU

Atau sebaliknya, active connection lama bisa murah karena sedang idle menunggu event.

Jadi least_conn baik ketika connection lifetime berkorelasi cukup baik dengan work. Ia buruk ketika work berat terjadi cepat tetapi CPU/DB pressure tidak terlihat dari jumlah koneksi.

Trap: long-lived connection bisa membuat server tampak sibuk selamanya

Untuk WebSocket/SSE, least_conn sering lebih baik dari round robin. Tetapi jika connection long-lived sangat tidak merata antar tenant, server yang kebetulan menampung tenant aktif bisa terus terlihat sibuk dan request baru dialihkan ke server lain.

Ini bisa bagus atau buruk.

Bagus karena melindungi server yang sudah banyak connection. Buruk jika long-lived connection itu idle dan tidak mengonsumsi banyak resource.

Decision-nya harus berdasarkan resource dominan:

DominanSinyal active connection berguna?
Memory per connectionYa, cukup berguna.
File descriptorYa.
CPU per messageKadang.
DB query per requestLemah.
Tenant-specific burstLemah tanpa route isolation.

5. Algorithm 3 — IP Hash

ip_hash memilih upstream berdasarkan IP address client.

upstream app_backend {
    ip_hash;

    server 10.0.10.11:8080;
    server 10.0.10.12:8080;
    server 10.0.10.13:8080;
}

Tujuannya: client yang sama cenderung masuk ke server yang sama.

Ini sering disebut “sticky by IP”.

Kapan ip_hash cocok?

Gunakan hanya jika:

  • aplikasi masih punya server-side session lokal,
  • kamu butuh affinity sederhana,
  • client IP cukup stabil dan cukup tersebar,
  • kamu menerima risiko skew akibat NAT/proxy,
  • kamu belum punya session store eksternal.

Contoh legacy:

PHP session file lokal per node
old Java servlet session in-memory
stateful websocket gateway without shared session registry

Kenapa ip_hash berbahaya di internet modern?

Karena “client IP” tidak selalu berarti “user”.

Satu IP bisa mewakili ribuan user:

enterprise NAT
mobile carrier NAT
campus network
public Wi-Fi
corporate proxy
CDN/proxy layer jika real IP salah dikonfigurasi

Akibatnya satu upstream bisa menjadi hotspot.

Trap: ip_hash tidak otomatis memakai X-Forwarded-For

Jika NGINX berada di belakang CDN/load balancer, $remote_addr bisa berisi IP proxy, bukan IP user asli.

Sebelum memakai IP-based routing, trust boundary harus benar:

set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

Tetapi ini hanya aman jika header berasal dari hop yang dipercaya. Kalau kamu menerima X-Forwarded-For langsung dari internet, user bisa memalsukan routing identity.

Menghapus server pada ip_hash

Jika server perlu dikeluarkan sementara, jangan langsung hapus line-nya jika ingin menjaga mapping sebanyak mungkin.

Gunakan down:

upstream app_backend {
    ip_hash;

    server 10.0.10.11:8080;
    server 10.0.10.12:8080 down;
    server 10.0.10.13:8080;
}

Dengan begitu posisi server tetap dipertahankan dalam konfigurasi, dan mapping tidak berubah total karena list server berubah.

Better alternative

Untuk sistem modern, lebih baik hilangkan kebutuhan sticky session:

session state -> Redis/database/session service
file upload state -> object storage
workflow state -> durable database
websocket routing -> shared registry / message broker

Kalau tetap butuh affinity, generic hash dengan key yang lebih eksplisit biasanya lebih defensible daripada client IP.


6. Algorithm 4 — Generic Hash

hash memilih upstream berdasarkan key yang kamu tentukan.

upstream app_backend {
    hash $request_uri consistent;

    server 10.0.10.11:8080;
    server 10.0.10.12:8080;
    server 10.0.10.13:8080;
}

Key bisa berupa variable NGINX:

hash $cookie_session_id consistent;
hash $arg_tenant_id consistent;
hash $http_x_tenant_id consistent;
hash $request_uri consistent;
hash "$scheme|$host|$request_uri" consistent;

Kapan generic hash cocok?

Gunakan generic hash ketika kamu ingin mempertahankan affinity berdasarkan domain-specific key:

Use caseKey candidate
Tenant sharding$http_x_tenant_id setelah divalidasi
Cache backend$request_uri atau normalized cache key
User affinitystable session cookie
Document processingdocument id
Stateful collaborationroom id / workspace id
Consistent routing for expensive warm cachenormalized resource key

consistent penting untuk perubahan cluster

Tanpa consistent hashing, menambah/menghapus server bisa meremap banyak key.

Dengan consistent, remapping dikurangi. Ini sangat penting untuk:

  • cache servers,
  • warm local memory,
  • tenant affinity,
  • stateful-but-recoverable workloads.

Tetapi “consistent” bukan berarti zero movement. Ia hanya mengurangi movement.

Trap: hash key harus trustworthy dan bounded

Jangan hash langsung pada header arbitrary dari user tanpa validasi.

Buruk:

upstream tenant_backend {
    hash $http_x_tenant_id consistent;
    server 10.0.10.11:8080;
    server 10.0.10.12:8080;
}

Kalau header bisa dipalsukan, user bisa memengaruhi routing. Dalam sistem multi-tenant, ini bisa menjadi policy bypass atau load attack.

Lebih aman:

map $http_x_tenant_id $tenant_route_key {
    default "anonymous";
    "tenant-a" "tenant-a";
    "tenant-b" "tenant-b";
    "tenant-c" "tenant-c";
}

upstream tenant_backend {
    hash $tenant_route_key consistent;
    server 10.0.10.11:8080;
    server 10.0.10.12:8080;
    server 10.0.10.13:8080;
}

Untuk environment serius, validasi tenant identity sebaiknya terjadi di auth layer, bukan hanya whitelist statis.

Trap: key skew membuat hotspot

Generic hash tidak otomatis membuat load rata. Ia membuat key tertentu masuk ke server tertentu.

Jika satu tenant menyumbang 70% traffic:

tenant-a -> server 1 -> 70% traffic
tenant-b -> server 2 -> 10% traffic
tenant-c -> server 3 -> 10% traffic
tenant-d -> server 2 -> 5% traffic
tenant-e -> server 3 -> 5% traffic

Secara hash benar. Secara kapasitas buruk.

Solusi:

  • pecah heavy tenant ke dedicated upstream,
  • gunakan sub-key seperti tenant + bucket,
  • pindahkan state ke external store,
  • gunakan rate limiting per tenant,
  • gunakan route-specific load balancing,
  • jangan memakai affinity jika tidak benar-benar perlu.

7. Catatan Versi: Least Time

Walaupun judul part ini fokus pada round robin, least connections, IP hash, dan generic hash, kamu perlu tahu least_time.

Current NGINX documentation mencantumkan least_time sebagai load balancing method. Di upstream module docs, least_time memilih server dengan average response time terendah dan active connections paling sedikit, dengan parameter seperti header atau last_byte. Dokumentasi juga mencatat bahwa sebelum versi 1.31.0 directive ini commercial-only.

Konsekuensi praktis:

Jika fleet kamu campur versi lama dan baru, jangan anggap least_time tersedia di semua node.

Gunakan nginx -V dan nginx -t di CI/CD untuk membuktikan capability, bukan asumsi.

Contoh:

upstream app_backend {
    least_time header;

    server 10.0.10.11:8080;
    server 10.0.10.12:8080;
    server 10.0.10.13:8080;
}

Kapan dipertimbangkan:

  • backend homogen tetapi latency real berbeda,
  • upstream punya intermittent slowness,
  • kamu punya versi NGINX yang mendukungnya,
  • kamu mengerti bahwa average response time adalah sinyal historis dan bisa terlambat merespons perubahan mendadak.

Untuk seri ini, kita tetap menempatkan least_conn sebagai default advanced choice karena lebih umum, lebih mudah dipahami, dan lebih stabil secara operasional lintas versi.


8. Choosing Algorithm by System Shape

System shapeRecommended starting pointReason
Stateless CRUD API, uniform latencyRound robinSimple, predictable.
Heterogeneous instance capacityWeighted round robin or weighted least_connRespect measured capacity.
Variable request durationLeast connectionsAvoid piling new work onto busy nodes.
Long polling/SSE/WebSocket-ishLeast connectionsActive connection count matters.
Legacy server-local sessionIP hash, temporarySimple affinity, but fragile.
Tenant/resource affinityGeneric hash with validated keyDomain-specific stickiness.
Cache shard backendGeneric hash consistentMinimize remapping and cache miss storm.
Multiple independent load balancersConsider hash/random-two/architecture-specific strategyEach LB may not see full global load.
Strict correctness write APIAlgorithm is secondary; idempotency/retry policy is primaryAvoid duplicate write.

9. Route-Specific Upstream Is Better Than One Global Algorithm

A common production mistake is using one upstream group for all endpoints.

upstream app_backend {
    least_conn;
    server 10.0.10.11:8080;
    server 10.0.10.12:8080;
}

server {
    location / {
        proxy_pass http://app_backend;
    }
}

It works, but it hides workload differences.

Better:

upstream app_default_backend {
    server 10.0.10.11:8080;
    server 10.0.10.12:8080;
}

upstream app_stream_backend {
    least_conn;
    server 10.0.20.11:8080;
    server 10.0.20.12:8080;
}

upstream app_report_backend {
    least_conn;
    server 10.0.30.11:8080 max_fails=2 fail_timeout=10s;
    server 10.0.30.12:8080 max_fails=2 fail_timeout=10s;
}

server {
    location /api/stream/ {
        proxy_pass http://app_stream_backend;
        proxy_read_timeout 1h;
    }

    location /api/reports/export/ {
        proxy_pass http://app_report_backend;
        proxy_read_timeout 300s;
    }

    location / {
        proxy_pass http://app_default_backend;
    }
}

Ini membuat algoritma sesuai dengan workload.


10. Worker State and Shared View

NGINX punya multiple worker. Beberapa state upstream bisa worker-local kecuali kamu memakai shared memory zone untuk upstream state tertentu.

Pattern production:

upstream app_backend {
    zone app_backend_zone 64k;

    least_conn;
    server 10.0.10.11:8080 max_conns=200;
    server 10.0.10.12:8080 max_conns=200;
}

Kenapa ini penting?

Tanpa shared state, worker bisa punya pandangan berbeda tentang counters/failure/limits tertentu. Untuk capacity controls seperti max_conns, dokumentasi upstream module memperingatkan bahwa limit dapat bekerja per worker jika server group tidak berada di shared memory.

Gunakan zone ketika kamu mengandalkan upstream state lintas worker.


11. Algorithm Failure Modes

Round robin failure modes

FailureCauseMitigation
Slow backend tetap dapat request baruTidak melihat active workleast_conn, split route, timeout.
Count fair tapi CPU unfairCost per request skewRoute isolation, app-level queue, rate limit.
New backend cold langsung dapat trafficTidak ada slow start di OSS baselineWeighted ramp, canary upstream, Plus slow_start jika tersedia.

Least_conn failure modes

FailureCauseMitigation
Active conn tidak merepresentasikan CPUWork cepat tapi CPU-heavyRoute isolation, metrics, app concurrency limit.
Idle long connection membuat node tampak sibukWebSocket/SSE idleSeparate pool, capacity model by memory/FD.
Bursty retry pindah ke node “kosong”Node kosong mungkin coldRetry budget, warmup, rate limiting.

IP hash failure modes

FailureCauseMitigation
NAT hotspotBanyak user di satu IPHindari IP affinity, gunakan session store.
Proxy IP dianggap clientReal IP salahset_real_ip_from, trust boundary.
Mapping berubah saat server dihapusServer list berubahGunakan down saat remove sementara.

Generic hash failure modes

FailureCauseMitigation
Hot tenantKey distribution skewDedicated pool, bucketed key, per-tenant rate limit.
User memalsukan keyHeader/cookie tidak trustedAuth-derived key, whitelist, signed token.
Cache miss storm saat resizeKey remapconsistent, gradual migration.

12. Observability: Prove the Algorithm Works

Minimal log fields:

log_format upstream_lb_json escape=json
'{'
  '"time":"$time_iso8601",'
  '"request_id":"$request_id",'
  '"host":"$host",'
  '"uri":"$request_uri",'
  '"status":$status,'
  '"upstream_addr":"$upstream_addr",'
  '"upstream_status":"$upstream_status",'
  '"request_time":$request_time,'
  '"upstream_connect_time":"$upstream_connect_time",'
  '"upstream_header_time":"$upstream_header_time",'
  '"upstream_response_time":"$upstream_response_time"'
'}';

Questions to answer from logs:

Is traffic distributed according to expected ratio?
Do retries cluster on specific upstreams?
Does one upstream have higher response time?
Does one tenant hash to one overloaded upstream?
Does a backend marked unavailable recover as expected?
Are 502/504 correlated with one upstream address?

A load balancing algorithm is not production-ready until you can prove its behavior from telemetry.


13. Mini Lab: Compare Algorithms

Use three backend containers that return their identity and optional delay.

Pseudo app behavior:

GET /fast     -> 10 ms
GET /slow     -> 2 sec
GET /whoami   -> backend id

Round robin test:

upstream rr_backend {
    server app1:8080;
    server app2:8080;
    server app3:8080;
}

Least conn test:

upstream lc_backend {
    least_conn;
    server app1:8080;
    server app2:8080;
    server app3:8080;
}

IP hash test:

upstream iph_backend {
    ip_hash;
    server app1:8080;
    server app2:8080;
    server app3:8080;
}

Generic hash test:

upstream tenant_backend {
    hash $arg_tenant consistent;
    server app1:8080;
    server app2:8080;
    server app3:8080;
}

Run:

# Round robin distribution
for i in $(seq 1 30); do curl -s http://localhost/whoami; done | sort | uniq -c

# Least conn under slow load
seq 1 20 | xargs -n1 -P20 -I{} curl -s http://localhost/slow >/dev/null

# Hash stability
for i in $(seq 1 10); do curl -s 'http://localhost/whoami?tenant=tenant-a'; done
for i in $(seq 1 10); do curl -s 'http://localhost/whoami?tenant=tenant-b'; done

Observe:

  • round robin distributes by request sequence,
  • least_conn avoids nodes with many active slow requests,
  • ip_hash depends on client IP visibility,
  • generic hash depends on key distribution.

14. Production Decision Checklist

Before approving an upstream algorithm, answer:

[ ] Is the workload stateless?
[ ] Is request duration roughly uniform?
[ ] Does any endpoint keep connections open?
[ ] Does any endpoint produce CPU/DB-heavy work?
[ ] Is user/session affinity actually required?
[ ] Is the affinity key trustworthy?
[ ] Can a single tenant/user/key dominate traffic?
[ ] What happens when one server is removed?
[ ] What happens when one server recovers cold?
[ ] Are retries safe for this route?
[ ] Are upstream selection and latency visible in logs?
[ ] Is upstream state shared across workers where required?
[ ] Does CI verify this directive is supported by deployed NGINX version?

15. The Practical Rule

Start with this:

Default API traffic        -> weighted round robin
Variable/long requests     -> least_conn
Legacy sticky session      -> ip_hash only as transitional solution
Domain-specific affinity   -> hash <validated-key> consistent
Cache shard routing        -> hash <cache-key> consistent
Latency-aware selection    -> least_time only after version/capability verification

Then prove with logs.

Do not ship an algorithm because it looks elegant. Ship it because the failure model is understood.


References

Lesson Recap

You just completed lesson 47 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.