From Zero to a Kubernetes Operator that Watches ConfigMaps
Hey everyone!
Today Iâm going to show you how to create a Kubernetes Operator from scratch that monitors ConfigMap changes and sends events to a webhook. Itâs a super useful feature for hot reloading configurations in applications running inside K8s clusters.
If you want to see the complete process in action, check out the YouTube video I recorded about this!
Why is this useful?
Imagine you have an application running in Kubernetes and need to change a configuration. Instead of restarting the entire application, you can:
- Change the ConfigMap
- The operator detects the change
- Sends an event to your application
- Your application does hot reload of the configuration
If you donât have everything installed, Iâll show you how to do it:
1
2
3
4
5
6
7
8
9
10
# Install Kubebuilder
curl -L -o kubebuilder "https://go.kubebuilder.io/dl/latest/$(go env GOOS)/$(go env GOARCH)"
chmod +x kubebuilder
sudo mv kubebuilder /usr/local/bin/
# Install controller-gen
go install sigs.k8s.io/controller-tools/cmd/controller-gen@latest
# Install kustomize
go install sigs.k8s.io/kustomize/kustomize/v5@latest
Complete Step by Step
Step 1: Creating the Base Project
First, letâs create the project structure using Kubebuilder:
1
2
3
4
5
# Create the project
kubebuilder init --domain exemplo.com --repo github.com/HunnTeRUS/meu-operator
# Create the API and Controller
kubebuilder create api --group apps --version v1alpha1 --kind ConfigMapWatcher --resource --controller
Kubebuilder will generate the entire base project structure. Itâs like a scaffold that gives you the starting point.
Step 2: Defining the API (Custom Resource Definition)
Now letâs edit the api/v1alpha1/configmapwatcher_types.go
file to define our API:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// ConfigMapWatcherSpec defines the desired state of ConfigMapWatcher
type ConfigMapWatcherSpec struct {
// ConfigMapName is the name of the ConfigMap to be watched
ConfigMapName string `json:"configMapName"`
// ConfigMapNamespace is the namespace where the ConfigMap is located
ConfigMapNamespace string `json:"configMapNamespace"`
// EventEndpoint is the URL where events will be sent when the ConfigMap changes
EventEndpoint string `json:"eventEndpoint"`
// EventSecretName is the name of the secret containing credentials for the endpoint
// +optional
EventSecretName string `json:"eventSecretName,omitempty"`
// EventSecretNamespace is the namespace where the secret is located
// +optional
EventSecretNamespace string `json:"eventSecretNamespace,omitempty"`
}
// ConfigMapWatcherStatus defines the observed state of ConfigMapWatcher
type ConfigMapWatcherStatus struct {
// LastConfigMapVersion is the last observed version of the ConfigMap
LastConfigMapVersion string `json:"lastConfigMapVersion,omitempty"`
// LastEventSent is the timestamp of the last event sent
LastEventSent metav1.Time `json:"lastEventSent,omitempty"`
// Conditions represent the most recent observations of the current state
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// ConfigMapWatcher is the Schema for the configmapwatchers API
type ConfigMapWatcher struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ConfigMapWatcherSpec `json:"spec,omitempty"`
Status ConfigMapWatcherStatus `json:"status,omitempty"`
}
// +kubebuilder:object:root=true
// ConfigMapWatcherList contains a list of ConfigMapWatcher
type ConfigMapWatcherList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ConfigMapWatcher `json:"items"`
}
func init() {
SchemeBuilder.Register(&ConfigMapWatcher{}, &ConfigMapWatcherList{})
}
What changed here?
- ConfigMapWatcherSpec: Defines what the user wants (which ConfigMap to watch, where to send events)
- ConfigMapWatcherStatus: Defines the current state (last observed version, last event sent)
- JSON tags: Essential for serialization/deserialization
- kubebuilder markers: Automatically generate the CRD
Step 3: Implementing the Controller Logic
Now letâs implement the logic in internal/controller/configmapwatcher_controller.go
. Iâll break this down into parts to make it clearer:
Part 1: Base Structure and Imports
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package controller
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
appsv1alpha1 "github.com/HunnTeRUS/meu-operator/api/v1alpha1"
)
What do we import here?
bytes
,encoding/json
,net/http
: For sending HTTP datacontext
: For managing operation contexttime
: For timestamps and delayscorev1
: For working with native K8s ConfigMapserrors
: For handling K8s-specific errorsctrl
,client
: For the controller-runtime framework
Part 2: Reconciler Structure
1
2
3
4
5
// ConfigMapWatcherReconciler reconciles ConfigMapWatcher objects
type ConfigMapWatcherReconciler struct {
client.Client // Client for interacting with the K8s API
Scheme *runtime.Scheme // Scheme for serialization/deserialization
}
What is this?
Client
: Itâs like an âHTTP clientâ for Kubernetes. Allows CRUD operationsScheme
: Defines how to convert Go objects to/from K8s YAML/JSON format
Part 3: RBAC Permissions
1
2
3
4
5
// +kubebuilder:rbac:groups=apps.exemplo.com,resources=configmapwatchers,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=apps.exemplo.com,resources=configmapwatchers/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps.exemplo.com,resources=configmapwatchers/finalizers,verbs=update
// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch
// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch
What are these permissions?
- apps.exemplo.com: Our custom API (ConfigMapWatcher)
- core: Native Kubernetes APIs (ConfigMaps, Secrets)
- verbs: Allowed operations (get, list, watch, create, update, etc.)
Part 4: Reconcile Function - Fetching the ConfigMapWatcher
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Reconcile handles the reconciliation loop for ConfigMapWatcher resources
func (r *ConfigMapWatcherReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := log.FromContext(ctx)
// Fetch the ConfigMapWatcher instance
configMapWatcher := &appsv1alpha1.ConfigMapWatcher{}
err := r.Get(ctx, req.NamespacedName, configMapWatcher)
if err != nil {
if errors.IsNotFound(err) {
log.Info("ConfigMapWatcher resource not found. Ignoring since the object must be deleted")
return ctrl.Result{}, nil
}
log.Error(err, "Failed to get ConfigMapWatcher")
return ctrl.Result{}, err
}
What happens here?
- req.NamespacedName: Contains the name and namespace of the object that changed
- r.Get(): Fetches the ConfigMapWatcher from the cluster
- errors.IsNotFound(): If not found, it means it was deleted (normal behavior)
Part 5: Fetching the Target ConfigMap
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Fetch the target ConfigMap
configMap := &corev1.ConfigMap{}
err = r.Get(ctx, types.NamespacedName{
Name: configMapWatcher.Spec.ConfigMapName,
Namespace: configMapWatcher.Spec.ConfigMapNamespace,
}, configMap)
if err != nil {
if errors.IsNotFound(err) {
log.Info("Target ConfigMap not found", "ConfigMap", configMapWatcher.Spec.ConfigMapName)
return ctrl.Result{RequeueAfter: time.Minute}, nil
}
log.Error(err, "Failed to get target ConfigMap")
return ctrl.Result{}, err
}
What do we do here?
- types.NamespacedName: Creates an identifier with name + namespace
- configMapWatcher.Spec: Accesses the specification (what the user defined)
- RequeueAfter: If the ConfigMap doesnât exist, try again in 1 minute
Part 6: Checking for Changes
1
2
3
4
5
// Check if the ConfigMap changed
currentVersion := configMap.ResourceVersion
if currentVersion == configMapWatcher.Status.LastConfigMapVersion {
return ctrl.Result{RequeueAfter: time.Minute}, nil
}
How do we detect changes?
- ResourceVersion: Each object in K8s has a unique version that changes with every modification
- Status.LastConfigMapVersion: We store the last version we processed
- Comparison: If theyâre equal, there was no change
Part 7: Preparing Event Data
1
2
3
4
5
6
7
8
9
// Prepare event data
eventData := map[string]interface{}{
"configMapName": configMap.Name,
"configMapNamespace": configMap.Namespace,
"resourceVersion": configMap.ResourceVersion,
"data": configMap.Data,
"binaryData": configMap.BinaryData,
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
What do we send in the event?
- Metadata: Name, namespace, version
- Data: Current ConfigMap content
- Timestamp: When the event was generated
Part 8: Sending the Event
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Send event
jsonData, err := json.Marshal(eventData)
if err != nil {
log.Error(err, "Failed to marshal event data")
return ctrl.Result{}, err
}
resp, err := http.Post(configMapWatcher.Spec.EventEndpoint, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
log.Error(err, "Failed to send event")
return ctrl.Result{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("failed to send event: status code %d", resp.StatusCode)
log.Error(err, "Event sending failed")
return ctrl.Result{}, err
}
Sending process:
- json.Marshal(): Converts the Go map to JSON
- http.Post(): Sends POST to the webhook
- defer resp.Body.Close(): Ensures the connection is closed
- Status check: Confirms the webhook received (status 200)
Part 9: Updating Status
1
2
3
4
5
6
7
8
9
10
// Update status
configMapWatcher.Status.LastConfigMapVersion = currentVersion
configMapWatcher.Status.LastEventSent = metav1.Now()
if err := r.Status().Update(ctx, configMapWatcher); err != nil {
log.Error(err, "Failed to update ConfigMapWatcher status")
return ctrl.Result{}, err
}
return ctrl.Result{RequeueAfter: time.Minute}, nil
}
Why do we update the status?
- LastConfigMapVersion: To not process the same version again
- LastEventSent: To know when the last event was sent
- r.Status().Update(): Updates only the status (not the spec)
Part 10: Configuring the Watch
1
2
3
4
5
6
7
8
9
10
// SetupWithManager configures the controller with the Manager
func (r *ConfigMapWatcherReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&appsv1alpha1.ConfigMapWatcher{}). // Watch for changes in ConfigMapWatcher
Watches(
&corev1.ConfigMap{}, // Also watch for changes in ConfigMaps
handler.EnqueueRequestsFromMapFunc(r.findObjectsForConfigMap),
).
Complete(r)
}
What does this do?
- For(): Tells to watch for changes in ConfigMapWatcher
- Watches(): Also watches for changes in native ConfigMaps
- EnqueueRequestsFromMapFunc(): When a ConfigMap changes, calls our function to find which ConfigMapWatchers are watching it
Part 11: Finding Related ConfigMapWatchers
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// findObjectsForConfigMap finds ConfigMapWatcher objects that are watching the given ConfigMap
func (r *ConfigMapWatcherReconciler) findObjectsForConfigMap(ctx context.Context, obj client.Object) []reconcile.Request {
configMap := obj.(*corev1.ConfigMap)
var requests []reconcile.Request
// Fetch all ConfigMapWatchers
var watchers appsv1alpha1.ConfigMapWatcherList
if err := r.List(ctx, &watchers); err != nil {
return requests
}
// Check which ones are watching this ConfigMap
for _, watcher := range watchers.Items {
if watcher.Spec.ConfigMapName == configMap.Name &&
watcher.Spec.ConfigMapNamespace == configMap.Namespace {
requests = append(requests, reconcile.Request{
NamespacedName: types.NamespacedName{
Name: watcher.Name,
Namespace: watcher.Namespace,
},
})
}
}
return requests
}
Logic here:
- Receives: A ConfigMap that changed
- Lists: All ConfigMapWatchers in the cluster
- Filters: Only those watching this specific ConfigMap
- Returns: List of requests to process
How Everything Works Together?
- ConfigMap changes â Controller detects
- findObjectsForConfigMap() â Finds related ConfigMapWatchers
- Reconcile() â Called for each ConfigMapWatcher
- Check change â Compare versions
- Send event â If changed, notify webhook
- Update status â Mark as processed
Now itâs clearer how each part works? Each function has a specific responsibility and works together to create the desired behavior!