Writing a Controller for Pod Labels
Authors: Arthur Busser (Padok)
Kubernetes operators run complex software inside your cluster. The open source
community has already built
An operator is a set of
The Operator SDK is best suited for building fully-featured operators.
Nonetheless, you can use it to write a single controller. This post will walk
you through writing a Kubernetes controller in Go that will add a I recently worked on a project where we needed to create a Service that routed
traffic to a specific Pod in a ReplicaSet. The problem is that a Service can
only select pods by label, and all pods in a ReplicaSet have the same labels.
There are two ways to solve this problem: A controller is a control loop that tracks one or more Kubernetes resource
types. The controller from option n°2 above only needs to track pods, which
makes it simpler to implement. This is the option we are going to walk through
by writing a Kubernetes controller that adds a StatefulSets pod-name label to each Pod in the set. But what if we don't want to or can't
use StatefulSets? We rarely create pods directly; most often, we use a Deployment, ReplicaSet, or
another high-level resource. We can specify labels to add to each Pod in the
PodSpec, but not with dynamic values, so no way to replicate a StatefulSet's
Once a Pod exists in the Kubernetes API, it is mostly immutable, but we can
still add a label. We can even do so from the command line: We need to watch for changes to any pods in the Kubernetes API and add the label
we want. Rather than do this manually, we are going to write a controller that
does it for us. A controller is a reconciliation loop that reads the desired state of a resource
from the Kubernetes API and takes action to bring the cluster's actual state
closer to the desired state. In order to write this controller as quickly as possible, we are going to use
the Operator SDK. If you don't have it installed, follow the
. Let's create a new directory to write our controller in: Next, let's initialize a new operator, to which we will add a single controller.
To do this, you will need to specify a domain and a repository. The domain
serves as a prefix for the group your custom Kubernetes resources will belong
to. Because we are not going to be defining custom resources, the domain does
not matter. The repository is going to be the name of the Go module we are going
to write. By convention, this is the repository where you will be storing your
code. As an example, here is the command I ran: Next, we need a create a new controller. This controller will handle pods and
not a custom resource, so no need to generate the resource code. Let's run this
command to scaffold the code we need: We now have a new file: The The second method is The Lets use the Before moving on, we should set the RBAC permissions our controller needs. Above
the We don't need all of those. Our controller will never interact with a Pod's
status or its finalizers. It only needs to read and update pods. Lets remove the
unnecessary permissions and keep only what we need: We are now ready to write our controller's reconciliation logic. Here is what we want our Lets define some constants for the annotation and label: The first step in our reconciliation function is to fetch the Pod we are working
on from the Kubernetes API: Our We can now handle this specific error and — since our controller does not care
about deleted pods — explicitly ignore it: Next, lets edit our Pod so that our dynamic label is present if and only if our
annotation is present: Finally, let's push our updated Pod to the Kubernetes API: When writing our updated Pod to the Kubernetes API, there is a risk that the Pod
has been updated or deleted since we first read it. When writing a Kubernetes
controller, we should keep in mind that we are not the only actors in the
cluster. When this happens, the best thing to do is start the reconciliation
from scratch, by requeuing the event. Lets do exactly that: Let's remember to return successfully at the end of the method: And that's it! We are now ready to run the controller on our cluster. To run our controller on your cluster, we need to run the operator. For that,
all you will need is All it takes to run the operator from your machine is this command: After a few seconds, you should see the operator's logs. Notice that our
controller's Let's keep the operator running and, in another terminal, create a new Pod: The operator should quickly print some logs, indicating that it reacted to the
Pod's creation and subsequent changes in status: Lets check the Pod's labels: Let's add an annotation to the Pod so that our controller knows to add our
dynamic label to it: Notice that the controller immediately reacted and produced a new line in its
logs: Bravo! You just successfully wrote a Kubernetes controller capable of adding
labels with dynamic values to resources in your cluster. Controllers and operators, both big and small, can be an important part of your
Kubernetes journey. Writing operators is easier now than it has ever been. The
possibilities are endless. If you want to go further, I recommend starting by deploying your controller or
operator inside a cluster. The When deploying an operator to production, it is always a good idea to implement
robust testing. The first step in that direction is to write unit tests.
. The
When modeling a more complex use-case, a single controller acting on built-in
Kubernetes types may not be enough. You may need to build a more complex
operator with
and multiple controllers. The Operator SDK is a great tool to help you do this. If you want to discuss building an operator, join the
channel in the !pod-name
label to pods that have a specific annotation.Why do we need a controller for this?
pod-name
label to our pods.pod-name
label.kubectl label my-pod my-label-key=my-label-value
Bootstrapping a controller with the Operator SDK
$ operator-sdk version
operator-sdk version: "v1.4.2", commit: "4b083393be65589358b3e0416573df04f4ae8d9b", kubernetes version: "v1.19.4", go version: "go1.15.8", GOOS: "darwin", GOARCH: "amd64"
mkdir label-operator && cd label-operator
# Feel free to change the domain and repo values.
operator-sdk init --domain=padok.fr --repo=github.com/busser/label-operator
operator-sdk create api --group=core --version=v1 --kind=Pod --controller=true --resource=false
controllers/pod_controller.go
. This file contains a
PodReconciler
type with two methods that we need to implement. The first is
Reconcile
, and it looks like this for now:func (r *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
_ = r.Log.WithValues("pod", req.NamespacedName)
// your logic here
return ctrl.Result{}, nil
}
Reconcile
method is called whenever a Pod is created, updated, or deleted.
The name and namespace of the Pod are in the ctrl.Request
the method receives
as a parameter.SetupWithManager
and for now it looks like this:func (r *PodReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
// Uncomment the following line adding a pointer to an instance of the controlled resource as an argument
// For().
Complete(r)
}
SetupWithManager
method is called when the operator starts. It serves to
tell the operator framework what types our PodReconciler
needs to watch. To
use the same Pod
type used by Kubernetes internally, we need to import some of
its code. All of the Kubernetes source code is open source, so you can import
any part you like in your own Go code. You can find a complete list of available
packages in the Kubernetes source code or k8s.io/api/core/v1 package.package controllers
import (
// other imports...
corev1 "k8s.io/api/core/v1"
// other imports...
)
Pod
type in SetupWithManager
to tell the operator framework we
want to watch pods:func (r *PodReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&corev1.Pod{}).
Complete(r)
}
Reconcile
method, we have some default permissions:// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=pods/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=core,resources=pods/finalizers,verbs=update
// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;update;patch
Implementing reconciliation
Reconcile
method to do:
ctrl.Request
to fetch the Pod
from the Kubernetes API.add-pod-name-label
annotation, add a pod-name
label to
the Pod; if the annotation is missing, don't add the label.const (
addPodNameLabelAnnotation = "padok.fr/add-pod-name-label"
podNameLabel = "padok.fr/pod-name"
)
// Reconcile handles a reconciliation request for a Pod.
// If the Pod has the addPodNameLabelAnnotation annotation, then Reconcile
// will make sure the podNameLabel label is present with the correct value.
// If the annotation is absent, then Reconcile will make sure the label is too.
func (r *PodReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := r.Log.WithValues("pod", req.NamespacedName)
/*
Step 0: Fetch the Pod from the Kubernetes API.
*/
var pod corev1.Pod
if err := r.Get(ctx, req.NamespacedName, &pod); err != nil {
log.Error(err, "unable to fetch Pod")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
Reconcile
method will be called when a Pod is created, updated, or
deleted. In the deletion case, our call to r.Get
will return a specific error.
Let's import the package that defines this error:package controllers
import (
// other imports...
apierrors "k8s.io/apimachinery/pkg/api/errors"
// other imports...
)
/*
Step 0: Fetch the Pod from the Kubernetes API.
*/
var pod corev1.Pod
if err := r.Get(ctx, req.NamespacedName, &pod); err != nil {
if apierrors.IsNotFound(err) {
// we'll ignore not-found errors, since we can get them on deleted requests.
return ctrl.Result{}, nil
}
log.Error(err, "unable to fetch Pod")
return ctrl.Result{}, err
}
/*
Step 1: Add or remove the label.
*/
labelShouldBePresent := pod.Annotations[addPodNameLabelAnnotation] == "true"
labelIsPresent := pod.Labels[podNameLabel] == pod.Name
if labelShouldBePresent == labelIsPresent {
// The desired state and actual state of the Pod are the same.
// No further action is required by the operator at this moment.
log.Info("no update required")
return ctrl.Result{}, nil
}
if labelShouldBePresent {
// If the label should be set but is not, set it.
if pod.Labels == nil {
pod.Labels = make(map[string]string)
}
pod.Labels[podNameLabel] = pod.Name
log.Info("adding label")
} else {
// If the label should not be set but is, remove it.
delete(pod.Labels, podNameLabel)
log.Info("removing label")
}
/*
Step 2: Update the Pod in the Kubernetes API.
*/
if err := r.Update(ctx, &pod); err != nil {
log.Error(err, "unable to update Pod")
return ctrl.Result{}, err
}
/*
Step 2: Update the Pod in the Kubernetes API.
*/
if err := r.Update(ctx, &pod); err != nil {
if apierrors.IsConflict(err) {
// The Pod has been updated since we read it.
// Requeue the Pod to try to reconciliate again.
return ctrl.Result{Requeue: true}, nil
}
if apierrors.IsNotFound(err) {
// The Pod has been deleted since we read it.
// Requeue the Pod to try to reconciliate again.
return ctrl.Result{Requeue: true}, nil
}
log.Error(err, "unable to update Pod")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
Run the controller on your cluster
kubectl
. If you don't have a Kubernetes cluster at hand,
I recommend you start one locally with .make run
Reconcile
method was called for all pods already running in the
cluster.kubectl run --image=nginx my-nginx
INFO controllers.Pod no update required {"pod": "default/my-nginx"}
INFO controllers.Pod no update required {"pod": "default/my-nginx"}
INFO controllers.Pod no update required {"pod": "default/my-nginx"}
INFO controllers.Pod no update required {"pod": "default/my-nginx"}
$ kubectl get pod my-nginx --show-labels
NAME READY STATUS RESTARTS AGE LABELS
my-nginx 1/1 Running 0 11m run=my-nginx
kubectl annotate pod my-nginx padok.fr/add-pod-name-label=true
INFO controllers.Pod adding label {"pod": "default/my-nginx"}
$ kubectl get pod my-nginx --show-labels
NAME READY STATUS RESTARTS AGE LABELS
my-nginx 1/1 Running 0 13m padok.fr/pod-name=my-nginx,run=my-nginx
What next?
Makefile
generated by the Operator SDK will do
most of the work.How to learn more?