kubernetes CRD 开发入门指南

CRD是什么?

CRD全称为Custom Resource Definition,是kubernetes提供的开放的扩展api方式。

下面是我的理解:

通常我们说的Operator指的是CRD + Controller。CRD也是kubernetes提供的一种资源类型,我们通过CRD向kubernetes注册自定义的资源类型。空有定义好的CRD没有任何作用,要让自定义的资源类型像kubernetes中的资源一样工作,需要开发一个Controller来控制、调度、实现该资源中定义的状态。而我们真正使用的则是CR(Custom Resource)。

举个例子

现在很多项目都大量使用到CRD, 为了能有更清楚的理解,下面以matrix项目为例:

[root@centos01 ~]# kubectl get crd | grep crd.cxwen.com
dns.crd.cxwen.com                                     2020-06-30T06:21:09Z
etcdclusters.crd.cxwen.com                            2020-06-30T06:21:09Z
masters.crd.cxwen.com                                 2020-06-30T06:21:09Z
matrices.crd.cxwen.com                                2020-06-30T06:21:09Z
networkplugins.crd.cxwen.com                          2020-06-30T06:21:09Z

可以看到matrix定义了许多的CRD。以masters.crd.cxwen.com为例,来看看CRD里面到底定义了哪些东西。

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: masters.crd.cxwen.com
spec:
  conversion:
    strategy: None
  group: crd.cxwen.com
  names:
    kind: Master
    listKind: MasterList
    plural: masters
    singular: master
  preserveUnknownFields: true
  scope: Namespaced
  versions:
  - additionalPrinterColumns:       # 定义kubectl get时打印出的字段
    - description: version
      jsonPath: .spec.version
      name: VERSION
      type: string
    - description: pod replicas
      jsonPath: .spec.replicas
      name: REPLICAS
      type: string
    - description: etcdcluster name
      jsonPath: .spec.etcdCluster
      name: ETCD
      type: string
    - description: expose type
      jsonPath: .spec.expose.method
      name: EXPOSETYPE
      type: string
    - description: expose node
      jsonPath: .spec.expose.node
      name: EXPOSENODE
      type: string
    - description: expose port
      jsonPath: .spec.expose.port
      name: EXPOSEPORT
      type: string
    - description: phase
      jsonPath: .status.phase
      name: PHASE
      type: string
    - jsonPath: .metadata.creationTimestamp
      name: AGE
      type: date
    name: v1
    schema:                       # 定义CRD的schema
      openAPIV3Schema:
        description: Master is the Schema for the masters API
        properties:
          apiVersion:
            type: string
          kind:
            type: string
          metadata:
            type: object
          spec:
            description: MasterSpec defines the desired state of Master
            properties:
              etcdCluster:
                type: string
              expose:
                properties:
                  method:
                    type: string
                  node:
                    items:
                      type: string
                    type: array
                  port:
                    type: string
                type: object
              imageRegistry:
                type: string
              imageRepo:
                properties:
                  apiserver:
                    type: string
                  controllerManager:
                    type: string
                  proxy:
                    type: string
                  scheduler:
                    type: string
                type: object
              replicas:
                type: integer
              version:
                description: Foo is an example field of Master. Edit Master_types.go
                  to remove/update
                type: string
            type: object
          status:
            description: MasterStatus defines the observed state of Master
            properties:
              adminKubeconfig:
                type: string
              exposeUrl:
                items:
                  type: string
                type: array
              phase:
                description: 'INSERT ADDITIONAL STATUS FIELD - define observed state
                  of cluster Important: Run "make" to regenerate code after modifying
                  this file'
                type: string
            type: object
        type: object
    served: true
    storage: true
    subresources:
      status: {}

CRD里面主要定义了两部分内容:

1、additionalPrinterColumns

顾名思意,additional Printer Columns就是额外的打印列的意思,即设置使用kubectl get命令去查看自定义资源时会打印哪些字段,例如:

[root@centos01 ~]# kubectl get master
NAME         VERSION    REPLICAS   ETCD         EXPOSETYPE   EXPOSENODE         EXPOSEPORT   PHASE   AGE
example-km   v1.15.12   1          example-ec   NodePort     [192.168.83.128]   31299        Ready   12m

这里就打印出了上面CRD中定义的字段。

2、schema

定义Custom Resource的模式或者说规范,这里面定义一个CR的各个属性的数据类型,CR一定要遵循CRD里面的定义才能创建成功。

有以下两种情况:

  • 属性是一个基本数据类型

type直接指定属性的类型,如apiserverstring类型:

apiserver:
  type: string
  • 属性是一个结构体类型

type值为object, properties中描述其它属性的类型,如imageRepo属性:

  imageRepo:
    type: object
    properties:
      apiserver:
        type: string
      controllerManager:
        type: string
      proxy:
        type: string
      scheduler:
        type: string

总结一下:

类型 说明
Operator CRD + Controller
CRD (Custom Resource Definition) 定义自定义资源的各种属性并向kubernetes中注册
CR (Custom Resource) 通过CRD定义好的真正可以在k8s中使用的资源,类似于pod,deployment这样的k8s中定义好的资源
Controller 监听CRD的CRUD事件并添加自定义业务逻辑,负责确保其追踪的资源对象的当前状态接近期望状态

如何开发?

了解了CRD的是什么,那如何来开发一个CRD呢?

