公司服务器上的日志文件多年积压, 已经占用了很大一部分不必要的内存空间. 所以本篇将完成一个功能性脚本, 其内容是扫描过期的日志文件, 并对文件进行对应的操作.

好久没有写过 bash 脚本了, 本篇也算是对这项技能的一个温习吧.

明确需求

这个脚本最核心的目标是 清理过期的日志文件 , 然后就是需要考虑如何进行清理(清理的逻辑).

文件是否需要被清理, 其依据文件的最后修改日期, 该内容可以通过 ls -l 或者 date -r 等命令实现.

在确定文件需要被清理之后, TL 给出的需求是, 6 个月 ~ 12 个月的清空, 大于 12 个月的删除. 这里比较敏感的是删除操作, 如果该文件被 python 的open() 之类的命令打开, 而没有被 f.close(), 文件的句柄无法释放, 强行使用 rm 命令无法达到空间释放的目的(需要进行重启才能释放句柄, 进而释放内存). 所以, 在进行删除操作之前, 最好看一下文件句柄的情况.

最后, 类似日期 \ 路径等参数, 最好是能够从外部输入, 这样最为灵活.

开工

bash 复习

bash 脚本的学习与回顾强推 Bash 脚本教程, 其中对 bash 以及其引申的一些知识都做得很好.

预料中, 这个脚本需要用到的指令如下:

  • find
  • readarray
  • rm
  • kill
  • echo

另外, 条件判断 \ 循环 \ 函数 \ 数组这几项也需要有所了解.

查找过期文件

一开始笔者准备通过 ls + awk 来实现找到过期文件的目的的, 但是这样逻辑上会比较复杂, 后来想到可以直接使用 find 嘛~

核心代码如下, -iname 等相关的参数可以使用 man find 了解.

# path_dir: 文件的路径
# remove_timeline: 最后修改日期大于(+)这个时间则会被删除

find $path_dir -iname "*.log*" -atime +$remove_timeline -type f

处理文件

直接对 find 得到的结果进行遍历处理即可, 但是在实际操作之中, 去发现了一些比较奇怪的文件.

比如 /logs/ example1.log, 这个文件不知为何以 空格 开头, 这在对 find 的结果做切分时会被切分为两个子串, 不仅达不到我们的目的, 甚至会成为安全隐患.

所以在这里, 将 find 的结果进行预切分, 形成数组, 然后对每个元素进行清洗. 这里参考了stackover flow的回答, 其代码如下.

# bash version >= 4.4
readarray -d '' files_half_year_ago < <(find $path_dir -iname "*.log*" -atime -$remove_timeline -atime +$clean_timeline -type f -print0);

# bash version < 4.4
# files_half_year_ago=()
# while IFS=  read -r -d $'\n'; do
#     files_half_year_ago+=("$REPLY")
# done < <(find $path_dir -iname "*.log*" -atime -$remove_timeline -atime +$clean_timeline -type f)

这里将 find 的结果存入了 files_half_year_ago 变量, 接下来使用 for 循环遍历该数组.

fine_handler(){
    # 文件处理函数
}

for filename in "${files_half_year_ago[@]}"; do
    file_handler "clean" $filename
done

需要注意的是, "${files_half_year_ago[@]}" 的双引号不能丢掉, 不然空格在遍历时仍然会作为拆分符号被使用.

file_handler 函数接收两个参数, 第一个参数是操作, 如 “clean“. 第二个参数, 更严谨地说应该是第二组参数, 是文件的路径, 如果其中没有空格, 则就是一个字符串, 而如果其中有空格, 则会被作为两个字符串参数处理. 所以, 在函数接收参数时, 需要用一个取巧的办法.

file_handler() {
    local option=$1
    shift
    local file_name="$@"

    ...
}

这样, 即使路径因为分隔符而被作为多个参数传入了, 仍然会被约束为一个完整的字符串使用. 需要注意的是, 在后续的使用中都需要对 filename 变量添加双引号.

文件操作

至此, 我们已经能够顺利地遍历 find 的结果了. 接下来就是对文件进行操作.

正如前面的分析, 在对文件进行操作之前, 我们需要知道文件的句柄有没有被其他进程调用. 这里我的判断方法就是使用 lsof 查看文件使用被其他进程使用. 核心代码如下:

local process_list=`lsof -t "$file_name"`
if [ "$process_list" ]; then
    kill -9 $process_list
fi

这里将 lsof 的结果交给了 process list 变量, 然后进行判断, 如果 lsof 为空, 或者只有 1 个进程在调用, 则可以直接使用 [ $process_list ], 但是当有多个进程调用时, 再使用这个命令就会出错了, 所以最保险的是如上代码所示, 使用双引号将 process_list 包起来.

到这里, 文件相关的进程被 kill 掉, 句柄自然也就释放了, 接下来进行文件操作也就无所顾忌.

  • 删除: rm $file_name
  • 清空: echo '' > $file_name

完整的脚本

一个完整的脚本, 输入参数 \ 使用说明等内容都是缺不得的, 最好还要在各个处理阶段加上一些输出内容. 所以, 最终代码如下.

删除 \ 清空 这些敏感操作已经在展示代码中去除了, 可以自行在注释的位置添加.

#!/bin/bash

remove_timeline=365
clean_timeline=180
path_dir=-1
force_clean=0

Help()
{
   echo "Log clean tool."
   echo
   echo "Syntax: scriptTemplate [-p|h|t|T|v]"
   echo "options:"
   echo "-p     Path of log files."
   echo "-f     Force clean | remove files. It will kill the releated process."
   echo "-h     Print this Help."
   echo "-v     Verbose mode."
   echo "-T     Num of days. File modified time over than this nums will be delete. default 365"
   echo "-t     Num of days. File modified time over than this nums will be clean. default 180"
   echo
}

file_handler() {
    local option=$1
    shift
    local file_name="$@"

    if ! [ "$file_name" ]; then
        echo "invalid filename: $file_name , skip it"
        return
    fi

    local process_list=`lsof -t "$file_name"`
    if [ "$process_list" ]; then
        if [ $force_clean -eq 1 ]; then
            kill -9 $process_list
        else
            printf "file $file_name is been used,\nprocess pid:\n--------\n$process_list\n--------\nskip it\n"
            continue
        fi
    fi

    if [ $option == "delete" ]; then
        echo "delete" $file_name
        # 删除操作
    elif [ $option == "clean" ]; then
        echo "clean" $file_name
        # 清空操作
    else
        echo "invalid option"
    fi
}

while getopts "hvfp:t:T:" option; do
   case $option in
        f)
            force_clean=1
            ;;
        h)
            Help
            exit 0
            ;;
        p)
            path_dir="$OPTARG"
            ;;
        v)
            echo "to be continue"
            exit 1
            ;;
        T)
            remove_timeline="$OPTARG"
            ;;
        t)
            clean_timeline="$OPTARG"
            ;;
        ?)
            echo "script usage: $(basename $0) [-v] [-h] [-f] [-p] [-t] [-T]" >&2
            exit 1
            ;;
   esac
done
shift "$(($OPTIND - 1))"

# echo "$remove_timeline , $clean_timeline , $path_dir"

if [[ $remove_timeline -lt $clean_timeline ]]; then
    echo "Error: value of -T must more than value of -t."
    exit 1
fi

if [[ $path_dir == "-1" ]]; then
    echo "Error: -p arg is required."
    exit 1
fi

echo "$remove_timeline , $clean_timeline , $path_dir"

# v4.4+
readarray -d '' files_half_year_ago < <(find $path_dir -iname "*.log*" -atime -$remove_timeline -atime +$clean_timeline -type f -print0);
readarray -d '' files_year_ago < <(find $path_dir -iname "*.log*" -atime -$remove_timeline -type f -print0);
# declare -p files_year_ago;

# v4.4-
# files_year_ago=()
# while IFS=  read -r -d $'\n'; do
#     files_year_ago+=("$REPLY")
# done < <(find $path_dir -iname "*.log*" -atime +$remove_timeline -type f)

# files_half_year_ago=()
# while IFS=  read -r -d $'\n'; do
#     files_half_year_ago+=("$REPLY")
# done < <(find $path_dir -iname "*.log*" -atime -$remove_timeline -atime +$clean_timeline -type f)


for filename in "${files_half_year_ago[@]}"; do
    file_handler "clean" $filename
done

for filename in "${files_year_ago[@]}"; do
    file_handler "delete" $filename
done