自定义资源对象与控制器的实现 · K8S 实践 04

Hailiang Zhao | 2021/12/23

Categories: practice Tags: Kubernetes Custom-Resource-Definition


为了提高可扩展性,自 v1.7 以来,Kubernetes 引入了 CRD 机制(CustomResourceDefinition),它允许开发者自定义资源并将其注册到集群中,从而使得我们可以像操作 Pod 那样操作这个自定义的资源对象。 本文将以项目 Balancer(https://github.com/hliangzhao/balancer)为例介绍如何在 Kubernetes 中实现自定义资源对象和对应的控制器。Balancer 与其参考项目 Proxier(https://github.com/draveness/proxier)没有区别,但是为了方便本文讲述,笔者对其做了一些变更。主要的变化有:

接下来的阐述将以 https://github.com/hliangzhao/balancer 中的代码为依据。读者可以 clone 此项目,对照着源码阅读本文。

1 前置知识

1.1 Kubernetes 中的 API 组

当我们谈论 Kubernetes 中的 API 时,通常会涉及如下 4 个术语:组(groups)、版本(versions)、类型(kinds)和资源(resources)1

在任意集群中,可以通过 kubectl api-resources 查看本集群所支持的全体 API 组。以笔者的集群为例,除了 Kubernetes 核心 API 组,笔者的集群还安装了许多第三方提供的 API 组,如 application.kubesphere.io/v1alpha1,读者可以在之前的文章中找到消费这些 API 组的案例。这些第三方 API 组被称之为 Custom Resources(CR),通过 Kubernetes 官方提供的工具来实现 Custom Resources 的机制就是 CRD。本文的目的就是全面展示如何基于 CRD 机制实现我们自己的 CR。

k8s@ubuntu:~$ k api-resources
NAME                              SHORTNAMES   APIVERSION                             NAMESPACED   KIND
bindings                                       v1                                     true         Binding
componentstatuses                 cs           v1                                     false        ComponentStatus
configmaps                        cm           v1                                     true         ConfigMap
...
helmreleases                      hrls         application.kubesphere.io/v1alpha1     false        HelmRelease
helmrepos                         hrepo        application.kubesphere.io/v1alpha1     false        HelmRepo
controllerrevisions                            apps/v1                                true         ControllerRevision
daemonsets                        ds           apps/v1                                true         DaemonSet
deployments                       deploy       apps/v1                                true         Deployment
replicasets                       rs           apps/v1                                true         ReplicaSet
statefulsets                      sts          apps/v1                                true         StatefulSet
...

一个 API 组中的一个类型被称为 GroupVersionKind(GVK),API 组的一份资源被称为 GroupVersionResource(GVR)。 每个 GVK 对应 Golang 代码中的一个 Go type / struct。这意味着,我们只需要编写符合要求和规范的 Go struct,就可以通过 Kubernetes 提供的一系列工具将其转换为 GVK。 具体地,GVK 与 Go struct 之间的转换由 runtime.Scheme(src link) 来实现。当我们编写好 CR 的 Go struct 之后,需要调用形如 AddToScheme() 的代码将其注册到一个全局的 runtime.Scheme 实例中,然后就可以全权委托它来实现二者之间的转换了。

// https://github.com/kubernetes/apimachinery/blob/master/pkg/runtime/scheme.go

type Scheme struct {
	// versionMap allows one to figure out the go type of an object with
	// the given version and name.
	gvkToType map[schema.GroupVersionKind]reflect.Type

	// typeToGroupVersion allows one to find metadata for a given go object.
	// The reflect.Type we index by should *not* be a pointer.
	typeToGVK map[reflect.Type][]schema.GroupVersionKind
    ...
}

如果读者想了解更多有关 API 组的知识,可以阅读官方文档 Kubernetes API Conventions

1.2 Kubebuilder 与 code-generator

仅仅通过编写 Go struct 来实现 CR 还不够,我们还需要监听对 CR 实例的增删改查,然后执行相应的业务逻辑,这正是 controller 的职责。CRD 与 controller 的组合,被称为 Operator 模式。本文所述项目正是通过 Operator 模式实现,依赖于 Kubernetes 官方提供的工具 kubebuildercode-generator

Kubebuilder 使用 client-go 作为 Kubernetes 的客户端,封装和抽象了 controller-runtimecontroller-tools,用于快速构建 Operator。通过 kubebuilder 生成 Operator 的脚手架,使得我们不必关注其与 api-server 通信、请求的队列化等细节,只需要专注于业务逻辑的实现。具体地,kubebuilder 会为我们生成 controller 和 admission webhooks 的样本代码,以及最终用于部署的 manifests yaml,我们只需要将业务逻辑填充到合适的位置即可

基于 kubebuilder 实现一个 Operator 的流程如下:

  1. 创建一个新的工程目录并通过 kubebuilder init 命令将其初始化;
  2. kubebuilder create 命令创建 API 组和 CR,并编写对应的 Go struct;
  3. 使用 code-generator 为 CR 生成 clientset、informers、listers 和 deepcopy 等代码(笔者会在后面阐述这些代码的用处);
  4. 在 controller 中实现 协调循环(reconcile loop),让 CR 实例从当前状态向预期状态演进;
  5. 构建、测试与发布 Operator。

除了 kubebuilder,CoreOS 团队也提供了 Operator 开发框架 operator-sdk,感兴趣的读者可自行把玩。

Code-generator 是 Kubernetes 官方提供的代码生成工具,它提供了多个可执行文件,常用的有:

下图展示了 controller 的工作原理。一个 controller 有一个或多个 informer 来跟踪某一个资源。Informer 与 api-server 保持通讯,基于 watch 机制观察 api-server 中对所关心资源的记录。一旦资源发生事件变更,informer 就将相应 callbacks 存入 WorkQueue 并等待 Worker 将其取出运行——Worker 首先会比较 WorkQueue 中资源对象的实际状态与预期状态的差别,然后执行相应的业务逻辑(例如,删除不再需要的资源、创建新的资源等),直到达成预期状态。业务逻辑的运行需要调用 clientset 提供的 CRUD 方法,由其代为向 api-server 发送执行请求。Api-server 完成执行后,执行结果会被写入 etcd,相应的记录也会被更新到 api-server 自身。更细致的描述请阅读文章 client-go under the hood

图 1 Controller 的工作原理。

综上所述,通过使用 kubebuilder 和 code-generator,我们只需要关注 controller 中具体业务逻辑的实现,而无需操心其与 api-server 通信、请求的队列化等细节问题。接下来,笔者将按照上述 5 个步骤深入解析 Balancer 及其 controller 的实现。

2 从零开始实现 Balancer

2.1 项目介绍

本文要实现的 CR 叫做 Balancer,其架构如图 2 所示。一个 Balancer 资源对象实例负责转发一种特定的请求,它由一个 front-end Service、一个 Deployment(控制着一个 nginx Pod)和数个 backend Services 所组成。Front-end Service 负责在前端接收请求,它所代理的正是这个由 Deployment 所控制的 nginx Pod。Nginx Pod 会根据内部实现的负载均衡策略,将请求转发给后端的某个特定的 backend Service,每个 backend Service 都是一个特定的外部 Pod 的代理。这些外部 Pod 是真正负责处理请求的 endpoints。Balancer 负载均衡的能力由这个 nginx Pod 实现。当我们为 Balancer 编写 controller 的时候,重点就是为这个 nginx Pod 生成正确的 nginx.conf 配置文件。

图 2 Balancer 的架构。

待项目构建完毕并成功部署,我们期望通过如下方式使用它——首先,通过 kubectl apply -f http-echo.yaml 创建三个 http-echo Pods,它们是被代理的外部 Pods(当然,你可以换成任意想要代理的 Pods):

# http-echo.yaml
apiVersion: v1
kind: Pod
metadata:
  name: echo-v1
  labels:
    app: test
    version: v1
    name: echo-v1
spec:
  containers:
    - name: echo
      image: hashicorp/http-echo
      command: ["/http-echo"]
      args: ["-text", "hello world v1"]
      ports:
        - containerPort: 5678
---
apiVersion: v1
kind: Pod
metadata:
  name: echo-v2
  labels:
    app: test
    version: v2
    name: echo-v2
spec:
  containers:
    - name: echo
      image: hashicorp/http-echo
      command: ["/http-echo"]
      args: ["-text", "hello world v2"]
      ports:
        - containerPort: 5678
---
apiVersion: v1
kind: Pod
metadata:
  name: echo-v3
  labels:
    app: test
    version: v3
    name: echo-v3
spec:
  containers:
    - name: echo
      image: hashicorp/http-echo
      command: ["/http-echo"]
      args: ["-text", "hello world v3"]
      ports:
        - containerPort: 5678

然后,通过 kubectl apply -f balancer-example.yaml 创建 Balancer 资源对象实例:

# balancer-exmaple.yaml
apiVersion: exposer.hliangzhao.io/v1alpha1
kind: Balancer
metadata:
  name: balancer-sample
spec:
  ports:
    # This is a front-end service for handling all input requests.
    # Thus, the targetPort is the port exposed by the target backend containers.
    - name: http
      protocol: TCP
      port: 80
      targetPort: 5678
  selector:
    # for selecting a group of related backends
    app: test
  backends:
    - name: v1
      weight: 40
      selector:
        # for selecting a specific backend
        version: v1
    - name: v2
      weight: 20
      selector:
        # for selecting a specific backend
        version: v2
    - name: v3
      weight: 40
      selector:
        # for selecting a specific backend
        version: v3

最后,我们只需要访问 Balancer 内部的 front-end Service 的 ClusterIP 和它所暴露的端口,请求就应当根据负载的权重配比被正确地转发到某个 http-echo Pod 上。

接下来,笔者将深入展示如何实现这个期望效果。

2.2 使用 kubebuilder 搭建手脚架

在开始以下步骤之前,首先要确保 kubebuilder 已经被正确安装:

(base) ➜  ~ brew list | grep ku
kubebuilder
kustomize

(base) ➜  ~ kubebuilder version
Version: main.version{KubeBuilderVersion:"3.2.0", KubernetesVendor:"unknown", GitCommit:"b7a730c84495122a14a0faff95e9e9615fffbfc5", BuildDate:"2021-10-29T16:10:09Z", GoOs:"darwin", GoArch:"amd64"}

如下面的命令所示,我们在 $GOPATH/src 下创建 Balancer 项目的根目录,然后使用 kubebuilder 初始化项目:

(base) ➜  hliangzhao echo $GOPATH
/Users/hliangzhao/Documents/Codes/pubgopath

# 将项目放置在 $GOPATH/src 目录下
# 相对 $GOPATH/src 而言,项目的路径为 github.com/hliangzhao/balancer,这样 code-generator 生成的代码会被放置在正确的位置
(base) ➜  hliangzhao pwd
/Users/hliangzhao/Documents/Codes/pubgopath/src/github.com/hliangzhao

# 创建项目根目录并使用 kubebuilder init 命令将其初始化
(base) ➜  hliangzhao mkdir -p balancer
(base) ➜  hliangzhao cd balancer
# 指定域为 hliangzhao.io
(base) ➜  balancer kubebuilder init --domain hliangzhao.io --owner "hliangzhao"
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
Get controller runtime:
$ go get sigs.k8s.io/controller-runtime@v0.10.0
Update dependencies:
$ go mod tidy
Next: define a resource with:
$ kubebuilder create api

初始化之后,一系列配置和代码框架已生成:

(base) ➜  balancer tree
.
├── Dockerfile                                  # 用于构建 CR 的 controller-manager 镜像
├── Makefile                                    # 负责项目的构建、测试、代码生成和部署
├── PROJECT                                     # 存放本项目元数据
├── config                                      # 在集群中启动本项目所需的各类资源清单文件
│   ├── default
│   │   ├── kustomization.yaml
│   │   ├── manager_auth_proxy_patch.yaml
│   │   └── manager_config_patch.yaml
│   ├── manager
│   │   ├── controller_manager_config.yaml
│   │   ├── kustomization.yaml
│   │   └── manager.yaml
│   ├── prometheus
│   │   ├── kustomization.yaml
│   │   └── monitor.yaml
│   └── rbac
│       ├── auth_proxy_client_clusterrole.yaml
│       ├── auth_proxy_role.yaml
│       ├── auth_proxy_role_binding.yaml
│       ├── auth_proxy_service.yaml
│       ├── kustomization.yaml
│       ├── leader_election_role.yaml
│       ├── leader_election_role_binding.yaml
│       ├── role_binding.yaml
│       └── service_account.yaml
├── go.mod
├── go.sum
├── hack                                        # 存放脚本(例如,调用 code-generator 生成代码、安装一个本地集群等)
│   └── boilerplate.go.txt                      # 生成代码的头注释
└── main.go                                     # 程序入口

6 directories, 24 files

根目录下的 Makefile 为我们在开发、构建和部署阶段提供了一系列命令:

(base) ➜  balancer git:(dev) ✗ make help

Usage:
  make <target>

General
  help             Display this help.

Development
  manifests        Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects.
  generate         Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.
  fmt              Run go fmt against code.
  vet              Run go vet against code.
  test             Run tests.

Build
  build            Build manager binary.
  run              Run a controller from your host.
  docker-build     Build docker image with the manager.
  docker-push      Push docker image with the manager.

Deployment
  install          Install CRDs into the K8s cluster specified in ~/.kube/config.
  uninstall        Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
  deploy           Deploy controller to the K8s cluster specified in ~/.kube/config.
  undeploy         Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion.
  controller-gen   Download controller-gen locally if necessary.
  kustomize        Download kustomize locally if necessary.
  envtest          Download envtest-setup locally if necessary.

在开发阶段,每次更新 CR 和 controller 的实现之后,都应当执行相应的 make 指令更新生成的代码和 manifests yaml。

接下来,我们通过 kubebuilder create api 命令创建类型 Balancer,隶属于 api 组 exposer,版本为 v1alpha1。由此,Balancer 所属的 API 组为 exposer.hliangzhao.io/v1alpha1

(base) ➜  balancer kubebuilder create api --group exposer --version v1alpha1 --kind Balancer
Create Resource [y/n]
y
Create Controller [y/n]
y
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
api/v1alpha1/balancer_types.go
controllers/balancer_controller.go
Update dependencies:
$ go mod tidy
Running make:
$ make generate
go: creating new go.mod: module tmp
Downloading sigs.k8s.io/controller-tools/cmd/controller-gen@v0.7.0
go get: installing executables with 'go get' in module mode is deprecated.
	To adjust and download dependencies of the current module, use 'go get -d'.
	To install using requirements of the current module, use 'go install'.
	To install ignoring the current module, use 'go install' with a version,
	like 'go install example.com/cmd@latest'.
	For more information, see https://golang.org/doc/go-get-install-deprecation
	or run 'go help get' or 'go help install'.
go get: added github.com/fatih/color v1.12.0
...
go get: added sigs.k8s.io/yaml v1.2.0
/Users/hliangzhao/Documents/Codes/pubgopath/src/github.com/hliangzhao/balancer/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
Next: implement your new API and generate the manifests (e.g. CRDs,CRs) with:
$ make manifests

此时项目内的文件布局更新为:

(base) ➜  balancer tree
.
├── Dockerfile
├── Makefile
├── PROJECT
├── api                                         # api 模版代码所在目录
│   └── v1alpha1
│       ├── balancer_types.go                   # 修改本文件从而自定义类型 Balancer
│       ├── groupversion_info.go
│       └── zz_generated.deepcopy.go
├── bin
│   └── controller-gen                          # 当运行 make manifests 和 make generate 时,真正负责生成的程序
├── config                                      # 在集群中启动本项目的各类资源清单文件
│   ├── crd                                     # 当运行 make install 的时候,会 apply 本目录下的资源清单文件,在集群中安装本 CRD
│   │   ├── kustomization.yaml
│   │   ├── kustomizeconfig.yaml
│   │   └── patches
│   │       ├── cainjection_in_balancers.yaml
│   │       └── webhook_in_balancers.yaml
│   ├── default                                 # 当运行 make deploy 的时候,会 apply 本目录下的资源清单文件,将 CRD 的 controller 部署在集群中
│   │   ├── kustomization.yaml
│   │   ├── manager_auth_proxy_patch.yaml
│   │   └── manager_config_patch.yaml
│   ├── manager                                 # controller-manager 的配置与部署
│   │   ├── controller_manager_config.yaml
│   │   ├── kustomization.yaml
│   │   └── manager.yaml
│   ├── prometheus                              # 与 controller-manager 绑定的监视器的配置与部署
│   │   ├── kustomization.yaml
│   │   └── monitor.yaml
│   ├── rbac                                    # 与本 CRD 在集群中运行相关的权限管理
│   │   ├── auth_proxy_client_clusterrole.yaml
│   │   ├── auth_proxy_role.yaml
│   │   ├── auth_proxy_role_binding.yaml
│   │   ├── auth_proxy_service.yaml
│   │   ├── balancer_editor_role.yaml
│   │   ├── balancer_viewer_role.yaml
│   │   ├── kustomization.yaml
│   │   ├── leader_election_role.yaml
│   │   ├── leader_election_role_binding.yaml
│   │   ├── role_binding.yaml
│   │   └── service_account.yaml
│   └── samples                                 # 创建 CRD 对象实例的样例资源清单文件
│       └── exposer_v1alpha1_balancer.yaml
├── controllers                                 # 修改本目录下的代码以实现 controller 的具体业务逻辑
│   ├── balancer_controller.go
│   └── suite_test.go
├── go.mod
├── go.sum
├── hack
│   └── boilerplate.go.txt
└── main.go

13 directories, 37 files

2.3 编写 Balancer 的 Go Struct

我们在 附录与补遗 · K8S 实践 03 一文中说过,大多数资源对象都应当具有如下 5 个一级属性:apiVersionkindmetadataspecstatus。因此,Balancer 作为一个 Go struct,其内容如下:

// https://github.com/hliangzhao/balancer/blob/main/pkg/apis/balancer/v1alpha1/balancer_types.go

// Balancer is the Schema for the balancers API
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// +genclient
// +k8s:openapi-gen=true
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
type Balancer struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   BalancerSpec   `json:"spec,omitempty"`
	Status BalancerStatus `json:"status,omitempty"`
}

上方的注释(被称作 “tag”)用于暗示 code-generator 为 Balancer type 生成 deepcopy、clientset 和 openapi 代码,同时告知 kubebuilder 本 type 是一个类型(kind),并且拥有一个有状态子资源。读者可以阅读 Kubernetes Deep Dive: Code Generation for CustomResources 获取更多关于 tag 的内容。此外,结构体中的每一个字段都需要打上 json tag,因为 Go struct 要经过序列化之后才可以和 GVK 进行转换。

BalancerSpecBalancerStatus 仍然是 Go struct,我们先看前者:

// https://github.com/hliangzhao/balancer/blob/main/pkg/apis/balancer/v1alpha1/balancer_types.go

type Protocol string
type Port int32

// BalancerSpec defines the desired state of Balancer
// +k8s:openapi-gen=true
type BalancerSpec struct {
	// +kubebuilder:validation:MinItems=1
	Backends []BackendSpec `json:"backends"`

	Selector map[string]string `json:"selector,omitempty"`

	Ports []BalancerPort `json:"ports"`
}

// BackendSpec defines the desired status of endpoints of Balancer
// +k8s:openapi-gen=true
type BackendSpec struct {
	// +kubebuilder:validation:MinLength=1
	Name string `json:"name"`

	// +kubebuilder:validation:Minimum=1
	Weight int32 `json:"weight"`

	Selector map[string]string `json:"selector,omitempty"`
}

// BalancerPort contains the endpoints and exposed ports.
// +k8s:openapi-gen=true
type BalancerPort struct {
	// The name of this port within the balancer. This must be a DNS_LABEL.
	// All ports within a ServiceSpec must have unique names. This maps to
	// the 'Name' field in EndpointPort objects.
	// Optional if only one BalancerPort is defined on this service.
	// +required
	Name string `json:"name,omitempty"`

	// +optional
	Protocol Protocol `json:"protocol,omitempty"`

	// the port that will be exposed by the balancer
	Port Port `json:"port"`

	// the port that used by the container
	// +optional
	TargetPort intstr.IntOrString `json:"targetPort,omitempty"`
}

