写Shell练习题的第五篇时,我其实犹豫了一下:市面上的练习题不是太少,而是太多,但多数都是“背诵指令+抄答案”的路子,练完就忘。所以我决定把这一篇做成贴近真实运维场景的实战题,考察的不只是“会不会写命令”,而是“遇到问题能不能想到用Shell去解决”。这篇文章适合刚学完Shell基础、准备往运维或自动化方向走的读者,也适合想通过做题查漏补缺的在职者。选题范围集中在文本处理、批量文件管理、系统巡检这三个高频方向,后面会附上我的参考答案、踩坑记录和调试心得,尽量做到每道题都能直接放到工作里用。
1. 这次练习的选题思路:为什么只挑文本、批量和巡检三类场景
Shell的语法体系其实不大,变量、循环、条件、函数、管道、重定向,加起来基本就这些。但语法是语法,应用是应用,真正让人头疼的是“什么场景该用什么组合”。我翻看了之前几套练习题的留言,发现大家的薄弱点出奇一致:一是处理日志和文本时总是 grep 一把梭,遇到复杂需求就卡住;二是批量操作文件时担心误删误改,不敢放手写;三是脚本写完能跑,但一到周末没人盯着就容易出问题。这次就针对这三个痛点设计题目。
先说文本处理。日常运维里日志是最常见的数据源,访问日志、错误日志、业务埋点日志,全都有相同套路:按时间筛选、按字段统计、去重、排序、取Top N。把这类题练熟,等于拿到了一把万能钥匙,今后无论面对什么格式的文本都能拆解成“切列、过滤、分组、汇总”四个动作。
再说批量文件管理。很多人的工作流是“先手动处理几个文件试试,再写脚本处理全部”,但手动处理和脚本处理之间经常出现标准不一致的问题。比如你手动删文件时注意了“排除今天正在使用的文件”,脚本里却没写这个逻辑;你手动重命名时保留了原始扩展名,脚本里却用 awk 直接拼字符串,把 .tar.gz 拆成了两截。所以题目里我特意放了几个容易在“手动作业转脚本”过程中翻车的细节。
最后说系统巡检。这个方向最考验Shell的“胶水”能力:一条命令拿cpu使用率,一条命令看磁盘空间,再写一段循环去遍历多台机器。单独看每条命令都不难,但要把它们整合成一个完整的巡检脚本,并具备告警和输出报表的能力,就需要对格式、进制、阈值这些细节足够敏感。把系统巡检放进来,是想让大家看到Shell脚本怎么从“跑一次”变成“定期无人值守跑”。
三类场景的代码量都不大,但每一道题都埋了几个常见的坑,比如 awk 变量引用、find 的 -exec 与管道混用、脚本收到 SIGTERM 后的清理动作等等。做一遍再对照答案,比单纯看十篇教程有效得多。
2. 第一组实践:日志文本的统计、去重与Top N排行
2.1 Nginx访问日志统计:从单条命令到可复用脚本
题目要求:给定一份Nginx访问日志 access.log,字段格式为IP - - [时间] "请求方法 路径 协议" 状态码 响应字节 "来源页" "UA",统计以下内容:
- 访问次数排前10的IP;
- 状态码为 404 的请求路径排名前5;
- 上午10点到11点之间的请求总数。
第一反应很多人会写成一行命令加 grep 加 awk 加 sort 加 uniq,这没错,但我要提醒一句:一行命令适合现场排查,不适合沉淀成脚本。真正到项目里,你需要考虑日志路径可配置、统计维度可复用、输出格式清晰。所以正确的做法是先把功能拆成三个小函数,再拼装。
#!/bin/bash LOG_FILE="/var/log/nginx/access.log" top_ip() { awk '{print $1}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10 } top_404() { awk '$9 == 404 {print $7}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -5 } count_10_11() { awk -F'[: ]' '$2 >= "18/May/2025:10:00:00" && $2 <= "18/May/2025:11:00:00"' "$LOG_FILE" | wc -l } echo "===== Top 10 IP =====" top_ip echo "===== Top 5 404 Path =====" top_404 echo "===== 10:00-11:00 count =====" count_10_11这段代码里最大的坑在第4个函数的时间过滤。Nginx日志的时间字段长这样:18/May/2025:10:32:11 +0800,包含空格。如果只用 awk 按默认空格切,$4是[18/May/2025:10:32:11,带了一个左中括号,拿它做字符串比较没问题,但可读性很差。我用了-F'[: ]'同时把冒号和空格都当分隔符,时间字段会被拆成多列,$2就是年月日,$3是小时,$4是分钟,这样比较起来直观很多。
这种字符串比较严格依赖日期格式统一。如果日志里日期是2025-05-18,按字符串比较是成立的;但如果是18/May/2025,排序上就不如数字格式自然,需要你自己先确认格式。我建议养成习惯:处理前先用head -3看一眼原始日志,别想当然。
2.2 去重统计时最容易忽略的 sort 位置
题目:统计 access.log 中有多少不同的IP,并输出每个IP的请求次数。
这里遇到的第一个坑:很多人直接写awk '{print $1}' access.log | uniq -c,然后发现结果出现了多个同样的IP。原因在于uniq只能去掉相邻的重复行,IP 在日志里不可能按顺序排好,所以必须先sort再uniq。这属于基础但高频的错误,几乎每个新人都踩一次。
第二个坑在于统计顺序。要想让输出结果从多到少排列,必须再套一层sort -rn。看起来是“多此一举”,但这是管道命令的标准顺序:提取 → 排序 → 去重计数 → 再次排序。去掉任何一环,结果都会对不上。
第三个坑是内存问题。日志几百兆的时候,sort | uniq -c会临时占用大量磁盘空间,因为 sort 是外排序,会写临时文件。我曾在一台磁盘剩余空间不到2GB的机器上处理5GB日志,直接写满了 /tmp,后来改成只统计前100万行才勉强跑通。如果是教学练习,文件小无所谓;但大家将来面对生产日志,一定要心里有数,必要时用head -n 1000000限制输入规模,或者改用 awk 数组计数。
# 只统计IP,不排序输出 awk '{count[$1]++} END {for (ip in count) print count[ip], ip}' access.log # 统计后按次数排序输出Top 10 awk '{count[$1]++} END {for (ip in count) print count[ip], ip}' access.log | sort -rn | head -10awk 数组的好处是只遍历一次文件,不需要外部 sort,性能更好。缺点是这个count[$1]++的语法对新手不太友好,且当IP维度极多时内存占用也不小。我的建议是:文件小于100MB用管道方式,简单直观;文件很大时用 awk 数组。
2.3 按时间段过滤日志的边界问题
练习里的“上午10点到11点”属于闭区间,代码里我用了>= 10:00:00 && <= 11:00:00。但很多人在现场会写成10:00:00 ~ 10:59:59,结果把11点整的那一秒漏掉了。日志统计最怕边界定义不清,一旦统计口径变了,同样的日志能得出不同的数字。
更隐蔽的是跨天问题。如果日志跨了两天,比如从5月17日22点到5月18日6点,直接写$2 >= "2025-05-18 10:00"会把5月17日的部分全部排除,这部分数据恰好是凌晨的,往往也是异常流量高发时段,漏掉会影响分析。我处理跨天时一般先把日期转成 epoch 秒,用数值比较。
awk ' { # 假设日志第4列是 [18/May/2025:10:32:11 +0800] split($4, a, "[/:]"); # a[1]=18 a[2]=May a[3]=2025 a[4]=10 a[5]=32 month = (index("JanFebMarAprMayJunJulAugSepOctNovDec", a[2]) + 2) / 3; timestamp = mktime(a[3] " " month " " a[1] " " a[4] " " a[5] " 00"); if (timestamp >= start_ts && timestamp < end_ts) count++; } ' start_ts=$(date -d "2025-05-17 22:00:00" +%s) end_ts=$(date -d "2025-05-18 06:00:00" +%s)这段代码里的 mktime 是 gawk 专有函数,在 mawk 下不可用。跨平台脚本里要用就得先检测 awk 版本。不过我觉得作为练习题,掌握“闭区间改半开区间”“字符串转时间戳比较”这两个思路就足够了,没必要一上来就把所有边角都做全。
3. 第二组实践:批量文件操作的误删防护与命名规范
3.1 批量重命名:不要在原文件名上直接拼接
题目:某目录 data/ 下有2024年1月到2025年6月的数据文件,命名格式为report_20240101.txt,要求把所有2025年的文件重命名为annual_2025_report_YYYYMMDD.txt,其他文件不动。
第一版答案很自然:
for f in data/report_2025*.txt; do date_part="${f##*_}" mv "$f" "data/annual_2025_report_${date_part}" done这个写法对不对?对,但它很脆弱。问题在于"${f##*_}"取的是最后一个下划线后面的字符串,如果文件名改成report_final_20251231.txt,取出来的就不是日期,而是20251231.txt,加上后面的.txt会重复。正确做法是先提取完整文件名,再在文件名层面处理。
for f in data/report_2025*.txt; do base=$(basename "$f") date_part="${base#report_}" # 删除前缀 report_ date_part="${date_part%.txt}" # 删除后缀 .txt mv "$f" "data/annual_2025_report_${date_part}.txt" done变量扩展虽然看起来啰嗦,但它比 sed 处理更安全,因为不涉及正则转义。生产环境里文件名可能带空格、中文、括号,用 sed 很容易出错,变量扩展在这些情况下表现稳定。
这一题我额外想强调的是:永远在 for 循环前使用 nullglob 选项约定。如果目录下一个匹配文件都没有,直接 for 循环会原样输出data/report_2025*.txt这个字符串作为文件名,mv 就会报错。这个问题偶发性很强,但一旦发生,脚本就会产生一条误导日志,让人误以为是文件移动失败。加上shopt -s nullglob可以避免。
3.2 清理过期文件:find 的正确使用比 rm -rf 重要得多
题目:清理 /app/logs/ 下所有30天前的 *.log 文件,但保留 weekly 子目录和当前正在由Java进程写入的 application.log。
这是非常典型的清理任务。新人最爱写的版本是:
find /app/logs -name "*.log" -mtime +30 | xargs rm -f这个版本问题太多:第一,find 结果里包含 weekly 子目录下所有超过30天的 log,题目明确要求保留;第二,application.log 如果超过30天没滚动,也会被误删;第三,xargs 在文件名带空格时会切分错误。我给出的参考版本:
find /app/logs \( -name "*.log" -a -type f \) \ -not -path "*/weekly/*" \ -not -name "application.log" \ -mtime +30 \ -deletefuser 不是万能的,但可以做一个加强版:先判断 application.log 是否被进程占用,如果占用则跳过,防止正在写入的文件被清理。
if fuser /app/logs/application.log >/dev/null 2>&1; then echo "application.log 正在使用,跳过" else find /app/logs -name "application.log" -mtime +30 -delete fi强调一点:-delete比-exec rm {} \;快很多,也安全很多,因为 find 内部的删除不会借助外部命令。但-delete隐含-depth,如果同时使用了-prune可能会有意外行为。稳妥起见,清理类脚本我都会先加-print跑一遍,确认列表无误,再换成-delete正式执行。这是成本最低的保险策略。
3.3 批量压缩归档:tar 与 find 结合时的常见误区
题目:把 /data/releases/ 下所有以 2024 开头且 7 天前修改的目录打包成 release_archive_2024.tar.gz。
这个场景在发布系统里经常出现,比如定期归档历史版本。常见错误是把 find 的结果直接塞给 tar:
find /data/releases -maxdepth 1 -type d -name "2024*" -mtime +7 | tar czf archive.tar.gz -T -这行基本逻辑没问题,但有一个坑:find 默认会进入目录,如果你直接用-print把目录传进去,tar 会连目录里的子文件一起归档,如果目录里存在符号链接指向外部路径,归档就会带上目标。加上-type d时尤其要注意,它匹配的是目录本身,tar 遇到目录会递归打包。
要控制路径,正确做法是先 cd 到目标目录的上级,再执行归档,让 tar 记录相对路径,而不是绝对路径。否则解包时会提示 absolute path, 拒绝解包。
cd /data/releases || exit 1 find . -maxdepth 1 -type d -name "2024*" -mtime +7 -print0 \ | tar --null -czf /tmp/release_archive_2024.tar.gz -T --print0配合 tar 的--null可以解决文件名含空格的问题,这套组合在处理路径带空格的场景下是关键经验。很多人不知道 tar 还有--null参数,以为只有 xargs 才支持-0,所以遇到奇怪文件名就会被切分。
另外需要注意:tar 打包时不要带时间戳,虽然czf默认不会带,但如果加了--format=ustar或某些平台自动记录 atime,会影响下次增量备份。归档类脚本我一般统一加--mtime固定修改时间,保证脚本可重复执行时产物一致。
4. 第三组实践:系统巡检脚本的阈值判断与告警输出
4.1 怎么稳妥地取CPU、内存、磁盘数值
题目:写一个巡检脚本,检查本机的CPU使用率、内存使用率、磁盘使用率,超过阈值时打印告警信息,并且把结果写入 /var/log/check_result.log,每次巡检保留最近10条记录。
这道题很多人卡在第一步:怎么用Shell优雅地拿到CPU使用率。top -bn1是常见方法,但要注意-b表示非交互模式,-n1表示只取一次。关键是它的输出里有多个字段,第一行的%Cpu(s)含义在不同Linux发行版里有细微差异。更稳妥的方式是读取/proc/stat,两次采样做差,算出1秒内的CPU占用。
cpu_usage() { local prev_idle local prev_total local idle local total prev_idle=$(awk '/^cpu / {print $5}' /proc/stat) prev_total=$(awk '/^cpu / {print $2+$3+$4+$5+$6+$7+$8}' /proc/stat) sleep 1 idle=$(awk '/^cpu / {print $5}' /proc/stat) total=$(awk '/^cpu / {print $2+$3+$4+$5+$6+$7+$8}' /proc/stat) awk -v prev_idle="$prev_idle" -v prev_total="$prev_total" \ -v idle="$idle" -v total="$total" \ 'BEGIN { diff_idle = idle - prev_idle; diff_total = total - prev_total; usage = (1 - diff_idle / diff_total) * 100; printf "%.2f\n", usage }' }这段代码一次只取 CPU 整体使用率,没有区分用户态、内核态等,但做巡检已经够用。注意它依赖两次采样之间的 sleep,在脚本里每次巡检都会耗时1秒,如果巡检目标是每5秒一次,这类方式就不合适,可以改成读 /proc/loadavg 再按核数折算。
内存和磁盘相对简单:
mem_usage() { awk '/MemTotal/ {total=$2} /MemAvailable/ {avail=$2} END {printf "%.2f", (total-avail)/total*100}' /proc/meminfo } disk_usage() { df -P / | awk 'NR==2 {print $5}' | tr -d '%' }内存这里用的是 MemAvailable,不是 MemFree。MemFree 只算完全空闲的页框,MemAvailable 还考虑了可回收的缓存,更接近应用视角的真实可用内存。很多人习惯用 free -m 再算,不是不行,但容错性不如 /proc/meminfo 直接读。
4.2 阈值判断:浮点数比较是Shell里最大的门槛
拿到使用率之后,该写判断条件了。CPU使用率是浮点数,比如 67.89%,而test命令只能比较整数。新人经常直接写:
if [ "$cpu" -gt 80 ]; then ...这在 CPU 为 80.5 时会报错:integer expression expected。解决办法有两个:一是把浮点数转成整数,二是用 bc 比较。
# 方案1:去掉小数点 cpu_int=$(printf "%.0f" "$cpu") if [ "$cpu_int" -gt 80 ]; then echo "CPU使用率告警:${cpu}%" fi # 方案2:用 bc 做浮点数比较 if echo "$cpu > 80.0" | bc -l | grep -q 1; then echo "CPU使用率告警:${cpu}%" fi方案1简单直接,但四舍五入可能导致79.6被当成80,恰好擦边。方案2更精确,可读性差一点,而且依赖 bc 安装。我个人更推荐方案1,因为巡检告警本身就应该有2%的冗余区间,如果79.6%也要纠结,说明阈值设置不合理。真正应该设置的是多级告警,比如85%警告、95%严重,而不是把阈值卡死在80。
4.3 告警输出与日志轮替:追加写入要防止无限膨胀
巡检结果需要写日志,最自然的做法是echo >> /var/log/check_result.log。但这样日志会无限增长,不加控制迟早把磁盘塞满。题目要求保留最近10条,我习惯用 tail 重写方式来做:
LOG_FILE="/var/log/check_result.log" log_message() { local msg="$1" echo "$(date '+%Y-%m-%d %H:%M:%S') $msg" >> "$LOG_FILE" tail -n 10 "$LOG_FILE" > "$LOG_FILE.tmp" mv "$LOG_FILE.tmp" "$LOG_FILE" }这个办法比较土,但效果直观。真正到生产环境,我一般交给 logrotate 管理,脚本只负责追加写。练习题里保留10条,主要是想让大家体会“日志不能只写不管理”。值得一提的是,用mv替换文件会改变 inode,如果有进程正持有该文件句柄,它继续写入会写到旧文件上,导致文件没有真正被清理。生产环境里我更倾向用: > "$LOG_FILE"清空而不是替换,或者干脆让 logrotate 处理。
告警输出除了写日志,我还建议把告警信息同时输出到标准输出。这样既可以人工观察,也可以被上层调度程序捕获。脚本语言层面,这是“输出与日志分离”,一行代码的事,却能让脚本复用性高很多。
综合成一个完整的巡检脚本前,先跑通每一个子函数,再逐步组装。我会把阈值定义为脚本头部变量,这样后续调整不需要改代码逻辑。脚本末尾可以加一个简单的main函数入口,控制执行顺序。这类脚本结构可以直接套用到多台机器,也用 cron 调度,第一次实测跑通后,后续维护成本很低。
5. 调试Shell练习的实战经验:那些答案里不会写的坑
5.1 set -euxo pipefail 不是万能的,别迷信
很多教程建议脚本开头一律加set -euxo pipefail。我认可 set 的作用,但对-e要理解它的副作用:一旦某条命令返回非零,脚本立即退出。这听起来合理,但现实中很多命令是非零返回的,比如grep在没匹配到内容时返回1,ls匹配不到文件时返回2。如果脚本逻辑里明确允许“没匹配到”不报错,-e反而会让脚本中断。
我处理这类问题有两种方式。一是给该命令主动补充|| true,明确表示“非零返回是可接受的”。二是用if包裹,比如:
if grep -q "PATTERN" file.log; then echo "found" else echo "not found, 继续执行" fiif条件里的命令即使返回非零,也不会触发-e退出,因为它在条件判断上下文里。这是很多人容易搞混的地方。练习中如果能灵活运用这个特性,脚本健壮性会提升不少。
5.2 管道中第一个命令快失败,第二个命令仍在跑
管道前的pipefail可以让管道返回第一个失败命令的状态码,但它不阻止第二个命令继续执行。比如grep "xxx" big.log | head -5,grep 还在逐行扫描时 head 收到5行就关闭了,管道触发 SIGPIPE,grep 可能报“Broken pipe”。这在脚本里经常导致误报。
处理方式有两种:一是明确接受这种行为,像head截断输出本来就是预期行为,那就别用管道做大事;二是改用 awk 直接内部处理,不依赖管道截断。比如取前5行匹配结果:
awk '/xxx/ {print; count++; if (count==5) exit}' big.log这比grep | head少一次进程切换,也避免SIGPIPE噪声。练习题里我特意加了这个知识点,就是想让大家体会到:命令数量少不等于效率高,关键是减少无谓的进程和信号交互。
5.3 用 bash -x 逐行跟踪,比拼接 echo 快得多
调试Shell脚本最土但最有效的办法就是bash -x script.sh。它会打印每一条实际执行的命令,变量会被展开,这样你一眼就能看出$1到底传成了什么、if 判断走了哪个分支。很多新人不愿意用,因为输出太乱,但其实配合bash -x加PS4设置,可读性会好很多:
export PS4='+【$LINENO】' bash -x check_system.sh$LINENO会显示当前行号,配合脚本找出问题行位非常高效。
另一种调试技巧是分阶段注释。把脚本里的大函数拆开,临时在一个函数后面加exit,确认前一段逻辑没问题再继续下一步。这看起来笨,但实际调试效率很高,因为Shell没有断点调试器,分阶段定位是唯一可靠的方式。哪怕是我写正式脚本,也会先在关键函数后加echo "pass",确认执行路径符合预期再删掉。
5.4 IFS与空值:for循环读文件内容时的隐藏杀手
处理带空格的文件名或读取文件行时,IFS设置直接影响结果。默认IFS包含空格、制表符和换行,而read命令如果不指定IFS,行首行尾的空格会被吃掉。比如读取一行配置key = value,期望 key 是key,value 是value,默认 IFS 会丢弃多余空格,结果不确定,还容易出错。
我一般在读取场景里显式设置:
while IFS= read -r line; do echo "原始内容:$line" done < config.txt这里的IFS=表示“不按任何字符分隔”,read会把整行内容原样放进变量;-r表示不处理反斜杠转义。这两个参数组合是读取配置文件的标配,少了任何一个都可能改变内容内容。练习题里如果遇到从文件读取路径做批量操作,这条经验会帮你省半小时排查时间。
5.5 变量加双引号不是形式主义
最后一个老生常谈但必须强调的坑:变量引用一定要加双引号。rm -rf $f和rm -rf "$f"在文件名是rm -rf /tmp/a b.txt时结果完全不同。不加双引号,Shell会做分词,a和b.txt会被当成两个参数;加双引号,整个字符串才是一个参数。
我甚至见过一段生产脚本因此出事:变量来自外部输入,里面夹了*通配符,没有双引号保护时被展开成目录下所有文件列表,最终命令变成了删除多个不相关目录。从那以后,我给自己定了一条规矩:脚本里所有变量出现在命令行时,只要它不是故意要生效通配或分词,一律加双引号。看起来繁琐,但能避免绝大多数低级别事故。
6. 练习后的自查清单与进一步改进方向
每次做完这套题,我建议你按下面的清单自查一遍,这也是我平时检查自己脚本的标准流程。
第一,确认脚本开头是否声明了解释器和编码。#!/bin/bash和LANG=C在脚本不跨平台跑的时候不一定有影响,但一旦日志里混入中文时间或者环境变量被修改,编码问题可能让 grep 和 awk 匹配失败。加一行LANG=C没坏处。
第二,检查所有超过一行的命令是否考虑了输入数据为空的情况。比如 for 循环没有匹配到文件,默认会输出通配符字符串;find 没有匹配到文件,-exec不会执行,但管道后面的命令可能依然执行,输出空结果。要想脚本在“没有数据”时也能正常结束,必须主动考虑空值分支。
第三,检查告警阈值是否被写死在代码里。我的习惯是把所有阈值集中成变量放脚本顶部,例如CPU_WARN=80、DISK_WARN=85,改起来一目了然。如果阈值散落在各处,后期调整极易漏改,也容易造成监控口径不一致。
第四,脚本是否输出了足够的上下文。告警日志不能只写“CPU使用率过高”,还得带上当前值、阈值、主机名、时间点。缺少上下文的告警没法快速定位问题,等于白告警。巡检脚本更要把每次检查的具体数值都打印出来,即便没有告警,也要能追溯历史状态。
做完这套练习题,如果再想进一步深挖,可以从几个方向扩展:把文本统计题改成支持任意日志路径的命令行工具,增加压缩归档功能;把系统巡检脚本改成支持远程主机批量巡检,并用 SSH 密钥免密执行;还可以引入 cron 调度,让脚本每天自动跑并输出日报。Shell 的边界很大,一套练习题肯定覆盖不完,但只要把文本处理、文件管理、系统巡检这三块基础打牢,后面不管面对什么岗位的自动化需求,心里都会比较踏实。