Axelix 1.3 будет поддерживать реактивный стек

—

от автора

Всем привет! На связи Михаил Поливаха, технический лидер Open Source проекта Axelix (give us a star!).

Я долго думал, сделать это статьей или новостью, но для новости данный поток сознания слишком большой. Поэтому, всё же, решили выпустить новость 🙂

В ноябре выходит Axelix 1.3. Одно из изменений релиза заключается в том, что Spring Boot стартеры Axelix начнут нативно работать в приложениях на реактивном стеке, то есть на Spring WebFlux с Reactor Netty. Раньше подключить стартер к такому сервису было нельзя. Теперь реактивные приложения можно будет мониторить и отлаживать из Axelix Master так же, как сервлетные.

Это одна из самых частых просьб пользователей, спасибо всем, кто писал нам о ней в Telegram-канале и в issues. Я лично встертил на ряде конференций как минимум 4 команды, которые просили добавить поддержку Project Reactor-а. Мы оценили масштаб поражения

Ниже расскажем, как это устроено внутри и почему мы сделали почти так же, как сам Spring Boot Actuator.

Что изменится для пользователя

Ничего настраивать не придётся, достаточно будет добавить стартер в WebFlux-приложение:

dependencies {    implementation("org.springframework.boot:spring-boot-starter-webflux")    implementation("org.springframework.boot:spring-boot-starter-actuator")    implementation("com.axelixlabs:axelix-spring-boot-4-starter:1.3.0")}

Стартер сам определит тип веб-приложения (SERVLET или REACTIVE) и поднимет нужную инфраструктуру. После старта сервис появится в Axelix Master, и в UI будут доступны бины, environment, configuration properties, логгеры и остальные разделы.

Немного контекста: как стартер работал раньше

Стартер Axelix это по большому счету набор собственных Actuator-эндпоинтов (/actuator/axelix-env, /actuator/axelix-configprops и т.д.) и набор RBAC инфраструктуры, который ее защищает.

Hidden text

P.S: Мы уже много, *много* раз пожалели о том, что стали строить Axelix путем добавления кастомных Actuator endpoints. Думаю, скоро мы поднимем ворпос о том, чтобы это поменять в общем и целом.

Master ходит в эти эндпоинты с токеном, фильтр проверяет токен и права пользователя.

Права важны не только для того, чтобы пустить или не пустить запрос. Например, axelix-env маскирует значения свойств, если у пользователя нет конкретной authority, а именно ENV_VALUES_READ. Например, очевидно, что Spring-овый enviornment может и часто содержит сенсативные вещи, такие как API ключи, УЗ к базам данных и т.д. Само собой, тут нужен RBAC, и Axelix его дате из коробки.

Но вопрос в другом. Из прмиера c env я думаю, уже понятно, что нашим эндпоинтам во время работы нужно знать, кто его вызвал.

На сервлетном стеке это решается довольно просто: один запрос обрабатывается одним потоком, поэтому фильтр кладёт контекст безопасности в ThreadLocal, а эндпоинт ниже по цепочке его оттуда читает:

// JwtAuthorizationFilter (Servlet)User user = webIdentityAccessManager.verifyAccess(relativePath, method, token);securityContextExecutor.runWithinSecurityContext(        () -> filterChain.doFilter(request, response),   // весь запрос в одном потоке        new DefaultSecurityContext(user, token));
 Servlet: thread-per-request ─────────────────────────────────────────────────────────────────  http-nio-exec-7 │ JwtFilter ──► ThreadLocal.set(ctx)                  │      │                  │      ▼                  │ /actuator/axelix-env ──► ThreadLocal.get() ✔                  │      │                  │      ▼                  │ ThreadLocal.clear() ─────────────────────────────────────────────────────────────────

Эта модель совершенно не новая, сам Spring Framework использует её активно, в том числе Spring Security использует её для хранения SecurityContext.

Почему на WebFlux так не получится

В реактивном приложении всё иначе:

  1. Нет привязки запроса к потоку. Запрос может начать обрабатываться на одном потоке event loop Netty, а закончить на другом. ThreadLocal, выставленный в фильтре, эндпоинт просто не увидит. Наверняка среди читателей есть продвинутые пользователи Project Reactor и они знают, что у реактивного стека есть свой собственный Context API. В целом можно было бы использовать и его, но с ним будет много подводных камней и ограничений. Поэтому мы решили этого не делать.

  2. Event loop нельзя блокировать. Потоков reactor-http-nio-* столько же, сколько ядер. Есть некоторые операции, которые предоставляет Axelix, которые по природе блокирующие, например, снятие heap dump-а на приложении, или рендеринг Thread Dump на живом сервисе (эта проблема кстати не только для нас специфична. Чуть ниже поймете, что в таких ситуациях делает Spring Boot).

  3. Сервлетных фильтров нет. Вместо OncePerRequestFilter и FilterRegistrationBean там WebFilter, а вместо HttpServletRequest используется ServerWebExchange.

 WebFlux: event loop, без ThreadLocal ─────────────────────────────────────────────────────────────────  reactor-http-nio-2 │ JwtFilter ──► ThreadLocal.set(ctx)                     │      │                     │      ▼  (продолжение может уйти на другой поток)  reactor-http-nio-5 │ /actuator/axelix-env ──► ThreadLocal.get() ✘ null                     │                         + блокирующая работа                     │                           на event loop ✘ ─────────────────────────────────────────────────────────────────

