确保 Bash 脚本在不同系统间的可移植性

编写可移植的 Bash 脚本,处理 GNU、BSD 和 BusyBox 在 Linux、macOS 和 CI 环境中的差异。

确保 Bash 脚本在不同系统间的可移植性

编写能在笔记本电脑、Linux 服务器和 CI 运行器上正常工作的 Bash 脚本比看起来要困难得多。Bash 脚本的可移植性通常会在细微差异上出现问题:一个在 Linux 上正常但在 macOS 上失败的 sed -i 标志,一个仅存在于 GNU coreutils 中的 date 选项,或者一个假设 /bin/bash 是您测试过的版本的脚本。

核心难点在于 Bash 只是环境的一部分。Linux 通常附带 GNU 工具。macOS 附带 BSD 风格的工具。基于 BusyBox 的容器可能提供功能更少的较小实现。您的脚本需要明确其要求。

本指南专注于 Bash 脚本,而非严格的 POSIX sh 脚本。如果您需要真正的 /bin/sh 可移植性,请完全避免使用 Bash 专属语法,并使用 dash 等 shell 进行测试。

从明确的 Shell 约定开始

使用与您的意图匹配的 shebang。如果脚本需要 Bash,请明确说明:

#!/usr/bin/env bash

/usr/bin/env 通过 $PATH 定位 Bash,当用户在 /bin 之外安装了更新的 Bash 时,这很有用。如果您的生产主机需要固定的解释器路径,请记录并强制执行该路径。

严格模式可以及早捕获许多错误,但它并非万能:

#!/usr/bin/env bash

set -euo pipefail
IFS=$'\n\t'

这些选项有帮助,但有一些注意事项:

  • -e:当许多简单命令返回非零状态时退出。
  • -u:将未设置的变量视为错误。
  • pipefail:如果管道中的任何命令失败,则使整个管道失败。

显式处理预期失败:

if ! grep -q "ready" "$log_file"; then
    echo "服务尚未就绪"
fi

了解您的 Bash 版本

不要意外依赖目标系统没有的 Bash 功能。macOS 历史上在 /bin/bash 中附带了较旧的 Bash,而许多 Linux 发行版则附带了较新的版本。

需要谨慎使用的功能包括:

  • 关联数组。
  • 高级通配符,例如 **
  • 进程替换,例如 <(command)
  • 较新的参数扩展行为。

如果您需要最低 Bash 版本,请在脚本顶部附近进行检查:

if (( BASH_VERSINFO[0] < 4 )); then
    echo "此脚本需要 Bash 4 或更新版本。" >&2
    exit 1
fi

处理 GNU、BSD 和 BusyBox 差异

最大的可移植性问题通常来自外部命令,而非 Bash 本身。

sed -i

GNU sed 接受不带备份扩展名的 -i。macOS 上的 BSD sed 要求在 -i 之后提供扩展名参数,即使该扩展名是空字符串。

file="data.txt"
pattern="s/error/success/g"

case "$(uname -s)" in
    Darwin)
        sed -i '' "$pattern" "$file"
        ;;
    *)
        sed -i "$pattern" "$file"
        ;;
esac

对于关键脚本,更安全的模式是写入临时文件,然后将其移动到目标位置。这样可以完全避免依赖原地编辑行为。

date

不同系统上的日期计算不同:

目标 GNU date macOS 上的 BSD date
30 天前 date -d "30 days ago" +%Y%m%d date -v-30d +%Y%m%d

如果您的脚本需要复杂的日期计算,请使用一致的依赖项,例如 Python,或者在 macOS 上要求 GNU coreutils 并显式调用 gdate。不要静默地假设 date -d 存在。

grepfindxargs

尽可能坚持使用广泛支持的选项:

  • 使用 grep -E 而不是依赖 egrep
  • 避免使用 grep -P,除非您检查具有 PCRE 支持的 GNU grep。
  • 注意 GNU 和 BSD 实现之间不同的 find 谓词。
  • 在支持的情况下,优先使用以 null 分隔的管道处理文件名:
find "$root" -type f -name '*.log' -print0 | xargs -0 rm -f

管理依赖项和路径

使用 $PATH 进行正常的命令查找,但在执行工作之前检查所需的工具:

check_dependency() {
    if ! command -v "$1" >/dev/null 2>&1; then
        echo "错误:未找到所需命令 '$1'。" >&2
        exit 1
    fi
}

check_dependency jq
check_dependency curl

优先使用 command -v 而不是 which,因为它是 Bash 中的内置命令,在脚本中行为更可预测。

除非您有意进行单词拆分,否则请引用变量:

cp "$source_file" "$target_dir/"

这对于像 Project Files/report.txt 这样的路径很重要,并且还可以保护您免受意外输入中的通配符扩展的影响。

安全使用临时文件

使用 mktemp 进行临时工作。一个简单、可移植的模式是创建一个临时目录并将文件放入其中:

tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT

tmp_file="$tmp_dir/output.txt"
some_command > "$tmp_file"

单引号的 trap 可以防止 $tmp_dir 在 trap 运行之前被展开。由于变量仍在作用域内,清理操作会删除正确的目录。

注意行尾和文件系统大小写

在 Windows 上编辑的脚本可能使用 CRLF 行尾。一个常见的症状是:

/usr/bin/env: bash\r: No such file or directory

配置您的编辑器以使用 LF 行尾保存 shell 脚本,或者在构建过程中运行 dos2unix

还要记住,大多数 Linux 文件系统默认区分大小写,而 macOS 的默认 APFS 设置通常不区分大小写。如果您的脚本写入 Config.yml 然后读取 config.yml,它可能在您的 Mac 上正常工作,但在 Linux 上失败。

在您支持的系统上进行测试

最好的可移植性检查是一个小的测试矩阵:

  • 使用 GNU 工具的 Linux。
  • 使用 BSD 工具的 macOS。
  • 如果您的脚本在 Alpine 或 BusyBox 环境中运行,则使用最小容器。

也要运行 ShellCheck。它不会捕获所有平台问题,但会在用户发现之前捕获许多引用、未定义变量和脆弱的命令模式。

要点

Bash 脚本的可移植性来自于明确您的假设。选择 shell,检查依赖项,引用变量,避免仅限 GNU 的标志(除非您需要它们),并在用户运行的操作系统上进行测试。一个包含 Linux 和 macOS 的小型 CI 矩阵可以在您的自动化投入生产之前捕获大多数可移植性错误。