Strona główna / Artykuły

Dlaczego kod backendowy, który działa lokalnie, zawodzi pod rzeczywistym obciążeniem w środowisku produkcyjnym.

Praktyczny przegląd założeń dotyczących środowiska, bazy danych, bezpieczeństwa i niezawodności, które sprawnie działają na localhost, ale powodują przerwy w dostępności, gdy napływa rzeczywisty ruch.

Wydawca: REACTAPP.TOP39 min czytania8945 słów

Źródła: 10 Backend Mistakes That Work Perfectly on localhost but Break in Production

Działa doskonale na twoim komputerze. Ale czy wytrzyma, gdy pojawi się rzeczywisty ruch? Oto 10 błędów w warstwie backendowej, które mogą potajemnie przekształcić sprawnie działającą aplikację w katastrofę produkcyjną.

Lokalnie wszystko wydaje się w porządku. Prośby są obsługiwane szybko. Zapytania zwracają właściwe dane. Logowanie działa. Docker uruchamia się bez problemów. Testy przechodzą pomyślnie. Następnie wdrażasz aplikację, otwierasz jej publiczną adresację URL i coś się psuje.

Czasami aplikacja odmawia uruchomienia.

Czasami użytkownicy tracą dostęp do swoich kont.

Czasami baza danych nagle zaczyna działać bardzo wolno.

Czasami wszystko działa sprawnie przez kilka minut, zanim zaczynają się gromadzić błędy połączenia.

A czasami usługa wygląda na zupełnie sprawną na pierwszy rzut oka, podczas gdy w rzeczywistości powoduje ukryte szkody.

To, co jest frustrujące, to fakt, że większość tych awarii nie ma nic wspólnego z skomplikowanymi algorytmami.

Pochodzą one z małych, nierozważonych założeń.

Założenia takie jak:

  • ">Nie będziemy mieli aż tak dużego ruchu.
  • ">Baza danych znajduje się na tym samym serwerze.
  • ">To zmienne środowiskowe zawsze będą ustawione.
  • ">Jedno połączenie z bazą danych wystarczy.
  • ">Zapytania zawsze będą realizowane szybko.
  • ">Pliki zapisane na dysku nadal tam będą później.
  • ">Nikt nie będzie intensywnie korzystać z tego punktu końcowego.
  • ">API będzie zawsze otrzymywać prawidłowe dane wejściowe.
  • ">Kontener nigdy nie będzie miał mało pamięci.
  • ">Działający proces oznacza zdrową aplikację.

Po Twojej lokalnej konfiguracji jesteś chroniony przed większością tych problemów.

W środowisku produkcyjnym nie ma takiej ochrony.

To artykuł omawiający 10 błędów w warstwie backendowej, które zazwyczaj działają bez zarzutu na localhost, ale powodują poważne problemy po wdrożeniu.

Żaden z nich nie jest abstrakcyjny ani hipotetyczny.

Są to rodzaj szybkich rozwiązań, które wydają się całkowicie uzasadnione podczas intensywnego tworzenia nowej funkcjonalności.

Dokładnie to sprawia, że są ryzykowne.

1. Traktowanie zmiennych środowiskowych jak opcjonalnej konfiguracji

Zacznijmy od jednego z najczęstszych błędów.

Załóżmy, że budujesz API na swoim laptopie.

Konfiguracja połączenia z bazą danych wygląda tak:

DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
JWT_SECRET=my-super-secret-key
PORT=5000

Aplikacja uruchamia się bez problemów.

Nic nie wydaje się nie tak.

Rozwijasz ją i wdrażasz do środowiska produkcyjnego.

A potem, nagle:

Error: password authentication failed

Lub może:

JWT_SECRET is undefined

Lub:

ECONNREFUSED 127.0.0.1:5432

Pierwszą reakcją może być obwinianie platformy hostingowej.

Rzadko jest to rzeczywista przyczyna.

W rzeczywistości twoja aplikacja korzystała bez świadomości z ustawień, które istniały tylko na twoim komputerze deweloperskim.

Pułapka localhost

Lokalnie możesz mieć coś takiego:

DATABASE_URL=postgresql://localhost/myapp

Tymczasem w środowisku produkcyjnym konfiguracja wygląda tak:

DATABASE_URL=postgresql://db.internal:5432/myapp

To są dwa zupełnie różne ustawienia.

Sytuacja pogarsza się, gdy deweloperzy włączają konfigurację bezpośrednio do kodu źródłowego:

const dbHost = "localhost";
const dbUser = "postgres";
const dbPassword = "password";

To działa doskonale.

Aż nagle przestaje.

Środowisko produkcyjne to nie twój laptop.

Baza danych może znajdować się na:

  • oddzielnym serwerze
  • innym kontenerze
  • usłudze zarządzanej bazą danych
  • prywatnej sieci wewnętrznej
  • innej strefie dostępności
  • zupełnie innym dostawcy chmury

Twoja aplikacja potrzebuje sposobu na ustalenie, gdzie dokładnie znajduje się ta baza danych.

Konfiguracja powinna pochodzić z środowiska

Bardziej odporny wzorzec wygląda tak:

const config = {
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
  port: process.env.PORT || 5000
};

Jednak wciąż kryje się tu subtelniejszy błąd.

Samo pobieranie wartości z zmiennych środowiskowych nie wystarcza.

Należy również sprawdzić, czy one rzeczywiście istnieją.

Naprzимер:

const requiredEnv = [
  "DATABASE_URL",  "JWT_SECRET"
];
for (const key of requiredEnv) {
  if (!process.env[key]) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
}

Teraz aplikacja odmawia uruchomienia, jeśli brakuje niezbędnego ustawienia.

Taki wczesny błąd jest właściwie dobrą rzeczą.

Porównaj to ze scenariuszem, w którym aplikacja uruchamia się normalnie i zaczyna działać niepoprawnie dopiero w momencie, gdy pierwszy użytkownik próbuje się zalogować.

Widzenie czegoś takiego:

Missing required environment variable: JWT_SECRET

w trakcie wdrażania jest o wiele lepsze niż dowiadując się o tym z gniewnego zgłoszenia obsługi klienta.

Nie podawaj w tajemnicy niebezpiecznych domyślnych wartości

To skrócenie jest kuszące:

const jwtSecret = process.env.JWT_SECRET || "secret";

Ułatwia to pracę podczas lokalnego rozwoju.

To również otwiera drzwi przed luką bezpieczeństwa.

Załóżmy, że zapomnisz ustawić JWT_SECRET w środowisku produkcyjnym.

Aplikacja nie zakończy się błędem.

Będzie po prostu cicho wracać do następującego rozwiązania:

secret

Teraz twoje systemy produkcyjne podpisują tokeny wartością, którą każdy może odgadnąć.

Bardziej bezpieczna wersja wygląda tak:

if (!process.env.JWT_SECRET) {
  throw new Error("JWT_SECRET must be configured");
}

Wartości awaryjne mają sens w przypadku niegroźnych ustawień.

Są jednak zagrożeniem w kwestiach związanych z bezpieczeństwem.

Wzorcem, który warto przyjąć, jest traktowanie konfiguracji niemal jak wymaganej zależności, której musi zostać spełniony warunek przed uruchomieniem czegokolwiek innego.

Naprzимер:

function loadConfig() {
  if (!process.env.DATABASE_URL) {
    throw new Error("DATABASE_URL is required");
  }
  if (!process.env.JWT_SECRET) {
    throw new Error("JWT_SECRET is required");
  }
  return {
    databaseUrl: process.env.DATABASE_URL,
    jwtSecret: process.env.JWT_SECRET,
    port: Number(process.env.PORT || 5000)
  };
}
const config = loadConfig();

To daje twojej aplikacji jasne zasady funkcjonowania.

Lub konfiguracja jest prawidłowa, lub proces w ogóle nie zostanie uruchomiony.

Ta kompromisowa decyzja jest w pełni uzasadniona.

2. Wskazywanie na localhost dla usług znajdujących się poza Twoją aplikacją

To błąd zasługuje na osobne uwzględnienie, ponieważ pojawia się ciągle, gdy kod opuszcza komputer programisty.

Załóżmy, że Twój backend komunikuje się z PostgreSQL.

Na Twoim laptopie konfiguracja wygląda tak:

Backend → localhost:5432 → PostgreSQL

I to po prostu działa.

Następnie zkonteneryzujesz backend za pomocą Dockera.

PostgreSQL teraz działa w oddzielnym kontenerze.

Możesz pozostawić konfigurację bez zmian:

DATABASE_HOST=localhost

A wtedy otrzymujesz:

connection refused

Co się dzieje?

localhost nie odnosi się już do tego, co myślisz.

Z wnętrza kontenera:

localhost

odnosi się do:

tego konkretnego kontenera.

A nie do:

kontenera, w którym działa Twoja baza danych.

Niezrozumienie tej jednej różnicy jest przyczyną dużej części problemów z wdrażaniem Dockera.

Sieciowanie w Dockerze wymaga innego modelu myślenia

Wyobraź sobie trzy oddzielne kontenery:

backend
database
redis

Możesz je zdefiniować w ten sposób:

services:
  backend:
    build: .
    depends_on:
      - database
      - redis
  database:
    image: postgres
  redis:
    image: redis

Z wnętrza kontenera backendowego nie możesz po prostu uzyskać dostępu do:

localhost:5432

