Jak wdrożyć model ML jako skalowalne API w Spring Boot na produkcji

0
90
Rate this post

Z tej publikacji dowiesz się:

Na czym potyka się produkcja: szybki model lokalnie, zadyszka po wdrożeniu

Objawy, które pojawiają się po pierwszym deployu

Model działał błyskawicznie na laptopie, a po wdrożeniu jako API w Spring Boot dostajesz lawinę timeoutów, niestabilne czasy odpowiedzi (P95/P99 odjeżdża), a czasem restart JVM z powodu OutOfMemoryError. Zdarza się też, że pierwsze żądania po deployu trwają wieki, bo model „wstaje”. Do tego dochodzą spadki throughputu pod większym obciążeniem, dziwne skoki GC i sporadyczne błędy serializacji wejścia/wyjścia.

To nie jest rzadkość. Najczęściej problemem nie jest sam algorytm, ale sposób jego oprawienia w usługę: jak ładujesz model, jak go wołasz, w ilu wątkach, jak zarządzasz pamięcią i jak planujesz budżet opóźnień od bramy HTTP do predykcji.

Stawka: SLA, koszty i reputacja

Predykcje blisko żądań użytkownika muszą mieć stabilne czasy odpowiedzi. Każda wpadka uderza w konwersję lub obsługę klienta. Z drugiej strony przewymiarowana infrastruktura pod model ML potrafi wyczyścić budżet chmurowy w kilka tygodni. Dlatego architektura API i strategia skalowania muszą honorować konkretne cele: SLA (np. 99.9% dostępności), SLO (np. P95 < 150 ms) i limit kosztów.

Mini‑checklista na start (jeśli choć jedno „nie”, zwolnij)

  • Czy masz policzony budżet opóźnień end‑to‑end (od Edge/ALB do inferencji i z powrotem)?
  • Czy model ładuje się w procesie przed przyjęciem ruchu (warmup), a nie przy pierwszym żądaniu?
  • Czy wiesz, ile pamięci i CPU/GPU realnie potrzebuje jedna równoległa predykcja?
  • Czy masz limity rozmiaru requestu, timeouts i zabezpieczenia przed lawiną retry?
  • Czy API ma jasny kontrakt i wersjonowanie (OpenAPI, semver)?

Dlaczego to się dzieje: przyczyny opóźnień i niestabilności

Spóźnione ładowanie modelu i zimne starty

Najczęstsza pułapka: model jest ładowany leniwie przy pierwszym żądaniu. Dla dużych artefaktów (setki MB) to gwarantuje długie pierwsze odpowiedzi i często timeouts. W mikroserwisach skalowanych automatycznie każdy nowy pod powiela to zjawisko. Dochodzi jeszcze rozgrzewka JIT i ewentualne inicjalizacje bibliotek natywnych (np. MKL, cuDNN), które również wydłużają pierwsze minuty po deployu.

Blokujące I/O, rozdmuchany JSON i ciężka serializacja

Modele rzadko „lubią” ogromne payloady wejściowe. Jeśli API przyjmuje surowe obrazy lub dokumenty, wolna deserializacja i kopiowanie bajtów potrafią pożreć znaczną część budżetu opóźnień. Blokujące I/O (np. pobieranie cech z innego systemu w trakcie żądania) dorzuca własny jitter i ryzyko kaskadowych timeoutów.

GC i zatory na wątkach

Wiele wdrożeń traci stabilność, bo wątki HTTP i wątki inferencji współdzielą jeden pulpit i walczą o CPU. Agresywne alokacje tablic i tensora w pętli żądań powodują presję na GC. Efekt to losowe skoki P95/P99 i czasem Stop‑the‑World. Bez wyraźnych limitów puli wątków i backpressure serwis może wejść w spiralę: rosnące czasy odpowiedzi → retry → jeszcze większy backlog.

Format modelu i brak kompatybilności biblioteki

Artefakt dostarczony przez zespół DS bywa niedopasowany do środowiska produkcyjnego JVM. PMML/ONNX pomaga, ale nie każdy model daje się sensownie przekonwertować. Niewłaściwy runtime (np. CPU‑only dla modelu trenowanego na GPU) lub brak wektorowych optymalizacji potrafią obniżyć throughput o rząd wielkości.

Architektura: inferencja w JVM czy osobny serwer modelu?

Trzy sprawdzone warianty integracji

  • Inferencja w JVM: Spring Boot ładuje model i wykonuje predykcje w tym samym procesie (np. ONNX Runtime for Java, DJL – Deep Java Library, JPMML).
  • Zewnętrzny serwer modelu: Spring Boot to cienki frontend; predykcje wykonuje TensorFlow Serving/TorchServe/ONNX Runtime Server/BentoML po HTTP/gRPC.
  • Oddzielny mikroserwis w Pythonie: FastAPI/Flask/uvicorn z bibliotekami natywnymi (PyTorch/TensorFlow), a Spring Boot deleguje żądania.

Porównanie opcji – mocne i słabe strony

WariantZaletyWadyKiedy wybrać
Inferencja w JVMNiskie opóźnienia (brak hopa sieciowego), prostszy deployment, jedna obserwowalnośćOgraniczenia bibliotek ML w JVM, trudniejsza obsługa GPU, większe binariaModele klasyczne (tree/linear), ONNX/PMML, twarde SLA latencji, brak GPU
Serwer modeluGotowe optymalizacje (batching, GPU), niezależny cykl życia modeluDodatkowy hop sieciowy, złożone operowanie dwoma serwisamiCiężkie modele, potrzeba GPU, wiele języków/modeli
Python microserviceSzybka integracja z ekosystemem DS, duża elastycznośćOperacyjny narzut, uwaga na GIL/uvicorn workers, spójność obserwowalnościModel trudny do przeniesienia na JVM, potrzeba customowej logiki w Py

Na czym oprzeć wybór – praktyczne kryteria

  • Docelowy P95/P99: gdy celem jest ekstremalnie niska latencja, uniknięcie hopa sieciowego (JVM‑inference) bywa decydujące.
  • GPU vs CPU: jeżeli wymagasz GPU, zewnętrzny serwer modelu lub mikroserwis w Pythonie zwykle będzie prostszy operacyjnie.
  • Cykl życia: jak często zmienia się model vs API? Rozdzielenie może przyspieszyć rollout modeli bez ruszania aplikacji.
  • Zespół: kto utrzyma środowisko? JVM‑only upraszcza stack w zespołach Java.

Antywzorce, których unikać

  • Featuryzacja online przez wiele zewnętrznych usług w ścieżce krytycznej requestu – latencja i zawodność rosną nieliniowo.
  • Ładowanie modelu „na gorąco” z S3 przy każdym starcie poda bez cache’u lokalnego.
  • Brak kontraktu i schematu danych wejściowych – chaos i łamanie kompatybilności.
  • Budżet opóźnień: policz go zanim napiszesz pierwszy kontroler

    Mapowanie czasu: od bramy do predykcji

    Rozbij jedną odpowiedź na składniki i nadaj im limity. Prosty szkielet: Edge/ALB → Spring (deserializacja, walidacja) → przygotowanie cech → inferencja → serializacja → wysyłka. Dołóż warmup jako etap „przed ruchem”. Jeśli SLO to P95 150 ms, na czystą inferencję rzadko zostaje więcej niż 60–80 ms. Reszta to I/O i klejenie danych.

    Gdzie znika czas w praktyce

  • Handshake/TLS i brama: zimne połączenia, rotacja certyfikatów – minimalizuj przez keep‑alive i connection pooling.
  • Deserializacja JSON/Avro/Protobuf: duże pola string/base64 to cichy zabójca. Przy obrazach rozważ multipart lub wskazanie do obiektu w storage.
  • Featuryzacja online: każdy zewnętrzny call to jitter i ryzyko kaskady. Jeśli musisz – buforuj, limituj i mierz.
  • Inferencja: sama predykcja bywa stabilna, ale inicjalizacje bibliotek i alokacje per żądanie robią swoje.
  • GC i logowanie: synchroniczne appendery lub nadmierny poziom logów przyspieszają czasy pauz.

Krótki test „dymny”: odpal lokalny profil ze sztuczną pauzą 50 ms w miejscu inferencji i sprawdź, gdzie nadal uciekają milisekundy. Jeśli bez modelu masz już 120 ms, to nie model jest problemem.

Projekt ścieżki żądania: synchronicznie, asynchronicznie czy wsadowo

Synchroniczne API – gdy liczy się natychmiastowa odpowiedź

