使用Bash脚本进行日志轮换

26

我有以下问题:

我有一个应用程序,它不断在stderr和stdout上产生输出。这个应用程序的输出被保存在一个日志文件中(该应用程序被重定向为:&> log.txt)。我没有任何选项来将其适当地记录到文件中。

现在,我有一个cron作业,每小时运行一次,除了做其他事情外,还尝试通过将其复制到log.txt.1并创建一个空文件并将其复制到log.txt来旋转此日志文件。

看起来像这样:

cp log.txt log.txt.1
touch /tmp/empty
cp /tmp/empty log.txt

问题在于应用程序仍然在向其写入内容,因此我会在log.txt.1中得到一些非常奇怪的东西。它以许多垃圾字符开头,而实际的日志文件位于结尾某处。

你有任何想法如何为这种特定情况创建正确的日志轮换吗(我也尝试过cat log.txt > log.txt.1,但不起作用)?对于这个特定的应用程序,使用logrotate不是一个选项,因为后台有整个机制我可能无法更改。

谢谢, f.


2
@fritzone:你考虑过使用logrotate工具本身吗?它还允许你强制进行日志轮换。 - 0xC0000022L
head --lines=-10 log.txt > log.txt.1 这个命令可以工作吗(其中 10 是保留在末尾的一些“合理数量”的行数)?然后,为了清空日志,也许可以使用 truncate 命令,不过它是按字节大小而非行数来操作的... - drysdam
2
logrotate 是一个可以从 Shell 脚本中使用的命令行实用程序。如果你不能使用它,还有什么是不能使用的?你之前使用了 catcptouch,那么 mv 处于可使用状态吗? - drysdam
我已经尝试使用logrotate,但效果相同,文件中出现了很多垃圾字符...也许原因是当我重定向时,底层操作系统知道当前文件描述符的位置,并从那个点继续写入? - Ferenc Deak
1
开发一个用于日志轮换的Bash脚本,而不是使用广泛可用的“logrotate”命令,不仅是重复造轮子,而且做得非常糟糕。 - MestreLion
显示剩余2条评论
6个回答

13

好的,这里有一个灵感,受到http://en.wikibooks.org/wiki/Bourne_Shell_Scripting/Files_and_streams的启发

  1. 创建一个命名管道:

