1. 先说清楚这篇要解决什么问题如果你一直在跟着这个系列往前啃到第五篇的时候基础语法基本都过了一遍——变量、判断、函数、字符串处理这些单独拿出来都能写可一旦要写一个真正能跑业务逻辑的脚本很多人就开始卡壳了。卡在哪不是语法不会而是不知道怎么把零散的语法拼成一个能稳定运行的东西。这篇我就来专门聊几个在实战里特别高频、但教程里往往一笔带过的内容循环结构的正确打开方式、日期时间的格式化处理、grep在脚本里的常规用法和边界、shift命令的参数处理逻辑还有最让人头疼的脚本坑。顺便把后台执行和交互式输入密码这块也讲透因为写自动化脚本时几乎绕不开。适合谁看如果你已经会写简单的shell脚本能读懂变量和if判断但写稍复杂一点的逻辑就心里没底那这篇就是给你准备的。纯零基础可以先往前翻系列前几篇把变量和基础命令过一遍再回来。我不打算把每个命令的man手册复述一遍重点放在真实项目里这个命令到底怎么用、有哪些坑别踩。说一下我自己的背景。我日常工作里有大量时间在和shell打交道——日志分析、数据搬运、定时任务、批量运维、CI流水线到处都是脚本的影子。写多了之后你会发现shell脚本最大的优势不是性能而是粘合能力能把各种命令以最快速度拼成一个自动化流程。但也是因为太灵活坑特别多。这篇里的经验基本都是我从生产环境里踩出来的。2. 循环结构脚本提效的第一把钥匙2.1 for循环的三种形态你至少得懂后两种有编程经验的人第一次写for循环的时候通常会下意识去找那种传统的三表达式写法初始值、条件、自增。shell跟这个不太一样它的for循环更像是在遍历一个列表。基本形态是这样for i in 1 2 3 4 5; do echo number: $i done这种写法能跑但真正用起来会受局限。你想遍历1到100总不能手写100个数字。更实用的写法是用花括号展开for i in {1..100}; do echo number: $i done还有一种是C风格的三表达式它更接近你脑子里的传统循环for ((i1; i100; i)); do echo number: $i done三种写法我平时都见过。最推荐的是第二种和第三种。第二种写起来短但有个细节——{1..100}这个展开发生在变量替换之前所以如果你写成{1..$n}它是不会生效的。想用变量控制范围就得换成seq或者C风格循环n100 for i in $(seq 1 $n); do echo number: $i donen100 for ((i1; in; i)); do echo number: $i done实战里我用的最多的是C风格因为它的步长控制最灵活也最容易表达每隔两个处理一次这种逻辑for ((i0; i10; i2)); do echo even index: $i done2.2 for循环遍历文件时别忽略通配符的行为差异遍历当前目录下的所有 log 文件大家都会想到写for f in *.log。这个写法没问题但有两个行为你必须清楚。第一如果没有匹配到任何.log文件通配符不会展开而是原样保留。也就是说f的值会变成字符串*.log你的循环体还是会执行一次只是拿到的是一个不存在的文件路径。这是个经典的空匹配但没跳过陷阱。解决办法是开nullglob这个shell选项shopt -s nullglob for f in *.log; do echo processing: $f done这样在没有匹配文件时循环体压根不会进入。另一个相关选项是failglob如果没匹配会直接报错看个人习惯。第二如果你用ls的结果去for循环遇到文件名有空格就会炸。比如目录里有个my app.log它会先被拆成my和app.log循环就完全乱了。正确姿势永远是用通配符展开或者find配合-print0while read后面细说。2.3 while循环和until循环别再只会写死循环while循环在shell里最常见的应用场景其实是逐行读取文件。先看最典型的读文件写法while IFS read -r line; do echo line: $line done input.txt这个写法值得拆开讲讲。IFS是防止行首和行尾的空格被修剪-r是防止反斜杠被当作转义符read一次读一行。这三个细节丢了任何一个遇到特殊内容就会出问题。我见过很多人写while read line然后数据里有个反斜杠结果被莫名吞掉排查半天。如果要把文件按某种分隔符拆分比如按冒号解析/etc/passwd格式的数据可以这样while IFS: read -r user pass uid gid rest; do echo user: $user, uid: $uid done data.txtuntil循环的语义和while正好相反——条件为假时执行为真时退出。它适合等到某个条件满足的场景。比如你在边启动服务边等端口就绪不要傻傻sleep固定秒数可以这样写until curl -s --connect-timeout 1 http://127.0.0.1:8080/health /dev/null 21; do echo service not ready, waiting... sleep 2 done echo service is up这种写法比固定sleep优雅得多服务早好了就能早继续服务一直没起也不会无脑等结束后才发现问题。当然生产环境最好再加上超时重试上限防止无限循环max_retry30 count0 until curl -s --connect-timeout 1 http://127.0.0.1:8080/health /dev/null 21; do count$((count 1)) if [ $count -ge $max_retry ]; then echo timeout waiting for service 2 exit 1 fi echo waiting... retry $count sleep 2 done2.4 循环体里的管道和子shell是最容易出幺蛾子的地方新手最容易在这里抓狂在循环里处理完数据循环结束后发现变量值没变。原因很简单——管道符两边的命令是在子shell里执行的你在子shell里改的变量影响不到父shell。举个例子count0 cat input.txt | while read -r line; do count$((count 1)) done echo total lines: $count # 输出0因为你改的是子shell里的count这里while在管道右边等于在一个子shell里跑外边的count永远不会变。正确写法是去掉管道用输入重定向count0 while read -r line; do count$((count 1)) done input.txt echo total lines: $count # 这次是正确值还有一个同样隐蔽的场景$()命令替换和后台执行也一样会拉起子shell。记住这条铁律在shell里凡是打了管道、命令替换、或者用放到后台的部分都和当前shell不是一家人。要改数据就尽量用输入重定向别用管道。3. 把时间变成字符串——日期时间的格式化和计算3.1 date命令的基本功远不止一个date脚本里和时间打交道的需求太常见了生成备份文件名、写日志时间戳、统计某个时间窗口的数据、算文件的年龄。date命令是这一切的核心。先看几个最常用的格式化输出date %Y-%m-%d # 2025-01-15 date %Y-%m-%d %H:%M:%S # 2025-01-15 14:30:22 date %Y%m%d%H%M%S # 20250115143022 date %s # 秒级时间戳 date %N # 纳秒一般取前几位做毫秒 date %u # 星期几1周一, 7周日这些格式符里%Y是完整的四位年份小写%y是两位%m是两位月份%d是两位日期。最容易被忽略的就是两位数靠不靠前补零——在排序场景里2025-1-5和2025-01-05在字典序排序时结果是不同的。所以只要你是为了文件名排序就要坚持用%m、%d、%H这种补零的格式。热搜词里有个需求shell脚本获取当前日期时间并转换为数字串其实就是上面这种date %Y%m%d%H%M%S或类似格式。数字串的好处是方便做唯一文件名、流水号。比如备份脚本里backup_dir/data/backup ts$(date %Y%m%d_%H%M%S) tar -czf ${backup_dir}/app_backup_${ts}.tar.gz /path/to/app3.2 时间的运算加减天数、时间差计算光会格式化还不够很多时候需要算时间差。Linux的date命令原生支持-d做时间推导这是GNU coreutils提供的能力macOS上默认的BSD date不支持需要date -j -f那套语法所以脚本如果要跨平台跑别直接用-d要么兼容要么只在Linux上用。下面的例子都按Linux环境为主。加减天数date -d today %Y-%m-%d date -d yesterday %Y-%m-%d date -d 7 days ago %Y-%m-%d date -d 2025-01-01 1 month %Y-%m-%d date -d 2025-01-01 -30 days %Y-%m-%d算两个时间点之间的差值可以先转成时间戳再相减start_ts$(date -d 2025-01-15 08:00:00 %s) end_ts$(date -d 2025-01-15 12:30:00 %s) diff$((end_ts - start_ts)) hours$((diff / 3600)) minutes$(( (diff % 3600) / 60 )) echo elapsed: ${hours}h ${minutes}m这种时间戳相减的方式很原始但足够可靠。如果在日志分析里算平均耗时就逐行把时间戳取出来累加最后除以行数。命令本身不复杂真正要注意的倒是数据的格式统一千万别混进1/15/2025和2025-01-15这种不同样式不然脚本跑出来的结果是错的还不好查。另有一个能提升脚本质量的小习惯——用时间戳做临时文件名避免多个脚本实例冲突tmpfile/tmp/myapp_$(date %s)_$$.tmp trap rm -f $tmpfile EXIT echo temp data $tmpfile$$是当前脚本的PID加时间戳基本不会重名EXITtrap保证脚本退出时清理掉临时文件这是很省心的组合。3.3 处理日志里昨天的文件一个完整小例子把上面的知识串一下。假设每天凌晨要处理前一天的日志文件文件名格式是app_20250114.log。脚本要自动算出昨天对应的文件名#!/bin/bash ts$(date -d yesterday %Y%m%d) logfile/var/log/myapp/app_${ts}.log if [ ! -f $logfile ]; then echo [ERROR] log file not found: $logfile 2 exit 1 fi echo [INFO] processing $logfile # 这里做具体的分析逻辑 wc -l $logfile这个脚本虽然短但把日期推导、变量拼接、文件判断都串起来了。真实的定时任务脚本往往就是在这些基础上慢慢膨胀的。4. grep在shell脚本中的正确姿势4.1 别再只记grep xxx了这几个参数才是日常主力grep在命令行里随手用是一回事在脚本里作为判断条件或者数据过滤器又是另一回事。先说最常用的几种场景。按模式过滤基础正则grep ERROR app.log # 找包含ERROR的行 grep ^2025-01-15 app.log # 按行首匹配 grep ERROR\|WARN app.log # 匹配ERROR或WARN基础正则里要加反斜杠 grep -E ERROR|WARN app.log # 用扩展正则 grep -v INFO app.log # 反向匹配排除INFO行这里我要单独提一下-E和grep正则标准的坑。老派的grepgrep基本正则里、?、|这些元字符是字面字符必须加反斜杠才有特殊含义。所以碰到|来区分多个模式最容易出错的写法是漏加反斜杠。一个建议脚本里统一用grep -E也就是扩展正则逻辑更接近你脑子里想的正则语法不容易踩转义的坑。上下文控制参数在日志排障的时候特别有用grep -B 5 OutOfMemory app.log # 报错前5行 grep -A 10 Exception app.log # 报错后10行 grep -C 3 timeout app.log # 前后各3行4.2 在脚本里用grep区分找行和查状态两种用法脚本里用grep有两种完全不同的姿势。第一种是拿grep当过滤器把符合的行抽出来再处理第二种是拿grep当判断条件根据有没有匹配到决定分支逻辑。先看过滤用法。最常见的组合是grep | awk或者grep | sed。比如从日志里抽出所有ERROR发生的时间点grep ERROR app.log | awk {print $1, $2}但这里有个性能与代码风格的小建议如果你已经准备用awk处理列那awk本身也能做正则过滤没必要先grep再awkawk /ERROR/ {print $1, $2} app.log少一次管道多清爽一点。先把整条链路读一遍再决定哪些活儿给grep哪些给awk代码会简洁很多。第二种姿势是用grep -q静默判断if grep -q ERROR app.log; then echo found errors fi-q表示不输出任何内容只根据退出码判断有没有匹配到。这是脚本里最标准的文件内容里是否包含XXX的判断写法。你可能会看到有人写grep -c然后判断次数大于0其实远远不如-q干脆-q在找到第一个匹配后就会提前退出性能也更好。4.3 递归搜索和静默输出关键词搜索的进阶用法搜索整个项目目录里的关键词一般用-r参数grep -r TODO --include*.sh . # 只搜.sh文件不过我得说句实话能不用grep -r就不用。项目稍大一点这命令会扫出你压根不想看的文件还慢。更推荐用find先精确圈定文件再喂给grepfind . -name *.sh -exec grep -H TODO {} 再配合-l只列文件名list files with matches、-c输出每文件匹配行数脚本里做代码检查、统计工作就很好用了grep -rl password --include*.conf /etc/ | while read -r f; do echo check file: $f done这里其实还藏了一个grep回传状态的经典坑管道左边的grep退出码是拿不到的。你写成grep -r xxx | while read ...然后想判断有没有匹配grep的退出码被管道吞了。要用上面的-l判断文件数量或者用进程替换等手段绕过。这个跟前面循环里的子shell问题是同一类思维。4.4 匹配中文、特殊字符以及隐藏文件的几个细节grep匹配中文时默认locale决定能不能命中。如果你的环境是LANGC正则里出现中文基本会失灵而UTF-8 locale下就没问题。脚本里涉及中文匹配前可以先检查一下locale必要时在脚本里加export LANGen_US.UTF-8。还有一个容易被忽略的问题匹配某些正则元字符时要格外小心点号和时间戳格式。想匹配字面意义的.得写成grep -F或用反斜杠转义grep -F app.tar.gz files.txt # -F 是固定字符串匹配 grep app\.tar\.gz files.txt-F参数很多人不用其实在匹配路径字符串带点的文件全名这类场景下特别好用完全不用考虑转义。5. shift命令处理参数的一把好刀5.1 shift到底做了什么shift干的事特别简单把位置参数整体左移一位$2变成$1$3变成$2以此类推原来的$1被丢弃$#参数个数减一。可以指定移多少位比如shift 2一次移两位。为什么需要它因为你在解析多个参数的时候往往要消费掉已经处理过的参数。举个最直观的场景脚本要解析-f 文件名 -v#!/bin/bash verbose0 while [ $# -gt 0 ]; do case $1 in -f) file$2 shift 2 # 参数和它的值都消费掉 ;; -v) verbose1 shift # 只消费一个选项 ;; *) echo unknown option: $1 2 exit 1 ;; esac done echo file$file, verbose$verbose这段代码就是教科书式的参数解析。shift的作用是保证循环的每次迭代都处理一个新的队首参数不会无限循环。5.2 当参数里有空格或者多个值时shift的配合策略有时候一个选项后面跟的是多个值。比如参数--file a.log b.log c.log你希望在--file后连续读入不限数量的文件。用shift处理while [ $# -gt 0 ]; do case $1 in --file) shift while [ $# -gt 0 ] [[ $1 ! --* ]]; do files($1) shift done ;; *) shift ;; esac done echo files: ${files[]}这里用[[ $1 ! --* ]]判断下一个参数不是新的选项遇到--开头就停止收集。要注意的是这种写法把所有非选项参数都归到了files里实际用哪种策略取决于你的脚本定位。但shift能灵活处理变长参数组这个特性就在这。5.3 与getopts对比什么时候该换getopts如果参数很多且需要支持-a、-b、-c这种短选项的组合手写shift case也可以但getopts更标准。getopts的一个优势是自动处理选项和值的拆分不用你手动去shift。不过getopts对长选项--help这类支持比较弱需要继续用shift自己解析。我个人的习惯是参数少于5个且都是简单的选项用getopts。参数复杂、有长选项、有变长值列表直接手写while case shift。如果有依赖关系比如参数A必须和参数B同时出现手写逻辑控制起来更清楚。getopts的基本形态#!/bin/bash while getopts f:vh opt; do case $opt in f) file$OPTARG ;; v) verbose1 ;; h) usage; exit 0 ;; *) usage; exit 1 ;; esac done shift $((OPTIND - 1))注意最后的shift $((OPTIND - 1))它的作用是把已经处理完的选项参数从$里删掉剩下的就是位置参数了。OPTIND是getopts内部维护的下一个要处理的位置这个细节很多人漏掉导致后面取位置参数时取值错位。5.4 一个真实的多选项脚本骨架综合起来我给一个相对完整的模板它适合大多数手动解析复杂参数的场景#!/bin/bash # parse_complex_args.sh usage() { echo usage: $0 [-f file] [-v] [--count N] [--tag TAG] path... } file verbose0 count1 tagdefault paths() while [ $# -gt 0 ]; do case $1 in -f|--file) file$2 shift 2 ;; -v|--verbose) verbose1 shift ;; --count) count$2 shift 2 ;; --tag) tag$2 shift 2 ;; -h|--help) usage exit 0 ;; --) shift paths($) break ;; -*) echo unknown option: $1 2 usage 2 exit 1 ;; *) paths($1) shift ;; esac done echo file$file, verbose$verbose, count$count, tag$tag echo paths${paths[]}--是一个约定俗成的分隔符表示后面不再是选项都是位置参数。这个模板扩展性很好选项加多或者改长选项都不费劲。6. 写shell脚本常见的那些坑我一条条给你踩给你排6.1 变量未定义、空变量、带空格的文件名三连坑sh脚本最经典的坑就是变量没定义的时候不会报错结果以空字符串参与运算后面生成一堆看起来莫名其妙的路径。我建议你在写脚本的时候默认开启set -u这样引用未定义变量就会直接报错退出#!/bin/bash set -u echo $not_defined_var # 会报 unbound variable第二个坑是变量为空。比如dir rm -rf /tmp/${dir}/sub如果dir为空这个路径会变成/tmp/sub你原本想删的是某个子目录结果可能把不该删的删了。防御性写法是给变量一个默认值dir${dir:-/tmp/default}第三个坑是带空格的文件名。脚本里所有引用文件路径的变量一律要加双引号# 错 rm $file # 对 rm $file6.2 判断语法[ ][[ ]](())各自的地盘别互相越权新手最容易混的就是[、[[和((。我简单总结一下[是test命令的别名POSIX风格老而稳。字符串比较用数字比较用-eq、-lt、-gt。注意它要求变量加引号尤其是可能为空的变量。[[ ]]是bash的关键字更安全更强大。支持、、的字符串比较支持正则~也支持、||。推荐在bash脚本里一律用[[ ]]。(( ))是算术判断里面可以不用$符号做数值比较非常自然a5 if (( a 3 )); then echo big fi举一个日常例子判断一个数字是否在某个区间里面if (( n 1 n 10 )); then echo n is between 1 and 10 fi6.3 set -e到底要不要开我的态度是有条件地开set -e表示脚本中任何命令返回非零退出码时立即退出脚本。很多模板默认带这一行但实际业务里它经常误伤。比如你明明允许某个命令失败像grep找不到匹配退出码是1开了set -e整个脚本就停了。我的做法是脚本开头写set -Eeuo pipefail让绝大多数错误立刻暴露。对允许失败的命令在末尾加|| true或显式处理退出码。对确实可能失败的单独命令用if command; then包裹。#!/bin/bash set -Eeuo pipefail # 允许找不到匹配的情况 if grep -q pattern file.txt; then echo matched else echo no match fi # 某个命令失败不影响整体 rm -f /tmp/not_exist || truepipefail这个选项很关键没有它管道命令的退出码只看最后一条命令。比如grep xxx file | sed s/a/b/中grep失败了、sed成功了整体退出码是sed的0你会以为整个流程没问题。开了pipefail管道里任何一环失败都算失败。调试流水线和多命令协作时这个选项能救你一命。6.4 反引号与$()之争以及单引号双引号的区别命令替换有两种写法resultcommand result$(command)我最推荐用$()。好处有三个支持嵌套、可读性更好、老式反引号在处理反斜杠时行为很诡异。不夸张地说凡是你在脚本里看到长串反引号包着复杂命令的十有八九是从老教程里抄的。变量替换里单双引号的差异必须记牢nameworld echo hello $name # 输出 hello world双引号会做变量展开 echo hello $name # 输出 hello $name单引号原样输出需要拼路径、拼字符串的时候尽量用双引号包变量。需要保留$字样比如生成shell脚本内容传给别的程序时才用单引号。6.5 IFS、通配符、换行符——数据分割的隐藏控制者IFSInternal Field Separator决定了shell怎么拆分词。默认是空格、Tab、换行。这一般够用但当你处理的是按逗号分割的一行数据时就可以临时改IFSdataalice,bob,charlie OLDIFS$IFS IFS, for name in $data; do echo name: $name done IFS$OLDIFS改完记得恢复。另外很多人在读文件时会遇到行尾是\rWindows换行符导致最后多了个奇怪符号的问题可以在read时做处理while IFS read -r line; do line${line%$\r} # 去掉行尾的CR echo $line done input.txt${line%$\r}这段是bash的去除末尾匹配字符串技巧%表示从末尾删除$\r是bash里的转义CR表示法。这个坑在跨平台处理文本文件时特别常见尤其你从Windows上拉下来的日志文件。6.6 脚本执行权限、CRLF换行符、shebang的幺蛾子在脚本跑不通的时候先按这个顺序排查一查权限。你是不是忘了chmod x script.sh检查方式是直接执行bash script.sh如果这样能跑而./script.sh报Permission denied那就是权限问题。二查换行符。你在Windows上编辑的脚本传到Linux上执行时如果报错$\r: command not found说明文件是CRLF换行。用sed -i s/\r$// script.sh转成LF。现在很多编辑器都支持右下角切换换行符保存时选LF就完事儿。三查shebang。第一行写#!/bin/bash和#!/usr/bin/env bash的差异是后者会去PATH里找bash。如果你的bash不在/bin/bash下比如在/usr/local/bin用env方式更兼容。但要注意/usr/bin/env在有些极简环境里也不存在。多数Linux发行版#!/bin/bash是最稳的选择。7. 后台执行与交互式输入密码自动化脚本里的两大磨人环节7.1 用放后台shell的子shell逻辑还得再敲一遍命令末尾加程序就放后台跑了当前终端还能继续敲命令。这个大家都会但要注意后台任务是在子shell里运行的。和前面说的管道一样后台任务里改的变量不会影响当前shell。后台执行的标准姿势是配nohup加上输出重定向nohup ./long_task.sh /tmp/task.log 21 nohup让进程在终端退出后继续存活把标准输出重定向到日志21把错误输出也合并进去。如果不做这个重定向进程的输出会直接抛向终端一旦你关闭SSH会话进程可能被挂断。判断后台任务是否完成的常规思路是写轮询。我给一个例子./long_task.sh pid$! echo task pid: $pid # 轮询等待任务结束 while kill -0 $pid 2/dev/null; do echo [$(date %H:%M:%S)] task still running... sleep 5 done echo task finishedkill -0 $pid是一个特殊的探测方式——不发信号只检查进程是否存在。如果进程已经不在了kill -0返回非零循环退出。这个写法比ps -p $pid更轻量是后台任务管理里很实用的一招。7.2 交互式输入密码的几种处理思路在自动化脚本里需要执行带交互提示的命令比如sudo、ssh、ftp这就面临如何把密码喂给程序的问题。先声明一句密码处理一定要谨慎尽量别在命令行明文传递、别把密码硬编码到脚本里。但在受控环境里确实有一些常规做法。第一种也是最简单的管道输入echo mypassword | sudo -S commandsudo -S表示从标准输入读密码。但有几个问题密码留在shell历史里管道把echo和sudo连接起来sudo可能已经输出了提示信息密码本身在进程列表里可见。所以只能用在一次性、不敏感的内部场景。第二种用expect这类工具模拟交互。expect不是shell自带需要安装但它能处理更复杂的交互流程expect EOF spawn ssh userhost ls /tmp expect password: send mypassword\r expect eof EOF这里用了heredoc把expect脚本直接喂给expect命令看起来干净一些。但expect脚本本身有很多细节要调试匹配的提示文本必须和真实程序输出的完全吻合。第三种有些命令支持通过环境变量或配置文件提供密码。比如sshpasssshpass -p mypassword ssh userhost ls /tmp还有MYSQL_PWD、PGPASSWORD这类专属环境变量。能用专属变量的时候优先用专属变量比-p明文参数安全一丁点。第四种基于ssh密钥免密登录这才是向阳面的解法。自动化任务如果是对自己的服务器做操作就不要反复输密码了直接用密钥ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N ssh-copy-id userhost之后ssh就不用密码了脚本里彻底告别密码交互。注意以上这些涉及密码的方式都只适用于你完全掌控的受信环境。把密码写进脚本本身就有风险脚本要入库管理的话想清楚三件事——权限是不是够紧、日志会不会泄露密码、其他人能不能看到这个文件。7.3 让脚本支持后台密码的正确结构把后台和密码放到一起实际场景往往是这样的远程备份、远程执行命令、然后等待结果。我个人会这样组织#!/bin/bash # 远程执行场景 remote_hostuserbackup-server remote_cmdtar -czf /backup/app_$(date %Y%m%d).tar.gz /data/app sshpass -p ${SSH_PASS:-} ssh -o StrictHostKeyCheckingno $remote_host $remote_cmd生产环境我更偏好用环境变量传密码比如在CI系统里配置密文变量脚本里从${SSH_PASS}读取不把明文写死在文件里。这是一个比较务实的折中方案。7.4 basename、dirname、$0、$?脚本里被低估的小配件最后补几个非常常用的小命令。basename去掉路径只留文件名dirname反过来只留目录部分script_path/home/user/bin/deploy.sh basename $script_path # deploy.sh dirname $script_path # /home/user/bin这两个命令在脚本里定位自身目录很有用。比如很多脚本要读取同目录下的配置文件但用相对路径./config.ini在调度工具里调用时可能会跑错目录。正确写法是SCRIPT_DIR$(cd $(dirname ${BASH_SOURCE[0]}) pwd) source ${SCRIPT_DIR}/config.ini这里的BASH_SOURCE[0]在脚本被source时也能指向当前文件路径比$0更可靠。$0在source场景下会变成父shell的名字单独执行脚本时才代表脚本自身路径这个区别知道的人不多。$?是上一条命令的退出码0代表成功非0代表失败。它是if语句里隐式使用的判断来源也是调试脚本最重要的信息源。脚本写得越长越要在关键步骤后打印退出码或关键结果出问题的时候排查成本能少一半。command rc$? if [ $rc -ne 0 ]; then echo [ERROR] command failed with code $rc 2 exit $rc fi提示上面这些工具命令看起来不起眼但它们是脚本自我定位和错误传递的基础。一个专业脚本和一个能跑的脚本之间差的往往就是这种细节的处理。8. 实战串一串把这些知识拼成一个可用的日志统计脚本光讲散点不够最后拿一个杂质处理级别的完整脚本来收尾。场景运维需要每天统计前一天的应用日志按小时汇总每个小时的ERROR数量并且把结果写到一个汇总文件里。这个脚本会用到date格式化、for循环、grep匹配、awk列处理、后台运行和退出码判断。#!/bin/bash set -Eeuo pipefail log_dir/var/log/myapp yesterday$(date -d yesterday %Y%m%d) log_file${log_dir}/app_${yesterday}.log out_file/tmp/error_summary_${yesterday}.txt if [ ! -f $log_file ]; then echo [ERROR] log file not found: $log_file 2 exit 1 fi : $out_file for ((h0; h23; h)); do hour_padded$(printf %02d $h) count$(grep ^${yesterday} ${hour_padded}: $log_file | grep -c ERROR) echo ${hour_padded}:00 ${count} $out_file done echo [INFO] summary written to $out_file cat $out_file这个小脚本值得拆的点set -Eeuo pipefail一开任何环节出错立刻退出。: $out_file是清空文件的惯用法比touch靠谱touch不会清空已有内容。printf %02d $h负责补零%02d是C风格格式化shell里同样能用。grep ^${yesterday} ${hour_padded}:通过行首锚定和精确小时切面把每个小时的日志块截出来再grep -c ERROR计数。这是典型的日志按小时切片统计需求在你需要做业务日志报表、异常分析的时候稍微改改正则和输出格式就能复用到很多场景。如果你想让统计结果更直观还可以顺手在这个脚本里加上当日总错误数total$(grep -c ERROR $log_file) echo total errors: $total $out_file然后把整个统计过程放到后台运行nohup ./daily_error_summary.sh /tmp/summary_cron.log 21 这个组合可以放进crontab每天凌晨跑第二天看一眼汇总文件就可以了。一个典型的数据搬运预处理统计自动化流程就这么搭起来了。写到这里这个系列的第五篇也就差不多了。说来说去shell脚本的学习没有太多玄学就是多写、多踩、多总结。循环、时间、文本处理、参数解析、排错思维这些硬骨头啃下来绝大多数日常自动化任务你都能接得住。剩下没讲的比如sed的高级用法、awk的功能深化、函数的作用域细节我后面再找机会继续写。