写过很多 Bash 脚本的人都知道,Bash 的坑不是一般的多^。所以大家都认可应该用 Python 而不是 Bash 的一个理由就是 Bash 脚本不可靠,不仅运行不稳定,而且还很难维护。关于 Bash 脚本很难维护,在《关于 Bash 的 10 个常见误解》里面有提到,其实是很多人对于 Bash 的语法就根本不熟悉。连最常用的 if 表达式的语法都不甚了解,更何谈什么进程替代啊,或者来做Bash 脚本的单元测试呢?没有人会认为使用一个自己不熟悉的语言来做开发维护工作是一件容易的事情。

不过呢,就算是我们对 Bash 语法不熟悉,但我们只是来实现一个简单的功能。Bash 脚本也没写几行,甚至在命令行都一条一条的验证过,放到 Bash 脚本里面运行起来就是会莫名其妙的出错。有些在服务器上的出错根本没法儿在本地重现,本地测试的好好的,刚部署上去也好好的,一到晚上或者假期就是各种出错。所以,有人说这是因为 Bash 这个语言其实不严禁,导致其运行不可靠,不稳定,所以得用 Python。但同样的功能用 Python 实现起来的代码量比 Bash 多,所以只是为了个简单的功能,还是直接写到 Bash 脚本里面吧,在 Python 脚本里面用 os.command 把很多命令一个一个包装起来也是挺烦人的。

从一个简单的例子开始

下面我们就以一个简单的例子来看一下为什么 Bash 脚本会如此不可靠。用 Bash 脚本为 GNU/Linux 实现一个很常见的、也很简单的功能,重启 Tomcat 服务。

Tomcat 是 Java 开发里面很常见的一个服务器应用,安装和使用都很简单。只要系统上已经安装了对应的 JDK,配置好 JAVA_HOME 环境变量,把 Tomcat 下载回来后解压就可以使用了。在 Tomcat 的安装目录里面执行 ./bin/startup.sh 就可以启动 Tomcat 服务,运行 ./bin/shutdown.sh 就可以停止 Tomcat 服务。

如果你读到这里,请稍微暂停来思考一下:如果由你来实现「重启 Tomcat」这个脚本,需要考虑哪些情况呢?

重启 Tomcat 服务,无非就是先「停止」再「启动」就可以了。我们在命令行的 Tomcat 目录下执行了 ./bin/startup.sh 后,打开浏览器验证了 Tomcat 已经启动。然后再执行 ./bin/shutdown.sh,再次打开浏览器发现无法访问了。所以要重启 Tomcat 就是先后执行这两个命令就可以了。

但是有一个要考虑的,如果 Tomcat 已经停止了,再次执行命令来停止 Tomcat 会不会出错呢?我们在命令行下测试了反复执行 ./bin/shutdown.sh 之后,发现除了打印出一个无关紧要的可以被忽略的出错信息后,执行过程并不会出错。

看起来实现重启 Tomcat 这个功能的 Bash 脚本无非就是要做如下几步:1、切换到 Tomcat 的安装目录;2、停止 Tomcat;3、启动 Tomcat。

#!/bin/bash
export JAVA_HOME=/opt/jdk1.8.0_262
export PATH="$JAVA_HOME/bin:$PATH"
cd /home/chaifeng/apache-tomcat-8.5.57
./bin/shutdown.sh
./bin/startup.sh

为了防止 Bash 脚本没有获取到 JAVA_HOME 这个环境变量的设置,我们在脚本里面还额外设置了一下,这又能避免一些奇怪的场景下无法获得这个变量的问题。看起来这个脚本很完美了,在本地也执行了一下,确实如我们期望的 Tomcat 已经重启了。用 ps 命令也确认了重启前后的 java 进程 ID 也发生了变化。

但是当我们把这个脚本部署到服务器上以后,情况却不一样了,大部分时候这个脚本都无法重启 Tomcat。明明 Tomcat 已经运行,但是服务端口处于无法访问的状态,甚至还会出现好几个 Tomcat 进程。难道是 ./bin/shutdown.sh 不起作用吗?当我们登录到服务器上,用 kill -9 杀掉这些 java 进程后,手动执行命令启动 Tomcat,验证没有问题。然后再手动运行命令停止 Tomcat,发现也没问题。更诡异的是,不管我们在命令行反复测试多少次,这两个启动和停止命令都正常工作。

明明在命令行下测试过都正常工作的命令,只有放到 Bash 脚本里面执行才有问题。看来 Bash 脚本的执行确实有时候不知何故的会出问题。难道真的是 Bash 是个不严禁的语言,运行不稳定吗?

为什么连这个简单的 Bash 脚本都运行不稳定?

为什么在命令行下一条一条的执行 Bash 脚本中的命令都没有问题,但是放到一个脚本中执行却会出问题呢?这两种方式下的命令都一样,除了用 Bash 语言不严禁这个理由来解释外,好像也没什么其他原因了。

实际上,有一个很重要的因素很容易被我们所忽略,就是:两条命令执行之间的时间差

在命令行上手工执行 Tomcat 的停止命令 ./bin/shutdown.sh 后,到输入了启动命令 ./bin/startup.sh 准备按下回车键执行之间,已经过去了好几秒。即使我们是快速复用命令行的历史命令,也要耗费2、3秒。而放到一个脚本里面执行,前一个命令执行结束到下一个命令开始运行,中间大概只有几个毫秒的时间差。几秒与几毫秒的时间差的区别就是这个简单的重启 Tomcat 脚本运行不稳定的根本原因。

当执行了 ./bin/shutdown.sh 来停止 Tomcat,实际上只是给 Tomcat 服务器发送了一个「停止」的信号,然后命令就退出。但此时,Tomcat 收到了「停止」信号后开始运行停止前的清理任务,包括但不限于等待尚未结束运行的客户端连接、释放数据库资源、网络资源、清理临时文件等。所以 Tomcat 还在运行,并没有立刻停止。这个停止运行前的清理工作的耗费时间依据我们的项目不同可能会差别很大,从零点几秒到几十秒,甚至几分钟都有可能。所以,这里是第一个关键的地方,./bin/shutdown.sh 命令执行结束后,Tomcat 可能还在后台进行清理工作,而没有停止运行!

等我们输入了 ./bin/startup.sh 后,时间已经过去了几秒,这个时间差已经足够让 Tomcat 在大多数的情况下真正停止运行了。所以 Tomcat 就又正常启动了。

放在脚本中运行,由于两条命令之间的时间差可能只有几毫秒,这么短的时间不足以让 Tomcat 停止运行。当我们在本地测试运行这个 Bash 脚本时,停止命令和启动命令都已经先后结束运行后。此时在后台,前一个老 Tomcat 进程还尚未结束运行,一个新的 Tomcat 进程就已经启动。但这个新的 Tomcat 可能还需要几秒钟来初始化资源,才能够开始绑定服务端口,这又为老 Tomcat 进程的停止争取到宝贵的几秒时间。当这个新的 Tomcat 结束了资源初始化后开始绑定服务端口的时候,老 Tomcat 可能已经释放了这个服务端口并停止运行。所以在本地测试时,这个重启 Tomcat 的 Bash 脚本表现的非常完美。

当部署到服务器上以后,Tomcat 服务可能会因为资源使用很多而需要更久的时间来释放资源,仅仅几秒是不够的。这就使得当一个新的 Tomcat 结束了资源初始化后,由于老的 Tomcat 服务还没有结束资源释放且没有释放服务端口,导致新的 Tomcat 会因为服务端口被占用而启动失败。

这就是为什么这么简单的一个重启 Tomcat 的 Bash 脚本会出现运行不稳定的原因。而且也不会因为我们用 Python 重写这个 Bash 脚本就可以解决这个问题。

让这个简单的例子变的稳定

既然找到了原因,那就好说了,启动 Tomcat 之前先把现有的 Tomcat 进程杀掉。因为 Tomcat 本身是一个 Java 应用,所以也就是杀掉 java 进程。

现在的问题是「如何杀掉进程」。有人会说这很简单啊,用 kill -9 就可以杀掉进程。没错,这样的确可以。而且如果大家去搜索「Linux 杀掉进程」,很多的搜索结果都提到要用 kill -9 来杀掉进程。为什么要使用选项 -9 呢?有人会说,因为有时候直接用 kill 命令杀不掉进程,反复执行也杀不掉,用了选项 -9 之后就可以了。

事实上,kill -9是对系统危害最大的命令之一。也有人会说,我一直用 kill -9 来杀进程,也从来没破坏过系统啊。这是典型的幸存者偏差,就跟开车不系安全带、骑车不带头盔一样,确实有很多人不系安全带不带头盔,也没见过出事儿。但是只要发生一次,那就是严重伤害。

直接用 kill 命令去杀一个进程,默认是给进程发送了 SIGTERM 信号,当进程收到这个终止信号后,可能会开始终止前的清理工作。当清理工作结束后,该进程就会自行终止,如果不需要清理就直接结束了。如果清理时间稍微长一些,就会让我们产生 kill 命令不工作的错觉。但我们可能只要稍微多等待几秒,进程就会结束了。而使用 kill -9 则类似于一枪爆头,没有给进程在自杀前善后工作的机会。如果进程打开了很大的文件,或者正在写入数据库。暴力终止进程的执行是很有可能会导致文件或者数据库的损坏。

作为一名称职的司机,我们一定会系安全带的。作为一名称职的运维工程师,一定不会滥用 kill -9 这个命令的。现在抬头看看你的周围,有多少不称职的运维工程师呢?

既然在执行了停止 Tomcat 的命令后,不能立即强行终止 java 进程,那就需要等待。很显然,我们不能预计要等待多久。如果固定的等待一个比较长的时间,而 Tomcat 早已经停止,这就会让别人觉得这个 Bash 脚本的性能太差了。现在我们修改一下这个重启 Tomcat 的 Bash 脚本:

#!/bin/bash
export JAVA_HOME=/opt/jdk1.8.0_262
export PATH="$JAVA_HOME/bin:$PATH"
cd /home/chaifeng/apache-tomcat-8.5.57
./bin/shutdown.sh
for ((i=0;i<60;i++)); do
    pgrep java || break
    sleep 1
done
pgrep java | xargs --no-run-if-empty kill -9
./bin/startup.sh

新增了一个等待循环,每秒钟都检查一下 java 进程,如果没找到就终止循环,最多等待 60 秒。退出循环后,再次查找 java 进程,这里用了 GNU xargs 的选项 -—no-run-if-empty 可以避免 xargs 没有从管道接受了数据而去执行 kill -9 命令,导致无谓的出错。

到这里,这个简单的重启 Tomcat 的 Bash 脚本看上去没啥问题了,不管是本地还是服务器上都可以正常的稳定的工作了。而且我们应该也要注意到,讨论的这个问题不管我们用 Python 还是 Bash 还是其他什么语言来实现这个功能,都同样是需要考虑的。要承认,这个造成 Bash 脚本运行不稳定的原因与 Bash 一点儿关系都没有。

这个简单的例子还没有结束

