12553 字
63 分钟

Shell 脚本编程完全指南:从基础语法到实战自动化 | 2026 最新实践

你有没有遇到过这些场景:

  • 每次部署都要手动执行 20 条命令,漏一条就出问题
  • 日志文件几十万行,想提取关键信息却不知道怎么处理
  • 同事写了个 .sh 脚本一键搞定服务器配置,你却看不懂里面写了什么
  • 重复性任务每周做 5 次,每次手动操作 30 分钟

Shell 脚本是 Linux 自动化的基础——把重复的手动命令组合成可复用的脚本,一次编写,终身自动执行。它是服务器运维的必备技能,和 SSH、Vim、Tmux 一样基础。

本文从零开始,系统讲解 Shell 脚本编程的完整知识体系:

  • 变量、字符串、条件判断、循环
  • 数组、函数、输入输出
  • 正则表达式与文本处理三剑客(grep/sed/awk)
  • 进程管理与信号处理
  • 调试技巧与安全实践
  • 10 个实战脚本模板

1. Shell 脚本基础#

1.1 Bash vs Zsh vs Sh#

特性BashZshSh (POSIX)
全称Bourne Again ShellZ ShellBourne Shell
默认于大多数 LinuxmacOS(Catalina+)Alpine Linux / Docker
兼容性最广兼容 BashPOSIX 标准
数组✅ 索引+关联✅ 更强大❌ 仅位置参数
关联数组Bash 4.0+
自动补全基础强大
脚本兼容最通用兼容 Bash最精简
推荐用途运维脚本日常交互Docker/嵌入式

建议:写运维脚本用 Bash(兼容性最好),日常终端交互用 Zsh(体验更好)。本文以 Bash 为主。

1.2 Shebang#

脚本第一行 #!(读作 shebang / she-bang)指定解释器:

#!/usr/bin/env bash # 推荐:通过 env 查找 bash,可移植性最好
#!/bin/bash # 常见:硬编码路径
#!/bin/sh # POSIX Shell,最精简
#!/usr/bin/env zsh # Zsh
#!/usr/bin/env python3 # Python 脚本

为什么推荐 #!/usr/bin/env bash 不同系统 bash 安装路径可能不同(/bin/bash/usr/bin/bash/usr/local/bin/bash),env 会自动在 PATH 中查找。

1.3 第一个脚本#

#!/usr/bin/env bash
# 这是一个注释
echo "Hello, World!"
echo "当前用户: $USER"
echo "当前目录: $(pwd)"
echo "系统时间: $(date '+%Y-%m-%d %H:%M:%S')"

执行脚本的两种方式:

Terminal window
# 方式1:添加执行权限后直接运行
chmod +x hello.sh
./hello.sh
# 方式2:用解释器执行(不需要执行权限)
bash hello.sh

区别:方式1 需要 Shebang 和执行权限,方式2 直接用指定解释器。调试时常用方式2。

1.4 脚本规范模板#

deploy.sh
#!/usr/bin/env bash
#
# 功能描述: 一键部署应用
# 作者: nine
# 创建日期: 2026-07-06
# 使用方法: ./deploy.sh [环境名]
#
set -euo pipefail # 安全模式
IFS=$'\n\t' # 安全分词
# ============================================
# 配置变量
# ============================================
APP_NAME="myapp"
APP_DIR="/opt/${APP_NAME}"
LOG_FILE="/var/log/${APP_NAME}/deploy.log"
# ============================================
# 函数定义
# ============================================
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
# ============================================
# 主逻辑
# ============================================
main() {
log "开始部署 ${APP_NAME}..."
# ... 具体逻辑
log "部署完成"
}
main "$@"

2. 变量#

2.1 变量定义与使用#

Terminal window
# 基本赋值(等号两边不能有空格!)
name="九所长"
age=30
pi=3.14
# 使用变量($ 符号)
echo $name
echo "姓名: $name, 年龄: $age"
# 推荐用花括号(明确变量边界)
echo "${name}的博客"
echo "文件名: ${name}_report.txt"
# 命令替换(两种写法等价)
current_date=$(date +%Y%m%d) # 推荐写法
current_date=`date +%Y%m%d` # 旧写法,不推荐(不能嵌套)
echo "今天: ${current_date}"

三个常见错误

  1. name = "value" — 等号两边有空格,Bash 会把 name 当命令执行
  2. echo $name_report — Bash 认为变量名是 name_report,应该写 echo ${name}_report
  3. echo "$name 的博客" — 变量后跟中文,Bash 可能解析错误,应该写 echo "${name}的博客"

2.2 变量类型#

Bash 变量默认都是字符串,但可以做算术运算:

Terminal window
# 字符串
str="hello"
# 整数运算(用 $(( )) 或 let 或 expr)
a=10
b=3
echo $((a + b)) # 13 加法
echo $((a - b)) # 7 减法
echo $((a * b)) # 30 乘法
echo $((a / b)) # 3 除法(取整)
echo $((a % b)) # 1 取余
echo $((a ** b)) # 1000 幂运算
let "c = a + b" # let 方式
echo $c # 13
# 自增自减
count=0
((count++)) # count=1
((count++)) # count=2
((count += 5)) # count=7
echo $count # 7

2.3 特殊变量#

变量含义示例
$0脚本名./script.sh
$1~$9第1-9个参数$1 = 第一个参数
${10}第10个及以上参数需要花括号
$#参数个数3
$@所有参数(各自独立)"a" "b" "c"
$*所有参数(合为一个字符串)"a b c"
$?上一条命令退出码0 = 成功
$$当前脚本 PID12345
$!最近后台进程 PID12346
$_上一条命令的最后一个参数
#!/usr/bin/env bash
echo "脚本名: $0"
echo "参数个数: $#"
echo "第一个参数: $1"
echo "所有参数(@): $@"
echo "所有参数(*): $*"
echo "当前PID: $$"
echo "上一条命令退出码: $?"
# 运行: ./script.sh aaa bbb ccc
# 输出:
# 脚本名: ./script.sh
# 参数个数: 3
# 第一个参数: aaa
# 所有参数(@): aaa bbb ccc
# 所有参数(*): aaa bbb ccc
# 当前PID: 12345
# 上一条命令退出码: 0

$@ vs $* 的关键区别:

Terminal window
# 当用双引号包裹时,区别明显
for arg in "$@"; do # 每个参数独立迭代(推荐)
echo "参数: $arg"
done
for arg in "$*"; do # 所有参数作为一个整体
echo "参数: $arg"
done
# 运行: ./script.sh "a b" c
# $@ 版本输出:
# 参数: a b
# 参数: c
# $* 版本输出:
# 参数: a b c

最佳实践:遍历参数永远用 "$@"(带引号)。

2.4 环境变量#

Terminal window
# 导出为环境变量(子进程可继承)
export DATABASE_URL="postgresql://localhost:5432/mydb"
export API_KEY="secret-key-12345"
# 在 .bashrc / .zshrc 中设置永久环境变量
# echo 'export MY_VAR="value"' >> ~/.bashrc
# 查看所有环境变量
env
printenv
# 查看单个变量
echo $PATH
printenv HOME

2.5 变量默认值#

Terminal window
# ${var:-default} — var 为空时返回 default,不修改 var
echo "${PORT:-8080}" # 如果 PORT 未设置,输出 8080
# ${var:=default} — var 为空时赋值为 default 并返回
echo "${PORT:=8080}" # 如果 PORT 未设置,赋值为 8080 并输出
# ${var:+alternative} — var 非空时返回 alternative
echo "${DEBUG:+enabled}" # 如果 DEBUG 已设置,输出 enabled
# ${var:?error} — var 为空时报错并退出
echo "${API_KEY:?API_KEY is required}" # 如果未设置,报错退出

2.6 只读变量与删除变量#

Terminal window
# 只读变量(不能修改,不能删除)
readonly PI=3.14159
declare -r MAX_CONNECTIONS=100
# 尝试修改会报错
# PI=3 → bash: PI: readonly variable
# 删除变量(不能删除只读变量)
name="test"
unset name
echo "${name:-已删除}" # 输出: 已删除

3. 字符串操作#

3.1 引号规则#

Terminal window
# 单引号:完全字面量,不解析变量和转义
echo 'Hello $USER' # 输出: Hello $USER
echo '单引号中\n不换行' # 输出: 单引号中\n不换行
# 双引号:解析变量和命令替换,支持转义
echo "Hello $USER" # 输出: Hello nine
echo "今天是 $(date +%A)" # 输出: 今天是 Monday
echo "转义: \$USER" # 输出: 转义: $USER
# 无引号:会进行通配符展开(危险)
files=*.txt # 可能展开为文件列表
echo $USER # 可以工作但不安全

核心规则:变量引用永远加双引号 "$var",纯文本用单引号 'text'

3.2 字符串长度#

Terminal window
str="Hello World"
echo ${#str} # 11
# 中文长度(每个汉字算1个字符)
cn="你好世界"
echo ${#cn} # 4

3.3 子字符串截取#

Terminal window
str="Hello World"
# ${var:offset:length} — 从 offset 开始截取 length 个字符
echo ${str:0:5} # Hello
echo ${str:6:5} # World
echo ${str:6} # World(从第6位到末尾)
echo ${str: -5} # World(从倒数第5位到末尾,注意空格)

3.4 字符串替换#

Terminal window
str="hello world hello bash"
# ${var/old/new} — 替换第一个匹配
echo ${str/hello/HELLO} # HELLO world hello bash
# ${var//old/new} — 替换所有匹配
echo ${str//hello/HELLO} # HELLO world HELLO bash
# ${var/#old/new} — 仅替换行首匹配
echo ${str/#hello/HELLO} # HELLO world hello bash
# ${var/%old/new} — 仅替换行尾匹配
echo ${str/%bash/BASH} # hello world hello BASH

3.5 字符串删除(模式匹配)#

Terminal window
file="/usr/local/bin/python3"
# ${var#pattern} — 从头部删除最短匹配
echo ${file#*/} # usr/local/bin/python3
# ${var##pattern} — 从头部删除最长匹配
echo ${file##*/} # python3
# ${var%pattern} — 从尾部删除最短匹配
echo ${file%/*} # /usr/local/bin
# ${var%%pattern} — 从尾部删除最长匹配
echo ${file%%/*} # (空)

实用场景:

Terminal window
# 获取文件名
filename="/path/to/file.tar.gz"
echo "${filename##*/}" # file.tar.gz
# 获取扩展名
echo "${filename##*.}" # gz
# 获取不带扩展名的文件名
base="${filename##*/}" # file.tar.gz
echo "${base%.*}" # file.tar
# 获取目录路径
echo "${filename%/*}" # /path/to

3.6 大小写转换(Bash 4.0+)#

Terminal window
str="Hello World"
# 转大写
echo ${str^^} # HELLO WORLD
# 转小写
echo ${str,,} # hello world
# 首字母大写
echo ${str^} # Hello World(已是大写不变)
# 首字母小写
echo ${str,} # hello World

4. 条件判断#

4.1 if 语句#

Terminal window
# 基本结构
if [ condition ]; then
echo "条件成立"
fi
# if-else
if [ $age -ge 18 ]; then
echo "成年"
else
echo "未成年"
fi
# if-elif-else
if [ $score -ge 90 ]; then
echo "优秀"
elif [ $score -ge 80 ]; then
echo "良好"
elif [ $score -ge 60 ]; then
echo "及格"
else
echo "不及格"
fi

4.2 test 命令:[ ] vs [[ ]] vs (( ))#

Terminal window
# [ ] — POSIX 兼容,最通用
if [ "$var" = "hello" ]; then ... fi
# [[ ]] — Bash 扩展,支持模式匹配和逻辑运算,推荐使用
if [[ "$var" == h* ]]; then ... fi
# (( )) — 算术比较
if (( a > b )); then ... fi
特性[ ][[ ]](( ))
POSIX 兼容❌ Bash 特有❌ Bash 特有
字符串比较
数值比较-lt-lt> <
模式匹配==
正则匹配=~
逻辑与-a&&&&
逻辑或-o||||
变量需引号否(更安全)
推荐场景POSIX 脚本日常使用数值比较

4.3 文件测试#

Terminal window
# 存在性
[[ -e /path/to/file ]] # 文件存在(任意类型)
[[ -f /path/to/file ]] # 存在且是普通文件
[[ -d /path/to/dir ]] # 存在且是目录
[[ -s /path/to/file ]] # 存在且非空(大小 > 0)
[[ -L /path/to/link ]] # 存在且是符号链接
[[ -b /dev/sda ]] # 块设备
[[ -c /dev/tty ]] # 字符设备
[[ -p /path/to/pipe ]] # 命名管道(FIFO)
[[ -S /path/to/socket ]] # Socket 文件
# 权限
[[ -r file ]] # 可读
[[ -w file ]] # 可写
[[ -x file ]] # 可执行
[[ -u file ]] # 有 SUID 位
[[ -g file ]] # 有 SGID 位
[[ -k file ]] # 有 sticky 位
# 比较
[[ file1 -nt file2 ]] # file1 比 file2 新
[[ file1 -ot file2 ]] # file1 比 file2 旧
[[ file1 -ef file2 ]] # file1 和 file2 是同一文件(硬链接)
# 实战
if [[ -f /etc/nginx/nginx.conf ]]; then
echo "Nginx 配置文件存在"
elif [[ -d /etc/nginx ]]; then
echo "Nginx 目录存在但配置文件不存在"
else
echo "Nginx 未安装"
fi

4.4 字符串比较#

Terminal window
str1="hello"
str2="world"
# 相等
[[ "$str1" == "hello" ]] # true
[[ "$str1" = "hello" ]] # 同上(= 和 == 等价)
# 不等
[[ "$str1" != "world" ]] # true
# 长度
[[ -z "$str1" ]] # 为空(长度为0)
[[ -n "$str1" ]] # 非空(长度 > 0)
# 字典序比较
[[ "$str1" < "$str2" ]] # true(h < w)
[[ "$str1" > "$str2" ]] # false
# 模式匹配(通配符)
[[ "$str1" == h* ]] # true(以 h 开头)
[[ "$str1" != *o* ]] # false(包含 o)
# 正则匹配
[[ "$str1" =~ ^h[a-z]+$ ]] # true(h 开头,全小写字母)
email="user@example.com"
[[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] # true

4.5 数值比较#

Terminal window
a=10
b=20
# 使用 [[ ]] + 标志符(兼容 [ ] 语法)
[[ $a -eq $b ]] # 等于 (equal)
[[ $a -ne $b ]] # 不等 (not equal)
[[ $a -lt $b ]] # 小于 (less than)
[[ $a -le $b ]] # 小于等于 (less or equal)
[[ $a -gt $b ]] # 大于 (greater than)
[[ $a -ge $b ]] # 大于等于 (greater or equal)
# 使用 (( )) — 更直观的数学符号(推荐)
(( a == b )) # 等于
(( a != b )) # 不等
(( a < b )) # 小于
(( a <= b )) # 小于等于
(( a > b )) # 大于
(( a >= b )) # 大于等于
(( a > 0 && a < 100 )) # 组合条件
(( a % 2 == 0 )) # 偶数

4.6 逻辑组合#

Terminal window
# [[ ]] 中的逻辑运算
if [[ -f file.txt && -r file.txt ]]; then # AND
echo "文件存在且可读"
fi
if [[ -z "$var1" || -z "$var2" ]]; then # OR
echo "至少一个变量为空"
fi
# ! 取反
if [[ ! -f /tmp/lockfile ]]; then
echo "锁文件不存在,可以继续"
fi
# 命令的 && 和 ||
[[ -d /backup ]] && echo "备份目录存在"
[[ -d /backup ]] || mkdir -p /backup
# 等价于
if [[ ! -d /backup ]]; then
mkdir -p /backup
fi

4.7 case 语句#

Terminal window
case "$1" in
start)
echo "启动服务..."
systemctl start myapp
;;
stop)
echo "停止服务..."
systemctl stop myapp
;;
restart)
echo "重启服务..."
systemctl restart myapp
;;
status)
systemctl status myapp
;;
*)
echo "用法: $0 {start|stop|restart|status}"
exit 1
;;
esac

模式匹配:

Terminal window
case "$file" in
*.jpg|*.jpeg|*.png|*.gif) # 多模式用 | 分隔
echo "图片文件"
;;
*.mp4|*.avi|*.mkv)
echo "视频文件"
;;
*.txt|*.md)
echo "文本文件"
;;
*)
echo "未知类型: $file"
;;
esac

5. 循环#

5.1 for 循环#

Terminal window
# 基础列表
for fruit in apple banana cherry; do
echo "水果: $fruit"
done
# 遍历范围
for i in {1..5}; do
echo "数字: $i"
done
# 带步长(Bash 4.0+)
for i in {0..10..2}; do # 0, 2, 4, 6, 8, 10
echo "偶数: $i"
done
# C 风格 for 循环
for ((i=0; i<5; i++)); do
echo "索引: $i"
done
# 遍历命令输出
for file in $(ls *.txt); do
echo "处理: $file"
done
# 遍历参数
for arg in "$@"; do
echo "参数: $arg"
done
# 遍历数组
arr=(apple banana cherry)
for item in "${arr[@]}"; do
echo "数组元素: $item"
done
# 遍历文件行
while IFS= read -r line; do
echo "行: $line"
done < /etc/hosts

注意:遍历文件行时用 while read 而非 for,因为 for 会按空格分词,while read 按行读取。

5.2 while 循环#

Terminal window
# 基础
count=0
while (( count < 5 )); do
echo "计数: $count"
((count++))
done
# 读取用户输入
while true; do
read -p "继续吗?(y/n): " answer
case $answer in
[yY]*) break ;;
[nN]*) echo "退出"; exit 0 ;;
*) echo "请输入 y 或 n" ;;
esac
done
# 读取文件行
line_num=0
while IFS= read -r line; do
((line_num++))
echo "第 ${line_num} 行: $line"
done < input.txt

