用 Codex 编写并验收 Linux Bash 主机巡检脚本,覆盖 CPU、内存、磁盘、inode、systemd、日志、OOM、僵尸进程及监听端口。
运维脚本最危险的状态,不是"不会运行",而是"看起来能运行"。
一个脚本可以顺利输出 CPU、内存和磁盘信息,却可能在生产环境里隐藏很多问题:命令缺失时仍然返回成功、日志没有读取权限却显示"0 条错误"、磁盘和 inode 只检查其一、阈值写死、输出无法被监控系统消费,甚至把巡检和自动修复混在一起。
这篇文章不讨论"让 AI 随便写个 Shell 脚本"。我会用一个完整案例,演示如何让 Codex 参与需求拆解、Bash 实现、安全审查和验收测试,最终得到一套默认只读、可配置、可生成文本和 JSON 报告,并且有明确退出码与自测模式的 Linux 主机健康巡检脚本。
本文代码不包含任何真实服务器地址、用户名、业务名称、内网路径、密钥或生产日志。演示结果全部来自脚本内置的匿名数据。

一、为什么选择 Linux 主机巡检
"用 AI 写运维脚本"这个主题很容易写成几个零散示例:清理日志、批量 SSH、检查 Nginx。问题是,示例能运行,不等于具备运维价值。
Linux 主机巡检更适合作为完整案例,因为它同时涉及:
-
多种系统信息源,例如 /proc、df、ps、systemctl、journalctl 和 ss。
-
不同 Linux 发行版和运行环境的兼容性。
-
命令缺失、权限不足、systemd 不可用等异常分支。
-
阈值、退出码和机器可读报告等自动化接口。
-
最重要的安全边界:巡检脚本默认不应该修改系统。
这个案例也能体现 Codex 和普通"代码补全"的区别。真正有价值的不是一次生成多少行代码,而是能否把模糊需求逐步收敛为可执行、可测试、可审查的工程结果。OpenAI 官方的 Codex 用例也强调了自动化、代码、测试和构建可复用 CLI 这类工作流。
二、先定义验收标准,再让 Codex 写代码
如果只给 Codex 一句话:
帮我写一个 Linux 巡检脚本。
它大概率会生成一个能显示 top、free 和 df 的脚本,但这还不够。
我把需求改写成下面这份提示词:
你是一名资深 Linux SRE。请编写一个 Bash 4+ 主机健康巡检脚本,目标系统包括 Ubuntu、Debian、Rocky Linux、AlmaLinux 和 CentOS Stream。
要求:
-
默认只读,不修改配置、不重启服务、不清理文件、不调用 sudo。
-
检查 1 分钟归一化负载、MemAvailable 内存使用率、磁盘空间、inode、失败的 systemd 单元、僵尸进程、最近 1 小时高优先级日志、最近 24 小时 OOM 和 TCP 监听端口数量。
-
磁盘、inode、内存、负载和错误日志阈值必须可通过命令行参数调整。
-
同时生成 report.txt 和 report.json。
-
退出码固定为:0 正常、1 告警、2 严重、3 脚本错误。
-
使用严格模式,并处理命令缺失、日志权限不足、systemd 不可用等情况,不能把"无法检查"误报为"正常"。
-
提供 --demo 匿名演示模式和 --self-test 自测模式。
-
参数必须校验;告警阈值必须小于严重阈值。
-
JSON 必须正确转义,能被标准解析器读取。
-
给出 bash -n、内置自测、非法参数、演示报告和 JSON 解析的验收方法。
先解释设计,再生成代码。不要声称未经验证的发行版一定兼容。
这份提示词最关键的不是"角色设定",而是把运维要求写成了验收条件:输入是什么、输出是什么、允许做什么、禁止做什么、失败时怎样表达。
三、脚本架构:采集、判断、报告三层分离

最终脚本分成三层。
- 数据采集层
采集层只负责读取系统状态:
-
/proc/loadavg:获取 1 分钟负载。
-
/proc/meminfo:读取 MemTotal 和 MemAvailable。
-
df -P:检查文件系统空间。
-
df -Pi:检查 inode 使用率。
-
systemctl --failed:列出失败的 systemd 单元。
-
ps:统计僵尸进程。
-
journalctl:统计高优先级日志和 OOM 记录。
-
ss -lntH:统计 TCP 监听端口。
这里有一个容易写错的点:Linux 内存不能简单地用 MemFree / MemTotal 判断。内核文档将 MemAvailable 定义为"不发生交换的情况下,可供新应用使用的内存估计值",因此脚本使用:
内存使用率 = (MemTotal - MemAvailable) / MemTotal × 100%
它比只看 MemFree 更符合现代 Linux 的缓存回收机制。
- 状态判断层
所有百分比指标统一经过同一个判断函数:
status_for_value() {
local value=$1
local warn=$2
local crit=$3
if awk -v value="value" -v crit="crit" 'BEGIN { exit !(value >= crit) }'; then
printf 'CRITICAL'
elif awk -v value="value" -v warn="warn" 'BEGIN { exit !(value >= warn) }'; then
printf 'WARNING'
else
printf 'OK'
fi
}
这样可以避免 CPU、内存、磁盘分别维护一套互相不一致的阈值逻辑。
总体状态遵循"最严重状态优先":
只要有 CRITICAL → 总体 CRITICAL → 退出码 2
没有 CRITICAL,但有 WARNING → 总体 WARNING → 退出码 1
其余 → 总体 OK → 退出码 0
脚本自身错误 → 退出码 3
这个退出码设计可以直接接入 cron、CI、监控采集器或其他编排系统。
- 报告层
脚本同时输出两份报告:
-
report.txt:便于人工查看和归档。
-
report.json:便于监控平台、自动化任务或其他程序解析。
报告层不重新采集系统数据,只消费已经标准化的检查结果。这样文本和 JSON 不会出现同一次巡检结果不一致的问题。
四、安全设计:默认只读比"自动修复"更重要
这套脚本刻意没有加入自动清日志、重启服务、杀进程或修改防火墙。
原因很简单:
-
巡检与修复属于不同权限等级。
-
同一个现象可能有多种根因,自动修复容易扩大故障。
-
生产变更应该有审批、回滚和审计记录。
-
AI 生成的脚本必须先经过人工复核和测试环境验证。
脚本使用:
set -Eeuo pipefail
其中 -u 可以发现未定义变量,pipefail 可以让管道中间命令的失败被传递出来,-e 和 -E 用于强化失败传播。不过严格模式不是魔法,某些 if、!、|| 和命令替换场景仍需要显式处理返回码。因此脚本对 systemctl 和 journalctl 采用了单独判断:读取失败时显示 INFO / N/A,而不是错误地显示 OK / 0。
这是 AI 生成运维代码时非常值得检查的一点:
"没有发现问题"和"没有权限检查问题"不是一回事。
五、关键实现解析
- 对负载进行 CPU 核数归一化
负载 4.0 在 2 核机器和 16 核机器上的含义完全不同。脚本把 1 分钟负载换算为单核百分比:
load_percent=(awk -v load="load1" -v cpu="$cpu_count" \
'BEGIN { printf "%.0f", load * 100 / cpu }')
默认 80% 为告警,120% 为严重,但可以根据业务特点调整。
- 同时检查磁盘空间和 inode
磁盘"还有很多 GB"不代表一定能继续写文件。大量小文件可能先耗尽 inode。因此脚本分别执行:
df -P
df -Pi
每个挂载点都会形成独立检查项,避免只看根分区而漏掉数据盘或日志盘。
- systemd 不可用时不误报
容器、WSL、精简系统或非 systemd 发行版中,systemctl 可能存在但无法连接 PID 1。脚本先检查命令,再检查执行结果:
if ! failed_output=$(systemctl --failed --no-legend --plain 2>/dev/null); then
add_check '失败的 systemd 单元' 'INFO' 'N/A' \
'systemctl 无法连接到 systemd,已跳过。'
return
fi
- 日志权限不足时返回 N/A
普通用户不一定能读取完整 journal。下面这种写法比直接把错误重定向到 /dev/null 更稳妥:
if ! error_output=$(journalctl -p 0..3 --since '1 hour ago' \
--no-pager -q 2>/dev/null); then
add_check '最近 1 小时错误日志' 'INFO' 'N/A' \
'journalctl 无法读取日志,可能需要调整权限。'
return
fi
journalctl -p 0..3 表示筛选 emerg、alert、crit 和 err 级别,--since 用于限定时间范围。
- 提供匿名演示和自测模式
运维文章不应该为了展示效果而公开真实主机名、业务服务和告警数据。因此脚本内置:
./linux-health-audit.sh --demo
它只使用匿名数据,生成稳定的文本和 JSON 报告。
自测模式:
./linux-health-audit.sh --self-test
它会验证阈值分类和 JSON 转义,不读取系统状态,也不生成报告。
六、完整脚本
下面是完整可复制版本。
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_VERSION="1.0.0"
DISK_WARN=80
DISK_CRIT=90
INODE_WARN=80
INODE_CRIT=90
MEM_WARN=80
MEM_CRIT=90
LOAD_WARN=80
LOAD_CRIT=120
ERROR_WARN=20
OUTPUT_DIR=""
DEMO_MODE=0
SELF_TEST=0
declare -a CHECK_NAMES=()
declare -a CHECK_STATUSES=()
declare -a CHECK_VALUES=()
declare -a CHECK_DETAILS=()
usage() {
cat <<'EOF'
用法:
./linux-health-audit.sh 选项
选项:
--output-dir PATH 指定报告目录
--disk-warn N 磁盘使用率告警阈值,默认 80
--disk-crit N 磁盘使用率严重阈值,默认 90
--inode-warn N inode 使用率告警阈值,默认 80
--inode-crit N inode 使用率严重阈值,默认 90
--mem-warn N 内存使用率告警阈值,默认 80
--mem-crit N 内存使用率严重阈值,默认 90
--load-warn N 单核归一化负载告警阈值,默认 80
--load-crit N 单核归一化负载严重阈值,默认 120
--error-warn N 最近 1 小时错误日志告警条数,默认 20
--demo 使用匿名演示数据生成报告
--self-test 运行内置单元测试,不读取系统状态
-h, --help 显示帮助
退出码:
0 全部正常
1 存在告警
2 存在严重问题
3 脚本运行错误
EOF
}
die() {
printf '错误:%s\n' "$*" >&2
exit 3
}
is_uint() {
\[ ${1:-} =\~ \^\[0-9\]+$ \]
}
require_value() {
local option=$1
local value=${2:-}
\[ -n $value ] || die "$option 缺少参数。"
}
while (($# > 0)); do
case "$1" in
--output-dir)
require_value "1" "{2:-}"
OUTPUT_DIR=$2
shift 2
;;
--disk-warn) DISK_WARN=${2:-}; shift 2 ;;
--disk-crit) DISK_CRIT=${2:-}; shift 2 ;;
--inode-warn) INODE_WARN=${2:-}; shift 2 ;;
--inode-crit) INODE_CRIT=${2:-}; shift 2 ;;
--mem-warn) MEM_WARN=${2:-}; shift 2 ;;
--mem-crit) MEM_CRIT=${2:-}; shift 2 ;;
--load-warn) LOAD_WARN=${2:-}; shift 2 ;;
--load-crit) LOAD_CRIT=${2:-}; shift 2 ;;
--error-warn) ERROR_WARN=${2:-}; shift 2 ;;
--demo) DEMO_MODE=1; shift ;;
--self-test) SELF_TEST=1; shift ;;
-h|--help) usage; exit 0 ;;
*) die "未知参数:$1" ;;
esac
done
for threshold in \
"DISK_WARN" "DISK_CRIT" "INODE_WARN" "INODE_CRIT" \
"MEM_WARN" "MEM_CRIT" "LOAD_WARN" "LOAD_CRIT" "$ERROR_WARN"; do
is_uint "threshold" \|\| die "阈值必须是非负整数:threshold"
done
((DISK_WARN < DISK_CRIT)) || die 'disk-warn 必须小于 disk-crit。'
((INODE_WARN < INODE_CRIT)) || die 'inode-warn 必须小于 inode-crit。'
((MEM_WARN < MEM_CRIT)) || die 'mem-warn 必须小于 mem-crit。'
((LOAD_WARN < LOAD_CRIT)) || die 'load-warn 必须小于 load-crit。'
status_for_value() {
local value=$1
local warn=$2
local crit=$3
if awk -v value="value" -v crit="crit" 'BEGIN { exit !(value >= crit) }'; then
printf 'CRITICAL'
elif awk -v value="value" -v warn="warn" 'BEGIN { exit !(value >= warn) }'; then
printf 'WARNING'
else
printf 'OK'
fi
}
add_check() {
CHECK_NAMES+=("$1")
CHECK_STATUSES+=("$2")
CHECK_VALUES+=("$3")
CHECK_DETAILS+=("$4")
}
json_escape() {
local value=${1-}
value=${value//\\/\\\\}
value=${value//\"/\\\"}
value={value//'\n'/\\n}
value={value//'\r'/\\r}
value={value//'\t'/\\t}
printf '%s' "$value"
}
run_self_test() {
local failures=0
\[ $(status_for_value 79 80 90) == OK ] || ((failures += 1))
\[ $(status_for_value 80 80 90) == WARNING ] || ((failures += 1))
\[ $(status_for_value 90 80 90) == CRITICAL ] || ((failures += 1))
\[ $(json_escape 'a"b\\c') == 'a\\"b\\\\c' ] || ((failures += 1))
if ((failures == 0)); then
printf 'SELF-TEST PASS:阈值分类与 JSON 转义测试通过。\n'
exit 0
fi
printf 'SELF-TEST FAIL:%d 项测试失败。\n' "$failures" >&2
exit 3
}
if ((SELF_TEST == 1)); then
run_self_test
fi
collect_demo_data() {
add_check 'CPU 归一化负载' 'OK' '42%' '1 分钟负载 1.68,CPU 逻辑核数 4。'
add_check '内存使用率' 'WARNING' '83%' '可用内存低于告警阈值。'
add_check '磁盘 /' 'OK' '61%' '根分区空间充足。'
add_check 'inode /' 'OK' '22%' '根分区 inode 充足。'
add_check '失败的 systemd 单元' 'CRITICAL' '1' 'demo-worker.service'
add_check '僵尸进程' 'OK' '0' '未发现僵尸进程。'
add_check '最近 1 小时错误日志' 'WARNING' '27' '超过告警阈值 20。'
add_check '最近 24 小时 OOM' 'OK' '0' '未发现 OOM 记录。'
add_check 'TCP 监听端口' 'INFO' '5' '仅记录数量,不修改防火墙。'
}
collect_load() {
local load1 cpu_count load_percent status
read -r load1 _ < /proc/loadavg
cpu_count=$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc)
load_percent=(awk -v load="load1" -v cpu="$cpu_count" 'BEGIN { printf "%.0f", load * 100 / cpu }')
status=(status_for_value "load_percent" "LOAD_WARN" "LOAD_CRIT")
add_check 'CPU 归一化负载' "status" "{load_percent}%" "1 分钟负载 load1,CPU 逻辑核数 cpu_count。"
}
collect_memory() {
local total available used_percent status
read -r total available < <(
awk '
1 == "MemTotal:" { total=2 }
1 == "MemAvailable:" { available=2 }
END { print total, available }
' /proc/meminfo
)
\[ -n ${total:-} \&\& -n ${available:-} \&\& $total -gt 0 ] || die '无法解析 /proc/meminfo。'
used_percent=(awk -v total="total" -v available="$available" 'BEGIN { printf "%.0f", (total - available) * 100 / total }')
status=(status_for_value "used_percent" "MEM_WARN" "MEM_CRIT")
add_check '内存使用率' "status" "{used_percent}%" '基于 MemAvailable 计算,包含内核可回收缓存。'
}
collect_filesystems() {
local filesystem blocks used available capacity mount percent status
while read -r filesystem blocks used available capacity mount; do
\[ $filesystem == Filesystem ] && continue
percent=${capacity%%%}
is_uint "$percent" || continue
status=(status_for_value "percent" "DISK_WARN" "DISK_CRIT")
add_check "磁盘 mount" "status" "{percent}%" "文件系统:filesystem"
done < <(df -P -x tmpfs -x devtmpfs 2>/dev/null || df -P)
while read -r filesystem inodes iused ifree capacity mount; do
\[ $filesystem == Filesystem ] && continue
percent=${capacity%%%}
is_uint "$percent" || continue
status=(status_for_value "percent" "INODE_WARN" "INODE_CRIT")
add_check "inode mount" "status" "{percent}%" "文件系统:filesystem"
done < <(df -Pi -x tmpfs -x devtmpfs 2>/dev/null || df -Pi)
}
collect_systemd() {
if ! command -v systemctl >/dev/null 2>&1; then
add_check '失败的 systemd 单元' 'INFO' 'N/A' '系统未提供 systemctl,已跳过。'
return
fi
local failed_output failed_count detail status
if ! failed_output=$(systemctl --failed --no-legend --plain 2>/dev/null); then
add_check '失败的 systemd 单元' 'INFO' 'N/A' 'systemctl 无法连接到 systemd,已跳过。'
return
fi
failed_count=(awk 'NF { count++ } END { print count+0 }' \<\<\<"failed_output")
if ((failed_count > 0)); then
status='CRITICAL'
detail=(awk 'NF { print 1 }' <<<"$failed_output" | head -n 5 | paste -sd ', ' -)
else
status='OK'
detail='未发现失败的 systemd 单元。'
fi
add_check '失败的 systemd 单元' "status" "failed_count" "$detail"
}
collect_processes() {
local zombie_count status
zombie_count=(ps -eo stat= 2\>/dev/null \| awk '1 ~ /^Z/ { count++ } END { print count+0 }')
status=(\[\[ zombie_count -gt 0 ]] && printf 'WARNING' || printf 'OK')
add_check '僵尸进程' "status" "zombie_count" "(\[\[ zombie_count -gt 0 ]] && printf '需要定位父进程。' || printf '未发现僵尸进程。')"
}
collect_journal() {
if ! command -v journalctl >/dev/null 2>&1; then
add_check '最近 1 小时错误日志' 'INFO' 'N/A' '系统未提供 journalctl,已跳过。'
add_check '最近 24 小时 OOM' 'INFO' 'N/A' '系统未提供 journalctl,已跳过。'
return
fi
local error_output error_count error_status kernel_output oom_count oom_status
if ! error_output=$(journalctl -p 0..3 --since '1 hour ago' --no-pager -q 2>/dev/null); then
add_check '最近 1 小时错误日志' 'INFO' 'N/A' 'journalctl 无法读取日志,可能需要调整权限。'
add_check '最近 24 小时 OOM' 'INFO' 'N/A' 'journalctl 无法读取日志,可能需要调整权限。'
return
fi
error_count=(awk 'NF { count++ } END { print count+0 }' \<\<\<"error_output")
error_status=(\[\[ error_count -ge $ERROR_WARN ]] && printf 'WARNING' || printf 'OK')
add_check '最近 1 小时错误日志' "error_status" "error_count" "告警阈值:$ERROR_WARN。"
if ! kernel_output=$(journalctl -k --since '24 hours ago' --no-pager -q 2>/dev/null); then
add_check '最近 24 小时 OOM' 'INFO' 'N/A' 'journalctl 无法读取内核日志,可能需要调整权限。'
return
fi
oom_count=(grep -Eic 'out of memory\|oom-killer\|killed process' \<\<\<"kernel_output" || true)
oom_status=(\[\[ oom_count -gt 0 ]] && printf 'CRITICAL' || printf 'OK')
add_check '最近 24 小时 OOM' "oom_status" "oom_count" "(\[\[ oom_count -gt 0 ]] && printf '发现内存耗尽相关内核日志。' || printf '未发现 OOM 记录。')"
}
collect_ports() {
if ! command -v ss >/dev/null 2>&1; then
add_check 'TCP 监听端口' 'INFO' 'N/A' '系统未提供 ss,已跳过。'
return
fi
local port_count
port_count=$(ss -lntH 2>/dev/null | awk 'NF { count++ } END { print count+0 }')
add_check 'TCP 监听端口' 'INFO' "$port_count" '仅记录监听数量,不修改防火墙。'
}
overall_status() {
local status has_warning=0
for status in "${CHECK_STATUSES@}"; do
\[ $status == CRITICAL ] && { printf 'CRITICAL'; return; }
\[ $status == WARNING ] && has_warning=1
done
((has_warning == 1)) && printf 'WARNING' || printf 'OK'
}
exit_code_for_status() {
case "$1" in
OK) printf '0' ;;
WARNING) printf '1' ;;
CRITICAL) printf '2' ;;
*) printf '3' ;;
esac
}
write_text_report() {
local file=$1
local generated_at=$2
local host_name=$3
local overall=$4
local index
{
printf 'Linux 主机健康巡检报告\n'
printf '生成时间:%s\n' "$generated_at"
printf '主机:%s\n' "$host_name"
printf '脚本版本:%s\n' "$SCRIPT_VERSION"
printf '总体状态:%s\n\n' "$overall"
printf '%-28s %-10s %-10s %s\n' '检查项' '状态' '数值' '说明'
printf '%-28s %-10s %-10s %s\n' '----------------------------' '----------' '----------' '------------------------------'
for index in "${!CHECK_NAMES@}"; do
printf '%-28s %-10s %-10s %s\n' \
"{CHECK_NAMES\[index]}" \
"{CHECK_STATUSES\[index]}" \
"{CHECK_VALUES\[index]}" \
"{CHECK_DETAILS\[index]}"
done
} > "$file"
}
write_json_report() {
local file=$1
local generated_at=$2
local host_name=$3
local overall=$4
local index comma=''
{
printf '{\n'
printf ' "generated_at": "%s",\n' "(json_escape "generated_at")"
printf ' "hostname": "%s",\n' "(json_escape "host_name")"
printf ' "script_version": "%s",\n' "$SCRIPT_VERSION"
printf ' "overall_status": "%s",\n' "$overall"
printf ' "checks": [\n'
for index in "${!CHECK_NAMES@}"; do
printf '%s {"name":"%s","status":"%s","value":"%s","detail":"%s"}' \
"$comma" \
"(json_escape "{CHECK_NAMES$index}")" \
"(json_escape "{CHECK_STATUSES$index}")" \
"(json_escape "{CHECK_VALUES$index}")" \
"(json_escape "{CHECK_DETAILS$index}")"
comma=$',\n'
done
printf '\n ]\n}\n'
} > "$file"
}
if ((DEMO_MODE == 1)); then
collect_demo_data
HOST_NAME='demo-linux-host'
else
\[ -r /proc/loadavg \&\& -r /proc/meminfo ] || die '缺少 Linux /proc 接口;请在 Linux 主机运行。'
command -v awk >/dev/null 2>&1 || die '缺少 awk。'
command -v df >/dev/null 2>&1 || die '缺少 df。'
command -v ps >/dev/null 2>&1 || die '缺少 ps。'
collect_load
collect_memory
collect_filesystems
collect_systemd
collect_processes
collect_journal
collect_ports
HOST_NAME=$(hostname -f 2>/dev/null || hostname)
fi
GENERATED_AT=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
OVERALL_STATUS=$(overall_status)
REPORT_EXIT_CODE=(exit_code_for_status "OVERALL_STATUS")
if \[ -z $OUTPUT_DIR ]; then
OUTPUT_DIR="./linux-health-report-$(date '+%Y%m%d-%H%M%S')"
fi
if \[ ! -d $OUTPUT_DIR ]; then
mkdir -p -- "$OUTPUT_DIR"
fi
TEXT_REPORT="$OUTPUT_DIR/report.txt"
JSON_REPORT="$OUTPUT_DIR/report.json"
write_text_report "TEXT_REPORT" "GENERATED_AT" "HOST_NAME" "OVERALL_STATUS"
write_json_report "JSON_REPORT" "GENERATED_AT" "HOST_NAME" "OVERALL_STATUS"
printf '巡检完成:%s\n' "$OVERALL_STATUS"
printf '文本报告:%s\n' "$TEXT_REPORT"
printf 'JSON 报告:%s\n' "$JSON_REPORT"
printf '退出码:%s\n' "$REPORT_EXIT_CODE"
exit "$REPORT_EXIT_CODE"
七、运行方法
- 保存并授权
chmod 750 linux-health-audit.sh
- 先运行语法检查
bash -n linux-health-audit.sh
没有输出并返回 0,表示 Bash 语法检查通过。
- 运行内置自测
./linux-health-audit.sh --self-test
预期输出:
SELF-TEST PASS:阈值分类与 JSON 转义测试通过。
- 先用匿名演示模式查看报告格式
./linux-health-audit.sh --demo --output-dir ./demo-report
演示数据故意包含一个严重状态,所以退出码应为 2。
- 在测试机执行真实巡检
./linux-health-audit.sh --output-dir ./health-report
自定义阈值示例:
./linux-health-audit.sh \
--disk-warn 75 \
--disk-crit 88 \
--mem-warn 85 \
--mem-crit 95 \
--error-warn 10 \
--output-dir ./health-report
不要一开始就用 root 运行。先用普通运维账号执行,确认哪些检查需要额外日志权限,再按最小权限原则授权。
八、验收测试结果

本地完成了以下验证:
验收项:`bash -n` 语法检查;结果:通过;说明:返回 0
验收项:内置 `--self-test`;结果:通过;说明:阈值分类与 JSON 转义通过
验收项:非法阈值;结果:通过;说明:`--disk-warn 95 --disk-crit 90` 返回 3
验收项:匿名 `--demo`;结果:通过;说明:总体状态 CRITICAL,返回 2
验收项:文本报告;结果:通过;说明:成功生成 `report.txt`
验收项:JSON 报告;结果:通过;说明:标准解析器可读取,包含 9 个检查项
需要明确的是:当前验证覆盖脚本语法、核心逻辑、参数校验、报告生成和 JSON 解析,不等于已经覆盖所有 Linux 发行版和生产环境。正式使用前仍应在目标发行版的测试机验证:
-
Bash 版本是否为 4 或以上。
-
df、ps、awk、systemctl、journalctl 和 ss 的可用性。
-
运维账号对 systemd journal 的读取权限。
-
容器、云主机和特殊挂载点是否需要排除规则。
-
阈值是否符合当前业务基线,而不是照搬示例值。
九、Codex 在这个案例里真正解决了什么
Codex 的价值不是替运维人员做决定,而是缩短"需求---实现---测试---修订"的循环。
在这个案例中,它适合承担:
-
把自然语言需求拆成参数、函数和退出码。
-
生成重复但容易出错的 Bash 代码。
-
检查异常分支和兼容性问题。
-
补充 --demo、--self-test 和验收命令。
-
根据测试结果修改脚本,而不是只给一次性答案。
人仍然需要负责:
-
确认指标是否有业务意义。
-
决定阈值、权限和执行频率。
-
审查命令是否会修改系统。
-
在目标发行版和测试环境中验证。
-
对生产变更、回滚和事故结果负责。
最稳妥的合作模式不是"AI 写完,我直接执行",而是:
人定义边界 → Codex 生成 → 静态检查 → 匿名演示 → 测试机验证 → 人工审批 → 生产只读运行
十、如何接入 cron 和监控系统
脚本退出码已经标准化,可以被外部系统直接判断:
./linux-health-audit.sh --output-dir /var/tmp/linux-health
case $? in
-
echo 'OK' ;;
-
echo 'WARNING' ;;
-
echo 'CRITICAL' ;;
*) echo 'SCRIPT_ERROR' ;;
esac
cron 示例:
15 * * * * /opt/ops/linux-health-audit.sh --output-dir /var/tmp/linux-health >/var/log/linux-health-audit.log 2>&1
这里仍然建议先做两项改造:
-
报告目录增加保留策略,避免报告自身占满磁盘。
-
使用监控平台或日志系统收集结果,而不是只依赖本机文件。
脚本本身没有实现通知发送,因为邮件、Webhook 和监控接口通常涉及凭据。凭据应该由环境变量、受控配置文件或密钥管理系统提供,不应硬编码在脚本或文章中。
十一、总结
用 Codex 写运维脚本,最重要的不是提示词有多华丽,而是有没有把需求变成可以验收的工程约束。
这次实践得到的经验可以浓缩成五句话:
-
先定义只读边界,再写采集逻辑。
-
"无法检查"必须和"检查正常"分开。
-
人看的文本报告和机器读的 JSON 报告都要有。
-
退出码、参数校验和自测决定脚本能否进入自动化流程。
-
AI 生成代码必须经过静态检查、匿名演示和目标环境验证。
真正专业的 AI 运维,不是让模型获得更大的权限,而是让每一步都更可控、更可查、更容易回滚。
参考资料
-
OpenAI 官方 Codex 用例:https://learn.chatgpt.com/use-cases
-
GNU Bash Reference Manual:https://www.gnu.org/software/bash/manual/bash.html
-
systemd journalctl 官方手册:https://www.freedesktop.org/software/systemd/man/latest/journalctl.html
-
Linux man-pages:/proc/meminfo:https://www.kernel.org/doc/man-pages/
#Codex #Linux #Bash #运维 #DevOps #Shell #AI编程