路线图

Shell 脚本

星辉 2026-07-02 阅读 9 min 1,899 字 路线图
Shell 脚本 封面

Linux 运维基础 · 第十四章 目标:掌握 Shell 脚本基础语法,能编写运维自动化脚本


1. 概述#

Shell 脚本是运维的"自动化语言"。把一系列命令串起来,加上条件、循环、函数,就成了一个可复用的工具。

本章从变量到函数,从条件判断到错误处理,覆盖编写日常运维脚本所需的核心语法。


2. 脚本基础#

2.1 Hello World#

bash
#!/bin/bash
# 第一行是 shebang,告诉系统用哪个解释器来执行

echo "Hello, World!"
bash
# 执行脚本
chmod +x script.sh               # 加执行权限
./script.sh                      # 执行

# 或直接
bash script.sh

2.2 Shebang 差异#

写法含义
#!/bin/bash用 bash 执行(最常用)
#!/bin/sh用系统默认 Shell(可能指向 dash,功能更少)
#!/usr/bin/env bash从 PATH 找 bash(可移植,但可能不安全)

运维推荐:用 #!/bin/bash,明确指定。


3. 变量#

3.1 定义和使用#

bash
# 定义:= 两边不能有空格
name="hello"
count=42

# 使用:$ 引用
echo $name                       # hello
echo ${name}                     # hello(推荐加 {},避免歧义)
echo "变量值: ${name}_suffix"    # 变量值: hello_suffix

# 只读变量
readonly PI=3.14
PI=3.15    # 报错

# 删除变量(不是删除值,是删除变量本身)
unset name

3.2 引号区别#

写法含义示例
'单引号'字面量,所有字符保持原样echo '$HOME'$HOME
"双引号"解释变量和转义符echo "$HOME"/home/user
`反引号`命令替换echo `date` → 输出当前日期
$()命令替换(推荐)echo $(date) → 同上
bash
# 引号典型场景
dir="/var/log"
echo "当前目录是 $dir"           # 推荐:双引号
echo '当前目录是 $dir'           # 会被当作字面量

# 命令替换
today=$(date +%Y%m%d)
files_count=$(ls | wc -l)
path=$(dirname $(realpath "$0")) # 获取脚本所在目录(嵌套命令替换可行)

3.3 特殊变量#

变量含义
$0脚本名
$1 ~ $n第 N 个参数
$#参数个数
$@所有参数(加引号后每个参数独立,几乎总是用这个
$*所有参数(加引号后拼成一个字符串,用 IFS 首字符分隔)
$?上一条命令的退出码(0 = 成功)
$$当前 Shell 的 PID
$!最后一个后台进程的 PID
$_上一条命令的最后一个参数
bash
#!/bin/bash
echo "脚本名: $0"
echo "参数 1: $1"
echo "参数个数: $#"
echo "所有参数: $@"
echo "上一个命令的退出码: $?"

"$@" vs "$*":只有加引号时才有区别,而且这个区别很致命

假设脚本收到两个参数 "a b""c"

写法展开结果说明
"$@""a b" "c"保持原样的 2 个参数,边界正确
"$*""a b c"拼成 1 个字符串(用 IFS 首字符连接,默认空格)
$@ / $*(不加引号)a b c都会按空格拆成 3 个,含空格的参数被拆散

结论:转发参数给其他命令永远用 "$@"(带引号)。例如 cp "$@" /backup/ 才能正确处理带空格的文件名;用 $@"$*" 都会出错。

3.4 参数展开:默认值与字符串处理#

bash
# 默认值 —— 写运维脚本极其常用(配合 set -u 防止未定义变量炸掉)
echo "${NAME:-guest}"          # NAME 未设或为空 → 用 guest(不改变 NAME)
echo "${PORT:=8080}"           # 未设或为空 → 赋值 8080 并使用(会改变 PORT)
echo "${CONFIG:?必须设置 CONFIG}"  # 未设或为空 → 打印错误并退出(参数校验神器)
echo "${DEBUG:+--verbose}"     # 已设置 → 取 --verbose,否则取空(开关型参数)

# 字符串长度与截取
str="/var/log/nginx/access.log"
echo "${#str}"                 # 长度
echo "${str:0:8}"              # 从第 0 位取 8 个字符 → /var/log

# 掐头去尾(路径/文件名处理,比调用 basename/dirname 更快)
echo "${str##*/}"              # 删最长的 */ 前缀 → access.log(= basename)
echo "${str%/*}"               # 删最短的 /* 后缀 → /var/log/nginx(= dirname)
echo "${str%.log}"             # 删后缀 .log → .../access

# 替换
path="a:b:c"
echo "${path/:/ }"             # 替换第一个 : → a b:c
echo "${path//:/ }"            # 替换所有 : → a b c

3.5 算术运算#

bash
# $(( )) 做算术求值(返回结果),(( )) 做算术命令(改变量/判断,无 $)
a=5; b=3
echo $(( a + b ))              # 8($(( )) 内不用写 $,也支持 * / % **)
sum=$(( a * b + 1 ))          # 16

(( a++ ))                      # 自增,等价 a=$((a+1))
(( total += 10 ))             # 累加

# (( )) 也能当条件(非零为真、零为假,注意和退出码相反)
if (( a > b && a < 100 )); then echo "在范围内"; fi

# 坑:Bash 只做整数运算,小数会报错 / 截断
echo $(( 7 / 2 ))             # 3(不是 3.5!)
echo "scale=2; 7/2" | bc      # 3.50 —— 要小数用 bc 或 awk
awk 'BEGIN{print 7/2}'        # 3.5

4. 数组#

bash
# 定义
fruits=("apple" "banana" "orange")
fruits[3]="grape"

# 访问
echo ${fruits[0]}                # apple
echo ${fruits[@]}                # 所有元素
echo ${#fruits[@]}               # 数组长度
echo ${!fruits[@]}               # 所有索引

# 遍历
for fruit in "${fruits[@]}"; do
    echo "水果: $fruit"
done

5. 条件判断#

5.1 if 语句#

bash
if [ condition ]; then
    # 条件成立
elif [ another_condition ]; then
    # 另一条件成立
else
    # 都不成立
fi

5.2 test / [ ] / [[ ]]#

bash
# test 命令和 [ ] 等价
# [[ ]] 是 bash 增强版,支持更多语法(推荐)

# 字符串判断
if [ "$a" = "$b" ]; then ... fi          # 字符串相等
if [ "$a" != "$b" ]; then ... fi         # 字符串不等
if [ -z "$a" ]; then ... fi              # 字符串为空
if [ -n "$a" ]; then ... fi              # 字符串非空

# 数字判断
if [ "$a" -eq "$b" ]; then ... fi        # 数字相等
if [ "$a" -ne "$b" ]; then ... fi        # 数字不等
if [ "$a" -gt "$b" ]; then ... fi        # 大于
if [ "$a" -lt "$b" ]; then ... fi        # 小于
if [ "$a" -ge "$b" ]; then ... fi        # 大于等于

# 文件判断
if [ -f "$file" ]; then ... fi           # 是普通文件
if [ -d "$dir" ]; then ... fi            # 是目录
if [ -e "$path" ]; then ... fi           # 存在
if [ -x "$file" ]; then ... fi           # 可执行
if [ -r "$file" ]; then ... fi           # 可读
if [ -w "$file" ]; then ... fi           # 可写
if [ -s "$file" ]; then ... fi           # 非空文件
if [ -L "$file" ]; then ... fi           # 是符号链接

# 逻辑运算
if [ "$a" -gt 10 ] && [ "$a" -lt 100 ]; then ... fi  # 且
if [ "$a" -gt 10 ] || [ "$a" -lt 0 ]; then ... fi    # 或
if ! [ -f "$file" ]; then ... fi                      # 非

# [[ ]] 增强写法(推荐)
if [[ $a -gt 10 && $a -lt 100 ]]; then ... fi
if [[ $str =~ ^[0-9]+$ ]]; then ... fi      # 正则匹配

5.3 case 分支#

bash
read -p "选择操作 (start|stop|restart): " action

case $action in
    start)
        echo "启动服务..."
        ;;
    stop)
        echo "停止服务..."
        ;;
    restart|reload)
        echo "重启服务..."
        ;;
    *)
        echo "未知操作: $action"
        exit 1
        ;;
