Прямой ответ
Загрузка файла проходит как запрос multipart/form-data, в котором тело разбито на части. Главная инженерная задача — не прочитать файл целиком в память, а обработать его потоково, и согласовать лимиты на всех уровнях пути запроса.
Типовая ошибка выглядит так: файл 200 МБ загружается на сервер, который держит его в оперативной памяти, и при пяти одновременных загрузках контейнер исчерпывает лимит памяти и перезапускается. Причина не в объёме диска, а в способе чтения.
Правильный подход — стриминг:
# FastAPI / Starlette: файл приходит как SpooledTemporaryFile
from fastapi import FastAPI, UploadFile, HTTPException
app = FastAPI()
MAX_SIZE = 50 * 1024 * 1024 # 50 МБ
CHUNK = 1024 * 1024 # 1 МБ
@app.post("/upload")
async def upload(file: UploadFile):
total = 0
with open(f"/data/inbox/{uuid4().hex}", "wb") as out:
while chunk := await file.read(CHUNK):
total += len(chunk)
if total > MAX_SIZE:
raise HTTPException(status_code=413, detail="Файл больше 50 МБ")
out.write(chunk)
return {"size": total}
Чтение порциями даёт три преимущества: память не зависит от размера файла, превышение лимита обнаруживается в процессе, а не после полной загрузки, и соединение можно прервать сразу.
Лимиты задаются на нескольких уровнях, и запрос проходит через все:
| Уровень | Параметр | Поведение при превышении |
|---|---|---|
| nginx / ingress | client_max_body_size |
413 до передачи тела приложению |
| Обратный прокси | client_body_buffer_size |
Буферизация во временный файл |
| Приложение | Собственная проверка | 413 с понятным сообщением |
| Хранилище | Квота бакета | Ошибка записи |
По умолчанию client_max_body_size в nginx равен 1 МБ, поэтому загрузка файла большего размера отклоняется на уровне прокси, и приложение об этом не узнаёт. Настройка:
location /upload {
client_max_body_size 50m;
client_body_timeout 300s;
proxy_request_buffering off; # стриминг без буферизации
proxy_read_timeout 300s;
}
Флаг proxy_request_buffering off принципиален: без него nginx сначала принимает всё тело на диск и только затем передаёт приложению, что удваивает задержку и расход места. Детали настройки прокси — в статье про nginx.
Практика: надёжный приём файлов
1. Никогда не доверяйте имени файла из запроса. Поле filename задаётся клиентом и может содержать ../ или управляющие символы. Имя генерируйте на сервере, а исходное сохраняйте как метаданные.
2. Проверяйте размер до записи, а не после. Заголовок Content-Length можно подделать или не отправить при chunked-передаче, поэтому реальный подсчёт байтов в цикле обязателен.
3. Различайте 413 и таймаут. 413 означает превышение размера; разрыв соединения во время передачи чаще связан с таймаутом прокси. Сообщения об ошибке должны отличаться — см. статью про обработку ошибок.
4. Учитывайте лимиты контейнера. Процесс, читающий файл в память, потребляет её сверх базового уровня; лимит памяти пода должен это учитывать, иначе загрузка приводит к OOMKill — см. статью про requests и limits.
5. Пишите во временный файл и переименовывайте. Запись сразу в целевой путь оставляет неполный файл при обрыве соединения. Переименование в пределах одной файловой системы атомарно.
6. Логируйте факт загрузки без содержимого. В логе должны быть размер, контрольная сумма и идентификатор, но не данные файла.
7. Ограничьте параллелизм. Десять одновременных загрузок по 50 МБ — это 500 МБ входящего трафика и соответствующая нагрузка на диск.
Рекомендации:
- Вынесите приём больших файлов из приложения. Presigned URL позволяет клиенту писать напрямую в объектное хранилище, минуя сервер приложения.
- Возвращайте идентификатор, а не путь. Клиенту нужен токен загруженного файла, а не расположение в файловой системе.
- Обрабатывайте повторную загрузку. Сеть рвётся, и клиент повторит запрос; идемпотентность по контрольной сумме предотвращает дубли.
- Не храните файлы на локальном диске пода. Он исчезает при пересоздании пода; нужно внешнее хранилище.
- Проверяйте тип содержимого по данным, а не по расширению. Расширение задаёт клиент и оно ничего не гарантирует.
Практический сценарий: intermittent 502 при загрузке отчётов
Контекст: пользователи загружали CSV-отчёты объёмом до 80 МБ. Примерно каждая пятая загрузка завершалась ошибкой 502, остальные проходили успешно.
- Диагностика. В логах приложения записей о неудачных загрузках не было — значит, запрос не доходил до обработчика. В логах nginx нашлась строка
client intended to send too large body. - Проверка конфигурации.
client_max_body_sizeбыл установлен в 100m для основногоlocation /, но путь/api/uploadпопадал в другой блок, где действовало значение по умолчанию 1m. - Уточнение. Ошибка 502 вместо ожидаемой 413 возникала из-за того, что прокси закрывал соединение до отправки ответа, а клиент интерпретировал это как сбой шлюза.
- Действия. (а) Вынесли настройку размера на уровень
server, чтобы она действовала для всех путей; (б) включилиproxy_request_buffering off, чтобы тело не дублировалось на диске прокси; (в) увеличилиclient_body_timeoutдо 300 с для медленных каналов; (г) добавили в приложении подсчёт байтов с возвратом 413 и понятным сообщением; (д) добавили в мониторинг счётчик ответов 413 и 502 по пути загрузки. - Результат. Ошибки 502 прекратились; превышение размера теперь возвращается как 413 с указанием лимита, а метрика позволяет увидеть всплеск неудачных загрузок.
Вывод: лимит размера файла задаётся в нескольких местах, и запрос ограничивается самым строгим из них. Ошибка загрузки почти всегда объясняется расхождением настроек прокси и приложения, а не кодом обработчика.
FAQ
Почему nginx возвращает 413, хотя приложение разрешает больший размер? Прокси проверяет размер раньше приложения. По умолчанию лимит nginx составляет 1 МБ, поэтому его нужно поднимать явно и согласовывать с приложением.
Чем стриминг отличается от буферизации на диск? При буферизации прокси сначала сохраняет всё тело локально, затем передаёт его приложению. Стриминг передаёт данные по мере поступления, что снижает задержку и расход диска, но требует обработки ошибок в процессе.
Как ограничить размер, если клиент не отправляет Content-Length? Считать байты в цикле чтения и прерывать обработку при превышении. Заголовок может отсутствовать при chunked-передаче, поэтому полагаться на него нельзя.
Что делать с файлом, если обработка после загрузки завершилась ошибкой? Удалять временный файл в блоке очистки и вести учёт незавершённых загрузок для периодической очистки. Иначе хранилище постепенно заполняется осиротевшими файлами.
Источники: MDN (413), RFC 7578 (multipart/form-data), nginx docs — ссылки в шапке.