Балансировщик нагрузки Nginx с использованием Docker

Балансировщик нагрузки Nginx с использованием Docker

nginxloadbalancer

Итак, эта фотография наглядно демонстрирует то, чем nginx балансировщик нагрузки не является. Давайте сначала вкратце поговорим об общем понятии балансировки нагрузки, и их разновидностях в nginx. И, заодно, применим на практике знания о Docker, полученные ранее.

Балансировка нагрузки для многих приложений является широко используемой методикой оптимизации скорости отклика, доступности и распределения нагрузки сайта на несколько серверов. Балансировщики нагрузки управляют обменом данными между серверами и клиентами. Они оптимально распределяют трафик между различными серверами, при этом следя, чтобы ни один сервер не был перегружен работой. Это может быть как аппаратнное, так и программное решение.

structure_load_balancing

Nginx из коробки можно использовать как балансировщик нагрузки для распределения входящего трафика по серверам и передачи ответов от выбранного сервера клиенту. И nginx имеет некоторые преимущества по сравнению с другими балансировщиками нагрузки:

  • Он поддерживает несколько различных методов балансировки, которые отлично подходят для большинства случаев.
  • Поддерживает статическое и динамическое кэширование.
  • Каждый экземпляр Nginx полностью поддерживает запуск нескольких различных приложений.
  • Он может распределять трафик на основе: заголовков запроса, куки-файлов, аргументов и даже GET-параметров запроса.
  • Ограничение скорости (rate limiting), вес сервера (weight) и привязка сессий.

1_eaAaPaZB3cW3JUep6FFkFA

Nginx Open Source поддерживает четыре метода балансировки нагрузки, а Nginx Plus имеет в себе ещё два дополнительных.

  1. Round Robin: Это метод по умолчанию, если вы ничего не указываете. В этом методе запросы будут равномерно распределены между указанными бекенд-серверами. Или вы можете указать дополнительные веса серверов для приоретизации распределения в сторону каких-то серверов.
  2. Least Connections: В этом методе запрос посылается на сервер с наименьшим количеством активных соединений на данный момент. Вы также можете указать веса в этом методе.
  3. IP Hash: В других методах балансировки каждый запрос от клиентов может быть отправлен на случайный сервер. Ввиду этого, если вы используете сессии в своём приложении, вы не можете обеспечить персистентность данных. Потому как, пользователь открывает сессию, которая хранится на одном сервере. А в следующем запросе мы обращаемся уже к другому серверу, на котором нет той пользовательской сессии.

    Для решения этой проблемы у вас есть два варианта:
    • вы можете вынести хранилище сессий на отдельный сервер, с использованием Redis, например. Затем, необходимо настроить сессии приложения для работы с ним.
    • или же можете использовать метод IP хэша в Nginx.

В этом методе Nginx использует IP-адрес клиента в качестве ключа и посылает запросы, поступающие с одного и того же IP на один и тот же сервер. Это гарантирует, что клиент всегда будет перенаправлен на один и тот же сервер, где будет доступна его сессия.

  1. Generic Hash: В этом методе вы можете использовать свои собственные переменные, чтобы указать, на какой сервер будут отправляться запросы. Этими переменными могут выступать: заголовок запроса, IP клиента или GET-параметры запроса.

Создадим просто пример с Docker и примитивным node.js API, чтобы протестировать описанные методы и посмотреть, как клиентские запросы распределяются между серверами. Я буду использовать k6 для нагрузочного тестирования API приложения. K6 - это инструмент с открытым исходным кодом для нагрузочного тестирования и облачный сервис для тестирования производительности API.

Во-первых, я создал простое Node.js API с единственным API методом. Он вернёт текущее имя хоста, благодаря чему я смогу определить, какие запросы на какой хост были перенаправлены. API будет работать на 3003 порту, что мы и должны указать в Dockerfile.

Для начала, напишем код простого API:

// app.js
const os = require('os');
const http = require('http');

http.createServer(function(request, response){
    response.end(os.hostname());
}).listen(process.env.LISTEN_PORT);

console.log('Server started on port ' + process.env.LISTEN_PORT);

А теперь создадим файл docker-compose.yml, в котором настроим сборку сервисов nginx и самого приложения.

version: '3.4'

services:
    nginx:
        build:
            context : .
            dockerfile: docker/nginx/Dockerfile
        depends_on:
            - api
        ports:
            - "5100:5100"
        restart: always

    api:
        build:
            context: .
            dockerfile: docker/node/Dockerfile
        environment:
            NODE_ENV: production
        restart: always

В файле docker/nginx/Dockerfile я возьму за основу образ nginx и прокину во внутрь каталога nginx внутри контейнера локальный файл с конфигурацией приложения nginx.conf.

FROM nginx:latest
COPY ./docker/nginx/nginx.conf /etc/nginx/nginx.conf

Затем, создадим Dockerfile для приложения на node.

FROM node:latest

ENV NODE_ENV production
ENV LISTEN_PORT=3003

WORKDIR /app
COPY . .
CMD [ "node", "app.js" ]

В первом случае я протестирую API при одном работающем экземпляре приложения. В файле nginx.conf оно будет висеть на 5100 порту и перенаправлять запросы на сервис API, которое работает на 3003 порту.

events { worker_connections 1024; }

