头图

一、介绍

Kubernetes operator是一种封装、部署、管理kubernetes应用的方法。它是Kubernetes的扩展软件,利用自定义资源管理应用及组件。
operator所有的操作都是调用Kubernetes Apiserver的接口,所以本质上它也是Apiserver的客户端软件。

本文是关于Kubernetes operator开发入门的教程,旨在带领有兴趣了解Operator开发的新手一窥Operator开发的基本流程。

二、 准备工作

  • 首先你需要有一个可用的kubernetes测试集群,如果你对kubernetes相关概念,集群建设还没有充分的了解,我建议你先了解这方面的知识
  • 本教程使用Go语言,需要你对Go语言的语法有简单的了解,Go语言也是kubernetes的开发语言。如果你使用其他语言也是没有问题的,进入到底层都是HTTP请求。官方的或社区的SDK也提供了多种语言可供选择,当你了解了其中原理,再使用其他语言进行开发应当是能得心应手
  • 我们将使用官方提供的k8s.io/client-go库来做测试, 它基本封装了对Kurbernetes的大部分操作。

示例代码目录如下:

├── Dockerfile
├── go.mod
├── go.sum
├── k8s           //客户端封装
│   └── client.go
├── LICENSE
├── main.go
├── Makefile
├── utils       //助手组件
│   ├── errs
│   │   └── errs.go
│   └── logs
│       └── slog.go
└── yaml
    ├── Deployment.yaml    //operator 部署文件
    └── ServiceAccount.yaml //权限绑定文件, 下文把权限配置,绑定定义分开了,这里放在一起也是可以的

作为演示,本教程我们主要关注以下几个方面的操作:

  • 列出所有Node/namespace
  • 列出指定命名空间的Deployment/Services
  • 创建一个Deployment/Service
  • 删除Deployment/Service

Operator的开发跟你平常开发的程序并无二致,它最重要的关注点是权限问题。Kubernetes有非常严格细致的权限设计,具体到每个资源每个操作。
所以我们的Operator软件并无严格要求必须运行在Kubernetes集群的容器里,只要权限配置得当,你可以直接运行go build出来的二进制包,甚至你可以直接在你的开发环境里go run都是可以的。通常我们为了开发调试方便,都会直接采用这种方式运行。

如果你对Kubernetes的权限管理并不熟悉,我建议你把你的代码放在你的测试集群的Master节点里运行,Master节点拥有集群的最高权限,省去了你配置权限的麻烦,把主要精力集中在业务逻辑上面。

三、开始

0x01、初始化客户端对象

首先我们需要在代码中实例化一个k8s.io/client-go/kubernetes.Clientset类型的对象变量,它就是我们整个Operator应用操作的客户端对象。

它可以由

  • func NewForConfig(c *rest.Config) (*Clientset, error)
  • func NewForConfigOrDie(c *rest.Config) *Clientset

两个函数实例化。
两个函数的区别:一个是实例化失败返回错误,另一个直接抛出异常。通常建议使用前者,由程序处理错误,而不是直接抛出异常。

两个方法都需要一个rest.Config对象作为参数, rest.Config最重要的配置项目就是权限配置。

SDK给我们提供了func BuildConfigFromFlags(masterUrl, kubeconfigPath string) (*restclient.Config, error) 方法来实例化rest.Config对象。

  • masterUrl参数就是主节点的Server URL
  • kubeconfigPath参数就是权限配置文件路径。

Master节点的权限配置文件通常是文件:/etc/kubernetes/admin.conf

kubernetes在部署master节点后通过会建议你把/etc/kubernetes/admin.conf文件拷贝到$HOME/.kube/config,所以你看到这两个地方的文件内容是一样的。

我们在传参的时候通常建议使用$HOME/.kube/config文件,以免因为文件权限问题出现异常,增加问题的复杂性。

BuildConfigFromFlags方法两个参数其实都是可以传空值的,如果我们的Operator程序在Kubernetes集群容器里运行,传空值(通过也是这么干的)进来它会使用容器里的默认权限配置。但是在非kubernetes集群容器里,它没有这个默认配置的,所以在非kubernetes集群容器我们需要显式把权限配置文件的路径传入。

说了一堆,我们直接上代码吧:


import "k8s.io/client-go/kubernetes"

