nginx: Aplikacioni backendi
Ciljevi lekcije
Nakon ove lekcije trebalo bi da:
- povežeš nginx sa PHP-FPM-om preko FastCGI protokola, i to bezbedno
- postaviš Python aplikaciju iza Gunicorn-a ili uWSGI-ja
- pustiš Node.js aplikaciju u produkciju iza reverse proxy-ja
- napišeš systemd unit koji drži aplikaciju živom
- rešiš probleme sa dozvolama nad socket fajlovima
1. Tri načina komunikacije
nginx ne izvršava aplikacioni kod. On saobraća sa zasebnim procesom, na jedan od tri načina:
| Protokol | Direktiva | Tipično za |
|---|---|---|
| FastCGI | fastcgi_pass |
PHP-FPM |
| HTTP | proxy_pass |
Node.js, Gunicorn, Puma, Kestrel |
| uWSGI | uwsgi_pass |
Python preko uWSGI servera |
Izbor ne zavisi od jezika nego od toga šta aplikacioni server govori. Gunicorn govori HTTP, pa se ka njemu ide proxy_pass-om iako je Python. uWSGI govori sopstveni binarni protokol, pa ide uwsgi_pass-om.
Sve tri postavke dele isti obrazac:
klijent → nginx → [socket ili TCP port] → aplikacioni proces
↑
statika ide odavde, sa diska
Statiku uvek servira nginx direktno. Nema razloga da CSS fajl prolazi kroz Python interpreter.
2. PHP-FPM
Instalacija
Ubuntu 24.04 nosi PHP 8.3:
sudo apt install php8.3-fpm php8.3-mysql php8.3-mbstring php8.3-xml php8.3-curl
systemctl status php8.3-fpm
ls -l /run/php/php8.3-fpm.sock
Ako ti treba druga verzija PHP-a, koristi ppa:ondrej/php — tada se putanja socket-a menja u skladu sa verzijom.
Konfiguracija
server {
listen 80;
server_name php.example.com;
root /var/www/php.example.com/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
try_files $uri =404; # ← bezbednosno ključno
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
fastcgi_read_timeout 60s;
}
location ~ /\. {
deny all;
}
}
Zašto try_files $uri =404 u PHP bloku
Ovo je najvažniji red u celoj konfiguraciji i objašnjenje zaslužuje pažnju.
Bez njega, zahtev za /uploads/slika.jpg/x.php prolazi kroz regex \.php$ i ide PHP-FPM-u. Ako je u PHP-u uključen cgi.fix_pathinfo, interpreter će „popraviti" putanju i izvršiti /uploads/slika.jpg kao PHP kod. Napadač koji je uspeo da otpremi sliku sa PHP kodom u sebi upravo je dobio izvršavanje na tvom serveru.
try_files $uri =404; proverava da fajl sa tačno tim imenom postoji pre nego što se išta prosledi interpreteru. Nepostojeći /uploads/slika.jpg/x.php vraća 404 i nikada ne stiže do PHP-a.
Snippet fastcgi-php.conf na Ubuntu-u već sadrži fastcgi_split_path_info i postavlja PATH_INFO ispravno, ali try_files je pojas i tregeri. Pogledaj šta je unutra:
cat /etc/nginx/snippets/fastcgi-php.conf
Zabrana PHP-a u direktorijumu sa uploadima
Podsetnik na lekciju 5: location /uploads/ ne štiti od regex bloka. Ugnježdeni blok jeste rešenje:
location ^~ /uploads/ {
location ~ \.php$ {
deny all;
}
}
Prvi ^~ sprečava spoljni regex da uopšte pogleda ovu putanju; unutrašnji blok blokira PHP i za slučaj da neko kasnije promeni strukturu.
Podešavanje FPM bazena
Konfiguracija bazena je u /etc/php/8.3/fpm/pool.d/www.conf:
user = www-data
group = www-data
listen = /run/php/php8.3-fpm.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6
pm.max_requests = 500
listen.owner i listen.group moraju odgovarati korisniku pod kojim rade nginx workeri. Ako si nginx instalirao sa nginx.org, taj korisnik je nginx, a ne www-data — i dobićeš 502 sa Permission denied dok to ne uskladiš.
Kako izabrati pm.max_children: izmeri koliko memorije troši jedan PHP proces i podeli raspoloživu memoriju tim brojem.
ps -ylC php-fpm8.3 --sort:rss | awk '{print $8/1024 " MB"}' | tail -n +2 | sort -n | tail -5
Ako je proces oko 60 MB, a aplikaciji si namenio 1,2 GB, max_children je oko 20. Postavljanje previsoke vrednosti ne donosi propusnost nego OOM killer.
pm.max_requests restartuje worker nakon zadatog broja zahteva — jeftina zaštita od curenja memorije u aplikaciji.
Nakon izmena: sudo systemctl reload php8.3-fpm.
Tajmauti
fastcgi_read_timeout 60s;
Mora biti usklađen sa max_execution_time u PHP-u. Ako je nginx-ov kraći, dobijaš 504 iako PHP skripta uredno radi. Za dugotrajne operacije podigni oba, ali samo na konkretnoj putanji.
3. Python — Gunicorn
Gunicorn govori HTTP, pa se ka njemu ide običnim proxy_pass-om.
Priprema
sudo mkdir -p /var/www/pyapp
sudo chown $USER:www-data /var/www/pyapp
cd /var/www/pyapp
python3 -m venv venv
./venv/bin/pip install gunicorn flask
systemd unit
/etc/systemd/system/pyapp.service:
[Unit]
Description=Python aplikacija (Gunicorn)
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/var/www/pyapp
Environment="PATH=/var/www/pyapp/venv/bin"
ExecStart=/var/www/pyapp/venv/bin/gunicorn \
--workers 4 \
--bind unix:/run/pyapp/pyapp.sock \
--umask 007 \
app:app
ExecReload=/bin/kill -s HUP $MAINPID
Restart=always
RestartSec=5
RuntimeDirectory=pyapp
RuntimeDirectoryMode=0755
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/www/pyapp
[Install]
WantedBy=multi-user.target
Nekoliko stvari koje vredi primetiti:
RuntimeDirectory=pyapp kaže systemd-u da napravi /run/pyapp pri pokretanju i obriše ga pri gašenju. Bez ovoga socket ne bi imao gde da se napravi nakon restarta mašine, jer je /run u memoriji.
Restart=always je razlog zbog kog uopšte koristiš systemd umesto pokretanja u screen-u. Aplikacija koja padne vraća se sama.
Direktive za izolaciju (ProtectSystem, PrivateTmp, NoNewPrivileges) ograničavaju štetu ako aplikacija bude kompromitovana. Košta te tri reda.
Broj workera: uobičajena preporuka je 2 × broj_jezgara + 1 za sinhrone workere.
sudo systemctl daemon-reload
sudo systemctl enable --now pyapp
sudo systemctl status pyapp
nginx strana
upstream pyapp {
server unix:/run/pyapp/pyapp.sock;
}
server {
listen 80;
server_name py.example.com;
client_max_body_size 20m;
location /static/ {
alias /var/www/pyapp/static/;
expires 30d;
access_log off;
}
location / {
proxy_pass http://pyapp;
include proxy_params;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
Statika ide sa diska, sve ostalo aplikaciji. Django i Flask u produkciji ne treba da serviraju statičke fajlove — za Django ne zaboravi collectstatic.
uWSGI umesto Gunicorn-a
Ako aplikacija radi pod uWSGI-jem sa njegovim nativnim protokolom:
location / {
include uwsgi_params;
uwsgi_pass unix:/run/uwsgi/app.sock;
}
Fajl uwsgi_params dolazi sa nginx paketom i sadrži isto ono što proxy_params radi za HTTP. Nativni protokol je nešto efikasniji od HTTP-a, ali razlika je mala; biraj prema tome šta ti je tim već navikao da održava.
4. Node.js
Node aplikacija sluša na lokalnom portu i nikada se ne izlaže direktno na internet.
/etc/systemd/system/nodeapp.service:
[Unit]
Description=Node.js aplikacija
After=network.target
[Service]
Type=simple
User=nodeapp
Group=www-data
WorkingDirectory=/var/www/nodeapp
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/var/www/nodeapp
[Install]
WantedBy=multi-user.target
Aplikacija mora da sluša na 127.0.0.1, ne na 0.0.0.0:
app.listen(process.env.PORT, '127.0.0.1');
Sa 0.0.0.0 port je dostupan spolja i neko može zaobići nginx sa svim njegovim rate limiting-om, TLS-om i logovanjem. Proveri:
sudo ss -tlnp | grep node
Mora pisati 127.0.0.1:3000, ne 0.0.0.0:3000 ni *:3000.
nginx strana
upstream nodeapp {
server 127.0.0.1:3000;
keepalive 32;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name node.example.com;
location /public/ {
alias /var/www/nodeapp/public/;
expires 7d;
}
location / {
proxy_pass http://nodeapp;
include proxy_params;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
Pošto map postavlja Connection zaglavlje, ne treba ti poseban proxy_set_header Connection ""; za keep-alive — map vraća close za obične zahteve, a keep-alive bazen i dalje radi jer nginx sam upravlja konekcijama ka upstream-u.
Više Node procesa
Node je jednonitni. Da bi iskoristio sva jezgra, pokreni više instanci na različitim portovima:
upstream nodeapp {
least_conn;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
server 127.0.0.1:3003;
server 127.0.0.1:3004;
keepalive 32;
}
Sa systemd template unitom (nodeapp@.service sa Environment=PORT=%i) pokrećeš ih sa systemctl enable --now nodeapp@3001 i tako redom. Alternativa je Node-ov ugrađeni cluster modul ili PM2, ali systemd ti daje istu stvar bez dodatnog alata.
5. Dozvole nad socket fajlovima
Najčešći uzrok 502 greške pri prvom podešavanju. nginx worker (www-data) mora imati rw nad socket fajlom i x nad svakim direktorijumom do njega.
Dijagnostika:
ls -l /run/pyapp/pyapp.sock
ls -ld /run/pyapp
sudo -u www-data test -w /run/pyapp/pyapp.sock && echo "OK" || echo "NEMA PRISTUPA"
Za Gunicorn, --umask 007 daje socket-u dozvole srw-rw----. Uz Group=www-data u unit fajlu, nginx ima pristup.
Za PHP-FPM to rešavaju listen.owner, listen.group i listen.mode u konfiguraciji bazena.
Ako i dalje ne radi nakon što dozvole izgledaju ispravno, sledeći osumnjičeni je AppArmor:
sudo dmesg | grep -i apparmor | tail -5
6. Dijagnostika 502
Idi ovim redom, svaki put isto:
1. Da li backend uopšte radi?
systemctl status pyapp
sudo journalctl -u pyapp -n 50 --no-pager
journalctl je prvo mesto gde gledaš — tu je izlaz aplikacije, uključujući greške pri pokretanju.
2. Da li sluša tamo gde misliš?
sudo ss -tlnp | grep -E "3000|node|gunicorn"
ls -l /run/pyapp/pyapp.sock
3. Odgovara li zaobilazeći nginx?
curl -v http://127.0.0.1:3000/
curl -v --unix-socket /run/pyapp/pyapp.sock http://localhost/
Ako ovo ne prolazi, problem je u aplikaciji, ne u nginx-u.
4. Šta piše u nginx error logu?
sudo tail -20 /var/log/nginx/error.log
| Poruka | Uzrok |
|---|---|
connect() failed (111: Connection refused) |
aplikacija ne radi ili sluša na drugom portu |
connect() to unix:... failed (13: Permission denied) |
dozvole nad socket fajlom |
no such file or directory uz putanju socket-a |
pogrešna putanja, ili /run poddirektorijum nije napravljen |
upstream prematurely closed connection |
aplikacija je pala usred obrade — pogledaj journalctl |
upstream timed out |
504, ne 502 — podigni proxy_read_timeout ili popravi spor upit |
7. Zajednička pravila
Nezavisno od jezika:
- statiku servira nginx, aplikacija samo dinamiku
- aplikacija sluša na
127.0.0.1ili unix socket-u, nikada na0.0.0.0 - systemd sa
Restart=always, ne ručno pokretanje - aplikacija radi kao neprivilegovan korisnik, nikad kao root
client_max_body_sizemora biti usklađen sa ograničenjem u aplikacijiX-Forwarded-Protomora se prosleđivati, inače aplikacija ne zna da je iza TLS-a (lekcija 9)- tajmauti nginx-a i aplikacije moraju biti usklađeni
Praktična vežba
1. Postavi PHP-FPM od nule:
sudo apt install php8.3-fpm
sudo mkdir -p /var/www/php-test/public
echo '<?php phpinfo();' | sudo tee /var/www/php-test/public/index.php
sudo chown -R $USER:www-data /var/www/php-test
Napiši server blok po uzoru iz teksta, primeni i proveri:
curl -s -H "Host: php.local" http://localhost/ | head -20
2. Demonstriraj ranjivost sa PATH_INFO. Ukloni try_files $uri =404; iz PHP bloka, pa pošalji:
curl -sI -H "Host: php.local" http://localhost/index.php/nepostojeca.php
Uporedi odgovor sa i bez tog reda. Vrati ga i ostavi tamo zauvek.
3. Izmeri potrošnju PHP procesa i izračunaj razuman pm.max_children za svoju mašinu:
ps -ylC php-fpm8.3 --sort:rss | awk 'NR>1 {print $8/1024}' | sort -n | tail -3
free -m
4. Postavi Python aplikaciju sa Gunicorn-om. Napravi minimalni Flask app:
sudo mkdir -p /var/www/pyapp && cd /var/www/pyapp
sudo chown $USER:www-data .
python3 -m venv venv && ./venv/bin/pip install gunicorn flask
cat > app.py <<'EOF'
from flask import Flask, request
app = Flask(__name__)
@app.route('/')
def index():
return (f"proto: {request.headers.get('X-Forwarded-Proto','(nema)')}\n"
f"host: {request.headers.get('Host')}\n"
f"ip: {request.headers.get('X-Real-IP','(nema)')}\n")
EOF
Napiši systemd unit iz teksta, pokreni ga i proveri kroz nginx. Vrednosti u odgovoru pokazuju da li si ispravno podesio prosleđivanje zaglavlja.
5. Namerno pokvari dozvole nad socket-om:
sudo chmod 600 /run/pyapp/pyapp.sock
curl -s -H "Host: py.local" http://localhost/
sudo tail -3 /var/log/nginx/error.log
Pročitaj tačnu poruku, pa popravi kroz --umask u unit fajlu.
6. Testiraj da Restart=always radi:
sudo pkill -f gunicorn
sleep 6
systemctl status pyapp
curl -s -H "Host: py.local" http://localhost/
Aplikacija se vratila sama.
7. Proveri da aplikacija nije izložena spolja:
sudo ss -tlnp | grep -E "gunicorn|node"
Ako vidiš 0.0.0.0, popravi to u aplikaciji i ponovi.
Rezime
- Izbor između
fastcgi_pass,proxy_passiuwsgi_passzavisi od protokola aplikacionog servera, ne od jezika. try_files $uri =404;u PHPlocationbloku je bezbednosno obavezan — bez njega otpremljeni fajlovi mogu da se izvrše kao PHP.location ^~ /uploads/sa ugnježdenom zabranom PHP-a je drugi sloj iste zaštite.listen.ownerilisten.groupu FPM bazenu moraju odgovarati korisniku nginx workera.pm.max_childrenračunaj iz stvarne potrošnje procesa, ne iz preporuka sa interneta.- systemd sa
Restart=alwaysiRuntimeDirectoryje pravi način pokretanja aplikacije. - Aplikacija sluša na
127.0.0.1ili unix socket-u — nikada na0.0.0.0. - Kod 502 idi redom:
journalctl→ss→curldirektno na backend → nginx error log. - Statiku uvek servira nginx.
Pitanja za proveru
- Zašto je
try_files $uri =404;u PHP bloku sigurnosno pitanje, a ne stvar performansi? - Instalirao si nginx sa nginx.org i dobijaš 502 sa
Permission deniedna PHP socket-u. Šta menjaš? - PHP skripta radi 90 sekundi i dobijaš 504. Koja dva podešavanja moraš uskladiti?
- Čemu služi
RuntimeDirectory=pyappu systemd unit fajlu? - Zašto je opasno da Node aplikacija sluša na
0.0.0.0:3000? - Kako izračunavaš razuman
pm.max_childrenza svoju mašinu? - U error logu vidiš
upstream prematurely closed connection. Gde tražiš uzrok?
Sledeća lekcija: kompresija i keširanje — gzip i brotli, expires zaglavlja, proxy_cache i invalidacija keša.
Comments
Post a Comment