以下是完整的 ​ConfigMap 热更新 Operator​ 开发步骤和代码实现,从零开始手把手带你完成。我们将使用 ​Operator SDK(Kubebuilder 风格)​​ 和 ​Go 语言​ 开发。


环境准备

  1. 安装工具​:

    # 1. 安装 Operator SDK
    curl -LO https://github.com/operator-framework/operator-sdk/releases/download/v1.28.0/operator-sdk_linux_amd64
    chmod +x operator-sdk_linux_amd64
    sudo mv operator-sdk_linux_amd64 /usr/local/bin/operator-sdk
    
    # 2. 安装 kubebuilder(可选,Operator SDK 已集成)
    # 3. 确保已安装 Go (1.24+) 和 Kubernetes 集群(如 Minikube)
  2. 验证工具​:

    operator-sdk version  # 输出类似:operator-sdk version: "v1.28.0"

步骤 1:初始化 Operator 项目

# 创建项目目录
mkdir configmap-reload-operator && cd configmap-reload-operator

# 初始化项目(使用 Kubebuilder 风格)
operator-sdk init \
  --domain example.com \
  --repo github.com/your-username/configmap-reload-operator \
  --project-name configmap-reload-operator

# 创建 API 和 Controller(CRD + 控制器)
operator-sdk create api \
  --group ops \
  --version v1 \
  --kind ConfigMapReload \
  --resource \
  --controller

步骤 2:定义 CRD 结构

编辑 api/v1/configmapreload_types.go

package v1

import (
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// ConfigMapReloadSpec 定义用户期望的状态
type ConfigMapReloadSpec struct {
	// 需要监听的 ConfigMap 名称
	ConfigMapName string `json:"configMapName"`
	// ConfigMap 所在的命名空间(默认与 CR 相同)
	// +optional
	ConfigMapNamespace string `json:"configMapNamespace,omitempty"`
	// 关联的工作负载类型(Deployment/StatefulSet)
	WorkloadType string `json:"workloadType"`
	// 关联的工作负载名称
	WorkloadName string `json:"workloadName"`
}

// ConfigMapReloadStatus 定义观测到的状态
type ConfigMapReloadStatus struct {
	// 最后一次触发 reload 的时间
	// +optional
	LastReloadTime string `json:"lastReloadTime,omitempty"`
	// ConfigMap 内容的哈希值(用于检测变化)
	// +optional
	LastConfigMapHash string `json:"lastConfigMapHash,omitempty"`
}

// +kubebuilder:object:root=true
// +kubebuilder:subresource:status

// ConfigMapReload 是 CRD 的定义
type ConfigMapReload struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   ConfigMapReloadSpec   `json:"spec,omitempty"`
	Status ConfigMapReloadStatus `json:"status,omitempty"`
}

// +kubebuilder:object:root=true

