首页  /  Linux 进阶  /  正文

Shell 运维脚本实战之统计清理与备份

Linux 进阶 2026-10-02📖 20 分钟👁 —
🐚 Linux 进阶 · Shell 编程第 9 / 10 页12345678910📖 完整导航 →

一开始是照着这么一张练习清单挨个写的,从统计目录文件数开始,一天一个,最后一个再把它们攒成一个菜单:

Day1 到 Day7 的脚本练习清单和每一步的核心命令

一、统计目录文件数量并排序前 10

思路是用 find 把文件全捞出来,再用 awk 按 / 切分出目录,最后统计次数排序。不过在写脚本之前,得先看看 find 出来的东西长什么样。

在根目录执行,先取前 3 行看看每一列都是什么:

# 在根目录执行
# 输出所有子目录,按照 / 分隔,查看得到前 3 行的每列
# 并且查看最后一列文件名 ($NF)
find . -type f | head -3 | awk -F / '{print "$1=" $1 " $2=" $2 " $3=" $3 " $4=" $4 " $5=" $5}'


root@VM-0-12-ubuntu:/# find . -type f | head -3 | awk -F / '{print "$1="$1 " $2="$2 " $3="$3 " $4="$4 " $NF="$NF}'
$1=. $2=tmp $3=itcast_clean.sql $4= $NF=itcast_clean.sql
$1=. $2=tmp $3=test.sql $4= $NF=test.sql
$1=. $2=tmp $3=disable_rt_runtime_share.log $4= $NF=disable_rt_runtime_share.log

# 去掉文件名(最后一列)并指定输出分隔符为 /
find -type f|awk -F / -v OFS=/ '{$NF=""; print $0}'|head -3
# $0表示 当前行 的全部初始内容(1 2 3都是输出指定列)

# 数组统计每个目录出现的次数
find . -type f | awk -F / -v OFS=/ '{$NF=""; count[$0]++} END {for(d in count) print count[d], d}'

awk 后面单引号里的东西就相当于一段代码,每一块 {} 就是一段,对 awk 处理过的内容做操作,只是横着写了一行而已,想竖着格式化写也行,但日常都是横着写。

为什么统计目录出现的次数就等于文件数量?因为 find -type f 输出的每一条都是一个文件路径,多一个文件,它所在的目录就多出现一次。

脚本 file_cnt.sh:

root@VM-0-12-ubuntu:~/scripts# vim file_cnt.sh
#! /bin/bash
# 找出执行脚本目录,子目录文件最多的目录
# 用法:./count_files.sh [目录]  不指定目录则统计当前目录

if [ $# -eq 1 ] 
then
        FILE_DIR=$1
        echo "获取到的目录:$FILE_DIR"
    else
        echo "只能输入一个目录喵"
        exit 1
fi

cd $FILE_DIR || exit 1
# 这里上了一层保险
# 但是相对滴,执行脚本也要写绝对路径不然下面的find找不到脚本

find "$FILE_DIR" -type f|awk -F / -v OFS=/ '{$NF=""; count[$0]++} END {for(dir in count) print count[dir],dir}' | sort -nr|head -10

注释里写的“不指定目录则统计当前目录”其实没实现,参数不等于 1 就直接退出了,想支持的话自己给 FILE_DIR 加个默认值就行。

执行结果,路径里那两个斜杠是因为 OFS=/ 加上最后一列被清空,多出来一个:

root@VM-0-12-ubuntu:~/scripts# bash /root/scripts/file_cnt.sh /
获取到的目录:/
8823//usr/src/linux-headers-5.15.0-160-generic/include/config/
3060//var/lib/dpkg/info/
1985//usr/local/qcloud/YunJing/YDFlow/pocs/vul/
1437//usr/local/qcloud/python/lib/python3.7/test/__pycache__/
1329//usr/src/linux-headers-5.15.0-160/include/linux/
1121//usr/share/man/man1/
945//usr/bin/
893//usr/share/man/man3/
822//data/safeline/resources/postgres/data/base/16384/
795//usr/share/man/man8/


root@VM-0-12-ubuntu:/# source /root/scripts/file_cnt.sh /var
获取到的目录:/var
3060//var/lib/dpkg/info/
703//var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/594/fs/usr/lib/mysqlsh/lib/python3.13/site-packages/oci/core/models/
686//var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/594/fs/usr/lib/mysqlsh/lib/python3.13/site-packages/oci/database_management/models/
623//var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/602/fs/usr/lib64/python2.7/
601//var/lib/docker/rootfs/overlayfs/5f89ee9839b9474dfb17cbfe2e6016c67f59de2c2f40180922529055ef09a774/var/lib/dpkg/info/
601//var/lib/docker/rootfs/overlayfs/09d76763c36e61d7ab1f42d835d318a8d065e692e0438df6a844926c9563e65c/var/lib/dpkg/info/
598//var/lib/docker/rootfs/overlayfs/e1a7f0150fed235f6502d1eabee5009f0f424e3f842946261338be2c52ce0d7a/var/lib/dpkg/info/
594//var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/610/fs/usr/lib/mysqlsh/lib/python3.9/site-packages/oci/data_integration/models/
594//var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/594/fs/usr/lib/mysqlsh/lib/python3.13/site-packages/oci/data_integration/models/
554//var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/594/fs/usr/lib/mysqlsh/lib/python3.13/site-packages/oci/database/models/
root@VM-0-12-ubuntu:/var# 

二、统计 nginx 日志里出现最多的 10 个 IP

先看一眼日志格式,第一列就是 IP:

root@VM-0-12-ubuntu:/var/log/nginx# cat access.log|head
220.196.160.146 - - [09/Sep/2026:00:02:19 +0800] "GET / HTTP/1.1" 200 2298 "-" "Mozilla/5.0 (Linux; Android 11; RMX2117) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36"
43.152.72.247 - - [09/Sep/2026:00:16:02 +0800] "GET / HTTP/1.1" 200 0 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
220.196.160.61 - - [09/Sep/2026:00:17:45 +0800] "GET / HTTP/1.1" 200 2298 "-" "Mozilla/5.0 (Linux; Android 11; RMX2117) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36"
220.196.160.61 - - [09/Sep/2026:00:17:45 +0800] "GET /taffy.png HTTP/1.1" 200 29570 "http://senrenbanka.art/" "Mozilla/5.0 (Linux; Android 11; RMX2117) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36"
220.196.160.61 - - [09/Sep/2026:00:17:45 +0800] "GET /mudan.png HTTP/1.1" 200 75413 "http://senrenbanka.art/" "Mozilla/5.0 (Linux; Android 11; RMX2117) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36"
220.196.160.61 - - [09/Sep/2026:00:17:45 +0800] "GET /leimi.png HTTP/1.1" 200 261374 "http://senrenbanka.art/" "Mozilla/5.0 (Linux; Android 11; RMX2117) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36"
27.148.196.62 - - [09/Sep/2026:00:21:52 +0800] "GET / HTTP/1.1" 200 2298 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
43.159.36.180 - - [09/Sep/2026:00:29:35 +0800] "GET / HTTP/1.1" 200 10 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
104.23.223.64 - - [09/Sep/2026:00:31:05 +0800] "GET /wp-admin/install.php?step=1 HTTP/1.1" 404 0 "-" "http://senrenbanka.art/wp-admin/install.php?step=1"
180.101.245.250 - - [09/Sep/2026:00:32:40 +0800] "GET / HTTP/1.1" 200 2298 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"

第一版直接把日志路径写死在脚本里:

vim nginx_ip.sh

# 获取Nginx访问日志中出现次数最多的10个IP

NGINX_LOG="/var/log/nginx/access.log"

echo "当前统计$NGINX_LOG中的IP"

cat $NGINX_LOG | awk '{count[$1]++} END {for(ip in count) print count[ip],ip}'|sort -nr | head -10
                                                                            

改成从参数传进来:

# 获取Nginx访问日志中出现次数最多的10个IP

NGINX_LOG=$1

if [ $# -eq 1 ]; then

        echo "当前统计$NGINX_LOG中的IP"
        cat $NGINX_LOG | awk '{count[$1]++} END {for(ip in count) print count[ip],ip}'|sort -nr | head -10

    else
        echo "只能输入一个目录喵"
        exit 1 # 这个可以不写,如果执行错误了会退出ssh,因为给到了当前shell执行
fi


root@VM-0-12-ubuntu:/# source /root/scripts/nginx_ip.sh /var/log/nginx/access.log
当前统计/var/log/nginx/access.log中的IP
51 193.32.204.199
28 154.17.162.2
27 192.210.234.226
24 46.151.182.62
14 45.91.64.7
11 16.5.0.236
9 220.196.160.61
8 165.22.77.54
6 66.132.195.72
6 45.194.92.67

再优化一版,干脆用 read 交互着输入,顺便判断一下文件在不在:

# 获取Nginx访问日志中出现次数最多的10个IP

read -p "输入日志绝对路径:" NGINX_LOG
# 判断文件存在,存在为真
if [ -f "$NGINX_LOG" ]; then

        echo "当前统计$NGINX_LOG中的IP"
        cat $NGINX_LOG | awk '{count[$1]++} END {for(ip in count) print count[ip],ip}'|sort -nr | head -10

    else
        echo "该路径不存在"
fi

root@VM-0-12-ubuntu:~# source /root/scripts/nginx_ip.sh
输入日志绝对路径:/var/log/nginx/access.log
当前统计/var/log/nginx/access.log中的IP
51 193.32.204.199
32 154.17.162.2
27 192.210.234.226
24 46.151.182.62
14 45.91.64.7
11 16.5.0.236
9 220.196.160.61
8 165.22.77.54
6 66.132.195.72
6 45.194.92.67
root@VM-0-12-ubuntu:~# source /root/scripts/nginx_ip.sh
输入日志绝对路径:/add/d
该路径不存在

三、日志自动清理脚本

先备份重要日志,这个脚本负责自动清理 /var/log 下超过七天的日志压缩包(注意是压缩包,不是正在写的日志文件)。

想要的效果

不递归子目录,要清掉的就是图里标红的那批 log.数字.gz 文件:

/var/log 里的文件列表,标红的是要清理的 log.数字.gz

顺手把要求也记一下,日志修改时间超过 7 天就清掉,最后报一句释放了多少空间:

清理脚本的需求描述和期望输出

涉及规律数字,肯定得用正则。

构建过程

#! /bin/bash

# 清理输入路径下log.数字.gz的压缩包日志文件

read -p "输入要清理日志压缩包的目录" LOG_DIR

if [ -d "$LOG_DIR" ]; then
        echo "正在清理"
        sleep 2
    else
        echo "目录不存在"
fi

cd "$LOG_DIR" || echo "对该目录没有读权限"

rm *log.[0-9]*.gz
echo "清理完毕"

不建议用 rm,rm 不支持复杂的正则,这种活交给 find -delete:

把正则里的每个符号拆开解释
# .*经常和 -regex配合使用,因为是匹配完整路径
find "$LOG_DIR" -type f -regextype posix-extended -regex ".*\.log\.[0-9]+(\.gz)?$" -delete

find 这几个核心参数记住就行:

-name 和 -regex 的区别也顺便记一下,一个只匹配文件名、用通配符,一个匹配完整路径、用正则:

-name 和 -regex 的对照表

最终脚本:

#! /bin/bash

# 清理输入路径下log.数字.gz的压缩包日志文件

read -p "输入要清理日志压缩包的目录" LOG_DIR

if [ -d "$LOG_DIR" ]; then
        echo "正在清理"
        sleep 2
    else
        echo "目录不存在"
fi
#                {}和代码直接必须有空格,可以去掉看看,高亮会变暗
cd "$LOG_DIR" || { echo "对该目录没有读权限"; exit 1; } # 末尾要有结束符、空格也可以
# 如果没有cd这里就写绝对路径
find . -type f -regextype posix-extended -regex ".*\.log\.[0-9]+(\.gz)?$" -delete
echo "清理完毕"

root@VM-0-12-ubuntu:~/scripts# bash clear_var_log.sh
输入要清理日志压缩包的目录/var/log
正在清理
清理完毕

如果报错就重新创建文件试试。

清完之后长这样,log.数字.gz 都没了:

清理后的 /var/log,压缩包日志已经不见了

如果还想把清理掉的文件名打印出来,加上 -print 就行:

find . -type f -regextype posix-extended -regex \
".*\.log\.[0-9]+(\.gz)?$" -print -delete

四、磁盘监控告警

磁盘主要看物理磁盘 /dev 开头的,最要紧的是使用率那一列:

df -h 里 /dev 开头的行和使用率列
#! /bin/bash
# 如果磁盘只有一个,就直接赋值
DEV_USED=$(df -h | grep '^/dev/' | awk '{print $5}'| tr -d '%')

if [ "$DEV_USED" -gt 80 ]; then
    echo "磁盘空间不足(使用率:$DEV_USED%)"
  else
      echo "磁盘空间使用正常($DEV_USED%)"
fi

tr 是命令名,全称 translate(转换/翻译);-d 是参数,全称 delete(删除);'%' 就是要删掉的字符。

顺便记一个清空文件的办法:

> [文件名]

优化

还可以加上 date,以及磁盘有多个的时候用循环一个个判断:读两列,while read 一次赋两个变量。

# 如果磁盘有多个可以配合循环使用
# 这时就需要加入磁盘名,读两列,while赋值两个变量

df -h | grep '^/dev/' | awk '{print $1, $5}'| tr -d '%' | while read DEV DEV_USED; do
  if [ "$DEV_USED" -gt 80 ]; then
      echo "磁盘$DEV内存不足($DEV_USED%)"
    else
      echo "磁盘$DEV空间使用正常($DEV_USED%)"
      sleep 2
  fi
done


# echo -e也可以解析\n,不然会当作字符输出


df -h | grep '^/dev/' | awk '{print $1, $5}'| tr -d '%' | while read DEV DEV_USED; do
  if [ "$DEV_USED" -gt 80 ]; then
      echo -e "磁盘$DEV内存不足($DEV_USED%)\n"
    else
      echo -e "磁盘$DEV空间使用正常($DEV_USED%)\n"
      sleep 2
  fi
done

数值比较的几个符号:


五、服务存活检测和自动重启

$(...) 的意思是:先执行括号里的命令,然后把命令的输出返回。

#! /bin/bash
# 服务探测重启脚本,重启失败打印报错信息

SERVICE_NAME=$1

SERVICE_STATUS=$(systemctl is-active "$SERVICE_NAME")

if [ "$SERVICE_STATUS" = "active" ]; then
        echo -e "$SERVICE_NAME服务正常运行\n"
        sleep 1
else
        echo -e "$SERVICE_NAME未启动\n"
        sleep 1

        RETURN=$(systemctl restart "$SERVICE_NAME" 2>&1)

        if [ $? -ne "0" ]; then
                echo -e "$RETURN\n"
        else
                echo -e "服务重启成功\n"
        
        fi
fi


# 值得区分的是,mysqld是进程名,服务名是mysql
root@VM-0-12-ubuntu:~/scripts# bash service_status.sh mysqld
mysqld未启动

Failed to restart mysqld.service: Unit mysqld.service not found.

root@VM-0-12-ubuntu:~/scripts# bash service_status.sh mysql
mysql服务正常运行

root@VM-0-12-ubuntu:~/scripts# systemctl is-active mysql
active
root@VM-0-12-ubuntu:~/scripts# systemctl is-active mysqld
inactive

这里有个坑值得说一下:systemctl restart 成功的时候什么都不输出,失败才会往错误输出(stderr)里打印报错。所以 RETURN=$(systemctl restart "$SERVICE_NAME" 2>&1) 里那个 2>&1 不能省,它的意思是把错误输出重定向到标准输出,这样 $() 才能把报错信息也一起装进袋子里。不加的话,RETURN 永远是空的,报错直接喷到屏幕上了。

加了 2>&1 之后报错才能被 $() 装进变量里

关于重定向和标准输出、错误输出,常用的就这几个写法:

写法作用
> file标准输出 → file
2> file标准错误 → file
>> file标准输出追加 → file
2>> file标准错误追加 → file
&> file标准输出 + 标准错误 → file
> file 2>&1同上(更通用)
2>&1错误输出合并到标准输出
> /dev/null丢弃标准输出
2> /dev/null丢弃标准错误
&> /dev/null全部丢弃

六、文件备份脚本

这个脚本要干这几件事:

  1. 把指定目录打包压缩成 .tar.gz
  2. 文件名带时间戳(backup_2026-09-12.tar.gz)
  3. 备份文件存到指定目录
  4. 自动删除 7 天前的旧备份
  5. 每次操作记录日志
#! /bin/bash
# 传入指定目录打包
mkdir -p /backup/
read -p "传入一个目录(绝对路径)" DIR
DATE=$(date +%Y%m%d_%H%M%S) # date格式化,右边无空格,左边空一格

if [ -d "$DIR" ]; then
	echo "备份目录:$DIR"
 	sleep 1
  # 1.记录1(追加写入,必须加echo,否则会当成命令执行)
	echo "[$DATE] 开始备份$DIR" >> /backup/operate.log
 	cd /backup/ || { echo "未找到备份目录"; exit 1;  } 
 	tar -czf ${DATE}.tar.gz "$DIR"

  # 检查tar是否成功
  if [ $? -ne "0" ]; then
  	echo "备份失败"
  	exit 1
  fi
  # 记录2
  echo "[$DATE] 备份成功 ${DATE}.tar.gz" >> /backup/operate.log
	
 	# 如果不指定普通文件,会把文件目录、软连接、设备文件都找出
	find /backup -type f -name "202*_*.tar.gz" -mtime +7 -delete && echo "7天前的文件已删除"
	
 	# 记录3
	echo "[$DATE] 清理了7天前的文件" >> /backup/operate.log

else
	echo "传入的不是一个目录"
 	exit 1 # 写了退出就不能用source再当前shell执行,会退出ssh,bash就行
fi

-mtime 是 modification time(修改时间)的缩写,后面那个 +7 是它的参数,几个写法的区别:

-mtime 后面数字的含义

${} 是为了明确变量的边界,不加的话 shell 会顺着往后读变量名:

加了大括号才能把变量名和后面的字符分开

每一步都记日志,出了问题才能回头查:

operate.log 里每一行都是时间和做了什么

tar 打包会把开头的 / 去掉

# 1. 打包命令
tar -czf backup.tar.gz /tmp/test_data

# 2. 查看压缩包里的内容
tar -tzf backup.tar.gz

你会看到输出是:

tmp/test_data/a.txt
tmp/test_data/b.txt

开头的 / 没了,它变成了 tmp/test_data。这时候解压:

tar -xzf backup.tar.gz

它不会去覆盖系统的 /tmp/test_data,而是在当前所在的目录下新建一个 tmp 目录放进去。备份包不在当前目录的话,把路径写全就行:

tar -xzf /backup/20260912_123456.tar.gz

想指定解压到哪个位置,加一个 -C:

tar -xzf /backup/20260912_123456.tar.gz -C /

这样它才会解压回 /tmp/test_data。

真想保留绝对路径,就加一个大写的 -P 参数:

tar -czPf backup.tar.gz /tmp/test_data

警告:千万别在生产环境随便用 -P,解压的时候它会直接按绝对路径覆盖原来的文件。

跑一遍看看:

root@VM-0-12-ubuntu:~/scripts# bash bak_file.sh 
传入一个目录(绝对路径)/root/scripts
备份目录:/root/scripts
tar: Removing leading `/' from member names
7天前的文件已删除

备份目录和日志都在:

root@VM-0-12-ubuntu:~/scripts# cd /backup
root@VM-0-12-ubuntu:/backup# l
20260912_222922.tar.gz  operate.log
root@VM-0-12-ubuntu:/backup# cat operate.log
[20260912_222922] 开始备份/root/scripts
[20260912_222922] 备份成功 20260912_222922.tar.gz
[20260912_222922] 清理了7天前的文件
root@VM-0-12-ubuntu:/backup# tar -ztf 20260912_222922.tar.gz 
root/scripts/
root/scripts/bak_file.sh
root/scripts/dev_monitor.sh
root/scripts/clear_var_log.sh
root/scripts/nginx_ip.sh
root/scripts/service_status.sh
root/scripts/file_cnt.sh