//调用之前请确认文件存在,如果不存在使用/etc/kubernetes/admin.conf
cfg, err := clientcmd.BuildConfigFromFlags("", "/root/.kube/config") 
if err != nil {
    log.Fatalln(err)
}
k8sClient, err := kubernetes.NewForConfig(cfg)
if err != nil {
    log.Fatalln(err)
}

k8sClient就是我们频繁使用的客户端对象。

文章末尾附带了本次教程的代码repo,最终的代码经过调整与润色,保证最终的代码是可用的。

下面我们来开始展示“真正的技术”。

0x02、列出所有nodes/namespace

//ListNodes 获取所有节点
func ListNodes(g *gin.Context) {
    nodes, err := k8sClient.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
    if err != nil {
        g.Error(err)
        return
    }
    g.JSON(0, nodes)
}

//ListNamespace 获取所有命令空间
func ListNamespace(g *gin.Context) {
    ns, err := k8sClient.CoreV1().Namespaces().List(context.Background(),metav1.ListOptions{})
    if err != nil {
        g.Error(err)
        return
    }
    g.JSON(0, ns)
}

为简单,我们把接口返回的数据不作任务处理直接打印出来。

返回内容太多,我就不把内容贴出来了。从返回内容我们可以看到节点信息包含了

  • 系统信息
  • 节点状态
  • 节点事件
  • 资源使用量
  • 节点标签,注解,创建时间等
  • 节点本地的镜像,容器组

不一一例举了,有兴趣的读者在自己的环境运行起来看看输出结果。

下面是namespace打印出来的结果,截取了一个命名空间的数据。

{
    "metadata": {
        "resourceVersion": "190326"
    },
    "items": [
        {
            "metadata": {
                "name": "default",
                "uid": "acf4b9e4-b1ae-4b7a-bbdc-b65f088e14ec",
                "resourceVersion": "208",
                "creationTimestamp": "2021-09-24T11:17:29Z",
                "labels": {
                    "kubernetes.io/metadata.name": "default"
                },
                "managedFields": [
                    {
                        "manager": "kube-apiserver",
                        "operation": "Update",
                        "apiVersion": "v1",
                        "time": "2021-09-24T11:17:29Z",
                        "fieldsType": "FieldsV1",
                        "fieldsV1": {
                            "f:metadata": {
                                "f:labels": {
                                    ".": {},
                                    "f:kubernetes.io/metadata.name": {}
                                }
                            }
                        }
                    }
                ]
            },
            "spec": {
                "finalizers": [
                    "kubernetes"
                ]
            },
            "status": {
                "phase": "Active"
            }
        },
        ... ...
    ]
}

0x03、列出指定命名空间的Deployment/Services

//列出指定命名空间的deployment
func ListDeployment(g *gin.Context) {
    ns := g.Query("ns")
    
    dps, err := k8sClient.AppsV1().Deployments(ns).List(context.Background(), metav1.ListOptions{})
    if err != nil {
        g.Error(err)
        return
    }
    g.JSON(200, dps)
}
//列出指定命名空间的Services
func ListService(g *gin.Context) {
    ns := g.Query("ns")

    svc, err := k8sClient.CoreV1().Services(ns).List(context.Background(), metav1.ListOptions{})
    if err != nil {
        g.Error(err)
        return
    }
    g.JSON(200, svc)
}

通过参数指定命名空间。
我们来看看返回结果:

# deployment
{
    ... ...
    "items": [
        {
            "metadata": {
                "name": "nginx",
                "namespace": "testing",
                "labels": {
                    "k8s.kuboard.cn/layer": "web",
                    "k8s.kuboard.cn/name": "nginx"
                },
                ... ...
            },
            "spec": {
                "replicas": 2,
                "selector": {
                    "matchLabels": {
                        "k8s.kuboard.cn/layer": "web",
                        "k8s.kuboard.cn/name": "nginx"
                    }
                },
                "template": {
                    "metadata": {
                        "labels": {
                            "k8s.kuboard.cn/layer": "web",
                            "k8s.kuboard.cn/name": "nginx"
                        }
                    },
                    "spec": {
                        "containers": [
                            {
                                "name": "nginx",
                                "image": "nginx:alpine",
                                ... ...
                            }
                        ],
                    }
                },
                "strategy": {
                    "type": "RollingUpdate",
                    "rollingUpdate": {
                        "maxUnavailable": "25%",
                        "maxSurge": "25%"
                    }
                },
            },
            "status": ...
        }
        ... ...
    ]
}

# Services
{
    "items": [
        {
            "metadata": {
                "name": "nginx",
                "namespace": "testing",
                "labels": {
                    "k8s.kuboard.cn/layer": "web",
                    "k8s.kuboard.cn/name": "nginx"
                },
                "managedFields": [...]
            },
            "spec": {
                "ports": [
                    {
                        "name": "nkcers",
                        "protocol": "TCP",
                        "port": 8080,
                        "targetPort": 80
                    }
                ],
                "selector": {
                    "k8s.kuboard.cn/layer": "web",
                    "k8s.kuboard.cn/name": "nginx"
                },
                "clusterIP": "10.96.55.66",
                "clusterIPs": [
                    "10.96.55.66"
                ],
                "type": "ClusterIP",
                "sessionAffinity": "None",
                "ipFamilies": [
                    "IPv4"
                ],
                "ipFamilyPolicy": "SingleStack"
            },
            "status": ...
        }
        ... ...
    ]
}

从结果来看testing命名空间下有一个名为nginxDeployment,使用的是nginx:alpine镜像。一个名为nginxServiceClusterIP形式映射8080端口到同名Deployment的80端口。

0x04 创建一个Deployment/Service

func CreateDeployment(g *gin.Context) {
    var replicas int32 = 2
    var AutomountServiceAccountTokenYes bool = true

    deployment := &apiAppv1.Deployment{
        TypeMeta:   metav1.TypeMeta{
            Kind:       "Deployment",
            APIVersion: "apps/v1",
        },
        ObjectMeta: metav1.ObjectMeta{
            Name:  "k8s-test-stub",
            Namespace: "testing",
            Labels: map[string]string{
                "app": "k8s-test-app",
            },
            Annotations: map[string]string{
                "creator":"k8s-operator-test",
            },
        },
        Spec: apiAppv1.DeploymentSpec{
            Selector: &metav1.LabelSelector{
                MatchLabels: map[string]string{
                    "app": "k8s-test-app",
                },
            },
            Replicas:  &replicas,
            Template: v1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{
                    Labels: map[string]string{
                        "app": "k8s-test-app",
                    },
                },
                Spec:v1.PodSpec{
                    Containers: []apiCorev1.Container{
                        {
                            Name:  "nginx",
                            Image: "nginx:alpine",
                        },
                    },
                    RestartPolicy: "Always",
                    DNSPolicy: "ClusterFirst",
                    NodeSelector: nil,
                    ServiceAccountName: "",
                    AutomountServiceAccountToken:  &AutomountServiceAccountTokenYes,
                },
            },
            Strategy: apiAppv1.DeploymentStrategy{
                Type: "RollingUpdate",
                RollingUpdate: &apiAppv1.RollingUpdateDeployment{
                    MaxUnavailable: &intstr.IntOrString{
                        Type:   intstr.String,
                        IntVal: 0,
                        StrVal: "25%",
                    },
                    MaxSurge: &intstr.IntOrString{
                        Type: intstr.String,
                        IntVal: 0,
                        StrVal: "25%",
                    },
                },
            },
        },
    }

    dp, err := k8sClient.AppsV1().Deployments("testing").Create(context.Background(), deployment, metav1.CreateOptions{})
    if err != nil {
        g.AbortWithStatusJSON(500, err)
        return
    }
    g.JSON(200, dp)
}

上面的代码就是在testing命名空间创建一个名为k8s-test-stubDeployment。容器使用的是nginx:alpine镜像,replicas指定为2.配置精简了很多非必要的配置项。
执行成功后我们可以看到两个pod已经启动了:

root@main ~# kubectl get pods -n testing --selector=app=k8s-test-app
NAME                             READY   STATUS    RESTARTS   AGE
k8s-test-stub-7bcdb4f5ff-bmcgf   1/1     Running   0          16m
k8s-test-stub-7bcdb4f5ff-cmng8   1/1     Running   0          16m

接下来我们给这个Deployment创建Service,让它可以对外提供服务,代码如下:

