作为业内首个全托管Istio兼容的阿里云服务网格产品ASM,一开始从架构上就保持了与社区、业界趋势的一致性,控制平面的组件托管在阿里云侧,与数据面侧的用户集群独立。ASM产品是基于社区Istio定制实现的,在托管的控制面侧提供了用于支撑精细化的流量管理和安全管理的组件能力。通过托管模式,解耦了Istio组件与所管理的K8s集群的生命周期管理,使得架构更加灵活,提升了系统的可伸缩性。从2022年4月1日起,阿里云服务网格ASM正式推出商业化版本, 提供了更丰富的能力、更大的规模支持及更完善的技术保障,更好地满足客户的不同需求场景,详情可进入阿里云官方网站 - 搜索服务网格ASM。
各位,众所周知,服务网格通过向Pod注入Sidecar代理来实现流量的拦截与治理。拦截固然是有好东西的,有了Sidecar的拦截,我们才能在服务网格中实现非侵入式的流量治理、安全、可观测等等强大功能。可拦截却也带来了不少新的问题和技术挑战。
带来了什么问题呢?别的不说,我们就谈谈这最显而易见的 —— 一个容器变成了俩。
有人说这不废话!注入一个Sidecar,那不肯定是一个变俩吗,这能有什么问题呢?
诶,问题就出在这俩容器上了。以前这Pod里就业务容器老根一个,突出一个一人吃饱全家不饿。你说这Pod启动起没起来?看看业务容器起没起来就完了:他起来了就是起来了,他停止了就是真停止了。可自从有了这Sidecar,因为默认要给所有经过pod的流量都拦一腿,Sidecar实际就成了这业务容器流量的“命门”。
一般来说,在两个容器都运行着的时候,流量肯定还是一如既往的,只是Sidecar要拦截中转下罢了。我们主要看看Pod在启动和停止的时候(注意,如果什么都不配置,启动/停止时这两个容器谁先启动、谁先停止是不确定的!)。
在Pod启动时,如果Sidecar没起来,业务容器先起来了:
-
业务容器将无法响应请求,因为Sidecar还没法进行转发。
在Pod关闭时:如果业务容器还没结束,Sidecar先结束了:
-
业务容器可能有正在处理的请求,此时这些正在处理的请求将无法响应,下游服务会检测到连接中断。
-
后续可能有新的请求过来,此时这些请求将无法处理。
这就是Sidecar模式下、两个容器环境中我们亟需解决的问题:Sidecar与业务容器的生命周期管理。
理想情况下,对于一个常规的响应请求的Pod,我们应该期望:Sidecar容器永远先于业务容器启动,并在业务容器之后停止。我们来看看服务网格能够提供怎样的配置帮我们做到这些。
Sidecar代理启动生命周期配置
我们首先来看一个注入Sidecar的Pod在启动时的流程。
首先需要知道,在服务网格 istio / 阿里云服务网格ASM 中,Sidecar代理是以Envoy作为技术实现,但Sidecar代理容器中并非只有Envoy进程在运行。实际上,Sidecar代理容器中还运行着一个叫做pilot-agent的进程。pilot-agent大概起到以下的这么几种作用:
-
为Envoy生成引导配置并启动Envoy
-
代理容器的健康检查
-
为Envoy更新证书
-
终止Envoy
-
……
可以看到其实pilot-agent基本就起着对Envoy代理进行生命周期管理的作用。
启动Envoy是pilot-agent的一个很重要的职责,pilot-agent会为Envoy代理准备引导配置(也就是config_dump中的BOOTSTRAP部分),并启动Envoy进程。
随便找一个注入Sidecar的Pod YAML:
apiVersion: v1
kind: Pod
metadata:
...
spec:
containers:
- args:
- proxy
- sidecar
- --domain
- $(POD_NAMESPACE).svc.cluster.local
- --proxyLogLevel=warning
- --proxyComponentLogLevel=misc:error
- --log_output_level=default:info
- --concurrency
- "2"
...
name: istio-proxy
...
我们可以看到pilot-agent在启动Envoy时使用的各项参数。
HoldApplicationUntilProxyStarts
现在看看我们在Pod启动时可以为Sidecar配置什么:对于Sidecar容器来说,我们理想的状态是要确保在业务容器启动之前、Sidecar容器就已经启动完成了。
实际上,我们可以通过两个Kubernetes的机制来做到这件事情:
-
在Pod启动时,Pod中的容器是以在YAML中声明的顺序依次启动的。
-
我们可以在Pod YAML中定义lifecycle(参考: https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/ )。lifecycle包含postStart和preStop两个字段,在启动一个容器后,若容器的声明中包含lifecycle.postStart,则postStart中声明的指令会被运行,指令运行完毕后才会启动下一个容器。
Sidecar代理配置项 HoldApplicationUntilProxyStarts 就是用来干这个的。该配置项是一个bool的开关,当此项开启时,Sidecar注入器会做两件事情:
-
保证Sidecar容器的声明顺序位于Pod中所有容器的最前。
-
为Sidecar容器加入带有postStart的lifecycle声明如下:
lifecycle:
postStart:
exec:
command:
- pilot-agent
- wait
postStart中声明的指令pilot-agent wait,其含义是让pilot-agent持续等待、直到Envoy进程启动完毕。
通过上述两步,我们就可以保证Sidecar在Pod启动时的生命周期,不会影响业务容器的流量。
在服务网格ASM中,HoldApplicationUntilProxyStarts是默认在全局开启的,我们也推荐默认开启此项,以确保正确的启动生命周期:
然而,在环境中同时有大量Pod启动时,可能会因为控制面/ API Server压力过大而造成Envoy启动过慢,在这种极端情况下、postStart指令可能持续时间过长并导致整个Pod启动失败。在这种情况下,也可以考虑暂时关闭HoldApplicationUntilProxyStarts,率先保证Pod可以正常启动。
Sidecar代理终止生命周期配置
在Pod停止时,情况可能会更复杂一些,对于Pod来说,大概会发生下面的这些事情:
-
Kubelet会同时向Pod中的所有容器发送SIGTERM信号。
-
如果某个容器的lifecycle中配置了preStop字段,则Kubelet不会立即发送SIGTERM信号,而是先执行preStop中声明的指令,等待指令完成之后再发送SIGTERM信号。
-
容器接受到SIGTERM信号后,会进入自己的停止逻辑。
对于Sidecar容器来说,pilot-agent会接受到SIGTERM信号,并开始处理其停止Envoy的逻辑,因此它的停止流程是这样的。
-
如果Sidecar容器的lifecycle中声明了preStop指令,则会先执行preStop指令。
-
preStop指令执行完毕后,Kubelet向Sidecar容器发送SIGTERM信号。
-
pilot-agent收到停止信号后,进入停止逻辑,此时,pilot-agent会首先向Envoy进程的这个路径post一个请求: POST localhost:15000/drain_listeners?inboundonly&graceful ,在接收到这个请求后,Envoy会停止所有入向端口的监听,不再接收新的连接和请求,但仍然可以继续处理存量的请求。请求路径中的graceful参数则可以令Envoy以尽可能优雅的方式来中断监听(细节可以参考 https://github.com/envoyproxy/envoy/pull/11639 )。
-
pilot-agent随后会sleep一段时间(这个时间间隔可以由Sidecar代理配置项 terminationDrainDuration 来自定义)
-
在sleep结束后,pilot-agent会强制停止Envoy进程,Sidear容器正式停止。
1、TerminationDrainDuration
刚才提到,在Pod停止时、向Envoy发送请求停止接收新的请求后,pilot-agent会sleep一段时间后再停止Envoy进程。sleep这段时间的意义在于:Envoy进程在这段时间之内不会接收新的请求,但仍然可以处理存量的请求,这段时间就可以成为一段“缓冲时间”,让pilot-agent等待Envoy处理完所有存量请求后再行停止,这样就不会让这些存量请求被凭空丢弃了。
Sidecar代理配置项 “Sidecar代理终止等待时长(TerminationDrainDuration)”就是用来配置这一段睡眠时长的,默认配置为5s。
在实际使用中,往往需要根据业务需求调整这个TerminationDrainDuration,如果在收到SIGTERM信号后,业务容器还需要一段比较长的时间来处理存量的请求,则往往需要适当延长TerminationDrainDuration,否则可能发生pilot-agent已经sleep完了,存量请求还没处理完的尴尬情况,此时存量请求还是会被丢弃。
2、EXIT_ON_ZERO_ACTIVE_CONNECTIONS
经过上面的描述,我们可以发现配置TerminationDrainDuration本身还是有一定的局限性的,因为Envoy的生命周期和业务容器的生命周期还是完全没有关系的。我们可以根据对业务的观察和生产经验来设定一个合理的TerminationDrainDuration,但我们永远无法保证pilot-agent睡过了这段时间之后、存量的请求就真的都处理完了。
在新版本的服务网格中,Sidecar代理提供了一个配置项 EXIT_ON_ZERO_ACTIVE_CONNECTIONS,该配置项是以Sidecar容器的环境变量的形式传入的。服务网格ASM在1.15.3.104版本开始支持EXIT_ON_ZERO_ACTIVE_CONNECTIONS,您可以在控制台上找到它对应的选项“终止Pod时等待Sidecar连接数归零”。
此配置项默认关闭。在开启此项后,上述Sidecar代理停止流程中的第4步将会发生变化。
原先:
pilot-agent随后会sleep一段时间(这个时间间隔可以由Sidecar代理配置项 terminationDrainDuration 来自定义)
开启 EXIT_ON_ZERO_ACTIVE_CONNECTIONS 后:
pilot-agent会首先sleep一段时间(这个时间间隔改为由环境变量 MINIMUM_DRAIN_DURATION 来设定,默认为5s),睡醒后每隔1s时间,检测一次Envoy上是否还存在活跃的连接(这个检测是通过向Envoy的管理端口发送stats查询请求GET localhost:15000/stats?usedonly&filter=downstream_cx_active来实现的,参考:https://github.com/istio/istio/pull/35059、https://www.envoyproxy.io/docs/envoy/latest/configuration/listeners/stats)。
可以发现,在开启 EXIT_ON_ZERO_ACTIVE_CONNECTIONS 后,最大的区别在于pilot-agent睡醒后会以1s为周期不断查询当前Envoy的连接状态,当没有活跃的连接后再停止Envoy。活跃连接数无疑是一个判断何时停止Envoy的良好指标,至少比原先粗暴地sleep TerminationDrainDuration的效果要来得好。
需要注意的是,当开启 EXIT_ON_ZERO_ACTIVE_CONNECTIONS 后,由于pilot-agent的停止逻辑发生改变,我们设定的TerminationDrainDuration也就不好使了,社区istio中pilot-agent的停止逻辑参考如下:
func (a *Agent) terminate() {
log.Infof("Agent draining Proxy")
e := a.proxy.Drain()
if e != nil {
log.Warnf("Error in invoking drain listeners endpoint %v", e)
}
// If exitOnZeroActiveConnections is enabled, always sleep minimumDrainDuration then exit
// after min(all connections close, terminationGracePeriodSeconds-minimumDrainDuration).
// exitOnZeroActiveConnections is disabled (default), retain the existing behavior.
if a.exitOnZeroActiveConnections {
log.Infof("Agent draining proxy for %v, then waiting for active connections to terminate...", a.minDrainDuration)
time.Sleep(a.minDrainDuration)
log.Infof("Checking for active connections...")
ticker := time.NewTicker(activeConnectionCheckDelay)
for range ticker.C {
ac, err := a.activeProxyConnections()
if err != nil {
log.Errorf(err.Error())
a.abortCh <- errAbort
return
}
if ac == -1 {
log.Info("downstream_cx_active are not available. This either means there are no downstream connection established yet" +
" or the stats are not enabled. Skipping active connections check...")
a.abortCh <- errAbort
return
}
if ac == 0 {
log.Info("There are no more active connections. terminating proxy...")
a.abortCh <- errAbort
return
}
log.Infof("There are still %d active connections", ac)
}
} else {
log.Infof("Graceful termination period is %v, starting...", a.terminationDrainDuration)
time.Sleep(a.terminationDrainDuration)
log.Infof("Graceful termination period complete, terminating remaining proxies.")
a.abortCh <- errAbort
}
log.Warnf("Aborted proxy instance")
}
3、自定义lifecycle中的preStop与postStart
需要注意到,我们在刚才讨论的TerminationDrainDuration 与 EXIT_ON_ZERO_ACTIVE_CONNECTIONS 都是在我们假设的理想环境中讨论的。所谓假设的理想环境,即是指我们假设Sidecar容器与业务容器的lifecycle中都没有声明preStop,且业务容器也和Sidecar容器一样,在接到SIGTERM后就进入停止流程了。
然而在实际的业务流程中,业务容器往往有着五花八门的生命周期,不能以理想状况一概而论。最典型的状况就比如:业务容器定义了自己的lifecycle,需要在停止之前执行preStop指令,并且在这期间仍然接收了来自外部的请求。此时如果Sidecar容器不设定preStop,直接进入停止流程,则在这段时间内过来的请求可能无法连接,因为Envoy已经停止监听了。
在面对类似这样的情况时,我们可以自定义Sidecar的lifecycle,通过声明里面的preStop来自行实现Sidecar的停止生命周期逻辑,尽可能保证Sidecar容器能够和业务容器的生命周期有比较好的同步。服务网格ASM提供了对Sidecar容器的lifecycle进行自定义的配置项,您可以直接编写json格式的lifecycle来对Sidecar容器的lifecycle字段进行覆写。至于自定义lifecycle的内容则需要根据具体的业务情况来指定编写了。
在默认情况下,如果您开启了HoldApplicationUntilProxyStarts,则Sidecar注入器默认会为Sidecar容器加入以下的lifecycle:
lifecycle:
postStart:
exec:
command:
- pilot-agent
- wait
preStop:
exec:
command:
- /bin/sh
- -c
- sleep 15
除了上面提过的postStart外,还会默认加入一个sleep 15的 preStop,来尽可能地保证Sidecar在业务容器之后终止。当然,这个preStop无法直接适应所有的业务场景,可以通过自定义的lifecycle来覆盖它。