前言
Spring Boot中有四个非常好用的模块,堪称神奇,auto-configuration、starters、cli、actuator,其中actuator很少被人提到,但是却不能掩盖actuator强大的功能。下面就让我们来一起看一下,Actuator在实际应用中的最佳实践。本文要点:安全性、自定义端点,基于Spring Boot版本-2.0.6。
一、依赖引入
springboot项目的依赖版本通常由Spring Boot的版本统一约束,这里也是一个需要注意的地方。当spring-boot-starter-parent,或spring-boot-dependencies版本固定的时候,就不要指定Spring Boot其他组件版本了。因为Spring Boot其他组件都是基于Spring Boot的开发的,如果版本不一致,将引起冲突。
- maven引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
- Gradle引入依赖
dependencies {
compile("org.springframework.boot:spring-boot-starter-actuator")
}
二、Actuator能做什么
先说一下endpoint的概念,个人理解,在spring MVC中,每一个接口都可以称为一个端点,在Log中,所有包、类、组成的树状结构的每一个节点,也可以认为是一个端点。以下是Actuator中默认提供的端点,其中有一些端点是默认不开启的,如shutdown。
ID | Description | Enabled by default |
---|---|---|
auditevents | Exposes audit events information for the current application. | Yes |
beans | Displays a complete list of all the Spring beans in your application. | Yes |
conditions | Shows the conditions that were evaluated onconfiguration and auto-configuration classes and the reasons why they did or did not match. | Yes |
configprops | Displays a collated list of all @ConfigurationProperties. | Yes |
env | Exposes properties from Spring’s ConfigurableEnvironment. | Yes |
flyway | Shows any Flyway database migrations that have been applied. | Yes |
health | Shows application health information. | Yes |
httptrace | Displays HTTP trace information (by default, the last 100 HTTP request-response exchanges). | Yes |
info | Displays arbitrary application info. | Yes |
metrics | Shows ‘metrics’ information for the current application. | Yes |
mappings | Displays a collated list of all @RequestMapping paths. | Yes |
scheduledtasks | Displays the scheduled tasks in your application. | Yes |
sessions | "Allows retrieval and deletion of user sessions from aSpring Session-backed session store. Not available when using Spring Session’s support for reactive web applications." | Yes |
shutdown | Lets the application be gracefully shutdown. | No |
threaddump | Performs a thread dump. | Yes |
loggers | Shows and modifies the configuration of loggers in the application. | Yes |
liquibase | Shows any Liquibase database migrations that have been applied. | Yes |
另外当应用程序是WEB应用时,还会启用以下端点
ID | Description | Enabled by default |
---|---|---|
heapdump | Returns a GZip compressed hprof heap dump file. | Yes |
jolokia | Exposes JMX beans over HTTP (when Jolokia is on the classpath, not available for WebFlux). | Yes |
logfile | Returns the contents of the logfile (if logging.file or logging.path properties have been set). Supports the use of the HTTP Range header to retrieve part of the log file’s content. | Yes |
prometheus | Exposes metrics in a format that can be scraped by a Prometheus server | Yes |
三、向用户暴露端点
端点启用之后,用户如何访问呢?Actuator提供了两种方式向用户暴露端点:
- 基于JMX
JMX是借助于一些工具,如jdk自带的jvisualvm 等 - 基于HTTP
HTTP访问只支持GET、POST、DELETE三种,当然这三种已经很多了:)
HTTP请求肯定要有路径呀,那么Actuator的访问路径具体是什么样的呢?请看
# Actuator 默认URL是IP:[WEB项目配置的端口号]+/actuator,非WEB项目需要指定端口号
#WEB项目中,为Actuator指定其他端口号后,Actuator会和应用监听不同的端口号
#修改Actuator服务端口号还可以与应用服务端口隔离,防止非法请求——划重点
management.server.port=9190
# 还可以自定义Actuator的默认路径,或者叫discovery page,因为可以输出所有的端点。
# 自定义discovery page 访问地址变成ip:port/custom
management.endpoints.web.base-path=/custom
# 自定义根路径 访问地址变成ip:port/router/custom
management.server.servlet.context-path=/router
# 修改默认的端点启用规则,默认情况(true)下除shutdown外,全部启用,false即禁止默认启用
management.endpoints.enabled-by-default=false
# 以下配置可以指定端点启用,因上面禁用了默认启用,所以需要手动指定端点
# 语法是management.endpoint.[Endpoint ID].enabled=ture
# 启用health
management.endpoint.health.enabled=true
# 启用info
management.endpoint.info.enabled=true
# 启用prometheus
management.endpoint.prometheus.enabled=true
# 启用了端点需要制定暴露方式,JMX默认暴露所有端点,WEB(HTTP方式默认暴露health、info端点)
# 通过web方式暴露的端点,默认 health, info
management.endpoints.web.exposure.include=health,info,prometheus
经过以上配置,开启了health、info、prometheus端点,同时也暴露了他们。
ps prometheus需要引入相关依赖,此处使用了1.2.0的prometheus。
<!-- https://mvnrepository.com/artifact/io.micrometer/micrometer-core -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
<version>1.2.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.micrometer/micrometer-registry-prometheus -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<version>1.2.0</version>
</dependency>
以下是访问结果
so easy~~~
四、自定义端点
默认的端点就说到这里,接下来说一下如何自定义端点,以及为何要自定义端点。
- 首先为什么要通过Actuator自定义端点呢?
我的考虑是,应用向用户提供服务时,并不是说有的接口都要向用户暴露,有些控制功能的接口,只能掌握在管理员手中,当我需要将用户访问和管理服务端点分离开时,就可以使用Actuator来自定义端点。 - 如何自定义端点?
非常简单,给Bean打上端点的注解,那么拥有@ReadOperation,@WriteOperation,@DeleteOperation注解的方法就会通过JMX向外暴露,如果是Web应用也会通过HTTP方式向外暴露。
端点的种类有很多种,通过注解实现。如@Endpoint、@WebEndpoint、@JmxEndpoint、@ServletEndpoint、@ControllerEndpoint、@RestControllerEndpoint各自有不同的偏向,但官方推荐@Endpoint,因为其他的都会牺牲可移植性。 - Operation注解和HTTP method的对应关系,如下
Operation | HTTP method | 暴露方式个数限制 |
---|---|---|
@ReadOperation | GET | 无限制,根据参数个数匹配 |
@WriteOperation | POST | 1 |
@DeleteOperation | DELTEE | 无限制,根据参数个数匹配 |
标准情况下,ReadOperation和DeleteOperation两种Operation将会根据传输参数个数的不同,匹配到不同的方法。但WriteOperation只能存在一个,否则会在运行时抛出异常。
- 说了HTTP的请求类型就不得不说Content-type,三种注解,在返回不同类型的对象时,Content-type也不一样。
Operation | Return | Content-type(Consumes) | Content-type(Produces) |
---|---|---|---|
@ReadOperation | returns void | ||
returns org.springframework.core.io.Resource | application/octet-stream | ||
returns other | application/vnd.springboot.actuator.v2+json, application/json. | ||
@WriteOperation | returns void | application/vnd.spring-boot.actuator.v2+json, application/json | |
returns org.springframework.core.io.Resource | application/vnd.spring-boot.actuator.v2+json, application/json | application/octet-stream | |
returns other | application/vnd.spring-boot.actuator.v2+json, application/json | application/vnd.springboot.actuator.v2+json, application/json. | |
@DeleteOperation | returns void | ||
returns org.springframework.core.io.Resource | application/octet-stream |
- 最重要的来了,如何进行参数传递
@Selector 注解可以取出路径中的参数
注意只能在方法的形参中使用简单类型(包括包装类型),因为endpoint和技术无关。即意味着,如果使用request body传递json格式参数,如
{"name": "test","counter": 42}
那么方法形参应该是(String name,String counter),可以使用@org.springframework.lang.Nullable注解使参数非必传
下面给出几个样例
/**
* get
*
* @param name
* @return
*/
@ReadOperation
public String get(@Selector String name) {
return name;
}
@ReadOperation
public String get2(@Selector String param1, @Selector String param2) {
return param1 + param2;
}
/**
* post
*
* @param name
* @param counter
* @return
*/
@WriteOperation
public String postRequest(String name, @Nullable Integer counter) {
return name + String.valueOf(counter);
}
/**
* delete
*
* @author wxtang
* @Date: 2019/08/30 11:08
* @param: param1
*/
@DeleteOperation
public String del1(@Selector String param1) {
return "del1";
}
@DeleteOperation
public String del2(@Selector String param1, @Selector String param2) {
return "del2";
}
五、安全性
开启了端点,就不得不考虑一下安全性,自我提问一下:我想让谁访问?什么人可以访问?什么样的请求是合法的?
Springboot官方使用Spring Security组件进行鉴权演示,但是实际应用中,通常会对多个启用Actuator的应用做集中管理,这时Spring Security组件就不是那么好用了。而在Spring Boot的官方文档中,有这么一句话:
If you deploy applications behind a firewall, you may prefer that all your actuator
endpoints can be accessed without requiring authentication.
如果您在防火墙后部署应用程序,您可能更喜欢所有执行器可以在不需要身份验证的情况下访问端点。
在微服务盛行的时代,我们的应用通常处于API网关之后的位置,在经过网关的路由分发,鉴权,访问控制之后,用户很难直接访问到Actuator的端点。对于网关没有做路径约束的情况,还可以通过端口隔离的方式对Actuator端点进行保护,参看第三部分。
可能有人说了,日防夜防,家贼难防,万一有人通过内部网络跳过网关访问直接访问应用,岂不是钻了空子?take it easy ~ 我们还有另一个办法:基于IP过滤,只有在白名单中的ip才能访问。具体实现如下
- 基于IP过滤需要将Actuator的端口号与服务本身的端口号隔离开,以免产生影响(当然可以不分开,只是IP白名单就要多加一些地址了)
- 写Filter,实现对IP的过滤,代码如下:
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
/**
* @author wxtang
* @version 1.0.0
* @ClassName: FilterConfig
* @Description: 注册filter
* @Date: 2019/08/20 15:04
*/
public class FilterConfig {
@Bean
public FilterRegistrationBean actuatorFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new IpFilter());
registration.addUrlPatterns("/custom/test-endpoint-json/*");
registration.setName("ipfilter");
registration.setOrder(1);
return registration;
}
}
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.*;
import javax.servlet.FilterConfig;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;
import java.util.List;
/**
* @author wxtang
* @version 1.0.0
* @ClassName: IpFilter
* @Description: ip过滤器
* @Date: 2019/08/20 13:36
*/
public class IpFilter implements Filter {
private final static Logger log = LoggerFactory.getLogger(IpFilter.class);
// @Value("${shutdown.whitelist:0:0:0:0:0:0:0:1}")
private String[] ipWhitelist = {"0:0:0:0:0:0:0:1", "127.0.0.1", "172.22.42.132"};
@Override
public void destroy() {
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("ipFilter is init!");
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String ip = this.getIpAddress(httpServletRequest);
log.info("访问的机器的原始IP:{}", ip);
if (!isMatchWhiteList(ip)) {
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
PrintWriter writer = response.getWriter();
writer.write("{\"code\":401}");
writer.flush();
writer.close();
return;
}
filterChain.doFilter(request, response);
}
/**
* 匹配是否是白名单
*
* @param ip
* @return
*/
private boolean isMatchWhiteList(String ip) {
List<String> list = Arrays.asList(ipWhitelist);
return list.stream().anyMatch(ip::startsWith);
}
/**
* 获取用户真实IP地址,不使用request.getRemoteAddr();的原因是有可能用户使用了代理软件方式避免真实IP地址,
* 可是,如果通过了多级反向代理的话,X-Forwarded-For的值并不止一个,而是一串IP值,究竟哪个才是真正的用户端的真实IP呢?
* 答案是取X-Forwarded-For中第一个非unknown的有效IP字符串。
* <p>
* 如:X-Forwarded-For:192.168.1.110, 192.168.1.120, 192.168.1.130, 192.168.1.100
* <p>
* 用户真实IP为: 192.168.1.110
*
* @param request
* @return
*/
private String getIpAddress(HttpServletRequest request) {
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_CLIENT_IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("HTTP_X_FORWARDED_FOR");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
}
注意,获取ip时应该获取源ip,避免使用代理伪造ip
写到这里并没有向容器中注入filter,为什么呢?因为,如果Actuator使用独立端口号,那么将启用新的servletContext,也就是说,如果使用默认的@Configuration注解向容器中注入filter,过滤器就不会再正确的位置生效。需要使用其他方法标注filter注入到Actuator建立的servletContext中,如下:
resource目录下建立META-INF文件夹,其下新建文件spring.factory 内容:
org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration=\
[FilterConfig.reference]
#例如org.springframework.boot.actuate.autoconfigure.web.ManagementContextConfiguration=
#\com.tir.na.nog.actuatortest.config.FilterConfig
以上就是个人在使用Actuator时的最佳实践。如有问题还请指出,欢迎留言~