一文搞懂 Kubernetes shareInformer 通知机制的实现

我们在前文 一文搞懂 Kubernetes 的负载均衡 中可以看到监听节点变化的是 informer 机制,本文我们来看看informer机制是如何实现的。

informer监听configmap

我们先看看怎么使用 client-go informer 来监听configmap的变化

go 复制代码
func ConfigWatcher(ctx context.Context, namespace, configMapName string, onChangeFunc ConfigMapOnChangeFunc) (cancel func()) {
	// 获取 api server的客户端
	clientSet, err := GetClient()
	if err != nil {
		panic(err)
	}

	// 实例化 configmap 的 informer
	listWatcher := k8sCache.NewListWatchFromClient(
		clientSet.CoreV1().RESTClient(),
		"configmaps",
		namespace,
		fields.Everything(),
	)

	// 实例化 informer
	informer := k8sCache.NewSharedInformer(
		listWatcher,
		&corev1.ConfigMap{},
		0, // No resync
	)

	// 监听处理方法
	_, err = informer.AddEventHandler(k8sCache.ResourceEventHandlerFuncs{
		// 更新的方法
		UpdateFunc: func(oldObj, newObj interface{}) {
			log.Info().Msg("ConfigMap updated")
			oldConfigMap, ok := oldObj.(*corev1.ConfigMap)
			newConfigMap, ok := newObj.(*corev1.ConfigMap)
			// 更新方法外部传入
			err = onChangeFunc(namespace, configMapName, oldConfigMap, newConfigMap, updateConfigMap)
		},
		// 处理删除的方法
		DeleteFunc: func(obj interface{}) {
			log.Info().Msg("ConfigMap deleted")
			// 更新方法由外部传入
			err = onChangeFunc(namespace, configMapName, nil, nil, deleteConfigMap)
		},
	})

	// 取消informer的方法返回给外面
	stopCh := make(chan struct{})
	cancel = func() {
		close(stopCh)
	}
	// 启动 informer
	go informer.Run(stopCh)
	return cancel
}

这里使用了 shareIndexInformer 来初始化,indexInformer 的区别是可以共享 reflector ,通过增加事件处理函数来处理资源变化。

informer的实现流程

整体实现流程

ShareInformer和Informer的区别

shareIndexInformer 对事件先进行缓存、转发和处理,而 informer 则是直接对事件进行处理。**

informer在processLoop中就直接调用变更处理方法对Event进行处理

informer 实例化出 controller 并运行

go 复制代码
func (s *sharedIndexInformer) Run(stopCh <-chan struct{}) {

	func() {
		// 将锁写在函数范围内,函数执行结束立马释放锁,避免了死锁,编码的一个小技巧
		s.startedLock.Lock()
		defer s.startedLock.Unlock()

		// 初始化DeltaFifo
		fifo := NewDeltaFIFOWithOptions(DeltaFIFOOptions{})

		cfg := &Config{
			// 处理变更的方法
			Process:           s.HandleDeltas,
		}

		// 初始化 controller
		s.controller = New(cfg)
	}()

	// 处理监听的事件
	wg.StartWithChannel(processorStopCh, s.processor.run)
	// 启动 controller
	s.controller.Run(stopCh)
}

controller 通过 Reflector的Run方法来向api server拉取变更

go 复制代码
func (c *controller) Run(stopCh <-chan struct{}) {
	// 初始化 Reflector
	r := NewReflectorWithOptions(
		c.config.ListerWatcher,
		c.config.ObjectType,
		c.config.Queue,
	)

	// 将Reflector 赋值给 controller
	c.reflectorMutex.Lock()
	c.reflector = r
	c.reflectorMutex.Unlock()

	var wg wait.Group
	// 运行reflector run 方法
	wg.StartWithChannel(stopCh, r.Run)
	// 每一秒循环执行 controller 的 processLoop
	wait.Until(c.processLoop, time.Second, stopCh)
	wg.Wait()
}

func (r *Reflector) Run(stopCh <-chan struct{}) {
	wait.BackoffUntil(func() {
		// 循环执行 ListAndWatch方法监听变化
		if err := r.ListAndWatch(stopCh); err != nil {
			//...
		}
	}, r.backoffManager, true, stopCh)
}

