各位,众所周知,服务网格通过向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一个请求:
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也就不好使了,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来覆盖它。