
Привет, Хабр! На связи Игорь Шишкин, я руковожу командой R&D в облачном направлении Рег.ру и являюсь архитектором наших сервисов. В скоуп моих задач входит архитектура систем хранения данных. На внутреннем митапе я знакомил команду с возможностями S3 — теперь делюсь с вами. В статье расскажу, как в целом работать с S3-совместимыми хранилищами, зачем их использовать, какие бывают базовые паттерны и что с этим делать. Поехали!
Навигация по тексту
База: что такое S3 и как это работает
S3 — Simple Storage Service. Сама аббревиатура намекает, что это достаточно простой в использовании инструмент.
S3 — это объектное хранилище и одноименный протокол, то есть данные здесь организованы в виде объектов, которые адресуются по ключам.
Для группировки объектов в S3 используются бакеты — по сути, именованная группа объектов. Определенный набор метаданных помогает нам настроить политики доступа к объектам.
API в S3 реализован на базе HTTP-протокола. Это позволяет использовать хранилище для обращения как из наших собственных приложений, где мы контролируем механику доступа и интерфейс взаимодействия, так и напрямую отдавать ссылки на S3 клиенту. Так, доступ к объектам в S3 получаем напрямую из браузера. Ниже посмотрим на примерах, как это делать.
Особенности S3, которые надо учитывать
Примитивная имплементация некоторых операций
В определенных операциях в S3 есть свои нюансы. Например, удаления бакета можно сделать только в том случае, если он пустой. Некоторые приложения сами удаляют объекты, проходя их по одному, даже если бакет не пустой. В S3 не предусмотрено механики для рекурсивного удаления, потому что там нет рекурсии, структура объектов плоская и без «папок». При этом бакет — это отдельная сущность. Приходится заботиться о том, чтобы при его удалении внутри не было объекта.
Отсутствие информация о потреблении в готовом виде
В S3 нет статистики для клиента через S3 API, которая предоставит информацию о количестве объектов в бакете и их суммарном размере. Приходится обходить весь бакет с объектами и спрашивать отдельно у каждого: «Сколько ты весишь?».
Особенности операций в зависимости от SDK
При работе с S3 следует учитывать локальные особенности клиентских библиотек. Мы можем использовать официальный SDK от Amazon (AWS) или сторонний, например, MinIO. В MinIO SDK, чтобы положить объект в бакет, нужно знать его размер. В AWS SDK — нет. Иногда мы получаем объект напрямую от клиента и банально не знаем, сколько он занимает.
Адресация объектов зависит от настроек конкретной инсталляции S3
В S3 предусмотрены два варианта доступа к бакетам:
path-style, когда мы указываем адрес
{endpoint}/{название бакета}/{путь к объекту}
;domain-style или dns-style, в котором имя бакета является частью адреса endpoint’а.
Выбор способа доступа к бакету зависит от того, как сконфигурирована конкретная инсталляция в S3, что за приложение реализует S3-хранилище, и как оно выглядит в итоге, потому что это отдается на откуп вендору S3-решения.
Поговорили об особенностях — теперь разберем реальные примеры.
Что можно делать с S3: кейсы
Посмотрим, что можно делать с S3, какие существуют сценарии использования и как это работает.
Кейс 1: хранение объектов
Начнем с простого — хранения объектов. В нашем случае примеры описаны на Go, но существуют клиенты и для Python, и для других языков.
_, err := client.PutObject(&s3.PutObjectInput{
Bucket: aws.String("my-bucket"),
Key: aws.String("my-object.txt"),
Body: strings.NewReader("test-data"),
})
if err != nil {
// ....
}
resp, err := client.GetObject(&s3.GetObjectInput{
Bucket: aws.String("my-bucket"),
Key: aws.String("my-object.txt"),
})
if err != nil {
// ....
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
// ....
}
Достаточно знать всего три вещи, чтобы положить объект в бакет: как называется бакет, с каким ключом кладем объект и какое содержимое у этого объекта. Вот минимально необходимый набор. Для получения объекта почти то же самое: нам нужны название бакета и ключ объекта.
Кейс 2: раздача статики направо и налево
Допустим, мы хотим сделать сайт и раздавать на нем статику: картинки, видео, гифки, или что угодно — еще через CSS и JavaScript. Для этого, как и в первом кейсе, мы кладем объект, но указываем для него политику доступа AСL(access control list) с конкретным пресетом, который называется public-read.
_, err := client.PutObject(&s3.PutObjectInput{
Bucket: aws.String("my-bucket"),
Key: aws.String("my-object.txt"),
Body: strings.NewReader("test-data"),
ACL: aws.String("public-read"),
})
if err != nil {
// ....
}
Объекты, которые создаются с этим AСL, доступны всем без аутентификации по конкретному адресу на чтение.

Кейс 3: раздача статики, но только тем, кому нужно
Часто мы не хотим давать доступ всем подряд пользователям без аутентификации, а хотим дать его посетителям нашего сайта, генерируя ссылку при обращении к конкретной странице, например. В этом случае подойдет механизм Presigned URL. С ним мы должны подписать каждую ссылку, которую выдаем на объект в нашем бакете. Эта подпись и станет способом аутентификации.
req, _ := client.GetObjectRequest(&s3.GetObjectInput{
Bucket: aws.String("my-bucket"),
Key: aws.String("my-object.txt"),
})
url, err := req.Presign(300 * time.Second)
if err != nil {
// ...
}

Подписывая конкретный запрос, мы подписываем в том числе конкретное действие. Сгенерировали ссылку, в ней подписано действие — GET, путь до объекта. Если отправить на этот адрес запрос с другой операцией, например HEAD, мы получим ошибку 403 Forbidden и сервис S3 не авторизует эту операцию.
Создание подписи в presigned URL происходит в оффлайн-режиме, т.е. нам не нужно обращаться к S3-хранилищу, когда вызывается метод Presign()
. Операция происходит исключительно на стороне клиента. Побочный эффект создания ссылки без обращения к S3 — мы не знаем, существует ли такой ключ в бакете и можем сделать ссылку на несуществующий объект.
Еще немного о методе Presigned. Выражение 300 * time.Second
позволяет задать время жизни подписи (фактически — ссылки). По прошествии этого времени операция на странице станет недоступной и пользователь получит 403 ошибку.
Кейс 3.1.: создание/удаление объектов внешним приложением сразу в S3
Допустим, мы хотим позволить клиенту через форму залить файл. Для этого действия генерируем presigned URL с действием PUT.
req, _ := client.PutObjectRequest(&s3.PutObjectInput{
Bucket: aws.String("my-bucket"),
Key: aws.String("my-object-to-upload-by-client.txt"),
})
url, err := req.Presign(300 * time.Second)
if err != nil {
// ...
}
req, _ := client.DeleteObjectRequest(&s3.DeleteObjectInput{
Bucket: aws.String("my-bucket"),
Key: aws.String("my-object-to-delete-by-client.txt"),
})
url, err := req.Presign(300 * time.Second)
if err != nil {
// ...
}
Используем PutObjectRequest для метода PutObject. DeleteObjectRequest — для метода DeleteObject соответственно.
Кейс 4: заголовки при получении объекта
В HTTP-протоколе есть ряд заголовков, которые позволяют триггерить некоторые вещи на стороне клиента. Например, реагировать на конкретный контент. Если бы мы вернули заголовок Content-Type: image/jpeg
, браузер попытался бы отрисовать то, что он получит, в виде картинки.
req, _ := client.GetObjectRequest(&s3.GetObjectInput{
Bucket: aws.String("my-bucket"),
Key: aws.String("my-object.txt"),
ResponseContentType: aws.String("application/json"),
ResponseContentDisposition: aws.String("attachment; filename=test-file.txt"),
})
url, err := req.Presign(300 * time.Second)
if err != nil {
// ...
}

