業務系統正常執行的穩定性十分重要,作為SpringBoot的四大核心之一,Actuator讓你時刻探知SpringBoot服務執行狀態資訊,是保障系統正常執行必不可少的元件。
spring-boot-starter-actuator提供的是一系列HTTP或者JMX監控端點,通過監控端點我們可以獲取到系統的執行統計資訊,同時,我們可以自己選擇開啟需要的監控端點,也可以自定義擴充套件監控端點。
Actuator通過端點對外暴露的監控資訊是JSON格式資料,我們需要使用介面來展示,目前使用比較多的就是Spring Boot Admin或者Prometheus + Grafana的方式:Spring Boot Admin實現起來相對比較簡單,不存在資料庫,不能儲存和展示歷史監控資料;Prometheus(時序資料庫) + Grafana(介面)的方式相比較而言功能更豐富,提供歷史記錄儲存,介面展示也比較美觀。
相比較而言,Prometheus + Grafana的方式更為流行一些,現在的微服務及Kubernetes基本是採用這種方式的。但是對於小的專案或者單體應用,Spring Boot Admin會更加方便快捷一些。具體採用那種方式,可以根據自己的系統運維需求來取捨,這裡我們把框架整合兩種方式,在實際應用過程中自有選擇。
本文主要介紹如何整合Spring Boot Admin以及通過SpringSecurity控制Actuator的端點許可權。
無論是使用Spring Boot Admin還是使用Prometheus + Grafana的方式都需要spring-boot-starter-actuator來獲取監控資訊,這裡將spring-boot-starter-actuator包新增到gitegg-platform-boot基礎平臺包中,這樣所有的微服務都整合了此功能。
<!-- spring boot 健康監控 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
spring-boot-admin-starter-server是Spring Boot Admin的伺服器端,我們需要新建一個SpringBoot工程來啟動這個伺服器端,用來接收需要監控的服務註冊,展示監控告警資訊。spring-boot-admin-starter-client是使用者端,需要被監控的服務需要引入這個依賴包。
此處請注意: 看到網上很多文章裡面寫著新增spring-boot-admin-starter-client包,在SpringCloud微服務中是不需要引入的,spring-boot-admin-starter-client包僅僅是為了引入我們gitegg-platform平臺工程的對應版本,在gitegg-boot框架中使用,在SpringCloud微服務架構中,不需要引入spring-boot-admin-starter-client,SpringBootAdmin會自動根據微服務註冊資訊查詢伺服器端點,官方檔案說明:spring-cloud-discovery-support 。
在選擇版本時,一定要找到對應SpringBoot版本的Spring Boot Admin,GitHub上有版本對應關係的說明:
我們在gitegg-platform-pom中來定義需要引入的spring-boot-admin-starter-server和spring-boot-admin-starter-client依賴包版本,然後在微服務業務開發中具體引入,這裡不做統一引入,方便微服務切換監控方式。
......
<!-- spring-boot-admin 微服務監控-->
<spring.boot.admin.version>2.3.1</spring.boot.admin.version>
......
<!-- spring-boot-admin監控 伺服器端 https://mvnrepository.com/artifact/de.codecentric/spring-boot-admin-starter-server -->
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-server</artifactId>
<version>${spring.boot.admin.version}</version>
</dependency>
<!-- spring-boot-admin監控 使用者端 https://mvnrepository.com/artifact/de.codecentric/spring-boot-admin-starter-client -->
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-client</artifactId>
<version>${spring.boot.admin.version}</version>
</dependency>.
......
<dependencies>
<!-- gitegg Spring Boot自定義及擴充套件 -->
<dependency>
<groupId>com.gitegg.platform</groupId>
<artifactId>gitegg-platform-boot</artifactId>
<!-- 去除gitegg-platform-boot預設的依賴-->
<exclusions>
<exclusion>
<groupId>com.gitegg.platform</groupId>
<artifactId>gitegg-platform-cache</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- gitegg Spring Cloud自定義及擴充套件 -->
<dependency>
<groupId>com.gitegg.platform</groupId>
<artifactId>gitegg-platform-cloud</artifactId>
</dependency>
<!-- security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<!-- 去除springboot預設的logback設定-->
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>de.codecentric</groupId>
<artifactId>spring-boot-admin-starter-server</artifactId>
</dependency>
</dependencies>
@EnableAdminServer
@SpringBootApplication
@RefreshScope
public class GitEggMonitorApplication {
public static void main(String[] args)
{
SpringApplication.run(GitEggMonitorApplication.class, args);
}
}
@Configuration(proxyBeanMethods = false)
public class SecuritySecureConfig extends WebSecurityConfigurerAdapter {
private final AdminServerUiProperties adminUi;
private final AdminServerProperties adminServer;
private final SecurityProperties security;
public SecuritySecureConfig(AdminServerUiProperties adminUi, AdminServerProperties adminServer, SecurityProperties security) {
this.adminUi = adminUi;
this.adminServer = adminServer;
this.security = security;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// 當設定了publicUrl時,Gateway跳轉到login或logout連結需要redirect到publicUrl
String publicUrl = this.adminUi.getPublicUrl() != null ? this.adminUi.getPublicUrl() : this.adminServer.getContextPath();
SavedRequestAwareAuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
successHandler.setTargetUrlParameter("redirectTo");
successHandler.setDefaultTargetUrl(publicUrl + "/");
http.authorizeRequests(
(authorizeRequests) -> authorizeRequests.antMatchers(this.adminServer.path("/assets/**")).permitAll()
.antMatchers(this.adminServer.path("/actuator/info")).permitAll()
.antMatchers(this.adminServer.path("/actuator/health")).permitAll()
.antMatchers(this.adminServer.path("/login")).permitAll().anyRequest().authenticated()
).formLogin(
(formLogin) -> formLogin.loginPage(publicUrl + "/login").loginProcessingUrl(this.adminServer.path("/login")).successHandler(successHandler).and()
).logout((logout) -> logout.logoutUrl(publicUrl + "/logout")).httpBasic(Customizer.withDefaults())
.csrf((csrf) -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.ignoringRequestMatchers(
new AntPathRequestMatcher(this.adminServer.path("/instances"),
HttpMethod.POST.toString()),
new AntPathRequestMatcher(this.adminServer.path("/instances/*"),
HttpMethod.DELETE.toString()),
new AntPathRequestMatcher(this.adminServer.path("/actuator/**"))
))
.rememberMe((rememberMe) -> rememberMe.key(UUID.randomUUID().toString()).tokenValiditySeconds(1209600));
}
/**
* Required to provide UserDetailsService for "remember functionality"
* @param auth
* @throws Exception
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser(security.getUser().getName())
.password("{noop}" + security.getUser().getPassword()).roles(security.getUser().getRoles().toArray(new String[0]));
}
}
spring:
boot:
admin:
ui:
brand: <img src="https://img2022.cnblogs.com/blog/460952/202207/460952-20220727124816822-208395561.png"><span>GitEgg微服務監控系統</span>
title: GitEgg微服務監控系統
favicon: https://img2022.cnblogs.com/blog/460952/202207/460952-20220727124816822-208395561.png
public-url: http://127.0.0.1:80/gitegg-admin-monitor/monitor
context-path: /monitor
server:
port: 8009
spring:
profiles:
active: '@spring.profiles.active@'
application:
name: '@artifactId@'
cloud:
inetutils:
ignored-interfaces: docker0
nacos:
discovery:
server-addr: ${spring.nacos.addr}
metadata:
# 啟用SpringBootAdmin時 使用者端端點資訊的安全認證資訊
user.name: ${spring.security.user.name}
user.password: ${spring.security.user.password}
config:
server-addr: ${spring.nacos.addr}
file-extension: yaml
extension-configs:
# 必須帶副檔名,此時 file-extension 的設定對自定義擴充套件設定的 Data Id 副檔名沒有影響
- data-id: ${spring.nacos.config.prefix}.yaml
group: ${spring.nacos.config.group}
refresh: true
- data-id: ${spring.nacos.config.prefix}-admin-monitor.yaml
group: ${spring.nacos.config.group}
refresh: true
SpringSecurity許可權驗證支援多過濾器設定,同時可設定驗證順序,我們這裡需要改造之前的過濾器,這裡新增Basic認證過濾器,通過securityMatcher設定,只有健康檢查的請求走這個許可權過濾器,其他請求繼續走之前我們設定的OAuth2+JWT許可權驗證器。
/**
* 許可權設定
* 註解需要使用@EnableWebFluxSecurity而非@EnableWebSecurity,因為SpringCloud Gateway基於WebFlux
*
* @author GitEgg
*
*/
@RequiredArgsConstructor(onConstructor_ = @Autowired)
@Configuration
@EnableWebFluxSecurity
public class MultiWebSecurityConfig {
private final AuthorizationManager authorizationManager;
private final AuthServerAccessDeniedHandler authServerAccessDeniedHandler;
private final AuthServerAuthenticationEntryPoint authServerAuthenticationEntryPoint;
private final AuthUrlWhiteListProperties authUrlWhiteListProperties;
private final WhiteListRemoveJwtFilter whiteListRemoveJwtFilter;
private final SecurityProperties securityProperties;
@Value("${management.endpoints.web.base-path:}")
private String actuatorPath;
/**
* 健康檢查介面許可權設定
* @param http
* @return
*/
@Order(Ordered.HIGHEST_PRECEDENCE)
@Bean
@ConditionalOnProperty( value = {"management.security.enabled", "management.endpoints.enabled-by-default"}, havingValue = "true")
SecurityWebFilterChain webHttpSecurity(ServerHttpSecurity http) {
if (StringUtils.isEmpty(actuatorPath))
{
throw new BusinessException("當啟用健康檢查時,不允許健康檢查的路徑為空");
}
http
.cors()
.and()
.csrf().disable()
.formLogin().disable()
.securityMatcher(new OrServerWebExchangeMatcher(
new PathPatternParserServerWebExchangeMatcher(actuatorPath + "/**"),
new PathPatternParserServerWebExchangeMatcher("/**" + actuatorPath + "/**")
))
.authorizeExchange((exchanges) -> exchanges
.anyExchange().hasAnyRole(securityProperties.getUser().getRoles().toArray(new String[0]))
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
/**
* 設定Basic認證使用者資訊
* @return
*/
@Bean
@ConditionalOnProperty( value = {"management.security.enabled", "management.endpoints.enabled-by-default"}, havingValue = "true")
ReactiveUserDetailsService userDetailsService() {
return new MapReactiveUserDetailsService(User
.withUsername(securityProperties.getUser().getName())
.password(passwordEncoder().encode(securityProperties.getUser().getPassword()))
.roles(securityProperties.getUser().getRoles().toArray(new String[0]))
.build());
}
/**
* 設定密碼編碼
* @return
*/
@Bean
@ConditionalOnProperty( value = {"management.security.enabled", "management.endpoints.enabled-by-default"}, havingValue = "true")
public static PasswordEncoder passwordEncoder() {
DelegatingPasswordEncoder delegatingPasswordEncoder =
(DelegatingPasswordEncoder) PasswordEncoderFactories.createDelegatingPasswordEncoder();
return delegatingPasswordEncoder;
}
/**
* 路由轉發許可權設定
* @param http
* @return
*/
@Bean
SecurityWebFilterChain apiHttpSecurity(ServerHttpSecurity http) {
http.oauth2ResourceServer().jwt()
.jwtAuthenticationConverter(jwtAuthenticationConverter());
// 自定義處理JWT請求頭過期或簽名錯誤的結果
http.oauth2ResourceServer().authenticationEntryPoint(authServerAuthenticationEntryPoint);
// 對白名單路徑,直接移除JWT請求頭,不移除的話,後臺會校驗jwt
http.addFilterBefore(whiteListRemoveJwtFilter, SecurityWebFiltersOrder.AUTHENTICATION);
// Basic認證直接放行
if (!CollectionUtils.isEmpty(authUrlWhiteListProperties.getTokenUrls()))
{
http.authorizeExchange().pathMatchers(ArrayUtil.toArray(authUrlWhiteListProperties.getTokenUrls(), String.class)).permitAll();
}
// 判斷是否有靜態檔案
if (!CollectionUtils.isEmpty(authUrlWhiteListProperties.getStaticFiles()))
{
http.authorizeExchange().pathMatchers(ArrayUtil.toArray(authUrlWhiteListProperties.getStaticFiles(), String.class)).permitAll();
}
http.authorizeExchange()
.pathMatchers(ArrayUtil.toArray(authUrlWhiteListProperties.getWhiteUrls(), String.class)).permitAll()
.anyExchange().access(authorizationManager)
.and()
.exceptionHandling()
/**
* 處理未授權
*/
.accessDeniedHandler(authServerAccessDeniedHandler)
/**
* 處理未認證
*/
.authenticationEntryPoint(authServerAuthenticationEntryPoint)
.and()
.cors()
.and().csrf().disable();
return http.build();
}
/**
* ServerHttpSecurity沒有將jwt中authorities的負載部分當做Authentication,需要把jwt的Claim中的authorities加入
* 解決方案:重新定義ReactiveAuthenticationManager許可權管理器,預設轉換器JwtGrantedAuthoritiesConverter
*/
@Bean
public Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
jwtGrantedAuthoritiesConverter.setAuthorityPrefix(AuthConstant.AUTHORITY_PREFIX);
jwtGrantedAuthoritiesConverter.setAuthoritiesClaimName(AuthConstant.AUTHORITY_CLAIM_NAME);
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(jwtGrantedAuthoritiesConverter);
return new ReactiveJwtAuthenticationConverterAdapter(jwtAuthenticationConverter);
}
}
spring:
......
security:
# # 啟用SpringBootAdmin時,健康檢查許可權校驗,不使用SpringBootAdmin此處可省略
user:
name: user
password: password
roles: ACTUATOR_ADMIN
......
# 效能監控端點設定
management:
security:
enabled: true
role: ACTUATOR_ADMIN
endpoint:
health:
show-details: always
endpoints:
enabled-by-default: true
web:
base-path: /actuator
exposure:
include: '*'
server:
servlet:
context-path: /actuator
health:
mail:
enabled: false
......
spring:
gateway:
discovery:
locator:
enabled: true
routes:
......
- id: gitegg-admin-monitor
uri: lb://gitegg-admin-monitor
predicates:
- Path=/gitegg-admin-monitor/**
filters:
- StripPrefix=1
- id: monitor
uri: lb://gitegg-admin-monitor
predicates:
- Path=/monitor/**
filters:
- StripPrefix=0
......
根據我們在Nacos中的設定,我們這裡的登入使用者名稱密碼是:user / password
以上為SpringBootAdmin在SpringCloud微服務中的搭建和設定步驟,相比較而言比較簡單,但是一定要注意許可權問題,不要因為健康檢查而洩露了系統資訊。我們這裡是通過Gateway進行的統一鑑權,在生產環境部署時,一定要注意修改預設的Basic校驗使用者名稱密碼,甚至需要修改健康檢查端點。
Gitee: https://gitee.com/wmz1930/GitEgg
GitHub: https://github.com/wmz1930/GitEgg