Вчера я разработал новый интересный кейс для моего тренажера и с интересом вам расскажу о нем:
Представим довольно неприятную ситуацию.
После очередного деплоя пользователи начинают жаловаться, что сайт перестал работать. Открывают страницу — белый экран. Никаких сообщений, ошибок или признаков жизни.
Проверяем сервер:
systemctl status nginxNginx работает. В журналах нет ничего необычного.
Проверяем HTTP:
curl -I https://example.comПолучаем:
HTTP/2 200
content-type: text/htmlCPU не перегружен, память свободна, сервер отвечает. Мониторинг тоже показывает зелёный статус.
Только вот сайт по-прежнему не работает.
Я решил разобрать такую ситуацию на небольшом тестовом стенде. Оказалось, что получить 200 OK от полностью неработающего frontend довольно легко.
И причин для этого как минимум несколько.
Почему HTTP 200 ещё ничего не гарантирует
Начнём с простого.
Когда мы выполняем:
curl -I https://example.comмы проверяем ответ сервера на HEAD-запрос.
Если сервер возвращает 200, это означает, что запрос успешно обработан на уровне HTTP.
Но браузеру для отображения современного веб-приложения зачастую недостаточно одного HTML-файла.
Допустим, у нас обычное SPA-приложение.
Его HTML выглядит примерно так:
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Dashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/assets/app.abc123.js"></script>
</body>
</html>Сам HTML практически пустой. Интерфейс формируется JavaScript-кодом после загрузки страницы.
Если JavaScript не загрузится, браузер вполне может показать белый экран.
При этом Nginx продолжит совершенно спокойно отвечать 200 OK на запрос главной страницы.
С точки зрения веб-сервера всё нормально.
С точки зрения пользователя - приложение мертво.
Ситуация №1. Nginx возвращает HTML вместо JavaScript
Допустим, после деплоя у нас получилась следующая структура:
/srv/www/app/
├── index.html
└── assets/
└── app.def456.jsНо в index.html осталась ссылка на старую версию:
<script type="module" src="/assets/app.abc123.js"></script>Файла app.abc123.js больше нет.
Посмотрим на упрощённую конфигурацию Nginx:
server {
listen 80;
server_name example.com;
root /srv/www/app;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}Для SPA это довольно распространённая настройка. Если пользователь открывает /dashboard, а такого файла нет, Nginx возвращает index.html. Дальше маршрутизацией занимается JavaScript.
Но здесь есть нюанс.
Запрос к отсутствующему /assets/app.abc123.js тоже попадает под это правило.
Nginx не находит файл и вместо JavaScript возвращает index.html.
Проверяем:
curl -i http://localhost/assets/app.abc123.jsРезультат будет примерно таким:
HTTP/1.1 200 OK
Content-Type: text/html
<!DOCTYPE html>
<html lang="ru">
...И вот оно.
Запрос JavaScript-файла вернул 200 OK. Но в ответе вообще нет JavaScript.
Открываем DevTools в браузере и смотрим Console.
Там можно увидеть:
Failed to load module script:
Expected a JavaScript module script
but the server responded with a MIME
type of "text/html".Браузер ожидал JavaScript-модуль, а получил HTML.
Он отказывается его выполнять.
Как исправить
Для начала нужно восстановить согласованность файлов деплоя: index.html должен ссылаться на реально существующий JavaScript-бандл.
А чтобы Nginx не скрывал подобную ошибку, можно отдельно обрабатывать статические ресурсы:
location ^~ /assets/ {
try_files $uri =404;
}Теперь при запросе отсутствующего JS-файла сервер вернёт честный 404 Not Found.
Это не починит сломанную сборку автоматически, зато сделает проблему гораздо заметнее.
И мониторингу, и разработчику будет проще понять, что ресурс действительно отсутствует.
Ситуация №2. Файл существует, но Nginx ищет его не там
Теперь представим другой вариант.
Файл JavaScript на сервере есть. Путь в HTML правильный.
Но браузер всё равно его не получает.
В конфигурации обнаруживается:
location /assets/ {
alias /srv/www/app/;
}А реальные файлы находятся в:
/srv/www/app/assets/Проблема в том, как работает alias.
При таком правиле запрос:
/assets/app.jsNginx попытается сопоставить с:
/srv/www/app/app.jsА нужный файл лежит в другой директории.
Исправляем путь:
location /assets/ {
alias /srv/www/app/assets/;
}Затем проверяем конфигурацию:
sudo nginx -tЕсли всё нормально:
sudo systemctl reload nginxИ повторяем запрос.
Это кажется довольно очевидной ошибкой, когда уже знаешь причину.
Но в момент расследования её легко пропустить: файл на месте, Nginx работает, главная страница открывается.
Нужно проверять не только наличие файла на диске, но и то, каким путём Nginx пытается его найти.
Ситуация №3. JavaScript есть, но браузер отказывается его выполнять
Третий случай ещё интереснее.
Файл существует. Nginx его находит. HTTP-ответ - 200.
Но приложение не запускается.
Проверяем заголовки:
curl -I http://localhost/assets/app.jsПолучаем:
HTTP/1.1 200 OK
Content-Type: text/plainНа первый взгляд ничего страшного.
Но для JavaScript-модулей браузеры применяют строгую проверку MIME-типа.
Если сервер возвращает JavaScript как text/plain, модуль не будет выполнен.
Например, такая проблема может появиться из-за неправильной конфигурации MIME-типов:
location /assets/ {
types { }
default_type text/plain;
}Здесь мы фактически заставляем Nginx отдавать ресурсы с неподходящим типом содержимого.
В нормальной конфигурации необходимо использовать корректные MIME-сопоставления, обычно подключаемые через:
include /etc/nginx/mime.types;При этом нужно убедиться, что локальные настройки types не переопределяют нужные сопоставления.
После исправления заголовок JavaScript-файла должен содержать подходящий MIME-тип, например:
Content-Type: text/javascriptИ браузер сможет выполнить модуль.
В очередной раз: сервер работал, файл существовал, HTTP возвращал 200. Но сайт оставался неработоспособным.
А почему мониторинг ничего не заметил?
Теперь вернёмся к исходной ситуации.
Допустим, наш мониторинг каждые 30 секунд делает:
curl -fsS -o /dev/null https://example.com/Если сервер возвращает успешный HTTP-код, проверка завершается без ошибки.
Для простых сайтов этого иногда достаточно.
Но для SPA-приложения подобная проверка говорит только о доступности HTTP-страницы.
Она не отвечает на несколько важных вопросов:
Загрузились ли JavaScript-ресурсы?
Выполнился ли код приложения?
Появился ли интерфейс?
Может ли пользователь выполнить основное действие?
Получается, что мы проверяем доступность Nginx, а называем это доступностью приложения.
Как я бы улучшил проверку
Я бы разделил мониторинг на несколько уровней.
Первый - доступность сервера.
Проверяем, что Nginx отвечает и главная страница доступна.
Второй - доступность ресурсов.
Проверяем, что JavaScript-файлы действительно существуют, имеют корректный MIME-тип и возвращают ожидаемое содержимое.
Третий - работоспособность приложения.
Здесь уже полезен браузерный smoke-тест.
Например, с Playwright можно открыть страницу в Chromium и проверить, что приложение действительно отрисовало нужный элемент.
Условный пример:
import { chromium } from 'playwright';
const browser = await chromium.launch();
try {
const page = await browser.newPage();
await page.goto('https://example.com/');
await page.locator('[data-testid="dashboard"]')
.waitFor({ state: 'visible', timeout: 10000 });
console.log('Frontend is working');
} finally {
await browser.close();
}Разумеется, в самом приложении должен существовать соответствующий элемент.
Но такая проверка уже гораздо ближе к тому, что видит реальный пользователь.
Она не просто спрашивает у Nginx, жив ли он.
Она проверяет, что интерфейс действительно смог загрузиться.
Я бы не запускал полноценный браузерный тест каждые несколько секунд на каждом экземпляре сервиса. Но для проверки после деплоя и периодического внешнего мониторинга это полезный дополнительный уровень.
Что в итоге
У всех трёх ситуаций есть одна общая черта.
HTTP 200 означает успешный ответ на конкретный HTTP-запрос, но не гарантирует, что приложение работает.
Можно получить 200 от главной страницы, 200 от отсутствующего JavaScript-файла, 200 от ресурса с неправильным MIME-типом.
И при этом пользователь увидит только белый экран.
Меня в подобных задачах интересует даже не сама ошибка, а разница между тем, что показывает мониторинг, и тем, что происходит на самом деле.
Мы привыкли проверять службы, порты и HTTP-коды. Но иногда все эти показатели зелёные, а продукт уже недоступен.
Наверное, поэтому хороший healthcheck должен отвечать не только на вопрос «жив ли сервер», но и на вопрос «может ли пользователь сделать то, ради чего вообще существует этот сервис».

Комментарии 0