Nginx как балансировщик нагрузки: практика на реальном VPS
В этой статье я прохожу практическое задание по DevOps — настраиваю Nginx как балансировщик нагрузки с HTTPS, кешированием и защитой. Всё делаю на реальном VPS, рядом с работающим блогом. Никаких учебных стендов — сразу как в продакшене.
Что будем строить
Nginx будет принимать запросы на домен app.lis.im и распределять их между тремя инстансами приложения. Если один инстанс падает — остальные продолжают работать, клиент ничего не замечает.
graph LR
C([Клиент]) -->|"HTTPS :443"| N["Nginx<br/>app.lis.im"]
N --> B1["backend #1<br/>:3001"]
N --> B2["backend #2<br/>:3002"]
N --> B3["backend #3<br/>:3003"]
На реальном проде три бэкенда — это три разных сервера. Здесь мы эмулируем их тремя Python-процессами на одной машине. Nginx не знает разницы: ему важен только адрес host:port.
Исходное состояние
На VPS уже работает блог ru.blog.lis.im. Вот его конфиг:
1server {
2 server_name ru.blog.lis.im;
3 root /var/www/ru.blog.lis.im;
4 index index.html;
5
6 location / {
7 try_files $uri $uri/ $uri.html =404;
8 }
9
10 location ~* \.(css|js|woff2?|png|jpg|ico|svg)$ {
11 expires 1y;
12 add_header Cache-Control "public, immutable";
13 }
14
15 error_page 404 /404.html;
16
17 listen 443 ssl;
18 ssl_certificate /etc/letsencrypt/live/ru.blog.lis.im/fullchain.pem;
19 ssl_certificate_key /etc/letsencrypt/live/ru.blog.lis.im/privkey.pem;
20 include /etc/letsencrypt/options-ssl-nginx.conf;
21 ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
22}
23
24server {
25 if ($host = ru.blog.lis.im) {
26 return 301 https://$host$request_uri;
27 }
28 listen 80;
29 server_name ru.blog.lis.im;
30 return 404;
31}Блог трогать не будем. Всё новое — в отдельном конфиге.
Шаг 1. Улучшаем nginx.conf
До начала практики исправляем то, что уже должно было быть настроено: убираем версию из заголовков, отключаем старые протоколы TLS, нормально настраиваем Gzip, добавляем формат логов с временем ответа и объявляем зону rate limiting.
1user www-data;
2worker_processes auto;
3pid /run/nginx.pid;
4include /etc/nginx/modules-enabled/*.conf;
5
6events {
7 worker_connections 1024;
8 multi_accept on;
9}
10
11http {
12 ##
13 # Basic Settings
14 ##
15 sendfile on;
16 tcp_nopush on;
17 tcp_nodelay on;
18 types_hash_max_size 2048;
19 server_tokens off;
20
21 include /etc/nginx/mime.types;
22 default_type application/octet-stream;
23
24 ##
25 # SSL Settings
26 ##
27 ssl_protocols TLSv1.2 TLSv1.3;
28 ssl_prefer_server_ciphers on;
29 ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
30 ssl_session_cache shared:SSL:10m;
31 ssl_session_timeout 1d;
32
33 ##
34 # Logging Settings
35 ##
36 log_format main '$remote_addr - $remote_user [$time_local] "$request" '
37 '$status $body_bytes_sent "$http_referer" '
38 '"$http_user_agent" rt=$request_time '
39 'uct=$upstream_connect_time urt=$upstream_response_time';
40
41 access_log /var/log/nginx/access.log main;
42 error_log /var/log/nginx/error.log warn;
43
44 ##
45 # Gzip Settings
46 ##
47 gzip on;
48 gzip_vary on;
49 gzip_proxied any;
50 gzip_comp_level 6;
51 gzip_buffers 16 8k;
52 gzip_http_version 1.1;
53 gzip_types text/plain text/css application/json application/javascript
54 text/xml application/xml application/xml+rss text/javascript
55 image/svg+xml font/woff2;
56
57 ##
58 # Rate Limiting
59 ##
60 limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
61
62 ##
63 # Virtual Host Configs
64 ##
65 include /etc/nginx/conf.d/*.conf;
66 include /etc/nginx/sites-enabled/*;
67}Проверяем что синтаксис правильный:
1nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Шаг 2. Поднимаем три бэкенда
Создаём простой Python-сервер. Каждый инстанс будет отдавать ответ с указанием своего номера и порта — так сразу будет видно какой бэкенд обработал запрос.
1#!/usr/bin/env python3
2import http.server
3import sys
4
5PORT = int(sys.argv[1])
6INSTANCE = sys.argv[2]
7
8class Handler(http.server.BaseHTTPRequestHandler):
9 def do_GET(self):
10 body = f"Hello from backend {INSTANCE} (port {PORT})\n".encode()
11 self.send_response(200)
12 self.send_header("Content-Type", "text/plain")
13 self.send_header("Content-Length", len(body))
14 self.end_headers()
15 self.wfile.write(body)
16
17 def log_message(self, format, *args):
18 pass
19
20if __name__ == "__main__":
21 server = http.server.HTTPServer(("127.0.0.1", PORT), Handler)
22 print(f"Backend {INSTANCE} listening on port {PORT}")
23 server.serve_forever()Сохраняем в /opt/backends.py и запускаем три инстанса в фоне:
1python3 /opt/backends.py 3001 "#1" &
2python3 /opt/backends.py 3002 "#2" &
3python3 /opt/backends.py 3003 "#3" &
Проверяем каждый напрямую:
1curl http://127.0.0.1:3001
2curl http://127.0.0.1:3002
3curl http://127.0.0.1:3003
Hello from backend #1 (port 3001)
Hello from backend #2 (port 3002)
Hello from backend #3 (port 3003)
Все три живые. Идём дальше.
Шаг 3. Конфиг Nginx для app.lis.im
Сначала создаём самоподписанный SSL-сертификат:
1mkdir -p /etc/nginx/ssl
2
3openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
4 -keyout /etc/nginx/ssl/app.lis.im.key \
5 -out /etc/nginx/ssl/app.lis.im.crt \
6 -subj "/CN=app.lis.im/O=Practice/C=MD"
Создаём конфиг /etc/nginx/sites-available/app.lis.im:
1upstream app_backend {
2 server 127.0.0.1:3001 max_fails=2 fail_timeout=30s;
3 server 127.0.0.1:3002 max_fails=2 fail_timeout=30s;
4 server 127.0.0.1:3003 max_fails=2 fail_timeout=30s;
5}
6
7# Редирект HTTP → HTTPS
8server {
9 listen 80;
10 server_name app.lis.im;
11 return 301 https://$host$request_uri;
12}
13
14# Основной HTTPS блок
15server {
16 listen 443 ssl;
17 server_name app.lis.im;
18
19 ssl_certificate /etc/nginx/ssl/app.lis.im.crt;
20 ssl_certificate_key /etc/nginx/ssl/app.lis.im.key;
21
22 # Логирование
23 access_log /var/log/nginx/app.lis.im.access.log main;
24 error_log /var/log/nginx/app.lis.im.error.log warn;
25
26 # Защита
27 limit_req zone=general burst=20 nodelay;
28 client_max_body_size 100M;
29
30 # Таймауты
31 proxy_connect_timeout 5s;
32 proxy_send_timeout 60s;
33 proxy_read_timeout 60s;
34
35 # Заголовки для бэкенда
36 proxy_set_header Host $host;
37 proxy_set_header X-Real-IP $remote_addr;
38 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
39 proxy_set_header X-Forwarded-Proto $scheme;
40
41 # Основной location — проксируем на бэкенды
42 location / {
43 proxy_pass http://app_backend;
44 }
45
46 # Кеширование картинок
47 location ~* \.(jpg|jpeg|png|gif|ico)$ {
48 proxy_pass http://app_backend;
49 expires 30d;
50 add_header Cache-Control "public, immutable";
51 }
52
53 # Кеширование CSS и JS
54 location ~* \.(css|js)$ {
55 proxy_pass http://app_backend;
56 expires 30d;
57 add_header Cache-Control "public, immutable";
58 }
59}Активируем и проверяем:
1ln -s /etc/nginx/sites-available/app.lis.im /etc/nginx/sites-enabled/
2nginx -t
3systemctl reload nginx
Шаг 4. logrotate
Создаём /etc/logrotate.d/nginx-app:
1/var/log/nginx/app.lis.im.*.log {
2 daily
3 missingok
4 rotate 14
5 compress
6 delaycompress
7 notifempty
8 sharedscripts
9 postrotate
10 nginx -s reopen
11 endscript
12}Проверяем:
1logrotate --debug /etc/logrotate.d/nginx-app
Шаг 5. Проверяем всё
Балансировка
Делаем 6 запросов подряд и смотрим — Nginx должен по очереди отдавать каждый бэкенд:
1for i in {1..6}; do
2 curl -sk https://app.lis.im
3done
Hello from backend #2 (port 3002)
Hello from backend #3 (port 3003)
Hello from backend #1 (port 3001)
Hello from backend #2 (port 3002)
Hello from backend #3 (port 3003)
Hello from backend #1 (port 3001)
Round-robin в действии: #2 → #3 → #1 и по кругу. Каждый запрос уходит на следующий бэкенд.
graph LR
N[Nginx] -->|"1, 4, 7..."| B1["backend #1"]
N -->|"2, 5, 8..."| B2["backend #2"]
N -->|"3, 6, 9..."| B3["backend #3"]
Отказоустойчивость
Убиваем бэкенд #2 и снова делаем запросы:
1kill $(lsof -t -i:3002)
2
3for i in {1..6}; do
4 curl -sk https://app.lis.im
5done
Hello from backend #3 (port 3003)
Hello from backend #3 (port 3003)
Hello from backend #1 (port 3001)
Hello from backend #3 (port 3003)
Hello from backend #1 (port 3001)
Hello from backend #3 (port 3003)
Бэкенд #2 выпал — трафик автоматически перераспределился между #1 и #3. Это работает благодаря max_fails=2 fail_timeout=30s: после двух неудачных попыток Nginx исключает сервер из ротации на 30 секунд.
graph LR
N[Nginx] --> B1["backend #1 ✓"]
N -.->|"исключён"| B2["backend #2 ✗"]
N --> B3["backend #3 ✓"]
style B2 fill:#dc2626,color:#fff,stroke:#dc2626
Возвращаем #2:
1python3 /opt/backends.py 3002 "#2" &
Кеширование статики
1curl -sk -D - https://app.lis.im/style.css -o /dev/null
HTTP/1.1 200 OK
Server: nginx
Expires: Fri, 05 Jun 2026 12:42:04 GMT
Cache-Control: max-age=2592000
Cache-Control: public, immutable
Заголовок Expires выставлен ровно на 30 дней вперёд. Cache-Control: public, immutable говорит браузеру кешировать файл и не перепроверять его даже при наличии соединения.
Rate limiting
Посылаем 30 параллельных запросов:
1for i in {1..30}; do
2 curl -sk -o /dev/null -w "%{http_code}\n" https://app.lis.im &
3done
4wait
200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200 200
503 503 503 503 503 503 503 503 503
Первые ~20 запросов проходят (10 по лимиту + буфер burst=20), остальные получают 503 Service Unavailable. Rate limiting работает.
Финал: заменяем самоподписанный сертификат на Let’s Encrypt
Поскольку домен реальный, одной командой получаем валидный сертификат:
1certbot --nginx -d app.lis.im
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/app.lis.im/fullchain.pem
Congratulations! You have successfully enabled HTTPS on https://app.lis.im
Certbot сам обновил конфиг и настроил автообновление.
Как это всё работает — разбираем по частям
Почему 3 процесса на одном сервере?
В реальной жизни балансировщик распределяет запросы между несколькими физическими серверами — например, три разных машины с IP 192.168.1.1, 192.168.1.2, 192.168.1.3. Каждая крутит одно и то же приложение.
Мы это эмулируем локально: вместо трёх серверов — три Python-процесса на разных портах одной машины. Nginx не знает и не заботится о том, где физически находится бэкенд — ему важен только адрес host:port. Поэтому 127.0.0.1:3001, 127.0.0.1:3002, 127.0.0.1:3003 для него ничем не отличаются от трёх разных серверов.
Что мы проверяем этим? Не железо, а логику: правильно ли Nginx распределяет запросы, что происходит когда один бэкенд падает, работают ли таймауты и health checks. Всё это поведение одинаково и на одной машине, и в реальном кластере.
nginx.conf — глобальные настройки
1worker_processes auto;
Nginx запускает столько рабочих процессов, сколько ядер у процессора. Каждый worker обрабатывает запросы независимо.
1worker_connections 1024;
2multi_accept on;
Каждый worker держит до 1024 одновременных соединений. multi_accept — принимать сразу несколько новых соединений за один раз, а не по одному.
1server_tokens off;
Без этого Nginx в заголовке ответа пишет Server: nginx/1.24.0 — версию видят все, включая тех кто ищет известные уязвимости. С server_tokens off — только Server: nginx.
1ssl_protocols TLSv1.2 TLSv1.3;
TLSv1.0 и TLSv1.1 — устаревшие протоколы с известными уязвимостями (POODLE, BEAST). Оставляем только современные.
1ssl_session_cache shared:SSL:10m;
SSL-рукопожатие — дорогая операция. Кеш позволяет переиспользовать уже установленные сессии, не делая полное рукопожатие каждый раз.
1log_format main '... rt=$request_time uct=$upstream_connect_time urt=$upstream_response_time';
Стандартный формат не пишет время ответа. Мы добавили:
rt— сколько времени Nginx потратил на весь запросuct— сколько времени ушло на соединение с бэкендомurt— сколько бэкенд думал и отвечал
Это критично для диагностики — сразу видно, тормозит ли сеть или само приложение.
1gzip_types text/plain text/css application/javascript ...;
Gzip был включён, но без списка типов — сжимал только text/html. Теперь сжимает CSS, JS, JSON, SVG. Меньше трафика, быстрее загрузка.
1limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
Создаём зону rate limiting. $binary_remote_addr — ключ, по которому считаем запросы (IP адрес клиента в бинарном виде, занимает меньше памяти). 10m — 10 мегабайт памяти под эту зону (хватит на ~160 тысяч IP). rate=10r/s — не больше 10 запросов в секунду с одного IP. Это объявление зоны, применяется она в server блоке.
Конфиг app.lis.im — upstream и балансировка
1upstream app_backend {
2 server 127.0.0.1:3001 max_fails=2 fail_timeout=30s;
3 server 127.0.0.1:3002 max_fails=2 fail_timeout=30s;
4 server 127.0.0.1:3003 max_fails=2 fail_timeout=30s;
5}
upstream — это именованная группа бэкендов. По умолчанию Nginx использует round-robin: первый запрос идёт на 3001, второй на 3002, третий на 3003, четвёртый снова на 3001 и так по кругу.
max_fails=2 fail_timeout=30s — это health check. Если бэкенд не ответил 2 раза подряд, Nginx считает его мёртвым и исключает на 30 секунд. Именно это мы проверяли когда убивали процесс #2 — Nginx сам понял что он недоступен и перестал туда слать запросы.
1server {
2 listen 80;
3 server_name app.lis.im;
4 return 301 https://$host$request_uri;
5}
Отдельный server блок только для редиректа. Любой HTTP запрос → 301 → тот же URL но уже HTTPS. $request_uri сохраняет путь — /some/page не потеряется при редиректе.
1limit_req zone=general burst=20 nodelay;
Применяем зону которую объявили в nginx.conf. burst=20 — буфер: даже если лимит превышен, до 20 лишних запросов ставятся в очередь а не сразу отбиваются. nodelay — запросы из буфера обрабатываются немедленно, не ждут своей очереди. Когда буфер заполнен — 503.
1proxy_connect_timeout 5s;
2proxy_send_timeout 60s;
3proxy_read_timeout 60s;
connect— сколько ждать пока бэкенд примет соединение. 5 секунд — если бэкенд не отвечает, быстро фейлимся и пробуем следующийsend— сколько ждать пока клиент дошлёт запрос бэкендуread— сколько ждать ответа от бэкенда. 60 секунд — для тяжёлых операций
1proxy_set_header X-Real-IP $remote_addr;
2proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
3proxy_set_header X-Forwarded-Proto $scheme;
Без этих заголовков бэкенд видит все запросы как приходящие от 127.0.0.1 (это Nginx). С ними — видит реальный IP клиента и знает что запрос пришёл по HTTPS.
1location ~* \.(css|js)$ {
2 proxy_pass http://app_backend;
3 expires 30d;
4 add_header Cache-Control "public, immutable";
5}
~* — регулярное выражение без учёта регистра. Если URL заканчивается на .css или .js — этот location перехватывает запрос. expires 30d — Nginx добавляет заголовок Expires на 30 дней вперёд. immutable — говорит браузеру “этот файл никогда не изменится, не перепроверяй его даже если есть соединение”.
logrotate
daily
rotate 14
compress
delaycompress
Каждый день создаётся новый лог-файл, старый переименовывается. Хранится 14 штук. compress — старые логи сжимаются в gzip. delaycompress — вчерашний лог не сжимается сразу (Nginx ещё может в него писать), сжимается только позавчерашний.
postrotate
nginx -s reopen
endscript
После ротации говорим Nginx открыть новые файлы. Без этого он продолжал бы писать в старый переименованный файл.
Почему это всё работает вместе
Nginx работает как обратный прокси. Клиент думает что разговаривает с одним сервером app.lis.im. На самом деле Nginx принимает соединение, сам идёт к одному из бэкендов, получает ответ и отдаёт клиенту. Бэкенды вообще не знают о существовании клиента.
graph LR
C([Клиент]) -->|"видит только app.lis.im"| N["Nginx<br/>обратный прокси"]
N -->|proxy_pass| B1["backend #1"]
N -->|proxy_pass| B2["backend #2"]
N -->|proxy_pass| B3["backend #3"]
Это даёт три вещи сразу:
- Масштабирование — добавил строчку в upstream и трафик пошёл на новый сервер
- Отказоустойчивость — один упал, остальные работают, клиент ничего не заметил
- Безопасность — бэкенды вообще не торчат наружу, только Nginx