1. 程式人生 > >Spring Cloud Config Server 節點遷移引起的問題,請格外注意這一點!

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

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