Zamiast tego adresujesz inny kontener pod jego nazwą usługi:

database:5432

Ta sama logika obowiązuje w przypadku Redis:

redis:6379

Wynikowa topologia sieci wygląda tak:

backend
   |
   +---- database:5432
   |
   +---- redis:6379

Początkowo może to wydawać się nieznane, ponieważ lokalne konfiguracje zazwyczaj uruchamiają wszystko bezpośrednio na maszynie hosta. W środowisku produkcyjnym pojawiają się rzeczywiste granice sieciowe pomiędzy komponentami.

Usługi chmurowe wprowadzają tę samą pułapkę

Lokalnie twoja aplikacja może uzyskać dostęp do Redis w ten sposób:

localhost:6379

Lecz w środowisku produkcyjnym Redis znajduje się na usłudze zarządzanej w zupełnie innym miejscu.

Odpowiednia konfiguracja mogłaby wyglądać tak:

REDIS_URL=redis://redis-production.example.com:6379

Twoje aplikacja nie powinna wiedzieć o tej różnicy.

To jest kluczowa idea.

Napisz kod, który korzysta z konfiguracji w ten sposób:

createRedisClient({
  url: process.env.REDIS_URL
});

Zamiast hardkodować szczegóły połączenia w ten sposób:

createRedisClient({
  host: "localhost",
  port: 6379
});

Zachowaj spójność struktury konfiguracji we wszystkich środowiskach

Wartości mogą się różnić pomiędzy środowiskami.

Struktura nie powinna się zmieniać.

Naprzимер, w środowisku rozwojowym:

DATABASE_URL=postgresql://localhost:5432/myapp
REDIS_URL=redis://localhost:6379

A w środowisku produkcyjnym:

DATABASE_URL=postgresql://production-db:5432/myapp
REDIS_URL=redis://production-redis:6379

Ta sama baza kodu.

Różne wartości dla poszczególnych środowisk.

To właśnie ta spójność jest celem, którego dążysz.

3. Otwieranie nowego połączenia z bazą danych przy każdej prośbie

To konkretny błąd może pozostawać niewidoczny przez długi czas, zanim spowoduje problemy.

Piszesz trasę w ten sposób:

app.get("/users", async (req, res) => {
  const client = new Client({
    connectionString: process.env.DATABASE_URL
  });
  await client.connect();
  const result = await client.query("SELECT * FROM users");
  await client.end();
  res.json(result.rows);
});

Funkcjonuje bez zarzutu.

Testujesz ją ręcznie.

Odpowiedzi przychodzą szybko.

Rozpoczynasz jej używanie w produkcji.

Następnie pojawia się rzeczywisty ruch użytkowników.

W końcu zaczynasz widzieć błędy takie jak:

too many connections

lub:

remaining connection slots are reserved

albo zauważasz, że zużycie CPU w bazie danych stale rośnie.

Jaka jest przyczyna?

Każda przychodząca prośba otwiera zupełnie nowe połączenie z bazą danych.

Przy 100 jednoczesnych prośbach może to oznaczać aż 100 oddzielnych połączeń.

Przy 1 000 prośbach sytuacja staje się znacznie gorsza.

Tworzenie połączeń jest kosztowne

