
В первой части мы разобрались с архитектурой Apache Iceberg. Увидели, как иерархия метаданных позволяет находить нужную информацию без полного сканирования хранилища. Также стало понятно, почему Iceberg — это не вычислительный движок и не формат файлов, а табличная спецификация поверх объектного хранилища.
Теперь настало время ответить на практический вопрос: как все эти метаданные связать в единую систему, чтобы несколько движков могли работать с одними и теми же таблицами без хаоса. Помимо теории развернем HMS в Docker, настроим PyIceberg и разберем внутреннюю анатомию файла metadata.json.
Привет! Я Денис, старший бэкенд-разработчик в Selectel. Надеюсь, материал будет полезен инженерам данных и архитекторам. Мы рассмотрим Hive Metastore, AWS Glue, REST Catalog и Nessie и расскажем как выбрать подходящий инструмент под специфику проекта.
Содержание
→ Зачем нужен каталог метаданных
→ Виды каталогов
→ Как выбрать каталог метаданных
→ Регистрация namespace и создание таблицы
→ Поднимаем каталог локально
→ Внутренние механизмы каталога
→ Заключение

Зачем нужен каталог метаданных
Физически, Iceberg-таблица — просто набор файлов в объектном хранилище. Это могут быть как данные в формате Parquet, ORC или Avro, так и манифесты, и метаданные. Без внешнего сервиса, отслеживающего их существование, они ничем не отличаются от любого другого набора объектов в S3.
Ни один движок — будь то Spark, Trino или DuckDB — не смогут найти таблицу, поскольку не знают ее расположение и ссылку на актуальное состояние в metadata.json.
Именно эту задачу решает каталог метаданных. Он хранит указатель (metadata pointer) на текущий файл метаданных для каждой таблицы. Когда движок инициирует чтение, он обращается к каталогу, получает ссылку на актуальный metadata.json и далее спускается по иерархии — от списка манифестов к файлам манифестов и, наконец, к файлам данных.
Конечно, можно отсканировать warehouse для нахождения актуального состояния, но это, скорее, частное решение, которое не подходит для повседневного использования.
Главная задача каталога — атомарно обновлять этот указатель. Когда записывается новый снапшот (например, при вызове INSERT или MERGE), создается новый metadata.json. Однако он остается скрытым от процессов чтения до тех пор, пока каталог атомарно не переключит указатель со старого файла на новый. В случае неатомарного переключения возникает состояние гонки — параллельные процессы могут увидеть разные снапшоты одной таблицы. Без применения механизма CAS (compare-and-swap) подобных коллизий избежать невозможно.
Вспомним иерархию из первой части: каталог → файл метаданных → список манифестов → файл манифеста → файлы данных. Каталог — это самый верхний уровень и единственный изменяемый узел в структуре.
Транзакционная надежность Iceberg опирается на то, что каталог может атомарно заменить один указатель на другой.
Без каталога невозможно обеспечить консистентное чтение — гарантию того, что все процессы видят согласованное состояние таблицы. Представьте: Spark записал батч и обновил metadata.json в S3, но Trino может об этом и не узнать из‑за отсутствия информации о смене указателя. В результате Spark будет видеть новые данные, а Trino — старые.
В распределенной системе с несколькими движками подобная рассинхронизация гарантированно приведет к некорректным отчетам и потерянным часам на отладку. Стоит отметить, что зрелые ETL‑процессы работают не просто ежедневно, а ежечасно.
Каталог выступает единой точкой правды (Single Source of Truth) для всех компонентов платформы данных. Независимо от источника записи (Spark, Flink) и потребителя (Trino, DuckDB), все запросы направляются к одному реестру и получают единый действительный указатель. Именно этот механизм превращает разрозненный набор файлов в S3 в полноценную и актуальную таблицу.
Виды каталогов
Теперь, когда мы понимаем, зачем нужен каталог метаданных, разберемся, какие существуют варианты его реализации и в чем их отличие. Выбор подходящего решения — далеко не академический вопрос. От него зависит совместимость с вычислительными движками, объем поддерживаемой инфраструктуры, а также степень привязки к конкретному вендору.
Hive Metastore (HMS)
Это исторически первый и самый распространенный каталог в экосистеме Hadoop. HMS был создан еще в 2008 году как часть проекта Apache Hive и с тех пор стал де-факто стандартом. Практически любой движок, работающий с данными в HDFS или S3, умеет подключаться к нему «из коробки». Среди них Spark, Trino, Flink, Presto и DuckDB — подобных десятки.
Архитектурно HMS — это Java-программа (JVM), который хранит метаданные в реляционной СУБД (PostgreSQL, MySQL, MariaDB). Для развертывания HMS потребуется как минимум сам metastore-сервис и база данных под ним.
На практике это означает запуск контейнера с JVM ( около 2 ГБ RAM) и отдельный инстанс PostgreSQL (еще примерно 2 ГБ RAM). При практическом использовании в продакшине к этому добавятся системы мониторинга, резервного копирования баз данных, механизмы отказоустойчивости.
Для взаимодействия с движками HMS использует протокол Thrift. Это бинарный транспортный протокол, который требует специфичных клиентов — к сервису нельзя обратиться просто сделав curl-запрос, нужен Thrift-клиент или обертка. Типичными потребителями выступают Python-скрипты, использующие специализированные библиотеки, а также крупный коммерческий софт — например, Casandra. В настоящий момент протокол Thrift активно не развивается.
AWS Glue
Известно, что если есть распространенная проблема, то в AWS уже наверняка придумали ее решение. AWS Glue работает по модели Metastore as-a-Service. Развертывание собственных БД и JVM не требуется, всю инфраструктурную поддержку берет на себя провайдер. Каталог Glue поддерживает Iceberg-таблицы с 2022 года и глубоко интегрирован во всю экосистему AWS. Такое встраивание становится ощутимым плюсом при использовании таких сервисов, как Athena, EMR или Glue ETL.
Главный минус — в жесткой привязке к вендору. Glue ограничен конкретным аккаунтом и регионом AWS. При мультиоблачной инфраструктуре или использовании S3-совместимого хранилища другого провайдера (например, объектного хранилища Selectel), Glue не подойдет. Кроме того, он не поддерживает все возможности Iceberg — в частности, ограничены branch-операции.
Iceberg REST Catalog
Де‑факто это текущий стандарт, закрепленный в спецификации Apache Iceberg. По своей сути REST Catalog — это HTTP-протокол (в виде спецификации OpenAPI), который описывает, как движки должны взаимодействовать с каталогом. Ключевое слово здесь — спецификация. Важно подчеркнуть, что это не готовый продукт, а лишь описание, открытое для реализации любым разработчиком.Наряду с Glue, Amazon уже встроил это решение в свое S3‑хранилище под видом S3 Tables. В последующих статьях, мы еще вернемся к этому важному явлению на рынке.
Принципиальное отличие от HMS — в самой природе взаимодействия. REST Catalog не требует поддержания серверных сессий. Каждый HTTP-запрос содержит всю необходимую информацию для его обработки (как и любой REST-протокол). При этом конкретные реализации каталога могут хранить метаданные по‑разному: в реляционной БД (PostgreSQL, SQLite), в объектном хранилище или в памяти. Понятие stateless здесь характеризует именно протокол, а не слой хранения. Движок обращается по HTTP, получает указатель на metadata.json, после чего самостоятельно читает все метаданные из S3.
Создание REST Catalog стало ответом на архитектурные ограничения появившиеся в HMS. Протокол Thrift сложно расширять, а JVM добавляет тяжелую зависимость, тогда как связка HTTP и JSON — универсальный язык, понятный любому технологическому стеку. Сегодня спецификация REST Catalog — стандарт для построения платформ данных.
Project Nessie
Nessie — реализация REST-каталога с парадигмой версионирования, похожей на Git. Проект предоставляет собственный REST API и обеспечивает совместимость со спецификацией Iceberg REST Catalog Spec через специальный адаптер.
Такой подход позволяет движкам, поддерживающим REST Catalog, подключаться к Nessie — нет необходимости скачивать сторонние плагины или создавать собственные аналоги. Поверх стандартного протокола, Nessie добавляет модель ветвления: операции branch, tag, merge работают как в Git, но применяются к наборам данных.
В классическом REST Catalog (как и в HMS) указатель на таблицу один: «текущий» metadata.json — и все. При необходимости экспериментов с данными (например, ML-инженеру требуется переписать feature-файлы и проверить модель), приходится либо создавать отдельную таблицу, либо надеяться, что сторонние процессы не прочитают промежуточное, несогласованное состояние.
Nessie решает эту проблему иначе: создается отдельная ветка (branch), куда вносятся любые изменения (INSERT, UPDATE, DELETE). Видны они только в рамках созданной ветки. Основная ветка (main) остается нетронутой. Когда эксперимент завершен, выполняется слияние (merge), и обновленные данные попадают в основную ветку (main).
Пример сценария работы с Nessie.
API‑запрос
POST /v1/branches/experiment_v2создает веткуexperiment_v2.Данные в таблицу записываются в рамках этой ветки — движки Spark и Flink видят только данные
experiment_v2.Проверяется результат:
SELECT * FROM table AT BRANCH experiment_v2.Если все ОК, выполняется слияние в основную ветку
main:POST /v1/branches/experiment_v2/merge.Если эксперимент не удался, ветка
experiment_v2просто удаляется, без влияния наmain.
Это принципиально другой подход по сравнению с моделью одного указателя у HMS и стандартного REST Catalog. Nessie — целая система с дополнительной семантикой версионирования, реализованной поверх стандартного протокола.
Если вы ознакомились с первой частью статьи об Iceberg, то уже догадались, что здесь используется свойство Iceberg — Time Travel. Фактически ветки в Nessie — это именованные ссылки на конкретные снапшоты, а сама возможность «ходить по истории» появляется именно благодаря Time Travel в Iceberg.
Новые реализации REST-каталогов
Помимо HMS и Nessie, в 2024−2025 годах появилось несколько заметных реализаций REST Catalog.
Unity Catalog — открытый каталог от Databricks, ставший частью Linux Foundation. Он также добавляет слой управления данными: отслеживание их происхождения, контроль доступа и аудит. Решение позиционируется как унифицированный каталог для всей lakehouse‑архитектуры.
Polaris — реализация от Snowflake, переданная в Apache Software Foundation. Каталог Polaris работает по модели catalog-as-a-service и размещается в облачной платформе Snowflake, позволяя подключаться внешним движкам (тем же Spark или Trino) через REST API. Исходный код открыт.
Lakekeeper — независимая реализация с открытым исходным кодом на Rust. Легковесный, быстрый, без JVM. Хорошо подходит для минималистичных развертываний, но пока имеет меньшее сообщество по сравнению с HMS.

