Go Operator
Go Operator - Memcached
Let’s begin by creating a new project called myproject
:
oc new-project myproject
If the project already exits, make sure we are currently scoped to it:
oc project myproject
Let’s now create a new directory for our project:
mkdir -p $HOME/projects/memcached-operator
Navigate to the directory:
cd $HOME/projects/memcached-operator
Initialize a new Go-based Operator SDK project for the Memcached Operator:
operator-sdk init --domain example.com --repo github.com/example/memcached-operator
Add a new Custom Resource Definition (CRD) API called Memcached with APIVersion cache.example.com/v1alpha1
and Kind Memcached
. This command will also create our boilerplate controller logic and Kustomize configuration files.
operator-sdk create api --group cache --version v1alpha1 --kind Memcached --resource --controller
We should now see the api
, config
, and controllers
directories.
Note: This guide will cover the default case of a single group API. If you would like to support Multi-Group APIs see https://book.kubebuilder.io/migration/multi-group.html
Let’s begin by inspecting the newly generated api/v1alpha1/memcached_types.go
file for our Memcached API:
cat api/v1alpha1/memcached_types.go
In Kubernetes, every functional object (with some exceptions, i.e. ConfigMap) includes spec
and status
. Kubernetes functions by reconciling desired state (Spec) with the actual cluster state. We then record what is observed (Status).
Also observe the +kubebuilder
comment markers found throughout the file. operator-sdk
makes use of a tool called controler-gen (from the controller-tools project) for generating utility code and Kubernetes YAML. More information on markers for config/code generation can be found here.
Let’s now modify the MemcachedSpec
and MemcachedStatus
of the Memcached
Custom Resource (CR) at api/v1alpha1/memcached_types.go
It should look like the file below:
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
// MemcachedSpec defines the desired state of Memcached
type MemcachedSpec struct {
// +kubebuilder:validation:Minimum=0
// Size is the size of the memcached deployment
Size int32 `json:"size"`
}
// MemcachedStatus defines the observed state of Memcached
type MemcachedStatus struct {
// Nodes are the names of the memcached pods
Nodes []string `json:"nodes"`
}
Add the +kubebuilder:subresource:status
marker to add a status subresource to the CRD manifest so that the controller can update the CR status without changing the rest of the CR object:
// Memcached is the Schema for the memcacheds API
// +kubebuilder:printcolumn:JSONPath=".spec.size",name=Desired,type=string
// +kubebuilder:printcolumn:JSONPath=".status.nodes",name=Nodes,type=string
// +kubebuilder:subresource:status
type Memcached struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec MemcachedSpec `json:"spec,omitempty"`
Status MemcachedStatus `json:"status,omitempty"`
}
func init() {
SchemeBuilder.Register(&Memcached{}, &MemcachedList{})
}
You can easily update this file by running the following command:
\cp /tmp/memcached_types.go api/v1alpha1/memcached_types.go
After modifying the *_types.go
file, always run the following command to update the zz_generated.deepcopy.go
file:
make generate
The above makefile target will invoke the controller-gen utility to update the api/v1alpha1/zz_generated.deepcopy.go file to ensure our API’s Go type definitons implement the runtime.Object interface that all Kind types must implement.
Now we can run the make manifests
command to generate our customized CRD and additional object YAMLs.
make manifests
This Makefile target will invoke controller-gen to generate the CRD manifests at config/crd/bases/cache.example.com_memcacheds.yaml.
Thanks to our comment markers, observe that we now have a newly generated CRD yaml that reflects the spec.size
and status.nodes
OpenAPI v3 schema validation and customized print columns.
cat config/crd/bases/cache.example.com_memcacheds.yaml
Deploy your Memcached Custom Resource Definition to the live OpenShift Cluster:
oc create -f config/crd/bases/cache.example.com_memcacheds.yaml
Confirm the CRD was successfully created:
oc get crd memcacheds.cache.example.com -o yaml
Note: The next two subsections explain how the controller watches resources and how the reconcile loop is triggered.
Let’s now observe the default controllers/memcached_controller.go
file:
cat controllers/memcached_controller.go
This default controller requires additional logic so we can trigger our reconciler whenever kind: Memcached
objects are added, updated, or deleted.
We also want to trigger the reconciler whenever Deployment owned by a given Memcached are added, updated, and deleted as well.
To accomplish this. we modify the controller’s SetupWithManager
method.
For this example replace the generated controller at controllers/memcached_controller.go
with the following code:
package controllers
import (
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"reflect"
"time"
"context"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
ctrllog "sigs.k8s.io/controller-runtime/pkg/log"
cachev1alpha1 "github.com/example/memcached-operator/api/v1alpha1"
)
// MemcachedReconciler reconciles a Memcached object
type MemcachedReconciler struct {
client.Client
Scheme *runtime.Scheme
}
//+kubebuilder:rbac:groups=cache.example.com,resources=memcacheds,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=cache.example.com,resources=memcacheds/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=cache.example.com,resources=memcacheds/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
// TODO(user): Modify the Reconcile function to compare the state specified by
// the Memcached object against the actual cluster state, and then
// perform operations to make the cluster state reflect the state specified by
// the user.
//
// For more details, check Reconcile and its Result here:
// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.8.3/pkg/reconcile
func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := ctrllog.FromContext(ctx)
// Fetch the Memcached instance
memcached := &cachev1alpha1.Memcached{}
err := r.Get(ctx, req.NamespacedName, memcached)
if err != nil {
if errors.IsNotFound(err) {
// Request object not found, could have been deleted after reconcile request.
// Owned objects are automatically garbage collected. For additional cleanup logic use finalizers.
// Return and don't requeue
log.Info("Memcached resource not found. Ignoring since object must be deleted")
return ctrl.Result{}, nil
}
// Error reading the object - requeue the request.
log.Error(err, "Failed to get Memcached")
return ctrl.Result{}, err
}
// Check if the deployment already exists, if not create a new one
found := &appsv1.Deployment{}
err = r.Get(ctx, types.NamespacedName{Name: memcached.Name, Namespace: memcached.Namespace}, found)
if err != nil && errors.IsNotFound(err) {
// Define a new deployment
dep := r.deploymentForMemcached(memcached)
log.Info("Creating a new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
err = r.Create(ctx, dep)
if err != nil {
log.Error(err, "Failed to create new Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
return ctrl.Result{}, err
}
// Deployment created successfully - return and requeue
return ctrl.Result{Requeue: true}, nil
} else if err != nil {
log.Error(err, "Failed to get Deployment")
return ctrl.Result{}, err
}
// Ensure the deployment size is the same as the spec
size := memcached.Spec.Size
if *found.Spec.Replicas != size {
found.Spec.Replicas = &size
err = r.Update(ctx, found)
if err != nil {
log.Error(err, "Failed to update Deployment", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)
return ctrl.Result{}, err
}
// Ask to requeue after 1 minute in order to give enough time for the
// pods be created on the cluster side and the operand be able
// to do the next update step accurately.
return ctrl.Result{RequeueAfter: time.Minute}, nil
}
// Update the Memcached status with the pod names
// List the pods for this memcached's deployment
podList := &corev1.PodList{}
listOpts := []client.ListOption{
client.InNamespace(memcached.Namespace),
client.MatchingLabels(labelsForMemcached(memcached.Name)),
}
if err = r.List(ctx, podList, listOpts...); err != nil {
log.Error(err, "Failed to list pods", "Memcached.Namespace", memcached.Namespace, "Memcached.Name", memcached.Name)
return ctrl.Result{}, err
}
podNames := getPodNames(podList.Items)
// Update status.Nodes if needed
if !reflect.DeepEqual(podNames, memcached.Status.Nodes) {
memcached.Status.Nodes = podNames
err := r.Status().Update(ctx, memcached)
if err != nil {
log.Error(err, "Failed to update Memcached status")
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil
}
// deploymentForMemcached returns a memcached Deployment object
func (r *MemcachedReconciler) deploymentForMemcached(m *cachev1alpha1.Memcached) *appsv1.Deployment {
ls := labelsForMemcached(m.Name)
replicas := m.Spec.Size
dep := &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{
Name: m.Name,
Namespace: m.Namespace,
},
Spec: appsv1.DeploymentSpec{
Replicas: &replicas,
Selector: &metav1.LabelSelector{
MatchLabels: ls,
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: ls,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{
Image: "memcached:1.4.36-alpine",
Name: "memcached",
Command: []string{"memcached", "-m=64", "-o", "modern", "-v"},
Ports: []corev1.ContainerPort{{
ContainerPort: 11211,
Name: "memcached",
}},
}},
},
},
},
}
// Set Memcached instance as the owner and controller
ctrl.SetControllerReference(m, dep, r.Scheme)
return dep
}
// labelsForMemcached returns the labels for selecting the resources
// belonging to the given memcached CR name.
func labelsForMemcached(name string) map[string]string {
return map[string]string{"app": "memcached", "memcached_cr": name}
}
// getPodNames returns the pod names of the array of pods passed in
func getPodNames(pods []corev1.Pod) []string {
var podNames []string
for _, pod := range pods {
podNames = append(podNames, pod.Name)
}
return podNames
}
// SetupWithManager sets up the controller with the Manager.
func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&cachev1alpha1.Memcached{}).
Owns(&appsv1.Deployment{}).
Complete(r)
}
You can easily update this file by running the following command:
\cp /tmp/memcached_controller.go controllers/memcached_controller.go
go mod tidy
ensures that the go.mod file matches the source code in the module. It adds any missing module requirements necessary to build the current module’s packages and dependencies, and it removes requirements on modules that don’t provide any relevant packages. It also adds any missing entries to go.sum and removes unnecessary entries.
go mod tidy
Note: The next two subsections explain how the controller watches resources and how the reconcile loop is triggered. If you’d like to skip this section, head to the deploy section to see how to run the operator.
cat controllers/memcached_controller.go
The SetupWithManager()
function in controllers/memcached_controller.go
specifies how the controller is built to watch a CR and other resources that are owned and managed by that controller.
import (
...
appsv1 "k8s.io/api/apps/v1"
...
)
func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&cachev1alpha1.Memcached{}).
Owns(&appsv1.Deployment{}).
Complete(r)
}
The NewControllerManagedBy()
provides a controller builder that allows various controller configurations.
For(&cachev1alpha1.Memcached{})
specifies the Memcached type as the primary resource to watch. For each Memcached type Add/Update/Delete event the reconcile loop will be sent a reconcile Request
(a namespace/name key) for that Memcached object.
Owns(&appsv1.Deployment{})
specifies the Deployments type as the secondary resource to watch. For each Deployment type Add/Update/Delete event, the event handler will map each event to a reconcile Request for the owner of the Deployment. Which in this case is the Memcached object for which the Deployment was created.
There are a number of other useful configurations that can be made when initialzing a controller. For more details on these configurations consult the upstream builder and controller godocs.
- Set the max number of concurrent Reconciles for the controller via the MaxConcurrentReconciles option. Defaults to 1.
func (r *MemcachedReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&cachev1alpha1.Memcached{}).
Owns(&appsv1.Deployment{}).
WithOptions(controller.Options{MaxConcurrentReconciles: 2}).
Complete(r)
}
-
Filter watch events using predicates
-
Choose the type of EventHandler to change how a watch event will translate to reconcile requests for the reconcile loop. For operator relationships that are more complex than primary and secondary resources, the EnqueueRequestsFromMapFunc handler can be used to transform a watch event into an arbitrary set of reconcile requests. The reconcile function is responsible for enforcing the desired CR state on the actual state of the system. It runs each time an event occurs on a watched CR or resource, and will return some value depending on whether those states match or not.
In this way, every Controller has a Reconciler object with a Reconcile() method that implements the reconcile loop. The reconcile loop is passed the Request argument which is a Namespace/Name key used to lookup the primary resource object, Memcached, from the cache:
import (
ctrl "sigs.k8s.io/controller-runtime"
cachev1alpha1 "github.com/example/memcached-operator/api/v1alpha1"
...
)
func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
_ = context.Background()
...
// Lookup the Memcached instance for this reconcile request
memcached := &cachev1alpha1.Memcached{}
err := r.Get(ctx, req.NamespacedName, memcached)
...
}
For a guide on Reconcilers, Clients, and interacting with resource Events, see https://sdk.operatorframework.io/docs/building-operators/golang/references/client/
The following are a few possible return options for a Reconciler:
- With the error:
return ctrl.Result{}, err
- Without an error:
return ctrl.Result{Requeue: true}, nil
- Therefore, to stop the Reconcile, use:
return ctrl.Result{}, nil
- Reconcile again after X time:
return ctrl.Result{RequeueAfter: nextRun.Sub(r.Now())}, nil
For more details, check the Reconcile and https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg/reconcile
The controller needs certain RBAC permissions to interact with the resources it manages. These are specified via RBAC markers like the following:
//+kubebuilder:rbac:groups=cache.example.com,resources=memcacheds,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=cache.example.com,resources=memcacheds/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=cache.example.com,resources=memcacheds/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;
func (r *MemcachedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
...
}
The ClusterRole
manifest at config/rbac/role.yaml
is generated from the above markers via controller-gen with the following command:
make manifests
Once the CRD is registered, there are two ways to run the Operator:
- As a Pod inside a Kubernetes cluster
- As a Go program outside the cluster using Operator-SDK. This is great for local development of your Operator.
For the sake of this tutorial, we will run the Operator as a Go program outside the cluster using Operator-SDK and our kubeconfig
credentials
Run the following command:
WATCH_NAMESPACE=myproject make run
You can continue interacting with the OpenShift cluster by opening a new
terminal window. You can quit the session by pressing CTRL + C
.
In a new terminal window, run the following:
cd $HOME/projects/memcached-operator
cat config/samples/cache_v1alpha1_memcached.yaml
Ensure your kind: Memcached
Custom Resource (CR) is updated with spec.size
apiVersion: cache.example.com/v1alpha1
kind: Memcached
metadata:
name: memcached-sample
spec:
size: 3
You can easily update this file by running the following command:
\cp /tmp/cache_v1alpha1_memcached.yaml config/samples/cache_v1alpha1_memcached.yaml
Ensure you are currently scoped to the myproject
Namespace:
oc project myproject
Deploy your PodSet Custom Resource to the live OpenShift Cluster:
oc create -f config/samples/cache_v1alpha1_memcached.yaml
Verify the memcached exists:
oc get memcached
Verify the Memcached operator has created 3 pods:
oc get pods
Verify that status shows the name of the pods currently owned by the Memcached:
oc get memcached memcached-sample -o yaml
Increase the number of replicas owned by the Memcached:
oc patch memcached memcached-sample --type='json' -p '[{"op": "replace", "path": "/spec/size", "value":5}]'
Verify that we now have 5 running pods
oc get pods
Our Memcached controller creates pods containing OwnerReferences in their metadata
section. This ensures they will be removed upon deletion of the memcached-sample
CR.
Observe the OwnerReference set on a Memcached’s pod:
oc get pods -o yaml | grep ownerReferences -A10
Delete the memcached-sample Custom Resource:
oc delete memcached memcached-sample
Thanks to OwnerReferences, all of the pods should be deleted:
oc get pods