Skip to content

运维小工具实战

Go 写运维小工具时,常见形态是一个可编译的命令行程序:从参数或配置文件读取目标,执行 HTTP、TCP、系统命令或 Kubernetes 查询,再把结果以文本、JSON 或退出码交给定时任务、流水线和监控系统。

这类工具的重点不是框架,而是输入清楚、超时明确、输出稳定、失败时能看出原因。一个简单的巡检工具通常由参数解析、配置读取、检查函数、并发执行、结果输出和退出码组成。

一、工具结构

示例工具做一个 HTTP 巡检程序。它读取一组 URL,并发访问这些地址,然后输出每个目标的状态码、耗时和错误信息。HTTP 巡检是运维小工具里比较典型的一类:输入是目标列表,外部依赖是网络和远端服务,输出既要给人看,也要能给定时任务或流水线判断成功失败。

单文件工具可以先从这个结构开始:

text
ops-http-checker/
├── go.mod
├── main.go
└── targets.json

目标配置用 JSON 保存:

json
[
  {
    "name": "api",
    "url": "https://example.com/health",
    "timeout_seconds": 3
  },
  {
    "name": "grafana",
    "url": "http://127.0.0.1:3000/api/health",
    "timeout_seconds": 2
  }
]

name 用于输出和排查,url 是检查地址,timeout_seconds 是单个目标的超时时间。不设置超时时,某个地址卡住会拖慢整批检查。

这个配置文件本身不负责执行检查,只是描述“要检查哪些目标”。实际发请求的是后面的 Go 代码。把目标写进配置文件后,新增或删除检查项不需要重新编译二进制。

二、命令行参数

小工具需要把运行时可变的东西做成参数,比如配置文件路径、并发数、输出格式。

go
package main

import (
	"flag"
	"fmt"
)

func main() {
	configPath := flag.String("config", "targets.json", "target config file")
	concurrency := flag.Int("concurrency", 5, "max concurrent checks")
	output := flag.String("output", "text", "output format: text or json")
	flag.Parse()

	fmt.Println(*configPath, *concurrency, *output)
}

运行:

bash
go run . -config targets.json -concurrency 10 -output json

-config 指向目标配置文件,-concurrency 控制同时检查几个目标,-output 控制输出给人读的文本还是给程序读的 JSON。不传参数时会使用代码里的默认值。

参数默认值要偏保守。并发数默认太高时,工具在测试环境里正常,放到生产网段批量跑就可能造成连接突增。

三、读取配置

配置文件里的字段用结构体承接:

go
type Target struct {
	Name           string `json:"name"`
	URL            string `json:"url"`
	TimeoutSeconds int    `json:"timeout_seconds"`
}

这段结构体和 targets.json 对应。JSON 里的 timeout_seconds 会写入 TimeoutSeconds 字段,靠的是字段后面的 json:"timeout_seconds" tag。字段名首字母大写,是为了让 encoding/json 能写入这些字段。

读取配置:

go
func loadTargets(path string) ([]Target, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read config %s: %w", path, err)
	}

	var targets []Target
	if err := json.Unmarshal(data, &targets); err != nil {
		return nil, fmt.Errorf("parse config %s: %w", path, err)
	}

	return targets, nil
}

错误里带上文件路径,定时任务失败时能直接看出是路径不存在、权限不足,还是 JSON 格式有问题。

os.ReadFile 读取的是本机文件内容,json.Unmarshal 把 JSON 字节解析成 []Target。如果 JSON 少了某个字段,Go 会使用该类型的零值,比如数字字段是 0、字符串字段是空字符串。后面的检查函数会把 timeout_seconds <= 0 当成未设置,并给它一个默认超时。

四、HTTP 检查函数

HTTP 检查至少要包含超时、状态码和错误信息。

go
type CheckResult struct {
	Name       string `json:"name"`
	URL        string `json:"url"`
	OK         bool   `json:"ok"`
	StatusCode int    `json:"status_code"`
	Message    string `json:"message"`
	CostMS     int64  `json:"cost_ms"`
}