Бесплатное S3-хранилище на 30 дней
Для всех, кто ранее не использовал услугу в Selectel.
Как выбрать каталог метаданных
Теперь, когда мы знаем основные варианты, систематизируем критерии выбора. На практике решение редко сводится к «возьму самый новый». Важнее учитывать сложившуюся экосистему, инфраструктурные ограничения и объем ресурсов, выделяемых на поддержку.
Экосистема и совместимость
Зададимся вопросом: какие движки уже используются или планируются?
Например, если платформа строится на базе Spark и Trino, и оба движка работают с Iceberg, необходимо проверить, какие каталоги они поддерживают «из коробки». Spark и Flink сразу работают с HMS и REST Catalog, Trino — c HMS, REST Catalog и Glue. При использовании устаревших версий движков, HMS оказывается единственным вариантом.
Версионирование данных
Следующий вопрос: нужны ли инженерам данных и ML‑командам отдельные ветки одного датасета?
Когда несколько экспериментов идут параллельно и каждый работает со своей версией таблицы, то Nessie, LakeFS с их Git‑подобной моделью (branch/tag/merge) — единственные варианты среди перечисленных. Ни HMS, ни стандартный REST Catalog не поддерживают ветвление данных.
Операционная нагрузка
HMS — тяжелый сервис: как мы подсчитывали выше, потребуют нескольких гигабайт только на запуск JVM. Также понадобятся открытые порты для Thrift и REST. Не забываем про мониторинг, бэкапы БД, и настройку пула подключений для продакшен.
REST Catalog (Lakekeeper, Polaris) — легче на порядок. Тот же Lakekeeper на Rust не требует JVM и существенно экономит оперативную память по сравнению с HMS — идеально для небольшой дата платформы на старе. Также он может использовать SQLite или внешний PostgreSQL. Docker-образ — один контейнер вместо двух. Однако для систем с продолжительной историей миграция на новые каталоги может оказаться затруднительной.
Несколько движков и согласованность данных
Критически важно, чтобы все движки в платформе обращались к одному и тому же каталогу. Если Spark пишет в HMS, а Trino читает из Glue — рассинхронизация неизбежна, ведь потребитель не увидит новых данных. Проблема отнюдь не теоретическая. В крупных компаниях, где разные команды работают с инфраструктурой изолировано, подобные инциденты случаются регулярно.
Правило: один каталог на платформу. Если нужно разделить окружения для продакшен и разработки, то используется пространство имен внутри одного каталога, а не разные каталоги.
Сообщество и зрелость продукта
Правильно ли считать, что давняя история проекта и его большое сообщество — основные критерии качества?
Если так, то HMS — самый проверенный каталог: более 15 лет в продакшене, огромное сообщество, тысячи интеграций. Если нужно, чтобы «просто работало» — HMS даст надежность. Это не молодой стартап, его разработали инженеры еще до активной экспансии со стороны AI Code агентов.
Спецификация REST Catalog гораздо новее. Она утверждена в 2023−2024 годах, но уже поддерживается всеми основными движками. Конкретные реализации — Lakekeeper, Polaris, Unity Catalog — да, еще молодые, но сообщество вокруг них формируется быстро.
Советы для минималки
Если нет специфических требований (таких как Git-подобное версионирование данных или привязка к AWS), то самый простой путь входа — Iceberg REST Catalog.
Если нужна максимальная совместимость с существующими движками, минимум проблем на старте, обоснованный выбор — HMS 4.2.0 с REST-интерфейсом, который дает оба протокола в одном сервисе. Именно этот вариант мы и развернем для примера.
Поднимаем каталог локально
Перейдем к практике.
Понадобится поднять HMS 4.2.0 в Docker Compose с REST-интерфейсом и подключить его к полностью совместимому с S3 хранилищу.
Также потребуются S3-ключи. Под спойлером — как их получить.
Прежде всего, конечно, понадобится сам бакет в S3. Чтобы его создать, перейдите в панель управления → Продукты → S3 → Создать бакет. Сконфигурируйте все под свою задачу и нажмите «Создать бакет».
В проекте слева кликните на «S3-ключи» → Создать S3-ключ. Если вы еще не создавали сервисного пользователя, сейчас самое время это сделать.

После нажатия на кнопку «Создать S3-ключ» будет сгенерирована пара ключей: Access Key и Secret Key. Имейте в виду, что Secret Key показывается только один раз в этот момент, он не хранится в открытом виде, поэтому его лучше сохранить.

С S3‑ключами разобрались. Продолжаем.
В файл .env добавим записи:
AWS_ACCESS_KEY_ID=<your_access_key> AWS_SECRET_ACCESS_KEY=<your_secret_key>
Далее нам нужен Docker-файл для Metastore с нужными зависимостями.
На момент написания статьи актуальная версия Hive — 4.2.0. Когда выйдет новая, понадобится адаптировать имя образа.
FROM apache/hive:standalone-metastore-4.2.0 ARG HADOOP_AWS_VERSION=3.4.1 ARG AWS_JAVA_SDK_V2_VERSION=2.29.1 ARG POSTGRES_JDBC_VERSION=42.7.4 USER root RUN rm -f /opt/hive/lib/hadoop-aws-*.jar /opt/hive/lib/aws-java-sdk-bundle-*.jar /opt/hive/lib/software-amazon-awssdk-bundle-*.jar && \ rm -f /opt/hadoop/share/hadoop/tools/lib/hadoop-aws-*.jar /opt/hadoop/share/hadoop/tools/lib/bundle-*.jar /opt/hadoop/share/hadoop/tools/lib/aws-java-sdk-bundle-*.jar && \ curl -fSL -o /tmp/hadoop-aws-${HADOOP_AWS_VERSION}.jar https://repo1.maven.org/maven2/org/apache/hadoop/hadoop-aws/${HADOOP_AWS_VERSION}/hadoop-aws-${HADOOP_AWS_VERSION}.jar && \ curl -fSL -o /tmp/aws-java-sdk-bundle-${AWS_JAVA_SDK_V2_VERSION}.jar https://repo1.maven.org/maven2/software/amazon/awssdk/bundle/${AWS_JAVA_SDK_V2_VERSION}/bundle-${AWS_JAVA_SDK_V2_VERSION}.jar && \ curl -fSL -o /tmp/postgresql-${POSTGRES_JDBC_VERSION}.jar https://jdbc.postgresql.org/download/postgresql-${POSTGRES_JDBC_VERSION}.jar && \ cp /tmp/hadoop-aws-${HADOOP_AWS_VERSION}.jar /opt/hive/lib/ && \ cp /tmp/aws-java-sdk-bundle-${AWS_JAVA_SDK_V2_VERSION}.jar /opt/hive/lib/ && \ cp /tmp/postgresql-${POSTGRES_JDBC_VERSION}.jar /opt/hive/lib/ && \ cp /tmp/hadoop-aws-${HADOOP_AWS_VERSION}.jar /opt/hadoop/share/hadoop/tools/lib/ && \ cp /tmp/aws-java-sdk-bundle-${AWS_JAVA_SDK_V2_VERSION}.jar /opt/hadoop/share/hadoop/tools/lib/ && \ rm /tmp/*.jar && \ echo "Installed jars (hive):" && ls -1 /opt/hive/lib/hadoop-aws-*.jar /opt/hive/lib/aws-java-sdk-bundle-*.jar /opt/hive/lib/postgresql-*.jar && \ echo "Installed jars (hadoop):" && ls -1 /opt/hadoop/share/hadoop/tools/lib/hadoop-aws-*.jar /opt/hadoop/share/hadoop/tools/lib/aws-java-sdk-bundle-*.jar COPY conf/core-site.xml /opt/hadoop/etc/hadoop/core-site.xml USER hive
Минимальная конфигурация для Docker Compose:
services: postgres: image: postgres:16 environment: POSTGRES_USER: metastore POSTGRES_PASSWORD: metastore123 POSTGRES_DB: metastore ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U metastore"] interval: 5s timeout: 5s retries: 5 networks: - metastore-net hive-metastore: build: context: . dockerfile: Dockerfile.metastore depends_on: postgres: condition: service_healthy environment: SERVICE_NAME: metastore DB_DRIVER: postgres AWS_ACCESS_KEY_ID: <your_access_key> AWS_SECRET_ACCESS_KEY: <your_secret_key> ports: - "9083:9083" # Thrift API - "8080:8080" # Iceberg REST Catalog volumes: - ./conf/hive-site.xml:/opt/hive/conf/metastore-site.xml:ro restart: unless-stopped networks: - metastore-net volumes: pgdata: networks: metastore-net: ipam: config: - subnet: "10.99.0.0/16"
Разберем ключевые параметры конфигурации.
metastore.warehouse.dir— корневой путь в S3, куда HMS будет складывать данные таблиц. В нашем примере,s3a://warehouse/— это бакет в объектном хранилище Selectel.fs.s3a.endpoint— адрес подключения S3-совместимого хранилища. Для Selectel этоhttps://s3.ru-1.storage.selcloud.ru, аru-1— зона доступности (актуальное значение можно уточнить в панели управления).fs.s3a.path.style.access=true— обязательный флаг для большинства S3‑совместимых хранилищ. AWS использует адресацию через виртуальный хост (virtual-hosted style,bucket.s3.amazonaws.com), а Selectel — на основе пути (path style,s3.example.com/bucket).AWS_ACCESS_KEY_IDиAWS_SECRET_ACCESS_KEY— учетные данные, хранимые в файле.env, который обязательно должен быть добавлен в.gitignore, чтобы не попасть в репозиторий
Конфигурационный файл metastore-site.xml содержит минимальный набор параметров для связки HMS с REST Catalog:
<?xml version="1.0" encoding="UTF-8"?> <configuration> <!-- Thrift API --> <property> <name>metastore.thrift.uris</name> <value>thrift://0.0.0.0:9083</value> </property> <!-- PostgreSQL backend --> <property> <name>javax.jdo.option.ConnectionDriverName</name> <value>org.postgresql.Driver</value> </property> <property> <name>javax.jdo.option.ConnectionURL</name> <value>jdbc:postgresql://postgres:5432/metastore</value> </property> <property> <name>javax.jdo.option.ConnectionUserName</name> <value>metastore</value> </property> <property> <name>javax.jdo.option.ConnectionPassword</name> <value>metastore123</value> </property> <property> <name>datanucleus.schema.autoCreateAll</name> <value>true</value> </property> <!-- Iceberg REST Catalog Servlet --> <property> <name>metastore.catalog.servlet.port</name> <value>8080</value> </property> <property> <name>hive.metastore.catalog.servlet.port</name> <value>8080</value> </property> <property> <name>metastore.catalog.servlet.auth</name> <value>none</value> </property> <property> <name>hive.metastore.iceberg.catalog.servlet.path</name> <value>iceberg</value> </property> <!-- S3 / Selectel Object Storage --> <property> <name>fs.defaultFS</name> <value>s3a://<your-bucket-name></value> </property> <property> <name>fs.s3a.access.key</name> <value><your_access_key></value> </property> <property> <name>fs.s3a.secret.key</name> <value><your_secret_key></value> </property> <property> <name>fs.s3a.aws.credentials.provider</name> <value>org.apache.hadoop.fs.s3a.SimpleAWSCredentialsProvider</value> </property> <property> <name>fs.s3a.endpoint</name> <value>s3.<your-region>.storage.selcloud.ru</value> </property> <property> <name>fs.s3a.impl</name> <value>org.apache.hadoop.fs.s3a.S3AFileSystem</value> </property> <property> <name>fs.s3a.path.style.access</name> <value>true</value> </property> <property> <name>fs.s3a.connection.ssl.enabled</name> <value>true</value> </property> <property> <name>fs.s3a.connection.establish.timeout</name> <value>30000</value> </property> <property> <name>fs.s3a.connection.timeout</name> <value>200000</value> </property> <property> <name>fs.s3a.socket.timeout</name> <value>200000</value> </property> <!-- Warehouse --> <property> <name>metastore.warehouse.dir</name> <value>s3a://<your-bucket-name>/warehouse</value> </property> <property> <name>hive.metastore.warehouse.external.dir</name> <value>s3a://<your-bucket-name>/warehouse</value> </property> <!-- Skip managed directory creation (S3 compatibility) --> <property> <name>hive.metastore.database.create.skip.dir</name> <value>true</value> </property> </configuration>
Запускаем:
docker-compose up -d
Ждем, пока HMS поднимет REST Catalog (может занять до 30 секунд после старта контейнера):
curl -s http://localhost:8080/iceberg/v1/config | python3 -m json.tool
Ожидаемый ответ:
{ "defaults": {}, "overrides": {}, "endpoints": [ "GET v1/config", "GET /v1/{prefix}/namespaces", "POST /v1/{prefix}/namespaces", "HEAD /v1/{prefix}/namespaces/{namespace}", "GET /v1/{prefix}/namespaces/{namespace}", "DELETE /v1/{prefix}/namespaces/{namespace}", "POST /v1/{prefix}/namespaces/{namespace}/properties", "GET /v1/{prefix}/namespaces/{namespace}/tables", "POST /v1/{prefix}/namespaces/{namespace}/tables", "HEAD /v1/{prefix}/namespaces/{namespace}/tables/{table}", "GET /v1/{prefix}/namespaces/{namespace}/tables/{table}", "POST /v1/{prefix}/namespaces/{namespace}/register", "POST /v1/{prefix}/namespaces/{namespace}/tables/{table}", "DELETE /v1/{prefix}/namespaces/{namespace}/tables/{table}", "POST /v1/{prefix}/tables/rename", "POST /v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics", "POST /v1/{prefix}/transactions/commit", "GET /v1/{prefix}/namespaces/{namespace}/views", "HEAD /v1/{prefix}/namespaces/{namespace}/views/{view}", "GET /v1/{prefix}/namespaces/{namespace}/views/{view}", "POST /v1/{prefix}/namespaces/{namespace}/views", "POST /v1/{prefix}/namespaces/{namespace}/views/{view}", "POST /v1/{prefix}/views/rename", "DELETE /v1/{prefix}/namespaces/{namespace}/views/{view}" ] }
Список namespace:
curl -s http://localhost:8080/iceberg/v1/namespaces
Вывод:
{"namespaces":[["default"]],"next-page-token":null}
Регистрация namespace и создание таблицы
Каталог работает. Теперь создадим структуру: namespace (логическая директория для таблиц) и саму таблицу. В Iceberg namespace — это аналог схемы в PostgreSQL или базы данных в Hive: группируются таблицы и задаются общие настройки, такие как путь в S3.
В качестве инструмента взаимодействия воспользуемся PyIceberg — официальный Python-клиент. Версию жестко зафиксируем из‑за активных изменений в API:
pip install "pyiceberg[pyarrow,s3fs]>=0.10.0"
После установки перейдем к коду. Задача — выполнить базовую инициализацию. Нужно подключиться к нашему локальному каталогу, создать логическое пространство имен и зарегистрировать пустую таблицу с настроенной схемой партиционирования.
from pyiceberg.catalog import load_catalog from pyiceberg.schema import Schema from pyiceberg.types import NestedField, StringType, LongType, TimestamptzType from pyiceberg.partitioning import PartitionSpec, PartitionField from pyiceberg.transforms import DayTransform from pyiceberg.exceptions import NamespaceAlreadyExistsError import pyarrow as pa from datetime import datetime, timezone catalog = load_catalog( "local", **{ "type": "rest", "uri": "http://localhost:8080/iceberg", "warehouse": "s3a://<your-bucket-name>/warehouse", "s3.endpoint": "https://s3.<your-region>.storage.selcloud.ru", "s3.access-key-id": "<your_access_key>", "s3.secret-access-key": "<your_secret_key>", "s3.path-style-access": "true", }, ) try: catalog.create_namespace("analytics", properties={"location": "s3a://<your-bucket-name>/warehouse/analytics"}) print("Namespace 'analytics' created") except NamespaceAlreadyExistsError: print("Namespace 'analytics' already exists") try: schema = Schema( NestedField(field_id=1, name="event_id", field_type=LongType(), required=False), NestedField(field_id=2, name="event_type", field_type=StringType(), required=False), NestedField(field_id=3, name="timestamp", field_type=TimestamptzType(), required=False), ) partition_spec = PartitionSpec( spec_id=0, fields=[ PartitionField( source_id=3, field_id=1000, transform=DayTransform(), name="timestamp_day", ) ], ) catalog.create_table("analytics.events", schema=schema, partition_spec=partition_spec) print("Table 'analytics.events' created") except Exception as e: if "already exists" in str(e).lower(): print("Table 'analytics.events' already exists") else: raise table = catalog.load_table("analytics.events") data = pa.table({ "event_id": pa.array([1, 2], type=pa.int64()), "event_type": pa.array(["click", "view"], type=pa.string()), "timestamp": pa.array( [ datetime(2026, 7, 24, 10, 0, tzinfo=timezone.utc), datetime(2026, 7, 24, 10, 1, tzinfo=timezone.utc), ], type=pa.timestamp("us", tz="UTC"), ), }) table.append(data) print("Data appended") result = table.scan().to_arrow() print(f"Rows: {result.num_rows}") print(f"event_id: {result['event_id'].to_pylist()}") print(f"event_type: {result['event_type'].to_pylist()}") print(f"timestamp: {result['timestamp'].to_pylist()}")
Ожидаемый вывод:
Namespace 'analytics' created Table 'analytics.events' created Data appended Rows: 2 event_id: [1, 2] event_type: ['click', 'view'] timestamp: [datetime.datetime(2026, 7, 24, 10, 0, tzinfo=zoneinfo.ZoneInfo(key='UTC')), datetime.datetime(2026, 7, 24, 10, 1, tzinfo=zoneinfo.ZoneInfo(key='UTC'))]
Разберем ключевые моменты:
type: rest— подключение через Iceberg REST Catalog Spec (HTTP), не Thrift;uri:http://localhost:8080/iceberg— адрес REST Catalog servlet;warehouse— путь в S3, где будут храниться данные таблиц;s3.path-style-access: "true"— обязательно для Selectel Object Storage;поля указаны как
required=False— PyArrow всегда создает nullable-колонки, и приrequired=Trueвозникнет ошибка совместимости схем;NamespaceAlreadyExistsError— правильный способ обработки дублирования namespace (неlist_namespaces(), который может вернуть данные в неожиданном формате);предупреждение
Unable to resolve region for bucket— безобидно. Selectel не использует AWS-регионы, а PyIceberg пытается определить регион для Signer.
Внутренние механизмы каталога
Теперь самое интересное: заглянем под капот и разберемся, как именно каталог обеспечивает атомарность и что происходит с метаданными при изменении таблицы.
aws --endpoint-url https://s3.<your-region>.storage.selcloud.ru \ s3 ls s3://<your-bucket-name>/warehouse/analytics.db/ aws --endpoint-url https://s3.<your-region>.storage.selcloud.ru \ s3 ls --recursive s3://<your-bucket-name>/warehouse/analytics.db/events/
Ожидаемый вывод:
2026-08-02 16:00:57 1408 warehouse/analytics.db/events/data/timestamp_day=2026-07-24/00000-0-6732f683-3f8b-41a2-8b65-6d6f7ac8fa57.parquet 2026-08-02 16:00:56 888 warehouse/analytics.db/events/metadata/00000-fb4fc25b-e5f0-44b4-a713-fefe75bee9b1.metadata.json 2026-08-02 16:00:58 1715 warehouse/analytics.db/events/metadata/00001-994c1fe9-ef45-4560-8d30-9e20c36c32d1.metadata.json 2026-08-02 16:00:58 4616 warehouse/analytics.db/events/metadata/6732f683-3f8b-41a2-8b65-6d6f7ac8fa57-m0.avro 2026-08-02 16:00:58 1800 warehouse/analytics.db/events/metadata/snap-6335359114163657760-0-6732f683-3f8b-41a2-8b65-6d6f7ac8fa57.avro
Что примечательного:
data/timestamp_day=2026-07-24/— Parquet-файл с данными, партицияtimestamp_dayсоздана автоматически трансформациейDayTransform;metadata/00000-*.metadata.json— первый metadata-файл (пустая таблица послеCREATE TABLE);metadata/00001-*.metadata.json— второй metadata-файл (послеINSERT);metadata/snap-*-.avro— список манифестов для снапшота;metadata/*-m0.avro— список data-файлов.
Скачиваем актуальный metadata.json:
aws --endpoint-url https://s3.<your-region>.storage.selcloud.ru \ s3 cp s3://<your-bucket-name>/warehouse/analytics.db/events/metadata/00001-994c1fe9-ef45-4560-8d30-9e20c36c32d1.metadata.json - | python3 -m json.tool
Реальный вывод (после INSERT):
{ "format-version": 2, "table-uuid": "34275392-39e0-41ee-af29-a36fb8c1f892", "location": "s3a://ice-iceberg/warehouse/analytics.db/events", "last-sequence-number": 1, "last-updated-ms": 1785686458099, "last-column-id": 3, "current-schema-id": 0, "schemas": [ { "type": "struct", "schema-id": 0, "fields": [ {"id": 1, "name": "event_id", "required": false, "type": "long"}, {"id": 2, "name": "event_type", "required": false, "type": "string"}, {"id": 3, "name": "timestamp", "required": false, "type": "timestamptz"} ] } ], "default-spec-id": 0, "partition-specs": [ { "spec-id": 0, "fields": [ {"name": "timestamp_day", "transform": "day", "source-id": 3, "field-id": 1000} ] } ], "last-partition-id": 1000, "default-sort-order-id": 0, "sort-orders": [ {"order-id": 0, "fields": []} ], "properties": { "write.parquet.compression-codec": "zstd" }, "current-snapshot-id": 6335359114163657760, "refs": { "main": {"snapshot-id": 6335359114163657760, "type": "branch"} }, "snapshots": [ { "sequence-number": 1, "snapshot-id": 6335359114163657760, "timestamp-ms": 1785686458099, "summary": { "operation": "append", "added-files-size": "1408", "added-data-files": "1", "added-records": "2", "changed-partition-count": "1", "total-data-files": "1", "total-delete-files": "0", "total-records": "2", "total-files-size": "1408", "total-position-deletes": "0", "total-equality-deletes": "0" }, "manifest-list": "s3a://ice-iceberg/warehouse/analytics.db/events/metadata/snap-6335359114163657760-0-6732f683-3f8b-41a2-8b65-6d6f7ac8fa57.avro", "schema-id": 0 } ], "statistics": [], "partition-statistics": [], "snapshot-log": [ {"timestamp-ms": 1785686458099, "snapshot-id": 6335359114163657760} ], "metadata-log": [ {"timestamp-ms": 1785686456672, "metadata-file": "s3a://ice-iceberg/warehouse/analytics.db/events/metadata/00000-fb4fc25b-e5f0-44b4-a713-fefe75bee9b1.metadata.json"} ] }
Снова разберем ключевые поля:
current-snapshot-id: 6335359114163657760— указатель на текущий снапшот (именно для обновления этого состояния при записи каталог атомарно переключает указатель на новую версию метаданных);snapshots[0].summary.operation: "append"— операция добавления данных;snapshots[0].summary.added-records: "2"— добавлено две записи;snapshots[0].manifest-list— ссылка на список манифестов в формате Avro, , с помощью которого движок находит целевые файлы с данными;metadata-log— история metadata-файлов;00000-*.metadata.json— файл до выполненияINSERT(еще пустая таблица);00001-*.metadata.json— послеINSERT;refs.main— ссылка на ветку main с текущимsnapshot-id;properties.write.parquet.compression-codec: "zstd"— кодек сжатия файлов Parquet (PyIceberg использует zstd по умолчанию).
Механизм атомарного переключения
Ключевой вопрос: как именно каталог обеспечивает атомарность обновления указателя? Способ зависит от типа каталога.
В случае с HMS движок не манипулирует указателем напрямую. Вместо этого он вызывает Thrift-операцию alter_table с новым параметром metadata-file, после чего HMS самостоятельно обновляет запись в СУБД.
Атомарность операции обеспечивается на уровне БД через механизм сравнения (compare-and-swap):
UPDATE TBLS SET ... WHERE metadata-file = 'expected_old'
Если другой процесс успел обновить указатель раньше, то WHERE не найдет строку, и движок получит ошибку конфликта.
В реализациях REST Catalog, использующих собственное выделенное хранилище (Lakekeeper, Polaris) применяется аналогичный CAS-механизм, но поверх своей внутренней БД. Движок отправляет HTTP-запрос с параметром expected-identifier (в котором передается текущий путь к файлу метаданных metadata-location). Каталог обновляет указатель только если ожидаемое значение совпадает с фактическим.
Если другой процесс успел обновить указатель раньше, каталог вернет ошибку конфликта (409 Conflict), и движку придется повторить операцию с учетом нового состояния.
CAS (Compare-And-Swap) — базовый примитив для реализации концепции optimistic concurrency control. Идея проста: записать новое значение, если текущее совпадает с ожидаемым. При обнаружении сторонних изменений, транзакция отклоняется. Тогда процесс должен заново прочитать актуальное состояние, применить к нему свои изменения и повторить попытку.
В Iceberg этот алгоритм реализован на уровне каталога, а не файловой системы.
Механизм хранения указателя
В HMS при выполнении Thrift-операции get_table возвращается объект Table, содержащий поле metadata-file:
{"timestamp-ms": 1785686456672, "metadata-file": "s3a://ice-iceberg/warehouse/analytics.db/events/metadata/00000-fb4fc25b-e5f0-44b4-a713-fefe75bee9b1.metadata.json"}
Именно metadata-file — и есть тот самый указатель. Движок берет его значение, обращается к файлу в S3 и запускает алгоритм спуска по иерархии метаданных.
На физическом уровне в PostgreSQL (основе HMS) этот указатель хранится в таблице TBLS через связанную структуру SDS (Storage Descriptor), которая содержит путь к актуальным метаданным (location). Когда в каталог приходит Thrift-запрос, он транслируется в SQL-запрос Именно на этом уровне — за счет стандартных механизмов транзакций PostgreSQL — и обеспечивается атомарность операции.
Заключение
Итак, мы разобрались, зачем таблицам Iceberg нужен каталог метаданных, какие решения есть на рынке (HMS, Glue, REST Catalog, Nessie) и как сделать правильный выбор. Но главное — мы перешли от теории к практике: локально развернули HMS 4.2.0 в Docker Compose, создали пространство имен и таблицу с помощью PyIceberg, заглянули внутрь metadata.json и на реальном примере увидели, как именно каталог обеспечивает атомарное обновление указателя через механизм CAS.
Наш каталог готов к работе. В следующей части подключим к нему вычислительные движки — Trino, Spark — и начнем анализировать данные в таблицах Iceberg. А пока пишите в комментариях, какие движки вы используете и о чем было бы интересно узнать подробнее.