Skip to content

06|管道与重定向

把命令的输出存进文件,写 命令 > out.log,结果屏幕上还冒出红色错误信息,文件里却没有。给同事开个服务账号,sudo echo "deploy" >> /etc/sudoers 提示权限不足——明明加了 sudo。

这些看起来诡异的现象,根因都指向同一套机制:标准输入输出、重定向、管道。本篇讲清楚这套机制,上面这些问题就都通了。

一、三个标准通道

每个进程启动时,内核会默认打开三个文件描述符(file descriptor),分别对应输入、输出、错误输出:

文件描述符名称默认指向用途
0stdin(标准输入)键盘命令读取数据的来源
1stdout(标准输出)终端屏幕命令正常输出的去向
2stderr(标准错误)终端屏幕命令错误信息的去向

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.log

3. 合并 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 的执行顺序是:

  1. > all.log — 把 stdout(fd 1)指向 all.log
  2. 2>&1 — 把 stderr(fd 2)指向"当前的 stdout",此时 stdout 已经是 all.log,所以 stderr 也指向 all.log

如果反过来写成 2>&1 > all.log:

  1. 2>&1 — 把 stderr 指向"当前的 stdout",此时 stdout 仍然是终端
  2. > 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>&1

cron 任务静默失败的真实故障

某团队的备份脚本通过 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 nginx

1. 管道只传送 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 -eset -uset -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 把标准输入转换为目标命令的参数。许多命令(rmlscp 等)不接受从 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 ... -deletexargs rm,一旦 find 的路径条件写错(例如本想删 /tmp/old/*,实际写成 /tmp /old/*,中间多了一个空格),会导致严重的误删。多花几秒钟预览是必要的安全成本。

find 的 -delete 参数

如果只需要删除符合条件的文件,find 自带 -delete 参数,无需经过 xargs:

bash
find /tmp -type f -mtime +7 -delete

-deletefind 进程直接调用 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 的每一项替换到命令中的 {} 位置。

并行处理的注意点:

  1. 输出可能乱序——多个进程同时写 stdout,行与行之间可能交错
  2. 不适合操作同一资源——同时 mv 文件到同一目录可能有冲突
  3. 数据库/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 独有的特性,shdash、纯 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 -nr

2. 进程与端口排查组合

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 | head

3. 大文件的高效处理

大日志(几 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 unexpectedcron 默认用 /bin/sh,部分发行版的 sh 不支持进程替换cron 顶部 SHELL=/bin/bash 或脚本 shebang 写 #!/bin/bash
重定向 2>&1 写在 > file 前面,stderr 没进文件重定向从左到右处理,顺序错则 stderr 仍绑在终端> 写在前,2>&1 写在后
> 意外覆盖了重要文件> 默认会截断目标文件脚本中开启 set -C(noclobber),或改用 >> 追加