Linux服务器巡检Shell脚本:资源、账号与cron实战
2026/9/17 16:30:00 网站建设 项目流程

简介:这份 Linux 服务器日常巡检脚本,面向系统管理员与运维工程师,用于把日常人工巡检流程固化为可重复执行的一键脚本,适合中级运维人员直接上手使用。资源压缩包仅 118KB,内含 1 个 doc 文档,完整收录巡检脚本源码与说明,轻量却覆盖全面。脚本涵盖磁盘、内存、CPU、进程、文件更改、用户登录等核心巡检项,并延伸至监听端口、Selinux、防火墙、用户与空密码账户、相同 UID 用户、sudo 授权、SSH 信任主机与协议版本、root 远程登录、僵尸进程、自启动服务与计划任务、Syslog、SNMP、NTP、JDK 版本等检查点,同时统计物理 CPU 个数、逻辑 CPU 数、每 CPU 核心数、CPU 型号与架构,以及内存总容量、剩余量与使用率、硬盘与 Inode 总量和占用率、IP、MAC、网关、DNS 等主机信息。脚本通过 echo 拼接报表,在 log 目录生成巡检报告并借助 mail 发送到指定邮箱,配合环境变量与日志处理,可直接部署或按需裁剪。目前已有 1372 人学习,便于快速搭建巡检基线、排查隐患。

1. 巡检脚本能解决什么:从一批跑了三年的服务器说起

接手一批跑了三年的 CentOS,最先要回答的往往不是“服务活着吗”,而是“这批机器现在到底什么状态”。哪台的 /var 只剩不到 5%,哪台 inode 已经到 90% 却没人发现,哪台上还留着空密码账号,cron 里堆了多少条早该下线的历史任务——这些东西监控面板不一定覆盖,出了事却要有人负责。

这份巡检脚本就是冲着这个场景写的。纯 bash 实现,靠 /proc、df、ss、last、chage 这些系统自带命令把 CPU、内存、磁盘与 inode、系统版本、服务自启、网络与监听、计划任务、用户与密码策略、sudoers、僵尸进程、JDK 等十几类信息采一遍,输出成一份纯文本报告落到脚本同级 log 目录,再通过邮件发到指定邮箱。

适合的人群很明确:还没上 Zabbix 或 Prometheus 的中小规模环境,需要按天留一份可读巡检记录的服务器运维;也适合当作自己写自动化采集器之前的参照物。脚本不依赖任何第三方包,一台最小化安装的机器就能跑起来。

2. 资源层采集:CPU、内存、磁盘与 inode 的解析与计算

资源层的四个指标决定了这份报告有没有人愿意看。CPU、内存、磁盘空间的采集命令本身很简单,难的是解析字段和单位换算,稍微糊弄一下,使用率就能差出十几个百分点。

2.1 CPU 采集:physical id 去重与 processor 计数

原始脚本这一段是完全可用的,直接抄:

# /proc/cpuinfo 中每个物理插槽有一个 physical id,去重计数即物理 CPU 数 Physical_CPUs=$(grep "physical id" /proc/cpuinfo | sort | uniq | wc -l) # 每个逻辑核一条 processor 记录,直接计数得到逻辑 CPU 数(含超线程) Virt_CPUs=$(grep "processor" /proc/cpuinfo | wc -l) # cores 会在每颗物理 CPU 上重复出现,先去重再取值 CPU_Kernels=$(grep "cores" /proc/cpuinfo | uniq | awk -F ': ' '{print $2}') # 同一台机器型号通常一致,sort|uniq 兜住异构插槽的情况 CPU_Type=$(grep "model name" /proc/cpuinfo | awk -F ': ' '{print $2}' | sort | uniq) CPU_Arch=$(uname -m)

逻辑上就是三件事:插槽数、逻辑核数、单颗核心数。grep "physical id"在带超线程的机器上会返回与逻辑核数相同的行数,必须sort | uniq | wc -l才能收敛到真实插槽数,少一步结果就翻倍。

注意:ARM 架构的 /proc/cpuinfo 里没有physical id字段,grep 返回空,wc -l得到 0。容器里读到的也是宿主机裁剪视图。要给这几个变量补默认值,否则报表里会出现“物理 CPU 个数:0”。

2.2 内存采集:free 与 /proc/meminfo 的双通道

原脚本按 CentOS 版本分流:7 以下用free -mo,7 及以上用free -hf。这里有个长期被误读的点,先把字段表列清楚。

字段含义说明
total物理内存总量不含 swap
used已用内存新版本 free 已扣除可回收部分
free完全空闲通常很小,不代表内存不足
buff/cache缓冲与页缓存可被回收
available实际可用内核 3.14+ 提供,判断内存压力看这个

报表变量用的是 /proc/meminfo:

# 取 KB 单位的原始值,便于统一换算 MemTotal=$(awk '/MemTotal/{print $2}' /proc/meminfo) MemFree=$(awk '/MemFree/{print $2}' /proc/meminfo) MemUsed=$((MemTotal - MemFree)) # 除零保护,容器里 MemTotal 可能为 0 MemPercent=$(awk "BEGIN{if($MemTotal==0){printf 100}else{printf \"%.2f\",$MemUsed*100/$MemTotal}}")

MemFree只统计完全空闲页,不含 buff/cache,所以这样算出来的使用率会明显偏高,一台看着 85% 的机器实际压力可能很小。做阈值告警时要把MemFree换成MemAvailable,读法一样,只是键名不同。容器场景还要注意 cgroup 限制,/proc/meminfo给的是宿主机的值,要看/sys/fs/cgroup/memory/memory.limit_in_bytes

2.3 磁盘与 inode:df -hTP 与 join 合并

磁盘采集的核心是两个命令加一次合并:

# -T 输出文件系统类型,-P 保证一行一个文件系统不会折行 # 排除 tmpfs,它在容器和 /dev/shm 上数量很多,会稀释统计结果 df -hTP | sed '1d' | awk '$2!="tmpfs"{print}' > /tmp/disk df -iTP | sed '1d' | awk '$2!="tmpfs"{print}' > /tmp/inode # 表头统一成 Mounted(原脚本用 sed 把 "Mounted on" 改写)后再 join 对齐 join /tmp/disk /tmp/inode | column -t

-P这个参数很容易被忽略。不加它,设备名过长时 GNU df 会折行输出,一行数据变两行,后面所有按列取值的 awk 全部错位。sed '1d'去掉表头,$2!="tmpfs"排除内存文件系统——不排除的话,一台跑了十几个容器的机器,报表里一半行都是 tmpfs,真正要看的根分区反而被淹了。

inode 单独统计是这份脚本比较专业的地方。df -i看的是 inode 数量而不是字节数,/var/spool/postfix这类目录里堆满小文件时,磁盘还剩几百 G,inode 已经 100%,一样写不进新文件。原脚本把 inode 总量除以 1000 标成 K 单位,其实 df -i 输出的是“个”,这个单位标注不太严谨,自己用的时候直接按个数展示更清楚。

提示:coreutils 8.21 以后可以直接df -PT --output=source,fstype,size,used,avail,pcent,target指定列,不用再靠列号硬切,挂载点带空格的场景更稳。

3. 系统与服务层:版本分流、网络监听与计划任务

资源层看完,接下来是系统身份、网络暴露面和定时任务。这三块决定了报告的“背景信息”是否可信,也决定了脚本能不能迁移到非 RHEL 系的机器上。

3.1 发行版判定与 systemctl/chkconfig 分流

原脚本的版本判定写死了一条命令:

centosVersion=$(awk '{print $(NF-1)}' /etc/redhat-release)

CentOS Linux release 7.9.2009 (Core)这类输出,$(NF-1)取到的是7.9.2009,能工作。但/etc/redhat-release是 RedHat 系专有文件,Ubuntu、Debian、统信 UOS 上根本不存在,变量为空,后续所有[[ $centosVersion < 7 ]]判断都会走错分支。改造成兼容写法:

# os-release 是 systemd 时代的标准文件,主流发行版都有 if [ -f /etc/os-release ]; then . /etc/os-release os_id=$ID # 取值如 centos / ubuntu / debian / uos os_ver=${VERSION_ID%%.*} # 只取主版本号做数值比较 else os_ver=$(awk '{print $(NF-1)}' /etc/redhat-release 2>/dev/null | cut -d. -f1) fi # 数值比较,避免字符串比较下 "10" < "7" 为真的陷阱 if [ "${os_ver:-0}" -ge 7 ] 2>/dev/null; then is_systemd=1 else is_systemd=0 fi

服务采集据此分流:systemd 机器走systemctl list-unit-files --type=service --state=enabled --no-pager统计自启服务,走systemctl list-units --type=service --state=running --no-pager统计运行中服务;老系统走chkconfigservice --status-all。两边的输出格式不一样,所以 grep 的关键字也不一样,systemd 侧匹配enabled,sysvinit 侧要同时匹配:on:启用——中文环境下 chkconfig 输出是本地化的,这个坑在国产化替代的机器上尤其常见。

3.2 网络、监听端口与登录记录

网络信息分三块取。接口地址在 CentOS 7 之后用ip命令替代 ifconfig:

# 只取有 BROADCAST 标志的物理/虚拟接口,过滤 lo for i in $(ip link | grep BROADCAST | awk -F: '{print $2}'); do ip add show $i | grep -E "BROADCAST|global" | awk '{print $2}' | tr '\n' ' ' echo "" done # 默认网关从路由表取 GATEWAY=$(ip route | grep default | awk '{print $3}') # DNS 从 resolv.conf 取,过滤注释行 DNS=$(grep nameserver /etc/resolv.conf | grep -v "#" | awk '{print $2}' | tr '\n' ',')

监听端口用ss -ntul而不是netstat -ntul。ss 走内核 netlink 直接读 socket 表,几万条连接的机器上比 netstat 快一个量级,而且新内核已经默认不装 net-tools。-n不做反向解析、-tTCP、-uUDP、-l只显示监听态,四个参数组合起来正好是要的暴露面清单。

登录记录用last | head,但更值得加的是时间同步检查。集群里时间不同步会导致日志时间戳对不上、证书校验失败、分布式任务调度错乱。常见做法是补一段chronyc trackingntpq -p,把偏移量一并写进报告。

3.3 计划任务扫描与自启动程序

计划任务分两处扫,用户 crontab 和系统级 cron 目录:

Crontab=0 # 第一层:遍历所有可用 shell for shell in $(grep -v "/sbin/nologin" /etc/shells); do # 第二层:找出使用该 shell 的用户 for user in $(grep "$shell" /etc/passwd | awk -F: '{print $1}'); do if crontab -l -u $user >/dev/null 2>&1; then echo "$user"; crontab -l -u $user let Crontab=Crontab+$(crontab -l -u $user | wc -l) fi done done # 系统级:/etc/cron.d /etc/cron.daily 等 find /etc/cron* -type f | xargs -i ls -l {} | column -t let Crontab=Crontab+$(find /etc/cron* -type f | wc -l)

两层循环的设计是为了过滤 nologin 用户——这类系统账号不会有 crontab,逐个去crontab -l -u只会刷出成屏的报错。>/dev/null 2>&1抑制输出,靠返回码判断该用户有没有任务,比解析报错文本可靠。自启动程序检查 rc.local 时,grep -v "^#" | sed '/^$/d'这两步去掉注释和空行,避免把小节标题也算成一条命令。

检查项命令关注点
用户任务crontab -l -u USER退出码 0 表示有任务
系统任务find /etc/cron* -type fcron.d 目录最易被忽略
rc.localgrep -v "^#" /etc/rc.d/rc.local老系统遗留启动项

4. 账号安全巡检:空密码、同 UID、密码过期与 sudoers

这一章是报告里最有价值的部分,也是最容易写错的部分。用户和权限的检查逻辑一旦有偏差,要么漏报真实风险,要么天天误报,最后没人看。

4.1 空密码用户与相同 UID 的判定

原脚本用一条 awk 找空密码用户:

# 目标是找出密码字段异常的账号 awk -F: '$2=="!!"{print $1}' /etc/shadow | grep -w $user

这里要拆清楚:/etc/shadow第二个字段如果是!!!,表示账号被锁定,不是空密码;真正的空密码是第二个字段为空字符串,也就是冒号紧挨着冒号。判定应该分开写:

# 真正的空密码:第二个字段长度为 0 awk -F: 'length($2)==0 {print $1}' /etc/shadow # 被锁定账号单独列出来,属于另一类风险 awk -F: '$2 ~ /^!/ {print $1, "locked"}' /etc/shadow

相同 UID 的检查逻辑是对的:

# 统计每个 UID 出现次数,出现超过一次即存在重复 UIDs=$(cut -d: -f3 /etc/passwd | sort | uniq -c | awk '$1>1{print $2}') for uid in $UIDs; do echo -n "$uid" awk -F: 'ORS=""; $3=='"$uid"'{print ":",$1}' /etc/passwd echo "" done

重复 UID 意味着两个账号实际拥有同一份文件权限,审计上属于必须清理的项。运维脚本创建账号时手写 UID 最容易撞上,useradd -u指定已有 UID 时如果不加-o,系统会报错;加了-o就悄悄建成了重复账号。

4.2 chage 批量检测密码过期

密码过期检测要遍历用户,逐个查 chage:

for user in $(echo "$pwdfile" | grep "$shell" | cut -d: -f1); do get_expiry_date=$(chage -l $user | grep 'Password expires' | cut -d: -f2) if [[ $get_expiry_date =~ never ]]; then printf "%-15s 永不过期\n" $user else # 把过期日期和当前时间都转成秒级时间戳再相减 expiry_ts=$(date -d "$get_expiry_date" "+%s") current_ts=$(date "+%s") days=$(( (expiry_ts - current_ts) / 86400 )) printf "%-15s %s 天后过期\n" $user $days fi done

日期转时间戳这一步不能省。date -d解析 chage 输出的自然语言日期,+%s转成 Unix 时间戳,再做减法得到秒差,除以 86400 取整。直接对日期字符串做减法在跨月和跨年时必然出错。chage 输出里never的写法在不同版本不完全一致,用=~做正则匹配比=严格相等稳。

4.3 sudoers 授权与 root 特权账号盘点

sudoers 的检查不能只读主文件:

# 主文件里去掉注释和 Defaults 行,剩下的才是授权规则 grep -v "^#" /etc/sudoers | grep -v "^Defaults" | sed '/^$/d' # 补充扫描子目录,运维习惯把授权拆到 /etc/sudoers.d/ ls -l /etc/sudoers.d/ && cat /etc/sudoers.d/* 2>/dev/null

很多发行版和云镜像会把授权拆进/etc/sudoers.d/,只读主文件会漏掉全部实际授权。另外主文件里的#includedir /etc/sudoers.d这行本身以#开头,会被grep -v "^#"一起过滤掉,第一次看容易以为目录没被启用。特权账号的统计更直接:

# 遍历 passwd 中所有用户,UID 为 0 的即为特权账号 for user in $(awk -F: '{print $1}' /etc/passwd); do [ "$(id -u $user)" -eq 0 ] && echo "$user" done

4.4 用 lastlog 替代逐年回溯的登录查询

原脚本里getUserLastLoginlast-t时间过滤逐年往前找,思路是对的但有两个问题:last不显示年份,只能靠“今天之前登录次数是否变化”来推断;wtmp 一旦被 logrotate 轮转,历史记录就找不到,last只能追到最早一条。更省事的做法是直接读 lastlog:

# lastlog 一次输出所有用户的最后登录时间,读的是 /var/log/lastlog lastlog | awk 'NR==1{print;next} $2!="**Never"' # 只关注 90 天以上没登录的活跃账号 lastlog -b 90

-b 90表示只显示 90 天以前登录过的用户,这个参数用来找“僵尸账号”非常顺手,比在循环里调last快几个数量级,也不受 wtmp 轮转影响。

提示:空密码、重复 UID、UID 为 0 的非 root 账号,这三类一旦命中,报告标题里就该带个醒目标记,别让它们混在几十行输出中间被扫过去。

5. cron 环境、邮件投递与阈值改造的实战细节

脚本能手动跑通和能进 crontab 稳定运行,中间隔着好几道坎。前面提过的 PATH 是第一道,邮件通道和阈值改造是后两道。

5.1 PATH 与 source /etc/profile 的真实差异

原脚本注释里写着“环境变量 PATH 没设好,在 cron 里执行时有很多命令会找不到”,然后同时做了两件事:export PATH=...source /etc/profile。实际上后者往往无效——/etc/profile开头常见一段[ "${-#*i}" != "$-" ] && return,非交互 shell 执行时直接返回,PATH 根本没被赋值。真正可靠的是显式导出:

# 覆盖 cron 的精简 PATH,把 sbin 目录补全 export PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin:/root/bin # 需要 JAVA_HOME 等变量的,显式 source 具体文件而不是 profile [ -f /etc/profile.d/jdk.sh ] && . /etc/profile.d/jdk.sh

另外要注意 cron 会把脚本的所有标准输出当作邮件正文再发一份。脚本内部采集时最好把中间输出重定向掉,只在最后一次性写文件和发信,否则收到两封邮件,一封是报告,一封是采集过程的杂散输出。

5.2 邮件投递通道的选择

发信命令本身简单:

# CentOS 7 上 mail 由 mailx 提供 mail -s "$(hostname)-巡检报告-$(date +%F)" ops@example.com < "$RESULTFILE"

难点在本机 MTA 的配置。最小方案是装 postfix 并启用本地投递,把外部投递交给上游;但多数云服务器默认封禁出站 25 端口,直连外部 MX 发不出去,得配 smtp relay 或者改用 msmtp 走 587/465 端口:

# msmtp 的最小配置,写到 ~/.msmtprc account default host smtp.example.com port 587 auth on tls on user ops@example.com password <授权码> from ops@example.com

发信内容里带主机名和日期,标题格式统一成主机名-报告类型-日期,收件端做规则过滤时才好按主题归档。

5.3 阈值判定与日志清理

报告是给人看的,但阈值可以给机器看。在生成 RESULTFILE 之后追加一段判断,命中异常就把主题前缀改成[WARN]

WARN="" # 磁盘使用率去掉百分号后做数值比较 disk_pct=${report_DiskUsedPercent%\%} [ "${disk_pct%.*}" -ge 85 ] && WARN="${WARN}[DISK]" inode_pct=${report_InodeUsedPercent%\%} [ "${inode_pct%.*}" -ge 85 ] && WARN="${WARN}[INODE]" [ -n "$report_USEREmptyPassword" ] && WARN="${WARN}[EMPTY_PWD]" # 有告警时改标题前缀,方便收件箱规则分流 subject="${WARN:-[OK]}$(hostname)-巡检报告-$(date +%F)"

${var%\%}是去掉尾部百分号,${var%.*}是去掉小数点后的部分取整,两步之后才能进-ge做数值比较,直接拿带%的字符串比会报语法错。报告文件本身也要清理,不然 log 目录一年下来就是几千个 txt:

# 只保留最近 30 天的巡检报告 find "$LOGPATH" -name 'HostDailyCheck-*.txt' -mtime +30 -delete

把生成时间戳和主机名拼进文件名,配合这条清理规则,日志目录就不会无限膨胀了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询