Отказоустойчивость без Kubernetes

Как построить систему, переживающую отказ сервера, не поднимая Kubernetes.

Kubernetes решает задачу отказоустойчивости через оркестрацию контейнеров — но приносит с собой control plane, etcd, CNI, свою модель сети и хранилища, и собственный класс аварий. Для трёх-пяти серверов это часто дороже проблемы, которую решает.

mitdev предлагает другой путь: репликация на уровне служб + watchdog на каждом узле + единая точка входа. Никакого оркестратора, никакого центрального координатора. Каждый узел сам понимает, что делать, когда сосед умер.


Содержание


Когда это ваш путь, а когда нужен Kubernetes

Признак Без Kubernetes Kubernetes
Серверов 2–6 6+
Приложений 1–5 10+
Команда 1–3 инженера есть выделенный ops
Деплой несколько раз в неделю десятки раз в день
Состав меняется редко постоянно, автоскейлинг
Нужен ли self-service для команд нет да
Стоимость простоя обучения высокая окупается

Практическое правило: если вы можете перечислить все свои серверы по именам — Kubernetes вам, скорее всего, не нужен.

Схема из этого документа даёт RTO (время восстановления) порядка 30–40 секунд для БД и секунды для приложений. Этого достаточно для подавляющего большинства бизнес-задач. Kubernetes не даёт принципиально лучше — он даёт лучше при десятках сервисов.

Если решите, что нужен, — KUBERNETES.md описывает, как подготовить проект и развернуть HA-кластер k3s. Заметьте: описанные ниже кластеры БД остаются актуальными и в k8s — базы данных в кластере обычно всё равно живут снаружи.


Общая архитектура

Целевая картина на трёх серверах:

                         пользователи
                              │
                    ┌─────────┴─────────┐
                    │   DNS round-robin │  (или Anycast/LB провайдера)
                    └─────────┬─────────┘
        ┌─────────────────────┼─────────────────────┐
        │                     │                     │
   ╔════╧═════╗          ╔════╧═════╗          ╔════╧═════╗
   ║  srv-1   ║          ║  srv-2   ║          ║  srv-3   ║
   ║          ║          ║          ║          ║          ║
   ║  nginx   ║          ║  nginx   ║          ║  nginx   ║
   ║  app ×2  ║          ║  app ×2  ║          ║  app ×2  ║
   ║          ║          ║          ║          ║          ║
   ║ PG primary◄─── WAL ─╫─►PG replica◄── WAL ─╫─►PG replica
   ║ Redis mstr◄─────────╫─►Redis rplc◄────────╫─►Redis rplc
   ║ Sentinel ║          ║ Sentinel ║          ║ Sentinel ║
   ║ watchdog ║          ║ watchdog ║          ║ watchdog ║
   ╚════╤═════╝          ╚════╤═════╝          ╚════╤═════╝
        │                     │                     │
        └──── WireGuard mesh: 10.100.0.1/.2/.3 ─────┘
                              │
                      VIP 10.100.0.100
                  (keepalived, следует за primary)

Три независимых механизма отказоустойчивости, каждый со своим доменом:

  1. Репликация данных — PostgreSQL WAL, Redis replication, MongoDB oplog. Отвечает за то, чтобы данные существовали больше чем в одном месте.
  2. Автоматический failover — watchdog PostgreSQL, Sentinel, выборы в replica set. Отвечает за то, чтобы новая главная реплика появилась без человека.
  3. Единая точка входа — VIP через keepalived или локальный HAProxy. Отвечает за то, чтобы приложению не пришлось узнавать о смене primary.

Все три обязательны. Репликация без failover — это бэкап, а не HA. Failover без единой точки входа — это failover, о котором приложение не узнает.


Предпосылка: приватная сеть

Кластеры БД обязаны общаться по приватным адресам. Соберите mesh до любых кластерных команд — см. MESH.md.

# srv-1
sudo mitdev net create              # → 10.100.0.1

# srv-2, srv-3
sudo mitdev net join <токен>        # → 10.100.0.2, 10.100.0.3
# и add-peer на остальных узлах

Дальше везде используются адреса 10.100.0.x. Если вы вводите публичные IP — вы открываете репликацию в интернет; не делайте так.


