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

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

Чем 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. «Папка» существует ровно пока есть хотя бы один объект с таким префиксом.

Гарантии консистентности

До декабря 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в двадцать раз дешевлечасы–суткиХранение от 7 лет, compliance
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 в принципе не должен видеть незашифрованные данные.

Передача данных по сети — всегда через HTTPS. Это не настраивается, так работает по умолчанию.

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 дней — иначе их не видно в обычном листинге, но за них всё равно платится.

Lifecycle policy — стандартная часть настройки любого production-bucket.

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 дней».

На практике вручную управлять 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 — плоский namespace объектов с HTTP-доступом. Нет папок, нет rename, нет seek. За счёт этого масштабируется до триллионов объектов.
  • Три понятия: bucket (глобально-уникальный контейнер), объект (байты + метаданные), ключ (строка-адрес объекта).
  • С 2020 года S3 даёт strong read-after-write consistency — данные после записи доступны мгновенно.
  • Классы хранения различаются ценой, не скоростью (кроме Glacier Flexible/Deep). Горячие данные — Standard, редко используемые — IA или Glacier.
  • Версионирование защищает от случайного удаления и перезаписи. Включать для любого production-хранилища с пользовательскими файлами.
  • Lifecycle policy — автоматически переводит объекты в холодный класс и удаляет их. Необходима для контроля расходов и управления версиями.
  • Presigned URL позволяет загружать и скачивать файлы напрямую из S3, минуя backend. Время жизни — минимально нужное.
  • Multipart upload — для файлов больше 100 МБ. SDK управляют им автоматически.

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

  • Spring + AWS SDK v2 для S3 — конкретный клиентский код для Java.
  • Operations: резервные копии, репликация, затраты — операционная сторона S3.