无状态应用

DaemonSet

主要作用是让Kubernetes集群中运行一个Daemon Pod,这个Pod有如下三个特征:

  1. 这个Pod运行在Kubernetes集群里的每一个节点(Node)上

  2. 每个节点上只有一个这样的Pod实例

  3. 当有新节点加入Kubernetes集群后,该Pod会自动地在新节点上被创建出来;而当旧节点被删除后,它上面的Pod也相应地会被回收掉

例如:

  1. 各种网络插件的Agent组件,都必须运行在每一个节点上,用来处理这个节点上的容器网络

  2. 各种存储插件的Agent组件,也必须运行在每一个节点上,用来在这个节点上挂载远程存储目录,操作容器的Volume目录

  3. 各种监控组件和日志组件,也必须运行在每一个节点上,负责这个节点上的监控信息和日志搜集

更重要的是,与其他编排对象不同,DaemonSet开始运行的时机,很多时候比整个kubernetes集群出现的时机都要早。例如,这个DaemonSet是网络插件的Agent组件,在整个kubernetes集群中还没有可用的容器网络时,所有的worker节点的状态都是NotReady。这个时候普通的Pod肯定不能运行的,所以DaemonSet要先于其他Pod运行。

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluentd-elasticsearch
  namespace: kube-system
  labels:
    k8s-app: fluentd-logging
spec:
  selector:
    matchLabels:
      name: fluentd-elasticsearch
  template:
    metadata:
      labels:
        name: fluentd-elasticsearch
    spec:
      tolerations:
      - key: node-role.kubernetes.io/master
        effect: NoSchedule
      containers:
      - name: fluentd-elasticsearch
        image: k8s.gcr.io/fluentd-elasticsearch:1.20
        resources:
          limits:
            memory: 200Mi
          requests:
            cpu: 100m
            memory: 200Mi
        volumeMounts:
        - name: varlog
          mountPath: /var/log
        - name: varlibdockercontainers
          mountPath: /var/lib/docker/containers
          readOnly: true
      terminationGracePeriodSeconds: 30
      volumes:
      - name: varlog
        hostPath:
          path: /var/log
      - name: varlibdockercontainers
        hostPath:
          path: /var/lib/docker/containers

这个DaemonSet管理一个fluented-elasticsearch镜像的Pod,功能是通过fluented将Docker容器里的日志转发到ElasticSearch。

DaemonSet与Deployment很类似,只是没有replicas字段,也是使用selector管理Pod

template中定义Pod的模板,包含一个镜像,这个镜像挂载了两个hostPath类型的Volume,分别对应宿主机的/var/log目录和/var/lib/docker/containers目录。fluented启动后,它会从这两个目录里搜集日志信息,并转发给ElasticSearch保存,这样就可以通过ElasticSearch方便地检索这些日志了。

注意,Docker容器里应用的日志,默认会保存在宿主机的/var/lib/docker/containers/{{.容器ID}}/{{.容器ID}}-json.log文件里,这个目录就是fluented搜集的目标之一。

如何保证每个Node上有且仅有一个被管理的Pod,DaemonSet Controller首先从Etcd里获取所有的Node列表,遍历所有的Node,遍历的过程中可以检查当前节点上是否有携带了对应标签的Pod在运行。检查结果有三种情况:

  1. 没有被管理的Pod,所以需要在这个节点上新建一个

  2. 有被管理的Pod,但是数量超过1,直接调用kubernetes API这个节点上删除多余的Pod

  3. 有且只有一个,整个节点很正常

第一种情况,新建Pod的时候,利用Pod API,通过nodeSelector选择Node的名字即可。新版本中nodeSelector将被弃用,使用新的nodeAffinity字段。如下例子:

apiVersion: v1
kind: Pod
metadata:
  name: with-node-affinity
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      # 这个nodeAffinity必须在被调度的时候予以考虑,同时也可以设置在某些情况下不予考虑这个nodeAffinity
        nodeSelectorTerms:
        - matchExpressions:
          - key: metadata.name
            # 这个Pod只允许运行在“metadata.name”是“node-1”的节点上
            operator: In
            values:
            - node-1

在这个Pod中,声明一个spec.affinity字段,然后定义一个nodeAffinity。其中spec.Affinity字段是Pod里跟调度相关的一个字段。nodeAffinity的定义支持丰富的语法:

  • operator:In(即,部分匹配)

  • operator:Equal(即,完全匹配)

丰富的语法,是其取代前一代的原因之一。其实大多数时候,Operator语义没啥用。所以,DaemonSet Controller会在创建Pod的时候,自动在这个Pod的API对象里加上这个nodeAffinity定义,nodeAffinity中需要绑定的节点名字,正是当前正在遍历的这个节点。

  1. DaemonSet并不修改用户提交的YAML文件里的Pod模板,而是在向kubernetes发起请求之前,直接修改根据模板生成的Pod对象

  2. DaemonSet会给这个Pod自动加上另一个与调度相关的字段的字段tolerations,这就意味着这个Pod能够容忍(toleration)某些Node上的污点(taint)。会自动加入如下字段:

apiVersion: v1
kind: Pod
metadata:
  name: with-toleration
spec:
  tolerations:
  - key: node.kubernetes.io/unschedulable
    operator: Exists
    effect: NoSchedule

这个Toleration的含义是:容忍所有被标记为unschedulable污点的节点,容忍的效果是允许调度。

在正常情况下,被标记了unschedulable污点(effect:NoSchedule)的节点,是不会有任何Pod被调度上去的。添加了容忍之后就可以忽略这个限制,这样就能保证每个节点都有一个Pod。如果这个节点存在故障,那么Pod可能会启动失败,DaemonSet则会始终尝试直到Pod启动成功

更新策略

  • RollingUpdate:一个一个的更新,先更新第一个 pod,然后老的 pod 被移除,通过健康检查之后再去创建第二个pod,这样对于业务上来说会比较平滑地升级,不会中断;

  • OnDelete:也是一个很好的更新策略,就是模板更新之后,pod不会有任何变化,需要手动控制。当删除某一个节点对应的 pod,它就会重建,不删除的话它就不会重建,这样的话对于一些需要手动控制的特殊需求也会有特别好的作用。

DaemonSet控制器

watch API Server的状态包括node的状态,这些数据都是通过API Server存储在etcd中的。

  1. 当node节点状态发生变化时,通过一个内存消息队列发出消息

  2. DaemonSet Controller会watch到这个状态,然后查看各个节点上是否都有对应的Pod

  3. 如果没有Pod就会新建

  4. 如果有Pod就会做一个对比,比较一下版本,然后进行滚动更新

  5. Ondelete的时候也会检查一下版本,判断是否更新还是创建Pod

如何比其他Pod运行的早

通过Toleration机制实现。在Kubernetes项目中,当一个节点的网络插件尚未安装时,这个节点就会被自定加上一个“污点”:node.kubernetes.io/network-unavailable。DaemonSet通过添加容忍的方式就可以跳过这个限制,从而成功的启动一个网络插件的Pod在这个节点:

...
template:
    metadata:
      labels:
        name: network-plugin-agent
    spec:
      tolerations:
      - key: node.kubernetes.io/network-unavailable
        operator: Exists
        effect: NoSchedule

这种机制正是在部署kubernetes集群的时候,能够先部署kubernetes本身,再部署网络插件的根本原因。因为网络插件本身就是一个DaemonSet。可以在Pod的模板中添加更多种类的Toleration,从而利用DaemonSet实现自己的目的。比如添加下面的容忍:

tolerations:
- key: node-role.kubernetes.io/master
  effect: NoSchedule

这样的话Pod可以被调度到主节点,默认主节点有“node-role.kubernetes.io/master”的污点,Pod是不能运行的。一般在DaemonSet上都要加上resource字段,来限制CPU和内存的使用,防止占用过多的宿主机资源。

版本管理(ControllerRevision)

ControllerRevision 其实是一个通用的版本管理对象,这样可以巧妙的避免每种控制器都要维护一套冗余的代码和逻辑。

DaemonSet也可以像Deployment那样进行版本管理:

#查看版本历史
kubectl rollout history daemonset fluentd-elasticsearch -n kube-system
daemonsets "fluentd-elasticsearch"
REVISION  CHANGE-CAUSE
1         <none>

# 更新镜像版本
kubectl set image ds/fluentd-elasticsearch fluentd-elasticsearch=k8s.gcr.io/fluentd-elasticsearch:v2.2.0 --record -n=kube-system
# 增加--record参数,升级指令会直接出现在history中

# 查看滚动更新的过程
kubectl rollout status ds/fluentd-elasticsearch -n kube-system
Waiting for daemon set "fluentd-elasticsearch" rollout to finish: 0 out of 2 new pods have been updated...
Waiting for daemon set "fluentd-elasticsearch" rollout to finish: 0 out of 2 new pods have been updated...
Waiting for daemon set "fluentd-elasticsearch" rollout to finish: 1 of 2 updated pods are available...
daemon set "fluentd-elasticsearch" successfully rolled out

有了版本号,就可以像Deployment那样进行历史版本回滚。Deployment通过每一个版本对应一个ReplicaSet来控制不同的版本,DaemonSet没有ReplicaSet,使用ControllerRevision进行控制。

ControllerRevision专门用来记录某种Controller对象的版本,Kubernetes v1.7之后添加的API对象。

查看对应的ControllerRevision:

# 获取集群中存在的ControllerRevision
kubectl get controllerrevision -n kube-system -l name=fluentd-elasticsearch
NAME                               CONTROLLER                             REVISION   AGE
fluentd-elasticsearch-64dc6799c9   daemonset.apps/fluentd-elasticsearch   2          1h

# 查看详细信息
kubectl describe controllerrevision fluentd-elasticsearch-64dc6799c9 -n kube-system
Name:         fluentd-elasticsearch-64dc6799c9
Namespace:    kube-system
Labels:       controller-revision-hash=2087235575
              name=fluentd-elasticsearch
Annotations:  deprecated.daemonset.template.generation=2
              kubernetes.io/change-cause=kubectl set image ds/fluentd-elasticsearch \
              fluentd-elasticsearch=k8s.gcr.io/fluentd-elasticsearch:v2.2.0 --record=true --namespace=kube-system
API Version:  apps/v1
Data:
  Spec:
    Template:
      $ Patch:  replace
      Metadata:
        Creation Timestamp:  <nil>
        Labels:
          Name:  fluentd-elasticsearch
      Spec:
        Containers:
          Image:              k8s.gcr.io/fluentd-elasticsearch:v2.2.0
          Image Pull Policy:  IfNotPresent
          Name:               fluentd-elasticsearch
...
Revision:                  2
Events:                    <none>

# 对DaemonSet进行版本回滚
kubectl rollout undo daemonset fluentd-elasticsearch --to-revision=1 -n kube-system
daemonset.extensions/fluentd-elasticsearch rolled back
# undo操作读取Revision=1的ControllerRevision对象保存的Data字段

注意,执行了上述undo操作后,DaemonSet的Revision并不会从2变回1,而是变成3,每一个操作都是一个新的ControllerRevision对象被创建。

ControllerRevision对象:

  • Data字段保存了该版本对象的完整的DaemonSet的API对象

  • Annotation字段保存了创建这个对象所使用的kubectl命令

Job

Deployment、StatefulSet、DaemonSet这三种编排概念,主要编排的对象是“在线业务”(即Long Running Task长作业),比如Nginx、MySQL等。这类应用一旦运行起来,除非出错或者停止,它的容器进程会一直保持在Running状态

但是,有一类作业显然不满足这个情况,就是“离线业务”(即Batch Job计算任务),这种任务在计算完成后就直接退出了,而此时如果依然用Deployment来管理这类作业,就会发现Pod计算任务结束后退出,然后Controller不断重启这个任务,向“滚动更新”这样的功能就更不需要了。

apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  template:
    spec:
      containers:
      - name: pi
        image: resouer/ubuntu-bc
        command: ["sh", "-c", "echo 'scale=10000; 4*a(1)' | bc -l "]
      restartPolicy: Never
  backoffLimit: 4

