Сжатие включили дважды, и браузеры получали битый ответ
Заявки в поддержку выглядели странно: одни пользователи открывают сайт нормально, другие видят пустую страницу или набор нечитаемых символов вместо HTML. Ни у кого в команде страница не ломалась — все смотрели с рабочих машин, и всё грузилось. Разгадка нашлась не в коде и не в базе, а в том, что два разных слоя стека одновременно решили сжать один и тот же ответ — и получившийся поток данных браузер уже не мог разжать.
Содержание
Что сломалось: первые сигналы
Заявки шли волнами и без явной системы: то от пользователей на одном провайдере, то через корпоративный прокси, то с конкретной версии мобильного браузера. Общая жалоба — «сайт не открывается» или «вместо страницы каша». Скриншоты приносили редко, а те, что приносили, показывали либо пустой белый экран, либо текст, будто открытый не в том кодировании: вперемешку кракозябры и бинарные символы.
Первая проверка ничего не дала. С рабочего ноутбука в Chrome и Firefox страница открывалась мгновенно, без ошибок в консоли. curl тоже отдавал вменяемый HTML. Это самый неприятный тип инцидента: воспроизвести его на своём окружении не получается, а поток жалоб не прекращается. Возникло подозрение на CDN — незадолго до этого перед сервером настроили кеширование статики, и первая версия гипотезы звучала так: «кеш отдаёт какой-то битый объект части пользователей».
Что видели в логах и метриках
На сервере (nginx перед PHP-FPM, приложение — не самое новое, унаследованное вместе с VPS от предыдущей команды) все запросы, включая проблемные, логировались с кодом 200 и нормальным временем ответа. Access-лог ничего не подсказывал — с точки зрения сервера каждый запрос отработал штатно: бэкенд отдал тело, nginx передал его клиенту, соединение закрылось без ошибок.
203.0.113.44 - - [24/Aug/2026:11:02:17 +0000] "GET /catalog HTTP/1.1" 200 18422 "-" "Mozilla/5.0 ..."
200 и корректный Content-Length — но это Content-Length уже сжатого тела, а не оригинального HTML, и сервер не знает и не обязан знать, смог ли клиент это тело разжать. Здесь первая важная деталь инцидента: сервер не видит клиентских ошибок декодирования. Если браузер получил валидный HTTP-ответ, но не смог раскодировать Content-Encoding, для сервера это всё равно успешный запрос. Мониторинг по кодам ответа (Uptime-проверки, большинство синтетических чекеров) в такой ситуации будет зелёным сколько угодно долго.
Проверка через DevTools на живом кейсе — когда удалось поймать пользователя с багом на созвоне и попросить открыть вкладку Network — показала другое: у проблемного запроса browser показывал ошибку вида «ERR_CONTENT_DECODING_FAILED» прямо на уровне сетевого стека, до того как содержимое дошло до рендеринга. Заголовок ответа при этом был Content-Encoding: gzip, и на первый взгляд всё выглядело нормально.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверГипотезы, которые отбросили
Прежде чем дойти до реальной причины, проверили и закрыли несколько версий:
- Битый кеш CDN. Сбросили кеш полностью, прогнали запросы заново с
Cache-Control: no-cache— проблема воспроизводилась и на некешированных ответах. CDN оказался ни при чём: он честно передавал то, что получал от origin. - Проблема на уровне TLS/HTTP2-фреймов. Версия появилась из-за того, что баг был нестабильным и зависел от сети клиента. Проверили теми же клиентскими сетями через HTTP/1.1 напрямую к серверу, без HTTP/2 — эффект сохранился. Значит, дело не в мультиплексировании фреймов.
- Повреждение файла на диске. Логично было заподозрить битые статические файлы (fsck ничего не показал, но чек лишним не бывает) — однако ломались и полностью динамические ответы PHP, которые каждый раз генерируются заново и ни на каком диске не лежат. Это исключило файловую систему.
- Баг в шаблонизаторе приложения, который иногда выводит мусорные байты в буфер вывода. Проверили, отключив gzip на уровне PHP и оставив только сервер — страницы стали открываться у всех тестовых клиентов без единого сбоя. Это была первая зацепка, которая реально сузила круг: дело было в сжатии, а не в контенте.
Показательно, что подозревали в первую очередь CDN — просто потому, что его включили последним по времени. На деле CDN был прозрачным передатчиком, а причина лежала на два слоя глубже, в связке, которую никто не менял одновременно и специально.
В чём была реальная причина
В php.ini было выставлено zlib.output_compression = On — так делали годом раньше, когда сервер работал без reverse proxy и PHP сам отвечал на HTTP-запросы напрямую через встроенный сервер разработки на тестовом окружении, а настройку по инерции перенесли в прод. Именно PHP-интерпретатор сжимал HTML в gzip и выставлял Content-Encoding: gzip в своём ответе.
Позже, уже отдельно, при настройке nginx как reverse proxy перед PHP-FPM и статикой, по стандартному чек-листу производительности включили и gzip on; на уровне самого nginx — нормальная практика, если сервер отдаёт несжатый контент. Но в этой связке nginx получал от бэкенда тело, которое PHP уже сжало, и при используемой схеме fastcgi_pass заголовок Content-Encoding от бэкенда пробрасывался не так, как ожидалось — второй слой сжатия применился поверх первого. Итог — тело ответа сжато gzip дважды подряд: снаружи валидный gzip-контейнер, а внутри него не HTML, а ещё один, уже нечитаемый для браузера gzip-поток.
Проверка руками это подтвердила:
curl -sD - -H "Accept-Encoding: gzip" https://example.com/catalog -o /tmp/resp.bin
gunzip -t /tmp/resp.bin
# gzip: /tmp/resp.bin: OK — внешний слой действительно валиден
gunzip -c /tmp/resp.bin > /tmp/layer1.bin
file /tmp/layer1.bin
# /tmp/layer1.bin: gzip compressed data — а должно быть HTML
Первый gunzip отрабатывал без ошибок — именно поэтому симптом был непостоянным: часть браузеров и корпоративных прокси после снятия внешнего слоя пытались распознать и снять второй слой (или падали с ошибкой декодирования), а часть просто отдавала пользователю содержимое второго gzip-контейнера как есть — те самые «кракозябры» в скриншотах. HTTP не предусматривает Content-Encoding: gzip, gzip для такого случая, и каждый клиент по-своему обрабатывает эту ситуацию.
Почему не воспроизводилось у команды: в тестовом curl без флага --compressed запрос вообще не просил сжатый ответ и получал его как есть, что скрывало проблему, а у части реальных пользователей оба слоя срабатывали одновременно.
Почему двойное сжатие ломает Content-Encoding
Технически ничего экзотического не происходит: gzip — это формат сжатия потока байтов, ему всё равно, что именно он сжимает — текст, картинку или уже сжатые данные. Если сжать HTML один раз, получится компактный поток. Если сжать результат ещё раз, размер почти не изменится (сжатые данные уже близки к максимальной энтропии, второй проход почти ничего не выигрывает или даже немного увеличивает размер), но главное — снаружи получившегося файла будет валидный gzip-заголовок, а внутри — данные, которые нужно распаковывать ещё раз.
Проблема в том, что HTTP-заголовок Content-Encoding: gzip в ответе — ровно один, и он говорит браузеру: «сними один слой gzip и получишь исходный контент». Браузер так и делает. Он честно снимает один слой — и вместо HTML получает второй, всё ещё сжатый поток, который интерпретирует как обычный текст/HTML. Отсюда и видимый эффект — «мусор» вместо страницы, либо жёсткая ошибка декодирования, если браузер или прокси на этом этапе дополнительно проверяют валидность контента по MIME-типу.
Ключевой практический вывод для любой конфигурации, где есть больше одного слоя обработки ответа (приложение → веб-сервер/прокси → CDN): сжимать тело ответа должен ровно один слой в цепочке, и остальные слои должны либо не трогать уже сжатый контент, либо явно об этом сжатии знать. У nginx есть встроенная защита от части таких ситуаций — модуль gzip по умолчанию не сжимает повторно ответ, если видит, что Content-Encoding уже выставлен апстримом, но эта защита не универсальна для всех конфигураций проксирования и точно не спасает, если сжатие включено сразу на нескольких уровнях приложения (например, и в фреймворке, и в его же прокси-мидлваре). Полагаться на то, что сервер сам «разрулит» двойное сжатие, не стоит — правильнее вообще не создавать ситуацию, где два независимых слоя оба считают себя ответственными за компрессию.
Если у вас перед сервером стоит CDN, это тот же принцип, только с ещё одним звеном в цепочке: CDN тоже может пересжимать контент на edge-узле, и если origin уже отдал gzip, а на CDN включено собственное сжатие без проверки заголовков апстрима, повторится тот же сценарий, только диагностировать его будет ещё сложнее — надо проверять ответ и на origin, и на границе CDN отдельно.
Что изменили после инцидента
Решение было простым, как только причина стала понятна — отключить сжатие на одном из двух уровней. Оставили компрессию только на веб-сервере, а не в приложении: так проще централизованно управлять уровнем сжатия, списком MIME-типов и минимальным размером тела для сжатия, не трогая код и конфиги самого PHP-приложения при каждом изменении политики.
В php.ini:
zlib.output_compression = Off
В конфиге nginx явно зафиксировали, какие типы контента сжимаются и на каком уровне, а также включили проверку, что бэкенд не присылает свой Content-Encoding:
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/html;
gzip_min_length 512;
gzip_proxied any;
gzip_vary on;
# на время миграции — явно убеждаемся, что бэкенд не сжимает сам
proxy_hide_header Content-Encoding;
Последнюю строку оставили временно, как страховку на переходный период, пока не убедились, что все инстансы PHP-FPM обновили конфигурацию и нигде в коде приложения не осталось ручного вызова gzencode() или подобных функций для формирования ответа. Затем эту строку убрали, чтобы не терять возможность передавать сжатие «как есть» в редких случаях, когда апстрим всё-таки обязан сам управлять кодированием (например, для проксирования чужого API).
Отдельно добавили пункт в чек-лист пост-деплоя: после любого изменения, касающегося сжатия, кеширования или reverse proxy, руками прогонять проверку двойного кодирования, а не полагаться только на «страница открылась у меня в браузере»:
curl -sD - -H "Accept-Encoding: gzip" https://example.com/ -o /tmp/body.bin
gunzip -c /tmp/body.bin | file -
# ожидаем: HTML document text, UTF-8 Unicode text
# если видим "gzip compressed data" — сжатие сработало дважды
Синтетический мониторинг тоже расширили: до инцидента проверка доступности сайта смотрела только на HTTP-код ответа, что и позволило проблеме прожить незамеченной несколько дней. Добавили отдельную проверку, которая скачивает страницу с Accept-Encoding: gzip, распаковывает тело и сверяет, что результат — валидный HTML, а не ещё один бинарный поток.
Полезно было и завести отдельную страницу настроек reverse proxy и кеширования nginx во внутренней документации проекта — со списком того, какой слой за что отвечает: сжатие, кеш, заголовки безопасности. До инцидента эти настройки менялись разными людьми в разное время без единого источника правды, и именно поэтому одна и та же логичная по отдельности настройка (сжимать на уровне приложения — нормально; сжимать на уровне веб-сервера — тоже нормально) превратилась в баг, когда оба решения оказались применены одновременно и никто не смотрел на картину целиком.
Как быстро проверить свой сервер на ту же проблему
Если раньше на проекте отдельно настраивали сжатие в приложении (Node.js с compression, Python с gzip-мидлварой Flask/Django, PHP с zlib.output_compression) и отдельно на веб-сервере (nginx gzip on, Apache mod_deflate) — стоит явно проверить, не сложились ли они в такую же связку, даже если внешне всё работает:
- Посмотрите, выставляет ли приложение
Content-Encodingсамо — обратитесь напрямую к бэкенду (порту PHP-FPM или Node-процесса), минуя reverse proxy, если такой доступ возможен на тестовом окружении. - Прогоните
gunzip -c ответ.bin | file -для нескольких ключевых страниц: главная, страница с формой, API-эндпоинт с JSON. - Проверьте статику отдельно от динамики — иногда сжатие включено только для одного типа контента, и баг проявляется избирательно.
- Если есть CDN, повторите проверку и на edge-узле, и напрямую к origin — так видно, на каком слое возникает двойное кодирование.
- Держите в документации явную запись, какой слой отвечает за сжатие — это экономит часы разбора, когда кто-то в следующий раз решит «ускорить сайт» очередным
gzip on.
Для таких изменений удобен отдельный staging-сервер, повторяющий прод-конфигурацию nginx и PHP-FPM, с прогоном этих же curl-проверок перед каждым релизом, который трогает nginx.conf или php.ini.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Как понять, что у меня именно двойное сжатие, а не просто медленный сайт или сломанный SSL?
Главный признак — ошибка декодирования именно в браузере (ERR_CONTENT_DECODING_FAILED или аналогичная в других браузерах), а не в загрузке страницы целиком, плюс несовпадение между «у разработчиков всё работает» и «у части пользователей — нет». Проверка через curl -H "Accept-Encoding: gzip" с последующим gunzip -c | file - даёт точный ответ за минуту.
Почему у меня в Chrome всё открывается, а жалобы всё равно есть?
Разные браузеры и версии по-разному обрабатывают некорректный Content-Encoding: один может ошибиться сразу, другой — попытаться разжать дважды и получить мусор, третий — вообще не заметить проблему на конкретном ответе, если тело маленькое и сжатие для него не сработало ни на одном из уровней. Поэтому воспроизводимость бывает нестабильной, и полагаться на «у меня работает» нельзя.
Достаточно ли включить gzip_proxied any в nginx, чтобы обезопаситься?
Эта директива решает другую задачу — разрешает или запрещает nginx сжимать ответы, полученные через прокси, в зависимости от заголовков запроса клиента. Она не защищает от того, что бэкенд уже сжал тело сам. Единственная надёжная защита — держать сжатие только на одном уровне цепочки и явно это контролировать.
Можно ли сжимать на уровне и приложения, и веб-сервера, если аккуратно настроить заголовки?
Технически можно добиться корректной работы, если строго убедиться, что веб-сервер никогда не сжимает уже сжатый ответ (проверяя Content-Encoding от апстрима перед собственным gzip) и приложение никогда не сжимает то, что уже сожмёт сервер. На практике поддерживать такую договорённость сложнее и рискованнее, чем просто выбрать один слой ответственным за сжатие и отключить компрессию на остальных.
Влияет ли двойное сжатие на CPU сервера, если браузер всё равно не может прочитать ответ?
Да, и это отдельная неприятность: сервер тратит ресурсы на два прохода компрессии для каждого ответа, отдавая при этом клиенту нечитаемые данные — то есть теряется и производительность, и корректность одновременно, без какой-либо пользы взамен.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →