Пользователи из регионов начали писать в поддержку одно и то же: приложение не открывается. Симптомы у всех разные. У кого-то вечная загрузка, у кого-то белый экран, у кого-то заходит, только если включить VPN.
На своём компьютере всё летает. С зарубежного сервера проверяю, тоже без проблем. Сижу, чешу затылок.
Сначала списал на случайные глюки у провайдеров. Мало ли, бывает. Но обращений становилось больше, а не меньше, и в какой-то момент стало ясно: это не совпадение, это системная штука. Пришлось лезть в сетевой дебаг с головой.
В этой статье расскажу, как искал причину таймаутов, где я сам сначала неправильно объяснил себе проблему, и что в итоге поменял в инфраструктуре, чтобы всё заработало стабильно.
Два аномала в сети
Попросил нескольких пользователей открыть DevTools, вкладку Network, и прислать дамп запросов. Собрал штук пять таких дампов и увидел закономерность. Не одна проблема, а сразу две, и они маскировали друг друга.
Проблема первая. TLS зависает на рукопожатии
Часть запросов вообще не доходила до сервера. Клиент открывает соединение на 443 порт, начинается TLS ClientHello, и всё. Тишина. Соединение виснет по таймауту, а сессия так и не устанавливается.
Причём не у всех пользователей. У кого-то заходило нормально, у кого-то нет. Это первое, что меня сбило с толку, я полдня думал, что дело в конкретных браузерах или версиях TLS.
Проблема вторая. Даже установленное соединение работало нестабильно
Если TLS всё же проходил, легче не становилось. Vite по умолчанию режет код приложения на кучу мелких чанков, chunk-1.js, chunk-2.js и так далее, это стандартная штука для кэширования. У меня включён HTTP/2, так что формально все эти чанки едут не отдельными TCP-соединениями, а мультиплексируются внутри одного. Это важный момент, я сначала сам объяснял проблему по-другому и был неправ.
На деле причина оказалась в другом. Соединение к зарубежному IP уже было деградировавшим, то ли из-за SNI-фильтрации, то ли из-за банального шейпинга на уровне провайдера. Пока грузится один HTML, это почти незаметно, лишние сотни миллисекунд. А когда следом за ним по тому же плохому каналу нужно дотянуть ещё сорок файлов, деградация начинает бросаться в глаза: часть запросов таймаутится, часть обрывается на середине, и получается ровно тот белый экран с вечной загрузкой, на который жаловались пользователи.
То есть первопричина одна и та же в обоих случаях: сама сессия к зарубежному хостингу была нестабильной. 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:
# 1. Редирект с HTTP на HTTPS для всех доменов server { listen 80; server_name example.com www.example.com; return 301 https://www.example.com$request\_uri; } # 2. Основной HTTPS сервер (слушает www.example.com) server { listen 443 ssl; http2 on; server_name 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; # ... раздача статики и проксирование API ... } # 3. Отдельный HTTPS сервер для редиректа с корневого домен без www server { listen 443 ssl; http2 on; server_name example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; return 301 https://www.example.com$request\_uri; }
Тут ничего экзотического, просто нужно помнить, что после CDN исходный host клиента приходит уже в другом заголовке, и если это не учесть, редиректы будут работать через раз.
Единый бандл вместо россыпи чанков
CDN решил основную проблему: соединение теперь идёт до московской ноды, а не напрямую до деградировавшего зарубежного IP. Этого уже хватило бы, чтобы жалобы прекратились. Но раз уж я всё равно копался в сети, решил заодно убрать лишний источник нестабильности на будущее, на случай если CDN когда-нибудь тоже начнёт барахлить или запросы пойдут в обход него.
Тут сразу оговорюсь: то, что я сделал дальше, это не изящное решение, а осознанный костыль. Chunk-based сборка существует не просто так: редко обновляемые библиотеки лежат отдельно от кода приложения, и браузер кэширует их независимо, а при обновлении приложения пользователю не нужно заново скачивать все зависимости. Склеив всё в один файл, я эту схему сломал: любое изменение в коде теперь означает перекачку всего бандла целиком, а не только изменившегося куска.
Для моего масштаба (маленькое приложение, нечастые деплои) эта потеря показалась приемлемой ценой за более простую картину для дебага и меньшее число отдельных http-запросов. Оговорюсь сразу: строгого технического обоснования через round-trip'ы тут нет, при http/2 все чанки и так идут через одно мультиплексированное соединение, так что теоретического выигрыша по rtt тут скорее всего нет или он минимален. Решение было интуитивным, а не измеренным. Но это осознанный трейд-офф, а не универсальный совет. Если у вас крупное приложение с частыми обновлениями и тяжёлыми зависимостями, правильный путь — не единый бандл, а разумный vendor-chunking: вынести стабильные библиотеки в отдельный чанк, а не дробить всё подряд на десятки файлов и не склеивать всё в один.
Решение оказалось до смешного простым. Собрал весь 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. Меньше отдельных http-запросов, проще смотреть логи, когда что-то идёт не так. Возможно, чуть меньше шансов что что-то отвалится на нестабильном канале, но это не измерено и не доказано, кроме основного фикса с cdn других данных у меня нет.
Да, это противоречит классическим рекомендациям про code splitting, и я это осознаю. Но это была подстраховка сверху основного фикса с CDN, а не замена ему. Если бы CDN не решил проблему с доступностью, единый бандл сам по себе её бы не закрыл.
Ещё одна неочевидная точка отказа
Пока копался в сети, наткнулся на третью проблему, которую вообще не ожидал. Приложение подключало Telegram WebApp SDK внешним скриптом с домена telegram.org.
И вот когда у самого telegram.org случались замедления или проблемы с доступностью у части провайдеров, у меня падала загрузка всего фронтенда. Причём никак не связано с моим сервером, просто внешняя зависимость подвела.
Решение на пять минут. Скачал telegram-web-app.js и положил рядом со своей статикой. Теперь файл отдаётся с моего же сервера вместе со всем остальным, и внешняя зависимость больше не может уронить загрузку страницы.
Мелочь, а по количеству жалоб дала ощутимый вклад. Я бы не додумался её искать, если бы не смотрел дампы запросов построчно.
Что получилось в итоге
После раскатки всех изменений:
Доступность выросла ощутимо, жалобы на белые экраны и вечную загрузку почти исчезли.
Загрузка стала быстрее, в первую очередь за счёт CDN с российскими нодами. Склейка бандла шла довеском ради меньшего числа запросов и удобства дебага, но отдельно её эффект я не замерял.
API и вебхуки перестали залипать, потому что CDN теперь настроен правильно и не кэширует то, что кэшировать нельзя.
Если у вас клиенты из РФ жалуются на непонятные обрывы сессий, а с виду всё работает, первым делом проверьте качество самого канала до вашего сервера, а не количество запросов, которые уходит по нему. У меня причина была именно в этом: деградировавшее соединение к зарубежному IP, из-за которого TLS то не проходил вовсе, то проходил, но со сброшенными пакетами где-то на середине загрузки статики. CDN с российской нодой закрыл именно эту проблему, а всё остальное было уже вторичной оптимизацией сверху.
tnkwa
по ходу чтения возникли некоторые вопросы.
во первых, про обрыв соединения на этапе установки tls соединения, вроде как, у нас ech блокируется в tls1.3, небыло возможности проверить?
во вторых, касательно всплеска tcp соединений, http2 использует мультиплексирование, поэтому для провайдера клиента не имеет значения то, сколько именно запросов он делает, ведь они идут через одно tcp соединение и трафик выглядит идентично (особенно если учитывать тлс). могу предположить, что дело в рейтлимите или ограничении max_concurrent_streams?
буду рад если ответите
zaaaa Автор
про ech в tls 1.3 - на сервере его не включали, но провайдеры у нас его реально режут наглухо. вполне вероятно что у части юзеров браузер пытался подтянуть ech и соединение тупо падало на clienthello.
про http2 согласен, я там уже обновил и переписал этот блок в статье. при http2 соединение одно и мультиплексирование работает. проблема была именно в качестве самого канала до зарубежного ип. при потере пакетов на плохом канале проявляется head-of-line blocking и пачка стримов начинает отваливаться по таймаутам. плюс внешняя зависимость telegram.org зависала сама по себе.
переезд на московские ноды selectel cdn как раз и закрыл проблему с каналом. а склейка бандла была уже вторичной историей чтобы убрать лишние rtt.
tnkwa
касательно ech, раз поддержки небыло, то клиенты не могли его использовать, т.к. сервер не заявил о поддержке, так что да, вероятнее всего проблема в самом качестве соединения. (дополнительно не очень понял, почему был сделан вывод, что соединение рвется именно на clienthello, вроде в девтулзах настолько подробной информации нет, хотя могу ошибаться)
а про склейку бандла - интересно, вообще не очень понял при чем тут rtt, ведь контент запрашивался бы параллельно. да и в контексте использования cdn, наверное склеивать бандл все таки странное решение. после подключения cdn проводили замеры какие нибудь? пробовали старый подход с множеством файлов при использовании cdn? не увидел в статье четких цифр и замеров, если они есть - было бы интересно посмотреть, так как решение не очевидное. спасибо
zaaaa Автор
про clienthello - тут да, обычные девтулзы этого не покажут. смотрели через chrome://net-internals на компе у одного из юзеров, там видно что рвётся именно на этапе рукопожатия.
про склейку бандла - справедливое замечание. cdn там и правда основной фикс, он закрыл проблему с каналом полностью, а склейка бандла это уже была доп подстраховка сверху, не более того. решение принято в 2 ночи, без замеров до/после именно на cdn, чисто чтобы убрать лишнюю точку отказа раз уж всё равно копался в сети. если бы это был единственный фикс без cdn, я бы сначала померил. по мере роста проекта вернём нормальный vendor-chunking, но пока для моего масштаба (нечастые деплои, небольшое приложение) склейка себя оправдывает.
tnkwa
все равно не очень понял про rtt. если правильно помню, в http2 у нас есть окно как общее, так и у каждого стрима свое, поменьше. то есть, когда мы отдаем множество мелких файлов, они зачастую влезают в окно стрима, и вот таким образом rtt экономятся (если мы про round trip, конечно же). а вот если файл большой, и влезает в общее окно соединения, но не влезает в окно конкретного стрима, то отдав часть файла, нужно ждать пока клиент увеличит окно, и вот это вроде бы и увеличивает rtt, а не уменьшает.
дополнительно не вижу проблем в head of line блокировках. созданием большого бандла это точно не исправить, потери это врядли может уменьшить. поэтому все равно не до конца понимаю, как это может быть хоть в какой то степени полезно
zaaaa Автор
насчёт окон и hol, тут по делу, per-stream окно я не учёл, а hol на уровне tcp и правда никак не лечится склейкой файлов, раз всё идёт через одно соединение. если честно, объяснял это тогда интуицией, а не разбором механизма, и явных доказательств что склейка что-то ускорила у меня нет, кроме сокращения числа отдельных http-запросов. cdn был единственным подтверждённым фиксом, и он закрыл проблему полностью сам по себе. надо было в статье сразу так и написать, а не пытаться подкрепить довесок теорией которая не выдерживает проверки. поправил блок в статье, спасибо