构建 Kubernetes Operator 以声明式管理集成 Puppet 配置的 JWT 认证服务与 CSS Modules 前端


团队接手了一个棘手的存量项目:一套单体应用,配置管理深度绑定 Puppet,服务间认证依赖自签发的 JWT,前端则采用了当时流行的 CSS Modules 方案。目标是将其容器化并迁移至 Kubernetes 平台。直接用 Helm Chart 或 Kustomize 打包部署的方案很快被否决,因为应用的启动和运维逻辑远比简单的模板渲染要复杂。例如,部分关键配置并非静态存在于代码仓库,而是由 Puppet 在运行时从一个外部的配置中心(我们称之为 “Truth Service”)动态拉取并生成。此外,JWT 的签名密钥需要定期轮换,而前端部署时,后端 API 网关需要知道 CSS Modules 生成的哈希类名与原始类名的映射关系,以实现一些服务端渲染的注入。

这种运维逻辑的复杂性,如果用一堆 shell 脚本和 CI/CD pipeline 任务来胶合,会制造出一个脆弱且难以维护的怪物。在真实项目中,这种胶水代码是技术债的主要来源。我们需要一个能在 Kubernetes 内部,以云原生的方式,将这些过程自动化、声明化的解决方案。最终,我们决定为这个“遗留应用”编写一个专属的 Kubernetes Operator。

技术痛点与初步构想

核心矛盾在于,Kubernetes 的世界是声明式的,而我们应用的运维知识是过程式的,散落在 Puppet manifests 和运维人员的脑子里。

  1. 动态配置依赖: 应用的某个配置文件 legacy_config.ini,其内容由 Puppet 根据 “Truth Service” 的数据动态生成。在 K8s 中,我们不能为了一个 Pod 的启动去运行一个 Puppet Agent。
  2. 密钥生命周期管理: JWT 的 RSA 私钥需要安全地生成、存储,并能被应用 Pod 挂载。更重要的是,轮换密钥时,需要确保新旧密钥在一定时间内共存,平滑过渡。
  3. 前后端部署耦合: 前端 CI 流程会生成一个 classmap.json 文件 ({"button": "Button_button__aB3cD"}), 这个文件需要被上传到某个地方,并且在部署时告知后端,以便服务端能正确处理某些请求。

直接使用 Deployment 和 ConfigMap 的组合无法满足这种动态和协调性的需求。我们需要一个控制器,一个持续运行的循环(Reconciliation Loop),它不断地观察期望状态(我们定义的 Custom Resource)和实际状态(集群中的 Deployment, Secret, ConfigMap 等),并采取行动使二者一致。这就是 Operator 模式的核心。

Operator 的设计:定义 LegacyApp 资源

我们的第一步是设计一个 Custom Resource Definition (CRD),用来描述这个复杂应用的期望状态。我们称之为 LegacyApp

# config/crd/bases/app.techcrafter.io_legacyapps.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: legacyapps.app.techcrafter.io
spec:
  group: app.techcrafter.io
  names:
    kind: LegacyApp
    listKind: LegacyAppList
    plural: legacyapps
    singular: legacyapp
  scope: Namespaced
  versions:
    - name: v1alpha1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                replicaCount:
                  type: integer
                  minimum: 0
                  description: Number of desired pods.
                backend:
                  type: object
                  properties:
                    image:
                      type: string
                    jwtSecretName:
                      type: string
                      description: "Name of the Kubernetes Secret to store JWT signing keys."
                    truthServiceEndpoint:
                      type: string
                      description: "Endpoint for the legacy truth service."
                  required: ["image", "jwtSecretName", "truthServiceEndpoint"]
                frontend:
                  type: object
                  properties:
                    image:
                      type: string
                    cssModuleMapName:
                      type: string
                      description: "Name of the ConfigMap storing the CSS Modules class map."
                  required: ["image", "cssModuleMapName"]
              required: ["replicaCount", "backend", "frontend"]
            status:
              type: object
              properties:
                conditions:
                  type: array
                  items:
                    type: object
                    properties:
                      type:
                        type: string
                      status:
                        type: string
                      lastTransitionTime:
                        type: string
                      reason:
                        type: string
                      message:
                        type: string
                observedGeneration:
                  type: integer
                activeJwtKeyId:
                  type: string

