系统工具安装脚本设计:`curl | sh` 不是什么好习惯,但可以做得更好 系统工具安装脚本设计curl | sh不是什么好习惯但可以做得更好标签技术、系统工具、Shell、安装脚本、安全一、curl | sh为什么被骂了这么多年还用但现实是从 Rust 的 rustup 到 Node.js 的 nvm从 Homebrew 到 oh-my-zsh几乎所有主流开发工具的安装方式都是curl ... | sh。为什么因为好用。包管理器分发渠道apt、brew、chocolatey不够快GitHub Release 的二进制要手动下载解压配置 PATH对开发者来说一行命令搞定的体验实在太香了。我就直面这个问题既然这个模式不会消失我们能怎么把它做得更安全、更可靠这篇文章是我自己在设计一个 Rust 开发工具安装脚本时的一些思考和实践。不算最佳实践就是分享一下踩过的坑。二、一个安装脚本需要解决的问题在动手写之前我先梳理了一个合格的安装脚本应该做到哪些事flowchart TD Start[用户执行 curl | sh] -- Check{第一步环境检查} Check --|失败| Error1[给出明确的错误提示并退出] Check --|通过| Download{第二步下载二进制} Download --|失败| Error2[尝试备用下载源 / 给明确错误] Download --|成功| Verify{第三步校验完整性} Verify --|校验失败| Error3[拒绝安装报告风险] Verify --|校验通过| Install{第四步安装 配置} Install --|安装成功| Success[提示用户安装完成] Install --|部分失败| Rollback[尝试回滚残留文件] Error1 -- Exit{非 0 退出码} Error2 -- Exit Error3 -- Exit Rollback -- Exit Success -- ExitSuccess[退出码 0] style Start fill:#e3f2fd style Success fill:#c8e6c9 style Error1 fill:#ffcdd2 style Error2 fill:#ffcdd2 style Error3 fill:#ffcdd2四个步骤环环相扣缺一不可环境检查要确认操作系统、架构、依赖都满足要求。下载要有主备两个下载源网络不稳定也要能装。校验下载的文件要对得上预期哈希。安装 回滚装到一半失败了不能留下一堆垃圾。三、动手写安装脚本下面是我写的一个安装脚本目标是把 Rust 编译的二进制安装到系统里。每一步的解释都在注释里。#!/bin/sh # # 工具安装脚本 # 用法curl -fsSL https://example.com/install.sh | sh # # 设计原则 # 1. 每一步都有明确的错误处理set -e 手动检查 # 2. 有备用下载源单个源挂了不影响安装 # 3. 文件下载后校验 SHA256防止被篡改 # 4. 安装失败时清理残留不污染用户环境 # 5. 输出清晰的信息让用户知道每一步在干什么 # set -e # 任何命令返回非 0 就退出有手动回滚的地方除外 set -u # 使用未定义变量时报错 # ------------------ 可配置参数 ------------------ # 工具的 GitHub Release 地址 REPOhappyphper/my-rust-tool # 工具的二进制名称 BINARY_NAMEmy-rust-tool # 安装目录默认 ~/.local/bin INSTALL_DIR${HOME}/.local/bin # 备用下载源列表GitHub 主站挂了用镜像 DOWNLOAD_URLS https://github.com/${REPO}/releases/latest/download/${BINARY_NAME}-\${ARCH}-\${OS}.tar.gz https://ghproxy.com/https://github.com/${REPO}/releases/latest/download/${BINARY_NAME}-\${ARCH}-\${OS}.tar.gz # SHA256 校验文件的地址 CHECKSUM_URLhttps://github.com/${REPO}/releases/latest/download/SHA256SUMS # ------------------ 辅助函数 ------------------ # 输出带颜色和前缀的日志信息 info() { # 蓝色前缀方便用户在终端里识别 printf \033[34m[INFO]\033[0m %s\n $1 } warn() { # 黄色前缀表示需要注意但不致命 printf \033[33m[WARN]\033[0m %s\n $1 } error() { # 红色前缀致命错误 printf \033[31m[ERROR]\033[0m %s\n $1 2 } # 获取当前操作系统名称linux / darwin get_os() { local os os$(uname -s | tr [:upper:] [:lower:]) case $os in linux) echo linux ;; darwin) echo darwin ;; *) error 不支持的操作系统: $os exit 1 ;; esac } # 获取当前 CPU 架构amd64 / arm64 get_arch() { local arch arch$(uname -m) case $arch in x86_64|amd64) echo amd64 ;; aarch64|arm64) echo arm64 ;; *) error 不支持的 CPU 架构: $arch exit 1 ;; esac } # ------------------ 第一步环境检查 ------------------ info 正在检查系统环境... OS$(get_os) ARCH$(get_arch) info 检测到系统${OS} / ${ARCH} # 检查必要的命令是否存在 # curl 用于下载tar 用于解压这些是必备工具 for cmd in curl tar; do if ! command -v $cmd /dev/null 21; then error 缺少必要命令: $cmd请先安装 exit 1 fi done # 检查安装目录是否存在不存在就创建 if [ ! -d $INSTALL_DIR ]; then info 创建安装目录: $INSTALL_DIR mkdir -p $INSTALL_DIR fi # 检查安装目录是否在 PATH 中 case :$PATH: in *:$INSTALL_DIR:*) info 安装目录已在 PATH 中 ;; *) warn 安装目录 $INSTALL_DIR 不在 PATH 中 warn 请将以下行添加到你的 ~/.bashrc 或 ~/.zshrc warn export PATH\\$HOME/.local/bin:\$PATH\ ;; esac # ------------------ 第二步下载二进制 ------------------ info 正在下载 ${BINARY_NAME}... # 创建临时目录用于下载 TMP_DIR$(mktemp -d) # 注册退出时的清理函数确保临时目录被删除 # trap 确保即使脚本异常退出也会执行清理 cleanup() { info 清理临时文件... rm -rf $TMP_DIR } trap cleanup EXIT TARBALL${TMP_DIR}/${BINARY_NAME}.tar.gz DOWNLOAD_SUCCESSfalse # 逐一尝试下载地址直到有一个成功 for base_url in $DOWNLOAD_URLS; do # 替换 URL 中的占位符 url$(echo $base_url | sed s/\${ARCH}/${ARCH}/g | sed s/\${OS}/${OS}/g) info 尝试下载$url # -f: 失败不输出 HTML 错误页 # -s: 静默模式不显示进度条 # -S: 与 -s 配合显示错误信息 # -L: 跟随重定向 # --connect-timeout: 连接超时 if curl -fsSL --connect-timeout 10 --max-time 60 \ -o $TARBALL $url; then DOWNLOAD_SUCCESStrue info 下载成功 break else warn 从 $url 下载失败尝试备用地址... fi done if [ $DOWNLOAD_SUCCESS false ]; then error 所有下载地址均失败请检查网络连接或稍后重试 exit 1 fi # ------------------ 第三步校验 SHA256 ------------------ info 正在校验文件完整性... # 下载 SHA256SUMS 文件用于校验 CHECKSUM_FILE${TMP_DIR}/SHA256SUMS if curl -fsSL --connect-timeout 10 \ -o $CHECKSUM_FILE $CHECKSUM_URL; then # 从校验文件中提取当前平台的哈希值 local expected_hash expected_hash$(grep ${BINARY_NAME}-${ARCH}-${OS} $CHECKSUM_FILE | awk {print $1}) if [ -z $expected_hash ]; then warn 校验文件中未找到该平台的哈希值跳过校验 else # 计算下载文件的实际 SHA256 # 根据系统不同使用 shasum 或 sha256sum if command -v shasum /dev/null 21; then actual_hash$(shasum -a 256 $TARBALL | awk {print $1}) else actual_hash$(sha256sum $TARBALL | awk {print $1}) fi if [ $expected_hash $actual_hash ]; then info SHA256 校验通过 else error SHA256 校验失败文件可能已被篡改或下载不完整 error 期望: $expected_hash error 实际: $actual_hash exit 1 fi fi else warn 无法下载校验文件跳过 SHA256 校验 fi # ------------------ 第四步安装 ------------------ info 正在安装到 $INSTALL_DIR... # 解压到临时目录 tar -xzf $TARBALL -C $TMP_DIR # 备份旧版本如果存在 if [ -f $INSTALL_DIR/$BINARY_NAME ]; then BACKUP${INSTALL_DIR}/${BINARY_NAME}.backup.$$ info 备份旧版本: $BACKUP mv $INSTALL_DIR/$BINARY_NAME $BACKUP fi # 复制新二进制文件到安装目录 # 使用 || 而不是 set e这样我们可以精确控制回滚 if cp $TMP_DIR/$BINARY_NAME $INSTALL_DIR/$BINARY_NAME 2/dev/null; then # 设置可执行权限 chmod x $INSTALL_DIR/$BINARY_NAME info 安装完成 else # 安装失败尝试回滚 error 复制文件失败尝试回滚... if [ -f $BACKUP ]; then mv $BACKUP $INSTALL_DIR/$BINARY_NAME info 已恢复旧版本 fi exit 1 fi # 删除备份文件安装成功备份不需要了 if [ -f $BACKUP ]; then rm $BACKUP fi # ------------------ 安装后提示 ------------------ echo info info ${BINARY_NAME} 安装成功 info 位置: ${INSTALL_DIR}/${BINARY_NAME} info # 检查是否需要更新 PATH if ! command -v $BINARY_NAME /dev/null 21; then warn 请运行以下命令或添加到 shell 配置文件中 warn warn export PATH\\$HOME/.local/bin:\$PATH\ warn fi # 提示版本信息 if command -v ${INSTALL_DIR}/${BINARY_NAME} /dev/null 21; then info 版本信息 ${INSTALL_DIR}/${BINARY_NAME} --version 2/dev/null || true fi info 四、还能做得更好几个进阶想法写完后我发现上面的脚本还不够极品。有几个方向可以继续改进1. 签名验证而非仅哈希验证SHA256 能防止文件损坏但防不了攻击者连校验文件一起替换的情况。真正安全的做法是 GPG 签名验证flowchart LR subgraph 哈希校验[仅 SHA256 校验] A1[下载文件] -- A2[下载 SHA256SUMS] A2 -- A3[对比哈希] A3 -- A4[结果能防传输损坏防不了中间人攻击] end subgraph 签名校验[SHA256 GPG 签名] B1[下载文件] -- B2[下载 SHA256SUMS .asc 签名] B2 -- B3[用公钥验证签名] B3 -- B4[对比哈希] B4 -- B5[结果能确认文件确实来自开发者] end style A4 fill:#fff3e0 style B5 fill:#c8e6c92. 先展示脚本再执行网上有个很有趣的做法把 URL 里的| sh换成| cat让用户自己先看一遍# 第一步先看脚本内容 curl -fsSL https://example.com/install.sh # 第二步确认没问题再执行 curl -fsSL https://example.com/install.sh | sh虽然大多数用户不会真的去看但这个习惯值得培养。3. 包管理器优先如果用户用的是 macOS优先尝试 brew如果是 Debian/Ubuntu试试 apt。安装脚本应该是最后的兜底方案# 伪代码 if command -v brew /dev/null; then brew install my-rust-tool # 最优方案 elif command -v apt-get /dev/null; then apt-get install my-rust-tool # 次优 else # 兜底curl | sh curl ... | sh fi4. 幂等性脚本应该能反复执行不出错。已经安装过的要么跳过要么覆盖更新不能报错。上面脚本里做的备份/回滚机制就是为幂等服务的。我在项目里就踩过这个坑脚本第二次运行时因为备份文件已存在导致mv失败整个安装流程中断。后来加了mv -f强制覆盖同时在脚本开头检测已有版本号同版本直接跳过——这才是真正的幂等。五、总结curl | sh这件事社区吵了很久。但我觉得核心问题不是该不该用而是怎么用好。作为开发者设计安装脚本时至少做到明确的错误提示不要让用户猜为什么安装失败。备用下载源网络环境千差万别一个源挂了不应该阻碍安装。文件校验SHA256 是最低要求有能力上 GPG 签名更好。失败回滚安装到了一半不能留下脏数据。清晰的输出让用户知道脚本在干什么装去哪了下一步要做什么。另外作为用户我自己现在也会养成习惯先curl | cat看一下再curl | sh执行。虽然绝大多数脚本没问题但这个习惯能让我多了解一点自己在运行什么。我还是个自学的新手这些想法都是边学边用积累下来的有不对的地方欢迎大家指正。