# Шаг 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) |