这个 CRD 定义了我们关心的所有配置项:后端镜像、JWT 密钥存储的 Secret 名称、遗留配置服务的地址、前端镜像以及 CSS Modules 映射表的 ConfigMap 名称。.status 字段则用于 Operator 回写应用的实际状态,这是实现健壮控制循环的关键。

实现 Reconciler 核心调谐逻辑

我们使用 Kubebuilder 来搭建 Operator 项目骨架。核心工作在于 internal/controller/legacyapp_controller.go 文件中的 Reconcile 方法。这个方法就是我们的调谐循环,每次 LegacyApp 资源或其拥有的子资源发生变化时,它都会被触发。

一个常见的错误是把 Reconcile 函数写成一个巨大的、从头到尾的顺序脚本。在真实项目中,这会变得极难维护。更好的方式是将其分解为一系列独立的、幂等的协调函数,每个函数负责一个子资源。

// internal/controller/legacyapp_controller.go

// ... imports ...
import (
    "context"
    "crypto/rand"
    "crypto/rsa"
    "crypto/x509"
    "encoding/pem"
    "fmt"
    "time"

    // ... other imports ...
    "k8s.io/apimachinery/pkg/api/errors"
    "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"

    appv1alpha1 "github.com/your-repo/legacy-app-operator/api/v1alpha1"
    corev1 "k8s.io/api/core/v1"
    appsv1 "k8s.io/api/apps/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)


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

//+kubebuilder:rbac:groups=app.techcrafter.io,resources=legacyapps,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=app.techcrafter.io,resources=legacyapps/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=core,resources=secrets;configmaps,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete

func (r *LegacyAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    logger := log.FromContext(ctx)

    // 1. 获取 LegacyApp 实例
    var legacyApp appv1alpha1.LegacyApp
    if err := r.Get(ctx, req.NamespacedName, &legacyApp); err != nil {
        if errors.IsNotFound(err) {
            // 资源已被删除,无需处理
            logger.Info("LegacyApp resource not found. Ignoring since object must be deleted.")
            return ctrl.Result{}, nil
        }
        logger.Error(err, "Failed to get LegacyApp")
        return ctrl.Result{}, err
    }

    // 2. 调谐动态配置 ConfigMap (从 Puppet 的 "Truth Service" 获取)
    configMap, err := r.reconcileDynamicConfig(ctx, &legacyApp)
    if err != nil {
        logger.Error(err, "Failed to reconcile dynamic configmap")
        // 更新状态为错误,并重新排队
        // ... status update logic ...
        return ctrl.Result{}, err
    }

    // 3. 调谐 JWT 密钥 Secret
    jwtSecret, err := r.reconcileJWTSecret(ctx, &legacyApp)
    if err != nil {
        logger.Error(err, "Failed to reconcile JWT secret")
        return ctrl.Result{}, err
    }

    // 4. 调谐后端 Deployment
    if _, err := r.reconcileBackendDeployment(ctx, &legacyApp, configMap, jwtSecret); err != nil {
        logger.Error(err, "Failed to reconcile backend deployment")
        return ctrl.Result{}, err
    }
    
    // 5. 调谐前端 Deployment
    if _, err := r.reconcileFrontendDeployment(ctx, &legacyApp); err != nil {
        logger.Error(err, "Failed to reconcile frontend deployment")
        return ctrl.Result{}, err
    }
    
    // ... update status to Ready ...
    logger.Info("Successfully reconciled LegacyApp")
    return ctrl.Result{}, nil
}

// SetupWithManager sets up the controller with the Manager.
func (r *LegacyAppReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&appv1alpha1.LegacyApp{}).
		Owns(&appsv1.Deployment{}).
		Owns(&corev1.Secret{}).
		Owns(&corev1.ConfigMap{}).
		Complete(r)
}

