外观
Shell 编程规范
运维脚本出事,几乎都出在三个地方:变量没引号、错误没检查、变量为空导致路径变成 /。这一节给一套可以直接套用的骨架。
一、脚本头
bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'逐条解释:
| 设置 | 作用 |
|---|---|
set -e | 任何命令返回非 0 就退出 |
set -u | 用未定义变量时报错退出 |
set -o pipefail | 管道中任一命令失败,整个管道失败 |
IFS=$'\n\t' | 分词只按换行和 tab,避免空格拆坏文件名 |
pipefail 为什么重要:
bash
# 没有 pipefail:grep 失败但 wc 成功,整体返回 0
cat app.log | grep ERROR | wc -l
echo $? # 0
# 有 pipefail:grep 失败时整体失败set -e 有三个不生效的场景,必须知道:
bash
# 1. 命令在 if / while / until 的条件里
if grep -q foo file; then ...; fi
# 2. 命令在 && / || 链里(除最后一个)
cmd1 && cmd2 # cmd1 失败不会退出
# 3. 命令前有 !
! grep -q foo file反过来,这些场景失败也不会让脚本退出——需要显式检查。
set -e 在管道和子 shell 里的行为
set -e 不会传递到子 shell 内部,除非子 shell 也 set -e。(cd /tmp && rm -rf x) 这类写法要小心。
二、变量与引号
bash
NAME="world"
echo "hello, $NAME"
echo 'hello, $NAME' # 单引号不展开
echo "hello, ${NAME}" # 推荐加花括号,避免歧义所有变量都用双引号包起来,除非你明确要分词:
bash
# 错:路径有空格就炸
rm -rf $DIR/
# 对
rm -rf "${DIR:?}"/${DIR:?} 是"变量为空就报错退出",比 set -u 更精确(set -u 只管"未定义",不管"定义为空字符串")。
常用扩展:
| 写法 | 含义 |
|---|---|
${VAR:-default} | 未设置或为空时用默认值 |
${VAR:=default} | 未设置或为空时赋值并返回 |
${VAR:?msg} | 未设置或为空时报错退出 |
${VAR#prefix} | 去掉最短前缀 |
${VAR##*/} | 取文件名(去掉最长前缀到 /) |
${VAR%%.*} | 去掉最长后缀(从第一个 .) |
${VAR//a/b} | 全部替换 |
${#VAR} | 长度 |
bash
DIR="${1:-/data/app}"
FILE="/opt/app/config/app.yml"
echo "${FILE##*/}" # app.yml
echo "${FILE%%.*}" # /opt/app/config/app
echo "${FILE%/*}" # /opt/app/config三、数组
bash
HOSTS=(10.0.0.31 10.0.0.32 10.0.0.33)
echo "${HOSTS[0]}"
echo "${#HOSTS[@]}" # 长度
echo "${HOSTS[@]}" # 所有元素(加引号)
for h in "${HOSTS[@]}"; do
echo "checking $h"
done"${HOSTS[@]}" 的引号必须有。${HOSTS[@]} 不加引号会在元素含空格时被拆开。
追加:
bash
HOSTS+=(10.0.0.34)关联数组(bash 4+):
bash
declare -A MAP
MAP[host1]=10.0.0.31
MAP[host2]=10.0.0.32
echo "${MAP[host1]}"
for k in "${!MAP[@]}"; do echo "$k=${MAP[$k]}"; done四、条件判断
bash
# 用 [[ ]],不要用 [ ]
if [[ -f /etc/nginx/nginx.conf ]]; then
echo exists
fi| 测试 | 含义 |
|---|---|
-f / -d / -e | 文件 / 目录 / 存在 |
-r / -w / -x | 可读 / 可写 / 可执行 |
-s | 文件非空 |
-z / -n | 字符串为空 / 非空 |
== / != | 字符串比较 |
=~ | 正则匹配([[ ]] 内) |
-eq -ne -lt -gt | 数值比较 |
&& / || | 与 / 或 |
bash
if [[ "$STATUS" == "ok" && "$COUNT" -gt 0 ]]; then
echo fine
fi
if [[ "$LINE" =~ ^ERROR[[:space:]]+([0-9]+) ]]; then
echo "code=${BASH_REMATCH[1]}"
fi[ ] 和 [[ ]] 的区别
[ "$a" == "$b" ]在某些 shell 里==不合法,要用=。[ $a -gt 1 ]里$a为空会报integer expression expected,必须加引号或用[[ ]]。[[ ]]是 bash 关键字,支持&&、=~、模式匹配,不会分词,所以更安全。
脚本统一用 [[ ]]。
五、函数
bash
log() {
local level="$1"; shift
printf '%s [%s] %s\n' "$(date '+%F %T')" "$level" "$*" >&2
}
die() {
log ERROR "$*"
exit 1
}
check_service() {
local name="$1"
systemctl is-active --quiet "$name" || die "$name is not running"
}
check_service nginx规范:
- 局部变量用
local,避免污染全局。 - 用
return N返回状态码,用echo返回数据(不要混用)。 - 参数用
"$1",多参数用shift逐个取。
bash
# 返回值 vs 输出
get_port() {
echo 8080 # 输出:给调用方用 $() 接
}
PORT=$(get_port)六、错误处理与清理
bash
TMPDIR=$(mktemp -d /tmp/deploy.XXXXXX)
cleanup() {
local code=$?
rm -rf "$TMPDIR"
exit $code
}
trap cleanup EXIT INT TERM
# 主体
cp app.jar "$TMPDIR/"trap ... EXIT 在脚本任何方式退出时都会执行(正常、exit N、set -e 触发、收到 INT/TERM)。local code=$? 保住原始退出码,否则 exit 会改成 0。
七、参数解析
bash
MODE="daily"
DEPLOY=false
while [[ $# -gt 0 ]]; do
case "$1" in
-m|--mode) MODE="$2"; shift 2 ;;
-d|--deploy) DEPLOY=true; shift ;;
-h|--help)
echo "usage: $0 [-m daily|weekly] [-d]"
exit 0 ;;
*) die "unknown option: $1" ;;
esac
done比 getopts 更直观,支持长选项。注意 shift 2 和 shift 的区别。
八、完整骨架
bash
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG_FILE="${LOG_FILE:-/var/log/app-deploy.log}"
log() {
local level="$1"; shift
printf '%s [%s] %s\n' "$(date '+%F %T')" "$level" "$*" | tee -a "$LOG_FILE" >&2
}
die() {
log ERROR "$*"
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "missing command: $1"
}
main() {
local src="${1:?usage: $0 <jar>}"
[[ -f "$src" ]] || die "file not found: $src"
require_cmd java
require_cmd systemctl
log INFO "deploying $src"
install -m 0644 "$src" /opt/app/app.jar
systemctl restart app
systemctl is-active --quiet app || die "app failed to start"
log INFO "done"
}
main "$@"几个细节:
readonly防止后续被改。${1:?}缺参数时自动打印用法。command -v检查命令存在(比which可靠)。install -m一步完成复制 + 设权限。main "$@"把参数完整传进去。
九、静态检查
bash
shellcheck deploy.shtext
In deploy.sh line 12:
rm -rf $DIR/
^-- SC2086 (info): Double quote to prevent globbing and word splitting.shellcheck 能查出引号、未定义变量、不可达代码、[ ] 误用等几百类问题。提交前跑一遍。
bash
# 检查所有脚本
find . -name '*.sh' -exec shellcheck {} +十、踩过的坑
| 现象 | 根因 | 处置 |
|---|---|---|
| 脚本删错目录 | 变量为空 + 没引号 | "${DIR:?}" |
| 某个命令失败但脚本继续 | 没有 set -e 或在 && 链里 | 显式检查 || exit 1 |
| 管道失败没被发现 | 没有 pipefail | set -o pipefail |
| 文件名带空格被拆开 | 没加引号 / IFS 不对 | 加引号,IFS=$'\n\t' |
| 函数里改了全局变量 | 没用 local | 加 local |
trap 里 $? 总是 0 | 先执行了别的命令 | 第一行 local code=$? |
[[ $a -gt 1 ]] 报错 | $a 非数字 | 先校验,用 =~ ^[0-9]+$ |
| 脚本在 cron 里失败 | PATH 不同 | 绝对路径 + 显式设 PATH |
cd 失败但继续执行 | cd 在 && 链里 | cd "$DIR" || die |