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.sh2.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 name3.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首字符连接,默认空格)$@/$*(不加引号)abc都会按空格拆成 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 c3.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.54. 数组#
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"
done5. 条件判断#
5.1 if 语句#
bash
if [ condition ]; then
# 条件成立
elif [ another_condition ]; then
# 另一条件成立
else
# 都不成立
fi5.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
;;
esac6. 循环#
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"
done6.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
done7. 函数#
7.1 定义和调用#
bash
# 定义
function greeting() {
echo "Hello, $1"
}
# 或省略 function 关键字
greeting() {
echo "Hello, $1"
}
# 调用
greeting "World" # 输出: Hello, World7.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" # 结果: 308. 退出码和错误处理#
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
fi8.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 1009. 实战#
实战 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 -h10. 调试技巧#
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=失败,$? 取上一条 |
| set | set -euo pipefail 逐项防坑;注意 set -e 在 if/&&/||/local 里不生效 |
| getopts | while getopts ":s:d:vh" opt 解析短选项,shift $((OPTIND-1)) 收尾 |
| trap | trap cleanup EXIT 确保脚本退出时清理 |
上一章:十三、服务管理 systemd 下一章:十五、日志与排查思路