Пользователи из регионов начали писать в поддержку одно и то же: приложение не открывается. Симптомы у всех разные. У кого-то вечная загрузка, у кого-то белый экран, у кого-то заходит, только если включить VPN.
На своём компьютере всё летает. С зарубежного сервера проверяю, тоже без проблем. Сижу, чешу затылок.
Сначала списал на случайные глюки у провайдеров. Мало ли, бывает. Но обращений становилось больше, а не меньше, и в какой-то момент стало ясно: это не совпадение, это системная штука. Пришлось лезть в сетевой дебаг с головой.
В этой статье расскажу, как искал причину таймаутов, что в итоге поменял в инфраструктуре и почему обычная сборка Vite с разбивкой на чанки превратилась в проблему, а не в оптимизацию.
Два аномала в сети
Попросил нескольких пользователей открыть DevTools, вкладку Network, и прислать дамп запросов. Собрал штук пять таких дампов и увидел закономерность. Не одна проблема, а сразу две, и они маскировали друг друга.
Проблема первая. TLS зависает на рукопожатии
Часть запросов вообще не доходила до сервера. Клиент открывает соединение на 443 порт, начинается TLS ClientHello, и всё. Тишина. Соединение виснет по таймауту, а сессия так и не устанавливается.
Причём не у всех пользователей. У кого-то заходило нормально, у кого-то нет. Это первое, что меня сбило с толку, я полдня думал, что дело в конкретных браузерах или версиях TLS.
Проблема вторая. Браузер запрашивает слишком много файлов одновременно
Если соединение всё же устанавливалось, вылезала вторая беда. Vite по умолчанию режет код приложения на кучу мелких чанков, chunk-1.js, chunk-2.js и так далее, это стандартная штука для кэширования.
Только вот когда браузер разом дёргает тридцать-сорок файлов через HTTP/2 или несколько параллельных TCP-соединений, сетевые фильтры у провайдеров это не любят. Видят аномальный всплеск сессий, принимают за туннель, и рвут соединение. Либо тихо, либо явным TCP RST. Часть ресурсов просто не догружается, и вот тебе белый экран.
Забавно, что обе проблемы по отдельности выглядели как случайность, а вместе давали ту самую хаотичную картину из жалоб в поддержке. У одного человека рвётся на этапе TLS, у другого на этапе загрузки чанков, а итог одинаковый: приложение не открывается.
Что в итоге поменял
Задача была вернуть стабильную доступность без VPN. Для этого развернул гибридную схему проксирования и пересобрал фронтенд.
Пользователь в РФ │ (HTTPS) ▼Selectel CDN (Москва) │ (HTTPS + Host Header) ▼Origin сервер (Nginx / Caddy) │ ├────────► Статика (Vite, единый бандл) └────────► API (FastAPI / Node.js)
Проксирование через CDN с российскими нодами
Вместо того чтобы гонять пользователей напрямую на IP зарубежного хостинга, подключил Selectel CDN. Теперь запросы из РФ принимают московские ноды, к этим адресам у провайдеров претензий нет, TLS-рукопожатие проходит без всяких таймаутов.
Настройки заняли не так много времени, как я думал, но пара моментов чуть не сломали всё:
Передача Host-заголовка клиента должна быть включена обязательно. Без неё Nginx на сервере не понимает, какой домен запрашивают, и все редиректы с www ломаются. Я час убил, пытаясь понять, почему у меня все запросы улетают на дефолтный виртуальный хост.
Перенаправление HTTP на HTTPS включил прямо на уровне CDN. Незачем гонять лишний хоп до сервера ради одного редиректа.
SSL и разрешённые методы
Загрузил на CDN сертификат Let’s Encrypt для доменов. CDN сам терминирует HTTPS от клиента, а к origin-серверу ходит уже своим соединением.
Тут наткнулся на ещё одну засаду. По умолчанию CDN разрешает только GET и HEAD, а у меня API принимает POST, PUT, PATCH, DELETE. Формы просто переставали отправляться, авторизация ломалась, и я минут двадцать сидел в панели, не понимая, почему бэкенд отвечает пустотой на вроде бы рабочий запрос. Явно прописал все методы, стало нормально.
Кэширование для API выключил полностью, иначе получаешь устаревшие ответы вместо актуальных данных. И обработку параметров URL перевёл в режим учёта всех query-параметров, чтобы кэш-бастеры вроде случайного числа в конце запроса реально доходили до сервера, а не отдавались из кэша.
Nginx и редиректы
На сервере Nginx проверяет заголовок host и проброшенный от CDN заголовок x-forwarded-host, чтобы корректно редиректить без-www версию на www:
server { listen 80; server_name example.com www.example.com; if ($host = 'example.com') { return 301 https://www.example.com$request\_uri; } if ($http_x_forwarded_host = 'example.com') { return 301 https://www.example.com$request\_uri; }}server { listen 443 ssl; http2 on; server_name example.com www.example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; if ($host = 'example.com') { return 301 https://www.example.com$request\_uri; } if ($http_x_forwarded_host = 'example.com') { return 301 https://www.example.com$request\_uri; }}
Тут ничего экзотического, просто нужно помнить, что после CDN исходный host клиента приходит уже в другом заголовке, и если это не учесть, редиректы будут работать через раз.
Единый бандл вместо россыпи чанков
Даже после переезда на CDN проблема с обрывом сессий из-за множества параллельных запросов никуда не делась. CDN решил вопрос с TLS, а вот на аномальный всплеск TCP-соединений он никак не влияет, потому что сами файлы всё равно грузятся десятками штук.
Полез разбираться и понял, что дробление на чанки в моём случае не даёт почти ничего, а вреда приносит много. Chunk-based сборка хороша, когда у тебя редко обновляемые библиотеки лежат отдельно от часто обновляемого кода приложения, и браузер кэширует их независимо. У меня же приложение маленькое, и весь этот выигрыш в кэшировании съедался проблемами с сетью, которые он же и создавал.
Решение оказалось до смешного простым. Собрал весь JS в один файл:
import { defineConfig } from 'vite'import react from '@vitejs/plugin-react'import path from 'path'export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, './src'), }, }, build: { rollupOptions: { output: { manualChunks: () => 'app', } }, assetsInlineLimit: 10000, }})
Тут два момента. manualChunks с функцией, которая всегда возвращает одну и ту же строку, заставляет Rollup склеить весь код в один файл вместо привычной разбивки. А assetsInlineLimit подтянул до десяти килобайт, чтобы мелкие иконки и SVG превращались в base64 прямо внутри JS и CSS, а не превращались в отдельные сетевые запросы.
Результат: вместо тридцати-сорока параллельных запросов браузер тянет один файл app.js. Сетевые фильтры больше не видят аномального всплеска соединений, потому что его просто нет.
Да, это противоречит классическим рекомендациям про code splitting. Но когда у тебя часть пользователей физически не может открыть страницу, забота о теоретическом выигрыше в кэшировании отходит на второй план.
Ещё одна неочевидная точка отказа
Пока копался в сети, наткнулся на третью проблему, которую вообще не ожидал. Приложение подключало Telegram WebApp SDK внешним скриптом с домена telegram.org.
И вот когда у самого telegram.org случались замедления или проблемы с доступностью у части провайдеров, у меня падала загрузка всего фронтенда. Причём никак не связано с моим сервером, просто внешняя зависимость подвела.
Решение на пять минут. Скачал telegram-web-app.js и положил рядом со своей статикой. Теперь файл отдаётся с моего же сервера вместе со всем остальным, и внешняя зависимость больше не может уронить загрузку страницы.
Мелочь, а по количеству жалоб дала ощутимый вклад. Я бы не додумался её искать, если бы не смотрел дампы запросов построчно.
Что получилось в итоге
После раскатки всех изменений:
Доступность выросла ощутимо, жалобы на белые экраны и вечную загрузку почти исчезли.
Загрузка стала быстрее, за счёт того, что убрали лишние сетевые round-trip’ы и склеили файлы в один.
API и вебхуки перестали залипать, потому что CDN теперь настроен правильно и не кэширует то, что кэшировать нельзя.
Если у вас клиенты из РФ жалуются на непонятные обрывы сессий, а с виду всё работает, первым делом гляньте, сколько параллельных TCP-соединений открывает ваш фронтенд при загрузке. Может оказаться, что дело вообще не в вашем коде, а в том, как сеть реагирует на паттерн запросов.
ссылка на оригинал статьи https://habr.com/ru/articles/1065126/