可以发现,BalancerSpec 由一组 BackendSpec、用于选择这些 backends 的 Selector 和一组 BalancerPort 所组成 4BalancerPort.Port 是 front-end Service 所暴露的端口,用于转发全体请求,BalancerPort.TargetPort 则是外部 endpoints 所暴露的端口。由此,Balancer 便可充当外部 endpoints 的一个代理。BackendSpec 定义了一个 backend Service 的名称和权重。BackendSpec.SelectorBalancerSpec.Selector 的基础上,从一堆外部 Pods 中选择一个特定的 Pod,并将自己接受到的请求转发给它。我们还可以看到,上方的代码中某些字段前有形如 +kubebuilder:validation:Minimum=1 这样的注释,这被称为 CRD validation。这是为了告知 kubebuilder 在为本类型生成 manifests yaml 的时候按照注释描述对本字段进行检查。读者可以阅读 kubebuilder CRD validation 查看所有可用的 CRD validation。

BalancerStatus 则描述了我们希望 controller 或其他用户能够获得的信息——对于 Balancer,我们主要关心当前处于有效状态和无效状态的 backend Services 的个数。

// https://github.com/hliangzhao/balancer/blob/main/pkg/apis/balancer/v1alpha1/balancer_types.go

// BalancerStatus defines the observed state of Balancer
// +k8s:openapi-gen=true
type BalancerStatus struct {
	// +optional
	ActiveBackendsNum int32 `json:"activeBackendsNum,omitempty"`

	// +optional
	ObsoleteBackendsNum int32 `json:"obsoleteBackendsNum,omitempty"`
}

默认情况下,kubebuilder 还会为我们生成 CR list:

// https://github.com/hliangzhao/balancer/blob/main/pkg/apis/balancer/v1alpha1/balancer_types.go