从上文看起来CRD的定义文件这么长,似乎很复杂。不要怕,这些都可以用工具生成,不需要咱们手动编写的。正所谓工欲善其事,必先利其器。下面就来了解下CRD的开发利器: kubebuilder。有时间的也可以研究下kubebuilder的文档, 里面有详细的介绍。

使用kubebuilder构建CRD基本代码框架

环境安装

可以直接在机器上进行安装,或者使用构建好kubebuilder镜像运行一个容器在容器执行操作:推荐使用容器方式,方便很多。

机器直接安装

安装go环境

首先机器上得安装go环境,不会安装的可以看这个教程:Go语言环境安装。 如果因为墙的原因无法从go官网下载安装包,可以访问go语言中文网进行下载。

安装kubebuilder

接着需要在机器上安装kubebuilder, linux可以使用下面命令安装:

wget https://github.com/kubernetes-sigs/kubebuilder/releases/download/v2.3.1/kubebuilder_2.3.1_linux_amd64.tar.gz
tar -zxvf kubebuilder_2.3.1_linux_amd64.tar.gz
cp kubebuilder_2.3.1_linux_amd64/bin/kubebuilder /usr/local/bin/

其它版本和架构的机器安装方法一样,可以根据需要下载相应的安装包。

安装kustomize

kustomize是一个yaml渲染工具,kubebuilder依赖它进行yaml文件的渲染。

curl https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv3.6.1/kustomize_v3.6.1_linux_amd64.tar.gz | tar -zxv -C /usr/local/bin/

容器中运行

拉取镜像 xwcheng/kubebuilder:2.3.1

docker pull xwcheng/kubebuilder:2.3.1

运行容器

docker run -d --name kubebuilder -v /go/src/github.com/cxwen/:/go/src/github.com/cxwen -e GOPATH=/go -e GOROOT=/usr/local/go xwcheng/kubebuilder:2.3.1 sh -c "while true; do sleep 1000000000; done"

可以将宿主机的目录挂载到容器中。

进入容器

docker exec -ti kubebuilder bash

构建代码框架

1、初始化代码框架

国内因为墙的关系,最好执行 export GOPROXY=https://goproxy.io

mkdir -p crd/
cd crd/
kubebuilder init --domain cxwen.com --license apache2 --owner "cxwen"
Writing scaffold for you to edit...
Get controller runtime:
$ go get sigs.k8s.io/controller-runtime@v0.5.0
go: finding sigs.k8s.io/controller-runtime v0.5.0
......
Update go.mod:
$ go mod tidy
go: downloading github.com/go-logr/zapr v0.1.0
......
Running make:
$ make
go: creating new go.mod: module tmp
go: finding sigs.k8s.io v0.2.5
......
/go/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
go vet ./...
go build -o bin/manager main.go
Next: define a resource with:
$ kubebuilder create api

生成完之后,会出现如下这些文件机目录:

[root@centos01 ~]# tree
.
├── bin
│   └── manager
├── config
│   ├── certmanager
│   │   ├── certificate.yaml
│   │   ├── kustomization.yaml
│   │   └── kustomizeconfig.yaml
│   ├── default
│   │   ├── kustomization.yaml
│   │   ├── manager_auth_proxy_patch.yaml
│   │   ├── manager_webhook_patch.yaml
│   │   └── webhookcainjection_patch.yaml
│   ├── manager
│   │   ├── kustomization.yaml
│   │   └── manager.yaml
│   ├── prometheus
│   │   ├── kustomization.yaml
│   │   └── monitor.yaml
│   ├── rbac
│   │   ├── auth_proxy_client_clusterrole.yaml
│   │   ├── auth_proxy_role_binding.yaml
│   │   ├── auth_proxy_role.yaml
│   │   ├── auth_proxy_service.yaml
│   │   ├── kustomization.yaml
│   │   ├── leader_election_role_binding.yaml
│   │   ├── leader_election_role.yaml
│   │   └── role_binding.yaml
│   └── webhook
│       ├── kustomization.yaml
│       ├── kustomizeconfig.yaml
│       └── service.yaml
├── Dockerfile
├── go.mod
├── go.sum
├── hack
│   └── boilerplate.go.txt
├── main.go
├── Makefile
└── PROJECT

9 directories, 30 files

2、创建CRD

[root@centos01 ~]# kubebuilder create api --group crd --version v1 --kind TestCrd
Create Resource [y/n]
y
Create Controller [y/n]
y
Writing scaffold for you to edit...
api/v1/testcrd_types.go
controllers/testcrd_controller.go
Running make:
$ make
/go/bin/controller-gen object:headerFile="hack/boilerplate.go.txt" paths="./..."
go fmt ./...
go vet ./...
go build -o bin/manager main.go

执行完后可以看到目录下出现api和controllers这两个新的目录。

[root@centos01 ~]# tree api/
api/
└── v1
    ├── groupversion_info.go
    ├── testcrd_types.go
    └── zz_generated.deepcopy.go

1 directory, 3 files

[root@centos01 ~]# tree controllers/
controllers/
├── suite_test.go
└── testcrd_controller.go

0 directories, 2 files

代码解析

结构体: CRD的血肉

api目录中存放的是Custom Resource的结构体。 如下所示,TestCrdSpec结构体中可以自定义yaml文件spec属性下需要的字段。TestCrdStatus结构体中可以自定义yaml文件status属性下需要的字段。

// TestCrdSpec defines the desired state of TestCrd
type TestCrdSpec struct {
    // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
    // Important: Run "make" to regenerate code after modifying this file

    // Foo is an example field of TestCrd. Edit TestCrd_types.go to remove/update
    Foo string `json:"foo,omitempty"`
}

// TestCrdStatus defines the observed state of TestCrd
type TestCrdStatus struct {
    // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
    // Important: Run "make" to regenerate code after modifying this file
}

// +kubebuilder:object:root=true

// TestCrd is the Schema for the testcrds API
type TestCrd struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   TestCrdSpec   `json:"spec,omitempty"`
    Status TestCrdStatus `json:"status,omitempty"`
}

spec和status有什么区别呢?

可以查看kubernetes的官方文档对象规约(Spec)与状态(Status)

每个 Kubernetes 对象包含两个嵌套的对象字段,它们负责管理对象的配置:对象 spec 和 对象 status 。 spec 是必需的,它描述了对象的 期望状态(Desired State) —— 希望对象所具有的特征。 status 描述了对象的 实际状态(Actual State) ,它是由 Kubernetes 系统提供和更新的。在任何时刻,Kubernetes 控制面一直努力地管理着对象的实际状态以与期望状态相匹配。

对于我们开发的CRD来说,可以像这样理解。

  • spec是预先定义的期望状态,也就是控制器 (controller) 里面可以获取到的预置信息,并根据这些信息进行处理调度以达到这个期望状态,并且spec里面的信息不能被控制器里面的代码更改的。

  • status里面的字段里面的信息是标志当前实际状态。比如,一个Custom Resource生命周期有三个状态:initializing、ready、teminating, 可以在status中加一个phase字段来表示;当这个CR刚创建时,控制器将phase字段值更新为initializin;CR初始化完成,健康检查等通过,已经可以正常提供服务了,控制器就可以将phase字段置为ready;当这个CR使命已经完成,进入结束阶段,控制器就将phase字段置为teminating, 然后再执行资源清理操作。当然,这些状态的转换,都是需要在控制器代码里来实现的。

Reconcile:CRD的大脑

如果说结构体是CRD的血肉,那么controller里面的Reconcile方法就是CRD的大脑,因为CRD的所有行为,都是通过这个方法来控制的,这个方法也是我们代码实现的关键所在。每一个CRD都会在controllers目录中生成一个以{CRD名称}_controller.go格式命名的代码文件, Reconcile方法即在这个文件中。

func (r *TestCrdReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
    _ = context.Background()
    _ = r.Log.WithValues("testcrd", req.NamespacedName)

    // your logic here

    return ctrl.Result{}, nil
}

下面是Reconcile实现的一个模板:

func (r *TestCrdReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
    var err error
    ctx := context.Background() // 获取context
    log := r.Log.WithValues("master", req.NamespacedName) // 获取日志对象

    log.V(1).Info("TestCrd reconcile triggering")
    
    // 从kubernetes中获取crd对象
    testCrd := crdv1.TestCrd{}
    if err = r.Get(ctx, req.NamespacedName, &testCrd); err != nil { 
        if IgnoreNotFound(err) != nil {
            log.Error(err, "unable to fetch testCrd")
            return ctrl.Result{}, err
        }

        return ctrl.Result{}, nil
    }

    testCrdFinalizer := "crd.cxw.com"
    // 通过DeletionTimestamp字段来判断是删除还是创建更新操作
    if testCrd.ObjectMeta.DeletionTimestamp.IsZero() {
        // 判断是否是创建
        if ! ContainsString(master.ObjectMeta.Finalizers, testCrdFinalizer) {
            testCrd.ObjectMeta.Finalizers = append(testCrd.ObjectMeta.Finalizers, testCrdFinalizer)
            
            // 你的创建处理代码
            
        } else {
            // 你的更新处理代码
        }
    } else {
        if ContainsString(master.ObjectMeta.Finalizers, masterFinalizer) {
            
            // 你的删除处理代码

            testCrd.ObjectMeta.Finalizers = RemoveString(testCrd.ObjectMeta.Finalizers, testCrdFinalizer)
            if err = r.Update(ctx, &testCrd); err != nil {
                return ctrl.Result{}, err
            }
        }
    }

    return ctrl.Result{}, nil
}
注释也是代码

可以直接在代码里通过格式化的注释来实现授权、添加额外打印字段功能。具体使用可以参考matrix

1、给控制器授权

在Reconcile方法前面添加, 例如:

// +kubebuilder:rbac:groups=crd.cxwen.com,resources=testcrds,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=crd.cxwen.com,resources=testcrds/status,verbs=get;update;patch

func (r *TestCrdReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
    _ = context.Background()
    _ = r.Log.WithValues("testcrd", req.NamespacedName)

    // your logic here

    return ctrl.Result{}, nil
}

2、添加额外的打印字段

在CRD结构体前面添加,例如:

// +kubebuilder:printcolumn:name="VERSION",type="string",JSONPath=".spec.version",description="version"
// +kubebuilder:printcolumn:name="REPLICAS",type="string",JSONPath=".spec.replicas",description="pod replicas"
// +kubebuilder:printcolumn:name="ETCD",type="string",JSONPath=".spec.etcdCluster",description="etcdcluster name"
// +kubebuilder:printcolumn:name="EXPOSETYPE",type="string",JSONPath=".spec.expose.method",description="expose type"
// +kubebuilder:printcolumn:name="EXPOSENODE",type="string",JSONPath=".spec.expose.node",description="expose node"
// +kubebuilder:printcolumn:name="EXPOSEPORT",type="string",JSONPath=".spec.expose.port",description="expose port"
// +kubebuilder:printcolumn:name="PHASE",type="string",JSONPath=".status.phase",description="phase"
// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp"

