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
| Wariant | Zalety | Wady | Kiedy wybrać |
|---|---|---|---|
| Inferencja w JVM | Niskie opóźnienia (brak hopa sieciowego), prostszy deployment, jedna obserwowalność | Ograniczenia bibliotek ML w JVM, trudniejsza obsługa GPU, większe binaria | Modele klasyczne (tree/linear), ONNX/PMML, twarde SLA latencji, brak GPU |
| Serwer modelu | Gotowe optymalizacje (batching, GPU), niezależny cykl życia modelu | Dodatkowy hop sieciowy, złożone operowanie dwoma serwisami | Ciężkie modele, potrzeba GPU, wiele języków/modeli |
| Python microservice | Szybka integracja z ekosystemem DS, duża elastyczność | Operacyjny narzut, uwaga na GIL/uvicorn workers, spójność obserwowalności | Model 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.

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.

- 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