func checkHTTP(parent context.Context, target Target) CheckResult {
	timeout := time.Duration(target.TimeoutSeconds) * time.Second
	if timeout <= 0 {
		timeout = 3 * time.Second
	}

	ctx, cancel := context.WithTimeout(parent, timeout)
	defer cancel()

	start := time.Now()
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, target.URL, nil)
	if err != nil {
		return CheckResult{Name: target.Name, URL: target.URL, OK: false, Message: err.Error()}
	}

	resp, err := http.DefaultClient.Do(req)
	cost := time.Since(start).Milliseconds()
	if err != nil {
		return CheckResult{Name: target.Name, URL: target.URL, OK: false, Message: err.Error(), CostMS: cost}
	}
	defer resp.Body.Close()

	ok := resp.StatusCode >= 200 && resp.StatusCode < 400
	return CheckResult{
		Name:       target.Name,
		URL:        target.URL,
		OK:         ok,
		StatusCode: resp.StatusCode,
		Message:    resp.Status,
		CostMS:     cost,
	}
}

这里的 parent context.Context 是整批任务的上下文,context.WithTimeout 在它下面再派生出单个目标的超时。整批任务取消时,单个请求也会跟着取消;单个目标超时时,只影响当前请求。

http.NewRequestWithContext 把 context 绑定到 HTTP 请求上。请求还没连上、TLS 握手卡住、服务端一直不返回时,超时到了会返回错误,不会让工具一直挂着。

StatusCode 单独输出,后面接入日志平台或流水线时,比只输出 ok 更容易过滤。这里把 200-399 当成成功:200 常见于普通健康检查,301/302 这类跳转也算服务有响应。接口要求严格返回 200 的场景,可以把判断改成 resp.StatusCode == http.StatusOK

五、并发执行

巡检工具里常见的结构是:主流程投递目标,固定数量 worker 执行检查,主流程收集结果。

go
func runChecks(ctx context.Context, targets []Target, concurrency int) []CheckResult {
	if concurrency <= 0 {
		concurrency = 1
	}

	jobs := make(chan Target)
	results := make(chan CheckResult)

	var wg sync.WaitGroup
	for i := 0; i < concurrency; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for target := range jobs {
				results <- checkHTTP(ctx, target)
			}
		}()
	}

	go func() {
		for _, target := range targets {
			jobs <- target
		}
		close(jobs)
	}()

	go func() {
		wg.Wait()
		close(results)
	}()

	var collected []CheckResult
	for result := range results {
		collected = append(collected, result)
	}

	return collected
}

这里的并发上限只影响同时检查的目标数量,不影响最终检查数量。targets.json 里有 100 个目标、并发是 5 时,同一时间最多 5 个请求在跑。

这段代码里有两个 channel:jobs 传待检查目标,results 传检查结果。投递目标的协程负责关闭 jobs,表示任务已经全部投递;等待 worker 的协程负责关闭 results,表示后面不会再有结果。主流程对 resultsrange,读完所有结果后返回。

结果顺序不保证和配置文件顺序一致。并发执行时,哪个目标先返回,哪个结果就先进入 results。巡检输出如果要保持原配置顺序,需要额外按 Name 或配置里的序号排序。

六、输出和退出码

命令行工具给人看时,文本输出更直观:

go
func printText(results []CheckResult) {
	for _, result := range results {
		state := "FAIL"
		if result.OK {
			state = "OK"
		}

		fmt.Printf(
			"%-5s %-16s status=%d cost=%dms url=%s message=%s\n",
			state,
			result.Name,
			result.StatusCode,
			result.CostMS,
			result.URL,
			result.Message,
		)
	}
}

给流水线或其他程序读取时,JSON 更稳定:

go
func printJSON(results []CheckResult) error {
	data, err := json.MarshalIndent(results, "", "  ")
	if err != nil {
		return fmt.Errorf("marshal results: %w", err)
	}

	fmt.Println(string(data))
	return nil
}