// Master is the Schema for the masters API
type Master struct {
    metav1.TypeMeta   `json:",inline"`
    metav1.ObjectMeta `json:"metadata,omitempty"`

    Spec   MasterSpec   `json:"spec,omitempty"`
    Status MasterStatus `json:"status,omitempty"`
}
Finalizer

Finalizer的作用是基于kubernetes的处理机制。

当api接收到一个资源对象删除操作,Finalizer为空时,kubernetes会直接将这个资源对象删掉,删掉之后,etcd中就不存在改资源对象了,通过api也无法查询到了。

当Finalizer不为空时,kubernetes不会直接删除改资源对象,而是在对象ObjectMeta中添加DeletionTimestamp这么个字段,一直要等到Finalizer为空时才把资源对象删除。

所以,当我们的CRD在删除时有需要进行资源清理操作时,就可以在创建时添加上Finalizer,当检测到DeletionTimestamp不为空时,就知道该资源对象处于删除状态了,然后执行资源清理操作,最后移除Finalizer即可。

Finalizer的添加很简单,它的值可以是任何字符串。当然,为了起到一些标志作用,可以使用有意义的字符串。

OwnerReference

kubernetes GC在删除一个对象时,任何 ownerReference 是该对象的对象都会被清除。

下面是kubernetes官方文档中的描述。

某些 Kubernetes 对象是其它一些对象的所有者。例如,一个 ReplicaSet 是一组 Pod 的所有者。 具有所有者的对象被称为是所有者的 附属 。 每个附属对象具有一个指向其所属对象的 metadata.ownerReferences 字段。
有时,Kubernetes 会自动设置 ownerReference 的值。 例如,当创建一个 ReplicaSet 时,Kubernetes 自动设置 ReplicaSet 中每个 Pod 的 ownerReference 字段值。 在 Kubernetes 1.8 版本,Kubernetes 会自动为某些对象设置 ownerReference 的值,这些对象是由 ReplicationController、ReplicaSet、StatefulSet、DaemonSet、Deployment、Job 和 CronJob 所创建或管理。 也可以通过手动设置 ownerReference 的值,来指定所有者和附属之间的关系。

添加OwnerReference, 以deployment为例,在ObjectMeta下面添加OwnerReferences即可:

Deployment{
    TypeMeta: metav1.TypeMeta{
        APIVersion: "apps/v1",
        Kind:       "Deployment",
    },
    ObjectMeta: metav1.ObjectMeta{
        Name:      test,
        Namespace: test,
        OwnerReferences: []metav1.OwnerReference{
            *metav1.NewControllerRef(app, schema.GroupVersionKind{
                Group: v1.SchemeGroupVersion.Group,
                Version: v1.SchemeGroupVersion.Version,
                Kind: "TestCrd",
            }),
        },
    },
    ......
}

小结

进行CRD开发时

  • 使用注释的方法来为controller配置权限,以及添加额外打印字段
  • 使用Finalizer来做资源的清理
  • 使用OwnerReference进行对象之间依赖关系的管理

其它代码开发技巧可以研究下kubebuilder的文档

源码小窥

kubebuilder生成的源码架构,主要是基于controller-runtime这个go代码库。这个代码库对controller操作做了很好的封装,基于它开发CRD非常方便。

main.go

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

// 注册scheme
func init() {
    _ = clientgoscheme.AddToScheme(scheme)

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

func main() {
    ......
    
    // 初始化manager
    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme:             scheme,
        MetricsBindAddress: metricsAddr,
        Port:               9443,
        LeaderElection:     enableLeaderElection,
        LeaderElectionID:   "6ed5364d.cxwen.com",
    })
    if err != nil {
        setupLog.Error(err, "unable to start manager")
        os.Exit(1)
    }

    // 初始化controller
    if err = (&controllers.TestCrdReconciler{
        Client: mgr.GetClient(),
        Log:    ctrl.Log.WithName("controllers").WithName("TestCrd"),
        Scheme: mgr.GetScheme(),
    }).SetupWithManager(mgr); err != nil {
        setupLog.Error(err, "unable to create controller", "controller", "TestCrd")
        os.Exit(1)
    }
    // +kubebuilder:scaffold:builder

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

main.go中主要做了下面两件事:

1、注册scheme

在controller中,如果需要使用manager提供的client操作某种类型的资源,需要将资源类型注册到scheme中。从代码中的init函数可以看到,crdv1中的类型被注册到了scheme中。

2、创建并启动manager

这个manager就是管理controller的manager, 一个manager中可以管理多个controller。

创建manager过程中,创建了两个很重要的对象:cache和client。这两个对象是manager中所有controller共享的。

下面是创建Manager的函数:

