Kubernetes Operator模式技术预研:自定义资源控制器开发与部署最佳实践

 
更多

Kubernetes Operator模式技术预研:自定义资源控制器开发与部署最佳实践

引言

随着云原生技术的快速发展,Kubernetes已成为容器编排的事实标准。然而,传统的Deployment、Service等核心资源难以满足复杂应用的自动化运维需求。Operator模式应运而生,它通过将领域专业知识编码到控制器中,实现了对复杂应用的自动化管理。本文将深入探讨Operator模式的核心原理、实现机制,并提供完整的开发框架选型建议和生产环境部署最佳实践。

1. Operator模式概述

1.1 什么是Operator模式

Operator模式是CoreOS提出的一种在Kubernetes上运行复杂有状态应用的方法。它本质上是一个自定义控制器,用于管理特定类型的资源。Operator通过监听自定义资源(Custom Resource)的变化,自动执行相应的操作来维护应用的状态。

1.2 Operator的核心组件

Operator主要由三个核心组件构成:

  1. Custom Resource Definition (CRD): 定义自定义资源的API接口
  2. Custom Resource: 基于CRD创建的具体资源实例
  3. Controller: 监听并处理自定义资源变化的核心逻辑

1.3 Operator的优势

  • 自动化运维: 减少人工干预,提高运维效率
  • 状态管理: 能够处理复杂的有状态应用生命周期
  • 可扩展性: 支持自定义业务逻辑的集成
  • 标准化: 提供统一的管理界面和操作方式

2. CRD定义详解

2.1 CRD基础结构

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: databases.example.com
spec:
  group: example.com
  versions:
  - name: v1
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              replicas:
                type: integer
                minimum: 1
              version:
                type: string
                default: "13"
              storage:
                type: object
                properties:
                  size:
                    type: string
                  class:
                    type: string
            required:
            - replicas
            - version
          status:
            type: object
            properties:
              phase:
                type: string
              replicas:
                type: integer
  scope: Namespaced
  names:
    plural: databases
    singular: database
    kind: Database

2.2 CRD字段详解

group: 自定义资源的API组,通常使用公司域名反向命名

versions: 定义资源的版本信息,支持多版本共存

schema: OpenAPI v3规范定义的资源结构

scope: 资源作用域,可选Namespaced或Cluster

names: 资源的命名规则

2.3 高级CRD特性

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: applications.example.com
spec:
  group: example.com
  versions:
  - name: v1
    schema:
      openAPIV3Schema:
        type: object
        properties:
          spec:
            type: object
            properties:
              # 嵌套对象验证
              config:
                type: object
                additionalProperties:
                  type: string
                description: 配置参数
              # 枚举类型
              environment:
                type: string
                enum: [production, staging, development]
                default: development
              # 数组类型
              ports:
                type: array
                items:
                  type: object
                  properties:
                    containerPort:
                      type: integer
                    protocol:
                      type: string
                      enum: [TCP, UDP]
                  required:
                  - containerPort
                  - protocol
              # 对象引用
              serviceAccountRef:
                type: object
                properties:
                  name:
                    type: string
                  namespace:
                    type: string
                required:
                - name
            required:
            - environment
            - ports
        # 必需字段验证
        required:
        - spec
  scope: Namespaced
  names:
    plural: applications
    singular: application
    kind: Application
    listKind: ApplicationList

3. Operator控制器开发

3.1 控制器架构设计

一个典型的Operator控制器包含以下组件:

  • Reconciler: 核心协调逻辑
  • Client: Kubernetes API客户端
  • Informer: 资源事件监听器
  • Recorder: 事件记录器
  • Metrics: 指标收集器

3.2 使用Operator SDK开发

3.2.1 环境准备

# 安装Operator SDK
curl -L https://github.com/operator-framework/operator-sdk/releases/download/v1.29.0/operator-sdk_linux_amd64 -o operator-sdk
chmod +x operator-sdk
sudo mv operator-sdk /usr/local/bin/

# 创建项目
operator-sdk init --domain example.com --repo github.com/example/database-operator
operator-sdk create api --group db --version v1 --kind Database

3.2.2 实现Reconcile逻辑

// controllers/database_controller.go
package controllers

import (
    "context"
    "fmt"
    "time"

    appsv1 "k8s.io/api/apps/v1"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/errors"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/log"

    dbv1 "github.com/example/database-operator/api/v1"
)

// DatabaseReconciler reconciles a Database object
type DatabaseReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

//+kubebuilder:rbac:groups=db.example.com,resources=databases,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=db.example.com,resources=databases/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=db.example.com,resources=databases/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=services,verbs=get;list;watch;create;update;patch;delete

// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    log := log.FromContext(ctx)
    
    // 获取Database资源
    database := &dbv1.Database{}
    if err := r.Get(ctx, req.NamespacedName, database); err != nil {
        if errors.IsNotFound(err) {
            log.Info("Database resource not found. Ignoring since object must be deleted")
            return ctrl.Result{}, nil
        }
        log.Error(err, "Failed to get Database")
        return ctrl.Result{}, err
    }

    // 检查是否需要删除
    if database.DeletionTimestamp != nil {
        return r.handleDelete(ctx, database)
    }

    // 检查是否存在finalizer
    if !containsString(database.Finalizers, databaseFinalizerName) {
        if err := r.addFinalizer(ctx, database); err != nil {
            return ctrl.Result{}, err
        }
    }

    // 处理数据库部署
    if err := r.reconcileDeployment(ctx, database); err != nil {
        return ctrl.Result{}, err
    }

    // 处理服务
    if err := r.reconcileService(ctx, database); err != nil {
        return ctrl.Result{}, err
    }

    // 更新状态
    if err := r.updateStatus(ctx, database); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{RequeueAfter: time.Minute}, nil
}

// reconcileDeployment 创建或更新数据库部署
func (r *DatabaseReconciler) reconcileDeployment(ctx context.Context, database *dbv1.Database) error {
    deployment := &appsv1.Deployment{
        ObjectMeta: metav1.ObjectMeta{
            Name:      database.Name,
            Namespace: database.Namespace,
        },
    }

    // 检查部署是否存在
    if err := r.Get(ctx, client.ObjectKeyFromObject(deployment), deployment); err != nil {
        if errors.IsNotFound(err) {
            // 创建新的部署
            return r.createDeployment(ctx, database)
        }
        return err
    }

    // 更新现有部署
    return r.updateDeployment(ctx, database, deployment)
}

// createDeployment 创建数据库部署
func (r *DatabaseReconciler) createDeployment(ctx context.Context, database *dbv1.Database) error {
    deployment := &appsv1.Deployment{
        ObjectMeta: metav1.ObjectMeta{
            Name:      database.Name,
            Namespace: database.Namespace,
            Labels: map[string]string{
                "app": database.Name,
            },
        },
        Spec: appsv1.DeploymentSpec{
            Replicas: &database.Spec.Replicas,
            Selector: &metav1.LabelSelector{
                MatchLabels: map[string]string{
                    "app": database.Name,
                },
            },
            Template: corev1.PodTemplateSpec{
                ObjectMeta: metav1.ObjectMeta{
                    Labels: map[string]string{
                        "app": database.Name,
                    },
                },
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{
                        {
                            Name:  "database",
                            Image: fmt.Sprintf("postgres:%s", database.Spec.Version),
                            Ports: []corev1.ContainerPort{
                                {
                                    ContainerPort: 5432,
                                },
                            },
                            Env: []corev1.EnvVar{
                                {
                                    Name: "POSTGRES_PASSWORD",
                                    ValueFrom: &corev1.EnvVarSource{
                                        SecretKeyRef: &corev1.SecretKeySelector{
                                            LocalObjectReference: corev1.LocalObjectReference{
                                                Name: database.Name + "-secret",
                                            },
                                            Key: "password",
                                        },
                                    },
                                },
                            },
                        },
                    },
                },
            },
        },
    }

    if err := ctrl.SetControllerReference(database, deployment, r.Scheme); err != nil {
        return err
    }

    return r.Create(ctx, deployment)
}

// updateDeployment 更新数据库部署
func (r *DatabaseReconciler) updateDeployment(ctx context.Context, database *dbv1.Database, deployment *appsv1.Deployment) error {
    // 更新部署配置
    deployment.Spec.Replicas = &database.Spec.Replicas
    deployment.Spec.Template.Spec.Containers[0].Image = fmt.Sprintf("postgres:%s", database.Spec.Version)
    
    return r.Update(ctx, deployment)
}

// reconcileService 创建或更新服务
func (r *DatabaseReconciler) reconcileService(ctx context.Context, database *dbv1.Database) error {
    service := &corev1.Service{
        ObjectMeta: metav1.ObjectMeta{
            Name:      database.Name,
            Namespace: database.Namespace,
        },
    }

    if err := r.Get(ctx, client.ObjectKeyFromObject(service), service); err != nil {
        if errors.IsNotFound(err) {
            return r.createService(ctx, database)
        }
        return err
    }

    return r.updateService(ctx, database, service)
}

// createService 创建服务
func (r *DatabaseReconciler) createService(ctx context.Context, database *dbv1.Database) error {
    service := &corev1.Service{
        ObjectMeta: metav1.ObjectMeta{
            Name:      database.Name,
            Namespace: database.Namespace,
            Labels: map[string]string{
                "app": database.Name,
            },
        },
        Spec: corev1.ServiceSpec{
            Selector: map[string]string{
                "app": database.Name,
            },
            Ports: []corev1.ServicePort{
                {
                    Port:       5432,
                    TargetPort: intstr.FromInt(5432),
                },
            },
            Type: corev1.ServiceTypeClusterIP,
        },
    }

    if err := ctrl.SetControllerReference(database, service, r.Scheme); err != nil {
        return err
    }

    return r.Create(ctx, service)
}

// updateStatus 更新资源状态
func (r *DatabaseReconciler) updateStatus(ctx context.Context, database *dbv1.Database) error {
    // 获取当前部署状态
    deployment := &appsv1.Deployment{}
    if err := r.Get(ctx, client.ObjectKey{Name: database.Name, Namespace: database.Namespace}, deployment); err != nil {
        return err
    }

    // 更新状态
    database.Status.Phase = "Running"
    database.Status.Replicas = *deployment.Spec.Replicas
    
    return r.Status().Update(ctx, database)
}

3.3 自定义逻辑实现

3.3.1 数据库初始化逻辑

// 初始化数据库集群
func (r *DatabaseReconciler) initializeDatabase(ctx context.Context, database *dbv1.Database) error {
    // 创建初始化脚本
    initScript := &corev1.ConfigMap{
        ObjectMeta: metav1.ObjectMeta{
            Name:      database.Name + "-init",
            Namespace: database.Namespace,
        },
        Data: map[string]string{
            "init.sql": `CREATE DATABASE myapp;
                         CREATE USER myuser WITH PASSWORD 'mypassword';
                         GRANT ALL PRIVILEGES ON DATABASE myapp TO myuser;`,
        },
    }

    if err := r.Create(ctx, initScript); err != nil {
        if !errors.IsAlreadyExists(err) {
            return err
        }
    }

    // 在部署中挂载初始化脚本
    return r.patchDeploymentWithInitScript(ctx, database, initScript)
}

// patchDeploymentWithInitScript 将初始化脚本挂载到部署中
func (r *DatabaseReconciler) patchDeploymentWithInitScript(ctx context.Context, database *dbv1.Database, script *corev1.ConfigMap) error {
    deployment := &appsv1.Deployment{}
    if err := r.Get(ctx, client.ObjectKey{Name: database.Name, Namespace: database.Namespace}, deployment); err != nil {
        return err
    }

    // 添加初始化容器
    initContainer := corev1.Container{
        Name:    "init-db",
        Image:   "busybox:latest",
        Command: []string{"/bin/sh", "-c"},
        Args:    []string{"cp /scripts/init.sql /tmp/"},
        VolumeMounts: []corev1.VolumeMount{
            {
                Name:      "init-scripts",
                MountPath: "/scripts",
            },
            {
                Name:      "tmp",
                MountPath: "/tmp",
            },
        },
    }

    // 添加卷
    volume := corev1.Volume{
        Name: "init-scripts",
        VolumeSource: corev1.VolumeSource{
            ConfigMap: &corev1.ConfigMapVolumeSource{
                LocalObjectReference: corev1.LocalObjectReference{
                    Name: script.Name,
                },
            },
        },
    }

    // 更新部署
    deployment.Spec.Template.Spec.InitContainers = append(deployment.Spec.Template.Spec.InitContainers, initContainer)
    deployment.Spec.Template.Spec.Volumes = append(deployment.Spec.Template.Spec.Volumes, volume)

    return r.Update(ctx, deployment)
}

3.3.2 健康检查机制

