Индикатор загрузки дошёл до девяноста семи процентов, на секунду замер, а потом исчез вместе с соединением. В консоли браузера короткое «network error», в интерфейсе ничего, в каталоге с файлами пусто. Вы увеличиваете лимит в одном месте, повторяете, и файл снова пропадает, только теперь на девяноста девяти процентах. Это типичная картина для цепочки, где каждое звено имеет собственное представление о том, что такое «слишком большой», «слишком медленный» и «слишком много».

Такая цепочка не ломается в одной точке, поэтому одна правка редко её чинит. Файл проходит несколько участков, и на каждом участке свои причины потерь. Разберём путь целиком, научимся отличать отказ по размеру от отказа по ожиданию и от нехватки места, посмотрим, чем потоковая запись отличается от накопления в памяти, и закончим тем, как понять, что файл на сервере действительно целый.

Пять остановок на пути файла

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

  1. Браузер. Отправляет тело запроса порциями. Скорость зависит от канала пользователя: на мобильной сети она прыгает, соединение может замирать на секунды, а вкладку могут закрыть или усыпить.
  2. nginx. Принимает тело запроса, сверяет его размер с лимитом, следит за паузами между порциями и при необходимости складывает тело во временные файлы на диске.
  3. Next.js. Получает запрос в конкретном обработчике. Тут важно, какой это обработчик: Server Action, Route Handler или код со сторонним разбором multipart. У каждого свои правила и свои ограничения.
  4. Хранилище. Каталог, куда обработчик пишет данные. Сюда попадают нехватка места, права доступа, неудачная запись и недописанные «хвосты» после обрыва.
  5. Подтверждение. Момент, когда система решает, что файл принят целиком. Его легко перепутать с моментом, когда сервер ответил на первый запрос.

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

Три отказа, которые выглядят одинаково

Пользователь видит одно и то же: «загрузка не удалась». Сервер же различает как минимум три причины, и у каждой свой след в журналах.

Размер: ответ 413

В nginx директива client_max_body_size ограничивает размер тела запроса. Если тело больше, клиент получает HTTP 413, а значение 0 полностью отключает эту проверку (документация nginx, модуль core). Это самый «честный» отказ: он детерминирован. Один и тот же файл упрётся в лимит всякий раз, независимо от скорости канала и времени суток. В журнале ошибок nginx вы найдёте строку про превышение размера, а в браузере увидите статус 413, если не мешает прокси или CDN перед сервером.

Соблазн поставить 0 и забыть понятен, но это не решение, а снятие предохранителя. Лимит в nginx защищает сервер от тела произвольного размера, которое будет пухнуть во временных файлах, пока не кончится диск. Лучше назначить лимит чуть выше максимально допустимого файла и осознанно держать его в том месте, где загрузки разрешены, а не на весь сервер.

Ожидание: ответ 408

Второй отказ связан со временем, но не с тем, о чём обычно думают. Директива client_body_timeout измеряет паузу между двумя последовательными чтениями тела, а не общую длительность передачи. Если пауза затянулась, nginx отвечает 408 (та же страница документации). Следствие неочевидно и важно: двухгигабайтный файл может идти час и не получить 408, если данные поступают без больших провалов. И наоборот, файл на пятьдесят мегабайт получит 408, если у пользователя пропала связь секунд на сорок при значении в тридцать секунд.

Поэтому для 408 бесполезно «увеличивать время загрузки» в общем смысле. Нужно задавать, сколько молчания вы готовы терпеть. Для пользователей на мобильных сетях разумно давать запас, а не ставить значение впритык, но и бесконечным его делать не стоит: медленные соединения занимают воркеры и дескрипторы.

Похожая по смыслу, но относящаяся к другому участку директива — proxy_read_timeout. Она задаёт интервал между последовательными чтениями ответа от апстрима, а не общую длительность ответа (документация nginx, модуль proxy). Она вступает в дело, когда nginx уже передал тело в Next.js и ждёт ответа. Если после получения файла обработчик долго молчит (считает контрольную сумму, разбирает архив, перекодирует видео), nginx может прервать ожидание, хотя файл уже полностью записан. Пользователь видит ошибку, а на диске лежит целый файл. Это самая коварная из трёх ситуаций, потому что провал выглядит как неудача, а данные на самом деле дошли.