在这个yaml中包含一个Pod模板,即spec.template字段。这个Pod定义了一个计算π的容器。注意:这个Job对象并没有定义一个`spec.selector来描述要控制哪些Pod

# 创建job
kubectl create -f job.yaml

# 查看这个创建成功的job
kubectl describe jobs/pi
Name:             pi
Namespace:        default
Selector:         controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495
Labels:           controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495
                  job-name=pi
Annotations:      <none>
Parallelism:      1
Completions:      1
..
Pods Statuses:    0 Running / 1 Succeeded / 0 Failed
Pod Template:
  Labels:       controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495
                job-name=pi
  Containers:
   ...
  Volumes:              <none>
Events:
  FirstSeen    LastSeen    Count    From            SubobjectPath    Type        Reason            Message
  ---------    --------    -----    ----            -------------    --------    ------            -------
  1m           1m          1        {job-controller }                Normal      SuccessfulCreate  Created pod: pi-rq5rl

# 处于计算状态
kubectl get pods
NAME                                READY     STATUS    RESTARTS   AGE
pi-rq5rl                            1/1       Running   0          10s

# 任务结束
kubectl get pods
NAME                                READY     STATUS      RESTARTS   AGE
pi-rq5rl                            0/1       Completed   0          4m
# 这就是为什么在Job对象的模板中要定义restartPolicy=Never的原因,离线计算的任务永远不该被重启,再计算一遍毫无意义

# 查看计算结果
kubectl logs pi-rq5rl
3.141592653589793238462643383279...

# 如果计算失败
kubectl get pods
NAME                                READY     STATUS              RESTARTS   AGE
pi-55h89                            0/1       ContainerCreating   0          2s
pi-tqbcz                            0/1       Error               0          5s
# 根据restartPolicy的定义,如果为never,则会重新创建新的Pod再次计算,如果为onfailure则restart这个Pod里面的容器

通过describe可以看到,这个Job对象在创建后,它的Pod模板,被自动添加上了一个controller-uid=<一个随机字符串>这样的label。而这个Job对象本身,则被自动加上了这个Label对应的Selector,从而保证了Job与它所管理的Pod之间的匹配关系。

Job Controller使用这种携带UID的label的方式,是为了避免不同Job对象所管理的Pod发生重合。这种自动生成的Label对用户来说很不友好,所以不适合推广到Deployment等长作业编排对象上。

restartPolicy在Job对象中只能被设置为Never或者OnFailure,在Job的对象中添加spec.backoffLimit字段来定义重试的次数,默认为6次(即backoffLimit=6)。

需要注意,重新创建Pod或者重启Pod的间隔是呈指数增长的,即下一次重新创建Pod的动作会分别发生在10s、20s、40s。。。

当Job正常运行结束后,Pod处于Completed状态,如果Pod因为某种原因一直处于运行状态,则可以设置spec.activeDeadlineSeconds字段来设置最长运行时间,比如:

spec:
 backoffLimit: 5
 activeDeadlineSeconds: 100

运行超过100s这个Job的所有Pod都会终止,并且在Pod的状态里看到终止的原因是reason:DeadlineExceeded。

在Job对象中,负责并行控制的参数有两个:

  1. spec.parallelism:定义的是Job在任意时间最多可以启动多少个Pod同时运行

  2. spec.completions:定义的是Job至少完成的Pod数目,即Job最小完成数

# 添加最大并行数2,最小完成数4
apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  parallelism: 2
  completions: 4
  template:
    spec:
      containers:
      - name: pi
        image: resouer/ubuntu-bc
        command: ["sh", "-c", "echo 'scale=5000; 4*a(1)' | bc -l "]
      restartPolicy: Never
  backoffLimit: 4

kubectl create -f job.yaml

# Job维护两个状态字段,DESIRED和SUCCESSFUL
kubectl get job
NAME      DESIRED   SUCCESSFUL   AGE
pi        4         0            3s
# DESIRED就是completions定义的最小完成数

# 同时创建两个
kubectl get pods
NAME       READY     STATUS    RESTARTS   AGE
pi-5mt88   1/1       Running   0          6s
pi-gmcq5   1/1       Running   0          6s

# 当每个Pod完成计算后,进入Completed状态时,就会有一个新的Pod被创建出来,并且快速地从Pending状态进入ContainerCreating状态
kubectl get pods
NAME       READY     STATUS    RESTARTS   AGE
pi-gmcq5   0/1       Completed   0         40s
pi-84ww8   0/1       Pending   0         0s
pi-5mt88   0/1       Completed   0         41s
pi-62rbt   0/1       Pending   0         0s

kubectl get pods
NAME       READY     STATUS    RESTARTS   AGE
pi-gmcq5   0/1       Completed   0         40s
pi-84ww8   0/1       ContainerCreating   0         0s
pi-5mt88   0/1       Completed   0         41s
pi-62rbt   0/1       ContainerCreating   0         0s

# Job Controller第二次创建出来的两个并行的Pod也进入Running状态
kubectl get pods
NAME       READY     STATUS      RESTARTS   AGE
pi-5mt88   0/1       Completed   0          54s
pi-62rbt   1/1       Running     0          13s
pi-84ww8   1/1       Running     0          14s
pi-gmcq5   0/1       Completed   0          54s

# 最后所有Pod都计算完成,并进入Completed状态
kubectl get pods
NAME       READY     STATUS      RESTARTS   AGE
pi-5mt88   0/1       Completed   0          5m
pi-62rbt   0/1       Completed   0          4m
pi-84ww8   0/1       Completed   0          4m
pi-gmcq5   0/1       Completed   0          5m

# 所有Pod都成功退出,Job的SUCCESSFUL字段值为4
kubectl get job
NAME      DESIRED   SUCCESSFUL   AGE
pi        4         4            5m

Job Controller的控制对象是Pod,在控制循环中进行的协调(Reconcile)操作,是根据:

  1. 实际在Running状态Pod的数目

  2. 已经成功退出的Pod数目

  3. parallelism、completions参数的值

共同计算出在这个周期里,应该创建或者删除的Pod数目,然后调用Kubernetes API来执行这个操作。

Job Controller 实际上控制了作业执行的并行度和总共需要完成的任务数这两个重要的参数。在实际使用中,需要根据作业的特性,来决定并行度和任务数的合理取值。

Job控制器

controller会watch API Server,每次提交一个Job的YAML文件都是经过API Server写入到etcd中。Job Controller注册三个handler(Add、Update、Delete),有对应的操作的时候,通过内存级消息队列,发送到controller中。

  1. 接收到通知事件后,Controller会检查当前是否有运行的Pod

  2. 如果没有,通过Scale Up把这个Pod创建出来

  3. 如果有或者大于期望值,会执行Scale Down操作

  4. 如果Pod发生了变化,需要及时更新它的状态

  5. 同时检查是并行还是串行的Job,根据配置的并行度、串行度,及时把对应数量的Pod创建出来

  6. 最后把Job的整个状态更新到API Server中

常见的Job使用方法

外部管理器+Job模板

把Job的yaml文件定义为一个模板,然后用一个外部工具控制这些模板来生成Job,如下所示:

apiVersion: batch/v1
kind: Job
metadata:
  name: process-item-$ITEM
  labels:
    jobgroup: jobexample
spec:
  template:
    metadata:
      name: jobexample
      labels:
        jobgroup: jobexample
    spec:
      containers:
      - name: c
        image: busybox
        command: ["sh", "-c", "echo Processing item $ITEM && sleep 5"]
      restartPolicy: Never

在yaml文件中定义了$ITEM这样的变量,在控制这种Job时,只需要注意两个方面:

  1. 创建Job时替换掉$ITEM这样的变量

  2. 所有来自同一个模板的Job,都有一个jobgroup:jobexample标签,这一组Job使用这样一个相同的标识

# 通过shell来替换`$ITEM`变量
mkdir ./jobs
for i in apple banana cherry
do
  cat job-tmpl.yaml | sed "s/\$ITEM/$i/" > ./jobs/job-$i.yaml
done

# 这样一组yaml文件就生成了,通过create就能执行任务
kubectl create -f ./jobs
kubectl get pods -l jobgroup=jobexample
NAME                        READY     STATUS      RESTARTS   AGE
process-item-apple-kixwv    0/1       Completed   0          4m
process-item-banana-wrsf7   0/1       Completed   0          4m
process-item-cherry-dnfu9   0/1       Completed   0          4m

通过这种方式很方便的管理Job作业,只需要类似与for循环这样的外部工具,TensorFlow的KubeFlow就是这样实现的。在这种模式下使用Job对象,completions和parallelism这两个字段都应该使用默认值1,而不需要自行设置,作业的并行控制应该交给外部工具来管理(如KubeFlow)。

拥有固定任务数目的并行Job

这种模式下,只关心最后是否拥有指定数目(spec.completions)个任务成功退出。至于执行的并行度是多少并不关心。可以使用工作队列(Work Queue)进行任务分发,job的yaml定义如下:

apiVersion: batch/v1
kind: Job
metadata:
  name: job-wq-1
spec:
  completions: 8
  parallelism: 2
  template:
    metadata:
      name: job-wq-1
    spec:
      containers:
      - name: c
        image: myrepo/job-wq-1
        env:
        - name: BROKER_URL
          value: amqp://guest:guest@rabbitmq-service:5672
        - name: QUEUE
          value: job1
      restartPolicy: OnFailure

在yaml中总共定义了总共有8个任务会被放入工作队列,可以使用RabbitMQ充当工作队列,所以在Pod 的模板中定义BROKER_URL作为消费者。Pod中的执行逻辑如下:

/* job-wq-1 的伪代码 */
queue := newQueue($BROKER_URL, $QUEUE)
task := queue.Pop()
process(task)
exit

创建这个job后,每组两个Pod,一共八个,每个Pod都会连接BROKER_URL,从RabbitMQ里读取任务,然后各自处理。每个Pod只要将任务信息读取并完成计算,用户只关心总共有8个任务计算完成并退出,就认为整个job计算完成,对应的就是“任务总数固定”的场景。

指定并行度,但不设定completions

此时,需要自己想办法决定什么时候启动新的Pod,什么时候Job才算完成。这种情况下,任务的总数未知,所以需要工作队列来分发任务,并且判断队列是否为空(即任务已经完成)。Job的定义如下,只是不设置completions的值:

apiVersion: batch/v1
kind: Job
metadata:
  name: job-wq-2
spec:
  parallelism: 2
  template:
    metadata:
      name: job-wq-2
    spec:
      containers:
      - name: c
        image: gcr.io/myproject/job-wq-2
        env:
        - name: BROKER_URL
          value: amqp://guest:guest@rabbitmq-service:5672
        - name: QUEUE
          value: job2
      restartPolicy: OnFailure

Pod 的执行逻辑如下:

/* job-wq-2 的伪代码 */
for !queue.IsEmpty($BROKER_URL, $QUEUE) {
  task := queue.Pop()
  process(task)
}
print("Queue empty, exiting")
exit

由于任务数目的总数不固定,所以每一个Pod必须能够知道,自己什么时候可以退出。比如队列为空,所以这种用法对应的是“任务总数不固定”的场景。在实际使用中,需要处理的条件非常复杂,任务完成后的输出,每个任务Pod之间是不是有资源的竞争和协同等。

CronJob

定时任务,API对象如下:

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: hello
spec:
  schedule: "*/1 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: hello
            image: busybox
            args:
            - /bin/sh
            - -c
            - date; echo Hello from the Kubernetes cluster
          restartPolicy: OnFailure

在这个yaml文件中,最重要的是jobTemplateCronJob是一个Job对象的控制器。它创建和删除Job的依据是schedule字段定义的、一个标准UNIX Cron格式的表达式。

Cron表达式中的五个部分分别代表:分钟、小时、日、月、星期。CronJob对象会记录每次Job执行的时间。

由于定时任务的特殊性,很可能某个Job还没有执行完成,另外一个新job就产生了,这时候可以通过spec.concurrencyPolicy字段来定义具体的处理策略,如:

  1. concurrencyPolicy=Allow,默认的情况,这些Job可以同时存在

  2. concurrencyPolicy=Forbid,不会创建新的Pod,该创建周期被跳过

  3. concurrencyPolicy=Replace,新产生的Job会替换旧的,没有执行完的Job

如果某一次Job创建失败,就会被标记为“miss”。当在指定的时间窗口(通过字段spec.startingDeadlineSeconds字段指定,单位为秒)内,miss数目达到100时,那个Cronjob会停止再创建这个Job。

最后更新于

这有帮助吗?