使用 awk 编写 shell 脚本

  • awk 是一个强大的 Linux 命令,有强大的文本格式化的能力
  • 三剑客:
    • grep:擅长单纯的查找或匹配文本内容
    • awk:更适合编辑、处理匹配到的文本内容
    • sed:更适合格式化文本内容,对文本进行复杂处理

1. awk 基础

  • awk 语法:

    1
    2
    awk [option] 'pattern[action]' file ...
    awk 参数 '条件动作' 文件
  • Action 指的是动作,awk 擅长文本格式化,且输出格式化后的结果,因此最常用的动作就是 print

    • 条件动作举例:
    1
    2
    3
    4
    {print $0}  # 把每行都打印出来, 等同于 {print}
    {print $1} # 把每行的第一列打印出来(默认以空格为列的分隔符)
    {print $NF} # 每行的最后一列[倒数第二列可以写成$(NF-1)]
    {print $1,$4,$5} # 打印多列(中间加上逗号是为了空格分割)
  • awk 是按行处理文件,一行处理完毕,处理下一行,根据用户指定的分隔符去工作,没有指定则默认空格

  • awk 分隔符有两种:

    • 输入分隔符,awk 默认是空格,空白字符,英文是 file separator,变量名是 FS
    • 输出分隔符,output field separator,简称 OFS,默认也是空格

2. 文本格式化

2.1 awk 内置变量

内置变量 解释
$n 指定分隔符后,当前记录的第n个字段
$0 完整的输入记录
FS 字段分隔符,默认是空格
NF(Number of fields) 分割后,当前行一共有多少个字段(几列?)
NR(Number of records) 当前记录数,行数
FILENAME 当前文件名
可用 man 手册查看 man awk

2.2 自动定义输出内容

  • awk,必须外层单引号,内存双引号
  • 内置变量 $1、$2 都不得添加双引号,否则会识别为文本,尽量别加引号
    • 如:awk '{print "第一列: "$1, "第二列: "$3}' hello.txt
    • 如(将 ipconfig 里的 eth0 段的ip输出):ipconfig eth0 | awk 'NR==2{print $0}'

2.3 awk 参数

参数 解释
-F 指定分割字段符
-v 定义或修改一个 awk 内部的变量
-f 从脚本文件中读取 awk 命令
  • 举例(指定冒号作为分隔符):
    • awk -F ":" '{print $1}' hello.txt
    • 也可以用 -v 修改 FS 参数:awk -v FS=":" '{print $1,$NF}' hello.txt
    • 还可以修改输出分隔符:awk -v OFS="\t" '{print $1,$NF}' hello.txt

2.4 显示文件第5行

  • NR 在 awk 中表示行号,NR==5 表示行号是 5 的那一行
    • 举例:找到第五行和第六行的内容并打印:awk 'NR==5, NR==6{print $0}' hello.txt
    • 看行号2-5的内容:awk 'NR==2, NR==5' hello.txt
    • 打印行号37-40的内容同时显示行号:awk 'NR==37, NR==40{print NR,$0}' hello.txt

3. awk 模式 pattern

  • 特殊的 pattern:BEGINEND
    • BEGIN 模式是处理文本之前需要执行的操作
    • END 模式是处理完所有行之后执行的操作
  • 比如:
    • awk BEGIN{print "hhh"} hello.txt
    • awk 'BEGIN{print "处理文本之前"}{print $0}END{print "所有文本处理完毕"}' hello.txt

4. awk 与正则表达式

  • 主要与 pattern模式(条件)结合使用
    • 不指定模式,awk每一行都会执行对应的动作
    • 指定了模式,只有被模式匹配到的、符合条件的行才会执行动作
  • awk 使用正则语法:
    • awk '/正则表达式/动作' /etc/passwd
    • awk 命令使用正则表达式,必须把正则放入 // 双斜杠中,匹配到结果后执行动作 {print $0},打印整行信息
  • 比如:
    • 输出以 games 开头的行:awk '/^games/{print $0}' hello.txt
    • 输出符合上面条件的行的第一列和最后一列(分隔符为冒号):awk -F ":" '/^games/{print $0, $NF}' hello.txt

5. awk 案例

5.1 插入新字段和格式化空白

  • a b c db 后面插入 3 个字段 e f g
1
echo a b c d | awk '{$2=$2" e f g";print}'
  • 移除每行的前缀、后缀空白,并将各部分左对齐
1
awk 'BEGIN{OFS="\t"}{$1=$1;print}' a.txt

5.2 筛选 IPV4 地址

  • 从 ifconfig 命令的结果中筛选出除了 lo 网卡外的所有 IPv4 地址
1
2
3
4
5
6
# 法一:正则匹配开头为inet 且第二列不以 127 开头
ifconfig | awk '/inet / && !($2 ~ /^127/){print $2}'

# 按段落读取
# 法二:一次性读取一段,可以修改输入的行分隔符为""
ifconfig | awk 'BEGIN{RS=""}!/lo/{print $6}'

5.3 读取 .ini 配置文件中的某段

读取 mysql 段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 匹配包含 [mysql] 的,但是可读性较差
/\[mysql\]/{} # 使用反斜线转义一下中括号

# 对于能够确定的字符串,可用index去搜索
# 搜索到的话,返回索引位;搜索不到返回0
index($0, "[mysql]") {
print # 输出符合条件的那一行
while( (getline var) > 0 ) {
if (var ~ /\[.*\]/) { # 如果匹配到格式形如 [..] 的,就停止
exit
}
print var
}
}

# getline 返回值:
# >0 表示已经读取到数据
# =0 表示遇到结尾 EOF,也就表示没有读取到东西
# <0 表示读取报错

5.4 根据某字段去重

对出现的次数进行判断?

1
2
3
4
5
6
7
8
9
# 以问号为分隔符
awk -F "?" '{
arr[$2]++;if(arr[$2]==1){print}
}' 1.txt

# 可以更加简短
awk -F "?" '{
!arr[$2]++{print} # 后置++返回加之前的数, 所以如果加之前为0, 就打印
}' 1.txt

5.5 次数统计

需要用到数组

  • 统计单词出现次数
1
awk '{arr[$0]++}END{for(i in arr){print arr[i], i}' 1.txt
  • 统计 TCP 链接状态数量
1
netstat -tnap 2>/dev/null | awk '/^tcp/{arr[$6]++}END{for(i in arr){print arr[i], i}}' 
  • 统计日志中各IP访问非200状态码的次数,结合 sort 和 head 命令显示前十个最多的
1
awk '$8!=200{arr[$1]++}END{for(i in arr){print arr[i], i}' access.log | sort -k1nr | head -n 10 
  • 统计每个 URL 的独立访问 IP 有多少个 (去重),并且要为每个 URL 保存一个对应的文件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
BEGIN {
FS="|" # 指定字段分隔符
}
# SEPSUB \034 真正保存时以 $1\034$2 保存
{
# 如果是第一次出现(类似联合主键)
if (!arr[$1, $2]++) { # awk 中的数组是关联数组, 它的索引全是字符串
arr1[$1]++
}
}

END {
for(i in arr1) {
print i, arr1[i] > (i".txt")
}
}

6. 课后作业

6.1 需求

使用 awk 编程编写 shell 脚本,需要有函数式、管道式、流水线编程的思想,具体要求如下:

  • 现有 auth.log 日志文件,日志文件格式如下,要求统计登录出错的记录

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    Sep 20 06:25:01 iZbp1amwnz2vqj9ehdtgjvZ CRON[6905]: pam_unix(cron:session): session closed for user root
    Sep 20 06:35:01 iZbp1amwnz2vqj9ehdtgjvZ CRON[7029]: pam_unix(cron:session): session opened for user root by (uid=0)
    Sep 20 06:35:01 iZbp1amwnz2vqj9ehdtgjvZ CRON[7029]: pam_unix(cron:session): session closed for user root
    Sep 20 06:45:01 iZbp1amwnz2vqj9ehdtgjvZ CRON[7034]: pam_unix(cron:session): session opened for user root by (uid=0)
    ...
    ...
    Sep 26 19:07:15 iZbp1amwnz2vqj9ehdtgjvZ sshd[18760]: error: Could not load host key: /etc/ssh/ssh_host_ed25519_key
    Sep 26 19:07:28 iZbp1amwnz2vqj9ehdtgjvZ sshd[18760]: Invalid user administrator from 95.10.179.8 port 28508
    Sep 26 19:07:29 iZbp1amwnz2vqj9ehdtgjvZ sshd[18760]: pam_unix(sshd:auth): check pass; user unknown
    Sep 26 19:07:29 iZbp1amwnz2vqj9ehdtgjvZ sshd[18760]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=95.10.179.8
    Sep 26 19:07:31 iZbp1amwnz2vqj9ehdtgjvZ sshd[18760]: Failed password for invalid user administrator from 95.10.179.8 port 28508 ssh2
    Sep 26 19:07:32 iZbp1amwnz2vqj9ehdtgjvZ sshd[18760]: Connection closed by invalid user administrator 95.10.179.8 port 28508 [preauth]
    Sep 26 19:15:02 iZbp1amwnz2vqj9ehdtgjvZ CRON[18764]: pam_unix(cron:session): session opened for user root by (uid=0)
    Sep 26 19:15:02 iZbp1amwnz2vqj9ehdtgjvZ CRON[18764]: pam_unix(cron:session): session closed for user root
    ...
  • 对上述 log 文件进行分析,然后编写 shell 脚本,使用 awk 编程,先读取日志文件,然后将出错的记录以下面的格式写入/usr/local/log/error.md 文件中

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    ...
    ### 2021-9-26 19:07:15 [18760]
    #### sshd
    **error:** Could not load host key: /etc/ssh/ssh_host_ed25519_key
    **pam_unix(sshd:auth):** check pass; user user unknown
    **pam_unix(sshd:auth):** authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=95.10.179.8 port 28508

    Invalid user administrator from 95.10.1
    Failed password for invalid user administrator from 95.10.179.8 port 28508 ssh2
    Connection closed by invalid user administrator 95.10.179.8 port 28508 [preauth]





    ### 2021-9-26 19:15:02 [18764]
    #### CRON
    **pam_unix(cron:session):** session opened for user root by (uid=0)
    **pam_unix(cron:session):** session closed for user root

6.2 实现思路

  • 准备工作
    • 定义日志文件路径,确保日志目录存在
  • 处理日志文件
    • 使用 awk 处理 auth.log 文件,按进程 ID 分类记录错误信息
    • BEGIN 块中,初始化全局变量、获取当前年份、以及初始化一些变量
    • 在每一行的处理中,解析进程信息、过滤不需要的行、按照进程 ID 分类记录错误信息
    • END 块中,处理最后一个进程 ID 的错误信息
  • 输出错误信息
    • 错误信息会按照进程 ID 分类记录在 /usr/local/log/error.md 文件中

6.3 代码实现

  • count.sh

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    #!/bin/bash

    # LOG_FILE="/usr/local/log/error.md" # 日志文件名作为常量

    # # 检查日志目录是否存在,不存在则创建
    # mkdir -p "$(dirname "$LOG_FILE")"

    # # 检查日志文件是否存在,存在则清空,不存在则创建
    # if [ -e "$LOG_FILE" ]; then
    # true > "$LOG_FILE"
    # else
    # touch "$LOG_FILE"
    # fi

    LOG_FILE="/usr/local/log/error.md"

    # 确保日志目录存在,不存在则创建
    mkdir -p "$(dirname "$LOG_FILE")"

    # 清空日志文件,如果不存在则创建
    > "$LOG_FILE"


    # 使用 awk 处理 auth.log 文件
    awk -v log_file="$LOG_FILE" '
    BEGIN {
    # 全局变量的定义
    prev_process_id = ""
    has_colon = 0 # 初始化 has_colon

    # 获取系统当前年份
    cmd = "date +%Y"
    cmd | getline year
    close(cmd)

    # 初始化 error_data 为空字符串
    error_data = ""
    }

    {
    # 获取当前行的进程id号
    parseProcessInfo($5)
    process_name = process_info[1]
    process_id = process_info[2]

    # 过滤掉不是错误信息的行
    if (process_name ~ /(vsftpd|systemd-logind)/) {
    next
    }

    # 如果当前行的进程id号不等于上一行的进程id号或上一行的进程号为空
    if (process_id != prev_process_id || prev_process_id == "") {
    # 打印上一进程号的错误信息
    printErrorData(prev_process_id)
    # 清空数组
    delete error_array

    if (prev_process_id != "") {
    print "\n\n" >> log_file
    }

    # 记录当前的进程id号为 prev_process_id
    prev_process_id = process_id

    # 获取时间并打印, 同时打印进程id, 格式为 ### 2021-9-26 19:07:15 [process_id]
    formatted_datetime = getFormattedDateTime($1, $2, $3)
    print "###", formatted_datetime, "[" process_id "]" >> log_file
    print "####", process_name >> log_file
    }

    # 解析当前行的错误信息并输出到缓存
    error_data = parseErrorInfo($6)
    for (i = 7; i <= NF; i++) {
    error_data = error_data " " $i
    }
    # 存储到数组
    error_array[has_colon] = error_array[has_colon] error_data "\n"
    }

    END {
    # 打印最后一个进程号的错误信息
    printErrorData(prev_process_id)
    }

    # 获取格式化的日期和时间
    function getFormattedDateTime(month, day, time) {
    month_number = (index("JanFebMarAprMayJunJulAugSepOctNovDec", month) + 2) / 3
    return year"-"month_number"-"day" "time
    }

    # 获取并解析进程名和进程id
    function parseProcessInfo(process_data, arr) {
    split(process_data, arr, "[")
    if (length(arr) > 1) {
    gsub("]:", "", arr[2])
    process_info[1] = arr[1]
    process_info[2] = arr[2]
    } else {
    process_info[1] = process_data
    process_info[2] = ""
    }
    }

    # 解析错误信息
    function parseErrorInfo(error_column) {
    has_colon = (substr(error_column, length(error_column), 1) == ":")
    return has_colon ? "**" error_column "**" : error_column
    }

    # 打印错误信息
    function printErrorData(process_id) {
    if (process_id != "") {
    # 输出标识为1的错误信息
    print error_array[1] >> log_file

    # 检查是否有标识为0的错误信息
    if (error_array[0] != "") {
    # 输出空行
    print "" >> log_file
    # 输出标识为0的错误信息
    print error_array[0] >> log_file
    }
    }
    }

    ' auth.log
  • 运行命令如下:

1
2
3
4
5
# 给脚本赋予执行权限
chmod u+x count.sh

# 运行脚本
sudo ./count.sh