この連載が書籍になりました!『RancherによるKubernetes活用完全ガイド

Rancherコードリーディング入門(3/3)

2020年2月19日(水)
西脇 雄基
前回に続き、紙面の都合で「RancherによるKubernetes活用完全ガイド」に掲載されなかったパートをご紹介します。

前回の記事では、Rancher Agent(pkg/agent)とRancher API(pkg/api)がどのように実装されているのか紹介しました。

今回の記事では、Rancherコードリーディング入門の最後として、Rancher Controller(pkg/controllers)に関するコードをどのように読み進めればよいか紹介します。

3回目:Rancher Controller、Rancherの周辺レポジトリについて紹介します
  • rancher/rancherレポジトリの歩き方(3/3)
    • Rancherのアプリケーションコード
      • pkg/controllers
  • rancherレポジトリ以外の重要なレポジトリについて
    • rancher/types
    • rancher/rke
    • rancher/kontainer-engine
    • rancher/norman

本連載について

本連載では、コードリーディング入門の連載3回目として、Rancher Controller(pkg/controllers)について詳しく紹介していきます。

rancher/rancherレポジトリの歩き方(3/3)

Rancher Controllerについては、事前知識なしでいきなりコードの説明に入ると理解がかなり難しいと思います。そこでRancher Controllerについて「3種類に分けられるRancher Controller」と「Rancher ControllerのベースとなるNorman Generic Controller」のおさらいをしてから、コードの説明に入ります。

3種類に分けられるRancher Controller

Rancher Controllerは、必要に応じてRancherが起動しているKubernetes、Rancherで管理されているKubernetesの両方のリソース(カスタムリソース含む)を監視し、変更があった場合にはその内容に沿って、新しくKubernetesクラスタを構築したり、ノードを追加したり、新しくDeploymentリソースを作成したりします。

現在、40種類以上のControllerがRancher Server内に実装されています。

Rancher Controllerは、ライフサイクルや管理対象によって、下記の3種類に分類されます。

  • API Controllers
    • 複数台起動した時、Leaderを含むすべてのRancher Serverで起動される
    • 1台のコントローラで1つのRancher Serverの状態管理をするため、同一Rancher Server内で複数の同じコントローラは起動されない
    • Rancher API Serverの設定や、Rancher API関連の処理に責任を持つ
    • Rancherが起動しているKubernetes上のリソースのみを監視する
  • Management Controllers
    • 複数台起動した時、Leaderに選ばれたRancher Serverでのみ起動される
    • 1台のコントローラでRancher Serverクラスタの状態管理をするため、クラスタ全体で同一のコントローラが複数起動されることはない
    • 特定のKubernetesクラスタに縛られないRancher Serverクラスタ(同じKubernetes上で起動する複数のRancher Serverを「クラスタ」と一括りで表現している)内の状態に責任を持つ
    • Rancherが起動しているKubernetes上のリソースのみを監視する
  • User Controllers
    • 複数台起動した時、各Kubernetesクラスタに対して、Owner Rancher Serverが決定され、Owner Rancher Serverのみで起動される
    • コントローラ1台は、1つのKubernetesクラスタの専属コントローラになるため、複数のKubernetesクラスタを管理している場合は、複数のコントローラが起動される
    • Rancherで管理している各Kubernetesクラスタの状態管理と、Kubernetesをより便利に使うための付加機能に責任を持つ
    • Rancherが起動しているKubernetes上のリソースとRancherが管理しているKubernetesのリソース、必要に応じて両方を監視する

API Controllersについては、前回の記事のRancher API(pkg/api)の解説のところで、すでに紹介しているため、今回は紹介しません。pkg/controllers配下のコードで実現されているManagement ControllersとUser Controllersについては、後ほど紹介します。

Rancherのコントローラ実装のベースとなるGeneric Controllerの仕組み

Rancherのすべてのコントローラは、NormanのGeneric Controllerをベースに実装されているため、Rancher Controller(pkg/controllers)の実装を読み解いていく前に、NormanのGeneric Controllerについて理解する必要があります。

Generic Controllerは、下記のような構造になっています。

Generic Controllerの構造

Generic Controllerの構造

SharedIndexInformerは、Kubernetesのclient-goライブラリ内の構造体です。Generic Controllerは、初期化時に渡されるKubernetes Clientを利用して、SharedIndexInformerを初期化しています。

SharedIndexInformerは、Index機能付きの共有キャッシュストレージで、初期化時に渡されたKubernetes Clientを利用して、取得できるKubernetesのリソースをオンメモリにキャッシュします。Index機能を持っているため、リソースが新しく検知された時に評価されるIndex Functionも複数登録することができます。Index Functionは、リソースを引数に取り、文字列の配列を返り値とする必要があり、この返り値でリソースを検索可能にします。

また、このSharedIndexInformerは、共有キャッシュストレージに対する追加、変更、削除のEventでトリガーされるFunctionを、cache.ResourceEventHandlerFuncs(k8s.io/client-go/tools/cache)として登録することができます。

