Appearance
运维小工具实战
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,表示后面不会再有结果。主流程对 results 做 range,读完所有结果后返回。
结果顺序不保证和配置文件顺序一致。并发执行时,哪个目标先返回,哪个结果就先进入 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 状态码是远端服务返回的业务协议结果,比如 200、404、503;退出码是当前巡检工具这个进程结束时返回给操作系统的结果。某个目标返回 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.go 和 targets.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.log。2>&1 表示把错误输出也合并进同一个日志文件,不然配置读取失败这类错误可能只出现在邮件或调度系统里。
systemd timer 更适合需要统一管理日志和状态的场景。工具只要保持前台运行、标准输出写日志、退出码准确,后续接入 cron、systemd timer 或流水线都会顺一些。
十、常见问题
| 现象 | 常见原因 | 处理方向 |
|---|---|---|
| 手动运行正常,cron 失败 | 工作目录、PATH、权限不同 | 使用绝对路径,显式写配置文件位置 |
| 批量检查很慢 | 目标超时过长、并发数太低、DNS 慢 | 调整单目标超时和并发数,记录耗时 |
| 目标偶发失败 | 网络抖动、服务限流、TLS 证书问题 | 输出错误原因,必要时加一次重试 |
| 输出无法被平台解析 | 文本格式变化、字段缺失 | 给机器读取的输出使用 JSON |
| 工具挂住不退出 | 请求没有超时、channel 阻塞 | 所有外部请求带 context,检查 channel 关闭逻辑 |
小工具长期维护时,最有价值的是稳定输入输出和清晰错误。功能可以很小,但失败原因要能从日志和退出码里还原。