构建基于 etcd 和 Raft 的自定义控制平面以优化 AWS EKS 上的开发者配置体验


在管理横跨数十个团队、数百个微服务的 AWS EKS 集群时,原始的 Kubernetes YAML 清单很快成为研发效能的瓶颈。开发者需要理解大量 Kubernetes 对象和字段,配置的编写、审查和维护成本极高,并且错误频发。我们的核心问题是:如何在不牺牲 Kubernetes 强大能力的前提下,为开发者提供一个更抽象、更安全、更高效的应用配置与发布界面。

方案权衡:现成方案与自研控制平面

方案 A:采用业界成熟的内部开发者平台 (IDP) 框架

市面上有如 Backstage 或 Crossplane 等成熟的开源方案。Backstage 侧重于开发者门户和目录,而 Crossplane 通过组合资源模型(Composition Resource Models)提供了强大的平台抽象能力。

  • 优势:

    • 功能完备,社区活跃,拥有经过验证的设计模式。
    • 能够快速搭建起平台雏形。
    • Crossplane 的声明式模型与 Kubernetes 生态无缝集成。
  • 劣势:

    • 学习曲线陡峭。为了实现我们特定的工作流,需要深入理解其内部复杂的 CRD 和 Composition 逻辑。
    • 定制化能力受限于框架设计。我们希望提供一种基于 TypeScript 的类型安全配置体验,这在现有框架中难以原生实现,通常需要通过插件或复杂的模板转换,增加了另一层复杂性。
    • 重量级。引入一整套新概念和组件,对于我们的核心诉求——简化配置,可能有些过度设计。

方案 B:构建一个轻量级、专用的自定义控制平面

这个方案的核心是打造一个专为我们内部工作流设计的、高度定制化的控制平面。它的主要职责是将开发者友好的高级配置语言,转换为底层的 Kubernetes API 调用。

  • 优势:

    • 极致的开发者体验 (DX)。我们可以自由设计配置语言,精确满足开发者的需求。例如,使用 TypeScript 提供自动补全、类型检查和模块化能力。
    • 架构简洁。只实现必要的功能,避免引入不相关的复杂性,整个系统的行为完全在我们的掌控之中。
    • 技术栈可控。可以选用我们团队最熟悉、最高效的技术栈(例如 Go、etcd)来构建后端。
  • 劣势:

    • 前期研发投入大。需要自行设计和实现整个控制循环、状态管理和 API 对接。
    • 长期维护成本。平台的稳定性、扩展性和安全性完全由内部团队负责。

最终决策

我们选择了方案 B。决策的关键驱动力是对开发者体验的极致追求。我们坚信,为数百名开发者节省每天在 YAML 配置上花费的时间和心智,其长期价值远超自研控制平面的初期投入。我们的目标是让开发者仅需编写一份简洁的、类型安全的 TypeScript 文件,就能定义其应用的所有环境(开发、预发、生产)的部署、网络和依赖关系。

核心架构设计

我们设计的控制平面由几个关键组件构成,它们在 EKS 集群外部独立运行,以降低对集群本身的侵入性。

graph TD
    subgraph Developer Workflow
        A[Developer writes app.config.ts] --> B{Git Push};
    end

    subgraph CI/CD Pipeline
        B --> C[CI Job Triggered];
        C --> D[Babel Transpilation: TS -> JSON];
        D --> E{Schema Validation};
    end

    subgraph Custom Control Plane
        E --> F[Config Controller];
        F -- gRPC Write --> G[(Dedicated etcd Cluster)];
        G -- Watch --> H[Reconciliation Controller];
        H -- Kubernetes API --> I[AWS EKS API Server];
    end

    subgraph State & Consistency
        G -- Raft Consensus --> G;
    end

    style G fill:#f9f,stroke:#333,stroke-width:2px
  1. 开发者配置 (app.config.ts): 开发者使用我们提供的 TypeScript SDK 来定义应用。
  2. Babel 转译层: CI 流水线中使用 Babel 将 TypeScript 配置文件转译成标准的 JSON 格式。这一步是连接高级语言与后端系统的桥梁。
  3. Config Controller: 一个 Go 服务,负责接收 CI 推送的 JSON 配置,进行深度校验,然后将其写入一个专用的 etcd 集群作为目标状态 (Desired State)。
  4. 专用 etcd 集群: 这是我们控制平面的“大脑”和唯一可信来源 (Single Source of Truth)。etcd 基于 Raft 协议保证了数据的高可用和强一致性,这对于存储平台配置至关重要。
  5. Reconciliation Controller: 另一个 Go 服务,它会 Watch etcd 中配置的变化。一旦检测到变更,它会将 etcd 中的目标状态与 EKS 集群中的实际状态进行比较,并计算出必要的变更(创建、更新、删除),然后通过 Kubernetes Go client 调用 EKS API Server 来执行这些变更。

