nginx: Reverse proxy

Ciljevi lekcije

Nakon ove lekcije trebalo bi da:

  • razumeš razliku između proxy_pass sa URI-jem i bez njega — to je najvažnija stvar u ovoj lekciji
  • znaš koja zaglavlja moraš da prosleđuješ i zašto
  • podesiš tajmaute i baferisanje prema potrebama aplikacije
  • omogućiš WebSocket saobraćaj kroz nginx
  • prepoznaš uzrok grešaka 502, 504 i 413

1. Šta radi reverse proxy

Forward proxy stoji ispred klijenta i izlazi na internet u njegovo ime. Reverse proxy stoji ispred servera i prima saobraćaj u njegovo ime.

klijent ──▶ nginx :443 ──▶ aplikacija :3000
            (javno)         (127.0.0.1, nedostupna spolja)

Zašto ne pustiti aplikaciju direktno na port 443? Zato što nginx radi niz stvari koje aplikacija ne bi trebalo da radi:

  • TLS terminacija — aplikacija govori običan HTTP, ne mora da zna ništa o sertifikatima
  • statika sa diska — CSS i slike ne prolaze kroz aplikacioni proces
  • spori klijenti — nginx prihvata zahtev u komadima i prosleđuje ga backend-u tek kad je kompletan, pa aplikacioni worker nije blokiran
  • rate limiting, keširanje, kompresija, logovanje — na jednom mestu, van koda
  • više aplikacija na jednoj IP adresi i portu

Zadnja tri argumenta važe čak i kada imaš samo jednu aplikaciju.

2. proxy_pass — i zamka sa kosom crtom

Najjednostavniji oblik:

location / {
    proxy_pass http://127.0.0.1:3000;
}

Sada dolazi pravilo koje mora da ti uđe u refleks:

Ako proxy_pass sadrži putanju (makar i samo /), prefiks iz location bloka se zamenjuje tom putanjom. Ako ne sadrži putanju, originalni URI se prosleđuje nepromenjen.

Pogledaj razliku:

location /api/ {
    proxy_pass http://127.0.0.1:3000;      # BEZ putanje
}

Zahtev /api/korisnici → backend prima /api/korisnici

location /api/ {
    proxy_pass http://127.0.0.1:3000/;     # SA putanjom (kosa crta)
}

Zahtev /api/korisnici → backend prima /korisnici

Jedan znak menja ponašanje. Prvi oblik koristiš kada aplikacija sama zna za prefiks /api. Drugi kada aplikacija sluša na korenu, a ti je montiraš pod prefiks.

Može i drugačija putanja:

location /stari-api/ {
    proxy_pass http://127.0.0.1:3000/v2/;
}

Zahtev /stari-api/korisnici → backend prima /v2/korisnici.

Kada nešto stiže na pogrešnu putanju kod backend-a, ovo je prvo mesto koje proveravaš.

Ograničenje: oblik sa putanjom ne može da se koristi u location bloku definisanom regularnim izrazom, ni unutar imenovanog location-a. Tamo ide samo oblik bez putanje.

Unix socket

Kada backend sluša na socket fajlu umesto na TCP portu:

proxy_pass http://unix:/run/app.sock:/;

Sintaksa je nezgodna: unix: + putanja + : + URI deo. Ta poslednja kosa crta je URI deo i podleže pravilu odozgo.

proxy_pass sa promenljivom

resolver 127.0.0.53 valid=30s;

location /servis/ {
    set $backend "http://interni-servis.local:8080";
    proxy_pass $backend;
}

Kada proxy_pass sadrži promenljivu, nginx razrešava ime u vreme svakog zahteva, a ne pri startu. To je korisno u kontejnerskim okruženjima gde se IP adrese menjaju, ali zahteva resolver direktivu i malo je sporije. Bez promenljive, nginx razreši ime jednom pri startu i drži ga zauvek — što znači da ti restart backend kontejnera može tiho oboriti proxy.

3. Zaglavlja — šta nginx radi podrazumevano

Ovde je najčešći izvor problema u produkciji. Podrazumevano nginx prema backend-u šalje:

Host: 127.0.0.1:3000        ← adresa backend-a, ne originalni domen!
Connection: close

Posledica: aplikacija ne zna na koji je domen zahtev zaista došao, ne zna IP adresu pravog klijenta, i ne zna da li je originalni zahtev bio HTTPS. Redirekcije koje aplikacija generiše vodiće na 127.0.0.1:3000, korisnici u logovima će svi imati istu IP adresu, a aplikacija koja proverava HTTPS može upasti u petlju preusmeravanja.

Zato uvek prosleđuješ ova zaglavlja:

location / {
    proxy_pass http://127.0.0.1:3000;

    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host  $host;
}

Šta koje radi:

Zaglavlje Sadržaj
Host originalni domen iz zahteva klijenta
X-Real-IP IP adresa klijenta
X-Forwarded-For lanac IP adresa; $proxy_add_x_forwarded_for dodaje trenutnog klijenta na postojeći lanac
X-Forwarded-Proto http ili https — po ovome aplikacija zna da je iza TLS terminacije
X-Forwarded-Host originalni domen, za aplikacije koje traže baš ovo ime

Ubuntu paket ovo već nudi kao snippet:

location / {
    proxy_pass http://127.0.0.1:3000;
    include proxy_params;
}

Pogledaj šta je unutra sa cat /etc/nginx/proxy_params.

Podsetnik na zamku iz lekcije 3

proxy_set_header se ne spaja kroz nivoe. Ako definišeš zaglavlja u server bloku, a onda u jednom location bloku dodaš samo jedno — sva ostala nestaju za tu putanju:

server {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;

    location /api/ {
        proxy_set_header X-Api-Key "tajna";   # sada Host i X-Real-IP NE idu!
        proxy_pass http://backend;
    }
}

Rešenje je include proxy_params; na oba mesta.

Prava IP adresa iza CDN-a

Ako je ispred nginx-a još jedan sloj (Cloudflare, load balancer, CDN), $remote_addr je adresa tog sloja, a ne korisnika. Modul realip to ispravlja:

set_real_ip_from 10.0.0.0/8;          # opseg u kome je poverljivi proxy
real_ip_header X-Forwarded-For;
real_ip_recursive on;

Nakon ovoga $remote_addr sadrži pravu adresu klijenta i logovi, rate limiting i sve ostalo rade ispravno.

Kritično: set_real_ip_from navodi opsege kojima veruješ. Ako tu upišeš 0.0.0.0/0, bilo ko može da lažira X-Forwarded-For i zaobiđe ti sve što se oslanja na IP adresu. Navodi isključivo konkretne opsege svog infrastrukturnog sloja.

4. Baferisanje

Podrazumevano je proxy_buffering on. nginx prima odgovor od backend-a u svoje bafere, oslobađa backend čim je odgovor preuzet, pa ga zatim isporučuje klijentu svojim tempom.

proxy_buffering    on;
proxy_buffer_size  8k;      # bafer za zaglavlja odgovora
proxy_buffers      8 16k;   # broj i veličina bafera za telo
proxy_busy_buffers_size 32k;

Ovo je gotovo uvek ono što želiš, jer zaštita od sporih klijenata i jeste glavni razlog što reverse proxy postoji.

Kada isključiti: kod odgovora koji teku u realnom vremenu — Server-Sent Events, streaming API-ji, logovi uživo:

location /events {
    proxy_pass http://127.0.0.1:3000;
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 24h;
}

Bez ovoga nginx zadržava podatke u baferu i klijent ne vidi ništa dok se bafer ne napuni.

Greška koju ćeš sresti: upstream sent too big header while reading response header from upstream u error logu. Znači da zaglavlja odgovora ne staju u proxy_buffer_size — tipično kod aplikacija koje šalju velike cookie-je ili mnogo zaglavlja. Rešenje je povećanje:

proxy_buffer_size   16k;
proxy_buffers       8 16k;

5. Tajmauti i veličina zahteva

proxy_connect_timeout 5s;      # koliko čekamo da se konekcija uspostavi
proxy_send_timeout    60s;     # pauza između dva upisa ka backend-u
proxy_read_timeout    60s;     # pauza između dva čitanja od backend-a

Podrazumevano je 60 sekundi za sve tri. Napomena koja se često pogrešno razume: proxy_read_timeout nije ukupno vreme odgovora, nego maksimalna pauza između dva primljena komada. Odgovor koji stiže u delovima može trajati satima i neće isteći.

proxy_connect_timeout spusti na 3–5 sekundi. Ako se TCP konekcija ka lokalnom backend-u ne uspostavi za pet sekundi, neće ni za šezdeset — bolje da brzo dobiješ grešku nego da gomilaš konekcije koje čekaju.

