SpringCloud Gateway 在微服務架構下的最佳實踐

前言

本文整理自雲原生技術實踐營廣州站 Meetup 的分享,其中的經驗來自於我們團隊開發的阿里雲 CSB 2.0 這款產品,其基於開源 SpringCloud Gateway 開發,在完全兼容開源用法的前提下,做了諸多企業級的改造,涉及功能特性、穩定性、安全、性能等方面。

爲什麼需要微服務網關

從功能角度來看,微服務網關通常用來統一提供認證授權、限流、熔斷、協議轉換等功能。

從使用場景上來看:

  • 南北向流量,需要流量網關和微服務網關配合使用,主要是爲了區分外部流量和微服務流量,將內部的微服務能力,以統一的 HTTP 接入點對外提供服務
  • 東西向流量,在一些業務量比較大的系統中,可能會按照業務域隔離出一系列的微服務,在同一業務域內的微服務通信走的是服務發現機制,而跨業務域訪問,則建議藉助於微服務網關。

微服務網關核心功能

微服務架構、微服務/API 網關這些關鍵詞發展至今,早已不是什麼新鮮的概念,技術選型者也從出於好奇心關注一個技術,轉移到了更加關注這個技術的本質。市場上各類網關產品的功能也逐漸趨於同質化,基本可以用同一張圖來概括:

網關選型對比

企業在選擇使用一款網關產品時,通常會有兩個選擇,一是基於某一款開源產品做二次開發,二是選擇某一款商業化產品開箱即用,無論如何,都應當從穩定性、安全、性能、業務兼容性等方面去進行選型。請相信我今天是站在 SpringCloud Gateway 角度進行的分享,我會盡可能做到客觀、公正。

早期 SpringCloud 社區出現過 Zuul 這種產品,時至今日搜索微服務網關的資料,大概率都會出現它的身影,僅其通信模型是同步的線程模型這一條,就不足以支撐其成爲企業級的網關產品選型,我會主要對比 SpringCloud Gateway、阿里雲 CSB 2.0、Nginx、Kong、Envoy。

嚴謹來說,這幾個網關並不適合對比,因爲他們都有其各自適用的場景,表格僅供參考。

SpringCloud Gateway 的優勢在於其可以很好地跟 Spring 社區和 SpringCloud 微服務體系打通,這一點跟 Java 語言流行的原因如出一轍,所以如果一個企業的語言體系是 Java 技術棧,並且基於 SpringBoot/ SpringCloud 開發微服務,選型 SpringCloud Gateway 作爲微服務網關,會有着得天獨厚的優勢。

SpringCloud Gateway 選型的優勢:

  • SpringCloud Gateway 有很多開箱即用的功能,且擴展點多
  • 適合 Java 技術棧
  • Spring/SpringCloud 社區生態好
  • 適合跟 SpringBoot/ SpringCloud 微服務生態集成

SpringCloud Gateway 介紹

如果你之前沒有了解過 SpringCloud Gateway,也不用擔心,下面一小部分篇幅會介紹 SpringCloud Gateway 基本用法,這是一段非常基礎的 SpringCloud Gateway 路由配置示例。

spring:
  cloud:
    gateway:
      routes:
        - id: aliyun
          uri: https://www.aliyun.com
          predicates:
            - Host=*.aliyun.com
        - id: httpbin
          uri: http://httpbin.org
          predicates:
            - Path=/httpbin/**
          filters:
            - StripPrefix=1
        - id: sca-provider
          uri: lb://sca-provider
          predicates:
            - Path=/sca/**
          filters:
            - StripPrefix=1
    nacos:
      discovery:
        server-addr: mse-xxxxx-p.nacos-ans.mse.aliyuncs.com:8848

該示例介紹了微服務網關常見的幾種路由配置示例:

  • Host 路由匹配
  • 前綴 Path 路由匹配
  • 前綴 Path 路由匹配 & 服務發現

SpringCloud Gateway 支持豐富的路由匹配邏輯,以應對各種類型的業務訴求:

其中 Path、Header、Method 這幾種斷言最爲常用。

針對於網關請求路徑、參數和後端服務請求路徑、參數不一致的場景,SpringCloud Gateway 也提供了諸多開箱即用的 GatewayFilter,以實現對請求和響應的定製。

SpringCloud Gateway 的 user guide 介紹到此爲止,如果想要了解 develop guide,建議參考 SpringCloud Gateway 的官方文檔。

開源特性 VS 企業級特性需求

衆所周知,開源產品直接投入企業級生產使用一般是會面臨一些挑戰的,畢竟場景不同。以擴展性爲例,開源產品大多講究擴展點豐富,以應對開源用戶千奇百怪的需求,而企業級產品場景更爲單一,性能和穩定性是第一考慮因素,當二者發生 trade off 時,則需要一些取捨了。

開源 SpringCloud Gateway 沒有開箱即用地支持一些重要的企業級特性,如果選型 SpringCloud Gateway 構建生產級別可用的微服務網關,那我的建議是需要補足以上這些能力。下面我會花較多的篇幅介紹我們在開源基礎上做的一些企業級改造,希望能夠拋磚引玉。

白屏化管控

表面看來,SpringCloud Gateway 並沒有配套一個管理控制檯,深層次一點來看,是 SpringCloud Gateway 還停留在一個開發框架層面,不是那麼的產品化,同時它的領域模型也不是劃分的那麼清晰,說的好聽點,這說明 SpringCloud Gateway 有充足的改造空間。

我們的改造原則有兩點,一是完全兼容開源的規則及模型,不破壞底層規則的語義,這樣我們可以跟隨社區的節奏一起演進,將來也有機會貢獻給社區,二是區分研發態的領域模型和用戶態的產品模型,我們抽象出了路由、服務、來源、消費者、策略、插件等領域對象,這算不上什麼創新,實際上網關領域的這些模型早已有了一些約定俗成的規範。

白屏化管控的背後,也意味着一切配置:路由配置、服務配置、策略配置...都是動態的,並且配置的變更都會實時生效。

配置方案重構

上文提到了配置實時生效這一改造,有人可能會有疑問,開源不是已經支持將路由配置存儲在 Nacos 中了嗎?對的,開源支持兩種配置方式,一是將路由配置在 application.yaml 中,這樣最簡單,但對於路由配置的 curd 都需要重啓進程,非常繁瑣,二是將配置託管到 Nacos 這樣的配置中心組件中,實現分佈式配置,能夠動態刷新,但我們認爲這還不足以支持企業級需求,將配置存儲在單個 dataId 中這種開源方案有以下痛點:

  1. 配置推送慢:配置量大,網絡傳輸慢,萬級別配置推送耗時 5 分鐘
  2. 爆炸半徑大:不支持配置拆分,錯誤配置影響解析流程,導致網關路由整體不可用
  3. 配置規模:單個 value 有 10M 大小限制,僅支持千級別路由

配置拆分勢在必行,但其中困難也很多,例如動態監聽的管理,穩定性的保障流程尤爲複雜,額外提供的視圖層與實際配置中心數據一致性保障等等。方案參考下圖:

圖中還有一個細節,也是我們優先選擇 Nacos 作爲配置中心的原因,nacos-client 的 snapshot 機制可以保證在管控以及配置中心組件都不可用時,即使網關 broker 重啓了,依舊保證路由不丟失,保證自身可用性。

經過這套方案的改造,我們獲得顯著的優化效果:

  • 推送時間優化:1w 配置 5 分鐘 -> 30 秒
  • 配置量上限提升:1000 -> 10w
  • 確保了配置推送的最終一致性

協議轉換 x 服務發現

這兩個企業級改造放到一起說,在實現上這兩個模塊也耦合的比較緊密。

協議轉換:就以 Java 微服務體系而言,後端服務很有可能會出現 Dubbo 框架或者 GRPC 框架,甚至有些老的業務還會使用 WebService 這類框架,大多數時候我們說的網關都是隻對接 HTTP 這一類通信協議,這限制了我們後端服務只能是 SpringBoot 或者 SpringCloud 框架,網關支持後端不同協議類型的能力,我們稱之爲協議轉換。

服務發現:微服務框架離不開服務發現,一般常見的註冊中心包括 Nacos、Eureka 等,例如開源 SpringCloud Gateway 便支持對接 Nacos/Eureka 兩類註冊中心。

這類開源特性的痛點是:

  1. SpringCloud Gateway 僅支持 HTTP2HTTP,不支持 HTTP2DUBBO,HTTP2GRPC,HTTP2WEBSERVICE
  2. SpringCloud Gateway 僅支持單一註冊中心的靜態配置

一些常見的企業級訴求:

  1. 存在不同類型的微服務架構:SpringCloud、Dubbo、GRPC
  2. 網關支持跨環境訪問,需要連接多個註冊中心或者多個命名空間

針對這些痛點和訴求,分享一些我們改造時遇到的難點以及經驗

在支持不同協議時,對應的服務框架可能已經有了對應的 remoting 層和 discovery 層,我們的選擇是僅引入該協議的 remoting 二方包解決協議轉換問題,對於 discovery 層,應當自行封裝,避免使用對應協議的 discovery 層這個誤區,因爲迴歸到網關領域,服務發現和協議轉換是對等的模塊,抽象 ServiceDiscoveryFIlter 負責服務發現,ProtocolTransferFilter 則負責點對點的協議通信。

在服務發現層,爲了適配不同註冊中心的模型(推和拉),提供了兩個實現 PullServiceRegistry、PushServiceRegistry,這些改造是獨立於 spring-cloud-loadbalancer 模塊實現的,開源的默認實現存在諸多的限制,例如僅支持拉模型 + 緩存服務列表的方案,實際上推模型能夠爲網關的服務發現提供更高的實時性。

基本流程:服務發現 serviceName -> n x IP,負載均衡 IP n ->1,協議轉換 IP 點對點通信。

這樣一套擴展機制可以在有新的協議類型、註冊中心、負載均衡算法需要對接時實現快速擴展。

限流熔斷

如果仔細閱讀過 SpringCloud Gateway 的文檔,你會發現,開源對限流熔斷的支持是非常有限的,它強依賴一個 Redis 做集羣限流,且限流方案是自己實現的,而我們可能會更加信賴 Sentinel 提供的解決方案。事實上,開源 Sentinel 也對 SpringCloud Gateway 提供了一部分開箱即用的能力,使用層面完全沒問題,主要是欠缺了一部分可觀測性的能力。

在改造中,尤爲注意要使用高版本的 Sentinel,即按比例閾值這套模型實現的限流方案,集成 Sentinel 之後,我們按照網關的通用場景提供了兩類限流模型:基於慢調用比例的限流熔斷和基於響應碼比例的限流熔斷。藉助於 Sentinel 的能力,可惜實現漸進式的恢復。

可觀測體系建設

可觀測性體系的建設,可以說是很多開源產品距離企業級使用的距離,SpringCloud Gateway 亦是如此。

網關通常會需要記錄三類可觀測性指標。

  • Metrics:如上圖所示,記錄請求數、QPS、響應碼、P99、P999 等指標
  • Trace:網關鏈路能夠串聯後續微服務體系鏈路,實現全鏈路監控
  • Logging:按類別打印網關日誌,常見的日誌分類如 accessLog、requestLog、remotingLog 等

開源 SpringCloud Gateway 集成了 micrometer-registry-prometheus,提供了一個開箱即用的大盤:https://docs.spring.io/spring-cloud-gateway/docs/3.1.8/reference/html/gateway-grafana-dashboard.json,需要更加豐富維度的指標則需要自行埋點。

Trace 方案推薦對接 opentelemetry。

Logging 方案則是 SpringCloud Gateway 開源欠缺的,在實際生產中至少應該打印 accessLog 記錄請求信息,按需開啓 requestLog 記錄請求的 payload 信息和響應體信息,以及與後端服務連接的日誌,用於排查一些連接問題。日誌採集方案我們的實踐是將 accessLog 輸出到標準輸出中,方便在 K8s 架構下配置採集,或者採用日誌 agent 的方案進行文件採集。

性能優化

除了功能層面的優化與新增,網關的性能也是使用者尤爲關注的點。在前文中,我並沒有把 SpringCloud Gateway 歸爲一個性能特別高的網關分類中,主要是基於我們的實踐,發現其有不少優化空間。下面的章節我會分享一些基於 SpringCloud Gateway 進行的性能優化。

網關優化道阻且長,爲了驗證優化效果,建設性能基線不可避免,需要面向 benchmark 進行優化。

一些常用的優化技巧在網關中也同樣適用,例如:緩存、懶加載、預分配、算法複雜度優化、CPU 友好操作,減少線程切換。

火焰圖

通過火焰圖觀測性能可以從宏觀角度分析大的性能損耗點:

一個理想的網關火焰圖應當是大部分的時間片佔用花費在 IO 上,即圖中的 netty 相關的損耗,除此之外佔用了 CPU 的類,都需要重點關注。通過火焰圖,我們也定位到了相當多的性能損耗點,並針對進行了優化。

GlobalFilter 排序優化

SpringCloud Gateway 中通過 GlobalFilter、GatewayFilter 對請求進行過濾,在 FilteringWebHandler 中可以看到這段邏輯:

  public Mono<Void> handle(ServerWebExchange exchange) {
    Route route = exchange.getRequiredAttribute(GATEWAY_ROUTE_ATTR);
    List<GatewayFilter> gatewayFilters = route.getFilters();

    List<GatewayFilter> combined = new ArrayList<>(this.globalFilters);
    combined.addAll(gatewayFilters);
    // TODO: needed or cached?
    AnnotationAwareOrderComparator.sort(combined);

    return new DefaultGatewayFilterChain(combined).filter(exchange);
  }

開源實現在每次請求級別都會重新組裝出一個 FilterChain,並進行排序,內存分配和排序會佔用 CPU,無疑會導致性能下降,通過註釋可以看到 Contributor 自己也意識到了這裏的性能問題,但一直沒有修復。

一個可行的優化手段是在路由或者策略變更時,觸發 FilterChain 的更新,這樣請求時 FilterChain 就沒必要重新構造了。而觀測到這一性能問題,正是通過了火焰圖中的 FilteringWebHandler.handle 的佔用。

路由增量推送

之前的企業級特性章節中,我介紹了配置中心改造的方案,其中提及了開源方案爆炸半徑大的問題,可以從下面的代碼中,窺見一斑:

public class RouteDefinitionRouteLocator implements RouteLocator {

  @Override
  public Flux<Route> getRoutes() {
    Flux<Route> routes = this.routeDefinitionLocator.getRouteDefinitions().map(this::convertToRoute);

    if (!gatewayProperties.isFailOnRouteDefinitionError()) {
      // instead of letting error bubble up, continue
      routes = routes.onErrorContinue((error, obj) -> {
        if (logger.isWarnEnabled()) {
          logger.warn("RouteDefinition id " + ((RouteDefinition) obj).getId()
              + " will be ignored. Definition has invalid configs, " + error.getMessage());
        }
      });
    }

    return routes.map(route -> {
      return route;
    });
  }

可以見得,SpringCloud Gateway 認爲路由配置是一個整體,任意路由的變更,就會導致整個 Route 序列重新構建。並且在默認情況下,如果其中一個路由配置出錯了,會導致整個網關路由不可用,除非 isFailOnRouteDefinitionError 被關閉。

我們的改造方案是使用 Map 結構進行改造,配合路由配置的增量推送,實現 Route 的單點更新。

public class DynamicRouteRepository implements Ordered, RouteLocator, ApplicationEventPublisherAware, RouteDefinitionWriter {

  private RouteConverter routeConverter;

  static class RouteKey implements Ordered {
    private String id;
    private int order;
    ...
  }

  static final Map<RouteKey, Route> ORDERED_ROUTE = new TreeMap<>((o1, o2) -> {
    int order1 = o1.order;
    int order2 = o2.order;
    if (order1 != order2) {
      return Integer.compare(order1, order2);
    }
    return o1.id.compareTo(o2.id);
  });

  private static final Map<String, Integer> ORDER = new HashMap<>();

  public Route getRouteById(String id) {
    return ORDERED_ROUTE.get(new RouteKey(id, ORDER.getOrDefault(id, 0)));
  }
  ...
}

路由內存優化

這個優化來自於我們一次生產問題的排查,起初我們並沒有意識到該問題。問題表現爲路由數量非常大時,內存佔用的消耗超過了我們的預期,經過 dump 發現,同一份路由的配置內容竟然以 3 種形式常駐於內存中。

  • Nacos 配置中心自身的 Cache
  • SpringCloud Gateway 路由定義 RouteDefinition 的佔用
  • SpringCloud Gateway 真實路由 Route 的佔用

Nacos 的佔用在我們預期之類,但 RouteDefinition 其實僅僅是一箇中間變量,如果流程合理,其實是沒必要常駐內存的,經過優化,我們去除了一份佔用,增加了支持路由的數量。

內存泄漏優化

該問題通用來自於生產實踐,SpringCloud Gateway 底層依賴 netty 進行 IO 通信,熟悉 netty 的人應當知道其有一個讀寫緩衝的設計,如果通信內容較小,一般會命中 chunked buffer,而通信內容較大時,例如文件上傳,則會觸發內存的新分配,而 SpringCloud Gateway 在對接 netty 時存在邏輯缺陷,會導致新分配的池化內存無法完全回收,導致堆外內存泄漏。並且這塊堆外內存時 netty 使用 unsafe 自行分配的,通過常規的 JVM 工具還無法觀測,非常隱蔽。

出於改造成本考量,我們最終選擇的方案是增加一行啓動參數 -Dio.netty.allocator.type=unpooled,使得請求未命中 chunked buffer 時,分配的臨時內存不進行池化,規避內存性能問題。

可能有人會有疑問,-Dio.netty.allocator.type=unpooled會不會導致性能下降,這個擔心完畢沒有必要,首先只有大報文才會觸發該內存的分配,而網關的最佳實踐應該是不允許文件上傳這類需求,加上該參數只是爲了應對非主流場景的一個兜底行爲。

預構建 URI

該熱點問題由 org.springframework.cloud.client.loadbalancer.LoadBalancerUriTools 貢獻,SpringCloud Gateway 引用了 spring-cloud-loadbalancer 解決服務發現和負載均衡的問題。

    private static URI doReconstructURI(ServiceInstance serviceInstance, URI original) {
        String host = serviceInstance.getHost();
        String scheme = (String)Optional.ofNullable(serviceInstance.getScheme()).orElse(computeScheme(original, serviceInstance));
        int port = computePort(serviceInstance.getPort(), scheme);
        if (Objects.equals(host, original.getHost()) && port == original.getPort() && Objects.equals(scheme, original.getScheme())) {
            return original;
        } else {
            boolean encoded = containsEncodedParts(original);
            return UriComponentsBuilder.fromUri(original).scheme(scheme).host(host).port(port).build(encoded).toUri();
        }
    }

注意最後一行構建,實際是針對不可變對象的一次變更,從而進行了一次深拷貝,重新重構了一個 URI,這樣的行爲同樣發生在調用級別,不要小看這類行爲,它會嚴重佔用 CPU。

優化方案便是,對於不可變部分的構造,提前到路由推送時構建,對於可變的調用級別的參數,支持修改。這一點跟路由增量推送的優化是一個道理。

Spring 體系出於契約考慮,大量使用了不可變變量傳遞契約信息,但某些擴展點中,又的確希望對其進行變更,不得已進行了深拷貝,從而造成了性能下降,企業級應用需要在其中尋找到一個平衡點。

對象緩存

儘量避免調用鏈路中出現 new 關鍵字,它會加大 CPU 的開銷,從而影響 IO,可以使用 ThreadLocal 或者對象池化技術進行對象複用。

如果 new 關鍵詞僅出現在初始化,配置推送等異步場景,通常是一次性的行爲,則出於代碼可讀性的考慮,不做太多要求。

總結

今天的分享簡單介紹了一些主流的網關的對比,並重點介紹了 SpringCloud Gateway 適用的場景。並分析了 SpringCloud Gateway 如果在企業中投入生產使用,我們認爲需要新增&改造的一些能力,最後針對一些常見的性能優化場景,介紹了我們的一些優化方案。這些經驗完全來源我們 CSB 2.0 微服務網關基於 SpringCloud Gateway 改造的實踐,CSB 2.0 是一款適用於私有化輸出的網關產品,在今年,我們也會在公有云 EDAS 中將其進行輸出,敬請期待。

作者:徐靖峯(島風)

點擊立即免費試用雲產品 開啓雲上實踐之旅!

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章