步骤一:与 Puppet 的 “Truth Service” 对接

这里的坑在于,我们不能让 Operator 依赖于一个不稳定的外部服务。与外部服务交互时,必须有完善的超时、重试和错误处理机制。我们将这部分逻辑封装在 reconcileDynamicConfig 函数中。

// internal/controller/reconcile_configmap.go
func (r *LegacyAppReconciler) reconcileDynamicConfig(ctx context.Context, app *appv1alpha1.LegacyApp) (*corev1.ConfigMap, error) {
    logger := log.FromContext(ctx)
    configMapName := fmt.Sprintf("%s-dynamic-config", app.Name)
    
    // 假设我们有一个客户端,用于从 truth service 获取数据
    // truthServiceClient := NewTruthServiceClient(app.Spec.Backend.truthServiceEndpoint)
    // configData, err := truthServiceClient.GetConfig()
    // 在这个示例中,我们硬编码模拟返回
    configData := `
[database]
host = db.prod.svc.cluster.local
user = legacy_user
`
    
    cm := &corev1.ConfigMap{
        ObjectMeta: metav1.ObjectMeta{
            Name:      configMapName,
            Namespace: app.Namespace,
        },
    }

    // 使用 controller-runtime 的 CreateOrUpdate 来实现幂等操作
    op, err := ctrl.CreateOrUpdate(ctx, r.Client, cm, func() error {
        // 在这里更新 ConfigMap 的数据
        cm.Data = map[string]string{
            "legacy_config.ini": configData,
        }
        // 设置 OwnerReference,这样当 LegacyApp 删除时,这个 ConfigMap 会被级联删除
        return ctrl.SetControllerReference(app, cm, r.Scheme)
    })

    if err != nil {
        logger.Error(err, "Failed to create or update dynamic ConfigMap", "Operation", op)
        return nil, err
    }
    
    logger.Info("Dynamic ConfigMap reconciled", "Operation", op)
    return cm, nil
}

注意 ctrl.SetControllerReference 的使用,这是 Kubernetes Operator 开发中的最佳实践。它建立了 LegacyApp 和它所管理的 ConfigMap 之间的父子关系,确保了资源的生命周期被正确管理。

步骤二:管理 JWT 密钥生命周期

这部分是安全敏感的。密钥的生成必须在 Operator 内部完成,而不是依赖外部传入。生成的密钥存储在 Kubernetes Secret 中,应用 Pod 通过 Volume Mount 的方式使用它,避免了将密钥暴露在环境变量中。

// internal/controller/reconcile_secret.go
func (r *LegacyAppReconciler) reconcileJWTSecret(ctx context.Context, app *appv1alpha1.LegacyApp) (*corev1.Secret, error) {
    logger := log.FromContext(ctx)
    secretName := app.Spec.Backend.jwtSecretName

    secret := &corev1.Secret{
        ObjectMeta: metav1.ObjectMeta{
            Name:      secretName,
            Namespace: app.Namespace,
        },
    }

    // 检查 Secret 是否已存在
    err := r.Get(ctx, client.ObjectKey{Name: secretName, Namespace: app.Namespace}, secret)
    if err == nil {
        // 已存在,直接返回
        logger.Info("JWT Secret already exists, skipping creation.")
        return secret, nil
    }

    if !errors.IsNotFound(err) {
        // 其他 Get 错误
        logger.Error(err, "Failed to get JWT Secret")
        return nil, err
    }
    
    // Secret 不存在,开始创建
    logger.Info("JWT Secret not found, creating a new one.")
    privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
    if err != nil {
        logger.Error(err, "Failed to generate RSA private key")
        return nil, err
    }

    privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
    privateKeyPEM := pem.EncodeToMemory(&pem.Block{
        Type:  "RSA PRIVATE KEY",
        Bytes: privateKeyBytes,
    })

    publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
    if err != nil {
        logger.Error(err, "Failed to marshal public key")
        return nil, err
    }
    publicKeyPEM := pem.EncodeToMemory(&pem.Block{
        Type:  "PUBLIC KEY",
        Bytes: publicKeyBytes,
    })

    secret.Data = map[string][]byte{
        "private.pem": privateKeyPEM,
        "public.pem":  publicKeyPEM,
    }
    // 仍然要设置 OwnerReference
    if err := ctrl.SetControllerReference(app, secret, r.Scheme); err != nil {
        return nil, err
    }

    if err := r.Create(ctx, secret); err != nil {
        logger.Error(err, "Failed to create new JWT Secret")
        return nil, err
    }

    logger.Info("Successfully created new JWT Secret")
    return secret, nil
}