PostgreSQL: репликация с автоматическим failover

Что настраивается

Модуль pgcluster (install.sh → PostgreSQL-кластер) поднимает потоковую репликацию: primary пишет WAL, реплики его читают и применяют. Отставание обычно единицы миллисекунд в одной сети и десятки — между дата-центрами.

На каждом узле ставится mitdev-pg-watchdog — та часть, которая делает эту конструкцию отказоустойчивой, а не просто реплицированной.

Логика watchdog

Watchdog работает от имени postgres и имеет ровно одно право root через sudoers: systemctl (stop|start|restart) postgresql*. Ничего больше.

На реплике каждые CHECK_INTERVAL (5 с) он пробует primary двумя способами: pg_isready и сырой TCP-коннект. Оба должны провалиться, чтобы попытка засчиталась как отказ — это отсекает ложные срабатывания от временной загрузки БД.

После FAIL_THRESHOLD (6) подряд неудач и паузы PROMOTE_DELAY:

На primary watchdog занимается самоограждением (fencing). Он следит за role-endpoint'ами пиров. Если другой узел сообщает, что он primary, и его timeline выше — значит, этот узел был недоступен, реплику повысили, и он устарел. Тогда он сам себя понижает: останавливается, делает pg_rewind от победителя, стартует его репликой.

Именно поэтому split-brain не случается: узел с более старым timeline никогда не остаётся primary. Ожившый после сбоя старый primary возвращается в кластер репликой — без единой ручной команды.

t=0    srv-1 primary (timeline 1), srv-2 и srv-3 реплики
t=1    srv-1 умирает (питание/сеть/kernel panic)
t=6    srv-2: 6 неудачных проб подряд, никто не объявил себя primary
t=6    srv-2 → promote, timeline 2, поднимает role-endpoint
t=7    srv-3 видит: srv-2 primary → refollow srv-2 (без копирования данных)
t=45   srv-1 оживает, стартует как primary timeline 1
t=50   srv-1 видит: srv-2 primary с timeline 2 > 1 → self-demote
       stop → pg_rewind от srv-2 → start как реплика srv-2
t=55   кластер снова здоров: srv-2 primary, srv-1 и srv-3 реплики

Полный цикл — меньше минуты, ноль ручных действий.

Управление

sudo mitdev pg status              # роль, базы, состояние репликации, отставание
sudo mitdev pg replication         # детально: слоты, WAL-позиции, лаг по реплике
sudo mitdev pg watchdog status     # состояние службы + последние события

Настройка порогов (перезапускает службу автоматически):

sudo mitdev pg watchdog set CHECK_INTERVAL 5      # период проб, с
sudo mitdev pg watchdog set FAIL_THRESHOLD 6      # подряд неудач до реакции
sudo mitdev pg watchdog set PROMOTE_DELAY 0       # доп. пауза перед promote, с
sudo mitdev pg watchdog set AUTO_PROMOTE yes      # yes|no
sudo mitdev pg watchdog set PEERS "10.100.0.2 10.100.0.3"

CHECK_INTERVAL × FAIL_THRESHOLD + PROMOTE_DELAY = время до повышения. По умолчанию 5 × 6 + 0 = 30 секунд.

Как выбирать:

Ручные операции

sudo mitdev pg add-replica 10.100.0.3     # на primary: разрешить новую реплику
sudo mitdev pg remove-replica 10.100.0.3  # на primary: убрать (слот+pg_hba+ufw)
sudo mitdev pg allow 10.100.0.1           # разрешить серверу приложений
sudo mitdev pg promote                    # ручное повышение реплики
sudo mitdev pg follow 10.100.0.2          # плановое перецепление к новому primary
sudo mitdev pg import backup.dump         # залить бекап (формат — автоопределение)

promote и follow нужны для плановых операций: обслуживание primary, переезд между ДЦ. При авариях watchdog делает это сам — не мешайте ему параллельными ручными командами.


Единая строка подключения: VIP или локальный прокси

После failover primary — уже другой сервер. Приложение об этом не знает. Есть два способа не переучивать приложение.

Вариант A: VIP через keepalived — узлы в одной L2-сети

