【前情提要】
组合拳练完了,小林能从日志里挖出各种统计信息。老周说:“你手动跑命令没问题了,但每天手动跑,太累。你得写个脚本,让机器每天自动出日报。”小林打开 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 到 7date -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 跑,日报终于生成了。
排错思路总结:
cron不执行脚本,先看/var/log/syslog里的CRON日志。- 如果
exit status 1,脚本执行失败,加set -x看每一步。 - 如果
Permission denied,检查脚本执行用户对文件是否有读权限。 cron的PATH和登录 shell 不同,脚本开头设置PATH并export。- 日志文件权限不足,把用户加入对应组,或用
sudo跑。 sudo跑脚本需要免密,用visudo配置。- 最稳的方式:把脚本放在
/etc/cron.d/,指定root用户执行。
“记住,”老周说,“cron 的坑,一半是 PATH,一半是权限。 你以后 cron 脚本不跑,先看 /var/log/syslog,再加 set -x,再检查权限。”
九、练习
- 练习一:写一个
daily_report.sh,包含访问总量、Top 5 IP、Top 5 URL、状态码分布、5xx URL 五部分。用generate_report()函数组织,输出到~/reports/report_$(date +%Y%m%d).txt。 - 练习二:给脚本加
if判断,如果日志文件不存在,输出错误并exit 1。加PATH设置并export。加set -x用于调试。 - 练习三:用
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 也能每天自动跑了。老周说:“你现在能‘算’日志了,但真正的故障排查,不是算出来的,是‘查’出来的。”第二天,博客突然访问不了,监控没告警,日志也没明显错误。小林第一次面对“全链路排错”。
下一集:《综合故障排查:一次“全链路”排错》