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

一、老周的任务:每天早上一封日报

早上到工位,老周丢给小林一张纸,上面写着:

从明天开始,每天早上 8 点,我要在邮箱里看到一份日志日报。
内容:
1. 访问总量
2. Top 5 IP
3. Top 5 URL
4. 状态码分布
5. 5xx 错误对应的 URL

“手动做?”小林问。

“手动做一次可以,天天做,你受得了?”老周说,“写脚本,用 cron 定时跑。”

“我没写过脚本……”

“你之前写过 check_nginx.sh,那就是脚本。”老周说,“今天这个复杂一点,但套路一样。变量、命令替换、条件判断、循环、输出。 五个东西,够了。”

小林打开 nano,准备写。

二、脚本骨架:#!/bin/bash 和变量

“先写骨架。”老周说。

nano ~/daily_report.sh

小林写:

#!/bin/bash

LOG_FILE="/var/log/nginx/access.log"
REPORT_DIR="/home/devops/reports"
REPORT_FILE="$REPORT_DIR/report_$(date +%Y%m%d).txt"

echo "日报生成中..."

“一行一行解释。”老周说。

  • #!/bin/bash:指定用 bash 执行。
  • LOG_FILE:日志路径。
  • REPORT_DIR:日报存放目录。
  • REPORT_FILE:日报文件名,用 date +%Y%m%d 生成日期后缀,比如 report_20260914.txt。
  • echo:打印提示。

“那 $(date +%Y%m%d) 是啥?”

“命令替换。”老周说,“$(命令) 会把命令的输出替换到这里。date +%Y%m%d 输出当前日期,格式是年月日。”

小林试:

date +%Y%m%d

输出:

20260914

“所以文件名是 report_20260914.txt。”小林说。

“对。你用日期做文件名,每天生成的文件不会覆盖。 这是运维脚本的常见做法。”

小林记下:

  • $(命令):命令替换,把命令输出嵌入字符串
  • date +%Y%m%d:年月日
  • date +%Y-%m-%d:带连字符的日期
  • date +%H:%M:%S:时分秒

三、if 判断:日志不存在怎么办?

“脚本写好了,但你得考虑一种情况。”老周说,“如果日志文件不存在,怎么办?”

“报错?”

“对,但你不能让它直接报错。你得判断。”

小林写:

if [ ! -f "$LOG_FILE" ]; then
    echo "错误:日志文件 $LOG_FILE 不存在"
    exit 1
fi

“-f 判断文件是否存在,! 取反。”老周说,“if [ ! -f "$LOG_FILE" ] 表示‘如果文件不存在’。exit 1 退出脚本,返回码 1,表示失败。”

“那 fi 是啥?”

“if 的反写,表示结束。”老周说,“bash 里,if 用 fi 结尾,case 用 esac 结尾。你记住就行。”

“还有别的判断吗?”

“常用的几个。”老周写:

  • -f 文件:文件存在且是普通文件
  • -d 目录:目录存在
  • -z 字符串:字符串为空
  • -n 字符串:字符串非空
  • $? -eq 0:上一条命令成功
  • $? -ne 0:上一条命令失败

小林记下。

四、生成日报内容

“现在写日报内容。”老周说,“你先写一个函数,专门生成报告。”

“函数?”

“对,function。”老周说,“你把每条统计写成一个函数,最后调用。这样脚本清晰,改起来方便。”

小林写:

generate_report() {
    echo "=== 日志日报 ==="
    echo "生成时间:$(date '+%Y-%m-%d %H:%M:%S')"
    echo ""

    echo "--- 1. 访问总量 ---"
    awk 'END {print NR}' "$LOG_FILE"
    echo ""

    echo "--- 2. Top 5 IP ---"
    awk '{print $1}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -5
    echo ""

    echo "--- 3. Top 5 URL ---"
    awk '{print $7}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -5
    echo ""

    echo "--- 4. 状态码分布 ---"
    awk '{print $9}' "$LOG_FILE" | sort | uniq -c | sort -rn
    echo ""

    echo "--- 5. 5xx 错误 URL ---"
    awk '$9 >= 500 && $9 < 600 {print $7}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10
    echo ""
}

