なになれ

IT系のことを記録していきます

KubernetesのCustom Resourceを試した

KubernetesのCustom ResourceはKubernetesをユーザが拡張するためのものです。
Kubernetesの標準にあるDeploymentのようなリソースをユーザが独自に作成し、リソースの定義に応じて、Kubernetes上のリソースをコントロールすることが可能です。

ControllerとResourceについて

Custom Resourceを学ぶにあたり、Kubernetesの仕組みを理解する必要があります。
ControllerとResourceというものがKubernetesでは機能していて、これがCustom Resourceのベースになっています。
まずはこれらをざっと理解しておくとCustom Resourceの理解がスムーズになります。
こちらの資料がとても参考になりました。

speakerdeck.com

Custom ControllerとCustom Resourceを作る

ControllerとResourceは一体です。
Custom Resourceというユーザ独自のリソースを作るにあたり、Custom ControllerというCustom Resource用のControllerを用意する必要があります。

今回はKubebuilderというCustom Resource、Custom Controllerを作成するためのSDKを利用します。

github.com

なお、Custom Resource、Custom Controllerは素の状態でも作成することが可能ですが、ControllerとResourceの仕組みに則って作成する必要があり、なかなか複雑な作業になります。

Custom Controllerの例としてsample controllerというものが公式で公開されています。これと同内容のものを作成します。
deploymentのリソースがセルフヒーリングするサンプルになっています。

github.com

Kubebuilderをインストールする

Kubebuilderを下記のドキュメントの通りにインストールします。
Quick Start - The Kubebuilder Book

プロジェクトを作成する

$ cd go/src/tutorial.kubebuilder.io
$ kubebuilder init --domain my.domain

APIを作成する

$ kubebuilder create api --group webapp --version v1 --kind MyKind
Create Resource [y/n]
y
Create Controller [y/n]
y

Custom Resourceを修正する

api/v1/mykind_types.goを修正します。
このファイルの定義内容がcustom resourceのyamlの定義に反映されます。

mykind_types.go

package v1

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.

// MyKindSpec defines the desired state of MyKind
type MyKindSpec struct {
    // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
    // Important: Run "make" to regenerate code after modifying this file

    // DeploymentName is the name of the Deployment resource that the
    // controller should create.
    // This field must be specified.
    // +kubebuilder:validation:MaxLength=64
    DeploymentName string `json:"deploymentName"`

    // Replicas is the number of replicas that should be specified on the
    // Deployment resource that the controller creates.
    // If not specified, one replica will be created.
    // +optional
    // +kubebuilder:validation:Minimum=0
    Replicas *int32 `json:"replicas,omitempty"`
}

// MyKindStatus defines the observed state of MyKind
type MyKindStatus struct {
    // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
    // Important: Run "make" to regenerate code after modifying this file

    // ReadyReplicas is the number of 'ready' replicas observed on the
    // Deployment resource created for this MyKind resource.
    // +optional
    // +kubebuilder:validation:Minimum=0
    ReadyReplicas int32 `json:"readyReplicas,omitempty"`
}

// +kubebuilder:object:root=true

// MyKind is the Schema for the mykinds API
type MyKind struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   MyKindSpec   `json:"spec,omitempty"`
    Status MyKindStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true

// MyKindList contains a list of MyKind
type MyKindList struct {
    metav1.TypeMeta `json:",inline"`
    metav1.ListMeta `json:"metadata,omitempty"`
    Items           []MyKind `json:"items"`
}

func init() {
    SchemeBuilder.Register(&MyKind{}, &MyKindList{})
}

goのstructを使ってcustom resourceのデータ構造を定義します。
kubernetesでは、項目名はcamelcaseがルールなので、structのjson tagを利用して項目名を変換します。
kubebuilderのmetadataを使って、validationなどの設定が可能です。
詳細は以下にあります。

Markers for Config/Code Generation - The Kubebuilder Book

Custom Controllerを修正する

controllers/mykind_controller.goを修正します。

mykind_controller.go(前半部分)

package controllers

import (
    "context"

    apps "k8s.io/api/apps/v1"
    core "k8s.io/api/core/v1"
    apierrors "k8s.io/apimachinery/pkg/api/errors"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"

    "k8s.io/client-go/tools/record"

    "github.com/go-logr/logr"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"

    webappv1 "tutorial.kubebuilder.io/sample-controller/api/v1"
)

// MyKindReconciler reconciles a MyKind object
type MyKindReconciler struct {
    client.Client
    Log      logr.Logger
    Recorder record.EventRecorder
}

// +kubebuilder:rbac:groups=webapp.my.domain,resources=mykinds,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=webapp.my.domain,resources=mykinds/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;delete
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch

func (r *MyKindReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
    ctx := context.Background()
    log := r.Log.WithValues("mykind", req.NamespacedName)

    // your logic here
    log.Info("fetching MyKind resource")
    myKind := webappv1.MyKind{}
    if err := r.Client.Get(ctx, req.NamespacedName, &myKind); err != nil {
        log.Error(err, "failed to get MyKind resource")
        // Ignore NotFound errors as they will be retried automatically if the
        // resource is created in future.
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    if err := r.cleanupOwnedResources(ctx, log, &myKind); err != nil {
        log.Error(err, "failed to clean up old Deployment resources for this MyKind")
        return ctrl.Result{}, err
    }

    log = log.WithValues("deployment_name", myKind.Spec.DeploymentName)

    log.Info("checking if an existing Deployment exists for this resource")
    deployment := apps.Deployment{}
    err := r.Client.Get(ctx, client.ObjectKey{Namespace: myKind.Namespace, Name: myKind.Spec.DeploymentName}, &deployment)
    if apierrors.IsNotFound(err) {
        log.Info("could not find existing Deployment for MyKind, creating one...")

        deployment = *buildDeployment(myKind)
        if err := r.Client.Create(ctx, &deployment); err != nil {
            log.Error(err, "failed to create Deployment resource")
            return ctrl.Result{}, err
        }

        r.Recorder.Eventf(&myKind, core.EventTypeNormal, "Created", "Created deployment %q", deployment.Name)
        log.Info("created Deployment resource for MyKind")
        return ctrl.Result{}, nil
    }
    if err != nil {
        log.Error(err, "failed to get Deployment for MyKind resource")
        return ctrl.Result{}, err
    }

    log.Info("existing Deployment resource already exists for MyKind, checking replica count")

    expectedReplicas := int32(1)
    if myKind.Spec.Replicas != nil {
        expectedReplicas = *myKind.Spec.Replicas
    }
    if *deployment.Spec.Replicas != expectedReplicas {
        log.Info("updating replica count", "old_count", *deployment.Spec.Replicas, "new_count", expectedReplicas)

        deployment.Spec.Replicas = &expectedReplicas
        if err := r.Client.Update(ctx, &deployment); err != nil {
            log.Error(err, "failed to Deployment update replica count")
            return ctrl.Result{}, err
        }

        r.Recorder.Eventf(&myKind, core.EventTypeNormal, "Scaled", "Scaled deployment %q to %d replicas", deployment.Name, expectedReplicas)

        return ctrl.Result{}, nil
    }

    log.Info("replica count up to date", "replica_count", *deployment.Spec.Replicas)

    log.Info("updating MyKind resource status")
    myKind.Status.ReadyReplicas = deployment.Status.ReadyReplicas
    if r.Client.Status().Update(ctx, &myKind); err != nil {
        log.Error(err, "failed to update MyKind status")
        return ctrl.Result{}, err
    }

    log.Info("resource status synced")

    return ctrl.Result{}, nil
}

Reconcile関数はCustom Resourceがapplyされて変更された時に実行される関数になります。
ここにLogicを書いていきます。

ClientでResourceを操作することが可能です。
GetでResourceを取得します。
CreateでResourceを作成します。
UpdateでResourceを更新します。

mykind_controller.go(後半部分)

// cleanupOwnedResources will Delete any existing Deployment resources that
// were created for the given MyKind that no longer match the
// myKind.spec.deploymentName field.
func (r *MyKindReconciler) cleanupOwnedResources(ctx context.Context, log logr.Logger, myKind *webappv1.MyKind) error {
    log.Info("finding existing Deployments for MyKind resource")

    // List all deployment resources owned by this MyKind
    var deployments apps.DeploymentList
    if err := r.List(ctx, &deployments, client.InNamespace(myKind.Namespace), client.MatchingField(deploymentOwnerKey, myKind.Name)); err != nil {
        return err
    }

    deleted := 0
    for _, depl := range deployments.Items {
        if depl.Name == myKind.Spec.DeploymentName {
            // If this deployment's name matches the one on the MyKind resource
            // then do not delete it.
            continue
        }

        if err := r.Client.Delete(ctx, &depl); err != nil {
            log.Error(err, "failed to delete Deployment resource")
            return err
        }

        r.Recorder.Eventf(myKind, core.EventTypeNormal, "Deleted", "Deleted deployment %q", depl.Name)
        deleted++
    }

    log.Info("finished cleaning up old Deployment resources", "number_deleted", deleted)

    return nil
}

func buildDeployment(myKind webappv1.MyKind) *apps.Deployment {
    deployment := apps.Deployment{
        ObjectMeta: metav1.ObjectMeta{
            Name:            myKind.Spec.DeploymentName,
            Namespace:       myKind.Namespace,
            OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(&myKind, webappv1.GroupVersion.WithKind("MyKind"))},
        },
        Spec: apps.DeploymentSpec{
            Replicas: myKind.Spec.Replicas,
            Selector: &metav1.LabelSelector{
                MatchLabels: map[string]string{
                    "deployment-name": myKind.Spec.DeploymentName,
                },
            },
            Template: core.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{
                    Labels: map[string]string{
                        "deployment-name": myKind.Spec.DeploymentName,
                    },
                },
                Spec: core.PodSpec{
                    Containers: []core.Container{
                        {
                            Name:  "nginx",
                            Image: "nginx:latest",
                        },
                    },
                },
            },
        },
    }
    return &deployment
}

var (
    deploymentOwnerKey = ".metadata.controller"
)

func (r *MyKindReconciler) SetupWithManager(mgr ctrl.Manager) error {
    if err := mgr.GetFieldIndexer().IndexField(&apps.Deployment{}, deploymentOwnerKey, func(rawObj runtime.Object) []string {
        // grab the Deployment object, extract the owner...
        depl := rawObj.(*apps.Deployment)
        owner := metav1.GetControllerOf(depl)
        if owner == nil {
            return nil
        }
        // ...make sure it's a MyKind...
        if owner.APIVersion != webappv1.GroupVersion.String() || owner.Kind != "MyKind" {
            return nil
        }

        // ...and if so, return it
        return []string{owner.Name}
    }); err != nil {
        return err
    }

    return ctrl.NewControllerManagedBy(mgr).
        For(&webappv1.MyKind{}).
        Owns(&apps.Deployment{}).
        Complete(r)
}

SetupWithManager関数でReconcile関数が呼ばれるように設定します。
cleanupOwnedResources関数で既存のCustom ResourceがDeploymentを作成していた場合にそれを削除します。
buildDeployment関数でDeploymentリソースを作成します。

起動処理を修正する

main.goを修正します。

main.go

package main

import (
    "flag"
    "os"

    "k8s.io/apimachinery/pkg/runtime"
    clientgoscheme "k8s.io/client-go/kubernetes/scheme"
    _ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/log/zap"
    webappv1 "tutorial.kubebuilder.io/sample-controller/api/v1"
    "tutorial.kubebuilder.io/sample-controller/controllers"
    // +kubebuilder:scaffold:imports
)

var (
    scheme   = runtime.NewScheme()
    setupLog = ctrl.Log.WithName("setup")
)

func init() {
    _ = clientgoscheme.AddToScheme(scheme)

    _ = webappv1.AddToScheme(scheme)
    // +kubebuilder:scaffold:scheme
}

func main() {
    var metricsAddr string
    var enableLeaderElection bool
    flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.")
    flag.BoolVar(&enableLeaderElection, "enable-leader-election", false,
        "Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager.")
    flag.Parse()

    ctrl.SetLogger(zap.Logger(true))

    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme:             scheme,
        MetricsBindAddress: metricsAddr,
        LeaderElection:     enableLeaderElection,
        Port:               9443,
    })
    if err != nil {
        setupLog.Error(err, "unable to start manager")
        os.Exit(1)
    }

    if err = (&controllers.MyKindReconciler{
        Client:   mgr.GetClient(),
        Log:      ctrl.Log.WithName("controllers").WithName("MyKind"),
        Recorder: mgr.GetEventRecorderFor("mykind-controller"),
    }).SetupWithManager(mgr); err != nil {
        setupLog.Error(err, "unable to create controller", "controller", "MyKind")
        os.Exit(1)
    }
    // +kubebuilder:scaffold:builder

    setupLog.Info("starting manager")
    if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
        setupLog.Error(err, "problem running manager")
        os.Exit(1)
    }
}

Recorderをcontrollerに渡すようにして、Custom Resourceのイベントを記録するようにします。

Custom Resourceをkubernetes環境にインストールする

$ make install

Custom Controllerを動かす

$ make run

結果を確認する

Custom Resourceを以下のように定義します。

webapp_v1_mykind.yaml

apiVersion: webapp.my.domain/v1
kind: MyKind
metadata:
  name: mykind-sample
spec:
  # Add fields here
  foo: bar
  deploymentName: mykind
  replicas: 1

custom resourceを作成します。

$ kubectl apply config/samples/webapp_v1_mykind.yaml

しばらくするとdeploymentが作られます。

$ kubectl get deployments
NAME     READY   UP-TO-DATE   AVAILABLE   AGE
mykind   1/1     1            1           33s

deploymentを削除してもしばらくするとまた同じdeploymentが復活します。

$ kubectl delete deployments mykind

まとめ

公式のドキュメントは内容が難しいと思います。

Custom Resources - Kubernetes

個人の方がCustom Resourceについてまとめているドキュメントを参考にしながらCustom Resourceを理解していきました。
Custom ResourceはKubernetes環境をより便利にできる機能です。
Kubebuilderを使うとより楽にCustom Resourceを準備することができます。
ただこれを使って何をするのかは分かっていません。Kubernetesを使っている人たちはどのようにCustom Resourceを使っているのかを知っていく必要があると思います。