引言——从自动扩缩容说起
服务接收到流量请求后,从0自动扩容为N,以及没有流量时自动缩容为0,是一个Serverless平台最本的特征。
可以说,自动扩缩容机制是那颗皇冠,戴上之后你才能被称之为Serverless。
当然了解Kubernetes的人会有疑问,HPA不就是用来干自动扩缩容的事儿的吗?难道我用了HPA就可以摇身一变成为Serverless了。
这里最关键的区别在于,Serverless语义下的自动扩缩容是可以让服务从0到N的,但是HPA不能。HPA的机制是检测服务Pod的metrics数据(例如CPU等)然后把Deployment扩容,但当你把Deployment副本数置为0时,流量进不来,metrics数据永远为0,此时HPA也无能为力。
所以HPA只能让服务从1到N,而从0到1的这个过程,需要额外的机制帮助hold住请求流量,扩容服务,再转发流量到服务,这就是我们常说的冷启动
。
可以说,冷启动
是Serverless皇冠中的那颗明珠,如何实现更好、更快的冷启动,是所有Serverless平台极致追求的目标。
Knative作为目前被社区和各大厂商如此重视和受关注的Serverless平台,当然也在不遗余力的优化自动扩缩容和冷启动功能。
不过,本文并不打算直接介绍Knative自动扩缩容机制,而是先探究一下Knative中的流量实现机制,流量机制和自动扩容密切相关,只有了解其中的奥秘,才能更好的理解Knative autoscale功能。
由于Knative其实包括Building(Tekton)、Serving和Eventing,这里只专注于Serving部分。另外需要提前说明的是,Knative并不强依赖Istio,Serverless网关的实际选择除了集成Istio,还支持Gloo、Ambassador。同时,即使使用了Istio,也可以选择是否使用envoy sidecar注入。本文介绍的时候,我们默认使用的是Istio和注入sidecar的部署方式。
2 简单但是有点过时的老版流量机制
先回顾一下Knative官方的一个简单的原理示意图如下所示。用户创建一个Knative Service(ksv c)后,Knative会自动创建Route(route),Configuration(cfg)资源,然后cfg会创建对应的Revision(rev)版本。rev实际上又会创建Deployment提供服务,流量最终会根据route的配置,导入到相应的rev中。
这是简单的CRD视角,实际上Knative的内部CRD会多一些层次结构,相对更复杂一点。下文会详细描述。 从冷启动和自动扩缩容的实现角度,可以参考一下下图 。从图中可以大概看到,有一个Istio Route充当网关的角色,当服务副本数为0时,自动将流量转发到Activator组件,Activator会hold住流量,同时Autoscaler组件会负责将副本数扩容,之后Activator再将流量导入到实际的Pod,并且在副本数不为0时,Istio Route会直接将流量负载均衡到Pod,不再走Activator组件。这也是Knative实现冷启动的一个基本思路。
在集成使用Istio部署时,Knative Route默认采用的是Istio Ingress Gateway实现,大概在Knative 0.6版本之前,我们可以发现,Route的流量转发本质上是由Istio virtualservice(vs)控制。副本数为0时,vs如下所示,其中destination指向的是Activator组件。此时Activator会帮助转发冷启动时的请求。
代码语言:javascript复制apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: route-f8c50d56-3f47-11e9-9a9a-08002715c9e6
spec:
gateways:
- knative-ingress-gateway
- mesh
hosts:
- helloworld-go.default.example.com
- helloworld-go.default.svc.cluster.local
http:
- appendHeaders:
route:
- destination:
host: Activator-Service.knative-serving.svc.cluster.local
port:
number: 80
weight: 100
当服务副本数不为0之后,vs会变为如下所示,将Ingress Gateway的流量直接打到服务Pod上。
代码语言:javascript复制apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: route-f8c50d56-3f47-11e9-9a9a-08002715c9e6
spec:
hosts:
- helloworld-go.default.example.com
- helloworld-go.default.svc.cluster.local
http:
- match:
route:
- destination:
host: helloworld-go-2xxcn-Service.default.svc.cluster.local
port:
number: 80
weight: 100
无非是通过修改vs的destination来实现冷启动中的流量保持和转发。
相信目前你在网上能找到资料,也基本上停留在该阶段。不过,knative的发展是如此的迅速,以至于,上面分析的细节已经过时。 下面以0.9版本为例,我们仔细探究一下现有的实现方式,和关于Knative流量的真正秘密。
3 复杂但是更优异的新版流量机制
鉴于官方文档并没有最新的具体实现机制介绍,我们创建一个简单的hello-go ksvc,并以此进行分析。ksvc如下所示:
代码语言:javascript复制apiVersion: serving.knative.dev/v1alpha1
kind: Service
metadata:
name: hello-go
namespace: faas
spec:
template:
spec:
containers:
- image: harbor-yx-jd-dev.yx.netease.com/library/helloworld-go:v0.1
env:
- name: TARGET
value: "Go Sample v1"
笔者的环境可简单的认为是一个标准的Istio部署,Serverless网关为Istio Ingress Gateway,所以创建完ksvc后,为了验证服务是否可以正常运行,需要发送http请求至网关。由于Gateway资源已经在部署Knative的时候创建了,所以我们只需要关心一下vs。在服务副本数为0的时候,Knative控制器创建的vs关键配置如下:
代码语言:javascript复制spec:
gateways:
- knative-serving/cluster-local-gateway
- knative-serving/knative-ingress-gateway
hosts:
- hello-go.faas
- hello-go.faas.example.com
- hello-go.faas.svc
- hello-go.faas.svc.cluster.local
- f81497077928a654cf9422088e7522d5.probe.invalid
http:
- match:
- authority:
regex: ^hello-go.faas.example.com(?::d{1,5})?$
gateways:
- knative-serving/knative-ingress-gateway
- authority:
regex: ^hello-go.faas(.svc(.cluster.local)?)?(?::d{1,5})?$
gateways:
- knative-serving/cluster-local-gateway
retries:
attempts: 3
perTryTimeout: 10m0s
route:
- destination:
host: hello-go-fpmln.faas.svc.cluster.local
port:
number: 80
vs指定了已经创建好的gw,同时destination指向的是一个Service域名。这个Service就是Knative默认自动创建的hello-go服务的Service。
不过细心的我们又发现vs的ownerReferences指向了一个Knative的CRD
代码语言:javascript复制ingress.networking.internal.knative.dev:
ownerReferences:
- apiVersion: networking.internal.knative.dev/v1alpha1
blockOwnerDeletion: true
controller: true
kind: Ingress
name: hello-go
uid: 4a27a69e-5b9c-11ea-ae53-fa163ec7c05f
根据名字可以看到这是一个Knative内部使用的CRD,该CRD的内容其实和vs比较类似,同时ingress.networking.internal.knative.dev的ownerReferences指向了我们熟悉的route,总结下来就是:
代码语言:javascript复制route -> ingress.networking.internal.knative.dev -> vs
在网关这一层体现到的CRD资源就是如上这些。这里ingress.networking.internal.knative.dev的意义在于增加一层抽象,如果我们使用的是Gloo等其他网关,则会将ingress.networking.internal.knative.dev转换成相应的网关资源配置。可见Knative正在计划下一盘大棋。 现在,我们已经了解到Serverless网关是由Knative控制器最终生成的vs生效到Istio Ingress Gateway上,为了验证我们刚才部署的服务是否可以正常的运行,简单的用curl命令试验一下。 和所有的网关或者负载均衡器一样,对于7层http访问,我们需要在Header里加域名Host,用于流量转发到具体的服务。在上面的vs中已经可以看到对外域名和内部Service域名均已经配置。所以,只需要:
代码语言:javascript复制curl -v -H'Host:hello-go.faas.example.com' <IngressIP>:<Port>
其中,IngressIP即网关实例对外暴露的IP。 对于冷启动来说,目前的Knative需要等十几秒,即会收到请求。根据之前老版本的经验,这个时候vs会被更新,destination指向hello-go的Service。 不过,现在我们实际发现,vs没有任何变化,仍然指向了服务的Service。这时候,我们才想起来,老版本中服务副本数为0时,其实vs的destination指向的是Activator组件的。但现在,不管服务副本数如何变化,vs一直不变。 蹊跷只能从destination的Service域名入手。 创建ksvc后,Knative会帮我们自动创建Service如下所示。
代码语言:javascript复制$ kubectl -n faas get svc
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
hello-go ExternalName <none> cluster-local-gateway.istio-system.svc.cluster.local <none>
hello-go-fpmln ClusterIP 10.178.4.126 <none> 80/TCP
hello-go-fpmln-m9mmg ClusterIP 10.178.5.65 <none> 80/TCP,8022/TCP
hello-go-fpmln-metrics ClusterIP 10.178.4.237 <none> 9090/TCP,9091/TCP
hello-go Service是一个ExternalName Service,作用是将hello-go的Service域名增加一个dns CNAME别名记录,指向网关的Service域名。
仔细研究hello-go-fpmln Service,我们可以发现这是一个没有label selector的Headless Service,它的Endpoint不是kubernetes自动创建的,需要额外去创建。 hello-go-fpmln-m9mmg Service和hello-go-fpmln-metrics则根据revision label selector到对应的服务Pod。 根据这些Service的一些annotation可以看到,Knative对hello-go-fpmln、hello-go-fpmln-m9mmg 、hello-go-fpmln-metrics这三个Service的定位分别为public Service、private Service和metric Service。 private Service和metric Service其实不难理解。问题的关键就在这里的public Service,并且由上面的分析可以看到,vs的destination指向的就是这里的public Service。 在服务副本数为0时,查看一下Service对应的Endpoint,如下所示:
代码语言:javascript复制$ kubectl -n faas get ep
NAME ENDPOINTS AGE
hello-go-fpmln 172.31.16.81:8012
hello-go-fpmln-m9mmg 172.31.16.121:8012,172.31.16.121:8022
hello-go-fpmln-metrics 172.31.16.121:9090,172.31.16.121:9091
其中,public Service的Endpoint IP是Knative Activator的Pod IP,实际发现Activator的副本数越多这里也会相应的增加。
输入几次curl命令模拟一下http请求,虽然副本数从0开始增加到1了,但是这里的Endpoint却没有变化,仍然为Activator Pod IP。 接着使用hey来压测一下:
代码语言:javascript复制./hey_linux_amd64 -n 1000000 -c 300 -m GET -host helloworld-go.faas.example.com http://<IngressIP>:80
果然,发现Endpoint变化了,通过对比服务的Pod IP,已经变成了新启动的服务Pod IP,不再是Activator Pod的IP。
代码语言:javascript复制$ kubectl -n faas get ep
NAME ENDPOINTS
helloworld-go-mpk25 172.31.16.121:8012
hello-go-fpmln-m9mmg 172.31.16.121:8012,172.31.16.121:8022
hello-go-fpmln-metrics 172.31.16.121:9090,172.31.16.121:9091
代码语言:javascript复制原来,现在新版本的冷启动流量转发机制已经不再是通过修改vs来改变网关的流量转发配置了,而是直接更新服务的public Service后端Endpoint,从而实现将流量从Activator负载均衡到真正的服务负载Pod上。
这样将流量的转发功能内聚到Kubernetes本身Service/Endpoint层,一方面减小了网关的配置更新压力,一方面Knative可以在对接各种不同的网关时的实现时更加解耦,网关层不再需要关心冷启动时的流量转发机制。
再深入从上述的三个Service入手研究,它们的ownerReference是serverlessservice.networking.internal.knative.dev(sks),而sks的ownerReference是podautoscaler.autoscaling.internal.knative.dev(kpa)。
在压测过程中同样发现,sks会在冷启动过后,会从Proxy模式变为Serve模式:
代码语言:javascript复制$ kubectl -n faas get sks
NAME MODE SERVICENAME PRIVATESERVICENAME READY REASON
hello-go-fpmln Proxy hello-go-fpmln hello-go-fpmln-m9mmg True
代码语言:javascript复制$ kubectl -n faas get sks
NAME MODE SERVICENAME PRIVATESERVICENAME READY REASON
hello-go-fpmln Serve hello-go-fpmln hello-go-fpmln-m9mmg True
这也意味着,当流量从Activator导入的时候,sks为Proxy模式,服务真正启动起来后会变成Serve模式,网关流量直接流向服务Pod。
从名称上也可以看到,sks和kpa均为Knative内部CRD,实际上也是由于Knative设计上可以支持自定义的扩缩容方式和支持Kubernetes HPA有关,实现更高一层的抽象。 现在为止,我们可以梳理Knative的绝大部分CRD的关系如下图所示:
一个更复杂的实际实现架构图如下所示。
简单来说,服务副本数为0时,流量路径为:网关-> public Service -> Activator
经过冷启动后,副本数为N时,流量路径为:网关->public Service -> Pod
当然流量到Pod后,实际内部还有Envoy sidecar流量拦截,Queue-Proxy sidecar反向代理,才再到用户的User Container。这里的机制背后实现我们会有另外一篇文章再单独细聊。
4 总结
Knative本身的实现可谓是云原生领域里的一个集大成者,融合Kubernetes、ServiceMesh、Serverless让Knative充满了魅力,但同时也导致了Knative的复杂性。网络流量的稳定保障是Serverless服务真正生产可用性的关键因素,Knative也还在高速的更新迭代中,相信Knative会在未来对网络方面的性能和稳定性投入更多的优化。