shell06-脚本实战
Shell 脚本实战
目录
- 这一篇的实战目标
- 实战:日志分析与报告脚本
- 第一步:搭脚本骨架
- 第二步:用 grep 筛日志
- 第三步:用 awk 统计
- 第四步:用 sed 处理输出
- 第五步:健壮性加固
- 第六步:完整脚本
- 实战二:目录备份脚本
- 测试与调试方法
- 总结:写脚本的方法论
- 参考资料
0. 这一篇的实战目标
前 5 篇学了很多「零件」——语法、健壮性技巧、grep、sed、awk。但零件不等于机器:知道每个工具怎么用,和能写出一整个能跑、能扛、能交付的脚本,之间还有一段距离。这一篇就是补上这段距离——教你怎么把零件组装成机器。
通过两个实战:
- 日志分析与报告脚本:统计访问日志里的状态码、TOP IP、错误数,输出一份报告;
- 目录备份脚本:压缩指定目录、校验、清理旧备份、日志记录。
这两个都是运维最常遇到的场景,能把 grep / sed / awk、变量、函数、校验、trap 全部用上。跟着一步步从零搭,你会看到写脚本的完整流程。
一、实战:日志分析与报告脚本
1.1 需求
假设我们有一份 nginx 访问日志 access.log,行格式类似(这是 nginx 默认的 combined 格式):
192.168.1.10 - - [15/Jan/2024:10:00:01 +0800] "GET /index.html HTTP/1.1" 200 1024
192.168.1.20 - - [15/Jan/2024:10:00:02 +0800] "POST /api/login HTTP/1.1" 500 512
先拆一下这行日志:第 1 列是客户端 IP,第 9 列是 HTTP 状态码(200、500...),最后是响应字节数。我们要写一个脚本,对这份日志输出报告:
- 一共多少条请求;
- 各种 HTTP 状态码(200/404/500...)的数量;
- 访问量最多的前 5 个 IP;
- 错误请求(4xx/5xx)的数量。
1.2 拆解任务
拿到需求先别急着写代码,而是拆成子任务、选对工具(这就是第 8 篇要总结的方法论的第一步):
| 任务 | 用什么工具 |
|---|---|
| 统计总行数 | wc -l 或 awk END |
| 统计状态码 | awk 取第 9 列(状态码)+ 分组计数 |
| 统计 TOP IP | awk 取第 1 列(IP)+ sort/uniq |
| 统计错误数 | grep 或 awk 匹配 4xx/5xx |
每个子任务对应一个明确的工具,后面就按这张表逐个实现。
二、第一步:搭脚本骨架
2.1 先写一个能跑的空骨架
写任何脚本,都别一上来就写全部逻辑。先写一个能跑的空壳——结构正确、能校验参数、能报错——先跑通,再一步步往里填逻辑。这样每一步都有可验证的成果,出错也容易定位。
#!/bin/bash
set -euo pipefail
# 用法
usage() {
echo "用法: $0 <access.log>"
exit 1
}
# 参数校验
[ $# -ge 1 ] || usage
logfile="$1"
[ -f "$logfile" ] || { echo "错误: 文件不存在 $logfile" >&2; exit 1; }
echo "开始分析 $logfile ..."
骨架做了三件事:set -euo pipefail 打开安全开关(第 2 篇讲过);usage 函数 + 参数个数校验,没传参数就打印用法退出;[ -f ] 检查文件存在。这一步先做到「能跑、能校验参数、能报错」。测试一下再往下加——确认骨架没问题,再填充业务逻辑。
三、第二步:用 grep 筛日志
骨架搭好了,开始填第一个业务逻辑——用 grep 筛出错误请求。
3.1 统计错误请求(4xx/5xx)
HTTP 状态码以 4 或 5 开头的是错误(4xx 客户端错误、5xx 服务端错误)。用 grep -E 找出状态码以 4 或 5 开头的行:
error_count=$(grep -cE '" [45][0-9]{2} ' "$logfile")
echo "错误请求(4xx/5xx): $error_count"
拆解这个模式 '" [45][0-9]{2} ':
"匹配状态码前面的引号和空格(日志里状态码在HTTP/1.1"之后,先有个引号再是空格);[45]匹配 4 或 5(状态码的第一位);[0-9]{2}匹配后两位数字;- 末尾的
匹配状态码后面的空格(避免误匹配到4000这种更多位的数字)。
合起来就是「" 4xx 或 " 5xx 」,也就是 HTTP 错误状态码。-c 统计匹配的行数,-E 用扩展正则(第 3 篇讲过 {} 需要 -E)。
3.2 注意 grep -c 的含义
grep -c 统计的是匹配的行数(一行即使有多个匹配也只算 1)。这里统计的正是「错误请求的行数」,符合需求——一条日志代表一次请求,所以「错误请求的行数」=「错误请求的次数」。
四、第三步:用 awk 统计
4.1 统计 HTTP 状态码分布
状态码在第 9 列(字段),用 awk 的分组计数(第 5 篇的 数组[键]++ 技巧):
echo "=== HTTP 状态码分布 ==="
awk '{count[$9]++} END {for (code in count) print code, count[code]}' "$logfile" | sort -rn
count[$9]++:以状态码为键累加——每遇到一个200,count["200"]就加 1;END {for (code in count) print code, count[code]}:处理完所有行,遍历数组输出「状态码 + 次数」;sort -rn:按次数(第二列)降序排列,最多的状态码排在最前。
4.2 统计 TOP 5 IP
IP 在第 1 列,取出后配合 sort/uniq 统计(第 5 篇 7.2 的方法):
echo "=== 访问量 TOP 5 IP ==="
awk '{print $1}' "$logfile" | sort | uniq -c | sort -rn | head -5
awk 取出所有 IP → sort 排序 → uniq -c 去重计数 → sort -rn 降序 → head -5 取前 5 个。这就是「访问量最大的 5 个 IP」。
4.3 统计总请求数
每条日志一行,所以总请求数 = 总行数:
total=$(wc -l < "$logfile")
echo "总请求数: $total"
wc -l < file 数行数,< 把文件作为输入(这样输出只有数字、没有文件名),命令替换存进 total。
五、第四步:用 sed 处理输出
5.1 给报告加分隔线和格式
用 sed 把 awk 输出的「数字 状态码」格式化成更美观的报表:
awk '{count[$9]++} END {for (code in count) print code, count[code]}' "$logfile" \
| sort -rn \
| sed 's/^/ 状态码 /'
sed 给每行开头加上 状态码 前缀(s/^/.../ 是「在行首插入」),让输出更易读——每个状态码前都有个标签。
5.2 用 printf + awk 对齐
更规范的做法,是直接在 awk 里用 printf 对齐(第 5 篇讲过 printf):
awk '{count[$9]++} END {for (code in count) printf " %-5s %s\n", code, count[code]}' "$logfile"
%-5s 让状态码左对齐占 5 位,输出整齐对齐,比 sed 加前缀更美观。能用 awk 内部解决格式化的,优先在 awk 里做,避免多余管道。
六、第五步:健壮性加固
逻辑写通了,开始「加固」——把第 2 篇学的健壮性技巧加进来。
6.1 封装日志与报错函数
统一日志格式和报错方式:
log() {
echo "[$(date '+%F %T')] $*"
}
die() {
echo "[$(date '+%F %T')] 错误: $*" >&2
exit 1
}
log 给日志加时间戳,die 统一报错格式并退出(>&2 走 stderr)。所有日志和报错都用这两个函数,风格统一、方便排查。
6.2 检查依赖命令
脚本用到了 awk、grep、sort 等命令,先检查它们都在系统里(第 2 篇 3.3 的 command -v):
for cmd in awk grep sort sed wc; do
command -v "$cmd" >/dev/null 2>&1 || die "缺少命令 $cmd"
done
6.3 文件可读性校验
文件存在不等于可读,再加一层校验:
[ -r "$logfile" ] || die "无法读取文件: $logfile"
6.4 结果写入报告文件
把报告内容写进一个文件,而不是只打屏。用 { ... } 块把多行内容整体重定向:
report="report_$(date +%F).txt"
{
echo "访问日志分析报告 - $(date '+%F %T')"
echo "日志文件: $logfile"
echo
echo "总请求数: $total"
...
} > "$report"
log "报告已生成: $report"
用 { ... } > 文件 把块里的所有 echo 输出一次性重定向到报告文件,比每条命令单独重定向干净得多。report 文件名带上日期,方便区分每天的报告。
七、第六步:完整脚本
把前面所有步骤整合成一个完整的日志分析脚本:
#!/bin/bash
set -euo pipefail
# ---------- 工具函数 ----------
log() {
echo "[$(date '+%F %T')] $*"
}
die() {
echo "[$(date '+%F %T')] 错误: $*" >&2
exit 1
}
# ---------- 依赖检查 ----------
for cmd in awk grep sort sed wc head; do
command -v "$cmd" >/dev/null 2>&1 || die "缺少命令 $cmd"
done
# ---------- 参数校验 ----------
usage() {
echo "用法: $0 <access.log> [报告文件]"
exit 1
}
[ $# -ge 1 ] || usage
logfile="$1"
report="${2:-report_$(date +%F).txt}"
[ -f "$logfile" ] || die "日志文件不存在: $logfile"
[ -r "$logfile" ] || die "日志文件不可读: $logfile"
# ---------- 分析 ----------
log "开始分析 $logfile"
total=$(wc -l < "$logfile")
error_count=$(grep -cE ' [45][0-9]{2} ' "$logfile" || true)
# ---------- 生成报告 ----------
{
echo "======================================"
echo " 访问日志分析报告"
echo "======================================"
echo "生成时间: $(date '+%F %T')"
echo "日志文件: $logfile"
echo
echo "总请求数: $total"
echo "错误请求(4xx/5xx): $error_count"
echo
echo "--- HTTP 状态码分布 ---"
awk '{count[$9]++} END {for (code in count) printf " %-5s %s\n", code, count[code]}' "$logfile" | sort -rn
echo
echo "--- 访问量 TOP 5 IP ---"
awk '{print $1}' "$logfile" | sort | uniq -c | sort -rn | head -5
} > "$report"
log "报告已生成: $report"
cat "$report"
几个要点回顾:${2:-report_$(date +%F).txt} 是「第 2 个参数缺省时用默认报告文件名」;error_count 那行末尾的 || true 是为了防止 grep 没匹配到(退出码 1)触发 set -e 退出——没有错误时我们不希望脚本停在这里。
7.1 脚本里用到的知识点回顾
| 知识点 | 用在哪 |
|---|---|
| set -euo pipefail | 脚本开头,健壮性 |
| log / die 函数 | 统一日志与报错 |
| command -v | 依赖检查 |
| 参数与默认值 | $#、$1、默认值 |
| [ -f ] / [ -r ] | 文件校验 |
| 命令替换 | $( ) |
| grep -cE | 统计错误行 |
| awk 关联数组 | 状态码与 IP 分组统计 |
| sort / uniq | 排序去重 |
| 重定向块 | { } 多行写入报告 |
| printf | 格式化输出 |
7.2 运行脚本
chmod +x analyze_log.sh
./analyze_log.sh /var/log/nginx/access.log
# 或指定报告文件名
./analyze_log.sh access.log myreport.txt
给脚本加可执行权限后直接运行,传日志文件路径即可;第二个参数可选,不传就生成默认命名的报告文件。
八、实战二:目录备份脚本
第二个实战换个场景:备份。它用到更多「生产级」技巧——参数缺省、打包、校验、按时间清理旧文件。
8.1 需求
写一个备份脚本:把指定目录压缩成 tar.gz,校验是否成功,并清理 N 天前的旧备份,最后记录日志。
8.2 完整脚本
#!/bin/bash
set -euo pipefail
# ---------- 配置 ----------
SRC_DIR="${1:?用法: $0 <源目录> [保留天数]}"
KEEP_DAYS="${2:-7}"
BACKUP_DIR="/backups"
DATE=$(date +%F_%H%M%S)
ARCHIVE="${BACKUP_DIR}/$(basename "$SRC_DIR")_${DATE}.tar.gz"
# ---------- 工具函数 ----------
log() {
echo "[$(date '+%F %T')] $*"
}
die() {
echo "[$(date '+%F %T')] 错误: $*" >&2
exit 1
}
# ---------- 校验 ----------
[ -d "$SRC_DIR" ] || die "源目录不存在: $SRC_DIR"
[ -d "$BACKUP_DIR" ] || mkdir -p "$BACKUP_DIR"
# ---------- 打包 ----------
log "开始备份 $SRC_DIR 到 $ARCHIVE"
if tar czf "$ARCHIVE" -C "$(dirname "$SRC_DIR")" "$(basename "$SRC_DIR")"; then
log "备份完成: $ARCHIVE"
else
die "打包失败"
fi
# ---------- 校验备份文件 ----------
if [ -s "$ARCHIVE" ]; then
log "备份文件大小: $(du -h "$ARCHIVE" | cut -f1)"
else
die "备份文件为空,疑似失败"
fi
# ---------- 清理旧备份 ----------
log "清理 $KEEP_DAYS 天前的旧备份"
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +"$KEEP_DAYS" -delete
log "清理完成"
8.3 脚本亮点拆解
| 技巧 | 说明 |
|---|---|
${1:?用法} |
参数为空直接报错退出(set -u 加强版) |
${2:-7} |
第 2 个参数缺省用 7 |
| tar + -C + basename | 打包时不带绝对路径,便于解压 |
| find -mtime +N -delete | 删除 N 天前的旧备份 |
| du -h | 显示备份文件大小 |
| set -euo pipefail | 全脚本健壮性 |
几个容易忽略但重要的点:
${1:?...}:如果用户没传源目录,这一行直接报错退出。它比if [ $# -lt 1 ]更简洁——参数为空时把?后面的提示语作为错误信息输出并退出,是「必填参数」的快捷写法;tar czf ... -C dir basename:-C先进入源目录的父目录,再用basename指定要打包的目录名。这样打包出来的压缩包内是不带绝对路径的相对结构,解压时不会覆盖到绝对路径下,更安全;[ -s ]:检查文件存在且非空,防止「打包过程出错却留下空文件」的情况;find -mtime +$KEEP_DAYS -delete:-mtime +7表示「修改时间在 7 天以前」,-delete直接删除,实现「只保留最近 7 天备份」的滚动清理。
九、测试与调试方法
脚本写完,怎么保证它是对的?测试和调试有一套方法。
9.1 语法检查
改完脚本先查语法,再运行——这是第一道防线:
bash -n analyze_log.sh # 语法正确则无输出
bash -n(no execute)只检查语法不执行,有语法错误会立刻提示。
9.2 用少量数据测试
先用几行测试日志跑通逻辑,再上真实日志。别一上来就拿几十 GB 的生产日志试:
head -100 access.log > test.log
bash -x analyze_log.sh test.log # -x 跟踪每一步
head -100 取前 100 行做测试数据;bash -x 跟踪每一条命令,看到每一步实际执行了什么、变量展开成了什么。小数据 + 跟踪,问题一目了然。
9.3 分阶段验证
不要一次验证整个脚本,而是分阶段:
- 先验证「能跑、能报错」(骨架);
- 再验证「统计正确」(拿已知结果的样例对比——比如手工数几行日志,看脚本统计是否一致);
- 最后验证「输出报告」(检查文件内容和格式)。
每一阶段都通过,再进入下一阶段,出错时就能快速定位是哪一步。
9.4 用临时目录隔离测试
测试时不要污染真实目录,用 mktemp -d 建一个临时目录,测完删掉:
tmpdir=$(mktemp -d)
# 在 tmpdir 里造测试数据、跑脚本、看结果
rm -rf "$tmpdir"
9.5 常见调试手段
| 手段 | 作用 |
|---|---|
| bash -n | 查语法错误 |
| bash -x | 跟踪每条命令及变量展开 |
| echo 打桩 | 在关键点打印变量值 |
| set -x/+x | 局部开启/关闭跟踪 |
| 先小后大 | 小数据测通再上全量 |
十、总结:写脚本的方法论
10.1 写脚本的标准流程
把两个实战串起来,写脚本有一套可复用的标准流程:
- 拆需求:把任务拆成「筛行/改内容/统计」等子任务,选对应工具;
- 搭骨架:先写能跑的空壳(shebang、set、函数、参数校验);
- 填逻辑:逐块填充业务,先用小数据验证;
- 加固:加依赖检查、错误处理、trap 清理、日志;
- 测试:bash -n 查语法、bash -x 调试、小数据先跑;
- 交付:加注释、写清用法,可读可维护。
10.2 工具选择口诀
- 要筛行到 grep;
- 要改内容到 sed;
- 要按列统计计算到 awk;
- 要流程控制到 bash 语法(if/case/for/while/函数);
- 要健壮到 set -euo pipefail + 参数校验 + trap。
10.3 本系列回顾
| 篇 | 内容 |
|---|---|
| 第 1 篇 | 语法基础(变量、条件、循环、函数) |
| 第 2 篇 | 进阶与健壮性(数组、set、trap、日志) |
| 第 3 篇 | grep 与正则 |
| 第 4 篇 | sed 流编辑器 |
| 第 5 篇 | awk 文本处理 |
| 第 6 篇 | 实战(本篇) |
学完本系列,你应该能读懂大部分运维脚本,并独立写出健壮、可维护的自动化脚本了。写脚本没有捷径,但先搭骨架、分步填充、小步测试、逐步加固这套方法,能让你少走很多弯路。
参考资料
- GNU Bash 手册:https://www.gnu.org/software/bash/manual/
- GNU grep / sed / awk 手册:https://www.gnu.org/manual/manual.html
- ShellCheck(shell 脚本静态检查工具):https://www.shellcheck.net/
- 菜鸟教程 - Shell 教程:https://www.runoob.com/linux/linux-shell.html
