Загрузка больших файлов в Go: рабочие приёмы из практики
Что выбрать для приёма файлов на 2-10 ГБ в Go-сервисе: multipart, стриминг в io.Copy, прямой upload в S3. Разбор четырёх подходов с ценой каждого и типичными ошибками.

Сервис на Go принимает загрузку видео и архивов. Пока файлы были по паре мегабайт, работал стандартный r.ParseMultipartForm — и всё было нормально. Как только пользователи начали заливать файлы по 2-8 ГБ, под нагрузкой в 20-30 параллельных загрузок RSS процесса вырос до нескольких гигабайт, а потом OOM killer убил контейнер. Причина не в утечке, а в том, что очевидное решение читает весь запрос в память (или во временные файлы) до того, как вы напишете хоть строку логики.
Разберём четыре подхода к приёму больших файлов в Go 1.22+ и цену каждого: сколько памяти ест, где ломается, когда его выбирать. Про раздачу больших файлов клиенту (http.ServeContent, Range-запросы) здесь не говорим — только приём на сервер.
Почему ParseMultipartForm течёт по памяти
Метод r.ParseMultipartForm(maxMemory) парсит всё тело запроса сразу. Аргумент maxMemory — это порог, до которого части хранятся в оперативке; всё сверх него уходит во временные файлы в os.TempDir(). Звучит безопасно, но есть нюансы.
- Даже с маленьким
maxMemoryфайл целиком материализуется на диске во временной директории до вашего кода. При 30 параллельных загрузках по 5 ГБ это 150 ГБ временных файлов. - Метод читает всё тело до возврата управления. Вы не можете начать обработку первого байта, пока не пришёл последний.
- Временные файлы удаляются не мгновенно — они привязаны к жизни
Requestи подчищаются, когда сборщик доберётся доmultipart.Form.
Для загрузки в килобайты и мегабайты это норма. Для гигабайтов — источник и памяти, и дискового давления.
Подход 1. Стриминг через multipart.Reader
Вместо парсинга всего тела берите multipart.Reader и читайте части по одной как поток. Тело не буферизуется целиком — вы получаете io.Reader на каждую часть и сами решаете, куда его лить.
// Go 1.22, приём одного файла стримом без временных файлов
func uploadHandler(w http.ResponseWriter, r *http.Request) {
mr, err := r.MultipartReader()
if err != nil {
http.Error(w, "expected multipart", http.StatusBadRequest)
return
}
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// интересует только поле файла
if part.FormName() != "file" {
part.Close()
continue
}
dst, err := os.Create("/data/" + filepath.Base(part.FileName()))
if err != nil {
http.Error(w, "cannot create file", http.StatusInternalServerError)
return
}
// io.Copy льёт поток чанками, не держит файл в памяти
written, err := io.Copy(dst, part)
dst.Close()
part.Close()
if err != nil {
http.Error(w, "write failed", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "saved %d bytes\n", written)
}
}Ключевой момент: r.MultipartReader() и r.ParseMultipartForm() взаимоисключающи. Как только вы вызвали любой метод, который дёргает ParseMultipartForm под капотом (включая r.FormValue и r.FormFile), потоковое чтение уже недоступно — тело прочитано. Поэтому в стриминговом хендлере забудьте про r.FormValue.
Правило простое: либо вы читаете тело потоком через MultipartReader и сами разбираете все части, либо отдаёте всё на откуп ParseMultipartForm. Смешать нельзя — тело у запроса одно, и его читают один раз.
Буфер io.Copy и io.CopyBuffer
io.Copy по умолчанию использует внутренний буфер 32 КБ. Для сетевого чтения с последующей записью на быстрый диск это часто узкое место — слишком много системных вызовов. Дайте буфер побольше через io.CopyBuffer:
buf := make([]byte, 1<<20) // 1 МБ
written, err := io.CopyBuffer(dst, part, buf)Замерять эффект надо на своём железе: выигрыш зависит от скорости диска и сети. На SSD и гигабитной сети переход с 32 КБ на 1 МБ буфер обычно заметно снижает нагрузку на CPU за счёт меньшего числа read/write. На медленном сетевом хранилище разница может быть в пределах шума — сначала мерьте time и число сисколлов через strace -c, потом крутите буфер.
Подход 2. Ограничение размера через MaxBytesReader
Стриминг не защищает от того, что клиент зальёт вам файл на 500 ГБ и забьёт диск. Оберните тело в http.MaxBytesReader — он вернёт ошибку, как только чтение превысит лимит, и не даст читать дальше.
const maxUpload = 10 << 30 // 10 ГБ
r.Body = http.MaxBytesReader(w, r.Body, maxUpload)Важно, что MaxBytesReader ограничивает всё тело запроса целиком, а не отдельную часть multipart. Если вам нужен лимит на конкретный файл, а не на весь запрос, считайте байты сами при копировании через io.LimitReader(part, maxFileSize) и проверяйте, не оборвалось ли чтение раньше конца.
Подход 3. Прямая загрузка в объектное хранилище (presigned URL)
Самый разгружающий вариант — вообще не пропускать файл через ваш сервис. Клиент запрашивает у бэкенда presigned URL, а дальше льёт файл напрямую в S3-совместимое хранилище. Ваш процесс не тратит ни памяти, ни трафика на тело.
- Бэкенд генерирует presigned PUT-URL (в AWS SDK for Go v2 это
s3.PresignClient.PresignPutObject). - Отдаёт URL клиенту.
- Клиент делает
PUTнапрямую в хранилище. - Хранилище шлёт событие (S3 event / webhook), бэкенд узнаёт, что файл готов.
Цена: усложняется схема — появляется асинхронность и необходимость обрабатывать событие о завершении. Валидацию содержимого (тип, размер, антивирус) придётся делать пост-фактум или через lifecycle-политику. Точные имена методов SDK сверяйте с актуальной документацией aws-sdk-go-v2 — API пресайна менялся между версиями SDK.
Подход 4. Chunked / resumable upload (TUS)
Если загрузки рвутся на мобильных сетях, спасает возобновляемая загрузка: файл бьётся на части, каждая догружается независимо, при обрыве продолжаем с последнего успешного чанка. Стандарт де-факто — протокол tus, для Go есть референсная серверная реализация tusd. Своя реализация возможна, но вы будете переизобретать учёт офсетов, дедупликацию и сборку — берите готовое, если требование именно в устойчивости к обрывам.
Сравнение подходов
| Подход | Память на загрузку | Диск сервиса | Устойчивость к обрыву | Сложность |
|---|---|---|---|---|
| ParseMultipartForm | до maxMemory + весь файл во temp | весь файл | нет | минимальная |
| MultipartReader стрим | размер буфера (КБ-МБ) | весь файл | нет | низкая |
| Presigned URL в S3 | около нуля | не используется | частичная (multipart S3) | средняя |
| Resumable (tus) | размер чанка | чанки до сборки | полная | высокая |
Когда что выбирать
- Файлы стабильно до сотен МБ, внутренний сервис — стриминг через
MultipartReaderсMaxBytesReader. Простой, предсказуемый по памяти. - Гигабайты, публичный сервис, есть S3 — presigned URL. Снимает нагрузку с вашего процесса полностью.
- Мобильные клиенты, плохие сети — resumable через tus. Обрыв на 90% не заставит начинать заново.
- Маленькие файлы, прототип —
ParseMultipartFormвсё ещё уместен, не усложняйте.
Что ломается на практике
Три ошибки, которые вы увидите почти гарантированно, если недосмотрите.
- Забыли
part.Close()иdst.Close()в цикле — при потоке из сотен частей копятся открытые дескрипторы, ловитеtoo many open files. Закрывайте в теле цикла, не черезdefer, иначе они накопятся до конца функции. - Таймаут сервера рвёт долгую загрузку. Если у
http.ServerвыставленReadTimeoutв 30 секунд, файл на 5 ГБ по медленному каналу не успеет дойти, и вы увидите оборванное соединение. Для загрузочных эндпоинтов используйтеReadHeaderTimeoutвместо жёсткогоReadTimeout, а тайминг тела контролируйте отдельно. - Reverse proxy буферизует тело. Nginx по умолчанию с
client_max_body_size 1mотдаст413 Request Entity Too Largeраньше, чем запрос дойдёт до Go. Проверяйте лимит на прокси, а не только в приложении.
Что настроить после
Начните с стриминга через MultipartReader плюс MaxBytesReader — это закрывает 80% случаев и убирает OOM. Дальше по нагрузке:
- вынесите таймауты тела в отдельную обработку, не полагайтесь на глобальный
ReadTimeout; - если файлы растут — сразу проектируйте под presigned URL, чтобы не гонять трафик через сервис;
- мерьте не только память, но и число открытых дескрипторов (
lsof -p PID | wc -l) под пиковой параллельной загрузкой; - на проде смотрите RSS процесса и рост временной директории — если растут вместе, значит где-то остался
ParseMultipartForm.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Пакет mime/multipart — документация Go, http.MaxBytesReader — документация net/http
Частые вопросы
Можно ли вызвать r.FormValue вместе с MultipartReader?
А если у меня старая версия Go, до 1.22 — код сработает?
Почему при 30 параллельных загрузках падает по OOM, если я использую стриминг?
Как ограничить размер именно файла, а не всего запроса?
Нужно ли самому писать resumable-загрузку или брать готовое?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.