Модуль pgvip ставит keepalived на каждый узел кластера с одинаковым конфигом. Скрипт проверки отвечает на вопрос «я сейчас primary?»; узел, который отвечает «да», получает вес VRRP и забирает виртуальный IP.

sudo mitdev pg vip           # кто держит VIP прямо сейчас

Приложение подключается по одному адресу навсегда:

postgresql://app:[email protected]:5432/app?sslmode=require

Используется unicast VRRP — работает в облаках, где мультикаст отфильтрован. В UFW открывается IP-протокол 112 (VRRP) через before.rules.

Ограничение принципиальное: VIP требует общего L2-домена. Узлы в разных дата-центрах через WireGuard L2 не образуют — ARP не ходит через туннель. Для них — вариант B.

Вариант B: локальный HAProxy — узлы где угодно

На сервере приложений поднимается HAProxy, который знает все узлы кластера и через role-endpoint (порт 8008) определяет, кто сейчас primary. Приложение подключается к 127.0.0.1.

# на сервере приложений
sudo mitdev pg proxy 10.100.0.1 10.100.0.2 10.100.0.3
postgresql://app:[email protected]:5432/app?sslmode=require

Если порт 5432 на этом сервере занят (локальный PostgreSQL):

sudo PG_PROXY_PORT=6432 mitdev pg proxy 10.100.0.1 10.100.0.2 10.100.0.3

Не забудьте разрешить серверу приложений доступ на каждом узле БД:

sudo mitdev pg allow <ip-сервера-приложений>

HAProxy проверяет конфигурацию перед перезапуском (haproxy -c); при ошибке изменения откатываются на бэкап.

Как выбрать

VIP (pgvip) Прокси (pg proxy)
Узлы БД в одной L2-сети
Узлы в разных ДЦ
Точка отказа нет (VRRP) HAProxy на сервере приложений
Переключение 3–5 с (VRRP) ~2 с (health check)
Настройка на каждом узле БД на каждом сервере приложений
Приложение видит 10.100.0.100 127.0.0.1

Если серверов приложений много, а узлов БД три — VIP настраивается один раз. Если приложение и БД живут на одних и тех же серверах в разных ДЦ — прокси.


Redis: реплики и Sentinel

Sentinel — это кворумный наблюдатель. Три Sentinel на трёх узлах голосуют, жив ли master; при согласии большинства повышают реплику.

# srv-1 — инициализация
sudo mitdev redis cluster-init my-cache 2
#                              ^имя      ^кворум
sudo mitdev redis token

# srv-2, srv-3
sudo mitdev redis join <токен>

Кворум — сколько Sentinel должны согласиться, что master мёртв. Для трёх узлов ставьте 2. Правило: кворум = N/2 + 1. Кворум 1 при трёх узлах означает, что один изолированный Sentinel может устроить failover — split-brain гарантирован.

Нечётное число узлов обязательно. Два Sentinel с кворумом 2 не переживут отказ ни одного: оставшийся не наберёт кворум. Два узла — это не HA.

sudo mitdev redis status              # роль, реплики, состояние Sentinel
sudo mitdev redis add-replica 10.100.0.4
sudo mitdev redis promote             # ручное повышение

Приложение должно подключаться через Sentinel, а не напрямую к master — иначе оно не узнает о смене. Все нормальные клиенты это умеют:

// ioredis
new Redis({
  sentinels: [
    { host: '10.100.0.1', port: 26379 },
    { host: '10.100.0.2', port: 26379 },
    { host: '10.100.0.3', port: 26379 },
  ],
  name: 'my-cache',      // имя из cluster-init
});
# redis-py
from redis.sentinel import Sentinel
s = Sentinel([('10.100.0.1', 26379), ('10.100.0.2', 26379), ('10.100.0.3', 26379)])
r = s.master_for('my-cache')

Redis-репликация асинхронная. При failover теряются записи, не успевшие дойти до реплики. Для кэша это нормально. Для очереди задач или счётчика платежей — нет; используйте PostgreSQL или RabbitMQ с quorum-очередями.


MongoDB: replica set

Встроенный механизм MongoDB: узлы сами выбирают primary большинством голосов.

# srv-1
sudo mitdev mongo cluster-init rs0
sudo mitdev mongo token