// New returns a new Manager for creating Controllers.
func New(config *rest.Config, options Options) (Manager, error) {
    // Initialize a rest.config if none was specified
    if config == nil {
        return nil, fmt.Errorf("must specify Config")
    }

    // Set default values for options fields
    options = setOptionsDefaults(options)

    // Create the mapper provider
    mapper, err := options.MapperProvider(config)
    if err != nil {
        log.Error(err, "Failed to get API Group-Resources")
        return nil, err
    }

    // Create the cache for the cached read client and registering informers
    cache, err := options.NewCache(config, cache.Options{Scheme: options.Scheme, Mapper: mapper, Resync: options.SyncPeriod, Namespace: options.Namespace})
    if err != nil {
        return nil, err
    }

    apiReader, err := client.New(config, client.Options{Scheme: options.Scheme, Mapper: mapper})
    if err != nil {
        return nil, err
    }

    writeObj, err := options.NewClient(cache, config, client.Options{Scheme: options.Scheme, Mapper: mapper})
    if err != nil {
        return nil, err
    }
    // Create the recorder provider to inject event recorders for the components.
    // TODO(directxman12): the log for the event provider should have a context (name, tags, etc) specific
    // to the particular controller that it's being injected into, rather than a generic one like is here.
    recorderProvider, err := options.newRecorderProvider(config, options.Scheme, log.WithName("events"), options.EventBroadcaster)
    if err != nil {
        return nil, err
    }

    // Create the resource lock to enable leader election)
    resourceLock, err := options.newResourceLock(config, recorderProvider, leaderelection.Options{
        LeaderElection:          options.LeaderElection,
        LeaderElectionID:        options.LeaderElectionID,
        LeaderElectionNamespace: options.LeaderElectionNamespace,
    })
    if err != nil {
        return nil, err
    }

    // Create the metrics listener. This will throw an error if the metrics bind
    // address is invalid or already in use.
    metricsListener, err := options.newMetricsListener(options.MetricsBindAddress)
    if err != nil {
        return nil, err
    }

    // Create health probes listener. This will throw an error if the bind
    // address is invalid or already in use.
    healthProbeListener, err := options.newHealthProbeListener(options.HealthProbeBindAddress)
    if err != nil {
        return nil, err
    }

    stop := make(chan struct{})

    return &controllerManager{
        config:                config,
        scheme:                options.Scheme,
        cache:                 cache,
        fieldIndexes:          cache,
        client:                writeObj,
        apiReader:             apiReader,
        recorderProvider:      recorderProvider,
        resourceLock:          resourceLock,
        mapper:                mapper,
        metricsListener:       metricsListener,
        internalStop:          stop,
        internalStopper:       stop,
        port:                  options.Port,
        host:                  options.Host,
        certDir:               options.CertDir,
        leaseDuration:         *options.LeaseDuration,
        renewDeadline:         *options.RenewDeadline,
        retryPeriod:           *options.RetryPeriod,
        healthProbeListener:   healthProbeListener,
        readinessEndpointName: options.ReadinessEndpointName,
        livenessEndpointName:  options.LivenessEndpointName,
    }, nil
}
  • cache

cache用到了kubernetes中一个重要的工具包:informer。

cache主要就是创建了InformersMap,scheme里面的每个GVK (GroupVersionKind结构体,包含Group、Version、Kind三个字段,可以唯一确定一个资源) 都创建了对应的 Informer,通过 informersByGVK这个map来存放GVK和Informer的映射关系,每个 Informer会根据ListWatch 函数对对应的GVK进行List和Watch。我们为controller开发的Reconcile方法最终都会注册到informer的handler中,这样利用informer就可以达到监控资源的事件并触发Reconcile目的。

// newSpecificInformersMap returns a new specificInformersMap (like
// the generical InformersMap, except that it doesn't implement WaitForCacheSync).
func newSpecificInformersMap(config *rest.Config,
    scheme *runtime.Scheme,
    mapper meta.RESTMapper,
    resync time.Duration,
    namespace string,
    createListWatcher createListWatcherFunc) *specificInformersMap {
    ip := &specificInformersMap{
        config:            config,
        Scheme:            scheme,
        mapper:            mapper,
        informersByGVK:    make(map[schema.GroupVersionKind]*MapEntry), // schema GVK和informer映射
        codecs:            serializer.NewCodecFactory(scheme),
        paramCodec:        runtime.NewParameterCodec(scheme),
        resync:            resync,
        startWait:         make(chan struct{}),
        createListWatcher: createListWatcher,
        namespace:         namespace,
    }
    return ip
}

MapEntry中包含最终创建的informer对象。

// MapEntry contains the cached data for an Informer
type MapEntry struct {
    // Informer is the cached informer
    Informer cache.SharedIndexInformer

    // CacheReader wraps Informer and implements the CacheReader interface for a single type
    Reader CacheReader
}
  • client

从下面的代码可以看出,读操作使用上面创建的 cache,写操作使用client直连kubernetes。

// defaultNewClient creates the default caching client
func defaultNewClient(cache cache.Cache, config *rest.Config, options client.Options) (client.Client, error) {
    // Create the Client for Write operations.
    c, err := client.New(config, options)
    if err != nil {
        return nil, err
    }

    return &client.DelegatingClient{
        Reader: &client.DelegatingReader{
            CacheReader:  cache,
            ClientReader: c,
        },
        Writer:       c,
        StatusClient: c,
    }, nil
}

CRD Reconcile执行

在main.go中, 通过下面的代码把Manager中的client、scheme以及日志对象传递给相应的CRD对象。

