- Opublikowano
Kiedy podwojenie pamięci serwera to zły fix: jak rozdzielenie serwisów zakończyło nasz kryzys OOM
- Autorzy
Ściana 2 GB
Prowadzimy platformę e-commerce na Vercelu — jedna aplikacja Nuxt obsługuje wszystko: publiczny sklep, API B2B serwujące katalogi produktów zewnętrznym integracjom, crony synchronizujące stany magazynowe z systemów źródłowych i stronę marketingową. Jedno repo, jeden deploy, jedna pula funkcji serverless.
API B2B serwuje katalogi produktów — pliki JSON i XML budowane z około 3000 produktów, każdy z ponad 30 relacjami. Zbudowanie jednego takiego katalogu wygląda w pamięci mniej więcej tak:
Prisma findMany (all products): ~200-300MB
Transform to response format: ~50-100MB
JSON.stringify the entire array: ~50-100MB ← doubles the allocation
gzip compression: ~20MB output (input still in scope)
Base64 encoding: ~27MB
─────────────────────────────────
Peak per rebuild: ~500MB
Na instancji serverless z 2 GB pojedynczy rebuild zjada ćwierć dostępnej pamięci. Brzmi znośnie — dopóki nie zrozumiesz, jak nowoczesne platformy serverless obsługują współbieżność.
Dlaczego współbieżność w serverless pogorszyła sprawę
Nasz hosting korzysta z modelu compute, który reużywa instancje Node.js między wieloma requestami HTTP. Zamiast wystartować świeżą instancję na każdy request (cold start), kieruje wiele równoległych requestów do tego samego działającego procesu, żeby obniżyć latencję i koszt.
Dla większości workloadów to świetne rozwiązanie. Ale oznacza, że pamięć się kumuluje. Jeśli trzech klientów B2B poprosi o katalogi produktów w tym samym momencie, w oknie inwalidacji cache'a, każdy z nich odpala rebuild:
Request 1: 500MB ─────────────────────────────────→
Request 2: 500MB ────────────────────────────→
Request 3: 500MB ───────────────────────→
↑
Total: 1.5GB on a 2GB instance
One more request = OOM crash
Nasz nadrzędny system magazynowy synchronizował dane ponad 20 razy na jedno uruchomienie crona, a każda synchronizacja unieważniała cache B2B. W tych oknach kilku klientów trafiało jednocześnie na nieaktualny cache i wszyscy odpalali równoległe rebuildy. Proces skakał do 2 GB i padał.
Prowizorka: podwojenie pamięci
Doraźny fix był oczywisty: podbić 2 GB do 4 GB. Zadziałało — crashe ustały. Ale koszt compute wzrósł dwukrotnie, a my wiedzieliśmy, że prawdziwy problem został nietknięty. Planowaliśmy urosnąć z 3000 do 10 000 produktów. Przy 10 tysiącach jeden rebuild zjadałby bliżej 1,5 GB. Nawet na instancji 4 GB dwa równoległe rebuildy oznaczałyby crash.
Kupowaliśmy czas, nie naprawialiśmy problemu.
Pięć dni nieudanych optymalizacji
Zanim doszliśmy do docelowego rozwiązania, spędziliśmy pięć dni na próbach wyoptymalizowania się z problemu. Każde podejście miało sens w teorii i poległo w praktyce. Zrozumienie, dlaczego poległy, jest bardziej wartościowe niż sama informacja, że poległy.
Podejście 1: deduplikacja w pamięci
Pomysł: użyć trzymanej w pamięci Map<string, Promise> do deduplikacji równoległych requestów. Jeśli request B przychodzi, gdy request A już przebudowuje katalog, B czeka na Promise od A zamiast startować własny rebuild.
const inflightRequests = new Map<string, Promise<Response>>();
// If already rebuilding, wait for existing Promise
if (inflightRequests.has(cacheKey)) {
return inflightRequests.get(cacheKey);
}
Dlaczego nie zadziałało: model współbieżności nie współdzieli stanu sterty JavaScriptu między równoległymi requestami tak, jak zakładaliśmy. Każdy request dostaje własny kontekst wykonania. Map z requestu A jest niewidoczna dla requestu B. Deduplikacja w ogóle nie zachodzi — oba requesty przebudowują katalog niezależnie.
Podejście 2: flagi rebuildu w Redisie + polling
Pomysł: skoro stan w pamięci nie jest współdzielony, użyć Redisa jako rozproszonej koordynacji. Ustawiamy flagę w Redisie w momencie startu rebuildu, a pozostałe requesty pollują, aż flaga zniknie.
Request 1: SET rebuild:products = true (TTL 30s) → rebuild → clear flag
Request 2: sees flag → sleep 3s → check → sleep 3s → check → sleep 3s → timeout
Dlaczego nie zadziałało: polling dokładał do 9 sekund latencji (3 próby × 3 sekundy uśpienia). Jeśli request wykonujący rebuild padł, flaga wisiała przez 30 sekund — a każdy inny request odczekiwał pełne 9 sekund, żeby i tak się poddać i odpalić własny rebuild. Złożoność rosła błyskawicznie, latencja była nie do zaakceptowania, a kumulacji pamięci to i tak nie zapobiegało — tylko ją opóźniało.
Podejście 3: kompresja strumieniowa
Pomysł: zamiast JSON.stringify(entireArray) i dopiero potem gzip(), wpychać produkty pojedynczo do kompresora strumieniowego i uniknąć gigantycznej alokacji pośredniego stringa.
const deflator = new pako.Deflate({ gzip: true, level: 6 });
deflator.push('{"data":[', false);
for (const product of products) {
deflator.push(JSON.stringify(product), false);
}
deflator.push(']}', true);
Efekt: zadziałało — szczyt zużycia pamięci spadł o jakieś 100 MB. Ale 400 MB to wciąż za dużo, gdy przemnożysz to przez równoległe requesty. To była optymalizacja na złej warstwie. Oszczędność 100 MB na rebuildzie nie ma znaczenia, gdy problemem są trzy rebuildy dziejące się jednocześnie.
Podejście 4: stale-while-revalidate
Pomysł: zawsze natychmiast zwracać nieaktualną odpowiedź z cache'a i odpalać rebuild w tle. Klienci dostają szybkie odpowiedzi, a cache pozostaje w miarę świeży.
Dlaczego nie zadziałało: SWR sprawdza się na endpointach zdominowanych przez odczyty, gdzie lekka nieaktualność jest akceptowalna. Ale klienci B2B podejmujący decyzje zakupowe potrzebowali aktualnych stanów magazynowych. Co ważniejsze, rebuild w tle nadal zjadał 500 MB — przeniósł tylko presję na pamięć ze ścieżki requestu do tła. OOM dalej występował, tyle że asynchronicznie.
Rewert
Po pięciu dniach narastającej złożoności — mapy deduplikacji, flagi w Redisie, kompresja strumieniowa, SWR — mieliśmy kod trudniejszy do zrozumienia, miejscami wolniejszy i wciąż podatny na skoki pamięci. Zrewertowaliśmy wszystko w jedno popołudnie. Dziewięć revert commitów jeden po drugim.
Nieudane podejścia nauczyły nas czegoś ważnego: z problemu izolacji zasobów nie da się wyoptymalizować. Nieważne, jak sprytny jest kod — jeśli kilka pamięciożernych workloadów dzieli jedną ograniczoną pulę pamięci, skoki prędzej czy później przekroczą limit. Rozwiązanie musiało być architektoniczne, nie algorytmiczne.
Drugi problem z pamięcią, o którym nie wiedzieliśmy
Walcząc z pamięcią przy B2B, odkryliśmy osobny wyciek pamięci w naszym setupie i18n (internacjonalizacji), który tylko pogarszał sprawę.
Nasz serwis obsługuje 11 języków. Każda strona używała useSeoMeta() z bezpośrednimi wywołaniami funkcji tłumaczącej:
// This creates reactive tracking dependencies into the i18n message store
useSeoMeta({
title: t('seo.title'),
description: t('seo.description'),
ogTitle: t('seo.title'),
ogDescription: t('seo.description'),
})
Po stronie klienta jest to w porządku — Vue sprząta reaktywność przy odmontowaniu komponentu. Ale przy renderowaniu po stronie serwera te reaktywne zależności nie były poprawnie zwalniane. Każde wyrenderowanie strony wyciekało około 11 MB osieroconych reaktywnych proxy do store'a wiadomości i18n.
Przypomnę: platforma serverless reużywa instancje między requestami. Request 1 wycieka 11 MB. Request 2 kolejne 11 MB. Po 150 requestach masz wyciek 1,6 GB — i crash OOM nie ma już nic wspólnego z katalogami B2B.
Fix polegał na opakowaniu wywołań tłumaczeń w funkcje strzałkowe, które Vue potrafi poprawnie śledzić i sprzątać:
// Arrow functions create computed getters that Vue disposes properly
useSeoMeta({
title: () => t('seo.title'),
description: () => t('seo.description'),
ogTitle: () => t('seo.title'),
ogDescription: () => t('seo.description'),
})
Znaleźliśmy i poprawiliśmy ten wzorzec na 43 stronach (174 bezpośrednie wywołania t()). Ale to odkrycie tylko wzmocniło główny wniosek: gdy wszystko działa w jednym procesie, każdy problem z pamięcią dotyka każdego workloadu. Wyciek w i18n zjadał budżet potrzebny rebuildom B2B. Każdy z problemów pogarszał ten drugi.
Właściwy fix: trzy serwisy zamiast jednego
Kiedy pogodziliśmy się z tym, że problemem jest izolacja zasobów, rozwiązanie stało się proste: rozbić monolit na trzy niezależne serwisy, każdy z własnym przydziałem pamięci.
Before:
┌───────────────────────────────────┐
│ Nuxt Monolith (4GB) │
│ Storefront + B2B API + Crons │
│ Peak: 500MB API + 11MB/req leak │
│ + cron spikes │
└───────────────────────────────────┘
After:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Nuxt App │ │ Nitro API │ │ Nitro Crons │
│ 2GB │ │ 2GB │ │ 2GB │
│ Storefront │ │ B2B Feeds │ │ Data Sync │
│ + Dashboard │ │ Only │ │ Only │
└──────────────┘ └──────────────┘ └──────────────┘
API B2B stało się samodzielną aplikacją Nitro — ten sam framework (Nitro to silnik serwerowy Nuxta), ale bez pełnego narzutu Nuxta: SSR Vue, routingu, kontekstu i18n i renderowania komponentów. Same handlery HTTP, uwierzytelnianie i serializacja danych.
Crony przeniosły się do kolejnej samodzielnej aplikacji Nitro. Te zadania zużywały średnio 961 MB podczas wykonania, ze szczytami 1,3 GB — i konkurowały o tę samą pulę pamięci co requesty użytkowników.
Przy głównej aplikacji Nuxt zostały sklep i dashboard. Bez rebuildów B2B i skoków z cronów zjadających jej pamięć spokojnie zeszła z powrotem do 2 GB w tierze Standard.
Matematyka, która wszystko wyjaśniła
Przed (monolit):
- 1 × instancja 4 GB = 4 GB łącznego przydziału
- rebuild B2B (500 MB) + skok crona (1,3 GB) + sklep + wyciek i18n = walka o te same 4 GB
- Koszt: 2× cennik standardowy
Po (trzy serwisy):
- 3 × instancja 2 GB = 6 GB łącznego przydziału
- Ale: każdy workload konkuruje wyłącznie sam ze sobą
- B2B dostaje 2 GB — bezpiecznie obsłuży 3 równoległe rebuildy
- Crony dostają 2 GB — szczyt 1,3 GB ma komfortowy zapas
- Sklep dostaje 2 GB — po zdjęciu ciężkich workloadów jest lekki
- Koszt: 3× cennik standardowy za instancję, ale każda instancja jest w tierze standard (nie performance)
Koszt pojedynczej instancji spadł. Łączny przydział pamięci wzrósł. A crashe ustały całkowicie, bo żaden workload nie mógł już naruszyć budżetu pamięci innego.
Co się popsuło przy podziale
Rozdzielenie serwisów nie jest darmowe. Oto co poszło nie tak i jak naprawiliśmy każdy z problemów.
Zmienne środowiskowe zniknęły na produkcji
W Nuxcie useRuntimeConfig() automatycznie mapuje w runtimie zmienne środowiskowe z prefiksem NUXT_. Mieliśmy ich na hostingu dziesiątki. Po przejściu na samodzielne Nitro (bez Nuxta) to mapowanie przestało działać.
Źródło problemu: samodzielne Nitro wypieka wartości runtimeConfig na etapie builda, przez Rollupa. W runtimie czyta wyłącznie nadpisania z prefiksem NITRO_. Nasze zmienne z prefiksem NUXT_ były niewidoczne.
Spędziliśmy popołudnie, dorzucając na produkcję tymczasowe endpointy diagnostyczne i porównując wartości z builda z tymi z runtime'u, zanim zrozumieliśmy różnicę między frameworkami. Fixem okazała się walidacja oparta na schemacie, czytająca process.env bezpośrednio:
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
CDN_SECURITY_KEY: z.string().min(1),
SYNC_TOKEN: z.string().min(1),
},
runtimeEnv: process.env,
});
Jeśli którejś zmiennej brakuje albo jest pusta, aplikacja pada przy starcie z czytelnym błędem — a nie po cichu na produkcji, gdy klient zgłosi popsute odpowiedzi.
Ścieżki importów ESM przestały działać na serverless
Importy, które działały w Nuxcie, wywalały się w samodzielnym Nitro na serverless:
// Works in Nuxt (bundler resolves it)
import sortBy from 'lodash-es/sortBy';
// Required in standalone Nitro on some serverless runtimes
import sortBy from 'lodash-es/sortBy.js';
Bundler Nuxta rozwiązuje gołe importy na etapie builda. Samodzielne Nitro na części runtime'ów serverless korzysta z natywnego rozwiązywania ESM w Node, które wymaga jawnych rozszerzeń plików. Lokalnie nie pojawiło się to nigdy, przeszło wszystkie testy i ujawniło się dopiero na produkcji.
Starzy klienci potrzebowali wstecznej kompatybilności
Klienci B2B mieli zahardkodowane adresy API wskazujące na naszą główną domenę. Nie mogliśmy im tego zepsuć z dnia na dzień. Rozwiązanie: główna aplikacja Nuxt zachowała stare endpointy, ale logikę handlerów zastąpiła redirectami 307:
export default defineEventHandler((event) => {
const query = event.node.req.url?.split('?')[1] ?? '';
return sendRedirect(event,
`https://api.example.com/api/products${query ? `?${query}` : ''}`,
307
);
});
Każdy klient HTTP podąża za redirectami 307 automatycznie. Zero zmian w kodzie po stronie klienta. Główna aplikacja przestała w ogóle dotykać bajtów API.
Bonus: redirecty
Wydzielenie API pozwoliło nam też naprawić problem proxowania odpowiedzi, od którego zaczęło się całe śledztwo. Zamiast pobierać pliki ze storage'u CDN i streamować bajty z powrotem do klientów (co powodowało niezgodności Content-Length i ucięte odpowiedzi), teraz uwierzytelniamy request, generujemy podpisany URL do CDN i robimy redirect:
Before: Client → API (auth + proxy 2MB of bytes) → Response
After: Client → API (auth + generate signed URL) → 307 → CDN (direct download)
Serwer API zajmuje się uwierzytelnianiem i kontrolą dostępu, po czym schodzi z drogi. CDN zajmuje się dostarczaniem treści, kompresją i dystrybucją na edge. Koniec z niezgodnymi nagłówkami. Koniec z uciętymi katalogami. Koniec z płaceniem za compute serverless tylko po to, żeby przekazywać bajty między dwoma innymi systemami.
Logi produkcyjne po przełączeniu pokazały 83,4% trafień w cache CDN, przy czym trafienia kończyły się 24 razy szybciej niż zimne missy (mediana 58 ms wobec 471 ms).
Kiedy dzielić monolit
Popełniliśmy dwa błędy w kwestii timingu: powinniśmy byli podzielić monolit wcześniej i wcześniej odpuścić optymalizowanie wewnątrz niego.
Dziel, gdy widzisz te sygnały:
- Rywalizacja o pamięć między workloadami — skok jednej funkcjonalności wywala drugą
- Różne wymagania niezawodnościowe — API potrzebuje 99,9% uptime'u, a crony mogą sobie pozwolić na porażki
- Koszty skalowania rosną nieliniowo — podbicie z 2 GB do 4 GB podwaja koszt, a kupuje tylko czas
- Debugowanie zamienia się w zgadywanie — logi błędów z trzech różnych workloadów przeplatane w jednym strumieniu
Nie dziel, gdy:
- Twój monolit działa dobrze, a koszty są rozsądne
- Dzielisz z powodów organizacyjnych (granice zespołów), a nie technicznych
- Nie masz gotowego monitoringu per serwis
A gdy już dzielisz:
- Zachowaj wsteczną kompatybilność redirectami, nie breaking changes
- Waliduj konfigurację przy starcie, nie w czasie obsługi requestu
- Testuj na docelowej platformie deploymentu, nie tylko lokalnie
- Bezlitośnie usuwaj martwy kod — nasza główna aplikacja schudła po migracji o ponad 6300 linii
Wnioski
Odruch przy uderzeniu w limit pamięci jest jeden: optymalizować. Mocniej kompresować, mądrzej cache'ować, deduplikować requesty. Poświęciliśmy temu odruchowi pięć dni. Nic nie zadziałało, bo rozwiązywaliśmy zły problem.
Problemem nie było to, że nasz kod zużywa za dużo pamięci. Problemem było to, że niepowiązane ze sobą workloady dzieliły jedną granicę pamięci. Rebuild katalogu B2B nie powinien wywalać sklepu. Cron nie powinien spowalniać requestów użytkowników. Wyciek pamięci w i18n nie powinien zjadać budżetu potrzebnego API.
Kiedy przeformułowaliśmy pytanie „jak zużywać mniej pamięci?” na „dlaczego te workloady w ogóle dzielą pamięć?”, wdrożenie rozwiązania zajęło dwa dni i od razu zamknęło problemy, z którymi walczyliśmy tygodniami.
Czasem właściwą optymalizacją nie jest lepszy kod — tylko lepsze granice.