自己写了个一键部署脚本,能动态选版本

​本文来自我的个人博客:自己写了个一键部署脚本,能动态选版本 - 叹惋博客

为什么要自己写部署脚本

买了新服务器如果要部署项目,第一件事是什么?装环境。

Nginx、MySQL、PHP、Docker、Node.js……一个一个装下来,少说十几条命令。装完之后还得配防火墙、改权限。这些事情做一次两次还好,做多了就变成纯粹的重复劳动(对于我这种喜欢经常换服务器的人来说)。最近我又去雨云买了一台服务器,我又想到这个问题了。

网上有一键安装包,比如 oneinstack、lnmp.org 那些。但它们有个共同的问题:你基本不知道它在你服务器上干了什么。装完之后文件散落在哪里、改了哪些配置、装了什么版本,全都是黑箱。一旦出了故障,你连从哪里开始排查都不知道。

另一个痛点是版本。很多一键脚本把版本号写死在代码里,比如 nginx_version="1.18.0"。过半年官方出了 1.26,你想装新版?要么等作者更新脚本,要么自己改代码。自己改还有个风险——改完能不能跑通是另一回事。

上周,我在雨云购买了一台云服务器,他们家的服务器是可以预装LNMP和docker的。

但是我最终还是选择安装了雨云提供的纯净镜像,自己再去装LNMP和docker,原因在于,雨云提供的预装的镜像都是有固定的系统和软件版本的,无法自己随心安装使用。

所以我想(在AI帮助下)写一个完全透明、能动态获取版本、结构清晰到任何人都能看懂的部署脚本。这就是 LXDeploy 的由来。也欢迎各位雨云和其他云服务商用户前来体验。

整体架构:分层设计

整个项目分成两层:

install.sh(安装器) 负责一件事:把主脚本拉到用户机器上,加到 PATH 里。它不留下多余的配置文件,不污染系统目录。用户执行完 curl | bash 之后,得到的只是一个 lxdeploy 命令。

lxdeploy.sh(主脚本) 才是核心。它的结构是这样的:

主菜单(main_menu)
    ├── 工具函数层(颜色、日志、系统检测、IP获取)
    ├── 版本获取层(fetch_*_versions)
    ├── 版本选择层(select_version)
    ├── 幂等检查层(is_installed)
    └── 安装执行层(deploy_lnmp / install_docker / ...)

每一层只做一件事,层与层之间通过变量传递数据。比如版本选择层只负责把用户选的版本号存到 SELECTED_VERSION 里,安装执行层只负责读这个变量然后去装。这样拆开的好处是:哪天你想换一种版本获取方式(比如不从 apt-cache 抓,改从官网 API 抓),只需要改 fetch_* 函数,其他代码一行都不用动。

一、动态版本获取

1.1 一个容易被忽略的痛点

大多数一键脚本会把版本号写死在代码里,比如:

NGINX_VERSION="1.18.0"
PHP_VERSION="7.4.33"

这种做法有两个问题。第一,官方发布新版本之后,用户想用新版只能等作者更新脚本;第二,用户根本没得选,装什么版本完全由脚本作者决定。

有人可能会说:“那我把版本号做成配置文件让用户自己填不就行了吗?”这确实是一个改进,但本质上还是在要求用户“自己去查版本号是什么”。用户需要先知道有哪些版本可用,然后才能决定装哪个——这又把负担推回给了用户。

LXDeploy 的做法是:每次运行时,直接从包管理器里拉取当前所有可用版本,让用户当场选

1.2 apt-cache madison 和 yum --showduplicates 的原理

在 Debian/Ubuntu 系统上,用的是 apt-cache madison 命令。apt-cache 是 APT 包管理工具集中的一个命令,它不修改系统状态,只负责查询和展示本地缓存的软件包元数据。这些元数据是之前执行 apt update 时从配置的软件源下载下来的。madisonapt-cache 的一个子命令,它的设计初衷是模仿 Debian 官方仓库管理工具 madison 的输出格式,以表格形式展示一个软件包的所有可用版本。简单说,apt-cache madison nginx 的输出就是当前系统配置的所有软件源里,nginx 这个包有哪些版本可以装。

在 CentOS/RHEL 系统上,对应的是 yum --showduplicates list nginx--showduplicates 这个参数告诉 yum,在列出软件包时不要只显示最新版本,而是把仓库里所有的版本都显示出来。yum 的仓库元数据是由 createrepo 工具生成的,里面包含了每个 RPM 包的版本号、依赖关系等信息。加上 --showduplicates 之后,yum 就会把这些元数据里的所有版本都呈现出来。

获取到版本列表之后,再用 sort -Vr 做一次版本号排序。-V 是“版本号感知”的排序,能正确处理 1.101.9 这种数字比较;-r 是倒序,最新的排在最前面。

1.3 为什么 Node.js 的获取方式不一样

Node.js 的情况比较特殊。如果用 apt 或 yum 去装 Node.js,能获取到的版本取决于你添加的软件源——nodesource 的源通常只提供当前 LTS 版本,不会把所有历史版本都放上去。

所以 LXDeploy 换了一种思路:直接从 Node.js 官网的发布目录抓取版本列表。

curl -s https://nodejs.org/dist/ | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | cut -c2- | sort -Vr | head -30

这条命令做的事情是:请求 Node.js 官方发布页的 HTML,用正则表达式匹配出所有 vx.y.z 格式的版本号,去掉前缀 v,排序,只取前 30 个。这样不管官方发布了什么版本,脚本都能感知到。而且只取前 30 个是为了避免列表太长,实际使用中 30 个版本已经远远够用了。

1.4 偏移量选择:不让用户背版本号

有了版本列表之后,怎么让用户选是个交互设计问题。最直接的做法是让用户输入版本号,比如 1.26.0。但这就要求用户记住版本号长什么样——1.26.0-1~focal 这种东西,正常人记不住。

LXDeploy 设计了一套偏移量选择:

  • 输入 0 → 选列表第一项(最新版)
  • 输入 -1 → 选列表第二项(上一个版本)
  • 输入 -2 → 选列表第三项(上上个版本)

这套设计的出发点是:用户真正需要的不是“记住版本号”,而是“选一个稳定的版本”。最新版出了问题就退一个,再有问题再退一个,很方便啊也是。

二、幂等性

2.1 什么是幂等性

幂等性(Idempotence)这个词来自数学,在计算机领域的意思是:同一个操作执行多次和执行一次,产生的效果完全相同

放在部署脚本的场景里,就是:不管用户跑一次脚本还是一百次脚本,服务器的最终状态应该是一致的

为什么要强调这个?因为用户在实际使用中,几乎一定会跑第二次。第一次装完环境之后,过了一段时间想加装一个 Redis,用户不会去翻文档找“只装 Redis”的命令——他最自然的做法是重新跑一遍整个脚本。如果脚本没有幂等性,第二次跑的时候就会出问题:文件已存在、端口被占用、服务已启动……各种报错。

2.2 实现方式:先查后做

LXDeploy 实现幂等性的方式很简单:每个操作执行之前,先检查目标是否已经达成。

is_installed() {
    local pkg=$1
    if [[ $PM == "apt" ]]; then
        dpkg -l $pkg 2>/dev/null | grep -q "^ii"
    else
        rpm -q $pkg 2>/dev/null | grep -q "is installed"
    fi
}

dpkg -l 列出所有已安装的包,grep "^ii" 匹配状态为“已安装”的记录。rpm -q 直接查询某个包是否已安装。两个命令都加了 2>/dev/null 把错误输出丢掉,避免查询不存在的包时打印警告。

每个安装函数在动手之前都会调用这个检查:

if is_installed nginx && [[ -z "$nginx_ver" ]]; then
    echo "Nginx 已安装,跳过"
else
    # 执行安装
fi

这里还有一个细节:&& [[ -z "$nginx_ver" ]] 的意思是“如果用户没有指定特定版本,才跳过”。如果用户指定了某个版本,即使系统里已经装了 Nginx,脚本也会尝试安装指定版本——这给了用户“降级”或“升级”到特定版本的能力。

2.3 幂等性的边界

需要说明的是,LXDeploy 的幂等性是“安装级别”的,不是“配置级别”的。也就是说,脚本能保证“不会重复安装同一个软件包”,但不能保证“配置文件始终保持一致”。如果用户手动修改了 Nginx 的配置文件,再次运行脚本不会去覆盖它——因为脚本的设计目标是“装环境”,不是“配置管理”。

这个取舍是故意的。配置管理是 Ansible、Puppet 这类工具擅长的领域,一个 Bash 脚本强行去做配置管理,只会把事情搞复杂。

三、跨发行版兼容

3.1 两大包管理器家族的差异

Linux 发行版虽然都叫 Linux,但包管理器的差异很大。Debian/Ubuntu 用 apt,CentOS/RHEL 用 yum。这两个包管理器不仅命令不同,包名也可能不同——比如 p7zip-full 在 Ubuntu 上叫这个名字,在 CentOS 上叫 p7zip

LXDeploy 的处理方式是先检测系统类型,然后根据类型走不同的分支。

检测的依据是 /etc/os-release 文件。这是 systemd 引入的标准文件,几乎所有现代 Linux 发行版都有,里面定义了 ID(发行版标识)和 VERSION_ID(版本号)等字段。对于不支持 /etc/os-release 的旧系统(比如 CentOS 6),回退到读取 /etc/redhat-release

if [[ -f /etc/os-release ]]; then
    . /etc/os-release
    OS=$ID
    OS_VERSION=$VERSION_ID
elif [[ -f /etc/redhat-release ]]; then
    OS="centos"
    OS_VERSION=$(rpm -q --qf "%{VERSION}" $(rpm -q --whatprovides redhat-release) | cut -d. -f1)
fi

检测到系统类型之后,设置 PM 变量为 aptyum,后续所有包管理操作都通过这个变量来路由。

3.2 包名不一致的处理

包名不一致的问题更隐蔽。同样是安装 PHP 的 MySQL 扩展,Ubuntu 上的包名是 php-mysql,CentOS 上是 php-mysqlnd。如果写死了包名,换一个系统就装不上。

LXDeploy 的处理方式是在安装函数里根据 PM 变量走不同的分支:

if [[ $PM == "apt" ]]; then
    apt install -y php-mysql php-curl ...
else
    yum install -y php-mysqlnd php-curl ...
fi

这种做法虽然不够优雅(每安装一个软件都要写两套包名),但胜在清晰——用户看代码的时候能一眼看出不同系统上装的是什么包,出了问题也容易定位。

3.3 为什么不直接用 Ansible

有人可能会问:既然跨发行版兼容这么麻烦,为什么不直接用 Ansible?

Ansible 确实能优雅地解决这些问题——它有专门的 package 模块抽象了不同包管理器的差异,有 ansible_facts 自动收集系统信息。但 Ansible 的代价是:用户需要先装 Python、配 inventory、写 playbook。对于一个只想“快速搭个环境”的用户来说,这个门槛太高了。

Bash 脚本的优势就是零依赖——任何 Linux 系统都自带 bash,不需要额外安装任何东西。用户拿到脚本就能跑,没有任何前置条件。代价就是跨发行版的兼容性需要自己处理。

四、错误处理

4.1 错误处理的两种思路

Bash 脚本的错误处理有两种常见的哲学。

一种是“严格模式”:在脚本开头加上 set -euo pipefail,让脚本在任何命令失败时立即退出。这种做法的好处是能尽早发现问题,坏处是脚本变得很脆弱——任何一个非关键命令失败都会导致整个脚本中断。

另一种是“容错模式”:每个可能失败的命令都单独检查返回值,根据情况决定是继续还是退出。这种做法的好处是灵活,坏处是代码会变得很冗长。

LXDeploy 走的是中间路线:关键操作严格检查,非关键操作容错处理

4.2 关键操作 vs 非关键操作

什么是关键操作?软件安装就是关键操作。如果 Nginx 没装上,后续的配置都没有意义。所以安装命令会检查返回值:

apt install -y nginx
if [[ $? -ne 0 ]]; then
    echo "Nginx 安装失败,请检查网络或软件源"
    exit 1
fi

什么是非关键操作?防火墙放行就是非关键操作。有些系统可能根本没装防火墙,放行命令失败不代表环境有问题。所以防火墙操作会加 2>/dev/null 静默掉错误:

ufw allow 80/tcp 2>/dev/null

4.3 版本指定失败的降级策略

版本指定安装是一个比较特殊的场景。apt install nginx=1.26.0 这个命令有可能失败——可能是因为版本号写错了,可能是因为软件源里没有这个版本,也可能是因为版本号里包含特殊字符。

LXDeploy 的处理方式是“尝试指定版本,失败则降级到默认版本”:

apt install -y nginx=${nginx_ver} 2>/dev/null || apt install -y nginx

|| 左边的命令失败时,会自动执行右边的命令。这样用户选的版本能装上最好,装不上也不至于整个脚本崩溃,而是回退到装默认版本。

这个设计的出发点是:用户选择版本是“期望”不是“强约束”。用户说想装 1.26,但如果因为某些原因装不上,装 1.24 也比什么都不装强。

五、国内镜像源

5.1 被忽视的网络问题

写部署脚本的人很容易忽视一个问题:很多用户的服务器在国内,从国外软件源拉取软件包的速度非常慢。一个几十 MB 的包可能要下十几分钟,整个部署流程被网络延迟拖垮。

这个问题在技术上并不复杂——把软件源换成国内的镜像就行了。但很多脚本没有这个功能,用户只能自己去改 /etc/apt/sources.list 或者 /etc/yum.repos.d/ 下的文件。

5.2 实现方式

LXDeploy 的“切换国内镜像源”功能做的事情很简单:把 apt 或 yum 的源地址替换成阿里云的镜像地址。

对于 Ubuntu,把 archive.ubuntu.com 换成 mirrors.aliyun.com;对于 CentOS,把 mirror.centos.org 换成 mirrors.aliyun.com。替换之前先备份原文件,万一出了问题用户可以恢复。

这个功能的技术含量不高,但在实际使用中,它可能是用户最常用的功能之一。下载速度从几十 KB/s 提升到几 MB/s,体验差别非常大。

这也引出一个更通用的设计原则:功能的价值不取决于技术难度,而取决于它解决了什么问题。一个简单的镜像源切换,可能比一个复杂的负载均衡配置对用户更有用。

六、总结

其实我觉得一个脚本功能多的不一定好。OneinStack 支持 LEMP、LAMP、LNMP、LNMPA、LTMP 等好几种组合,功能确实多,但用户真的需要这么多选择吗?大部分用户只是想装一个能跑 PHP 网站的环境,给他十几个选项反而增加了决策负担。

技术复杂的不一定好。用 Ansible 写部署脚本确实更优雅,但用户需要先学 Ansible。Bash 脚本虽然原始,但用户拿到就能看懂、就能改——这才是最重要的。

设计LXDeploy前,我让deepseek给了我三个要注意的点,我自己又修改了一下,也是按照这些要点写的:

1.让用户知道脚本在做什么。 所有操作都有明确的输出,所有选择都有清晰的提示。用户不是面对一个黑箱,而是在和一个透明的工具交互。

2.让用户有选择的权利。 版本可以选,镜像源可以切,安装可以跳过已存在的组件。脚本不替用户做决定,而是帮用户做决定。

3.让脚本能安全地反复运行。 幂等性不是锦上添花,而是基本要求。用户不应该因为“跑了一遍脚本”就被迫重装系统。

这三个原则听起来很简单,但在实际实现中,每一个都需要在代码层面认真对待。动态版本获取需要处理不同包管理器的差异,幂等性需要在每个操作前做状态检查,透明化意味着每个步骤都要有清晰的输出。

这些工作单独看都不算什么高深的技术,但合在一起,就构成了一个“让人放心”的工具。而这,正是 LXDeploy 想要达到的目标。

七、使用截图

安装脚本:

主脚本:

LNMP选择版本:

安装完成展示:


项目地址:https://github.com/gzy318/LXDeploy

1 个赞