Spring Cloud Config Server 節點遷移引起的問題,請格外注意這一點!


前言:

雖然強烈推薦選擇使用國內開源的配置中心,如攜程開源的 Apollo 配置中心、阿里開源的 Nacos 注冊&配置中心。

但實際架構選型時,根據實際項目規模、業務復雜性等因素,有的項目還是會選擇 Spring Cloud Config,也是 Spring Cloud 官網推薦的。特別是對性能要求也不是很高的場景,Spring Cloud Config 還算是好用的,基本能夠滿足需求,通過 Git 天然支持版本控制方式管理配置。

而且,目前 github 社區也有小伙伴針對 Spring Cloud Config 一些「缺陷」,開發了簡易的配置管理界面,並且也已開源,如 spring-cloud-config-admin,也是超哥(程序員DD)傑作,該項目地址:https://dyc87112.github.io/spring-cloud-config-admin-doc/

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

問題分析:

個人認為這個問題是有代表性的,也能基於該問題,了解到官網是如何改進的。使用 Spring Cloud Config 過程中,如果遇到配置中心服務器遷移,可能會遇到 DD 這篇博客所描述的問題:
http://blog.didispace.com/Spring-Cloud-Config-Server-ip-change-problem/

我這里大概簡述下該文章中提到的問題:

當使用的 Spring Cloud Config 配置中心節點遷移或容器化方式部署(IP 是變化的),Config Server 端會因為健康檢查失敗報錯,檢查失敗是因為使用的還是遷移之前的節點 IP 導致。

本文結合這個問題作為切入點,繼續延伸下,並結合源碼探究下原因以及改進措施。

前提條件是使用了 DiscoveryClient 服務注冊發現,如果我們使用了 Eureka 作為注冊中心,其實現類是 EurekaDiscoveryClient
客戶端通過 Eureka 連接配置中心,需要做如下配置:

spring.cloud.config.discovery.service-id=config-server
spring.cloud.config.discovery.enabled=true

這里的關鍵是 spring.cloud.config.discovery.enabled 配置,默認值是 false,設置為 true 表示激活服務發現,最終會由 DiscoveryClientConfigServiceBootstrapConfiguration 啟動配置類來查找配置中心服務。

接下來我們看下這個類的源碼:

@ConditionalOnProperty(value = "spring.cloud.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<ServiceInstance> 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.cloud.config.discovery.service-id 配置項獲取所有的服務列表, instances.get(0) 從服務列表中得到第一個實例。每次從注冊中心得到的服務列表是無序的。

從配置中心獲取最新的資源屬性是由 ConfigServicePropertySourceLocator 類的 locate() 方法實現的,繼續深入到該類的源碼看下具體實現:

@Override
@Retryable(interceptor = "configServerRetryInterceptor")
public org.springframework.core.env.PropertySource<?> locate(
        org.springframework.core.env.Environment environment) {
	
	// 獲取當前的客戶端配置屬性,override作用是優先使用spring.cloud.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.info("Fetching config from server at: " + properties.getRawUri());
	try {
			String[] labels = new String[] { "" };
			if (StringUtils.hasText(properties.getLabel())) {
					labels = StringUtils.commaDelimitedListToStringArray(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.info(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.info("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.info("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.info("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理論模型」做下相關測試,確保不會出現不可預知的問題。

大家感興趣可進一步參考 github 最新源碼實現:

https://github.com/spring-cloud/spring-cloud-config

歡迎關注我的公眾號,掃二維碼關注獲得更多精彩文章,與你一同成長~
Java愛好者社區


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM