Загрузка больших файлов в Go: три подхода и цена каждого
Пользователь заливает видео на 800 МБ, а сервис отвечает out of memory. Разбираем, чем отличаются загрузка через память, потоковую обработку и presigned URL, и когда что выбирать.

Форма загрузки принимает файл. Пока это скриншоты по 200 КБ, всё работает. Потом приходит клиент, который льёт видео на 800 МБ, и сервис на инстансе с 512 МБ памяти падает с out of memory. Причина почти всегда одна: файл целиком оказался в оперативной памяти, потому что кто-то вызвал ParseMultipartForm с большим лимитом или прочитал тело запроса в []byte.
Разберём три способа принять большой файл в Go-сервисе (актуально для Go 1.22+, стандартная библиотека net/http), у каждого своя цена по памяти, сложности и нагрузке на сам сервис. В конце — таблица и критерий выбора.
Почему очевидное решение бьёт по памяти
Типичный обработчик загрузки выглядит так:
func uploadHandler(w http.ResponseWriter, r *http.Request) {
// 32 МБ уходят в память, остальное — во временные файлы на диске
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
defer file.Close()
// ... сохранение
}Первый аргумент ParseMultipartForm — это maxMemory: сколько байт держать в RAM. Всё, что сверху, стандартная библиотека сбрасывает во временные файлы (os.TempDir()). Опасность не в этом лимите, а в соблазне поднять его до размера файла, чтобы «не возиться с диском». При десяти параллельных загрузках по 800 МБ вы получите восемь гигабайт в куче — и OOM.
Отдельная ловушка — io.ReadAll(r.Body) где-нибудь в middleware, которое читает тело целиком, чтобы залогировать или провалидировать. Один такой вызов сводит на нет любую потоковую обработку ниже по стеку.
Подход 1. Потоковая обработка через multipart.Reader
Вместо ParseMultipartForm берём r.MultipartReader() и читаем части по мере поступления, не буферизируя файл целиком. Данные текут через io.Copy прямо в место назначения.
func streamUpload(w http.ResponseWriter, r *http.Request) {
reader, err := r.MultipartReader()
if err != nil {
http.Error(w, "ожидается multipart-запрос", http.StatusBadRequest)
return
}
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if part.FormName() != "file" {
continue
}
// Открываем целевой файл и копируем потоком.
// io.Copy использует буфер 32 КБ, память не растёт с размером файла.
dst, err := os.Create("/data/uploads/" + filepath.Base(part.FileName()))
if err != nil {
http.Error(w, "не удалось создать файл", http.StatusInternalServerError)
return
}
written, err := io.Copy(dst, part)
dst.Close()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "принято %d байт\n", written)
}
}Память на одну загрузку — размер буфера io.Copy (32 КБ) плюс накладные расходы, независимо от того, 10 МБ файл или 10 ГБ. Цена: вы теряете удобство FormFile и сами следите за порядком частей и лимитами. filepath.Base здесь обязателен — иначе имя из запроса с ../ уведёт запись куда не следует.
Правило, которое экономит инстансы: размер файла не должен влиять на потребление памяти сервисом. Если влияет — где-то файл читается целиком.
Ограничение размера
Потоковая обработка не отменяет лимита сверху, иначе кто-то зальёт терабайт и забьёт диск. Оборачивайте тело в http.MaxBytesReader до чтения:
// не больше 2 ГБ на запрос; при превышении Read вернёт ошибку
r.Body = http.MaxBytesReader(w, r.Body, 2<<30)Подход 2. Прокси в объектное хранилище на лету
Если файл всё равно едет в S3-совместимое хранилище (MinIO, Yandex Object Storage, AWS S3), нет смысла складывать его на локальный диск. Читаете part и одновременно пишете в клиент хранилища. Многие SDK принимают io.Reader и сами разбивают поток на multipart-части для хранилища.
Точные имена методов зависят от версии SDK — сверяйте с документацией вашего клиента (для github.com/minio/minio-go/v7 это операция загрузки объекта, принимающая io.Reader и размер). Суть: вы не материализуете файл ни в памяти, ни на диске сервиса.
Цена подхода: сервис становится узким местом по сети — весь трафик клиент → сервис → хранилище идёт через ваш процесс. При десятках параллельных заливок это ощутимая полоса и время удержания соединений. Плюс вы не знаете размер заранее, если клиент не прислал Content-Length, а часть SDK требует размер для эффективной загрузки.
Подход 3. Presigned URL — сервис вообще не касается файла
Радикальный вариант: сервис не принимает файл сам. Клиент запрашивает у сервиса подписанную ссылку на загрузку, а потом заливает файл напрямую в хранилище, минуя ваш процесс.
- Клиент шлёт запрос: «хочу загрузить файл, имя такое-то».
- Сервис проверяет права, генерирует presigned PUT URL с TTL (например, 15 минут) и отдаёт его.
- Клиент делает
PUTнапрямую в хранилище по этому URL. - Хранилище уведомляет сервис о завершении (webhook, событие бакета) либо клиент сам подтверждает загрузку.
Трафик файла вообще не проходит через сервис — ни память, ни диск, ни полоса не тратятся. Это единственный подход, который масштабируется до тысяч параллельных больших загрузок без роста вашего парка серверов.
Цена: сложнее логика. Нужен второй раунд-трип, обработка подтверждения, валидация уже загруженного объекта (размер, тип, антивирус — постфактум, а не в потоке). CORS на бакете придётся настроить, если грузите из браузера. И вы доверяете клиенту сделать PUT корректно — часть проверок сдвигается на after-upload.
Сравнение
| Критерий | Потоком на диск | Прокси в хранилище | Presigned URL |
|---|---|---|---|
| Память сервиса | Константа (~десятки КБ) | Константа | Не задействована |
| Сетевая полоса сервиса | Входящий трафик | Входящий + исходящий | Только метаданные |
| Сложность | Низкая | Средняя | Высокая |
| Валидация до сохранения | В потоке | В потоке | Только постфактум |
| Масштаб при массовых заливках | Упирается в диск | Упирается в полосу | Практически не ограничен |
Когда что выбирать
- Потоком на диск — файлы обрабатываются локально (конвертация, распаковка), заливок немного, лишней инфраструктуры нет. Самый простой рабочий вариант для монолита.
- Прокси в хранилище — файл всё равно едет в S3, но вам нужна проверка содержимого в момент загрузки (тип, подпись, размер) и вы готовы платить полосой.
- Presigned URL — большие файлы, много клиентов, важна цена инфраструктуры. Особенно из браузера и мобильных приложений, где сервис не должен быть транзитом гигабайтов.
Что ломается на практике
- Middleware, читающее тело. Логгер или трейсер с
io.ReadAll(r.Body)буферизирует весь файл. Исключайте маршруты загрузки из такого middleware. - Таймауты сервера.
WriteTimeoutиReadTimeoutвhttp.Server, выставленные в 30 секунд, оборвут заливку 2 ГБ на медленном канале. Для загрузочных маршрутов таймауты нужны отдельные и большие, либо черезhttp.ResponseController(Go 1.20+). - Обратный прокси. Nginx с дефолтным
client_max_body_size 1mвернёт 413 раньше, чем запрос дойдёт до Go. Иproxy_request_bufferingпо умолчанию буферизирует тело — для потоковой обработки его выключают. - Диск временных файлов.
ParseMultipartFormпишет overflow вos.TempDir(). На контейнере с крошечным/tmpэто тихо забьёт диск при параллельных загрузках.
Что делать дальше
Начните с замера: включите runtime/pprof heap-профиль и залейте файл на 500 МБ — если куча вырастает пропорционально размеру, файл где-то буферизируется целиком, ищите виновника. Проверьте лимиты на всех слоях: Go (MaxBytesReader), обратный прокси (client_max_body_size), хранилище (лимит размера объекта). Если заливок станет много — двигайтесь к presigned URL, это единственный путь, который снимает нагрузку с сервиса, а не переносит её на диск или полосу.
Локализатор видео: Перевод и озвучка видео на другой язык с укладкой в исходный тайминг. Глоссарий терминов, отметки проблемных реплик.
Спросить за 250 ₽Источники: net/http — пакет стандартной библиотеки Go, mime/multipart — Reader и NextPart
Частые вопросы
Чем ParseMultipartForm отличается от MultipartReader?
У меня другая версия Go, код заработает?
Заливка обрывается на середине больших файлов, в чём дело?
Nginx возвращает 413 до того, как запрос доходит до сервиса
Как валидировать файл при presigned URL, если сервис его не видит?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.