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.1 ili unix socket-u, nikada na 0.0.0.0
  • systemd sa Restart=always, ne ručno pokretanje
  • aplikacija radi kao neprivilegovan korisnik, nikad kao root
  • client_max_body_size mora biti usklađen sa ograničenjem u aplikaciji
  • X-Forwarded-Proto mora 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_pass i uwsgi_pass zavisi od protokola aplikacionog servera, ne od jezika.
  • try_files $uri =404; u PHP location bloku 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.owner i listen.group u FPM bazenu moraju odgovarati korisniku nginx workera.
  • pm.max_children računaj iz stvarne potrošnje procesa, ne iz preporuka sa interneta.
  • systemd sa Restart=always i RuntimeDirectory je pravi način pokretanja aplikacije.
  • Aplikacija sluša na 127.0.0.1 ili unix socket-u — nikada na 0.0.0.0.
  • Kod 502 idi redom: journalctlsscurl direktno na backend → nginx error log.
  • Statiku uvek servira nginx.

Pitanja za proveru

  1. Zašto je try_files $uri =404; u PHP bloku sigurnosno pitanje, a ne stvar performansi?
  2. Instalirao si nginx sa nginx.org i dobijaš 502 sa Permission denied na PHP socket-u. Šta menjaš?
  3. PHP skripta radi 90 sekundi i dobijaš 504. Koja dva podešavanja moraš uskladiti?
  4. Čemu služi RuntimeDirectory=pyapp u systemd unit fajlu?
  5. Zašto je opasno da Node aplikacija sluša na 0.0.0.0:3000?
  6. Kako izračunavaš razuman pm.max_children za svoju mašinu?
  7. 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

Popular posts from this blog

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

Konverzija tipova podataka u Pythonu

groupadd