基于目前Hadoop的实现,在很多时候大家都会诟病于它的NameNode/JobTracker单点故障问题,特别是NameNode节点,一旦它发生了不可恢复的故障之后就意味着整个HDFS文件系统不在可用了。对于NameNode节点的单点故障问题,Hadoop目前采取的解决办法是冷备份,就是在HDFS集群中另外开启一个SecondaryNameNode节点,这个节点会定期地对NameNode节点上的元数据进行备份(这一点请参看博文:HDFS中的SecondaryNameNode节点解析),缺点就是NameNode节点一旦崩溃或出错,整个HDFS集群将不得不停止运行,因为它不会自动地切换到另一个备用的NameNode节点,当然HDFS集群中目前也没有这样的节点。不过笔者在写这篇博客的时候已经得知,Hadoop将会在下一个版本中针对这个问题引入Hadoop社区的HA解决方案来克服NameNode节点的SPOF问题,而且HDFS的集群将可达到6000节点的规模。JobTracker节点的SPOF问题也类似于NameNode节点,唯一不同的是并不需要另外的一个单独的节点来对JobTracker上的数据进行备份,因为JobTracker节点上的重要数据都可以保存到一个分布式文件系统上,如HDFS等。不过这里有一个问题就是,如果JobTracker节点由于意外情况而宕机的话,那么可能有一部分Job正在执行,也有一部分Job被用户成功提交了可还没有开始被调度执行,那么当我们重启JobTracker节点的时候就需要恢复或者重做这些还没有完成的Job。所以,本文将重点讲述JobTracker节点上的作业恢复管理器——RecoveryManager。
对于Hadoop中的Map-Reduce集群,配置有一个系统目录,客户端在向JobTracker节点提交一个Job之前会为该Job在集群的系统目录下创建一个子目录(子目录的名字是Job的Id),然后会把该Job所需要的一切文件copy到它的目录下(这一点我也在前面的博文Job的提交—客户端中讲过),这个系统目录一般会在分布式文件系统中,它可以通过JobTracker节点的配置文件来设置,对应的配置项为:mapred.system.dir。如果一个Job被完成了,那么这的Job在系统目录下的数据将会被自动清除,而JobTracker节点在启动的时候也会检查系统目录下有没有子目录(每一个子目录对应一个没有完成的Job),如果有没完成的作业的话,这个Job会被提交到添加到RecoveryManager的作业缓存jobsToRecover中,以便被JobTracker节点恢复或重新执行。这里要着重强调的是,RecoveryManager并不负责上一次未完成Job的调度执行,而只是恢复对这些作业的管理,即让这些作业的任务状态恢复到各自重启之前的状态。
在RecoveryManager正式启动恢复上一次没完成的Job之前,会先干一件事情,这件事就是往系统目录下的一个文件中写入一个数据,这个数据就是当前Map-Reduce集群重启的次数,而这个文件就是jobtracker.info。这里要说的是RecoveryManager启动对未完成Job的恢复是在JobTracker节点的主线程中完成的,而且是在JobTracker节点的所有后台线程启动之前,这个调用必须要在所有的未完成的Job被完成之后才返回。也就是说,JobTracker的作业恢复管理器在恢复作业的处理过程中,JobTracker节点不会接受客户端的任何请求,也不接受TaskTracker的任何请求。
RecoveryManager对作业的恢复(本质上是恢复作业的各个任务的执行状态)依赖于作业对应的执行日志文件,该日志文件详细的记录了作业及其任务状态变化的详细信息。RecoveryManager通过对作业的日志文件的解析即可恢复该作业的状态到重启之前的状态了。每一个作业的日志文件都作为一个单独的文件存储在系统的作业历史目录下面,该目录可以通过JobTracker节点的配置文件来设置,相应的配置项为:hadoop.job.history.location。关于作业日志文件内容的格式,我将在介绍作业日志记录器是详细讲述,而一个作业的日志文件名的格式如下图:
在对作业的管理恢复过程中,RecoveryManager会记录与这些恢复的作业任务相关联的TaskTracker节点和Task实例。例如在回复一个作业的某一个Task实例状态的时候,它会记录执行该Task实例的TaskTracker节点并将该节点放入trackerExpiryQueue中,同时也把该Task实例添加到JobTracker节点的任务监控对列expireLaunchingTasks中,如果该日志文件中记录了该Task实例已经完成了的话(无论成功还是失败),就把该Task实例从expireLaunchingTasks中删除。在RecoveryManager恢复了重启之前未完成作业的管理之后,JobTracker节点就可以启动其它的工作线程了,当然包括RPC服务组件。之后,JobTracker节点就可以接受用户提交作业的请求、TaskTracker节点的心跳包,但是此时JobTracker节点并不会为TaskTracker分配任何任务,除非被RecoveryManager标记过的TaskTracker节点在JobTracker节点重启之后都向它注册或发送过心跳包,总之JobTracker要明确地知道与本次恢复的作业相关的所有TaskTracker节点是活着还是挂了,对于还是活着的TaskTracker节点,JobTracker节点就可以确定在其上已执行完的Task是可用的,对于已经挂掉的TaskTracker节点(通过JobTracker节点的一个后台工作线程来对trackerExpiryQueue进行监控),JobTracker就可以通知对应的JobInProgress和TaskInProgress该Task实例已经失效了。对于那些RecoveryManager认为与本次恢复作业相关的正在TaskTracker节点上执行的Task实例,如果该Task实例确实正在对应的TaskTracker节点上执行,那么该TaskTracker节点会不断的向JobTracker节点报告该Task实例的执行状态,而JobTracker节点在接收到该Task实例的状态报告之后会将其从expireLaunchingTasks中删除;如果该Task实例没有在对应的TaskTracker节点上执行,那没就没有TaskTracker节点向JobTracker节点报告该Task实例的执行状态,那么该Task实例会被运行在JobTracker节点的一个后台工作线程检测出来,该后台线程回认为该Task实例已经执行失败并将该情况通知给对应的JobInProgress来处理。
其实,对于与本次恢复作业相关的已成功完成的Task实例的处理还有一个异常情况:如果一个被RecoveryManager认为已经成功执行完某一个Task实例的TaskTracker节点在JobTracker节点重启的时候自己也重启了,那么,已经在该TaskTracker节点上完成的Task实例失效了,而JobTracker节点是不会知道这个情况的而继续认为该Task实例是可用的。JobTracker节点虽然不会马上知道这一情况,但它最终还是会知道的,因为,如果该Task实例是Reduce型的,那么已执行完的Reduce任务实例不受TaskTracker节点的影响;如果该该Task实例是Map型的,那么当对应的Reduce任务是无法抓取该Task实例的输出数据的,从而Reduce任务实例会向JobTracker节点报告而知道这一异常情况了。
从JobTracker节点对作业的恢复处理来看,它是特别耗时间的(主要用在等待与恢复作业任务相关的TaskTracker节点的unmark上),所以对于短作业居多的应用场景,笔者并不赞同开启RecoveryManager,而对于长作业居多的应用场景来说,开始RecoveryManager还是值得的。关于开启/关闭RecoveryManager,可以通过JobTracker节点的配置文件来设置,对应的配置项为:mapred.jobtracker.restart.recover。启动未完成Job的恢复工作其实也很简单,不做详细的讨论,它的源码如下:
public void recover() { if (!shouldRecover()) { // clean up jobs structure jobsToRecover.clear(); return; } LOG.debug("Restart count of the jobtracker : " + restartCount); // I. Init the jobs and cache the recovered job history filenames Map<JobID, Path> jobHistoryFilenameMap = new HashMap<JobID, Path>(); Iterator<JobID> idIter = jobsToRecover.iterator(); while (idIter.hasNext()) { JobID id = idIter.next(); LOG.info("Trying to recover Job[" + id + "]..."); try { // 1. Create the job object LOG.debug("creating a JobInProgress for Job["+id+"].."); JobInProgress job = new JobInProgress(id, JobTracker.this, conf, restartCount); // 2. Check if the user has appropriate access Get the user group info for the job's owner UserGroupInformation ugi = UserGroupInformation.readFrom(job.getJobConf()); LOG.debug("User["+ugi.getUserName()+":"+StringUtils.arrayToString(ugi.getGroupNames())+"] submit the Job["+id+"]."); // check the access try { LOG.debug("checking whether User["+ugi.getUserName()+":"+StringUtils.arrayToString(ugi.getGroupNames())+"] is able to submit the Job["+id+"]"); checkAccess(job, QueueManager.QueueOperation.SUBMIT_JOB, ugi); } catch (Throwable t) { LOG.warn("Access denied for user " + ugi.getUserName() + " in groups : [" + StringUtils.arrayToString(ugi.getGroupNames()) + "]"); throw t; } // 3. Get the log file and the file path String logFileName = JobHistory.JobInfo.getJobHistoryFileName(job.getJobConf(), id); if (logFileName != null) { Path jobHistoryFilePath = JobHistory.JobInfo.getJobHistoryLogLocation(logFileName); // 4. Recover the history file. This involved // - deleting file.recover if file exists // - renaming file.recover to file if file doesnt exist // This makes sure that the (master) file exists JobHistory.JobInfo.recoverJobHistoryFile(job.getJobConf(), jobHistoryFilePath); // 5. Cache the history file name as it costs one dfs access jobHistoryFilenameMap.put(job.getJobID(), jobHistoryFilePath); } else { LOG.info("No history file found for job " + id); idIter.remove(); // remove from recovery list } // 6. Sumbit the job to the jobtracker addJob(id, job); } catch (Throwable t) { LOG.warn("Failed to recover job " + id + " Ignoring the job.", t); idIter.remove(); continue; } } long recoveryStartTime = System.currentTimeMillis(); // II. Recover each job idIter = jobsToRecover.iterator(); while (idIter.hasNext()) { JobID id = idIter.next(); JobInProgress pJob = getJob(id); // 1. Get the required info // Get the recovered history file Path jobHistoryFilePath = jobHistoryFilenameMap.get(pJob.getJobID()); String logFileName = jobHistoryFilePath.getName(); FileSystem fs; try { fs = jobHistoryFilePath.getFileSystem(conf); } catch (IOException ioe) { LOG.warn("Failed to get the filesystem for job " + id + ". Ignoring.", ioe); continue; } // 2. Parse the history file // Note that this also involves job update JobRecoveryListener listener = new JobRecoveryListener(pJob); try { JobHistory.parseHistoryFromFS(jobHistoryFilePath.toString(), listener, fs); } catch (Throwable t) { LOG.info("Error reading history file of job " + pJob.getJobID() + ". Ignoring the error and continuing.", t); } // 3. Close the listener listener.close(); // 4. Update the recovery metric totalEventsRecovered += listener.getNumEventsRecovered(); // 5. Cleanup history // Delete the master log file as an indication that the new file // should be used in future try { synchronized (pJob) { JobHistory.JobInfo.checkpointRecovery(logFileName,pJob.getJobConf()); } } catch (Throwable t) { LOG.warn("Failed to delete log file (" + logFileName + ") for job " + id + ". Continuing.", t); } if (pJob.isComplete()) { idIter.remove(); // no need to keep this job info as its successful } } recoveryDuration = System.currentTimeMillis() - recoveryStartTime; hasRecovered = true; // III. Finalize the recovery synchronized (trackerExpiryQueue) { // Make sure that the tracker statuses in the expiry-tracker queue // are updated long now = System.currentTimeMillis(); int size = trackerExpiryQueue.size(); for (int i = 0; i < size ; ++i) { // Get the first status TaskTrackerStatus status = trackerExpiryQueue.first(); // Remove it trackerExpiryQueue.remove(status); // Set the new time status.setLastSeen(now); // Add back to get the sorted list trackerExpiryQueue.add(status); } } LOG.info("Restoration complete"); }