写点什么

Kubernetes Operator 开发入门

用户头像
侯生
关注
发布于: 刚刚
Kubernetes Operator 开发入门

Kubernetes Operator 开发入门


一、介绍

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.confcfg, 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

//列出指定命名空间的deploymentfunc 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)}//列出指定命名空间的Servicesfunc 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-appNAME                             READY   STATUS    RESTARTS   AGEk8s-test-stub-7bcdb4f5ff-bmcgf   1/1     Running   0          16mk8s-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-appNAME            TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGEk8s-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-appNo 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/v1kind: ClusterRolemetadata:  name: k8s-operator、  annotations:    app: k8s-operator-testrules:  - 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: v1kind: ServiceAccountmetadata:  name: k8s-test-operator  namespace: testing  annotations:    app: k8s-operator-testsecrets:  - name: k8s-test-operator-token-2hfbn
复制代码


绑定ClusterRoleServiceAccount:


apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRoleBindingmetadata:  name: k8s-test-operator-cluster  annotations:    app: k8s-operator-testroleRef:  apiGroup: rbac.authorization.k8s.io  kind: ClusterRole  name: k8s-operatorsubjects:  - 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-operator-tester.git

发布于: 刚刚阅读数: 3
用户头像

侯生

关注

还未添加个人签名 2016.12.05 加入

还未添加个人简介

评论

发布
暂无评论
Kubernetes Operator 开发入门