Почему код бэкенда, работающий локально, выходит из строя под реальной нагрузкой в производстве.
Практический обзор предположений относительно среды, базы данных, безопасности и надежности, которые корректно работают на localhost, но приводят к сбоям при реальном объеме трафика в производственной среде.
Источники: 10 Backend Mistakes That Work Perfectly on localhost but Break in Production
Он работает идеально на вашем компьютере. Но сможет ли он выдержать нагрузку при реальном трафике? Вот 10 ошибок в бэкенде, которые могут незаметно превратить хорошо работающее приложение в катастрофу в производственных условиях.
Локально всё кажется в порядке: запросы возвращаются быстро, результаты поиска соответствуют запросу, вход работает, Docker запускается без проблем, тесты проходят. Но как только вы развернёте приложение, откроете его по живому адресу — что-то ломается.
Иногда приложение отказывается запускаться.
Иногда пользователи теряют доступ к своим учётным записям.
Иногда база данных вдруг начинает работать крайне медленно.
Иногда всё работает гладко несколько минут, а затем начинают появляться ошибки подключения.
А иногда сервис с виду кажется совершенно нормальным, но на самом деле наносит скрытый ущерб.
Что делает ситуацию ещё более раздражающей, так это то, что большинство этих сбоев не связаны с сложными алгоритмами.
Они возникают из незначительных, не проверенных предположений.
Такие предположения, как:
- «У нас не будет столько трафика».
- «База данных находится на этом же сервере».
- «Эта переменная среды всегда будет установлена».
- «Одно соединение с базой данных — это достаточно».
- «Запросы всегда будут обрабатываться быстро».
- «Файлы, сохраненные на диске, останутся там и позже».
- «Никто не будет агрессивно использовать этот конечный пункт».
- «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.
Теперь представьте, что такая ошибка возникает у 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.
Идемпотентность существует именно для предотвращения этого.
Ключ идемпотентности может выглядеть так:
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
Это гораздо более высокий стандарт, который необходимо преодолеть. Именно это делает инженерию бэкенда по-настоящему интересной дисциплиной.
Ещё раз 10 ошибок
Вот краткий обзор всего, что было рассмотрено.
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 вопрос прост: работает ли это?
В производстве важен другой вопрос: что происходит, когда это не работает?
Этот второй вопрос, задаваемый снова и снова, учит вас гораздо большему о инженерии бэкенда, чем любая фреймворк или инструмент.
Если вы создаете свою первую производственную систему, сдерживайте желание исправить всё сразу. Начните с малого: наладьте обработку конфигурации, внедрите пулы подключений, проверяйте каждый входной данный, создавайте индексы в соответствии с реальными запросами к данным, явно обрабатывайте таймауты, храните файлы в подходящем месте, добавляйте информативную логгирование, отслеживайте важные аспекты и по мере работы размышляйте о сценариях сбоев. Затем продолжайте повторять этот процесс.
Готовность к эксплуатации — это не что-то, что вы отмечаете один раз и забываете об этом. Это привычка, которую вы формируете со временем как разработчик.
И постепенно это становится разницей между системой, которая лишь говорит «она работает у меня на компьютере», и системой, которая действительно справляется с задачами в критические моменты.