前言
我们在一个HDFS集群中进行部分节点的Decommission操作。在Decommission过程中,我们发现了如下问题:
- Decommission的整个过程进展非常缓慢,一个星期以后,依然有一部分节点无法完成Decommission操作。
- 我们尝试通过-refreshNodes重新Decommission,系统没有任何response,这让我们无法确认到底当前的Decommission和我们重新trigger的尝试是成功了还是失败了。
- 当我们发现Decommission进展缓慢以后,我们在INFO日志中,无法找到任何与Decommission相关的操作日志。即使我们打开与与Decommission相关的DEBUG日志,也无法从中获取直接跟Decommissiion相关的日志信息,整个过程的诊断非常困难。后来我们发现,这是由于Decommission操作的触发和对Decommission的Block的调度是完全两个相互剥离的异步操作。但是即使设计本身无法改变,我们的想法是,HDFS是否能在日志上有所优化,在对Block进行调度的时候,能否有所提示?
对于 Decommission缓慢的情况,我们采取的措施是:
- 在不修改代码不变的情况下,调整了部分与Decommission相关的配置以加快Decommission的进行;
- 我们详细检查了Decommission过程的代码,发现了一个优化点,并且将对应的优化提交给了HDFS社区 TODO。
对于日志问题,我们也向HDFS社区提交了Patch(TODO),这个Patch想解决的问题是:
- 当用户反复执行
-refreshNodes
操作,其实只要一个节点正在Decommission,那么后面针对这个节点的所有的-refreshNodes
操作都是无效的,会被skip掉。这种关键的日志,能否在日志中体现?毕竟,又有多少用户能在代码层面去详细研究Decommission的详细触发和执行逻辑呢? - 当Decommission执行缓慢的时候,能否在DEBUG日志打开的情况下提供给管理员充足的信息以诊断对应原因?
Decommission过慢的分析过程
NameNode页面并不显示Decommission的进度和剩余块数量
首先,在我们发现我们的Decommission操作在过了一周以后依然没有结束,待Decommission的节点依然处于DECOMMISSSIOIN_IN_PROGRESS的状态。我们首先想到的是在NameNode的页面查看这些DataNode上有多少Block已经移走了,有多少Block的Replica还没有移走。
然后,我们从NameNode上看到的是下面的页面,其中,最后一行的DataNode正处在DECOMMISSIONING_IN_PROGRESS状态中:
我们发现,这个DECOMMISSIONING_IN_PROGRESS的Block数量是一直不变的。所以,我们的问题是,这个数字一直不变,这是否意味着这个节点的Decommission已经没有任何进展了?
后来,我们从代码分析看到,我们从这个页面看到的一个DECOMMISSIONING_IN_PROGRESS节点的块数量,代表的并不是这个节点还需要transfer出去的块的数量,而是这个机器上本身存放的副本数量,与Decommission无关。Decommission操作不会删除这个节点上的任何块数据,而是Decommission操作的发生,导致这个节点上的Replica的状态不再是Live状态,因此,如果不采取任何操作,那么这个待Decommission节点上的Replica对应的Block的副本状态可能就无法满足Redundancy。基于这个考虑,Decommission的过程,其实就是轮询这个机器上的所有块,保证机器在Decommission并且停机以后,这些块的副本数依然满足要求。
这个过程就是Decommission的workload,即块的复制。
增加每次调度的块数量
我们从日志中发现,每一轮调度,只有四个节点被尝试。比如,下面的日志显示,06:18
的时候调度4个,然后似乎sleep 了 3s,下一轮调度又是4个节点:
2024-07-02 06:18:49,908 DEBUG org.apache.hadoop.hdfs.server.blockmanagement.BlockManager: Creating a ReplicationWork to reconstruct blk_1073795480_54656
2024-07-02 06:18:49,908 DEBUG org.apache.hadoop.hdfs.server.blockmanagement.BlockManager: Creating a ReplicationWork to reconstruct blk_1073795483_54659
2024-07-02 06:18:49,908 DEBUG org.apache.hadoop.hdfs.server.blockmanagement.BlockManager: Creating a ReplicationWork to reconstruct blk_1073795486_54662
2024-07-02 06:18:49,908 DEBUG org.apache.hadoop.hdfs.server.blockmanagement.BlockManager: Creating a ReplicationWork to reconstruct blk_1073795487_54663
2024-07-02 06:18:52,910 DEBUG org.apache.hadoop.hdfs.server.blockmanagement.BlockManager: Creating a ReplicationWork to reconstruct blk_1073795538_54714
2024-07-02 06:18:52,910 DEBUG org.apache.hadoop.hdfs.server.blockmanagement.BlockManager: Creating a ReplicationWork to reconstruct blk_1073795547_54723
2024-07-02 06:18:52,910 DEBUG org.apache.hadoop.hdfs.server.blockmanagement.BlockManager: Creating a ReplicationWork to reconstruct blk_1073795548_54724
2024-07-02 06:18:52,910 DEBUG org.apache.hadoop.hdfs.server.blockmanagement.BlockManager: Creating a ReplicationWork to reconstruct blk_1073795549_54725
我们查看对应代码,这是由于RedundancyMonitor根据neededReconstruction中的low-redundancy block构造对应的BlockReconstruct的时候,会根据当前集群的Live节点的数量确定这一轮需要考量多少个low-redundancy Block。
RedundancyMonitor
会根据集群中当前Live节点的数量乘以一个倍数(dfs.namenode.replication.work.multiplier.per.iteration
)得到每轮循环取出的、尝试进行构造BlockReconstructionWork的Block数量。
相邻两轮循环之间RedundancyMonitor会sleep 3s。
在我们的小集群中,我们正在Decommission 5个节点,保留了2个节点,然后有一个节点正在加入到集群中,因此在我们的场景下,每轮(3s钟一轮)仅仅取出4个节点尝试构造BlockReconstructionWork,这个速度是非常低的。
我们将对应的参数调整为10,以增加RedundancyMonitor每轮处理的节点数量:
<property>
<name>dfs.namenode.replication.work.multiplier.per.iteration</name>
<value>10</value>
</property>
具体原理和代码可以参考我的另一篇文章HDFS块重构和RedundancyMonitor详解中讲解RedundancyMonitor的章节。
增加Stream Limit以避免节点被Skip
在构造BlockReconstructionWork的时候,RedundancyMonitor会首先挑选Source节点。
对于一个Block的所有的source节点,如果一个节点负载过高,那么这个节点就不应该被选择成为Source。显然,如果一个Block的所有Replica所在的节点的负载都很高因此没有被选作Source,那么本轮就不会为这个节点调度BlockReconstructionWork。
NameNode端评价一个DataNode负载是否过高,使用的标准是:已经调度到这个节点上(待调度的(重构任务还没有被DataNode的心跳认领)和已经调度出去(重构任务已经被DataNode的心跳认领)的)的Task的数量是否大于配置的阈值。这两个阈值分别是dfs.namenode.replication.max-streams
(默认值是2)和dfs.namenode.replication.max-streams-hard-limit
(默认值是4)。其中,dfs.namenode.replication.max-streams
其实是一个Soft Limit,即只约束普通优先级的Block,但是不约束QUEUE_HIGHEST_PRIORITY的块的重构。而 dfs.namenode.replication.max-streams-hard-limit
属于Hard Limit,也会约束QUEUE_HIGHEST_PRIORITY的块的重构。
在一个大型的HDFS集群中,我们可以使用默认值,因为块分布均匀,每一台机器上所承载的Replica所属的Block都只是集群所有Block中的一小部分。但是在我们只有几台节点的机器上,这些机器上所承载的块很多,并且几乎每一台DataNode机器上的Replica很可能Cover了集群中所有的Block,因此任何一台机器由于负载过高而被排除在source node以外都会导致所有Block的BlockReconstructionWork都无法被选作source nodes。
因此,我们需要将它们设置成一个较大的值:
<property>
<name>dfs.namenode.replication.max-streams</name>
<value>10</value>
</property>
<property>
<name>dfs.namenode.replication.max-streams-hard-limit</name>
<value>20</value>
</property>
具体原理和代码可以参考我的另一篇文章HDFS块重构和RedundancyMonitor详解中讲解RedundancyMonitor的章节。
节点被Skip时应该在DEBUG时打印原因
为了找到Decommission缓慢的原因,我们打开了DEBUG日志,发现RedundancyMonitor在尝试为neededReconstructionBlock构造BlockReconstructionWork的时候打印如下日志:
2024-07-02 03:20:54,311 DEBUG org.apache.hadoop.hdfs.server.blockmanagement.BlockManager: Block blk_1073810374_69550 cannot be reconstructed from any node
我们分析代码,看到RedundancyMonitor为一个Block调度对应的BlockReconstructionWork发生在方法BlockManager.computeReconstructionWorkForBlocks()中,其基本流程分为3步:
- source node的选择:构造对应的BlockReconstructionWork的实现。
- 对于基于Erasure Coding的和基于Replication的redundancy,BlockReconstructionWork的具体实现类分别是ErasureCodingWork和ReplicationWork。在选择Source node的过程中,涉及到很多不同的选择标准的总和考虑。对于基于Replication 的redundancy,如果我们无法为一个Block选择任何一个符合要求的source node,那么显然这个Reconstruction是无法工作的。
- target node的选择:target nodes的选择和我们写文件的过程中为一个Block选择target节点时一模一样的,都是使用的BlockPlacementPolicy接口的具体实现类。其中对于基于Replication和基于Erasure Coding的BlockPlacementPolicy接口实现各不相同,分别是BlockPlacementPolicyDefault和BlockPlacementPolicyRackFaultTolerant,这里不再赘述。
- 我们打开DEBUG以后,同时从代码中也看到,在选择目标节点过程中是有比较好的DEBUG日志的,详细打印在选择target节点的过程中一个Node因为哪些具体原因被排除。这些原因被定义在了NodeNotChosenReason中:
private enum NodeNotChosenReason { NOT_IN_SERVICE("the node is not in service"), NODE_STALE("the node is stale"), NODE_TOO_BUSY("the node is too busy"), TOO_MANY_NODES_ON_RACK("the rack has too many chosen nodes"), NOT_ENOUGH_STORAGE_SPACE("not enough storage space to place the block"), NO_REQUIRED_STORAGE_TYPE("required storage types are unavailable"), NODE_SLOW("the node is too slow");
- 我们打开DEBUG以后,同时从代码中也看到,在选择目标节点过程中是有比较好的DEBUG日志的,详细打印在选择target节点的过程中一个Node因为哪些具体原因被排除。这些原因被定义在了NodeNotChosenReason中:
- 当source node和target node都选择成功,还需要对构建的BlockReconstructionWork进行校验,这发生在方法validateReconstructionWork()中。如果校验不通过,同样这个Block的BlockReconstructionWork不会被调度。
所以,一个Low Redundancy Block最后却没有被成功调度BlockReconstructionWork,大致原因有上面的三种情况。但是很可惜,即使打开DEBUG日志,除了target node的选择失败会打印DEBUG日志,其他原因都不会打印日志。
因此我们向HDFS社区提了对应的Issue HDFS-17568,在DEBUG环境下,打印无法为某一个LowRedundancyBlock调度BlockReconstructionWork的具体原因。
在大量节点被Skip的时候加快有效调度
上面讲过,RedundancyMonitor的每一轮调度,都尝试从neededReconstruction中取出固定数量的Block尝试进行调度,每运行一轮,就会sleep 3s才进入下一轮。这个逻辑发生在方法computeBlockReconstructionWork()中:
int computeBlockReconstructionWork(int blocksToProcess) {
List<List<BlockInfo>> blocksToReconstruct = null;
blocksToReconstruct = neededReconstruction
.chooseLowRedundancyBlocks(blocksToProcess, reset); // 选择最多blocksToProcess个需要进行重构的Block
return computeReconstructionWorkForBlocks(blocksToReconstruct); // 对这些Block进行重构
}
具体流程为:
-
选择最多blocksToProcess个需要进行重构的Block,这些Block存放在一个二维数组中,数组的第一维表示这些Blocks调度的优先级,第二维度是对应优先级的Block的列表
blocksToReconstruct = neededReconstruction .chooseLowRedundancyBlocks(blocksToProcess, reset); // 选择最多blocksToProcess个需要进行重构的Block
-
对这些选定的需要重构的Block进行重构任务的构造和调度
return computeReconstructionWorkForBlocks(blocksToReconstruct); // 对这些Block进行重构
这里存在一个巨大的问题:通过chooseLowRedundancyBlocks()选定的重构的Block可能大部分由于各种原因(没有合适的source,没有合适的target,validate不通过等)并没有实际调度出BlockReconstructionWork。但是,即使这样,这一轮RedundancyMonitor结束了,要想到下一轮,必须等待3s。这极大拖累了BlockReconstructionWork的调度效率,即可能候选Block在每一轮都是固定的,但是其中有效的、成功的调度可能非常少。
在我们的集群中,我们的Decommission产生的Block在neededReconstruction中的优先级较低,如果有其他高优先级的Block但是这些高优先级的Block总是无法成功调度(比如source节点负载过高而被skip),那么为这些由Decommission带来的Low-Redundancy Block调度BlockReconstructionWork由于优先级很低,会进行无谓的空等过程。这非常像资源预留机制中的饿死现象:可用资源被预留给大应用,小应用却长时间无法获取资源去运行。
我们的优化逻辑是:当遇到无法为其构建BlockReconstructionWork的low-redundancy block时,在当前这一轮调度内,快进检查其他低冗余块并为其安排重建,从而在RedundancyMonitor的一轮运行中,尽量多地进行有效调度。当然,有效调度的块总数将受到参数blocksToProcess的限制。
具体issue和PR请查看HDFS-17569
其他可能改进
-
JMX中暴露一个节点的under replicated 的Block的数量
对于一个正在Decommission的节点,是否可以暴露对应的JMX metrics,显示这个节点的under replicate的块数量?这个数量的减少趋势直接反印了这个节点的Decommission的进展。 -
能够通过关键词将Decommission的几个大的异步的Stage串联起来
当Decommission看起来过慢的时候,我们直觉性地通过关键字Decommission搜索NameNode日志,但是很显然,基于我们在下文将会降到的一个节点Decommission的整个过程,由于其基于各种队列的异步特性,尤其是后面的Reconstruction等过程,其实与Decommission并无直接管理。
尽管这样,我们的问题是,在日志层面我们能否有所优化,比如,虽然,由于往neededReconstruioint中添加Block的原因很多,Decommission只是其中一个,我们是否可以添加辅助DEBUG日志给管理员一些提示,比如这些Block添加进neededReconstruction 的原因,因为Decommission,因为Maintenance,因为用户的手动升副本等等。这样,我们通过比如Decommission搜索,能够搜索到Decommission的全流程的一些进展数据,而不是仅仅是下文所讲的第一个Stage的DEBUG日志。
基本流程解析
下图总结了我们Decommission一个节点的整个过程:
在忽略具体细节的情况下,我们可以看到,一个节点的Decommissiion是由多个队列、多个线程完成的,而不是一个职责单一、阻塞式等待的过程。这种基于队列和多线程的异步设计,往往对于一个长时间运行的任务的调度是必须的,但是这种设计方式给我们对整个过程的跟踪、调试、日志的分析和理解带来了非常大的麻烦,因为逻辑上的因果关系不再和时间上的因果关系一一对应,事件发生的有序性不再存在,而是以流水线的方式互相重叠。
- 第一阶段: 当我们通过
-refreshNodes
触发一个Decommission或者进入Maintenance的操作的时候,将节点的AdminState置为ENTERING_MAINTENANCE或者DECOMMISSION_IN_PROGRESS的状态并将节点加入到pendingNodes以后,触发结束,第一阶段的任务完成。此后如果用户再次通过-refreshNodes
重新出发,NameNode仅仅检查发现当前已经处于AdminState的ENTERING_MAINTENANCE或者DECOMMISSION_IN_PROGRESS,就什么也不做就直接退出。 - 第二阶段:独立线程DatanodeAdminDefaultMonitor从pendingNodes中取出待decommission或者maintenance的节点进行块的扫描和调度。
- 对节点上的所有Block进行一轮全扫描,全扫描过程中,对于确定由于decommission或者maintenance操作会导致low-redundancy的Block,放入neededReconstruction中待构造ReconstructionWork,同时生成一个low-redundancy的Block的List(insufficientList)。
- 随后,基于insufficientList中的每一个Block进行剪枝扫描,同样检查将需要加入到neededReconstruction中的Block加入进去,并且由于重构的发生所以副本已经充足因此不会阻塞Decommission的Block从insufficientList中删除。
- 第三阶段:独立线程RedundancyMonitor从neededReconstruction中取出块,进行Reconstruction的任务调度。任务调度主要需要确认可用的source节点和target节点。这个过程中可能因为各种原因无法调度成功;
- 第四阶段:DataNode端在收到调度任务以后进行数据的读写和拷贝,并将重构完成的块汇报给NameNode;
- 第五阶段:收到块汇报的NameNode会重新考量块的副本情况。对于由于重构的发生副本数已经充足的节点,就从neededReconstruction中删除。同时,DatanodeAdminDefaultMonitor在不断地扫描和判定过程中也会发现这个块的副本数已经充足,因此从insufficientList中将该块删除。当一个Node对应的insufficientList被清空,这个Node就可以最终进入Decommission或者Maintenance状态。
用户通过节点刷新触发Decommission
我们通过自定义的 exludes 节点来告诉HDFS我们需要Decommision的节点列表:
<property>
<name>dfs.hosts.exclude</name>
<value>/hadoop/decommissioned-datanodes</value>
</property>
然后我们通过刷新节点命令,触发对这些节点的decommission操作(参考 Hadoop Official Doc)
$HADOOP_HOME/bin/hdfs dfsdmin -refreshNodes
实际上,是出发了DatanodeManager的refreshDatanodes()方法:
private void refreshDatanodes() {
.....
for (DatanodeDescriptor node : copy.values()) {
// Check if not include.
if (!hostConfigManager.isIncluded(node)) {
node.setDisallowed(true); // 这个Node是显式包含在include中的,因此不允许对这个节点进行操作
} else {
// 只有不在includes中的节点,才有可能进入maintenance或者进入decommission
long maintenanceExpireTimeInMS =
hostConfigManager.getMaintenanceExpirationTimeInMS(node);
if (node.maintenanceNotExpired(maintenanceExpireTimeInMS)) {
// maintenance时间还没有到期
datanodeAdminManager.startMaintenance( // 开始maintenance
node, maintenanceExpireTimeInMS);
} else if (hostConfigManager.isExcluded(node)) {
//
datanodeAdminManager.startDecommission(node); // 对于在exclude中的节点执行decommission操作
} else {
//离开maintenance,离开decommission
datanodeAdminManager.stopMaintenance(node);
datanodeAdminManager.stopDecommission(node);
}
}
node.setUpgradeDomain(hostConfigManager.getUpgradeDomain(node));
}
}
这里涉及到对于节点maintenance和decommision的处理逻辑,最终会交付给DatanodeAdminManager,其基本逻辑为:
2. 在include文件(dfs.hosts.exclude配置文件中的节点列表)中的节点,是绝对不会进入maintenance中或者decommission中的
3. 如果节点不在includes中
4. 如果节点的确进入了maintenance,并且maintenance还没有到期,那么就继续进行maintenance
5. 如果节点在excludes中,那么就通过调用startDecommision()状态
6. 如果都不是,意味着节点不应该处于maintenance状态,也不应该处于decommissiion状态,那么这时候会通过调用stopMaintenance()和stopDecommission()结束这两种状态。
这里不具体讲述maintenance的具体细节,主要关注在decommission的过程:
@VisibleForTesting
public void startDecommission(DatanodeDescriptor node) {
if (!node.isDecommissionInProgress() && !node.isDecommissioned()) {
// Update DN stats maintained by HeartbeatManager
hbManager.startDecommission(node);
// Update cluster's emptyRack
blockManager.getDatanodeManager().getNetworkTopology().decommissionNode(node);
// hbManager.startDecommission will set dead node to decommissioned.
if (node.isDecommissionInProgress()) {
for (DatanodeStorageInfo storage : node.getStorageInfos()) {
LOG.info("Starting decommission of {} {} with {} blocks",
node, storage, storage.numBlocks());
}
node.getLeavingServiceStatus().setStartTime(monotonicNow());
monitor.startTrackingNode(node);
}
} else {
LOG.trace("startDecommission: Node {} in {}, nothing to do.",
node, node.getAdminState());
}
}
从上面代码我们看到:
-
如果节点既不是正在decommission,也不是已经完成了decommission,那么就执行decommission的启动工作:
if (!node.isDecommissionInProgress() && !node.isDecommissioned()) { // Update DN stats maintained by HeartbeatManager ......
启动Decommission的具体过程为:
- 通过HeartbeatManager,将DataNode的AdminStates.DECOMMISSION_INPROGRESS
其实质上是将这个hbManager.startDecommission(node);
DatanodeDescriptor
中adminState
置为DECOMMISSION_INPROGRESS
状态------------------------------------------------ HeartbeatManager ------------------------
- 通过HeartbeatManager,将DataNode的AdminStates.DECOMMISSION_INPROGRESS