Загрузка файлов в Go заполняет temp: где нужен MultipartForm.RemoveAll

Сервис принимает CSV через multipart/form-data. Обработчик читает файл, разбирает строки и возвращает ответ. После серии загрузок временный каталог неожиданно вырос. Поиск только забытых Close не объяснил ситуацию: открытый дескриптор и временный файл, созданный парсером multipart, имеют разные сроки жизни.

Разделите память, размер запроса и временное хранение

ParseMultipartForm принимает maxMemory, но этот параметр не является полным пределом размера загрузки. Файловые части, которые не помещаются в выбранный порог, могут сохраняться во временные файлы. Для ограничения входящего тела перед разбором ставят MaxBytesReader. Для очистки созданных парсером файлов используется MultipartForm.RemoveAll.

Обычный HTTP-сервер Go очищает временные multipart-файлы после завершения обработки запроса. Но код может вызываться вне штатного серверного цикла: в тесте через ServeHTTP, при самостоятельном использовании Request или внутри другого конвейера. Явная очистка в функции-владельце делает время освобождения понятным. Не объясняйте рост temp только отсутствием RemoveAll: проверьте, заканчиваются ли запросы и не остаются ли они висеть внутри обработки.

Увидьте настоящий файл на диске

Пример создаёт multipart-запрос в памяти и разбирает его с нулевым порогом файловой памяти. Файловая часть поэтому сохраняется на диск. Мы открываем её, узнаём путь, закрываем дескриптор, проверяем наличие, затем удаляем временные файлы формы. Программа не использует пользовательские каталоги и создаёт только собственный тестовый upload.

package main

import (
	"bytes"
	"fmt"
	"mime/multipart"
	"net/http/httptest"
	"os"
	"strings"
)

func main() {
	var body bytes.Buffer
	writer := multipart.NewWriter(&body)
	part, err := writer.CreateFormFile("file", "sample.csv")
	if err != nil {
		panic(err)
	}
	if _, err = part.Write([]byte(strings.Repeat("x", 1024))); err != nil {
		panic(err)
	}
	if err = writer.Close(); err != nil {
		panic(err)
	}
	r := httptest.NewRequest("POST", "/upload", &body)
	r.Header.Set("Content-Type", writer.FormDataContentType())
	if err = r.ParseMultipartForm(0); err != nil {
		panic(err)
	}
	defer r.MultipartForm.RemoveAll()
	f, err := r.MultipartForm.File["file"][0].Open()
	if err != nil {
		panic(err)
	}
	disk, ok := f.(*os.File)
	if !ok {
		panic("expected disk file")
	}
	name := disk.Name()
	if err = f.Close(); err != nil {
		panic(err)
	}
	_, err = os.Stat(name)
	fmt.Println(err == nil)
	if err = r.MultipartForm.RemoveAll(); err != nil {
		panic(err)
	}
	_, err = os.Stat(name)
	fmt.Println(os.IsNotExist(err))
}

Вывод true/true подтверждает существование файла до очистки и отсутствие после неё. Закрытие f расположено до RemoveAll: это особенно существенно на Windows, где открытый дескриптор может помешать удалению. В реальном обработчике defer следует располагать так, чтобы сначала закрывался открытый файл, затем удалялись временные файлы формы.

Назначьте владельца временных данных

После ParseMultipartForm проверьте ошибку и наличие r.MultipartForm. Если форма создана, запланируйте RemoveAll на выходе. Затем вызывайте FormFile, закрывайте возвращённый файл и выполняйте обработку. Ошибки очистки полезно записывать в журнал: они показывают проблему прав, антивирусной блокировки или оставленного дескриптора. Не превращайте успешный уже отправленный ответ в попытку дописать 500 из defer.

Когда файл нужен после завершения HTTP-запроса, временное хранилище парсера не подходит. До ответа перенесите данные в принадлежащее приложению хранилище и сохраните устойчивый идентификатор задания. Передача FileHeader фоновой goroutine опасна: штатная очистка запроса может удалить файл раньше чтения. Владение должно перейти явно и завершиться собственной очисткой по правилам приложения.

Имя файла от клиента не является безопасным путём назначения. Для собственного хранилища генерируйте имя или ключ, а исходное имя сохраняйте как метаданные после проверки. Ограничивайте число файлов и общий объём. Если обработка поддерживает поток, MultipartReader позволяет разбирать части последовательно, но тогда лимиты и завершение чтения становятся ответственностью вашего кода.

Для расследования снимите число и суммарный размер временных файлов, число активных upload-запросов и их длительность. Если рост совпадает с незавершёнными запросами, сначала найдите блокирующий шаг. Если файлы остаются после завершения самостоятельного парсинга, проверяйте путь очистки. Многократный тест с маленьким maxMemory помогает воспроизвести дисковую ветку, которую обычный маленький upload не затрагивает.

Отдельный разбор лимита тела запроса дополняет эту схему. Порог памяти, предел загрузки и срок хранения решают три разные задачи; задайте каждый явно перед запуском импорта больших файлов.

Проверьте путь отказа импорта

Один успешный upload не проверяет очистку при ошибке CSV. Добавьте сценарий, где чтение начинается, затем парсер возвращает ошибку формата. После выхода из функции файл должен быть закрыт, а временная форма удалена. Отдельный сценарий — отмена запроса во время записи в постоянное хранилище: частичный файл уже принадлежит вашему приложению и не удалится через MultipartForm.RemoveAll. Для него нужен собственный defer или запись под временным ключом с последующей публикацией результата. Это позволяет пользователю видеть либо законченный импорт, либо понятное состояние ошибки.

Продолжите до взаимодействия сервисов

Когда импорт становится долгим, полезно сохранить задание и отделить его обработку от HTTP-запроса. Платный курс микросервисов на Go учит связывать изменения PostgreSQL с событиями через outbox/inbox и восстанавливать обработку после сбоев. Вы сможете разобрать, где хранить принятый результат и как выдержать повторную доставку задания. Откройте программу курса «Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker».

Продолжить на практике

  • Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗

    От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.