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
| 特性 | Bash | Zsh | Sh (POSIX) |
|---|---|---|---|
| 全称 | Bourne Again Shell | Z Shell | Bourne Shell |
| 默认于 | 大多数 Linux | macOS(Catalina+) | Alpine Linux / Docker |
| 兼容性 | 最广 | 兼容 Bash | POSIX 标准 |
| 数组 | ✅ 索引+关联 | ✅ 更强大 | ❌ 仅位置参数 |
| 关联数组 | 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')"执行脚本的两种方式:
# 方式1:添加执行权限后直接运行chmod +x hello.sh./hello.sh
# 方式2:用解释器执行(不需要执行权限)bash hello.sh区别:方式1 需要 Shebang 和执行权限,方式2 直接用指定解释器。调试时常用方式2。
1.4 脚本规范模板
#!/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 变量定义与使用
# 基本赋值(等号两边不能有空格!)name="九所长"age=30pi=3.14
# 使用变量($ 符号)echo $nameecho "姓名: $name, 年龄: $age"
# 推荐用花括号(明确变量边界)echo "${name}的博客"echo "文件名: ${name}_report.txt"
# 命令替换(两种写法等价)current_date=$(date +%Y%m%d) # 推荐写法current_date=`date +%Y%m%d` # 旧写法,不推荐(不能嵌套)echo "今天: ${current_date}"三个常见错误:
name = "value"— 等号两边有空格,Bash 会把name当命令执行echo $name_report— Bash 认为变量名是name_report,应该写echo ${name}_reportecho "$name 的博客"— 变量后跟中文,Bash 可能解析错误,应该写echo "${name}的博客"
2.2 变量类型
Bash 变量默认都是字符串,但可以做算术运算:
# 字符串str="hello"
# 整数运算(用 $(( )) 或 let 或 expr)a=10b=3echo $((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=7echo $count # 72.3 特殊变量
| 变量 | 含义 | 示例 |
|---|---|---|
$0 | 脚本名 | ./script.sh |
$1~$9 | 第1-9个参数 | $1 = 第一个参数 |
${10} | 第10个及以上参数 | 需要花括号 |
$# | 参数个数 | 3 |
$@ | 所有参数(各自独立) | "a" "b" "c" |
$* | 所有参数(合为一个字符串) | "a b c" |
$? | 上一条命令退出码 | 0 = 成功 |
$$ | 当前脚本 PID | 12345 |
$! | 最近后台进程 PID | 12346 |
$_ | 上一条命令的最后一个参数 |
#!/usr/bin/env bashecho "脚本名: $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 $* 的关键区别:
# 当用双引号包裹时,区别明显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 环境变量
# 导出为环境变量(子进程可继承)export DATABASE_URL="postgresql://localhost:5432/mydb"export API_KEY="secret-key-12345"
# 在 .bashrc / .zshrc 中设置永久环境变量# echo 'export MY_VAR="value"' >> ~/.bashrc
# 查看所有环境变量envprintenv
# 查看单个变量echo $PATHprintenv HOME2.5 变量默认值
# ${var:-default} — var 为空时返回 default,不修改 varecho "${PORT:-8080}" # 如果 PORT 未设置,输出 8080
# ${var:=default} — var 为空时赋值为 default 并返回echo "${PORT:=8080}" # 如果 PORT 未设置,赋值为 8080 并输出
# ${var:+alternative} — var 非空时返回 alternativeecho "${DEBUG:+enabled}" # 如果 DEBUG 已设置,输出 enabled
# ${var:?error} — var 为空时报错并退出echo "${API_KEY:?API_KEY is required}" # 如果未设置,报错退出2.6 只读变量与删除变量
# 只读变量(不能修改,不能删除)readonly PI=3.14159declare -r MAX_CONNECTIONS=100
# 尝试修改会报错# PI=3 → bash: PI: readonly variable
# 删除变量(不能删除只读变量)name="test"unset nameecho "${name:-已删除}" # 输出: 已删除3. 字符串操作
3.1 引号规则
# 单引号:完全字面量,不解析变量和转义echo 'Hello $USER' # 输出: Hello $USERecho '单引号中\n不换行' # 输出: 单引号中\n不换行
# 双引号:解析变量和命令替换,支持转义echo "Hello $USER" # 输出: Hello nineecho "今天是 $(date +%A)" # 输出: 今天是 Mondayecho "转义: \$USER" # 输出: 转义: $USER
# 无引号:会进行通配符展开(危险)files=*.txt # 可能展开为文件列表echo $USER # 可以工作但不安全核心规则:变量引用永远加双引号
"$var",纯文本用单引号'text'。
3.2 字符串长度
str="Hello World"echo ${#str} # 11
# 中文长度(每个汉字算1个字符)cn="你好世界"echo ${#cn} # 43.3 子字符串截取
str="Hello World"
# ${var:offset:length} — 从 offset 开始截取 length 个字符echo ${str:0:5} # Helloecho ${str:6:5} # Worldecho ${str:6} # World(从第6位到末尾)echo ${str: -5} # World(从倒数第5位到末尾,注意空格)3.4 字符串替换
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 BASH3.5 字符串删除(模式匹配)
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%%/*} # (空)实用场景:
# 获取文件名filename="/path/to/file.tar.gz"echo "${filename##*/}" # file.tar.gz
# 获取扩展名echo "${filename##*.}" # gz
# 获取不带扩展名的文件名base="${filename##*/}" # file.tar.gzecho "${base%.*}" # file.tar
# 获取目录路径echo "${filename%/*}" # /path/to3.6 大小写转换(Bash 4.0+)
str="Hello World"
# 转大写echo ${str^^} # HELLO WORLD
# 转小写echo ${str,,} # hello world
# 首字母大写echo ${str^} # Hello World(已是大写不变)
# 首字母小写echo ${str,} # hello World4. 条件判断
4.1 if 语句
# 基本结构if [ condition ]; then echo "条件成立"fi
# if-elseif [ $age -ge 18 ]; then echo "成年"else echo "未成年"fi
# if-elif-elseif [ $score -ge 90 ]; then echo "优秀"elif [ $score -ge 80 ]; then echo "良好"elif [ $score -ge 60 ]; then echo "及格"else echo "不及格"fi4.2 test 命令:[ ] vs [[ ]] vs (( ))
# [ ] — 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 文件测试
# 存在性[[ -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 未安装"fi4.4 字符串比较
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,}$ ]] # true4.5 数值比较
a=10b=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 逻辑组合
# [[ ]] 中的逻辑运算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 /backupfi4.7 case 语句
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模式匹配:
case "$file" in *.jpg|*.jpeg|*.png|*.gif) # 多模式用 | 分隔 echo "图片文件" ;; *.mp4|*.avi|*.mkv) echo "视频文件" ;; *.txt|*.md) echo "文本文件" ;; *) echo "未知类型: $file" ;;esac5. 循环
5.1 for 循环
# 基础列表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 循环
# 基础count=0while (( 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" ;; esacdone
# 读取文件行line_num=0while IFS= read -r line; do ((line_num++)) echo "第 ${line_num} 行: $line"done < input.txt5.3 until 循环
# until — 条件为假时循环(与 while 相反)count=5until (( count == 0 )); do echo "倒计时: $count" ((count--)) sleep 1doneecho "发射!"
# 等待服务启动until curl -s http://localhost:8080/health > /dev/null 2>&1; do echo "等待服务启动..." sleep 2doneecho "服务已启动"5.4 select 菜单
PS3="请选择操作: "select option in "安装" "配置" "启动" "停止" "退出"; do case $option in 安装) echo "正在安装..." ;; 配置) echo "正在配置..." ;; 启动) echo "正在启动..." ;; 停止) echo "正在停止..." ;; 退出) break ;; *) echo "无效选项" ;; esacdone5.5 break 与 continue
# 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" donedone6. 数组
6.1 索引数组
# 定义数组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]} # cherryecho ${fruits[-1]} # date(最后一个元素,Bash 4.3+)
# 访问所有元素echo ${fruits[@]} # apple banana cherry dateecho ${fruits[*]} # 同上
# 数组长度echo ${#fruits[@]} # 4echo ${#fruits[0]} # 5(第一个元素的字符长度)
# 获取所有索引echo ${!fruits[@]} # 0 1 2 3
# 切片echo ${fruits[@]:1:2} # banana cherry(从索引1开始取2个)6.2 数组操作
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 gecho ${!arr[@]} # 0 1 3 4 5 6(索引不连续)
# 删除整个数组unset arrecho ${arr[@]:-空} # 空
# 遍历数组for item in "${arr[@]}"; do echo "元素: $item"done
# 带索引遍历for index in "${!arr[@]}"; do echo "[$index] = ${arr[$index]}"done6.3 关联数组(Bash 4.0+)
# 声明关联数组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]} # localhostecho ${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 键存在"fi6.4 从命令输出创建数组
# 按行分割mapfile -t lines < /etc/hostsecho "共 ${#lines[@]} 行"echo "第一行: ${lines[0]}"
# 按空格分割files=($(ls *.txt))echo "找到 ${#files[@]} 个 txt 文件"
# 从字符串分割csv="a,b,c,d"IFS=',' read -ra arr <<< "$csv"echo "${arr[0]}" # aecho "${arr[3]}" # decho ${#arr[@]} # 47. 函数
7.1 函数定义与调用
# 方式1:function 关键字function greet() { echo "Hello, $1!"}
# 方式2:省略 function(POSIX 兼容)greet() { echo "Hello, $1!"}
# 调用greet "九所长" # Hello, 九所长!greet "World" # Hello, World!7.2 函数参数
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 ccc7.3 返回值
# 方式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 7echo "结果: $result" # 42
# 方式4:局部变量传回(引用传递)# Bash 不支持引用传递,但可以用 nameref(Bash 4.3+)set_value() { local -n ref=$1 # nameref ref="通过引用设置的值"}
declare my_var=""set_value my_varecho "$my_var" # 通过引用设置的值7.4 局部变量
function process() { local temp="局部变量" # local 关键字声明局部变量 local count=0 echo "内部: $temp"
# 局部变量不影响外部同名变量 local PATH="/custom/path:$PATH" echo "内部 PATH: $PATH"}
temp="全局变量"processecho "外部: $temp" # 全局变量(不受函数影响)最佳实践:函数内所有变量都用
local声明,避免污染全局命名空间。
7.5 函数库
#!/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]$ ]]}
# 获取公网 IPget_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 bashsource ~/lib/utils.sh # 或 . ~/lib/utils.sh
log_info "脚本启动"if confirm "是否继续?"; then log_info "公网 IP: $(get_public_ip)"filog_warn "操作完成"8. 输入与输出
8.1 echo 与 printf
# echo — 简单输出echo "Hello" # 输出后换行echo -n "不换行" # 不换行echo -e "转义\n换行\t制表" # 解析转义字符
# printf — 格式化输出(更强大)printf "姓名: %s\n" "九所长"printf "年龄: %d\n" 30printf "PI: %.2f\n" 3.14159printf "%-10s | %5d | %8.2f\n" "Apple" 100 3.14printf "%-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 小数位 N8.2 read 读取输入
# 基本读取echo -n "请输入姓名: "read nameecho "你好, $name"
# 单行提示(-p)read -p "请输入年龄: " ageecho "年龄: $age"
# 限时输入(-t 秒数)if read -t 5 -p "5秒内输入(否则使用默认): " input; then echo "你输入了: $input"else echo "超时,使用默认值"fi
# 密码输入(不回显,-s)read -s -p "密码: " passwordecho ""echo "密码长度: ${#password}"
# 读取多个值read -p "姓 名: " first_name last_nameecho "姓: $first_name, 名: $last_name"
# 读取到数组(-a)read -a arr -p "输入多个值(空格分隔): "echo "第一个: ${arr[0]}"
# 逐行读取文件while IFS= read -r line; do echo "处理: $line"done < filename.txt
# 读取 CSVwhile IFS=',' read -r col1 col2 col3; do echo "列1=$col1 列2=$col2 列3=$col3"done < data.csv8.3 重定向
# 输出重定向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 字符串
# Here 文档(多行输入)cat << 'EOF'这是一段多行文本$USER 不会被展开(因为 EOF 用单引号)EOF
cat << EOF这会展开变量: $USER当前目录: $(pwd)EOF
# 写入文件cat << 'EOF' > /etc/myapp/config.confserver_host = localhostserver_port = 8080debug_mode = falseEOF
# Here 字符串(单行输入)grep "pattern" <<< "search in this string"
# 实战:将变量传给命令text="hello world"read first rest <<< "$text"echo "$first" # helloecho "$rest" # world8.5 管道
# 基本管道:前一个命令的输出作为后一个的输入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 — 文本搜索
# 基本搜索grep "error" logfile.txtgrep -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 文件中的 TODOgrep -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 登录的 IPgrep "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 — 流编辑器
# 替换(最常用)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.txtsed '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 # 数字替换为 NUMsed -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 转为 TSVsed 's/,/\t/g' data.csv
# 4. 每行行首加序号sed = file.txt | sed 'N;s/\n/\t/'
# 5. 提取两个标记之间的内容sed -n '/START/,/END/p' file.txt9.4 awk — 文本分析
# 基本结构: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 # CSVawk -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 # 第一列等于 ERRORawk '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 -u9.5 文本处理组合技
# 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 个 IPawk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10
# 4. 将多行合并为一行(逗号分隔)cat list.txt | tr '\n' ',' | sed 's/,$//'
# 5. 字符串反转echo "hello" | revecho "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 但不在 file2comm -13 <(sort file1) <(sort file2) # 在 file2 但不在 file1comm -12 <(sort file1) <(sort file2) # 交集10. 进程管理
10.1 后台运行
# & 后台运行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 # 含 PID10.2 等待与控制
# wait — 等待后台进程完成sleep 5 &sleep 3 &wait # 等待所有后台任务echo "全部完成"
wait $! # 等待指定 PIDecho "指定任务完成"
# 并行执行 + 等待for host in server1 server2 server3; do ssh "$host" "uptime" &donewaitecho "所有服务器检查完毕"
# 超时控制timeout 30 curl http://slow-api.example.comtimeout 10 ./script.sh || echo "脚本超时"
# 控制并发数max_parallel=3running=0for task in task1 task2 task3 task4 task5; do process_task "$task" & ((running++)) if (( running >= max_parallel )); then wait -n # 等待任意一个完成(Bash 4.3+) ((running--)) fidonewait10.3 信号处理
# 常见信号# 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 bashPIDFILE="/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 5done10.4 进程检查
# 检查进程是否运行if pgrep -x "nginx" > /dev/null; then echo "Nginx 正在运行"else echo "Nginx 未运行" systemctl start nginxfi
# 检查端口if ss -tlnp | grep -q ":8080"; then echo "端口 8080 已被占用"fi
# 等待端口可用while ss -tlnp | grep -q ":8080"; do echo "等待端口 8080 释放..." sleep 1doneecho "端口可用"
# 锁文件(防止重复运行)LOCKFILE="/tmp/myapp.lock"if [[ -f "$LOCKFILE" ]]; then echo "另一个实例正在运行" exit 1fiecho $$ > "$LOCKFILE"trap 'rm -f "$LOCKFILE"' EXIT
# 主逻辑echo "运行中..."sleep 6011. 调试与安全
11.1 调试模式
# 方法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 +xecho "调试结束"
# 方法4:输出到日志exec 2> debug.log # 标准错误写入文件set -x # 跟踪信息写入 debug.log11.2 set 选项详解
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; thenecho "关键命令失败" >&2exit 1fi
11.3 shellcheck
shellcheck 是 Shell 脚本静态分析工具,能发现大量常见错误:
# 安装# macOSbrew install shellcheck
# Ubuntu/Debiansudo apt install shellcheck
# 使用shellcheck script.sh
# 在 Vim/VS Code 中集成# Vim: 安装 dense-analysis/ale 插件# VS Code: 安装 shellcheck 插件shellcheck 常见警告:
# 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 # ❌ 后续未使用 x11.4 安全实践清单
# 1. 始终给变量加引号file="/path/to/my file.txt"cat "$file" # ✅cat $file # ❌ 空格导致分词
# 2. 使用 set -euo pipefailset -euo pipefail
# 3. 检查命令是否存在if ! command -v docker &> /dev/null; then echo "错误: docker 未安装" >&2 exit 1fi
# 4. 安全创建临时文件tmpfile=$(mktemp) # ✅ 安全tmpfile="/tmp/myapp.$$" # ❌ 不安全(竞争条件)trap 'rm -f "$tmpfile"' EXIT # 确保清理
# 5. 避免使用 evaleval "$user_input" # ❌ 极其危险(命令注入)# 替代方案:直接执行或使用数组
# 6. 安全读取密码read -s -p "密码: " password # -s 不回显
# 7. 验证输入if [[ ! "$port" =~ ^[0-9]+$ ]] || (( port < 1 || port > 65535 )); then echo "无效端口号" >&2 exit 1fi
# 8. 使用 readonly 和 localreadonly CONFIG_FILE="/etc/myapp/config.conf"local temp_var="value"
# 9. 避免使用 cd(用绝对路径或子 shell)# ❌ 危险cd /some/dirrm -f *.tmp
# ✅ 安全(cd /some/dir && rm -f *.tmp)
# 10. 错误信息输出到 stderrecho "错误: 文件不存在" >&212. 实战脚本
12.1 自动备份脚本
#!/usr/bin/env bash## 自动备份脚本 — 压缩备份指定目录,保留最近 N 份#
set -euo pipefail
# 配置SOURCE_DIR="${1:-/opt/myapp}"BACKUP_DIR="/backup"KEEP_COUNT=7DATE=$(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 1fi
# 创建备份目录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]}" donefi
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 ~/DocumentsEOF exit 1}
PREFIX=""EXT=""NUMBER=falseDRY_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 ;; esacdoneshift $((OPTIND - 1))
DIR="${1:-.}"[[ ! -d "$DIR" ]] && echo "目录不存在: $DIR" >&2 && exit 1
count=0for 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++)) fidone
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}%"fiecho ""
# 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}%" fidone < <(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} 未监听" fidone
# 检查外网连通性if ping -c 1 -W 2 8.8.8.8 &> /dev/null; then ok "外网连通: 正常"else fail "外网连通: 不通"fiecho ""
# 6. 关键服务echo "--- 服务状态 ---"for svc in sshd nginx; do if systemctl is-active --quiet "$svc"; then ok "${svc}: 运行中" else fail "${svc}: 未运行" fidoneecho ""
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=0MAX_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 fidone12.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 $*"; }
# 检查 Dockerif ! command -v docker &> /dev/null; then echo "Docker 未安装" >&2 exit 1fi
# 清理前的磁盘使用before=$(df / | awk 'NR==2 {print $4}')
# 1. 清理停止的容器log "清理停止的容器..."stopped=$(docker ps -q --filter "status=exited")if [[ -n "$stopped" ]]; then docker container prune -felse echo " 无停止的容器"fi
# 2. 清理悬空镜像log "清理悬空镜像..."dangling=$(docker images -q --filter "dangling=true")if [[ -n "$dangling" ]]; then docker image prune -felse 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 -ffi
# 清理后的磁盘使用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=30CRITICAL_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} 天" fidone12.8 批量文件编码转换
#!/usr/bin/env bash## 批量将 GBK 编码文件转为 UTF-8#
set -euo pipefail
DIR="${1:-.}"FROM_ENCODING="GBK"TO_ENCODING="UTF-8"
# 检查 iconvif ! command -v iconv &> /dev/null; then echo "需要安装 iconv" >&2 exit 1fi
count=0for 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++)) fidone
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 1fi
# 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 jqelif command -v dnf &> /dev/null; then dnf update -y dnf install -y curl wget git vim tmux htop net-tools tree unzip jqfiok "系统更新完成"
# 2. 创建用户log "创建部署用户..."if ! id -u deploy &> /dev/null; then useradd -m -s /bin/bash deploy echo "deploy 用户已创建" echo "请设置密码: passwd deploy"fiok "用户创建完成"
# 3. 配置 SSH 密钥log "配置 SSH..."mkdir -p /home/deploy/.sshcp /root/.ssh/authorized_keys /home/deploy/.ssh/ 2>/dev/null || truechown -R deploy:deploy /home/deploy/.sshchmod 700 /home/deploy/.sshchmod 600 /home/deploy/.ssh/authorized_keys 2>/dev/null || true
# 禁用密码登录和 root 登录sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_configsed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_configsystemctl restart sshdok "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/Shanghaiok "时区设置为 Asia/Shanghai"
# 6. 配置自动更新log "配置自动安全更新..."if command -v apt &> /dev/null; then apt install -y unattended-upgrades dpkg-reconfigure -plow unattended-upgradesfiok "自动更新已配置"
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=8080HEALTH_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
# 原因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
# 原因1:命令未安装# 解决:安装对应包
# 原因2:PATH 不正确echo $PATHexport PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
# 原因3:脚本有 BOM 头# 解决:去除 BOMsed -i '1s/^\xEF\xBB\xBF//' script.sh13.2 变量问题
问题:变量值为空或包含空格
# ❌ 变量未加引号for f in $files; do ... # 文件名含空格会出错
# ✅ 加引号for f in "$files"; do ...
# ❌ 命令替换未加引号result=$(ls -la)echo $result # 换行变成空格
# ✅ 加引号echo "$result" # 保留换行问题:unbound variable(使用了 set -u)
set -uecho $UNDEFINED # 报错: UNDEFINED: unbound variable
# 解决:使用默认值echo "${UNDEFINED:-default}"13.3 权限问题
问题:Permission denied
# 原因1:脚本无执行权限chmod +x script.sh
# 原因2:需要 root 权限sudo ./script.sh
# 原因3:文件系统只读mount -o remount,rw /13.4 子 Shell 问题
# 问题:管道中的变量修改不生效count=0echo -e "a\nb\nc" | while read line; do ((count++))doneecho $count # 0(管道在子 Shell 中执行)
# 解决1:使用进程替换count=0while read line; do ((count++))done < <(echo -e "a\nb\nc")echo $count # 3
# 解决2:使用 lastpipe(Bash 4.2+)shopt -s lastpipecount=0echo -e "a\nb\nc" | while read line; do ((count++))doneecho $count # 313.5 常见错误速查表
| 错误信息 | 原因 | 解决方案 |
|---|---|---|
syntax error near unexpected token | Windows 换行符/语法错误 | 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 interpreter | Windows 换行符 | 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 f2 | f1 比 f2 新 |
f1 -ot f2 | f1 比 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 常用组合速查
# 安全模式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.1doneecho ""15. 最佳实践
15.1 脚本规范
#!/usr/bin/env bash## 功能描述: 一句话说明脚本用途# 作者: nine# 创建日期: 2026-07-06# 修改记录: 2026-07-06 初始版本# 使用方法: ./script_name.sh [参数]#
set -euo pipefailIFS=$'\n\t'15.2 十条核心最佳实践
set -euo pipefail— 脚本开头必加,防错第一道防线- 变量加引号 —
"$var"而非$var,防空格和通配符 - 用
[[ ]]替代[ ]— 更安全,功能更强 - 函数用
local— 所有局部变量都加local shellcheck检查 — 提交前运行,消除隐患- Shebang 用
env—#!/usr/bin/env bash可移植 - 错误输出到 stderr —
echo "error" >&2 - 清理临时文件 —
trap 'rm -f "$tmp"' EXIT - 函数化 — 主逻辑拆成函数,
main "$@"调用 - 加日志 — 关键步骤加
echo或log函数
15.3 场景推荐
| 场景 | 推荐方案 |
|---|---|
| 简单文件操作 | Shell 脚本 |
| 系统运维自动化 | Shell 脚本 + cron |
| 复杂数据处理 | Python |
| CI/CD 流水线 | Shell + YAML |
| 文本日志分析 | Shell(grep/sed/awk) |
| API 调用 | Shell(curl)或 Python |
| 跨平台脚本 | Python |
| 快速原型 | Shell |
15.4 学习资源
# 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' # 条件表达式
# ShellCheckshellcheck 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 |
记住三个核心规律:
- 安全第一 —
set -euo pipefail+ 变量加引号 +shellcheck - 函数化 — 把逻辑拆成命名良好的函数,
main "$@"调用 - 组合优于编程 — Shell 的强项是组合现有命令(管道),而非复杂逻辑
如果你只记住一句话:
set -euo pipefail+ 变量加双引号 +shellcheck。 这三条消除 80% 的 Shell 脚本 bug。
推荐阅读
- Linux 常用命令速查手册 — Shell 脚本的基础命令
- Vim 编辑器完全指南 — 用 Vim 编写 Shell 脚本
- Tmux 终端复用器完全指南 — 在 Tmux 中运行长时间脚本
- SSH 完全指南 — 远程执行 Shell 脚本
- Nginx 反向代理配置实战 — 用脚本管理 Nginx
- Git 高级技巧与最佳实践 — 用 Shell 脚本自动化 Git 操作