MAATRIX / Блог / Файлы загружались, но не открывались: прокси резал тело запроса на мегабайте

Файлы загружались, но не открывались: прокси резал тело запроса на мегабайте

MAATRIX

Тикеты выглядели одинаково: «загрузил файл, всё было зелёное, а он не открывается». Ни ошибки, ни предупреждения — прогресс-бар доходил до конца, сервер отвечал 200 OK, и только потом выяснялось, что файл битый. Это один из самых неприятных классов инцидентов: система на каждом шагу врёт, что всё хорошо, а на самом деле теряет данные молча. Ниже — как мы нашли виновника и почему им оказался не бэкенд и не диск, а прокси где-то посередине цепочки, обрезавший тело запроса ровно на границе в один мегабайт.

Симптомы: успех, который на самом деле не успех

Жалобы начали приходить после того, как мобильное приложение обновили и добавили загрузку документов и фото в высоком разрешении. Раньше через эту форму летели маленькие аватарки по 100–300 КБ, теперь — сканы документов и фотографии по нескольку мегабайт. И именно с этого момента посыпались тикеты.

Первое, что бросилось в глаза: проблема не была случайной. Один и тот же пользователь на одном и том же файле мог гарантированно воспроизвести баг, просто загрузив его ещё раз. Мы попросили у нескольких пользователей проблемные файлы и сравнили размер оригинала с тем, что реально лежало в хранилище. Разница была не «немного меньше» — файл на сервере обрывался в одном и том же месте, около отметки в 1 048 576 байт, с небольшими колебаниями в зависимости от того, где именно проходила граница multipart-заголовков в конкретном запросе.

Второй важный факт: маленькие файлы (до примерно 900 КБ) проходили без единой проблемы, всегда. Файлы больше мегабайта — ломались не всегда, но стабильно чаще, и почти всегда одинаково: PDF не открывался, потому что обрывался в середине потока объектов, JPEG показывал верхнюю часть картинки и серый мусор внизу — классическая картина обрезанного файла, не повреждённого случайным образом.

В логах приложения при этом не было ни одной ошибки. Запрос приходил, backend писал файл на диск (или в S3-совместимое хранилище), отвечал 200, всё было зафиксировано как успех. Никакого исключения, никакого таймаута, никакого 413 Request Entity Too Large — а именно этот код мы ожидали бы, если бы дело было в банальном лимите на размер тела запроса.

Что проверили и отбросили

Прежде чем идти вглубь инфраструктуры, мы прошли по самым вероятным и самым дешёвым в проверке версиям — и все они не подтвердились.

  • Плохая сеть на мобильных. Логичная первая версия для мобильного приложения. Отбросили быстро: баг воспроизводился и с офисного Wi-Fi, и по кабелю, через обычный curl с одного и того же сервера в том же дата-центре, где стоит бэкенд.
  • Место на диске. df -h на сервере приложения и на нодах хранилища показывал десятки свободных гигабайт. Это не он.
  • Баг в парсере multipart на бэкенде. Проверили в обход всей внешней цепочки: зашли на сам сервер приложения по SSH и обратились к нему напрямую через curl --data-binary @file.bin http://127.0.0.1:8080/upload — файл в 5 МБ прилетел и сохранился полностью, побайтово совпадая с оригиналом (сверяли sha256sum). Значит, бэкенд сам по себе исправен.
  • Проблема на стороне объектного хранилища. Точно так же протестировали прямую заливку в S3-совместимый бакет тем же SDK и теми же ключами — файл дошёл целиком. Хранилище тоже не виновато.
  • nginx явно режет по client_max_body_size. Версия выглядела наиболее правдоподобной с самого начала — но именно она первой не сходилась с фактами: если бы сработал client_max_body_size, клиент увидел бы чистый 413, а не тихий 200 с обрезанным файлом внутри. Мы специально проверили error.log и access.log на всех nginx-узлах — ни одной записи о превышении лимита не было.

Именно последний пункт и стал зацепкой. Если лимит на размер тела не в классическом виде — где-то дальше по цепочке тело обрезают, но при этом сознательно (или по ошибке) отвечают клиенту так, будто всё в порядке.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Как локализовали: разбор по звеньям

Инфраструктура выглядела так: клиент → edge-узел (TLS-терминация и гео-роутинг между двумя регионами, добавлен несколько месяцев назад) → внутренний nginx как reverse proxy → приложение → объектное хранилище. Мы уже знали, что напрямую в приложение файл долетает целым — значит, проблема где-то между клиентом и приложением, и дальше искали методом исключения звеньев.

Подход был простым и грубым: собрать тестовый файл заведомо больше границы, где всё ломается, и прогонять его через цепочку, каждый раз убирая или обходя одно звено.

dd if=/dev/urandom of=test.bin bs=1M count=5
sha256sum test.bin

