nginx: Load balancing

Ciljevi lekcije

Nakon ove lekcije trebalo bi da:

  • definišeš grupu backend servera kroz upstream blok
  • biraš metod raspodele prema prirodi aplikacije
  • podesiš keep-alive konekcije prema backend-u i znaš zašto je to obavezno
  • razumeš pasivne provere zdravlja i njihova ograničenja u besplatnoj verziji
  • rešiš pitanje sesija kada aplikacija nije bezstanjna

1. upstream blok

Umesto jedne adrese u proxy_pass, definišeš imenovanu grupu:

upstream aplikacija {
    server 10.0.1.10:3000;
    server 10.0.1.11:3000;
    server 10.0.1.12:3000;
}

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://aplikacija;
        include proxy_params;
    }
}

upstream ide u http kontekst, van server blokova. Ime grupe je proizvoljno i koristiš ga u proxy_pass na mestu gde bi inače stajao host.

Isti mehanizam radi i sa unix socket-ima, što je korisno kada više instanci aplikacije radi na istoj mašini:

upstream aplikacija {
    server unix:/run/app-1.sock;
    server unix:/run/app-2.sock;
    server unix:/run/app-3.sock;
}

2. Metodi raspodele

Round-robin (podrazumevano)

Zahtevi idu redom: prvi, drugi, treći, prvi... Ne moraš ništa da navodiš.

Dobro radi kada su serveri jednaki i zahtevi približno jednako skupi. Loše kada neki zahtevi traju sekundu, a neki minut — server koji je zaglavljen na dugom zahtevu i dalje uredno dobija svoj deo novih.

Težine

upstream aplikacija {
    server 10.0.1.10:3000 weight=3;   # dobija 3 od svakih 5 zahteva
    server 10.0.1.11:3000 weight=1;
    server 10.0.1.12:3000 weight=1;
}

Koristi kada mašine nisu iste snage, ili pri postepenom uvođenju nove verzije — pustiš joj weight=1 naspram weight=9 i posmatraš.

least_conn — najmanje aktivnih konekcija

upstream aplikacija {
    least_conn;
    server 10.0.1.10:3000;
    server 10.0.1.11:3000;
}

Novi zahtev ide serveru koji trenutno opslužuje najmanje konekcija. Ovo je bolji podrazumevani izbor od round-robina za većinu realnih aplikacija, jer se sam prilagođava kada zahtevi imaju neujednačeno trajanje.

ip_hash — vezivanje po IP adresi

upstream aplikacija {
    ip_hash;
    server 10.0.1.10:3000;
    server 10.0.1.11:3000;
}

Isti klijent uvek pada na isti server. Rešava problem sesija u aplikacijama koje ih drže lokalno.

Ograničenja su ozbiljna i treba ih znati unapred: korisnici iza istog NAT-a (cela firma, mobilni operater) računaju se kao jedan klijent, pa raspodela postaje neravnomerna. Kada dodaš ili ukloniš server, hash se menja i veliki deo korisnika gubi sesiju. Klijenti sa promenljivom IP adresom gube sesiju sami od sebe.

Uzmi ovo kao privremeno rešenje dok ne prebaciš sesije u Redis ili bazu.

hash — proizvoljni ključ

upstream aplikacija {
    hash $request_uri consistent;
    server 10.0.1.10:3000;
    server 10.0.1.11:3000;
}

Raspodela po bilo kojoj promenljivoj. Odličan za keš servere — isti URI uvek ide istom serveru, pa svaki server kešira samo svoj deo sadržaja.

Parametar consistent uključuje konzistentno heširanje (ketama algoritam): kada dodaš ili ukloniš server, preraspoređuje se samo mali deo ključeva umesto skoro svih. Ako koristiš hash, praktično uvek želiš i consistent.

Vezivanje po sesijskom kolačiću:

hash $cookie_sessionid consistent;

random

upstream aplikacija {
    random two least_conn;
    server 10.0.1.10:3000;
    server 10.0.1.11:3000;
    server 10.0.1.12:3000;
}

Bira dva servera nasumično, pa između njih onaj sa manje konekcija. Zvuči čudno, ali je dokazano dobar algoritam ("power of two choices") u postavkama sa više load balancera, gde least_conn na svakom od njih može da dovede do toga da svi istovremeno preplave isti server.