这里的逻辑是,如果 Secret 不存在,就创建它。如果存在,则什么都不做。一个更完整的实现会包含密钥轮换逻辑:例如,检查密钥的创建时间,如果超过一定期限,则生成新密钥并以不同的 key(如 private-new.pem)放入 Secret,同时更新 LegacyApp.Status 中的 activeJwtKeyId,过一段时间后再移除旧密钥。

步骤三:调谐后端与前端 Deployment

这是将所有部分串联起来的地方。后端的 Deployment 需要挂载动态配置 ConfigMap 和 JWT Secret。前端的 Deployment 则需要挂载包含 CSS Modules 映射表的 ConfigMap

graph TD
    A[Reconcile Loop Triggered] --> B{LegacyApp CR};
    B --> C[reconcileDynamicConfig];
    C --> D[Fetch from Truth Service];
    D --> E[Create/Update ConfigMap: dynamic-config];
    B --> F[reconcileJWTSecret];
    F --> G{Secret Exists?};
    G -- No --> H[Generate RSA Key];
    H --> I[Create Secret: jwt-keys];
    G -- Yes --> J[Done];
    I --> K;
    J --> K;
    subgraph "Backend Reconciliation"
        K[reconcileBackendDeployment];
        K --> L[Construct Deployment Spec];
        L --> M[Mount dynamic-config];
        L --> N[Mount jwt-keys];
        M & N --> O[Create/Update Backend Deployment];
    end
    subgraph "Frontend Reconciliation"
        B --> P[reconcileFrontendDeployment];
        P --> Q[Find ConfigMap: css-module-map];
        Q --> R[Construct Deployment Spec];
        R --> S[Mount css-module-map];
        S --> T[Create/Update Frontend Deployment];
    end

下面是后端 Deployment 的构造代码片段,展示了如何将依赖注入 Pod。

// internal/controller/reconcile_deployment.go

func (r *LegacyAppReconciler) reconcileBackendDeployment(ctx context.Context, app *appv1alpha1.LegacyApp, configMap *corev1.ConfigMap, secret *corev1.Secret) (*appsv1.Deployment, error) {
    // ...
    deployment := &appsv1.Deployment{
        ObjectMeta: metav1.ObjectMeta{
            Name:      fmt.Sprintf("%s-backend", app.Name),
            Namespace: app.Namespace,
        },
    }

    _, err := ctrl.CreateOrUpdate(ctx, r.Client, deployment, func() error {
        // ... (设置 Replicas, Selector, etc.)
        
        // 关键部分:定义 Volumes
        deployment.Spec.Template.Spec.Volumes = []corev1.Volume{
            {
                Name: "dynamic-config-vol",
                VolumeSource: corev1.VolumeSource{
                    ConfigMap: &corev1.ConfigMapVolumeSource{
                        LocalObjectReference: corev1.LocalObjectReference{
                            Name: configMap.Name,
                        },
                    },
                },
            },
            {
                Name: "jwt-key-vol",
                VolumeSource: corev1.VolumeSource{
                    Secret: &corev1.SecretVolumeSource{
                        SecretName: secret.Name,
                    },
                },
            },
        }

        // 关键部分:定义容器和 Volume Mounts
        deployment.Spec.Template.Spec.Containers = []corev1.Container{
            {
                Name:  "backend-app",
                Image: app.Spec.Backend.Image,
                Ports: []corev1.ContainerPort{{ContainerPort: 8080}},
                VolumeMounts: []corev1.VolumeMount{
                    {
                        Name:      "dynamic-config-vol",
                        MountPath: "/etc/app/config", // 配置文件挂载到容器内
                        ReadOnly:  true,
                    },
                    {
                        Name:      "jwt-key-vol",
                        MountPath: "/etc/app/keys", // JWT 密钥挂载
                        ReadOnly:  true,
                    },
                },
                // 通过环境变量告知应用密钥文件的路径
                Env: []corev1.EnvVar{
                    {
                        Name:  "JWT_PRIVATE_KEY_PATH",
                        Value: "/etc/app/keys/private.pem",
                    },
                },
            },
        }

        return ctrl.SetControllerReference(app, deployment, r.Scheme)
    })
    // ... error handling ...
    return deployment, nil
}

