Appearance
管道与重定向
Shell 里处理日志、串联命令、写脚本,都离不开标准输入输出、重定向和管道。这几个概念看起来基础,但很多诡异的现象——"命令明明报错了为什么没进日志""脚本为什么静默失败了""为什么 sudo echo 重定向没权限"——根因都在这。把这三个标准通道和重定向机制搞清楚,排查这类问题就心里有底了。
一、三个标准通道
每个进程启动的时候,内核会默认给它打开三个文件描述符,分别对应输入、输出、错误输出:
| 文件描述符 | 名称 | 默认指向 | 干什么用 |
|---|---|---|---|
0 | stdin(标准输入) | 键盘 | 命令读取数据的地方 |
1 | stdout(标准输出) | 终端屏幕 | 命令正常输出结果的地方 |
2 | stderr(标准错误) | 终端屏幕 | 命令输出错误信息的地方 |
在 Shell 里,正常输出和错误输出默认都打到屏幕上,看起来混在一起。但底层它们是分开的两条通道,可以分别重定向。理解这点很关键——后面很多操作都是基于"stdout 和 stderr 是分开的"这个事实。
bash
command >out.log 2>err.log> 默认只重定向 stdout(等同于 1>)。stderr 不会被它带走,仍然会打印到屏幕上。这就是为什么有时候你把命令输出重定向到文件了,屏幕上还是蹦出来一堆红字——那些是 stderr,根本没被你的 > 接住。
二、重定向
覆盖写入(文件原有内容会被清掉):
bash
echo "hello" > file.txt追加写入(保留原有内容,在末尾加):
bash
echo "world" >> file.txt单独重定向错误输出:
bash
ls /not-exist 2> err.log把 stdout 和 stderr 合并到同一个文件,这是最常用的写法:
bash
command > all.log 2>&1
command &> all.log # 上面一句的简写形式2>&1 这个写法有个特别容易踩的顺序坑。command >all.log 2>&1 的执行顺序是:先 >all.log(把 stdout 指到文件),再 2>&1(把 stderr 指向"当前的 stdout",也就是那个文件)。但如果反过来写成 2>&1 >all.log,stderr 先指向了"原来的 stdout"(终端),然后 stdout 才被改到文件——结果是 stderr 还打印在屏幕上,没进文件。所以记住顺序:重定向从左到右依次处理,2>&1 一定要写在目标重定向之后。
定时任务里常见的写法:
bash
*/5 * * * * /opt/check.sh >>/var/log/check.log 2>&1用 >> 追加而不是 > 覆盖,避免每次执行把之前的日志清掉。脚本执行频繁的时候,还要配合 logrotate 控制日志文件大小,不然几个月之后日志文件几个 G,打开都费劲。
三、丢弃输出
bash
command >/dev/null
command >/dev/null 2>&1/dev/null 是个特殊的设备文件,写进去的数据直接被丢弃,俗称"黑洞"。临时调试不想看输出的时候可以用。但生产脚本里千万别把 stdout 和 stderr 全丢到 /dev/null——一旦脚本失败,你没有任何排查线索,只能两眼一抹黑。至少要保留 stderr,或者关键步骤的输出到日志文件。
四、管道
管道 | 把前一个命令的 stdout 接到后一个命令的 stdin,串起来用:
bash
ps aux | grep nginx管道只传送 stdout,不传送 stderr。前一个命令报错的时候,错误信息会直接显示在终端上,不会进入管道。所以 command 2>&1 | grep xxx 这种写法是有意义的——先把 stderr 合并到 stdout,再进管道,才能 grep 到错误信息。
一个小优化:很多命令可以直接读文件,不需要 cat 中转:
bash
grep " 500 " access.log # 更直接
cat access.log | grep " 500 " # 多起了一个 cat 进程,功能完全一样小文件无所谓,但脚本里大批量处理的时候,少一次管道就少一次进程开销,积少成多。
管道还有一个容易被忽略的坑:默认情况下,管道命令的退出码是最后一个命令的退出码,前面命令失败不算数。
bash
grep "ERROR" app.log | head如果 head 成功了,但 grep 没找到匹配(返回 1),整条管道的退出码还是 0——看起来"成功了",其实根本没匹配到。巡检脚本里这种"假成功"很危险,所以要加上:
bash
set -o pipefailpipefail 打开之后,管道里任何一个命令失败,整条管道的退出码就是失败,这样 set -e(出错即退出)才能正确捕获管道中的错误。
五、tee
tee 这个名字来自水管里的 T 型三通——数据流进来,一份写到文件,另一份继续往下游传(输出到终端,或者传给下一个命令)。
bash
echo "hello" | tee file.txt
echo "world" | tee -a file.txt # -a 追加而非覆盖tee 一个特别实用的场景:需要 sudo 权限写入受保护的文件:
bash
echo "net.ipv4.ip_forward = 1" | sudo tee /etc/sysctl.d/99-ip-forward.conf很多人第一次会遇到这个困惑:sudo echo "xxx" > /etc/protected.conf 会报"权限不够",明明加了 sudo 啊?原因在于——重定向 > 是当前 Shell 做的,不是 sudo 做的。Shell 用你的普通用户权限去打开目标文件写入,自然没权限。sudo 只提升了 echo 命令的权限,但 echo 的输出是 Shell 重定向的,跟 sudo 无关。用 sudo tee 就对了——tee 以 sudo 身份运行,有权限打开那个文件,然后把 stdin 的内容写进去。
六、xargs
xargs 把标准输入转换成命令的参数。很多命令(比如 rm、ls、cp)不能直接从 stdin 读数据,得靠 xargs 把输入转成参数:
bash
find /var/log -name "*.log" | xargs ls -lhxargs 从 stdin 读数据,按空白字符分割,然后拼到目标命令末尾执行。但默认的分割逻辑遇到文件名里有空格会出错——一个文件名被切成两半。正确做法是配合 -print0 和 -0,用 null 字符做分隔符:
bash
find /var/log -name "*.log" -print0 | xargs -0 ls -lh这是 find + xargs 的标准安全写法,文件名里有空格、换行、特殊字符都不会出错。
批量删除的时候,一定要先预览再执行,养成习惯:
bash
find /tmp -type f -mtime +7 -print # 先看哪些会被删除
find /tmp -type f -mtime +7 -print0 | xargs -0 rm -f # 确认无误后再删路径条件写错导致误删的事故太多了——比如本来想删 /tmp 下的,结果路径拼错删了别的目录。先 -print 看一遍要删哪些,确认无误再换成 rm -f,花不了几秒钟,但能避免灾难。
七、几个常用组合
统计访问日志里来源 IP 的 TOP N,这是分析日志的经典套路:
bash
awk '{print $1}' access.log | sort | uniq -c | sort -nr | head每条命令各司其职:awk 提取第一列(IP),sort 排序让相同 IP 聚在一起,uniq -c 统计每个 IP 出现的次数,sort -nr 按次数降序排,head 取前几行。这个套路改改参数就能用在不同场景——统计访问最多的 URL、错误最多的接口,都是这个结构。
查看某个端口被哪个进程监听:
bash
ss -lntp | grep ':80'过滤最近的错误日志,只看最后 50 行:
bash
grep -i "error" app.log | tail -n 50统计 500 状态码的请求数量:
bash
grep ' 500 ' access.log | wc -l管道串到三四段以上的时候,可读性和排错都会变差——过几个月回头看,自己都忘了这条管道在干嘛。这种情况下,最好写成脚本,给中间结果起个变量名,或者加注释说明每一步在做什么。一行能搞定的简短管道很优雅,但为了"一行流"硬凑七八段管道,其实是给自己挖坑。