返回文章归档
ShellLinuxDevOps自动化运维

Shell 项目复盘

复盘一套覆盖负载均衡、Web、数据库、Redis、NFS 与备份节点的 Shell 自动化项目,分析脚本化部署的价值、边界和演进方向。

学习 Linux 运维时,我们通常先从一条条命令开始:安装软件包、修改配置文件、启动服务、检查端口。单台服务器上,这种方式足够直接;但当环境扩展到多台服务器,并且服务之间开始产生依赖,问题就不再只是“命令会不会写”,而是“部署过程能不能被重复执行”。

我的 linux_shell 项目,就是对这个问题的一次实践。

它以 Shell 脚本为基础,尝试部署一套包含负载均衡、Web 应用、数据库、缓存、共享存储和备份服务的完整环境。项目中没有复杂的平台,也没有成熟的编排引擎,核心方法很朴素:先梳理服务器职责,再把每个节点上的手工操作固化为脚本,最后按照依赖顺序完成整套环境部署。

这套项目后来也成为我编写 Ansible 自动化项目的重要基础。回头看,它最有价值的地方并不是节省了多少次键盘输入,而是让我第一次真正理解:自动化的本质,是把系统架构、依赖关系和操作顺序写进代码。

项目要解决什么问题

项目模拟的是一套常见的中小型 Web 业务架构,共有 8 台业务服务器:

节点 主要职责
lb01、lb02 Nginx 负载均衡与 Keepalived 高可用
web01、web02 Nginx、PHP-FPM 和 Web 业务
nfs01 NFS 共享存储与 Lsyncd 实时同步
backup01 Rsync 备份接收端
db01 MariaDB 数据库
rd01 Redis 缓存与 Session 存储

此外还有一台跳板机,用于统一登录和执行初始化操作。

如果完全手工部署,需要在不同服务器上反复完成以下工作:

  1. 创建用户与目录。
  2. 安装并配置 Nginx、PHP、MariaDB、Redis 等服务。
  3. 配置 NFS 挂载和文件权限。
  4. 部署 WordPress 等 Web 业务。
  5. 配置负载均衡与虚拟 IP。
  6. 建立 NFS 到备份服务器的同步链路。
  7. 启动服务并设置开机自启。

这些操作单独看并不复杂,真正麻烦的是它们不能随意调换顺序。

例如,Web 节点挂载共享目录之前,NFS 服务必须已经可用;应用连接数据库之前,数据库用户和权限必须创建完成;Lsyncd 启动之前,Rsync 服务端必须准备好认证信息和备份目录;Keepalived 接管虚拟 IP 之前,Nginx 必须能够正常提供服务。

因此,项目首先要解决的不是“如何写一个很长的脚本”,而是“如何描述整套系统的部署链路”。

先画出服务依赖关系

这套架构可以拆成四层:

访问入口
  └─ lb01 / lb02
       ├─ Nginx 负载均衡
       └─ Keepalived 虚拟 IP

业务层
  └─ web01 / web02
       ├─ Nginx
       ├─ PHP-FPM
       └─ WordPress 等 Web 业务

数据层
  ├─ db01:MariaDB
  ├─ rd01:Redis
  └─ nfs01:共享业务文件

备份层
  └─ backup01:接收 nfs01 的实时同步数据

从这个关系可以反推出部署顺序:

SSH 初始化
  → NFS、数据库、Redis
  → Web 节点
  → 负载均衡节点
  → 备份节点
  → 重启并验证同步链路

这一步非常重要。很多自动化脚本失败,并不是某条命令本身有问题,而是执行命令时,它依赖的服务还没有准备好。

如果没有先理清依赖,脚本只是把手工操作变快;一旦有了明确的依赖链,脚本才开始具备“部署系统”的能力。

第一步:统一 SSH 访问

多服务器自动化首先要解决的是远程连接。

项目中的 setup_ssh.sh 会遍历服务器列表,将跳板机公钥复制到各个目标节点,然后收集服务器的 SSH 主机指纹。它的基本结构可以简化为:

servers=(
  "10.0.0.105"
  "10.0.0.106"
  "10.0.0.107"
  "10.0.0.108"
)

for server in "${servers[@]}"; do
  ssh-copy-id "root@$server"
done

服务器数组解决了两个问题:

  • 节点清单集中维护,不需要重复修改多段命令。
  • 可以使用循环对所有节点执行相同的初始化操作。

这是非常典型的 Shell 自动化思路:先把变化的数据提取出来,再用循环消除重复操作。

不过,项目最初为了减少首次连接时的交互,使用过 sshpass 和关闭严格主机指纹检查的方式。这在隔离的学习环境中比较方便,但不应该直接照搬到生产环境。

更稳妥的方案是:

  1. 不在脚本中保存明文密码。
  2. 首次初始化后立即停用密码登录。
  3. 保留 StrictHostKeyChecking,通过可信渠道提前维护 known_hosts
  4. 使用普通运维账号配合 sudo,避免长期直接使用 root。
  5. 为自动化账号限制可登录来源和权限范围。

自动化降低了操作成本,也会放大错误和凭据泄漏的影响。连接越方便,权限边界越需要被认真设计。

按节点职责拆分脚本

项目没有把所有命令塞进一个总脚本,而是按照服务器职责拆分:

setup_ssh.sh
lb01.sh
lb02.sh
web01.sh
nfs01.sh
backup01.sh
db01.sh
rd01.sh

这种拆分方式虽然简单,但已经具备了模块化的雏形。

db01.sh 只关心数据库安装、启动、账号和权限;rd01.sh 只负责 Redis;nfs01.sh 负责共享目录和实时同步;负载均衡脚本则聚焦 Nginx upstream、代理参数、Keepalived 和健康检查。

按职责拆分有三个直接好处:

  • 某个节点部署失败时,可以单独排查和重跑。
  • 阅读脚本时,能够快速定位某项服务的配置。
  • 将来迁移到 Ansible Role 时,拆分边界已经基本形成。

它也说明一个很实用的经验:Shell 项目不一定要一开始就设计复杂框架,但至少应该避免把数据库、缓存、Web 和负载均衡混在同一个不可维护的大文件中。

Web 节点:一台服务器上的完整业务栈

Web 节点是项目中内容最多的部分。

脚本需要依次完成:

  1. 创建统一的 www 用户和用户组。
  2. 安装并配置 Nginx。
  3. 安装 PHP-FPM 及扩展。
  4. 调整 PHP-FPM 运行用户和监听地址。
  5. 挂载 NFS 共享目录。
  6. 写入不同站点的 Nginx 配置。
  7. 下载并部署 WordPress。
  8. 设置文件权限。
  9. 连接远程 MariaDB 和 Redis。

这里最值得复盘的不是安装命令,而是服务之间的边界。

Nginx 负责接收 HTTP 请求,遇到 PHP 文件时交给 PHP-FPM;业务代码放在 NFS 共享目录中,让两台 Web 服务器访问相同文件;数据库独立运行在 db01;Session 保存到 Redis,避免请求在 web01 和 web02 之间切换时丢失登录状态。

也就是说,这些脚本不只是在“装软件”,而是在实现一套无状态 Web 节点架构:

用户请求
  → 负载均衡
  → 任意 Web 节点
  → NFS 读取共享代码和文件
  → MariaDB 读取业务数据
  → Redis 读取 Session

当 Web 节点不再独占业务数据时,新增节点和故障切换都会更容易。

项目中 web02 采用复制 web01 的方式快速生成。对于实验环境,这能节省部署时间;但从自动化设计上看,这也是一个明显的改进点。更理想的方式,是让 web01 和 web02 从同一份脚本独立部署,而不是依赖虚拟机复制。只有这样,脚本才能证明自己完整描述了节点状态。

负载均衡与虚拟 IP 漂移

入口层由两台负载均衡服务器组成。

Nginx upstream 将请求转发到两台 Web 节点:

upstream web_cluster {
    server 10.0.0.107:80;
    server 10.0.0.108:80;
}

server {
    listen 80;

    location / {
        proxy_pass http://web_cluster;
    }
}

只部署一台负载均衡服务器,会产生新的单点故障。因此项目又引入 Keepalived,在 lb01 和 lb02 之间维护一个虚拟 IP。

正常情况下,MASTER 节点持有虚拟 IP;当它发生故障时,BACKUP 节点接管地址,客户端仍然访问同一个入口。

项目还编写了 Nginx 健康检查脚本:

if ! pgrep -x nginx >/dev/null; then
  systemctl restart nginx
  sleep 1

  if ! pgrep -x nginx >/dev/null; then
    systemctl stop keepalived
    exit 1
  fi
fi

这段逻辑体现了两级处理:

  1. Nginx 进程消失时,先尝试自动恢复。
  2. 恢复失败时退出高可用集群,让备用节点接管入口。

比起只检查服务器是否在线,这种方式更接近真正的服务健康检查。服务器还活着,不代表 Nginx 一定能工作;高可用判断应该尽量靠近用户真正依赖的服务。

进一步改进时,还可以从“进程是否存在”升级为“HTTP 请求是否返回预期状态”,例如:

curl --fail --silent --max-time 2 http://127.0.0.1/health >/dev/null

这样可以识别进程存在但配置错误、端口不可用或后端异常等情况。

NFS、Lsyncd 与 Rsync 备份链路

共享存储和备份是项目中另一条完整链路。

nfs01 将业务目录通过 NFS 提供给 Web 节点,同时运行 Lsyncd 监听文件变化;当目录内容发生变化时,Lsyncd 调用 Rsync,将数据同步到 backup01。

web01 / web02
      ↓ 挂载
    nfs01
      ↓ Lsyncd 监听变化
      ↓ Rsync 增量传输
  backup01

这套组合的优点是实现成本低,而且非常适合理解 Linux 文件服务之间的协作方式:

  • NFS 解决多台 Web 节点共享文件的问题。
  • Lsyncd 负责监听目录变化。
  • Rsync 负责高效传输变化的数据。
  • backup01 将备份数据与生产共享目录分离。

但实时同步不等于完整备份。

如果源目录中的文件被误删,并且同步配置允许删除目标文件,那么错误也会被快速同步到 backup01。真正的生产备份还应该考虑版本保留、快照、异地副本、恢复演练和备份监控。

因此,这条链路更准确的名称应该是“实时副本”或“同步备份”。它解决了服务器损坏后的数据副本问题,但不能独立解决误操作、勒索软件或历史版本恢复问题。

Shell 自动化最容易遇到的三个问题

这套项目成功地把大量手工操作固化了下来,但它也暴露了 Shell 作为配置管理工具的边界。

1. 重复执行不一定安全

例如:

useradd www
mkdir /data
cat >> /etc/fstab << 'EOF'
...
EOF

第一次执行可能成功,第二次执行就可能因为用户已存在、目录已存在或配置重复追加而产生问题。

更稳妥的写法需要显式判断状态:

id www >/dev/null 2>&1 || useradd -M -s /sbin/nologin www
mkdir -p /data
grep -qF "$mount_entry" /etc/fstab || echo "$mount_entry" >> /etc/fstab

自动化脚本不能只考虑“从零开始第一次运行”,还应该考虑失败后重跑、局部修复和重复执行。

2. 错误可能被悄悄忽略

Shell 默认会继续执行后续命令。前面的软件安装或配置写入失败,脚本仍可能在最后打印“部署完成”。

一个基础改进是:

set -Eeuo pipefail

同时为关键步骤增加检查:

nginx -t
php-fpm -t
systemctl is-active --quiet nginx
curl --fail http://127.0.0.1/

“命令执行完”不等于“服务部署成功”。自动化必须定义自己的成功标准。

3. 配置、变量和敏感信息混在代码中

IP 地址、端口、用户名和密码如果直接散落在脚本里,修改环境时很容易漏改,也不适合提交到 Git 仓库。

至少可以先把普通变量集中到配置文件:

DB_HOST="10.0.0.151"
REDIS_HOST="10.0.0.161"
NFS_HOST="10.0.0.131"

敏感信息则应通过环境变量、权限受控的独立文件或专门的密钥管理服务注入:

: "${DB_PASSWORD:?DB_PASSWORD is required}"

代码仓库应该描述“需要一个数据库密码”,而不是保存密码本身。

从 Shell 项目到 Ansible 项目

完成这套 Shell 自动化后,再看 Ansible 的 Inventory、变量、Role、Handler 和 Playbook,会更容易理解它们为什么存在。

Shell 项目中的做法 在 Ansible 中的演进
服务器 IP 数组 Inventory 主机清单
脚本顶部变量 group_varshost_vars
按节点拆分脚本 Role
按顺序手动执行 Playbook 编排
systemctl restart Handler
grep 后再修改 声明式模块的幂等性
明文敏感变量 Ansible Vault
手工查看输出 Task 状态与执行结果

这并不意味着 Shell 脚本没有价值。

Shell 仍然非常适合单机初始化、小型维护任务、故障处理、系统检查和作为其他自动化工具的辅助脚本。问题只在于,当服务器数量、环境数量和服务依赖不断增加时,仅靠 Shell 手动维护执行顺序和状态判断,成本会越来越高。

这套项目可以看作我的自动化学习路径中的第一阶段:

手工命令
  → 单机 Shell 脚本
  → 多节点 Shell 部署链路
  → Ansible 角色化与统一编排

每一步都不是推翻前一步,而是在前一步暴露的问题上继续演进。

如果重新设计一次

如果今天重新整理这个项目,我会保留它清晰的节点职责,同时进行以下改造:

  1. 增加统一入口脚本,明确部署阶段和执行顺序。
  2. 所有脚本启用严格错误处理,并记录执行日志。
  3. 把 IP、端口和普通变量移到独立配置文件。
  4. 移除所有明文密码,改为运行时注入。
  5. 为用户、目录、挂载和配置追加操作增加幂等判断。
  6. 每个服务部署后执行配置检查、端口检查和业务探测。
  7. 不再复制虚拟机生成相同节点,而是让节点独立执行同一份脚本。
  8. 为备份增加快照、版本保留和恢复测试。
  9. 使用 ShellCheck 和测试环境提前发现脚本问题。
  10. 当环境规模继续增长时,将稳定逻辑迁移到 Ansible。

其中最重要的一项,是让脚本能够回答三个问题:

  • 当前执行到了哪一步?
  • 失败的具体原因是什么?
  • 修复问题后能否安全重跑?

一个只能在全新系统上成功运行一次的脚本,更像安装记录;一个能够检查状态、处理错误并安全重跑的脚本,才真正接近工程化自动化。

结语

linux_shell 项目并不完美。它有学习阶段常见的硬编码、重复执行风险和安全边界问题,也包含一些依赖人工补充的步骤。

但它完成了一次非常重要的转变:把分散在 8 台服务器上的操作,整理成了有顺序、有职责、有架构关系的一套部署链路。

也正是通过这套脚本,我开始意识到,运维自动化不只是“把命令写进文件”。真正需要被自动化的,是系统状态,是服务器之间的关系,也是部署失败后可以重新恢复的过程。

Shell 让我把手工经验写成代码;Ansible 则让我进一步把这些代码组织成工程。

这两段实践连接起来,才构成了一条完整的自动化演进路径。

Discussion

评论

加载中
正在检查登录状态…