让 bash 脚本变得健壮的技术

有关如何写出健壮 Bash Shell 脚本的方法技巧,包括 set -uset -e竟态条件信号描述 等,健康的 bash shell 脚本对 linux 系统管理很重要,感兴趣的朋友参考下。

使用 set -u

对变量初始化而使脚本崩溃过多少次?

1
2
3
chroot=$1
...
rm -rf $chroot/usr/share/doc

如果以上代码没有给参数就运行,不会仅仅删除掉 chroot 中的文档,而是将系统的所有文档都删除。

bash 提供 set -u ,当使用未初始化的变量时,让 bash 自动退出。

也可以使用可读性更强一点的 set -o nounset

1
2
3
4
bash /tmp/shrink-chroot.sh
$chroot=
bash -u /tmp/shrink-chroot.sh
/tmp/shrink-chroot.sh: line 3: $1: unbound variable

使用 set -e

每一个脚本的开始都应该包含 set -e 。这告诉 bash 一但有任何一个语句返回非真的值,则退出 bash 。

使用 -e 的好处是避免错误滚雪球般的变成严重错误,能尽早的捕获错误。更加可读的版本: set -o errexit

使用 -e 把你从检查错误中解放出来。如果你忘记了检查, bash 会替你做这件事。不过你也没有办法使用 $? 来获取命令执行状态了,因为 bash 无法获得任何非 0 的返回值。

可以使用另一种结构:

1
2
command
if [ "$?"-ne 0]; then echo "command failed"; exit 1; fi

可以替换成:

1
command || { echo "command failed"; exit 1; }

或使用:

1
if ! command; then echo "command failed"; exit 1; fi

如果必须使用返回非 0 值的命令,或对返回值并不感兴趣呢?

可以使用 command || true ,或有一段很长的代码,可以暂时关闭错误检查功能,不过建议谨慎使用。

1
2
3
4
set +e
command1
command2
set -e

相关文档指出, bash 默认返回管道中最后一个命令的值,也许是你不想要的那个。比如执行 false | true 将会被认为命令成功执行。如果你想让这样的命令被认为是执行失败,可以使用 set -o pipefail

程序防御 - 考虑意料之外的事

你的脚本也许会被放到 意外 的账户下运行,像缺少文件或者目录没有被创建等情况。你可以做一些预防这些错误事情。比如,当你创建一个目录后,如果父目录不存在, mkdir 命令会返回一个错误。如果你创建目录时给 mkdir 命令加上 -p 选项,它会在创建需要的目录前,把需要的父目录创建出来。另一个例子是 rm 命令。如果你要删除一个不存在的文件,它会“吐槽”并且你的脚本会停止工作。(因为你使用了 -e 选项,对吧?)你可以使用 -f 选项来解决这个问题,在文件不存在时让脚本继续工作。

准备好处理文件名中的空格。

有些人从在文件名或者命令行参数中使用空格,你需要在编写脚本时时刻记得这件事。你需要时刻记得用引号包围变量。

1
if [ $filename = "foo" ];

$filename 变量包含空格时就会挂掉。可以这样解决:

1
if [ "$filename" = "foo" ];

使用 $@ 变量时,你也需要使用引号,因为空格隔开的两个参数会被解释成两个独立的部分。

1
2
3
4
5
6
7
foo() { for i in $@; do echo $i; done }; foo bar "baz quux"
bar
baz
quux
foo() { for i in "$@"; do echo $i; done }; foo bar "baz quux"
bar
baz quux

我没有想到任何不能使用 "$@" 时,所以当你有疑问时,使用引号就没有错误。

如果同时使用 findxargs ,应该使用 -print0 来让字符分割文件名,而不是换行符分割。

1
2
3
4
5
6
touch "foo bar"
find | xargs ls
ls: ./foo: No such file or directory
ls: bar: No such file or directory
find -print0 | xargs -0 ls
./foo bar

设置的陷阱

当脚本挂掉后,文件系统处于未知状态。比如锁文件状态、临时文件状态或者更新了一个文件后在更新下一个文件前挂掉。

无论是删除锁文件,又或在脚本遇到问题时回滚到已知状态。

bash 提供了一种方法,当 bash 接收到一个 UNIX 信号时,运行一个命令或者一个函数。可以使用 trap 命令。

1
trap command signal [signal ...]

你可以链接多个信号(列表可以使用 kill -l 获得),但是为了清理残局,我们只使用其中的三个: INTTERMEXIT 。你可以使用 -as 来让 traps 恢复到初始状态。

信号描述

INTInterrupt - 当有人使用 Ctrl-C 终止脚本时被触发

TERMTerminate - 当有人使用 kill 杀死脚本进程时被触发

EXITExit - 这是一个伪信号,当脚本正常退出或者 set -e 后因为出错而退出时被触发

当使用锁文件时:

1
2
3
4
5
6
7
if [ ! -e $lockfile ]; then
touch $lockfile
critical-section
rm $lockfile
else
echo "critical-section is already running"
fi

当最重要的部分(critical-section)正在运行时,如果杀死了脚本进程,会发生什么呢?

锁文件会被扔在那,而且你的脚本在它被删除以前再也不会运行了。解决方法:

1
2
3
4
5
6
7
8
9
if [ ! -e $lockfile ]; then
trap " rm -f $lockfile; exit" INT TERM EXIT
touch $lockfile
critical-section
rm $lockfile
trap - INT TERM EXIT
else
echo "critical-section is already running"
fi

现在当你杀死进程时,锁文件一同被删除。注意在 trap 命令中明确地退出了脚本,否则脚本会继续执行 trap 后面的命令。

竟态条件 ( wikipedia )

在上面锁文件的例子中,有一个竟态条件是不得不指出的,它存在于判断锁文件和创建锁文件之间。一个可行的解决方法是使用 IO重定向 和 bash 的 noclobber(wikipedia) 模式,重定向到不存在的文件。我们可以这么做:

1
2
3
4
5
6
7
8
9
10
if ( set -o noclobber; echo "$" > "$lockfile") 2> /dev/null;
then
trap 'rm -f "$lockfile"; exit $?' INT TERM EXIT
critical-section
rm -f "$lockfile"
trap - INT TERM EXIT
else
echo "Failed to acquire lockfile: $lockfile"
echo "held by $(cat $lockfile)"
fi

更复杂一点儿的问题是你要更新一大堆文件,当它们更新过程中出现问题时,你是否能让脚本挂得更加优雅一些。你想确认那些正确更新了,哪些根本没有变化。比如你需要一个添加用户的脚本。

1
2
3
add_to_passwd $user
cp -a /etc/skel /home/$user
chown $user /home/$user -R

当磁盘空间不足或者进程中途被杀死,这个脚本就会出现问题。
在这种情况下,你也许希望用户账户不存在,而且他的文件也应该被删除。

1
2
3
4
5
6
7
8
9
10
11
12
rollback() {
del_from_passwd $user
if [ -e /home/$user ]; then
rm -rf /home/$user
fi
exit
}
trap rollback INT TERM EXIT
add_to_passwd $user
cp -a /etc/skel /home/$user
chown $user /home/$user -R
trap - INT TERM EXIT

在脚本最后需要使用 trap 关闭 rollback 调用,否则当脚本正常退出时 rollback 将会被调用,那么脚本等于什么都没做。

保持原子化

又是你需要一次更新目录中的一大堆文件,比如你需要将URL重写到另一个网站的域名。你也许会写:

1
2
3
for file in $(find /var/www -type f -name "*.html"); do
perl -pi -e 's/www.example.net/www.example.com/' $file
done

如果修改到一半是脚本出现问题,一部分使用 www.example.com ,而另一部分使用 www.example.net 。你可以使用备份和 trap 解决,但在升级过程中你的网站 URL 是不一致的。

解决方法是将这个改变做成一个原子操作。先对数据做一个副本,在副本中更新 URL ,再用副本替换掉现在工作的版本。你需要确认副本和工作版本目录在同一个磁盘分区上,这样你就可以利用 Linux 系统的优势,它移动目录仅仅是更新目录指向的 inode 节点。

1
2
3
4
5
6
cp -a /var/www /var/www-tmp
for file in $(find /var/www-tmp -type -f -name "*.html"); do
perl -pi -e 's/www.example.net/www.example.com/' $file
done
mv /var/www /var/www-old
mv /var/www-tmp /var/www

如果更新过程出问题,线上系统不会受影响。线上系统受影响的时间降低为两次 mv 操作的时间,这个时间非常短,因为文件系统仅更新 inode 而不用真正的复制所有的数据。

缺点:需要两倍的磁盘空间,而且那些长时间打开文件的进程需要比较长的时间才能升级到新文件版本,建议更新完成后重新启动这些进程。

对于 apache 服务器来说这不是问题,因为它每次都重新打开文件。

可以使用 lsof 命令查看当前正打开的文件。

做点:有了一个先前的备份,当你需要还原时,它就派上用场了。