“generate_report() 定义一个函数,{ ... } 里是函数体。”老周说,“你调用的时候,直接写 generate_report 就行。”

“那 awk 里的 $LOG_FILE 用双引号包起来,为啥?”

“因为变量可能包含空格。”老周说,“双引号保证变量作为一个整体传给 awk。你以后写脚本,变量永远加双引号。”

小林记下:

  • 函数名() { ... }:定义函数
  • 调用函数:直接写函数名
  • 变量加双引号:"$LOG_FILE"

五、输出到文件

“现在把日报输出到文件。”老周说。

mkdir -p "$REPORT_DIR"
generate_report > "$REPORT_FILE"
echo "日报已生成:$REPORT_FILE"

“mkdir -p 创建目录,-p 表示如果目录已存在不报错。”老周说,“> "$REPORT_FILE" 把函数输出重定向到文件。”

“那如果我想同时在屏幕上看到呢?”

“用 tee。”

generate_report | tee "$REPORT_FILE"

“tee 把输出同时写到屏幕和文件。”老周说,“你调试的时候用 tee,正式跑的时候用 >。”

小林记下:

  • >:输出到文件,覆盖
  • >>:输出到文件,追加
  • tee:同时输出到屏幕和文件

六、for 循环:批量处理多天日志

“日报写好了,但只处理当天的。”老周说,“如果我想处理前 7 天的日志呢?”

“用 for 循环。”

“对。”老周说,“for 遍历日期,每天生成一份。”

小林写:

for i in {1..7}; do
    DATE=$(date -d "$i days ago" +%Y%m%d)
    LOG_FILE="/var/log/nginx/access.log.$DATE"
    if [ -f "$LOG_FILE" ]; then
        echo "处理 $LOG_FILE"
    fi
done

“{1..7} 是 bash 的序列展开,表示 1 到 7。”老周说,“date -d "$i days ago" 算 i 天前的日期。”

“那 $DATE 和 $i 有啥区别?”

“$i 是 1 到 7 的数字,$DATE 是具体日期。”老周说,“你 date -d "1 days ago" +%Y%m%d 输出的是昨天的日期。”

小林试:

date -d "1 days ago" +%Y%m%d

输出:

20260913

“对。”老周说,“date -d 是 GNU 的写法,Mac 上不支持。 你在 Mac 上写脚本,date 得用 -v。但你的脚本在 Ubuntu 上跑,用 -d 就行。”

小林记下:

  • for i in {1..7}; do ... done:循环 1 到 7
  • date -d "$i days ago" +%Y%m%d:算 i 天前日期
  • date -d 是 GNU 写法,Mac 用 -v

七、cron:定时任务

“脚本写好了,现在让它每天 8 点自动跑。”老周说。

“用 cron?”

“对。”老周说,“crontab -e 编辑当前用户的定时任务。”

小林敲:

crontab -e

第一次会问用哪个编辑器,选 nano。

然后加一行:

0 8 * * * /home/devops/daily_report.sh >> /home/devops/reports/cron.log 2>&1

“一行一行解释。”老周说。

  • 0 8 * * *:分 时 日 月 周。0 8 表示每天 8 点 0 分。
  • /home/devops/daily_report.sh:脚本路径。必须写绝对路径。
  • >> /home/devops/reports/cron.log 2>&1:标准输出和错误输出都追加到 cron.log。

“为什么必须写绝对路径?”

“因为 cron 的 PATH 和你的登录 shell 不一样。”老周说,“你登录时,PATH 包含 /usr/local/bin、/usr/bin 等。cron 的 PATH 可能只有 /usr/bin:/bin。你脚本里的 awk、date、sort,cron 可能找不到。”

“那怎么办?”

“两个办法。”老周说,“第一,脚本里用绝对路径。第二,脚本开头设置 PATH。”

#!/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH

“export PATH 把 PATH 导出,子进程也能用。”老周说,“你以后写 cron 脚本,开头加这两行,省很多事。”

小林记下:

  • crontab -e:编辑定时任务
  • 格式:分 时 日 月 周 命令
  • 命令必须写绝对路径
  • 脚本开头设置 PATH 并 export
  • 输出重定向:>> logfile 2>&1

八、排错挑战:cron 执行脚本失败

第二天早上,小林打开邮箱,没有日报。

他检查脚本:

ls -l ~/daily_report.sh

输出:

-rw-r--r-- 1 devops devops 1234 Sep 14 10:00 /home/devops/daily_report.sh

“权限是 644。”小林说,“没有执行权限。”

“加执行权限。”

chmod +x ~/daily_report.sh

再次检查 cron 日志:

cat ~/reports/cron.log

输出:

/bin/bash: /home/devops/daily_report.sh: Permission denied

“果然是权限。”小林说。

加了执行权限后,手动跑:

~/daily_report.sh

输出:

日报生成中...
日报已生成:/home/devops/reports/report_20260914.txt

“能跑了。”小林说。

但第二天早上,还是没收到日报。

他检查 cron 日志:

cat ~/reports/cron.log

输出为空。

“日志都是空的。”小林说,“说明 cron 根本没执行。”

他检查 cron 服务:

systemctl status cron

输出:

Active: active (running)

“在跑啊。”

他检查 crontab:

crontab -l

输出:

0 8 * * * /home/devops/daily_report.sh >> /home/devops/reports/cron.log 2>&1

“任务在啊。”

他喊来老周。

老周看了一眼:“你脚本里 REPORT_DIR 是 /home/devops/reports,但这个目录存在吗?”

小林检查:

ls -ld /home/devops/reports

输出:

ls: cannot access '/home/devops/reports': No such file or directory

“不存在。”小林说。

“你脚本里写了 mkdir -p "$REPORT_DIR",但 cron 执行时,PATH 可能不包含 mkdir 的路径?不对,mkdir 是 /bin/mkdir,肯定在 PATH 里。”老周说,“你再看看脚本。”

小林打开脚本,看到:

REPORT_DIR="/home/devops/reports"
REPORT_FILE="$REPORT_DIR/report_$(date +%Y%m%d).txt"

mkdir -p "$REPORT_DIR"
generate_report > "$REPORT_FILE"

“没问题啊。”

老周仔细看:“你脚本开头有没有设置 PATH?”

“没有……”

“那就对了。”老周说,“cron 的 PATH 不包含 /usr/local/bin,但你的 awk、sort、uniq、head 都在 /usr/bin,cron 默认 PATH 应该包含。但你 date 命令呢?”

“date 在 /usr/bin/date,也在。”

“那问题在哪?”老周皱眉,“你手动跑脚本,能生成报告吗?”

“能。”

“那 cron 跑的时候,LOG_FILE 存在吗?”

“存在啊。”

“你 cron 日志是空的,说明 cron 根本没执行你的脚本。”老周说,“你 crontab -l 看到任务了,但 cron 服务可能没重新加载。”

“怎么重新加载?”

“cron 不需要重新加载。你保存 crontab 后,cron 会自动读取。”老周说,“但你有没有注意到,你 crontab -e 保存时,有没有报错?”

小林回忆:“好像提示 crontab: installing new crontab。”

“那说明保存成功了。”

老周想了想:“你 cron 日志是空的,说明脚本没执行,或者执行了但输出被丢了。你试试用 cron 跑一个最简单的命令。”

小林加了一行:

* * * * * echo "test" >> /home/devops/reports/cron_test.log 2>&1

等了一分钟,检查:

cat ~/reports/cron_test.log

输出为空。

