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

291 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Шаг 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<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 — локальный:
```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 нет |
| Значение ключа | `<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. Шаги
```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) |