Вчера я разработал новый интересный кейс для моего тренажера и с интересом вам расскажу о нем:
Представим довольно неприятную ситуацию.
После очередного деплоя пользователи начинают жаловаться, что сайт перестал работать. Открывают страницу — белый экран. Никаких сообщений, ошибок или признаков жизни.
Проверяем сервер:
systemctl status nginx
Nginx работает. В журналах нет ничего необычного.
Проверяем HTTP:
curl -I https://example.com
Получаем:
HTTP/2 200 content-type: text/html
CPU не перегружен, память свободна, сервер отвечает. Мониторинг тоже показывает зелёный статус.
Только вот сайт по-прежнему не работает.
Я решил разобрать такую ситуацию на небольшом тестовом стенде. Оказалось, что получить 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.js
Nginx попытается сопоставить с:
/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 должен отвечать не только на вопрос «жив ли сервер», но и на вопрос «может ли пользователь сделать то, ради чего вообще существует этот сервис».