5.3 until 循环#

Terminal window
# until — 条件为假时循环(与 while 相反)
count=5
until (( count == 0 )); do
echo "倒计时: $count"
((count--))
sleep 1
done
echo "发射!"
# 等待服务启动
until curl -s http://localhost:8080/health > /dev/null 2>&1; do
echo "等待服务启动..."
sleep 2
done
echo "服务已启动"

5.4 select 菜单#

Terminal window
PS3="请选择操作: "
select option in "安装" "配置" "启动" "停止" "退出"; do
case $option in
安装) echo "正在安装..." ;;
配置) echo "正在配置..." ;;
启动) echo "正在启动..." ;;
停止) echo "正在停止..." ;;
退出) break ;;
*) echo "无效选项" ;;
esac
done

5.5 break 与 continue#

Terminal window
# break — 跳出整个循环
for i in {1..10}; do
if (( i == 5 )); then
echo "在 5 处跳出"
break
fi
echo "i = $i"
done
# continue — 跳过本次迭代
for i in {1..10}; do
if (( i % 2 == 0 )); then
continue # 跳过偶数
fi
echo "奇数: $i"
done
# break N — 跳出 N 层循环
for i in {1..3}; do
for j in {1..3}; do
if (( j == 2 )); then
break 2 # 跳出两层循环
fi
echo "i=$i j=$j"
done
done

6. 数组#

6.1 索引数组#

Terminal window
# 定义数组
fruits=("apple" "banana" "cherry" "date")
numbers=(1 2 3 4 5)
mixed=("hello" 42 "world" 3.14)
# 逐个添加
arr[0]="first"
arr[1]="second"
arr[5]="fifth" # 可以跳过索引
# 访问元素
echo ${fruits[0]} # apple(第一个元素,索引从0开始)
echo ${fruits[2]} # cherry
echo ${fruits[-1]} # date(最后一个元素,Bash 4.3+)
# 访问所有元素
echo ${fruits[@]} # apple banana cherry date
echo ${fruits[*]} # 同上
# 数组长度
echo ${#fruits[@]} # 4
echo ${#fruits[0]} # 5(第一个元素的字符长度)
# 获取所有索引
echo ${!fruits[@]} # 0 1 2 3
# 切片
echo ${fruits[@]:1:2} # banana cherry(从索引1开始取2个)

6.2 数组操作#

Terminal window
arr=("a" "b" "c" "d")
# 追加元素
arr+=("e")
arr+=("f" "g")
echo ${arr[@]} # a b c d e f g
# 修改元素
arr[0]="A"
echo ${arr[@]} # A b c d e f g
# 删除元素
unset arr[2]
echo ${arr[@]} # A b d e f g
echo ${!arr[@]} # 0 1 3 4 5 6(索引不连续)
# 删除整个数组
unset arr
echo ${arr[@]:-空} # 空
# 遍历数组
for item in "${arr[@]}"; do
echo "元素: $item"
done
# 带索引遍历
for index in "${!arr[@]}"; do
echo "[$index] = ${arr[$index]}"
done

6.3 关联数组(Bash 4.0+)#

Terminal window
# 声明关联数组
declare -A config
# 赋值
config[host]="localhost"
config[port]="8080"
config[user]="admin"
config[debug]="true"
# 也可以一次性赋值
declare -A colors=(
[red]="#FF0000"
[green]="#00FF00"
[blue]="#0000FF"
)
# 访问
echo ${config[host]} # localhost
echo ${colors[red]} # #FF0000
# 所有值
echo ${config[@]} # localhost 8080 admin true
# 所有键
echo ${!config[@]} # host port user debug
# 元素个数
echo ${#config[@]} # 4
# 遍历
for key in "${!config[@]}"; do
echo "$key = ${config[$key]}"
done
# 判断键是否存在
if [[ -v config[host] ]]; then
echo "host 键存在"
fi

6.4 从命令输出创建数组#

Terminal window
# 按行分割
mapfile -t lines < /etc/hosts
echo "共 ${#lines[@]} 行"
echo "第一行: ${lines[0]}"
# 按空格分割
files=($(ls *.txt))
echo "找到 ${#files[@]} 个 txt 文件"
# 从字符串分割
csv="a,b,c,d"
IFS=',' read -ra arr <<< "$csv"
echo "${arr[0]}" # a
echo "${arr[3]}" # d
echo ${#arr[@]} # 4

7. 函数#

7.1 函数定义与调用#

Terminal window
# 方式1:function 关键字
function greet() {
echo "Hello, $1!"
}
# 方式2:省略 function(POSIX 兼容)
greet() {
echo "Hello, $1!"
}
# 调用
greet "九所长" # Hello, 九所长!
greet "World" # Hello, World!

7.2 函数参数#

Terminal window
add() {
local a=$1
local b=$2
echo $((a + b))
}
result=$(add 3 5)
echo "3 + 5 = $result" # 3 + 5 = 8
# 参数个数
function show_args() {
echo "参数个数: $#"
echo "所有参数: $@"
echo "第1个: $1"
echo "第2个: $2"
echo "所有参数(循环):"
for arg in "$@"; do
echo " - $arg"
done
}
show_args aaa bbb ccc

7.3 返回值#

Terminal window
# 方式1:echo 输出(最常用,通过命令替换获取)
get_date() {
date '+%Y-%m-%d'
}
today=$(get_date)
echo "今天是 $today"
# 方式2:return 返回退出码(0-255)
is_even() {
local num=$1
if (( num % 2 == 0 )); then
return 0 # true
else
return 1 # false
fi
}
if is_even 4; then
echo "4 是偶数"
fi
# 方式3:全局变量(不推荐,但有时方便)
result=""
calculate() {
result=$(( $1 * $2 ))
}
calculate 6 7
echo "结果: $result" # 42
# 方式4:局部变量传回(引用传递)
# Bash 不支持引用传递,但可以用 nameref(Bash 4.3+)
set_value() {
local -n ref=$1 # nameref
ref="通过引用设置的值"
}
declare my_var=""
set_value my_var
echo "$my_var" # 通过引用设置的值

7.4 局部变量#

Terminal window
function process() {
local temp="局部变量" # local 关键字声明局部变量
local count=0
echo "内部: $temp"
# 局部变量不影响外部同名变量
local PATH="/custom/path:$PATH"
echo "内部 PATH: $PATH"
}
temp="全局变量"
process
echo "外部: $temp" # 全局变量(不受函数影响)

最佳实践:函数内所有变量都用 local 声明,避免污染全局命名空间。

7.5 函数库#

~/lib/utils.sh
#!/usr/bin/env bash
# 日志函数
log_info() { echo -e "\033[32m[INFO]\033[0m $*"; }
log_warn() { echo -e "\033[33m[WARN]\033[0m $*" >&2; }
log_error() { echo -e "\033[31m[ERROR]\033[0m $*" >&2; }
# 检查命令是否存在
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# 确认操作
confirm() {
local prompt="${1:-确认?}"
read -p "$prompt (y/n): " -r
[[ $REPLY =~ ^[Yy]$ ]]
}
# 获取公网 IP
get_public_ip() {
curl -s ifconfig.me
}
# 生成随机字符串
random_string() {
local length="${1:-16}"
tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c "$length"
}
# 在其他脚本中引入函数库
#!/usr/bin/env bash
source ~/lib/utils.sh # 或 . ~/lib/utils.sh
log_info "脚本启动"
if confirm "是否继续?"; then
log_info "公网 IP: $(get_public_ip)"
fi
log_warn "操作完成"

8. 输入与输出#

8.1 echo 与 printf#

Terminal window
# echo — 简单输出
echo "Hello" # 输出后换行
echo -n "不换行" # 不换行
echo -e "转义\n换行\t制表" # 解析转义字符
# printf — 格式化输出(更强大)
printf "姓名: %s\n" "九所长"
printf "年龄: %d\n" 30
printf "PI: %.2f\n" 3.14159
printf "%-10s | %5d | %8.2f\n" "Apple" 100 3.14
printf "%-10s | %5d | %8.2f\n" "Banana" 50 2.71
# 输出:
# Apple | 100 | 3.14
# Banana | 50 | 2.71
# printf 格式符
# %s 字符串
# %d 整数
# %f 浮点数
# %x 十六进制
# %o 八进制
# %-Ns 左对齐,宽度 N
# %Ns 右对齐,宽度 N
# %.Nf 小数位 N

8.2 read 读取输入#

Terminal window
# 基本读取
echo -n "请输入姓名: "
read name
echo "你好, $name"
# 单行提示(-p)
read -p "请输入年龄: " age
echo "年龄: $age"
# 限时输入(-t 秒数)
if read -t 5 -p "5秒内输入(否则使用默认): " input; then
echo "你输入了: $input"
else
echo "超时,使用默认值"
fi
# 密码输入(不回显,-s)
read -s -p "密码: " password
echo ""
echo "密码长度: ${#password}"
# 读取多个值
read -p "姓 名: " first_name last_name
echo "姓: $first_name, 名: $last_name"
# 读取到数组(-a)
read -a arr -p "输入多个值(空格分隔): "
echo "第一个: ${arr[0]}"
# 逐行读取文件
while IFS= read -r line; do
echo "处理: $line"
done < filename.txt
# 读取 CSV
while IFS=',' read -r col1 col2 col3; do
echo "列1=$col1 列2=$col2 列3=$col3"
done < data.csv

8.3 重定向#

Terminal window
# 输出重定向
echo "hello" > file.txt # 覆盖写入
echo "world" >> file.txt # 追加写入
# 输入重定向
while read line; do
echo "$line"
done < file.txt
# 标准错误重定向
ls /nonexistent 2> error.log
# 标准输出和标准错误都重定向
command > output.log 2>&1 # 传统写法
command &> output.log # Bash 4.0+ 简写
command >> output.log 2>&1 # 追加模式
# 丢弃输出
command > /dev/null 2>&1
# /dev/null 作为输入
< /dev/null # 空输入(常用于后台进程)
# 同时显示和保存输出(tee)
command | tee output.log # 屏幕+文件
command | tee -a output.log # 屏幕+追加文件
command 2>&1 | tee output.log # 含错误输出

8.4 Here 文档与 Here 字符串#

Terminal window
# Here 文档(多行输入)
cat << 'EOF'
这是一段多行文本
$USER 不会被展开(因为 EOF 用单引号)
EOF
cat << EOF
这会展开变量: $USER
当前目录: $(pwd)
EOF
# 写入文件
cat << 'EOF' > /etc/myapp/config.conf
server_host = localhost
server_port = 8080
debug_mode = false
EOF
# Here 字符串(单行输入)
grep "pattern" <<< "search in this string"
# 实战:将变量传给命令
text="hello world"
read first rest <<< "$text"
echo "$first" # hello
echo "$rest" # world

8.5 管道#

Terminal window
# 基本管道:前一个命令的输出作为后一个的输入
cat file.txt | grep "error" | sort | uniq -c | sort -rn | head -10
# 实战管道组合
# 1. 统计每个 IP 的访问次数
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10
# 2. 找出最大的 5 个文件
du -sh * | sort -rh | head -5
# 3. 批量重命名
ls *.JPG | while read f; do mv "$f" "${f%.JPG}.jpg"; done
# 进程替换(避免临时文件)
diff <(ls dir1) <(ls dir2) # 比较两个目录
comm <(sort file1) <(sort file2) # 集合运算

9. 正则表达式与文本处理#

9.1 正则表达式基础#

基础元字符:
. 匹配任意单个字符(除换行)
* 前一个字符出现 0 次或多次
+ 前一个字符出现 1 次或多次(需 -E 或 egrep)
? 前一个字符出现 0 次或 1 次
^ 行首
$ 行末
[abc] 字符集(a 或 b 或 c)
[^abc] 反向字符集(非 a/b/c)
[a-z] 范围
{m,n} 重复 m 到 n 次
| 或(需 -E)
() 分组(需 -E)
扩展元字符(ERE,需 grep -E 或 sed -E):
\d 数字(部分工具支持,否则用 [0-9])
\w 单词字符 [a-zA-Z0-9_]
\s 空白字符
\b 单词边界

9.2 grep — 文本搜索#

Terminal window
# 基本搜索
grep "error" logfile.txt
grep -i "error" logfile.txt # 忽略大小写
grep -v "debug" logfile.txt # 反向(不包含的行)
grep -n "error" logfile.txt # 显示行号
grep -c "error" logfile.txt # 只统计匹配行数
grep -r "pattern" /path/to/dir/ # 递归搜索目录
grep -l "pattern" *.txt # 只显示匹配的文件名
grep -L "pattern" *.txt # 只显示不匹配的文件名
# 扩展正则(-E 或 egrep)
grep -E "error|warning|critical" log.txt # 匹配多个词
grep -E "202[0-9]-[01][0-9]-[0-3][0-9]" log.txt # 匹配日期
grep -E "[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}" log.txt # 匹配 IP
# 常用组合
grep -rn "TODO" --include="*.py" . # 递归搜索 Python 文件中的 TODO
grep -E "^\s*function" script.sh # 找出所有函数定义
grep -A 3 "error" log.txt # 匹配行 + 后 3 行
grep -B 2 "error" log.txt # 匹配行 + 前 2 行
grep -C 3 "error" log.txt # 匹配行 + 前后各 3 行
# 实战
# 1. 提取 Nginx 日志中的 404 请求
grep ' 404 ' access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -10
# 2. 查找已 SSH 登录的 IP
grep "Accepted" /var/log/auth.log | grep -oE "([0-9]{1,3}\.){3}[0-9]{1,3}" | sort | uniq -c | sort -rn
# 3. 搜索代码中未使用的变量
grep -rn "var_name" --include="*.sh" .

9.3 sed — 流编辑器#

Terminal window
# 替换(最常用)
sed 's/old/new/' file.txt # 替换每行第一个
sed 's/old/new/g' file.txt # 替换每行所有
sed 's/old/new/3' file.txt # 替换每行第3个
sed -i 's/old/new/g' file.txt # 直接修改文件(in-place)
sed -i.bak 's/old/new/g' file.txt # 修改并备份原文件
# 多个替换
sed -e 's/foo/bar/g' -e 's/baz/qux/g' file.txt
sed 's/foo/bar/g; s/baz/qux/g' file.txt
# 行操作
sed '3d' file.txt # 删除第3行
sed '1,5d' file.txt # 删除第1-5行
sed '/^$/d' file.txt # 删除空行
sed '/pattern/d' file.txt # 删除匹配行
sed '3a\new line' file.txt # 在第3行后追加
sed '3i\new line' file.txt # 在第3行前插入
sed '2c\replaced' file.txt # 替换第2行
sed -n '5,10p' file.txt # 只打印第5-10行
sed -n '/pattern/p' file.txt # 只打印匹配行
# 使用扩展正则(-E)
sed -E 's/[0-9]+/NUM/g' file.txt # 数字替换为 NUM
sed -E 's/([A-Z])/\L\1/g' file.txt # 大写转小写
sed -E 's/\bword\b/WORD/g' file.txt # 精确匹配单词
# 实战
# 1. 配置文件批量修改
sed -i 's/port = 8080/port = 9090/g' config.ini
# 2. 删除 HTML 标签
sed -E 's/<[^>]+>//g' page.html
# 3. CSV 转为 TSV
sed 's/,/\t/g' data.csv
# 4. 每行行首加序号
sed = file.txt | sed 'N;s/\n/\t/'
# 5. 提取两个标记之间的内容
sed -n '/START/,/END/p' file.txt

9.4 awk — 文本分析#

Terminal window
# 基本结构:awk 'pattern { action }' file
# 默认按空格/Tab 分列,$1=第一列,$2=第二列,$0=整行,$NF=最后一列
# 打印列
awk '{print $1}' file.txt # 第一列
awk '{print $1, $3}' file.txt # 第一和第三列
awk '{print $NF}' file.txt # 最后一列
awk '{print $(NF-1)}' file.txt # 倒数第二列
awk '{print NR, $0}' file.txt # 行号 + 整行
# 指定分隔符
awk -F',' '{print $1, $2}' data.csv # CSV
awk -F: '{print $1, $7}' /etc/passwd # 冒号分隔
awk -F'\t' '{print $1}' data.tsv # Tab 分隔
# 条件过滤
awk '$3 > 100' data.txt # 第三列大于 100 的行
awk '$1 == "ERROR" {print $2}' log.txt # 第一列等于 ERROR
awk 'NR > 1' file.txt # 跳过第一行(表头)
awk 'NR >= 10 && NR <= 20' file.txt # 第10-20行
awk '/error/ {print $0}' log.txt # 包含 error 的行
# 内置变量
awk 'BEGIN{OFS="|"} {print $1, $2, $3}' file.txt # 输出分隔符
awk '{sum += $1} END {print sum}' numbers.txt # 求和
awk '{sum += $2; count++} END {print sum/count}' data.txt # 平均值
# 实战
# 1. Nginx 日志分析:统计 HTTP 状态码
awk '{print $9}' access.log | sort | uniq -c | sort -rn
# 2. 统计每个 URL 的访问次数和平均响应时间
awk '{url[$7]++; time[$7]+=$NF} END {for (u in url) printf "%s\t%d\t%.2f\n", u, url[u], time[u]/url[u]}' access.log
# 3. 找出最大的文件
ls -la | awk '$5 > 1000000 {print $9, $5}'
# 4. 计算内存使用百分比
free | awk '/Mem/ {printf "%.1f%%\n", $3/$2*100}'
# 5. 提取特定列并去重
awk -F',' '{print $1}' data.csv | sort -u

9.5 文本处理组合技#

Terminal window
# 1. 提取 JSON 中的字段(无 jq 时)
curl -s http://api.example.com/data | grep -oE '"name":"[^"]*"' | sed 's/"name":"//;s/"//'
# 2. 日志分析:每小时的错误数
grep "ERROR" app.log | awk '{print substr($2, 1, 2)}' | sort | uniq -c
# 3. 找出日志中最频繁的 10 个 IP
awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10
# 4. 将多行合并为一行(逗号分隔)
cat list.txt | tr '\n' ',' | sed 's/,$//'
# 5. 字符串反转
echo "hello" | rev
echo "hello" | awk '{for(i=length;i>0;i--) printf "%s", substr($0,i,1); print ""}'
# 6. 统计文本行数、单词数、字符数
wc file.txt # 行 词 字符
wc -l file.txt # 仅行数
wc -w file.txt # 仅词数
wc -c file.txt # 仅字符数
# 7. 去重并保持顺序
awk '!seen[$0]++' file.txt
# 8. 两个文件的差集
comm -23 <(sort file1) <(sort file2) # 在 file1 但不在 file2
comm -13 <(sort file1) <(sort file2) # 在 file2 但不在 file1
comm -12 <(sort file1) <(sort file2) # 交集

10. 进程管理#

10.1 后台运行#

Terminal window
# & 后台运行
sleep 60 &
echo "后台 PID: $!"
# nohup — 终端关闭后继续运行
nohup ./long_running_script.sh > output.log 2>&1 &
# disown — 从 job 列表移除(类似 nohup 但事后操作)
./task.sh &
disown %1
# setsid — 在新会话中运行
setsid ./daemon.sh > /dev/null 2>&1
# 查看后台任务
jobs # 当前 Shell 的后台任务
jobs -l # 含 PID

10.2 等待与控制#

Terminal window
# wait — 等待后台进程完成
sleep 5 &
sleep 3 &
wait # 等待所有后台任务
echo "全部完成"
wait $! # 等待指定 PID
echo "指定任务完成"
# 并行执行 + 等待
for host in server1 server2 server3; do
ssh "$host" "uptime" &
done
wait
echo "所有服务器检查完毕"
# 超时控制
timeout 30 curl http://slow-api.example.com
timeout 10 ./script.sh || echo "脚本超时"
# 控制并发数
max_parallel=3
running=0
for task in task1 task2 task3 task4 task5; do
process_task "$task" &
((running++))
if (( running >= max_parallel )); then
wait -n # 等待任意一个完成(Bash 4.3+)
((running--))
fi
done
wait

10.3 信号处理#

Terminal window
# 常见信号
# SIGINT (2) — Ctrl+C 中断
# SIGTERM (15) — 终止信号(默认 kill)
# SIGKILL (9) — 强制终止(不可捕获)
# SIGHUP (1) — 终端断开
# SIGSTOP (19) — 暂停(不可捕获)
# SIGCONT (18) — 继续
# trap — 捕获信号
cleanup() {
echo "正在清理临时文件..."
rm -f /tmp/myapp_*.tmp
echo "清理完成,退出"
exit 0
}
# 捕获多个信号
trap cleanup SIGINT SIGTERM
# 捕获退出
trap 'echo "脚本结束"' EXIT
# 忽略信号
trap '' SIGINT # 忽略 Ctrl+C(不推荐)
# 实战:优雅退出脚本
#!/usr/bin/env bash
PIDFILE="/tmp/myapp.pid"
RUNNING=true
cleanup() {
echo "收到退出信号,正在停止..."
RUNNING=false
rm -f "$PIDFILE"
exit 0
}
trap cleanup SIGINT SIGTERM
echo $$ > "$PIDFILE"
echo "服务启动,PID: $$"
while $RUNNING; do
echo "运行中..."
sleep 5
done

10.4 进程检查#

Terminal window
# 检查进程是否运行
if pgrep -x "nginx" > /dev/null; then
echo "Nginx 正在运行"
else
echo "Nginx 未运行"
systemctl start nginx
fi
# 检查端口
if ss -tlnp | grep -q ":8080"; then
echo "端口 8080 已被占用"
fi
# 等待端口可用
while ss -tlnp | grep -q ":8080"; do
echo "等待端口 8080 释放..."
sleep 1
done
echo "端口可用"
# 锁文件(防止重复运行)
LOCKFILE="/tmp/myapp.lock"
if [[ -f "$LOCKFILE" ]]; then
echo "另一个实例正在运行"
exit 1
fi
echo $$ > "$LOCKFILE"
trap 'rm -f "$LOCKFILE"' EXIT
# 主逻辑
echo "运行中..."
sleep 60

11. 调试与安全#

11.1 调试模式#

Terminal window
# 方法1:命令行参数
bash -n script.sh # 语法检查(不执行)
bash -x script.sh # 跟踪执行(打印每条命令)
bash -v script.sh # 打印输入行(详细模式)
# 方法2:脚本内设置
set -x # 开启跟踪
set +x # 关闭跟踪
set -e # 遇到错误即退出
set -u # 使用未定义变量报错
set -o pipefail # 管道中任一命令失败则整体失败
set -euo pipefail # 组合使用(推荐)
# 方法3:局部调试
echo "开始调试区域"
set -x
# 需要调试的代码
var=$(some_command)
result=$((var * 2))
set +x
echo "调试结束"
# 方法4:输出到日志
exec 2> debug.log # 标准错误写入文件
set -x # 跟踪信息写入 debug.log

11.2 set 选项详解#

Terminal window
set -e # errexit — 命令失败(返回非0)时立即退出
# 注意:if/while/until 条件中的命令不受影响
set -u # nounset — 使用未定义变量时报错
# 防止 $UNDEFINED 导致空字符串问题
set -o pipefail # 管道中任一命令失败,整个管道失败
# 默认只看最后一个命令的退出码
set -x # xtrace — 打印执行的每条命令(调试用)
set -f # noglob — 禁用通配符展开(防止 * 被意外展开)
# 组合推荐
set -euo pipefail
# 或在脚本开头
set -Eeuo pipefail
# -E: errtrace,让 trap ERR 在函数和子shell中也生效

注意 set -e 的陷阱:在管道、子 shell、函数中行为不一致。关键命令应显式检查退出码:

Terminal window
if ! critical_command; then
echo "关键命令失败" >&2
exit 1
fi

11.3 shellcheck#

shellcheck 是 Shell 脚本静态分析工具,能发现大量常见错误:

Terminal window
# 安装
# macOS
brew install shellcheck
# Ubuntu/Debian
sudo apt install shellcheck
# 使用
shellcheck script.sh
# 在 Vim/VS Code 中集成
# Vim: 安装 dense-analysis/ale 插件
# VS Code: 安装 shellcheck 插件

shellcheck 常见警告:

Terminal window
# SC2086: 变量未加引号
echo $file # ❌ 危险
echo "$file" # ✅ 安全
# SC2046: 命令替换未加引号
for f in $(ls) # ❌ 文件名含空格会出错
for f in * # ✅ 用通配符
# SC2004: $ 在算术表达式中多余
echo $(($a + $b)) # ❌
echo $((a + b)) # ✅
# SC2186: 使用已弃用的 $[
echo $[1 + 2] # ❌
echo $((1 + 2)) # ✅
# SC2034: 未使用的变量
x=1 # ❌ 后续未使用 x

11.4 安全实践清单#

Terminal window
# 1. 始终给变量加引号
file="/path/to/my file.txt"
cat "$file" # ✅
cat $file # ❌ 空格导致分词
# 2. 使用 set -euo pipefail
set -euo pipefail
# 3. 检查命令是否存在
if ! command -v docker &> /dev/null; then
echo "错误: docker 未安装" >&2
exit 1
fi
# 4. 安全创建临时文件
tmpfile=$(mktemp) # ✅ 安全
tmpfile="/tmp/myapp.$$" # ❌ 不安全(竞争条件)
trap 'rm -f "$tmpfile"' EXIT # 确保清理
# 5. 避免使用 eval
eval "$user_input" # ❌ 极其危险(命令注入)
# 替代方案:直接执行或使用数组
# 6. 安全读取密码
read -s -p "密码: " password # -s 不回显
# 7. 验证输入
if [[ ! "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then
echo "无效端口号" >&2
exit 1
fi
# 8. 使用 readonly 和 local
readonly CONFIG_FILE="/etc/myapp/config.conf"
local temp_var="value"
# 9. 避免使用 cd(用绝对路径或子 shell)
# ❌ 危险
cd /some/dir
rm -f *.tmp
# ✅ 安全
(cd /some/dir && rm -f *.tmp)
# 10. 错误信息输出到 stderr
echo "错误: 文件不存在" >&2

12. 实战脚本#

12.1 自动备份脚本#

#!/usr/bin/env bash
#
# 自动备份脚本 — 压缩备份指定目录,保留最近 N 份
#
set -euo pipefail
# 配置
SOURCE_DIR="${1:-/opt/myapp}"
BACKUP_DIR="/backup"
KEEP_COUNT=7
DATE=$(date '+%Y%m%d_%H%M%S')
BACKUP_NAME="backup_${DATE}"
ARCHIVE="${BACKUP_DIR}/${BACKUP_NAME}.tar.gz"
# 函数
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
# 检查源目录
if [[ ! -d "$SOURCE_DIR" ]]; then
log "错误: 源目录不存在: $SOURCE_DIR"
exit 1
fi
# 创建备份目录
mkdir -p "$BACKUP_DIR"
# 执行备份
log "开始备份: $SOURCE_DIR$ARCHIVE"
tar -czf "$ARCHIVE" -C "$(dirname "$SOURCE_DIR")" "$(basename "$SOURCE_DIR")"
log "备份完成: $(du -h "$ARCHIVE" | cut -f1)"
# 清理旧备份
backups=("${BACKUP_DIR}"/backup_*.tar.gz)
backup_count=${#backups[@]}
if (( backup_count > KEEP_COUNT )); then
log "清理旧备份(保留最近 ${KEEP_COUNT} 份)..."
# 按时间排序,删除最旧的
IFS=$'\n' sorted=($(ls -t "${BACKUP_DIR}"/backup_*.tar.gz))
unset IFS
for ((i=KEEP_COUNT; i<${#sorted[@]}; i++)); do
rm -f "${sorted[$i]}"
log "已删除: ${sorted[$i]}"
done
fi
log "备份完成"

12.2 批量重命名脚本#

#!/usr/bin/env bash
#
# 批量重命名文件 — 支持前缀添加、扩展名替换、序号编号
#
set -euo pipefail
usage() {
cat << 'EOF'
用法: ./rename.sh [选项] <目录>
选项:
-p PREFIX 添加前缀
-e EXT 替换扩展名
-n 添加序号前缀(001_、002_...)
-d 干跑(只显示不执行)
示例:
./rename.sh -p "vacation_" ~/Pictures
./rename.sh -e jpg -d ~/Photos
./rename.sh -n ~/Documents
EOF
exit 1
}
PREFIX=""
EXT=""
NUMBER=false
DRY_RUN=false
while getopts "p:e:ndh" opt; do
case $opt in
p) PREFIX="$OPTARG" ;;
e) EXT="$OPTARG" ;;
n) NUMBER=true ;;
d) DRY_RUN=true ;;
h) usage ;;
*) usage ;;
esac
done
shift $((OPTIND - 1))
DIR="${1:-.}"
[[ ! -d "$DIR" ]] && echo "目录不存在: $DIR" >&2 && exit 1
count=0
for file in "$DIR"/*; do
[[ -f "$file" ]] || continue
filename=$(basename "$file")
newname="$filename"
# 添加前缀
[[ -n "$PREFIX" ]] && newname="${PREFIX}${newname}"
# 替换扩展名
if [[ -n "$EXT" ]]; then
newname="${newname%.*}.${EXT}"
fi
# 添加序号
if $NUMBER; then
printf -v num "%03d" "$count"
newname="${num}_${newname}"
fi
if [[ "$filename" != "$newname" ]]; then
if $DRY_RUN; then
echo "[干跑] $filename$newname"
else
mv "$file" "$DIR/$newname"
echo "$filename$newname"
fi
((count++))
fi
done
echo "共处理 ${count} 个文件"

12.3 服务器健康检查脚本#

#!/usr/bin/env bash
#
# 服务器健康检查 — 检查 CPU、内存、磁盘、网络等
#
set -euo pipefail
# 颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
fail() { echo -e "${RED}[✗]${NC} $*"; }
echo "========================================"
echo " 服务器健康检查 — $(date '+%Y-%m-%d %H:%M:%S')"
echo "========================================"
echo ""
# 1. 系统信息
echo "--- 系统信息 ---"
echo "主机名: $(hostname)"
echo "系统: $(uname -sr)"
echo "运行时间: $(uptime -p)"
echo "当前时间: $(date)"
echo ""
# 2. CPU 使用率
echo "--- CPU ---"
cpu_usage=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1)
cpu_int=${cpu_usage%.*}
if (( cpu_int > 80 )); then
fail "CPU 使用率: ${cpu_usage}%(过高)"
elif (( cpu_int > 50 )); then
warn "CPU 使用率: ${cpu_usage}%"
else
ok "CPU 使用率: ${cpu_usage}%"
fi
# CPU 核心数
echo "CPU 核心: $(nproc)"
echo ""
# 3. 内存
echo "--- 内存 ---"
mem_info=$(free -h)
echo "$mem_info"
mem_percent=$(free | awk '/Mem/ {printf "%.0f", $3/$2*100}')
if (( mem_percent > 90 )); then
fail "内存使用率: ${mem_percent}%(危险)"
elif (( mem_percent > 70 )); then
warn "内存使用率: ${mem_percent}%"
else
ok "内存使用率: ${mem_percent}%"
fi
echo ""
# 4. 磁盘
echo "--- 磁盘 ---"
df -h | grep -vE '^tmpfs|^devtmpfs'
echo ""
while read -r line; do
usage=$(echo "$line" | awk '{print $5}' | tr -d '%')
mount=$(echo "$line" | awk '{print $6}')
if (( usage > 90 )); then
fail "磁盘 ${mount}: ${usage}%(空间不足)"
elif (( usage > 80 )); then
warn "磁盘 ${mount}: ${usage}%"
fi
done < <(df -h | grep '^/dev')
echo ""
# 5. 网络
echo "--- 网络 ---"
# 检查关键端口
for port in 22 80 443; do
if ss -tln | grep -q ":${port} "; then
ok "端口 ${port} 正在监听"
else
warn "端口 ${port} 未监听"
fi
done
# 检查外网连通性
if ping -c 1 -W 2 8.8.8.8 &> /dev/null; then
ok "外网连通: 正常"
else
fail "外网连通: 不通"
fi
echo ""
# 6. 关键服务
echo "--- 服务状态 ---"
for svc in sshd nginx; do
if systemctl is-active --quiet "$svc"; then
ok "${svc}: 运行中"
else
fail "${svc}: 未运行"
fi
done
echo ""
echo "========================================"
echo " 检查完成"
echo "========================================"

12.4 日志监控脚本#

#!/usr/bin/env bash
#
# 实时日志监控 — 监控关键字并报警
#
set -euo pipefail
LOG_FILE="${1:-/var/log/syslog}"
KEYWORDS="error ERROR CRITICAL panic fatal"
ALERT_EMAIL="admin@example.com"
ALERT_COUNT=0
MAX_ALERTS=10
echo "监控日志: $LOG_FILE"
echo "关键字: $KEYWORDS"
echo "按 Ctrl+C 退出"
echo "----------------------------------------"
# 构建 grep 模式
pattern=$(echo "$KEYWORDS" | tr ' ' '|')
# tail -f 实时监控
tail -f "$LOG_FILE" | while IFS= read -r line; do
if echo "$line" | grep -qE "$pattern"; then
((ALERT_COUNT++))
timestamp=$(date '+%H:%M:%S')
echo -e "\033[31m[${timestamp}] 匹配 #${ALERT_COUNT}\033[0m: $line"
# 超过阈值发邮件
if (( ALERT_COUNT >= MAX_ALERTS )); then
echo "警告次数已达 ${MAX_ALERTS},发送邮件..."
# echo "$line" | mail -s "日志告警" "$ALERT_EMAIL"
ALERT_COUNT=0
fi
fi
done

12.5 Git 批量操作脚本#

#!/usr/bin/env bash
#
# 批量 Git 操作 — 拉取/推送多个仓库
#
set -euo pipefail
PROJECTS_DIR="${1:-~/projects}"
ACTION="${2:-status}"
log() { echo -e "\033[36m[$(date '+%H:%M:%S')]\033[0m $*"; }
# 遍历目录下所有 Git 仓库
for repo in "$PROJECTS_DIR"/*/; do
[[ -d "$repo/.git" ]] || continue
repo_name=$(basename "$repo")
log "=== ${repo_name} ==="
case "$ACTION" in
pull)
git -C "$repo" pull --ff-only 2>&1 | head -3
;;
push)
git -C "$repo" push 2>&1 | head -3
;;
status)
git -C "$repo" status -s 2>&1 | head -5
;;
fetch)
git -C "$repo" fetch --all 2>&1 | head -3
;;
*)
echo "未知操作: $ACTION (支持: pull/push/status/fetch)"
exit 1
;;
esac
echo ""
done
log "批量操作完成"

