Understanding Kubernetes Custom Controller Informer Mechanism

Introduction

The Kubernetes client-go library provides a powerful mechanism for interacting with the API server. At the heart of this library lies the Informer pattern, which enables developers to efficiently watch and respond to changes in cluster resources. This article explores the internal workings of the Informer mechanism within client-go's tool/cache package.

Architecture Overview

When building custom controllers in Kubernetes, the Informer serves as the primary mechanism for resource synchronization. The architecture consists of several interconnected components that work together to provide efficient event-driven resource monitoring.

The workflow involves the following key components operating in sequence to maintain a local cache and dispatch events to registered handlers.

Reflector Component

The Reflector is responsible for watching specific resource types and populating a local store with the results. It implements the ListAndWatch pattern to maintani synchronization with the API server.

Reflector Structure

Located in tools/cache/reflector.go, the Reflector struct contains the core fields needed for resource synchronization:

type Reflector struct {
    name string
    expectedType reflect.Type
    store Store
    listerWatcher ListerWatcher
    period time.Duration
    resyncPeriod time.Duration
    ShouldResync func() bool
    clock clock.Clock
    resourceVersion string
    resourceVersionMu sync.RWMutex
}

ListAndWatch Implementation

The ListAndWatch method performs an initial list operation to retrieve all resources, then establishes a watch connection starting from that resource version. This approach ensures no events are missed during synchronization.

func (r *Reflector) ListAndWatch(stopCh <-chan struct{}) error {
    // Execute initial list operation
    resourceList, err := r.listerWatcher.List(listOptions)
    items, err := meta.ExtractList(resourceList)
    
    // Populate local store with current state
    if err := r.syncWith(items, resourceVersion); err != nil {
        return fmt.Errorf("failed to sync list result: %v", err)
    }
    r.setLastSyncResourceVersion(resourceVersion)

    // Establish watch connection
    for {
        select {
        case <-stopCh:
            return nil
        default:
        }
        
        watchOptions := metav1.ListOptions{
            ResourceVersion: resourceVersion,
            TimeoutSeconds: &timeoutSeconds,
        }
        
        watcher, err := r.listerWatcher.Watch(watchOptions)
        if err := r.watchHandler(watcher, &resourceVersion, stopCh); err != nil {
            return nil
        }
    }
}

Watch Handler Processing

The watchHandler processes events received from the watch connection and forwards them to the Delta FIFO queue for further processing.

func (r *Reflector) watchHandler(w watch.Interface, resourceVersion *string, 
    stopCh <-chan struct{}) error {
    
loop:
    for {
        select {
        case <-stopCh:
            return errorStopRequested
        case event, ok := <-w.ResultChan():
            if !ok {
                break loop
            }
            
            newResourceVersion := meta.GetResourceVersion(event.Object)
            
            // Route events based on type
            switch event.Type {
            case watch.Added:
                err := r.store.Add(event.Object)
            case watch.Modified:
                err := r.store.Update(event.Object)
            case watch.Deleted:
                err := r.store.Delete(event.Object)
            default:
                utilruntime.HandleError(fmt.Errorf("unknown event type"))
            }
            
            *resourceVersion = newResourceVersion
            r.setLastSyncResourceVersion(newResourceVersion)
        }
    }
    return nil
}

Controller Implementation

The Controller orchestrates the entire Informer pipeline by coordinating between the Reflector and the processing logic.

Controller Interface

type Controller interface {
    Run(stopCh <-chan struct{})
    HasSynced() bool
    LastSyncResourceVersion() string
}

type controller struct {
    config Config
    reflector *Reflector
    reflectorMutex sync.RWMutex
    clock clock.Clock
}

Controller Execution Flow

func (c *controller) Run(stopCh <-chan struct{}) {
    defer utilruntime.HandleCrash()
    
    // Create Reflector with configuration
    r := NewReflector(
        c.config.ListerWatcher,
        c.config.ObjectType,
        c.config.Queue,
        c.config.FullResyncPeriod,
    )
    
    c.reflectorMutex.Lock()
    c.reflector = r
    c.reflectorMutex.Unlock()

    var wg wait.Group
    defer wg.Wait()

    wg.StartWithChannel(stopCh, r.Run)
    // Start the processing loop
    wait.Until(c.processLoop, time.Second, stopCh)
}

Process Loop

The processLoop continuously pops items from the Delta FIFO queue and processes them through the configured handler:

func (c *controller) processLoop() {
    for {
        obj, err := c.config.Queue.Pop(PopProcessFunc(c.config.Process))
        if err != nil {
            // Handle error cases
        }
    }
}

The Process function handles different delta types by updating the local indexer and invoking appropriate event handlers:

Process: func(obj interface{}) error {
    for _, d := range obj.(Deltas) {
        switch d.Type {
        case Sync, Added, Updated:
            if old, exists, err := clientState.Get(d.Object); err == nil && exists {
                if err := clientState.Update(d.Object); err != nil {
                    return err
                }
                handler.OnUpdate(old, d.Object)
            } else {
                if err := clientState.Add(d.Object); err != nil {
                    return err
                }
                handler.OnAdd(d.Object)
            }
        case Deleted:
            if err := clientState.Delete(d.Object); err != nil {
                return err
            }
            handler.OnDelete(d.Object)
        }
    }
    return nil
}

Indexer and Thread-Safe Storage

The Indexer provides a thread-safe storage mechanism with indexing capabilities for efficient object retrieval.

Creating the Indexer

func NewIndexer(keyFunc KeyFunc, indexers Indexers) Indexer {
    return &cache{
        cacheStorage: NewThreadSafeStore(indexers, Indices{}),
        keyFunc:      keyFunc,
    }
}

Thread-Safe Store Implementation

The threadSafeMap provides synchronized access to the underlying data structure:

type threadSafeMap struct {
    items    map[string]interface{}
    indexers Indexers
    indices  Indices
    lock     sync.RWMutex
}

func (c *threadSafeMap) Add(key string, obj interface{}) {
    c.lock.Lock()
    defer c.lock.Unlock()
    
    oldObject := c.items[key]
    c.items[key] = obj
    c.updateIndices(oldObject, obj, key)
}

SharedIndexInformer

The SharedIndexInformer extends the basic Controller with shared caching and event distribution capabilities, allowing multiple listeners to receive the same events.

SharedInformer Interface

type SharedInformer interface {
    AddEventHandler(handler ResourceEventHandler)
    AddEventHandlerWithResyncPeriod(handler ResourceEventHandler, resyncPeriod time.Duration)
    GetStore() Store
    GetController() Controller
    Run(stopCh <-chan struct{})
    HasSynced() bool
    LastSyncResourceVersion() string
}

Internal Structure

type sharedIndexInformer struct {
    indexer    Indexer
    controller Controller
    processor             *sharedProcessor
    cacheMutationDetector CacheMutationDetector
    listerWatcher ListerWatcher
    objectType    runtime.Object
    resyncCheckPeriod time.Duration
    defaultEventHandlerResyncPeriod time.Duration
    clock clock.Clock
    started, stopped bool
    startedLock      sync.Mutex
    blockDeltas sync.Mutex
}

Shared Processor

The sharedProcessor manages multiple listeners and distributes events to all registered handlers:

type sharedProcessor struct {
    listenersStarted bool
    listenersLock    sync.RWMutex
    listeners        []*processorListener
    syncingListeners []*processorListener
    clock            clock.Clock
    wg               wait.Group
}

Processor Listener

Each listener maintains its own notification channel and buffer:

type processorListener struct {
    nextCh chan interface{}
    addCh  chan interface{}
    handler ResourceEventHandler
    pendingNotifications buffer.RingGrowing
}

The listener run method processes incoming notifications:

func (p *processorListener) run() {
    stopCh := make(chan struct{})
    wait.Until(func() {
        err := wait.ExponentialBackoff(retry.DefaultRetry, func() (bool, error) {
            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)
                case deleteNotification:
                    p.handler.OnDelete(notification.oldObj)
                default:
                    utilruntime.HandleError(fmt.Errorf("unrecognized notification"))
                }
            }
            return true, nil
        })
        
        if err == nil {
            close(stopCh)
        }
    }, 1*time.Minute, stopCh)
}

The pop method manages the flow of notifications from the sharedProcessor to the listener:

