Controller

Pod 是 Kubernetes 项目里最核心的编排对象,而编排功能则是由 Controller(kube-controller-manager) 完成。Pod 和 Controller 之间通过 label 标签建立关系。

Deployment 是控制器的一种,控制器要么就是创建、更新一些 Pod(或者其他的 API 对象、资源),要么就是删除一些已经存在的 Pod(或者其他的 API 对象、资源)。

Deployment

控制器,是用一种对象来管理另一种对象。

Deployment yaml

控制器对象本身,负责定义被管理对象的期望状态。比如,Deployment 里的 replicas=2 这个字段。而被控制对象的定义,则来自于一个“模板”。比如,Deployment 里的 template 字段,所有被这个 Deployment 管理的 Pod 实例,都是根据这个 template 字段的内容创建出来的。

Deployment、ReplicaSet 和 Pod

Deployment 的设计思想是应用版本和 ReplicaSet 一一对应。Deployment 实际上是一个两层控制器,Deployment 控制 ReplicaSet(版本),ReplicaSet 控制 Pod(副本数)。

Deployment 所管理的 Pod,互相之间没有顺序,也无所谓运行在哪台宿主机上。需要的时候,Deployment 就可以通过 Pod 模板创建新的 Pod;不需要的时候,Deployment 就可以“杀掉”任意一个 Pod。

Deployment 典型应用场景是:Web服务。

StatefulSet

尤其是分布式应用,它的多个实例之间,往往有依赖关系,比如:主从关系、主备关系。还有就是数据存储类应用,它的多个实例,往往都会在本地磁盘上保存一份数据。而这些实例一旦被杀掉,即便重建出来,实例与数据之间的对应关系也已经丢失,从而导致应用启动失败。所以,这种实例之间有不对等关系,以及实例对外部数据有依赖关系的应用,就被称为“有状态应用”(Stateful Application)。

StatefulSet 可以认为是对 Deployment 的改良。StatefulSet 这个控制器的主要作用之一,就是使用 Pod 模板创建 Pod 的时候,对它们进行编号,编号与 Pod 的名字和 hostname 等标识信息绑定上,并且按照编号顺序逐一完成创建工作。当需要新建或者删除 Pod 进行“调谐”的时候,它会严格按照这些 Pod 编号的顺序,逐一完成这些操作。

Service 是如何被访问的?

第一种方式,是以 Service 的 VIP(Virtual IP,即:虚拟 IP)方式。比如:当我访问 10.0.23.1 这个 Service 的 IP 地址时,10.0.23.1 其实就是一个 VIP,它会把请求转发到该 Service 所代理的某一个 Pod 上。

第二种方式,就是以 Service 的 DNS 方式。比如:这时候,只要我访问“my-svc.my-namespace.svc.cluster.local”这条 DNS 记录,就可以访问到名叫 my-svc 的 Service 所代理的某一个 Pod。

在第二种 Service DNS 的方式下,具体还可以分为两种处理方法:

第一种处理方法,是 Normal Service。这种情况下,你访问“my-svc.my-namespace.svc.cluster.local”解析到的,正是 my-svc 这个 Service 的 VIP,后面的流程就跟 VIP 方式一致了。

第二种处理方法,是 Headless Service。这种情况下,访问“my-svc.my-namespace.svc.cluster.local”解析到的,直接就是 my-svc 代理的某一个 Pod 的 IP 地址。

这样的设计有什么作用呢?

拓扑状态的维持

因为<pod-name>.<svc-name>.<namespace>.svc.cluster.local这样的DNS 记录,是 Kubernetes 项目为 Pod 分配的唯一的“可解析身份”(Resolvable Identity)。

Pod每次重建,它的IP都会发生变化,如果通过IP来定位Pod,拓扑状态就不稳定了。

应用的多个实例分别绑定了不同的存储数据,一对一绑定,即使 Pod 被重新创建,也会按照之前的编号顺序和绑定关系重新创建出新的 Pod,虽然新的 Pod 的集群ip发生了变化,由于这些 Pod 名字里的编号不变,那么 Service 里类似于 web-0.nginx.default.svc.cluster.local 这样的 DNS 记录也就不会变,网络标识和数据的绑定关系与之前都是相同的。

例子

apiVersion: v1
kind: Service
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  ports:
  - port: 80
    name: web
  clusterIP: None
  selector:
    app: nginx

clusterIP 字段的值为None的就是 Headless Service。这个 Service 被创建后并不会被分配一个 VIP,而是会以 DNS 记录的方式暴露出它所代理的 Pod。也是用 Label Selector 机制选择出来的,所有携带了 app=nginx 标签的 Pod,都会被这个 Service 代理起来。创建的 Headless Service 会使它所代理的所有 Pod 的 IP 地址再绑定一个这样格式的 DNS 记录<pod-name>.<svc-name>.<namespace>.svc.cluster.local

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web
spec:
  serviceName: "nginx"
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.9.1
        ports:
        - containerPort: 80
          name: web

和 deployment 的 yaml 唯一区别,就是多了一个 serviceName=nginx 字段。这个字段的作用,就是告诉 StatefulSet 控制器,在执行控制循环(Control Loop)的时候,请使用 nginx 这个 Headless Service 来保证 Pod 的“可解析身份”。

StatefulSet 典型应用场景是:按顺序启动的主从关系,或者一个数据库应用的多个存储实例。

存储状态的维持

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web
spec:
  serviceName: "nginx"
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.9.1
        ports:
        - containerPort: 80
          name: web
        volumeMounts:
        - name: www
          mountPath: /usr/share/nginx/html
  volumeClaimTemplates:
  - metadata:
      name: www
    spec:
      accessModes:
      - ReadWriteOnce
      resources:
        requests:
          storage: 1Gi
---
kind: PersistentVolume
apiVersion: v1
metadata:
  name: pv-volume
  labels:
    type: local
spec:
  capacity:
    storage: 10Gi
  rbd:
    monitors:
    - '10.16.154.78:6789'
    - '10.16.154.82:6789'
    - '10.16.154.83:6789'
    pool: kube
    image: foo
    fsType: ext4
    readOnly: true
    user: admin
    keyring: /etc/ceph/keyring
    imageformat: "2"
    imagefeatures: "layering"

PVC 的定义,就来自于 volumeClaimTemplates 这个模板字段,这个 PVC 的名字,会被分配一个与这个 Pod 完全一致的编号。

自动创建的 PVC,与 PV 绑定成功后,就会进入 Bound 状态,这就意味着这个 Pod 可以挂载并使用这个 PV 了。

$ kubectl create -f statefulset.yaml
$ kubectl get pvc -l app=nginx
NAME        STATUS    VOLUME                                     CAPACITY   ACCESSMODES   AGE
www-web-0   Bound     pvc-15c268c7-b507-11e6-932f-42010a800002   1Gi        RWO           48s
www-web-1   Bound     pvc-15c79307-b507-11e6-932f-42010a800002   1Gi        RWO           48s

可以看到,这些 PVC,都以“--< 编号 >”的方式命名,并且处于 Bound 状态。

Pod 的编号与 PVC 的编号一一对应,比如,在名叫 web-0 的 Pod 的 volumes 字段,它会声明使用名叫 www-web-0 的 PVC。

如果在此时删除 web-0 这个 Pod。

当把一个 Pod 删除之后,这个 Pod 对应的 PVC 和 PV,并不会被删除,而这个 Volume 里已经写入的数据,也依然会保存在远程存储服务里。

当把一个 Pod 删除之后,StatefulSet 控制器会创建一个新的 Pod。在这个新的 Pod 对象的定义里,它声明使用的 PVC 的名字与之前相同,还是叫作:www-web-0。

所以,在这个新的 web-0 Pod 被创建出来之后,Kubernetes 为它查找名叫 www-web-0 的 PVC 时,就会直接找到旧 Pod 遗留下来的同名的 PVC,进而找到跟这个 PVC 绑定在一起的 PV。

这样,新的 Pod 就可以挂载到旧 Pod 对应的那个 Volume,并且获取到保存在 Volume 里的数据。

DaemonSet

DaemonSet 的主要作用,是在 Kubernetes 集群里,运行一个 Daemon Pod:

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

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

  3. 当有新的节点加入 Kubernetes 集群后,该 Pod 会自动地在新节点上被创建出来;而当旧节点被删除后,它上面的 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:  # 挂载了两个 hostPath 类型的 Volume, fluentd 启动之后,它会从这两个目录里搜集日志信息,并转发给 ElasticSearch 保存
        - 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,管理的是一个 fluentd-elasticsearch 镜像的 Pod,作用是通过 fluentd 将 Docker 容器里的日志转发到 ElasticSearch。

可以看到,DaemonSet 跟 Deployment 其实非常相似,只不过是没有 replicas 字段。

DaemonSet是如何保证每个 Node 上有且只有一个被管理的 Pod 呢?

DaemonSet Controller,首先从 Etcd 里获取所有的 Node 列表,然后遍历所有的 Node。这时,它就可以很容易地去检查,当前这个 Node 上是不是有一个携带了 name=fluentd-elasticsearch 标签的 Pod 在运行。

而检查的结果,可能有这么三种情况:

  1. 没有这种 Pod,那么就意味着要在这个 Node 上创建这样一个 Pod;

  2. 有这种 Pod,但是数量大于 1,那就说明要把多余的 Pod 从这个 Node 上删除掉;

  3. 正好只有一个这种 Pod,那说明这个节点是正常的。

其中,删除节点(Node)上多余的 Pod 非常简单,直接调用 Kubernetes API 就可以了。

DaemonSet是如何在指定的 Node 上创建新 Pod 的呢?

apiVersion: v1
kind: Pod
metadata:
  name: with-node-affinity
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: metadata.name
            operator: In  # operator: In 含义是部分匹配;operator: Equal,就是完全匹配
            values:
            - node-special

如上,定义的 nodeAffinity 的含义是:

  1. requiredDuringSchedulingIgnoredDuringExecution:它的意思是说,这个 nodeAffinity 必须在每次调度的时候予以考虑。同时,这也意味着你可以设置在某些情况下不考虑这个 nodeAffinity;

  2. 这个 Pod,将来只允许运行在“metadata.name”是“node-special”的节点上。

DaemonSet 会在向 Kubernetes 发起请求之前,直接修改根据模板生成的 Pod 对象,加上这样一个 nodeAffinity 定义。此外,DaemonSet 还会给这个 Pod 自动加上另外一个与调度相关的字段,叫作 tolerations

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

被标记为 unschedulable “污点”的 Node,一般是不允许把 Pod 调度到它上面的。但是给 Pod 加上这样一个 tolerations 定义,“容忍”这个“污点”,就使得定义的这个 Pod 可以忽略这个限制。

注:“污点”可以理解为一种特殊的 Label。

总结:在创建每个 Pod 的时候,DaemonSet 会自动给这个 Pod 加上一个 nodeAffinity,从而保证这个 Pod 只会在指定节点上启动。同时,它还会自动给这个 Pod 加上一个 Toleration,从而忽略节点的 unschedulable“污点”。

DaemonSet的版本管理

Deployment 的版本管理,靠的是“一个版本对应一个 ReplicaSet 对象”。可是,DaemonSet 控制器操作的直接就是 Pod,不可能有 ReplicaSet 这样的对象参与其中。那么,它的这些版本又是如何维护的呢?

ControllerRevision,专门用来记录某种 Controller 对象的版本。

$ 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>
$ kubectl rollout undo daemonset fluentd-elasticsearch --to-revision=1 -n kube-system
daemonset.extensions/fluentd-elasticsearch rolled back

通过使用 kubectl describe 查看 ControllerRevision 对象可知,这个 ControllerRevision 对象,实际上是在 Data 字段保存了该版本对应的完整的 DaemonSet 的 API 对象,并且,在 Annotation 字段保存了创建这个对象所使用的 kubectl 命令。

kubectl rollout undo 操作,实际上相当于读取到了 Revision=1 的 ControllerRevision 对象保存的 Data 字段。而这个 Data 字段里保存的信息,就是 Revision=1 时这个 DaemonSet 的完整 API 对象。