if err = (&controllers.TestCrdReconciler{
    Client: mgr.GetClient(),
    Log:    ctrl.Log.WithName("controllers").WithName("TestCrd"),
    Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
    setupLog.Error(err, "unable to create controller", "controller", "TestCrd")
    os.Exit(1)
}
// TestCrdReconciler reconciles a TestCrd object
type TestCrdReconciler struct {
    client.Client
    Log    logr.Logger
    Scheme *runtime.Scheme
}

然后在Reconcile方法中就可以直接使用client来进行CURD操作。

总结

kubernetes的强大之处之一就是支持CRD对API进行扩展,当今很多项目都大量使用到CRD,像calico、istio以及kubevirt等等。如果有在kubernetes上层进行二次开发需求,可以优先考虑CRD,这是一种非常优雅的扩展方式,也是kubernetes生态的发展趋势。除此之外,有kubebuilder这个CRD开发利器,也能让我们的开发工作事半功倍。

资源链接

matrix github:https://github.com/cxwen/matrix

kubebuilder github: https://github.com/kubernetes-sigs/kubebuilder
kubebuilder的文档: https://book.kubebuilder.io/

Go语言环境安装: https://www.runoob.com/go/go-environment.html

go语言中文网go安装包下载: https://studygolang.com/dl

kustomize github: https://github.com/kubernetes-sigs/kustomize

kubernetes的官方文档对象规约(Spec)与状态(Status): https://kubernetes.io/zh/docs/concepts/overview/working-with-objects/kubernetes-objects/#%E5%AF%B9%E8%B1%A1%E8%A7%84%E7%BA%A6-spec-%E4%B8%8E%E7%8A%B6%E6%80%81-status

controller-runtime github: https://github.com/kubernetes-sigs/controller-runtime

2020/07/05 posted in  kubernetes

kubernetes v1.10.0 高可用集群部署

kubernetes官方并为明确给出高可用生产集群的部署方案,经过调研,使用keepalived和haproxy可以实现高可用集群的部署。该方案也已经过实践检验,运行的还是比较稳定的。

机器

IP 用途 备注
172.28.79.11 master、etcd 主节点
172.28.79.12 master、etcd、keepalived、haproxy 主节点,同时部署keepalived、haproxy,保证master高可用
172.28.79.13 master、etcd、keepalived、haproxy 主节点,同时部署keepalived、haproxy,保证master高可用
172.28.79.14 node、etcd 业务节点
172.28.79.15 node、etcd 业务节点
172.28.79.16 node 业务节点
172.28.79.17 node 业务节点
172.28.79.18 node 业务节点
172.28.79.19 node 业务节点
172.28.79.20 node 业务节点
172.28.79.21 node 业务节点
172.28.79.22 node 业务节点
172.28.79.23 node 业务节点
172.28.79.24 node、harbor 业务节点
172.28.79.25 node 业务节点

机器基础配置信息

版本信息

项目 版本
系统版本 CentOS Linux release 7.4.1708 (Core)
内核版本 4.14.49

ntpd时间同步配置

/etc/ntp.conf

#perfer 表示『优先使用』的服务器
server ntp.aliyun.com prefer
#下面没有prefer参数,做为备用NTP时钟上层服务器地址,我这里设置的是公网,语音云则可以设置其他两地NTP IP。
server cn.ntp.org.cn
#我们每一个system clock的频率都有小小的误差,这个就是为什么机器运行一段时间后会不精确. NTP会自动来监测我们时钟的误差值并予以调整.但问题是这是一个冗长的过程,所以它会把记录下来的误差先写入driftfile.这样即使你重新开机以后之前的计算结果也就不会丢了
driftfile /var/lib/ntp/ntp.drift
statistics loopstats peerstats clockstats
filegen loopstats file loopstats type day enable
filegen peerstats file peerstats type day enable
filegen clockstats file clockstats type day enable
# By default, exchange time with everybody, but don't allow configuration.
restrict -4 default kod notrap nomodify nopeer noquery
restrict -6 default kod notrap nomodify nopeer noquery
# Local users may interrogate the ntp server more closely.
restrict 127.0.0.1
restrict ::1

kubernetes组件配置信息

组件版本

组件名 版本
docker Docker version 1.12.6, build 78d1802
kubernetes v1.10.0
etcd 3.1.12
calico v3.0.4
harbor v1.2.0
keepalived v1.3.5
haproxy 1.7

配置

组件配置

docker

配置文件:/usr/lib/systemd/system/docker.service

[Unit]
Description=Docker Application Container Engine
Documentation=https://docs.docker.com
After=network.target

[Service]
Type=notify
# the default is not to use systemd for cgroups because the delegate issues still
# exists and systemd currently does not support the cgroup feature set required
# for containers run by docker
ExecStart=/usr/bin/dockerd -H 0.0.0.0:2375 -H unix:///var/run/docker.sock --registry-mirror https://registry.docker-cn.com --insecure-registry 172.16.59.153 --insecure-registry hub.cxwen.cn --insecure-registry k8s.gcr.io --insecure-registry quay.io --default-ulimit core=0:0 --live-restore
ExecReload=/bin/kill -s HUP $MAINPID
# Having non-zero Limit*s causes performance problems due to accounting overhead
# in the kernel. We recommend using cgroups to do container-local accounting.
LimitNOFILE=infinity
LimitNPROC=infinity
LimitCORE=infinity
# Uncomment TasksMax if your systemd version supports it.
# Only systemd 226 and above support this version.
#TasksMax=infinity
TimeoutStartSec=0
# set delegate yes so that systemd does not reset the cgroups of docker containers
Delegate=yes
# kill only the docker process, not all processes in the cgroup
KillMode=process

[Install]
WantedBy=multi-user.target
--registry-mirror:指定 docker pull 时使用的注册服务器镜像地址,指定为https://registry.docker-cn.com可以加快docker hub中的镜像拉取速度
--insecure-registry:配置非安全的docker镜像注册服务器
--default-ulimit:配置容器默认的ulimit选项
--live-restore:开启此选项,当dockerd服务出现问题时,容器照样运行,服务恢复后,容器也可以再被服务抓到并可管理

kubernetes

etcd

以172.28.79.11节点为例,其它节点类似:

apiVersion: v1
kind: Pod
metadata:
  labels:
    component: etcd
    tier: control-plane
  name: etcd-172.28.79.11
  namespace: kube-system
spec:
  containers:
  - command:
    - etcd
    - --name=infra0
    - --initial-advertise-peer-urls=http://172.28.79.11:2380
    - --listen-peer-urls=http://172.28.79.11:2380
    - --listen-client-urls=http://172.28.79.11:2379,http://127.0.0.1:2379
    - --advertise-client-urls=http://172.28.79.11:2379
    - --data-dir=/var/lib/etcd
    - --initial-cluster-token=etcd-cluster-1
    - --initial-cluster=infra0=http://172.28.79.11:2380,infra1=http://172.28.79.12:2380,infra2=http://172.28.79.13:2380,infra3=http://172.28.79.14:2380,infra4=http://172.28.79.15:2380
    - --initial-cluster-state=new
    image: k8s.gcr.io/etcd-amd64:3.1.12
    livenessProbe:
      httpGet:
        host: 127.0.0.1
        path: /health
        port: 2379
        scheme: HTTP
      failureThreshold: 8
      initialDelaySeconds: 15
      timeoutSeconds: 15
    name: etcd
    volumeMounts:
    - name: etcd-data
      mountPath: /var/lib/etcd
  hostNetwork: true
  volumes:
  - hostPath:
      path: /var/lib/etcd
      type: DirectoryOrCreate
    name: etcd-data

kubernetes系统组件

kubeadm init 启动k8s集群config.yaml配置

apiVersion: kubeadm.k8s.io/v1alpha1
kind: MasterConfiguration
networking:
  podSubnet: 192.168.0.0/16
api:
  advertiseAddress: 172.28.79.11
etcd:
  endpoints:
  - http://172.28.79.11:2379 
  - http://172.28.79.12:2379
  - http://172.28.79.13:2379
  - http://172.28.79.14:2379
  - http://172.28.79.15:2379

apiServerCertSANs:
  - 172.28.79.11
  - master01.bja.paas
  - 172.28.79.12
  - master02.bja.paas
  - 172.28.79.13
  - master03.bja.paas
  - 172.28.79.10
  
  - 127.0.0.1
token:
kubernetesVersion: v1.10.0
apiServerExtraArgs:
  endpoint-reconciler-type: lease
  bind-address: 172.28.79.11
  runtime-config: storage.k8s.io/v1alpha1=true
  admission-control: NamespaceLifecycle,LimitRanger,ServiceAccount,DefaultStorageClass,DefaultTolerationSeconds,NodeRestriction,MutatingAdmissionWebhook,ValidatingAdmissionWebhook,ResourceQuota
featureGates:
  CoreDNS: true

kubelet配置

/etc/systemd/system/kubelet.service.d/10-kubeadm.conf

[Service]
Environment="KUBELET_KUBECONFIG_ARGS=--bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.conf"
Environment="KUBELET_SYSTEM_PODS_ARGS=--pod-manifest-path=/etc/kubernetes/manifests --allow-privileged=true"
Environment="KUBELET_NETWORK_ARGS=--network-plugin=cni --cni-conf-dir=/etc/cni/net.d --cni-bin-dir=/opt/cni/bin"
Environment="KUBELET_DNS_ARGS=--cluster-dns=10.96.0.10 --cluster-domain=cluster.local"
Environment="KUBELET_AUTHZ_ARGS=--authorization-mode=Webhook --client-ca-file=/etc/kubernetes/pki/ca.crt"
Environment="KUBELET_CADVISOR_ARGS=--cadvisor-port=0"
Environment="KUBELET_CGROUP_ARGS=--cgroup-driver=cgroupfs"
Environment="KUBELET_CERTIFICATE_ARGS=--rotate-certificates=true --cert-dir=/var/lib/kubelet/pki --eviction-hard=memory.available<5%,nodefs.available<5%,imagefs.available<5%"
ExecStart=
ExecStart=/usr/bin/kubelet $KUBELET_KUBECONFIG_ARGS $KUBELET_SYSTEM_PODS_ARGS $KUBELET_NETWORK_ARGS $KUBELET_DNS_ARGS $KUBELET_AUTHZ_ARGS $KUBELET_CADVISOR_ARGS $KUBELET_CGROUP_ARGS $KUBELET_CERTIFICATE_ARGS $KUBELET_EXTRA_ARGS

keepalived

keepalived采取直接在物理机部署,使用yum命令进行安装,并设置开机自启。

yum install -y keepalived

systemctl enable keeplalived

配置keepalived配置文件

启动配置文件:"/etc/keepalived/keepalived.conf"。keepalived的MASTER和BACKUP配置有部分差异

MASTER

! Configuration File for keepalived

global_defs {
    notification_email {
      root@localhost
    }
    router_id master02
}

vrrp_script chk_haproxy {
    script "/etc/keepalived/haproxy_check.sh"
    interval 3
    weight -20
}

vrrp_instance VI_1 {
    state MASTER    # BACKUP节点改成BACKUP
    interface bond1
    virtual_router_id 151
    priority 110    # BACKUP节点改成100
    advert_int 1
    authentication {
        auth_type PASS
        auth_pass 1111
    }
    virtual_ipaddress {
       172.28.79.10 # k8s使用的VIP
       172.28.79.9  # 数据库组件使用的VIP
    }
    track_script {
       chk_haproxy
    }
}

haproxy检查脚本:/etc/keepalived/haproxy_check.sh

#!/bin/bash

if [ `ps -C haproxy --no-header |wc -l` -eq 0 ] ; then
    docker restart k8s-haproxy
    sleep 2
    if [ `ps -C haproxy --no-header |wc -l` -eq 0 ] ; then
        service keepalived stop
    fi
fi

haproxy

haproxy以容器的形式启动,启动命令如下:

docker run -d --net host --restart always --name k8s-haproxy -v /etc/haproxy:/usr/local/etc/haproxy:ro hub.xfyun.cn/k8s/haproxy:1.7

haproxy配置文件:/etc/haproxy/haproxy.cfg

global
  daemon
  log 127.0.0.1 local0
  log 127.0.0.1 local1 notice
  maxconn 4096

defaults
  log               global
  retries           3
  maxconn           2000
  timeout connect   5s
  timeout client    50s
  timeout server    50s

frontend k8s
  bind *:6444
  mode tcp
  default_backend k8s-backend

backend k8s-backend
  balance roundrobin
  mode tcp
  server k8s-1 172.28.79.11:6443 check
  server k8s-2 172.28.79.12:6443 check
  server k8s-3 172.28.79.13:6443 check

部署完成后操作

修改kube-proxy configmap

kubectl edit configmap kube-proxy -n kube-system
.....
kubeconfig.conf: |-
  apiVersion: v1
  kind: Config
  clusters:
  - cluster:
      certificate-authority: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
      server: https://172.28.79.10:6444  # 更改此行ip为vip,改成172.28.79.10
    name: default
  contexts:
  - context:
      cluster: default
      namespace: default
      user: default
    name: default
  current-context: default
  users:
  - name: default
    user:
      tokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token
......

执行如下命令让kube-proxy组件重新启动

kubectl get pod -n kube-system | grep kube-proxy | awk '{print $1}' | xargs kubectl delete pod -n kube-system

修改所有node节点kubelet.conf

/etc/kubernetes/kubelet.conf
apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUN5RENDQWJDZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRFNE1EVXhPREF4TXpNME1Gb1hEVEk0TURVeE5UQXhNek0wTUZvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTGJoCmw1TDRaNHFiWTJ3MmY5TFlEb0ZqVlhhcHRhYklkQmZmTS9zMTJaWFd1NU5LYWlPR09ub3RxK1gwM0VJb3Z4VEkKUGh5NzBqY294VGlLUTk5ZkFsUS82a2Vhc0x5MDNGZXJvYkhmaldUenBkZE5mWVNEZStMazlMV0hIZ0phOXVUQQpDU3kyay9sZGo3VWQ0Sk9pMi9lcGhVTUNNMUNlbmdPeWZDNUl0SUpFZzJmMk95cTE5U0JBeW1zYzFTalg5Q0F6CnNyMlhiTm9hK1lVS2Flek1QSldvYlNxdEg0czQ1TkluYytMREJFTkk4VGVITktybENsamdIeUorUjU1V2pCTW8KeSs3Y1BxL2cwTkxmSU4xRjJVbkFFa3RTSmVYUFBSaGlQUUhJcGRBU0xySXhVcE9HNlN3Yk51bmRGdGsxaUJiUgpUSW9md2UyT0VhZkhySmV5OHJrQ0F3RUFBYU1qTUNFd0RnWURWUjBQQVFIL0JBUURBZ0trTUE4R0ExVWRFd0VCCi93UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dFQkFLME1mOFM5VjUyaG9VZ3JYcGQyK09rOHF2Ny8KR3hpWnRFelFISW9RdWRLLzJ2ZGJHbXdnem10V3hFNHRYRWQyUnlXTTZZR1VQSmNpMmszY1Z6QkpSaGcvWFB2UQppRVBpUDk5ZkdiM0kxd0QyanlURWVaZVd1ekdSRDk5ait3bStmcE9wQzB2ZU1LN3hzM1VURjRFOFlhWGcwNmdDCjBXTkFNdTRxQmZaSUlKSEVDVDhLUlB5TEN5Zlgvbm84Q25WTndaM3pCbGZaQmFONGZaOWw0UUdGMVd4dlc0OHkKYmpvRDhqUVJnL1kwYUVUMWMrSEhpWTNmNDF0dG9kMWJoSWR3c1NDNUhhRjJQSVAvZ2dCSnZ2Uzh2V1cwcVRDegpDV2EzcVJ0bVB0MHdtcEZic2RPWmdsWkl6aWduYTdaaDFWMDJVM0VFZ2kwYjNGZWR5OW5MRUZaMGJZbz0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
    server: https://172.28.79.10:6444   # 此处改为VIP加haproxy监听端口6444
  name: default-cluster
contexts:
- context:
    cluster: default-cluster
    namespace: default
    user: default-auth
  name: default-context
current-context: default-context
kind: Config
preferences: {}
users:
- name: default-auth
  user:
    client-certificate: /var/lib/kubelet/pki/kubelet-client.crt
    client-key: /var/lib/kubelet/pki/kubelet-client.key

部署前注意事项

1. 确保所有节点时间同步

使用ntpdate命令进行时间同步,若无私网时间服务器,可以使用阿里云时间服务器。

ntpdate ntp1.aliyun.com

2. 确保所有节点ip转发功能打开

net.ipv4.ip_forward = 1
2020/07/05 posted in  kubernetes