func CreateService(g *gin.Context) {
    svc := &apiCorev1.Service{
        TypeMeta:   metav1.TypeMeta{
            Kind:       "Service",
            APIVersion: "v1",
        },
        ObjectMeta: metav1.ObjectMeta{
            Name: "k8s-test-stub",
            Namespace: "testing",
            Labels: map[string]string{
                "app": "k8s-test-app",
            },
            Annotations: map[string]string{
                "creator":"k8s-test-operator",
            },
        },
        Spec:apiCorev1.ServiceSpec{
            Ports: []apiCorev1.ServicePort{
                {
                    Name:        "http",
                    Protocol:    "TCP", //注意这里必须为大写
                    Port:        80,
                    TargetPort:  intstr.IntOrString{
                        Type:   intstr.Int,
                        IntVal: 80,
                        StrVal: "",
                    },
                    NodePort:    0,
                },
            },
            Selector: map[string]string{
                "app": "k8s-test-app",
            },
            Type: "NodePort",
        },
    }

    svs, err := k8sClient.CoreV1().Services("testing").Create(context.Background(), svc, metav1.CreateOptions{})
    if err != nil {
        g.AbortWithStatusJSON(500, err)
        return
    }
    g.JSON(200, svs)
}

上面代码为k8s-test-stub Deployment创建一个同名的Service。以NodePort方式对外提供服务

root@main ~# kubectl get svc -n testing --selector=app=k8s-test-app
NAME            TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
k8s-test-stub   NodePort    10.96.138.143   <none>        80:30667/TCP   113s

0x05 删除Deployment/Service

func DeleteDeploymentAndService(g *gin.Context) {
    //删除Deployment
    err := k8sClient.AppsV1().Deployments("testing").Delete(context.Background(), "k8s-test-stub", metav1.DeleteOptions{})
    if err != nil {
        g.AbortWithStatusJSON(500, err)
        return
    }
    //删除Service
    err = k8sClient.CoreV1().Services("testing").Delete(context.Background(), "k8s-test-stub", metav1.DeleteOptions{})
    if err != nil {
        g.AbortWithStatusJSON(500, err)
        return
    }
    g.JSON(200, nil)
}

上述代码删除了testing命名空间中名为k8s-test-stubDeployment和对应的Service

root@main ~# kubectl get deployment,svc -n testing --selector=app=k8s-test-app
No resources found in testing namespace.

四、让你的Operator运行在Kubernetes集群里

前面的代码示例演示了创建命名空间,创建和删除Deployment、Service的基本操作,作为抛砖引玉,更多的操作留待读者去探索分享。

前面的示例都是直接运行在master主节点的Host环境里,方便我们引用主节点的权限配置。
我们的operator最终是要运行在k8s集群里的。如果不进行必要的权限设置,我们大概率会得到类似以下的错误:

{
    "ErrStatus": {
        "metadata": {},
        "status": "Failure",
        "message": "nodes is forbidden: User \"system:serviceaccount:testing:default\" cannot list resource \"nodes\" in API group \"\" at the cluster scope",
        "reason": "Forbidden",
        "details": {
            "kind": "nodes"
        },
        "code": 403
    }
}

上面的返回结果就是nodes操作被禁止了,因为operator没有足够的运行权限。

那如何赋予operator足够的权限来满足我们的需求?

前文提到过k8s有着严格详尽的权限设计,为了安全考虑,集群里普通的容器并没有赋予过多的权限。每个容器默认拥有的权限无法满足大部分operator的功能需求。

我们先来看看Operator在容器里是如何获取权限配置的。

我们先从SDK的代码开始。我在SDK中可以找到以下代码:

func BuildConfigFromFlags(masterUrl, kubeconfigPath string) (*restclient.Config, error) {
    if kubeconfigPath == "" && masterUrl == "" {
        klog.Warning("Neither --kubeconfig nor --master was specified.  Using the inClusterConfig.  This might not work.")
        kubeconfig, err := restclient.InClusterConfig()
        if err == nil {
            return kubeconfig, nil
        }
        klog.Warning("error creating inClusterConfig, falling back to default config: ", err)
    }
    return NewNonInteractiveDeferredLoadingClientConfig(
        &ClientConfigLoadingRules{ExplicitPath: kubeconfigPath},
        &ConfigOverrides{ClusterInfo: clientcmdapi.Cluster{Server: masterUrl}}).ClientConfig()
}

