MAATRIX / Блог / Сессии слетают у части пользователей: балансировщик без липкости

Сессии слетают у части пользователей: балансировщик без липкости

MAATRIX

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

Как выглядит проблема снаружи

Первое, что сбивает с толку — она не системная. Не "все разлогинены", не "авторизация не работает вообще". Разлогинивания случаются у части пользователей, и даже у одного и того же человека то есть, то нет. Можно час работать без единого сбоя, а потом два раза подряд вылететь из личного кабинета за десять минут.

Есть характерные приметы именно этого класса проблем:

  • частота растёт вместе с активностью пользователя — чем чаще он делает запросы (обновляет страницу, кликает по разделам), тем выше шанс словить разлогин;
  • проблема появилась ровно после того, как за балансировщиком стало больше одного бэкенда — если откатить масштабирование обратно на один сервер, жалобы исчезают;
  • в логах приложения нет ошибок аутентификации, нет исключений, нет намёков на взлом или истёкшие токены — просто на очередном запросе сервер "не узнаёт" пользователя;
  • проблема не привязана к конкретному браузеру, устройству или провайдеру — это отличает её от, например, проблем с cookies в конкретном браузере.

Первая реакция обычно — грешить на код: может, сессия обнуляется по таймауту раньше времени, может, не тот SameSite или Secure у cookie. Эти гипотезы стоит проверить и отбросить, но быстрее приводит к разгадке один вопрос: а куда физически пишется сессия при логине?

Настоящая причина: сессия живёт на одном сервере, а запросы — нет

В типичной небольшой конфигурации веб-приложение хранит сессии в памяти собственного процесса — например, express-session с дефолтным MemoryStore в Node.js, встроенная сессия Flask/Django без внешнего backend'а для сессий, или PHP с session.save_handler = files, когда файлы сессий лежат на локальном диске конкретной машины. Пока сервер один, это работает идеально: пользователь залогинился — сессия появилась в памяти (или в файле на диске) этого единственного процесса, и каждый следующий его запрос неизбежно приходит туда же, потому что деваться некуда.

Проблема начинается в момент, когда за балансировщиком появляется второй (а тем более третий) бэкенд. Балансировщик по умолчанию распределяет запросы одним из "круговых" алгоритмов — round robin, least connections, random — и делает это на уровне отдельных HTTP-запросов, а не сессий пользователя. Для балансировщика все бэкенды равноправны и взаимозаменяемы, он не хранит понятия "этот пользователь уже закреплён за сервером номер два".

Происходит следующее:

  1. Пользователь логинится, запрос попадает на backend-1. Сессия создаётся в памяти процесса на backend-1 (или в локальном файле /var/lib/php/sessions/ на этой же машине).
  2. Балансировщик отдаёт куку connect.sid / PHPSESSID / sessionid пользователю — она одна и та же независимо от того, какой бэкенд её выдал.
  3. Следующий запрос того же пользователя балансировщик направляет по своему алгоритму — и с некоторой вероятностью это уже backend-2.
  4. Backend-2 получает куку с идентификатором сессии, лезет в свою собственную память (или в свою собственную файловую систему) — и ничего не находит, потому что эта сессия физически существует только на backend-1.
  5. Приложение честно делает единственное, что может: считает пользователя неавторизованным и либо редиректит на логин, либо создаёт новую пустую сессию.

Со стороны это выглядит как случайное разлогинивание, потому что оно и есть случайное — в буквальном смысле, вероятность зависит от того, на какой бэкенд балансировщик направит следующий запрос. При двух бэкендах и равномерном round robin шанс "промазать" на каждом следующем запросе — примерно 50%, при трёх — около двух третей. Это грубая оценка для равномерного распределения без учёта повторных попаданий на свой же сервер, но она объясняет, почему с ростом числа бэкендов проблема обычно становится заметнее, а не наоборот.

Отдельно стоит сказать, почему "у одних чаще, чем у других". Некоторые балансировщики и некоторые версии клиентов (браузер с keep-alive, HTTP/2 с мультиплексированием) в рамках одного открытого TCP-соединения действительно продолжают слать запросы на тот же бэкенд, к которому подключились изначально — тогда пользователь может подолгу не замечать проблемы, пока соединение не оборвётся и не переустановится на другой бэкенд. Это создаёт обманчивое впечатление "у меня-то работает", пока кто-то другой жалуется.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Путь первый: включить sticky sessions на балансировщике

Самое быстрое исправление — не менять архитектуру хранения сессий, а заставить балансировщик всегда направлять запросы одного и того же клиента на один и тот же бэкенд. Это называется sticky sessions (session affinity, липкие сессии).

