クライアントの使い方

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

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

クライアントの作成

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

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

main.go

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

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

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

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

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

つぎにGetConfigOrDieでクライアントの設定を取得しています。 この関数はコマンドラインオプションの--kubeconfigや、環境変数KUBECONFIGで指定された設定ファイルを利用するか、またはKubernetesクラスタ上でPodとして動いているのであれば、Podが持つサービスアカウントの認証情報を利用します。 通常コントローラはKubernetesクラスタ上で動いているので、サービスアカウントの認証情報が利用されます。

このSchemeとConfigを利用してManagerを作成し、GetClient()でクライアントを取得することができます。 ただし、ManagerのStart()を呼び出す前にClientを利用することはできないので注意しましょう。

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
    Scheme: scheme,
})
client := mgr.GetClient()

Get/List

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

Getの使い方

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

tenant_controller.go

var tenant multitenancyv1.Tenant
err := r.Get(ctx, req.NamespacedName, &tenant)

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

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のリソースを取得します。

main.go

func list(cli client.Client) error {
    var pods corev1.PodList
    err := cli.List(context.Background(), &pods, &client.ListOptions{
        Namespace:     "default",
        LabelSelector: labels.SelectorFromSet(map[string]string{"app": "sample"}),
    })
    if err != nil {
        return err
    }
    for _, pod := range pods.Items {
        fmt.Println(pod.Name)
    }
    return nil
}

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

main.go

func pagination(cli client.Client) error {
    token := ""
    for i := 0; ; i++ {
        var pods corev1.PodList
        err := cli.List(context.Background(), &pods, &client.ListOptions{
            Limit:    3,
            Continue: token,
        })
        if err != nil {
            return err
        }

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

        token = pods.ListMeta.Continue
        if len(token) == 0 {
            return nil
        }
    }
}

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

インデックス

複数のリソースを取得する際にラベルやnamespaceだけでなく、特定のフィールドの値に応じてフィルタリングしたいことがあるかと思います。 controller-runtimeではインメモリキャッシュにインデックスを張る仕組みが用意されています。

index

インデックスを利用するためには事前にGetFieldIndexer().IndexField()を利用して、どのフィールドの値に基づいてインデックスを張るのかを指定しておきます。 下記の例ではnamespaceリソースに対して、ownerReferenceに指定されているTenantリソースの名前に応じてインデックスを作成しています。

tenant_controller.go

const ownerControllerField = ".metadata.ownerReference.controller"

func indexByOwnerTenant(obj runtime.Object) []string {
    namespace := obj.(*corev1.Namespace)
    owner := metav1.GetControllerOf(namespace)
    if owner == nil {
        return nil
    }
    if owner.APIVersion != multitenancyv1.GroupVersion.String() || owner.Kind != "Tenant" {
        return nil
    }
    return []string{owner.Name}
}

tenant_controller.go

err := mgr.GetFieldIndexer().IndexField(ctx, &corev1.Namespace{}, ownerControllerField, indexByOwnerTenant)
if err != nil {
    return err
}

フィールド名には、どのフィールドを利用してインデックスを張っているのかを示す文字列を指定します。 実際にインデックスに利用しているフィールドのパスと一致していなくても問題はないのですが、なるべく一致させたほうが可読性がよくなるのでおすすめです。 なおインデックスはGVKごとに作成されるので、異なるタイプのリソース間でフィールド名が同じになっても問題ありません。 またnamespaceスコープのリソースの場合は、内部的にフィールド名にnamespace名を付与して管理しているので、明示的にフィールド名にnamespaceを含める必要はありません。 インデクサーが返す値はスライスになっていることから分かるように、複数の値にマッチするようにインデックスを構成することも可能です。

上記のようなインデックスを作成しておくと、List()を呼び出す際に特定のフィールドが指定した値と一致するリソースだけを取得することができます。 例えば以下の例であれば、ownerReferenceに指定したTenantリソースがセットされているnamespaceだけを取得することができます。

tenant_controller.go

