1. 从一个运维夜班说起:为什么.sh文件值得每个Linux使用者吃透
凌晨两点,告警群里弹出一条消息:某台业务服务器的磁盘使用率飙到 92%。我登录上去,df -h一看,日志目录里堆了几十个 G 的历史文件。手动删?几十个目录,一个个rm敲到天亮。当时我写了一个不到十行的.sh文件,遍历目录、按修改时间过滤、批量清理、输出清理报告,跑完只用了十几秒。那一刻我真切体会到:在 Linux 世界里,.sh 文件不是“可选项”,而是把重复劳动压缩成一次点击的核心工具。
这篇内容我想聊的就是.sh文件在 Linux 系统中的实战应用与技巧。它是什么?简单说,.sh是 Shell 脚本文件最常见的后缀,里面写的是一串可以被bash、sh、zsh等 Shell 解释器逐行执行的命令。它能做什么?把日常重复的命令组合、流程编排、定时任务、批量处理、环境初始化全部自动化。适合谁看?刚接触 Linux 的新手、需要维护服务器的运维、做数据处理的开发,甚至只是想在个人电脑上少敲几行命令的普通用户。
很多人对.sh有误解,觉得它是“高级运维才玩的东西”。其实你只要会在终端里敲ls、cd、cp,就已经具备写脚本的基础了。脚本无非是把你手敲的命令按顺序写进文件,再加上变量、判断、循环,让它能应对不同情况。真正拉开差距的,不是语法背得多熟,而是知不知道在什么场景下该用哪种写法、踩过哪些坑、怎么让脚本跑得稳。下面我按自己的实战经验,从设计思路到落地细节,一层层拆开讲。
2. 脚本整体设计与思路拆解:先想清楚“谁来跑、跑在哪、跑完留下什么”
2.1 为什么很多人写的脚本第一次能跑,第二次就翻车
我见过太多这样的脚本:在作者自己的机器上跑得好好的,换一台服务器就报错。原因往往不是语法问题,而是设计阶段没想清楚三件事——执行环境、输入来源、输出结果。
执行环境决定了你用哪个解释器。文件头那行#!/bin/bash叫 shebang,它告诉系统用哪个程序来执行这个脚本。如果你写#!/bin/sh,但在脚本里用了[[ ]]这种 bash 特有的语法,在某些把sh指向dash的系统上就会直接报错。我的习惯是:只要用到数组、[[ ]]、${var//}这类 bash 扩展,就老老实实写#!/bin/bash;如果追求最大兼容性,就只用 POSIX 标准语法,shebang 写#!/bin/sh。
输入来源决定了脚本的健壮性。硬编码路径的脚本最脆弱,今天目录叫/data/log,明天改成/data/logs就废了。更好的做法是把可变部分抽成变量,或者通过参数传入。比如清理日志的脚本,我会把目标目录、保留天数、文件匹配模式都做成变量放在开头,改的时候只动一处。
输出结果决定了脚本能不能被“信任”。一个合格的脚本跑完应该告诉你:处理了多少文件、释放了多少空间、有没有失败项。如果它默默跑完什么都不说,出了问题你根本不知道从哪查。所以我在脚本里几乎都会加日志输出,重要的操作还会写进日志文件,方便回溯。
2.2 解释器选型:bash、sh、zsh 到底怎么选
热词里经常出现bash、zsh、fish这些词,它们统称为 Shell,是用户和 Linux 内核之间的“翻译官”。你敲的命令,由 Shell 翻译成系统能理解的操作。
sh是最古老的 POSIX 标准 Shell,几乎每个 Linux 系统都有,兼容性最好,但功能最少。bash是大多数 Linux 发行版的默认 Shell,功能丰富,支持数组、字符串操作、进程替换等,是写脚本的首选。zsh交互体验好,配置花哨,但写脚本时语法和 bash 有细微差别,不太适合作为脚本解释器。fish语法和主流 Shell 差异较大,基本不用于写.sh脚本。
我的建议很直接:写脚本就用 bash。除非你的脚本要在极其精简的嵌入式环境里跑,否则没必要为了兼容性牺牲开发效率。shebang 写#!/usr/bin/env bash比写#!/bin/bash更灵活,因为env会在 PATH 里找 bash,适配不同系统上 bash 的安装位置。
2.3 脚本的“骨架”应该长什么样
一个结构清晰的脚本,我通常按这个顺序组织:
- shebang 和脚本说明注释
- 严格模式设置(
set -euo pipefail) - 变量定义区
- 函数定义区
- 主逻辑
- 退出码处理
set -euo pipefail这行值得单独说。-e让脚本遇到错误命令立即退出,避免错误累积;-u让使用未定义变量时报错,防止变量名拼错导致意外;-o pipefail让管道中任何一个环节失败都算失败。这三个选项加上去,脚本的“抗造”程度会明显提升。我早期写脚本不爱加这些,结果有一次变量名拼错,脚本把空值当成路径,差点删错目录,从那以后这行成了我的标配。
3. 核心细节解析与实操要点:从 chmod 到变量,每个环节都有讲究
3.1 chmod:让脚本“能跑”的第一步
写完.sh文件,直接./script.sh执行,大概率会看到Permission denied。这是因为新建的文件默认没有执行权限。这时候就要用到chmod。
chmod是 change mode 的缩写,用来修改文件权限。Linux 权限分三组:文件所有者、所属组、其他用户,每组有读(r=4)、写(w=2)、执行(x=1)三种权限。chmod +x script.sh是最常用的写法,给文件加上执行权限。等价于chmod 755 script.sh,即所有者可读写执行(7=4+2+1),组和其他用户可读可执行(5=4+1)。
热词里频繁出现chmod 777,我得提醒一句:777 意味着所有用户都能读写执行,这是安全隐患。生产环境里给脚本 755 就够了,除非有特殊需求,否则不要用 777。我见过有人为了图省事,把整个 web 目录设成 777,结果被上传了恶意脚本,教训很深刻。
还有一种情况:脚本在 Windows 上编辑过,传到 Linux 后执行报bad interpreter: No such file or directory。这通常是换行符问题,Windows 用\r\n,Linux 用\n,shebang 行末尾多了个\r,系统就找不到解释器了。解决办法是用dos2unix script.sh转换,或者用sed -i 's/\r$//' script.sh去掉回车符。
3.2 变量与输入:脚本灵活性的来源
变量是脚本的“记忆”。定义变量很简单:LOG_DIR="/var/log/app",注意等号两边不能有空格,这是新手最容易犯的错。使用变量用$LOG_DIR或${LOG_DIR},后者在变量名后面紧跟其他字符时更清晰,比如${LOG_DIR}_backup。
变量分局部和全局。函数里用local声明的变量只在函数内有效,不加local默认是全局的。我踩过的坑是:在函数里用了和外部同名的变量,没加local,结果把外部的值覆盖了,排查了半天才发现。所以函数内的变量,能加local就加。
脚本接收外部输入有三种常见方式。第一种是位置参数,$1、$2分别代表第一个、第二个参数,$#是参数个数,$@是所有参数。第二种是read命令交互式读取,适合需要用户确认的场景。第三种是环境变量,适合配置类信息。我写脚本时习惯在开头检查参数个数,比如if [ $# -lt 2 ]; then echo "用法: $0 <源目录> <目标目录>"; exit 1; fi,这样用户用错了能立刻得到提示,而不是跑到一半才报错。
3.3 条件判断与循环:脚本的“大脑”和“肌肉”
条件判断用if,循环用for和while。这两个是脚本自动化的核心。
if的写法有个细节:[ ]和[[ ]]的区别。[ ]是 POSIX 标准,[[ ]]是 bash 扩展。[[ ]]支持正则匹配=~、逻辑运算符&&||,而且不用对变量加引号也不会因为空值报错。所以用 bash 写脚本时,我优先用[[ ]]。判断文件是否存在用-f,判断目录用-d,判断变量是否为空用-z,这些是高频操作。
for循环遍历列表很直观:for file in *.log; do ...; done。但要注意,如果目录里没有匹配的文件,*.log会原样作为字符串传入,导致循环体执行一次却处理了不存在的文件。解决办法是加shopt -s nullglob,让没有匹配时展开为空。这个坑我在批量处理文件时踩过,脚本报了一堆“文件不存在”,查了半天才发现是这个原因。
while循环常配合read逐行读取文件:while IFS= read -r line; do ...; done < file.txt。IFS=防止行首行尾空格被吃掉,-r防止反斜杠被转义。这两个参数不加,处理配置文件时经常出问题。
3.4 函数与模块化:让脚本从“能用”到“好维护”
脚本超过一百行,就该考虑用函数拆分。函数定义有两种写法:function name { ... }和name() { ... },后者兼容性更好,我一般用后者。
函数的价值在于复用和隔离。比如日志输出,我定义一个log()函数,统一加上时间戳和级别,所有地方调用它,格式一致,改起来也只改一处。再比如错误处理,定义一个die()函数,打印错误信息后退出,比到处写echo ...; exit 1清爽得多。
函数返回值要注意:bash 函数只能返回 0-255 的整数作为退出码,想返回字符串得用echo输出,调用方用$(function_name)捕获。这个设计初看别扭,习惯了就好。我通常用退出码表示成功失败,用标准输出传递数据。
4. 实操过程与核心环节实现:三个可直接抄作业的脚本
4.1 日志清理脚本:从需求到落地
需求很明确:清理/var/log/app下超过 7 天的.log文件,保留最近 7 天,输出清理报告。
先想清楚几个点。怎么判断“超过 7 天”?用find的-mtime +7,表示修改时间在 7 天以前。怎么保证安全?先echo出要删的文件,确认无误再真正删。怎么输出报告?统计删除数量和释放空间。
脚本骨架如下:
#!/usr/bin/env bash set -euo pipefail LOG_DIR="/var/log/app" KEEP_DAYS=7 PATTERN="*.log" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" } if [[ ! -d "$LOG_DIR" ]]; then log "错误:目录 $LOG_DIR 不存在" exit 1 fi log "开始清理 $LOG_DIR 下超过 $KEEP_DAYS 天的 $PATTERN 文件" count=$(find "$LOG_DIR" -name "$PATTERN" -mtime +"$KEEP_DAYS" | wc -l) size=$(find "$LOG_DIR" -name "$PATTERN" -mtime +"$KEEP_DAYS" -exec du -ch {} + 2>/dev/null | tail -1 | cut -f1) if [[ "$count" -eq 0 ]]; then log "没有需要清理的文件" exit 0 fi log "找到 $count 个文件,合计 $size,开始删除" find "$LOG_DIR" -name "$PATTERN" -mtime +"$KEEP_DAYS" -delete log "清理完成"这里有几个细节值得说。du -ch的-c是总计,tail -1取最后一行总计行,cut -f1取第一列大小。2>/dev/null是屏蔽权限不足等错误信息,避免干扰输出。-delete比-exec rm {} \;效率高,因为它不需要为每个文件启动一个rm进程。
注意:
-delete会直接删除,没有确认环节。如果你不放心,可以先跑一遍不带-delete的版本,看看输出列表对不对,确认后再加-delete。
4.2 批量重命名脚本:处理文件名里的空格和特殊字符
另一个高频场景是批量重命名。比如把目录下所有.jpeg改成.jpg,或者给文件名加统一前缀。
朴素写法是for f in *.jpeg; do mv "$f" "${f%.jpeg}.jpg"; done。${f%.jpeg}是字符串操作,去掉末尾的.jpeg。但这里有个隐患:如果文件名里有空格,for f in *.jpeg会把带空格的文件名拆成多个词。解决办法是用find配合-print0和while read -d '':
#!/usr/bin/env bash set -euo pipefail TARGET_DIR="${1:-.}" OLD_EXT="jpeg" NEW_EXT="jpg" find "$TARGET_DIR" -maxdepth 1 -name "*.${OLD_EXT}" -print0 | while IFS= read -r -d '' file; do newname="${file%.${OLD_EXT}}.${NEW_EXT}" if [[ -e "$newname" ]]; then echo "跳过:$newname 已存在" continue fi mv -- "$file" "$newname" echo "重命名:$file -> $newname" done-print0用空字符分隔文件名,read -d ''以空字符为分隔符读取,这样带空格、换行符的文件名都能正确处理。mv --里的--是告诉mv后面没有更多选项了,防止文件名以-开头被当成参数。这些细节平时不起眼,但处理用户上传的文件时,能避免很多诡异问题。
4.3 服务健康检查脚本:配合定时任务做监控
这个脚本的用途是检查某个服务是否在运行,不在就尝试重启,并记录日志。适合放在crontab里每分钟跑一次。
#!/usr/bin/env bash set -euo pipefail SERVICE_NAME="myapp" CHECK_URL="http://127.0.0.1:8080/health" LOG_FILE="/var/log/health_check.log" MAX_RETRY=3 log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE" } check_service() { curl -sf -o /dev/null --max-time 5 "$CHECK_URL" } if check_service; then exit 0 fi log "服务 $SERVICE_NAME 健康检查失败,尝试重启" for i in $(seq 1 "$MAX_RETRY"); do systemctl restart "$SERVICE_NAME" 2>/dev/null || true sleep 5 if check_service; then log "第 $i 次重启成功" exit 0 fi log "第 $i 次重启后仍未恢复" done log "服务 $SERVICE_NAME 重启 $MAX_RETRY 次后仍失败,请人工介入" exit 1curl -sf的-s静默模式,-f让 HTTP 错误码返回非零退出码。--max-time 5设置超时,防止脚本卡死。systemctl restart ... || true是防止重启命令本身失败导致set -e让脚本退出,我们要的是继续重试而不是直接挂掉。
提示:这个脚本依赖
systemctl,在容器环境里可能不可用。容器里通常用supervisor或直接检查进程。写脚本前先确认目标环境的服务管理方式,别照搬。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 脚本报错速查表
| 报错信息 | 常见原因 | 解决办法 |
|---|---|---|
Permission denied | 没有执行权限 | chmod +x script.sh |
bad interpreter: No such file or directory | 换行符是\r\n或 shebang 路径错误 | dos2unix转换,或检查 shebang |
command not found | 命令不在 PATH 里,或拼写错误 | 用绝对路径,或which确认命令位置 |
unbound variable | 使用了未定义变量,且开了set -u | 给变量设默认值${var:-default} |
syntax error near unexpected token | 括号、引号不匹配,或用了不支持的语法 | 用bash -n script.sh检查语法 |
Too many arguments | [ ]里变量没加引号且为空 | 改用[[ ]]或给变量加引号 |
No such file or directory | 路径不存在,或变量为空导致路径错误 | echo出路径确认,加存在性判断 |
5.2 调试脚本的三个实用手段
第一个是bash -x script.sh,它会打印每条执行的命令和变量展开后的值,能快速定位到哪一行出了问题。输出太多的话,可以配合set -x和set +x只对关键段落开启追踪。
第二个是在脚本里加echo调试。虽然土,但有效。我习惯在关键分支加echo "DEBUG: 进入清理分支,count=$count",跑一遍看输出对不对,确认后删掉。
第三个是用shellcheck。这是一个静态检查工具,能发现很多潜在问题,比如未加引号的变量、不可达代码、语法陷阱。装好之后shellcheck script.sh,它会给出详细的警告和修改建议。我现在的习惯是脚本写完先过一遍 shellcheck,能省下大量调试时间。
5.3 几个我踩过的真实坑
坑一:cd失败后继续执行。脚本里写cd /some/dir然后rm -rf *,如果cd失败,rm就在当前目录执行了,后果不堪设想。解决办法是cd /some/dir || exit 1,或者用set -e让脚本在cd失败时退出。
坑二:管道中的错误被忽略。cat file | grep pattern,如果file不存在,cat报错但grep正常退出,整个管道退出码是 0,脚本以为成功了。加set -o pipefail能解决这个问题。
坑三:rm -rf $DIR/中变量为空。如果DIR没定义或为空,命令变成rm -rf /,灾难性后果。防御写法是rm -rf "${DIR:?DIR is not set}/",${DIR:?}在变量为空时直接报错退出。
坑四:定时任务里环境变量缺失。手动跑正常的脚本,放进crontab就报command not found。因为cron的 PATH 和登录 shell 不一样。解决办法是在脚本开头显式设置 PATH,或者所有命令用绝对路径。
坑五:脚本在 Windows 编辑后传到 Linux。除了换行符问题,还可能遇到编码问题。Windows 默认 GBK,Linux 用 UTF-8,中文注释可能乱码。统一用 UTF-8 保存,编辑器里设置好。
5.4 关于“张豪全防格机sh文件”这类热词的说明
热词里出现了一些特定名称的.sh文件,比如“张豪全防格机sh文件”。这类词往往指向某些特定场景下的脚本,但具体内容我不了解,也不做展开。我想说的是:拿到任何来源不明的.sh文件,执行前一定要先看内容。用cat或less打开,重点看有没有rm -rf、dd、mkfs、> /dev/sda这类危险操作。不认识的脚本,先在虚拟机或测试环境里跑,确认安全再上生产。这个习惯能帮你避开绝大多数“脚本事故”。
6. 从入门到进阶:脚本能力提升的路径建议
6.1 新手阶段:先把手敲的命令变成脚本
如果你刚接触 Linux,别急着学高级语法。先做一件事:把你每天重复敲的命令序列记下来,写成一个.sh文件。比如每天要cd到项目目录、git pull、重启服务、看日志,这四步就可以写成一个脚本。写的过程你会自然遇到权限问题、路径问题、变量问题,解决这些问题的过程就是最好的学习。
这个阶段的目标是“能跑”。不用追求优雅,不用纠结用[ ]还是[[ ]],先让脚本完成工作。跑通了,你就有成就感,就有动力继续深入。
6.2 进阶阶段:关注健壮性和可维护性
当你能写出跑得通的脚本后,下一步是让它“跑得稳”。加上set -euo pipefail,加上参数检查,加上日志输出,加上错误处理。把重复逻辑抽成函数,把可变配置抽成变量。这个阶段你会开始理解为什么有些脚本“看起来差不多,但就是更靠谱”。
同时开始用shellcheck检查自己的脚本,看它给出的每一条建议,理解背后的原因。比如它提示“变量加引号”,你去查为什么,就会学到单词拆分和 glob 展开的知识。这种“先实践后理论”的路径,比先啃语法书效率高得多。
6.3 高阶阶段:脚本编排与工程化
再往上走,就是多个脚本的协作和工程化。比如用Makefile或justfile管理常用脚本,用crontab或systemd timer做定时调度,用ansible做批量部署。脚本本身也会变得更复杂,需要处理并发、锁、信号、超时等。
这个阶段的核心能力是“设计”。知道什么该用脚本做,什么该用专门工具做;知道脚本的边界在哪,什么时候该换成 Python 或 Go。脚本不是万能的,它的优势是轻量、直接、无需编译,适合胶水逻辑和系统管理。把脚本用在合适的地方,比把脚本写得多么复杂更重要。
6.4 几个值得长期坚持的习惯
第一,脚本开头写注释,说明用途、作者、修改记录。三个月后你回来看,会感谢当时的自己。
第二,危险操作加确认。删除、覆盖、重启这类操作,要么加read -p确认,要么先 dry-run 输出一遍。
第三,脚本纳入版本管理。用 git 管理你的脚本目录,每次修改都有记录,改坏了能回滚。
第四,定期回顾和重构。用得多了会发现有些写法可以优化,有些函数可以合并,保持脚本的整洁。
我个人在实际操作中的体会是:脚本能力的提升,不靠背语法,靠解决真实问题。每遇到一个重复劳动,就想“这个能不能写成脚本”;每写一个脚本,就想“下次怎么写得更好”。一年下来,你会发现自己处理问题的速度和底气完全不一样了。最后再分享一个小技巧:把你最常用的脚本放在~/bin目录下,把这个目录加到 PATH 里,以后在任何地方都能直接敲脚本名执行,省去./和路径的麻烦。