Dobre, gdy wejścia są małe, predykcja jest krótka i deterministyczna, a biznes naprawdę czeka na wynik (skoring przy akceptacji koszyka). Wymaga twardych limitów: maksymalny rozmiar requestu, deadline po stronie klienta i serwera, brak zewnętrznych wywołań w hot‑path albo tylko z cache.

Asynchroniczne/kolejkowane – gdy kosztujesz setki ms lub sekundy

Wstawiasz wiadomość do kolejki (Kafka/SQS/Redis Streams), a odpowiedź odbierasz przez callback/webhook lub polling. Zyskujesz odporność na szczyty i możliwość batchowania, tracisz natychmiastowość. Kluczowe elementy: idempotency key, DLQ (kolejka błędów), retry z backoffem i metryka głębokości kolejki jako sygnał do autoskalera.

Batching i micro‑batching – szczególnie przy GPU

Łącz małe żądania w paczki, by lepiej wykorzystać wektoryzację/GPU. Ustal dwa limity: maksymalny rozmiar batcha i maksymalny czas czekania na jego zapełnienie (np. 5–10 ms). Dla serwerów modelu (TF Serving, Triton) włącz wbudowany batcher; w JVM użyj kolejki + własnego sklejacza. Uważaj na „head‑of‑line blocking”: jeśli batch zawiera trudny przypadek, cierpią wszystkie rekordy.

Jak wdrożyć model ML jako skalowalne API w Spring Boot na produkcji
Źródło: Pexels | Autor: Markus Spiske

Równoległość, wątki i pamięć: spokój JVM to stabilne P99

Oddzielaj wątki HTTP od wątków inferencji

Żądania przyjmuj małą, kontrolowaną pulą (np. 100–200 wątków), a inferencję uruchamiaj na dedykowanym Executorze z limitem kolejki. Gdy kolejka pełna – zwróć 429/503, nie „oszukuj” klienta czekaniem w nieskończoność. To jest backpressure, nie błąd.

Kontroluj równoległość modelu

Nie każdy runtime jest w pełni thread‑safe. Czasem lepiej utworzyć N instancji sesji modelu niż współdzielić jedną przez setki wątków. Prosty wzór na start: concurrency_per_pod ≈ cores × (docelowe_użycie_CPU) ÷ (czas_inferencji × CPU_na_inferencję). Zweryfikuj to w testach obciążeniowych.

Ogranicz alokacje w pętli żądania

  • Używaj prealokowanych buforów i struktur roboczych; recykling tensora tam, gdzie runtime pozwala.
  • Preferuj Protobuf/Avro nad JSON przy ciężkich payloadach; mniej kopiowania i mniej śmieci dla GC.
  • Skonfiguruj JVM dla kontenerów (MaxRAMPercentage, G1 lub ZGC, liczba wątków GC = CPU requests). Zmierz pauzy – nie zakładaj, że „będzie dobrze”.

Warmup i pamięć modeli

Ładuj model w czasie startu i wykonaj kilka próbnych inferencji z typowymi danymi. Jeśli trzymasz dwie wersje modelu na raz (canary/rollout), policz pamięć: wąska maszyna + dwa duże artefakty = szybkie OOM. W razie potrzeby rozdziel wersje na różne pody.

Skalowanie i zasoby: jak nie przepłacić za stabilność

Planowanie pojemności: prosty rachunek

Znaj P95 inferencji per pod i licz: docelowe_RPS ÷ (RPS_na_pod_przy_P95) = wymagana liczba replik. RPS_na_pod zmierz przy docelowym P95, nie przy średniej. Unikaj „skalowania po CPU”, jeśli inferencja jest krótkotrwała i skokowa – lepiej skalować po kolejce lub latencji.

Autoskalowanie w K8s – co naprawdę działa

  • HPA po custom metryce: głębokość kolejki, inferences/sec na pod, P95 latencji – przez Prometheus Adapter/KEDA.
  • Jak skalować bez spięć koszt–SLA

  • KEDA po metryce kolejki: gdy ruch przychodzi falami, skaluj po lag/time-in-queue, nie po CPU. Dla HTTP użyj wewnętrznej kolejki w podzie i eksportuj jej głębokość.
  • Budżet podów rozgrzanych: trzymaj minReadyReplik (np. 1–2 na strefę), żeby uniknąć „zimnych” startów bibliotek numerycznych.
  • Pod Disruption Budget i priorytety: bez PDB pojedynczy drain węzła potrafi wybić P99 na kilka minut.

