Пользователь открывает example.com, вводит логин и пароль, видит личный кабинет. Он нажимает на раздел «Заказы», и вместо списка заказов перед ним снова форма входа. Он вводит пароль второй раз, всё работает, а через минуту сайт опять «забывает» его. Пользователь уверен, что сломался сайт. Администратор уверен, что всё настроено правильно: домен один, сертификат один, код один и тот же. Разгадка почти всегда одна: запросы одного человека попадают на разные площадки, и эти площадки по-разному представляют себе, кто он такой.
Пока у проекта один сервер, вопрос «где живёт сессия» не возникает: она живёт там же, где приложение. Как только появляется второй узел, например во Франции рядом с первым в Великобритании, а потом и третий в США, сессия превращается в распределённые данные со всеми их неприятными свойствами. Эта статья про то, как рассуждать об этом до того, как вы раскидали узлы по карте и начали гадать, почему у пользователей «слетает» вход.
Браузер носит ключ, а замок стоит на сервере
Начнём с разделения, без которого дальше легко запутаться. Cookie в браузере — это не сеанс. Это маленькая запись, которую браузер сохранил по команде сервера и теперь прикладывает к подходящим запросам. Обычно в ней лежит идентификатор — длинная случайная строка, которая сама по себе ничего не значит. Сеанс — это серверная запись, найденная по этому идентификатору: кто вошёл, какие у него права, что лежит в корзине, когда сеанс истекает. Ключ у пользователя, замок и всё, что за дверью, — у вас.
Из этого следует неочевидная вещь: браузер не знает и не интересуется, на какой именно сервер уйдёт запрос. Он знает только имя хоста. Согласно RFC 6265, атрибут Domain определяет, каким хостам отправлять cookie, а Path — для каких путей она годится. Если Domain не указан, cookie привязывается к хосту, который её выдал. Всё это решается по строке в адресной строке, а не по IP-адресу и не по стране сервера.
Отсюда два практических следствия. Первое: если у вас один домен example.com, а DNS или балансировщик раскладывают запросы по разным площадкам, браузер честно отправит одну и ту же cookie на любую из них. Для него это один и тот же сайт. Куки «доедут» везде, и это хорошо, но вся нагрузка по пониманию, что это за идентификатор, ложится на сами узлы.
Второе следствие касается схемы с отдельными именами, например uk.example.com и fr.example.com. Если cookie выдана без Domain, она будет привязана к конкретному хосту и на соседний поддомен не попадёт. Если указать Domain=example.com, она станет видна всем поддоменам, что может быть удобно, а может расширить область действия сильнее, чем вы планировали: это решение стоит принимать осознанно, а не копировать из примера. Но даже самая аккуратная настройка cookie ничего не говорит о том, что находится на сервере. Она лишь гарантирует, что ключ будет предъявлен. Подойдёт ли он к замку — вопрос другого уровня.
Один вход, просмотр и изменение: как это ломается шаг за шагом
Возьмём условный сценарий. Он вымышленный и нужен только для разбора механики: у проекта два узла, во Франции и в США, один домен, а входящие запросы раскладываются по ближайшей площадке. Пользователь находится в Европе, но у него нестабильное мобильное соединение, и иногда маршрутизация ведёт его на американский узел.
Шаг первый: вход. Пользователь отправляет форму на французский узел. Приложение проверяет пароль, создаёт запись сеанса и отвечает заголовком Set-Cookie с идентификатором. Если сессии хранятся в памяти процесса или в файлах на диске этого узла, запись теперь существует только там.
Шаг второй: просмотр. Пользователь открывает страницу заказов. Браузер прикладывает cookie, но запрос по какой-то причине уходит на американский узел. Тот получает идентификатор, ищет его у себя, не находит и делает единственное, что разумно: считает пользователя анонимным и перенаправляет на форму входа. С точки зрения узла он всё сделал правильно. С точки зрения человека сайт сломан.
Шаг третий: изменение. Допустим, вы это исправили и сессии теперь общие. Пользователь вошёл и на американском узле меняет адрес доставки. Запись обновляется в локальной базе США. Следующая страница открывается уже через французский узел, который читает из своей базы, а изменения туда ещё не дошли. Пользователь видит старый адрес и решает, что сохранение не сработало. Он нажимает «сохранить» повторно, и теперь у вас два конкурирующих изменения.
Обратите внимание: на втором шаге проблема была в авторизации, на третьем — в данных. Это разные проблемы с разными решениями, и если чинить только первую, вторая проявится позже, причём в менее заметной форме. Формы входа пользователь не пропустит, а устаревший адрес в карточке может пройти незамеченным до момента отправки посылки.
Что должно быть общим между узлами
Чтобы любой узел мог обслужить любой запрос авторизованного пользователя, у узлов должно быть общее понимание о нём. Способов обеспечить это немного, и у каждого своя цена.
Подписанный токен вместо записи
Вместо идентификатора, ведущего к серверной записи, можно выдавать cookie, внутри которой всё необходимое: идентификатор пользователя, срок действия, права, плюс подпись. Любой узел, знающий секрет подписи, проверит её без обращения куда-либо. Это красиво: ни общего хранилища, ни межокеанских запросов на каждый хит.
Но «знающий секрет» означает, что секрет должен быть одинаковым на всех площадках и должен там как-то оказаться. Ротация превращается в отдельную операцию: если вы сменили ключ на одном узле, а на другом ещё нет, токены, выпущенные первым, будут отвергаться вторым. Поэтому при ротации нужно временно принимать и старый, и новый ключ, а выпускать только новый. Если секрет утёк с одного узла, он скомпрометирован для всех сразу.
Ещё одно ограничение: токен нельзя «отозвать» просто так. Пока подпись валидна и срок не вышел, любой узел его примет. Чтобы всё же реализовать выход и блокировку пользователя, нужен либо короткий срок жизни токена, либо список отозванных, который опять же должен быть общим. Так что чисто бессерверный вариант на практике часто превращается в гибрид.
Общее хранилище сессий
Второй путь: все узлы обращаются к одному хранилищу сессий. Тогда cookie остаётся простым идентификатором, отзыв сеанса — удаление одной записи, а выход действует мгновенно везде. Цена — расстояние. Если хранилище находится во Франции, то американский узел ходит туда через океан на каждый запрос, где нужна авторизация. Задержка складывается с остальной обработкой. Если хранилище станет недоступным, потеряют вход все площадки сразу, а не одна.
Такое хранилище становится критичной точкой: вы разнесли приложение по картам, но вход зависит от одного места. Это не значит, что так делать нельзя. Это значит, что нужно понимать: географическое разнесение веб-узлов само по себе не даёт устойчивости, если под ними один общий компонент.
Реплицируемое хранилище
Третий путь — держать копию хранилища на каждой площадке и синхронизировать их. Чтения становятся локальными и быстрыми, но появляется окно рассинхронизации. Если пользователь вошёл на одном узле и в ту же секунду пришёл на другой, запись о сеансе может ещё не доехать.
Документация PostgreSQL про warm standby хорошо описывает суть выбора. При асинхронной передаче WAL остаётся окно, в котором уже подтверждённые изменения ещё не переданы standby и при аварии будут потеряны. Синхронная фиксация, напротив, может ждать подтверждения от standby, то есть каждая запись становится медленнее на время обмена между площадками. Кроме того, пропускная способность канала должна превышать скорость образования WAL, а после разрыва связи реплика должна догнать основной сервер. Это описание про базу данных, но логика переносится на любое реплицируемое хранилище: либо вы платите задержкой за каждое изменение, либо принимаете окно, в котором площадки видят разное.
Сведём варианты в таблицу.
| Подход | Что получаете | Что платите |
|---|---|---|
| Подписанный токен | Проверка на любом узле без обращений к хранилищу | Одинаковый секрет везде, сложная ротация, трудный немедленный отзыв |
| Одно общее хранилище сессий | Мгновенный выход и блокировка, простая модель | Задержка для удалённых узлов, общая точка отказа |
| Реплики хранилища на каждой площадке | Быстрые локальные чтения | Окно рассинхронизации, потеря недавних записей при аварии, нагрузка на канал |
Выбор зависит от того, что для вашего сервиса страшнее. Для блога с личным кабинетом, где потеря сеанса означает просто повторный вход, подойдёт самый простой вариант. Для платёжной формы, где неверно прочитанное состояние означает деньги, нужна строгая модель, даже если она медленнее.
Липкая маршрутизация: что она обещает, а что нет
Самое популярное быстрое решение — «приклеить» пользователя к одному узлу, чтобы все его запросы шли в одно место. Тогда локальные сессии вроде бы работают: человек вошёл на узле А и дальше всё время общается с узлом А.
В документации модуля upstream в NGINX описан один из таких механизмов, ip_hash: он выбирает узел по адресу клиента и направляет его туда же повторно, пока этот узел доступен. Тут же сказано и то, что при недоступности выбранного сервера запрос может попасть на другой. Эта оговорка важнее, чем кажется.
Липкость решает ровно одну задачу: уменьшает вероятность, что два последовательных запроса пойдут на разные узлы. Она не делает узлы согласованными. Разберём, что ломается.
- Отказ узла. Если узел, где жила локальная сессия, упал или его вывели из ротации, пользователь уходит на другой, где его записи нет. Липкость не копирует состояние, она лишь откладывает проблему до первой аварии. Хуже всего, что в спокойное время всё выглядит прекрасно, и недостаток вскрывается именно тогда, когда у вас и без того есть дела.
- Смена адреса клиента. Если привязка идёт по IP, то пользователь, переключившийся с домашнего Wi-Fi на мобильную сеть, получает другой адрес и может перейти на другой узел посреди сеанса.
- Много людей за одним адресом. Офис или оператор с общим исходящим адресом будут направлены на один узел целиком. Распределение нагрузки получается неравномерным именно там, где пользователей больше всего.
- Данные. Даже если пользователь прилип к узлу, его данные должны быть доступны и остальным. Другие люди, администраторы, фоновые задачи читают и пишут те же записи с других площадок. Липкость сессии ничего не говорит о синхронизации данных.
Поэтому честная формулировка такая: липкая маршрутизация — оптимизация поверх согласованного состояния, а не замена ему. Если без неё система перестаёт работать, значит, вы строите на хрупкой основе. Если с ней система просто работает быстрее, потому что кеши на узле остаются тёплыми, а лишних обращений к общему хранилищу меньше, — это нормальное применение.
Заметим и другое: липкость и география — не одно и то же. Выбор ближайшей площадки по расположению пользователя и закрепление пользователя за узлом — две разные идеи, которые могут противоречить друг другу. Человек в поездке по правилам географии должен перейти на другую площадку, а по правилам липкости обязан остаться на прежней. Что победит, нужно решить заранее.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Выход из системы — проверка на честность
Хороший способ проверить, понимаете ли вы свою схему, — спросить, что происходит при выходе. Пользователь нажимает «Выйти» на узле во Франции. Что должно случиться с его сеансом, который так же валиден на американском узле?
При общем хранилище запись удаляется, и все узлы немедленно перестают принимать этот идентификатор. При реплицируемом хранилище есть короткий промежуток, в котором американский узел ещё считает сеанс живым. При подписанных токенах без списка отзыва выход сводится к удалению cookie в браузере, а сам токен остаётся валидным до истечения срока: украденную копию можно использовать дальше.
Для некоторых сайтов это допустимо, для других нет. Принцип прост: чем чувствительнее действия, тем короче должно быть окно между «выход» и «токен больше не принимается». Для критичных операций, вроде смены пароля или платежа, разумно перепроверять состояние в авторитетном источнике, а не полагаться на срок жизни токена.
То же относится к блокировке пользователя администратором и к смене пароля: оба события должны инвалидировать существующие сеансы, и вам нужно знать, как быстро эта информация достигает всех площадок.
У каждой записи должен быть один хозяин
С сессиями разобрались. Теперь о данных пользователя: профиль, заказы, настройки, документы. Здесь главный вопрос: кто имеет право записывать?
Если две площадки могут одновременно менять одну и ту же запись, то при задержке репликации они неизбежно разойдутся. Тогда придётся решать конфликты: «выигрывает последняя запись», слияние полей, ручной разбор. Каждое из этих правил где-то теряет данные или удивляет пользователя. Самый простой способ избежать конфликтов — не допустить их: у каждой записи или группы записей есть одна площадка-владелец, и только она принимает изменения.
Как выбрать владельца, зависит от модели данных.
- Одна основная площадка для всего. Все записи идут в одно место, остальные узлы читают с реплик или перенаправляют запись туда. Это самый понятный вариант. Он оправдан, когда пользователи распределены, но записей мало по сравнению с чтениями.
- Домашний регион пользователя. Каждый пользователь «приписан» к площадке при регистрации, и его данные меняются только там. Остальные узлы либо пересылают запись домой, либо отдают чтение с реплики. Это хорошо ложится на сервисы, где пользователи редко пересекаются между собой.
- Разделение по типу данных. Профили живут в одном месте, заказы в другом. Подход рабочий, но требует внимания к связям между ними: заказ ссылается на профиль, который находится за океаном.
Теперь вернёмся к сценарию с адресом доставки. При модели «домашний регион» пользователь из Европы приписан к французской площадке. Если его запрос случайно попал на американский узел, тот не записывает адрес у себя, а пересылает изменение владельцу и сообщает пользователю результат только после подтверждения. Запрос становится медленнее на время пересылки, но данные не расходятся. Такой компромисс обычно лучше обратного.
Остаётся вопрос чтения после записи. Пользователь изменил адрес, и следующая страница читает с локальной реплики, куда изменение ещё не дошло. Чтобы он не увидел старое значение, есть несколько приёмов. Можно на короткое время после записи направлять чтения этого пользователя к владельцу. Можно вернуть из операции записи некоторую отметку версии и требовать от реплики, чтобы она была не старше этой отметки. Можно просто показывать новое значение из ответа на запись, пока реплика не догонит. Ни один из приёмов не бесплатен: все они усложняют код и маршрутизацию, но все лучше, чем ситуация, когда пользователь видит собственное изменение исчезнувшим.
Что происходит при аварии и сколько вы готовы потерять
Схема с одним владельцем записи хороша в штатном режиме. Проверка настоящая начинается, когда владелец недоступен. Допустим, площадка с основной базой выпала. Вы переводите резервную реплику в основной режим, и приложение продолжает работать.
Но вспомним документацию по PostgreSQL: при асинхронной передаче WAL изменения, не успевшие уйти на standby, будут потеряны. В нашем сценарии это означает, что последний адрес, который пользователь сохранил за секунды до аварии, может исчезнуть. Сессии, созданные в последние секунды, тоже. Что хуже, пользователь получил подтверждение «сохранено», а данных нет.
Синхронная фиксация убирает это окно, но заставляет каждую запись ждать подтверждения от реплики. Если реплика на другом континенте, каждое сохранение становится ощутимо медленнее. Если реплика недоступна, запись может вообще останавливаться, пока вы не примете решение. Тут нет правильного ответа для всех, есть только вопрос: что для вас хуже — медленная запись или потерянная?
Есть и вторая сторона. После восстановления связи реплика должна догнать основной сервер, а для этого пропускная способность канала должна превышать скорость, с которой образуются изменения. Если канал между площадками едва справляется в обычный день, то после перерыва реплика может догонять очень долго, и всё это время вы живёте с повышенным риском. Поэтому при планировании нескольких площадок полезно заранее прикинуть объём изменений в час и сопоставить с реальной пропускной способностью соединения между ними.
Важный вывод: несколько адресов на одном домене не создают согласованный бэкенд. DNS может перечислить сколько угодно серверов, но он ничего не знает о том, видят ли они одни и те же данные. Отказоустойчивость появляется не от числа площадок, а от продуманного хранения состояния, переключения и проверки этого переключения.
Теперь можно говорить о карте
Только после того как определены сессии и владельцы данных, имеет смысл решать, куда направлять запросы. Тут полезно разделить трафик на классы, потому что у них разные требования.
| Класс запроса | Зависит от состояния пользователя? | Куда направлять |
|---|---|---|
| Публичные страницы и статика | Нет | На ближайшую площадку, ограничений почти нет |
| Авторизованное чтение | Да, допускает небольшое отставание | Ближайшая площадка с локальной репликой и общим знанием о сеансе |
| Авторизованная запись | Да, требует единственного владельца | К владельцу записи, либо через пересылку |
| Чувствительные операции: платёж, смена пароля, выход | Да, нужна свежая проверка | К авторитетному источнику, даже если он далеко |
Такая таблица меняет сам вопрос. Вместо «в какую страну отправлять пользователя?» получается «какой запрос можно обслужить рядом, а какой должен дойти до владельца данных?». Публичные страницы выигрывают от близости полностью. Чтения с авторизацией выигрывают, если вы готовы терпеть небольшое отставание или применять приёмы чтения после записи. Записи же ускорить географией почти нельзя: они определяются местом, где живёт владелец.
Практически это означает, что для приложений с активной записью выигрыш от нескольких площадок меньше, чем ожидают. Задержка на запись всё равно включает путь до владельца. Выгода реальна там, где преобладают чтения, много статики, или где разные группы пользователей естественно прикреплены к разным регионам и почти не пересекаются.
Есть и соображения помимо скорости. Расположение площадки может влиять на доступность внешних сервисов для вашего приложения и на правовые вопросы, связанные с хранением данных. Эти вопросы нужно проверять отдельно для вашего случая: сам факт размещения сервера в какой-то стране не решает их автоматически.
Как это ложится на VPS
На практике каждая площадка — это отдельный сервер, и его ресурсы нужно прикидывать исходя из роли, а не из количества локаций. VPS MAATRIX доступны в локациях UK, US, RU и FR, на NVMe-накопителях с процессорами AMD EPYC и с root-доступом. Это даёт свободу настроить хранилище сессий, реплику и прокси так, как требует ваша схема, а не подстраиваться под готовую обёртку.
Роли узлов стоит разделять сознательно. Веб-узел, который только принимает запросы и ходит за состоянием в другое место, нагружает процессор и сеть. Узел с базой данных или хранилищем сессий упирается в диск, память и пропускную способность соединения с репликами. Тарифы начинаются с Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и идут через Uab (2 vCPU, 4 ГБ, 80 ГБ), Heka (4 vCPU, 8 ГБ, 160 ГБ) и Maat (8 vCPU, 16 ГБ, 240 ГБ) до Djed (16 vCPU, 32 ГБ, 320 ГБ). Эти параметры — отправная точка для теста, а не обещание ёмкости: сколько одновременных пользователей выдержит ваша связка, покажет только нагрузочная проверка с реальным сценарием входа, просмотра и изменения.
Один момент нужно обозначить прямо: два разных VPS не дают приложению отказоустойчивость сами по себе. Они дают вам два места, где можно запустить компоненты. Устойчивость появляется, когда вы продумали, что происходит с сеансами и записями при потере любого из них, и проверили это на практике, например, остановив один узел во время тестового сеанса и посмотрев, что увидит пользователь. Для оценки нагрузки на узлы при совместной работе нескольких людей может пригодиться и рассуждение из статьи о выборе сервера под несколько пользователей: там тот же принцип планирования по роли и сценарию, а не по числу клиентов.
Порядок решений, который экономит нервы
Если собрать всё сказанное в последовательность, получится такой порядок, где каждый шаг опирается на предыдущий.
- Определите, где хранится состояние входа: в подписанном токене, в общем хранилище или в реплицируемом. Выберите по тому, насколько быстро должен срабатывать выход и блокировка.
- Решите, как узлы получают общие секреты и как вы меняете их без разрыва сеансов.
- Назначьте владельца каждой группе записей: единая основная площадка или домашний регион пользователя.
- Определите допустимое окно отставания реплик и выберите, как пользователь увидит собственное изменение сразу после записи.
- Продумайте аварию: что теряется при переключении, как реплика догоняет и сколько на это нужно времени.
- И лишь теперь расписывайте карту: что идёт на ближайшую площадку, что к владельцу, что к авторитетному источнику.
Проверку можно провести руками за один вечер: войдите на одном узле, откройте страницу через другой, измените запись через третий и вернитесь на первый. Потом выйдите на одном узле и проверьте, принимает ли другой старую cookie. Затем остановите узел во время сеанса. Каждое из этих действий вскрывает конкретное допущение, которое иначе вскрылось бы у пользователей.
Тот самый человек из начала статьи на самом деле не жаловался на географию. Он столкнулся с тем, что две площадки по-разному ответили на один и тот же вопрос: «кто это?». Пока этот вопрос не получил единственного ответа, любая карта маршрутов будет лишь красиво раскрашенной путаницей. Сначала договоритесь о том, что узлы знают о пользователе, кто вправе менять его данные и как долго допустимо расхождение. После этого распределять запросы по карте становится простой инженерной задачей: вы просто выбираете, насколько близко к человеку можно обслужить каждый из его запросов.