Место: диск закончился

Третий отказ не имеет красивого кода ответа из документации. Когда на разделе заканчивается место, запись падает на полпути, и снаружи это выглядит как внезапный обрыв, иногда с ошибкой от Node.js, иногда просто с закрытым соединением. Дело усугубляется тем, что места требуют сразу два звена. nginx может складывать тело запроса во временные файлы, каталог для них задаёт client_body_temp_path (документация nginx), а приложение затем пишет итоговый файл в своё хранилище. Если оба каталога лежат на одном разделе, то в пике файл фактически занимает место дважды: копия во временном каталоге и копия в целевом.

Покажем это на условном примере; цифры здесь только для арифметики. Допустим, на разделе свободно 10 ГБ, а пользователи одновременно загружают пять файлов по 2 ГБ. Если временная копия и итоговая сосуществуют, потребность в пике — до 20 ГБ, и места не хватит задолго до конца. Если же каждый файл пишется один раз, потребность — 10 ГБ, впритык. А ведь в системе ещё живут журналы, пакеты и сама сборка Next.js. Простое правило: свободное место должно покрывать число одновременных загрузок, умноженное на максимальный размер файла и на число копий, существующих одновременно, плюс запас на всё остальное.

На VPS с root-доступом вы вольны выбрать, где лежат временные каталоги и итоговые файлы, и проверить это командами, а не догадками. Ресурсы тарифа тут нужно рассматривать как исходные данные для такого расчёта, а не как гарантию ёмкости: небольшой диск на 40 ГБ NVMe при нескольких крупных одновременных загрузках расходуется быстро, и решать нужно по расчёту, а не по названию тарифа.

Как отличить отказ по симптому
Что видноВероятный участокГде искать подтверждение
Статус 413 сразу или после отправки телаЛимит размера в nginxЖурнал ошибок nginx, значение client_max_body_size в нужном блоке
Статус 408 после паузы в передачеТайм-аут чтения тела в nginxЗначение client_body_timeout; качество канала клиента
Файл дошёл, но клиент видит ошибкуОжидание ответа приложенияЗначение proxy_read_timeout, время работы обработчика после записи
Обрыв на случайном проценте, у разных файлов разныйНехватка места или память процессаdf -h, df -i, журнал процесса Node.js
Ошибка Failed to find Server ActionРасхождение сборок или ключейНастройки сборки, список запущенных экземпляров

Лимит, который сработал не там, где его ждали

Теперь перейдём к самому Next.js, потому что именно здесь чаще всего ошибаются, перенося правило из одного API на другой. Server Actions, Route Handlers и код, использующий сторонний разбор multipart, — это три разных пути, и ограничения у них разные. У Server Actions в конфигурации Next.js есть собственный параметр, ограничивающий размер тела. Если вы нашли в интернете совет «увеличьте лимит в next.config» и применили его, он поможет только тем загрузкам, которые идут через Server Action. Точное имя параметра и значение по умолчанию сверяйте с документацией вашей версии Next.js: они меняются, и привязывать их к версии мы здесь не будем.

Из этого следует практический вывод. Если ваша загрузка идёт через обычный маршрут, то есть файл с app/api/upload/route.js принимается как поток, то лимит Server Actions к нему отношения не имеет. Его повышение ничего не изменит, а вот отсутствие собственной проверки размера в самом обработчике станет проблемой: nginx, возможно, настроен с запасом, а приложение будет принимать всё подряд. Верно и обратное: если вы приняли решение использовать Server Action для загрузки и упёрлись в ограничение этого механизма, то увеличение client_max_body_size в nginx проблему не снимет, потому что отказ произойдёт уже в приложении.

Есть и третья история: сторонний multipart-парсер вроде тех, что подключают в Node.js. У него свои параметры предельного размера файла и количества полей. Он сработает со своим сообщением об ошибке, которое никак не связано ни с nginx, ни с Next.js. Если вы подключили такой парсер, его лимиты нужно читать в его собственной документации, а не искать аналоги в настройках фреймворка.

Хороший способ не запутаться — вести маленькую таблицу лимитов именно для вашей загрузки. Для каждого звена запишите, какой параметр ограничивает размер, где он задан и чему равен. Как правило, значения должны убывать по мере продвижения вглубь: внешний слой пропускает не меньше, чем внутренний, и тогда отказ приходит из того места, где его проще всего объяснить пользователю. Если же внешний слой строже внутреннего, внутренний лимит просто не работает, и вы можете десять раз повысить его без эффекта.

Отдельный случай — несколько запущенных экземпляров Next.js за nginx. Если загрузка идёт через Server Action, то Next.js шифрует замкнутые переменные серверных функций, и по умолчанию ключ создаётся заново для каждой сборки. Экземплярам, обслуживающим один сайт, нужен одинаковый ключ, иначе возникают ошибки Failed to find Server Action. Ключ задаётся переменной NEXT_SERVER_ACTIONS_ENCRYPTION_KEY на этапе сборки и встраивается в результат сборки (руководство Next.js по self-hosting). Для длинной загрузки это значит следующее: если во время передачи файла вы выкатываете новую версию, а запрос пришёл на экземпляр с другой сборкой, он может завершиться ошибкой, не имеющей отношения к размеру. Защиту от рассогласования версий при поэтапной выкатке даёт deploymentId, но общий кеш и согласование его инвалидации — отдельные задачи, которые этот параметр сам не решает (там же). Поэтому крупные загрузки разумно не пересекать с выкаткой: так вы исключаете целый класс загадочных обрывов.

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

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

АРЕНДОВАТЬ СЕРВЕР

Поток или ведро: что происходит с памятью

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

Первый — удержание в памяти. Обработчик вызывает метод, который собирает всё тело запроса целиком: файл становится большим объектом в оперативной памяти процесса, и только после этого его записывают на диск. Для файла в десять мегабайт это незаметно. Для файла в два гигабайта это означает, что процессу нужен как минимум такой объём памяти, причём на каждую одновременную загрузку. Три параллельных загрузки на сервере с 2 ГБ оперативной памяти (как у тарифа Ba) закончатся принудительным завершением процесса системой. Снаружи это выглядит как обрыв соединения без внятной ошибки, а в журнале ядра останется запись о нехватке памяти.

Второй — потоковая обработка. Тело читается кусками, и каждый кусок сразу пишется в файл. В памяти одновременно находится только небольшой фрагмент, а размер файла определяется диском, а не оперативной памятью. Объём памяти при этом почти не зависит от размера файла, и это главное свойство, которое делает большие загрузки управляемыми.

Вот набросок обработчика маршрута, который идёт вторым путём. Это иллюстрация логики, а не готовое решение: имена каталогов и политику ошибок придётся приспособить к вашему проекту.

import { createWriteStream } from 'node:fs';
import { rename, unlink } from 'node:fs/promises';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { randomUUID } from 'node:crypto';

export const runtime = 'nodejs';

const MAX_BYTES = 2 * 1024 * 1024 * 1024;
const UPLOAD_DIR = '/srv/uploads';

export async function PUT(request) {
  const id = randomUUID();
  const partPath = UPLOAD_DIR + '/' + id + '.part';
  const finalPath = UPLOAD_DIR + '/' + id + '.bin';
  const expected = Number(request.headers.get('x-upload-size'));
  let received = 0;

  const counter = new Transform({
    transform(chunk, _enc, cb) {
      received += chunk.length;
      if (received > MAX_BYTES) return cb(new Error('too_large'));
      cb(null, chunk);
    },
  });

  try {
    await pipeline(Readable.fromWeb(request.body), counter, createWriteStream(partPath));
    if (!expected || received !== expected) throw new Error('size_mismatch');
    await rename(partPath, finalPath);
    return Response.json({ id, size: received }, { status: 201 });
  } catch (e) {
    await unlink(partPath).catch(() => {});
    return Response.json({ error: String(e.message) }, { status: 400 });
  }
}

Обратите внимание на три решения в этом наброске. Во-первых, запись идёт в файл с суффиксом .part, а не сразу под итоговым именем: никто не примет недописанный файл за готовый. Во-вторых, размер считается на лету и сверяется с собственным лимитом, то есть защита не зависит от настроек nginx. В-третьих, итоговое имя появляется только после сравнения принятого размера с заявленным, и переименование внутри одного раздела происходит как одна операция.