看到这里,或许有人会疑惑还需要考虑什么呢?停止 Tomcat 后,如果在 60 秒内 Tomcat 还没有停止就会强制停止,然后再启动 Tomcat。完美,没什么要考虑的了。

如果在系统上除了 Tomcat 这个 java 进程外,还有其他的 Java 进程,我们怎样确保这个重启脚本不会杀掉其他无关的 Java 进程呢?

如果 Tomcat 是以某个特定用户来运行,如何阻止其他用户无意中执行这个脚本呢?

如果系统上运行了多个 Tomcat 实例,如果确保这个重启脚本只会重启特定的 Tomcat 而不是其他的 Tomcat 服务呢?

如何能够让不同的 Tomcat 复用同一个重启脚本,而不用是为每个 Tomcat 的安装实例复制多份脚本呢?

如何让这个脚本自动判断 Tomcat 版本并选择对应的 JDK?以避免使用错误的 JDK 来启动 Tomcat 呢?

如何让这个重启脚本自动判断当前目录是否位于一个 Tomcat 安装目录中,运行后重启的就是这个 Tomcat,这样就避免了每次使用可能需要传入 Tomcat 目录作为参数了。万一输入了错误的目录呢?

如何避免使用特权用户执行这个脚本来重启 Tomcat?否则不仅会引入额外的安全风险,也会让因一些文件所有者发生变化而导致非特权用户以后无法再管理 Tomcat。

如何让每次重新开机后可以自动执行这个脚本来重启 Tomcat?如果要做开机自动启动脚本,是不是还要考虑 System V 还是 Systemd ?

作为开机的自动启动脚本时,如何自动判断 Tomcat 的所属用户,并自动切换到该用户的权限下执行?这样可以避免无谓的启动配置。

如果 Tomcat 上部署的服务依赖其他的服务,比如 MySQL,如何让这个脚本自动判断依赖的服务已经运行?避免 Tomcat 启动后服务运行后失败

如何让重启脚本判断 Tomcat 重启后服务已经成功运行,否则将以某种方式发出通知?这样就避免了重启不成功,但是我们不知道。

如何避免这个脚本互斥运行?以防止这个脚本在不同的终端上分别同时运行而导致的运行冲突。

如何避免远程登录到一个服务器上运行了脚本,并确认 Tomcat 运行正常,结果退出登录后,Tomcat 服务也会终止运行的问题。

等等……等等……

为了写出一个稳定、可靠、易用的 Tomcat 重启脚本,我们有很多要考虑的事情。列出了这么多可能需要考虑的问题,有哪个问题是和 Bash 有关的?很显然没有。如果我们用 Python 去开发这样一个功能,是否也需要考虑这些问题呢?答案是肯定的。

结论,Bash 脚本不稳定的原因其实就是我们自己。用 Python 重写 Bash 脚本后,原先该有的问题还是存在,只是我们不能再怪罪是 Python 这个语言不严谨,Python 运行不稳定了。只能乖乖从我们自身去寻找问题,然后慢慢得以解决。

如果一开始我们就能够清楚的意识到我们对于一些基础技能的欠缺,就可以提前避免很多不该有的问题。比如对 Bash 的语法不熟悉,也存在很多的误解,那我们怎么能写好 Bash?缺乏对于操作系统的一些功能理解,比如滥用 SIGKILL 信号,不明白什么时候该忽略 SIGHUP 信号等等,那我们怎么能管理好系统?其次,我们缺乏一个系统的运维知识体系,认为运维工作就是去使用一个又一个的工具,不理解这些工具在运维知识体系中的位置。那我们如何能够有效的利用这些不同工具的组合来解决问题呢?再者,我们缺乏从全局系统角度去思考问题,解决了一个又冒出另一个。或者就没明白这些不同的问题之间可能存在一个共同的根因,总是就问题解决问题,那我们要浪费多少时间和人力才可以维护好一个系统?最后,做好运维工作,提升运维技能不只是运维工程师才需要掌握的。对于开发工程师也有提升自己工作效率,更有效的与运维团队协作和沟通,无论是对自己、对团队、对项目都有好处。

要想了解运维工作的知识体系以及技术脉络请看DevOps 技术栈。再看看你是否也有关于 Bash 的 10 个常见误解?要想写出可靠稳定的 Bash 脚本,那一定要写Bash 脚本的单元测试

为什么要为 Bash 脚本写单元测试?

因为 Bash 脚本通常都是在执行一些与操作系统有关的操作,可能会对运行环境造成一些不可逆的操作,比如修改或者删除文件、升级系统中的软件包等。所以为了确保 Bash 脚本的安全可靠,在生产环境中部署之前一定需要做好足够的测试以确保其行为符合我们的预期。

如何能够安全可靠的去测试 Bash 脚本呢?有人可能会说我们可以用 Docker 容器。是的,这样做即安全又方便。在容器隔离出来的环境中不用担心脚本会破坏我们的系统,而且也能非常简单的快速重建出一个可用的测试环境。

不过呢,请考虑以下的几个常见的场景:

场景一:在执行 Bash 脚本测试前,我们需要需要事先安装好所有在 Bash 脚本中会用到的第三方工具,否则这些测试将会因为命令找不到而执行失败。例如,我们在脚本中使用了 Bazel 这个构建工具。我们必须提前安装并配置好 Bazel,而且不要忘记为了能够正常使用 Bazel 还得需要一个支持使用 Bazel 构建的工程。

场景二:测试结果的稳定性可能取决于脚本中访问的第三方服务的稳定性。比如,我们在脚本中使用 curl 命令从一个网络服务中获取数据,但这个服务有时候可能会访问失败。有可能是因为网络不稳定导致的,也可能是因为这个服务本身不稳定。再或者如果我们需要第三方服务返回不同的数据以便测试脚本的不同分支逻辑,但我们可能很难去修改这个第三方服务的数据。

场景三:Bash 脚本的测试用例的执行时间取决于脚本中使用的命令的执行时间。例如,如果我们中脚本中使用了 Gradle 来构建一个工程,由于不同的工程大小 Gradle 的一个构建可能要执行3分钟或者3个小时。这还只是一个测试用例,如果我们还有20个或者100个测试用例呢?我们是否还能在几秒内获得测试报告呢?

即使使用了容器来执行 Bash 脚本测试,也一样无法避免上面的几个问题。环境的准备过程可能会随着测试用例的增多而变的繁琐,测试用例的稳定性和执行时长取决于第三方命令和服务的稳定性和执行时长,还可能很难做到使用不同数据来覆盖不同的测试场景。

对于测试 Bash 脚本来说,我们真正要验证的是 Bash 脚本的执行逻辑。比如在 Bash 脚本中可能会根据传入的参数来组合出内部所调用的命令的选项和参数,我们要验证的是这些选项和参数确实如我们预期的。至于调用的命令在接受了这些选项和参数后由于什么原因而失败,可能我们并不关心这所有的可能原因。因为这会有更多的外部影响因素,比如硬件和网络都是否工作正常、第三方服务是否正常运行、构建工程所需的编译器是否安装并配置妥当、授权和认证信息是否都有效、等等。但对于 Bash 脚本来说,这些外部原因导致的结果就是所调用的命令执行成功或者失败了。所以 Bash 脚本只要关注的是脚本中调用的命令是否能够成功执行,以及命令输出了哪些,并决定随后执行脚本中的哪些不同分支逻辑。

如果说我们就是想知道这个命令搭配上这些选项参数是否能按我们预期的那样工作呢?很简单,那就单独在命令行里面去执行一下。如果在命令行中也不能按预期的工作,放到 Bash 脚本里面也一样不会按预期的工作。这种错误和 Bash 脚本几乎没什么关系了。

所以,为了尽量去除影响 Bash 脚本验证的那些外部因素,我们应该考虑为 Bash 脚本编写单元测试,以关注在 Bash 脚本的执行逻辑上。

什么样的测试才是 Bash 脚本的单元测试?

首先,所有存在于 PATH 环境变量的路径中的命令都不应该在单元测试中被执行。对 Bash 脚本来说,被调用的这些命令可以正常运行,有返回值,有输出。但脚本中调用的这些命令都是被模拟出来的,用于模拟对应的真实命令的行为。这样,我们在 Bash 脚本的单元测试中就避免了很大一部分的外部依赖,而且测试的执行速度也不会受到真实命令的影响了。

其次,每个单元测试用例之间都应该是独立的。这意味着,这些测试用例可以独立执行或者被任意乱序执行,而不会影响验证结果。

最后,这些测试用例可以在不同的操作系统上执行,且都应该得到相同的验证结果。比如 Bash 脚本中使用了只有 GNU/Linux 上才有的命令,对应的单元测试也可以在 Windows 或者 macOS 上执行,且结果一致。

怎样为 Bash 脚本写单元测试?

与其他编程语言一样,Bash 也有多个测试框架,比如 BatsShunit2 等,但这些框架实际上并不能隔离所有 PATH 环境变量中的命令。有一个名为 Bach Testing Framework 的测试框架是目前唯一一个可以为 Bash 脚本编写真正的单元测试的框架。

Bach Testing Framework 的最独特的特性就是默认不会执行任何位于 PATH 环境变量中的命令,因此 Bach Testing Framework 非常适用于验证 Bash 脚本的执行逻辑。并且还带来了以下好处:

  • 简单
    什么也不用安装。我们就可以执行这些测试。比如可以在一个全新的环境中执行一个调用了大量第三方命令的 Bash 脚本。

  • 因为所有的命令都不会被真正执行,所以每一个测试用例的执行都非常快。
  • 安全
    因为不会执行任何外部的命令,所以即使因为 Bash 脚本中的某些错误导致执行了一个危险的命令,比如 rm -rf *。Bach 会保证这些危险命令不会被执行。
  • 与运行环境无关
    可以在 Windows 上去执行只能工作在 GNU/Linux 上的脚本的测试。

由于操作系统和 Bash 的一些限制,Bach Testing Framework 无法做到:

  • 拦截使用绝对路径调用的命令
    事实上我们应该避免在 Bash 脚本中使用绝对路径,如果不可避免的要使用,我们可以把这个绝对路径抽取为一个变量,或者放入到一个函数中,然后用 @mock API 去模拟这个函数。
  • 拦截诸如 >>><< 等等这样的 I/O 重定向
    是的,无法拦截 I/O 重定向。我们也同样可以把这些重定向操作隔离到一个函数中,然后再模拟这个函数。

Bach Testing Framework 的使用

Bach Testing Framework 需要 Bash v4.3 或更高版本。在 GNU/Linux 上还需要 Coreutils 和 Diffutils,在常用的发行版中都已经默认安装好了。Bach 在 Linux/macOS/Cygwin/Git Bash/FreeBSD 等操作系统或者运行环境中验证通过。
Bash v4.3+
Coreutils (GNU/Linux)
Diffutils (GNU/Linux)

安装 Bach Testing Framework

Bach Testing Framework 的安装很简单,只需要下载 https://github.com/bach-sh/bach/raw/master/bach.sh 到你的项目中,在测试脚本中用 source 命令导入 Bach Testing Framework 的 bach.sh 即可。

比如:

source path/to/bach.sh

一个简单的例子

与其它的测试框架不同,Bach Testing Framework 的每一个测试用例都是由两个 Bash 函数组成,一个是以 test- 开头的测试执行函数,另一个是同名的以 -assert 结尾的测试验证函数。

比如在下面的例子中,有两个测试用例,分别是
test-rm-rf
test-rm-your-dot-git

一个完整的测试用例:

#!/usr/bin/env bash
set -euo pipefail

source bach.sh # 导入 Bach Testing Framework

test-rm-rf() {
    # Bach 的标准测试用例是由两个方法组成
    #   - test-rm-rf
    #   - test-rm-rf-assert
    # 这个方法 `test-rm-rf` 是测试用例的执行

    project_log_path=/tmp/project/logs
    sudo rm -rf "$project_log_ptah/" # 注意,这里有个笔误!
}
test-rm-rf-assert() {
    # 这个方法 `test-rm-rf-assert` 是测试用例的验证
    sudo rm -rf /   # 这就是真实的将会执行的命令
                    # 不要慌!使用 Bach 测试框架不会让这个命令真的执行!
}

test-rm-your-dot-git() {
    # 模拟 `find` 命令来查找你的主目录下的所有 `.git` 目录,假设会找到两个目录

    @mock find ~ -type d -name .git === @stdout ~/src/your-awesome-project/.git \
                                                ~/src/code/.git

    # 开始执行!删除你的主目录下的所有 `.git` 目录!
    find ~ -type d -name .git | xargs -- rm -rf
}
test-rm-your-dot-git-assert() {
    # 验证在 `test-rm-your-dot-git` 这个测试执行方法中最终是否会执行以下这个命令。

    rm -rf ~/src/your-awesome-project/.git ~/src/code/.git
}

Bach 会分别运行每一个测试用例的两个方法,去验证两个方法中执行的命令及其参数是否是一致的。比如,第一个方法 test-rm-rf 是 Bach 的测试用例的执行,与之对应的测试验证方法就是 test-rm-rf-assert 这个方法

在第二个测试用例 test-rm-your-dot-git 中使用了 @mock API 来模拟了命令 find ~ type d -name .git 的行为,这个命令用来找出用户目录下的所有 .git 目录。模拟之后,这个命令并不会真的执行,而是利用了 @stdout API 在标准终端上输出了两个虚拟的目录名。

然后我们就可以执行真正的命令了,将 find 命令的输出结果传递给 xargs 命令,并组合到 rm -rf 命令之后。

在对应的测试验证函数 test-rm-your-dot-git-assert 里面就验证是 find ~ -type d -name .git | xargs -- rm -rf 的运行结果是否等同于命令 rm -rf ~/src/your-awesome-project/.git ~/src/code/.git

@mock 是 Bach Testing Framework 中很重要的一个 API,利用这个 API 我们就可以模拟 Bash 脚本中所使用的任意命令的行为或者输出。

比如

@mock curl --silent google.com === \
    @stdout "baidu.com"

模拟了命令 curl --silent google.com 的执行结果是输出 baidu.com。在真实的正常场景下,我们是无法做到访问 google.com 得到的是 baidu.com。这样模拟之后就可以用来验证 Bash 脚本中处理一个命令不同响应时的行为了。

@mock API 甚至还支持更复杂的行为模拟,我们可以自定义一个复杂的模拟逻辑,比如:


@mock ls <<\CMD
    if [[ "$var" -eq 1 ]]; then
        @stdout one
    else
        @stdout others
    fi
CMD

在这个模拟中,会根据变量 $var 的值来决定命令 ls 的输出 one 还是 others

@mock API 模拟的命令在任何时候执行的时候都是同样的行为。但如果要模拟同一个命令重复执行的时候要返回不同的值,Bach Testing Framework 还提供了一个 @@mock 这个 API,比如:

@@mock uuid === @stdout aaaa-1111-2222
@@mock uuid === @stdout bbbb-3333-4444
@@mock uuid === @stdout cccc-5555-6666

这三个模拟命令模拟了 uuid 在重复执行三次的时候都返回不同的结果,按照模拟的先后顺序分别输出对应的模拟输出。如果在执行完所有的模拟输出后,再重复执行将会始终输出最后一个模拟的输出。

更详细的 API 介绍请在 Bach Testing Framework 的官网 https://bach.sh 查看。

使用 Bach Testing Framework 还可以让我们更安全方便的练习 Bash 编程。

比如,我们希望实现一个函数 cleanup 用来删除参数上指定的文件。一个实现可能是:

function cleanup() {
    rm $1
}

这个函数的实现其实是有安全问题的,因为对于 Bash 来说,有没有把一个变量用双引号包含起来是非常重要的。在这个实现中,变量 $1 就没有用双引号,这会带来严重的后果。下面我们将使用 @touch API 来创建几个文件,其中将有一个文件名中含有特殊字符 的文件 bar。我们都知道,对于含有特殊字符的文件名是要放入到双引号中的。现在这个这个 cleanup 的实现里面没有使用双引号,但是传参的时候使用了双引号,那是否还会按照我们的预期来执行呢?


function cleanup() {
    rm -rf $1
}

test-learn-bash-no-double-quote-star() {
    # 创建了三个文件,其中有一个名为 "bar*" 的文件
    @touch bar1 bar2 bar3 "bar*"

    # 要删除这个错误的文件名 bar*,而不删除其他文件,使用了双引号来传参,这是正确的
    cleanup "bar*"
}

test-learn-bash-no-double-quote-star-assert() {
    rm -rf "bar*"
}

这个测试用例将会失败,从验证结果中我们可以看到,期望只删除文件 bar,但是在函数 cleanup 里面,因为遗漏了双引号,会导致变量被二次展开。实际执行的命令是 rm -rf "bar*" bar1 bar2 bar3

现在修复函数 cleanup,把变量 $1 放入双引号:

function cleanup() {
    rm -rf "$1"
}

再次执行测试,会发现确实执行的是命令 rm -rf "bar*"

Bach Testing Framework 目前已经在宝马集团和华为内部使用了。在宝马集团的一个有数千人规模的大型项目里,Bach Testing Framework 保证了数个非常重要的构建脚本的维护。这些脚本的可靠性和稳定性决定了数千人团队的工作效率,现在就可以在本地快速验证这些构建脚本的执行逻辑,也避免了在本地很难复现一些构建集群中的特殊场景的问题。

如果大家在使用上有啥问题和建议可以去Bach 测试框架的项目上留言。

即使为 Bash 脚本写了单元测试后,我们可能还会遇到为什么 Bash 脚本总是不稳定?难道真的是 Bash 不严禁不稳定吗?另外还有《关于 Bash 的 10 个常见误解》,看看你有多少误解。以及在学习和掌握运维技术栈时,除了 Bash 以外可能还要学有很多工具,比如 Python、Ansible、Kubernetes等等。这些工具在运维技术栈里处于什么位置,他们之间的关系又是如何呢?我们是应该学 Bash 还是 Python 还是 Ansible 呢?请看《DevOps 技术栈》这个我认为最重要的一篇文章以建立起我们的运维技术脉络。

09. August 2020 · 2 comments · Categories: Uncategorized · Tags: ,

Bash 大概是程序员们既是最熟悉的而又是最陌生的。说熟悉,Bash 几乎存在于程序员身边的绝大多数电脑上。说陌生,Bash 作为最常用的脚本语言,很少有程序员们了解 Bash 的特性和语法,而且因为不了解还产生了很多的误解。比如什么 Bash 作为一个脚本语言,其语法怪异且不严禁,仅适合编写简单的脚本,我们应该用 Python 代替 Bash。其实这完全是错误的理解。甚至很多程序员并不知道他们当前使用的 Bash 版本是多少,用的是 3.x?还是 4.x?还是 5.x?你知道你用的是哪个版本吗?

其实 Bash 的语法是图灵完备的,也就是说理论上我们可以用 Bash 实现任何其他语言可以实现的功能。话虽如此,但不同的语言有其业务场景的适用性,我们当然应该把 Bash 用在最适合的场景下,这才能发挥出 Bash 的强大威力。

完全没有道理用自己熟悉的编程语言去比较其他语言,如果不符合自己熟悉的习惯就评价为怪异。作为程序员一定要保持开放的心态去学习。

这篇文章介绍了大家对 Bash 的常见误解,看看你知道多少?

Bash 的 if 语句后面其实不是括号

这应该是被人误解最多的,吐槽最多的一点。比如 Bash 的语法非常怪异,if 后面的括号竟然是方括号,而且括号的前后都要有空格!

事实上,Bash 的 if 语法定义上其实并没有定义括号,而且命令序列。执行命令 help if 可以看到 if 的使用方法

if COMMANDS; then COMMANDS; 
[ elif COMMANDS; then COMMANDS; ]... 
[ else COMMANDS; ] 
fi

实际上所谓的方括号 [ 其实是一个命令,这个命令既是 Bash 的内建命令,也是一个外部命令,根据系统的不同可能位于 /bin 或者 /usr/bin 目录下。比如 macOS 上执行命令 ls -l “/bin/[” 就可以找到。

当我们知道了方括号 [ 是个命令后,这些怪异的语法就变得很容易理解了。因为 if 后面是命令,所以当然也可以用方括号 [ 命令了。既然这是一个命令,那命令的参数和命令本身自然也需要有空格来做分割了。事实上方括号 [ 命令等同于 test 命令,但是要求命令的最后一个参数必须是 ]。哈哈,如果一个括号只有开头而没有结尾,这才是最怪异的呢。

不过还有一种常见的是双方括号 [[,这个是 Bash 的内建关键字。虽然可以把关键字当作命令来看到,但是 Bash 会对关键字做特殊处理。比如在 [[ 与其结尾参数 ]] 之间是可以使用 &&、|| 这样的逻辑操作的,如果我们把 [[ 1 -eq 1 && 2 -eq 2 ]] 当作两条命令来看到,第二个命令是 2,这肯定会执行失败了。这就是 Bash 对关键字做的一个特殊处理。而 [ 是一个命令,不管是内建命令也好外部命令也好,[ 1 -ne 0 && 2 -ne 3 ] 一定会出错的,因为这将执行两条命令,第一条 [ 1 -ne 0 将会因为 [ 命令的最后一个参数不是 ] 而出错,第二条命令 2- ne 3 ] 可能将会因为在 PATH 环境变量中没有找到一个名为 2 的命令而出错。

说到这里,我们可以想想看。假如有一个 Java 程序员竟然对 Java 的 if 语句的语法不熟悉,这个程序员怎么可能写出好的 Java 程序?把 Java 换成 Bash 也是一样的。

真的无法阻止 Bash 中的函数修改全局的变量吗?

这应该是第二个被人误解最多的一个 Bash 特性,也常被用来证明 Bash 的语言多么不可靠。原因是如果在函数中没有使用 local 关键字来声明变量,那函数里的变量将会对全局产生影响。或许看起来有些绕口,请看如下的例子:

#!/usr/bin/env bash
x=42
bar=answer

function foo() {
  local x=999
  bar=world
  echo foo: x=$x bar=$bar
}

foo
echo x=$x bar=$bar

执行的结果是

foo: x=999 bar=world
x=42 bar=world

在函数 foo 中用 local 关键字声明了变量 x,这使得函数内变量 x 的作用域限于函数 foo 内,不同于外部的全局变量 x。而函数内使用变量 bar 时没有用 local 关键字声明,这使得函数内对变量 bar 的赋值实际上是修改了函数之外的变量 bar。这可能会造成我们在编写函数的时候,必须显式的使用 local 关键字来声明局部变量,否则会发生无意中使用了全局的同名变量,导致该全局变量被无意中修改的问题。

这个问题真的很严重,也的确是有些人吐槽 Bash 不严谨的地方之一。事实上,这也恰巧说明了我们可能并不了解 Bash 中定义函数的语法。

大家都知道,在 Bash 中定义函数时,把函数体放到花括号中。看上去这点和很多编程语言也非常类似,比如 C、Java 等。但却不会把变量的作用域限制在花括号的范围内。其实 Bash 中声明函数时,其语法要求函数体是一个组合命令即可。所以,花括号并不是必须的,我们还可以用圆括号。请看下面的例子中函数 foo 的函数体用了圆括号:

#!/usr/bin/env bash
x=42
bar=answer

function foo() ( # <--
  local x=999
  bar=world
  echo foo: x=$x bar=$bar
) # <--

foo
echo x=$x bar=$bar

执行的结果是:

foo: x=999 bar=world
x=42 bar=answer

在第二个例子中,我们把函数体放到了圆括号里面,这使得这个函数将会在子进程中执行。而子进程是无法修改父进程中的任何变量的。所以,即使在函数中没有用 local 关键字声明变量 bar,对这个变量的变更也不会影响到全局的同名变量。

因为在定义 Bash 的函数时需要的是组合命令,所以下面的函数定义也是合法的:

function foo()
for param; do
  echo "=> $param";
done

执行 foo a b c 会得到

=> a
=> b
=> c

是的,出乎大家的意料,定义 Bash 函数的时候连什么括号都可以不要。

所以,我想再吐槽一次。如果有人连 Bash 的语法都不了解不熟悉,有很多的误解。他们是如何得出 Bash 的语法不严禁,功能不如其他语言这些结论的?

Bash 也支持直接执行目录名来切换到该目录中

很多使用 Zsh 的用户在举例 Zsh 与 Bash 的区别时,这是一个常用来对比的特性。很可惜,这不是 Zsh 的独特特性,Bash 其实也支持。实际上 Zsh 下这个特性也是默认关闭的,只是很多人都会使用 Oh My Zsh 这个知名的配置工程,而 Oh My Zsh 默认开启了这个特性。在 Bash 下开启这个特性的命令是 shopt -s autocd,开启之后我们就可以直接执行目录名来切换到该目录了。

Bash 其实也支持 ** 通配符来匹配多级子目录

这个特性也是有些人会误认为 ** 通配符是 Zsh 比 Bash 好用的一个特性,但其实 Bash 也是支持的。

开启命令是 shopt -s globstar

然后我们就可以用 ls **/*.txt 来列出当前目录及其子目录下的所有 *.txt 文件了。

所以,以后也请不要那这个特性来对比 Zsh 和 Bash 了。

Bash 在命令行上其实也支持撤销、粘贴等操作

当然有很多人知道在命令行下如何快速跳转光标、删除单词或者删除到行尾等常用操作,但很少有人会知道的 Bash 在命令行也支持撤销、粘贴等操作,而且粘贴功能事实上比操作系统默认的更强大。

撤销的快捷键是 Ctrl-/,反复按下会继续撤销之前的操作。

粘贴的快捷键是 Ctrl-y。当在命令行使用了 Ctrl-k 删除到行尾、Ctrl-w 删除前一个单词、Alt-d 删除下一个单词、Ctrl-u 删除整行等操作后,Bash 会将删除掉的内容放到自己的剪贴板中。要注意的是这个 Bash 的剪贴板不同于操作系统的剪贴板。删除之后,可以按下 Ctrl-y 在当前光标处粘贴最后一次删除的内容,重复按下会重复粘贴。

如果删除过多次,按下 Ctrl-y 粘贴出最后一次删除的内容后,再紧接着按下 Alt-y 就会替换为前一次的内容,重复按下 Alt-y 会继续替换更早之前删除的历史内容。

Bash 中使用 I/O 重定向的时候,重定向可以不用放在命令的最后

我们经常使用 >、>>、<、<< 来把命令结果保存到文件、或者从文件中读取内容。通常我们都是把重定向放在命令的最后。比如 ls -ld * >/tmp/files.txt

实际上,重定向可以位于一条命令的任何位置,放在中间或者最前面都是可以的。比如以下命令都是一样的

>/tmp/files.txt ls -ld *

ls >/tmp/files.txt -ld *

有时候不把重定向放在命令的最后会给我们快速复用并修改之前的命令非常方便。比如我们只需要快速搜索到之前执行的命令 >/tmp/files.txt ls -ld *,就可以很容易的修改最后一个参数了。

顺便说以下,看上去 Bash 中的 I/O 重定向很简单、很易使用。但事实上Bash 的 I/O 重定向比绝大多数人认为的更强大且复杂。或许我会在另外的文章中来详细介绍一下。

Bash 支持直接访问网络

Bash 的网络功能是很多人不知道的一个特性,不需要什么 nc、socat 这些命令就可以访问网络资源。只需要使用 I/O 重定向直接访问设备文件 /dev/tcp/<host>/<port> 即可,把 <host> 和 <port> 换成你希望访问的网络地址和端口。比如 /dev/tcp/baidu.com/80 就是访问主机 baidu.com 的 80 端口。

请看下面的命令,用 Bash 的内建命令实现了一个简单的 HTTP 客户端功能,访问 baidu.com 并打印出响应。

#!/usr/bin/env bash
exec 6<>/dev/tcp/baidu.com/80;
printf "GET / HTTP/1.1\nHOST: baidu.com\nConnection: close\n\n" >&6;
while read -r -u 6 line; do
  echo "$line";
done;
exec 6>&-

执行结果

HTTP/1.1 200 OK
Date: Sat, 08 Aug 2020 23:31:36 GMT
Server: Apache
Last-Modified: Tue, 12 Jan 2010 13:48:00 GMT
ETag: "51-47cf7e6ee8400"
Accept-Ranges: bytes
Content-Length: 81
Cache-Control: max-age=86400
Expires: Sun, 09 Aug 2020 23:31:36 GMT
Connection: Close
Content-Type: text/html

<html>
<meta http-equiv="refresh" content="0;url=http://www.baidu.com/">
</html>

其他的 Shell,比如 Zsh、Sh 等可能不支持这种直接访问网络的方式,但是 Zsh 的网络支持其实更强大,只是方式不同。

Bash 的受限模式让自动化运维更安全

在启动 Bash 的时候,使用选项 -r 可以让 Bash 进入受限模式。在受限模式下,会有很多的限制,比如:

  • 不能用 cd 命令切换目录
  • 不能修改 PATH 环境变量
  • 不可以通过指定路径的方式来运行命令
  • 不能使用 I/O 重定向
  • ……

Bash 受限模式的一个常见的使用场景是自动化运维。想想看,如果系统会根据一些用户的请求来自动生成并执行一些命令,在这些命令中可能会使用一些用户的输入,这可能会产生类似 SQL 注入的漏洞攻击。再比如,在一些自动化运维的时候难免要临时切换到特权用户下执行命令。为了让系统更安全,在临时的特权用户环境里面只能执行一些特定的命令或者脚本,以尽量减少系统被攻击的可能性。使用 Bash 的受限模式就可以避免非期望的命令或者脚本被执行,让系统更加安全。

有些「Bash 命令」其实和 Bash 无关

或许有的人会把 ls、rm、mkdir、…… 等命令称为 Bash 命令,其实这些命令与 Bash 并没有关系。比如在 GNU/Linux 上,这些命令属于 GNU Coreutils 这个项目。而 Bash 命令应该是 Bash 的内建命令,比如 alias、exec、printf 等等。有些 Coreutils 中的命令因为使用的很多,为了避免无谓的进程创建开销,所以 Bash 也将其内建了,比如 echo、test 等。不过要注意的是,Bash 的内建命令的选项与 Coreutils 中的同名命令可能不同。

比如执行下列命令可能会得到不同的结果:

echo --help
/bin/echo --help

# macOS 上用 brew install coreutils 后的默认路径
/usr/local/opt/coreutils/libexec/gnubin/echo --help

我们也能够为 Bash 写单元测试

Bash 还有一个最易被人诟病的一点就是,很难去测试一个 Bash 脚本。所以这也是很多人说应该用 Python 代替 Bash 的一个理由,因为我们可以为 Python 写单元测试,而不能为 Bash 写单元测试。虽然已经有了一些 Bash 的测试框架,比如 Batsshunit2 等。但这些测试框架都是集成测试框架。这意味着,为了执行 Bash 脚本的测试用例,我们需要事先安装好被测脚本中依赖的各种工具,配置好所需的目录、配置等。比如在我们的脚本中使用了 Bazel 或者 CMake,如果没有安装 Bazel 或者 CMake,很显然这些测试都将失败。

如果可以为 Bash 脚本写单元测试,这就意味着这些测试可以在任意时候、任意的机器上乱序执行任意的测试都应该在秒级的时间内得到相同的执行结果。无论是在 Windows 上执行 GNU/Linux 的 Bash 脚本的单元测试,还是在一个全新的极简的环境中执行一个使用了很多第三方工具的复杂的 Bash 脚本的单元测试,都应该得到稳定的测试结果。

现在有一个名为 Bach 的测试框架(Bach Testing Framework),这是目前唯一的一个真正可以为 Bash 脚本编写单元测试的框架。Bach 测试框架的独特之处在于它不会执行任何在 PATH 环境变量中的命令,而且还提供了丰富的模拟命令用于模拟其它命令的行为。Bach 测试框架非常适合于测试 Bash 脚本的执行逻辑,而不用实现安装并配置好被测脚本中使用到的各个工具。

请看下面的例子:

#!/usr/bin/env bash
set -euo pipefail
source bach.sh

test-rm-rf() {
    # Write your test case

    project_log_path=/tmp/project/logs
    sudo rm -rf "$project_log_ptah/" # 这里写错了变量名,导致了一个严重的错误
}
test-rm-rf-assert() {
    # Verify your test case
    sudo rm -rf /   # 这就是将要执行的命令
                    # 别慌!Bach 不会让这个命令真的执行!
}

Bach 测试框架的每一个测试用例默认是由两个函数来组成,一个是用于编写测试用例,比如上例中的 test-rm-rf,与之对应的是用于验证测试结果的测试验证函数 test-rm-rf-assert。Bach 测试框架会分别运行这两个函数,并对比执行中调用的命令序列是否一致。