Manager
Managerは、 複数のコントローラーを管理し、リーダー選出機能やメトリクスやヘルスチェックサーバーなどの機能を提供します。
すでにこれまでManagerのいくつかの機能を紹介してきましたが、他にもたくさんの便利な機能を持ってるのでここで紹介していきます。
Leader Election
カスタムコントローラーの可用性を向上させたい場合、Deploymentの機能を利用してカスタムコントローラーのPodを複数個立ち上げます。 しかし、Reconcile処理が同じリソースに対して何らかの処理を実行した場合、競合が発生してしまいます。
そこで、Managerはリーダー選出機能を提供しています。 これにより複数のプロセスの中から1つだけリーダーを選出し、リーダーに選ばれたプロセスだけがReconcile処理を実行できるようになります。
リーダー選出の利用方法は、NewManager
のオプションのLeaderElection
にtrueを指定し、LeaderElectionID
にリーダー選出用のIDを指定するだけです。
リーダー選出は、同じLeaderElectionID
を指定したプロセスの中から1つだけリーダーを選ぶという挙動になります。
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsServerOptions,
WebhookServer: webhookServer,
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,
})
それでは、config/manager/manager.yamlのreplicas
フィールドを2に変更して、MarkdownViewコントローラーをデプロイしてみましょう。
デプロイされた2つのPodのログを表示させてみると、リーダーに選出された方のPodだけがReconcile処理をおこなっている様子が確認できます。 リーダーに選出されたPodを終了させると、もう片方のPodにリーダーが切り替わる様子を確認できます。
なお、Admission Webhook処理は競合の心配がないため、リーダーではないプロセスも呼び出されます。
Runnable
カスタムコントローラーの実装において、Reconcile Loop以外にもgoroutineを立ち上げて定期的に実行したり、何らかのイベントを待ち受けたりしたい場合があります。 Managerではそのような処理を実現するための仕組みを提供しています。
例えばTopoLVMでは、定期的なメトリクスの収集やgRPCサーバーの起動用にRunnableを利用しています。
Runnable機能を利用するためには、Runnableインタフェースを実装した以下のようなコードを用意します。 ここでは30秒周期で、MarkdownViewControllerにReconcileを実行するように通知するRunnerを実装しています。
package controller
import (
"context"
"time"
"github.com/go-logr/logr"
viewv1 "github.com/zoetrope/markdown-view/api/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/event"
)
type Runner struct {
client client.Client
logger logr.Logger
interval time.Duration
channel chan<- event.TypedGenericEvent[*viewv1.MarkdownView]
}
func NewRunner(client client.Client, logger logr.Logger, interval time.Duration, channel chan<- event.TypedGenericEvent[*viewv1.MarkdownView]) *Runner {
return &Runner{
client: client,
logger: logger,
interval: interval,
channel: channel,
}
}
func (r Runner) Start(ctx context.Context) error {
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
r.notify(ctx)
}
}
}
func (r Runner) notify(ctx context.Context) {
var mdviewList viewv1.MarkdownViewList
err := r.client.List(ctx, &mdviewList)
if err != nil {
r.logger.Error(err, "failed to list MarkdownView")
return
}
for _, sts := range mdviewList.Items {
r.channel <- event.TypedGenericEvent[*viewv1.MarkdownView]{
Object: sts.DeepCopy(),
}
}
}
func (r Runner) NeedLeaderElection() bool {
return true
}
StartメソッドはManagerのStartを呼び出した際に、goroutineとして呼び出されます。
引数のcontext
によりManagerからの終了通知を受け取ることができます。
ch := make(chan event.TypedGenericEvent[*viewv1.MarkdownView])
runner := controller.NewRunner(mgr.GetClient(), mgr.GetLogger().WithName("Runner"), 30*time.Second, ch)
err = mgr.Add(runner)
if err != nil {
setupLog.Error(err, "unable to add runner")
os.Exit(1)
}
なお、このRunnerの処理は通常リーダーとして動作しているManagerでしか動きません。
リーダーでなくても常時動かしたい処理である場合、LeaderElectionRunnableインタフェースを実装し、
NeedLeaderElectionメソッドで false
を返すようにします。
また、MarkdownViewControllerでは、Runnerからの通知を受けてReconcileを実行するように、WatchesRawSource
を利用します。
return ctrl.NewControllerManagedBy(mgr).
For(&viewv1.MarkdownView{}).
Owns(&corev1.ConfigMap{}).
Owns(&appsv1.Deployment{}).
Owns(&corev1.Service{}).
WatchesRawSource(source.Channel(ch, &handler.TypedEnqueueRequestForObject[*viewv1.MarkdownView]{})).
Complete(r)
EventRecorder
カスタムリソースのStatusには、現在の状態が保存されています。 一方、これまでどのような処理が実施されてきたのかを記録したい場合、Kubernetesが提供するEventリソースを利用できます。
Managerはイベントを記録するための機能を提供しており、GetEventRecorderFor
でEventRecorderを取得できます。
以下のように、Reconcilerを初期化する際にEventRecorderを渡します。
ctx := ctrl.SetupSignalHandler()
if err = (&controller.MarkdownViewReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
Recorder: mgr.GetEventRecorderFor("markdownview-controller"),
}).SetupWithManager(ctx, mgr, ch); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "MarkdownView")
os.Exit(1)
}
Reconcilerではこれをフィールドとして持っておきます。
// MarkdownViewReconciler reconciles a MarkdownView object
type MarkdownViewReconciler struct {
client.Client
Scheme *runtime.Scheme
Recorder record.EventRecorder
}
Eventを記録するための関数として、Event
, Eventf
, AnnotatedEventf
などが用意されています。
ここでは、ステータス更新時に以下のようなイベントを記録することにしましょう。なお、イベントタイプにはEventTypeNormal
, EventTypeWarning
のみ指定できます。
if meta.IsStatusConditionFalse(prevStatus.Conditions, viewv1.TypeMarkdownViewDegraded) &&
meta.IsStatusConditionTrue(mdView.Status.Conditions, viewv1.TypeMarkdownViewDegraded) {
r.Recorder.Event(&mdView, corev1.EventTypeWarning, "Degraded", fmt.Sprintf("MarkdownView(%s:%s) degraded", mdView.Namespace, mdView.Name))
}
if meta.IsStatusConditionFalse(prevStatus.Conditions, viewv1.TypeMarkdownViewAvailable) &&
meta.IsStatusConditionTrue(mdView.Status.Conditions, viewv1.TypeMarkdownViewAvailable) {
r.Recorder.Event(&mdView, corev1.EventTypeNormal, "Available", fmt.Sprintf("MarkdownView(%s:%s) available", mdView.Namespace, mdView.Name))
}
このEventリソースは第1引数で指定したリソースに結びいており、そのリソースと同じnamespaceにEventリソースが作成されます。
カスタムコントローラーがEventリソースを作成できるように、以下のようなRBACのマーカーを追加し、make manifests
でマニフェストを更新しておきます。
// +kubebuilder:rbac:groups=core,resources=events,verbs=create;update;patch
コントローラーを実行し、作成したEventリソースを確認してみましょう。なお、Eventリソースはデフォルト設定では1時間経つと消えてしまいます。
$ kubectl get events -n default
LAST SEEN TYPE REASON OBJECT MESSAGE
4s Normal Available markdownview/markdownview-sample MarkdownView(default:markdownview-sample) available
HealthProbe
Managerには、ヘルスチェック用のAPIのエンドポイントを作成する機能が用意されています。
ヘルスチェック機能を利用するには、Managerの作成時にHealthProbeBindAddress
でエンドポイントのアドレスを指定します。
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsServerOptions,
WebhookServer: webhookServer,
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,
})
そして、AddHealthzCheck
とAddReadyzCheck
で、ハンドラの登録をおこないます。
デフォルトではhealthz.Ping
という何もしない関数を利用していますが、独自関数の登録も可能です。
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up health check")
os.Exit(1)
}
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
setupLog.Error(err, "unable to set up ready check")
os.Exit(1)
}
カスタムコントローラーのマニフェストでは、このヘルスチェックAPIをlivenessProbe
とreadinessProbe
として利用するように指定されています。
livenessProbe:
httpGet:
path: /healthz
port: 8081
initialDelaySeconds: 15
periodSeconds: 20
readinessProbe:
httpGet:
path: /readyz
port: 8081
initialDelaySeconds: 5
periodSeconds: 10
FieldIndexer
クライアントの使い方で紹介したように、複数のリソースを取得する際にラベルやnamespaceで絞り込むことが可能です。 しかし、特定のフィールドの値に応じてフィルタリングしたいこともあるでしょう。 controller-runtimeではインメモリにキャッシュしているリソースに対してインデックスを張る仕組みが用意されています。
インデックスを利用するためには事前にManagerのGetFieldIndexer()
を利用して、どのフィールドの値に基づいてインデックスを張るのかを指定します。
下記の例ではConfigMapリソースに対して、ownerReferences
に指定されているMarkdownViewリソースの名前でインデックスを作成しています。
const ownerControllerField = ".metadata.ownerReference.controller"
func indexByOwnerMarkdownView(obj client.Object) []string {
cm := obj.(*corev1.ConfigMap)
owner := metav1.GetControllerOf(cm)
if owner == nil {
return nil
}
if owner.APIVersion != viewv1.GroupVersion.String() || owner.Kind != "MarkdownView" {
return nil
}
return []string{owner.Name}
}
err := mgr.GetFieldIndexer().IndexField(ctx, &corev1.ConfigMap{}, ownerControllerField, indexByOwnerMarkdownView)
if err != nil {
return err
}
IndexField
の第3引数のフィールド名には、どのフィールドを利用してインデックスを張っているのかを示す文字列を指定します。
ここでは、.metadata.ownerReference.controller
という文字列を指定しています。
実際にインデックスに利用しているフィールドのパスと一致していなくても問題はないのですが、一致させると可読性がよくなるのでおすすめです。
なおインデックスはGVKごとに作成されるので、異なるタイプのリソース間でフィールド名が同じになっても問題ありません。 またnamespaceスコープのリソースの場合は、内部的にフィールド名にnamespace名を付与して管理しているので、明示的にフィールド名にnamespaceを含める必要はありません。 インデクサーが返す値はスライスになっていることから分かるように、複数の値にマッチするようなインデックスの構成も可能です。
上記のようなインデックスを作成しておくと、List()
を呼び出す際に特定のフィールドが指定した値と一致するリソースだけを取得できます。
例えば以下の例であれば、ownerReferenceに指定したMarkdownViewリソースがセットされているConfigMapだけを取得できます。
var cms corev1.ConfigMapList
err := r.List(ctx, &cms, client.MatchingFields(map[string]string{ownerControllerField: mdView.Name}))
if err != nil {
return err
}