Kubernetes Operators 入门笔记

为什么需要 Kubernetes Operator?

在查阅 Kubernetes Operators 由来的时候,看到阿里云的这篇文章[8]. 这篇文章的作者之一邓洪超是下文学习的 etcd operator 的作者之一。这篇文章讲述了 Operator 的历史,写出了历史大片的感觉,值得一看。

按照我的理解,Operator 的就是一个运维人员,只是这个运维人员是软件而不是人类。 对于无状态的应用,像是 web server, 其实用 k8s 本身的 deployment 就足够了,一个 pod 坏了,自动部署一个新的 pod, 又有弹性又高可用。但是对于一些稍微复杂的应用,尤其是有状态,有特定拓扑的应用,就需要特定的逻辑才能管理起来。 比如 etcd, 如果有一半以上的节点挂了,那么就会出现 quorum lost, 集群无法工作。还是 etcd, 里面的数据需要定期备份,万一整个集群挂了,也能及时地恢复。 这些操作可以通过人类实现,也可以通过软件实现,Operator 就是这样的软件,它会自动管理 k8s 上一个特定的复杂应用。

于是,Operator 也成为了应用软件在 Kubernetes 上的一种标准化交付方式。应用的开发者可以定义好如何运维这个应用,用户想用某个应用,只需要安装上相应的 Operator,那么 Operator 就会自动完成整个应用生命周期的管理,包括安装、升级、备份、恢复等等。

如果把饼画得大一点,在 k8s 里,下有管理 k8s 节点的 Operator, 上有管理应用的 Operator, 那么这整个平台都是自动管理的,可以做到免运维,这很云计算。

CR & CRD

在 Kubernetes 里,Pod, ReplicaSet 这些都是一个 Resource. 我们也可以定义自己的一个 Resource, 例如,可以定义一个叫 EtcdCluster 的 Resource, 这个 Resource 实际上包含 Pod, Service 等一系列构成一个 etcd cluster 集群的资源。 定义这个 CR 的东西,叫做 Custom Resource Definition. 有了这个 Definition, 就可以创建多个这个 Definition 所定义的 CR. 我的类比是,CRD 是 Class, CR 是 Instance.

运行一个 etcd Operator

在本地环境中,可以通过 minikube[5] 来测试 Kubernetes. 首先,需要安装上 VirtualBox 或者 KVM. 我在 RHEL8 中安装了 KVM. 按照文档[5]下载并安装 minikube, 指定 kvm 作为驱动,启动一个本地的 Kubernetes 集群。

$ minikube start --driver=kvm2
$ kubectl get po -A

下载预先写好的配置文件,用以部署 etcd operator.

$ git clone https://github.com/kubernetes-operators-book/chapters.git
$ cd chapters/ch03

首先,创建一个 CRD. 这个 CRD 定义 etcdcluster 这个 Custom Resource.

$ cd chapters/ch03
$ cat etcd-operator-crd.yaml
apiVersion
: apiextensions.k8s.io/v1beta1
kind
: CustomResourceDefinition
metadata
:
  name
: etcdclusters.etcd.database.coreos.com
spec
:
  group
: etcd.database.coreos.com
  names
:
    kind
: EtcdCluster
    listKind
: EtcdClusterList
    plural
: etcdclusters
    shortNames
:
   - etcdclus
    - etcd
    singular
: etcdcluster
  scope
: Namespaced
  version
: v1beta2
  versions
:
  - name
: v1beta2
    served
: true
    storage
: true
$ kubectl create -f etcd-operator-crd.yaml
customresourcedefinition.apiextensions.k8s.io/etcdclusters.etcd.database.coreos.com created

$ kubectl get crd                        
NAME                                    CREATED AT
etcdclusters.etcd.database.coreos.com   2020-08-07T10:07:28Z

然后,创建一个 Service account, 这个 Service account 是 Operator 用来操作集群资源的用户。Kubernetes 使用 Role-Based Access Control (RBAC) 来控制权限。 在 Kubernetes 中,普通用户的 account 是通过 OpenID 等外部源管理的。而 Service account 则不是给人类用户使用的,是给 Kubernetes 上的程序操作集群资源使用的。

cat etcd-operator-sa.yaml
apiVersion
: v1
kind
: ServiceAccount
metadata
:
  name
