1. 集群运维的日常痛点与脚本化思路
干过大数据运维的兄弟都清楚,一个中等规模的Hadoop集群,节点数量少则十几台,多则上百台。每台机器上跑着NameNode、DataNode、ResourceManager、NodeManager、JournalNode、ZooKeeper、Hive Metastore、HiveServer2、HBase Master、RegionServer、Spark History Server等一大堆角色。如果每次重启集群都靠手动敲命令,一台一台SSH上去执行,那基本上一天啥也别干了,光启动服务就能耗掉大半天。
我刚入行那会儿,就吃过这个亏。当时管着一个二十来台机器的测试集群,每次版本升级或者配置变更后需要整体重启,我拿着一个Excel表格,上面列着每台机器要启动哪些服务,然后挨个登录、挨个执行。结果有一次漏了一台JournalNode没启动,导致HDFS一直处于安全模式,排查了快两个小时才发现问题。从那以后我就下定决心,必须把启停和状态查看这套流程脚本化、自动化。
这篇文章要聊的,就是怎么用Shell脚本把Hadoop生态圈里常见组件的启动、关闭和状态查看统一管起来。核心思路其实不复杂:按角色分组、按节点分发、按顺序执行、按结果反馈。但真正落地的时候,细节特别多,比如启动顺序有依赖关系、不同组件的启停命令风格不统一、状态查看需要解析不同的输出格式、SSH免密和并行执行要考虑超时和失败重试等等。
适合谁来参考呢?如果你手里管着几台到几十台机器的Hadoop集群,日常需要做启停维护,又不想引入Ambari或Cloudera Manager这类重型管理平台,那这套脚本思路就非常合适。如果你是用Ansible或SaltStack做批量管理的,也可以把这里的逻辑迁移过去,作为playbook的底层执行单元。甚至你只是想在单台机器上快速切换各个组件的运行状态,这套脚本也能帮你省不少事。
接下来我会从整体设计、核心细节、实操过程和问题排查四个维度,把这件事掰开揉碎讲清楚。每个部分都会附上我实际用过的脚本片段和踩坑经验,你可以直接抄作业,也可以根据自己的环境做调整。
2. 脚本整体架构与设计取舍
2.1 为什么选择Shell而不是Python或Ansible
这个问题我被问过很多次。Python写运维脚本确实更优雅,异常处理、日志记录、并发控制都有成熟的库;Ansible更是专门做批量管理的工具,幂等性和模块化做得很好。但在Hadoop集群这个场景下,我还是倾向于用Shell,原因有这么几个。
第一,环境依赖最小。Hadoop集群的每台机器上必然有bash,但不一定有Python3,更不一定有Ansible。尤其是在一些老旧的CentOS 6或CentOS 7环境里,系统自带的Python版本可能是2.6或2.7,你写的Python3脚本根本跑不起来。而Shell脚本只要有bash就能执行,不需要额外安装任何东西。
第二,与Hadoop自带脚本风格一致。Hadoop发行版自带的start-dfs.sh、stop-yarn.sh这些脚本本身就是Shell写的,它们依赖的环境变量、配置文件路径、日志目录结构都是固定的。你用Shell去调用它们,天然就能复用这些约定,不需要额外做适配。
第三,调试直观。Shell脚本出问题了,加个set -x就能看到每一步执行了什么命令,输出是什么。Python脚本虽然也能调试,但涉及到子进程调用、环境变量传递的时候,排查起来反而更绕。
当然,Shell的缺点也很明显:字符串处理能力弱、并发控制麻烦、错误处理容易写得粗糙。所以在设计的时候,我会尽量把复杂的逻辑拆成独立的函数,用清晰的命名和注释来弥补语言本身的不足。
2.2 按角色分组还是按节点分组
这是脚本设计时第一个要做的决策。所谓按角色分组,就是把所有DataNode归为一组,所有NodeManager归为一组,启动的时候一组一组来。按节点分组,就是每台机器一个配置项,列出这台机器上要跑的所有角色,然后逐台启动。
我两种方式都用过,最后选择了混合模式:配置文件按节点组织,但执行的时候按角色排序。具体来说,配置文件长这样:
# nodes.conf # 格式: 主机名:角色1,角色2,角色3 node01:namenode,journalnode,zkfc node02:namenode,journalnode,zkfc node03:journalnode,zkfc node04:datanode,nodemanager,regionserver node05:datanode,nodemanager,regionserver node06:datanode,nodemanager,regionserver node07:resourcemanager,historyserver node08:hivemetastore,hiveserver2 node09:zookeeper,hbase-master node10:zookeeper,regionserver这样配置的好处是,每台机器的角色一目了然,新增或下线节点只需要改一行。而执行的时候,脚本会先解析这个配置,生成一个“角色到节点列表”的映射,然后按照预定义的顺序依次启动各个角色。
为什么启动要按角色顺序?因为Hadoop生态圈的组件之间有明确的依赖关系。比如HDFS必须先于YARN启动,因为YARN的ResourceManager需要向HDFS写入日志和临时文件;ZooKeeper必须先于HBase启动,因为HBase Master需要依赖ZooKeeper做协调;JournalNode必须先于NameNode启动,否则NameNode无法完成EditLog的同步。如果顺序搞反了,轻则启动报错,重则整个集群起不来。
我定义的启动顺序是这样的:
| 优先级 | 角色 | 说明 |
|---|---|---|
| 1 | zookeeper | 分布式协调基础,最先启动 |
| 2 | journalnode | HDFS元数据共享存储 |
| 3 | namenode | HDFS主节点 |
| 4 | zkfc | NameNode自动故障转移控制器 |
| 5 | datanode | HDFS数据节点 |
| 6 | resourcemanager | YARN主节点 |
| 7 | nodemanager | YARN计算节点 |
| 8 | historyserver | MapReduce历史服务器 |
| 9 | hbase-master | HBase主节点 |
| 10 | regionserver | HBase数据节点 |
| 11 | hivemetastore | Hive元数据服务 |
| 12 | hiveserver2 | Hive查询服务 |
关闭的顺序则基本反过来,先停上层服务,再停下层服务。不过ZooKeeper和JournalNode可以最后停,因为它们被依赖得最多。
2.3 串行执行还是并行执行
串行执行就是一台一台来,启动完一台再启动下一台。并行执行就是同时向多台机器发起启动命令。这两种方式各有优劣。
串行执行的好处是可控性强。每台机器的启动结果都能及时看到,出问题了可以立即停下来排查,不会把问题扩散到整个集群。缺点是速度慢。假设每台机器启动需要10秒,20台机器就是200秒,三分多钟。如果集群规模上百台,那等待时间就非常可观了。
并行执行的好处是速度快,理论上启动时间取决于最慢的那台机器。缺点是问题排查困难。如果同时向20台机器发起启动命令,输出会混在一起,很难分辨哪台成功了哪台失败了。而且如果某个角色有启动顺序要求,并行执行可能会打乱顺序。
我的做法是分层并行:同一角色的多台机器并行启动,不同角色之间串行启动。比如有5台DataNode,这5台同时启动,等它们都启动完了,再启动下一批NodeManager。这样既保证了角色间的依赖顺序,又利用了同角色机器之间的并行性。
实现上,我用的是Shell的后台任务加wait命令:
start_role_parallel() { local role=$1 local nodes=$2 local pids=() for node in $nodes; do ( ssh "$node" "source /etc/profile; ${HADOOP_HOME}/bin/hadoop-daemon.sh start ${role}" echo "$node:$role:$?" >> /tmp/start_result.log ) & pids+=($!) done for pid in "${pids[@]}"; do wait "$pid" done }这里有个细节要注意:每个后台任务里都要把执行结果写入一个临时文件,因为后台任务的输出不会直接显示在当前终端。等所有任务都wait完了,再从临时文件里读取结果,统一汇总展示。
2.4 状态查看的设计考量
状态查看比启停要复杂一些,因为不同组件的状态查看方式完全不同。HDFS和YARN可以用hdfs dfsadmin -report和yarn node -list来查看,但这两个命令需要在有Hadoop客户端配置的机器上执行。ZooKeeper可以用zkServer.sh status,HBase可以用hbase shell里的status命令,Hive则通常通过检查进程是否存在来判断。
我的设计是分两层:第一层是进程级检查,通过jps命令看Java进程是否在运行;第二层是服务级检查,通过各组件的客户端命令看服务是否真正可用。进程在但服务不可用的情况太常见了,比如NameNode进程活着但处于安全模式,DataNode进程活着但无法与NameNode通信。
进程级检查的脚本片段:
check_process() { local node=$1 local process_pattern=$2 local count count=$(ssh "$node" "jps -l | grep -c '$process_pattern'") if [ "$count" -gt 0 ]; then echo "OK" else echo "FAIL" fi }服务级检查则需要根据组件类型分别处理。比如检查HDFS是否退出安全模式:
check_hdfs_safemode() { local result result=$(hdfs dfsadmin -safemode get 2>/dev/null) if echo "$result" | grep -q "Safe mode is OFF"; then echo "OK" else echo "SAFEMODE" fi }状态查看的输出我建议用表格形式展示,每行一个节点,每列一个角色,用颜色区分正常和异常。虽然Shell里做颜色输出稍微麻烦一点,但视觉效果提升很大,一眼就能看出哪个节点有问题。
3. 核心脚本细节与实操要点
3.1 环境变量加载的坑
这个问题看似简单,但坑特别多。Hadoop的启停脚本依赖JAVA_HOME、HADOOP_HOME、HADOOP_CONF_DIR等环境变量。当你通过SSH远程执行命令时,默认加载的是非交互式非登录Shell的环境,/etc/profile和~/.bash_profile里的配置可能不会被加载。
我踩过的坑是这样的:在终端里手动执行hadoop-daemon.sh start datanode没问题,但通过SSH远程执行就报JAVA_HOME is not set。原因就是SSH远程执行时没有加载/etc/profile。
解决办法有两个:一是在SSH命令里显式source环境变量文件,二是把环境变量配置写到~/.bashrc里,因为非交互式Shell会加载.bashrc。我推荐第一种,因为更可控:
ssh "$node" "source /etc/profile; source ~/.bash_profile; ${HADOOP_HOME}/bin/hadoop-daemon.sh start datanode"但这里又有一个新问题:如果/etc/profile里有交互式判断逻辑(比如[ -z "$PS1" ] && return),那么在非交互式环境下source会直接返回,后面的环境变量都不会加载。所以更稳妥的做法是,把Hadoop相关的环境变量单独写到一个文件里,比如/etc/hadoop/hadoop-env.sh,然后在脚本里直接source这个文件。
提示:建议在所有节点上统一维护一个
/etc/profile.d/hadoop.sh文件,里面只放环境变量导出语句,不做任何交互式判断。这样无论是登录Shell还是非登录Shell,都能正确加载。
3.2 SSH免密与超时控制
批量执行的前提是SSH免密登录已经配好。这个基础操作就不展开讲了,重点说说超时控制。
默认情况下,SSH连接的超时时间很长,如果某台机器网络不通或者负载过高,SSH可能会卡住几十秒甚至几分钟。在批量脚本里,这种卡顿会严重影响整体执行效率。所以必须加上超时参数:
ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -o BatchMode=yes "$node" "command"这几个参数的含义分别是:连接超时5秒、不检查主机密钥(避免首次连接时的交互提示)、批量模式(禁止密码交互,只使用密钥认证)。加上这些参数后,如果某台机器5秒内连不上,SSH会直接返回失败,脚本可以继续处理下一台。
另外,命令执行本身也可能超时。比如启动NameNode可能需要较长时间,如果SSH连接建立了但命令一直不返回,脚本也会卡住。这时候可以用timeout命令包裹:
ssh -o ConnectTimeout=5 "$node" "timeout 60 ${HADOOP_HOME}/bin/hadoop-daemon.sh start namenode"这样即使命令卡住,60秒后也会强制终止,脚本可以继续往下走。
3.3 启动结果的判定逻辑
启动命令执行完了,怎么判断是否真的启动成功?这个问题比想象中复杂。
最直接的方式是看命令的退出码。$?为0表示命令执行成功,非0表示失败。但Hadoop的启停脚本有个特点:即使启动失败,退出码也可能是0。比如hadoop-daemon.sh start datanode,如果DataNode因为端口被占用而启动失败,脚本可能仍然返回0,只是在日志里打印了错误信息。
所以不能只看退出码,还要结合日志和进程状态来判断。我的做法是三步走:
第一步,检查命令退出码。非0直接判定为失败。
第二步,等待几秒后检查进程是否存在。因为启动命令返回后,Java进程可能还在初始化中,需要等几秒再检查。
第三步,检查组件日志的最后几行是否有明显的错误信息。这一步可选,但在排查问题时很有用。
start_and_verify() { local node=$1 local role=$2 local start_cmd=$3 local process_pattern=$4 # 执行启动命令 ssh -o ConnectTimeout=5 "$node" "source /etc/profile; $start_cmd" >/dev/null 2>&1 local exit_code=$? if [ $exit_code -ne 0 ]; then echo "FAIL: command exit code $exit_code" return 1 fi # 等待进程初始化 sleep 5 # 检查进程 local count count=$(ssh -o ConnectTimeout=5 "$node" "jps -l | grep -c '$process_pattern'") if [ "$count" -gt 0 ]; then echo "OK" return 0 else echo "FAIL: process not found" return 1 fi }3.4 关闭脚本的特殊处理
关闭服务比启动服务要小心,因为有些组件需要优雅停止,不能直接kill进程。比如HBase的RegionServer,如果直接kill -9,可能会导致Region没有正确关闭,下次启动时需要做长时间的WAL回放。HDFS的DataNode如果直接kill,也可能导致块数据不一致。
所以关闭脚本要优先使用组件自带的stop命令,只有在stop命令无效时才考虑强制kill。而且stop命令执行后,要等待一段时间确认进程真的退出了,不能立即认为关闭成功。
stop_and_verify() { local node=$1 local role=$2 local stop_cmd=$3 local process_pattern=$4 local max_wait=30 local waited=0 ssh -o ConnectTimeout=5 "$node" "source /etc/profile; $stop_cmd" >/dev/null 2>&1 while [ $waited -lt $max_wait ]; do local count count=$(ssh -o ConnectTimeout=5 "$node" "jps -l | grep -c '$process_pattern'") if [ "$count" -eq 0 ]; then echo "OK" return 0 fi sleep 3 waited=$((waited + 3)) done echo "WARN: process still running after ${max_wait}s" return 1 }这里有个经验值:大部分Hadoop组件的优雅停止时间在10到20秒之间。如果超过30秒还没停掉,要么是组件卡住了,要么是stop命令没有正确执行。这时候可以考虑强制kill,但一定要在日志里记录清楚,方便后续排查。
注意:强制kill之前,建议先dump一下进程的线程栈,命令是
jstack <pid> > /tmp/jstack_$(date +%s).log。这样即使强制kill了,后续分析问题时还有现场可查。
4. 完整实操流程与脚本实现
4.1 配置文件设计与解析
先把配置文件的结构定下来。我习惯用冒号分隔主机名和角色列表,角色之间用逗号分隔。这种格式简单直观,解析起来也方便。
# /opt/scripts/hadoop/nodes.conf # 格式: 主机名:角色1,角色2,角色3 # 以#开头的行为注释 node01:namenode,journalnode,zkfc node02:namenode,journalnode,zkfc node03:journalnode,zkfc node04:datanode,nodemanager,regionserver node05:datanode,nodemanager,regionserver node06:datanode,nodemanager,regionserver node07:resourcemanager,historyserver node08:hivemetastore,hiveserver2 node09:zookeeper,hbase-master node10:zookeeper,regionserver解析这个配置的Shell函数:
parse_nodes_conf() { local conf_file=$1 local role=$2 # 输出所有包含指定角色的节点,每行一个 grep -v '^#' "$conf_file" | grep -v '^$' | while IFS=: read -r host roles; do if echo "$roles" | tr ',' '\n' | grep -qx "$role"; then echo "$host" fi done }这个函数的作用是:给定配置文件和角色名,输出所有承担该角色的节点列表。比如parse_nodes_conf nodes.conf datanode会输出node04、node05、node06。
4.2 启动脚本的完整实现
把前面的各个模块组合起来,启动脚本的主流程是这样的:
#!/bin/bash # start-cluster.sh # 用途: 按角色顺序启动Hadoop生态圈各组件 set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) CONF_FILE="${SCRIPT_DIR}/nodes.conf" LOG_FILE="${SCRIPT_DIR}/logs/start_$(date +%Y%m%d_%H%M%S).log" # 角色启动顺序定义 ROLE_ORDER=( "zookeeper" "journalnode" "namenode" "zkfc" "datanode" "resourcemanager" "nodemanager" "historyserver" "hbase-master" "regionserver" "hivemetastore" "hiveserver2" ) # 角色对应的启动命令模板 declare -A ROLE_START_CMD=( ["zookeeper"]='${ZK_HOME}/bin/zkServer.sh start' ["journalnode"]='${HADOOP_HOME}/bin/hadoop-daemon.sh start journalnode' ["namenode"]='${HADOOP_HOME}/bin/hadoop-daemon.sh start namenode' ["zkfc"]='${HADOOP_HOME}/bin/hadoop-daemon.sh start zkfc' ["datanode"]='${HADOOP_HOME}/bin/hadoop-daemon.sh start datanode' ["resourcemanager"]='${HADOOP_HOME}/bin/yarn-daemon.sh start resourcemanager' ["nodemanager"]='${HADOOP_HOME}/bin/yarn-daemon.sh start nodemanager' ["historyserver"]='${HADOOP_HOME}/bin/mr-jobhistory-daemon.sh start historyserver' ["hbase-master"]='${HBASE_HOME}/bin/hbase-daemon.sh start master' ["regionserver"]='${HBASE_HOME}/bin/hbase-daemon.sh start regionserver' ["hivemetastore"]='nohup ${HIVE_HOME}/bin/hive --service metastore > /dev/null 2>&1 &' ["hiveserver2"]='nohup ${HIVE_HOME}/bin/hive --service hiveserver2 > /dev/null 2>&1 &' ) # 角色对应的进程匹配模式 declare -A ROLE_PROCESS=( ["zookeeper"]="QuorumPeerMain" ["journalnode"]="JournalNode" ["namenode"]="NameNode" ["zkfc"]="DFSZKFailoverController" ["datanode"]="DataNode" ["resourcemanager"]="ResourceManager" ["nodemanager"]="NodeManager" ["historyserver"]="JobHistoryServer" ["hbase-master"]="HMaster" ["regionserver"]="HRegionServer" ["hivemetastore"]="HiveMetaStore" ["hiveserver2"]="HiveServer2" ) main() { mkdir -p "$(dirname "$LOG_FILE")" for role in "${ROLE_ORDER[@]}"; do local nodes nodes=$(parse_nodes_conf "$CONF_FILE" "$role") if [ -z "$nodes" ]; then continue fi echo "===== Starting role: $role =====" | tee -a "$LOG_FILE" local pids=() for node in $nodes; do ( local cmd="${ROLE_START_CMD[$role]}" local pattern="${ROLE_PROCESS[$role]}" local result result=$(start_and_verify "$node" "$role" "$cmd" "$pattern") echo "$node $role $result" >> "$LOG_FILE" ) & pids+=($!) done for pid in "${pids[@]}"; do wait "$pid" done # 检查本角色是否有启动失败的节点 local fail_count fail_count=$(grep -c "FAIL" "$LOG_FILE" || true) if [ "$fail_count" -gt 0 ]; then echo "WARNING: Some nodes failed to start. Check $LOG_FILE" | tee -a "$LOG_FILE" fi sleep 2 done echo "===== Start sequence completed =====" | tee -a "$LOG_FILE" print_summary "$LOG_FILE" }这个脚本有几个关键点值得说明。第一,set -euo pipefail让脚本在遇到错误时立即退出,避免错误累积。第二,每个角色的启动是并行的,但角色之间是串行的,保证了依赖顺序。第三,所有结果都写入日志文件,方便事后追溯。
4.3 关闭脚本的实现差异
关闭脚本的整体结构和启动脚本类似,但有几个关键差异。
第一,关闭顺序基本是启动顺序的逆序,但ZooKeeper和JournalNode要放到最后关。因为其他组件在关闭过程中可能还需要它们提供服务。
第二,关闭命令执行后需要等待进程真正退出,不能立即返回。
第三,对于Hive Metastore和HiveServer2这类没有自带stop脚本的服务,需要通过进程号来kill。
# 关闭顺序 ROLE_STOP_ORDER=( "hiveserver2" "hivemetastore" "regionserver" "hbase-master" "historyserver" "nodemanager" "resourcemanager" "datanode" "zkfc" "namenode" "journalnode" "zookeeper" )对于Hive服务的关闭,需要先找到进程号再kill:
stop_hive_service() { local node=$1 local pattern=$2 ssh -o ConnectTimeout=5 "$node" " pid=\$(jps -l | grep '$pattern' | awk '{print \$1}') if [ -n \"\$pid\" ]; then kill \$pid sleep 5 if kill -0 \$pid 2>/dev/null; then kill -9 \$pid fi fi " }4.4 状态查看脚本的实现
状态查看脚本我设计成输出一个表格,行是节点,列是角色,单元格里显示状态。为了在终端里对齐,需要计算每列的最大宽度。
print_status_table() { local conf_file=$1 local roles=("${ROLE_ORDER[@]}") # 计算每列宽度 local host_width=10 declare -A col_width for role in "${roles[@]}"; do col_width[$role]=${#role} [ ${col_width[$role]} -lt 8 ] && col_width[$role]=8 done # 打印表头 printf "%-${host_width}s" "HOST" for role in "${roles[@]}"; do printf " %-${col_width[$role]}s" "$role" done echo # 打印分隔线 printf "%-${host_width}s" "----" for role in "${roles[@]}"; do printf " %-${col_width[$role]}s" "--------" done echo # 打印每行 grep -v '^#' "$conf_file" | grep -v '^$' | while IFS=: read -r host roles_str; do printf "%-${host_width}s" "$host" for role in "${roles[@]}"; do if echo "$roles_str" | tr ',' '\n' | grep -qx "$role"; then local status status=$(check_role_status "$host" "$role") printf " %-${col_width[$role]}s" "$status" else printf " %-${col_width[$role]}s" "-" fi done echo done }状态检查函数根据角色类型分别处理:
check_role_status() { local node=$1 local role=$2 local pattern="${ROLE_PROCESS[$role]}" local count count=$(ssh -o ConnectTimeout=3 "$node" "jps -l 2>/dev/null | grep -c '$pattern'" 2>/dev/null || echo "0") if [ "$count" -gt 0 ]; then echo "RUNNING" else echo "STOPPED" fi }这个基础版本只检查进程是否存在。如果要检查服务是否真正可用,需要针对不同角色做额外判断。比如NameNode要检查是否处于安全模式,ResourceManager要检查是否有活跃的节点。这些检查可以后续逐步完善。
5. 常见问题与排查技巧实录
5.1 启动失败问题速查表
下面这张表是我在实际运维中总结出来的常见问题,按现象、可能原因、排查方法三个维度整理。遇到启动失败的时候,可以按这个表逐项排查。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| SSH连接超时 | 网络不通、防火墙拦截、目标机负载过高 | ping测试连通性,telnet测试22端口,检查目标机uptime |
| JAVA_HOME未设置 | 环境变量未加载 | 在SSH命令中显式source环境变量文件 |
| 端口被占用 | 上次进程未完全退出、其他服务占用端口 | netstat -tlnp | grep <端口>查看占用进程 |
| NameNode启动后立即退出 | EditLog目录权限问题、JournalNode未启动 | 查看NameNode日志logs/hadoop-*-namenode-*.log |
| DataNode无法连接NameNode | NameNode未启动、网络分区、配置错误 | 检查dfs.namenode.rpc-address配置,测试端口连通性 |
| ResourceManager启动失败 | 与NameNode通信异常、端口冲突 | 查看ResourceManager日志,检查yarn.resourcemanager.address配置 |
| HBase Master启动卡住 | ZooKeeper未就绪、HDFS处于安全模式 | 先确认ZooKeeper和HDFS状态,再启动HBase |
| Hive Metastore启动报错 | 元数据库连接失败、驱动缺失 | 检查hive-site.xml中的JDBC配置,确认数据库可达 |
5.2 进程在但服务不可用的排查
这是最让人头疼的一类问题。jps看到进程明明在,但客户端就是连不上。我遇到过几次,总结下来主要有这么几种情况。
第一种是进程假死。Java进程还在,但线程全部阻塞或者死锁了,无法响应请求。这种情况可以通过jstack看线程状态来确认。如果发现大量线程处于BLOCKED或WAITING状态,基本就是假死了。解决办法通常是重启该进程,但重启前最好dump一下线程栈,方便后续分析根因。
第二种是端口监听异常。进程在,但端口没有正常监听。用netstat -tlnp查看,如果发现该进程没有监听预期端口,可能是启动过程中绑定端口失败了。这种情况需要查看进程启动日志,通常会有BindException之类的错误。
第三种是服务处于特殊状态。比如NameNode处于安全模式,虽然进程在运行,但拒绝写入操作。用hdfs dfsadmin -safemode get可以查看安全模式状态。如果确实处于安全模式,需要检查DataNode的启动情况,因为安全模式通常是因为可用块比例不足导致的。
第四种是配置不一致。进程在运行,但使用的配置和客户端不一致,导致客户端连接到了错误的地址或端口。这种情况比较隐蔽,需要对比服务端和客户端的配置文件。
实操心得:我习惯在启动完所有服务后,跑一个简单的冒烟测试。比如在HDFS上创建目录、写入文件、读取文件,在YARN上提交一个简单的MapReduce任务,在Hive里执行一条
select 1。这些操作能快速验证服务是否真正可用,比单纯看进程状态靠谱得多。
5.3 批量执行时的并发问题
并行执行虽然快,但并发量太大的时候会出问题。我遇到过两种情况。
一种是SSH连接数过多被限制。有些服务器的sshd_config里设置了MaxStartups,默认是10:30:100,意思是当未认证连接数超过10个时开始随机拒绝,超过30个时全部拒绝。如果同时向50台机器发起SSH连接,很可能有一部分会被拒绝。解决办法是控制并发数,比如用xargs -P限制并行度,或者自己实现一个简单的信号量机制。
# 用xargs限制并发数为10 cat node_list.txt | xargs -P 10 -I {} ssh {} "command"另一种是目标机负载过高。同时启动多个Java进程,CPU和内存瞬间飙升,可能导致部分进程启动失败。这种情况在内存较小的机器上尤其常见。解决办法是错峰启动,比如每启动一批等待几秒再启动下一批。
5.4 日志管理与追溯
批量脚本的日志管理很重要。我见过有人把日志直接输出到终端,结果刷屏太快,根本看不清哪台成功了哪台失败了。也见过有人不记日志,出了问题只能靠回忆。
我的做法是分级记录。终端上只输出关键信息,比如每个角色的启动结果汇总;详细日志写入文件,包括每台机器的执行命令、退出码、进程检查结果。日志文件按时间戳命名,保留最近30天的记录。
# 日志清理,保留最近30天 find "${SCRIPT_DIR}/logs" -name "*.log" -mtime +30 -delete另外,建议在日志里记录每次操作的执行人。虽然Shell脚本本身不太方便获取执行人信息,但可以在脚本开头加一个参数,让执行人自己填写:
OPERATOR=${1:-"unknown"} echo "Operator: $OPERATOR" >> "$LOG_FILE"这样出了问题追溯的时候,至少知道是谁执行的。
6. 脚本的扩展与个人经验体会
这套脚本在实际使用中还可以做不少扩展。比如加上邮件或消息通知,启动完成后自动发送结果到指定渠道;比如加上重试机制,对于启动失败的节点自动重试一到两次;比如把配置文件改成从CMDB或配置中心动态获取,避免手动维护节点列表。
我个人在实际操作中的体会是,脚本的健壮性比功能丰富更重要。一开始我总想加各种花哨的功能,结果脚本越来越复杂,出问题的时候反而不好排查。后来我坚持一个原则:核心流程保持简单,扩展功能做成独立模块,按需加载。这样即使某个扩展模块出问题了,也不会影响核心的启停功能。
还有一个经验是,不要过度依赖脚本的自动化判断。脚本能告诉你进程在不在、端口通不通,但服务是否真正健康,很多时候还是需要人工确认。所以我在脚本的最后总是留一个交互式的确认步骤,让执行人自己决定是否继续下一步操作。这个习惯帮我避免了好几次因为脚本误判而导致的误操作。
最后分享一个小技巧:如果你管理的集群节点经常变动,建议把nodes.conf文件放到一个共享存储上,比如NFS或者Git仓库,所有管理节点都从这个共享位置读取配置。这样新增或下线节点的时候,只需要改一个地方,不用每台管理机都同步一遍。