// 健康检查函数
func (r *DatabaseReconciler) checkDatabaseHealth(ctx context.Context, database *dbv1.Database) (bool, error) {
    // 获取Pod列表
    podList := &corev1.PodList{}
    err := r.List(ctx, podList, client.InNamespace(database.Namespace), client.MatchingLabels{"app": database.Name})
    if err != nil {
        return false, err
    }

    // 检查所有Pod是否就绪
    for _, pod := range podList.Items {
        if !isPodReady(pod) {
            return false, nil
        }
    }

    // 进行数据库连接测试
    return r.testDatabaseConnection(ctx, database)
}

// isPodReady 检查Pod是否就绪
func isPodReady(pod corev1.Pod) bool {
    for _, condition := range pod.Status.Conditions {
        if condition.Type == corev1.PodReady {
            return condition.Status == corev1.ConditionTrue
        }
    }
    return false
}

// testDatabaseConnection 测试数据库连接
func (r *DatabaseReconciler) testDatabaseConnection(ctx context.Context, database *dbv1.Database) (bool, error) {
    // 这里可以实现具体的数据库连接测试逻辑
    // 返回true表示健康,false表示不健康
    return true, nil
}

4. 开发框架选型

4.1 Operator SDK vs KubeBuilder

Operator SDK优势

# Operator SDK项目结构
my-operator/
├── api/
│   └── v1/
│       ├── database_types.go
│       └── groupversion_info.go
├── controllers/
│   └── database_controller.go
├── hack/
│   └── boilerplate.go.txt
├── main.go
└── Makefile

KubeBuilder优势

// KubeBuilder使用示例
// main.go
import (
    "flag"
    "os"
    
    "k8s.io/apimachinery/pkg/runtime"
    clientgoscheme "k8s.io/client-go/kubernetes/scheme"
    _ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/log/zap"
    
    dbv1 "my-operator/api/v1"
    "my-operator/controllers"
)

var (
    scheme   = runtime.NewScheme()
    setupLog = ctrl.Log.WithName("setup")
)

func init() {
    _ = clientgoscheme.AddToScheme(scheme)
    _ = dbv1.AddToScheme(scheme)
}

func main() {
    var metricsAddr string
    var enableLeaderElection bool
    flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.")
    flag.BoolVar(&enableLeaderElection, "enable-leader-election", false,
        "Enable leader election for controller manager. "+
            "Enabling this will ensure there is only one active controller manager.")
    flag.Parse()

    ctrl.SetLogger(zap.New(zap.UseDevMode(true)))

    mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
        Scheme:             scheme,
        MetricsBindAddress: metricsAddr,
        Port:               9443,
        LeaderElection:     enableLeaderElection,
        LeaderElectionID:   "db.example.com",
    })
    if err != nil {
        setupLog.Error(err, "unable to start manager")
        os.Exit(1)
    }

    if err = (&controllers.DatabaseReconciler{
        Client: mgr.GetClient(),
        Scheme: mgr.GetScheme(),
    }).SetupWithManager(mgr); err != nil {
        setupLog.Error(err, "unable to create controller", "controller", "Database")
        os.Exit(1)
    }

    setupLog.Info("starting manager")
    if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
        setupLog.Error(err, "problem running manager")
        os.Exit(1)
    }
}

4.2 自定义控制器开发

// 自定义控制器实现
package main

import (
    "context"
    "fmt"
    "time"

    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    "k8s.io/apimachinery/pkg/runtime"
    ctrl "sigs.k8s.io/controller-runtime"
    "sigs.k8s.io/controller-runtime/pkg/client"
    "sigs.k8s.io/controller-runtime/pkg/controller"
    "sigs.k8s.io/controller-runtime/pkg/handler"
    "sigs.k8s.io/controller-runtime/pkg/log"
    "sigs.k8s.io/controller-runtime/pkg/reconcile"
    "sigs.k8s.io/controller-runtime/pkg/source"
)

type CustomReconciler struct {
    client.Client
    Scheme *runtime.Scheme
}

func (r *CustomReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)
    logger.Info("Reconciling custom resource", "name", req.Name, "namespace", req.Namespace)

    // 业务逻辑实现
    result, err := r.processCustomResource(ctx, req)
    if err != nil {
        logger.Error(err, "Error processing custom resource")
        return ctrl.Result{}, err
    }

    return result, nil
}

