Внутренний сервис, два бэкенда, десять к одному:
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 nginxgit 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-robin
ngx_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 — пять запросов подряд на один бэкенд. Нынешний алгоритм даёт другое:
То же соотношение 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:679src/http/modules/ngx_http_upstream_least_conn_module.c:248src/http/modules/ngx_http_upstream_least_time_module.c:326src/http/ngx_http_upstream_round_robin.c:910src/stream/ngx_stream_upstream_hash_module.c:653src/stream/ngx_stream_upstream_least_conn_module.c:236src/stream/ngx_stream_upstream_least_time_module.c:320src/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 просто нет.
effective_weight: вес, которого нет в документации
Когда попытка сходить на бэкенд провалилась, 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 |
0 |
|
|
3 / 5 |
0 |
|
|
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_weightsel=a [ 5, 1] [-1, 1] <-- отказ #1, eff: 10 - 5sel=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_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;#endif
Для 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
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. То есть даже единственное исключение наполовину состоит из заимствований.
Sticky платит долг round-robin — ровно один раз
Вернёмся к пропущенному блоку. Он начинается не там, где я его обрезал, а на пятнадцать строк выше цикла (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;, и оба счётчика видно в логе. Готовый стенд — в разделе выше.
Про NGINX Plus и zone
Весь разбор — про 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 nginxgit checkout release-1.31.3auto/configure --prefix=/tmp/ngx --with-debug \ --without-http_rewrite_module --without-http_gzip_modulemake -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, 200127.0.0.1:9001, 127.0.0.1:9002 502, 200
Оба ушли на a, оба упали, b подхватил. В debug-логе — просадка веса:
free rr peer failed: 00005C77AF816B00 5free 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 4get rr peer, current: 00005C77AF816B00 3get rr peer, current: 00005C77AF816B00 2get rr peer, current: 00005C77AF816B00 1get rr peer, current: 00005C77AF816B00 0get 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 bestselect(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. О чем не пишут в книгах» — но связки «директива проставила указатель → движок фаз по нему диспатчится» там нет. Это тема следующей части.
ссылка на оригинал статьи https://habr.com/ru/articles/1066752/