Using the Controller Runtime Client API with Operator SDK

Overview

The controller-runtime library provides various abstractions to watch and reconcile resources in a Kubernetes cluster via CRUD (Create, Update, Delete, as well as Get and List in this case) operations. Operators use at least one controller to perform a coherent set of tasks within a cluster, usually through a combination of CRUD operations. The Operator SDK uses controller-runtime’s Client interface, which provides the interface for these operations.

controller-runtime defines several interfaces used for cluster interaction:

  • client.Client: implementers perform CRUD operations on a Kubernetes cluster.
  • manager.Manager: manages shared dependencies, such as Caches and Clients.
  • reconcile.Reconciler: compares provided state with actual cluster state and updates the cluster on finding state differences using a Client.

Clients are the focus of this document. A separate document will discuss Managers.

Client Usage

Default Client

The SDK relies on a manager.Manager to create a client.Client interface that performs Create, Update, Delete, Get, and List operations within a reconcile.Reconciler‘s Reconcile function. The SDK will generate code to create a Manager, which holds a Cache and a Client to be used in CRUD operations and communicate with the API server. By default a Controller’s Reconciler will be populated with the Manager’s Client which is a split-client.

pkg/controller/<kind>/<kind>_controller.go:

  1. func newReconciler(mgr manager.Manager) reconcile.Reconciler {
  2. return &ReconcileKind{client: mgr.GetClient(), scheme: mgr.GetScheme()}
  3. }
  4. type ReconcileKind struct {
  5. // Populated above from a manager.Manager.
  6. client client.Client
  7. scheme *runtime.Scheme
  8. }

A split client reads (Get and List) from the Cache and writes (Create, Update, Delete) to the API server. Reading from the Cache significantly reduces request load on the API server; as long as the Cache is updated by the API server, read operations are eventually consistent.

Non-default Client

An operator developer may wish to create their own Client that serves read requests(Get List) from the API server instead of the cache, for example. controller-runtime provides a constructor for Clients:

  1. // New returns a new Client using the provided config and Options.
  2. func New(config *rest.Config, options client.Options) (client.Client, error)

client.Options allow the caller to specify how the new Client should communicate with the API server.

  1. // Options are creation options for a Client
  2. type Options struct {
  3. // Scheme, if provided, will be used to map go structs to GroupVersionKinds
  4. Scheme *runtime.Scheme
  5. // Mapper, if provided, will be used to map GroupVersionKinds to Resources
  6. Mapper meta.RESTMapper
  7. }

Example:

  1. import (
  2. "sigs.k8s.io/controller-runtime/pkg/client/config"
  3. "sigs.k8s.io/controller-runtime/pkg/client"
  4. )
  5. cfg, err := config.GetConfig()
  6. ...
  7. c, err := client.New(cfg, client.Options{})
  8. ...

Note: defaults are set by client.New when Options are empty. The default scheme will have the core Kubernetes resource types registered. The caller must set a scheme that has custom operator types registered for the new Client to recognize these types.

Creating a new Client is not usually necessary nor advised, as the default Client is sufficient for most use cases.

Reconcile and the Client API

A Reconciler implements the reconcile.Reconciler interface, which exposes the Reconcile method. Reconcilers are added to a corresponding Controller for a Kind; Reconcile is called in response to cluster or external Events, with a reconcile.Request object argument, to read and write cluster state by the Controller, and returns a reconcile.Result. SDK Reconcilers have access to a Client in order to make Kubernetes API calls.

Note: For those familiar with the SDK’s old project semantics, Handle received resource events and reconciled state for multiple resource types, whereas Reconcile receives resource events and reconciles state for a single resource type.

  1. // ReconcileKind reconciles a Kind object
  2. type ReconcileKind struct {
  3. // client, initialized using mgr.Client() above, is a split client
  4. // that reads objects from the cache and writes to the apiserver
  5. client client.Client
  6. // scheme defines methods for serializing and deserializing API objects,
  7. // a type registry for converting group, version, and kind information
  8. // to and from Go schemas, and mappings between Go schemas of different
  9. // versions. A scheme is the foundation for a versioned API and versioned
  10. // configuration over time.
  11. scheme *runtime.Scheme
  12. }
  13. // Reconcile watches for Events and reconciles cluster state with desired
  14. // state defined in the method body.
  15. // The Controller will requeue the Request to be processed again if an error
  16. // is non-nil or Result.Requeue is true, otherwise upon completion it will
  17. // remove the work from the queue.
  18. func (r *ReconcileKind) Reconcile(request reconcile.Request) (reconcile.Result, error)

Reconcile is where Controller business logic lives, i.e. where Client API calls are made via ReconcileKind.client. A client.Client implementer performs the following operations:

Get

  1. // Get retrieves an API object for a given object key from the Kubernetes cluster
  2. // and stores it in obj.
  3. func (c Client) Get(ctx context.Context, key ObjectKey, obj runtime.Object) error

Note: An ObjectKey is simply a client package alias for types.NamespacedName.

Example:

  1. import (
  2. "context"
  3. "github.com/example-org/app-operator/pkg/apis/cache/v1alpha1"
  4. "sigs.k8s.io/controller-runtime/pkg/reconcile"
  5. )
  6. func (r *ReconcileApp) Reconcile(request reconcile.Request) (reconcile.Result, error) {
  7. ...
  8. app := &v1alpha1.App{}
  9. ctx := context.TODO()
  10. err := r.client.Get(ctx, request.NamespacedName, app)
  11. ...
  12. }

List

  1. // List retrieves a list of objects for a given namespace and list options
  2. // and stores the list in obj.
  3. func (c Client) List(ctx context.Context, list runtime.Object, opts ...client.ListOption) error

A client.ListOption is an interface that sets client.ListOptions fields. A client.ListOption is created by using one of the provided implementations: MatchingLabels, MatchingFields, InNamespace.

Example:

  1. import (
  2. "context"
  3. "fmt"
  4. "k8s.io/api/core/v1"
  5. "sigs.k8s.io/controller-runtime/pkg/client"
  6. "sigs.k8s.io/controller-runtime/pkg/reconcile"
  7. )
  8. func (r *ReconcileApp) Reconcile(request reconcile.Request) (reconcile.Result, error) {
  9. ...
  10. // Return all pods in the request namespace with a label of `app=<name>`
  11. // and phase `Running`.
  12. podList := &v1.PodList{}
  13. opts := []client.ListOption{
  14. client.InNamespace(request.NamespacedName.Namespace),
  15. client.MatchingLabels{"app": request.NamespacedName.Name},
  16. client.MatchingFields{"status.phase": "Running"},
  17. }
  18. ctx := context.TODO()
  19. err := r.client.List(ctx, podList, opts...)
  20. ...
  21. }

Create

  1. // Create saves the object obj in the Kubernetes cluster.
  2. // Returns an error
  3. func (c Client) Create(ctx context.Context, obj runtime.Object, opts ...client.CreateOption) error

A client.CreateOption is an interface that sets client.CreateOptions fields. A client.CreateOption is created by using one of the provided implementations: DryRunAll, ForceOwnership. Generally these options are not needed.

Example:

  1. import (
  2. "context"
  3. "k8s.io/api/apps/v1"
  4. "sigs.k8s.io/controller-runtime/pkg/reconcile"
  5. )
  6. func (r *ReconcileApp) Reconcile(request reconcile.Request) (reconcile.Result, error) {
  7. ...
  8. app := &v1.Deployment{ // Any cluster object you want to create.
  9. ...
  10. }
  11. ctx := context.TODO()
  12. err := r.client.Create(ctx, app)
  13. ...
  14. }

Update

  1. // Update updates the given obj in the Kubernetes cluster. obj must be a
  2. // struct pointer so that obj can be updated with the content returned
  3. // by the API server. Update does *not* update the resource's status
  4. // subresource
  5. func (c Client) Update(ctx context.Context, obj runtime.Object, opts ...client.UpdateOption) error

A client.UpdateOption is an interface that sets client.UpdateOptions fields. A client.UpdateOption is created by using one of the provided implementations: DryRunAll, ForceOwnership. Generally these options are not needed.

Example:

  1. import (
  2. "context"
  3. "k8s.io/api/apps/v1"
  4. "sigs.k8s.io/controller-runtime/pkg/reconcile"
  5. )
  6. func (r *ReconcileApp) Reconcile(request reconcile.Request) (reconcile.Result, error) {
  7. ...
  8. dep := &v1.Deployment{}
  9. err := r.client.Get(context.TODO(), request.NamespacedName, dep)
  10. ...
  11. ctx := context.TODO()
  12. dep.Spec.Selector.MatchLabels["is_running"] = "true"
  13. err := r.client.Update(ctx, dep)
  14. ...
  15. }

Patch

  1. // Patch patches the given obj in the Kubernetes cluster. obj must be a
  2. // struct pointer so that obj can be updated with the content returned by the Server.
  3. func (c Client) Patch(ctx context.Context, obj runtime.Object, patch client.Patch, opts ...client.UpdateOption) error

A client.PatchOption is an interface that sets client.PatchOptions fields. A client.PatchOption is created by using one of the provided implementations: DryRunAll, ForceOwnership. Generally these options are not needed.