Generic Controllerではcache.ResourceEventHandlerFuncsで、すべてのEventを同じくclient-goライブラリのRate Limit機能付きのQueueに伝搬しています。抽象的にEventと表現していますが、実際には変更のあったObjectが伝搬されています。

Generic Controllerは、このQueueからひたすらEvent(変更のあったObject)を取得し、そのEventに対して特定の処理を実施するためのHandlerを実行するgoroutineを複数起動しています。この時Handlerの実行に失敗すると、該当のEventは再度Queueに戻されます。

Generic Controllerを使うと、このKubernetes ClientとHandler部分だけを用途ごとに差し替えるだけで、簡単にKubernetesのコントローラを実装できます。

Rancherは、コントローラを通じて監視したいリソースタイプ(Node、Cluster、Catalog……)ごとに、1つのGeneric Controllerを作成しています。そのGeneric Controllerに対して、様々なHandlerを登録する形で、Rancherに必要なコントローラの実装をしています。

Rancherの実装では、1つのGeneric Controllerに複数の意味を持つHandlerが登録されることになるので、Generic Controllerとは言いつつも、コントローラとして考えると何を実現するためのコントローラなのか理解が難しくなります。そのため、「pkg/api/controllersの配下のディレクトリ名」「pkg/controllers/managementの配下のディレクトリ名」「pkg/controllers/userの配下のディレクトリ名」をコントローラとして捉える対象とみなすと、理解しやすいでしょう。

Generic ControllerとHandler

Generic ControllerとHandler

これらのディレクトリは、意味のある単位で分けられています。例えば、pkg/controllers/management/clusterprovisionerは、Kubernetesクラスタの構築に責任を持ち、NodeリソースのGeneric ControllerとClusterリソースのGeneric Controllerに、node-controllerという名前のHandlerを登録しています。

リスト1:pkg/controllers/management/clusterprovisioner/provisioner.go

144  func Register(management *config.ManagementContext) {
2            ~省略~
353
454          // Add handlers
555          p.Clusters.AddLifecycle("cluster-provisioner-controller", p)
656          management.Management.Nodes("").AddHandler("cluster-provisioner-controller", p.machineChanged)
757
8            ~省略~
971  }

上記内でp.ClustersはClusterリソースのGeneric Controllerで、management.Management.Nodes("")は、NodeリソースのGeneric Controllerになります。

さて、ここで「LifeCycle」というまだ解説していない概念が出てきました。LifeCycleは、HandlerのためのFrameworkのようなものです。

Handlerは単純なFunctionを登録しますが、LifeCycleの場合はCreate、Finalize、Updatedを実装した構造体を登録します。それぞれ、作成時に評価されるFunction、常に評価されるFunction、削除時に評価されるFunctionであり、リソースのLifeCycleを容易にHandlerとして実装することができます。

またNormanのレイヤでは、Create、Finalize、Updatedを実装した構造体ですが、RancherがLifeCycleの登録をするときは、Create、Remove、Updatedを実装した構造体になっています。内部的に、RancherがLifeCycleオブジェクトを変換しているので、ここの違いは特に意識する必要はありません。