func (p *processorListener) pop() {
    defer utilruntime.HandleCrash()
    defer close(p.nextCh)
    
    var nextCh chan<- interface{}
    var notification interface{}
    
    for {
        select {
        case nextCh <- notification:
            notification, ok := p.pendingNotifications.ReadOne()
            if !ok {
                nextCh = nil
            }
        case notificationToAdd, ok := <-p.addCh:
            if !ok {
                return
            }
            if notification == nil {
                notification = notificationToAdd
                nextCh = p.nextCh
            } else {
                p.pendingNotifications.WriteOne(notificationToAdd)
            }
        }
    }
}

Event Distribution

The distribute method forwards events to registered listeners:

func (p *sharedProcessor) distribute(obj interface{}, sync bool) {
    p.listenersLock.RLock()
    defer p.listenersLock.RUnlock()

    if sync {
        for _, listener := range p.syncingListeners {
            listener.add(obj)
        }
    } else {
        for _, listener := range p.listeners {
            listener.add(obj)
        }
    }
}

Handling Deltas

The HandleDeltas method processes incoming deltas and updates both the indexer and distributes events:

func (s *sharedIndexInformer) HandleDeltas(obj interface{}) error {
    s.blockDeltas.Lock()
    defer s.blockDeltas.Unlock()

    for _, d := range obj.(Deltas) {
        switch d.Type {
        case Sync, Added, Updated:
            isSync := d.Type == Sync
            s.cacheMutationDetector.AddObject(d.Object)
            
            if old, exists, err := s.indexer.Get(d.Object); err == nil && exists {
                if err := s.indexer.Update(d.Object); err != nil {
                    return err
                }
                s.processor.distribute(
                    updateNotification{oldObj: old, newObj: d.Object}, 
                    isSync,
                )
            } else {
                if err := s.indexer.Add(d.Object); err != nil {
                    return err
                }
                s.processor.distribute(
                    addNotification{newObj: d.Object}, 
                    isSync,
                )
            }
        case Deleted:
            if err := s.indexer.Delete(d.Object); err != nil {
                return err
            }
            s.processor.distribute(
                deleteNotification{oldObj: d.Object}, 
                false,
            )
        }
    }
    return nil
}

Running the Informer

The Run method initializes all components and starts the synchronization process:

func (s *sharedIndexInformer) Run(stopCh <-chan struct{}) {
    defer utilruntime.HandleCrash()
    
    // Initialize DeltaFIFO
    fifo := NewDeltaFIFO(MetaNamespaceKeyFunc, s.indexer)

    cfg := &Config{
        Queue:            fifo,
        ListerWatcher:    s.listerWatcher,
        ObjectType:       s.objectType,
        FullResyncPeriod: s.resyncCheckPeriod,
        RetryOnError:     false,
        ShouldResync:     s.processor.shouldResync,
        Process:          s.HandleDeltas,
    }

    func() {
        s.startedLock.Lock()
        defer s.startedLock.Unlock()
        s.controller = New(cfg)
        s.controller.(*controller).clock = s.clock
        s.started = true
    }()

    processorStopCh := make(chan struct{})
    var wg wait.Group
    defer wg.Wait()
    defer close(processorStopCh)
    
    wg.StartWithChannel(processorStopCh, s.cacheMutationDetector.Run)
    wg.StartWithChannel(processorStopCh, s.processor.run)

    defer func() {
        s.startedLock.Lock()
        defer s.startedLock.Unlock()
        s.stopped = true
    }()
    
    s.controller.Run(stopCh)
}

The sharedProcessor.run method enitializes all listener goroutines:

func (p *sharedProcessor) run(stopCh <-chan struct{}) {
    func() {
        p.listenersLock.RLock()
        defer p.listenersLock.RUnlock()
        
        for _, listener := range p.listeners {
            p.wg.Start(listener.run)
            p.wg.Start(listener.pop)
        }
        p.listenersStarted = true
    }()
    <-stopCh
}

Summary

The Informer mechanism in Kubernetes client-go provides a sophisticated yet elegant solution for resource synchronization. From the Reflector's ListAndWatch operations through the Delta FIFO queue, to the Controller's processing loop, and finally to the SharedIndexInformer's event distribution system, each component plays a crucial role in maintaining cluster state consistency while minimizing API server load through efficient caching and event-driven patterns.

Tags: kubernetes client-go Informer Controller Reflector

Posted on Mon, 03 Aug 2026 16:12:05 +0000 by Irap