
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会报错必须写成nametest。这是因为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 count0 # ... }不加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 ((i0; i10; i)); do echo 第 $i 次执行 done遍历文件时不要用for f in $(ls)这是经典错误。文件名带空格会被拆分正确做法是用通配符或find配合while readfor f in /data/*.log; do echo 处理 $f done find /data -name *.log -print0 | while IFS read -r -d f; do echo 处理 $f donewhile循环适合“条件满足就一直做”的场景比如等待服务启动retry0 while ! curl -s http://localhost:8080/health /dev/null; do retry$((retry1)) if [ $retry -ge 30 ]; then echo 服务启动超时 exit 1 fi sleep 2 done这里$((retry1))是算术运算比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改权限有两种写法。数字模式用三位八进制数表示数字权限含义7rwx读写执行6rw-读写5r-x读执行4r--只读0---无权限所以chmod 755 script.sh表示所有者可读写执行组和其他人可读可执行。chmod 644 file.txt表示所有者可读写其他人只读。符号模式更直观chmod x script.sh # 给所有人加执行权限 chmod ux 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 us /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_DAYS7 BACKUP_KEEP_DAYS30 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 日志清理脚本告警 opsexample.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 21这行表示每天凌晨2点执行标准输出和错误都追加到日志文件。cron环境变量和登录Shell不一样PATH可能不完整所以脚本里最好用绝对路径或者在脚本开头显式设置PATHexport 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.shbad interpreter: No such fileShebang路径错误或文件有Windows换行符检查#!/bin/bash用dos2unix转换command not foundPATH不完整或命令未安装用绝对路径或补全PATHunary operator expected变量为空导致[ ]语法错误变量加双引号或改用[[ ]]syntax error near unexpected token括号、引号不匹配用bash -n检查语法No such file or directory路径错误或变量未展开用bash -x查看实际路径Argument list too long通配符展开文件过多改用find -exec或xargs6.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里执行里面修改的变量外面看不到count0 echo -e a\nb\nc | while read -r line; do count$((count1)) done echo $count # 输出0不是3解决办法是用进程替换count0 while read -r line; do count$((count1)) done (echo -e a\nb\nc) echo $count # 输出3 (...)是进程替换把命令输出当作文件输入循环在当前Shell执行变量修改能保留。这个坑我在统计日志行数时踩过排查了半天才发现是子Shell的问题。6.5 实操心得与避坑清单最后分享几条我这些年总结的经验。第一脚本里所有路径用绝对路径相对路径在cron里会以用户家目录为基准很容易找不到文件。第二关键操作前先echo再执行确认无误后再去掉echo尤其是rm、mv、这类破坏性操作。第三脚本要有幂等性重复执行结果一致这样失败重跑才安全。第四日志要带时间戳和上下文出问题时能快速定位。第五重要脚本纳入版本管理用Git记录每次修改出问题能回滚。还有一点别迷信chmod 777。我见过太多人遇到权限问题就777结果把系统搞出一堆安全隐患。权限问题要具体分析是文件没有执行权限还是目录没有写权限还是运行用户不对。找到根因再改而不是一把梭。脚本这东西写出来能跑只是及格能稳定跑、出错能查、别人能接手才算合格。多花十分钟加错误处理和日志能省下未来几小时的排查时间。