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

Почему привычный http.Request.FormFile — только начало
Стандартный подход «вызвать r.FormFile, сохранить на диск» покрывает хеппи-пас и ничего больше. Первая же реальная нагрузка приносит проблемы: клиент отправляет трёхгигабайтный файл и забивает память, расширение .jpg скрывает исполняемый ELF-бинарник, два одновременных запроса пишут файл с одним именем. Каждая из этих ситуаций — потенциальный инцидент в проде.
Ниже — чек-лист из десяти пунктов, каждый с конкретным приёмом на Go 1.22+. Если вы используете стандартную библиотеку net/http без фреймворков, всё применимо напрямую. Для Echo, Gin, Chi механика та же, обёртки отличаются.
1. Ограничьте размер тела запроса до парсинга
http.MaxBytesReader оборачивает r.Body и обрывает чтение, когда лимит превышен. Это единственный надёжный способ не дать клиенту выжрать всю память до того, как вы начнёте разбирать multipart. Ключевой момент: вызывайте его до r.ParseMultipartForm.
// Go 1.22+
const maxUploadSize = 10 << 20 // 10 МБ
func uploadHandler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
// maxMemory — сколько держать в RAM до сброса во временный файл
if err := r.ParseMultipartForm(1 << 20); err != nil {
http.Error(w, "Файл слишком большой или повреждён", http.StatusRequestEntityTooLarge)
return
}
// дальше работаем с r.MultipartForm
}
Проверка: отправьте файл на мегабайт больше лимита — должны получить 413.
2. Валидируйте MIME-тип по содержимому, а не по расширению
Расширение — это строка, которую выбирает отправитель. Доверять ей нельзя. Функция http.DetectContentType читает первые 512 байт и определяет MIME по сигнатуре файла (magic bytes).
file, header, err := r.FormFile("document")
if err != nil {
http.Error(w, "Не удалось прочитать файл", http.StatusBadRequest)
return
}
defer file.Close()
// Читаем начало для определения типа
buf := make([]byte, 512)
n, err := file.Read(buf)
if err != nil {
http.Error(w, "Ошибка чтения", http.StatusInternalServerError)
return
}
contentType := http.DetectContentType(buf[:n])
allowed := map[string]bool{
"image/jpeg": true,
"image/png": true,
"application/pdf": true,
}
if !allowed[contentType] {
http.Error(w, "Недопустимый тип файла", http.StatusUnsupportedMediaType)
return
}
// Перематываем обратно, чтобы потом записать полностью
if _, err := file.Seek(0, io.SeekStart); err != nil {
http.Error(w, "Ошибка перемотки", http.StatusInternalServerError)
return
}
DetectContentType решает 80 % случаев, но не все. Для ZIP-архивов и офисных форматов (DOCX — это ZIP) сигнатуры совпадают. Если вам критично отличать DOCX от произвольного ZIP, придётся парсить содержимое архива.
3. Генерируйте имя файла на сервере
Имя, которое прислал клиент (header.Filename), может содержать ../, нулевые байты, юникодные трюки. Не используйте его как часть пути на диске. Генерируйте своё.
import "github.com/google/uuid" // v1.6+
// Безопасное имя: UUID + расширение на основе MIME
extByMIME := map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"application/pdf": ".pdf",
}
ext, ok := extByMIME[contentType]
if !ok {
ext = ".bin"
}
safeName := uuid.New().String() + ext
Оригинальное имя, если нужно, храните в базе — но никогда не подставляйте в путь файловой системы без санитизации.
4. Пишите на диск потоково, не копируйте весь файл в память
io.Copy читает из multipart.File и пишет в os.File чанками. Для файлов в сотни мегабайт это разница между стабильным сервисом и OOM-kill.
dstPath := filepath.Join("/var/uploads", safeName)
dst, err := os.Create(dstPath)
if err != nil {
http.Error(w, "Ошибка создания файла", http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
os.Remove(dstPath) // убираем мусор
http.Error(w, "Ошибка записи", http.StatusInternalServerError)
return
}
5. Выставьте таймаут и на чтение, и на запись
Медленный клиент, отправляющий байт в секунду, держит горутину и файловый дескриптор. Таймауты на уровне http.Server не дают этому растянуться до бесконечности.
srv := &http.Server{
Addr: ":8080",
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second, // для загрузки крупных файлов увеличивайте
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
Подбирайте ReadTimeout под реальный верхний размер файла и минимальную допустимую скорость канала. 10 МБ при 1 Мбит/с — около 80 секунд.
6. Не отдавайте загруженные файлы из директории приложения
Каталог загрузок не должен находиться внутри корня веб-сервера и тем более внутри каталога с кодом. Идеальный вариант — отдельный бакет в S3-совместимом хранилище. Если остаётесь на диске:
- каталог вне GOPATH и вне webroot;
- права
0640на файлы,0750на директорию; - при отдаче — через отдельный хендлер, проверяющий авторизацию, а не через
http.FileServerнапрямую.
7. Логируйте метаданные, а не содержимое
Для каждого аплоада полезно писать в лог: оригинальное имя, размер, определённый MIME, сгенерированное имя, ID пользователя, результат (успех или код ошибки). Содержимое файла в лог не пишите — это очевидно, но бывает.
Сводный чек-лист
| Пункт | Что проверить | Цена пропуска |
|---|---|---|
| Лимит размера | MaxBytesReader до ParseMultipartForm | OOM, диск забит |
| MIME по содержимому | DetectContentType, не расширение | Загрузка исполняемых файлов |
| Безопасное имя | UUID, без пользовательского ввода в пути | Path traversal |
| Потоковая запись | io.Copy, не ioutil.ReadAll | Взрыв потребления RAM |
| Таймауты | ReadTimeout, WriteTimeout на сервере | Slowloris, зависшие горутины |
| Изоляция каталога | Отдельно от кода и webroot | Доступ к чужим файлам, RCE |
| Логирование | Метаданные каждого аплоада | Слепая зона при инцидентах |
| Очистка временных файлов | r.MultipartForm.RemoveAll() после обработки | Утечка места в /tmp |
| Антивирусная проверка | ClamAV или аналог для пользовательского контента | Распространение малвари через ваш сервис |
| Rate limiting | По IP или по токену — на endpoint загрузки | Исчерпание диска/трафика |
Про временные файлы: RemoveAll
Когда ParseMultipartForm получает файл, превышающий maxMemory, он сбрасывает остаток во временный файл в os.TempDir(). Если вы не вызовете r.MultipartForm.RemoveAll(), эти файлы останутся на диске до перезагрузки или ручной чистки. В сервисе с тысячами аплоадов в день это заметно.
defer func() {
if r.MultipartForm != nil {
r.MultipartForm.RemoveAll()
}
}()
Где подход с локальным диском ломается
Горизонтальное масштабирование. Два экземпляра сервиса за балансировщиком — и файл, загруженный на первый, не виден второму. Решения: S3-совместимое хранилище (MinIO для on-premise, любой облачный S3), NFS (медленно, но просто), или проксирование отдачи через единый сервис. Это отдельная тема, здесь фиксируем: если вы знаете, что будет больше одного экземпляра — не начинайте с локального диска.
MIME (Multipurpose Internet Mail Extensions) — стандарт описания типа содержимого. В контексте HTTP используется в заголовке Content-Type и при детектировании по сигнатуре файла.
Что делать дальше
После прохождения чек-листа стоит добавить три вещи. Первое — метрики: количество аплоадов в минуту, процент отклонённых по размеру и типу, средний размер файла. Prometheus-экспортёр пишется за полчаса, а без метрик вы не узнаете о проблеме, пока не упадёт диск. Второе — интеграция с объектным хранилищем: даже если сейчас один сервер, миграция на S3 проще, когда запись в хранилище вынесена за интерфейс. Третье — нагрузочный тест: отправьте тысячу параллельных аплоадов по 10 МБ и посмотрите, что произойдёт с RSS процесса, очередью горутин и свободным местом в /tmp. Лучше увидеть это в стейджинге, чем в проде.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Go стандартная библиотека: пакет net/http — документация, Go стандартная библиотека: пакет mime/multipart — документация
Частые вопросы
Почему нельзя просто проверить расширение файла?
Что будет, если не вызвать RemoveAll для multipart-формы?
Какой maxMemory ставить в ParseMultipartForm?
Как ведут себя таймауты при загрузке большого файла по медленному каналу?
А если у меня не стандартный net/http, а Gin или Echo?
Нужна ли антивирусная проверка загруженных файлов?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.