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

浙公网安备 33010602011771号