Nginx + Lua, гибкая балансировка нагрузки с сохранением сессии


    При балансировке нагрузки важный вопрос — сохранение сессии клиента. Особенно, если за балансировщиком стоит какой-то интерактивный backend. И тем более, если захотелось сделать A/B тестирование и гибко регулировать порции клиентов к различному содержанию. "Nginx plus" предлагает такие возможности, но что делать, если хочется дёшево и быстро?


    На помощь приходит возможность расширить функционал Nginx с помощью Lua.



    Алгоритм прост. При первом запросе клиента, выставляем ему cookie, а при последующих в зависимости от значения, отправляем к конкретному бэкенду. Сами же куки распределяем подходящим алгоритмом с анализом нужных параметров.


    В качестве мощного nginx-комбайна можно использовать сборку OpenResty, но для наших нужд это избыточно, потому соберём только нужный функционал на базе nginx 1.10.3 из репозитория.


    Подопытным у нас будет:


    Debian jessie 4.9.0-0.bpo.1-amd64
    Nginx 1.10.3 (nginx.org)
    libluajit-5.1-2

    Необходимые компоненты сборки:


    ngx_devel_kit-0.3.0
    lua-nginx-module-0.10.8
    lua-resty-core-0.1.11
    lua-resty-lrucache-0.06

    Устанавливаем пакеты для сборки deb-пакета:


    # cd /usr/src/
    # aptitude install quilt debhelper libluajit-5.1-dev libluajit-5.1-2
    # apt-get -t jessie source nginx

    Последняя команда скачивает исходные коды nginx-а, из настроенного репозитория. Мы используем nginx: пакеты для Linux.


    Скачиваем и распаковываем текущие версии исходных кодов модулей: ngx_devel_kit и lua-nginx-module


    # wget https://github.com/simpl/ngx_devel_kit/archive/v0.3.0.tar.gz
    # wget https://github.com/openresty/lua-nginx-module/archive/v0.10.8.tar.gz
    # tar -xf v0.3.0.tar.gz
    # tar -xf v0.10.8.tar.gz

    Первый модуль необходим для сборки желанного второго.


    Правим файл правил сборки deb-пакета по адресу nginx-1.10.3/debian/rules, добавив в список параметров секции config.status.nginx: config.env.nginx:


    --add-module=/usr/src/ngx_devel_kit-0.3.0 --add-module=/usr/src/lua-nginx-module-0.10.8

    Собираем и устанавливаем получившийся пакет:


    # cd nginx-1.10.3 && dpkg-buildpackage -us -uc -b && cd ../
    # dpkg -i nginx_1.10.3-1~jessie_amd64.deb
    # aptitude hold nginx

    Последняя команда зафиксирует установленный пакет, чтобы избежать его случайного обновления.


    Кроме этого, нам потребуется ещё две lua-библиотеки из проекта OpenResty, предоставляющих Nginx API for Lua: lua-resty-core и lua-resty-lrucache. Они из себя представляют набор *.lua файлов, устанавливаемых (по умолчанию) по пути /usr/local/lib/lua/.


    # wget https://github.com/openresty/lua-resty-core/archive/v0.1.11.tar.gz
    # wget https://github.com/openresty/lua-resty-lrucache/archive/v0.06.tar.gz
    # tar -xf v0.1.11.tar.gz 
    # tar -xf v0.06.tar.gz
    # cd lua-resty-core-0.1.11 && make install && cd ../
    # cd lua-resty-lrucache-0.06 && make install && cd ../

    Подготовительная часть завершена, приступаем к настройке nginx-а. Приведу упрощённую конфигурацию с комментариями происходящего.


    В нашем случае требовалось реализовать только варианты контента, потому и балансировщик и бэкенд будут на одном сервере и upstream будет указывать на локальные адреса с портами 800x.
    Но гибкость реализации позволяют построить любые желаемые конфигурации. Итак по порядку.


    В блоке http {} инициализируем lua.


    Код с комментариями:
    # путь до локально установленных *.lua библиотек с добавлением системных путей
    lua_package_path "/usr/local/lib/lua/?.lua;;";
    init_by_lua_block {
        -- подключение основного модуля
        -- в принципе, этот блок можно опустить
        require "resty.core"
        collectgarbage("collect")  -- just to collect any garbage
    }

    в блоках *_lua_block уже идёт lua-код со своим синтаксисом и функциями.


    Основной сервер, который принимает на себя внешние запросы.


    Код с комментариями:
    server {
        listen 80;
        server_name test.domain.local;
    
      location / {
        # проверяем наличие cookie "upid" и если нет — выставляем по желаемому алгоритму
        if ($cookie_upid = "") {
                # инициализируем пустую переменную nginx-а, в которую запишем выбранный ID бэкенда
                set $upstream_id '';
                rewrite_by_lua_block {
                    -- инициализируем математический генератор для более рандомного рандома используя время nginx-а
                    math.randomseed(ngx.time())
                    -- также пропускаем первое значение, которое совсем не рандомное (см документацию)
                    math.random(100)
                    local num = math.random(100)
                    -- получив число, бесхитростно и в лоб реализуем веса 20% / 80%
                    if num > 20 then
                        ngx.var.upstream_id = 1
                        ngx.ctx.upid = ngx.var.upstream_id
                    else
                        ngx.var.upstream_id = 2
                        ngx.ctx.upid = ngx.var.upstream_id
                    end
                    -- ID запоминаем в переменной nginx-а "upstream_id" и в "upid" таблицы ngx.ctx модуля lua, которая используется для хранения значений в рамках одного запроса 
                }
        # отдаём клиенту куку "upid" со значением выбранного ID
        # время жизни явно не задаём, потому она будет действительна только на одну сессию (до закрытия браузера), что нас устраивает
        add_header Set-Cookie "upid=$upstream_id; Domain=$host; Path=/";
        }
    
        # если же кука у клиента уже есть, то запоминаем ID в ngx.ctx.upid текущего запроса
        if ($cookie_upid != "") {
            rewrite_by_lua_block {
                ngx.ctx.upid = ngx.var.cookie_upid
            }
        }
    
        # передаём обработку запроса на блок upstream-ов
        proxy_pass http://ab_test;
      }
    }

    Блок upstream, который используя lua заменяет встроенную логику nginx.


    Код с комментариями:
    upstream ab_test {
      # заглушка, чтобы nginx не ругался. В алгоритме не участвует
      server 127.0.0.1:8001;
    
        balancer_by_lua_block {
            local balancer = require "ngx.balancer"
    
            -- инициализируем локальные переменные
            -- port выбираем динамически, в зависимости от запомненного ID бэкенда
            local host = "127.0.0.1"
            local port = 8000 + ngx.ctx.upid
    
            -- задаём выбранный upstream и обрабатываем код возврата
            local ok, err = balancer.set_current_peer(host, port)
                if not ok then
                    ngx.log(ngx.ERR, "failed to set the current peer: ", err)
                    return ngx.exit(500)
                end
            -- в общем случае надо, конечно же, искать доступный бэкенд, но нам не к чему
        }
    }

    Ну и простой демонстрационный бэкенд, на который в итоге придут клиенты.


    код без комментариев:
    server {
      listen        127.0.0.1:8001;
      server_name   test.domain.local;
    
      location / {
        root                /var/www/html;
        index               index.html;
      }
    }
    
    server {
      listen        127.0.0.1:8002;
      server_name   test.domain.local;
    
      location / {
        root                /var/www/html;
        index               index2.html;
      }
    }

    При запуске nginx-a с этой конфигурацией в логи свалится предупреждение:


    use of lua-resty-core with LuaJIT 2.0 is not recommended; use LuaJIT 2.1+ instead while connecting to upstream

    Которое можно убрать собрав и установив требуемую версию. Но и на 2.0 (libluajit-5.1-2) работает.
    Теперь, используя браузер с инструментами разработчика, можем проверять работу сервера и выставляемые куки.


    Таким образом мы получили необходимую для тестирования и статистики гибкость. И необходимое для правильной работы бэкенда сохранение сессии клиента. Ну и просто интересный опыт.


    PS Подобные задачи можно решить и другими методами, например используя haproxy, который позволяет балансировать с учётом сессий. Или для разделения клиентов использовать ngx_http_split_clients_module и с помощью map сопоставлять одни значения в зависимости от других.
    Но приведённый вариант распределения клиентов и выбора бэкенда позволяет гибче настраивать систему. И при необходимости, добавлять разнообразную логику в работу. При этом не перестраивая текущую систему.


    Спасибо за внимание.

    Комментарии 24

      +3
      После многолетних игр с PHP-сессиями пришёл к выводу, что при малейшем намёке на горизонтальное масштабирование апп(php-fpm)-серверов нужно делать их стейтлесс с разделяемыми всеми инстансами хранилищами данных, в том числе хранилищами сессий.
      Приклеенные сессии несут много проблем, например выбивая пользователей при падении ноды или при вводе новой ноды при росте нагрузки не снимают нагрузку со старых сразу, а только по мере окончания текущих сессий.

      Думаю, это справедливо и для других платформ. А вот для А/Б тестирования приклеивание варианта на веб-сервере очень хорошо работает, позволяя выкатить два приложения одновременно, а не писать приложение с двумя вариантами внутри.
        0
        Варианты падений, роста нагрузки, тормозов бэкенда и тп можно обработать. Lua+Nginx в этом плане довольно интересны и позволяют всякое. В статье я привёл совсем уж простой вариант, но возможности есть.
          0

          Я как раз про то, что пример приклеенных сессий не самый удачный для демонстрации.

            0
            Ну, я решал конкретную задачу и успешно, с минимальными изменениями системы в целом.
              0
              ip_hash решает не хуже, поверьте.
                0
                Использовал, хуже. К тому же он вычисляет хеш только по первым трём октетам и веса не работают.
                  0
                  Его достаточно для решения задачи, и он раз в 50 быстрее.
        +1
        Последнее время храню сессии в редиске. Работает шустро, и мне без разницы на какую ноду попадет пользователь
          0
          Такой подход предполагает поддержку со стороны приложения и идентичность на нодах, а это не всегда есть.
            0

            Не со стороны приложения, а со стороны бэкенда.

          0
          То, что вы показали как использовать Lua, это хорошо, спасибо.
          Но тут можно было обойтись чистым nginx'ом, на if'ах и несколько групп upstream'ов, cookie можно выставить непосредственно из приложения.

          Ну и в довершении покажу Вам вот это — ngx_stream_split_clients_module
            0
            Чистого Nginx-а не достаточно, и о split модуле я писал в конце статьи. К тому же он реализует не такой случайный разброс как хотелось бы. А чтобы выставлять нужные куки нужным клиентам бэкенд нужно соответственно модифицировать. И настроить эту часть без участия бэкенда гораздо удобнее.
              0

              обычно "липкие", они же sticky, сессии нужны только когда бэкенд знает зачем они ему, например, поднимает сессию на сервере приложений. А зачем бэкенду, которому в принципе все равно на пользователей, мапить юзеров на те же сервера?

                0
                Затем, что контент на разных бэкендах разный, и если у пользователя в процессе работы вдруг поменяется форма странички, он несколько удивится. Реализовывался вариант A/B тестирования с настраиваемыми весами по разным критериям и запоминанием привязки.
                  0

                  Может надо различать маппинг сессии на конкретный сервер и маппинг юзера на конкретную версию серверов? Хотя, конечно, второй случай может выродиться в первый.

                    0
                    Так было проще и быстрее, при этом бэкенд совсем не в курсе о происходящем, что очень удобно.
              0

              Можно ли этот модуль использовать в связке с mod_uid, чтобы назначать пользователями рандомные, но постоянные куки и по ним разделять? Проблема в том, что нужно использовать переменную одну из $uid_set или $uid_got — смотря какая заполнена.

                0
                Если вопрос к комментатору выше, про модуль split — то он просто задаёт соответствие входящим клиентам определённые значения, согласно желаемому распределению. Далее эти значения можно использовать как нужно.
                Но Lua в дополнение к имеющимся методам nginx-a добавляет возможность обработать запрос методами языка программирования. Те же $uid_set или $uid_got и прочие значения можно проанализировать и изменить и куки сопоставить.
                  0

                  Скорее не клиентам, а запросам. То есть после F5 клиент может попасть в другую группу?

                    0
                    При модуле split задаётся параметр по которому высчитывается хеш и от него разброс (например IP адрес). В таком раскладе, при правильных параметрах, клиента не должно кидать с сервера на сервер.
                    Но в нашем случае, этот вариант не был удобен, во первых из за параметров хеширования. А во вторых, из-за дальнейшей обработки сопоставленных переменных. Напрямую upstream не сопоставишь, нужно либо реврайт писать, либо на стороне бэкенда обрабатывать или ещё как.
                    А так получилась полная независимость от бэкенда и желаемое равномерное распределение, не зависимое от параметров клиента. Плюс к этому возможность в дальнейшем отфильтровать группы клиентов по каким-то признакам.
                      0

                      Если бы можно было передать по такому правилу:
                      uid_got or uid_set (если первый пустой)
                      то это бы решило проблему.
                      Пока да — приходится делать куку, придумывать промежуточный proxy_pass, и там распределять.

              0
              >>if num > 20 then

              Я б такое вынес в словарь на shmem. Это позволило бы менять настройку не перечитывая конфиг, и уж тем более не перезапуская nginx.
                0
                Можно. А можно и более интересный алгоритм организовать и выбирать из пула серверов и тп.
                  0
                  Что угодно, кроме хардкода.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое