Kubernetes — Patterns#

Sampling note: Kubernetes is an XL project (~9,000+ Go files across the main repo plus staging modules). Pattern detection was performed via targeted grep across the full repository (excluding vendor/) to obtain counts and representative examples. Deep-reads were limited to the most architecturally significant subsystems: pkg/controller/, staging/src/k8s.io/client-go/, staging/src/k8s.io/apiserver/, and staging/src/k8s.io/apimachinery/.


Concurrency patterns#

Worker Pool (Controller reconcile workers)#

  • Usage: Every built-in controller in pkg/controller/ uses this pattern. The controller spawns N goroutines (typically 1–25, configurable) each of which runs an identical runWorker loop that pulls items from a rate-limited work queue.
  • Example: pkg/controller/deployment/deployment_controller.go:193
    for i := 0; i < workers; i++ {
        go wait.UntilWithContext(ctx, dc.worker, time.Second)
    }
    worker dequeues one key from dc.queue, calls syncDeployment, then calls queue.Done(key). On error the item is re-queued with exponential back-off.
  • Assessment: Highly idiomatic. The number of workers is explicit and externally configurable. The work queue (client-go/util/workqueue) provides deduplication (if the same key is enqueued twice before it is processed, it only processes once), rate limiting, and retry back-off — eliminating the need for per-worker synchronization.

Channel-based stop/done signalling#

  • Usage: Pervasive — 1,317 make(chan …) calls, 884 select {} blocks (excl. vendor). The dominant pattern is stopCh chan struct{} (or ctx.Done() in newer code) as a broadcast shutdown signal.
  • Example: staging/src/k8s.io/apimachinery/pkg/util/proxy/upgradeaware.go
    writerComplete := make(chan struct{})
    readerComplete := make(chan struct{})
    Two goroutines signal completion through separate done channels; a select waits for both before returning.
  • Assessment: The older idiom (pre–Go 1.7 context) uses chan struct{} for lifecycle control. Newer code increasingly passes context.Context for the same purpose. Both coexist throughout the codebase, which reflects the project’s decade-long history.

Context cancellation & propagation#

  • Usage: 16,784 context.Context parameters; 1,013 context.WithCancel/WithTimeout/WithDeadline calls (excl. vendor). Context is threaded through virtually every public function. The API server’s handler chain attaches a request-scoped context at entry and cancels it when the connection closes.
  • Example: staging/src/k8s.io/apiserver/pkg/server/signal.go:SetupSignalHandler returns a context that is cancelled on SIGTERM/SIGINT; this context is then passed to server.Run(ctx) and propagated through all subsystems.
  • Assessment: Exemplary. Kubernetes treats context.Context as the canonical lifecycle handle for every long-running operation. Components that predate the context package have been progressively retrofitted.

Graceful shutdown via signal context#

  • Usage: staging/src/k8s.io/apiserver/pkg/server/signal.go registers SIGTERM/SIGINT handlers that cancel a root context. All components check ctx.Done() rather than global flags.
  • Assessment: Clean and idiomatic. The pattern ensures all goroutines share a single cancellable root and avoids global mutable state for shutdown signalling.

errgroup (limited use)#

  • Usage: Only 2 files use golang.org/x/sync/errgroup: staging/src/k8s.io/cli-runtime/pkg/resource/visitor.go (fan-out over resource visitors) and pkg/controlplane/controller/leaderelection/leaderelection_controller.go.
  • Assessment: Not the preferred pattern in this codebase. Kubernetes predates errgroup and uses its own wait.Group, sync.WaitGroup, and context-propagating wait.UntilWithContext helpers instead.

Categories summary#

CategoryPresentNotes
Worker poolsYesEvery controller — for i := 0; i < workers loop
Fan-out/fan-inYesScheduler runs Filter plugins in parallel across nodes
Pipeline processingYesAPI server handler chain, scheduler extension point pipeline
Context cancellationYesUniversal — 16k+ uses
Graceful shutdownYesSignal → context → propagation
Rate limitingYesclient-go work queue with token-bucket rate limiter

Error handling#

  • Style: Mixed, but tilting strongly toward modern Go 1.13+ wrapping. Three distinct layers coexist:

    1. Simple sentinel errors: errors.New(...) — 1,729 uses. Used for package-level error variables (e.g., ErrNotFound, ErrAlreadyExists).
    2. Modern wrapped errors: fmt.Errorf("...: %w", err) — 2,402 uses. The dominant style in all code written after 2020.
    3. Domain-typed errors: Custom types carrying structured data (HTTP status code, resource GVK, etc.).
  • Error types defined:

    • StatusError (staging/src/k8s.io/apimachinery/pkg/api/errors/errors.go:35) — wraps an metav1.Status struct; the standard wire-format error for all REST API responses. Every HTTP error from the API server is a StatusError.
    • AmbiguousResourceError (staging/src/k8s.io/apimachinery/pkg/api/meta/errors.go:28) — returned when a short resource name maps to multiple GVRs.
    • VolumeError (staging/src/k8s.io/api/storage/v1/types.go:241) — structured error embedded in API object status for volume attach/detach failures.
    • field.Error and field.ErrorList (staging/src/k8s.io/apimachinery/pkg/util/validation/field/) — structured validation errors with field paths, used throughout admission and validation.
  • Wrapping approach: fmt.Errorf with %w for the majority of new code. errors.Is (296 uses) and errors.As (112 uses) for structured error inspection.

  • Notable pattern — utilruntime.Must: Used to panic on errors that “must not happen” during init (e.g., scheme registration). This is deliberate: if a type is not registered correctly the process should crash immediately rather than silently misbehave.

    utilruntime.Must(core.AddToScheme(scheme))  // panics if registration fails
  • Examples:

    • Validation: staging/src/k8s.io/apimachinery/pkg/util/validation/field/errors.gofield.Required, field.Invalid, etc. return typed *field.Error with JSON path.
    • API errors: apierrors.NewNotFound(resource, name) constructs a StatusError with HTTP 404 and a standard reason string.

Configuration pattern#

  • Approach: Two-stage options→config pattern. No functional options at the component level; no Viper; no environment variable config.

    Stage 1 — *Options struct (flag-backed):

    // cmd/kube-apiserver/app/options/options.go
    type ServerRunOptions struct {
        GenericServerRunOptions *genericoptions.ServerRunOptions
        Etcd                    *genericoptions.EtcdOptions
        SecureServing           *genericoptions.SecureServingOptions
        // ...
    }

    Each field is a nested options sub-struct populated by pflag. Options structs only hold raw (string/int) values.

    Stage 2 — *Config / CompletedConfig (resolved types):

    func (s *ServerRunOptions) Complete(ctx context.Context) (*completedOptions, error) {
        // resolves string IPs to net.IP, loads TLS certs, creates client-go clients, etc.
    }

    The Complete() method validates flag values and produces a CompletedConfig holding live objects. The CompletedConfig type is unexported except via the complete() method, enforcing the two-phase flow.

  • Functional options (limited scope): Used in SharedInformerFactory (the code-generated informer factory) with With* functions:

    type SharedInformerOption func(*sharedInformerFactory) *sharedInformerFactory
    func WithCustomResyncConfig(...) SharedInformerOption { ... }
    func WithNamespace(namespace string) SharedInformerOption { ... }

    This is the generated pattern for informer factories and is not representative of the wider config style.

  • Feature gates: Runtime feature flags used pervasively to gate alpha/beta features:

    if utilfeature.DefaultFeatureGate.Enabled(features.DynamicResourceAllocation) {
        // ...
    }

    Gates are registered in pkg/features/kube_features.go and set via --feature-gates=Foo=true.


Dependency injection#

  • Approach: Manual, explicit constructor injection. No framework (no google/wire, no uber/dig, no uber/fx).

  • Evidence: Every major component is initialized via a New(config) or New(informers, clients, …) function that receives all dependencies as parameters:

    func NewDeploymentController(
        ctx context.Context,
        dInformer appsinformers.DeploymentInformer,
        rsInformer appsinformers.ReplicaSetInformer,
        podInformer coreinformers.PodInformer,
        client clientset.Interface,
    ) (*DeploymentController, error) { ... }

    Dependencies flow from top-level wiring code (e.g., cmd/kube-controller-manager/app/controllermanager.go) down through constructor chains. No container or service locator is used.

  • Config structs as DI vehicle: The ControllerContext struct acts as the dependency container passed to every controller’s InitFunc. It holds the shared informer factory, client set, event recorder, etc.:

    type ControllerContext struct {
        ClientBuilder    clientbuilder.ControllerClientBuilder
        InformerFactory  informers.SharedInformerFactory
        ObjectOrMetaInformerFactory controllermanager.InformerFactory
        // ...
    }

Other notable patterns#

Scheme + SchemeBuilder registration (init-time registry)#

The runtime.Scheme is the central type registry mapping Go types to API Group/Version/Kind tuples. Every API package registers its types at init() time:

// staging/src/k8s.io/api/apps/v1/register.go
var localSchemeBuilder = &SchemeBuilder{AddToScheme}
var AddToScheme = localSchemeBuilder.AddToScheme

func init() {
    localSchemeBuilder.Register(addKnownTypes)
}

