Skip to content

运维小工具实战

Go 写运维小工具时,巡检类程序很适合做第一条项目线。它从一组目标开始,发起检查,整理结果,再通过文本、JSON 和退出码交给定时任务、流水线或监控系统判断。

示例程序叫 ops-checker。代码从 HTTP 健康检查开始:输入是目标地址,输出是状态码、耗时和退出码。配置文件、命令行参数、结构化输出、并发执行和构建部署分别有自己的文件落点。

一、项目结构

初始目录很小:

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

初始化模块:

bash
mkdir ops-checker
cd ops-checker
go mod init example.com/ops-checker

go.mod 记录模块名和 Go 版本。项目暂时只用标准库。

二、HTTP 检查

这个 main.go 里只有一个写死的 URL,还没有配置文件、并发和结构化输出。代码处理的事情很集中:发请求、拿状态码、处理错误、控制超时。

修改文件:main.go

go
package main

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel() // 释放计时器资源,避免超时控制用完后还挂着

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil)
	if err != nil {
		fmt.Fprintln(os.Stderr, "create request:", err)
		os.Exit(2) // 工具自身构造请求失败,和远端服务异常区分开
	}

	start := time.Now()
	resp, err := http.DefaultClient.Do(req)
	cost := time.Since(start)
	if err != nil {
		fmt.Fprintln(os.Stderr, "request failed:", err)
		os.Exit(1) // 请求发出失败,本次巡检结果按失败处理
	}
	defer resp.Body.Close()

	fmt.Printf("status=%d cost=%dms\n", resp.StatusCode, cost.Milliseconds())

	if resp.StatusCode < 200 || resp.StatusCode >= 400 {
		os.Exit(1)
	}
}

运行:

bash
go run .
echo $?

这段代码里已经出现几个关键点:

作用
context.WithTimeout防止请求一直卡住
http.NewRequestWithContext把超时传给 HTTP 请求
resp.StatusCode记录远端服务返回的 HTTP 状态
os.Exit(1)把失败结果交给外层调度系统
os.Exit(2)区分工具自身错误,比如请求对象创建失败

HTTP 状态码和进程退出码是两件事。503 是远端服务返回的协议状态;1ops-checker 这个进程告诉操作系统“检查有失败”。cron(Linux 定时任务调度器)、Jenkins(CI 平台)、GitLab CI(代码仓库内置流水线)、systemd timer(systemd 定时器)都属于这类"外层调度系统",它们判断任务成败的唯一依据就是进程退出码。

三、数据模型

硬编码 URL 只能验证思路。巡检工具迟早要检查多个目标,每个目标至少要有名称、地址和超时时间。检查完成后,也需要一份统一结果,方便文本输出、JSON 输出和退出码判断复用。

修改文件:main.go

新增位置:import 后面,main 前面。

go
type Target struct {
	Name           string
	URL            string
	TimeoutSeconds int
}

type Result struct {
	Name       string
	URL        string
	OK         bool
	StatusCode int
	Message    string
	CostMS     int64
}

Target 表示要检查什么,Result 表示检查后得到什么。把输入和输出分开后,配置读取、并发调度、Kubernetes 检查都可以沿用这一层模型。

继续修改文件:main.go

新增函数:checkHTTP

go
func checkHTTP(parent context.Context, target Target) Result {
	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 Result{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 Result{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 Result{
		Name:       target.Name,
		URL:        target.URL,
		OK:         ok,
		StatusCode: resp.StatusCode,
		Message:    resp.Status,
		CostMS:     cost,
	}
}

200-399 都算成功。普通 HTTP 探活里,301/302 表示服务有响应但发生跳转;接口要求严格返回 200 时,可以把判断条件换成 resp.StatusCode == http.StatusOK

main 里先临时构造目标列表:

go
targets := []Target{
	{Name: "example", URL: "https://example.com", TimeoutSeconds: 3},
}

for _, target := range targets {
	result := checkHTTP(context.Background(), target)
	fmt.Printf("%s ok=%v status=%d cost=%dms message=%s\n",
		result.Name,
		result.OK,
		result.StatusCode,
		result.CostMS,
		result.Message,
	)
}

当前还没有配置文件,目标仍然写在代码里。checkHTTP 已经从入口流程里分出来,下一步只改目标来源。

四、配置文件

目标写死在代码里,每次增删都要重新编译。配置文件解决的是“目标列表经常变,但程序逻辑不一定变”的问题。

新增文件:targets.json

当前目录:

text
ops-checker/
├── go.mod
├── main.go
└── targets.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
  }
]

targets.json 暂时是一个数组。每个元素对应一个 Target,字段名用下划线风格,便于和常见配置文件习惯保持一致。

修改文件:main.go

Target 加 JSON tag:

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

字段名首字母大写,是为了让 encoding/json 能写入这些字段。tag 负责把 JSON 里的 timeout_seconds 映射到 Go 里的 TimeoutSeconds

继续修改文件:main.go

新增函数:loadTargets

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 格式写错。

入口里把临时目标列表替换成配置读取:

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

运行:

bash
go run .

五、命令行参数

配置文件路径、并发数、输出格式都属于运行时参数。写死在代码里以后,每次换配置、调并发或改输出格式都要重新编译。

修改文件:main.go

新增 import:flag

main 开头增加参数解析:

go
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()

三个参数的含义:

参数默认值含义
-configtargets.json目标配置文件路径
-concurrency5同时检查的目标数量上限
-outputtext输出格式,text 给人看,json 给程序读

运行:

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

