Accessing Kubernetes CRDs from the client-go package

Accessing Kubernetes CRDs from the client-go package
The Kubernetes API server is easily extendable by Custom Resource Defintions. However, actually accessing these resources from the popular client-go library is a bit more complex and not thoroughly documented. This article contains a short guide on how to access Kubernetes CRDs from your own Go code (UPDATED 2020/04 to adjust to API changes in recent client-go versions, using Go modules and doing (some) code generation with controller-gen).

Motivation

I came across this challenge while wanting to integrate a third-party storage vendor into a Kubernetes cluster in my day job at Mittwald. The plan was to use Custom Resource Definitions to define things like Filesystem Pools and Filesystems. Then, a custom Operator could listen for these resources being created and deleted and take care of the actual provisioning of these resources.

Defining and creating a Custom Resource

For this article, we’ll work with an easy example: Custom Resource Definitions can be easily created using kubectl, and for this example, we will start with a single simple resource definition:

apiVersion: "apiextensions.k8s.io/v1beta1"
kind: "CustomResourceDefinition"
metadata:
  name: "projects.example.martin-helmich.de"
spec:
  group: "example.martin-helmich.de"
  version: "v1alpha1"
  scope: "Namespaced"
  names:
    plural: "projects"
    singular: "project"
    kind: "Project"
  validation:
    openAPIV3Schema:
      required: ["spec"]
      properties:
        spec:
          required: ["replicas"]
          properties:
            replicas:
              type: "integer"
              minimum: 1

For defining a Custom Resource Definition, you will need to think of an API Group Name (in this case, example.martin-helmich.de). By convention, this is usually the Domain Name of a domain that you control (for example, your organization’s domain) in order to prevent naming conflicts. The CRD’s name then follows the pattern <plural-resource-name>.<api-group-name>, so in this case projects.example.martin-helmich.de.

Also, be careful when choosing your definition version (spec.version in the example above). As long as your definitions are still evolving, it’s usually a good idea to declare your first definition with an alpha API group version. To users of your custom resource, this will clearly communicate that the definitions might still change, later.

Often, you want to validate the data that users store in your custom resources against a certain schema. This is what the spec.validation.openAPIV3Schema is for: This contains a JSON Schema that describes the format that your resources should have.

After saving the CRD in a file, you can use kubectl to create your resource definition:

> kubectl apply -f projects-crd.yaml
customresourcedefinition "projects.example.martin-helmich.de" created

After you have created your Custom Resource Definition, you can create instances of this new resource type. These are defined like regular Kubernetes objects (like, for example, Pods, Deployments and others). Only the kind and apiVersion vary:

apiVersion: "example.martin-helmich.de/v1alpha1"
kind: "Project"
metadata:
  name: "example-project"
  namespace: "default"
spec:
  replicas: 1

You can create custom resources like any other object with kubectl:

> kubectl apply -f project.yaml
project "example-project" created

You can even use kubectl get to retrieve your custom resources back from the Kubernetes API. Most other commands like kubectl edit, apply or delete will work, as well:

> kubectl get projects
NAME               AGE
example-project    2m

Creating a Golang client

Next, we’ll use the client-go package to access these custom resources. For this example, I’ll assume that you are working in a Go project with the package name github.com/martin-helmich/kubernetes-crd-example (yes, that repository actually exists) and have the client-go and apimachinery libraries installed as Go modules:

<pre class="highlight" style="color: rgb(0, 0, 0); font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-align: start; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration-style: initial; text-decoration-color: initial;">go mod init github.com/martin-helmich/kubernetes-crd-example
go get k8s.io/[[email protected]](https://www.martin-helmich.de/cdn-cgi/l/email-protection)
go get k8s.io/[[email protected]](https://www.martin-helmich.de/cdn-cgi/l/email-protection)</pre>

Note Many documentations working with CRDs will assume that you are working with some kind of code generation to generate client libraries automatically. However, this process is documented sparsely, and from reading a few heated discussions on Github, I got the impression that it's still very much a work-in-progress. We'll stick with a (mostly) manually implemented client, for now.

Step 1: Define types

Start by defining the types for your custom resource. I’ve found it to be a good practice to organize these types by the API group version; for example, you could create a file api/types/v1alpha1/project.go with the following contents:

package v1alpha1

import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

type ProjectSpec struct {
    Replicas int `json:"replicas"`
}

type Project struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec ProjectSpec `json:"spec"`
}

type ProjectList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata,omitempty"`

    Items []Project `json:"items"`

The metav1.ObjectMeta type contains the typical metadata properties that you can find in any Kubernetes resource (like for example, the name, namespace, labels and annotations).

Step 2: Define DeepCopy methods

Each type that is being served by the Kubernetes API (in this case, Project and ProjectList) needs to implement the k8s.io/apimachinery/pkg/runtime.Object interface. This interface defines the two methods GetObjectKind() and DeepCopyObject(). The first method is already provided by the embedded metav1.TypeMeta struct; the second you’ll have to implement yourself.

The DeepCopyObject method is intended to generate a deep copy of an object. Since this involves a lot of boilerplate code, these methods are often automatically generated. For the sake of this article, we’ll do it manually. Continue by adding a second file deepcopy.go to the same package:

package v1alpha1

import "k8s.io/apimachinery/pkg/runtime"

// DeepCopyInto copies all properties of this object into another object of the
// same type that is provided as a pointer.
func (in *Project) DeepCopyInto(out *Project) {
    out.TypeMeta = in.TypeMeta
    out.ObjectMeta = in.ObjectMeta
    out.Spec = ProjectSpec{
        Replicas: in.Spec.Replicas,
    }
}

// DeepCopyObject returns a generically typed copy of an object
func (in *Project) DeepCopyObject() runtime.Object {
    out := Project{}
    in.DeepCopyInto(&out)

    return &out
}

// DeepCopyObject returns a generically typed copy of an object
func (in *ProjectList) DeepCopyObject() runtime.Object {
    out := ProjectList{}
    out.TypeMeta = in.TypeMeta
    out.ListMeta = in.ListMeta

    if in.Items != nil {
        out.Items = make([]Project, len(in.Items))
        for i := range in.Items {
            in.Items[i].DeepCopyInto(&out.Items[i])
        }
    }

    return &out
}

Interlude: Generate the DeepCopy methods automatically

OK - you may have already noticed that defining all these various DeepCopy methods is not a lot of fun. There are many different tools and frameworks around to automatically generate these methods (all with very different levels of documentation and overall maturity). The one that I’ve found works best is the controller-gen tool, which is part of the Kubebuilder framework:

$ go get -u github.com/kubernetes-sigs/controller-tools/cmd/controller-gen

To use controller-gen, annotate your CRD types with a +k8s:deepcopy-gen annotation:

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type Project struct {
    // ...
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type ProjectList struct {
    // ...
}

Then, run controller-gen object paths=./api/types/v1alpha1/project.go to automatically generate the deepcopy methods.

To make things even more easy, you could add a go:generate statement to the entire file:

package v1alpha1

import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

//go:generate controller-gen object paths=$GOFILE

// ...

Then, just run go generate ./... in your root directory to update the generated code.

Step 3: Register types at the scheme builder

Next, you’ll need to make your new types known to the client library. This will allow the client to (more or less) automatically process your new types when communicating with the API server.

For this, add a new file register.go to your package:

package v1alpha1

import (
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/runtime/schema"
)

const GroupName = "example.martin-helmich.de"
const GroupVersion = "v1alpha1"

var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: GroupVersion}

var (
    SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
    AddToScheme   = SchemeBuilder.AddToScheme
)

func addKnownTypes(scheme *runtime.Scheme) error {
    scheme.AddKnownTypes(SchemeGroupVersion,
        &Project{},
        &ProjectList{},
    )

    metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
    return nil
}

As you may notice, this code does not really do anything, yet (except for creating a new runtime.SchemeBuilder instance). The important part is the AddToScheme function (line 16), which is an exported struct member of the runtime.SchemeBuilder type created in line 15. You can call this function later from any part of your client code as soon as the Kubernetes client is initialized to register your type definitions.

Step 4: Build a HTTP client

After defining types and adding a method to register them at the global scheme builder, you can now create a HTTP client that is capable of loading your custom resource.

For this, add the following code to your package’s main.go file (for now):

package main

import (
    "flag"
    "log"
    "time"

    "k8s.io/apimachinery/pkg/runtime/schema"
    "k8s.io/apimachinery/pkg/runtime/serializer"

    "github.com/martin-helmich/kubernetes-crd-example/api/types/v1alpha1"
    "k8s.io/client-go/kubernetes/scheme"
    "k8s.io/client-go/rest"
    "k8s.io/client-go/tools/clientcmd"
)

var kubeconfig string

func init() {
    flag.StringVar(&kubeconfig, "kubeconfig", "", "path to Kubernetes config file")
    flag.Parse()
}

func main() {
    var config *rest.Config
    var err error

    if kubeconfig == "" {
        log.Printf("using in-cluster configuration")
        config, err = rest.InClusterConfig()
    } else {
        log.Printf("using configuration from '%s'", kubeconfig)
        config, err = clientcmd.BuildConfigFromFlags("", kubeconfig)
    }

    if err != nil {
        panic(err)
    }

    v1alpha1.AddToScheme(scheme.Scheme)

    crdConfig := *config
    crdConfig.ContentConfig.GroupVersion = &schema.GroupVersion{Group: v1alpha1.GroupName, Version: v1alpha1.GroupVersion}
    crdConfig.APIPath = "/apis"
    crdConfig.NegotiatedSerializer = serializer.NewCodecFactory(scheme.Scheme)
    crdConfig.UserAgent = rest.DefaultKubernetesUserAgent()

    exampleRestClient, err := rest.UnversionedRESTClientFor(&crdConfig)
    if err != nil {
        panic(err)
    }
}

You can now use the exampleRestClient created in line 48 to query all custom resources within the example.martin-helmich.de/v1alpha1 API group. An example might look like this:

result := v1alpha1.ProjectList{}
err := exampleRestClient.
    Get().
    Resource("projects").
    Do().
    Into(&result)

In order to use your API in a more typesafe way, it is usually a good idea to wrap these operations within your own clientset. For this, create a new subpackage clientset/v1alpha1. To start, implement an interface that defines the types of your API group and move the configuration setup from your main method into that clientset’s constructor function (NewForConfig in the example below):

package v1alpha1

import (
    "github.com/martin-helmich/kubernetes-crd-example/api/types/v1alpha1"
    "k8s.io/apimachinery/pkg/runtime/schema"
    "k8s.io/apimachinery/pkg/runtime/serializer"
    "k8s.io/client-go/kubernetes/scheme"
    "k8s.io/client-go/rest"
)

type ExampleV1Alpha1Interface interface {
    Projects(namespace string) ProjectInterface 
}

type ExampleV1Alpha1Client struct {
    restClient rest.Interface
}

func NewForConfig(c *rest.Config) (*ExampleV1Alpha1Client, error) {
    config := *c
    config.ContentConfig.GroupVersion = &schema.GroupVersion{Group: v1alpha1.GroupName, Version: v1alpha1.GroupVersion}
    config.APIPath = "/apis"
    config.NegotiatedSerializer = scheme.Codecs.WithoutConversion()
    config.UserAgent = rest.DefaultKubernetesUserAgent()

    client, err := rest.RESTClientFor(&config)
    if err != nil {
        return nil, err
    }

    return &ExampleV1Alpha1Client{restClient: client}, nil
}

func (c *ExampleV1Alpha1Client) Projects(namespace string) ProjectInterface {
    return &projectClient{
        restClient: c.restClient,
        ns: namespace,
    }
}

The code below will not compile yet, as it’s still missing the ProjectInterface and projectClient types. We’ll get to those in a moment.

The ExampleV1Alpha1Interface and its implementation, the ExampleV1Alpha1Client struct are now the central point of entry for accessing your custom resources. You can now easily create a new clientset in your main.go by simply calling clientset, err := v1alpha1.NewForConfig(config).

Next, you’ll need to implement a specific clientset for accessing the Project custom resource (note that the example above already uses the ProjectInterface and projectClient types that we still need to supply). Create a second file projects.go in the same package:

package v1alpha1

import (
    "github.com/martin-helmich/kubernetes-crd-example/api/types/v1alpha1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/watch"
    "k8s.io/client-go/kubernetes/scheme"
    "k8s.io/client-go/rest"
)

type ProjectInterface interface {
    List(opts metav1.ListOptions) (*v1alpha1.ProjectList, error)
    Get(name string, options metav1.GetOptions) (*v1alpha1.Project, error)
    Create(*v1alpha1.Project) (*v1alpha1.Project, error)
    Watch(opts metav1.ListOptions) (watch.Interface, error)
    // ...
}

type projectClient struct {
    restClient rest.Interface
    ns         string
}

func (c *projectClient) List(opts metav1.ListOptions) (*v1alpha1.ProjectList, error) {
    result := v1alpha1.ProjectList{}
    err := c.restClient.
        Get().
        Namespace(c.ns).
        Resource("projects").
        VersionedParams(&opts, scheme.ParameterCodec).
        Do().
        Into(&result)

    return &result, err
}

func (c *projectClient) Get(name string, opts metav1.GetOptions) (*v1alpha1.Project, error) {
    result := v1alpha1.Project{}
    err := c.restClient.
        Get().
        Namespace(c.ns).
        Resource("projects").
        Name(name).
        VersionedParams(&opts, scheme.ParameterCodec).
        Do().
        Into(&result)

    return &result, err
}

func (c *projectClient) Create(project *v1alpha1.Project) (*v1alpha1.Project, error) {
    result := v1alpha1.Project{}
    err := c.restClient.
        Post().
        Namespace(c.ns).
        Resource("projects").
        Body(project).
        Do().
        Into(&result)

    return &result, err
}

func (c *projectClient) Watch(opts metav1.ListOptions) (watch.Interface, error) {
    opts.Watch = true
    return c.restClient.
        Get().
        Namespace(c.ns).
        Resource("projects").
        VersionedParams(&opts, scheme.ParameterCodec).
        Watch()
}

This client is obviously not yet complete and misses methods like Delete, Update and others. However, these can be implemented similar to the already existing methods. Have a look at the existing client sets (for example, the Pod client set) for inspiration.

After creating your client set, using it to list your existing resources becomes quite easy:

import clientV1alpha1 "github.com/martin-helmich/kubernetes-crd-example/clientset/v1alpha1"
// ...

func main() {
    // ...

    clientSet, err := clientV1alpha1.NewForConfig(config)
    if err != nil {
        panic(err)
    }

    projects, err := clientSet.Projects("default").List(metav1.ListOptions{})
    if err != nil {
        panic(err)
    }

    fmt.Printf("projects found: %+v\n", projects)
}

Step 5: Build an informer

When building a Kubernetes Operator, you’ll typically want to be able to react on newly created or updated resources. In theory, you could just periodically call the List() method and check if new resources were added. In practice, this is a sub-optimal solution, especially when you have lots of these resources.

Most operators work by initially loading all relevant instances of a resource by using an initial List() call, and then subscribing to updates using a Watch() call. The initial object list and the updates received from the watch are then used to construct a local cache that allows quick access to any custom resources without having to hit the API server every time.

This pattern is so common that the client-go library offers a helper for this: the Informer from the k8s.io/client-go/tools/cache package. You can construct a new Informer for your custom resource as follows:

package main

import (
    "time"

    "github.com/martin-helmich/kubernetes-crd-example/api/types/v1alpha1"
    client_v1alpha1 "github.com/martin-helmich/kubernetes-crd-example/clientset/v1alpha1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/util/wait"
    "k8s.io/apimachinery/pkg/watch"
    "k8s.io/client-go/tools/cache"
)

func WatchResources(clientSet client_v1alpha1.ExampleV1Alpha1Interface) cache.Store {
    projectStore, projectController := cache.NewInformer(
        &cache.ListWatch{
            ListFunc: func(lo metav1.ListOptions) (result runtime.Object, err error) {
                return clientSet.Projects("some-namespace").List(lo)
            },
            WatchFunc: func(lo metav1.ListOptions) (watch.Interface, error) {
                return clientSet.Projects("some-namespace").Watch(lo)
            },
        },
        &v1alpha1.Project{},
        1*time.Minute,
        cache.ResourceEventHandlerFuncs{},
    )

    go projectController.Run(wait.NeverStop)
    return projectStore
}

The NewInformer method returns two objects: The second return value, the controller controls the List() and Watch() calls and fills the first return value, the store with a (more or less) recent cache of the watched resource’s state on the API server (in this case, the project CRD).

You can now use the store to easily access your CRDs, either listing them all or accessing them by name. Keep in mind that the store functions return generic interface{} types, so you’ll have to typecast them back to your CRD type:

store := WatchResource(clientSet)

project := store.GetByKey("some-namespace/some-project").(*v1alpha1.Project)

Conclusion

Building clients for Custom Resources is something that is (at least, currently) only sparsely documented and can be a bit tricky at times.

A client library for your Custom Resource as shown in this article, along with a respective Informer is a good starting point for building your own Kubernetes operator that reacts on changes made to Custom Resources. Check the Github project for this article for a complete and working version.

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容