http {

    # Список всех бекенд серверов
    upstream api_servers {
        server dockernginxloadbalancer_api_1:3003;
    }

    # Настройки сервера
    server {

        # Порт, на котором работает nginx
        listen [::]:5100;
        listen 5100;

        # Проксируем все запросы, перенаправляя запросы на бекенд сервера
        location / {
            proxy_pass         http://api_servers;
        }
    }
}

С помощью тестового скрипта, написанного на k6, мы выполним запрос к localhost:5001, и запишем в лог ответ, полученный от сервера, включая имя хоста. Всего будет выполнено 200 запросов, и с помощью лога, я произведу подсчёт запросов для каждого сервера, к которому мы обращались.

Ради упрощения статьи, для выполнения нагрузочного тестирования, k6 должен быть установлен на вашей основной ОС.

// test.js
import { check } from "k6";
import http from 'k6/http';

export default function() {
    var url = "http://localhost:5100/";
    let res = http.get(url);
    check(res, {
        "is status 200": (r) => r.status === 200
    }, { my_tag: res.body },);
}

При выполнении тестов я буду производить сравнение при одинаком периоде времени и одинаковом количестве виртуальных пользователей для объективности результатов тестов. Тест будет длиться 30 секунд при 200 виртуальных пользователях.

k6 run -u 200 -d 30s --summary-export=export.json --out json=my_test_result.json test.js

1_XMZLNmZZS2PidJBNlWZQzw

При тесте одного экземпляра приложения, в общей сложности было сделано 30426 теста, и выполнено 1014 запроса в секунду. Далее я буду тестировать различные методы балансировки нагрузки nginx и поделюсь результатами.

  1. Round Robin

Для проведения данного теста я масшабирую наше API до 3 экземпляров, а nginx настрою, чтобы он, случайным образом, перенаправлял входящие запросы на эти экземпляры.

docker-compose up --scale api=3 -d

И перепишем nginx.conf:

events { worker_connections 1024; }

http {

    # List of application servers
    upstream api_servers {
        server docker-nginx-load-balancer_api_1:3003;
        server docker-nginx-load-balancer_api_2:3003;
        server docker-nginx-load-balancer_api_3:3003;
    }

    # Configuration for the server
    server {

        # Running port
        listen [::]:5100;
        listen 5100;

        # Proxying the connections
        location / {
            proxy_pass         http://api_servers;
        }
    }
}

1_lEZ12lmCbwU4RMK0Oh-Yfw
1_XSkCigLWMw_bXexE25tx1w

Как видно из результатов k6, в текущем варианте было создано больше запросов, чем в при одном экземпляре, всего было выполнено 52545 запросов, при 1751 запросах в секунду. А также nginx равномерно размазал эти запросы по всем серверам.

Сейчас попробуем задать вес одному из серверов в nginx.conf и повторим этот тест еще раз:

upstream api_servers {
    server docker-nginx-load-balancer_api_1:3003 weight=2;
    server docker-nginx-load-balancer_api_2:3003;
    server docker-nginx-load-balancer_api_3:3003;
}

1__aFZm4If6EC70oypUWkG6A
1_d_d8_StYZDwAJ5TI0y6EsQ

Результаты теста k6 практически совпадают с результатами предыдущего теста, но пропорция распределения запросов сильно отличаются. Так как наш api1 имеет weight=2, то он имеет двойной счётчик запросов по сравнению с другими серверами.

  1. Least Connections

В этом методе запросы будут перенаправляться на сервер с наименьшим количеством активных соединений. Так как мы делаем наши запросы одновременно, то результат этого теста будет практически тем же самым, что и результаты Round Robin.

upstream api_servers {
    least_conn;
    server docker-nginx-load-balancer_api_1:3003;
    server docker-nginx-load-balancer_api_2:3003;
    server docker-nginx-load-balancer_api_3:3003;
}

1_Bxv8EQjBvPhr2qOmaGSFBg
1_56GYY4KJ3tLz7PER4BiD3Q

Как видно из отчёта, он показывает примерно такие же результаты при использовании robin method. Общее количество запросов равно 49998, количество запросов в секунду - 1666. А также видим, что nginx равномерно распределил эти запросы по всем экземплярам API.

  1. IP Hash

В этом методе nginx использует ip-адрес клиента для привязки запроса к какому-то конкретному экземпляру. Так как я делаю все запросы от localhost, то для всех запросов будет использоваться один и тот же экземпляр API. Но, ради интереса, давайте посмотрим на результаты тестирования.

upstream api_servers {
    ip_hash;
    server docker-nginx-load-balancer_api_1:3003;
    server docker-nginx-load-balancer_api_2:3003;
    server docker-nginx-load-balancer_api_3:3003;
}

1_9-awntWjLrtASeeRxJYOnA1_k215EZv4z5tKv8naoJWK8w

Как видно из результатов, хотя у меня и добавлено 3 экземпляра API серверов, nginx перенаправлял все клиентские запросы на первый экземпляр. А результаты k6 примерно совпадают как при тестах одного экземпляра API.

В этой статье я попытался изложить теорию, с реальными практическими примерами, рассказав вам о том, как работает nginx load balancing с Docker и как проводить тесты балансировщика нагрузки локально. В этой статье мы пользовались инструментом для нагрузочного тестирования k6, который очень прост в синтаксисе и использовании. Потому, надеюсь, что из этой статьи вы узнали не только, как масштабировать конейтнеры Docker, но и как выполнить балансировку нагрузки Docker контейнеров с nginx. А затем, как проверсти локальные тесты с помощью нагрузочного тестирования.