Загрузка больших файлов в Go: как сделать правильно сразу
Наивный обработчик multipart в Go складывает файл в память и роняет сервис на файле в пару гигабайт. Разбираем, как принимать большие загрузки потоком, ограничивать размер и не держать всё в RAM.

Обработчик загрузки, написанный «по первому туториалу», выглядит невинно: r.ParseMultipartForm, потом r.FormFile, потом копирование в файл. На тесте с картинкой в 200 КБ всё летает. В проде приходит файл на 3 ГБ, процесс упирается в лимит памяти и падает с OOM, а вместе с ним умирают все остальные запросы, которые в этот момент обрабатывал сервис. Ниже — как принимать большие файлы в Go так, чтобы память не зависела от размера загрузки, с ограничением сверху и потоковой записью на диск. Актуально для Go 1.21–1.23, стандартная библиотека net/http и mime/multipart.
Почему очевидное решение течёт
Метод r.ParseMultipartForm(maxMemory) парсит всё тело запроса. Аргумент maxMemory задаёт, сколько байт формы держать в RAM; всё, что сверху, уходит во временные файлы. Звучит безопасно, но есть две ловушки.
Первая: ParseMultipartForm читает весь запрос до конца, прежде чем вернуть управление. Клиент может слать хоть 50 ГБ — сервер честно примет их на диск во временную папку, забьёт её и только потом отдаст вам ошибку записи. Никакого раннего отказа «файл слишком большой» вы не получите бесплатно.
Вторая: значение maxMemory относится к каждому полю формы, а не ко всему запросу целиком, плюс поверх него стандартная библиотека держит дополнительные буферы. Прикинуть реальный потолок памяти на глаз не выйдет — он зависит от количества частей в запросе.
Правило простое: размер файла не должен превращаться в размер занятой памяти. Если ваш обработчик держит в RAM объём, растущий вместе с загрузкой, — это баг, а не деталь реализации.
Подход, который работает: multipart.Reader вручную
Вместо ParseMultipartForm берём r.MultipartReader() — он даёт потоковый доступ к частям запроса. Вы читаете части по одной и копируете тело каждой части напрямую в файл через io.Copy, не накапливая ничего целиком. Память при этом занята только буфером копирования (по умолчанию 32 КБ у io.Copy), независимо от того, гигабайт файл или сто.
package upload
import (
"errors"
"io"
"mime"
"net/http"
"os"
"path/filepath"
)
// maxUploadBytes — жёсткий потолок на всё тело запроса.
const maxUploadBytes = 5 << 30 // 5 ГБ
func Handler(w http.ResponseWriter, r *http.Request) {
// Ограничиваем тело запроса на уровне соединения.
// Как только клиент превысит лимит, чтение вернёт ошибку.
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
mr, err := r.MultipartReader()
if err != nil {
http.Error(w, "ожидается multipart/form-data", http.StatusBadRequest)
return
}
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break // части кончились
}
if err != nil {
http.Error(w, "не удалось прочитать часть запроса", http.StatusBadRequest)
return
}
// Интересует только поле file с именем файла.
if part.FormName() != "file" || part.FileName() == "" {
part.Close()
continue
}
// filepath.Base отрезает пути вида ../../etc — не доверяем имени клиента.
safeName := filepath.Base(part.FileName())
dst, err := os.CreateTemp("/var/uploads", "up-*-"+safeName)
if err != nil {
http.Error(w, "не удалось создать файл", http.StatusInternalServerError)
part.Close()
return
}
// io.Copy читает part потоком и пишет в файл кусками по 32 КБ.
written, err := io.Copy(dst, part)
dst.Close()
part.Close()
if err != nil {
// MaxBytesReader вернёт ошибку именно здесь, если превышен лимит.
os.Remove(dst.Name())
http.Error(w, "файл слишком большой или соединение оборвано", http.StatusRequestEntityTooLarge)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"saved":` + itoa(written) + `}`))
return
}
http.Error(w, "поле file не найдено", http.StatusBadRequest)
}
// itoa вынесен, чтобы не тянуть fmt ради одной конверсии.
func itoa(n int64) string {
return strconvFormatInt(n)
}
Функция strconvFormatInt в примере — заглушка ради краткости; в реальном коде используйте strconv.FormatInt(n, 10). Ключевое здесь — io.Copy(dst, part): он никогда не держит в памяти больше своего внутреннего буфера, сколько бы байт ни пришло.
Что делает MaxBytesReader и почему без него нельзя
http.MaxBytesReader оборачивает тело запроса и возвращает ошибку, как только суммарно прочитано больше заданного лимита. Он же аккуратно закрывает соединение, чтобы клиент не продолжал слать данные впустую. Без этой обёртки злоумышленник или кривой клиент могут заставить вас записать на диск столько, сколько влезет, — потоковая запись сама по себе от переполнения диска не спасает.1
Сравнение подходов
| Подход | Память | Ранний отказ по размеру | Когда уместен |
|---|---|---|---|
| ParseMultipartForm + FormFile | Растёт с формой, лишнее во temp-файлы | Нет, читает всё до конца | Мелкие формы: аватарки, документы до пары МБ |
| MultipartReader + io.Copy | Постоянная (~буфер копирования) | Да, через MaxBytesReader | Большие файлы, потоковая обработка |
| Прямой io.Copy из r.Body | Постоянная | Да, через MaxBytesReader | Загрузка одного файла без формы (raw body) |
Если у вас нет полей формы вообще, а клиент шлёт файл целиком в теле запроса (Content-Type: application/octet-stream), не нужен даже multipart — читайте r.Body напрямую через io.Copy, обернув в MaxBytesReader. Это самый экономный вариант, но он не даёт передавать метаданные вместе с файлом.
Когда что выбирать
- Файлы гарантированно мелкие и вам удобны
r.FormValueдля остальных полей — оставайтесь наParseMultipartFormс честным маленьким лимитом и обязательнымMaxBytesReaderсверху. - Файлы большие, но нужны и другие поля формы —
MultipartReader: он отдаёт части по порядку, включая текстовые поля (у нихFileName()пустой). - Нужно не сохранять файл, а сразу стримить его дальше — в S3-совместимое хранилище или другому сервису — тот же
MultipartReader, только вместоos.CreateTempподставляете writer вашего клиента хранилища.
Что ломается на практике
- Порядок частей.
MultipartReaderотдаёт части в том порядке, в каком их прислал клиент. Если бизнес-логике нужно полеuserIdдо файла, а клиент прислал файл первым — вы прочитаете гигабайт впустую, прежде чем узнаете, кому он принадлежит. Либо фиксируйте порядок в контракте API, либо буферизуйте небольшие текстовые поля. - Незакрытые части. Каждую
partнужно закрывать. ЗабытыйCloseв цикле с раннимcontinue— типичная утечка дескрипторов, которая всплывёт под нагрузкой, а не на тесте. - Таймауты сервера. Загрузка 5 ГБ по медленному каналу может идти минуты. Стандартный
http.Serverс выставленнымWriteTimeoutилиReadTimeoutоборвёт такое соединение на полпути. Для эндпоинтов загрузки задавайтеReadTimeoutс запасом или используйтеhttp.Serverбез общего таймаута, а контролируйте время отдельно через контекст запроса. - Мусор во временной папке. При ошибке записи файл, созданный
os.CreateTemp, нужно удалять руками —defer os.Removeсразу после создания снимает эту проблему. - Место на диске.
MaxBytesReaderограничивает один запрос. Десять параллельных загрузок по 5 ГБ — это уже 50 ГБ. Ограничивайте число одновременных загрузок семафором или на уровне обратного прокси.
Прокси перед сервисом
Если перед Go-сервисом стоит Nginx, у него свой лимит client_max_body_size (по умолчанию 1 МБ). Файл упрётся в него раньше, чем дойдёт до вашего кода, и клиент получит 413 от Nginx, а не от приложения. Согласуйте лимит на прокси с maxUploadBytes в сервисе, иначе будете ловить отказы там, где не ждёте.
Что настроить дальше
Рабочий обработчик — это половина. Дальше:
- Добавьте семафор на число одновременных загрузок и метрику занятого места на диске раздела с temp-файлами.
- Проверяйте реальный тип файла по сигнатуре (
http.DetectContentTypeпо первым 512 байтам), а не доверяйте расширению из имени. - Для загрузок, которые могут прерываться, посмотрите в сторону протокола возобновляемых загрузок tus — он решает проблему докачки, которую голый multipart не закрывает.
1. MaxBytesReader — обёртка из стандартной библиотеки net/http, ограничивающая число байт, которое можно прочитать из тела запроса, и возвращающая ошибку при превышении.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: net/http — MaxBytesReader, package documentation, mime/multipart — Reader, package documentation
Частые вопросы
А если файлы у меня маленькие, стоит ли всё равно переходить на MultipartReader?
У меня Go 1.20, код будет работать?
Файл превысил лимит, но клиент уже отправил половину — что произойдёт?
Почему не сработало: MaxBytesReader стоит, а память всё равно растёт?
Как отдавать файл сразу в S3, а не на диск?
Клиент прислал текстовые поля формы после файла — как их прочитать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.