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

Когда нужно хранить файлы пользователей, видео, резервные копии или логи — первая мысль бывает «поставим NFS» или «сохраним на диск сервера». Это работает до какого-то предела, а потом начинаются проблемы: диск заканчивается, файлы не видны другим серверам, сделать резервную копию всего тяжело. Object storage решает именно эти проблемы — и устроен совсем иначе, чем файловая система.

bucket shop-media: один плоский список ключей, папок внутри нет avatars/u-7/ava.jpg logs/2026-05-14.log products/2026/05/cover-3.jpg products/2026/05/cover-4.jpg products/2026/06/cover-9.jpg products/2026/05/cover-3.jpgproducts/2026/05/cover-4.jpg ListObjects, prefix=products/2026/05/сравнение — по началустроки, не по каталогу совпало 2 ключа из 5 mkdir здесь не нужен тот же bucket, ключ report.pdf, версионирование включено versionId=v1PUT report.pdf versionId=v2PUT тот же ключ delete markerDELETE report.pdf GET report.pdf → 404, GET report.pdf?versionId=v1 → отдаёт первую загрузку

Ключ — обычная строка, а «папка» существует ровно как общий префикс: листинг сравнивает начало строки, и как только оба объекта удалены, префикса больше нет. При включённом версионировании повторная загрузка того же ключа не затирает предыдущую, а кладёт сверху новую версию; удаление кладёт сверху 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>

Три правила здесь решают три разные проблемы:

  1. Логи переводятся в холодный класс через 30 дней, в архив — через 90, удаляются через год.
  2. Старые версии (при включённом версионировании) удаляются через 30 дней после замены.
  3. Незавершённые многочастные загрузки (см. ниже) удаляются через 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 ТБ.

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