在Docker容器中运行Spark,资源调度的复杂度比裸机部署高出不少。容器提供了隔离边界,但也引入了一层新的资源抽象。Spark默认按照宿主机的CPU核数和内存大小来计算executor资源,如果容器没有正确传递限制,Spark会误判可用资源,导致任务挤压或者频繁失败。本文从容器资源边界、调度模式、动态分配三个角度拆解Spark on Docker的资源管理方法。

容器资源边界与Spark JVM的认知差异
在裸机环境中,Spark通过Runtime.getRuntime().availableProcessors()和OperatingSystemMXBean来获取CPU核数和内存大小,这些数值直接来自操作系统。但容器环境里,Docker通过cgroup限制CPU和内存,JVM如果不感知cgroup,读取到的仍是宿主机的配置。例如宿主机有64核、256GB内存,而容器只分配了4核、8GB,Spark driver默认会认为可用资源是64核,从而把executor数量或并行度设置得过大。
解决这个问题的关键是让JVM识别容器限制。对于Java 8u191+和Java 11+,可以开启UseContainerSupport参数,并配合ActiveProcessorCount设置。Spark 3.x已经默认开启容器感知,但在自定义镜像或旧版本中需要手动指定。建议在Dockerfile中设置JAVA_OPTS环境变量,或者在spark-env.sh中配置。以下是一个docker run示例,显式声明CPU和内存资源,同时让容器内的Spark使用相同配置。
docker run -d \ --name spark-worker \ --cpus=4 \ --memory=8g \ --memory-swap=8g \ -e SPARK_WORKER_CORES=4 \ -e SPARK_WORKER_MEMORY=8g \ -p 8081:8081 \ spark:3.5.0
需要注意的是,内存限制必须同时设置memory和memory-swap,否则容器可能使用交换分区,导致Spark性能急剧下降。另外,CPU限制使用--cpus而非--cpu-quota,能更直观地映射到Spark的spark.executor.cores。
Standalone与Kubernetes两种调度模式对比
Spark on Docker有两种常见部署形态:一是将容器作为Spark的worker节点,继续使用Spark Standalone集群管理器;二是直接使用Kubernetes作为资源调度器,让Spark通过kubernetes调度器原生创建和销毁executor pod。前者迁移成本低,适合已有Standalone集群的团队;后者弹性更强,资源利用率更高,但需要额外维护Kubernetes环境。
在Standalone模式下,资源调度由Spark Master负责,容器只是承载Worker进程。配置重点是让每个Worker上报的核数和内存与Docker限制一致。可以在spark-env.sh中指定SPARK_WORKER_CORES和SPARK_WORKER_MEMORY,也可以通过docker环境变量注入。但Standalone模式缺少细粒度的资源隔离,多个Worker可能挤在同一宿主机上,需要额外规划。
apiVersion: v1
kind: Pod
metadata:
name: spark-worker
spec:
containers:
- name: spark-worker
image: spark:3.5.0
resources:
requests:
cpu: "2"
memory: "4Gi"
limits:
cpu: "2"
memory: "4Gi"
command: ["/opt/spark/bin/spark-class", "org.apache.spark.deploy.worker.Worker"]
args: ["spark://spark-master:7077", "--cores", "2", "--memory", "4g"]
Kubernetes模式则可以利用Pod的资源声明自动对齐Spark配置。使用spark-submit时,通过--conf spark.kubernetes.executor.request.cores和spark.kubernetes.executor.limit.cores等参数精确控制每个executor pod的CPU配额。这种模式下,资源超额分配的风险更低,而且可以利用集群自动伸缩能力。
动态资源分配与shuffle服务适配
Spark动态资源分配允许executor根据任务负载自动增减,在Docker环境下这一特性非常有用,因为容器启停速度快,能够快速回收空闲资源。但启用动态分配需要外部shuffle服务来保存executor退出后的shuffle数据,否则数据会丢失。在Standalone模式下,需要在每个Worker节点启动shuffle服务;在Kubernetes模式下,需要部署独立的shuffle service DaemonSet,或者使用Spark 3.1+支持的内置shuffle数据持久化。
以下是一个启用动态分配的spark-defaults.conf配置片段,适用于Kubernetes模式。注意shuffle服务地址需要指向正确的外部服务。
spark.dynamicAllocation.enabled=true spark.dynamicAllocation.shuffleTracking.enabled=true spark.dynamicAllocation.minExecutors=2 spark.dynamicAllocation.maxExecutors=20 spark.dynamicAllocation.initialExecutors=4 spark.kubernetes.shuffle.service.enabled=true spark.kubernetes.shuffle.namespace=spark
在Docker环境中启用动态分配时,还要考虑本地存储的持久化。executor容器退出后,如果使用emptyDir作为本地目录,shuffle数据会丢失。建议为shuffle目录挂载hostPath或者使用支持动态供应的PVC,避免频繁的磁盘写入导致节点压力过大。
常见资源调度陷阱与排查方法
第一个常见陷阱是内存超限。容器内存限制8g,但Spark executor配置的内存也是8g,再加上JVM自身开销和堆外内存,很快就会触发OOM Kill。正确做法是预留15%-20%的内存余量,比如容器限制设为10g,executor内存设为8g。第二个常见问题是CPU throttling。如果容器分配了2核,但Spark任务创建了4个并发线程,CPU使用率会超过配额,导致容器被限流,任务延迟飙升。
排查时,可以使用docker stats观察容器的内存和CPU使用情况,结合Spark UI中的executor指标判断是否达到上限。如果发现频繁GC或OOM,可以降低spark.executor.memory,增加spark.executor.memoryOverhead。对于CPU throttling,可以通过cgroup的cpu.stat文件查看被限流的次数,适当减少并行度或增加CPU配额。
最后一个提示:不要直接在容器里修改spark-defaults.conf来覆盖全局配置,建议通过spark-submit的--conf参数或环境变量传递,这样更灵活,也便于审计和版本回滚。