Заголовок Content-Disposition
отвечает за то, как воспринимать этот контент. Есть два варианта — attachment
и intine
. В случае с attachment
браузер не будет пытаться отобразить контент, а сразу начнет скачивание и потому может передать ему параметр filename
с указанием на имя скачиваемого файла.
Кейс 5: автоматическое удаление объектов
Иногда мы хотим автоматически чистить бакет. Например, от данных, которые перестают быть нужными через какой-то период времени.
{
"Rules": [
{
"ID": "DeleteOldDocs",
"Filter": {
"Prefix": "data/"
},
"Status": "Enabled",
"Expiration": {
"Days": 30
}
}
]
}
aws s3api put-bucket lifecycle -bucket my-bucket -lifecycle-configuration file://bucket-lifecycle.json
Тогда можно задать политику, которая производила бы автоматически поиск объектов и их удаление из бакета по каким-то критериям. Это называется lifecycle-policy, например так можно реализовать удаление данных спустя заданный интервал. Для политики можно указать и где искать объекты — через префикс ключей.
Кейс 6: версионирование объектов
В S3-бакетах можно включить версионирование. Оно позволяет сохранять несколько версий одного и того же объекта по определенному пути. Когда мы заливаем объект в бакет без версионирования впервые, он сохраняется без каких-то особенностей, последующая запись в тот же ключ перезаписывает объект целиком. Включив версионирование на каждую новую попытку перезаписи мы будем порождать отдельный объект, который идентифицируется по VersionID с тем же ключом.
versions, err := client.ListObjectVersions(&s3.ListObjectVersionsInput{
Bucket: aws.String("my-bucket"),
Prefix: aws.String("my-object.txt"),
})
if err != nil {
// ...
}
req, _ := client.GetObjectRequest(&s3.GetObjectInput{
Bucket: aws.String("my-bucket"),
Key: aws.String(*versions.Versions[0].Key),
VersionId: aws.String(*versions.Versions[0].VersionId),
})
url, err := req.Presign(300 * time.Second)
if err != nil {
// ...
}
aws s3api put-bucket-versioning -bucket my-bucket -versioning-configuration Status=Enabled
Но важно помнить, что использование версий — вещь специфичная, и зачастую нам может быть проще сделать это на стороне нашего приложения и не полагаться на конкретную систему хранения с конкретным протоколом (тот же S3).
Кейс 7: CAS — Сontent Addressable Storage
Часто нам приходится работать с данными, обладающими достаточно низкой, но не нулевой кардинальностью. Было бы неплохо иметь возможность дедупликации. Аналога хардлинков в S3 нет, но на помощь нам может прийти такой паттерн, как CAS — это архитектурный паттерн для адресации данных в хранилище (будь то файловая система или объектное хранилище). Он позволяет использовать уникальное (относительно содержимого) свойство объекта в качестве ключа доступа к нему. В примере приведена плоская структура объектов с использованием SHA256 от содержимого объектов в качестве их ключей. Бонусом мы получаем сразу и контрольные суммы, которые относительно не сложно проверить при необходимости.

Однако в этом паттерне нам приходится держать на своей стороне связи объектов с другими сущностями и, скорее всего, в базе данных. При большом количестве объектов таблица увеличивается и значительно занимает место, особенно в части индексов. Если данные с достаточно низкой кардинальностью — эта работа окупается. Если каждый объект уникален и дубли не встречаются, паттерн не имеет смысла, потому что сэкономить на дедупликации всё равно не получится.
Кейс 8: монтирование бакетов, или почему этого кейса нет 🙂
Один из вариантов использования S3-бакетов — монтирование в виде обычной POSIX-совместимой файловой системы. При работе с S3 мы учитываем особенности латентности. Например, не каждый GET-запрос на хранилище будет обслужен за максимально короткое время, и это нормально — такая природа объектных хранилищ.
Если мы захотим сделать бакет доступным в качестве файловой системы, получится неловкая ситуация — все достаточно мягкие гарантии S3-протокола мы начинаем перекладывать на систему, в которой обычно требуются более строгие гарантии. Становятся важны: латентность доступа и гарантированность записи, потому что в синхронном IO (input/output) процесс не выйдет из заблокированного состояния, пока мы не запишем. Кроме того, бываюта нужны различные фичи файловых систем, например, блокировки, хардлинки, симлинки.

Всё начинает работать не так, как хотелось. В проекции POSIX-совместимой файловой системы операция записи становится сложной. Мы должны создать файл с метаданными (например, mode), записать метаданные, связанные с владельцем этого файла. Каждая из этих операций — атомарная сама по себе. Но в рамках системного вызова open — это три последовательные записи на стороне S3. Так же и с блоками, где каждая запись может приводить к обновлению метаданных этого файла. Зачастую об этом позаботились разработчики конкретного драйвера для монтирования S3, но бывает, что и там что-то идет не так.
Закрытие файлового дескриптора — это финализирование записи. Такая операция должна сигнализировать VFS, что работа с файлом завершена. И, если данных накопилось достаточно много, то их запись может занять существенное время при прохождении всей цепочки — драйвер, клиент, сервис. Получается ненадежно и долго.
Хочется призвать всех причастных к S3 монтировать бакеты, только если вы точно знаете, зачем вы это делаете, и на что подписываетесь 🙂
Небольшие выводы
В статье познакомились с базовыми возможностями S3 и разобрали пару реальных кейсов. Теперь давайте подведем небольшие итоги. S3 — это отличный инструмент с эластичным хранилищем и простотой доступа. Многие решения, реализуемые S3, сочетают такие плюсы как гибкость и масштабируемость, которые помогут с долгосрочным хранением больших объемов данных.
Как и у всех инструментов, у S3 есть особенности, которые стоит учитывать. Я искренне верю, что если понимать особенности функционирования самого S3, то использование этого протокола не вызовет существенных проблем и позволит реализовать самые смелые идеи в части хранения данных.
А протестировать S3 в облаке Рег.ру можно здесь.
На этой ноте мы заканчиваем — спасибо, что дочитали!