В nginx один алгоритм балансировки

от автора

Внутренний сервис, два бэкенда, десять к одному:

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 — пять запросов подряд на один бэкенд. Нынешний алгоритм даёт другое:

Эволюция current_weight при весах 5-1-1

Эволюция 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: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):

конфиг

weight / max_fails

штраф

server a; (дефолт max_fails=1)

1 / 1

1 — вес обнуляется

server a max_fails=2;

1 / 2

0 — не происходит ничего

server a max_fails=3;

1 / 3

0

server a weight=3 max_fails=5;

3 / 5

0

server a weight=10 max_fails=2;

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 не годится: превратить готовое число — хэш, случайный бросок — в пира, соблюдая веса.

Мест его обитания три:

где

как

hash_module.c:237

по хэшу ключа, линейный проход

ip_hash_module.c:203

по хэшу адреса, тот же цикл дословно

random_module.c:149 и :478

таблица диапазонов строится заранее, обход бинарным поиском

Оба примитива решают одну задачу — раздать запросы пропорционально весам. Разница между ними в одном: есть ли память между запросами. У 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
ip_hash: три октета IPv4 против шестнадцати байт IPv6

ip_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

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 ведёт себя по-разному в трёх копиях:

копия

поведение

round_robin.c:836, :897

клапан по low_limit, иначе подмена best и вычитание

least_conn_module.c:142

goto best_chosen сразу — ни арифметики, ни клапана

stream/ngx_stream_upstream_round_robin.c

NGX_HTTP_UPSTREAM_SID не встречается ни разу

Одна фича, три разных ответа на вопрос «а что делать с весами». Копии разошлись не только в арифметике.

Что из этого следует

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/