Перейти к содержанию

Слой узлов

Когда несколько серверов (или воркеров) работают в одной частной сети, узлы 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: … при каждом изменении живых участников.

Обнаружение

Слой ищет другие узлы в таком порядке:

  1. PEERS: адреса, пары host:port или DNS-имена. Имена разрешаются заново каждые 30 секунд (каждые 2, пока ни один другой узел не известен), так что имя, в записях которого перечислены серверы, продолжает работать, когда серверы приходят и уходят.
  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 на весь кластер.