天天看点

一文搞定shell中各种常见的日志的处理方法

作者:SuperOps
一文搞定shell中各种常见的日志的处理方法

‬背景

生产环境经常需要将脚本的所有输出发送到一个日志文件中。

更进一步我希望能够在脚本内部完成这个操作,并且同时在终端上看到输出内容!

‬思路

通常情况下,如果你想运行一个脚本并将其输出重定向到一个日志文件中,你可以简单地使用重定向操作:myscript > log 2>&1。

或者如果你想在屏幕上看到输出并同时重定向到文件中,你可以使用命令:myscript 2>&1 | tee log(或者更好的方式是,如果您的系统支持的话,可以使用script(1)命令来运行您的脚本)。

如果你想在脚本中插入一些命令,让它在内部实现这种日志记录,而不改变你的调用方式,那就会更复杂一些。

要在脚本中设置重复日志记录(同样的输出同时写入两个位置),需要进行一些文件描述符的操作,并使用命名管道或者Bash的进程替换(Process Substitution)。

普通日志记录

如上所述,如果你只想运行脚本并将输出记录下来,你可以使用以下命令:

myscript > log 2>&1           

如果你想在脚本内部完成这个操作,在脚本的顶部加入以下内容:

#!/usr/bin/env bash
# or whichever shebang you prefer

exec >log 2>&1           

现在,脚本的其余部分中的所有命令都将继承log作为它们的标准输出和错误输出。有关更多详细信息,请参阅shell脚本最佳实践专栏文件描述符和重定向。

简单的重复日志记录

我们从最简单的情况开始:我想将stdout同时重定向到一个日志文件和屏幕上。

这意味着我们希望stdout的所有内容都有两个副本 —— 一个用于屏幕(或者当脚本启动时stdout指向的位置),一个用于日志文件。可以使用tee程序实现这一目标:

# Bash
exec > >(tee mylog)             

进程替换的语法会创建一个命名管道(或类似的东西),并在后台运行tee程序来读取该管道的内容。tee会将其读取的内容复制为两份 —— 一份写入mylog文件中,另一份通过继承自脚本的stdout输出。最后,exec命令将shell的stdout重定向到该管道。

由于存在一个后台任务需要读取和处理所有的输出,因此这会引入一些异步延迟。要考虑这样一个情况:

# Bash
exec > >(tee mylog)

echo "A" >&2
cat file
echo "B" >&2           

写入到 stderr 的行 A 和 B 不经过 tee 进程 - 它们直接发送到 stderr。然而,我们从 cat 得到的文件会通过管道和 tee 发送,然后才会看到它。如果我们在终端中运行这个脚本,没有任何重定向,可能(但不一定!)会看到类似下面的东西:

~$ ./foo
A
B
~$ hi superops           

实际上没有办法避免输出顺序的改变。我们可以用类似的方式放慢 stderr,希望能够幸运地得到相同的结果,但无法保证每个流的行都会被同等延迟。

此外,注意文件的内容是在下一个 shell 提示符之后打印的。有些人觉得这很困扰。再次强调,由于 tee 是在后台进程中完成的,而且不在我们的控制之下,所以没有干净的方法来避免这一点。即使在脚本中添加 wait 命令也没有效果(前提是 bash 4.4 之前的版本)。有些人在脚本的末尾加上 sleep 1,以便给后台的 tee 一个完成的机会。这通常可以解决问题,但有些人觉得这比原来的问题更让人不舒服。

如果我们避免使用 Bash 语法,并设置自己的命名管道和后台进程,那么我们就可以得到控制:

# Bash
mkdir -p ~/tmp || exit 1
trap 'rm -f ~/tmp/pipe$; exit' EXIT
mkfifo ~/tmp/pipe$
tee mylog < ~/tmp/pipe$ & pid=$!
exec > ~/tmp/pipe$

echo A >&2
cat bar
echo B >&2

exec >&-
wait $pid           

尽管标准输出和标准错误仍然不同步,但至少在脚本退出后不会再向终端写入了:

~$ ./foo
A
B
hi superops
~$           

在 bash 4.4 版本中,ProcessSubstitution 设置了 $! 参数,因此如果需要的话,我们可以明确等待它。然而,这仍然相当笨拙:

# Bash 4.4
exec > >(tee myfile)
pspid=$!
: ... stuff ...

# All done.  Close stdout so the proc sub will terminate, and wait for it.
exec >&-
wait $pspid           

现在,考虑第二个变体的问题:我想将标准输出和标准错误一起记录,并保持行的同步。

这个相对容易,只要我们不关心破坏终端上标准输出和标准错误之间的分离即可。我们只需复制其中一个文件描述符即可:

# Bash
exec > >(tee mylog) 2>&1

echo A >&2
cat file
echo B >&2           

事实上,这甚至比第一个变体更容易。所有内容都正确同步,无论是在终端还是日志文件中:

~$ ./foo
A
hi mom
B
~$ cat mylog
A
hi superops
B
~$            

然而,仍然有可能部分输出出现在下一个 shell 提示符之后:

~$ ./foo
A
hi superops
~$ B           

(这可以通过前面展示的同样的命名管道和后台进程解决。)

第三个变体的问题也相对简单:我想将标准输出记录到一个文件,将标准错误记录到另一个文件。

这很简单,因为我们没有额外的限制,必须在终端上保持两个流的同步。我们只需设置两个日志写入程序即可:

exec > >(tee mylog.stdout) 2> >(tee mylog.stderr >&2)

echo A >&2
cat bar
echo B >&2           

现在,我们的流被分别记录。由于日志是分开的,不需要担心行的写入顺序。然而,在终端上,我们将得到混合的结果:

~$ ./foo
A
hi mom
B
~$ ./foo
hi mom
A
B
~$           

复杂的重复日志记录

有些人既不接受标准输出和标准错误之间失去分离,也不接受行的不同步。他们是纯粹主义者,所以要求最困难的形式 - 我想将标准输出和标准错误一起记录到单个文件中,但我也希望它们保留各自原始的分离目标。

为了实现这个目标,首先需要做几点说明:

如果要存在两个独立的标准输出和标准错误流,那么必须有某个进程分别写入它们。

在Shell脚本中,没有办法编写一个进程,当其中一个文件描述符有输入可用时,它就从两个独立的文件描述符读取数据,因为Shell没有poll(2)或select(2)接口。

因此,我们需要两个独立的写入进程。

唯一保证两个独立写入进程不会相互干扰的方法是确保它们都以追加模式打开其输出。以追加模式打开的文件描述符具有这样的特性:每次写入数据时,都会先跳到文件末尾。

因此:

# Bash
> mylog
exec > >(tee -a mylog) 2> >(tee -a mylog >&2)

echo A >&2
cat file
echo B >&2           

这样可以确保日志文件是正确的。但不能保证写入进程在下一个Shell提示符出现前完成:

~$ ./foo
A
hi superops
B
~$ cat mylog
A
hi superops
B
~$ ./foo
A
hi superops
~$ B           

我们可以使用之前提到的相同的命名管道加等待的技巧来解决这个问题(留给读者作为练习)。

剩下一个问题是,出现在终端上的行是否保证以正确的顺序出现。目前为止:我不知道。

还不够复杂。我还想添加时间戳!请参阅另一篇文章《如何为流中的每一行添加时间戳?》。

‬最后

如果你想学习如何编写更加健壮和可靠的 Shell 脚本,减少生产环境中的错误和故障,那么关注我吧!我会分享 Shell 编程的最佳实践和建议,帮助你提高 Shell 脚本的鲁棒性和可维护性。如果你想深入了解 Shell 编程的实际应用和技巧,可以关注我的《Shell 脚本编程最佳实践》专栏,里面有我在一线互联网大厂的实际生产经验和最佳实践,帮助你高效完成各种自动化任务。