刚接触 OpenEuler 的 Shell 编程时,很多人觉得脚本不过就是把几条 Linux 命令按顺序写进一个文件,然后执行一下,仅此而已。这个理解不算错,但真正动手之后你会发现,一个看起来再简单的起步脚本,里面也藏着解释器路径、变量展开、退出码、执行权限、相对路径这些一不小心就踩坑的细节。这篇是 OpenEuler Shell 编程系列的第 11 篇,我想认真拆一个能在 openEuler 上直接跑的起步脚本:从设计思路、核心语法到调试排错,把每一步为什么要这样做讲清楚。适合刚接触 shell 脚本入门的新手,也适合在 arm 架构 openEuler 服务器上做虚拟化运维的同学,把日常重复检查变成一键执行。
1. 为什么要把“起步脚本”当作一个正经项目来做
1.1 起步脚本不是玩具
很多人会把“简单脚本”和“不重要”画等号。我在实际运维里见过不少反过来教训的例子:一个只需要 20 行的健康检查脚本,因为没考虑退出码,被监控平台误判成“脚本执行失败”;另一个环境巡检脚本因为总在系统根目录下运行,导致日志文件写错位置,占满磁盘。越简单的脚本,运行频率往往越高,出问题后的影响范围也越广。
起步脚本的正确心态是“最小可用”而不是“随便写写”。它要完成一个明确的小目标,比如输出当前系统的基本状态、检查某个服务是否在运行、把一批文件的名称统一改掉。目标越小,越容易验证结果,也越容易在出错时定位问题。把这个最小闭环走通,后面再往里面加 for 循环、case 分支、函数定义,都是在同一个骨架上长肉。
所以这篇的定位不是教你写 Hello World 就完事,而是给你一个可以在 openEuler 上直接投入日常使用的起步脚本。它简单,但它是一个真正干活的东西。
1.2 在 openEuler 上学习 Shell 的独特优势
OpenEuler 是面向服务器场景的操作系统,装上它之后,你面对的就是真实的生产环境。在这里学 Shell 编程,不需要额外搭一套所谓的“实验环境”,你日常敲命令的地方就是最好的练手场。尤其是 arm 架构的 openEuler 服务器,很多人会用它搭配 libvirt-daemon-kvm 做虚拟化,这时候要批量查看虚拟机状态、检查服务存活、清理过期日志,手动一条一条敲命令会让人非常崩溃。起步脚本的价值在这个场景下会立刻显现出来。
另外,openEuler 默认的 Shell 环境是 Bash,这和绝大多数主流 Linux 发行版保持一致。你在 openEuler 上写的脚本,拿到其他 Linux 机器上基本可以直接跑,反过来也一样。这种兼容性降低了学习成本,也意味着你的脚本能力不会绑定在某一个特定系统上。
2. 起步脚本的设计思路:先想清楚再动笔
2.1 脚本要完成的最小目标
动手写脚本之前,先问自己一个问题:这个脚本到底要解决什么痛点?比如你每天登录服务器都要敲df -h看磁盘、敲free -m看内存、敲systemctl status看服务,这些操作是重复的,而且容易漏。那就把它们组合在一起,变成一个“环境巡检脚本”。它的目标很小,就是“一次输出当前系统的关键状态”。
设计时坚持三个原则:
- 单一职责:一个起步脚本只做一类事情,不要把环境巡检、日志清理、服务重启全塞进去。
- 退出码明确:脚本执行成功返回 0,遇到异常返回非 0,方便监控平台调用。
- 输出可读:每条输出前面带明确标签,比如
[DISK]、[MEM],别让人看半天猜不出哪行对应哪个指标。
这三个原则听起来简单,但很多老手写的脚本都做不到。起步阶段养成这个习惯,后面写大脚本才不会失控。
2.2 先避开这几个进阶特性
写第一个脚本的时候,我建议刻意避开几样东西:函数定义、getopts参数解析、关联数组、复杂的正则。不是这些特性不好,而是它们会把“脚本能跑”和“脚本为什么跑”这两件事混在一起。新手一旦引入过多特性,脚本报错时根本分不清是语法问题、变量问题还是逻辑问题。
我说的“避开”是暂时避开。当你把基础变量展开、条件判断、循环执行这些东西吃透之后,再回头用函数封装逻辑,会顺手很多。起步脚本的价值在于让你先建立对执行过程的感觉,而不是一步到位写出架构优雅的大脚本。先把路走通,再追求走好。
3. 核心语法拆解:起步脚本里的每一条都是知识点
3.1 第一行#!/bin/bash为什么是它
几乎每个 shell 脚本文件的第一行都是#!/bin/bash,这一行叫 shebang,它的作用是告诉系统用哪个解释器来执行这个文件。你可以试试把这行删掉,然后执行./check_env.sh,大概率会报错,因为系统不知道应该用什么程序去解释这个文件里的内容。
为什么用/bin/bash而不是/bin/sh?在 openEuler 上,/bin/sh通常指向 dash 或者一个兼容 POSIX 的精简 Shell,它的语法支持范围和 Bash 不完全一样。如果脚本里用了[[ ]]、source、数组这些 Bash 特性,用/bin/sh跑会直接报错,用/bin/bash跑就没问题。为了少踩莫名其妙的坑,统一写#!/bin/bash。这行后面如果加上调试选项也很有用,比如#!/bin/bash -x,可以在执行时把每条命令打印出来,便于观察脚本走向。平时先不加,调试时再用别的方式。
3.2 变量赋值与字符串拼接
Shell 入门第一个最容易翻车的点就是变量赋值。语法要求等号两边不能有空格,name=openEuler是赋值,name = openEuler会被解释成执行name命令并传递两个参数。这个区别新手很容易忽略,尤其在习惯了其他编程语言写法的情况下。
变量展开时,建议养成写${var}而不是$var的习惯。看下面这个例子:
#!/bin/bash LOG_DIR=/data/logs echo "${LOG_DIR}_backup" echo "$LOG_DIR_backup"第一行会输出/data/logs_backup,第二行会因为$LOG_DIR_backup被误认为一个变量名而输出空行。这种边界问题在拼接路径、加后缀时非常常见。另一个绝对值得养成的习惯是变量展开时加双引号:echo "$var"。如果变量内容里带空格,不加引号会导致它被拆成多个参数,行为完全出乎意料。
3.3 if、for 与 test:控制结构怎么进脚本
条件判断里最坑的是空格。if [ -f "$file" ]中,[是命令,后面的-f、$file、]之间都必须有空格。写成if [-f "$file"]会直接报 "command not found"。这个写法不符合直觉,但它是 Shell 语法的一部分:[本质上是test命令的别名,命令和参数之间当然要空格。
[ ]和[[ ]]的区别也值得知道。[ ]是 POSIX 语法,里面不能用&&和||,必须写成-a和-o;[[ ]]是 Bash 扩展语法,支持&&、||、正则匹配,更直观。起步脚本里我推荐直接使用[[ ]],前提是 shebang 严格指向 Bash。例如:
#!/bin/bash FILE=/etc/openEuler-release if [[ -f "$FILE" ]]; then echo "release file exists" fifor 循环是起步脚本里最实用的结构之一。比如你想批量重命名一堆文件,把.txt后缀改成.log:
#!/bin/bash for f in /tmp/test/*.txt; do mv "$f" "${f%.txt}.log" done这里的${f%.txt}是参数展开,作用是去掉变量f末尾的.txt。这个技巧在处理批量文件时比sed更直接,也避免了子进程带来的复杂度。for 循环的常见写法还有for i in 1 2 3 4 5和for i in $(seq 1 5),做端口扫描或批量检查时可以用到。
3.4 shift、$@、$?:参数解析的入门三件套
起步脚本往往需要接收参数,比如./check_env.sh --path /var/log。这里的$1是第一个参数,$2是第二个,$@是所有参数。shift命令的作用是把参数列表往左移一位,原来的$2变成新的$1,方便逐个处理。
写一个简单的参数循环:
#!/bin/bash while [[ $# -gt 0 ]]; do case "$1" in --path) TARGET_PATH="$2" shift 2 ;; --verbose) VERBOSE=1 shift ;; *) echo "unknown option: $1" exit 1 ;; esac doneshift 2表示移走两个参数,这样--path /var/log作为一个整体被消费掉。exit 1是退出码,非 0 表示异常,外部监控程序拿到这个返回值就能判断脚本执行失败了。$?则用来读取上一条命令的退出码。在脚本里经常能看到这样的模式:
df -h > /dev/null 2>&1 if [[ $? -ne 0 ]]; then echo "df command failed" exit 1 fi第一行的> /dev/null 2>&1是把标准输出和错误输出都丢弃,只保留退出码。这个组合在写脚本时出现的频率极高,也是排查问题时的重点。
4. 实操:从零写一个可在 openEuler 上直接跑的起步脚本
4.1 用 vim 创建脚本并设置可执行权限
我挑一个最常见的场景来实战:写一个环境巡检脚本,输出主机名、运行时长、磁盘使用率、内存使用率和关键服务状态。在 openEuler 终端里执行:
vim check_env.sh把下面内容写进去:
#!/bin/bash echo "===== Basic Info =====" hostname uptime echo "===== Disk Usage =====" df -h | grep -v tmpfs echo "===== Memory Usage =====" free -m echo "===== Service Status =====" systemctl is-active sshd systemctl is-active libvirtd这里每个部分都在回应最开始说的“最小目标”。df -h | grep -v tmpfs是为了过滤掉 tmpfs 这类内存文件系统的干扰,聚焦真实磁盘分区。systemctl is-active的输出很简单,要么active要么inactive,适合脚本直接判断。
下一步是赋予可执行权限:
chmod +x check_env.sh关于执行权限有个细节值得记住:./check_env.sh需要读和执行权限,而bash check_env.sh只需要读权限。前者是文件作为程序被内核加载,后者是显式让 bash 去解释文件。日常测试时用bash check_env.sh更省事,但正式使用时我建议都用./check_env.sh,这样能尽早发现权限缺失的问题,避免部署到生产环境时突然失效。
4.2 运行输出解读与参数微调
直接运行脚本:
./check_env.sh输出类似这样:
===== Basic Info ===== my-server 14:32:19 up 120 days, 3:25, 2 users, load average: 0.01, 0.03, 0.05 ===== Disk Usage ===== Filesystem Size Used Avail Use% Mounted on /dev/vda1 100G 62G 38G 63% / /dev/vda2 200G 80G 120G 40% /data ===== Memory Usage ===== total used free shared buff/cache available Mem: 7823 2048 3050 210 2725 5277 Swap: 2047 0 2047 ===== Service Status ===== active active如果你在 arm 架构的 openEuler 服务器上跑,输出中的磁盘设备名可能是/dev/sda而不是/dev/vda,这取决于虚拟化底层。我这边是 qemu/kvm 环境,所以看到的是 virtio 设备。这不影响脚本逻辑,脚本本身和你用的是什么架构无关,这正是 Shell 脚本跨平台的价值。
如果想进一步加一个磁盘空间告警,可以取用率这一列:
#!/bin/bash THRESHOLD=80 USAGE=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%') if [[ $USAGE -gt $THRESHOLD ]]; then echo "Warning: root disk usage is ${USAGE}%" else echo "Root disk usage is ${USAGE}%, OK" fi$( )是命令替换,把df -h /的输出经过awk提取第五列,再用tr -d '%'去掉百分号,最终得到一个纯数字,然后和阈值比较。命令行里手动操作时你不会觉得这些命令有什么特别,但把它们组合在一起时,每一个管道和引号都可能让脚本行为偏离预期,所以要保持头脑清晰。
4.3 用 bash -x 进行最简单的调试
脚本跑通之后,我想强烈建议你体验一次调试模式。执行:
bash -x check_env.sh-x选项会让 bash 在执行每条命令前把这条命令打印到标准错误,前面带一个+号。你会看到变量展开后的真实值,比如输出是+ df -h /而不是df -h /,这时就说明脚本已经从上到下按顺序执行到这里了。
当脚本出错时,bash -x的输出就是最有用的破案线索。比如[[ $USAGE -gt $THRESHOLD ]]这条,如果$USAGE是63,你看到的输出就是+ [[ 63 -gt 80 ]],一眼就能判断比较逻辑有没有问题。如果变量值是空的,那就不用费劲查下面的逻辑了,问题一定出在变量赋值那一步。
在脚本内部也可以局部开启调试,用set -x开启、set +x关闭,这样只针对某一段代码打印详细过程。这个方法比在整块脚本里加echo强得多,因为不会污染输出内容。
4.4 让起步脚本进入日常运维:crontab 定时执行
脚本写好了,手动执行没问题,接下来一个很自然的需求是让它定时跑。在 openEuler 上编辑 crontab:
crontab -e添加一行,比如每隔 30 分钟执行一次:
*/30 * * * * /home/user/check_env.sh >> /var/log/check_env.log 2>&1有两个地方特别容易踩坑。第一,crontab 里的命令要用绝对路径,因为定时任务环境的 PATH 很精简,相对路径和仅在交互式 Shell 里生效的变量都不可靠。第二,脚本的输出必须重定向到文件,否则 cron 会把输出通过邮件发给你,有时候邮件没配置,输出就悄悄丢失,让你以为脚本没跑。
如果想排除 cron 环境问题,可以在脚本开头加一行:
#!/bin/bash source /etc/profile这样能加载系统环境变量,避免脚本里用到某些自定义变量时在 cron 环境下为空。
5. 常见问题与排查技巧实录
5.1 从 Windows 复制脚本后各种报错
这是一个出现频率极高的问题。你在 Windows 上用记事本或 IDE 写好脚本,然后上传到 openEuler 上执行,结果报错bad interpreter: /bin/bash^M: No such file or directory。这里的^M是 Windows 的 CRLF 换行符(\r\n)和 Linux 的 LF 换行符(\n)冲突造成的。Linux 把\r也当成了解释器路径的一部分,自然找不到这个“解释器”。
解决办法是在 openEuler 上执行:
sed -i 's/\r$//' check_env.sh或者用dos2unix check_env.sh。从根上避免的方法是,在 Windows 端编辑时把换行符设置成 LF,几乎所有主流编辑器都支持这个设置。这个坑本身不大,但第一次遇到时非常容易让人困惑。
5.2 cd 相对路径引起的“找不到目录”
脚本里如果写了cd mydir,这个mydir是相对路径,它会相对于你执行脚本时所在的目录去解析。你在/home/user下执行./check_env.sh没问题,但放到 crontab 里从/下执行时,脚本开头那个cd mydir就找不到目录了。
正确的做法有两种。一种是用绝对路径:
cd /home/user/mydir另一种是让脚本以自身所在目录为基准:
cd "$(dirname "$0")"这里的$0是脚本自身的路径,dirname提取出目录部分。组合起来就是“无论脚本从哪里被调用,都先切换到脚本自己所在的目录”。这个写法在部署脚本、备份脚本里非常常见,也是我写脚本时的固定开头之一。
5.3 export 作用域与 source 的区别
export在 shell 编程里很容易被误解。它并不是把变量设为“全局变量”,而是把这个变量标记为“可以被当前 Shell 启动的子进程继承”。比如你在终端执行:
export MY_VAR=hello ./check_env.sh在check_env.sh里是可以读到MY_VAR的;但如果不在终端里 export,脚本里就读不到。反过来也一样,脚本内部定义的普通变量,脚本结束后不会留在当前终端里。
还有一个和 export 紧密相关的知识点是source。执行source check_env.sh时,脚本是在当前 Shell 进程里被逐行解释执行的,它里面定义的变量会留在当前 Shell 里;执行./check_env.sh时,系统会启动一个新的子 Shell 来运行脚本,变量不会回流。所以如果你想在配置变更后马上让当前会话生效,就要用source,比如source ~/.bashrc。想隔离脚本里的变量污染,则用./script.sh。这个区别直接影响脚本的设计方式。
5.4 起步脚本常见坑位速查表
我把这个过程中最容易翻车的几个点整理成一张表,写脚本时可以对照着自查:
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
报command not found | 变量赋值等号两边有空格,或[后没空格 | 去掉空格,按[ 条件 ]格式写 |
| 变量输出为空 | 变量名边界未加{} | 使用${var} |
| 文件名带空格时处理失败 | 变量展开未加双引号 | 写成"$var" |
bad interpreter | Windows 换行符残留 | sed -i 's/\r$//' script.sh |
| crontab 里跑不出结果 | 使用了相对路径 | 全部改成绝对路径 |
set -e后脚本提前退出 | 某条命令退出码非 0 | 在可能失败的命令后加 ` |
| 管道符后判断失真 | 管道中前一条命令失败但最后一条成功 | 使用set -o pipefail |
最后一条值得多解释两句。set -e的意思是“一旦有命令返回非 0,脚本立即退出”。但这个行为在管道里会打折,因为管道的退出码只看最后一个命令,比如df -h | grep tmpfs,如果df失败了,但grep没匹配到内容返回 1,脚本反而会退出;如果grep返回 0,脚本又继续跑。set -o pipefail可以让管道的退出码变成第一个失败命令的退出码,配合set -e用,脚本的行为才真正可预期。起步阶段可能用不上,但迟早会遇到。
写到这里,算是我个人在 openEuler 上折腾 shell 脚本的一点经验沉淀。启动脚本不一定要多高级,但一定要让每次执行都有明确的结果、明确的退出码,还要让后来的人看得懂输出。写完脚本后建议顺手跑一次shellcheck check_env.sh,这个静态检查工具能帮你找出很多隐藏问题,openEuler 的软件源里可以直接用dnf install shellcheck安装。我的体会是,把第一个简单的起步脚本认真打磨到“能交付给别人用”的程度,比随手写十个能跑的临时脚本,对能力的提升大得多。