Example:

  1. import (
  2. "context"
  3. "k8s.io/api/apps/v1"
  4. "sigs.k8s.io/controller-runtime/pkg/client"
  5. "sigs.k8s.io/controller-runtime/pkg/reconcile"
  6. )
  7. func (r *ReconcileApp) Reconcile(request reconcile.Request) (reconcile.Result, error) {
  8. ...
  9. dep := &v1.Deployment{}
  10. err := r.client.Get(context.TODO(), request.NamespacedName, dep)
  11. ...
  12. ctx := context.TODO()
  13. // A merge patch will preserve other fields modified at runtime.
  14. patch := client.MergeFrom(dep.DeepCopy())
  15. dep.Spec.Selector.MatchLabels["is_running"] = "true"
  16. err := r.client.Patch(ctx, dep, patch)
  17. ...
  18. }
Updating Status Subresource

When updating the status subresource from the client, the StatusWriter must be used. The status subresource is retrieved with Status() and updated with Update() or patched with Patch().

Update() takes variadic client.UpdateOption‘s, and Patch() takes variadic client.PatchOption‘s. See Client.Update() and Client.Patch() for more details. Generally these options are not needed.

Status
  1. // Status() returns a StatusWriter object that can be used to update the
  2. // object's status subresource
  3. func (c Client) Status() (client.StatusWriter, error)

Example:

  1. import (
  2. "context"
  3. cachev1alpha1 "github.com/example-inc/memcached-operator/pkg/apis/cache/v1alpha1"
  4. "sigs.k8s.io/controller-runtime/pkg/reconcile"
  5. )
  6. func (r *ReconcileApp) Reconcile(request reconcile.Request) (reconcile.Result, error) {
  7. ...
  8. ctx := context.TODO()
  9. mem := &cachev1alpha1.Memcached{}
  10. err := r.client.Get(ctx, request.NamespacedName, mem)
  11. ...
  12. // Update
  13. mem.Status.Nodes = []string{"pod1", "pod2"}
  14. err := r.client.Status().Update(ctx, mem)
  15. ...
  16. // Patch
  17. patch := client.MergeFrom(mem.DeepCopy())
  18. mem.Status.Nodes = []string{"pod1", "pod2", "pod3"}
  19. err := r.client.Status().Patch(ctx, mem, patch)
  20. ...
  21. }

Delete

  1. // Delete deletes the given obj from Kubernetes cluster.
  2. func (c Client) Delete(ctx context.Context, obj runtime.Object, opts ...client.DeleteOption) error

A client.DeleteOption is an interface that sets client.DeleteOptions fields. A client.DeleteOption is created by using one of the provided implementations: GracePeriodSeconds, Preconditions, PropagationPolicy.

Example:

  1. import (
  2. "context"
  3. "k8s.io/api/core/v1"
  4. "sigs.k8s.io/controller-runtime/pkg/client"
  5. "sigs.k8s.io/controller-runtime/pkg/reconcile"
  6. )
  7. func (r *ReconcileApp) Reconcile(request reconcile.Request) (reconcile.Result, error) {
  8. ...
  9. pod := &v1.Pod{}
  10. err := r.client.Get(context.TODO(), request.NamespacedName, pod)
  11. ...
  12. ctx := context.TODO()
  13. if pod.Status.Phase == v1.PodUnknown {
  14. // Delete the pod after 5 seconds.
  15. err := r.client.Delete(ctx, pod, client.GracePeriodSeconds(5))
  16. ...
  17. }
  18. ...
  19. }

DeleteAllOf

  1. // DeleteAllOf deletes all objects of the given type matching the given options.
  2. func (c Client) DeleteAllOf(ctx context.Context, obj runtime.Object, opts ...client.DeleteAllOfOption) error

A client.DeleteAllOfOption is an interface that sets client.DeleteAllOfOptions fields. A client.DeleteAllOfOption wraps a client.ListOption and client.DeleteOption.

Example:

  1. import (
  2. "context"
  3. "fmt"
  4. "k8s.io/api/core/v1"
  5. "sigs.k8s.io/controller-runtime/pkg/client"
  6. "sigs.k8s.io/controller-runtime/pkg/reconcile"
  7. )
  8. func (r *ReconcileApp) Reconcile(request reconcile.Request) (reconcile.Result, error) {
  9. ...
  10. // Delete all pods in the request namespace with a label of `app=<name>`
  11. // and phase `Failed`.
  12. pod := &v1.Pod{}
  13. opts := []client.DeleteAllOfOption{
  14. client.InNamespace(request.NamespacedName.Namespace),
  15. client.MatchingLabels{"app", request.NamespacedName.Name},
  16. client.MatchingFields{"status.phase": "Failed"},
  17. client.GracePeriodSeconds(5),
  18. }
  19. ctx := context.TODO()
  20. err := r.client.DeleteAllOf(ctx, pod, opts...)
  21. ...
  22. }

Example usage

  1. import (
  2. "context"
  3. "reflect"
  4. appv1alpha1 "github.com/example-org/app-operator/pkg/apis/app/v1alpha1"
  5. appsv1 "k8s.io/api/apps/v1"
  6. corev1 "k8s.io/api/core/v1"
  7. "k8s.io/apimachinery/pkg/api/errors"
  8. metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
  9. "k8s.io/apimachinery/pkg/labels"
  10. "k8s.io/apimachinery/pkg/runtime"
  11. "k8s.io/apimachinery/pkg/types"
  12. "sigs.k8s.io/controller-runtime/pkg/client"
  13. "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
  14. "sigs.k8s.io/controller-runtime/pkg/reconcile"
  15. )
  16. type ReconcileApp struct {
  17. client client.Client
  18. scheme *runtime.Scheme
  19. }
  20. func (r *ReconcileApp) Reconcile(request reconcile.Request) (reconcile.Result, error) {
  21. // Fetch the App instance.
  22. app := &appv1alpha1.App{}
  23. err := r.client.Get(context.TODO(), request.NamespacedName, app)
  24. if err != nil {
  25. if errors.IsNotFound(err) {
  26. return reconcile.Result{}, nil
  27. }
  28. return reconcile.Result{}, err
  29. }
  30. // Check if the deployment already exists, if not create a new deployment.
  31. found := &appsv1.Deployment{}
  32. err = r.client.Get(context.TODO(), types.NamespacedName{Name: app.Name, Namespace: app.Namespace}, found)
  33. if err != nil {
  34. if errors.IsNotFound(err) {
  35. // Define and create a new deployment.
  36. dep := r.deploymentForApp(app)
  37. if err = r.client.Create(context.TODO(), dep); err != nil {
  38. return reconcile.Result{}, err
  39. }
  40. return reconcile.Result{Requeue: true}, nil
  41. } else {
  42. return reconcile.Result{}, err
  43. }
  44. }
  45. // Ensure the deployment size is the same as the spec.
  46. size := app.Spec.Size
  47. if *found.Spec.Replicas != size {
  48. found.Spec.Replicas = &size
  49. if err = r.client.Update(context.TODO(), found); err != nil {
  50. return reconcile.Result{}, err
  51. }
  52. return reconcile.Result{Requeue: true}, nil
  53. }
  54. // Update the App status with the pod names.
  55. // List the pods for this app's deployment.
  56. podList := &corev1.PodList{}
  57. listOpts := []client.ListOption{
  58. client.InNamespace(app.Namespace),
  59. client.MatchingLabels(labelsForApp(app.Name)),
  60. }
  61. if err = r.client.List(context.TODO(), podList, listOpts...); err != nil {
  62. return reconcile.Result{}, err
  63. }
  64. // Update status.Nodes if needed.
  65. podNames := getPodNames(podList.Items)
  66. if !reflect.DeepEqual(podNames, app.Status.Nodes) {
  67. app.Status.Nodes = podNames
  68. if err := r.client.Status().Update(context.TODO(), app); err != nil {
  69. return reconcile.Result{}, err
  70. }
  71. }
  72. return reconcile.Result{}, nil
  73. }
  74. // deploymentForApp returns a app Deployment object.
  75. func (r *ReconcileKind) deploymentForApp(m *appv1alpha1.App) *appsv1.Deployment {
  76. lbls := labelsForApp(m.Name)
  77. replicas := m.Spec.Size
  78. dep := &appsv1.Deployment{
  79. ObjectMeta: metav1.ObjectMeta{
  80. Name: m.Name,
  81. Namespace: m.Namespace,
  82. },
  83. Spec: appsv1.DeploymentSpec{
  84. Replicas: &replicas,
  85. Selector: &metav1.LabelSelector{
  86. MatchLabels: lbls,
  87. },
  88. Template: corev1.PodTemplateSpec{
  89. ObjectMeta: metav1.ObjectMeta{
  90. Labels: lbls,
  91. },
  92. Spec: corev1.PodSpec{
  93. Containers: []corev1.Container{{
  94. Image: "app:alpine",
  95. Name: "app",
  96. Command: []string{"app", "-a=64", "-b"},
  97. Ports: []corev1.ContainerPort{{
  98. ContainerPort: 10000,
  99. Name: "app",
  100. }},
  101. }},
  102. },
  103. },
  104. },
  105. }
  106. // Set App instance as the owner and controller.
  107. // NOTE: calling SetControllerReference, and setting owner references in
  108. // general, is important as it allows deleted objects to be garbage collected.
  109. controllerutil.SetControllerReference(m, dep, r.scheme)
  110. return dep
  111. }
  112. // labelsForApp creates a simple set of labels for App.
  113. func labelsForApp(name string) map[string]string {
  114. return map[string]string{"app_name": "app", "app_cr": name}
  115. }

Last modified January 1, 0001