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脚本的几个原则:

  1. 开头加 set -euo pipefail,早发现问题
  2. 变量一律用引号包,避免空格和空值问题
  3. 用双括号 [[]] 做条件判断
  4. 函数内变量用 local
  5. 写注释,一个月后你自己都看不懂
  6. 加日志,出问题好排查

Bash不是银弹,超过200行就该考虑Python了。但对于日常运维的小工具,Bash足够好用。


有问题评论区聊,我尽量回。


posted @ 2025-12-29 20:16  花宝宝  阅读(1)  评论(0)    收藏  举报