// BalancerList contains a list of Balancer
// +kubebuilder:object:root=true
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// +k8s:openapi-gen=true
type BalancerList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`

	Items []Balancer `json:"items"`
}

完成 balancer_types.go 的编写之后,我们需要运行 make generatemake manifests 更新 zz_generated.deepcopy.goconfig 目录下的资源清单文件。和 balancer_types.go 处于同一路径下的,有一个名为 zz_generated.deepcopy.go(src link) 的文件和一个名为 groupversion_info.go(src link) 的文件。前者给出了所有标记过的 Go struct 的深拷贝方法的实现,由 kubebuilder 调用 controller-gen 生成;而对于后者,我们看看里面写了什么:

// https://github.com/hliangzhao/balancer/blob/main/pkg/apis/balancer/v1alpha1/groupversion_info.go

var (
	// SchemeGroupVersion is group version used to register these objects
	SchemeGroupVersion = schema.GroupVersion{Group: "exposer.hliangzhao.io", Version: "v1alpha1"}

	// SchemeBuilder is used to add go types to the GroupVersionKind scheme
	SchemeBuilder = &scheme.Builder{GroupVersion: SchemeGroupVersion}

	// AddToScheme adds the types in this group-version to the given scheme.
	AddToScheme = SchemeBuilder.AddToScheme
)

// Kind takes an unqualified kind and returns a Group-qualified GroupKind.
func Kind(kind string) schema.GroupKind {
	return SchemeGroupVersion.WithKind(kind).GroupKind()
}

// Resource takes an unqualified resource and returns a Group-qualified GroupResource.
func Resource(resource string) schema.GroupResource {
	return SchemeGroupVersion.WithResource(resource).GroupResource()
}

在本文件中,我们定义了 API 组 exposer.hliangzhao.io/v1alpha1。然后,我们为该 API 组创建了一个 SchemeBuilder 和一个叫做 AddToScheme 的函数引用。前者的 Register() 函数在 balancer_types.goinit() 函数 (src link) 中被调用,负责将类型 BalancerBalancerList 注册到本 API 组;后者用于将 API 组 exposer.hliangzhao.io/v1alpha1 添加到传入的 scheme 中,它在程序入口 main.goinit() 函数 (src link) 中被调用。

至此,Balancer 的 Go struct 编写完毕。

2.4 调用 code-generator 生成代码

依照前文描述的步骤,接下来我们用 code-generator 为编写好的 CR 生成代码。首先,我们略微调整一下项目的结构 5,然后在 hack 目录下添加一些调用 code-generator 的脚本。

  1. 创建目录 cmd,然后将程序入口 main.go 放在 cmd/manager 目录下。如果后续支持别的命令,也将其放置在 cmd 目录下;
  2. 创建目录 pkg,在其中依次创建 apiscontrollers 两个子目录。在 apis 中,以 resourceName/version 的形式放置一个特定版本的 CR;在 controllers 中,以 resourceName 的形式放置对应 CR 的 controller 实现。这样布局是为了方便后续扩展更多 API 组和 controller。
(base) ➜  balancer git:(dev) ✗ tree
.
├── Dockerfile
├── Makefile
├── PROJECT
├── bin
│   └── controller-gen
├── cmd
│   └── manager
│       └── main.go
├── config
│   └── ...
├── go.mod
├── go.sum
├── hack                                        # 添加一些脚本用于调用 code-generator
│   ├── boilerplate.go.txt
│   ├── generate-groups.sh
│   ├── generate-internal-groups.sh
│   ├── tools.go                                # 用于指挥 go mod 把 code-generator 纳入项目依赖
│   ├── update-codegen.sh
│   ├── update-gofmt.sh
│   └── verify-codegen.sh
└── pkg
    ├── apis
    │   └── balancer
    │       └── v1alpha1                        # 从 api/v1alpha1 调整为 pkg/apis/balancer/v1alpha1
    │           ├── balancer_types.go
    │           ├── doc.go                      # 本文件放置了全局 tag,用于指导 code-generator 的工作
    │           ├── groupversion_info.go
    │           ├── zz_generated.deepcopy.go
    └── controllers
        └── balancer
            ├── balancer_controller.go          # 放到 pkg/controllers/balancer 目录下
            └── suite_test.go

17 directories, 50 files

如上所示,我们往 hack 目录中添加了一些文件。其中 generate-groups.shgenerate-internal-groups.sh 来自 code-generator 官方仓库 https://github.com/kubernetes/code-generator,前者实现的功能是后者的子集。这两个脚本的内容是下载 client-gen、lister-gen、informer-gen 等可执行文件到 $GOPATH/bin,然后根据传入的参数为指定的 api 在指定路径生成指定的代码。以 generate-internal-groups.sh 为例,它的使用方式为:

(base) ➜  balancer git:(dev) ✗ ./hack/generate-internal-groups.sh --help
Usage: generate-internal-groups.sh <generators> <output-package> <int-apis-package> <ext-apis-package> <groups-versions> ...

    <generators>        the generators comma separated to run (deepcopy,defaulter,conversion,client,lister,informer,openapi) or "all".
    <output-package>    the output package name (e.g. github.com/example/project/pkg/generated).
    <int-apis-package>  the internal types dir (e.g. github.com/example/project/pkg/apis).
    <ext-apis-package>  the external types dir (e.g. github.com/example/project/pkg/apis or github.com/example/apis).
    <groups-versions>   the groups and their versions in the format "groupA:v1,v2 groupB:v1 groupC:v2", relative
                    to <api-package>.
    ...                 arbitrary flags passed to all generator binaries.

Examples:
    generate-internal-groups.sh all                           github.com/example/project/pkg/client github.com/example/project/pkg/apis github.com/example/project/pkg/apis "foo:v1 bar:v1alpha1,v1beta1"
    generate-internal-groups.sh deepcopy,defaulter,conversion github.com/example/project/pkg/client github.com/example/project/pkg/apis github.com/example/project/apis     "foo:v1 bar:v1alpha1,v1beta1"

本项目调用 generate-groups.shgenerate-internal-groups.sh 的脚本被放置在了 update-codegen.sh 中,其中调用语句为:

(base) ➜  balancer git:(dev) ✗ cat ./hack/update-codegen.sh
#!/bin/bash
...
bash ${SCRIPT_ROOT}/hack/generate-internal-groups.sh "all" \
github.com/hliangzhao/balancer/pkg/client github.com/hliangzhao/balancer/pkg/apis github.com/hliangzhao/balancer/pkg/apis \
"balancer:v1alpha1" \
--go-header-file ${SCRIPT_ROOT}/hack/boilerplate.go.txt

这意味着,我们将调用全体 *-gen 工具为位于 github.com/hliangzhao/balancer/pkg/apis 的全部 api(实际上只有 1 个,也就是 balancer/v1alpha1/balancer_types.go)生成代码 6,然后将生成的文件放置在 github.com/hliangzhao/balancer/pkg/client 目录下。现在,我们执行 update-codegen.sh 脚本:

(base) ➜  balancer git:(dev) ✗ ./hack/update-codegen.sh
./hack
For install generated codes at right position, the project root should be GOPATH/src.
Or you need to manually copy the generated code to the right place.
go get: installing executables with 'go get' in module mode is deprecated.
        To adjust and download dependencies of the current module, use 'go get -d'.
        To install using requirements of the current module, use 'go install'.
        To install ignoring the current module, use 'go install' with a version,
        like 'go install example.com/cmd@latest'.
        For more information, see https://golang.org/doc/go-get-install-deprecation
        or run 'go help get' or 'go help install'.
Generating deepcopy funcs
Generating defaulters
Generating conversions
Generating clientset for balancer:v1alpha1 at github.com/hliangzhao/balancer/pkg/client/clientset
Generating listers for balancer:v1alpha1 at github.com/hliangzhao/balancer/pkg/client/listers
Generating informers for balancer:v1alpha1 at github.com/hliangzhao/balancer/pkg/client/informers
Generating OpenAPI definitions for balancer:v1alpha1 at github.com/hliangzhao/balancer/pkg/client/openapi
API rule violation: list_type_missing,github.com/hliangzhao/balancer/pkg/apis/balancer/v1alpha1,BalancerSpec,Backends
API rule violation: list_type_missing,github.com/hliangzhao/balancer/pkg/apis/balancer/v1alpha1,BalancerSpec,Ports
...
done

完成之后,会在 pkg 路径下多出来如下代码:

(base) ➜  pkg git:(dev) ✗ tree
.
├── apis
│   └── balancer
│       └── v1alpha1
│           ├── annotations.go              # 由我所添加
│           ├── balancer_types.go
│           ├── doc.go
│           ├── groupversion_info.go
│           ├── labels.go                   # 由我所添加
│           ├── zz_generated.deepcopy.go
│           └── zz_generated.defaults.go    # 生成的代码
├── client                                  # 生成的代码
│   ├── clientset
│   │   ├── internalversion                 # internalversion 和 versioned 目录内的布局完全一致。本项目不包含任何 internal api,因此本路径下的程序代码文件没有实质内容,可以删除
│   │   │   ├── clientset.go
│   │   │   ├── doc.go
│   │   │   ├── fake
│   │   │   │   ├── clientset_generated.go
│   │   │   │   ├── doc.go
│   │   │   │   └── register.go
│   │   │   ├── scheme
│   │   │   │   ├── doc.go
│   │   │   │   └── register.go
│   │   │   └── typed
│   │   │       └── balancer
│   │   │           └── internalversion
│   │   │               ├── balancer_client.go
│   │   │               ├── doc.go
│   │   │               ├── fake
│   │   │               │   ├── doc.go
│   │   │               │   └── fake_balancer_client.go
│   │   │               └── generated_expansion.go
│   │   └── versioned
│   │       ├── clientset.go
│   │       ├── doc.go
│   │       ├── fake
│   │       │   ├── clientset_generated.go
│   │       │   ├── doc.go
│   │       │   └── register.go
│   │       ├── scheme
│   │       │   ├── doc.go
│   │       │   └── register.go
│   │       └── typed
│   │           └── balancer
│   │               └── v1alpha1
│   │                   ├── balancer.go
│   │                   ├── balancer_client.go
│   │                   ├── doc.go
│   │                   ├── fake
│   │                   │   ├── doc.go
│   │                   │   ├── fake_balancer.go
│   │                   │   └── fake_balancer_client.go
│   │                   └── generated_expansion.go
│   ├── informers
│   │   └── externalversions
│   │       ├── balancer
│   │       │   ├── interface.go
│   │       │   └── v1alpha1
│   │       │       ├── balancer.go
│   │       │       └── interface.go
│   │       ├── factory.go
│   │       ├── generic.go
│   │       └── internalinterfaces
│   │           └── factory_interfaces.go
│   ├── listers
│   │   └── balancer
│   │       └── v1alpha1
│   │           ├── balancer.go
│   │           └── expansion_generated.go
│   └── openapi
│       └── zz_generated.openapi.go
└── controllers
    └── balancer
        ├── balancer_controller.go
        └── suite_test.go

31 directories, 52 files

注意,kubebuildercode-generator 都可以生成 zz_generated.deepcopy.go。当我们运行 make generate 的时候,会调用 bin/controller-gen 重新生成一遍,这和 deepcopy-gen 生成的代码没有区别。

(base) ➜  balancer make generate
/Users/hliangzhao/Documents/Codes/pubgopath/src/github.com/hliangzhao/balancer/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."

最后需要说明的是,由于我们是基于 kubebuilder 进行开发的,而 kubebuilder 封装了 controller-runtime 库,因此在 Balancer 项目中,我们并不会直接调用生成的 informers。不依赖任何框架、手动实现图 1 所述过程的样例参见 Kubernetes 官方仓库sample-controller

2.5 实现 Balancer 的控制器

控制器是 Kubernetes 的核心,也是任何 Operator 的核心。 控制器的工作是确保对于任何给定的对象,世界的实际状态(包括集群状态,以及潜在的外部状态,如 kubelet 的运行容器或云提供商的负载均衡器)与资源对象实例中所描述的期望状态相匹配。每个控制器专注于一个根类型(对于本项目就是 Balancer 类型),但可能会与其他类型交互。这个过程被称作协调(reconciling)

2.5.1 Manager、Controller 与 Reconciler

Controller 由 controller-manager 创建并管理。对于二者的关系,我们在 附录与补遗 · K8S 实践 03 曾给出过解释——manager 管理 controller,controller 管理具体的资源。这意味着,我们在完成 controller 代码的编写之后,需要创建 manager 并将 controller 添加到该 manager 中。我们先来看 controller 如何编写。

// https://github.com/kubernetes-sigs/controller-runtime/blob/master/pkg/controller/controller.go

// Controller implements a Kubernetes API.  A Controller manages a work queue fed reconcile.Requests
// from source.Sources.  Work is performed through the reconcile.Reconciler for each enqueued item.
// Work typically is reads and writes Kubernetes objects to make the system state match the state specified
// in the object Spec.
type Controller interface {
	// Reconciler is called to reconcile an object by Namespace/Name
	reconcile.Reconciler

	// Watch takes events provided by a Source and uses the EventHandler to
	// enqueue reconcile.Requests in response to the events.
	//
	// Watch may be provided one or more Predicates to filter events before
	// they are given to the EventHandler.  Events will be passed to the
	// EventHandler if all provided Predicates evaluate to true.
	Watch(src source.Source, eventhandler handler.EventHandler, predicates ...predicate.Predicate) error

	// Start starts the controller.  Start blocks until the context is closed or a
	// controller has an error starting.
	Start(ctx context.Context) error

	// GetLogger returns this controller logger prefilled with basic information.
	GetLogger() logr.Logger
}

上方的代码给出了 controller 的定义。后面三个函数在 controller-runtime 中均已实现。我们需要做的,是为 Balancer 的 controller 实现一个 reconcile.Reconcilerreconcile.Reconciler 是一个接口,其定义为:

// https://github.com/kubernetes-sigs/controller-runtime/blob/master/pkg/reconcile/reconcile.go

/*
Reconciler implements a Kubernetes API for a specific Resource by Creating, Updating or Deleting Kubernetes
objects, or by making changes to systems external to the cluster (e.g. cloudproviders, github, etc).
reconcile implementations compare the state specified in an object by a user against the actual cluster state,
and then perform operations to make the actual cluster state reflect the state specified by the user.
Typically, reconcile is triggered by a Controller in response to cluster Events (e.g. Creating, Updating,
Deleting Kubernetes objects) or external Events (GitHub Webhooks, polling external sources, etc).
...
Reconciliation is level-based, meaning action isn't driven off changes in individual Events, but instead is
driven by actual cluster state read from the apiserver or a local cache.
For example if responding to a Pod Delete Event, the Request won't contain that a Pod was deleted,
instead the reconcile function observes this when reading the cluster state and seeing the Pod as missing.
*/
type Reconciler interface {
	// Reconciler performs a full reconciliation for the object referred to by the Request.
	// The Controller will requeue the Request to be processed again if an error is non-nil or
	// Result.Requeue is true, otherwise upon completion it will remove the work from the queue.
	Reconcile(context.Context, Request) (Result, error)
}

这意味着,我们需要实现一个 Reconcile() 函数,其入参为上下文 context.Context 和协调请求 reconcile.Request,前者用来在协程之间传递上下文信息,包括取消信号、超时时间、截止时间以及一些键值对等。对于后者,我们需要通过 clientset 对其解析,从而获取资源对象实例中所记录着的期望状态。在这个方法中,我们的主要工作是让整个集群(涉及 Balancer 资源的部分)向着期望状态演进

读者可以阅读 https://pkg.go.dev/sigs.k8s.io/controller-runtime/pkg 获取更多关于这三者的信息。

2.5.2 为 Balancer 类型实现 Reconciler

我们将 Balancer 的 Reconciler 定义如下:

// https://github.com/hliangzhao/balancer/blob/main/pkg/controllers/balancer/balancer_controller.go

// ReconcilerBalancer reconciles a Balancer instance. Reconciler is the core of a controller.
type ReconcilerBalancer struct {
	// client reads obj from the cache
	client client.Client
	scheme *runtime.Scheme
}

// newReconciler creates the ReconcilerBalancer with input controller-manager.
func newReconciler(manager manager.Manager) reconcile.Reconciler {
	return &ReconcilerBalancer{
		client: manager.GetClient(),
		scheme: manager.GetScheme(),
	}
}

最关键的 Reconcile() 函数的实现为:

// https://github.com/hliangzhao/balancer/blob/main/pkg/controllers/balancer/balancer_controller.go

var log = logf.Log.WithName("balancer-controller")

// NOTE: if we do not add the following tags, the ClusterRole manager-role (config/rbac/role.yaml) will not be created!
// +kubebuilder:rbac:groups=exposer.hliangzhao.io,resources=balancers,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=exposer.hliangzhao.io,resources=balancers/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=apps,resources=replicasets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;
// +kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=replicasets,verbs=get;list;watch;create;update;patch;delete

// Reconcile reads the status of the Balancer object and makes changes toward to Balancer.Spec.
// This func must be implemented to be a legal reconcile.Reconciler!
func (r *ReconcilerBalancer) Reconcile(context context.Context, request reconcile.Request) (reconcile.Result, error) {
	reqLogger := log.WithValues("Request.Namespace", request.Namespace, "Request.Name", request.Name)
	reqLogger.Info("Reconciling Balancer")

	// fetch the expected Balancer instance through the client
	balancer := &exposerv1alpha1.Balancer{}
	if err := r.client.Get(context, request.NamespacedName, balancer); err != nil {
		// balancer not exist
		if errors.IsNotFound(err) {
			// the namespaced name in request is not found, return empty result and requeue the request
			return reconcile.Result{}, nil
		}
	}

	// Founded. Update SVCs, deployments, etc. according to the expected Balancer.
	// If any error happens, the request would be requeue
	if err := r.syncFrontendService(balancer); err != nil {
		return reconcile.Result{}, err
	}
	if err := r.syncDeployment(balancer); err != nil {
		return reconcile.Result{}, nil
	}
	if err := r.syncBackendServices(balancer); err != nil {
		return reconcile.Result{}, nil
	}
	if err := r.syncBalancerStatus(balancer); err != nil {
		return reconcile.Result{}, nil
	}

	return reconcile.Result{}, nil
}

在上面的代码中,我们定义了结构体 ReconcilerBalancer,它实现了 Reconcile() 函数,因而它是一个 reconcile.Reconciler 接口。它包含两个字段:client.Client*runtime.Scheme。如前所述,client.Client 为我们提供了对 Balancer 资源对象的 CRUD 功能(当然,前提是 Balancer 类型已经在 controller-manager 的 scheme 中被注册),我们需要用它从 reconcile.Request 中解析出 Balancer 资源对象实例的期望状态。*runtime.Scheme 是 controller-manager 的 scheme 的一个地址引用,这个 scheme 中记录了被注册的 Balancer 类型。注册过程在程序入口 cmd/manager/main.goinit() 函数中实现,这一点笔者在前文提及过。

Reconcile() 函数上方我们定义了一系列的 kubebuilder tags。这是为了告诉 kubebuilder 为本 controller-manager 生成一个 ClusterRole,它具有对 API 组 exposer.hliangzhao.io 下的资源 balancer 的全部操作权限。这个 controller-manager 还拥有对 deployments、pods、services 等资源的操作权限。 更多关于 ClusterRole 的解释可以参见 附录与补遗 · K8S 实践 03

我们现在来看看 Reconcile() 函数是如何工作的。 首先,基于 ReconcilerBalancerclient 字段,我们从 request 中拿出了 Balancer 资源对象实例的期望状态并放到了一个新创建的 balancer 变量中。然后我们调用 4 个 sync 函数,以 balancer 为目标,将涉及的 services、deployment、configmaps 和 pods 资源向着目标中指示的期望状态演进。 如果一切正常,那么 Reconcile() 函数返回空结果 reconcile.Result{}。如果在同步的过程中出现了问题,那么这次请求将会被重新加入 WorkQueue(参见图 1)。

2.5.3 sync 函数分析

接下来,我们依次分析这 4 个 sync 函数是如何工作的。我们首先来看 syncFrontendService()syncDeployment()。前者用于更新 Balancer 资源对象实例的 front-end Service,后者用于更新 Deployment 所控制的 nginx 实例。

函数 syncFrontendService() 的实现如下:

// https://github.com/hliangzhao/balancer/blob/main/pkg/controllers/balancer/frontend_service.go

// NewFrontendService creates a new front-end Service for handling all requests incoming.
// All the incoming requests will be forwarded to backend services by the nginx instance.
func NewFrontendService(balancer *exposerv1alpha1.Balancer) (*corev1.Service, error) {
	var balancerPorts []corev1.ServicePort
	for _, port := range balancer.Spec.Ports {
		balancerPorts = append(balancerPorts, corev1.ServicePort{
			Name:     port.Name,
			Protocol: corev1.Protocol(port.Protocol),
			Port:     int32(port.Port),
		})
	}
	return &corev1.Service{
		...
		Spec: corev1.ServiceSpec{
			Selector: NewPodLabels(balancer),
			Type:     corev1.ServiceTypeClusterIP,
			Ports:    balancerPorts,
		},
	}, nil
}

// syncFrontendService sync the front-end service that created by balancer.
func (r *ReconcilerBalancer) syncFrontendService(balancer *exposerv1alpha1.Balancer) error {
	svc, err := NewFrontendService(balancer)
	if err != nil {
		return err
	}

	// set balancer as the controller owner-reference of svc
	if err := controllerutil.SetControllerReference(balancer, svc, r.scheme); err != nil {
		return err
	}

	foundSvc := &corev1.Service{}
	err = r.client.Get(context.Background(), types.NamespacedName{Namespace: svc.Namespace, Name: svc.Name}, foundSvc)
	if err != nil && errors.IsNotFound(err) {
		// corresponding service not found in the cluster, create it with the newest svc
		if err = r.client.Create(context.Background(), svc); err != nil {
			return err
		}
		log.Info("Sync Frontend Service", svc.Name, "created")
		return nil
	} else if err != nil {
		return err
	}

	// corresponding service found, update it with the newest svc
	foundSvc.Spec.Ports = svc.Spec.Ports
	foundSvc.Spec.Selector = svc.Spec.Selector
	if err = r.client.Update(context.Background(), foundSvc); err != nil {
		return err
	}
	log.Info("Sync Frontend Service", foundSvc.Name, "updated")
	return nil
}

如上方代码所示,在函数 syncFrontendService() 中,我们首先根据传入的 balancer 变量(注意,这是我们试图达到的期望状态)创建最新的 front-end Service 变量。随后,我们通过 r.client.Get() 在集群中寻找当前真实的 front-end Service 实例。

剩下三个 sync 函数也是按照上述流程编写的。唯一的区别是,要更新的具体字段有所不同。在函数 syncFrontendService() 中,我们要设定的是 Service.Spec.Ports 字段。可以看到,这个字段中的 Ports 变量来自 balancer.Spec.Ports。对应到图 2,有且仅有一个 Port,就是 80

我们接下来看函数 syncDeployment() 的实现:

// https://github.com/hliangzhao/balancer/blob/main/pkg/controllers/balancer/deployment.go

func (r *ReconcilerBalancer) syncDeployment(balancer *exposerv1alpha1.Balancer) error {
	// firstly, we sync configmap
	cm, err := r.syncConfigMap(balancer)
	if err != nil {
		return err
	}

	// now we sync deployment
	dp, err := NewDeployment(balancer)
	if err != nil {
		return err
	}
	annotations := map[string]string{
		exposerv1alpha1.ConfigMapHashKey: ConfigMapHash(cm),
	}
	// always use the newest annotations
	dp.Spec.Template.ObjectMeta.Annotations = annotations

	// set balancer as the controller owner-reference of dp
	if err = controllerutil.SetControllerReference(balancer, dp, r.scheme); err != nil {
		return err
	}

	foundDp := &appv1.Deployment{}
	err = r.client.Get(context.Background(), types.NamespacedName{Namespace: balancer.Namespace, Name: balancer.Name}, foundDp)
	if err != nil && errors.IsNotFound(err) {
		// corresponding dp not found in the cluster, create it with the newest dp
		if err = r.client.Create(context.Background(), dp); err != nil {
			return err
		}
		log.Info("Sync Deployment", dp.Name, "created")
		return nil
	} else if err != nil {
		return err
	}

	// corresponding dp found, update it with the newest dp
	foundDp.Spec.Template = dp.Spec.Template
	if err = r.client.Update(context.Background(), foundDp); err != nil {
		return err
	}
	log.Info("Sync Deployment", foundDp.Name, "updated")
	return nil
}

syncDeployment() 中,我们首先调用 syncConfigMap() 同步 ConfigMap 资源对象实例。回顾图 2,这个 Deployment 控制的是一个 nginx Pod,并且这个 Pod 挂载了一个 key 为 nginx.conf 的 ConfigMap。接下来的流程和 syncFrontendService() 并无二致。即,根据传入的 balancer 变量创建最新的 Deployment 变量,然后用这个变量在集群中更新或直接创建最新的 Deployment 资源对象实例。区别是,这里更新的字段是 Spec.Templates,它记录了 nginx Pod 的期望状态。

NewDeployment() 函数的实现如下:

// https://github.com/hliangzhao/balancer/blob/main/pkg/controllers/balancer/deployment.go

// NewDeployment creates a new deployment (which controls one nginx pod) for the Balancer.
func NewDeployment(balancer *exposerv1alpha1.Balancer) (*appv1.Deployment, error) {
	replicas := int32(1)
	labels := NewPodLabels(balancer)
	nginxContainer := corev1.Container{
		Name:  "nginx",
		Image: "nginx:1.15.9",
		Ports: []corev1.ContainerPort{{ContainerPort: 80}},
		VolumeMounts: []corev1.VolumeMount{
			{
				Name:      ConfigMapName(balancer),
				MountPath: "/etc/nginx",
				ReadOnly:  true,
			},
		},
	}
	nginxVolume := corev1.Volume{
		Name: ConfigMapName(balancer),
		VolumeSource: corev1.VolumeSource{ConfigMap: &corev1.ConfigMapVolumeSource{
			LocalObjectReference: corev1.LocalObjectReference{
				Name: ConfigMapName(balancer),
			},
		}},
	}
	return &appv1.Deployment{
		...
		Spec: appv1.DeploymentSpec{
			Replicas: &replicas,
			Selector: &metav1.LabelSelector{MatchLabels: labels},
			Template: corev1.PodTemplateSpec{
				ObjectMeta: metav1.ObjectMeta{
					Name:      DeploymentName(balancer),
					Namespace: balancer.Namespace,
					Labels:    labels,
				},
				Spec: corev1.PodSpec{
					Containers: []corev1.Container{nginxContainer},
					Volumes:    []corev1.Volume{nginxVolume},
				},
			},
		},
	}, nil
}

func DeploymentName(balancer *exposerv1alpha1.Balancer) string {
	return balancer.Name + "proxy"
}

这个函数就是在老老实实地根据 balancer 各个字段的信息创建出一个控制着单个 nginx Pod 的 Deployment 实例。

syncDeployment() 调用了 syncConfigMap()。后者的实现也很类似,此处不在展开。我们接下来重点看一下 nginx.conf 是如何生成的。

// https://github.com/hliangzhao/balancer/blob/main/pkg/controllers/balancer/nginx/nginx.go

// NewConfig generates the `nginx.conf` with the given Balancer instance.
func NewConfig(balancer *exposerv1alpha1.Balancer) string {
	var servers []server
	for _, balancerPort := range balancer.Spec.Ports {
		servers = append(servers, server{
			name:     balancerPort.Name,
			protocol: strings.ToLower(string(balancerPort.Protocol)),
			port:     int32(balancerPort.Port),
			upstream: fmt.Sprintf("upstream_%s", balancerPort.Name),
		})
	}

	var backends []backend
	for _, balancerBackend := range balancer.Spec.Backends {
		backends = append(backends, backend{
			name:   fmt.Sprintf("%s-%s-backend", balancer.Name, balancerBackend.Name),
			weight: balancerBackend.Weight,
		})
	}

	var upstreams []upstream
	for _, s := range servers {
		upstreams = append(upstreams, upstream{
			name:     s.upstream,
			backends: backends,
			port:     s.port,
		})
	}

	conf := ""
	conf += "events {\n"
	conf += "worker_connections 1024;\n"
	conf += "}\n"
	conf += "stream {\n"

	for _, s := range servers {
		conf += s.conf()
	}

	for _, us := range upstreams {
		conf += us.conf()
	}

	conf += "}\n"

	return conf
}

其中涉及的 Go struct 有 serverbackendupstream

// https://github.com/hliangzhao/balancer/blob/main/pkg/controllers/balancer/nginx/nginx.go

type server struct {
	name     string
	protocol string
	port     int32
	upstream string
}

func (s *server) conf() string {
	var protocol string
	if s.protocol == "udp" {
		protocol = "udp"
	}
	return fmt.Sprintf(`
server {
    listen %d %s;
    proxy_pass %s;
}
`, s.port, protocol, s.upstream)
}

type backend struct {
	name   string
	weight int32
}

type upstream struct {
	name     string
	backends []backend
	port     int32
}

func (us *upstream) conf() string {
	backendStr := ""
	for _, b := range us.backends {
		backendStr += fmt.Sprintf("server %s:%d weight=%d;\n", b.name, us.port, b.weight)
	}
	return fmt.Sprintf(`
upstream %s {
%s
}
`, us.name, backendStr)
}

NewConfig() 函数本质上是在根据传入的期望 balancer 变量生成一个配置了负载均衡nginx.conf 文件。生成的结果形如:

events {
    worker_connections 1024;
}
stream {
    server {
        listen 80 tcp;
        proxy_pass upstream_http;
    }
    upstream upstream_http {
        server example-balancer-v1-backend:80 weight=40;
        server example-balancer-v2-backend:80 weight=20;
        server example-balancer-v3-backend:80 weight=40;
    }
}

其中,listen 80 tcp 中的端口号 80 和协议 TCP 均来自 balancer.Spec.Ports,而 upstream 中定义的数个出口则来自 balancer.Spec.Backends。当 nginx Pod 的 /etc/nginx 目录挂载了这个 ConfigMap 之后,负载均衡就自动由 nginx 实现了。 为了方便字符串生成,我们额外定义了结构体 serverbackendupstream,它们各自负责生成上述字符串中的一部分,最终的结果由它们拼凑而来。

回到 Reconcile() 函数,我们现在还有 syncBalancerStatus()syncBackendServices() 尚未讲解。前者是根据期望 balancer 变量重新统计当前集群中处于正常状态和游离状态的 backend Services 的个数并更新 balancer.Status 字段;后者则是根据真实的统计结果和期望达到的状态之间的差异,把该创建的 backend Services 创建一下,把该删除的 backend Services 删除掉。读者可自行阅读 backend_servers.go(src link),此处不在展开。

至此,Reconcile() 函数讲解完毕。

2.5.4 Controller 的创建与注册

我们通过 addReconciler() 函数创建类型 Balancer 的 controller 并将其注册到传入的 manager 中。然后,我们需要调用 controller 的 Watch() 函数监听所关心资源的变更。读者可以回顾图 1 理解这个过程。

// https://github.com/hliangzhao/balancer/blob/main/pkg/controllers/balancer/balancer_controller.go

// addReconciler adds r to controller-manager.
func addReconciler(manager manager.Manager, r reconcile.Reconciler) error {
	// creates a balancer-controller registered in controller-manager
	c, err := controller.New("balancer-controller", manager, controller.Options{Reconciler: r})
	if err != nil {
		return err
	}

	// takes events provided by a Source and uses the EventHandler to enqueue reconcile.Requests in response to the events.
	if err = c.Watch(&source.Kind{Type: &exposerv1alpha1.Balancer{}}, &handler.EnqueueRequestForObject{}); err != nil {
		return err
	}
	// the changes of the configmap, pod, and svc which are created by balancer will also be enqueued
	if err = c.Watch(&source.Kind{Type: &corev1.ConfigMap{}}, &handler.EnqueueRequestForOwner{
		IsController: true,
		OwnerType:    &exposerv1alpha1.Balancer{}},
	); err != nil {
		return err
	}
	if err = c.Watch(&source.Kind{Type: &corev1.Pod{}}, &handler.EnqueueRequestForOwner{
		IsController: true,
		OwnerType:    &exposerv1alpha1.Balancer{}},
	); err != nil {
		return err
	}
	if err = c.Watch(&source.Kind{Type: &corev1.Service{}}, &handler.EnqueueRequestForOwner{
		IsController: true,
		OwnerType:    &exposerv1alpha1.Balancer{}},
	); err != nil {
		return err
	}

	return nil
}

// Add creates a newly registered balancer-controller to controller-manager.
func Add(manager manager.Manager) error {
	return addReconciler(manager, newReconciler(manager))
}

addReconciler() 被封装到一个 Add() 函数中供外部调用。在 controllers/add_all.go(src link)中,我们以工厂模式封装了 “将 controller 添加到 manager” 这个过程。虽然我们只有一个 controller,但不排除未来会添加新的 API 组和 controller。这种写法方便项目的扩展。代码文件 controllers/add_all.go(src link)公开了 AddToManager() 函数供程序入口 cmd/manager/main.go 调用。

至此,controller 部分讲解完毕。

2.6 实现程序入口

如前所述,每一组控制器都需要一个 runtime.Scheme,它提供了 GVK 和相应的 Go struct 之间的映射。我们创建一个全局的 scheme 变量,然后在 init() 函数中将 Balancer 注册到这个 scheme 变量中:

// https://github.com/hliangzhao/balancer/blob/main/cmd/manager/main.go

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

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

然后我们实例化了一个 manager,它记录着所有 controller 的运行情况。上面创建的 scheme 变量也将作为一个字段用于 manager 的创建:

// https://github.com/hliangzhao/balancer/blob/main/cmd/manager/main.go

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
    Scheme:                 scheme,
    MetricsBindAddress:     metricsAddr,
    Port:                   9443,
    HealthProbeBindAddress: probeAddr,
    LeaderElection:         enableLeaderElection,
    LeaderElectionID:       "24a4cdb8.hliangzhao.io",
})
if err != nil {
    setupLog.Error(err, "unable to start manager")
    os.Exit(1)
}

然后,我们调用 controllers/add_all.go(src link) 公开的 AddToManager() 函数把所有 controller 注册到这个 manager 实例中:

// https://github.com/hliangzhao/balancer/blob/main/cmd/manager/main.go

if err = controllers.AddToManager(mgr); err != nil {
    setupLog.Error(err, "unable to create controller", "controller", "Balancer")
    os.Exit(1)
}

最后,启动 manager 即可。

// https://github.com/hliangzhao/balancer/blob/main/cmd/manager/main.go

setupLog.Info("starting manager")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
    setupLog.Error(err, "problem running manager")
    os.Exit(1)
}

至此,Balancer 及其控制器的实现全部讲解完毕。

3 项目的构建与部署

接下来我们将阐述如何在本地或远程集群中启动本项目。我们先来看看 config 目录下的文件到底在做什么,当我们运行 make installmake deploy 的时候都会用到它。

3.1 资源清单文件解读

完成全部代码编写之后,我们需要运行 make generate && make manifests 更新 DeepCopy 代码和 manifests yaml 文件。 生成的资源清单文件有:

(base) ➜  balancer git:(dev) ✗ tree config
config
├── crd
│   ├── bases
│   │   └── exposer.hliangzhao.io_balancers.yaml
│   ├── kustomization.yaml
│   ├── kustomizeconfig.yaml
│   └── patches
│       ├── cainjection_in_balancers.yaml
│       └── webhook_in_balancers.yaml
├── default
│   ├── kustomization.yaml
│   ├── manager_auth_proxy_patch.yaml
│   └── manager_config_patch.yaml
├── manager
│   ├── controller_manager_config.yaml
│   ├── kustomization.yaml
│   └── manager.yaml
├── prometheus
│   ├── kustomization.yaml
│   └── monitor.yaml
├── rbac
│   ├── auth_proxy_client_clusterrole.yaml
│   ├── auth_proxy_role.yaml
│   ├── auth_proxy_role_binding.yaml
│   ├── auth_proxy_service.yaml
│   ├── balancer_editor_role.yaml
│   ├── balancer_viewer_role.yaml
│   ├── kustomization.yaml
│   ├── leader_election_role.yaml
│   ├── leader_election_role_binding.yaml
│   ├── role.yaml
│   ├── role_binding.yaml
│   └── service_account.yaml
└── samples
    ├── exposer_v1alpha1_balancer.yaml
    └── http-echo.yaml

8 directories, 27 files

config/default/kustomization.yaml(src link) 是指导应用程序 kustomize 在集群中安装 CRD 和部署 manager 的入口。其内容如下:

# https://github.com/hliangzhao/balancer/blob/main/config/default/kustomization.yaml

# Adds namespace to all resources.
namespace: balancer-system

# Value of this field is prepended to the
# names of all resources, e.g. a deployment named
# "wordpress" becomes "alices-wordpress".
# Note that it should also match with the prefix (text before '-') of the namespace
# field above.
namePrefix: balancer-

bases:
  - ../crd
  - ../rbac
  - ../manager
  - ../prometheus
  ...

patchesStrategicMerge:
  # Protect the /metrics endpoint by putting it behind auth.
  # If you want your controller-manager to expose the /metrics
  # endpoint w/o any authn/z, please comment the following line.
  - manager_auth_proxy_patch.yaml

  # Mount the controller config file for loading manager configurations
  # through a ComponentConfig type
  - manager_config_patch.yaml
  ...

当我们 apply 这个文件时,会在集群中创建 balancer-system 这个命名空间,然后 apply 如下 4 个目录中的全部资源的清单文件:crdrbacmanagerprometheus。 对于 config/default 目录下的剩余两个文件(manager_auth_proxy_patch.yamlmanager_config_patch.yaml),前者用于鉴别所有访问 controller-manager 的 metrics url(/metrics)的账户是否有访问权限(以 sidecar container 的形式进行鉴权);后者用于更新 controller-manager 的配置(配置文件以 ConfiMap 的形式被挂载)。

接下来,我们依次解释 crdrbacmanagerprometheus 这 4 个目录下的文件内容。

crd 目录给出了自定义资源对象 Balancer 的资源清单。 显然,crd/bases/exposer.hliangzhao.io_balancers.yaml(src link) 是最核心的 manifest yaml,它定义了 API 组 exposer.hliangzhao.io/v1alpha1 和类型 Balancer。当我们 apply 这个文件时,会在当前集群中安装 exposer.hliangzhao.io/v1alpha1 这个 API 组。 这是我们创建 balancer 资源对象的前提。 config/crd/patches 目录 (src link) 下的两个文件用于设定 webhook 和对应的 cert-manager。由于我们没有为 Balancer 编写 webhook,因此这两个文件是用不到的。

rbac 目录下的资源清单文件为 controller-manager 赋予特定的操作权限。具体地,所有的 rbac/*role.yamlrbac/*clusterrole.yaml 都是在定义 Role 和 ClusterRole,这些角色对不同类型的资源有特定的访问权限。rbac/service_account.yaml 文件定义了一个叫做 controller-manager 的 ServiceAccount,rbac/*role_binding.yaml 会将不同的角色和这个 ServiceAccount 绑定。更多关于 Role、ServiceAccount 和 RoleBinding 的细节参见 附录与补遗 · K8S 实践 03

manager 目录下的文件定义了如何启动 controller-manager 并更新它的配置。其中,最重要的是 manager/manager.yaml(src link),它将 controller-manager 以 Deployment 的形式创建,对应的容器镜像为 docker.io/hliangzhao97/balancer:latest,由我们通过 make docker-build && make docker-push 生成。manager/controller_manager_config.yaml(src link) 定义了 manager 的配置信息。

prometheus 目录给出了针对 controller-manager 的监控服务的配置。

需要说明的是,config 目录下的所有 kustomization.yaml 文件都是写给应用程序 kustomize 看的。Kustomize 作为一个定制 Kubernetes 配置的工具,可以用来组织和定制资源集合。我们可以简单地将其理解为一个操纵资源清单文件的生成和字段替换的工具。当我们运行 make kustomize 的时候,会将应用程序 kustomize 安装到 bin 目录。

3.2 项目的本地构建与测试

我们通过 make build 命令生成二进制文件 bin/manager

(base) ➜  balancer git:(dev) ✗ make build
/Users/hliangzhao/Documents/GitHub/balancer/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
go vet ./...
go build -o bin/manager cmd/manager/main.go

(base) ➜  balancer git:(dev) ✗ ll bin
total 232528
-rwxr-xr-x  1 hliangzhao  staff    19M Dec 21 12:04 controller-gen
-rwxr-xr-x  1 hliangzhao  staff    44M Dec 21 19:15 kustomize
-rwxr-xr-x  1 hliangzhao  staff    43M Dec 27 16:26 manager			# 生成的二进制文件
-rwxr-xr-x  1 hliangzhao  staff   7.3M Dec 21 19:15 setup-envtest

接下来,我们尝试在本机(macOS 10.15)上直接运行 manager。我们启动 Docker Desktop v4.0.1,它会同时启动一个单节点的 Kubernetes 集群,版本为 v1.21.4。

图 3 启动 Docker Desktop。

确保集群运作正常:

(base) ➜  balancer git:(dev) ✗ k get po -A
NAMESPACE     NAME                                     READY   STATUS    RESTARTS   AGE
kube-system   coredns-558bd4d5db-2vqth                 1/1     Running   10         91d
kube-system   coredns-558bd4d5db-7hl6g                 1/1     Running   10         91d
kube-system   etcd-docker-desktop                      1/1     Running   10         91d
kube-system   kube-apiserver-docker-desktop            1/1     Running   10         91d
kube-system   kube-controller-manager-docker-desktop   1/1     Running   10         91d
kube-system   kube-proxy-ftcxs                         1/1     Running   10         91d
kube-system   kube-scheduler-docker-desktop            1/1     Running   10         91d
kube-system   storage-provisioner                      1/1     Running   15         91d
kube-system   vpnkit-controller                        1/1     Running   86         91d

环境准备完毕后,我们运行 make install 将 CRD 安装到本集群中:

(base) ➜  balancer git:(dev) ✗ make install && k api-resources | grep exposer
/Users/hliangzhao/Documents/GitHub/balancer/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
/Users/hliangzhao/Documents/GitHub/balancer/bin/kustomize build config/crd | kubectl apply -f -
customresourcedefinition.apiextensions.k8s.io/balancers.exposer.hliangzhao.io created
balancers                                      exposer.hliangzhao.io/v1alpha1         true         Balancer

我们先创建 backend Pods:

(base) ➜  balancer git:(dev) ✗ k apply -f config/samples/http-echo.yaml
pod/echo-v1 created
pod/echo-v2 created
pod/echo-v3 created
(base) ➜  balancer git:(dev) ✗ k get po -o wide
NAME      READY   STATUS    RESTARTS   AGE   IP          NODE             NOMINATED NODE   READINESS GATES
echo-v1   1/1     Running   0          32s   10.1.0.78   docker-desktop   <none>           <none>
echo-v2   1/1     Running   0          32s   10.1.0.76   docker-desktop   <none>           <none>
echo-v3   1/1     Running   0          32s   10.1.0.77   docker-desktop   <none>           <none>

然后,我们创建 balancer 资源对象:

(base) ➜  balancer git:(dev) ✗ k apply -f config/samples/exposer_v1alpha1_balancer.yaml
balancer.exposer.hliangzhao.io/balancer-sample created
(base) ➜  balancer git:(dev) ✗ k get po
NAME      READY   STATUS    RESTARTS   AGE
echo-v1   1/1     Running   0          88s
echo-v2   1/1     Running   0          88s
echo-v3   1/1     Running   0          88s

可以发现,并没有相应的 Deployment 被创建出来。这是因为我们没有启动 Balancer 的 controller-manager。我们有 4 种方式启动它:

  1. 直接在 GoLand 中运行,run 或者 debug 均可。manager 启动后会自行获取 ~/.kube/config 和集群建立通信;
  2. 通过 make run 运行。这和上面是一样的,本质上是执行如下命令:go run ./cmd/manager/main.go
  3. 直接启动构建出来的二进制文件:./bin/manager
  4. 通过 make deploy 将其部署到集群中。当我们执行这条命令时,它会下载我们指定的 controller-manager 镜像,然后在集群中通过 customize build config/default | kubectl apply -f - 命令启动它。

因为我们目前尚在开发机进行开发和测试,因此我们直接在 GoLand 中以 debug 模式启动它。我们将断点打在如下位置 7,然后启动它:

图 4 打上断点用于 debug。

现在我们可以看到刚才的 apply 命令被响应了——我们现在有 3 个 backend Services 等待创建。

图 5 在断点处,我们需要创建 3 个 backend Services。

我们直接 Step Over,观察输出日志,可以发现,balancer 资源对象已经被顺利创建了。

图 6 日志输出。

我们也可以通过 kubectl get 确定这一点:

(base) ➜  balancer git:(dev) ✗ k get po -o wide
NAME                                    READY   STATUS    RESTARTS   AGE   IP          NODE             NOMINATED NODE   READINESS GATES
balancer-sampleproxy-665dc9ddb8-p6cm6   1/1     Running   7          11m   10.1.0.79   docker-desktop   <none>           <none>
echo-v1                                 1/1     Running   0          14m   10.1.0.78   docker-desktop   <none>           <none>
echo-v2                                 1/1     Running   0          14m   10.1.0.76   docker-desktop   <none>           <none>
echo-v3                                 1/1     Running   0          14m   10.1.0.77   docker-desktop   <none>           <none>
(base) ➜  balancer git:(dev) ✗ k get svc -o wide
NAME                         TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)   AGE     SELECTOR
balancer-sample              ClusterIP   10.107.42.41     <none>        80/TCP    11m     balancer.exposer.hliangzhao.io/balancer-name=balancer-sample
balancer-sample-v1-backend   ClusterIP   10.110.169.214   <none>        80/TCP    3m42s   app=test,version=v1
balancer-sample-v2-backend   ClusterIP   10.109.176.141   <none>        80/TCP    3m42s   app=test,version=v2
balancer-sample-v3-backend   ClusterIP   10.103.88.244    <none>        80/TCP    3m42s   app=test,version=v3
kubernetes                   ClusterIP   10.96.0.1        <none>        443/TCP   91d     <none>

以上测试是在 Docker Desktop 自带的单机版 Kubernetes 集群中进行的。笔者还有一个包含 11 个节点的集群,我们只需略微修改代码,在 cmd/manager/main.go 中指定正确的 kubeconfig 位置即可连接到这个集群,这里就不再展开。 下面我们直接将代码推送到远端节点,然后在远程节点上直接通过 make deploy 的方式部署 manager。

在此之前,我们先运行 make docker-buildmake docker-push 构建 manager 镜像并将其 push 到远端仓库:

(base) ➜  balancer git:(dev) ✗ make docker-build
/Users/hliangzhao/Documents/GitHub/balancer/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
/Users/hliangzhao/Documents/GitHub/balancer/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
go vet ./...
KUBEBUILDER_ASSETS="/Users/hliangzhao/Library/Application Support/io.kubebuilder.envtest/k8s/1.22.1-darwin-amd64" go test ./... -coverprofile cover.out
?       github.com/hliangzhao/balancer/cmd/manager      [no test files]
...
docker build -t docker.io/hliangzhao97/balancer:latest .
[+] Building 61.0s (17/17) FINISHED
 => [internal] load build definition from Dockerfile                                                                                                                  0.0s
 => => transferring dockerfile: 826B                                                                                                                                  0.0s
 => [internal] load .dockerignore                                                                                                                                     0.0s
 => => transferring context: 34B                                                                                                                                      0.0s
 => [internal] load metadata for gcr.io/distroless/static:nonroot                                                                                                     0.7s
 => [internal] load metadata for docker.io/library/golang:1.17                                                                                                        3.1s
 => [auth] library/golang:pull token for registry-1.docker.io                                                                                                         0.0s
 => [builder 1/8] FROM docker.io/library/golang:1.17@sha256:c72fa9afc50b3303e8044cf28fb358b48032a548e1825819420fd40155a131cb                                          0.0s
 => CACHED [stage-1 1/3] FROM gcr.io/distroless/static:nonroot@sha256:80c956fb0836a17a565c43a4026c9c80b2013c83bea09f74fa4da195a59b7a99                                0.0s
 => [internal] load build context                                                                                                                                     0.1s
 => => transferring context: 170.16kB                                                                                                                                 0.1s
 => CACHED [builder 2/8] WORKDIR /workspace                                                                                                                           0.0s
 => CACHED [builder 3/8] COPY go.mod go.mod                                                                                                                           0.0s
 => CACHED [builder 4/8] COPY go.sum go.sum                                                                                                                           0.0s
 => CACHED [builder 5/8] RUN go mod download                                                                                                                          0.0s
 => CACHED [builder 6/8] COPY cmd ./cmd                                                                                                                               0.0s
 => [builder 7/8] COPY pkg ./pkg                                                                                                                                      0.1s
 => [builder 8/8] RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o manager cmd/manager/main.go                                                               57.1s
 => [stage-1 2/3] COPY --from=builder /workspace/manager .                                                                                                            0.1s
 => exporting to image                                                                                                                                                0.2s
 => => exporting layers                                                                                                                                               0.2s
 => => writing image sha256:6f0eba09d582d06787d4cd3bd3d87ffd0a39c7cad58621f58ab02789406dc3d5                                                                          0.0s
 => => naming to docker.io/hliangzhao97/balancer:latest                                                                                                               0.0s

(base) ➜  balancer git:(dev) ✗ make docker-push
docker push docker.io/hliangzhao97/balancer:latest
The push refers to repository [docker.io/hliangzhao97/balancer]
f5eb2a2f2bdf: Pushed
5b1fa8e3e100: Layer already exists
latest: digest: sha256:b5461dd655bd9ae40f7d954a6520a99ef4d94072b5c32759db5e717779b9bb89 size: 739

这样我们在远端集群就可以直接使用 docker.io/hliangzhao97/balancer:latest 这个镜像了。读者也可以直接使用此镜像。

3.3 项目的远程部署

笔者拥有一个包含 11 个节点的集群,这些节点(私网 IP 地址为 192.168.23.160~192.168.23.170)相互之间可以免密登录,且均不具备公网 IP。 在本机通过 ssh 登录到节点 ubuntu 上获取各节点信息:

k8s@ubuntu:~$ k get no -o wide
NAME              STATUS   ROLES                  AGE   VERSION   INTERNAL-IP      EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION       CONTAINER-RUNTIME
ubuntu            Ready    control-plane,master   76d   v1.22.2   192.168.23.160   <none>        Ubuntu 18.04.5 LTS   4.15.0-112-generic   docker://20.10.9
worker-large-2    Ready    <none>                 76d   v1.22.2   192.168.23.161   <none>        Ubuntu 18.04.5 LTS   4.15.0-112-generic   docker://20.10.9
worker-large-3    Ready    <none>                 76d   v1.22.2   192.168.23.162   <none>        Ubuntu 18.04.5 LTS   4.15.0-112-generic   docker://20.10.9
worker-medium-1   Ready    <none>                 76d   v1.22.2   192.168.23.163   <none>        Ubuntu 18.04.5 LTS   4.15.0-112-generic   docker://20.10.9
worker-medium-2   Ready    <none>                 76d   v1.22.2   192.168.23.164   <none>        Ubuntu 18.04.5 LTS   4.15.0-112-generic   docker://20.10.9
worker-medium-3   Ready    <none>                 76d   v1.22.2   192.168.23.165   <none>        Ubuntu 18.04.5 LTS   4.15.0-112-generic   docker://20.10.9
worker-medium-4   Ready    <none>                 76d   v1.22.2   192.168.23.166   <none>        Ubuntu 18.04.5 LTS   4.15.0-112-generic   docker://20.10.9
worker-small-1    Ready    <none>                 76d   v1.22.2   192.168.23.167   <none>        Ubuntu 18.04.5 LTS   4.15.0-112-generic   docker://20.10.9
worker-small-2    Ready    <none>                 76d   v1.22.2   192.168.23.168   <none>        Ubuntu 18.04.5 LTS   4.15.0-112-generic   docker://20.10.9
worker-small-3    Ready    <none>                 76d   v1.22.2   192.168.23.169   <none>        Ubuntu 18.04.5 LTS   4.15.0-112-generic   docker://20.10.9
worker-small-4    Ready    <none>                 76d   v1.22.2   192.168.23.170   <none>        Ubuntu 18.04.5 LTS   4.15.0-112-generic   docker://20.10.9

我们首先将项目推送到远程节点 ubuntu 上:

k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ echo $GOPATH
/home/k8s/gopath
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ ll
total 176
drwxrwxr-x 9 k8s k8s  4096 Dec 27 20:07 ./
drwxrwxr-x 3 k8s k8s  4096 Dec 27 20:11 ../
-rw-r--r-- 1 k8s k8s   782 Dec 27 20:07 Dockerfile
-rw-r--r-- 1 k8s k8s   551 Dec 27 20:07 LICENSE
-rw-r--r-- 1 k8s k8s  4925 Dec 27 20:07 Makefile
-rw-r--r-- 1 k8s k8s   346 Dec 27 20:07 PROJECT
-rw-r--r-- 1 k8s k8s   590 Dec 27 20:07 README.md
drwxrwxr-x 2 k8s k8s  4096 Dec 27 20:07 bin/
drwxrwxr-x 3 k8s k8s  4096 Dec 27 20:07 cmd/
drwxrwxr-x 8 k8s k8s  4096 Dec 27 20:07 config/
-rw-r--r-- 1 k8s k8s 10937 Dec 27 20:07 cover.out
-rw-r--r-- 1 k8s k8s  5752 Dec 27 20:07 go.mod
-rw-r--r-- 1 k8s k8s 97659 Dec 27 20:07 go.sum
drwxrwxr-x 2 k8s k8s  4096 Dec 27 20:07 hack/
drwxrwxr-x 5 k8s k8s  4096 Dec 27 20:07 pkg/
drwxrwxr-x 4 k8s k8s  4096 Dec 27 20:07 test/
drwxrwxr-x 2 k8s k8s  4096 Dec 27 20:07 version/

注意,bin 目录下用到的所有二进制文件都应该通过相应的 make 指令重新生成 / 安装一遍(因为 OS 变了)。现在,我们运行 make deploy 将 balancer 的 controller-manager 部署在集群中:

k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ make deploy
/home/k8s/gopath/src/github.com/hliangzhao/balancer/bin/controller-gen rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
cd config/manager && /home/k8s/gopath/src/github.com/hliangzhao/balancer/bin/kustomize edit set image controller=docker.io/hliangzhao97/balancer:latest
/home/k8s/gopath/src/github.com/hliangzhao/balancer/bin/kustomize build config/default | kubectl apply -f -
namespace/balancer-system created
customresourcedefinition.apiextensions.k8s.io/balancers.exposer.hliangzhao.io created
serviceaccount/balancer-controller-manager created
role.rbac.authorization.k8s.io/balancer-leader-election-role created
clusterrole.rbac.authorization.k8s.io/balancer-manager-role created
clusterrole.rbac.authorization.k8s.io/balancer-metrics-reader created
clusterrole.rbac.authorization.k8s.io/balancer-proxy-role created
rolebinding.rbac.authorization.k8s.io/balancer-leader-election-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/balancer-manager-rolebinding created
clusterrolebinding.rbac.authorization.k8s.io/balancer-proxy-rolebinding created
configmap/balancer-manager-config created
service/balancer-controller-manager-metrics-service created
deployment.apps/balancer-controller-manager created

k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ k get po -o wide -n balancer-system
NAME                                           READY   STATUS    RESTARTS   AGE   IP            NODE             NOMINATED NODE   READINESS GATES
balancer-controller-manager-59c47f54c4-vchdm   1/1     Running   0          31s   10.244.7.87   worker-small-1   <none>           <none>

注意,当我们运行 make deploy 时,make 会自动运行 make install,这会把 CRD 和 controller-manager 都安装到集群中。

至此,balancer 的 CRD 和 controller-manager 部署完毕。现在我们创建相应的资源:

# 创建 backends
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ k apply -f config/samples/http-echo.yaml
pod/echo-v1 created
pod/echo-v2 created
pod/echo-v3 created

# 创建 balancer
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ k apply -f config/samples/exposer_v1alpha1_balancer.yaml
balancer.exposer.hliangzhao.io/balancer-sample created

# 查看资源是否已经被创建
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ k get svc -o wide
NAME                         TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)   AGE    SELECTOR
balancer-sample              ClusterIP   10.105.158.237   <none>        80/TCP    110s   balancer.exposer.hliangzhao.io/balancer-name=balancer-sample
balancer-sample-v1-backend   ClusterIP   10.108.177.216   <none>        80/TCP    110s   app=test,version=v1
balancer-sample-v2-backend   ClusterIP   10.99.224.189    <none>        80/TCP    110s   app=test,version=v2
balancer-sample-v3-backend   ClusterIP   10.97.164.171    <none>        80/TCP    110s   app=test,version=v3
kubernetes                   ClusterIP   10.96.0.1        <none>        443/TCP   76d    <none>
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ k get po -o wide
NAME                                      READY   STATUS    RESTARTS      AGE     IP             NODE              NOMINATED NODE   READINESS GATES
balancer-sampleproxy-6b8577b9dc-g7sgg     1/1     Running   0             115s    10.244.4.195   worker-medium-2   <none>           <none>
echo-v1                                   1/1     Running   0             2m11s   10.244.4.194   worker-medium-2   <none>           <none>
echo-v2                                   1/1     Running   0             2m11s   10.244.3.85    worker-medium-1   <none>           <none>
echo-v3                                   1/1     Running   0             2m10s   10.244.8.87    worker-small-2    <none>           <none>
nfs-client-provisioner-645fcf5574-75jvn   1/1     Running   1 (46d ago)   47d     10.244.2.184   worker-large-3    <none>           <none>

现在,我们访问 balancer-sampleproxy-6b8577b9dc-g7sgg 的 ClusterIP,或者它的 frontend Service balancer-sample 的 ClusterIP,请求应该被路由到某个 http-echo Pod 上:

k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.105.158.237
hello world v3
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.105.158.237
hello world v1
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.105.158.237
hello world v3
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.105.158.237
hello world v2
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.105.158.237
hello world v1
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.105.158.237
hello world v3
...
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.244.4.195
hello world v1
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.244.4.195
hello world v3
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.244.4.195
hello world v2
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.244.4.195
hello world v1
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.244.4.195
hello world v3
...
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.244.4.194:5678
hello world v1
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.244.3.85:5678
hello world v2
k8s@ubuntu:~/gopath/src/github.com/hliangzhao/balancer$ curl 10.244.8.87:5678
hello world v3
...

至此,本项目讲解完毕。

完结撒花!

参考

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接。您必须给出适当的署名,并标明是否对本文作了修改。


  1. 接下来我们提到 “组”、“版本”、“类型” 和“资源”这四个名词的时候,请和这 4 个术语严格对应起来。 ↩︎

  2. 对于 apiextensions.k8s.io/v1 这种写法,k8s.io 被称为域(domain),apiextensions 则是本域下的一个组。 ↩︎

  3. 有时,同一类型可能由多个资源返回。例如,Scale 类型是由所有 scale 子资源返回的,如 deployments/scalereplicasets/scale,这是类型 HorizontalPodAutoscaler 能与不同资源交互的原因。 ↩︎

  4. 虽然 BalancerSpec.Ports 被定义为切片,但大多数时候我们只需要一个 BalancerPort。以图 2 为例,我们只暴露了一个端口,也就是 80。 ↩︎

  5. 项目布局调整之后,PROJECT、Dockerfile 等文件中涉及 api 路径的内容也需要相应更改。 ↩︎

  6. 其实真正用到的只有 deepcopy、informers、listers 和 clientset。不过,全部都生成总是没有错的。 ↩︎

  7. 在比较早期的版本中,这个地方有 bug。读者可以阅读 Pull Request #7 查看 bug 和对应的修改方案。 ↩︎