Appearance
35|运维小工具实战
前面各讲分别介绍了 Go 的语法、类型、控制流、函数、错误处理、并发和标准库。这些知识需要串成一条完整的项目线,才能看出在真实运维场景里怎么落地。巡检类程序很适合做第一条项目线:输入是一组目标地址,输出是状态码、耗时和退出码,中间经过数据建模、配置读取、命令行参数、并发执行和结构化输出。
示例程序叫 ops-checker,从最简单的 HTTP 健康检查开始,逐步叠加配置文件、命令行参数、并发执行和目录拆分。
一、项目初始化
创建项目目录并初始化模块:
bash
mkdir ops-checker
cd ops-checker
go mod init example.com/ops-checker初始目录只有一个 go.mod 和一个 main.go。项目暂时只用标准库。
二、HTTP 健康检查
最简版本只检查一个写死的 URL,处理超时、状态码和错误:
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、Jenkins、GitLab CI、systemd timer 都属于外层调度系统,它们判断任务成败的唯一依据就是进程退出码。
三、数据模型
硬编码 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 表示检查后得到什么。把输入和输出分开后,配置读取、并发调度、不同检查类型都可以沿用这一层模型。
把检查逻辑从 main 里抽出来,写成 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
func main() {
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:
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
}
]给 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。
读取配置的函数:
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)
}五、命令行参数
配置文件路径、并发数、输出格式都属于运行时参数。写死在代码里以后,每次换配置、调并发或改输出格式都要重新编译。
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()
// ... 后续逻辑使用 *configPath、*concurrency、*output
}三个参数的含义:
| 参数 | 默认值 | 含义 |
|---|---|---|
-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 结构体字段名绑死。
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"`
}文本输出函数:
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)
}
}JSON 输出函数:
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 控制并发,避免所有目标同时发起请求。
go
func runChecks(ctx context.Context, targets []Target, concurrency int) []Result {
if concurrency <= 0 {
concurrency = 1
}
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)
}()
go func() {
wg.Wait()
close(results)
}()
var collected []Result
for result := range results {
collected = append(collected, result)
}
return collected
}这个版本有两个 channel:jobs 传待检查目标,results 传检查结果。投递目标的协程负责关闭 jobs,表示没有新任务;等待 worker 的协程负责关闭 results,表示没有新结果。
结果顺序不保证和配置文件顺序一致。并发执行时,哪个目标先返回,哪个结果就先进入 results。输出里已经带了目标名、状态和耗时;如果需要按配置顺序展示,可以给 Target 加序号再排序。
八、入口流程
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)
}
}
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 | runChecks,也就是 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
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 配合 timer:
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,问题在工具配置或部署路径。
十一、扩展方向
ops-checker 现在只会检查 HTTP。项目继续展开时,常见变化有几类:
| 方向 | 增加内容 |
|---|---|
| 多检查类型 | TCP、命令执行、证书过期时间 |
| Kubernetes 检查 | 读取 Pod、Deployment、Node 状态 |
| 集群内运行 | CronJob、Controller、Operator |
接 Kubernetes 时,Target 增加 type 字段,checker 里增加新的检查实现,Result 继续承接检查结果。已有的配置读取、并发调度、输出和退出码还能沿用。