func (r *Reflector) ListAndWatch(stopCh <-chan struct{}) error {
	if r.UseWatchList {
		w, err = r.watchList(stopCh)
	}

	// 不是WatchList则走下面的list,拉取该资源下全量的内容
	if fallbackToList {
		err = r.list(stopCh)
	}
	return r.watch(w, stopCh, resyncerrc)
}

list获取对应资源的内容并不是每次都是从最开始获取,而是从relistResourceVersion 的位置开始获取,如果为空则全量拉取

go 复制代码
func (r *Reflector) list(stopCh <-chan struct{}) error {
	// 设置重新拉取的version
	options := metav1.ListOptions{ResourceVersion: r.relistResourceVersion()}

	var list runtime.Object
	go func() {
		// 通过分页进行拉取
		pager := pager.New(pager.SimplePageFunc(func(opts metav1.ListOptions) (runtime.Object, error) {
			return r.listerWatcher.List(opts)
		}))
	}()
	// 获取最新版本的信息
	listMetaInterface, err := meta.ListAccessor(list)
	if err != nil {
		return fmt.Errorf("unable to understand list result %#v: %v", list, err)
	}
	resourceVersion = listMetaInterface.GetResourceVersion()
	// 把数据解析成items
	items, err := meta.ExtractListWithAlloc(list)

	//写入到 store中
	if err := r.syncWith(items, resourceVersion); err != nil {
		// ...
	}

	// 设置重新拉取资源的version
	r.setLastSyncResourceVersion(resourceVersion)
	return nil
}

// 同步items到 DeltaFIFO中
func (r *Reflector) syncWith(items []runtime.Object, resourceVersion string) error {
	found := make([]interface{}, 0, len(items))
	for _, item := range items {
		found = append(found, item)
	}
	return r.store.Replace(found, resourceVersion)
}

这里我们先暂时不管 DeltaFIFO的实现,后续会将,这里先专注整体流程,把它当作是一个存储即可

watchList 监听变化

go 复制代码
func (r *Reflector) watch(w watch.Interface, stopCh <-chan struct{}, resyncerrc chan error) error {
	for {
		if w == nil {
			options := metav1.ListOptions{
				ResourceVersion: r.LastSyncResourceVersion(),
			}
			// 监听变更,把变更扔到watch.ResultChan中
			w, err = r.listerWatcher.Watch(options)
		}
		// 监听新增的事件Add到deltaFIFO中
		err = watchHandler(start, w, r.store, r.expectedType, r.expectedGVK, r.name, r.typeDescription, r.setLastSyncResourceVersion, nil, r.clock, resyncerrc, stopCh)
	}
}

通过 ListWatcher 客户端通过 HTTP chunked 方法接收API的数据并进行解码,传入到 ResultChan 中:

go 复制代码
type StreamWatcher struct {
	result   chan Event
}

// NewStreamWatcher creates a StreamWatcher from the given decoder.
func NewStreamWatcher(d Decoder, r Reporter) *StreamWatcher {
	sw := &StreamWatcher{
		source:   d,
		result: make(chan Event),
	}
	go sw.receive()
	return sw
}

// 获取Chan中的数据
func (sw *StreamWatcher) ResultChan() <-chan Event {
	return sw.result
}

// 接收decode数据并传入到chan中
func (sw *StreamWatcher) receive() {
	for {
		action, obj, err := sw.source.Decode()
		case sw.result <- Event{
			Type:   action,
			Object: obj,
		}:
		}
	}
}

api server通过chunked分块传输,处理客户端请求

go 复制代码
func (s *WatchServer) HandleHTTP(w http.ResponseWriter, req *http.Request) {
	w.Header().Set("Content-Type", s.MediaType)
	w.Header().Set("Transfer-Encoding", "chunked")
	w.WriteHeader(http.StatusOK)

	// 要监听的范围
	kind := s.Scope.Kind
	watchEncoder := newWatchEncoder(req.Context(), kind, s.EmbeddedEncoder, s.Encoder, framer)
	ch := s.Watching.ResultChan()

	for {
		select {
		case event, ok := <-ch:
			if !ok {
				return
			}
			// api server服务端对事件进行编码
			if err := watchEncoder.Encode(event); err != nil {
				//...
			}

			if len(ch) == 0 {
				flusher.Flush()
			}
		}
	}
}

通过 http chunked 来实现数据流监听