实现细节与代码剖析

1. TypeScript SDK 与 Babel 转译

我们为开发者提供了一个 npm 包,其中包含了定义应用所需的类型和辅助函数。

一个典型的 app.config.ts 文件如下:

// file: my-awesome-app/app.config.ts

import { App, Environment, DeploymentStrategy } from '@my-corp/idp-sdk';

const app = new App('my-awesome-app', { team: 'backend-alpha' });

app.addEnvironment(new Environment('staging', {
  replicas: 2,
  cpu: '250m',
  memory: '512Mi',
  strategy: DeploymentStrategy.Canary,
  envVars: {
    LOG_LEVEL: 'debug',
    DB_HOST: 'staging-db.internal',
  },
}));

app.addEnvironment(new Environment('production', {
  replicas: 10,
  cpu: '1',
  memory: '2Gi',
  strategy: DeploymentStrategy.BlueGreen,
  autoScaling: {
    minReplicas: 10,
    maxReplicas: 50,
    targetCPUUtilization: 80,
  },
  envVars: {
    LOG_LEVEL: 'info',
    DB_HOST: 'prod-db.internal',
  },
}));

// Export the final configuration object
export default app.toJSON();

这里的 AppEnvironment 类封装了复杂的 Kubernetes 概念。CI 流程中的 Babel 配置非常直接:

// file: babel.config.json
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "node": "current"
        }
      }
    ],
    "@babel/preset-typescript"
  ]
}

CI 脚本会执行 npx babel app.config.ts --out-file app.config.json,生成如下的结构化 JSON:

{
  "appName": "my-awesome-app",
  "team": "backend-alpha",
  "environments": {
    "staging": {
      "replicas": 2,
      "resources": {
        "requests": { "cpu": "250m", "memory": "512Mi" },
        "limits": { "cpu": "500m", "memory": "1Gi" }
      },
      "strategy": "Canary",
      "envVars": { "...": "..." }
    },
    "production": {
      "replicas": 10,
      "resources": {
        "requests": { "cpu": "1", "memory": "2Gi" },
        "limits": { "cpu": "2", "memory": "4Gi" }
      },
      "strategy": "BlueGreen",
      "autoScaling": { "...": "..." },
      "envVars": { "...": "..." }
    }
  }
}

这个 JSON 就是我们控制平面的标准输入格式。

2. etcd 的键结构设计与索引优化

直接将整个 JSON 存入一个 etcd key 是不可取的,这会导致更新效率低下且难以查询。我们必须精心设计 key 的结构,利用 etcd 的前缀扫描能力来实现高效查询,这本质上是一种索引优化策略。

我们的 key 结构设计如下:

  • 应用规格 (Spec): /apps/spec/{team}/{appName} -> 存储应用的静态元数据和期望状态的 JSON。
  • 部署状态 (Status): /apps/status/{team}/{appName}/{environment} -> 存储由 Reconciliation Controller 回写 的实际部署状态。
  • 按团队索引: 通过前缀扫描 /apps/spec/{team}/ 可以快速列出某个团队的所有应用。
  • 按环境查询 (反向索引): 为了能快速查找某个环境下的所有应用,我们额外维护了一套索引 key:/idx/env/{environment}/{team}/{appName} -> value 可以是一个空值或者指向主 key 的引用。

