control-systems-and-automation
Applying the Singleton Pattern for Global Configuration Management in Kubernetes Operators
Table of Contents
The Challenge of Configuration Management in Kubernetes Operators
Kubernetes operators extend the Kubernetes API to manage complex applications. They often need to read configuration parameters—such as connection strings, feature flags, logging levels, or resource limits—from multiple sources. Without a disciplined approach, configuration can become scattered across the codebase, leading to inconsistencies, race conditions, and difficult-to-track bugs.
Most operator projects are written in Go, and they typically run as a single binary. However, the operator may be composed of multiple controllers, admission webhooks, and background workers. Each component might need the same configuration data. Duplicating configuration loading logic across these components violates the DRY principle and increases maintenance costs. The Singleton pattern provides a clean solution: a single, globally accessible instance that holds the configuration.
Understanding the Singleton Pattern
The Singleton pattern is a creational design pattern that ensures a class or struct has only one instance and provides a global point of access to it. In the context of Go and Kubernetes operators, we apply this pattern to configuration objects.
Core Characteristics of a Singleton
- Private constructor – Prevents external instantiation.
- Static accessor method – Returns the single instance, creating it on first access.
- Lazy initialization – The instance is created only when first needed.
- Thread safety – Concurrent access must not produce multiple instances or corrupted state.
Implementing a Thread-Safe Singleton in Go
Go does not have classes, but we can achieve the same effect using packages and sync.Once. Below is a production‐ready implementation that many operators adopt:
package config
import (
"os"
"sync"
)
// Config holds all operator configuration.
type Config struct {
LogLevel string
DatabaseURL string
// ... other fields
}
var (
instance *Config
once sync.Once
)
// GetConfig returns the singleton Config, initializing it on the first call.
func GetConfig() *Config {
once.Do(func() {
instance = &Config{
LogLevel: getEnv("LOG_LEVEL", "info"),
DatabaseURL: getEnv("DATABASE_URL", "localhost:5432"),
}
// Optionally validate or parse from a file / ConfigMap.
})
return instance
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
Why sync.Once is Preferable
Using sync.Once guarantees that the initialization function runs exactly once, even under heavy concurrency. The Do method blocks all callers until the function completes, ensuring that the singleton is fully constructed before any goroutine can read it.
Testing the Singleton
A common concern with singletons is testability. In operator unit tests, you often want to supply a mock configuration. A simple workaround is to expose a test hook that resets the instance:
// ResetForTest clears the singleton – only for use in test files.
func ResetForTest() {
once = sync.Once{}
instance = nil
}
Then in tests you can call ResetForTest(), set environment variables, and call GetConfig() again to get a fresh instance. This pattern is used by prominent projects like cert-manager and Prometheus Operator.
Alternative Approaches: ConfigMaps and Environment Variables
Before adopting a Singleton, it’s worth understanding the alternatives available in the Kubernetes ecosystem:
1. Environment Variables
These are the simplest and most common method. The operator’s deployment manifest defines env entries, and the operator reads them via os.LookupEnv. No singleton is needed if each component reads what it needs independently. However, this becomes problematic when:
- Multiple components need the same value – you repeat
os.LookupEnveverywhere. - You want to change the source (e.g., from env to a file) – you must update every call site.
2. Kubernetes ConfigMaps
Operators often watch a ConfigMap to allow live configuration updates. A singleton that holds the latest config and updates it via a watch is a natural fit. For example:
func WatchConfigMap(ctx context.Context, client kubernetes.Interface, namespace, name string) {
watcher, _ := client.CoreV1().ConfigMaps(namespace).Watch(ctx, metav1.ListOptions{FieldSelector: "metadata.name=" + name})
for event := range watcher.ResultChan() {
cm := event.Object.(*v1.ConfigMap)
updateFromConfigMap(cm)
}
}
func updateFromConfigMap(cm *v1.ConfigMap) {
// Write to a global singleton.
configSingleton.Update(cm.Data)
}
The singleton pattern complements ConfigMaps: the watch routine updates the single instance, and all other goroutines simply read from it.
3. Dependency Injection
The most flexible alternative is to pass configuration explicitly to each controller or struct. This improves testability and makes dependencies clear. However, in a large operator with many controllers, wiring up all dependencies can become verbose. A singleton provides a pragmatic middle ground.
Comparing Singleton with Dependency Injection
| Aspect | Singleton | Dependency Injection |
|---|---|---|
| Ease of use | High – just call config.GetConfig() | Medium – requires a container or manual wiring |
| Testability | Requires reset mechanism | Excellent – mock easily injected |
| Concurrency safety | Built‑in with sync.Once | Depends on implementation |
| Global state | Yes – can cause hidden coupling | No – explicit at construction |
| Configuration updates | Easily added with watcher | Must propagate changes manually |
For many operators, the Singleton pattern is the default choice because it simplifies the codebase without sacrificing reliability. Teams that prioritise test purity may prefer DI, but the overhead is often not justified for small‑to‑medium operators.
Best Practices for Configuration Management in Operators
- Validate configuration eagerly – Call
GetConfig()once during startup and validate all fields. Fail fast instead of crashing later. - Use environment variables as defaults – Let a ConfigMap override them at runtime. The singleton can merge both sources.
- Expose configuration via a reconciler – Some operators store the effective configuration in a custom resource status for debugging.
- Avoid modifying the singleton after initialization (unless you implement a controlled update mechanism). Uncontrolled writes from multiple goroutines will break thread safety.
- Document the singleton’s lifecycle – Especially how it gets initialized and when resets are allowed (usually only in tests).
- Consider immutability – Return a copy or a read‑only wrapper to prevent accidental mutation.
Pitfalls to Avoid
- Using init() functions –
init()runs at package load time, before configuration sources (like environment variables or ConfigMaps) may be ready. Always use lazy initialization withsync.Once. - Forgetting thread safety – If you implement your own double‑checked locking, you risk subtle data races. Stick with
sync.Once. - Over‑complicating with global mutexes – A read‐write mutex for every config access is unnecessary if the config is set once and never changed (or changed via a dedicated update channel).
- Leaking test state – Ensure your
ResetForTestfunction is not exposed in production binaries. Use build tags or a separate test package.
Conclusion
The Singleton pattern is not a silver bullet, but for global configuration in Kubernetes operators, it offers a balanced blend of simplicity, performance, and reliability. By using Go’s sync.Once and pairing the singleton with a ConfigMap watcher, you create a configuration system that is both easy to use and robust under concurrency.
Ultimately, the choice between Singleton and dependency injection depends on your team’s priorities. If you value straightforward code and quick onboarding, the Singleton approach will serve you well. For teams that need extensive unit testing and are willing to invest in a DI framework, that path is also valid. Most production operators—including the Kubernetize Prometheus Operator and the Ingress NGINX Controller—use a singleton for their core configuration. Adopting this pattern can lead to more maintainable and consistent operators.
For further reading, see the Go documentation on sync.Once, the Kubernetes Operator pattern, and a detailed discussion on Singleton design patterns.