bash 复制代码
$ curl -i http://{kube-api-server-ip}:{kube-api-server-port}/api/v1/watch/pods
HTTP/1.1 200 OK
Content-Type: application/json
Transfer-Encoding: chunked
Date: Thu, 02 Jan 2020 20:22:59 GMT
Transfer-Encoding: chunked

{"type":"ADDED", "object":{"kind":"Pod","apiVersion":"v1",...}}
{"type":"ADDED", "object":{"kind":"Pod","apiVersion":"v1",...}}
{"type":"MODIFIED", "object":{"kind":"Pod","apiVersion":"v1",...}}

watchHandler 则将 ResultChan 的变更读取出来之后存储到 DeltasFIFO

go 复制代码
func watchHandler(start time.Time,
	w watch.Interface,
	store Store,
	// 这里省略其他参数,我们先看watch如何将数据读取后放入到队列中
) error {

loop:
	for {
		select {
		// 读取 Chan里面变更的数据,通过事件类型不同调用不同方法加入到队列中
		case event, ok := <-w.ResultChan():
			switch event.Type {
			case watch.Added:
				err := store.Add(event.Object)
			case watch.Modified:
				err := store.Update(event.Object)
			case watch.Deleted:
				err := store.Delete(event.Object)
		}
	}
	return nil
}

到这里我们已经知道了客户端informer是如何从api-server获取变更

processLoop 同步变更

我们重新看一下informer的Run方法,里面同时启动了 processor 的方法

go 复制代码
func (s *sharedIndexInformer) Run(stopCh <-chan struct{}) {
	func() {
		cfg := &Config{
			Process:           s.HandleDeltas,
		}
	}()

	processorStopCh := make(chan struct{})
	wg.StartWithChannel(processorStopCh, s.processor.run)
}

func (c *controller) processLoop() {
	for {
		// 从队列中获取需要消费的数据
		obj, err := c.config.Queue.Pop(PopProcessFunc(c.config.Process))
	}
}

// Process的值为HandleDeltas
func (s *sharedIndexInformer) HandleDeltas(obj interface{}, isInInitialList bool) error {
	if deltas, ok := obj.(Deltas); ok {
		return processDeltas(s, s.indexer, deltas, isInInitialList)
	}
}

// 处理变更的数据
func processDeltas(
	handler ResourceEventHandler,
	clientState Store,
	deltas Deltas,
	isInInitialList bool,
) error {
	for _, d := range deltas {
		switch d.Type {
		case Sync, Replaced, Added, Updated:
			if old, exists, err := clientState.Get(obj); err == nil && exists {
				handler.OnUpdate(old, obj)
			} else {
				handler.OnAdd(obj, isInInitialList)
			}
		case Deleted:
			handler.OnDelete(obj)
		}
	}
	return nil
}
// 这里是将变更的数据通过distribute分发到 addCh中
func (s *sharedIndexInformer) OnAdd(obj interface{}, isInInitialList bool) {  
	s.processor.distribute(addNotification{newObj: obj, isInInitialList: isInInitialList}, false)
}

distribute 将数据加入到 addCh

go 复制代码
// distribute 把事件传递给所有listeners
func (p *sharedProcessor) distribute(obj interface{}, sync bool) {
	for listener, isSyncing := range p.listeners {
		switch {
		case isSyncing:
			// 同步到每一个监听器
			listener.add(obj)
		}
	}
}

func (p *processorListener) add(notification interface{}) {
	p.addCh <- notification
}

启动协程对数据进行获取和消费

go 复制代码
func (p *sharedProcessor) run(stopCh <-chan struct{}) {
	func() {
		for listener := range p.listeners {
			// 获取到变更的数据并进行更新
			p.wg.Start(listener.run)
			p.wg.Start(listener.pop)
		}
		p.listenersStarted = true
	}()
	<-stopCh
}

pop将 addCh 里的数据写入到 nextCh

go 复制代码
func (p *processorListener) pop() {
	var nextCh chan<- interface{}
	var notification interface{}
	for {
		select {
		case nextCh <- notification:
			var ok bool
			// 获取待消费的消息
			notification, ok = p.pendingNotifications.ReadOne()
		case notificationToAdd, ok := <-p.addCh:
			if notification == nil { 
				// 没有等待的消息,则直接写入到nextCh中
				notification = notificationToAdd
				nextCh = p.nextCh
			} else { 
				// 写入待消费的消息
				p.pendingNotifications.WriteOne(notificationToAdd)
			}
		}
	}
}

run方法消费 nextCh 里的数据

go 复制代码
func (p *processorListener) run() {
	wait.Until(func() {
		for next := range p.nextCh {
			// 调用更新的方法处理新旧对象的变化
			switch notification := next.(type) {
			case updateNotification:
				p.handler.OnUpdate(notification.oldObj, notification.newObj)
			case addNotification:
				p.handler.OnAdd(notification.newObj, notification.isInInitialList)
			case deleteNotification:
				p.handler.OnDelete(notification.oldObj)
			}
		}
		close(stopCh)
	}, 1*time.Second, stopCh)
}

DeltaFIFO

从上面的代码可以看到使用了 storeAddReplaceUpdateDelete 的方法来存储变更, Pop 方法来获取Event进行处理 ,所以我们来分析这几个方法是怎么存储这些数据的。

首先要找到store的具体实现,store在最外层传入的是DeltaFIFO

go 复制代码
func (s *sharedIndexInformer) Run(stopCh <-chan struct{}) {
	func() {
		fifo := NewDeltaFIFOWithOptions(DeltaFIFOOptions{
			KnownObjects:          s.indexer, // 这里的实现是cache.cache,后续我们会进行分析
			EmitDeltaTypeReplaced: true,
			Transformer:           s.transform,
		})

		cfg := &Config{
			// 构建Reflector的时候会传入 Queue 来作为 Store
			Queue:             fifo,
		}
	}()
}

所以接下来我们分析 DeltaFIFO 的具体实现,我们先通过一张图来了解整体的流程

queueActionLocked

给队列上锁后进行的动作,这里的 actionType 包含了如下:

go 复制代码
type DeltaType string
const(
	Added   DeltaType = "Added"
	Updated DeltaType = "Updated"
	Deleted DeltaType = "Deleted"
	Replaced DeltaType = "Replaced"
	Sync DeltaType = "Sync"
)

实际的处理函数则会将变化加入到队列中,然后进行去重的动作,最后唤醒消费者:

go 复制代码
func (f *DeltaFIFO) queueActionLocked(actionType DeltaType, obj interface{}) error {
	// 获取items的key
	id, err := f.KeyOf(obj)

	// 将新的deltas 加入到deltas中
	oldDeltas := f.items[id]
	newDeltas := append(oldDeltas, Delta{actionType, obj})
	// Deltas进行一个去重的操作,尾部如果都是delete的对象,则认为是重复的
	newDeltas = dedupDeltas(newDeltas)

	if len(newDeltas) > 0 {
		// 如果原先的key不存在,则加入到key的队列中
		if _, exists := f.items[id]; !exists {
			f.queue = append(f.queue, id)
		}
		// 将对象的deltas更新成最新的值
		f.items[id] = newDeltas
		// 如果有在等待Pop的协程则唤醒
		f.cond.Broadcast()
	}else{
		// 这个分支不会出现,出现了的话做日志记录
	}
	return nil
}

Add

上面我们了解了 queueActionLocked 后,Add 、Update和Delete的操作都比较简单,我们这里直接看一个Add的实现,Update和Delete不做赘述。

go 复制代码
func (f *DeltaFIFO) Add(obj interface{}) error {
	f.lock.Lock()
	defer f.lock.Unlock()
	f.populated = true
	return f.queueActionLocked(Added, obj)
}

Replace

该方法主要的作用是将传入的list加入到detlasFIFO中,并且将不存在List中的key全部删除

go 复制代码
func (f *DeltaFIFO) Replace(list []interface{}, _ string) error {
	keys := make(sets.String, len(list))

	// 这里的action为Replaced
	action := Sync
	if f.emitDeltaTypeReplaced {
		action = Replaced
	}

	// 将对象加入到items中
	for _, item := range list {
		// 用set保存这次加入对象的Key值,用于后面替换
		key, err := f.KeyOf(item)
		if err != nil {
			return KeyError{item, err}
		}
		keys.Insert(key)
		if err := f.queueActionLocked(action, item); err != nil {
			//...
		}
	}

	// 删除不是本次添加的Deltas
	queuedDeletions := 0
	for k, oldItem := range f.items {
		// 通过key判断对象是否存在
		if keys.Has(k) {
			continue
		}
		// 获取要删除的对象并,给队列添加一个 deleted动作,相当于把对象删除了
		var deletedObj interface{}
		if n := oldItem.Newest(); n != nil {}
		if err := f.queueActionLocked(Deleted, DeletedFinalStateUnknown{k, deletedObj}); err != nil {
			return err
		}
	}
	return nil
}

Pop

消费的时候会通过 Pop 方法来获取变更,这里传入的 PopProcessFunc 就是我们最开始看到的 HandleDeltas

go 复制代码
func (f *DeltaFIFO) Pop(process PopProcessFunc) (interface{}, error) {
	for {
		// 如果队列是空则进行等待
		for len(f.queue) == 0 {
			f.cond.Wait()
		}
	
		// 获取队头的key,并将队头删除
		id := f.queue[0]
		f.queue = f.queue[1:]

		// 获取deltas给到处理函数,isInInitialList是第一次Replace插入的对象个数
		item, ok := f.items[id]
		err := process(item, isInInitialList)

		// 将detlas返回
		return item, err
	}
}

Indexer

cache.cache 是 indexed 的实现,在 DeltaFIFO 中用来查找已知的对象。同个对象的deltas动作在不同的 clientState 要执行的动作不同,比如Deltas虽然是 Add的类型,但是实际上对象已经在cache里面存在,则需要执行的是Update动作而不是 Add 的动作。

go 复制代码
func processDeltas(
	handler ResourceEventHandler,
	clientState Store,
	deltas Deltas,
	//...
) error {
	for _, d := range deltas {
		switch d.Type {
		case Sync, Replaced, Added, Updated:
			**// 这里会根据对象是否存在来看新增还是更新
			if old, exists, err := clientState.Get(obj); err == nil && exists {
				if err := clientState.Update(obj); err != nil {
					return err
				}
				handler.OnUpdate(old, obj)
			} else {
				if err := clientState.Add(obj); err != nil {
					return err
				}
				handler.OnAdd(obj, isInInitialList)
			}**
	}
}

至此,恭喜你已经学习了Informer整体的处理流程,我们通过一张UML类图来回顾一下关键的对象

  1. 蓝色框中的内容为controller的主要对象,统筹了整个informer的处理流程
  2. 绿色内容为监听变化处理的对象
  3. 橙色内容为与API server交互的对象抽象

Go编程小技巧

wait工具包

v1.29 代码路经 staging/src/k8s.io/apimachinery/pkg/util/wait

k8s抽象了自己的wait group:

go 复制代码
type Group struct {
	wg sync.WaitGroup
}

func (g *Group) Wait() {
	g.wg.Wait()
}

// 这样封装后就不需要自己提前先Add,每次执行都会先做Add的动作
func (g *Group) Start(f func()) {
	g.wg.Add(1)
	go func() {
		defer g.wg.Done()
		f()
	}()
}

// 增加了stopCh的参数,这里也可以直接使用闭包实现
func (g *Group) StartWithChannel(stopCh <-chan struct{}, f func(stopCh <-chan struct{})) {
	g.Start(func() {
		f(stopCh)
	})
}

封装了周期性执行方法,在一定周期内会一直运行,直到 stopCh 收到信号后才停止

go 复制代码
// 按period的时间周期执行f()
func Until(f func(), period time.Duration, stopCh <-chan struct{}) {
	JitterUntil(f, period, 0.0, true, stopCh)
}

func JitterUntil(f func(), period time.Duration, jitterFactor float64, sliding bool, stopCh <-chan struct{}) {
	BackoffUntil(f, NewJitteredBackoffManager(period, jitterFactor, &clock.RealClock{}), sliding, stopCh)
}

// 按一定时间周期去回退
func BackoffUntil(f func(), backoff BackoffManager, sliding bool, stopCh <-chan struct{}) {
	var t clock.Timer
	for {
		// 如果没有退出信号则一直处于循环中
		select {
		case <-stopCh:
			return
		default:
		}
		// 不滑动则period包含了f()运行的时间
		if !sliding {
			t = backoff.Backoff()
		}

		func() {
			defer runtime.HandleCrash()
			f()
		}()
	
		// 如果是滑动则在运行后才计算回退的时间,即等待时间周期 == period
		if sliding {
			t = backoff.Backoff()
		}

		// 这里有可能会发生竞争,为了缓解该问题,在重新开始执行的时候再次检查了stopCh
		select {
		case <-stopCh:
			if !t.Stop() {
				<-t.C()
			}
			return
		case <-t.C():
		}
	}
}

sync.Cond的使用

当资源未准备好的时候,可以通过 sync.Cond.Wait 进入等待,等待资源准备好了之后再通知协程启动。

我们来看一下下面模拟读写文件的例子

go 复制代码
package concurrency

import (
	"fmt"
	"sync"
	"time"
)

var done bool

func read(c *sync.Cond) {
	c.L.Lock()
	// 写入未完成之前先陷入等待
	for !done {
		fmt.Println("read func wait")
		// 内部有unlock,等待被通知后重新唤起,所以临界资源仍然是安全的
		c.Wait()
	}

	fmt.Println("read : ", done)
	c.L.Unlock()
}

func write(c *sync.Cond) {
	c.L.Lock()
	time.Sleep(time.Second)
	fmt.Println("write func signal")
	// 写入完成标记
	done = true
	c.L.Unlock()
	// 唤醒等待的资源
	c.Broadcast()
}

通过测试来进行验证

go 复制代码
func Test_write(t *testing.T) {
	cond := sync.NewCond(&sync.Mutex{})
	// 启动协程去读取,还未写入则不能读取成功
	go read(cond)

	go read(cond)

	time.Sleep(time.Second * 1)

	// 写入数据,完成后就能够读取成功
	write(cond)

	time.Sleep(time.Second * 3)
}

快速回顾

informer的机制总结起来其实就是通过version获取变更,然后将变更存储到队列中(DeltaFIFO)。

然后informer注册自己需要的处理方法(ResourceEventHandler),然后在循环中(processLoop)对变更进行相应的处理。

这个模式就是MQ的Producer和Consumer,最后再用一张比较简单的图来总结整个informer的流程

思考

1.为什么需要有DeltaFIFO,不能直接Watch吗?

要知道为什么需要有,可以先看看DeltaFIFO提供的功能。

它支持将变更的时间进行合并去重,避免了客户端收到重复的时间,减少了整体需要处理的事件,而直接 Watch 则需要处理所有事件。

2.Informer是如何保证事件不丢失的呢?

  • Watch 机制: 监听实时产生的变更,然后将变更加入到队列中,但是这时如果应用重启则会导致在内存中的数据丢失,就需要定期重新扫描最新状态来同步以及重新启动时 list 拉取所有已有的变更来补齐状态
  • Resync Period: Informer 支持设置一个定期重新同步(Resync)的时间间隔。在这个时间间隔内,Informer会主动向 API Server 查询资源对象的最新状态,以确保不会错过任何事件。虽然这并不能保证实时性,但是可以在一定程度上减小事件丢失的可能性。

Refrence

  1. 深入源码分析 kubernetes client-go list-watch 和 informer 机制的实现原理
  2. api-server的设计与实现
  3. How etcd works with and without Kubernetes
  4. 深入源码分析 kubernetes client-go sharedIndexInformer 和 SharedInformerFactory 的实现原理
相关推荐
Orlando cron7 小时前
K8s 中创建一个 Deployment 的完整流程
云原生·容器·kubernetes
企鹅侠客8 小时前
K8s高频命令实操手册
云原生·容器·kubernetes
li3714908909 小时前
k8s中应用容器随redis集群自动重启
redis·容器·kubernetes
ytttr87310 小时前
Rocky Linux 8.9配置Kubernetes集群详解,适用于CentOS环境
linux·kubernetes·centos
小猿姐11 小时前
通过 Chaos Mesh 验证 KubeBlocks Addon 可用性的实践
kubernetes
Wang's Blog13 小时前
K8S R&D: Kubernetes从核心调度到故障排查、网络优化与日志收集指南
网络·kubernetes
asom2214 小时前
互联网大厂Java求职面试实战:Spring Boot到Kubernetes的技术问答
java·spring boot·kubernetes·oauth2·电商·microservices·面试技巧
Bypass--16 小时前
《云原生安全攻防》-- K8s集群安全事件响应
安全·云原生·容器·kubernetes
李的阿洁1 天前
k8s中的容器服务
linux·容器·kubernetes
似水流年 光阴已逝1 天前
Kubernetes Deployment 控制器
云原生·容器·kubernetes