diff --git a/doc/thinking/state-extraction-and-ha-2026-10-10.md b/doc/thinking/state-extraction-and-ha-2026-10-10.md new file mode 100644 index 0000000..fde9681 --- /dev/null +++ b/doc/thinking/state-extraction-and-ha-2026-10-10.md @@ -0,0 +1,290 @@ +# Шаг 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. Схема + +```mermaid +flowchart LR + LB["nginx / LB
(проверяет /ready)"] + A["ВМ A: shared-sqs
лидер → /ready 200"] + P["ВМ B: shared-sqs
пассив → /ready 503"] + R[("Redis
ssq:leader, ssq:queues, ssq:tenants")] + + LB -->|трафик только на ready| A + LB -.->|503 — трафик не идёт| P + A <-->|SETNX / PEXPIRE heartbeat| R + P <-->|пытается взять лок| R +``` + +### 3.2. Механика лока + +- Ключ: **`ssq:leader`**. Значение — **`-`** (уникальный id инстанса, + `app/cmd/goaws.go:180`). +- **Взять лок:** `SET ssq:leader NX PX ` — атомарно; `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 — локальный: + +```bash +# 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 нет | +| Значение ключа | `-` того инстанса, который отдал 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. Шаги + +```bash +# /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`: + +```nginx +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 «в никуда»): + +```bash +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) + +```bash +# на каждой ВМ +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) |