mkfifo /dev/mypipe
  • 将标准输出和错误输出重定向到命名管道:

  • &> /dev/mypipe
    
  • 从 mypipe 读取数据并写入文件:

  • cat < /dev/mypipe > /var/log/log.txt &
    
  • 当你需要进行日志轮转时,终止进程并旋转日志,然后重新启动该进程。

  • 注意:你可以给命名管道起任何名称,比如 /var/tmp/pipe1, /var/log/pipe, /tmp/abracadabra等等。只需确保在开机之前重新创建管道,在运行日志脚本之前。


    另外,也可以不使用cat,而是使用一个简单的脚本文件:

    #!/bin/bash
    
    while : ; do
      read line
      printf "%s\n" "$line"
    done
    

    这个脚本保证每次读取换行符后都会有输出。(使用cat命令可能需要等到缓冲区满或遇到EOF时才会开始输出)


    最终版本-已测试

    重要提示: 请阅读下方@andrew的评论。他指出了几种需要注意的情况。

    好了!我终于能够访问我的Linux服务器了。以下是步骤:

    第一步:创建此记录器脚本:

    #!/bin/bash
    
    LOGFILE="/path/to/log/file"
    SEMAPHORE="/path/to/log/file.semaphore"
    
    while : ; do
      read line
      while [[ -f $SEMAPHORE ]]; do
        sleep 1s
      done
      printf "%s\n" "$line" >> $LOGFILE
    done
    

    步骤2: 开始使用录音器:

    1. 创建一个命名管道:

      mkfifo $PIPENAME
      
    2. 将应用程序的标准输出(STDOUT)和标准错误(STDERR)重定向到命名管道(named pipe):

    3. ...things... &> $PIPENAME
      
    4. 开始录制:

    5. /path/to/recorder.sh < $PIPENAME &
      

      你可能希望使用nohup命令,使得上述命令在退出登录后仍然运行。

    6. 完成!

    步骤三:如果需要进行日志轮换,请暂停记录器:

    touch /path/to/log/file.semaphore
    mv /path/to/log/file /path/to/archive/of/log/file
    rm /path/to/log/file.semaphore
    

    建议将上述步骤放入自己的脚本中。可以随意更改第二行以使用你想要使用的日志轮转方法。


    注意:如果您熟悉C编程,您可能想制作一个短小的C程序来执行recorder.sh的功能。编译后的C程序肯定比nohup分离bash脚本更轻巧。


    注意2: David Newcomb在评论中提供了有用的警告:当记录器未运行时,对管道的写操作将会阻塞并且可能会导致程序无法预测地失败。确保记录器关闭(或轮转)的时间尽可能短。

    因此,如果您可以确保旋转发生得非常快,则可以将sleep(仅接受整数值的内置命令)替换为/bin/sleep(接受浮点值的程序),并将睡眠时间设置为0.5或更短。


    1
    有一件事需要注意,我刚遇到:确保只有一个 recorder.sh 在运行,否则你的日志文件将缺少一半字符或其他奇怪的问题。这听起来可能很明显,但当主进程结束时,我没有想到要杀掉它。 - andrew
    1
    我刚刚修复的另一件事情是:当主进程被杀死时,记录器脚本会从命名管道中读取一行数据,而不管是否有数据。这意味着如果你的写入管道的进程失败了,你将会得到大量空行写入日志。我通过执行 read line; if [ "$line" != "" ]; then [do logging]; fi 来解决这个问题。 - andrew
    1
    @andrew,再次感谢你的发现!我必须承认我没有用脚本尝试过很多情况,所以你提供的额外提示非常有帮助;我会编辑我的答案,以确保人们能够看到你的提示 :) - pepoluan
    1
    我知道已经有一段时间了,但值得一提的是,当录制器未运行时,对管道的写入将会阻塞,并可能导致程序出现不可预测的故障。确保录制器停机(或旋转)的时间尽可能短。 - David Newcomb
    复杂且不规范。就像一个方轮一样。 (该帖子也是一段对话历史,而不是最终结果,因此有很多对于新读者无用的冗余内容) - ivan_pozdeev
    显示剩余5条评论

    7
    首先,你真的不应该在这里重新发明轮子。你的同行可能反对按照每日计划旋转日志,该计划自动适用于/etc/logrotate.d/中的所有脚本 - 这可以通过将脚本放置在其他位置来避免。
    现在,日志轮换的标准方法(在logrotate中实现)也可以被任何其他设施很好地实现。例如,这是一个使用bash的示例实现:
    MAXLOG=<maximum index of a log copy>
    for i in `seq $((MAXLOG-1)) -1 1`; do
        mv "log."{$i,$((i+1))}    #will need to ignore file not found errors here
    done 
    mv log log.1    # since a file descriptor is linked to an inode rather than path,
                    #if you move (or even remove) an open file, the program will continue
                    #to write into it as if nothing happened
                    #see https://dev59.com/W2435IYBdhLWcg3wxDL_
    <make the daemon reopen the log file with the old path>
    

    最后一项是通过发送SIGHUP或(较少情况下)SIGUSR1,并在守护进程中设置一个信号处理程序来替换相应的文件描述符或变量来完成的。这样,切换是原子的,因此日志记录可用性不会中断。在bash中,可以这样实现:

    trap { exec &>"$LOGFILE"; } HUP
    

    另一种方法是让编写程序自己跟踪每次写入时的日志大小并进行旋转。这将限制您可以写入的位置和程序本身支持的旋转逻辑选项。但它的好处是一个自包含的解决方案,而且每次写入都会检查日志大小而不是按计划运行。许多语言的标准库都有这样的功能。作为一个插入式解决方案,Apache的rotatelogs已经实现了此功能:rotatelogs
    <your_program> 2>&1 | rotatelogs <opts> <logfile> <rotation_criteria>
    

    1

    我在周末写了一个logrotee。如果我之前读过@JdeBP关于multilog精彩回答,我可能就不会写了。

    我专注于它的轻量级和能够像这样压缩输出块:

    verbosecommand | logrotee  \
      --compress "bzip2 {}" --compress-suffix .bz2 \
      /var/log/verbosecommand.log
    

    虽然还有很多工作和测试要做。


    0
    你可以利用rotatelogs这里的文档)来实现。该工具将会以透明的方式管理日志文件的轮换,从而将脚本的标准输出与日志文件分离开来。例如:
    your_script.sh | rotatelogs /var/log/your.log 100M
    

    当输出文件达到100M时(可以根据时间间隔配置旋转),将自动旋转。


    0

    您还可以通过Apache的rotatelogs工具管道传输输出。或者使用以下脚本:

    #!/bin/ksh
    #rotatelogs.sh -n numberOfFiles pathToLog fileSize[B|K|M|G]
    
    numberOfFiles=10
    
    while getopts "n:fltvecp:L:" opt; do
        case $opt in
      n) numberOfFiles="$OPTARG"
        if ! printf '%s\n' "$numberOfFiles" | grep '^[0-9][0-9]*$' >/dev/null; then
          printf 'Numeric numberOfFiles required %s. rotatelogs.sh -n numberOfFiles pathToLog fileSize[B|K|M|G]\n' "$numberOfFiles" 1>&2
          exit 1
        elif [ $numberOfFiles -lt 3 ]; then
          printf 'numberOfFiles < 3 %s. rotatelogs.sh -n numberOfFiles pathToLog fileSize[B|K|M|G]\n' "$numberOfFiles" 1>&2
        fi
      ;;
      *) printf '-%s ignored. rotatelogs.sh -n numberOfFiles pathToLog fileSize[B|K|M|G]\n' "$opt" 1>&2
      ;;
      esac
    done
    shift $(( $OPTIND - 1 ))
    
    pathToLog="$1"
    fileSize="$2"
    
    if ! printf '%s\n' "$fileSize" | grep '^[0-9][0-9]*[BKMG]$' >/dev/null; then
      printf 'Numeric fileSize followed by B|K|M|G required %s. rotatelogs.sh -n numberOfFiles pathToLog fileSize[B|K|M|G]\n' "$fileSize" 1>&2
      exit 1
    fi
    
    sizeQualifier=`printf "%s\n" "$fileSize" | sed "s%^[0-9][0-9]*\([BKMG]\)$%\1%"`
    
    multip=1
    case $sizeQualifier in
    B) multip=1 ;;
    K) multip=1024 ;;
    M) multip=1048576 ;;
    G) multip=1073741824 ;;
    esac
    
    fileSize=`printf "%s\n" "$fileSize" | sed "s%^\([0-9][0-9]*\)[BKMG]$%\1%"`
    fileSize=$(( $fileSize * $multip ))
    fileSize=$(( $fileSize / 1024 ))
    
    if [ $fileSize -le 10 ]; then
      printf 'fileSize %sKB < 10KB. rotatelogs.sh -n numberOfFiles pathToLog fileSize[B|K|M|G]\n' "$fileSize" 1>&2
      exit 1
    fi
    
    if ! touch "$pathToLog"; then
      printf 'Could not write to log file %s. rotatelogs.sh -n numberOfFiles pathToLog fileSize[B|K|M|G]\n' "$pathToLog" 1>&2
      exit 1
    fi
    
    lineCnt=0
    while read line
    do
      printf "%s\n" "$line" >>"$pathToLog"
      lineCnt=$(( $lineCnt + 1 ))
      if [ $lineCnt -gt 200 ]; then
        lineCnt=0
        curFileSize=`du -k "$pathToLog" | sed -e 's/^[  ][  ]*//' -e 's%[   ][  ]*$%%' -e 's/[  ][  ]*/[    ]/g' | cut -f1 -d" "`
        if [ $curFileSize -gt $fileSize ]; then
          DATE=`date +%Y%m%d_%H%M%S`
          cat "$pathToLog" | gzip -c >"${pathToLog}.${DATE}".gz && cat /dev/null >"$pathToLog"
          curNumberOfFiles=`ls "$pathToLog".[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]_[0-9][0-9][0-9][0-9][0-9][0-9].gz | wc -l | sed -e 's/^[   ][  ]*//' -e 's%[   ][  ]*$%%' -e 's/[  ][  ]*/[    ]/g'`
          while [ $curNumberOfFiles -ge $numberOfFiles ]; do
            fileToRemove=`ls "$pathToLog".[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]_[0-9][0-9][0-9][0-9][0-9][0-9].gz | head -1`
            if [ -f "$fileToRemove" ]; then
              rm -f "$fileToRemove"
              curNumberOfFiles=`ls "$pathToLog".[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]_[0-9][0-9][0-9][0-9][0-9][0-9].gz | wc -l | sed -e 's/^[   ][  ]*//' -e 's%[   ][  ]*$%%' -e 's/[  ][  ]*/[    ]/g'`
            else
              break
            fi
          done
        fi
      fi
    done
    

    0
    一个最简单的脚本可能是这样的rotatelog.sh:
    #! /bin/bash
    
    #DATE_FMT="%Y%m%d-%H%M" # for testing
    #DATE_FMT="%Y%m%d-%H"   # rotate each hour
    DATE_FMT="%Y%m%d"       # rotate each day
    
    if [ "$1" != "" ]
    then
            f=$1
    else
            f="rotatelog"
    fi
    
    p=$(date +${DATE_FMT})
    
    r=$f-$p.log
    exec 2>&1 > $r
    
    while read l
    do
            d=$(date +${DATE_FMT})
            if [ $p != $d ]
            then
                    x=$r
                    p=$d
                    r=$f-$p.log
                    exec 2>&1 > $r
                    gzip $x
            fi
            echo $l
    done
    

    你可以像这样使用:

    your_process | rotatelog.sh yout_log_path_pattern
    

    1
    你的回答可以通过提供更多支持性信息来改进。请编辑以添加进一步的细节,例如引用或文档,以便他人能够确认你的回答是否正确。你可以在帮助中心找到关于如何撰写好回答的更多信息。 - Community
    1
    你的回答可以通过提供更多支持信息来改进。请编辑以添加进一步的细节,例如引用或文档,以便他人可以确认你的答案是正确的。您可以在帮助中心中找到有关如何编写良好答案的更多信息。 - Community

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接