Appearance
06|管道与重定向
把命令的输出存进文件,写 命令 > out.log,结果屏幕上还冒出红色错误信息,文件里却没有。给同事开个服务账号,sudo echo "deploy" >> /etc/sudoers 提示权限不足——明明加了 sudo。
这些看起来诡异的现象,根因都指向同一套机制:标准输入输出、重定向、管道。本篇讲清楚这套机制,上面这些问题就都通了。
一、三个标准通道
每个进程启动时,内核会默认打开三个文件描述符(file descriptor),分别对应输入、输出、错误输出:
| 文件描述符 | 名称 | 默认指向 | 用途 |
|---|---|---|---|
0 | stdin(标准输入) | 键盘 | 命令读取数据的来源 |
1 | stdout(标准输出) | 终端屏幕 | 命令正常输出的去向 |
2 | stderr(标准错误) | 终端屏幕 | 命令错误信息的去向 |
stdout 与 stderr 默认都打到屏幕上,视觉上混在一起,但底层是两条独立的通道,可以分别重定向。这是理解后续所有重定向机制的关键前提。
观察 stdout 与 stderr 分离的最简方式:
bash
$ ls /tmp /not-exist
ls: cannot access '/not-exist': No such file or directory
/tmp:
file1.txt file2.txt
# 把两条通道分别写到不同文件
$ ls /tmp /not-exist > out.log 2> err.log
$ cat out.log
/tmp:
file1.txt file2.txt
$ cat err.log
ls: cannot access '/not-exist': No such file or directory> 默认只重定向 stdout(等同于 1>),不会带走 stderr。这就解释了一个常见现象:将命令输出重定向到文件后,屏幕上仍然出现红色错误信息——那些是 stderr,没有被 > 接住。
二、重定向
1. 覆盖与追加
bash
echo "hello" > file.txt # 覆盖写入,原内容被清空
echo "world" >> file.txt # 追加写入,保留原内容> 在执行前会先把目标文件清空(截断为 0 字节),即便后续写入失败,原文件内容也会丢失。修改重要文件前应当先备份。
更安全的做法是用 set -o noclobber(简写 set -C)防止意外覆盖:
bash
set -C # 开启 noclobber 保护
echo "test" > existing.txt
# bash: existing.txt: cannot overwrite existing file
echo "test" >| existing.txt # 用 >| 显式覆盖,绕过保护
echo "test" >> existing.txt # 追加不受影响生产脚本中可以临时开启 noclobber 防止 > 误覆盖关键文件。
2. 错误输出重定向
bash
ls /not-exist 2> err.log3. 合并 stdout 与 stderr
将正常输出与错误输出合并到同一个文件,这是脚本日志场景中最常用的写法:
bash
command > all.log 2>&1
command &> all.log # 上面的等价简写(bash 4+)
command >> all.log 2>&1 # 追加模式合并4. 2>&1 的顺序陷阱
2>&1 有一个容易踩的顺序问题。command > all.log 2>&1 的执行顺序是:
> all.log— 把 stdout(fd 1)指向all.log2>&1— 把 stderr(fd 2)指向"当前的 stdout",此时 stdout 已经是all.log,所以 stderr 也指向all.log
如果反过来写成 2>&1 > all.log:
2>&1— 把 stderr 指向"当前的 stdout",此时 stdout 仍然是终端> all.log— 把 stdout 改指向all.log,但 stderr 已经在第一步绑定到了终端,不会跟随
结果:stderr 仍然打印在屏幕上,只有 stdout 进了文件。
记忆方式:重定向从左到右依次处理,2>&1 永远写在目标重定向之后。
实测对比:
bash
$ ls /tmp /not-exist > all.log 2>&1
$ cat all.log
ls: cannot access '/not-exist': No such file or directory
/tmp:
file1.txt
# 两条通道都进了文件 ✓
$ ls /tmp /not-exist 2>&1 > all.log
ls: cannot access '/not-exist': No such file or directory # stderr 仍然打在屏幕上
$ cat all.log
/tmp:
file1.txt
# 只有 stdout 进了文件 ✗5. 定时任务的标准写法
cron 任务的输出重定向几乎是固定模板:
bash
*/5 * * * * /opt/check.sh >> /var/log/check.log 2>&1使用 >> 追加而非 > 覆盖,避免每次执行清空之前的日志。频繁执行的脚本(每分钟一次)还需要配合 logrotate 控制日志文件大小,否则数月后日志文件可能达到 GB 级。
6. 文件描述符的复制与移动
除 stdout、stderr 之外,可以自定义打开更多文件描述符。这在脚本中"想让某段输出同时写日志和发送到屏幕"时很实用:
bash
# 把当前的 stdout 备份到 fd 3,后续可以恢复
exec 3>&1
# 重定向 stdout 到日志文件
exec > /var/log/myapp.log 2>&1
# 后续所有命令的输出都进日志
echo "执行步骤 1"
echo "执行步骤 2"
# 想临时在屏幕上显示一条消息,通过 fd 3
echo "这条到屏幕" >&3
# 恢复 stdout
exec >&3 3>&-这种用法在长时间运行的脚本里实现"日志全记录但关键节点显示进度"非常有用。
三、丢弃输出:/dev/null
/dev/null 是一个特殊设备文件,所有写入的数据都被直接丢弃,因此被称为"黑洞设备":
bash
command > /dev/null # 丢弃 stdout
command 2> /dev/null # 丢弃 stderr
command > /dev/null 2>&1 # 丢弃所有输出临时调试或抑制无关输出时这种写法很常用,但生产脚本中不应当将 stdout 和 stderr 全部丢到 /dev/null——脚本失败时没有任何排查线索,定位问题异常困难。
更合理的做法是至少保留 stderr,或将关键输出写入日志:
bash
# 抑制正常输出,保留错误信息
some_check > /dev/null
# 抑制错误中的部分预期失败,保留真正的异常
some_check 2>>/var/log/check-error.log
# 最完整的写法:记录全部输出,带时间戳
{
echo "[$(date '+%F %T')] 开始执行"
/opt/check.sh
} >> /var/log/check.log 2>&1cron 任务静默失败的真实故障
某团队的备份脚本通过 cron 每日凌晨 2 点执行:
cron
0 2 * * * /opt/backup.sh > /dev/null 2>&1某天发现 7 天的备份全部缺失,但没人察觉——原因是脚本 30 天前因为环境变量变更已经执行失败,但所有输出被丢到 /dev/null,cron 也不会因为脚本退出码非零而通知任何人。直到业务真正需要恢复时,才发现没有可用的备份。
正确写法:
cron
# 保留输出到日志文件,失败时通过监控告警
0 2 * * * /opt/backup.sh >> /var/log/backup.log 2>&1
# 或者明确捕获退出码,失败时发告警
0 2 * * * /opt/backup.sh >> /var/log/backup.log 2>&1 || /usr/local/bin/notify-failure.sh "backup"更彻底的方式是让脚本自身在结尾向监控系统上报心跳(健康度检查、Push Gateway),监控侧只要看到"超过 N 小时没有心跳"就告警。这种方式不依赖 cron 的退出码,即便整个 cron 服务出问题也能被发现。
四、管道
管道 | 把前一个命令的 stdout 接到后一个命令的 stdin,实现命令串联:
bash
ps aux | grep nginx1. 管道只传送 stdout
管道默认只传送 stdout,不传送 stderr。如果前一个命令报错,错误信息会直接显示在终端上,不会进入管道传给下一个命令:
bash
# stderr 不会进管道
$ ls /not-exist | wc -l
ls: cannot access '/not-exist': No such file or directory
0
# 需要先把 stderr 合并到 stdout,再进管道
$ ls /not-exist 2>&1 | wc -l
1排查日志时常用的 command 2>&1 | grep xxx,目的就是让 grep 也能匹配到 stderr 中的错误信息。
2. cat 滥用
许多命令可以直接读取文件,无需 cat 中转:
bash
grep " 500 " access.log # 推荐:grep 直接读文件
cat access.log | grep " 500 " # 多起了一个 cat 进程小文件无所谓性能差异,但脚本中批量处理时,每次省去一个 cat 进程,累计能减少可观的进程开销。
3. 管道退出码与 pipefail
默认情况下,管道整体的退出码等于最后一个命令的退出码,前面命令的失败被忽略:
bash
grep "ERROR" app.log | head如果 grep 没有匹配到任何 ERROR(返回 1),但 head 成功(返回 0),整条管道返回 0——看似执行成功,实际未匹配到任何内容。在巡检脚本、CI 流水线中,这种"假成功"会掩盖真实错误。
启用 pipefail 后,管道中任何一个命令失败,整条管道的退出码即为失败:
bash
#!/bin/bash
set -euo pipefail
grep "ERROR" app.log | head
echo "退出码:$?"
# 如果 grep 未匹配,这里不会执行(set -e 已经退出)set -e、set -u、set -o pipefail 是脚本健壮性的标准三件套:
| 选项 | 作用 |
|---|---|
set -e | 任何命令失败立即退出脚本 |
set -u | 引用未定义变量时报错退出 |
set -o pipefail | 管道中任意命令失败,整条管道视为失败 |
实际脚本开头推荐写成:
bash
#!/bin/bash
set -euo pipefail
IFS=$'\n\t' # 把 IFS 限制为换行和制表符,避免空格被当作分隔符4. 管道的缓冲行为(tail -f | grep 不实时的根因)
管道在内核中有缓冲,这是一个高频但少有人讲的陷阱。下面这条命令看起来很正常:
bash
tail -F /var/log/app.log | grep "ERROR"但很多场景下,匹配到 ERROR 的行不会立即显示——它被 grep 的输出缓冲区暂存着,直到积累到一定量(通常 4KB 或 8KB)才一起刷出。运维侧的表现是"日志已经写了,但 grep 半天没动静",误以为没匹配到。
这是因为 grep 在检测到 stdout 不是终端时,会切换到块缓冲模式。要让它在管道中也按行刷新,需要加 --line-buffered:
bash
tail -F /var/log/app.log | grep --line-buffered "ERROR"各常用工具对应的"按行刷新"参数:
| 工具 | 参数 |
|---|---|
| grep | --line-buffered |
| sed | -u(--unbuffered) |
| awk | 在脚本里加 fflush() 或用 mawk -W interactive |
| tee | 默认就是行缓冲,不需要参数 |
通用解法是用 stdbuf 强制改变任何命令的缓冲行为:
bash
# -oL 表示 stdout 改为行缓冲
stdbuf -oL command | grep "..."
# 例:实时观察 strace 的输出
stdbuf -oL strace -p 1234 2>&1 | grep "open"unbuffer(来自 expect 包)也能达到类似效果,把输出强制视为终端模式:
bash
unbuffer command | grep "..."五、tee:边写文件边输出
tee 命令的名称来源于水管中的 T 型三通——数据流入后,一份写入文件,另一份继续输出到 stdout(屏幕或下一个管道):
bash
echo "hello" | tee file.txt # 写入文件并打印
echo "world" | tee -a file.txt # -a 追加而非覆盖
ls -l | tee files.txt | wc -l # 写入文件的同时再传给下一个命令tee 配合 sudo 写入受保护文件
tee 最实用的场景之一是用 sudo 权限写入受保护文件。直觉上很多人会这样写,但会报错:
bash
$ sudo echo "net.ipv4.ip_forward = 1" > /etc/sysctl.d/99-ip-forward.conf
bash: /etc/sysctl.d/99-ip-forward.conf: Permission denied错误原因:重定向 > 是当前 Shell 处理的,不是 sudo 处理的。Shell 以当前用户的普通权限去打开目标文件准备写入,自然没有权限。sudo 只提升了 echo 命令的执行权限,但 echo 的输出去向是由 Shell 重定向决定的,与 sudo 无关。
正确做法是用 sudo tee,让 tee 进程以 sudo 权限打开目标文件:
bash
$ echo "net.ipv4.ip_forward = 1" | sudo tee /etc/sysctl.d/99-ip-forward.conf
net.ipv4.ip_forward = 1
$ cat /etc/sysctl.d/99-ip-forward.conf
net.ipv4.ip_forward = 1追加的写法:
bash
echo "新行内容" | sudo tee -a /etc/protected.conf如果不希望 tee 把内容同时打到屏幕上,把 stdout 丢掉即可:
bash
echo "内容" | sudo tee /etc/protected.conf > /dev/null写入多行内容的 here-doc 模式
经常需要一次性写入一段多行配置,here-doc + tee 是干净的写法:
bash
sudo tee /etc/sysctl.d/99-network-tuning.conf > /dev/null <<'EOF'
# 网络调优参数
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
EOF'EOF'(单引号)与 EOF(无引号)的区别:
bash
USER=deploy
# 无引号:`$USER` 被 shell 展开
cat <<EOF
Hello $USER
EOF
# 输出:Hello deploy
# 单引号:`$USER` 保持字面值
cat <<'EOF'
Hello $USER
EOF
# 输出:Hello $USER写 shell 脚本本身或配置文件时,通常需要 'EOF' 防止意外展开。写动态内容时用无引号的 EOF 才能让变量生效。
六、xargs:把 stdin 转成命令参数
xargs 把标准输入转换为目标命令的参数。许多命令(rm、ls、cp 等)不接受从 stdin 直接读取数据作为操作目标,必须通过 xargs 把输入转成命令行参数:
bash
find /var/log -name "*.log" | xargs ls -lh文件名包含空格的陷阱
xargs 默认按空白字符(空格、换行)分割输入,文件名中包含空格时会出错——一个文件名会被切成多个参数:
bash
$ ls
"my file.log"
$ find . -name "*.log" | xargs ls -lh
ls: cannot access './my': No such file or directory
ls: cannot access 'file.log': No such file or directory正确做法是使用 null 字符作为分隔符——find 用 -print0,xargs 用 -0:
bash
find /var/log -name "*.log" -print0 | xargs -0 ls -lh这是 find + xargs 处理文件名时的标准安全写法,即使文件名包含空格、换行、引号等特殊字符也不会出错。
批量删除前先预览
涉及 rm 的批量操作,必须先预览要操作的对象,再执行删除:
bash
# 第 1 步:预览哪些文件会被删除
find /tmp -type f -mtime +7 -print
# 第 2 步:核对无误后,实际执行
find /tmp -type f -mtime +7 -print0 | xargs -0 rm -f跳过预览直接 find ... -delete 或 xargs rm,一旦 find 的路径条件写错(例如本想删 /tmp/old/*,实际写成 /tmp /old/*,中间多了一个空格),会导致严重的误删。多花几秒钟预览是必要的安全成本。
find 的 -delete 参数
如果只需要删除符合条件的文件,find 自带 -delete 参数,无需经过 xargs:
bash
find /tmp -type f -mtime +7 -delete-delete 由 find 进程直接调用 unlink 系统调用,效率比 xargs rm 更高。但同样必须先用 -print 验证条件正确性。
xargs 的并行处理:-P
xargs -P 可以并行执行多个命令,适合需要批量处理大量对象的场景。例如对一批文件做 MD5 校验:
bash
# 单线程,按顺序处理(慢)
find /data -type f | xargs -I{} md5sum {}
# 并行 8 个进程同时跑(快很多)
find /data -type f -print0 | xargs -0 -P 8 -I{} md5sum {}参数含义:-P 8 同时跑 8 个子进程,-I{} 把 stdin 的每一项替换到命令中的 {} 位置。
并行处理的注意点:
- 输出可能乱序——多个进程同时写 stdout,行与行之间可能交错
- 不适合操作同一资源——同时
mv文件到同一目录可能有冲突 - 数据库/API 调用要控制并发——避免压垮下游
七、进程替换:<(...) 与 >(...)
进程替换是 bash 提供的高级特性,允许把命令的输出"伪装"成一个文件路径。常见于需要把多个命令输出作为某个工具的输入文件的场景。
<(...) 把命令输出当文件读
最常用的场景是 diff 比较两个命令的输出:
bash
# 直接对比两个目录的 ls 输出
diff <(ls /etc/nginx) <(ls /etc/nginx.bak)
# 对比生产和测试环境的进程列表
diff <(ssh prod-web-01 'ps -eo pid,cmd --sort=pid') \
<(ssh test-web-01 'ps -eo pid,cmd --sort=pid')
# 对比两个 SQL 查询的结果
diff <(mysql -e "SELECT * FROM users" db1) \
<(mysql -e "SELECT * FROM users" db2)没有进程替换时,这种对比需要先把每个命令输出存到文件再 diff,操作繁琐。
>(...) 把文件写入当命令输入
把数据流同时发往多个处理器(比单纯 tee 更灵活):
bash
# 备份的同时计算 MD5,不需要读两遍
tar czf - /data | tee >(md5sum > /backup/data.tar.gz.md5) > /backup/data.tar.gz
# 日志分流:错误进 error.log,警告进 warning.log,全量也保留
tail -F /var/log/app.log \
| tee >(grep --line-buffered ERROR > error.log) \
>(grep --line-buffered WARN > warning.log) \
> all.log进程替换是 bash 独有的特性,sh、dash、纯 POSIX shell 不支持。在 #!/bin/sh 的脚本里用不了——这是 cron 任务里偶尔会踩的坑。
八、运维场景中的常用组合
1. 统计访问日志中来源 IP 的 TOP N
这是日志分析的经典模板:
bash
awk '{print $1}' access.log | sort | uniq -c | sort -nr | head各命令分工:
| 命令 | 作用 |
|---|---|
awk '{print $1}' | 提取每行的第一列(通常是来源 IP) |
sort | 排序,让相同的 IP 聚集在一起 |
uniq -c | 去重并在每行前面输出出现次数 |
sort -nr | 按次数降序排列(-n 按数字,-r 反序) |
head | 取前 10 行(可改 head -n 20 取 20) |
这个模板的结构通用——把 $1 改成 $7 可以统计访问次数最多的 URL,加上 grep " 500 " 可以统计错误最多的接口:
bash
# 访问最多的 URL TOP 20
awk '{print $7}' access.log | sort | uniq -c | sort -nr | head -n 20
# 500 错误最多的接口
grep " 500 " access.log | awk '{print $7}' | sort | uniq -c | sort -nr | head
# UA 统计(看是不是有异常爬虫)
awk -F'"' '{print $6}' access.log | sort | uniq -c | sort -nr | head
# 按小时统计请求量(看流量曲线)
awk '{print $4}' access.log | cut -c2-15 | sort | uniq -c
# 找出 RT 最长的 100 个请求(假设第 11 列是响应时间)
awk '$11 > 0 {print $11, $7}' access.log | sort -nr | head -n 100
# 单 IP 异常访问检测(超过 1000 次)
awk '{print $1}' access.log | sort | uniq -c | awk '$1 > 1000 {print}' | sort -nr2. 进程与端口排查组合
bash
# 查看端口被哪个进程监听
ss -lntp | grep ':80'
# 找到占用某端口的进程并杀掉
ss -lntp | grep ':8080' | grep -oP 'pid=\K\d+' | xargs kill
# 查看某进程的所有打开连接
ss -tnap | awk -v pid=1234 '$0 ~ "pid="pid {print}'
# 统计 ESTABLISHED 连接最多的来源 IP
ss -tn state established | awk 'NR>1 {print $5}' | cut -d: -f1 | sort | uniq -c | sort -nr | head3. 大文件的高效处理
大日志(几 GB)的过滤场景,grep 直接读比 cat | grep 高效得多,且用 LC_ALL=C 关闭 locale 处理可以再加速 2-5 倍:
bash
# 关闭 locale 处理,纯 ASCII 处理速度更快
LC_ALL=C grep "ERROR" huge.log > errors.log
# 大文件不要 cat | wc -l 数行数,wc 直接读
wc -l huge.log
# 大日志只看末尾的错误,用 tail 限制读取范围
tail -n 100000 huge.log | grep "ERROR"4. 实时监控特定关键字
bash
# 实时观察错误日志,line-buffered 保证及时显示
tail -F /var/log/nginx/error.log | grep --line-buffered -i "error"
# 同时监控多个文件
tail -F /var/log/nginx/error.log /var/log/php/error.log | grep --line-buffered -E "(ERROR|FATAL)"
# 实时统计 5xx 错误的速率(每 10 秒一次)
tail -F /var/log/nginx/access.log \
| awk '{print $9}' \
| grep --line-buffered -E "^5[0-9]{2}" \
| awk '{count++} END {print count" errors in this batch"}' &5. 脚本健壮性:trap + cleanup
长时间运行的脚本必须处理"中途异常退出留下临时文件"的问题。trap 让脚本在退出前(无论正常还是异常)执行清理:
bash
#!/bin/bash
set -euo pipefail
# 创建临时目录,记录路径
TMPDIR=$(mktemp -d /tmp/myjob.XXXXXX)
# trap:在脚本退出时(EXIT 信号)清理临时目录
cleanup() {
local exit_code=$?
echo "[cleanup] 删除临时目录 $TMPDIR" >&2
rm -rf "$TMPDIR"
exit $exit_code
}
trap cleanup EXIT
# 业务逻辑——即使中间报错或被 Ctrl+C,trap 也会执行
download_data > "$TMPDIR/data.json"
process_data "$TMPDIR/data.json"
upload_result "$TMPDIR/result.csv"trap 支持多种信号:
| 信号 | 含义 |
|---|---|
EXIT | 脚本以任何方式退出(包括正常结束、error、被 kill) |
ERR | 命令返回非零退出码时(配合 set -e 使用) |
INT | 收到 Ctrl+C |
TERM | 收到 SIGTERM |
更精细的处理:
bash
trap 'echo "脚本因错误退出,行 $LINENO"' ERR
trap 'echo "用户中断"; cleanup; exit 130' INT
trap 'cleanup' EXIT生产脚本如果创建了临时文件、临时锁、临时表,trap 是必须的——否则脚本异常退出后,残留资源会逐渐积累。
八、管道的可读性边界
管道串联超过三四段后,可读性和排错难度都会显著上升。一行复杂管道在数月后回看,很难快速理解每一步在做什么。
这种情况下更合理的方式是写成脚本,为中间结果命名,或为每一步添加注释:
bash
#!/bin/bash
# 找出最近 24 小时内访问次数最多的 IP,排除内网
set -euo pipefail
# 1. 过滤最近 24 小时的日志文件
recent_logs=$(find /var/log/nginx/ -name "access.log*" -mtime -1)
# 2. 提取 IP,排除内网
external_ips=$(awk '{print $1}' $recent_logs | grep -v "^10\." | grep -v "^192\.168\.")
# 3. 统计 TOP 20
echo "$external_ips" | sort | uniq -c | sort -nr | head -n 20一行能搞定的简短管道很简洁,但为了"一行流"硬凑七八段管道,反而是给后续维护挖坑。
九、易踩的陷阱速查
前面各节已经讲过对应原理与修复,这里集中列出便于日后回查:
| 现象 | 根因 | 修复 |
|---|---|---|
| 管道整体返回 0,但中间某步实际失败 | 默认管道退出码只看最后一步 | set -o pipefail |
tail -F | grep 长时间无输出,但日志确实在写 | grep 在管道中切换为块缓冲 | grep --line-buffered,或 stdbuf -oL |
sudo cmd > /etc/xxx 报权限不足 | > 由当前 shell 处理,不受 sudo 影响 | cmd | sudo tee /etc/xxx |
| 排查时 grep 没匹配到错误信息,但 stderr 里确实有 | 默认管道只传 stdout | 排查类管道统一前置 2>&1 |
command > /dev/null 2>&1 静默失败后无任何线索 | 全部输出被丢弃 | 至少保留 stderr 到日志,或改为 >> /var/log/xxx.log 2>&1 |
diff <(cmd1) <(cmd2) 在 cron 中报 Syntax error: redirection unexpected | cron 默认用 /bin/sh,部分发行版的 sh 不支持进程替换 | cron 顶部 SHELL=/bin/bash 或脚本 shebang 写 #!/bin/bash |
重定向 2>&1 写在 > file 前面,stderr 没进文件 | 重定向从左到右处理,顺序错则 stderr 仍绑在终端 | > 写在前,2>&1 写在后 |
> 意外覆盖了重要文件 | > 默认会截断目标文件 | 脚本中开启 set -C(noclobber),或改用 >> 追加 |