В nginx (как reverse proxy / load balancer) самый простой вариант — привязка по IP-адресу клиента через ip_hash:

upstream backend_pool {
    ip_hash;
    server 10.0.0.11:3000;
    server 10.0.0.12:3000;
}

server {
    listen 80;
    location / {
        proxy_pass http://backend_pool;
    }
}

Минус ip_hash — он привязывает не пользователя, а IP-адрес, а за одним корпоративным NAT или мобильным оператором может сидеть множество разных людей с одним внешним IP, что портит равномерность распределения. Более точный вариант — привязка по значению самой сессионной куки, доступная в nginx только в платной версии Plus (sticky cookie) или через сторонние модули; в открытой версии для этого чаще берут HAProxy:

backend backend_pool
    balance roundrobin
    cookie SRV insert indirect nocache
    server web1 10.0.0.11:3000 check cookie web1
    server web2 10.0.0.12:3000 check cookie web2

Здесь HAProxy сам добавляет в ответ куку SRV, которая жёстко привязывает браузер к конкретному серверу на все последующие запросы, независимо от IP.

У sticky sessions есть три практических ограничения, о которых обычно не думают, пока они не проявятся:

  • Неравномерная нагрузка. Если один и тот же пользователь генерирует много запросов, а другой — мало, "липкое" распределение перестаёт быть балансировкой в полном смысле: часть серверов может простаивать, пока другие перегружены активными сессиями.
  • Потеря сессии при падении сервера. Если backend-1, к которому привязан пользователь, упал или ушёл на перезапуск, вся его сессия пропадает вместе с ним — пользователя разлогинит гарантированно, просто не случайно, а из-за реального сбоя. Sticky sessions не решают проблему отказоустойчивости, они лишь маскируют её на исправно работающих серверах.
  • Плохая совместимость с автомасштабированием. Если бэкенды создаются и удаляются автоматически (autoscaling), закреплённые за ними сессии умирают вместе с инстансом — это прямо противоречит идее эластичного масштабирования.

Sticky sessions — рабочее и быстрое решение для двух-трёх стабильных серверов, но это заплатка, а не архитектурное исправление: горизонтальное масштабирование строилось ради отказоустойчивости и гибкости, а привязка "клиент = конкретный сервер" их частично отменяет.

Путь второй: вынести сессии в общее хранилище

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

Для этого обычно берут Redis — он быстрый, поддерживает TTL "из коробки" (сессия сама истекает по времени, не нужен отдельный cron на чистку), и под него есть готовые адаптеры почти для всех стеков.

Node.js / Express:

const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');

const redisClient = createClient({ url: 'redis://10.0.0.20:6379' });
redisClient.connect().catch(console.error);

app.use(session({
  store: new RedisStore({ client: redisClient, prefix: 'sess:' }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { maxAge: 24 * 60 * 60 * 1000 }
}));

PHP:

; php.ini или отдельный конфиг пула
session.save_handler = redis
session.save_path = "tcp://10.0.0.20:6379?auth=your_redis_password"

Django:

SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
CACHES = {
    'default': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': 'redis://10.0.0.20:6379/1',
    }
}

Важный нюанс: Redis в этой схеме должен быть один общий экземпляр (или кластер), доступный со всех бэкендов по сети — а не отдельный локальный Redis на каждой машине, иначе вы просто перенесли ту же проблему на уровень ниже. Redis обычно ставят на отдельный сервер или закрывают его firewall'ом так, чтобы был доступен только из внутренней сети бэкендов, без выхода наружу. После такого переноса балансировщик снова может работать в честном round robin без всякой привязки — каждый бэкенд одинаково "знает" любую активную сессию.

Из минусов: появляется ещё один компонент инфраструктуры, за которым нужно следить — если Redis недоступен, недоступны все сессии сразу (это решается через Redis Sentinel или кластер для отказоустойчивости, но это уже отдельная настройка). Плюс небольшая дополнительная задержка на каждый запрос, где требуется чтение сессии — на практике для Redis в той же локальной сети это доли миллисекунды и обычно не заметно на фоне остального времени ответа.

Сравнение двух подходов

КритерийSticky sessionsОбщее хранилище (Redis)
Сложность внедренияНизкая — правка конфига балансировщикаСредняя — правка кода приложения + новый сервис
Изменения в коде приложенияНе требуютсяТребуются (подключение store)
Поведение при падении одного бэкендаСессии на упавшем сервере теряютсяСессии сохраняются, обслуживает любой живой бэкенд
Совместимость с autoscalingПлохаяХорошая
Равномерность нагрузкиМожет быть перекошена активными пользователямиНе зависит от привязки к серверу
Новая точка отказаНетДа (Redis), решается репликацией

Если бэкендов два-три и они стабильны без частого автомасштабирования — sticky sessions на первое время закрывают вопрос. Если план — расти дальше, добавлять и убирать бэкенды по нагрузке, переживать падение отдельных серверов без сбоев для пользователей — единственный устойчивый вариант это общее хранилище сессий, sticky sessions здесь рано или поздно снова всплывут той же проблемой.

Как диагностировать проблему стабильно, а не гадать по логам

Прежде чем что-то менять, стоит воспроизвести проблему управляемо, а не ждать очередной жалобы пользователя. Это делается направлением запросов явно на разные бэкенды в обход балансировщика.

Первый шаг — узнать, действительно ли балансировка "прыгает" между серверами для одного клиента. В nginx для этого добавляют заголовок с именем апстрима в ответ:

location / {
    proxy_pass http://backend_pool;
    add_header X-Upstream-Server $upstream_addr always;
}

После этого несколько запросов подряд из одной сессии браузера:

curl -s -D - -o /dev/null -b cookies.txt -c cookies.txt https://example.com/api/me
curl -s -D - -o /dev/null -b cookies.txt -c cookies.txt https://example.com/api/me
curl -s -D - -o /dev/null -b cookies.txt -c cookies.txt https://example.com/api/me

Если в заголовке X-Upstream-Server меняется адрес между вызовами, а один из ответов при этом внезапно перестаёт узнавать пользователя (401 или редирект на логин вместо ожидаемых данных) — это прямое подтверждение диагноза, без всяких предположений.

Второй, ещё более прямой способ — обратиться к каждому бэкенду напрямую, минуя балансировщик, если сеть это позволяет (например, из той же внутренней сети):

# логинимся через backend-1 напрямую
curl -s -c cookies.txt -X POST http://10.0.0.11:3000/login -d 'user=test&pass=test'

# тем же набором кук идём на backend-2
curl -s -b cookies.txt http://10.0.0.12:3000/api/me

Если второй запрос отвечает "не авторизован", а сессия при этом создана и жива на backend-1 — сомнений в причине не остаётся: дело именно в том, что сессия не видна второму серверу, а не в куках, не в CORS и не в истекших токенах.

Третий шаг, если используется MemoryStore или файловые сессии — заглянуть непосредственно в хранилище на каждом бэкенде. Для файловых PHP-сессий:

ls -la /var/lib/php/sessions/ | grep sess_<идентификатор_из_куки>

Если файл с нужным sess_... существует на backend-1, но отсутствует на backend-2 — это финальное подтверждение на уровне файловой системы, дальше остаётся выбрать один из двух путей решения выше.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли просто увеличить время жизни сессии, чтобы проблема была реже заметна?

Нет, это не решает причину — балансировщик всё равно будет случайно отправлять запросы на сервер, который не знает про сессию, просто увеличенный maxAge не спасёт от попадания на "чужой" бэкенд прямо сейчас.

Влияет ли HTTPS или тип куки (Secure, SameSite, HttpOnly) на эту проблему?

Нет напрямую — эти атрибуты касаются того, отправит ли браузер куку и в каких условиях, но не того, найдёт ли конкретный бэкенд сессию по идентификатору из куки. Проблема на уровне сервера, а не браузера.

Если у меня всего один бэкенд сейчас, но я планирую добавить второй позже — стоит ли сразу настроить общее хранилище?

Да, разумно сделать это заранее: переход на Redis-хранилище сессий на этапе одного сервера не требует sticky sessions вообще и избавляет от инцидента в момент масштабирования, когда меньше всего есть время на разбор проблемы.

Что произойдёт с уже открытыми сессиями пользователей в момент перехода с MemoryStore на Redis?

Обычно они просто становятся невалидными — Redis не увидит сессий, созданных до переключения, поэтому переход стоит делать в окно с низкой нагрузкой и предупреждать, что часть пользователей один раз перелогинится.

Sticky sessions в Kubernetes работают так же, как в nginx/HAProxy?

Принцип тот же, но реализация зависит от Ingress-контроллера — например, у ingress-nginx это аннотация nginx.ingress.kubernetes.io/affinity: "cookie", у других контроллеров синтаксис отличается; общее ограничение с потерей сессии при пересоздании пода сохраняется.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →