S3 и MinIO в Go: работа с объектами
Один сервис уже принял оригинальный файл, а фоновый обработчик запустится позже — возможно, в другом контейнере и после перезапуска системы. В примерах эти роли выполняют media-api и image-worker. К моменту запуска обработчика байты не должны исчезнуть, потому что из них ещё предстоит создать версию для просмотра и миниатюру.
Локальный диск контейнера для этого не подходит: другой контейнер его не видит, а после пересоздания файлы могут исчезнуть. База данных хорошо хранит состояние ресурса, но неудобна для выдачи и масштабирования больших бинарных данных. RabbitMQ должен переносить описание работы, а не само изображение.
Объектное хранилище разделяет эти обязанности. MinIO сохраняет поток байтов под стабильным ключом, RabbitMQ передаёт этот ключ обработчику, а PostgreSQL хранит состояние и метаданные. Проследим изображение asset-456 от оригинала до публичной ссылки.
Примеры проверены с minio-go версии 7.0.76. MinIO реализует S3-совместимый API для используемых здесь операций; при переходе к другому провайдеру всё равно проверьте его адреса, политики и ограничения.
Один файл в трёх системах
HTTP-загрузка │ ▼ MinIO: media-assets/uploads/account-42/asset-456/original.jpg │ └── ключ объекта ──▶ сообщение RabbitMQ │ ▼ image-worker │ ┌──────────────────────────┼────────────────────────┐ ▼ ▼ ▼ PostgreSQL: ready .../display.jpg .../thumb.jpg
В MinIO объект состоит из четырёх частей:
бакет: media-assets ключ объекта: uploads/account-42/asset-456/original.jpg тип содержимого: image/jpeg тело: <поток байтов>
Бакет (bucket) — верхнеуровневый контейнер, а ключ объекта (object key) — имя объекта внутри него. Косые черты помогают читать ключ, но не создают настоящие каталоги.
Более короткий ключ uploads/{ownerID}/original.jpg допустим, если у владельца может быть только один исходник и замены строго выполняются по очереди. Здесь assetID входит в ключ намеренно: новая загрузка не перезапишет объект, который ещё обрабатывает старая задача.
В таблице media_assets достаточно сохранить бакет, ключи объектов, размер, тип содержимого и состояние обработки. Внешний URL — представление этих данных для клиента, а не устойчивый идентификатор объекта.
Формируем ключ на сервере
Исходное имя image.jpg нельзя использовать как единственный ключ объекта. Другой пользователь загрузит файл с тем же именем, а неожиданные символы усложнят URL и записи в журнале.
Сервер уже знает ownerID и создал assetID, поэтому формирует ключ сам:
func originalKey(ownerID, assetID, extension string) string { return fmt.Sprintf( "uploads/%s/%s/original.%s", ownerID, assetID, extension, ) }
extension должна происходить из проверенного типа содержимого, а ownerID и assetID — из валидированных серверных идентификаторов. Не подставляйте произвольный пользовательский путь в ключ.
Версия asset-456 в ключе даёт полезное свойство: новая загрузка не перезаписывает старый объект до успешной обработки. После переключения записи в БД прежнюю версию можно удалить отдельно.
Создаём клиент при старте
Параметр endpoint описывает адрес, который видит Go-сервис, например minio:9000 внутри Docker. Схема в это поле не входит: HTTPS выбирается через Secure.
package objectstore import ( "context" "fmt" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" ) type Store struct { client *minio.Client bucket string } func New( endpoint, accessKey, secretKey, bucket string, secure bool, ) (*Store, error) { client, err := minio.New(endpoint, &minio.Options{ Creds: credentials.NewStaticV4(accessKey, secretKey, ""), Secure: secure, }) if err != nil { return nil, fmt.Errorf("create MinIO client: %w", err) } return &Store{client: client, bucket: bucket}, nil } func (s *Store) EnsureBucket(ctx context.Context) error { exists, err := s.client.BucketExists(ctx, s.bucket) if err != nil { return fmt.Errorf("check bucket %q: %w", s.bucket, err) } if exists { return nil } if err := s.client.MakeBucket( ctx, s.bucket, minio.MakeBucketOptions{}, ); err != nil { return fmt.Errorf("create bucket %q: %w", s.bucket, err) } return nil }
Создайте один Store при старте и передайте его обработчикам. EnsureBucket также вызывается один раз — либо приложением, либо, что предпочтительнее в рабочем окружении, инфраструктурной миграцией.
Если несколько экземпляров одновременно пытаются создать бакет, один может получить конфликт после успешной проверки BucketExists. Приложение может повторно проверить существование, но централизованное создание инфраструктуры устраняет эту гонку целиком.
Ключ доступа и секретный ключ приходят из защищённой конфигурации. Не помещайте их в URL, Git или журналы.
Загружаем оригинал потоком
PutObject принимает io.Reader. Это позволяет передавать ограниченное тело HTTP-запроса дальше без io.ReadAll всего файла в память.
func (s *Store) Put( ctx context.Context, objectKey string, reader io.Reader, size int64, contentType string, ) error { if size < 0 { return fmt.Errorf("object size must be known") } _, err := s.client.PutObject( ctx, s.bucket, objectKey, reader, size, minio.PutObjectOptions{ContentType: contentType}, ) if err != nil { return fmt.Errorf("put object %q: %w", objectKey, err) } return nil }
Фрагмент продолжает пакет objectstore и требует импорт io. До вызова Put HTTP-слой должен ограничить размер, проверить сигнатуру файла и разрешить только ожидаемые типы. Пользовательский заголовок Content-Type сам по себе не доказывает, что внутри JPEG или PNG.
Метод намеренно требует известный размер. minio-go поддерживает size: -1 для потоковой составной загрузки (multipart), но тогда нужно отдельно учитывать размеры частей и потребление памяти. Для небольшого изображения явный предел делает поведение проще.
После загрузки откройте консоль MinIO и найдите:
media-assets/uploads/account-42/asset-456/original.jpg
Проверьте размер и Content-Type. Только после успешного Put сохраняйте ключ объекта в БД и публикуйте задачу обработки.
Эти три действия не образуют общую транзакцию. Сбой записи в БД оставит объект-сироту, а сбой публикации — задачу без обработчика. При допустимой редкой потере достаточно повторных попыток и фоновой очистки; если потеря задачи недопустима, сохраняйте событие в транзакционном журнале исходящих событий (transactional outbox) одной транзакцией с записью ресурса и публикуйте его отдельным процессом.
Обработчик читает тот же объект
GetObject имеет неочевидное поведение: он может вернуть *minio.Object до фактического сетевого чтения. Ошибка NoSuchKey проявится при Stat или первом Read, поэтому проверить только первый err недостаточно.
func (s *Store) Get( ctx context.Context, objectKey string, ) (*minio.Object, error) { object, err := s.client.GetObject( ctx, s.bucket, objectKey, minio.GetObjectOptions{}, ) if err != nil { return nil, fmt.Errorf("get object %q: %w", objectKey, err) } if _, err := object.Stat(); err != nil { _ = object.Close() return nil, fmt.Errorf("stat object %q: %w", objectKey, err) } return object, nil }
Обработчик закрывает результат независимо от исхода декодирования. Удобно изолировать этот жизненный цикл в небольшой функции:
func decodeOriginal( ctx context.Context, store *objectstore.Store, objectKey string, ) (image.Image, error) { object, err := store.Get(ctx, objectKey) if err != nil { return nil, fmt.Errorf("open original image: %w", err) } defer func() { _ = object.Close() }() decodedImage, _, err := image.Decode(object) if err != nil { return nil, fmt.Errorf("decode original image: %w", err) } return decodedImage, nil }
Контекст здесь принадлежит обрабатываемой задаче и имеет предельный срок. При отмене обработчик перестаёт читать объект вместо продолжения фоновой сетевой операции.
После изменения размера тот же Store.Put сохраняет, например, display.jpg и thumb.jpg рядом с оригиналом. Только когда оба объекта готовы, обработчик переводит asset-456 в состояние ready и записывает их ключи.
Решаем, кто может читать изображение
Для публичного изображения есть два разных варианта:
- разрешить анонимный
s3:GetObjectтолько для публичного бакета или префикса; - оставить данные приватными и выдавать короткоживущую предварительно подписанную ссылку (presigned URL).
Публичная политика удобна для открытого каталога, CDN и долгого кэширования. Но она должна охватывать только готовые изображения. Если оригиналы не прошли проверку или не должны быть видны, храните их в приватном бакете либо под префиксом, который не входит в публичную политику доступа.
Для приватного объекта создадим ограниченную по времени ссылку. Аргумент presigner здесь — отдельный клиент minio.Client, настроенный с внешним адресом, который откроет браузер:
func PresignedGetURL( ctx context.Context, presigner *minio.Client, bucket string, objectKey string, expires time.Duration, ) (string, error) { if expires <= 0 || expires > 24*time.Hour { return "", fmt.Errorf("expiration must be between 0 and 24 hours") } objectURL, err := presigner.PresignedGetObject( ctx, bucket, objectKey, expires, nil, ) if err != nil { return "", fmt.Errorf("create presigned URL: %w", err) } return objectURL.String(), nil }
Фрагмент требует импорт time. Ссылка остаётся действительной до истечения срока, поэтому не логируйте её целиком.
Внутренний адрес minio:9000 работает для контейнера, но браузер пользователя обычно не знает DNS-имён Docker. Для предварительно подписанной ссылки недостаточно заменить имя узла в уже готовой строке: оно участвует в подписи. Создайте клиент для подписания с внешним именем узла и правильным значением Secure; этот адрес должен маршрутизироваться к тому же хранилищу и иметь корректный TLS-сертификат. По этой же причине база хранит стабильный ключ объекта, а URL строится для текущего окружения при формировании ответа.
Заменяем изображение без общей транзакции
PostgreSQL и S3 не участвуют в одной ACID-транзакции. Если удалить старый объект первым, а загрузка нового завершится ошибкой, пользователь потеряет рабочую версию изображения.
Более безопасный порядок выглядит так:
- сохранить новую версию под новым ключом объекта;
- обработать её и проверить результат;
- транзакцией БД переключить активную версию изображения на новые ключи;
- после фиксации транзакции удалить старые объекты;
- периодической задачей очищать объекты, на которые больше нет ссылок.
Удаление само по себе небольшое:
func (s *Store) Remove(ctx context.Context, objectKey string) error { err := s.client.RemoveObject( ctx, s.bucket, objectKey, minio.RemoveObjectOptions{}, ) if err != nil { return fmt.Errorf("remove object %q: %w", objectKey, err) } return nil }
Сбой шага 4 не должен откатывать уже видимую новую версию. Он создаёт объект-сироту, который найдёт повторная попытка или фоновая очистка.
Пример предполагает бакет без версионирования объектов. Если версионирование включено, обычное удаление может создать маркер удаления (delete marker), а старые версии продолжат занимать место; задайте отдельную политику жизненного цикла для их хранения и очистки.
Переводим ошибки на границе API
Не возвращайте клиенту исходный текст MinIO: в нём могут быть внутренний адрес, бакет и детали инфраструктуры. Пакет хранилища оборачивает причину через %w, а HTTP-слой переводит известные случаи:
- объект не найден —
404 Not Found; - превышен лимит загрузки —
413 Content Too Large; - тип не поддерживается —
415 Unsupported Media Type; - MinIO недоступен или предельный срок истёк —
503 Service Unavailable; - неизвестная внутренняя ошибка —
500 Internal Server Errorс идентификатором в безопасной записи журнала.
Если API передаёт объект клиенту через io.Copy, используйте контекст запроса, закройте *minio.Object и проверьте ошибку копирования. После отправки заголовков HTTP-статус изменить уже может быть нельзя, но разрыв всё равно нужен в логах и метриках.
Что проверить перед использованием
- Ключ объекта формируется сервером и содержит версию загрузки, поэтому новое изображение не затирает старое преждевременно.
- Размер и сигнатура файла проверены до
PutObject, а данные передаются потоком. - База и RabbitMQ получают ключ объекта только после успешной загрузки оригинала.
GetObjectпроверяется черезStatили чтение, а результат закрывается.- Публичная политика доступа не открывает приватные оригиналы; для закрытых данных используется короткоживущая предварительно подписанная ссылка.
- Внешний URL строится из публичного адреса, а не из адреса Docker.
- Замена и удаление имеют явный порядок и процедуру очистки объектов-сирот.
Место MinIO рядом с очередью и базой кратко объяснено в статье «Redis, RabbitMQ и S3/MinIO в микросервисах».