尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Shell脚本避坑指南:openEuler运维中的关键规则解析

Shell脚本避坑指南:openEuler运维中的关键规则解析 Shell 是 Linux 运维绕不开的看家本领openEuler 服务器上更是如此。写一两条命令很容易把命令拼成脚本也凑合能跑但真正让脚本稳定运行、在 ARM 架构服务器上也不出幺蛾子靠的是记住一堆看起来很简单、实际一踩一个准的规则。这篇番外篇我不讲 Shell 语法大全专门讲那些最该刻在脑子里的规则。很多是我在 openEuler 上写脚本时真实踩过的坑比如变量赋值多打一个空格、文件名里带着空格导致 for 循环裂开、判断字符串是否为空时[ -n ]莫名其妙成了真、循环里改好的变量一过管道就归零。这些坑单独看都很小连在一起足以让一个深夜上线的脚本瞬间变成大型翻车现场。适合正在学 Shell 脚本入门知识、准备在 openEuler 上写自动化任务的读者也适合那些“命令会用、脚本总出错”的老手把规则系统过一遍。文章不挑架构x86 和 ARM 都适用只是遇到 openEuler 特有的细节我会单独标注出来。1. 变量与引号Shell最容易翻车的语法规则1.1 变量赋值等号两侧的空格是隐藏地雷先说一个最基础但也最容易被初次上手的人踩爆的规则Shell 变量赋值时变量名、等号、值之间不能有空格。写成CONFIG_FILE /etc/my.conf的后果是 Shell 把CONFIG_FILE当成一条命令去执行然后报command not found。为什么因为 Shell 的语法里赋值是一个完整单词中间出现空格就意味着单词断开后面那些内容全成了命令参数。这个约束和 C、Python 等编程语言差异很大编程老手反而容易在这里栽跟头。正确写法就是CONFIG_FILE/etc/my.conf中间一个空格都不要有。习惯上我再加两个附则一是变量名尽量大写跟命令输出区分二是展开变量时最好带上花括号比如${CONFIG_FILE}_bak否则 Shell 会认为你要找的变量叫CONFIG_FILE_bak结果为空。这在拼接路径、文件名时太常见了。另外如果某个值你希望脚本运行过程中不被意外修改可以用 readonly 定义只读变量比如readonly SRC_DIR/data/backup。这不算语法强需求但对长时间跑的脚本来说防御价值很高。注意变量赋值等号两侧任何一边出现空格都可能导致命令找不到或值丢失。这条规则没有任何例外。1.2 三种引号的职责单引号、双引号、命令替换Shell 里的引号不是给人看的装饰它直接决定解析器怎么对待里面的内容。单引号保留字面值双引号允许变量展开和命令替换但不做通配符展开反引号和$()专门做命令替换。大部分场景我推荐用双引号包裹变量用$()做命令替换。一个最典型的场景是复制文件cp $SRC $DEST当路径里出现空格时cp 会收到多余参数。改成cp $SRC $DEST就稳了。热词里常有人搜 Linux 用 shell 重命名文件我直接给个例子old_namereport 2024.txt new_namereport_2024.txt mv $old_name $new_name如果不加引号mv 会认为这里有四个参数report、2024.txt、report_2024.txt然后直接报错。加了引号整个文件名才作为一个整体传过去。至于命令替换$(date %F)比date %F可读性好而且可以嵌套$(dirname $(find /opt -name *.conf | head -1))这种写法在反引号时代基本是不可读的。记住能用$()就别用反引号。1.3 默认值、花括号与作用域一条命令背后的展开顺序变量展开有很多种写法最该牢记的是默认值系列${var:-default}表示变量未定义时用 default${var:default}表示未定义时把 default 赋值给 var${var:alt}表示已定义且非空时输出 alt。在 openEuler 上写脚本时我经常要兼容不同环境。比如判断 JAVA_HOME 是否配置JAVA_HOME${JAVA_HOME:-/usr/lib/jvm/java} export PATH$JAVA_HOME/bin:$PATH这样脚本在没配 JAVA_HOME 的服务器上也能跑而不是直接报变量未定义。作用域这件事一句话先记住普通变量只在当前 Shell 进程可见子进程拿不到要通过 export 变成环境变量子进程才能继承。但继承是复制子进程改了自己的副本父进程不受影响。具体到脚本和命令的进程边界我会在第3章展开。这里埋个伏笔很多新手问“我 export 了为什么脚本里的其他命令还是看不到”大概率是 export 和赋值写反了或者一个在子Shell里另一个在外层。2. 条件判断与循环那些不能想当然的规则2.1 [ ]、[[ ]] 与 [ -n ]字符串判断的三个深坑先回答热词里被问爆的shell 脚本 if 判断的 -n 到底什么意思-n 表示“字符串长度非空则真”-z 表示“字符串长度为零则真”。看起来很简单实际翻车率极高。经典错误[ -n $var ]。当 var 为空时Shell 先把$var展开成空命令变成[ -n ]此时 test 看到的是一个长度为 2 的字符串-n于是返回真。你明明想判断 var 是否有内容结果空字符串也判成了真。正确写法是加引号[ -n $var ]或者干脆用 bash 扩展的[[ -n $var ]]后者更稳因为[[ ]]是语法级关键字不会做那么原始的分词。另一个坑在[ ]本身。[在 POSIX 里是一个命令所以里面每个参数都得正确展开空空如也或参数过多都会报语法错。而[[ ]]是 bash/zsh 的关键字支持、||、正则~不用加引号也能处理空值。所以我的建议写 bash 脚本就用[[ ]]如果你必须写#!/bin/sh兼容 POSIX再用[ ]但所有变量必须加双引号。openEuler 的/bin/sh在不同版本和不同安装模式下可能指向不同解释器别赌它一定是 bash。2.2 for 循环处理带空格文件名分词才是隐形杀手搜索引擎里常年有人搜 shell 脚本 for 循环问题大多不是语法而是循环对象没搞对。最常见的错误写法是for file in $(ls *.txt); do echo $file donels 输出经过命令替换后会按空白分词。文件名叫annual report.txt循环里会被拆成两个词后面的处理全乱套。正确做法是让 Shell 自己做通配符展开for file in *.txt; do echo $file done通配符展开时文件名不会被空格拆开因为 Shell 知道那是一个匹配结果。如果目录里一个匹配都没有反而要小心*.txt会保持字面量传给命令。所以循环体里加一句[ -e $file ] || continue是很好的习惯。需要递归处理时用 find 配合 -print0find /var/log/myapp -name *.log -print0 | while IFS read -r -d file; do echo 处理: $file done这里-print0用空字符分隔read -d 也按空字符读取任何奇怪的文件名都能完整传过去。这个组合值得背下来。2.3 while read 与管道循环里改的变量怎么丢了有一个坑排查起来相当费劲统计日志行数你写了count0 cat access.log | while read line; do ((count)) done echo 总数: $count跑完发现 count 永远是 0。原因是管道左边和右边各自在子Shell里执行while 在子Shell里改的是自己的 count循环结束子Shell退出外层 count 原封未动。解决办法很简单把文件重定向进来而不是用管道count0 while read line; do ((count)) done access.log echo 总数: $count这个原则在 openEuler 上处理日志、读取配置文件时经常用到。如果你确实想用管道处理也可以用进程替换 (command)思路是让 while 留在当前Shell里。总之记住一句话管道会制造子Shell子Shell里的变量改完带不出来。3. 命令执行与进程边界退出码、export、shift 与管道3.1 $? 的时效性只在下一行有效脚本里经常要判断上一条命令成没成功$?就是干这个的。但它只在执行下一条命令之前有效。你可以连续 echo 两次验证grep error app.log echo $? # 这是 grep 的退出码 echo $? # 这已经是前面 echo 的退出码基本是 0所以拿到退出码后第一件事是保存ret$?然后再判断。更推荐的做法是不用$?直接if grep -q error app.log; then ...让 if 自己吃命令的退出码。在 openEuler 上调用 dnf install 时退出码非 0 就说明有问题。但别假设一定等于 1不同工具可能返回 2、127、130。脚本里只要判断非 0就别精确定位到某个数字除非你有意处理特殊信号。set -e也依赖退出码后面第4章会讲到。3.2 export 与作用域为什么脚本里 cd /tmp 没改变当前 Shell很多人刚写脚本时都会有个疑问我在脚本里 cd /tmp执行完脚本之后当前 Shell 怎么还在原来的目录原因很简单执行一个脚本操作系统会 fork 出一个子进程跑这个脚本脚本里 cd 改变的是子进程的工作目录子进程结束父进程的工作目录不受影响。环境变量同理。普通变量默认只属于当前进程子进程里定义的东西对父进程不可见。想让子进程继承某个变量必须 export。但 export 也只是把变量复制到子进程环境子进程怎么改它父进程都不知道。openEuler 上有个非常实际的场景你在终端里 export 了一个变量然后手动执行脚本脚本里能看到但把这个脚本丢给 crontab 或 Systemd变量就不见了因为那些环境里没有经过你手动的 export。解决方法是脚本开头显式 source 环境文件source /etc/profile或. /etc/profile.d/xxx.sh或者干脆在脚本里再次 export。同样的道理两个脚本之间想共享变量别指望一个 source 就完事。source 的确是在同一个 Shell 里执行但环境变量文件如果写在函数或子Shell里照样传不出去。3.3 shift位置参数的消费顺序shift 的作用是把位置参数集体左移$2变成$1$3变成$2同时$#减一。它常用来写参数解析循环。比如要支持-d 目录 -f 文件这种选项while [ $# -gt 0 ]; do case $1 in -d) DIR$2; shift 2 ;; -f) FILE$2; shift 2 ;; *) echo 未知参数: $1 2; exit 1 ;; esac done这里shift 2表示一次消费两个参数因为选项名和值是一对。如果循环体里忘写 shift程序就会原地死循环。还有一个容易错的地方函数里的$1、$2和脚本顶层的$1、$2是两套位置参数。函数里 shift 只影响函数自己的参数列表不影响脚本接收到的参数。写函数时想拿脚本参数先存一份SCRIPT_ARG1$1再进函数。3.4 函数的局部变量、返回值与子ShellShell 函数默认情况下操作的是全局变量。你在函数里写count100函数一执行外层 count 也变了。不想污染全局就加 localmyfunc() { local tmp tmp$(date %Y) echo $tmp }local 只能用在函数里这也是脚本里推荐把逻辑都包成函数的一个原因局部变量不容易互相踩。返回值方面return 只能返回 0-255 的整数一般只用来表示成功失败。想从函数拿字符串别用 return用 echo 配合命令替换result$(myfunc)。还有一个隐蔽行为函数里 cd 也会改变当前脚本的全局目录。如果希望函数内部做目录切换但不影响外层把函数体放到子Shell里执行myfunc() ( cd /tmp ...; )注意这里用的是圆括号而不是花括号。3.5 少写 cat管道不免费能直读就直读搜索引擎里很多人查 shell 命令 cat常见用法却是cat file | grep xxx。这条路在功能上没毛病但没必要grep 本来就能直接读文件写成grep xxx file就行。多一个 cat 意味着多一个子进程多一层管道缓冲还可能引入前面说的变量丢失问题。比如cat file | while read ...count 在外层就是变不了。什么时候可以用 cat合并多个文件再统一处理比如cat part1.log part2.log | sort这时 cat 是合理的工具。另一个常见场景是把生成的文件内容导入变量content$(cat file)等价于content$(file)后者少一个外部命令调用。顺带提醒一句网上大量的 adb shell 命令、手机 shell 教程那是 Android 环境下的 toybox 工具集和 openEuler 上的 GNU bash 命令集有本质区别很多参数不通用。看到命令先分清环境别直接往服务器上套。4. 防御性脚本写法健壮性优先的必备规则4.1 set -euo pipefail 能开就开但要看清它的边界现在很多脚本开头会写#!/bin/bash set -euo pipefail-e脚本里任意命令失败就退出-u碰到未定义变量直接报错-o pipefail管道中只要有一个命令失败整条管道的返回状态就是失败。这套组合能拦住大量低级错误。但-e有它的盲区最典型的是这几类if 条件里的命令比如if grep -q foo file; thengrep 返回 1 不会触发退出cmd || true这种显式兜底也不会退出管道左侧的命令在未开启 pipefail 时失败也不会退出。所以别以为开了-e就万事大吉关键步骤还是要自己写 if 判断。我还会加一行 traptrap echo 脚本出错行号: $LINENO; exit 1 ERRERR 陷阱会在-e生效退出前被触发行号直接告诉你在脚本哪一行出问题排查效率高很多。这个习惯在 openEuler 上跑长任务脚本时特别值钱。4.2 引号、IFS 与文件名不给引号就是找坑前面反复强调引号这里再总结成一条硬规则变量展开一律加双引号除非你明确就是要拆词。$var、${var}、$都是没问题的$var裸奔几乎总会带来意想不到的分词。分词规则其实由一个环境变量 IFS 控制默认是空格、制表符、换行。你想自定义分隔符读取一行数据时可以这样IFS, read -r name age city 张三,28,北京 echo $name这里用了 here-string也是 Shell 的常用技巧。注意 read 前临时指定 IFS而不是全局改否则后面所有命令的分词逻辑都会变。文件名处理上还有一个容易忽视的点目录路径本身也可能带空格。比如用户目录是/home/My Workspace脚本里写死路径或者不引号同样会翻车。openEuler 服务器上少见但 Windows 用户上传的文件、挂载的共享目录里很常见千万别赌环境。4.3 命令存在性检查command -v 比 which 更可靠写脚本要调用某个外部命令时先确认它存不存在。比如要管理 openEuler 上的虚拟化环境用到 virsh启动脚本第一件事就是检查if ! command -v virsh /dev/null 21; then echo virsh 不可用请先安装 libvirt 客户端工具 2 exit 1 fi为什么不用 whichwhich 的退出码和输出在部分发行版、部分 shell 环境下并不稳定脚本里判断依赖时它不可靠。command -v 是 POSIX 内置的行为和退出码都规范得多。检查系统服务可以用 systemctlsystemctl is-active --quiet libvirtd退出码为 0 表示服务在运行非 0 表示没起来。这个组合写进脚本比直接执行virsh list --all然后解析乱糟糟的输出要稳妥。4.4 脚本文件本身shebang、换行、编码与自检Shell 脚本的坑不止在语法里很多时候脚本文件本身就有问题。最常见的是在 Windows 上编辑完传到 Linux文件是 CRLF 换行脚本一跑就报command not found: $\r。解决方法是转一下换行sed -i s/\r$// script.sh # 或者 dos2unix script.sh另一个是 shebang脚本第一行写#!/bin/bash并且确认文件有可执行权限否则只能bash script.sh显式调用。如果脚本里用了[[ ]]、${var// /_}这些 bash 扩展开头一定要写 bash不要写 sh。写完脚本我习惯先过三道自检bash -n script.sh只做语法检查不执行能抓住大量低级错误bash -x script.sh把每一步展开都打印出来调试逻辑特别好用有时间再用 shellcheck 做静态分析。openEuler 仓库里搜一下 shellcheck能找到就装上找不着也不强求前两道已经能救命。5. openEuler 环境专项ARM 架构与虚拟化场景中的 Shell 细节5.1 ARM 架构下的 Shell语法没变工具细节别大意openEuler 大量跑在 ARM 架构鲲鹏服务器上很多人担心 Shell 脚本会不会不兼容。放心bash 语法和 CPU 架构没有关系一套脚本在 x86 和 ARM 上都能跑。真正有差异的是外部命令和工具链。比如某些发行版的 grep 可能不支持-P选项某些精简系统的 sed 不支持某些扩展指令。openEuler 默认是 GNU 工具集比较完整但如果跑到 Docker 最小化容器里工具集可能缩水。写脚本时尽量用 POSIX 选项而不是 GNU 专属选项比如用grep -E而不是grep -P兼容性更好。如果脚本要按架构选择软件包可以用uname -m判断ARM 上输出 aarch64x86 上输出 x86_64。在 openEuler 的 ARM 服务器上写自动部署脚本时这个判断很常见ARCH$(uname -m) case $ARCH in aarch64) PKGpackage-arm64.rpm ;; x86_64) PKGpackage-amd64.rpm ;; *) echo 未知架构: $ARCH 2; exit 1 ;; esac5.2 初始化文件、PATH 与 DISPLAY环境变量从哪里来问得特别多的问题终端里手动跑脚本没问题一放到 crontab 或者 Systemd 里就不行找不到命令环境变量也是空。原因要回到 Shell 的初始化文件加载机制。openEuler 里登录 Shell 启动时会依次加载/etc/profile、/etc/profile.d/*.sh、~/.bash_profile非登录 Shell比如打开一个终端标签页主要加载~/.bashrc。你手动 source 的很多东西只存在于交互环境crontab、Systemd 本身就是独立进程不会走这些文件。解决办法是脚本开头显式加载环境source /etc/profile或者只加载需要的文件。注意别在函数里 source作用域问题会横生枝节。调试时可以故意用env -i ./script.sh模拟一个干净环境看看脚本缺什么就补什么。还有个图形界面相关的小细节如果 openEuler 装了图形界面DISPLAY 变量只会在登录图形会话时存在。脚本里想拉起 GUI 程序先检查 DISPLAY 是否存在否则程序会直接报 cannot open display。5.3 管理 KVM/libvirt 的脚本要检查哪些状态ARM 架构的 openEuler 服务器上做虚拟化很常见热词里也点了 libvirt-daemon-kvm。写这类管理脚本核心不是 virsh 命令本身而是状态检查。我的模板是这样先确认内核 KVM 支持再确认服务再确认客户端命令最后才执行管理操作。#!/bin/bash set -euo pipefail # 1. KVM 是否可用 [ -e /dev/kvm ] || { echo 当前内核没有 KVM 支持 2; exit 1; } # 2. libvirtd 是否在运行 if ! systemctl is-active --quiet libvirtd; then echo libvirtd 未运行请先启动 systemctl start libvirtd 2 exit 1 fi # 3. virsh 工具是否存在 command -v virsh /dev/null 21 || { echo 未找到 virsh 2; exit 1; } # 4. 列出虚拟机 virsh list --all这里每一步都有明确目的没/dev/kvm说明硬件虚拟化没开或内核模块没加载后面 virsh 大概率报错libvirtd 没起virsh 连接不到本地 hypervisor。三步前置检查能让脚本在 5 秒内给出明确提示而不是在深层报错里迷路。另外解析 virsh 输出时别迷信列位置。不同 libvirt 版本输出的列可能有细微差别尽量用virsh list --all --name这类自带格式化选项比output | awk {print $2}稳得多。5.4 脚本遇上交互式安装VMware Tools 场景的自动化思路有热词在问 openEuler 的 VMware Tools 怎么安装这背后其实是个 Shell 通用问题交互式安装程序怎么在脚本里自动化先说结论openEuler 现在优先装 open-vm-tools一条 dnf 命令就搞定不用手工挂载安装包dnf install -y open-vm-tools。如果你确实要跑官方 VMware Tools 的 tar 包脚本里会涉及这几步挂载光驱、解压、自动应答、卸载光驱。mount /dev/cdrom /mnt cp /mnt/VMwareTools-*.tar.gz /tmp/ umount /mnt tar -xzf /tmp/VMwareTools-*.tar.gz -C /tmp/ cd /tmp/vmware-tools-distrib # 尽量找静默参数别盲目 yes ./vmware-install.pl --help /dev/null 21 || true交互式安装脚本最怕读不到输入直接中断或者死等。yes | ./install.sh这类粗暴做法有时候能通但碰到它逐条读 y/n 还好说碰到需要输入路径就很尴尬。更稳的方法是先看安装脚本支持哪些静默选项再决定怎么传参。这个场景给我们的规则是脚本里执行第三方安装程序前先搞清楚目标程序有没有非交互模式。盲目用管道给 yes等于把控制权交给了天知道怎么实现的交互逻辑。6. 常见问题速查与一个完整排查案例6.1 问题速查表先把高频问题汇总成一张表方便你保存。症状最常见原因解决办法变量赋值后输出为空等号两边多了空格写成VARvalue空格一个都不要脚本报command not found: $\rWindows 的 CRLF 换行sed -i s/\r$// script.sh或 dos2unix[ -n $var ]对空字符串返回真变量展开后参数丢失加引号[ -n $var ]或改用[[ -n $var ]]for 循环把带空格文件名拆成多个使用了for f in $(ls ...)改用通配符for f in *.txtwhile 循环里累加的变量最后是 0管道创建了子Shell改用重定向done file脚本在 crontab 里找不到命令非交互环境没有加载 profile脚本开头source /etc/profile或补全绝对路径set -e 没拦住错误脚本继续跑-e 在 if/||/管道左侧有盲区关键命令手写 if 判断 trap ERR这张表里的问题我基本都在 openEuler 环境里见过有些是自己的脚本有些是帮同事查的问题。每一条都能用前面章节的规则解释。6.2 完整案例批量重命名带空格的日志文件最后走一遍完整案例。场景是 openEuler 服务器上/var/log/myapp目录下积累了一批文件格式类似app log 2024-11-01.txt现在要归档把文件名里的空格统一替换成下划线。第一版脚本我见过很多人这么写#!/bin/bash for f in /var/log/myapp/*.txt; do mv $f ${f// /_} done跑完不是报参数错误就是文件名变得残缺。问题有两个mv 的参数没加引号以及替换后的文件名还带着完整路径路径里的空格会二次捣乱。改好的版本#!/bin/bash set -euo pipefail SRC_DIR/var/log/myapp DEBUG1 # 1 只打印不执行0 真正执行 for file in $SRC_DIR/*.txt; do [ -e $file ] || continue new_name${file// /_} if [[ $file ! $new_name ]]; then if [[ $DEBUG -eq 1 ]]; then printf 将重命名: %s - %s\n $file $new_name else mv $file $new_name fi fi done拆开讲几个关键点[ -e $file ] || continue避免目录里一个 txt 都没有时通配符不展开导致对字面量路径执行 mv变量一律加双引号保证空格不拆词${file// /_}是 bash 参数扩展把字符串里所有空格替换成下划线比 sed、tr 更直接不用起子进程printf 输出比 echo 安全文件名以-开头或带特殊字符时不会出歧义。先用 DEBUG1 跑一遍看输出没问题再改成 0 正式执行这个习惯能救回成千上万个被误操作的文件。6.3 写完脚本后的自检清单给新手一份自检顺序同时也是我自己这些年沉淀下来的习惯。每次写完脚本按这个顺序过一遍第一bash -n script.sh查语法第二把所有变量展开的裸变量都加上双引号除非你明确想分词第三检查 shebang 是#!/bin/bash文件没有 CRLF第四对有破坏性的命令mv、rm、dd、覆盖写入先打印命令本身而不是直接执行第五在测试目录或者虚拟机里跑一遍确认退出码和结果最后保留脚本的 DEBUG 开关给后续维护留一条看得见的路径。这些步骤不做凭感觉写脚本早晚要还债。我见过太多问题不是高深技术导致的就是最基本的空格、引号、换行、退出码。最后说点我的体会。Shell 这门语言表面看语法松散实际上每一条被忽略的规则后面都跟着一个坑。我自己现在写 openEuler 运维脚本开头必写set -euo pipefail所有变量一律加引号碰到重命名、删除这类操作一定先打印一遍再执行。规则积累成肌肉记忆之后脚本自然就稳了。如果你正在学 Shell 脚本入门不妨把这篇番外篇当成一份避坑清单写脚本前翻一翻比报错之后再搜命令效率高得多。这套规则不只在 openEuler 上有效在大多数 Linux 服务器上都是一样的道理。
返回列表