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

Почему ParseMultipartForm — не единственный вариант
Стандартный пакет net/http в Go предлагает удобный метод r.ParseMultipartForm(maxMemory). Он парсит тело запроса, складывает файлы до maxMemory байт в оперативку, а остальное сбрасывает во временные файлы на диск. Для внутреннего сервиса с картинками до 5 МБ этого хватает. Но стоит дать пользователям загружать видео или архивы на сотни мегабайт — и начинаются проблемы: временные файлы в /tmp не чистятся, память растёт в пиках, а при десятках параллельных загрузок сервис упирается в лимит файловых дескрипторов.
Ниже — четыре подхода к приёму файлов. Каждый решает конкретную задачу и за что-то платит.
Подход 1. ParseMultipartForm — быстрый старт
Классический путь из документации Go (версия 1.22+). Вызываете ParseMultipartForm, получаете multipart.File, копируете в нужное место.
// Go 1.22+
func uploadHandler(w http.ResponseWriter, r *http.Request) {
// Ограничиваем тело запроса — без этого клиент может отправить сколько угодно
r.Body = http.MaxBytesReader(w, r.Body, 50<<20) // 50 МБ
// maxMemory: сколько байт хранить в RAM, остальное — во временный файл
if err := r.ParseMultipartForm(10 << 20); err != nil {
http.Error(w, "Файл слишком большой или повреждён", http.StatusBadRequest)
return
}
defer r.MultipartForm.RemoveAll() // чистим временные файлы
file, header, err := r.FormFile("document")
if err != nil {
http.Error(w, "Поле document отсутствует", 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)
}
Проверка: отправьте файл через curl -F "document=@test.pdf" http://localhost:8080/upload — ответ 201, файл на диске.
За что платите
- Временные файлы: если забыть
RemoveAll()или сервис упадёт до вызова —/tmpрастёт. - Весь файл буферизуется до того, как вы начнёте с ним работать. Для потоковой обработки (хеширование, отправка в S3) это лишний шаг.
- Имя файла из
header.Filenameконтролируется клиентом — путь к файлу нужно санитизировать, иначе../../etc/passwdпопадёт куда не надо.
Подход 2. Потоковый multipart.Reader — минимум памяти
Вместо парсинга всей формы читаете части по одной через r.MultipartReader(). Каждая часть — это io.Reader, и вы копируете данные порциями напрямую куда нужно: в файл, в S3, в хеш-функцию. Временных файлов не создаётся вовсе.
// Go 1.22+
func streamUploadHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 500<<20) // 500 МБ
mr, err := r.MultipartReader()
if err != nil {
http.Error(w, "Не multipart запрос", http.StatusBadRequest)
return
}
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
http.Error(w, "Ошибка чтения формы", http.StatusBadRequest)
return
}
if part.FormName() != "document" {
part.Close()
continue
}
// Санитизируем имя файла
safeName := filepath.Base(part.FileName())
if safeName == "." || safeName == string(os.PathSeparator) {
safeName = "unnamed"
}
dst, err := os.Create(filepath.Join("/var/uploads", safeName))
if err != nil {
part.Close()
http.Error(w, "Ошибка сервера", http.StatusInternalServerError)
return
}
// Копируем потоком — в памяти только буфер io.Copy (32 КБ по умолчанию)
if _, err := io.Copy(dst, part); err != nil {
dst.Close()
part.Close()
http.Error(w, "Ошибка записи", http.StatusInternalServerError)
return
}
dst.Close()
part.Close()
}
w.WriteHeader(http.StatusCreated)
}
Проверка: загрузите файл на 200 МБ. RSS процесса не должен вырасти больше чем на пару мегабайт.
За что платите
- Нельзя обратиться к другим полям формы (
title,description) после начала чтения — они тоже приходят как части и их нужно читать в том же цикле. - Код сложнее: вы сами управляете закрытием каждой части.
- Если клиент отправляет 50 частей, а вам нужна одна — остальные всё равно пройдут через цикл.
Подход 3. io.TeeReader — параллельные действия на лету
Часто файл нужно одновременно сохранить и посчитать контрольную сумму, или сохранить и отправить в вирусный сканер. Вместо двух проходов по данным используйте io.TeeReader: он дублирует поток — что прочитали из одного источника, автоматически записывается во второй.
// Go 1.22+
// Внутри цикла из подхода 2, после создания dst:
hasher := sha256.New()
tee := io.TeeReader(part, hasher)
if _, err := io.Copy(dst, tee); err != nil {
// обработка ошибки
}
checksum := hex.EncodeToString(hasher.Sum(nil))
// checksum готов без повторного чтения файла
Тот же приём работает для отправки в объектное хранилище: вместо хешера подставляете io.Pipe, читающую сторону которого отдаёте в горутину с вызовом S3 PutObject.
Подход 4. Приём через PUT и Content-Length — без multipart
Если фронтенд контролируете вы, multipart не обязателен. Файл приходит как тело PUT-запроса, метаданные — в заголовках. Это упрощает серверный код до предела: r.Body — уже io.Reader, никакого парсинга форм.
// Go 1.22+
func putUploadHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
http.Error(w, "PUT only", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 500<<20)
contentType := r.Header.Get("Content-Type")
if !isAllowedType(contentType) {
http.Error(w, "Тип файла не разрешён", http.StatusUnsupportedMediaType)
return
}
dst, err := os.CreateTemp("/var/uploads", "upload-*")
if err != nil {
http.Error(w, "Ошибка сервера", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, r.Body); err != nil {
http.Error(w, "Ошибка записи", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
Подходит для загрузки через JavaScript (fetch с body: file) и CLI-клиенты. Не подходит, если в одном запросе нужно передать и файл, и текстовые поля формы.
Сравнение подходов
| Критерий | ParseMultipartForm | MultipartReader | TeeReader + поток | PUT без multipart |
|---|---|---|---|---|
| Потребление RAM | До maxMemory + копия при io.Copy | ~32 КБ (буфер io.Copy) | ~32 КБ | ~32 КБ |
| Временные файлы | Да, в /tmp | Нет | Нет | Нет |
| Доступ к полям формы | Удобный, по имени | Последовательный, в цикле | Последовательный | Только через заголовки |
| Параллельная обработка (хеш, S3) | Второй проход по файлу | Возможна через TeeReader | Встроена | Возможна через TeeReader |
| Сложность кода | Низкая | Средняя | Средняя | Минимальная |
| Нужен multipart на клиенте | Да | Да | Да | Нет |
Главное правило загрузки файлов: не доверяйте ни одному байту от клиента. Ни имени файла, ни Content-Type, ни размеру. Всё проверяется на сервере, всё ограничивается на сервере.
Общие грабли — независимо от подхода
- Нет MaxBytesReader — нет лимита. Без него клиент может слать гигабайты, пока у сервера не кончится диск или память.
Content-Lengthконтролируется клиентом и может врать, поэтому ограничивайте именно чтение. - Имя файла не санитизировано.
header.Filenameможет содержать../, абсолютный путь или спецсимволы. Используйтеfilepath.Base()и дополнительно проверяйте результат. - Content-Type из заголовка — не гарантия типа. Клиент может отправить исполняемый файл с
Content-Type: image/png. Если тип критичен — проверяйте содержимое. Пакетnet/httpпредлагаетhttp.DetectContentType(), которому достаточно первых 512 байт. - Нет таймаута на чтение тела. Медленный клиент держит горутину и файловый дескриптор. Выставляйте
ReadTimeoutна уровнеhttp.Serverили используйте контекст с дедлайном. - Запись на тот же диск, откуда читает ОС. Если
/tmpи/var/uploadsна одном разделе, массовая загрузка может забить диск и уронить весь сервер, включая логи. Мониторьте свободное место и выносите загрузки на отдельный раздел или в объектное хранилище.
Сноска: multipart/form-data — формат кодирования тела HTTP-запроса, описанный в RFC 2046 и RFC 7578. Браузер использует его по умолчанию при отправке формы с enctype="multipart/form-data". Каждое поле и каждый файл передаются как отдельная «часть» (part) с собственными заголовками, разделённые уникальной строкой-разделителем (boundary).
Что делать дальше
- Если файлы уходят в S3-совместимое хранилище — изучите multipart upload API на стороне хранилища: он позволяет загружать файл частями параллельно, а не ждать полного приёма на сервере.
- Добавьте метрику
upload_bytes_total(Prometheus-счётчик) — она покажет реальную нагрузку по объёму раньше, чем вы заметите её по CPU. - Для антивирусной проверки на лету смотрите в сторону ClamAV с потоковым интерфейсом: файл можно передать через
io.TeeReader, не сохраняя на диск отдельно. - Тестируйте граничные случаи: нулевой файл, файл ровно на границе лимита, оборванное соединение на середине загрузки. В Go это удобно делать через
httptest.NewServerи кастомныйio.Reader, возвращающий ошибку после N байт.
Создатель презентаций: Структура и текст каждого слайда: заголовки-утверждения, одна мысль на слайд, заметки докладчику.
Спросить за 150 ₽Источники: Пакет net/http — официальная документация Go, Пакет mime/multipart — официальная документация Go
Частые вопросы
Чем MaxBytesReader лучше проверки Content-Length?
Как безопасно получить расширение и тип загруженного файла?
А если у меня другая версия Go — код будет работать?
Что произойдёт, если клиент оборвёт соединение на середине загрузки?
Можно ли ограничить количество одновременных загрузок, чтобы не перегрузить сервер?
Какие подводные камни в проде чаще всего пропускают?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.