上一篇里出现过 tail -F app.log | grep timeout 这样把两个命令接起来的写法,服务器上的启动脚本里也到处是 > log 2>&1 这种看起来很像乱码的东西。这篇要讲清楚的是同一件事:一条命令打出来的输出,默认去哪,>、2>、2>&1、| 又把它改去了哪。

搞明白这一层,日志排查里那些“明明重定向了还是有东西冒出来”“错误怎么不见了”“为什么只有一半进了文件”的怪事,就都变成理所当然的了。

一条命令有三个固定出口,编号 0、1、2

先看一个现象:

1ls /etc /nonexistent

屏幕上会出现两类东西:/etc 下的文件列表,和一行 ls: cannot access '/nonexistent': No such file or directory。看起来都在屏幕上,其实是两条不同的流。

程序本身并不知道“屏幕”是什么。每个进程一启动,内核就给它准备好三个带编号的通道,叫做 文件描述符(file descriptor,常缩写为 fd):

  • 0:标准输入(stdin),程序从这里读东西;
  • 1:标准输出(stdout),正常结果写这里;
  • 2:标准错误(stderr),报错信息写这里。

程序只管往编号 1 或 2 里写,完全不关心编号背后连的是终端、文件还是管道。默认情况下 shell 把这三个编号都接到你的终端,所以两股输出混在一起显示,你分不出哪句来自 1、哪句来自 2。

这里有个关键认识:重定向不是命令做的,是 shell 做的。shell 在启动命令这个进程之前,先把 0、1、2 接到你指定的地方,然后再执行。所以 ls 从头到尾都不知道自己写去的是文件。1

> 和 >>:把 1 接到文件

1ls -l > list.txt

> 后面跟文件名,就是“让 1 指向这个文件”。文件不存在就创建;已经存在,就 先截断成 0 字节(truncate),再往里写。想追加而不是覆盖,用 >>。

这里有个容易吃亏的地方:截断发生在命令运行 之前。所以 ls /nonexistent > out.txt 里,ls 什么都没输出、还报了错,但 out.txt 已经被清空了。你不能靠“命令失败了文件就没动”来自我保护——真要保留旧内容,就得用 >>。

> 省掉编号时默认作用于 1;把符号换成 <,就默认作用于 0,这是输入侧的重定向,比如 wc -l < app.log 让 wc 从文件读,而不是从键盘读。完整写法是 [n]>文件 或 [n]<文件。1

2>:只把错误接走

只写 > 的时候,它只管编号 1。错误(编号 2)照样打在屏幕上——这正是“我明明重定向了,怎么还有东西冒出来”最常见的答案。

要让两股分开,就分别指定编号:

1ls /etc /nonexistent > ok.log 2> err.log

> ok.log 把 1 接到 ok.log,2> err.log 把 2 接到 err.log。文件列表进 ok.log,报错进 err.log。

既不想看错误又不想存,就丢进 /dev/null——一个专门“吃”掉写入内容的空设备:2>/dev/null。

2>&1:让 2 变成 1 的一份拷贝

想把两股合到一起,新手容易写 > log 2> log。这很危险:两个描述符各自独立打开同一个文件,写入位置互不知情,会互相覆盖。

正确写法是 2>&1。这里的 & 在说“1 是一个描述符编号,不是名叫 1 的文件”。整句话的意思是:让 2 变成 1 此刻所指目标的拷贝。

“此刻”是关键。Bash 从左到右处理一条命令上的重定向 1,所以下面两条顺序不同的命令,结果完全不同:

1cmd > log 2>&1      # 1 先接到 log,然后 2 拷贝 1 —— 两个都写进 log
2cmd 2>&1 > log      # 2 先拷贝 1(此时 1 还是终端),然后 1 才接到 log —— 只有 stdout 进 log

第一种才是你要的:日志文件里既有正常输出也有报错。第二种里,2 已经拷成了“终端”,后面 1 再改去 log 跟它无关,所以错误照旧打在屏幕上,log 里只有正常输出。3

可以借变量来记:2>&1 相当于赋值 2 = 1,拷贝的是当时的值。之后再给 1 赋新值,2 不会跟着变。

因为这种写法用得太频繁,Bash 给了简写:&> log 等价于 > log 2>&1,&>> log 等价于 >> log 2>&1。1

| 只接 stdout,不接 stderr

管道的规则一句话:把左边命令的 1,接到右边命令的 0,中间不经过任何文件。2

就这么一条,却解释了一整类困惑。./app | grep timeout 里,app 写进 stderr 的错误根本没进管道——它还是直接打在终端上,grep 永远看不到。所以“我接了管道,怎么错误还是刷屏”是正常现象,不是管道没生效。4

要让错误也进管道,在管道前面加 2>&1:

1./app 2>&1 | grep -i error

这样能生效,是因为 bash 建立管道这一步,发生在处理命令自带的那些重定向之前 2。也就是说,| 已经把 1 接到管道上了,接着的 2>&1 让 2 拷贝这个“已经指向管道的 1”,于是两股都进了管道。|& 就是 2>&1 | 的简写。

反过来,只想筛错误、把正常输出丢掉,就写成 ./app 2>&1 >/dev/null | grep -i error:先让 2 跟着 1 进管道,再把 1 改去 /dev/null,两者互不影响。这仍然是同一条“拷贝当时的值”的规则在起作用。

可以直接抄的两个写法

把一次部署的完整输出记下来:

1./deploy.sh > deploy.log 2>&1

正常情况下屏幕上一片安静,全部内容都在 deploy.log 里;出问题也不会只截到一半。想同时看着它滚动,就用 ./deploy.sh 2>&1 | tee deploy.log(tee 一边写文件一边往屏幕复制一份)。

最后提醒一句:> 会无条件截断目标文件,所以不要对正在被服务写着的日志用 > app.log——那是清空日志,不是查看日志。