1. 从一次线上事故说起:为什么.sh文件值得每个Linux使用者认真对待
很多人第一次接触Linux,都是从双击一个.sh文件开始的。有人用它一键部署环境,有人用它批量处理日志,也有人只是想让服务器每天凌晨自动备份一次数据库。.sh文件看起来简单,就是一堆命令堆在一起,但真正把它用好、用稳、用到生产环境里不出事,其实是一门需要踩过坑才能掌握的技能。
我自己就经历过一次典型的翻车:一个用来清理临时目录的脚本,因为变量没加引号,路径里带空格时直接把整个目录删空了。那次事故之后,我才真正意识到,Shell脚本不是“能跑就行”,而是要考虑边界条件、错误处理、权限控制和可维护性。这篇文章就围绕.sh文件在Linux系统中的实战应用展开,把Bash语法、chmod权限、脚本调试、常见报错排查这些内容串起来讲清楚。不管你是刚接触Linux的新手,还是已经能写几行脚本但总感觉不够扎实的运维人员,都能从下面这些内容里找到可以直接抄作业的部分。
文章会从脚本的整体设计思路讲起,再深入到核心语法细节、实操流程、权限管理,最后给出常见问题的排查方法。每一部分我都会尽量解释“为什么这么做”,而不是只丢一段代码让你照抄。毕竟脚本这东西,换个环境就可能出问题,理解原理比记住命令更重要。
2. 脚本整体设计与思路拆解
2.1 为什么选择Shell脚本而不是其他语言
在Linux环境里做自动化,可选的语言很多:Python、Perl、Go,甚至直接用Ansible这类工具。但.sh文件依然有它不可替代的位置。原因很直接:Shell是Linux的母语。系统启动、服务管理、日志切割、定时任务,底层几乎都是Shell在驱动。你写一个Shell脚本,不需要额外安装运行时,不需要考虑依赖版本,放到任何一台Linux机器上基本都能跑。
另一个关键优势是管道和命令组合。Shell天生擅长把grep、awk、sed、find这些工具串起来,几行代码就能完成复杂的文本处理。Python虽然也能做,但写起来往往更长,而且调用系统命令时还要处理子进程的返回值。对于“把几个命令按顺序执行并判断结果”这类任务,Shell是最短路径。
当然,Shell也有明显短板:不适合做复杂数据结构运算,不适合写大型应用,错误处理机制相对粗糙。所以我的选型原则是:系统层面的自动化、文件操作、命令编排用Shell;业务逻辑复杂、需要数据结构支撑的场景用Python。两者不是替代关系,而是配合关系。很多生产脚本就是Shell负责调度,Python负责具体计算。
2.2 一个合格脚本的骨架应该包含什么
很多人写脚本是从第一行命令直接开始的,写到哪算哪。这种脚本自己用还行,一旦交给别人或者放到定时任务里,问题就会暴露。一个结构清晰的脚本,通常包含这几个部分:
- Shebang行:
#!/bin/bash,明确告诉系统用哪个解释器执行。虽然sh和bash在很多系统上指向同一个程序,但语法并不完全兼容,写清楚能避免很多诡异问题。 - 脚本说明:用途、作者、修改日期、依赖环境。别小看这几行注释,半年后你自己回来看,没有说明的脚本跟天书一样。
- 严格模式:
set -euo pipefail,让脚本在出错时立即退出,使用未定义变量时报错,管道中任一环节失败都能被捕获。这一行能挡掉大量隐蔽bug。 - 变量定义区:把路径、文件名、阈值这些集中放在前面,方便修改,避免散落在各处。
- 函数定义区:把重复逻辑封装成函数,主流程只负责调用。
- 主流程:按业务顺序调用函数,逻辑一目了然。
- 退出码:明确返回0表示成功,非0表示失败,方便上层调用判断。
这个骨架不是形式主义,而是为了让脚本可读、可改、可排查。我见过太多“一次性脚本”最后变成核心业务依赖,结果没人敢动,就是因为当初没按结构写。
2.3 脚本执行方式的差异与选择
.sh文件有多种执行方式,效果并不一样,这一点新手特别容易混淆。
| 执行方式 | 命令示例 | 是否需执行权限 | 是否新开子进程 | 变量影响当前终端 |
|---|---|---|---|---|
| 直接执行 | ./script.sh | 需要 | 是 | 否 |
| 解释器执行 | bash script.sh | 不需要 | 是 | 否 |
| source执行 | source script.sh | 不需要 | 否 | 是 |
| 点号执行 | . script.sh | 不需要 | 否 | 是 |
直接执行和bash script.sh的区别在于,前者依赖Shebang行指定的解释器,后者强制用你指定的解释器。如果脚本里用了Bash特有语法(比如数组、[[ ]]),但Shebang写的是#!/bin/sh,直接执行就可能报错,而bash script.sh能正常跑。
source和.是在当前Shell环境里执行,不会新开进程。这意味着脚本里定义的变量、函数、cd操作都会影响当前终端。这个特性在加载环境变量配置时非常有用,但也要小心,脚本里的exit会直接退出你的终端。
提示:写正式脚本时,建议统一用
#!/bin/bash加./script.sh的方式执行,行为最可预测。需要加载环境配置时才用source。
3. 核心语法细节与实操要点
3.1 变量:引号、作用域与默认值
Shell变量看起来简单,但坑最多。最基本的一条规则:变量赋值时等号两边不能有空格。name = "test"会报错,必须写成name="test"。这是因为Shell把空格当作命令和参数的分隔符,加了空格它就把name当成命令去执行了。
引用变量时,永远加双引号。这是我最想强调的一条。看下面这个对比:
file_path="/data/my docs/report.txt" rm $file_path # 危险!会被拆成两个参数 rm "$file_path" # 正确,整体作为一个参数不加引号时,Shell会做单词拆分,路径里的空格会把一个参数变成两个,rm就可能删错东西。这个坑我踩过,代价是一个目录的文件全没了。
变量默认值也是常用技巧:
name=${1:-"default_user"} # 位置参数为空时用默认值 port=${PORT:-8080} # 环境变量未设置时用默认值:-表示变量为空或未定义时使用默认值,:=则会同时赋值。处理用户输入时非常实用。
关于作用域,Shell变量默认是全局的,函数里修改会影响外面。如果只想在函数内使用,加local关键字:
process_file() { local filename="$1" local count=0 # ... }不加local的变量会污染全局环境,函数多了以后很容易互相干扰。
3.2 条件判断:test、[ ]与[[ ]]的选择
条件判断是脚本逻辑的核心。Shell里有三种写法:test、[ ]和[[ ]]。它们功能重叠但行为有差异。
[ ]是test命令的别名,属于POSIX标准,所有Shell都支持。但它对变量引用很敏感,变量为空时如果不加引号会报语法错误:
if [ $name = "admin" ]; then # name为空时报错 if [ "$name" = "admin" ]; then # 正确[[ ]]是Bash的扩展语法,功能更强:支持正则匹配=~,支持&&和||逻辑运算,变量为空也不会报错。写Bash脚本时我基本都用[[ ]]。
if [[ "$name" =~ ^admin[0-9]+$ ]]; then echo "匹配管理员账号" fi数值比较要用-eq、-ne、-gt、-lt这些操作符,不能用>、<,因为后者在[ ]里是重定向符号。字符串比较用=和!=。文件判断用-f(普通文件)、-d(目录)、-e(存在)、-r(可读)、-w(可写)、-x(可执行)。
| 判断类型 | 操作符 | 示例 |
|---|---|---|
| 数值相等 | -eq | [ "$a" -eq 10 ] |
| 数值大于 | -gt | [ "$a" -gt 10 ] |
| 字符串相等 | = | [[ "$a" = "ok" ]] |
| 字符串匹配 | =~ | [[ "$a" =~ ^[0-9]+$ ]] |
| 文件存在 | -e | [ -e "$file" ] |
| 是目录 | -d | [ -d "$dir" ] |
3.3 循环:for、while与实战场景
for循环最常用的两种形式。一种是遍历列表:
for service in nginx mysql redis; do systemctl restart "$service" done另一种是C语言风格:
for ((i=0; i<10; i++)); do echo "第 $i 次执行" done遍历文件时,不要用for f in $(ls),这是经典错误。文件名带空格会被拆分,正确做法是用通配符或find配合while read:
for f in /data/*.log; do echo "处理 $f" done find /data -name "*.log" -print0 | while IFS= read -r -d '' f; do echo "处理 $f" donewhile循环适合“条件满足就一直做”的场景,比如等待服务启动:
retry=0 while ! curl -s http://localhost:8080/health > /dev/null; do retry=$((retry+1)) if [ "$retry" -ge 30 ]; then echo "服务启动超时" exit 1 fi sleep 2 done这里$((retry+1))是算术运算,比expr更简洁。注意retry初始化和边界判断,否则可能死循环。
3.4 函数与参数传递
函数让脚本模块化。定义和调用都很直接:
backup_db() { local db_name="$1" local backup_dir="$2" mysqldump "$db_name" > "$backup_dir/${db_name}_$(date +%F).sql" } backup_db "app_db" "/backup"函数内部用$1、$2接收参数,和脚本的位置参数一样。返回值用return只能返回0-255的整数,要返回字符串得用echo输出,调用方用$(函数名)捕获:
get_timestamp() { echo "$(date +%Y%m%d%H%M%S)" } ts=$(get_timestamp)local关键字一定要加,否则函数里的变量会覆盖全局同名变量,这种bug特别难查。
4. 权限管理与chmod实战
4.1 为什么脚本需要执行权限
新建的.sh文件默认没有执行权限,直接./script.sh会报Permission denied。这是因为Linux对文件有读、写、执行三种权限,分别对应r、w、x。脚本要被执行,必须有x权限。
用ls -l看权限:
-rw-r--r-- 1 user user 1024 Jan 1 10:00 script.sh第一位是文件类型,-表示普通文件,d表示目录。后面九位分三组,分别是所有者、所属组、其他人的权限。上面这个文件所有者只有读写权限,没有执行权限。
4.2 chmod的数字模式与符号模式
chmod改权限有两种写法。数字模式用三位八进制数表示:
| 数字 | 权限 | 含义 |
|---|---|---|
| 7 | rwx | 读+写+执行 |
| 6 | rw- | 读+写 |
| 5 | r-x | 读+执行 |
| 4 | r-- | 只读 |
| 0 | --- | 无权限 |
所以chmod 755 script.sh表示所有者可读写执行,组和其他人可读可执行。chmod 644 file.txt表示所有者可读写,其他人只读。
符号模式更直观:
chmod +x script.sh # 给所有人加执行权限 chmod u+x script.sh # 只给所有者加执行权限 chmod go-w file.txt # 去掉组和其他人的写权限u代表所有者,g代表组,o代表其他人,a代表所有人。+加权限,-减权限,=设置权限。
4.3 chmod 777的风险与正确用法
chmod 777是网上出现频率最高的命令之一,遇到权限问题就有人建议777。但这个命令意味着所有人对文件有完全控制权,包括读、写、执行。在生产环境里,这等于把门完全敞开。
风险具体体现在:任何用户都能修改脚本内容,如果脚本以root身份运行,等于给了普通用户提权通道;任何用户都能删除或替换文件,可能被植入恶意代码。所以chmod 777只应该用在临时调试,绝不能出现在正式部署里。
正确的做法是最小权限原则:脚本文件用755(所有者可改,其他人只能执行),配置文件用644(所有者可改,其他人只读),敏感数据文件用600(只有所有者可读写)。
chmod 755 deploy.sh chmod 644 config.conf chmod 600 secret.key注意:如果脚本需要写日志或生成文件,不要给脚本本身加写权限,而是确保脚本运行用户对目标目录有写权限。权限应该加在目录上,而不是脚本上。
4.4 特殊权限位与粘滞位
除了基本权限,还有三个特殊位:SUID、SGID、粘滞位。SUID让程序以文件所有者身份运行,SGID让程序以文件所属组身份运行,粘滞位(t)用在目录上,让目录里的文件只有所有者能删除。
chmod 1777 /tmp # 粘滞位,/tmp目录的典型配置 chmod u+s /usr/bin/passwd # SUID示例粘滞位在共享目录里很有用,比如/tmp,所有人都能写,但只能删自己的文件。SUID和SGID风险较高,普通脚本不要用,这里了解即可。
5. 完整实操流程与核心环节实现
5.1 场景定义:每日日志清理与备份脚本
光讲语法不够,下面用一个完整场景把前面内容串起来。需求是:每天凌晨清理/var/log/app下超过7天的日志,把清理前的日志打包备份到/backup/logs,保留最近30天的备份,整个过程记录日志,出错时发邮件通知。
这个场景覆盖了文件判断、循环、日期计算、打包、权限、错误处理,是运维里非常典型的任务。
5.2 脚本完整实现与逐段解析
#!/bin/bash # 用途:清理并备份应用日志 # 作者:运维组 # 依赖:tar, find, mail set -euo pipefail LOG_DIR="/var/log/app" BACKUP_DIR="/backup/logs" KEEP_DAYS=7 BACKUP_KEEP_DAYS=30 SCRIPT_LOG="/var/log/log_cleaner.log" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$SCRIPT_LOG" } send_alert() { local msg="$1" echo "$msg" | mail -s "日志清理脚本告警" ops@example.com } cleanup_old_backup() { find "$BACKUP_DIR" -name "logs_*.tar.gz" -mtime +"$BACKUP_KEEP_DAYS" -delete log "已清理超过 ${BACKUP_KEEP_DAYS} 天的备份" } main() { if [ ! -d "$LOG_DIR" ]; then log "错误:日志目录 $LOG_DIR 不存在" send_alert "日志目录不存在,脚本退出" exit 1 fi mkdir -p "$BACKUP_DIR" local timestamp timestamp=$(date +%Y%m%d%H%M%S) local archive="$BACKUP_DIR/logs_${timestamp}.tar.gz" local old_logs old_logs=$(find "$LOG_DIR" -name "*.log" -mtime +"$KEEP_DAYS") if [ -z "$old_logs" ]; then log "没有超过 ${KEEP_DAYS} 天的日志需要处理" exit 0 fi tar -czf "$archive" $old_logs log "已备份到 $archive" echo "$old_logs" | while IFS= read -r f; do rm -f "$f" log "已删除 $f" done cleanup_old_backup log "任务完成" } main "$@"逐段看几个关键点。set -euo pipefail放在最前面,保证任何命令失败都会让脚本退出,避免错误被忽略。log函数用tee -a同时输出到终端和日志文件,方便调试和留痕。
find的-mtime +7表示修改时间超过7天,注意是大于7天,不是7天以内。-delete直接删除,比-exec rm更高效。
tar -czf打包时,$old_logs没有加引号,这是故意的,因为需要让find的输出按行拆分成多个参数传给tar。但前提是文件名里没有空格。如果日志文件名可能带空格,就要改用-T参数从文件读取列表:
find "$LOG_DIR" -name "*.log" -mtime +"$KEEP_DAYS" -print0 | \ tar -czf "$archive" --null -T -main "$@"把脚本接收的所有参数传给main函数,这是标准写法,方便后续扩展。
5.3 部署与定时任务配置
脚本写好后,先赋执行权限:
chmod 755 /usr/local/bin/log_cleaner.sh然后配置定时任务。用crontab -e编辑当前用户的定时任务:
0 2 * * * /usr/local/bin/log_cleaner.sh >> /var/log/log_cleaner_cron.log 2>&1这行表示每天凌晨2点执行,标准输出和错误都追加到日志文件。cron环境变量和登录Shell不一样,PATH可能不完整,所以脚本里最好用绝对路径,或者在脚本开头显式设置PATH:
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin5.4 脚本调试技巧
脚本不按预期运行时,别急着改代码,先用调试手段定位。bash -x script.sh会打印每条执行的命令和变量展开结果,非常直观:
bash -x log_cleaner.sh输出里带+的行是实际执行的命令,能清楚看到变量被替换成了什么值。如果输出太多,可以在脚本里局部开启:
set -x # 开启调试 # 可疑代码段 set +x # 关闭调试另一个技巧是用shellcheck做静态检查。这个工具能发现引号缺失、变量未定义、语法错误等常见问题,相当于给脚本做体检:
shellcheck log_cleaner.sh我现在的习惯是脚本写完先过一遍shellcheck,能挡掉八成低级错误。
6. 常见问题与排查技巧实录
6.1 典型报错速查表
| 报错信息 | 常见原因 | 解决方法 |
|---|---|---|
Permission denied | 没有执行权限 | chmod +x script.sh |
bad interpreter: No such file | Shebang路径错误或文件有Windows换行符 | 检查#!/bin/bash,用dos2unix转换 |
command not found | PATH不完整或命令未安装 | 用绝对路径或补全PATH |
unary operator expected | 变量为空导致[ ]语法错误 | 变量加双引号,或改用[[ ]] |
syntax error near unexpected token | 括号、引号不匹配 | 用bash -n检查语法 |
No such file or directory | 路径错误或变量未展开 | 用bash -x查看实际路径 |
Argument list too long | 通配符展开文件过多 | 改用find -exec或xargs |
6.2 换行符导致的诡异问题
从Windows传过来的.sh文件经常报bad interpreter,原因就是换行符是\r\n而不是\n。Shebang行末尾多了个\r,系统找不到/bin/bash\r这个解释器。
排查方法:
cat -A script.sh | head -1如果行尾显示^M$,就是Windows换行符。用dos2unix转换:
dos2unix script.sh或者用sed去掉:
sed -i 's/\r$//' script.sh这个问题在跨平台协作时特别常见,建议把dos2unix加入脚本发布流程。
6.3 变量为空引发的连锁故障
变量为空是脚本事故的头号原因。比如rm -rf "$dir/",如果dir为空,就变成rm -rf /,后果不堪设想。防御手段有几个:
第一,用set -u让未定义变量直接报错。第二,删除操作前做非空判断:
if [ -z "$dir" ]; then echo "目录变量为空,终止" exit 1 fi第三,用${var:?}语法,变量为空时直接退出并报错:
rm -rf "${dir:?目录变量未设置}/"这个写法在关键操作前特别值得加,等于给自己上了道保险。
6.4 管道与子Shell的变量陷阱
while循环放在管道后面时,循环体在子Shell里执行,里面修改的变量外面看不到:
count=0 echo -e "a\nb\nc" | while read -r line; do count=$((count+1)) done echo "$count" # 输出0,不是3解决办法是用进程替换:
count=0 while read -r line; do count=$((count+1)) done < <(echo -e "a\nb\nc") echo "$count" # 输出3< <(...)是进程替换,把命令输出当作文件输入,循环在当前Shell执行,变量修改能保留。这个坑我在统计日志行数时踩过,排查了半天才发现是子Shell的问题。
6.5 实操心得与避坑清单
最后分享几条我这些年总结的经验。第一,脚本里所有路径用绝对路径,相对路径在cron里会以用户家目录为基准,很容易找不到文件。第二,关键操作前先echo再执行,确认无误后再去掉echo,尤其是rm、mv、>这类破坏性操作。第三,脚本要有幂等性,重复执行结果一致,这样失败重跑才安全。第四,日志要带时间戳和上下文,出问题时能快速定位。第五,重要脚本纳入版本管理,用Git记录每次修改,出问题能回滚。
还有一点,别迷信chmod 777。我见过太多人遇到权限问题就777,结果把系统搞出一堆安全隐患。权限问题要具体分析:是文件没有执行权限,还是目录没有写权限,还是运行用户不对。找到根因再改,而不是一把梭。
脚本这东西,写出来能跑只是及格,能稳定跑、出错能查、别人能接手,才算合格。多花十分钟加错误处理和日志,能省下未来几小时的排查时间。