Чаму код для бэкенду, який працюе локальна, зламваецца пад справжнім навантажэнням у працэйной среде.
Практычны ўзвешчанні пра аптэкую, базу дадзеных, захаванне і надзею на надзейнасць, якія спакойна працуюць на 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
};
Але тут яшчэ застаёцца болей тонкія памылка.
Лiчна выцягванне значэнняў з зменных атмосферы сама па сабе недастатня.
Таксама трэба пераканацца, што яны дзейсна ўсё ж такі ў наявнасці.
Напрыклад:
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
колі UI насправды викорыстоўвае толькі:
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;
Гэта можа выявіць вельмі большы запас дадзеных, якія вы ніколі не планавалі адправляць. Гэта прыемны штуткі, калі вы яшчэ ствараеце прылады, з такой логікай:
"Я проста верну всё і дам фронтэнду выбраць тое, канкліво яму."
Супротставляйцеся гэтаму прагненню.
Форма адпаведзі AI должна быць намеранай. Напрыклад:
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.
Тепер уявіце гэтую памылку на 10 000 корыстнікаў.
У вашам бэкендзе раптам з’яўляецца справжняя проблема.
Лімітар частоты створае для вас захоўну сетку проты самэй такой неконтрольванай дзеяльнасці.
Лімітаванне частоты стае сложнейшым, калі вы расширяецеся горизонтальна
Сказаўмо, ваша аплікацыя працуе за дапамогою трох разных экземпляраў бэкенду:
Server A
Server B
Server C
Якщо кожны экземпляр зберагае свой скалькавач у памяці, кліент можа фактычна атрымаць у тры разы больш часу, ніж запланавана, по аднаму на кожны сервер.
Ёжчы застаўляць аднаковы ліміт на всіх экземплярах, команды зазвычай викорыстоўваюць спяльны хранілнік, напрыклад Redis.
Тады структура выглядае працоўна так:
┌── 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.
Ідэмпотентнасць існуе самэлькі для таго, каб запобiec гэтаму.
Ключ ідэмпотентнасці можа выглядаць так:
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 MB
- Двойны клік на кнопку адправы
- Адправа некоректнага 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 * з прыzwычкі. Працягвайце толькі тыя поля, якія насправды патрабуюць вашы корыстнікі 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 вопыт прост: чы робіць гэта?
У працоўным сераўе важлівы вопыт: што будзе, калі гэта не будзе работаць?
Гэты другі вопыт, які ставяцца знова і знова, навучыць вас гораздка больш пра інжынерыю бэкенда, чым будзь-якая платформа чы інструмент.
Якщо вы ствараеце свую першую систему для прыменення, стрымайцеся ад спрагі заўсёды ўсё парадкаваць адразу. Пачніце з малага: правільна налаштавьце канфігурацыю, дадзіце памерканне з’ўязкаў, пераканаўце кожны вхідны даны, стварыце індэксы на аднойчыну з тым, як вы фактычна запытваеце даны, часткальна керуйце тайма-аутамі, зберагаўце файлы на правым месцы, дадзіце значамяны логгінг, стежыце за тым, што мае значэнне, і прааналізавайце сценарыя неудач па ходу роботы. Пасля таго продовжайце павтараць гэты процес.
Гатовасць да прыменення — гэта не пункт, які вы пазначыце раз і пераходзіце да наступнага. Гэта прычынка, якую вы развіваеце з часам як разработчык.
І паступова гэта станавіцца разлікам межы системы, якая толькі кажа «вона працуе на майм комп’ютеры», і той, якая дэйсна працуе, калі гэта мае значэнне.