Shell脚本实战:从基础语法到自动化运维脚本编写
Shell 脚本是 Linux 自动化运维里最基础也最高频的技能。很多新手把ls、cd当成 shell 的全部,但遇到批量改文件、清理日志、定时备份、服务异常拉起这些任务时,才发现自己只会手动一条条敲命令,敲完还容易漏掉某一步。这篇文章默认读者没有写过脚本,从 shell 是什么开始讲,一路写到可以放到生产环境跑的基础运维脚本,中间会解释为什么要这样写,以及脚本报错时应该怎么查。
文章会围绕一条学习主线展开:先用最小脚本理解执行流程,再掌握变量、条件、循环、函数这些骨架语法,然后通过日志清理、批量重命名、进程守护三个实战案例把语法串起来,最后处理最常见的调试和排错问题。每段都会给出可直接运行的命令和代码,读者可以用自己的 Linux 机器或虚拟机跟着做一遍。学完之后,至少能把日常重复性的运维操作改成脚本执行,并能理解别人写的常见运维脚本。
1. 先搞清楚 Shell 到底是什么,以及自动化运维为什么依赖它
1.1 Shell 不是“黑窗口”,而是一层命令解释器
在 Linux 系统里,Shell 是一个位于用户和内核之间的程序。用户每输入一行命令,Shell 负责解析命令、查找可执行程序、传递参数,并把结果返回给终端。之所以叫“Shell”,是因为它像外层壳一样包裹着内核,提供交互界面。常见的 Shell 包括 Bash、sh、Zsh、Dash 等,它们在语法细节上有差异,但在 Linux 上最通用的还是 Bash,很多发行版默认也使用 Bash。
| Shell 类型 | 常见位置 | 特点 |
|---|---|---|
| bash | /bin/bash | Linux 默认 shell,功能丰富,适合编写运维脚本 |
| sh | /bin/sh | 兼容性较好,很多系统上实际由 dash 提供 |
| zsh | /bin/zsh | 交互体验好,兼容 bash 大部分语法,常用于开发环境 |
| dash | /bin/dash | 轻量、启动快,通常用于系统初始化脚本 |
写脚本的时候,应该在文件第一行声明用哪个解释器执行,例如#!/usr/bin/env bash。这个声明叫 shebang。这里不是普通注释,而是告诉系统:如果这个脚本有执行权限并被直接运行,就使用/usr/bin/env找到的bash程序来执行它。使用env而不是写死/bin/bash,可以兼容 bash 安装在不同路径的情况。
1.2 自动化运维里的 Shell 价值:把重复动作变成可复用流程
自动化的本质不是“写一段没人看得懂的复杂命令”,而是把频繁操作固化成可重复执行、可记录、可排错的流程。日常运维中,Shell 特别适合处理三类问题:批量操作(对多台服务器或多文件做统一处理)、定时任务(每天凌晨清理日志、备份数据)、异常恢复(进程挂了自动拉起,磁盘满了发告警)。这些场景命令本身不难,难的是把边界情况处理好,比如文件不存在、权限不足、目录为空、服务名变化。
Shell 脚本正是用程序逻辑把命令串起来的胶水。它可以调用系统自带的find、grep、awk、sed等工具,也能调用自己编写的二进制程序。如果你需要解析 JSON,可以用jq;需要处理复杂文本,可以用awk和sed;需要做更大的配置管理,可以上 Ansible 或 SaltStack。但无论上什么工具,最终落到底层仍然离不开命令和脚本思维。把 Shell 学扎实,后续理解自动化运维工具会轻松很多。
2. 搭建一个能安心练手的环境,再确认 Bash 版本和脚本执行权限
2.1 学习环境和生产环境怎么准备
入门阶段不需要生产机器,最少只需要一台有 Linux 环境的主机。常见做法:虚拟机装 Ubuntu、CentOS Stream、Debian;Windows 上开启 WSL;也可以购买一台低配云服务器。生产环境建议使用发行版官方维护的版本,并提前确认 Bash 版本,因为新版 Bash 对[[ ]]、数组、字符串处理支持更完整。
先把环境信息确认一遍,避免后面脚本跑出莫名其妙的问题。执行下面几条命令:
uname -a cat /etc/os-release bash --version echo "$SHELL"uname -a查看内核版本和架构;cat /etc/os-release查看发行版名称和版本;bash --version确认 Bash 版本;echo "$SHELL"查看当前登录用户的默认 shell。如果默认 shell 不是 bash,可以手动执行bash进入,但这只影响当前终端,不影响系统其他用户。
2.2 脚本权限、执行方式和常见的 Permission denied
先创建一个练习目录和第一个脚本:
mkdir -p ~/shell-lab cd ~/shell-lab cat > hello.sh <<'EOF' #!/usr/bin/env bash echo "hello, shell" EOF这里使用cat和 heredoc 把内容写入hello.sh。<<'EOF'中的单引号表示内容里的变量不会被立即展开,保持原样写入文件。
先用bash直接执行,这条命令不要求脚本有执行权限:
bash hello.sh如果直接改成./hello.sh执行,大概率会提示Permission denied。因为直接执行脚本时,内核需要检查文件是否有执行权限。这时需要设置执行位:
chmod +x hello.sh ./hello.sh| 执行方式 | 是否要求文件可执行 | 执行环境 | 推荐场景 |
|---|---|---|---|
bash hello.sh | 不需要 | 新启动 bash 子进程 | 快速测试,不关心文件权限 |
./hello.sh | 需要 | 按 shebang 指定的解释器执行 | 正式发布脚本,路径固定 |
source hello.sh或. hello.sh | 不需要 | 当前 shell 进程内执行 | 加载配置、修改当前环境变量 |
直接执行脚本时会创建一个新的 Bash 子进程,脚本里定义的变量不会影响当前终端。使用source时,脚本是在当前进程里执行,所以变量会保留下来,这也是为什么修改~/.bashrc后要用source ~/.bashrc让配置立即生效。
注意:
./hello.sh启动后会创建一个新的 Bash 进程;source hello.sh则是在当前进程里执行。前者适合隔离,后者适合改环境变量。
3. 从变量、条件判断到循环,把 Shell 脚本的骨架搭起来
3.1 变量、引号和命令替换
变量是脚本最基本的数据容器。Shell 变量名区分大小写,等号两侧不能有空格,否则会被当作命令而不是赋值。
#!/usr/bin/env bash name="alice" age=18 echo "${name} is ${age}" today=$(date +%F) echo "today is ${today}"双引号里的变量会展开,单引号里的内容会原样输出。为了避免文件路径或字符串中包含空格导致被拆成多个参数,建议所有变量都用"${变量}"的形式引用。命令替换使用$(...)代替老式的反引号,因为$(...)可读性更好,也支持嵌套。
| 特殊变量 | 含义 |
|---|---|
$0 | 脚本名 |
$1,$2, ... | 第 1、第 2 个位置参数 |
$# | 位置参数的个数 |
$@ | 所有参数,双引号引用时每个参数独立 |
$? | 上一条命令的退出码,0 表示成功 |
$$ | 当前脚本进程 PID |
带参数使用的例子:
#!/usr/bin/env bash echo "脚本名: $0" echo "第一个参数: $1" echo "参数数量: $#"如果是接收多个文件,推荐用"$@"而不是$@,这样参数中的空格和特殊字符不会被打散。
3.2 条件判断:if、test 和 [[ ]]
Shell 的if不是按照“布尔表达式”来理解,而是按照“命令退出码”来理解。命令返回 0 表示真,返回非 0 表示假。因此可以把grep、test等命令直接作为判断条件。
常用文件测试表达式:
| 表达式 | 含义 |
|---|---|
-e 文件 | 文件或目录存在 |
-f 文件 | 存在且是普通文件 |
-d 文件 | 存在且是目录 |
-r 文件 | 存在且可读 |
-w 文件 | 存在且可写 |
-x 文件 | 存在且可执行 |
字符串和数值判断也有固定写法:=判断字符串相等,!=判断不等,-z判断字符串为空,-n判断非空;数值比较使用-eq、-ne、-gt、-ge、-lt、-le。
#!/usr/bin/env bash config="/etc/nginx/nginx.conf" if [ -f "${config}" ]; then echo "配置文件存在" else echo "配置文件不存在" fi if [ "$(id -u)" -eq 0 ]; then echo "当前是 root" else echo "当前不是 root" fi这里要注意[本身是一个命令,后面必须有空格,条件表达式和]之间也必须用空格隔开。另一个常见问题是[ $var = "x" ]在$var为空时会展开成[ = "x" ],直接报语法错误。所以变量必须加双引号。
在 Bash 中,推荐使用[[ ]]替代[ ]。[[ ]]是 Bash 内置的关键字,不需要额外空格也能正常工作,支持&&、||、正则匹配=~,而且变量为空时不会报错。但要注意[[ ]]是 Bash 扩展,如果脚本声明的是#!/bin/sh,那么不能保证所有系统都支持。建议脚本统一写#!/usr/bin/env bash。
3.3 for/while 循环和函数封装
循环用于处理重复任务。for遍历一个列表,while在条件为真时反复执行。
#!/usr/bin/env bash for i in 1 2 3 4 5; do echo "number: ${i}" done for file in *.txt; do echo "find: ${file}" done第一个循环输出 1 到 5。第二个循环会把当前目录下所有.txt文件名展开并逐个赋值给file。这里有一个隐藏问题:如果目录下没有任何.txt文件,Shell 默认不会把*.txt当作空列表,而是把字面的*.txt当作一个普通字符串。这样脚本可能进入预期之外的逻辑。可以用nullglob选项规避:
#!/usr/bin/env bash shopt -s nullglob for file in *.txt; do echo "find: ${file}" done shopt -u nullglobshopt -s nullglob之后,没有匹配时列表为空,循环不会执行。用完再关掉,避免影响脚本其他地方的通配符行为。
函数用来封装一段可复用的逻辑:
#!/usr/bin/env bash log_info() { echo "[INFO] $(date '+%F %T') $*" } log_info "start backup" log_info "end backup"函数体内可以直接使用$1、$2作为自己的参数,也可以使用$*表示所有参数。函数里的变量默认是全局的,如果只想在函数内部使用,用local声明:
#!/usr/bin/env bash process_file() { local target="$1" echo "处理文件: ${target}" }3.4 用 set -euo pipefail 提前发现错误
写了多条命令的脚本,如果某条命令失败,默认 Shell 会继续执行后面的命令。这在自动化场景很容易放大故障。比如删除文件的命令失败了,脚本却继续执行后续的打包命令,最后得到一个不完整的结果。使用set -e可以让脚本在遇到非零退出码时立即退出。
#!/usr/bin/env bash set -euo pipefailset -e:命令失败时立即退出。set -u:把未定义变量当作错误处理。set -o pipefail:管道中最后一个非零退出码作为整个管道的退出码。
比如grep找不到匹配时返回 1,如果脚本里有grep pattern file这样直接执行的命令,加了set -e后脚本会退出。但在if条件中的命令例外,因为if本身需要根据退出码分支,不会直接退出。
注意:
set -e只能拦截“未处理”的非零退出码,管道中的失败需要set -o pipefail配合,函数返回值也要注意用return显式传递。
4. 用三个运维实战案例把语法串起来
4.1 案例一:清理过期日志文件
日志清理是运维最常见的需求。最简单的思路是:找出指定目录下修改时间超过 N 天的.log文件,先打印列表,确认无误后删除。
#!/usr/bin/env bash set -euo pipefail log_dir="/var/log/myapp" keep_days=30 find "${log_dir}" -type f -name "*.log" -mtime +${keep_days} -print这条命令会列出log_dir下修改时间超过 30 天的日志文件。-mtime +30表示修改时间大于 30 天,-30表示 30 天以内,不带符号表示正好等于 30 天,容易写混。先-print打印,确认列表正确,再把-print改成-delete:
find "${log_dir}" -type f -name "*.log" -mtime +${keep_days} -delete生产环境执行删除之前,建议先加一个 dry-run 模式,通过环境变量或参数控制是否真正删除。
#!/usr/bin/env bash set -euo pipefail log_dir="${1:-/var/log/myapp}" keep_days=30 dry_run="${DRY_RUN:-1}" if [ "${dry_run}" = "1" ]; then find "${log_dir}" -type f -name "*.log" -mtime +${keep_days} -print else find "${log_dir}" -type f -name "*.log" -mtime +${keep_days} -delete fi默认DRY_RUN=1,只打印会删除哪些文件。确认无误后,用DRY_RUN=0 ./clean_log.sh真正执行。这样能很大程度避免误删。真正部署前还要确认日志目录是否存在、是否有备份策略,以及脚本被 cron 调用时的 PATH。
4.2 案例二:批量重命名文件
批量重命名是另一个高频需求。假设一个目录里有一批report_2025.txt文件,需要把.txt改成.bak,可以用for循环加参数扩展实现。
#!/usr/bin/env bash set -euo pipefail shopt -s nullglob for file in *.txt; do mv -v "${file}" "${file%.txt}.bak" done${file%.txt}是参数扩展,表示从文件名右侧删除匹配的.txt,然后追加.bak。%删除最短匹配,%%删除最长匹配。-v参数让mv打印每一步操作,方便观察。安全做法是先把mv改成echo mv试运行,确认后去掉echo再执行。
如果文件名中包含空格,这里必须给变量加双引号。如果文件超过数百个,还要注意命令长度限制,通常用find + xargs或rename工具来扩展。
4.3 案例三:监控服务进程并在异常时自动拉起
服务进程的保护通常由 systemd 或 supervisor 这类进程管理器完成,但在没有这些工具的简化环境,可以用 Shell 写一个轻量看护脚本。核心思路:用pgrep检查进程是否存在,不存在就启动。
#!/usr/bin/env bash set -euo pipefail service_bin="/usr/sbin/nginx" if pgrep -x "nginx" > /dev/null 2>&1; then echo "[OK] nginx is running" else echo "[WARN] nginx is down, try to start" if [ -x "${service_bin}" ]; then "${service_bin}" else echo "[ERROR] ${service_bin} not executable" >&2 exit 1 fi fipgrep -x "nginx"要求进程名精确匹配,避免把nginx-foo也算进来。> /dev/null 2>&1表示丢弃标准输出和错误输出,我们只关心退出码。实际部署中,nginx启动后会有 master 和 worker 多个进程,pgrep -x nginx可能匹配到子进程,具体要看部署方式。如果有 systemd,更推荐用systemctl is-active nginx判断。
如果担心脚本反复拉起失败进程,可以引入失败次数记录。
#!/usr/bin/env bash set -euo pipefail fail_file="/tmp/nginx_guard_fail_count" max_fail=3 if ! pgrep -x "nginx" > /dev/null 2>&1; then count=0 if [ -f "${fail_file}" ]; then count=$(cat "${fail_file}") fi count=$((count + 1)) echo "${count}" > "${fail_file}" if [ "${count}" -ge "${max_fail}" ]; then echo "[ERROR] nginx failed too many times" >&2 exit 1 fi systemctl start nginx echo "[WARN] nginx restarted, count=${count}" else rm -f "${fail_file}" fi这里用本地文件记录失败次数,一旦连续失败超过max_fail次,脚本就退出并输出错误。生产环境更应该把日志输出到文件,并把告警接入钉钉、企业微信或其他通知渠道。
4.4 用 crontab 让脚本按计划运行
脚本写好之后,借助 crontab 做定时执行。例如每天凌晨 3 点清理日志:
crontab -e加入一行:
0 3 * * * /opt/scripts/clean_log.sh >> /var/log/clean_log.log 2>&1cron 的字段依次是:分、时、日、月、周。这条规则表示每天 3 点 0 分执行。后面必须有输出重定向,否则 cron 出错时很难看到日志。Cron 环境里的 PATH 通常比较小,脚本内部要尽量使用绝对路径,或者在脚本开头显式设置 PATH。
#!/usr/bin/env bash export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"5. 脚本跑不通时,按这条链路排查
5.1 常见报错和根因速查表
| 报错或现象 | 常见原因 | 排查方式 | 处理建议 |
|---|---|---|---|
command not found | 命令未安装,或 PATH 不完整 | 执行which 命令、type 命令 | 安装命令,或在脚本开头设置 PATH |
Permission denied | 脚本没有执行权限 | ls -l script.sh | chmod +x script.sh |
syntax error: unexpected end of file | if/for 缺少fi/done,引号未闭合 | 编辑器语法高亮,检查成对关键字 | 补齐关键字或引号 |
bad interpreter: /bin/bash: no such file or directory | 脚本第一行解释器路径不对,或文件有 CRLF 换行 | head -1 script、file script、cat -A script | 修改 shebang,执行dos2unix |
| 变量值被拆成多个单词 | 变量没有加双引号 | bash -x script.sh观察实际执行命令 | 所有变量用"${var}"包裹 |
排查顺序建议:先看输入,包括文件路径、参数、环境变量;再看文件权限和解释器;然后看依赖命令是否存在;最后用bash -x逐行跟踪。不要刚看到报错就去改代码,先确认当前脚本到底是在什么环境、什么用户下执行的。
5.2 用 bash -x 观察每一步执行
最有效的调试方法是让 Bash 打印每条命令展开后的结果:
bash -x clean_log.sh输出中每行前面有+号,表示该行命令展开后的实际内容。如果看到变量为空,说明变量赋值或参数传递有问题;如果看到某条命令报错但没有退出,则需要检查是否设置了set -e,以及是否在管道中执行。
也可以在脚本内临时加set -x,调试完删掉。使用echo输出关键变量是笨办法但很直接。注意不要在大量循环里频繁echo,避免输出淹没日志。
for file in *.txt; do echo "DEBUG file=${file}" # 具体处理逻辑 done查完变量,再看退出码。在命令后面执行echo $?,0表示成功,非 0 表示失败。注意管道后的$?默认是最后一个命令的退出码,所以配合set -o pipefail更可靠。
5.3 引用、空格、换行符和通配符的坑
Shell 脚本最常见的错误发生在“展开”阶段。不写双引号会把带空格的路径拆成多个参数,if [ $var = "x" ]在变量为空时会变成if [ = "x" ],直接报错。解决方案是始终给变量加双引号:[ "${var}" = "x" ],或者使用[[ ]]。
Windows 下编辑过脚本后传到 Linux,可能出现bad interpreter或$'\r'之类的问题,因为文件末尾多了\r。检查方式:
file hello.sh cat -A hello.sh如果看到^M$,说明文件是 CRLF 换行。转换方式:
dos2unix hello.sh # 或者用 sed sed -i 's/\r$//' hello.sh通配符*在没有匹配时会保留原样,配合shopt -s nullglob可以避免。如果文件名以-开头,命令会误认为选项,需要使用--分隔,例如rm -- -file.txt。
6. Shell 脚本的工程化实践与检查清单
6.1 写一个可维护的脚本骨架
一个适合长期维护的脚本,至少应该包含:解释器声明、用途说明、set -euo pipefail、变量定义、主流程、日志函数、退出码。参考骨架如下:
#!/usr/bin/env bash # # 脚本名称: backup_log.sh # 用途: 备份并清理指定目录的日志文件 # 使用: ./backup_log.sh /var/log/myapp 30 # set -euo pipefail log_info() { echo "[$(date '+%F %T')] $*" } die() { echo "[ERROR] $*" >&2 exit 1 } log_dir="${1:-}" keep_days="${2:-30}" if [ -z "${log_dir}" ]; then die "用法: $0 <log_dir> [keep_days]" fi if [ ! -d "${log_dir}" ]; then die "目录不存在: ${log_dir}" fi log_info "开始清理: ${log_dir}, 保留 ${keep_days} 天" find "${log_dir}" -type f -name "*.log" -mtime +"${keep_days}" -print log_info "执行完成" exit 0这个骨架没有直接删除文件,适合作为模板扩展。die函数把错误信息输出到标准错误,并返回非零退出码。正式使用前要确认find的-mtime参数、日志文件命名规则、是否有历史备份等。
6.2 安全和性能建议:引用、eval、临时文件、凭据
脚本越接近生产,越要注意安全和副作用。
不要使用eval拼接用户输入,这很容易引发命令注入。如果非用不可,要仔细校验输入。其次,变量必须加双引号,尤其是把用户输入作为文件路径或参数时。第三,临时文件要用mktemp创建,而不是使用固定路径,并配置trap清理:
tmp_file=$(mktemp) trap 'rm -f "${tmp_file}"' EXIT敏感信息如数据库密码,不应该硬编码在脚本里,可以读取权限受限的配置文件,或使用密钥管理工具。脚本给他人使用前检查是否有chmod 777、是否包含明文密码。
性能方面,避免在循环里重复调用外部命令。例如统计文件行数,不要对每个文件都启动一次wc,先收集文件列表,再用xargs或awk统一处理。Shell 适合做调度和胶水,复杂数据处理交给awk、sed、jq或 Python。
6.3 发布前检查清单和下一步学习方向
下面这份检查清单可以直接用于脚本上线前走查:
- 脚本开头是否有
#!/usr/bin/env bash。 - 是否设置
set -euo pipefail,并明确已知例外。 - 使用到的命令是否都已安装,PATH 是否完整。
- 所有变量是否都加了双引号。
- 是否处理了空目录、文件不存在、权限不足等异常情况。
- 删除、移动、覆盖等破坏性操作是否有 dry-run 或人工确认。
- 是否打印了可读日志,是否记录退出码。
- 定时任务是否把标准输出和错误输出重定向到日志。
- 脚本是否包含明文密码或敏感信息。
- 是否在测试环境执行过,是否验证过失败恢复。
学完基础语法,可以继续深入:掌握正则表达式和awk、sed处理文本;用jq解析 JSON;用find和xargs构建复杂文件操作;了解 systemd 的 Timer 代替 crontab;再往后可以理解 Ansible、SaltStack 这类自动化工具,你会发现它们底层大量依赖 Shell 命令,但把状态管理和幂等性封装好了。Shell 是进入自动化运维领域性价比最高的第一门语言,先把这里面的变量、条件、循环、函数和调试方法磨熟,后面学任何运维工具都会更快。
