diff --git a/README.md b/README.md index 53868a5..d6bb097 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ make check-services экземпляра, внешние порты, учебные учётные данные и ключи. Одно значение образца верно не везде — `DOCKER_GID`: у группы `docker` на каждой машине свой номер, и свой подскажет `getent group docker`. Пока он не сойдётся, стенд работает весь, -кроме пульта мира — о нём ниже. `compose.yaml` не +кроме [пульта мира](docs/architecture/world.md#пульт-мира). `compose.yaml` не дублирует их умолчаниями: если значения нет в `.env`, Compose сразу предложит скопировать образец. Внутренние адреса, имена топиков и версии образов описывают сам стенд, поэтому записаны литералами в `compose.yaml` и Dockerfile. @@ -71,56 +71,8 @@ make check-services все локальные данные стенда, а следующий `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 up`, кого спрашивает каждая проверка и как устроен смоук, +описано в [карте проверок](docs/architecture/testing.md). Обычный рабочий цикл — `make config-test` и `make smoke`; остальные цели гоняют тогда, когда правка их касается. Что утверждает каждая проверка, нужен ли ей @@ -137,168 +89,21 @@ Airflow его явную пересборку: удалит прикладны ## Стартовый мир -Стенд поднимается не пустым: `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 -``` +Стартовый мир и его опись описаны в +[справочнике мира](docs/architecture/world.md). ## Как позвать генератор -События производит генератор из [`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`: день называет тот, кто запускает, а не тот, кого запускают. +Режимы и команды запуска генератора описаны в +[справочнике мира](docs/architecture/world.md#как-позвать-генератор). ### Пульт мира -Считать дни самому необязательно. В 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`. +Даги пульта, позиция мира и форма запуска описаны в +[справочнике мира](docs/architecture/world.md#пульт-мира). ## Состав и доступ -- `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`; @@ -309,95 +114,8 @@ uv run --project generator python -m clickstream_generator batch \ - 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/). +Службы, пользователи и роли, применение настроек и результаты сверки +конфигурации описаны в [справочнике стенда](docs/architecture/stand.md). ## Что здесь будет diff --git a/docs/architecture/stand.md b/docs/architecture/stand.md new file mode 100644 index 0000000..1abe354 --- /dev/null +++ b/docs/architecture/stand.md @@ -0,0 +1,122 @@ +# Стенд: службы, доступ, настройка + +Здесь описано, из каких служб собран стенд, кто к кому подключается и как +настройки доходят до контейнеров. Состав каркаса обоснован в +[ADR 0001](../adr/0001-stand-services.md), доступ к ClickHouse — в +[ADR 0007](../adr/0007-clickhouse-access.md). Адреса и пароли интерфейсов +остаются в [корневом README](../../README.md#состав-и-доступ). + +## Службы + +- `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. + +## Пользователи и роли ClickHouse + +В 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](../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, отставание +чтения и дневной слепок заказов. Устройство зоны — в [справочнике +мониторинга](monitoring/README.md), разбор панелей — в +[описании дашборда](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/). diff --git a/docs/architecture/testing.md b/docs/architecture/testing.md index 3434ad5..d5a3ebe 100644 --- a/docs/architecture/testing.md +++ b/docs/architecture/testing.md @@ -76,6 +76,15 @@ Airflow. В цену целей она не входит. На готовом мире подъём больше не пересчитывает данные: `world_initialize` зеленеет без изменений. +`make up` собирает локальные образы Airflow, Superset и Grafana, поднимает весь +стенд и ждёт здорового состояния долгоживущих контейнеров. В образ Airflow +добавлены закреплённые клиенты ClickHouse и Kafka. Одноразовые `airflow-init` и +`superset-init` завершаются с кодом 0. Первый обновляет схему Airflow, +подготавливает администратора и два подключения к `clickhouse-01`. Второй +обновляет Superset, создаёт администратора и импортирует подключение к +`clickhouse-02`. На ноутбуке в режиме энергосбережения приёмочный подъём с +пустых томов занял около десяти минут, повторный на живом стенде — около трёх. + `make rebuild-storage` тоже не проверка. Команда через Airflow удаляет прикладные базы ClickHouse, топики и позиции групп Kafka, заново применяет DDL, заливает стартовый мир и возвращает его пульт к концу стартовых дней. Службы и @@ -237,6 +246,45 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`. скриптов нет. Отдельной проверкой этот вход тоже не считается: то же самое утверждает смоук. +### Что спрашивает смоук + +`make smoke` за секунды спрашивает, собран ли стенд: зависимости машины, +здоровье контейнеров, устройство keeper, ответ Kafka с машины через отображённый +порт, четыре цели Prometheus, два источника Grafana, компоненты Airflow и +подготовленное подключение к `clickhouse-01`. + +Двенадцать проверок здоровья сразу после `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 и очистку временных таблиц. Девятая +из десяти проверок спрашивает словарь товаров на обеих нодах. Последняя — +подневный счёт событий против описи, о нем сказано выше. + ## Что проверено **Калибровка заказной стороны 30 августа 2026 года при исполнении #194.** diff --git a/docs/architecture/world.md b/docs/architecture/world.md new file mode 100644 index 0000000..0a5aad9 --- /dev/null +++ b/docs/architecture/world.md @@ -0,0 +1,161 @@ +# Мир и его пульт + +Модельное время стенда со стороны оператора: как рождается мир, что двигает +его ось, кто играет дни и где хранится позиция. +Доводы об устройстве пульта — в [ADR 0009](../adr/0009-world-control.md), о +безопасном повторном подъеме и жизненном цикле мира — в +[ADR 0013](../adr/0013-world-lifecycle.md). Почему последний день стартового +мира неполон и что хранит опись, разобрано в [справочнике стартового +мира](orders/start-world.md) и [справочнике описи](orders/inventory.md). + +## Стартовый мир + +Стенд поднимается не пустым: `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](../adr/0008-order-ingestion.md)). + +Позицию хранит переменная Airflow `world_position` — номер первого несыгранного +дня. Ставит её работник, сыгравший день, и только по успеху: оборванный прогон +позицию не двигает, и следующий запуск играет тот же день заново. Отсутствующая +переменная означает, что мир ещё не создавали; `-1` — очистка не закончена, +`0` — очистка закончена, а начальная генерация ещё не завершена. Работники +начинают играть только с позиции `8` и больше. + +В форме запуска можно выбрать только число дней. Первый день работник берет из +`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options». +Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую +позицию работник пишет в журнал шага `remember_played`. + +Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно — +встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы +целиком — [ADR 0009](../adr/0009-world-control.md). + +**Плата названа вслух: планировщику Airflow отдан сокет докера** — иначе +контейнер генератора ему не поднять. Доступ к сокету равен праву root на +машине; для локального учебного стенда размен принят, но знать о нём надо. +Открывает дверь не монтирование, а членство в группе: GID группы `docker` у +каждой машины свой, живёт в `.env` и подсматривается командой +`getent group docker`. diff --git a/docs/handbook/README.md b/docs/handbook/README.md index e16c900..630c83c 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -27,7 +27,7 @@ Пульт играет модельный день, а дневной конвейер раскладывает данные по слоям. Глава поможет увидеть их связь и роль каждого дага. Пока главы нет, смотрите [справочник конвейера](../architecture/etl.md) и -[раздел «Пульт мира» корневого README](../../README.md#пульт-мира). +[справочник мира и его пульта](../architecture/world.md). ### Мониторинг: куда смотреть diff --git a/generator/README.md b/generator/README.md index a5bba80..c191572 100644 --- a/generator/README.md +++ b/generator/README.md @@ -77,7 +77,9 @@ ## Команды -Проиграть день — [быстрый старт](../README.md#как-позвать-генератор) и `--help`. +Проиграть день — раздел [«Как позвать +генератор»](../docs/architecture/world.md#как-позвать-генератор) справочника +мира и `--help`. Из этого каталога — цели генератора живут здесь, а не в корне: