Чому код бекенду, який працює локально, зламується під справжньою навантаженням у продакшені.
Практичний огляд припущень щодо середовища, бази даних, безпеки та надійності, які успішно функціонують на localhost, але спричиняють перерви у роботі, коли починається справжній трафік у продакшені.
Джерела: 10 Backend Mistakes That Work Perfectly on localhost but Break in Production
Він працює ідеально на вашому комп’ютері. Але чи витримає він навантаження реального трафіку? Ось 10 помилок бекенду, які можуть тихо перетворити безпроблемно працюючий додаток на катастрофу в умовах реальної експлуатації.
Локально все здається гаразд: запити повертаються швидко, запити до бази даних надають правильні дані, входження працює, Docker запускається без проблем, тести проходять. Але коли ви розгортаєте додаток, відкриваєте його живу URL-адресу, щось ламається.
Іноді додаток відмовляється запускатися.
Іноді користувачі не можуть увійти до своїх облікових записів.
Іноді база даних раптово починає працювати надзвичайно повільно.
Іноді все працює без проблем кілька хвилин, а потім починають накопичуватися помилки підключення.
А іноді сервіс ззовні виглядає абсолютно нормальним, хоча всередині завдає прихованої шкоди.
Що робить ситуацію ще більш дратівливою, так це те, що більшість цих проблем не пов’язані з складними алгоритмами.
Вони виникають з незаперечених, неперевірених припущень.
Такі припущення, як:
- "У нас не буде такого великого трафіку."
- "База даних знаходиться на цьому ж пристрої."
- "Ця змінна середовища завжди буде встановлена."
- "Одне підключення до бази даних — це достатньо."
- "Запити завжди будуть виконуватися швидко."
- "Файли, збережені на диску, залишатимуться там і пізніше."
- "Ніхто не буде інтенсивно використовувати цей кінець шляху."
- "API завжди отримуватиме лише коректний вхідний даний."
- "У контейнері ніколи не закінчиться пам’ять."
- "Процес, який працює, означає, що додаток працює коректно."
Ваша локальна налаштування захищає вас від більшості цих проблем.
У продакшені такого захисту немає.
У цій статті розглядаються 10 помилок бекенду, які зазвичай працюють бездоганно на localhost, але завдають серйозної шкоди після розгортання.
Жодна з цих проблем не є абстрактною чи гіпотетичною.
Це ті швидкі рішення, які здаються цілком доцільними під час активної розробки функцій.
Саме тому вони є ризикованими.
1. Розгляд змінних середовища як необов’язкових параметрів конфігурації
Почнемо з однієї з найпоширеніших помилок.
Припустимо, ви розробляєте API на своєму ноутбуці.
Налаштування підключення до бази даних виглядають так:
DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
JWT_SECRET=my-super-secret-key
PORT=5000
Додаток запускається без проблем.
Нічого не здається неправильним.
Ви випускаєте його у продакшн.
І тоді, раптово:
Error: password authentication failed
Або можливо:
JWT_SECRET is undefined
Або:
ECONNREFUSED 127.0.0.1:5432
Вашим першим інстинктом може бути звинувачення платформи хостингу.
Це рідко є справжньою причиною.
Насправді ваш додаток тихо використовує налаштування, які існували лише на вашому розробницькому комп’ютері.
Пастка localhost
Локально у вас може бути щось на кшталт:
DATABASE_URL=postgresql://localhost/myapp
Тоді як у продакшені налаштування виглядають так:
DATABASE_URL=postgresql://db.internal:5432/myapp
Це дві абсолютно різні конфігурації.
Ситуація погіршується, коли розробники вбудовують налаштування безпосередньо у вихідний код:
const dbHost = "localhost";
const dbUser = "postgres";
const dbPassword = "password";
Це працює чудово.
Принаймні до того моменту, поки це не перестає працювати.
Продакшен — це не ваш ноутбук.
База даних може знаходитися на:
- окремому сервері
- іншому контейнері
- провайдері керованих баз даних
- приватній внутрішній мережі
- іншій зоні доступності
- зовсім іншому постачальнику хмарних послуг
Ваш додаток потребує способу визначення того, де саме знаходиться ця база даних.
Конфігурація має походити з середовища
const config = {
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
port: process.env.PORT || 5000
};
Але тут все ще приховується ще одна, більш тонка помилка.
Просте отримання значень з змінних середовища саме по собі недостатньо.
Також потрібно перевірити, чи вони справді наявні.
Наприклад:
const requiredEnv = [
"DATABASE_URL", "JWT_SECRET"
];
for (const key of requiredEnv) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
Тепер додаток відмовляється запускатися, якщо бракує необхідної налаштування.
Така рання помилка насправді є позитивною річчю.
Порівняйте це з ситуацією, коли додаток запускається нормально та починає працювати некоректно лише тоді, коли перший користувач намагається увійти.
Бачити щось подібне:
Missing required environment variable: JWT_SECRET
під час розгортання набагато краще, ніж дізнаватися про це з розлюченого запиту підтримки.
Не встановлюйте потайні небезпечні значення за замовчуванням
Цей шляхшот здається привабливим:
const jwtSecret = process.env.JWT_SECRET || "secret";
Він полегшує роботу під час локального розроблення.
Це також створює можливість для вразливостей у безпеці.
Уявіть, що ви забули встановити JWT_SECRET у своєму продакшн-середовищі.
Додаток не завалиться.
Він просто буде тихо переходити на:
secret
Тепер ваша діюча система підписує токени значенням, яке може вгадати будь-хто.
Більш безпечна версія виглядає так:
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET must be configured");
}
Значення за замовчуванням мають сенс для нешкідливих налаштувань.
Вони є ризиком для всього, що пов’язано з безпекою.
Перевірка конфігурації має відбуватися під час запуску
Патерн, який варто застосувати, — це ставитися до конфігурації майже як до обов’язкової залежності, яку необхідно задовольнити перед тим, як запустити щось інше.
Наприклад:
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();
Це надає вашому додатку чіткі умови для роботи.
Або конфігурація є коректною, або процес ніколи не почнеться.
Ця жертва цілком варта того.
2. Вказування на localhost для сервісів, які знаходяться поза вашим додатком
Ця помилка заслуговує окремої уваги, тому що вона постійно з’являється, як тільки код виходить за межі комп’ютера розробника.
Припустимо, ваш бекенд взаємодіє з PostgreSQL.
На вашому ноутбуці налаштування виглядають так:
Backend → localhost:5432 → PostgreSQL
І все працює без проблем.
Потім ви контейнеризуєте бекенд за допомогою Docker.
PostgreSQL тепер запускається у окремому контейнері.
Ви можете залишити конфігурацію незмінною:
DATABASE_HOST=localhost
І тоді ви отримуєте:
connection refused
Що відбувається?
localhost більше не вказує на те, що ви думали.
Зсередини контейнера:
localhost
вказує на:
цей конкретний контейнер.
А не на:
контейнер, у якому запущена ваша база даних.
Непорозуміння цієї єдиної відмінності становить значну частину проблем під час розгортання Docker.
Мережування в Docker вимагає іншої моделі мислення
backend
database
redis
Ви можете визначити їх ось так:
services:
backend:
build: .
depends_on:
- database
- redis
database:
image: postgres
redis:
image: redis
Зсередини контейнера бекенду ви не можете просто отримати доступ до:
localhost:5432
Натомість ви звертаєтесь до іншого контейнера за його назвою сервісу:
database:5432
Та сама логіка застосовується до Redis:
redis:6379
Отримана топологія мережі виглядає так:
backend
|
+---- database:5432
|
+---- redis:6379
Спочатку це здається незнайомим, адже локальні налаштування зазвичай запускають усе безпосередньо на вашому хост-комп’ютері. У продакшені між компонентами з’являються справжні мережеві межі.
Хмарні сервіси створюють ту саму пастку
Локально ваш додаток може отримати доступ до Redis за адресою:
localhost:6379
Але в умовах продакшну Redis знаходиться на керованій послузі в зовсім іншому місці.
Правильна конфігурація там може виглядати так:
REDIS_URL=redis://redis-production.example.com:6379
Вашому додатку не потрібно знати цю різницю.
Ось у чому суть.
Пишіть свій код так, щоб він використовував конфігурацію у такому форматі:
createRedisClient({
url: process.env.REDIS_URL
});
Замість того, щоб жорстко закодовувати деталі підключення ось так:
createRedisClient({
host: "localhost",
port: 6379
});
Зберігайте послідовність формату конфігурації у всіх середовищах
Значення будуть змінюватися між середовищами.
Але структура не повинна змінюватися.
Наприклад, у режимі розробки:
DATABASE_URL=postgresql://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
А в умовах продакшну:
DATABASE_URL=postgresql://production-db:5432/myapp
REDIS_URL=redis://production-redis:6379
Одна й та сама база коду.
Різні значення для різних середовищ.
Саме така послідовність є метою.
3. Створення нового з’єднання до бази даних з кожним запитом
Ця конкретна помилка може залишатися непоміченою протягом тривалого часу, перш ніж спричинити проблеми.
Ви пишете маршрут ось так:
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);
});
Він працює без проблем.
Ви тестуєте його вручну.
Відповіді надходять швидко.
Ви розгортаєте його в продакшен.
Потім починає надходити справжній трафік.
Зрештою ви починаєте бачити помилки на кшталт:
too many connections
або:
remaining connection slots are reserved
або помічаєте, що використання CPU вашої бази даних постійно зростає.
У чому причина?
Кожен вхідний запит відкриває абсолютно нове з’єднання з базою даних.
При 100 одночасних запитах це може означати 100 окремих з’єднань.
При 1 000 запитах ситуація стає ще гіршою.
Створення з’єднань є дорогим
Встановлення з’єднання — це не безкоштовно. Залежно від вашої бази даних та налаштувань це може включати:
- налаштування мережевого з’єднання
Повторення всього цього для кожного запиту марнує ресурси. Саме тому додатки зазвичай використовують базу підключень замість цього.
У PostgreSQL це виглядає так:
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10
});
А ваш маршрут стає таким:
app.get("/users", async (req, res) => {
const result = await pool.query(
"SELECT id, name, email FROM users"
);
res.json(result.rows);
});
База підключень зберігає набір повторно використовуваних підключень у готовому стані.
Візуально це виглядає ось так:
┌── connection 1
Request ────────┼── connection 2
├── connection 3
├── connection 4
└── connection 5
А не так:
Request 1 → new connection
Request 2 → new connection
Request 3 → new connection
Request 4 → new connection
Бази підключень все одно можуть створювати проблеми у масштабі
Навіть із наявністю бази підключень можлива неправильна конфігурація.
Припустимо, ви запускаєте 10 інстанцій бекенду, і кожна з них налаштовується так:
pool.max = 20
Це призводить до потенційних проблем:
10 × 20 = 200
З’єднання. Якщо ваша база даних обмежує кількість з’єднань до 100, ви вже перевищуєте ліміт, навіть якщо налаштування пулу для кожної окремої інстанції здаються розумними окремо.
Саме такі проблеми ніколи не проявляються на машині окремого розробника.
Локально ви можете побачити:
1 application × 10 connections = 10
У продакшені:
10 applications × 20 connections = 200
Горизонтальне масштабування вашого додатку може тихо збільшити навантаження на базу даних.
Розмір пулу — це проблема всієї системи
При визначенні розміру пулу потрібно враховувати:
database connection limit
↓
number of application instances
↓
pool size per instance
↓
background workers
↓
admin/monitoring connections
Це не те, що можна вибрати довільно.
4. Передача кожної колонки бази даних клієнту
Ця конкретна помилка рідко призводить до миттєвого зупинення вашого додатку.
Саме тому вона є ризикованою.
id
name
email
password_hash
phone
address
created_at
updated_at
internal_notes
Ви пишете запит на кшталт:
SELECT * FROM users;
А потім відразу передаєте рядки у відповідь:
res.json(result.rows);
Це працює. Фронтенд отримує свій об’єкт користувача. Ви переходите далі.
Але насправді ви одночасно створили кілька проблем.
По-перше: витік даних
Відповідь може виглядати так:
{
"id": 12,
"name": "John",
"email": "john@example.com",
"password_hash": "...",
"internal_notes": "..."
}
Навіть якщо поле пароля зашифроване, а не у вигляді чистого тексту, немає жодних причин, чому воно мало б потрапити до клієнта. Крім того, всі поля, призначені лише для внутрішнього використання, також надсилаються разом з ним.
По-друге: пропускна здатність
Припустимо, кожен запис важить 2 КБ. Для тисячі записів це буде:
2 KB × 1,000 = 2 MB
Тепер уявіть, що таблиця з часом стає все ширшою. Ви можете опинитися в ситуації, коли доведеться передавати:
20 fields
хоча інтерфейс насправді використовує лише:
id
name
avatar
База даних не має уявлення про те, що насправді потребує ваш фронтенд — це рішення належить шару API.
Тож замість того, щоб:
SELECT * FROM users;
ви повинні написати:
SELECT
id,
name,
avatar_url
FROM users;
Тепер навантаження містить лише те, що потрібно саме цьому кінцевому пункту.
Це стає ще важливішим при об’єднанні даних
Розгляньмо запит на кшталт:
SELECT *
FROM users
JOIN orders ON orders.user_id = users.id
JOIN payments ON payments.order_id = orders.id;
Це може повернути величезну кількість даних, які ви ніколи не планували надсилати. Це спокусливий шляхшорт під час розробки, з такими міркуваннями:
"Я просто поверну все і дозволю фронтенду вибрати те, що йому потрібно."
Протистояйте цьому бажанню.
Формат відповіді API має бути свідомим. Наприклад:
const users = result.rows.map(user => ({
id: user.id,
name: user.name,
avatar: user.avatar_url
}));
Або, ще краще, перекладіть цю відповідальність на спеціалізований шар DTO чи серіалізації.
Контракт API має велике значення
API промислового рівня повинен вміти відповісти на таке:
Що саме обіцяє повернути цей ендпоїнт?
А не на таке:
Які саме стовпці існують у базі даних?
Ця різниця стає ще важливішою з ростом кодової бази.
5. Ігнорування індексів бази даних поки система не почне працювати повільно
Це може бути одним із найяскравіших прикладів ситуації, коли щось здається абсолютно нормальним у локальних умовах, але потайки перетворюється на проблему в продакшені.
Візьмемо запит на кшталт:
SELECT * FROM users
WHERE email = 'john@example.com';
У вашій дев-базі даних є:
500 users
запит повертається миттєво.
Ви розгортаєте систему. Минає шість місяців, і тепер у таблиці є:
5 million users
Раптово та сама запитування може стати значно повільнішою, оскільки база даних може потребувати сканування величезної частини таблиці для пошуку відповідностей.
Типова реакція є такою:
„Але раніше ця запитування працювала швидко!“
Звісно — тому що тоді таблиця була дуже маленькою.
Індекси існують саме з цієї причини
Додавання чогось на кшталт:
CREATE INDEX idx_users_email
ON users(email);
дозволяє PostgreSQL знаходити відповідні рядки набагато ефективніше, ніж шляхом повного сканування таблиці.
Просто створюйте індекси до всього.
Це само по собі є помилкою. Індекси не є безкоштовними — вони:
- займають простір на диску
- уповільнюють записи
- вимагають постійного обслуговування
- не допомагають з кожним типом запитів
- не варто додавати їх до кожної колонки
Натомість потрібно поставити таке запитання:
Які запити насправді виконує це додаток на практиці?
Індекс, заснований на шаблонах доступу
Якщо ваш додаток регулярно виконує щось на кшталт:
SELECT *
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
то композитний індекс у такому форматі може бути доцільним:
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);
Правильний вибір індексування завжди залежить від конкретного двигуна бази даних, способу розподілу даних, поведінки планувальника запитів та вашої фактичної навантаженості — саме тому індексування на основі припущень не працює.
Використовуйте EXPLAIN
Одним із найцінніших діагностичних інструментів у PostgreSQL є:
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
Він показує вам точно, як база даних виконала певний запит, замінюючи припущення на кшталт:
Цей запит має виконуватися швидко
на фактичні докази:
Ось що насправді робить база даних
Ця зміна мислення має величезне значення.
Локальні дані можуть бути неправдивими
Ось правило, яке варто запам’ятати: оцінка продуктивності на основі невеликого локального набору даних мало що говорить про те, як буде працювати та сама запитування у реальних умовах.
Запитування, яке виконується за:
5 ms
1 000 рядків, може поводитися зовсім інакше, коли йдеться про:
10 million rows
Щоб значуще тестувати продуктивність, потрібні обсяги даних, схожі на ті, які ви фактично побачите у реальних умовах.
6. Віра в те, що все, що збережено на диску, залишиться там
Ця проблема зазвичай виникає під час завантаження файлів.
На власному комп’ютері ви можете написати щось на кшталт:
app.post("/upload", upload.single("file"), (req, res) => {
console.log(req.file.path);
});
Завантажений файл потрапляє десь ось:
/uploads/photo.jpg
Ви завантажуєте додаток знову.
Файл залишається саме там, де ви його залишили.
Здається, нічого не так.
Потім ви надсилаєте додаток у контейнер або на якусь тимчасову платформу хостингу.
Відбувається нове розгортання.
Раптово файл зникає.
Починають надходити запити на підтримку:
Куди поділися мої зображення?
Чому це трапляється?
Тому що файлова система на вашому локальному комп’ютері поводиться зовсім інакше, ніж сховище в багатьох продуктивних середовищах.
Контейнер зазвичай проходить такий життєвий цикл:
created
running
destroyed
recreated
Як тільки контейнер знищується, все, що було записано лише на його локальний диск, зникає разом із ним.
Багато платформ хостингу за своєю конструкцією вважають файли, записані локально, тимчасовими.
Перестаньте використовувати сервер додатку як довгострокове сховище
Для обробки завантажень у продакшені зазвичай використовується підхід, коли файли зберігаються у спеціалізованих системах об’єктного сховища, а не на власному диску сервера.
Це дає вам архітектуру приблизно такою:
Client
↓
Backend
↓
Object Storage
↓
Database stores file metadata
Ваша база даних, тим часом, потребує лише зберігати посилання — щось на кшталт:
id
user_id
filename
storage_key
content_type
created_at
Фактичні байти файлу знаходяться у сервісі об’єктного зберігання, а не у вашій реляційній базі даних.
Розділення обов’язків у такий спосіб значно полегшує розуміння ситуації.
База даних та шар зберігання вирішують різні проблеми
Бази даних чудово справляються з:
- структурованими записами
- взаємозв’язками між записами
- гарантіями транзакцій
- запитами та фільтрацією
Об’єктне зберігання створене для:
- зображень
- відео
- PDF-файлів та інших великих документів
- статичних ресурсів взагалі
Спроба змусити одну систему виконувати обидві функції зазвичай призводить до проблем у довгостроковій перспективі.
Пов’язана проблема: включення завантажених файлів до вашої бази коду
Не є рідкістю знаходити проекти, організовані так:
project/
uploads/
src/
package.json
де користувач завантажує файли прямо поруч із власними вихідними файлами додатку.
Такий підхід швидко призводить до проблем.
100,000 uploaded images
файли знаходяться всередині папки вашого проекту.
Розгортання стає надзвичайно повільним та хаотичним.
Код додатку та контент, створений користувачем, мають зовсім різні цикли життя, тому вони мають знаходитися у окремих системах.
7. Випуск API без обмежень швидкості
Робота локально створює оманливу ситуацію.
Ймовірно, ви єдиний користувач, який використовує API.
GET /api/users
один раз.
Потім знову.
Можливо, ще кілька разів.
Нічого не ламається.
У продакшені все ведеться не так ввічливо.
Один користувач — випадково чи навмисно — може надіслати тисячі запитів протягом короткого проміжку часу.
Візьмемо, наприклад, такий кінцевий пункт:
POST /api/login
Без будь-якого обмеження швидкості атакувач може постійно намагатися увійти за допомогою повторних спроб входу:
attempt 1
attempt 2
attempt 3
...
attempt 10,000
Навіть ідеально безпечна перевірка пароля не запобігає зловживанню самим кінцевим пунктом у масштабах.
Обмеження частоти запитів створює контрольний пункт
Концептуально це виглядає так:
Client
↓
Rate limiter
↓
Application
Замість того, щоб дозволяти запитам надходити без обмежень, можна встановити ліміт для певної категорії кінцевих пунктів, наприклад:
100 requests / minute
Як тільки користувач перевищує цей ліміт, він отримує:
429 Too Many Requests
Правильні значення залежать від сценарію використання — кінцевий пункт для публічних пошуків не потребує такого ж ліміту, як, наприклад:
POST /login
або:
POST /forgot-password
Це стосується не лише зловмисного трафіку
Обмеження частоти запитів також захищають від помилок з боку клієнта.
setInterval(() => {
fetch("/api/notifications");
}, 100);
Ця єдина вкладка браузера тепер безперервно надсилає запити до вашого API.
У вашому бекенді раптово виникає справжня проблема.
Обмежувач частоти запитів створює захист від саме такої неконтрольованої поведінки.
Обмеження частоти запитів ускладнюється при горизонтальному масштабуванні
Server A
Server B
Server C
Якщо кожна інстанція зберігає в пам’яті власний лічильник, клієнт може фактично отримати в три рази більше дозволених запитів, по одному на кожен сервер.
Тоді налаштування виглядають приблизно так:
┌── Server A
Client → LB ──┼── Server B
└── Server C
|
Redis
Завдяки цьому спільному стану кожен екземпляр послідовно дотримується одних і тих самих обмежень.
Знову ж таки, це залишається непомітним під час виконання окремого процесу локально — немає проблем із розподіленим станом, поки це не стане проблемою у продакшені.
8. Припущення про те, що запити завжди виконуються протягом розумного часу
Це може стати несподіванкою.
Припустимо, ви створюєте ендпоінт наступного вигляду:
app.get("/report", async (req, res) => {
const data = await generateHugeReport();
res.json(data);
});
Локально звіт охоплює:
1,000 records
і завершується протягом:
300 ms
Все виглядає нормально.
У продакшені той самий звіт тепер охоплює:
5 million records
а запит тепер займає:
45 seconds
Тим часом ваш обернений проксі може бути налаштований так, щоб здатися після:
30 seconds
тож клієнт у кінцевому підсумку бачить:
504 Gateway Timeout
Навіть якщо ваш бекенд все ще працює у фоновому режимі.
Ця неузгодженість створює заплутану ситуацію з помилками.
Довготривалі операції не повинні виконуватися в межах циклу запит-відповідь
Якщо завдання потребує часу на виконання, зазвичай краще обробляти його як фонову роботу, а не так, щоб клієнт чекав на її результат безпосередньо.
Замість такого підходу:
Client
↓
POST /generate-report
↓
Wait 45 seconds
↓
Response
використовується щось на кшталт:
Client
↓
POST /generate-report
↓
Job created
↓
202 Accepted
де сама обробка відбувається окремо:
Queue
↓
Worker
↓
Generate report
↓
Store result
Клієнт може потім перевіряти статус за допомогою чогось на кшталт:
GET /reports/:id
або просто отримати сповіщення після завершення роботи.
Фонові черги виконують важку роботу
Поширені приклади завдань, які слід виконувати поза основним циклом запитів, включають:
- надсилання електронних листів
- створення PDF-файлів
Якщо не використовувати ці операції на основному потоці, ваш API залишатиметься швидким та реактивним.
Не забувайте про таймаути
Кожен виклик вашого сервісу до зовнішньої системи має мати розумний таймаут.
Наприклад:
fetch(url, {
signal: AbortSignal.timeout(5000)
});
Якщо ви проігноруєте це, залежність, яка зависла, може змусити ваше додаток чекати нескінченно.
API
↓
Payment service
↓
hangs
Ваш запит застряг, чекаючи на відповідь від чогось, що може ніколи не відповісти.
У певний момент у вашому сервері закінчуються ресурси для обробки будь-яких інших завдань.
Кожна залежність потребує плану на випадок збою
Для кожного зовнішнього сервісу, з яким спілкується ваше додаток, ви повинні мати можливість відповісти на одне конкретне запитання:
Що станеться, якщо цей сервіс не відповість?
А не на таке:
Що станеться, коли все працює без проблем?
На друге запитання легко відповісти.
Саме перше насправді визначає, чи зможе ваша система функціонувати під тиском.
9. Довіра до вхідних даних клієнта
Цю помилку майже незручно вказувати, оскільки проблемний код часто виглядає абсолютно логічним.
Візьмемо, наприклад, такий ендпоїнт:
app.post("/users", async (req, res) => {
const { name, email } = req.body;
await createUser(name, email);
res.json({
message: "User created"
});
});
На перший погляд, нічого не виглядає неправильно.
Але уявіть, що станеться, якщо тіло запиту виглядатиме ось так:
{
"name": "",
"email": "hello"
}
Або ось так:
{
"name": null,
"email": null
}
Або ось так:
{
"name": "A".repeat(1000000),
"email": "..."
}
Що станеться, якщо клієнт надішле поля, яких ваш код зовсім не очікував?
Під час розробки локально ви зазвичай надсилаєте ретельно підготовлені тестові запити.
У продакшені ваш API отримує все, що хтось вирішить йому надіслати.
Перевірка має відбуватися на вході
Добре структурований API перевіряє надходжені дані перед тим, як запустити будь-яку бізнес-логіку.
Наприклад, ви можете визначити таку структуру:
const schema = {
name: "string",
email: "email"
};
За допомогою бібліотеки перевірки ви можете застосовувати такі правила:
name required
name length <= 100
email required
email valid
Не має великого значення, яку саме бібліотеку перевірки ви оберете.
Важливим є основне правило:
Ніколи не припускайте, що клієнт надіслав те, чого ви очікували.
Пам’ятайте, що „клієнт“ може бути:
- ваш власний фронтенд
- мобільний додаток
- інтеграція з сторонньою системою
Перевірки з боку клієнта не є шаром безпеки
Поширена заперечення є такою:
"Але я вже перевіряю це на фронтенді, у React."
Це справді допомагає покращити користувацький досвід.
Але це нічим не захищає ваш бекенд.
Будь-хто може повністю проігнорувати код фронтенду.
Для цього достатньо запустити:
curl
спрямований безпосередньо на ваш кінець API.
Код з боку сервера все одно мусить самостійно дотримуватися правил.
SQL-ін’єкція — це пов’язане ризик
Уникайте створення запитів шляхом об’єднання рядків, як це:
const query = `
SELECT *
FROM users
WHERE email = '${email}'
`;
Натомість використовуйте параметризовані запити:
const result = await pool.query(
"SELECT * FROM users WHERE email = $1",
[email]
);
Таким чином драйвер бази даних зберігає значення параметрів окремо від структури запиту.
Перевірка вхідних даних та параметризовані запити борються з різними загрозами, але сервер виробничого рівня потребує обох.
10. Думка про те, що „додаток працює“, означає, що „додаток у належному стані“, — це найбільш фундаментальне непорозуміння.
Це, можливо, найбільше фундаментальне непорозуміння у цьому списку.
Ваш сервер запускається:
Server listening on port 5000
Журнали підтверджують:
Application started successfully.
У цей момент хочеться подумати:
Чудово. Ми закінчили.
Це не завжди так.
Процес може продовжувати працювати навіть тоді, коли сам додаток фактично несправний.
Розгляньте такий стан:
Backend process: running
Database: unavailable
Redis: unavailable
Payment provider: unavailable
Queue: stuck
З точки зору операційної системи процес є живим та у належному стані.
Ваш панель моніторингу може відображати:
CPU: 10%
Memory: 20%
Process: alive
Тим часом справжні користувачі бачать:
500 Internal Server Error
Перевірки стану заповнюють частину прогалини
Базовий ендпоїнт стану, наприклад:
GET /health
може просто повертати:
{
"status": "ok"
}
Але навіть це може бути оманливим.
Якщо /health лише підтверджує, що HTTP-сервер відповідає на запити, це нічого не говорить про те, чи функціонують залежності, від яких насправді залежить ваш додаток.
Більш повна конфігурація розділяє два ендпоїнти. Один:
/health
для базової перевірки життєздатності, а інший:
/ready
для перевірки готовності.
Концептуально різниця виглядає так:
Liveness: "Is this process alive?"
і:
Readiness:
"Can this instance actually serve traffic?"
Перевірка стану бази даних конкретно
Перевірка готовності може бути ще більш детальною та підтвердити, що з’єднання з базою даних справді працює, наприклад, шляхом виконання:
SELECT 1;
Якщо до бази даних неможливо дістатися, додаток може позначити себе як неготовий.
Це дає інструментам оркестрації можливість припинити направляти трафік до інстанції, яка насправді не може належним чином обробляти запити.
Лише інформація про час роботи мало що говорить
Сервер може повідомляти:
99.99% uptime
при цьому все одно надаючи своїм користувачам дуже повільний сервіс.
Саме тому моніторинг у продакшені повинен враховувати більше, ніж просто те, чи процес активний, включаючи такі показники, як:
- затримка обробки запитів
- частота помилок
- пропускна здатність
- продуктивність бази даних
- використання CPU
- використання пам’яті
- глибина черги
- проблеми з зовнішніми сервісами
Одним з особливо корисних показників є:
p95 latency
Замість того, щоб запитувати:
Який середній час відповіді?
більш корисно запитати:
Скільки часу займає 95% запитів?
Середні значення часто приховують найгірші ситуації.
Розгляньмо таку ситуацію:
99 requests = 50 ms
1 request = 30 seconds
Середній час відповіді тут може зовсім не здаватися тривожним.
Але цей один повільний запит все одно становить справді поганий досвід для того, хто його надіслав.
Ще одна помилка: логування всього, крім корисних даних
Ця помилка потрапила до списку тому, що допомогла врятувати більше однієї сесії відлагодження.
У продакшні щось ламається.
Ви відкриваєте журнали.
Усе, що ви знаходите:
Something went wrong
Це весь запис.
Немає ідентифікатора запиту.
Немає інформації про маршрут.
Немає контексту користувача.
Немає стек-трейсу.
Немає інформації про те, який саме запит до бази даних був використаний.
Немає часової мітки, достатньо точної для порівняння з чимось іншим.
Лише простий рядок з текстом:
Something went wrong
У такій ситуації ви намагаєтесь вирішити проблему без реальної інформації.
Як має виглядати корисний запис журналу
Коли щось зазнає невдачі, запитання, на які потрібні відповіді, є такими:
When?
Where?
Which request?
Which service?
What failed?
How long did it take?
What dependency was involved?
Належним чином структурований запис журналу фіксує цей контекст, щось на кшталт:
{
"level": "error",
"requestId": "abc-123",
"route": "/api/orders",
"method": "POST",
"duration": 843,
"error": "Database timeout"
}
Ця версія дійсно надає вам щось, з чим можна працювати.
Ніколи не записуйте конфіденційні дані у журнал
Водночас будьте обережні, щоб ненавмисно не записувати:
password
access token
refresh token
credit card information
private keys
Журналізація існує для допомоги у діагностиці проблем, а не для того, щоб надати зловмисникам ще одну проблему.
Чому ці проблеми ніколи не з’являються на вашому ноутбуці
Можна цілком логічно запитати, чому стільки розробників повторюють одні й ті самі помилки, якщо вони настільки поширені.
Відповідь полягає у тому, що локальна налаштування та середовище продакшну — це фундаментально різні системи.
Ось у чому різниця.
Як виглядає локальна розробка
Зазвичай ви маєте справу з:
1 developer
1 application
1 database
small dataset
low traffic
fast network
known inputs
stable filesystem
single process
Тим часом у продакшні може бути:
thousands of users
multiple application instances
millions of database rows
unpredictable traffic
network latency
invalid requests
external dependencies
ephemeral containers
background workers
multiple regions
Це не скорочена версія тієї самої системи — це зовсім інша система.
Вихідний код може залишатися незмінним.
Але середовище навколо нього — ні.
Звичка орієнтуватися лише на оптимістичний сценарій
Цілком природно тестувати лише успішний перебіг справ.
Типовий приклад:
Create user
↓
Login
↓
Create order
↓
Payment succeeds
↓
Return response
Ця послідовність представляє найкращий сценарій.
Але трафік у продакшні ніколи не обмежується лише оптимістичними сценаріями.
Вам також потрібно враховувати ситуації на кшталт:
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?
У розподіленій системі такі випадки не є рідкісними узголів’ями.
Це звичайні ситуації.
Перехід від „Чи працює це?“ до „А що, якщо це зазнає невдачі?“
З ростом досвіду роботи з системами бекенду змінюються ваші інстинкти.
Спочатку постає таке питання:
Чи працює цей код?
З часом воно стає таким:
Що станеться, якщо цей код зазнає невдачі?
Це друге питання є набагато кориснішим.
Візьмемо цей рядок:
const result = await paymentService.charge();
Міркування початківця закінчуються тут:
Якщо виклик успішний, переходьте далі.
Міркування, орієнтовані на продакшн, продовжуються:
А що, якщо виникне тайм-аут?
Потім:
Що, як оплата пройде, але відповідь до мого API не надійде через тайм-аут?
Тоді:
Що, як клієнт спробує знову надіслати запит?
Тоді:
Чи може клієнт бути оштрафований двічі?
У цьому випадку ви розглядаєте сценарії збоїв, що є суттю роботи інженерів бекенду.
Ідемпотентність: що забезпечує безпеку процесів оплати
Продовжуючи цей приклад: уявіть, що користувач натискає
Pay
Запит надходить на ваш сервер.
Оплата відбувається успішно.
Але з’єднання втрачається до того, як надійде відповідь.
Клієнт не може дізнатися, чи справді відбулась оплата, тому намагається знову.
Тепер сервер вже обробив:
Payment request #1 → success
Payment request #2 → success
Що може легко призвести до:
Customer charged twice.
Ідемпотентність існує саме для того, щоб запобігти цьому.
Ключ ідемпотентності може виглядати так:
payment_7f8c123
Клієнт додає цей ключ до запиту, а сервер зберігає його разом із результатом цієї операції. Якщо знову надходить запит із тим самим ключем, сервер просто повертає збережений результат замість того, щоб виконувати операцію ще раз.
У стислому вигляді:
Request
↓
Idempotency key
↓
Already processed?
├── Yes → return previous result
└── No → process → store result
Ця модель має велике значення для таких операцій, як:
- оплати
- створення замовлень
- реєстрація облікових записів
- надсилання електронних листів
- запити до API сторонніх сервісів
Баги, пов’язані з дублюванням запитів, рідко з’являються під час локальних тестів, але постійно виникають, коли задіяні реальні, ненадійні мережі.
Транзакції: ще одна річ, яка здається нормальною, поки це не припиняється
Подумайте про те, що відбувається після створення замовлення.
Робочий процес може включати кілька кроків:
Create order
Create order items
Update inventory
Create payment record
Якщо ви не обгорнете ці операції у транзакцію, одна з них може зазнати невдачі на певному етапі.
Create order ✓
Create items ✓
Update inventory ✓
Payment record ✗
На цьому етапі ваша база даних знаходиться у напівзавершеному стані.
Транзакції існують для того, щоб можна було об’єднати операції, які повинні або всі виконатися успішно, або всі зазнати невдачі як єдине ціле.
Ось як це виглядає на практиці:
BEGIN;
INSERT INTO orders (...);
INSERT INTO order_items (...);
UPDATE inventory
SET quantity = quantity - 1
WHERE product_id = 123;
COMMIT;
А коли щось йде не так протягом виконання:
ROLLBACK;
Проте обгортання кожної окремої операції у одну величезну транзакцію також не є правильним підходом.
Межі транзакцій потребують ретельного проектування.
Транзакції, які тривають занадто довго, можуть створити свої власні проблеми.
Питання, яке ви завжди повинні ставити собі:
Якщо це зазнає невдачі на певному етапі, у якому стані опиниться система?
Запити N+1: тихий вбивця продуктивності
Існує ще одна проблема, яка зазвичай залишається непоміченою під час розробки локально.
Припустимо, ви отримуєте список користувачів:
SELECT * FROM users LIMIT 100;
А потім для кожного окремого користувача ви отримуєте їхні замовлення:
SELECT * FROM orders WHERE user_id = ?;
Це призводить до:
1 query for users
+
100 queries for orders
=
101 queries
Це є відомою пасткою запитів N+1.
Якщо у вас лише 10 користувачів:
11 queries
Це ще під контролем.
Але якщо кількість користувачів зросте до 10 000:
10,001 queries
то раптово виникає справжня проблема з продуктивністю.
Рішенням не обов’язково є „об’єднати все в один величезний запит“.
Залежно від вашої ситуації, ви можете скористатися:
- з’єднаннями
- швидким завантаженням
- групуванням
IN (...) конструкціїНайважливіше — це здатність спочатку помітити цей шаблон.
Бекенд може здаватися абсолютно швидким при роботі з невеликою локальною базою даних, проте при справжній навантаженості таємно виконувати тисячі запитів.
Пагінація є обов’язковою для великих колекцій
Такий ендпоїнт здається досить безневинним:
GET /users
А запит, який стоїть за ним:
SELECT *
FROM users;
функціонує без проблем, коли у вас є:
50 users
Але що відбувається, коли ви маєте справу з:
5 million users?
Надсилання п’яти мільйонів записів назад до клієнта — це те, чого ви не хочете робити.
Пагінація дає можливість обмежити це.
Ви можете використовувати пагінацію на основі зсуву:
GET /users?page=2&limit=50
або пагінацію на основі курсора:
GET /users?cursor=abc123&limit=50
Сторінкування за курсором зазвичай працює краще для великих або постійно змінюваних наборів даних, оскільки воно уникає деяких проблем з продуктивністю та послідовністю, які виникають при дуже великих зсувах.
І знову основна проблема полягає у:
localhost data = small
production data = potentially huge
Ваш API потрібно створювати, враховуючи цю прогалину.
Витоки пам’яті не завжди проявляються негайно
Це ще один тип помилки, яка проявляється лише у продакшені.
Ваш додаток запускається за допомогою:
Memory: 150 MB
Через годину:
300 MB
Через шість годин:
600 MB
А зрештою:
Out of memory
Потім він зупиняється та перезапускається.
На цьому етапі все знову виглядає нормально.
Така схема є яскравим сигналом того, що щось утримує пам’ять, яку не повинно.
Поширені причини включають:
- масиви, які ростуть без будь-яких обмежень
Коли ви тестуєте програму локально, вона рідко працює достатньо довго, щоб такі проблеми проявилися.
У продакшені вона працює достатньо довго, щоб ці витоки стали значущими.
Саме тому так важливо стежити за поведінкою додатку протягом тривалого часу.
Кешування також може пошкодити роботу в продакшені
На папері кешування здається простим рішенням:
Database → slow
Redis → fast
Тож інстинктивно хочеться кешувати все.
Але потім базові дані змінюються.
І API продовжує повертати застарілу інформацію.
У результаті виникає неузгодження, наприклад:
Database:
balance = 100
Cache:
balance = 80
API відповідає:
80
І користувач хоче знати:
Чому мій баланс неправильний?
Кешування змушує вас відповідати на такі запитання:
- Коли записи мають закінчити свою дію?
- Коли кеш стає недійсним?
- Який запасний варіант, якщо Redis зупиниться?
- Що відбувається, коли два сервери записують у той самий ключ?
- Чи є прийнятним тут надання застарілих даних?
- Що має відбутися одразу після розгортання?
Найскладнішою частиною кешування ніколи не було зберігання даних.
Це визначення моменту, коли збережені дані перестають бути надійними.
При збоях кешу має бути стратегія
Якщо Redis несподівано зникає, чи буде ваш додаток:
fail completely
або чи буде він:
fall back to database
Універсальної відповіді немає — це залежить від системи, яку ви створюєте.
Важливо те, щоб ви здійснювали цей дзвінок навмисно, а не випадково.
Таємниці в Git: помилка, яка може призвести до інциденту з безпекою
Наступна річ здається здоровим глуздом, проте вона продовжує траплятися.
Ви створюєте файл:
DATABASE_PASSWORD=...
JWT_SECRET=...
API_KEY=...
А потім запускаєте:
git add .
git commit -m "config"
git push
У цей момент таємниця вже входить до історії Git.
Видалення файлу пізніше не стирає її — значення все одно можна відновити з попередніх комітів.
Кращий підхід — зберігати:
.env
у списку всередині .gitignore, та комітувати шаблон замість цього:
.env.example
з місцями для заміни, такими як:
DATABASE_URL=
JWT_SECRET=
REDIS_URL=
Справжні значення слід вставляти через налаштування середовища вашої платформи розгортання або спеціалізований менеджер таємниць.
А якщо справжня таємниця все ж потрапить у коміт:
Не просто видаляйте цей рядок та вважайте, що все готово.
Негайно змініть цей секретний код.
Вважайте його порушеним, незалежно від того, чи можете ви довести, що до нього колись був доступ.
CORS — це не механізм автентифікації
Ось ще один момент, який вводить людей в оману:
Access-Control-Allow-Origin: *
CORS регулює те, як браузери обробляють запити між різними доменами.
Він нічого не робить для фактичного захисту вашого API.
Ніщо не заважає безпосередньому доступу до серверного кінцевого пункту через:
curl
Postman
mobile apps
backend services
scripts
CORS ніколи не був створений як шар автентифікації.
Вашому API все одно потрібні справжні:
- механізми автентифікації
- механізми авторизації
- перевірка вхідних даних
- обмеження частоти запитів
- контроль доступу
Безпека має бути забезпечена прямо, а не через правила браузера.
Автентифікація та авторизація — це різні речі
Плутання цих двох понять призводить до справжніх вразливостей безпеки.
Відповіді на запитання автентифікації:
Хто ви?
Відповіді на запитання авторизації:
Чи дозволено вам це робити?
Розгляньмо запит на кшталт:
GET /users/123
Користувач може бути автентифікованим.
Але це нічого не говорить про те, чи дозволено йому переглядати:
user 123
Якщо особа, яка надсилає запит, є:
user 456
сервер все одно має з’ясувати, чи:
456 → allowed to access 123?
Поширена помилка виглядає так:
if (req.user) {
return getUser(req.params.id);
}
Цей код лише перевіряє, чи хтось увійшов у систему.
Він ніколи не перевіряє, чи дозволено йому отримати доступ до конкретного ресурсу.
Більш безпечний підхід враховує право власності або прямі дозволи:
if (req.user.id !== req.params.id) {
return res.status(403).json({
error: "Forbidden"
});
}
Конкретні правила будуть відрізнятися залежно від додатку.
Але основний принцип залишається однаковим у всіх випадках:
Увійшовши, ви не отримуєте повного доступу до всього в системі.
Розгортання — це частина додатку
Важливий урок, який варто засвоїти з самого початку, — це те, що розгортання не є окремим етапом, який додається наприкінці розробки.
Це частина самої інженерної роботи.
Ваш додаток — це не лише:
source code
Це поєднання:
source code
+
configuration
+
database
+
network
+
storage
+
secrets
+
dependencies
+
monitoring
+
deployment process
Людина, яка розуміє лише код додатку, все одно може відчути перевтому, коли система починає працювати в умовах продакшну.
Щоб ефективно працювати, не обов’язково опановувати кожну деталь Kubernetes.
Але необхідно мати чітке розуміння середовища, в якому фактично працює ваш код.
Чек-лист готовності до продакшну, який я справді використовую
Перш ніж оголосити щось готовим до використання в продакшені, корисно пройтися по такому списку перевірок.
Конфігурація
- Чи перевіряються необхідні змінні середовища під час запуску?
- Чи зберігаються конфіденційні дані окремо від коду джерела?
- Чи є стандартні налаштування для продакшену безпечними, а не лише зручними?
- Чи зберігаються налаштування для розробки та продакшену окремо?
База даних
- Чи налаштовано пулі з’єднань?
- Чи є розмір пулу адекватним?
- Чи індексовані важливі запити?
- Чи були проаналізовані повільні запити?
- Чи використовуються транзакції там, де це необхідно?
- Чи відстежуються міграції за допомогою контролю версій?
API
- Чи перевіряється вхідний даний?
- Чи свідомо формуються тіла відповідей?
- Чи структуровані великі набори результатів у форматі сторінок?
- Чи обмежений розмір тіла запиту?
Безпека
- Чи хешуються паролі за допомогою безпечного алгоритму?
- Чи зберігаються конфіденційні дані у безпечний спосіб?
- Чи дійсно застосовується авторизація?
- Чи є SQL-запити параметризованими?
- Чи застосовується обмеження частоти запитів там, де це важливо?
- Чи не потрапляють конфіденційні поля до журналів?
Інфраструктура
- Чи є щось, що неправильно залежить від
localhost? - Чи зберігаються постійні дані поза тимчасовими контейнерами?
- Чи є відкритими кінцеві точки перевірки стану?
- Чи реалізовано плавне завершення роботи?
- Що насправді відбувається під час перезапуску додатку?
Продуктивність
- Чи тестувався система з використанням реалістичних об’ємів даних?
- Чи існують приховані шаблони запитів N+1?
- Чи переносяться дорогі завдання на фонові обробки?
- Чи є обмеження за розміром кешу?
- Чи відстежується використання пам’яті?
Спостережуваність
- Чи містять журнали помилок достатньо контексту, щоб бути корисними?
- Чи налаштоване відстеження запитів?
- Чи контролюються затримки та частота помилок?
- Чи видно збої в базі даних у системі моніторингу?
- Чи справді налаштовані сповіщення?
Грайсфул шутдаун: дрібниця, про яку забувають
SIGTERM
сигнал, оскільки платформа хостингу хоче перезапустити інстанцію.
Якщо процес завершується миттєво, будь-які запити, які ще виконуються, можуть повністю зазнати невдачі.
Грайсфул шутдаун краще справляється з цим.
Ідея в коді приблизно виглядає так:
process.on("SIGTERM", async () => {
console.log("Shutdown signal received");
server.close(async () => {
await pool.end();
console.log("Server closed");
process.exit(0);
});
});
Точна реалізація буде відрізнятися залежно від вашої фреймворк-системи.
Але основна послідовність залишається однаковою:
Stop accepting new requests
↓
Finish active requests
↓
Close database connections
↓
Exit
Це стає ще важливішим, коли ви одночасно запускаєте кілька інстанцій додатку.
Логіка повторних спроб може погіршити проблему
Коли виклик зовнішнього сервісу провалюється, повторна спроба здається очевидним рішенням:
try {
await callService();
} catch {
await callService();
}
Саме по собі це здається досить безневинним.
Але тепер уявіть собі тисячу запитів, які всі дотримуються цього ж шаблону.
Припустимо, залежність починає відповідати повільно.
Кожен з цих невдалих викликів автоматично підлягає повторній спробі.
Залежність, яка вже має труднощі, тепер отримує ще більше навантаження, ніж раніше.
У цього шаблону є назва: буря повторних спроб.
Логіку повторних спроб потрібно розробляти свідомо, враховуючи такі аспекти:
- обмеження кількості спроб
- експоненційне затримування
- нерегулярності
- раціональні таймаути
- гарантії ідемпотентності
- захист від збоїв там, де це доцільно
Повторна спроба не є автоматичним механізмом відновлення.
У деяких випадках саме це перетворює незначну проблему на повне зупинення роботи.
Чому баги в продакшені здаються настільки іншими
Типовий локальний баг зводиться приблизно до такого:
I made a mistake.
Типова ситуація в продакшені зазвичай виглядає більше так:
Several individually reasonable things interacted badly.
Як приклад:
Traffic increases
↓
More API instances
↓
More database connections
↓
Database reaches connection limit
↓
Queries fail
↓
Requests retry
↓
More load
↓
System becomes even less stable
Жоден з окремих рядків сам по собі не виглядає небезпечним.
Справжня проблема полягає у тому, як вони взаємодіють під навантаженням.
Саме тому інженерія бекенду з часом зміщує увагу від ізольованих функцій до поведінки системи в цілому.
Найнебезпечніше речення у розробці бекенду
Мабуть, найризикованішою фразою у всій роботі з бекендом є:
"Ніхто цього не зробить."
Ніхто не буде:
- надіслати 10 000 запитів одночасно
- завантажити файл розміром 500 МБ
- двічі клацнути кнопку надсилання
- надіслати некоректний JSON-даний
- запитати мільйон рядків одразу
- продовжувати використовувати застарілий клієнт
- звертатися до API безпосередньо, а не через інтерфейс
- спричинити тайм-аут зовнішнього сервісу
- запустити два оновлення одночасно
- перезавантажити сервер під час обробки запиту
Проте зрештою хтось це все одно робить.
Іноді випадково. Іноді через баг у іншій частині системи. Іноді через нестабільну мережу. Іноді просто тому, що ваш продукт починає набирати популярності.
У реальних умовах не потрібні зловмисники, щоб виявилися хиткі припущення — сама масштабність достатня, щоб їх виявити.
Створюйте з урахуванням можливостей провалу, а не лише успіху
Суттєва зміна у способі мислення досвідчених розробників бекенду полягає у наступному: коли ви пишете функцію, не слід обмежуватися запитанням про те, що відбувається при її успішному виконанні. Вам також потрібно замислитися над тим, що відбувається при її невдачі.
What happens when it succeeds?
What happens when it fails?
Коли ви звертаєтесь до бази даних, подумайте про те, що станеться, якщо до неї неможливо дістатися.
What if it's unavailable?
Коли ви звертаєтесь до Redis, подумайте про те, що станеться, якщо він відповідає повільно.
What if it's slow?
Коли ви надсилаєте електронний лист, подумайте про те, що станеться, якщо час очікування відповіді від постачальника закінчиться.
What if the provider times out?
Коли ви завантажуєте файл, подумайте про те, що станеться, якщо зберігання завершиться невдачею відразу після того, як запис у базі даних вже було створено.
What if storage fails after the database record is created?
Коли ви створюєте замовлення, подумайте про те, що станеться, якщо один і той самий запит надійде двічі.
What if the request is repeated?
Коли ви обробляєте оплату, подумайте про те, що станеться, якщо клієнт так і не отримає відповіді.
What if the client never receives the response?
Під час розгортання запитайте, що станеться, якщо нова версія зламається під час запуску.
What if the new version crashes?
Регулярне поставлення цих запитань змінює спосіб проектування вашої архітектури.
Проста модель мислення для розробки бекенду в промислових умовах
Під час створення функціоналу бекенду корисно пройтися через п’ять рівнів мислення.
1. Правильність
Чи справді ця функція виконує те, для чого призначена?
Create order → order actually gets created
2. Безпека
Чи може хтось виконати дію, яка йому не дозволена?
User A → access User B's order
3. Продуктивність
Що відбувається, коли обсяг даних або трафік значно зростають?
100 users → 10 million users
4. Надійність
Що відбувається, коли системи, від яких залежить ця функція, перестають працювати?
Database unavailable
5. Керованість
Якщо щось зламається, чи можете ви справді діагностувати та виправити це?
Request failed at 03:42:17
Проходження через ці п’ять рівнів зазвичай виявляє досить багато проблем ще до того, як вони потраплять у продакшн.
Існує справжня різниця між програмним забезпеченням, яке просто працює, та програмним забезпеченням, яке може витримати вплив реального світу.
Локальна версія програми має довести лише одну річ:
feature works
Версія для продакшну має довести кілька речей:
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
Це значно вища планка для досягнення. Саме це робить інженерію бекенду справді цікавою як галузь.
Ось короткий огляд усього, про що йшлося.
1. Відсутня конфігурація середовища
Не припускайте, що те, що налаштовано у вашому ноутбуці, буде таким самим у продакшні. Перевіряйте необхідні змінні середовища під час запуску та явно повідомляйте про їх відсутність.
2. Жорстке вказування localhost
Усередині контейнера localhost відноситься лише до самого цього контейнера. Слід покладатися на належне виявлення сервісів та конфігурацію, яка адаптується до кожного середовища.
3. Створення з’єднань до бази даних за кожним запитом
Використовуйте пул з’єднань та враховуйте загальну кількість з’єднань у всіх працюючих інстанціях вашого додатку.
4. Повернення всього з бази даних
Уникайте використання SELECT * зі звички. Надсилайте назад лише ті поля, які дійсно потрібні користувачам вашого API.
5. Ігнорування індексів
Запит, який працює добре з тисячею рядків, може зламатися при обробці мільйонів. Тестуйте з обсягами даних, схожими на реальні умови виробництва.
6. Розгляд локального зберігання як постійного
Контейнери та багато хмарних платформ взагалі не гарантують збереження локальних файлів. Направляйте контент, завантажений користувачем, до сховища, створеного для довготривалого зберігання.
7. Відсутність обмежень швидкості
Клієнти самостійно визначають частоту викликів вашого API. Встановіть обмеження для кінцевих точок, які є чутливими або мають високу вартість обробки.
8. Довготривалі синхронні запити
Важка обробка зазвичай має відбуватися у фоновому завданні, а не чекати на результат HTTP-запиту.
9. Довіра до даних від клієнта
Перевіряйте все ще раз на рівні бекенду. Одних лише перевірок фронтенду ніколи не достатньо.
10. Плутанина між „процес активний“ та „додаток у стані здоров’я“
Покладайтесь на перевірки стану, моніторинг, структуровані журнали, показники та сповіщення, які справді мають значення.
Заключні думки
Що вражає у проблемах зі продукцією, так це те, що майже жодна з них на перший погляд не виглядає серйозною.
Жорстко закодований localhost не здається проблемою. Відсутній індекс не здається проблемою. Створення нового з’єднання з базою даних за кожним запитом не здається проблемою. Відсутній таймаут не здається проблемою. API-шлях без обмежень не здається проблемою. Запис файлу безпосередньо на диск не здається проблемою.
Лише тоді, коли з’являються справжні користувачі.
Саме тоді ці дрібні, на перший погляд безневинні припущення починають зіштовхуватися одне з одним. І раптово ви стикаєтесь з:
slow APIs
database connection exhaustion
memory spikes
timeouts
failed deployments
missing files
duplicate payments
security issues
Мета не в тому, щоб заздалегідь передбачати кожну можливу несправність. Це неможливо. Мета — припинити будувати системи на підставі припущення, що все завжди буде працювати без проблем.
Тому що це не буде так.
Мережі зламуються. Бази даних уповільнюють роботу. Контейнери перезапускаються. Користувачі надсилають дані, яких ви зовсім не очікували. Залежності на наступних етапах втрачають підключення. Обсяг трафіку різко зростає без попередження. У серверів закінчується пам’ять. Запити дублюються. Процеси розгортання йдуть не так, як планувалося.
Усе це не обов’язково має призводити до катастрофи, якщо ваша система з самого початку була розроблена з урахуванням таких можливостей.
Те, що справді відрізняє бекенд, який працює лише на localhost, від того, що витримує навантаження у продакшні, — це не мова програмування, яку ви обрали. Це підхід до проектування.
На localhost питання просте: чи працює це?
У продакшні важливе питання: що станеться, якщо це не працюватиме?
Це друге питання, поставлене знову та знову, навчить вас набагато більшого про інженерію бекенду, ніж будь-яка фреймворк чи інструмент.
Якщо ви створюєте свою першу систему для продакшну, стримуйте бажання виправити все одразу. Почніть з малого: налагодьте правильну обробку конфігурації, додайте механізм пулінгу з’єднань, перевіряйте кожен вхідний даний, створюйте індекси відповідно до способу запитів до даних, чітко керуйте таймаутами, зберігайте файли у правильному місці, додавайте змістовний логування, стежте за тим, що має значення, та проробляйте сценарії збоїв по ходу роботи. Потім продовжуйте повторювати цей процес.
Готовність до продакшну — це не пункт, який потрібно позначити один раз та переходити далі. Це звичка, яку ви розвиваєте з часом як розробник.
І поступово це стає різницею між системою, яка лише стверджує «вона працює на моєму комп’ютері», та системою, яка справді функціонує у критичних ситуаціях.