测试小站: 处理网 回收帮 培训网 富贵论坛 老富贵论坛

Spring Cloud Config Server 节点迁移引起的问题,请注意这一点

  前言:

  虽然强烈推荐选择使用国内开源的配置中心,如携程开源的 Apollo 配置中心、阿里开源的 Nacos 注册&配置中心。

  但实际架构选型时,根据实际项目规模、业务复杂性等因素,有的项目还是会选择 Spring Cloud Config,也是 Spring Cloud 官网推荐的。特别是对性能要求也不是很高的场景,Spring Cloud Config 还算是好用的,基本能够满足需求,通过 Git 天然支持版本控制方式管理配置。

  而且,目前 github 社区也有小伙伴针对 Spring Cloud Config 一些「缺陷」,开发了简易的配置管理界面,并且也已开源,如 spring-cloud-config-admin,也是超哥(程序员DD)杰作,该项目地址:

  dyc87112.github/spring-cloud-config-admin-doc/

  本文所使用的 Spring Cloud 版本:Edgware.SR3,Spring Boot 版本:1.5.10.RELEASE

  问题分析:

  个人认为这个问题是有代表性的,也能基于该问题,了解到官网是如何改进的。使用 Spring Cloud Config 过程中,如果遇到配置中心服务器迁移,可能会遇到 DD 这篇博客所描述的问题:

  blog.didispace/Spring-Cloud-Config-Server-ip-change-problem/

  我这里大概简述下该文章中提到的问题:

  当使用的 Spring Cloud Config 配置中心节点迁移或容器化方式部署(IP 是变化的),Config Server 端会因为健康检查失败报错,检查失败是因为使用的还是迁移之前的节点 IP 导致。

  本文结合这个问题作为切入点,继续延伸下,并结合源码探究下原因以及改进措施。

  前提条件是使用了 DiscoveryClient 服务注册发现,如果我们使用了 Eureka 作为注册中心,其实现类是 EurekaDiscoveryClient

  客户端通过 Eureka 连接配置中心,需要做如下配置:

  spring.config.discovery.service-id=config-server

  spring.config.discovery.enabled=true

  这里的关键是

  spring.config.discovery.enabled 配置,默认值是 false,设置为 true 表示激活服务发现,最终会由

  DiscoveryClientConfigServiceBootstrapConfiguration 启动配置类来查找配置中心服务。

  接下来我们看下这个类的源码:

  @ConditionalOnProperty(value="spring.config.discovery.enabled", matchIfMissing=false)

  @Configuration

  // 引入工具类自动配置类

  @Import({ UtilAutoConfiguration.class })

  // 开启服务发现

  @EnableDiscoveryClient

  public class DiscoveryClientConfigServiceBootstrapConfiguration {

  @Autowired

  private ConfigClientProperties config;

  @Autowired

  private ConfigServerInstanceProvider instanceProvider;

  private HeartbeatMonitor monitor=new HeartbeatMonitor();

  @Bean

  public ConfigServerInstanceProvider configServerInstanceProvider(

  DiscoveryClient discoveryClient) {

  return new ConfigServerInstanceProvider(discoveryClient);

  }

  // 上下文刷新事件监听器,当服务启动或触发 /refresh 或触发消息总线的 /bus/refresh 后都会触发该事件

  @EventListener(ContextRefreshedEvent.class)

  public void startup(ContextRefreshedEvent event) {

  refresh();

  }

  // 心跳事件监听器,这个监听事件是客户端从Eureka中Fetch注册信息时触发的。

  @EventListener(HeartbeatEvent.class)

  public void heartbeat(HeartbeatEvent event) {

  if (monitor.update(event.getValue())) {

  refresh();

  }

  }

  // 该方法从注册中心获取一个配合中心的实例,然后将该实例的url设置到ConfigClientProperties中的uri字段。

  private void refresh() {

  try {

  String serviceId=this.config.getDiscovery().getServiceId();

  ServiceInstance server=this.instanceProvider

  .getConfigServerInstance(serviceId);

  String url=getHomePage(server);

  if (server.getMetadata().containsKey("password")) {

  String user=server.getMetadata().get("user");

  user=user==null ? "user" : user;

  this.config.setUsername(user);

  String password=server.getMetadata().get("password");

  this.config.setPassword(password);

  }

  if (server.getMetadata().containsKey("configPath")) {

  String path=server.getMetadata().get("configPath");

  if (url.endsWith("/") && path.startsWith("/")) {

  url=url.substring(0, url.length() - 1);

  }

  url=url + path;

  }

  this.config.setUri(url);

  }

  catch (Exception ex) {

  if (config.isFailFast()) {

  throw ex;

  }

  else {

  logger.warn("Could not locate configserver via discovery", ex);

  }

  }

  }

  }

  这里会开启一个上下文刷新的事件监听器 @EventListener(

  ContextRefreshedEvent.class),所以当通过消息总线 /bus/refresh 或者直接请求客户端的 /refresh 刷新配置后,该事件会自动被触发,调用该类中的 refresh() 方法从 Eureka 注册中心获取配置中心实例。

  这里的

  ConfigServerInstanceProvider 对 DiscoveryClient 接口做了封装,通过如下方法获取实例:

  @Retryable(interceptor="configServerRetryInterceptor")

  public ServiceInstance getConfigServerInstance(String serviceId) {

  logger.debug("Locating configserver (" + serviceId + ") via discovery");

  List instances=this.client.getInstances(serviceId);

  if (instances.isEmpty()) {

  throw new IllegalStateException(

  "No instances found of configserver (" + serviceId + ")");

  }

  ServiceInstance instance=instances.get(0);

  logger.debug(

  "Located configserver (" + serviceId + ") via discovery: " + instance);

  return instance;

  }

  以上源码中看到通过 serviceId 也就是

  spring.config.discovery.service-id 配置项获取所有的服务列表, instances.get(0) 从服务列表中得到第一个实例。每次从注册中心得到的服务列表是无序的。

  从配置中心获取最新的资源属性是由

  ConfigServicePropertySourceLocator 类的 locate() 方法实现的,继续深入到该类的源码看下具体实现:

  @Override

  @Retryable(interceptor="configServerRetryInterceptor")

  public org.springframework.core.envpertySource<?> locate(

  org.springframework.core.env.Environment environment) {

  // 获取当前的客户端配置属性,override作用是优先使用spring.config.application、profile、label(如果配置的话)

  ConfigClientProperties properties=this.defaultProperties.override(environment);

  CompositePropertySource composite=new CompositePropertySource("configService”);

  // resetTemplate 可以自定义,开放了公共的 setRestTemplate(RestTemplate restTemplate) 方法。如果未设置,则使用默认的 getSecureRestTemplate(properties) 中的定义的resetTemplate。该方法中的默认超时时间是 3分5秒,相对来说较长,如果需要缩短这个时间只能自定义 resetTemplate 来实现。

  RestTemplate restTemplate=this.restTemplate==null ? getSecureRestTemplate(properties)

  : this.restTemplate;

  Exception error=null;

  String errorBody=null;

  logger("Fetching config from server at: " + properties.getRawUri());

  try {

  String[] labels=new String[] { "" };

  if (StringUtils.hasText(properties.getLabel())) {

  labels=StringUtilsmaDelimitedListToStringArray(properties.getLabel());

  }

  String state=ConfigClientStateHolder.getState();

  // Try all the labels until one works

  for (String label : labels) {

  // 循环labels分支,根据restTemplate模板请求config属性配置中的uri,具体方法可以看下面。

  Environment result=getRemoteEnvironment(restTemplate,

  properties, label.trim(), state);

  if (result !=null) {

  logger(String.format("Located environment: name=%s, profiles=%s, label=%s, version=%s, state=%s",

  result.getName(),

  result.getProfiles()==null ? "" : Arrays.asList(result.getProfiles()),

  result.getLabel(), result.getVersion(), result.getState()));

  ……

  if (StringUtils.hasText(result.getState()) || StringUtils.hasText(result.getVersion())) {

  HashMap<String, Object> map=new HashMap<>();

  putValue(map, "config.client.state", result.getState());

  putValue(map, "config.client.version", result.getVersion());

  // 设置到当前环境中的Git仓库最新版本号。

  composite.addFirstPropertySource(new MapPropertySource("configClient", map));

  }

  return composite;

  }

  }

  }

  …… // 忽略部分源码

  }

  根据方法内的 uri 来源看到是从 properties.getRawUri() 获取的。

  从配置中心服务端获取 Environment 方法:

  private Environment getRemoteEnvironment(RestTemplate restTemplate, ConfigClientProperties properties,

  String label, String state) {

  String path="/{name}/{profile}";

  String name=properties.getName();

  String profile=properties.getProfile();

  String token=properties.getToken();

  String uri=properties.getRawUri();

  ……// 忽略部分源码

  response=restTemplate.exchange(uri + path, HttpMethod.GET,

  entity, Environment.class, args);

  }

  …...

  Environment result=response.getBody();

  return result;

  }

  上述分析看到从远端配置中心根据 properties.getRawUri(); 获取的固定 uri,通过 restTemplate 完成请求得到最新的资源属性。

  源码中看到的 properties.getRawUri() 是一个固化的值,当配置中心迁移或者使用容器动态获取 IP 时为什么会有问题呢?

  原因是当配置中心迁移后,当超过了注册中心的服务续约失效时间(Eureka 注册中心默认是 90 秒,其实这个值也并不准确,官网源码中也已注明是个 bug,这个可以后续单独文章再说)会从注册中心被踢掉,当我们通过 /refresh 或 /bus/refresh 触发这个事件的刷新,那么这个 uri 会更新为可用的配置中心实例,此时

  ConfigServicePropertySourceLocator 是新创建的实例对象,所以会通过最新的 uri 得到属性资源。

  但因为健康检查

  ConfigServerHealthIndicator 对象以及其所依赖的

  ConfigServicePropertySourceLocator 对象都没有被重新实例化,还是使用服务启动时初始化的对象,所以 properties.getRawUri() 中的属性值也没有变化。

  这里也就是 Spring Cloud Config 的设计缺陷,因为即使刷新配置后能够获取其中一个实例,但是并不代表一定请求该实例是成功的,比如遇到网络不可达等问题时,应该通过负载均衡方式,重试其他机器获取数据,保障最新环境配置数据一致性。

  解决姿势:

  github 上 spring cloud config 的 2.x.x 版本中已经在修正这个问题。实现方式也并没有使用类似 Ribbon 软负载均衡的方式,猜测可能考虑到减少框架的耦合。

  在这个版本中 ConfigClientProperties 类中配置客户端属性中的 uri 字段由 String 字符串类型修改为 String[] 数组类型,通过 DiscoveryClient 获取到所有的可用的配置中心实例 URI 列表设置到 uri 属性上。

  然后

  ConfigServicePropertySourceLocator.locate() 方法中循环该数组,当 uri 请求不成功,会抛出 ResourceAccessException 异常,捕获此异常后在 catch 中重试下一个节点,如果所有节点重试完成仍然不成功,则将异常直接抛出,运行结束。

  同时,也将请求超时时间 requestReadTimeout 提取到 ConfigClientProperties 作为可配置项。

  部分源码实现如下:

  private Environment getRemoteEnvironment(RestTemplate restTemplate,

  ConfigClientProperties properties, String label, String state) {

  String path="/{name}/{profile}";

  String name=properties.getName();

  String profile=properties.getProfile();

  String token=properties.getToken();

  int noOfUrls=properties.getUri().length;

  if (noOfUrls > 1) {

  logger("Multiple Config Server Urls found listed.");

  }

  for (int i=0; i < noOfUrls; i++) {

  Credentials credentials=properties.getCredentials(i);

  String uri=credentials.getUri();

  String username=credentials.getUsername();

  String password=credentials.getPassword();

  logger("Fetching config from server at : " + uri);

  try {

  ......

  response=restTemplate.exchange(uri + path, HttpMethod.GET, entity,

  Environment.class, args);

  }

  catch (HttpClientErrorException e) {

  if (e.getStatusCode() !=HttpStatus.NOT_FOUND) {

  throw e;

  }

  }

  catch (ResourceAccessException e) {

  logger("Connect Timeout Exception on Url - " + uri

  + ". Will be trying the next url if available");

  if (i==noOfUrls - 1)

  throw e;

  else

  continue;

  }

  if (response==null || response.getStatusCode() !=HttpStatus.OK) {

  return null;

  }

  Environment result=response.getBody();

  return result;

  }

  return null;

  }

  总结:

  本文主要从 Spring Cloud Config Server 源码层面,对 Config Server 节点迁移后遇到的问题,以及对此问题过程进行剖析。同时,也进一步结合源码,了解到 Spring Cloud Config 官网中是如何修复这个问题的。

  当然,现在一般也都使用最新版的 Spring Cloud,默认引入的 Spring Cloud Config 2.x.x 版本,也就不会存在本文所描述的问题了。

  如果你选择了 Spring Cloud Config 作为配置中心,建议你在正式上线到生产环境前,按照 「CAP理论模型」做下相关测试,确保不会出现不可预知的问题。

posted @ 2021-12-12 21:11  linjingyg  阅读(386)  评论(0)    收藏  举报