Резервирование и балансировка (Nginx)

ESIA-Adapter : 3.2. Резервирование и балансировка (Nginx)


Вариант балансировки двух идентичных узлов ESIA Adapter на Nginx. Принцип тот же, что для HAProxy: запросы одного входа и выданные узлом токены направляются на тот же узел, а узел определяется по cookie и по значению nid в токенах. Параметр NODE_ID, состояние входа на узле и настройка самих узлов описаны в разделе «Резервирование и балансировка (HAProxy)» и здесь не повторяются.

Возможности Nginx

Nginx в открытой редакции не разбирает JWT, не выдаёт cookie привязки к серверу и не выполняет активные проверки состояния. Поэтому в конфигурации:

  • узел из токена определяется скриптом njs (модуль ngx_http_js_module);
  • cookie ESIA_NODE выдаётся именованным location выбранного узла;
  • отказ узла обнаруживается пассивно (max_fails, fail_timeout), а резервным сервером группы служит второй узел.

Модуль njs входит в состав сборок nginx.org (пакет nginx-module-njs) и подключается директивой load_module.

Определение узла

ЗапросОткуда определяется узел
/oauth2/authorize, /extidp/esia, /extidp/esia/callback, /oauth2/logout (от браузера)cookie ESIA_NODE
/oauth2/token, обмен кода (code) или обновление (refresh_token)claim nid токена в теле запроса
/oauth2/userinfoclaim nid токена доступа в заголовке Authorization
/oauth2/logout с id_token_hintclaim nid в параметре id_token_hint
Новый вход без cookie и токенаслучайный выбор узла, 50% на каждый

Приоритет: токен, затем cookie, затем выбор для нового входа.

Скрипт njs

Файл /etc/nginx/njs/node.js. Имена узлов в массиве NODES совпадают со значениями NODE_ID.

// Определение узла: токен (Authorization, тело запроса, id_token_hint) -> cookie ESIA_NODE -> новый вход
var NODES = ['node-a', 'node-b'];

function nidFromJwt(token) {
    var parts = token.split('.');
    if (parts.length < 2) { return ''; }
    try {
        return JSON.parse(Buffer.from(parts[1], 'base64url').toString()).nid || '';
    } catch (e) {
        return '';
    }
}

function tokenOf(r) {
    var auth = r.headersIn['Authorization'];
    if (auth && /^Bearer\s+/i.test(auth)) {
        return auth.replace(/^Bearer\s+/i, '');
    }
    if (r.uri === '/oauth2/token') {
        var m = /(?:^|&)(?:code|refresh_token)=([^&]+)/.exec(r.requestText || '');
        if (m) { return decodeURIComponent(m[1]); }
    }
    if (r.uri === '/oauth2/logout' && r.args.id_token_hint) {
        return r.args.id_token_hint;
    }
    return '';
}

function route(r) {
    var node = nidFromJwt(tokenOf(r));
    if (NODES.indexOf(node) < 0) { node = r.variables.cookie_ESIA_NODE; }
    if (NODES.indexOf(node) < 0) { node = r.variables.new_node; }
    r.internalRedirect('@' + node);
}

export default { route };

Скрипт читает тело запроса /oauth2/token и после выбора узла выполняет внутренний переход на именованный location узла, при этом тело передаётся на узел без изменений. Подпись токена скрипт не проверяет, её проверяет адаптер.

Конфигурация Nginx

Файл /etc/nginx/nginx.conf:

load_module modules/ngx_http_js_module.so;

events {}

http {
    js_path /etc/nginx/njs;
    js_import node from node.js;

    # узел для нового входа (без cookie и токена)
    split_clients "${request_id}" $new_node {
        50%     node-a;
        *       node-b;
    }

    # в каждой группе первым идёт свой узел, вторым — резервный
    upstream node-a {
        server 10.0.0.11:4010 max_fails=2 fail_timeout=10s;
        server 10.0.0.12:4010 backup;
    }
    upstream node-b {
        server 10.0.0.12:4010 max_fails=2 fail_timeout=10s;
        server 10.0.0.11:4010 backup;
    }

    server {
        listen 443 ssl;
        ssl_certificate     /etc/nginx/certs/esia-adapter.crt;
        ssl_certificate_key /etc/nginx/certs/esia-adapter.key;

        client_body_buffer_size 64k;
        client_max_body_size 1m;

        location / {
            js_content node.route;
        }

        location @node-a {
            proxy_pass http://node-a;
            include /etc/nginx/esia-proxy.conf;
            add_header Set-Cookie "ESIA_NODE=node-a; Path=/; HttpOnly; Secure" always;
        }

        location @node-b {
            proxy_pass http://node-b;
            include /etc/nginx/esia-proxy.conf;
            add_header Set-Cookie "ESIA_NODE=node-b; Path=/; HttpOnly; Secure" always;
        }
    }
}

Файл /etc/nginx/esia-proxy.conf:

proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_next_upstream error timeout http_502 http_503 http_504;
proxy_connect_timeout 3s;
ЭлементНазначение
js_content node.routeвсе запросы проходят через скрипт, который выбирает узел и выполняет переход на @node-a или @node-b
split_clientsраспределяет новые входы между узлами
upstream node-a, upstream node-bгруппа с основным сервером и вторым узлом в роли backup: при отказе основного запросы уходят на второй
max_fails, fail_timeoutсервер считается недоступным после двух неудачных обращений на 10 секунд
add_header Set-Cookie ... ESIA_NODEзакрепляет браузерный вход за узлом
client_body_buffer_sizeтело запроса обмена токена должно помещаться в буфер, чтобы скрипт мог его прочитать
proxy_next_upstreamусловия передачи запроса на резервный сервер

Поведение при отказе узла

  • Запросы, адресованные недоступному узлу, передаются на второй узел группы. Состояние входа, сессии и данные пользователя находятся в памяти отказавшего узла, поэтому пользователю, находившемуся в процессе входа, требуется повторная аутентификация; последствия для токенов описаны в разделе про HAProxy.
  • Отказ обнаруживается пассивно: запрос, пришедший к недоступному узлу, повторяется на втором узле при ошибке соединения или таймауте. Запрос POST повторяется только если ошибка произошла до его передачи на узел.
  • Узел возвращается в работу после истечения fail_timeout, когда очередной запрос к нему завершается успешно.
  • Проверка /health/ready балансировщиком в открытой редакции Nginx не выполняется; активные проверки (health_check) доступны в NGINX Plus и контролируются мониторингом.

Плановое обслуживание

Чтобы вывести узел из приёма новых входов, в split_clients заменяют блок на * node-b; и выполняют перезагрузку конфигурации:

sudo nginx -t && sudo nginx -s reload

Запросы уже начатых входов и выданных токенов продолжают направляться на узел node-a по cookie и nid. После истечения срока жизни сессий и токенов (SESSION_EXPIRED, TOKEN_TTL) узел останавливается и обновляется. Затем блок split_clients возвращается к исходному виду.

Проверка

  1. Выполнить nginx -t: конфигурация и модуль njs загружаются без ошибок.
  2. Выполнить запрос /oauth2/userinfo с токеном, выданным узлом node-a: ответ приходит от node-a; токен узла node-b направляется на node-b.
  3. Повторить для обмена кода и токена обновления (POST /oauth2/token) и для /oauth2/logout?id_token_hint=....
  4. Выполнить GET /oauth2/authorize без cookie: ответ содержит Set-Cookie: ESIA_NODE=..., повторный запрос с этим cookie попадает на тот же узел.
  5. Остановить node-a: запросы с его токеном и cookie обслуживаются узлом node-b; после запуска node-a и истечения fail_timeout запросы возвращаются на него.

Обсуждение