20 KiB
Шаг 3 — HA (active/passive) и путь к выносу состояния
Дата: 2026-10-10
Статус: реализовано (код в main, задеплоено на ВМ 213, флаг выключен — поведение прежнее)
Связанные документы:
doc/thinking/step3-state-extraction-design-2026-10-10.md— разбор вариантов A/B/Cdoc/thinking/vm-public-entrypoint-2026-10-10.md— публичная точка входа, TLS, Шаг 2HISTORY/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. Предусловия
- Вторая ВМ, тот же бинарник, тот же
sqs.sinefo.ru. - Общий Redis, доступный обеим ВМ (сейчас на 213 — только локальный!). Без общего Redis лок бессмысленен: каждый увидит свой.
- 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. Порядок действий при деплое новой версии
- Обновить бинарник на пассивной ВМ, перезапустить —
/readyостаётся 503, трафика нет. - Дождаться, пока пассивная станет лидером нельзя «по кнопке» — переключение идёт только через
TTL. Поэтому: остановить лидера (
systemctl stop) → лок освобождается сразу (ReleaseLockвelector.Stop()) → пассивный берёт лок на следующем тике (≤ 3 с). - Обновить бинарник на бывшей лидерской ВМ → она встанет пассивной.
Итого простой при деплое — единицы секунд вместо «пока перезапускается».
6. Ограничения — важно понимать до включения
- 🔴 Состояние НЕ шарится между инстансами.
models.SyncQueues— in-memory. Пассивный инстанс загрузил состояние из Redis на своём старте и с тех пор его не обновляет. → Пока активно ограничение: пассивный не должен получать SQS-трафик. Именно для этого/ready= 503: LB обязан его уважать. Если LB игнорирует/ready, клиенты начнут получать «пустые» очереди с пассивного. Это главный риск варианта C. - 🟠 Свежесть состояния после failover. Новый лидер видит состояние на момент своего старта,
а не на момент падения предыдущего. Write-through (Redis) спасает: очереди и сообщения,
записанные лидером, лежат в Redis. А изменения, не персистентные, теряются: в первую
очередь
SaveQueue(метаданные очереди) — асинхронная запись; см. открытый пункт вHISTORY/2026-10-10-session-log.md§6. Плюс любой инстанс можно перезапустить, чтобы подтянуть актуальное состояние из Redis. - 🟠 Простой = время перехвата.
kill -9→ доttl + interval(≈13 с по умолчанию). Уменьшать TTL рискованно (ложные потери лидерства при сетевых задержках); лучше иметь failover на уровне LB + второй инстанс, уже прогретый. - 🟠 Redis — общий SPOF. Если Redis недоступен, лидер теряет лок → оба инстанса отдают 503. То есть вариант C переносит зависимость, но не устраняет её. Для продакшена Redis должен быть отдельным от SQS-ВМ и с репликацией.
- 🟡 Split-brain теоретически возможен при сетевом разделении, когда старый лидер не может
продлить лок (считает себя пассивным), а Redis тем же временем отдаёт лок новому — это
нормально (старый сам сдаётся). Обратный случай — только при «залипшем» Redis (например,
пауза процесса Redis > TTL). Практический риск низкий, но именно поэтому LB должен
проверять
/ready, а не полагаться на то, что трафик не пойдёт куда не надо. - 🟡 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 деплой без ручного переключения.
Куда двигаться (по шагам, каждый самостоятелен):
- Убрать асинхронность записи метаданных —
SaveQueueсинхронно (какSaveMessagesSync). Мелкая правка, снимает риск №2 из §6. Делать первым — дешево и полезно независимо от A. - Вынести
models.SyncQueuesза интерфейс (напримерQueueStore), оставив текущую in-memory реализацию. Поведение не меняется, но появляется точка подмены. - Атомарные операции очереди в Redis — приёмка/видимость/удаление/дедуп через
Lua-скрипты (или Redis Streams). Это основная часть работы. Тесты — на существующих
app/gosqs/*_test.go+tests/load_test.py. - Убрать
PeriodicTasksиз процесса — вынести в отдельный «sweeper» (тоже через лок, чтобы не дублировался на всех инстансах). - Снять лидер-лок — все инстансы становятся равными,
/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) |