跳到正文

Shell 脚本通用要点

适用于所有用 shell 完成的工作:部署和运维脚本、构建脚本、一次性的命令行操作。示例以 bash 为准。

一、动手前

  • 先确认目标和流程。 写下脚本要从什么状态到什么状态、中间有哪些步骤、哪一步之后不能回头。流程有多种做法时,先和使用者确认,从最简单的那种开始。
  • 先看一眼要改动的对象。 删除、覆盖、移动之前,确认目标路径、分支、主机和自己设想的一致。
  • 能用现成工具就不自己造。 优先用系统自带的命令,只有确实需要时才调用应用代码或额外的运行时。

二、失败要安全

  • 出错就停。 开头写 set -Eeuo pipefail,再加 trap 'printf "第 %s 行失败(退出码 %s)\n" "$LINENO" "$?" >&2' ERR,失败时能看到是哪一行。
  • 操作要可逆,影响范围要小。 改名保留,少用删除。改动前先备份,备份放在脚本固定管理的位置。rm -rf -- 只用于脚本自己创建、路径固定的目录,路径绝不能来自可能为空的变量。
  • 半途失败要回到能用的状态。 在改动线上状态之前注册 trap ... EXIT,用来撤回改动或恢复旧服务。到达“不能自动撤回”的那一步时,用 trap - EXIT 取消。
  • 不能自动撤回的失败,给出下一步命令。 例如“启动失败,执行 bash xxx rollback”。会丢数据的恢复由人决定,不自动执行。
  • 可以重复执行。 第二次运行不会破坏第一次的结果:已经完成的步骤要么跳过,要么明确拒绝并说明原因。

三、检查要准

  • 检查的必须是真正关心的东西。 “接口返回 200”比“进程存在”更能说明服务可用,“读得到数据库的接口返回 200”又比“首页返回 200”更可靠。
  • 在不会变化的数据上做严格比对。 比对前后状态时,先让数据停止变化(停服务、取快照),再要求完全相等。不要在一直变化的数据上设容忍规则。
  • 不写死只对一次运行成立的值。 具体的版本号、行数、条目数、文件内容标记,下一次运行就会误报。
  • 有差异就列出明细。 打印“前 → 后”,再由人确认。不确定时默认取消。
  • 能自动查到的事实,不让人回答。 只有判断本身需要人来做时才询问。

四、交互

  • 确认词按后果分级。 只是提醒、可以继续的用 y;停机、删除、改数据用 yes。不提供跳过所有确认的开关。
  • 非交互时默认取消。 read -r -p '…' answer || true,空答案当作取消。
  • 输出分层。 步骤、警告、错误各用固定的前缀;警告和错误写到 stderr;错误以非零状态退出。每一步结束时告诉使用者下一步做什么。
  • 不打印凭据。 密码、令牌、私钥、Cookie 不出现在输出、日志和命令行参数里,需要时引用文件路径。

五、写法

  • 常量放在顶部。 路径、服务名、端口写成大写常量。函数里的变量用 local。
  • 只调用一次的逻辑直接内联。 被多处调用的才写成函数。不要为了“看起来整齐”拆出只用一次的小函数。
  • 子命令少而完整。 每个子命令完成一件完整的事。不带参数时打印用法,并以 2 退出。
  • 依赖在入口处检查。 用 command -v 检查要用的命令,缺少时直接告诉使用者怎么安装,不要等到中途才失败。
  • 注释只写原因。 代码已经说明了在做什么,注释解释为什么这样做。
  • 变量一律加引号。 "$var"、"${arr[@]}"。路径参数前加 --,防止以 - 开头的值被当成选项。

六、常见陷阱

  • &&、|| 链里的失败不会触发 set -e。 必须失败即停的链写成 a && b || die '原因'。
  • local x=$(cmd) 会吞掉退出码。 先写 local x,再写 x=$(cmd)。
  • 命令替换直接作为参数时,失败不会让脚本停下。 例如 tool "$(生成参数)"。先赋给变量,再使用。
  • pipefail 下,cmd | grep -q 可能因为 SIGPIPE 得到错误的退出码。 先 out=$(cmd),再用 case 或 grep 处理这个变量。
  • 函数或循环的最后一行是 [ 条件 ] && 动作 时,条件为假会让返回值非零。 改用 if。
  • 通配符没有匹配时会原样保留。 使用前先用 [ -e ] 判断,或者开启 nullglob。
  • 相对路径依赖当前目录。 给函数和外部命令传完整路径。很多工具遇到不存在的路径会默默新建,例如 sqlite3 会新建一个空库,检查反而“通过”。
  • cd 会影响后面的所有命令。 需要时放进子 shell:(cd dir && cmd)。
  • sudo cmd > file 的重定向不在 sudo 里执行。 改用 cmd | sudo tee file。
  • printf '%-10s' 按字节计算宽度。 中文等多字节字符会对不齐,改用“名称:值”的格式。
  • heredoc 是否展开变量取决于结束标记的引号。 <<'EOF' 不展开,<<EOF 展开。嵌入其他语言的代码时用带引号的形式。
  • Windows 编辑的文件可能带 CRLF。 脚本和要比较的命令输出都要确认没有 \r。

七、验证

  • bash -n 只能检查语法。 能装 shellcheck 就跑一遍,但它也代替不了实际运行。
  • 没有真实环境时搭沙箱。 把路径常量改到临时目录,在 PATH 前面放桩命令代替 systemctl、curl、sudo 等,核心逻辑用真实依赖执行。
  • 逐个场景运行。 正常流程、使用者取消、每一种检查失败、恢复、重复执行。每个场景结束后检查服务状态、目录和文件内容,不只看退出码。
  • 测试本身也要可信。 一项检查从来没失败过时,先确认它真的检查到了东西。
  • 写清楚没验证的部分。 哪些靠桩代替、哪些只能在真实环境确认,都要明确写出来,不要把沙箱结果当作真实环境的结果。
记录于此。