esac

6. 循环#

6.1 for 循环#

bash
# 遍历列表
for i in 1 2 3 4 5; do
    echo "数字: $i"
done

# 遍历序列
for i in {1..10}; do
    echo $i
done

# C 风格
for ((i=0; i<10; i++)); do
    echo $i
done

# 遍历文件
for file in /var/log/*.log; do
    echo "处理: $file"
done

6.2 while 循环#

bash
# 计数循环
i=1
while [ $i -le 10 ]; do
    echo $i
    ((i++))
done

# 读文件(逐行处理)
while IFS= read -r line; do
    echo "行: $line"
done < /etc/passwd

# 无限循环 + break
while true; do
    ping -c 1 8.8.8.8 > /dev/null && break
    echo "等待网络..."
    sleep 5
done
echo "网络通了"

6.3 until 循环#

bash
# 条件为假时循环,为真时退出(while 的相反)
until ping -c 1 8.8.8.8 > /dev/null; do
    echo "等待网络..."
    sleep 5
done
echo "网络通了"

6.4 break / continue#

bash
for i in {1..10}; do
    if [ $i -eq 3 ]; then
        continue                    # 跳过 3
    fi
    if [ $i -eq 8 ]; then
        break                       # 到 8 停止
    fi
    echo $i                         # 输出:1 2 4 5 6 7
done

7. 函数#

7.1 定义和调用#

bash
# 定义
function greeting() {
    echo "Hello, $1"
}

# 或省略 function 关键字
greeting() {
    echo "Hello, $1"
}

# 调用
greeting "World"             # 输出: Hello, World

7.2 参数和返回值#

bash
add() {
    local a=$1              # local 表示局部变量
    local b=$2
    return $((a + b))       # return 只能返回 0-255,建议用 echo
}

# 正确做法:用 echo 返回结果
add_v2() {
    local a=$1
    local b=$2
    echo $((a + b))
}

result=$(add_v2 10 20)
echo "结果: $result"        # 结果: 30

8. 退出码和错误处理#

8.1 退出码#

bash
#!/bin/bash
# exit 0 = 成功,exit 1 = 失败(错误)
if [ $# -lt 1 ]; then
    echo "用法: $0 <文件名>"
    exit 1                   # 参数不足,返回失败
fi

# 检查上一条命令
cp source.txt dest.txt
if [ $? -ne 0 ]; then
    echo "复制失败"
    exit 1
fi

8.2 set 选项(错误处理)#

bash
# 运维脚本推荐开头
#!/bin/bash
set -euo pipefail

逐项拆解 set -euo pipefail(各管一件事)

选项作用不加它的坑
set -e (errexit)任一命令返回非 0 立即退出出错后还继续往下跑,酿成更大破坏
set -u (nounset)引用未定义变量直接报错退出rm -rf "$DIR/"DIR 拼错成空 → 变成 rm -rf /
set -o pipefail管道里任一环失败则整个管道失败cmd | tee log 中 cmd 挂了,退出码却看的是 tee(0),错误被吞
set -x (xtrace)执行前打印每条命令(调试用,别留生产——

set -e 的例外(重点,被坑过的人才懂)——以下情况命令失败不会触发退出:

bash
# 1) 出现在 if / while / until 条件里的命令(这是设计如此,否则没法判断)
if grep foo file; then ...; fi        # grep 没找到(退出码1)不会让脚本退出

# 2) 用 && / || 连接的命令,非最后一个
false && echo x                       # false 失败但不退出
cmd || true                           # 常用这招"忽略某条命令的失败"

# 3) 命令前加 ! 取反
! ping -c1 host                       # 不退出

# 4) 函数在上述"条件上下文"里被调用时,函数内部的 set -e 会失效(经典陷阱)
check() { grep foo file; ls /nope; }  # 若 check 用在 if check 中,内部 ls 失败也不退出

# 5) 只有【最后一条】命令的退出码算数:
foo=$(some_cmd)                       # some_cmd 失败会退出(赋值场景 -e 生效)
local foo=$(some_cmd)                 # 但加了 local!退出码变成 local 的(0),失败被吞→拆两行写

记法:set -e 只在"命令的返回值没被用来做判断"时才生效。想显式忽略某条命令的失败, 用 cmd || true;想在失败时自定义处理,用 cmd || { echo 出错; exit 1; }

8.3 trap(捕获信号)#

bash
#!/bin/bash
set -euo pipefail

cleanup() {
    echo "清理临时文件..."
    rm -f /tmp/myapp_*.tmp
}

# 正常退出、被中断、被终止时都执行 cleanup
trap cleanup EXIT
trap 'echo "被 Ctrl+C 中断"' SIGINT

# 业务逻辑
echo "PID: $$,按 Ctrl+C 退出"
sleep 100

9. 实战#

实战 1:服务健康检查 + 告警#

bash
#!/bin/bash
# 检查服务是否运行,不运行则发告警

set -euo pipefail

SERVICE="nginx"
WEBHOOK_URL="https://hooks.dingtalk.com/xxx"   # 钉钉机器人 webhook

check_service() {
    if ! systemctl is-active --quiet "$SERVICE"; then
        return 1
    fi
    return 0
}

send_alert() {
    local msg="$1"
    curl -s -X POST "$WEBHOOK_URL" \
        -H "Content-Type: application/json" \
        -d "{\"msgtype\":\"text\",\"text\":{\"content\":\"[告警] $(hostname): $msg\"}}"
}

# 主逻辑
if ! check_service; then
    ALERT_MSG="${SERVICE} 服务未运行,尝试重启..."
    echo "$ALERT_MSG"

    systemctl restart "$SERVICE" || true
    sleep 3

    if check_service; then
        echo "${SERVICE} 重启成功"
    else
        ALERT_MSG="${SERVICE} 重启失败,请立即处理!"
        echo "$ALERT_MSG"
        send_alert "$ALERT_MSG"
    fi
fi

实战 2:批量备份到远程机器#

bash
#!/bin/bash
# 批量备份指定目录,打包后 scp 到远程备份服务器

set -euo pipefail

REMOTE_USER="backup"
REMOTE_HOST="backup-server.internal"
REMOTE_PATH="/backup/daily"
LOCAL_DIRS=("/app/data" "/etc/nginx" "/etc/myapp")

DATE=$(date +%Y%m%d_%H%M%S)
TMP_DIR="/tmp/backup_${DATE}"
mkdir -p "$TMP_DIR"

# 逐个目录打包
for dir in "${LOCAL_DIRS[@]}"; do
    if [ ! -d "$dir" ]; then
        echo "跳过不存在的目录: $dir"
        continue
    fi
    
    dir_name=$(basename "$dir")
    tar_file="${TMP_DIR}/${dir_name}_${DATE}.tar.gz"
    
    echo "备份: $dir$tar_file"
    tar czf "$tar_file" -C "$(dirname "$dir")" "$dir_name"
done

# 发送到远程
echo "正在上传到 ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"
scp -r "${TMP_DIR}/"* "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PATH}/"

# 清理
rm -rf "$TMP_DIR"

echo "备份完成: ${DATE}"

实战 3:生产级脚本模板(getopts + 参数校验 + trap 清理)#

把前面的最佳实践拼成一个可直接抄用的骨架:set -euo pipefail + getopts 解析参数

  • 参数校验 + trap 保证清理 + 日志函数。
bash
#!/usr/bin/env bash
set -euo pipefail

# ---------- 元信息 ----------
SCRIPT_NAME=$(basename "$0")
readonly SCRIPT_NAME

# ---------- 日志函数 ----------
log()  { echo "[$(date '+%F %T')] $*"; }
err()  { echo "[$(date '+%F %T')] ERROR: $*" >&2; }
die()  { err "$*"; exit 1; }

# ---------- 用法 ----------
usage() {
    cat <<EOF
用法: $SCRIPT_NAME -s SOURCE -d DEST [-v] [-h]

  -s SOURCE   源目录(必填)
  -d DEST     目标目录(必填)
  -v          详细输出
  -h          显示帮助
EOF
    exit "${1:-0}"
}

# ---------- 清理(无论正常/异常退出都会执行)----------
WORKDIR=""
cleanup() {
    local code=$?
    [[ -n "$WORKDIR" && -d "$WORKDIR" ]] && rm -rf "$WORKDIR"
    (( code != 0 )) && err "脚本异常退出,退出码 $code"
    return $code
}
trap cleanup EXIT
trap 'die "被中断 (SIGINT/SIGTERM)"' INT TERM

# ---------- getopts 解析参数 ----------
# 冒号在最前 = 静默模式(自己处理错误);字母后带冒号 = 该选项需带值
SOURCE="" DEST="" VERBOSE=0
while getopts ":s:d:vh" opt; do
    case "$opt" in
        s) SOURCE="$OPTARG" ;;
        d) DEST="$OPTARG"   ;;
        v) VERBOSE=1        ;;
        h) usage 0          ;;
        :)  die "选项 -$OPTARG 需要参数" ;;   # 缺参数值
        \?) die "未知选项 -$OPTARG"      ;;   # 非法选项
    esac
done
shift $((OPTIND - 1))          # 移除已解析的选项,剩下的是位置参数

# ---------- 参数校验 ----------
[[ -n "$SOURCE" ]] || usage 1
[[ -n "$DEST"   ]] || usage 1
[[ -d "$SOURCE" ]] || die "源目录不存在: $SOURCE"
(( VERBOSE )) && set -x

# ---------- 主逻辑 ----------
WORKDIR=$(mktemp -d)           # 临时目录,退出时由 cleanup 删除
log "打包 $SOURCE$DEST"
tar czf "$WORKDIR/payload.tar.gz" -C "$SOURCE" .
mkdir -p "$DEST"
mv "$WORKDIR/payload.tar.gz" "$DEST/payload_$(date +%s).tar.gz"
log "完成"
bash
# 调用
./deploy.sh -s /app/data -d /backup -v
./deploy.sh -h

10. 调试技巧#

bash
# 方法 1:set -x(打印每条命令)
bash -x script.sh                    # 运行时就开调试

# 或在脚本内
set -x                               # 开启调试
# ... 代码 ...
set +x                               # 关闭调试

# 方法 2:检查语法(不执行)
bash -n script.sh                    # 只检查语法错误

# 方法 3:shellcheck(代码审查工具)
shellcheck script.sh                 # 需要安装

11. 小结#

知识点一句话记忆
Shebang#!/bin/bash 第一行
变量name=value 定义,${name} 引用,双引号内变量会展开
默认值${var:-默认} 取默认、${var:?报错} 校验必填
字符串${s##*/}=basename、${s%/*}=dirname、${s//a/b} 全替换
算术$(( )) 求值、(( )) 判断/自增;只支持整数,小数用 bc/awk
"$@" vs "$*"转发参数永远用带引号的 "$@",才能保住带空格的参数边界
条件if [[ ... ]]; then ... fi-f 文件 -d 目录 -eq 数字
循环for i in ... 遍历列表,while read line 逐行读文件
函数func() { ... },返回值用 echo 而不用 return
退出码0=成功,非 0=失败,$? 取上一条
setset -euo pipefail 逐项防坑;注意 set -e 在 if/&&/||/local 里不生效
getoptswhile getopts ":s:d:vh" opt 解析短选项,shift $((OPTIND-1)) 收尾
traptrap cleanup EXIT 确保脚本退出时清理

上一章:十三、服务管理 systemd 下一章:十五、日志与排查思路