// ConfigMapReloadList 是 ConfigMapReload 的列表
type ConfigMapReloadList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`
	Items           []ConfigMapReload `json:"items"`
}

func init() {
	SchemeBuilder.Register(&ConfigMapReload{}, &ConfigMapReloadList{})
}

步骤 3:实现 Controller 逻辑

编辑 controllers/configmapreload_controller.go

package controllers

import (
	"context"
	"crypto/sha256"
	"fmt"
	"time"

	"github.com/go-logr/logr"
	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/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/handler"
	"sigs.k8s.io/controller-runtime/pkg/reconcile"
	"sigs.k8s.io/controller-runtime/pkg/source"

	opsv1 "git.woa.com/devcloud/test-env/configmap-reload-operator/api/v1"
)

// ConfigMapReloadReconciler  reconciles a ConfigMapReload object
type ConfigMapReloadReconciler struct {
	client.Client
	Log    logr.Logger
	Scheme *runtime.Scheme
}

// +kubebuilder:rbac:groups=ops.example.com,resources=configmapreloads,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=ops.example.com,resources=configmapreloads/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;update;patch
// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch

func (r *ConfigMapReloadReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	if r.Log.GetSink() == nil {
		r.Log = ctrl.Log.WithName("controllers").WithName("ConfigMapReload")
	}
	log := r.Log.WithValues("ConfigMapReload", req.NamespacedName)

	// 1. 获取 ConfigMapReload 实例
	reloader := &opsv1.ConfigMapReload{}
	if err := r.Get(ctx, req.NamespacedName, reloader); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	// 2. 获取目标 ConfigMap
	configMapNamespace := reloader.Spec.ConfigMapNamespace
	if configMapNamespace == "" {
		configMapNamespace = reloader.Namespace // 默认使用 CR 的命名空间
	}

	configMap := &corev1.ConfigMap{}
	configMapKey := client.ObjectKey{
		Namespace: configMapNamespace,
		Name:      reloader.Spec.ConfigMapName,
	}
	if err := r.Get(ctx, configMapKey, configMap); err != nil {
		log.Error(err, "无法获取 ConfigMap")
		return ctrl.Result{}, err
	}

	// 3. 计算 ConfigMap 内容的哈希值
	currentHash := computeConfigMapHash(configMap.Data)

	// 4. 如果哈希值未变化,则无需处理
	if reloader.Status.LastConfigMapHash == currentHash {
		return ctrl.Result{}, nil
	}

	// 5. 获取关联的 Deployment
	deployment := &appsv1.Deployment{}
	deploymentKey := client.ObjectKey{
		Namespace: reloader.Namespace,
		Name:      reloader.Spec.WorkloadName,
	}
	if err := r.Get(ctx, deploymentKey, deployment); err != nil {
		log.Error(err, "无法获取 Deployment")
		return ctrl.Result{}, err
	}

	// 6. 修改 Deployment 的 Annotation 以触发滚动重启
	if deployment.Spec.Template.ObjectMeta.Annotations == nil {
		deployment.Spec.Template.ObjectMeta.Annotations = make(map[string]string)
	}
	deployment.Spec.Template.ObjectMeta.Annotations["configmap.reload/trigger"] = currentHash

	if err := r.Update(ctx, deployment); err != nil {
		log.Error(err, "更新 Deployment 失败")
		return ctrl.Result{}, err
	}

	// 7. 更新 Status
	reloader.Status.LastConfigMapHash = currentHash
	reloader.Status.LastReloadTime = time.Now().Format(time.RFC3339)
	if err := r.Status().Update(ctx, reloader); err != nil {
		log.Error(err, "更新 Status 失败")
		return ctrl.Result{}, err
	}

	log.Info("成功触发滚动重启",
		"configMap", configMap.Name,
		"deployment", deployment.Name,
		"hash", currentHash)

	return ctrl.Result{}, nil
}

// 计算 ConfigMap 数据的哈希值
func computeConfigMapHash(data map[string]string) string {
	hasher := sha256.New()
	for k, v := range data {
		hasher.Write([]byte(k + v))
	}
	return fmt.Sprintf("%x", hasher.Sum(nil))
}

// 将 ConfigMap 事件映射到关联的 ConfigMapReload
func (r *ConfigMapReloadReconciler) mapConfigMapToReloader(obj client.Object) []reconcile.Request {
	configMap := obj.(*corev1.ConfigMap)
	var reloaders opsv1.ConfigMapReloadList
	if err := r.List(context.TODO(), &reloaders); err != nil {
		return []reconcile.Request{}
	}

	requests := []reconcile.Request{}
	for _, reloader := range reloaders.Items {
		configMapNamespace := reloader.Spec.ConfigMapNamespace
		if configMapNamespace == "" {
			configMapNamespace = reloader.Namespace
		}

		if reloader.Spec.ConfigMapName == configMap.Name &&
			configMapNamespace == configMap.Namespace {
			requests = append(requests, reconcile.Request{
				NamespacedName: client.ObjectKey{
					Namespace: reloader.Namespace,
					Name:      reloader.Name,
				},
			})
		}
	}
	return requests
}

func (r *ConfigMapReloadReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&opsv1.ConfigMapReload{}).
		Watches(
			&source.Kind{Type: &corev1.ConfigMap{}},
			handler.EnqueueRequestsFromMapFunc(r.mapConfigMapToReloader),
		).
		Complete(r)
}

编辑main.go函数,添加日志初始化

if err = (&controllers.ConfigMapReloadReconciler{
		Client: mgr.GetClient(),
		Scheme: mgr.GetScheme(),
		Log:    ctrl.Log.WithName("controllers").WithName("ConfigMapReload"), // 必须初始化
	}).SetupWithManager(mgr); err != nil {
		setupLog.Error(err, "unable to create controller", "controller", "ConfigMapReload")
		os.Exit(1)
	}

步骤 4:生成 CRD 和安装 Operator

# 生成 CRD 的 YAML 文件
make manifests

# 安装 CRD 到集群
make install

# 本地运行 Operator(测试用)
make run

# 或构建镜像并部署到集群(生产用)
make docker-build docker-push IMG=your-repo/configmap-reload-operator:v1
make deploy IMG=your-repo/configmap-reload-operator:v1

步骤 5:测试 Operator

  1. 创建示例 Deployment 和 ConfigMap​:

    # configmap.yaml
    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: myapp-config
    data:
      app.conf: |
        key1=value1
        key2=value2
    
    # deployment.yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: myapp
    spec:
      replicas: 2
      selector:
        matchLabels:
          app: myapp
      template:
        metadata:
          labels:
            app: myapp
        spec:
          containers:
          - name: nginx
            image: nginx:alpine
            volumeMounts:
            - name: config
              mountPath: /etc/config
          volumes:
          - name: config
            configMap:
              name: myapp-config
    kubectl apply -f configmap.yaml -f deployment.yaml
  2. 创建 ConfigMapReload 实例​:

    # reloader.yaml
    apiVersion: ops.example.com/v1
    kind: ConfigMapReload
    metadata:
      name: myapp-reloader
    spec:
      configMapName: myapp-config
      workloadType: Deployment
      workloadName: myapp
    kubectl apply -f reloader.yaml
  3. 测试 ConfigMap 更新​:

    # 修改 ConfigMap
    kubectl edit configmap myapp-config  # 将 key2=value2 改为 key2=value3
    
    # 观察 Pod 重启
    kubectl get pods -w
    # 输出应显示 Pod 被重新创建(AGE 重置)

关键点解析

  1. 事件监听​:

    • Controller 同时监听 ConfigMapReload CR 和 ConfigMap 资源的变化。
    • 通过 mapConfigMapToReloader 将 ConfigMap 事件映射到关联的 CR。
  2. 滚动重启原理​:

    • 修改 Deployment 的 spec.template.metadata.annotations 会触发 K8s 的滚动更新机制。
    • 哈希值(configmap.reload/trigger)确保每次 ConfigMap 内容变化都会触发更新。
  3. 幂等性保障​:

    • 通过比较 LastConfigMapHash 避免不必要的重启。

其他:

为了方便测试,不需要将opeartor服务部署到集群也可以测试

本地执行make run (这里会直接关联到集群上)


通过这个完整的实现,你已经拥有了一个生产可用的 ConfigMap 热更新 Operator!

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