12.6 Docker 清理脚本#

#!/usr/bin/env bash
#
# Docker 清理 — 清理悬空镜像、停止的容器、未使用的网络和卷
#
set -euo pipefail
log() { echo -e "\033[33m[清理]\033[0m $*"; }
# 检查 Docker
if ! command -v docker &> /dev/null; then
echo "Docker 未安装" >&2
exit 1
fi
# 清理前的磁盘使用
before=$(df / | awk 'NR==2 {print $4}')
# 1. 清理停止的容器
log "清理停止的容器..."
stopped=$(docker ps -q --filter "status=exited")
if [[ -n "$stopped" ]]; then
docker container prune -f
else
echo " 无停止的容器"
fi
# 2. 清理悬空镜像
log "清理悬空镜像..."
dangling=$(docker images -q --filter "dangling=true")
if [[ -n "$dangling" ]]; then
docker image prune -f
else
echo " 无悬空镜像"
fi
# 3. 清理未使用的网络
log "清理未使用的网络..."
docker network prune -f
# 4. 清理构建缓存
log "清理构建缓存..."
docker builder prune -f 2>/dev/null || true
# 5. 清理未使用的卷(需要确认)
if [[ "${1:-}" == "--volumes" ]]; then
log "清理未使用的卷..."
docker volume prune -f
fi
# 清理后的磁盘使用
after=$(df / | awk 'NR==2 {print $4}')
freed=$((after - before))
log "清理完成,释放空间: $(numfmt --to=iec "$freed" 2>/dev/null || echo "${freed} KB")"

12.7 SSL 证书到期检查#

#!/usr/bin/env bash
#
# SSL 证书到期检查 — 检查多个域名的证书有效期
#
set -euo pipefail
DOMAINS=(
"971918.xyz"
"www.971918.xyz"
"api.971918.xyz"
)
WARN_DAYS=30
CRITICAL_DAYS=7
RED='\033[0;31m'
YELLOW='\033[1;33m'
GREEN='\033[0;32m'
NC='\033[0m'
echo "SSL 证书到期检查"
echo "警告阈值: ${WARN_DAYS} 天 | 严重阈值: ${CRITICAL_DAYS} 天"
echo "================================"
for domain in "${DOMAINS[@]}"; do
# 获取证书到期时间
expiry_date=$(echo | openssl s_client -servername "$domain" -connect "${domain}:443" 2>/dev/null \
| openssl x509 -noout -enddate 2>/dev/null \
| cut -d= -f2)
if [[ -z "$expiry_date" ]]; then
echo -e "${RED}[错误]${NC} ${domain}: 无法获取证书信息"
continue
fi
# 转为时间戳
expiry_epoch=$(date -d "$expiry_date" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$expiry_date" +%s 2>/dev/null)
now_epoch=$(date +%s)
days_left=$(( (expiry_epoch - now_epoch) / 86400 ))
if (( days_left <= CRITICAL_DAYS )); then
echo -e "${RED}[严重]${NC} ${domain}: 剩余 ${days_left} 天(到期: ${expiry_date})"
elif (( days_left <= WARN_DAYS )); then
echo -e "${YELLOW}[警告]${NC} ${domain}: 剩余 ${days_left} 天(到期: ${expiry_date})"
else
echo -e "${GREEN}[正常]${NC} ${domain}: 剩余 ${days_left} 天"
fi
done

12.8 批量文件编码转换#

#!/usr/bin/env bash
#
# 批量将 GBK 编码文件转为 UTF-8
#
set -euo pipefail
DIR="${1:-.}"
FROM_ENCODING="GBK"
TO_ENCODING="UTF-8"
# 检查 iconv
if ! command -v iconv &> /dev/null; then
echo "需要安装 iconv" >&2
exit 1
fi
count=0
for file in "$DIR"/*; do
[[ -f "$file" ]] || continue
# 检测编码
encoding=$(file -bi "$file" | grep -o 'charset=[^ ]*' | cut -d= -f2)
if [[ "$encoding" == "iso-8859-1" ]] || [[ "$encoding" == "gbk" ]] || [[ "$encoding" == "gb2312" ]]; then
echo "转换: $(basename "$file") ($encoding$TO_ENCODING)"
iconv -f "$FROM_ENCODING" -t "$TO_ENCODING" "$file" > "${file}.tmp" && mv "${file}.tmp" "$file"
((count++))
fi
done
echo "共转换 ${count} 个文件"

12.9 系统初始化脚本#

#!/usr/bin/env bash
#
# 新服务器初始化 — 更新系统、安装常用工具、配置防火墙
#
set -euo pipefail
log() { echo -e "\033[36m[初始化]\033[0m $*"; }
ok() { echo -e "\033[32m[完成]\033[0m $*"; }
# 必须以 root 运行
if [[ $EUID -ne 0 ]]; then
echo "请以 root 运行此脚本" >&2
exit 1
fi
# 1. 更新系统
log "更新系统包..."
if command -v apt &> /dev/null; then
apt update && apt upgrade -y
apt install -y curl wget git vim tmux htop net-tools tree unzip jq
elif command -v dnf &> /dev/null; then
dnf update -y
dnf install -y curl wget git vim tmux htop net-tools tree unzip jq
fi
ok "系统更新完成"
# 2. 创建用户
log "创建部署用户..."
if ! id -u deploy &> /dev/null; then
useradd -m -s /bin/bash deploy
echo "deploy 用户已创建"
echo "请设置密码: passwd deploy"
fi
ok "用户创建完成"
# 3. 配置 SSH 密钥
log "配置 SSH..."
mkdir -p /home/deploy/.ssh
cp /root/.ssh/authorized_keys /home/deploy/.ssh/ 2>/dev/null || true
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys 2>/dev/null || true
# 禁用密码登录和 root 登录
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config
systemctl restart sshd
ok "SSH 配置完成"
# 4. 配置防火墙
log "配置防火墙..."
if command -v ufw &> /dev/null; then
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ok "UFW 防火墙已启用"
elif command -v firewall-cmd &> /dev/null; then
firewall-cmd --permanent --add-service=ssh
firewall-cmd --permanent --add-service=http
firewall-cmd --permanent --add-service=https
firewall-cmd --reload
ok "firewalld 已配置"
fi
# 5. 设置时区
log "设置时区..."
timedatectl set-timezone Asia/Shanghai
ok "时区设置为 Asia/Shanghai"
# 6. 配置自动更新
log "配置自动安全更新..."
if command -v apt &> /dev/null; then
apt install -y unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
fi
ok "自动更新已配置"
echo ""
ok "===== 服务器初始化完成 ====="
echo "建议操作:"
echo " 1. 设置 deploy 密码: passwd deploy"
echo " 2. 测试 SSH 登录: ssh deploy@服务器IP"
echo " 3. 安装 Docker: curl -fsSL https://get.docker.com | sh"

12.10 一键部署脚本(完整模板)#

#!/usr/bin/env bash
#
# 一键部署 — 拉取代码、构建、重启服务、健康检查
#
set -euo pipefail
# ============================================
# 配置
# ============================================
APP_NAME="myapp"
APP_DIR="/opt/${APP_NAME}"
GIT_REPO="git@github.com:user/${APP_NAME}.git"
GIT_BRANCH="main"
PORT=8080
HEALTH_URL="http://localhost:${PORT}/health"
DEPLOY_LOG="/var/log/${APP_NAME}/deploy.log"
BACKUP_DIR="/backup/${APP_NAME}"
# ============================================
# 函数
# ============================================
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$DEPLOY_LOG"; }
fail() { log "错误: $*"; exit 1; }
pre_check() {
command -v git >/dev/null || fail "git 未安装"
command -v docker >/dev/null || fail "docker 未安装"
log "预检查通过"
}
backup_current() {
if [[ -d "$APP_DIR" ]]; then
local backup_name="${APP_NAME}_$(date '+%Y%m%d_%H%M%S')"
mkdir -p "$BACKUP_DIR"
tar -czf "${BACKUP_DIR}/${backup_name}.tar.gz" -C "$(dirname "$APP_DIR")" "$APP_NAME"
log "已备份当前版本: ${backup_name}.tar.gz"
fi
}
pull_code() {
if [[ -d "$APP_DIR/.git" ]]; then
log "拉取最新代码..."
git -C "$APP_DIR" fetch --all
git -C "$APP_DIR" reset --hard "origin/${GIT_BRANCH}"
else
log "首次部署,克隆代码..."
git clone -b "$GIT_BRANCH" "$GIT_REPO" "$APP_DIR"
fi
log "代码版本: $(git -C "$APP_DIR" rev-parse --short HEAD)"
}
build() {
log "构建 Docker 镜像..."
docker compose -f "$APP_DIR/docker-compose.yml" build --no-cache
log "构建完成"
}
deploy() {
log "重启服务..."
docker compose -f "$APP_DIR/docker-compose.yml" up -d
log "服务已启动"
}
health_check() {
log "健康检查..."
local retries=30
while (( retries > 0 )); do
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
log "健康检查通过"
return 0
fi
log "等待服务启动...(剩余 ${retries} 次)"
sleep 2
((retries--))
done
fail "健康检查失败,服务未正常启动"
}
cleanup() {
log "清理旧镜像..."
docker image prune -f 2>/dev/null || true
}
rollback() {
log "部署失败,执行回滚..."
local latest_backup=$(ls -t "${BACKUP_DIR}"/${APP_NAME}_*.tar.gz 2>/dev/null | head -1)
if [[ -n "$latest_backup" ]]; then
rm -rf "$APP_DIR"
tar -xzf "$latest_backup" -C "$(dirname "$APP_DIR")"
docker compose -f "$APP_DIR/docker-compose.yml" up -d
log "已回滚到: $(basename "$latest_backup")"
else
fail "无可用备份,无法回滚"
fi
}
# ============================================
# 主流程
# ============================================
main() {
log "========== 开始部署 ${APP_NAME} =========="
pre_check
backup_current
if ! pull_code || ! build || ! deploy; then
rollback
fail "部署失败,已回滚"
fi
if ! health_check; then
rollback
fail "健康检查失败,已回滚"
fi
cleanup
log "========== 部署成功 =========="
}
# 执行
main "$@"

13. 常见问题排查#

13.1 语法错误#

问题:syntax error near unexpected token

Terminal window
# 原因1:Windows 换行符(CRLF)
# 解决:转换为 Unix 换行符
sed -i 's/\r$//' script.sh
# 或
dos2unix script.sh
# 原因2:中文标点混入
# 检查:是否用了中文括号()而非英文 ()
# 解决:替换为英文标点
# 原因3:if/for/while 未正确闭合
# 确保 then/do/fi/done 配对

问题:command not found

Terminal window
# 原因1:命令未安装
# 解决:安装对应包
# 原因2:PATH 不正确
echo $PATH
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
# 原因3:脚本有 BOM 头
# 解决:去除 BOM
sed -i '1s/^\xEF\xBB\xBF//' script.sh

13.2 变量问题#

问题:变量值为空或包含空格

Terminal window
# ❌ 变量未加引号
for f in $files; do ... # 文件名含空格会出错
# ✅ 加引号
for f in "$files"; do ...
# ❌ 命令替换未加引号
result=$(ls -la)
echo $result # 换行变成空格
# ✅ 加引号
echo "$result" # 保留换行

问题:unbound variable(使用了 set -u)

Terminal window
set -u
echo $UNDEFINED # 报错: UNDEFINED: unbound variable
# 解决:使用默认值
echo "${UNDEFINED:-default}"

13.3 权限问题#

问题:Permission denied

Terminal window
# 原因1:脚本无执行权限
chmod +x script.sh
# 原因2:需要 root 权限
sudo ./script.sh
# 原因3:文件系统只读
mount -o remount,rw /

13.4 子 Shell 问题#

Terminal window
# 问题:管道中的变量修改不生效
count=0
echo -e "a\nb\nc" | while read line; do
((count++))
done
echo $count # 0(管道在子 Shell 中执行)
# 解决1:使用进程替换
count=0
while read line; do
((count++))
done < <(echo -e "a\nb\nc")
echo $count # 3
# 解决2:使用 lastpipe(Bash 4.2+)
shopt -s lastpipe
count=0
echo -e "a\nb\nc" | while read line; do
((count++))
done
echo $count # 3

13.5 常见错误速查表#

错误信息原因解决方案
syntax error near unexpected tokenWindows 换行符/语法错误dos2unix / 检查语法
command not found命令未安装/PATH 错误安装命令/修正 PATH
Permission denied无执行权限chmod +x
No such file or directory路径错误/空格未加引号检查路径/加引号
unbound variable使用未定义变量 + set -u${var:-default}
argument list too long参数过多(如 rm *find . -delete
binary operator expected[ ] 中变量为空[[ ]] 或加引号
too many arguments[ ] 中变量含空格[[ ]] 或加引号
./script.sh: /bin/bash^M: bad interpreterWindows 换行符sed -i 's/\r$//' script.sh

14. 速查表#

14.1 特殊变量速查#

变量含义
$0脚本名
$1~$9位置参数
$#参数个数
$@所有参数(各自独立)
$*所有参数(合为一个)
$?上一条命令退出码
$$当前脚本 PID
$!最近后台进程 PID
$_上一条命令最后一个参数
$-当前 Shell 选项

14.2 条件判断速查#

文件测试:

操作符含义
-e存在
-f普通文件
-d目录
-s非空文件
-r -w -x可读/可写/可执行
-L符号链接
f1 -nt f2f1 比 f2 新
f1 -ot f2f1 比 f2 旧

字符串:

操作符含义
-z为空
-n非空
== =相等
!=不等
< >字典序比较
=~正则匹配([[ ]] 内)

数值:

操作符含义
-eq等于
-ne不等
-lt小于
-le小于等于
-gt大于
-ge大于等于

14.3 字符串操作速查#

语法含义
${#var}长度
${var:offset:length}截取
${var/old/new}替换第一个
${var//old/new}替换所有
${var#pattern}删头部(最短)
${var##pattern}删头部(最长)
${var%pattern}删尾部(最短)
${var%%pattern}删尾部(最长)
${var^^}转大写
${var,,}转小写
${var:-default}空时返回默认值
${var:=default}空时赋值并返回
${var:+alt}非空时返回 alt
${var:?error}空时报错退出

14.4 常用组合速查#

Terminal window
# 安全模式
set -euo pipefail
# 检查命令是否存在
command -v docker &> /dev/null || { echo "需要 docker"; exit 1; }
# 检查 root 权限
[[ $EUID -eq 0 ]] || { echo "需要 root"; exit 1; }
# 检查目录存在
[[ -d "$DIR" ]] || mkdir -p "$DIR"
# 获取脚本所在目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# 获取脚本绝对路径
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
# 安全临时文件
tmpfile=$(mktemp); trap 'rm -f "$tmpfile"' EXIT
# 读取配置文件
while IFS='=' read -r key value; do
[[ "$key" =~ ^#.*$ ]] && continue
[[ -z "$key" ]] && continue
eval "$key=\"$value\""
done < config.conf
# 颜色输出
RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'
echo -e "${RED}错误${NC}"
echo -e "${GREEN}成功${NC}"
# 进度条
for i in $(seq 1 100); do
printf "\r进度: [%-50s] %d%%" "$(printf '#%.0s' $(seq 1 $((i/2))))" "$i"
sleep 0.1
done
echo ""

15. 最佳实践#

15.1 脚本规范#

script_name.sh
#!/usr/bin/env bash
#
# 功能描述: 一句话说明脚本用途
# 作者: nine
# 创建日期: 2026-07-06
# 修改记录: 2026-07-06 初始版本
# 使用方法: ./script_name.sh [参数]
#
set -euo pipefail
IFS=$'\n\t'

15.2 十条核心最佳实践#

  1. set -euo pipefail — 脚本开头必加,防错第一道防线
  2. 变量加引号"$var" 而非 $var,防空格和通配符
  3. [[ ]] 替代 [ ] — 更安全,功能更强
  4. 函数用 local — 所有局部变量都加 local
  5. shellcheck 检查 — 提交前运行,消除隐患
  6. Shebang 用 env#!/usr/bin/env bash 可移植
  7. 错误输出到 stderrecho "error" >&2
  8. 清理临时文件trap 'rm -f "$tmp"' EXIT
  9. 函数化 — 主逻辑拆成函数,main "$@" 调用
  10. 加日志 — 关键步骤加 echolog 函数

15.3 场景推荐#

场景推荐方案
简单文件操作Shell 脚本
系统运维自动化Shell 脚本 + cron
复杂数据处理Python
CI/CD 流水线Shell + YAML
文本日志分析Shell(grep/sed/awk)
API 调用Shell(curl)或 Python
跨平台脚本Python
快速原型Shell

15.4 学习资源#

Terminal window
# Bash 内置帮助
help # 所有内置命令
help if # if 语句帮助
help test # test 命令帮助
help read # read 命令帮助
# man 手册
man bash # Bash 完整手册(非常长)
man bash | grep -A5 'Parameter Expansion' # 参数展开
man bash | grep -A5 'CONDITIONAL EXPRESSIONS' # 条件表达式
# ShellCheck
shellcheck script.sh # 静态检查
# 在线资源
# https://www.shellcheck.net/ — 在线检查
# https://shellhat.org/ — Shell 编程规范
# https://github.com/awesome-lists/awesome-bash — Bash 资源

总结#

Shell 脚本的核心价值是 自动化——把重复的手动操作变成可复用的脚本。以下是核心要点回顾:

知识点核心语法
变量name="value" echo "${name}" $((a+b))
字符串${#var} ${var:0:5} ${var/old/new} ${var##*/}
条件if [[ ]]; then ... fi case ... in ... esac
循环for ... in ... do ... done while ... do ... done
数组arr=(a b c) ${arr[@]} ${#arr[@]} declare -A
函数func() { local x=$1; ... }
文本处理grep sed awk 三剑客
进程& nohup wait trap timeout
调试set -x bash -n shellcheck
安全set -euo pipefail 引号 local trap

记住三个核心规律:

  1. 安全第一set -euo pipefail + 变量加引号 + shellcheck
  2. 函数化 — 把逻辑拆成命名良好的函数,main "$@" 调用
  3. 组合优于编程 — Shell 的强项是组合现有命令(管道),而非复杂逻辑

如果你只记住一句话:set -euo pipefail + 变量加双引号 + shellcheck 这三条消除 80% 的 Shell 脚本 bug。


推荐阅读#

Shell 脚本编程完全指南:从基础语法到实战自动化 | 2026 最新实践
https://971918.xyz/posts/docs/shell-scripting-guide/
作者
九所长
发布于
2026-07-06
许可协议
CC BY-NC-SA 4.0