Za dugotrajne operacije podigni čitanje na konkretnoj putanji, ne globalno:

location /izvestaji/generisi {
    proxy_pass http://127.0.0.1:3000;
    proxy_read_timeout 300s;
}

client_max_body_size i greška 413

Podrazumevana vrednost je 1 megabajt. Svaki upload veći od toga vraća 413 Request Entity Too Large — i to nginx odbija zahtev, aplikacija ga nikada ne vidi.

client_max_body_size 50m;

Kada korisnik prijavi da ne može da otpremi fajl, ovo je prvi osumnjičeni. Postavi je u server blok ili na konkretnu putanju za upload. Ne postavljaj je preterano visoko globalno — to je i mera zaštite.

Ne zaboravi da i backend ima svoje ograničenje (PHP upload_max_filesize i post_max_size, client_max_body_size ekvivalenti u drugim okruženjima). Oba moraju biti podešena.

6. WebSocket

WebSocket počinje kao običan HTTP zahtev koji traži nadogradnju protokola:

GET /ws HTTP/1.1
Upgrade: websocket
Connection: Upgrade

nginx podrazumevano ne prosleđuje ta zaglavlja, pa nadogradnja ne uspeva. Standardno rešenje koristi map blok:

# u http kontekstu, van server bloka
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    location /ws/ {
        proxy_pass http://127.0.0.1:3000;

        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header Host       $host;

        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

Zašto baš ovako:

  • proxy_http_version 1.1 je obavezno — nadogradnja ne postoji u HTTP/1.0
  • map postavlja Connection: upgrade samo kada klijent traži nadogradnju, a Connection: close inače. Ako bezuslovno napišeš Connection "upgrade", pokvarićeš obične HTTP zahteve na istoj putanji.
  • WebSocket veze su dugotrajne i tihe, pa podrazumevanih 60 sekundi znači da ti nginx prekida vezu svakog minuta. Zato produženi tajmauti.

Ako aplikacija ima ugrađen ping/pong mehanizam sa intervalom manjim od 60 sekundi, tajmaute ne moraš da diraš.

7. Ostale korisne direktive

Sakrivanje zaglavlja backend-a:

proxy_hide_header X-Powered-By;
proxy_hide_header Server;

Nema razloga da svetu javljaš verziju svog aplikacionog framework-a.

Ispravljanje redirekcija: ako backend vraća Location zaglavlje sa svojom internom adresom:

proxy_redirect http://127.0.0.1:3000/ /;

Podrazumevano proxy_redirect default već pokriva prost slučaj, ali kod složenijih montiranja putanja moraš eksplicitno.

Keep-alive prema backend-u — vidi sledeću lekciju, jer se konfiguriše kroz upstream blok.

8. Dijagnostika

Greška Značenje Gde tražiti
502 Bad Gateway nginx ne može da se poveže ili je dobio neispravan odgovor backend ne radi, pogrešan port/socket, dozvole nad socket fajlom
504 Gateway Timeout backend nije odgovorio u roku proxy_read_timeout, spor upit, zaglavljen backend
413 telo zahteva preveliko client_max_body_size
400 uz upstream sent too big header zaglavlja ne staju u bafer proxy_buffer_size

Kod 502 uvek prvo proveri da li backend uopšte odgovara, zaobilazeći nginx:

curl -v http://127.0.0.1:3000/

Ako ovo ne radi, problem nije u nginx-u. Ako radi, pogledaj error log:

sudo tail -20 /var/log/nginx/error.log
connect() failed (111: Connection refused) while connecting to upstream

Backend ne sluša na toj adresi.

connect() to unix:/run/app.sock failed (13: Permission denied)

Socket postoji, ali www-data nema pristup. Proveri ls -l /run/app.sock i dozvole nad direktorijumom.

Dodaj vreme odgovora backend-a u log — jednom podešeno, štedi sate:

log_format proxy '$remote_addr - $status "$request" '
                 'ukupno=$request_time backend=$upstream_response_time '
                 'upstream=$upstream_addr status=$upstream_status';

access_log /var/log/nginx/proxy.log proxy;

Razlika između $request_time i $upstream_response_time odmah kaže da li usporava aplikacija ili mreža ka klijentu.


Praktična vežba

1. Pokreni prosti backend koji vraća putanju i zaglavlja koja je primio:

mkdir -p ~/test-backend && cd ~/test-backend
cat > server.js <<'EOF'
const http = require('http');
http.createServer((req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end(`PUTANJA: ${req.url}\nHOST: ${req.headers.host}\n` +
          `X-Real-IP: ${req.headers['x-real-ip'] || '(nema)'}\n` +
          `X-Forwarded-Proto: ${req.headers['x-forwarded-proto'] || '(nema)'}\n`);
}).listen(3000, '127.0.0.1');
EOF
node server.js &

Ako nemaš Node: sudo apt install nodejs. Alternativa je python3 -m http.server 3000, ali neće prikazivati zaglavlja.

2. Demonstriraj zamku sa kosom crtom. Napravi dva bloka i uporedi:

server {
    listen 80;
    server_name proxy.local;

    location /bez/ {
        proxy_pass http://127.0.0.1:3000;
    }

    location /sa/ {
        proxy_pass http://127.0.0.1:3000/;
    }
}
curl -H "Host: proxy.local" http://localhost/bez/korisnici
curl -H "Host: proxy.local" http://localhost/sa/korisnici

Uporedi vrednost PUTANJA u oba odgovora.

3. Vidi šta backend dobija bez prosleđenih zaglavlja. Zahtev ka bilo kom bloku pokazuje HOST: 127.0.0.1:3000 i X-Real-IP: (nema). Sada dodaj include proxy_params; u blok, primeni i ponovi — vrednosti se menjaju.

4. Izazovi 502 i pročitaj log:

kill %1                                    # ugasi backend
curl -H "Host: proxy.local" http://localhost/bez/
sudo tail -3 /var/log/nginx/error.log

Pronađi poruku Connection refused i tačnu adresu koju je nginx pokušao.

5. Izazovi 413:

node server.js &
dd if=/dev/zero of=/tmp/veliki.bin bs=1M count=5
curl -H "Host: proxy.local" -F "fajl=@/tmp/veliki.bin" http://localhost/bez/upload

Dodaj client_max_body_size 10m; u server blok, primeni i probaj ponovo.

6. Podesi log sa vremenima odgovora kao u primeru iz teksta i pošalji nekoliko zahteva. Uporedi ukupno i backend kolone.


Rezime

  • proxy_pass http://host; prosleđuje URI nepromenjen. proxy_pass http://host/; zamenjuje location prefiks. Jedan znak, potpuno drugačije ponašanje.
  • Bez proxy_set_header, backend vidi Host: 127.0.0.1:3000 i nema pojma ko je klijent ni da li je zahtev bio HTTPS.
  • include proxy_params; je najkraći put do ispravnog skupa zaglavlja — i mora se ponoviti u svakom location bloku koji ima sopstveni proxy_set_header.
  • set_real_ip_from navodi samo opsege kojima zaista veruješ.
  • Baferisanje drži uključeno, osim za streaming odgovore.
  • client_max_body_size je 1 MB podrazumevano — to je uzrok većine neuspelih uploada.
  • WebSocket traži proxy_http_version 1.1, map za Connection zaglavlje i duže tajmaute.
  • Kod 502 prvo proveri backend direktno sa curl, pa tek onda gledaj nginx.

Pitanja za proveru

  1. location /api/ { proxy_pass http://backend:3000/v1/; } — koju putanju backend prima za zahtev /api/korisnici/5?
  2. Aplikacija generiše redirekciju na http://127.0.0.1:3000/prijava. Šta si propustio da podesiš?
  3. U server bloku imaš četiri proxy_set_header direktive, a u jednom location bloku jednu dodatnu. Šta backend dobija na toj putanji?
  4. Zašto je map $http_upgrade $connection_upgrade bolji od proxy_set_header Connection "upgrade";?
  5. Server-Sent Events stižu klijentu tek na kraju, u jednom komadu. Šta popravljaš?
  6. Kada je opasno napisati set_real_ip_from 0.0.0.0/0;?
  7. Kako iz logova razlikuješ sporu aplikaciju od spore mreže ka klijentu?

Sledeća lekcija: load balancing — upstream blok, metodi raspodele, keep-alive konekcije i pasivne provere zdravlja.

Comments

Popular posts from this blog

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

Konverzija tipova podataka u Pythonu

groupadd