Nawiązanie połączenia nie jest bezkosztowe. W zależności od bazy danych i ustawień może wymagać:

  • utworzenia połączenia sieciowego
  • autoryzacja
  • negocjacje TLS
  • inicjalizacja sesji
  • przydzielanie zasobów
  • uruchamianie procesu lub wątku po stronie bazy danych
  • Powtarzanie tego wszystkiego dla każdej prośby marnuje zasoby. Dlatego aplikacje zazwyczaj polegają zamiast tego na poolu połączeń.

    W przypadku PostgreSQL wygląda to tak:

    const { Pool } = require("pg");
    const pool = new Pool({
      connectionString: process.env.DATABASE_URL,
      max: 10
    });
    

    A wtedy twoja ścieżka staje się:

    app.get("/users", async (req, res) => {
      const result = await pool.query(
        "SELECT id, name, email FROM users"
      );
      res.json(result.rows);
    });
    

    Pool przechowuje zestaw ponownie używalnych połączeń gotowych do użycia.

    Wizualnie wygląda to tak:

    ┌── connection 1
    Request ────────┼── connection 2
                    ├── connection 3
                    ├── connection 4
                    └── connection 5
    

    Zamiast tego:

    Request 1 → new connection
    Request 2 → new connection
    Request 3 → new connection
    Request 4 → new connection
    

    Pooli nadal mogą sprawiać problemy w skali

    Nawet przy użyciu pooli możliwa jest błędna konfiguracja.

    Załóżmy, że uruchamiasz 10 instancji backendu, a każda z nich ustawia:

    pool.max = 20
    

    To skutkuje potencjalnym:

    10 × 20 = 200
    

    połączenia. Jeśli twoja baza danych ogranicza liczbę połączeń do 100, już przekraczasz limit, mimo że ustawienia puli dla poszczególnych instancji wydają się rozsądne osobno.

    To właśnie ten typ problemu, który nigdy nie pojawia się na komputerze pojedynczego programisty.

    Lokalnie możesz zauważyć:

    1 application × 10 connections = 10
    

    W środowisku produkcyjnym:

    10 applications × 20 connections = 200
    

    Horyzontalne skalowanie aplikacji może w sposób niewidoczny zwiększyć obciążenie, jakie wywiera na bazę danych.

    Rozmiar puli to kwestia dotycząca całego systemu

    Podczas określania, jak duży powinien być pul, musisz wziąć pod uwagę:

    database connection limit
            ↓
    number of application instances
            ↓
    pool size per instance
            ↓
    background workers
            ↓
    admin/monitoring connections
    

    To nie jest coś, co można wybrać arbitralnie.

    4. Zwracanie wszystkich kolumn bazy danych do klienta

    To konkretny błąd, który rzadko powoduje natychmiastowe zawieszenie aplikacji.

    Dlatego właśnie jest ryzykowny.

    Pomyśl o tabeli użytkowników z następującymi polami:

    id
    name
    email
    password_hash
    phone
    address
    created_at
    updated_at
    internal_notes
    

    Piszesz zapytanie w takim wyglądzie:

    SELECT * FROM users;
    

    A następnie przekazujesz wiersze bezpośrednio do odpowiedzi:

    res.json(result.rows);
    

    To działa. Frontend otrzymuje swój obiekt użytkownika. Możesz przejść dalej.

    Ale w rzeczywistości naraziłeś się na wiele problemów jednocześnie.

    Pierwszy: ujawnienie danych

    Odpowiedź może wyglądać tak:

    {
      "id": 12,
      "name": "John",
      "email": "john@example.com",
      "password_hash": "...",
      "internal_notes": "..."
    }
    

    Nawet jeśli pole z hasłem jest zhaszowane, a nie w formie tekstowej, nie ma powodu, by kiedykolwiek trafiło ono do klienta. A wszystkie pola przeznaczone wyłącznie do użycia wewnętrznego, o których zapomniałeś, zostaną wysłane razem z nim.

    Drugi: przepustowość

    Załóżmy, że każdy rekord waży 2 KB. Dla tysiąca rekordów oznacza to:

    2 KB × 1,000 = 2 MB
    

    Teraz wyobraź sobie, że tabela z czasem staje się coraz szersza. Możesz w końcu musieć przesyłać:

    20 fields
    

    podczas gdy interfejs użytkownika faktycznie potrzebuje tylko:

    id
    name
    avatar
    

    Baza danych nie ma pojęcia, czego faktycznie wymaga twoja strona frontend – ta decyzja należy do warstwy API.

    Zamiast więc:

    SELECT * FROM users;
    

    powinieneś napisać:

    SELECT
      id,
      name,
      avatar_url
    FROM users;
    

    Teraz payload zawiera tylko to, czego potrzebuje dany konkretny endpoint.

    To staje się jeszcze ważniejsze przy łączeniach

    Rozważ zapytanie takie jak:

    SELECT *
    FROM users
    JOIN orders ON orders.user_id = users.id
    JOIN payments ON payments.order_id = orders.id;
    

    To może przywołać ogromną ilość danych, których wcale nie zamierzałeś nigdzie wysłać. To kusząca skrótka podczas budowania systemu, przy czym uzasadnienie brzmi mniej więcej tak:

    ">Zwrócę po prostu wszystko i pozwolę frontendowi wybrać to, czego potrzebuje."

    Opieraj się temu pociągowi.

    Kształt odpowiedzi API powinien być celowy. Na przykład:

    const users = result.rows.map(user => ({
      id: user.id,
      name: user.name,
      avatar: user.avatar_url
    }));
    

    Lub, jeszcze lepiej, przenieś tę odpowiedzialność do dedykowanej warstwy DTO lub serializacji.

    Umowa API ma znaczenie

    API klasy produkcyjnej powinno być w stanie odpowiedzieć na:

    Czego obiecuje zwrócić ten endpoint?

    A nie na:

    Które kolumny istnieją w podstawowej tabeli?

    To rozróżnienie staje się coraz ważniejsze w miarę wzrostu bazy kodu.

    5. Ignorowanie indeksów bazy danych dopóki produkcja nie zwolni

    To może być jeden z najwyraźniejszych przykładów sytuacji, w której coś wydaje się całkowicie w porządku lokalnie, ale potajemnie przeradza się w problem w środowisku produkcyjnym.

    Weźmy na przykład zapytanie takie:

    SELECT * FROM users
    WHERE email = 'john@example.com';
    

    Podczas gdy twoja baza deweloperska ma:

    500 users
    

    zapytanie zwraca wyniki natychmiast.

    Rozwijasz aplikację. Mija sześć miesięcy, a tabela teraz zawiera:

    5 million users
    

    Nagle ta sama zapytanie może stać się znacznie wolniejsze, ponieważ baza danych może musieć przeszukać ogromną część tabeli, aby znaleźć pasujące rekordy.

    Typowa reakcja jest następująca:

    "Ale to zapytanie kiedyś działało szybko!"

    Oczywiście — bo wtedy tabela była mała.

    Dlatego istnieją indeksy

    Dodanie czegoś takiego jak:

    CREATE INDEX idx_users_email
    ON users(email);
    

    pozwala PostgreSQL znacznie efektywniej znajdować pasujące wiersze niż poprzez pełną przeszukanie tabeli.

    Mimo to wniosek, który należy wyciągnąć, nie jest taki:

    Należy indeksować wszystko.

    To samo w sobie jest błędem. Indeksy nie są bezkosztowe — one:

    • zajmują miejsce na dysku
    • spowalniają zapisy
    • wymagają ciągłej konserwacji
    • nie pomagają przy każdym typie zapytania
    • nie są warte dodawania do każdej kolumny

    Zamiast tego należy zadać pytanie:

    Które zapytania ta aplikacja faktycznie wykonywa w praktyce?

    Indeks oparty na wzorcach dostępu

    Jeśli twoja aplikacja regularnie wykonywa coś w rodzaju:

    SELECT *
    FROM orders
    WHERE user_id = 123
    ORDER BY created_at DESC
    LIMIT 20;
    

    to złożony indeks w tym kierunku może być rozsądny:

    CREATE INDEX idx_orders_user_created
    ON orders(user_id, created_at DESC);
    

    Prawidłowy wybór indeksu zawsze zależy od twojego konkretnego silnika bazy danych, sposobu rozproszenia danych, zachowania planera zapytań oraz twojego rzeczywistego obciążenia — właśnie dlatego indeksowanie na podstawie domysłów nie działa.

    Użyj EXPLAIN

    Jednym z najcenniejszych narzędzi diagnostycznych w PostgreSQL jest:

    EXPLAIN ANALYZE
    SELECT *
    FROM orders
    WHERE user_id = 123
    ORDER BY created_at DESC
    LIMIT 20;
    

    Pokazuje on dokładnie, w jaki sposób baza danych wykonała dane zapytanie, zastępując założenia takie jak:

    To zapytanie powinno działać szybko

    faktycznymi dowodami:

    Oto, co baza danych faktycznie robi

    Taka zmiana sposobu myślenia ma ogromne znaczenie.

    Dane lokalne mogą być błędne

    Oto zasada, którą warto przyswoić: wydajność mierzona na małym lokalnym zbiorze danych niewiele mówi o tym, jak ta sama zapytanie będzie się zachowywać w środowisku produkcyjnym.

    Zapytanie, które kończy się w:

    5 ms
    

    przy 1 000 wierszach, może zachowywać się zupełnie inaczej, gdy mamy do czynienia z:

    10 million rows
    

    Aby w sposób znaczący testować wydajność, potrzebne są ilości danych przypominające te, które faktycznie pojawią się w środowisku produkcyjnym.

    6. Przekonanie, że wszystko, co zapiszesz na dysku, tam pozostanie

    To problem często pojawia się przy przesyłaniu plików.

    Na swoim komputerze możesz napisać coś takiego:

    app.post("/upload", upload.single("file"), (req, res) => {
      console.log(req.file.path);
    });
    

    Zapisany plik trafia w jakimś miejscu takim jak:

    /uploads/photo.jpg
    

    Ładujesz ponownie aplikację.

    Plik jest dokładnie tam, gdzie go zostawiłeś.

    Nic nie wydaje się nie tak.

    Następnie wysyłasz aplikację do kontenera lub jakiejś tymczasowej platformy hostingowej.

    Rozpoczyna się nowa implementacja.

    Nagle plik znika.

    Zaczynają napływać zgłoszenia obsługi klienta:

    Gdzie zniknęły moje obrazy?

    Dlaczego to się dzieje?

    Ponieważ system plików na twoim lokalnym komputerze zachowuje się zupełnie inaczej niż magazynowanie w wielu środowiskach produkcyjnych.

    Kontener zazwyczaj przechodzi przez taki cykl życia:

    created
    running
    destroyed
    recreated
    

    Gdy ten kontener zostanie usunięty, wszystko, co zostało zapisane wyłącznie na jego lokalnym dysku, znika wraz z nim.

    Wiele platform hostingowych projektuje je tak, aby pliki zapisane lokalnie były traktowane jako tymczasowe.

    Przestań traktować swój serwer aplikacji jako długoterminowe magazynowanie

    Aby obsługiwać przesyłanie plików w środowisku produkcyjnym, zwykłym podejściem jest przechowywanie plików w dedykowanym magazynie obiektów zamiast na własnym dysku serwera.

    To daje architekturę mniej więcej taka:

    Client
      ↓
    Backend
      ↓
    Object Storage
      ↓
    Database stores file metadata
    

    Twoja baza danych musi natomiast przechowywać jedynie odnośnik — coś w rodzaju:

    id
    user_id
    filename
    storage_key
    content_type
    created_at
    

    Rzeczywiste bajty pliku znajdują się w usłudze przechowywania obiektów, a nie w twojej bazie danych relacyjnej.

    Takie rozdzielenie obowiązków znacznie upraszcza zrozumienie całej struktury.

    Baza danych i warstwa przechowywania rozwiązują różne problemy

    Bazy danych doskonale radzą sobie z:

    • ustrukturyzowanymi rekordami
    • relacjami pomiędzy rekordami
    • gwarancjami transakcyjnymi
    • wyszukiwaniem i filtrowaniem

    Przechowywanie obiektów jest przeznaczone do:

    • obrazów
    • wideo
    • plików PDF i innych dużych dokumentów
    • zasobów statycznych w ogóle

    Próba sprawienia, by jeden system wykonywał obie te funkcje, zazwyczaj w końcu przynosi negatywne skutki.

    Inny podobny pułapka: umieszczanie przesłanych plików w bazie kodu

    Nie jest rzadkością spotkanie projektów zorganizowanych w ten sposób:

    project/
      uploads/
      src/
      package.json
    

    gdzie pliki przesłane przez użytkownik znajdują się tuż obok własnych plików źródłowych aplikacji.

    Taki podejście szybko przestaje działać poprawnie.

    Wyobraźmy sobie:

    100,000 uploaded images
    

    pliki te znajdujące się w folderze projektu.

    Procesy deploju stają się nieznośnie powolne i chaotyczne.

    Kod aplikacji oraz treści tworzone przez użytkowników podlegają zupełnie różnym cyklom życia, dlatego powinny znajdować się w oddzielnych systemach.

    7. Wysyłanie API bez ograniczeń szybkości

    Praca lokalnie tworzy mylący obraz sytuacji.

    Prawdopodobnie jesteś jedynym klientem korzystającym z API.

    Zostawiasz tam:

    GET /api/users
    

    jednorazowo.

    Potem ponownie.

    Może jeszcze kilka razy.

    Nic się nie psuje.

    Środowisko produkcyjne nie zachowuje się tak uprzejmie.

    Jedna osoba dzwoniąca — przypadkowo lub celowo — może wysłać tysiące żądań w krótkim czasie.

    Weźmy na przykład taki endpoint:

    POST /api/login
    

    Bez żadnego ograniczenia przepustowości, atakujący mógłby bombardować go powtarzającymi się próbami logowania:

    attempt 1
    attempt 2
    attempt 3
    ...
    attempt 10,000
    

    Nawet doskonale bezpieczna weryfikacja haseł nie zapobiega nadużyciom tego endpointu na dużą skalę.

    Ograniczenie przepustowości wprowadza punkt kontrolny

    Koncepcyjnie działa w ten sposób:

    Client
       ↓
    Rate limiter
       ↓
    Application
    

    Zamiast pozwalać na nieograniczone przepływ żądań, można ustalić limit dla określonej kategorii endpointów na poziomie:

    100 requests / minute
    

    Gdy osoba dzwoniąca przekroczy ten próg, otrzymuje odpowiedź:

    429 Too Many Requests
    

    Odpowiednie wartości różnią się w zależności od przypadku użycia — publiczny endpoint do wyszukiwania nie wymaga takiego samego limitu jak na przykład:

    POST /login
    

    lub:

    POST /forgot-password
    

    To nie dotyczy tylko złośliwego ruchu

    Ograniczenia szybkości działania chronią również przed błędami po stronie klienta.

    Załóżmy, że istnieje błąd w kodzie frontendu w postaci:

    setInterval(() => {
      fetch("/api/notifications");
    }, 100);
    

    To pojedyncze karty przeglądarki nieprzerwanie atakują twoją API.

    A teraz wyobraź sobie tę samą pomyłkę u 10 000 użytkowników.

    Twój backend nagle ma poważny problem.

    Ogranicznik szybkości działania stanowi dla ciebie zabezpieczenie przed właśnie takim niekontrolowanym zachowaniem.

    Ograniczenia szybkości działania stają się trudniejsze przy skalowaniu poziomym

    Załóżmy, że twoja aplikacja działa za pomocą trzech oddzielnych instancji backendu:

    Server A
    Server B
    Server C
    

    Jeśli każda instancja przechowuje swój własny licznik w pamięci, klient może faktycznie uzyskać trzy razy większą ilość zasobów niż przewidziano, po jednej na serwer.

    Aby zapewnić spójne ograniczenia we wszystkich instancjach, zespoły zazwyczaj korzystają z wspólnego magazynu danych, takiego jak Redis.

    Konfiguracja wygląda wtedy mniej więcej tak:

    ┌── Server A
    Client → LB ──┼── Server B
                  └── Server C
                        |
                      Redis
    

    Dzięki temu wspólnemu stanowi każda instancja konsekwentnie stosuje tę samą granicę.

    Jeszcze raz, jest to niewidoczne podczas uruchamiania pojedynczego procesu lokalnie — nie ma żadnego rozproszonego stanu, który wymagałby uwagi, dopóki produkcja nie spowoduje problemu.

    8. Zakładanie, że żądania zawsze są realizowane w rozsądnym czasie

    To może cię zaskoczyć.

    Załóżmy, że tworzysz punkt końcowy w następujący sposób:

    app.get("/report", async (req, res) => {
      const data = await generateHugeReport();
      res.json(data);
    });
    

    Lokalnie raport obejmuje:

    1,000 records
    

    i jest kończony w:

    300 ms
    

    Wydaje się w porządku.

    W produkcji ten sam raport teraz obejmuje:

    5 million records
    

    a żądanie teraz trwa:

    45 seconds
    

    Tymczasem twój proxy odwrotny może być skonfigurowany tak, aby zrezygnować po:

    30 seconds
    

    dlatego klient ostatecznie widzi:

    504 Gateway Timeout
    

    mimo że twoja usługa backend nadal pracuje cicho w tle.

    To niezgodność powoduje mylący tryb awarii.

    Długotrwałe zadania nie powinny znajdować się w cyklu żądanie-odpowiedź

    Gdy zadanie będzie wymagało trochę czasu, zazwyczaj lepiej jest obsłużyć je jako zadanie w tle, zamiast czekać na nie bezpośrednio przez klienta.

    Zamiast takiego przebiegu:

    Client
      ↓
    POST /generate-report
      ↓
    Wait 45 seconds
      ↓
    Response
    

    przechodzimy na coś w rodzaju:

    Client
      ↓
    POST /generate-report
      ↓
    Job created
      ↓
    202 Accepted
    

    przy czym rzeczywista obróbka odbywa się oddzielnie:

    Queue
      ↓
    Worker
      ↓
    Generate report
      ↓
    Store result
    

    Klient może wtedy sprawdzać status za pomocą czegoś takiego jak:

    GET /reports/:id
    

    lub po prostu otrzymać powiadomienie po zakończeniu zadania.

    Kolejki w tle zajmują się ciężką pracą

    Powszechne przykłady zadań, które powinny być wykonywane poza głównym cyklem żądań, to:

    • wysyłanie e-maili
    • generowanie plików PDF
  • przetwarzanie obrazów
  • przetwarzanie wideo
  • tworzenie raportów
  • imporowanie danych
  • wysyłanie powiadomień
  • tworzenie dużych plików eksportowych
  • wykonywanie zadań zaplanowanych
  • Zapobieganie wykonywaniu tych operacji na głównej wątku sprawia, że twoja podstawowa API pozostaje szybka i responsywna.

    Nie zapominaj o czasach wygaśnięcia

    Każde wezwanie, które twoja usługa kieruje do zewnętrznego systemu, powinno mieć ustalony rozsądny czas wygaśnięcia.

    Jako przykład:

    fetch(url, {
      signal: AbortSignal.timeout(5000)
    });
    

    Jeśli to pominiesz, zależność, która się zablokuje, może sprawić, że twoja aplikacja będzie czekać w nieskończoność.

    Pomyśl o tej łańcuchowej reakcji:

    API
     ↓
    Payment service
     ↓
    hangs
    

    Twoja prośba utknęła teraz, czekając na coś, co może nigdy nie odpowiedzieć.

    A teraz wyobraź sobie, że dzieje się to przy setkach jednoczesnych żądań.

    W pewnym momencie twój serwer zabraknie dostępnych zasobów na obsługę czegokolwiek innego.

    Każda zależność wymaga planu na wypadek awarii

    Dla każdej zewnętrznej usługi, z którą komunikuje się twoja aplikacja, powinieneś być w stanie odpowiedzieć na jedno konkretne pytanie:

    Co się stanie, jeśli ta usługa nie odpowie?

    A nie to:

    Co się stanie, gdy wszystko działa poprawnie?

    Na to drugie pytanie łatwo jest odpowiedzieć.

    To pierwsze faktycznie decyduje o tym, czy twój system będzie funkcjonować pod presją.

    9. Ufanie danych wprowadzanych przez klienta

    To błędne podejście jest niemal nieprzyjemne do wskazania, ponieważ błędny kod często wygląda zupełnie rozsądnie.

    Weźmy na przykład taki endpoint:

    app.post("/users", async (req, res) => {
      const { name, email } = req.body;
      await createUser(name, email);
      res.json({
        message: "User created"
      });
    });
    

    Na pierwszy rzut oka nic nie wydaje się nie tak.

    Ale pomyśl, co się stanie, jeśli ciało żądania będzie wyglądać w ten sposób:

    {
      "name": "",
      "email": "hello"
    }
    

    Albo w ten sposób:

    {
      "name": null,
      "email": null
    }
    

    Albo w ten sposób:

    {
      "name": "A".repeat(1000000),
      "email": "..."
    }
    

    Co się stanie, jeśli klient przekaże pola, których twój kod w ogóle nie przewidział?

    Podczas rozwoju lokalnego zwykle wysyłasz starannie przygotowane żądania testowe.

    W środowisku produkcyjnym twoja API otrzymuje wszystko, co ktoś zdecyduje się do niej przesłać.

    Walidacja musi odbywać się na poziomie wejścia

    Dobrze zaprojektowana API sprawdza przychodzące dane przed uruchomieniem jakiejkolwiek logiki biznesowej.

    Na przykład możesz zdefiniować taki kształt:

    const schema = {
      name: "string",
      email: "email"
    };
    

    Używając biblioteki do walidacji, możesz narzucić takie zasady jak:

    name required
    name length <= 100
    email required
    email valid
    

    Nie ma większego znaczenia, którą konkretną bibliotekę do walidacji wybierzesz.

    Ważna jest podstawowa zasada:

    Nigdy nie zakładaj, że klient przesłał to, czego się spodziewałeś.

    Pamiętaj, że „klient” może być:

    • twoim własnym interfejsem użytkownika
    • aplikacją mobilną
    • integracją z podmiotem trzecim
  • skrypt automatyczny
  • przestarzała wersja Twojej aplikacji
  • ktoś o złą wolę
  • Kontrole po stronie klienta nie stanowią warstwy bezpieczeństwa

    Częsty zarzut brzmi:

    "Ale ja już weryfikuję to po stronie frontendu, w React.”

    To rzeczywiście poprawia doświadczenie użytkownika.

    Nie chroni to jednak Twojego backendu.

    Każdy może całkowicie pominąć kod frontendu.

    Wystarczy uruchomić:

    curl
    

    skierowany bezpośrednio na Twój punkt końcowy API.

    Kod po stronie serwera nadal musi sam egzekwować zasady.

    Iniekcja SQL to powiązane zagrożenie

    const query = `
      SELECT *
      FROM users
      WHERE email = '${email}'
    `;
    

    Zamiast tego korzystaj z zapytań parametryzowanych:

    const result = await pool.query(
      "SELECT * FROM users WHERE email = $1",
      [email]
    );
    

    W ten sposób sterownik bazy danych trzyma wartości parametrów oddzielnie od struktury zapytania.

    Walidacja danych wejściowych oraz zapytania parametryzowane adresują różne zagrożenia, ale backend o poziomie produkcyjnym wymaga obu rozwiązań.

    10. Myślenie, że „aplikacja działa” oznacza „aplikacja jest sprawna”, jest błędne

    To może być najbardziej podstawowe nieporozumienie na tej liście.

    Twój serwer się uruchamia:

    Server listening on port 5000
    

    Logi to potwierdzają:

    Application started successfully.
    

    W tym momencie łatwo jest pomyśleć:

    Świetnie. Skończyliśmy.

    To niekoniecznie jest prawdą.

    Proces może nadal działać, nawet jeśli sama aplikacja jest w praktyce uszkodzona.

    Rozważmy taki stan:

    Backend process: running
    Database: unavailable
    Redis: unavailable
    Payment provider: unavailable
    Queue: stuck
    

    Z perspektywy systemu operacyjnego proces jest żywy i sprawny.

    Panele monitoringu mogą pokazywać:

    CPU: 10%
    Memory: 20%
    Process: alive
    

    Tymczasem rzeczywiści użytkownicy widzą:

    500 Internal Server Error
    

    Sprawdzanie stanu wypełnia część luki

    Bazowy punkt końcowy sprawdzający stan, na przykład:

    GET /health
    

    może po prostu zwracać:

    {
      "status": "ok"
    }
    

    Jednak nawet to może być mylące.

    Jeśli /health jedynie potwierdza, że serwer HTTP odpowiada na żądania, nie mówi nic o tym, czy zależności, od których faktycznie korzysta aplikacja, działają poprawnie.

    Bardziej kompletna konfiguracja dzieli te dwa punkty końcowe. Jeden służy do:

    /health
    

    prostego sprawdzenia aktywności, a drugi do:

    /ready
    

    sprawdzenia gotowości.

    Różnica, koncepcyjnie, wygląda następująco:

    Liveness: "Is this process alive?"
    

    i:

    Readiness:
    "Can this instance actually serve traffic?"
    

    Sprawdzanie konkretnie stanu bazy danych

    Sprawdzenie gotowości może być bardziej szczegółowe i potwierdzić, że połączenie z bazą danych faktycznie działa, na przykład poprzez uruchomienie:

    SELECT 1;
    

    Jeśli nie można nawiązać połączenia z bazą danych, aplikacja może oznaczyć się jako niegotowa.

    To daje narzędziom do orkiestracji możliwość zatrzymania kierowania ruchu do instancji, która nie jest w stanie prawidłowo obsłużyć żądań.

    Sam czas działania mówi niewiele

    Server może raportować:

    99.99% uptime
    

    a mimo to zapewniać swoim użytkownikom frustrująco wolne doświadczenie.

    Dlatego monitorowanie w środowisku produkcyjnym musi obejmować coś więcej niż tylko sprawdzenie, czy proces jest aktywny – w tym elementy takie jak:

    • opóźnienie w obsłudze żądań
    • wskaźnik błędów
    • przepustowość
    • wydajność bazy danych
    • użytkowanie CPU
    • użytkowanie pamięci
    • głębokość kolejki
    • błędy w usługach zewnętrznych

    Jednym szczególnie przydatnym wskaźnikiem jest:

    p95 latency
    

    Zamiast pytać:

    Jaki jest średni czas odpowiedzi?

    bardziej pouczające jest pytanie:

    Ile czasu zajmuje 95% zapytań?

    Średnie wartości mają tendencję do ukrywania najgorszych doświadczeń.

    Rozważmy następujący scenariusz:

    99 requests = 50 ms
    1 request = 30 seconds
    

    Średni czas odpowiedzi tutaj może wcale nie wydawać się alarmujący.

    Jednak to jedno wolne zapytanie nadal stanowiło naprawdę złe doświadczenie dla osoby, która je wysłała.

    Kolejny błąd: rejestrowanie wszystkiego oprócz użytecznych informacji

    To błąd zasługuje na miejsce na liście, ponieważ uratował już więcej niż jedną sesję debugowania.

    Coś się psuje w środowisku produkcyjnym.

    Otwierasz logi.

    Jedyne, co znajdujesz, to:

    Something went wrong
    

    To jest cała wiadomość.

    Brak identyfikatora zapytania.

    Brak informacji o trasie.

    Brak kontekstu użytkownika.

    Brak śladu stosu wywołań.

    Brak informacji o tym, która operacja na bazie danych została wykonana.

    Brak daty i godziny wystarczająco dokładnej, aby ją skorelować z czymkolwiek.

    Tylko prosta linia mówiąca:

    Something went wrong
    

    W takiej sytuacji naprawiasz problem bez żadnych rzeczywistych informacji do pracy.

    Jak powinna wyglądać przydatna wpis w dzienniku

    Gdy coś się nie udaje, pytania, na które chcesz otrzymać odpowiedzi, to:

    When?
    Where?
    Which request?
    Which service?
    What failed?
    How long did it take?
    What dependency was involved?
    

    Poprawnie skonstruowany wpis w dzienniku zawiera ten kontekst, na przykład:

    {
      "level": "error",
      "requestId": "abc-123",
      "route": "/api/orders",
      "method": "POST",
      "duration": 843,
      "error": "Database timeout"
    }
    

    Taka wersja rzeczywiście daje ci coś, z czym możesz pracować.

    Nigdy nie rejestruj danych wrażliwych

    Jednocześnie uważaj, aby nigdy przypadkowo nie zapisywać:

    password
    access token
    refresh token
    credit card information
    private keys
    

    Dziennikowanie służy do pomocy w diagnozowaniu problemów, a nie do dostarczania atakującym kolejnego problemu.

    Dlaczego te problemy nigdy nie pojawiają się na twoim laptopie

    Można uzasadnione pytanie, dlaczego tylu programistów powtarza te same błędy, skoro są one tak powszechne.

    Odpowiedź brzmi, że środowisko lokalne i środowisko produkcyjne to zasadniczo różne systemy.

    Oto ich różnice.

    Jak wygląda rozwój lokalny

    Zazwyczaj mamy do czynienia z:

    1 developer
    1 application
    1 database
    small dataset
    low traffic
    fast network
    known inputs
    stable filesystem
    single process
    

    Z kolei środowisko produkcyjne może obejmować:

    thousands of users
    multiple application instances
    millions of database rows
    unpredictable traffic
    network latency
    invalid requests
    external dependencies
    ephemeral containers
    background workers
    multiple regions
    

    To nie jest mniejsza wersja tego samego systemu — to zupełnie inny system.

    Kod źródłowy może pozostać niezmieniony.

    Ale otaczające go środowisko nie.

    Nawyk skupiania się na najlepszym scenariuszu

    To naturalne, by testować tylko przepływ działający poprawnie.

    Typowy przykład:

    Create user
    ↓
    Login
    ↓
    Create order
    ↓
    Payment succeeds
    ↓
    Return response
    

    Ta sekwencja przedstawia najlepszy możliwy scenariusz.

    Ale ruch w środowisku produkcyjnym nigdy nie ogranicza się tylko do najlepszych scenariuszy.

    Należy również uwzględnić sytuacje takie jak:

    What if the database is slow?
    What if Redis is unavailable?
    What if the payment service times out?
    What if the user sends invalid JSON?
    What if the request is duplicated?
    What if two requests update the same record?
    What if the process restarts?
    What if the container disappears?
    What if the database connection pool is exhausted?
    What if the user clicks the button five times?
    What if the request arrives twice?
    What if the response is lost after the database transaction succeeds?
    

    W systemie rozproszonym takie przypadki nie są rzadkością.

    Są to zjawiska powszechne.

    Przejście od „Czy to działa?” do „A co jeśli się nie uda?”

    W miarę nabierania doświadczenia w pracy z systemami backendowymi zmieniają się nasze instynkty.

    Na początku pytanie brzmi:

    Czy ten kod działa?

    Z czasem staje się ono:

    Co się stanie, jeśli ten kod zawiedzie?

    To drugie pytanie jest o wiele bardziej przydatne.

    Weźmy tę linię kodu:

    const result = await paymentService.charge();
    

    Rozumowanie początkującega kończy się na:

    Jeśli wywołanie się powiedzie, przechodź dalej.

    Rozumowanie skupione na produkcji kontynuuje się:

    A co jeśli nastąpi timeout?

    Następnie:

    A co jeśli transakcja zostanie zrealizowana, ale odpowiedź do mojego API się opóźni?

    Wtedy:

    A co jeśli klient spróbuje ponownie wysłać żądanie?

    Wtedy:

    Czy klient może zostać obciążony dwa razy?

    W tym momencie rozważasz różne sposoby awarii, co stanowi istotę inżynierii backendu.

    Idempotentność: co zapewnia bezpieczeństwo procesów płatności

    Kontynuując ten sam przykład: wyobraź sobie, że użytkownik naciska

    Pay
    

    Żądanie trafia na twój serwer.

    Transakcja zostaje zrealizowana pomyślnie.

    Jednak połączenie zostaje przerwane, zanim odpowiedź dotrze z powrotem.

    Klient nie ma sposobu, by dowiedzieć się, że płatność rzeczywiście została przetworzona, więc próbuje ponownie.

    Teraz serwer już przetworzył:

    Payment request #1 → success
    Payment request #2 → success
    

    Co może łatwo doprowadzić do:

    Customer charged twice.
    

    Idempotencja istnieje właśnie po to, by temu zapobiec.

    Klucz idempotencji może wyglądać w ten sposób:

    payment_7f8c123
    

    Klient dołącza ten klucz do żądania, a serwer przechowuje go razem z wynikiem tej operacji. Jeśli ponownie przyjdzie żądanie z tym samym kluczem, serwer po prostu zwraca przechowywany wynik zamiast wykonywać operację ponownie.

    W zarysie:

    Request
      ↓
    Idempotency key
      ↓
    Already processed?
      ├── Yes → return previous result
      └── No → process → store result
    

    Ten wzorzec ma ogromne znaczenie dla operacji takich jak:

    • płatności
    • tworzenie zamówień
    • rejestracja konta
    • wysyłanie e-maili
    • wołania do API stron trzecich

    Błędy związane z duplikowanymi żądaniami rzadko pojawiają się podczas testów lokalnych, ale stale występują, gdy dochodzi do użycia rzeczywistych, niestabilnych sieci.

    Transakcje: Kolejna rzecz, która wydaje się w porządku, dopóki tak nie jest

    Pomyśl o tym, co dzieje się, gdy tworzony jest zamówienie.

    Proces może obejmować kilka kroków:

    Create order
    Create order items
    Update inventory
    Create payment record
    

    Jeśli nie otoczysz tych operacji transakcją, jedna z nich może zawieść w trakcie wykonywania sekwencji.

    Pomyśl o takim scenariuszu:

    Create order       ✓
    Create items       ✓
    Update inventory   ✓
    Payment record     ✗
    

    W tym momencie twoja baza danych znajduje się w stanie nieukończonym.

    Transakcje istnieją po to, aby można było łączyć operacje, które muszą albo wszystkie się udać, albo wszystkie zawieść jako całość.

    Oto jak to wygląda w praktyce:

    BEGIN;
    INSERT INTO orders (...);
    INSERT INTO order_items (...);
    UPDATE inventory
    SET quantity = quantity - 1
    WHERE product_id = 123;
    COMMIT;
    

    A gdy coś pójdzie nie tak w trakcie:

    ROLLBACK;
    

    Mimo to otaczanie każdej pojedynczej operacji jedną dużą transakcją również nie jest właściwym podejściem.

    Granice transakcji wymagają starannego zaprojektowania.

    Transakcje trwające zbyt długo mogą powodować własne problemy.

    Pytanie, które zawsze powinieneś sobie zadawać, brzmi:

    Jeśli to się nie uda w połowie procesu, w jakim stanie znajdzie się system?

    Zapytania N+1: Cichy zabójca wydajności

    Istnieje jeszcze jeden problem, który zwykle pozostaje niewidoczny podczas rozwoju lokalnego.

    Załóżmy, że pobierasz listę użytkowników:

    SELECT * FROM users LIMIT 100;
    

    A następnie, dla każdego pojedynczego użytkownika, pobierasz jego zamówienia:

    SELECT * FROM orders WHERE user_id = ?;
    

    To daje w sumie:

    1 query for users
    +
    100 queries for orders
    =
    101 queries
    

    To jest dobrze znana pułapka zapytań N+1.

    Jeśli masz tylko 10 użytkowników:

    11 queries
    

    To jest do opanowania.

    Ale gdy liczba użytkowników wzrośnie do 10 000:

    10,001 queries
    

    nagle masz poważny problem z wydajnością.

    Rozwiązanie niekoniecznie polega na „połączeniu wszystkiego w jedno ogromne zapytanie”.

    w zależności od Twojej sytuacji możesz skorzystać z:

    • łączeń
    • ładowania asynchronicznego
    • prac w partiach
  • IN (...) klauzule
  • loaderzy danych
  • zapytania zaprojektowane specjalnie po to, by uniknąć tego wzorca
  • Najważniejsze jest umiejętność rozpoznania tego wzorca od samego początku.

    Serwer backend może wydawać się doskonale szybki przy małym lokalnym zbiorze danych, podczas gdy w rzeczywistości przy prawdziwym obciążeniu wysyła tysiące zapytań.

    Paginacja jest niezbędna przy dużych zbiorach

    Taki endpoint wydaje się dość nieszkodliwy:

    GET /users
    

    A zapytanie za nim:

    SELECT *
    FROM users;
    

    działa bez problemów, gdy masz:

    50 users
    

    Ale co się dzieje, gdy masz do czynienia z:

    5 million users?
    

    Wysyłanie pięciu milionów rekordów z powrotem do klienta to coś, czego chyba nie chcesz robić.

    Paginacja daje sposobność na ograniczenie tego.

    Mogłbyś zastosować paginację opartą na offsetach:

    GET /users?page=2&limit=50
    

    lub paginację opartą na wskaźniku:

    GET /users?cursor=abc123&limit=50
    

    Paginacja kursorowa zazwyczaj lepiej funkcjonuje przy dużych lub stale zmieniających się zbiorkach danych, ponieważ unika niektórych problemów z wydajnością i spójnością, które pojawiają się przy bardzo dużych odstępach.

    Jeszcze raz, kluczowy problem to:

    localhost data = small
    production data = potentially huge
    

    Twoja API musi być zbudowana z myślą o tym braku.

    Ucieczki pamięci nie zawsze pojawiają się od razu

    To kolejny typ błędu, który ujawnia się dopiero w środowisku produkcyjnym.

    Memory: 150 MB
    

    Godzinę później:

    300 MB
    

    Sześć godzin później:

    600 MB
    

    A w końcu:

    Out of memory
    

    Następnie aplikacja zawiesza się i restartuje.

    W tym momencie wszystko znów wygląda normalnie.

    Taki wzorzec jest silnym sygnałem, że coś utrzymuje pamięć, której nie powinno posiadać.

    Poważne przyczyny obejmują:

    • tablice rosnące bez żadnych ograniczeń
  • cache bez ograniczeń usuwania danych
  • słuchacze zdarzeń, które nigdy nie są usuwane
  • wielkie obiekty pozostające nieumyślnie w pamięci
  • odniesienia istniejące znacznie dłużej, niż powinny
  • strumienie, które nie są prawidłowo obsługiwane
  • Gdy testujesz lokalnie, aplikacja rzadko działa wystarczająco długo, aby pojawiły się takie problemy.

    W środowisku produkcyjnym działa na tyle długo, że wyciek pamięci staje się istotny.

    To jeden z powodów, dla których obserwacja zachowania aplikacji przez dłuższy czas jest tak cenna.

    Caching może również zakłócić działanie w środowisku produkcyjnym

    Na papierze caching wydaje się prostym rozwiązaniem:

    Database → slow
    Redis → fast
    

    Dlatego instynkt podpowiada, by zapamiętywać wszystko.

    Ale potem dane ulegają zmianie.

    A API nadal zwraca przestarzałe informacje.

    W rezultacie pojawia się niezgodność typu:

    Database:
    balance = 100
    Cache:
    balance = 80
    

    API odpowiada:

    80
    

    A użytkownik chce wiedzieć:

    Dlaczego mój sald jest błędny?

    Kasowanie cache zmusza do odpowiadania na pytania takie jak:

    • Kiedy wpisy powinny wygasnąć?
    • Kiedy cache traci ważność?
    • Jaka jest alternatywa, jeśli Redis przestanie działać?
    • Co się dzieje, gdy dwa serwery zapisują dane do tej samej klucza?
    • Czy dostarczanie przestarzałych danych jest tutaj kiedykolwiek do przyjęcia?
    • Co powinno się stać zaraz po wdrożeniu?

    Najtrudniejszą częścią kasowania cache nie było przechowywanie danych.

    Trudno jest określić moment, w którym przechowywane dane przestają być wiarygodne.

    Błędy cache powinny mieć strategię

    Jeśli Redis nagle zniknie, czy twoja aplikacja:

    fail completely
    

    czy też:

    fall back to database
    

    Nie ma uniwersalnej odpowiedzi — zależy to od systemu, który budujesz.

    Najważniejsze jest to, aby wykonać ten telefon celowo, a nie przypadkiem.

    Tajemnice w Git: Błąd, który może stać się incydentem bezpieczeństwa

    Następna kwestia wydaje się oczywista, a mimo to nadal się zdarza.

    Ustawiasz plik:

    DATABASE_PASSWORD=...
    JWT_SECRET=...
    API_KEY=...
    

    A potem uruchamiasz:

    git add .
    git commit -m "config"
    git push
    

    W tym momencie tajemnica zostaje już włączona do historii Git.

    Usunięcie pliku później jej nie usuwa — wartość nadal można odzyskać z wcześniejszych komitów.

    Lepsze rozwiązanie polega na przechowywaniu:

    .env
    

    wspomnianych w .gitignore, oraz komitowaniu zamiast tego szablonu:

    .env.example
    

    z miejscówkami takimi jak:

    DATABASE_URL=
    JWT_SECRET=
    REDIS_URL=
    

    Prawdziwe wartości powinny być wprowadzane za pośrednictwem ustawień środowiskowych platformy deployment lub dedykowanego menedżera tajemnic.

    A jeśli jakaś prawdziwa tajemnica i tak trafi do komitu:

    Nie wystarczy usunąć tę linię i uważać, że wszystko gotowe.

    Niezwłocznie zmień ten sekret.

    Traktuj go jako naruszony, niezależnie od tego, czy możesz udowodnić, że ktoś do niego miał dostęp.

    CORS to nie autoryzacja

    Oto kolejny punkt, który myli ludzi:

    Access-Control-Allow-Origin: *
    

    CORS określa, w jaki sposób przeglądarki obsługują żądania między domenami.

    Nie robi nic, by faktycznie zabezpieczyć twoją API.

    Nic nie powstrzymuje bezpośredniego dostępu do punktu końcowego serwera poprzez:

    curl
    Postman
    mobile apps
    backend services
    scripts
    

    CORS nigdy nie został zaprojektowany jako warstwa autoryzacji.

    • autoryzacji
    • upoważnienia
    • weryfikacji danych wejściowych
    • ograniczeń szybkości
    • kontroli dostępu

    Bезpieczeństwo musi być jawnie wdrożone, a nie wynikać jedynie z polityki przeglądarki.

    Autoryzacja i upoważnienie to różne rzeczy

    Pomylenie tych dwóch koncepcji prowadzi do prawdziwych luk bezpieczeństwa.

    Odpowiedzi dotyczące autoryzacji:

    Kto jesteś?

    Odpowiedzi dotyczące uprawnień:

    Czy masz pozwolenie na wykonanie tej czynności?

    Rozważmy prośbę w następującym kształcie:

    GET /users/123
    

    Użytkownik może być dobrze zautoryzowany.

    Ale to nic nie mówi o tym, czy powinien mieć dostęp do:

    user 123
    

    Jeśli osoba składająca prośbę jest:

    user 456
    

    serwer nadal musi ustalić, czy:

    456 → allowed to access 123?
    

    Częsty błąd wygląda tak:

    if (req.user) {
      return getUser(req.params.id);
    }
    

    Ten kod sprawdza jedynie, czy ktoś jest zalogowany.

    Nigdy nie sprawdza, czy ma uprawnienia do dostępu do tego konkretnego zasobu.

    Bardziej bezpieczny model uwzględnia właścicielstwo lub wyraźne uprawnienia:

    if (req.user.id !== req.params.id) {
      return res.status(403).json({
        error: "Forbidden"
      });
    }
    

    Określone zasady mogą się różnić w zależności od aplikacji.

    Jednak podstawowa zasada pozostaje ta sama wszędzie:

    Logowanie się nie daje pełnego dostępu do wszystkiego w systemie.

    Rozwój aplikacji jest częścią samej aplikacji

    Lekcją, którą warto przyswoić już na wczesnym etapie, jest to, że rozwój aplikacji nie stanowi odrębnej fazy dodawanej na końcu procesu tworzenia.

    Jest to część samej pracy inżynierskiej.

    Pana/Pani aplikacja to nie tylko:

    source code
    

    Jest to połączenie różnych elementów:

    source code
    +
    configuration
    +
    database
    +
    network
    +
    storage
    +
    secrets
    +
    dependencies
    +
    monitoring
    +
    deployment process
    

    Osoba, która zna tylko kod aplikacji, może nadal czuć się przytłoczona, gdy aplikacja trafi do środowiska produkcyjnego.

    Aby być skutecznym, nie musi pan/pani opanować każdego szczegółu Kubernetes.

    Jednak musi pan/pani mieć praktyczne zrozumienie środowiska, w którym faktycznie działa kod.

    Listwa kontrolna gotowości do produkcji, której rzeczywiście używam

    Zanim oświadczymy, że coś jest gotowe do użycia w produkcji, pomocne jest przejście przez listę kontrolną podobną do tej.

    Konfiguracja

    • Czy wymagane zmienne środowiskowe są weryfikowane przy uruchamianiu?
    • Czy tajne dane nie znajdują się w kodzie źródłowym?
    • Czy domyślne ustawienia dla produkcji są bezpieczne, a nie tylko wygodne?
    • Czy konfiguracje rozwojowe i produkcyjne są trzymane oddzielnie?

    Baza danych

    • Czy skonfigurowano pool połączeń?
    • Czy rozmiar poola jest odpowiedni?
    • Czy ważne zapytania są indeksowane?
    • Czy powolne zapytania zostały faktycznie przeanalizowane pod kątem wydajności?
    • Czy transakcje są używane tam, gdzie jest to konieczne?
    • Czy migracje są śledzone za pomocą kontroli wersji?

    API

    • Czy przychodzące dane wejściowe są weryfikowane?
    • Czy treść odpowiedzi jest celowo kształtowana?
    • Czy duże zbiory wyników są paginowane?
    • Czy istnieją ograniczenia dotyczące rozmiaru ciała żądania?
  • Czy skonfigurowano rozsądne limity czasowe?
  • Bезpieczeństwo

    • Czy hasła są haszowane za pomocą bezpiecznego algorytmu?
    • Czy tajne dane są przechowywane w bezpieczny sposób?
    • Czy faktycznie stosuje się mechanizmy autoryzacji?
    • Czy zapytania SQL są parametryzowane?
    • Czy stosuje się ograniczenia szybkości działania tam, gdzie to istotne?
    • Czy dane wrażliwe nie znajdują się w dziennikach?

    Infrastruktura

    • Czy coś błędnie polega na localhost?
    • Czy dane trwałe są przechowywane poza tymczasowymi kontenerami?
    • Czy punkty końcowe do sprawdzania stanu są dostępne?
    • Czy zaimplementowano bezproblemowe wyłączanie aplikacji?
    • Co dokładnie dzieje się podczas ponownego uruchomienia aplikacji?

    Wydajność

    • Czy system został przetestowany przy rzeczywistych objętościach danych?
    • Czy istnieją ukryte wzorce zapytań typu N+1?
    • Czy kosztowne zadania są przenoszone do zadań w tle?
    • Czy rozmiar pamięci cache jest ograniczony?
    • Czy monitoruje się zużycie pamięci?

    Obserwowalność

    • Czy logi błędów zawierają wystarczająco dużo kontekstu, by być przydatne?
    • Czy skonfigurowano śledzenie zapytań?
    • Czy monitoruje się opóźnienia i wskaźniki błędów?
    • Czy awarie bazy danych są widoczne w systemie monitoringu?
    • Czy faktycznie skonfigurowano powiadamiania?

    Spokojne wyłączenie: mały szczegół, o którym ludzie zapominają

    Załóżmy, że twój serwer otrzymuje:

    SIGTERM
    

    sygnał, ponieważ platforma hostingowa chce restartować instancję.

    Jeśli proces zakończy się natychmiast, wszystkie nadal trwające zapytania mogą całkowicie zawieść się.

    Spokojne wyłączenie lepiej radzi sobie z tym problemem.

    Idea w kodzie wygląda mniej więcej tak:

    process.on("SIGTERM", async () => {
      console.log("Shutdown signal received");
    server.close(async () => {
        await pool.end();
        console.log("Server closed");
        process.exit(0);
      });
    });
    

    Dokładna implementacja będzie różnić się w zależności od używanego frameworka.

    Ale podstawowa sekwencja działań pozostaje niezmienna:

    Stop accepting new requests
            ↓
    Finish active requests
            ↓
    Close database connections
            ↓
    Exit
    

    To ma jeszcze większe znaczenie, gdy uruchamiasz kilka instancji aplikacji jednocześnie.

    Logika ponawiania może pogorszyć sytuację

    Gdy próba połączenia z usługą zewnętrzną się nie udaje, ponawianie prób wydaje się oczywistym rozwiązaniem:

    try {
      await callService();
    } catch {
      await callService();
    }
    

    Sam w sobie wydaje się dość nieszkodliwy.

    Ale teraz wyobraź sobie tysiąc żądań, które wszystkie działają według tego samego wzorca.

    Załóżmy, że zależność zaczyna odpowiadać powoli.

    Każda z tych nieudanych prób jest automatycznie ponawiana.

    Zależność, która ma trudności, otrzymuje teraz jeszcze większe obciążenie niż wcześniej.

    Taki wzorzec ma nazwę: burza ponawień.

    Logikę ponawiania należy projektować starannie, biorąc pod uwagę takie kwestie jak:

    • ograniczenie liczby prób
    • eksponencjalne opóźnienia
    • wariacje czasowe
    • rozsądne limity czasowe
    • gwarancje idempotencji
    • mechanizmy ochronne tam, gdzie to ma sens

    Ponowna próba nie jest automatycznie mechanizmem naprawczym.

    W niektórych przypadkach to właśnie ona zamienia drobny problem w całkowitą awarię.

    Dlaczego błędy w środowisku produkcyjnym wydają się tak inne

    Typowy lokalny błąd sprowadza się zazwyczaj do czegoś takiego:

    I made a mistake.
    

    Typowy incydent w środowisku produkcyjnym wygląda raczej tak:

    Several individually reasonable things interacted badly.
    

    Jako ilustracja:

    Traffic increases
            ↓
    More API instances
            ↓
    More database connections
            ↓
    Database reaches connection limit
            ↓
    Queries fail
            ↓
    Requests retry
            ↓
    More load
            ↓
    System becomes even less stable
    

    Żadna z poszczególnych linijek nie wygląda koniecznie groźnie w izolacji.

    Prawdziwy problem leży w tym, jak one współdziałają pod obciążeniem.

    Dlatego właśnie inżynieria backendu z biegiem czasu przesuwa punkt uwagi z pojedynczych funkcji na zachowanie całego systemu.

    Najniebezpieczniejsze zdanie w rozwoju backendu

    Prawdopodobnie najbardziej ryzykownym zwrotem w całej pracy nad backendem jest:

    ">Nikt tego nie zrobi."

    Nikt nie zamierza:

    • wysłanie 10 000 zapytań jednocześnie
    • wgranie pliku o wielkości 500 MB
    • dwukrotne kliknięcie przycisku wysłania
    • wysłanie niepoprawnego JSON
    • żądanie miliona wierszy naraz
    • kontynuowanie używania przestarzałego klienta
    • działanie bezpośrednio z API, a nie przez interfejs użytkownika
    • powodowanie wygaśnięcia czasu oczekiwania usługi zewnętrznej
    • uruchomienie dwóch aktualizacji w tym samym momencie
    • ponowne uruchomienie serwera w trakcie zapytania

    Jednak w końcu ktoś to robi.

    Czasami przez przypadek. Czasami z powodu błędu gdzie indziej w systemie. Czasami z powodu niestabilnej sieci. Czasami po prostu dlatego, że twój produkt zaczął odnosić sukcesy.

    W środowisku produkcyjnym nie są potrzebni źli użytkownicy, aby ujawnić błędne założenia – sama skala wystarcza, by je odsłonić.

    Buduj z myślą o awariach, a nie tylko o sukcesie

    Znacząca zmiana w sposobie myślenia doświadczonych programistów zajmujących się backendem polega na tym, że przy pisaniu funkcji nie należy poprzestać na zastanawianiu się, co się dzieje w przypadku jej pomyślnego wykonania – trzeba również zastanowić się, co się dzieje w przypadku niepowodzenia.

    What happens when it succeeds?
    
    What happens when it fails?
    

    Gdy łączysz się z bazą danych, zastanów się, co się stanie, jeśli nie będzie dostępna.

    What if it's unavailable?
    

    Gdy łączysz się z Redis, zastanów się, co się stanie, jeśli odpowie powoli.

    What if it's slow?
    

    Gdy wysyłasz e-mail, zastanów się, co się stanie, jeśli czas oczekiwania na odpowiedź od usługodawcy się skończy.

    What if the provider times out?
    

    Gdy przesyłasz plik, zastanów się, co się stanie, jeśli przechowywanie pliku zawiedzie tuż po zapisaniu rekordu w bazie danych.

    What if storage fails after the database record is created?
    

    Gdy tworzysz zamówienie, zastanów się, co się stanie, jeśli ta sama prośba dotrze dwa razy.

    What if the request is repeated?
    

    Gdy przetwarzasz płatność, zastanów się, co się stanie, jeśli klient nigdy nie otrzyma odpowiedzi.

    What if the client never receives the response?
    

    Podczas wdrażania należy zapytać, co się stanie, jeśli nowa wersja ulegnie awarii przy starcie.

    What if the new version crashes?
    

    Regularne zadawanie tych pytań zmienia sposób projektowania architektury.

    Prosty model myślowy do rozwoju backendu produkcyjnego

    Podczas tworzenia funkcji backendowej pomocne jest przeanalizowanie jej przez pięć poziomów myślenia.

    1. Poprawność

    Czy funkcja rzeczywiście robi to, do czego jest przeznaczona?

    Create order → order actually gets created
    

    2. Bezpieczeństwo

    Czy ktoś może wykonać czynność, do której nie powinien mieć dostępu?

    User A → access User B's order
    

    3. Wydajność

    Co się stanie, gdy objętość danych lub ruch wzrośnie znacznie?

    100 users → 10 million users
    

    4. Niezawodność

    Co się stanie, gdy systemy, od których zależy ta funkcja, przestaną działać?

    Database unavailable
    

    5. Obsługiwalność

    Czy w przypadku awarii można ją faktycznie zdiagnozować i naprawić?

    Request failed at 03:42:17
    

    Przejście przez te pięć warstw zazwyczaj ujawnia zaskakująco dużą liczbę problemów, zanim te dotrą do środowiska produkcyjnego.

    Istnieje rzeczywista luka pomiędzy oprogramowaniem, które po prostu działa, a takim, które potrafi przetrwać kontakt z rzeczywistym światem.

    Lokalna wersja oprogramowania musi udowodnić tylko jedną rzecz:

    feature works
    

    Wersja produkcyjna musi udowodnić kilka rzeczy:

    feature works
    feature fails safely
    feature handles load
    feature protects data
    feature can be monitored
    feature can recover
    feature can be deployed
    feature can be rolled back
    

    To znacznie wyższy standard do osiągnięcia. To również sprawia, że inżynieria backendu jest naprawdę interesującą dyscypliną.

    Oto krótkie podsumowanie wszystkiego, co zostało omówione.

    1. Brak konfiguracji środowiska

    Nie zakładaj, że to, co jest skonfigurowane na twoim laptopie, będzie dostępne również w środowisku produkcyjnym. Sprawdzaj wymagane zmienne środowiskowe przy uruchamianiu i wyświetlaj wyraźne komunikaty o błędzie, jeśli ich brakuje.

    2. Hardcoding localhost

    Wewnątrz kontenera localhost odnosi się wyłącznie do tego samego kontenera. Należy polegać na właściwym odkrywaniu usług oraz konfiguracji, która dostosowuje się do poszczególnych środowisk.

    3. Tworzenie połączeń z bazą danych na żądanie

    Należy używać puli połączeń i uwzględniać całkowitą liczbę połączeń we wszystkich działających instancjach aplikacji.

    4. Zwracanie wszystkiego z bazy danych

    Unikaj używania polecenia SELECT * z przyzwyczajenia. Zwracaj tylko te pola, których faktycznie potrzebują użytkownicy Twojej API.

    5. Ignorowanie indeksów

    Zapytanie, które działa dobrze przy tysiącu wierszach, może zawieść przy milionach. Testuj z obarami danych przypominającymi te w środowisku produkcyjnym.

    6. Traktowanie lokalnego przechowywania jako trwałego

    Kontenery, a także wiele platform chmurowych w ogóle, nie gwarantują, że pliki lokalne będą przechowywane. Kieruj treści przesłane przez użytkowników do magazynowania zaprojektowanego z myślą o trwałości.

    7. Brak ograniczeń szybkości

    Klienci sami decydują, jak często korzystają z twojej API. Wprowadź ograniczenia dla punktów końcowych, które są wrażliwe lub wymagają dużych zasobów do działania.

    8. Długotrwałe żądania synchroniczne

    Złożone przetwarzanie powinno odbywać się w tle, a nie czekać na zakończenie żądania HTTP.

    9. Ufanie danych wprowadzanych przez klienta

    Zawsze waliduj wszystko ponownie na poziomie backendu. Samych sprawdzeń w frontendzie nigdy nie wystarczy.

    10. Mylenie pojęć „proces aktywny” z „aplikacja sprawna”

    Opieraj się na sprawdzaniach stanu, monitoringu, uporządkowanych logach, metrykach oraz alertach, które rzeczywiście mają znaczenie.

    Ostateczne uwagi

    To, co uderzające w problemach produkcyjnych, to fakt, że prawie żaden z nich nie wygląda na alarmujący przy pierwszym spojrzeniu.

    Hardcodedowy localhost nie wygląda na problem. Brak indeksu nie wygląda na problem. Otwieranie nowego połączenia z bazą danych przy każdej prośbie nie wygląda na problem. Brak limitu czasowego nie wygląda na problem. Trasa API bez żadnych ograniczeń nie wygląda na problem. Zapisywanie pliku bezpośrednio na dysku nie wygląda na problem.

    Dopiero gdy pojawiają się rzeczywiści użytkownicy.

    Dopiero wtedy te małe, pozornie nieszkodliwe założenia zaczynają ze sobą kolidować. I nagle mamy do czynienia z:

    slow APIs
    database connection exhaustion
    memory spikes
    timeouts
    failed deployments
    missing files
    duplicate payments
    security issues
    

    Celem nie jest przewidywanie z góry każdego możliwego awarii. To niemożliwe do osiągnięcia. Celem jest przestanie budowania systemów w założeniu, że wszystko zawsze będzie przebiegać gładko.

    Ponieważ tak nie będzie.

    Sieci zawodzą. Bazy danych zwalniają. Kontenery są restartowane. Użytkownicy wysyłają dane, których w ogóle się nie spodziewasz. Zależności poniżej w łańcuchu dostaw tracą połączenie. Ruch sieciowy gwałtownie rośnie bez uprzedzenia. Serwery kończą pamięć. Prośby są duplikowane. Wdrożenia idą nie tak.

    Nic z tego nie musi mieć katastrofalnych konsekwencji, o ile system został zaprojektowany z myślą o tych możliwościach od samego początku.

    To, co naprawdę odróżnia backend działający tylko na localhost od takiego, który radzi sobie w środowisku produkcyjnym, to nie język programowania, który wybrałeś. To sposób myślenia leżący u podstaw projektu.

    Na localhost pytanie brzmi po prostu: czy to działa?

    W środowisku produkcyjnym istotne jest pytanie: co się dzieje, gdy to nie działa?

    To drugie pytanie, zadawane raz po raz, uczy o inżynierii backendu znacznie więcej niż jakikolwiek framework czy narzędzie.

    Jeśli budujesz swój pierwszy system produkcyjny, opieraj się pokusie naprawiania wszystkiego od razu. Zaczynaj od małych kroków: upewnij się, że poprawnie zarządzasz konfiguracją, dodaj mechanizm gromadzenia połączeń, waliduj każdy wprowadzony dane, twórz indeksy na podstawie sposobu, w jaki faktycznie wyszukujesz dane, wyraźnie obsługuj timeouty, przechowuj pliki we właściwym miejscu, dodaj sensowne logowanie, monitoruj to, co jest ważne, i przy każdym kroku przemyśl możliwe scenariusze awarii. Następnie powtarzaj ten proces.

    Gotowość do użytku w środowisku produkcyjnym to nie coś, co zaznaczasz raz i przechodzisz dalej. To nawyk, który rozwijasz z czasem jako programista.

    I stopniowo to staje się różnicą między systemem, który tylko mówi „działa na moim komputerze”, a takim, który naprawdę funkcjonuje w kluczowych sytuacjach.

    Literatura pokrewna

  • Siedem ukrytych założeń, które powodują failure JavaScript przy rzeczywistym ruchu — Dowiedz się, jakie założenia dotyczące typów, ponawiania prób, równoczesności, kolejności odpowiedzi, objętości danych, brakujących danych oraz stanu w pamięci powodują błędy w kodzie JavaScript po wprowadzeniu go do środowiska produkcyjnego.
  • Tabele outbox i inbox: projektowanie webhooki, które przetrwają błędy — Dowiedz się, w jaki sposób transakcyjny outbox, idempotentny inbox, mechanizm odkładania z uwzględnieniem stanu oraz kolejki list nieodebranych zapewniają niezawodną dostawę webhooki w AWS, Azure i GCP.