这段代码是构建客户端配置的方法。我们前面在调用这部分代码的时候输入了kubeconfigPath参数,把master节点的权限文件传进来了,所以我们的operator拥有了超级管理员的所有权限。虽然方便,也带了极大的安全风险,Operator拥有所有权限可以干很多坏事。

从代码可以看到BuildConfigFromFlags函数是允许传入参数空值,在传入的参数为空的时候会调用restclient.InClusterConfig()方法,我们进入到这个方法:

func InClusterConfig() (*Config, error) {
    const (
        tokenFile  = "/var/run/secrets/kubernetes.io/serviceaccount/token"
        rootCAFile = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
    )
    host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")
    if len(host) == 0 || len(port) == 0 {
        return nil, ErrNotInCluster
    }

    token, err := ioutil.ReadFile(tokenFile)
    if err != nil {
        return nil, err
    }

    tlsClientConfig := TLSClientConfig{}

    if _, err := certutil.NewPool(rootCAFile); err != nil {
        klog.Errorf("Expected to load root CA config from %s, but got err: %v", rootCAFile, err)
    } else {
        tlsClientConfig.CAFile = rootCAFile
    }

    return &Config{
        Host:            "https://" + net.JoinHostPort(host, port),
        TLSClientConfig: tlsClientConfig,
        BearerToken:     string(token),
        BearerTokenFile: tokenFile,
    }, nil
}

我们看到代码引用了容器里以下两个文件:

  • /var/run/secrets/kubernetes.io/serviceaccount/token
  • /var/run/secrets/kubernetes.io/serviceaccount/ca.crt

这两个文件就是k8s集群赋予容器的默认权限配置。它其实对应的就是当前命名空间里一个名为defaultServiceAccount(每个命名空间在创建的时候都会附带创建一个defaultServiceAccount并生成一个名称类似default-token-xxxx密文和名为kube-root-ca.crt字典)。上述两个文件映射的就是这两个配置。

更多关于ServiceAccount的知识,请参与官方的文档!

默认的default ServiceAccount满足不了Operator的需要,我们需要创建一个新的ServiceAccount同时赋予它足够的权限。

首先需要定义ClusterRole

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: k8s-operator、
  annotations:
    app: k8s-operator-test
rules:
  - apiGroups:
      - apps
    resources:
      - daemonsets
      - deployments
      - replicasets
      - statefulsets
    verbs:
      - create
      - delete
      - get
      - list
      - update
      - watch
      - patch
  - apiGroups:
      - ''
    resources:
      - nodes
      - namespaces
      - pods
      - services
      - serviceaccounts
    verbs:
      - create
      - delete
      - get
      - list
      - patch
      - update
      - watch

创建新的ServiceAccount,名为k8s-test-operator

apiVersion: v1
kind: ServiceAccount
metadata:
  name: k8s-test-operator
  namespace: testing
  annotations:
    app: k8s-operator-test
secrets:
  - name: k8s-test-operator-token-2hfbn

绑定ClusterRoleServiceAccount:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: k8s-test-operator-cluster
  annotations:
    app: k8s-operator-test
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: k8s-operator
subjects:
  - kind: ServiceAccount
    name: k8s-test-operator
    namespace: testing

执行kubbectl apply -f *.yaml让权限绑定生效,然后我们在Deployment的配置文件中的以下位置指定新的角色名

deployment.Spec.Template.Spec.ServiceAccountName: "k8s-test-operator"

我们可以直接执行:kubectl edit deployment operator-test -n testing找到Spec.Template.Spec添加serviceAccountName: k8s-test-operator,使权限绑定生效。

我们再依次执行刚才的命令

  • 列出所有Node/namespace
  • 列出指定命名空间的Deployment/Services
  • 创建一个Deployment/Service
  • 删除Deployment/Service
    可以看到都能正常的执行

总结

kubernetes operator开发跟平常开发软件没什么区别,最终都是调用ApiServer的http接口。唯一需要关注的是权限,operator只有拥有足够的权限就能实现你能想象的所有功能!

demo repo: https://gitee.com/longmon/k8s...


longmon
316 声望6 粉丝