При деплое небольшого веб-приложения я столкнулся со странной проблемой. Сервер работал, сертификат был настроен, через домашний интернет сайт открывался. А с мобильного практически не загружался.
Проверка через curl показала, что TLS-соединение устанавливалось и сервер возвращал HTTP 200 OK, но загрузка зависала после первых нескольких килобайт. Причём такое поведение удалось воспроизвести через Билайн, Т2 и МегаФон.
Сначала я подозревал конфигурацию приложения. В итоге дошёл до tcpdump, увидел повторные передачи TCP-сегментов и перенёс приложения на российский VPS. Расскажу, что удалось выяснить и почему я подозреваю DPI/ТСПУ, хотя доказать их участие не смог.
Об обезличивании. Домены и публичные IP-адреса в статье заменены на алиасы:
app-a.example,app-b.example,203.0.113.10(сервер) и198.51.100.25(мобильный клиент). Это специально зарезервированные для документации адреса и домены, а не действующие хосты. Все приведённые ниже команды с ними — иллюстрации реально выполненных команд, а не готовые для запуска копипасты. Числовые результатыcurl, TCPseq/ackи временные интервалы сохранены из эксперимента.
Что было развёрнуто
На VPS Hetzner в Финляндии были установлены Ubuntu 24.04, Docker и Caddy в роли входного reverse proxy. На одном публичном IPv4 работали два независимых приложения:
Приложение A |
Приложение B |
|
|---|---|---|
Условный домен |
|
|
Технологии |
React, Next.js, TypeScript |
React 19, JavaScript (JSX), CSS |
Веб-слой |
Caddy → контейнер приложения |
Caddy → Nginx 1.27 внутри контейнера приложения (раздаёт SPA) |
Публичный IP |
|
|
Для диагностики использовал компьютер с Windows и curl/8.21.0 с TLS-бэкендом Schannel. Интернет на компьютер раздавал с телефона. Так было удобнее снимать и сохранять вывод команд, оставаясь в той же мобильной сети.

На схеме показаны участники обмена. На каком сетевом устройстве терялись данные, выяснить не удалось, поэтому DPI/ТСПУ здесь не обозначены.
При чём здесь регистратор домена?
Доменами я управлял через разные площадки: Selectel и Cloudflare. Упоминаю это, потому что по незнанию при диагностике легко начать подозревать зарубежный DNS или регистратора.
Регистратор домена не передаёт через себя HTTPS-трафик. Он отвечает за регистрацию домена, а DNS позволяет клиенту узнать адрес сервера. После разрешения имени браузер устанавливает соединение с IP назначения. Если оба домена указывают напрямую на один и тот же IP, сам факт регистрации в России или за рубежом не определяет дальнейший маршрут пакетов.
У меня Cloudflare использовался в режиме DNS only, без проксирования (серое облако). Поэтому запросы шли непосредственно на Hetzner, а не через сеть Cloudflare. О варианте с проксированием расскажу ближе к концу.
Как проявилась проблема
На старте сайт A открывался с мобильного интернета по HTTP и напрямую по IP. После привязки домена и настройки HTTPS через мобильную сеть он перестал нормально загружаться. При этом через домашний Wi-Fi HTTPS работал.
В этих проверках различались сразу две вещи: обращение по IP или домену и протокол, HTTP или HTTPS. Поэтому в тот момент нельзя было уверенно утверждать, что «оператор блокирует HTTPS». Нужно было выяснить, до какой стадии вообще доходит соединение.
Проверку начал с Билайна.
TLS работает. HTTP 200 тоже. А страницы нет
На компьютере, подключённом к раздаче телефона с Билайном, запустил:
curl -4 -vk --http1.1 --connect-timeout 10 --max-time 20 https://app-a.example/
Здесь -4 принудительно выбирает IPv4, --http1.1 фиксирует версию HTTP для сопоставимости тестов, а -v показывает детали подключения. -k отключает проверку сертификата, но не TLS-шифрование; отдельной проверкой корректности цепочки сертификатов эта команда не является.
В логе увидел следующее (адреса заменены):
* Host app-a.example:443 was resolved. * IPv4: 203.0.113.10 * Trying 203.0.113.10:443... * ALPN: server accepted http/1.1 * Established connection to app-a.example (203.0.113.10 port 443) > GET / HTTP/1.1 > Host: app-a.example * Request completely sent off < HTTP/1.1 200 OK < Content-Type: text/html; charset=utf-8 < Via: 1.1 Caddy < Transfer-Encoding: chunked ... * Operation timed out after 20006 milliseconds with 2366 bytes received curl: (28) Operation timed out after 20006 milliseconds with 2366 bytes received
Из приведенного выше результат видно, что DNS разрешился, TCP-соединение установилось, TLS прошёл, запрос ушёл, а сервер вернул 200 OK. Но тело ответа остановилось на 2366 байтах. Через 20 секунд сработал тайм-аут.
Это уже не похоже на классический сценарий, когда фильтр распознаёт SNI в ClientHello и сразу сбрасывает TLS-соединение. Здесь HTTPS-сессия успела установиться и передать часть HTTP-ответа.
Случайность или устойчивая граница?
Повторил загрузку, ограничив скорость до 10 КБ/с и увеличив тайм-аут до 30 секунд:
curl -4 -vk --http1.1 --max-time 30 --limit-rate 10k -o NUL https://app-a.example/
Результат оказался тем же:
curl: (28) Operation timed out after 30004 milliseconds with 2366 bytes received
Затем проверил маленький статический /icon.svg размером 230 байт. Он целиком скачался и через домашний Wi-Fi, и через Билайн. Но статический CSS первого приложения снова завис после 2366 байт.
Запрос |
Сеть |
Получено |
Результат |
|---|---|---|---|
HTML главной страницы |
Билайн |
2366 Б |
Тайм-аут, 20 с |
HTML с ограничением скорости |
Билайн |
2366 Б |
Тайм-аут, 30 с |
|
Домашний Wi-Fi |
230 Б |
Успешно, 0,15 с |
|
Билайн |
230 Б |
Успешно, 0,35 с |
Статический CSS |
Билайн |
2366 Б |
Тайм-аут, 20 с |
Next.js умеет отдавать HTML потоково, и сначала можно было заподозрить зависание при серверном рендеринге. После проверки статического CSS версия об ошибке исключительно в SSR(Server-Side Rendering или серверный рендеринг) стала значительно слабее. Да и через wi-fi приложение работало.
Считать 2366 байт доказанным «порогом DPI» нельзя. curl считает полученные байты HTTP-тела, а TCP передаёт зашифрованные TLS-записи вместе со служебными данными. Эти величины нельзя напрямую приравнять к размерам сетевых пакетов.
Зачем я вообще полез в MTU
На этом этапе было ещё одно простое сетевое объяснение, не связанное с блокировками: проблема Path MTU Discovery. Мобильный маршрут может включать туннели с дополнительными заголовками. Если большие IP-пакеты не проходят, а сообщения, необходимые для обнаружения допустимого MTU, теряются или фильтруются, небольшие ответы могут работать, а более крупные зависать.
Это не было моей основной версией, но проверить её стоило: на клиентском Wi-Fi интерфейсе временно снизил MTU с 1500 до 1200. Такое изменение может повлиять на объявляемый клиентом TCP размеры сегментов, отправляемых сервером.
netsh interface ipv4 set subinterface "Беспроводная сеть" mtu=1200 store=active
После изменения сервер по-прежнему отвечал HTTP 200, но curl получил 0 байт тела до тайм-аута. То есть снижение MTU проблему не исправило, а на одном повторе результат стал хуже. Затем вернул исходное значение 1500. Снижение MTU на клиенте не помогло.
tcpdump: сервер отправляет продолжение ответа
После тестов с curl оставалось понять: сервер перестаёт отправлять данные или продолжает отправку, но не получает подтверждений от клиента?
На Ubuntu tcpdump уже был установлен. Сначала я слушал все интерфейсы (-i any), но Docker создавал дубли пакетов на veth, bridge и eth0, а в вывод попадали посторонние TCP-сессии. Поэтому для анализа выбрал внешний eth0 и отфильтровал трафик по публичному IP мобильного подключения.
Клиентский IP определил в Windows:
curl -4 https://api.ipify.org
На Hetzner запустил захват (IP клиента ниже заменён):
sudo tcpdump -ni eth0 -nn -tttt \ 'host 198.51.100.25 and tcp port 443' > /tmp/beeline-debug.txt
И одновременно повторил проблемный запрос к CSS через Билайн. На этот раз в дампе нашлась нужная TCP-сессия.
Сначала обычный TCP handshake
15:03:04.959734 client.5259 > server.443: Flags [S], seq 943589031, options [mss 1400,...] 15:03:04.959834 server.443 > client.5259: Flags [S.], seq 1175333000, ack 943589032 15:03:05.116286 client.5259 > server.443: Flags [.], ack 1 ... 15:03:05.518647 client.5259 > server.443: Flags [P.], seq 283:425, ack 3814, length 142
TCP handshake прошёл успешно, и стороны обменялись данными. Последнее подтверждение от клиента содержало ack 3814. Это означает: «все байты серверного потока до 3814 получены; следующим ожидается байт 3814».
Затем пошли повторные передачи
Сервер продолжил отправлять данные:
15:03:05.528389 server.443 > client.5259: seq 3814:4268, length 454 15:03:05.529033 server.443 > client.5259: seq 4268:6655, length 2387 15:03:05.529093 server.443 > client.5259: seq 6655:8422, length 1767 15:03:05.529159 server.443 > client.5259: seq 8422:11222, length 2800 15:03:05.529184 server.443 > client.5259: seq 11222:13167, length 1945
TCP так устроен, что сервер пытается сразу передавать несколько пакетов данных одновременно, что видно из дампа. Но новых ACK с продвижением подтверждённой последовательности сервер не увидел. Если упрощать, ACK это ответ от клиента об успешной доставке пакета. При отсутствии ACK протокол TCP запускает политику повторной отправки недоставленных пакетов. Как раз в дампе несколько раз повторялся один и тот же диапазон:
15:03:06.318645 server.443 > client.5259: seq 3814:4268, length 454 15:03:07.230702 server.443 > client.5259: seq 3814:4268, length 454 15:03:09.086610 server.443 > client.5259: seq 3814:4268, length 454 15:03:12.734545 server.443 > client.5259: seq 3814:4268, length 454 15:03:20.158662 server.443 > client.5259: seq 3814:4268, length 454

Дамп показал, что сервер продолжал отправлять данные, а затем повторно передавал неподтверждённый диапазон. Знак вопроса на схеме не указывает на место, где пакет «убило ТСПУ». Из серверного дампа видно лишь, что дальнейшие подтверждения не пришли. По нему нельзя различить потерю данных по пути к клиенту, потерю обратного ACK или иное вмешательство промежуточного устройства. Но проблему с сервером я исключил.
Второй сайт и три мобильных оператора
Чтобы точно исключить зависимость от Next.js и одного домена, я перешёл ко второму приложению. Оно живёт на том же публичном IP, но у него другой домен и другой фронтенд: React 19 + JSX + CSS. Это статическое SPA(Single Page Application).
Маленький HTML загрузился целиком:
< HTTP/1.1 200 OK < Content-Length: 579 < Server: nginx/1.27.5 < Via: 1.1 Caddy 100 579 100 579
В ответе видны оба сервера: Server: nginx/1.27.5 и Via: 1.1 Caddy. Nginx работает внутри образа приложения, Caddy в отдельном контейнере. В Docker Compose у приложения открыт внутренний порт 80, а у Caddy опубликованы 80 и 443. Путь запроса: клиент → HTTPS → Caddy → HTTP внутри Docker → Nginx → статические файлы React SPA. Стек отличается от первого приложения, а зависание оказалось таким же.
Главный HTML весил всего 579 байт. Для SPA это нормально: в нём почти нет интерфейса, только контейнер <div id="root"></div> и ссылки на JS/CSS. Браузеру нужен JavaScript, чтобы вообще отобразить страницу. Поэтому успешная загрузка маленького HTML ничего не говорила о доступности самого приложения.
Нашёл основной JS-бандл размером 197 092 байта и скачивал его одинаковой командой через мобильные сети. Имя файла ниже тоже условное, поскольку хэш сборки не влияет на смысл эксперимента:
curl -4 -k --http1.1 --max-time 20 -o NUL -w "HTTP: %{http_code}, Bytes: %{size_download}, Time: %{time_total}\n" https://app-b.example/assets/index-EXAMPLE.js
Результаты на разных операторах:
Мобильный оператор |
HTTP |
Получено из 197 092 Б |
Время |
Исход |
|---|---|---|---|---|
Билайн |
200 |
3693 Б |
20,010 с |
Тайм-аут |
Т2 |
200 |
3693 Б |
20,005 с |
Тайм-аут |
МегаФон |
200 |
3155 Б |
20,013 с |
Тайм-аут |
Характер ошибки везде один и тот же:
curl: (28) Operation timed out after 20004 milliseconds with 3693 out of 197092 bytes received HTTP: 200, Bytes: 3693, Time: 20.005383
Это вывод из теста Т2. Для Билайна объём был такой же, а у МегаФона удалось получить 3155 байт.
В каждом случае соединение успевало вернуть HTTP 200, но за 20 секунд не приходило даже 2% файла. Совпадение 3693 байт на двух операторах выглядит подозрительно, но не говорит о «настройке фильтра на 3693 байта». Размер полученного HTTP-тела может зависеть от TLS-буферизации и сегментации.
Отдельные tcpdump для Т2 и МегаФона я уже не снимал. Поэтому показанные выше повторы seq 3814:4268 относятся только к первоначальному тесту с Билайном, а не ко всем трём операторам.
DPI/ТСПУ: вывод или всё ещё гипотеза?
После этих экспериментов основной версией для меня стала селективная фильтрация зарубежного трафика, возможно с участием DPI/ТСПУ. В пользу этой версии говорили результаты проверок:
Приложения работали по домашнему подключению, но испытывали проблемы в мобильных сетях.
Маленькие HTTPS-ответы проходили, большие систематически зависали.
Сбой не зависел от конкретного фронтенд-стека: проверены два разных приложения на одном зарубежном сервере.
Для одного из соединений
tcpdumpпоказал повторные отправки TCP-данных после остановки новых ACK.На втором сайте одинаковый тип зависания воспроизвёлся у трёх операторов.
HTTPS шифрует содержимое соединения, но не скрывает всю информацию о нём. IP-адрес назначения остаётся видимым сетевому оборудованию. А при обычном TLS доменное имя можно определить из поля SNI в сообщении ClientHello. Это позволяет системам фильтрации применять ограничения не только к конкретным доменам, но и к IP-адресам и подсетям.
Исследователи OONI документировали вмешательство в TLS в российских сетях, в частности остановку соединений после ClientHello. Наш сценарий отличается: TLS завершился успешно, HTTP-заголовки и начало тела ответа дошли до клиента. Поэтому я не буду утверждать, что воспроизвёл именно описанную OONI блокировку по SNI.
В моём случае проблема воспроизводилась на двух разных доменах, указывающих на один IP-адрес Hetzner. Поэтому одной из основных гипотез стала фильтрация трафика по адресу или подсети зарубежного хостинг-провайдера.
Проверки показали, что крупные HTTPS-ответы с финского VPS систематически не загружались из трёх мобильных сетей. В одном проблемном соединении сервер перестал получать новые подтверждения TCP и повторно отправлял данные. Какое устройство вызвало сбой, выяснить не удалось.
Почему не попробовал Cloudflare CDN?
Такой вариант был. Я мог переключить DNS-запись второго домена с DNS only на Proxied («оранжевое облако»). Тогда клиент подключался бы к одному из адресов Cloudflare, а Cloudflare уже общался бы с моим Hetzner. Для части статических файлов CDN мог бы ещё и отдавать закэшированные копии без обращения к origin.
Это потенциально обходит проблемный прямой маршрут к конкретному IP/подсети Hetzner, так как точка входа и маршрут для пользователя меняются. Доступность Cloudflare из конкретной российской сети тоже может отличаться. А лишний проксирующий участок способен как уменьшить, так и увеличить задержку в зависимости от расположения узлов, кэша и маршрутизации.
Контрольного теста с включённым Cloudflare Proxy я не проводил, поэтому сравнить скорость не могу. Для своей задачи я выбрал перенос.
Что сделал в итоге
Я перенёс оба приложения на VPS в России. Название провайдера здесь не принципиально: сменились площадка и сетевой маршрут для основной аудитории.
После переноса проверил доступность сайтов через Билайн, Т2 и МегаФон. Оба приложения стали нормально открываться во всех трёх сетях. Повторять диагностику с tcpdump на новой площадке уже не понадобилось, так как проблема перестала воспроизводиться.
Что заберу с собой в следующие деплои
Проверка «с моего домашнего Wi-Fi открывается» слишком слабая. Особенно если пользователи находятся в России, а сервер за границей. Теперь после релиза буду проверять сайт через реальную мобильную сеть.
Больше всего помог вопрос: «какие именно TCP-байты сервер отправил и какие из них клиент подтвердил?». Ответ на этот вопрос помог устранить подозрения в адрес React, Next.js и Caddy. Проверки перешли на сторону доставки данных по сети.
Итог для моих экспериментов следующий. Приложения на сервере работали исправно, даже на клиенте получал HTTP 200. Но ответ не доходил целиком из-за того, что клиент прекращал отвечать серверу при достижении определенного лимита по полученным данным. Скорее всего из-за вмешательства DPI/ТСПУ.
Источники и технические справки: OONI: о вмешательстве в TLS в России, RFC 9293: TCP, Linux Segmentation Offloads, Cloudflare: DNS only и Proxied.

predpremi
Мой Мечтатель: «Вот это да, какая интересная проблема! Думаешь, такой трюк с зависанием после нескольких килобайт может быть универсальным способом обхода блокировок?»
Мой Валидатор: «Да какой обход, тут бизнес встал! Я такое видел, когда хостинг чудил, или на проде внезапно лимиты по трафику выстреливали. Пока клиенты не видят сайт, денег нет. Это надо было сразу на мониторинг ставить, а не ждать жалоб.»