func (r *CustomReconciler) processCustomResource(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    // 实现具体的处理逻辑
    return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
}

func (r *CustomReconciler) SetupWithManager(mgr ctrl.Manager) error {
    // 创建控制器
    c, err := controller.New("custom-controller", mgr, controller.Options{
        Reconciler: r,
    })
    if err != nil {
        return err
    }

    // 监听自定义资源
    if err := c.Watch(&source.Kind{Type: &corev1.Pod{}}, &handler.EnqueueRequestForObject{}); err != nil {
        return err
    }

    return nil
}

5. 生产环境部署最佳实践

5.1 Helm Chart部署

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "operator.fullname" . }}
  labels:
    {{- include "operator.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "operator.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      {{- with .Values.podAnnotations }}
      annotations:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      labels:
        {{- include "operator.selectorLabels" . | nindent 8 }}
    spec:
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      serviceAccountName: {{ include "operator.serviceAccountName" . }}
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      containers:
        - name: {{ .Chart.Name }}
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - name: http
              containerPort: 8080
              protocol: TCP
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 15
            periodSeconds: 20
          readinessProbe:
            httpGet:
              path: /readyz
              port: http
            initialDelaySeconds: 5
            periodSeconds: 10
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.affinity }}
      affinity:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}

5.2 RBAC权限配置

# rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: operator-service-account
  namespace: system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: operator-cluster-role
rules:
- apiGroups:
  - ""
  resources:
  - pods
  - services
  - endpoints
  - persistentvolumeclaims
  - events
  - configmaps
  - secrets
  verbs:
  - "*"
- apiGroups:
  - apps
  resources:
  - deployments
  - daemonsets
  - replicasets
  - statefulsets
  verbs:
  - "*"
- apiGroups:
  - db.example.com
  resources:
  - databases
  - databases/status
  - databases/finalizers
  verbs:
  - "*"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: operator-cluster-role-binding
subjects:
- kind: ServiceAccount
  name: operator-service-account
  namespace: system
roleRef:
  kind: ClusterRole
  name: operator-cluster-role
  apiGroup: rbac.authorization.k8s.io

5.3 监控与日志

# monitoring.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: operator-monitor
  labels:
    app: operator
spec:
  selector:
    matchLabels:
      app: operator
  endpoints:
  - port: metrics
    interval: 30s
---
apiVersion: v1
kind: Service
metadata:
  name: operator-metrics
  labels:
    app: operator
spec:
  ports:
  - port: 8080
    targetPort: 8080
    name: metrics
  selector:
    app: operator

5.4 故障恢复机制

// 故障恢复实现
func (r *DatabaseReconciler) handleRecovery(ctx context.Context, database *dbv1.Database) error {
    // 记录故障事件
    r.recorder.Event(database, corev1.EventTypeWarning, "RecoveryStarted", 
        fmt.Sprintf("Starting recovery for database %s", database.Name))

    // 执行恢复逻辑
    if err := r.performRecovery(ctx, database); err != nil {
        r.recorder.Event(database, corev1.EventTypeWarning, "RecoveryFailed", 
            fmt.Sprintf("Recovery failed for database %s: %v", database.Name, err))
        return err
    }

    // 恢复成功事件
    r.recorder.Event(database, corev1.EventTypeNormal, "RecoveryCompleted", 
        fmt.Sprintf("Recovery completed for database %s", database.Name))

    return nil
}

func (r *DatabaseReconciler) performRecovery(ctx context.Context, database *dbv1.Database) error {
    // 实现具体的恢复逻辑
    // 可能包括数据备份恢复、配置重置等
    return nil
}

6. 性能优化与安全考虑

6.1 缓存策略

// 使用缓存优化性能
type DatabaseReconciler struct {
    client.Client
    Scheme *runtime.Scheme
    //

打赏

本文固定链接: https://www.cxy163.net/archives/9942 | 绝缘体

该日志由 绝缘体.. 于 2017年06月28日 发表在 未分类 分类下, 你可以发表评论,并在保留原文地址及作者的情况下引用到你的网站或博客。
原创文章转载请注明: Kubernetes Operator模式技术预研:自定义资源控制器开发与部署最佳实践 | 绝缘体
关键字: , , , ,

Kubernetes Operator模式技术预研:自定义资源控制器开发与部署最佳实践:等您坐沙发呢!

发表评论


快捷键:Ctrl+Enter