团队接手了一个棘手的存量项目:一套单体应用,配置管理深度绑定 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 和运维人员的脑子里。
- 动态配置依赖: 应用的某个配置文件
legacy_config.ini,其内容由 Puppet 根据 “Truth Service” 的数据动态生成。在 K8s 中,我们不能为了一个 Pod 的启动去运行一个 Puppet Agent。 - 密钥生命周期管理: JWT 的 RSA 私钥需要安全地生成、存储,并能被应用 Pod 挂载。更重要的是,轮换密钥时,需要确保新旧密钥在一定时间内共存,平滑过渡。
- 前后端部署耦合: 前端 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,我们彻底改变了该应用的运维模式。
过去:
- 运维手动或通过脚本触发 Puppet。
- Puppet 从 Truth Service 拉取配置,写入服务器文件。
- 运维手动生成或轮换 JWT 密钥,并分发到各应用服务器。
- 前端发布后,需要手动或通过脚本更新后端的某个配置文件。
- 整个过程充满风险,且难以回滚。
现在:
- 开发者/SRE 只需要维护一个
LegacyApp的 YAML 文件。 - 提交该 YAML 到 Git 仓库,由 GitOps 工具(如 ArgoCD)自动
kubectl apply到集群。 - Operator 自动完成所有协调工作:拉取动态配置、创建并管理密钥、部署和配置前后端应用。
- 更新镜像版本、副本数、甚至更换密钥 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 的单元测试和集成测试也至关重要,以确保调谐逻辑在各种边界条件下都能正确工作。