前端 Deployment 的逻辑类似,只是它挂载的是 app.Spec.Frontend.cssModuleMapName 所指定的 ConfigMap。这里的假设是,CI/CD 流程负责将 classmap.json 推送到这个 ConfigMap 中。Operator 的职责是确保这个 ConfigMap 被正确地挂载到前端服务 Pod 中,供其在运行时(例如服务端渲染时)读取。

最终成果与运维模式的转变

通过这个 Operator,我们彻底改变了该应用的运维模式。

过去:

  1. 运维手动或通过脚本触发 Puppet。
  2. Puppet 从 Truth Service 拉取配置,写入服务器文件。
  3. 运维手动生成或轮换 JWT 密钥,并分发到各应用服务器。
  4. 前端发布后,需要手动或通过脚本更新后端的某个配置文件。
  5. 整个过程充满风险,且难以回滚。

现在:

  1. 开发者/SRE 只需要维护一个 LegacyApp 的 YAML 文件。
  2. 提交该 YAML 到 Git 仓库,由 GitOps 工具(如 ArgoCD)自动 kubectl apply 到集群。
  3. Operator 自动完成所有协调工作:拉取动态配置、创建并管理密钥、部署和配置前后端应用。
  4. 更新镜像版本、副本数、甚至更换密钥 Secret 名称,都只是修改 YAML 文件中的一个字段而已。整个系统会自动收敛到新的期望状态。
# A developer would apply this file to deploy/update the application
apiVersion: app.techcrafter.io/v1alpha1
kind: LegacyApp
metadata:
  name: my-legacy-service
  namespace: prod
spec:
  replicaCount: 3
  backend:
    image: my-registry/legacy-backend:v1.2.1
    jwtSecretName: legacy-service-jwt-keys
    truthServiceEndpoint: "http://truth-service.internal:8080/config/legacy-app"
  frontend:
    image: my-registry/legacy-frontend:v2.5.0
    cssModuleMapName: frontend-v2.5.0-css-map

一个 kubectl apply -f 命令,背后是 Operator 执行的一整套复杂的、经过编码的、可测试的运维逻辑。

遗留问题与未来迭代

这个 Operator 方案并非一劳永逸。首先,它依旧依赖于那个遗留的 “Truth Service”。长远来看,应该将这个外部服务的配置逻辑逐步迁移到 Kubernetes 的 ConfigMap 或专门的 CRD 中,最终彻底移除这个外部依赖。

其次,当前的 JWT 密钥管理只处理了首次创建,并未实现自动轮换。一个完整的实现需要增加一个定时逻辑,在 Reconcile 循环中检查密钥年龄,并执行安全的、带缓冲期的轮换流程,这会显著增加代码的复杂性。

最后,Operator 本身的健壮性也需要持续打磨。例如,对外部服务调用的失败处理需要更精细的退避策略(exponential backoff),对子资源状态的监控和反馈到 LegacyApp.Status 中也需要更详尽的设计,以便用户能清晰地看到部署的每一个阶段和可能遇到的问题。Operator 的单元测试和集成测试也至关重要,以确保调谐逻辑在各种边界条件下都能正确工作。


  目录