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

0
12
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.
  • We