使用 Packer etcd Caddy 与 Material-UI 构建动态服务网关与控制平面


管理几十个内部微服务的路由配置是一件令人头疼的苦差事。在最初的阶段,我们依赖手动修改 Nginx 配置文件和 nginx -s reload。每次上线一个新服务或下线一个旧实例,都需要运维介入,这个过程不仅效率低下,而且极易出错。证书管理更是另一场噩梦,自签证书的信任问题和公签证书的续期流程,都消耗了大量本该用于业务开发的时间。我们需要一个自动化、声明式的系统,让服务注册后即可被外部访问,无需任何人工干预。

这个系统的核心诉求是动态性。服务实例的生命周期是短暂且不可预测的,网关必须能实时响应集群状态的变化。初步构想是建立一个服务注册中心作为事实的来源(Source of Truth),并让一个智能网关自动订阅这些变化来更新其路由规则。

技术选型决策

在真实项目中,技术选型从来不是追逐时髦,而是基于解决特定问题的成本、稳定性和维护性的综合考量。

  1. 网关 - Caddy: 我们放弃了 Nginx 和 Traefik,选择了 Caddy。原因非常明确:Caddy 的核心设计就是 API 驱动的动态配置。它提供了一个稳定的 JSON API 端点 (/load),可以在不中断服务、不丢失连接的情况下原子化地更新全部配置。相比之下,Nginx 的 reload 机制存在微妙的进程切换开销,而基于文件或 K8s Ingress 的配置方式对我们这种混合部署环境(部分在VM,部分在容器)来说不够通用。Caddy 内置的 Automatic HTTPS 功能,能自动管理 Let’s Encrypt 证书,彻底解决了证书管理的痛点。

  2. 服务注册中心 - etcd: 我们需要一个高可用的分布式键值存储。Consul 是一个备选方案,但它是一个功能更全面的“重”解决方案,包含了服务网格等我们当前不需要的功能。etcd 更纯粹,作为一个由 Kubernetes 背书的组件,其稳定性和性能得到了大规模验证。最关键的是,etcd 的 Watch 机制是我们实现配置动态响应的核心,它允许客户端高效地订阅指定前缀下的键值变化。

  3. 标准化运行环境 - Packer: 为了确保服务在任何地方都以相同的方式启动和注册,我们需要不可变的基础设施。Packer 是这个领域的不二之选。它能让我们通过一份代码(HCL)定义虚拟机镜像(AMI, qcow2)或容器镜像的构建过程。我们将把应用二进制文件、启动脚本和一个轻量级的服务注册代理一同打包进镜像。这样,从这个镜像启动的任何实例都天生具备了自动注册到 etcd 的能力。

  4. 控制与观测平面 - Material-UI (MUI): 尽管核心流程是自动化的,但人总是需要一个窗口来观察系统状态,并在必要时进行干预。从零开始写一个前端界面是耗时且不讨好的。MUI (Material-UI) 提供了一套生产级的 React 组件库,让我们能用极低的成本快速搭建一个功能完善、风格统一的管理后台。我们不需要在 CSS 上浪费时间,而是可以专注于数据展示和交互逻辑。

这套技术栈的组合,形成了一个完整的闭环:

graph TD
    subgraph "构建时 (Build Time)"
        A[App Code + Register Agent] -- HCL --> B(Packer)
        B --> C[Golden VM/Container Image]
    end

    subgraph "运行时 (Runtime)"
        D[DevOps/Pipeline] -- deploys --> E{Instance from Golden Image}
        E -- on start --> F(Register Agent)
        F -- writes service info --> G((etcd))
        
        H(Config Sync Agent) -- watches /services/* --> G
        H -- generates JSON config --> I[Caddy Admin API]
        I -- updates routes --> J(Caddy Server)
    end

    subgraph "管理与流量"
        K[User Traffic] --> J
        J -- routes to --> E

        L[Platform Admin] --> M{MUI Dashboard}
        M -- reads/writes --> G
    end

步骤化实现

1. Packer: 构建自注册的黄金镜像

我们的目标是让每个服务实例启动后,做的第一件事就是向 etcd 宣告自己的存在。这通过在镜像中内置一个注册代理来实现。

首先是 Packer 的模板文件 ubuntu-service.pkr.hcl

// ubuntu-service.pkr.hcl

packer {
  required_plugins {
    amazon = {
      version = ">= 1.0.0"
      source  = "github.com/hashicorp/amazon"
    }
  }
}

variable "app_binary_path" {
  type    = string
  default = "./app/my-service"
}

variable "register_agent_path" {
  type    = string
  default = "./agent/register-agent"
}

source "amazon-ebs" "ubuntu" {
  ami_name      = "service-base-{{timestamp}}"
  instance_type = "t2.micro"
  region        = "us-east-1"
  source_ami_filter {
    filters = {
      name                = "ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"
      root-device-type    = "ebs"
      virtualization-type = "hvm"
    }
    most_recent = true
    owners      = ["099720109477"] # Canonical's owner ID
  }
  ssh_username = "ubuntu"
}

build {
  name    = "service-golden-image"
  sources = ["source.amazon-ebs.ubuntu"]

  provisioner "file" {
    source      = var.app_binary_path
    destination = "/usr/local/bin/my-service"
  }

  provisioner "file" {
    source      = var.register_agent_path
    destination = "/usr/local/bin/register-agent"
  }

  provisioner "shell" {
    inline = [
      "sudo chmod +x /usr/local/bin/my-service",
      "sudo chmod +x /usr/local/bin/register-agent",
      // 创建 systemd 服务单元
      // 这里的 Service 定义是关键
      "sudo tee /etc/systemd/system/my-service.service > /dev/null <<'EOF'",
      "[Unit]",
      "Description=My Application Service",
      "After=network.target",
      "",
      "[Service]",
      "Type=simple",
      // ExecStartPre 会先执行注册代理,成功后再启动主服务
      // %H 是主机名, ${INSTANCE_ID} 和 ${SERVICE_PORT} 是环境变量
      "ExecStartPre=/usr/local/bin/register-agent --service-name=my-service --instance-id=${INSTANCE_ID} --port=${SERVICE_PORT} --etcd-endpoints=http://etcd.internal:2379",
      "ExecStart=/usr/local/bin/my-service -port=${SERVICE_PORT}",
      // ExecStopPost 会在服务停止后,从etcd中移除注册信息
      "ExecStopPost=/usr/local/bin/register-agent --deregister --service-name=my-service --instance-id=${INSTANCE_ID} --etcd-endpoints=http://etcd.internal:2379",
      "Restart=on-failure",
      "User=ubuntu",
      "Group=ubuntu",
      "",
      "[Install]",
      "WantedBy=multi-user.target",
      "EOF",
      "sudo systemctl enable my-service.service"
    ]
  }
}

这里的核心在于 systemd 服务单元的定义。我们使用 ExecStartPreExecStopPost 钩子来调用 register-agent。这意味着服务启动前会先注册,服务停止后会清理注册信息,实现了服务生命周期与注册状态的绑定。环境变量 INSTANCE_IDSERVICE_PORT 会在实例启动时由部署脚本或云厂商的 user-data 注入。

2. etcd 的数据结构设计

保持 etcd 中的数据结构清晰、可预测是后续自动化处理的基础。我们设计的键结构如下:

/services/{service-name}/{instance-id}

值是一个 JSON 字符串,包含了路由所需的信息。

例如,对于 my-service 的一个实例 i-0123abcd

  • Key: /services/my-service/i-0123abcd
  • Value: {"host": "my-service.internal.corp", "address": "10.0.1.23:8080", "registered_at": "2023-10-27T10:00:00Z"}

host 字段是 Caddy 用来匹配请求的域名,address 则是上游服务的实际 IP 和端口。

3. 核心枢纽:Caddy 配置同步代理

这是整个系统的“大脑”。我们用 Go 编写一个守护进程 caddy-config-agent,它只做两件事:

  1. 启动时,全量拉取 etcd 中 /services/ 前缀下的所有键值对,生成一份完整的 Caddy JSON 配置。
  2. 保持对 /services/ 前缀的 Watch,一旦有任何变更(新增、修改、删除),就重新计算完整的配置,并通过 Admin API 更新 Caddy。

一个常见的错误是增量更新 Caddy 配置。在真实项目中,这会导致状态不一致和复杂的逻辑。更稳健的做法是,每次变更都生成一份全量的、代表最终期望状态的配置,然后让 Caddy 原子化地应用它。

// main.go for caddy-config-agent
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"time"

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

const (
	etcdEndpoints = "http://etcd.internal:2379"
	caddyAdminAPI = "http://localhost:2019/load"
	etcdPrefix    = "/services/"
)

// ServiceInfo 定义了存储在 etcd 中的服务元数据结构
type ServiceInfo struct {
	Host    string `json:"host"`
	Address string `json:"address"`
}

// CaddyRoute 代表 Caddy JSON 配置中的一个 route
type CaddyRoute struct {
	Match  []map[string][]string `json:"match"`
	Handle []CaddyHandler        `json:"handle"`
}

// CaddyHandler 是 route 的处理逻辑,这里我们只关心 reverse_proxy
type CaddyHandler struct {
	Handler   string           `json:"handler"`
	Upstreams []CaddyUpstream  `json:"upstreams"`
}

// CaddyUpstream 定义了后端服务
type CaddyUpstream struct {
	Dial string `json:"dial"`
}

func main() {
	// --- 优雅退出设置 ---
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	sigChan := make(chan os.Signal, 1)
	signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
	go func() {
		<-sigChan
		log.Println("Shutdown signal received, stopping watcher...")
		cancel()
	}()

	// --- etcd 客户端初始化 ---
	cli, err := clientv3.New(clientv3.Config{
		Endpoints:   []string{etcdEndpoints},
		DialTimeout: 5 * time.Second,
	})
	if err != nil {
		log.Fatalf("Failed to connect to etcd: %v", err)
	}
	defer cli.Close()

	log.Println("Connected to etcd, starting config synchronization...")

	// 启动一个 goroutine 循环处理 watch 事件
	go watchAndSync(ctx, cli)

	// 首次启动时,先进行一次全量同步
	if err := syncCaddyConfig(ctx, cli); err != nil {
		log.Printf("Initial sync failed: %v", err)
	}

	<-ctx.Done()
	log.Println("Caddy config agent stopped.")
}

func watchAndSync(ctx context.Context, cli *clientv3.Client) {
	// 创建一个 watch channel,监控指定前缀
	rch := cli.Watch(ctx, etcdPrefix, clientv3.WithPrefix())

	// 使用 ticker 来做事件防抖 (debounce)
	// 在高频变更时,没必要每次变更都更新Caddy,可以合并处理
	ticker := time.NewTicker(2 * time.Second)
	defer ticker.Stop()
	
	needsSync := false

	for {
		select {
		case <-ctx.Done():
			return
		case wresp, ok := <-rch:
			if !ok {
				log.Println("Watch channel closed. Reconnecting...")
				// 简单的重连逻辑
				time.Sleep(5 * time.Second)
				rch = cli.Watch(ctx, etcdPrefix, clientv3.WithPrefix())
				continue
			}
			if wresp.Err() != nil {
				log.Printf("Watch error: %v", wresp.Err())
				continue
			}
			// 只要有事件,就标记为需要同步
			needsSync = true
			log.Printf("Detected %d changes in etcd.", len(wresp.Events))

		case <-ticker.C:
			// 每隔2秒检查是否需要同步
			if needsSync {
				log.Println("Debounce timer triggered. Starting sync...")
				if err := syncCaddyConfig(ctx, cli); err != nil {
					log.Printf("Sync failed after watch event: %v", err)
				}
				needsSync = false // 重置标记
			}
		}
	}
}

// syncCaddyConfig 是核心函数,它从 etcd 获取数据、生成配置、然后提交给 Caddy
func syncCaddyConfig(ctx context.Context, cli *clientv3.Client) error {
	// 1. 从 etcd 获取所有服务
	resp, err := cli.Get(ctx, etcdPrefix, clientv3.WithPrefix())
	if err != nil {
		return err
	}

	services := make(map[string][]string) // map[hostname][]upstream_address
	for _, ev := range resp.Kvs {
		var info ServiceInfo
		if err := json.Unmarshal(ev.Value, &info); err != nil {
			log.Printf("Failed to unmarshal value for key %s: %v", ev.Key, err)
			continue
		}
		// 按 host 分组
		services[info.Host] = append(services[info.Host], info.Address)
	}
	
	// 2. 生成 Caddy JSON 配置
	caddyConfig, err := generateCaddyJSON(services)
	if err != nil {
		return err
	}

	// 3. 将配置 POST 到 Caddy Admin API
	req, err := http.NewRequestWithContext(ctx, "POST", caddyAdminAPI, bytes.NewBuffer(caddyConfig))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")

	client := &http.Client{Timeout: 5 * time.Second}
	res, err := client.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()

	if res.StatusCode != http.StatusOK {
		log.Printf("Caddy admin API returned non-200 status: %d", res.StatusCode)
		// 可以考虑读取 response body 获取更详细的错误信息
	} else {
		log.Printf("Successfully updated Caddy config with %d hosts.", len(services))
	}
	
	return nil
}

func generateCaddyJSON(services map[string][]string) ([]byte, error) {
	var routes []CaddyRoute

	for host, addresses := range services {
		var upstreams []CaddyUpstream
		for _, addr := range addresses {
			upstreams = append(upstreams, CaddyUpstream{Dial: addr})
		}

		route := CaddyRoute{
			Match: []map[string][]string{
				{"host": {host}},
			},
			Handle: []CaddyHandler{
				{
					Handler:   "reverse_proxy",
					Upstreams: upstreams,
				},
			},
		}
		routes = append(routes, route)
	}

	// 这是 Caddy 配置的顶层结构
	// 我们只修改 http app 中的 server routes
	config := map[string]interface{}{
		"apps": map[string]interface{}{
			"http": map[string]interface{}{
				"servers": map[string]interface{}{
					"srv0": map[string]interface{}{
						"listen": []string{":443"},
						"routes": routes,
					},
				},
			},
		},
	}
	
	return json.Marshal(config)
}

这段 Go 代码包含了基本的错误处理、优雅退出和事件防抖。在生产环境中,还需要加入更完善的重试逻辑、指数退避以及 metrics 暴露 (Prometheus)。

4. MUI 仪表盘:提供可见性和控制力

最后,我们需要一个界面来展示 etcd 中的数据。这是一个简化的 React 组件,使用了 MUI 的 TableChip

// ServiceDashboard.jsx
import React, { useState, useEffect } from 'react';
import {
  Table, TableBody, TableCell, TableContainer, TableHead,
  TableRow, Paper, Chip, Typography, Box, CircularProgress, Button
} from '@mui/material';

// 假设我们有一个 API client
// import { getServices, deregisterInstance } from './api';

// 模拟 API 调用
const fetchServicesFromApi = async () => {
  // 在真实应用中,这里会向后端 API 发起请求
  // 后端 API 从 etcd 读取数据
  await new Promise(resolve => setTimeout(resolve, 500)); // 模拟网络延迟
  return [
    { name: 'my-service', instanceId: 'i-0123abcd', host: 'my-service.internal.corp', address: '10.0.1.23:8080', status: 'healthy' },
    { name: 'my-service', instanceId: 'i-4567efgh', host: 'my-service.internal.corp', address: '10.0.1.24:8080', status: 'healthy' },
    { name: 'another-api', instanceId: 'i-8910ijkl', host: 'another-api.internal.corp', address: '10.0.2.50:9000', status: 'unhealthy' },
  ];
};

const deregisterInstanceFromApi = async (serviceName, instanceId) => {
    // 真实应用中,这将调用后端 API,后端删除 etcd 中的对应 key
    console.log(`Requesting deregistration for ${serviceName}/${instanceId}`);
    await new Promise(resolve => setTimeout(resolve, 300));
    return { success: true };
};


export default function ServiceDashboard() {
  const [services, setServices] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const fetchData = async () => {
    try {
      setLoading(true);
      const data = await fetchServicesFromApi();
      setServices(data);
      setError(null);
    } catch (err) {
      setError('Failed to fetch service data.');
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData();
    // 轮询更新数据
    const interval = setInterval(fetchData, 5000);
    return () => clearInterval(interval);
  }, []);

  const handleDeregister = async (serviceName, instanceId) => {
    if (window.confirm(`Are you sure you want to deregister ${instanceId}?`)) {
        await deregisterInstanceFromApi(serviceName, instanceId);
        // 触发一次立即刷新
        fetchData();
    }
  };


  if (loading && services.length === 0) {
    return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
  }

  if (error) {
    return <Typography color="error" sx={{ p: 4 }}>{error}</Typography>;
  }

  return (
    <Paper sx={{ margin: 2, overflow: 'hidden' }}>
      <TableContainer>
        <Table stickyHeader aria-label="service instances table">
          <TableHead>
            <TableRow>
              <TableCell>Service Name</TableCell>
              <TableCell>Instance ID</TableCell>
              <TableCell>Hostname</TableCell>
              <TableCell>Upstream Address</TableCell>
              <TableCell align="center">Status</TableCell>
              <TableCell align="center">Actions</TableCell>
            </TableRow>
          </TableHead>
          <TableBody>
            {services.map((row) => (
              <TableRow key={row.instanceId} hover>
                <TableCell component="th" scope="row">
                  <Typography variant="body2" fontWeight="bold">{row.name}</Typography>
                </TableCell>
                <TableCell>{row.instanceId}</TableCell>
                <TableCell><code>{row.host}</code></TableCell>
                <TableCell><code>{row.address}</code></TableCell>
                <TableCell align="center">
                  <Chip
                    label={row.status}
                    color={row.status === 'healthy' ? 'success' : 'error'}
                    size="small"
                  />
                </TableCell>
                <TableCell align="center">
                  <Button 
                    variant="outlined" 
                    color="error" 
                    size="small"
                    onClick={() => handleDeregister(row.name, row.instanceId)}>
                    Deregister
                  </Button>
                </TableCell>
              </TableRow>
            ))}
          </TableBody>
        </Table>
      </TableContainer>
    </Paper>
  );
}

这个界面虽然简单,但提供了核心价值:状态可见性手动干预能力。当自动化流程出现问题时,管理员可以从这里强制摘除一个有问题的实例,caddy-config-agent 会立刻收到 etcd 的删除事件,并将该实例从 Caddy 的上游列表中移除,从而实现快速故障隔离。

遗留问题与未来迭代

当前这套方案已经能很好地解决动态路由和证书自动续期的问题,但在生产环境中,它还不是终点。

首先,caddy-config-agent 本身是一个单点故障。虽然它崩溃不会影响 Caddy 当前的路由,但会导致配置无法更新。后续需要将其部署为高可用模式,例如主备或多活,利用 etcd 的 lease 和 a lock 机制来确保同一时间只有一个实例在工作。

其次,安全性需要加固。etcd 和 Caddy Admin API 都暴露在网络上,必须启用 mTLS 双向认证,并配合严格的网络策略,确保只有授权的代理可以访问。

再者,etcd 的 watch 机制在服务数量达到数千级别且变更频繁时,可能会对 etcd server 造成压力。届时可能需要探索更高级的模式,例如使用分级 key 或者引入一个中间的消息队列(如 NATS)来解耦配置生成器和 etcd 集群。

最后,目前的控制平面功能还很薄弱。一个完整的内部开发者平台(IDP)还需要集成认证授权(RBAC)、审计日志、配额管理、蓝绿发布控制等高级功能。MUI 只是解决了“形”的问题,真正的“神”还需要在后端做大量工作。


  目录