并发数默认值偏保守。内网 HTTP 探活可以适当高一些,SSH、数据库连接、管理接口这类目标通常更容易被并发打满。

六、输出格式

文本输出主要给人看,JSON 输出经常被流水线、日志平台或其他脚本读取。Result 字段加上 JSON tag 后,输出字段名就不会跟 Go 结构体字段名绑死。

修改文件:main.go

替换 Result

go
type Result 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"`
}

新增函数:printText

go
func printText(results []Result) {
	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,
		)
	}
}

新增函数:printJSON

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

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

字段名变化会影响下游解析。JSON 输出一旦被脚本或平台消费,字段名就要当成接口看待。

七、并发执行

目标多起来后,串行检查会变慢。ops-checker 用固定数量 worker 控制并发,避免所有目标同时发起请求。

修改文件:main.go

新增 import:sync

新增函数:runChecks

go
func runChecks(ctx context.Context, targets []Target, concurrency int) []Result {
	if concurrency <= 0 {
		concurrency = 1 // 参数传成 0 或负数时,退回串行执行
	}

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

	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) // 任务投递完再关闭,worker 的 range 才能正常结束
	}()

	go func() {
		wg.Wait()
		close(results) // 所有 worker 退出后再关闭,避免写入已关闭 channel
	}()

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

	return collected
}

这个版本有两个 channel:jobs 传待检查目标,results 传检查结果。投递目标的协程负责关闭 jobs,表示没有新任务;等待 worker 的协程负责关闭 results,表示没有新结果。

结果顺序不保证和配置文件顺序一致。并发执行时,哪个目标先返回,哪个结果就先进入 results。输出里已经带了目标名、状态和耗时;如果需要按配置顺序展示,可以给 Target 加序号再排序。

八、入口流程

main 负责串流程:解析参数、读取配置、执行检查、输出结果、设置退出码。检查细节留在函数里。

修改文件:main.go

替换 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)
	}
}

新增函数:hasFailed

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

退出码按含义区分:

退出码含义
0所有目标检查成功
1工具运行完成,但至少一个目标失败
2工具自身失败,比如配置读取失败、JSON 输出失败

九、目录拆分

单个 main.go 跑通后,配置、检查、输出、并发逻辑会挤在一起。目录拆分从这些重复出现的职责开始,不改变程序行为。

拆分前:

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

拆分后:

text
ops-checker/
├── go.mod
├── cmd/
│   └── ops-checker/
│       └── main.go
├── configs/
│   └── targets.json
└── internal/
    ├── checker/
    │   ├── http.go
    │   ├── result.go
    │   └── runner.go
    ├── config/
    │   └── target.go
    └── output/
        ├── json.go
        └── text.go

文件落点:

文件放入内容
cmd/ops-checker/main.go参数解析、读取配置、调用检查、输出、退出码
internal/config/target.goTargetLoadTargets
internal/checker/result.goResultHasFailed
internal/checker/http.goCheckHTTP
internal/checker/runner.goRun,也就是 worker pool
internal/output/text.goPrintText
internal/output/json.goPrintJSON
configs/targets.json示例目标配置

internal/ 里的包只能被当前模块引用,用来放这个工具自己的内部逻辑。没有明确复用方时,pkg/ 目录暂时没有实际作用。

拆完后运行路径也变了:

bash
go run ./cmd/ops-checker -config configs/targets.json -concurrency 5

十、构建部署

本地运行用 go run,放到服务器上通常先编译成 Linux 二进制。

bash
# 产物放到 bin/,方便后面 install 或打包镜像时引用
GOOS=linux GOARCH=amd64 go build -o bin/ops-checker ./cmd/ops-checker

放到服务器:

bash
install -m 0755 bin/ops-checker /usr/local/bin/ops-checker
install -d /etc/ops-checker
install -m 0644 configs/targets.json /etc/ops-checker/targets.json

/usr/local/bin/ops-checker -config /etc/ops-checker/targets.json
echo $?

install 比直接 cp 多了权限控制。二进制需要可执行权限,配置文件通常只需要普通读取权限。

cron 运行:

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

2>&1 表示把标准错误合并进同一个日志文件。配置读取失败、DNS 错误、TLS 错误这类信息通常会写到 stderr,合并后排查时不用再找调度系统邮件。

systemd service 用来手动触发和统一看日志:

ini
[Unit]
Description=Ops Checker

[Service]
Type=oneshot
ExecStart=/usr/local/bin/ops-checker -config /etc/ops-checker/targets.json

配合 timer 后,可以用 journalctl -u ops-checker 查看执行记录。比如日志里有 status=503 cost=120ms target=api,同时 systemd 记录本次退出码是 1,就能确认是目标检查失败;如果日志是 open /etc/ops-checker/targets.json: no such file or directory 且退出码是 2,问题在工具配置或部署路径。工具保持前台运行,输出写 stdout/stderr,退出码表达本次检查结果。

十一、扩展方向

ops-checker 现在只会检查 HTTP。项目继续展开时,常见变化有几类:

方向增加内容记录
多检查类型TCP、命令执行、证书过期时间HTTP 只能说明入口有响应,不能覆盖所有运维对象
Kubernetes 检查读取 Pod、Deployment、Node 状态HTTP 200 但 Deployment 可用副本不足时,结果里能直接标出对象状态
集群内运行CronJob、Controller、Operator检查规则和运行周期进入 Kubernetes

接 Kubernetes 时,Target 增加 type 字段,checker 里增加新的检查实现,Result 继续承接检查结果。已有的配置读取、并发调度、输出和退出码还能沿用。