Когда нужно хранить файлы пользователей, видео, резервные копии или логи — первая мысль бывает «поставим NFS» или «сохраним на диск сервера». Это работает до какого-то предела, а потом начинаются проблемы: диск заканчивается, файлы не видны другим серверам, сделать резервную копию всего тяжело. Object storage решает именно эти проблемы — и устроен совсем иначе, чем файловая система.
Ключ — обычная строка, а «папка» существует ровно как общий префикс: листинг сравнивает начало строки, и как только оба объекта удалены, префикса больше нет. При включённом версионировании повторная загрузка того же ключа не затирает предыдущую, а кладёт сверху новую версию; удаление кладёт сверху delete marker, из-под которого старые версии по-прежнему читаются по versionId.
Чем object storage отличается от файловой системы
На обычном диске файлы лежат в папках, папки вложены друг в друга. Есть операции rename, seek (прочитать кусок с середины), атомарное переименование директории.
Object storage — другая модель:
- Нет папок. Есть единое плоское пространство имён, где у каждого файла — ключ-строка.
- Нет
renameиseek. Объект читается и записывается целиком (или диапазоном байт). - Доступ — только через HTTP API.
Именно поэтому object storage масштабируется до триллионов объектов и эксабайт данных: нет накладных расходов файловой системы, нет блокировок папок.
Три типа хранилищ в одной таблице:
| Тип | Модель | Доступ | Когда использовать |
|---|---|---|---|
| Block storage (EBS, локальные диски) | Сырые блоки | Драйвер файловой системы | Базы данных, ОС |
| File storage (NFS, EFS) | Папки и файлы | POSIX: read/write/seek | Шаринг файлов между серверами |
| Object storage (S3, MinIO, R2) | Плоский namespace, ключ → объект | HTTP API | Фото, видео, резервные копии, статика |
Amazon S3 (Simple Storage Service) — первый коммерчески успешный object storage и де-факто стандарт. «S3-совместимый API» сегодня понимают MinIO, Cloudflare R2, Yandex Object Storage, Backblaze B2, Google Cloud Storage и десятки других. Дальше в статье говорим о модели S3 — она применима ко всем ним.
Bucket, объект и ключ
Три главных понятия:
s3://my-bucket/products/2026/05/cover-3.jpg
│ │ │
│ └─ ключ (key) ─────┘
└─ bucket
Bucket — корневой контейнер. Имя глобально-уникально в AWS S3 (другие провайдеры ограничивают уникальность в рамках аккаунта). У каждого bucket — один регион.
Объект — единица хранения. Состоит из содержимого (байты), системных и пользовательских метаданных, и идентификатора версии (если версионирование включено).
Ключ — это просто строка, например products/2026/05/cover-3.jpg. S3 никакой иерархии внутри не строит. Слэши в ключе — это удобство для группировки при листинге, не структура каталогов. Консоль AWS показывает «папки» — это только визуализация поверх ключей.
Практическое следствие: в S3 нет mkdir. «Папка» существует ровно пока есть хотя бы один объект с таким префиксом.
Вся «иерархия» сводится к сравнению начала строки:
живой пример
import java.util.Map;
import java.util.TreeMap;
public class KeySpaceDemo {
public static void main(String[] args) {
TreeMap<String, Integer> bucket = new TreeMap<>();
bucket.put("avatars/u-7/ava.jpg", 18_400);
bucket.put("products/2026/05/cover-3.jpg", 240_100);
bucket.put("products/2026/05/cover-4.jpg", 198_700);
bucket.put("products/2026/06/cover-9.jpg", 205_300);
bucket.put("logs/2026-05-14.log", 9_120);
String prefix = "products/2026/05/";
System.out.println("листинг с prefix=" + prefix);
for (Map.Entry<String, Integer> entry : bucket.subMap(prefix, prefix + "\uffff").entrySet()) {
System.out.println(" " + entry.getKey() + " " + entry.getValue() + " Б");
}
bucket.remove("products/2026/05/cover-3.jpg");
bucket.remove("products/2026/05/cover-4.jpg");
System.out.println("остался ли префикс products/2026/05/ после удаления обоих объектов: "
+ !bucket.subMap(prefix, prefix + "\uffff").isEmpty());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Неделя бесплатно →
Гарантии консистентности
До декабря 2020 года S3 был eventually consistent: после загрузки нового файла попытка его сразу прочитать могла вернуть старую версию или ошибку 404. Это рождало трудноуловимые баги.
С декабря 2020 — strong read-after-write consistency: прочитал после записи — всегда получишь актуальные данные, гарантированно. Это работает и для листинга: список объектов тоже консистентен.
MinIO и Yandex Object Storage тоже дают strong consistency. Если используете менее распространённый S3-совместимый сервис — стоит проверить его документацию отдельно.
Классы хранения
Не все данные нужны одинаково быстро. Логи прошлого года читают редко, а активные аватарки пользователей — постоянно. S3 предлагает классы хранения с разным балансом цены хранения и цены чтения:
| Класс | Стоимость хранения | Доступность | Когда |
|---|---|---|---|
| Standard | базовая | мгновенно, без доплаты | Активные данные |
| Standard-IA | примерно вдвое дешевле | мгновенно, доплата за чтение | Резервные копии, редкий доступ |
| One Zone-IA | чуть дешевле IA | мгновенно, доплата за чтение | То же, но в одной зоне доступности |
| Glacier Instant Retrieval | примерно в пять-шесть раз дешевле | мгновенно, доплата | Архивы, но иногда нужен быстрый доступ |
| Glacier Flexible Retrieval | примерно в шесть раз дешевле | минуты–часы | Долгосрочный архив |
| Glacier Deep Archive | примерно в двадцать раз дешевле | часы–сутки | Архив на годы, требования по срокам хранения |
| Intelligent-Tiering | базовая + плата за мониторинг | автоматически | Когда не знаете паттерн доступа |
Важный момент: все классы, кроме Glacier Flexible и Deep Archive, отдают объект мгновенно. Разница только в цене, не в скорости. Перевести объект в более холодный класс легко; обратно — требует нового копирования.
Класс указывается при загрузке через заголовок x-amz-storage-class, либо устанавливается автоматически через lifecycle policy.
Версионирование
По умолчанию новая загрузка файла с тем же ключом перезаписывает предыдущий. Включите версионирование на уровне bucket — и каждая загрузка создаёт новую версию, старые сохраняются:
# Загрузили файл первый раз
PUT s3://bucket/report.pdf → versionId=v1
# Загрузили новую версию
PUT s3://bucket/report.pdf → versionId=v2 # v1 остаётся доступной
# Удалили файл
DELETE s3://bucket/report.pdf → создаётся "delete marker", v1 и v2 остаются
После удаления GET s3://bucket/report.pdf вернёт 404 — S3 читает delete marker. Но GET s3://bucket/report.pdf?versionId=v1 по-прежнему работает.
Версионирование — обязательный атрибут production-хранилища с пользовательскими файлами. Оно защищает от случайного удаления, от атак с шифрованием данных (злоумышленник не сможет физически уничтожить версии) и от багов, перезаписавших контент неправильными данными.
Минус: старые версии копятся — их подчищает lifecycle policy (см. ниже).
Шифрование
S3 шифрует данные на своей стороне — server-side encryption (SSE). С января 2023 года SSE-S3 применяется по умолчанию ко всем новым объектам во всех bucket, не только в свежесозданных, — AWS управляет ключами автоматически, ничего настраивать не нужно.
Когда нужен больший контроль:
- SSE-KMS — ключи в AWS KMS (вашем), есть аудит-лог всех обращений к ключу. Используют в финансах и медицине, где требуется audit trail именно на уровне ключей шифрования.
- SSE-C — вы передаёте свой ключ с каждым запросом. Очень специфичные compliance-требования.
- Client-side encryption — шифруете до отправки. Когда S3 в принципе не должен видеть незашифрованные данные.
Обращаться к хранилищу можно и по обычному HTTP, поэтому «шифрование в пути» стоит не оставлять на умолчания, а закрепить правилом доступа к бакету: запретить любые запросы, пришедшие не по защищённому соединению. Иначе достаточно одной библиотеки со старым адресом, чтобы файлы поехали открытым текстом.
Lifecycle policy — автоматическое управление данными
Данные растут. Логи месячной давности никто не читает, но они занимают дорогое хранилище Standard. Lifecycle policy — это правила, по которым S3 автоматически переводит объекты в более дешёвый класс или удаляет их.
Типичная конфигурация для хранилища логов:
<LifecycleConfiguration>
<Rule>
<ID>logs-retention</ID>
<Filter><Prefix>logs/</Prefix></Filter>
<Status>Enabled</Status>
<Transition>
<Days>30</Days>
<StorageClass>STANDARD_IA</StorageClass>
</Transition>
<Transition>
<Days>90</Days>
<StorageClass>GLACIER</StorageClass>
</Transition>
<Expiration><Days>365</Days></Expiration>
</Rule>
<Rule>
<ID>cleanup-old-versions</ID>
<Status>Enabled</Status>
<NoncurrentVersionExpiration>
<NoncurrentDays>30</NoncurrentDays>
</NoncurrentVersionExpiration>
</Rule>
<Rule>
<ID>abort-incomplete-uploads</ID>
<Status>Enabled</Status>
<AbortIncompleteMultipartUpload>
<DaysAfterInitiation>7</DaysAfterInitiation>
</AbortIncompleteMultipartUpload>
</Rule>
</LifecycleConfiguration>
Три правила здесь решают три разные проблемы:
- Логи переводятся в холодный класс через 30 дней, в архив — через 90, удаляются через год.
- Старые версии (при включённом версионировании) удаляются через 30 дней после замены.
- Незавершённые многочастные загрузки (см. ниже) удаляются через 7 дней — иначе их не видно в обычном листинге, но за них всё равно платится.
Presigned URL — прямая загрузка без прогона через сервер
Типичная задача: пользователь загружает аватарку. Очевидное решение — клиент отправляет файл на ваш backend, backend кладёт его в S3. Проблема: файл проходит через ваш сервер, нагружая канал и процессор.
Лучше — presigned URL: backend генерирует временную подписанную ссылку с коротким временем жизни (10–15 минут), клиент загружает файл напрямую в S3. Сервер в передаче данных не участвует.
Серверная часть — генерация ссылки:
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
import java.time.Duration;
// S3Presigner инжектится как бин
PresignedPutObjectRequest req = s3Presigner.presignPutObject(b -> b
.signatureDuration(Duration.ofMinutes(10))
.putObjectRequest(p -> p
.bucket("avatars")
.key("users/" + userId + "/avatar.jpg")
.contentType("image/jpeg")));
return req.url().toString();
// presignClient — *s3.PresignClient, созданный один раз при старте
presignReq, err := presignClient.PresignPutObject(context.Background(),
&s3.PutObjectInput{
Bucket: aws.String("avatars"),
Key: aws.String(fmt.Sprintf("users/%s/avatar.jpg", userID)),
ContentType: aws.String("image/jpeg"),
},
s3.WithPresignExpires(10*time.Minute),
)
if err != nil {
return "", fmt.Errorf("presign: %w", err)
}
return presignReq.URL, nil
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
// s3Client — экземпляр S3Client, созданный один раз
const command = new PutObjectCommand({
Bucket: "avatars",
Key: `users/${userId}/avatar.jpg`,
ContentType: "image/jpeg",
});
const url = await getSignedUrl(s3Client, command, { expiresIn: 600 });
return url;
# s3_client — boto3.client("s3"), созданный один раз
url = s3_client.generate_presigned_url(
"put_object",
Params={
"Bucket": "avatars",
"Key": f"users/{user_id}/avatar.jpg",
"ContentType": "image/jpeg",
},
ExpiresIn=600,
)
return url
Клиент загружает файл напрямую по полученной ссылке:
fetch(presignedUrl, {
method: 'PUT',
body: fileBlob,
headers: { 'Content-Type': 'image/jpeg' }
});
То же работает для скачивания (presignGetObject): раздавать приватные файлы с ограниченным временем доступа.
Несколько вещей о безопасности presigned URL:
- Ставьте минимальное время жизни: 10 минут на загрузку, 5 минут на скачивание.
- В подпись включены bucket, ключ, метод (PUT/GET) и content-type. Клиент не может изменить ни одно из них — иначе подпись не пройдёт проверку.
- Ограничить размер файла можно через условия S3 POST policy.
Multipart upload — файлы больше 100 МБ
Обычный PUT принимает файл размером до 5 ГБ. Для больших файлов есть multipart upload: файл разбивается на части (от 5 МБ до 5 ГБ каждая), части загружаются параллельно, в конце S3 собирает их в один объект.
1. InitiateMultipartUpload → uploadId
2. UploadPart (часть 1, uploadId) → ETag-1
3. UploadPart (часть 2, uploadId) → ETag-2
... параллельно
4. CompleteMultipartUpload (uploadId, [ETag-1, ETag-2, ...]) → объект готов
Зачем это нужно:
- Параллельность — части загружаются одновременно, ограничена только пропускная способность сети.
- Возобновление — если часть 5 из 10 не загрузилась, повторяется только она.
- Размер — с multipart максимум 5 ТБ вместо 5 ГБ.
Подводный камень: если загрузка прервалась между InitiateMultipartUpload и CompleteMultipartUpload, части остаются в хранилище. В обычном листинге их не видно, но платить за них придётся. Поэтому в lifecycle policy всегда добавляют правило «удалять незавершённые загрузки через 7 дней».
Механику сборки видно на чистой Java: каждая часть считает свою контрольную сумму, а ETag готового объекта — это сумма от склеенных сумм частей плюс их количество через дефис. Поэтому у многочастного объекта ETag не совпадает с MD5 исходного файла, и сравнивать их бесполезно:
живой пример
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Random;
public class MultipartDemo {
static final int PART_SIZE = 5 * 1024 * 1024;
public static void main(String[] args) throws Exception {
byte[] file = new byte[12 * 1024 * 1024];
new Random(42).nextBytes(file);
List<byte[]> partDigests = new ArrayList<>();
for (int offset = 0; offset < file.length; offset += PART_SIZE) {
int length = Math.min(PART_SIZE, file.length - offset);
MessageDigest md5 = MessageDigest.getInstance("MD5");
md5.update(file, offset, length);
byte[] digest = md5.digest();
partDigests.add(digest);
System.out.printf("часть %d: %d Б, ETag %s%n",
partDigests.size(), length, HexFormat.of().formatHex(digest));
}
MessageDigest combined = MessageDigest.getInstance("MD5");
for (byte[] digest : partDigests) {
combined.update(digest);
}
System.out.println("ETag собранного объекта: "
+ HexFormat.of().formatHex(combined.digest()) + "-" + partDigests.size());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Неделя бесплатно →
На практике вручную управлять multipart не нужно — SDK делают это автоматически:
import software.amazon.awssdk.transfer.s3.S3TransferManager;
import software.amazon.awssdk.transfer.s3.model.Upload;
import java.nio.file.Paths;
// S3TransferManager строится поверх готового S3AsyncClient
S3TransferManager tm = S3TransferManager.builder().s3Client(s3AsyncClient).build();
Upload upload = tm.uploadFile(b -> b
.source(Paths.get("big-video.mp4"))
.putObjectRequest(p -> p.bucket("videos").key("user-42/video.mp4")));
upload.completionFuture().join();
// SDK сам выбирает multipart или обычный PUT в зависимости от размера
// uploader — *manager.Uploader, созданный один раз поверх s3.Client
file, err := os.Open("big-video.mp4")
if err != nil {
return fmt.Errorf("open file: %w", err)
}
defer file.Close()
_, err = uploader.Upload(context.Background(), &s3.PutObjectInput{
Bucket: aws.String("videos"),
Key: aws.String("user-42/video.mp4"),
Body: file,
})
// manager.Uploader сам выбирает multipart при размере > PartSize (по умолчанию 5 МБ)
import { Upload } from "@aws-sdk/lib-storage";
import { createReadStream } from "fs";
// s3Client — экземпляр S3Client
const upload = new Upload({
client: s3Client,
params: {
Bucket: "videos",
Key: "user-42/video.mp4",
Body: createReadStream("big-video.mp4"),
},
});
await upload.done();
// Upload из @aws-sdk/lib-storage автоматически использует multipart
from boto3.s3.transfer import TransferConfig
# s3_client — boto3.client("s3")
config = TransferConfig(multipart_threshold=100 * 1024 * 1024) # 100 МБ
s3_client.upload_file(
Filename="big-video.mp4",
Bucket="videos",
Key="user-42/video.mp4",
Config=config,
)
# boto3 автоматически переключается на multipart при превышении порога
Коротко
- Object storage — плоское пространство ключей с доступом по HTTP: ни папок, ни
rename, ниseek. «Папка» — это общий префикс, и она исчезает вместе с последним объектом. - Три понятия: bucket (глобально-уникальный контейнер с одним регионом), объект (байты плюс метаданные), ключ (строка-адрес).
- С декабря 2020 года действует strong read-after-write consistency: чтение сразу после записи и листинг отдают актуальное.
- Классы хранения различаются ценой, а не скоростью — мгновенно отдают все, кроме Glacier Flexible и Deep Archive; обратный перевод в горячий класс равен новому копированию.
- Версионирование спасает от случайного удаления и перезаписи, но копит версии, поэтому идёт в паре с lifecycle policy — она же убирает брошенные многочастные загрузки.
- Presigned URL уводит файл мимо вашего сервера, multipart нужен от 100 МБ и поднимает потолок с 5 ГБ до 5 ТБ.
Что почитать дальше
- Spring + AWS SDK v2 для S3 — конкретный клиентский код для Java.
- S3 в production: резервные копии, репликация, стоимость — операционная сторона хранилища.
- Object Storage в Yandex Cloud — та же модель у S3-совместимого провайдера, со своими классами хранения.
- IAM в AWS — кто и по какому правилу получает доступ к bucket.