CPU kontra GPU – jak rozdzielić pule

GPU dają skok przepustowości, ale karzą za byle drobiazg. Oddziel je logicznie i operacyjnie od CPU.

  • Oddzielne node‑poole: selektory/tainty, aby CPU‑only pody nie „wsiadały” na drogie węzły GPU.
  • Rozmiar batcha a SLA: zacznij od micro‑batchingu (np. 8–16 rekordów) i maks. opóźnienia batchera 5–10 ms. Później zwiększaj rozmiar, jeśli P95 jest stabilne.
  • MIG lub time‑slicing: przy mniejszych modelach rozważ partycjonowanie GPU (tam gdzie wspierane), by uniknąć jałowego czasu.
  • Cold/warm routing: kieruj pierwsze zapytania po deployu na CPU, aż GPU się rozgrzeje (alokacje, JIT, cache).

Requests/limits, QoS i OOM‑y

Większość „losowych” restartów na produkcji to źle dobrane limity. Biblioteki numeryczne alokują poza stertą JVM (off‑heap), więc pamięć procesowa > Xmx.

  • Policz RAM: Xmx + metaspace + off‑heap (np. ONNX/TensorFlow) + bufory I/O. Daj zapas 20–30% na szczyty alokacji.
  • QoS Guaranteed: ustaw requests = limits dla CPU/RAM w krytycznych podach, żeby uniknąć throttlingu i OOMKill przy presji.
  • CPU throttling boli P99: przy limicie blisko requestu użyj burstable tylko, gdy SLA to toleruje. Inaczej zwiększ limit lub skaluj w poziomie.
  • HugePages i numactl rzadko pomagają przy małych modelach – używaj dopiero po pomiarach.

Bezpieczeństwo i odporność ścieżki predykcji

Walidacja wejścia na początku, schemat jako kontrakt

Śmieci na wejściu niszczą SLA szybciej niż błędny model. Waliduj zanim dotkniesz runtime’u ML.

Jak wdrożyć model ML jako skalowalne API w Spring Boot na produkcji
Źródło: Pexels | Autor: Stanislav Kondratiev
  • Schema‑first: Protobuf/Avro/JSON Schema z weryfikacją rozmiarów i typów; twarde limity payloadu i pól (np. maks. liczba elementów wektorów).
  • Treści binarne poza JSON: obrazy/audio jako multipart lub odsyłacz do storage; unikniesz kopiowania i Base64.
  • Lista dozwolonych wartości (whitelist) dla pól kategorycznych; nieznane mapuj na UNK – unikniesz KeyErrorów po featuryzacji.

Timeouty, limity i bezpieczne degradacje

Lepszy szybki 429/503 niż wiszące połączenia. Zdefiniuj deadline end‑to‑end i egzekwuj go warstwa po warstwie.

  • Serwer HTTP: ogranicz czas czytania żądania i sumaryczny czas obsługi. Ustaw małą kolejkę żądań oczekujących.
  • Clienty do modelu: krótkie timeouts, małe connection pool’e z backpressure. Lepiej odrzucić niż generować ogon P99.
  • Circuit breaker + bulkhead (Resilience4j): osobne pule wątków/limitów dla zależności zewnętrznych (feature store, serwer modelu), aby nie zablokować całej aplikacji.
  • Tryb degradacji: zwróć predykcję „fallback” lub reguły heurystyczne, gdy model jest niedostępny. Oznacz odpowiedź flagą w nagłówkach/logach.

Idempotencja i ochrona przed nadużyciami

Przy asynchronicznym API użyj klucza idempotencyjnego, aby ponowne wysłanie nie dublowało zadań. Dołóż:

  • Rate limiting przy bramie (Envoy/NGINX/API GW) po kliencie/kluczu; miękki 429 zamiast twardego przeciążenia.
  • Quota per klient i per model‑version, aby testy A/B nie zmiotły puli dla starej wersji.
  • AUDIT: loguj request_id, model_version, feature_checksum bez wylewania całych danych osobowych.

Obserwowalność: mierz to, co decyduje o budżecie opóźnień

Metryki systemowe i modelowe w jednym miejscu

Mikrometry liczbowe są tańsze niż późniejsze dochodzenie „dlaczego”. Zbierz oba światy: platformę i jakość modelu.

  • RPS, P50/P95/P99, error rate, łącze saturacji (kolejka, wątki, GPU utilizacja), rozmiar requestu.
  • Model: odsetek braków cech, rozkłady wejść, confidence/entropy, drift (np. PSI) liczony okresowo w tle.
  • Wersja modelu i checksum wejść jako labels – bez tego nie zestawisz regresji po rolloutcie.

Trasy i śledzenie end‑to‑end

Bez trace’a łatwo obwinia się model o winy sieci. Noś ze sobą identyfikator przez wszystkie hopy.

  • OpenTelemetry: propaguj w3c traceparent; dodaj baggage: model_version, tenant, experiment_id.
  • Span’y: edge → Spring → featuryzacja → serwer modelu → baza. Zmierz każdy segment osobno – zobaczysz, gdzie paruje budżet.

Logi i dane wrażliwe

Strukturalne logi (JSON) ułatwiają korelację. Redaguj PII, a duże tensory loguj wyłącznie próbkując i po skrótach (hash).

Testy: zanim włączysz ruch produkcyjny

Golden tests i kontrakty

Ustal „złoty” zestaw wejść/wyjść i trzymaj go w repo modelu. Każdy build aplikacji i modelu porównuje wyniki w budżecie tolerancji (np. delta MSE/accuracy). Dodaj testy zgodności schematu: zmiany kompatybilne wstecz przechodzą automaty, łamiące – wymagają bumpa wersji API.

Obciążenie i długie testy

  • Step‑load i soak (godziny): obserwuj P99, GC pauzy, stabilność batchera. Jeśli P99 „krąży”, kolejka/limity są złe.
  • Chaos: kill ‑9 poda, drain noda, spowolnij featuryzację. SLO ma się trzymać, a system wracać do steady‑state bez ręki na konsoli.

Shadow i canary, czyli wdrożenia bez bólu

Shadow: kopiuj realny ruch na nową wersję, nie zwracaj jej odpowiedzi klientowi, porównuj metryki i wyniki. Canary: promuj procent ruchu i mierz guardraile (P95, error rate, odchył jakości). Rollback to komenda, nie projekt: trzymaj obrazy i modele poprzedniej wersji pod ręką.

Wersjonowanie modeli i zgodna ewolucja API

Schematy i kompatybilność

Dodawaj pola tylko opcjonalnie, nie usuwaj istniejących. Zmiany łamiące wymagają nowego endpointu lub nowej wersji protokołu. Jeśli musisz migrować cechy – wspieraj oba zestawy równolegle przez krótki czas z sygnałem deprecjacji.

Rejestr modeli i metadane

  • Model registry (np. MLflow, S3 + index): artefakt + podpis (hash), wersja, data, metryki walidacyjne, kompatybilny runtime.
  • Nie ładuj „latest” z wiadra. Zawsze konkretny URI + checksum. Startup ma failować, jeśli suma się nie zgadza.

Strategie rotacji

  • Side‑by‑side: ładuj N i N+1, routuj procent ruchu. Gdy RAM napięty – rozdziel na osobne pody i użyj routera na poziomie usługi.
  • Sticky A/B: to samo żądanie tego samego użytkownika idzie stale do tej samej wersji – porównania są uczciwe.

Router wersji to nie tylko procent ruchu. Ustal deterministyczne przypięcie (hash po user_id/tenant_id), żeby ten sam użytkownik trafiał do tej samej wersji przez cały eksperyment. Jeśli identyfikatora brak, generuj syntetyczny (cookie, fingerprint nagłówków) i nadaj krótki TTL – inaczej porównania rozjadą się przez „skakanie” między modelami.

Budżet opóźnień: gdzie paruje czas i jak go odzyskać

Większość SLA rozpada się nie w samym modelu, ale „na obrzeżach”: przy walidacji, featuryzacji, serializacji i kolejkach. Rozpisz budżet P95 od brzegu do modelu i trzymaj się go jak kontraktu.