Kako birati

Situacija Izbor
Bezstanjni API, ujednačeni zahtevi round-robin
Neujednačeno trajanje zahteva least_conn
Sesije u memoriji aplikacije ip_hash (privremeno) ili hash $cookie_...
Keš serveri hash $request_uri consistent
Više load balancera ispred istih backend-ova random two least_conn

3. Keep-alive prema backend-u

Ovo je podešavanje koje se najčešće izostavi, a ima najveći uticaj na performanse.

Podrazumevano nginx otvara novu TCP konekciju za svaki zahtev ka backend-u i zatvara je odmah nakon odgovora. Pri par hiljada zahteva u sekundi to znači stalno uspostavljanje i rušenje konekcija, gomilanje socket-a u TIME_WAIT stanju i nepotrebno kašnjenje.

Rešenje ima tri dela i sva tri su obavezna:

upstream aplikacija {
    least_conn;
    server 10.0.1.10:3000;
    server 10.0.1.11:3000;

    keepalive 32;                      # 1. bazen zadržanih konekcija
}

server {
    location / {
        proxy_pass http://aplikacija;
        include proxy_params;

        proxy_http_version 1.1;        # 2. keep-alive ne postoji u HTTP/1.0
        proxy_set_header Connection "";# 3. ukloni "Connection: close"
    }
}

Ako izostaviš bilo koji od ova tri reda, keep-alive neće raditi — a nećeš dobiti nikakvo upozorenje. Tiho ćeš i dalje otvarati konekciju po zahtevu.

Treći red zaslužuje objašnjenje. nginx podrazumevano šalje Connection: close ka backend-u. Postavljanje na prazan niz uklanja zaglavlje, pa se primenjuje HTTP/1.1 podrazumevano ponašanje, a to je zadržana konekcija.

Koliko? Broj u keepalive je maksimum neaktivnih konekcija po worker procesu, ne ukupan limit. Za većinu postavki 16–32 je dovoljno. Previše velika vrednost troši deskriptore na obe strane bez koristi.

Proveri i da backend ne zatvara konekcije prerano — Node.js, Gunicorn i ostali imaju svoje keep-alive tajmaute koji treba da budu duži od nginx-ovog, inače dolazi do trke gde backend zatvori konekciju taman kad nginx šalje zahtev, što se u logu vidi kao povremeni 502.

4. Provere zdravlja

Pasivne provere (besplatna verzija)

nginx prati neuspehe tokom normalnog saobraćaja:

upstream aplikacija {
    server 10.0.1.10:3000 max_fails=3 fail_timeout=30s;
    server 10.0.1.11:3000 max_fails=3 fail_timeout=30s;
}

Čita se: ako server tri puta ne uspe u roku od 30 sekundi, izbaci ga iz rotacije na 30 sekundi. Nakon isteka, nginx mu pošalje jedan zahtev da proveri da li se oporavio.

Podrazumevano je max_fails=1 fail_timeout=10s, što je često prestrogo — jedan slučajan neuspeh izbacuje server. Vrednost 2–3 je razumnija.

Šta se računa kao neuspeh kontroliše se sa proxy_next_upstream:

proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 10s;

Podrazumevano su to samo error i timeout — HTTP greške poput 502 se ne računaju osim ako ih ne navedeš. Aplikacija koja vraća 500 zbog buga u kodu ne treba da izbacuje server iz rotacije, ali 502 od zaglavljenog procesa treba.

Oprez sa non_idempotent: podrazumevano nginx ne ponavlja POST i druge neidempotentne zahteve na sledećem serveru. Ako to uključiš, rizikuješ dupli upis u bazu.

Ograničenje pasivnih provera: server koji ne prima saobraćaj se ne proverava. Ako je izbačen, saznaćeš da je opet živ tek kad mu pošalješ zahtev — a to znači da jedan korisnik dobija grešku umesto tebe.

Aktivne provere

Prave aktivne provere (health_check direktiva, koja periodično sama šalje zahtev na /health endpoint) postoje samo u NGINX Plus. To je jedna od glavnih razlika između verzija.

