Skip to main content

Controller

在本节中,我们将正式介绍在Kubernetes语境下,控制器(Controller)究竟指的是什么。 在正式介绍Controller概念之前,我们需要铺垫一些辅助概念。

对象的状态与协调

早在Kubernetes对象小节,我们就已经介绍了Kubernetes是一个以Kubernetes API为中心的声明式资源管理系统。 用户向Kubernetes描述资源的期望状态,由Kubernetes系统控制以使资源达到期望状态。 在Kubernetes中,使Kubernetes对象的期望状态与实际状态保持一致的过程被称为协调(reconcile)

控制器(Controller)

在Kubernetes中,控制器(Controller)是指用于协调的无限循环23

当然,我们也可以从Kubernetes作为一个声明式系统的角度来解释控制器。 在声明式系统中,用户知道系统的期望状态,用户仅向系统提供期望状态的描述,由系统来确定从当前状态达到期望状态所需要的动作序列。 而系统中决定并执行动作序列的组件被称为控制器

另外,Kubernetes设计文档The Kubernetes Resource Model (KRM) 也对Controller的行为做了规范:

The intent is carried out by asynchronous controllers, which interact through the Kubernetes API. Controllers don’t access the state store, etcd, directly, and don’t communicate via private direct APIs.

...

Controllers continuously strive to make the observed state match the desired state, and report back their status to the apiserver asynchronously.

意思是说控制器只能通过与Kubernetes API交互来实施协调动作,而不能直接访问状态集群的存储(etcd)或者其他私有的服务。

Kubernetes集群内置了一些原生资源的控制器,例如Job控制器等。 当用户声明一个job资源后,Job控制器本身并不创建pod资源来执行任务,它而是通过Kubernetes API来创建pod资源来运行任务4,并同时不断将job对象的状态"汇报"给Kubernetes API以写入etcd中。 可见,Job控制器的所有行为都只与Kubernetes交互。

tip

由于资源控制器只能与Kubernetes API交互,我们也可以认为控制器是一种具有特殊行为的资源客户端。

控制器与子资源status

在之前子资源(subresource)小节中,我们引入了子资源的概念。 我们已经知道引入status子资源的动机是为了将对Kubernetes对象status字段(实际状态)的变更与spec字段(期望状态)的变更分开。 但当时由于缺乏Kubernetes控制器的概念,我们从并发控制的角度去解释分开的原由。在这里,我们可以补充更多的背景。Kubernetes作为一个声明式资源管理系统

  • 用户可以写入(或更改)资源的spec(期望状态),但不应更改资源的status字段;
  • 控制器可以写入(或更改)status字段(实际状态),但不应更改资源的spec字段。

通过资源API创建或者更改资源时,kube-apiserver会自动忽略请求体中status字段中的内容;而通过子资源statusAPI更新资源状态时,kube-apiserver也会忽略请求体中status字段之外的更改。

使用RESTClient更新Kubernetes对象状态

我们现在已经知道控制器是一种具有特殊行为的Kubernetes客户端,它需要通过Kubernetes子资源APIstatus"汇报"Kubernetes对象的当前状态。幸运的是,client-go作为Kubernetes标准客户端库已经为我们封装了相关方法用于与子资源API交互。

我们先从client-go封装的资源客户端说起。对于那些是Kubernetes对象的资源类型,client-go的原生资源客户端专门封装了一个UpdateStatus()的方法用于更新对象的当前状态。 例如,我们以Pod客户端为例:

type PodInterface interface {
Create(ctx context.Context, pod *v1.Pod, opts metav1.CreateOptions) (*v1.Pod, error)
Update(ctx context.Context, pod *v1.Pod, opts metav1.UpdateOptions) (*v1.Pod, error)
UpdateStatus(ctx context.Context, pod *v1.Pod, opts metav1.UpdateOptions) (*v1.Pod, error)
Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error
Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1.Pod, error)
List(ctx context.Context, opts metav1.ListOptions) (*v1.PodList, error)
Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error)
Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (result *v1.Pod, err error)
// ...
}

