编写健壮的Bash shell脚本

时间:2021-01-01 08:48:37

许多人都能很快的码出一些shell代码来完成简单的任务,而且这种写法将会一直持续下去。问题是编写的shell脚本经常会包含着许多足以导致脚本运行失败的细小的缺陷(subtle effects)。本文中我就将解释编写一个健壮的Bash脚本所需要的一些技术,告诉你是能做到将这些问题减少到最小的。

使用set -u

你是否会经常遇到因为变量没有赋值而导致脚本无法成功运行的情况呢?反正我是经常的遇到。

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

如果你在运行上述脚本的时候忘记了提供一个参数的话,最后的结果是你会把所有系统文档都删掉,而不是仅仅删除$chroot下指定的文档。那你该怎么办呢?还好,Bash提供了一个选项set -u,使用这个选项可以使脚本在使用未初始化的变量时直接退出。这个选项的另一个可读性更强点的写法是set -o nounset。

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

 

使用set -e

你应该在你编写的每个脚本上方都加上set -e选项,打开这个选项之后,脚本在运行时碰到返回值不为0的语句之后会直接退出。使用-e选项的好处是使你能及早的发现问题,而不是让错误越滚越大。同样这个选项也有另外一种可读性更强点的写法set -o errexit。

-e选项可以为你做免费的错误检查,如果你忘了检查的话,它会替你完成。不过不好的是你无法在使用$?来进行检查了,因为如果返回值不为0的话语句根本就执行不到检查$?的那一步的。解决方法是重写下代码:

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

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

替代

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

如果你有一个命令它就是返回0或者是你根本就不关心返回值那怎么办呢?你可以使用command || true,或者假如你要对很长的一段代码都如此处理的话,你可以暂时关闭错误处理,不过我的建议是尽量少用。

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

另外有个和这个有点相关的说明:默认情况下Bash返回最后一个管道命令的执行结果,你可能不希望是这样,例如false | true执行返回值为是0,成功;如果你想它是失败的话,执行set -o pipefail就可以了。

防御型编程 – 未雨绸缪

你的脚本应该要考虑到应对一些比如文件不存在或者无法创建目录之类的异常情况,采取一些措施避免在碰到这些情况时发生错误。比如说如果在创建一个目录时,如果上级目录不存在,mkdir就会返回错误,如果你在使用mkdir是加上-p选项的话就能一并创建所有不存在的上级目录了。另一个例子就是rm,如果你要rm一个不存在的文件,那rm就会报错,脚本也会退出(应该在用-e选项了吧,对不?)你可以通过加上-f参数来解决这个,这个参数会保证在文件不存在时安静的执行下一步。

注意文件名中的空格

有人总喜欢在文件名或者是命令行参数中使用空格,所以你在写脚本时一定要注意这点,特别注意要在变量上加上引号。

1 if [ $filename = "foo" ];

上述代码在$filename中包含空格时会失败,可以这样修正:

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

在使用$@变量时,总要记得使用双引号,这样才能保证当参数值中存在空格时不会被解析成单独的词。

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

我想不出有什么时候是需要你用$@代替”$@”的,所以当你存疑时,就加上双引号吧。

设置trap(Setting traps)

脚本在运行之中意外的退出经常会让文件系统处于一种不一致的状态,就像锁文件(lock file)、临时文件或者是你更新了一个文件却在更新另外一个文件的时候发生了错误,如果我们能有什么方法能解决这个问题好了,使得我们的程序运行出现问题的时候能够删除掉锁文件或是回滚到一个已知的正常的状态。好在Bash提供了trap命令,这个命令能让运行中的脚本接受到unix信号的时候执行指定的命令或是函数。

1 trap command signal [signal ...]

trap命令能捕获很多的unix信号(可以通过kill -l来得到unix信号的清单),不过只为了清理的话只需要关注3个信号量就行了,这3个就是:INT, TERM和EXIT。你也可以使用-作为trap命令中的command参数重置trap的状态。

信号量 描述
INT 中断 – 使用CTRL+C组合键时会向脚本发送这个信号
TERM 终止 – kill命令使用这个信号来终止进程
EXIT 退出 – 这是个伪信号量(pseudo-signal),在脚本运行结束或者是使用exit命令退出脚本时都会被触发。

通常你可能会写出如下使用锁文件的脚本:

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

当脚本还在critical-section阶段运行时被别人杀掉会发生什么呢?锁文件会一直留在那里,而你的程序也无法再运行了,解决方法就是

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

现在即使脚本运行中被杀掉,锁文件也能被正常的删除。注意我们要在trap命令执行之后使用exit退出,要不然程序将会从接收到信号量的那个地方恢复执行。

竞争条件
这里要指出来一下上面那个例子由于创建锁文件和检查锁文件的时间不一样会存在小的竞争条件的问题,一个可行的解决方案就是使用IO重定向加上使用Bash的noclobber模式,noclobber模式不允许重定向到一个存在的文件上,代码如下所示:
1 if ( set -o noclobber; echo "$$" > "$lockfile") 2> /dev/null;
2 then
3    trap 'rm -f "$lockfile"; exit $?' INT TERM EXIT
4   
5    critical-section
6   
7    rm -f "$lockfile"
8    trap - INT TERM EXIT
9 else
10    echo "Failed to acquire lockfile: $lockfile."
11    echo "Held by $(cat $lockfile)"
12 fi

另一个更复杂点的问题就是当需要一次更新多个文件的时候,如果程序中途退出,你得让它退出的更加优雅些,就是要做到要改变的东西被正确的改变,或是做到像什么都没有发生一样。假设你用下面的脚本增加用户:

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

当磁盘空间不足或是进程中途被杀的话就会有问题了,这种情况下你可能就希望这个用户以及相应的文件都清理掉:

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

这里必须在脚本的最后重置trap状态为默认值,要不然在脚本退出的时候rollback函数也会被执行的,然后所有的辛苦工作都白费了。

保持原子性(Be atomic)

有时你需要更新一个文件夹下面的多个文件,比如说你需要将网站的url从一个主机重写为另一个主机,你可能会写下如下脚本:

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

现在如果脚本运行途中出现问题退出的话,很可能就会造成网站的一部分已经换成了www.example.com而另一部分还是指向www.example.net,你可以通过恢复备份或者是使用trap来修复这个问题,但是在替换的过程中还是会有不一致的问题的。

解决方法就是将整个更改当成一个(接近于)原子操作来执行。就是先将数据备份一份,然后在备份文件上面做变更,变更完以后接着将原文件移走,用变更后的文件替换到原来位置上。在此过程中要确保新旧文件都存在于同一个分区上,这样就可以利用unix文件系统的快速移动文件夹的特性,因为这样需要更改的只是目录的inode。

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

这样做就可以保证一旦更改出现问题,当前运行的系统不会受到影响,同时受影响的时间也就是两个mv操作所花费的时间了,这个通常是非常快的,因为只需要更改目录的inode,不需要移动任何的文件。

这种做法的缺点一个是你需要两倍的磁盘空间,再一个是如果有的进程需要一直打开文件的话那么变换目录之后这些进程打开的还是旧文件,而非新文件,在这种情况下你就需要重启这些进程了。如果使用apache的话这不会有问题,因为它在每次请求的时候都会重新打开文件,你也可以使用lsof命令来检查那些文件正在被打开。好处就是你现在有一个变更前系统文件的备份了,这样一旦你后悔了还有回来的机会。