写了十年运维脚本,最深的体会是:Bash不难,难的是写出不坑人的脚本。
见过太多"能跑"但一改就崩的脚本,也踩过不少自己挖的坑。这篇把我积累的经验整理出来,都是血泪教训。
为什么还要学Bash
有人说现在都用Python了,Bash还有必要学吗?
我的看法是:轻量任务用Bash,复杂逻辑用Python。
部署脚本、日志清理、批量操作这些,几十行Bash搞定的事,没必要起个Python环境。而且很多时候服务器上就只有Bash,你不得不用。
脚本开头:别省这几行
#!/bin/bashset-euo pipefail# 脚本说明# 作者:xxx# 日期:2024-12-29# 用途:xxxset -euo pipefail这行很重要:
-e:命令失败立即退出,不会继续执行后面的-u:使用未定义变量报错,避免typo导致的问题-o pipefail:管道中任一命令失败,整个管道返回失败
没加这个的脚本,经常是前面出错了,后面还在跑,最后一看结果全乱了。
# 反面例子:没有 set -ecd/data/backup# 这个目录不存在rm-rf *# 灾难发生...变量:引号是个大坑
永远用双引号包裹变量,这是血泪教训。
# 错误写法file_path=/data/my file.txtrm$file_path# 实际执行: rm /data/my file.txt (删了两个文件!)# 正确写法file_path="/data/my file.txt"rm"$file_path"还有一个常见问题:
# 变量为空时的坑if[$name="admin"];then# name为空时语法错误echo"hi admin"fi# 正确写法if["$name"="admin"];thenecho"hi admin"fi# 更推荐用双括号if[["$name"=="admin"]];thenecho"hi admin"fi字符串操作:不用awk也能干
# 获取文件名path="/data/logs/app.log"filename="${path##*/}"# app.logdirname="${path%/*}"# /data/logs# 字符串替换str="hello world world"echo"${str/world/bash}"# hello bash world (只替换第一个)echo"${str//world/bash}"# hello bash bash (替换所有)# 提取子串str="hello world"echo"${str:0:5}"# hello (从0开始取5个)echo"${str:6}"# world (从6开始到结尾)# 字符串长度echo"${#str}"# 11# 默认值echo"${name:-default}"# name为空用defaultecho"${name:=default}"# name为空用default,并赋值给name这些操作比调用外部命令快很多,处理大量数据时差距明显。
数组:批量操作的基础
# 定义数组servers=("192.168.1.1""192.168.1.2""192.168.1.3")# 遍历forserverin"${servers[@]}";doecho"检查$server"ping-c1"$server"&>/dev/null&&echo"OK"||echo"FAIL"done# 数组长度echo"共${#servers[@]}台服务器"# 添加元素servers+=("192.168.1.4")# 取特定元素echo"第一台:${servers[0]}"# 取所有索引foriin"${!servers[@]}";doecho"索引$i:${servers[$i]}"done实际应用:批量部署
#!/bin/bashset-euo pipefailservers=("web1""web2""web3")package="app-v2.0.tar.gz"forserverin"${servers[@]}";doecho"=== 部署到$server==="scp"$package""$server:/tmp/"ssh"$server""cd /tmp && tar xzf$package&& ./install.sh"echo"===$server完成 ==="done条件判断:方括号的玄学
Bash的条件判断语法挺乱的,我整理个对照表:
# 字符串比较[["$a"=="$b"]]# 相等[["$a"!="$b"]]# 不等[[-z"$a"]]# 为空[[-n"$a"]]# 不为空# 数值比较[["$a"-eq"$b"]]# 等于[["$a"-ne"$b"]]# 不等于[["$a"-gt"$b"]]# 大于[["$a"-lt"$b"]]# 小于[["$a"-ge"$b"]]# 大于等于[["$a"-le"$b"]]# 小于等于# 或者用双括号做算术比较((a>b))((a==b))# 文件判断[[-f"$file"]]# 是普通文件[[-d"$dir"]]# 是目录[[-e"$path"]]# 存在[[-r"$file"]]# 可读[[-w"$file"]]# 可写[[-x"$file"]]# 可执行[[-s"$file"]]# 文件大小>0# 逻辑运算[[$a&&$b]]# 与[[$a||$b]]# 或[[!$a]]# 非为什么推荐双括号[[]]而不是单括号[]:
# 单括号的坑name=""[$name="admin"]# 语法错误:[ = "admin" ]# 双括号没问题[[$name=="admin"]]# 正常工作# 单括号要转义["$a"\>"$b"]# 字符串比较大于# 双括号不用[["$a">"$b"]]函数:写可复用的代码
# 基本写法log(){locallevel="$1"localmessage="$2"echo"[$(date'+%Y-%m-%d %H:%M:%S')] [$level]$message"}log"INFO""脚本启动"log"ERROR""出错了"# 返回值check_service(){localservice="$1"systemctl is-active"$service"&>/dev/nullreturn$?# 返回上个命令的退出码}ifcheck_service nginx;thenecho"nginx 正在运行"elseecho"nginx 未运行"fi# 返回字符串(通过echo)get_ip(){hostname-I|awk'{print $1}'}my_ip=$(get_ip)echo"本机IP:$my_ip"注意local关键字,函数内的变量如果不加local,会变成全局变量,很容易出问题:
# 坑count=10add_count(){count=20# 修改了全局变量!}add_countecho$count# 20,不是10# 正确做法add_count(){localcount=20# 局部变量}参数处理:让脚本更专业
简单参数用位置变量:
#!/bin/bash# usage: ./deploy.sh <env> <version>env="${1:-prod}"# 第一个参数,默认prodversion="${2:-latest}"# 第二个参数,默认latestecho"部署$version到$env环境"复杂参数用getopts:
#!/bin/bashset-euo pipefailusage(){cat<<EOF 用法:$0[选项] 选项: -e, --env ENV 环境 (prod/test) -v, --version VER 版本号 -f, --force 强制执行 -h, --help 帮助 EOFexit1}# 默认值env="prod"version="latest"force=false# 解析参数while[[$#-gt0]];docase"$1"in-e|--env)env="$2"shift2;;-v|--version)version="$2"shift2;;-f|--force)force=trueshift;;-h|--help)usage;;*)echo"未知参数:$1"usage;;esacdoneecho"环境:$env"echo"版本:$version"echo"强制:$force"错误处理:优雅地失败
#!/bin/bashset-euo pipefail# 清理函数cleanup(){localexit_code=$?echo"清理临时文件..."rm-rf"$tmp_dir"2>/dev/null||trueexit$exit_code}# 注册退出时执行trapcleanup EXIT# 错误处理error_handler(){echo"错误发生在第$1行"exit1}trap'error_handler $LINENO'ERR# 临时目录tmp_dir=$(mktemp -d)echo"临时目录:$tmp_dir"# 你的逻辑...trap是个好东西,常用信号:
EXIT:脚本退出时ERR:命令出错时INT:Ctrl+C时TERM:kill时
实战:日志清理脚本
#!/bin/bashset-euo pipefail# 配置LOG_DIR="/var/log/app"KEEP_DAYS=7MAX_SIZE_MB=100log(){echo"[$(date'+%Y-%m-%d %H:%M:%S')]$1"}# 删除N天前的日志clean_old_logs(){localcountcount=$(find"$LOG_DIR"-name"*.log"-mtime"+$KEEP_DAYS"|wc-l)if[[$count-gt0]];thenlog"删除$count个${KEEP_DAYS}天前的日志"find"$LOG_DIR"-name"*.log"-mtime"+$KEEP_DAYS"-deleteelselog"没有需要删除的旧日志"fi}# 压缩大日志compress_large_logs(){localmax_size=$((MAX_SIZE_MB*1024*1024))whileIFS=read-r -d''file;dolocalsizesize=$(stat-f%z"$file"2>/dev/null||stat-c%s"$file")if[[$size-gt$max_size]];thenlog"压缩大文件:$file($((size/1024/1024))MB)"gzip"$file"fidone<<(find"$LOG_DIR"-name"*.log"-print0)}# 主逻辑main(){log"=== 日志清理开始 ==="if[[!-d"$LOG_DIR"]];thenlog"目录不存在:$LOG_DIR"exit1ficlean_old_logs compress_large_logs log"=== 日志清理完成 ==="}main"$@"实战:服务健康检查
#!/bin/bashset-euo pipefail# 配置SERVICES=("nginx""mysql""redis")WEBHOOK_URL="https://your-webhook-url"CHECK_INTERVAL=60send_alert(){localmessage="$1"# 发送告警,根据实际情况对接企业微信/钉钉/飞书curl-s -X POST"$WEBHOOK_URL"\-H"Content-Type: application/json"\-d"{\"text\":\"$message\"}"&>/dev/null||true}check_service(){localservice="$1"ifsystemctl is-active"$service"&>/dev/null;thenreturn0elsereturn1fi}check_port(){localhost="$1"localport="$2"ifnc-z -w3"$host""$port"&>/dev/null;thenreturn0elsereturn1fi}main(){localfailed_services=()forservicein"${SERVICES[@]}";doif!check_service"$service";thenfailed_services+=("$service")fidoneif[[${#failed_services[@]}-gt0]];thenlocalmessage="[告警]$(hostname)服务异常:${failed_services[*]}"echo"$message"send_alert"$message"elseecho"所有服务正常"fi}# 单次检查或持续监控if[["${1:-}"=="--daemon"]];thenwhiletrue;domainsleep"$CHECK_INTERVAL"doneelsemainfi调试技巧
# 方法1:打印执行的每条命令bash-x script.sh# 方法2:在脚本里开启set-x# 开启调试# ... 你的代码 ...set+x# 关闭调试# 方法3:只调试一部分#!/bin/bashecho"正常输出"set-x problematic_functionset+xecho"继续正常"# 方法4:打印变量echo"DEBUG: var=$var">&2常见坑汇总
1. 空格问题
# 错误var="value"# 赋值等号两边不能有空格if[$a=$b]# 判断等号两边必须有空格# 正确var="value"if["$a"="$b"]2. 路径中的特殊字符
# 永远用引号包路径forfilein"$dir"/*;doprocess"$file"done3. 管道中的变量
# 坑:管道在子shell执行,变量改不了count=0catfile.txt|whilereadline;do((count++))doneecho$count# 还是0!# 正确:用进程替换count=0whilereadline;do((count++))done<file.txtecho$count# 正确的值4. 命令替换中的换行
# 换行会变成空格files=$(ls)echo"$files"# 保留换行echo$files# 变成一行总结
写Bash脚本的几个原则:
- 开头加
set -euo pipefail,早发现问题 - 变量一律用引号包,避免空格和空值问题
- 用双括号
[[]]做条件判断 - 函数内变量用
local - 写注释,一个月后你自己都看不懂
- 加日志,出问题好排查
Bash不是银弹,超过200行就该考虑Python了。但对于日常运维的小工具,Bash足够好用。
有问题评论区聊,我尽量回。