0.1. 进程与进程组 0.1.1. 成组调度问题 0.1.2. 超亲密关系 0.2. Pod实现原理 0.3. 容器设计模式 0.3.1. 示例一 0.3.2. 示例二 0.4. 共享Network Namespace 0.5. 总结 Pod 是Kubernetes项目中最小的API对象,原子调度单位。 Docker的原理与本质:Namespace做隔离,Cgroups做限制,rootfs做文件系统。 容器的本质:进程。 容器,是未来云计算系统中的进程 容器镜像,是未来云计算系统里的“.exe”安装包 kubernetes,就是这个云计算系统(操作系统) 0.1. 进程与进程组 安装psmics工具集,并使用pstree工具查看系统进程。 yum install -y psmisc # 返回当前系统中正在运行的进程的树状结构 pstree -g systemd(1)───rsyslogd(627) ─┬─{rs:main Q:Reg}(627) └─{in:imklog}(627) # 省略很多 ... 在一个正在运行的操作系统中,进程是以进程组的方式“有原则的”组织在一起,在进程后的括号中的数组表示进程组ID(Process Group ID,PGID)。 rsyslogd程序是负责Linux系统里的日志处理,它的主程序是main,和它要用的内核日志模块imklog等,同属于627进程组,这些进程相互协作,共同完成rsyslog程序的职责。对于操作系统来说,这样的进程更容易管理。 例如,Linux系统只需要将信号(如SIGKILL)发送给进程组,那么该进程组中的所有进程都会收到这个信号而终止运行。 kubernetes项目所做的,就是将“进程组”的概念映射到了容器技术中,并使其成为云计算“操作系统”里面的“一等公民”。 在实际应用中,应用之间有着密切的协作关系,类似于“进程与进程组”的关系,这使得它们必须部署在同一台机器上,否则基于Socket的通信和文件交换,会出现问题。没有组的概念,这样的运维关系非常难以处理。 容器是单进程模型。 容器的“单进程模型”,并不是指容器里只能运行“一个”进程,而是指容器没有管理多个进程的能力。这是因为容器里PID=1的进程就是应用本身,其他的进程都是这个PID=1进程的子进程。 用户编写的应用,并不能够向正常操作系统里的init进程或者systemd那样拥有进程管理的功能。 举个例子:一个Java Web程序(PID=1),然后执行docker exec 进入该容器后,在后台启动一个Nginx进程(PID=3),当这个Nginx进程异常退出的时候如何知道?进程退出后的垃圾回收工作又该谁去做? 0.1.1. 成组调度问题 Mesos采用资源囤积机制:在所有亲和性任务到达后才开始统一进行调度。 Google Omega采用乐观调度机制:先不管冲突,通过精心设计的回滚机制在出现冲突后解决问题。 以上解决方案存在的问题: 囤积机制容易带来不可避免的调度效率损失和死锁的可能性。 客观调度的实现程度过于复杂。 在Kubernetes中以Pod为原子调度单位,调度器统一按照Pod而非容器的资源需求进行计算。 0.1.2. 超亲密关系 容器之间的紧密协作称为“超亲密关系”。这样的容器的典型特征包括但不限于: 互相之间会发生直接的文件交换 使用 localhost 或者Socket文件进行本地通信 发生非常频繁的远程调用 需要共享某些 Linux Namespace(比如,一个容器要加入另一个容器的 Network Namespace) 。。。 容器的超亲密关系可以在调度层面实现,Pod在kubernetes项目中,最重要的是“容器设计模式”。 0.2. Pod实现原理 Pod是逻辑上的概念。即Kubernetes真正处理的其实是宿主机操作系统上Linux容器的Namespace和Cgroups,而不存在一个所谓的Pod隔离环境。 Pod是一组共享了某些资源的容器,Pod里的所有容器共享的是同一个Network Namespace,并且可以声明共享同一个Volume。 使用Docker原理能否实现Pod?A与B两个容器共享网络和Volume,如下命令: docker run --net=B --volumes-from=B --name=A image-A ... # 容器B必须比容器A先启动 这样的话,多个容器就不是对等关系,而是拓扑关系。 Pod的实现需要使用一个中间容器,这个容器叫作Infra容器。在这个Pod中,Infra容器永远都是第一个被创建的容器,而其他用户定义的容器,则通过Join Network Namespace的方式与Infra容器关联在一起。 这个Pod里有两个用户容器A和B,还有一个Infra容器。在Kubernetes项目中,Infra容器占用极少资源,使用一个非常特殊的镜像(k8s.gcr.io/pause)。这个镜像是一个用汇编语言编写的,永远处于“暂停”状态的容器,解压后的大小也只有100~20KB左右。 对于Pod里的容器A和B: 它们可以直接使用localhost通信 它们看到的网络设备与Infra容器看到的完全一样 一个Pod只有一个IP地址,是这个Pod的Network Namespace对应的IP地址 其他的所有网络资源,都是一个Pod一份,并且被该Pod中的所有容器共享 Pod的生命周期只跟Infra容器一致,与容器A和B无关 对于同一个 Pod里面的所有用户容器来说,它们的进出流量,可以认为都是通过Infra容器完成的。 如果要为 Kubernetes 开发一个网络插件,应该重点考虑的是如何配置这个 Pod 的 Network Namespace,而不是每一个用户容器如何使用网络配置,这是没有意义的。 这意味着,如果网络插件需要在容器里安装某些包或者配置才能完成的话,是不可取的,在Infra 容器镜像的 rootfs里几乎什么都没有,没有你随意发挥的空间。 这意味着,网络插件完全不必关心用户容器启动与否,而只需要关注如何配置 Pod,也就是 Infra 容器的Network Namespace 即可。 有了这个设计之后,共享Volume就简单多了:Kubernetes 项目只要把所有Volume 的定义都设计在 Pod 层级即可。这样,一个 Volume对应的宿主机目录对于Pod来说就只有一个,Pod 里的容器只要声明挂载这个 Volume,就一定可以共享这个Volume对应的宿主机目录。比如下面这个例子: apiVersion: v1 kind: Pod metadata: name: two-containers spec: restartPolicy: Never volumes: - name: shared-data hostPath: path: /data containers: - name: nginx-container image: nginx volumeMounts: - name: shared-data mountPath: /usr/share/nginx/html - name: debian-container image: debian volumeMounts: - name: shared-data mountPath: /pod-data command: ["/bin/sh"] args: ["-c", "echo Hello from the debian container > /pod-data/index.html"] 在这个例子中,debian-container 和 nginx-container 都声明挂载了 shared-data 这个Volume。而 shared-data 是 hostPath 类型。所以,它对应在宿主机上的目录就是:/data。而这个目录,其实就被同时绑定挂载进了上述两个容器当中。 0.3. 容器设计模式 Pod 这种“超亲密关系”容器的设计思想,实际上就是希望,当用户想在一个容器里跑多个功能并不相关的应用时,应该优先考虑它们是不是更应该被描述成一个 Pod 里的多个容器。 为了能够掌握这种思考方式,应该尽量尝试使用它来描述一些用单个容器难以解决的问题。 0.3.1. 示例一 第一个最典型的例子是:WAR 包与 Web 服务器。我们现在有一个 Java Web 应用的 WAR 包,它需要被放在 Tomcat 的 webapps 目录下运行起来。 假如,你现在只能用 Docker 来做这件事情,那该如何处理这个组合关系呢? 一种方法是,把 WAR 包直接放在 Tomcat 镜像的 webapps 目录下,做成一个新的镜像运行起来。可是,这时候,如果你要更新WAR包的内容,或者要升级Tomcat镜像,就要重新制作一个新的发布镜像,非常麻烦。 另一种方法是,你压根儿不管 WAR 包,永远只发布一个 Tomcat 容器。不过,这个容器的webapps 目录,就必须声明一个 hostPath 类型的 Volume,从而把宿主机上的 WAR 包挂载进Tomcat 容器当中运行起来。不过,这样你就必须要解决一个问题,即:如何让每一台宿主机,都预先准备好这个存储有 WAR 包的目录呢?这样来看,你只能独立维护一套分布式存储系统了。 实际上,有了 Pod 之后,这样的问题就很容易解决了。我们可以把 WAR 包和 Tomcat 分别做成镜像,然后把它们作为一个 Pod 里的两个容器“组合”在一起。这个 Pod 的配置文件如下所示: apiVersion: v1 kind: Pod metadata: name: javaweb-2 spec: initContainers: - image: geektime/sample:v2 name: war command: ["cp", "/sample.war", "/app"] volumeMounts: - mountPath: /app name: app-volume containers: - image: geektime/tomcat:7.0 name: tomcat command: ["sh","-c","/root/apache-tomcat-7.0.42-v2/bin/start.sh"] volumeMounts: - mountPath: /root/apache-tomcat-7.0.42-v2/webapps name: app-volume ports: - containerPort: 8080 hostPort: 8001 volumes: - name: app-volume emptyDir: {} 在这个 Pod 中,我们定义了两个容器: 第一个容器使用的镜像是 geektime/sample:v2,这个镜像里只有一个 WAR 包(sample.war)放在根目录下 第二个容器则使用的是一个标准的 Tomcat镜像 WAR 包容器的类型不再是一个普通容器,而是一个 Init Container 类型的容器。 在 Pod 中,所有 Init Container 定义的容器,都会比 spec.containers 定义的用户容器先启动。并且,Init Container 容器会按顺序逐一启动,而直到它们都启动并且退出了,用户容器才会启动。 这个 Init Container 类型的 WAR 包容器启动后,执行了一句 "cp /sample.war /app",把应用的 WAR 包拷贝到 /app 目录下,然后退出 而后这个 /app 目录,就挂载了一个名叫 app-volume 的 Volume 接下来就很关键了。Tomcat 容器,同样声明了挂载 app-volume 到自己的 webapps 目录下。所以,等 Tomcat 容器启动时,它的 webapps 目录下就一定会存在 sample.war 文件:这个文件正是 WAR 包容器启动时拷贝到这个 Volume 里面的,而这个 Volume 是被这两个容器共享的 像这样,用一种“组合”方式,解决了 WAR 包与 Tomcat 容器之间耦合关系的问题。实际上,这个所谓的“组合”操作,正是容器设计模式里最常用的一种模式,它的名字叫:sidecar。 sidecar 指的就是可以在一个 Pod 中,启动一个辅助容器,来完成一些独立于主进程(主容器)之外的工作。 比如,这个应用Pod中,Tomcat容器是要使用的主容器,而WAR包容器的存在,只是为了给它提供一个 WAR包而已。所以,用InitContainer 的方式优先运行 WAR包容器,扮演了一个 sidecar 的角色。 0.3.2. 示例二 第二个例子:容器的日志收集。 比如,现在有一个应用,需要不断地把日志文件输出到容器的 /var/log 目录中。 把一个 Pod 里的 Volume 挂载到应用容器的 /var/log 目录上 在这个 Pod 里同时运行一个 sidecar 容器,它也声明挂载同一个 Volume 到自己的 /var/log 目录上 sidecar容器就只需要做一件事儿,不断地从自己的/var/log目录里读取日志文件,转发到 MongoDB 或者 Elasticsearch 中存储起来 一个最基本的日志收集工作就完成了。 这个例子中的 sidecar 的主要工作也是使用共享的 Volume 来完成对文件的操作。 0.4. 共享Network Namespace Pod中所有容器都共享同一个 Network Namespace。这就使得很多与 Pod 网络相关的配置和管理,也都可以交给 sidecar 完成,而完全无须干涉用户容器。 最典型的例子莫过于Istio这个微服务治理项目了。Istio项目使用sidecar容器完成微服务治理。 0.5. 总结 容器设计模式:启动一个辅助容器,来完成一些独立于主进程(主容器)之外的工作 共享Network Namespace:与Pod网络相关的配置和管理,交给sidecar完成 容器技术的本质是“进程”,一个运行在虚拟机里的应用,是被管理在Systemd或者supervisord执行的一组进程,而不是一个进程。 Pod实际上在扮演传统基础设施里的虚拟机的角色,而容器,则是这个虚拟机里运行的用户程序。 当需要把一个运行在虚拟机里的应用迁移到 Docker 容器中时,一定要仔细分析到底有哪些进程(组件)运行在这个虚拟机里。 然后,就可以把整个虚拟机想象成为一个Pod,把这些进程分别做成容器镜像,把有顺序关系的容器,定义为 Init Container。这才是更加合理的、松耦合的容器编排诀窍,也是从传统应用架构,到“微服务架构”最自然的过渡方式。 如果强行把整个应用塞到一个容器里,甚至不惜使用Docker in Docker 这种在生产环境中后患无穷的解决方案,恐怕最后往往得不偿失。
0.1. Pod 中几个重要字段 0.1.1. NodeSelector 0.1.2. NodeName 0.1.3. HostAliases 0.1.3.1. 例子 0.2. Pod 生命周期 Pod,而不是容器,才是 Kubernetes 项目中的最小编排单位。将这个设计落实到 API 对象上,容器(Container)就成了 Pod 属性里的一个普通的字段。 那么问题来了: 到底哪些属性属于 Pod 对象, 而又有哪些属性属于 Container 呢? Pod 扮演的是传统部署环境里“虚拟机”的角色。这样的设计,是为了使用户从传统环境(虚拟机环境)向 Kubernetes(容器环境)的迁移,更加平滑。 如果把 Pod 看成传统环境里的“机器”、把容器看作是运行在这个“机器”里的“用户程序”,那么很多关于 Pod 对象的设计就非常容易理解了。 比如,凡是调度、网络、存储,以及安全相关的属性,基本上是 Pod 级别的。 这些属性的共同特征是,它们描述的是“机器”这个整体,而不是里面运行的“程序”。比如: 配置这个“机器”的网卡(即:Pod 的网络定义), 配置这个“机器”的磁盘(即:Pod 的存储定义), 配置这个“机器”的防火墙(即:Pod 的安全定义), 这台“机器”运行在哪个服务器之上(即:Pod 的调度)。 0.1. Pod 中几个重要字段 0.1.1. NodeSelector 是一个供用户将 Pod 与 Node 进行绑定的字段,用法如下所示: apiVersion: v1 kind: Pod ... spec: nodeSelector: disktype: ssd 这样的一个配置,意味着这个 Pod 永远只能运行在携带了“disktype:ssd”标签(Label)的节点上;否则,它将调度失败。 0.1.2. NodeName 一旦 Pod 的这个字段被赋值,Kubernetes项目就会被认为这个Pod已经经过了调度,调度的结果就是赋值的节点名字。 所以,这个字段一般由调度器负责设置,但用户也可以设置它来“骗过”调度器,当然这个做法一般是在测试或者调试的时候才会用到。 0.1.3. HostAliases 定义了 Pod 的 hosts 文件(比如 /etc/hosts)里的内容,用法如下: apiVersion: v1 kind: Pod ... spec: hostAliases: - ip: "10.1.2.3" hostnames: - "foo.remote" - "bar.remote" ... 在这个 Pod 的 YAML 文件中,设置了一组 IP 和 hostname 的数据。这样,这个 Pod 启动 后,/etc/hosts 文件的内容将如下所示: cat /etc/hosts # Kubernetes-managed hosts file. 127.0.0.1 localhost ... 10.244.135.10 hostaliases-pod 10.1.2.3 foo.remote 10.1.2.3 bar.remote 其中,最下面两行记录,就是通过 HostAliases 字段为 Pod 设置的。 需要指出的是: 在Kubernetes 项目中,如果要设置hosts文件里的内容,一定要通过这种方法。 如果直接修改了 hosts 文件的话,在 Pod 被删除重建之后,kubelet会自动覆盖掉被修改的内容。 除了上述跟“机器”相关的配置外,凡是跟容器的 Linux Namespace 相关的属性,也一定是 Pod级别的。 Pod的设计,就是要让它里面的容器尽可能多地共享 Linux Namespace,仅保留必要的隔离和限制能力。这样,Pod模拟出的效果,就跟虚拟机里程序间的关系非常类似了。 举个例子,在下面这个 Pod 的 YAML 文件中,定义 shareProcessNamespace=true: apiVersion: v1 kind: Pod metadata: name: nginx spec: shareProcessNamespace: true containers: - name: nginx image: nginx - name: shell image: busybox stdin: true tty: true 这就意味着这个 Pod 里的容器要共享 PID Namespace。 而在这个 YAML 文件中,还定义了两个容器:一个是 nginx 容器,一个是开启了 tty 和 stdin 的shell 容器。在 Pod 的 YAML 文件里声明开启它们俩,其实等同于设置了 docker run 里的 -it(-i 即 stdin,-t 即 tty)参数。 可以直接认为 tty 就是Linux给用户提供的一个常驻小程序,用于接收用户的标准输入,返回操作系统的标准输出。当然,为了能够在 tty 中输入信息,你还需要同时开启 stdin(标准输入流)。 这个 Pod 被创建后,就可以使用shell容器的tty跟这个容器进行交互了。 0.1.3.1. 例子 kubectl create -f nginx.yaml //上一节创建的yaml文件 kubectl attach -it nginx -c shell //使用kubectl attach 命令,连接到shell容器的tty上 kubectl attach -it nginx -c shell # ps ax PID USER TIME COMMAND 1 root 0:00 /pause 8 root 0:00 nginx: master process nginx -g daemon off; 14 101 0:00 nginx: worker process 15 root 0:00 sh 21 root 0:00 ps ax 在这个容器里,我们不仅可以看到它本身的 ps ax 指令,还可以看到 nginx 容器的进程,以及 Infra容器的 /pause 进程。这就意味着,整个 Pod 里的每个容器的进程,对于所有容器来说都是可见的:它们共享了同一个 PID Namespace。 类似地,凡是 Pod 中的容器要共享宿主机的 Namespace,也一定是Pod级别的定义,比如: apiVersion: v1 kind: Pod metadata: name: nginx spec: hostNetwork: true hostIPC: true hostPID: true containers: - name: nginx image: nginx - name: shell image: busybox stdin: true tty: true 在这个 Pod 中,定义了共享宿主机的 Network、IPC 和 PID Namespace。这就意味着,这个Pod 里的所有容器: 会直接使用宿主机的网络、 直接与宿主机进行IPC通信、 看到宿主机里正在运行的所有进程。 当然,除了这些属性,Pod 里最重要的字段当属“Containers”了。 "container"与“Init Containers”。其实,这两个字段都属于Pod对容器的定义,内容也完全相同,只是 Init Containers 的生命周期,会先于所有的Containers,并且严格按照定义的顺序执行。 Kubernetes 项目中对 Container 的定义,和 Docker 相比并没有什么太大区别。 容器技术概念中: Image(镜像) Command(启动命令) workingDir(容器的工作目录) Ports(容器要开放的端口) 以及 volume Mounts(容器要挂载的 Volume) 都是构成 Kubernetes 项目中 Container 的主要字段。 不过在这里,还有这么几个属性值得额外关注。 首先,是 ImagePullPolicy 字段。它定义了镜像拉取的策略。而它之所以是一个 Container 级别的属性,是因为容器镜像本来就是 Container 定义中的一部分。 ImagePullPolicy 的值默认是Always,即每次创建Pod都重新拉取一次镜像。另外,当容器的镜像是类似于 nginx 或者 nginx:latest 这样的名字时,ImagePullPolicy 也会被认为 Always。而如果它的值被定义为 Never 或者 IfNotPresent,则意味着 Pod 永远不会主动拉取这个镜像,或者只在宿主机上不存在这个镜像时才拉取。 其次,是 Lifecycle 字段。它定义的是 Container Lifecycle Hooks。顾名思义,Container Lifecycle Hooks 的作用,是在容器状态发生变化时触发一系列“钩子”。 我们来看这样一个例子: apiVersion: v1 kind: Pod metadata: name: lifecycle-demo spec: containers: - name: lifecycle-demo-container image: nginx lifecycle: postStart: exec: command: ["/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"] preStop: exec: command: ["/usr/sbin/nginx","-s","quit"] 这是一个来自 Kubernetes 官方文档的 Pod 的 YAML 文件。它其实非常简单,只是定义了一个 nginx 镜像的容器。不过,在这个 YAML 文件的容器(Containers)部分,你会看到这个容器分别设置了一个 postStart 和 preStop 参数。 postStart ,它指的是,在容器启动后,立刻执行一个指定的操作。需要明确的是, postStart 定义的操作,虽然是在 Docker 容器 ENTRYPOINT 执行之后,但它并不严格保证顺序。 也就是说,在 postStart 启动时,ENTRYPOINT 有可能还没有结束。 当然,如果 postStart 执行超时或者错误,Kubernetes 会在该 Pod 的 Events 中报出该容器启动失败的错误信息,导致 Pod 也处于失败的状态。 preStop 发生的时机,则是容器被杀死之前(比如,收到了 SIGKILL 信号)。preStop操作的执行,是同步的。所以,它会阻塞当前的容器杀死流程,直到这个 Hook定义操作完成之后,才允许容器被杀死,这跟 postStart 不一样。 所以,在这个例子中,我们在容器成功启动之后,在 /usr/share/message 里写入了一句“欢迎信息”(即postStart定义的操作)。而在这个容器被删除之前,我们则先调用了 nginx 的退出指令(即 preStop 定义的操作),从而实现了容器的“优雅退出”。 0.2. Pod 生命周期 Pod 对象在 Kubernetes 中的生命周期。Pod 生命周期的变化,主要体现在 Pod API 对象的Status 部分,这是它除了 Metadata 和 Spec 之外的第三个重要字段。其中,pod.status.phase,就是 Pod的当前状态,它有如下几种可能的情况: Pending。这个状态意味着,Pod 的 YAML 文件已经提交给了 Kubernetes,API 对象已经被创建并保存在 Etcd 当中。但是,这个 Pod 里有些容器因为某种原因而不能被顺利创建。比如,调度不成功。 Running。这个状态下,Pod已经调度成功,跟一个具体的节点绑定。它包含的容器都已经创建成功,并且至少有一个正在运行中。 Succeeded。这个状态意味着,Pod 里的所有容器都正常运行完毕,并且已经退出了。这种情况在运行一次性任务时最为常见。 Failed。这个状态下,Pod 里至少有一个容器以不正常的状态(非 0 的返回码)退出。这个状态的出现,意味着得想办法 Debug 这个容器的应用,比如查看 Pod 的 Events 和日志。 Unknown。这是一个异常状态,意味着 Pod 的状态不能持续地被 kubelet 汇报给 kubeapiserver,这很有可能是主从节点(Master和 Kubelet)间的通信出现了问题。 Pod 对象的 Status 字段,还可以再细分出一组 Conditions。这些细分状态的值包括: PodScheduled Ready Initialized Unschedulable 它们主要用于描述造成当前Status 的具体原因是什么。 比如,Pod 当前的 Status 是 Pending,对应的 Condition 是Unschedulable,这就意味着它的调度出现了问题。 而其中,Ready 这个细分状态非常值得我们关注:它意味着 Pod 不仅已经正常启动(Running 状态),而且已经可以对外提供服务了。Running和Ready是有区别的。 Pod 的这些状态信息,是我们判断应用运行情况的重要标准,尤其是 Pod 进入了非“Running”状态后,一定要能迅速做出反应,根据它所代表的异常情况开始跟踪和定位,而不是去手忙脚乱地查阅文档。 对于 Pod 状态是 Ready,实际上不能提供服务的情况能想到几个例子: 程序本身有 bug,本来应该返回 200,但因为代码问题,返回的是500; 程序因为内存问题,已经僵死,但进程还在,但无响应; Dockerfile 写的不规范,应用程序不是主进程,那么主进程出了什么问题都无法发现; 程序出现死循环。
0.1. Secret 0.2. ConfigMap 0.3. Downward API 0.4. Service Account 0.5. livenessProbe 0.6. readinessProbe 0.7. PodPreset 0.8. 总结 Pod中特殊的Volume:Project Volume(投射数据卷)。 Project Volume 是Kubernetes v1.11之后的新特性 在 Kubernetes 中,有几种特殊的 Volume,它们存在的意义: 不是为了存放容器里的数据, 也不是用来进行容器和宿主机之间的数据交换。 这些特殊 Volume 的作用,是为容器提供预先定义好的数据。所以,从容器的角度来看,这些 Volume 里的信息就是仿佛是被 Kubernetes“投射”(Project)进入容器当中的。这正是 Projected Volume 的含义。 到目前为止,Kubernetes 支持的 Projected Volume 一共有四种: Secret; ConfigMap; Downward API; ServiceAccountToken。 0.1. Secret 作用是把 Pod 想要访问的加密数据,存放到 Etcd 中。然后,就可以通过在 Pod 的容器里挂载 Volume 的方式,访问到这些 Secret里保存的信息了。 Secret 最典型的使用场景,莫过于存放数据库的 Credential 信息,比如下面这个例子: apiVersion: v1 kind: Pod metadata: name: test-projected-volume spec: containers: - name: test-secret-volume image: busybox args: - sleep - "86400" volumeMounts: - name: mysql-cred mountPath: "/projected-volume" readOnly: true volumes: - name: mysql-cred projected: sources: - secret: name: user - secret: name: pass 在这个 Pod 中,定义了一个简单的容器。它声明挂载的 Volume,并不是常见的 emptyDir 或者hostPath 类型,而是 projected 类型。而这个 Volume的数据来源(sources),则是名为 user和 pass 的 Secret 对象,分别对应的是数据库的用户名和密码。 这里用到的数据库的用户名、密码,正是以 Secret 对象的方式交给 Kubernetes 保存的。完成这个操作的指令,如下所示: cat ./username.txt admin cat ./password.txt c1oudc0w! kubectl create secret generic user --from-file=./username.txt kubectl create secret generic pass --from-file=./password.txt 其中,username.txt 和 password.txt 文件里,存放的就是用户名和密码;而 user 和 pass,则是我为 Secret 对象指定的名字。而我想要查看这些 Secret 对象的话,只要执行一条 kubectl get 命令就可以了: $ kubectl get secrets NAME TYPE DATA AGE user Opaque 1 51s pass Opaque 1 51s 当然,除了使用 kubectl create secret 指令外,也可以直接通过编写 YAML 文件的方式来创建这个 Secret 对象,比如: apiVersion: v1 kind: Secret metadata: name: mysecret type: Opaque data: user: YWRtaW4= pass: MWYyZDFlMmU2N2Rm 可以看到,通过编写 YAML 文件创建出来的 Secret 对象只有一个。但它的 data 字段,却以 KeyValue的格式保存了两份Secret数据。其中: “user”是第一份数据的Key “pass”是第二份数据的 Key 需要注意的是,Secret对象要求这些数据必须是经过Base64转码的,以免出现明文密码的安全隐患。这个转码操作也很简单,比如: $ echo -n 'admin' | base64 YWRtaW4= $ echo -n '1f2d1e2e67df' | base64 MWYyZDFlMmU2N2Rm 这里需要注意的是,像这样创建的 Secret 对象,它里面的内容仅仅是经过了转码,而并没有被加密。在真正的生产环境中,需要在 Kubernetes 中开启 Secret 的加密插件,增强数据的安全性。 接下来,我们尝试一下创建这个 Pod: kubectl create -f test-projected-volume.yaml 当 Pod 变成 Running 状态之后,我们再验证一下这些 Secret 对象是不是已经在容器里了: kubectl exec -it test-projected-volume -- /bin/sh ls /projected-volume/ user pass cat /projected-volume/user root cat /projected-volume/pass 1f2d1e2e67df 从返回结果中,我们可以看到,保存在 Etcd 里的用户名和密码信息,已经以文件的形式出现在了容器的 Volume 目录里。而这个文件的名字,就是 kubectl create secret 指定的 Key,或者说是Secret 对象的 data 字段指定的 Key。 更重要的是,像这样通过挂载方式进入到容器里的 Secret,一旦其对应的 Etcd 里的数据被更新,这些 Volume 里的文件内容,同样也会被更新。 其实,这是 kubelet 组件在定时维护这些Volume。需要注意的是,这个更新可能会有一定的延时。所以在编写应用程序时,在发起数据库连接的代码处写好重试和超时的逻辑,绝对是个好习惯。 0.2. ConfigMap 与 Secret 类似的是 ConfigMap,它与 Secret 的区别在于,ConfigMap 保存的是不需要加密的、应用所需的配置信息。而 ConfigMap 的用法几乎与 Secret 完全相同: 可以使用 kubectl create configmap 从文件或者目录创建 ConfigMap, 也可以直接编写 ConfigMap 对象的 YAML 文件。 比如,一个 Java 应用所需的配置文件(.properties文件),就可以通过下面这样的方式保存在ConfigMap 里: 查看 .properties 文件的内容 $ cat example/ui.properties color.good=purple color.bad=yellow allow.textmode=true how.nice.to.look=fairlyNice 从.properties 文件创建 ConfigMap kubectl create configmap ui-config --from-file=example/ui.properties 查看这个 ConfigMap 里保存的信息 (data) $ kubectl get configmaps ui-config -o yaml apiVersion: v1 data: ui.properties: color.good=purple color.bad=yellow allow.textmode=true how.nice.to.look=fairlyNice kind: ConfigMap metadata: name: ui-config ... kubectl get -o yaml 这样的参数,会将指定的 Pod API 对象以 YAML 的方式展示出来。 0.3. Downward API 它的作用是:让 Pod 里的容器能够直接获取到这个 Pod API 对象本身的信息。 举个例子: apiVersion: v1 kind: Pod metadata: name: test-downwardapi-volume labels: zone: us-est-coast cluster: test-cluster1 rack: rack-22 spec: containers: - name: client-container image: k8s.gcr.io/busybox command: ["sh", "-c"] args: - while true; do if [[ -e /etc/podinfo/labels ]]; then echo -en '\n\n'; cat /etc/podinfo/labels; fi; sleep 5; done; volumeMounts: - name: podinfo mountPath: /etc/podinfo readOnly: false volumes: - name: podinfo projected: sources: - downwardAPI: items: - path: "labels" fieldRef: fieldPath: metadata.labels 在这个 Pod 的 YAML 文件中,定义了一个简单的容器,声明了一个 projected 类型的 Volume。只不过这次 Volume 的数据来源,变成了 Downward API。而这个 Downward API Volume,则声明了要暴露 Pod 的 metadata.labels 信息给容器。 通过这样的声明方式,当前 Pod 的 Labels 字段的值,就会被 Kubernetes 自动挂载成为容器里的 /etc/podinfo/labels 文件。而这个容器的启动命令,则是不断打印出 /etc/podinfo/labels 里的内容。 所以,当创建了这个Pod 之后,就可以通过 kubectl logs 指令,查看到这些 Labels 字段被打印出来,如下所示: kubectl create -f dapi-volume.yaml kubectl logs test-downwardapi-volume cluster="test-cluster1" rack="rack-22" zone="us-est-coast" 目前,Downward API 支持的字段已经非常丰富了,比如: 使用 fieldRef 可以声明使用: 字段名 描述 spec.nodeName 宿主机名字 status.hostIP 宿主机 IP metadata.name Pod 的名字 metadata.namespace Pod 的 Namespace status.podIP Pod 的 IP spec.serviceAccountName Pod 的 Service Account 的名字 metadata.uid Pod 的 UID metadata.labels[<KEY>] 指定 <KEY> 的 Label 值 metadata.annotations[<KEY>] 指定 <KEY> 的 Annotation 值 metadata.labels Pod 的所有 Label metadata.annotations Pod 的所有 Annotation 使用 resourceFieldRef 可以声明使用: 容器的 CPU limit 容器的 CPU request 容器的 memory limit 容器的 memory request 上面这个列表的内容,仅供参考,在使用 Downward API 时,还是要去查阅一下官方文档。 需要注意的是,Downward API 能够获取到的信息,一定是 Pod 里的容器进程启动之前就能够确定下来的信息。 而如果你想要获取 Pod 容器运行后才会出现的信息,比如,容器进程的 PID,那就肯定不能使用 Downward API 了,而应该考虑在 Pod 里定义一个 sidecar 容器。 Secret、ConfigMap,以及 Downward API 这三种 Projected Volume 定义的信息,大多还可以通过环境变量的方式出现在容器里。 但是,通过环境变量获取这些信息的方式,不具备自动更新的能力。 所以,一般情况下,建议使用 Volume 文件的方式获取这些信息。 0.4. Service Account Pod 中与Secret密切相关的Service Account。 现在有一个 Pod,能不能在这个 Pod 里安装一个Kubernetes的Client?这样就可以从容器里直接访问并且操作这个 Kubernetes 的 API 了。 这当然是可以的。 不过,首先要解决 API Server 的授权问题。 Service Account 对象的作用,就是 Kubernetes 系统内置的一种“服务账户”,它是 Kubernetes 进行权限分配的对象。 比如: Service Account A,可以只被允许对 Kubernetes API 进行 GET 操作, Service Account B,则可以有 Kubernetes API 的所有操作的权限。 像这样的 Service Account 的授权信息和文件,实际上保存在它所绑定的一个特殊的 Secret 对象里的。这个特殊的 Secret 对象,就叫作ServiceAccountToken。 任何运行在 Kubernetes 集群上的应用,都必须使用这个 ServiceAccountToken 里保存的授权信息,也就是Token,才可以合法地访问 API Server。 所以说,Kubernetes 项目的 Projected Volume 其实只有三种,因为第四种ServiceAccountToken,只是一种特殊的 Secret 而已。 为了方便使用,Kubernetes 提供了一个的默认“服务账户”(default Service Account)。并且,任何一个运行在 Kubernetes 里的 Pod,都可以直接使用这个默认的 Service Account,而无需显示地声明挂载它。 这是如何做到的呢? 当然还是靠 Projected Volume 机制。 查看一下任意一个运行在 Kubernetes 集群里的 Pod,就会发现,每一个 Pod,都已经自动声明一个类型是 Secret、名为 default-token-xxxx 的 Volume,然后自动挂载在每个容器的一个固定目录上。比如: $ kubectl describe pod nginx-deployment-5c678cfb6d-lg9lw Containers: ... Mounts: /var/run/secrets/kubernetes.io/serviceaccount from default-token-s8rbq (ro) Volumes: default-token-s8rbq: Type: Secret (a volume populated by a Secret) SecretName: default-token-s8rbq Optional: false 这个 Secret 类型的 Volume,正是默认 Service Account 对应的 ServiceAccountToken。 Kubernetes在每个Pod创建的时候,自动在它的spec.volumes部分添加上了默认ServiceAccountToken的定义,然后自动给每个容器加上了对应的 volumeMounts 字段。这个过程对于用户来说是完全透明的。 这样,一旦 Pod创建完成,容器里的应用就可以直接从这个默认 ServiceAccountToken 的挂载目录里访问到授权信息和文件。 这个容器内的路径在 Kubernetes 里是固定的,即: /var/run/secrets/kubernetes.io/serviceaccount 而这个 Secret 类型的Volume里面的内容如下所示: $ ls /var/run/secrets/kubernetes.io/serviceaccount ca.crt namespace token 应用程序只要直接加载这些授权文件,就可以访问并操作 Kubernetes API 了。 如果使用的是 Kubernetes 官方的 Client 包(k8s.io/client-go)的话,它还可以自动加载这个目录下的文件,不需要做任何配置或者编码操作。 这种把 Kubernetes客户端以容器的方式运行在集群里,然后使用 default Service Account 自动授权的方式,被称作“InClusterConfig”,也是最推荐的进行 Kubernetes API 编程的授权方式。 当然,考虑到自动挂载默认 ServiceAccountToken 的潜在风险,Kubernetes 允许你默认不为 Pod 里的容器自动挂载这个 Volume。 除了这个默认的Service Account外,很多时候需要创建自己定义的 Service Account,来对应不同的权限设置。 Pod 里的容器就可以通过挂载这些 Service Account对应的ServiceAccountToken,来使用这些自定义的授权信息。 0.5. livenessProbe 在 Kubernetes 中,可以为 Pod 里的容器定义一个健康检查“探针”(Probe)。kubelet 会根据这个 Probe 的返回值决定这个容器的状态,而不是直接以容器进程是否运行(来自Docker返回的信息)作为依据。这种机制,是生产环境中保证应用健康存活的重要手段。 Kubernetes 文档中的例子: apiVersion: v1 kind: Pod metadata: labels: test: liveness name: test-liveness-exec spec: containers: - name: liveness image: busybox args: - /bin/sh - -c - touch /tmp/healthy; sleep 30; rm -rf /tmp/healthy; sleep 600 livenessProbe: exec: command: - cat - /tmp/healthy initialDelaySeconds: 5 periodSeconds: 5 在这个 Pod 中,定义了一个有趣的容器。 它在启动之后做的第一件事,就是在 /tmp 目录下创建了一个healthy文件,以此作为自己已经正常运行的标志。 而 30 s 过后,它会把这个文件删除掉。 与此同时,定义了一个livenessProbe。 它的类型是exec,这意味着,它会在容器启动后,在容器里面执行一句指定的命令,比如:“cat /tmp/healthy”。 如果文件存在,这条命令的返回值就是 0,Pod 就会认为这个容器不仅已经启动,而且是健康的。 这个健康检查,在容器启动5s后开始执行(initialDelaySeconds:5),每5s执行一次(periodSeconds: 5)。 30秒之后,查看Pod的Events,会报告容器是不健康的。再次查看Pod的状态,这个异常的容器已经被Kubernetes重启了,在这个过程中,Pod报错Running状态不变。 Kubernetes中并没有Docker的Stop语义。所以虽然是Restart(重启),但实际却是重新创建了容器。 这个功能就是 Kubernetes 里的Pod 恢复机制,也叫 restartPolicy。它是 Pod 的 Spec 部分的一个标准字段(pod.spec.restartPolicy),默认值是 Always,即:任何时候这个容器发生了异常,它一定会被重新创建。 但一定要强调的是,Pod 的恢复过程,永远都是发生在当前节点上,而不会跑到别的节点上去。 事实上,一旦一个Pod与一个节点(Node)绑定,除非这个绑定发生了变化(pod.spec.node 字段被修改),否则它永远都不会离开这个节点。 这也就意味着,如果这个宿主机宕机了,这个 Pod 也不会主动迁移到其他节点上去。 而如果你想让Pod出现在其他的可用节点上,就必须使用 Deployment 这样的“控制器”来管理 Pod,哪怕你只需要一个 Pod 副本。 这就是一个单 Pod 的 Deployment 与一个 Pod 最主要的区别。 而作为用户,可以通过设置 restartPolicy,改变 Pod 的恢复策略。 Always:在任何情况下,只要容器不在运行状态,就自动重启容器; OnFailure:只在容器异常时才自动重启容器; Never:从来不重启容器。 在实际使用时,需要根据应用运行的特性,合理设置这三种恢复策略。 比如,一个 Pod,它只计算 1+1=2,计算完成输出结果后退出,变成 Succeeded 状态。这时,你如果再用 restartPolicy=Always 强制重启这个 Pod 的容器,就没有任何意义了。 而如果你要关心这个容器退出后的上下文环境,比如容器退出后的日志、文件和目录,就需要将 restartPolicy设置为Never。因为一旦容器被自动重新创建,这些内容就有可能丢失掉了(被垃圾回收了)。 值得一提的是,Kubernetes 的官方文档,把 restartPolicy 和 Pod 里容器的状态,以及 Pod 状态的对应关系,总结了非常复杂的一大堆情况。实际上,你根本不需要死记硬背这些对应关系,只要记住如下两个基本的设计原理即可: 只要 Pod 的 restartPolicy 指定的策略允许重启异常的容器(比如:Always),那么这个Pod就会保持Running状态,并进行容器重启。否则,Pod 就会进入 Failed 状态 。 对于包含多个容器的Pod,只有它里面所有的容器都进入异常状态后,Pod 才会进入 Failed 状态。在此之前,Pod都是Running状态。此时,Pod的READY字段会显示正常容器的个数,比如: $ kubectl get pod test-liveness-exec NAME READY STATUS RESTARTS AGE liveness-exec 0/1 Running 1 1m 假如一个Pod里只有一个容器,然后这个容器异常退出了。那么,只有当restartPolicy=Never 时,这个 Pod才会进入Failed状态。而其他情况下,Kubernetes 都可以重启这个容器,所以 Pod 的状态保持 Running 不变。 而如果这个Pod有多个容器,仅有一个容器异常退出,它就始终保持 Running 状态,哪怕即使 restartPolicy=Never。只有当所有容器也异常退出之后,这个 Pod 才会进入 Failed 状态。 其他情况,都可以以此类推出来。 除了在容器中执行命令外,livenessProbe也可以定义为发起HTTP或者TCP请求的方式,定义格式如下: ... livenessProbe: httpGet: path: /healthz port: 8080 httpHeaders: - name: X-Custom-Header value: Awesome initialDelaySeconds: 3 periodSeconds: 3... livenessProbe: tcpSocket: port: 8080 initialDelaySeconds: 15 periodSeconds: 20 Pod 可以暴露一个健康检查URL(比如/healthz) 直接让健康检查去检测应用的监听端口 这两种配置方法,在 Web服务类的应用中非常常用。 0.6. readinessProbe readinessProbe 检查结果的成功与否: 决定的这个Pod是不是能被通过Service的方式访问到, 而并不影响 Pod 的生命周期。 详见server章节。 0.7. PodPreset Pod 的字段这么多,我又不可能全记住,Kubernetes 能不能自动给 Pod 填充某些字段呢? 这个需求实际上非常实用。 比如,开发人员只需要提交一个基本的、非常简单的 Pod YAML,Kubernetes 就可以自动给对应的 Pod 对象加上其他必要的信息,(如labels,annotations,volumes 等等)。而这些信息,可以是运维人员事先定义好的。这么一来,开发人员编写 Pod YAML 的门槛,就被大大降低了。 PodPreset(Pod 预设置)的功能已经出现在了 v1.11 版本的 Kubernetes 中。 举个例子,现在开发人员编写了如下一个 pod.yaml 文件: apiVersion: v1 kind: Pod metadata: name: website labels: app: website role: frontend spec: containers: - name: website image: nginx ports: - containerPort: 80 这种 Pod 在生产环境里根本不能用啊! 所以,这个时候,就可以定义一个 PodPreset 对象。在这个对象中,凡是想在上述编写的 Pod 里追加的字段,都可以预先定义好。比如这个 preset.yaml: apiVersion: settings.k8s.io/v1alpha1 kind: PodPreset metadata: name: allow-database spec: selector: matchLabels: role: frontend env: - name: DB_PORT value: "6379" volumeMounts: - mountPath: /cache name: cache-volume volumes: - name: cache-volume emptyDir: {} 在这个 PodPreset 的定义中, 首先,是一个 selector。这就意味着后面这些追加的定义,只会作用于selector所定义的、带有“role:frontend”标签的 Pod 对象,这就可以防止“误伤”。 然后,定义了一组Pod的Spec里的标准字段,以及对应的值。 比如,env 里定义了DB_PORT这个环境变量,volumeMounts 定义了容器 Volume 的挂载目录,volumes 定义了一个 emptyDir 的 Volume。 接下来,我们假定运维人员先创建了这个 PodPreset,然后开发人员才创建 Pod: kubectl create -f preset.yaml kubectl create -f pod.yaml 这时,Pod运行起来之后,查看整个Pod的API对象: $ kubectl get pod website -o yaml apiVersion: v1 kind: Pod metadata: name: website labels: app: website role: frontend annotations: podpreset.admission.kubernetes.io/podpreset-allow-database: "resource version" spec: containers: - name: website image: nginx volumeMounts: - mountPath: /cache name: cache-volume ports: - containerPort: 80 env: - name: DB_PORT value: "6379" volumes: - name: cache-volume emptyDir: {} 这个时候,我们就可以清楚地看到: 这个 Pod 里多了新添加 labels、env、volumes 和volumeMount 的定义,它们的配置跟 PodPreset 的内容一样。 此外,这个 Pod 还被自动加上了 一个 annotation 表示这个 Pod 对象被 PodPreset 改动过。 需要说明的是,PodPreset 里定义的内容,只会在 Pod API 对象被创建之前追加在这个对象本身上,而不会影响任何 Pod 的控制器的定义。 比如,我们现在提交的是一个nginx-deployment,那么这个 Deployment 对象本身是永远不会被 PodPreset 改变的,被修改的只是这个Deployment 创建出来的所有 Pod。 这一点请务必区分清楚。 这里有一个问题:如果你定义了同时作用于一个 Pod 对象的多个 PodPreset,会发生什么呢? 实际上,Kubernetes项目会帮你合并(Merge)这两个 PodPreset要做的修改。而如果它们要做的修改有冲突的话,这些冲突字段就不会被修改。 0.8. 总结 Kubernetes“一切皆对象”的设计思想: 应用是 Pod 对象 应用的配置是 ConfigMap 对象 应用要访问的密码则是 Secret 对象 PodPreset专门用来对Pod进行批量化、自动化修改的工具对象
0.1. 举个例子 0.2. kube-controller-manager 0.2.1. 例子又来了 0.3. 控制器模型 Kubernetes项目最核心的功能---编排。 Pod这个看似复杂的API对象,其实就是对容器的进一步抽象和封装。Pod对象就是容器的升级版,它对容器进行了组合,添加了更多的属性和字段。 Kubernetes操作Pod的逻辑都是由控制器(controller)完成的。 0.1. 举个例子 创建一个nginx-deployment: apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment spec: selector: matchLabels: app: nginx replicas: 2 template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.7.9 ports: - containerPort: 80 这个Deployment定义的编排动作:确保携带了app=nginx标签的Pod的个数,永远等于spec.replicas指定的个数,即2个。 如果集群中携带app=nignx标签的Pod的个数大于2,就会有旧的Pod被删除,反之,就会有新的Pod被创建。 0.2. kube-controller-manager 这个组件是一些列控制器的集合,查看kubernetes项目中pkg/controller目录: $ cd kubernetes/pkg/controller/ $ ls -d */ deployment/ job/ pod scaler/ cloud/ disruption/ namespace/ replicaset/ serviceaccount/ volume/ cronjob/ garbagecollector/ nodelifecycle/ replication/ statefulset/ daemon/ ... 这个目录下的每一个控制器,都以独有的方式负责某种编排功能。 这些控制器之所以都被统一放在pkg/controller目录下,是因为它们都遵循kubernetes项目中的一个通用编排模式,即:控制循环(controller loop)。 0.2.1. 例子又来了 比如,现在有一种待编排的对象X,它有一个对应的控制器,那么就可以使用一段Go语言风格的伪代码,来描述这个控制循环: for { 实际状态 := 获取集群中对象 X 的实际状态(Actual State) 期望状态 := 获取集群中对象 X 的期望状态(Desired State) if 实际状态 == 期望状态{ 什么都不做 } else { 执行编排动作,将实际状态调整为期望状态 } } 在具体实现中,实际状态往往来自Kubernetes集群本身。 比如: kubelet通过心跳汇报的容器状态和节点状态, 监控系统中保存的应用监控数据, 控制器主动收集的它感兴趣的信息, 这些都是常见的实际状态的来源。 期望状态,一般来自于用户提交的YAML文件。 比如: Deployment对象中Replicas字段的值,这些信息都保存在Etcd中。 0.3. 控制器模型 以Deployment为例: Deployment控制器从Etcd中获取所有携带“app:nginx”标签的Pod,然后统计它们的数量,这就是实际状态。 Deployment对象的Replicas字段的值就是期望状态。 Deployment控制器将两个状态做比较,然后根据比较结果,确定是创建Pod,还是删除已有的Pod。 一个Kubernetes对象的主要编排逻辑,实际上是在第三步的比对阶段完成的。 这个操作,通常被叫作调谐(Reconcile),调谐的过程被称作“Reconcile Loop”(调谐循环)或者“Sync Loop”(同步循环)。 调谐的结果往往是对被控制对象的某种写操作,如增加Pod或者删除已有Pod,或者增加Pod的某个字段。这是Kubernetes项目“面向API对象编程”的一个直观体现。 控制器对象本身负责定义被管理对象的期望状态,比如Deployment里面replicas=2。 被控制对象的定义,来自于一个模板,比如Deployment里面的template字段。 Deployment这个template字段里的内容,跟一个标准的Pod对象的API定义完全一样。所有被整个Deployment管理的Pod实例,都是根据这个template字段的内容创建的。 Deployment定义的template字段,在kubernetes项目中称为PodTemplate(Pod模板)。 大多数控制器,都会使用PodTemplate来统一定义它所要管理的Pod。也有其他类型的对象模板,比如Volume模板。 类似Deployment的控制器,实际上由上半部分的控制器定义(包括期望状态)和下半部分的被控制对象的模板组成: 在所有的API对象的Metadata里,都要有一个字段叫作ownerReference,用于保存这个API对象的拥有者的信息。 Kubernetes使用的“控制器模式”与“事件驱动模式”的区别与联系:(控制器视角) 控制器模式 事件驱动模式 主动 被动 循环不断的尝试,最终达到申明一致 一次性的操作,失败后难以处理
Deployment实现了Kubernetes项目中一个非常重要的功能:Pod的水平扩展/收缩(Horizontal scaling out/in)。 如果修改了Deployment的Pod模板,那么Deployment就需要遵循滚动更新(rolling update)的方式来升级现有容器。 这个能力的实现依赖的是kubernetes项目中的另一个API 对象 : ReplicaSet。 apiVersion: apps/v1 kind: ReplicaSet metadata: name: nginx-set labels: app: nginx spec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:1.7.9 一个ReplicaSet对象就是由副本数目的定义和一个Pod模板组成。它是Deployment的一个子集。 Deployment控制器实际操纵的,就是ReplicaSet对象,而不是Pod对象。即,Pod的ownerReference是ReplicaSet。 层层控制关系:其中ReplicaSet负责通过控制器模式,来保证系统中Pod的个数永远等于指定的个数。这也正是Deployment只允许容器的restartPolicy=Always的主要原因:只有在容器能保证自己始终Running的状态下,ReplicaSet调整Pod的个数才有意义。 在此基础上,Deployment同样通过控制器模式,来操作ReplicaSet的个数和属性,进而实现水平扩展/收缩和滚动更新,这两个编排动作。 水平扩展/收缩 水平扩展/收缩:Deployment Controller只需要修改它所控制的ReplicaSet的Pod副本个数就可以了。 通过kubectl scale指令实现: $ kubectl scale deployment nginx-deployment --replicas=4 deployment.apps/nginx-deployment scaled 滚动更新 滚动更新 $ kubectl create -f nginx-deployment.yaml --record //创建一个Deployment --record参数的含义是记录每次操作所执行的命令 $ kubectl get deployments //查看创建的deployment NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE nginx-deployment 3 0 0 0 1s 返回结果中的四个状态字段的含义: DESIRED:用户期望的Pod副本个数(spec.replicas的值) CURRENT:当前处于Running状态的Pod的个数 UP-TO-DATE:当前处于最新版的Pod的个数,所谓最新版指的是Pod的Spec部分与Deployment里Pod模板的定义完全一致。 AVAILABLE:当前已经可用的Pod的个数,即,既是Running又是最新版,并且已经处于Ready(健康检查正确)状态的Pod个数。 AVAILABLE字段,描述的才是用户所期望的最终状态。 $ kubectl rollout status deployment/nginx-deployment //实时查看Deployment对象的状态变化 Waiting for rollout to finish: 2 out of 3 new replicas have been updated... //意味着有2个Pod到了UP-TO-DATE状态 deployment.apps/nginx-deployment successfully rolled out $ kubectl get deployments NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE nginx-deployment 3 3 3 3 20s $ kubectl get rs NAME DESIRED CURRENT READY AGE nginx-deployment-3167673210 3 3 3 20s 当用户提交了Deployment对象后,Deployment Controller就会立即创建一个Pod副本个数为3的ReplicaSet。这个ReplicaSet的名字,则是由Deployment的名字和一个随机字符串共同组成的。 随机字符串叫作pod-template-hash,ReplicaSet会把这个随机字符串加在它所控制的所有Pod的标签里,从而保证这些Pod不会与集群里的其他Pod混淆。 ReplicaSet也有DESIRED、CURRENT、READY字段,Deployment只是多加了UP-TO-DATE这个与版本有关的状态字段。 这个时候修改Deployment的Pod模板,滚动更新会自动触发。 修改Deployment的方法 修改yaml文件 直接修改Etcd里的API对象,使用kubectl edit指令 $ kubectl edit deployment/nginx-deployment //kubectl edit 指令直接打开nginx-deployment的API对象,然后就可以修改Pod模板部分 ... spec: containers: - name: nginx image: nginx:1.9.1 # 1.7.9 -> 1.9.1 ports: - containerPort: 80 ... deployment.extensions/nginx-deployment edited //编辑完成后,保存退出,kubernetes会立刻触发滚动更新的过程。 $ kubectl rollout status //查看deployment的状态变化 deployment/nginx-deployment Waiting for rollout to finish: 2 out of 3 new replicas have been updated... deployment.extensions/nginx-deployment successfully rolled out $ kubectl describe deployment nginx-deployment //通过查看Evemts,来查看滚动更新的流程 ... Events: Type Reason Age From Message ---- ------ ---- ---- ------- ... Normal ScalingReplicaSet 24s deployment-controller Scaled up replica set nginx-deployment-1764197365 to 1 Normal ScalingReplicaSet 22s deployment-controller Scaled down replica set nginx-deployment-3167673210 to 2 Normal ScalingReplicaSet 22s deployment-controller Scaled up replica set nginx-deployment-1764197365 to 2 Normal ScalingReplicaSet 19s deployment-controller Scaled down replica set nginx-deployment-3167673210 to 1 Normal ScalingReplicaSet 19s deployment-controller Scaled up replica set nginx-deployment-1764197365 to 3 Normal ScalingReplicaSet 14s deployment-controller Scaled down replica set nginx-deployment-3167673210 to 0 kubectl edit 只不过是将API对象的内容下载到本地文件,修改完成后再提交上去。 修改Deployment里的Pod定义后,Deployment Controller会使用这个修改后的Pod模板,创建一个新的ReplicaSet(hash=1764197365),这个新的ReplicaSet的初始Pod副本数是0; 在Age=24S的位置,Deployment Controller开始将新的ReplicaSet所控制的Pod副本数从0变成1,即水平扩展出1个副本; 在Age=22S的位置,Deployment Controller又将旧的ReplicaSet(hash=316763210)所控制的旧Pod副本数减少1个,即水平收缩成为2个副本; 如此交替,直到新ReplicaSet管理的副本数从0变到3,旧ReplicaSet管理的Pod副本数从3变到0。 这样就完成了这一组Pod的版本升级过程。 将一个集群中正在运行的多个Pod版本,交替地逐一升级的过程,就是滚动更新。 $ kubectl get rs //滚动更新完成后,查看ReplicaSet的最终状态 NAME DESIRED CURRENT READY AGE nginx-deployment-1764197365 3 3 3 6s nginx-deployment-3167673210 0 0 0 30s 滚动更新的好处 比如,在升级刚开始的时候,集群里只有 1 个新版本的 Pod。如果这时,新版本 Pod 有问题启动不起来,那么“滚动更新”就会停止,从而允许开发和运维人员介入。而在这个过程中,由于应用本身还有两个旧版本的 Pod 在线,所以服务并不会受到太大的影响。 当然,这也就要求你一定要使用 Pod 的 Health Check 机制检查应用的运行状态,而不是简单地依赖于容器的 Running 状态。要不然的话,虽然容器已经变成 Running 了,但服务很有可能尚未启动,“滚动更新”的效果也就达不到了。 而为了进一步保证服务的连续性,Deployment Controller 还会确保,在任何时间窗口内,只有指定比例的 Pod 处于离线状态。同时,它也会确保,在任何时间窗口内,只有指定比例的新 Pod 被创建出来。这两个比例的值都是可以配置的,默认都是 DESIRED 值的 25%。 所以,在上面这个 Deployment 的例子中,它有 3 个 Pod 副本,那么控制器在“滚动更新”的过程中永远都会确保至少有 2 个 Pod 处于可用状态,至多只有 4 个 Pod 同时存在于集群中。 这个策略,是 Deployment 对象的一个字段,名叫 RollingUpdateStrategy,如下所示: apiVersion: apps/v1 kind: Deployment metadata: name: nginx-deployment labels: app: nginx spec: ... strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 1 在上面这个 RollingUpdateStrategy 的配置中: maxSurge 指定的是除了 DESIRED 数量之外,在一次“滚动”中,Deployment 控制器还可以创建多少个新 Pod; maxUnavailable 指的是,在一次“滚动”中,Deployment 控制器可以删除多少个旧 Pod。 同时,这两个配置还可以用前面我们介绍的百分比形式来表示,比如:maxUnavailable=50%,指 的是我们最多可以一次删除“50%*DESIRED 数量”个 Pod。 结合以上讲述,现在我们可以扩展一下 Deployment、ReplicaSet 和 Pod 的关系图了。 如上所示,Deployment 的控制器,实际上控制的是 ReplicaSet 的数目,以及每个 ReplicaSet 的属性。 而一个应用的版本,对应的正是一个 ReplicaSet;这个版本应用的 Pod 数量,则由 ReplicaSet 通过它自己的控制器(ReplicaSet Controller)来保证。 通过这样的多个 ReplicaSet 对象,Kubernetes 项目就实现了对多个“应用版本”的描述。 Deployment 对应用进行版本控制的具体原理 $ kubectl set image deployment/nginx-deployment nginx=nginx:1.91 //修改Deployment所使用的镜像 deployment.extensions/nginx-deployment image updated 使用一个叫kubectl set image的指令,直接修改 nginx-deployment 所使用的镜像。这个命令的好处就是,你可以不用像 kubectl edit 那样需要打开编辑器。 $ kubectl rollout undo deployment/nginx-deployment //把整个Deployment回滚到上一个版本 deployment.extensions/nginx-deployment 在具体操作上,Deployment的控制器,让旧的ReplicaSet再次扩展,让新的ReplicaSet重新收缩。 $ kubectl rollout history deployment/nginx-deployment //查看每次Deployment变更对应的版本,在kubectl create的时候使用--record参数配合,记录所有版本被创建时的kubectl命令 deployments "nginx-deployment" REVISION CHANGE-CAUSE 1 kubectl create -f nginx-deployment.yaml --record 2 kubectl edit deployment/nginx-deployment 3 kubectl set image deployment/nginx-deployment nginx=nginx:1.91 $ kubectl rollout history deployment/nginx-deployment --revision=2 //查看每个版本对应的Deployment的AOI对象的细节 $ kubectl rollout undo deployment/nginx-deployment --to-revision=2 //回滚到指定版本,控制前按照滚动更新的方式完成降级操作 deployment.extensions/nginx-deployment 对Deployment的每一次更新操作,都会生成一个新的ReplicaSet对象,浪费资源,使用如下指令,在多次更新后只生成一个ReplicaSet对象: $ kubectl rollout pause deployment/nginx-deployment //让Deployment进入暂停状态 deployment.extensions/nginx-deployment paused 目前Deployment处于暂停状态 使用kubectl edit 或者 kubectl set image指令修改的Deployment的内容,并不会触发滚动更新,也不会创建新的ReplicaSet 对Deployment的修改完成后执行如下指令,把Deployment恢复回来 $ kubectl rollout resume deploy/nginx-deployment deployment.extensions/nginx-deployment resumed 在kubectl rollout resume指令执行之前,在kubectl rollout pause指令执行之后的这段时间里,对Deployment进行的所有修改,最后只会触发一次滚动更新。 Deployment对象有一个字段,叫作spec.revisionHistoryLimit,就是Kubernetes为Deployment保留的“历史版本”个数,把它设置为0,就再也不能回滚了。 总结 Deployment是一个两层控制器: 通过ReplicaSet的个数来描述应用的版本, 通过ReplicaSet的属性(比如replicas的值)来保证Pod的副本数量。 graph LR Deployment-->ReplicaSet版本 ReplicaSet版本-->Pod副本数 Kubernetes 项目通过对Deployment的设计,完成了对应用的抽象,我们可以使用Deployment来描述应用,使用kubectl rollout 命令控制用应用的版本 有了Deployment的能力,可以轻松实现金丝雀发布,蓝绿发布,A/B测试等很多应用发布模式。参考这个GitHub库。 滚动更新是一个自动化更新的金丝雀发布。 金丝雀部署:优先发布一台或少量机器升级,等验证无误后再更新其他机器。 优点是用户影响范围小, 不足之处是要额外控制如何做自动更新。 蓝绿部署:2组机器, 蓝代表当前的V1版本, 绿代表已经升级完成的V2版本。 通过LB(Load Balancer)将流量全部导入V2完成升级部署。 优点是切换快速, 缺点是影响全部用户。
0.1. 拓扑状态 0.1.1. Headless Service 0.1.2. 使用DNS记录来维持Pod的拓扑状态 0.2. 存储状态 0.3. StatefulSet工作原理 0.3.1. 总结 0.3.2. 滚动更新 0.4. 实践 0.4.1. 搭建MySQL集群 0.4.1.1. 第一步:备份主节点 0.4.1.2. 第二步:配置从节点 0.4.1.3. 第三步:启动从节点 0.4.1.4. 第四步:添加从节点 0.4.2. 迁移到kubernetes集群中 0.4.2.1. 问题一:主从节点需要不同的配置文件 0.4.2.2. 问题二:主从节点需要传输备份文件 0.4.2.2.1. 第一步,从ConfigMap中,获取MySQL的Pod对应的配置文件 0.4.2.2.2. 第二步:在Slave Pod启动前,从Master或其他Slave里拷贝数据库数据到自己的目录下 0.4.2.2.3. 第三步,Slave角色的MySQL容器启动前,执行初始化SQL语句 0.4.2.2.3.1. 工作一:MySQL节点初始化 0.4.2.2.3.2. 工作二:启动数据传输服务 0.4.3. 创建PV 0.4.4. 小结 Deployment并不足以覆盖所有的应用编排问题,因为它对应用做了一个简单的假设: 一个应用的所有Pod是完全一样的,他们互相之间没有顺序也无所谓运行在哪台宿主机上。需要的时候Deployment通过Pod模板创建新的Pod,不需要的时候,就可以“杀掉”任意一个Pod。 在分布式应用中,多个实例之间并不是这样的关系,有很多的依赖关系(主从关系、主备关系)。 数据存储类应用,它的多个实例往往都会在本地磁盘上保存一份数据。这些实例一旦被“杀掉”,即便重建出来,实例与数据之间的对应关系也丢失了,从而导致应用失败。 有状态应用: 实例之间有不对等关系 实例对外部数据有依赖关系 容器技术用于封装“无状态应用”尤其是Web服务,非常好,但是“有状态应用”就很困难。 kubernetes得益于“控制器模式”,在Deployment的基础上扩展出StatefulSet,它将应用抽象为两种情况: 拓扑状态:应用的多个实例之间不是完全对等的关系。这些应用实例必须按照某些顺序启动。 存储状态:应用的多个实例分别绑定了不同的存储数据。 比如应用的主节点A要先于从节点B启动,如果把A和B两个Pod删掉,它们被再次创建出来时,必须严格按照这个顺序才行,并且新建的Pod必须与原来的Pod的网络标识一样,这样原先的访问者才能使用同样的方法访问到这个新的Pod。 比如Pod A第一次读取到的数据应该和十分钟之后读取到的是同一份数据,哪怕在这期间Pod A被重新创建过,典型的例子就是一个数据库应用的多个存储实例。 StatefulSet的核心功能,通过某种方式记录这些状态,然后在Pod被创建时,能够为新的Pod恢复这些状态。 0.1. 拓扑状态 0.1.1. Headless Service 通过Service,可以访问对应的Deployment所包含的Pod。那么Service是如何被访问的: 以Service的VIP(Virtual IP)方式:访问Service的VIP时,会把请求转发到该Servcice所代理的某一个Pod上。 以Service 的DNS方式:比如通过my-svc.my-namespace.svc.cluster.local这条DNS可以访问到名为my-svc的Service所代理的某个Pod。 通过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地址。 区别在于,Headless Servcice不需要分配VIP,可以直接以DNS记录的方式解析出被代理Pod的IP地址。 # headless service example apiVersion: v1 kind: Service metadata: name: nginx labels: app: nginx spec: ports: - port: 80 name: web clusterIP: None #这里是重点 selector: app: nginx Headless Service仍然是一个标准的Service的YAML文件,只不过clusterIP字段为None。这样的话,这个Service没有VIP作为头,被创建后不会被分配VIP,而是以DNS记录的方式暴露出它所代理的Pod。 通过Label Selector筛选出需要被代理的Pod 以上述方式创建的Headless Service之后,它所代理的Pod的IP地址,会被绑一个如下格式的DNS记录: # 这个DNS是kubernetes为Pod分配的唯一的“可解析身份” <pod-name>.<svc-name>.<namespace>.svc.cluster.local 有了可解析身份,只要知道Pod的名字和对应的Service名字,就可以通过DNS记录访问到Pod的IP地址。 0.1.2. 使用DNS记录来维持Pod的拓扑状态 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 这个StatefulSet的YAML文件与同类型的Deployment的YAML文件的唯一区别是多了一个serviceName=nginx字段。 这个字段的作用,告诉StatefulSet控制器,在执行控制循环(control loop)的时候,使用nginx这个Headless Service来保证Pod的“可解析身份”。 此时执行创建任务,分别创建service和对应的StatefulSet: $ kubectl create -f svc.yaml $ kubectl get service nginx NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE nginx ClusterIP None <none> 80/TCP 10s $ kubectl create -f statefulset.yaml $ kubectl get statefulset web NAME DESIRED CURRENT AGE web 2 1 19s 查看StatefulSet的创建事件,或者使用kubectl的-w参数查看StatefulSet对应的Pod的创建过程: $ kubectl get pods -w -l app=nginx NAME READY STATUS RESTARTS AGE web-0 0/1 Pending 0 0s web-0 0/1 Pending 0 0s web-0 0/1 ContainerCreating 0 0s web-0 1/1 Running 0 19s web-1 0/1 Pending 0 0s web-1 0/1 Pending 0 0s web-1 0/1 ContainerCreating 0 0s web-1 1/1 Running 0 20s StatefulSet给它所管理的Pod的名字进行了编号,从0开始,短横(-)相接,每个Pod实例一个,绝不重复。 Pod的创建也按照编号顺序进行,只有当编号为0的Pod进入Running状态,并且细分状态为Ready之前,编号为1的pod都会一直处于pending状态。 为Pod设置livenessProbe和readinessProbe很重要。 当两个Pod都进入Running状态后,可以查看他们各自唯一的“网络身份”。 kubectl exec web-0 -- sh -c 'hostname' web-0 //pod的名字与hostname一致 kubectl exec web-1 -- sh -c 'hostname' web-1 以DNS的方式访问Headless Service,在启动的Pod的容器中,使用nslookup命令来解析Pod对应的Headlesss Service。 kubectl run -i --tty --image busybox dns-test --restart=Never --rm /bin/sh nslookup web-0.nginx Server: 10.0.0.10 Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local Name: web-0.nginx Address 1: 10.244.1.7 nslookup web-1.nginx Server: 10.0.0.10 Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local Name: web-1.nginx Address 1: 10.244.2.7 从nslookup命令的输出结果中发现,在访问web-0.nginx的时候,最后解析到的正是web-0这个pod的IP地址。 当删除这两个Pod后,会按照原先编号的顺序重新创建两个新的Pod,并且依然会分配与原来相同的“网络身份”。 通过这种严格的对应规则,StatefulSet就保证了Pod网络标识的稳定性。 通过这种方法,Kubernetes就成功地将Pod的拓扑状态(比如:哪个节点先启动,哪个节点后启动),按照“Pod名字+编号”的方式固定下来。并且Kubernetes还为每一个Pod提供了一个固定并且唯一的访问入口,即:这个Pod对应的DNS记录。 这些状态,在StatefulSet的整个生命周期里都保持不变,绝不会因为对应Pod的删除或重新创建而失效。 虽然web-0.nginx这条记录本身不会变化,但是它解析到的Pod的IP地址,并不是固定的,所以对于“有状态应用”实例的访问,必须使用DNS记录或者hostname的方式,绝不应该直接访问这些Pod的IP地址。 StatefulSet其实是Deployment的改良。通过Headless Service的方式,StatefulSet为每个Pod创建了一个固定并且稳定的DNS记录,来作为它的访问入口。 0.2. 存储状态 StatefulSet对存储状态的管理机制,主要是使用Persistent Volume Claim的功能。 在Pod的定义中可以声明Voluem(spec.volumes字段),在这个字段里定义一个具体类型的Volume,如hostPath。 当我们并不知道有哪些Volume类型(比如Ceph、GlusterFS)可用时,怎么办呢? # Ceph RBD volume example apiVersion: v1 kind: Pod metadata: name: rbd spec: containers: - image: kubernetes/pause name: rbd-rw volumeMounts: - name: rbdpd mountPath: /mnt/rbd volumes: - name: rbdpd 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" 如果不懂Ceph RBD的使用方法,这个Pod的Volume字段基本看不懂。 这个Ceph RBD对应的存储服务器、用户名、授权文件的位置都暴露出来了(信息被过度暴露)。 Kubernetes引入了一组叫作PVC和PV的API对象,大大降低了用户声明和使用Volume的门槛。 使用PVC来定义Volume,只要两步。 第一步: 定义一个PVC,声明想要的Volume属性。 kind: PersistentVolumeClaim apiVersion: v1 metadata: name: pv-claim spec: accessModes: - ReadWriteOnce resources: requests: storage: 1Gi 不需要任何Volume细节的字段,只有描述的属性和定义。 storage:1Gi【表示需要的Volume大小至少为1GiB】 accessMode:ReadWriteOnce【表示Volume的挂载方式为可读写,并且只能被挂载到一个节点上,而不是多个节点共享】 volume类型和支持的访问模式,如下表。 Volume Plugin ReadWriteOnce ReadOnlyMany ReadWriteMany AWSElasticBlockStore ✓ - - AzureFile ✓ ✓ ✓ AzureDisk ✓ - - CephFS ✓ ✓ ✓ Cinder ✓ - - FC ✓ ✓ - Flexvolume ✓ ✓ depends on the driver Flocker ✓ - - GCEPersistentDisk ✓ ✓ - Glusterfs ✓ ✓ ✓ HostPath ✓ - - iSCSI ✓ ✓ - Quobyte ✓ ✓ ✓ NFS ✓ ✓ ✓ RBD ✓ ✓ - VsphereVolume ✓ - - (works when pods are collocated) PortworxVolume ✓ - ✓ ScaleIO ✓ ✓ - StorageOS ✓ - - 第二步:在Pod中声明使用这个PVC apiVersion: v1 kind: Pod metadata: name: pv-pod spec: containers: - name: pv-container image: nginx ports: - containerPort: 80 name: "http-server" volumeMounts: - mountPath: "/usr/share/nginx/html" name: pv-storage volumes: - name: pv-storage persistentVolumeClaim: claimName: pv-claim 在这个pod的Volume定义中只需要声明它的类型是persistentVolumeClaim,然后指定PVC的名字,完全不必关心Volume本身的定义。 当我们创建这个Pod时,kubernetes会自动绑定一个符合条件的Volume。 这个Volume来自预先创建的PV(Persistent Volume)对象。 常见的PV对象如下: 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" 这个PV对象的spec.rbd字段,正是前面介绍的Ceph RBD Volume的详细定义。它声明的容量是10GiB,kubernetes会为刚才创建的PVC绑定这个PV。 kubernetes中PVC和PV的设计,实际上类似于“接口”和“实现”的思想。这种解耦合,避免了因为向开发者暴露过多的存储系统细节而带来隐患。 开发者只需要知道并使用“接口”,即PVC; 运维人员负责给这个“接口”绑定具体的实现,即PV。 PV和PVC的设计,使得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 为这个StatefulSet添加一个volumeClaimTemplates字段(类似于Deployment中PodTemplate的作用)。 凡是被这个StatefulSet管理的pod。都会声明一个对应的PVC,这个PVC的定义来自于volumeClaimTemplates这个模板字段。 更重要的是,这个PVC的名字会被分配一个与这个Pod完全一致的编号。 这个自动创建的PVC,与PV绑定成功后,就进入bound状态,这就意味着这个Pod可以挂载并使用这个PV。 PVC是一种特殊的Volume。一个PVC具体是什么类型的Volume,要在跟某个PV绑定之后才知道。PVC与PV能够绑定的前提是,在kubernetes系统中已经创建好符合条件的PV,或者在公有云上通过Dynamic Provisioning的方式,自动为创建的PVC匹配PV。 创建上述StatefulSet后,在集群中会出现两个PVC: 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都是以<PVC名字>-<StatefulSet名字>-<编号>的方式命名,并且处于Bound状态。 这个StatefulSet创建出来的Pod都会声明使用编号的PVC,比如名叫web-0的Pod的Volume字段就会声明使用www-web-0的PVC,从而挂载到这个PVC所绑定的PV。 当容器向这个Volume挂载的目录写数据时,都是写入到这个PVC所绑定的PV中。当这两个Pod被删除后,这两个Pod会被按照编号的顺序重新创建出来,原先与相同编号的Pod绑定的PV在Pod被重新创建后依然绑定在一起。 StatefulSet控制器恢复Pod的过程: 当Pod被删除时,对应的PVC和PV并不会被删除,所以这个Volume里已经写入的数据,也依然会保存在远程存储服务里。 StatefulSet控制器发现,有Pod消失时,就会重新创建一个新的、名字相同的Pod来纠正这种不一致的情况。 在这个新的Pod对象的定义里,它声明使用的PVC与原来的名字相同;这个PVC的定义来自PVC模板,这是StatefulSet创建Pod的标准流程。 所有在这个新的Pod被创建出来后,kubernetes为它查找原来名字的PVC,就会直接找到旧的Pod遗留下来的同名的PVC,进而找到与这个PVC绑定在一起的PV。 这样新的Pod就可以挂载到旧Pod对应的那个Volume,并且获得到保存在Volume中的数据。 通过这种方式,kubernetes的StatefulSet就实现了对应用存储状态的管理。 0.3. StatefulSet工作原理 StatefulSet控制器直接管理Pod,因为StatefulSet里面不同的Pod实例,不再像ReplicaSet中那样都是完全一样的,而是有细微区别的。比如每个Pod的hostname、名字等都是不同的、都携带编号。 Kubernetes通过Headless Service,为这些有编号的Pod,在DNS服务器中生成带有同样编号的DNS记录。只要StatefulSet能够保证这些Pod名字里的编号不变,那么Service里类似于<pod名字>.<svc名字>.<命名空间>.cluster.local这样的DNS记录也就不会变,而这条记录解析出来的Pod的IP地址,则会随着后端Pod的删除和再创建而自动更新。这是Service机制本身的能力,不需要StatefulSet操心。 StatefulSet还为每一个Pod分配并创建一个同样编号的PVC。这样Kubernetes就可以通过Persistent Volume机制为这个PVC绑定上对应的PV,从而保证每个Pod都拥有独立的Volume。在这种情况下,即使Pod被删除,它所对应的PVC和PV依然会保留下来,所以当这个Pod被重新创建出来之后,Kubernetes会为它找到同样编号的PVC,挂载这个PVC对应的Volume,从而获取到以前保存在Volume里的数据。 0.3.1. 总结 StatefulSet其实就是一种特殊的Deployment,其独特之处在于,它的每个Pod都被编号。而且,这个编号会体现在Pod的名字和hostname等标识信息上,这不仅代表了Pod的创建顺序,也是Pod的重要网络标识(即:在整个集群里唯一的、可被访问的身份)。 有了这个编号后,StatefulSet就使用kubernetes里的两个标准功能:Headless Service和PV/PVC,实现了对Pod的拓扑状态和存储状态的维护。StatefulSet是kubernetes中作业编排的集大成者。 0.3.2. 滚动更新 StatefulSet编排“有状态应用”的过程,其实就是对现有典型运维业务的容器化抽象。也就是说,在不使用kubernetes和容器的情况下,也可以实现,只是在升级、版本管理等工程的能力很差。 使用StatefulSet进行“滚动更新”,只需要修改StatefulSet的Pod模板,就会自动触发“滚动更新”的操作。 kubectl patch statefulset mysql --type='json' \ -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value":"mysql:5.7.23"}]' statefulset.apps/mysql patched 使用kubectl path命令,以“补丁”的方式(JSON格式的)修改一个API对象的指定字段,即spec/template/spec/containers/0/image。 这样,StatefulSet Controller就会按照与Pod编号相反的顺序,从最后一个Pod开始,逐一更新这个StatefulSet管理的每个Pod。如果发生错误,这次滚动更新会停止。 StatefulSet的滚动更新允许进行更精细的控制如(金丝雀发布,灰度发布),即应用的多个实例中,被指定的一部分不会被更新到最新的版本。StatefulSet的spec.updateStragegy.rollingUpdate的partition字段。 如下命令,将StatefulSet的partition字段设置为2: kubectl patch statefulset mysql \ -p '{"spec":{"updateStrategy":{"type":"RollingUpdate","rollingUpdate":{"partition":2}}}}' statefulset.apps/mysql patched 上面的操作等同于使用kubectl edit命令直接打开这个对象,然后把partition字段修改为2。这样当模板发生变化时,只有序号大于等于2的Pod会被更新到这个版本,并且如果删除或者重启序号小于2的Pod,它再次启动后,还是使用原来的模板。 0.4. 实践 0.4.1. 搭建MySQL集群 相比于Etcd、Cassandra等“原生”就考虑分布式需求的项目,MySQL以及很多其他的数据库项目,在分布式集群上搭建并不友好,甚至有点“原始”。使用StatefulSet将MySQL集群搭建过程“容器化”,部署过程如下: 部署一个“主从复制(Master-Slave Replication)”的MySQL集群 部署一个主节点(Master) 部署多个从节点(Slave) 从节点需要水平扩展 所有的写操作只在主节点上执行 读操作可以在所有节点上执行 典型的主从模式MySQL集群如下所示: 在常规环境中,部署这样一个主从模式的MySQL集群的主要难点在于:如何让从节点能拥有主节点的数据,即:如何配置主(Master)从(Slave)节点的复制与同步。 0.4.1.1. 第一步:备份主节点 所以在安装好MySQL的Master节点后,需要做的第一步工作:通过XtraBackup将Master节点的数据备份到指定目录。 XtraBackup是业界主要使用的开源MySQL备份和恢复工具。 这个过程会自动在目标目录生成一个备份信息文件,名叫:xtrabackup_binlog_info。这个文件一般会包含如下两个信息: cat xtrabackup_binlog_info TheMaster-bin.000001 481 这两个信息会在接来配置Slave节点的时候用到。 0.4.1.2. 第二步:配置从节点 Slave节点在第一次启动之前,需要先把Master节点的备份数据,连同备份信息文件,一起拷贝到自己的数据目录(/var/lib/mysql)下,然后执行如下SQL语句: TheSlave|mysql> CHANGE MASTER TO MASTER_HOST='$masterip', MASTER_USER='xxx', MASTER_PASSWORD='xxx', MASTER_LOG_FILE='TheMaster-bin.000001', MASTER_LOG_POS=481; 其中,MASTER_LOG_FILE和MASTER_LOG_POS,就是上一步中备份对应的二进制日志(Binary Log)文件的名称和开始的位置(偏移量),也正是xtrabackup_binlog_info文件里的那两部分内容(即TheMaster-bin.000001和481). 0.4.1.3. 第三步:启动从节点 执行如下SQL语句来启动从节点: TheSlave|mysql> START SLAVE; Slave节点启动并且会使用备份信息文件中的二进制日志文件和偏移量,与主节点进行数据同步。 0.4.1.4. 第四步:添加从节点 注意:新添加的Slave节点的备份数据,来自于已经存在的Slave节点。 所以,在这一步,需要将Slave节点的数据备份在指定目录。而这个备份操作会自动生成另一份备份信息文件,名叫:xtrabackup_slave_info。这个文件也包含MASTER_LOG_FILE和MASTER_LOG_POS字段。 然后再执行第二步和第三步。 0.4.2. 迁移到kubernetes集群中 从上述步骤不难免看出,将部署MySQL集群的流程迁移到kubernetes项目上,需要能够“容器化”地解决下面的“三个问题”。 Master与Slave需要有不同的配置文件(my.cnf) Master与Slave需要能够传输备份信息文件 在Slave第一次启动之前,需要执行一些初始化SQL操作 由于MySQL本身同时拥有拓扑状态(主从)和存储状态(MySQL数据保存在本地),所以使用StatefulSet来部署MySQL集群。 0.4.2.1. 问题一:主从节点需要不同的配置文件 为主从节点分别准备两份配置文件,然后根据pod的序号挂载进去。配置信息应该保存在ConfigMap里供Pod使用: apiVersion: v1 kind: ConfigMap metadata: name: mysql labels: app: mysql data: master.cnf: | # 主节点 MySQL 的配置文件 [mysqld] log-bin slave.cnf: | # 从节点 MySQL 的配置文件 [mysqld] super-read-only 定义master.cnf和slave.cnf两个MySQL配置文件。 master.cnf:开启log-bin,即使用二进制文件的方式进行主从复制 slave.cnf:开启super-read-only,即从节点会拒绝除了主节点的数据同步操作之外的所有写操作(对用户只读)。 在ConfigMap定义里的data部分,是key-value格式的。比如master.cnf就是这份配置数据的Key,而“|”后面的内容,就是这份配置数据的Value。这份数据将来挂载到Master节点对应的Pod后,就会在Volume目录里生成一个叫做master.cnf的文件。 然后创建两个Service来供StatefulSet以及用户使用,定义如下: apiVersion: v1 kind: Service metadata: name: mysql labels: app: mysql spec: ports: - name: mysql port: 3306 clusterIP: None selector: app: mysql --- apiVersion: v1 kind: Service metadata: name: mysql-read labels: app: mysql spec: ports: - name: mysql port: 3306 selector: app: mysql 相同点:这两个Service都代理了所有携带app=mysql标签的pod。端口映射都是用service的3306端口对应Pod的3306端口。 不同点:第一个service是headless service(ClusterIP=None),它的作用是通过为pod分配DNS记录来固定它的拓扑状态。比如mysql-0.mysql和mysql-1.mysql这样的DNS名字,其中编号为0的节点就是主节点。第二个Service是一个常规的Service。 规定: 所有的用户请求都必须访问第二个Service被自动分配的DNS记录,即mysql-read或者访问这个Service的VIP。这样读请求就可以被转发到任意一个MySQL的主节点或者从节点。 所有用户的写请求,则必须直接以DNS的方式访问到MySQL的主节点,也就是mysql-0.mysql这条DNS记录。 Kubernetes中所有的Service和pod对象,都会被自动分配同名的DNS记录。 0.4.2.2. 问题二:主从节点需要传输备份文件 推荐的做法:先搭建框架,再完善细节。其中Pod部分如何定义,是完善细节时的重点。 创建StatefulSet对象的大致框架如下: apiVersion: apps/v1 kind: StatefulSet metadata: name: mysql spec: selector: matchLabels: app: mysql serviceName: mysql replicas: 3 template: metadata: labels: app: mysql spec: initContainers: - name: init-mysql - name: clone-mysql containers: - name: mysql - name: xtrabackup volumes: - name: conf emptyDir: {} - name: config-map configMap: name: mysql volumeClaimTemplates: - metadata: name: data spec: accessModes: ["ReadWriteOnce"] resources: requests: storage: 10Gi 首先定义一些通用的字段: selector,表示这个StatefulSet要管理的Pod必须携带app=mysql这个label serviceName,声明这个StatefulSet要使用的Headless Servie的名字是mysql replicas,表示这个StatefulSet定义的MySQL集群有三个节点(一个主节点两个从节点) volumeClaimTemplate(PVC模板),还需要管理存储状态,通过PVC模板来为每个Pod创建PVC StatefulSet管理的“有状态应用”的多个实例,也是通过同一份Pod模板创建出来的,使用同样的Docker镜像。这就意味着,如果应用要求不同类型节点的镜像不一样,那就不能再使用StatefulSet,应该考虑使用Operator。 重点就是Pod部分的定义,也就是StatefulSet的template字段。 StatefulSet管理的Pod都来自同一个镜像,编写Pod时需要分别考虑这个pod的Master节点做什么,Slave节点做什么。 0.4.2.2.1. 第一步,从ConfigMap中,获取MySQL的Pod对应的配置文件 需要根据主从节点不同的角色进行相应的初始化操作,为每个Pod分配对应的配置文件。MySQL要求集群中的每个节点都要唯一的ID文件(server-id.cnf)。 初始化操作使用InitContainer完成,定义如下: ... # template.spec initContainers: - name: init-mysql image: mysql:5.7 command: - bash - "-c" - | set -ex # 从 Pod 的序号,生成 server-id [[ `hostname` =~ -([0-9]+)$ ]] || exit 1 ordinal=${BASH_REMATCH[1]} echo [mysqld] > /mnt/conf.d/server-id.cnf # 由于 server-id=0 有特殊含义,我们给 ID 加一个 100 来避开它 echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf # 如果 Pod 序号是 0,说明它是 Master 节点,从 ConfigMap 里把Master 的配置文件拷贝到 /mnt/conf.d/ 目录; # 否则,拷贝 Slave 的配置文件 if [[ $ordinal -eq 0 ]]; then cp /mnt/config-map/master.cnf /mnt/conf.d/ else cp /mnt/config-map/slave.cnf /mnt/conf.d/ fi volumeMounts: - name: conf mountPath: /mnt/conf.d - name: config-map mountPath: /mnt/config-map 这个初始化容器主要完成的初始化操作为: 从Pod的hostname里,读取到了Pod的序号,以此作为MySQL节点的server-id。 通过这个序号判断当前Pod的角色(序号为0表示为Master,其他为Slave),从而把对应的配置文件从/mnt/config-map目录拷贝到/mnt/conf.d目录下。 其中文件拷贝的源目录/mnt/config-map,就是CongifMap在这个Pod的Volume,如下所示: ... # template.spec volumes: - name: conf emptyDir: {} - name: config-map configMap: name: mysql 通过这个定义,init-mysql在声明了挂载config-map这个Volume之后,ConfigMap里保存的内容,就会以文件的方式出现在它的/mnt/config-map目录当中。 而文件拷贝的目标目录,即容器里的/mnt/conf.d/目录,对应的则是一个名叫conf的emptyDir类型的Volume。基于Pod Volume 共享的原理,当InitContainer复制完配置文件退出后,后面启动的MySQL容器只需要直接声明挂载这个名叫conf的Volume,它所需要的.cnf 配置文件已经出现在里面了。 0.4.2.2.2. 第二步:在Slave Pod启动前,从Master或其他Slave里拷贝数据库数据到自己的目录下 再定义一个初始化容器来完成这个操作: ... # template.spec.initContainers - name: clone-mysql image: gcr.io/google-samples/xtrabackup:1.0 command: - bash - "-c" - | set -ex # 拷贝操作只需要在第一次启动时进行,所以如果数据已经存在,跳过 [[ -d /var/lib/mysql/mysql ]] && exit 0 # Master 节点 (序号为 0) 不需要做这个操作 [[ `hostname` =~ -([0-9]+)$ ]] || exit 1 ordinal=${BASH_REMATCH[1]} [[ $ordinal -eq 0 ]] && exit 0 # 使用 ncat 指令,远程地从前一个节点拷贝数据到本地 ncat --recv-only mysql-$(($ordinal-1)).mysql 3307 | xbstream -x -C /var/lib/mysql # 执行 --prepare,这样拷贝来的数据就可以用作恢复了 xtrabackup --prepare --target-dir=/var/lib/mysql volumeMounts: - name: data mountPath: /var/lib/mysql subPath: mysql - name: conf mountPath: /etc/mysql/conf.d 这个初始化容器使用xtrabackup镜像(安装了xtrabackup工具),主要进行如下操作: 在它的启动命令里首先进行判断,当初始化所需的数据(/var/lib/mysql/mysql目录)已经存在,或者当前Pod是Master时,不需要拷贝操作。 使用Linux自带的ncat命令,向DNS记录为“mysql- <当前序号减1>.mysql”的Pod(即当前Pod的前一个Pod),发起数据传输请求,并且直接使用xbstream命令将收到的备份数据保存在/var/lib/mysql目录下。传输数据的方式包括scp、rsync等。 拷贝完成后,初始化容器还需要对/var/lib/mysql目录执行xtrabackup --prepare命令,目的是保证拷贝的数据进入一致性状态,这样数据才能被用作数据恢复。 3307是一个特殊的端口,运行着一个专门负责备份MySQL数据的辅助进程。 这个容器的/var/lib/mysql目录,实际上是一个名为data的PVC。这就保证哪怕宿主机服务器宕机,数据库的数据也不会丢失。 因为Pod的Volume是被Pod中的容器所共享的,所以后面启动的MySQL容器,就可以把这个Volume挂载到自己的/var/lib/mysql目录下,直接使用里面的备份数据进行恢复操作。 通过两个初始化容器完成了对主从节点配置文件的拷贝,主从节点间备份数据的传输操作。 注意,StatefulSet里面的所有Pod都来自同一个Pod模板,所以在定义MySQL容器的启动命令时,需要区分Master和Slave节点的不同情况。 直接启动Master角色没有问题 第一次启动的Slave角色,在执行MySQL启动命令之前,需要使用初始化容器拷贝的数据进行容器的初始化操作 容器是单进程模型,Slave角色的MySQL启动前,谁负责执行初始化SQL语句? 0.4.2.2.3. 第三步,Slave角色的MySQL容器启动前,执行初始化SQL语句 为这个MySQL容器定义一个额外的sidecar容器,来完成初始化SQL语句的操作: ... # template.spec.containers - name: xtrabackup image: gcr.io/google-samples/xtrabackup:1.0 ports: - name: xtrabackup containerPort: 3307 command: - bash - "-c" - | set -ex cd /var/lib/mysql # 从备份信息文件里读取 MASTER_LOG_FILEM 和 MASTER_LOG_POS 这两个字段的值,用来拼装集群初始化 SQLA if [[ -f xtrabackup_slave_info ]]; then # 如果 xtrabackup_slave_info 文件存在,说明这个备份数据来自于另一个 Slave 节点。 # 这种情况下,XtraBackup 工具在备份的时候,就已经在这个文件里自动生成了 "CHANGE MASTER TO" SQL 语句。 # 所以,我们只需要把这个文件重命名为 change_master_to.sql.in,后面直接使用即可 mv xtrabackup_slave_info change_master_to.sql.in # 所以,也就用不着 xtrabackup_binlog_info 了 rm -f xtrabackup_binlog_info elif [[ -f xtrabackup_binlog_info ]]; then # 如果只存在 xtrabackup_binlog_inf 文件,那说明备份来自于 Master 节点, # 我们就需要解析这个备份信息文件,读取所需的两个字段的值 [[ `cat xtrabackup_binlog_info` =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1 rm xtrabackup_binlog_info # 把两个字段的值拼装成 SQL,写入 change_master_to.sql.in 文件 echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\ MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in fi # 如果 change_master_to.sql.in,就意味着需要做集群初始化工作 if [[ -f change_master_to.sql.in ]]; then # 但一定要先等 MySQL 容器启动之后才能进行下一步连接 MySQL 的操作 echo "Waiting for mysqld to be ready (accepting connections)" until mysql -h 127.0.0.1 -e "SELECT 1"; do sleep 1; done echo "Initializing replication from clone position" # 将文件 change_master_to.sql.in 改个名字,防止这个 Container 重启的时候, # 因为又找到了 change_master_to.sql.in,从而重复执行一遍这个初始化流程 mv change_master_to.sql.in change_master_to.sql.orig # 使用 change_master_to.sql.orig 的内容, # 也是就是前面拼装的 SQL,组成一个完整的初始化和启动 Slave 的 SQL 语句 mysql -h 127.0.0.1 <<EOF $(<change_master_to.sql.orig), MASTER_HOST='mysql-0.mysql', MASTER_USER='root', MASTER_PASSWORD='', MASTER_CONNECT_RETRY=10; START SLAVE; EOF fi # 使用 ncat 监听 3307 端口。它的作用是,在收到传输请求的时候, # 直接执行 "xtrabackup --backup" 命令,备份 MySQL 的数据并发送给请求者 exec ncat --listen --keep-open --send-only --max-conns=1 3307 -c \ "xtrabackup --backup --slave-info --stream=xbstream --host=127.0.0.1 --user=root" volumeMounts: - name: data mountPath: /var/lib/mysql subPath: mysql - name: conf mountPath: /etc/mysql/conf.d 在这个sidecar容器的启动命令中,完成两部分工作。 0.4.2.2.3.1. 工作一:MySQL节点初始化 这个初始化需要的SQL是sidecar容器拼装出来、保存在名为change_master_to.sql.in的文件里的。具体过程如下: sidecar容器首先判断当前Pod的/var/lib/mysql目录下,是否有xtrabackup_slave_info这个备份信息文件。 如果有,说明这个目录下的备份数据库是由一个Slave节点生成的。这种情况下,xtrabackup工具在备份的时候,就已经在这个文件里生成了“CHANGE MASTER TO”SQL语句。所以只需要把这个文件名重命名为change_master_to.sql.in,然后直接使用即可。 如果没有,但是存在xtrabackup_binlog_info文件,那就说明备份数据来自Master节点。这种情况下,sidecar容器需要解析这个备份文件,读取MASTER_LOG_FILE和MASTER_LOG_POS这两个字段的值,用它们拼装出初始化SQL语句,然后把这句SQL写入change_master_to.sql.in文件中。 只要change_master_to.sql.in存在,那就说明下一个步骤是进行集群初始化操作。 sidecar容器执行初始化操作。即,读取并执行change_master_to.sql.in里面的“CHANGE MASTER TO”SQL语句,在执行START SLAVE命令,一个Slave角色就启动成功了。 Pod里面的容器没有先后顺序,所以在执行初始化SQL之前,必须先执行select 1来检查MySQL服务是否已经可用。 当初始化操作都执行完成后,需要删除前面用到的这些备份信息文件,否则下次这个容器重启时,就会发现这些文件已经存在,然后又重新执行一次数据恢复和集群初始化的操作,这就不对了。同样的change_master_to.sql.in在使用后也要被重命名,以免容器重启时因为发现这个文件而又执行一遍初始化。 0.4.2.2.3.2. 工作二:启动数据传输服务 sidecar容器使用ncat命令启动一个工作在3307端口上的网络发送服务。一旦收到数据传输请求时,sidecar容器就会调用xtrabackup --backup命令备份当前MySQL的数据,然后把备份数据返回给请求者。 这就是为什么在初始化容器里面定义数据拷贝的时候,访问的是上一个MySQL节点的3307端口。 sidecar容器和MySQL容器处于同一个Pod中,它们是直接通过localhost来访问和备份MySQL的数据的,非常方便。数据的备份方式有多种,也可使用innobackupex命令。 完成上述初始化操作后,定义的MySQL容器就比较简单,如下: ... # template.spec containers: - name: mysql image: mysql:5.7 env: - name: MYSQL_ALLOW_EMPTY_PASSWORD value: "1" ports: - name: mysql containerPort: 3306 volumeMounts: - name: data mountPath: /var/lib/mysql subPath: mysql - name: conf mountPath: /etc/mysql/conf.d resources: requests: cpu: 500m memory: 1Gi livenessProbe: exec: command: ["mysqladmin", "ping"] initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 readinessProbe: exec: # 通过 TCP 连接的方式进行健康检查 command: ["mysql", "-h", "127.0.0.1", "-e", "SELECT 1"] initialDelaySeconds: 5 periodSeconds: 2 timeoutSeconds: 1 使用MySQL官方镜像,数据目录/var/lib/mysql,配置文件目录/etc/mysql/conf.d。并且为容器定了livenessProbe,通过mysqladmin Ping命令来检查它是否健康。同时定义readinessProbe,通过SQL(select 1)来检查MySQL服务是否可用。凡是readinessProbe检查失败的Pod都会从Service中被踢除。 如果MySQL容器是Slave角色时,它的数据目录中的数据就是来自初始化容器从其他节点里拷贝而来的备份。它的配置目录里的内容则是是来自ConfigMap对应的Volume。它的初始化工作由sidecar容器完成。 0.4.3. 创建PV 使用Rook存储插件创建PV: kubectl create -f rook-storage.yaml cat rook-storage.yaml apiVersion: ceph.rook.io/v1beta1 kind: Pool metadata: name: replicapool namespace: rook-ceph spec: replicated: size: 3 --- apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: rook-ceph-block provisioner: ceph.rook.io/block parameters: pool: replicapool clusterNamespace: rook-ceph 在这里使用到StorageClass来完成这个操作,它的作用是自动地为集群里存在的每个PVC调用存储插件创建对应的PV,从而省去了手动创建PV的过程。 在使用Rook时,在MySQL的StatefulSet清单文件中的volumeClaimTemplates字段需要加上声明storageClassName=rook-ceph-block,这样才能使用Rook提供的持久化存储。 0.4.4. 小结 “人格分裂”:在解决需求的过程中,一定要记得思考,该Pod在扮演不同角色时的不同操作。 “阅后即焚”:很多“有状态应用”的节点,只是在第一次启动的时候才需要做额外处理。所以,在编写YAML文件时,一定要考虑到容器重启的情况,不能让这一次的操作干扰到下一次容器启动。 “容器之间平等无序”:除非InitContainer,否则一个Pod里的多个容器之间,是完全平等的。所以,镜像设计的sidecar,绝不能对容器的启动顺序做出假设,否则就需要进行前置检查。 StatefulSet就是一个特殊的Deployment,只是这个“Deployment”的每个Pod实例的名字里,都携带了一个唯一并且固定的编号。 这个编号的顺序,固定了Pod之间的拓扑关系; 这个编号对应的DNS记录,固定了Pod的访问方式; 这个编号对应的PV,绑定了Pod与持久化存储的关系。 所有,当Pod被删除重建时,这些“状态”都会保持不变。 如果应用没办法通过上述方式进行状态的管理,就代表StatefulSet已经不能解决它的部署问题,Operator可能是一个更好的选择。
0.1. 例子 0.2. Operator工作原理 0.3. Etcd集群的构建方式 0.4. Etcd Operator构建过程 0.5. 编写EtcdCluster这个CRD 0.6. Operator创建集群 0.6.1. 启动种子节点 0.6.2. 添加普通节点 0.7. Etcd Operator工作原理 0.8. Operator与StatefulSet对比 管理有状态应用的另一个解决方方案:Operator。 0.1. 例子 Etcd Operator。 克隆仓库 git clone https://github.com/coreos/etcd-operator 部署Operator $ example/rbac/create_role.sh # 为Etcd Operator创建RBAC规则, # 因为Etcd Operator需要访问APIServer 具体的为Etcd OPerator定义了如下所示的权限: 具有Pod、Service、PVC、Deployment、Secret等API对象的所有权限 具有CRD对象的所有权限 具有属于etcd.database.coreos.com这个API Group的CR对象的所有权限 Etcd Operator本身是一个Deployment,如下所示: apiVersion: extensions/v1beta1 kind: Deployment metadata: name: etcd-operator spec: replicas: 1 template: metadata: labels: name: etcd-operator spec: containers: - name: etcd-operator image: quay.io/coreos/etcd-operator:v0.9.2 command: - etcd-operator env: - name: MY_POD_NAMESPACE valueFrom: fieldRef: fieldPath: metadata.namespace - name: MY_POD_NAME valueFrom: fieldRef: fieldPath: metadata.name ... 创建这个Etcd Operator: $ kubectl create -f example/deployment.yaml $ kubectl get pods NAME READY STATUS RESTARTS AGE etcd-operator-649dbdb5cb-bzfzp 1/1 Running 0 20s $ kubectl get crd NAME CREATED AT etcdclusters.etcd.database.coreos.com 2018-09-18T11:42:55Z 有一个名叫etcdclusters.etcd.database.coreos.com的CRD被创建,查看它的具体内容: $ kubectl describe crd etcdclusters.etcd.database.coreos.com ... Group: etcd.database.coreos.com Names: Kind: EtcdCluster List Kind: EtcdClusterList Plural: etcdclusters Short Names: etcd Singular: etcdcluster Scope: Namespaced Version: v1beta2 ... 这个CRD告诉kubernetes集群,如果有API组(Group)是etcd.database.coreos.com,API资源类型(Kind)是EtcdCluster的YAML文件被提交时,就能够认识它。 上述操作是在集群中添加了一个名叫EtcdCluster的自定义资源类型,Etcd Operator本身就是这个自定义资源类型对应的自定义控制器。 Etcd Operator部署好之后,在集群中创建Etcd集群的工作就直接编写EtcdCluster的YAML文件就可以,如下: $ kubectl apply -f example/example-etcd-cluster.yaml # example-etcd-cluster.yaml文件描述了3个节点的Etcd集群 $ kubectl get pods NAME READY STATUS RESTARTS AGE example-etcd-cluster-dp8nqtjznc 1/1 Running 0 1m example-etcd-cluster-mbzlg6sd56 1/1 Running 0 2m example-etcd-cluster-v6v6s6stxd 1/1 Running 0 2m 具体看一下example-etcd-cluster.yaml的文件内容,如下: apiVersion: "etcd.database.coreos.com/v1beta2" kind: "EtcdCluster" metadata: name: "example-etcd-cluster" spec: size: 3 version: "3.2.13" 这个yaml文件的内容很简单,只有集群节点数3,etcd版本3.2.13,具体创建集群的逻辑有Etcd Operator完成。 0.2. Operator工作原理 利用kubernetes的自定义API资源(CRD)来描述需要部署的有状态应用 在自定义控制器里,根据自定义API对象的变化,来完成具体的部署和运维工作 编写Operator和编写自定义控制器的过程,没什么不同。 0.3. Etcd集群的构建方式 Etcd Operator部署Etcd集群,采用的是静态集群(Static)的方式。 静态集群: 好处:它不必依赖于一个额外的服务发现机制来组建集群,非常适合本地容器化部署。 难点:必须在部署的时候就规划好这个集群的拓扑结构,并且能够知道这些节点固定的IP地址,如下所示。 $ etcd --name infra0 --initial-advertise-peer-urls http://10.0.1.10:2380 \ --listen-peer-urls http://10.0.1.10:2380 \ ... --initial-cluster-token etcd-cluster-1 \ --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \ --initial-cluster-state new $ etcd --name infra1 --initial-advertise-peer-urls http://10.0.1.11:2380 \ --listen-peer-urls http://10.0.1.11:2380 \ ... --initial-cluster-token etcd-cluster-1 \ --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \ --initial-cluster-state new $ etcd --name infra2 --initial-advertise-peer-urls http://10.0.1.12:2380 \ --listen-peer-urls http://10.0.1.12:2380 \ ... --initial-cluster-token etcd-cluster-1 \ --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \ --initial-cluster-state new 启动三个Etcd进程,组建三节点集群。当infra2节点启动后,这个Etcd集群中就会有infra0、infra1、infra2三个节点。节点的启动参数-initial-cluster正是当前节点启动时集群的拓扑结构,也就是当前界定在启动的时候,需要跟那些节点通信来组成集群。 --initial-cluster参数是由“<节点名字>=<节点地址>”格式组成的一个数组。 --listen-peer-urls参数表示每个节点都通过2380端口进行通信,以便组成集群。 --initial-cluster-token字段,表示集群独一无二的Token。 编写Operator就是要把上述对每个节点进行启动参数配置的过程自动化完成,即使用代码生成每个Etcd节点Pod的启动命令,然后把它们启动起来。 0.4. Etcd Operator构建过程 0.5. 编写EtcdCluster这个CRD CRD对应的内容在types.go文件中,如下所示: // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object type EtcdCluster struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` Spec ClusterSpec `json:"spec"` Status ClusterStatus `json:"status"` } type ClusterSpec struct { // Size is the expected size of the etcd cluster. // The etcd-operator will eventually make the size of the running // cluster equal to the expected size. // The vaild range of the size is from 1 to 7. Size int `json:"size"` ... } EtcdCluster是一个有Status字段的CRD,在Spec中只需要关心Size(集群的大小)字段,这个字段意味着需要调整集群大小时,直接修改YAML文件即可,Operator会自动完成Etcd节点的增删操作。 这种scale能力,也是Etcd Operator自动化运维Etcd集群需要实现的主要功能。为了实现这个功能,不能在--initial-cluster参数中把拓扑结构固定死。所有Etcd Operator在构建集群时,虽然也是静态集群,但是是通过逐个节点动态添加的方式实现。 0.6. Operator创建集群 Operator创建“种子节点” Operator创建新节点,逐一加入集群中,直到集群节点数等于size 生成不同的Etcd Pod时,Operator要能够区分种子节点和普通节点,这两个节点的不同之处在--initial-cluster-state这个启动参数: 参数值设为new,表示为种子节点,种子节点不需要通过--initial-cluster-token声明独一无二的Token 参数值为existing,表示为普通节点,Operator将它加入已有集群 需要注意,种子节点启动时,集群中只有一个节点,即--initial-cluster参数的值为infra0=<http://10.0.1.10:2380>,其他节点启动时,节点个数依次增加,即--initial-cluster参数的值不断变化。 0.6.1. 启动种子节点 用户提交YAML文件声明要创建EtcdCluster对象,Etcd Operator先创建一个单节点的种子集群,并启动它,启动参数如下: $ etcd --data-dir=/var/etcd/data --name=infra0 --initial-advertise-peer-urls=http://10.0.1.10:2380 --listen-peer-urls=http://0.0.0.0:2380 --listen-client-urls=http://0.0.0.0:2379 --advertise-client-urls=http://10.0.1.10:2379 --initial-cluster=infra0=http://10.0.1.10:2380 # 目前集群只有一个节点 --initial-cluster-state=new # 参数值为new表示是种子节点 --initial-cluster-token=4b5215fa-5401-4a95-a8c6-892317c9bef8 # 种子节点需要唯一指定token 这个创建种子节点的阶段称为:Bootstrap。 0.6.2. 添加普通节点 对于其他每个节点,Operator只需要执行如下两个操作即可: # 通过Etcd命令行添加新成员 $ etcdctl member add infra1 http://10.0.1.11:2380 # 为每个成员节点生成对应的启动参数,并启动它 $ etcd --data-dir=/var/etcd/data --name=infra1 --initial-advertise-peer-urls=http://10.0.1.11:2380 --listen-peer-urls=http://0.0.0.0:2380 --listen-client-urls=http://0.0.0.0:2379 --advertise-client-urls=http://10.0.1.11:2379 --initial-cluster=infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380 #目前集群有两个节点 --initial-cluster-state=existing # 参数值为existing表示为普通节点,并且不需要唯一的token 继续添加,直到集群数量变成size为止。 0.7. Etcd Operator工作原理 与其他自定义控制器一样,Etcd Operator的启动流程也是围绕Informer,如下: func (c *Controller) Start() error { for { err := c.initResource() ... time.Sleep(initRetryWaitTime) } c.run() } func (c *Controller) run() { ... _, informer := cache.NewIndexerInformer(source, &api.EtcdCluster{}, 0, cache.ResourceEventHandlerFuncs{ AddFunc: c.onAddEtcdClus, UpdateFunc: c.onUpdateEtcdClus, DeleteFunc: c.onDeleteEtcdClus, }, cache.Indexers{}) ctx := context.TODO() // use workqueue to avoid blocking informer.Run(ctx.Done()) } Etcd Operator: 第一步,创建EtcdCluster对象所需的CRD,即etcdclusters.etcd.database.coreos.com 第二步,定义EtcdCluster对象的Informer 注意,Etcd Operator并没有使用work queue来协调Informer和控制循环。 因为在控制循环中执行的业务逻辑(如创建Etcd集群)往往比较耗时,而Informer的WATCH机制对API对象变化的响应,非常迅速。所以控制器里的业务逻辑会拖慢Informer的执行周期,甚至可能block它,要协调快慢任务典型的解决方案,就是引入工作队列。 在Etcd Operator里没有工作队列,在它的EventHandler部分,就不会有入队的操作,而是直接就是每种事件对应的具体的业务逻辑。Etcd Operator在业务逻辑的实现方式上,与常规自定义控制器略有不同,如下所示: 不同之处在于,Etcd Operator为每一个EtcdCluster对象都启动一个控制循环,并发地响应这些对象的变化。这样不仅可以简化Etcd Operator的代码实现,还有助于提高响应速度。 0.8. Operator与StatefulSet对比 StatefulSet里,它为Pod创建的名字是带编号的,这样就把整个集群的拓扑状态固定,而在Operator中名字是随机的 Etcd Operator在每次添加节点或删除节点时都执行etcdctl命令,整个过程会更新Etcd内部维护的拓扑信息,所以不需要在集群外部通过编号来固定拓扑关系。 在Operator中没有为EtcdCluster对象声明Persistent Volume,在节点宕机时,是否会导致数据丢失? Etcd是一个基于Raft协议实现的高可用键值对存储,根据Raft协议的设计原则,当Etcd集群里只有半数以下的节点失效时,当前集群依然可用,此时,Etcd Operator只需要通过控制循环创建出新的Pod,然后加入到现有集群中,就完成了期望状态和实际状态的调谐工作。 当集群中半数以上的节点失效时,这个集群就会丧失数据写入能力,从而进入“不可用”状态,此时,即使Etcd Operator 创建出新的Pod出来,Etcd集群本身也无法自动恢复起来。这个时候就必须使用Etcd本身的备份数据(由单独的Etcd Backup Operator完成)来对集群进行恢复操作。 创建和使用Etcd Backup Operator的过程: # 首先,创建 etcd-backup-operator $ kubectl create -f example/etcd-backup-operator/deployment.yaml # 确认 etcd-backup-operator 已经在正常运行 $ kubectl get pod NAME READY STATUS RESTARTS AGE etcd-backup-operator-1102130733-hhgt7 1/1 Running 0 3s # 可以看到,Backup Operator 会创建一个叫 etcdbackups 的 CRD $ kubectl get crd NAME KIND etcdbackups.etcd.database.coreos.com CustomResourceDefinition.v1beta1.apiextensions.k8s.io # 我们这里要使用 AWS S3 来存储备份,需要将 S3 的授权信息配置在文件里 $ cat $AWS_DIR/credentials [default] aws_access_key_id = XXX aws_secret_access_key = XXX $ cat $AWS_DIR/config [default] region = <region> # 然后,将上述授权信息制作成一个 Secret $ kubectl create secret generic aws --from-file=$AWS_DIR/credentials --from-file=$AWS_DIR/config # 使用上述 S3 的访问信息,创建一个 EtcdBackup 对象 $ sed -e 's|<full-s3-path>|mybucket/etcd.backup|g' \ -e 's|<aws-secret>|aws|g' \ -e 's|<etcd-cluster-endpoints>|"http://example-etcd-cluster-client:2379"|g' \ example/etcd-backup-operator/backup_cr.yaml \ | kubectl create -f - 注意,每次创建一个EtcdBackup对象,就相当于为它所指定的Etcd集群做了一次备份。EtcdBackup对象的etcdEndpoints字段,会指定它要备份的Etcd集群的访问地址。在实际环境中,可以把备份操作编写成一个CronJob。 当Etcd集群发生故障时,可以通过创建一个EtcdRestore对象来完成恢复操作。需要事先创建Etcd Restore Operator,如下: # 创建 etcd-restore-operator $ kubectl create -f example/etcd-restore-operator/deployment.yaml # 确认它已经正常运行 $ kubectl get pods NAME READY STATUS RESTARTS AGE etcd-restore-operator-4203122180-npn3g 1/1 Running 0 7s # 创建一个 EtcdRestore 对象,来帮助 Etcd Operator 恢复数据,记得替换模板里的 S3 的访问信息 $ sed -e 's|<full-s3-path>|mybucket/etcd.backup|g' \ -e 's|<aws-secret>|aws|g' \ example/etcd-restore-operator/restore_cr.yaml \ | kubectl create -f - 当一个EtcdRestore对象创建成功之后,Etcd Restore Operator就会通过上述信息,恢复出一个全新的Etcd集群,然后Etcd Operator会把这个新的集群直接接管从而重新进入可用状态。
0.1. 举一些例子 0.2. 例子yaml 0.3. 如何保证每个Node上有且仅有一个被管理的Pod 0.3.1. 检查结果有三种情况 0.3.2. DaemonSet如何比其他pod运行的早 0.4. 技巧 0.5. 版本管理(ControllerRevision) 主要作用是让Kubernetes集群中运行一个Daemon Pod,这个Pod有如下三个特征: 这个Pod运行在Kubernetes集群里的每一个节点(Node)上 每个节点上只有一个这样的Pod实例 当有新节点计入Kubernetes集群后,该Pod会自动地在新节点上被创建出来;而当旧节点被删除后,它上面的Pod也相应地会被回收掉 0.1. 举一些例子 各种网络插件的Agent组件,都必须运行在每一个节点上,用来处理这个节点上的容器网络 各种存储插件的Agent组件,也必须运行在每一个节点上,用来在这个节点上挂载远程存储目录,操作容器的Volume目录 各种监控组件和日志组件,也必须运行在每一个节点上,负责这个节点上的监控信息和日志搜集 更重要的是,与其他编排对象不同,DaemonSet开始运行的时机,很多时候比整个kubernetes集群出现的时机都要早。 例如这个DaemonSet是网络存储插件的Agent组件,在整个kubernetes集群中还没有可用的容器网络时,所有的worker节点的状态都是NotReady。这个时候普通的Pod肯定不能运行的,所以DaemonSet要先于其他的。 0.2. 例子yaml 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/lin/docker/containers目录。 fluented启动后,它会从这两个目录里搜集日志信息,并转发给ElasticSearch保存,这样就可有通过ElasticSearch方便地检索这些日志了。 注意,Docker容器里应用的日志,默认会保存在宿主机的/var/lib/docker/containers/{{.容器ID}}/{{.容器ID}}-json.log文件里,这个目录就是fluented搜集的目标之一。 0.3. 如何保证每个Node上有且仅有一个被管理的Pod DaemonSet Controller首先从Etcd里获取所有的Node列表 遍历所有的Node,遍历的过程中可以检查当前节点上是否有携带了对应标签的Pod在运行。 0.3.1. 检查结果有三种情况 没有被管理的pod,所以需要在这个节点上新建一个 有被管理的pod,但是数量超过1,直接调用kubernetes API这个节点上删除多余的pod 有且只有一个,整个节点很正常 第一种情况,新建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中需要绑定的节点名字,正是当前正在遍历的这个节点。 DaemonSet并不修改用户提交的YAML文件里的Pod模板,而是在向kubernetes发起请求之前,直接修改根据模板生成的Pod对象。 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启动成功。 0.3.2. DaemonSet如何比其他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。 0.4. 技巧 可以在Pod的模板中添加更多种类的Toleration,从而利用DaemonSet实现自己的目的。比如添加下面的容忍: tolerations: - key: node-role.kubernetes.io/master effect: NoSchedule 这样的话pod可以被调度到主节点,默认主节点有“node-role.kubernetes.io/master”的污点,pod是不能运行的。 一般在DaemonSet上都要加上resource字段,来限制CPU和内存的使用,防止占用过多的宿主机资源。 0.5. 版本管理(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进行控制。 Kubernetes v1.7 之后添加的API对象,ControllerRevision专门用来记录某种Controller对象的版本。 查看对应的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命令。
0.1. Job 0.1.1. 来个例子 0.1.2. 控制器模式 0.1.3. 失败重启策略 0.1.4. 最长运行时间 0.1.5. 并行运行 0.1.5.1. 举个例子 0.2. 常见的Job使用方法 0.3. 外部管理器+Job模板 0.3.1. 拥有固定任务数目的并行Job 0.3.2. 指定并行度,但不设定completions 0.4. CronJob Deployment、StatefulSet、DaemonSet这三种编排概念,主要编排的对象是“在线业务”(即Long Running Task长作业)。比如Nginx、MySQL等等。这行应用一旦运行起来,除非出错或者停止,它的容器进程会一直保持在Running状态。 但是,有一类作业显然不满足这个情况,就是“离线业务”(即Batch Job计算任务),这种任务在计算完成后就直接退出了,而此时如果你依然用Deployment来管理这类作业,就会发现Pod计算任务结束后退出,然后Controller不断重启这个任务,向“滚动更新”这样的功能就更不需要了。 0.1. Job 0.1.1. 来个例子 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里面的容器 0.1.2. 控制器模式 通过describe可以看到,这个Job对象在创建后,它的Pod模板,被自动添加上了一个controller-uid=<一个随机字符串> 这样的label。而这个Job对象本身,则被自动加上了这个Label对应的Selector,从而保证了Job与它所管理的Pod之间的匹配关系。 Job Controller使用这种携带UID的label的方式,是为了避免不同Job对象所管理的Pod发生重合。这种自动生成的Label对用户来说很不友好,所以不适合推广到Deployment等长作业编排对象上。 0.1.3. 失败重启策略 restartPolicy在Job对象中只能被设置为Never或者OnFailure 在Job的对象中添加spec.backoffLimit字段来定义重试的次数,默认为6次(即backoffLimit=6)。 需要注意,重新创建Pod或者重启Pod的间隔是呈指数增长的,即下一次重新创建Pod的动作会分别发生在10s、20s、40s。。。 0.1.4. 最长运行时间 当Job正常运行结束后,Pod处于Completed状态,如果Pod因为某种原因一直处于运行状态,则可以设置spec.activeDeadlineSeconds字段来设置最长运行时间,比如: spec: backoffLimit: 5 activeDeadlineSeconds: 100 运行超过100s这个Job的所有Pod都会终止,并且在Pod的状态里看到终止的原因是reason:DeadlineExceeded。 0.1.5. 并行运行 在Job对象中,负责并行控制的参数有两个: spec.parallelism:定义的是一个Job在任意时间最多可以启动多少个Pod同时运行 spec.completions:定义的是Job至少完成的Pod数目,即Job最小完成数 0.1.5.1. 举个例子 # 添加最大并行数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)操作,是根据: 实际在Running状态Pod的数目 已经成功退出的Pod数目 parallelism、completions参数的值 共同计算出在这个周期里,应该创建或者删除的Pod数目,然后调用Kubernetes API来执行这个操作。 Job Controller 实际上控制了作业执行的并行度和总共需要完成的任务数这两个重要的参数。在实际使用中,需要根据作业的特性,来决定并行度和任务数的合理取值。 0.2. 常见的Job使用方法 0.3. 外部管理器+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时,只需要注意两个方面: 创建Job时替换掉$ITEM这样的变量 所有来自同一个模板的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)。 0.3.1. 拥有固定任务数目的并行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计算完成,对应的就是“任务总数固定”的场景。 0.3.2. 指定并行度,但不设定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之间是不是有资源的竞争和协同等。 0.4. 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文件中,最重要的是jobTemplate,CronJob是一个Job对象的控制器。它创建和删除Job的依据是schedule字段定义的、一个标准UNIX Cron格式的表达式。 Cron表达式中的五个部分分别代表:分钟、小时、日、月、星期。 CronJob对象会记录每次Job执行的时间。 由于定时任务的特殊性,很可能某个Job还没有执行完成,另外一个新job就产生了,这时候可以通过spec.concurrencyPolicy字段来定义具体的处理策略,如: concurrencyPolicy=Allow,默认的情况,这些Job可以同时存在 concurrencyPolicy=Forbid,不会创建新的Pod,该创建周期被跳过 concurrencyPolicy=Replace,新产生的Job会替换旧的,没有执行完的Job 如果某一次Job创建失败,就会被标记为“miss”。当在指定的时间窗口(通过字段spec.startingDeadlineSeconds字段指定,单位为秒)内,miss数目达到100时,那个Cronjob会停止再创建这个Job。
0.1. API对象 0.1.1. 命令式命令行操作 0.1.2. 声明式API 0.1.2.1. 定义 0.2. 编程范式 0.3. 实际使用的意义 0.3.1. 例子 0.3.1.1. 容器在Istio中如何使用 0.3.2. Initializer 0.3.2.1. Initializer的工作逻辑 0.3.2.2. 配置Initializer操作 0.3.2.2.1. 创建InitializerConfiguration文件 0.3.2.2.2. 添加annotation字段 0.1. API对象 用于描述应用 为应用提供各种各样的服务 为了使用这些API对象提供的能力,需要编写一个对应的YAML文件交给Kubernetes。这个YAML文件是kubernetes声明式API所必须具备的一个要素。 0.1.1. 命令式命令行操作 容器的编排操作都是基于命令行。 比如,要修改容器的镜像: 通过kubectl set image和kubectl edit命令直接修改kubernetes里面的API对象。 通过修改本地YAML文件,然后使用kubectl replace命令完成镜像的更新操作。 第二种方法,基于YAML文件的操作就是“声明式API”吗?显然不是。第二种方式称为命令式配置文件操作。处理方式与第一种没有区别,只不过把命令行参数写在配置文件中。 0.1.2. 声明式API kubectl apply命令,就是声明式API。 执行的操作 声明式API 命令式操作 异同 创建容器 kubectl apply kubectl create 效果一样 修改容器配置 kubectl apply kubectl replace apply都可以,create需要更换命令才行 这两者的本质区别是什么? create到replace是使用新的YAML文件中的API对象替换原有的API对象 apply 是执行一个对原有API对象的PATCH操作 kube-apiserver在响应命令式请求(如 kubectl replace)的时候,一次只能处理一个写请求,否则会产生冲突的可能 对于声明式请求(如 kubectl apply),一次能处理多个写操作,并且具备Merge能力 0.1.2.1. 定义 需要提交一个定义好的API对象来“声明”,我们所期望的状态是什么样子 声明式API允许有多个API写端,以PATCH的方式对API对象进行修改,而无需关心本地原始YAML文件的内容 在完全无需外界干预的情况下,基于对API对象的增删改查,完成对“实际状态”和“期望状态的调谐(Reconcile)过程。 声明式API才是kubernetes项目编排能力“赖以生存”的核心所在。 0.2. 编程范式 kubernetes编程范式:如何使用控制器模式,同kubernetes里API对象的“增删改查”进行协作,进而完成用户业务逻辑的编写过程。 0.3. 实际使用的意义 Istio项目,实际上是一个基于kubernetes项目的微服务治理框架。架构如下所示: Istio最根本的组件,是运行在每一个应用Pod里的Envoy容器(网络代理)。把每个代理服务以sidecar容器的方式,运行在了每一个被治理的应用Pod中。 Envoy容器是Lyft公司推出的一个高性能C++网络代理。Pod中的所有容器都共享同一个Network Namespace。所以Enovy容器就能够通过配置Pod的iptables规则,把整个Pod的进出流量都接管下来。 Istio的控制层(Control Plane)里的Pilot组件,就能够通过调用每个Envoy容器的API,对整个Envoy代理进行配置,从而实现微服务治理。 0.3.1. 例子 假设这个 Istio 架构图左边的 Pod 是已经在运行的应用,而右边的 Pod 则是我们刚刚上线的应用的新版本。 这时候,Pilot 通过调节这两 Pod 里的 Envoy 容器的配置,从而将 90% 的流量分配给旧版本的应用,将 10% 的流量分配给新版本应用,并且,还可以在后续的过程中随时调整。 这样,一个典型的“灰度发布”的场景就完成了。 比如,Istio 可以调节这个流量从90%-10%,改到 80%-20%,再到 50%-50%,最后到 0%-100%,就完成了这个灰度发布的过程。 更重要的是,整个微服务治理的过程中,无论是对Envoy容器的部署,还是对Envoy代理的配置,用户和应用都是“无感”的。 Istio项目使用,kubernetes中的Dynamic Admission Control功能,实现上述的“无感”操作。在kubernetes项目中,当一个Pod或者任何API对象被提交给APIServer之后,总有一些“初始化“性质的工作(如,自动为所有Pod加上某些标签)需要在它们被kubernetes项目正式处理之前进行。 初始化操作的实现借助的是Admission功能,它是kubernetes项目里一组被成为Admission Controller的代码,可以选择性地被编译进APIServer中,在API对象创建之后被立刻调用到。 当需要使用Admission Controller的时候,需要重新编译并启动APIServer,通过这种方式很麻烦,因此提供了一种”热插拔“的Admission机制,就是Dynamic Admission Controller,也叫作Initializer。 0.3.1.1. 容器在Istio中如何使用 如下pod: apiVersion: v1 kind: Pod metadata: name: myapp-pod labels: app: myapp spec: containers: - name: myapp-container image: busybox command: ['sh', '-c', 'echo Hello Kubernetes! && sleep 3600'] pod中有一个容器,Istio项目需要完成的是,在这个Pod YAML被提交给kubernetes后,在它对应的API对象中自动加上Envoy容器的配置,编程如下: apiVersion: v1 kind: Pod metadata: name: myapp-pod labels: app: myapp spec: containers: - name: myapp-container image: busybox command: ['sh', '-c', 'echo Hello Kubernetes! && sleep 3600'] - name: envoy image: lyft/envoy:845747b88f102c0fd262ab234308e9e22f693a1 command: ["/usr/local/bin/envoy"] ... 被Istio处理后,Pod中除了有一个自定义的容器还会有一个叫envoy的容器,它就是Istio要使用的Envoy代理。 如何在用户和应用无感的情况下,完成上述操作? Istio编写一个用来给Pod”自动注入“Envoy容器的Initializer。 0.3.2. Initializer Istio将这个容器本身的定义,以ConfigMap(名字叫envoy-initializer)的方式保存在kubernetes当中。 apiVersion: v1 kind: ConfigMap metadata: name: envoy-initializer data: config: | containers: - name: envoy image: lyft/envoy:845747db88f102c0fd262ab234308e9e22f693a1 command: ["/usr/local/bin/envoy"] args: - "--concurrency 4" - "--config-path /etc/envoy/envoy.json" - "--mode serve" ports: - containerPort: 80 protocol: TCP resources: limits: cpu: "1000m" memory: "512Mi" requests: cpu: "100m" memory: "64Mi" volumeMounts: - name: envoy-conf mountPath: /etc/envoy volumes: - name: envoy-conf configMap: name: envoy 这个ConfigMap包括两部分,containers字段和volumes字段。 Initializer要完成的工作是把这部分Envoy相关的字段,自动添加到用户提交的Pod的API对象里。用户提交的Pod中本来就有containers和volumes字段,所有kubernetes在处理这样的更新请求的时,类似于git merge的操作,将两部分内容合并在一起。 在Initializer更新用户的Pod对象时,必须用到PATCH API,这正式声明式API最主要的能力。 Istio将一个编写好的Initializer,作为一个Pod部署在kubernetes中,这个Pod的定义如下: apiVersion: v1 kind: Pod metadata: labels: app: envoy-initializer name: envoy-initializer spec: containers: - name: envoy-initializer image: envoy-initializer:0.0.1 imagePullPolicy: Always 这是一个事先编写好的“自定义控制器(custom controller)”。这个Initializer控制器,不断获取到“实际状态”(就是用户创建的Pod),它的“期望状态”就是在这个Pod中添加Envoy容器的定义。 在Kubernetes中,一个控制器,就是一个死循环,不断地获取“实际状态”,然后与“期望状态”作对比,并以此为依据决定下一步操作。如下所示。 for { // 获取新创建的 Pod pod := client.GetLatestPod() // Diff 一下,检查是否已经初始化过 if !isInitialized(pod) { // 没有?那就来初始化一下 doSomething(pod) } } 如果这个Pod中已经添加过Envoy容器,那就放过这个pod,进入下一个检查周期 如果还没有添加Envoy容器,那就进行Initializer操作,即修改这个Pod的API对象 0.3.2.1. Initializer的工作逻辑 从APIServer中获取ConfigMap 把ConfigMap中存储的containers和volumes字段,直接添加到一个空的Pod对象里 kubernetes的API库中,有一个方法,使得我们可以直接使用新旧两个Pod对象,生成一个TwoWayMergePatch。 Initializer使用这个TwoWayMergePatch的patch数据,调用kubernetes的client,发起一个PATCH请求 用户提交的Pod对象里,就自动加上了Envoy容器相关的字段 // 步骤一 func doSomething(pod) { cm := client.Get(ConfigMap, "envoy-initializer") } // 步骤二 func doSomething(pod) { cm := client.Get(ConfigMap, "envoy-initializer") newPod := Pod{} newPod.Spec.Containers = cm.Containers newPod.Spec.Volumes = cm.Volumes } // 步骤三 func doSomething(pod) { cm := client.Get(ConfigMap, "envoy-initializer") newPod := Pod{} newPod.Spec.Containers = cm.Containers newPod.Spec.Volumes = cm.Volumes // 生成 patch 数据 patchBytes := strategicpatch.CreateTwoWayMergePatch(pod, newPod) // 发起 PATCH 请求,修改这个 pod 对象 client.Patch(pod.Name, patchBytes) } 0.3.2.2. 配置Initializer操作 0.3.2.2.1. 创建InitializerConfiguration文件 通过配置,来指定对什么样的资源进行Initializer操作,如下例子: apiVersion: admissionregistration.k8s.io/v1alpha1 kind: InitializerConfiguration metadata: name: envoy-config initializers: // 这个名字必须至少包括两个 "." - name: envoy.initializer.kubernetes.io rules: - apiGroups: - "" // "" 就是 core API Group 的意思 apiVersions: - v1 resources: - pods 这个配置意味着kubernetes对所有pod进行Initializer操作,并且指定了负责这个操作的Initializer叫envoy-initializer。 这个InitializerConfiguration创建后,kubernetes就会自动把这个Initializer的名字,加到每个新创建的Pod的metadata中,如下所示: apiVersion: v1 kind: Pod metadata: initializers: pending: - name: envoy.initializer.kubernetes.io name: myapp-pod labels: app: myapp ... 每个新创建的Pod都会自动携带metadata.initializers.pending的metadata信息。 这个metadata信息,就是Initializer控制器判断这个Pod有没有执行过自己所负责的初始化操作的重要依据。 当在Initializer中完成了要做的操作后,一定要将这个metadata.initializers.pending标志清除。 0.3.2.2.2. 添加annotation字段 除了创建配置文件,也可在具体的Pod的annotation里添加一个字段,如下所示: apiVersion: v1 kind: Pod metadata annotations: "initializer.kubernetes.io/envoy": "true" ... 添加后,就会使用到前面定义的envoy-Initializer。 Istio项目的核心就是由无数个运行在应用Pod中的Envoy容器组成的服务代理网格。这也就是server mesh的含义。这个机制实现的原理是借助于kubernetes能够对API对象进行在线更新的能力,这就是kubernetes“声明式API”的独特之处。 Istio项目对sidecar容器的巧妙设计,对Initializer操作的合理利用,都依托于kubernetes的声明式API和它所提供的各种编排能力。Istio项目是kubernetes的集大成者。 Istio项目部署完成,会在kubernetes里创建大约43个API对象。
0.1. 工作原理 0.1.1. 例子 0.1.2. 解析过程 0.1.2.1. 匹配Group 0.1.2.1.1. 核心API 0.1.2.1.2. 非核心API 0.1.2.2. 匹配Version 0.1.2.3. 匹配Resource 0.2. 添加自定义API对象 0.2.1. 第一步,让kubernetes认识这个自定义的API对象 0.2.2. 例子 0.2.3. 第二步,让kubernetes认识自定义的API对象中的字段 0.3. 为自定义API对象编写控制器 0.3.1. main函数 0.3.2. 自定义控制器的工作原理 0.3.2.1. 第一步:自定义控制器从APIServer里获取它所关心的对象 0.3.2.2. 第二步:根据事件类型触发事先注册好的ResourceEventHandler 0.3.2.3. 第三步:循环控制 0.1. 工作原理 在kubernetes项目中,一个API对象在Etcd里的完整资源路径,由: Group(API组) Version(API版本) Resource(API资源类型) 三部分组成。 通过这样的结构,整个Kubernetes里的所有API对象,实际上就是如下的树形结构: API对象的组织方式是层层递进的。 0.1.1. 例子 apiVersion: batch/v2alpha1 kind: CronJob ... 上面的例子中: Group(API组): batch Version(API版本): v2alpha1 Resource(API资源类型): CronJob 提交给kubernetes后,平台就把YAML文件中描述的内容转换成kubernetes里的一个CronJob对象。 0.1.2. 解析过程 那么,如何对Resource、Group、Vsersion进行解析,从而得到Kubernetes项目里找到CronJob对象? 0.1.2.1. 匹配Group 0.1.2.1.1. 核心API 对于kubernetes的核心对象(如pod、Node等),是不需要Group的,因为它们的Group是"",kubernetes会在/api这个层级进行下一步的匹配过程。 0.1.2.1.2. 非核心API 对于非核心API来说,kubernetes就必须在/apis这个层级查找到对应的Group。 API Group的分类是以对象功能为依据的。 0.1.2.2. 匹配Version 在对应的Group中匹配Version。 同一种API对象可以有多个版本,这是kubernetes进行API版本化管理的重要手段。对于会影响到用户的变更就可以通过升级新版本来处理,从而保证了向后兼容。 0.1.2.3. 匹配Resource 匹配到正确的版本后,APIServer就会根据Resource创建对应的API对象。 具体的执行过程如下图所示: 发起创建API对象的POST请求后,编写的YAML的信息就被提交给了APIServer APIServer会过滤这个请求,并完成前置工作(授权、超时处理、审计等) 请求进入MUX和Routes(APIServer完成URL和Handler绑定的场所) APIServer的Handler要做的事情就是按照上面过程找到对应的API对象类型的定义 根据API对象类型定义,使用用户提交的YAML文件里面的字段,创建一个对象 在这个过程中,APIServer会进行一个Convert工作,把用户提交的YAML文件,转换成一个叫作Super Version的对象(该API资源类型所有版本的字段全集),用户提交的不同版本的YAML文件,都可以使用这个Super Version对象来处理 APIServer进行Admission()和Validation()操作 Admission Controller 和 Initializer属于Admission的功能,Validation,负责校验每个字段是否合法,被验证过的API对象保存在APIServer的Registry数据结构中。【只要一个API对象的定义能够在Registry中查到,那就是一个有效的对象】 APIServer把验证过的API对象转换成用户最初提交的版本,进行序列化操作,并调用Etcd的API把它保存起来。 由此看见APIServer的重要性,同时要兼顾性能、API完备性、版本化、向后兼容等,因此在APIServer中大量使用Go语言的代码生成功能,来自动化诸如Convert、DeepCopy等与API资源相关的操作。 这导致要添加一个kubernetes风格的API非常困难。 0.2. 添加自定义API对象 0.2.1. 第一步,让kubernetes认识这个自定义的API对象 在kubernetes v1.7之后,添加了全新的API插件机制CRD,使得自定义API变得容易很多。 CRD(custom Resource Definition),允许用户在kubernetes中添加与Pod、Node类似的新的API资源类型,即自定义API资源。 0.2.2. 例子 添加一个叫Network的自定义API资源类型,它的作用是一旦用户创建了一个Network对象,那么Kubernetes就会使用这个对象定义的网络参数,调用真实的网络插件(如Neutron项目),为用户创建一个真正的“网络”。这样,将来创建的Pod就可以声明使用这个网络。 这个Network对象的YAML文件,如下,称为一个自定义API资源,CR(Custon Resource): apiVersion: samplecrd.k8s.io/v1 kind: Network metadata: name: example-network spec: cidr: "192.168.0.0/16" gateway: "192.168.0.1" 上面的例子中: Group(API组): samplecrd.k8s.io Version(API版本): v1 Resource(API资源类型): Network 那么问题来了,Kubernetes如何知道自定义的API组的存在? 为了让Kubernetes能够认识这个CR,需要让kubernetes知道CR对应的宏观定义,即CRD。 所以需要编写一个CRD对应的YAML文件,如下所示。 apiVersion: apiextensions.k8s.io/v1beta1 kind: CustomResourceDefinition metadata: name: networks.samplecrd.k8s.io spec: group: samplecrd.k8s.io version: v1 names: kind: Network plural: networks # 复数 scope: Namespaced 考考你 在这个CRD的定义中: Group(API组): apiextensions.k8s.io Version(API版本): v1beta1 Resource(API资源类型): CustomResourceDefinition 在这个CRD中定义了一个新的资源类型: Group(API组): samplecrd.k8s.io Version(API版本): v1 Resource(API资源类型): Network scope:Namespaced,表示这是一个属于Namespace的对象,隔离于不同namespace中 这些内容就是上面我们自定的Network对象。 有了这个CRD,kubernetes就能处理所有声明为samplecrd.k8s.io/v1/network类型的YAML文件 0.2.3. 第二步,让kubernetes认识自定义的API对象中的字段 编写代码实现。 首先,在GOPATH下创建一个结构如下的项目: $ tree $GOPATH/src/github.com/<your-name>/k8s-controller-custom-resource . ├── controller.go ├── crd │ └── network.yaml ├── example │ └── example-network.yaml ├── main.go └── pkg └── apis └── samplecrd ├── register.go # 存放全局变量 └── v1 ├── doc.go ├── register.go # APIServer完成服务器服务器端的注册,客户端的注册在这里完成 └── types.go # 定义对Network对象的完整描述 其中,pkg/api/samplecrd是API组的名字,v1是版本。 register.go的代码如下: package samplecrd const ( GroupName = "samplecrd.k8s.io" Version = "v1" ) doc.go (Golang的文档源文件),代码如下: // +k8s:deepcopy-gen=package /* 这里是对代码的注释,不属于这个源文件 +<tag_name>[=value]格式的注释,就是kubernetes进行代码生成要用的Annotation风格的注释 +k8s:deepcopy-gen=package意思是,请为整个v1包里的所有类型定义自动生成DeepCopy方法 */ // +groupName=samplecrd.k8s.io /* 这里是对代码的注释,不属于这个源文件 +groupName=samplecrd.k8s.io,定义了这个包对应的API组的名字 */ package v1 这些定义在doc.go文件的注释,起到的是全局的代码生成控制的作用,也被称为Global Tags。 types.go文件,它的作用就是定义一个Network类型有哪些字段(比如,spec字段里面的内容),代码如下: package v1 ... /* +genclient,代码生成注释的意思是为下面这个API资源类型生成对应的Client代码 +genclient:noStatus,表示这个API类型定义中没有Status字段,否则生成的Client会自动带上UpdateStatus方法 */ // +genclient // +genclient:noStatus /* 下面的这个注释表示,在生成DeepCopy的时候,实现kubernetes提供的runtime.Object接口 否则在某些kubernetes版本中会出现编译错误 这是一个固定操作 */ // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // Network describes a Network resource type Network struct { // TypeMeta is the metadata for the resource, like kind and apiversion metav1.TypeMeta `json:",inline"` // ObjectMeta contains the metadata for the particular object, including // things like... // - name // - namespace // - self link // - labels // - ... etc ... metav1.ObjectMeta `json:"metadata,omitempty"` Spec networkspec `json:"spec"` } // networkspec is the spec for a Network resource type networkspec struct { Cidr string `json:"cidr"` //反引号中内容表示,该字段被转换为JSON格式后的名字,即在YAML文件里的字段名字 Gateway string `json:"gateway"` } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // NetworkList is a list of Network resources type NetworkList struct { //描述一组Network对象应该包括哪些字段 metav1.TypeMeta `json:",inline"` metav1.ListMeta `json:"metadata"` Items []Network `json:"items"` } Network类型定义方法和标准的kubernetes对象一样,包括TypeMeta(API元数据)和ObjectMeta字段(对象元数据)。 注意,+genclient写在Network类型(主类型)上,而不是NetworkList类型(返回值类型)上。 registry.go(pkg/apis/samplecrd/v1/register.go,定义了如下的一个addKnowTypes()方法: package v1 ... // addKnownTypes adds our types to the API scheme by registering // Network and NetworkList func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes( SchemeGroupVersion, &Network{}, &NetworkList{}, ) // register the type in the scheme metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil } 有了这个方法,kubernetes就能够在后面生成的client是知道Network以及NetworkList类型的定义。 通常,register.go文件里面的内容比较固定,使用的时候,资源类型,GruopName、Version等即可。 至此,自定义API对象完成,主要进行了两个部分: 自定义资源类型的API描述,包括:Group、Version、Resource等 自定义资源类型的对象描述,包括:Sepc、Status等 使用kubernetes提供的代码生成工具(k8s.io/code-generator),为上面定义的Network资源类型自动生成clientset、informer和lister,其中clientset就是操作Network对象所需要是使用的客户端。 使用方式如下: # 代码生成的工作目录,也就是我们的项目路径 $ ROOT_PACKAGE="github.com/resouer/k8s-controller-custom-resource" # API Group $ CUSTOM_RESOURCE_NAME="samplecrd" # API Version $ CUSTOM_RESOURCE_VERSION="v1" # 安装 k8s.io/code-generator $ go get -u k8s.io/code-generator/... $ cd $GOPATH/src/k8s.io/code-generator # 执行代码自动生成,其中 pkg/client 是生成目标目录,pkg/apis 是类型定义目录 $ ./generate-groups.sh all "$ROOT_PACKAGE/pkg/client" "$ROOT_PACKAGE/pkg/apis" "$CUSTOM_RESOURCE_NAME:$CUSTOM_RESOURCE_VERSION" 执行后项目结构如下: $ tree . ├── controller.go ├── crd │ └── network.yaml ├── example │ └── example-network.yaml ├── main.go └── pkg ├── apis │ └── samplecrd │ ├── constants.go │ └── v1 │ ├── doc.go │ ├── register.go │ ├── types.go │ └── zz_generated.deepcopy.go # 自动生成的DeepCopy代码文件 └── client # kubernetes为Network类型生成的客户端库,在编写自定义控制器时用到 ├── clientset ├── informers └── listers 使用自定义的API对象: # 创建CRD $ kubectl apply -f crd/network.yaml customresourcedefinition.apiextensions.k8s.io/networks.samplecrd.k8s.io created # 查看已经创建的CRD $ kubectl get crd NAME CREATED AT networks.samplecrd.k8s.io 2018-09-15T10:57:12Z # 创建API对象 $ kubectl apply -f example/example-network.yaml network.samplecrd.k8s.io/example-network created # 获取API对象 $ kubectl get network NAME AGE example-network 8s # 查看API对象的详细信息 $ kubectl describe network example-network Name: example-network Namespace: default Labels: <none> ...API Version: samplecrd.k8s.io/v1 Kind: Network Metadata: ... Generation: 1 Resource Version: 468239 ... Spec: Cidr: 192.168.0.0/16 Gateway: 192.168.0.1 0.3. 为自定义API对象编写控制器 创建出一个自定义API对象,只是完成了kubernetes声明式API的一半工作,接下来还需要为这个API对象编写一个自定义控制器,这样kubernetes才能根据Network API对象的增删改查操作。 声明是API并不像命令式API那样有着明显的执行逻辑,使得基于声明式API的业务功能实现,往往需要通过控制器模式来“监视”API对象的变化(创建或删除),然后以此来决定实际要执行的具体工作。 总体来说,编写自定义控制器代码的过程包括: 编写main函数 编写自定义控制器的定义 编写控制器里的业务逻辑 0.3.1. main函数 主要工作是定义并初始化一个自定义控制器(Custom Controller),然后启动它,代码如下: func main() { ... cfg, err := clientcmd.BuildConfigFromFlags(masterURL, kubeconfig) ... kubeClient, err := kubernetes.NewForConfig(cfg) ... networkClient, err := clientset.NewForConfig(cfg) ... networkInformerFactory := informers.NewSharedInformerFactory(networkClient, ...) controller := NewController(kubeClient, networkClient, networkInformerFactory.Samplecrd().V1().Networks()) go networkInformerFactory.Start(stopCh) if err = controller.Run(2, stopCh); err != nil { glog.Fatalf("Error running controller: %s", err.Error()) } } main函数主要通过三个步骤完成初始化并启动一个自定义控制器的工作: main函数根据提供的Master配置(APIServer的地址端口和kubeconfig的路径)创建一个kubernetes的client(kubeclient)和Network对象的client(networkclient)。 如果没有提供Master的配置,main函数会直接使用一种叫InClusterConfig的方式来创建这个client。这种方式我会假设自动以控制器是以Pod的方式运行在集群中的。因为集群中所有的pod都会默认以volume的形式挂载ServiceAccount,所以控制器就直接使用默认的ServiceAccount数据卷里的授权信息来访问APIServer。 main函数为Network对象创建一个叫作InformerFactory(networkinformerfactory)的工厂,并使用它生成一个Network独享的informer,传递给控制器。 main函数启动上述的informer,然后执行controller.Run,启动自定义控制器。 0.3.2. 自定义控制器的工作原理 自定义控制器的工作原理如下图所示: 0.3.2.1. 第一步:自定义控制器从APIServer里获取它所关心的对象 这个操作依靠informer(通知器)的代码库完成。informer与API对象是一一对应的。所以传递给自定义控制器的就是API对象的informer。 创建informer工厂时,需要给它传递networkclient,informer使用这个networkclient与APIServer建立连接。informer使用Reflector包来维护这个连接。 Reflector使用ListAndWatch方法来获取并监听这些network对象实例的变化。 ListAndWatch方法的首先通过APIServer的LIST API获取最新版的API对象;然后通过WATCH机制来监听这些API的变化。 在ListAndWatch机制下,一旦APIServer有新的对象实例被创建、删除或更新,Reflector都会收到事件通知。该事件以及对应的API对象的组合被以增量的形式放进Delta FIFO Queue中。 informer会不断从这个Delta FIFO Queue里读取增量,每拿到一个增量就判断里面的事件类型,然后创建或者更新本地对象的缓存(在kubernetes中称为Store)。 每经过resyncPeriod指定时间,Informer维护的本地缓存,都会使用最近一次LIST返回的结果强制更新一次,从而保证换成的有效性。该操作也会出发informer注册的更新事件,但是两个对象的ResourceVersion一样,因此informer不做进一步处理。 如果事件类型是Added,informer就会通知indexer把这个API对象保存到本地缓存,并为它创建索引。如果是删除,则从本地缓存中删除这个对象。 同步本地缓存是informer的第一个职责,最重要的职责。 0.3.2.2. 第二步:根据事件类型触发事先注册好的ResourceEventHandler Handler需要在创建控制器的时候注册给它对应的informer。控制器的定义如下: func NewController( kubeclientset kubernetes.Interface, networkclientset clientset.Interface, networkInformer informers.NetworkInformer) *Controller { ... controller := &Controller{ kubeclientset: kubeclientset, networkclientset: networkclientset, networksLister: networkInformer.Lister(), networksSynced: networkInformer.Informer().HasSynced, workqueue: workqueue.NewNamedRateLimitingQueue(..., "Networks"), ... } networkInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: controller.enqueueNetwork, UpdateFunc: func(old, new interface{}) { oldNetwork := old.(*samplecrdv1.Network) newNetwork := new.(*samplecrdv1.Network) if oldNetwork.ResourceVersion == newNetwork.ResourceVersion { return } controller.enqueueNetwork(new) }, DeleteFunc: controller.enqueueNetworkForDelete, return controller } 在mian函数中创建了kubeclientser和networkclientset,然后使用这两个client和informer初始化自定义控制器。 在自定义控制器中设置了一个工作队列(work queue),负责同步informer和控制循环之间的数据。 kubernetes预置了很多工作队列的实现,可直接使用。 为networkinformer注册三个Handler(AddFunc、UpdateFunc、DeleteFunc),分别对应API对象的增删改操作。具体的操作就是将该事件对应的API对象加入到工作队列中(实际入队的是key而不是API对象本身,即<namespace>/<name>)。 控制循环将不断从这个工作队列里拿到这些key,然后开始执行真正的控制逻辑。 informer其实是一个带有本地缓存和索引机制的可注册EventHandler的client,它是实现自定义控制器跟APIServer数据同步的重要组件。 0.3.2.3. 第三步:循环控制 main函数最后调用controller.Run()启动循环控制,代码如下: func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error { ... if ok := cache.WaitForCacheSync(stopCh, c.networksSynced); !ok { return fmt.Errorf("failed to wait for caches to sync") } ... for i := 0; i < threadiness; i++ { go wait.Until(c.runWorker, time.Second, stopCh) } ... return nil } 等待informer完成一次本地缓存的数据同步操作 通过goroutine启动一个(或者并发启动多个)无限循环的任务(任务的每一个循环周期执行的正式具体的业务逻辑) 自定义控制器的业务逻辑如下: func (c *Controller) runWorker() { for c.processNextWorkItem() { } } func (c *Controller) processNextWorkItem() bool { obj, shutdown := c.workqueue.Get() ... err := func(obj interface{}) error { ... if err := c.syncHandler(key); err != nil { return fmt.Errorf("error syncing '%s': %s", key, err.Error()) } c.workqueue.Forget(obj) ... return nil }(obj) ... return true } func (c *Controller) syncHandler(key string) error { namespace, name, err := cache.SplitMetaNamespaceKey(key) ... network, err := c.networksLister.Networks(namespace).Get(name) if err != nil { if errors.IsNotFound(err) { glog.Warningf("Network does not exist in local cache: %s/%s, will delete it from Neutron ...", namespace, name) glog.Warningf("Network: %s/%s does not exist in local cache, will delete it from Neutron ...", namespace, name) // FIX ME: call Neutron API to delete this network by name. // // neutron.Delete(namespace, name) return nil } ... return err } glog.Infof("[Neutron] Try to process network: %#v ...", network) // FIX ME: Do diff(). // // actualNetwork, exists := neutron.Get(namespace, name) // // if !exists { // neutron.Create(namespace, name) // } else if !reflect.DeepEqual(actualNetwork, network) { // neutron.Update(namespace, name) // } return nil } 从Workqueue中出对一个key syncHandler方法使用这个key,尝试从informer维护的缓存中拿到了它所对应的对象(使用networksLister方法) 如果控制循环从缓存中拿不到这个对象,说明key是通过删除操作被加入到workqueue中,这是调用对应的API把key对应的对象从集群中删除 如果能够获取到对应的对象,就可以执行控制器模式里面的对比期望状态和实际状态的逻辑 自定义控制器拿到的API对象,就是APIServer中保存的期望状态。 实际状态直接从集群中获取,通过对比两者的状态来完成一次调谐过程。
kubernetes内置的编排对象很难完全满足所有需求,基于插件机制来设计自己的编排对象,实现自己的控制器模式。 kubernetes中所有的API对象都是保存在Etcd中,但是,对于这些API对象的操作,却一定要通过访问kube-apiserver实现,这是因为需要APIServer来帮助完成授权工作。 在kubernetes中,负责授权工作的机制就是RBAC,基于角色的访问控制(Role-Based Access Control) RBAC的三个基本概念: Role:一组规则,定义了一组对kubernetesAPI对象的操作权限 Subject:被作用者,可以是人,也可以是机器,也可以是kubernetes中定义的用户 RoleBinding:定义了被作用者和角色之间的绑定关系 Role Role是Kubernetes的API对象,定义如下: kind: Role apiVersion: rbac.authorization.k8s.io/v1 metadata: namespace: mynamespace # 指定了产生作用的Namespace name: example-role rules: # 定义权限规则 - apiGroups: [""] resources: ["pods"] verbs: ["get", "watch", "list"] Namespace是kubernetes项目里的逻辑管理单位,不同Namespace的API对象,在通过kubectl命令操作的时候,是相对隔离的(逻辑上的隔离并不提供实际的隔离或者多租户能力)。 RoleBinding RoleBinding本身也是一个kubernetes的API对象,定义如下: kind: RoleBinding apiVersion: rbac.authorization.k8s.io/v1 metadata: name: example-rolebinding namespace: mynamespace subjects: #被作用者 - kind: User #类型为user,即kubernetes里的用户 name: example-user apiGroup: rbac.authorization.k8s.io roleRef: # 利用这个字段,直接通过使用名字的方式来引用定义好的Role对象,进行规则的绑定 kind: Role name: example-role apiGroup: rbac.authorization.k8s.io 在kubernetes中,并没有user这个API对象。 User 在kubernetes中的User,即用户,只是一个授权系统里的逻辑概念: 它需要通过外部认证服务,如Keystone来提供 直接给APIServer自定一个用户名和密码文件,kubernetes的授权系统,能够从这个文件里找到对应的用户 Role、RoleBinding都是Namespaced对象,只能在某个namespace中。对于non-namespace对象,或者某个对象要作用于所有的namespace时,使用ClusterRole和ClusterRoleBinding。 用法与Role完全一样,只是没有namespace字段。 kind: ClusterRole apiVersion: rbac.authorization.k8s.io/v1 metadata: name: example-clusterrole rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "watch", "list"] # 赋予所有权限 # verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] # 以上是当前能够对API对象进行的全部操作kind: ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 metadata: name: example-clusterrolebinding subjects: - kind: User name: example-user apiGroup: rbac.authorization.k8s.io roleRef: kind: ClusterRole name: example-clusterrole apiGroup: rbac.authorization.k8s.io rules字段也可以针对某一个具体的对象进行权限设置: rules: - apiGroups: [""] resources: ["configmaps"] resourceNames: ["my-config"] verbs: ["get"] kubernetes中有一个内置的用户,ServiceAccout。 创建一个ServiceAccount apiVersion: v1 kind: ServiceAccount metadata: namespace: mynamespace name: example-sa 编写Rolebinding,进行权限分配 kind: RoleBinding apiVersion: rbac.authorization.k8s.io/v1 metadata: name: example-rolebinding namespace: mynamespace subjects: - kind: ServiceAccount # 类型为ServiceAccount name: example-sa namespace: mynamespace roleRef: kind: Role name: example-role apiGroup: rbac.authorization.k8s.io 分别创建对应的对象 kubectl create -f svc-account.yaml kubectl create -f role-binding.yaml kubectl create -f role.yaml 查看ServiceAccount的详细信息 $ kubectl get sa -n mynamespace -o yaml - apiVersion: v1 kind: ServiceAccount metadata: creationTimestamp: 2018-09-08T12:59:17Z name: example-sa namespace: mynamespace resourceVersion: "409327" ... secrets: # kubernetes自动创建并分配 - name: example-sa-token-vmfg6 kubernetes会为ServiceAccount自定创建并分配一个Secret对象,这个Secret就是与ServiceAccount对应的,用来与APIServer进行交互的授权文件(称为Token)。Token文件的内容一般是证书或者密码,以一个secret对象的方式保存在Etcd中。 使用这个ServiceAccount apiVersion: v1 kind: Pod metadata: namespace: mynamespace name: sa-token-test spec: containers: - name: nginx image: nginx:1.7.9 serviceAccountName: example-sa 定义的pod使用的是example-sa这个ServiceAccount,等pod运行后,该ServiceAccount的token(也就是secret对象),被kubernetes自动挂载到容器的/var/run/secretc/kubernetes.io/serviceaccount目录下。 $ kubectl describe pod sa-token-test -n mynamespace Name: sa-token-test Namespace: mynamespace ... Containers: nginx: ... Mounts: /var/run/secrets/kubernetes.io/serviceaccount from example-sa-token-vmfg6 (ro) 使用kubectl exec 查看目录中的文件: $ kubectl exec -it sa-token-test -n mynamespace -- /bin/bash root@sa-token-test:/# ls /var/run/secrets/kubernetes.io/serviceaccount ca.crt namespace token 容器中的应用可以会用这个ca.crt来访问APIServer。此时应用只能进行GET、WATCH、LIST操作,因为ServiceAccount的权限被Role限制了。 如果一个pod没有声明ServiceAccount,kubernetes会自动在它的Namespace下创建一个叫default的默认ServiceAccount,然后被分配给这个Pod。在这种情况下,默认ServiceAccount并没有关联任何Role,此时它有访问APIServer的绝大多数权限。这个访问所需要的Token还是默认的ServiceAccount对应的Secret对象提供。 $kubectl describe sa default Name: default Namespace: default Labels: <none> Annotations: <none> Image pull secrets: <none> Mountable secrets: default-token-s8rbq Tokens: default-token-s8rbq Events: <none> $ kubectl get secret NAME TYPE DATA AGE kubernetes.io/service-account-token 3 82d $ kubectl describe secret default-token-s8rbq Name: default-token-s8rbq Namespace: default Labels: <none> Annotations: kubernetes.io/service-account.name=default kubernetes.io/service-account.uid=ffcb12b2-917f-11e8-abde-42010aa80002 Type: kubernetes.io/service-account-token Data ==== ca.crt: 1025 bytes namespace: 7 bytes token: <TOKEN 数据 > kubernetes会自动为默认ServiceAccount创建并绑定一个特殊的Secret: 类型为:kubernetes.io/service-account-token Annotation:kubernetes.io/service-account.name=default(这个secret会跟同一Namespace下名叫default的ServiceAccount进行绑定)。 用户组 除了user、还有group的概念,如果为kubernetes配置外部认证服务,这个用户组由外部认证服务提供。 对于kubernetes的内置用户ServiceAccount来说,上述用户组的概念也同样适用,实际上,一个ServiceAccount,在kubernetes里对应用户的名字是: system:serviceaccount:<ServiceAccount 名字 > 它对应的内置用户组的名字: system:serviceaccounts:<Namespace 名字 > 这两个很重要。 例子 在RoleBinding里定义如下的subjects: subjects: - kind: Group name: system:serviceaccounts:mynamespace apiGroup: rbac.authorization.k8s.io 这就意味着,这个Role的权限规则,作用于mynamespace里所有ServiceAccount,用到了用户组的概念。 subjects: - kind: Group name: system:serviceaccounts apiGroup: rbac.authorization.k8s.io 意味着这个Role作用于整个系统里所有的ServiceAccount。 在kubernetes中已经预置了很多系统保留的ClusterRole,都是以System:开头,通过使用kubectl get clusterroles来查看。这些一般是绑定给kubernetes系统组件对应的ServiceAccount使用的。 system:kube-scheduler 这个clusterRole定义的权限规则是kube-scheduler运行所必须的权限。 $ kubectl describe clusterrole system:kube-scheduler Name: system:kube-scheduler ... PolicyRule: Resources Non-Resource URLs Resource Names Verbs --------- ----------------- -------------- ----- ... services [] [] [get list watch] replicasets.apps [] [] [get list watch] statefulsets.apps [] [] [get list watch] replicasets.extensions [] [] [get list watch] poddisruptionbudgets.policy [] [] [get list watch] pods/status [] [] [patch update] 这个clusterRole会被绑定给kube-system Namespace下名叫kube-scheduler的ServiceAccount,它正式kubernetes调度器的pod声明使用的ServiceAccount。 kubernetes预置了四个clusterRole: cluster-admin:kubernetes中的最权限,verb=* admin edit view:规定被作用这只有kubernetes API的只读权限 $ kubectl describe clusterrole cluster-admin -n kube-system Name: cluster-admin Labels: kubernetes.io/bootstrapping=rbac-defaults Annotations: rbac.authorization.kubernetes.io/autoupdate=true PolicyRule: Resources Non-Resource URLs Resource Names Verbs --------- ----------------- -------------- ----- *.* [] [] [*] [*] [] [*]