一、介紹
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 URLkubeconfigPath
引數就是許可權配置檔案路徑。
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
}
我們看到程式碼引用了容器裡以下兩個檔案:
/var/run/secrets/kubernetes.io/serviceaccount/token
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
這兩個檔案就是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
,使許可權繫結生效。
我們再依次執行剛才的命令
- 列出所有Node/namespace
- 列出指定名稱空間的Deployment/Services
- 建立一個Deployment/Service
- 刪除Deployment/Service
可以看到都能正常的執行
總結
kubernetes operator開發跟平常開發軟體沒什麼區別,最終都是呼叫ApiServer的http介面。唯一需要關注的是許可權,operator只有擁有足夠的許可權就能實現你能想象的所有功能!
demo repo: https://gitee.com/longmon/k8s...