【前情提要】 sed 练熟了,小林能脱敏 IP、提取时间段、删除空行和注释了。老周说:“grep 筛,awk 算,sed 改,三把刀你都会了。但真正分析日志,得把它们串起来。”小林打开终端,准备学组合拳。但他不知道,grep | awk | sort | uniq -c | sort -rn | head 这条管道,每一步都有讲究。

一、老周的任务:日报

周一早上,老周丢给小林一张纸。

上面写着:

日志分析日报
1. 访问总量
2. Top 10 IP
3. Top 10 URL
4. 状态码分布
5. 5xx 错误对应的 URL
6. 平均响应大小
7. 排除内网 IP 后的访问量

“这是什么?”小林问。

“日报。”老周说,“你以后每天要出一份。老板要看,我也要看。你手动做,还是写脚本?”

“写脚本。”

“对。”老周说,“但你写脚本之前,得先知道每条怎么算。你今天把七条命令写出来,明天再整合成脚本。”

小林看着纸上的七条,打开终端。

二、第一步:访问总量

“最简单的。”老周说,“总行数。”

sudo wc -l /var/log/nginx/access.log

输出:

102347 /var/log/nginx/access.log

“10 万条。”小林说。

“但 wc -l 算的是行数。如果日志里有空行,或者格式不对,行数不准。”老周说,“更精确的是 awk 'END {print NR}'。”

sudo awk 'END {print NR}' /var/log/nginx/access.log

输出:

102347

“一样。”小林说。

“对,因为这个日志没有空行。”老周说,“但你要养成习惯,统计用 awk,不用 wc。awk 更灵活,还能加条件。”

小林记下:

# 访问总量
awk 'END {print NR}' /var/log/nginx/access.log

# 只看 200 的数量
awk '$9 == 200' /var/log/nginx/access.log | wc -l

三、第二步:Top 10 IP

“这个你练过。”老周说。

sudo awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

输出:

   5000 192.168.1.100
   1200 192.168.1.101
    800 192.168.1.102
    300 192.168.1.103
    100 192.168.1.104

“等等,这些全是内网 IP。”小林说,“我博客不是给外面看的吗?”

“你本地虚拟机,哪来的外网?”老周说,“你以后上生产,就会看到真实公网 IP。现在先练手。”

四、第三步:Top 10 URL

sudo awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

输出:

   3000 /posts/my-first-post/
   1500 /
    800 /tags/hugo/
    500 /about/
    300 /images/hugo-logo.png
    200 /favicon.ico
    100 /posts/hello-world/
     50 /archives/
     30 /categories/
     20 /404.html

“你的文章最高,首页第二。”老周说,“/404.html 有点意思,说明有人访问了不存在的页面。你以后可以看看 404.html 的请求,找出哪些链接坏了。”

“怎么看?”

“awk '$9 == 404 {print $7}'。”老周说,“你后面会练到。”

五、第四步:状态码分布

sudo awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

输出:

  95000 200
   5000 404
   1500 301
    500 500
    347 502

“200 占绝大多数,404 有 5000,500 有 500,502 有 347。”小林说。

“502 有 347 个?”老周皱眉,“你博客是静态的,怎么会有 502?”

“我不知道。”

“你查一下 error.log。”

sudo grep " 502 " /var/log/nginx/access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -10

输出:

    347 /api/health

“全是 /api/health。”小林说,“又是那个死循环脚本留下的。”

“对。”老周说,“状态码分布能告诉你‘哪里出错了’。 你以后每天看一次,异常状态码一多,就去看具体 URL。”

六、第五步:5xx 错误对应的 URL

“把 5xx 的 URL 列出来。”

sudo awk '$9 >= 500 && $9 < 600 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

输出:

    347 /api/health
    500 /api/login

“/api/login 有 500 个 500?”小林说,“这不对啊,我博客没有登录功能。”

“可能是别人在扫你的站。”老周说,“公网上,每天都有扫描器到处试 /api/login、/admin、/.env。你现在本地,可能是我之前测试留下的。”

“那怎么区分?”

“看 IP。”老周说,“你 awk 把 IP 也带上。”

sudo awk '$9 >= 500 && $9 < 600 {print $1, $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

输出:

    347 192.168.1.100 /api/health
    500 10.0.0.5 /api/login

“10.0.0.5 是谁?”

“我另一个虚拟机的 IP。”老周说,“我测过你的博客。你看,加上 IP,就能区分是正常请求还是扫描。”

小林记下:

# 5xx URL 分布
awk '$9 >= 500 && $9 < 600 {print $7}' access.log | sort | uniq -c | sort -rn | head

# 5xx URL + IP
awk '$9 >= 500 && $9 < 600 {print $1, $7}' access.log | sort | uniq -c | sort -rn | head

七、第六步:平均响应大小

“$10 是响应大小。”老周说。

sudo awk '{sum += $10; count++} END {print sum/count}' /var/log/nginx/access.log

输出:

1234.56

“平均 1234 字节。”

“但 $10 可能是 -。”老周说,“你加个条件。”

sudo awk '$10 != "-" {sum += $10; count++} END {print sum/count}' /var/log/nginx/access.log

输出:

2345.67

“排除 - 后,平均 2345 字节。”小林说。

“这两个值差挺多,说明有些请求没有响应体。”老周说,“你以后统计,先确认字段是否有效。”

八、第七步:排除内网 IP

“最后一条,排除内网 IP 后的访问量。”老周说。

“怎么排除?”

“grep -v。”

sudo grep -v -E "^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)" /var/log/nginx/access.log | wc -l

输出:

1500

“1500 条外网访问。”小林说。

“这个正则匹配所有内网 IP 段:192.168.、10.、172.16. 到 172.31.。”老周说,“grep -v -E 是排除多个模式的标准写法。”

“那如果我只想看外网 IP 的 Top 10 呢?”

sudo grep -v -E "^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)" /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -10

输出:

    800 203.0.113.5
    300 198.51.100.22
    200 203.0.113.99
    100 198.51.100.44
    100 203.0.113.1

“这才是外网访问。”老周说,“排除内网,是为了看真实用户。 你以后生产环境,内网请求可能是监控、健康检查,不是真实用户。”

小林记下:

# 排除内网 IP
grep -v -E "^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)" access.log | wc -l

# 外网 Top 10
grep -v -E "^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)" access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head

九、组合拳:一条管道搞定复杂分析

“七条你都写出来了。”老周说,“但真正的高手,是把它们串成一条。”

“怎么串?”

“比如,你想找出‘访问量最高的 5 个外网 IP,且这些 IP 的请求里有 500 错误’。”

小林想了想:“先筛外网 IP,再筛 500,再统计?”

“对。”

sudo grep -v -E "^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)" /var/log/nginx/access.log | awk '$9 == 500 {print $1}' | sort | uniq -c | sort -rn | head -5

输出:

    300 203.0.113.5
    100 198.51.100.22

“这条管道干了四件事:”老周说。

  1. grep -v -E:排除内网 IP
  2. awk '$9 == 500':筛出 500 错误
  3. awk '{print $1}':提取 IP
  4. sort | uniq -c | sort -rn | head:统计 Top 5

“每一步都缩小范围,最后得出精确结论。”老周说,“这就是组合拳。grep 筛,awk 算,sort | uniq -c 统计。”

“那 sed 呢?”

“sed 在管道里用得少,但脱敏时用。”老周说,“比如你把结果脱敏:”

sudo grep -v -E "^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)" /var/log/nginx/access.log | awk '$9 == 500 {print $1}' | sort | uniq -c | sort -rn | head -5 | sed 's/[0-9]*$/***/'

“把 IP 最后一段替换成 ***。”

十、排错挑战:管道中某一步结果为空

下午,小林想统计“访问量最高的 5 个 404 URL,且排除 /api/health”。

他写:

sudo awk '$9 == 404 {print $7}' /var/log/nginx/access.log | grep -v "/api/health" | sort | uniq -c | sort -rn | head -5

输出为空。

“啥?”小林又敲了一遍。还是空。

他检查每一步:

sudo awk '$9 == 404 {print $7}' /var/log/nginx/access.log | head -5

输出:

/api/health
/api/health
/api/health
/api/health
/api/health

“有 404 啊。”小林说。

sudo awk '$9 == 404 {print $7}' /var/log/nginx/access.log | grep -v "/api/health" | head -5

输出为空。

“grep -v 之后全没了。”小林说,“说明所有 404 都是 /api/health?”

他统计:

sudo awk '$9 == 404 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

输出:

   5000 /api/health

“对,所有 404 都是 /api/health。”小林说,“难怪排除后为空。”

他喊来老周。

老周看了一眼:“管道中某一步结果为空,不一定是命令写错,可能是数据本身就没有。 你先单独跑每一步,确认每步的输出。”

“那我怎么知道是数据问题还是命令问题?”

“逐步调试。”老周说,“你把管道拆开,每一步 | head 看输出。哪一步开始为空,问题就在哪一步。”

sudo awk '$9 == 404 {print $7}' /var/log/nginx/access.log | head -5
sudo awk '$9 == 404 {print $7}' /var/log/nginx/access.log | grep -v "/api/health" | head -5

“第一步有输出,第二步为空,说明数据全是 /api/health。”老周说,“不是命令问题,是数据问题。”

排错思路总结:

  1. 管道结果为空,先逐步拆开,每一步 | head 看输出。
  2. 找到第一步为空的命令,问题就在那。
  3. 如果命令本身没问题,检查数据:wc -l、head、tail。
  4. 用 sort | uniq -c 统计全部数据,确认是否有你想要的内容。
  5. 管道调试的核心:分段执行,逐步缩小。
  6. 别一上来就写完整管道,先写单步,确认无误再串。

“记住,”老周说,“管道是‘流水线’,每一步都可能出问题。 你写复杂管道,先写单步,再串起来。串完之后,如果结果不对,再拆开逐步检查。这是调试管道的基本功。”

十一、练习

  1. 练习一:用一条管道命令,统计 access.log 中访问量最高的前 5 个外网 IP。要求排除 192.168.、10.、172.16. 到 172.31. 的内网 IP。
  2. 练习二:用一条管道命令,找出所有状态码为 500 的请求中,访问量最高的前 5 个 URL。然后加上 IP 字段,找出是哪些 IP 在触发 500。
  3. 练习三:用管道命令,把 access.log 中的 IP 脱敏(最后一段替换为 ***),然后统计脱敏后每个 IP 的访问量。对比脱敏前后的 Top 5 IP,确认脱敏不影响统计结果。

参考答案

练习一:

sudo grep -v -E "^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)" /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -5

练习二:

# 500 请求 Top 5 URL
sudo awk '$9 == 500 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -5

# 500 请求的 IP + URL
sudo awk '$9 == 500 {print $1, $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10

练习三:

# 脱敏并统计
sudo sed 's/\([0-9]*\.[0-9]*\.[0-9]*\.\)[0-9]*/\1***/g' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -5

# 原 IP Top 5
sudo awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -5

十二、下集预告

组合拳练完了,小林能从日志里挖出各种统计信息。老周说:“你手动跑命令没问题了,但每天手动跑,太累。你得写个脚本,让机器每天自动出日报。”小林打开 nano,准备写日报脚本。但他不知道,date、if、for、cron,每一个都有坑。

下一集:《写一个“日志日报”脚本》