ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Shell脚本自动化实战:从参数解析到并发控制,打造终端生产力工具

Shell脚本自动化实战:从参数解析到并发控制,打造终端生产力工具 1. 内容整体设计与思路拆解想在终端里干点正事的人几乎都绕不开 Shell。但真正把 Shell 当生产力工具来用的人其实不多。我见过太多同事日常操作全是手敲命令备份靠复制粘贴部署靠人工一条条执行一旦命令稍微复杂一点就懵了。OpenShell这个标题看起来很泛但落到实际场景里它代表的其实是一整套开放式、可扩展、把 Shell 变成自动化流水线的思路——不是某一个工具而是一套终端提效的方法论。我最早接触这个方向是因为每周都要做一次固定的环境巡检登录服务器、看磁盘、查负载、盯日志、清临时文件每次半小时做的事一模一样。后来我把整套操作写成脚本再后来把所有零散脚本收拢到一个统一的入口用参数控制行为用函数封装逻辑用退出码判断成败——这就是我理解的OpenShell打开 Shell 的边界让它替你干活而不是你替它干活。这篇内容不是讲某个特定软件怎么装而是分享一套可以落到自己机器上的 Shell 自动化方案覆盖从脚本骨架设计、入参解析、日志输出到并发控制这些核心环节。适合谁看如果你日常工作里经常碰终端已经会写简单的echo hello但还没系统梳理过自己的脚本库或者你写脚本全靠网上拼凑一遇到引号、空格、特殊字符就翻车——那这篇内容应该能帮你少踩几个坑。我会把每一段关键代码的意图、为什么这么写、踩过哪些雷都讲清楚方便你直接抄作业也方便你理解之后改造成自己的版本。2. 环境准备与工具选型先弄清楚你到底在用哪个 Shell2.1 Bash 和 Zsh 的差异以及为什么我推荐以 Bash 为基线动手写脚本之前第一件事是确认你机器上的默认 Shell 到底是什么。macOS 从 Catalina 开始默认是 Zsh大多数 Linux 发行版默认是 BashWindows 上如果开了 WSL 或者装了 Git Bash用的也基本是 Bash。很多新手翻车不是因为脚本写得不对而是脚本开头写的解析器路径和实际环境不匹配。我的建议是统一以 Bash 作为脚本基线。原因有两条。第一Bash 的兼容性最好Linux 服务器上几乎必然存在/bin/bash而 Zsh 不一定第二Bash 的语法资料最多遇到问题随便一搜就有答案Zsh 在人机交互层面确实更舒服补全、提示符、插件生态但写脚本时的差异点反而容易造成困惑。你可以用这条命令快速确认当前 Shellecho $SHELL输出/bin/bash就是 Bash/bin/zsh就是 Zsh。写脚本的时候第一行建议写成#!/usr/bin/env bash有人会问为什么不用#!/bin/bashenv会去 PATH 里找bash在一些自定义安装的环境里比如 Homebrew 装了新版 Bash/bin/bash可能还是老版本但env能找到正确的路径。代价是每次启动脚本会多一次env查找这点开销完全可以忽略。注意如果你的脚本用到了 Bash 4.0 之后才有的特性比如关联数组declare -AmacOS 自带的 Bash 版本是 3.2会直接报错。这种时候要么用 Homebrew 装新版 Bash要么避开这些特性。我在 Mac 上吃过这个亏写了一个关联数组的脚本在自己机器上跑得好好的发到同事机器上直接语法错误。2.2 用到的核心命令和工具以及它们各自擅长干什么整套方案里我会频繁用到下面这些命令。它们不是冷门工具都是终端里的基本功但组合起来能做的事情非常多echo/printf输出信息。printf比echo更可控支持格式化字符串建议养成用printf的习惯。grep文本过滤。grep -E支持扩展正则grep -o只输出匹配部分做日志分析时非常好用。sed流编辑。最常见的用法是sed -i原地替换文件内容配合正则做批量修改。awk按列处理文本。awk {print $1}取第一列复杂的统计任务也能胜任。find按条件找文件。find . -name *.log -mtime 7找出 7 天前的日志文件是清理任务的标配。xargs把前一个命令的输出当作参数传给下一个命令。处理批量操作时find ... | xargs rm比find ... -exec rm {} \;快很多。还有一个容易被忽略的工具jq专门处理 JSON。如果你经常跟 API 返回结果打交道jq能把 JSON 拆得明明白白。比如curl -s https://api.example.com/data | jq .items[0].name这行命令把接口返回的 JSON 里第一个item的name字段直接提取出来配合脚本做自动化数据检查非常方便。jq不属于 Bash 内置命令需要单独安装但值得装。2.3 目录组织方式把脚本当成一个项目来管理很多人写脚本是随手扔在某个目录里时间一长自己都找不到。我在实际使用中摸索出一套目录结构分享出来供参考~/bin/ ├── open-shell/ # 主项目目录 │ ├── lib/ # 公共函数库 │ │ ├── logger.sh # 日志输出函数 │ │ └── utils.sh # 通用工具函数 │ ├── scripts/ # 具体业务脚本 │ │ ├── backup.sh # 备份任务 │ │ ├── cleanup.sh # 清理任务 │ │ └── deploy.sh # 部署任务 │ └── open-shell.sh # 统一入口为什么要这么组织因为脚本数量一旦超过十个单一目录就会爆炸。把公共函数抽到lib里业务脚本只关注自己的逻辑调用公共函数时用source引入既减少重复代码也方便统一维护。open-shell.sh作为统一入口负责参数解析和分发。比如./open-shell.sh backup --target /data --output /tmp/backup ./open-shell.sh cleanup --days 7 --path /var/log这样所有任务都从一个口进去命令格式统一记起来也容易。下面的内容我会详细讲讲这个入口脚本怎么设计。3. 从零设计一个 Shell 脚本骨架参数解析、帮助信息、退出码3.1 参数解析$1到${11}以及getopts的取舍参数解析是 Shell 脚本里最容易写乱的部分。刚开始写脚本的人经常直接写$1、$2脚本逻辑复杂之后根本记不清第几个参数是什么含义。更规范的做法是用getopts处理短参数-h、-v或者在脚本开头统一做位置参数的语义映射。以我的备份脚本为例#!/usr/bin/env bash set -euo pipefail TARGET_DIR OUTPUT_DIR COMPRESSgzip usage() { cat EOF 用法: $0 [选项] 选项: -t 目录 要备份的目标目录必填 -o 目录 备份输出目录必填 -c 方式 压缩方式: gzip/zstd/none默认: gzip -h 显示帮助信息 示例: $0 -t /data -o /tmp/backup $0 -t /data -o /tmp/backup -c zstd EOF } while getopts t:o:c:h opt; do case $opt in t) TARGET_DIR$OPTARG ;; o) OUTPUT_DIR$OPTARG ;; c) COMPRESS$OPTARG ;; h) usage; exit 0 ;; *) usage; exit 1 ;; esac done这段代码里getopts后面的字符串t:o:c:h定义了支持的参数字母后面带冒号表示该参数需要一个值不带冒号表示只是一个开关。OPTARG就是参数的值。在while getopts后面我建议顺手做两件事。第一校验必填参数是否为空if [[ -z $TARGET_DIR || -z $OUTPUT_DIR ]]; then echo 错误: 目标目录和输出目录都是必填项 2 usage exit 1 fi第二处理参数校验之后的多余位置参数shift $((OPTIND - 1)) if [[ $# -gt 0 ]]; then echo 警告: 忽略多余的位置参数: $* 2 fiOPTIND是getopts维护的当前索引shift之后剩下的就是普通位置参数。对多数工具型脚本来说处理完具名参数后再出现的位置参数基本都是误用直接给个警告比默默忽略要友好得多。3.2set -euo pipefail的含义以及为什么它能救你一命刚接触脚本的人经常遇到这种情况脚本里某条命令失败了后面的命令照样执行最后结果莫名其妙排查半天才发现是前面哪一步悄悄失败了。这就是没开set -e的后果。我写脚本的第一行固定是set -euo pipefail拆开解释一下set -e脚本只要有一条命令返回非零退出码立刻中止执行。这样出错就不会继续往下跑避免错误叠加。set -u使用未定义的变量时报错而不是当成空字符串。这个太重要了尤其是脚本里拼路径的时候变量一旦没赋值rm -rf $VAR/可能直接变成rm -rf /。set -o pipefail管道中只要有一个命令失败整个管道的退出码就是失败的。默认情况下false | echo ok的退出码是 0因为最后一个echo成功了开了pipefail之后会正确返回失败状态。这三个选项合在一起相当于给脚本加了三道保险。但也要注意set -e并不完美在条件判断里使用grep、test这类命令时如果它在if语句里失败不会导致退出。这是符合直觉的因为if本来就是要判断它是一个分支条件。注意set -u在 Bash 4.4 之前对$有个已知坑。如果脚本里写了set -u然后用$传入参数旧版 Bash 在某些情况下会报错。规避方式是${:-}。绝大多数现代环境不用管这个但我在 CentOS 7 上碰到过提一嘴。3.3 帮助文档中文还是英文写什么内容帮助文档是很多人懒得写、但实际作用巨大的东西。理由很实际你的脚本三个月以后自己回来看大概率已经忘了参数是什么更别提同事接手你的脚本时一脸迷茫。我的习惯是帮助文档里包含五块内容脚本用途一句话说明、所有参数及其含义、必须满足的前置条件比如需要安装什么工具、至少一个可复制的完整示例、常见退出码含义。用cat EOF输出配合usage()函数方便统一维护。中文帮助文档没有任何技术问题终端完全支持 UTF-8。但如果你要给海外同事共享英文更稳妥。折中方案是参数名保持英文说明部分写中文或者中英双语看团队情况。3.4 退出码规范0 和 1 之外的自定义码写脚本时顺手设置合理的退出码能让调用方可能是另一个脚本可能是 CI 系统快速判断失败原因。Unix 惯例是 0 表示成功非 0 表示失败。我一般这样分配退出码含义0执行成功1通用错误参数校验失败、执行过程中的异常2用法错误getopts解析失败推荐返回 2和 Bash 内置约定一致3依赖缺失需要的命令不存在4文件或目录不存在示例如下if ! command -v jq /dev/null; then echo 错误: 需要安装 jq 2 exit 3 fi if [[ ! -d $TARGET_DIR ]]; then echo 错误: 目标目录不存在: $TARGET_DIR 2 exit 4 fi定义退出码规范的最大价值在于你的脚本可以成为别人的工具别人拿到脚本跑失败时退出码直接告诉他问题出在哪一层不用翻日志。4. 核心实操环节函数库、日志系统、并发与进度反馈4.1 用函数库替代复制粘贴logger.sh 和 utils.sh 的设计我见过最夸张的脚本两百行代码里出现了五次几乎一模一样的echo 开始备份 XXX中间只改了名字。这种重复代码改起来非常痛苦加一个时间戳要改五处。把公共逻辑抽成函数是唯一的解法。以日志输出为例最简单的版本是#!/usr/bin/env bash # lib/logger.sh # 带时间戳的 INFO 日志 log_info() { printf [%s] [INFO] %s\n $(date %Y-%m-%d %H:%M:%S) $* } # 红色 ERR 日志带颜色非交互终端下自动禁用 log_error() { if [[ -t 1 ]]; then printf \033[31m[%s] [ERROR] %s\033[0m\n $(date %Y-%m-%d %H:%M:%S) $* else printf [%s] [ERROR] %s\n $(date %Y-%m-%d %H:%M:%S) $* fi }这里的-t 1判断标准输出是不是终端是终端才加 ANSI 颜色码否则无脑输出颜色反而会让日志文件里全是乱码转义序列这是我在 CI 日志里踩过的坑。utils.sh里我一般放两个高频函数一个是环境依赖检查一个是路径标准化。#!/usr/bin/env bash # lib/utils.sh # 检查指定的命令是否已安装未安装则报错退出 require_cmd() { local cmd$1 if ! command -v $cmd /dev/null; then echo 错误: 缺少依赖命令: $cmd 2 exit 3 fi } # 把相对路径转成绝对路径 resolve_path() { local path$1 if [[ $path /* ]]; then echo $path else echo $(pwd)/$path fi }函数库的引入方式是在业务脚本里sourceSCRIPT_DIR$(cd $(dirname ${BASH_SOURCE[0]}) pwd) source $SCRIPT_DIR/../lib/logger.sh source $SCRIPT_DIR/../lib/utils.sh这段路径获取比较重要单独解释一下${BASH_SOURCE[0]}是当前脚本的路径比$0安全因为$0在source场景下指向的是父脚本dirname取目录cd进去再pwd是为了把相对路径转成绝对路径。这样不管在哪个目录下执行脚本都能正确找到lib目录。4.2 业务脚本的标准流程任务开始、执行、结束三段式一个规范的业务脚本我建议按三段式组织第一段是环境和前置条件检查包括set -euo pipefail、初始化和source函数库、解析参数、校验依赖、检查关键路径。第二段是核心逻辑尽量拆成小函数每个函数只做一件事函数名用动词开头backup_dir、cleanup_old_logs函数之间通过全局变量传参要谨慎最好把变量作为参数传给函数。第三段是收尾输出结果摘要如果有中间产物要落盘这里统一清理。以备份脚本的核心函数为例backup_dir() { local src$1 local dest$2 local method$3 local ts ts$(date %Y%m%d_%H%M%S) local archive_name archive_name$(basename $src)_${ts}.tar.${method} log_info 开始备份: $src case $method in gzip) tar -czf $dest/$archive_name -C $(dirname $src) $(basename $src) ;; zstd) tar --zstd -cf $dest/$archive_name -C $(dirname $src) $(basename $src) ;; none) tar -cf $dest/$archive_name -C $(dirname $src) $(basename $src) ;; *) log_error 不支持的压缩方式: $method return 1 ;; esac if [[ $? -eq 0 ]]; then log_info 备份完成: $dest/$archive_name else log_error 备份失败: $src return 1 fi }这个函数有几个细节值得展开。第一local声明局部变量避免污染外部作用域。第二压缩归档时使用-C $(dirname $src) $(basename $src)而不是直接写$src这样生成的压缩包内不包含绝对路径的上一级目录解压时不会跑到奇怪的地方。第三case结构处理三种压缩方式后续加一种新压缩方式只需要加一个分支。4.3 并发执行任务后台进程与wait的正确打开方式串行执行多个任务时总耗时是每个任务耗时之和。如果任务之间互相独立比如分别备份三个目录完全可以用并发把总耗时压到最慢那个任务的耗时级别。Bash 并发的方式不复杂核心是后台执行和wait等待backup_dir /data/a $OUTPUT_DIR gzip pid_a$! backup_dir /data/b $OUTPUT_DIR gzip pid_b$! backup_dir /data/c $OUTPUT_DIR gzip pid_c$! wait $pid_a wait $pid_b wait $pid_c$!是刚放到后台的进程 PIDwait $pid_a会等这个 PID 结束并返回它的退出码。注意wait不带参数是等所有后台进程带参数是等指定 PID带参数的好处是可以拿到每个任务独立的退出码。但直接这么写有个问题并发三个任务如果其中两个失败而第三个成功你得能区分是谁失败了。更稳妥的写法是pids() for dir in /data/a /data/b /data/c; do backup_dir $dir $OUTPUT_DIR gzip pids($!) done fail_count0 for pid in ${pids[]}; do if ! wait $pid; then fail_count$((fail_count 1)) fi done if [[ $fail_count -gt 0 ]]; then log_error 共 $fail_count 个备份任务失败 exit 1 fi如果并发数量很大直接把所有任务一次性丢到后台会瞬间打满 CPU 或 IO反而拖慢整体速度。控制并发上限的经典手法是队列这里给出一个简单的实现max_jobs4 jobs_now0 for dir in ${dirs[]}; do backup_dir $dir $OUTPUT_DIR gzip jobs_now$((jobs_now 1)) if [[ $jobs_now -ge $max_jobs ]]; then wait -n jobs_now$((jobs_now - 1)) fi done waitwait -n是 Bash 4.3 之后支持的选项表示等待任意一个后台任务结束。这个写法虽然不如 GNUparallel或者xargs -P简洁但不依赖额外工具在大多数环境中可以直接跑。4.4 给用户反馈进度条、日志冗余度控制脚本跑起来之后最让用户焦虑的一件事是它到底在干嘛为什么还没结束。长时间运行的脚本如果一点反馈都没有用户会怀疑它卡死了。合理的输出策略是慢速任务在关键节点输出状态循环任务按百分比或剩余数量输出进度。一个简单的进度条可以用printf实现覆盖当前行progress() { local total$1 local current$2 local percent$((current * 100 / total)) local bar_width50 local filled$((percent * bar_width / 100)) local empty$((bar_width - filled)) printf \r[ printf %0.s# $(seq 1 $filled) printf %0.s- $(seq 1 $empty) printf ] %d%% (%d/%d) $percent $current $total }调用方式是在循环里调用这个函数结束时打印换行for i in $(seq 1 $total); do do_something progress $total $i done echo这里%0.s#是 Bash 里重复输出 n 个字符的技巧printf的格式字符串%0.s会消费一个参数但不输出后面跟的#是每轮固定输出的字符配合seq出来的参数序列形成重复效果。如果环境不支持这种写法旧版 Bash 有时会出问题退而求其次用循环printf #也行但效率稍低。注意进度条只适合标准输出是终端的时候展示。如果脚本输出被重定向到文件你还硬打\r和退格日志文件里会出现一堆没意义的控制字符。判断方式和日志颜色一样用[[ -t 1 ]]做开关。日志冗余度控制也很重要。我习惯给日志函数加一个全局变量LOG_LEVEL默认是info开启--verbose后输出debug级日志log_debug() { if [[ ${LOG_LEVEL:-info} debug ]]; then printf [%s] [DEBUG] %s\n $(date %Y-%m-%d %H:%M:%S) $* fi }这样脚本默认输出干净的信息调试时打开 verbose 模式就能看见详细过程。5. 常见问题与排查技巧实录这些坑我替你们踩过了5.1 参数和变量里出现空格、特殊字符时为什么脚本会炸Shell 脚本最大的坑之一就是什么时候该加引号。我见过不少这类场景某个文件名是my file.tar.gz中间有空格脚本里写了tar -czf $dest/$archive_name然后执行结果 tar 收到两个参数my和file.tar.gz最终创建了两个文件或者直接报错。规则其实不复杂只要变量内容可能包含空格、通配符或其他特殊字符就必须加双引号包起来。带local的赋值也一样local var$1必须加引号不加的话$1里的空格会被拆成多个参数。另一个容易踩的坑是find -exec后面接{}时如果文件名有空格默认会按空格拆开。规避方式是改用find ... -print0 | xargs -0或者find ... -exec ... {} 。比如清理三天前的日志find /var/log/myapp -name *.log -mtime 3 -print0 | xargs -0 rm -f-print0和xargs -0配合使用用 NUL 字符而不是换行分隔文件名任何特殊字符都不会出问题。这个技巧在文件名里有换行这种极端情况下都能安全处理。5.2 脚本根目录问题的排查为什么在别处执行脚本就找不到文件很多脚本在项目目录下能跑换个目录就挂最典型的原因就是脚本依赖了相对路径。比如脚本里写了source ./lib/logger.sh那这个脚本只能在根目录下执行一旦你用bash open-shell/scripts/backup.sh的姿势去调用./就指向当前工作目录而不一定是脚本所在目录。解决方案在前面已经提过就是脚本开头统一做一次自身路径解析SCRIPT_DIR$(cd $(dirname ${BASH_SOURCE[0]}) pwd)拿到SCRIPT_DIR之后所有外部文件的引用都改成source $SCRIPT_DIR/../lib/logger.sh DATA_FILE$SCRIPT_DIR/../config/app.conf这样无论从哪个目录发起执行脚本都能找到自己的家。我在写多个互相调用的脚本时也统一用这个模式基本告别了换个目录就找不到文件的问题。另外一个相关排查点是当脚本里用cd切换了工作目录之后后续的路径引用会跟着改变。要么在cd之前用变量把路径记下来要么所有路径从一开始就写绝对路径基于SCRIPT_DIR拼出来的路径就是绝对路径。5.3 CRLF 问题Windows 上写的脚本Linux 上为什么报错如果你在 Windows 上编辑过脚本然后拿到 Linux 或容器里执行经常会遇到一个非常迷惑的报错$\r: command not found或者脚本行为极其诡异。原因很简单Windows 文本文件的换行是\r\n而 Linux 只认\n多出来的\r被当成命令的一部分了。排查方法比较快cat -A script.sh | grep \^Mcat -A会把不可见字符显示出来\r显示为^M。看到^M就说明文件是 CRLF 编码。修复方式有两种。一种是命令行直接转换sed -i s/\r$// script.sh另一种是配置好编辑器保证新建文件默认 LF。VS Code 右下角可以切换行尾序列选 LF 就行。写 Docker 或 CI 脚本的人如果在 Windows 上开发这一条是最高频的坑。5.4 超时处理curl 和交互式命令卡死怎么办脚本里调用外部命令时最怕的就是命令不退出。curl访问一个不响应的地址默认行为是无限等下去你的脚本也跟着卡死。用set -e也救不了因为命令从不返回就永远不会触发错误处理。curl的解决方式是指定--max-timecurl -s --max-time 10 https://api.example.com/data10 秒不响应就放弃。更精细一点的还有--connect-timeout控制连接超时、--max-time控制整体超时两个一起用可以快速失败。如果调用的是普通命令Linux 下可以用timeout命令包一层timeout 30 ./long-running-task.sh || echo 任务超过 30 秒已终止timeout是 GNU coreutils 自带的绝大多数 Linux 都有。macOS 上可能没有不过 macOS 用户可以用gtimeoutHomebrew 的 coreutils 包装或者用后台进程加wait模拟。5.5 常见问题速查表日常排障时以下问题是出现频率最高的整理成一个速查表方便对照定位。现象大概率原因快速排查命令解决方案脚本在 Windows 写、Linux 跑报$\r: command not foundCRLF 换行问题cat -A script.sh | grep ^Msed -i s/\r$// script.sh变量有空格命令执行异常变量引用未加双引号bash -x script.sh观察参数拆分所有变量引用用$var换了目录执行脚本找不到文件用了相对路径用pwd对比脚本路径SCRIPT_DIR$(cd $(dirname ${BASH_SOURCE[0]}) pwd)之后基于它拼路径脚本中某条命令失败但脚本继续跑未开启set -e检查脚本开头加set -euo pipefail脚本用了declare -A在 mac 上报错macOS 自带 Bash 3.2bash --version用brew install bash装新版或用其他方式实现curl 请求一直挂着未设置超时时间无加--max-time 10 --connect-timeout 5提示排查 Shell 脚本问题最有力的武器是bash -x script.sh。它会把每条命令展开成实际执行的形态打印出来变量值一目了然。遇到任何诡异问题先开调试模式跑一遍八成能直接看到答案。5.6 我踩过的两个印象深刻的坑第一个坑是rm -rf加变量为空。当时写一个清理脚本要删指定目录下的旧备份变量是从配置读出来的。有一天配置读出来是空值脚本里写的是rm -rf $BACKUP_DIR/*因为变量为空实际执行变成rm -rf /*。幸好那台机器用户权限受限没造成灾难但把我吓出一身冷汗。从那以后我对所有rm -rf类操作都加了路径安全校验类似这样CLEAN_DIR/data/backups if [[ -z $CLEAN_DIR || $CLEAN_DIR / ]]; then echo 拒绝删除根目录或空路径 2 exit 1 fi rm -rf ${CLEAN_DIR:?}/old${CLEAN_DIR:?}是 Bash 的参数为空就报错并退出语法配合set -u双保险能有效防止删库跑路的剧本。第二个坑是管道加set -e的组合。脚本里写了set -e然后执行grep something file | awk {print $1}当grep没匹配到内容时按直觉脚本应该报错退出但实际上set -e不生效——因为grep是管道的一部分只有管道的最后一个命令awk成功才会终结整个管道。加set -o pipefail就能让这种中间失败冒出来。但pipefail也有反噬grep没匹配会返回 1这种情况在很多场景下是合法预期不是错误。我的处理方式是明确预期失败的命令放在if条件里而不是让它裸露在主干逻辑中if grep -q pattern file; then echo 找到了 else echo 没找到这是预期分支不算失败 fi6. 进阶方向脚本与 Cron、远程执行、CI 的联动6.1 用 Cron 把脚本变成定时任务手动跑脚本只是第一步真正的自动化是把脚本交给 Cron。我的习惯是先把脚本完整调到在命令行执行不报错再配置 Cron。否则 Cron 环境变量和交互终端差很多PATH 不一样、没有终端、HOME 可能不同容易踩环境差异的坑。Cron 配置是每分钟检查、按规则执行0 2 * * * /home/user/bin/open-shell/scripts/backup.sh -t /data -o /tmp/backup /var/log/backup.log 21这条配置的含义是每天凌晨两点整执行备份脚本标准输出和错误输出都追加到日志文件。有几个细节值得强调脚本路径和执行命令都写绝对路径Cron 的 PATH 只有/usr/bin:/bin相对路径和自定义路径下的命令可能找不到。 /var/log/backup.log 21同时收集 stdout 和 stderr排查问题不用两头看。如果脚本里有需要外部环境变量比如自定义的PATH建议在脚本开头自己重新设置export PATH/usr/local/bin:/usr/bin:/bin:$PATHCron 出错最常见的姿势是脚本手动能跑Cron 跑不了9 成是环境变量问题按上面两条排查基本能解决。6.2 远程服务器批量执行SSH 与脚本的分发管理的服务器多起来之后单独登录一台台跑命令完全不现实。最简单的批量方案是本地脚本配合 SSH 远程执行。比如要对三台服务器执行健康检查for host in web01 web02 web03; do ssh $host bash -s ./scripts/health_check.sh donebash -s表示从标准输入读取脚本内容脚本不必提前复制到远程机器上。这里有个坑要注意远程脚本里如果用了source引用别的文件bash -s模式下当前目录是远程登录目录source同样要基于SCRIPT_DIR去解析路径。如果要把脚本分发给多台机器再执行可以用scp配合循环for host in web01 web02 web03; do scp ./scripts/deploy.sh $host:/tmp/deploy.sh ssh $host chmod x /tmp/deploy.sh /tmp/deploy.sh done在ssh上开启ControlMaster复用连接可以显著加速批量操作在~/.ssh/config里加这样一段Host web01 web02 web03 ControlMaster auto ControlPath ~/.ssh/controlmasters/%r%h:%p ControlPersist 10m首次连接之后后续连接复用同一个隧道省去每次重新握手的时间。批量操作几十台机器时效果立竿见影。6.3 把 Shell 脚本接入 CIGitLab CI 与 GitHub Actions 的通用模式现在很多项目用 CI 做自动化测试和部署Shell 脚本在其中扮演的角色通常是在 CI 的某个阶段里被平台调用执行。最核心的一条经验是CI 环境尽量不要太依赖平台注入的环境变量脚本自己要有默认值因为你在本地能跑通不代表平台的 runner 环境一致。以 GitLab CI 为例deploy: stage: deploy script: - ./open-shell/scripts/deploy.sh --env staging --app myapp only: - main脚本内部对--env参数做校验默认给development如果平台传了staging就用覆盖值。另一个关键是 CI 执行环境通常是干净的环境require_cmd函数在这里非常有用——脚本开头检查curl、jq等依赖是否安装缺了就直接退出CI 日志里一目了然。GitHub Actions 的run块默认是bash -e和本地set -euo pipefail有近似作用但 shell 版本可能不同。我在 Actions 里偶尔遇到过 Bash 版本差异导致wait -n报错的情况处理方式是在 job 的defaults.run.shell里显式指定bash或者脚本里做版本检查。6.4 ShellCheck代码检查和静态扫描最后强烈推荐一个工具ShellCheck。它是一款 Shell 脚本静态分析工具能查出脚本里几乎所有常见的坑包括你还没踩到的那些。安装方式# macOS brew install shellcheck # Ubuntu / Debian apt install shellcheck # CentOS / RHEL yum install shellcheck使用方式非常简单shellcheck script.shShellCheck 会对每种问题给出级别和具体行号比如SC2086: Double quote to prevent globbing and word splitting变量没加双引号、SC2164: Use cd ... || exit in case cd failscd可能失败但你忽略了。这些提示非常直接看到就能懂。我有段时间要求自己所有新脚本必须过 shellcheck 零警告才准进仓库确实拦下了一堆问题。不过也要说明ShellCheck 是基于规则的分析器偶尔会有误报比如你觉得某个写法没问题但它坚持要加引号这种时候在代码里加一行注释排除规则即可# shellcheck disableSC2086现在写脚本之前我都会先想一下如果 shellcheck 会怎么说这已经变成了肌肉记忆。新入门的读者我真的建议把 shellcheck 当成脚本的编译器来用它比你靠谱。7. 个人经验收尾这套方案我用了两年后的真实体会写了这么多年脚本我的核心体会是Shell 脚本真正的分水岭不在于会用多少高级语法而在于有没有一套稳定的约定。统一的目录结构、统一的参数解析方式、统一的日志风格、统一的错误处理策略这些规矩才是让脚本从能跑变成好维护的关键。我自己从零散脚本到统一入口最大的改变是心态。以前写脚本遇到没见过的场景就慌满网搜拼凑出一个能跑的版本就收工下次遇到类似问题继续搜。现在遇到新需求的第一反应是这个逻辑可以拆成哪几个函数哪些部分能复用lib里的能力参数怎么设计才能和其他脚本保持一致有了约定之后写新脚本的速度比以前快了两倍不止而且脚本质量稳定在敢交给别人用的水平。最后再分享一个我从实际使用中总结的小技巧脚本里所有需要写路径的地方不要直接手写。统一从一个配置变量取比如OPEN_SHELL_ROOT$HOME/bin/open-shell然后所有路径基于这个根拼接。这样哪天你改了目录结构只需要在一处修改不需要翻遍所有脚本逐个替换。这套OpenShell的方案不是一天设计出来的它是在一次次备份失败、一次次半夜被叫起来处理部署问题之后慢慢迭代出来的。如果你刚接触这个方向不用试图一步到位先把手头最频繁的那个手动操作写成一个干净的小脚本跑通之后再慢慢把更多任务装进这套框架里。终端替你干活的感觉一旦尝到了就回不去了。
返回列表