Mapa czasu od bramy do predykcji

  • Ingress/edge: TLS, WAF, rate limit – zwykle 2–10 ms; policz osobno cold/TLS‑resume.
  • Spring: deserializacja + walidacja schematu – 3–15 ms dla JSON, 1–5 ms dla Protobuf.
  • Featuryzacja online: look‑upy, normalizacja, bucketization – od 5 ms (cache) do 40+ ms (IO sieciowe).
  • Skok do modelu: sieć (jeśli zewnętrzny serwer) 1–5 ms w obrębie DC; marshalling gRPC/JSON 1–8 ms.
  • Inferencja: 5–60 ms CPU, szeroki rozrzut na GPU (zależny od batcha).
  • Zapis metryk/logu audytowego: 1–5 ms async; nigdy nie blokuj żądania na dysku.

Przykładowy budżet P95 120 ms: 10 ms edge, 10 ms walidacja, 35 ms featuryzacja, 45 ms model, 10 ms reszta. Gdy featuryzacja zaczyna „jeść” 60 ms, wiesz, gdzie tniec – nie w modelu.

Zasady budżetowania i limity per‑hop

  • Ustal „deadline” w nagłówku i propaguj go do każdego klienta (gRPC Deadline/HTTP header). Każda warstwa skraca budżet o swój czas.
  • Wejścia binarne przesyłaj poza JSON. Base64 potrafi dodać 30–40% rozmiaru i kilka ms CPU.
  • Wewnątrz DC używaj HTTP/2/gRPC z keep‑alive; unikniesz kosztów TCP/TLS handshaku i head‑of‑line blocking na HTTP/1.1.
  • Kompresja tylko powyżej progu (np. >64 KB); dla małych payloadów dołożysz narzut bez zysku.

Kształt API: synchroniczne, asynchroniczne i batch — wybierz świadomie

Dobierz tryb do SLA i charakteru ruchu, nie „bo wszyscy tak robią”.

Kiedy synchroniczne HTTP

  • P95 do ~150 ms, przewidywalny RPS, brak długich look‑upów zewnętrznych.
  • Prosto wdrożyć: Spring MVC (wątki) lub WebFlux/Netty (reaktywnie) przy dużej liczbie połączeń oczekujących.
  • Backpressure na wejściu: mała kolejka serwera, 429 zamiast wiszenia.

Kiedy asynchroniczne/kolejkowane

  • Predykcja >300 ms, ryzyko timeoutów klientów, praca partiami (np. rekomendacje, scoring wsadowy).
  • API: POST zadanie → 202 + id → GET status/odbiór wyniku; w środku kolejka (Kafka/SQS/Rabbit) i konsument‑inference.
  • Naturalny bufor szczytów + łatwiejsze batchowanie. Zaplanuj TTL i czyszczenie martwych zadań.

Batchowanie bez łamania SLA

  • Micro‑batcher czasowy: okno 5–10 ms lub N elementów, co pierwsze. Trzymaj twardy limit opóźnienia.
  • Stosuj tam, gdzie model skaluje się pod batch (GPU, matmul ciężkie). Na CPU ma sens tylko przy dużym narzucie przełączania/kontekstów.
  • Batchuj „jednorodne” żądania (ten sam kształt tensora) – inaczej stracisz na re‑padowaniu i kopiowaniu.

Równoległość i pamięć w JVM: jak nie ubić P99

Inferencja to CPU/GPU‑bound, ale wszystko dookoła (JSON, IO) bywa blokujące. Oddziel to pulami i twardymi limitami.

Izolacja pracy modelu

  • Dedykowana pula wątków dla inferencji (Executor) z krótką kolejką (bounded). Gdy pełna – natychmiast 429/503.
  • Sekcja krytyczna na sesję modelu: część runtime’ów nie jest w 100% thread‑safe. Albo sesja per wątek, albo semafor ograniczający współbieżność.
  • Rozgrzewanie przy starcie: załaduj wagę, wykonaj kilka suchych przebiegów, sprawdź alokacje off‑heap. Readiness=OK dopiero gdy cache’y są ciepłe.

Spring MVC, WebFlux czy wątki wirtualne

  • MVC (Tomcat/Jetty): prosto, przewidywalnie. Pilnuj maxThreads i małej kolejki accept. Inferencję wynieś do osobnego Executora.
  • WebFlux (Netty): dobry, gdy jest dużo czekania na IO (feature store). Sekcję CPU (model) zawsze zdejmij na boundedElastic/dedykowany Executor.
  • Wątki wirtualne (Java 21): świetne na IO, ale nie zamienią ciężkiej matmy w tańszą. Nadal limituj liczbę równoległych inferencji semaforem.

GC i off‑heap

  • Ustal Xmx po pomiarach, ale policz też off‑heap (JNI, ONNX/TensorFlow) i bufory. Requests/limits ustaw na „proces”, nie tylko JVM heap.
  • Wyłącz nadmierne tworzenie krótkich tablic/tensorów. Reużywaj bufory (ByteBuffer direct) tam, gdzie runtime to wspiera.
  • GC: G1/ZGC dobrze trzyma pauzy, ale i tak ważniejsza jest kontrola alokacji niż „strojenie pod magię”.

Hot‑reload i wycieki klas

  • Unikaj produkcyjnego hot‑reloada i dynamicznego podmienia klas modelu w tym samym ClassLoaderze – typowy powód rosnącego RAM.
  • Jeśli musisz przełączać modele w locie: ładuj w osobnym procesie albo w odseparowanym ClassLoaderze i czyść go deterministycznie.

Architektura wywołań: w‑JVM czy zewnętrzny serwer modelu

Decyzja rozstrzyga o kosztach, SLA i operacjach. Złota zasada: tam, gdzie liczy się spójność wdrożenia i najniższe opóźnienie – trzymaj model blisko żądania; gdy potrzebujesz elastyczności runtime’u i GPU – rozdziel.

Kiedy w‑JVM (ONNX/PMML/DJL)

  • SLA ostre na P95/P99, a model mieści się w RAM procesu (kilkaset MB–parę GB).
  • Featuryzacja w Javie lub w bibliotece współdzielonej. Brak egzotyki (niestandardowych warstw Pythona).
  • Niższy koszt operacyjny: jedna usługa, mniej sieci, prostsze trace’y. Minusy: mniejsza elastyczność GPU i trudniejsze roll‑backi samego runtime’u ML.

Kiedy osobny serwer modelu

  • Model w Pythonie z custom ops, zależny od wersji CUDA/NumPy; chcesz korzystać z TensorFlow Serving/TorchServe/BentoML/Ray Serve.
  • Dzielisz GPU między wiele usług lub potrzebujesz agresywnego batchingu.
  • Wadą jest dodatkowy hop sieciowy i dwa miejsca do skalowania. Zabezpiecz kontrakt (gRPC/Protobuf), krótki timeout i circuit breaker.

Praktyczne kryterium: jeśli łączny narzut sieć+serializacja zabiera >15% budżetu P95 – spróbuj in‑JVM. Jeśli zależności Pythona „dyktują warunki” albo GPU jest konieczne – wydziel.

Gdy model jest tylko w Pythonie: droga najmniejszego bólu

Nie próbuj na siłę przenosić wszystkiego do JVM, jeśli to zje budżet czasu. Lepiej zbudować cienki, szybki serwis modelu i wołać go z Springa po gRPC.

Serwis modelu w praktyce

  • Frontend: gRPC (HTTP/2, Protobuf). JSON zostaw do publicznego API, wewnątrz idź w binarny protokół.
  • Procesy zamiast wątków: GIL w Pythonie hamuje CPU‑bound. Ustaw kilka workerów (gunicorn/uvicorn workers) + pinning modelu na start.
  • Warmup: wczytaj wagę, wykonaj próbne inferencje, zablokuj endpoint health do czasu rozgrzania.
  • Batching: wbudowany micro‑batcher (TorchServe/TF Serving) lub prosty time‑window w serwisie.
  • Kontrakt interfejsu modelu: kompatybilność ponad tempo zmian

    Najwięcej błędów pojawia się przy „drobnych” zmianach wejścia/wyjścia. Kompatybilność kontraktu blokuje lawinę timeoutów i 500‑ek po wdrożeniu nowej wersji.

  • Protobuf/Avro zamiast „luźnego” JSON. Jeśli JSON – dołącz schemat (np. JSON Schema) i waliduj go w filtrze tuż po wejściu.
  • Dodawaj, nie usuwaj. Nowe pola ustawiaj jako opcjonalne z bezpiecznymi wartościami domyślnymi po stronie modelu.
  • Enumy wersjonuj ostrożnie: brakująca wartość po stronie klienta musi mapować się na „UNKNOWN”.
  • Numeruj kontrakt i model osobno: api_version (np. v1/v2) i model_version (np. 2024‑06‑15‑r17). Nie mieszaj tych pojęć w logach.
  • Dołącz hash schematu cech (feature_schema_hash) do żądania i metryk. Niezgodność = szybki sygnał o driftcie infrastruktur.
  • Małe pole do ekspansji: trzymaj „reserved fields” w schemacie pod przyszłe rozszerzenia, żeby uniknąć reorganizacji numeracji.