Config Controller 在写入 etcd 时的 Go 代码片段:

// file: pkg/controller/config_controller.go

package controller

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"time"

	"go.etcd.io/etcd/clientv3"
)

// AppSpec represents the structure of our app configuration.
type AppSpec struct {
	AppName      string                    `json:"appName"`
	Team         string                    `json:"team"`
	Environments map[string]EnvSpec        `json:"environments"`
}

// ... other structs

type ConfigController struct {
	etcdClient *clientv3.Client
}

// UpdateAppSpec processes the JSON config and writes to etcd.
func (c *ConfigController) UpdateAppSPec(ctx context.Context, appConfigJSON []byte) error {
	var spec AppSpec
	if err := json.Unmarshal(appConfigJSON, &spec); err != nil {
		return fmt.Errorf("failed to unmarshal app spec: %w", err)
	}

	// Key for the main spec object
	specKey := fmt.Sprintf("/apps/spec/%s/%s", spec.Team, spec.AppName)

	// Use a transaction to ensure atomicity
	txn := c.etcdClient.Txn(ctx)

	// Put the main spec
	txn.Then(clientv3.OpPut(specKey, string(appConfigJSON)))

	// Update environment indexes
	for envName := range spec.Environments {
		idxKey := fmt.Sprintf("/idx/env/%s/%s/%s", envName, spec.Team, spec.AppName)
		// The value can be empty, we only care about the key for indexing
		txn.Then(clientv3.OpPut(idxKey, "1"))
	}
	
	// A real implementation would also need to handle deletion of old environments
	// from the index, which requires comparing the new spec with the old one.

	resp, err := txn.Commit()
	if err != nil {
		return fmt.Errorf("etcd transaction failed: %w", err)
	}

	if !resp.Succeeded {
		log.Printf("WARN: etcd transaction was not successful for app %s", spec.AppName)
	}

	log.Printf("Successfully updated spec for app: %s", spec.AppName)
	return nil
}

// FindAppsByEnv uses the index to efficiently find all apps in an environment.
func (c *ConfigController) FindAppsByEnv(ctx context.Context, environment string) ([]string, error) {
    prefix := fmt.Sprintf("/idx/env/%s/", environment)
    
    // Using WithPrefix() is the core of etcd "indexing"
    resp, err := c.etcdClient.Get(ctx, prefix, clientv3.WithPrefix())
    if err != nil {
        return nil, fmt.Errorf("failed to get from etcd with prefix %s: %w", prefix, err)
    }

    var appKeys []string
    for _, kv := range resp.Kvs {
        // Here we would parse the key string to extract team/appName
        appKeys = append(appKeys, string(kv.Key))
    }
    return appKeys, nil
}

这段代码展示了如何使用 etcd 事务来确保主数据和索引数据的一致性。FindAppsByEnv 函数则体现了如何利用前缀查询高效地实现 “索引” 功能。这里的关键是,etcd 的 Raft 协议保证了这些事务操作的原子性和持久性,使其成为一个可靠的状态存储。

3. Reconciliation Controller

这个控制器是连接 etcd 和 EKS 的桥梁。它持续监控 /apps/spec/ 前缀下的所有变更。

// file: pkg/controller/reconciliation_controller.go
package controller

import (
	"context"
	"log"
	
	"go.etcd.io/etcd/clientv3"
	"k8s.io/client-go/kubernetes"
)

type ReconciliationController struct {
	etcdClient *clientv3.Client
	kubeClient *kubernetes.Clientset
}

func (c *ReconciliationController) Run(ctx context.Context) {
	watchChan := c.etcdClient.Watch(ctx, "/apps/spec/", clientv3.WithPrefix())

	log.Println("Starting reconciliation controller, watching for spec changes in etcd...")

	for watchResp := range watchChan {
		for _, event := range watchResp.Events {
			// We only care about PUT events (creations and updates)
			// Deletion should be handled separately, perhaps with tombstones.
			if event.Type != clientv3.EventTypePut {
				continue
			}

			log.Printf("Detected change for key: %s", string(event.Kv.Key))
			
			// In a real implementation, add this key to a rate-limited work queue
			// to handle reconciliation asynchronously and robustly.
			if err := c.reconcile(ctx, event.Kv.Value); err != nil {
				log.Printf("ERROR: reconciliation failed for key %s: %v", string(event.Kv.Key), err)
				// Here you should implement retry logic, possibly with exponential backoff.
			}
		}
	}
}

// reconcile function compares desired state from etcd with actual state in EKS
// and applies the necessary changes.
func (c *ReconciliationController) reconcile(ctx context.Context, specJSON []byte) error {
	var spec AppSpec
	// ... unmarshal JSON ...

	for envName, envSpec := range spec.Environments {
		// 1. Construct desired Kubernetes Deployment object from envSpec
		desiredDeployment := buildDeployment(spec, envName, envSpec)

		// 2. Get the current Deployment from EKS API Server
		currentDeployment, err := c.kubeClient.AppsV1().Deployments(namespace).Get(ctx, desiredDeployment.Name, metav1.GetOptions{})
		
		if err != nil {
			if errors.IsNotFound(err) {
				// 3a. If not found, create it
				log.Printf("Creating Deployment %s in env %s", desiredDeployment.Name, envName)
				_, createErr := c.kubeClient.AppsV1().Deployments(namespace).Create(ctx, desiredDeployment, metav1.CreateOptions{})
				if createErr != nil {
					return fmt.Errorf("failed to create deployment: %w", createErr)
				}
			} else {
				return fmt.Errorf("failed to get deployment: %w", err)
			}
		} else {
			// 3b. If found, compare and update if necessary
			// This requires a sophisticated diffing logic to avoid unnecessary updates.
			// For simplicity, we just update the replica count here.
			if *currentDeployment.Spec.Replicas != *desiredDeployment.Spec.Replicas {
				log.Printf("Updating Deployment %s replicas from %d to %d", desiredDeployment.Name, *currentDeployment.Spec.Replicas, *desiredDeployment.Spec.Replicas)
				currentDeployment.Spec.Replicas = desiredDeployment.Spec.Replicas
				_, updateErr := c.kubeClient.AppsV1().Deployments(namespace).Update(ctx, currentDeployment, metav1.UpdateOptions{})
				if updateErr != nil {
					return fmt.Errorf("failed to update deployment: %w", updateErr)
				}
			}
		}
		
		// ... similar logic for Services, HPAs, Ingresses, etc. ...
	}

	// Finally, update the status key in etcd to reflect the current state.
	// ... c.etcdClient.Put("/apps/status/...") ...
	
	return nil
}

// buildDeployment is a helper function to convert our spec to a k8s Deployment object.
// func buildDeployment(...) (*appsv1.Deployment) { ... }

这个 reconcile 循环是所有 Kubernetes Operator 的核心模式。通过将 etcd 作为可信状态源,我们将控制逻辑与 EKS API Server 解耦,使得系统更加健壮和可测试。

架构的局限性与未来展望

尽管这个自研控制平面极大地提升了开发者的配置效率,但它并非银弹。

首先,系统的运维复杂性增加了。我们现在需要维护一个高可用的 etcd 集群及其备份恢复机制。虽然 etcd 本身极其稳定,但这仍是一个不可忽视的运维负担。

其次,我们的和解逻辑 (reconciliation logic) 相对简单。一个生产级的控制器需要处理更复杂的场景,例如配置漂移检测、更智能的 diff/patch 策略、以及对应用删除的优雅处理(例如,处理关联的 PVC、Ingress 规则等)。目前的设计在这些方面还有待完善。

此外,当前基于 key 前缀的“索引”策略在应用数量达到数十万级别时可能会遇到性能瓶颈。届时,可能需要引入外部索引服务(如 Elasticsearch)或设计更复杂的多级索引 key 结构来应对查询压力。

未来的迭代方向将聚焦于增强系统的鲁棒性,比如引入工作队列和指数退避重试机制来处理 EKS API 的瞬时故障,以及开发一个可视化的 UI 来展示 etcd 中的期望状态和 EKS 中的实际状态,为平台管理员提供更强的可观测性。


  目录