docs(ha): detailed active/passive HA design, measurements and migration path to option A
This commit is contained in:
@@ -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<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) |
|
||||||
Reference in New Issue
Block a user