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

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

Папок в объектном хранилище нет: всё, что после имени bucket, — один длинный ключ. Слэши в нём ничего не разделяют, консоль лишь рисует по ним дерево.

Bucket — корневой контейнер. Имя занято не только у вас: в AWS S3 и в Yandex Object Storage оно уникально на весь сервис, так что чужой photos вам уже не достанется. Там, где адрес привязан к аккаунту (Cloudflare R2) или к своей установке (MinIO), имя уникально только внутри неё. У каждого 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());
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"fmt"
	"maps"
	"slices"
	"strings"
)

func withPrefix(bucket map[string]int, prefix string) []string {
	var keys []string
	for _, key := range slices.Sorted(maps.Keys(bucket)) {
		if strings.HasPrefix(key, prefix) {
			keys = append(keys, key)
		}
	}
	return keys
}

func main() {
	bucket := map[string]int{
		"avatars/u-7/ava.jpg":          18_400,
		"products/2026/05/cover-3.jpg": 240_100,
		"products/2026/05/cover-4.jpg": 198_700,
		"products/2026/06/cover-9.jpg": 205_300,
		"logs/2026-05-14.log":          9_120,
	}

	prefix := "products/2026/05/"
	fmt.Println("листинг с prefix=" + prefix)
	for _, key := range withPrefix(bucket, prefix) {
		fmt.Printf("  %s  %d Б\n", key, bucket[key])
	}

	delete(bucket, "products/2026/05/cover-3.jpg")
	delete(bucket, "products/2026/05/cover-4.jpg")
	fmt.Println("остался ли префикс products/2026/05/ после удаления обоих объектов:", len(withPrefix(bucket, prefix)) > 0)
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const bucket = new Map([
  ['avatars/u-7/ava.jpg', 18_400],
  ['products/2026/05/cover-3.jpg', 240_100],
  ['products/2026/05/cover-4.jpg', 198_700],
  ['products/2026/06/cover-9.jpg', 205_300],
  ['logs/2026-05-14.log', 9_120],
]);
const withPrefix = (prefix) => [...bucket.keys()].sort().filter((key) => key.startsWith(prefix));

const prefix = 'products/2026/05/';
console.log('листинг с prefix=' + prefix);
for (const key of withPrefix(prefix)) console.log(`  ${key}  ${bucket.get(key)} Б`);

bucket.delete('products/2026/05/cover-3.jpg');
bucket.delete('products/2026/05/cover-4.jpg');
console.log('остался ли префикс products/2026/05/ после удаления обоих объектов: ' + (withPrefix(prefix).length > 0));
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

bucket = {
    "avatars/u-7/ava.jpg": 18_400,
    "products/2026/05/cover-3.jpg": 240_100,
    "products/2026/05/cover-4.jpg": 198_700,
    "products/2026/06/cover-9.jpg": 205_300,
    "logs/2026-05-14.log": 9_120,
}


def with_prefix(prefix: str) -> list[str]:
    return [key for key in sorted(bucket) if key.startswith(prefix)]


prefix = "products/2026/05/"
print("листинг с prefix=" + prefix)
for key in with_prefix(prefix):
    print(f"  {key}  {bucket[key]} Б")

del bucket["products/2026/05/cover-3.jpg"]
del bucket["products/2026/05/cover-4.jpg"]
print("остался ли префикс products/2026/05/ после удаления обоих объектов: " + str(bool(with_prefix(prefix))).lower())
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Пределы, которые надо знать заранее

Модель простая, но у неё есть жёсткие рамки, и половина сюрпризов на старте — из них.

Имя бакета: от 3 до 63 символов, только строчные буквы, цифры, дефис и точка, и оно глобально уникально в пределах провайдера. Точки в имени лучше не использовать вовсе: при адресации через поддомен (bucket.s3.region.amazonaws.com) имя с точкой не покрывается сертификатом, и TLS-проверка падает.

Ключ объекта: до 1024 байт в кодировке UTF-8 — байт, а не символов, поэтому русские имена «съедают» лимит вдвое быстрее. В ключе допустимы любые символы Unicode, но слэш, пробел, #, ?, % и управляющие символы усложняют жизнь ссылкам и инструментам; практическое правило — латиница, цифры, дефис, подчёркивание и слэш как разделитель «каталогов».

Пользовательские метаданные объекта: суммарно до 2 КБ, только заголовки вида x-amz-meta-*, и значения — обязательно в кодировке, пригодной для заголовка HTTP (русский текст придётся кодировать). Метаданные нельзя изменить у существующего объекта: их правят копированием объекта в себя же с новыми метаданными.

Размер одного объекта: до 5 ТБ, но одним запросом (PUT) — не больше 5 ГБ; всё крупнее только составной загрузкой. Минимальный размер части составной загрузки — 5 МБ (кроме последней).

Частота запросов и раскладка ключей

Отсюда же растёт правило про ключи, которое иначе выглядит суеверием. Хранилище масштабируется по префиксам: на один префикс приходится порядка 3500 операций записи и 5500 операций чтения в секунду, и это не квота, а свойство внутреннего разделения. Префикс здесь — начало ключа до разделителя, то есть «каталог».

Практически это значит вот что. Ключи вида uploads/2026-05-07/..., куда в течение дня пишут весь поток, упираются в предел одного префикса. Ключи вида uploads/<хеш первых символов>/2026-05-07/... или uploads/<идентификатор клиента>/... раскладываются по многим префиксам, и общая пропускная способность растёт кратно числу префиксов.

Второе следствие — про порядок. Раньше советовали ставить случайные символы в начало ключа; сейчас это не обязательно (хранилище само разделяет префиксы по нагрузке), но для потока в тысячи запросов в секунду разложить по префиксам всё равно надо, и делают это осознанно: по клиенту, по типу файла, по первым символам идентификатора.

И третье, про которое забывают: список объектов (ListObjectsV2) отдаёт по 1000 ключей за запрос и стоит денег за каждый запрос. Каталог на десять миллионов объектов — это десять тысяч запросов; поэтому «пройти по бакету» никогда не делают в горячем пути, а для отчётов берут готовую выгрузку описи (inventory).

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

Загрузили файл, тут же прочитали и получили 404 или старую версию. До декабря 2020 года так вёл себя S3: он был eventually consistent, согласованным лишь со временем, и это рождало трудноуловимые баги.

С декабря 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.

Отдельно про то, что делает арифметику экономии нелинейной: у холодных классов есть минимальный срок хранения и минимальный оплачиваемый размер.

Минимальный срок: у класса редкого доступа это около 30 дней, у гибкого архивного — около 90, у глубокого архива — около 180. Удалили объект раньше — заплатите как за полный срок. Значит, перекладывать в архив файлы, которые через неделю удалятся по правилу жизненного цикла, — способ увеличить счёт.

Минимальный оплачиваемый размер объекта: у классов редкого доступа и мгновенного архива — порядка 128 КБ, у архивных — порядка 40 КБ плюс служебные накладные расходы на каждый объект. То есть миллион файлов по 10 КБ в архивном классе тарифицируется как миллион файлов по 40 КБ с лишним, и экономия испаряется: за хранение платите больше, чем в горячем классе.

Практический вывод, который надо сделать до написания правила жизненного цикла: холодные классы выгодны для крупных и долгоживущих объектов. Мелочь либо оставляют в горячем классе, либо перед архивированием склеивают в архивы (по дню, по клиенту) — тогда вместо миллиона мелких объектов получается тысяча крупных, и тарификация становится честной.

Версионирование

По умолчанию новая загрузка файла с тем же ключом перезаписывает предыдущий. Включите версионирование на уровне 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, так делают в финансах и медицине. Регламент требует, чтобы ключ никогда не хранился у провайдера: это SSE-C, вы передаёте свой ключ с каждым запросом. А если S3 в принципе не должен видеть незашифрованные данные, шифруют до отправки, это client-side encryption.

Обращаться к хранилищу можно и по обычному HTTP, поэтому «шифрование в пути» стоит не оставлять на умолчания, а закрепить правилом доступа к бакету: запретить любые запросы, пришедшие не по защищённому соединению. Иначе достаточно одной библиотеки со старым адресом, чтобы файлы поехали открытым текстом.

Шифрование в пути закрепляют политикой бакета — она отклоняет любой запрос, пришедший не по HTTPS:

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "DenyInsecureTransport",
    "Effect": "Deny",
    "Principal": "*",
    "Action": "s3:*",
    "Resource": ["arn:aws:s3:::my-bucket", "arn:aws:s3:::my-bucket/*"],
    "Condition": { "Bool": { "aws:SecureTransport": "false" } }
  }]
}

Два обязательных элемента. Запрет (Deny), а не разрешение: запрет действует всегда и перебивает любые разрешения, выданные в другом месте. И два ресурса в списке — сам бакет (для операций вроде получения списка) и всё его содержимое (/*); политика только на содержимое оставляет дыру на операциях уровня бакета.

Тем же условным блоком закрывают и другие требования: s3:x-amz-server-side-encryption — потребовать шифрование при записи, s3:x-amz-acl — запретить публичные права, aws:SourceIp — ограничить сеть.

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>
    <Filter></Filter>
    <Status>Enabled</Status>
    <NoncurrentVersionExpiration>
      <NoncurrentDays>30</NoncurrentDays>
    </NoncurrentVersionExpiration>
  </Rule>
  <Rule>
    <ID>abort-incomplete-uploads</ID>
    <Filter></Filter>
    <Status>Enabled</Status>
    <AbortIncompleteMultipartUpload>
      <DaysAfterInitiation>7</DaysAfterInitiation>
    </AbortIncompleteMultipartUpload>
  </Rule>
</LifecycleConfiguration>

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

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

Пустой <Filter></Filter> во втором и третьем правиле выглядит лишним, но без него S3 отвечает MalformedXML и всю конфигурацию не принимает: каждое правило обязано сказать, на какие объекты оно распространяется, и пустой фильтр означает «на все».

Presigned URL — прямая загрузка без прогона через сервер

Типичная задача: пользователь загружает аватарку. Очевидное решение — клиент отправляет файл на ваш backend, backend кладёт его в S3. Проблема: файл проходит через ваш сервер, нагружая канал и процессор.

Лучше — presigned URL: backend генерирует временную подписанную ссылку с коротким временем жизни (10–15 минут), клиент загружает файл напрямую в S3. Сервер в передаче данных не участвует.

клиент просит ссылку имя и тип файла запрос бэкенд подписывает PUT, 10 минут подписанная ссылка клиент шлёт PUT байты мимо бэкенда прямо в 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 сложнее: он защищён, только если попал в список подписанных заголовков (SignedHeaders). В примерах выше он туда попадает, потому что задан при генерации ссылки; если его не задать, клиент пришлёт содержимое с любым типом.
  • Ограничить размер файла можно через условия S3 POST policy.

Две границы подписи, о которые спотыкаются в проде.

Потолок срока — семь дней. Алгоритм подписи (SigV4) не допускает срока больше 604800 секунд, и попытка подписать «на месяц» падает с ошибкой. Значит, «постоянная ссылка на файл» так не делается: для этого либо публичный доступ через сеть доставки, либо своя ручка в приложении, которая каждый раз выдаёт свежую подпись.

Ссылка умирает вместе с ключами, которыми подписана. Если приложение работает под ролью (в контейнере, на виртуальной машине), то его ключи временные и живут часы; подпись, сделанная такими ключами, перестанет действовать в момент их истечения — даже если в самой ссылке срок ещё не вышел. Отсюда практическое правило: срок подписи держат заведомо меньше остатка жизни ключей (минуты, а не дни), а «долгие» ссылки не выдают вовсе. Проверить это легко по симптому: ссылки внезапно перестают работать все сразу, обычно через час после выката.

Как узнать, что файл появился

Опрашивать бакет в цикле не нужно: хранилище само сообщает о событиях. Настраивается это на бакете — правило «при создании объекта с префиксом uploads/ отправить сообщение» — и получателем может быть очередь, тема подписки, функция или шина событий.

Это штатный способ построить обработку загруженного файла: пользователь положил файл подписанной ссылкой (приложение об этом не знает), хранилище прислало событие, обработчик проверил содержимое, сделал превью и перевёл запись в базе в рабочее состояние. Альтернатива — шаг подтверждения от клиента («я загрузил») — работает, но зависит от клиента: закрыл вкладку, потерял сеть — файл лежит, а запись осталась в промежуточном состоянии.

Три вещи, которые к событиям прилагаются. Доставка не гарантирует единственности: одно и то же событие может прийти дважды, поэтому обработчик обязан быть идемпотентным. Событие содержит ключ и размер, но не содержимое — файл надо прочитать самому. И события не приходят задним числом: если обработчик лежал, события ушли в очередь (если получатель — очередь) или потерялись (если функция без очереди), поэтому между хранилищем и обработчиком почти всегда ставят очередь.

Multipart upload — файлы больше 100 МБ

Обычный PUT принимает файл размером до 5 ГБ. Для больших файлов есть multipart upload: файл разбивается на части (от 5 МБ до 5 ГБ каждая), части загружаются параллельно, в конце S3 собирает их в один объект.

Сотня мегабайт в заголовке — это рекомендация самого AWS: с этого размера обычный PUT уже стоит менять на многочастную загрузку. Библиотеки переключаются гораздо раньше: у Java и Python порог по умолчанию 8 МиБ, у Go — 5 МиБ. В примерах ниже видно, где этот порог задан явно, а где взят из умолчаний.

InitiateMultipartUpload получаем uploadId UploadPart × N части грузятся параллельно, каждая даёт ETag CompleteMultipartUpload список ETag — и объект собран

Большой файл грузится частями: обрыв стоит одной части, а не всей загрузки. Незавершённые загрузки занимают место и в списке объектов не видны — их чистят правилом.

Зачем это нужно:

  • Параллельность — части загружаются одновременно, ограничена только пропускная способность сети.
  • Возобновление — если часть 5 из 10 не загрузилась, повторяется только она.
  • Размер — с multipart максимум 5 ТБ вместо 5 ГБ. Частей при этом не больше 10 000, и размер части приходится подбирать под файл: у файла, близкого к потолку, часть не может быть меньше примерно 537 МБ, иначе в лимит по числу частей не уложиться.

Подводный камень: если загрузка прервалась между InitiateMultipartUpload и CompleteMultipartUpload, части остаются в хранилище. В обычном листинге их не видно, но платить за них придётся. Поэтому в lifecycle policy всегда добавляют правило «удалять незавершённые загрузки через 7 дней».

Механику сборки видно на маленькой программе: каждая часть считает свою контрольную сумму, а 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());
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"crypto/md5"
	"encoding/hex"
	"fmt"
)

const partSize = 5 * 1024 * 1024

func main() {
	file := make([]byte, 12*1024*1024)
	for i := range file {
		file[i] = byte(i % 251)
	}

	var partDigests [][]byte
	for offset := 0; offset < len(file); offset += partSize {
		length := min(partSize, len(file)-offset)
		digest := md5.Sum(file[offset : offset+length])
		partDigests = append(partDigests, digest[:])
		fmt.Printf("часть %d: %d Б, ETag %s\n", len(partDigests), length, hex.EncodeToString(digest[:]))
	}

	combined := md5.New()
	for _, digest := range partDigests {
		combined.Write(digest)
	}
	fmt.Printf("ETag собранного объекта: %s-%d\n", hex.EncodeToString(combined.Sum(nil)), len(partDigests))
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const { createHash } = require('node:crypto');

const PART_SIZE = 5 * 1024 * 1024;
const file = Buffer.alloc(12 * 1024 * 1024);
for (let i = 0; i < file.length; i++) file[i] = i % 251;

const partDigests = [];
for (let offset = 0; offset < file.length; offset += PART_SIZE) {
  const part = file.subarray(offset, Math.min(offset + PART_SIZE, file.length));
  const digest = createHash('md5').update(part).digest();
  partDigests.push(digest);
  console.log(`часть ${partDigests.length}: ${part.length} Б, ETag ${digest.toString('hex')}`);
}

const combined = createHash('md5');
for (const digest of partDigests) combined.update(digest);
console.log(`ETag собранного объекта: ${combined.digest('hex')}-${partDigests.length}`);
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

import hashlib

PART_SIZE = 5 * 1024 * 1024
file = bytes(i % 251 for i in range(12 * 1024 * 1024))

part_digests = []
for offset in range(0, len(file), PART_SIZE):
    part = file[offset:offset + PART_SIZE]
    digest = hashlib.md5(part).digest()
    part_digests.append(digest)
    print(f"часть {len(part_digests)}: {len(part)} Б, ETag {digest.hex()}")

combined = hashlib.md5()
for digest in part_digests:
    combined.update(digest)
print(f"ETag собранного объекта: {combined.hexdigest()}-{len(part_digests)}")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

На практике вручную управлять multipart не нужно — SDK делают это автоматически:

import software.amazon.awssdk.transfer.s3.S3TransferManager;
import software.amazon.awssdk.transfer.s3.model.Upload;
import java.nio.file.Paths;

// s3AsyncClient должен быть собран с .multipartEnabled(true) (или это CRT-клиент):
// без этого TransferManager отправит файл одним запросом
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 уже сам решает по размеру: части или обычный 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 при превышении порога
Дополнительно: при первом чтении можно пропустить

Глубже: CORS: без него загрузка из браузера не работаетрасширенное

Первая попытка загрузить файл по presigned URL прямо из браузера заканчивается одинаково: в консоли blocked by CORS policy, а в сети видно запрос OPTIONS, на который бакет ответил отказом. Сервис всё сделал правильно, подпись верна, а браузер файл не отправил.

Причина в правиле браузера: страница с shop.example.com не может слать запросы на bucket.s3.example.com, пока тот сам не разрешит. Перед «необычным» запросом (а PUT с заголовком Content-Type необычен) браузер отправляет предварительный OPTIONS и ждёт заголовки Access-Control-Allow-*. У бакета своей настройки CORS нет, и он отвечает отказом. Программа на сервере, curl и мобильное приложение этого правила не знают, поэтому у них всё работает, и разработчик долго ищет ошибку не там.

Настройка живёт на бакете и разрешает конкретный источник, методы и заголовки:

[{
  "AllowedOrigins": ["https://shop.example.com"],
  "AllowedMethods": ["PUT", "GET"],
  "AllowedHeaders": ["*"],
  "ExposeHeaders": ["ETag"],
  "MaxAgeSeconds": 3000
}]

ExposeHeaders: ETag нужен для multipart-загрузки: браузер обязан прочитать ETag каждой части, чтобы потом собрать файл, а без этой строки заголовок ему не покажут. MaxAgeSeconds разрешает кэшировать ответ на OPTIONS, иначе перед каждой частью будет лишний запрос. Звёздочка в AllowedOrigins на закрытых бакетах недопустима: она разрешает загрузку с любого сайта, а presigned ссылка, утёкшая в чужую страницу, станет рабочей.

И вторая половина той же ошибки: presigned URL подписывается вместе с заголовками, которые сервис включил в подпись. Если сервис подписал Content-Type: image/jpeg, а браузер отправил PUT без этого заголовка или с другим, бакет ответит 403 SignatureDoesNotMatch, и CORS тут ни при чём. Клиент отправляет ровно те заголовки, что были подписаны, и никаких сверх.

Коротко

  • Object storage — плоское пространство ключей с доступом по HTTP: ни папок, ни rename, ни seek. «Папка» — это общий префикс, и она исчезает вместе с последним объектом.
  • Три понятия: bucket (глобально-уникальный контейнер с одним регионом), объект (байты плюс метаданные), ключ (строка-адрес). Пределы: имя бакета 3–63 символа и без точек, ключ до 1024 байт UTF-8, метаданные 2 КБ и меняются только копированием, одним PUT до 5 ГБ.
  • С декабря 2020 года действует strong read-after-write consistency: чтение сразу после записи и листинг отдают актуальное.
  • Классы хранения различаются ценой, а не скоростью (мгновенно отдают все, кроме Glacier Flexible и Deep Archive), но у холодных есть минимальный срок хранения (30/90/180 дней) и минимальный оплачиваемый размер (128 и 40 КБ) — мелочь в архиве дороже, чем в горячем классе.
  • Версионирование спасает от случайного удаления и перезаписи, но копит версии, поэтому идёт в паре с lifecycle policy — она же убирает брошенные многочастные загрузки.
  • Presigned URL уводит файл мимо вашего сервера, multipart нужен от 100 МБ и поднимает потолок с 5 ГБ до 5 ТБ.
  • Загрузка из браузера по presigned URL требует настройки CORS на бакете: источник, методы, ExposeHeaders: ETag для multipart; заголовки PUT совпадают с подписанными.
  • Пропускная способность масштабируется префиксами (порядка 3500 записей и 5500 чтений в секунду на префикс), поэтому ключи раскладывают по клиенту или хешу.
  • Подпись живёт максимум семь дней и умирает вместе с временными ключами роли; узнавать о появлении файла правильнее событием бакета через очередь, а обработчик делать идемпотентным.

Что пощупать

Загрузка файла мимо сервиса, по временной подписанной ссылке, реализована во взрослом каталоге практикума remodov/marketplace-system: сервис решает только, кому выдать ссылку, а сам файл браузер кладёт прямо в хранилище. Хранилище на стенде — MinIO, для тестов оно не нужно: подпись считается локально.

Код: image, usecase/image.

Сделаем сами

Ветка step-12-tokens-and-files — подпись и проверка владельца вынуты, тест красный.

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