Appearance
运维小工具实战
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-checkergo.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 是远端服务返回的协议状态;1 是 ops-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.jsonjson
[
{
"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()三个参数的含义:
| 参数 | 默认值 | 含义 |
|---|---|---|
-config | targets.json | 目标配置文件路径 |
-concurrency | 5 | 同时检查的目标数量上限 |
-output | text | 输出格式,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.go | Target、LoadTargets |
internal/checker/result.go | Result、HasFailed |
internal/checker/http.go | CheckHTTP |
internal/checker/runner.go | Run,也就是 worker pool |
internal/output/text.go | PrintText |
internal/output/json.go | PrintJSON |
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>&12>&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 继续承接检查结果。已有的配置读取、并发调度、输出和退出码还能沿用。