Shell
Shell 接收命令,展开其中的变量和通配符,安排输入输出,然后执行程序。把这些命令保存为文件,就能重复完成文件检查、配置生成、进程调用和定时巡检。脚本的正确性取决于每一步实际收到哪些参数、产生什么结果,以及下一步在什么条件下执行。
例如 grep 'ERROR' app.log > errors.log 同时涉及两个程序职责:Shell 先打开输出文件,grep 再读取日志并筛选记录。文件打不开,grep 不会启动;没有匹配行时,grep 返回 1,但它完成了搜索。理解这些区别,才能写出合适的分支。
从命令行到脚本进程
准备普通用户的练习环境
使用 Ubuntu 24.04 LTS、Bash 5.2 和 GNU 命令行工具。文件实验在普通用户自己的临时目录执行,无须 root;软件安装由有 sudo 权限的管理员完成:
sudo apt-get update
sudo apt-get install --no-install-recommends bash coreutils findutils util-linux curl jq shellcheck shfmt企业内网使用已配置的受信任软件源;离线环境应连同依赖和架构准备软件包,具体准备方法见服务器初始化。不把下载失败处理成关闭签名校验,也不在脚本启动时偷偷安装依赖。
打开普通用户的 Bash 终端,确认命令可用,再创建练习目录。后续未另行说明的命令都在这个终端执行:
id
bash --version
command -v bash printf grep sed awk find flock timeout curl jq shellcheck shfmt
umask 077
LAB_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/shell-lab.XXXXXXXX")
cd -- "$LAB_ROOT"
printf 'workspace=%s\n' "$PWD"id 的 UID 应为普通用户;bash --version 应显示 5.2 系,命令检查应逐项列出路径或内建名称。mktemp 生成的目录名每次不同,以自己的输出为准。umask 077 限制此后新建文件的默认权限,不能改变已有文件。
也可以在已有 Docker 环境中启动一次性 Ubuntu 容器练习。先在镜像构建时安装上述工具,再以非 root 用户运行;容器内的 systemctl 通常无法连接 systemd,定时器一节需在有 systemd 用户会话的 Linux 主机上操作。宿主机目录和生产配置不应挂入语法练习容器。
解释器、执行权限与当前 Shell
在练习目录创建第一个脚本。这里的引号使 here-document 中的 $1、$PWD 原样保存,留到脚本运行时展开:
cat > hello.sh <<'BASH'
#!/usr/bin/env bash
name=${1:-demo}
printf 'name=<%s>\n' "$name"
printf 'cwd=<%s>\n' "$PWD"
BASH
chmod u+x hello.sh
./hello.sh 'order service'第一行输出 name=<order service>,第二行是练习目录。参数中的空格保留在同一个字符串里。
| 启动方式 | 解释器与执行位置 | 对调用者的影响 |
|---|---|---|
./hello.sh | 系统按首行 shebang 找解释器;文件需有执行权限 | 新进程执行,脚本内 cd 不改变当前终端 |
bash hello.sh | 显式使用找到的 Bash;脚本文件需可读 | 不依赖脚本自身的执行位,shebang 不决定解释器 |
sh hello.sh | 显式使用 sh,Ubuntu 默认通常为 dash | 数组、[[ ... ]] 等 Bash 语法不能照搬 |
source hello.sh | 当前 Bash 读取并执行文件 | 变量、工作目录、选项和 exit 都可能影响当前终端 |
#!/usr/bin/env bash 从 PATH 查找 Bash,适合跨常见 Linux 路径的脚本;管理脚本也可指定已确认的 /bin/bash。二者都需要控制解释器来源。不要 source 下载来的配置或把用户上传的文本当脚本执行。
终端提供交互界面,Shell 解释命令;grep、curl 是它启动的外部程序。cd 必须改变 Shell 自己的工作目录,因此是内建命令。用 type -a cd printf grep 可以查看同名内建、函数或外部程序;脚本不应依赖交互终端中的别名。
环境变量怎样传给程序
普通 Shell 变量只保存在当前 Shell,导出后才进入随后启动的子进程环境:
(
unset SHELL_LAB_MODE
SHELL_LAB_MODE=dev
bash -c 'printf "child=<%s>\n" "${SHELL_LAB_MODE-unset}"'
export SHELL_LAB_MODE
bash -c 'printf "child=<%s>\n" "$SHELL_LAB_MODE"'
SHELL_LAB_MODE=test bash -c 'printf "child=<%s>\n" "$SHELL_LAB_MODE"'
printf 'parent=<%s>\n' "$SHELL_LAB_MODE"
)依次得到 unset、dev、test、dev。外层括号把练习放在子 Shell 中,先清除练习变量,结束后也不改变当前终端的同名变量。单次命令前的赋值只为该次外部命令提供环境。子进程不能直接把自己的变量或目录改动传回父 Shell。
bash -c '...' label value 中,label 是新 Shell 的 $0,value 才是 $1。通过位置参数传数据,比把数据拼入 -c 字符串安全。非交互 Bash 还可能读取环境变量 BASH_ENV 指向的文件;--noprofile --norc 不会取消这一机制。需要干净环境的任务应显式处理环境继承,启动细节可查 Ubuntu Bash 手册。
参数展开与引号:程序究竟收到什么
先区分命令文本与参数列表
下面的函数将参数编号打印出来:
show_args() {
local n=0 arg
for arg in "$@"; do
n=$((n + 1))
printf '%s=<%s>\n' "$n" "$arg"
done
}
value='order service'
show_args "$value" '*.log' ''输出是三个参数,第三个是空字符串:
1=<order service>
2=<*.log>
3=<>引号在 Shell 解析时生效,不作为普通字符传给函数。把第一处改成未引用的 $value,在默认 IFS 下会拆成 order、service;把第二处的引号去掉,*.log 会尝试匹配当前目录中的文件。没有匹配时,Bash 默认保留该模式原文,开启 nullglob 后则会把它移除。
普通命令参数的展开可以按下面的顺序理解:
命令文本
├─ 语法识别:引号、管道、重定向、命令位置
├─ 花括号展开、波浪号展开
├─ 参数展开 / 命令替换 / 算术展开
├─ 未引用结果的单词拆分
├─ 路径名展开:*、?、[...] 匹配文件名
└─ 去除语法引号 → 程序收到 argv 参数列表赋值和 [[ ... ]] 等位置有自己的规则,不能把普通参数拆分机械套到所有语法中。最常用的写法是:数据用 "$value",明确需要模式匹配的表达式才保留模式。引号缺失的典型后果见 ShellCheck SC2086。
单引号中的字符保持原样;双引号允许 $name、$(command) 等展开,同时保留展开结果的参数边界。反斜线用于转义特定字符,续行反斜线后不能再跟空格。
用数组保存多个参数
把多个选项塞进一个字符串,会丢失“哪些空格分隔参数、哪些空格属于参数”的信息。Bash 数组保存的是独立元素:
files=('order service.log' 'audit.log')
printf 'file=<%s>\n' "${files[@]}"
files+=('worker.log')
printf 'count=%s\n' "${#files[@]}""${files[@]}" 将每个元素作为独立参数,"$@" 对脚本或函数的位置参数做同样的事。"$*" 则把所有位置参数连接成一个字符串,连接符取 IFS 的首字符;未引用的 $@、$* 还可能继续拆分和匹配文件。
$1、$2 依次取位置参数,${10} 取第十个;$# 是位置参数个数,不包含 $0。脚本解析选项时,shift 从左侧移除已消费的参数,剩下的参数重新从 $1 编号。
命令也可以用数组保存:args=(grep -F -- "$pattern" "$file"),随后 "${args[@]}" 执行。-- 告诉支持它的程序“选项到此结束”,避免把 -draft.log 当成选项;它是程序的约定,不是所有命令通用的 Shell 语法。
不要通过 eval "$command" 恢复字符串里的引号。eval 会再次把字符串当代码解析,配置里的分号、重定向和命令替换都可能变成可执行语法。含多层 SSH 或 bash -c 的调用尤其要减少字符串拼接。
默认值、字符串处理与命令替换
| 表达式 | 用途 |
|---|---|
${name:-demo} | 未设置或为空时使用默认值,不修改变量 |
${name-demo} | 仅未设置时使用默认值,保留显式空字符串 |
${name:?需要提供 name} | 未设置或为空时报错;非交互 Shell 会退出 |
${#name} | 字符串长度 |
${file##*/} | 去掉最长的目录前缀,得到末尾名称 |
${file%.log} | 去掉匹配 .log 的末尾部分 |
value=$(command) 接收标准输出,标准错误仍输出到原来的目的地。末尾连续的换行会被移除;Shell 变量也无法承载 NUL 字节。二进制数据应保存在文件或管道中,不要装进变量。
value=$(printf 'ready\n\n')
printf '<%s> length=%s\n' "$value" "${#value}"输出 <ready> length=5。配置文件中的空行如果有意义,就应按文件处理。
算术展开 $((...)) 处理整数,不是通用的数值解析器。未经检查的外部字符串不要直接放入算术表达式。先限制为数字和合理长度,再转换;带前导零的字符串可用 10# 明确十进制,避免把 08 按八进制解释。后面的配置更新脚本会完整实现输入检查。
条件、循环与函数怎样组织执行
条件判断检查退出状态
每条命令都有退出状态,0 通常表示成功,非零的具体含义由该命令定义。if command; then ...; fi 直接检查这个状态。
printf 'ready\n' > status.txt
if grep -Fx 'ready' status.txt; then
printf 'state is ready\n'
else
rc=$?
case "$rc" in
1) printf 'state is not ready\n' ;;
*) printf 'cannot read status: rc=%s\n' "$rc" >&2 ;;
esac
figrep 的 1 表示无匹配,读取错误等情况通常是 2。不能把所有非零都解释成“文件中没有目标内容”。$? 只保留上一条命令的状态,需要在 else 第一条赋值中保存。
[[ ... ]] 是 Bash 条件语法,常用 -f 检查普通文件、-d 检查目录、-L 检查符号链接、-r 检查当前身份是否可读、-z 检查空字符串。多个条件可用 &&、||。[[ "$name" == *.log ]] 的右侧是模式;右侧加引号后按字面比较。[ ... ] 是另一套更接近 POSIX 的写法,参数必须正确分隔并引用,不能简单去掉一层方括号后继续照用全部 Bash 语法。
(( expression )) 将非零数值视为真,退出状态为 0;表达式值为零时状态为 1。因此 ((n++)) 在旧值是 0 时会返回 1。普通计数用 n=$((n + 1)) 更容易与命令失败区分。
循环要选择正确的记录分隔方式
数组适合明确的参数列表,for item in "${items[@]}" 按元素处理。读取文本文件时,用 read 按行保留空格和反斜线:
printf 'order service\nworker' > services.txt
while IFS= read -r service || [[ -n "$service" ]]; do
[[ -z "$service" ]] && continue
printf 'service=<%s>\n' "$service"
done < services.txt输出两行服务名。最后的 worker 没有换行,read 返回非零但已读入数据,|| [[ -n ... ]] 让它仍被处理。IFS= 只作用于这一条 read,无须全局修改 IFS。break 结束循环,continue 跳到下一条记录。
文件名可以包含换行,不能用“每行一个文件”处理任意目录。GNU find -print0 和 read -d '' 以 NUL 分隔;需要先确认扫描成功时,可把清单保存在临时文件,再开始处理:
mkdir -p -- "$LAB_ROOT/files"
printf 'sample\n' > "$LAB_ROOT/files/order service.log"
printf 'sample\n' > "$LAB_ROOT/files/worker.log"
list_file=$(mktemp "$LAB_ROOT/file-list.XXXXXXXX")
if find "$LAB_ROOT/files" -type f -name '*.log' -print0 > "$list_file"; then
while IFS= read -r -d '' file; do
printf 'candidate=%q\n' "$file"
done < "$list_file"
else
printf 'scan failed; no processing performed\n' >&2
fi
rm -f -- "$list_file"此处只打印候选路径,不删除文件。%q 把换行等字符显示为可辨认的 Bash 转义形式。批量传给外部程序还可用 find ... -exec command {} +,或使用支持 NUL 的 xargs -0;GNU xargs -r 可阻止空输入时执行一次命令。匹配和动作规则见 GNU find 手册。
清理真实日志时,应先明确目录归属、日志是否仍被进程打开、保留策略和恢复需求。在其他用户可改名、替换符号链接的目录里,“先列出,再删除”存在检查与执行间的竞态;这一练习清单不能直接升级成 root 清理程序。
函数通过输出传数据,通过状态报告成功
函数的位置参数独立于调用者,local 限制变量作用域;return 结束函数,exit 结束当前脚本。函数未显式返回时,状态取最后一条命令。
read_state() {
local file=$1 value
if value=$(cat -- "$file"); then
printf '%s\n' "$value"
else
printf 'cannot read state file\n' >&2
return 1
fi
}
if state=$(read_state status.txt); then
printf 'state=<%s>\n' "$state"
else
printf 'state is unavailable\n' >&2
fi诊断发往标准错误,标准输出只返回数据。否则调用者会把日志也收进 state。
声明与可能失败的赋值应分开。local value=$(command) 取得的是 local 内建命令的状态,可能掩盖 command 的失败;export value=$(command) 有相同问题。具体规则见 ShellCheck SC2155。
输入输出、管道与结构化数据
三个文件描述符与重定向顺序
每个普通命令启动时通常继承 0、1、2 三个文件描述符,分别对应标准输入、标准输出、标准错误。文件描述符指向已打开的文件、终端或管道,程序不需要知道它们最初来自哪个路径。
输入文件 ── 0 stdin ──→ 程序 ── 1 stdout ──→ 结果文件 / 下一条命令
└─ 2 stderr ──→ 终端 / 错误日志< file 改变输入,> file 创建或截断输出文件,>> file 追加。重定向从左到右处理:
bash -c 'printf "data\n"; printf "problem\n" >&2' > combined.log 2>&1
bash -c 'printf "data\n"; printf "problem\n" >&2' 2>&1 > data.log第一条将 stdout 指向文件,再让 stderr 复制此时 stdout 的目的地,两种输出都进入 combined.log。第二条先让 stderr 复制原 stdout,随后才改变 stdout,因此 problem 仍出现在终端。
Shell 在程序读取前就可能截断目标文件。sort input.txt > input.txt 会破坏输入,应先生成另一个文件,成功后再替换;相关陷阱见 ShellCheck SC2094。程序检查本身是否成功,还需检查退出状态,不能只看目标文件存在。
管道中的命令并发运行
producer | consumer 连接前者 stdout 与后者 stdin,stderr 默认不进入管道。管道缓冲区满时生产者等待,消费者退出后生产者继续写可能收到 SIGPIPE。两条命令通常并发执行,不能假定左边全部完成后右边才启动。
默认情况下,Bash 用最后一条命令的状态作为整个管道的状态。下面的子 Shell 启用 pipefail,只影响括号内部:
(
set -o pipefail
false | cat
statuses=("${PIPESTATUS[@]}")
printf 'producer=%s consumer=%s\n' "${statuses[0]}" "${statuses[1]}"
)得到 producer=1 consumer=0。PIPESTATUS 保存各阶段的状态,必须紧接管道复制;哪怕先执行一条 echo,记录也会被覆盖。开启 pipefail 后,管道状态取最右侧非零状态,全部为零才返回零。
yes | head -n 1 的消费者只要一行,会主动关闭管道。在 Linux 上,生产者通常以 141(128 + SIGPIPE 13)结束。此时 pipefail 非零并不表示取第一行失败。需要判断的是消费者是否按契约取得数据,不能把所有短路读取都套成“上游服务坏了”。grep -q 提前找到匹配后也可能产生同类现象。
子 Shell 中的修改怎样返回
括号 ( commands ) 在子 Shell 环境中执行;{ commands; } 通常在当前 Shell 分组执行。默认 Bash 管道也会让各阶段处在子 Shell 环境,下面的 count 在循环里增加,循环外仍为 0:
count=0
printf 'a\nb\n' | while IFS= read -r line; do
count=$((count + 1))
done
printf 'count=%s\n' "$count"需要循环结束后使用计数时,可像前面的 services.txt 示例一样,把文件重定向给 while,避免把循环放在管道末端。Bash 的 lastpipe 选项在特定条件下可改变行为,日常脚本最好不依赖调用环境预先开启它。此问题可参阅 ShellCheck SC2030。
进程替换 while ...; done < <(producer) 可以让循环留在当前 Shell,但 producer 的失败不会自动变成循环状态。扫描或下载结果必须完整时,先确认生产命令成功再消费文件,因果关系更清楚。
文本用行和字段,JSON 用解析器
grep 选择记录,sed 适合规则明确的文本替换,awk 处理字段与聚合。例如下面的空格分隔文件由自己生成,第三列始终是状态码:
printf 'GET /orders 200\nPOST /orders 201\nGET /orders 503\n' > requests.log
awk '$3 >= 500 { count++ } END { print count + 0 }' requests.log
sed 's/\/orders/\/purchases/g' requests.log第一条统计结果为 1;第二条打印替换后的记录,原文件保持不变。若日志是 JSON、带引号的 CSV 或不固定列结构,就应换相应解析器,不能靠分隔空格猜字段。
JSON 的字符串转义与类型交给 jq:
jq -n --arg name 'order "worker"' --argjson workers 4 \
'{name: $name, workers: $workers}' > settings.json
jq -er '.workers | select(type == "number" and . >= 1 and . <= 32)' settings.json前一条安全转义名称中的引号,把 workers 保存为数字;后一条取到 4。-e 让 false、null 或没有产生有效结果反映为非零状态,过滤规则需要与输入约定相匹配。参数和退出状态见 jq 官方手册。不要用 grep '"success":true' 判断任意 JSON,也不要把 API 返回值拿去 eval。
退出状态与错误处理
显式保存失败原因
对必须成功才能继续的步骤,直接把命令放进条件判断:
if cp -- status.txt status-copy.txt; then
printf 'copy completed\n'
else
rc=$?
printf 'copy failed: rc=%s\n' "$rc" >&2
fi不要写 if ! command; then rc=$? 来保存原始错误码。! 已经取反,进入分支时 $? 为 0。只关心失败与否时可以用 !,需要原始状态时采用上面的 else 写法。
脚本自身应给调用者稳定的状态约定,例如 0 成功、2 参数错误、1 执行失败、75 锁冲突。这里的 2 和 75 是该脚本的接口选择,不能据此解释所有外部程序。命令找不到通常是 127,找到但无法执行通常是 126;这些情况先检查安装、路径、权限和解释器。
set -e 有上下文例外
set -e 会让 Shell 在部分未处理的非零状态后退出,set -u 检查未设置变量,set -o pipefail 改变管道状态。这三个选项分别处理不同问题,组合使用仍需理解程序的控制流。
在 if 条件、while/until 测试、!、部分 &&/|| 列表等上下文中,-e 不按“遇到非零就退出”工作。尤其是作为条件调用的整个函数:
bash -c '
set -e
build_step() {
false
printf "continued\n"
}
if build_step; then
printf "reported success\n"
fi
'它会输出 continued 和 reported success。函数被用于条件判断,内部 false 没让脚本退出,随后 printf 又返回 0。修复应让失败步骤显式返回,例如 false || return 1,真实构建命令也采用相同结构;见 ShellCheck SC2310。
默认非 POSIX 模式下,Bash 的命令替换还会清除子 Shell 中继承的 -e。result=$(false; printf ready) 因最后一条命令成功而得到 ready。shopt -s inherit_errexit 可以改变继承,但不能消除条件上下文等其他例外。复杂的命令替换同样应显式传播状态,详见 ShellCheck SC2311。
ERR trap 适合补充错误位置;它也受测试上下文等规则影响,不是全局异常捕获器。set -E 扩大 ERR trap 的继承范围,却不改变命令本身的成功定义。
日志、调试与清理分别处理
把业务结果留在 stdout,错误和进度写到 stderr。批量任务至少能关联任务名称、目标、阶段和最终状态;包含账号密码、令牌或签名 URL 的参数不能原样进入日志。
bash -n script.sh 检查语法而不执行普通命令;bash -x script.sh 会执行,并输出展开后的命令参数。调试生产凭据时不要开启全局 xtrace,也不要在 ERR trap 中直接打印全部 BASH_COMMAND、环境变量或 HTTP 头。
trap cleanup EXIT 在 Shell 退出时清理自己创建的临时文件;trap 'exit 130' INT、trap 'exit 143' TERM 可将常见中断转为退出,再触发 EXIT 清理。清理函数应先保存 $?,成功清理后保留原始失败码。若原任务成功但清理失败,也应报告,而非安静地留下垃圾。SIGKILL、断电和内核崩溃不能执行 trap,持续运行的任务还需要可识别的遗留文件和恢复办法。
文件写入的完整清理函数见下一节。它只删除本次 mktemp 返回的文件,不用空变量、通配符或目录根执行递归删除。
完整任务:有锁的配置文件更新
先确定更新的对象和成功含义
练习任务把工作线程数写入 settings.conf,内容形如 workers=4。线程数允许 1 到 32;输入错误时不写文件;相同内容重复执行不替换文件;两个执行者使用同一把锁,避免同时比较和覆盖。
目标目录由当前用户独占,权限为 700,使用本地文件系统。目录及其上级路径须可信,不能让其他用户替换目录;脚本还会拒绝目标文件和锁文件是符号链接的情况。此约定使文件检查与后续操作之间不受其他用户干扰。它没有解决同一账号下恶意进程竞争修改路径的问题,不能改用 root 后直接处理不可信上传目录。
更新过程如下:
校验参数、目录和目标文件
├─ -n → 打印待生成内容 → 结束,不创建锁或临时文件
└─ 执行 → 获取独占锁 → 在目标目录创建临时文件
→ 写入、设置权限、比较内容
├─ 相同 → 删除临时文件,保留原文件
├─ 不同 → rename 替换目标文件
└─ 失败 → 保留原文件,清理临时文件flock 默认等待锁,这里使用 -n -E 75 将争用报告为 75,其他错误保留自己的状态。锁在文件描述符关闭后释放;不要删除锁文件来“释放锁”,否则后续进程可能打开新 inode,形成两把互不相干的锁。文件系统与进程继承限制见 util-linux flock 手册。
保存完整脚本
在练习目录创建 render-settings.sh,内容如下。脚本只生成配置,不重载或重启任何服务:
#!/usr/bin/env bash
set -u
set -o pipefail
export PATH=/usr/bin:/bin
export LC_ALL=C
umask 077
usage() {
printf 'usage: render-settings.sh [-n] -d PRIVATE_DIRECTORY -w WORKERS\n'
}
fail() {
printf '%s\n' "$1" >&2
exit "${2:-1}"
}
dry_run=0
directory=
workers=
while getopts ':nd:w:h' option; do
case "$option" in
n) dry_run=1 ;;
d) directory=$OPTARG ;;
w) workers=$OPTARG ;;
h)
usage
exit 0
;;
:) fail "missing value for -$OPTARG" 2 ;;
\?) fail "unknown option: -$OPTARG" 2 ;;
esac
done
shift "$((OPTIND - 1))"
[[ $# -eq 0 ]] || fail 'unexpected positional argument' 2
[[ "$workers" =~ ^[0-9]{1,2}$ ]] || fail 'workers must be 1..32' 2
workers=$((10#$workers))
((workers >= 1 && workers <= 32)) || fail 'workers must be 1..32' 2
[[ "$directory" == /* && -d "$directory" && ! -L "$directory" ]] ||
fail 'directory must be an existing absolute, non-symlink directory' 2
[[ -O "$directory" ]] || fail 'directory must belong to the current user' 2
mode=$(stat -c '%a' -- "$directory") || fail 'cannot inspect directory'
[[ "$mode" == 700 ]] || fail 'directory mode must be 700' 2
directory=$(realpath -e -- "$directory") || fail 'cannot resolve directory'
target=$directory/settings.conf
lock=$directory/.settings.lock
for path in "$target" "$lock"; do
[[ ! -L "$path" ]] || fail 'refusing a symlink target or lock' 2
[[ ! -e "$path" || -f "$path" ]] || fail 'target and lock must be regular files' 2
done
if ((dry_run)); then
printf 'PREVIEW target=%s\nworkers=%s\n' "$target" "$workers"
exit 0
fi
exec 9>>"$lock" || fail 'cannot open lock file'
if flock -n -E 75 9; then
:
else
rc=$?
fail "cannot acquire lock: rc=$rc" "$rc"
fi
tmp_file=
cleanup() {
local rc=$?
trap - EXIT
if [[ -n "$tmp_file" ]]; then
if rm -f -- "$tmp_file"; then
:
else
printf 'temporary file cleanup failed\n' >&2
((rc != 0)) || rc=1
fi
fi
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
tmp_file=$(mktemp "$directory/.settings.XXXXXXXX") || fail 'cannot create temporary file'
printf 'workers=%s\n' "$workers" >"$tmp_file" || fail 'cannot write temporary file'
chmod 600 -- "$tmp_file" || fail 'cannot set temporary file permissions'
if [[ -e "$target" ]]; then
if cmp -s -- "$tmp_file" "$target"; then
printf 'UNCHANGED\n'
exit 0
else
rc=$?
[[ "$rc" -eq 1 ]] || fail "cannot compare files: rc=$rc"
fi
fi
mv -T -- "$tmp_file" "$target" || fail 'cannot replace configuration'
tmp_file=
printf 'APPLIED workers=%s\n' "$workers"getopts 处理短选项;冒号表示该选项需要参数,最前面的冒号让脚本自行报告缺值与未知选项。处理完选项后,shift 移除已经消费的参数,再拒绝多余的位置参数。-w 08 被明确转换为十进制 8;字母、算术表达式、超长数字和超范围值都会被拒绝。
exec 9>>"$lock" 没有指定要替换当前进程的命令,只在当前 Shell 中打开文件描述符 9;追加方式避免截断已有锁文件。flock ... 9 随后对这个已打开的文件加锁。脚本及其同步子命令结束后,描述符关闭,锁随之释放。成功分支中的独立 : 是返回 0 的空操作,表示继续执行。
脚本使用 set -u 和 pipefail,关键操作显式判断返回值,没有依赖 set -e 自动完成错误处理。cmp 返回 0 表示相同、1 表示不同,其他状态表示比较失败;只有“不同”才进入替换。
临时文件建在目标目录,确保与目标位于同一文件系统。mv -T 用新文件替换指定路径,路径读者看到完整旧文件或完整新文件;已经打开旧文件的进程仍可继续读旧 inode。跨文件系统的移动可能退化为复制,不能据此推导相同的原子性。文件系统改名接口见 rename 手册。
这一步也没有执行 fsync 来承诺断电持久性,没有更新多个文件的事务,更没有让应用自动采用新值。应用何时重新读取配置、读取失败是否保留旧值,需要由应用或服务管理接口定义。
执行正例、重复执行和拒绝路径
保存后,在原练习终端执行:
chmod u+x render-settings.sh
bash -n render-settings.sh
shellcheck render-settings.sh
mkdir -m 700 -- "$LAB_ROOT/state"
./render-settings.sh -n -d "$LAB_ROOT/state" -w 4
find "$LAB_ROOT/state" -mindepth 1 -maxdepth 1 -print
./render-settings.sh -d "$LAB_ROOT/state" -w 4
cat -- "$LAB_ROOT/state/settings.conf"
./render-settings.sh -d "$LAB_ROOT/state" -w 4
./render-settings.sh -d "$LAB_ROOT/state" -w 8语法和静态检查通过时无错误输出。预览显示目标和 workers=4,随后 find 没有输出,说明此时未创建锁或文件。三次真实执行依次输出 APPLIED workers=4、UNCHANGED、APPLIED workers=8。可以用 stat 比较第二次执行前后的 inode 和修改时间;内容相同时不替换目标。
异常输入用条件分支观察,不让终端的 Shell 选项影响实验:
if ./render-settings.sh -d "$LAB_ROOT/state" -w 99; then
printf 'unexpected success\n'
else
printf 'expected argument failure: rc=%s\n' "$?"
fi
flock "$LAB_ROOT/state/.settings.lock" \
./render-settings.sh -d "$LAB_ROOT/state" -w 4
printf 'lock test rc=%s\n' "$?"第一项返回 2,原配置仍为 8。第二项由外层 flock 持锁,再启动相同脚本,脚本无法取得独占锁并返回 75;在未启用 -e 的练习终端,最后一行会打印该状态。不要为了成功而删除 .settings.lock。
目录不可写、磁盘满、权限设置或比较失败时,脚本返回非零。先检查 stderr,再查看原文件和 .settings.* 临时文件;正常 EXIT 清理会移除本次临时文件。若最终替换已经成功而后续输出失败,调用者也可能收到非零,因此重试前仍应查看目标内容。文件级操作与调用方收到的结果之间也可能出现不确定窗口。
同样输入重复执行保持配置不变,这就是此任务的幂等性。锁保证参与者串行进入更新区;两个不同配置并发提交时,最终值由实际取得锁并执行的顺序决定,锁不会判断哪个配置更新、哪个业务版本更合理。
子进程、超时与外部服务调用
启动后台任务后必须收集结果
在命令后加 & 会异步启动;$! 是最近一次后台任务的 PID,启动成功仅表示任务已被创建。下面在独立 Bash 中启动两个短任务,并逐个 wait:
bash <<'BASH'
pids=()
bash -c 'sleep 0.1; exit 0' &
pids+=("$!")
bash -c 'sleep 0.1; exit 3' &
pids+=("$!")
failed=0
for pid in "${pids[@]}"; do
if wait "$pid"; then
printf 'child succeeded\n'
else
rc=$?
printf 'child failed: rc=%s\n' "$rc" >&2
failed=1
fi
done
exit "$failed"
BASH能看到一个成功和一个状态为 3 的失败,整个脚本返回 1。省略 PID 的一次 wait 不适合逐个归属失败原因。批量处理大量节点时,还要限制并发数量,例如每批最多四个,收完这一批结果再启动下一批,避免一次创建几百个连接。
父脚本收到 TERM 后,普通后台子进程不一定自动结束。自己管理子进程时,需要保存直接子进程 PID、发送信号并等待退出;涉及多层进程树、长驻服务和重启策略时,交给 systemd 或容器运行时管理更合适。不要仅凭一个历史 PID 文件直接 kill -9,PID 可能已经被复用。服务托管的完整操作见服务器初始化。
超时限制等待,结果仍需核对
GNU timeout 在到期时发送 TERM,--kill-after 指定再等多久才升级为 KILL:
if timeout --kill-after=1s 1s sleep 10; then
printf 'completed\n'
else
rc=$?
printf 'timeout command rc=%s\n' "$rc"
fi这里应得到 124。常见状态还有 125(timeout 自身失败)、126/127(不能执行或找不到命令)、137(收到 KILL);未超时时通常传播命令自身状态。完整定义见 GNU timeout 的 Ubuntu 手册。
超时只能限制本地等待。远端服务器可能已经接受请求并完成写入,本地进程被终止不会自动撤销远端操作。重试查询通常容易控制;重试创建订单、触发部署或发送短信,则需要幂等键、任务查询或人工确认结果。
curl 成功后还要解析业务结果
对已有测试服务,先将 HEALTH_URL 设置为该服务实际提供的健康接口,例如本机 http://127.0.0.1:8080/actuator/health。该地址要求应用已启动且已开放此接口;没有服务时应得到连接失败,不应期望 Shell 示例自动创建 Spring Boot。
HEALTH_URL=http://127.0.0.1:8080/actuator/health
if response=$(curl -q --noproxy '*' --fail-with-body \
--connect-timeout 2 --max-time 5 --silent --show-error "$HEALTH_URL"); then
if jq -e 'type == "object" and .status == "UP"' <<< "$response" > /dev/null; then
printf 'health is UP\n'
else
printf 'HTTP succeeded but health payload is invalid or not UP\n' >&2
fi
else
rc=$?
printf 'health request failed: curl rc=%s\n' "$rc" >&2
fi-q 放在首个选项,禁用默认 curl 配置文件;--noproxy '*' 明确回环直连;连接超时与总超时分别限制建连和整次请求。--fail-with-body 让 HTTP 400 及以上返回非零并保留响应体,网络失败、HTTP 错误、JSON 解析与健康字段不符因此可以分开定位。参数语义见 curl 官方手册。
企业出口必须经过代理时,应按网络政策配置代理,不能把回环直连选项复制到所有公网任务。示例只打印结果;封装成巡检脚本时,两个失败分支都需要返回非零,让调度器收到失败。真实业务健康还应选择能反映当前服务能力的检查项。
SSH 批量只读检查先准备经过核对的主机别名列表。认证、主机密钥和连接参数沿用 SSH 中的配置;循环读取列表时,远端命令不需要 stdin 就给 ssh 加 -n,否则它可能读走下一行主机名。固定远端命令优先于把本地变量拼成远端代码;超时后同样应核对远端任务是否仍在运行。
部署脚本还需要记录实际旧版本,在新版本启动和业务检查成功后才切换或确认流量;失败后恢复的是已记录的旧状态,而非排序后猜出的“上一个目录”。数据库备份则先选数据库支持的一致性备份方式,再检查备份产物和恢复结果;运行中目录打包成功只说明归档命令完成。跨服务状态、复杂补偿或长期重试逐渐增多时,应使用能持久保存任务状态的程序或自动化平台,Shell 保留明确的单机命令接口。
巡检脚本如何进入定时运行
一个有明确状态码的空间检查
在练习目录保存 check-space.sh。它只读取指定路径所在文件系统的使用率,默认阈值 85%;达到阈值返回 1,参数错误返回 2,采集或解析失败返回 3。输出为一行 JSON,便于调度日志收集:
#!/usr/bin/env bash
set -u
set -o pipefail
export PATH=/usr/bin:/bin
export LC_ALL=C
[[ $# -ge 1 && $# -le 2 ]] || {
printf 'usage: check-space.sh PATH [THRESHOLD_PERCENT]\n' >&2
exit 2
}
path=$1
threshold=${2:-85}
[[ "$threshold" =~ ^[0-9]{1,3}$ ]] || exit 2
threshold=$((10#$threshold))
((threshold >= 0 && threshold <= 100)) || exit 2
if report=$(df -Pk -- "$path"); then
:
else
printf 'cannot collect filesystem usage\n' >&2
exit 3
fi
used=$(awk 'NR == 2 { sub(/%$/, "", $5); print $5 }' <<<"$report") || exit 3
[[ "$used" =~ ^[0-9]+$ ]] || {
printf 'unexpected df output\n' >&2
exit 3
}
status=ok
rc=0
if ((used >= threshold)); then
status=threshold_reached
rc=1
fi
jq -cn --arg path "$path" --arg status "$status" \
--argjson used "$used" --argjson threshold "$threshold" \
'{path: $path, usedPercent: $used, thresholdPercent: $threshold, status: $status}' || exit 3
exit "$rc"LC_ALL=C 固定 df 的输出语言,-P 使用固定的可移植列布局。路径由 -- 与选项分开;输出中的挂载点即使带空格,也不影响前面的使用率列。该使用率是容量指标,inode、磁盘 I/O 和文件系统只读状态仍需各自检查。
chmod u+x check-space.sh
bash -n check-space.sh
shellcheck check-space.sh
./check-space.sh "$LAB_ROOT" 85
if ./check-space.sh "$LAB_ROOT" 0; then
printf 'unexpected success\n'
else
printf 'threshold test rc=%s\n' "$?"
fi正常调用显示本机使用率,低于 85 时状态为 ok、退出 0。阈值 0 的负例必定命中,JSON 为 threshold_reached,退出 1;不必真的填满磁盘。不存在的路径返回 3,提醒调用者这是采集失败,不能记录成“磁盘正常”。
cron 与交互终端的环境不同
先把已检查的脚本安装到当前普通用户的固定位置,避免依赖可能被清理的临时目录:
mkdir -p -- "$HOME/.local/libexec" "$HOME/.local/state/shell-space"
chmod 700 -- "$HOME/.local/state/shell-space"
install -m 700 check-space.sh "$HOME/.local/libexec/check-space.sh"
"$HOME/.local/libexec/check-space.sh" "$HOME" 85确认当前用户目录未接近阈值后,再配置调度。Ubuntu 主机需安装并运行 cron 服务;管理员可执行 sudo apt-get install cron,通过 systemctl is-active cron 确认服务。普通用户用 crontab -e 编辑自己的任务,在已有内容中新增,不覆盖整个 crontab:
SHELL=/bin/sh
PATH=/usr/bin:/bin
*/5 * * * * "$HOME/.local/libexec/check-space.sh" "$HOME" 85 >> "$HOME/.local/state/shell-space/check.log" 2>&1五个时间字段依次是分钟、小时、日、月、星期,这条每五分钟触发一次。用户 crontab 不填写执行用户名;系统 /etc/crontab 和 /etc/cron.d/ 文件通常多一个用户字段,不能直接交换格式。
这里的 SHELL 决定 cron 如何解释整条命令;真正执行可执行脚本时仍按其 Bash shebang 启动。cron 不会自动加载交互终端的别名、当前目录和开发工具环境,脚本应使用固定路径并声明依赖。crontab 命令中的 % 还有专门含义,复杂命令宜放回脚本,规则见 Ubuntu crontab 手册。
用 crontab -l 确认任务存在,经过触发时间后读取 check.log。日志中应出现该次 JSON 或明确错误;无日志先查 cron 服务、用户、目录权限和路径,不要反复复制任务。追加日志需要限制保留量;任务失败后的通知还需接入实际可达的邮件或监控通道。仅有退出码和本地日志不会主动通知值班人员。
systemd timer 可以关联执行状态
在 Ubuntu 的已登录普通用户会话中,可以改用用户级 timer。不要同时启用相同任务的 cron 和 timer。先通过 crontab -e 移除刚才新增的那一行,其余任务保持原样。
准备用户单元目录:
mkdir -p -- "$HOME/.config/systemd/user"保存 ~/.config/systemd/user/shell-space.service:
[Unit]
Description=Filesystem space check
[Service]
Type=oneshot
ExecStart=%h/.local/libexec/check-space.sh %h 85
TimeoutStartSec=20s
UMask=0077
NoNewPrivileges=yes这是当前用户的服务,未使用 root;%h 由用户管理器展开为用户主目录,ExecStart 不通过 Shell 解析。TimeoutStartSec 限制任务运行时间,stdout 和 stderr 默认进入 journal。再保存同目录 shell-space.timer:
[Unit]
Description=Check filesystem space every five minutes
[Timer]
OnCalendar=*:0/5
Persistent=true
AccuracySec=1s
[Install]
WantedBy=timers.target同名 timer 默认触发同名 service。OnCalendar 按日历时间触发,Persistent=true 可在再次启动 timer 时补一次停用期间漏掉的触发,不会逐次补跑所有历史间隔。服务已处于 active 时,timer 不再启动第二个同名单元;用户绕过单元直接执行脚本则属于另一个入口。这里的只读检查无需共享写锁,配置更新任务仍应使用自己的锁。具体行为见 systemd.timer 的 Ubuntu 手册。
检查配置,先手动启动服务,再启用 timer:
systemd-analyze --user verify "$HOME/.config/systemd/user/shell-space.service" \
"$HOME/.config/systemd/user/shell-space.timer"
systemctl --user daemon-reload
systemctl --user start shell-space.service
systemctl --user show shell-space.service -p Result -p ExecMainStatus
journalctl --user -u shell-space.service -n 10 --no-pager
systemctl --user enable --now shell-space.timer
systemctl --user list-timers shell-space.timer空间低于阈值时,手动任务应显示 Result=success、ExecMainStatus=0,日志包含 status: ok 对应的 JSON 字段。oneshot 执行完进入 inactive 是正常状态;timer 列表应显示下一次时间。若系统没有用户管理器而报连接总线失败,先确认登录会话,普通容器里不能仅靠安装 systemd 包获得完整用户服务。
用户管理器通常跟随登录会话生命周期。无人登录也要长期运行时,需要管理员按主机策略配置 linger,或改为有专用运行用户的系统级单元;不能仅凭 enable 成功推断退出登录后仍会触发。停止此练习的后续调度:
systemctl --user disable --now shell-space.timer
systemctl --user stop shell-space.service停 timer 不会自动停止已经启动的 service,所以两项分别处理。保留单元和日志便于回看,不影响其他用户任务。
检查、排错与后续维护
先静态检查,再执行可控负例
ShellCheck 检查引号、变量、管道作用域等常见问题;shfmt 统一缩进和语法排版。对两个完整脚本执行:
for script in render-settings.sh check-space.sh; do
bash -n "$script" || break
done
shellcheck render-settings.sh check-space.sh
shfmt -d -i 2 render-settings.sh check-space.shbash -n 每次检查一个文件;循环中发现语法错误就停止后续语法检查,先修正对应错误。shfmt -d 只显示格式差异,接受格式调整后可使用 shfmt -w -i 2。静态工具无法确认磁盘满、远端超时或业务回退是否正确;执行测试应同时观察退出状态和最终文件、进程或任务状态。对配置更新至少保留正常、重复、预览、非法参数和锁冲突测试;对巡检保留阈值命中与采集失败测试。
按失败位置查问题
| 现象 | 优先检查 | 下一步 |
|---|---|---|
Permission denied,脚本未启动 | 文件执行位、路径目录权限、文件系统 noexec | 区分解释器可读与直接执行权限,不用 root 掩盖原因 |
bad interpreter 或出现 ^M | shebang 路径、CRLF 换行、UTF-8 BOM | 用编辑器保存为 LF、无 BOM;只修改已确认的脚本 |
| 终端成功,cron 失败 | 用户、PATH、工作目录、BASH_ENV、依赖与凭据来源 | 在受控环境用绝对路径复现,查看任务日志 |
| 带空格文件失败或处理数量变化 | 未引用变量、字符串命令、通配符展开 | 打印参数边界,用数组与 "$@" 传递 |
| 前一步失败,最终仍返回 0 | 函数最后一条命令、if 上下文、local 赋值、tee 管道 | 立即保存状态,让必需步骤显式返回失败 |
| 管道返回 141 | 下游是否提前结束,上游是否收到 SIGPIPE | 根据消费任务判断,不统一当成系统故障 |
| 配置更新返回 75 | 是否有其他执行者持有同一锁 | 等待或检查持锁进程,不删除锁文件 |
| 新配置写入后服务仍用旧值 | 服务实际配置路径、重载接口、已打开文件与启动参数 | 先核对运行状态,再选择重载或重启 |
| timer 存在却没有新结果 | 用户管理器、下次时间、service 状态、journal | 核对单次执行和调度生命周期,再检查外部告警接收 |
运行日志和脚本版本应能对应起来,任务参数只记录经过脱敏的必要信息。维护脚本时,先检查调用方是否依赖 stdout 格式或退出码,再调整实现。把错误信息从 stderr 改到 stdout、把 75 改为成功,都会改变自动化调用者的判断。
当一个脚本开始维护复杂数据结构、并发状态、跨系统补偿和大量单元测试时,Python、Java 或专用自动化平台通常更便于组织这些逻辑。Shell 仍适合在进程之间传递参数、连接输入输出并调用已有命令;保持接口清楚,后续实现替换也更容易。
权威资料与规范地址
Bash 与静态检查
- Ubuntu 24.04 Bash 手册:启动、展开、参数、执行环境与内建命令
- ShellCheck:项目说明与使用
- SC2086:引号、单词拆分和路径名展开
- SC2155:声明赋值掩盖退出状态
- SC2030:管道子 Shell 的变量修改
- SC2094:同一管道读写同一个文件
- SC2310:条件函数调用与 errexit
- SC2311:命令替换与 inherit_errexit
- shfmt:Shell 解析器与格式化工具
文件、进程与数据处理
- GNU find 手册:表达式、文件名与执行动作
- util-linux flock 手册:锁模式、描述符与状态
- Linux rename 手册:文件替换语义
- GNU timeout 的 Ubuntu 手册
- jq 官方手册
- curl 官方手册