所以,现在 DaemonSet Controller 就可以使用这个历史 API 对象,对现有的 DaemonSet 做一次 PATCH 操作(等价于执行一次 kubectl apply -f “旧的 DaemonSet 对象”),从而把这个 DaemonSet“更新”到一个旧版本。

这也是为什么,在执行完这次回滚完成后,DaemonSet 的 Revision 并不会从 Revision=2 退回到 1,而是会增加成 Revision=3。这是因为,一个新的 ControllerRevision 被创建了出来。

注:ControllerRevision 是一个通用的版本管理对象,StatefulSet 也是直接控制 Pod 对象的,也是使用 ControllerRevision 来进行版本管理的。

Job

Deployment、StatefulSet、DaemonSet 它们主要编排的对象,都是“在线业务”,即:Long Running Task(长作业)。但是,有一类作业显然不满足这样的条件,这就是“离线业务”,或者叫作 Batch Job(计算业务),这种业务在计算完成后就直接退出了。

apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  parallelism: 2  # 最多可以启动的同时运行的 Pod 数量
  completions: 4  # Job 的最小完成数
  template:
    spec:
      containers:
      - name: pi
        image: resouer/ubuntu-bc
        command: ["sh", "-c", "echo 'scale=5000; 4*a(1)' | bc -l "]
      restartPolicy: Never
  backoffLimit: 4  # 当作业失败时的重试次数
  activeDeadlineSeconds: 600  # 最长执行时间,单位s

如果定义的 restartPolicy=OnFailure,那么离线作业失败后,Job Controller 就不会去尝试创建新的 Pod。但是,它会不断地尝试重启 Pod 里的容器。

在这个 Pod 模板中,定义了一个安装了 bc 命令的 Ubuntu 镜像,它运行的程序是:echo "scale=10000; 4*a(1)" | bc -l。其中,bc 命令是 Linux 里的“计算器”;-l 表示,我现在要使用标准数学库;而 a(1),则是调用数学库中的 arctangent 函数。tan(π/4) = 1。所以,4*atan(1)正好就是π,也就是 3.1415926…。通过 scale=10000,即是指定了输出的小数点后的位数是 10000。在我的计算机上,这个计算大概用时 1 分 54 秒。

Job 跟其他控制器不同的是,Job 对象并不要求定义一个 spec.selector 来描述要控制哪些 Pod。

$ kubectl create -f job.yaml
$ 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

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

使用方法之外部管理器

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

可以看到,在这个 Job 的 YAML 里,定义了 $ITEM 这样的“变量”。

  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

接下来,创建这些 Job 即可:

$ 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

使用场景:当已经有了一套自己的方案,需要做的往往就是集成工作。这时候,Kubernetes 项目对这些方案来说最有价值的,就是 Job 这个 API 对象。所以,编写一个外部工具(等同于我们这里的 for 循环)来管理这些 Job 即可。

使用方法之Operator

在实际的应用中,需要处理的条件往往会非常复杂,比如任务 Pod 之间的关系不那么“单纯”,是“有状态”Job。在这种情况下,使用Operator(即自定义资源和控制器),加上 Job 对象一起,可能才能更好的满足实际离线任务的编排需求。

CronJob

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

CronJob 与 Job 的关系,正如同 Deployment 与 Pod 的关系一样。CronJob 是一个专门用来管理 Job 对象的控制器。它创建和删除 Job 的依据,是 schedule 字段定义的、一个标准的 Unix Cron 格式的表达式。

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

  1. concurrencyPolicy=Allow,这也是默认情况,这意味着这些 Job 可以同时存在;

  2. concurrencyPolicy=Forbid,这意味着不会创建新的 Pod,该创建周期被跳过;

  3. concurrencyPolicy=Replace,这意味着新产生的 Job 会替换旧的、没有执行完的 Job。

如果某一次 Job 创建失败,这次创建就会被标记为“miss”。spec.startingDeadlineSeconds=200 的含义是:在过去 200 s 里,如果 miss 的数目达到了 100 次,那么这个 Job 就不会被创建执行了。

Last updated

Was this helpful?