ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

279个Shell脚本开箱即用:分类预检、配置统一与cron落地

279个Shell脚本开箱即用:分类预检、配置统一与cron落地

简介:面向Linux运维、DevOps工程师及Shell脚本学习者的实用脚本工具集,2024年新版收录279个开箱即用案例,覆盖DDoS攻击防范与恶意IP自动封禁、Linux系统告警邮件、MySQL单库及多库多表备份、Nginx访问日志按天切割与流量分析、网卡实时流量查看等常见运维场景。脚本按功能场景组织,可直接用于日常巡检、安全防护、数据库备份与日志治理,帮助读者快速搭建自动化维护流程,积累命令组合、日志处理与故障排查思路。压缩包共1个PDF文件,大小约4.69MB,将脚本示例、关键命令与场景说明集中编排,便于按任务检索和对照实践。已有154人学习,适合需要提升服务器管理效率、补充脚本案例库的初中级运维人员、后端开发者及相关专业学生参考。

1. 279 个开箱即用 shell 脚本:它替你省掉的到底是哪一段工时

去年在一台对外提供接口的测试机上,我把这套「279 个开箱即用 shell 脚本」里的防攻击脚本直接跑了。十分钟后运维群里有人问:为什么办公室出口 IP 连不上测试环境了。脚本没错,它忠实地把访问频次超过阈值的 IP 塞进了 iptables,只是它不知道我们整个办公室走一个 NAT 出口。这件事之后我对「开箱即用」的理解变了——开的是箱,能不能用,取决于你对自己环境的了解程度。

这份合集覆盖的是运维日常里出现频率最高的一批场景:自动封禁异常 IP、Nginx 访问日志按天切割与 TOP 统计、MySQL 单库与分表备份、网卡实时流量、系统初始化加固、上百台机器磁盘巡检、/proc 进程状态统计、jstack 定时采集、企业微信告警、FTP 与 SSH 客户端封装。它适合已经能登服务器、会看日志,但每次写 awk 都要现场翻资料的人;也适合想把零散操作沉淀成 cron 任务的小团队。下面按「怎么落地」的顺序拆。

2. 摸清目录与运行基线:279 个文件里真正能直接用的不到三成

合集是网络收集整理而成,文件名和目录结构没有统一规范,有的脚本叫1.sh,有的叫nginx_log_cut.sh,还有一部分是同一个功能的多个版本(比如 MySQL 备份就有单循环和多循环两份)。这意味着你没法「全部解压然后逐个跑」,必须先把它们按副作用分类,再挑出自己真正需要的十几个改透。这一步花一两个小时,能省掉后面几天的排障。我一般的做法是先建一个空目录,把压缩包解到~/shell-lab,所有实验都在这个目录里做,确认没问题再往/usr/local/sbin或/etc/opsscripts挪。

2.1 按副作用分两刀:只读脚本和动系统的脚本

分类的标准只有一个——这个脚本执行完之后,系统的哪个部分被改变了。只读脚本随便跑,大不了输出难看;动系统的脚本一旦跑错,轻则日志断档,重则机器登不上。所以第一刀必须切在这里,而不是按功能切。

类别典型脚本是否需要 root首次执行前必做
只读分析访问日志 TOP IP/URL、网卡实时流量、/proc 进程统计否确认日志格式与网卡名
定时采集jstack 定时抓取、磁盘巡检、GC 日志统计视情况确认保留策略与磁盘余量
落盘备份MySQL 单库备份、MySQL 分表备份、大文件迁移否确认目标目录容量与校验方式
改系统状态初始化加固、自动封禁 IP、禁用 root 远程登录是打快照 + 保留一个已登录会话
外部调用企业微信告警、FTP 客户端、SSH 客户端否确认出口连通性与凭据存放位置

把「改系统状态」这一类单独挑出来,可以先用一条 grep 做粗筛:

# 在解压目录里找出所有可能改动系统状态的脚本,重定向到一个清单 grep -rlE 'iptables|firewall-cmd|systemctl|sed[[:space:]]+-i|rm[[:space:]]+-rf|kill[[:space:]]+-9|>>?[[:space:]]*/etc/' \ . --include='*.sh' | sort | tee dangerous.txt # 统计一下有多少个,心里有个数 wc -l < dangerous.txt

这里的正则不是万能的,它只能抓到显式写出来的危险动作。像「系统初始化脚本」那种把cat >> /etc/sysctl.conf和sed -i混在一起、还顺手关掉防火墙的,通篇读一遍比 grep 靠谱。dangerous.txt的作用是给你一个审阅顺序,不是安全边界。真正要上生产前,我习惯把清单里的脚本逐个读完,重点看三处:有没有rm -rf后面跟变量、有没有直接改/etc/ssh/sshd_config、有没有把防火墙整个关掉。

2.2 运行基线核对:awk 实现、bash 版本、cron 的 PATH

合集里的脚本大量依赖awk和 bash 的数组语法。问题在于不同发行版默认的 awk 不是同一个实现:Debian/Ubuntu 上很多时候是mawk,它不支持gensub()、strtonum()这类 gawk 扩展;而合集里不少统计脚本用了asort、length(a)之类的写法,在 mawk 下要么报错,要么静默给出空结果。静默失败比报错更麻烦,因为 cron 里你看不到任何输出。

第二处基线是 bash 版本。脚本头部写着#!/bin/bash,但如果用sh script.sh或者放进 cron 时被/bin/sh解释,关联数组、[[ ]]、<<<这些语法会直接失效。cron 的环境比登录 shell 干净得多,PATH 通常只有/usr/bin:/bin,iptables在/usr/sbin下就找不到。这两点合起来,构成了「手工执行没问题、cron 跑就没反应」这类问题的绝大多数原因。

第三处是外部命令版本。MySQL 备份脚本要求mysqldump客户端版本不低于服务端主版本,否则可能报Unknown table COLUMN_STATISTICS之类的错误;封 IP 脚本在只用 nftables 的新系统上找不到iptables命令。

#!/usr/bin/env bash # preflight.sh —— 执行任何脚本前先跑一遍,缺什么补什么 set -uo pipefail for c in bash awk sed grep find xargs ssh crontab gzip; do command -v "$c" >/dev/null 2>&1 || echo "[缺失] $c" done # awk 实现差异:输出第一行就能看出是 gawk 还是 mawk awk -W version 2>&1 | head -1 echo "bash 版本: ${BASH_VERSION}" # 只在需要封 IP 的机器上查 if command -v iptables >/dev/null 2>&1; then iptables -S INPUT 2>/dev/null | head -5 else echo "[提示] 未找到 iptables,封禁类脚本需要换成 nft 或 ipset 方案" fi # 备份机上看客户端版本是否和服务端匹配 command -v mysqldump >/dev/null 2>&1 && mysqldump --version # cron 的真实环境,用它来判断脚本在定时任务里能不能找到命令 env -i PATH=/usr/bin:/bin bash -c 'command -v awk; command -v iptables || echo "cron 环境找不到 iptables"'

set -uo pipefail在这里只开未定义变量和管道错误的检查,故意没开-e——预检脚本本来就要把缺失项全部列出来,中途退出反而看不全。最后一行用env -i模拟 cron 的最小环境,这是我最常用的自检手段:很多「手工能跑、定时不跑」的问题,在这一步就现形了。awk -W version在 mawk 下也能输出,但格式不同,看到mawk字样就要留意脚本里有没有 gawk 专有函数。

2.3 统一改造:把 host、密码、路径抽到一个 conf 里

合集里几乎每个脚本都把配置硬编码在开头,HOST=localhost、PASS=123.com、BACKUP_DIR=/data/db_backup这种写法到处都是。这在演示里没问题,但在真实环境里有三个直接后果:改一台机器要改十几个文件、密码散落在各处、脚本没法进版本库。

我一般会建一个/etc/opsscripts/common.conf存配置,再写一个common.sh被所有脚本source。权限上,common.conf设成chmod 600并且属主是 root,业务脚本用非 root 跑的时候通过 sudo 白名单调用。

#!/usr/bin/env bash # common.sh —— 所有脚本统一 source 这个文件,改配置只改一处 set -euo pipefail # cron 里 PATH 不完整,显式补齐,避免 iptables、mysqldump 找不到 export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CONF_DIR=${CONF_DIR:-/etc/opsscripts} # shellcheck source=/dev/null source "$CONF_DIR/common.conf" # 统一日志格式,方便后面 grep 和轮转 log() { local level=$1; shift printf '%s [%s] %s\n' "$(date +'%F %T')" "$level" "$*" >> "${LOG_FILE:-/var/log/opsscripts.log}" }
# common.conf —— 权限 600,属主 root NGINX_LOG_DIR=/var/log/nginx NGINX_PID=/run/nginx.pid BACKUP_DIR=/data/db_backup KEEP_DAYS=7 DISK_THRESHOLD=80 LOG_FILE=/var/log/opsscripts.log ALERT_MAIL=ops@example.com

set -euo pipefail的含义要拆开看:-e让命令返回非零就退出,-u让引用未定义变量直接报错,-o pipefail让管道中任意一段失败都算整条失败。第三点对备份脚本尤其关键,因为mysqldump ... | gzip > x.sql.gz这种写法,默认只取 gzip 的退出码,mysqldump 失败了也看不出来。

改造的时候不要贪多。279 个脚本里,日常真正用得上的大概十五到二十个:日志切割、日志 TOP 统计、MySQL 单库备份、磁盘巡检、进程状态统计、网卡流量、系统初始化、告警。剩下的留在归档目录当参考实现,需要的时候翻出来抄一段就行。把常用的那几个改到「换一台机器只改 conf」的程度,比把 279 个全部改一遍有价值得多。

3. 日志与流量类脚本:awk 一行流怎么改成能挂 cron 的版本

合集里的日志类脚本是使用频率最高的一批,也最容易出问题。原因在于它们大多是从一条命令行演化来的:有人在终端里敲了一行awk,效果不错,就直接存成了.sh。这种脚本在交互式终端里跑没问题,一旦变成定时任务,路径、时区、字段位置、文件句柄这些隐式假设全都会暴露。

3.1 Nginx 日志切割:mv 和 kill -USR1 的顺序不能反

Nginx 打开日志文件后会一直持有这个文件的句柄。mv只是改了目录项的名字,进程手里的句柄还指向原来那个 inode,所以 nginx 会继续往被移走的旧文件里写。kill -USR1的语义是「重新打开日志文件」,nginx 收到信号后关闭旧句柄、按配置里的路径重新open()一次。

顺序必须是先 mv、后发信号。反过来的话,nginx 重新打开的仍然是那个还没被移走的旧文件,你紧接着 mv 走它,nginx 又继续往被移走的那份写——切割看起来做了,实际新文件一直是空的。这个坑我在两三个环境里都见过,日志目录里躺着一堆 0 字节的access.log。

#!/usr/bin/env bash # nginx_cut.sh —— 按天切割 nginx 日志,保留原文件名 set -euo pipefail source /etc/opsscripts/common.sh # 关键:月份目录要用「昨天」算。每月 1 号切割时,昨天属于上个月 YESTERDAY=$(date -d "yesterday" +%F) MONTH_DIR="$NGINX_LOG_DIR/$(date -d "yesterday" +%Y-%m)" mkdir -p "$MONTH_DIR" for f in access.log error.log; do [[ -f "$NGINX_LOG_DIR/$f" ]] || continue # 先移走,nginx 手里的句柄仍指向旧 inode,必须靠 USR1 才会重新打开 mv "$NGINX_LOG_DIR/$f" "$MONTH_DIR/${f}.${YESTERDAY}" done # USR1 = 重新打开日志文件,不要用 HUP,HUP 是重新加载配置 kill -USR1 "$(cat "$NGINX_PID")"

三个参数值得单独说。date -d "yesterday"依赖系统的时区设置,如果服务器是 UTC 而业务按东八区算,切割出来的文件名会差一天,排查时对不上。NGINX_PID在 CentOS 上通常是/var/run/nginx.pid,Ubuntu 的包安装版常见/run/nginx.pid,容器里可能压根没有这个文件——容器场景更推荐在 entrypoint 里用nginx -s reopen。最后,kill -USR1发完不会等你确认,新文件是 nginx 收到信号后才创建的,紧接着跑ls可能看不到。要验证的话加一句sleep 1再检查文件大小。

如果系统里有 logrotate,其实不必自己写这个脚本。两者的差别在配置复杂度上:

方案优点代价
自写脚本 + cron逻辑完全可控,能顺带做归档和上报需要自己处理跨月目录、异常退出、文件句柄
logrotate内置copytruncate/create模式、压缩、保留份数配置分散在/etc/logrotate.d,排障要看多个文件

我的习惯是:单机两三份日志用脚本,机器多了统一交给 logrotate,脚本只负责切割后的归档上报。混合用的时候注意别让两套机制同时作用于同一个文件,否则会出现一天切两次、目录里一堆空文件的情况。

3.2 TOP IP / TOP URL / 状态码统计:字段位置是怎么定下来的

合集里的日志分析脚本基本都基于 nginx 默认的combined格式:

$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent "$http_referer" "$http_user_agent"

按空格切分后,$1是客户端 IP,$4是带方括号的时间,$7是请求行(含方法、路径、协议),$9是状态码,$10是响应字节数。这套字段位置是所有写法的前提,一旦你在nginx.conf里加了自定义字段(比如$request_time、$upstream_addr),后面的位置就会整体后移,脚本全部失效。所以分析脚本的第一行注释里我一般会写明「依据的 log_format 字段顺序」,方便后面接手的人核对。

#!/usr/bin/env bash # ngx_top.sh <access.log> —— 三个最常用的统计口径 set -euo pipefail LOG_FILE=${1:?用法: ngx_top.sh /var/log/nginx/access.log} echo "== TOP 10 客户端 IP ==" awk '{c[$1]++} END{for(i in c) printf "%8d %s\n", c[i], i}' "$LOG_FILE" \ | sort -k1,1nr | head -10 echo "== TOP 10 请求路径(只统计 4xx/5xx)==" awk '$9 ~ /^[45]/ {c[$7]++} END{for(i in c) printf "%8d %s\n", c[i], i}' "$LOG_FILE" \ | sort -k1,1nr | head -10 echo "== 状态码分布 ==" awk '{c[$9]++} END{for(i in c) printf "%8d %s\n", c[i], i}' "$LOG_FILE" \ | sort -k2,2 echo "== 指定时间段内的请求数(格式必须与 $time_local 一致)==" awk '$4 >= "[10/Mar/2024:00:00:00" && $4 <= "[10/Mar/2024:23:59:59" {n++} END{print n+0}' "$LOG_FILE"

sort -k1,1nr里的-k1,1限定只按第一列排序,不加这个限定的话,行数相同时会拿整行比较,结果看起来会很怪。head之前必须排完序,awk输出的顺序是哈希遍历顺序,不排序直接 head 拿到的不是 TOP。时间段过滤靠的是字符串比较,前提是时间格式里的月份缩写和位宽固定——[10/Mar/2024:00:00:00这种格式按字典序排恰好等于按时间排,因为日、月、年的位宽一致。如果日志里出现[1/Mar/2024这种单位数日期,比较就会错,这时候要把$4拆出来先规范化。

还有一个常见误用:用length(a)当作 UV。awk '{a[$1]++} END{print length(a)}'得到的是「不同 IP 的个数」,不是独立访客数。同一个用户换网络、整个公司共用一个出口 IP,这两种情况它都算不准。真实 UV 一般靠 cookie 或者前端埋点,日志里近似的做法是把 IP 和 User-Agent 拼起来去重,a[$1"|"$12]++这类写法,但也只是近似。

3.3 网卡流量与端口连接数:/proc/net/dev 的两列和 netstat 的字段漂移

/proc/net/dev是内核暴露的网卡计数器,每行一个接口,格式是「接口名: 接收 16 个字段 发送 16 个字段」。用 awk 的默认分隔符(连续空白)切分后,$1是带冒号的接口名,$2是累计接收字节数,$10是累计发送字节数。这两个数是单调递增的累计值,所以脚本必须自己算差分:取一次快照,sleep一段时间,再取一次,两个值相减除以间隔。

合集里的版本有个小问题——它用整数除法$(( ($NEW_IN - $OLD_IN) / 1024 )),间隔 1 秒的情况下,低于 1KB/s 的流量会显示成 0,跑在低流量机器上看着像脚本挂了。另一个隐患是awk '$0~"'$NIC'"'这种拼接写法,如果接口名是eth0,机器上同时存在eth0和eth0.100就会两行都匹配上,取到的值随机。

#!/usr/bin/env bash # nic_mon.sh <iface> [interval] —— 实时看某个网卡的进出流量 set -euo pipefail NIC=${1:?用法: nic_mon.sh eth0 [1]} INTERVAL=${2:-1} snapshot() { # 用默认分隔符,$1 是 "eth0:" 带冒号,$2 收字节,$10 发字节 awk -v n="$NIC:" '$1==n {print $2, $10}' /proc/net/dev } read -r last_rx last_tx < <(snapshot) [[ -n ${last_rx:-} ]] || { echo "接口 $NIC 不存在"; exit 1; } while true; do sleep "$INTERVAL" read -r rx tx < <(snapshot) # 网卡重启或模块重载会清零计数器,出现负值直接跳过这一轮 if (( rx < last_rx || tx < last_tx )); then last_rx=$rx; last_tx=$tx; continue fi printf '%s IN %8.1f KB/s OUT %8.1f KB/s\n' "$(date +%T)" \ "$(awk -v a="$rx" -v b="$last_rx" -v i="$INTERVAL" 'BEGIN{printf "%.1f", (a-b)/i/1024}')" \ "$(awk -v a="$tx" -v b="$last_tx" -v i="$INTERVAL" 'BEGIN{printf "%.1f", (a-b)/i/1024}')" last_rx=$rx; last_tx=$tx done

用awk '$1==n'精确匹配带冒号的接口名,避开了eth0匹配到eth0.100的问题。差分用 awk 来算而不是 bash 算术,是为了保留小数——bash 的$(( ))只做整数运算。计数器回绕或清零那段判断不是可有可无的:虚拟机热迁移、网卡 down/up、容器重启都会让计数从 0 开始,不做保护的话会打出一个几 TB/s 的负数。

同一章里的「端口请求数 TOP 20」脚本问题更大。合集版本写的是 `netstat -ant | awk -F'

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进