我们在资源客户端小节中已经说明了资源客户端使用的是RESTClient组件。我们以pods资源客户端UpdateStatus()方法的实现为例:

func (c *pods) UpdateStatus(ctx context.Context, pod *v1.Pod, opts metav1.UpdateOptions) (result *v1.Pod, err error) {
result = &v1.Pod{}
err = c.client.Put().
Namespace(c.ns).
Resource("pods").
Name(pod.Name).
SubResource("status").
VersionedParams(&opts, scheme.ParameterCodec).
Body(pod).
Do(ctx).
Into(result)
return
}

从实现来看,Request类型已经为我们封装了SubResource()方法,使用方式也十分简单,只要填入相应的子资源名称就能与相应的子资源API交互。 在本例中,由于要通过子资源status更新对象的当前状态,只要在参数中填入"status"即可。🎈

通过探究原生pods资源客户端UpdateStatus()的实现,我们其实已经学会了如何使用RESTClient基础组件更新对象的当前状态——我们在编写自定义资源客户端时也需要使用同样的方法。

控制器模式

根据控制器的定义,我们可以通过一段伪代码描述一种最为简单基础的控制器实现:

for {
desired := getDesiredState()
current := getCurrentState()
makeChanges(desired, current)
}

Kubernetes各种控制器的实现本质上都是对此基本框架的优化和扩展。

被遗忘的监听(watch)机制

在上述的结构中,相比于通过轮询不断主动获取资源的状态, 一个明显可以优化的地方是我们可以被动地只在有对象状态发生变更的时候才触发协调动作。 原因也很简单,只有当对象状态发生变更的时候,才有可能与期望的状态发生偏离,因此我们只要在此时刻触发协调动作即可。

幸运的是,Kubernetes API支持持续监听(watch)资源的变更(包括新增修改以及删除事件)。 具体来说,在资源对应的API的基础上加上?watch=true的查询参数(query parameter)就可以对资源的变更进行监听。 例如:

GET /api/v1/namespaces/test/pods?watch=true
---
200 OK
Transfer-Encoding: chunked
Content-Type: application/json

{
"type": "ADDED",
"object": {"kind": "Pod", "apiVersion": "v1", "metadata": {"resourceVersion": "10596", ...}, ...}
}
{
"type": "MODIFIED",
"object": {"kind": "Pod", "apiVersion": "v1", "metadata": {"resourceVersion": "11020", ...}, ...}
}
...

这些变更事件以HTTP"流"5的形式返回。另外,从上述例子可以看出:每个变更事件包括两个部分:类型(type)以及资源本身(object)。

使用RESTClient监听资源

RESTClient中为我们封装了相关的方法用于对资源类型的监听。我们先来看看如何使用RESTClient来监听资源的一个例子:

// Assume restClient is initialized.
opts := metav1.ListOptions{}
opts.Watch = true
watcher, _ = restClient.
Get().
Namespace("default").
Resource("pods").
VersionedParams(&opts, scheme.ParameterCodec).
Watch()

for event := range watcher.ResultChan() {
fmt.Printf("Event type: %v\n", event.Type)
fmt.Printf("Pod name: %v\n", event.Object.(*v1.Pod).Name)
}
  • 首先我们需要将metav1.ListOptions对象的Watch成员设置为truemetav1.ListOptions对象将最终会被scheme.ParameterCodec编码成请求URL中的?watch=true部分。
  • Watch()方法返回的一个是StreamWatcher对象。StreamWatcher对象不断将基于返回体中的数据块解码成WatchEvent对象6,并写入channel中(也就是它的ResultChan成员)。
  • 我们通过迭代ResultChan()就可以"源源不断"的从中获取资源的变更事件。

下图展示使用RESTClient监听资源时的过程:

Namespace()Resource()Name()watch()RESTClientRequestGet()GET /apis/{group}/{version}/namespaces/{namespace}/{resourcetype}/{name}?{watch=true}VersionedParams(){versionedAPIPathStreamWatcherkube-apiserverwatch...RequestRESTClientresponse bodyserializer©Xudong Wang🤖️🎈

下图展示了StreamWatcher将HTTP返回的数据块转化为事件类型发送到channel的过程:

...decodetype Event struct { // ...}channelStreamWatcher©Xudong Wang🤖️🎈requestresponsedata chunk

至此,我们已经学会使用RESTClient监听资源的变更。🎈

通过监听资源的变更事件,我们可以将上述控制器基础版伪代码优化为:

for event := range eventChannel {
desired := getDesiredState()
current := getCurrentState()
makeChanges(desired, current)
}

我们再稍稍补上一点细节:根据控制器的定义,控制器本身要有检索某种(或者多种7)资源类型的期望状态与实际状态的能力。 一种简单直接的方式是通过Kubernetes API来获取此刻的资源的集合:

for event := range eventChannel {

resources := getResourceListFromK8sAPI()

for resource := range resources {
makeChanges(resource.desired, resource.current)
}

}

然而这其实并不是一种好的办法。 一方面,通过Kubernetes API检索资源集合本身并不高效8。另一方面,在控制器中,协调是控制器中需要频繁发生的动作。

那么在控制器中,我们该如何高效地获取资源集合呢? 我们不妨换一种思路,Kubernetes API的watch机制不仅仅可以让我们有监听资源变更的能力,我们理论上是也可以利用watch机制本身在本地维护一套资源当前的最新副本——资源最新的状态其实等同于该资源最新的事件。

从watch机制到Informer

幸运的是在client-go中,官方开发者已经为我们实现了基于watch机制的本地资源缓存机制——Informer组件。它设计的初衷旨在让开发者尽量避免直接通过Kubernetes API检索资源集合从而达到减轻Kubernetes服务端压力。 当然,它也完美契合了我们对于控制器的需求:

  1. 资源变更事件的通知——以优化协调动作的触发时机。
  2. Kubernetes资源的本地缓存——让我们拥有一个高效检索Kubernetes资源集合的方式。

  1. 这个定义其实是整合了官方文档中两个对控制器的描述:

    • Controllers

      In robotics and automation, a control loop is a non-terminating loop that regulates the state of a system.

      In Kubernetes, controllers are control loops that watch the state of your cluster, then make or request changes where needed. Each controller tries to move the current cluster state closer to the desired state.

    • Writing Controllers

      A Kubernetes controller is an active reconciliation process. That is, it watches some object for the world's desired state, and it watches the world's actual state, too. Then, it sends instructions to try and make the world's current state be more like the desired state.

  2. Kubernetes控制器其实借用了自动化中closed-loop controller这个概念。在自动化里,closed-loop controller使用反馈来控制动态系统的状态或输出。它们在本质上是相通的。

  3. 在Kubernetes系统中,真正创建pod资源的动作由kubelet组件完成。

  4. 所谓HTTP"流"指的是HTTP 1.1的分块传输编码HTTP/1.1 Chunked Transfer Coding)。 kube-apiserver会在返回的头(header)中 设置Transfer-Encodingchunked,表示采用分块传输编码,客户端每读完一个数据块(即资源的变更事件)后,会继续读取或等待下一个数据块,直到客户端主动终止连接或者读到长度为0的数据分块为止。 每个数据块由两部分组成,第一部分是以十六进制形式表示的分块长度,后面紧跟着 \r\n以告知客户端分块的大小。第二部分是分块数据本身,后面也是 \r\n, 终止块则是一个长度为0的分块。

  5. WatchEvent由于是Kubernetes API返回体的解码后的Go类型,所以根据定义,它仍然是kind,并且属于kind的第三种类。

  6. 对于复杂的控制器可能需要同时管理pods, servicesconfigmaps等多种资源类型。

  7. Kubernetes API Concepts

    Retrieving large results sets in chunks

    On large clusters, retrieving the collection of some resource types may result in very large responses that can impact the server and client. For instance, a cluster may have tens of thousands of Pods, each of which is equivalent to roughly 2 KiB of encoded JSON. Retrieving all pods across all namespaces may result in a very large response (10-20MB) and consume a large amount of server resources.