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

前言:git

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

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

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

本文所使用的 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 致使。app

本文結合這個問題做爲切入點,繼續延伸下,並結合源碼探究下緣由以及改進措施。負載均衡

前提條件是使用了 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愛好者社區

相關文章
相關標籤/搜索