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/bashLinux 默认 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、DebianWindows 上开启 WSL也可以购买一台低配云服务器。生产环境建议使用发行版官方维护的版本并提前确认 Bash 版本因为新版 Bash 对[[ ]]、数组、字符串处理支持更完整。先把环境信息确认一遍避免后面脚本跑出莫名其妙的问题。执行下面几条命令uname -a cat /etc/os-release bash --version echo $SHELLuname -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 namealice age18 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_days30 find ${log_dir} -type f -name *.log -mtime ${keep_days} -print这条命令会列出log_dir下修改时间超过 30 天的日志文件。-mtime 30表示修改时间大于 30 天-30表示 30 天以内不带符号表示正好等于 30 天容易写混。先-print打印确认列表正确再把-print改成-deletefind ${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_days30 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_RUN1只打印会删除哪些文件。确认无误后用DRY_RUN0 ./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 21; 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 21表示丢弃标准输出和错误输出我们只关心退出码。实际部署中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_fail3 if ! pgrep -x nginx /dev/null 21; then count0 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 21cron 的字段依次是分、时、日、月、周。这条规则表示每天 3 点 0 分执行。后面必须有输出重定向否则 cron 出错时很难看到日志。Cron 环境里的 PATH 通常比较小脚本内部要尽量使用绝对路径或者在脚本开头显式设置 PATH。#!/usr/bin/env bash export PATH/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin5. 脚本跑不通时按这条链路排查5.1 常见报错和根因速查表报错或现象常见原因排查方式处理建议command not found命令未安装或 PATH 不完整执行which 命令、type 命令安装命令或在脚本开头设置 PATHPermission denied脚本没有执行权限ls -l script.shchmod x script.shsyntax error: unexpected end of fileif/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 是进入自动化运维领域性价比最高的第一门语言先把这里面的变量、条件、循环、函数和调试方法磨熟后面学任何运维工具都会更快。