Теперь про путаницу, которая встречается часто. Директива proxy_buffering управляет буферизацией ответов апстрима: при её отключении данные уходят клиенту по мере получения, а заголовок X-Accel-Buffering может управлять буферизацией, если конфигурация этого не запрещает (документация модуля proxy). Это про направление от сервера к клиенту. На путь файла от браузера к приложению эта директива не влияет, и отключать её в расчёте на «потоковую загрузку» бессмысленно. Она нужна, когда вы отдаёте большие файлы наружу или стримите ответ. Кроме того, отключение буферизации в nginx не гарантирует, что приложение само ничего не накапливает, и не отменяет проверку размера. Потоковость обеспечивает код обработчика, а не одна строка в конфигурации прокси.

Как проверить, что обработчик действительно потоковый? Не гадайте по коду, а измеряйте. Запустите загрузку большого файла и наблюдайте за потреблением памяти процесса Node.js. Если память растёт пропорционально отправленному объёму, вы удерживаете тело целиком. Если держится на небольшом уровне, а растёт только занятое место на диске, обработчик работает как поток. Одной такой проверки на тестовом файле достаточно, чтобы отличить два подхода.

Когда клиент ушёл на середине

Пользователь закрыл ноутбук, мобильный интернет сменился на другую сеть, вкладка завершилась. Соединение разорвано, а на диске остаётся то, что успело записаться. Если обработчик пишет напрямую в итоговый файл, этот огрызок выглядит как обычный файл с правильным именем, просто короче оригинала. Любой код, который потом проверяет «существует ли файл», получит неверный ответ.

Работать с такими хвостами нужно в трёх местах.

  • Не давать хвосту итогового имени. Запись в .part и переименование после проверки, как в наброске выше. Переименование в пределах одной файловой системы выполняется одной операцией, поэтому читатель видит либо отсутствие файла, либо целый файл. Через границу разделов переименование превращается в копирование, и это свойство теряется, так что держите оба каталога на одном разделе.
  • Убирать за собой при ошибке. Блок обработки ошибки в наброске удаляет .part. Но он сработает только если процесс жив и ошибка дошла до него. При принудительном завершении процесса или перезагрузке сервера этого не произойдёт.
  • Подметать по расписанию. Для всего, что пережило обработчик, нужна отдельная уборка. Это обычная периодическая задача, которая удаляет файлы .part старше разумного срока.

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

find /srv/uploads -name '*.part' -mmin +360 -print
# после проверки списка:
find /srv/uploads -name '*.part' -mmin +360 -delete

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

Не забывайте и про временные файлы nginx. Каталог, заданный через client_body_temp_path, nginx обслуживает сам, но при пользовательской настройке стоит убедиться, что он расположен там, где есть место, и что у рабочего процесса есть права записи. Следите за ним теми же командами, что и за каталогом загрузок: du -sh по каталогу и df -h по разделу. Если место уходит, а в каталоге приложения ничего не прибавляется, ищите причину во временных файлах прокси.

И последнее по диску: проверяйте не только байты, но и индексные дескрипторы. Команда df -i покажет, не кончились ли они, и при множестве мелких файлов такая проверка бывает неожиданно полезной. Крупные файлы с нею не конфликтуют, но если ваше приложение раздробляет загрузку на мелкие части, картина другая.

Ответ сервера и завершённая загрузка — не одно и то же

Теперь про обратную сторону индикатора. Сто процентов в интерфейсе означают лишь то, что браузер отправил последний байт. Это не означает, что сервер получил все байты, записал их и проверил. Между «отправлено» и «принято» лежат nginx, приложение, диск и сеть.

Особенно это важно, когда загрузка разбита на несколько запросов. Тогда у каждого запроса свой успешный ответ, и ответ на первый запрос говорит только о том, что сервер согласился начать. Считать такой ответ подтверждением всей загрузки нельзя. Правильнее ввести явное состояние: загрузка существует, но не завершена, пока клиент не вызвал завершающий шаг, а сервер не сверил итог.

Минимальный набор сверок для завершающего шага:

  1. Размер. Количество записанных байт совпадает с заявленным клиентом. В наброске выше это сравнение стоит перед переименованием.
  2. Контрольная сумма. Клиент заранее вычисляет сумму файла, сервер считает сумму сохранённого и сравнивает. Размер может совпасть при повреждённом содержимом, сумма такие случаи ловит.
  3. Состояние записи. Файл переименован из .part в итоговое имя только после успешных сверок, и только тогда запись о нём становится видимой остальной системе.

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

