Skip to content
Snippets Groups Projects

doc: extend clustering.md

Merged Yaroslav Dynnikov requested to merge doc-clustering into master
All threads resolved!
Files
4
+ 130
120
# Общая схема инициализации кластера
Данный раздел содержит описание архитектуры Picodata, в том числе
Данный раздел содержит описание архитектуры Picodata, а конкретно —
высокоуровневый процесс инициализации кластера на основе нескольких
отдельно запущенных экземпляров Picodata (инстансов).
отдельно запущенных экземпляров (инстансов) Picodata.
Администратор запускает несколько инстансов, передавая в качестве
аргументов необходимые параметры:
@@ -19,7 +19,7 @@ picodata run --instance-id iN --listen iN --peer i1
инстансов — одного обычно достаточно, но для подстраховки можно взять
три. Именно на их основе будет произведена инициализация кластера и
поиск всех работающих инстансов для их включения в состав кластера
(discovery).
(discovery).
Подробности алгоритма discovery приведены в отдельном
[документе](discovery.md). В контексте сборки кластера важно лишь
@@ -37,13 +37,16 @@ picodata run --instance-id iN --listen iN --peer i1
![main.rs](clustering_curves.svg "main.rs control flow")
В контексте операционных систем каждый инстанс соответствует группе из
двух процессов — родительсого (supervisor) и дочернего (именно он
выполняет tarantool runtime).
Красным показан родительский процесс, который запущен на всем протяжении
жизненного цикла инстанса. Вся логика, начиная с присоединения к
кластеру, и заканчивая обслуживанием клиентских запросов, происходит в
дочернем процессе (голубой цвет). Единственное предназначение
родительского процесса — иметь возможность сбросить состояние дочернего
(выполнить rebootstrap) и инициализировать его повторно (сиреневый
цвет).
родительского процесса — выполнять [ребутстрап](#Ребутстрап) и
инициализировать дочерний процес повторно (сиреневый цвет).
Данная схема наиболее полно отражает логику кода в файле `main.rs`. Ниже
описаны детали выполнения каждого этапа и соответствующей программной
@@ -54,25 +57,50 @@ picodata run --instance-id iN --listen iN --peer i1
На этом этапе происходит ветвление (форк) процесса `picodata`.
Родительский процесс (supervisor) ожидает от дочернего процесса
сообщения по механизму IPC и при необходимости перезапускает дочерний
процесс. Также, при необходимости дочерний процесс может попросить
родителя удалить все файлы БД, т.е. вызвать функцию `drop_db()`. Это
может понадобиться для повторной инициализации кластера когда, например,
у инстанса изначально имеется временный, случайно сгенерированный
`replicaset_id`.
процесс.
Выполнение дочернего процесса начинается с вызова функции
[`start_discover()`](#fn-start_discover) и далее следует алгоритму. При
необходимости дочерний процесс может попросить родителя удалить все
файлы БД (см. раздел ["Ребутстрап"](#Ребутстрап)). Это используется для
повторной инициализации инстанса с нормальным `replicaset_uuid` вместо
рандомного.
### Ребутстрап
У тарантула есть две особенности, из-за которых процесс инициализации
выглядит так как выглядит:
1. Принадлежность инстанса тому или иному репликасету определяется в
момент первого вызова `box.cfg()` когда создается первый снапшот.
Впоследстии изменить принадлежность репликасету невозможно.
2. Инициализация iproto сервера, реализующего бинарный сетевой протокол
тарантула, выполняется той же функцией `box.cfg()`.
В совокупности эти две особенности создают проблему курицы и яйца:
- Инстанс не может общаться по сети, пока не узнает принадлежность
репликасету.
- Принадлежность репликасету невозможно узнать без общения по сети.
Чтобы эту проблему решить, Picodata инициализируется со случайно
сгенерированными идентификаторами, а позже перезапускает процесс,
попутно очищая рабочую директорию.
### fn start_discover()
Дочерний процесс начинает свое существование с запуска модуля
`box.cfg()` и вызова функции `start_discover()`. Возможно, что при этом
из БД будет ясно, что bootstrap данного инстанса уже был произведен
ранее и что Raft уже знает о вхождении этого инстанса в кластер — в
таком случае никакого discovery не будет, инстанс сразу перейдет к этапу
`postjoin()`. В противном случае, если место инстанса в кластере еще не
известно, алгоритм discovery определяет значение флага
`i_am_bootstrap_leader` и адрес лидера Raft-группы. Далее инстанс
сбрасывает свое состояние (этап rebootstrap), чтобы повторно провести
инициализацию `box.cfg()`, теперь уже с известными параметрами. Сам
лидер (единственный с `i_am_bootstrap_leader == true`) выполняет функцию
Дочерний процесс начинает свое существование с функции
[`init_common()`](#fn-init_common), в рамках которой в т.ч.
инициализируется модуль `box`. Возможно, что при этом из БД будет ясно,
что bootstrap данного инстанса уже был произведен ранее и что Raft уже
знает о вхождении этого инстанса в кластер — в таком случае никакого
discovery не будет, инстанс сразу перейдет к этапу `postjoin()`. В
противном случае, если место инстанса в кластере еще не известно,
алгоритм discovery определяет значение флага `i_am_bootstrap_leader` и
адрес лидера Raft-группы. Далее инстанс сбрасывает свое состояние (см.
["Ребутстрап"](#Ребутстрап)), чтобы повторно провести инициализацию
`box.cfg()`, теперь уже с известными параметрами. Сам лидер
(единственный с `i_am_bootstrap_leader == true`) выполняет функцию
`start_boot()`. Остальные инстансы переходят к функции `start_join()`.
### fn start_boot()
@@ -88,25 +116,28 @@ picodata run --instance-id iN --listen iN --peer i1
### fn start_join()
Вызову функции `start_join()` всегда предшествует rebootstrap (удаление
БД и перезапуск процесса), поэтому на данном этапе в БД нет ни модуля
box, ни пространства хранения. Функция `start_join()` имеет простое
устройство:
Инстанс-клиент отправляет запрос `raft_join` лидеру Raft-группы (он
известен после discovery). После достижения консенсуса в Raft-группе
лидер присылает в ответе необходимую информацию:
- Идентификатор `raft_id` и данные таблицы `raft_group` — для
инициализации Raft-узла;
- Идентификаторы `instance_uuid`, `replicaset_uuid` и параметры
`replication`, `read_only` для `box.cfg`.
Получив все настройки, инстанс использует их в `box.cfg()`, и затем
создает в БД группу `raft_group` с актуальными адресами других
инстансов. Без этого инстанс не сможет отвечать на сообщения от других
членов Raft-группы. Для того чтобы записи в `raft_group` не были
заменены на менее актуальные из журнала Raft, каждая запись маркируется
значением `commit_index`.
Вызову функции `start_join()` всегда предшествует
[ребутстрап](#Ребутстрап) (удаление всех данных и перезапуск процесса),
поэтому на данном этапе в БД нет ни модуля `box`, ни пространства
хранения. Функция `start_join()` имеет простое устройство:
Инстанс отправляет запрос [`rpc::join`](#rpcjoin) лидеру Raft-группы (он
известен после discovery), который магическим образом присылает в ответе
всю необходимую для инициализаии информацию:
Для инициализации Raft-узла:
- идентификатор `raft_id`,
- данные таблицы `_picodata_peer_address`.
Для первичного вызова `box.cfg()`:
- идентификаторы `instance_uuid`, `replicaset_uuid`,
- `box.cfg.replication` — список урлов для репликации.
Получив все настройки, инстанс использует их в `box.cfg()` (см.
[`init_common()`](#fn-init_common)), и затем создает в БД группу
`_picodata_peer_address` с актуальными адресами других инстансов. Без
этого инстанс не сможет отвечать на сообщения от других членов
Raft-группы.
По завершении этих манипуляций инстанс также переходит к этапу
`postjoin()`.
@@ -115,35 +146,68 @@ box, ни пространства хранения. Функция `start_join(
Логика функции `postjoin()` одинакова для всех инстансов. К этому
моменту для инстанса уже инициализированы корректные пространства
хранения в БД и могут быть накоплены записи в журнале Raft. Инстанс
инициализирует узел Raft, который начинает взаимодействовать с
Raft-группой. В случае, если других кандидатов нет, инстанс тут же
избирает себя лидером группы.
хранения в БД и могут быть накоплены записи в журнале Raft.
Функция `postjoin()` выполняет следующие действия:
- Инициализирует HTTP сервер в соответствии с параметром `--http-listen`.
- Запускает Lua скрипт, указанный в аргументе `--script`.
- Инициализирует узел Raft, который начинает взаимодействовать с
Raft-группой.
В этом месте также устанавливается `on_shutdown` callback, который
обеспечит [корректное завершение работы инстанса](#Graceful-shutdown).
Следующим шагом инстанс оповещает кластер о том, что он готов проходить
- В случае, если других кандидатов нет, инстанс тут же
избирает себя лидером группы.
- Устанавливает `on_shutdown` триггер, который обеспечит
[корректное завершение работы инстанса](#Graceful-shutdown).
Последним шагом инстанс оповещает кластер о том, что он готов проходить
настройку необходимых подсистем (репликации, шардинга, и т.д.). Для
этого лидеру отправляется запрос на обновление `target_grade` текущего
инстанса до уровня `Online`, после чего за дальнейшие действия будет
отвечать специальный поток управления [topology
governor](#Topology-governor), также называемый `governor_loop`.
отвечать специальный поток управления [topology governor](#Topology-governor).
Как только запись с обновленным грейдом будет зафиксирована в Raft, узел
готов к использованию.
### fn init_common()
Функция `init_common` обобщает действия, необходимые для инициализации
инстанса во всех трех вышеописанных сценариях — `start_discover`,
`start_boot`, `start_join`.
Инициализация инстанса сводится к следующим шагам:
- создание `data_dir`,
- первичный вызов `box.cfg`,
- инициализация `package.preload.vshard`,
- инициализация хранимок (`box.schema.func.create`),
- создание системных спейсов (`_picodata_raft_log` и т.д).
Параметры первичного вызова `box.cfg` зависят от конкретного сценария:
| param | `start_discover` | `start_boot` | `start_join` |
|-------------|------------------|--------------|-------------------------------|
| listen | None | None | _from args_ |
| read_only | false | false | from `rpc::join` response |
| uuids | _random_ | _given_ | from `rpc::join` response |
| replication | None | None | from `rpc::join` response |
| data_dir | _from args_ | ... | ... |
| log_level | _from args_ | ... | ... |
## Обработка запросов
### \#\[proc\] fn raft_join()
### rpc::join
Значительная часть всей логики по управлению топологией берет свое
начало в хендлере запроса `rpc::join`. Его делает инстанс
Значительная часть всей логики по управлению топологией находится в
хранимой процедуре `raft_join`. Аргументом для нее является следующая
структура:
Аргументом для нее является следующая структура:
```rust
struct join::Request {
struct rpc::join::Request {
cluster_id: String,
instance_id: Option<String>,
replicaset_id: Option<String>,
@@ -152,10 +216,10 @@ struct join::Request {
}
```
Ответом служит структура:
Ответом служит такая:
```rust
struct JoinResponse {
struct rpc::join::OkResponse {
/// Добавленный инстанс (чтобы знать все ID)
instance: Instance,
/// Голосующие узлы (чтобы добавляемый инстанс мог наладить контакт)
@@ -176,82 +240,28 @@ struct Instance {
peer_address: String,
failure_domain: FailureDomain,
// целевая и текущая оценки уровня инстанса с точки зрения лидера кластера
target_grade: CurrentGrade,
// текущий и целевой грейды
current_grade: CurrentGrade,
/// Индекс записи в Raft-журнале. Препятствует затиранию
/// более старыми записями по мере применения Raft-журнала.
commit_index: RaftIndex,
target_grade: TargetGrade,
}
```
Цель такого запроса сводится к добавлению нового инстанса в Raft-группу.
Для этого алгоритма справедливы следующие тезисы:
- `join::Request` отправляет всегда неинициализированный инстанс.
- В зависимости от того, содержится ли в запросе `instance_id`,
проводится анализ его корректности (уникальности).
- Запрос `rpc::join` всегда делает инстанс без снапшотов.
- В процессе обработки запроса в Raft-журнал добавляется запись
`op::PersistPeer { peer }`, которая помимо всевозможных ID содержит
поля `current_grade: Offline`, `target_grade: Offline`, играющие
важную роль в обеспечении надежности кластера (подробнее о них в
разделе [topology governor](#Topology-governor)).
`op::PersistPeer { peer }`, при этом `current_grade: Offline`,
`target_grade: Offline` (подробнее о них в разделе [topology
governor](#Topology-governor)).
- В ответ выдается всегда новый `raft_id`, никому другому ранее не
принадлежавший.
- Генерировать значение `raft_id` может только лидер Raft-группы.
- Помимо всевозможных ID, ответ содержит список голосующих членов
Raft-группы. Они понадобятся новому инстансу для того чтобы знать
адреса соседей и общаться с ними.
- Помимо идентификаторов нового инстанса, ответ содержит список
голосующих членов Raft-группы. Они необходимы новому инстансу для
того чтобы отвечать на запросы от Raft-лидера.
- Также ответ содержит параметр `box_replication`, который требуется для
правильной настройки репликации.
## Обработка записей Raft-журнала
Последовательность состояний каждой отдельной записи в Raft-журнале
можно описать так:
```md
`Persisted``Committed``Applied`
```
При добавлении в журнал (по правилам это делает лидер) запись получает
статус `Persisted` и начинает реплицироваться (это асинхронно делает
файбер `raft_main_loop` ). Когда кворум узлов подтверждает
персистентность записи, она считается зафиксированной. Важно понимать,
что статус `Committed` присваивается записи на основе совокупности
полученной информации, а не какого-то конкретного действия.
Конкретные действия по обработке той или иной записи выполняет
отдельный поток `raft_applier`<!--([TODO](## "Пока что отдельного
потока нет, но лучше бы был"))-->. Для каждой записи он выполняет
обработчик `Op::on_commit()` и по завершении присваивает записи статус
`Applied`. Важно помнить, что обновление статуса и сама операция могут
выполняться не атомарно (если в `Op::on_commit()` происходит передача
управления другому потоку — yield). В таком случае, следует
позаботиться хотя бы об идемпотентности операции.
Схема ниже иллюстрирует эту информацию.
![Raft log](raft_log_curves.svg "Последовательность обработки записей в
Raft-журнале")
Стоит также помнить, что алгоритм Raft гарантирует лишь консистентность
последовательности записей, но не конкретные сроки выполнения. Смена
статусов на разных инстансах так или иначе происходит в разные моменты
времени, и иногда эту очередность приходится учитывать в алгоритмах.
Например, при корректном запланированном выводе инстанса из эксплуатации
(graceful shutdown) может возникнуть ситуация, когда инстанс завершится
слишком быстро, и его соседи могут ошибочно продолжать считать его
голосующим. Причиной такой ситуации может быть критерий остановки,
включающий ожидание коммита лишь локально на завершаемом инстансе, но не
на других — это может стать причиной потери кворума в кластере.
<!-- Ниже данная ситуация рассмотрена подробнее. -->
<!-- Была у нас однажды такая история — шла разработка graceful shutdown. Тест (`test_joining.py::test_deactivation`) останавливал один из двух инстансов и проверял, что тот (назовем его i2) перестал быть голосующим. Иногда тест проходил нормально, но иногда падал — `i2` завершал работу раньше, чем `i1` получал от него подтверждение. При этом критерий остановки включал в себя ожидание коммита, но только локально на `i2`, а не на `i1`. Из-за этого `i1` терял кворум. -->
## Graceful shutdown
Чтобы выключение прошло штатно и не имело негативных последствий,
Loading