Flink/Spark on K8s 实战

一、Native 简介

现在,Docker、Kubernetes等容器技术已发展为一项通用技术。

Kubernetes 的核心难点无外乎这几个方面:

  • 容器内抓包定位网络问题
  • 容器进程主动退出、只能运行一个参数
  • JVM 参数在容器中突然失效

说白了, Kubernetes 的核心理念并不复杂,但涉及的维度的确很多。比如,微服务架构理念、分布式原理、网络、存储等各个层级的知识体系都会覆盖,非运维出身理解起来会比较困难。

以 Flink 和 Spark 为代表的分布式流批计算框架的下层资源管理平台逐渐从 Hadoop 生态的 YARN 转向 Kubernetes生态的K8s原生scheduler以及周边资源调度器,比如 Volcano 和 Yunikorn 等。

这篇文章简单比较一下两种计算框架在 Native Kubernetes 的支持和实现上的异同,以及对于应用到生产环境我们还需要做些什么。

这里的 native 其实就是计算框架直接向 Kubernetes 申请资源。比如很多跑在 YARN 上面的计算框架,需要自己实现一个 AppMaster 来想 YARN 的 ResourceManager 来申请资源。Native K8s 相当于计算框架自己实现一个类似 AppMaster 的角色向 k8s 去申请资源,当然和 AppMaster 还是有差异的 (AppMaster 需要按 YARN 的标准进行实现)。

二、Spark on K8s 

1、提交作业

向 k8s 集群提交作业和往 YARN 上面提交很类似,命令如下,主要区别包括:

  • --master 参数指定 k8s 集群的 ApiServer
  • 需要通过参数 spark.kubernetes.container.image 指定在 k8s 运行作业的 image,
  • 指定 main jar,需要 driver 进程可访问:如果 driver 运行在 pod 中,jar 包需要包含在镜像中;如果 driver 运行在本地,那么 jar 需要在本地。
  • 通过 --name 或者 spark.app.name 指定 app 的名字,作业运行起来之后的 driver 命名会以 app 名字为前缀。当然也可以通过参数 spark.kubernetes.driver.pod.name 直接指定 dirver 的名字
$ ./bin/spark-submit \
    --master k8s://https://: \
    --deploy-mode cluster \
    --name spark-pi \
    --class org.apache.spark.examples.SparkPi \
    --conf spark.executor.instances=5 \
    --conf spark.kubernetes.container.image= \
    local:///path/to/examples.jar

提交完该命令之后,spark-submit 会创建一个 driver pod 和一个对应的 servcie,然后由 driver 创建 executor pod 并运行作业。

2、deploy-mode

和在 YARN 上面使用 Spark 一样,在 k8s 上面也支持 cluster 和 client 两种模式:

  • cluster mode: driver 在 k8s 集群上面以 pod 形式运行。
  • client mode: driver 运行在提交作业的地方,然后 driver 在 k8s 集群上面创建 executor。为了保证 executor 能够注册到 driver 上面,还需要提交作业的机器可以和 k8s 集群内部的 executor 网络连通(executor 可以访问到 driver,需要注册)。

3、资源清理

这里的资源指的主要是作业的 driver 和 executor pod。spark 通过 k8s 的 onwer reference 机制将作业的各种资源连接起来,这样当 driver pod 被删除的时候,关联的 executor pod 也会被连带删除。但是如果没有 driver pod,也就是以 client 模式运行作业的话,如下两种情况涉及到资源清理:

  • 作业运行完成,driver 进程退出,executor pod 运行完自动退出
  • driver 进程被杀掉,executor pod 连不上 driver 也会自行退出

详情,可参考:
https://kubernetes.io/docs/concepts/architecture/garbage-collection/

4、依赖管理

前面说到 main jar 包需要在 driver 进程可以访问到的地方,如果是 cluster 模式就需要将 main jar 打包到 spark 镜像中。但是在日常开发和调试中,每次重新 build 一个镜像的 effort 实在是太大了。spark 支持提交的时候使用本地的文件,然后使用 s3 等作为中转:先上传上去,然后作业运行的时候再从 s3 上面下载下来。

下面是一个实例:

...
--packages org.apache.hadoop:hadoop-aws:3.2.0
--conf spark.kubernetes.file.upload.path=s3a:///path
--conf spark.hadoop.fs.s3a.access.key=...
--conf spark.hadoop.fs.s3a.impl=org.apache.hadoop.fs.s3a.S3AFileSystem
--conf spark.hadoop.fs.s3a.fast.upload=true
--conf spark.hadoop.fs.s3a.secret.key=....
--conf spark.driver.extraJavaOptions=-Divy.cache.dir=/tmp -Divy.home=/tmp
file:///full/path/to/app.jar

5、Pod Template

k8s 的 controller (比如 Deployment,Job)创建 Pod 的时候根据 spec 中的 pod template 来创建。下面是一个 Job 的示例。

apiVersion: batch/v1
kind: Job
metadata:
  name: hello
spec:
  template:
    # 下面的是一个 pod template
    spec:
      containers:
      - name: hello
        image: busybox
        command: ['sh', '-c', 'echo "Hello, Kubernetes!" && sleep 3600']
      restartPolicy: OnFailure
    # The pod template ends here

由于我们通过 spark-submit 提交 spark 作业的时候,最终的 k8s 资源(driver/executor pod)是由 spark 内部逻辑构建出来的。但是有的时候我们想要在 driver/executor pod 上做一些额外的工作,比如增加 sidecar 容器做一些日志收集的工作。这种场景下 PodTemplate 就是一个比较好的选择,同时 PodTemplate 也将 spark 和底层基础设施(k8s)解耦开。比如 k8s 发布新版本支持一些新的特性,那么我们只要修改我们的 PodTemplate 即可,而不涉及到 spark 的内部改动。

6、RBAC

RBAC 全称是 Role-based access control,是 k8s 中的一套权限控制机制。通俗来说:

  • RBAC 中包含了一系列的权限设置,比如 create/delete/watch/list pod 等,这些权限集合的实体叫 Role 或者 ClusterRole
  • 同时 RBAC 还包含了角色绑定关系(Role Binding),用于将 Role/ClusterRole 赋予一个或者一组用户,比如 Service Account 或者 UserAccount

为了将 Spark 作业在 k8s 集群中运行起来,我们还需要一套 RBAC 资源:

  • 指定 namespace 下的 serviceaccount
  • 定义了权限规则的 Role 或者 ClusterRole,我们可以使用常见的 ClusterRole "edit"(对几乎所有资源具有操作权限,比如 create/delete/watch 等)
  • 绑定关系

下面命令在 spark namespace 下为 serviceaccount spark 赋予了操作同 namespace 下其他资源的权限,那么只要 spark 的 driver pod 挂载了该 serviceaccount,它就可以创建 executor pod 了。

$ kubectl create serviceaccount spark
$ kubectl create clusterrolebinding spark-role --clusterrole=edit --serviceaccount=spark:spark --namespace=spark

下面做一个简单的演示:

通过如下命令提交作业 SparkPiSleep 到 k8s 集群中。

$ spark-submit --master k8s://https://: --deploy-mode cluster --class org.apache.spark.examples.SparkPiSleep --conf spark.executor.memory=2g --conf spark.driver.memory=2g --conf spark.driver.core=1 --conf spark.app.name=test12 --conf spark.kubernetes.submission.waitAppCompletion=false --conf spark.executor.core=1 --conf spark.kubernetes.container.image= --conf spark.eventLog.enabled=false --conf spark.shuffle.service.enabled=false --conf spark.executor.instances=1 --conf spark.dynamicAllocation.enabled=false --conf spark.kubernetes.namespace=spark --conf spark.kubernetes.authenticate.driver.serviceAccountName=spark --conf spark.executor.core=1  local:///path/to/main/jar

查看 k8s 集群中的资源:

$ kubectl get po -n spark
NAME                               READY   STATUS              RESTARTS   AGE
spark-pi-5b88a27b576050dd-exec-1   0/1     ContainerCreating   0          2s
test12-9fd3c27b576039ae-driver     1/1     Running             0          8s

其中第一个就是 executor pod,第二个是 driver 的 pod。除此之外还创建了一个 service,可以通过该 service 访问到 driver pod,比如 Spark UI 都可以这样访问到。

$ kubectl get svc -n spark
NAME                                 TYPE           CLUSTER-IP     EXTERNAL-IP     PORT(S)                                       AGE
test12-9fd3c27b576039ae-driver-svc   ClusterIP      None                     7078/TCP,7079/TCP,4040/TCP                    110s

下面再看一下 service owner reference,executor pod 也是类似的。

$ kubectl get svc test12-9fd3c27b576039ae-driver-svc -n spark -oyaml
apiVersion: v1
kind: Service
metadata:
  creationTimestamp: "2021-08-18T03:48:50Z"
  name: test12-9fd3c27b576039ae-driver-svc
  namespace: spark
  # service 的 ownerReference 指向了 driver pod,只要 driver pod 被删除,该 service 也会被删除
  ownerReferences:
  - apiVersion: v1
    controller: true
    kind: Pod
    name: test12-9fd3c27b576039ae-driver
    uid: 56a50a66-68b5-42a0-b2f6-9a9443665d95
  resourceVersion: "9975441"
  uid: 06c1349f-be52-4133-80d9-07af34419b1f

三、Flink on K8s

Flink作为新一代的大数据处理引擎,不仅是业内公认的最好的流处理引擎,而且具备机器学习等多种强大计算功能,用户只需根据业务逻辑开发一套代码,无论是全量数据还是增量数据,亦或者实时处理,一套方案即可全部解决。K8S是业内最流行的容器编排工具,与docker容器技术结合,可以提供比Yarn与Mesos更强大的集群资源管理功能,成为容器云的主要解决方案之一。如果能将两者结合,无疑是双剑合璧,对生产效能有着巨大的提升。

Flink on Kubernetes 的架构如图所示:

Flink/Spark on K8s 实战_第1张图片

1、Flink集群架构

如下图所示,Flink集群中一个 JobManger 和若干个TaskManager。由 Client 提交任务给 JobManager,JobManager再调度任务到各个 TaskManager 去执行,然后 TaskManager 将心跳和统计信息汇报给 JobManager。TaskManager 之间以流的形式进行数据的传输。上述三者均为独立的JVM进程。

Flink/Spark on K8s 实战_第2张图片

Client是提交Job的客户端,可以是运行在任何机器上(与JobManager 环境连通即可),也可以运行在容器中。提交Job后,Client可以结束进程(Streaming的任务),也可以不结束并等待结果返回。

JobManager主要负责调度Job并协调Task做checkpoint。从Client处接收到 Job 和 JAR 包等资源后,会生成优化后的执行计划,并以Task粒度调度到各个TaskManager上去执行。

TaskManager在启动的时候就设置好了槽位数(Slot),每个slot能启动一个Task,Task为线程。从JobManager处接收需要部署的Task,部署启动后,与自己的上游建立 Netty 连接,接收数据并处理。

可以看到Flink的任务调度是多线程模型,并且不同Job/Task混合在一个 TaskManager 进程中。

目前在K8S中执行Flink任务的方式有两种,一种是Standalone,一种是原生模式。

2、Standalone模式

Flink 任务在 Kubernetes 上运行的步骤有:

Flink/Spark on K8s 实战_第3张图片

  • 首先往 Kubernetes 集群提交了资源描述文件后,会启动 Master 和 Worker 的 container。
  • Master Container 中会启动 Flink Master Process,包含 Flink-Container ResourceManager、JobManager 和 Program Runner。
  • Worker Container 会启动 TaskManager,并向负责资源管理的 ResourceManager 进行注册,注册完成之后,由 JobManager 将具体的任务分给 Container,再由 Container 去执行。
  • 需要说明的是,Master Container 与Worker Container是用一个镜像启动的,只是启动参数不一样,如下图所示,两个deployment文件的image都是flink:latest。

Flink/Spark on K8s 实战_第4张图片

计算任务可以以Session模式与Per-Job模式运行提交:

  • Session模式:先启动一个Flink集群,然后向该集群提交任务,所有任务共用JobManager。任务提交速度快,适合频繁提交运行的短时间任务。
  • Per-Job模式:每提交一个任务,单独启动一个集群运行该任务,运行结束集群被删除,资源也被释放。任务启动较慢,适合于长时间运行的大型任务。

1. Session 模式

在Session模式下,需要先启动一个Flink集群,然后向该集群提交任务,主要步骤为:先将集群配置定义为ConfigMap、然后通过官方资源描述文件分别启动JobManager与一定数量的TaskManager,最后在flink客户端向这个启动的Flink集群中提交任务。

1)定义ConfigMap

对于 JobManager 和 TaskManager 运行过程中需要的一些配置文件,如:flink-conf.yaml、hdfs-site.xml、core-site.xml,可以通过flink-configuration-configmap.yaml文件将它们定义为 ConfigMap 来实现配置的传递和读取。如果使用默认配置,这一步则不需要。

kubectl create -f flink-configuration-configmap.yaml

2)启动JobManager

JobManager 的执行过程分为两步:

  • 首先,JobManager 通过 Deployment 进行描述,保证 1 个副本的 Container 运行 JobManager,可以定义一个标签,例如 flink-jobmanager。
kubectl create -f jobmanager-deployment.yaml
  • 其次,还需要定义一个JobManager Service,通过 service name 和 port 暴露 JobManager 服务,通过标签选择对应的 pods。
kubectl create -f jobmanager-service.yaml

3)启动TaskManager

TaskManager 也是通过 Deployment 来进行描述,保证 n 个副本的 Container 运行 TaskManager,同时也需要定义一个标签,例如 flink-taskmanager。

kubectl create -f taskmanager-deployment.yaml

4)提交任务

提交服务是通过请求JobManager Service实现的,如果从K8S集群外部请求该Service,需要对外暴露端口

kubectl port-forward service/flink-jobmanager 8081:8081

然后通过flink命令的m参数,指定服务的地址,即可向刚创建的集群中提交任务了。

./bin/flink run -d -m localhost:8081 ./examples/streaming/TopSpeedWindowing.jar

5)删除集群

直接利用K8S的命令行工具或者API删除前面创建的资源对象即可

kubectl delete -f jobmanager-deployment.yaml
kubectl delete -f taskmanager-deployment.yaml
kubectl delete -f jobmanager-service.yaml
kubectl delete -f flink-configuration-configmap.yaml

Flink on Kubernetes–交互原理:

Flink/Spark on K8s 实战_第5张图片

整个交互的流程比较简单,用户往 Kubernetes 集群提交定义好的资源描述文件即可,例如 deployment、configmap、service 等描述。后续的事情就交给 Kubernetes 集群自动完成。Kubernetes 集群会按照定义好的描述来启动 pod,运行用户程序。各个组件的具体工作如下:

  • Service: 通过标签(label selector)找到 job manager 的 pod 暴露服务。
  • Deployment:保证 n 个副本的 container 运行 JM/TM,应用升级策略。
  • ConfigMap:在每个 pod 上通过挂载 /etc/flink 目录,包含 flink-conf.yaml 内容。

2. Per-Job模式

在官方的Per Job模式下,需要先将用户代码都打到镜像里面,然后根据该镜像来部署一个flink集群运行用户代码,即Flink job cluster。所以主要分为两步:创建镜像与部署Flink job cluster。

1)创建镜像

在flink/flink-container/docker目录下有一个build.sh脚本,可以根据指定版本的基础镜像去构建你的job镜像,成功后会输出 “Successfully tagged topspeed:latest” 的提示。

sh build.sh --from-release --flink-version 1.7.0 --hadoop-version 2.8 --scala-version 2.11 --job-jar ~/flink/flink-1.7.1/examples/streaming/TopSpeedWindowing.jar --image-name topspeed

镜像构建完成后,可以上传到 hub.docker.com 上,也可以上传到你们项目组的内部Registry。

docker tag topspeed zkb555/topspeedwindowing 
docker push zkb555/topspeedwindowing

2)部署Flink job cluster

在镜像上传之后,可以根据该镜像部署Flink job cluster。

# 启动Servive
kubectl create -f job-cluster-service.yaml 
# 启动JobManager
FLINK_IMAGE_NAME=zkb555/topspeedwindowing:latest FLINK_JOB=org.apache.flink.streaming.examples.windowing.TopSpeedWindowing FLINK_JOB_PARALLELISM=3 envsubst < job-cluster-job.yaml.template | kubectl create -f – 
# 启动TaskManager
FLINK_IMAGE_NAME=zkb555/topspeedwindowing:latest FLINK_JOB_PARALLELISM=4 envsubst < task-manager-deployment.yaml.template | kubectl create -f -

参数说明:

  1. FLINK_DOCKER_IMAGE_NAME - 镜像名称(默认:flink-job:latest)
  2. FLINK_JOB - 要执行的Flink任务名称(默认:none)
  3. DEFAULT_PARALLELISM - Flink任务的默认并行度 (默认: 1)
  4. FLINK_JOB_ARGUMENTS - 其他任务参数;
  5. SAVEPOINT_OPTIONS - Savepoint选项 (default: none)

这种方式比较笨重,如果业务逻辑的变动涉及代码的修改,都需要重新生成镜像,非常麻烦,在生产环境提交一个新任务重新打镜像是不切实际的。一种更好的替代方案是将你的业务代码放到NFS或者HDFS上,然后在启动容器时通过挂载或者将jar包下载到容器内的方式执行你的Flink代码,代码位置通过启动参数传入。

需要注意的是Standalone模式需要在任务启动时就确定TaskManager的数量,暂且不能像Yarn一样,可以在任务启动时申请动态资源。然而很多时候任务需要多少个TaskManager事先并不知道,TaskManager设置少了,任务可能跑不起来,多了又会造成资源浪费,需要在任务启动时才能确定需要多少个TaskMananger,为了支持任务启动时实时动态申请资源的功能,就有了下面介绍的原生模式, 这意味着Flink任务可以直接向K8s集群申请资源。

3、原始模式

原生模式提供了与K8S更好的集成,在Flink 1.9以上版本内置了K8S的客户端,Flink的可以直接向K8S申请计算资源,集群资源得到了更高效的利用。这点与同Flink on Yarn/Mesos一样。

做好以下准备工作就可以从你的flink客户端直接提交flink任务到K8S集群。

  • KubeConfig, 位于 ~/.kube/config,需要具备查看、创建与删除pod与service对象的权限,可以在K8S客户端通过 kubectl auth can-i pods来验证;
  • Kubernetes开启DNS服务;
  • 一个Kubernetes账户,需要具备创建与删除pod的权限。

原生模式同样支持Session模式玉Per-job两种方式提交任务

1. 原生Session模式

与Standalone模式中的Session模式类似,还是分为两步,先启动一个集群,然后向集群提交任务。可以通过运行kubernetes-session.sh文件来启动一个集群:

./bin/kubernetes-session.sh

或者通过一些超参数来对集群进行设置:

./bin/kubernetes-session.sh \
  -Dkubernetes.cluster-id= \
  -Dtaskmanager.memory.process.size=4096m \
  -Dkubernetes.taskmanager.cpu=2 \
  -Dtaskmanager.numberOfTaskSlots=4 \
  -Dresourcemanager.taskmanager-timeout=3600000

然后在flink客户端,通过flink命令提交任务:

./bin/flink run -d -e kubernetes-session -Dkubernetes.cluster-id= examples/streaming/WindowJoin.jar

原生Session cluster的创建流程为:

  • Flink客户端先通过K8S的ApiServer提交cluster的描述信息,包括ConfigMap spec, Job Manager Service spec, Job Manager Deployment spec等;
  • K8S接收到这些信息后就会拉取镜像、挂载卷轴来启动Flink master,这时候Dispatcher 与KubernetesResourceManager也会被启动,从而可以接受Flink job;

Flink/Spark on K8s 实战_第6张图片

  • 当用户通过Flink客户端提交一个job时,客户端就会生成这个job的job graph,并与这个job的jar包一起提交到Dispatcher,然后就会生成这个job的JobMaster;
  • 最后JobMaster会向KubernetesResourceManager申请slot来执行这个job graph,如果集群中slot数量不够,KubernetesResourceManager会启动新的TaskManager pod并将它注册到集群中。

2. 原生Per-Job模式

目前尚处于实验阶段,在Flink 1.11版本中才支持。

官方的使用方式也是与前面Standalone-Per-Cluster模式类似,先创建一个包含用户jar的用于启动Flink Master的docker image,然后在客户端通过flink命令根据该image提交任务,从而创建一个运行该任务的独立集群。

./bin/flink run -d -e kubernetes-per-job 
  -Dkubernetes.cluster-id=
  -Dtaskmanager.container.image=
  -Dtaskmanager.memory.process.size=4096m \
  -Dkubernetes.taskmanager.cpu=2 \
  -Dtaskmanager.numberOfTaskSlots=4 \
  -Dresourcemanager.taskmanager-timeout=3600000
  -Dkubernetes.container-start-command-template="%java% %classpath% %jvmmem% %jvmopts% %logging% %class% %args%"

Per-Job模式的运行过程与Session模式的不同点在于Flink Master的启动,其他步骤都一样。Flink Master Deployment里面已经有Flink任务的jar包,在启动Flink Master时Cluster Entrypoint就会运行该jar包的main函数产生job graph,并将该job graph与jar包提交给Dispatcher。

Flink/Spark on K8s 实战_第7张图片

当然这种方式的缺点与Standalone-Per-Cluster一样,每个用户jar都需要一个单独的镜像,实际还是建议将用户jar放在外部,在运行时挂载或者下载到容器中。 

总结:

如果式以频繁提交的短期任务,如批处理为主,则适合Session模式,如果以长期运行的流式任务为主,则适合用Per-Job模式。

四、Flink on K8s 实现

1、Standalone Session On K8s 实现

1. 实现步骤 

  1. 使用 Kubectl 或者 K8s 的 Dashboard 提交请求到 K8s Master。
  2. K8s Master 将创建 Flink Master Deployment、TaskManager Deployment、ConfigMap、SVC 的请求分发给 Slave 去创建这四个角色,创建完成后,这时 Flink Master、TaskManager 启动。
  3. TaskManager 注册到 JobManager。在非 HA 的情况下,是通过内部 Service 注册到 JobManager。 至此,Flink 的 Sesion Cluster 已经创建起来。此时就可以提交任务。
  4. 在 Flink Cluster 上提交 Flink run 的命令,通过指定 Flink Master 的地址,将相应任务提交上来,用户的 Jar 和 JobGrapth 会在 Flink Client 生成,通过 SVC 传给 Dispatcher。
  5. Dispatcher 会发现有一个新的 Job 提交上来,这时会起一个新的 JobMaster,去运行这个 Job。
  6. JobMaster 会向 ResourceManager 申请资源,因为 Standalone 方式并不具备主动申请资源的能力,所以这个时候会直接返回,而且我们已经提前把 TaskManager 起好,并且已经注册回来了。
  7. 这时 JobMaster 会把 Task 部署到相应的 TaskManager 上,整个任务运行的过程就完成了。

2. 创建配置

以下使用的镜像都是最新版本的镜像:

#configmap 针对flink配置文件格式化(端口、日志、jvm项)
flink-configuration-configmap.yaml

#rest-service配置项
jobmanager-rest-service.yaml

#jobmanager的svc、后端代理、http端口暴露
jobmanager-service.yaml

#master与job的session部署,一master多job
jobmanager-session-deployment.yaml
taskmanager-session-deployment.yaml

3. 配置文件yaml

[root@km01 flink]# cat flink-configuration-configmap.yaml

apiVersion: v1
kind: ConfigMap
metadata:
  name: flink-config
  labels:
    app: flink
data:
  flink-conf.yaml: |+
    jobmanager.rpc.address: flink-jobmanager
    taskmanager.numberOfTaskSlots: 2
    blob.server.port: 6124
    jobmanager.rpc.port: 6123
    taskmanager.rpc.port: 6122
    jobmanager.heap.size: 1024m
    taskmanager.memory.process.size: 1024m
    #jobmanager.archive.fs.dir: 192.168.1.31:/data/nfs-share/flink
  log4j.properties: |+
    log4j.rootLogger=INFO, file
    log4j.logger.akka=INFO
    log4j.logger.org.apache.kafka=INFO
    log4j.logger.org.apache.hadoop=INFO
    log4j.logger.org.apache.zookeeper=INFO
    log4j.appender.console=org.apache.log4j.ConsoleAppender
    log4j.appender.console.layout=org.apache.log4j.PatternLayout
    log4j.appender.console.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p %-60c %x - %m%n
    log4j.appender.file=org.apache.log4j.FileAppender
    log4j.appender.file.file=${log.file}
    log4j.appender.file.layout=org.apache.log4j.PatternLayout
    log4j.appender.file.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p %-60c %x - %m%n
    log4j.logger.org.apache.flink.shaded.akka.org.jboss.netty.channel.DefaultChannelPipeline=ERROR, file
[root@km01 flink]# cat jobmanager-rest-service.yaml

apiVersion: v1
kind: Service
metadata:
  name: flink-jobmanager-rest
spec:
  type: NodePort
  ports:
  - name: rest
    port: 8081
    targetPort: 8081
  selector:
    app: flink
    component: jobmanager
[root@km01 flink]# cat jobmanager-service.yaml 
apiVersion: v1
kind: Service
metadata:
  name: flink-jobmanager
spec:
  type: ClusterIP
  ports:
  - name: rpc
    port: 6123
  - name: blob-server
    port: 6124
  - name: webui
    port: 8081
  selector:
    app: flink
    component: jobmanager
[root@km01 flink]# cat jobmanager-session-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: flink-jobmanager
spec:
  replicas: 1
  selector:
    matchLabels:
      app: flink
      component: jobmanager
  template:
    metadata:
      labels:
        app: flink
        component: jobmanager
    spec:
      containers:
      - name: jobmanager
        image: hub.deri.org.cn/library/flink:latest
        workingDir: /opt/flink
        command: ["/bin/bash", "-c", "$FLINK_HOME/bin/jobmanager.sh start;\
          while :;
          do
            if [[ -f $(find log -name '*jobmanager*.log' -print -quit) ]];
              then tail -f -n +1 log/*jobmanager*.log;
            fi;
          done"]
        ports:
        - containerPort: 6123
          name: rpc
        - containerPort: 6124
          name: blob
        - containerPort: 8081
          name: ui
        livenessProbe:
          tcpSocket:
            port: 6123
          initialDelaySeconds: 30
          periodSeconds: 60
        volumeMounts:
        - name: flink-config-volume
          mountPath: /opt/flink/conf
        securityContext:
          runAsUser: 9999  # refers to user _flink_ from official flink image, change if necessary
      volumes:
      - name: flink-config-volume
        configMap:
          name: flink-config
          items:
          - key: flink-conf.yaml
            path: flink-conf.yaml
          - key: log4j.properties
            path: log4j.properties
[root@km01 flink]# cat taskmanager-session-deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: flink-taskmanager
spec:
  replicas: 4
  selector:
    matchLabels:
      app: flink
      component: taskmanager
  template:
    metadata:
      labels:
        app: flink
        component: taskmanager
    spec:
      containers:
      - name: taskmanager
        image: hub.deri.org.cn/library/flink:latest
        workingDir: /opt/flink
        command: ["/bin/bash", "-c", "$FLINK_HOME/bin/taskmanager.sh start; \
          while :;
          do
            if [[ -f $(find log -name '*taskmanager*.log' -print -quit) ]];
              then tail -f -n +1 log/*taskmanager*.log;
            fi;
          done"]
        ports:
        - containerPort: 6122
          name: rpc
        livenessProbe:
          tcpSocket:
            port: 6122
          initialDelaySeconds: 30
          periodSeconds: 60
        volumeMounts:
        - name: flink-config-volume
          mountPath: /opt/flink/conf/
        securityContext:
          runAsUser: 9999  # refers to user _flink_ from official flink image, change if necessary
      volumes:
      - name: flink-config-volume
        configMap:
          name: flink-config
          items:
          - key: flink-conf.yaml
            path: flink-conf.yaml
          - key: log4j.properties
            path: log4j.properties

4. 部署配置

kubectl apply -f .

Flink/Spark on K8s 实战_第8张图片

一个job多个task: 

查看svc: 

访问:

http://node-ip:30976

2、Flink 的 Native K8s 实现

Flink 的 Native K8s 实现:

  • Flink Client 创建 JobManager 的 Deployment,然后将 Deployment 托管给 k8s
  • k8s 的 Deployment Controller 创建 JobManager 的 Pod
  • JobManager 内的 ResourceManager 负责先 Kubernetes Scheduler 请求资源并创建 TaskManager 等相关资源并创建相关的 TaskManager Pod 并开始运行作业
  • 当作业运行到终态之后所有相关的 k8s 资源都被清理掉

代码(基于分支 release-1.13)实现主要如下:

  • CliFrontend 作为 Flink Client 的入口根据命令行参数 run-application 判断通过方法 runApplication 去创建 ApplicationCluster
  • KubernetesClusterDescriptor 通过方法 deployApplicationCluster 创建 JobManager 相关的 Deployment 和一些必要的资源
  • JobManager 的实现类 JobMaster 通过 ResourceManager 调用类 KubernetesResourceManagerDriver 中的方法 requestResource 创建 TaskManager 等资源

其中:KubernetesClusterDescriptor 实现自 interface ClusterDescriptor ,用来描述对 Flink 集群的操作。

根据底层的资源使用不同, ClusterDescriptor 有不同的实现,包括 KubernetesClusterDescriptor、YarnClusterDescriptor、StandaloneClusterDescriptor。

public interface ClusterDescriptor extends AutoCloseable {
    /* Returns a String containing details about the cluster (NodeManagers, available memory, ...). */
    String getClusterDescription();
    /* 查询已存在的 Flink 集群. */
    ClusterClientProvider retrieve(T clusterId) throws ClusterRetrieveException;
    /** 创建 Flink Session 集群 */
    ClusterClientProvider deploySessionCluster(ClusterSpecification clusterSpecification)
            throws ClusterDeploymentException;
    /** 创建 Flink Application 集群 **/
    ClusterClientProvider deployApplicationCluster(
            final ClusterSpecification clusterSpecification,
            final ApplicationConfiguration applicationConfiguration)
            throws ClusterDeploymentException;
    /** 创建 Per-job 集群 **/
    ClusterClientProvider deployJobCluster(
            final ClusterSpecification clusterSpecification,
            final JobGraph jobGraph,
            final boolean detached)
            throws ClusterDeploymentException;
    /** 删除集群 **/
    void killCluster(T clusterId) throws FlinkException;
    @Override
    void close();
}

下面简单看一下KubernetesClusterDescriptor 的核心逻辑:创建 Application 集群。

public class KubernetesClusterDescriptor implements ClusterDescriptor {
    private final Configuration flinkConfig;
  
    // 内置 k8s client
    private final FlinkKubeClient client;
    private final String clusterId;
  
    @Override
    public ClusterClientProvider deployApplicationCluster(
            final ClusterSpecification clusterSpecification,
            final ApplicationConfiguration applicationConfiguration)
            throws ClusterDeploymentException {
        // 查询 flink 集群在 k8s 中是否存在
        if (client.getRestService(clusterId).isPresent()) {
            throw new ClusterDeploymentException(
                    "The Flink cluster " + clusterId + " already exists.");
        }
        final KubernetesDeploymentTarget deploymentTarget =
                KubernetesDeploymentTarget.fromConfig(flinkConfig);
        if (KubernetesDeploymentTarget.APPLICATION != deploymentTarget) {
            throw new ClusterDeploymentException(
                    "Couldn't deploy Kubernetes Application Cluster."
                            + " Expected deployment.target="
                            + KubernetesDeploymentTarget.APPLICATION.getName()
                            + " but actual one was \""
                            + deploymentTarget
                            + "\"");
        }
        
        // 设置 application 参数:$internal.application.program-args 和 $internal.application.main
        applicationConfiguration.applyToConfiguration(flinkConfig);
      
        // 创建集群
        final ClusterClientProvider clusterClientProvider =
                deployClusterInternal(
                        KubernetesApplicationClusterEntrypoint.class.getName(),
                        clusterSpecification,
                        false);
        try (ClusterClient clusterClient = clusterClientProvider.getClusterClient()) {
            LOG.info(
                    "Create flink application cluster {} successfully, JobManager Web Interface: {}",
                    clusterId,
                    clusterClient.getWebInterfaceURL());
        }
        return clusterClientProvider;
    }
  
    // 创建集群逻辑
    private ClusterClientProvider deployClusterInternal(
            String entryPoint, ClusterSpecification clusterSpecification, boolean detached)
            throws ClusterDeploymentException {
        final ClusterEntrypoint.ExecutionMode executionMode =
                detached
                        ? ClusterEntrypoint.ExecutionMode.DETACHED
                        : ClusterEntrypoint.ExecutionMode.NORMAL;
        flinkConfig.setString(
                ClusterEntrypoint.INTERNAL_CLUSTER_EXECUTION_MODE, executionMode.toString());
        flinkConfig.setString(KubernetesConfigOptionsInternal.ENTRY_POINT_CLASS, entryPoint);
        // Rpc, blob, rest, taskManagerRpc ports need to be exposed, so update them to fixed values.
        // 将端口指定为固定值,方便 k8s 的资源构建。因为 pod 的隔离性,所以没有端口冲突
        KubernetesUtils.checkAndUpdatePortConfigOption(
                flinkConfig, BlobServerOptions.PORT, Constants.BLOB_SERVER_PORT);
        KubernetesUtils.checkAndUpdatePortConfigOption(
                flinkConfig, TaskManagerOptions.RPC_PORT, Constants.TASK_MANAGER_RPC_PORT);
        KubernetesUtils.checkAndUpdatePortConfigOption(
                flinkConfig, RestOptions.BIND_PORT, Constants.REST_PORT);
        // HA 配置
        if (HighAvailabilityMode.isHighAvailabilityModeActivated(flinkConfig)) {
            flinkConfig.setString(HighAvailabilityOptions.HA_CLUSTER_ID, clusterId);
            KubernetesUtils.checkAndUpdatePortConfigOption(
                    flinkConfig,
                    HighAvailabilityOptions.HA_JOB_MANAGER_PORT_RANGE,
                    flinkConfig.get(JobManagerOptions.PORT));
        }
        try {
            final KubernetesJobManagerParameters kubernetesJobManagerParameters =
                    new KubernetesJobManagerParameters(flinkConfig, clusterSpecification);
            // 补充 PodTemplate 逻辑
            final FlinkPod podTemplate =
                    kubernetesJobManagerParameters
                            .getPodTemplateFilePath()
                            .map(
                                    file ->
                                            KubernetesUtils.loadPodFromTemplateFile(
                                                    client, file, Constants.MAIN_CONTAINER_NAME))
                            .orElse(new FlinkPod.Builder().build());
            final KubernetesJobManagerSpecification kubernetesJobManagerSpec =
                    KubernetesJobManagerFactory.buildKubernetesJobManagerSpecification(
                            podTemplate, kubernetesJobManagerParameters);
          
            // 核心逻辑:在 k8s 中创建包括 JobManager Deployment 在内 k8s 资源,比如 Service 和 ConfigMap
            client.createJobManagerComponent(kubernetesJobManagerSpec);
            return createClusterClientProvider(clusterId);
        } catch (Exception e) {
            //...
        }
    }
}

上面代码中需要说的在构建 JobManager 的时候补充 PodTemplate。简单来说 PodTemplate 就是一个 Pod 文件。

第三步的 TaskManager 创建就不再赘述了。

五、Spark On K8s 实现

Spark on Kubernetes 的实现比较简单:

  • Spark Client 创建一个 k8s pod 运行 driver
  • driver 创建 executor pod,然后开始运行作业
  • 作业运行结束之后 driver pod 进入到 Completed 状态,executor pod 会被清理掉。作业结束之后通过 driver pod 我们还是可以查看 driver pod 的。

代码实现:

Spark 的 native k8s 实现代码在
resource-managers/kubernetes module 中。我们可以从 SparkSubmit 的代码开始分析。我们主要看一下 deploy-mode 为 cluster 模式的代码逻辑。

// Set the cluster manager
    val clusterManager: Int = args.master match {
      case "yarn" => YARN
      case m if m.startsWith("spark") => STANDALONE
      case m if m.startsWith("mesos") => MESOS
      case m if m.startsWith("k8s") => KUBERNETES
      case m if m.startsWith("local") => LOCAL
      case _ =>
        error("Master must either be yarn or start with spark, mesos, k8s, or local")
        -1
    }

首先根据 spark.master 配置中 scheme 来判断是不是 on k8s。我们上面也看到这个配置的形式为 --master k8s://https://: 。如果是 on k8s 的 cluster 模式,则去加载 Class
org.apache.spark.deploy.k8s.submit.KubernetesClientApplication,并运行其中的 start 方法。childArgs 方法的核心逻辑简单来说就是根据 spark-submit 提交的参数构造出 driver pod 提交到 k8s 运行。

private[spark] class KubernetesClientApplication extends SparkApplication {
  override def start(args: Array[String], conf: SparkConf): Unit = {
    val parsedArguments = ClientArguments.fromCommandLineArgs(args)
    run(parsedArguments, conf)
  }
  private def run(clientArguments: ClientArguments, sparkConf: SparkConf): Unit = {
    // For constructing the app ID, we can't use the Spark application name, as the app ID is going
    // to be added as a label to group resources belonging to the same application. Label values are
    // considerably restrictive, e.g. must be no longer than 63 characters in length. So we generate
    // a unique app ID (captured by spark.app.id) in the format below.
    val kubernetesAppId = KubernetesConf.getKubernetesAppId()
    val kubernetesConf = KubernetesConf.createDriverConf(
      sparkConf,
      kubernetesAppId,
      clientArguments.mainAppResource,
      clientArguments.mainClass,
      clientArguments.driverArgs,
      clientArguments.proxyUser)
    // The master URL has been checked for validity already in SparkSubmit.
    // We just need to get rid of the "k8s://" prefix here.
    val master = KubernetesUtils.parseMasterUrl(sparkConf.get("spark.master"))
    val watcher = new LoggingPodStatusWatcherImpl(kubernetesConf)
    Utils.tryWithResource(SparkKubernetesClientFactory.createKubernetesClient(
      master,
      Some(kubernetesConf.namespace),
      KUBERNETES_AUTH_SUBMISSION_CONF_PREFIX,
      SparkKubernetesClientFactory.ClientType.Submission,
      sparkConf,
      None,
      None)) { kubernetesClient =>
        val client = new Client(
          kubernetesConf,
          new KubernetesDriverBuilder(),
          kubernetesClient,
          watcher)
        client.run()
    }
  }
}

上面的代码的核心就是最后创建 Client 并运行。这个 Client 是 Spark 封装出来的 Client,内置了 k8s client。

private[spark] class Client(
    conf: KubernetesDriverConf,
    builder: KubernetesDriverBuilder,
    kubernetesClient: KubernetesClient,
    watcher: LoggingPodStatusWatcher) extends Logging {
  def run(): Unit = {
    // 构造 Driver 的 Pod
    val resolvedDriverSpec = builder.buildFromFeatures(conf, kubernetesClient)
    val configMapName = KubernetesClientUtils.configMapNameDriver
    val confFilesMap = KubernetesClientUtils.buildSparkConfDirFilesMap(configMapName,
      conf.sparkConf, resolvedDriverSpec.systemProperties)
    val configMap = KubernetesClientUtils.buildConfigMap(configMapName, confFilesMap)
    // 修改 Pod 的 container spec:增加 SPARK_CONF_DIR
    val resolvedDriverContainer = new ContainerBuilder(resolvedDriverSpec.pod.container)
      .addNewEnv()
        .withName(ENV_SPARK_CONF_DIR)
        .withValue(SPARK_CONF_DIR_INTERNAL)
        .endEnv()
      .addNewVolumeMount()
        .withName(SPARK_CONF_VOLUME_DRIVER)
        .withMountPath(SPARK_CONF_DIR_INTERNAL)
        .endVolumeMount()
      .build()
    val resolvedDriverPod = new PodBuilder(resolvedDriverSpec.pod.pod)
      .editSpec()
        .addToContainers(resolvedDriverContainer)
        .addNewVolume()
          .withName(SPARK_CONF_VOLUME_DRIVER)
          .withNewConfigMap()
            .withItems(KubernetesClientUtils.buildKeyToPathObjects(confFilesMap).asJava)
            .withName(configMapName)
            .endConfigMap()
          .endVolume()
        .endSpec()
      .build()
    val driverPodName = resolvedDriverPod.getMetadata.getName
    var watch: Watch = null
    var createdDriverPod: Pod = null
    try {
      // 通过 k8s client 创建 Driver Pod
      createdDriverPod = kubernetesClient.pods().create(resolvedDriverPod)
    } catch {
      case NonFatal(e) =>
        logError("Please check \"kubectl auth can-i create pod\" first. It should be yes.")
        throw e
    }
    try {
      // 创建其他资源,修改 owner reference 等
      val otherKubernetesResources = resolvedDriverSpec.driverKubernetesResources ++ Seq(configMap)
      addOwnerReference(createdDriverPod, otherKubernetesResources)
      kubernetesClient.resourceList(otherKubernetesResources: _*).createOrReplace()
    } catch {
      case NonFatal(e) =>
        kubernetesClient.pods().delete(createdDriverPod)
        throw e
    }
    val sId = Seq(conf.namespace, driverPodName).mkString(":")
    // watch pod
    breakable {
      while (true) {
        val podWithName = kubernetesClient
          .pods()
          .withName(driverPodName)
        // Reset resource to old before we start the watch, this is important for race conditions
        watcher.reset()
        watch = podWithName.watch(watcher)
        // Send the latest pod state we know to the watcher to make sure we didn't miss anything
        watcher.eventReceived(Action.MODIFIED, podWithName.get())
        // Break the while loop if the pod is completed or we don't want to wait
        // 根据参数 "spark.kubernetes.submission.waitAppCompletion" 判断是否需要退出
        if(watcher.watchOrStop(sId)) {
          watch.close()
          break
        }
      }
    }
  }
}

下面再简单介绍一下 Driver 如何管理 Executor 的流程。当 Spark Driver 运行 main 函数时,会创建一个 SparkSession,SparkSession 中包含了 SparkContext,SparkContext 需要创建一个 SchedulerBackend 会管理 Executor 的生命周期。对应到 k8s 上的 SchedulerBackend 其实就是
KubernetesClusterSchedulerBackend,下面主要看一下这个 backend 是如何创建出来的。大胆猜想一下,大概率也是根据 spark.master 的 url 的 scheme "k8s" 创建的。

下面是 SparkContext 创建 SchedulerBackend 的核心代码逻辑:

private def createTaskScheduler(...) = {
  case masterUrl =>
    // 创建出 KubernetesClusterManager
    val cm = getClusterManager(masterUrl) match {
      case Some(clusterMgr) => clusterMgr
      case None => throw new SparkException("Could not parse Master URL: '" + master + "'")
    }
    try {
      val scheduler = cm.createTaskScheduler(sc, masterUrl)
      // 上面创建出来的 KubernetesClusterManager 这里会创建出 KubernetesClusterSchedulerBackend
      val backend = cm.createSchedulerBackend(sc, masterUrl, scheduler)
      cm.initialize(scheduler, backend)
      (backend, scheduler)
    } catch {
      case se: SparkException => throw se
      case NonFatal(e) =>
        throw new SparkException("External scheduler cannot be instantiated", e)
    }
}
// 方法 getClsuterManager 会通过 ServiceLoader 加载所有实现 ExternalClusterManager 的 ClusterManager (KubernetesClusterManager 和 YarnClusterManager),然后通过 master url 进行 filter,选出 KubernetesClusterManager
private def getClusterManager(url: String): Option[ExternalClusterManager] = {
  val loader = Utils.getContextOrSparkClassLoader
  val serviceLoaders =
    ServiceLoader.load(classOf[ExternalClusterManager], loader).asScala.filter(_.canCreate(url))
  if (serviceLoaders.size > 1) {
    throw new SparkException(
      s"Multiple external cluster managers registered for the url $url: $serviceLoaders")
  }
  serviceLoaders.headOption
}

后面就是KubernetesClusterSchedulerBackend 管理 Executor 的逻辑了。

Flink/Spark on K8s 实战_第9张图片

可以简单看一下创建 Executor 的代码逻辑。

private def requestNewExecutors(
      expected: Int,
      running: Int,
      applicationId: String,
      resourceProfileId: Int,
      pvcsInUse: Seq[String]): Unit = {
    val numExecutorsToAllocate = math.min(expected - running, podAllocationSize)
    logInfo(s"Going to request $numExecutorsToAllocate executors from Kubernetes for " +
      s"ResourceProfile Id: $resourceProfileId, target: $expected running: $running.")
    // Check reusable PVCs for this executor allocation batch
    val reusablePVCs = getReusablePVCs(applicationId, pvcsInUse)
    for ( _ <- 0 until numExecutorsToAllocate) {
      val newExecutorId = EXECUTOR_ID_COUNTER.incrementAndGet()
      val executorConf = KubernetesConf.createExecutorConf(
        conf,
        newExecutorId.toString,
        applicationId,
        driverPod,
        resourceProfileId)
      // 构造 Executor 的 Pod Spec
      val resolvedExecutorSpec = executorBuilder.buildFromFeatures(executorConf, secMgr,
        kubernetesClient, rpIdToResourceProfile(resourceProfileId))
      val executorPod = resolvedExecutorSpec.pod
      val podWithAttachedContainer = new PodBuilder(executorPod.pod)
        .editOrNewSpec()
        .addToContainers(executorPod.container)
        .endSpec()
        .build()
      val resources = replacePVCsIfNeeded(
        podWithAttachedContainer, resolvedExecutorSpec.executorKubernetesResources, reusablePVCs)
      // 创建 Executor Pod
      val createdExecutorPod = kubernetesClient.pods().create(podWithAttachedContainer)
      try {
        // 增加 owner reference
        addOwnerReference(createdExecutorPod, resources)
        resources
          .filter(_.getKind == "PersistentVolumeClaim")
          .foreach { resource =>
            if (conf.get(KUBERNETES_DRIVER_OWN_PVC) && driverPod.nonEmpty) {
              addOwnerReference(driverPod.get, Seq(resource))
            }
            val pvc = resource.asInstanceOf[PersistentVolumeClaim]
            logInfo(s"Trying to create PersistentVolumeClaim ${pvc.getMetadata.getName} with " +
              s"StorageClass ${pvc.getSpec.getStorageClassName}")
            kubernetesClient.persistentVolumeClaims().create(pvc)
          }
        newlyCreatedExecutors(newExecutorId) = (resourceProfileId, clock.getTimeMillis())
        logDebug(s"Requested executor with id $newExecutorId from Kubernetes.")
      } catch {
        case NonFatal(e) =>
          kubernetesClient.pods().delete(createdExecutorPod)
          throw e
      }
    }
  }

 六、 trouble-shooting 

下面主要讨论在生产环境上面用来做 trouble-shooting 的两个功能:日志和监控。

1、日志

日志收集对于线上系统是非常重要的一环,毫不夸张地说,80% 的故障都可以通过日志查到原因。但是前面也说过,Flink 作业在作业运行到终态之后会清理掉所有资源,Spark 作业运行完只会保留 Driver Pod 的日志,那么我们如何收集到完整的作业日志呢?

有几种方案可供选择:

  • DaemonSet。每个 k8s 的 node 上面以 DaemonSet 形式部署日志收集 agent,对 node 上面运行的所有容器日志进行统一收集,并存储到类似 ElasticSearch 的统一日志搜索平台。
  • SideCar。使用 Flink/Spark 提供的 PodTemplate 功能在主容器侧配置一个 SideCar 容器用来进行日志收集,最后存储到统一的日志服务里面。

这两种方式都有一个前提是有其他的日志服务提供存储、甚至搜索的功能,比如 ELK,或者各大云厂商的日志服务。

除此之外还有一种简易的方式可以考虑:利用 log4j 的扩展机制,自定义 log appender,在 appender 中定制化 append 逻辑,将日志直接收集并存储到 remote storage,比如 hdfs,对象存储等。这种方案需要将自定义的 log appender 的 jar 包放到运行作业的 ClassPath 下,而且这种方式有可能会影响作业主流程的运行效率,对性能比较敏感的作业并不太建议使用这种方式。

2、监控

目前 Prometheus 已经成为 k8s 生态的监控事实标准,下面我们的讨论也是讨论如何将 Flink/Spark 的作业的指标对接到 Prometheus。下面先看一下 Prometheus 的架构。

Flink/Spark on K8s 实战_第10张图片

其中的核心在于 Prometheus Servier 收集指标的方式是 pull 还是 push:

  • 对于常驻的进程,比如在线服务,一般由 Prometheus Server 主动去进程暴露出来的 api pull 指标。
  • 对于会结束的进程指标收集,比如 batch 作业,一般使用进程主动 push 的方式。详细流程是进程将指标 push 到常驻的 PushGateway,然后 Prometheus Server 去 PushGateway pull 指标。

上面两种使用方式也是 Prometheus 官方建议的使用方式,但是看完描述不难发现其实第一种场景也可以使用第二种处理方式。只不过第二种方式由于 PushGateway 是常驻的,对其稳定性要求会比较高。

1)Flink

Flink 同时提供了 PrometheusReporter (将指标通过 api 暴露,由 Prometheus Server 来主动 pull 数据) 和
PrometheusPushGatewayReporter (将指标主动 push 给 PushGateway,Prometheus Server 不需要感知 Flink 作业)。

这两种方式中
PrometheusPushGatewayReporter 会更简单一点,但是 PushGateway 可能会成为瓶颈。如果使用 PrometheusReporter 的方式,需要引入服务发现机制帮助 Prometheus Server 自动发现运行的 Flink 作业的 Endpoint。Prometheus 目前支持的主流的服务发现机制主要有:

  • 基于 Consul。Consul 是基于 etcd 的一套完整的服务注册与发现解决方案,要使用这种方式,我们需要 Flink 对接 Consul。比如我们在提交作业的时候,将作业对应的 Service 进行捕获并写入 Consul。
  • 基于文件。文件也就是 Prometheus 的配置文件,里面配置需要拉取 target 的 endpoint。文件这种方式本来是比较鸡肋的,因为它需要 Prometheus Server 和 Flink 作业同时都可以访问,但是需要文件是 local 的。但是在 k8s 环境中,基于文件反而变的比较简单,我们可以将 ConfigMap 挂载到 Prometheus Server 的 Pod 上面,Flink 作业修改 ConfigMap 就可以了。
  • 基于 Kubernetes 的服务发现机制。Kubernetes 的服务发现机制简单来说就是 label select。可以参考 https://prometheus.io/docs/prometheus/latest/configuration/configuration/#kubernetes_sd_config

关于 Prometheus 支持的更多服务发现机制,可以参考:
https://prometheus.io/docs/prometheus/latest/configuration/configuration/ ,简单罗列包括:

  • azure
  • consul
  • digitalocean
  • docker
  • dockerswarm
  • dns
  • ec2
  • eureka
  • file
  • gce
  • hetzner
  • http
  • kubernetes
  • ...

2)Spark

以批计算为代表的 Spark 使用 PushGateway 的方式来对接 Prometheus 是比较好的方式,但是 Spark 官方并没有提供对 PushGateway 的支持,只支持了 Prometheus 的 Exporter,需要 Prometheus Server 主动去 pull 数据。

这里推荐使用基于 Kubernetes 的服务发现机制。

需要注意的是 Prometheus Server 拉取指标是按固定时间间隔进行拉取的,对于持续时间比较短的批作业,有可能存在还没有拉取指标,作业就结束的情况。

七、K8s实现的缺陷与不足

虽然 Spark 和 Flink 都实现了 native k8s 的模式,具体实现略有差异。但是在实际使用上发现两者的实现在某些场景下还是略有缺陷的。

1、Spark

pod 不具有容错性:spark-submit 会先构建一个 k8s 的 driver pod,然后由 driver pod 启动 executor 的 pod。但是在 k8s 环境中并不太建议直接构建 pod 资源,因为 pod 不具有容错性,pod 所在节点挂了之后 pod 就挂了。熟悉 k8s scheduler 的同学应该知道 pod 有一个字段叫 podName,scheduler 的核心是为 pod 填充这个字段,也就是为 pod 选择一个合适的 node。一旦调度完成之后 pod 的该字段就固定下来了。这也是 pod 不具有 node 容错的原因。

2、Flink

Deployment 语义。 Deployment 可以认为是 ReplicaSet 的增强版,而 ReplicaSet 的官方定义如下。

A ReplicaSet's purpose is to maintain a stable set of replica Pods running at any given time. As such, it is often used to guarantee the availability of a specified number of identical Pods.

简单来说,ReplicaSet 的目的是保证几个相同的 Pod 副本可以不间断的运行,说是为了线上服务量身定制的也不为过(线上服务最好是无状态且支持原地重启,比如 WebService)。但是尽管 Flink 以流式作业为主,但是我们并不能简单地将流式作业等同于无状态的 WebService。

比如 Flink 作业的 Main Jar 如果写的有问题,会导致 JobManager 的 Pod 一直启动失败,但是由于是 Deployment 语义的问题会不断被重启。这个可能是 ByDesign 的,但是感觉并不太好。

Batch 作业处理:由于 Flink 作业运行完所有资源包括 Deployment 都会被清理掉,拿不到最终的作业状态,不知道成功有否(流作业的话停止就可以认为是失败了)。对于这个问题可以利用 Flink 本身的归档功能,将结果归档到外部的文件系统(兼容 s3 协议,比如阿里云对象存储 oss)中。涉及到的配置如下:

  • s3.access-key
  • s3.secret-key
  • s3.region
  • s3.endpoint
  • jobmanager.archive.fs.dir

如果不想引入外部系统的话,需要改造 Flink 代码在作业运行完成之后将数据写到 k8s 的 api object 中,比如 ConfigMap 或者 Secret。

作业日志:Spark 作业运行结束之后 Executor Pod 被清理掉,Driver Pod 被保留,我们可以通过它查看到 Driver 的日志。Flink 作业结束之后就什么日志都查看不到了。

总结:

YARN 的时代已经过去了,以后 on k8s scheduler 将成为大数据计算以及 AI 框架的标配。但是 k8s scheduler 这种天生为在线服务设计的调度器在吞吐上面有很大的不足,并不是很契合大数据作业。

k8s 社区的批调度器 kube-batch,以及基于 kube-batch 衍生出来的 Volcano 调度器,基于 YARN 的调度算法实现的 k8s 生态调度器 Yunikorn 也逐渐在大数据 on k8s 场景下崭露头角,不过这些都是后话了。

你可能感兴趣的:(Hadoop,Hive,Spark,大数据安全,flink,spark,大数据)