AddLifecycleをすると、最終的にはlifecycle.NewObjectLifecycleAdapter(https://github.com/rancher/norman/tree/master/lifecycle)にCreate、Finalize、Updatedを実装したLifeCycleオブジェクトが渡され、NewObjectLifecycleAdapterの返り値であるobjectLifecycleAdapterのsync関数が、Handlerとして登録されます。

AddLifecycleの流れ

AddLifecycleの流れ

そのため、Generic Controllerで変更を検知した際に呼ばれるHandlerは、objectLifecycleAdapterのsync関数になります。どのように登録したLifeCycleオブジェクトが評価されるのか、どのように作成時、削除時などの判断をしているのか、もう少し詳しく見ていきます。

Handlerに登録されるobjectLifecycleAdapterのsync関数は、次のようになっています

リスト2:vendor/github.com/rancher/norman/lifecycle/object.go

0143 func (o *objectLifecycleAdapter) sync(key string, obj runtime.Object) error {
0244         if obj == nil {
0345                 return nil
0446         }
0547
0648         metadata, err := meta.Accessor(obj)
0749         if err != nil {
0850                 return err
0951         }
1052
1153         if cont, err := o.finalize(metadata, obj); err != nil || !cont {
1254                 return err
1355         }
1456
1557         if cont, err := o.create(metadata, obj); err != nil || !cont {
1658                 return err
1759         }
1860
1961         copyObj := obj.DeepCopyObject()
2062         newObj, err := o.lifecycle.Updated(copyObj)
2163         if newObj != nil {
2264                 o.update(metadata.GetName(), obj, newObj)
2365         }
2466         return err
2567 }

sync関数の中で、o.finalize、o.create、o.updateを呼び出しています。これらの関数を1つずつ紹介していきます。最初に評価されるのは、o.finalize関数です。

リスト3:o.finalize関数

0176 func (o *objectLifecycleAdapter) finalize(metadata metav1.Object, obj runtime.Object) (bool, error) {
0277         // Check finalize
0378         if metadata.GetDeletionTimestamp() == nil {
0479                 return true, nil
0580         }
0681
0782         if !slice.ContainsString(metadata.GetFinalizers(), o.constructFinalizerKey()) {
0883                 return false, nil
0984         }
1085
1186         copyObj := obj.DeepCopyObject()
1287         if newObj, err := o.lifecycle.Finalize(copyObj); err != nil {
1388                 if newObj != nil {
1489                         o.update(metadata.GetName(), obj, newObj)
1590                 }
1691                 return false, err
1792         } else if newObj != nil {
1893                 copyObj = newObj
1994         }
2095
2196         return false, o.removeFinalizer(o.constructFinalizerKey(), copyObj)
2297 }

この関数は、クリーンナップ処理に責任を持ち、リソースにDeletionTimestampが設定されている場合のみlifecycle.Finalizeを評価します。そしてlifecycle.Finalizeがerrを返さなかった場合のみ、Finalizerから自分のLifecycleに対応するFinalizerを削除します。

Finzlizer削除の流れ(o.finalize)

Finzlizer削除の流れ(o.finalize)

続いて評価される関数は、o.create関数です。

リスト4:o.create関数

01147 func (o *objectLifecycleAdapter) create(metadata metav1.Object, obj runtime.Object) (bool, error) {
02148         if o.isInitialized(metadata) {
03149                 return true, nil
04150         }
05151
06152         copyObj := obj.DeepCopyObject()
07153         copyObj, err := o.addFinalizer(copyObj)
08154         if err != nil {
09155                 return false, err
10156         }
11157
12158         if newObj, err := o.lifecycle.Create(copyObj); err != nil {
13159                 o.update(metadata.GetName(), obj, newObj)
14160                 return false, err
15161         } else if newObj != nil {
16162                 copyObj = newObj
17163         }
18164
19165         return false, o.setInitialized(copyObj)
20166 }

この関数は、Annotation(lifecycle.cattle.io/create.<LifeCycleの名前>)をべースに、すでにlifecycle.Create関数の処理に成功しているかどうかを調べます。もし、lifecycle.cattle.io/create.<LifeCycleの名前>: trueのAnnotationが存在した場合、lifecycle.Create関数は評価されません。Annotationが見つからなかった場合はlifecycle.Create関数を実行し、リソースの初期化処理を実施します。

また、この関数は、リソースの削除前にlifecycle.Finalizeでリソースクリーンナップ処理が実施できるように、自分のLifeCycleに対応するFinalizer(controller.cattle.io/<LifeCycleの名前>)を追加します。

Finalizer追加(o.create)

Finalizer追加(o.create)

最後に評価されるのは、o.updateになります。これはsync関数から直接実行され、状況に関わらず常に実行されます。

o.updateの実行

o.updateの実行

LifeCycleを利用すると、ここまで紹介したように、Finalizerや初期化処理のロジックを意識せずHandlerを実装することができます。

Rancherは、このLifeCycleとHandlerをうまく組み合わせて利用しています。一度だけ成功するまで実行したい初期化処理、リソース削除前に必ず実施したいクリーンナップ処理がある場合はLifeCycle、シンプルなロジックの場合はHandlerと使い分けをしています。

このようにRancherは、NormanのGeneric Controllerという仕組みをうまく使うことで、rancher/rancher側のコードでは、ビジネスロジックの実装に集中できるようにしています。

LINE株式会社

Verda室 Verdaプラットフォーム開発室 マネージャ兼Senior Software Engineer

LINE株式会社にてOpenStackとManaged Kubernetes Clusterの開発/運営を担当しているVerdaプラットフォーム開発チームリード。大規模なクラスターの開発者およびオペレーターとして3年以上OpenStackに取り組んでいる。2018年からはRancherを利用したManaged Kubernetes Serviceの開発/運用も開始。

現在は両プロジェクトを担当し、安定したプライベートクラウドプラットフォームの提供に務める。

連載バックナンバー

クラウド技術解説
第11回

Rancherコードリーディング入門(3/3)

2020/2/19
前回に続き、紙面の都合で「RancherによるKubernetes活用完全ガイド」に掲載されなかったパートをご紹介します。
クラウド技術解説
第10回

Rancherコードリーディング入門(2/3)

2019/12/25
前回に続き、紙面の都合で「RancherによるKubernetes活用完全ガイド」に掲載されなかったパートをご紹介します。
クラウド技術解説
第9回

Rancherコードリーディング入門(1/3)

2019/11/5
今回からは、紙面の都合で「RancherによるKubernetes活用完全ガイド」に掲載されなかったパートをご紹介します。

Think ITメルマガ会員登録受付中

Think ITでは、技術情報が詰まったメールマガジン「Think IT Weekly」の配信サービスを提供しています。メルマガ会員登録を済ませれば、メルマガだけでなく、さまざまな限定特典を入手できるようになります。

Think ITメルマガ会員のサービス内容を見る

他にもこの記事が読まれています