Вернёмся к тайм-ауту ожидания ответа. Если после получения файла приложение долго что-то считает, а proxy_read_timeout меньше этого времени, клиент увидит ошибку при целом файле. Есть два способа жить с этим. Можно увеличить время ожидания для маршрута загрузки с запасом. Или, что надёжнее, разделить работу: принять файл, быстро ответить, а тяжёлую обработку выполнять отдельно, предоставив клиенту проверять состояние. Второй способ делает поведение предсказуемым и не заставляет держать соединение открытым ради длинной операции.

Собираем конфигурацию, не ломая остальное сайта

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

location /api/upload {
    client_max_body_size 2g;
    client_body_timeout 120s;
    client_body_temp_path /srv/nginx-upload-tmp;
    proxy_read_timeout 300s;
    proxy_pass http://127.0.0.1:3000;
}

Смысл каждой строки теперь понятен из предыдущих разделов. Первая задаёт потолок размера для одного маршрута, остальной сайт сохраняет строгие умолчания. Вторая определяет допустимую паузу между порциями, а не общее время. Третья выносит временные файлы туда, где вы выделили для них место. Четвёртая даёт приложению запас на ответ после получения файла. Каталог из третьей строки должен существовать и быть доступен на запись рабочему процессу nginx, иначе загрузки начнут падать по новой причине. После правки проверяйте конфигурацию командой nginx -t и перезагружайте её без остановки сервиса.

Если Next.js и nginx размещены на одном VPS, вам не нужно делить ресурсы с чужими заданиями, и вы можете настроить расположение каталогов сами благодаря root-доступу. Подробнее о том, как развернуть само приложение на сервере, вы найдёте в материале про VPS для Next.js. Выбирая размер сервера под загрузки, считайте не по названию тарифа, а по вашему расчёту: сколько файлов одновременно, какой наибольший размер, сколько копий живёт в пике и сколько места остаётся другим данным. Тарифы с разным объёмом NVMe и оперативной памяти нужны именно затем, чтобы исходные цифры расчёта совпали с реальностью, но сами они не гарантируют пропускную способность и число пользователей: это проверяется замером на вашей нагрузке.

Порядок диагностики, когда файл уже пропал

Если загрузка ломается прямо сейчас, не меняйте настройки наугад. Пройдите слои по порядку, от внешнего к внутреннему, и на каждом задайте один вопрос.

  1. Какой статус увидел клиент? Откройте вкладку сети в инструментах разработчика. 413 указывает на лимит размера, 408 на паузу при передаче. Отсутствие статуса и обрыв соединения отправляют вас в журналы сервера.
  2. Что записал nginx? Посмотрите журнал ошибок и журнал доступа по времени попытки. Там видно, дошёл ли запрос до апстрима и как закончился.
  3. Есть ли место? Выполните df -h и df -i, затем du -sh для каталога временных файлов nginx и каталога загрузок.
  4. Что случилось с процессом? Если память росла, а процесс перезапустился, причина в удержании тела в памяти. Сверьте это с наблюдением за потреблением во время тестовой загрузки.
  5. Какой именно обработчик принимает файл? Определите, идёт ли запрос через Server Action, Route Handler или сторонний парсер, и читайте лимиты именно этого механизма.
  6. Не было ли выкатки? Если во время загрузки менялась сборка или вы запускаете несколько экземпляров, проверьте ключ шифрования Server Actions и согласованность версий.

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

Файл, который можно открыть

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

Практически это выглядит так. Загрузите контрольный файл заведомо большого размера, из тех, что реально встречаются у ваших пользователей, и заранее посчитайте его сумму на своей машине. Затем на сервере сравните размер командой ls -l и сумму командой sha256sum. Совпали оба значения — значит, цепочка от браузера до диска сохранила содержимое. После этого откройте файл так, как открывал бы его пользователь: распакуйте архив, воспроизведите видео, откройте документ. Если у вас есть собственная проверка формата, пусть она выполняется на серверной копии, а не на оригинале.

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

Итог проверки не измеряется процентами на экране. Он выглядит как открытый файл, сумма которого совпала с оригиналом. Начните именно с этого и закончите этим же.