Файлы в Go-сервисе живут не в базе и не на диске контейнера, а в объектном хранилище с протоколом S3: в AWS это сам S3, в Yandex Cloud это Object Storage, в своём контуре это MinIO или Ceph. Протокол один, поэтому и клиент один: официальный aws-sdk-go-v2. Отличия между хранилищами укладываются в три строки настройки, а всё остальное, от потоковой отдачи файла до presigned-ссылок, работает одинаково.
SDK второй версии разбит на модули: github.com/aws/aws-sdk-go-v2 (ядро), config (загрузка настроек и цепочка учётных данных), credentials (явные ключи), service/s3 (сам клиент) и feature/s3/manager (многопоточная загрузка больших файлов). Подключают только нужные.
Клиент: один на процесс, три строки под хранилище
*s3.Client безопасен для горутин и держит пул HTTP-соединений, поэтому его создают один раз при старте и передают в хранилище файлов.
import (
"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"
)
type Settings struct {
Endpoint string
Region string
Bucket string
AccessKey string
SecretKey string
PathStyle bool
}
func newClient(ctx context.Context, s Settings) (*s3.Client, error) {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(s.Region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(s.AccessKey, s.SecretKey, "")),
config.WithRequestChecksumCalculation(aws.RequestChecksumCalculationWhenRequired),
config.WithResponseChecksumValidation(aws.ResponseChecksumValidationWhenRequired),
config.WithRetryMaxAttempts(3),
)
if err != nil {
return nil, err
}
return s3.NewFromConfig(cfg, func(o *s3.Options) {
if s.Endpoint != "" {
o.BaseEndpoint = aws.String(s.Endpoint)
}
o.UsePathStyle = s.PathStyle
o.HTTPClient = &http.Client{Timeout: 60 * time.Second}
}), nil
}
Что здесь меняется от хранилища к хранилищу.
- AWS S3.
Endpointпустой,UsePathStyleложь, регион настоящий. Ключей в коде и в переменных окружения в проде нет:LoadDefaultConfigбезWithCredentialsProviderсам пройдёт цепочку (переменные окружения, файл профиля, роль пода через IRSA, роль инстанса), и сервис получает права от роли. - Yandex Object Storage.
Endpoint: https://storage.yandexcloud.net,Region: ru-central1, статический ключ сервисного аккаунта из секрета Kubernetes. Виртуальные хосты (bucket.storage.yandexcloud.net) поддерживаются, path-style тоже. - MinIO в своём контуре и в тестах.
Endpoint: http://minio:9000,UsePathStyle: trueобязательно: без него клиент пойдёт наbucket.minio, которого нет в DNS. Регион любой, MinIO его не проверяет.
Контрольные суммы. С начала 2025 года SDK по умолчанию считает CRC32 для каждого PutObject и присылает его заголовком. S3 это понимает, часть совместимых хранилищ и старые версии MinIO отвечают ошибкой подписи или NotImplemented. Настройки WhenRequired возвращают прежнее поведение: сумма считается только там, где её требует сама операция. С настоящим S3 они не мешают.
Таймаут. У SDK нет своего таймаута на операцию, только повторы. http.Client{Timeout: 60s} ограничивает один HTTP-вызов целиком, включая чтение тела, поэтому для гигабайтных файлов его ставят больше или ограничивают только дедлайном контекста. Повторы (WithRetryMaxAttempts) касаются сетевых ошибок и 5xx; 404 и 403 не повторяются.
Загрузка файла
type Store struct {
client *s3.Client
bucket string
}
func (s *Store) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: body,
ContentLength: aws.Int64(size),
ContentType: aws.String(contentType),
})
return err
}
Три договорённости, которые стоит зафиксировать в хранилище файлов с первого дня.
- Ключ строит сервис, а не пользователь.
orders/2026/10/0192f1e4-....pdf: префикс по сущности и дате, идентификатор вместо имени файла. Имя, которое прислал пользователь, живёт в базе и возвращается вContent-Dispositionпри отдаче. Так в ключ не попадут пробелы, кириллица и../. ContentLengthизвестен заранее. Тело изmultipart/form-dataHTTP-запроса это поток без длины; если длину не передать, SDK прочитает поток в память или отправит его частями с подписью по кускам, что поддерживают не все хранилища. Длину берут из заголовка файла в форме или изContent-Lengthзапроса, а при сомнении пишут сначала во временный файл.ContentTypeзадаёт сервис по проверенному содержимому (http.DetectContentTypeпервых 512 байт), а не по расширению от клиента: именно его потом отдаст браузер.
Загрузка в обработчике идёт потоком из r.Body или из multipart.File прямо в PutObject, без io.ReadAll: иначе каждый файл на 200 МБ это 200 МБ кучи на запрос.
Большие файлы: multipart через manager
Один PutObject ограничен 5 ГБ и идёт одним соединением. Для видео, бэкапов и выгрузок есть многочастная загрузка: файл режется на части от 5 МБ, части уходят параллельно, хранилище склеивает их по команде. Руками это три вызова и обработка сбоев, в SDK это manager.Uploader:
import "github.com/aws/aws-sdk-go-v2/feature/s3/manager"
func (s *Store) PutLarge(ctx context.Context, key string, body io.Reader, contentType string) error {
up := manager.NewUploader(s.client, func(u *manager.Uploader) {
u.PartSize = 16 << 20
u.Concurrency = 4
})
_, err := up.Upload(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: body,
ContentType: aws.String(contentType),
})
return err
}
Загрузчик сам решает, слать файл одним запросом или частями, и принимает поток без длины. PartSize на Concurrency это память, которую он займёт на одну загрузку (здесь 64 МБ), и это надо помнить, когда таких загрузок десятки одновременно. Прерванная многочастная загрузка оставляет в бакете части, которые не видны в списке объектов, но занимают место и стоят денег: правило жизненного цикла AbortIncompleteMultipartUpload через сутки обязательно для любого бакета, куда грузят большие файлы.
Отдача файла
Скачивание это тоже поток: тело ответа S3 переливается в тело HTTP-ответа без буфера.
func (s *Store) Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error) {
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
var nsk *types.NoSuchKey
if errors.As(err, &nsk) {
return nil, 0, "", ErrNotFound
}
return nil, 0, "", err
}
return out.Body, aws.ToInt64(out.ContentLength), aws.ToString(out.ContentType), nil
}
func serveFile(store *Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, size, ctype, err := store.Get(r.Context(), r.PathValue("key"))
if errors.Is(err, ErrNotFound) {
http.NotFound(w, r)
return
}
if err != nil {
http.Error(w, "storage unavailable", http.StatusBadGateway)
return
}
defer body.Close()
w.Header().Set("Content-Type", ctype)
w.Header().Set("Content-Length", fmt.Sprint(size))
io.Copy(w, body)
}
}
Ошибки SDK типизированы: *types.NoSuchKey у GetObject, *types.NotFound у HeadObject (он отвечает без тела, поэтому и ошибка другая), *types.NoSuchBucket. Их распознают через errors.As и переводят в ошибки своего домена, чтобы обработчик не знал о пакете types. Частичное чтение (докачка, видео с середины) это заголовок Range: bytes=0-1048575 в GetObjectInput.
Отдавать файл через сервис оправдано, когда нужна проверка прав на каждое скачивание или файл маленький. Для всего остального сервис проверяет права один раз и выдаёт ссылку, по которой клиент идёт в хранилище сам.
Presigned-ссылки: хранилище работает вместо сервиса
Подписанная ссылка это обычный URL с подписью и сроком жизни. Тот, у кого она есть, может выполнить ровно одну операцию над ровно одним объектом, пока срок не вышел. Сервис подписывает ссылку своими ключами и ничего не передаёт через себя.
type Presigner struct {
ps *s3.PresignClient
bucket string
}
func NewPresigner(client *s3.Client, bucket string) *Presigner {
return &Presigner{ps: s3.NewPresignClient(client), bucket: bucket}
}
func (p *Presigner) DownloadURL(ctx context.Context, key, fileName string) (string, error) {
req, err := p.ps.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(p.bucket),
Key: aws.String(key),
ResponseContentDisposition: aws.String(`attachment; filename="` + fileName + `"`),
}, s3.WithPresignExpires(15*time.Minute))
if err != nil {
return "", err
}
return req.URL, nil
}
func (p *Presigner) UploadURL(ctx context.Context, key, contentType string, size int64) (string, error) {
req, err := p.ps.PresignPutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(p.bucket),
Key: aws.String(key),
ContentType: aws.String(contentType),
ContentLength: aws.Int64(size),
}, s3.WithPresignExpires(10*time.Minute))
if err != nil {
return "", err
}
return req.URL, nil
}
Подпись покрывает всё, что указано в запросе: браузер, загружающий файл по UploadURL, обязан прислать тот же Content-Type и тот же размер, иначе хранилище ответит 403. Это не неудобство, а защита: пользователь не загрузит исполняемый файл под видом картинки и не зальёт гигабайт вместо обещанных ста килобайт. Срок жизни ссылки короткий, минуты, потому что отозвать её нельзя.
Загрузка из браузера напрямую в бакет требует CORS на бакете: разрешённый источник (домен приложения), методы PUT и GET, заголовок Content-Type. Без этого запрос из браузера упадёт ещё на preflight, а в логах сервиса не будет ничего. CORS это настройка бакета (PutBucketCors), её делают один раз при создании инфраструктуры, а не из кода сервиса.
База и хранилище: кто кому верит
Транзакции, которая охватит и строку в PostgreSQL, и объект в S3, не существует. Порядок операций выбирают так, чтобы сбой между ними оставлял систему в безопасном состоянии.
- Загрузка: сначала объект, потом запись. Сервис выдаёт presigned-ссылку на ключ, клиент загружает файл, затем вызывает «подтвердить загрузку». Обработчик проверяет
HeadObject(объект есть, размер и тип совпадают) и только тогда пишет строку в базу. Упал клиент посередине: в бакете лежит сирота, в базе нет ничего, ссылки на несуществующий файл не появилось. - Удаление: сначала запись, потом объект, и не в запросе. Обработчик помечает файл удалённым в базе (в той же транзакции, что и бизнес-операцию), а объект удаляет фоновая задача по этой отметке с повторами. Удалять из обработчика нельзя: откат транзакции после удаления объекта оставит в базе ссылку в никуда.
- Сироты чистит сверка. Раз в сутки задача проходит по префиксу
ListObjectsV2постранично и удаляет объекты старше часа, на которые нет строки в базе.DeleteObjectsпринимает до тысячи ключей за вызов и возвращает список тех, что удалить не удалось.
func (s *Store) List(ctx context.Context, prefix string, fn func(key string, size int64) error) error {
p := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket),
Prefix: aws.String(prefix),
})
for p.HasMorePages() {
page, err := p.NextPage(ctx)
if err != nil {
return err
}
for _, o := range page.Contents {
if err := fn(aws.ToString(o.Key), aws.ToInt64(o.Size)); err != nil {
return err
}
}
}
return nil
}
Когда хранилище недоступно
Чтение файла это 502 с коротким дедлайном контекста, а не ожидание минуту: пользователь увидит «файл временно недоступен», остальное приложение работает. Повторы SDK (три попытки с нарастающей паузой) закрывают короткие сбои, дольше их растягивать не нужно. Загрузка через presigned-ссылку недоступности сервиса вообще не касается, а недоступность хранилища клиент увидит сам и повторит. Фоновые удаления и сверка просто повторятся в следующий запуск. В проверке здоровья сервиса хранилище значится как зависимость, но не как причина снять под с балансировщика.
Тесты на MinIO
Хранилище файлов это интерфейс (Put, Get, Delete, DownloadURL), и обработчики тестируются с заглушкой в памяти. Сама реализация на SDK тестируется против MinIO в контейнере: он совместим с S3 настолько, что код не отличает их.
import tcminio "github.com/testcontainers/testcontainers-go/modules/minio"
c, err := tcminio.Run(ctx, "minio/minio:RELEASE.2024-01-16T16-07-38Z")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { c.Terminate(ctx) })
endpoint, err := c.ConnectionString(ctx)
client, err := newClient(ctx, Settings{
Endpoint: "http://" + endpoint, Region: "us-east-1",
AccessKey: c.Username, SecretKey: c.Password, PathStyle: true,
})
_, err = client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String("files")})
Что проверяют: загрузка и чтение возвращают те же байты и тот же Content-Type, отсутствующий ключ даёт ErrNotFound, а не ошибку SDK, presigned-ссылка на скачивание открывается обычным http.Get без ключей, DeleteObjects чистит префикс. Контейнер один на пакет, бакет на каждый тест свой.
Глубже: многочастная загрузка рукамирасширенное
Когда файл грузит клиент напрямую, а не сервис, manager.Uploader не поможет: его части подписывает сервис. Схема такая: сервис вызывает CreateMultipartUpload и получает UploadId, выдаёт клиенту presigned-ссылки на UploadPart для каждого номера части, клиент грузит части параллельно и возвращает ETag каждой, сервис завершает CompleteMultipartUpload со списком частей. Минимальный размер части 5 МБ (кроме последней), максимум частей 10 000. Незавершённую загрузку закрывает AbortMultipartUpload или правило жизненного цикла бакета.
Глубже: события бакетарасширенное
Хранилище умеет сообщать о появлении объекта: в AWS это уведомления в SQS или EventBridge, в MinIO это события в Kafka, NATS или вебхук, в Yandex Object Storage это очередь сообщений. Так строят обработку загруженного: превью картинок, антивирус, разбор выгрузок. Потребитель на Go читает событие с ключом объекта, забирает объект GetObject и пишет результат рядом под другим префиксом. Событие может прийти дважды, поэтому обработчик идемпотентен: перед работой он проверяет HeadObject на уже существующий результат.
Коротко
- Один
*s3.Clientна процесс; для AWS права от роли без ключей в коде, для совместимых хранилищBaseEndpoint,UsePathStyleи статический ключ из секрета. - Контрольные суммы
WhenRequired, иначе часть S3-совместимых хранилищ отвергаетPutObjectнового SDK. - Ключ объекта строит сервис, имя файла пользователя живёт в базе и возвращается в
Content-Disposition. - Загрузка и отдача потоком, без
io.ReadAll;ContentLengthиContentTypeзадаёт сервис. - Большие файлы через
manager.Uploader; правилоAbortIncompleteMultipartUploadна бакете обязательно. - Ошибки типизированы:
NoSuchKey,NotFound,NoSuchBucketчерезerrors.Asи в ошибки домена. - Presigned-ссылки на минуты: скачивание с
Content-Disposition, загрузка с фиксированными типом и размером, CORS на бакете. - Сначала объект, потом строка в базе; удаление объекта фоновой задачей по отметке; сверка сирот по
ListObjectsV2. - Недоступность хранилища это 502 на файловых ручках с коротким дедлайном, а не падение сервиса.
- Тесты реализации на MinIO в контейнере, обработчики на заглушке интерфейса.
Что почитать дальше
- Как устроено объектное хранилище — объекты, бакеты, согласованность и почему это не файловая система.
- Объектное хранилище в проде — классы хранения, жизненный цикл, версионирование и стоимость.
- База или S3 — что хранить объектами, а что строками.
- Паттерны распределённых систем на Go — outbox и фоновые задачи, на которых держится удаление и сверка.
- Интеграционные тесты на Go — контейнер на пакет, изоляция данных и build-теги.