# srv-2, srv-3
sudo mitdev mongo join <токен>
sudo mitdev mongo status
sudo mitdev mongo add-member 10.100.0.4
sudo mitdev mongo remove-member 10.100.0.4
sudo mitdev mongo promote

Как и Sentinel — нечётное число членов. Строка подключения перечисляет всех:

mongodb://user:[email protected]:27017,10.100.0.2:27017,10.100.0.3:27017/db?replicaSet=rs0

Драйвер сам находит primary и переключается при выборах. Для гарантий записи используйте writeConcern: { w: "majority" } — иначе подтверждённая запись может исчезнуть при failover.


RabbitMQ: кластер с quorum-очередями

# srv-1
sudo mitdev rabbitmq cluster-init
sudo mitdev rabbitmq token

# srv-2, srv-3
sudo mitdev rabbitmq join <токен>

Узлы объединяются общим Erlang cookie. mitdev настраивает quorum-очереди (на базе Raft) — в отличие от классических зеркальных, они не теряют сообщения при разделении сети и не требуют ручной настройки политик зеркалирования.

sudo mitdev rabbitmq status
sudo mitdev rabbitmq forget srv-2      # исключить мёртвый узел из кластера

Очередь должна быть объявлена как quorum на стороне приложения:

await channel.assertQueue('jobs', {
  durable: true,
  arguments: { 'x-queue-type': 'quorum' },
});

Классическая (не-quorum) очередь на кластере переживёт отказ узла только если она на нём не жила. Это самая частая ошибка: кластер собран, а очереди классические — и половина сообщений исчезает вместе с узлом.

Публикуйте с publisher confirms, потребляйте с manual ack. Без этого надёжность очереди не спасёт: сообщение потеряется между брокером и вашим кодом.


MinIO: распределённое хранилище

MinIO в распределённом режиме использует erasure coding: объект разрезается на блоки данных и чётности, распределённые по дискам. Кластер переживает отказ до половины дисков без потери данных.

# на КАЖДОМ узле указываются ВСЕ адреса, включая себя
sudo mitdev minio cluster-init 10.100.0.1 10.100.0.2 10.100.0.3 10.100.0.4

Четыре узла — практический минимум. На меньшем числе erasure coding не даёт защиты, ради которой затевался. Состав кластера фиксируется при старте и не меняется на лету — добавление узла означает пересоздание пула.

sudo mitdev minio status
sudo mitdev minio token
sudo mitdev minio join <токен>

Слой приложения: несколько инстансов за nginx

БД реплицирована, но если приложение живёт на одном сервере — этот сервер и есть точка отказа.

На одном сервере: PM2 cluster mode

Переживает падение процесса, не переживает падение сервера.

mitdev app node myapp https://github.com/org/myapp.git app.example.com
pm2 scale myapp 4          # 4 процесса на ядрах
pm2 save

На нескольких серверах: nginx upstream

На каждом сервере nginx проксирует на локальные и удалённые инстансы:

# /etc/nginx/conf.d/upstream-myapp.conf
upstream myapp {
    server 127.0.0.1:3000       max_fails=2 fail_timeout=10s;
    server 10.100.0.2:3000      max_fails=2 fail_timeout=10s backup;
    server 10.100.0.3:3000      max_fails=2 fail_timeout=10s backup;
    keepalive 32;
}

server {
    listen 443 ssl http2;
    server_name app.example.com;

    location / {
        proxy_pass http://myapp;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        proxy_next_upstream error timeout http_502 http_503 http_504;
        proxy_connect_timeout 2s;
    }
}

backup означает «использовать, только если локальный лёг». Уберите backup для честной балансировки по всем трём.

Точка входа снаружи

Остаётся вопрос: куда указывает DNS? Варианты по возрастанию цены:

  1. DNS round-robin — три A-записи на один домен. Браузеры и большинство HTTP-клиентов сами пробуют следующий адрес при отказе. Дёшево, но TTL и кэширование резолверов дают шлейф ошибок на минуты.
  2. Failover DNS (Cloudflare, Route53 health checks) — записи снимаются автоматически при отказе. Переключение за 30–60 с.
  3. Балансировщик провайдера — Hetzner LB, AWS ALB. Секунды, но платно и привязывает к провайдеру.
  4. Anycast + BGP — если у вас своя AS. Если бы у вас была своя AS, вы бы не читали этот документ.
  5. Cloudflare app-ingress (mitdev cf) — туннель на каждый узел + Cloudflare Load Balancer с health-монитором. Узел не выставляет наружу ни одного порта (трафик идёт исходящим соединением к edge), больной узел (в т.ч. при «сером отказе» — жив app, мёртв путь к БД) выводится из ротации за секунды локальным watchdog'ом и со стороны edge монитором. Всё настраивается через API. Подробно — modules/24-cloudflare.md.

Для большинства проектов вариант 2 — правильный компромисс. Вариант 5 берут, когда важно не открывать узлы наружу вовсе и ловить серый отказ, а не только смерть узла.


Полный пример: три сервера

Задача: Node.js-приложение, PostgreSQL с failover, Redis-кэш, всё переживает отказ любого одного сервера.

0. Подготовка

# на каждом из трёх серверов
sudo hostnamectl set-hostname srv-1     # srv-2, srv-3 соответственно
curl -fsSL https://ваш-домен/get | sudo bash
# в инсталляторе: Node.js, Nginx, PM2, Certbot, UFW, PostgreSQL, Redis

1. Приватная сеть

# srv-1
sudo mitdev net create                  # 10.100.0.0/24, порт 51820 → 10.100.0.1
sudo mitdev net token --raw             # скопировать TOKEN

# srv-2
sudo mitdev net join <TOKEN>            # → 10.100.0.2, печатает PEER_2
# srv-1
sudo mitdev net add-peer <PEER_2>

# srv-3
sudo mitdev net join <TOKEN>            # → 10.100.0.3, печатает PEER_3
# srv-1 и srv-2
sudo mitdev net add-peer <PEER_3>

# проверка на любом узле
sudo mitdev net status                  # три узла, handshake свежий

2. Кластер PostgreSQL

# srv-1 — primary
sudo ./install.sh                       # модуль pgcluster → роль primary
                                        # PG_REPLICA_IPS="10.100.0.2 10.100.0.3"
sudo mitdev net bind postgresql private

# srv-2 и srv-3 — реплики
sudo ./install.sh                       # модуль pgcluster → роль replica
                                        # PG_PRIMARY_HOST=10.100.0.1
sudo mitdev net bind postgresql private

# проверка
sudo mitdev pg status                   # srv-1: primary, 2 реплики, лаг ~0
sudo mitdev pg replication              # слоты активны
sudo mitdev pg watchdog status          # служба активна на всех трёх

3. Единая строка подключения

Узлы в одной L2-сети (один ДЦ):

sudo ./install.sh                       # модуль pgvip на каждом узле
                                        # PG_VIP_ADDRESS=10.100.0.100/24
sudo mitdev pg vip                      # → VIP держит srv-1

Узлы в разных ДЦ:

# на каждом сервере приложений (здесь — на всех трёх)
sudo mitdev pg proxy 10.100.0.1 10.100.0.2 10.100.0.3
# на каждом узле БД
sudo mitdev pg allow 10.100.0.1
sudo mitdev pg allow 10.100.0.2
sudo mitdev pg allow 10.100.0.3

4. Redis + Sentinel

# srv-1
sudo mitdev redis cluster-init app-cache 2
sudo mitdev redis token                 # → RTOKEN
sudo mitdev net bind redis private

# srv-2, srv-3
sudo mitdev redis join <RTOKEN>
sudo mitdev net bind redis private

sudo mitdev redis status                # master + 2 replica, 3 Sentinel, кворум 2

5. Приложение

# на каждом сервере
mitdev app node myapp https://github.com/org/myapp.git app.example.com

.env приложения (одинаковый на всех трёх):

# VIP-вариант
DATABASE_URL=postgresql://app:[email protected]:5432/app?sslmode=require
# или прокси-вариант
DATABASE_URL=postgresql://app:[email protected]:5432/app?sslmode=require

REDIS_SENTINELS=10.100.0.1:26379,10.100.0.2:26379,10.100.0.3:26379
REDIS_MASTER_NAME=app-cache

6. Балансировка и DNS

Добавьте upstream-конфиг nginx (см. выше) на каждом сервере, затем три A-записи app.example.com → публичные IP всех трёх, с health-check у DNS-провайдера.

7. Проверка

sudo mitdev doctor --deep     # реальные пробы: nginx HTTP, PG SELECT 1, Redis PONG, SSL

Сценарии отказов и что происходит

Отказ Обнаружение Реакция Простой Действия человека
Процесс приложения упал PM2, мгновенно перезапуск < 1 с нет
Сервер приложений (не primary БД) nginx max_fails, 2 запроса трафик на backup-upstream секунды нет
Primary PostgreSQL watchdog, 6 проб × 5 с реплика → primary; вторая реплика перецепляется; VIP переезжает ~30–35 с нет
Реплика PostgreSQL primary видит разрыв слота ничего, кластер работает 0 вернуть узел
Redis master Sentinel, кворум 2 из 3 реплика → master ~10–15 с нет
Один узел MongoDB выборы replica set новый primary ~10 с нет
Один узел RabbitMQ Raft в quorum-очереди лидерство переезжает секунды rabbitmq forget если узел мёртв навсегда
Один диск MinIO (из 4+) erasure coding чтение/запись продолжаются 0 заменить диск
Разрыв WireGuard-туннеля net-watchdog, 150 с reconnect, перерезолв endpoint до 150 с нет
Оживший старый primary fencing по timeline self-demote, pg_rewind, стал репликой 0 (кластер уже работал) нет
Отказ 2 из 3 узлов кворум потерян, БД read-only до восстановления ручное вмешательство

Последняя строка — принципиальное ограничение любого кворумного кластера. Три узла переживают отказ одного. Хотите переживать два — нужно пять.

Отказ большинства: что делать

Если из трёх узлов остался один, автоматика правильно не повышает его — она не может отличить «остальные умерли» от «я изолирован сетью». Повышение в такой ситуации гарантирует split-brain, когда связь восстановится.

Решение — человеческое, после подтверждения, что узлы действительно мертвы:

sudo mitdev pg watchdog disable         # чтобы не мешал
sudo mitdev pg promote                  # осознанное повышение
# ... восстановить остальные узлы ...
sudo mitdev pg watchdog enable

Учения: проверьте, что оно работает

Отказоустойчивость, которую не проверяли, — это гипотеза. Проверяйте на стенде, воспроизводящем прод, до того, как понадобится.

Учение 1: убить primary PostgreSQL

# srv-1 (текущий primary) — жёсткая эмуляция отказа сервера
sudo systemctl stop postgresql          # мягко: только БД
# или
sudo ip link set wg0 down               # жёстче: изоляция по сети

Наблюдайте на srv-2:

watch -n1 'sudo mitdev pg status'
journalctl -u mitdev-pg-watchdog -f

Ожидаемо: через ~30 с srv-2 становится primary, srv-3 перецепляется к нему. Приложение переживает: соединения через VIP/прокси переустанавливаются.

Верните srv-1:

sudo ip link set wg0 up
# наблюдайте self-demote: stop → pg_rewind → start как реплика
journalctl -u mitdev-pg-watchdog -f
sudo mitdev pg status                   # srv-1 теперь реплика

Измерьте: сколько секунд приложение получало ошибки записи. Это ваш реальный RTO. Если он неприемлем — снижайте FAIL_THRESHOLD.

Учение 2: убить Redis master

sudo systemctl stop redis-server
# на другом узле
redis-cli -p 26379 sentinel masters

Учение 3: разорвать сеть между ДЦ

# на srv-3
sudo ip link set wg0 down
sleep 120
sudo ip link set wg0 up

Проверьте, что srv-3 вернулся сам (mitdev net status на всех узлах) и PostgreSQL на нём перецепился к актуальному primary.

Учение 4: проверить бэкапы

Failover не спасает от DROP TABLE. Реплика послушно повторит его за миллисекунды.

sudo mitdev backup --volumes
# на чистом сервере
sudo mitdev restore mitdev-backup-*.tar.gz

Восстановление, которое не проверяли, — тоже гипотеза.


Чего эта схема не даёт

Честный список ограничений.


Чек-лист перед продакшеном

Сеть

Базы данных

Точка входа

Проверено на практике

Наблюдаемость


Что дальше