现在,Docker、Kubernetes等容器技术已发展为一项通用技术。
Kubernetes 的核心难点无外乎这几个方面:
说白了, 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 的标准进行实现)。
向 k8s 集群提交作业和往 YARN 上面提交很类似,命令如下,主要区别包括:
$ ./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 并运行作业。
和在 YARN 上面使用 Spark 一样,在 k8s 上面也支持 cluster 和 client 两种模式:
这里的资源指的主要是作业的 driver 和 executor pod。spark 通过 k8s 的 onwer reference 机制将作业的各种资源连接起来,这样当 driver pod 被删除的时候,关联的 executor pod 也会被连带删除。但是如果没有 driver pod,也就是以 client 模式运行作业的话,如下两种情况涉及到资源清理:
详情,可参考:
https://kubernetes.io/docs/concepts/architecture/garbage-collection/
前面说到 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
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 的内部改动。
RBAC 全称是 Role-based access control,是 k8s 中的一套权限控制机制。通俗来说:
为了将 Spark 作业在 k8s 集群中运行起来,我们还需要一套 RBAC 资源:
下面命令在 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作为新一代的大数据处理引擎,不仅是业内公认的最好的流处理引擎,而且具备机器学习等多种强大计算功能,用户只需根据业务逻辑开发一套代码,无论是全量数据还是增量数据,亦或者实时处理,一套方案即可全部解决。K8S是业内最流行的容器编排工具,与docker容器技术结合,可以提供比Yarn与Mesos更强大的集群资源管理功能,成为容器云的主要解决方案之一。如果能将两者结合,无疑是双剑合璧,对生产效能有着巨大的提升。
Flink on Kubernetes 的架构如图所示:
如下图所示,Flink集群中一个 JobManger 和若干个TaskManager。由 Client 提交任务给 JobManager,JobManager再调度任务到各个 TaskManager 去执行,然后 TaskManager 将心跳和统计信息汇报给 JobManager。TaskManager 之间以流的形式进行数据的传输。上述三者均为独立的JVM进程。
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,一种是原生模式。
Flink 任务在 Kubernetes 上运行的步骤有:
计算任务可以以Session模式与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 的执行过程分为两步:
kubectl create -f jobmanager-deployment.yaml
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–交互原理:
整个交互的流程比较简单,用户往 Kubernetes 集群提交定义好的资源描述文件即可,例如 deployment、configmap、service 等描述。后续的事情就交给 Kubernetes 集群自动完成。Kubernetes 集群会按照定义好的描述来启动 pod,运行用户程序。各个组件的具体工作如下:
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 -
参数说明:
FLINK_DOCKER_IMAGE_NAME
- 镜像名称(默认:flink-job:latest
)FLINK_JOB
- 要执行的Flink任务名称(默认:none)DEFAULT_PARALLELISM
- Flink任务的默认并行度 (默认: 1)FLINK_JOB_ARGUMENTS
- 其他任务参数;SAVEPOINT_OPTIONS
- Savepoint选项 (default: none)这种方式比较笨重,如果业务逻辑的变动涉及代码的修改,都需要重新生成镜像,非常麻烦,在生产环境提交一个新任务重新打镜像是不切实际的。一种更好的替代方案是将你的业务代码放到NFS或者HDFS上,然后在启动容器时通过挂载或者将jar包下载到容器内的方式执行你的Flink代码,代码位置通过启动参数传入。
需要注意的是Standalone模式需要在任务启动时就确定TaskManager的数量,暂且不能像Yarn一样,可以在任务启动时申请动态资源。然而很多时候任务需要多少个TaskManager事先并不知道,TaskManager设置少了,任务可能跑不起来,多了又会造成资源浪费,需要在任务启动时才能确定需要多少个TaskMananger,为了支持任务启动时实时动态申请资源的功能,就有了下面介绍的原生模式, 这意味着Flink任务可以直接向K8s集群申请资源。
原生模式提供了与K8S更好的集成,在Flink 1.9以上版本内置了K8S的客户端,Flink的可以直接向K8S申请计算资源,集群资源得到了更高效的利用。这点与同Flink on Yarn/Mesos一样。
做好以下准备工作就可以从你的flink客户端直接提交flink任务到K8S集群。
~/.kube/config
,需要具备查看、创建与删除pod与service对象的权限,可以在K8S客户端通过 kubectl auth can-i pods
来验证;原生模式同样支持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的创建流程为:
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。
当然这种方式的缺点与Standalone-Per-Cluster一样,每个用户jar都需要一个单独的镜像,实际还是建议将用户jar放在外部,在运行时挂载或者下载到容器中。
总结:
如果式以频繁提交的短期任务,如批处理为主,则适合Session模式,如果以长期运行的流式任务为主,则适合用Per-Job模式。
1. 实现步骤
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 .
一个job多个task:
查看svc:
访问:
http://node-ip:30976
Flink 的 Native K8s 实现:
代码(基于分支 release-1.13)实现主要如下:
其中: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 Kubernetes 的实现比较简单:
代码实现:
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 的逻辑了。
可以简单看一下创建 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 的两个功能:日志和监控。
日志收集对于线上系统是非常重要的一环,毫不夸张地说,80% 的故障都可以通过日志查到原因。但是前面也说过,Flink 作业在作业运行到终态之后会清理掉所有资源,Spark 作业运行完只会保留 Driver Pod 的日志,那么我们如何收集到完整的作业日志呢?
有几种方案可供选择:
这两种方式都有一个前提是有其他的日志服务提供存储、甚至搜索的功能,比如 ELK,或者各大云厂商的日志服务。
除此之外还有一种简易的方式可以考虑:利用 log4j 的扩展机制,自定义 log appender,在 appender 中定制化 append 逻辑,将日志直接收集并存储到 remote storage,比如 hdfs,对象存储等。这种方案需要将自定义的 log appender 的 jar 包放到运行作业的 ClassPath 下,而且这种方式有可能会影响作业主流程的运行效率,对性能比较敏感的作业并不太建议使用这种方式。
目前 Prometheus 已经成为 k8s 生态的监控事实标准,下面我们的讨论也是讨论如何将 Flink/Spark 的作业的指标对接到 Prometheus。下面先看一下 Prometheus 的架构。
其中的核心在于 Prometheus Servier 收集指标的方式是 pull 还是 push:
上面两种使用方式也是 Prometheus 官方建议的使用方式,但是看完描述不难发现其实第一种场景也可以使用第二种处理方式。只不过第二种方式由于 PushGateway 是常驻的,对其稳定性要求会比较高。
1)Flink
Flink 同时提供了 PrometheusReporter (将指标通过 api 暴露,由 Prometheus Server 来主动 pull 数据) 和
PrometheusPushGatewayReporter (将指标主动 push 给 PushGateway,Prometheus Server 不需要感知 Flink 作业)。
这两种方式中
PrometheusPushGatewayReporter 会更简单一点,但是 PushGateway 可能会成为瓶颈。如果使用 PrometheusReporter 的方式,需要引入服务发现机制帮助 Prometheus Server 自动发现运行的 Flink 作业的 Endpoint。Prometheus 目前支持的主流的服务发现机制主要有:
关于 Prometheus 支持的更多服务发现机制,可以参考:
https://prometheus.io/docs/prometheus/latest/configuration/configuration/ ,简单罗列包括:
2)Spark
以批计算为代表的 Spark 使用 PushGateway 的方式来对接 Prometheus 是比较好的方式,但是 Spark 官方并没有提供对 PushGateway 的支持,只支持了 Prometheus 的 Exporter,需要 Prometheus Server 主动去 pull 数据。
这里推荐使用基于 Kubernetes 的服务发现机制。
需要注意的是 Prometheus Server 拉取指标是按固定时间间隔进行拉取的,对于持续时间比较短的批作业,有可能存在还没有拉取指标,作业就结束的情况。
虽然 Spark 和 Flink 都实现了 native k8s 的模式,具体实现略有差异。但是在实际使用上发现两者的实现在某些场景下还是略有缺陷的。
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 容错的原因。
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)中。涉及到的配置如下:
如果不想引入外部系统的话,需要改造 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 场景下崭露头角,不过这些都是后话了。