Scala-和-Spark-大数据分析-七-

31 阅读1小时+

Scala 和 Spark 大数据分析(七)

原文:annas-archive.org/md5/39eecc62e023387ee8c22ca10d1a221a

译者:飞龙

协议:CC BY-NC-SA 4.0

第十六章:Spark 调优

“竖琴手把 90%的时间花在调弦上,只有 10%的时间是在演奏不和谐的音乐。”

  • 伊戈尔·斯特拉文斯基

在本章中,我们将深入探讨 Apache Spark 的内部机制,看到尽管 Spark 让我们感觉就像在使用另一个 Scala 集合,但我们不能忘记 Spark 实际上运行在一个分布式系统中。因此,需要额外的注意。简而言之,本章将涵盖以下主题:

  • 监控 Spark 作业

  • Spark 配置

  • Spark 应用程序开发中的常见错误

  • 优化技巧

监控 Spark 作业

Spark 提供了 Web UI,用于监控在计算节点(驱动程序或执行器)上运行或已完成的所有作业。在本节中,我们将简要讨论如何使用 Spark Web UI 监控 Spark 作业,并通过适当的示例来说明。我们将看到如何监控作业的进度(包括提交、排队和运行中的作业)。所有 Spark Web UI 中的选项卡将简要讨论。最后,我们将讨论 Spark 的日志记录过程,以便更好地进行调优。

Spark Web 界面

Web UI(也称为 Spark UI)是一个 Web 界面,用于运行 Spark 应用程序,以便在 Firefox 或 Google Chrome 等 Web 浏览器中监控作业的执行。当 SparkContext 启动时,一个显示应用程序有用信息的 Web UI 会在独立模式下启动,并监听 4040 端口。Spark Web UI 的可用方式根据应用程序是仍在运行还是已完成执行而有所不同。

此外,你可以在应用程序执行完成后通过使用EventLoggingListener将所有事件持久化,从而使用 Web UI。然而,EventLoggingListener不能单独工作,必须结合 Spark 历史服务器一起使用。将这两项功能结合起来,可以实现以下设施:

  • 调度器阶段和任务列表

  • RDD 大小的摘要

  • 内存使用

  • 环境信息

  • 关于正在运行的执行器的信息

你可以通过在 Web 浏览器中访问http://<driver-node>:4040来访问 UI。例如,在独立模式下提交并运行的 Spark 作业可以通过http://localhost:4040访问。

请注意,如果多个 SparkContext 在同一主机上运行,它们将绑定到从 4040 开始的连续端口,依次为 4041、4042 等。默认情况下,这些信息仅在 Spark 应用程序运行期间有效。这意味着当你的 Spark 作业执行完成时,这些绑定将不再有效或可访问。

只要作业正在运行,就可以在 Spark UI 上观察到阶段。然而,要在作业完成执行后查看 Web UI,你可以在提交 Spark 作业之前将spark.eventLog.enabled设置为 true。这会强制 Spark 将所有事件记录到存储中(如本地文件系统或 HDFS),以便在 UI 中显示。

在前一章中,我们看到如何将 Spark 作业提交到集群。让我们重用其中一个命令来提交 k-means 聚类,如下所示:

# Run application as standalone mode on 8 cores
SPARK_HOME/bin/spark-submit \
 --class org.apache.spark.examples.KMeansDemo \
 --master local[8] \
 KMeansDemo-0.1-SNAPSHOT-jar-with-dependencies.jar \
 Saratoga_NY_Homes.txt

如果您使用上述命令提交作业,则无法看到已完成执行的作业的状态,因此要使更改永久化,请使用以下两个选项:

spark.eventLog.enabled=true 
spark.eventLog.dir=file:///home/username/log"

通过设置前两个配置变量,我们要求 Spark 驱动程序启用事件日志记录并保存到file:///home/username/log

总结地说,通过以下更改,您的提交命令将如下所示:

# Run application as standalone mode on 8 cores
SPARK_HOME/bin/spark-submit \
 --conf "spark.eventLog.enabled=true" \
 --conf "spark.eventLog.dir=file:///tmp/test" \
 --class org.apache.spark.examples.KMeansDemo \
 --master local[8] \
 KMeansDemo-0.1-SNAPSHOT-jar-with-dependencies.jar \
 Saratoga_NY_Homes.txt

图 1: Spark Web UI

如前面的屏幕截图所示,Spark Web UI 提供以下标签:

  • 作业

  • 阶段

  • 存储

  • 环境

  • 执行器

  • SQL

需要注意的是,并非所有功能一次性可见,例如在运行流式作业时。

作业

根据 SparkContext 的不同,作业标签显示 Spark 应用程序中所有 Spark 作业的状态。当您使用 Web 浏览器访问 Spark UI 上的作业标签,地址为http://localhost:4040(对于独立模式),您应该看到以下选项:

  • 用户:显示提交 Spark 作业的活跃用户

  • 总正常运行时间:显示作业的总正常运行时间

  • 调度模式:在大多数情况下,是先进先出(即 FIFO)

  • 活跃作业:显示活跃作业的数量

  • 已完成作业:显示已完成作业的数量

  • 事件时间线:显示已完成执行的作业的时间线

在内部,作业标签由JobsTab类表示,它是具有作业前缀的自定义 SparkUI 标签。作业标签使用JobProgressListener访问关于 Spark 作业的统计信息,以在页面上显示上述信息。请看下面的屏幕截图:

图 2: Spark Web UI 中的作业标签

如果您在作业标签中进一步展开“活跃作业”选项,您将能够看到执行计划、状态、已完成阶段的数量以及该特定作业的作业 ID,如 DAG 可视化所示:

图 3: Spark Web UI 中任务的 DAG 可视化(简化版)

当用户在 Spark 控制台中输入代码(例如,Spark shell 或使用 Spark submit),Spark Core 创建操作图。这基本上是当用户在特定节点上对 RDD 执行操作(例如 reduce、collect、count、first、take、countByKey、saveAsTextFile)或转换(例如 map、flatMap、filter、mapPartitions、sample、union、intersection、distinct)时发生的情况,这些 RDD 是不可变对象。

图 4: DAG 调度程序将 RDD 衍生线路转换为阶段 DAG

在变换或动作期间,有向无环图DAG)信息用于恢复节点至最后的变换和动作(参考图 4图 5以获得更清晰的视图),以保持数据的容错性。最终,图会被提交给 DAG 调度器。

Spark 如何从 RDD 计算 DAG 并随后执行任务?

从高层次来看,当对 RDD 调用任何动作时,Spark 会创建 DAG 并将其提交给 DAG 调度器。DAG 调度器将操作符划分为任务阶段。一个阶段包含基于输入数据分区的任务。DAG 调度器将操作符串联在一起。例如,多个 map 操作符可以在单一阶段内调度。DAG 调度器的最终结果是一个阶段集合。这些阶段会传递给任务调度器。任务调度器通过集群管理器(Spark Standalone/YARN/Mesos)启动任务。任务调度器并不知道阶段之间的依赖关系。工作节点在阶段上执行任务。

DAG 调度器随后会跟踪阶段输出的 RDD 来源。它会找到一个最小化的调度来运行作业,并将相关的操作符划分为任务阶段。根据输入数据的分区,一个阶段包含多个任务。然后,操作符与 DAG 调度器一起进行流水线化。例如,多个 map 或 reduce 操作符(例如)可以在一个阶段中调度。

图 5: 执行动作会导致 DAGScheduler 中新的 ResultStage 和 ActiveJob 的产生

DAG 调度器中的两个基本概念是作业和阶段。因此,它必须通过内部注册表和计数器进行跟踪。技术上讲,DAG 调度器是 SparkContext 初始化的一部分,仅在驱动程序上工作(任务调度器和调度器后端准备好之后立即)。DAG 调度器负责 Spark 执行中的三项主要任务。它计算作业的执行 DAG,也就是阶段的 DAG。它确定每个任务的首选节点,并处理由于 shuffle 输出文件丢失而导致的失败。

图 6: 由 SparkContext 创建的 DAGScheduler 与其他服务

DAG 调度器的最终结果是一个阶段集合。因此,大多数统计数据和作业状态可以通过这种可视化查看,例如执行计划、状态、已完成的阶段数量以及特定作业的作业 ID。

阶段

Spark UI 中的阶段选项卡显示 Spark 应用程序中所有作业的所有阶段的当前状态,包括阶段和池详情的任务和阶段统计信息的两个可选页面。请注意,仅当应用程序以公平调度模式运行时才可用此信息。您应该能够在http://localhost:4040/stages访问阶段选项卡。请注意,当没有提交作业时,该选项卡除标题外不显示任何内容。阶段选项卡显示 Spark 应用程序中的阶段。在此选项卡中可以看到以下阶段:

  • 活跃阶段

  • 待处理阶段

  • 已完成阶段

例如,当您本地提交 Spark 作业时,您应该能够看到以下状态:

图 7: Spark Web UI 中所有作业的阶段

在这种情况下,只有一个活动阶段。然而,在接下来的章节中,当我们将我们的 Spark 作业提交到 AWS EC2 集群时,我们将能够观察到其他阶段。

要进一步深入到已完成作业的摘要,请单击“描述”列中包含的任何链接,您应该找到有关执行时间统计作为度量标准的相关统计信息。在度量标准中,还可以看到最小值、中位数、第 25 百分位数、第 75 百分位数和最大值的大约时间。以下图中还可以看到:

图 8: Spark Web UI 上已完成作业的摘要

您的情况可能不同,因为在撰写本书期间,我仅执行并提交了两个作业以进行演示。您还可以查看有关执行器的其他统计信息。对于我的情况,我通过使用 8 核和 32 GB RAM 在独立模式下提交了这些作业。除此之外,还显示了与执行器相关的信息,例如 ID、带有相关端口号的 IP 地址、任务完成时间、任务数量(包括失败任务、被杀任务和成功任务的数量)以及每个记录的数据集输入大小。

图像中的其他部分显示与这两个任务相关的其他信息,例如索引、ID、尝试、状态、本地性级别、主机信息、启动时间、持续时间、垃圾收集GC)时间等。

存储

存储选项卡显示每个 RDD、DataFrame 或 Dataset 的大小和内存使用情况。您应该能够看到 RDDs、DataFrames 或 Datasets 的存储相关信息。以下图显示存储元数据,例如 RDD 名称、存储级别、缓存分区的数量、已缓存数据的分数百分比以及 RDD 在主内存中的大小:

图 9: 存储选项卡显示 RDD 在磁盘中消耗的空间

请注意,如果 RDD 无法缓存在主内存中,则将使用磁盘空间。在本章的后续部分将进行更详细的讨论。

图 10: RDD 在磁盘中的数据分布和使用的存储

环境

Environment 标签页显示当前在你的机器(即驱动程序)上设置的环境变量。更具体地说,可以在 Runtime Information 下看到运行时信息,如 Java Home、Java 版本和 Scala 版本。Spark 属性,如 Spark 应用程序 ID、应用程序名称、驱动程序主机信息、驱动程序端口、执行器 ID、主节点 URL 以及调度模式等,也可以查看。此外,系统相关属性和作业属性,如 AWT 工具包版本、文件编码类型(例如 UTF-8)和文件编码包信息(例如 sun.io)也可以在 System Properties 下查看。

图 11: Spark Web UI 上的 Environment 标签页

Executors

Executors 标签页使用ExecutorsListener收集关于 Spark 应用程序执行器的信息。执行器是一个分布式代理,负责执行任务。执行器的实例化方式有多种。例如,它们可以在CoarseGrainedExecutorBackend接收到RegisteredExecutor消息时实例化,适用于 Spark Standalone 和 YARN。第二种情况是在 Spark 作业提交到 Mesos 时,Mesos 的MesosExecutorBackend会进行注册。第三种情况是当你本地运行 Spark 作业时,也就是LocalEndpoint被创建。执行器通常在整个 Spark 应用程序生命周期内运行,这称为执行器的静态分配,尽管你也可以选择动态分配。执行器后端专门管理计算节点或集群中的所有执行器。执行器定期向驱动程序的HeartbeatReceiver RPC 端点报告心跳和活动任务的部分度量,结果会发送回驱动程序。它们还为通过块管理器由用户程序缓存的 RDD 提供内存存储。有关更清晰的理解,请参见下图:

图 12: Spark 驱动程序实例化一个执行器,负责处理 HeartbeatReceiver 的心跳消息

当执行器启动时,它首先向驱动程序注册,并直接与驱动程序通信以执行任务,如下图所示:

图 13: 使用 TaskRunners 在执行器上启动任务

你应该能够通过http://localhost:4040/executors访问 Executors 标签页。

图 14: Spark Web UI 上的 Executors 标签页

如上图所示,可以查看与执行器相关的信息,包括 Executor ID、地址、状态、RDD 块、存储内存、磁盘使用、核心数、活动任务、失败任务、完成任务、总任务数、任务时间(GC 时间)、输入、Shuffle 读取、Shuffle 写入和线程转储。

SQL

Spark UI 中的 SQL 选项卡显示每个操作符的所有累加器值。你应该能够通过http://localhost:4040/SQL/访问 SQL 选项卡。默认情况下,它会显示所有 SQL 查询执行及其底层信息。然而,SQL 选项卡仅在选择查询后才会显示 SQL 查询执行的详细信息。

SQL 的详细讨论超出了本章的范围。感兴趣的读者可以参考spark.apache.org/docs/latest/sql-programming-guide.html#sql,了解如何提交 SQL 查询并查看其结果输出。

使用 Web UI 可视化 Spark 应用程序

当提交一个 Spark 作业执行时,会启动一个 Web 应用程序 UI,展示关于该应用程序的有用信息。事件时间轴显示应用程序事件的相对顺序和交错情况。时间轴视图有三个级别:跨所有作业、单个作业内部和单个阶段内部。时间轴还显示执行器的分配和解除分配情况。

图 15: Spark 作业作为 DAG 在 Spark Web UI 上执行

观察正在运行和已完成的 Spark 作业

要访问和观察正在运行及已完成的 Spark 作业,请在 Web 浏览器中打开http://spark_driver_host:4040。请注意,你需要根据实际情况将spark_driver_host替换为相应的 IP 地址或主机名。

请注意,如果多个 SparkContext 在同一主机上运行,它们会绑定到从 4040 开始的连续端口,如 4040、4041、4042 等。默认情况下,这些信息仅在 Spark 应用程序执行期间可用。这意味着当 Spark 作业完成执行后,该绑定将不再有效或可访问。

现在,要访问仍在执行的活动作业,请点击“活动作业”链接,你将看到相关作业的信息。另一方面,要访问已完成作业的状态,请点击“已完成作业”,你将看到如前一节所述的 DAG 样式的相关信息。

图 16: 观察正在运行和已完成的 Spark 作业

你可以通过点击“活动作业”或“已完成作业”下的作业描述链接来实现这些操作。

使用日志调试 Spark 应用程序

查看所有正在运行的 Spark 应用程序的信息取决于你使用的集群管理器。在调试 Spark 应用程序时,请遵循以下说明:

  • Spark 独立模式:前往 Spark 主节点 UI,地址为http://master:18080。主节点和每个工作节点会展示集群及相关作业的统计信息。此外,每个作业的详细日志输出也会写入每个工作节点的工作目录。我们将在后续内容中讨论如何使用log4j手动启用 Spark 日志。

  • YARN:如果你的集群管理器是 YARN,并假设你正在 Cloudera(或任何其他基于 YARN 的平台)上运行 Spark 作业,那么请前往 Cloudera Manager 管理控制台中的 YARN 应用程序页面。现在,要调试运行在 YARN 上的 Spark 应用程序,请查看 Node Manager 角色的日志。为此,打开日志事件查看器,然后过滤事件流,选择一个时间窗口和日志级别,并显示 Node Manager 源。你也可以通过命令访问日志。命令的格式如下:

 yarn logs -applicationId <application ID> [OPTIONS]

例如,以下是这些 ID 的有效命令:

 yarn logs -applicationId application_561453090098_0005 
 yarn logs -applicationId application_561453090070_0005 userid

请注意,用户 ID 是不同的。然而,只有当 yarn.log-aggregation-enableyarn-site.xml 中为 true,并且应用程序已经完成执行时,这种情况才成立。

使用 log4j 在 Spark 中记录日志

Spark 使用 log4j 进行自己的日志记录。所有后台发生的操作都会记录到 Spark shell 控制台(该控制台已经配置到底层存储)。Spark 提供了一个 log4j 的模板作为属性文件,我们可以扩展并修改该文件来进行 Spark 中的日志记录。进入 SPARK_HOME/conf 目录,你应该能看到 log4j.properties.template 文件。这可以作为我们自己日志系统的起点。

现在,让我们在运行 Spark 作业时创建我们自己的自定义日志系统。当你完成后,将文件重命名为 log4j.properties 并放在同一目录下(即项目树中)。文件的一个示例快照如下所示:

图 17: log4j.properties 文件的快照

默认情况下,所有日志都会发送到控制台和文件。然而,如果你想将所有噪音日志绕过,发送到一个系统文件,例如 /var/log/sparkU.log,那么你可以在 log4j.properties 文件中设置这些属性,如下所示:

log4j.logger.spark.storage=INFO, RollingAppender
log4j.additivity.spark.storage=false
log4j.logger.spark.scheduler=INFO, RollingAppender
log4j.additivity.spark.scheduler=false
log4j.logger.spark.CacheTracker=INFO, RollingAppender
log4j.additivity.spark.CacheTracker=false
log4j.logger.spark.CacheTrackerActor=INFO, RollingAppender
log4j.additivity.spark.CacheTrackerActor=false
log4j.logger.spark.MapOutputTrackerActor=INFO, RollingAppender
log4j.additivity.spark.MapOutputTrackerActor=false
log4j.logger.spark.MapOutputTracker=INFO, RollingAppender
log4j.additivty.spark.MapOutputTracker=false

基本上,我们希望隐藏 Spark 生成的所有日志,以便我们不必在 shell 中处理它们。我们将它们重定向到文件系统中进行日志记录。另一方面,我们希望我们自己的日志能够在 shell 和单独的文件中记录,这样它们就不会与 Spark 的日志混合。在这里,我们将 Splunk 指向我们自己日志所在的文件,在这个特定的情况下是 /var/log/sparkU.log*。

然后,log4j.properties 文件将在应用程序启动时被 Spark 自动加载,所以我们除了将其放置在指定位置外,别无他法。

现在让我们来看一下如何创建我们自己的日志系统。请查看以下代码并尝试理解这里发生了什么:

import org.apache.spark.{SparkConf, SparkContext}
import org.apache.log4j.LogManager
import org.apache.log4j.Level
import org.apache.log4j.Logger

object MyLog {
 def main(args: Array[String]):Unit= {
   // Stting logger level as WARN
   val log = LogManager.getRootLogger
   log.setLevel(Level.WARN)

   // Creating Spark Context
   val conf = new SparkConf().setAppName("My App").setMaster("local[*]")
   val sc = new SparkContext(conf)

   //Started the computation and printing the logging information
   log.warn("Started")                        
   val data = sc.parallelize(1 to 100000)
   log.warn("Finished")
 }
}

上述代码在概念上仅记录警告信息。它首先打印警告信息,然后通过并行化从 1 到 100,000 的数字来创建一个 RDD。RDD 作业完成后,它会打印另一个警告日志。然而,我们还没有注意到之前代码段中的一个问题。

org.apache.log4j.Logger 类的一个缺点是它不可序列化(更多细节请参考优化技术部分),这意味着我们不能在 Spark API 的某些部分操作时将其用于 闭包 中。例如,如果你尝试执行以下代码,你应该会遇到一个异常,提示任务不可序列化:

object MyLog {
  def main(args: Array[String]):Unit= {
    // Stting logger level as WARN
    val log = LogManager.getRootLogger
    log.setLevel(Level.WARN)
    // Creating Spark Context
    val conf = new SparkConf().setAppName("My App").setMaster("local[*]")
    val sc = new SparkContext(conf)
    //Started the computation and printing the logging information
    log.warn("Started")
    val i = 0
    val data = sc.parallelize(i to 100000)
    data.foreach(i => log.info("My number"+ i))
    log.warn("Finished")
  }
}

解决这个问题也很简单;只需要声明 Scala 对象时使用 extends Serializable,现在代码看起来如下所示:

class MyMapper(n: Int) extends Serializable{
  @transient lazy val log = org.apache.log4j.LogManager.getLogger
                                ("myLogger")
  def MyMapperDosomething(rdd: RDD[Int]): RDD[String] =
   rdd.map{ i =>
    log.warn("mapping: " + i)
    (i + n).toString
  }
}

所以在前面的代码中发生的事情是,由于闭包不能在日志记录器上关闭,它不能被整齐地分发到所有分区;因此,整个 MyMapper 类型的实例被分发到所有分区;一旦完成,每个分区都会创建一个新的日志记录器并用于记录。

总结一下,以下是帮助我们解决此问题的完整代码:

package com.example.Personal
import org.apache.log4j.{Level, LogManager, PropertyConfigurator}
import org.apache.spark._
import org.apache.spark.rdd.RDD

class MyMapper(n: Int) extends Serializable{
  @transient lazy val log = org.apache.log4j.LogManager.getLogger
                                ("myLogger")
  def MyMapperDosomething(rdd: RDD[Int]): RDD[String] =
   rdd.map{ i =>
    log.warn("Serialization of: " + i)
    (i + n).toString
  }
}

object MyMapper{
  def apply(n: Int): MyMapper = new MyMapper(n)
}

object MyLog {
  def main(args: Array[String]) {
    val log = LogManager.getRootLogger
    log.setLevel(Level.WARN)
    val conf = new SparkConf().setAppName("My App").setMaster("local[*]")
    val sc = new SparkContext(conf)
    log.warn("Started")
    val data = sc.parallelize(1 to 100000)
    val mapper = MyMapper(1)
    val other = mapper.MyMapperDosomething(data)
    other.collect()
    log.warn("Finished")
  }
}

输出如下所示:

17/04/29 15:33:43 WARN root: Started 
.
.
17/04/29 15:31:51 WARN myLogger: mapping: 1 
17/04/29 15:31:51 WARN myLogger: mapping: 49992
17/04/29 15:31:51 WARN myLogger: mapping: 49999
17/04/29 15:31:51 WARN myLogger: mapping: 50000 
.
. 
17/04/29 15:31:51 WARN root: Finished

我们将在下一节讨论 Spark 的内建日志记录功能。

Spark 配置

配置 Spark 作业的方式有很多。在本节中,我们将讨论这些方式。更具体地说,根据 Spark 2.x 的版本,系统的配置有三个位置:

  • Spark 属性

  • 环境变量

  • 日志记录

Spark 属性

如前所述,Spark 属性控制大多数应用程序特定的参数,并可以通过 Spark 的 SparkConf 对象进行设置。或者,这些参数也可以通过 Java 系统属性进行设置。SparkConf 允许你配置一些常见的属性,如下所示:

setAppName() // App name 
setMaster() // Master URL 
setSparkHome() // Set the location where Spark is installed on worker nodes. 
setExecutorEnv() // Set single or multiple environment variables to be used when launching executors. 
setJars() // Set JAR files to distribute to the cluster. 
setAll() // Set multiple parameters together.

可以配置应用程序使用你机器上可用的多个核心。例如,我们可以通过以下方式初始化一个有两个线程的应用程序。注意,我们使用local [2]运行,意味着两个线程,这代表最小的并行性,而使用local [*]则会利用你机器上的所有可用核心。或者,你可以在提交 Spark 作业时通过以下 spark-submit 脚本指定执行器的数量:

val conf = new SparkConf() 
             .setMaster("local[2]") 
             .setAppName("SampleApp") 
val sc = new SparkContext(conf)

可能会有一些特殊情况,需要在需要时动态加载 Spark 属性。你可以在通过 spark-submit 脚本提交 Spark 作业时进行此操作。更具体地说,你可能希望避免在 SparkConf 中硬编码某些配置。

Apache Spark 优先级:

Spark 在提交的作业中有以下优先级:来自配置文件的配置优先级最低。来自实际代码的配置相对于来自配置文件的配置具有更高优先级,而通过 Spark-submit 脚本从命令行传递的配置具有更高的优先级。

例如,如果你想在不同的主节点、执行器或不同的内存配置下运行你的应用程序,Spark 允许你简单地创建一个空的配置对象,如下所示:

val sc = new SparkContext(new SparkConf())

然后,你可以在运行时为 Spark 作业提供配置,如下所示:

SPARK_HOME/bin/spark-submit 
 --name "SmapleApp" \
 --class org.apache.spark.examples.KMeansDemo \
 --master mesos://207.184.161.138:7077 \ # Use your IP address
 --conf spark.eventLog.enabled=false 
 --conf "spark.executor.extraJavaOptions=-XX:+PrintGCDetails" \ 
 --deploy-mode cluster \
 --supervise \
 --executor-memory 20G \
 myApp.jar

SPARK_HOME/bin/spark-submit 还会从 SPARK_HOME /conf/spark-defaults.conf 读取配置选项,其中每一行由键和值组成,键值之间由空格分隔。以下是一个示例:

spark.master  spark://5.6.7.8:7077 
spark.executor.memor y   4g 
spark.eventLog.enabled true 
spark.serializer org.apache.spark.serializer.KryoSerializer

在属性文件中指定的标志值将传递给应用程序,并与通过 SparkConf 指定的值合并。最后,如前所述,应用程序 Web UI 在 http://<driver>:4040 上的 Environment 标签下列出了所有 Spark 属性。

环境变量

环境变量可以用于设置计算节点或机器的配置。例如,可以通过每个计算节点上的 conf/spark-env.sh 脚本设置 IP 地址。下表列出了需要设置的环境变量的名称和功能:

图 18: 环境变量及其含义

日志记录

最后,日志记录可以通过你 Spark 应用程序树下的 log4j.properties 文件进行配置,如前面所述。Spark 使用 log4j 进行日志记录。log4j 与 Spark 支持多个有效的日志级别,它们如下所示:

日志级别用途
OFF这是最具体的,完全不记录日志
FATAL这是最具体的日志级别,显示带有少量数据的致命错误
ERROR仅显示常规错误
WARN显示建议修复但不是强制的警告
INFO显示 Spark 作业所需的信息
DEBUG在调试时,这些日志将被打印出来
TRACE这是最不具体的错误追踪,包含大量数据
ALL最不具体的日志级别,包含所有数据

表格 1: log4j 和 Spark 的日志级别

你可以在 conf/log4j.properties 中设置 Spark shell 的默认日志记录。在独立的 Spark 应用程序中或在 Spark Shell 会话中,使用 conf/log4j.properties.template 作为起点。在本章的早期部分,我们建议你将 log4j.properties 文件放在项目目录下,适用于像 Eclipse 这样的基于 IDE 的环境。然而,为了完全禁用日志记录,你应该使用以下 conf/log4j.properties.template 作为 log4j.properties 。只需将 log4j.logger.org 的标志设置为 OFF,如下所示:

log4j.logger.org=OFF

在下一节中,我们将讨论开发人员或程序员在开发和提交 Spark 作业时常见的一些错误。

Spark 应用开发中的常见错误

常见的错误包括应用程序失败、由于多种因素导致作业卡住、聚合、操作或转换中的错误、主线程中的异常,以及当然,内存溢出OOM)。

应用程序失败

大多数情况下,应用程序失败是因为一个或多个阶段最终失败。正如本章前面讨论的那样,Spark 作业由多个阶段组成。阶段并不是独立执行的:例如,处理阶段不能在相关的输入读取阶段之前执行。因此,假设阶段 1 执行成功,但阶段 2 执行失败,整个应用程序最终会失败。可以通过以下方式展示:

图 19: 一个典型 Spark 作业中的两个阶段

举个例子,假设你有以下三个 RDD 操作作为阶段。可以通过图 20图 21图 22的方式可视化表示:

val rdd1 = sc.textFile(“hdfs://data/data.csv”)
                       .map(someMethod)
                       .filter(filterMethod)   

图 20: rdd1 的第 1 阶段

val rdd2 = sc.hadoopFile(“hdfs://data/data2.csv”)
                      .groupByKey()
                      .map(secondMapMethod)

从概念上讲,这可以通过图 21展示,首先使用hadoopFile()方法解析数据,使用groupByKey()方法对其分组,最后进行映射:

图 21: rdd2 的第 2 阶段

val rdd3 = rdd1.join(rdd2).map(thirdMapMethod)

从概念上讲,这可以通过图 22展示,首先解析数据,进行连接,最后映射:

图 22: rdd3 的第 3 阶段

现在你可以执行一个聚合函数,例如,像这样执行收集操作:

rdd3.collect()

好吧!你已经开发了一个由三个阶段组成的 Spark 作业。从概念上讲,这可以通过以下方式展示:

图 23: rdd3.collect() 操作的三个阶段

现在,如果其中一个阶段失败,你的任务最终会失败。因此,最终的rdd3.collect()语句会抛出关于阶段失败的异常。此外,你可能会遇到以下四个因素的问题:

  • 聚合操作中的错误

  • 主线程中的异常

  • 面向对象编程(OOP)

  • 使用spark-submit脚本提交作业时出现类未找到异常

  • 关于 Spark 核心库中某些 API/方法的误解

为了摆脱上述问题,我们的通用建议是确保在执行任何 map、flatMap 或聚合操作时没有犯任何错误。其次,确保在使用 Java 或 Scala 开发应用程序时,主方法没有缺陷。有时候代码中看不到语法错误,但很重要的一点是你已经为你的应用程序开发了一些小的测试用例。主方法中最常见的异常如下:

  • java.lang.noclassdeffounderror

  • java.lang.nullpointerexception

  • java.lang.arrayindexoutofboundsexception

  • java.lang.stackoverflowerror

  • java.lang.classnotfoundexception

  • java.util.inputmismatchexception

这些异常可以通过仔细编写 Spark 应用程序代码来避免。或者,可以广泛使用 Eclipse(或其他 IDE)的代码调试功能,消除语义错误以避免异常。对于第三个问题,即 OOM,这是一个非常常见的问题。需要注意的是,Spark 至少需要 8 GB 的主内存,并且独立模式下需要有足够的磁盘空间。另一方面,要获取完整的集群计算能力,这个要求通常会更高。

准备包含所有依赖项的 JAR 文件以执行 Spark 作业至关重要。许多从业者使用 Google 的 Guava,它在大多数发行版中包含,但并不能保证向后兼容。这意味着,有时即使你明确提供了 Guava 类,你的 Spark 作业也找不到该类;这是因为 Guava 库的两个版本中的一个会优先于另一个版本,而这个版本可能不包含所需的类。为了克服这个问题,通常需要使用 shading。

确保在使用 IntelliJ、Vim、Eclipse、Notepad 等编码时,已经通过 –Xmx 参数设置了足够大的 Java 堆空间。在集群模式下工作时,提交 Spark 作业时应指定执行器内存,使用 Spark-submit 脚本。假设你需要解析一个 CSV 文件并使用随机森林分类器进行一些预测分析,你可能需要指定合适的内存量,比如 20 GB,如下所示:

--executor-memory 20G

即使你收到 OOM 错误,你可以将内存增加到例如 32 GB 或更高。由于随机森林计算密集型,要求较大的内存,这只是随机森林的一个例子。你可能在仅解析数据时也会遇到类似的问题。即使是某个特定的阶段也可能由于这个 OOM 错误而失败。因此,确保你了解这个错误。

对于 class not found exception,确保已将主类包含在生成的 JAR 文件中。该 JAR 文件应包含所有依赖项,以便在集群节点上执行 Spark 作业。我们将在第十七章中提供详细的 JAR 准备指南,前往 ClusterLand - 在集群上部署 Spark。

对于最后一个问题,我们可以提供一些关于 Spark 核心库的常见误解的例子。例如,当你使用 wholeTextFiles 方法从多个文件准备 RDD 或 DataFrame 时,Spark 并不会并行运行;在 YARN 的集群模式下,有时可能会因为内存不足而失败。

曾经有一次,我遇到了一个问题,首先我将六个文件从 S3 存储复制到了 HDFS 中。然后,我尝试创建一个 RDD,如下所示:

sc.wholeTextFiles("/mnt/temp") // note the location of the data files is /mnt/temp/

然后,我尝试使用 UDF 按行处理这些文件。当我查看计算节点时,发现每个文件只运行了一个执行器。然而,我接着收到了一个错误信息,提示 YARN 内存不足。为什么会这样?原因如下:

  • wholeTextFiles的目标是确保每个文件只由一个执行器处理

  • 例如,如果使用.gz文件,则每个文件最多只有一个执行器

任务执行缓慢或无响应

有时,如果 SparkContext 无法连接到 Spark 独立模式的主节点,则驱动程序可能会显示如下错误:

02/05/17 12:44:45 ERROR AppClient$ClientActor: All masters are unresponsive! Giving up. 
02/05/17 12:45:31 ERROR SparkDeploySchedulerBackend: Application has been killed. Reason: All masters are unresponsive! Giving up. 
02/05/17 12:45:35 ERROR TaskSchedulerImpl: Exiting due to error from cluster scheduler: Spark cluster looks down

在其他情况下,驱动程序能够连接到主节点,但主节点无法与驱动程序进行通信。即使驱动程序报告无法连接到主节点的日志目录,仍会尝试多次连接。

此外,你可能会经常遇到 Spark 作业的性能和进展非常缓慢。这是因为你的驱动程序在计算作业时速度较慢。如前所述,有时某个阶段可能比平常耗时更长,因为可能涉及到洗牌、映射、连接或聚合操作。即使计算机的磁盘存储或主内存不足,你也可能会遇到这些问题。例如,如果你的主节点没有响应,或者计算节点在一段时间内无响应,你可能会认为 Spark 作业已经停滞,卡在了某个阶段:

图 24: 执行器/驱动程序无响应的日志示例

可能的解决方案有几个,包括以下几点:

  1. 检查以确保工作节点和驱动程序已正确配置,能够连接到 Spark 主节点,且连接地址与 Spark 主节点的 Web UI/日志中列出的地址完全一致。然后,在启动 Spark shell 时,显式地提供 Spark 集群的主节点 URL:
 $ bin/spark-shell --master spark://master-ip:7077

  1. SPARK_LOCAL_IP设置为驱动程序、主节点和工作节点进程可访问的集群主机名。

有时,由于硬件故障,我们会遇到一些问题。例如,如果计算节点中的文件系统意外关闭,即发生了 I/O 异常,那么你的 Spark 作业最终也会失败。这是显而易见的,因为 Spark 作业无法将结果 RDD 或数据写入本地文件系统或 HDFS 进行存储。这也意味着由于阶段失败,无法执行 DAG 操作。

有时,这种 I/O 异常是由于底层磁盘故障或其他硬件故障引起的。通常会提供如下日志:

图 25: 一个示例文件系统已关闭

然而,你常常会遇到作业计算性能缓慢的问题,因为你的 Java 垃圾回收有些忙,或者无法快速执行垃圾回收。例如,以下图示显示了任务 0 花费了 10 小时来完成垃圾回收!我在 2014 年遇到过这个问题,那时我刚接触 Spark。然而,这类问题的控制并不在我们手中。因此,我们的建议是,应该让 JVM 释放资源,然后重新提交作业。

图 26: 一个垃圾回收暂停的示例

第四个因素可能是响应缓慢或作业性能较差,原因是缺乏数据序列化。这个问题将在下一节中讨论。第五个因素可能是代码中的内存泄漏,它会导致应用程序消耗更多的内存,留下打开的文件或逻辑设备。因此,请确保没有任何可能导致内存泄漏的选项。例如,完成 Spark 应用程序时,最好调用 sc.stop()spark.stop()。这将确保一个 SparkContext 仍然保持打开并处于活动状态。否则,你可能会遇到不必要的异常或问题。第六个问题是我们常常保持过多的打开文件,这有时会在洗牌或合并阶段引发 FileNotFoundException

优化技术

调优 Spark 应用程序以获得更好的优化技术有多个方面。在本节中,我们将讨论如何通过数据序列化来进一步优化 Spark 应用程序,通过更好的内存管理调优主内存。我们还可以通过在开发 Spark 应用程序时调优 Scala 代码中的数据结构来优化性能。另一方面,可以通过利用序列化的 RDD 存储来良好维护存储。

其中最重要的一个方面是垃圾回收(GC),以及如果你使用 Java 或 Scala 编写了 Spark 应用程序,如何调优它。我们将讨论如何通过调优来优化性能。对于分布式环境和基于集群的系统,必须确保一定程度的并行性和数据局部性。此外,通过使用广播变量,还可以进一步提高性能。

数据序列化

序列化是任何分布式计算环境中提高性能和优化的重要调优手段。Spark 也不例外,但 Spark 作业通常数据和计算都非常繁重。因此,如果你的数据对象格式不佳,首先需要将它们转换为序列化的数据对象。这会占用大量内存的字节。最终,整个过程会大幅拖慢整个处理和计算的速度。

结果是,你常常会体验到计算节点的响应迟缓。这意味着我们有时未能 100%利用计算资源。的确,Spark 试图在方便性和性能之间保持平衡。这也意味着数据序列化应该是 Spark 调优的第一步,以获得更好的性能。

Spark 提供了两种数据序列化选项:Java 序列化和 Kryo 序列化库:

  • **Java 序列化:**Spark 使用 Java 的ObjectOutputStream框架序列化对象。你通过创建任何实现了java.io.Serializable的类来处理序列化。Java 序列化非常灵活,但通常速度较慢,这对于大数据对象的序列化并不适用。

  • **Kryo 序列化:**你也可以使用 Kryo 库更快地序列化数据对象。与 Java 序列化相比,Kryo 序列化速度快 10 倍,而且比 Java 序列化更紧凑。然而,它有一个问题,即它不支持所有可序列化类型,但你需要注册你的类。

你可以通过使用SparkConf初始化你的 Spark 作业,并调用conf.set(spark.serializer, org.apache.spark.serializer.KryoSerializer)来开始使用 Kryo。要注册你自己的自定义类到 Kryo,使用registerKryoClasses方法,如下所示:

val conf = new SparkConf()
               .setMaster(“local[*]”)
               .setAppName(“MyApp”)
conf.registerKryoClasses(Array(classOf[MyOwnClass1], classOf[MyOwnClass2]))
val sc = new SparkContext(conf)

如果你的对象很大,你可能还需要增加spark.kryoserializer.buffer配置。这个值需要足够大,以容纳你序列化的最大对象。最后,如果你没有注册自定义类,Kryo 仍然可以工作;然而,每个对象需要存储完整的类名,这确实是浪费的。

例如,在监控 Spark 作业部分末尾的日志记录子部分,可以通过使用Kryo序列化来优化日志记录和计算。最初,先创建MyMapper类作为一个普通类(也就是没有任何序列化),如下所示:

class MyMapper(n: Int) { // without any serialization
  @transient lazy val log = org.apache.log4j.LogManager.getLogger("myLogger")
  def MyMapperDosomething(rdd: RDD[Int]): RDD[String] = rdd.map { i =>
    log.warn("mapping: " + i)
    (i + n).toString
  }
}

现在,让我们将这个类注册为Kyro序列化类,然后按照如下方式设置Kyro序列化:

conf.registerKryoClasses(Array(classOf[MyMapper])) // register the class with Kyro
conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer") // set Kayro serialization

这就是你所需要的。以下给出了这个示例的完整源代码。你应该能够运行并观察到相同的输出,但与之前的示例相比,这是经过优化的:

package com.chapter14.Serilazition
import org.apache.spark._
import org.apache.spark.rdd.RDD
class MyMapper(n: Int) { // without any serilization
  @transient lazy val log = org.apache.log4j.LogManager.getLogger
                                ("myLogger")
  def MyMapperDosomething(rdd: RDD[Int]): RDD[String] = rdd.map { i =>
    log.warn("mapping: " + i)
    (i + n).toString
  }
}
//Companion object
object MyMapper {
  def apply(n: Int): MyMapper = new MyMapper(n)
}
//Main object
object KyroRegistrationDemo {
  def main(args: Array[String]) {
    val log = LogManager.getRootLogger
    log.setLevel(Level.WARN)
    val conf = new SparkConf()
      .setAppName("My App")
      .setMaster("local[*]")
    conf.registerKryoClasses(Array(classOf[MyMapper2]))
     // register the class with Kyro
    conf.set("spark.serializer", "org.apache.spark.serializer
             .KryoSerializer") // set Kayro serilazation
    val sc = new SparkContext(conf)
    log.warn("Started")
    val data = sc.parallelize(1 to 100000)
    val mapper = MyMapper(1)
    val other = mapper.MyMapperDosomething(data)
    other.collect()
    log.warn("Finished")
  }
}

输出如下:

17/04/29 15:33:43 WARN root: Started 
.
.
17/04/29 15:31:51 WARN myLogger: mapping: 1 
17/04/29 15:31:51 WARN myLogger: mapping: 49992
17/04/29 15:31:51 WARN myLogger: mapping: 49999
17/04/29 15:31:51 WARN myLogger: mapping: 50000 
.
.                                                                                
17/04/29 15:31:51 WARN root: Finished

做得很好!现在让我们快速看看如何调整内存。我们将在下一节中讨论一些高级策略,以确保主内存的高效使用。

内存调优

在本节中,我们将讨论一些高级策略,您可以通过这些策略确保在执行 Spark 作业时有效地使用内存。具体而言,我们将展示如何计算对象的内存使用情况。我们将建议一些优化数据结构或使用 Kryo 或 Java 序列化器将数据对象转换为序列化格式的高级方法。最后,我们将探讨如何调整 Spark 的 Java 堆大小、缓存大小和 Java 垃圾回收器。

调整内存使用时有三个考虑因素:

  • 对象使用的内存量:您可能希望您的整个数据集能够适配内存

  • 访问这些对象的成本

  • 垃圾回收的开销:如果对象的更新频繁

尽管 Java 对象的访问速度足够快,但它们可能会比原始(即原始)数据在字段中占用 2 到 5 倍更多的空间。例如,每个独立的 Java 对象都有 16 字节的开销作为对象头。例如,一个 Java 字符串,比原始字符串多占用接近 40 字节的额外开销。此外,Java 集合类如 SetListQueueArrayListVectorLinkedListPriorityQueueHashSetLinkedHashSetTreeSet 等,也会占用额外空间。另一方面,链式数据结构过于复杂,占用了过多的额外空间,因为每个数据结构中的条目都有一个包装对象。最后,原始类型的集合通常会将它们存储为装箱对象,例如 java.lang.Doublejava.lang.Integer

内存使用和管理

您的 Spark 应用程序和底层计算节点的内存使用可以分为执行内存和存储内存。执行内存在合并、洗牌、连接、排序和聚合的计算过程中使用。另一方面,存储内存用于缓存和在集群中传播内部数据。简而言之,这是由于网络中大量的 I/O 操作。

从技术上讲,Spark 会将网络数据本地缓存。当与 Spark 进行迭代或交互式操作时,缓存或持久化是 Spark 中的优化技术。这两种技术有助于保存中间的部分结果,以便在后续阶段中重用。然后,这些中间结果(作为 RDD)可以保存在内存中(默认)或更持久的存储中,如磁盘,和/或进行复制。此外,RDD 也可以使用缓存操作进行缓存。它们也可以通过持久化操作进行持久化。缓存与持久化操作之间的区别纯粹是语法上的。缓存是持久化的同义词(MEMORY_ONLY),也就是说,缓存仅使用默认存储级别 MEMORY_ONLY 进行持久化。

如果你在 Spark Web UI 的存储选项卡中查看,你应该能够看到 RDD、DataFrame 或 Dataset 对象使用的内存/存储,如 图 10 所示。尽管有两个相关的配置项可以用于调节 Spark 的内存,但用户不需要重新调整它们。原因是配置文件中的默认值已经足够满足你的需求和工作负载。

spark.memory.fraction 是统一区域的大小,占 (JVM 堆空间 - 300 MB) 的比例(默认值为 0.6)。其余的空间(40%)保留给用户数据结构、Spark 内部元数据以及防止稀疏和异常大记录时的 OOM 错误。另一方面,spark.memory.storageFraction 表示 R 存储空间的大小,占统一区域的比例(默认值为 0.5)。该参数的默认值是 Java 堆空间的 50%,即 300 MB。

有关内存使用和存储的更详细讨论,请参见 第十五章,使用 Spark ML 的文本分析

现在,你可能会有一个问题:选择哪个存储级别?为了解答这个问题,Spark 存储级别为你提供了内存使用和 CPU 效率之间的不同权衡。如果你的 RDDs 可以舒适地适应默认存储级别(MEMORY_ONLY),那么就让你的 Spark 驱动程序或主节点使用这个级别。这是内存效率最高的选项,允许 RDD 操作尽可能快速地运行。你应该让它使用这个级别,因为这是最节省内存的选择。这也允许对 RDDs 执行许多操作,使其尽可能快。

如果你的 RDDs 不适合主内存,也就是说,MEMORY_ONLY 无法正常工作,你应该尝试使用 MEMORY_ONLY_SER。强烈建议除非你的 UDF(即你为处理数据集定义的 用户定义函数)太过复杂,否则不要将 RDDs 溢出到磁盘。如果你的 UDF 在执行阶段过滤了大量数据,这也适用。在其他情况下,重新计算一个分区,即重新分区,可能会更快地从磁盘读取数据对象。最后,如果你需要快速的故障恢复,使用复制的存储级别。

总结来说,Spark 2.x 支持以下存储级别:(名称中的数字 _2 表示有 2 个副本):

  • DISK_ONLY:适用于基于磁盘的 RDDs 操作。

  • DISK_ONLY_2:适用于基于磁盘的操作,适用于有 2 个副本的 RDDs。

  • MEMORY_ONLY:这是默认的缓存操作存储级别,适用于内存中的 RDDs。

  • MEMORY_ONLY_2:这是默认的内存缓存操作,适用于有 2 个副本的 RDDs。

  • MEMORY_ONLY_SER:如果你的 RDDs 不适合主内存,也就是说,MEMORY_ONLY 无法正常工作,这个选项特别适用于以序列化形式存储数据对象。

  • MEMORY_ONLY_SER_2:如果你的 RDD 无法适应主内存,即MEMORY_ONLY在 2 个副本的情况下无法使用,该选项也有助于以序列化的形式存储数据对象。

  • MEMORY_AND_DISK:基于内存和磁盘(即组合)存储的 RDD 持久化。

  • MEMORY_AND_DISK_2:基于内存和磁盘(即组合)存储的 RDD 持久化,带有 2 个副本。

  • MEMORY_AND_DISK_SER:如果MEMORY_AND_DISK无法使用,可以使用此选项。

  • MEMORY_AND_DISK_SER_2:如果MEMORY_AND_DISK在 2 个副本的情况下无法使用,可以使用此选项。

  • OFF_HEAP:不允许写入 Java 堆空间。

请注意,缓存是持久化的同义词(MEMORY_ONLY)。这意味着缓存仅在默认存储级别下持久化,即MEMORY_ONLY。详细信息请参见jaceklaskowski.gitbooks.io/mastering-apache-spark/content/spark-rdd-StorageLevel.html

调整数据结构

减少额外内存使用的第一种方法是避免 Java 数据结构中会带来额外开销的某些特性。例如,基于指针的数据结构和包装对象会带来显著的开销。为了用更好的数据结构优化源代码,以下是一些建议,可能会有所帮助。

首先,设计数据结构时,尽量更多地使用对象数组和原始类型。由此也建议更频繁地使用标准的 Java 或 Scala 集合类,如SetListQueueArrayListVectorLinkedListPriorityQueueHashSetLinkedHashSetTreeSet

其次,尽量避免使用包含大量小对象和指针的嵌套结构,这样可以让你的源代码更加优化和简洁。第三,当可能时,考虑使用数字 ID,有时使用枚举对象,而不是使用字符串作为键。这样做是推荐的,因为,正如我们之前所说,单个 Java 字符串对象会额外产生 40 字节的开销。最后,如果你的主内存小于 32GB(即 RAM),请设置 JVM 标志-XX:+UseCompressedOops,将指针大小从 8 字节改为 4 字节。

前述选项可以在SPARK_HOME/conf/spark-env.sh.template中设置。只需将文件重命名为spark-env.sh,然后直接设置值!

序列化的 RDD 存储

如前所述,尽管有其他类型的内存调优,当你的对象过大,无法高效地放入主内存或磁盘时,减少内存使用的一个更简单且更好的方法是将其以序列化的形式存储。

可以通过 RDD 持久化 API 中的序列化存储级别来实现这一点,如MEMORY_ONLY_SER。有关更多信息,请参阅前一节关于内存管理的内容,并开始探索可用选项。

如果您指定使用MEMORY_ONLY_SER,Spark 将把每个 RDD 分区存储为一个大的字节数组。然而,这种方法的唯一缺点是,它可能会减慢数据访问的速度。这是合理且显而易见的;公平地说,没有办法避免这种情况,因为每个对象需要在重新使用时动态反序列化。

如前所述,我们强烈建议使用 Kryo 序列化而不是 Java 序列化,以加快数据访问速度。

垃圾回收调优

尽管在您的 Java 或 Scala 程序中,仅仅顺序或随机读取一次 RDD 然后执行多个操作时,GC 不会成为大问题,但如果您在驱动程序中存储了大量关于 RDD 的数据对象,Java 虚拟机JVM)的 GC 可能会变得复杂且具有问题。当 JVM 需要从旧对象中移除过时且未使用的对象,为新对象腾出空间时,必须识别并最终从内存中移除这些对象。然而,这个操作在处理时间和存储方面是一个昂贵的操作。您可能会想,GC 的成本与存储在主内存中的 Java 对象数量成正比。因此,我们强烈建议您调优数据结构。此外,建议在内存中存储更少的对象。

GC 调优的第一步是收集与您的机器上 JVM 执行垃圾回收的频率相关的统计信息。此时需要的第二个统计数据是 JVM 在您的机器或计算节点上进行 GC 时所花费的时间。可以通过在您的 IDE(例如 Eclipse)中的 JVM 启动参数中添加-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCTimeStamps,并指定 GC 日志文件的名称和位置来实现,如下所示:

图 27: 在 Eclipse 中设置 GC verbose

另外,您可以在提交 Spark 作业时,通过 Spark-submit 脚本指定verbose:gc,如下所示:

--conf “spark.executor.extraJavaOptions = -verbose:gc -XX:-PrintGCDetails -XX:+PrintGCTimeStamps"

简而言之,在为 Spark 指定 GC 选项时,您必须确定希望将 GC 选项指定在哪里,是在执行器(executors)上还是在驱动程序(driver)上。当您提交作业时,指定--driver-java-options -XX:+PrintFlagsFinal -verbose:gc等选项。对于执行器,指定--conf spark.executor.extraJavaOptions=-XX:+PrintFlagsFinal -verbose:gc等选项。

现在,当您的 Spark 作业执行时,您将能够在每次发生 GC 时,在工作节点的 /var/log/logs 目录下看到打印的日志和信息。这种方法的缺点是,这些日志不会出现在您的驱动程序中,而是在集群的工作节点上。

需要注意的是,verbose:gc只有在每次 GC 回收后才会打印适当的消息或日志。相应地,它打印有关内存的详细信息。然而,如果你有兴趣寻找更关键的问题,比如内存泄漏,verbose:gc可能不够用。在这种情况下,你可以使用一些可视化工具,如 jhat 和 VisualVM。有关如何在 Spark 应用中进行 GC 调优的更好方法,可以参见databricks.com/blog/2015/05/28/tuning-java-garbage-collection-for-spark-applications.html

并行度

尽管你可以通过可选参数来控制要执行的 map 任务的数量(例如SparkContext.text文件),但 Spark 会根据文件的大小自动为每个文件设置相同的值。除此之外,对于诸如groupByKeyreduceByKey之类的分布式reduce操作,Spark 使用最大的父 RDD 的分区数量。然而,有时我们会犯一个错误,那就是没有充分利用计算集群中节点的计算资源。因此,除非你显式地设置并指定 Spark 作业的并行度级别,否则集群的计算资源将无法得到充分利用。因此,你应该将并行度级别作为第二个参数进行设置。

更多关于此选项的信息,请参考spark.apache.org/docs/latest/api/scala/index.html#org.apache.spark.rdd.PairRDDFunctions.

另外,你也可以通过设置配置属性 spark.default.parallelism来更改默认值。对于没有父 RDD 的并行化操作,平行度的级别取决于集群管理器,即独立模式、Mesos 或 YARN。对于本地模式,将并行度级别设置为本地机器上的核心数。对于 Mesos 或 YARN,将精细粒度模式设置为 8。在其他情况下,所有执行器节点的总核心数或 2 中较大的值,并且通常推荐每个 CPU 核心 2-3 个任务。

广播

广播变量使得 Spark 开发者可以将一个实例或类变量的只读副本缓存到每个驱动程序上,而不是将其自身与依赖任务一起传输。然而,只有当多个阶段的任务需要相同的数据并且以反序列化形式存在时,显式创建广播变量才有用。

在 Spark 应用开发中,使用 SparkContext 的广播选项可以大大减少每个序列化任务的大小。这也有助于减少在集群中启动 Spark 作业的开销。如果你的 Spark 作业中有某个任务使用了来自驱动程序的大型对象,你应该将其转化为广播变量。

要在 Spark 应用程序中使用广播变量,您可以使用 SparkContext.broadcast 来实例化它。之后,使用该类的 value 方法访问共享值,如下所示:

val m = 5
val bv = sc.broadcast(m)

输出/日志:bv: org.apache.spark.broadcast.Broadcast[Int] = Broadcast(0)

bv.value()

输出/日志:res0: Int = 1

图 28: 从驱动程序到执行器广播值

Spark 的广播功能使用SparkContext来创建广播值。之后,BroadcastManagerContextCleaner用于控制其生命周期,如下图所示:

图 29: SparkContext 使用 BroadcastManager 和 ContextCleaner 广播变量/值

驱动程序中的 Spark 应用程序会自动打印每个任务在驱动程序上的序列化大小。因此,您可以决定任务是否过大,无法并行处理。如果任务大于 20 KB,可能值得优化。

数据局部性

数据局部性是指数据与待处理代码的接近程度。从技术上讲,数据局部性对在本地或集群模式下执行的 Spark 作业的性能有着不容忽视的影响。因此,如果数据和待处理的代码紧密绑定,计算应该会更快。通常,从驱动程序向执行器传输序列化代码要比数据传输更快,因为代码的大小远小于数据。

在 Spark 应用程序开发和作业执行中,有多个局部性级别。按从最接近到最远的顺序排列,级别取决于您必须处理的数据的当前位置:

数据局部性含义特殊说明
PROCESS_LOCAL数据和代码在同一位置最佳局部性
NODE_LOCAL数据和代码在同一节点上,例如,数据存储在 HDFS 上PROCESS_LOCAL 略慢,因为数据需要跨进程和网络传播
NO_PREF数据来自其他地方并且没有特定的局部性偏好没有局部性偏好
RACK_LOCAL数据位于同一机架上的服务器上,通过网络连接适用于大规模数据处理
ANY数据位于网络的其他地方,不在同一机架上除非没有其他选择,否则不推荐使用

表 2: 数据局部性与 Spark

Spark 的开发设计使其倾向于在最佳局部性级别调度所有任务,但这并不总是保证的,且也不总是可能的。因此,根据计算节点的情况,如果可用计算资源过于繁忙,Spark 会切换到较低的局部性级别。此外,如果您希望拥有最佳的数据局部性,有两种选择:

  • 等待直到繁忙的 CPU 释放出来,以便在同一服务器或同一节点上启动任务处理数据

  • 立即启动一个新的任务,这需要将数据移动到那里

总结

在本章中,我们讨论了一些关于提高 Spark 作业性能的高级主题。我们讨论了一些基本的调优技术来优化您的 Spark 作业。我们讨论了如何通过访问 Spark web UI 来监视您的作业。我们还讨论了一些 Spark 用户常见的错误,并提供了一些建议。最后,我们讨论了一些优化技术,帮助优化 Spark 应用程序。

在下一章中,您将看到如何测试 Spark 应用程序并调试以解决大多数常见问题。

第十七章:进入集群世界的时刻——在集群上部署 Spark

“我看到月亮像一块剪裁过的银片。像金色的蜜蜂一样,星星围绕着她。”

  • 奥斯卡·王尔德

在前面的章节中,我们已经看到如何使用不同的 Spark API 开发实用的应用程序。然而,在本章中,我们将看到 Spark 在集群模式下如何工作,并了解其底层架构。最后,我们将看到如何在集群上部署完整的 Spark 应用程序。简而言之,本章将涵盖以下主题:

  • 集群中 Spark 的架构

  • Spark 生态系统与集群管理

  • 在集群上部署 Spark

  • 在独立集群上部署 Spark

  • 在 Mesos 集群上部署 Spark

  • 在 YARN 集群上部署 Spark

  • 基于云的部署

  • 在 AWS 上部署 Spark

集群中 Spark 的架构

基于 Hadoop 的MapReduce框架在过去几年中得到了广泛应用;然而,它在 I/O、算法复杂性、低延迟流处理作业和完全基于磁盘的操作方面存在一些问题。Hadoop 提供了Hadoop 分布式文件系统HDFS)用于高效计算和廉价存储大数据,但你只能通过基于 Hadoop 的 MapReduce 框架以高延迟的批处理模型或静态数据进行计算。Spark 带给我们的主要大数据范式是引入了内存计算和缓存抽象。这使得 Spark 非常适合大规模数据处理,并使计算节点能够通过访问相同的输入数据来执行多个操作。

Spark 的弹性分布式数据集RDD)模型可以完成 MapReduce 范式所能做的一切,甚至更多。然而,Spark 能够对你的数据集进行大规模的迭代计算。这个选项有助于加速执行机器学习、通用数据处理、图分析和结构化查询语言SQL)算法,不管是否依赖 Hadoop。因此,复兴 Spark 生态系统此时变得至关重要。

了解了 Spark 的魅力和特点后,接下来,你需要了解 Spark 是如何工作的。

Spark 生态系统简介

为了提供更多高级和附加的大数据处理能力,你的 Spark 作业可以运行在基于 Hadoop(即 YARN)或基于 Mesos 的集群之上。另一方面,Spark 中的核心 API 是用 Scala 编写的,允许你使用多种编程语言(如 Java、Scala、Python 和 R)开发 Spark 应用程序。Spark 提供了多个库,这些库是 Spark 生态系统的一部分,提供了额外的功能,适用于通用数据处理和分析、图处理、大规模结构化 SQL 以及机器学习ML)领域。Spark 生态系统包括以下组件:

图 1: Spark 生态系统(直到 Spark 2.1.0)

Spark 的核心引擎是用 Scala 编写的,但支持不同的语言来开发你的 Spark 应用程序,如 R、Java、Python 和 Scala。Spark 核心引擎中的主要组件/API 如下:

  1. SparkSQL:帮助将 SQL 查询与 Spark 程序无缝结合,从而可以在 Spark 程序中查询结构化数据。

  2. Spark Streaming:用于大规模流应用程序开发,提供 Spark 与其他流数据源(如 Kafka、Flink 和 Twitter)的无缝集成。

  3. SparkMLlibSparKML:这些是基于 RDD 和数据集/DataFrame 的机器学习和管道创建工具。

  4. GraphX:用于大规模图计算和处理,使你的图数据对象完全连接。

  5. SparkR:Spark 上的 R 帮助进行基本的统计计算和机器学习。

正如我们之前所述,完全可以无缝结合这些 API 来开发大规模机器学习和数据分析应用程序。此外,Spark 作业可以通过集群管理器(如 Hadoop YARN、Mesos 和独立模式)提交和执行,或者通过访问数据存储和源(如 HDFS、Cassandra、HBase、Amazon S3,甚至 RDBMS)在云中执行。然而,为了充分利用 Spark 的功能,我们需要将 Spark 应用程序部署到计算集群上。

集群设计

Apache Spark 是一个分布式并行处理系统,它还提供了内存计算能力。这种计算模式需要一个关联的存储系统,以便你能够在大数据集群上部署你的应用程序。为了实现这一点,你需要使用分布式存储系统,如 HDFS、S3、HBase 和 Hive。为了数据传输,你将需要其他技术,如 Sqoop、Kinesis、Twitter、Flume 和 Kafka。

实际上,你可以非常轻松地配置一个小型的 Hadoop 集群。你只需要一个主节点和多个工作节点。在你的 Hadoop 集群中,通常,主节点由NameNodeDataNodeJobTrackerTaskTracker组成。另一方面,工作节点可以配置成既作为 DataNode,又作为 TaskTracker。

出于安全原因,大多数大数据集群可能会设置在网络防火墙后面,以便通过计算节点克服或至少减少防火墙带来的复杂性。否则,计算节点将无法从外部网络访问,也就是外部网。下图展示了一个简化的大数据集群,通常用于 Spark:

图 2: JVM 上的大数据处理通用架构

上图显示了一个由五个计算节点组成的集群。这里每个节点都有一个专用的执行器 JVM,每个 CPU 核心一个,而 Spark Driver JVM 位于集群外部。磁盘直接连接到节点,采用 JBOD仅为一堆磁盘)方式。非常大的文件会被分区存储到磁盘上,虚拟文件系统(如 HDFS)使这些分区数据以一个大的虚拟文件呈现。以下简化的组件模型显示了位于集群外部的 Driver JVM。它与集群管理器(参见 图 4)通信,以获得在工作节点上调度任务的权限,因为集群管理器会跟踪集群中所有进程的资源分配。

如果你使用 Scala 或 Java 开发了 Spark 应用程序,那么你的作业就是基于 JVM 的进程。对于这种 JVM 基于的进程,你可以通过指定以下两个参数来简单地配置 Java 堆内存:

  • -Xmx: 此参数指定了 Java 堆内存的上限

  • -Xms: 这个参数是 Java 堆内存的下限

一旦你提交了 Spark 作业,堆内存需要为你的 Spark 作业分配。下图提供了一些分配方法的见解:

**图 3:**JVM 内存管理

如前图所示,Spark 启动 Spark 作业时,JVM 堆内存为 512 MB。然而,为了确保 Spark 作业的持续处理并避免出现内存不足OOM)错误,Spark 允许计算节点仅使用堆的 90%(即约 461 MB),这个比例最终可以通过控制 Spark 环境中的 spark.storage.safetyFraction 参数来调整。更为现实地说,JVM 可以看作是由 存储(占 Java 堆的 60%)、执行(即 Shuffle)所需的 20% 堆内存,以及其余的 20% 用于其他存储构成的。

此外,Spark 是一款集群计算工具,旨在利用内存和基于磁盘的计算,并允许用户将部分数据存储在内存中。实际上,Spark 仅将主内存用于其 LRU 缓存。为了确保缓存机制的连续性,需要为应用程序特定的数据处理保留一小部分内存。非正式地说,这大约是由 spark.memory.fraction 控制的 Java 堆内存的 60%。

因此,如果你想查看或计算在 Spark 应用中可以缓存多少应用特定的数据,你可以简单地将所有执行器的堆内存使用量相加,然后乘以 safetyFractionspark.memory.fraction。在实际应用中,你可以允许 Spark 计算节点使用总堆内存的 54%(即 276.48 MB)。现在,shuffle 内存的计算方法如下:

Shuffle memory= Heap Size * spark.shuffle.safetyFraction * spark.shuffle.memoryFraction

spark.shuffle.safetyFractionspark.shuffle.memoryFraction 的默认值分别是 80% 和 20%。因此,在实际操作中,你最多可以使用 JVM 堆内存的 0.80.2 = 16%* 用于 shuffle 操作。最后,解压内存是指计算节点中可以被解压过程利用的主内存量。计算方式如下:

Unroll memory = spark.storage.unrollFraction * spark.storage.memoryFraction * spark.storage.safetyFraction

上述约占堆内存的 11%(0.20.60.9 = 10.8~11%),即 Java 堆内存的 56.32 MB。

更详细的讨论可以在spark.apache.org/docs/latest/configuration.html.找到。

如我们稍后将看到的那样,存在各种不同的集群管理器,其中一些也能够管理其他 Hadoop 工作负载,甚至可以与 Spark 执行器并行管理非 Hadoop 应用程序。需要注意的是,执行器和驱动程序之间是双向通信的,因此从网络角度来看,它们也应该尽量靠近部署。

图 4: 集群中 Spark 的驱动程序、主节点和工作节点架构

Spark 使用驱动程序(即驱动程序程序)、主节点和工作节点架构(即主机、从节点或计算节点)。驱动程序(或机器)与单个协调器通信,该协调器称为主节点。主节点实际上管理所有的工作节点(即从节点或计算节点),多个执行器在集群中并行运行。需要注意的是,主节点本身也是一个计算节点,具有大内存、存储、操作系统和底层计算资源。从概念上讲,这种架构可以通过图 4来展示。更多细节将在本节后续讨论。

在真实的集群模式中,集群管理器(即资源管理器)管理集群中所有计算节点的资源。通常,防火墙在为集群提供安全性时,也会增加复杂性。系统组件之间的端口需要打开,以便它们能够互相通信。例如,Zookeeper 被许多组件用于配置。Apache Kafka 作为一种订阅消息系统,使用 Zookeeper 配置其主题、组、消费者和生产者。因此,需要打开客户端到 Zookeeper 的端口,可能还需要穿越防火墙。

最后,需要考虑将系统分配到集群节点。例如,如果 Apache Spark 使用 Flume 或 Kafka,那么将使用内存通道。Apache Spark 不应与其他 Apache 组件竞争内存使用。根据你的数据流和内存使用情况,可能需要将 Spark、Hadoop、Zookeeper、Flume 和其他工具部署在不同的集群节点上。或者,也可以使用 YARN、Mesos 或 Docker 等资源管理器来解决这个问题。在标准的 Hadoop 环境中,通常 YARN 本身就已经存在。

作为工作节点或 Spark 主节点的计算节点需要比防火墙内的集群处理节点更多的资源。当许多 Hadoop 生态系统组件被部署到集群时,它们都需要在主服务器上额外的内存。你应当监控工作节点的资源使用情况,并根据需要调整资源和/或应用程序的位置。例如,YARN 就会处理这些问题。

本节简要介绍了大数据集群中 Apache Spark、Hadoop 和其他工具的应用场景。然而,如何在大数据集群中配置 Apache Spark 集群本身呢?例如,可能存在多种类型的 Spark 集群管理器。下一节将详细讨论这一点,并描述每种类型的 Apache Spark 集群管理器。

集群管理

Spark 上下文可以通过 Spark 配置对象(即 SparkConf)和 Spark URL 来定义。首先,Spark 上下文的目的是连接 Spark 集群管理器,其中你的 Spark 作业将运行。集群或资源管理器随后将在计算节点之间分配所需的资源。集群管理器的第二个任务是将执行器分配到集群工作节点,以便执行 Spark 作业。第三,资源管理器还会将驱动程序(即应用程序 JAR 文件、R 代码或 Python 脚本)复制到计算节点上。最后,计算任务由资源管理器分配给计算节点。

以下小节描述了当前 Spark 版本(即本书撰写时的 Spark 2.1.0)中可用的 Apache Spark 集群管理器选项。要了解资源管理器(即集群管理器)如何管理资源,以下展示了 YARN 如何管理其所有底层计算资源。然而,无论你使用哪种集群管理器(例如 Mesos 或 YARN),这一点都是相同的:

图 5: 使用 YARN 进行资源管理

详细讨论请参考 spark.apache.org/docs/latest/cluster-overview.html#cluster-manager-types

假集群模式(也叫 Spark 本地模式)

正如你已经知道的,Spark 作业可以在本地模式下运行。这有时被称为执行的假集群模式。这也是一种非分布式、基于单一 JVM 的部署模式,在这种模式下,Spark 会将所有执行组件(例如驱动程序、执行器、LocalSchedulerBackend 和主节点)放入你的单个 JVM 中。这是唯一一个驱动程序本身充当执行器的模式。下图展示了在本地模式下提交 Spark 作业的高级架构:

图 6: Spark 作业本地模式的高层架构(来源:jaceklaskowski.gitbooks.io/mastering-apache-spark/content/spark-local.html

这会让你感到惊讶吗?不,我想不会,因为你也可以实现某种程度的并行性,默认的并行度是主节点 URL 中指定的线程数(即使用的核心数),例如,对于 4 个核心/线程为 local [4],而 local [*] 表示所有可用线程。我们将在本章后续部分讨论这一话题。

独立模式

通过指定 Spark 配置的本地 URL,可以使应用在本地运行。通过指定 local[n],可以让 Spark 使用 n 个线程在本地运行应用。这是一个有用的开发和测试选项,因为你可以测试某种并行化场景,但仍然将所有日志文件保留在单一机器上。独立模式使用一个由 Apache Spark 提供的基本集群管理器。Spark 主节点的 URL 将如下所示:

spark://<hostname>:7077

这里,<hostname> 是运行 Spark 主节点的主机名称。我指定了 7077 作为端口,这是默认值,但它是可以配置的。这个简单的集群管理器目前只支持 FIFO先进先出)调度。你可以通过为每个应用设置资源配置选项来实现并发应用调度。例如,spark.core.max 用于在应用之间共享处理器核心。本章后续会有更详细的讨论。

Apache YARN

如果 Spark 主节点值设置为 YARN-cluster,则应用程序可以提交到集群并最终终止。集群将负责分配资源和执行任务。然而,如果应用程序主节点设置为 YARN-client,则应用程序将在整个生命周期内保持运行,并向 YARN 请求资源。这些适用于更大规模的集成场景,特别是在与 Hadoop YARN 集成时。之后的章节会提供逐步的指导,帮助你配置一个单节点的 YARN 集群来启动需要最小资源的 Spark 作业。

Apache Mesos

Apache Mesos 是一个用于跨集群资源共享的开源系统。它通过管理和调度资源,允许多个框架共享一个集群。它是一个集群管理器,使用 Linux 容器提供隔离,允许多个系统如 Hadoop、Spark、Kafka、Storm 等安全地共享集群。这是一个基于主从架构的系统,使用 Zookeeper 进行配置管理。通过这种方式,你可以将 Spark 作业扩展到成千上万个节点。对于单主节点 Mesos 集群,Spark 主节点的 URL 将如下所示:

mesos://<hostname>:5050

使用 Mesos 提交 Spark 作业的结果可以通过以下图示来展示:

图 7: Mesos 实时操作(图片来源:jaceklaskowski.gitbooks.io/mastering-apache-spark/content/spark-architecture.html

在上图中,<hostname> 是 Mesos 主服务器的主机名,端口定义为 5050,这是默认的 Mesos 主端口(可以配置)。如果在大规模高可用的 Mesos 集群中有多个 Mesos 主服务器,那么 Spark 主 URL 会如下所示:

mesos://zk://<hostname>:2181

所以,Mesos 主服务器的选举将由 Zookeeper 控制。<hostname> 将是 Zookeeper 仲裁节点中的主机名。此外,端口号 2181 是 Zookeeper 的默认主端口。

基于云的部署

云计算范式中有三种不同的抽象层次:

  • 基础设施即服务(即 IaaS

  • 平台即服务(即 PaaS

  • 软件即服务(即 SaaS

IaaS 通过为空你的 SaaS 运行的程序提供空虚拟机来提供计算基础设施。这对于在 OpenStack 上运行的 Apache Spark 也是一样的。

OpenStack 的优势在于它可以在多个不同的云服务提供商之间使用,因为它是一个开放标准,并且也是基于开源的。你甚至可以在本地数据中心使用 OpenStack,并在本地、专用和公共云数据中心之间透明动态地移动工作负载。

相比之下,PaaS 为你消除了安装和操作 Apache Spark 集群的负担,因为它作为服务提供。换句话说,你可以将其看作是一个类似操作系统的层级。

有时,你甚至可以将你的 Spark 应用程序 Docker 化,并以独立的方式部署到云平台上。然而,关于 Docker 是 IaaS 还是 PaaS 仍然存在争论,但在我们看来,这只是一种轻量级预安装虚拟机的形式,因此更倾向于 IaaS。

最后,SaaS 是云计算范式下由应用层提供和管理的服务。坦率来说,你不会看到或需要担心前两层(IaaS 和 PaaS)。

Google Cloud、Amazon AWS、Digital Ocean 和 Microsoft Azure 是提供这三层服务的云计算服务的典型例子。在本章后面,我们将展示如何使用 Amazon AWS 在云上部署 Spark 集群的示例。

在集群上部署 Spark 应用程序

在本节中,我们将讨论如何在计算集群上部署 Spark 作业。我们将看到如何在三种部署模式下部署集群:独立模式、YARN 和 Mesos。下图总结了本章中需要参考的集群概念术语:

图 8: 需要参考集群概念的术语(来源:spark.apache.org/docs/latest…

然而,在深入了解之前,我们需要知道如何一般地提交一个 Spark 作业。

提交 Spark 作业

一旦 Spark 应用程序被打包成 JAR 文件(使用 Scala 或 Java 编写)或 Python 文件,它可以通过位于 Spark 分发版 bin 目录下的 Spark-submit 脚本提交(即 $SPARK_HOME/bin)。根据 Spark 网站提供的 API 文档(spark.apache.org/docs/latest/submitting-applications.html),该脚本负责处理以下任务:

  • 配置 JAVA_HOMESCALA_HOME 与 Spark 的类路径

  • 设置执行作业所需的所有依赖项

  • 管理不同的集群管理器

  • 最后,部署 Spark 支持的模型

简而言之,Spark 作业提交的语法如下:

$ spark-submit [options] <app-jar | python-file> [app arguments]

在这里,[options] 可以是:--conf <configuration_parameters> --class <main-class> --master <master-url> --deploy-mode <deploy-mode> ... # other options

  • <main-class> 是主类的名称。实际上,这是我们 Spark 应用程序的入口点。

  • --conf 表示所有使用的 Spark 参数和配置属性。配置属性的格式为 key=value。

  • <master-url> 指定集群的主 URL(例如,spark://HOST_NAME:PORT)用于连接 Spark 独立集群的主节点,local 用于在本地运行 Spark 作业。默认情况下,它只允许使用一个工作线程且没有并行性。local [k] 可以用于在本地运行 Spark 作业,并使用 K 个工作线程。需要注意的是,K 是你机器上的核心数。最后,如果你指定 local[*] 作为主节点来本地运行 Spark 作业,那么你就是在允许 spark-submit 脚本使用你机器上的所有工作线程(逻辑核心)。最后,你可以将主节点指定为 mesos://IP_ADDRESS:PORT 来连接可用的 Mesos 集群。或者,你也可以使用 yarn 来运行 Spark 作业在基于 YARN 的集群上。

有关主 URL 的其他选项,请参考下图:

图 9: Spark 支持的主 URL 详细信息\

  • <deploy-mode> 你必须指定这个选项,如果你想将驱动程序部署在工作节点(集群)上,或者作为外部客户端(客户端)本地运行。支持四种模式:local、standalone、YARN 和 Mesos。

  • <app-jar> 是你构建的 JAR 文件,其中包含依赖项。提交作业时只需传递该 JAR 文件。

  • <python-file> 是使用 Python 编写的应用程序主源代码。提交任务时只需传递 .py 文件即可。

  • [app-arguments] 可能是应用程序开发人员指定的输入或输出参数。

在使用spark-submit脚本提交 Spark 作业时,您可以使用--jars选项指定 Spark 应用程序的主 jar(以及包含的其他相关 JAR 文件)。然后,所有 JAR 文件将被传输到集群中。在--jars后面提供的 URLs 必须用逗号分隔。

然而,如果您使用 URL 指定 jar 文件,最好在--jars后面用逗号分隔 JAR 文件。Spark 使用以下 URL 方案,允许为分发 JAR 文件采用不同策略:

  • file: 指定绝对路径和file:/

  • hdfs**:, http:, https:, ftp:** JAR 文件或任何其他文件将从您指定的 URL/URI 按预期下载

  • local:local:/开头的 URI 可用于指向每个计算节点上的本地 jar 文件。

需要注意的是,依赖的 JAR 文件、R 代码、Python 脚本或任何其他相关的数据文件需要被复制或复制到每个计算节点的 SparkContext 工作目录中。这有时会产生较大的开销,并且需要相当大的磁盘空间。随着时间的推移,磁盘使用量会增加。因此,在某个时间点,未使用的数据对象或相关代码文件需要清理。然而,这在 YARN 上是相当容易的。YARN 会定期处理清理工作,并可以自动处理。例如,在 Spark 独立模式下,自动清理可以通过提交 Spark 作业时配置 spark.worker.cleanup.appDataTtl属性来实现。

在计算方面,Spark 的设计是,在作业提交(使用spark-submit脚本)过程中,默认的 Spark 配置值可以从属性文件中加载并传递到 Spark 应用程序中。主节点将从名为spark-default.conf的配置文件中读取指定的选项。该文件的确切路径是SPARK_HOME/conf/spark-defaults.conf,位于您的 Spark 分发目录中。然而,如果您在命令行中指定所有参数,这将具有更高的优先级,并将相应地使用。

本地和独立模式下运行 Spark 作业

示例见第十三章,我的名字是贝叶斯,朴素贝叶斯,并且可以扩展到更大的数据集以解决不同的任务。您可以将这三种聚类算法及所有必需的依赖项打包,并作为 Spark 作业提交到集群中。如果您不知道如何制作包并从 Scala 类创建 jar 文件,您可以使用 SBT 或 Maven 将所有依赖项与您的应用程序捆绑在一起。

根据 Spark 文档在spark.apache.org/docs/latest/submitting-applications.html#advanced-dependency-management中的描述,SBT 和 Maven 都有汇编插件,用于将您的 Spark 应用程序打包为一个 fat jar。如果您的应用程序已经捆绑了所有依赖项,则可以使用以下代码行来提交您的 k-means 聚类 Spark 作业,例如(对其他类使用类似的语法),适用于 Saratoga NY Homes 数据集。要在本地提交和运行 Spark 作业,请在 8 个核心上运行以下命令:

$ SPARK_HOME/bin/spark-submit 
 --class com.chapter15.Clustering.KMeansDemo 
 --master local[8] 
 KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar 
 Saratoga_NY_Homes.txt

在上述代码中,com.chapter15.KMeansDemo是用 Scala 编写的主类文件。Local [8]是利用您计算机的八个核心的主 URL。KMeansDemo-0.1-SNAPSHOT-jar-with-dependencies.jar是我们刚刚由 Maven 项目生成的应用程序 JAR 文件;Saratoga_NY_Homes.txt是 Saratoga NY Homes 数据集的输入文本文件。如果应用程序成功执行,您将在以下图中找到包含输出的消息(缩写):

图 10: Spark 作业在终端上的输出[本地模式]

现在,让我们深入独立模式中的集群设置。要安装 Spark 独立模式,您应该在集群的每个节点上放置预构建版本的 Spark。或者,您可以自行构建并按照spark.apache.org/docs/latest/building-spark.html中的说明使用它。

要将环境配置为 Spark 独立模式,您将需要为集群中的每个节点提供所需版本的预构建 Spark。或者,您可以自行构建并按照spark.apache.org/docs/latest/building-spark.html中的说明使用它。现在我们将看到如何手动启动一个独立集群。您可以通过执行以下命令启动一个独立主节点:

$ SPARK_HOME/sbin/start-master.sh

一旦启动,您应该在终端上观察以下日志:

Starting org.apache.spark.deploy.master.Master, logging to <SPARK_HOME>/logs/spark-asif-org.apache.spark.deploy.master.Master-1-ubuntu.out

默认情况下,您应该能够访问 Spark Web UI,网址为http://localhost:8080。请按照以下图中所示的 UI 观察以下 UI:

图 11: Spark 主节点作为独立运行

您可以通过编辑以下参数更改端口号:

SPARK_MASTER_WEBUI_PORT=8080

SPARK_HOME/sbin/start-master.sh中,只需更改端口号,然后应用以下命令:

$ sudo chmod +x SPARK_HOME/sbin/start-master.sh.

或者,您可以重新启动 Spark 主节点以应用前述更改。但是,您将需要在SPARK_HOME/sbin/start-slave.sh中进行类似的更改。

正如您在这里看到的那样,没有活动的工作节点与主节点关联。现在,要创建一个从节点(也称为工作节点或计算节点),请创建工作节点并使用以下命令将它们连接到主节点:

$ SPARK_HOME/sbin/start-slave.sh <master-spark-URL>

在成功执行前述命令后,你应该能在终端上看到以下日志:

Starting org.apache.spark.deploy.worker.Worker, logging to <SPARK_HOME>//logs/spark-asif-org.apache.spark.deploy.worker.Worker-1-ubuntu.out 

一旦启动了其中一个工作节点,你可以在 Spark Web UI 上查看其状态,网址是http://localhost:8081。不过,如果你启动了另一个工作节点,你可以通过连续的端口(即 8082、8083 等)访问其状态。你也应该看到新的节点列在那里,并显示它的 CPU 数量和内存,如下图所示:

图 12: Spark 工作节点作为独立模式

现在,如果你刷新http://localhost:8080,你应该能看到与你的主节点关联的一个工作节点已被添加,如下图所示:

图 13: Spark 主节点现在有一个工作节点作为独立模式

最后,如下图所示,这些是所有可以传递给主节点和工作节点的配置选项:

图 14: 可传递给主节点和工作节点的配置选项(来源:spark.apache.org/docs/latest/spark-standalone.html#starting-a-cluster-manually

现在,主节点和一个工作节点都在运行并处于活跃状态。最后,你可以使用以下命令以独立模式而非本地模式提交相同的 Spark 作业:

$ SPARK_HOME/bin/spark-submit  
--class "com.chapter15.Clustering.KMeansDemo"  
--master spark://ubuntu:7077   
KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar  
Saratoga_NY_Homes.txt

一旦作业启动,访问 Spark Web UI,主节点的网址是http://localhost:80810,工作节点的网址是http://localhost:8081,你可以查看你的作业进度,如第十四章所讨论的,为数据分类 - 使用 Spark MLlib 对数据进行集群化

总结本节内容时,我们希望引导你查看以下图片(即 图 15),它显示了启动或停止集群时使用的 Shell 脚本:

图 15: 启动或停止集群的 Shell 脚本的使用方法\

Hadoop YARN

如前所述,Apache Hadoop YARN 有两个主要组件:调度器和应用程序管理器,如下图所示:

图 16: Apache Hadoop YARN 架构(蓝色:系统组件;黄色和粉色:两个正在运行的应用程序)

现在,使用调度程序和应用程序管理器,可以配置以下两种部署模式,在基于 YARN 的集群上启动 Spark 作业:

  • 集群模式:在集群模式下,Spark 驱动程序在由 YARN 的应用程序管理器管理的应用程序的主进程内运行。即使客户端在应用程序启动后被终止或断开连接,也不会影响应用程序的运行。

  • 客户端模式:在这种模式下,Spark 驱动程序运行在客户端进程内。之后,Spark 主节点仅用于从 YARN(YARN 资源管理器)请求计算节点的计算资源。

在 Spark 独立模式和 Mesos 模式中,必须在--master参数中指定主节点的 URL(即地址)。然而,在 YARN 模式下,资源管理器的地址是从 Hadoop 配置文件中读取的。因此,--master参数为yarn。在提交 Spark 作业之前,您需要设置 YARN 集群。接下来的小节将详细介绍如何一步一步完成此操作。

配置单节点 YARN 集群

在本小节中,我们将介绍如何在 YARN 集群上运行 Spark 作业之前设置 YARN 集群。有几个步骤,请保持耐心,并按步骤操作:

第 1 步:下载 Apache Hadoop

从 Hadoop 官网(hadoop.apache.org/)下载最新的发行版。我使用的是最新的稳定版本 2.7.3,在 Ubuntu 14.04 上如以下所示:

$  cd /home
$  wget http://mirrors.ibiblio.org/apache/hadoop/common/hadoop-2.7.3/hadoop-2.7.3.tar.gz

接下来,在/opt/yarn中创建并解压包,如下所示:

$  mkdir –p /opt/yarn
$  cd /opt/yarn
$  tar xvzf /root/hadoop-2.7.3.tar.gz

第 2 步:设置 JAVA_HOME

详细信息请参考第一章《Scala 简介》中的 Java 设置部分,并应用相同的更改。

第 3 步:创建用户和组

以下是可以为hadoop组创建的yarnhdfsmapred用户账户:

$  groupadd hadoop
$  useradd -g hadoop yarn
$  useradd -g hadoop hdfs
$  useradd -g hadoop mapred

第 4 步:创建数据和日志目录

要在 Hadoop 上运行 Spark 作业,必须为数据和日志目录设置不同的权限。您可以使用以下命令:

$  mkdir -p /var/data/hadoop/hdfs/nn
$  mkdir -p /var/data/hadoop/hdfs/snn
$  mkdir -p /var/data/hadoop/hdfs/dn
$  chown hdfs:hadoop /var/data/hadoop/hdfs –R
$  mkdir -p /var/log/hadoop/yarn
$  chown yarn:hadoop /var/log/hadoop/yarn -R

现在,您需要创建 YARN 安装的日志目录,并设置所有者和组如下所示:

$  cd /opt/yarn/hadoop-2.7.3
$  mkdir logs
$  chmod g+w logs
$  chown yarn:hadoop . -R

第 5 步:配置 core-site.xml

需要将以下两个属性(即fs.default.namehadoop.http.staticuser.user)设置到etc/hadoop/core-site.xml文件中。只需复制以下代码行:

<configuration>
       <property>
               <name>fs.default.name</name>
               <value>hdfs://localhost:9000</value>
       </property>
       <property>
               <name>hadoop.http.staticuser.user</name>
               <value>hdfs</value>
       </property>
</configuration>

第 6 步:配置 hdfs-site.xml

需要将以下五个属性(即dfs.replicationdfs.namenode.name.dirfs.checkpoint.dirfs.checkpoint.edits.dirdfs.datanode.data.dir)设置到etc/hadoop/hdfs-site.xml文件中。只需复制以下代码行:

<configuration>
 <property>
   <name>dfs.replication</name>
   <value>1</value>
 </property>
 <property>
   <name>dfs.namenode.name.dir</name>
   <value>file:/var/data/hadoop/hdfs/nn</value>
 </property>
 <property>
   <name>fs.checkpoint.dir</name>
   <value>file:/var/data/hadoop/hdfs/snn</value>
 </property>
 <property>
   <name>fs.checkpoint.edits.dir</name>
   <value>file:/var/data/hadoop/hdfs/snn</value>
 </property>
 <property>
   <name>dfs.datanode.data.dir</name>
   <value>file:/var/data/hadoop/hdfs/dn</value>
 </property>
</configuration>

第 7 步:配置 mapred-site.xml

需要将以下一个属性(即mapreduce.framework.name)设置到etc/hadoop/mapred-site.xml文件中。首先,复制并替换原始模板文件为以下内容:

$  cp mapred-site.xml.template mapred-site.xml

现在,只需复制以下代码行:

<configuration>
<property>
   <name>mapreduce.framework.name</name>
   <value>yarn</value>
 </property>
</configuration>

第 8 步:配置 yarn-site.xml

需要将以下两个属性(即yarn.nodemanager.aux-servicesyarn.nodemanager.aux-services.mapreduce.shuffle.class)设置到etc/hadoop/yarn-site.xml文件中。只需复制以下代码行:

<configuration>
<property>
   <name>yarn.nodemanager.aux-services</name>
   <value>mapreduce_shuffle</value>
 </property>
 <property>
   <name>yarn.nodemanager.aux-services.mapreduce.shuffle.class</name>
   <value>org.apache.hadoop.mapred.ShuffleHandler</value>
 </property>
</configuration>

第 9 步:设置 Java 堆空间

要在基于 Hadoop 的 YARN 集群上运行 Spark 作业,您需要为 JVM 指定足够的堆空间。您需要编辑etc/hadoop/hadoop-env.sh文件,启用以下属性:

HADOOP_HEAPSIZE="500"
HADOOP_NAMENODE_INIT_HEAPSIZE="500"

现在,您还需要编辑mapred-env.sh文件并添加以下行:

HADOOP_JOB_HISTORYSERVER_HEAPSIZE=250

最后,确保你已编辑过 yarn-env.sh,以使 Hadoop YARN 的更改永久生效:

JAVA_HEAP_MAX=-Xmx500m
YARN_HEAPSIZE=500

步骤 10:格式化 HDFS

如果你想启动 HDFS 的 NameNode,Hadoop 需要初始化一个目录来存储或持久化它的所有元数据,用于跟踪文件系统的所有元数据。格式化操作会销毁所有内容并设置一个新的文件系统。然后它会使用在 etc/hadoop/hdfs-site.xml 中设置的 dfs.namenode.name.dir 参数的值。要进行格式化,首先进入 bin 目录并执行以下命令:

$  su - hdfs
$ cd /opt/yarn/hadoop-2.7.3/bin
$ ./hdfs namenode -format

如果前面的命令执行成功,你应该在 Ubuntu 终端看到以下内容:

INFO common.Storage: Storage directory /var/data/hadoop/hdfs/nn has been successfully formatted

步骤 11:启动 HDFS

从步骤 10 的 bin 目录,执行以下命令:

$ cd ../sbin
$ ./hadoop-daemon.sh start namenode

在成功执行前面的命令后,你应该在终端看到以下内容:

starting namenode, logging to /opt/yarn/hadoop-2.7.3/logs/hadoop-hdfs-namenode-limulus.out

要启动 secondarynamenodedatanode,你应该使用以下命令:

$ ./hadoop-daemon.sh start secondarynamenode

如果前面的命令成功执行,你应该在终端看到以下消息:

Starting secondarynamenode, logging to /opt/yarn/hadoop-2.7.3/logs/hadoop-hdfs-secondarynamenode-limulus.out

然后使用以下命令启动数据节点:

$ ./hadoop-daemon.sh start datanode

如果前面的命令成功执行,你应该在终端看到以下消息:

starting datanode, logging to /opt/yarn/hadoop-2.7.3/logs/hadoop-hdfs-datanode-limulus.out

现在确保检查所有与这些节点相关的服务是否正在运行,使用以下命令:

$ jps

你应该看到类似以下内容:

35180 SecondaryNameNode
45915 NameNode
656335 Jps
75814 DataNode

步骤 12:启动 YARN

在使用 YARN 时,必须作为用户 yarn 启动一个 resourcemanager 和一个节点管理器:

$  su - yarn
$ cd /opt/yarn/hadoop-2.7.3/sbin
$ ./yarn-daemon.sh start resourcemanager

如果前面的命令成功执行,你应该在终端看到以下消息:

starting resourcemanager, logging to /opt/yarn/hadoop-2.7.3/logs/yarn-yarn-resourcemanager-limulus.out

然后执行以下命令启动节点管理器:

$ ./yarn-daemon.sh start nodemanager

如果前面的命令成功执行,你应该在终端看到以下消息:

starting nodemanager, logging to /opt/yarn/hadoop-2.7.3/logs/yarn-yarn-nodemanager-limulus.out

如果你想确保这些节点上的所有服务都在运行,你应该使用 $jsp 命令。此外,如果你想停止资源管理器或 nodemanager,可以使用以下 g 命令:

$ ./yarn-daemon.sh stop nodemanager
$ ./yarn-daemon.sh stop resourcemanager

步骤 13:在 Web UI 上验证

访问 http://localhost:50070 查看 NameNode 的状态,访问 http://localhost:8088 查看资源管理器的状态。

前面的步骤展示了如何配置一个基于 Hadoop 的 YARN 集群,且只有少数几个节点。然而,如果你想配置一个从少数节点到包含成千上万节点的大型集群的 Hadoop YARN 集群,请参考 hadoop.apache.org/docs/current/hadoop-project-dist/hadoop-common/ClusterSetup.html

在 YARN 集群上提交 Spark 作业

现在,我们的 YARN 集群已经准备好(至少满足执行小型 Spark 作业的最低要求),要在 YARN 集群模式下启动 Spark 应用程序,你可以使用以下提交命令:

$ SPARK_HOME/bin/spark-submit --classpath.to.your.Class --master yarn --deploy-mode cluster [options] <app jar> [app options]

要运行我们的 KMeansDemo,应该这样做:

$ SPARK_HOME/bin/spark-submit  
    --class "com.chapter15.Clustering.KMeansDemo"  
    --master yarn  
    --deploy-mode cluster  
    --driver-memory 16g  
    --executor-memory 4g  
    --executor-cores 4  
    --queue the_queue  
    KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar  
    Saratoga_NY_Homes.txt

前述的submit命令以默认应用程序主节点启动 YARN 集群模式。然后,KMeansDemo将作为应用程序主节点的子线程运行。客户端将定期轮询应用程序主节点以获取状态更新并在控制台中显示它们。当您的应用程序(即我们的情况下的KMeansDemo)执行完毕时,客户端将退出。

提交作业后,您可能希望使用 Spark Web UI 或 Spark 历史服务器查看进度。此外,您应参考第十八章,了解如何分析驱动程序和执行器日志。

要以客户端模式启动 Spark 应用程序,您应使用先前的命令,只需将集群替换为客户端即可。对于那些希望使用 Spark Shell 的人,请在客户端模式下使用以下命令:

$ SPARK_HOME/bin/spark-shell --master yarn --deploy-mode client

在 YARN 集群中提前提交作业

如果您选择更高级的方式将 Spark 作业提交到您的 YARN 集群中进行计算,您可以指定额外的参数。例如,如果您想启用动态资源分配,请将spark.dynamicAllocation.enabled参数设置为 true。但是,为了这样做,您还需要指定minExecutorsmaxExecutorsinitialExecutors,如以下所述。另一方面,如果您想启用分片服务,请将spark.shuffle.service.enabled设置为true。最后,您还可以尝试使用spark.executor.instances参数指定将运行多少个执行器实例。

现在,为了使前述讨论更具体化,您可以参考以下提交命令:

$ SPARK_HOME/bin/spark-submit   
    --class "com.chapter13.Clustering.KMeansDemo"  
    --master yarn  
    --deploy-mode cluster  
    --driver-memory 16g  
    --executor-memory 4g  
    --executor-cores 4  
    --queue the_queue  
    --conf spark.dynamicAllocation.enabled=true  
    --conf spark.shuffle.service.enabled=true  
    --conf spark.dynamicAllocation.minExecutors=1  
    --conf spark.dynamicAllocation.maxExecutors=4  
    --conf spark.dynamicAllocation.initialExecutors=4  
    --conf spark.executor.instances=4  
    KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar  
    Saratoga_NY_Homes.txt

然而,前述作业提交脚本的后果是复杂的,有时是不确定的。根据我的经验,如果您从代码中增加分区数和执行器数,则应用程序将更快地完成,这是可以接受的。但是,如果您仅增加执行器核心数,则完成时间相同。但是,您可能期望时间低于初始时间。其次,如果您两次启动前述代码,则可以预期两个作业都会在 60 秒内完成,但这也可能不会发生。通常情况下,两个作业可能在 120 秒后才完成。这有点奇怪,不是吗?但是,以下是能够帮助您理解此场景的解释。

假设您的机器上有 16 个核心和 8 GB 内存。现在,如果您使用四个每个核心一个的执行器,会发生什么?嗯,当您使用执行器时,Spark 会从 YARN 中保留它,并且 YARN 会分配所需的核心数(例如,在我们的情况下为一个)和所需的内存。实际上,内存需要比您要求的更多,以实现更快的处理。如果您请求 1 GB,实际上将分配几乎 1.5 GB,其中包括 500 MB 的开销。此外,它可能会为驱动程序分配一个执行器,其内存使用量可能为 1024 MB(即 1 GB)。

有时,Spark 作业需要多少内存并不重要,重要的是它保留了多少内存。在前面的例子中,它不会占用 50MB 的测试内存,而是每个执行器大约占用 1.5 GB(包括开销)。我们将在本章稍后讨论如何在 AWS 上配置 Spark 集群。

Apache Mesos

当使用 Mesos 时,Mesos 主节点通常会取代 Spark 主节点,成为集群管理器(即资源管理器)。现在,当驱动程序创建 Spark 作业并开始分配相关任务进行调度时,Mesos 会决定哪些计算节点处理哪些任务。我们假设你已经在你的机器上配置并安装了 Mesos。

要开始使用,可以参考以下链接来安装 Mesos:blog.madhukaraphatak.com/mesos-single-node-setup-ubuntu/mesos.apache.org/gettingstarted/

根据硬件配置的不同,可能需要一段时间。在我的机器上(Ubuntu 14.04 64 位,配备 Core i7 和 32 GB RAM),构建完成花费了 1 小时。

为了通过 Mesos 集群模式提交和计算 Spark 作业,请确保 Spark 二进制包存放在 Mesos 可以访问的地方。此外,确保你的 Spark 驱动程序可以配置为自动连接到 Mesos。第二种方式是在与 Mesos 从节点相同的位置安装 Spark。然后,你需要配置spark.mesos.executor.home参数,指向 Spark 分发包的位置。需要注意的是,默认位置是SPARK_HOME

当 Mesos 第一次在 Mesos 工作节点(即计算节点)上执行 Spark 作业时,Spark 二进制包必须在该工作节点上可用。这将确保 Spark Mesos 执行器在后台运行。

Spark 二进制包可以托管到 Hadoop 上,以便使其可访问:

1. 通过http://获取 URI/URL(包括 HTTP),

2. 通过s3n://使用 Amazon S3,

3. 通过hdfs://使用 HDFS。

如果你设置了HADOOP_CONF_DIR环境变量,参数通常设置为hdfs://...;否则,设置为file://

你可以按以下方式指定 Mesos 的主节点 URL:

  1. 对于单主 Mesos 集群,使用mesos://host:5050,对于由 ZooKeeper 控制的多主 Mesos 集群,使用mesos://zk://host1:2181,host2:2181,host3:2181/mesos

如需更详细的讨论,请参考spark.apache.org/docs/latest/running-on-mesos.html

客户端模式

在此模式下,Mesos 框架的工作方式是,Spark 作业直接在客户端机器上启动。然后,它会等待计算结果,也就是驱动程序输出。然而,为了与 Mesos 正确交互,驱动程序期望在SPARK_HOME/conf/spark-env.sh中指定一些特定于应用程序的配置。为此,请修改$SPARK_HOME/conf中的spark-env.sh.template文件,并在使用此客户端模式之前,在spark-env.sh中设置以下环境变量:

$ export MESOS_NATIVE_JAVA_LIBRARY=<path to libmesos.so>

在 Ubuntu 上,这个路径通常是/usr/local/lib/libmesos.so。另一方面,在 macOS X 上,相同的库被称为libmesos.dylib,而不是libmesos.so

$ export SPARK_EXECUTOR_URI=<URL of spark-2.1.0.tar.gz uploaded above>

现在,在提交并启动 Spark 应用程序以在集群上执行时,必须将 Mesos 的://HOST:PORT作为主 URL 传入。通常在创建SparkContext时完成这一操作,如下所示:

val conf = new SparkConf()              
                   .setMaster("mesos://HOST:5050")  
                   .setAppName("My app")             
                  .set("spark.executor.uri", "<path to spark-2.1.0.tar.gz uploaded above>")
val sc = new SparkContext(conf)

实现这一目标的第二种方式是使用spak-submit脚本,并在SPARK_HOME/conf/spark-defaults.conf文件中配置spark.executor.uri。在运行 shell 时,spark.executor.uri参数会从SPARK_EXECUTOR_URI继承,因此不需要作为系统属性重复传入。只需使用以下命令从 Spark shell 中访问客户端模式:

$ SPARK_HOME/bin/spark-shell --master mesos://host:5050

集群模式

Spark 在 Mesos 上也支持集群模式。如果驱动程序已经启动了 Spark 作业(在集群上),并且计算已完成,客户端可以通过 Mesos Web UI 访问结果(来自驱动程序)。如果你通过SPARK_HOME/sbin/start-mesos-dispatcher.sh脚本在集群中启动了MesosClusterDispatcher,那么你可以使用集群模式。

同样,条件是,在创建SparkContext时必须传入 Mesos 的主 URL(例如,mesos://host:5050)。以集群模式启动 Mesos 时,还会在主机机器上启动MesosClusterDispatcher作为守护进程。

若要实现更灵活和高级的 Spark 作业执行,还可以使用Marathon。使用 Marathon 的优势在于,你可以通过 Marathon 运行MesosClusterDispatcher。如果这样做,请确保MesosClusterDispatcher在前台运行。

Marathon 是一个 Mesos 框架,旨在启动长时间运行的应用程序,在 Mesosphere 中,它替代了传统的初始化系统。它具有许多功能,可以简化在集群环境中运行应用程序的过程,如高可用性、节点约束、应用健康检查、脚本化和服务发现的 API,以及一个易于使用的 Web 用户界面。它为 Mesosphere 的功能集添加了扩展和自我修复能力。Marathon 可以用来启动其他 Mesos 框架,它还可以启动任何可以在常规 shell 中启动的进程。由于它是为长时间运行的应用程序设计的,因此它将确保它启动的应用程序继续运行,即使它们所在的从节点发生故障。有关在 Mesosphere 中使用 Marathon 的更多信息,请参阅 GitHub 页面 github.com/mesosphere/marathon

更具体地说,从客户端,你可以通过使用 spark-submit 脚本并指定 MesosClusterDispatcher 的 URL(例如 mesos://dispatcher:7077)来提交 Spark 作业到你的 Mesos 集群。过程如下:

$ SPARK_HOME /bin/spark-class org.apache.spark.deploy.mesos.MesosClusterDispatcher

你可以在 Spark 集群的 Web 用户界面上查看驱动程序的状态。例如,使用以下作业提交命令来查看:

$ SPARK_HOME/bin/spark-submit   
--class com.chapter13.Clustering.KMeansDemo   
--master mesos://207.184.161.138:7077    
--deploy-mode cluster   
--supervise   
--executor-memory 20G   
--total-executor-cores 100   
KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar   
Saratoga_NY_Homes.txt

请注意,传递给 Spark-submit 的 JARS 或 Python 文件应为 Mesos 从节点可以访问的 URI,因为 Spark 驱动程序不会自动上传本地的 JAR 文件。最后,Spark 可以在 Mesos 上以两种模式运行:粗粒度(默认)和 细粒度(已弃用)。有关更多详细信息,请参考 spark.apache.org/docs/latest/running-on-mesos.html

在集群模式下,Spark 驱动程序运行在不同的机器上,即驱动程序、主节点和计算节点是不同的机器。因此,如果你尝试使用 SparkContext.addJar 添加 JAR 文件,这将不起作用。为了避免这个问题,请确保客户端上的 JAR 文件也可以通过 SparkContext.addJar 使用,在启动命令中使用 --jars 选项:

$ SPARK_HOME/bin/spark-submit --class my.main.Class    
     --master yarn    
     --deploy-mode cluster    
     --jars my-other-jar.jar, my-other-other-jar.jar    
     my-main-jar.jar    
     app_arg1 app_arg2

部署到 AWS

在前一节中,我们介绍了如何在本地、独立或部署模式(YARN 和 Mesos)下提交 Spark 作业。在这里,我们将展示如何在 AWS EC2 上的真实集群模式下运行 Spark 应用程序。为了让我们的应用程序在 Spark 集群模式下运行,并且具有更好的可扩展性,我们考虑将 Amazon Elastic Compute Cloud (EC2) 服务作为 IaaS 或 Platform as a Service (PaaS)。有关定价和相关信息,请参考 aws.amazon.com/ec2/pricing/

第一步:密钥对和访问密钥配置

我们假设你已经创建了 EC2 账户。那么,第一步是创建 EC2 密钥对和 AWS 访问密钥。EC2 密钥对是当你通过 SSH 与 EC2 服务器或实例建立安全连接时需要使用的私钥。为了生成密钥,你需要通过 AWS 控制台,访问docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair进行操作。请参阅以下截图,展示了 EC2 账户的密钥对创建页面:

图 17: AWS 密钥对生成窗口

下载后,请将其命名为 aws_key_pair.pem 并保存在本地机器上。然后,通过执行以下命令确保权限设置正确(为了安全起见,应该将此文件存储在安全位置,例如 /usr/local/key):

$ sudo chmod 400 /usr/local/key/aws_key_pair.pem

现在,你需要的是 AWS 访问密钥和你的账户凭证。如果你希望从本地机器通过 spark-ec2 脚本将 Spark 作业提交到计算节点,这些信息是必须的。要生成并下载这些密钥,请登录到你的 AWS IAM 服务,访问docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html#Using_CreateAccessKey

下载完成后(即 /usr/local/key),你需要在本地机器上设置两个环境变量。只需执行以下命令:

$ echo "export AWS_ACCESS_KEY_ID=<access_key_id>" >> ~/.bashrc 
$ echo " export AWS_SECRET_ACCESS_KEY=<secret_access_key_id>" >> ~/.bashrc 
$ source ~/.bashrc

步骤 2:在 EC2 上配置 Spark 集群

在 Spark 1.6.3 版本之前,Spark 发行版(即 /SPARK_HOME/ec2)提供了一个名为spark-ec2的脚本,用于从本地机器启动 EC2 实例上的 Spark 集群。这有助于启动、管理和关闭你在 AWS 上使用的 Spark 集群。然而,自 Spark 2.x 版本起,该脚本被移到了 AMPLab,以便更容易修复错误并独立维护脚本。

该脚本可以通过 GitHub 仓库github.com/amplab/spark-ec2访问并使用。

在 AWS 上启动和使用集群会产生费用。因此,完成计算后,停止或销毁集群始终是一个好习惯。否则,将会为你带来额外的费用。如需了解更多关于 AWS 定价的信息,请参阅aws.amazon.com/ec2/pricing/

您还需要为您的 Amazon EC2 实例(控制台)创建一个 IAM 实例配置文件。有关详细信息,请参阅docs.aws.amazon.com/codedeploy/latest/userguide/getting-started-create-iam-instance-profile.html。为了简便起见,我们可以下载脚本并将其放在 Spark 主目录下的ec2目录中($SPARK_HOME/ec2)。执行以下命令以启动新实例时,它会自动在集群上设置 Spark、HDFS 及其他依赖项:

$ SPARK_HOME/spark-ec2 
--key-pair=<name_of_the_key_pair> 
--identity-file=<path_of_the key_pair>  
--instance-type=<AWS_instance_type > 
--region=<region> zone=<zone> 
--slaves=<number_of_slaves> 
--hadoop-major-version=<Hadoop_version> 
--spark-version=<spark_version> 
--instance-profile-name=<profile_name>
launch <cluster-name>

我们认为这些参数是不言自明的。或者,欲了解更多详细信息,请参考github.com/amplab/spark-ec2#readme

如果您已经拥有 Hadoop 集群并且希望在其上部署 Spark: 如果您使用的是 Hadoop-YARN(甚至是 Apache Mesos),运行 Spark 作业相对更容易。即使您不使用这两者,Spark 也可以在独立模式下运行。Spark 运行一个驱动程序,该驱动程序调用 Spark 执行器。这意味着您需要告诉 Spark 在何处运行您的 Spark 守护进程(即主节点/从节点)。在您的spark/conf目录中,您可以看到一个名为slaves的文件。请更新它,列出您要使用的所有机器。您可以从源代码安装 Spark,也可以使用网站上的二进制文件。您始终应使用完全限定域名FQDN)来指定所有节点,并确保这些机器可以从您的主节点进行无密码 SSH 访问。

假设您已经创建并配置了实例配置文件。现在,您已准备好启动 EC2 集群。对于我们的情况,它应该类似于以下内容:

$ SPARK_HOME/spark-ec2 
 --key-pair=aws_key_pair 
 --identity-file=/usr/local/aws_key_pair.pem 
 --instance-type=m3.2xlarge 
--region=eu-west-1 --zone=eu-west-1a --slaves=2 
--hadoop-major-version=yarn 
--spark-version=2.1.0 
--instance-profile-name=rezacsedu_aws
launch ec2-spark-cluster-1

下图显示了您在 AWS 上的 Spark 主目录:

图 18: AWS 上的集群主页

完成后,Spark 集群将被实例化,并且在您的 EC2 账户上会有两个工作节点(从节点)。然而,这个过程有时可能需要大约半小时,具体取决于您的互联网速度和硬件配置。因此,您可能需要休息一下喝杯咖啡。集群设置完成后,您将在终端中获得 Spark 集群的 URL。为了确保集群是否正常运行,请在浏览器中检查https://<master-hostname>:8080,其中master-hostname是您在终端中获得的 URL。如果一切正常,您将看到集群在运行;请参见图 18中的集群主页。

第三步:在 AWS 集群上运行 Spark 作业

现在,您的主节点和工作节点已经激活并正在运行。这意味着您可以将 Spark 作业提交给它们进行计算。不过,在此之前,您需要使用 SSH 登录远程节点。为此,请执行以下命令来 SSH 远程 Spark 集群:

$ SPARK_HOME/spark-ec2 
--key-pair=<name_of_the_key_pair> 
--identity-file=<path_of_the _key_pair> 
--region=<region> 
--zone=<zone>
login <cluster-name> 

对于我们的情况,它应该类似于以下内容:

$ SPARK_HOME/spark-ec2 
--key-pair=my-key-pair 
--identity-file=/usr/local/key/aws-key-pair.pem 
--region=eu-west-1 
--zone=eu-west-1
login ec2-spark-cluster-1

现在,将你的应用程序(即 JAR 文件或 Python/R 脚本)复制到远程实例(在我们的例子中是ec2-52-48-119-121.eu-west-1.compute.amazonaws.com)通过执行以下命令(在新的终端中):

$ scp -i /usr/local/key/aws-key-pair.pem /usr/local/code/KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar ec2-user@ec2-52-18-252-59.eu-west-1.compute.amazonaws.com:/home/ec2-user/

然后,你需要通过执行以下命令将数据(在我们的例子中是/usr/local/data/Saratoga_NY_Homes.txt)复制到相同的远程实例:

$ scp -i /usr/local/key/aws-key-pair.pem /usr/local/data/Saratoga_NY_Homes.txt ec2-user@ec2-52-18-252-59.eu-west-1.compute.amazonaws.com:/home/ec2-user/

请注意,如果你已经在远程机器上配置了 HDFS 并放置了代码/数据文件,则不需要将 JAR 文件和数据文件复制到从节点;主节点会自动执行此操作。

做得很好!你已经快完成了!现在,最后,你需要提交你的 Spark 作业,由从节点或工作节点计算。为此,只需执行以下命令:

$SPARK_HOME/bin/spark-submit 
 --class com.chapter13.Clustering.KMeansDemo 
--master spark://ec2-52-48-119-121.eu-west-1.compute.amazonaws.com:7077 
file:///home/ec2-user/KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar 
file:///home/ec2-user/Saratoga_NY_Homes.txt

如果你的机器上没有设置 HDFS,请将输入文件放在file:///input.txt

如果你已经将数据放在 HDFS 上,你应该像以下这样发出提交命令:

$SPARK_HOME/bin/spark-submit 
 --class com.chapter13.Clustering.KMeansDemo 
--master spark://ec2-52-48-119-121.eu-west-1.compute.amazonaws.com:7077 
hdfs://localhost:9000/KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar 
hdfs://localhost:9000//Saratoga_NY_Homes.txt

作业计算成功完成后,你应该能在 8080 端口看到作业的状态和相关统计信息。

第四步:暂停、重新启动和终止 Spark 集群

当计算完成后,最好停止集群以避免额外的费用。要停止集群,请从本地机器执行以下命令:

$ SPARK_HOME/ec2/spark-ec2 --region=<ec2-region> stop <cluster-name>

对于我们的案例,应该是如下所示:

$ SPARK_HOME/ec2/spark-ec2 --region=eu-west-1 stop ec2-spark-cluster-1

要稍后重新启动集群,执行以下命令:

$ SPARK_HOME/ec2/spark-ec2 -i <key-file> --region=<ec2-region> start <cluster-name>

对于我们的案例,它将类似于以下内容:

$ SPARK_HOME/ec2/spark-ec2 --identity-file=/usr/local/key/-key-pair.pem --region=eu-west-1 start ec2-spark-cluster-1

最后,要终止 AWS 上的 Spark 集群,我们使用以下代码:

$ SPARK_HOME/ec2/spark-ec2 destroy <cluster-name>

对于我们的案例,它应该是如下所示:

$ SPARK_HOME /spark-ec2 --region=eu-west-1 destroy ec2-spark-cluster-1

Spot 实例非常适合降低 AWS 成本,有时可以将实例成本降低一个数量级。可以通过以下链接访问使用该功能的逐步指南:blog.insightdatalabs.com/spark-cluster-step-by-step/

有时候,移动大数据集(比如 1 TB 的原始数据文件)非常困难。在这种情况下,如果你希望你的应用能够在大规模数据集上扩展,最快的方式是从 Amazon S3 或 EBS 设备加载数据到你节点上的 HDFS,并使用hdfs://指定数据文件路径。

数据文件或任何其他文件(数据、JAR、脚本等)可以托管在 HDFS 上,以便更易于访问:

1. 通过http://拥有 URIs/URLs(包括 HTTP)

2. 通过 Amazon S3 使用s3n://

3. 通过 HDFS 使用hdfs://

如果你设置了HADOOP_CONF_DIR环境变量,通常参数会设置为hdfs://...;否则是file://

总结

在本章中,我们讨论了 Spark 在集群模式下的工作原理及其底层架构。你还学习了如何在集群上部署一个完整的 Spark 应用程序。你了解了如何为运行 Spark 应用程序部署集群,涵盖了不同的集群模式,如本地模式、独立模式、YARN 模式和 Mesos 模式。最后,你还看到了如何使用 EC2 脚本在 AWS 上配置 Spark 集群。我们相信本章内容将帮助你对 Spark 有一定的了解。然而,由于篇幅限制,我们未能覆盖许多 API 及其底层功能。

如果你遇到任何问题,请不要忘记将其报告到 Spark 用户邮件列表 user@spark.apache.org。在此之前,请确保你已订阅该邮件列表。在下一章中,你将学习如何测试和调试 Spark 应用程序。

第十八章:测试与调试 Spark

“每个人都知道调试比编写程序本身要难两倍。所以如果你在写程序时尽可能聪明,那你怎么能调试它呢?”

  • Brian W. Kernighan

在理想的世界里,我们编写完美的 Spark 代码,所有事情都完美运行,对吧?开玩笑;实际上,我们知道,处理大规模数据集几乎从来都不那么简单,总会有一些数据点暴露出代码的任何边角问题。

考虑到上述挑战,因此,在本章中,我们将看到测试一个分布式应用程序是多么困难;接下来,我们将看到一些应对方法。简而言之,本章将涵盖以下主题:

  • 在分布式环境中的测试

  • 测试 Spark 应用程序

  • 调试 Spark 应用程序

在分布式环境中的测试

Leslie Lamport 定义了分布式系统这个术语,具体如下:

“分布式系统是这样一个系统,在其中我无法完成任何工作,因为一台我从未听说过的机器崩溃了。”

通过万维网(也叫WWW)进行资源共享,连接计算机的网络(也叫集群),是分布式系统的一个好例子。这些分布式环境通常是复杂的,且经常出现异质性。在这些异质环境中进行测试也充满挑战。在这一部分,首先,我们将观察在与此类系统工作时常见的一些问题。

分布式环境

关于分布式系统有许多定义。让我们看看一些定义,然后我们将尝试关联上述类别。Coulouris 将分布式系统定义为一种硬件或软件组件位于联网计算机上的系统,这些组件仅通过消息传递来通信并协调其操作。另一方面,Tanenbaum 通过几种方式定义了这个术语:

  • 一组独立的计算机,对系统用户而言,表现为一台单一的计算机。

  • 一个由两台或更多独立计算机组成的系统,这些计算机通过同步或异步的消息传递协调它们的处理过程。

  • 分布式系统是一组通过网络连接的自治计算机,软件设计用来提供一个集成的计算设施。

现在,基于上述定义,分布式系统可以被分类如下:

  • 只有硬件和软件是分布式的:本地分布式系统通过局域网(LAN)连接。

  • 用户是分布式的,但有一些计算和硬件资源在后台运行,例如 WWW。

  • 用户和硬件/软件都分布式:通过广域网(WAN)连接的分布式计算集群。例如,当你使用 Amazon AWS、Microsoft Azure、Google Cloud 或 Digital Ocean 的 droplets 时,你可以获得这些类型的计算设施。

分布式系统中的问题

在这里,我们将讨论在软件和硬件测试过程中需要注意的一些主要问题,以确保 Spark 作业能够在集群计算中顺利运行,而集群计算本质上是一个分布式计算环境。

请注意,所有这些问题都是不可避免的,但我们至少可以对它们进行调整以达到更好的效果。你应该遵循上一章中给出的指示和建议。根据Kamal Sheel MishraAnil Kumar Tripathi在《分布式软件系统的一些问题、挑战和难题》中提到的内容,见于《国际计算机科学与信息技术杂志》,第 5 卷(4 期),2014 年,4922-4925 页,网址:pdfs.semanticscholar.org/4c6d/c4d739bad13bcd0398e5180c1513f18275d8.pdf,在分布式环境下使用软件或硬件时,需要解决几个问题:

  • 扩展性

  • 异构语言、平台和架构

  • 资源管理

  • 安全与隐私

  • 透明性

  • 开放性

  • 互操作性

  • 服务质量

  • 故障管理

  • 同步

  • 通信

  • 软件架构

  • 性能分析

  • 生成测试数据

  • 测试组件选择

  • 测试顺序

  • 系统扩展性和性能测试

  • 源代码的可用性

  • 事件的可重现性

  • 死锁和竞争条件

  • 故障容忍性测试

  • 分布式系统的调度问题

  • 分布式任务分配

  • 测试分布式软件

  • 来自硬件抽象层的监控与控制机制

确实我们无法完全解决所有这些问题,但使用 Spark 后,我们至少可以控制一些与分布式系统相关的问题。例如,扩展性、资源管理、服务质量、故障管理、同步、通信、分布式系统的调度问题、分布式任务分配、以及测试分布式软件时的监控与控制机制。这些大多数问题在前两章中已有讨论。另一方面,我们可以在测试和软件方面解决一些问题,例如:软件架构、性能分析、生成测试数据、组件选择、测试顺序、系统扩展性与性能测试,以及源代码的可用性。至少本章中会显式或隐式地涵盖这些内容。

分布式环境中软件测试的挑战

敏捷软件开发中的任务常常伴随着一些共同的挑战,这些挑战在将软件部署之前,在分布式环境中进行测试时变得更加复杂。团队成员通常需要在错误传播后并行合并软件组件。然而,由于紧急性,合并通常发生在测试阶段之前。有时,许多利益相关者分布在不同的团队之间。因此,误解的潜力很大,团队经常因此而迷失。

例如,Cloud Foundry (www.cloudfoundry.org/) 是一个开源的、重度分布式的 PaaS 软件系统,用于管理云中应用程序的部署和可扩展性。它承诺提供不同的功能,如可扩展性、可靠性和弹性,这些功能在 Cloud Foundry 上的部署中固有地要求底层的分布式系统实施措施,以确保稳健性、弹性和故障切换。

软件测试的过程早已为人熟知,包括单元测试集成测试冒烟测试验收测试可扩展性测试性能测试服务质量测试。在 Cloud Foundry 中,分布式系统的测试过程如下面的图所示:

图 1: 分布式环境中软件测试的示例,如 Cloud

如前面图所示(第一列),在像 Cloud 这样的分布式环境中,测试过程从对系统中最小的契约点执行单元测试开始。在所有单元测试成功执行后,进行集成测试,以验证作为一个单一一致软件系统的交互组件的行为(第二列),并运行在单个设备上(例如,虚拟机VM)或裸机)。然而,尽管这些测试验证了系统作为一个整体的行为,但它们并不能保证在分布式部署中的系统有效性。一旦集成测试通过,下一步(第三列)是验证系统的分布式部署,并运行冒烟测试。

正如你所知道的,成功配置软件并执行单元测试为我们验证系统行为的可接受性做好了准备。这一验证是通过执行验收测试(第四列)来完成的。现在,为了克服分布式环境中上述的问题和挑战,仍然有其他隐藏的挑战需要研究人员和大数据工程师解决,但这些内容实际上超出了本书的范围。

现在我们知道了在分布式环境中,软件测试所面临的真实挑战,接下来让我们开始测试一下我们的 Spark 代码。下一节将专门介绍测试 Spark 应用程序。

测试 Spark 应用程序

测试 Spark 代码有很多种方式,这取决于它是 Java 代码(你可以做基本的 JUnit 测试来测试非 Spark 部分)还是 ScalaTest 用于 Scala 代码。你还可以通过在本地运行 Spark 或在小型测试集群上运行进行完整的集成测试。另一个很棒的选择是 Holden Karau 的 Spark-testing base。你可能知道,目前 Spark 没有原生的单元测试库。然而,我们可以使用以下两种库的替代方案:

  • ScalaTest

  • Spark-testing base

然而,在开始测试你用 Scala 编写的 Spark 应用程序之前,了解一些关于单元测试和测试 Scala 方法的背景知识是必须的。

测试 Scala 方法

在这里,我们将展示一些简单的技巧来测试 Scala 方法。对于 Scala 用户来说,这是最熟悉的单元测试框架(你也可以用它来测试 Java 代码,未来还可以用于 JavaScript)。ScalaTest 支持多种不同的测试风格,每种风格旨在支持特定类型的测试需求。详情请参见 ScalaTest 用户指南:www.scalatest.org/user_guide/selecting_a_style。虽然 ScalaTest 支持多种风格,但最简单的入门方法之一是使用以下 ScalaTest 特性,并以TDD测试驱动开发)风格编写测试:

  1. FunSuite

  2. Assertions

  3. BeforeAndAfter

请随意浏览前面的链接,以了解更多关于这些特性的内容;这将使本教程的其余部分顺利进行。

需要注意的是,TDD(测试驱动开发)是一种用于开发软件的编程技术,它指出你应该从测试开始开发。因此,它不会影响如何编写测试,而是影响何时编写测试。在ScalaTest.FunSuiteAssertionsBeforeAndAfter中没有强制或鼓励 TDD 的特性或测试风格,它们更类似于 xUnit 测试框架。

在 ScalaTest 的任何风格特性中,都有三种可用的断言:

  • assert:用于你 Scala 程序中的一般断言。

  • assertResult:用于区分预期值与实际值。

  • assertThrows:用于确保某段代码抛出预期的异常。

ScalaTest 的断言定义在Assertions特性中,该特性进一步由Suite扩展。简而言之,Suite特性是所有风格特性的超类。根据 ScalaTest 文档:www.scalatest.org/user_guide/using_assertionsAssertions特性还提供以下功能:

  • assume 用于有条件地取消测试

  • fail 用于无条件地使测试失败

  • cancel 用于无条件地取消测试

  • succeed 用于无条件地使测试成功

  • intercept 用于确保某段代码抛出预期的异常,并对该异常进行断言

  • assertDoesNotCompile 用于确保某段代码不编译

  • assertCompiles 用于确保代码能够编译

  • assertTypeError 用于确保代码由于类型(而非解析)错误无法编译

  • withClue 用于添加更多关于失败的信息

从前面的列表中,我们将展示其中的一些。在你的 Scala 程序中,你可以通过调用 assert 并传入一个 Boolean 表达式来编写断言。你可以简单地开始编写你的简单单元测试用例,使用 AssertionsPredef 是一个对象,在其中定义了 assert 的行为。注意,Predef 的所有成员都会自动导入到每个 Scala 源文件中。以下源代码会打印 Assertion success,对于以下情况:

package com.chapter16.SparkTesting
object SimpleScalaTest {
  def main(args: Array[String]):Unit= {
    val a = 5
    val b = 5
    assert(a == b)
      println("Assertion success")       
  }
}

然而,如果你将 a = 2b = 1,例如,断言将失败,并且你将遇到以下输出:

图 2: 断言失败的示例

如果你传递一个为真的表达式,assert 将正常返回。然而,如果传入的表达式为假,assert 将会突然终止并抛出 Assertion Error。与 AssertionErrorTestFailedException 形式不同,ScalaTest 的 assert 提供了更多的信息,告诉你测试失败发生在哪一行,或是哪个表达式出了问题。因此,ScalaTest 的 assert 提供比 Scala 的 assert 更好的错误信息。

例如,对于以下源代码,你应该会遇到 TestFailedException,它会告诉你 5 不等于 4:

package com.chapter16.SparkTesting
import org.scalatest.Assertions._
object SimpleScalaTest {
  def main(args: Array[String]):Unit= {
    val a = 5
    val b = 4
    assert(a == b)
      println("Assertion success")       
  }
}

以下图像展示了前面 Scala 测试的输出:

图 3: TestFailedException 的示例

以下源代码解释了如何使用 assertResult 单元测试来测试方法的结果:

package com.chapter16.SparkTesting
import org.scalatest.Assertions._
object AssertResult {
  def main(args: Array[String]):Unit= {
    val x = 10
    val y = 6
    assertResult(3) {
      x - y
    }
  }
}

前面的断言将失败,Scala 会抛出一个 TestFailedException 异常,并打印 Expected 3 but got 4 (图 4):

图 4: 另一个 TestFailedException 的示例

现在,让我们看一个单元测试来展示预期的异常:

package com.chapter16.SparkTesting
import org.scalatest.Assertions._
object ExpectedException {
  def main(args: Array[String]):Unit= {
    val s = "Hello world!"
    try {
      s.charAt(0)
      fail()
    } catch {
      case _: IndexOutOfBoundsException => // Expected, so continue
    }
  }
}

如果你尝试访问超出索引范围的数组元素,前面的代码将告诉你是否可以访问前一个字符串 Hello world! 的第一个字符。如果你的 Scala 程序能够访问某个索引的值,断言将失败。这也意味着测试用例失败。因此,前面的测试用例将自然失败,因为第一个索引包含字符 H,你应该会遇到以下错误信息 (图 5):

图 5: 第三个 TestFailedException 示例

然而,现在让我们尝试如下访问位置为 -1 的索引:

package com.chapter16.SparkTesting
import org.scalatest.Assertions._
object ExpectedException {
  def main(args: Array[String]):Unit= {
    val s = "Hello world!"
    try {
      s.charAt(-1)
      fail()
    } catch {
      case _: IndexOutOfBoundsException => // Expected, so continue
    }
  }
}

现在,断言应该为真,因此,测试用例将通过。最终,代码将正常终止。现在,让我们检查一下我们的代码片段是否能编译。通常,你可能希望确保某些代表“用户错误”的代码顺序根本无法编译。其目标是检查库在错误面前的强度,以阻止不希望的结果和行为。ScalaTest 的Assertions特性包括以下语法:

assertDoesNotCompile("val a: String = 1")

如果你想确保一段代码由于类型错误(而非语法错误)而无法编译,可以使用以下代码:

assertTypeError("val a: String = 1")

语法错误仍会导致抛出TestFailedException。最后,如果你想声明一段代码能够编译,可以通过以下方式更明显地表达:

assertCompiles("val a: Int = 1")

完整的示例如下所示:

package com.chapter16.SparkTesting
import org.scalatest.Assertions._ 
object CompileOrNot {
  def main(args: Array[String]):Unit= {
    assertDoesNotCompile("val a: String = 1")
    println("assertDoesNotCompile True")

    assertTypeError("val a: String = 1")
    println("assertTypeError True")

    assertCompiles("val a: Int = 1")
    println("assertCompiles True")

    assertDoesNotCompile("val a: Int = 1")
    println("assertDoesNotCompile True")
  }
}

上述代码的输出如下图所示:

图 6: 多个测试一起进行

由于页面限制,我们现在将结束基于 Scala 的单元测试部分。然而,关于其他单元测试的案例,你可以参考 Scala 测试指南,网址为www.scalatest.org/user_guide

单元测试

在软件工程中,通常会对单独的源代码单元进行测试,以确定它们是否适合使用。这种软件测试方法也被称为单元测试。此测试确保软件工程师或开发人员编写的源代码符合设计规范,并按预期工作。

另一方面,单元测试的目标是将程序的每个部分分开(即以模块化的方式)。然后尝试观察各个部分是否正常工作。单元测试在任何软件系统中的几个好处包括:

  • 尽早发现问题: 它能在开发周期的早期发现错误或缺失的规范部分。

  • 促进变更: 它有助于重构和升级,而无需担心破坏功能。

  • 简化集成: 它使得集成测试更容易编写。

  • 文档化: 它提供了系统的活文档。

  • 设计: 它可以作为项目的正式设计。

测试 Spark 应用

我们已经看到如何使用 Scala 的内置ScalaTest包来测试你的 Scala 代码。然而,在本小节中,我们将看看如何测试用 Scala 编写的 Spark 应用。接下来将讨论以下三种方法:

  • 方法 1: 使用 JUnit 测试 Spark 应用

  • 方法 2: 使用ScalaTest包进行 Spark 应用测试

  • 方法 3: 使用 Spark 测试基础进行 Spark 应用测试

方法 1 和方法 2 将在这里讨论,并附有一些实际代码。然而,方法 3 的详细讨论将在下一个小节中提供。为了简化理解,我们将使用著名的单词计数应用程序来演示方法 1 和方法 2。

方法 1:使用 Scala JUnit 测试

假设你已经用 Scala 编写了一个应用程序,它可以告诉你文档或文本文件中有多少个单词,如下所示:

package com.chapter16.SparkTesting
import org.apache.spark._
import org.apache.spark.sql.SparkSession
class wordCounterTestDemo {
  val spark = SparkSession
    .builder
    .master("local[*]")
    .config("spark.sql.warehouse.dir", "E:/Exp/")
    .appName(s"OneVsRestExample")
    .getOrCreate()
  def myWordCounter(fileName: String): Long = {
    val input = spark.sparkContext.textFile(fileName)
    val counts = input.flatMap(_.split(" ")).distinct()
    val counter = counts.count()
    counter
  }
}

上述代码仅解析了一个文本文件,并通过简单地分割单词来执行 flatMap 操作。然后,它执行另一个操作,只考虑不同的单词。最后,myWordCounter 方法计算单词数量并返回计数器的值。

现在,在进行正式测试之前,让我们先检查一下前述方法是否正常工作。只需添加主方法并按如下方式创建一个对象:

package com.chapter16.SparkTesting
import org.apache.spark._
import org.apache.spark.sql.SparkSession
object wordCounter {
  val spark = SparkSession
    .builder
    .master("local[*]")
    .config("spark.sql.warehouse.dir", "E:/Exp/")
    .appName("Testing")
    .getOrCreate()    
  val fileName = "data/words.txt";
  def myWordCounter(fileName: String): Long = {
    val input = spark.sparkContext.textFile(fileName)
    val counts = input.flatMap(_.split(" ")).distinct()
    val counter = counts.count()
    counter
  }
  def main(args: Array[String]): Unit = {
    val counter = myWordCounter(fileName)
    println("Number of words: " + counter)
  }
}

如果你执行上述代码,你应该观察到以下输出:Number of words: 214。太棒了!它确实像本地应用程序一样运行。现在,使用 Scala JUnit 测试用例测试前面的测试用例。

package com.chapter16.SparkTesting
import org.scalatest.Assertions._
import org.junit.Test
import org.apache.spark.sql.SparkSession
class wordCountTest {
  val spark = SparkSession
    .builder
    .master("local[*]")
    .config("spark.sql.warehouse.dir", "E:/Exp/")
    .appName(s"OneVsRestExample")
    .getOrCreate()   
    @Test def test() {
      val fileName = "data/words.txt"
      val obj = new wordCounterTestDemo()
      assert(obj.myWordCounter(fileName) == 214)
           }
    spark.stop()
}

如果你仔细查看之前的代码,我在 test() 方法前使用了 Test 注解。在 test() 方法内部,我调用了 assert() 方法,实际的测试就在这里发生。我们尝试检查 myWordCounter() 方法的返回值是否等于 214。现在,按如下方式将之前的代码作为 Scala 单元测试运行(图 7):

图 7: 以 Scala JUnit 测试运行 Scala 代码

如果测试用例通过,你应该在 Eclipse IDE 上观察到以下输出(图 8):

图 8: 单词计数测试用例通过

现在,举个例子,尝试以以下方式进行断言:

assert(obj.myWordCounter(fileName) == 210)

如果之前的测试用例失败,你应该观察到以下输出(图 9):

图 9: 测试用例失败

现在让我们看一下方法 2 以及它如何帮助我们改进。

方法 2:使用 FunSuite 测试 Scala 代码

现在,让我们通过仅返回文档中文本的 RDD 来重新设计之前的测试用例,如下所示:

package com.chapter16.SparkTesting
import org.apache.spark._
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.SparkSession
class wordCountRDD {
  def prepareWordCountRDD(file: String, spark: SparkSession): RDD[(String, Int)] = {
    val lines = spark.sparkContext.textFile(file)
    lines.flatMap(_.split(" ")).map((_, 1)).reduceByKey(_ + _)
  }
}

所以,前述类中的 prepareWordCountRDD() 方法返回了一个包含字符串和整数值的 RDD。现在,如果我们想测试 prepareWordCountRDD() 方法的功能,我们可以通过扩展测试类,使用来自 ScalaTest 包的 FunSuiteBeforeAndAfterAll 来使测试更为明确。测试的工作方式如下:

  • 使用来自 Scala 的 ScalaTest 包中的 FunSuiteBeforeAndAfterAll 扩展测试类

  • 重写创建 Spark 上下文的 beforeAll() 方法

  • 使用 test() 方法执行测试,并在 test() 方法内部使用 assert() 方法

  • 重写停止 Spark 上下文的 afterAll() 方法

根据前面的步骤,让我们看看用于测试 prepareWordCountRDD() 方法的类:

package com.chapter16.SparkTesting
import org.scalatest.{ BeforeAndAfterAll, FunSuite }
import org.scalatest.Assertions._
import org.apache.spark.sql.SparkSession
import org.apache.spark.rdd.RDD
class wordCountTest2 extends FunSuite with BeforeAndAfterAll {
  var spark: SparkSession = null
  def tokenize(line: RDD[String]) = {
    line.map(x => x.split(' ')).collect()
  }
  override def beforeAll() {
    spark = SparkSession
      .builder
      .master("local[*]")
      .config("spark.sql.warehouse.dir", "E:/Exp/")
      .appName(s"OneVsRestExample")
      .getOrCreate()
  }  
  test("Test if two RDDs are equal") {
    val input = List("To be,", "or not to be:", "that is the question-", "William Shakespeare")
    val expected = Array(Array("To", "be,"), Array("or", "not", "to", "be:"), Array("that", "is", "the", "question-"), Array("William", "Shakespeare"))
    val transformed = tokenize(spark.sparkContext.parallelize(input))
    assert(transformed === expected)
  }  
  test("Test for word count RDD") {
    val fileName = "C:/Users/rezkar/Downloads/words.txt"
    val obj = new wordCountRDD
    val result = obj.prepareWordCountRDD(fileName, spark)    
    assert(result.count() === 214)
  }
  override def afterAll() {
    spark.stop()
  }
}

第一个测试表明,如果两个 RDD 以两种不同的方式物化,那么它们的内容应该是相同的。因此,第一个测试应该通过。我们将在以下示例中看到这一点。对于第二个测试,正如我们之前所看到的,RDD 的单词计数为 214,但我们暂时假设它是未知的。如果它恰好是 214,测试用例应该通过,这就是它的预期行为。

因此,我们预期两个测试都会通过。现在,在 Eclipse 中,将测试套件作为 ScalaTest-File 运行,如下图所示:

图 10: 作为 ScalaTest-File 运行测试套件

现在你应该观察以下输出(图 11)。输出显示了我们执行了多少个测试用例,多少个通过、失败、取消、忽略或待处理。它还显示了执行整个测试所需的时间。

图 11: 运行两个测试套件作为 ScalaTest 文件时的测试结果

太棒了!测试用例通过了。现在,让我们尝试在两个独立的测试中使用 test() 方法更改断言中的比较值,如下所示:

test("Test for word count RDD") { 
  val fileName = "data/words.txt"
  val obj = new wordCountRDD
  val result = obj.prepareWordCountRDD(fileName, spark)    
  assert(result.count() === 210)
}
test("Test if two RDDs are equal") {
  val input = List("To be", "or not to be:", "that is the question-", "William Shakespeare")
  val expected = Array(Array("To", "be,"), Array("or", "not", "to", "be:"), Array("that", "is", "the", "question-"), Array("William", "Shakespeare"))
  val transformed = tokenize(spark.sparkContext.parallelize(input))
  assert(transformed === expected)
}

现在,你应该预期测试用例将失败。现在作为 ScalaTest-File 运行之前的类(图 12):

图 12: 运行之前两个测试套件作为 ScalaTest-File 时的测试结果

干得好!我们已经学习了如何使用 Scala 的 FunSuite 进行单元测试。然而,如果你仔细评估前面的方法,你应该同意它有几个缺点。例如,你需要确保显式管理 SparkContext 的创建和销毁。作为开发者或程序员,你必须为测试一个示例方法编写更多的代码。有时,由于 BeforeAfter 步骤必须在所有测试套件中重复,因此会发生代码重复。然而,这个问题是有争议的,因为公共代码可以放在一个公共特征中。

现在的问题是,我们如何改善我们的体验?我的建议是使用 Spark 测试库,让生活变得更轻松、更直观。我们将讨论如何使用 Spark 测试库进行单元测试。

方法 3:使用 Spark 测试库简化生活

Spark 测试库帮助你轻松地测试大多数 Spark 代码。那么,这个方法的优点是什么呢?其实有很多。例如,使用这个方法,代码不会冗长,我们可以得到非常简洁的代码。它的 API 比 ScalaTest 或 JUnit 更加丰富。支持多种语言,例如 Scala、Java 和 Python。它支持内置的 RDD 比较器。你还可以用它来测试流式应用程序。最后,也是最重要的,它支持本地模式和集群模式的测试。这对于分布式环境中的测试至关重要。

GitHub 仓库位于 github.com/holdenk/spark-testing-base

在使用 Spark 测试库进行单元测试之前,您应在项目树中的 Maven 友好型 pom.xml 文件中包含以下依赖项,以便支持 Spark 2.x:

<dependency>
  <groupId>com.holdenkarau</groupId>
  <artifactId>spark-testing-base_2.10</artifactId>
  <version>2.0.0_0.6.0</version>
</dependency>

对于 SBT,您可以添加以下依赖项:

"com.holdenkarau" %% "spark-testing-base" % "2.0.0_0.6.0"

请注意,建议在 test 范围内添加上述依赖项,方法是为 Maven 和 SBT 两种情况都指定 <scope>test</scope>。除了这些之外,还有其他需要考虑的问题,比如内存需求、OOM(内存溢出)以及禁用并行执行。在 SBT 测试中的默认 Java 选项过小,无法支持运行多个测试。有时,如果作业以本地模式提交,测试 Spark 代码会更困难!现在,您可以自然理解在真实集群模式(即 YARN 或 Mesos)下的复杂性。

为了解决这个问题,您可以增加项目树中 build.sbt 文件中的内存量。只需添加以下参数即可:

javaOptions ++= Seq("-Xms512M", "-Xmx2048M", "-XX:MaxPermSize=2048M", "-XX:+CMSClassUnloadingEnabled")

然而,如果您使用 Surefire,您可以添加以下内容:

<argLine>-Xmx2048m -XX:MaxPermSize=2048m</argLine>

在基于 Maven 的构建中,您可以通过设置环境变量中的值来实现。有关此问题的更多信息,请参阅 maven.apache.org/configure.html

这只是一个运行 Spark 测试库自己测试的示例。因此,您可能需要设置更大的值。最后,确保在您的 SBT 中禁用了并行执行,方法是添加以下代码行:

parallelExecution in Test := false

另一方面,如果您使用 Surefire,请确保 forkCountreuseForks 分别设置为 1 和 true。让我们来看一下使用 Spark 测试库的示例。以下源代码包含三个测试用例。第一个测试用例是一个虚拟测试,用来比较 1 是否等于 1,显然会通过。第二个测试用例计算句子 Hello world! My name is Reza 中的单词数,并比较是否有六个单词。最后一个测试用例尝试比较两个 RDD:

package com.chapter16.SparkTesting
import org.scalatest.Assertions._
import org.apache.spark.rdd.RDD
import com.holdenkarau.spark.testing.SharedSparkContext
import org.scalatest.FunSuite
class TransformationTestWithSparkTestingBase extends FunSuite with SharedSparkContext {
  def tokenize(line: RDD[String]) = {
    line.map(x => x.split(' ')).collect()
  }
  test("works, obviously!") {
    assert(1 == 1)
  }
  test("Words counting") {
    assert(sc.parallelize("Hello world My name is Reza".split("\\W")).map(_ + 1).count == 6)
  }
  test("Testing RDD transformations using a shared Spark Context") {
    val input = List("Testing", "RDD transformations", "using a shared", "Spark Context")
    val expected = Array(Array("Testing"), Array("RDD", "transformations"), Array("using", "a", "shared"), Array("Spark", "Context"))
    val transformed = tokenize(sc.parallelize(input))
    assert(transformed === expected)
  }
}

从前面的源代码中,我们可以看到我们可以使用 Spark 测试库执行多个测试用例。在成功执行后,您应该观察到以下输出(图 13):

图 13: 使用 Spark 测试库成功执行并通过的测试

在 Windows 上配置 Hadoop 运行时

我们已经了解了如何在 Eclipse 或 IntelliJ 上测试使用 Scala 编写的 Spark 应用程序,但还有另一个潜在问题不容忽视。尽管 Spark 可以在 Windows 上运行,但它是为在类 UNIX 操作系统上运行而设计的。因此,如果您在 Windows 环境中工作,您需要特别小心。

在使用 Eclipse 或 IntelliJ 开发 Spark 应用程序时,如果你在 Windows 平台上解决数据分析、机器学习、数据科学或深度学习应用,你可能会遇到 I/O 异常错误,应用程序可能无法成功编译或可能会中断。实际上,问题在于 Spark 期望 Windows 上也有 Hadoop 的运行环境。例如,如果你第一次在 Eclipse 上运行一个 Spark 应用程序,比如KMeansDemo.scala,你会遇到一个 I/O 异常,显示以下信息:

17/02/26 13:22:00 ERROR Shell: Failed to locate the winutils binary in the hadoop binary path java.io.IOException: Could not locate executable null\bin\winutils.exe in the Hadoop binaries.

之所以这样,是因为 Hadoop 默认是为 Linux 环境开发的,如果你在 Windows 平台上开发 Spark 应用程序,则需要一个桥接工具,为 Spark 提供 Hadoop 运行时环境,确保 Spark 能够正确执行。I/O 异常的详细信息可以在下图中看到:

**图 14:**由于未能在 Hadoop 二进制路径中找到 winutils 二进制文件,导致 I/O 异常发生

那么,如何解决这个问题呢?解决方案很简单。正如错误信息所示,我们需要一个可执行文件,即winutils.exe。现在,从github.com/steveloughran/winutils/tree/master/hadoop-2.7.1/bin下载winutils.exe文件,将其粘贴到 Spark 分发目录中,并配置 Eclipse。更具体地说,假设你的 Spark 分发包包含 Hadoop,位于C:/Users/spark-2.1.0-bin-hadoop2.7,在 Spark 分发包内部有一个名为 bin 的目录。现在,将可执行文件粘贴到该目录中(即,path = C:/Users/spark-2.1.0-bin-hadoop2.7/bin/)。

解决方案的第二阶段是进入 Eclipse,选择主类(即本例中的KMeansDemo.scala),然后进入运行菜单。在运行菜单中,选择运行配置选项,并从中选择环境选项卡,如下图所示:

**图 15:**解决由于 Hadoop 二进制路径中缺少 winutils 二进制文件而导致的 I/O 异常

如果你选择了该标签,你将有一个选项来为 Eclipse 创建一个新的环境变量,使用 JVM。现在创建一个名为HADOOP_HOME的新环境变量,并将其值设置为C:/Users/spark-2.1.0-bin-hadoop2.7/。然后点击应用按钮,重新运行你的应用程序,问题应该得到解决。

需要注意的是,在 Windows 上使用 PySpark 与 Spark 时,也需要winutils.exe文件。有关 PySpark 的参考,请参见第十九章,PySpark 与 SparkR

请注意,前述解决方案同样适用于调试你的应用程序。有时,即使出现上述错误,你的 Spark 应用程序仍然可以正常运行。然而,如果数据集的大小较大,那么前述错误很可能会发生。

调试 Spark 应用程序

在本节中,我们将了解如何调试在本地(Eclipse 或 IntelliJ)、独立模式或 YARN 或 Mesos 集群模式下运行的 Spark 应用程序。然而,在深入探讨之前,了解 Spark 应用程序中的日志记录是必要的。

使用 log4j 记录 Spark 日志总结

我们已经在第十四章中讨论过这个话题,是时候整理一下 - 使用 Spark MLlib 对数据进行聚类。然而,为了让你的思路与当前讨论的主题调试 Spark 应用程序对齐,我们将重播相同的内容。如前所述,Spark 使用 log4j 进行自己的日志记录。如果你正确配置了 Spark,Spark 会将所有操作记录到 shell 控制台。以下是该文件的样本快照:

图 16: log4j.properties 文件的快照

将默认的 spark-shell 日志级别设置为 WARN。在运行 spark-shell 时,这个类的日志级别将覆盖根日志记录器的日志级别,从而让用户可以为 shell 和普通的 Spark 应用程序设置不同的默认值。我们还需要在启动由执行器执行并由驱动程序管理的作业时附加 JVM 参数。为此,你应该编辑 conf/spark-defaults.conf。简而言之,可以添加以下选项:

spark.executor.extraJavaOptions=-Dlog4j.configuration=file:/usr/local/spark-2.1.1/conf/log4j.properties spark.driver.extraJavaOptions=-Dlog4j.configuration=file:/usr/local/spark-2.1.1/conf/log4j.properties

为了让讨论更清晰,我们需要隐藏所有 Spark 生成的日志。然后,我们可以将这些日志重定向到文件系统中。同时,我们希望自己的日志能够在 shell 和单独的文件中记录,以避免与 Spark 的日志混淆。从这里开始,我们将指向保存自己日志的文件,在此案例中为/var/log/sparkU.log。当应用程序启动时,Spark 会加载这个log4j.properties文件,因此我们只需要将其放置在指定的位置,无需做其他操作:

package com.chapter14.Serilazition
import org.apache.log4j.LogManager
import org.apache.log4j.Level
import org.apache.spark.sql.SparkSession
object myCustomLog {
  def main(args: Array[String]): Unit = {   
    val log = LogManager.getRootLogger    
    //Everything is printed as INFO once the log level is set to INFO untill you set the level to new level for example WARN. 
    log.setLevel(Level.INFO)
    log.info("Let's get started!")    
    // Setting logger level as WARN: after that nothing prints other than WARN
    log.setLevel(Level.WARN)    
    // Creating Spark Session
    val spark = SparkSession
      .builder
      .master("local[*]")
      .config("spark.sql.warehouse.dir", "E:/Exp/")
      .appName("Logging")
      .getOrCreate()
    // These will note be printed!
    log.info("Get prepared!")
    log.trace("Show if there is any ERROR!")
    //Started the computation and printing the logging information
    log.warn("Started")
    spark.sparkContext.parallelize(1 to 20).foreach(println)
    log.warn("Finished")
  }
}

在前面的代码中,一旦日志级别设置为INFO,所有内容都会以 INFO 级别打印,直到你将日志级别设置为新的级别,比如WARN。然而,在此之后,不会再打印任何信息或跟踪等内容。此外,log4j 在 Spark 中支持多个有效的日志级别。成功执行前面的代码应该会生成以下输出:

17/05/13 16:39:14 INFO root: Let's get started!
17/05/13 16:39:15 WARN root: Started
4 
1 
2 
5 
3 
17/05/13 16:39:16 WARN root: Finished

你还可以在 conf/log4j.properties 中设置 Spark shell 的默认日志记录。Spark 提供了一个 log4j 的属性文件模板,我们可以扩展并修改这个文件来进行 Spark 的日志记录。进入 SPARK_HOME/conf 目录,你应该会看到 log4j.properties.template 文件。你应该将其重命名为 log4j.properties 后使用这个 conf/log4j.properties.template。在开发 Spark 应用程序时,你可以将 log4j.properties 文件放在你的项目目录下,尤其是在像 Eclipse 这样的 IDE 环境中工作时。然而,要完全禁用日志记录,只需将 log4j.logger.org 的标志设置为 OFF,如下所示:

log4j.logger.org=OFF

到目前为止,一切都很简单。然而,在前面的代码段中,我们还没有注意到一个问题。org.apache.log4j.Logger 类的一个缺点是它不可序列化,这意味着在进行 Spark API 的某些操作时,我们不能在闭包中使用它。例如,假设我们在 Spark 代码中做了如下操作:

object myCustomLogger {
  def main(args: Array[String]):Unit= {
    // Setting logger level as WARN
    val log = LogManager.getRootLogger
    log.setLevel(Level.WARN)
    // Creating Spark Context
    val conf = new SparkConf().setAppName("My App").setMaster("local[*]")
    val sc = new SparkContext(conf)
    //Started the computation and printing the logging information
    //log.warn("Started")
    val i = 0
    val data = sc.parallelize(i to 100000)
    data.map{number =>
      log.info(“My number”+ i)
      number.toString
    }
    //log.warn("Finished")
  }
}

你应该会遇到如下的异常,提示 Task 不可序列化:

org.apache.spark.SparkException: Job aborted due to stage failure: Task not serializable: java.io.NotSerializableException: ...
Exception in thread "main" org.apache.spark.SparkException: Task not serializable 
Caused by: java.io.NotSerializableException: org.apache.log4j.spi.RootLogger
Serialization stack: object not serializable

首先,我们可以尝试用一种简单的方法来解决这个问题。你可以做的是仅使用 extends Serializable 使 Scala 类(执行实际操作的类)可序列化。例如,代码如下:

class MyMapper(n: Int) extends Serializable {
  @transient lazy val log = org.apache.log4j.LogManager.getLogger("myLogger")
  def logMapper(rdd: RDD[Int]): RDD[String] =
    rdd.map { i =>
      log.warn("mapping: " + i)
      (i + n).toString
    }
  }

本节旨在进行日志记录的讨论。然而,我们借此机会将其拓展为更通用的 Spark 编程和问题处理方法。为了更高效地解决 task not serializable 错误,编译器会尝试通过使整个对象(而不仅仅是 lambda)变为可序列化来发送,并强制 Spark 接受这一做法。然而,这样会显著增加数据洗牌,尤其是对于大对象!其他方法包括使整个类 Serializable 或者仅在传递给 map 操作的 lambda 函数内声明实例。有时,让不可序列化的对象跨节点传递也能解决问题。最后,使用 forEachPartition()mapPartitions() 而不是仅仅使用 map() 来创建不可序列化的对象。总而言之,解决此问题的方法有以下几种:

  • 使类可序列化

  • 仅在传递给 map 操作的 lambda 函数内声明实例

  • 将不可序列化对象设置为静态,并且每台机器只创建一次

  • 调用 forEachPartition()mapPartitions() 而不是 map() 来创建不可序列化对象

在前面的代码中,我们使用了注解 @transient lazy,它将 Logger 类标记为非持久化。另一方面,包含方法 apply(即 MyMapperObject)的对象实例化了 MyMapper 类的对象,代码如下:

//Companion object 
object MyMapper {
  def apply(n: Int): MyMapper = new MyMapper(n)
}

最后,包含 main() 方法的对象如下:

//Main object
object myCustomLogwithClosureSerializable {
  def main(args: Array[String]) {
    val log = LogManager.getRootLogger
    log.setLevel(Level.WARN)
    val spark = SparkSession
      .builder
      .master("local[*]")
      .config("spark.sql.warehouse.dir", "E:/Exp/")
      .appName("Testing")
      .getOrCreate()
    log.warn("Started")
    val data = spark.sparkContext.parallelize(1 to 100000)
    val mapper = MyMapper(1)
    val other = mapper.logMapper(data)
    other.collect()
    log.warn("Finished")
  }

现在,让我们看一个提供更好见解的例子,以便继续解决我们讨论的问题。假设我们有以下类,它计算两个整数的乘积:

class MultiplicaitonOfTwoNumber {
  def multiply(a: Int, b: Int): Int = {
    val product = a * b
    product
  }
}

现在,实际上,如果你尝试使用这个类在 lambda 闭包中通过 map() 计算乘法,你将会得到我们之前描述的 Task Not Serializable 错误。现在,我们可以简单地使用 foreachPartition() 和 lambda 代码如下:

val myRDD = spark.sparkContext.parallelize(0 to 1000)
    myRDD.foreachPartition(s => {
      val notSerializable = new MultiplicaitonOfTwoNumber
      println(notSerializable.multiply(s.next(), s.next()))
    })

现在,如果你编译它,它应该返回预期的结果。为了方便,你可以查看以下包含 main() 方法的完整代码:

package com.chapter16.SparkTesting
import org.apache.spark.sql.SparkSession
class MultiplicaitonOfTwoNumber {
  def multiply(a: Int, b: Int): Int = {
    val product = a * b
    product
  }
}
object MakingTaskSerilazible {
  def main(args: Array[String]): Unit = {
    val spark = SparkSession
      .builder
      .master("local[*]")
      .config("spark.sql.warehouse.dir", "E:/Exp/")
      .appName("MakingTaskSerilazible")
      .getOrCreate()
 val myRDD = spark.sparkContext.parallelize(0 to 1000)
    myRDD.foreachPartition(s => {
      val notSerializable = new MultiplicaitonOfTwoNumber
      println(notSerializable.multiply(s.next(), s.next()))
    })
  }
}

输出结果如下:

0
5700
1406
156
4032
7832
2550
650

调试 Spark 应用程序

本节将讨论如何调试本地运行的 Spark 应用程序,使用 Eclipse 或 IntelliJ,作为 YARN 或 Mesos 上的独立模式或集群模式。在开始之前,你还可以阅读调试文档,网址是https://hortonworks.com/hadoop-tutorial/setting-spark-development-environment-scala/

在 Eclipse 中调试 Spark 应用程序作为 Scala 调试

为了实现这一点,只需将 Eclipse 配置为以常规的 Scala 代码调试方式调试你的 Spark 应用程序。配置时选择 运行 | 调试配置 | Scala 应用程序,如下图所示:

图 17: 配置 Eclipse 以调试 Spark 应用程序作为常规的 Scala 代码调试

假设我们想调试我们的KMeansDemo.scala并要求 Eclipse(你在 IntelliJ IDE 上也可以有类似的选项)从第 56 行开始执行,并在第 95 行设置断点。为此,运行你的 Scala 代码进行调试,你应该在 Eclipse 上观察到以下场景:

图 18: 在 Eclipse 中调试 Spark 应用程序

然后,Eclipse 将在你要求它在第 95 行停止执行时暂停,如下图所示:

图 19: 在 Eclipse 中调试 Spark 应用程序(断点)

总结来说,简化前面的示例,如果在第 56 行和第 95 行之间出现任何错误,Eclipse 将显示实际发生错误的位置。否则,如果没有中断,它将遵循正常的工作流程。

调试以本地和独立模式运行的 Spark 作业

在本地调试你的 Spark 应用程序或以独立模式调试时,你需要知道调试驱动程序程序和调试某个执行器是不同的,因为使用这两种节点需要传递不同的提交参数给 spark-submit。在本节中,我将使用端口 4000 作为地址。例如,如果你想调试驱动程序程序,可以在你的 spark-submit命令中添加以下内容:

--driver-java-options -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=4000

之后,你应该设置远程调试器连接到你提交驱动程序程序的节点。对于前面的情况,已指定端口号 4000。但是,如果该端口已经被其他 Spark 作业、其他应用程序或服务等占用,你可能还需要自定义该端口,也就是说,修改端口号。

另一方面,连接到执行器类似于前面的选项,唯一不同的是地址选项。更具体来说,你需要将地址替换为本地计算机的地址(IP 地址或主机名加上端口号)。然而,通常来说,建议测试一下是否能从 Spark 集群(实际进行计算的地方)访问本地计算机。例如,你可以使用以下选项来使调试环境能够在你的 spark-submit 命令中启用:

--num-executors 1\
--executor-cores 1 \
--conf "spark.executor.extraJavaOptions=-agentlib:jdwp=transport=dt_socket,server=n,address=localhost:4000,suspend=n"

总结一下,使用以下命令提交你的 Spark 作业(此处以 KMeansDemo 应用为例):

$ SPARK_HOME/bin/spark-submit \
--class "com.chapter13.Clustering.KMeansDemo" \
--master spark://ubuntu:7077 \
--num-executors 1\
--executor-cores 1 \
--conf "spark.executor.extraJavaOptions=-agentlib:jdwp=transport=dt_socket,server=n,address= host_name_to_your_computer.org:5005,suspend=n" \
--driver-java-options -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=4000 \
 KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar \
Saratoga_NY_Homes.txt

现在,启动本地调试器的监听模式并启动 Spark 程序。最后,等待执行器连接到调试器。你会在终端中看到如下消息:

Listening for transport dt_socket at address: 4000 

重要的是要知道,你需要将执行器的数量设置为 1。如果设置多个执行器,它们都会尝试连接到调试器,最终会导致一些奇怪的问题。需要注意的是,有时设置 SPARK_JAVA_OPTS 有助于调试本地运行或独立模式下的 Spark 应用。命令如下:

$ export SPARK_JAVA_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,address=4000,suspend=y,onuncaught=n

然而,自 Spark 1.0.0 版本起,SPARK_JAVA_OPTS 已被弃用,并被 spark-defaults.conf 和 Spark-submit 或 Spark-shell 的命令行参数所取代。需要注意的是,设置 spark.driver.extraJavaOptionsspark.executor.extraJavaOptions,我们在前一节看到的这些,在 spark-defaults.conf 中并不是 SPARK_JAVA_OPTS 的替代品。但坦率地说,SPARK_JAVA_OPTS 仍然非常有效,你可以尝试使用它。

在 YARN 或 Mesos 集群上调试 Spark 应用

当你在 YARN 上运行 Spark 应用时,可以通过修改 yarn-env.sh 启用一个选项:

YARN_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=4000 $YARN_OPTS"

现在,通过 Eclipse 或 IntelliJ IDE,你可以通过 4000 端口进行远程调试。第二种方法是设置 SPARK_SUBMIT_OPTS。你可以使用 Eclipse 或 IntelliJ 开发你的 Spark 应用程序,并将其提交到远程的多节点 YARN 集群执行。我通常会在 Eclipse 或 IntelliJ 上创建一个 Maven 项目,将我的 Java 或 Scala 应用打包为一个 jar 文件,然后将其提交为 Spark 作业。然而,为了将你的 IDE(如 Eclipse 或 IntelliJ)调试器附加到 Spark 应用中,你可以使用如下的 SPARK_SUBMIT_OPTS 环境变量来定义所有的提交参数:

$ export SPARK_SUBMIT_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=4000

然后按如下方式提交你的 Spark 作业(请根据你的需求和设置更改相应的值):

$ SPARK_HOME/bin/spark-submit \
--class "com.chapter13.Clustering.KMeansDemo" \
--master yarn \
--deploy-mode cluster \
--driver-memory 16g \
--executor-memory 4g \
--executor-cores 4 \
--queue the_queue \
--num-executors 1\
--executor-cores 1 \
--conf "spark.executor.extraJavaOptions=-agentlib:jdwp=transport=dt_socket,server=n,address= host_name_to_your_computer.org:4000,suspend=n" \
--driver-java-options -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=4000 \
 KMeans-0.0.1-SNAPSHOT-jar-with-dependencies.jar \
Saratoga_NY_Homes.txt

在运行上述命令后,系统将等待直到你连接调试器,如下所示:Listening for transport dt_socket at address: 4000。现在你可以在 IntelliJ 调试器中配置你的 Java 远程应用程序(Scala 应用也可以),如下图所示:

图 20: 在 IntelliJ 上配置远程调试器

对于前述情况,10.200.1.101 是远程计算节点的 IP 地址,Spark 作业基本上在该节点上运行。最后,你需要通过点击 IntelliJ 的运行菜单下的 Debug 来启动调试器。然后,如果调试器成功连接到你的远程 Spark 应用程序,你将看到 IntelliJ 中应用程序控制台的日志信息。现在,如果你可以设置断点,其他的步骤就是正常的调试过程。下图展示了在 IntelliJ 中暂停 Spark 作业并设置断点时,你将看到的界面:

图 21: 在 IntelliJ 中暂停 Spark 作业并设置断点时的示例

尽管它运行得很好,但有时我发现使用 SPARK_JAVA_OPTS 并不会在 Eclipse 或甚至 IntelliJ 的调试过程中提供太多帮助。相反,在实际集群(如 YARN、Mesos 或 AWS)上运行 Spark 作业时,应使用并导出 SPARK_WORKER_OPTSSPARK_MASTER_OPTS,如下所示:

$ export SPARK_WORKER_OPTS="-Xdebug -Xrunjdwp:server=y,transport=dt_socket,address=4000,suspend=n"
$ export SPARK_MASTER_OPTS="-Xdebug -Xrunjdwp:server=y,transport=dt_socket,address=4000,suspend=n"

然后启动你的主节点,如下所示:

$ SPARKH_HOME/sbin/start-master.sh

现在,打开 SSH 连接到远程机器,该机器上正在实际运行 Spark 作业,并将本地主机的 4000 端口(即 localhost:4000)映射到 host_name_to_your_computer.org:5000,假设集群位于 host_name_to_your_computer.org:5000 并监听 5000 端口。现在,Eclipse 会认为你仅在调试一个本地 Spark 应用程序或进程。然而,要实现这一点,你需要在 Eclipse 上配置远程调试器,如下图所示:

图 22: 在 Eclipse 中连接远程主机进行 Spark 应用程序调试

就是这样!现在你可以像调试桌面上的应用程序一样在实时集群上调试。前述示例适用于将 Spark 主节点设置为 YARN-client 的情况。然而,它在 Mesos 集群上运行时也应该可以工作。如果你在 YARN-cluster 模式下运行,你可能需要将驱动程序设置为连接到你的调试器,而不是将调试器连接到驱动程序,因为你不一定能提前知道驱动程序将在哪种模式下执行。

使用 SBT 调试 Spark 应用程序

前述设置主要适用于 Eclipse 或 IntelliJ 中使用 Maven 项目的情况。假设你已经完成了应用程序并正在使用你偏好的 IDE,如 IntelliJ 或 Eclipse,具体如下:

object DebugTestSBT {
  def main(args: Array[String]): Unit = {
    val spark = SparkSession
      .builder
      .master("local[*]")
      .config("spark.sql.warehouse.dir", "C:/Exp/")
      .appName("Logging")
      .getOrCreate()      
    spark.sparkContext.setCheckpointDir("C:/Exp/")
    println("-------------Attach debugger now!--------------")
    Thread.sleep(8000)
    // code goes here, with breakpoints set on the lines you want to pause
  }
}

现在,如果你想将此作业部署到本地集群(独立模式),第一步是将应用程序及其所有依赖项打包成一个 fat JAR。为此,请使用以下命令:

$ sbt assembly

这将生成 fat JAR。现在的任务是将 Spark 作业提交到本地集群。你需要在系统中的某个位置有 spark-submit 脚本:

$ export SPARK_JAVA_OPTS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

前述命令导出了一个 Java 参数,将用于启动带调试器的 Spark:

$ SPARK_HOME/bin/spark-submit --class Test --master local[*] --driver-memory 4G --executor-memory 4G /path/project-assembly-0.0.1.jar

在前面的命令中,--class需要指向你的作业的完全限定类路径。成功执行该命令后,你的 Spark 作业将会在没有中断的情况下执行。现在,要在你的 IDE(比如 IntelliJ)中获得调试功能,你需要配置以连接到集群。有关 IDEA 官方文档的详细信息,请参阅stackoverflow.com/questions/21114066/attach-intellij-idea-debugger-to-a-running-java-process

需要注意的是,如果你只是创建了一个默认的远程运行/调试配置并保持默认端口 5005,它应该可以正常工作。现在,当你下次提交作业并看到附加调试器的提示时,你有八秒钟的时间切换到 IntelliJ IDEA 并触发此运行配置。程序将继续执行,并在你定义的任何断点处暂停。然后,你可以像调试任何普通的 Scala/Java 程序一样逐步调试。你甚至可以逐步进入 Spark 的函数,查看它在背后究竟在做什么。

总结

在本章中,你看到了测试和调试 Spark 应用程序是多么困难。这些问题在分布式环境中可能更加严重。我们还讨论了一些高级方法来解决这些问题。总结一下,你学习了在分布式环境中进行测试的方法。接着,你学习了更好的测试 Spark 应用程序的方法。最后,我们讨论了一些调试 Spark 应用程序的高级方法。

我们相信这本书能帮助你对 Spark 有一个良好的理解。然而,由于篇幅限制,我们无法涵盖许多 API 及其底层功能。如果你遇到任何问题,请记得将问题报告到 Spark 用户邮件列表,邮件地址为user@spark.apache.org。在此之前,请确保你已订阅该邮件列表。

这差不多就是我们关于 Spark 高级主题的整个旅程的结束。现在,作为读者,或者如果你相对较新于数据科学、数据分析、机器学习、Scala 或 Spark,我们有一个一般性的建议:你应该首先尝试理解你想要执行哪种类型的分析。更具体地说,例如,如果你的问题是一个机器学习问题,试着猜测哪种类型的学习算法最适合,即分类、聚类、回归、推荐或频繁模式挖掘。然后定义和构建问题,之后,你应该根据我们之前讨论的 Spark 特征工程概念生成或下载适当的数据。另一方面,如果你认为你可以使用深度学习算法或 API 来解决问题,你应该使用其他第三方算法并与 Spark 集成,直接进行工作。

我们对读者的最终建议是定期浏览 Spark 网站(spark.apache.org/),以获取最新更新,并尝试将 Spark 提供的常规 API 与其他第三方应用程序或工具结合使用,以获得最佳的协作效果。