前言
各位,知道的越多,就越会发现自己的无知。在面对服务网格这样的新兴概念之时,就更是如此了。回想昨日,满头大汗地研究VirtualService和DestinationRule是干什么用的自己仿佛还近在眼前。然而,在搞明白了服务网格的基本概念之后,我却发现自己甚至坠落进更大的疑惑之中了。
如果你看过了一些istio的基本知识与概念,你应该知道istio为每个数据面的Pod都注入了一个Sidecar,这Sidecar也就是Envoy代理、会拦截所有业务容器收发的请求,并根据用户在网格中应用的VirtualService、DestinationRule等规则,对请求进行这样的以及那样的处理、并最终转发至路由目标(如果是出向请求/outbound,就向被请求方转发;如果是入向请求/inbound,就向业务容器自身转发)。而控制面则存在着一个核心的组件istiod,该组件的其中一个重要职责就是通过一组被称作xds的协议向每个Envoy代理下发配置,以改变Envoy代理处理与转发请求时的行为。
那么,istiod到底向envoy下发了什么配置,而这些配置又如何影响envoy的行为,并最终使得服务网格成为一个有机整体的呢?如果每天只是看这些表层上的概念,相信我们一定这辈子都没法知道这个问题的答案了。俗话说,百闻不如一见,如果不知道这配置是什么样,那实际看一看这配置是什么东西便好了,相信至少也能获得一点启发。
说干就干,我设计了一个简单的方式,让我们(至少我(・∀・*))能一睹envoy配置的芳容,同时大概也许能不被它绕晕,还能看出点名堂出来:
1、创建一个服务网格,并部署一个bookinfo应用。相信如果你了解过istio,那一定很熟悉这个应用了,也很明白这个应用是干啥的。我这辈子部署这玩意不说几千、也有上百遍了。
当然,我推荐你使用ASM的快速入门,帮你快速在ASM中部署一套bookinfo应用。
2、在定义Istio资源这一步,我们不要做的那么快,而是每应用一种Istio资源,我们就把当时ASM网关和productpage这两个Pod(分别作为网关Pod与业务Pod的代表)中envoy的配置信息拉出来,研究一下istiod又往里面加了点啥东西。
怎么拉envoy的配置信息?istio-proxy已经为我们准备了调试接口,我们只要进到容器里面下载一下就行了。
就像这样:
# 先确保kubectl连接的是你的数据面集群
kubecctl exec -it {你的网关或者业务Pod名} -c istio-proxy -n {你的网关或者业务Pod所在的命名空间} -- curl localhost:15000/config_dump > {一个文件名,你把配置下载到这个文件里}
到目前为止,其实我们已经知道了点东西,至少我们现在知道这个配置叫做config_dump了,为自己喝彩吧!
First Peek
好的,那么赶紧来看看这个config_dump里都有啥吧。现在,我们在数据面中部署好了bookinfo应用和ASM网关,但是没有在网格中应用任何istio资源。productpage的config_dump是这样的(网关的也差不多):
你会发现config_dump可真长!足有10000行!所以我在这里把里面的内容收起来了,只留下一点主干内容,以便于看清楚整体的配置结构。
虽然它很长,但也不过是个json罢了。我们发现config_dump的主体就是这个configs,这是一个config的数组,包含了几个方面的配置(嗯,是句废话),我们发现每个配置都指定了一个独特的@type字段,来指定这段配置是有关哪个方面的配置。
看到这里,我们可能要回顾一下Envoy的主要作用是什么,来对照着看一下这些配置。
我们刚才说什么来着?
如果你看过了一些istio的基本知识与概念,你应该知道istio为每个数据面的Pod都注入了一个Sidecar,这Sidecar也就是Envoy代理、会拦截所有业务容器收发的请求,并根据用户在网格中应用的VirtualService、DestinationRule等规则,对请求进行这样的以及那样的处理、并最终转发至路由目标
对,就是这个,我们可以从中得到一些信息:
1、Envoy拦截了业务容器请求(Envoy将请求的发送方称为downstream)
2、Envoy对请求做了一些处理,比如限流、给请求加header之类的(Envoy将每一步处理都称为一个filter)
3、Envoy最终将请求转发到路由目标(Envoy将请求的发送目标称为upstream)
现在我们来看这些config。
BootStrapConfigDump
用于在Envoy启动时加载的一些静态配置,包括类似Sidecar的环境变量等信息。我们可以先不用在意这个。
ListenersConfigDump —— 监听器
这里存储着Envoy的“listeners”配置。顾名思义,listener就是Envoy的“监听器”。Envoy在拦截到请求后,会根据请求的地址与端口,将请求交给匹配的listener处理。
我们看到这个ListenersConfigDump中的listener配置似乎分为两派:static_listners和dynamic_listeners。其实这俩内部是一样的,都是一堆listener的集合,不过static_listners里面是Envoy的静态配置,是Envoy建出来就带的,而dynamic_listeners内部的listener则是istiod通过LDS协议为Envoy下发的~
我们好像已经get到一点istiod在给Envoy下发什么了,真不错。
那么每个listner又长什么样呢?举个例子:
还是太长了,不过作为入门者我们也不用考虑太多,可以重点关注这个json里面的这几个信息:
-
name:每个listener都有个名字,和其它的都不一样,是个用来区分自己的id
-
address:这个listener监听的地址与端口号。在这个例子中,这个listener监听0.0.0.0和9080,也就是说发送到任意地址的9080端口的请求都会被这个listener监听并处理
-
filter_chains:一个filter的列表,这个就厉害了。直到刚才我们都在讨论的是Envoy拦截请求,到这里,我们终于要“处理”请求了。请求到达这个listener之后,Envoy就会将请求交给filter_chain中的这些filter,由配置的filter依次处理请求。我们详细看一下。
实际上,Envoy内置了很多的filter,filter_chains里声明的则是filter的配置数据。
我们注意到这个listener的filter_chains中目前就配置了一个filter,它的名字是envoy.filters.network.http_connection_manager,这是一个专门处理7层协议HTTP请求的filter。比较引起人兴趣的是这个filter内部通过http_filters这个字段又配置了几个小的子filter,看起来明显是负责对HTTP请求不同方面的处理的。比如展开的部分,这里有一个envoy.filters.http.fault,明显是用来处理故障注入的,而它的后面跟着一个envoy.filters.http.router,毫无疑问是用来对请求做路由转发的。
目前就能够很明显的看出,路由转发也是“处理”的一环,实际上、它是Envoy处理请求的最后一步。这个设计倒也不令人以外,而且还是比较清晰的。你甚至从这个配置里就能想象得到Envoy对请求的处理过程:请求先到达envoy.filters.http.fault这个filter内部有一套机制,可以以一定的概率延迟几秒、再将请求交给envoy.filters.http.router去路由,这样故障注入就完成了,完美(虽然实际实现肯定还是差了不少,哈哈)
诶?但是这个路由要转发到哪去是怎么决定的呢?
这个就涉及到Envoy的路由配置了(route_config)。在envoy.filters.network.http_connection_manager里你还能看到一个叫rds的配置,里面配了一个route_config_name,这个route_config_name引用了一个名字:"9080";这个名字连接到我们下一节要说的内容。
RoutesConfigDump —— 路由规则配置
顾名思义,这里存着的就是Envoy的路由配置了。和listeners一样,RoutesConfigDump也分为static_route_configs和dynamic_route_configs,也是分别对应着静态的路由配置和动态下发的路由配置,内部的每个路由配置的结构也都是相同的。
和listeners一样,我们再来看一下一个路由配置是长什么样的:
注意到这个路由配置的name字段是9080,那么这一切就串起来了。上文中提到listener中配置了一个叫做envoy.filters.network.http_connection_manager的filter来处理和转发HTTP请求,它的子filterenvoy.filters.http.router通过读取这个叫做9080的路由配置(route_config)来对请求做最后的转发。
可以看到一个route_config里面也没啥,最主要的内容就是它的virtual_hosts,里面是一堆虚拟主机(virtual_host),我们先看看一个虚拟主机里面有啥:
虚拟主机是一个Envoy在路由过程中使用的概念,一组虚拟主机就是Envoy在路由配置中使用的顶级元素。其实可以先简单地将一个虚拟主机想象成一个服务即可。实际上,虚拟主机顾名思义,就是一个“虚拟”的主机,可以对我们的请求进行响应,其后面可能直接是一个服务,接受我们的所有请求并响应;也有可能是一堆服务,根据请求的不同特征,虚拟主机将请求发送给不同的服务、并返回响应,但对请求的发送方来说,虚拟主机的表现和一个服务是没啥区别的,都是接受请求并给出响应。
原本Pod需要访问一个服务,我们需要使用k8s的DNS机制、将服务域名变为Service ip、再使用ip地址去访问服务。但加入了Sidecar拦截机制后,Envoy配置监听所有发送到某一端口(如9080)的请求,并统一交给一个listener处理;请求到了转发这一步时,Envoy其实是不能知道这个请求是发给哪个服务的,因为只要是9080端口的服务请求都混在这里。此时Envoy就需要一些额外的信息来决定转发目标了。
我们看到一个虚拟主机最重要的字段就是它的“domains”字段,这里配置了一系列的匹配域名来与请求进行匹配。实际的做法是,Envoy通过获取请求中的host请求头字段、并与这里的domains一一比对,如果对上了就说明这个请求应该发送到这个虚拟主机;于是请求就被交给了这个虚拟主机来进行下一步的路由。
在虚拟主机里,我们发现对于七层协议的HTTP请求还可以通过匹配请求的不同属性(如请求的路径、header等)来进行更细致的路由。在这个简单的例子里,虚拟主机details.default.svc.cluster.local:9080的“routes”字段中只有这么一条路由,它包含如下的“match”字段:
...
"match": {
"prefix": "/"
},
...
这说明只要是路径以根路径开头的请求都会走到这条路由,而这条路由中又包含着这么一个“route”字段:
...
"route": {
"cluster": "outbound|9080||details.default.svc.cluster.local",
"timeout": "0s",
"retry_policy": {
"retry_on": "connect-failure,refused-stream,unavailable,cancelled,retriable-status-codes",
"num_retries": 2,
"retry_host_predicate": [
{
"name": "envoy.retry_host_predicates.previous_hosts"
}
],
"host_selection_retry_max_attempts": "5",
"retriable_status_codes": [
503
]
}
...
这个字段就是在描述我们最终匹配到的路由目标了。针对这个目标,可以看到有一些诸如“retry_policy”、“timeout”等的字段来配置请求这个目标时的超时、重试策略等;不过最重要的还是“cluster”字段,它指定了这个路由目标对应着哪个上游“集群”,Envoy最终将请求发送到这个“集群”,而“集群”这个概念就连接到下面这部分的配置 ——
ClustersConfigDump —— 集群
Cluster —— “集群”,又是Envoy内部使用的一个概念。这里的集群明显不是说k8s集群,而是Envoy定义的一个概念,是指“Envoy 连接的一组逻辑相同的上游主机”。在大多数情况下,我们可以把这个“集群”就理解成k8s集群中的一个Service。一个Service通常对应着一组Pod、由这组Pod响应请求并提供同一种服务,而Envoy的这个“集群”实际可以理解成这种“Pod的集合”。不过Envoy的一个集群也不一定就对应着一个Service,因为集群是“一组逻辑相同的上游主机”,所以也有可能是别的符合定义的东西,比如说是服务的一个特定版本(如只是v2版本的reviews服务)。实际上istio著名的版本灰度能力就是基于这个做的,因为两个版本的同一服务实际上可以分成两个集群。(下直接称Cluster为集群,k8s的集群就叫k8s集群)
我们刚才提到virtual host——虚拟主机,可以想象成一个虚拟主机可以路由到不同的服务。但是从Envoy的角度来说,实际上是一个一个虚拟主机可以路由到不同的集群,Envoy最终通过集群来向集群中的端点(大多数情况下就是k8s集群中的Pod)转发请求。
同样的,ClustersConfigDump里面也有static_clusters和dynamic_active_clusters两种,这俩分别是啥意思相信看到现在你也已经明白了╮( ̄▽ ̄)╭。
还是简单看一下一个cluster config里面有啥:
目前这个阶段其实没必要关注太多,我们可以在这个配置里给集群中的Pod配置各种负载均衡或者超时策略等,比较重要的是这个name,实际上除了name其它配置都是可选的。集群的name大多数会由“|”分成四个部分,分别是 inbound 或 outbound 代表入向流量或出向流量、端口号、subcluster 名称(就是对应着destination rule里那个subset)、Service FQDN,由istio的服务发现进行配置,通过这个name我们很容易就能看出来这个集群对应的是k8s集群的哪个服务。
你可能比较关心:既然集群对应着一组Pod,Envoy要在这些Pod之间做负载均衡转发,那这些Pod的地址放在哪了呢?没有Pod地址Envoy怎么知道往哪转发呢?
实际上Pod的信息确实是有的,不过不在config_dump里,而是在另一个叫做clusters的配置里,你可以通过与下载config_dump类似的方式下载clusters:
# 先确保kubectl连接的是你的数据面集群
kubecctl exec -it {你的网关或者业务Pod名} -c istio-proxy -n {你的网关或者业务Pod所在的命名空间} -- curl localhost:15000/clusters > {一个文件名,你把配置下载到这个文件里}
这个clusters是一个plain text的配置,举个例子,里面有这么几行对应着集群outbound|9080||details.default.svc.cluster.local:
里面记载着这个集群的最大连接数(max_connection)等配置、以及各个端点(Pod)的各项数据(如rq_total代表总共发送的请求数)等,具体这些都是啥意思就不再赘述了。
SecretsConfigDump
由于网格中的envoy之间互相通信会使用mTLS模式,因此每个Envoy通信时都需要提供本工作负载的证书,同时为了签发证书还需要istio ca的根证书,这些证书的信息保存在config_dump的这一配置之下。此处不再赘述。
小结、与envoyctl
至此,我们终于把config_dump中这几块主要的配置看了一遍,它们分别是Bootstrap、Listeners(监听器)、Routes(路由)、Clusters(集群)、Secrets,其中又涉及到virtual host(虚拟主机)等细分概念。总体来看,一个典型的HTTP请求在Envoy内部经历了以下事情:
可以看到,整体上一个请求在Envoy内部的处理与转发过程中,listener、route、cluster这几个配置是环环相扣的,它们通过配置的name一层又一层地向下引用(listener内的filter引用route、route内的virtual_host引用cluster),形成了一条引用链,最终将请求从listener递交到具体的cluster。
这里可以用一个小工具——envoyctl,来更加清晰地梳理这一关系。
工具链接:https://github.com/djannot/envoyctl
注意:这是一个用js写的小工具,所以要先安装node.js。
这个工具的主要用途就是解析config_dump文件,然后给你画出一张表来。解析命令:
envoyctl -f
输出就是一张大表,这大表的每一行大抵都是下面的样子:
可以看到这一行上,从左往右把listener的name、listener中指定的route_config的name、route_config中virtual_host匹配的域名、virtual_host内“match”字段指定的子路由匹配规则、virtual_host的路由转发目标、转发目标对应的cluster等信息给你依次列了一遍。这一行就对应着一个请求的处理链条,能够帮你迅速找到某个请求匹配到的处理流程。
发生什么事了
first peek之后,让我们继续开头说到的那个实验,来看看我们在定义各种Istio资源时,Istiod给Envoy到底下发了什么配置,使得Envoy的行为发生改变了呢?
好的,假设我们已经开启了自动注入、在数据面集群部署了bookinfo与ASM网关。
1、定义Gateway资源
对应执行步骤:定义Gateway资源
这一步主要作用于我们的网关,我们的网关实际上也是一个Envoy,只不过它不是作为业务容器的Sidecar、而是独自作为一个Pod存在的,所有的入向流量都要流经我们的ASM网关。
在定义Gateway资源之前,网关的ListenersConfigDump是这样的:
这个配置里面完全没有动态下发的listener配置。
而定义好Gateway之后就变成了这样:
可以看到Istiod为网关下发了一个dyamic_listener,它的名字是0.0.0.0_80,负责监听发往80端口的所有流量。
这个listener内部也引用了一个新下发的route_config,它的名字是http.80。
再来看看网关的RouteConfigDump,里面的确新增了一个叫做http.80的route_config!
这个route_config内部配置了一个virtual_host,这个virtual_host匹配一个*(通配符,匹配所有域名),可以发现这个“*”正是我们在Gateway的hosts字段中配置的域名内容。但是这个virtual_host还没有配置任何路由目标,这很正常,因为我们此时还没有定义任何路由规则!
此时对应关系已经比较明显,我们对照着Gateway资源的结构来看:
apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
name: bookinfo-gateway
spec:
selector:
istio: ingressgateway # use istio default controller
servers:
- port:
number: 80
name: http
protocol: HTTP
hosts:
- "*"
在Gateway的servers配置中,port部分对应着listener,Istiod会根据port的number和protocal下发一个动态的监听器给网关;而hosts字段则规定了这个listener对应的route_config中、virtual_hosts匹配的域名。
这也解释了为什么我们的网关的Pod和Service已经规定了监听的端口,我们却仍然要定义一个Gateway资源把监听端口再指定一遍。因为我们需要Istiod为Envoy下发一个对应端口的listener、作为处理和转发链条的入口,才能让网关具备处理和转发请求的能力。
2、定义虚拟服务
对应执行步骤:定义虚拟服务
我们定义了一个虚拟服务,让发往网关的特定请求能够路由到productpage服务,到这里我们访问入口网关的时候就已经可以看到productpage的页面了。
由于这个虚拟服务还是通过gateways字段指定了生效的网关,所以主要还是看网关的config_dump发生了什么变化。
查看刚才名为http.80的route_config,我们发现其virtual_hosts中已经增加了若干的路由目标:
我们在虚拟服务中规定了五种匹配请求uri的规则:/productpage、/static、/login、/logout、/api/v1/products,将路径匹配这五种规则的请求都发送到productpage服务。可以发现virtual_hosts下正好也增加了五条子路由,其“match”字段也正好就是匹配这五个路径的(后面四个我给折叠了、要不然太长了),而这五条子路由的路由目标则无一例外都是outbound|9080||productpage.default.svc.cluster.local这个cluster(也就对应着productpage服务)。
所以,我们的虚拟服务(VirtualService),实际上就对应着Envoy中的virtual_host。虚拟服务的hosts字段是为了指定virtual_host匹配的域名、从而将其中配置生效到正确的virtual_host;而虚拟服务中http路由字段下的“match”和“route”这两个字段(以http路由为例)正好就对应着virtual_host里一条子路由(你能够看到virtual_host里一条子路由其实也就是这俩字段)。
3、定义目标规则
对应执行步骤:定义目标规则
在这一步,我们定义了一个目标规则(DestinationRule),为reviews服务指定了v1、v2、v3三个subset、其中v2版本的Pod使用LEAST_CONN类型的负载均衡、而v3版本的Pod使用RANDOM类型的负载均衡。
我们刚才提到,在ClustersConfigDump中,每个cluster最重要的就是它的name,name由“|”分成四个部分,分别是 inbound 或 outbound 代表入向流量或出向流量、端口号、subcluster 名称、Service FQDN。
在定义了上述的目标规则后,我们来看config_dump发生了什么变化。我截取了productpage的config_dump的前后变化做例子。在定义这个目标规则前,针对reviews服务,config_dump中只有以下的一个cluster配置:
它的名字是outbound|9080||reviews.default.svc.cluster.local。
而定义目标规则后,我们发现这个reviews的cluster分裂成了仨,其中一个如图所示(其它两个折叠了)
可以看到这个cluster叫做:outbound|9080|v2|reviews.default.svc.cluster.local,当然我们还有v1和v3的。这个cluster的“lb_policy”一项配置被设定成了"LEAST_REQUEST",很明显对应着我们在目标规则中设定的“LEAST_CONN类型的负载均衡”。
于是这也很明显了,目标规则实际上对应着config_dump中的cluster配置。诸如loadBalancer等字段实际上是修改对应cluster的配置、来实现不同负载均衡规则等功能;而subset实际上就是Envoy的cluster本身固有能力的一个封装。
如果你很好奇,也可以在此时看一下Envoy的clusters,就是通过上面说过的这个指令:
# 先确保kubectl连接的是你的数据面集群
kubecctl exec -it {你的网关或者业务Pod名} -c istio-proxy -n {你的网关或者业务Pod所在的命名空间} -- curl localhost:15000/clusters > {一个文件名,你把配置下载到这个文件里}
里面记载的reviews的cluster也分成了三个:v1 v2 v3,对应的Pod地址正好就是三个版本的Pod地址,这里就不赘述了。
4、增加新的虚拟服务
对应执行步骤:增加新的虚拟服务
这里又增加了一个新的虚拟服务,将来自productpage的请求在v2和v3版本的reviews之间进行50:50 的按权重路由。
由于有了之前的经验,我们知道这个虚拟服务肯定是改virtual_host去了,我们来拿productpage的config_dump看一下,看看Istiod又下发了啥:
这是匹配reviews的virtual_host内的子路由。可以发现原先只规定了一个cluster作为路由目标的地方变成了一个“weighted_clusters”,在其中规定了两个cluster并可以根据权重进行路由。可见权重路由也不过是Envoy的又一个能力之一罢了,只不过istio将其抽象封装在了虚拟服务的配置之中。
结语
虽然没看的东西很多,我们姑且算是走马观花地审视了一遍config_dump,也算窥探了一丢丢网格的奥秘吧。
其实这么一看,istiod也并非做了多么复杂的逻辑,而主要是起到了一个翻译器的作用。subset、负载均衡、权重路由、故障注入……这些其实都是Envoy已有的能力、可以通过直接修改Envoy的配置值实现。而Gateway、VirtualService、DestinationRule则分别与listener、virtual_host、cluster一一对应、环环相扣,分别负责翻译Envoy请求处理链条中每一环的具体配置内容。
通过将复杂的Envoy config_dump抽象成为具体的istio资源,用户就能够通过(相对)简单的方式激活Envoy的各项能力、实现流量的灵活治理(虽然还是挺难的(〃´皿`))。而未经抽象的各种能力、也可以通过EnvoyFilter来直接下发原汁原味的envoy配置,让入门用户和高级用户都能各取所需,不得不感慨服务网格的设计哲学。
可悲的是,做完了所有这些事情、写下这篇文章之后,我感觉自己不会的更多了,可能这就是人生吧。:-(
共勉。
参考
istio 数据面调试指南 - zhonghua | 钟华的博客 | zhongfox
https://zhonghua.io/2020/02/12/istio-debug-with-envoy-log/
详细解读Service Mesh的数据面Envoy - 简书
https://www.jianshu.com/p/5720e913759b
Envoy 运维调试 - 简书
https://www.jianshu.com/p/0fc4e20aca35
Istio sidecar proxy 配置 · Istio 服务网格——云原生应用网络构建指南
Istio 的数据平面 Envoy Proxy 配置详解 · Service Mesh|服务网格中文社区
https://www.servicemesher.com/blog/envoy-proxy-config-deep-dive/
Istio 中的 Sidecar 注入及透明流量劫持过程详解 - 知乎