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 //权限绑定文件, 下文把权限配置,绑定定义分开了,这里放在一起也是可以的
复制代码
作为演示,本教程我们主要关注以下几个方面的操作:
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
对象。
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
命名空间下有一个名为nginx
的Deployment
,使用的是nginx:alpine
镜像。一个名为nginx
的Service
以ClusterIP
形式映射 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-stub
的Deployment
。容器使用的是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-stub
的Deployment
和对应的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
}
复制代码
我们看到代码引用了容器里以下两个文件:
这两个文件就是 k8s 集群赋予容器的默认权限配置。它其实对应的就是当前命名空间里一个名为default
的ServiceAccount
(每个命名空间在创建的时候都会附带创建一个default
的ServiceAccount
并生成一个名称类似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
复制代码
绑定ClusterRole
到ServiceAccount
:
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
,使权限绑定生效。
我们再依次执行刚才的命令
总结
kubernetes operator 开发跟平常开发软件没什么区别,最终都是调用 ApiServer 的 http 接口。唯一需要关注的是权限,operator 只有拥有足够的权限就能实现你能想象的所有功能!
demo repo: https://gitee.com/longmon/k8s-operator-tester.git
评论