记录一个独立开发者从零到一构建监控系统的技术历程

第一阶段:技术选型的纠结

为什么选 Go + Vue?

后端选 Go 几乎没有犹豫。监控系统对性能和并发有天然要求,Go 的 goroutine 模型简直是为这种场景量身定做的。而且单二进制部署太香了,不用折腾 JVM、不用装 Python 环境。

前端纠结了一下 React 还是 Vue。最后选了 Vue 3,主要是 Composition API 比较舒服,加上 Vite 的体验太丝滑了。

技术栈最终定型:

├── 后端: Go + Gin + gRPC

├── 前端: Vue 3 + TypeScript + Vite

├── 数据库: PostgreSQL + Redis

└── 探针: Go (跨平台编译)

探针通信:HTTP 还是 gRPC?

这个问题困扰了我很久。

最开始用的 HTTP 轮询,简单粗暴。但问题很快暴露:

  1. 轮询间隔太长,实时性差

  2. 轮询间隔太短,服务器压力大

  3. 服务端无法主动推送指令给探针

后来改成了 gRPC 双向流。探针和服务端建立一条长连接,数据可以双向流动:

// 探针端:建立双向流连接

stream, err := c.client.Connect(ctx)
// 发送数据

stream.Send(&pb.AgentMessage{...})

// 接收服务端指令

msg, err := stream.Recv()

这样服务端可以随时推送更新指令、配置变更给探针,探针也能实时上报数据。

第二阶段:增量上报——带宽优化的艺术

问题:全量上报太浪费

探针每秒采集一次数据,每次上报的 SystemMetrics 结构体大概 5-10KB。如果有 100 个节点,每秒就是 500KB-1MB 的流量。

但仔细想想,大部分时候服务器状态是稳定的。CPU 从 30% 变成 31%,有必要上报吗?

解决方案:边缘计算 + 增量上报

我在探针端实现了一个 DeltaReporter,只有当指标变化超过阈值时才上报:

// probe/edge/delta.go

type DeltaConfig struct {

    CPUThreshold     float64 // CPU 变化超过 2% 才上报

    MemThreshold     float64 // 内存变化超过 1% 才上报

    DiskThreshold    float64 // 磁盘变化超过 0.5% 才上报

    NetworkThreshold float64 // 网络速率变化超过 10% 才上报

    ForceFullEvery   int     // 每 60 次强制全量上报

}

核心逻辑是对比当前指标和上次上报的指标,只发送变化的字段:

func (d DeltaReporter) ComputeDelta(current SystemMetrics) *DeltaResult {

    // 首次上报或强制全量

    if d.lastMetrics == nil || d.counter >= d.config.ForceFullEvery {

        d.lastMetrics = current

        return &DeltaResult{Metrics: current, IsDelta: false}

    }

    

    // 计算增量

    delta := &SystemMetrics{Timestamp: current.Timestamp}

    

    // CPU 变化检测

    if d.hasSignificantChange(d.lastMetrics.CPU.UsagePercent, 

                              current.CPU.UsagePercent, 

                              d.config.CPUThreshold) {

        delta.CPU = current.CPU

        hasChanges = true

    }

    // ... 其他字段类似

}

服务端合并:MetricsMerger

增量数据到了服务端,需要和缓存的完整数据合并:

// server/grpc/merger.go

func (m MetricsMerger) Merge(nodeID string, delta pb.SystemMetrics) MergeResult {

    if !delta.IsDelta {

        m.cache[nodeID] = delta

        return MergeResult{Metrics: delta}

    }

    

    cached := m.cache[nodeID]

    merged := m.mergeMetrics(cached, delta)

    m.cache[nodeID] = merged

    return MergeResult{Metrics: merged}

}

效果:带宽节省了 60-80%,而且对实时性几乎没有影响。

第三阶段:探针激活机制

问题:如何安全地管理探针?

探针部署在用户的服务器上,如何确保:

  1. 只有授权的探针才能上报数据

  2. 探针被盗用后可以快速撤销

  3. 部署过程尽量简单

解决方案:激活码 + Token 双重认证

第一步:生成激活码

探针首次启动时,生成一个 6 位激活码(排除了容易混淆的字符):

// probe/activation/activation.go

func generateActivationCode() string {

    const charset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // 排除 0OIL1

    code := make([]byte, 6)

    randomBytes := make([]byte, 6)

    rand.Read(randomBytes)

    

    for i := 0; i < 6; i++ {

        code[i] = charset[int(randomBytes[i])%len(charset)]

    }

    return string(code)

}

第二步:管理后台激活

用户在后台输入激活码,服务端下发 Token:

case *pb.ServerMessage_Activation:

    if payload.Activation.Success {

        c.SetToken(payload.Activation.Token)

        activation.Activate(payload.Activation.Token)

    }

第三步:HMAC 签名验证

每次上报数据都带上签名,防止伪造:

func (c Client) signMessage(msg pb.AgentMessage) {

    data := msg.NodeId + strconv.FormatInt(msg.Timestamp, 10)

    mac := hmac.New(sha256.New, []byte(c.token))

    mac.Write([]byte(data))

    msg.Signature = hex.EncodeToString(mac.Sum(nil))

}

