通过前面的学习,相信你已经掌握了一些基础指令。今天我们继续挑战一个更复杂的问题——用 Linux 指令管理一个集群。这属于 Linux 指令的高级技巧。而所谓高级技巧,并不是要学更多的指令,而是要把之前所学的指令进行排列组合。当你从最初只能写几条指令、执行然后看结果,成长到具备书写一个拥有几十行甚至上百行 bash 脚本的能力时,就意味着你具备了解决复杂问题的能力。
本文的目标很具体:在成百上千台机器的集群中安装一个 Java 环境。我们会把简单的指令组合起来,分层组织成多个脚本文件,最终解决这个工程问题。
第一步:搭建学习用的集群
先搭一个学习用的集群,这里把模型简化一下:在自己的电脑上装一个 Ubuntu 桌面版虚拟机,再装两个 Ubuntu 服务器版虚拟机。
相对于桌面版,服务器版对资源的消耗会少很多。我把桌面版命名为 u1(主控机),两个被管理的服务器版叫 v1 和 v2。
| 角色 | 名称 | 说明 |
|---|
| 主服务器(主控) | u1 | Ubuntu 桌面版,我们在这里敲命令 |
| 被管理节点 | v1、v2 | Ubuntu 服务器版,部署目标 |
用桌面版的原因是:我喜欢 Ubuntu 漂亮的开源字体,这样准备素材时心情会好一些。如果你感兴趣,可以搜索 Ubuntu Mono,把这个字体装到自己的编辑器里。不过我还是觉得在 Ubuntu 中敲代码更有感觉。
注意:这里只用了 3 台服务器,但接下来要写的脚本,是可以在很多台服务器之间复用的。 3 台和 300 台,脚本一个字都不用改——这正是本文的重点。
第二步:循环遍历 IP 列表
你可以想象一个局域网中有很多服务器需要管理,它们彼此之间网络互通,我们通过一台主服务器对它们进行操作——即通过 u1 操作 v1 和 v2。
在主服务器上维护一个 IP 地址列表,保存成一个文件 iplist:
192.168.1.101
192.168.1.102
目前 iplist 中只有两项,但如果你有足够的机器,可以在里面放成百上千项。
接下来请你思考:shell 如何遍历这些 IP?
我们先实现一个最简单的程序,从 iplist 读出这些 IP 并用 for 循环遍历:
foreach.sh
#!/usr/bin/bash
readarray -t ips < iplist
for ip in ${ips[@]}
do
echo $ip
done
逐个拆解:
首行的 #! 叫作 Shebang。 Linux 的程序加载器会分析 Shebang 的内容,决定用哪个程序执行脚本。这里我们希望用 bash 执行,因为用到的 readarray 是 bash 4.0 之后才增加的能力。
readarray 把 iplist 文件中的每一行读取到变量 ips 中。ips 是一个数组,可以用 echo ${ips[@]} 打印全部内容:@ 代表取数组中的全部内容;$ 是一个求值符号——不带 $ 的话,ips[@] 会被当成一个字符串,而不是表达式。
-t 参数的作用是去掉每行末尾的换行符(补充)。不加 -t,数组里每个元素都会多带一个看不见的 \n(192.168.1.101 会变成 14 个字符而不是 13 个)。本例中因为 ${ips[@]} 没加引号,单词切分恰好把换行符抹掉了,看起来没事,但一旦你写成 "${ips[@]}",或者把变量拼进 ssh 命令里,就会出问题。养成加 -t 的习惯。
for 循环遍历数组中的每个 IP,echo 把地址打印到屏幕上。
注意:如果用 sh 执行上面的程序会报错,因为 readarray 是 bash 4.0 后才支持的能力。所以我们用 chmod 给 foreach.sh 增加执行权限,然后直接利用 Shebang 的能力执行:
chmod +x ./foreach.sh
./foreach.sh
这个十行的小脚本,就是后面所有操作的骨架。记住它,它会进化四次。
第三步:创建集群管理账户
为了方便集群管理,通常使用统一的用户名管理集群。这个账号在所有机器上都要保持命名一致,比如就叫 lagou。
我们一步步来看怎么创建这个账户。
1. 创建用户并加入 sudo 组
sudo useradd -m -d /home/lagou lagou
sudo passwd lagou
sudo usermod -G sudo lagou
-m 表示创建家目录,-d 指定家目录位置(补充)。把 lagou 加入 sudo 分组,这样 lagou 就有了 sudo 成为 root 的能力。
2. 设置初始 shell 为 bash
sudo usermod --shell /bin/bash lagou
3. 把 .bashrc 拷过去
这时如果用 su lagou 切换过去,你会发现命令行没有了颜色。因此把原来用户下的 .bashrc 拷到 /home/lagou 目录下:
sudo cp ~/.bashrc /home/lagou/
sudo chown lagou.lagou /home/lagou/.bashrc
这样就把平时自己用的设置拷了过去,包括终端颜色。.bashrc 是启动 bash 时会默认执行的一个脚本文件。
4. 配置 sudo 免密
编辑 /etc/sudoers 文件,增加一行:
lagou ALL=(ALL) NOPASSWD:ALL
表示 lagou 账号 sudo 时可以免去密码输入环节。这一步是后面能批量无人值守执行的前提。
安全提醒(补充):直接改 /etc/sudoers 有风险,写错语法可能导致所有人都无法 sudo。生产环境建议用 sudo visudo 编辑,它会在保存时做语法检查;或者往 /etc/sudoers.d/ 目录下放一个独立文件。
5. 整理成脚本
把上面的完整过程整理成 create_lagou.sh:
sudo useradd -m -d /home/lagou lagou
sudo passwd lagou
sudo usermod -G sudo lagou
sudo usermod --shell /bin/bash lagou
sudo cp ~/.bashrc /home/lagou/
sudo chown lagou.lagou /home/lagou/.bashrc
sudosh -c'echo "lagou ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers'
怎么验证脚本写对了? 用 userdel lagou 删除账户,并清理 /etc/sudoers 最后一行,然后执行 create_lagou.sh 重新创建回来。如果结果一致,就代表脚本功能没有问题。
6. 分发到 v1、v2
最后我们想在 v1、v2 上都执行这个脚本。但别忘了目标是让程序在成百上千台机器上传播,所以还需要一个脚本,把 create_lagou.sh 拷贝到需要执行的机器上去。
这里对 foreach.sh 稍作修改——这是它的第二个版本:
foreach.sh
#!/usr/bin/bash
readarray -t ips < iplist
for ip in ${ips[@]}
do
scp ~/remote/create_lagou.sh ramroll@ $ip:~/create_lagou.sh
done
在循环中用 scp 进行文件拷贝,然后分别去每台机器上执行 create_lagou.sh。
如果你的机器非常多,上述过程会变得非常烦琐。你可以先带着这个问题学习第四步,再回来重新思考。当然也可以远程执行脚本。另外还有一个叫 sshpass 的工具,可以帮你把密码传递给要远程执行的指令,感兴趣可以自己研究一下。
第四步:打通集群权限(免密登录)
接下来需要打通从主服务器到 v1、v2 的权限。当然也可以每次都用 ssh 输入用户名密码登录,但这不是长久之计——如果有成百上千台服务器,输入用户名密码就成了一件繁重的工作。
这时候可以利用主服务器的公钥在各个服务器间登录,避免输入密码。
1. 在 u1 上生成公私钥对
mkdir -p ~/.ssh
cd ~/.ssh
ssh-keygen
这里有两个知识点:
执行完会在 ~/.ssh 中生成两个文件:id_rsa.pub 公钥文件和 id_rsa 私钥文件。
id_rsa.pub 里是一串加密的字符串。我们把它拷到其他机器对应用户的 ~/.ssh/authorized_keys 文件中,ssh 登录时就不用重新输入密码了。
私钥千万不要外传(补充)。公钥可以随便发,私钥泄露等于家门钥匙丢了。
2. 用脚本传播公钥
这个传播公钥的能力可以用一个 shell 脚本执行。我们再次修改 foreach.sh——这是它的第三个版本,并配一个 transfer_key.sh:
foreach.sh
#!/usr/bin/bash
readarray -t ips < iplist
for ip in ${ips[@]}
do
sh ./transfer_key.sh $ip
done
transfer_key.sh
ip=$1
pubkey=$(cat ~/.ssh/id_rsa.pub)
echo "execute on .. $ip"
ssh lagou@$ip"
mkdir -p ~/.ssh
echo $pubkey >> ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
"
拆解一下:
在 foreach.sh 中执行 transfer_key.sh,并把 IP 地址通过参数传递过去;
在 transfer_key.sh 中用 $1 读出 IP 地址参数;
用 $(...) 把公钥内容读入变量 pubkey(补充:这叫命令替换,把命令的输出赋给变量);
然后登录到对应服务器,执行多行指令——注意 ssh 后面跟的是一个用双引号包住的多行字符串;
用 mkdir -p 检查 .ssh 目录,不存在就创建;
最后把公钥追加写入目标机器的 ~/.ssh/authorized_keys。
为什么要 chmod 700 和 600? 因为某些特定的 Linux 版本要求 .ssh 目录必须是可读写执行(700),authorized_keys 文件的权限为只可读写(600)。而为了保证安全性,组用户和其他所有用户都不可以访问这个文件。
补充一个记忆方式:>> 是追加,> 是覆盖。这里必须用 >>,否则会把目标机器上已有的其他公钥全部冲掉。
3. 效果
此前执行 foreach.sh 需要输入两次密码。完成上述操作后,再登录这两台服务器就不需要输入密码了——登录任何一台机器,都不再需要用户名和密码。
到这一步,集群的「任督二脉」就打通了。后面所有的批量操作,都建立在这个基础上。
第五步:先在单机上装一遍 Java
在远程部署之前,先在单机上完整走一遍 Java 安装,用来收集需要执行的脚本。
这是一个很重要的工作方法:先手动跑通一遍,把每一条命令记录下来,再自动化。 直接写批量脚本,出错了你都不知道错在哪台机器上。
1. 安装 JDK
在 Ubuntu 上安装 Java 环境可以直接用 apt:
sudo apt install openjdk-11-jdk
经过一番等待就装好了,然后确认一下:
which java
java --version
2. 创建专用用户 ujava
根据最小权限原则,执行 Java 程序我们再创建一个用户 ujava:
sudo useradd -m -d /opt/ujava ujava
sudo usermod --shell /bin/bash ujava
这个用户可以不设置密码,因为我们不会真的登录到这个用户下去做任何事情。
3. 找到 JAVA_HOME
接下来为用户配置 Java 环境变量。通过两次 ls 追查可以发现:java 可执行文件先软连接到 /etc/alternatives/java,然后再次软连接到 /usr/lib/jvm/java-11-openjdk-amd64 下。
ls -l $(which java)
ls -l /etc/alternatives/java
于是就可以这样设置 JAVA_HOME 了:
export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64/
4. 插播:环境变量与 PATH
Linux 的环境变量就好比全局可见的数据。 这里我们用 export 设置 JAVA_HOME 的指向。如果想看所有环境变量的指向,可以用 env 指令:
env
其中有一个环境变量比较重要,就是 PATH:
echo $PATH
PATH 中用 : 分割,每一个目录都是 Linux 查找执行文件的目录。 当用户在命令行输入一个命令,Linux 就会在 PATH 中寻找对应的执行文件。
5. 让环境变量持久化
我们不希望 JAVA_HOME 配置完重启一次电脑就消失,因此把它加入 ujava 用户的 profile 中。这样只要发生用户登录,就有这个环境变量。
sudo sh -c 'echo "export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64/" >> /opt/ujava/.bash_profile'
把 JAVA_HOME 加入 .bash_profile,这样后续远程执行 java 指令时就能用上这个环境变量了。
6. 整理成 install_java.sh
最后把上面所有指令整理起来:
install_java.sh
sudo apt -y install openjdk-11-jdk
sudo useradd -m -d /opt/ujava ujava
sudo usermod --shell /bin/bash ujava
sudo sh -c 'echo "export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64/" >> /opt/ujava/.bash_profile'
注意 apt 后面加了一个 -y,是为了让执行过程不弹出确认提示。 这个细节在批量部署时至关重要——无人值守的脚本里,任何一个交互式提示都会让整台机器卡在那里等你敲回车。
第六步:远程批量安装 Java 环境
终于到了远程安装这一步,我们又需要用到 foreach.sh。
为了避免每次都改脚本,可以让 foreach.sh 带一个文件参数,指定需要远程执行的脚本——这是它的第四个版本,也是最终形态:
foreach.sh
#!/usr/bin/bash
readarray -t ips < iplist
script=$1
for ip in ${ips[@]}
do
ssh $ip 'bash -s' < $script
done
改写后的 foreach 会读取第一个执行参数作为远程执行的脚本文件。其中:
这一行 ssh $ip 'bash -s' < $script 是整篇文章最精妙的地方(补充说明):它根本不需要先把脚本 scp 到远程机器上,而是直接把本地脚本的内容「喂」给远程的 bash 去执行。回过头看第三步里那个先 scp 再登录执行的做法,一下就被简化掉了。
执行
./foreach.sh install_java.sh
等待 1 分钟左右,执行结束后,用下面这个脚本检测两台机器的安装情况:
check.sh
sudo -u ujava -i /bin/bash -c 'echo $JAVA_HOME'
sudo -u ujava -i java --version
check.sh 中我们切换到 ujava 用户去检查 JAVA_HOME 环境变量和 Java 版本。-i 表示以登录方式启动 shell,这样才会加载 .bash_profile,环境变量才生效(补充)。
配合最终版的 foreach,检查也可以一键完成:
./foreach.sh check.sh
至此,成百上千台机器的 Java 环境部署,就变成了两条命令。
回头看:foreach.sh 的四次进化
这一节是我加的小结(补充)。整篇文章其实就是一个脚本的四次进化史,把这张表看懂,思路就通了。
| 版本 | 循环体里做的事 | 解决的问题 |
|---|
| v1 | echo $ip | 验证能否正确遍历 IP 列表 |
| v2 | scp ... create_lagou.sh | 把文件分发到每台机器 |
| v3 | sh ./transfer_key.sh $ip | 把 IP 当参数传给子脚本,打通免密 |
| v4 | ssh $ip 'bash -s' < $script | 脚本名当参数,任意脚本都能批量远程执行 |
可以看到一条清晰的演进路线:从「打印」到「传文件」,从「硬编码脚本名」到「脚本名参数化」。 到了 v4,foreach.sh 已经变成一个通用工具——以后不管是装 Java、装 MySQL、改配置、收集日志,都只需要写好那个单机脚本,然后 ./foreach.sh xxx.sh。
这就是「彼此独立,又互成一体」在自己代码里的体现: foreach 只管遍历,transfer_key 只管传公钥,install_java 只管装 Java,check 只管检查。每个脚本都很笨,组合起来却很强。
总结
这节课我们所讲的场景,是自动化运维的一些皮毛。通过这样的场景练习,我们复习了很多之前学过的 Linux 指令。在尝试用脚本文件构建一个又一个小工具的过程中,可以发现:复用很重要。
最终我们一共写了 5 个文件,各司其职:
| 文件 | 职责 |
|---|
iplist | 集群 IP 清单,唯一需要随机器规模变化的文件 |
foreach.sh | 通用遍历器,把任意脚本推到所有机器上执行 |
create_lagou.sh | 创建统一的集群管理账号 |
transfer_key.sh | 分发公钥,打通免密登录 |
install_java.sh / check.sh | 安装与验证 |
在工作中,优秀的工程师总是善于积累和复用,而 shell 脚本就是积累和复用的利器。如果你第一次安装 Java 环境,可以把今天的安装脚本保存在自己的笔记本中,下次再安装就能自动化完成了。
除了积累和总结,另一个非常重要的能力是:你要尝试自己去查资料,包括用 man 熟悉各种指令的用法、用搜索引擎查阅资料等。
回到开头那句话——彼此独立,又互成一体。今天写的这几个脚本,每一个都简单到不值得一提,但组合起来就完成了一件成百上千台机器规模的工作。这既是 Linux 的设计哲学,也是工程能力的起点