Autoskalowanie i orkiestracja w Kubernetes: jak nie przepłacić za P99

Skalowanie wyłącznie po CPU to prosty sposób na kosztowne i spóźnione reakcje. Skaluj po tym, co koreluje z opóźnieniem.

  • HPA po metrykach domenowych:
    • in_flight_inferences (liczba równoległych wywołań modelu na poda),
    • executor_queue_depth (długość kolejki inferencji),
    • P95 latencji (histogram z Micrometer/Prometheus – ostrożnie, bo „lepki” sygnał),
    • Dla asynchronicznych: lag kolejki (Kafka consumer lag/KEDA).
  • Warm‑up i drain:
    • startupProbe dopiero po rozgrzaniu modelu (kilka suchych przebiegów),
    • preStop hook + gracePeriod: zamknij przyjmowanie nowych żądań, poczekaj na dokończenie bieżących,
    • minReadySeconds i maxUnavailable=0 w rolloutach, żeby nie tracić pojemności.
  • Rozmieszczenie:
    • topologySpreadConstraints – unikniesz „skupienia” replik na jednym węźle,
    • PodDisruptionBudget, by utrzymać minimalną liczbę replik podczas aktualizacji/wędrówek węzłów,
    • initContainer do pobrania artefaktów modelu + weryfikacja checksumy; readOnlyRootFilesystem.
  • GPU:
    • Oddzielne Deploymenty CPU i GPU; nodeSelector/taints,
    • MIG na A100/H100: partycjonuj GPU dla przewidywalności,
    • Autoskalowanie po utilization GPU (DCGM Exporter) i średnim batch size, nie tylko po RPS,
    • Limituj memory fraction per proces – out‑of‑memory na GPU kończy się restartem poda.
  • Budżet kosztów:
    • Scale‑to‑zero dla asynchronicznych przepływów (KEDA),
    • Niższa klasa maszyn dla warstw HTTP/featuryzacji, „grube” węzły z GPU tylko na model.

Bezpieczeństwo i odporność żądań: ograniczenia zamiast heroizmu

Gdy wejście jest nieprzewidywalne, serwis powinien przewidywalnie odmawiać. Szybko i tanio.

  • Walidacja wejścia:
    • Maksymalny rozmiar żądania i pojedynczych pól (np. zdjęć) na bramie i w Spring Boot,
    • Jakarta Validation na DTO + filtry Content‑Type/charset,
    • Sanity checks na wartości cech (zakresy, brakujące pola) przed featuryzacją.
  • Timeouty i przerwania:
    • Globalny deadline w nagłówku; krótsze connect/read timeouts na każdy hop,
    • Circuit breakers per zależność (feature store, serwis modelu),
    • Retry tylko dla idempotentnych i z jitterem (unikniesz „thundering herd”).
  • Rate limiting:
    • Token bucket per tenant/klucz, 429 zamiast kolejek bez końca,
    • Oddzielne limity na endpointy wolne (batch) i szybkie (online).
  • Transport i klucze:
    • mTLS między usługami, rotacja certów automatycznie (SPIFFE/istio lub cert‑manager),
    • Minimalizacja PII w logach; tagowanie pól do anonimizacji już w DTO.

Obserwowalność i jakość predykcji w runtime

Bez metryk działasz „na ucho”. Z metrykami szybko widzisz, czy zawodzi model, czy infrastruktura.

  • Metryki techniczne:
    • p50/p95/p99 latencji per endpoint i per model_version,
    • in_flight, queue_depth, odsetek 4xx/5xx,
    • GC pauzy i allocation rate; dla GPU: utilization, memory used, batch size.
  • Metryki danych/modelu:
    • Rozkłady cech (średnia, odchylenie, percentyle) i odsetek NULL/NaN,