Contour 中 Envoy 优雅停服的实现与源码分析

设计文档

envoy shutdown 提案

目标

  • 提供一种途径用于反馈当前连接数和envoy进程负载
  • 允许 envoy 滚动升级过程中尽可能少的丢失连接

非目标

  • 保证滚动升级过程中连接 0 丢失

背景

envoy 进程作为 contour 的数据面组件,有时需要被重新部署。可能是由于升级、修改配置、或者节点问题导致的pod漂移。

contour 早期在 pod 中提供了 preStop hook,用于发送信号给 envoy ,envoy 开始减少连接,readiness probe 会触发实例不健康,envoy 停止接收新的连接。

这种方案的主要问题在于:preStop hook 发送的 /healthcheck/fail 请求没有等到 envoy 处理完所有的连接,因此当容器重启时,客户端会收到错误的返回值。

新的设计方案新增了一个新的组件,提供了一种在发送 SIGTERM 信号前能感知到 envoy 打开的连接是否存在的方式。

设计

实现了一个新的子命令:contour envoy shutdown-manager,用于处理发送给 envoy 的 healthcheck fail 请求,然后开始轮训 http listener 中的活跃连接数,这些信息是通过管理端口 localhost:9001/stats 中暴露的指标获取的。

除此之外,提供了一个可选参数 min-open-connections 参数,用于用户定于在等待连接关闭过程中允许的最小连接数

k8s 中的 prehook 允许容器在发送 SIGTERM 信号前有一段时间做清理工作和其他额外处理

设计细节

  • 实现一个 contour 的子命令,命名为 envoy shutdown-manager
  • 这个命令会暴露一个 http endpoint,端口默认是 8090,访问路径是 /shutdown
  • Envoy 的 Daemonset 中会新增一个容器,这个容器执行这个新的命令,暴露接口
  • 当 preStop hook触发时,Envoy 容器和这个新的容器会被更新
  • 当 pod 收到一个关闭的请求时,preStop hook 将发送一个 localhost:8090/shutdown 的请求,用于告诉 envoy 开始关闭连接,同时开始轮训获取活跃连接数,阻塞知道连接数将为0,或者是用户配置的 min-open-connections
  • pod 中的 terminationGracePeriodSeconds 参数需要设置一个比较大的值(默认30s),允许足够的事件关闭连接,如果时间到了还没有完全关闭,k8s将强制发送 SIGTERM信号并杀死pod
  • 另外一个请求 /healthz,用于检查容器的监控状况
apiVersion: extensions/v1beta1
kind: DaemonSet
metadata:
  annotations:
  labels:
    app: envoy
  name: envoy
  namespace: projectcontour
spec:
  revisionHistoryLimit: 10
  selector:
    matchLabels:
      app: envoy
  template:
    metadata:
      annotations:
        prometheus.io/path: /stats/prometheus
        prometheus.io/port: "8002"
        prometheus.io/scrape: "true"
      creationTimestamp: null
      labels:
        app: envoy
    spec:
      automountServiceAccountToken: false
      containers:
      - command:                          # <----- New Pod
        - /bin/contour
        args:
        	- envoy
        	- shutdown-manager
        image: stevesloka/envoyshutdown
        imagePullPolicy: Always
        lifecycle:
          preStop:                       # <----- PreStop Hook
            exec:
            	command:
            		- /bin/contour
            		- envoy
            		- shutdown
         livenessProbe:                  # <------ Liveness probe  
           httpGet:
             path: /healthz
             port: 8090
           initialDelaySeconds: 3
           periodSeconds: 10
        name: shutdown-manager
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
      - args:
        - -c
        - /config/envoy.json
        - --service-cluster $(CONTOUR_NAMESPACE)
        - --service-node $(ENVOY_POD_NAME)
        - --log-level info
        command:
        - envoy
        env:
        - name: CONTOUR_NAMESPACE
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: metadata.namespace
        - name: ENVOY_POD_NAME
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: metadata.name
        image: docker.io/envoyproxy/envoy:v1.13.0
        imagePullPolicy: IfNotPresent
        lifecycle:                                # <----- PreStop Hook
          preStop:
            httpGet:
              path: /shutdown
              port: 8090
              scheme: HTTP
        name: envoy
        ports:
        - containerPort: 80
          hostPort: 80
          name: http
          protocol: TCP
        - containerPort: 443
          hostPort: 443
          name: https
          protocol: TCP
        readinessProbe:
          failureThreshold: 4
          httpGet:
            path: /ready
            port: 8002
            scheme: HTTP
          initialDelaySeconds: 3
          periodSeconds: 3
          successThreshold: 1
          timeoutSeconds: 1
        volumeMounts:
        - mountPath: /config
          name: envoy-config
        - mountPath: /certs
          name: envoycert
        - mountPath: /ca
          name: cacert
      dnsPolicy: ClusterFirst
      initContainers:
      - args:
        - bootstrap
        - /config/envoy.json
        - --xds-address=contour
        - --xds-port=8001
        - --envoy-cafile=/ca/cacert.pem
        - --envoy-cert-file=/certs/tls.crt
        - --envoy-key-file=/certs/tls.key
        command:
        - contour
        env:
        - name: CONTOUR_NAMESPACE
          valueFrom:
            fieldRef:
              apiVersion: v1
              fieldPath: metadata.namespace
        image: docker.io/projectcontour/contour:master
        imagePullPolicy: Always
        name: envoy-initconfig
        resources: {}
        terminationMessagePath: /dev/termination-log
        terminationMessagePolicy: File
        volumeMounts:
        - mountPath: /config
          name: envoy-config
        - mountPath: /certs
          name: envoycert
          readOnly: true
        - mountPath: /ca
          name: cacert
          readOnly: true
      restartPolicy: Always
      schedulerName: default-scheduler
      securityContext: {}
      terminationGracePeriodSeconds: 300
      volumes:
      - emptyDir: {}
        name: envoy-config
      - name: envoycert
        secret:
          defaultMode: 420
          secretName: envoycert
      - name: cacert
        secret:
          defaultMode: 420
          secretName: cacert
  updateStrategy:
    rollingUpdate:
      maxUnavailable: 10%
    type: RollingUpdate

其他备选方案

  • bash 脚本也可以打到该目的,但是实现比较困难并且难于测试
  • 除了使用 preStop hook 机制,可以调用一个二进制进程去做检查,但是获取 envoy 容器的信息比较困难(可能需要挂载共享磁盘)

原理图

原理图

1: initContainer envoy-initconfig 调用 contour boostrap 生成配置文件 /config/envoy.json

2: 主容器通过磁盘挂载共享 config 文件,并作为启动的配置参数启动 envoy 进程

3:envoy 和服务端通过 XDS 协议做服务发现和路由配置

4-1:envoy关闭前,会执行 preStop 钩子,preStop调用 shutdown-manager 通过 9090 端口暴露的 /shutdown 接口。这个接口会去校验 /ok 文件是否存在,存在才说明 envoy 成功关闭了。不存在说明暂时还不能关闭,接口会阻塞在这里

4-2:和envoy一样(pod 中容器关闭是没有顺序的,可以简单理解为并行执行),shutdown-manager 关闭前,会执行 preStop 钩子,执行 contour envoy shutdown 命令。

5:调用 envoy 后台管理的 Post 请求,请求关闭 envoy

6:检查 envoy 监控指标,当活跃连接数小于某个值才认为关闭成功

7:关闭成功后,会生成 /ok 文件,用于让 /shutdown 接口成功返回

8:当 /ok 文件存在后,说明 envoy 已经优雅关闭了,envoy 进程可以退出。

完成以上步骤,整个 pod 才可以退出

源码分析

从前面的设计文档得知,优雅停机主要跟 shutdown-manger这个进程有关,这里主要是分析 shutdown-manager 这个进程的源码。

shutdown-manager

使用方式:

$ ./contour envoy shutdown-manager -h
usage: contour envoy shutdown-manager [<flags>]

Start envoy shutdown-manager.

Flags:
  -h, --help                   Show context-sensitive help (also try --help-long and --help-man).
      --serve-port=SERVE-PORT  Port to serve the http server on.

代码入口:

// contour/cmd/contour/contour.go
func main() {
  ...
  // 一级子命令:envoy
  envoyCmd := app.Command("envoy", "Sub-command for envoy actions.")
  sdm, shutdownManagerCtx := registerShutdownManager(envoyCmd, log)
  ...
  switch kingpin.MustParse(app.Parse(args)) {
	case sdm.FullCommand():
		doShutdownManager(shutdownManagerCtx)
    ...
  }
}

注册命令行

// contour/cmd/contour/shutdownmanager.go
func registerShutdownManager(cmd *kingpin.CmdClause, log logrus.FieldLogger) (*kingpin.CmdClause, *shutdownmanagerContext) {
	ctx := newShutdownManagerContext()
	ctx.FieldLogger = log.WithField("context", "shutdown-manager")

  // 二级子命令:shutdown-manager
	shutdownmgr := cmd.Command("shutdown-manager", "Start envoy shutdown-manager.")
	shutdownmgr.Flag("serve-port", "Port to serve the http server on.").IntVar(&ctx.httpServePort)

	return shutdownmgr, ctx
}

// 初始化默认参数
func newShutdownManagerContext() *shutdownmanagerContext {
	// Set defaults for parameters which are then overridden via flags, ENV, or ConfigFile
	return &shutdownmanagerContext{
		httpServePort:              8090,
    // const shutdownReadyFile = "/ok"
		shutdownReadyFile:          shutdownReadyFile,
		shutdownReadyCheckInterval: shutdownReadyCheckInterval,
	}
}

命令对应的执行动作:启动 http 服务。内部暴露的 /shutdown 接口,在 envoy 被关闭之前,preStop 钩子会调用这个接口。

// contour/cmd/contour/shutdownmanager.go
func doShutdownManager(config *shutdownmanagerContext) {

	config.Info("started envoy shutdown manager")
	defer config.Info("stopped")

  // 暴露两个接口
	http.HandleFunc("/healthz", config.healthzHandler)
	http.HandleFunc("/shutdown", config.shutdownReadyHandler)
  // 默认监听 8090 端口
	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", config.httpServePort), nil))
}

shutdown 接口对应的 handler 函数:

  • 该接口提供给 Envoy 使用,决定是否可以终止服务
  • 一旦连接数降低到某个阈值(0或者配置的阈值),/ok 这个文件会被创建
  • 当调用 /shutdown 请求时,使用这个 /ok 文件判断 envoy 是否可以被安全的终止
  • 如果没有 /ok 文件,请求会阻塞住
func (s *shutdownmanagerContext) shutdownReadyHandler(w http.ResponseWriter, r *http.Request) {
	l := s.WithField("context", "shutdownReadyHandler")
	ctx := r.Context()
	for {
    // 判断 /ok 文件是否存在
		_, err := os.Stat(s.shutdownReadyFile)
		if os.IsNotExist(err) {
      // 不存在就跳过,等待下次执行。说明这时候连接数还没有将到阈值,还不能安全的退出
			l.Infof("file %s does not exist; checking again in %v", s.shutdownReadyFile,
				s.shutdownReadyCheckInterval)
		} else if err == nil {
			l.Infof("detected file %s; sending HTTP response", s.shutdownReadyFile)
			http.StatusText(http.StatusOK)
			if _, err := w.Write([]byte("OK")); err != nil {
				l.Error(err)
			}
			return
		} else {
			l.Errorf("error checking for file: %v", err)
		}

		select {
    // 休眠一段时间
		case <-time.After(s.shutdownReadyCheckInterval):
		case <-ctx.Done():
			l.Infof("client request cancelled")
			return
		}
	}
}

shutdown

使用方式如下,该命令会在 shutdown-manager 进程被杀死之前的 preStop 中被调用执行。负责关闭 evnoy,同时写入 /ok 文件表明关闭成功。

$ ./contour envoy shutdown -h        
usage: contour envoy shutdown [<flags>]

Initiate an shutdown sequence which configures Envoy to begin draining connections.

Flags:
  -h, --help                   Show context-sensitive help (also try --help-long and --help-man).
      --admin-port=ADMIN-PORT  Envoy admin interface port.
      --check-interval=CHECK-INTERVAL  
                               Time to poll Envoy for open connections.
      --check-delay=60s        Time to wait before polling Envoy for open connections.
      --drain-delay=0s         Time to wait before draining Envoy connections.
      --min-open-connections=MIN-OPEN-CONNECTIONS  
                               Min number of open connections when polling Envoy.

代码入口:

func main() {
	...
  // 一级子命令:envoy
  envoyCmd := app.Command("envoy", "Sub-command for envoy actions.")
	sdmShutdown, sdmShutdownCtx := registerShutdown(envoyCmd, log)
  ...
  switch kingpin.MustParse(app.Parse(args)) {
    ...
  	case sdmShutdown.FullCommand():
			sdmShutdownCtx.shutdownHandler()
    ...
  }
}

注册命令行:

func registerShutdown(cmd *kingpin.CmdClause, log logrus.FieldLogger) (*kingpin.CmdClause, *shutdownContext) {
	ctx := newShutdownContext()
	ctx.FieldLogger = log.WithField("context", "shutdown")

  // 二级命令:shutdown
	shutdown := cmd.Command("shutdown", "Initiate an shutdown sequence which configures Envoy to begin draining connections.")
	shutdown.Flag("admin-port", "Envoy admin interface port.").IntVar(&ctx.adminPort)
	shutdown.Flag("check-interval", "Time to poll Envoy for open connections.").DurationVar(&ctx.checkInterval)
	shutdown.Flag("check-delay", "Time to wait before polling Envoy for open connections.").Default("60s").DurationVar(&ctx.checkDelay)
	shutdown.Flag("drain-delay", "Time to wait before draining Envoy connections.").Default("0s").DurationVar(&ctx.drainDelay)
	shutdown.Flag("min-open-connections", "Min number of open connections when polling Envoy.").IntVar(&ctx.minOpenConnections)

	return shutdown, ctx
}

命令处理函数如下,核心逻辑:

  1. 调用 envoy 后台管理端口 http://localhost:9001/healthcheck/fail 发送请求,表明需要关闭 envoy
  2. 调用 envoy metrics 指标 http://localhost:9001/stats/prometheus 获取连接数,判断是否真的已经关闭了
func (s *shutdownContext) shutdownHandler() {

  // 重试去关闭 envoy
	err := retry.OnError(wait.Backoff{
		Steps:    4,
		Duration: 200 * time.Millisecond,
		Factor:   5.0,
		Jitter:   0.1,
	}, func(err error) bool {
		// Always retry any error.
		return true
	}, func() error {
		s.Infof("attempting to shutdown")
    // 尝试 shutdown
		return shutdownEnvoy(s.adminPort)
	})
	...
	time.Sleep(s.checkDelay)

	for {
    // 连接 envoy 的管理端口,获取指标信息
    // 地址:http://localhost:9001/stats/prometheus
    // 然后获取连接数
    // envoy_http_downstream_cx_active 指标,label 取 ingress_http
		openConnections, err := getOpenConnections(s.adminPort)
		if err != nil {
			s.Error(err)
		} else {
      // 如果连接数 <= 配置的最小连接数
			if openConnections <= s.minOpenConnections {
				...
        // 创建 /ok 文件,表明可以安全的终止服务
				file, err := os.Create(shutdownReadyFile)
				...
				return
			}
		}
    // 连接数没有降下来,需要再 sleep 一段时间
		time.Sleep(s.checkInterval)
	}
}

关闭 envoy 的逻辑

func shutdownEnvoy(adminPort int) error {
  // 向管理端口发送一个将康检查失败的请求
  // http://localhost:9001/healthcheck/fail
	healthcheckFailURL := fmt.Sprintf(healthcheckFailURLFormat, adminPort)
	// 发送 POST 请求
	resp, err := http.Post(healthcheckFailURL, "", nil)
	if err != nil {
		return fmt.Errorf("creating healthcheck fail POST request failed: %s", err)
	}

	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("POST for %q returned HTTP status %s", healthcheckFailURL, resp.Status)
	}
	return nil
}

获取连接数

func getOpenConnections(adminPort int) (int, error) {
  // 指标地址:http://localhost:9001/stats/prometheus
	prometheusURL := fmt.Sprintf(prometheusURLFormat, adminPort)
	// 发送 Get 请求获取指标
	resp, err := http.Get(prometheusURL)
  ...
	// 提取指标中的连接数
	return parseOpenConnections(resp.Body)
}

// parseOpenConnections returns the sum of open connections from a Prometheus HTTP request
func parseOpenConnections(stats io.Reader) (int, error) {
	...
  // 将指标文本转换为对象
	metricFamilies, err := parser.TextToMetricFamilies(stats)
	
  // 获取 envoy_http_downstream_cx_active 指标
	if _, ok := metricFamilies[prometheusStat]; !ok {
		return -1, fmt.Errorf("error finding Prometheus stat %q in the request result", prometheusStat)
	}

	// 查找 ingress_http label的数据并累加
	for _, metrics := range metricFamilies[prometheusStat].Metric {
		for _, labels := range metrics.Label {
			for _, item := range prometheusLabels() {
				if item == labels.GetValue() {
					openConnections += int(metrics.Gauge.GetValue())
				}
			}
		}
	}
	return openConnections, nil
}
本站文章资源均来源自网络,除非特别声明,否则均不代表站方观点,并仅供查阅,不作为任何参考依据!
如有侵权请及时跟我们联系,本站将及时删除!
如遇版权问题,请查看 本站版权声明
THE END
分享
二维码
海报
Contour 中 Envoy 优雅停服的实现与源码分析
envoy 进程作为 contour 的数据面组件,有时需要被重新部署。可能是由于升级、修改配置、或者节点问题导致的pod漂移。
<<上一篇
下一篇>>