退出码用于自动化系统判断成功失败。只要有一个目标失败,工具可以返回非零退出码:

go
func hasFailed(results []CheckResult) bool {
	for _, result := range results {
		if !result.OK {
			return true
		}
	}
	return false
}

cron、GitLab CI、Jenkins、systemd timer 都会读取进程退出码。稳定的退出码比在日志里搜索字符串更可靠。

退出码和 HTTP 状态码不是一回事。HTTP 状态码是远端服务返回的业务协议结果,比如 200404503;退出码是当前巡检工具这个进程结束时返回给操作系统的结果。某个目标返回 503 时,工具可以记录 status_code=503,最后用退出码 1 告诉外层调度系统“本次巡检有失败项”。

七、main 串起来

完整主流程可以保持很薄:

go
func main() {
	configPath := flag.String("config", "targets.json", "target config file")
	concurrency := flag.Int("concurrency", 5, "max concurrent checks")
	output := flag.String("output", "text", "output format: text or json")
	flag.Parse()

	targets, err := loadTargets(*configPath)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(2)
	}

	results := runChecks(context.Background(), targets, *concurrency)

	switch *output {
	case "json":
		if err := printJSON(results); err != nil {
			fmt.Fprintln(os.Stderr, err)
			os.Exit(2)
		}
	default:
		printText(results)
	}

	if hasFailed(results) {
		os.Exit(1)
	}
}

退出码可以按含义区分:0 表示全部成功,1 表示检查执行完成但有目标失败,2 表示工具自身参数、配置或输出处理失败。

上面的代码片段省略了 import。合在一个 main.go 里时,需要用到这些标准库包:

go
import (
	"context"
	"encoding/json"
	"flag"
	"fmt"
	"net/http"
	"os"
	"sync"
	"time"
)

所有示例函数放在同一个 package main 下即可。小工具还不复杂时,先让单文件版本能读、能跑、能排错。

八、构建和运行

当前目录里已经有 main.gotargets.json 后,再初始化模块。go.mod 记录这个工具的模块名和 Go 版本,后续如果引入第三方依赖,也会记录在这里。

初始化模块:

bash
go mod init example.com/ops-http-checker
go mod tidy

本地运行:

bash
go run . -config targets.json -concurrency 5

编译 Linux 二进制:

bash
GOOS=linux GOARCH=amd64 go build -o bin/ops-http-checker .

放到服务器后执行:

bash
./ops-http-checker -config /etc/ops-http-checker/targets.json -concurrency 5
echo $?

配置文件路径、二进制路径和运行用户要固定下来。定时任务里相对路径经常因为工作目录不同而找不到文件,生产环境更适合写绝对路径。

九、接入定时任务

简单场景可以用 cron:

cron
*/5 * * * * /usr/local/bin/ops-http-checker -config /etc/ops-http-checker/targets.json >> /var/log/ops-http-checker.log 2>&1

这条 cron 每 5 分钟执行一次工具,把标准输出和标准错误都追加到 /var/log/ops-http-checker.log2>&1 表示把错误输出也合并进同一个日志文件,不然配置读取失败这类错误可能只出现在邮件或调度系统里。

systemd timer 更适合需要统一管理日志和状态的场景。工具只要保持前台运行、标准输出写日志、退出码准确,后续接入 cron、systemd timer 或流水线都会顺一些。

十、常见问题

现象常见原因处理方向
手动运行正常,cron 失败工作目录、PATH、权限不同使用绝对路径,显式写配置文件位置
批量检查很慢目标超时过长、并发数太低、DNS 慢调整单目标超时和并发数,记录耗时
目标偶发失败网络抖动、服务限流、TLS 证书问题输出错误原因,必要时加一次重试
输出无法被平台解析文本格式变化、字段缺失给机器读取的输出使用 JSON
工具挂住不退出请求没有超时、channel 阻塞所有外部请求带 context,检查 channel 关闭逻辑

小工具长期维护时,最有价值的是稳定输入输出和清晰错误。功能可以很小,但失败原因要能从日志和退出码里还原。