server a.internal weight=10 max_fails=2; — десять к одному. Ночью бэкенд на пару секунд отвалился, к утру давно жив и из ротации не выведен. А первые полсотни запросов рабочего дня распределяются не 10:1, и среди них есть места, где два запроса подряд уходят на сосед с весом единица.Ни один лог об этом не скажет, в документации по upstream такого объяснения нет. Есть — в двух строках ngx_http_upstream_round_robin.c.Разбираем по тегу release-1.31.3, со ссылками файл:строка: smooth weighted round-robin и его восемь копий в исходниках; effective_weight, которого нет в документации, и то, как max_fails втихую им управляет; почему least_conn сравнивает не число соединений; почему ip_hash не работает на unix-сокетах. Плюс sticky и least_time, приехавшие в open source несколько месяцев назад. Читать далее
Внутренний сервис, два бэкенда, десять к одному:
upstream backend {
server a.internal weight=10 max_fails=2;
server b.internal weight=1;
}Трафика немного — десятки запросов в минуту. Ночью a на несколько секунд отвалился: сеть моргнула, две попытки не прошли. Утром он давно жив, из ротации не выведен, fail_timeout истёк часы назад.
И первые полсотни запросов рабочего дня распределяются не десять к одному. Более того, среди них есть места, где два запроса подряд уходят на b — на бэкенд с весом единица, пока сосед с весом десять здоров и доступен.
access.log с $upstream_addr покажет, что запросы ушли на b, но не объяснит почему. error.log промолчит вовсе. В документации по upstream тоже нет ни слова, которое объясняло бы такое поведение.
Сразу оговорка: дальше мы считаем, что оба ночных отказа пришлись на один воркер. Это не гарантировано — почему, разберём в разделе про zone.
Объясняется всё двумя строками в ngx_http_upstream_round_robin.c. Чтобы до них добраться, нужно сначала разобраться, что в nginx происходит при выборе бэкенда — и обнаружить, что «алгоритмов балансировки» там ровно один.
Документация перечисляет методы так, будто это независимые стратегии: round-robin по умолчанию, least_conn, least_time, ip_hash, hash, random. Выбираешь по задаче, как алгоритм сортировки.
В исходниках одна и та же арифметика весов лежит в восьми файлах. Не вызовом общей функции — скопированным телом цикла. Модули не реализуют выбор сами: они фильтруют кандидатов по своему критерию, а когда фильтр не даёт однозначного победителя, падают в ту самую арифметику, каждый в свою локальную копию. И копии, как мы увидим, уже успели разойтись.
Весь разбор — по тегу release-1.31.3 репозитория nginx/nginx. Чтобы идти по тексту со своим чекаутом:
git clone https://github.com/nginx/nginx && cd nginx
git checkout release-1.31.3Все ссылки дальше — в формате файл:строка для этого тега. Речь везде про open source nginx; про то, что меняет zone и чего нет в бесплатной версии, отдельный разговор в конце.
Два модуля совсем свежие: sticky приехал в open source в 1.29.6, least_time — в 1.31.0. В nginx из репозитория вашего дистрибутива их, скорее всего, ещё нет.
Балансировщик в nginx — не подсистема и не слой. Это два указателя на функции в структуре соединения с пиром (src/event/ngx_event_connect.h:46-47):
ngx_event_get_peer_pt get;
ngx_event_free_peer_pt free;get вызывается, когда нужно выбрать бэкенд. free — когда соединение с ним завершилось, успешно или нет. Всё, что делает директива least_conn или ip_hash в конфиге — подставляет в get другую функцию.
Тот же приём держит всю модульность nginx: конфигурация не переключает режимы, она проставляет указатели.
Ствол: smooth weighted round-robinngx_http_upstream_get_peer() — оригинал, от которого произошли все остальные. Ядро цикла (src/http/ngx_http_upstream_round_robin.c:884):
peer->current_weight += peer->effective_weight;
total += peer->effective_weight;
if (peer->effective_weight < peer->weight) {
peer->effective_weight++;
}
if (best == NULL || peer->current_weight > best->current_weight) {
best = peer;
p = i;
}
}
/* ... блок sticky, о нём ниже ... */
best->current_weight -= total;У каждого пира три веса. weight — тот, что в конфиге, он не меняется. current_weight — счётчик, живущий между запросами. effective_weight — третий, и про него документация молчит; пока считайте, что он равен weight.
На каждом выборе счётчик каждого пира увеличивается на его вес. Побеждает тот, у кого счётчик оказался больше. Победитель платит: из его счётчика вычитается сумма весов всех участников.
До 2012 года nginx при весах {5, 1, 1} выдавал c b a a a a a — пять запросов подряд на один бэкенд. Нынешний алгоритм даёт другое:
Эволюция current_weight при весах 5-1-1
То же соотношение 5:1:1 на дистанции, но без всплесков. Отсюда «smooth».
Единственное место, где этот алгоритм задокументирован, — сообщение коммита 52327e062 от 14 мая 2012 года. Там же приведена ровно эта таблица эволюции current_weight и та же последовательность {a, a, b, a, c, a, a}. Тринадцать лет — и ни строчки в официальной документации.
Обратите внимание: счётчик победителя уходит в минус. Это не аномалия, а механизм — именно накопленный долг заставляет пира пропустить следующие раунды. Запомните это, к финалу пригодится.
Восемь копий smooth WRRНайти их — одна команда:
grep -rn 'current_weight -= total' src/src/http/modules/ngx_http_upstream_hash_module.c:679
src/http/modules/ngx_http_upstream_least_conn_module.c:248
src/http/modules/ngx_http_upstream_least_time_module.c:326
src/http/ngx_http_upstream_round_robin.c:910
src/stream/ngx_stream_upstream_hash_module.c:653
src/stream/ngx_stream_upstream_least_conn_module.c:236
src/stream/ngx_stream_upstream_least_time_module.c:320
src/stream/ngx_stream_upstream_round_robin.c:781Один ствол, восемь копий
Модуль stream — это вся история заново, скопированная под TCP и UDP. Причём разошлась она дальше: в ngx_stream_upstream_round_robin.c блока sticky нет вовсе, а best->current_weight -= total стоит после rrp->tried[n] |= m (:781), тогда как в http — до присваивания rrp->current. Расхождение копий работает и в плюс: аномалии со sticky, о которой ниже, в stream просто нет.
Когда попытка сходить на бэкенд провалилась, ngx_http_upstream_free_round_robin_peer() делает следующее (:1063):
if (peer->max_fails) {
peer->effective_weight -= peer->weight / peer->max_fails;То есть max_fails управляет не только порогом отключения пира, но и шагом, с которым просаживается его вес.
При weight=10; max_fails=2 одна ошибка отнимает 5 — половину. При max_fails=10 та же ошибка отнимает 1. Один и тот же бэкенд с одинаковым weight получает разную долю трафика после сбоя — в зависимости от параметра, который ставили, думая только про порог.
И тут есть ловушка, которую видно, только если помнить, что деление целочисленное. Дефолтный weight — единица (src/http/ngx_http_upstream.c:6493):
конфиг |
| штраф |
|---|---|---|
| 1 / 1 | 1 — вес обнуляется |
| 1 / 2 | 0 — не происходит ничего |
| 1 / 3 | |
| 3 / 5 | |
| 10 / 2 | 5 |
То есть типовой тюнинг «поставлю max_fails побольше, чтобы бэкенд не выпадал от одного чиха» на апстриме без явных весов — а это большинство апстримов — не смягчает просадку веса, а выключает её целиком. Тот же параметр, тот же интент, а второй его эффект молча схлопывается в ноль.
(При max_fails=0 не работает ни порог, ни просадка: весь блок стоит под if (peer->max_fails).)
Вес не уходит в минус (:1076):
if (peer->effective_weight < 0) {
peer->effective_weight = 0;
}А восстанавливается тем самым effective_weight++ из цикла выбора — по единице за проход, пока не дорастёт до weight. Никакого таймера: восстановление привязано к трафику.
Здесь важен порядок внутри итерации. Инкремент стоит до того, как запрос уйдёт на бэкенд и упадёт. Поэтому две ошибки подряд не обнуляют вес: между ними успевает пройти один инкремент.
Прогоним конфиг из начала статьи — weight=10 max_fails=2 против weight=1:
eff current_weight
sel=a [ 5, 1] [-1, 1] <-- отказ #1, eff: 10 - 5
sel=a [ 1, 1] [-2, 2] <-- отказ #2, eff: 6 - 5
fails=2, пир выведен на fail_timeout
--- ночь: a пропускается, eff не растёт, cw у b возвращается в 2 ---
sel=b [ 2, 1] [-1, 1] <-- утро, fail_timeout давно истёк
sel=b [ 3, 1] [ 1, -1]
sel=a [ 4, 1] [ 0, 0]
sel=a [ 5, 1] [-1, 1]
sel=a [ 6, 1] [-2, 2]
...
sel=a [10, 1] вес вернулся, 9-й утренний проходРазделитель посередине существенен. Пока fails >= max_fails и fail_timeout не истёк, цикл выбора пропускает пира целиком (:873) — а вместе с пропуском не выполняется и effective_weight++. Именно поэтому просевший вес переживает ночь: восстанавливать его нечем, пока пир выведен.
10 → 5 → 6 → 1. Ноль достижим, только если оба запроса были в полёте одновременно — оба выбрали a, пока eff ещё равнялся десяти, и оба упали. При конкурентной нагрузке это реально, но это уже другой сценарий.
И вот вторая половина ответа, ради которой стоило запомнить отрицательные счётчики. К моменту второго отказа current_weight у a равен -2. Пир не просто ослаблен до веса единица — он ещё и должен. Поэтому в первых двух утренних проходах побеждает b. Два запроса подряд на бэкенд с весом единица, при живом и здоровом соседе с весом десять. В production-сборке об этом не скажет ничего: строка upstream server temporarily disabled пишется только при достижении порога.
Зато на сборке с --with-debug весь механизм видно живьём. Обе величины логируются (:1072 и :753):
ngx_log_debug2(NGX_LOG_DEBUG_HTTP, pc->log, 0,
"free rr peer failed: %p %i",
peer, peer->effective_weight);
ngx_log_debug2(NGX_LOG_DEBUG_HTTP, pc->log, 0,
"get rr peer, current: %p %i",
peer, peer->current_weight);При error_log /var/log/nginx/debug.log debug; всё, о чём написано выше, проверяется двумя грепами:
grep 'get rr peer, current' debug.log # current_weight на каждом выборе
grep 'free rr peer failed' debug.log # effective_weight после каждой просадкиВес возвращается быстро, пропорция — нетВес восстанавливается за девять утренних вызовов балансировщика. Пропорция — нет, потому что current_weight остаётся сдвинутым по фазе уже после того, как effective_weight дорос до десяти:
первых утренних запросов | a : b | было бы при 10:1 |
|---|---|---|
11 | 8 : 3 | 10 : 1 |
20 | 16 : 4 | 18 : 2 |
50 | 44 : 6 | 45.5 : 4.5 |
100 | 89 : 11 | 91 : 9 |
200 | 180 : 20 | 182 : 18 |
Вот те самые «первые полсотни запросов» из начала статьи: b получает шесть вместо четырёх с половиной — на треть больше положенного.
И дальше начинается интересное. Сдвиг фазы current_weight даёт постоянное абсолютное смещение, которое не рассасывается вообще:
запросов | b фактически | b по 10:1 | избыток |
|---|---|---|---|
200 | 20 | 18.2 | +1.8 (10%) |
500 | 47 | 45.5 | +1.5 (3.4%) |
1000 | 93 | 90.9 | +2.1 (2.3%) |
5000 | 456 | 454.5 | +1.5 (0.3%) |
20000 | 1820 | 1818.2 | +1.8 (0.1%) |
Лишние примерно два запроса у b остаются навсегда. Убывает только относительная ошибка, как 1/N: ниже процента она падает где-то к пяти тысячам запросов.
Восстановление веса и восстановление пропорции — разные события. Первое занимает девять вызовов, второе не завершается никогда.
least_conn сравнивает отношение, а не количествоНазвание говорит «наименьшее число соединений». Код говорит другое (src/http/modules/ngx_http_upstream_least_conn_module.c:181):
if (best == NULL
|| peer->conns * best->weight < best->conns * peer->weight)
{
best = peer;
many = 0;
p = i;
} else if (peer->conns * best->weight == best->conns * peer->weight) {
many = 1;
}Перекрёстное умножение — это сравнение дробей conns/weight без деления. Метод выбирает не пир с наименьшим числом соединений, а пир с наименьшей загрузкой относительно своего веса. Бэкенд с weight=10 и 20 соединениями считается менее загруженным, чем бэкенд с weight=1 и тремя: 20/10 < 3/1.
Флаг many взводится, когда несколько пиров дали одинаковое отношение. Дальше идёт знакомая арифметика — но не дословно та же (:200):
if (many) {
for (peer = best, i = p;
peer;
peer = peer->next, i++)
{
/* ... те же проверки down / max_fails / max_conns ... */
if (peer->conns * best->weight != best->conns * peer->weight) {
continue;
}
peer->current_weight += peer->effective_weight;
total += peer->effective_weight;
if (peer->effective_weight < peer->weight) {
peer->effective_weight++;
}
if (peer->current_weight > best->current_weight) {Четыре отличия от оригинала, и все содержательные.
Цикл стартует с best, а не с головы списка. Пиры, стоящие в списке раньше найденного best, не участвуют ни в total, ни в накоплении current_weight — даже если они в ничьей по conns/weight. То есть при равенстве отношений выбор идёт не среди всех, а среди хвоста списка.
Появилась проверка, которой в оригинале нет (:219): все, кто не в ничьей с best, отсеиваются. Вместе со стартом цикла это и делает отбор: кандидаты в ничьей до best исключены стартовой точкой, не в ничьей после — вот этой строкой.
Условие победы без best == NULL — потому что best на этот момент гарантированно найден.
А вычитание выполняется всегда (:248):
best->current_weight -= total;Оно стоит за пределами if (many). Когда ничьей не было, total равен нулю, и строка не делает ничего. То есть на обычном пути least_conn вообще не трогает весовые счётчики — они живут только на ветке ничьей.
least_time устроен идентично: тот же старт с best (least_time_module.c:280), то же вычитание после блока (:326).
Отдельный случай — единственный пир. Тогда least_conn даже не начинает работать (:114):
if (rrp->peers->single) {
return ngx_http_upstream_get_round_robin_peer(pc, rrp);
}Копия в consistent hash — и что она разруливаетОсторожно: hash_module.c содержит две независимые реализации. Обычный hash $key; — это ngx_http_upstream_get_hash_peer() (:168), и весовой арифметики там нет вовсе. Всё, что ниже, — про hash $key consistent;, то есть ngx_http_upstream_get_chash_peer() (:566).
Там арифметика лежит внутри for(;;) и заканчивается прыжком (:678):
if (best) {
best->current_weight -= total;
goto found;
}Но интереснее фильтр перед ней (:658):
if (peer->server.len != server->len
|| ngx_strncmp(peer->server.data, server->data, server->len)
!= 0)Сравниваются не хэши, а имена серверов. И это логично именно для consistent hash: точка на кольце принадлежит имени из директивы server, а не конкретному адресу. Если доменное имя развернулось в несколько A-записей, за одной точкой кольца стоит несколько пиров — и вот между ними выбирает весовая арифметика.
Раз уж мы открыли обычный hash, посмотрим, как он вообще учитывает веса (:237):
w = hp->hash % hp->rrp.peers->total_weight;
peer = hp->rrp.peers->peer;
p = 0;
while (w >= peer->weight) {
w -= peer->weight;
peer = peer->next;
p++;
}Запомните этот цикл — он встретится ещё дважды.
Это второй общий примитив nginx: остаток от деления на сумму весов плюс проход по кумулятивным диапазонам. Он решает ту задачу, для которой smooth WRR не годится: превратить готовое число — хэш, случайный бросок — в пира, соблюдая веса.
Мест его обитания три:
где | как |
|---|---|
| по хэшу ключа, линейный проход |
| по хэшу адреса, тот же цикл дословно |
| таблица диапазонов строится заранее, обход бинарным поиском |
Оба примитива решают одну задачу — раздать запросы пропорционально весам. Разница между ними в одном: есть ли память между запросами. У smooth WRR она есть, счётчики переживают выбор; у прохода по диапазонам её нет, каждое решение считается с нуля.
И вот следствие, которое стоит унести с собой. Проход по диапазонам ходит по peer->weight, а peers->total_weight считается один раз при загрузке конфига (round_robin.c:159). Ни effective_weight, ни current_weight в нём не участвуют вообще.
Значит, всё, что рассказано выше про тихую просадку веса, действует только на семействе smooth WRR. У hash, ip_hash и random деградации нет: бэкенд держит полную долю кольца до тех пор, пока не наберёт max_fails, и в этот момент выпадает целиком. Плавно против ступенькой — и выбирать между этими двумя режимами приходится, выбирая метод балансировки.
ip_hash объясняют как «запросы с одного IP идут на один бэкенд». Код уточняет, что считается «одним IP» (src/http/modules/ngx_http_upstream_ip_hash_module.c:121):
case AF_INET:
sin = (struct sockaddr_in *) r->connection->sockaddr;
iphp->addr = (u_char *) &sin->sin_addr.s_addr;
iphp->addrlen = 3;
break;
#if (NGX_HAVE_INET6)
case AF_INET6:
sin6 = (struct sockaddr_in6 *) r->connection->sockaddr;
iphp->addr = (u_char *) &sin6->sin6_addr.s6_addr;
iphp->addrlen = 16;
break;
#endifip_hash: три октета IPv4 против шестнадцати байт IPv6
Для IPv4 берутся три октета из четырёх: вся подсеть /24 — один ключ. Сделано ради клиентов за пулом NAT с меняющимся последним октетом, но цена очевидна: один офис, один провайдер с плотной адресацией — и балансировка для них выключена.
Для IPv6 берутся все 16 байт. На одном апстриме гранулярность зависит от того, по какому протоколу пришёл клиент.
Есть и третья ветка, про которую почти не пишут (:135):
default:
iphp->addr = ngx_http_upstream_ip_hash_pseudo_addr;
iphp->addrlen = 3;Всё, что не IPv4 и не IPv6 — прежде всего unix-сокеты — получает общий псевдоадрес. Все такие клиенты схлопываются в один ключ и едут на один бэкенд. Это уже не «гранулярность /24», а полное отключение балансировки.
Экзотикой это не назовёшь: двухуровневая схема, где фронтовый nginx ходит в бэковый через proxy_pass http://unix:/var/run/app.sock, и часть ingress-конфигураций попадают сюда целиком.
Хэш накапливается по байтам адреса (:200), стартуя с сида 89 (:140):
hash = (hash * 113 + iphp->addr[i]) % 6271;Он не сбрасывается между попытками внутри for(;;): на ретрае адрес прогоняется по уже сдвинутому значению, поэтому следующий пир выбирается детерминированно, но неочевидно.
И главное для нашего тезиса (:203):
w = hash % iphp->rrp.peers->total_weight;
peer = iphp->rrp.peers->peer;
p = 0;
while (w >= peer->weight) {
w -= peer->weight;
peer = peer->next;
p++;
}Хэш берётся по модулю суммы весов, а не числа пиров, и дальше идёт проход по кумулятивным диапазонам. ip_hash учитывает веса — веса пролезли даже в хэш-балансировщик.
Наконец, ip_hash не заменяет round-robin, а держит его наготове (:142):
iphp->get_rr_peer = ngx_http_upstream_get_round_robin_peer;Условия отката — те же самые, что мы увидим у random, дословно (:166):
if (iphp->tries > 20 || iphp->rrp.peers->number < 2) {
ngx_http_upstream_rr_peers_unlock(iphp->rrp.peers);
return iphp->get_rr_peer(pc, &iphp->rrp);
}Та же двадцатка попыток, то же number < 2, а ниже — та же проверка поколения конфигурации под #if (NGX_HTTP_UPSTREAM_ZONE). Ещё одна копипаста, на этот раз условий отката. Залипание на бэкенд — предпочтение, а не гарантия.
random — единственный метод, который считает по-своему. current_weight и effective_weight в модуле не встречаются вообще.
При загрузке конфига строится таблица кумулятивных диапазонов (src/http/modules/ngx_http_upstream_random_module.c:149):
for (peer = peers->peer, i = 0; peer; peer = peer->next, i++) {
ranges[i].peer = peer;
ranges[i].range = total_weight;
total_weight += peer->weight;
}А выбор — бросок и поиск по диапазонам (:478):
x = ngx_random() % peers->total_weight;Разница принципиальная. У smooth WRR есть память: счётчики переживают запрос, последовательность детерминирована. У random памяти нет — каждый бросок независим, соотношение весов соблюдается только статистически.
Но и здесь ствол никуда не делся (:356):
if (rp->tries > 20 || peers->number < 2) {
ngx_http_upstream_rr_peers_unlock(peers);
return ngx_http_upstream_get_round_robin_peer(pc, rrp);
}Обратите внимание: peers->number — это число сконфигурированных пиров (round_robin.c:157 для блока upstream {}), а не живых. Апстрим из пяти серверов, четыре из которых лежат, в этот откат не попадёт.
Второе условие отката существует только когда апстрим объявлен с zone (:242 в ветке random, :362 в random two):
#if (NGX_HTTP_UPSTREAM_ZONE)
if (peers->config && rrp->config != *peers->config) {Срабатывает при смене поколения конфигурации: набор пиров переразрешил резолвер, и таблица ranges, построенная на старте, больше не соответствует total_weight. Предвычисленная таблица умеет протухать — и запасной путь на этот случай, разумеется, round-robin.
Хотя и не везде. ip_hash на той же проверке уходит в round-robin (:171), а least_conn — в goto busy (:128), то есть отдаёт NGX_BUSY и 502. Одна и та же проверка, скопированная в три модуля, в одном из них ведёт не туда, куда в остальных.
Всего откатов в модуле шесть, по три на каждую из двух веток — random и random two.
А в самой ветке random two лежит ещё одно заимствование — на этот раз другого фрагмента (:423):
if (prev) {
if (peer->conns * prev->weight > prev->conns * peer->weight) {Это перекрёстное умножение из least_conn, скопированное сюда. random two бросает кубик дважды и выбирает менее загруженного из двух — по тому же отношению conns/weight. То есть даже единственное исключение наполовину состоит из заимствований.
Вернёмся к пропущенному блоку. Он начинается не там, где я его обрезал, а на пятнадцать строк выше цикла (round_robin.c:836):
st_peer = ngx_http_upstream_get_rr_peer_by_sid(rrp, pc->hint, &p, 0);
if (st_peer) {
low_limit = -((ngx_int_t)(rrp->peers->total_weight - st_peer->weight));
/*
* note: current code accounts only one sticky request in a row, if it
* is required to account more, multiply low_limit by N below
*/
if (st_peer->current_weight <= low_limit) {
/* do not update weights if the limit exceeded */
best = st_peer;
goto best_chosen;
}
/* else: proceed to reweight with existing st_peer */
}И только после этого — цикл, а за ним подмена (:897):
#if (NGX_HTTP_UPSTREAM_SID)
/* prefer peer chosen by sticky to best from RR */
if (st_peer) {
best = st_peer;
p = st_p;
}
#endif
best->current_weight -= total;Механика получается такая. Пир, найденный по sid, участвует в общем цикле наравне со всеми: ему тоже начисляется current_weight += effective_weight. Он просто не обязан выиграть. Если не выиграл — его всё равно подставят вместо победителя, и вычитание суммарного веса достанется ему. То есть за право залипнуть на бэкенд платят из его весового счётчика.
Но долг ограничен. Как только current_weight sticky-пира упирается в -(total_weight - weight) — ровно один круг раздачи, — срабатывает goto best_chosen, который перепрыгивает и цикл, и вычитание на :910. Дальше sticky-пир из весового учёта выпадает совсем: счётчики больше не трогаются.
Комментарий авторов объясняет замысел прямо: «accounts only one sticky request in a row». Это не аномалия, а компромисс с явным потолком — sticky отдаёт round-robin ровно один раунд и дальше работает бесплатно.
Что при этом остаётся верным: настоящий победитель RR вычитания не получает. Его накопленный current_weight переживает раунд, и следующий выбор он почти наверняка возьмёт.
Подставить мёртвый бэкенд sticky не может — ngx_http_upstream_get_rr_peer_by_sid() прогоняет кандидата через те же фильтры, что и цикл: tried, down, max_fails/fail_timeout, max_conns (:965-988).
А теперь то, ради чего сюда стоило лезть. Sticky ведёт себя по-разному в трёх копиях:
копия | поведение |
|---|---|
| клапан по |
|
|
|
|
Одна фича, три разных ответа на вопрос «а что делать с весами». Копии разошлись не только в арифметике.
Что из этого следуетmax_fails — это два параметра в одном. Порог отключения и шаг просадки веса. Меняя его «чтобы бэкенд не выпадал так быстро», вы одновременно меняете, насколько он просядет по трафику после ошибки.
После сбоя бэкенд не только ослаблен, но и должен. Отрицательный current_weight заставляет его пропустить несколько раундов сверх того, что следует из просевшего веса. Это и объясняет два запроса подряд на b из начала статьи.
Восстановление привязано к трафику, а не ко времени. Девять вызовов балансировщика на возврат веса с единицы до десяти. На нагруженном апстриме — доли секунды, на тихом внутреннем сервисе — минуты.
least_conn — это least loaded. Сравнивается conns/weight.
ip_hash на IPv4 работает по подсетям /24, а на unix-сокетах не работает вовсе.
random не всегда random. Меньше двух сконфигурированных бэкендов, больше двадцати попыток, смена поколения конфигурации при zone — и выбор уходит в round-robin.
Хотите, чтобы просадка веса работала — задавайте weight явно. При дефолтном весе единица любой max_fails больше единицы выключает её целиком: 1 / 2 == 0.
Нужна ступенчатая деградация вместо плавной — берите hash, ip_hash или random. Они не читают effective_weight вовсе: бэкенд держит полную долю до max_fails, потом выпадает разом. Плавную даёт только семейство smooth WRR.
На тихом апстриме ночной сбой доживает до утра. Восстановление привязано к трафику, а не к таймеру, и zone этого не чинит — он лишь делает состояние общим для воркеров, то есть предсказуемым.
Проверить всё это у себя можно за десять минут — сборка с --with-debug, error_log ... debug;, и оба счётчика видно в логе. Готовый стенд — в разделе выше.
Весь разбор — про open source nginx. Без директивы zone состояние пиров (conns, fails, effective_weight, current_weight) живёт в памяти воркера, и каждый воркер балансирует независимо: max_fails и max_conns фактически умножаются на число воркеров. С zone состояние переезжает в разделяемую память и становится общим — и заодно включается тот самый механизм поколений конфигурации.
Активных health-check в open source нет: пир помечается неудачным только по факту реального запроса, ушедшего в ошибку. Бэкенд, стабильно отдающий 500-е, при дефолтном proxy_next_upstream error timeout вообще не будет считаться неудачным.
В форках — Angie, Tengine, OpenResty — многое из этого переписано; проверяйте по их исходникам.
Проверка на живом nginxВсё вышеописанное получено чтением кода. Проверим на стенде — он собирается за пять минут.
git clone https://github.com/nginx/nginx && cd nginx
git checkout release-1.31.3
auto/configure --prefix=/tmp/ngx --with-debug \
--without-http_rewrite_module --without-http_gzip_module
make -j4 && make installДва бэкенда на 9001 и 9002 — подойдёт любой python3 -m http.server. Конфиг ровно тот, с которого начиналась статья:
worker_processes 1;
error_log /tmp/ngx/logs/debug.log debug;
http {
upstream backend {
server 127.0.0.1:9001 weight=10 max_fails=2 fail_timeout=3s;
server 127.0.0.1:9002 weight=1;
}
log_format up '$upstream_addr $upstream_status';
server {
listen 8080;
access_log /tmp/ngx/logs/up.log up;
location / { proxy_pass http://backend; }
}
}worker_processes 1 здесь принципиален — иначе состояние размажется по воркерам, как описано ниже в разделе про zone.
Ночь. Гасим a, делаем два запроса:
127.0.0.1:9001, 127.0.0.1:9002 502, 200
127.0.0.1:9001, 127.0.0.1:9002 502, 200Оба ушли на a, оба упали, b подхватил. В debug-логе — просадка веса:
free rr peer failed: 00005C77AF816B00 5
free rr peer failed: 00005C77AF816B00 1Второе число — это effective_weight. 10 → 5, затем → 1. Не ноль, ровно как в трассе выше.
Утро. Поднимаем a, ждём fail_timeout, гоним 200 запросов и считаем по $upstream_addr:
запросов | a : b (стенд) | a : b (симулятор) |
|---|---|---|
11 | 8 : 3 | 8 : 3 |
20 | 16 : 4 | 16 : 4 |
50 | 44 : 6 | 44 : 6 |
100 | 89 : 11 | 89 : 11 |
200 | 180 : 20 | 180 : 20 |
Совпадение точное во всех пяти точках. Те самые «первые полсотни запросов» из первого абзаца — 44 : 6 вместо 45.5 : 4.5.
А current_weight победителя видно прямо в логе:
get rr peer, current: 00005C77AF816B00 4
get rr peer, current: 00005C77AF816B00 3
get rr peer, current: 00005C77AF816B00 2
get rr peer, current: 00005C77AF816B00 1
get rr peer, current: 00005C77AF816B00 0
get rr peer, current: 00005C77AF816B00 -1Счётчик убывает на единицу за раунд и уходит в минус — тот самый долг.
Одна честная оговорка: пофазно стенд и симулятор расходятся на одну позицию (b b a a a a a b a… против b b a a a a a a b…). Причина — ретрай: неудачный запрос вызывает балансировщик дважды, и модель этого не учитывает. На агрегаты это не влияет.
Модель, по которой считались трассы, — тридцать строк, повторяющих арифметику из ngx_http_upstream_get_peer() и free_round_robin_peer(). Подставьте свои веса и сверьтесь со стендом:
W, MAX_FAILS = [10, 1], [2, 0] # weight и max_fails по пирам
eff, cur = W[:], [0] * len(W)
def select(fail=False):
total, best = 0, None
for i in range(len(W)):
cur[i] += eff[i]
total += eff[i]
if eff[i] < W[i]:
eff[i] += 1
if best is None or cur[i] > cur[best]:
best = i
cur[best] -= total
if fail and MAX_FAILS[best]:
eff[best] = max(0, eff[best] - W[best] // MAX_FAILS[best])
return best
select(fail=True); select(fail=True) # две ночные ошибки
counts = [0] * len(W)
for n in range(1, 201):
counts[select()] += 1
if n in (11, 20, 50, 100, 200):
print(n, counts, 'eff =', eff)Оговорка: это модель одного воркера, последовательных запросов и без ретраев. Конкурентность, fail_timeout и разделяемое состояние при zone она не воспроизводит — что и даёт расхождение на одну позицию, замеченное на стенде.
По коду есть куда идти: ngx_http_upstream_t и его колбэки, из-за которых proxy_pass и fastcgi_pass — один модуль с разными обработчиками; и ngx_http_core_content_phase(), где решается, будет ли nginx в этом запросе веб-сервером или прокси. Про фазы обработки на Хабре уже есть отличный разбор — «Nginx. О чем не пишут в книгах» — но связки «директива проставила указатель → движок фаз по нему диспатчится» там нет. Это тема следующей части.
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | proxy_pass и fastcgi_pass — одна машина | 0 | 8.53 | 06-08-2026 |
| 2 | Как Яндекс Диск выдерживает сотни гигабит входящего трафика: устройство балансировки загрузок | 1 | 8.04 | 01-06-2026 |
| 3 | Как я придумывал замену Redis и что из этого получилось | 1 | 7 | 05-08-2026 |
| 4 | ggrebalance: Часть 2. Планирование операции ребаланса | 0 | 7 | 17-07-2026 |
| 5 | Добавили потоков, стало медленнее: разбираемся с ложным разделением кеш‑линии | 0 | 9.26 | 10-08-2026 |
| 6 | Приложение течёт, а утечки нет: как оптимизатор картинок Next.js съел 4-гигабайтный VPS | 0 | 6.97 | 13-08-2026 |
| 7 | Мажорное обновление Greengage с помощью pg_upgrade и ggupgrade | 5 | 7 | 26-06-2026 |
| 8 | Apache AGE под нагрузкой: что происходит, когда графы внутри PostgreSQL начинают по-настоящему тестировать | 0 | 11.67 | 24-03-2026 |
| 9 | Вторая копия Vue: как лишняя строка в lockfile повесила Chromium | 0 | 6.77 | 09-08-2026 |