Как Spring Boot Actuator решает ту же проблему

Стандартные эндпоинты Actuator (/actuator/threaddump, /actuator/heapdump, и т.д.) тоже само собой блокирующие, но в WebFlux-приложениях работают без особоых проблем. Делается это двумя приёмами:

  • Блокирующие операции уходят с event loop. Если операция эндпоинта возвращает обычный объект, а не Mono/Flux, Actuator вызывает её через Mono.fromCallable(...) и subscribeOn(Schedulers.boundedElastic()), то есть в отдельном пуле, где блокироваться в целом можно.

  • Пользователь берётся из exchange, а не из потока. Аутентифицированный пользователь достаётся из ServerWebExchange.getPrincipal() и передаётся в операцию явно, как часть контекста вызова.

В Axelix мы просто сделали так же, как и Spring Boot.

Как это будет устроено в Axelix 1.3

1. Отдельный реактивный фильтр

Стартер регистрирует один из двух фильтров в зависимости от типа приложения:

@Configuration(proxyBeanMethods = false)@ConditionalOnWebApplication(type = Type.SERVLET)static class ServletJwtAuthConfiguration {         // FilterRegistrationBean<JwtAuthorizationFilter>}​@Configuration(proxyBeanMethods = false)@ConditionalOnWebApplication(type = Type.REACTIVE)static class ReactiveJwtAuthConfiguration {         @Bean     ReactiveJwtAuthorizationFilter reactiveJwtAuthorizationFilter(...) { ... }}

Как и раньше, фильтр регистрируется в том контексте, который обслуживает Actuator: в основном или в дочернем management-контексте, если задан отдельный management.server.port.

Вообще иерархия контекстов в Spring Boot это отдельная большая тема, мы даже нечаянно из-за нее получили CVE в Axelix, но быстро пофиксили в 1.2.1.

Для тех, кто не знает, контексты в Spring Framework в целом представляют собой иерархию. В большинстве традиционных Spring Boot приложений вы работаете в рамках одного единственного контекста. Но в теории есть способы создать эту иерархию (намерянно или нет). Один из них это как раз специфицирование Spring Boot Actuator на стороннем порту, как описано выше.

Для многих чаще всего проблема в таком случае возникает в той ситуации, когда есть, например, бин Х, и он находится в дочернем контексте, в родительском его нет (частый кейс).

Когда бины из дочернего контекста пытаются найти этот бин Х, они его находят без проблем. А вот когда бин Y из родительского контекста пытается найти этот бин X, то он его не находит. Поиск работает снизу вверх, но не сверху вниз. Это может привести к различного рода интересным исходам, например, к CVE, которую мы ранее подметили. Иерархия контекстов вообще отдельная большая тема, возможно, стоит её осветить в отдельной статье. Пишите, если интересно 😉.

2. Контекст безопасности едет вместе с запросом

ReactiveJwtAuthorizationFilter проверяет токен и права так же, как сервлетный, но результат не кладёт в ThreadLocal. Он упаковывает его в нативный Java-овый Principal и подменяет его в exchange:

User user = webIdentityAccessManager.verifyAccess(relativePath, method, token);var principal = new AxelixPrincipal(new DefaultSecurityContext(user, token));ServerWebExchange axelixExchange = exchange.mutate()        .request(new BoundedElasticBodyRequest(request))  // тело запроса тоже читаем не на event loop        .principal(Mono.just(principal))                  // контекст едет вместе с запросом        .build();return chain.filter(axelixExchange)        .subscribeOn(Schedulers.boundedElastic());        // уходим с event loop, как Actuator

Ошибки токена (просрочен, невалиден, не парсится) дают 401, нехватка прав даёт 403, как и в сервлетной версии.

3. Эндпоинт восстанавливает контекст из Principal

Эндпоинт получает Principal параметром и выполняет свою логику внутри контекста безопасности, который в нём лежит:

@ReadOperationpublic EnvironmentFeed environment(@Nullable String pattern, @Nullable Principal principal) {    return AxelixPrincipal.callWithin(            principal, securityContextExecutor,            () -> environmentService.getEnvironmentFeed(pattern));}

callWithin ставит контекст на время вызова, уже на потоке boundedElastic, и снимает его после. Бизнес-логика стартера (маскирование значений, проверка прав) остается той же и не знает, на каком стеке работает.

Как итог, мы не стали писать отдельную реактивную реализацию сервисов (чего на самом деле так сильно боялись, т.к. это отдельная большая история). Изменился только способ доставить контекст до них.

Заключение

Изменения из этой статьи лежат в PR #1812.

Если у вас WebFlux в проде, будем рады, если поделитесь своими юз кейсами, мы правда на это обращаем внимание. Пишите, что ещё хотите увидеть в Axelix: в Telegram или в GitHub Issues.

Всем спасибо!

ссылка на оригинал статьи https://habr.com/ru/articles/1098680/