目录

Istio 1.7 是如何保证sidecar的启动顺序的

前言

2020 年 8 月 27 日,Istio 发布了 1.7 版本,宣称这是一个真正产线可用的版本.其中针对Traffic Management增了一个配置values.global.proxy.holdApplicationUntilProxyStarts,可以支持让 sidecar 启动后才启动你的应用容器,这解决了一个 Istio 一直存在的一个问题:

/images/blog/sidecar-lifecycle-1.gif
容器生命周期(图源自 https://banzaicloud.com)

sidecar 容器 和应用容器的启动顺序是不确定的,如果应用容器先启动了,sidecar 还未完成启动,这时候应用容器往外发送流量,流量仍然会被拦截,发往未启动的 envoy,就会导致请求失败。

现状

我在公司落地Istio时就遇到这个问题, 我们使用的是 1.6 版本,具体的场景就是微服务启动初始化时要调接口拉取配置中心的配置,但是由于 sidecar 未启动,拉取配置失败,服务则会终止启动,导致容器只能等待 sidecar 起来后被 KubernetesrestartPolicy 机制自动拉起.为了解决这个问题,目前业界有比较普遍的作法:

  • 一种是应用容器延迟几秒启动
  • 另一种则是在应用容器的启动脚本调用 envoy 健康检查接口,确保 envoy 启动了再启动应用,这样就避免了应用启动时的访问异常.

由于我们公司目前的微服务基本都是 Go 编写的,而服务初始化拉取配置的逻辑都使用了基础库中封装的逻辑,为了方便,我们直接在基础库拉取配置的代码之前加了WaitSidecar()这么一段逻辑,就是每隔一秒调用envoy的健康检查接口/healthz/ready,直到返回 200 或者超时(理论上此时 envoy 已经正常启动),则继续后续的服务初始化逻辑.

 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
...
// WaitSidecar waits until sidecar is health
func WaitSidecar() {
    //判断是否运行在k8s集群中
	if len(os.Getenv("KUBERNETES_SERVICE_HOST")) > 0 && len(os.Getenv("NO_SIDECAR")) <= 0 {
		tic := time.NewTicker(1 * time.Second)
		defer tic.Stop()
		after := time.After(30 * time.Second)
		for {
			select {
			case <-tic.C:
                logger.Infof("[main] sidecar health checking")
                //sidercar健康检查
				if sidecarHealthCheck() {
					return
				}
			case <-after:
				logger.Warn("[main] sidecar health check timeout after 30 seconds")
				return
			}
		}
	}
	return
}


...

func sidecarHealthCheck() bool {
	cli := http.Client{
		Timeout: 1 * time.Second,
	}
    // envoy健康检查接口
	resp, err := cli.Get("http://127.0.0.1:15021/healthz/ready")
	if err != nil {
		logger.Warn("[main] sidecarHealthCheck failed,err", err)
		return false
	}
	defer resp.Body.Close()
	if resp.StatusCode == 200 {
		return true
	}
	return false
}

加上这段逻辑,基本上解决了我们服务部署时启动异常的问题.这么做,虽然达到了目的,但是所有的服务都需要重新打包部署,一定程度上也违背了 service mesh 的理念,实在是不够优雅,但是有没有更好的实现方式呢?

Istio 1.7 的实现

容器的启动顺序

在出现问个问题的时候,我们都会下意识的认为原因是因为同一个 pod 中的容器都是同时开始启动的,应用容器启动的时间比 sidecar 容器启动时间短导致的。但是根据kubelet的源码,你会发现容器确实是按顺序启动的:

kubelet/kuberuntime/kuberuntime_manager.go

1
2
3
4
    // Step 7: start containers in podContainerChanges.ContainersToStart.
	for _, idx := range podContainerChanges.ContainersToStart {
		start("container", containerStartSpec(&pod.Spec.Containers[idx]))
	}

那么如果我们调整spec.containers,确保 sidecar 容器是第一个个启动的,是不是就解决问题了呢? 实际上,问题依然存在。假设我们的容器的镜像都已经在本地了,那么启动完 sidecar 容器后(可能并未正常工作),启动应用容器花的时间几乎可以忽略不计了,也就是说,容器的启动时间点几乎是同时的.在这种情况下,调整容器的启动顺序其实并不能改变什么.所以,我们只能通过其他方式来解决这个问题.

容器生命周期回调

类似于一些编程语言框架,kubernetes 也为容器提供了生命周期回调,这使得我们可以在相对应的容器生命周期执行一些代码。 kubernetes 提供了两种生命周期回调,并可以为他配置回调处理程序:

  • PostStart:在创建容器后立即执行
  • PreStop:在容器终止之前执行

根据官方文档的说明:

Hook handler calls are synchronous within the context of the Pod containing the Container. This means that for a PostStart hook, the Container ENTRYPOINT and hook fire asynchronously. However, if the hook takes too long to run or hangs, the Container cannot reach a running state.

这意味着,PostStart回调和容器的ENTRYPOINT是异步执行的,只有两者执行都成功了,容器才会达到 running 的状态。

所以,我们完全可以在 sidecar 容器的生命周期回调的PostStart 上做手脚, 让他执行一段脚本或程序去对envoy做健康检查,这样就能阻止第二个容器的启动:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
apiVersion: v1
kind: Pod
metadata:
  name: sidecar-starts-first
spec:
  containers:
    - name: sidecar
      image: my-sidecar
      lifecycle:
        postStart:
          exec:
            command:
              - /bin/wait-until-ready.sh
    - name: application
      image: my-application
/images/blog/sidecar-lifecycle.png
sidecar postStart(图源自文章 Delaying application start until sidecar is ready)

实际上,为了解决 sidecar 启动顺序的问题,Kubernetes官方在 1.18 之后特别引入了sidecar container lifecycle的概念,也就是说,通过对 k8s 配置对应的 lifecycle,就能确保 sidecar container 在应用容器启动之前启动。

/images/blog/sidecar-lifecycle-2.gif
kubernetes1.18之后容器生命周期(图源自 https://banzaicloud.com)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
apiVersion: v1
kind: Pod
metadata:
  name: bookings-v1-b54bc7c9c-v42f6
  labels:
    app: demoapp
spec:
  containers:
  - name: bookings
    image: banzaicloud/allspark:0.1.1
    ...
  - name: istio-proxy
    image: docker.io/istio/proxyv2:1.4.3
    lifecycle:
      type: Sidecar

但是,升级 Kubernetes的版本是有一定风险的,而且当时 istio 官方也并未支持这个 feature,所以,这个方式目前并不行得通。

实现源码简析

在 Istio 发布 1.7 的时候,我对于这个 feature 的实现非常感兴趣,因为 Istio 1.7 针对values.global.proxy.holdApplicationUntilProxyStarts这个 feature,并没有要求 Kubernetes的版本,显然不是基于 1.18 的sidecar container lifecycle实现,所以,就去看了一下官方实现的源码,对应的 PR 可以参考附录。这是相关实现的核心逻辑:

istio/pilot/cmd/pilot-agent/wait.go

 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
var (
	timeoutSeconds       int
	requestTimeoutMillis int
	periodMillis         int
	url                  string

	waitCmd = &cobra.Command{
		Use:   "wait",
		Short: "Waits until the Envoy proxy is ready",
		RunE: func(c *cobra.Command, args []string) error {
			client := &http.Client{
				Timeout: time.Duration(requestTimeoutMillis) * time.Millisecond,
			}
			log.Infof("Waiting for Envoy proxy to be ready (timeout: %d seconds)...", timeoutSeconds)

			var err error
			timeoutAt := time.Now().Add(time.Duration(timeoutSeconds) * time.Second)
			for time.Now().Before(timeoutAt) {
				err = checkIfReady(client, url)
				if err == nil {
					log.Infof("Envoy is ready!")
					return nil
				}
				log.Debugf("Not ready yet: %v", err)
				time.Sleep(time.Duration(periodMillis) * time.Millisecond)
			}
			return fmt.Errorf("timeout waiting for Envoy proxy to become ready. Last error: %v", err)
		},
	}
)

func checkIfReady(client *http.Client, url string) error {
	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return err
	}
	resp, err := client.Do(req)
	if err != nil {
		return err
	}
	defer func() { _ = resp.Body.Close() }()
	_, err = ioutil.ReadAll(resp.Body)
	if err != nil {
		return err
	}
	if resp.StatusCode != 200 {
		return fmt.Errorf("HTTP status code %v", resp.StatusCode)
	}
	return nil
}

对应的实现是在pilot-agent,看完代码你会发现,对应的实现就是为pilot-agent添加的一个wait的命令, 逻辑和我上文中在基础库中的实现几乎是一样的逻辑,定时 envoy的健康检查接口直到返回 200。然后,调整容器的启动顺序将 sidecar 容器放在第一位(init container 之后):

istio/pkg/kube/inject/inject.go

 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
26
27
28
29
30
31
32
func IntoObject(sidecarTemplate string, valuesConfig string, revision string, meshconfig *meshconfig.MeshConfig, in runtime.Object) (interface{}, error) {

...
    podSpec.InitContainers = append(podSpec.InitContainers, spec.InitContainers...)

	podSpec.Containers = injectContainers(podSpec.Containers, spec)
	podSpec.Volumes = append(podSpec.Volumes, spec.Volumes...)
...

}
func injectContainers(target []corev1.Container, sic *SidecarInjectionSpec) []corev1.Container {
	containersToInject := sic.Containers
	if sic.HoldApplicationUntilProxyStarts {
		// inject sidecar at start of spec.containers
		proxyIndex := -1
		for i, c := range containersToInject {
			if c.Name == ProxyContainerName {
				proxyIndex = i
				break
			}
		}
		if proxyIndex != -1 {
			result := make([]corev1.Container, 1, len(target)+len(containersToInject))
			result[0] = containersToInject[proxyIndex]
			result = append(result, target...)
			result = append(result, containersToInject[:proxyIndex]...)
			result = append(result, containersToInject[proxyIndex+1:]...)
			return result
		}
	}
	return append(target, containersToInject...)
}

还有部分修改就是修改 sidecar pod 模板,添加values.global.proxy.holdApplicationUntilProxyStarts的判断,如果为true,则在对应的 lifecycle 的postStart 添加 pilot-agent wait命令.

1
2
3
4
5
6
7
8
  {{- else if .Values.global.proxy.holdApplicationUntilProxyStarts}}
    lifecycle:
      postStart:
        exec:
          command:
          - pilot-agent
          - wait
  {{- end }}

总结

看完Istio1.7的代码,我们会发现,holdApplicationUntilProxyStarts的实现方式其实就是目前通用的解决方案之一的优化版本,而且将对envoy健康检查的逻辑集成到了 pilot-agent。这个实现虽然依然不太优雅,但是为了解决现有的一大痛点和向下兼容低版本的Kubernetes,这也是情理之中的。可以预见的是,在不远的将来,大部分的kubernetes用户的升级到 1.18 后,Istio 官方必然会废弃这个现实,使用更加优雅的sidecar container lifecycle 来实现。

参考 && 推荐阅读

PR#24737

Delaying application start until sidecar is ready

Istio-handbook

Container Lifecycle Hooks

Sidecar container lifecycle changes in Kubernetes 1.18

揭开 Istio Sidecar 注入模型的神秘面纱