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.
Ź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
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
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
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 (...) klauzuleNajważ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ń
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?
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
- 20 patterni Node.js, które zapobiegają przerwom w serwerze produkcyjnym — Poznaj 20 praktycznych patterni Node.js – od obsługi błędów po płynne wyłączanie i zarządzanie zasobami połączeń – które zapobiegają awariom, zanim staną się konieczne ponowne uruchomienia.
- Powszechne błędy w kontrakcie API, które niszczą poufność frontendu — Dowiedz się o dziesięciu często występujących wadach projektowania API backendu – od niestabilnej struktury odpowiedzi po kruche mechanizmy paginacji – które podważają zaufanie do frontendu oraz o sposobach ich naprawy.