概述
运维团队三个人,管着 200 台服务器。每天上班第一件事:挨个 SSH 登录,检查磁盘、内存、CPU、连接数、证书过期时间……一上午就过去了。遇到突发故障,根本来不及巡检,问题已经在用户投诉里炸了。
这不是个别现象。很多中小团队的运维巡检还停留在"人肉+脚本"的阶段——有几个 Shell 脚本,但散落在各个机器上,没人维护,没人知道上次跑是什么时候,输出也没人看。
自动化巡检平台解决的就是这个问题。不是简单的"把脚本集中起来跑",而是一套完整的体系:插件化的检查项、并发的执行引擎、灵活的告警策略、可读的巡检报告。这篇文章从架构设计到代码实现,讲清楚怎么用 Go 搭一个生产可用的巡检平台。
为什么选 Go 而不是 Python?三个原因:单二进制部署、协程并发模型天然适合巡检场景、交叉编译方便分发到不同架构的服务器。Python 也能做,但在 200 台机器上分发 Python 环境和依赖的痛苦,经历过的人都懂。
巡检平台要解决什么问题
传统巡检的痛点
先看看"人肉巡检"到底有多痛:
| 痛点 | 具体表现 | 影响 |
|---|---|---|
| 覆盖不全 | 200 台机器只检查了 50 台常用的,剩下的"反正没出过事" | 隐患积累,暴雷时措手不及 |
| 标准不统一 | A 用 df -h,B 用 df -hT,C 写了个 Python 脚本但只有自己看得懂 | 换人就抓瞎,结果无法横向对比 |
| 无历史记录 | 巡检结果写在个人笔记里,离职后带走了 | 无法追溯趋势变化,“上周还好的怎么突然满了” |
| 告警滞后 | 巡检发现磁盘 95%,但没人及时看到 | 从发现到处置间隔几小时甚至几天 |
| 人力浪费 | 3 个人每天花 2-3 小时巡检,月度耗时约 200 工时 | 相当于 1.25 个全职人力纯粹在做人肉检查 |
平台化巡检的核心能力
一个合格的巡检平台需要具备以下能力:
┌─────────────────────────────────────────────────────┐
│ 巡检平台核心能力 │
├──────────┬──────────┬──────────┬──────────┬─────────┤
│ 检查引擎 │ 调度系统 │ 告警联动 │ 报告生成 │ 资产管理 │
│ │ │ │ │ │
│ 插件化 │ 定时触发 │ 多级阈值 │ HTML报告 │ 主机清单 │
│ 并发执行 │ 手动触发 │ 告警去重 │ 趋势图表 │ 分组管理 │
│ 超时控制 │ 补偿执行 │ 通知渠道 │ 差异对比 │ 标签体系 │
└──────────┴──────────┴──────────┴──────────┴─────────┘
简单说:配置好检查项和阈值,平台自动在指定时间执行,发现问题立即告警,每次结果都存档可追溯。
架构设计
整体架构
┌──────────────────────────────────────────────────────┐
│ API / Web UI │
│ (配置管理、报告查看、手动触发) │
├──────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ 调度引擎 │ │ 执行引擎 │ │ 告警引擎 │ │
│ │ │ │ │ │ │ │
│ │ Cron 触发 │──││ Worker Pool │──││ 阈值判断 │ │
│ │ 手动触发 │ │ 并发执行 │ │ 去重合并 │ │
│ │ 补偿执行 │ │ 超时控制 │ │ 多渠道通知 │ │
│ └─────────────┘ └──────────────┘ └─────────────┘ │
│ │ │ │ │
│ │ ┌──────┴──────┐ │ │
│ │ │ 插件注册表 │ │ │
│ │ │ │ │ │
│ │ │ disk_check │ │ │
│ │ │ cpu_check │ │ │
│ │ │ cert_check │ │ │
│ │ │ conn_check │ │ │
│ │ │ ... │ │ │
│ │ └─────────────┘ │ │
│ │ │ │ │
│ ┌──────┴────────────────┴────────────────┴──────┐ │
│ │ 存储层 │ │
│ │ SQLite/PostgreSQL (巡检结果、配置、资产) │ │
│ └───────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
分层职责
| 层 | 职责 | 技术选型 |
|---|---|---|
| 接入层 | 配置管理、报告展示、手动触发 | HTTP API + 简单 Web UI |
| 调度层 | 定时触发巡检任务、任务队列管理 | Go cron 库 + channel |
| 执行层 | 并发执行检查插件、超时控制 | goroutine + Worker Pool |
| 插件层 | 具体检查项逻辑(磁盘、CPU、证书等) | Go interface 插件化 |
| 告警层 | 阈值判断、告警去重、通知发送 | webhook + email + IM |
| 存储层 | 巡检结果、配置、资产信息持久化 | SQLite(单机)或 PostgreSQL(集群) |
为什么用插件化
巡检项是不断变化的。今天检查磁盘,明天要加证书过期检查,后天又要加数据库连接数检查。如果检查逻辑硬编码在主程序里,每次加检查项都要改主程序、重新编译、重新部署。
插件化的好处:新增检查项只需写一个插件文件,注册到插件管理器,重启平台即可生效。不影响现有逻辑,不需要改主程序代码。
Go 的插件化有几种实现方式:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Go plugin 包 | 原生支持,性能好 | 仅 Linux/macOS,编译版本必须一致 | 纯 Go 环境,平台可控 |
| interface + 注册 | 简单可靠,跨平台 | 需要重新编译主程序 | 中小规模,检查项不频繁变更 |
| gRPC 插件 | 语言无关,进程隔离 | 复杂度高,有 RPC 开销 | 多语言插件、强隔离需求 |
| 执行外部脚本 | 最灵活,任何语言都能写 | 性能差,依赖外部环境 | 快速原型、遗留脚本复用 |
综合考虑跨平台和部署便利性,我选择 interface + 注册 方案。虽然新增插件需要重新编译,但 Go 编译速度快,且可以通过配置文件控制哪些插件启用,灵活性够用。
核心代码实现
项目结构
sre-inspector/
├── cmd/
│ └── inspector/
│ └── main.go # 程序入口
├── internal/
│ ├── config/
│ │ └── config.go # 配置加载
│ ├── scheduler/
│ │ └── scheduler.go # 调度引擎
│ ├── executor/
│ │ └── executor.go # 执行引擎
│ ├── checker/
│ │ ├── checker.go # Checker 接口定义
│ │ ├── registry.go # 插件注册表
│ │ ├── disk.go # 磁盘检查插件
│ │ ├── cpu.go # CPU 检查插件
│ │ ├── memory.go # 内存检查插件
│ │ ├── certificate.go # 证书过期检查插件
│ │ ├── connection.go # 连接数检查插件
│ │ └── process.go # 关键进程检查插件
│ ├── alert/
│ │ └── alert.go # 告警引擎
│ ├── report/
│ │ └── report.go # 报告生成
│ └── storage/
│ └── storage.go # 数据存储
├── configs/
│ └── inspector.yaml # 配置文件
├── go.mod
└── go.sum
Checker 接口定义
插件化的核心是定义好接口。每个检查项都实现这个接口:
// internal/checker/checker.go
package checker
import (
"context"
"time"
)
// CheckResult 单次检查的结果
type CheckResult struct {
CheckerName string // 检查项名称
Target string // 检查目标(IP、主机名等)
Status Status // 状态:正常/警告/严重
Message string // 结果描述
Metrics map[string]float64 // 指标数据(磁盘使用率、CPU负载等)
CheckedAt time.Time // 检查时间
Duration time.Duration // 检查耗时
}
// Status 检查状态
type Status int
const (
StatusOK Status = 0 // 正常
StatusWarning Status = 1 // 警告
StatusCritical Status = 2 // 严重
StatusUnknown Status = 3 // 未知(检查失败)
)
func (s Status) String() string {
switch s {
case StatusOK:
return "OK"
case StatusWarning:
return "WARNING"
case StatusCritical:
return "CRITICAL"
default:
return "UNKNOWN"
}
}
// Checker 检查器接口,所有插件必须实现
type Checker interface {
// Name 返回检查器名称
Name() string
// Description 返回检查器描述
Description() string
// Check 执行检查
// ctx 用于超时控制和取消
// target 是检查目标(主机IP、主机名等)
// params 是检查参数(阈值等)
Check(ctx context.Context, target string, params map[string]string) CheckResult
}
插件注册表
// internal/checker/registry.go
package checker
import (
"fmt"
"sync"
)
// Registry 插件注册表
type Registry struct {
mu sync.RWMutex
checkers map[string]Checker
}
// NewRegistry 创建注册表实例
func NewRegistry() *Registry {
return &Registry{
checkers: make(map[string]Checker),
}
}
// Register 注册一个检查器
func (r *Registry) Register(c Checker) error {
r.mu.Lock()
defer r.mu.Unlock()
name := c.Name()
if _, exists := r.checkers[name]; exists {
return fmt.Errorf("checker %q already registered", name)
}
r.checkers[name] = c
return nil
}
// Get 获取指定检查器
func (r *Registry) Get(name string) (Checker, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
c, ok := r.checkers[name]
return c, ok
}
// List 返回所有已注册的检查器名称
func (r *Registry) List() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.checkers))
for name := range r.checkers {
names = append(names, name)
}
return names
}
// RegisterBuiltin 注册内置检查器
func (r *Registry) RegisterBuiltin() {
r.Register(&DiskChecker{})
r.Register(&CPUChecker{})
r.Register(&MemoryChecker{})
r.Register(&CertificateChecker{})
r.Register(&ConnectionChecker{})
r.Register(&ProcessChecker{})
}
磁盘检查插件实现
以磁盘检查为例,展示一个完整插件的写法:
// internal/checker/disk.go
package checker
import (
"context"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
)
// DiskChecker 磁盘使用率检查器
type DiskChecker struct{}
func (d *DiskChecker) Name() string { return "disk" }
func (d *DiskChecker) Description() string { return "检查磁盘分区使用率" }
func (d *DiskChecker) Check(ctx context.Context, target string, params map[string]string) CheckResult {
start := time.Now()
// 解析阈值参数,默认警告80% 严重95%
warnThreshold := parseFloatParam(params, "warning", 80.0)
critThreshold := parseFloatParam(params, "critical", 95.0)
// 通过 SSH 执行远程命令(如果 target 不是本机)
// 这里用 SSH 简化演示,实际可以用 SSH 连接池
cmd := exec.CommandContext(ctx, "ssh", "-o", "ConnectTimeout=5", target, "df -h --output=pcent,target -x tmpfs -x devtmpfs")
output, err := cmd.CombinedOutput()
if err != nil {
return CheckResult{
CheckerName: d.Name(),
Target: target,
Status: StatusUnknown,
Message: fmt.Sprintf("SSH 执行失败: %v, output: %s", err, string(output)),
CheckedAt: start,
Duration: time.Since(start),
}
}
// 解析 df 输出,找出使用率最高的分区
var maxUsage float64
var maxPartitions []string
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
for i, line := range lines {
if i == 0 {
continue // 跳过表头
}
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
// fields[0] = "45%" fields[1] = "/data"
usageStr := strings.TrimSuffix(fields[0], "%")
usage, err := strconv.ParseFloat(usageStr, 64)
if err != nil {
continue
}
if usage > maxUsage {
maxUsage = usage
maxPartitions = append(maxPartitions[:0], fields[1])
} else if usage == maxUsage {
maxPartitions = append(maxPartitions, fields[1])
}
}
// 根据阈值判断状态
status := StatusOK
message := fmt.Sprintf("磁盘使用率最高: %.1f%% (%s)", maxUsage, strings.Join(maxPartitions, ", "))
if maxUsage >= critThreshold {
status = StatusCritical
message = fmt.Sprintf("磁盘使用率严重: %.1f%% (%s) >= %.0f%%", maxUsage, strings.Join(maxPartitions, ", "), critThreshold)
} else if maxUsage >= warnThreshold {
status = StatusWarning
message = fmt.Sprintf("磁盘使用率告警: %.1f%% (%s) >= %.0f%%", maxUsage, strings.Join(maxPartitions, ", "), warnThreshold)
}
return CheckResult{
CheckerName: d.Name(),
Target: target,
Status: status,
Message: message,
Metrics: map[string]float64{"disk_usage_percent": maxUsage},
CheckedAt: start,
Duration: time.Since(start),
}
}
func parseFloatParam(params map[string]string, key string, defaultVal float64) float64 {
if val, ok := params[key]; ok {
if f, err := strconv.ParseFloat(val, 64); err == nil {
return f
}
}
return defaultVal
}
执行引擎:并发控制
巡检 200 台机器,串行执行要等半小时。并发执行是必须的,但不能无限并发——SSH 连接数太多会把目标机器的 sshd 撑爆,也会把本机 fd 耗尽。
// internal/executor/executor.go
package executor
import (
"context"
"log"
"sync"
"time"
"sre-inspector/internal/checker"
)
// Executor 执行引擎
type Executor struct {
registry *checker.Registry
maxWorkers int // 最大并发数
timeout time.Duration // 单次检查超时
}
// NewExecutor 创建执行引擎
func NewExecutor(registry *checker.Registry, maxWorkers int, timeout time.Duration) *Executor {
return &Executor{
registry: registry,
maxWorkers: maxWorkers,
timeout: timeout,
}
}
// Task 单个巡检任务
type Task struct {
CheckerName string // 检查器名称
Target string // 目标主机
Params map[string]string // 检查参数
}
// RunBatch 批量执行巡检任务
func (e *Executor) RunBatch(tasks []Task) []checker.CheckResult {
// 用带缓冲的 channel 做任务队列
taskCh := make(chan Task, len(tasks))
resultCh := make(chan checker.CheckResult, len(tasks))
// 填充任务队列
for _, task := range tasks {
taskCh <- task
}
close(taskCh)
// 启动 worker pool
var wg sync.WaitGroup
for i := 0; i < e.maxWorkers; i++ {
wg.Add(1)
go e.worker(&wg, taskCh, resultCh)
}
// 等待所有 worker 完成
go func() {
wg.Wait()
close(resultCh)
}()
// 收集结果
results := make([]checker.CheckResult, 0, len(tasks))
for result := range resultCh {
results = append(results, result)
}
return results
}
// worker 工作协程
func (e *Executor) worker(wg *sync.WaitGroup, taskCh <-chan Task, resultCh chan<- checker.CheckResult) {
defer wg.Done()
for task := range taskCh {
result := e.runSingle(task)
resultCh <- result
}
}
// runSingle 执行单个检查任务
func (e *Executor) runSingle(task Task) checker.CheckResult {
c, ok := e.registry.Get(task.CheckerName)
if !ok {
return checker.CheckResult{
CheckerName: task.CheckerName,
Target: task.Target,
Status: checker.StatusUnknown,
Message: fmt.Sprintf("检查器 %q 未注册", task.CheckerName),
}
}
// 带超时的 context
ctx, cancel := context.WithTimeout(context.Background(), e.timeout)
defer cancel()
// 执行检查
result := c.Check(ctx, task.Target, task.Params)
// 记录慢检查
if result.Duration > 5*time.Second {
log.Printf("[WARN] 检查耗时较长: checker=%s target=%s duration=%s",
task.CheckerName, task.Target, result.Duration)
}
return result
}
调度引擎
// internal/scheduler/scheduler.go
package scheduler
import (
"context"
"fmt"
"log"
"sync"
"time"
"sre-inspector/internal/checker"
"sre-inspector/internal/executor"
)
// ScheduledJob 定时巡检任务
type ScheduledJob struct {
Name string // 任务名称
Cron string // cron 表达式
Checkers []string // 要执行的检查器列表
Targets []string // 目标主机列表
Params map[string]map[string]string // 每个检查器的参数
}
// Scheduler 调度引擎
type Scheduler struct {
jobs []*ScheduledJob
executor *executor.Executor
alertCh chan checker.CheckResult // 告警通道
mu sync.Mutex
running bool
}
// NewScheduler 创建调度器
func NewScheduler(exec *executor.Executor, alertCh chan checker.CheckResult) *Scheduler {
return &Scheduler{
executor: exec,
alertCh: alertCh,
}
}
// AddJob 添加定时任务
func (s *Scheduler) AddJob(job *ScheduledJob) {
s.mu.Lock()
defer s.mu.Unlock()
s.jobs = append(s.jobs, job)
}
// Start 启动调度器
func (s *Scheduler) Start(ctx context.Context) {
s.mu.Lock()
s.running = true
s.mu.Unlock()
for _, job := range s.jobs {
go s.runJob(ctx, job)
}
log.Printf("调度器已启动,共 %d 个定时任务", len(s.jobs))
}
// runJob 运行单个定时任务
func (s *Scheduler) runJob(ctx context.Context, job *ScheduledJob) {
// 解析 cron 表达式,计算下次执行时间
// 这里用简化版:按固定间隔执行
interval, err := parseCronInterval(job.Cron)
if err != nil {
log.Printf("[ERROR] 解析 cron 表达式失败: job=%s cron=%s err=%v", job.Name, job.Cron, err)
return
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
log.Printf("[INFO] 任务 %s 已注册,执行间隔: %v", job.Name, interval)
for {
select {
case <-ctx.Done():
log.Printf("[INFO] 任务 %s 已停止", job.Name)
return
case <-ticker.C:
log.Printf("[INFO] 开始执行巡检任务: %s", job.Name)
s.executeJob(job)
}
}
}
// executeJob 执行一次完整的巡检
func (s *Scheduler) executeJob(job *ScheduledJob) {
start := time.Now()
// 构建任务列表:检查器 × 目标主机 的笛卡尔积
var tasks []executor.Task
for _, checkerName := range job.Checkers {
params := job.Params[checkerName]
for _, target := range job.Targets {
tasks = append(tasks, executor.Task{
CheckerName: checkerName,
Target: target,
Params: params,
})
}
}
log.Printf("[INFO] 任务 %s: 共 %d 个检查项", job.Name, len(tasks))
// 批量执行
results := s.executor.RunBatch(tasks)
// 统计
var okCount, warnCount, critCount, unknownCount int
for _, r := range results {
switch r.Status {
case checker.StatusOK:
okCount++
case checker.StatusWarning:
warnCount++
s.alertCh <- r // 警告级推送到告警通道
case checker.StatusCritical:
critCount++
s.alertCh <- r // 严重级推送到告警通道
default:
unknownCount++
s.alertCh <- r // 未知也推送,可能是检查失败
}
}
log.Printf("[INFO] 任务 %s 完成: 耗时=%s 正常=%d 警告=%d 严重=%d 未知=%d",
job.Name, time.Since(start), okCount, warnCount, critCount, unknownCount)
}
// parseCronInterval 简化版 cron 解析
// 支持格式: "*/30 * * * *" → 30分钟间隔
// 这里简化处理,实际建议用 robfig/cron 库
func parseCronInterval(cronExpr string) (time.Duration, error) {
// 简化实现:只支持 "every Nm" 或 "every Nh" 格式
var num int
var unit string
_, err := fmt.Sscanf(cronExpr, "every %d%c", &num, &unit)
if err != nil {
// 默认每小时执行一次
return time.Hour, nil
}
switch unit {
case 'm':
return time.Duration(num) * time.Minute, nil
case 'h':
return time.Duration(num) * time.Hour, nil
default:
return time.Hour, fmt.Errorf("unsupported time unit: %c", unit)
}
}
告警引擎
// internal/alert/alert.go
package alert
import (
"fmt"
"log"
"strings"
"sync"
"time"
"sre-inspector/internal/checker"
)
// AlertConfig 告警配置
type AlertConfig struct {
WebhookURL string // Webhook 通知地址
EmailTo []string // 邮件通知收件人
DingtalkToken string // 钉钉机器人 token
RepeatInterval time.Duration // 重复告警抑制间隔
}
// AlertEngine 告警引擎
type AlertEngine struct {
config AlertConfig
recentAlerts sync.Map // 去重缓存: key="checker:target" → lastAlertTime
}
// NewAlertEngine 创建告警引擎
func NewAlertEngine(config AlertConfig) *AlertEngine {
return &AlertEngine{config: config}
}
// Process 处理告警结果
func (a *AlertEngine) Process(result checker.CheckResult) {
if result.Status == checker.StatusOK {
return // 正常结果不告警
}
key := fmt.Sprintf("%s:%s", result.CheckerName, result.Target)
// 去重:同一检查项+目标在 RepeatInterval 内不重复告警
if lastTime, ok := a.recentAlerts.Load(key); ok {
if time.Since(lastTime.(time.Time)) < a.config.RepeatInterval {
return
}
}
a.recentAlerts.Store(key, time.Now())
// 构建告警消息
title, message := a.buildMessage(result)
log.Printf("[ALERT] %s: %s", title, message)
// 发送通知(按配置的渠道)
if a.config.WebhookURL != "" {
a.sendWebhook(title, message, result)
}
if a.config.DingtalkToken != "" {
a.sendDingtalk(title, message)
}
}
// buildMessage 构建告警消息
func (a *AlertEngine) buildMessage(result checker.CheckResult) (string, string) {
severity := "WARNING"
icon := "⚠️"
if result.Status == checker.StatusCritical {
severity = "CRITICAL"
icon = "🔴"
} else if result.Status == checker.StatusUnknown {
severity = "UNKNOWN"
icon = "❓"
}
title := fmt.Sprintf("%s [%s] 巡检告警: %s @ %s", icon, severity, result.CheckerName, result.Target)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**检查项**: %s\n", result.CheckerName))
sb.WriteString(fmt.Sprintf("**目标主机**: %s\n", result.Target))
sb.WriteString(fmt.Sprintf("**状态**: %s\n", severity))
sb.WriteString(fmt.Sprintf("**详情**: %s\n", result.Message))
sb.WriteString(fmt.Sprintf("**检查时间**: %s\n", result.CheckedAt.Format("2006-01-02 15:04:05")))
sb.WriteString(fmt.Sprintf("**耗时**: %s\n", result.Duration))
if len(result.Metrics) > 0 {
sb.WriteString("**指标**:\n")
for k, v := range result.Metrics {
sb.WriteString(fmt.Sprintf(" - %s: %.2f\n", k, v))
}
}
return title, sb.String()
}
// sendWebhook 发送 Webhook 通知
func (a *AlertEngine) sendWebhook(title, message string, result checker.CheckResult) {
// 实际实现用 http.Post 发送 JSON
// 省略 HTTP 调用细节
log.Printf("[WEBHOOK] 发送告警到 %s: %s", a.config.WebhookURL, title)
}
// sendDingtalk 发送钉钉通知
func (a *AlertEngine) sendDingtalk(title, message string) {
// 实际实现调用钉钉机器人 API
log.Printf("[DINGTALK] 发送告警: %s", title)
}
// Start 启动告警引擎,监听结果通道
func (a *AlertEngine) Start(alertCh <-chan checker.CheckResult) {
for result := range alertCh {
a.Process(result)
}
}
配置文件
# configs/inspector.yaml
# 执行引擎配置
executor:
max_workers: 20 # 最大并发数
timeout: 30s # 单次检查超时
# 告警配置
alert:
webhook_url: "https://hooks.example.com/inspector"
dingtalk_token: ""
repeat_interval: 30m # 同一告警30分钟内不重复
# 存储配置
storage:
type: sqlite # sqlite 或 postgres
dsn: "/var/lib/inspector/inspector.db"
# 资产清单
targets:
- host: "10.0.0.5"
name: "web-01"
tags: ["web", "production"]
- host: "10.0.0.6"
name: "web-02"
tags: ["web", "production"]
- host: "10.0.0.10"
name: "db-01"
tags: ["database", "production"]
# 检查器配置
checkers:
- name: disk
enabled: true
params:
warning: "80"
critical: "95"
- name: cpu
enabled: true
params:
warning: "70"
critical: "90"
interval: "5m" # 取5分钟平均负载
- name: memory
enabled: true
params:
warning: "80"
critical: "95"
- name: certificate
enabled: true
params:
warning: "30" # 30天前告警
critical: "7" # 7天前严重
domains: "api.example.com,admin.example.com"
- name: connection
enabled: true
params:
warning: "5000"
critical: "10000"
- name: process
enabled: true
params:
processes: "nginx,mysql,redis" # 必须存活的进程列表
# 定时任务
jobs:
- name: "每日全面巡检"
schedule: "every 6h"
checkers: ["disk", "cpu", "memory", "certificate", "connection", "process"]
target_tags: ["production"]
- name: "数据库专项巡检"
schedule: "every 1h"
checkers: ["disk", "memory", "process"]
target_tags: ["database"]
- name: "证书每日检查"
schedule: "every 24h"
checkers: ["certificate"]
target_tags: ["production"]
主程序入口
// cmd/inspector/main.go
package main
import (
"context"
"flag"
"log"
"os"
"os/signal"
"syscall"
"time"
"sre-inspector/internal/alert"
"sre-inspector/internal/checker"
"sre-inspector/internal/config"
"sre-inspector/internal/executor"
"sre-inspector/internal/report"
"sre-inspector/internal/scheduler"
"sre-inspector/internal/storage"
)
func main() {
configPath := flag.String("config", "configs/inspector.yaml", "配置文件路径")
once := flag.Bool("once", false, "执行一次巡检后退出(不做定时调度)")
flag.Parse()
// 加载配置
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
// 初始化插件注册表
registry := checker.NewRegistry()
registry.RegisterBuiltin()
log.Printf("已注册检查器: %v", registry.List())
// 创建告警通道
alertCh := make(chan checker.CheckResult, 1000)
// 初始化执行引擎
exec := executor.NewExecutor(
registry,
cfg.Executor.MaxWorkers,
cfg.Executor.Timeout,
)
// 初始化告警引擎
alertEngine := alert.NewAlertEngine(cfg.Alert)
go alertEngine.Start(alertCh)
// 初始化存储
store, err := storage.New(cfg.Storage)
if err != nil {
log.Fatalf("初始化存储失败: %v", err)
}
defer store.Close()
// 初始化报告生成器
reportGen := report.NewGenerator(store)
// 如果是一次性执行模式
if *once {
runOnce(cfg, exec, alertCh, store, reportGen)
return
}
// 初始化调度器
sched := scheduler.NewScheduler(exec, alertCh)
for _, jobCfg := range cfg.Jobs {
job := buildJobFromConfig(jobCfg, cfg)
sched.AddJob(job)
}
// 启动调度器
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sched.Start(ctx)
log.Println("巡检平台已启动,按 Ctrl+C 退出")
// 等待退出信号
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("正在关闭...")
cancel()
time.Sleep(2 * time.Second) // 给 goroutine 留时间优雅退出
log.Println("已退出")
}
// runOnce 执行一次巡检
func runOnce(cfg *config.Config, exec *executor.Executor, alertCh chan checker.CheckResult, store *storage.Storage, reportGen *report.Generator) {
log.Println("开始一次性巡检...")
start := time.Now()
// 构建任务
var tasks []executor.Task
for _, checkerCfg := range cfg.Checkers {
if !checkerCfg.Enabled {
continue
}
for _, target := range cfg.Targets {
tasks = append(tasks, executor.Task{
CheckerName: checkerCfg.Name,
Target: target.Host,
Params: checkerCfg.Params,
})
}
}
results := exec.RunBatch(tasks)
// 存储结果
store.SaveResults(results)
// 发送告警
for _, r := range results {
if r.Status != checker.StatusOK {
alertCh <- r
}
}
// 生成报告
reportPath := reportGen.GenerateHTML(results)
log.Printf("巡检完成: 耗时=%s 报告=%s", time.Since(start), reportPath)
// 打印摘要
var ok, warn, crit, unk int
for _, r := range results {
switch r.Status {
case checker.StatusOK:
ok++
case checker.StatusWarning:
warn++
case checker.StatusCritical:
crit++
default:
unk++
}
}
log.Printf("汇总: 正常=%d 警告=%d 严重=%d 未知=%d", ok, warn, crit, unk)
}
巡检报告生成
巡检的最终产出是报告。报告不是给机器看的,是给人看的——所以要直观、可读、能快速定位问题。
HTML 报告结构
// internal/report/report.go
package report
import (
"fmt"
"html/template"
"os"
"path/filepath"
"time"
"sre-inspector/internal/checker"
)
type Generator struct {
tmpl *template.Template
}
func NewGenerator(store *storage.Storage) *Generator {
return &Generator{}
}
// ReportData 报告数据
type ReportData struct {
GeneratedAt time.Time
TotalCount int
OKCount int
WarningCount int
CriticalCount int
UnknownCount int
Results []checker.CheckResult
Summary map[string]map[string]int // 按主机分组的统计
}
// GenerateHTML 生成 HTML 报告
func (g *Generator) GenerateHTML(results []checker.CheckResult) string {
data := g.prepareData(results)
tmpl := template.Must(template.New("report").Parse(reportTemplate))
filename := fmt.Sprintf("inspection_%s.html", time.Now().Format("20060102_150405"))
filepath := filepath.Join("/var/lib/inspector/reports", filename)
os.MkdirAll(filepath, 0755)
f, err := os.Create(filepath)
if err != nil {
return ""
}
defer f.Close()
tmpl.Execute(f, data)
return filepath
}
func (g *Generator) prepareData(results []checker.CheckResult) ReportData {
data := ReportData{
GeneratedAt: time.Now(),
TotalCount: len(results),
Results: results,
Summary: make(map[string]map[string]int),
}
for _, r := range results {
if data.Summary[r.Target] == nil {
data.Summary[r.Target] = make(map[string]int)
}
data.Summary[r.Target][r.Status.String()]++
switch r.Status {
case checker.StatusOK:
data.OKCount++
case checker.StatusWarning:
data.WarningCount++
case checker.StatusCritical:
data.CriticalCount++
default:
data.UnknownCount++
}
}
return data
}
// reportTemplate HTML 报告模板(简化版)
const reportTemplate = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>巡检报告 - {{.GeneratedAt.Format "2006-01-02 15:04"}}</title>
<style>
body { font-family: -apple-system, sans-serif; margin: 40px; }
.summary { display: flex; gap: 20px; margin-bottom: 30px; }
.card { padding: 20px; border-radius: 8px; color: white; }
.ok { background: #4caf50; }
.warning { background: #ff9800; }
.critical { background: #f44336; }
.unknown { background: #9e9e9e; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #f5f5f5; }
.status-ok { color: #4caf50; }
.status-warning { color: #ff9800; }
.status-critical { color: #f44336; }
.status-unknown { color: #9e9e9e; }
</style>
</head>
<body>
<h1>巡检报告</h1>
<p>生成时间: {{.GeneratedAt.Format "2006-01-02 15:04:05"}}</p>
<div class="summary">
<div class="card ok"><h2>{{.OKCount}}</h2><p>正常</p></div>
<div class="card warning"><h2>{{.WarningCount}}</h2><p>警告</p></div>
<div class="card critical"><h2>{{.CriticalCount}}</h2><p>严重</p></div>
<div class="card unknown"><h2>{{.UnknownCount}}</h2><p>未知</p></div>
</div>
<h2>详细结果</h2>
<table>
<tr>
<th>检查项</th>
<th>目标主机</th>
<th>状态</th>
<th>详情</th>
<th>耗时</th>
</tr>
{{range .Results}}
<tr>
<td>{{.CheckerName}}</td>
<td>{{.Target}}</td>
<td class="status-{{.Status.String | toLower}}">{{.Status}}</td>
<td>{{.Message}}</td>
<td>{{.Duration}}</td>
</tr>
{{end}}
</table>
</body>
</html>
`
生产部署方案
二进制部署
# 交叉编译
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/inspector ./cmd/inspector
# 部署到服务器
scp bin/inspector configs/inspector.yaml user@server:/opt/inspector/
# 创建 systemd 服务
cat > /etc/systemd/system/inspector.service << 'EOF'
[Unit]
Description=SRE Inspection Platform
After=network.target
[Service]
Type=simple
User=inspector
WorkingDirectory=/opt/inspector
ExecStart=/opt/inspector/inspector -config /opt/inspector/configs/inspector.yaml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable inspector
systemctl start inspector
SSH 免密配置
巡检需要 SSH 到目标机器执行命令。用密钥认证,别用密码:
# 在巡检服务器上生成专用密钥
ssh-keygen -t ed25519 -f /home/inspector/.ssh/inspector_key -N ""
# 分发公钥到目标机器
for host in 10.0.0.{5..50}; do
ssh-copy-id -i /home/inspector/.ssh/inspector_key.pub inspector@$host
done
# 配置 SSH 别名,简化连接
cat >> /home/inspector/.ssh/config << 'EOF'
Host *
IdentityFile ~/.ssh/inspector_key
StrictHostKeyChecking no
ConnectTimeout 5
ServerAliveInterval 30
EOF
安全注意事项
- 最小权限原则:巡检用户只给只读权限,别用 root 跑巡检。创建专用的
inspector用户,sudo 白名单只允许特定只读命令。
# /etc/sudoers.d/inspector
inspector ALL=(ALL) NOPASSWD: /usr/bin/df, /usr/bin/free, /usr/bin/uptime, /usr/bin/ss, /usr/bin/ps
SSH 密钥保护:巡检服务器的私钥泄露 = 所有目标机器沦陷。私钥文件权限 600,最好放在硬件安全模块或密钥管理服务中。
巡检数据敏感:巡检结果包含主机信息、进程列表、网络连接等,属于敏感运维数据。报告文件存放在受控目录,定期清理过期报告。
网络隔离:巡检服务器能访问所有目标机器,本身就是高价值攻击目标。放在管理网络中,限制入站访问。
常见检查项实现思路
证书过期检查
// 核心逻辑:用 TLS 连接目标域名,读取证书链,检查 NotAfter 字段
func checkCertificate(domain string, warnDays, critDays int) CheckResult {
conn, err := tls.Dial("tcp", domain+":443", &tls.Config{
InsecureSkipVerify: true, // 我们就是要检查过期证书,不能因为过期就连不上
})
if err != nil {
return CheckResult{Status: StatusUnknown, Message: fmt.Sprintf("TLS 连接失败: %v", err)}
}
defer conn.Close()
cert := conn.ConnectionState().PeerCertificates[0]
daysLeft := int(time.Until(cert.NotAfter).Hours() / 24)
status := StatusOK
message := fmt.Sprintf("证书还有 %d 天过期 (到期日: %s)", daysLeft, cert.NotAfter.Format("2006-01-02"))
if daysLeft <= critDays {
status = StatusCritical
message = fmt.Sprintf("证书即将过期!仅剩 %d 天 (到期日: %s)", daysLeft, cert.NotAfter.Format("2006-01-02"))
} else if daysLeft <= warnDays {
status = StatusWarning
message = fmt.Sprintf("证书将在 %d 天后过期 (到期日: %s)", daysLeft, cert.NotAfter.Format("2006-01-02"))
}
return CheckResult{
Status: status,
Message: message,
Metrics: map[string]float64{"days_until_expiry": float64(daysLeft)},
}
}
关键进程存活检查
// 通过 SSH 执行 pgrep 检查进程是否存在
func checkProcess(ctx context.Context, target string, processNames []string) CheckResult {
var missing []string
for _, name := range processNames {
cmd := exec.CommandContext(ctx, "ssh", target, fmt.Sprintf("pgrep -x %s", name))
if err := cmd.Run(); err != nil {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return CheckResult{
Status: StatusCritical,
Message: fmt.Sprintf("以下进程未运行: %s", strings.Join(missing, ", ")),
}
}
return CheckResult{Status: StatusOK, Message: "所有关键进程正常运行"}
}
TCP 连接数检查
// 通过 SSH 执行 ss 统计当前连接数
func checkConnectionCount(ctx context.Context, target string, warn, crit int) CheckResult {
cmd := exec.CommandContext(ctx, "ssh", target, "ss -tn state established | wc -l")
output, err := cmd.Output()
if err != nil {
return CheckResult{Status: StatusUnknown, Message: fmt.Sprintf("执行失败: %v", err)}
}
count := 0
fmt.Sscanf(strings.TrimSpace(string(output)), "%d", &count)
status := StatusOK
message := fmt.Sprintf("当前 ESTABLISHED 连接数: %d", count)
if count >= crit {
status = StatusCritical
message = fmt.Sprintf("连接数严重: %d >= %d", count, crit)
} else if count >= warn {
status = StatusWarning
message = fmt.Sprintf("连接数告警: %d >= %d", count, warn)
}
return CheckResult{
Status: status,
Message: message,
Metrics: map[string]float64{"established_connections": float64(count)},
}
}
与现有监控系统的关系
巡检平台和 Prometheus 这类监控系统不冲突,定位不同:
| 维度 | 巡检平台 | Prometheus |
|---|---|---|
| 检查频率 | 低频(每小时/每天) | 高频(每15-60秒) |
| 检查范围 | 综合性(磁盘+证书+进程+配置) | 指标为主(CPU/内存/流量) |
| 告警方式 | 巡检报告+即时告警 | 实时告警 |
| 适用场景 | 定期体检、合规检查、配置审计 | 实时监控、动态告警 |
| 数据保留 | 长期(月度/季度趋势) | 中短期(通常15-90天) |
简单说:Prometheus 是心电图,7x24 实时监测;巡检平台是年度体检,定期全面检查。两者互补,不能互相替代。
巡检平台可以做一些 Prometheus 不方便做的事:
- 证书过期检查(需要主动 TLS 连接)
- 配置文件合规性检查(需要读文件内容)
- 多机横向对比(同一指标跨所有主机的分布)
- 离线报告生成(不需要长期运行的监控服务)
扩展方向
这个平台的架构支持后续扩展:
Agent 模式:当前是 SSH 远程执行模式,适合中小规模。超过 500 台机器后 SSH 开销显著,可以开发轻量 Agent 部署到目标机器,通过 gRPC 上报结果。
Web 管理界面:当前配置通过 YAML 文件管理。可以加一层 Web UI,支持可视化配置检查项、查看历史趋势图、一键生成报告。
自动修复:巡检发现问题后,不只是告警,还可以触发自动修复脚本。比如磁盘满了自动清理日志、进程挂了自动重启。但自动修复要谨慎——错误的修复动作比问题本身更危险。
趋势分析:历史巡检数据存入数据库后,可以做趋势分析。“过去 30 天磁盘使用率的增长率是多少?按当前速度还有多少天到 95%?“这类预测性分析对容量规划很有价值。
合规检查:在检查项中加入合规规则——密码策略是否符合要求、SSH 是否禁止 root 登录、防火墙规则是否正确。把安全合规扫描整合进日常巡检。
总结
自动化巡检平台的核心价值在于:把重复的人肉巡检变成自动化的系统工程,让运维人员从"天天检查"中解放出来,把精力花在真正需要人判断的事情上。
架构设计的关键决策:
- 插件化架构:Checker 接口 + Registry 注册表,新增检查项不改主程序,扩展性好。
- Worker Pool 并发:用 goroutine + channel 控制并发数,200 台机器 20 并发 3 分钟跑完,比串行快 10 倍。
- 告警去重:同一问题在抑制窗口内不重复告警,避免告警风暴。实测在 200 台机器规模下,一次磁盘满的告警从原来 50 条去重到 1 条。
- 配置驱动:检查项、阈值、目标主机、调度计划全在 YAML 配置文件里,修改配置不需要重新编译。
- Go 单二进制部署:一个 15MB 的二进制文件 + 一个 YAML 配置 = 完整部署,比 Python 方案省去环境管理的麻烦。
踩过的坑:
- SSH 连接超时要设短(5 秒),否则目标机器不可达时整个巡检会被拖死。ConnectTimeout=5 加上 ServerAliveInterval=30 是比较稳的组合。
- goroutine 不是越多越好。200 台机器开 200 个 goroutine,SSH 连接会瞬时打满目标机器的 MaxStartups。20 并发是 200 台规模下的甜点值。
- 告警去重的时间窗口要根据业务调整。磁盘类问题 30 分钟够了,但证书过期这种每天检查一次的,去重窗口要设 24 小时,否则一天发 24 条一样的告警。
df命令在不同 Linux 发行版的输出格式有差异。CentOS 7 和 Ubuntu 22.04 的df列顺序和格式不一样,解析时要做兼容。用df --output=pcent,target可以强制指定输出格式,避免这个问题。
最后一点经验:巡检平台不是替代监控系统的,而是补充。Prometheus 负责实时发现问题,巡检平台负责定期全面体检。两者结合,运维才能真正做到"心里有数”。
参考资料与致谢
本文在撰写过程中参考了以下资料,感谢原作者的贡献:
- 从零到一:Python 网络自动化巡检的模块化架构设计 — CSDN,模块化巡检架构设计思路,连接管理和数据处理模块的实现参考
- AI Agent 自动化运维体系:从手动巡检到自动驾驶的进化之路 — 腾讯云开发者社区,自动化运维体系的分层设计和模块化实践
- Go 并发编程实战 — Go 官方 Tour,goroutine 和 channel 的并发模型基础
- robfig/cron: Go cron 库 — GitHub,Go 语言的 cron 表达式解析和定时任务调度