简介:面向Linux服务器运维与系统管理员,这份实操文档以完整Shell脚本实现日常自动化巡检,覆盖磁盘、内存、CPU、进程、文件更改、用户登录等关键维度,并利用ifconfig、free、cat、ps、find、last等系统命令采集状态,经awk解析后生成结构化巡检报告,再通过mail自动发送至指定邮箱,同时支持日志记录与环境变量加载。脚本结构清晰,内置环境变量加载、日志目录自动创建等细节,可直接复制部署或按需扩展,降低日常巡检门槛。资源包为单个doc文档,体积仅118KB,内容包含脚本源码、注释与调用思路,便于理解脚本逻辑并快速接入现有运维体系。已有1375人学习下载,适合希望降低手工检查负担、提升异常发现与排查效率的初中级运维人员参考使用,尤其适合纳入每日定时巡检场景。
1. 为什么要用脚本接管 Linux 服务器日常巡检:一台台敲命令太浪费人了
我最早接触 Linux 服务器日常巡检脚本,是在帮某公司看一批线上机器的时候。十几台服务器,磁盘、内存、CPU、进程、登录记录,每周人工过一遍,光收集信息就要一上午,更别提还要整理成报告发出来。后来拿到这份巡检脚本,才意识到这些重复劳动完全可以用一个 bash 脚本标准化掉:它把磁盘、内存、CPU、进程、文件更改、用户登录这些检查项全部收敛成一份文本报告,落到 log 目录,顺手还能通过 mail 发到指定邮箱。对系统管理员和运维来说,这就是把「每周手工检查」变成「每天早上看一封邮件」的典型工具,也是入行时最值得先改造的一类 Linux 脚本。
适合谁?刚接手服务器巡检、想快速建立检查清单的新手,以及嫌手动命令太碎的熟手。它不解决监控告警那种实时问题,解决的是「定期要交一份巡检记录」这种周而复始的活儿。下面我按自己拆脚本的习惯,把结构、模块、坑和落地方式逐层说清楚。
2. 拆脚本结构:函数组织、全局变量与报表变量的定义方式
2.1 先看懂脚本骨架:从 shebang 到主流程
拿到一份 bash 脚本,我习惯先不看具体函数,先看它定义了什么全局变量、声明了哪些函数、最后怎么调用。这份脚本的结构很清晰:开头定义 IP 获取、环境变量、版本号和日志路径,中间是一批 getXxxStatus 函数,各自负责一类巡检项,结尾应该有调用入口。常见做法是在文件末尾加一段顺序调用,把所有函数串起来,再统一生成报告,类似下面这样:
# 定义完所有函数后,按检查顺序调用 getSystemStatus getCpuStatus getMemStatus getDiskStatus getNetworkStatus getListenStatus getServiceStatus getProcessStatus getUserStatus getPasswordStatus getSudoersStatus getLoginStatus getCronStatus getFirewallStatus getJDKStatus getSyslogStatus getInstalledStatus # 生成报告文件 { echo "巡检时间: $report_DateTime" echo "主机名: $report_Hostname" echo "IP 地址: $report_IP" echo "内存使用率: $report_MemUsedPercent" echo "磁盘使用率: $report_DiskUsedPercent" echo "僵尸进程数: $report_DefunctProsess" } > $RESULTFILE # 邮件发送 mail -s "Host Daily Check Report" your_email@example.com < $RESULTFILE逻辑说明:脚本把每个巡检项封装成独立函数,最后统一收集全局变量生成报告。这样做的好处是新增一个检查项时,不用动其他函数,只要加一个函数、在调用区加一行、在报告输出区加一个变量即可。
参数说明:RESULTFILE是报告文件路径,模板里包含 IP 和日期,避免多台机器同一天的报告互相覆盖。PROGPATH通过解析$0拿到脚本所在目录,这是 bash 脚本里比较常见的自定位方式,好处是用绝对路径执行脚本时,log 目录依然能落在脚本旁边,不会跟着当前工作目录跑偏。
2.2 报表变量的初始化:一份报告长什么样的关键
脚本里定义了一批report_开头的全局变量,从report_DateTime到report_JDK,共二十多个。这些变量是所有巡检函数共享的输出通道。每个采集函数在自己的逻辑里把数据填进对应变量,最后主流程统一写入报告文件。
这里有个细节值得注意:变量名的设计基本就是报告模板的字段名。比如report_MemUsedPercent存内存使用率,report_DiskUsedPercent存磁盘使用率,report_DefunctProsess存僵尸进程数量。这种命名方式对后续扩展非常友好,你加一个新的巡检项时,先想好报告里要展示什么字段,然后定义同名变量,让采集函数只负责赋值,报告部分只负责打印,职责是分开的。
# 报告文件内容示例:巡检日期和核心指标 echo "==== 主机基本信息 ====" echo "报告时间: 2024-06-01 08:30:22" echo "主机名: node-01" echo "IP: 192.168.1.101" echo "==== 资源使用情况 ====" echo "内存使用率: 67.25%" echo "磁盘使用率: 82.14%" echo "Inode 使用率: 3.82%" echo "僵尸进程数量: 0"逻辑说明:先填充结构化信息,再填资源指标,这是巡检报告的基本排版逻辑。如果你希望报告更易读,可以在这段基础上加一行分隔线,或者按模块分区打印。
参数说明:report_MemUsedPercent这类变量在赋值时已经带了%符号,打印时不需要再拼字符串。这个设计细节能避免在输出阶段到处补单位,也让报告里的数据看起来更整齐。我在自己改脚本时,通常会保留这个风格,所有涉及百分比的变量在源头就完成格式化。
2.3 log 目录与执行权限:为什么脚本一定要能跨目录执行
脚本里对PROGPATH做了处理:先去掉脚本名,如果处理完发现$PROGPATH还是文件,就置为当前目录.,然后创建$LOGPATH/log。这保证了你不管在哪个目录执行脚本,日志都稳定落在脚本旁边的 log 目录。
# 脚本自定位到所在目录,并创建 log 目录 PROGPATH=`echo $0 | sed -e 's,[\\/][^\\/][^\\/]*$,,'` [ -f $PROGPATH ] && PROGPATH="." LOGPATH="$PROGPATH/log" [ -e $LOGPATH ] || mkdir $LOGPATH RESULTFILE="$LOGPATH/HostDailyCheck-$IPADDR-`date +%Y%m%d`.txt"逻辑说明:sed在这里的作用是去掉最后一个斜杠及后面的脚本名,拿到纯目录路径。[ -f $PROGPATH ]判断的是:如果$0只包含脚本名而没有路径前缀,sed 处理后会剩下空字符串或原字符串,这时把目录改为当前目录。
参数说明:RESULTFILE文件名里的$IPADDR来自ifconfig eth0解析,适用于 eth0 作为主网卡的场景。如果你的机器网卡命名是 ens33、enp0s3,这里解析不到 IP,文件名会变成HostDailyCheck--20240601.txt。我在后面避坑章会专门讲这个。
3. 核心巡检模块逐个拆:磁盘、内存、CPU 与进程的采集逻辑
3.1 磁盘巡检:df 与 inode 的两次快照合并
磁盘检查的函数是整份脚本里信息量最大的部分。它先跑两次df,一次查磁盘空间(df -hTP),一次查 inode(df -hiP),然后用join命令把两份结果按文件系统挂载点合并,输出成易读的表格。
# 磁盘空间与 inode 信息合并展示 df -hiP | sed 's/Mounted on/Mounted/' > /tmp/inode df -hTP | sed 's/Mounted on/Mounted/' > /tmp/disk join /tmp/disk /tmp/inode | awk '{print $1,$2,"|",$3,$4,$5,$6,"|",$8,$9,$10,$11,"|",$12}' | column -t逻辑说明:这里的核心是字段对齐。df -hTP输出包含文件系统、类型、总大小、已用、可用、挂载点,df -hiP输出包含 inode 总量、已用、剩余、挂载点。直接 cat 两份文件对不齐,所以用join以挂载点为连接键,再通过awk重排字段顺序。
参数说明:-h表示人类可读,-T显示文件系统类型,-P使用 POSIX 输出格式避免换行错位,-i显示 inode 信息。sed 's/Mounted on/Mounted/'是为了让两个文件的表头一致,join 才不会因为表头字符串不同而报错。
再往后是磁盘使用率的统计逻辑,它用df -TP排除 tmpfs 后累加总量和已用量:
# 汇总磁盘总量、已用、剩余和使用率 diskdata=$(df -TP | sed '1d' | awk '$2!="tmpfs"{print}') disktotal=$(echo "$diskdata" | awk '{total+=$3}END{print total}') diskused=$(echo "$diskdata" | awk '{total+=$4}END{print total}') diskfree=$((disktotal-diskused)) diskusedpercent=$(echo $disktotal $diskused | awk '{if($1==0){printf 100}else{printf "%.2f",$2*100/$1}}')逻辑说明:先过滤掉表头和 tmpfs,再分别对第 3 列(总块数)和第 4 列(已用块数)做累加。磁盘总使用率 = 已用 / 总量,这样算出来的百分比是整机视角,而不是某个分区的使用率。
参数说明:这里有个容易被忽略的点,df -TP的数值列默认单位是 KB,脚本最后除以 1024 再除以 1024 转成 GB。如果你拿到脚本后发现报告里磁盘总量偏小,先检查是不是有分区挂载在 tmpfs 之外的虚拟文件系统上被awk '$2!="tmpfs"'过滤掉了,比如 overlay、squashfs。
3.2 内存巡检:free 命令的版本兼容处理
内存检查函数里有一个典型的版本分支,代码对 CentOS 6 和 CentOS 7 用了不同的free参数:
# CentOS 6 使用 -mo,CentOS 7+ 使用 -h if [[ $centosVersion < 7 ]]; then free -mo else free -h fi逻辑说明:CentOS 6 的 free 没有-h人类可读选项,-m以 MB 为单位展示,-o关闭缓冲区显示。CentOS 7 开始-h成为标配,直接用人类可读格式。这个分支思路值得保留,因为现在还有不少存量机器停留在 CentOS 6/7,脚本要在不同主版本上跑,最稳妥的方式就是先探测系统版本再决定命令参数。
内存使用率计算则统一走/proc/meminfo,跟 free 的显示层解耦:
# 从 /proc/meminfo 原始数据计算使用率 MemTotal=$(grep MemTotal /proc/meminfo | awk '{print $2}') # KB MemFree=$(grep MemFree /proc/meminfo | awk '{print $2}') # KB let MemUsed=MemTotal-MemFree MemPercent=$(awk "BEGIN{if($MemTotal==0){printf 100}else{printf \"%.2f\",$MemUsed*100/$MemTotal}}")逻辑说明:/proc/meminfo的前两列永远是MemTotal和MemFree,用awk '{print $2}'取的就是数值部分。let MemUsed=MemTotal-MemFree算出已用量,再用 awk 做浮点除法,避免 bash 整数运算直接截断小数。如果 MemTotal 为 0,按 100% 处理,这也是一个防御性写法。
参数说明:MemFree并不等于系统真实可用内存,它不含 buffers/cache。严格来说可用内存应该看MemAvailable,但这份脚本基于 CentOS 6/7 时代写的,当时更习惯用 MemFree。如果你想调,可以把 MemFree 换成 MemAvailable,但要注意 CentOS 6 的/proc/meminfo不一定有这个字段,改了得做版本判断。
3.3 CPU 巡检:从 /proc/cpuinfo 读物理核与逻辑核
CPU 检查走的是/proc/cpuinfo,这块写法比较经典。物理 CPU 个数看physical id字段去重后的行数,逻辑 CPU 个数看processor字段的行数,每颗 CPU 核心数看cores字段,型号看model name。
# CPU 物理个数、逻辑个数、核心数与型号 Physical_CPUs=$(grep "physical id" /proc/cpuinfo | sort | uniq | wc -l) Virt_CPUs=$(grep "processor" /proc/cpuinfo | wc -l) CPU_Kernels=$(grep "cores" /proc/cpuinfo | uniq | awk -F ': ' '{print $2}') CPU_Type=$(grep "model name" /proc/cpuinfo | awk -F ': ' '{print $2}' | sort | uniq) CPU_Arch=$(uname -m)逻辑说明:grep "physical id"出来的每一行是一个物理 CPU 槽位,sort | uniq去掉同一颗物理 CPU 的重复编号。grep "processor"数的是逻辑 CPU 数,超线程开启时逻辑核会多于物理核乘以核心数。cores字段在开了超线程的机器上仍然显示物理核心数,所以物理核数 = Physical_CPUs × CPU_Kernels,而逻辑核数就是 Virt_CPUs。这两个数字差一倍,说明开了超线程。
参数说明:awk -F ': ' '{print $2}'里分隔符是冒号加空格,不是冒号。这个细节容易看错,如果写成-F ':',取出来的值会带前导空格,后面拼字符串时排版会多一个空格。
3.4 进程巡检:僵尸进程与内存、CPU 占用 TOP10
进程检查函数有两个固定输出:僵尸进程列表、内存占用 TOP10、CPU 占用 TOP10。这几个数据是巡检报告里最能反映机器健康状态的。
# 僵尸进程统计与列表 if [ $(ps -ef | grep defunct | grep -v grep | wc -l) -ge 1 ]; then echo "僵尸进程" ps -ef | head -n1 ps -ef | grep defunct | grep -v grep fi逻辑说明:grep defunct匹配僵尸进程,grep -v grep去掉 grep 自身那条进程记录。如果统计数大于等于 1,说明存在僵尸进程,就打出来。这里用的是-ge 1而不是-gt 0,效果一样,但写成-ge 1更符合直觉。
# 内存占用 TOP10 与 CPU 占用 TOP10 echo -e "PID %MEM RSS COMMAND$(ps aux | awk '{print $2, $4, $6, $11}' | sort -k3rn | head -n 10)" | column -t top -b -n1 | head -17 | tail -11逻辑说明:ps aux输出的第 2 列是 PID,第 4 列是 %MEM,第 6 列是 RSS(物理内存占用),第 11 列是命令。sort -k3rn按 RSS 数值降序排,这里的-k3对应的是 awk 输出的第 3 列 RSS。CPU TOP10 直接用top -b -n1非交互模式跑一轮,截取第 7 到 17 行。-b是 batch 模式,适合脚本里调用,-n1指只刷新一次。
参数说明:内存 TOP10 里之所以不用sort -k2rn按 %MEM 排,是因为 %MEM 是百分比,很多进程数值相同,排序不稳定。按 RSS 排更能反映实际内存占用绝对值,这个取舍是合理的。
3.5 用户与登录巡检:last 命令与空密码检测
用户检查函数比较长,核心是遍历 /etc/passwd 里所有可登录用户,逐个查最近登录时间和密码过期时间。其中获取用户最近登录时间用了 last 命令配合年份推断:
# 推断用户最近一次登录时间(last 命令不直接显示年份) loginBeforeToday=$(last $username | grep $username | wc -l) loginBeforeNewYearsDayOfThisYear=$(last $username -t $thisYear"0101000000" | grep $username | wc -l) if [ $loginBeforeToday -gt $loginBeforeNewYearsDayOfThisYear ]; then echo "该用户今年有登录记录" fi逻辑说明:last 命令默认只显示最近若干条登录记录,如果要判断某用户是否在今年登录过,比较两个数字即可。如果「截至今天」的登录次数大于「截至今年元旦」的登录次数,说明今年有登录记录。这是一条非常取巧的思路,也是脚本里唯一一段带算法性质的逻辑。
空密码用户检测走的是 /etc/shadow 里密码位为!!的标记:
# 检测空密码用户 r=$(awk -F: '$2=="!!"{print $1}' /etc/shadow | grep -w $user) if [ ! -z $r ]; then echo $r fi逻辑说明:/etc/shadow第二列是加密后的密码,新创建但未设置密码的用户,该字段一般是!!。awk -F: '$2=="!!"'精确匹配这一状态,grep -w $user确保不是前缀匹配,避免漏掉或误判用户名类似的账户。
参数说明:判断条件用的是[ ! -z $r ],即非空就执行。这里如果$r为空字符串,[ ! -z ]在部分 bash 版本下会有告警,更严谨的写法是[ -n "$r" ]加双引号。
4. 落地避坑:从 cron 报错到邮件发不出的 5 条记录
4.1 cron 里执行报错 command not found
现象:脚本手动执行一切正常,丢到 crontab 里定时跑之后,日志文件里全是ifconfig: command not found、free: command not found。
原因:cron 的执行环境 PATH 极简,默认只有/usr/bin:/bin,很多系统命令在/sbin和/usr/sbin下。脚本里的 ifconfig、chkconfig、sestatus 都依赖这些路径。
解决:脚本里已经显式导出了完整 PATH,export PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin,同时source /etc/profile重新加载系统环境变量。如果你拿到的版本没有这行,在 crontab 的脚本执行行前补上也可以:0 8 * * * source /etc/profile && /path/to/check.sh。
4.2 CentOS 7+ 上 ifconfig 解析不到 IP
现象:报告文件名变成HostDailyCheck--20240601.txt,登录检查里网络模块的 IP 字段为空。
原因:CentOS 7 开始默认不装 net-tools,ifconfig 可能不存在;就算装了,主网卡名称也未必是 eth0,可能是 ens33、enp0s3 或 eno1。脚本开头用ifconfig eth0硬编码网卡名,拿不到 IP。
解决:把 IP 获取方式改成 ip 命令,兼容任意网卡名称:
# 兼容新老系统的 IP 获取方式 IPADDR=$(ip -4 addr show | grep -v 127.0.0.1 | grep inet | awk '{print $NF}' | head -n1)如果机器有多个网卡,这个命令取的是第一个非回环地址。想精确到指定网卡,可以把ip -4 addr show换成ip -4 addr show eth0或ip -4 addr show ens33,按实际环境改。
4.3 mail 命令发不出邮件
现象:脚本手动执行后,报告文件正常生成,但邮箱里收不到信,终端报mail: command not found或cannot send message: No such file or directory。
原因:机器上没装 mailx / mailutils,或者装了但系统没有配置可用的 MTA。很多精简安装的服务器默认不带邮件客户端,即使带了,也没有可用的 smtp 配置。
解决:没有 mail 命令就装一个,CentOS 7 的安装命令是:
# CentOS 7/RHEL 7 安装 mailx yum install -y mailx装完还要确认能发外域邮件。常见做法是配置 mailx 走外部 SMTP,在/etc/mail.rc末尾追加:
set smtp=smtp.example.com:465 set smtp-auth-user=your_account@example.com set smtp-auth-password=your_password set smtp-auth=login set ssl-verify=ignore参数说明:smtp=smtp.example.com:465的 465 是 SSL 端口,smtp-auth=login表示登录认证方式,ssl-verify=ignore跳过证书校验,适合内网自建邮件服务器或测试环境。配置完后可以先手动跑一次echo test | mail -s test your_email@example.com验证。
4.4 磁盘汇总与 df 实际输出对不上
现象:报告里磁盘总容量比df -h里所有分区加起来小很多,或者 Inode 使用率算出一个很不合理的数字。
原因:脚本用df -TP排除 tmpfs 后累加,但有些机器的/dev/shm、/run、/sys/fs/cgroup挂在 tmpfs、overlay、squashfs 上,这些都被过滤了,本身没问题;问题往往出在有的分区是 NFS 挂载,df 出来的数值单位不统一。另外join /tmp/disk /tmp/inode时,如果某个文件系统的挂载点包含空格,join 的字段分隔会错位,导致 awk 取列取错。
解决:遇到 NFS 挂载时,在awk '$2!="tmpfs"'后面追加过滤条件,把网络文件系统也排除掉:awk '$2!="tmpfs" && $2!="nfs" && $2!="nfs4"'。至于挂载点带空格的情况,检查时先看一眼df -TP原始输出,如果挂载点有空格,脚本需要改用-PPOSIX 格式并以冒号分隔后处理,属于个别场景,不用过度改。
4.5 chage 对 root 用户报错导致密码检查中断
现象:密码检查函数在部分机器上输出异常,chage -l root显示Permission denied,后续用户密码状态全部没打出来。
原因:chage -l查询其他用户密码信息时,要么需要 root 权限,要么用户本身是 root 时某些系统版本会限制查询。脚本没有对每个用户做权限判断,遇到权限不足直接退出当前循环。
解决:在调用 chage 前确认执行身份,保留脚本开头那段[ $(id -u) -gt 0 ] && echo "请用 root 用户执行此脚本!" && exit 1的校验;同时把 chage 查询的 stderr 丢弃,避免报错信息污染报告:
get_expiry_date=$(/usr/bin/chage -l $user 2>/dev/null | grep 'Password expires' | cut -d: -f2)5. 接入邮件与定时任务:把巡检报告变成每天早上的第一份日志
到了这一步,脚本本身已经能跑、能出报告了,剩下的问题是让它自动化运转起来。我的习惯是加两层:第一层是定时任务,每天早上固定时间自动执行;第二层是验证逻辑,确认报告真的生成、真的发出去了。
cron 配置我建议这么写,注意路径和环境变量一起带上:
# crontab 定时执行巡检脚本 0 8 * * * source /etc/profile && /usr/local/bin/host_daily_check.sh >> /var/log/host_check.log 2>&1逻辑说明:0 8 * * *表示每天早上 8 点执行。source /etc/profile在脚本外先加载一次系统环境变量,避免脚本里 export 不完整时 cron 环境缺东西。重定向日志是必要的,出了错能第一时间看到迹。
邮件发送的配置需要在脚本主流程末尾补一段,我这里给出一个带判断的版本:
# 报告生成后检查文件是否非空,再决定是否发邮件 if [ -s "$RESULTFILE" ]; then mail -s "Host Daily Check Report - $report_Hostname $(date +%F)" your_email@example.com < "$RESULTFILE" else echo "报告文件为空,可能巡检失败" | mail -s "Host Check ERROR" your_email@example.com fi逻辑说明:-s判断文件存在且非空。报告为空说明前面某个环节挂了,这时候发一封错误提醒邮件,比发一封空报告有用得多。邮件标题带上主机名和日期,在多台服务器同时巡检时,收件箱里一眼就能分辨是哪台机器。
报告内容本身也可以做点小优化。脚本把所有 report 变量拼成一个文本,我一般会在头部加一个「健康概览」块,把内存使用率、磁盘使用率、僵尸进程数这几个最关键的指标放最前面,避免收件人翻完整份报告才发现问题:
# 报告头部增加健康概览 echo "===== 健康概览 =====" echo "内存使用率: $report_MemUsedPercent" echo "磁盘使用率: $report_DiskUsedPercent" echo "Inode 使用率: $report_InodeUsedPercent" echo "僵尸进程数: $report_DefunctProsess" echo "计划任务数: $report_Crontab" echo "===== 完整巡检信息 ====="参数说明:$report_Crontab是脚本统计的当前用户计划任务总数,数值偏大不一定代表异常,但每次对比这个数字能发现是否有新增的恶意定时任务或业务任务误添加。
验证这套东西是否真正跑通,我的做法是:手动执行一次脚本,确认报告文件出现;再手动执行一次 mail 发送命令,确认邮箱能收到;最后把 cron 时间临时调成两分钟后,等它真跑完一遍,再改回 8 点。从那以后,我每次接手一批新机器,都强制先跑一遍这个流程,确认报告里所有字段都有值、邮件能收到,再做其他优化。如果哪天早上邮件没来,第一件事不是翻脚本,而是看/var/log/host_check.log里 cron 的执行记录——这个习惯帮我省了不止一次半夜查机器的功夫。希望帮到你。
本文还有配套的精品资源,点击获取