S3 из Go: типичные ошибки при работе с aws-sdk-go-v2
Утечки соединений, зависшие горутины, битые загрузки и ретраи, которые не спасают. Разбираем частые ошибки в коде работы с S3-совместимыми хранилищами на Go и показываем, как их избежать.

Код заливает файлы в S3-совместимое хранилище, локально всё работает, а в проде через пару часов сервис упирается в лимит открытых файловых дескрипторов и начинает отдавать 500. Или загрузка большого файла падает на середине, а err оказывается nil. Или ретраи настроены, но при таймауте запрос не повторяется. Все три случая — не про баги SDK, а про то, как его используют. Разберём приёмы, которые ломаются чаще всего, и что вместо них.
Материал — про aws-sdk-go-v2 (модуль github.com/aws/aws-sdk-go-v2, актуальные релизы 2024–2025 годов) и его работу с MinIO, Yandex Object Storage, VK Cloud и другими S3-совместимыми бэкендами. Старый aws-sdk-go (v1) переведён в maintenance-режим — новый код на нём начинать не стоит. Проверка конкретных имён методов и опций — по pkg.go.dev, версии API между релизами v2 иногда меняются.
Ошибка 1. Тело ответа не закрывают — и ловят утечку соединений
GetObject возвращает структуру, в которой Body — это io.ReadCloser. Пока вы его не закрыли, HTTP-соединение считается занятым и не возвращается в пул. На десятке запросов это незаметно. На потоке в сотни запросов в секунду дескрипторы кончаются, и вы видите too many open files.
// НЕПРАВИЛЬНО: Body не закрывается
out, err := client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String("reports"),
Key: aws.String("2025/report.pdf"),
})
if err != nil {
return err
}
data, err := io.ReadAll(out.Body) // соединение зависло навсегда
Правильно — defer out.Body.Close() сразу после проверки ошибки:
out, err := client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String("reports"),
Key: aws.String("2025/report.pdf"),
})
if err != nil {
return err
}
defer out.Body.Close() // соединение вернётся в пул
data, err := io.ReadAll(out.Body)
if err != nil {
return err
}
Как поймать проблему до прода: под нагрузкой смотрите на число открытых дескрипторов процесса (ls /proc/PID/fd | wc -l в Linux) и на количество горутин через runtime.NumGoroutine(). Если оба монотонно растут и не падают после спада нагрузки — где-то не закрыт Body.
Ошибка 2. Один общий контекст без таймаута на всю пачку операций
S3-совместимый бэкенд может отвечать медленно: сеть до облака, троттлинг, перегруженный MinIO. Если вы передаёте в каждый вызов context.Background(), зависший запрос будет висеть, сколько захочет сервер, — а вместе с ним висит горутина и занятое соединение.
Давайте на каждую операцию отдельный контекст с дедлайном:
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Second)
defer cancel()
_, err := client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String("uploads"),
Key: aws.String(key),
Body: reader,
})
Тонкость: для больших файлов через PutObject таймаут в 30 секунд может обрезать легитимную загрузку. Для многочастной загрузки (uploader) таймаут ставьте на весь процесс с запасом либо не ставьте общий дедлайн, а ограничивайте отдельные части — об этом ниже.
Контекст без дедлайна — это не «без ограничений», а «зависнуть навсегда, если сервер замолчит на уровне TCP». Дедлайн должен быть у каждого сетевого вызова.
Ошибка 3. Большие файлы льют через PutObject и держат всё в памяти
PutObject с Body в виде io.Reader без известной длины заставляет SDK буферизовать данные, чтобы посчитать Content-Length и подпись. Для файла на несколько гигабайт это либо OOM, либо неоправданный расход памяти на каждый параллельный запрос.
Для больших объектов используйте manager.Uploader из пакета feature/s3/manager — он режет данные на части и грузит их параллельно, не держа весь файл в памяти:
import (
"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
uploader := manager.NewUploader(client, func(u *manager.Uploader) {
u.PartSize = 16 * 1024 * 1024 // 16 МБ на часть
u.Concurrency = 4 // 4 части параллельно
})
_, err := uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String("backups"),
Key: aws.String("db-2025-06.dump"),
Body: file, // *os.File или любой io.Reader
})
Минимальный размер части в протоколе S3 — 5 МБ (кроме последней), меньше ставить нельзя. Проверьте, что ваш бэкенд поддерживает multipart upload: у большинства S3-совместимых он есть, но у экзотических реализаций и старых версий MinIO поведение отличалось — сверяйтесь с документацией конкретного хранилища.
Ошибка 4. Path-style против virtual-hosted-style
AWS S3 давно ушёл на virtual-hosted-style адресацию: bucket.s3.amazonaws.com. Многие S3-совместимые хранилища, особенно self-hosted MinIO по IP или по одному домену, работают только с path-style: endpoint/bucket/key. Если не указать это явно, вы получите ошибки резолва DNS или 400/403 на ровном месте.
В v2 адресация задаётся опцией клиента:
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String("https://storage.example.com")
o.UsePathStyle = true // path-style для MinIO и подобных
})
Точное имя опции конфигурации эндпоинта в v2 несколько раз менялось между релизами (был отдельный EndpointResolver, потом BaseEndpoint). Перед копированием сверьте с версией SDK в вашем go.mod и документацией на pkg.go.dev.
Ошибка 5. Регион не указан или указан неправильно
Даже если ваш бэкенд не имеет понятия «регион», подпись SigV4 его требует. Пустой регион даёт ошибку на этапе подписи запроса. Для S3-совместимых хранилищ, где регион не важен, ставят условное значение вроде us-east-1 или то, что требует провайдер.
- Yandex Object Storage ожидает регион
ru-central1. - MinIO по умолчанию принимает любой, часто ставят
us-east-1. - VK Cloud и другие — смотрите документацию провайдера, значение бывает жёстко зафиксировано.
Регион задаётся при загрузке конфигурации:
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("ru-central1"),
)
Ошибка 6. Ретраи считают «включёнными по умолчанию» и не проверяют
SDK v2 действительно повторяет некоторые ошибки автоматически: троттлинг (SlowDown, HTTP 503), временные сетевые сбои. Но при этом важно понимать два момента. Первый: ретраи потребляют тело запроса повторно — если ваш Body это неперематываемый io.Reader (например, сетевой поток), повтор невозможен, и SDK отдаст ошибку. Второй: клиентские таймауты через контекст ретраями не спасаются — отменённый контекст не повторяют, это не транзиентная ошибка.
Настроить количество попыток и максимальное время бэкоффа можно явно:
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRetryMaxAttempts(5),
config.WithRetryMode(aws.RetryModeStandard),
)
Проверять поведение ретраев нужно на реальных сбоях: искусственно роняйте соединение (например, через toxiproxy) и смотрите, повторяется ли запрос и укладывается ли суммарное время в ваш SLA.
Что с чем путают: сводка
| Симптом | Частая причина | Что сделать |
|---|---|---|
too many open files | Не закрыт out.Body | defer Body.Close() после каждого GetObject |
| Горутины и память растут | Контекст без дедлайна, зависшие запросы | WithTimeout на каждый вызов |
| OOM при загрузке | Большой файл через PutObject | manager.Uploader с PartSize |
| 400/403 на верных ключах | Не тот стиль адресации | UsePathStyle для MinIO и self-hosted |
| Ошибка подписи | Пустой или неверный регион | WithRegion под требования провайдера |
Что настроить дальше
Когда базовые ошибки закрыты, стоит заняться двумя вещами. Первое — переиспользование клиента: создавайте один *s3.Client на всё приложение, а не на каждый запрос; за ним стоит HTTP-транспорт с пулом соединений, и пересоздание клиента сводит пул на нет. Второе — метрики: экспортируйте число горутин, открытых дескрипторов и латентность операций S3 в Prometheus, чтобы утечка проявилась на графике за минуты, а не за часы падения в проде.
Инвалидация и версионирование объектов, настройка lifecycle-политик и presigned URL — отдельные темы, здесь мы разбирали только клиентский код загрузки и чтения.
Проектная работа: От проблемы до защиты: продукт, этапы, вопросы комиссии
Спросить за 15 ₽Источники: AWS SDK for Go v2 — pkg.go.dev, aws-sdk-go-v2 — репозиторий на GitHub
Частые вопросы
У меня aws-sdk-go v1, стоит ли переписывать на v2?
Имя опции эндпоинта из примера не компилируется — почему?
Загрузка большого файла упала посередине, а ошибка nil — как так?
manager.Uploader не работает с моим хранилищем — что проверить?
Ретраи включены, но при таймауте запрос не повторяется — это баг?
Нужно ли создавать новый s3.Client на каждый запрос?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.