Балансировщик нагрузки Nginx с использованием Docker
Итак, эта фотография наглядно демонстрирует то, чем nginx балансировщик нагрузки не является. Давайте сначала вкратце поговорим об общем понятии балансировки нагрузки, и их разновидностях в nginx. И, заодно, применим на практике знания о Docker, полученные ранее.
Балансировка нагрузки для многих приложений является широко используемой методикой оптимизации скорости отклика, доступности и распределения нагрузки сайта на несколько серверов. Балансировщики нагрузки управляют обменом данными между серверами и клиентами. Они оптимально распределяют трафик между различными серверами, при этом следя, чтобы ни один сервер не был перегружен работой. Это может быть как аппаратнное, так и программное решение.
Nginx из коробки можно использовать как балансировщик нагрузки для распределения входящего трафика по серверам и передачи ответов от выбранного сервера клиенту. И nginx имеет некоторые преимущества по сравнению с другими балансировщиками нагрузки:
- Он поддерживает несколько различных методов балансировки, которые отлично подходят для большинства случаев.
- Поддерживает статическое и динамическое кэширование.
- Каждый экземпляр Nginx полностью поддерживает запуск нескольких различных приложений.
- Он может распределять трафик на основе: заголовков запроса, куки-файлов, аргументов и даже GET-параметров запроса.
- Ограничение скорости (rate limiting), вес сервера (weight) и привязка сессий.
Nginx Open Source поддерживает четыре метода балансировки нагрузки, а Nginx Plus имеет в себе ещё два дополнительных.
- Round Robin: Это метод по умолчанию, если вы ничего не указываете. В этом методе запросы будут равномерно распределены между указанными бекенд-серверами. Или вы можете указать дополнительные веса серверов для приоретизации распределения в сторону каких-то серверов.
- Least Connections: В этом методе запрос посылается на сервер с наименьшим количеством активных соединений на данный момент. Вы также можете указать веса в этом методе.
- IP Hash: В других методах балансировки каждый запрос от клиентов может быть отправлен на случайный сервер. Ввиду этого, если вы используете сессии в своём приложении, вы не можете обеспечить персистентность данных. Потому как, пользователь открывает сессию, которая хранится на одном сервере. А в следующем запросе мы обращаемся уже к другому серверу, на котором нет той пользовательской сессии.
Для решения этой проблемы у вас есть два варианта:- вы можете вынести хранилище сессий на отдельный сервер, с использованием Redis, например. Затем, необходимо настроить сессии приложения для работы с ним.
- или же можете использовать метод IP хэша в Nginx.
В этом методе Nginx использует IP-адрес клиента в качестве ключа и посылает запросы, поступающие с одного и того же IP на один и тот же сервер. Это гарантирует, что клиент всегда будет перенаправлен на один и тот же сервер, где будет доступна его сессия.
- 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
При тесте одного экземпляра приложения, в общей сложности было сделано 30426 теста, и выполнено 1014 запроса в секунду. Далее я буду тестировать различные методы балансировки нагрузки nginx и поделюсь результатами.
- 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;
}
}
}
Как видно из результатов 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;
}
Результаты теста k6 практически совпадают с результатами предыдущего теста, но пропорция распределения запросов сильно отличаются. Так как наш api1 имеет weight=2
, то он имеет двойной счётчик запросов по сравнению с другими серверами.
- 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;
}
Как видно из отчёта, он показывает примерно такие же результаты при использовании robin method. Общее количество запросов равно 49998, количество запросов в секунду - 1666. А также видим, что nginx равномерно распределил эти запросы по всем экземплярам API.
- 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;
}
Как видно из результатов, хотя у меня и добавлено 3 экземпляра API серверов, nginx перенаправлял все клиентские запросы на первый экземпляр. А результаты k6 примерно совпадают как при тестах одного экземпляра API.
В этой статье я попытался изложить теорию, с реальными практическими примерами, рассказав вам о том, как работает nginx load balancing с Docker и как проводить тесты балансировщика нагрузки локально. В этой статье мы пользовались инструментом для нагрузочного тестирования k6, который очень прост в синтаксисе и использовании. Потому, надеюсь, что из этой статьи вы узнали не только, как масштабировать конейтнеры Docker, но и как выполнить балансировку нагрузки Docker контейнеров с nginx. А затем, как проверсти локальные тесты с помощью нагрузочного тестирования.