U besplatnoj verziji imaš nekoliko opcija:

  • spoljni monitoring koji, kada primeti pad, prepiše konfiguraciju i uradi reload
  • third-party modul nginx_upstream_check_module (traži kompajliranje iz izvora)
  • service discovery — Consul Template, confd i slično, koji generišu upstream blok iz registra servisa
  • premeštanje odgovornosti na sloj ispod: Kubernetes servisi, HAProxy, cloud load balancer

Za većinu manjih postavki pasivne provere sa razumnim max_fails su sasvim dovoljne.

5. Rezervni serveri i planirano isključivanje

upstream aplikacija {
    server 10.0.1.10:3000;
    server 10.0.1.11:3000;
    server 10.0.1.12:3000 backup;      # samo kad svi ostali padnu
    server 10.0.1.13:3000 down;        # trajno isključen
}

backup server ne prima saobraćaj dok je bar jedan primarni živ. Korisno za stranicu sa porukom o održavanju ili za rezervnu lokaciju.

down označava server kao trajno nedostupan. Koristi ovo umesto komentarisanja reda kada nekog isključuješ radi održavanja — konfiguracija ostaje čitljiva i vidi se da server postoji.

Napomena: backup se ne može kombinovati sa hash ni random metodama.

Postupak za bezbedno isključivanje servera

Kada treba da izbaciš server iz rotacije radi nadogradnje:

  1. dodaj down uz njegov red u upstream bloku
  2. sudo nginx -t && sudo systemctl reload nginx
  3. sačekaj da postojeći zahtevi na tom serveru završe
  4. radi šta si naumio
  5. ukloni down, ponovi reload

Zbog graceful reload-a iz lekcije 1, nijedan zahtev u toku se ne prekida.

Ograničavanje konekcija po serveru

server 10.0.1.10:3000 max_conns=100;

Kada backend ima ograničen broj workera, ovo sprečava da ga nginx preoptereti. Zahtevi preko limita čekaju u redu umesto da ruše aplikaciju.

6. Deljena zona

upstream aplikacija {
    zone aplikacija 64k;
    least_conn;
    server 10.0.1.10:3000;
    server 10.0.1.11:3000;
}

Bez zone, svaki worker proces vodi sopstvenu evidenciju o broju konekcija i o tome koji su serveri pali. Na mašini sa osam jezgara to znači osam nezavisnih pogleda na stanje — least_conn ne vidi celu sliku, a server koji je jedan worker izbacio i dalje prima saobraćaj od ostalih.

Sa zone stanje je u deljenoj memoriji i svi workeri ga vide isto. Ovo praktično uvek želiš kada imaš više od jednog backend servera.

7. Dinamičko razrešavanje imena

Ako backend serveri menjaju IP adrese (kontejneri, auto-scaling), imaš problem: nginx razrešava imena jednom, pri startu, i drži rezultat zauvek.

resolver 127.0.0.53 valid=10s ipv6=off;

server {
    location / {
        set $backend "http://aplikacija.servis.local:3000";
        proxy_pass $backend;
    }
}

Korišćenje promenljive prisiljava nginx da razrešava ime u vreme svakog zahteva, uz keširanje po valid parametru.

Cena je gubitak upstream bloka sa svim njegovim mogućnostima. U ozbiljnijim postavkama bolje je koristiti alat koji generiše konfiguraciju iz registra servisa i radi reload.

Na Ubuntu 24.04, lokalni DNS resolver je systemd-resolved na 127.0.0.53.

8. Praćenje raspodele

Dodaj u log koji je server obradio zahtev:

log_format lb '$remote_addr - $status "$request" '
              'upstream=$upstream_addr '
              'vreme_backend=$upstream_response_time '
              'status_backend=$upstream_status '
              'ukupno=$request_time';

access_log /var/log/nginx/lb.log lb;

Kada je zahtev pokušan na više servera, ove promenljive sadrže vrednosti razdvojene zarezima:

upstream=10.0.1.10:3000, 10.0.1.11:3000 status_backend=502, 200

To je zapis da je prvi server odbio zahtev, a drugi ga uspešno obradio — tačno ono što želiš da vidiš pri dijagnostici.

Brza provera raspodele:

sudo awk '{print $6}' /var/log/nginx/lb.log | sort | uniq -c | sort -rn

Praktična vežba

1. Pokreni tri instance jednostavnog backend-a, svaku na svom portu, tako da se u odgovoru vidi koja je:

mkdir -p ~/lb-test && cd ~/lb-test
cat > server.js <<'EOF'
const http = require('http');
const port = process.argv[2];
http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end(`backend na portu ${port}\n`);
}).listen(port, '127.0.0.1');
EOF
node server.js 3001 & node server.js 3002 & node server.js 3003 &

2. Podesi round-robin i posmatraj raspodelu:

upstream test_lb {
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
}

server {
    listen 80;
    server_name lb.local;

    location / {
        proxy_pass http://test_lb;
        include proxy_params;
    }
}
for i in $(seq 1 9); do curl -s -H "Host: lb.local" http://localhost/; done

Trebalo bi da vidiš uredno smenjivanje 3001 → 3002 → 3003.

3. Dodaj težine (weight=3 prvom serveru), primeni i pošalji 20 zahteva. Prebroj:

for i in $(seq 1 20); do curl -s -H "Host: lb.local" http://localhost/; done | sort | uniq -c

4. Simuliraj pad servera:

kill %1                                # gasi 3001
for i in $(seq 1 6); do curl -s -H "Host: lb.local" http://localhost/; done

Prvi zahtev može da bude sporiji ili da vrati grešku — to je trenutak u kome nginx otkriva da server ne odgovara. Nakon toga saobraćaj ide samo na preostala dva. Pogledaj error log.

Pokreni server ponovo (node server.js 3001 &) i posmatraj koliko treba da se vrati u rotaciju sa različitim vrednostima fail_timeout.

5. Uporedi ip_hash i round-robin. Dodaj ip_hash; u upstream blok, primeni i pošalji deset zahteva sa iste mašine — svi treba da idu na isti server.

6. Izmeri efekat keep-alive konekcija. Prvo bez njih, sa ab ili hey:

sudo apt install apache2-utils
ab -n 2000 -c 50 -H "Host: lb.local" http://localhost/

Zapamti broj zahteva u sekundi. Zatim dodaj sva tri reda za keep-alive, primeni i ponovi test. Uporedi i rezultat i broj socket-a u TIME_WAIT stanju:

ss -tan state time-wait | wc -l

7. Podesi lb log format, pošalji zahteve dok je jedan server oboren i pronađi red u kome upstream sadrži dve adrese.


Rezime

  • upstream blok ide u http kontekst; ime grupe koristiš u proxy_pass.
  • least_conn je bolji podrazumevani izbor od round-robina za većinu realnih aplikacija.
  • ip_hash rešava sesije, ali loše podnosi NAT i menjanje sastava grupe. Prava sesija ide u Redis.
  • Keep-alive prema backend-u traži tri stvari: keepalive N u upstream bloku, proxy_http_version 1.1 i proxy_set_header Connection "". Bez svih tri ne radi, i to tiho.
  • Pasivne provere (max_fails, fail_timeout) su jedino što besplatna verzija nudi; aktivne su NGINX Plus.
  • proxy_next_upstream podrazumevano ne obuhvata HTTP greške — 502 i 503 moraš navesti izričito.
  • zone je potreban da bi svi workeri delili stanje o konekcijama i o palim serverima.
  • down je čistiji način da isključiš server od komentarisanja reda.
  • Zapiši $upstream_addr i $upstream_response_time u log pre nego što ti zatreba.

Pitanja za proveru

  1. Zašto sam keepalive 32; u upstream bloku nije dovoljan?
  2. Aplikacija drži sesije u memoriji i imaš tri servera. Koja su tvoja rešenja i koje su mane svakog?
  3. Backend vraća 502 kada je preopterećen, ali nginx i dalje šalje zahteve na njega. Šta si propustio?
  4. Šta znači zone aplikacija 64k; i šta se dešava bez toga na mašini sa osam jezgara?
  5. Kako bezbedno isključuješ jedan server iz rotacije radi nadogradnje?
  6. U logu vidiš upstream=10.0.1.10:3000, 10.0.1.11:3000. Šta se dogodilo?
  7. Zašto je opasno dodati non_idempotent u proxy_next_upstream?

Sledeća lekcija: HTTPS i TLS — Let's Encrypt sertifikati, preusmeravanje sa HTTP-a, izbor protokola i šifri, HSTS.

Comments

Popular posts from this blog

Početak u Linuxu — šta je i zašto se uči

Konverzija tipova podataka u Pythonu

groupadd