作为一名DevOps工程师,我经常会遇到Bash脚本。在服务器上执行任务、编写CI/CD流水线或者自动化一些手动任务,这些都是日常活动,通常都需要编写一些Bash脚本。在这里,我将分享一些在Bash脚本编写时应该遵循的技巧和最佳实践。
1. 区分变量
在Bash中,对于变量有两个众所周知的良好实践:
- 总是在变量名周围使用大括号 {}。
- 总是在变量周围使用双引号 ""。
因此,一个变量应该像这样:"${variable_name}"。这样可以清晰地区分变量与代码的其他部分,并且变量值中的空格不会导致问题。让我们通过一个示例来说明有关空格的问题。
mkdir tips
echo "File content" > "tips/Filename with spaces"
ls -l tips
运行上述命令后,我们有一个包含一个文件的 tips 目录,但是该文件名中有空格。现在让我们尝试遍历 tips 目录中的文件。应该只有一个文件。
for filename in $(ls tips); do
echo Found: ${filename}
cat tips/${filename}
done
上述 for 循环将生成:
Found: Filename
cat: tips/Filename: No such file or directory
Found: with
cat: tips/with: No such file or directory
Found: spaces
cat: tips/spaces: No such file or directory
显然,输出结果与我们预期的不符。现在尝试相同的 for 循环,但是添加双引号。
for filename in "$(ls tips)"; do
echo Found: "${filename}"
cat tips/"${filename}"
done
注意,双引号也应该放在命令输出周围,即此处为 "$(ls tips)"。最终输出现在应该是正确的:
Found: Filename with spaces
File content
在这个示例中,echo 和 cat 也可以这样写:
echo "Found: ${filename}"
cat "tips/${filename}"
关于变量还有一件事需要注意。局部变量通常使用小写字母编写,环境变量则使用大写字母编写。运行 env 命令以查看当前终端的环境变量 - 它们都是大写的。因此,在使用 export 将变量从脚本导出到环境时,请使用大写字母。
export cluster_name="r2d2" # 不好
export CLUSTER_NAME="r2d2" # 好
2. 处理错误
不幸的是,Bash没有直观、简单的错误处理机制。无法使用 try-catch 块捕获异常。检查错误的唯一方法是评估命令的退出代码。无论您是交互式运行脚本还是非交互式运行脚本,脚本都应该检查这些错误代码。最简单的方法是使用 if 并检查 $? 中的退出代码:
if cat file; then
echo "退出代码为 0。没有问题。"
else
echo "退出代码为 $?"
echo "处理错误.."
fi
如果您没有使用 if 检查,则将 set -e 添加到脚本中。使用此选项,Bash 脚本将在某个命令失败时退出。这在其他编程语言中是预期的行为,但默认情况下,Bash 如果脚本中的命令失败,它并不会退出。
set -e
cat no_file # 抛出错误
cat no_file2 # 此部分不会被执行
使用 set -e,只有 cat no_file 会被执行,并且脚本将退出。现在尝试移除 set -e。您会看到在这种情况下都会运行两个 cat 命令,即使它们都失败了。
您还应该添加 set -o pipefail,这样如果管道中的某个命令失败,整个管道就会失败。默认情况下,管道只有在最后一个命令失败时才会失败。
set -eo pipefail
cat no_file | echo # 抛出错误
echo "End" # 此部分不会被执行
此外,还有 set -u,它将未定义(未绑定)变量的使用视为错误。
set -euo pipefail
echo "${var}" # 抛出错误并退出
如果您移除 -u 选项,脚本将成功退出,而 echo 将打印空字符串。
3. 使用缩进和多行
在Bash中,您可以使用 ; 在一行文本中容纳整个脚本。显然,没有人会这样做,但很多人会在单行中写入很长的命令和很多参数。这不是一个好的实践,因为您无法轻松地看到整个命令。相反,将该行拆分为多行,并添加缩进。例如:
az aks create \
--resource-group my-resource-group \
--name my-aks-cluster \
--enable-managed-identity \
--node-count 1 \
--enable-addons monitoring \
--generate-ssh-keys
有些人可能会说您可以将两个或三个参数放在一行中,但我更喜欢每个参数单独放在一行中,因为这更清晰。有趣的是,我从Microsoft Azure文档中提取了上述命令,他们将所有内容放在单行中,因此您必须水平滚动以查看要复制的命令:
https://learn.microsoft.com/en-us/azure/aks/learn/quick-kubernetes-deploy-cli#create-an-aks-cluster)
另一方面,Google Cloud文档确实将命令拆分为多行,因此更容易阅读:
此外,我强烈建议在编写if-else块和循环时使用缩进。以下是一些示例:
if [[ "${PASSWORD}" == "${ADMIN_PASSWORD}" ]]; then
echo "Access granted"
else
echo "Access denied"
fi
while "${RUN}"; do
if [[ -f new_file ]]; then
break
fi
sleep 1
done
此外,请确保规范化缩进应为两个空格还是四个空格。我更喜欢两个空格,但您或您的团队可能更喜欢四个。只需确保保持一致即可。
4. 使用 Shellcheck 进行测试
Shellcheck 是一个用于 Bash 的代码检查工具。您可以使用 apt、dnf、pkg 和 brew 进行安装,使用 在线版本,或者直接将其安装到您的 编辑器 中。
sudo apt install shellcheck
要检查一个脚本,只需执行:
shellcheck script.sh
Shellcheck 会方便地提醒您添加一个 shebang (#!/bin/bash),添加双引号,如果您错误使用引号或使用未赋值的变量,它还会警告您,告诉您条件表达式中的问题,以及更多。这真的非常有用,特别是如果您对 Bash 还不是很熟悉的话。我还建议将 Shellcheck 添加到您的 CI/CD 流水线中。
5. 在必要的地方添加注释
注释可以帮助其他人阅读您的脚本。当您阅读自己的旧脚本时,注释也可以帮助您,因为您可能已经忘记了该脚本,但现在又需要它。您不必花费时间编写深入的注释,但一定要在那些有点难以理解的行上添加注释。随着时间的推移,您会养成自动编写这些注释的习惯,甚至无需考虑。
# 获取所有启用了 prometheus 抓取的部署
kubectl get deployments \
--all-namespaces \
-o=jsonpath='{.items[?(@.spec.template.metadata.annotations.prometheus\.io/scrape=="true")].metadata.name}'
这是我在 Stack Overflow 上找到的一个示例命令,如果您要在脚本中使用它,那么绝对需要添加注释。
6. 使用超时设置
我遇到过许多情况,脚本必须等待某些外部任务完成。例如,它必须等到文件创建完成,等到 Kubernetes Pod 运行完成,等到虚拟机启动完成,等到某个作业完成等等。在这种情况下,您应该为该命令或循环定义一个超时,因为云中(或任何外部服务中)可能会出现故障,资源可能永远无法启动。要为命令添加超时,您可以简单地使用 timeout 命令。如果超时,退出状态为 124。尝试运行下面的示例,并缩短睡眠时间,以便打印出 Done。
timeout 2s sleep 5 && echo Done
对于循环,您可以使用以下技巧:
# 等待 new_file 10 秒钟
timeout 10s bash -c \
'until [[ -f new_file ]]; do
echo "User ${USER} is waiting a file.."
sleep 1
done'
在单引号内部使用双引号将起作用,因为 bash -c 启动一个新的 shell,然后单引号之间的所有内容都会像解析您的 shell 输入一样解析。尝试运行循环,查看 timeout 在 10 秒后将其终止的情况。再次运行循环,并在同一目录中快速创建 new_file。循环应该在没有超时的情况下成功退出。
7. 找到合适的工具
在编写脚本时,了解有很多工具可以帮助您是很重要的。我记得有一次实习生的任务是使用 Bash 脚本解析 YAML。他必须评估目录中的所有文件。如果文件具有特定的 YAML 键,并且该键的值为数组,他必须评估数组的所有元素。为了简化示例,让我们假设他必须评估每个元素是否包含字符 r。
yamlKey:
- first
- second
- third
anotherKey: "val"
因此,他使用 grep -n 查找文件是否包含 yamlKey 并保存键的行号。然后,他使用 sed 读取下一行,并检查它是否以两个空格和一个减号开头——这意味着它是数组的一个元素。在这种情况下,他将使用正则表达式检查该行是否包含 r。然后,他将继续下一行。如果该行不以两个空格和一个减号开头,则表示数组已结束。阅读代码是痛苦的。代码实际上是有效的,但仅在一段时间内有效。在 YAML 中,还有另一种定义数组的方式:
yamlKey: [first, second, third]
anotherKey: "val"
这就是为什么在编写一些 hacky 解决方案之前,您应该先稍微搜索一下。很可能有一个程序可以解决您的问题。在这种情况下,就是 yq,一个用于处理 YAML 语法的命令行界面。
8. 考虑对于复杂任务使用 Python
关于 Python 和 Bash 哪个更适合哪种情况的辩论永远不会停止。Python 更强大,但 Bash 更通用和轻量级。Bash 基本上可以在每台 Linux 服务器上和每个 CI/CD 工具上默认使用。了解 Bash 肯定是有用的。但是当涉及到发送大量 GET 和 POST 请求、与 API 进行交互和处理大量数据等复杂任务时,Python 更容易编写。在 Bash 中,您将不得不进行大量的文本解析和错误检查。尽管如此,Bash 仍然是 DevOps 工程师中的一种流行选择。大多数 DevOps 教程和许多文档主要使用命令行界面来解释如何执行某些任务。例如,Kubernetes 有一个 Python 客户端,但是当搜索如何在 Kubernetes 中执行某项任务时,您总是会找到可以在 Bash 中运行的 kubectl 命令。