Обработка загрузки файлов в Go-сервисе: сравниваем варианты
Загрузка файлов в Go кажется тривиальной — пока не приходится обрабатывать гигабайтные архивы или сотни параллельных запросов. Разбираем три подхода к приёму файлов, их стоимость по памяти и latency, и даём критерии выбора.

Почему «просто ParseMultipartForm» — не универсальный ответ
Стандартная библиотека Go (net/http, версия Go 1.22+) предлагает удобный метод ParseMultipartForm, который читает тело запроса в память и, если оно превышает заданный лимит, сбрасывает остаток во временный файл. Для аватарки в 200 КБ этого достаточно. Проблемы начинаются, когда сервис принимает видео на 2 ГБ или обрабатывает 500 одновременных загрузок изображений: память растёт скачкообразно, временные файлы копятся в /tmp, а клиент не получает никакого прогресса, пока весь файл не окажется на сервере.
Ниже — три подхода, каждый со своей ценой. Выбор зависит от размера файлов, допустимой задержки и инфраструктуры, которая стоит за Go-сервисом.
Подход 1. ParseMultipartForm — всё в памяти (с порогом на диск)
Самый короткий путь. Вызываете r.ParseMultipartForm(maxMemory), дальше достаёте файл через r.FormFile("upload") и копируете куда нужно.
// Go 1.22+
func uploadHandler(w http.ResponseWriter, r *http.Request) {
// maxMemory — сколько байт держать в RAM; остаток уйдёт во временный файл
const maxMemory = 10 << 20 // 10 МБ
if err := r.ParseMultipartForm(maxMemory); err != nil {
http.Error(w, "не удалось разобрать форму", http.StatusBadRequest)
return
}
defer r.MultipartForm.RemoveAll() // чистим временные файлы
file, header, err := r.FormFile("upload")
if err != nil {
http.Error(w, "файл не найден в запросе", http.StatusBadRequest)
return
}
defer file.Close()
dst, err := os.Create(filepath.Join("/var/uploads", header.Filename))
if err != nil {
http.Error(w, "ошибка сохранения", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, "ошибка записи", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
Что получаете
- Минимум кода, понятная семантика.
- Валидация Content-Type и имени поля «из коробки».
Чем платите
- Весь файл (или его часть до
maxMemory) находится в RAM одновременно. При 200 параллельных запросах по 10 МБ — это 2 ГБ только на буферы. - Временные файлы в
os.TempDir()могут заполнить раздел, еслиRemoveAllне вызван (паника, убитый процесс). - Клиент ждёт, пока сервер не прочитает всё тело. Для файлов больше сотен мегабайт задержка ощутима, а прогресс-бар на фронте не отражает реальность.
Подход 2. Потоковое чтение через multipart.Reader
Вместо буферизации всего тела вы берёте multipart.Reader у запроса и читаете каждую часть (part) как io.Reader — по частям, без промежуточного хранения в памяти.
// Go 1.22+
func streamUploadHandler(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, "ошибка чтения части", http.StatusBadRequest)
return
}
if part.FormName() != "upload" {
part.Close()
continue
}
dst, err := os.Create(filepath.Join("/var/uploads", part.FileName()))
if err != nil {
part.Close()
http.Error(w, "ошибка создания файла", http.StatusInternalServerError)
return
}
// Копируем потоково, буфер по умолчанию 32 КБ
written, err := io.Copy(dst, part)
dst.Close()
part.Close()
if err != nil {
http.Error(w, "ошибка записи", http.StatusInternalServerError)
return
}
// written содержит количество записанных байт
_ = written
}
w.WriteHeader(http.StatusCreated)
}
Что получаете
- Потребление памяти фиксировано: буфер
io.Copyпо умолчанию 32 КБ на запрос, независимо от размера файла. - Можно писать напрямую в S3-совместимое хранилище (через
PutObjectсio.Reader), минуя диск. - Контроль над каждой частью: легко проверить MIME-тип до того, как файл целиком окажется на диске.
Чем платите
- Больше кода и больше точек, где нужно аккуратно закрывать ресурсы.
- Нельзя получить доступ к полям формы, которые идут после файла, до окончания чтения файла — порядок частей в multipart имеет значение.
- Если клиент рвёт соединение на середине, у вас на диске остаётся полуфайл. Нужна своя логика очистки.
Подход 3. Presigned URL — сервис вообще не принимает файл
Go-сервис генерирует подписанный URL для прямой загрузки в объектное хранилище (S3, GCS, MinIO). Клиент отправляет файл туда напрямую, а сервис получает уведомление (webhook, событие очереди) или клиент сообщает о завершении.
Кода загрузки на стороне Go-сервиса нет вовсе — есть только генерация URL. Конкретный API зависит от SDK хранилища; смысл подхода — в архитектуре, а не в конкретной функции.
Что получаете
- Нулевая нагрузка на Go-сервис по CPU, памяти и диску при загрузке.
- Масштабирование загрузки — задача хранилища, а не вашего сервиса.
- Multipart upload на стороне хранилища: файлы в десятки гигабайт загружаются параллельными частями с retry на каждую.
Чем платите
- Дополнительная инфраструктура: объектное хранилище, настройка CORS, управление ключами.
- Сложнее валидация: проверить содержимое файла (не просто расширение, а magic bytes) можно только после загрузки — отдельным воркером.
- Два сетевых вызова вместо одного: получить URL, загрузить файл. Для мелких файлов (иконки, документы до 1 МБ) overhead может превышать выигрыш.
Сравнение подходов
| Критерий | ParseMultipartForm | multipart.Reader | Presigned URL |
|---|---|---|---|
| Потребление RAM на запрос | до maxMemory | ~32 КБ (буфер io.Copy) | ~0 (файл не проходит через сервис) |
| Максимальный размер файла | ограничен RAM + диском tmp | ограничен диском | ограничен хранилищем (ТБ) |
| Сложность кода | низкая | средняя | средняя + инфраструктура |
| Валидация до записи | после полного чтения | по первым байтам part | только после загрузки |
| Очистка при ошибке | RemoveAll() | ручная | TTL/lifecycle-политики хранилища |
| Подходит для файлов | до 50 МБ, низкий RPS | любых, средний RPS | любых, высокий RPS |
Потоковое чтение через multipart.Reader — это «золотая середина» для большинства Go-сервисов: вы контролируете память, не зависите от внешнего хранилища и можете валидировать файл до того, как он целиком ляжет на диск. Presigned URL выигрывает, когда файлы крупные, а параллельных загрузок много — но за это вы платите инфраструктурой и отложенной валидацией.
Когда что выбирать
Решение зависит от трёх параметров: размер файла, количество одновременных загрузок и наличие объектного хранилища.
- Файлы до 5–10 МБ, RPS невысокий (десятки в минуту), хранилища нет.
ParseMultipartForm— меньше кода, меньше ошибок. УстановитеmaxMemoryчуть больше ожидаемого размера файла, чтобы избежать сброса на диск. - Файлы от 10 МБ до нескольких ГБ, или важен контроль памяти.
multipart.Reader. Добавьтеio.LimitReaderповерх part, чтобы обрезать слишком большие загрузки на раннем этапе. - Файлы любого размера, высокий RPS, есть S3-совместимое хранилище. Presigned URL. Go-сервис не тратит ни байта на передачу — только генерирует ссылку и обрабатывает результат.
Общие ловушки — независимо от подхода
- Не доверяйте Content-Type из заголовка. Клиент может отправить
image/pngс исполняемым файлом внутри. Проверяйте magic bytes: первые 512 байт черезhttp.DetectContentTypeиз стандартной библиотеки. - Не используйте имя файла от клиента как путь.
../../etc/passwd— классика path traversal. Генерируйте имя на сервере (UUID, хеш) и храните оригинальное имя отдельно, в метаданных. - Ограничивайте размер тела запроса.
http.MaxBytesReaderоборачиваетr.Bodyи возвращает ошибку при превышении лимита. Без него клиент может отправить терабайт и занять соединение надолго. - Ставьте таймауты.
ReadTimeoutиWriteTimeoutна сервере,context.WithTimeoutна обработчике. Медленный клиент на загрузке 100 МБ по мобильной сети — не атака, но ведёт себя похоже.
Что дальше
После выбора подхода стоит решить три смежных вопроса, каждый из которых — отдельная тема:
- Антивирусная проверка. Если сервис принимает пользовательские файлы — ClamAV или аналог на стороне воркера.
- Resumable upload. Для файлов свыше сотен мегабайт протокол tus позволяет докачивать после обрыва. В Go есть реализации, совместимые со стандартным
net/http. - Метрики. Считайте: размер файлов (p50, p99), время загрузки, процент ошибок по типу (клиент оборвал, превышен лимит, ошибка диска). Без цифр вы не узнаете, что подход перестал справляться.
Presigned URL — подписанная ссылка с ограниченным сроком действия, которую генерирует сервер. Она разрешает клиенту загрузить файл напрямую в хранилище, без передачи через ваш бэкенд. Срок, допустимый размер и Content-Type задаются при генерации.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Пакет net/http — официальная документация Go, Пакет mime/multipart — официальная документация Go
Частые вопросы
Какой подход выбрать, если я не знаю заранее размер загружаемых файлов?
Можно ли использовать ParseMultipartForm и при этом не бояться за память?
Как проверить тип файла, если используется presigned URL и файл не проходит через Go-сервис?
Что будет, если при потоковом чтении клиент оборвёт соединение на середине файла?
Как ограничить размер загрузки на уровне HTTP-сервера, а не в обработчике?
Подходят ли эти подходы для Go 1.21 и ниже?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.