在管理横跨数十个团队、数百个微服务的 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
- 开发者配置 (app.config.ts): 开发者使用我们提供的 TypeScript SDK 来定义应用。
- Babel 转译层: CI 流水线中使用 Babel 将 TypeScript 配置文件转译成标准的 JSON 格式。这一步是连接高级语言与后端系统的桥梁。
- Config Controller: 一个 Go 服务,负责接收 CI 推送的 JSON 配置,进行深度校验,然后将其写入一个专用的 etcd 集群作为目标状态 (Desired State)。
- 专用 etcd 集群: 这是我们控制平面的“大脑”和唯一可信来源 (Single Source of Truth)。etcd 基于 Raft 协议保证了数据的高可用和强一致性,这对于存储平台配置至关重要。
- 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();
这里的 App
和 Environment
类封装了复杂的 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 中的实际状态,为平台管理员提供更强的可观测性。