var namespaces corev1.NamespaceList
err := r.List(ctx, &namespaces, client.MatchingFields(map[string]string{ownerControllerField: tenant.Name}))

Create/Update

リソースの作成は以下のようにCreate()を利用します。更新処理のUpdate()も同じように利用できます。

tenant_controller.go

target := corev1.Namespace{
    ObjectMeta: metav1.ObjectMeta{
        Name: name,
    },
}
err = r.Create(ctx, &target, &client.CreateOptions{})
if err != nil {
    log.Error(err, "unable to create the namespace", "name", name)
    return updated, err
}

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

CreateOrUpdate

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

tenant_controller.go

name := tenant.Spec.NamespacePrefix + ns

role := &rbacv1.ClusterRole{}
role.SetName(name + "-admin-role")
op, err := ctrl.CreateOrUpdate(ctx, r.Client, role, func() error {
    role.Rules = []rbacv1.PolicyRule{
        {
            Verbs:         []string{"get", "list", "watch", "update", "patch", "delete"},
            APIGroups:     []string{multitenancyv1.GroupVersion.Group},
            Resources:     []string{"tenants"},
            ResourceNames: []string{tenant.Name},
        },
        {
            Verbs:         []string{"get", "list", "watch"},
            APIGroups:     []string{""},
            Resources:     []string{"namespaces"},
            ResourceNames: []string{name},
        },
    }
    return ctrl.SetControllerReference(&tenant, role, r.Scheme)
})

リソースが存在した場合、role変数に取得したリソースの値が書き込まれます。 その後、第4引数で渡した関数の中でそのrole変数を書き換え、更新処理を実行します。

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

なお、AnnotationsなどのMetadataはKubernetesの標準コントローラが値を設定するので、以下のように初期化や上書きしてはいけません。

op, err := ctrl.CreateOrUpdate(ctx, r.Client, role, func() error {
    role.Annotations = map[string]string{
        "an1": "test",
    }
    return nil
}

Annotationsを追加するときは、以下のようにしましょう。

op, err := ctrl.CreateOrUpdate(ctx, r.Client, role, func() error {
    if role.Annotations == nil {
        role.Annotations = make(map[string]string)
    }
    role.Annotations["an1"] = "test"
    return nil
}

Patch

Update()でリソースを更新するには、そのリソースのすべてのフィールドを埋めなくてはなりません。

Patch()を利用すると、変更したいフィールドの値を用意するだけでリソースの更新をおこなうことができます。

PatchにはMergePath方式とServer-Side Apply方式があります。 Server-Side Apply方式では、リソースの各フィールドごとに管理者を記録することにより、複数のコントローラやユーザーが同一のリソースを編集した場合に衝突を検知することが可能です。 MergePatch方式ではそのような衝突検知はおこなわれません。

ここではServer-Side Apply方式によるPatch()の利用方法を紹介します。 以下の例では、Deploymentリソースのspec.replicasフィールドのみを更新しています。

main.go

func patch(cli client.Client) error {
    patch := &unstructured.Unstructured{}
    patch.SetGroupVersionKind(schema.GroupVersionKind{
        Group:   "apps",
        Version: "v1",
        Kind:    "Deployment",
    })
    patch.SetNamespace("default")
    patch.SetName("test")
    patch.UnstructuredContent()["spec"] = map[string]interface{}{
        "replicas": 2,
    }

    err := cli.Patch(context.Background(), patch, client.Apply, &client.PatchOptions{
        FieldManager: "misc",
    })

    return err
}

Server-Side Applyを利用するには、第3引数にclient.Applyを指定し、オプションにはFieldManagerを指定する必要があります。 このFieldManagerがフィールドごとの管理者の名前になるので、他のコントローラと被らないようにユニークな名前にしましょう。

なお、リストやマップをどのようにマージするのかは、Goの構造体に付与したマーカーで制御することが可能です。 詳しくはMerge strategyを参照してください。(TODO: あとで書く)

Status.Update/Patch

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

Status.Update()Status.Path()は、メインリソースのUpdate()Patch()と使い方は同じです。 ただし、現状カスタムリソースの Status サブリソースは Server-Side Apply による Patch をサポートされていません。

tenant.Status = multitenancyv1.TenantStatus{
    Conditions: []multitenancyv1.TenantCondition{
        {
            Type:               multitenancyv1.ConditionReady, 
            Status:             corev1.ConditionTrue,
            LastTransitionTime: metav1.NewTime(time.Now()),
        },
    },
}
err := r.Status().Update(ctx, &tenant)

Delete/DeleteOfAll

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

DeleteDeleteOfAllにはPreconditionsPropagationPolicyという特殊なオプションがあるのでそちらを紹介します。

まずはPreconditionsオプションを利用した例です。

main.go

func deleteWithPreConditions(cli client.Client) error {
    var deploy appsv1.Deployment
    err := cli.Get(context.Background(), client.ObjectKey{
        Namespace: "default",
        Name:      "test",
    }, &deploy)
    if err != nil {
        return err
    }
    uid := deploy.GetUID()
    resourceVersion := deploy.GetResourceVersion()
    cond := metav1.Preconditions{
        UID:             &uid,
        ResourceVersion: &resourceVersion,
    }
    err = cli.Delete(context.Background(), &deploy, &client.DeleteOptions{
        Preconditions: &cond,
    })
    return err
}

リソースを取得してから削除のリクエストを投げるまでの間にリソースが作り直されてしまう可能性があります。 そこで再作成したリソースを間違って消してしまわないように、UIDとResourceVersionを指定して、確実に指定したリソースを削除しています。

つづいてPropagationPolicyオプションを利用した例です。

main.go

func deleteWithPropagationPolicy(cli client.Client) error {
    var deploy appsv1.Deployment
    err := cli.Get(context.Background(), client.ObjectKey{
        Namespace: "default",
        Name:      "test",
    }, &deploy)
    if err != nil {
        return err
    }
    policy := metav1.DeletePropagationOrphan
    err = cli.Delete(context.Background(), &deploy, &client.DeleteOptions{
        PropagationPolicy: &policy,
    })
    return err
}

リソースの削除で解説するように、Kubernetesでは親リソースを削除するとそのリソースに結びつく子リソースも一緒に削除されます。 この挙動を変えるためのオプションとしてPropagationPolicyが用意されています。

上記のようにDeploymentリソースの削除時にDeletePropagationOrphanを指定すると、子のリソースであるReplicaSetやPodのリソースが削除されなくなります。

ディスカバリーベースのクライアント

client-goを利用してCRDを扱う場合、k8s.io/client-go/dynamick8s.io/apimachinery/pkg/apis/meta/v1/unstructuredによる動的型クライアントを利用するか、kubernetes/code-generatorを利用してコード生成をおこなう必要がありました。

しかし、controller-runtimeのClientでは、引数に構造体を渡すだけで標準リソースでもカスタムリソースでもAPIを呼び分けてくれています。 このClientはどのように仕組みになっているのでしょうか。

まずは渡された構造体の型を Scheme に登録された情報から探します。そうすると GVK が得られます。

次にREST APIを叩くためにはREST APIのパスを解決する必要があります。 REST APIのパスは、namespace-scopedのリソースであれば/apis/{group}/{version}/namespaces/{namespace}/{resource}/{name}、cluster-scopeのスコープであれば/apis/{group}/{version}/{resource}/{name}のようになります。 この情報はCRDに記述されているため、APIサーバーに問い合わせる必要があります。

これらの情報はkubectlでも確認することができます。以下のように実行してみましょう。

$ kubectl api-resources --api-group="multitenancy.example.com"
NAME      SHORTNAMES   APIGROUP                   NAMESPACED   KIND
tenants                multitenancy.example.com   false        Tenant

APIサーバーに問い合わせて取得した情報をもとにREST APIのパスが解決できました。 最後はこのパスに対してリクエストを発行します。

Clientはこのような仕組みによって、標準リソースとカスタムリソースを同じように扱うことができ、型安全で簡単に利用できるクライアントを実現しています。

results matching ""

    No results matching ""