S3-совместимое хранилище из Go: чек-лист перед продом
Что проверить в коде на aws-sdk-go-v2 перед выкатом: таймауты, ретраи, лимит соединений, multipart и особенности не-AWS провайдеров вроде MinIO и Yandex Object Storage.

Загрузка в S3 работает на ноутбуке и падает в проде под нагрузкой. Типичный сценарий: сервис заливает пользовательские файлы в Yandex Object Storage, при 200 одновременных запросах начинает выдавать context deadline exceeded и течь по памяти, а логи молчат, потому что ошибки ретраев проглатываются. Разберём, что проверить в клиенте на aws-sdk-go-v2 (актуальная линейка github.com/aws/aws-sdk-go-v2, модуль service/s3 версии v1.x) до выкатки, если хранилище не настоящий AWS, а MinIO, Yandex, VK Cloud или Selectel.
Это чек-лист для инженера, который уже написал загрузку и скачивание и хочет, чтобы код пережил боевой трафик. Разбор устройства SDK и внутренней очереди запросов — отдельная тема, здесь только то, что видно снаружи и ломается первым.
Клиент создаётся один раз, а не на каждый запрос
Самая частая ошибка — вызывать config.LoadDefaultConfig и s3.NewFromConfig внутри обработчика. Каждый такой вызов поднимает новый HTTP-транспорт и новый пул соединений. Под нагрузкой это выливается в тысячи полуоткрытых сокетов и упор в лимит файловых дескрипторов.
Клиент *s3.Client потокобезопасен. Создавайте его при старте приложения и переиспользуйте.
Один клиент на всё приложение. Если вы создаёте s3.Client в HTTP-хендлере, вы создаёте не клиент, а утечку сокетов.
Endpoint и path-style для не-AWS хранилищ
AWS SDK по умолчанию собирает virtual-hosted-style адреса вида bucket.s3.amazonaws.com. С MinIO и некоторыми self-hosted инсталляциями это не работает: домен бакета не резолвится, и вы получаете DNS-ошибку вместо ответа хранилища. Для таких провайдеров нужен path-style (endpoint/bucket/key) и явный endpoint.
В свежих версиях v2 базовый endpoint задаётся полем BaseEndpoint прямо при создании клиента, а прежний путь через EndpointResolverWithOptions помечен как устаревший. Точный набор опций сверяйте с документацией вашей версии service/s3 — API резолвера менялся между релизами v1.x.
package storage
import (
"context"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// NewClient создаёт клиент один раз при старте приложения.
// endpoint — например https://storage.yandexcloud.net
func NewClient(ctx context.Context, endpoint, region, keyID, secret string) (*s3.Client, error) {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(keyID, secret, ""),
),
)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint)
// MinIO и часть self-hosted требуют path-style
o.UsePathStyle = true
})
return client, nil
}
Проверка: сделайте ListObjectsV2 с ограничением на один объект. Если вернулся ответ без DNS-ошибки — endpoint и стиль адресации подобраны верно.
Таймауты: без них запрос висит вечно
SDK не ставит дедлайн на операцию за вас. Если хранилище зависло на середине ответа, горутина будет ждать столько, сколько позволит транспорт. Под нагрузкой зависшие горутины копятся и съедают память.
Два уровня защиты:
- context с дедлайном на каждую операцию. Передавайте
context.WithTimeoutв каждый вызов —PutObject,GetObject,ListObjectsV2. Для загрузки крупного файла таймаут должен учитывать реальное время передачи, иначе оборвёте валидную заливку. - таймауты транспорта. Настройте
http.ClientсTransport, где заданыDialContextс таймаутом соединения,TLSHandshakeTimeoutиResponseHeaderTimeout. Передайте его в SDK черезconfig.WithHTTPClient.
Общий http.Client.Timeout на весь запрос для S3 не подходит: он оборвёт долгую скачку большого объекта. Ограничивайте по фазам, а не одним числом на всё.
Пул соединений: дефолт не рассчитан на ваш трафик
Стандартный http.Transport держит MaxIdleConnsPerHost равным 2. Для S3, где весь трафик идёт на один-два хоста, это узкое горлышко: под сотней параллельных загрузок соединения постоянно устанавливаются заново, и каждое — с TLS-хендшейком.
import "net/http"
transport := &http.Transport{
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100, // ключевой параметр для S3
IdleConnTimeout: 90 * time.Second,
}
httpClient := &http.Client{Transport: transport}
// передаётся в LoadDefaultConfig:
// config.WithHTTPClient(httpClient)
Как понять, что упёрлись: в метриках рост числа исходящих TCP-соединений и всплески латентности, коррелирующие с TLS-хендшейками. После подъёма MaxIdleConnsPerHost латентность повторных запросов падает.
Ретраи: они уже включены, но не под всё
SDK v2 по умолчанию повторяет запросы при сетевых сбоях и части 5xx-ответов, с экспоненциальной задержкой. Максимальное число попыток настраивается через config.WithRetryMaxAttempts. Проблема не в отсутствии ретраев, а в двух вещах:
- Не-идемпотентные операции.
PutObjectс телом изio.Reader, который нельзя перемотать, при ретрае отправит пустое или обрезанное тело. Передавайте seekable-источник (*os.File,bytes.Reader), чтобы SDK мог перечитать данные. - Съеденные ошибки. После исчерпания попыток ошибка возвращается наверх — не игнорируйте её. Логируйте с типом через
errors.Asдля*types.NoSuchKey,*types.NoSuchBucketи подобных, чтобы отличать «нет объекта» от «хранилище недоступно».
Крупные файлы — через Uploader, а не PutObject
Заливать файл на несколько гигабайт одним PutObject — значит держать его целиком в одном запросе и терять всё при обрыве на 99%. Пакет feature/s3/manager даёт Uploader, который сам режет данные на части и грузит их параллельно multipart-загрузкой.
import "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
uploader := manager.NewUploader(client, func(u *manager.Uploader) {
u.PartSize = 16 * 1024 * 1024 // размер части, минимум 5 МБ
u.Concurrency = 4 // сколько частей грузим параллельно
})
_, err := uploader.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: file, // *os.File
})
Важный нюанс не-AWS хранилищ: при обрыве multipart-загрузки незавершённые части остаются в бакете и занимают место, за которое вы платите. На настоящем S3 их вычищает lifecycle-правило AbortIncompleteMultipartUpload; у стороннего провайдера проверьте, поддерживается ли оно, — не у всех работает одинаково. Иначе чистите брошенные загрузки сами через ListMultipartUploads и AbortMultipartUpload.
Что различается между провайдерами
| Что проверить | AWS S3 | MinIO / Yandex / VK Cloud |
|---|---|---|
| Стиль адресации | virtual-hosted | чаще path-style, проверяйте |
| BaseEndpoint | не нужен | обязателен |
| region | реальный регион | иногда любой непустой, но задать нужно |
| Lifecycle для multipart | поддерживается | может не работать — чистите вручную |
| Часть API (теги, версионирование) | полный | покрытие неполное, сверяйтесь с их docs |
Не считайте, что раз клиент один и тот же, поведение одинаковое. Совместимость с S3 API у провайдеров частичная, и расходятся детали именно там, где вы этого не ждёте.
Чек-лист перед выкаткой
- Клиент создаётся один раз при старте, не в хендлере.
- Для не-AWS хранилища задан
BaseEndpointи, при необходимости,UsePathStyle. - На каждую операцию передаётся
contextс дедлайном. - В транспорте подняты
MaxIdleConnsPerHostи заданы фазовые таймауты. - Тело
PutObject— seekable, чтобы ретраи не портили данные. - Ошибки после ретраев логируются и различаются по типу через
errors.As. - Крупные файлы идут через
Uploader, брошенные multipart-загрузки чистятся. - Секреты — из переменных окружения или секрет-менеджера, не в коде.
Что мерить в проде
После выката смотрите на три показателя: число исходящих TCP-соединений к хосту хранилища (рост говорит о нехватке idle-пула), долю запросов, дошедших до исчерпания ретраев (это уже боль хранилища или сети), и объём незавершённых multipart-загрузок в бакете. Последнее полезно вынести в отдельную проверку по расписанию: раз в сутки ListMultipartUploads и алерт, если висящих частей больше порога. Дальше стоит закрыть скачивание — потоковую отдачу через GetObject с проверкой Range, но это отдельный разговор.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: AWS SDK for Go v2 — Developer Guide, aws-sdk-go-v2 — репозиторий на GitHub
Частые вопросы
У меня другая мажорная версия — aws-sdk-go v1, а не v2. Код подойдёт?
Получаю ошибку, что бакет не найден, хотя он точно есть. Что не так?
Загрузка крупного файла обрывается по таймауту. Как быть?
Ретраи включены по умолчанию — значит, про идемпотентность можно не думать?
Не сработало на MinIO, хотя на Yandex всё хорошо. Почему?
Где хранить ключ доступа и секрет?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.