Объектные хранилища: S3, MinIO, слои хранения и lakehouse
Есть смешной способ понять, насколько S3 изменил индустрию: посмотрите, что лежит под современными базами данных. Под Snowflake — S3. Под BigQuery — Colossus, тот же класс системы. Kafka с версии 3.9 умеет выгружать старые сегменты в S3. ClickHouse умеет держать части MergeTree на S3. Elasticsearch умеет искать прямо по снапшотам в S3. pgBackRest льёт бэкапы PostgreSQL в S3. Databricks, Iceberg, Trino, DuckDB, Spark, Flink — всё это в конечном счёте пишет байты в объектное хранилище.
Это не мода. Это следствие одного экономического факта: объектное хранилище — единственный способ хранить петабайт данных, платя за него 20 000 $ в месяц вместо 200 $ 000, и при этом не думать о дисках, RAID, репликации и ёмкости. Всё остальное в этой статье — про то, чем именно вы платите за эту цену, потому что бесплатно не бывает.
И платите вы конкретной вещью: S3 — это не файловая система и не база данных. Это сетевое key-value хранилище с большими неизменяемыми значениями, задержкой в десятки миллисекунд и практически без транзакционных примитивов. Каждая ошибка, которую вы совершите с ним в проде, будет следствием того, что вы забыли одно из этих трёх слов.
Модель данных: bucket, key, object — и чего в ней нет
Формально всё просто. Есть бакет (пространство имён, привязанное к региону), внутри него — объекты. Объект — это тройка: ключ (UTF-8 строка до 1024 байт), тело (до 5 ТиБ) и набор метаданных (системных вроде Content-Type и пользовательских x-amz-meta-*, суммарно до 2 КБ). При включённом версионировании к этому добавляется versionId.
А теперь — список того, чего здесь нет, и это гораздо важнее:
- Нет каталогов.
logs/2026/07/app.parquet— это одна строка. Символ/не имеет служебного смысла; иерархию рисует консоль, выполняяLISTсdelimiter=/. Следствие: «удалить каталог» — это перечислить все ключи с префиксом и удалить их по одному (пачками по 1000 черезDeleteObjects). - Нет переименования.
mv=COPY+DELETE. Копирование внутри региона делает сам сервис (данные не идут через ваше приложение), но стоит запросов и времени, пропорциональных объёму. Переименование «каталога» на 10 млн ключей — это 20 млн запросов и часы работы. - Нет частичной перезаписи и нет append. Объект неизменяем. Изменить один байт — переписать объект целиком. Именно поэтому все форматы данных поверх S3 устроены как LSM: пишем новые файлы, старые помечаем мёртвыми, периодически схлопываем.
- Нет транзакций между объектами. Записать два объекта атомарно нельзя. Отсюда — целая индустрия табличных форматов, которой посвящена вторая половина статьи.
- Нет
fsyncи нет неопределённости. КогдаPUTвернул200 OKсETag, данные уже долговечны и реплицированы. Это как раз хорошая новость.
Консистентность: что изменилось в декабре 2020
Первые 14 лет S3 был eventually consistent для перезаписей и удалений: после PUT поверх существующего ключа последующий GET мог вернуть старое тело. На этом построена целая мифология и куча защитного кода в Hadoop-экосистеме (печально известный S3Guard поверх DynamoDB).
В декабре 2020 AWS включил строгую read-after-write консистентность для всех операций во всех регионах, без доплаты и без падения производительности: GET, LIST, HEAD после PUT/DELETE немедленно видят результат (анонс AWS). S3Guard был удалён из Hadoop.
Но границы важны:
| Что | Гарантия |
|---|---|
GET/HEAD после PUT/DELETE того же ключа |
Строгая read-after-write |
LIST после PUT/DELETE |
Строгая (список отражает результат) |
Атомарность отдельного PUT |
Полная: клиент видит либо старую версию, либо новую, никогда не «полуфайл» |
| Атомарность между двумя объектами | Отсутствует |
| Конфигурация бакета (policy, ACL, lifecycle) | Eventually consistent |
| Cross-Region Replication | Асинхронная, лаг от секунд до минут (SLA только с S3 RTC) |
Условная запись If-None-Match: * |
Есть с августа 2024 — атомарный «создай, если нет» |
Условная запись/удаление If-Match: <etag> |
Есть с ноября 2024 — атомарный compare-and-swap |
Последние две строки — тихая революция, которую многие пропустили. До 2024 года на S3 не было никакого примитива взаимного исключения, и Delta Lake на AWS требовал внешний DynamoDB-лог для сериализации коммитов. Теперь If-None-Match: * даёт ровно то, что нужно табличным форматам: «создать файл коммита версии N, и пусть проиграет тот, кто опоздал».
import boto3
from botocore.exceptions import ClientError
s3 = boto3.client("s3")
def try_commit(bucket: str, version: int, payload: bytes) -> bool:
"""Атомарно застолбить номер версии коммита.
If-None-Match: '*' означает «выполни PUT, только если ключа ещё нет».
Ровно один из конкурирующих писателей получит 200, остальные — 412.
Это единственный примитив взаимного исключения, который даёт S3."""
try:
s3.put_object(
Bucket=bucket,
Key=f"_delta_log/{version:020d}.json",
Body=payload,
IfNoneMatch="*", # условная запись
)
return True
except ClientError as e:
if e.response["Error"]["Code"] == "PreconditionFailed":
return False # кто-то занял эту версию раньше — читаем и повторяем
raise
Важная оговорка для тех, кто пишет на MinIO, Ceph или R2: условные записи — часть S3 API, но реализованы они не везде и не одинаково. Проверяйте свой бэкенд явным интеграционным тестом с двумя параллельными писателями, а не документацией.
Механика API: то, на чём реально теряют деньги и время
Multipart upload
Одиночный PUT ограничен 5 ГиБ. Всё, что больше (а на практике — всё, что больше ~100 МБ), заливается частями: от 5 МиБ каждая (кроме последней), максимум 10 000 частей, максимум 5 ТиБ на объект. Части можно грузить параллельно и повторять по отдельности при сбое.
и ТАРИФИЦИРУЮТСЯ, невидимые в LIST.
Обязательное lifecycle-правило:
AbortIncompleteMultipartUpload через 7 дней. end
Красный блок — не теория. Незавершённые multipart-загрузки не видны ни в консоли, ни в ListObjectsV2, но занимают место и попадают в счёт. Регулярно встречающийся сценарий: бакет на 40 ТБ в биллинге при 12 ТБ реальных данных. Ищутся они через ListMultipartUploads, лечатся правилом жизненного цикла:
{
"Rules": [
{
"ID": "abort-incomplete-mpu",
"Status": "Enabled",
"Filter": { "Prefix": "" },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
}
]
}
ETag: не хеш содержимого
Для объекта, залитого одним PUT без SSE-KMS, ETag — это MD5 тела. Для multipart — это MD5 от конкатенации бинарных MD5 всех частей, с суффиксом -<число частей>. То есть ETag зависит от того, каким размером частей вы заливали файл. Использовать его как контрольную сумму содержимого нельзя: тот же файл, залитый другим клиентом с другим multipart_chunksize, даст другой ETag. Если нужна честная проверка целостности — включайте ChecksumAlgorithm=CRC32C или SHA256, S3 посчитает и сохранит их отдельно.
Range GET и почему это основа всей аналитики
GET с заголовком Range: bytes=1048576-2097151 читает произвольный кусок объекта. Это выглядит как мелочь, но именно на этом стоит весь колоночный анализ поверх объектного хранилища: движок читает футер Parquet-файла (последние килобайты), из него узнаёт смещения нужных колонок и row group’ов, и вытягивает только их — иногда 2% от файла.
import pyarrow.parquet as pq
import pyarrow.fs as fs
s3 = fs.S3FileSystem(region="eu-central-1")
# Читаем ТОЛЬКО две колонки и только те row group'ы, где min/max
# статистика в футере допускает наличие подходящих строк.
# Физически это несколько Range GET, а не скачивание файла целиком.
tbl = pq.read_table(
"my-lake/events/dt=2026-07-16/part-0001.parquet",
filesystem=s3,
columns=["user_id", "amount"],
filters=[("amount", ">", 1000)], # предикат уходит в pushdown по статистике
)
Задержка: главный ориентир
| Операция | Типичное значение (S3 Standard, тот же регион) |
|---|---|
TTFB для GET небольшого объекта |
20–60 мс (p50), 100–200 мс (p99) |
TTFB для GET из Glacier Instant Retrieval |
те же миллисекунды |
| Пропускная способность одного потока | 60–120 МБ/с |
| Пропускная способность при 32 параллельных Range GET | 3–8 ГБ/с (упор в сеть инстанса) |
LIST на 1000 ключей |
40–150 мс |
| Лимит на разделяемый префикс | 3 500 PUT/COPY/POST/DELETE, 5 500 GET/HEAD в секунду |
S3 Express One Zone, GET |
2–8 мс TTFB |
| Restore из Glacier Flexible (Standard) | 3–5 часов |
| Restore из Deep Archive (Standard / Bulk) | ~12 часов / до 48 часов |
Из этой таблицы следует главное правило производительности: латентность вы не победите, победить можно только параллелизмом. 100 объектов по 10 МБ, скачанных последовательно, — это 100 × (50 мс + 100 мс) ≈ 15 секунд. Те же объекты в 32 потока — меньше секунды. Все библиотеки-обёртки (AWS CRT, s3transfer, object_store в Rust) существуют ради этого.
Экономика: где на самом деле формируется счёт
Хранение — это обычно не главная статья расходов. Ниже — реальные цены us-east-1 (порядок величин стабилен годами; актуальные смотрите на странице цен S3).
| Класс | $/ГБ·мес | Мин. срок | Мин. billable размер | Retrieval | PUT $/1000 | GET $/1000 | Задержка доступа |
|---|---|---|---|---|---|---|---|
| Standard | 0.023 | — | — | 0 $ | 0.005 | 0.0004 | мс |
| Intelligent-Tiering | 0.023 → 0.0036 | — | 128 КБ | 0 $ | 0.005 | 0.0004 | мс |
| Standard-IA | 0.0125 | 30 дней | 128 КБ | 0.01 $/ГБ | 0.010 | 0.0010 | мс |
| One Zone-IA | 0.010 | 30 дней | 128 КБ | 0.01 $/ГБ | 0.010 | 0.0010 | мс, но 1 AZ |
| Glacier Instant Retrieval | 0.004 | 90 дней | 128 КБ | 0.03 $/ГБ | 0.020 | 0.010 | мс |
| Glacier Flexible Retrieval | 0.0036 | 90 дней | 40 КБ | 0.01 $/ГБ (Standard), 0 $ (Bulk) | 0.030 | — | 3–5 ч |
| Glacier Deep Archive | 0.00099 | 180 дней | 40 КБ | 0.02 $/ГБ (Standard) | 0.050 | — | 12–48 ч |
| Express One Zone | 0.11 | 1 час | — | 0 $ | 0.0025 | 0.0002 | 2–8 мс |
Разберём этот график словами, потому что он ломает интуицию.
Standard-IA дешевле Standard, только если вы читаете меньше, чем весь датасет раз в месяц. Точка безубыточности: 23.55 $ (Standard, 1 ТБ) против 12.80 $ + 10.24 $·f. Пересечение при f ≈ 1.05. Если вы честно не знаете f — не гадайте, включите Intelligent-Tiering: он сам двигает объекты между уровнями по факту доступа, беря 0.0025 $ за 1000 объектов в месяц за мониторинг. Для объектов крупнее ~1 МБ это выгодно почти всегда; для миллиардов мелких объектов плата за мониторинг может превысить экономию — считайте.
Deep Archive монетарно дешевле Glacier IR всегда. У него и хранение в 4 раза дешевле, и retrieval дешевле (0.02 $ против 0.03 $). Разница не в деньгах, а в трёх других осях: 12 часов ожидания restore, минимальный срок хранения 180 дней и 0.05 $ за 1000 PUT. Последнее убивает: залить 100 млн мелких файлов в Deep Archive стоит 5 000 $ только за запросы, при стоимости хранения в 99 $/месяц. Архивные классы созданы для больших объектов; мелочь нужно предварительно паковать в tar/zip.
Скрытые статьи, которые взрывают счёт
- Egress в интернет. 0.09 $/ГБ за первые 10 ТБ (первые 100 ГБ в месяц бесплатно). Раздача 50 ТБ статики в месяц — 4 $ 500. Это в 195 раз дороже, чем хранить эти же 50 ТБ. Именно egress, а не хранение, продал Cloudflare R2 половине индустрии.
- NAT Gateway. Классика: приложение в приватной подсети ходит в S3 через NAT — 0.045 $ за каждый обработанный гигабайт, плюс почасовая плата. Лечится Gateway VPC Endpoint для S3, который бесплатен. Проверьте это в первый же день; счета на десятки тысяч долларов приходили именно отсюда.
- SSE-KMS. Каждый
PUT/GETшифрованного объекта — это вызов KMS, 0.03 $ за 10 000 запросов. При миллиарде GET в месяц это 3 000 $ сверху и заодно риск упереться в квоту KMS. Лечится S3 Bucket Keys: одна ключевая операция на бакет-уровне сокращает обращения к KMS примерно на 99%. - Lifecycle-переходы. Перевод объекта в другой класс — платный запрос (0.01 $ за 1000 переходов). 500 млн мелких логов, переезжающих в IA, — это 5 000 $ разом за то, что должно было сэкономить деньги.
LISTв аналитике.ListObjectsV2отдаёт 1000 ключей за запрос и стоит какPUT. Таблица из 4 млн файлов, которую движок перечисляет при каждом запросе, — 4000 запросов и десятки секунд только на планирование. Это одна из главных причин появления Iceberg.- Версионирование без lifecycle. Включили версионирование, приложение перезаписывает объекты — старые версии копятся вечно и оплачиваются. Обязателен
NoncurrentVersionExpiration.
{
"Rules": [
{
"ID": "logs-tiering",
"Status": "Enabled",
"Filter": { "And": { "Prefix": "logs/", "ObjectSizeGreaterThan": 131072 } },
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 120, "StorageClass": "GLACIER_IR" },
{ "Days": 365, "StorageClass": "DEEP_ARCHIVE" }
],
"Expiration": { "Days": 2555 }
},
{
"ID": "kill-old-versions",
"Status": "Enabled",
"Filter": { "Prefix": "" },
"NoncurrentVersionExpiration": { "NoncurrentDays": 30, "NewerNoncurrentVersions": 3 },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
}
]
}
Обратите внимание на ObjectSizeGreaterThan: 131072 в первом правиле — оно защищает от перевода объектов меньше 128 КБ в IA, где они всё равно тарифицируются как 128 КБ, и переход только увеличивает счёт.
Долговечность, версии и защита от шифровальщика
S3 Standard спроектирован на 11 девяток годовой долговечности (99.999999999%): при хранении 10 млн объектов ожидаемая потеря — один объект раз в 10 000 лет. Достигается это erasure coding с распределением фрагментов минимум по трём зонам доступности внутри региона.
Что 11 девяток не покрывают:
- Ваш
DELETE. Удаление по ошибке или по злому умыслу — это корректно выполненная операция. Durability тут ни при чём. - Ваш баг. Приложение, перезаписавшее объекты мусором, — тоже корректная операция.
- Гибель региона. 11 девяток — внутри региона. Кросс-региональная катастрофа — это CRR, и её вы настраиваете сами.
- One Zone-IA. Здесь данные в одной AZ. Потеря AZ = потеря данных. Класс годится только для того, что можно пересоздать.
Практический минимум защиты выглядит так:
# 1. Версионирование: DELETE ставит delete marker, тело остаётся восстановимым
aws s3api put-bucket-versioning --bucket prod-data \
--versioning-configuration Status=Enabled
# 2. Object Lock в режиме COMPLIANCE: объект нельзя удалить или изменить
# до истечения срока НИКОМУ, включая root-аккаунт. Ровно это ломает
# сценарий шифровальщика «получил админ-ключи → стёр бэкапы».
aws s3api put-object-lock-configuration --bucket prod-backups \
--object-lock-configuration '{
"ObjectLockEnabled":"Enabled",
"Rule":{"DefaultRetention":{"Mode":"COMPLIANCE","Days":30}}
}'
# 3. Блокировка любого публичного доступа на уровне аккаунта
aws s3control put-public-access-block --account-id 123456789012 \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
Разница между GOVERNANCE и COMPLIANCE принципиальна: GOVERNANCE можно обойти с правом s3:BypassGovernanceRetention (защита от дурака), COMPLIANCE не может обойти никто и никак до истечения срока (защита от злоумышленника с вашими ключами). Для бэкапов берите COMPLIANCE — и заранее подумайте о счёте, потому что удалить их досрочно вы тоже не сможете.
Про полную картину резервного копирования и восстановления БД см. https://courses.digitable.life/post/databases/08-replication-and-sharding/ — там же про то, почему бэкап без регулярной проверки восстановления бэкапом не является.
MinIO, Ceph и остальные: S3 у себя
S3 API стал де-факто стандартом, и локальных реализаций много. Ключевой вопрос при выборе — не «поддерживает ли оно S3 API» (заявляют все), а «какие именно части и с какими гарантиями».
MinIO
MinIO — это один Go-бинарник, который превращает набор дисков в S3-совместимое хранилище. Архитектурно он строится на erasure set: группа из 4–16 дисков, внутри которой объект режется на data- и parity-шарды по Рида — Соломона. Дефолт — EC:4 (четыре шарда чётности), то есть выдерживается потеря половины дисков в наборе на чтение.
# Продакшн-раскладка: 4 узла × 4 диска = 16 дисков в одном erasure set.
# ВАЖНО: диски передаются одной фигурной нотацией — MinIO сам
# распределяет erasure set так, чтобы шарды не оказались на одном узле.
minio server --console-address ":9001" \
http://minio-{1...4}.internal:9000/data/disk{1...4}
# Категорически НЕЛЬЗЯ: RAID под MinIO. Erasure coding уже даёт
# избыточность; RAID сверху удваивает расход места и мешает MinIO
# видеть отказ конкретного диска. Отдавайте сырые XFS-разделы.
Что важно знать про MinIO перед выбором:
- Лицензия — AGPLv3. Если вы встраиваете MinIO в продукт, который отдаёте наружу как сервис, AGPL распространяется на ваш код. Для внутреннего использования это не проблема, для вендора — юридический вопрос уровня «сходите к юристу».
- В 2025 году коммерческая политика ужесточилась: из community-сборок вырезали веб-консоль управления, оставив только базовый объектный браузер. Ставка сделана на коммерческий AIStor. Часть сообщества мигрировала на форки и альтернативы; учитывайте это в планировании на годы вперёд.
- Расширение только пулами. Нельзя добавить один диск. Расширяются серверными пулами (ещё N узлов такой же конфигурации), и старые данные автоматически не перебалансируются — новые записи идут преимущественно в свободный пул.
- Функциональные пробелы. Нет классов хранения в смысле S3 (есть tiering в удалённый S3), нет Intelligent-Tiering, поведение
ListObjectsV2и conditional writes отличается в деталях. Это ломает софт, написанный «по AWS».
Честное сравнение
| AWS S3 | MinIO (self-hosted) | Ceph RGW | Cloudflare R2 | GCS | Azure Blob | |
|---|---|---|---|---|---|---|
| Модель | Managed | Свой софт, своё железо | Свой софт, своё железо | Managed | Managed | Managed |
| $/ГБ·мес (горячее) | 0.023 | железо: 0.004 $–0.010 амортизированно | железо | 0.015 | 0.020 | 0.018 |
| Egress в интернет | 0.09 $/ГБ | сеть ДЦ | сеть ДЦ | 0 $ | 0.12 $/ГБ | 0.087 $/ГБ |
| Классы хранения | 8 классов | tiering во внешний S3 | пулы + lifecycle | Standard + Infrequent Access | 4 класса | 4 уровня |
| Долговечность | 11 девяток (заявлено) | зависит от вашей схемы EC | зависит от CRUSH-правил | 11 девяток | 11 девяток | 11+ девяток |
Условные записи (If-Match) |
Да | Частично, зависит от версии | Частично | Да | Да (generation) | Да (ETag) |
| Object Lock / WORM | Да, COMPLIANCE | Да | Да | Да | Да (retention policy) | Да (immutability) |
| Порог входа по эксплуатации | Нулевой | Средний | Очень высокий | Нулевой | Нулевой | Нулевой |
| Сильная сторона | Экосистема и надёжность | Простота, скорость на NVMe | Единый файл/блок/объект | Отсутствие egress | Интеграция с BigQuery | Интеграция с MS-стеком |
| Когда НЕ брать | Тяжёлый egress, регуляторика на локальность | Вендор-риск лицензии, нужна команда | Нет выделенной команды хранения | Нужны глубокие архивные классы | Нужен именно AWS-стек | Не-Azure окружение |
Отдельно про Ceph RGW: это не «MinIO, но сложнее», это принципиально другая система — единый кластер RADOS, отдающий одновременно объекты, блочные устройства (RBD) и файловую систему (CephFS). Мощнейшая штука, но её эксплуатация — отдельная профессия: PG-раскладка, CRUSH-правила, resharding индекса бакета (когда в бакете десятки миллионов объектов, шардированный индекс становится главной операционной болью). Без выделенных инженеров хранения не стоит.
Экономика self-hosted честно. 1 ПБ полезной ёмкости при EC 8+4 (накладные расходы 1.5×) — это 1.5 ПБ сырых дисков, около 75 дисков по 20 ТБ, 6–8 узлов с сетью 25 GbE. Капитальные затраты порядка 120 $–180 тыс., амортизация за 5 лет плюс ДЦ, питание и люди — примерно 0.004 $–0.008 за ГБ в месяц. На S3 Standard тот же петабайт стоит 21 000 $ в месяц, то есть 1.26 млн $ за пять лет. Разница огромна — но она целиком уходит в вашу команду и ваш риск. Self-hosted выигрывает при стабильных больших объёмах и наличии инженеров; проигрывает при непредсказуемом росте и маленькой команде.
Слои хранения: почему все разделили storage и compute
Классическая база держит данные на локальных дисках узла. Это даёт минимальную задержку, но связывает ёмкость и вычисления: нужен ещё терабайт — покупайте узел с процессорами, которые вам не нужны; нужен ещё один аналитик — тоже покупайте узел с дисками, которые не нужны.
Объектное хранилище разрывает эту связь.
ad-hoc аналитика"] Q2["ClickHouse
интерактивные дашборды"] Q3["DuckDB на ноутбуке
тот же датасет"] Q4["Flink
стриминг"] end subgraph CACHE["Слой кэша — локальные NVMe, необязательный"] C1["Кэш страниц/частей
10–50× по задержке"] end subgraph TABLE["Табличный слой — метаданные и транзакции"] T1["Iceberg / Delta Lake / Hudi
снапшоты, схема, статистика"] T2["Каталог: REST, Glue, Polaris, Unity
атомарный swap указателя"] end subgraph FILE["Файловый слой — колоночные форматы"] F1["Parquet / ORC
row groups, min/max, bloom"] end subgraph OBJ["Объектный слой — единственный источник истины"] O1["S3 / GCS / MinIO
неизменяемые объекты, 11 девяток"] end Q1 --> CACHE Q2 --> CACHE Q3 --> TABLE Q4 --> TABLE CACHE --> TABLE T2 -.->|"указывает на текущий снапшот"| T1 TABLE --> FILE FILE --> OBJ style OBJ fill:#5fa88c22,stroke:#5fa88c style TABLE fill:#6f9fd822,stroke:#6f9fd8 style COMPUTE fill:#d99b4e22,stroke:#d99b4e
Что даёт эта схема:
- Независимое масштабирование. Ноль вычислительных узлов ночью — платите только за хранение. Пик отчётности — 200 узлов на час.
- Много движков на одних данных. Spark пишет, Trino читает, DuckDB на ноутбуке читает те же файлы. Никакого ETL между системами.
- Бесплатное восстановление. Узел упал — поднимите новый, состояния на нём нет.
- Дешёвое хранение истории. Данные пятилетней давности лежат в Glacier IR по 4 $/ТБ.
Что она забирает:
- Задержка. Локальный NVMe — 100 мкс, S3 — 50 мс. Разница в 500 раз. Отсюда обязательный кэш для интерактивных нагрузок.
- Отсутствие транзакций «из коробки» — ровно та проблема, которую решает табличный слой.
Так работают сегодня практически все: ClickHouse умеет MergeTree на диске типа s3 (см. https://courses.digitable.life/post/databases/13-clickhouse-and-olap/), Kafka с KIP-405 выгружает старые сегменты в объектное хранилище, Elasticsearch делает searchable snapshots для «холодных» индексов (https://courses.digitable.life/post/databases/14-timeseries-and-search/), а векторные базы вроде Milvus изначально спроектированы вокруг S3 как слоя хранения (https://courses.digitable.life/post/databases/15-vector-databases/).
<!-- ClickHouse: политика хранения «горячее на NVMe, холодное в S3».
Части MergeTree старше месяца автоматически переезжают в объектное
хранилище и остаются полностью читаемыми — просто медленнее. -->
<clickhouse>
<storage_configuration>
<disks>
<s3_cold>
<type>s3</type>
<endpoint>https://my-bucket.s3.eu-central-1.amazonaws.com/ch/</endpoint>
<use_environment_credentials>true</use_environment_credentials>
<!-- Локальный кэш поверх S3 — без него интерактивные запросы
упрутся в 50 мс латентности на каждую засечку. -->
<data_cache_enabled>true</data_cache_enabled>
<data_cache_max_size>200000000000</data_cache_max_size>
</s3_cold>
</disks>
<policies>
<hot_to_cold>
<volumes>
<hot><disk>default</disk></hot>
<cold><disk>s3_cold</disk><prefer_not_to_merge>true</prefer_not_to_merge></cold>
</volumes>
<move_factor>0.15</move_factor>
</hot_to_cold>
</policies>
</storage_configuration>
</clickhouse>
Lakehouse: как превратить ведро с файлами в таблицу
«Data lake» первого поколения — это каталог с Parquet-файлами и Hive Metastore, который помнит, где какие партиции. У этой конструкции ровно четыре смертельные болезни:
- Нет атомарности. Job пишет 500 файлов и падает на 300-м. Читатель видит полтаблицы. Классический костыль — писать во временный каталог и «переименовывать», но на S3 rename — это копирование, а не атомарная операция.
LISTвместо метаданных. Планировщик обходит хранилище, чтобы понять, какие файлы читать. На 4 млн файлов это тысячи запросов и десятки секунд.- Нет эволюции схемы. Переименовали колонку — старые файлы стали нечитаемыми, потому что Hive сопоставляет колонки по имени и позиции.
- Нет
UPDATE/DELETE. А GDPR требует удалять данные пользователя. Приходится переписывать целые партиции.
Табличные форматы — Apache Iceberg, Delta Lake, Apache Hudi — решают всё это одним приёмом: они добавляют поверх файлов слой метаданных, а атомарность коммита сводят к одной атомарной операции над одним указателем.
Как устроен коммит в Iceberg
(таблица их пока НЕ видит) Manifest --> Commit: собран manifest list
со статистикой по файлам Commit --> Snapshot_N1: каталог атомарно заменил
указатель N → N+1 (CAS) Commit --> Retry: CAS проиграл —
кто-то закоммитил раньше Retry --> Manifest: переигрываем метаданные
поверх нового снапшота Snapshot_N1 --> [*] note right of Snapshot_N1 Старый снапшот N жив: отсюда time travel и мгновенный rollback. Удаляется только expire_snapshots. end note note right of Commit Единственная точка сериализации. Iceberg: CAS в каталоге. Delta Lake: PUT If-None-Match на файл _delta_log/N.json. end note
Ключевая идея: вся работа делается вне транзакции, и только в конце происходит одна атомарная подмена указателя. Это оптимистический контроль параллелизма — тот же принцип, что в снапшотной изоляции обычных СУБД (см. https://courses.digitable.life/post/databases/07-transactions-and-isolation/), только точка сериализации вынесена в каталог.
Дерево метаданных Iceberg выглядит так:
metadata/v42.metadata.json ← схема, партиционирование, список снапшотов
└─ snap-8291...avro (manifest list) ← перечень манифестов + границы партиций
├─ manifest-a.avro ← файлы + min/max по КАЖДОЙ колонке + число строк
└─ manifest-b.avro
data/dt=2026-07-16/00001.parquet
data/dt=2026-07-16/00002.parquet
Планировщик запроса читает metadata.json, затем manifest list, отбрасывает манифесты по границам партиций, затем читает нужные манифесты и отбрасывает файлы по min/max. LIST по хранилищу не выполняется никогда. На таблице из миллионов файлов планирование занимает сотни миллисекунд вместо десятков секунд.
-- Spark SQL поверх Iceberg. Обратите внимание на три вещи,
-- невозможные в «просто Parquet в каталоге».
-- 1) Скрытое партиционирование: пользователь фильтрует по ts,
-- Iceberg сам понимает, что это партиция по дням.
CREATE TABLE lake.events (
event_id BIGINT,
user_id BIGINT,
ts TIMESTAMP,
amount DECIMAL(12,2),
country STRING
) USING iceberg
PARTITIONED BY (days(ts), bucket(64, user_id))
TBLPROPERTIES (
'format-version' = '2',
'write.target-file-size-bytes' = '536870912', -- целимся в 512 МБ
'write.parquet.compression-codec' = 'zstd',
'write.delete.mode' = 'merge-on-read',
'history.expire.max-snapshot-age-ms' = '604800000' -- 7 дней истории
);
-- 2) Настоящий DELETE по строкам — раньше это означало
-- переписать всю партицию.
DELETE FROM lake.events WHERE user_id = 42; -- GDPR-запрос
-- 3) Time travel и откат: снапшоты живы, пока их не удалили.
SELECT count(*) FROM lake.events TIMESTAMP AS OF '2026-07-15 00:00:00';
CALL lake.system.rollback_to_snapshot('lake.events', 8291056432104123456);
-- Эволюция схемы без переписывания данных: Iceberg сопоставляет
-- колонки по стабильным ID, а не по имени или позиции.
ALTER TABLE lake.events RENAME COLUMN country TO country_code;
ALTER TABLE lake.events ADD COLUMN channel STRING AFTER amount;
-- Обслуживание — обязательное, не опциональное.
CALL lake.system.rewrite_data_files(
table => 'lake.events',
strategy => 'binpack',
options => map('min-input-files','8','target-file-size-bytes','536870912')
);
CALL lake.system.expire_snapshots('lake.events', TIMESTAMP '2026-07-12 00:00:00', 100);
CALL lake.system.remove_orphan_files(table => 'lake.events');
Последние три вызова — не украшение. Без rewrite_data_files стриминговая запись за месяц наплодит сотни тысяч мелких файлов и запросы деградируют в разы. Без expire_snapshots метаданные растут бесконечно, а удалённые данные продолжают оплачиваться. Без remove_orphan_files в бакете копятся файлы от упавших job’ов, которые не видит ни одна таблица, но за которые вы платите. Lakehouse без регламентного обслуживания — это медленно тухнущая система.
Iceberg против Delta Lake против Hudi
| Iceberg | Delta Lake | Hudi | |
|---|---|---|---|
| Происхождение | Netflix, 2018; Apache | Databricks, 2019; Linux Foundation | Uber, 2016; Apache |
| Метаданные | Дерево manifest’ов (Avro) | Лог _delta_log: JSON-коммиты + Parquet-чекпойнты каждые 10 |
Timeline из инстант-файлов |
| Сериализация коммита | CAS в каталоге (REST/Glue/Nessie/Polaris) | PUT If-None-Match на файл версии (раньше — DynamoDB на S3) |
Блокировка (ZooKeeper/DynamoDB/HMS) |
| Партиционирование | Скрытое: пользователь не пишет партиционный предикат | Явное (+ liquid clustering в новых версиях) | Явное |
| Эволюция схемы | По ID колонок — безопасны rename, reorder, drop | По имени + column mapping | По имени |
| Удаления | Position/equality delete files (v2), deletion vectors (v3) | Deletion vectors | Copy-on-write / merge-on-read |
| Upsert-нагрузка | Хорошо | Хорошо | Лучше всех (индекс по ключу записи) |
| Экосистема движков | Максимально широкая: Trino, Spark, Flink, ClickHouse, DuckDB, Snowflake, BigQuery, Redshift | Сильнее всего в Databricks; вне его — через delta-rs и UniForm | Уже Spark/Flink |
| Когда брать | Мультидвижковая архитектура, открытый стандарт, ставка «по умолчанию» | Вы уже в Databricks | Частые upsert’ы и CDC-приём с низкой задержкой |
| Когда НЕ брать | Данных меньше нескольких ТБ — накладные расходы не окупятся | Нужна независимость от вендора | Нужна широкая поддержка движков |
По состоянию на 2026 год Iceberg победил как отраслевой стандарт: его поддержали Snowflake, BigQuery, Redshift, Confluent и даже Databricks (купив Tabular и запустив UniForm), AWS выпустил управляемые S3 Tables. Если вы выбираете сегодня с чистого листа и не сидите в Databricks — берите Iceberg, это ставка с наименьшим риском.
DuckDB как проверка реальностью
Хороший тест зрелости lakehouse: можно ли прочитать вашу таблицу с ноутбука без кластера.
-- Установка и чтение таблицы Iceberg напрямую из S3, без Spark,
-- без Hadoop, одним процессом на ноутбуке.
INSTALL iceberg; LOAD iceberg;
INSTALL httpfs; LOAD httpfs;
CREATE SECRET (TYPE s3, PROVIDER credential_chain, REGION 'eu-central-1');
-- Планирование идёт по манифестам: DuckDB прочитает только те
-- Parquet-файлы, чья min/max статистика допускает совпадение.
SELECT country_code, date_trunc('day', ts) AS d, sum(amount) AS revenue
FROM iceberg_scan('s3://my-lake/warehouse/events')
WHERE ts >= '2026-07-01' AND amount > 100
GROUP BY 1, 2
ORDER BY revenue DESC
LIMIT 20;
Если это работает — вы построили открытый lakehouse. Если для чтения нужен конкретный кластер конкретного вендора — вы построили проприетарное хранилище, которое просто лежит в вашем бакете.
Практика продакшена
Проблема мелких файлов и как считать целевой размер
Стоимость чтения одного файла ≈ TTFB (≈50 мс) + размер/пропускная_способность. Для файла в 1 МБ это 50 мс накладных на ~10 мс полезной работы — КПД 17%. Для файла в 512 МБ — 50 мс на 5 секунд, КПД 99%.
Отсюда рабочее правило: целевой размер файла в lakehouse — 128 МБ до 1 ГБ, оптимум обычно 256–512 МБ. Стриминговая запись мелкими батчами неизбежно нарушает это правило, поэтому компакция обязательна. Дополнительный аргумент — Parquet row group по умолчанию 128 МБ: файл меньше этого просто не даёт движку возможности параллелить чтение внутри файла.
Считать деградацию можно прямо: таблица из 2 млн файлов по 5 МБ вместо 20 тыс. файлов по 500 МБ — это в 100 раз больше GET-запросов (0.0004 $/1000 → 0.80 $ против 0.008 $ на полный скан) и, что важнее, в 100 раз больше раундтрипов: при 64 потоках это 2 000 000/64 × 50 мс ≈ 26 минут только латентности против 15 секунд.
Раскладка ключей и параллелизм
Лимит 3 500 PUT в секунду действует на разделяемый префикс, и S3 делит пространство автоматически по мере роста нагрузки — но не мгновенно, а за минуты-часы. При резком старте высоконагруженной записи вы получите 503 SlowDown. Два приёма:
# Плохо: все ключи начинаются с растущей даты — запись всегда бьёт
# в один «горячий» хвост отсортированного пространства.
key = f"events/2026-07-16/{uuid4()}.parquet"
# Хорошо: короткий хеш-префикс равномерно размазывает запись
# по разделяемым префиксам с первой секунды.
h = hashlib.blake2b(str(uuid4()).encode(), digest_size=2).hexdigest() # 4 символа
key = f"events/{h}/2026-07-16/{uuid4()}.parquet"
Важная оговорка: для аналитики это ухудшает читаемость префиксов, поэтому в lakehouse так делать не нужно — там нагрузка на запись распределяется естественно и планирование идёт по манифестам, а не по LIST. Хеш-префикс нужен для сценариев с очень высокой частотой записи мелких объектов (телеметрия, загрузка пользовательских файлов).
И всегда включайте адаптивные повторы — 503 SlowDown и 500 штатны для S3, это не аварии:
from botocore.config import Config
cfg = Config(
retries={"max_attempts": 10, "mode": "adaptive"}, # экспоненциальная пауза + client-side rate limiting
max_pool_connections=64, # без этого 64 потока встанут в очередь
tcp_keepalive=True,
connect_timeout=3,
read_timeout=60,
)
s3 = boto3.client("s3", config=cfg)
Наблюдаемость
Три вещи, которые нужно включить в первый день, иначе диагностировать будет нечем:
- S3 Storage Lens — бесплатный дашборд по всем бакетам: объём, число объектов, распределение по классам, незавершённые MPU, доля неактуальных версий. Первое место, куда идти с вопросом «почему счёт вырос».
- S3 Inventory — ежедневный CSV/Parquet-отчёт со всеми объектами и их атрибутами. Дешевле, чем миллион
LIST-запросов, и позволяет считать статистику по бакету обычным SQL. - CloudTrail data events (выборочно, они платные) — кто удалил объект и когда. Без них расследование инцидента невозможно.
Типичные ошибки
- Считать S3 файловой системой. Монтирование через
s3fs/goofysдаёт POSIX-интерфейс поверх модели, которая ему не соответствует. Работает до первого конкурентного доступа,append, или требования согласованных метаданных. Mountpoint for Amazon S3 честнее — он прямо отказывается поддерживатьrenameи произвольную запись. - Забыть про незавершённые multipart-загрузки. Оплачиваются, невидимы, копятся годами.
- Включить версионирование без правила истечения неактуальных версий. Счёт растёт монотонно и навсегда.
- Перевести миллионы мелких объектов в архивный класс. Заплатить 5 000 $ за переходы, чтобы сэкономить 100 $ в месяц.
- Ходить в S3 через NAT Gateway вместо бесплатного Gateway Endpoint.
- Использовать SSE-KMS без Bucket Keys при высокой частоте запросов.
- Полагаться на ETag как на контрольную сумму содержимого.
- Строить lakehouse без регламента компакции и
expire_snapshots. Через полгода запросы деградируют в десять раз, и никто не поймёт почему. - Считать 11 девяток защитой от удаления. Нужен Object Lock, а не вера в durability.
- Не тестировать restore. Восстановление 300 ТБ из Deep Archive — это не «12 часов», это 12 часов на первый байт плюс сутки на выкачивание, плюс несколько тысяч долларов, о которых никто не предупредил.
- Игнорировать различия S3-совместимых реализаций. Код, отлаженный на MinIO, может упасть на реальном S3 (и наоборот) на conditional writes, семантике
LISTили лимитах multipart.
Когда объектное хранилище — неправильный выбор
- OLTP и любые точечные запросы с требованием единиц миллисекунд. 50 мс на объект — это приговор. Здесь ваш инструмент — https://courses.digitable.life/post/databases/02-postgresql/ или https://courses.digitable.life/post/databases/11-redis/.
- Частые мелкие обновления. Изменение одного байта переписывает весь объект. Если рабочая нагрузка — «много мелких
UPDATE», объектное хранилище не подходит ни в каком виде, даже под табличным форматом. - Строгие транзакции между несколькими сущностями. Даже с conditional writes вы получаете CAS над одним ключом, а не сериализуемость. Нужна серьёзная транзакционность в распределённой системе — смотрите https://courses.digitable.life/post/databases/18-newsql-and-distributed/.
- POSIX-семантика как требование. Легаси, которому нужны
rename, блокировки и произвольная запись, нужно ставить на настоящую сетевую ФС (EFS, CephFS, Lustre), а не эмулировать поверх S3. - Маленькие данные. До нескольких терабайт lakehouse со всей его машинерией — чистые накладные расходы. Одна PostgreSQL или ClickHouse справится быстрее, проще и дешевле.
- Тяжёлая раздача контента напрямую из бакета. Egress съест бюджет. Нужен CDN перед бакетом — или провайдер без платы за egress.
Мини-итог
- Объектное хранилище — это плоское key-value с большими неизменяемыми значениями, а не файловая система. Нет rename, нет append, нет частичной перезаписи, нет транзакций между объектами. Каждое архитектурное решение поверх S3 выводится из этих ограничений.
- Консистентность с 2020 года строгая, а с 2024 появились условные записи (
If-None-Match,If-Match) — единственный примитив взаимного исключения, и именно на нём стоят современные табличные форматы. - Счёт формируют не гигабайты, а запросы, egress, KMS, lifecycle-переходы и незавершённые multipart-загрузки. Считайте по формуле «хранение + retrieval·f + запросы», а не по цене за гигабайт.
- Архивные классы дёшевы только для крупных объектов: минимальный billable размер, минимальный срок хранения и цена PUT превращают экономию на мелочи в убыток.
- 11 девяток не защищают от вашего
DELETE. Защищают версионирование, Object Lock в режиме COMPLIANCE и протестированное восстановление. - Разделение storage и compute даёт независимое масштабирование и мультидвижковость ценой 500-кратной разницы в задержке. Кэш поверх объектного слоя не опция, а часть архитектуры.
- Lakehouse = Parquet (физический слой) + Iceberg/Delta (транзакционный слой) + каталог (точка сериализации коммита). Iceberg сегодня — ставка по умолчанию.
- Lakehouse требует регламентного обслуживания: компакция мелких файлов,
expire_snapshots, удаление осиротевших файлов. Без этого система тихо деградирует.
Источники
- Amazon S3 Developer Guide — модель данных, консистентность, multipart, Object Lock.
- S3 Performance Guidelines — лимиты на префикс, параллелизм, Range GET.
- Amazon S3 Update — Strong Read-After-Write Consistency — что именно изменилось в декабре 2020.
- Conditional writes in Amazon S3 —
If-None-MatchиIf-Match, основа современных табличных форматов. - Amazon S3 Pricing — актуальные цены классов, запросов и retrieval.
- Apache Iceberg Table Spec — формат метаданных, снапшоты, delete-файлы, эволюция схемы.
- Delta Lake Protocol — устройство
_delta_log, чекпойнты, deletion vectors. - Apache Hudi Concepts — timeline, copy-on-write и merge-on-read.
- Apache Parquet Format — row groups, страницы, статистика и bloom-фильтры.
- MinIO Documentation — erasure sets, серверные пулы, продакшн-раскладка дисков.
- Ceph RGW Documentation — устройство объектного шлюза и шардирования индекса бакета.
- KIP-405: Kafka Tiered Storage — как объектное хранилище стало слоем под брокером сообщений.
- Michael Armbrust и др., «Lakehouse: A New Generation of Open Platforms that Unify Data Warehousing and Advanced Analytics», CIDR 2021 — концепция lakehouse из первых рук.
- Martin Kleppmann, «Designing Data-Intensive Applications», главы 3 и 10 — колоночное хранение и пакетная обработка.
Что дальше
Мы разобрали хранилище, где данные — это неизменяемые байты, доступные по одному ключу. Следующая статья — про два семейства, которые двигаются в противоположные стороны от этой модели: графовые базы, где ценность именно в связях между записями, и встраиваемые key-value движки, где вся система умещается в библиотеку внутри вашего процесса: Графовые и key-value БД: Neo4j, Dgraph, etcd, RocksDB, LMDB.