Слой узлов¶
Когда несколько серверов (или воркеров) работают в одной частной сети, узлы
TelegramMe могут делить работу напрямую — без Redis или вместе с ним. У каждого
ключа кэша есть владелец среди живых узлов. Только владелец загружает ключ с
t.me и рендерит его; остальные узлы спрашивают владельца и сохраняют его ответ.
По умолчанию слой выключен (ENABLE_PEERS),
и без него всё работает как прежде.
Как это работает¶
- Узлы. Каждый процесс-воркер — узел. Узлы находят друг друга через
PEERS(адреса или DNS-имена), а с Redis — ещё и по меткам heartbeat друг друга. Кто жив, они отслеживают gossip-протоколом SWIM поверх UDP. - Владельцы. Владелец ключа — живой узел, выигрывающий rendezvous-хеш канала этого ключа, поэтому у всех ключей одного канала (страницы, отрендеренные тела и посты, фрагменты "more", превью) один владелец. Каждый узел вычисляет того же владельца сам; ушедший или пришедший узел перемещает только свои каналы.
- Промах. При промахе кэша в процессе узел спрашивает владельца по TCP. Владелец отвечает из своего кэша в процессе прямо в нативном коде, без Python. Спросивший хранит копию оставшийся у записи TTL.
- Загрузка. Если ключа нет и у владельца, спросивший просит его загрузить ключ. Владелец присоединяется к такой же работе, если она уже идёт, загружает один раз и отправляет значение (страница идёт вместе с отрендеренным телом) или ошибку. Пока узлы на связи, это заменяет lease в Redis.
- Координация. Обновлятор ленты работает на живом узле с наименьшим адресом из тех, кому это разрешено. Без Redis узлы раз в секунду ещё и отправляют друг другу счётчики лимита запросов и обращения к ленте для популярности.
Как включить¶
На каждом узле:
ENABLE_PEERS=true
PEER_SECRET=<the same 64 hex characters everywhere> # openssl rand -hex 32
PEERS=10.0.0.108,10.0.0.165
# In a container, the host's private address (the production deploy sets it):
PEER_ADVERTISE_ADDR=10.0.0.108
Каждый воркер слушает первый свободный порт начиная с
PEER_PORT (7946), по UDP и TCP, поэтому
откройте между узлами порты от PEER_PORT до PEER_PORT + WEB_CONCURRENCY.
В логе появится Peer layer on: this node is 10.0.0.108:7946, а затем
Peers: … при каждом изменении живых участников.
Обнаружение¶
Слой ищет другие узлы в таком порядке:
PEERS: адреса, парыhost:portили DNS-имена. Имена разрешаются заново каждые 30 секунд (каждые 2, пока ни один другой узел не известен), так что имя, в записях которого перечислены серверы, продолжает работать, когда серверы приходят и уходят.- Redis: с
REDIS_URLметка heartbeat каждого воркераtme:v2:node:<host>:<pid>содержит его адрес узла ("peer": "10.0.0.108:7946"), и каждый узел читает метки раз в 10 секунд.
Multicast не используется: облачные сети, например VCN в Oracle Cloud, его не передают. Чтобы присоединиться, достаточно одного доступного узла; остальное сделает gossip.
Безопасность¶
- Аутентификация. Каждый фрейм (UDP-датаграмма или TCP-сообщение) несёт
время отправителя, уникальный nonce и тег HMAC-SHA256 с ключом из
PEER_SECRET. Фрейм, тег которого не сходится, время отличается больше чем на 30 секунд или nonce уже встречался, отбрасывается, и ничего из него до этого не разбирается. - Шифрование. С
PEER_ENCRYPTIONфреймы вместо этого шифруются XChaCha20-Poly1305. Без него публичные данные каналов из кэша идут по сети открытым текстом, но с аутентификацией. - Сети. Узлы вне
PEER_ALLOW_CIDRотклоняются до того, как прочитан хотя бы байт. - Ограничения. Фрейм — не больше 8 МиБ, ключ — 1 КиБ; число входящих соединений и ожидающих загрузок ограничено.
Секрет общий для всего кластера: меняйте его на всех узлах сразу. Шифрование использует выведенный из него статический ключ, поэтому прямой секретности (forward secrecy) нет; взаимный TLS с сертификатами для каждого узла не реализован.
Файрвол и Docker¶
deploy/bootstrap-server.sh открывает порты от PEER_PORT до
PEER_PORT + 15, UDP и TCP, в файрволе хоста только для частной сети
(PEER_CIDR, по умолчанию 10.0.0.0/16). Образы Oracle Cloud отклоняют любой
другой порт с "No route to host"; список безопасности облака тоже должен
разрешать эти порты между серверами.
deploy/compose.prod.yaml публикует порты узлов контейнера API на
PEER_BIND. С ENABLE_PEERS=true скрипт deploy/remote-deploy.sh
устанавливает его, а также PEER_ADVERTISE_ADDR, в частный адрес сервера
(адрес источника его маршрута по умолчанию) и публикует по порту на воркер
плюс один.
Workflow развёртывания включает слой переменной ENABLE_PEERS и секретом
PEER_SECRET; PEERS, PEER_PORT, PEER_ALLOW_CIDR и PEER_ENCRYPTION —
необязательные переменные.
Когда что-то ломается¶
Сбой узла никогда не роняет запрос; спросивший просто делает работу сам:
| Ситуация | Что происходит |
|---|---|
Владелец не подключается или не отвечает на чтение за PEER_TIMEOUT |
Читается Redis или ключ загружается локально; владельца обходят 3 с |
Владелец не принимает загрузку за PEER_TIMEOUT |
Спросивший загружает ключ сам, с lease в Redis, если Redis есть |
| Владелец принял загрузку и молчит | Спросивший ждёт столько же, сколько держателя lease в Redis, затем загружает сам |
| Владелец упал | Его порт сразу отказывает; gossip объявляет его упавшим за несколько секунд |
Узел с другим секретом или вне PEER_ALLOW_CIDR |
Его фреймы отбрасываются и попадают в лог; он не присоединяется |
| Два узла на мгновение по-разному видят участников | Ключ может загрузиться дважды или ленту обновят два узла; больше ничего |
Измерения¶
На одной машине (Apple M-серии, всё на localhost):
- Промах, на который ответил владелец (страница 56 КБ в кэше): медиана 0,18 мс, p99 0,35 мс, с шифрованием и без. Через Redis в 90 мс — 95 мс; с локальным Redis — 0,6 мс.
- Нагрузка с большим числом дублей (
scripts/coalescing_load.py, все эндпоинты всех записанных каналов, t.me отвечает за 150 мс, 20 с):
| Узлов | Координация | Загрузок с t.me | Задержка p99 |
|---|---|---|---|
| 2 | нет | 138 | 147 мс |
| 2 | lease в Redis, Redis в 90 мс | 70 | 188 мс |
| 2 | узлы, без Redis | 69 | 163 мс |
| 6 | нет | 414 | 156 мс |
| 6 | lease в Redis, Redis в 90 мс | 75 | 164 мс |
| 6 | узлы, без Redis | 79 | 118 мс |
27 разных запросов к источнику устаревают примерно каждые 10 с, так что около 2,5 загрузки на запрос — это одна загрузка на ключ за TTL на весь кластер.