# Учебная дата-платформа кликстрима Стенд для работы с кликстримом. Преемник учебного стенда [clickstream-ch-kafka-superset-demo](https://git.dementev.space/ddmitry/clickstream-ch-kafka-superset-demo). ## Статус Репозиторий строится по спеке [«Боевой реализм стенда (v2)»](docs/specs/2026-07-30-stand-v2-realism.md). Сейчас работают кластер ClickHouse из двух шардов, отдельный clickhouse-keeper, односерверная Kafka в режиме KRaft, Airflow 3.3, Superset, Prometheus, Grafana, экспортёр метрик Kafka и общая база Postgres для метаданных. Генератор кликстрима в [`generator/`](generator/) собран целиком со стороны клиента: у него есть контракт схемы события, из которого собрано [описание выгрузки](docs/formats/clickstream-event.md), модельный мир и проигрыватель, отправляющий дни в Kafka или в файл. События доезжают до типизированного `ods.event`, а `dds.event_v` отдаёт их на языке бизнес-модели без копии данных. `make up` наполняет стенд стартовым миром — первой неделей модельного времени. ## Быстрый старт Нужны Docker с Compose и около 8 ГБ памяти, доступной Docker. Это не объём ноутбука, а то, что отдано самому Docker: в Docker Desktop и WSL2 он живёт внутри виртуальной машины и получает лишь часть памяти хозяина. Сколько выдано сейчас, в байтах, покажет `docker info --format '{{.MemTotal}}'`. В WSL2 это поднимается параметром `memory` в файле `.wslconfig` домашнего каталога пользователя Windows; после правки нужен `wsl --shutdown`. Если своей машины не хватает, стенд одинаково хорошо живёт на недорогом VPS. Проверкам на поднятом стенде также нужны `curl`, `jq`, `awk`, `grep`, `sed`, `tail`, `sleep` и `timeout`. `jq` нужен и самому `make up`: им читается опись мира, по которой он ждёт заливки. По умолчанию должны быть свободны порты `23000`, `28080`, `28088`, `28123`, `28124`, `29000`, `29001`, `29090` и `29092`. Проверкам без стенда — `make config-test` и `make lint` в корне, всем целям генератора в `generator/` — нужен `uv`. Сначала скопируйте настройки стенда: ```bash cp .env.example .env make up make smoke make check-clickhouse make check-services ``` Первому знакомству нужны все три проверки на стенде: `make smoke` говорит, что стенд собран, `make check-clickhouse` — что кластер работает кластером, а `make check-services` — что Airflow запускает DAG, а Superset ходит в базу. Дальше, в рабочей петле, обычно хватает `make smoke`. `.env.example` — образец обязательной локальной настройки. В нём живут имя экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь, кроме пульта мира — о нём ниже. `compose.yaml` не дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит скопировать образец. Внутренние адреса, имена топиков и версии образов описывают сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile. Учётные данные Postgres и Grafana применяются при создании их томов. После первого запуска меняйте их только вместе с `make clean`: команда удалит все локальные данные стенда, а следующий `make up` создаст их с новыми значениями. `make up` собирает локальные образы Airflow, Superset и Grafana, поднимает весь стенд и ждёт здорового состояния долгоживущих контейнеров. В образ Airflow добавлены закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и `superset-init` завершаются с кодом 0. Первый обновляет схему Airflow, подготавливает администратора и два подключения к `clickhouse-01`. Второй обновляет Superset, создаёт администратора и импортирует подключение к `clickhouse-02`. На ноутбуке в режиме энергосбережения приёмочный подъём с пустых томов занял около десяти минут, повторный на живом стенде — около трёх. `make smoke` за секунды спрашивает, собран ли стенд: зависимости машины, здоровье контейнеров, устройство keeper, ответ Kafka с машины через отображённый порт, четыре цели Prometheus, два источника Grafana, компоненты Airflow и подготовленное подключение к `clickhouse-01`. В конце проверка спрашивает у Docker, не убивало ли ядро что-нибудь в долгоживущих контейнерах за нехватку памяти и не включалась ли политика перезапуска: убитый контейнер Docker поднимает сам, и проверка состояния об этом промолчит. Двенадцать проверок здоровья сразу после `make up --wait` повторяют то, чего Compose уже дождался: у каждой долгоживущей службы есть своя `healthcheck`. Оставлены они потому, что первый вопрос к стенду всё равно «всё ли живо», а ответ на него стоит меньше секунды. Устройство keeper — другое дело: он должен работать от пользователя `clickhouse`, с пределом в 262144 открытых файла и со своим томом под данные. Всё это объявлено в `compose.yaml`, но здоровым keeper выглядит и без этого, поэтому смоук спрашивает у живого контейнера, дошли ли объявленные настройки до процесса. Kafka по той же причине спрашивают снаружи. Её `healthcheck` обращается к брокеру изнутри контейнера и по внутреннему слушателю, поэтому здоровой Kafka остаётся и тогда, когда объявленный наружу адрес ведёт не туда. Клиент с машины в этом случае подключается, получает метаданные и молча виснет на адресе, которого с его стороны не существует. Смоук идёт тем же путём, что и будущий генератор: стучится в отображённый порт и просит список топиков. `make check-services` проверяет то, ради чего приходится ждать службу: работу с топиком Kafka с машины — создан, найден, удалён, — ручной запуск пробников `test_clickhouse` и `test_kafka`, вход в Superset, его метаданные и подключение к `clickhouse-02`. Первый пробник создаёт таблицы на обеих нодах и читает через `Distributed` на ноде 2 строку из локальной таблицы ноды 1. Второй пишет в Kafka и читает свой маркер. Временный топик проверки с машины и запуски DAG удаляются; постоянный топик пробника сохраняется, а старые записи чистит Kafka. `make check-clickhouse` запускает отдельную глубокую проверку ClickHouse: описание кластера, макросы, связь с keeper, `ReplicatedMergeTree`, `Distributed`, очередь распределённых DDL и очистку временных таблиц. Девятая из десяти проверок спрашивает словарь товаров на обеих нодах. Последняя — единственная на настоящих событиях: она подневно сверяет стартовый мир с описью и при расхождении говорит, где искать — в событиях или в браке. `make config-test` проверяет Compose, синтаксис файлов DAG и пробельные ошибки в diff без запуска стенда. Обычный рабочий цикл — `make config-test` и `make smoke`; остальные цели гоняют тогда, когда правка их касается. Что утверждает каждая проверка, нужен ли ей поднятый стенд, сколько стоит прогон и куда класть новую — в [карте проверок](docs/architecture/testing.md). Остановить контейнеры без удаления данных можно командой `make down`. Если нужно вернуть прикладной мир к началу, `make rebuild-storage` запустит в Airflow его явную пересборку: удалит прикладные базы ClickHouse, топики и позиции потребителей Kafka, затем создаст схему и данные заново. Службы и их тома останутся на месте. `make clean` удаляет весь стенд вместе с именованными томами и для обычной разработки не нужен. Перед пересборкой поставьте `world_live` на паузу и дайте уже начатому работнику закончить день. ## Стартовый мир Стенд поднимается не пустым: `make up` через REST API запускает даг Airflow `world_initialize`. Тот создаёт прикладные топики и схему, собирает образ генератора и играет в `hits` первые восемь дней модельного времени — понедельник по понедельник, 401 185 событий. Следом второй запуск генератора отправляет в `orders` семь слепков — за дни 0…6. Даг ждёт приёма обоих источников и только тогда ставит позицию мира в `8`. Так у всякой лабы есть данные, и всегда одни и те же. Последний день намеренно неполон: события дня 7 уже приехали, а его слепок уедет только с первым ходом мира. Поэтому на свежем стенде правый край графика выручки отстаёт от кликстрима и дозаполняется по мере движения мира — так виден разный темп потокового трекера и ночной выгрузки магазина. Повторный `make up` безопасен: `world_initialize` видит готовую позицию, сообщает в журнале, что мир уже существует, и не применяет DDL, не собирает образ и не заливает данные повторно. Изменённую схему применяют адресно либо явной командой `make rebuild-storage`. Сам мир в git не хранится — он чистая функция зерна, и держать его в репозитории значило бы держать там кэш. Вместо него лежит **опись мира**, [`data/world-inventory.json`](data/world-inventory.json): зерно, версия генератора, хеш каталога товаров и по строке на каждый день — дата, число событий и хеш его байтов. У дней с закрытым окном там же лежат счётчики классов расхождений по заказам. Опись отвечает на единственный вопрос: тот ли это мир, что был вчера. Спрашивают её двое. `make test` в `generator/` сверяет опись с тем, что собирается из кода сегодня: правка генератора меняет мир, и опись надо пересобрать — `make inventory` там же. `make check-clickhouse` в корне сверяет с описью то, что доехало до `ods.event`, подневно. Хеш дня можно пересчитать и руками — это обычный `sha256sum` файла, который пишет файловый приёмник: ```bash uv run --project generator python -m clickstream_generator batch \ --day 0 --file tmp/day0.jsonl sha256sum tmp/day0.jsonl ``` ## Как позвать генератор События производит генератор из [`generator/`](generator/). Модельный день — функция зерна и номера дня, поэтому один и тот же день всегда даёт те же события: обрыв лечится повтором. Позицию на оси генератор не помнит — какой день играть, решает зовущий. Режима два, и различаются они темпом. **Пакетный** гонит день подряд, без пауз: так заливается мир и так переигрывается день после обрыва. **Живой** держит темп модельного времени — по умолчанию ×60: модельные сутки за 24 реальные минуты, суточная волна разворачивается на глазах, отставание видно в логе. На стенде генератор ходит своим образом — разовой службой, которую поднимают и убирают на один прогон. На каждый режим по цели: ```bash # день D0 целиком в топик hits make generate-batch # первая сотня событий дня D3 make generate-batch GENERATOR_DAY=3 GENERATOR_LIMIT=100 # день D3 живьём, ускорение ×1000 make generate-live GENERATOR_DAY=3 GENERATOR_SPEED=1000 ``` | Переменная | Что задаёт | Умолчание | | --- | --- | --- | | `GENERATOR_DAY` | номер дня на оси мира (D0 — первый) | `0`, задано в `Makefile` | | `GENERATOR_LIMIT` | потолок событий на прогон; только `generate-batch` | нет: день целиком | | `GENERATOR_SPEED` | ускорение модельного времени; только `generate-live` | ×60, задано в генераторе | Куда отправлять, обеим целям задаёт сам стенд: внутри сети Compose это брокер `kafka:9092` и топик `hits`. Образ цели не пересобирают: нет образа — Compose соберёт его сам, есть — возьмёт как есть. Пересобрать намеренно, после правки `generator/Dockerfile` или зависимостей, — отдельной командой: ```bash docker compose --profile generator build generator ``` Тот же образ несёт заливка стартового мира. Перед такой заливкой `world_initialize` сам собирает его из текущего дерева; на готовом мире повторный `make up` до сборки не доходит. Разведено это нарочно: собрать образ и запустить контейнер — разные действия, и цель запуска, молча пересобирающая образ, стирает между ними границу. Что образ устарел, видно по собственному прогону — это обратная связь, а не ловушка. Посмотреть на события, не поднимая стенд, помогает файловый приёмник: одно событие — одна строка. Флаг `--limit` берёт начало дня вместо целого дня — тому, кто смотрит на конвейер, ждать полсотни тысяч событий незачем. ```bash uv run --project generator python -m clickstream_generator batch \ --day 0 --limit 100 --file tmp/day0.jsonl ``` Несколько дней подряд играет один запуск: `--days 8`. Всё, что принимается флагами, принимается и переменными окружения — так генератор позовёт даг этапа 5; полный список у `--help`. **Умолчания есть не у всего, и это нарочно.** Зерно, число дней и темп живого дня описывают модель мира — их генератор знает сам. День на оси, приёмник и имя топика описывают стенд, на котором его запустили: не назвали — отказ и ненулевой код возврата, ещё до первого события. Поэтому умолчание дня и живёт в `Makefile`: день называет тот, кто запускает, а не тот, кого запускают. ### Пульт мира Считать дни самому необязательно. В Airflow живёт **пульт мира**: два дага создают его начальное состояние, а три двигают ось дальше. | Даг | Что делает | | --- | --- | | `world_initialize` | создаёт стартовый мир, если его ещё нет; повторный запуск ничего не меняет | | `world_recreate` | удаляет только прикладной мир и запускает его создание заново | | `world_next_day` | играет следующие дни пачкой; сколько — параметр запуска, по умолчанию один | | `world_live_day` | играет следующий день в темпе модельного времени, около двадцати четырёх минут | | `world_live` | выключатель: пока снят с паузы, дёргает `world_live_day` день за днём | Работник не только играет день: следом он отправляет слепок заказов за сыгранное и дёргает отдельный даг, `orders_ingest`, — тот забирает приехавший слепок из топика `orders` в сырьё, — и дожидается его конца. Так две половины стенда идут в ногу: упал приём — краснеет и работник, а позиция остаётся на месте ([ADR 0008](docs/adr/0008-order-ingestion.md)). Позицию хранит переменная Airflow `world_position` — номер первого несыгранного дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон позицию не двигает, и следующий запуск играет тот же день заново. Отсутствующая переменная означает, что мир ещё не создавали; `-1` — очистка не закончена, `0` — очистка закончена, а начальная генерация ещё не завершена. Работники начинают играть только с позиции `8` и больше. В форме запуска можно выбрать только число дней. Первый день работник берет из `world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options». Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую позицию работник пишет в журнал шага `remember_played`. Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно — встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы целиком — [ADR 0009](docs/adr/0009-world-control.md). **Плата названа вслух: планировщику Airflow отдан сокет докера** — иначе контейнер генератора ему не поднять. Доступ к сокету равен праву root на машине; для локального учебного стенда размен принят, но знать о нём надо. Открывает дверь не монтирование, а членство в группе: GID группы `docker` у каждой машины свой, живёт в `.env` и подсматривается командой `getent group docker`. ## Состав и доступ - `clickhouse-01` — инициатор DDL и точка подключения Airflow; - `clickhouse-02` — точка подключения Superset; - `clickhouse-keeper` — координатор кластера; - `kafka` — один брокер KRaft; - `kafka-exporter` — экспортёр метрик Kafka для Prometheus; - `postgres-metadata` — один Postgres с отдельными базами и пользователями `airflow` и `superset`; - `airflow-apiserver`, `airflow-scheduler` и `airflow-dag-processor` — Airflow 3.3 с LocalExecutor, без triggerer; - `superset` — интерфейс и подготовленное подключение ClickHouse; - `prometheus` и `grafana` — сбор и просмотр метрик ClickHouse и Kafka. С настройками из `.env.example` порты доступны только с локальной машины: - нода 1 — `http://127.0.0.1:28123`, нативный порт `29000`; - нода 2 — `http://127.0.0.1:28124`, нативный порт `29001`; - Kafka — `127.0.0.1:29092`; - Airflow — `http://127.0.0.1:28080`, пользователь `admin`, пароль `airflow`; - Superset — `http://127.0.0.1:28088`, пользователь `admin`, пароль `superset`; - Prometheus — `http://127.0.0.1:29090`; - Grafana — `http://127.0.0.1:23000`, пользователь `admin`, пароль `admin`. В ClickHouse семь пользователей и шесть ролей: - `etl` подключает рабочие даги Airflow; роль `etl_writer` читает и пишет слои хранилища; - `lifecycle` применяет канонический DDL и пересоздаёт прикладные базы; роль `lifecycle_owner` не даёт эти административные права рабочим дагам; - `bi` подключает Superset; роль `bi_reader` читает ODS, DDS, DM и справочники `dic`, а запросы Superset обращаются к публичным `_v`; - `analyst` предназначен для подключения человека и читает все слои и справочники; - `grafana` с ролью `monitoring_reader` подключает Grafana и читает все слои, системные таблицы и данные со всех реплик кластера; - `dict` читает только базу `dic` и пароля не имеет: им словарь товаров ходит за своей подложкой; - `default` остаётся служебным: им ходят проверки здоровья и скрипты внутри контейнеров, но не приложения. Почему пользователи объявлены файлом, а ноды доверяют общему секрету, — в [ADR 0007](docs/adr/0007-clickhouse-access.md). Пользователи и роли объявлены в `infra/clickhouse/users.d/access.xml`. Пароли и общий секрет нод живут в `.env` и передаются в конфигурацию через окружение; Superset получает пароль `bi`, а Grafana — пароль `grafana` тем же путём. Значения для локального стенда есть в `.env.example`. Изменения значений ClickHouse в `.env` и файлов настройки серверов в `infra/clickhouse/config.d/` и `infra/clickhouse/users.d/` применяются пересозданием нод: ```bash docker compose up --force-recreate --wait clickhouse-01 clickhouse-02 ``` Команда возвращает управление, когда обе ноды снова здоровы; именованные тома при этом сохраняются. `docker compose restart` оставит прежнее окружение и может оставить старую версию отдельно смонтированного файла. Пароли ClickHouse и интерфейсов, пароли Postgres, ключи Airflow и Superset, отсутствие проверки доступа у Kafka и Prometheus — намеренно простые и явно ненастоящие настройки локального учебного стенда. Это не пример настройки защиты: не копируйте значения из `.env.example` в рабочую среду. Все опубликованные порты привязаны только к `127.0.0.1`; Postgres наружу не опубликован. В бою перед репликами ClickHouse обычно был бы балансировщик. Здесь в каждом шарде одна реплика, поэтому балансировать нечего. Балансировщик и топология 2×2 намеренно не входят в стенд. Конфигурация сверена 30 июля 2026 года с официальной документацией ClickHouse: [настройками сервера](https://clickhouse.com/docs/operations/server-configuration-parameters/settings), [Keeper](https://clickhouse.com/docs/guides/oss/deployment-and-scaling/keeper/), [ReplicatedMergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/replication), [ON CLUSTER](https://clickhouse.com/docs/sql-reference/distributed-ddl) и [Distributed](https://clickhouse.com/docs/engines/table-engines/special/distributed). Описание кластера задаётся через `remote_servers`, макросы — через `macros`, подключение к keeper — через `zookeeper`; путь `ReplicatedMergeTree` содержит `{shard}` и `{replica}`, а `Distributed` получает имя кластера, базу, локальную таблицу и ключ шардирования. Макросы выбраны, чтобы один DDL через `ON CLUSTER` создавал отдельный путь каждого шарда без вписанных вручную значений. Для образа зафиксирован точный текущий [LTS-выпуск 26.3.17.56](https://github.com/ClickHouse/ClickHouse/releases/tag/v26.3.17.56-lts); серверы и keeper используют один образ. Настройки Kafka 4.3.1 сверены с [примером односерверного KRaft](https://github.com/apache/kafka/blob/4.3.1/docker/examples/docker-compose-files/single-node/plaintext/docker-compose.yml). Секция метрик взята из конфигурации закреплённого образа ClickHouse и проверена на серверах и keeper. Подготовка источников Grafana сверена с [официальным описанием автоматической настройки](https://grafana.com/docs/grafana/latest/administration/provisioning/) и [документацией плагина ClickHouse](https://grafana.com/docs/plugins/grafana-clickhouse-datasource/latest/configure/). Prometheus собирает встроенные метрики двух серверов и keeper, а через `kafka-exporter` — состояние Kafka. Поверх этого в Grafana поднимается дашборд «Данные»: свежесть, поток, брак, сходимость Kafka с ClickHouse, отставание чтения и дневной слепок заказов. Устройство зоны — в [справочнике мониторинга](docs/architecture/monitoring/README.md), разбор панелей — в [описании дашборда](docs/architecture/monitoring/data.md); правил оповещения пока нет. Airflow закреплён на 3.3.0. Состав обязательных процессов, LocalExecutor, публичный `airflow.sdk`, API здоровья и SimpleAuthManager сверены с [архитектурой Airflow 3.3](https://airflow.apache.org/docs/apache-airflow/3.3.0/core-concepts/overview.html), [публичным интерфейсом](https://airflow.apache.org/docs/apache-airflow/3.3.0/public-airflow-interface.html) и [описанием здоровья](https://airflow.apache.org/docs/apache-airflow/3.3.0/administration-and-deployment/logging-monitoring/check-health.html). Для ClickHouse установлен официальный провайдер Airflow: SQL-файлы исполняет общий `SQLExecuteQueryOperator`, программные запросы идут через `ClickHouseHook`. Провайдер работает поверх закреплённого `clickhouse-connect`. Официальный провайдер Kafka сам использует `confluent-kafka`; отдельное подключение и его обёртки здесь не нужны, поэтому клиент Kafka добавлен в образ напрямую. Superset закреплён на 6.1.0; драйвер `clickhouse-connect`, форма `clickhousedb://` и драйвер Postgres сверены с [документацией подключений Superset](https://superset.apache.org/user-docs/6.1.0/databases/) и [настройкой базы метаданных](https://superset.apache.org/admin-docs/6.1.0/configuration/configuring-superset/). ## Что здесь будет - одно широкое событие кликстрима по образцу выгрузки Яндекс Метрики вместо четырёх топиков; - второй источник — заказы бэкенда, ежедневным слепком в ту же Kafka; - сверка клиентской покупки с заказом бэкенда: деньги считаем по бэкенду, поведение и атрибуцию — по трекеру; - анонимный кликстрим и склейка кука↔пользователь через покупки; - ClickHouse кластером как единственным режимом. ## Чем отличается от предшественника Предшественник остаётся стабильным учебным стендом и заморожен для новых фич: там событие разрезано на четыре топика, есть только просмотры страниц, посетители опознаны по email, ClickHouse — одна нода. Развитие идёт здесь. ## Документация - [docs/specs/](docs/specs/) — спеки: источник истины о задуманном. - [docs/adr/](docs/adr/) — принятые решения с доводами и отвергнутыми вариантами: почему сделано так, а не иначе. - [docs/architecture/](docs/architecture/) — рабочие справочники по зонам ответственности: [хранилище](docs/architecture/storage.md) — конвенции имён, раскладка по шардам, приём событий, карта таблиц; [проверки](docs/architecture/testing.md) — что утверждает каждая цель `make` и куда класть новую проверку. - [docs/research/](docs/research/) — исследования; сейчас это формат кликстрима Яндекса, по которому строится модель события. - [docs/formats/](docs/formats/) — описания форматов источников: по ним пишется сторона хранилища. Собираются из кода командой `make docs` в `generator/`, руками не правятся. - [AGENTS.md](AGENTS.md) — контракт работы в репозитории.