: etcd-operator-sa
$ kubectl create -f etcd-operator-sa.yaml
serviceaccount/etcd-operator-sa created

$ kubectl get sa
NAME               SECRETS   AGE
default            1         5d4h
etcd-operator-sa   1         4s

有了 Service account, 我们还需要创建一个 Role, 给这个 Service account 使用。可以理解为, Service account 是一个用户,这个用户需要一个角色(role)才能获得这个 Role 所指定的权限。将 Role 赋予 Service account 的动作是 Rolebinding.

创建一个 Role, 这个 Role 定义了与资源对应的允许的动作:

$ cat etcd-operator-role.yaml
apiVersion
: rbac.authorization.k8s.io/v1
kind
: Role
metadata
:
  name
: etcd-operator-role
rules
:
- apiGroups
:
 - etcd.database.coreos.com
  resources
:
 - etcdclusters
  - etcdbackups
  - etcdrestores
  verbs
:
 - '*'
- apiGroups
:
 - ""
  resources
:
 - pods
  - services
  - endpoints
  - persistentvolumeclaims
  - events
  verbs
:
 - '*'
- apiGroups
:
 - apps
  resources
:
 - deployments
  verbs
:
 - '*'
- apiGroups
:
 - ""
  resources
:
 - secrets
  verbs
:
 - get

创建一个 rolebinding, 关联 Service account 和 Role.

$ cat etcd-operator-rolebinding.yaml
apiVersion
: rbac.authorization.k8s.io/v1
kind
: RoleBinding
metadata
:
  name
: etcd-operator-rolebinding
roleRef
:
  apiGroup
: rbac.authorization.k8s.io
  kind
: Role
  name
: etcd-operator-role
subjects
:
- kind
: ServiceAccount
  name
: etcd-operator-sa
  namespace
: default
$ kubectl create -f etcd-operator-role.yaml
role.rbac.authorization.k8s.io/etcd-operator-role created
$ kubectl create -f etcd-operator-rolebinding.yaml
rolebinding.rbac.authorization.k8s.io/etcd-operator-rolebinding created

$ kubectl get roles
NAME                 CREATED AT
etcd-operator-role   2020-08-09T06:28:02Z
$ kubectl get rolebindings                          
NAME                        ROLE                      AGE
etcd-operator-rolebinding   Role/etcd-operator-role   12s

然后,我们可以部署 Operator 的真身。其实 Operator 也是一个 Pod, 里面运行的是控制器,这个控制器会监控之前定义的 Customer Resource 的状况,并进行相应的运维操作。这个 Deployment 里,指定了它所使用的 Service account.

$ cat etcd-operator-deployment.yaml
apiVersion
: apps/v1
kind
: Deployment
metadata
:
  name
: etcd-operator
spec
:
  selector
:
    matchLabels
:
      app
: etcd-operator
  replicas
: 1
  template
:
    metadata
:
      labels
:
        app
: etcd-operator
    spec
:
      containers
:
      - name
: etcd-operator
        image
: quay.io/coreos/etcd-operator:v0.9.4
        command
:
       - etcd-operator
        - --create-crd=false
        env
:
        - name
: MY_POD_NAMESPACE
          valueFrom
:
            fieldRef
:
              fieldPath
: metadata.namespace
        - name
: MY_POD_NAME
          valueFrom
:
            fieldRef
:
              fieldPath
: metadata.name
        imagePullPolicy
: IfNotPresent
      serviceAccountName
: etcd-operator-sa
$ kubectl create -f etcd-operator-deployment.yaml
deployment.apps/etcd-operator created

$ kubectl get deployments    
NAME            READY   UP-TO-DATE   AVAILABLE   AGE
etcd-operator   1/1     1            1           9s

$ kubectl get pods
NAME                            READY   STATUS    RESTARTS   AGE
etcd-operator-d455d6d75-46vm4   1/1     Running   0          2m21s

至此,Custom Resource (EtcdCluster) 已经定义好了,控制这个 CR 的 Operator (etcd-operator) 也部署好了。现在,我们可以在 Kubernetes 中创建一个 EtcdCluster 资源,只要给定 EtcdCluster 资源的参数,剩下的事情 etcd-operator 就会代劳了。

$ cat etcd-cluster-cr.yaml
apiVersion
: etcd.database.coreos.com/v1beta2
kind
: EtcdCluster
metadata
:
  name
: example-etcd-cluster
spec
:
  size
: 3
  version
: 3.1.10
$ kubectl create -f etcd-cluster-cr.yaml
etcdcluster.etcd.database.coreos.com/example-etcd-cluster created

$ kubectl get etcdclusters                          
NAME                   AGE
example-etcd-cluster   9s

稍等片刻,就能看到 etcd 相关的 pod 就自动创建了,相关的 etcd service 也一并创建了。

$ kubectl get pods
NAME                              READY   STATUS    RESTARTS   AGE
etcd-operator-d455d6d75-46vm4     1/1     Running   0          22m
example-etcd-cluster-4hzzqvjqtq   1/1     Running   0          85s
example-etcd-cluster-5sbgjbbbsg   1/1     Running   0          2m5s
example-etcd-cluster-lcdrrpc9jp   1/1     Running   0          45s

$ kubectl get svc
NAME                          TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)             AGE
example-etcd-cluster          ClusterIP   None           <none>        2379/TCP,2380/TCP   2m28s
example-etcd-cluster-client   ClusterIP   10.105.69.22   <none>        2379/TCP            2m28s
kubernetes                    ClusterIP   10.96.0.1      <none>        443/TCP             5d5h

测试这个 etcd 集群,往它上面存一些数据。

$ kubectl run --rm -i --tty etcdctl --image quay.io/coreos/etcd --restart=Never -- /bin/sh
/ # export ETCDCTL_API=3
/ # export ETCDCSVC=http://example-etcd-cluster-client:2379
/ # etcdctl --endpoints $ETCDCSVC put foo bar
OK
/ # etcdctl --endpoints $ETCDCSVC get foo
foo
bar

测试增加 etcd 节点数量。修改 etcd-cluster-cr.yaml:

$ cat etcd-cluster-cr.yaml
apiVersion
: etcd.database.coreos.com/v1beta2
kind
: EtcdCluster
metadata
:
  name
: example-etcd-cluster
spec
:
  size
: 4
  version
: 3.1.10

可以看到会自动增加一个 etcd pod.

$ kubectl apply -f etcd-cluster-cr.yaml
$ kubectl get pods
NAME                              READY   STATUS     RESTARTS   AGE
etcd-operator-d455d6d75-46vm4     1/1     Running    0          91m
example-etcd-cluster-4hzzqvjqtq   1/1     Running    0          70m
example-etcd-cluster-5sbgjbbbsg   1/1     Running    0          71m
example-etcd-cluster-lcdrrpc9jp   1/1     Running    0          70m
example-etcd-cluster-zmq8mwtfqk   0/1     Init:0/1   0          7s

尝试升级 etcd. 修改 etcd-cluster-cr.yaml 里面的版本信息。

$ cat etcd-cluster-cr.yaml
apiVersion
: etcd.database.coreos.com/v1beta2
kind
: EtcdCluster
metadata
:
  name
: example-etcd-cluster
spec
:
  size
: 4
  version
: 3.2.13

观察 pod 的变化,可以看到在一段时间后,describe 这些 pod, 可以看到版本也变了。

$ kubectl apply -f etcd-cluster-cr.yaml

$ kubectl describe pod/example-etcd-cluster-4hzzqvjqtq
Annotations:  etcd.version: 3.2.13

尝试删掉其中一个 pod, Operator 会自动拉起新的 pod.

$ kubectl get pods
NAME                              READY   STATUS    RESTARTS   AGE
etcd-operator-d455d6d75-46vm4     1/1     Running   0          98m
example-etcd-cluster-5sbgjbbbsg   1/1     Running   1          78m
example-etcd-cluster-d9x7mzkmfg   0/1     Running   0          33s
example-etcd-cluster-lcdrrpc9jp   1/1     Running   1          76m
example-etcd-cluster-zmq8mwtfqk   1/1     Running   1          6m47s
kubectl delete pod/example-etcd-cluster-4hzzqvjqtq

测试结束,删掉相关资源。

$ kubectl delete -f etcd-operator-sa.yaml
$ kubectl delete -f etcd-operator-role.yaml
$ kubectl delete -f etcd-operator-rolebinding.yaml
$ kubectl delete -f etcd-operator-crd.yaml
$ kubectl delete -f etcd-operator-deployment.yaml
$ kubectl delete -f etcd-cluster-cr.yaml

etcd Operator 的逻辑是怎样的?

作为经典的学习案例,我们可以查看 etcd Operator 是如何实现的。它的源码可以从[6]获得。

可以看到,除了控制节点的数量和版本之外,Operator 还提供了 backup/restore 的功能,对于这种有状态的应用,自动的备份和恢复可以提高 etcd 的容灾能力,无需运维人员手工操作。

Operator 的核心逻辑在 reconcile 方法上。Kubernetes 会时不时地触发 reconcile, 通常是在 CR 相关资源发生变动的时候触发,但也可以随时触发,这就要求 Operator/reconcile 的操作是幂等的。[2]

查看 etcd-operator/pkg/cluster/reconcile.go,可以看到 reconcile 方法的入口:

// reconcile reconciles cluster current state to desired state specified by spec.
// - it tries to reconcile the cluster to desired size.
// - if the cluster needs for upgrade, it tries to upgrade old member one by one.
func (c *Cluster) reconcile(pods []*v1.Pod) error {
    c.logger.Infoln("Start reconciling")
    defer c.logger.Infoln("Finish reconciling")

    defer func() {
        c.status.Size = c.members.Size()
    }()

    sp := c.cluster.Spec
    running := podsToMemberSet(pods, c.isSecureClient())
    if !running.IsEqual(c.members) || c.members.Size() != sp.Size {
        return c.reconcileMembers(running)
    }
    c.status.ClearCondition(api.ClusterConditionScaling)

    if needUpgrade(pods, sp) {
        c.status.UpgradeVersionTo(sp.Version)

        m := pickOneOldMember(pods, sp.Version)
        return c.upgradeOneMember(m.Name)
    }
    c.status.ClearCondition(api.ClusterConditionUpgrading)

    c.status.SetVersion(sp.Version)
    c.status.SetReadyCondition()

    return nil
}

它的逻辑是这样的[7]. 先调谐 etcd 节点的数量,再调谐 etcd 节点的版本,而且,在每次 reconcile 操作中,只修改一个资源。

查看 reconcileMembers 的实现。它会先剔除不属于 etcd 集群的但是又意外运行的 pod, 然后,将 pod 的数量调谐为 etcdCluster 指定的数量。

// reconcileMembers reconciles
// - running pods on k8s and cluster membership
// - cluster membership and expected size of etcd cluster
// Steps:
// 1. Remove all pods from running set that does not belong to member set.
// 2. L consist of remaining pods of runnings
// 3. If L = members, the current state matches the membership state. END.
// 4. If len(L) < len(members)/2 + 1, return quorum lost error.
// 5. Add one missing member. END.
func (c *Cluster) reconcileMembers(running etcdutil.MemberSet) error {
    c.logger.Infof("running members: %s", running)
    c.logger.Infof("cluster membership: %s", c.members)

    unknownMembers := running.Diff(c.members)
    if unknownMembers.Size() > 0 {
        c.logger.Infof("removing unexpected pods: %v", unknownMembers)
        for _, m := range unknownMembers {
            if err := c.removePod(m.Name); err != nil {
                return err
            }
        }
    }
    L := running.Diff(unknownMembers)

    if L.Size() == c.members.Size() {
        return c.resize()
    }

    if L.Size() < c.members.Size()/2+1 {
        return ErrLostQuorum
    }

    c.logger.Infof("removing one dead member")
    // remove dead members that doesn't have any running pods before doing resizing.
    return c.removeDeadMember(c.members.Diff(L).PickOne())
}

值得留意的是,无论是增加节点还是删除节点,在一次 reconcile 中,只增加或者删除一个节点。升级节点也是,我们可以看到很多 PickOne*** 的方法调用。

func (c *Cluster) resize() error {
    if c.members.Size() == c.cluster.Spec.Size {
        return nil
    }

    if c.members.Size() < c.cluster.Spec.Size {
        return c.addOneMember()
    }

    return c.removeOneMember()

Machine Config Operator

https://github.com/openshift/machine-config-operator
//TODO

用 Operator SDK 创建一个 Operator

那要怎么开始写一个 Operator 呢?要从头开始写各种 API 是件痛苦的事情。使用 Operator SDK, 我们可以用 Go 语言编写核心的逻辑,剩下的事情可以交给 SDK 来处理。Operator SDK 也支持 Ansible 和 Helm,不过相比于 Go, 它们的灵活性相对较低。

安装 Operator SDK 比较简单的方法是直接下载 binary.[9]

curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v0.19.2/operator-sdk-v0.19.2-x86_64-linux-gnu

动物书给的例子是用 v0.11.x 版本的 SDK, 目前看官方文档的最新的版本是 v0.19.2[10],它要求 Go 版本 > 1.13, 而我目前 RHEL8 的版本是 1.11.5,啥都不兼容。卸载 RHEL8 本身的 golang, 直接从 golang 网站下载最新版本安装。

按照 Operator SDK 给出的例子,用 operator-sdk 创建一个项目,并创建 API.

$ mkdir -p $HOME/projects/memcached-operator
$ cd $HOME/projects/memcached-operator
$ operator-sdk init --domain=example.com --repo=github.com/example-inc/memcached-operator

$ operator-sdk create api --group=cache --version=v1alpha1 --kind=Memcached

修改 api/v1alpha1/memcached_types.go,定义这个 Operator 的输入参数,以及状态值。

// MemcachedSpec defines the desired state of Memcached
type MemcachedSpec struct {
    // +kubebuilder:validation:Minimum=0
    // Size is the size of the memcached deployment
    Size int32 `json:"size"`
}

// MemcachedStatus defines the observed state of Memcached
type MemcachedStatus struct {
    // Nodes are the names of the memcached pods
    Nodes []string `json:"nodes"`
}

修改完后,用以下命令让 SDK 生成相关的 code.

$ make generate
$ make manifests

接下来修改 opertor 的主要逻辑。可以直接用官方提供的源码:
https://github.com/operator-framework/operator-sdk/blob/v0.19.x/example/kb-memcached-operator/memcached_controller.go.tmpl
将上述源码替换到 controllers/memcached_controller.go 中。

然后,build 这个 Operator.

$ make manifests
$ make install

要部署这个 Operator,我们需要将它的镜像上传到公共的 Repo 中。注册一个 quay.io 的账号,然后 docker-build 和 docker-push 到 quay 中。

$ docker login quay.io
$ export USERNAME=<quay-username>
$ make docker-build IMG=quay.io/$USERNAME/memcached-operator:v0.0.1
$ make docker-push IMG=quay.io/$USERNAME/memcached-operator:v0.0.1

我在 docker-build 的过程中,遇到了以下问题:

 failed to start the controlplane. retried 5 times: fork/exec /usr/local/kubebuilder/bin/etcd: no such file or directory
  occurred

这个通过安装 kubebuilder 得到解决。

ERRO[0000] No subuid ranges found for user "siwu" in /etc/subuid
make: *** [Makefile:71: docker-build] Error 1

这个通过将用户加到 subuid 和 subgid 解决。

$ cat /etc/subuid
rhel-liveuser:100000:65536
splunk:165536:65536
testuser:231072:65536
siwu:100000:65536

$ cat /etc/subgid                                                  
rhel-liveuser:100000:65536
splunk:165536:65536
testuser:231072:65536
siwu:100000:65536

另一个问题是卡在 go mod download 这一步,没有任何输出。

STEP 7: FROM e5d2f3503aad4e6233b93c52ecf34075c3763aa70fe293e807a46f8f80d3cef7 AS builder
STEP 8: RUN go mod download
^^ taking forever to download. no verbose info.

这是因为 RHEL8.2 自带的 podman 有已知的问题,使用 Fedora 32 最新的 podman 可以解决问题。

用 OLM (Operator Lifecycle Manager) 管理 Operators

参考文档

[1] 免费的动物书 https://www.redhat.com/cms/managed-files/cl-oreilly-kubernetes-operators-ebook-f21452-202001-en_2.pdf
[2] https://www.openshift.com/blog/7-best-practices-for-writing-kubernetes-operators-an-sre-perspective
[3] https://coreos.com/blog/introducing-operators.html
[4] https://coreos.com/blog/introducing-the-etcd-operator.html
[5] https://minikube.sigs.k8s.io/docs/start/
[6] https://github.com/coreos/etcd-operator
[7] https://github.com/coreos/etcd-operator/blob/master/doc/design/reconciliation.md
[8] https://developer.aliyun.com/article/685522
[9] https://sdk.operatorframework.io/docs/install-operator-sdk/
[10] https://sdk.operatorframework.io/docs/golang/quickstart/