Files
SQS-service/doc/thinking/state-extraction-and-ha-2026-10-10.md
T

20 KiB
Raw Blame History

Шаг 3 — HA (active/passive) и путь к выносу состояния

Дата: 2026-10-10 Статус: реализовано (код в main, задеплоено на ВМ 213, флаг выключен — поведение прежнее) Связанные документы:

  • doc/thinking/step3-state-extraction-design-2026-10-10.md — разбор вариантов A/B/C
  • doc/thinking/vm-public-entrypoint-2026-10-10.md — публичная точка входа, TLS, Шаг 2
  • HISTORY/2026-10-10-session-log.md — хронология сессии

1. Какую проблему решаем

Сейчас прод — одна ВМ (213, 5.172.178.213) и один процесс shared-sqs. Внутри процесса живёт источник правды — in-memory models.SyncQueues (все очереди и сообщения). Redis — write-through кэш и восстановление при рестарте.

Отсюда три боли:

# Боль Следствие
1 ВМ/процесс упал → сервиса нет простой до ручного/автоматического рестарта (сейчас Restart=always, но это только рестарт того же инстанса)
2 Redis на той же ВМ падение ВМ уносит и состояние, и кэш — восстановление «с нуля по последнему AOF»
3 Обновление версии = простой нужно перезапустить единственный инстанс

Полноценное решение (вариант A из дизайна) — сделать Redis/внешнее хранилище источником правды и превратить инстансы в stateless. Это большой объём: атомарные операции над очередями (длинные скрипты/Lua, видимость, DLQ, batch, дедупликация), переписывание models.SyncQueues и всего, что на него опирается (gosqs.PeriodicTasks, метрики, админ-peek).

Задача была: снять боль №1 и №3 дёшево, не переписывая ядро, и подробно задокументировать, как потом перейти к варианту A.


2. Почему выбран вариант C (active/passive), а не A

Вариант Что даёт Цена Вердикт
A. Redis — источник правды настоящий horizontal scale-out переписать ядро очередей (Lua/транзакции), недели работы, риск регрессий в самой опасной части (доставка/видимость/DLQ) потом, когда упрёмся в нагрузку
B. Шардирование по тенантам масштаб без переписывания ядра сложная маршрутизация, каждый шард всё равно SPOF, эксплуатация N наборов не берём
C. active/passive + leader lock failover, рестарт/деплой без простоя (после ручного чекпоинта) не даёт scale-out; пассивный инстанс не принимает трафик берём сейчас

Ключевой довод: в 100% случаев причина простоя — недоступность одной ВМ, а не нехватка CPU. Вариант C закрывает именно это, стоит ~200 строк кода и не трогает ядро очередей.

Дополнительный довод: вариант C не мешает потом сделать A. Наоборот — он вводит понятие «активный инстанс», которое в варианте A просто исчезнет (все инстансы станут равными).


3. Как это работает

3.1. Схема

flowchart LR
  LB["nginx / LB<br/>(проверяет /ready)"]
  A["ВМ A: shared-sqs<br/>лидер → /ready 200"]
  P["ВМ B: shared-sqs<br/>пассив → /ready 503"]
  R[("Redis<br/>ssq:leader, ssq:queues, ssq:tenants")]

  LB -->|трафик только на ready| A
  LB -.->|503 — трафик не идёт| P
  A <-->|SETNX / PEXPIRE heartbeat| R
  P <-->|пытается взять лок| R

3.2. Механика лока

  • Ключ: ssq:leader. Значение — <hostname>-<pid> (уникальный id инстанса, app/cmd/goaws.go:180).
  • Взять лок: SET ssq:leader <id> NX PX <ttl> — атомарно; app/persistence/redis.go:666.
  • Продлить: Lua-скрипт — PEXPIRE только если значение всё ещё наше (GET == ARGV[1]), app/persistence/redis.go:676-682, вызов :692.
  • Отпустить: Lua-скрипт — DEL только если значение наше, app/persistence/redis.go:684-690, вызов :706. Вызывается при graceful shutdown (app/cmd/goaws.go:254).
  • Цикл: app/leader/leader.go:92-124. Каждые interval (по умолчанию 3 с):
    • если я лидер → RefreshLock; не подтвердился → теряю лидерство (падаю в passive);
    • если я пассивен → TryAcquireLock; получил → становлюсь лидером.
  • TTL лока: по умолчанию 10 с. Правило: ttl > 3 × interval, чтобы сетевые задержки и короткие всплески не приводили к ложной потере лидерства.

3.3. Готовность (/ready)

  • GET /ready (app/router/router.go:220-229) отдаёт 200 если ReadyFunc не задана или вернула true, иначе 503.
  • ReadyFunc — пакетная переменная (app/router/router.go:216), задаётся в main только при включённом leader-election: router.ReadyFunc = elector.IsLeader (app/cmd/goaws.go:182).
  • /ready зарегистрирован и в data-plane (router.go:48), и в control-plane (router.go:90).
  • /health (liveness) — всегда 200, независимо от лидерства. Это сознательно: процесс жив, его не надо перезапускать, просто он не должен получать трафик.

3.4. Включение/выключение

Способ Значение Поведение
по умолчанию — --leader-election=false → ReadyFunc == nil → всегда ready (одиночный режим, как раньше)
флаг --leader-election включается
env SHARED_SQS_LEADER_ELECTION=true включается (удобно для systemd EnvironmentFile)
тюнинг --leader-ttl (10s), --leader-interval (3s) значения лока

Код: app/cmd/goaws.go:47-49 (флаги), :159-187 (инициализация).

3.5. Поведение без Redis

Если REDIS_ADDR не задан (persistence.Client == nil), TryAcquireLock/RefreshLock возвращают true — инстанс всегда лидер. Логика: без Redis мы единственные, делить лок не с кем.

⚠️ Если Redis был, но отвалился после старта — TryAcquireLock вернёт ошибку → инстанс станет passive → /ready 503. При одном инстансе это означает «нет healthy-бэкенда». Поэтому leader-election нельзя включать на единственном инстансе, если Redis может исчезнуть — см. §6.


4. Что именно проверено (замеры, не гипотезы)

Проверка выполнена на ВМ 213 двумя временными инстансами нового бинарника (порты 4200 и 4210), Redis — локальный:

# 1) две копии с --leader-election
./shared-sqs --port=4200 --control-port=4201 --leader-election &
./shared-sqs --port=4210 --control-port=4211 --leader-election &
curl -s -o /dev/null -w %{http_code} http://127.0.0.1:4200/ready   # 200 (лидер)
curl -s -o /dev/null -w %{http_code} http://127.0.0.1:4210/ready   # 503 (пассив)
redis-cli get ssq:leader                                           # naeel-vm-21935

# 2) имитируем падение лидера БЕЗ graceful shutdown
kill -9 21935
sleep 12
curl -s -o /dev/null -w %{http_code} http://127.0.0.1:4210/ready   # 200
redis-cli get ssq:leader                                           # naeel-vm-21957
Что мерили Результат
Две копии одновременно 200 у первой, 503 у второй — split-brain нет
Значение ключа <hostname>-<pid> того инстанса, который отдал 200
kill -9 лидера → перехват пассивный стал лидером, /ready → 200; перехват занял ~12 с (теоретический максимум ttl + interval = 13 с)
Логи leader: инстанс стал ЛИДЕРОМ (активный) (16:29:12), leader: потерян лидерский лок (пассивный)
Регрессия прод-API aws sqs list-queues / send-message / receive-message через https://sqs.sinefo.ru — OK
Прод после деплоя systemctl is-active shared-sqs = active, /health 200, /ready 200

Временные инстансы остановлены, ключ ssq:leader удалён, прод работает с выключенным leader-election (/ready 200 безусловно).


5. Как включить на проде (когда появится вторая ВМ)

5.1. Предусловия

  1. Вторая ВМ, тот же бинарник, тот же sqs.sinefo.ru.
  2. Общий Redis, доступный обеим ВМ (сейчас на 213 — только локальный!). Без общего Redis лок бессмысленен: каждый увидит свой.
  3. LB (nginx upstream + proxy_next_upstream, или внешний LB) с проверкой GET /ready.

5.2. Шаги

# /etc/shared-sqs.env на ОБЕИХ ВМ
SHARED_SQS_LEADER_ELECTION=true
SHARED_SQS_ADMIN_TOKEN=<общий>
REDIS_ADDR=<общий-redis>:6379
REDIS_PASSWORD=<...>
# systemd: EnvironmentFile=/etc/shared-sqs.env, ExecStart=... (флаги не нужны — env достаточно)
sudo systemctl restart shared-sqs

В nginx upstream — health-check по /ready:

upstream sqs_backend {
    server 10.10.102.21:4100 max_fails=1 fail_timeout=5s;   # ВМ A
    server <ВМ-B-IP>:4100    max_fails=1 fail_timeout=5s;   # ВМ B
}
# плюс активная проверка /ready (nginx OSS: passive-режим через max_fails,
# nginx plus / внешний LB: active health check на GET /ready)

Проверка после включения — обязательно (иначе будет failover «в никуда»):

curl -s -o /dev/null -w "%{http_code}\n" https://sqs.sinefo.ru/ready   # 200 ровно у одного
# затем жёстко убить лидера и убедиться, что трафик поехал на второй за ≤ ttl+interval

5.3. Порядок действий при деплое новой версии

  1. Обновить бинарник на пассивной ВМ, перезапустить — /ready остаётся 503, трафика нет.
  2. Дождаться, пока пассивная станет лидером нельзя «по кнопке» — переключение идёт только через TTL. Поэтому: остановить лидера (systemctl stop) → лок освобождается сразу (ReleaseLock в elector.Stop()) → пассивный берёт лок на следующем тике (≤ 3 с).
  3. Обновить бинарник на бывшей лидерской ВМ → она встанет пассивной.

Итого простой при деплое — единицы секунд вместо «пока перезапускается».


6. Ограничения — важно понимать до включения

  1. 🔴 Состояние НЕ шарится между инстансами. models.SyncQueues — in-memory. Пассивный инстанс загрузил состояние из Redis на своём старте и с тех пор его не обновляет. → Пока активно ограничение: пассивный не должен получать SQS-трафик. Именно для этого /ready = 503: LB обязан его уважать. Если LB игнорирует /ready, клиенты начнут получать «пустые» очереди с пассивного. Это главный риск варианта C.
  2. 🟠 Свежесть состояния после failover. Новый лидер видит состояние на момент своего старта, а не на момент падения предыдущего. Write-through (Redis) спасает: очереди и сообщения, записанные лидером, лежат в Redis. А изменения, не персистентные, теряются: в первую очередь SaveQueue (метаданные очереди) — асинхронная запись; см. открытый пункт в HISTORY/2026-10-10-session-log.md §6. Плюс любой инстанс можно перезапустить, чтобы подтянуть актуальное состояние из Redis.
  3. 🟠 Простой = время перехвата. kill -9 → до ttl + interval (≈13 с по умолчанию). Уменьшать TTL рискованно (ложные потери лидерства при сетевых задержках); лучше иметь failover на уровне LB + второй инстанс, уже прогретый.
  4. 🟠 Redis — общий SPOF. Если Redis недоступен, лидер теряет лок → оба инстанса отдают 503. То есть вариант C переносит зависимость, но не устраняет её. Для продакшена Redis должен быть отдельным от SQS-ВМ и с репликацией.
  5. 🟡 Split-brain теоретически возможен при сетевом разделении, когда старый лидер не может продлить лок (считает себя пассивным), а Redis тем же временем отдаёт лок новому — это нормально (старый сам сдаётся). Обратный случай — только при «залипшем» Redis (например, пауза процесса Redis > TTL). Практический риск низкий, но именно поэтому LB должен проверять /ready, а не полагаться на то, что трафик не пойдёт куда не надо.
  6. 🟡 Control-plane (4101) не участвует. Он остаётся локальным на каждой ВМ; админка на пассивной покажет своё (устаревшее) состояние.

7. Чего сознательно НЕ сделано

  • Нет fencing/lease-токенов для операций записи в Redis (нужно только начиная с варианта A).
  • Нет авто-переключения DNS/nginx (ручное: LB + health-check).
  • Нет эндпоинта /leader с деталями (кто лидер, когда взят лок) — при необходимости добавить 3 строки в control-plane.
  • Нет репликации Redis в terraform/скриптах — платформенная задача (NUBES/девопсы).

8. Путь к варианту A (когда менять)

Триггеры, после которых вариант C перестаёт устраивать:

  • пассивный инстанс простаивает, а нагрузка уже выше возможностей одной ВМ (CPU/память/сеть);
  • нужны несколько активных инстансов (scale-out, а не только failover);
  • требуется zero-downtime деплой без ручного переключения.

Куда двигаться (по шагам, каждый самостоятелен):

  1. Убрать асинхронность записи метаданных — SaveQueue синхронно (как SaveMessagesSync). Мелкая правка, снимает риск №2 из §6. Делать первым — дешево и полезно независимо от A.
  2. Вынести models.SyncQueues за интерфейс (например QueueStore), оставив текущую in-memory реализацию. Поведение не меняется, но появляется точка подмены.
  3. Атомарные операции очереди в Redis — приёмка/видимость/удаление/дедуп через Lua-скрипты (или Redis Streams). Это основная часть работы. Тесты — на существующих app/gosqs/*_test.go + tests/load_test.py.
  4. Убрать PeriodicTasks из процесса — вынести в отдельный «sweeper» (тоже через лок, чтобы не дублировался на всех инстансах).
  5. Снять лидер-лок — все инстансы становятся равными, /ready всегда 200, LB просто round-robin. Код app/leader при этом удаляется (он был нужен только для C).

Что замерить перед переходом:

  • число операций/с и p99 по SQS-API на проде (/metrics уже есть);
  • сколько сейчас тормозит Redis при write-through (latency HGETALL/HSET);
  • объём ssq:msg:* (память Redis) при текущем темпе — там сейчас весь бэклог очередей.

9. Откат (выключить HA)

# на каждой ВМ
sudo sed -i '/SHARED_SQS_LEADER_ELECTION/d' /etc/shared-sqs.env
sudo systemctl restart shared-sqs
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:4100/ready   # 200 (безусловно)
redis-cli del ssq:leader                                                # удалить осиротевший лок

Код при этом остаётся — просто ReadyFunc == nil. Удалять пакет app/leader не обязательно.


10. Ссылки на код

Файл Что
app/leader/leader.go Elector: New (39), Start (50), IsLeader (56), Stop (63), loop (92), step (106)
app/persistence/redis.go TryAcquireLock (666), refreshLockScript (676), releaseLockScript (684), RefreshLock (692), ReleaseLock (706)
app/router/router.go /ready в data-plane (48), /ready в control-plane (90), ReadyFunc (216), readyHandler (220)
app/cmd/goaws.go флаги (47-49), инициализация и ReadyFunc (159-187), elector.Stop() (254)