Spark Connect 是 Apache Spark 生态系统中的一个相对较新的组件,它允许薄客户端在远程 Spark 集群上运行 Spark 应用。这项技术可以为使用 DataFrame API 的 Spark 应用带来一些好处。Spark 一直可以在远程的 Thrift JDBC 服务器上运行 SQL 查询。然而,远程运行使用任何支持的语言(如 Scala、Python)编写的客户端应用程序的功能才在 Spark 3.4 中出现。
在这篇文章里,我将分享我们使用Spark Connect(版本3.5)的经验和心得。我将谈谈我们从使用Spark Connect中获得的好处,运行Spark客户端应用程序的技术细节,以及一些让您的Spark Connect设置更高效稳定的技巧。
使用原因说明Spark 是 Joom 分析平台中的一个关键组件。我们有大量的内部用户,并且运行着超过 1000 个自定义的 Spark 应用程序。这些应用程序在一天中的不同时段运行,复杂度各不相同,并且所需的计算资源也各有差异(所需计算资源从几核运行几分钟到运行几天需要超过 250 核不等)。此前,所有这些应用程序都作为独立的 Spark 应用程序运行(每个都有自己独立的驱动程序和执行器)。对于小型到中型的应用程序(我们历史上有很多这样的应用程序),这导致了明显的开销。通过引入 Spark Connect,现在可以设置一个共享的 Spark Connect 服务器来运行多个 Spark 客户端应用程序。从技术角度看,Spark Connect 服务器是一个具备嵌入式 Spark Connect 端点的 Spark 应用程序。
图片来自作者
我們從中得到的好處如下:
- 资源节省
- 当通过Spark Connect运行时,客户端应用程序通常不需要自己的Spark驱动程序(这通常使用超过1.5GB的内存)。相反,它们使用一个典型的内存消耗为200MB的轻量级客户端。
- 执行器利用率提高,因为任何执行器都可以运行多个客户端应用程序的任务。例如,假设某些Spark应用程序在执行过程中某个时刻开始使用远少于最初请求的核心和内存。这种情况发生的原因多种多样。在这种情况下,如果有单独运行的其他Spark应用程序,目前未使用的资源通常会被浪费,因为动态分配通常无法实现有效的资源缩减。然而,通过Spark Connect服务器,释放的核心和内存可以立即用于运行其他客户端应用程序的任务。 - 启动等待时间减少
- 由于各种原因,我们必须限制同时运行的单独的Spark应用程序的数量,如果所有槽位都被占用,它们可能需要在队列中等待相当长的时间。这可能会影响数据准备时间并影响用户体验。在Spark Connect服务器的情况下,我们迄今为止能够避免这样的限制,并且所有的Spark Connect客户端应用程序在启动后立即开始运行。
- 对于即时执行,尽可能缩短获取结果的时间并避免让人们等待是很理想的。在单独的Spark应用程序的情况下,启动一个客户端应用程序通常需要提供额外的EC2节点来运行其驱动程序和执行器,以及初始化驱动程序和执行器。所有这些操作可能需要超过4分钟的时间。在Spark Connect服务器的情况下,至少其驱动程序总是保持启动和就绪状态,随时可以接受请求,因此只需要等待额外的执行器,而且执行器通常已经可用。这可能会显著减少即席查询或任务准备就绪的等待时间。
目前,我们不会在 Spark Connect 上运行负载大的长时间运行的应用程序,原因如下,具体来说:
- 它们可能会导致Spark Connect服务器失败或行为不稳定(例如,通过填满执行节点的磁盘来引发故障)。这可能会给整个平台带来大范围的问题。
- 它们通常需要独特的内存配置并使用特定的优化技术(例如,自定义的额外策略extraStrategies)。
- 我们目前遇到的一个问题是给Spark Connect服务器分配过多的执行器来处理非常大的同时任务负载(这与Spark任务调度器的行为有关,超出了本文讨论的范围)。
因此,重型应用依旧作为独立的Spark应用程序运行。
启动客户端应用。我们用Spark on Kubernetes/EKS和Airflow。其中一些代码示例可能特定于此环境。
我们有太多不同的、不断变化的Spark应用程序,手动确定每个应用程序是否应根据我们的标准在Spark Connect上运行,将花费大量时间。此外,定期更新运行在Spark Connect上的应用程序列表是必要的。例如,今天某个应用程序足够轻量,所以我们决定让它运行在Spark Connect上。但明天,它的开发人员可能会添加几个大型连接,使其变得非常沉重。那么,最好让它作为独立的Spark应用程序运行。这种情况也可能反过来发生。
最终,我们创建了一个自动服务,用于确定如何启动每个特定的客户端应用程序。该服务分析每个应用程序之前的运行历史,评估诸如总任务时间
、洗牌写操作
、磁盘溢写
等指标(这些数据是通过SparkListener收集的)。开发人员为应用程序设置的自定义参数(例如,驱动程序和执行器的内存设置)也被考虑在内。根据这些数据,该服务自动确定每个应用程序是否应在Spark Connect服务器上运行,或作为单独的Spark应用程序运行。我们的应用程序都准备好了,可以以两种方式中的任何一种运行。
在我们的环境中,每个客户端应用程序都是独立于其他应用程序构建的,并且每个应用程序都有自己的包含应用代码的 JAR 文件,以及特定的依赖项(例如,机器学习应用程序经常使用第三方库,如 CatBoost 等)。问题在于,Spark Connect 的 SparkSession API 与用于单独的 Spark 应用程序的 SparkSession API 在某些方面有所不同(Spark Connect 客户端使用 spark-connect-client-jvm
组件)。因此,我们需要在构建每个客户端应用程序时知道它是否会通过 Spark Connect 运行。但我们不知道这一点。以下描述了启动客户端应用程序的方法,该方法避免了为同一应用程序构建和管理两个版本的 JAR 构件的需要。
对于每个Spark客户端应用,我们仅构建一个包含应用代码及其特定依赖的JAR文件。此JAR在运行于Spark Connect和作为独立Spark应用运行时都会用到。因此,这些客户端JAR不包含特定的Spark依赖。合适的Spark依赖(spark-core
/spark-sql
或spark-connect-client-jvm
)将具体取决于运行模式,在后续的Java类路径中提供。无论如何,所有客户端应用均使用相同的Scala代码来初始化SparkSession
,并且SparkSession
的操作也会根据运行模式来决定。所有客户端应用的JAR都是为常规Spark API构建的,。在涉及Spark Connect客户端的部分代码中,通过反射调用Spark Connect API特定的SparkSession
方法,例如remote
和addArtifact
。
val sparkConnectUri: Option[String] = Option(System.getenv("SPARK_CONNECT_URI"))
val isSparkConnectMode: Boolean = sparkConnectUri.isDefined
def createSparkSession(): SparkSession = {
if (isSparkConnectMode) {
createRemoteSparkSession()
} else {
SparkSession.builder
// 配置SparkSession以适应单独的Spark应用程序所需的操作。
.getOrCreate
}
}
private def createRemoteSparkSession(): SparkSession = {
val uri = sparkConnectUri.getOrElse(throw new Exception(
"必需的环境变量 'SPARK_CONNECT_URI' 未被设置。"))
val builder = SparkSession.builder
// 这里使用反射,因为常规的SparkSession API中没有这些方法,而这些方法仅在Spark Connect的SparkSession API版本中可用。
classOf[SparkSession.Builder]
.getDeclaredMethod("remote", classOf[String])
.invoke(builder, uri)
// 该应用程序需要使用的一组标识符
val scAppId = s"spark-connect-${UUID.randomUUID()}"
val airflowTaskId = Option(System.getenv("AIRFLOW_TASK_ID"))
.getOrElse("unknown_airflow_task_id")
val session = builder
.config("spark.joom.scAppId", scAppId)
.config("spark.joom.airflowTaskId", airflowTaskId)
.getOrCreate()
// 如果客户端应用程序使用你的Scala代码(例如,自定义UDF),则必须添加包含该代码的jar工件,以便它可以在Spark Connect的服务器端使用。
val addArtifact = Option(System.getenv("ADD_ARTIFACT_TO_SC_SESSION"))
.forall(_.toBoolean)
if (addArtifact) {
val mainApplicationFilePath =
System.getenv("SPARK_CONNECT_MAIN_APPLICATION_FILE_PATH")
classOf[SparkSession]
.getDeclaredMethod("addArtifact", classOf[String])
.invoke(session, mainApplicationFilePath)
}
Runtime.getRuntime.addShutdownHook(new Thread() {
override def run(): Unit = {
session.close()
}
})
session
}
在 Spark Connect 模式下,这个客户端代码可以作为一个普通的 Java 应用程序在任何地方运行。由于我们使用 Kubernetes,这个程序在一个 Docker 容器中运行。所有特定于 Spark Connect 的依赖项都打包到了用于运行客户端应用程序的 Docker 镜像中(可以在这里找到这个镜像的一个最小示例:here)。该镜像不仅包含 spark-connect-client-jvm
组件,还包括其它几乎所有客户端应用都会用到的常见依赖项(例如,hadoop-aws
),因为我们客户端几乎总是要与 S3 存储进行交互。
FROM openjdk:11-jre-slim
WORKDIR /app
# 在这里,我们将复制任何Spark Connect客户端所需的通用工件(主要是spark-connect-client-jvm,以及spark-hive、hadoop-aws、scala-library等)。
COPY build/libs/* /app/
COPY src/main/docker/entrypoint.sh /app/
RUN chmod +x ./entrypoint.sh
ENTRYPOINT ["./entrypoint.sh"]
这个常用的 Docker 镜像用于通过 Spark Connect 运行我们的所有客户端应用程序。此外,它不包含特定应用及其依赖的客户端 JAR 文件,因为有许多这样的应用程序会不断更新和变化,并且可能依赖任何第三方库。相反,当启动特定的客户端应用程序时,会通过环境变量传递其 JAR 文件位置,并在 entrypoint.sh
初始化时下载该 JAR 文件。
#!/bin/bash
set -eo pipefail
# 此变量将在应用程序代码中的 SparkSession 构建器中被使用。
export SPARK_CONNECT_MAIN_APPLICATION_FILE_PATH="/tmp/$(uuidgen).jar"
# 下载包含客户端应用程序特定依赖项的 JAR 文件。这些 JAR 文件都被存储在 S3 中,并且当创建客户端 Pod 时,所需的 JAR 文件路径将通过环境变量传递给它。
java -cp "/app/*" com.joom.analytics.sc.client.S3Downloader \
${MAIN_APPLICATION_FILE_S3_PATH} ${SPARK_CONNECT_MAIN_APPLICATION_FILE_PATH}
# 启动客户端应用程序。任何 MAIN_CLASS 类在开始执行时都会使用上述代码初始化一个 SparkSession。
java -cp ${SPARK_CONNECT_MAIN_APPLICATION_FILE_PATH}:"/app/*" ${MAIN_CLASS} "$@"
最后,当应用程序启动时,我们的自定义SparkAirflowOperator会根据此应用程序之前的运行统计信息自动判定执行模式(Spark Connect模式或独立模式),以更贴切地传达源文本中的意思。
- 在Spark Connect的情况下,我们使用KubernetesPodOperator来启动应用程序的客户端Pod。
KubernetesPodOperator
作为参数接收前面提到的Docker镜像,环境变量(例如MAIN_CLASS
,JAR_PATH
等),这些变量可以在entrypoint.sh
和应用程序代码中使用。客户端Pod不需要分配过多资源(例如,在我们的环境中,内存通常为200MB,vCPU为0.15个单位)。 - 对于单独的Spark应用程序,我们使用自定义的AirflowOperator,通过spark-on-k8s-operator和官方的Spark Docker镜像来运行Spark应用程序。我们先略过我们Spark AirflowOperator的细节,因为这需要单独写一篇文章详细讨论。
与普通Spark应用程序的兼容问题
并非所有的现有Spark应用程序都能在Spark Connect上成功运行,因为它的SparkSession API与独立Spark应用程序使用的SparkSession API有所不同。例如,如果你的代码使用了sparkSession.sparkContext
或sparkSession.sessionState
,那么在Spark Connect客户端里就会出错,因为Spark Connect版本的SparkSession不包含这些属性。
在我们的案例中,问题最常见的原因之一是使用了 sparkSession.sessionState.catalog
和 sparkSession.sparkContext.hadoopConfiguration
。在某些情况下,sparkSession.sessionState.catalog
可以用 sparkSession.catalog
替换,但这并不总是可行。如果客户端代码中包含对数据存储的操作(例如),则可能需要使用 sparkSession.sparkContext.hadoopConfiguration
:
def 删除文件(path: Path, 是否递归删除: Boolean = true)
(implicit hadoopConfig: Configuration): Boolean = {
val fs = path.getFileSystem(hadoopConfig)
fs.delete(path, 是否递归删除)
}
幸运的是,可以为Spark Connect客户端创建一个独立的SessionCatalog
。在这种情况下,Spark Connect客户端的类路径必须包含org.apache.spark:spark-hive_2.12
,以及与存储交互所需的库(因为我们使用S3,所以这里需要org.apache.hadoop:hadoop-aws
)。
导入 org.apache.spark.SparkConf
导入 org.apache.hadoop.conf.Configuration
导入 org.apache.spark.sql.hive.StandaloneHiveExternalCatalog
导入 org.apache.spark.sql.catalyst.catalog.{ExternalCatalogWithListener, SessionCatalog}
// 下面是一个所需属性的示例。
// 所有这些属性都应以某种方式在现有的Spark应用程序中设置好,
// 它们的完整列表可以在任何正在运行的独立Spark应用程序的环境选项卡中找到。
val sessionCatalogConfig = Map(
"spark.hadoop.hive.metastore.uris" -> "thrift://metastore.spark:9083",
"spark.sql.catalogImplementation" -> "hive",
"spark.sql.catalog.spark_catalog" -> "org.apache.spark.sql.delta.catalog.DeltaCatalog",
)
val hadoopConfig = Map(
"hive.metastore.uris" -> "thrift://metastore.spark:9083",
"fs.s3.impl" -> "org.apache.hadoop.fs.s3a.S3AFileSystem",
"fs.s3a.aws.credentials.provider" -> "com.amazonaws.auth.DefaultAWSCredentialsProviderChain",
"fs.s3a.endpoint" -> "s3.amazonaws.com",
// 等等...
)
def createStandaloneSessionCatalog(): (SessionCatalog, Configuration) = {
val sparkConf = new SparkConf().setAll(sessionCatalogConfig)
val hadoopConfiguration = new Configuration()
hadoopConfig.foreach {
case (key, value) => hadoopConfiguration.set(key, value)
}
val externalCatalog = new StandaloneHiveExternalCatalog(
sparkConf, hadoopConfiguration)
val sessionCatalog = new SessionCatalog(
new ExternalCatalogWithListener(externalCatalog)
)
(sessionCatalog, hadoopConfiguration)
}
你也需要创建一个 HiveExternalCatalog
的包装类,以便你在代码中能够使用(因为 HiveExternalCatalog
类是私有的,位于 org.apache.spark
包中):
包 org.apache.spark.sql.hive 包
导入 org.apache.hadoop.conf.Configuration
导入 org.apache.spark.SparkConf
类 StandaloneHiveExternalCatalog(conf: SparkConf, hadoopConf: Configuration)
扩展 HiveExternalCatalog(conf: SparkConf, hadoopConf: Configuration)
此外,通常可以将无法在Spark Connect上运行的代码替换为其他可行的代码,例如:
sparkSession.createDataFrame(sparkSession.sparkContext.parallelize(data), schema)
==>sparkSession.createDataFrame(data.toList.asJava, schema)
sparkSession.sparkContext.getConf.get("some_property")
==>sparkSession.conf.getConf.get("some_property")
不幸的是,将一个特定的Spark应用程序调整为Spark Connect客户端并不总是容易。例如,项目中使用的第三方Spark组件存在较大隐患,因为它们通常是不考虑与Spark Connect兼容性而编写的。由于在我们的环境中,任何Spark应用程序都可以自动在Spark Connect上启动,因此我们认为在出现问题时回退到一个独立的Spark应用程序是合理的。简化来说,逻辑如下:
- 如果某个应用程序在 Spark Connect 上失败了,我们会马上尝试将其作为单独的 Spark 应用程序重新运行。同时,我们增加在 Spark Connect 上执行失败的次数(每个客户端应用程序都有自己的计数器)。
- 下次启动此应用程序时,我们会检查该应用程序的失败次数:
- 如果失败次数少于 3 次,我们假设上一次失败可能不是由于与 Spark Connect 不兼容,而是由于其他任何可能的暂时原因。因此,我们再次尝试在 Spark Connect 上运行它。如果这次成功,该客户端应用程序的失败计数器将重置为零。
- 如果已经失败了 3 次,我们假设该应用程序不能在 Spark Connect 上运行,并停止在那里再次尝试。之后,它将仅作为单独的 Spark 应用程序运行。
- 如果应用程序在 Spark Connect 上失败了 3 次,但最后一次失败发生在两个月以上,我们会再次尝试在 Spark Connect 上运行它(如果在这段时间内发生了改变,使其与 Spark Connect 兼容)。如果这次成功,我们将重置失败计数器为零。如果再失败,下次再试要等到两个月后。
这种方法比起通过日志来识别失败原因的代码维护起来稍微简单一些,并且在大多数情况下运行良好。通常尝试在Spark Connect上运行不兼容的应用程序并不会产生明显的负面影响,因为在大多数情况下,如果一个应用程序与Spark Connect不兼容,它会在启动时立即失败,不会浪费任何时间和资源。不过值得注意的是,我们所有的应用都具有幂等性。
统计数据收集如我之前提到的,我们会为每个Spark应用程序收集Spark性能统计(我们平台的多数优化和告警都依赖于这些统计信息)。当应用程序独立运行时,收集这些统计信息非常简单。然而,对于Spark Connect来说,每个客户端应用的阶段和任务需要从在共享Spark Connect服务器中同时运行的其他客户端应用中独立出来。
您可以为 SparkSession
设置自定义属性,将任何标识符传递给 Spark Connect 服务器。
val session = builder
.配置("spark.joom.scAppId", scAppId.toString)
.配置("spark.joom.airflowTaskId", airflowTaskId.toString)
.getOrCreate()
然后,在 Spark Connect 服务器端的 SparkListener
中,你可以获取所有信息,并将每个阶段和任务与特定的客户端应用关联起来。
类统计报告Spark监听器 extends Spark监听器 {
override def 阶段提交(stageSubmitted: Spark监听器阶段提交): Unit = {
val 阶段ID = stageSubmitted阶段信息.阶段ID
val 阶段尝试次数 = stageSubmitted阶段信息.尝试次数()
val scAppId = stageSubmitted属性.获取属性("spark.joom.scAppId")
// ...
}
}
在这里,你可以找到我们用来收集统计数据的 StatsReportingSparkListener
的代码:可以在这里。你可能会对这个免费工具感兴趣:这里,它可以帮助你发现 Spark 应用中的性能瓶颈。
Spark Connect 服务器是一个永久运行的 Spark 应用程序,允许多个客户端运行他们的任务。因此,调整其配置可能很有意义,这可以使其更加可靠,并有助于防止资源浪费。以下设置在我们的情况下很有帮助:
// 使用动态资源分配对 Spark Connect 服务器非常重要,因为工作负载在时间上的分布可能非常不均匀。
spark.dynamicAllocation.enabled: true // 默认:false
// 这两个参数负责及时移除空闲的执行器:
spark.dynamicAllocation.cachedExecutorIdleTimeout: 5m // 默认:无穷大
spark.dynamicAllocation.shuffleTracking.timeout: 5m // 默认:无穷大
// 只有当现有执行器无法在较长时间内处理接收到的任务时,才创建新的执行器。这允许在某些时间点只有少量任务到达时节省资源,这些任务不需要许多执行器来及时处理。随着 schedulerBacklogTimeout 的增大,不必要的执行器在所有传入任务完成之前不会出现。这会稍微增加完成任务所需的时间,但在大多数情况下,这种增加并不显著。
spark.dynamicAllocation.schedulerBacklogTimeout: 30s // 默认:1s
// 如果出于某种原因需要停止客户端应用程序的执行(并释放资源),可以强制终止客户端。目前,即使明确关闭了客户端 SparkSession,其对应的 Jobs 在服务器上也不会立即停止执行,而是继续运行一段时间,直到达到 'detachedTimeout' 的时间。因此,可能合理地减小这个超时时间。
spark.connect.execute.manager.detachedTimeout: 2m // 默认:5m
// 我们遇到过杀死的任务可能会挂起不可预测的长时间,从而给其执行器带来不良后果的情况。在这种情况下,最好移除出现问题的执行器。
spark.task.reaper.enabled: true // 默认:false
spark.task.reaper.killTimeout: 300s // 默认:-1
// Spark Connect 服务器可以长时间运行。在此期间,执行器可能会失败,包括超出我们控制的原因(例如,AWS Spot 中断)。这个选项用于防止在这些情况下整个服务器崩溃。
spark.executor.maxNumFailures: 1000
// 根据我们的经验,在某些情况下,BroadcastJoin 可能会导致严重的性能问题。因此,我们决定禁用广播。
// 禁用此选项通常不会导致我们典型应用的性能明显下降。
spark.sql.autoBroadcastJoinThreshold: -1 // 默认:10MB
// 对于许多客户端应用程序,我们不得不将一个工件添加到客户端会话中(方法 sparkSession.addArtifact())。
// 使用 'useFetchCache=true' 会导致应用程序 JAR 文件在执行器磁盘上的空间消耗翻倍,因为它们也会在本地缓存文件夹中被复制。有时这会导致磁盘溢出,从而给执行器带来后续问题。
spark.files.useFetchCache: false // 默认:true
// 为了确保在多个应用程序并发运行时公平地分配资源。
spark.scheduler.mode: FAIR // 默认:FIFO
例如,调整之后,idle timeout
属性的资源使用情况变化如下:
作者供图
预防重启在我们的环境中,Spark Connect 服务器(版本 3.5)在连续运行几天后可能会变得不稳定或崩溃。最常见的情况是,客户端应用程序作业可能会随机挂起,长时间无响应。除此之外,也可能出现其他问题。随着时间推移,整个 Spark Connect 服务器随机故障的概率会显著增加,而且这种问题可能在关键时刻发生。
随着这个组件的不断进步,它可能会变得更加稳定(或者我们会发现我们在Spark Connect设置中出了问题)。但目前,最简单的方法是在合适的时间点(即在没有客户端应用程序运行时)每天预防性重启Spark Connect服务器。一个重启代码示例可以在这里找到。
结论部分在这篇文章里,我分享了我们使用Spark Connect运行多种多样的Spark应用程序的经验。
总结一下:
- 此组件可以帮助节省资源并减少Spark客户端应用程序的执行等待时间。
- 应谨慎对待哪些应用程序应该在共享的Spark Connect服务器上运行,因为资源密集型应用程序可能会对整个系统造成问题。
- 您可以创建一个用于启动客户端应用程序的基础设施,这样在启动时可以自动决定运行任何应用程序的方式(作为独立的Spark应用程序或作为Spark Connect客户端)。
- 需要注意的是,并非所有应用程序都能在Spark Connect上运行,但这种情况的数量可以大大减少。对于与Spark Connect版本的SparkSession API兼容性尚未测试过的应用程序,如果有可能运行,则值得实现回退到独立的Spark应用程序。
- 值得注意的是那些可以提高Spark Connect服务器整体稳定性的Spark配置属性。还可能合理地定期进行预防性的Spark Connect服务器重启,以降低意外故障和意外行为的可能性。
总的来说,我们在公司使用Spark Connect的经历非常好。我们会非常关注这项技术的发展,并打算更广泛地使用它。
共同学习,写下你的评论
评论加载中...
作者其他优质文章