Всем привет! На связи Михаил Поливаха, технический лидер 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 так не получится
В реактивном приложении всё иначе:
Нет привязки запроса к потоку. Запрос может начать обрабатываться на одном потоке event loop Netty, а закончить на другом.
ThreadLocal, выставленный в фильтре, эндпоинт просто не увидит. Наверняка среди читателей есть продвинутые пользователи Project Reactor и они знают, что у реактивного стека есть свой собственный Context API. В целом можно было бы использовать и его, но с ним будет много подводных камней и ограничений. Поэтому мы решили этого не делать.Event loop нельзя блокировать. Потоков
reactor-http-nio-*столько же, сколько ядер. Есть некоторые операции, которые предоставляет Axelix, которые по природе блокирующие, например, снятие heap dump-а на приложении, или рендеринг Thread Dump на живом сервисе (эта проблема кстати не только для нас специфична. Чуть ниже поймете, что в таких ситуациях делает Spring Boot).Сервлетных фильтров нет. Вместо
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, которую мы ранее подметили. Иерархия контекстов вообще отдельная большая тема, возможно, стоит её осветить в отдельной статье. Пишите, если интересно