第四阶段:探针自动更新

问题:如何远程更新探针?

探针部署在用户服务器上,总不能每次更新都让用户手动下载吧。

解决方案:服务端推送 + 签名验证

服务端推送更新指令:

// 通过 gRPC 流推送

stream.Send(&pb.ServerMessage{

    Payload: &pb.ServerMessage_Update{

        Update: &pb.UpdateCommand{

            Version:     "1.2.0",

            DownloadURL: "https://xxx/probe_linux_amd64",

            Checksum:    "sha256:...",

            Signature:   "ed25519:...",

        },

    },

})

探针端处理更新:

func (u Updater) DownloadAndUpdate(info UpdateInfo) error {

    // 1. 下载新版本

    u.downloadFile(info.DownloadURL, downloadPath)

    

    // 2. 校验 SHA256

    checksum := calculateSHA256(downloadPath)

    if checksum != info.Checksum {

        return fmt.Errorf("checksum mismatch")

    }
 // 3. 验证 Ed25519 签名

    if err := signing.VerifyFile(downloadPath, info.Signature); err != nil {

        return fmt.Errorf("signature verification failed")

    }
// 4. 替换并重启

    u.applyUpdate(oldPath, downloadPath)

}

第五阶段:实时推送——WebSocket Hub

问题:前端如何实时展示数据?

探针数据通过 gRPC 到达服务端,但前端是 HTTP/WebSocket。需要一个桥梁。

解决方案:BrowserHub

// server/websocket/browser_hub.go

type BrowserHub struct {

    clients         map[*BrowserClient]bool

    siteSubscribers map[uint]map[*BrowserClient]bool   // 订阅特定站点

    nodeSubscribers map[string]map[*BrowserClient]bool // 订阅特定节点

    allSubscribers  map[*BrowserClient]bool            // 订阅所有

}

前端可以选择订阅模式:

// 订阅所有更新

ws.send(JSON.stringify({ type: 'subscribe_all' }))

// 只订阅某个站点

ws.send(JSON.stringify({ type: 'subscribe_site', site_id: 123 }))

```

服务端收到探针数据后,广播给订阅的客户端:

```go

func (h *BrowserHub) BroadcastNodeMetrics(nodeID string, metrics interface{}) {

    msg := BrowserMessage{Type: "node_metrics", NodeID: nodeID, Payload: metrics}

    h.broadcastToNodeSubscribers(nodeID, msg)

}

第六阶段:数据清理——别让数据库爆炸

问题:监控数据增长太快

每个站点每 30 秒检测一次,每个节点每秒上报一次。一个月下来,数据量惊人。

解决方案:可配置的数据清理器

// server/cleaner/cleaner.go

type CleanerConfig struct {

    NodeMetricsRetentionDays      int // 节点指标保留 7 天

    SiteChecksRetentionDays       int // 站点检测保留 30 天

    NotificationLogsRetentionDays int // 通知日志保留 30 天

    ResolvedAlertsRetentionDays   int // 已解决告警保留 90 天

    CleanupInterval               time.Duration // 每小时清理一次

}

清理逻辑很简单,但有个细节:启动后延迟 5 分钟再执行首次清理,避免启动时负载过高。

func (c *Cleaner) Start() {

    go func() {

        // 启动后延迟 5 分钟

        select {

        case <-time.After(5 * time.Minute):

            c.RunCleanup()

        case <-c.stopChan:

            return

        }

        

        // 定时执行

        ticker := time.NewTicker(c.config.CleanupInterval)

        for {

            select {

            case <-ticker.C:

                c.RunCleanup()

            case <-c.stopChan:

                return

            }

        }

    }()

}

一些有意思的小细节

gRPC 错误信息翻译

gRPC 的错误信息对用户不友好,比如 error reading from server: EOF。我写了个翻译函数:

func translateGRPCError(err error) string {

    translations := []struct {

        pattern string

        message string

    }{

        {"error reading from server: EOF", "服务器关闭了连接"},

        {"connection refused", "服务器拒绝连接(服务可能未启动)"},

        {"context deadline exceeded", "连接超时(服务器无响应)"},

        // ...

    }

    

    for _, t := range translations {

        if contains(errStr, t.pattern) {

            return t.message

        }

    }

    return errStr

}

指数退避重连

网络断开后不能疯狂重连,要用指数退避:

// 1s -> 2s -> 4s -> 8s -> ... -> 60s (最大)

currentInterval = time.Duration(float64(currentInterval) * 2.0)

if currentInterval > 60 * time.Second {

    currentInterval = 60 * time.Second

}

探针退出交互

前台运行的探针按 Ctrl+C 时,不是直接退出,而是弹出选择菜单:

检测到退出信号,请选择操作:

[1] 安装为后台服务并退出

[2] 停止探针                         

[3] 继续前台运行

这个小细节让部署体验好了很多。

后记

如果你也在做类似的项目,希望这篇文章能给你一些参考。