如何使用client-go的retrywatcher?

294 阅读7分钟

在使用client-go的watch接口时候碰到异常退出问题,查了一下google没有多少信息,于是扒了一下代码,把自己踩的坑记录下来方便以后自查自纠。

使用client-go watch接口

💡 全局的mycluster都等于*kubernetes.Clientset

1. 如何watch

由于kubernetes整合了etcd的watch功能,我们可以通过watch操作去建立一个长连接,不断的接收数据;这种方式要优于普通的反复轮询请求,降低server端的压力;

使用client-go调用对应对象的Watch()方法之后,会返回一个watch.Event对象,可以对其使用ResultChan()接受watch到的对象。

pod, err := mycluster.Clusterclientset.CoreV1().Pods(appNamespace).Watch(context.TODO(), metav1.ListOptions{LabelSelector: label})
if err != nil {
    log.Error(err)
}
...
event, ok := <-pod.ResultChan()
if !ok {
    log.Error(err)
}

异常:watch接口自动断开

1. 现象

在使用过程中,watch操作持续一段时间就会自动断开

2. 排查

我们进入watch包里面找到streamwatcher.go,其中节选了一些重要片段:

type StreamWatcher struct {
	sync.Mutex
	source   Decoder
	reporter Reporter
	result   chan Event
	stopped  bool
}
...
func NewStreamWatcher(d Decoder, r Reporter) *StreamWatcher {
	sw := &StreamWatcher{
		source:   d,
		reporter: r,
		// It's easy for a consumer to add buffering via an extra
		// goroutine/channel, but impossible for them to remove it,
		// so nonbuffered is better.
		result: make(chan Event),
	}
	go sw.receive()
	return sw
}
...
func (sw *StreamWatcher) receive() {
	defer close(sw.result)
	defer sw.Stop()
	defer utilruntime.HandleCrash()
	for {
		action, obj, err := sw.source.Decode()
		if err != nil {
			// Ignore expected error.
			if sw.stopping() {
				return
			}
			switch err {
			case io.EOF:
				// watch closed normally
			case io.ErrUnexpectedEOF:
				klog.V(1).Infof("Unexpected EOF during watch stream event decoding: %v", err)
			default:
				if net.IsProbableEOF(err) || net.IsTimeout(err) {
					klog.V(5).Infof("Unable to decode an event from the watch stream: %v", err)
				} else {
					sw.result <- Event{
						Type:   Error,
						Object: sw.reporter.AsObject(fmt.Errorf("unable to decode an event from the watch stream: %v", err)),
					}
				}
			}
			return
		}
		sw.result <- Event{
			Type:   action,
			Object: obj,
		}
	}
}

3. 原因

结合代码看一下,StreamWatcher实现了Watch()方法,我们上述调用ResultChan()的时候,实际上返回的是这里的sw.result;

再往下看新建StreamWatcher的时候,有一个”go sw.receive()”, 也就是几乎在新建对象的同步就开始接受处理数据了,最后看到sw的receive()方法可以看到,在处理数据的时候(sw.source.Decode()), 如果err不为nil, 会switch集中error情况,最后会直接return,然后defer sw.Stop();

也就是说如果接受数据解码的时候(sw.source.Decode()), 如果解码失败,那么StreamWatcher就被关闭了,那自然数据通道也就关闭了,造成”watch一段时间之后自动关闭的现象”。

解决办法(1):forinfor

那么既然是这种情况会导致watch断开,那么我们首先想到的就是暴力恢复这个StreamWatcher,代码实现如下:

for {
	pod, err := mycluster.Clusterclientset.CoreV1().Pods(appNamespace).Watch(context.TODO(), metav1.ListOptions{LabelSelector: label})
	if err != nil {
		log.Error(err)
	}
loopier:
	for {
		event, ok := <-pod.ResultChan()
		if !ok {
			time.Sleep(time.Second * 5)
			log.Info("Restarting watcher...")
			break loopier
		}
		// your process logic
	}
}

我们定义一层for嵌套,因为在上述退出的时候会先defer close(sw.result),所以我们接受数据的通道也就是上面代码里的pod.ResultChan()就会关闭,然后我们加一个错误处理,等待5s之后,break掉这个loopier循环,让外层的for循环继续新建StreamWatcher继续监听数据。以此达到持续监听的效果,好处是实现简单,坏处是缺少错误判断,不能针对错误类型分别处理,对于一直出错的场景也只是无脑重启。

解决办法(2):retrywatcher

在官方代码下client-go/tools/watch/retrywatcher.go中其实官方给了一个标准解法,用于解决watch异常退出的问题,下面我们看下这种实现方式:

type RetryWatcher struct {
	lastResourceVersion string
	watcherClient       cache.Watcher
	resultChan          chan watch.Event
	stopChan            chan struct{}
	doneChan            chan struct{}
	minRestartDelay     time.Duration
}
...
func newRetryWatcher(initialResourceVersion string, watcherClient cache.Watcher, minRestartDelay time.Duration) (*RetryWatcher, error) {
	switch initialResourceVersion {
	case "", "0":
		// TODO: revisit this if we ever get WATCH v2 where it means start "now"
		//       without doing the synthetic list of objects at the beginning (see #74022)
		return nil, fmt.Errorf("initial RV %q is not supported due to issues with underlying WATCH", initialResourceVersion)
	default:
		break
	}

	rw := &RetryWatcher{
		lastResourceVersion: initialResourceVersion,
		watcherClient:       watcherClient,
		stopChan:            make(chan struct{}),
		doneChan:            make(chan struct{}),
		resultChan:          make(chan watch.Event, 0),
		minRestartDelay:     minRestartDelay,
	}

	go rw.receive()
	return rw, nil
}

和普通的StreamWatcher很类似,这里面RetryWatcher多了一些结构体字段;lastResourceVersion、minRestartDelay用于出错之后重启Watcher的RV保存,以及重试时间;传入initialResourceVersion和watcherClient(cache.Watcher)即可创建一个RetryWatcher;

同理,RetryWatcher也是在创建对象的同时就开始go rw.receive()接受数据。

func (rw *RetryWatcher) receive() {
	defer close(rw.doneChan)
	defer close(rw.resultChan)

	klog.V(4).Info("Starting RetryWatcher.")
	defer klog.V(4).Info("Stopping RetryWatcher.")

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go func() {
		select {
		case <-rw.stopChan:
			cancel()
			return
		case <-ctx.Done():
			return
		}
	}()

	// We use non sliding until so we don't introduce delays on happy path when WATCH call
	// timeouts or gets closed and we need to reestablish it while also avoiding hot loops.
	wait.NonSlidingUntilWithContext(ctx, func(ctx context.Context) {
		done, retryAfter := rw.doReceive()
		if done {
			cancel()
			return
		}

		time.Sleep(retryAfter)

		klog.V(4).Infof("Restarting RetryWatcher at RV=%q", rw.lastResourceVersion)
	}, rw.minRestartDelay)
}
...
func (rw *RetryWatcher) Stop() {
	close(rw.stopChan)
}

上面代码的receive()函数中,wait包起到核心重试逻辑作用,他会循环执行里面的函数,直到收到context Done 的信号才会往下走;而上面代码的ctx只有两种情况才会被关闭:

  1. 有人调用了RetryWatcher的Stop();
  2. 另外就是rw.doReceive()中返回了done, 也会直接调用cancel()结束wait部分。

而如果接受的done为false,则会正常等待time.Sleep(retryAfter)之后,进行重试,实现RetryWatcher!

接下来就看下这个rw.doReceive(),也就是RetryWatcher的接收处理数据部分, 同时会根据err类型判断是否应该重试:

func (rw *RetryWatcher) doReceive() (bool, time.Duration) {
	watcher, err := rw.watcherClient.Watch(metav1.ListOptions{
		ResourceVersion:     rw.lastResourceVersion,
		AllowWatchBookmarks: true,
	})
	// We are very unlikely to hit EOF here since we are just establishing the call,
	// but it may happen that the apiserver is just shutting down (e.g. being restarted)
	// This is consistent with how it is handled for informers
	switch err {
...
// 省略watch的一些错误处理,都会返回false,也就是继续wait重试
...
	}

	if watcher == nil {
		klog.Error("Watch returned nil watcher")
		// Retry
		return false, 0
	}

	ch := watcher.ResultChan()
	defer watcher.Stop()

	for {
		select {
		case <-rw.stopChan:
			klog.V(4).Info("Stopping RetryWatcher.")
			return true, 0
		case event, ok := <-ch:
			if !ok {
				klog.V(4).Infof("Failed to get event! Re-creating the watcher. Last RV: %s", rw.lastResourceVersion)
				return false, 0
			}

			// We need to inspect the event and get ResourceVersion out of it
			switch event.Type {
			case watch.Added, watch.Modified, watch.Deleted, watch.Bookmark:
				metaObject, ok := event.Object.(resourceVersionGetter)
				...
				resourceVersion := metaObject.GetResourceVersion()
				...
        // All is fine; send the non-bookmark events and update resource version.
				if event.Type != watch.Bookmark {
					ok = rw.send(event)
					if !ok {
						return true, 0
					}
				}
				rw.lastResourceVersion = resourceVersion

				continue

			case watch.Error:
			...
			}
		}
	}
}
...
func (rw *RetryWatcher) send(event watch.Event) bool {
	// Writing to an unbuffered channel is blocking operation
	// and we need to check if stop wasn't requested while doing so.
	select {
	case rw.resultChan <- event:
		return true
	case <-rw.stopChan:
		return false
	}
}

上面代码我已经非常清晰的展示出了doReceive()的处理逻辑,第一步会按照rw中定义的watcher开始真实的监听对应资源对象,这里返回错误的话也会进行rw的重试逻辑;然后会获取真实的watcher.ResultChan()也就是可以获取到真实对象的通道,套用for select模式,循环接受数据,如果数据一直是正常的,那么会通过rw.send(event)发送给rw.ResultChan,然后记录保存rw.lastResourceVersion然后继续接收,实现watch的功能

这里多说一句,由于rw.lastResourceVersion是保存在rw的,也就是及时重启(对应上面的任意一个case返回的是true),会从rw.lastResourceVersion也就是最新的RV开始监听,这样实现根据特定原因重启故障的watcher,比较合理,也很巧妙,是官方的标准答案。

个人版RetryWatcher代码实现:

说了那么多,那么具体要怎么使用这个RetryWatcher呢?我个人做了一个妥协方案可以参考:

还记得RetryWatcher中定义的watcherClient类型吗,需要是cache.Watcher,然后我们看client-go/tools/cache里面定义的cache.Watcher接口,定义如下:

type Watcher interface {
	// Watch should begin a watch at the specified version.
	Watch(options metav1.ListOptions) (watch.Interface, error)
}

也就是实现了Watch(xxxx)这个方法的就是符合的cache.Watcher; 而最开始我们代码正好是通过MyCluster.Clusterclientset.CoreV1().Pods()返回的接口v1.PodInterface中调用的Watch(xxxxx), 那么直接用“MyCluster.Clusterclientset.CoreV1().Pods()”来生成RetryWatcher是不是就行了!

我们来看一下这个接口类型:

type PodInterface interface {
	...
	Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error)
	...
}

答案是不行… 下面这个多了一个ctx参数 🥶,但是我们就是想从Clientset这里用怎么办,自己实现一个符合的结构吧:

type PodListWatch struct {
	MyCluster    *initk8s.MyCluster
	AppNamespace string
}
// watch指定命名空间下的Pod例子
func (p PodListWatch) Watch(options metav1.ListOptions) (watch.Interface, error) {
	return p.MyCluster.Clusterclientset.CoreV1().Pods(p.AppNamespace).Watch(context.TODO(), options)
}

这个PodListWatch的Watch(xxxx)方法正好满足cache.Watcher, 单后watch的方式还是按照我们原先Clientset的,然后生成RetryWatcher的方式如下:

func ResourceWatcher(mycluster *initk8s.MyCluster, appNamespace string) {
	podWatcher := PodListWatch{MyCluster: mycluster, AppNamespace: appNamespace}
	// generate new retrywatcher, use podRetryWatcher.ResultChan() to keep a chronic watch operation
	podRetryWatcher, err := retrywatcher.NewRetryWatcher(label, podWatcher)
	if err != nil {
		log.Error(err)
	}
FORWATCHER:
	for {
		select {
		case <-podRetryWatcher.Done():
			log.Info("Retrywatcher is exiting, please check...")
			break FORWATCHER
		case event, ok := <-podRetryWatcher.ResultChan():
			if !ok {
				log.Warn("Retrywatcher is not open, please check...")
				continue
			}
			...

上述只是一种思路提供,希望能够帮到使用client-go进行watch的同学;官方的实现RetryWatcher确实更加合理,还可以进行改造加减不同情况是否重试的策略。另外watch方法毕竟是直接和kubernetes中的apiserver沟通,如果想要减轻apiserver的压力kubernetes提供了更加常用的informer机制(sharedinformer也是众多controller使用的,同时也是我认为kubernetes最核心的功能),至于使用informer就又有很多要说的了,以后有时间可能会更新~