# через полную публичную цепочку
curl -s -o /dev/null -w "%{http_code} %{size_upload}\n" \
  -X POST --data-binary @test.bin https://app.example.com/upload

# напрямую на внутренний nginx, минуя edge-узел
curl -s -o /dev/null -w "%{http_code} %{size_upload}\n" \
  -X POST --data-binary @test.bin http://10.0.1.15/upload

Результат: при обращении напрямую к внутреннему nginx (минуя edge-узел) файл долетал целиком и без искажений. Как только запрос шёл через публичный домен, то есть через edge-узел, файл на диске обрывался около мегабайта, а клиент всё равно получал 200. Это сузило круг подозреваемых до одного конкретного узла — того самого, который добавили относительно недавно ради гео-роутинга между регионами. Если вы ещё не разбирались, как в принципе устроен путь запроса через такой узел, полезно сначала посмотреть, как работает reverse proxy и что происходит с запросом на каждом шаге — без этой базы дальнейшие детали читаются тяжелее.

Реальная причина: связка из двух ошибок конфигурации

Когда мы вытащили конфиг edge-узла целиком (nginx -T), нашлись сразу две вещи, которые по отдельности звучат безобидно, а вместе дают именно такое поведение.

Первая: client_max_body_size тихо откатился к дефолту. Полгода назад для этого узла явно выставляли client_max_body_size 25m; для загрузок. Но когда конфиги перевели на шаблонизацию через систему управления конфигурацией, шаблон для нового региона собирали «с нуля» по образцу типового веб-сервера — и про override для upload-location просто забыли. В результате для этой location действовало то, что nginx подставляет, если директива вообще не задана явно — а это ровно 1 мегабайт, исторически консервативное значение по умолчанию. Отсюда и граница, на которой стабильно всё ломалось.

Вторая, куда менее очевидная: старый error_page, переехавший не туда. Ещё раньше на этом же сервере был отдельный location для приёма легковесных диагностических «маячков» от старого мобильного клиента — не критичных, и разработчики специально сделали так, чтобы превышение лимита по ним не считалось ошибкой на стороне клиента:

location /beacon/ {
    client_max_body_size 64k;
    error_page 413 = 200 /empty.json;
}

При очередном рефакторинге конфига блок error_page 413 = 200 ...; по невнимательности скопировали в общий location /upload/, рассчитывая на что-то другое (скорее всего, хотели точно так же «проглатывать» некритичные ошибки квоты для превью). В сочетании с proxy_request_buffering off;, включённым для upload-location ради потокового проксирования больших файлов без буферизации на диск edge-узла, это дало ровно тот эффект, который мы наблюдали: nginx начинал сразу же перенаправлять байты тела запроса дальше по цепочке, не дожидаясь, пока получит его целиком; как только накопленный объём превышал client_max_body_size, nginx обрывал приём от клиента — но благодаря подмене error_page отдавал клиенту не 413, а 200, будто всё прошло штатно. К этому моменту часть тела уже успевала уйти дальше, и на стороне приложения оказывался частично записанный файл, который никто не сверял с ожидаемым размером.

Важная оговорка: мы не можем на сто процентов утверждать, что именно в таком порядке события происходили в каждом отдельном запросе — цепочка nginx → приложение не давала нам однозначного трейса на уровне отдельных байтов. Но совокупность признаков — стабильная граница ровно в районе 1 МБ, отсутствие 413 в логах при наличии error_page 413 = 200, и то, что проблема исчезала при обходе edge-узла — сходилась только на этом объяснении, и после исправления обеих причин баг пропал полностью.

Что изменили после инцидента

Правки разделили на три группы: закрыть конкретную дыру, убрать источник тихих ошибок в принципе, и добавить средство обнаружения, если что-то подобное повторится в другом месте.

Лимиты тела запроса — явно и по назначению. Вместо одного общего значения на весь сервер завели отдельные лимиты по location, с комментарием в конфиге, почему выбрана именно такая цифра:

location /upload/avatar/ {
    client_max_body_size 10m;
}

location /upload/document/ {
    client_max_body_size 25m;
}

location /beacon/ {
    client_max_body_size 64k;
    # error_page 413 = 200 — сознательно только здесь,
    # маячки не критичны для пользователя
    error_page 413 = 200 /empty.json;
}

Эти значения также зафиксировали в шаблоне конфигурации, чтобы при генерации конфига для нового региона override не терялся снова, а явно требовал ревью в код-ревью инфраструктурных изменений.

error_page 413 оставили только там, где он был задуман изначально — на диагностическом маячке, и явно прокомментировали, почему подмена статуса в остальных local location недопустима. Заодно прошлись по всему конфигу в поисках других подобных «заимствований»:

nginx -T | grep -B2 "error_page"

