Nginx reverse proxy i HTTPS ispred lokalnog LLM-a

Do sada je sve slušalo na 127.0.0.1 i to je bila ispravna odluka. Kad servis treba da bude dostupan spolja, između njega i mreže ide proxy koji radi ono što ni Ollama ni vLLM ne umeju: TLS, kontrolu pristupa i ograničavanje broja zahteva.

Ovaj tekst pokriva prvi deo — postavljanje proxy-ja i sertifikata. Autentifikacija i ključevi su tema sledećeg teksta; ova dva idu zajedno i nijedan sam nije dovoljan.

Šta proxy rešava

Bez proxy-ja Sa proxy-jem
HTTP, sve u čitljivom obliku TLS sa važećim sertifikatom
Nema kontrole pristupa Ključevi, IP liste, lozinke
Nema ograničenja broja zahteva Rate limiting
Nema zapisa ko je šta tražio Pristupni log
Svaki servis na svom portu Više servisa na jednom imenu

Postavka izgleda ovako:

Internet → Nginx :443 (TLS) → Ollama :11434     (lokalno)
                             → Open WebUI :3000 (lokalno)
                             → vLLM :8000       (lokalno)

Priprema

$ sudo apt install -y nginx
$ sudo dnf install -y nginx
$ sudo systemctl enable --now nginx

Proveri da servisi ispod stvarno slušaju samo lokalno:

$ sudo ss -tulpn | grep -E '11434|8000|3000'
tcp LISTEN 0 4096 127.0.0.1:11434 0.0.0.0:*
tcp LISTEN 0 4096 127.0.0.1:3000  0.0.0.0:*

Ako neki od njih stoji na 0.0.0.0, proxy nije zaštita — svako i dalje može zaobići ga i otići pravo na port. Vrati ih na lokalnu adresu pre nego što nastaviš, kako je opisano u tekstu o systemd jedinici.

Za Docker postavke isto važi za mapiranje porta — -p 127.0.0.1:11434:11434, ne -p 11434:11434.

Firewall propušta samo veb portove:

$ sudo ufw allow 80/tcp
$ sudo ufw allow 443/tcp
$ sudo firewall-cmd --permanent --add-service=http --add-service=https
$ sudo firewall-cmd --reload

Sertifikat

Za javno dostupan server sa imenom domena:

$ sudo apt install -y certbot python3-certbot-nginx
$ sudo certbot --nginx -d ai.primer.rs

Certbot sam upisuje TLS deo u konfiguraciju i postavlja obnavljanje. Provera da obnavljanje radi:

$ sudo certbot renew --dry-run
$ systemctl list-timers | grep certbot

Za server u lokalnoj mreži, gde Let's Encrypt ne može da potvrdi ime, ide sopstveni sertifikat:

$ sudo openssl req -x509 -nodes -days 730 -newkey rsa:2048 \
    -keyout /etc/ssl/private/ai.key \
    -out /etc/ssl/certs/ai.crt \
    -subj "/CN=ai.lokalno"

Pregledači će prijavljivati upozorenje, a curl traži oznaku -k. Za internu upotrebu je prihvatljivo; bolje rešenje je sopstveni CA čiji sertifikat postaviš na klijentske mašine.

Konfiguracija

$ sudo nano /etc/nginx/conf.d/ai.conf
server {
    listen 80;
    server_name ai.primer.rs;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name ai.primer.rs;

    ssl_certificate     /etc/letsencrypt/live/ai.primer.rs/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ai.primer.rs/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    access_log /var/log/nginx/ai-access.log;
    error_log  /var/log/nginx/ai-error.log;

    client_max_body_size 50M;

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

        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_buffering off;
        proxy_cache off;
        proxy_http_version 1.1;

        proxy_read_timeout 600s;
        proxy_send_timeout 600s;
    }
}
$ sudo nginx -t
$ sudo systemctl reload nginx

Dva reda koja se najčešće izostave

proxy_buffering off; — bez ovoga Nginx skuplja ceo odgovor pre nego što ga prosledi. Streaming prestaje da radi: korisnik gleda u prazan ekran pola minuta, pa dobije sve odjednom. Ovo je najčešća pritužba posle postavljanja proxy-ja.

proxy_read_timeout 600s; — podrazumevanih šezdeset sekundi je premalo. Veći model sa dugim odgovorom prelazi taj rok, veza se prekida, a u logu stoji greška koja izgleda kao pad servisa.

Vrednost client_max_body_size podigni ako korisnici prilažu dokumente kroz Open WebUI — podrazumevani jedan megabajt je premali za PDF.

Više servisa iza jednog imena

server {
    listen 443 ssl;
    http2 on;
    server_name ai.primer.rs;

    ssl_certificate     /etc/letsencrypt/live/ai.primer.rs/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ai.primer.rs/privkey.pem;

    client_max_body_size 50M;

    # Veb interfejs na korenu
    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;

        # WebSocket, obavezno za Open WebUI
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_buffering off;
        proxy_read_timeout 600s;
    }

    # API na zasebnoj putanji
    location /api/ {
        proxy_pass http://127.0.0.1:11434/;

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

        proxy_buffering off;
        proxy_http_version 1.1;
        proxy_read_timeout 600s;
    }
}

Kosa crta na kraju proxy_pass adrese menja značenje. Sa njom se /api/ odseca pre prosleđivanja, pa zahtev na /api/v1/chat/completions stiže do Ollame kao /v1/chat/completions. Bez kose crte putanja ostaje cela i ništa ne radi — to je klasična greška koja košta pola sata.

$ curl https://ai.primer.rs/api/v1/models

Zasebna imena umesto putanja

Čistije, ako imaš kontrolu nad DNS zapisima:

server {
    listen 443 ssl;
    server_name chat.primer.rs;
    # ... sertifikat ...
    location / { proxy_pass http://127.0.0.1:3000; ... }
}

server {
    listen 443 ssl;
    server_name api.primer.rs;
    # ... sertifikat ...
    location / { proxy_pass http://127.0.0.1:11434; ... }
}

Prednost je što svaki servis dobija sopstvena pravila, logove i ograničenja, bez petljanja sa putanjama.

Ograničavanje broja zahteva

Model je skup resurs i jedan skript u petlji ume da obori ceo servis. Ograničenje ide u http blok:

$ sudo nano /etc/nginx/nginx.conf
limit_req_zone $binary_remote_addr zone=ai_limit:10m rate=30r/m;
limit_conn_zone $binary_remote_addr zone=ai_conn:10m;

Pa u location:

location /api/ {
    limit_req zone=ai_limit burst=10 nodelay;
    limit_conn ai_conn 3;

    proxy_pass http://127.0.0.1:11434/;
    ...
}

Trideset zahteva u minutu je razumno za pojedinca. Ograničenje istovremenih veza je ovde možda i važnije od broja zahteva — jedan korisnik sa deset otvorenih tokova zauzima celu karticu.

Zahtev preko granice dobija kod 503:

limit_req_status 429;

Provera

$ curl -sv https://ai.primer.rs/api/v1/models 2>&1 | grep -E 'SSL|subject|expire'

Da streaming stvarno teče postepeno:

$ curl -N https://ai.primer.rs/api/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"qwen3:8b","messages":[{"role":"user","content":"Nabroj deset Linux komandi sa objašnjenjem"}],"stream":true}'

Tekst treba da stiže u komadićima. Ako čekaš pa dobiješ sve odjednom, proxy_buffering nije isključen.

Da se port ne vidi spolja:

$ nmap -p 11434,3000,8000 ai.primer.rs

Sva tri treba da budu zatvorena. Ako nisu, ili servis ne sluša lokalno, ili je Docker upisao sopstveno pravilo ispred firewalla — problem opisan u tekstu o Dockeru.

Logovi

$ sudo tail -f /var/log/nginx/ai-access.log

Za API je korisno beležiti i trajanje zahteva:

log_format ai '$remote_addr - $status $request_time "$request"';
access_log /var/log/nginx/ai-access.log ai;
203.0.113.10 - 200 12.483 "POST /api/v1/chat/completions HTTP/1.1"

Vrednost $request_time ovde nosi najviše. Rast tog broja kroz vreme znači da se kartica zasićuje pre nego što se to vidi bilo gde drugde. Prikupljanje ovakvih podataka u vremenske serije tema je teksta o monitoringu.

Rotacija je podešena unapred, ali proveri veličinu ako servis radi neprekidno:

$ du -sh /var/log/nginx/

Praktični scenariji

Scenario 1: streaming ne radi kroz proxy

Dodaj proxy_buffering off;. Ovo je uzrok u gotovo svakom slučaju.

Scenario 2: veza puca posle minuta

upstream timed out (110: Connection timed out)

Podigni proxy_read_timeout na pet do deset minuta.

Scenario 3: Open WebUI se učita, ali razgovor ne radi

Nedostaju zaglavlja Upgrade i Connection. Bez WebSocket podrške interfejs izgleda ispravno, ali ne prenosi poruke.

Scenario 4: 404 na svaki API zahtev

Izostala kosa crta na kraju proxy_pass adrese, pa se putanja ne odseca.

Scenario 5: 502 Bad Gateway

$ sudo systemctl status ollama
$ curl http://127.0.0.1:11434

Servis ispod ne radi ili je promenio port. Ako je pod SELinux-om, proveri i da li je proxy-ju uopšte dozvoljeno da otvara mrežne veze:

$ sudo setsebool -P httpd_can_network_connect 1

Scenario 6: port je i dalje dostupan spolja

Servis sluša na 0.0.0.0, ili je Docker mapirao port bez adrese. Proxy tada ne štiti ništa.

Kratka referenca

  • certbot --nginx -d ime — sertifikat i automatsko obnavljanje
  • proxy_buffering off;bez ovoga nema streaminga
  • proxy_read_timeout 600s; — podrazumevanih 60 s je premalo
  • proxy_set_header Upgrade i Connection — WebSocket za Open WebUI
  • proxy_pass http://127.0.0.1:11434/; — kosa crta odseca putanju
  • client_max_body_size 50M; — prilaganje dokumenata
  • limit_req_zone, limit_conn_zone — ograničenja po adresi
  • nginx -t — provera pre svakog reload
  • ss -tulpn — servisi ispod moraju biti na 127.0.0.1
  • nmap — provera da portovi nisu vidljivi spolja
  • $request_time u logu — rani znak zasićenja kartice
  • setsebool -P httpd_can_network_connect 1 — SELinux i proxy

Vežba

  1. Postavi proxy ispred Ollame i potvrdi da API odgovara preko HTTPS-a.
  2. Namerno izostavi proxy_buffering off i uporedi ponašanje streaming zahteva.
  3. Dodaj Open WebUI na koren, a API na /api/, pa proveri obe putanje.
  4. Postavi ograničenje na trideset zahteva u minutu i izazovi ga petljom u bashu.
  5. Proveri kroz nmap da portovi servisa nisu dostupni spolja.
  6. Uključi $request_time u log i uporedi trajanje kratkog i dugačkog upita.

Sledeći tekst u serijalu: Zaštita API-ja: ključevi, firewall i ograničavanje pristupa

Povezano:

Comments

Popular posts from this blog

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

Konverzija tipova podataka u Pythonu

groupadd