Kuloodporna produkcja
Dobry system ≠ dobry kod. Dobry system to taki, który jest w stanie przetrwać, gdy coś pójdzie nie po Twojej myśli.
Postanowiłem spisać podstawowe praktyki, które na ten moment znam - to pobieżne wprowadzenie do tematu, nie wyczerpujący przewodnik.
Monitoring
Po co się męczyć zgadując, co poszło nie tak, skoro można to po prostu sprawdzić? Monitoring to podstawa. Bez niego jesteś jak kierowca jadący nocą bez świateł - możesz mieć najlepszy samochód na świecie, ale i tak nie dojedziesz do celu.
Logi
Logi to oczy systemu. Dzięki nim możesz zobaczyć, co faktycznie stało się w Twojej aplikacji. Upewnij się, że logujesz ważne dla biznesu informacje, a nie tylko błędy.
Preferuj structured logging, czyli logi w formacie JSON. Dzięki temu łatwiej będzie je przeszukiwać i analizować.
{
"timestamp": "2026-05-08T12:34:56Z",
"level": "ERROR",
"message": "Nie można połączyć się z bazą danych",
"service": "user-service",
"errorCode": "DB_CONN_ERR"
}Warto rozróżnić logi techniczne od eventów biznesowych, które opisują realne zdarzenia w systemie (np. payment_failed, user_registered). Te drugie są często wykorzystywane nie tylko do debugowania, ale też do analityki i monitorowania procesów biznesowych.
Metryki
Metryki to agregowane dane o stanie systemu w czasie, które mówią Ci, jak działa Twoja aplikacja. Mogą to być czasy odpowiedzi, liczba błędów, zużycie CPU czy pamięci. Regularne monitorowanie metryk pozwala szybko reagować na problemy, zanim staną się krytyczne na przykład memory leaki, disk usage.
Alerty
Alerty to najważniejszy element monitoringu. To one informują Cię, że coś poszło fatalnie nie tak.
Przykładowo możesz ustawić alert, gdy baza danych jest niedostępna, albo gdy czas odpowiedzi przekracza określony próg. Dzięki temu możesz szybko zareagować i naprawić problem, zanim użytkownicy zaczną narzekać.
Należy uważać, żeby nie przesadzić z alertami. Zbyt wiele alertów może prowadzić do tzw. “alert fatigue”, czyli sytuacji, w której zaczynasz ignorować wszystkie alerty, bo jest ich za dużo.
Tracing
Pozwala na śledzenie przepływu żądań przez różne usługi w systemie. W przeciwieństwie do logów, które pokazują co się stało w jednym miejscu, tracing łączy zdarzenia z wielu serwisów w jeden spójny obraz. Dzięki temu wiesz nie tylko że coś poszło nie tak, ale gdzie i dlaczego, np. który serwis dodał 2 sekundy latency do całego żądania.
Popularne narzędzia to OpenTelemetry, Jaeger czy Datadog APM.
Wersjonowanie aplikacji i rollbacki
Wersjonowanie jest kluczowe, gdy chcesz zapewnić stabilność produkcji. Dzięki temu jedną komendą jesteś w stanie przywrócić poprzednią, działającą wersję aplikacji, gdy coś się wywali.
Rollback często jest ważniejszy niż szybki hotfix. Dlaczego? Bo hotfixy często nie fixują wszystkiego, a wprowadzają dodatkowe ryzyko. Rollback pozwala szybko wrócić do stabilnej wersji, a następnie spokojnie zająć się naprawą problemu.
# build + tag
git tag -a v1.2.3 -m "Release 1.2.3"
# CI buduje image:
docker build -t myapp:v1.2.3 .
# deploy
./deploy.sh v1.2.3
# bug...
./rollback.sh v1.2.2
# fix
git tag -a v1.2.4 -m "Fix critical bug"
docker build -t myapp:v1.2.4 .
./deploy.sh v1.2.4Healthchecks
System musi powiadamiać, że “żyje”. Healthchecki to proste endpointy, które zwracają status aplikacji. Dzięki nim system orkiestrujący (np. Kubernetes) wie, czy Twoja aplikacja jest zdrowa i może przyjmować ruch.
curl -f http://localhost:8080/health || echo "Unhealthy"W praktyce healthchecki dzieli się na kilka rodzajów:
- liveness probe - sprawdza, czy aplikacja w ogóle działa (czy proces nie utknął / nie padł)
- readiness probe - sprawdza, czy aplikacja jest gotowa do obsługi ruchu
- dependency check - który weryfikuje kluczowe zależności, takie jak baza danych, cache czy kolejki
Dzięki temu system nie tylko wie, czy aplikacja „żyje”, ale też czy faktycznie może obsługiwać ruch. To pozwala uniknąć sytuacji, w której usługa działa, ale jest w praktyce niesprawna.
Mechanizm ten pozwala na automatyczne restartowanie aplikacji, gdy nie działa poprawnie, co zwiększa jej odporność na awarie.
Jak nie ubić bazy przy migracji?
Migracje bazy danych to jeden z najbardziej newralgicznych momentów w życiu aplikacji. Zła migracja może spowodować, że Twoja baza danych stanie się niedostępna, a użytkownicy nie będą mogli korzystać z aplikacji.
Aby tego uniknąć, warto stosować kilka praktyk:
- Nie usuwaj pól od razu - zamiast tego oznacz je jako deprecated i pozostaw przez pewien czas. Najpierw wdroż nową wersję aplikacji, upewnij się, że żaden komponent nie korzysta już ze starego pola, a dopiero później usuń je z bazy. Dzięki temu rollback starej wersji aplikacji nadal będzie możliwy.
- Dbaj o kompatybilność wsteczną - deploy aplikacji i migracja bazy często nie dzieją się jednocześnie. System powinien działać poprawnie zarówno ze starą, jak i nową wersją schemy przez pewien czas.
- Testuj migracje na stagingu - przed wdrożeniem na produkcję przetestuj migracje na środowisku jak najbardziej zbliżonym do produkcyjnego. Pozwala to wykryć problemy z wydajnością, lockami lub błędami w danych.
- Unikaj dużych migracji - zamiast jednego dużego „big bangu”, lepiej wprowadzać zmiany etapami. Mniejsze migracje są łatwiejsze do monitorowania, rollbacku i debugowania.
Mechanizm retry
Po co Ci retry? Bo nie zawsze używasz swojego API. Załóżmy, że Twoja aplikacja korzysta z API OpenAI. Co się stanie, gdy serwery OpenAI będą przeciążone i zaczną zwracać błędy 503? Jeśli nie masz mechanizmu retry, to Twoja aplikacja będzie po prostu zwracać błędy użytkownikom. Może to przynieść straty biznesowe i zniechęcić użytkowników do korzystania z Twojej aplikacji.
Jak to zaimplementować? W node najprostszy sposób, to użycie biblioteki p-retry:
import pRetry from 'p-retry';
await pRetry(() => checkDatabaseOnce(dbPool, timeoutMs), {
retries: 4, // Maksymalnie 4 próby
minTimeout: 100, // Pierwsza próba po 100ms
maxTimeout: 3000,// Maksymalny odstęp między próbami to 3 sekundy
factor: 2, // Każda kolejna próba czeka 2x dłużej (100ms, 200ms, 400ms...)
// p-retry automatycznie dodaje do powyższego czasu losowy Jitter (około +/- 10-20%),
// dzięki czemu rozbija synchronizację jednoczesnych strzałów z wielu instancji aplikacji.
onFailedAttempt: (error) => {
logger.warn(
{
err: error,
attempt: error.attemptNumber,
retriesLeft: error.retriesLeft,
},
"Database readiness attempt failed. Retrying with jitter...",
);
},
});Fasady i adaptery na zewnętrzne zależności
Załóżmy, że Twoja aplikacja korzysta z loggera o nazwie “SuperLogger”. Co się stanie, gdy autor biblioteki przestanie ją wspierać i zostanie w nim wykryta podatność? Albo gdy pojawi się nowa, lepsza biblioteka, która będzie oferować więcej funkcji? Będziesz musiał przepisać cały kod, który korzysta z “SuperLoggera”, żeby przejść na nową bibliotekę.
Zamiast tego, możesz stworzyć własną fasadę, która będzie opakowywać “SuperLoggera”. Dzięki temu, gdy będziesz chciał zmienić bibliotekę, wystarczy, że zmodyfikujesz tylko tę fasadę, a reszta Twojego kodu pozostanie nienaruszona.
import { SuperLogger } from "super-logger";
export interface Logger {
log: (message: string, metadata?: Record<string, unknown>) => void;
error: (message: string, metadata?: Record<string, unknown>) => void;
}
export const logger: Logger = {
log: (message, metadata) => {
// tutaj możesz użyć dowolnej biblioteki logującej
SuperLogger.log(message, metadata);
},
error: (message, metadata) => {
SuperLogger.error(message, metadata);
},
}W tym momencie możesz łatwiej przejść na inną bibliotekę, wystarczy, że zmienisz implementację w logger.ts, a reszta Twojego kodu będzie nadal korzystać z tej samej fasady.
import { SuperLogger } from "super-logger";
import { AnotherLogger } from "another-logger";
// nowy logger, który może miec inne API, ale fasada pozostaje ta sama
export interface Logger {
log: (message: string, metadata?: Record<string, unknown>) => void;
error: (message: string, metadata?: Record<string, unknown>) => void;
}
export const logger: Logger = {
log: (message, metadata) => {
SuperLogger.log(message, metadata);
AnotherLogger.info({
message,
...metadata
});
},
error: (message, metadata) => {
SuperLogger.error(message, metadata);
AnotherLogger.error({
message,
...metadata
});
},
}Fasada to taki “kontrakt”, który mówi: “nie ważne, jakiego loggera używasz, ważne, żebyś korzystał z tej fasady”. Dzięki temu masz większą kontrolę nad tym, jak Twoja aplikacja korzysta z zewnętrznych zależności i możesz łatwiej zarządzać zmianami w tych zależnościach.
Graceful shutdown
Kuloodporna produkcja to nie tylko radzenie sobie z błędami, ale też umiejętność poprawnego zamykania aplikacji, bez utraty danych czy przerywania obsługi aktywnych użytkowników.
Podczas shutdownu aplikacja powinna przestać przyjmować nowe żądania, dokończyć obsługę aktualnych, zamknąć połączenia z bazą i innymi zasobami, a dopiero potem zakończyć działanie.
W Node.js wygląda to mniej więcej tak:
import { Server } from 'http';
const server: Server = app.listen(8080);
function shutdown(signal: string) {
logger.info(`Otrzymano ${signal}. Rozpoczynam graceful shutdown...`);
// 1. Hard kill switch - jeśli system orkiestracji (np. K8s) dał nam 30 sekund,
// my dajemy sobie 25 sekund na sprzątanie. Jeśli nie zdążymy, ubijamy proces sami.
const timeout = setTimeout(() => {
logger.error("Graceful shutdown przekroczył limit czasu! Wymuszam zamknięcie...");
process.exit(1);
}, 25000);
// unref sprawia, że ten timer nie trzyma procesu Node.js przy życiu, jeśli wszystko inne się zamknie
timeout.unref();
// 2. Przestajemy przyjmować nowy ruch HTTP (Kubernetes w tym czasie usuwa nas z Load Balancera)
server.close(async (err) => {
if (err) {
logger.error({ err }, "Błąd podczas zamykania serwera HTTP");
process.exit(1);
}
logger.info("Serwer HTTP zamknięty (brak aktywnych połączeń).");
process.exit(0);
});
// 3. RÓWNOLEGLE zamykamy połączenia z bazami danych, kolejkami i pamięcią cache.
// Nie czekamy na zamknięcie serwera HTTP, bo wiszący klient HTTP zablokowałby zamknięcie bazy!
(async () => {
try {
logger.info("Zamykanie połączeń z bazą danych i zasobami...");
// Tutaj czyścisz swoje zasoby równolegle
await Promise.all([
db.disconnect(),
redis.quit(),
rabbitMQ.close()
]);
logger.info("Wszystkie zasoby zostały bezpiecznie zwolnione.");
} catch (err) {
logger.error({ err }, "Błąd podczas zamykania zasobów");
process.exit(1);
}
})();
}
// Nasłuchiwanie na sygnały systemowe
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));Podsumowanie
Wszystko sprowadza się do jednej rzeczy: nie bać się produkcji. To jest miejsce, w którym powinieneś być przygotowany na to, że coś pójdzie nie tak, i mieć mechanizmy, które pozwolą Ci szybko zareagować i naprawić problem.
Lepiej jest mieć system, który błyskawicznie może wrócić do poprzedniego stanu, niż system, który jest “idealny”, ale gdy coś pójdzie nie tak, to jest kompletnie nieprzydatny.