Буферизацию тела запроса вернули к более предсказуемому режиму для upload-location: proxy_request_buffering on; с явно заданным client_body_buffer_size и отдельным диском под client_body_temp_path, чтобы nginx сам полностью принимал и проверял тело запроса, прежде чем что-либо отправлять дальше — ценой дополнительной записи на диск при больших файлах, что для этого узла оказалось приемлемым компромиссом.

На уровне приложения добавили проверку целостности. Раньше backend считал загрузку успешной по факту завершения записи потока, не сверяя итоговый размер файла с заявленным Content-Length или контрольной суммой, которую клиент передаёт отдельным полем. Теперь несовпадение размера или чек-суммы — это явная ошибка загрузки, а не тихий успех, даже если сам HTTP-запрос формально завершился без исключений.

Добавили синтетический мониторинг всей цепочки. Отдельная задача по расписанию раз в несколько минут загружает тестовый файл размером чуть больше 1 МБ через публичный домен (то есть через весь путь целиком, включая edge-узел), сверяет контрольную сумму полученного файла с эталоном и шлёт алерт при малейшем расхождении — независимо от того, на каком именно узле цепочки в следующий раз что-то сломается.

Как проверить, что у вас не заложена та же мина

Даже если вы не сталкивались с такими симптомами, стоит один раз явно проверить цепочку загрузки файлов, особенно если в ней есть больше одного прокси или балансировщика.

  1. Посмотрите фактический лимит на каждом узле, а не тот, что вы когда-то задавали в конфиге:
   nginx -T | grep -i client_max_body_size

Если директива нигде явно не задана для нужной location — действует дефолт в 1m, и это стоит исправить осознанно, а не полагаться на память о том, что «мы вроде это настраивали».

  1. Проверьте, нет ли подмены кодов ошибок, которая может маскировать реальные отказы:
   nginx -T | grep -A1 "error_page"

Любой error_page, переводящий 4xx или 5xx в 2xx, — кандидат на пристальную проверку: для кого он был написан изначально и не «утёк» ли на соседние location.

  1. Прогоните файл чуть больше подозрительной границы через всю публичную цепочку и сверьте не только HTTP-код, но и реальный размер и контрольную сумму на другом конце:
   dd if=/dev/urandom of=probe.bin bs=1M count=2
   sha256sum probe.bin
   curl -s -o /dev/null -w "%{http_code}\n" \
     -X POST --data-binary @probe.bin https://ваш-домен/upload/
   # затем сверьте контрольную сумму того, что реально сохранилось
  1. Если перед сервером есть ещё и CDN или WAF, повторите тот же тест в обход него, обращаясь напрямую к серверу по IP с нужным Host-заголовком. Многие такие продукты по умолчанию инспектируют или буферизуют тело запроса с собственным внутренним лимитом, который не всегда виден в их публичной документации так же прозрачно, как client_max_body_size в nginx.

Если у вас классическая связка nginx как reverse proxy перед приложением, проверка первого пункта займёт пару минут и сразу снимет один из самых частых источников подобных инцидентов.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему nginx по умолчанию режет тело запроса именно на 1 МБ?

Это исторически консервативный дефолт client_max_body_size, рассчитанный на защиту от случайно огромных запросов на серверах, где никто не задумывался о загрузке файлов. Для любого endpoint, принимающего файлы больше мегабайта, лимит нужно поднимать явно и осознанно, а не полагаться на то, что «и так сойдёт».

Как быстро понять, что лимит режется именно на nginx, а не глубже по цепочке?

Проверьте access.log и error.log на статус 413 — если он там есть, дело в классическом лимите nginx на этом узле. Если клиент получает 200, а файл всё равно битый, ищите либо подмену кода ошибки через error_page, либо ещё один прокси дальше по пути, у которого свой собственный, менее заметный лимит.

Может ли CDN или WAF перед сервером обрезать тело точно так же незаметно?

Да, и часто это ещё труднее диагностировать, потому что логи такого узла не всегда доступны в той же степени, что и ваш собственный nginx. Универсальный способ — не гадать по документации конкретного продукта, а гонять сквозной тест с контрольной суммой через весь публичный путь, как описано выше: он покажет проблему независимо от того, на каком именно узле она возникает.

Что делать, если через эту же форму действительно должны загружаться большие файлы — видео, бэкапы, архивы?

Поднимайте client_max_body_size под реальные потребности конкретной location, а не сервера целиком, и на больших объёмах присматривайтесь к протоколам с возобновляемой закачкой по частям вместо одного гигантского POST — так меньше зависимость от того, что вся цепочка прокси выдержит один длинный запрос без сбоев.

Почему тестировать нужно именно через контрольную сумму, а не только по HTTP-коду?

Потому что именно этот инцидент и показал: 200 OK ничего не гарантирует, если где-то по пути статус ошибки можно тихо подменить. Контрольная сумма — единственный способ убедиться, что на другом конце лежит ровно тот же набор байтов, что был отправлен.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →