クライアントの使い方

カスタムコントローラーを実装する前に、Kubernetes APIにアクセスするためのクライアントライブラリの使い方を確認しましょう。

controller-runtimeでは、Kubernetes APIにアクセスするためのクライアントライブラリ(client.Client)を提供しています。

このクライアントは標準リソースとカスタムリソースを同じように扱うことができ、型安全で簡単に利用できます。

クライアントの作成

クライアントを作成するためにはまずSchemeを用意する必要があります。

SchemeはGoのstructとGroupVersionKindを相互に変換したり、異なるバージョン間でのSchemeの変換をおこなったりするための機能です。

kubebuilderが生成したコードでは、以下のように初期化処理をおこなっています。

main.go

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

func init() {
    utilruntime.Must(clientgoscheme.AddToScheme(scheme))

    utilruntime.Must(viewv1.AddToScheme(scheme))
    //+kubebuilder:scaffold:scheme
}

最初にruntime.NewScheme()で新しいschemeを作成します。 clientgoscheme.AddToSchemeでは、PodやServiceなどKubernetesの標準リソースの型をschemeに追加しています。 viewv1.AddToSchemeでは、MarkdownViewカスタムリソースの型をschemeに追加しています。

このSchemeを利用することで、標準リソースとMarkdownViewリソースを扱うことができるクライアントを作成できます。

つぎにGetConfigOrDieでクライアントの設定を取得しています。

main.go

    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme:                 scheme,
        MetricsBindAddress:     metricsAddr,
        Port:                   9443,
        HealthProbeBindAddress: probeAddr,
        LeaderElection:         enableLeaderElection,
        LeaderElectionID:       "3ca5b296.zoetrope.github.io",
        // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
        // when the Manager ends. This requires the binary to immediately end when the
        // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
        // speeds up voluntary leader transitions as the new leader don't have to wait
        // LeaseDuration time first.
        //
        // In the default scaffold provided, the program ends immediately after
        // the manager stops, so would be fine to enable this option. However,
        // if you are doing or is intended to do any operation such as perform cleanups
        // after the manager stops then its usage might be unsafe.
        // LeaderElectionReleaseOnCancel: true,
    })

GetConfigOrDie関数は、下記のいずれかの設定を読み込みます。

  • コマンドラインオプションの--kubeconfig指定された設定ファイル
  • 環境変数KUBECONFIGで指定された設定ファイル
  • Kubernetesクラスター上でPodとして動いているのであれば、カスタムコントローラーが持つサービスアカウントの認証情報を利用

カスタムコントローラーは通常Kubernetesクラスター上で動いているので、サービスアカウントの認証情報が利用されます。

このSchemeとConfigを利用してManagerを作成し、GetClient()でクライアントを取得できます。

以下のようにManagerから取得したクライアントを、MarkdownViewReconcilerに渡します。

main.go

    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme:                 scheme,
        MetricsBindAddress:     metricsAddr,
        Port:                   9443,
        HealthProbeBindAddress: probeAddr,
        LeaderElection:         enableLeaderElection,
        LeaderElectionID:       "3ca5b296.zoetrope.github.io",
        // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily
        // when the Manager ends. This requires the binary to immediately end when the
        // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly
        // speeds up voluntary leader transitions as the new leader don't have to wait
        // LeaseDuration time first.
        //
        // In the default scaffold provided, the program ends immediately after
        // the manager stops, so would be fine to enable this option. However,
        // if you are doing or is intended to do any operation such as perform cleanups
        // after the manager stops then its usage might be unsafe.
        // LeaderElectionReleaseOnCancel: true,
    })

ただし、ManagerのStart()を呼び出す前にクライアントは利用できないので注意しましょう。

Reconcile関数の中でクライアントの使い方

Managerから渡されたクライアントは、以下のようにMarkdownViewReconcilerの埋め込みフィールドとなります。

markdownview_controller.go

type MarkdownViewReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

そのため、Reconcile関数内ではクライアントのメソッドをr.Get(...)r.Create(...)のように呼び出すことができます。

また、クライアントを利用してDeploymentやServiceなどKubernetesの標準リソースを扱う際には、利用したいリソースのグループバージョンに 応じたパッケージをimportする必要があります。 例えばDeploymentリソースであれば"k8s.io/api/apps/v1"パッケージ、Serviceリソースであれば"k8s.io/api/core/v1"パッケージが必要となります。

しかし、これをそのままインポートするとv1というパッケージ名が衝突してしまうため、 import appsv1 "k8s.io/api/apps/v1"のようにエイリアスをつけてインポートするのが一般的です。

本ページのサンプルでは、以下のimportを利用します。

markdownview_controller.go

import (
    "context"
    "fmt"

    viewv1 "github.com/zoetrope/markdown-view/api/v1"
    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/equality"
    apierrors "k8s.io/apimachinery/pkg/api/errors"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    "k8s.io/apimachinery/pkg/labels"
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/runtime/schema"
    "k8s.io/apimachinery/pkg/util/intstr"
    applyappsv1 "k8s.io/client-go/applyconfigurations/apps/v1"
    applycorev1 "k8s.io/client-go/applyconfigurations/core/v1"
    applymetav1 "k8s.io/client-go/applyconfigurations/meta/v1"
    "k8s.io/utils/pointer"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
)

Get/List

クライアントを利用して、リソースを取得する方法を見ていきます。

Getの使い方

リソースを取得するには、下記のように第2引数で欲しいリソースのnamespaceとnameを指定します。 そして第3引数に指定した変数で結果を受け取ることができます。 なお、どの種類のリソースを取得するのかは、第3引数に渡した変数の型で自動的に判別されます。

markdownview_controller.go

func (r *MarkdownViewReconciler) Reconcile_get(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var deployment appsv1.Deployment
    err := r.Get(ctx, client.ObjectKey{Namespace: "default", Name: "sample"}, &deployment)
    if err != nil {
        return ctrl.Result{}, err
    }
    fmt.Printf("Got Deployment: %#v\n", deployment)
    return ctrl.Result{}, nil
}

クライアントのキャッシュ機構

Kubernetes上ではいくつものコントローラーが動いており、そのコントローラーはそれぞれたくさんのリソースを扱っています。 これらのコントローラーが毎回APIサーバーにアクセスしてリソースの取得をおこなうと、APIサーバーやそのバックエンドにいるetcdの負荷が高まってしまうという問題があります。

そこで、controller-runtimeの提供するクライアントはキャッシュ機構を備えています。 このクライアントはGet()List()でリソースを取得すると、同一namespace内の同じKindのリソースをすべて取得してインメモリにキャッシュします。 そして対象のリソースをWatchし、APIサーバー上でリソースの変更が発生した場合にキャッシュの更新をおこないます。

cache

このようなキャッシュの仕組みにより、コントローラーからAPIサーバーへのアクセスを減らすことが可能になっています。

なお、このようなキャッシュ機構を備えているため、実装上はGetしか呼び出していなくても、リソースのアクセス権限としてはListやWatchが必要となります。 RBACマニフェストの生成で解説したように、リソースの取得をおこなう場合はget, list, watchの権限を付与しておきましょう。

キャッシュの仕組みが必要ない場合は、ManagerのGetAPIReader()を利用してキャッシュ機能のないクライアントを取得できます。

Listの使い方

Listでは条件を指定して複数のリソースを一度に取得できます。

下記の例では、LabelSelectorやNamespaceを指定してリソースの取得をおこなっています。 なお、Namespaceを指定しなかった場合は、全Namespaceのリソースを取得します。

markdownview_controller.go

func (r *MarkdownViewReconciler) Reconcile_list(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var services corev1.ServiceList
    err := r.List(ctx, &services, &client.ListOptions{
        Namespace:     "default",
        LabelSelector: labels.SelectorFromSet(map[string]string{"app": "sample"}),
    })
    if err != nil {
        return ctrl.Result{}, err
    }
    for _, svc := range services.Items {
        fmt.Println(svc.Name)
    }
    return ctrl.Result{}, nil
}

LimitContinueを利用することで、ページネーションをおこなうことも可能です。 下記の例では1回のAPI呼び出しで3件ずつリソースを取得して表示しています。

markdownview_controller.go

func (r *MarkdownViewReconciler) Reconcile_pagination(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    token := ""
    for i := 0; ; i++ {
        var services corev1.ServiceList
        err := r.List(ctx, &services, &client.ListOptions{
            Limit:    3,
            Continue: token,
        })
        if err != nil {
            return ctrl.Result{}, err
        }

        fmt.Printf("Page %d:\n", i)
        for _, svc := range services.Items {
            fmt.Println(svc.Name)
        }
        fmt.Println()

        token = services.ListMeta.Continue
        if len(token) == 0 {
            return ctrl.Result{}, nil
        }
    }
}

.ListMeta.Continueにトークンが入っているを利用して、続きのリソースを取得できます。 トークンが空になるとすべてのリソースを取得したということになります。

Create/Update

リソースの作成はCreate()、更新にはUpdate()を利用します。 例えば、Deploymentリソースは以下のように作成できます。

markdownview_controller.go

func (r *MarkdownViewReconciler) Reconcile_create(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    dep := appsv1.Deployment{
        ObjectMeta: metav1.ObjectMeta{
            Name:      "sample",
            Namespace: "default",
        },
        Spec: appsv1.DeploymentSpec{
            Replicas: pointer.Int32Ptr(1),
            Selector: &metav1.LabelSelector{
                MatchLabels: map[string]string{"app": "nginx"},
            },
            Template: corev1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{
                    Labels: map[string]string{"app": "nginx"},
                },
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{
                        {
                            Name:  "nginx",
                            Image: "nginx:latest",
                        },
                    },
                },
            },
        },
    }
    err := r.Create(ctx, &dep)
    if err != nil {
        return ctrl.Result{}, err
    }
    return ctrl.Result{}, nil
}

なお、リソースがすでに存在する状態でCreate()を呼んだり、リソースが存在しない状態でUpdate()を呼び出したりするとエラーになります。

CreateOrUpdate

Get()でリソースを取得して、リソースが存在しなければCreate()を呼び、存在すればUpdate()を呼び出すという処理は頻出パターンです。 そこで、controller-runtimeにはCreateOrUpdate()という便利な関数が用意されています。

markdownview_controller.go

func (r *MarkdownViewReconciler) Reconcile_createOrUpdate(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    svc := &corev1.Service{}
    svc.SetNamespace("default")
    svc.SetName("sample")

    op, err := ctrl.CreateOrUpdate(ctx, r.Client, svc, func() error {
        svc.Spec.Type = corev1.ServiceTypeClusterIP
        svc.Spec.Selector = map[string]string{"app": "nginx"}
        svc.Spec.Ports = []corev1.ServicePort{
            {
                Name:       "http",
                Protocol:   corev1.ProtocolTCP,
                Port:       80,
                TargetPort: intstr.FromInt(80),
            },
        }
        return nil
    })
    if err != nil {
        return ctrl.Result{}, err
    }
    if op != controllerutil.OperationResultNone {
        fmt.Printf("Deployment %s\n", op)
    }
    return ctrl.Result{}, nil
}

この関数の第3引数に渡すオブジェクトには、NameとNamespaceのみを指定します(ただしクラスターリソースの場合はNamespace不要)。

リソースが存在した場合、この第3引数で渡した変数に既存のリソースの値がセットされます。 その後、第4引数で渡した関数の中でそのdep変数を書き換え、更新処理を実行します。

リソースが存在しない場合は、第4引数で渡した関数を実行した後、リソースの作成処理が実行されます。

Patch

Update()CreateOrUpdate()による更新処理は、リソースを取得してから更新するまでの間に、他の誰かがリソースを書き換えてしまう可能性があります (これをTOCTTOU: Time of check to time of useと呼びます)。

すでに書き換えられたリソースを更新しようとすると、以下のようなエラーが発生してしまいます。

Operation cannot be fulfilled on deployments.apps "sample": the object has been modified; please apply your changes to the latest version and try again

そこでPatch()を利用すると、競合することなく変更したいフィールドの値だけを更新できます。

Patchにはclient.MergeFromclient.StrategicMergeFromを利用する方法と, Server-Side Applyを利用する方法があります。

client.MergeFromclient.StrategicMergeFromの違いは、リスト要素の更新方法です。 client.MergeFromでリストを更新すると指定した要素で上書きされますが、client.StrategicMergeFromではリストはpatchStrategyに応じて 要素が追加されたり更新されたりします。

client.MergeFromを利用してDeploymentのレプリカ数のみを更新する例を以下に示します。

markdownview_controller.go

func (r *MarkdownViewReconciler) Reconcile_patchMerge(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var dep appsv1.Deployment
    err := r.Get(ctx, client.ObjectKey{Namespace: "default", Name: "sample"}, &dep)
    if err != nil {
        return ctrl.Result{}, err
    }

    newDep := dep.DeepCopy()
    newDep.Spec.Replicas = pointer.Int32Ptr(3)
    patch := client.MergeFrom(&dep)

    err = r.Patch(ctx, newDep, patch)

    return ctrl.Result{}, err
}

一方のServer-Side ApplyはKubernetes v1.14で導入されたリソースの更新方法です。 リソースの各フィールドを更新したコンポーネントを.metadata.managedFieldsで管理することで、 サーバーサイドでリソース更新の衝突を検出できます。

Server-Side Applyでは、以下のようにUnstructured型のパッチを用意してリソースの更新をおこないます。

なお、公式ドキュメントに記述されているように、 カスタムコントローラでServer-Side Applyをおこなう際には、常にForceオプションを有効にすることが推奨されています。

markdownview_controller.go

func (r *MarkdownViewReconciler) Reconcile_patchApply(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    patch := &unstructured.Unstructured{}
    patch.SetGroupVersionKind(schema.GroupVersionKind{
        Group:   "apps",
        Version: "v1",
        Kind:    "Deployment",
    })
    patch.SetNamespace("default")
    patch.SetName("sample2")
    patch.UnstructuredContent()["spec"] = map[string]interface{}{
        "replicas": 2,
        "selector": map[string]interface{}{
            "matchLabels": map[string]string{
                "app": "nginx",
            },
        },
        "template": map[string]interface{}{
            "metadata": map[string]interface{}{
                "labels": map[string]string{
                    "app": "nginx",
                },
            },
            "spec": map[string]interface{}{
                "containers": []interface{}{
                    map[string]interface{}{
                        "name":  "nginx",
                        "image": "nginx:latest",
                    },
                },
            },
        },
    }

    err := r.Patch(ctx, patch, client.Apply, &client.PatchOptions{
        FieldManager: "client-sample",
        Force:        pointer.Bool(true),
    })

    return ctrl.Result{}, err
}

上記のようにServer-Side ApplyはUnstructured型を利用するため、型安全なコードが記述できませんでした。

Kubernetes v1.21からApplyConfigurationが導入され、以下のように型安全なServer-Side Applyのコードが書けるようになりました。

markdownview_controller.go

func (r *MarkdownViewReconciler) Reconcile_patchApplyConfig(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    dep := applyappsv1.Deployment("sample3", "default").
        WithSpec(applyappsv1.DeploymentSpec().
            WithReplicas(3).
            WithSelector(applymetav1.LabelSelector().WithMatchLabels(map[string]string{"app": "nginx"})).
            WithTemplate(applycorev1.PodTemplateSpec().
                WithLabels(map[string]string{"app": "nginx"}).
                WithSpec(applycorev1.PodSpec().
                    WithContainers(applycorev1.Container().
                        WithName("nginx").
                        WithImage("nginx:latest"),
                    ),
                ),
            ),
        )

    obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(dep)
    if err != nil {
        return ctrl.Result{}, err
    }
    patch := &unstructured.Unstructured{
        Object: obj,
    }

    var current appsv1.Deployment
    err = r.Get(ctx, client.ObjectKey{Namespace: "default", Name: "sample3"}, &current)
    if err != nil && !apierrors.IsNotFound(err) {
        return ctrl.Result{}, err
    }

    currApplyConfig, err := applyappsv1.ExtractDeployment(&current, "client-sample")
    if err != nil {
        return ctrl.Result{}, err
    }

    if equality.Semantic.DeepEqual(dep, currApplyConfig) {
        return ctrl.Result{}, nil
    }

    err = r.Patch(ctx, patch, client.Apply, &client.PatchOptions{
        FieldManager: "client-sample",
        Force:        pointer.Bool(true),
    })
    return ctrl.Result{}, err
}

Status.Update/Patch

Statusをサブリソース化している場合、これまで紹介したUpdate()Patch()を利用してもステータスを更新できません。 Status更新用のクライアントを利用することになります。

Status().Update()Status().Patch()は、メインリソースのUpdate()Patch()と使い方は同じです。 以下のようにstatusフィールドを変更し、Status().Update()を呼び出します。 (このコードはあくまでもサンプルです。Deploymentリソースのステータスを勝手に書き換えるべきではありません。)

markdownview_controller.go

func (r *MarkdownViewReconciler) updateStatus(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var dep appsv1.Deployment
    err := r.Get(ctx, client.ObjectKey{Namespace: "default", Name: "sample"}, &dep)
    if err != nil {
        return ctrl.Result{}, err
    }

    dep.Status.AvailableReplicas = 3
    err = r.Status().Update(ctx, &dep)
    return ctrl.Result{}, err
}

Delete/DeleteAllOf

最後にリソースを削除するDeleteDeleteAllOfを見てみましょう。

DeleteDeleteAllOfにはPreconditionsという特殊なオプションがあります。 以下のコードはPreconditionsオプションを利用した例です。

markdownview_controller.go

func (r *MarkdownViewReconciler) Reconcile_deleteWithPreConditions(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var deploy appsv1.Deployment
    err := r.Get(ctx, client.ObjectKey{Namespace: "default", Name: "sample"}, &deploy)
    if err != nil {
        return ctrl.Result{}, err
    }
    uid := deploy.GetUID()
    resourceVersion := deploy.GetResourceVersion()
    cond := metav1.Preconditions{
        UID:             &uid,
        ResourceVersion: &resourceVersion,
    }
    err = r.Delete(ctx, &deploy, &client.DeleteOptions{
        Preconditions: &cond,
    })
    return ctrl.Result{}, err
}

リソースを削除する際、リソース取得してから削除のリクエストを投げるまでの間に、同じ名前の別のリソースが作り直される場合があります。 そのようなケースでは、NameとNamespaceのみを指定してDeleteを呼び出した場合、誤って新しく作成されたリソースを削除される可能性があります。 そこでこの例では再作成したリソースを間違って消してしまわないように、Preconditionsオプションを利用してUIDとResourceVersionが一致するリソースを削除しています。

DeleteAllOfは、以下のように指定した種類のリソースをまとめて削除できます。

markdownview_controller.go

func (r *MarkdownViewReconciler) Reconcile_deleteAllOfDeployment(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    err := r.DeleteAllOf(ctx, &appsv1.Deployment{}, client.InNamespace("default"))
    return ctrl.Result{}, err
}

なお、ServiceリソースなどDeleteAllOfが利用できないリソースもあるので注意しましょう。

results matching ""

    No results matching ""