← назад к разделу

Файлы в 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-data HTTP-запроса это поток без длины; если длину не передать, 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 в контейнере, обработчики на заглушке интерфейса.

Что почитать дальше