“连最简单的 echo 都没执行。”小林说,“cron 有问题。”

老周检查:

systemctl status cron

输出:

Active: active (running)

“服务在跑。”老周说,“你 cron 日志在哪?”

“/var/log/syslog。”

sudo grep CRON /var/log/syslog | tail -20

输出:

Sep 14 08:00:01 ubuntu CRON[1234]: (devops) CMD (/home/devops/daily_report.sh >> /home/devops/reports/cron.log 2>&1)
Sep 14 08:00:01 ubuntu CRON[1234]: (CRON) error (grandchild #1235 failed with exit status 1)

“看到了吗?”老周说,“cron 执行了,但 exit status 1,脚本失败了。”

“那为什么 cron.log 是空的?”

“因为你脚本里 REPORT_DIR 不存在,mkdir -p 创建了,但 generate_report > "$REPORT_FILE" 重定向失败,因为 $REPORT_FILE 的目录还不存在?不对,mkdir -p 已经创建了。”老周说,“你脚本里 mkdir -p "$REPORT_DIR" 在 generate_report 之前,应该没问题。”

“那为什么失败?”

“你 cron 的 PATH 不包含 /usr/local/bin,但你的 awk 在 /usr/bin/awk,应该没问题。”老周说,“你试试在脚本开头加 set -x,看 cron 执行时每一步的输出。”

小林加:

#!/bin/bash
set -x

再次等 cron 跑,检查 cron.log:

cat ~/reports/cron.log

输出:

+ LOG_FILE=/var/log/nginx/access.log
+ REPORT_DIR=/home/devops/reports
++ date +%Y%m%d
+ REPORT_FILE=/home/devops/reports/report_20260914.txt
+ mkdir -p /home/devops/reports
+ generate_report
+ awk 'END {print NR}' /var/log/nginx/access.log
awk: cannot open /var/log/nginx/access.log (Permission denied)

“Permission denied。”小林说,“cron 执行时,用户是 devops,但 access.log 是 www-data:adm,权限 640。devops 不在 adm 组里,读不了。”

“对。”老周说,“你手动跑脚本时,用了 sudo 吗?”

“没有……我手动跑的时候,脚本里 awk 读日志,能读吗?”

“你手动跑的时候,是不是用了 sudo?”

小林回忆:“好像没有,但能读啊。”

“那是因为你手动跑的时候,用了 sudo 或者你的用户有权限。”老周说,“你检查一下。”

ls -l /var/log/nginx/access.log

输出:

-rw-r----- 1 www-data adm 8.3M Sep 14 10:00 /var/log/nginx/access.log

“devops 不在 adm 组。”老周说,“你手动跑能读,可能是因为你之前改了权限,或者你用了 sudo。”

“那我怎么让 cron 能读?”

“两个办法。”老周说,“第一,把 devops 加入 adm 组。”

sudo usermod -aG adm devops

“加入组后要重新登录才生效。但 cron 任务不会重新登录,所以你得重启 cron 或者重启系统。”老周说,“第二,用 sudo 跑脚本。”

“怎么用 sudo 跑?”

“改 crontab,加 sudo。”

0 8 * * * sudo /home/devops/daily_report.sh >> /home/devops/reports/cron.log 2>&1

“但 sudo 需要密码。”小林说。

“对,所以你得配 sudo 免密。”老周说,“或者,你把脚本放在 /etc/cron.d/ 里,指定用户。”

sudo nano /etc/cron.d/daily_report

内容:

0 8 * * * root /home/devops/daily_report.sh >> /home/devops/reports/cron.log 2>&1

“root 指定用 root 跑,不需要密码。”老周说,“但注意,/etc/cron.d/ 里的文件格式,比 crontab -e 多一个用户字段。”

“那我选哪个?”

“你入门阶段,用 sudo 免密最简单。”老周说,“你给 devops 配个免密 sudo,然后 crontab 里加 sudo。”

“怎么配免密?”

sudo visudo

加一行:

devops ALL=(ALL) NOPASSWD: /home/devops/daily_report.sh

“这样 devops 执行这个脚本时,不需要密码。”老周说,“但只给这个脚本免密,别给所有命令免密。”

小林配好,再次等 cron 跑,日报终于生成了。

排错思路总结:

  1. cron 不执行脚本,先看 /var/log/syslog 里的 CRON 日志。
  2. 如果 exit status 1,脚本执行失败,加 set -x 看每一步。
  3. 如果 Permission denied,检查脚本执行用户对文件是否有读权限。
  4. cron 的 PATH 和登录 shell 不同,脚本开头设置 PATH 并 export。
  5. 日志文件权限不足,把用户加入对应组,或用 sudo 跑。
  6. sudo 跑脚本需要免密,用 visudo 配置。
  7. 最稳的方式:把脚本放在 /etc/cron.d/,指定 root 用户执行。

“记住,”老周说,“cron 的坑,一半是 PATH,一半是权限。 你以后 cron 脚本不跑,先看 /var/log/syslog,再加 set -x,再检查权限。”

九、练习

  1. 练习一:写一个 daily_report.sh,包含访问总量、Top 5 IP、Top 5 URL、状态码分布、5xx URL 五部分。用 generate_report() 函数组织,输出到 ~/reports/report_$(date +%Y%m%d).txt。
  2. 练习二:给脚本加 if 判断,如果日志文件不存在,输出错误并 exit 1。加 PATH 设置并 export。加 set -x 用于调试。
  3. 练习三:用 crontab -e 配置每天 8 点执行脚本。如果 cron 不执行,检查 /var/log/syslog 里的 CRON 日志,定位问题并修复。

参考答案

练习一:

nano ~/daily_report.sh

内容:

#!/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
export PATH

LOG_FILE="/var/log/nginx/access.log"
REPORT_DIR="/home/devops/reports"
REPORT_FILE="$REPORT_DIR/report_$(date +%Y%m%d).txt"

if [ ! -f "$LOG_FILE" ]; then
    echo "错误:日志文件 $LOG_FILE 不存在"
    exit 1
fi

generate_report() {
    echo "=== 日志日报 ==="
    echo "生成时间:$(date '+%Y-%m-%d %H:%M:%S')"
    echo ""
    echo "--- 1. 访问总量 ---"
    awk 'END {print NR}' "$LOG_FILE"
    echo ""
    echo "--- 2. Top 5 IP ---"
    awk '{print $1}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -5
    echo ""
    echo "--- 3. Top 5 URL ---"
    awk '{print $7}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -5
    echo ""
    echo "--- 4. 状态码分布 ---"
    awk '{print $9}' "$LOG_FILE" | sort | uniq -c | sort -rn
    echo ""
    echo "--- 5. 5xx 错误 URL ---"
    awk '$9 >= 500 && $9 < 600 {print $7}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10
    echo ""
}

mkdir -p "$REPORT_DIR"
generate_report > "$REPORT_FILE"
echo "日报已生成:$REPORT_FILE"
chmod +x ~/daily_report.sh
~/daily_report.sh

练习二:

if 判断和 PATH 设置已包含在上面的脚本中。加 set -x 调试:

#!/bin/bash
set -x

练习三:

crontab -e

添加:

0 8 * * * /home/devops/daily_report.sh >> /home/devops/reports/cron.log 2>&1

如果失败:

sudo grep CRON /var/log/syslog | tail -20
cat ~/reports/cron.log

根据日志修复权限或 PATH 问题。

十、下集预告

日报脚本写好了,cron 也能每天自动跑了。老周说:“你现在能‘算’日志了,但真正的故障排查,不是算出来的,是‘查’出来的。”第二天,博客突然访问不了,监控没告警,日志也没明显错误。小林第一次面对“全链路排错”。

下一集:《综合故障排查:一次“全链路”排错》