install packages then call AddToScheme(scheme) to populate a global scheme. The utilruntime.Must wrapper ensures registration failures are fatal. This pattern appears 827 times (func init()). It is a global mutable registry — a deliberate trade-off for API extensibility.

Table-driven tests (extremely heavy use)#

  • Prevalence: 7,431 occurrences of testCases, testcases, or []struct{ in *_test.go files. This is arguably the most uniformly applied pattern in the entire codebase.
  • Style: Anonymous struct slices with named fields are standard:
    tests := []struct {
        name     string
        input    *v1.Pod
        wantErr  bool
        expected int
    }{
        { name: "nil pod", input: nil, wantErr: true },
        // ...
    }
    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) { ... })
    }
  • Assessment: Table-driven tests are the de-facto Kubernetes standard. They make test cases self-documenting, keep tests concise, and make it easy to add regression cases without duplicating setup code.

Type switch for runtime polymorphism#

  • Usage: 369 switch obj.(type) constructs (excl. vendor), concentrated in apimachinery. Used to dispatch on runtime.Object concrete types without reflection.
  • Example: staging/src/k8s.io/apimachinery/pkg/api/meta/meta.go:40
    switch t := obj.(type) {
    case *metav1.ObjectMeta: return t, nil
    case metav1.Object: return t, nil
    // ...
    }
  • Assessment: Necessary given the project’s pre-generics API type hierarchy. The type switch is used as a controlled polymorphism mechanism rather than abused for flow control.

Generics (selective, recent)#

  • Usage: Generics appear in newer apimachinery validation utilities (staging/src/k8s.io/apimachinery/pkg/api/validate/), introduced to reduce repetition in update validation:
    func UpdateValueByCompare[T comparable](ctx context.Context, op operation.Operation, ...) field.ErrorList
    func EachSliceVal[T any](ctx context.Context, ...) field.ErrorList
  • Assessment: Generics use is conservative and deliberate — applied only where type parameters genuinely eliminate duplication (validation helpers) without obscuring intent. The bulk of the codebase predates Go 1.18 and uses interfaces + type switches instead.

Interface embedding for protocol composition#

  • Usage: 2,498 interface definitions in the non-vendor codebase. Heavy use of interface embedding to compose capabilities:
    // staging/src/k8s.io/apiserver/pkg/storage/interfaces.go
    type Interface interface {
        Versioner() APIObjectVersioner
        Create(ctx context.Context, key string, obj, out runtime.Object, ttl uint64) error
        Delete(ctx context.Context, key string, out runtime.Object, ...) error
        Watch(ctx context.Context, key string, opts ListOptions) (watch.Interface, error)
        Get(ctx context.Context, key string, opts GetOptions, objPtr runtime.Object) error
        List(ctx context.Context, key string, opts ListOptions, listObj runtime.Object) error
    }
    Informer and client interfaces are similarly composed from narrower capability interfaces.
  • Assessment: The sheer number of interfaces reflects a deliberate design: every major collaboration point is abstracted behind an interface, enabling fakes/mocks in tests and swappable implementations in production (e.g., etcd3 backend vs. in-memory cache store).

Level-triggered reconciliation (the meta-pattern)#

This is the most architecturally significant behavioral pattern in Kubernetes, transcending individual Go idioms:

  • Description: Controllers do not maintain local state about what changed; they always read current state from the cache, compute the delta to desired state, and apply corrections. A missed watch event, a controller restart, or a network blip does not cause divergence — the periodic re-sync timer triggers a full reconcile pass.
  • Implementation: Each controller’s syncX(key string) function is always a full “read-compute-write” cycle:
    func (dc *DeploymentController) syncDeployment(ctx context.Context, key string) error {
        d, err := dc.dLister.Deployments(namespace).Get(name)  // read from cache
        // compute desired replicasets ...
        // call API if action needed
    }
  • Assessment: This pattern is not a Go idiom per se, but it shapes every Go pattern choice in the controllers: why the work queue is deduplicating (idempotent re-queuing is safe), why reconcile functions take only a string key (stateless input), and why SharedInformers use a local cache (cheap re-reads during reconcile).

Sync primitives usage#

  • Count: 2,013 uses of sync.Mutex, sync.RWMutex, sync.Once, sync.WaitGroup, sync.Map, or atomic.* (excl. vendor).
  • Dominant use: sync.RWMutex guards in-memory caches within informer stores and the scheduler’s node cache. sync.Once is used for lazy initialization of singletons (e.g., defaulting functions, codec registration). sync.WaitGroup coordinates informer goroutine teardown in the SharedInformerFactory.
  • Assessment: Lock usage is localized — data structures acquire and release locks on specific methods rather than holding coarse global locks. The informer’s RWMutex-protected store is a canonical example of Go’s preferred fine-grained locking strategy.