Липкие сессии с Apache APISIX – демонстрация
Концепция липких сессий (sticky sessions): вы направляете запрос на определённый апстрим, потому что в этом узле есть контекстные данные, связанные с сессией. Однако при необходимости вы должны скопировать данные на другие апстримы, поскольку этот может выйти из строя. В этом посте мы собираемся проиллюстрировать это на примере.
Общая конструкция
Варианты конструкции безграничны. Я ограничусь знакомым стеком - JVM. Помимо того, что уже упоминалось в предыдущем посте, липкие сессии следует реализовывать только с репликацией сессий. Окончательная конструкция состоит из двух компонентов: экземпляра Apache APISIX с настроенными липкими сессиями и двух узлов JVM, на которых выполняется одно и то же приложение с репликацией сессий.
В приложении используется следующее:
Зависимость | Описание |
Spring Boot | Облегчает использование библиотек Spring |
Spring MVC | Позволяет предлагать конечные точки HTTP |
Thymeleaf | Показывает технологию |
Spring Session | Предлагает абстракцию для репликации сессий |
Hazelcast (встроенный) | Осуществляет репликацию сессий |
Spring Security | Привязывает идентификатор к сеансу пользователя |
Конструкция выглядит следующим образом:
app1
и app2
— это два экземпляра одного и того же приложения. Я не хотел перегружать диаграмму избыточными данными.
Основа приложения
Основой приложения является скопированный в сессию бин, который является оболочкой счетчика, значение которого может быть только увеличено:
@Component
@SessionScope
public class Counter implements Serializable { //1
private int value = 0;
public int getValue() {
return value;
}
public void incrementValue() {
value++;
}
}
- Необходимо для осуществления сериализации Hazelcast
Мы можем использовать этот бин в контроллере:
@Controller
public class IndexController {
private final Counter counter;
public IndexController(Counter counter) { //1
this.counter = counter;
}
@GetMapping("/")
public String index(Model model) { //2
counter.incrementValue();
model.addAttribute("counter", counter.getValue());
return "index";
}
}
- Внедрите скопированный в сессии бин в контроллер синглтона с помощью Spring
- Когда мы отправляем запрос
GET
к руту, мы увеличиваем значение счетчика и передаем его в модель.
Наконец, мы отображаем значение бина на странице Thymeleaf:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<body>
<div th:text="${counter}">3</div>
Конфигурирование Spring Session с помощью Hazelcast
Spring Session предлагает фильтр, который оборачивает исходный HttpServletRequest
, чтобы переопределить метод getSession()
. Этот метод возвращает конкретную реализацию Session
, подкрепленную реализацией, настроенной с помощью Spring Session, в нашем случае с помощью Hazelcast.
Для настройки Spring Session с помощью Hazelcast нам потребуется всего несколько настроек.
Во-первых, аннотируйте класс приложения Spring Boot соответствующей аннотацией:
@SpringBootApplication
@EnableHazelcastHttpSession
public class SessionApplication {
...
}
Hazelcast также требует определенной конфигурации. Мы можем использовать XML, YAML или код. Поскольку это демоверсия, я могу выбрать всё, что захочу, поэтому давайте напишем код. Spring Boot требует либо объект Hazelcast, либо объект конфигурации. Последнего будет достаточно:
@Bean
public Config hazelcastConfig() {
var config = new Config();
var networkConfig = config.getNetworkConfig();
networkConfig.setPort(0); //1
networkConfig.getJoin().getAutoDetectionConfig().setEnabled(true); //2
var attributeConfig = new AttributeConfig() //3
.setName(HazelcastIndexedSessionRepository.PRINCIPAL_NAME_ATTRIBUTE)
.setExtractorClassName(PrincipalNameExtractor.class.getName());
config.getMapConfig(HazelcastIndexedSessionRepository.DEFAULT_SESSION_MAP_NAME) //3
.addAttributeConfig(attributeConfig)
.addIndexConfig(new IndexConfig(
IndexType.HASH,
HazelcastIndexedSessionRepository.PRINCIPAL_NAME_ATTRIBUTE
));
var serializerConfig = new SerializerConfig();
serializerConfig.setImplementation(new HazelcastSessionSerializer()) //3
.setTypeClass(MapSession.class);
config.getSerializationConfig().addSerializerConfig(serializerConfig);
return config;
- Выберите случайный порт, чтобы избежать несостыковки портов.
- Разрешите Hazelcast искать другие экземпляры и автоматически формировать кластер. Это будет необходимо при развертывании в соответствии с нашей конструкцией.
- Скопировано из документации Spring Session.
Конфигурирование Spring Security
Большинство примеров Spring Session так или иначе используют Spring Security, и, хотя это не является строго необходимым, это облегчает проектирование. Сначала я хочу объяснить, почему.
Можно рассматривать сессии как гигантскую хэш-таблицу. В обычных приложениях ключом является значение куки JSESSIONID
, значение - другая хэш-таблица данных сессии. Однако JSESSIONID
специфичен для узла. Приложение выдаст другой JSESSIONID
, если использовать другой узел. Поскольку ключ отличается, нет возможности получить доступ к данным сессии, даже если данные сессии распределяются между узлами. Чтобы предотвратить эту потерю, нам нужно придумать другой ключ. Spring Security позволяет использовать принципал или имя пользователя в качестве ключа данных сессии.
Вот как я установил базовую конфигурацию Spring Security:
@Bean
public SecurityFilterChain securityFilterChain(UserDetailsService service, HttpSecurity http) throws Exception {
return http.userDetailsService(service) //1
.authorizeHttpRequests(authorize -> authorize.requestMatchers(
PathRequest.toStaticResources().atCommonLocations()) //2
.permitAll() //2
.anyRequest().authenticated() //3
).formLogin(form -> form.permitAll()
.defaultSuccessUrl("/") //4
).build();
}
- 1. Служба сведений о пользователе в памяти по умолчанию не позволяет использовать пользовательские классы сведений о пользователе. Мне пришлось создать свой собственный.
- Необходимо разрешить всем доступ к статическим ресурсам в "общих" местах.
- Все остальные запросы должны быть аутентифицированы.
- Необходимо разрешить всем доступ к форме аутентификации.
- В случае успеха перенаправить на корень, который отображает вышеуказанный контроллер.
Испытание нашей конструкции
Помимо счётчика, я хочу отобразить два дополнительных элемента данных: имя хоста и вошедшего пользователя.
Для имени хоста я добавляю в контроллер следующий метод:
@ModelAttribute("hostname")
private String hostname() throws UnknownHostException {
return InetAddress.getLocalHost().getHostName();
}
Отображение вошедшего в систему пользователя требует дополнительной зависимости:
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
</dependency>
На странице все предельно просто:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"> <!--1-->
<body>
<td sec:authentication="principal.label">Me</td> <!--2-->
- Добавьте пространство имен sec. Это не обязательно, но может помочь IDE помочь Вам.
- Требуйте, чтобы базовая реализация
UserDetail
имела методgetLabel()
.
И последнее, но не менее важное: нам нужно настроить Apache APISIX с липкими сессиями, как мы могли видеть на прошлой неделе:
routes:
- uri: /*
upstream:
nodes:
"webapp1:8080": 1
"webapp2:8080": 1
type: chash
hash_on: cookie
key: cookie_JSESSIONID
#END
Вот дизайн, реализованный на Docker Compose:
services:
apisix:
image: apache/apisix:3.3.0-debian
volumes:
- ./apisix/config.yml:/usr/local/apisix/conf/config.yaml:ro
- ./apisix/apisix.yml:/usr/local/apisix/conf/apisix.yaml:ro #1
ports:
- "9080:9080" #2
depends_on:
- webapp1
- webapp2
webapp1:
build: ./webapp
hostname: webapp1 #3
webapp2:
build: ./webapp
hostname: webapp2
- Используйте предыдущий файл конфигурации
- Выставите шлюз API только для внешнего мира
- Установите имя хоста, чтобы отобразить его на странице.
Мы можем войти в систему, используя одну из двух заданных учётных записей. Я использую john
, с паролем john
и меткой John Doe
. Обратите внимание, что Apache APISIX направляет меня на определённый узел и продолжит использовать этот же самый узел, если я обновлю.
Мы можем попробовать войти в систему с другой учетной записью (jane
/jane
) из личного окна и проверить, что счётчик начинается с 0.
Теперь начинается самое интересное. Давайте остановим узел, который должен хранить данные сессии, здесь это узел webapp2
, и обновим страницу:
docker compose stop webapp2
В журналах мы можем увидеть интересные вещи. Apache APISIX больше не может найти webapp2
, поэтому он перенаправляет запрос на другой известный ему апстрим, webapp1
.
- Запрос по-прежнему аутентифицирован; он проходит через Spring Security.
- Фреймворк получает главного пользователя из запроса.
- Фреймворк запрашивает Spring Session.
- Фреймворк получает правильное значение счетчика, которое Hazelcast реплицировал с другого узла.
Единственным побочным эффектом является увеличение задержки из-за таймаута Apache APISIX. По умолчанию он составляет 5 секунд, но при необходимости вы можете настроить его на меньшее значение.
Когда мы снова запускаем webapp2
, всё работает в соответствии с ожиданиями.
Заключение
В этом посте я описал возможную настройку для липких сессий с Apache APISIX и репликации с использованием экосистемы Spring и Hazelcast. В зависимости от выбранного вами стека и фреймворка возможно множество других вариантов.
Полный исходный код этой заметки можно найти на GitHub: