本文最初以英文写成。

我修的这个issue和kubernetes的内存watch cache有关。这部分代码负责维护一份内存里的缓存(我觉得它是用ring buffer实现的),并且在etcd发生变化时保持同步。问题的源头在staging/src/k8s.io/apiserver/pkg/storage/cacher/cacher.go里watch()函数的这一行:

go watcher.processInterval(ctx, cacheInterval, requiredResourceVersion)

这一行在函数快结束的位置,而且可以说是最重要的部分:它起了一个goroutine来让cache watcher保持同步。它前面的那些东西——取RV、配置options、检查feature gates——都只是准备工作。

回到这个issue:它说明某一类特定的客户端会导致api server的watch实现里出现goroutine泄漏。复现代码是这样的:

sendInitialEvents := true
timeoutSeconds := int64(time.Hour.Seconds())
watcher, _ := client.CoreV1().Pods(namespace).Watch(ctxOnce, metav1.ListOptions{
	ResourceVersion:      "0",
	ResourceVersionMatch: metav1.ResourceVersionMatchNotOlderThan,
	SendInitialEvents:    &sendInitialEvents,
	Watch:                true,
	AllowWatchBookmarks:  true,
	TimeoutSeconds:       &timeoutSeconds,
})
defer watcher.Stop()
for {
	select {
	case <-watcher.ResultChan():
                // do not deal with event
                time.Sleep(time.Hour)
	case <-ctx.Done():
		return
	}
}

这段代码跑了一个每小时才活跃一次的watch客户端。有个细节值得注意,就是那个"0"的resource version——它请求的是完整历史。

下一步是去看底层那个goroutine函数,也就是staging/src/k8s.io/apiserver/pkg/storage/cacher/cache_watcher.go里的processInterval。这个函数收到一个cacheInterval,里面有source字段、它的迭代逻辑、一个resourceVersion,还有一个initialEventsEndBookmark。这个类型是用来消化cache和etcd之间的那段间隔的。

那到底是什么让它卡住的?再往下挖就能找到这个:

c.sendWatchCacheEvent(event)

它里面是:

	select {
	case c.result <- *watchEvent:
		c.markBookmarkAfterRvSent(event)
		sentAt = c.clock.Now()
	case <-c.done:
	}

这就是问题的源头:一旦客户端不再从channel里消费,这个select就会阻塞在c.result <- *watchEvent这个case上——复现代码里正是这种情况,watch每小时才读一次。

有了这些,我们就可以从更大的视角看整条控制路径。Watch准备好cache watch,然后调用processInterval,主要的watch和同步逻辑都在里面。它内部有两个阶段。第一个阶段,对于还处在initial阶段的请求,它会通过sendWatchCacheEvent把etcd里的完整历史回放到c.result里。如果客户端不再消费这些事件,就会造成issue里描述的那种卡死。

搞清楚全貌之后,下一个问题就是怎么修。我最初的想法来自issue下面的一条评论,就是把那些已经不在watch cache里的引用清掉。但我很快就发现这么做很麻烦而且不值得——大概需要给每个event/snapshot加上反向引用,删除的时候才能回溯,而且这是热路径上的操作,可能会影响性能。

所以我选了更简单的做法:用一个固定的超时时间——30秒——如果一个watcher在initial阶段发送事件没有任何进展就把它干掉。每次成功发送都会重置计时器,所以它限制的只是一个watch能完全卡住多久,而不是把所有东西发完要多久。这样可以保护那些慢但是一直在推进的watcher,包括initial snapshot很大的那些,不会被不公平地清掉——如果用一个总时长预算就会有这种风险。这个改动能修好复现代码,也解决了连接卡死的问题。

注意processInterval最后会调用process,也就是说这个连接已经走完initial阶段,进入streaming阶段了。因为process里也调用了同一个sendWatchCacheEvent,我一开始以为我的修改也会影响到它——于是又往下挖了挖。结果发现它并不需要这个修改:streaming阶段本身就有一套处理卡死的路径。当它卡住的时候,c.input这个channel会阻塞,而在生产者那一侧,add()很快就会发现这一点并把卡住的watcher关掉。所以它不需要超时这个修复。

PR: https://github.com/kubernetes/kubernetes/pull/141899

Issue: https://github.com/kubernetes/kubernetes/issues/131721