在使用client-go的watch接口时候碰到异常退出问题,查了一下google没有多少信息,于是扒了一下代码,把自己踩的坑记录下来方便以后自查自纠。
使用client-go watch接口
💡 全局的mycluster都等于*kubernetes.Clientset1. 如何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只有两种情况才会被关闭:
- 有人调用了RetryWatcher的Stop();
- 另外就是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就又有很多要说的了,以后有时间可能会更新~