Spring Cloud Run In Istio

istio 迈入 1.6 之后趋于稳定,大家都慢慢的开始尝试起来,摆在大家面前会有这么一个问题,原来的旧系统如何兼容 istio

How Spring Cloud Works

Spring Cloud 体系是如何工作的呢?

Spring Cloud 体系中,每一个服务实例会将自己的 IP 地址上报至 Service Registry 中,这一步是通过 Registry Client 实现,当其需要访问其他的实例的时候,也会去 Service Registry 中获取其他实例的 IP 地址

Registry ClientService Registry 都是抽象的构成,在实现的过程中

  • Service Registry 常见为 Eureka Consul ZooKeepr
  • Registry Client 则对应这三者(eurke-client / consul-client / zookeerer-client),这三者会有一个共同的抽象: DiscoveryClient

通用概念

  • 实例: 每一个 Registry Client 所代表的就是一个服务实例,[ PS:通常一个Java程序就是一个实例]
  • 服务: 多个实例组合成一个服务,服务是 Spring Cloud 负载均衡的对象

对于 Eureka 所包含的 Zone 概念在其他的 Client 中并没有相对应的,因此对于 Spring Cloud 通用的抽象是比较的简单的。

基于 Eureka 的流程分析

注册流程

不同的注册中心的区别主要在注册中心自身逻辑上[AP/CP],对于其他部分的影响不大

实例所对应的服务是由 元数据 提供,IP 地址是由系统检测生成 (但是可以手动覆盖)

调用过程

而调用过程,Biz 一般会通过 RestfulTemaplate/Feign 来访问远程地址,通过一些中间件的集成。将 服务名 翻译成 IP 地址

1
2
3
4
5
6
this.restTemplate.exchange(
"http://bookmark-service/{userId}/bookmarks",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<Bookmark>>() {
},(Object) "mstine")

此例中 bookmark-service -> 192.168.xxx.yyyk8s 中,此 IP 约等同于 POD IP

How Istio Works

那么相对应的 istio 体系又是如何工作的呢?

对于 istio 来说,poliot 将监控所有的关心的数据,在客户端进行访问远程地址的时候,通过的是 ClusterIP/DNS 进行访问,istio 通过 tcp 协议中的 dest ip 或者是 http 协议中的 host 进行判断需要访问某个具体的服务,在 Envoy 进行负载均衡。

Isito の Pilot 组件 有深入的解读,就不作展开。

如果我们直接访问某个不受 istio 托管的服务地址 (比如 pod ip/未知的service ip) ,istio 会认为这次请求是 PassthroughCluster 无法利用任何 istio 的特性。

那么问题呢?

我们希望我们的系统能够跑在 istio 享受到 istio 所带来的诸多特性,而非是通过 sidecar 绕了一圈而什么都没有做。

Spring Cloud 体系中,Biz 通过 IP 访问其他服务,istio 并不能区分 192.168.2.3 是哪个服务的地址,会选择直接转发,就谈不上任何管控维度。

WHY [TLDR]

这就要了解到 Istio 的工作原理,istio 将配置转为 Envoy 的配置

xds 备注
Listener LDS 监听一个端口,可以配置多条,监听多个端口
Routes RDS 一个 cluster 是具有完全相同行为的多个 endpoint 它们组成一个Cluster,从 cluster 到 endpoint 的过程称为负载均衡
Clusters CDS 有时候多个 cluster 具有类似的功能,但是是不同的版本号, 可以通过 route 规则,选择将请求路由到某一个版本号
Endpoints EDS 目标的 ip 地址和端口,这个是 proxy 最终将请求转发到的地方

举个例子

比如远端有一个 helloworld.sample.svc.cluster.local ,端口 5000 提供 HTTP 服务

  • Envoy 的 Listener 配置中会监听 0.0.0.0:5000 的数据
1
2
3
$ istioctl proxy-config listeners sleep-854565cb79-9t29h.sample --context cluster2
address port type route
0.0.0.0 5000 Trans: raw_buffer; App: HTTP Route: 5000
  • Envoy 的 Route 配置从上面就知道指向名为 5000Route 配置
    1
    2
    3
    $ istioctl proxy-config routes sleep-854565cb79-9t29h.sample --context cluster2
    NAME DOMAINS MATCH VIRTUAL SERVICE
    5000 helloworld /*

根据 route 规则,我们知道这个服务的出口 clusteroutbound|5000||helloworld.sample.svc.cluster.local

1
2
3
4
5
6
7
8
9
10
"route": {
"cluster": "outbound|5000||helloworld.sample.svc.cluster.local",
"timeout": "0s",
"retryPolicy": {
"retryOn": "connect-failure,refused-stream,unavailable,cancelled,retriable-status-codes",
"numRetries": 2,
"retryHostPredicate": [
{
"name": "envoy.retry_host_predicates.previous_hosts"
}
  • outbound|5000||helloworld.sample.svc.cluster.localCluster 配置
1
2
3
4
5
6
7
8
9
$ istioctl proxy-config clusters sleep-854565cb79-9t29h.sample --fqdn helloworld.sample.svc.cluster.local --context cluster2
SERVICE FQDN PORT SUBSET DIRECTION TYPE DESTINATION RULE
helloworld.sample.svc.cluster.local 5000 - outbound EDS

$ istioctl proxy-config endpoints sleep-854565cb79-9t29h.sample --context cluster2 --cluster "outbound|5000||helloworld.sample.svc.cluster.local"
ENDPOINT STATUS OUTLIER CHECK CLUSTER
10.244.1.9:5000 HEALTHY OK outbound|5000||helloworld.sample.svc.cluster.local
10.244.2.15:5000 HEALTHY OK outbound|5000||helloworld.sample.svc.cluster.local
10.244.2.16:5000 HEALTHY OK outbound|5000||helloworld.sample.svc.cluster.local

我们可以发现,其实最终的目标地址也就是我们的 K8SPOD 地址,而整个拦截的链路最核心的是在 Route 的匹配定义中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
"domains": [
"helloworld.sample.svc.cluster.local",
"helloworld.sample.svc.cluster.local:5000",
"helloworld",
"helloworld:5000",
"helloworld.sample.svc.cluster",
"helloworld.sample.svc.cluster:5000",
"helloworld.sample.svc",
"helloworld.sample.svc:5000",
"helloworld.sample",
"helloworld.sample:5000",
"10.100.41.75", # Cluster IP
"10.100.41.75:5000" # Cluster IP
],

可见,istio 需要目标地址访问的是 dns 中的域名或者是 ClusterIP 才会进行拦截,

解决之道

显然我们有好几个办法来做这个兼容设计,大致上的思路可以分为

  • istio 匹配 pod ip
  • spring cloud 访问 cluster ip

Spring Cloud 访问 Cluster IP

By Eureka Server

eureka 解决这个问题,想办法让 Eureka Server 返回给客户端的是 Service Address

  • Fake Eureka Server 对于注册和心跳保持不处理直接返回 200
  • Fake Eureka Server 对于 Fetch 请求,返回服务名对应的 ClusterIP

我们为每个服务服务创建一个 Cluster IP 对象即可。

By Eureka Client

  • Eureka Client 在返回给 Ribbin 之前将 IP 翻译成 Srv IP

Ribbon: Spring Cloud 客户端负载均衡组件

Istio 匹配 Pod IP

Add Http Header

对于 istio 来说(Envoy), Http filter 是 一个接着一个运行的。

RoutingHTTP Filter 优先级较低,如果我们能在 Routing 之前增加一个 Filter 然后将 IP 转化为 Host 然后加入 HttpHeaders 中,应该就可以满足需求。https://github.com/envoyproxy/envoy-filter-example/blob/master/http-filter-example/http_filter.cc

example
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use log::info;
use proxy_wasm::traits::Context;
use proxy_wasm::traits::HttpContext;
use proxy_wasm::{types::Action};

#[no_mangle]
pub fn _start() {
proxy_wasm::set_log_level(proxy_wasm::types::LogLevel::Trace);
proxy_wasm::set_http_context(|_, _| -> Box<dyn HttpContext> { Box::new(HelloContext {}) });
}

struct HelloContext {}

impl Context for HelloContext {}

impl HttpContext for HelloContext {
fn on_http_request_headers(&mut self, num_headers: usize) -> Action {
info!("Set Host Header As reviews.default.svc.cluster.local.");
self.set_http_request_header("Host", Some("reviews.default.svc.cluster.local"));

// call server pod ip -> cluster ip
self.clear_http_route_cache(); //记得Cache Route Cache !!!!!!! FXXXXXXXXK
return Action::Continue;
}
}

Custom Registry

虽然官网并没有写这部分,其实我们类似于 MCP 的实现,将 Eureka 的数据作为其中一个 Registery 实现一遍,不过这个工程过于浩大,不做展开。

Best Way

重构代码,将 Spring Cloud 的部分除去,保留 Spring Boot 部分即可,不仅仅减少复杂度而且系统会运行的更快。

方案 优势 劣势 研发复杂度 性能
Fake Eureka 架构简单,易于排查 用户可见,增加了非透明性 多版本的适配性 与原生SpringCloud相比没有任何性能的损耗
Isito WASM 用户无感知 增大的系统复杂度 & 降低了可调试性 语言切换等 在转发逻辑中将无法利用原生的缓冲的机制,会带来一些转发性能的损耗(因是in Memory逻辑,应该是很低的值)
公共 一切基于 IP规则的策略都会失效(熔断 & 负载均衡)

参考