As we discussed in the previous blog, API versioning isn’t as simple as having running different services to handle the different versions, whereby each service processes each version independently of the other. With Kubernetes, your controller watches the state of an object in etcd, that stored object is versioned to a single value (i.e. only 1 version is ever stored at a time), and when you set-up a watch on the resource whichever version you ask for is the version you will get.
Therefore, when writing a k8s operator, there are a set of requirements and conventions we need to be aware of to ensure that we’re able to progress the software development lifecycle of the API specification and the controller versions in a robust manner.
For the purpose of this document there may be references to “client” or “client’s”. A client could mean a number of things. It could mean, as the term often means, some piece of software which is constructing an HTTP request and calling a specific version of our API. However, it could also mean the combination of kubectl, kustomize, helm or some technology like Config Sync, which effectively is doing this API call on behalf of the user, and the configuration manifest provided by a user who is specifying which API version they want to use in that YAML file. E.g.
Each point will be covered in more detail within the rest of the article
In general we want default values to be explicitly represented in our APIs, rather than asserting that "unspecified fields get the default behaviour". This means, set a default in the CRD and don’t just handle it in the code.
This is important so that:
There are 3 distinct ways that default values can be applied when creating or updating (including patch and apply) a resource:
Some care is required when deciding which mechanism to use and managing the semantics. Refer to defaulting docs for more information.
Static default values are specific to each API version. The default field values applied when creating an object with the "v1" API may be different than the values applied when using the "v2" API. In most cases, these values are defined as literal values by the API version (e.g. "if this field is not specified it defaults to 0").
Static defaults are the best choice for values which are logically required, but which have a value that works well for most users. Static defaulting must not consider any state except the object being operated upon.
Defaulting happens on the object when
With the exception of defaults in metadata, any fields which have defaults should be pruned for any versions which don't also specify the field.
Null values for fields that either don't specify the nullable flag, or give it a false value, will be pruned before defaulting happens. If a default is present, it will be applied. When nullable is true, null values will be conserved and won't be defaulted.
For example, given the OpenAPI schema below:
creating an object with null values for foo and bar and baz
leads to
with foo pruned and defaulted because the field is non-nullable, bar maintaining the null value due to nullable: true, and baz pruned because the field is non-nullable and has no default.
In some cases, it is useful to set a default value which is not derived from the object in question. For example, when creating a PersistentVolumeClaim, the storage class must be specified. For many users, the best answer is "whatever the cluster admin has decided for the default". StorageClass is a different API than PersistentVolumeClaim, and which one is denoted as the default may change at any time. Thus this is not eligible for static defaulting.
Instead, we can provide a built-in admission controller or a MutatingWebhookConfiguration. Unlike static defaults, these may consider external state (such as annotations on StorageClass objects) when deciding default values, and must handle things like race conditions (e.g. a StorageClass is designated the default, but the admission controller is written in a way that it doesn’t see the update as soon as it’s live). These admission controllers are strictly optional and can be disabled. As such, fields which are initialised this way must be strictly optional.
Like static defaults, these are run synchronously to the API operation in question, and when the API call completes, all static defaults will have been set. Subsequent GETs of the resource will include the default values explicitly.
Late initialisation is when resource fields are set by a system controller after an object is created/updated (asynchronously). For example, the scheduler sets the pod.spec.nodeName field after the pod is created.
In Kubernetes, sometimes you create a "pod" (like a container for your app) without specifying all the details. A special controller sees this incomplete pod and fills in the blanks, like which server it should run on. This happens after you create the pod.
All defaulting should follow these rules to only apply changes that:
These rules ensure that:
Kubernetes offers several ways of updating an object which preserve existing values in fields other than those being updated. However, the kubectl replace (aka HTTP PUT) way of updating objects can have bad interactions with default values. Imagine you're updating a document. You want to change some parts, but keep the rest the same.
Here's the problem: replace assumes you're providing the entire updated document. But sometimes, Kubernetes automatically fills in some blanks with default values. These defaults can change depending on how you update the document, leading to unexpected results.
Think of it like this:
This can cause trouble, especially with fields that can't be changed after they're set. You might get errors or end up with values you didn't intend.
To avoid this, Kubernetes developers need to be especially careful when adding new fields with default values. They might need to write extra code to preserve the original values during updates, even if the user technically provided an incomplete document.
For example, when adding a field with a static or admission controlled default, if the field is immutable after creation, consider adding logic to manually patch the value from the oldObject into the newObject when it has not been set by the user, rather than returning an error or allocating a different value. This will very often be what the user meant, even if it is not what they said. This may dictate that the change is performed by an admission controller. Also be particularly careful to detect and report legitimate errors where the new value is specified but is different from the old value.
For controller-defaulted fields, the situation is even more unpleasant. Controllers do not have an opportunity to patch the value before the API operation is committed. If the unset value is allowed then it will be saved, and any watch clients will be notified. If the unset value is not allowed or mutations are otherwise disallowed, the user will get an error, and there's simply nothing we can do about it.
The bottom line: replace can be tricky with default values. Developers need to be mindful of how it works to avoid unexpected behavior and provide a smoother experience for users.
If there are any notable differences - field names, types, structural change in particular - you must add some logic to convert versioned APIs to and from the internal representation.
For example, when there is a need to update a property from a singular value (e.g. string) to an array, there needs to be something in place so that older clients, that only know the singular field, continue to succeed and produce the same results as before the change. Whereas newer clients can use your change without impacting older clients. This way the API server can be rolled back and only objects that use your new change will be impacted.
In Kubernetes, the hub-and-spoke model is a strategy for managing multiple API versions of custom resources. It provides a structured approach to handling versioning, conversion, and backward compatibility.
By effectively utilising the hub-and-spoke model, it makes it possible to manage multiple API versions of custom resources in a more flexible and maintainable way, with the intention of ensuring a smoother evolution of Kubernetes resources.
As per the Kubernetes documentation, version management should follow a hub and spoke model. This model describes that all defined API versions in the CRD must be able to covert to each other but they should do so by first converting to an internally defined structure, before being converted to the other version.
In a hypothetical API (assume we're at version v6), the Frobber struct looks something like this:
You want to add a new Width field. It is generally allowed to add new fields without changing the API version, so you can simply change it to:
With regards to pre-made operator controllers, like those from the Operator SDK which allow the developer to create operators with Helm (not relevant for the purpose of complex API versioning), Ansible and Go, there should be a similar concept of managing an internal structure even though these technologies don’t directly support this sort of concept.
The assert module could also be used to aid validation or conversion.
Bringing together internal structure management and defaulting, developers will still need to be able to make some changes in-flight (i.e. mutating the requested object before being stored and read by the controller). In such cases, the way to perform this action is by writing a Conversion Webhook.
A conversation webhook is a basically a mutating or validating admission webhook which is an HTTP callback that is invoked by the Kubernetes API server when a custom resource is created or updated.
The webhook can modify the CR object (mutating webhook) or reject the request (validating webhook) in-flight during the user's request.
For a recommendation for an implementation of a custom resource conversion webhook server See the example webhook implementation (used in the K8S API E2E tests). It shows how the webhook handles the ConversionReview requests sent by the API servers, and sends back conversion results wrapped in ConversionResponse.
Note that the request contains a list of custom resources that need to be converted independently without changing the order of objects.
The example server is organised in a way to be reused for other conversions. Most of the common code are located in the framework file that leaves only one function to be implemented for different conversions.
There are more details on https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#object-references in general but it is worth highlighting some considerations for version management:
Below is some advice regarding use of labels, annotations when managing version changes. There is more details on https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#label-selector-and-annotation-conventions:
Partner with us to master Kubernetes API versioning. From implementing best practices to crafting seamless controller workflows, we’re here to innovate together.
If you would like to discuss any of these topics in more detail, please feel free to get in touch