Forays Into The Kubernetes Libs
Sat Mar 26, 2022 · 1281 words · 7 min
TagsĀ :  kubernetes
Authors :  Jess Bodzo

Coding On Kubernetes

Lately I have been working a lot with the core libraries inside of Kubernetes. As a developer, I highly recommend that anyone who works with Kubernetes take a peek inside. The code feels dense at first, but cracking it leads to a deeper intuition and understanding of what goes on inside of your clusters!

10,000 Foot View

Most standard interaction a developer will have with Kubernetes is through kubectl, which talks to the API Server. The API Server is a control plane component in Kubernetes, and its main job is to mediate requests defining an object or asking about an object's state.

To understand what goes on under the hood, let's start with a basic kubectl command with the output verbosity turned up:

> kubectl get pods -v=6

Among the output is the URL that gets called on the API Server:

I0325 18:08:37.052956   37863 round_trippers.go:553] GET https://127.0.0.1:41197/api/v1/namespaces/kube-system/pods?limit=500 200 OK in 11 milliseconds

Here the object we are interacting with is a resource of type pods. This resource is in the API Group called core/v1. In the api package we can see the struct defining a Pod at k8s.io/api@v0.23.0/core/v1/types.go :

type Pod struct {
   metav1.TypeMeta `json:",inline"`
   // +optional
   metav1.ObjectMeta `json:"metadata,omitempty" protobuf:"bytes,1,opt,name=metadata"`

   // +optional
   Spec PodSpec `json:"spec,omitempty" protobuf:"bytes,2,opt,name=spec"`

   // +optional
   Status PodStatus `json:"status,omitempty" protobuf:"bytes,3,opt,name=status"`
}

The TypeMeta gives the familiar Kind and APIVersion you might recognize from writing a YAML to deploy a Pod to Kubernetes. The ObjectMeta provides tons of metadata about the object itself, including the name and namespace of the Object. Both TypeMeta and ObjectMeta come from the meta package, because they describe metadata common to all objects in Kubernetes. Note also the marshaler tags for protobuf and json on all the fields.

Somewhat confusingly, this struct represents the Kind called Pod, not the resource called pod. So what is the difference? The Kind is the versioned object in the API Group that defines our contract. The resource can be defined as the subpath on the API Server that we hit. Many times there is a one-to-one mapping of Kind to Resource. Sometimes though, multiple Resources map to the same Kind.

Type <> API Group

There are many other types defined in core/v1 in types.go. How does Kubernetes store this information? How does it know which types belong in which API Groups?

Kubernetes maps this using a Scheme. Each combination of API Group, Version, and Kind maps an object uniquely (i.e. apps/v1/Deployment). These Schemes get registered by the packages that define them and the Kinds that they include.

Practically then, this means a couple of things:

As an example, to create a Pod Kind in your code...

obj := &corev1.Pod{
   TypeMeta: metav1.TypeMeta{
      Kind:       "Pod",
	  APIVersion: "v1",
 },
 ObjectMeta: metav1.ObjectMeta{
      Name:       "podzilla",
      Namespace:  "tools",
 },
 Spec: corev1.PodSpec{},
}

When You Don't Know The Type...

Recently, I was working with some library code that gives you the Resource, API Group, and Version for a Kubernetes object. The method I had to call didn't accept these though. It took in a runtime.Object instead.

runtime.Object is an interface that all structs representing Kinds must implement.

type Object interface {
    GetObjectKind() schema.ObjectKind
    DeepCopyObject() Object
}

GetObjectKind helps figure out the Kind to uniquely identify an object, and DeepCopyObject is necessary for serialization.

To provide something that has GetObjectKind, I need to know the Kind of the object I have. Given a GroupVersionResource (just all 3 variable squished in a struct) you can find out the corresponding Kind using a RESTMapper.

To create an object that satisfies the RESTMapper interface, I will:

RESTMapper and Discovery

This seems daunting at first, but lets unpack it a little bit. The discovery client is able to discover what API groups and resources the API server supports. It can also do basic REST requests, get the server version and parse the Swagger doc on the API server.

If you follow which interfaces embed other interfaces, starting from the DeferredDiscoveryRESTMapper you end up with DiscoveryInterface:

type DiscoveryInterface interface {
   RESTClient() restclient.Interface
   ServerGroupsInterface ServerResourcesInterface ServerVersionInterface OpenAPISchemaInterface
}

The discovery client can store the results of what API groups and resources the API server supports into a cache. That cache can be periodically invalidated and refreshed. Remember, what the API Server offers can change over time!

So we have this client with caching that can ask the API Server what API groups it supports, and we have the GroupVersionResource . What we need is a way to map that GroupVersionResource to a Kind , and this is exactly what RESTMapper does: effectively a mapping of GroupVersionResource to GroupVersionKind.

If you issue kubectl api-resources -v=9 you can see how this mapping is obtained. Each API Group requested on the API Server is consulted and they return APIResourceList objects defining the mappings and what verbs you can use to perform actions against each resource.

{
  "kind": "APIResourceList",
  "groupVersion": "v1",
  "resources": [
    {
      "name": "bindings",
      "singularName": "",
      "namespaced": "true",
      "kind": "Binding",
      "verbs": [
        "create"
      ]
    }
  ]
}         // the rest is elided (pun intended)

GVR <> GVK <> Runtime.Object

Putting this together, we create our RESTMapper so we can get the GVK (GroupVersionKind) from the GVR (GroupVersionResource).

clientset := kubernetes.NewForConfigOrDie(config)
discoveryClient, err := discovery.NewDiscoveryClientForConfig(config)
if err != nil {
   panic(err.Error())
}
memcacheClient := memory.NewMemCacheClient(discoveryClient)
memcacheClient.Invalidate()
restmapper := restmapper.NewDeferredDiscoveryRESTMapper(memcacheClient)

// hard-coded a GVR, but this technique is helpful when you need to handle many
// different `Kinds` uniformly where a `Runtime.Object` is expected
gvr := schema.GroupVersionResource{
   Group:  "rbac.authorization.k8s.io",
 Version:  "v1",
 Resource: "clusterrolebindings",
}
gvk, _ := d.mapper.KindFor(gvr)      // make sure you handle the error ;p

Now that we have the Kind, how can we convert this into a Runtime.Object ? It turns out that we can use an unstructured.Unstructured{}. Unstructured{} exists to handle types when you dont have the corresponding struct that they map to.

The way that Unstructured{} works is kind of clever. Whatever Kind of object you have is effectively a mapping of the members on its struct to their values. If you know all Kinds in Kubernetes implement certain interfaces and have certain members on them, you can satisfy those constraints and store all of the struct's members on a map[string]interface{}.

For example, Unstructured{} implements GetObjectKind() by retrieving the kind key on the struct's map. In my particular case, I had to pass in a Runtime.Object and I knew that the library I used would call GetObjectKind() on it. That's why I set only the API Version and Kind on the struct -- I knew that was all it needed to use the library without it blowing up.

u := &unstructured.Unstructured{}
u.SetAPIVersion("v1")
u.SetKind(gvk.Kind)

Wrap Up

Having used Kubernetes for years, I found the libraries less intuitive than I imagined. After using them a bit I appreciate them more. Hopefully this blog post helps someone else who decides to look under the hood at how it works!


posts · about · home