Балансировка нагрузки: L4 и L7, алгоритмы, health checks и разбор для собеседования
Балансировка нагрузки для собеседования: L4 против L7, round robin, least connections, power of two choices, проверки здоровья, sticky-сессии и отказоустойчивость. С симуляцией на Python.
Коротко
| Вопрос | Короткий ответ |
|---|---|
| L4 или L7 | L4 быстрее и балансирует соединения. L7 понимает HTTP и балансирует запросы. В больших системах стоят оба |
| Какой алгоритм брать | По умолчанию least connections. P2C, когда полный перебор непрактичен или данные о нагрузке устаревают |
| Почему не просто случайно | В симуляции на 100 серверах при загрузке 90% случайный выбор дал среднюю задержку 10,07, а P2C только 2,64 |
| Что с проверками здоровья | Активные плюс пассивные, отдельный readiness-эндпоинт, без глубокой проверки общих зависимостей |
| Sticky-сессии | Избегать: неровная нагрузка, потеря сессий при падении, сложный деплой. Состояние лучше вынести из приложения |
| Как не сделать его единой точкой отказа | Пара с общим VIP или, на большом масштабе, активные узлы за ECMP/anycast. Плюс запас мощности на потерю узла |
Что делает балансировщик
Балансировщик решает три задачи:
- Распределяет запросы между серверами, чтобы ни один не захлебнулся, пока остальные простаивают.
- Прячет отказы: упавший сервер выводится из ротации, и большинство клиентов ничего не замечает.
- Отвязывает клиентов от конкретных машин. Можно добавлять серверы и катить новую версию без изменений на стороне клиента.
Типичная схема большого сервиса выглядит так:
клиент │ DNS: имя сервиса → адрес или несколько адресов ▼ L4-балансировщики (несколько машин за одним IP) │ смотрят только на IP, порт и протокол ▼ L7-балансировщики (nginx, HAProxy, Envoy) │ завершают TLS, разбирают HTTP, маршрутизируют по пути ▼ серверы приложения → внутренние сервисы → базы данных
Небольшому проекту хватит одного nginx или управляемого балансировщика в облаке. На собеседовании важно объяснить, в какой момент оправдан каждый следующий слой.
L4 против L7
Цифры здесь из модели OSI: L4 это транспортный уровень (TCP, UDP), L7 это прикладной (HTTP, gRPC). Разница определяется тем, что балансировщик видит и на какую единицу работы он принимает решение.
| Критерий | L4 | L7 |
|---|---|---|
| Что видит | IP-адреса, порты, протокол | Путь, заголовки, куки, метод, gRPC-сервис |
| Единица балансировки | Соединение или поток | Запрос |
| TLS | Обычно проходит насквозь, но может и завершаться | Завершается на балансировщике |
| Стоимость | Низкая: не нужно разбирать содержимое | Выше: TLS, парсинг, буферизация |
| Маршрутизация | По адресу и порту | /api в один пул, /static в другой, канареечный релиз |
| Повтор при ошибке | Только при установке соединения: границ запроса он не знает | Да: может отправить запрос на другой сервер |
| IP клиента на бэкенде | Может сохраняться (зависит от режима) | Через X-Forwarded-For или PROXY protocol |
| Примеры | IPVS, Katran, Maglev, AWS NLB, HAProxy в mode tcp | nginx, HAProxy в mode http, Envoy, AWS ALB |
Соединение или запрос: главная ловушка
L4-балансировщик выбирает сервер один раз, на всё соединение. Пока соединения короткие, это незаметно. С долгоживущими соединениями нагрузка перекашивается.
Классический пример: gRPC поверх HTTP/2. Клиент открывает одно соединение и мультиплексирует в нём тысячи запросов. L4 честно отправил это соединение на один сервер, и весь поток запросов от этого клиента теперь идёт туда. В Kubernetes на такое натыкаются с обычным ClusterIP-сервисом: он работает на уровне соединений. Лечится L7-прокси, который разбирает HTTP/2 и балансирует каждый запрос, или балансировкой на стороне клиента.
То же касается HTTP keep-alive: L4-алгоритм least connections считает соединения, а на сервере может быть одно соединение с огромным потоком запросов и десять простаивающих.
Как L4 пересылает трафик
Режимов три. TCP-прокси завершает соединение и открывает второе к бэкенду. NAT подменяет адреса в пакетах и ведёт таблицу соединений, ответ тоже идёт через него. При DSR (Direct Server Return) сервер отвечает клиенту напрямую, минуя балансировщик: это выгодно, когда ответы намного тяжелее запросов, например при раздаче видео, но сеть настраивать сложнее.
Почему оба сразу
L4-слой дешёвый, легко масштабируется горизонтально и почти не меняется. L7-слой умный, но дорогой: на него приходятся TLS и разбор протокола. Если поставить L4 перед L7, можно добавлять L7-узлы без смены публичных адресов, а обновление конфигурации L7 не затрагивает точку входа.
Для внутренних сервисов и простого сайта один слой L7 обычно закрывает всё.
Алгоритмы
| Алгоритм | Стоимость выбора | Когда подходит | Главная проблема |
|---|---|---|---|
| Round robin | Запросы и серверы похожи | Не видит, что сервер перегружен | |
| Взвешенный round robin | Серверы разной мощности | Веса надо поддерживать | |
| Least connections | или | Длительность запросов сильно различается | Быстро падающий сервер притягивает трафик |
| Power of two choices | Большой пул, устаревшие данные о нагрузке | Хуже least connections при точных данных | |
| Хеш по IP | Грубая привязка клиента без куки | NAT и перестройка при изменении пула |
Round robin
Серверы по кругу: A, B, C, A, B, C. Дёшево и работает без данных о нагрузке, поэтому ломаться почти нечему. Это разумный выбор, когда запросы примерно одинаковые.
Проблема видна, когда один запрос занимает 2 мс, а другой 2 секунды: round robin продолжит слать равную долю на сервер, который застрял на тяжёлых.
Взвешенные алгоритмы
Вес нужен, когда серверы разные: новая машина вдвое мощнее старой или вы осторожно заливаете 5% трафика на новую версию.
Наивная реализация с весами 5, 1, 1 выдаёт a a a a a b c, то есть пачку из пяти запросов подряд на один сервер. Nginx использует плавный вариант:
def smooth_weighted_round_robin(weights, count): current = {name: 0 for name in weights} total = sum(weights.values()) order = [] for _ in range(count): for name, weight in weights.items(): current[name] += weight best = max(current, key=current.get) current[best] -= total order.append(best) return order print(smooth_weighted_round_robin({"a": 5, "b": 1, "c": 1}, 7)) # ['a', 'a', 'b', 'a', 'c', 'a', 'a']
Пропорция та же, но запросы к a разбавлены остальными.
Least connections
Отправляем запрос туда, где сейчас меньше всего активных запросов. Алгоритм сам подстраивается и под медленные запросы, и под медленные серверы: сервер, который тормозит, дольше держит запросы и получает меньше новых. Для веса сравнивают отношение активных запросов к весу. В nginx это least_conn, в AWS ALB похожий по смыслу режим называется least outstanding requests.
Простой перебор стоит . Если серверов много, помогает куча с на обновление счётчика.
Две ловушки, о которых стоит сказать вслух:
- Чёрная дыра. Сервер сломался и мгновенно отвечает ошибкой 500. Активных запросов у него всегда ноль, поэтому least connections отправляет на него больше трафика, чем на здоровые. Ниже есть замер: один сломанный сервер из ста получил 7,85% запросов. Нужно считать ошибки и выводить такой сервер из ротации.
- Стадо на новичка. Только что запущенный сервер имеет ноль соединений и получает непропорционально много запросов, причём с холодными кешами. Лечится медленным стартом (slow start): доля трафика растёт постепенно. Но это зависит от продукта: в nginx параметр
slow_startвходит в коммерческую подписку, а в AWS ALB slow start нельзя включить вместе с least outstanding requests.
Power of two choices
Берём два случайных сервера, отправляем запрос на менее загруженный из них. Звучит как полумера, но следующий раздел покажет, насколько это сильный приём.
def pick_p2c(load, rng): a, b = rng.sample(range(len(load)), 2) return a if load[a] <= load[b] else b
В реальных продуктах это, например, balance random в HAProxy (по умолчанию два кандидата), random two least_conn в nginx и least request в Envoy, который при равных весах тоже по умолчанию берёт двух кандидатов.
Хеш по IP
Сервер определяется хешем адреса клиента. Состояния нет, один и тот же клиент попадает на один и тот же сервер. Проблем три:
- NAT. Весь офис или мобильный оператор выходит через один адрес и попадает на один сервер. В
ip_hashу nginx ключом служат первые три октета IPv4, так что на один сервер уходит целая подсеть /24. - Смена адреса. Телефон перешёл с Wi-Fi на мобильную сеть, и привязка потеряна.
- Перестройка. При простом
hash % nизменение числа серверов перемещает большинство клиентов.
Последнее легко посчитать:
import hashlib def bucket(key, n): return int(hashlib.md5(key.encode()).hexdigest(), 16) % n keys = [f"user-{i}" for i in range(100_000)] for old, new in [(4, 5), (4, 3), (10, 11)]: moved = sum(bucket(k, old) != bucket(k, new) for k in keys) print(f"{old} -> {new}: переехало {moved / len(keys):.1%}")
4 -> 5: переехало 79.9% 4 -> 3: переехало 75.0% 10 -> 11: переехало 90.8%
Для кеша это катастрофа: добавили один сервер и потеряли большую часть попаданий. Поэтому там, где привязка действительно нужна, используют консистентное хеширование: при добавлении пятого сервера к четырём в идеале переезжает около 20% ключей. Такие варианты есть, например, в Envoy (ring hash и Maglev).
Power of two choices: считаем сами
Статическая модель
Классическая задача: запросов раскидываем по серверам и смотрим на самый загруженный. При случайном выборе максимум растёт как . Если выбирать меньший из двух случайных, получается . Это результат Азара, Бродера, Карлина и Упфала (1994), а в задаче балансировки его подробно разобрал Митценмахер в диссертации 1996 года.
Второй кандидат даёт экспоненциальный выигрыш, а дальше эффект резко падает: с кандидатами знаменатель становится , то есть переход от двух к трём улучшает результат лишь на постоянный множитель. Проверим первую часть:
import random def max_load(n, choices, rng): """n запросов на n серверов: сколько досталось самому загруженному.""" load = [0] * n for _ in range(n): best = min((rng.randrange(n) for _ in range(choices)), key=load.__getitem__) load[best] += 1 return max(load) rng = random.Random(42) print(f"{'серверов':>10} {'случайно':>9} {'p2c':>5}") for n in (1_000, 10_000, 100_000, 1_000_000): runs = 10 if n < 1_000_000 else 3 one = sum(max_load(n, 1, rng) for _ in range(runs)) / runs two = sum(max_load(n, 2, rng) for _ in range(runs)) / runs print(f"{n:>10} {one:>9.1f} {two:>5.1f}")
Мой запуск на Python 3.9.6, усреднение по 10 прогонам (для миллиона серверов по 3):
серверов случайно p2c 1000 5.4 3.0 10000 6.5 3.1 100000 7.7 3.4 1000000 9.3 4.0
Пул вырос в тысячу раз. У случайного выбора максимум вырос почти на 4 запроса, у P2C только на 1.
Модель с очередями
Запросы на самом деле завершаются, и важен не максимум, а задержка. Поэтому вторая симуляция ближе к жизни: пуассоновский поток, 100 серверов, у каждого FIFO-очередь и один обработчик, загрузка 90%. Длительность запроса случайная, среднее равно 1, в этих же единицах измерены задержки.
Параметр refresh показывает, как часто балансировщик обновляет данные о нагрузке. Ноль означает, что он всегда знает точное число запросов.
import itertools import random from collections import deque def pick_random(load, rng): return rng.randrange(len(load)) def pick_p2c(load, rng): a, b = rng.sample(range(len(load)), 2) return a if load[a] <= load[b] else b def pick_least(load, rng): low = min(load) return rng.choice([i for i, x in enumerate(load) if x == low]) def make_round_robin(): counter = itertools.count() return lambda load, rng: next(counter) % len(load) def exp_service(rng): return rng.expovariate(1.0) def heavy_service(rng): # логнормальное распределение со средним 1 и длинным хвостом return rng.lognormvariate(-1.125, 1.5) def simulate(pick, refresh=0.0, service=exp_service, servers=100, rho=0.9, requests=500_000, seed=1): rng = random.Random(seed) queues = [deque() for _ in range(servers)] # моменты завершения запросов view = [0] * servers # нагрузка глазами балансировщика now = next_refresh = 0.0 latencies = [] for i in range(requests): now += rng.expovariate(rho * servers) if now >= next_refresh: for q in queues: while q and q[0] <= now: q.popleft() view = [len(q) for q in queues] next_refresh = now + refresh q = queues[pick(view, rng)] start = max(now, q[-1]) if q else now finish = start + service(rng) q.append(finish) if i >= requests // 10: # первые 10% считаем прогревом latencies.append(finish - now) latencies.sort() mean = sum(latencies) / len(latencies) p99 = latencies[int(len(latencies) * 0.99)] return mean, p99 policies = [ ("random", lambda: pick_random), ("round robin", make_round_robin), ("p2c", lambda: pick_p2c), ("least conn", lambda: pick_least), ] for refresh in (0.0, 1.0, 5.0, 20.0): print(f"refresh={refresh}") for name, make in policies: mean, p99 = simulate(make(), refresh=refresh) print(f" {name:12} mean={mean:7.2f} p99={p99:7.2f}") print("длинный хвост") for name, make in policies: mean, p99 = simulate(make(), service=heavy_service) print(f" {name:12} mean={mean:7.2f} p99={p99:7.2f}")
Результаты с точными данными (refresh=0):
| Алгоритм | Средняя задержка | p99 |
|---|---|---|
| Случайный | 10,07 | 47,18 |
| Round robin | 5,22 | 24,49 |
| Power of two | 2,64 | 8,91 |
| Least connections | 1,06 | 4,89 |
Один дополнительный случайный кандидат уменьшил среднюю задержку в 3,8 раза, а p99 в 5,3 раза. И это при том, что балансировщик смотрит всего на два сервера из ста.
Проверка на здравый смысл: числа сходятся с теорией. Случайный выбор превращает каждый сервер в независимую очередь M/M/1, и средняя задержка равна . Для кандидатов при большом числе серверов Митценмахер выводит формулу
При и она даёт 2,61, симуляция показала 2,64. Заодно формула показывает, как быстро падает отдача: три кандидата дают 2,03, четыре 1,78. Самый большой шаг делает именно второй: с 10 до 2,61.
Что происходит с устаревшими данными
Посмотрим, что будет, если балансировщик видит общую картину нагрузки с задержкой: например, серверы отчитываются о загрузке раз в несколько секунд или данные приходят через управляющий сервис. В таблице средняя задержка и p99 при разной частоте обновления (refresh измеряется в длительностях среднего запроса):
| Алгоритм | refresh=0 | refresh=1 | refresh=5 | refresh=20 |
|---|---|---|---|---|
| Случайный | 10,07 / 47,18 | 10,07 / 47,18 | 10,07 / 47,18 | 10,07 / 47,18 |
| Round robin | 5,22 / 24,49 | 5,22 / 24,49 | 5,22 / 24,49 | 5,22 / 24,49 |
| Power of two | 2,64 / 8,91 | 2,96 / 9,86 | 4,00 / 13,26 | 7,05 / 24,76 |
| Least connections | 1,06 / 4,89 | 4,05 / 15,37 | 11,52 / 38,26 | 51,31 / 259,12 |
Least connections при refresh=20 оказался в пять раз хуже случайного выбора. Пока данные не обновились, все запросы летят на одни и те же «лучшие» серверы, и те захлёбываются. Это стадный эффект, его разбирал Митценмахер в работе «How Useful Is Old Information?». P2C держится намного лучше: случайная выборка размазывает запросы даже при старых данных.
Панацеей P2C не становится: при refresh=20 по средней задержке его обошёл обычный round robin, который данных не использует вовсе.
Несколько балансировщиков: популярный миф
Часто говорят, что стадный эффект неизбежен, как только балансировщиков становится несколько. Я проверил это отдельно, и картина оказалась другой. Если каждый балансировщик точно считает свои запросы, но не видит чужие, данные у него неполные, но не устаревшие. Свой запрос он учитывает сразу, поэтому не заваливает один и тот же сервер.
Заодно здесь же замерим «чёрную дыру» из раздела про least connections: сервер 0 мгновенно отвечает ошибкой, и его счётчик не растёт.
import heapq def simulate_balancers(pick, balancers=1, broken=False, servers=100, rho=0.9, requests=300_000, seed=1): rng = random.Random(seed) views = [[0] * servers for _ in range(balancers)] # у каждого только свои запросы free_at = [0.0] * servers running = [] # куча: (момент завершения, балансировщик, сервер) now = 0.0 latencies = [] errors = 0 for i in range(requests): now += rng.expovariate(rho * servers) while running and running[0][0] <= now: _, b, s = heapq.heappop(running) views[b][s] -= 1 b = rng.randrange(balancers) s = pick(views[b], rng) if broken and s == 0: # мгновенный ответ 500: счётчик не растёт errors += 1 continue finish = max(now, free_at[s]) + rng.expovariate(1.0) free_at[s] = finish views[b][s] += 1 heapq.heappush(running, (finish, b, s)) if i >= requests // 10: latencies.append(finish - now) latencies.sort() mean = sum(latencies) / len(latencies) p99 = latencies[int(len(latencies) * 0.99)] return mean, p99, errors / requests print("независимые балансировщики") for count in (1, 10, 50): for name, pick in [("p2c", pick_p2c), ("least conn", pick_least)]: mean, p99, _ = simulate_balancers(pick, balancers=count) print(f" {count:>2} {name:12} mean={mean:5.2f} p99={p99:5.2f}") print("один сломанный сервер из ста") for rho in (0.5, 0.9, 0.98): for name, make in policies: _, _, errors = simulate_balancers(make(), broken=True, rho=rho) print(f" rho={rho} {name:12} ошибок={errors:.2%}")
Средняя задержка и p99 при разном числе балансировщиков:
| Балансировщиков | Power of two | Least connections |
|---|---|---|
| 1 | 2,64 / 8,88 | 1,06 / 4,90 |
| 10 | 4,17 / 13,83 | 3,63 / 11,90 |
| 50 | 6,23 / 21,64 | 5,97 / 20,74 |
Least connections впереди на всех трёх строках, хотя разрыв быстро сокращается. Это согласуется с тестами HAProxy, где было до 12 балансировщиков на сервис: least connections выиграл во всех замерах, а P2C авторы предлагают там, где настоящий least connections непрактичен.
Доля запросов, попавших на сломанный сервер (справедливая доля равна 1%):
| Загрузка | Случайный | Round robin | Power of two | Least connections |
|---|---|---|---|---|
| 50% | 1,01% | 1,00% | 1,52% | 2,02% |
| 90% | 1,01% | 1,00% | 1,91% | 7,85% |
| 98% | 1,01% | 1,00% | 1,99% | 11,25% |
Здесь и видно настоящее преимущество P2C. Сервер попадает в пару с вероятностью , поэтому даже с нулевым счётчиком не получит больше двойной доли. То же ограничение защищает новичка без прогретых кешей от наплыва запросов.
Перекос у least connections растёт с загрузкой: чем меньше свободных серверов, с которыми сломанный делит ничью, тем больше запросов достаётся ему.
Что происходит на длинном хвосте
Теперь те же алгоритмы с тяжёлыми запросами: логнормальное распределение с тем же средним 1, точные данные.
| Алгоритм | Средняя задержка | p99 |
|---|---|---|
| Случайный | 39,36 | 266,93 |
| Round robin | 34,06 | 228,30 |
| Power of two | 5,23 | 37,92 |
| Least connections | 1,26 | 13,65 |
Round robin почти потерял преимущество над случайным выбором: он не знает, что на сервере застрял огромный запрос. Алгоритмы, которые смотрят на нагрузку, страдают заметно меньше.
Выводы и ограничения
- Если есть точные счётчики, даже свои собственные, least connections даёт лучшую задержку.
- P2C нужен, когда полный перебор непрактичен, данные о нагрузке приходят из общего устаревающего источника или важно ограничить долю трафика на один сервер. Цена: задержка выше, чем у least connections на точных данных.
- При похожих запросах и отсутствии данных round robin вполне достаточен.
Модель намеренно игрушечная: одинаковые серверы, один обработчик на сервер, один запуск с фиксированным зерном. При низкой загрузке разница намного меньше, потому что очередей там почти нет. На реальной системе числа будут другими: в тестах HAProxy на настоящем стенде при умеренной нагрузке least connections опередил P2C примерно на 4%. Переносить стоит качественные выводы, а не конкретные значения.
Проверки здоровья
Активные и пассивные
- Активные. Балансировщик сам ходит на
/healthраз в несколько секунд. Находит проблему даже без трафика и понимает, когда сервер ожил. - Пассивные. Балансировщик смотрит на настоящие ответы: ошибки, таймауты, сбросы соединений. Реагирует сразу, но только там, где идёт трафик, и на неудачных запросах пользователей.
В продакшене их совмещают. В открытом nginx из коробки есть только пассивные (max_fails, по умолчанию 1, и fail_timeout, по умолчанию 10 секунд), а активные health_check идут в коммерческой версии. У HAProxy активные проверки есть и в открытой версии. Envoy умеет и то и другое, пассивный вариант там называется outlier detection.
upstream backend { zone backend 64k; random two least_conn; server 10.0.0.1:8080 weight=2 max_fails=3 fail_timeout=30s; server 10.0.0.2:8080 max_fails=3 fail_timeout=30s; server 10.0.0.3:8080 max_fails=3 fail_timeout=30s; }
Директива zone нужна, чтобы воркеры nginx видели общие счётчики. Без неё каждый считает только свои запросы.
Пассивные проверки нужны и потому, что активные срабатывают с задержкой. Пример: 10 серверов, 1000 запросов в секунду, проверка раз в 5 секунд, порог 3 неудачи. На падение одного сервера уходит 15 секунд, и всё это время на него идёт 100 запросов в секунду. Это 1500 неудачных запросов, если нет пассивных проверок и повторов.
Что проверять
Самая дорогая ошибка: включить в проверку здоровья общую зависимость. Обработчик /health ходит в базу, база притормозила на 10 секунд, все серверы разом отвечают 503, балансировщик выводит из ротации весь пул. Вместо замедления получилась полная недоступность.
Поэтому в Kubernetes это две разные проверки:
- Liveness: процесс жив и не завис. Провал означает перезапуск контейнера. Внешние зависимости сюда включать нельзя, иначе проблема в базе вызовет бесконечные перезапуски.
- Readiness: сервер готов принимать трафик. Провал означает вывод из балансировки без перезапуска. Сюда идут прогрев кеша, загрузка конфигурации, заполненность собственных очередей.
Как про это спрашивают, есть в разборе собеседования на DevOps.
Настройки и подстраховка
- Гистерезис. Разные пороги на вывод и возврат, например 3 неудачи и 2 успеха подряд. Иначе сервер на границе будет постоянно входить в ротацию и выпадать из неё.
- Fail open. Если больным оказался почти весь пул, это скорее проблема в самой проверке. AWS ALB при нехватке здоровых целей сам начинает слать трафик на все зарегистрированные, а в Envoy для этого есть panic threshold.
Плавное выключение
Деплой без потерянных запросов: приложение получает SIGTERM и начинает отвечать на readiness ошибкой, балансировщик перестаёт слать новые запросы и даёт завершиться текущим (connection draining, в AWS это deregistration delay, по умолчанию 300 секунд), после чего процесс выходит. Если просто убить процесс, все запросы в работе станут ошибками.
Повторы
L7-балансировщик может скрыть сбой, отправив запрос на другой сервер. Опасностей две:
- Неидемпотентные запросы. Повтор
POSTпри оплате может списать деньги дважды, если первый сервер успел выполнить запрос, но не успел ответить. Поэтому nginx по умолчанию не отправляетPOST,LOCKиPATCHна следующий сервер, если запрос уже ушёл на первый. - Шторм повторов. Сервис деградировал, каждый запрос теперь повторяется трижды, нагрузка утроилась, и сервис уже не встаёт. Нужен бюджет: например, повторы не более 10% от обычного трафика.
Sticky-сессии
Sticky-сессии привязывают клиента к одному серверу: через куку балансировщика, хеш по IP или идентификатор сессии в приложении. Обычно они появляются не от хорошей жизни: сессия или корзина лежат в памяти процесса, и другой сервер про них ничего не знает.
Почему лучше избегать
- Неровная нагрузка. Алгоритм решает только один раз, при первом запросе. Дальше тяжёлые и долгие пользователи остаются там, где оказались, и один сервер может быть заметно загружен сильнее других.
- Масштабирование почти не помогает сразу. Новые серверы получают только новые сессии, старые остаются на прежних машинах. Документация AWS ALB прямо выделяет эту ситуацию и описывает ручную перебалансировку через сброс куки.
- Падение сервера теряет сессии. Балансировщик перенесёт клиента на другой сервер, но данных там нет: пользователя разлогинит, корзина опустеет.
- Деплой становится сложнее. Чтобы никого не выбросить, надо долго ждать, пока сервер опустеет.
- Состояние становится неявным. Баг воспроизводится только у пользователей на третьем сервере, и по логам это понять сложно.
Что вместо них
| Подход | Как работает | Цена |
|---|---|---|
| Внешнее хранилище сессий | Сессии в Redis или базе, сервер сам ничего не хранит | Сетевой запрос и ещё одна зависимость, которой нужна своя отказоустойчивость |
| Подписанный токен (JWT) | Состояние лежит на клиенте, сервер проверяет подпись | Сложно отозвать токен до истечения срока |
| Консистентное хеширование | Запрос по ключу идёт туда, где лежат данные | Потеря данных не страшна только для кеша |
Когда они оправданы
Хорошее правило: привязка допустима как оптимизация, но не как условие корректности. Если сервер пропал, а клиент получил лишний промах кеша или медленный ответ, это нормально. Если пользователь потерял работу, у вас скрытое состояние.
Случаи, где без привязки трудно: WebSocket привязан к серверу самим соединением, а Socket.IO с HTTP long-polling требует, чтобы запросы одной сессии попадали в один процесс. Если разрешить только WebSocket, это требование исчезает. Для игровых серверов и других процессов с большим состоянием в памяти чаще делают явную маршрутизацию на конкретный экземпляр.
Отказоустойчивость самого балансировщика
Балансировщик защищает от отказа серверов, но сам становится единой точкой отказа. Интервьюер почти наверняка об этом спросит.
| Подход | Как работает | Плюсы | Минусы |
|---|---|---|---|
| Активный + резервный, общий VIP | keepalived по VRRP переносит плавающий IP на живой узел | Просто, хватает для многих проектов | Резерв простаивает, потолок равен мощности одной машины |
| Активные узлы за ECMP | Роутер хеширует потоки по нескольким балансировщикам, узлы анонсируют IP по BGP | Масштабируется, нет простоя | Нужна сеть с BGP и забота о состоянии соединений |
| Anycast | Один IP анонсируется из нескольких точек, трафик идёт к ближайшей | Отказ площадки и низкие задержки | Смена маршрута может оборвать TCP |
| DNS с несколькими адресами | Имя отдаёт несколько адресов, упавший убирается из ответа | Работает между регионами | Клиенты, игнорирующие TTL, продолжат ходить на старый адрес и получать ошибки |
| Балансировка на клиенте | Клиент сам выбирает сервер из реестра, или это делает sidecar | Нет центрального звена | Логика у каждого клиента, данные о нагрузке только свои |
Про то, как клиент узнаёт адреса сервисов при балансировке на своей стороне, есть раздел о Service Discovery в статье про паттерны микросервисов.
Что происходит при падении узла
Если падает L7-балансировщик, его TCP-соединения обрываются: они на нём и заканчивались. Клиенты переподключаются, и на этот случай нужны повторы на стороне клиента.
С L4 интереснее. Роутер перехеширует потоки между оставшимися узлами, и соединение может попасть на другой балансировщик, у которого нет записи о нём. Чтобы оно не порвалось, этот узел должен выбрать тот же бэкенд. Поэтому в Maglev, балансировщике Google, используются консистентное хеширование и отслеживание соединений: разные машины независимо приходят почти к одному ответу.
На что ещё обратить внимание
- Запас мощности. Два активных узла под нагрузкой 70% каждый: после падения одного оставшийся получит 140% и упадёт следом. Чтобы пережить потерю одного из узлов, рабочая загрузка не должна превышать .
- Несколько зон. Балансировщик в одной зоне доступности разделит её судьбу. Управляемые балансировщики ставят узлы в каждую включённую зону, но следить за запасом серверов по зонам всё равно придётся вам.
- Плохая конфигурация. Узлы часто падают не от железа, а от одинаковой ошибочной конфигурации, выкаченной на все сразу. Поэтому конфигурацию катят постепенно, как и код.
Как говорить об этом на интервью
Балансировщик появляется почти в любой задаче на системный дизайн, и поэтому ошибка номер один: нарисовать прямоугольник LB и идти дальше. Общую схему ответа смотрите в статье про подходы к проектированию систем, а здесь только то, что касается балансировки.
Что сказать сразу
Порядок такой: зачем и где стоит балансировщик, какой уровень, какой алгоритм и почему, как узнаём об отказах, что будет, если упадёт он сам. Алгоритм привязывайте к свойствам задачи: длительности запросов и размеру пула.
Пример, как это может звучать:
Перед приложением поставлю L7-балансировщик: нужны TLS, маршрутизация
/apiи/uploadsв разные пулы и повторы для идемпотентных запросов. Серверы приложения сделаем без состояния, сессии в Redis, поэтому sticky не понадобится. Запросы у нас разной длительности, поэтому least connections, а не round robin. Если балансировка уедет в sidecar с большим пулом серверов, посмотрю на power of two choices: он ограничит долю трафика на сломанный или только что запущенный сервер. Проверки: readiness раз в 5 секунд без похода в базу, плюс вывод по ошибкам. Сам балансировщик в двух зонах, каждый узел держит не больше половины от своего максимума, чтобы пережить потерю другого.
Это занимает меньше минуты и закрывает почти все вопросы до того, как их зададут. Если говорить вслух вы ещё не привыкли, посмотрите статью про мышление вслух на интервью.
Частые уточняющие вопросы
| Вопрос | Что ответить |
|---|---|
| Чем L4 отличается от L7? | Что видит и что балансирует: соединения против запросов. Пример с gRPC и HTTP/2 |
| Зачем P2C, если есть least connections? | Выбор за , устойчивость к устаревшим данным, не больше двойной доли трафика на один сервер. На точных данных least connections лучше |
| Что будет, если сервер медленный, но живой? | Проверка может его не поймать. Спасут least connections, пассивные проверки и таймауты |
| Как обновлять серверы без простоя? | Readiness в ошибку, draining, потом остановка. Новые серверы с медленным стартом |
| Как балансировать между регионами? | DNS или anycast на верхнем уровне, внутри региона обычная схема. Отдельно думать о данных |
| Как подобрать число балансировщиков? | Пик трафика разделить на проверенную нагрузочным тестом мощность узла, добавить запас на отказ |
Общие приёмы для таких разговоров есть в статье про уточняющие вопросы.
Типичные ошибки
- Предложить sticky-сессии как первое решение проблемы с состоянием.
- Включить базу данных в проверку здоровья и не заметить последствия.
- Говорить, что L7 «умнее и поэтому лучше», не называя цену: TLS, разбор протокола, задержка.
- Считать балансировку решением для перегруженной базы данных. Она распределяет запросы между копиями, но записи от этого не масштабируются.