This commit is contained in:
louzefeng
2024-07-09 18:38:56 +00:00
parent 8bafaef34d
commit bf99793fd0
6071 changed files with 1017944 additions and 0 deletions

View File

@@ -0,0 +1,313 @@
<audio id="audio" title="13 | 为什么我们需要Pod" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a6/b6/a6fbf30060d0a184da6ad973f24e88b6.mp3"></audio>
你好我是张磊。今天我和你分享的主题是为什么我们需要Pod。
在前面的文章中我详细介绍了在Kubernetes里部署一个应用的过程。在这些讲解中我提到了这样一个知识点Pod是Kubernetes项目中最小的API对象。如果换一个更专业的说法我们可以这样描述Pod是Kubernetes项目的原子调度单位。
不过我相信你在学习和使用Kubernetes项目的过程中已经不止一次地想要问这样一个问题为什么我们会需要Pod
是啊我们在前面已经花了很多精力去解读Linux容器的原理、分析了Docker容器的本质终于“Namespace做隔离Cgroups做限制rootfs做文件系统”这样的“三句箴言”可以朗朗上口了**为什么Kubernetes项目又突然搞出一个Pod来呢**
要回答这个问题,我们还是要一起回忆一下我曾经反复强调的一个问题:容器的本质到底是什么?
你现在应该可以不假思索地回答出来:容器的本质是进程。
没错。容器,就是未来云计算系统中的进程;容器镜像就是这个系统里的“.exe”安装包。那么Kubernetes呢
你应该也能立刻回答上来Kubernetes就是操作系统
非常正确。
现在就让我们登录到一台Linux机器里执行一条如下所示的命令
```
$ pstree -g
```
这条命令的作用,是展示当前系统中正在运行的进程的树状结构。它的返回结果如下所示:
```
systemd(1)-+-accounts-daemon(1984)-+-{gdbus}(1984)
| `-{gmain}(1984)
|-acpid(2044)
...
|-lxcfs(1936)-+-{lxcfs}(1936)
| `-{lxcfs}(1936)
|-mdadm(2135)
|-ntpd(2358)
|-polkitd(2128)-+-{gdbus}(2128)
| `-{gmain}(2128)
|-rsyslogd(1632)-+-{in:imklog}(1632)
| |-{in:imuxsock) S 1(1632)
| `-{rs:main Q:Reg}(1632)
|-snapd(1942)-+-{snapd}(1942)
| |-{snapd}(1942)
| |-{snapd}(1942)
| |-{snapd}(1942)
| |-{snapd}(1942)
```
不难发现在一个真正的操作系统里进程并不是“孤苦伶仃”地独自运行的而是以进程组的方式“有原则地”组织在一起。比如这里有一个叫作rsyslogd的程序它负责的是Linux操作系统里的日志处理。可以看到rsyslogd的主程序main和它要用到的内核日志模块imklog等同属于1632进程组。这些进程相互协作共同完成rsyslogd程序的职责。
>
注意我在本篇中提到的“进程”比如rsyslogd对应的imklogimuxsock和main严格意义上来说其实是Linux 操作系统语境下的“线程”。这些线程,或者说,轻量级进程之间,可以共享文件、信号、数据内存、甚至部分代码,从而紧密协作共同完成一个程序的职责。所以同理,我提到的“进程组”,对应的也是 Linux 操作系统语境下的“线程组”。这种命名关系与实际情况的不一致是Linux 发展历史中的一个遗留问题。对这个话题感兴趣的同学,可以阅读[这篇技术文章](https://www.ibm.com/developerworks/cn/linux/kernel/l-thread/index.html)来了解一下。
而Kubernetes项目所做的其实就是将“进程组”的概念映射到了容器技术中并使其成为了这个云计算“操作系统”里的“一等公民”。
Kubernetes项目之所以要这么做的原因我在前面介绍Kubernetes和Borg的关系时曾经提到过在Borg项目的开发和实践过程中Google公司的工程师们发现他们部署的应用往往都存在着类似于“进程和进程组”的关系。更具体地说就是这些应用之间有着密切的协作关系使得它们必须部署在同一台机器上。
而如果事先没有“组”的概念,像这样的运维关系就会非常难以处理。
我还是以前面的rsyslogd为例子。已知rsyslogd由三个进程组成一个imklog模块一个imuxsock模块一个rsyslogd自己的main函数主进程。这三个进程一定要运行在同一台机器上否则它们之间基于Socket的通信和文件交换都会出现问题。
现在我要把rsyslogd这个应用给容器化由于受限于容器的“单进程模型”这三个模块必须被分别制作成三个不同的容器。而在这三个容器运行的时候它们设置的内存配额都是1 GB。
>
再次强调一下容器的“单进程模型”并不是指容器里只能运行“一个”进程而是指容器没有管理多个进程的能力。这是因为容器里PID=1的进程就是应用本身其他的进程都是这个PID=1进程的子进程。可是用户编写的应用并不能够像正常操作系统里的init进程或者systemd那样拥有进程管理的功能。比如你的应用是一个Java Web程序PID=1然后你执行docker exec在后台启动了一个Nginx进程PID=3。可是当这个Nginx进程异常退出的时候你该怎么知道呢这个进程退出后的垃圾收集工作又应该由谁去做呢
假设我们的Kubernetes集群上有两个节点node-1上有3 GB可用内存node-2有2.5 GB可用内存。
这时假设我要用Docker Swarm来运行这个rsyslogd程序。为了能够让这三个容器都运行在同一台机器上我就必须在另外两个容器上设置一个affinity=main与main容器有亲密性的约束它们俩必须和main容器运行在同一台机器上。
然后我顺序执行“docker run main”“docker run imklog”和“docker run imuxsock”创建这三个容器。
这样这三个容器都会进入Swarm的待调度队列。然后main容器和imklog容器都先后出队并被调度到了node-2上这个情况是完全有可能的
可是当imuxsock容器出队开始被调度时Swarm就有点懵了node-2上的可用资源只有0.5 GB了并不足以运行imuxsock容器可是根据affinity=main的约束imuxsock容器又只能运行在node-2上。
这就是一个典型的成组调度gang scheduling没有被妥善处理的例子。
在工业界和学术界,关于这个问题的讨论可谓旷日持久,也产生了很多可供选择的解决方案。
比如Mesos中就有一个资源囤积resource hoarding的机制会在所有设置了Affinity约束的任务都达到时才开始对它们统一进行调度。而在Google Omega论文中则提出了使用乐观调度处理冲突的方法先不管这些冲突而是通过精心设计的回滚机制在出现了冲突之后解决问题。
可是这些方法都谈不上完美。资源囤积带来了不可避免的调度效率损失和死锁的可能性;而乐观调度的复杂程度,则不是常规技术团队所能驾驭的。
但是到了Kubernetes项目里这样的问题就迎刃而解了Pod是Kubernetes里的原子调度单位。这就意味着Kubernetes项目的调度器是统一按照Pod而非容器的资源需求进行计算的。
所以像imklog、imuxsock和main函数主进程这样的三个容器正是一个典型的由三个容器组成的Pod。Kubernetes项目在调度时自然就会去选择可用内存等于3 GB的node-1节点进行绑定而根本不会考虑node-2。
像这样容器间的紧密协作我们可以称为“超亲密关系”。这些具有“超亲密关系”容器的典型特征包括但不限于互相之间会发生直接的文件交换、使用localhost或者Socket文件进行本地通信、会发生非常频繁的远程调用、需要共享某些Linux Namespace比如一个容器要加入另一个容器的Network Namespace等等。
这也就意味着并不是所有有“关系”的容器都属于同一个Pod。比如PHP应用容器和MySQL虽然会发生访问关系但并没有必要、也不应该部署在同一台机器上它们更适合做成两个Pod。
不过,相信此时你可能会有**第二个疑问:**
对于初学者来说一般都是先学会了用Docker这种单容器的工具才会开始接触Pod。
而如果Pod的设计只是出于调度上的考虑那么Kubernetes项目似乎完全没有必要非得把Pod作为“一等公民”吧这不是故意增加用户的学习门槛吗
没错如果只是处理“超亲密关系”这样的调度问题有Borg和Omega论文珠玉在前Kubernetes项目肯定可以在调度器层面给它解决掉。
不过Pod在Kubernetes项目里还有更重要的意义那就是**容器设计模式**。
为了理解这一层含义我就必须先给你介绍一下Pod的实现原理。
**首先关于Pod最重要的一个事实是它只是一个逻辑概念。**
也就是说Kubernetes真正处理的还是宿主机操作系统上Linux容器的Namespace和Cgroups而并不存在一个所谓的Pod的边界或者隔离环境。
那么Pod又是怎么被“创建”出来的呢
答案是Pod其实是一组共享了某些资源的容器。
具体的说:**Pod里的所有容器共享的是同一个Network Namespace并且可以声明共享同一个Volume。**
那这么来看的话一个有A、B两个容器的Pod不就是等同于一个容器容器A共享另外一个容器容器B的网络和Volume的玩儿法么
这好像通过docker run --net --volumes-from这样的命令就能实现嘛比如
```
$ docker run --net=B --volumes-from=B --name=A image-A ...
```
但是你有没有考虑过如果真这样做的话容器B就必须比容器A先启动这样一个Pod里的多个容器就不是对等关系而是拓扑关系了。
所以在Kubernetes项目里Pod的实现需要使用一个中间容器这个容器叫作Infra容器。在这个Pod中Infra容器永远都是第一个被创建的容器而其他用户定义的容器则通过Join Network Namespace的方式与Infra容器关联在一起。这样的组织关系可以用下面这样一个示意图来表达
<img src="https://static001.geekbang.org/resource/image/8c/cf/8c016391b4b17923f38547c498e434cf.png" alt=""><br>
如上图所示这个Pod里有两个用户容器A和B还有一个Infra容器。很容易理解在Kubernetes项目里Infra容器一定要占用极少的资源所以它使用的是一个非常特殊的镜像叫作`k8s.gcr.io/pause`。这个镜像是一个用汇编语言编写的、永远处于“暂停”状态的容器解压后的大小也只有100~200 KB左右。
而在Infra容器“Hold住”Network Namespace后用户容器就可以加入到Infra容器的Network Namespace当中了。所以如果你查看这些容器在宿主机上的Namespace文件这个Namespace文件的路径我已经在前面的内容中介绍过它们指向的值一定是完全一样的。
这也就意味着对于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: [&quot;/bin/sh&quot;]
args: [&quot;-c&quot;, &quot;echo Hello from the debian container &gt; /pod-data/index.html&quot;]
```
在这个例子中debian-container和nginx-container都声明挂载了shared-data这个Volume。而shared-data是hostPath类型。所以它对应在宿主机上的目录就是/data。而这个目录其实就被同时绑定挂载进了上述两个容器当中。
这就是为什么nginx-container可以从它的/usr/share/nginx/html目录中读取到debian-container生成的index.html文件的原因。
明白了Pod的实现原理后我们再来讨论“容器设计模式”就容易多了。
Pod这种“超亲密关系”容器的设计思想实际上就是希望当用户想在一个容器里跑多个功能并不相关的应用时应该优先考虑它们是不是更应该被描述成一个Pod里的多个容器。
为了能够掌握这种思考方式,你就应该尽量尝试使用它来描述一些用单个容器难以解决的问题。
**第一个最典型的例子是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: [&quot;cp&quot;, &quot;/sample.war&quot;, &quot;/app&quot;]
volumeMounts:
- mountPath: /app
name: app-volume
containers:
- image: geektime/tomcat:7.0
name: tomcat
command: [&quot;sh&quot;,&quot;-c&quot;,&quot;/root/apache-tomcat-7.0.42-v2/bin/start.sh&quot;]
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包而已。所以我们用Init Container的方式优先运行WAR包容器扮演了一个sidecar的角色。
**第二个例子,则是容器的日志收集。**
比如,我现在有一个应用,需要不断地把日志文件输出到容器的/var/log目录中。
这时我就可以把一个Pod里的Volume挂载到应用容器的/var/log目录上。
然后我在这个Pod里同时运行一个sidecar容器它也声明挂载同一个Volume到自己的/var/log目录上。
这样接下来sidecar容器就只需要做一件事儿那就是不断地从自己的/var/log目录里读取日志文件转发到MongoDB或者Elasticsearch中存储起来。这样一个最基本的日志收集工作就完成了。
跟第一个例子一样这个例子中的sidecar的主要工作也是使用共享的Volume来完成对文件的操作。
但不要忘记Pod的另一个重要特性是它的所有容器都共享同一个Network Namespace。这就使得很多与Pod网络相关的配置和管理也都可以交给sidecar完成而完全无须干涉用户容器。这里最典型的例子莫过于Istio这个微服务治理项目了。
Istio项目使用sidecar容器完成微服务治理的原理我在后面很快会讲解到。
>
备注Kubernetes社区曾经把“容器设计模式”这个理论整理成了[一篇小论文](https://www.usenix.org/conference/hotcloud16/workshop-program/presentation/burns),你可以点击链接浏览。
## 总结
在本篇文章中我重点分享了Kubernetes项目中Pod的实现原理。
Pod是Kubernetes项目与其他单容器项目相比最大的不同也是一位容器技术初学者需要面对的第一个与常规认知不一致的知识点。
事实上,直到现在,仍有很多人把容器跟虚拟机相提并论,他们把容器当做性能更好的虚拟机,喜欢讨论如何把应用从虚拟机无缝地迁移到容器中。
但实际上,无论是从具体的实现原理,还是从使用方法、特性、功能等方面,容器与虚拟机几乎没有任何相似的地方;也不存在一种普遍的方法,能够把虚拟机里的应用无缝迁移到容器中。因为,容器的性能优势,必然伴随着相应缺陷,即:它不能像虚拟机那样,完全模拟本地物理机环境中的部署方法。
所以,这个“上云”工作的完成,最终还是要靠深入理解容器的本质,即:进程。
实际上一个运行在虚拟机里的应用哪怕再简单也是被管理在systemd或者supervisord之下的**一组进程,而不是一个进程**。这跟本地物理机上应用的运行方式其实是一样的。这也是为什么,从物理机到虚拟机之间的应用迁移,往往并不困难。
可是对于容器来说,一个容器永远只能管理一个进程。更确切地说,一个容器,就是一个进程。这是容器技术的“天性”,不可能被修改。所以,将一个原本运行在虚拟机里的应用,“无缝迁移”到容器中的想法,实际上跟容器的本质是相悖的。
这也是当初Swarm项目无法成长起来的重要原因之一一旦到了真正的生产环境上Swarm这种单容器的工作方式就难以描述真实世界里复杂的应用架构了。
所以你现在可以这么理解Pod的本质
>
Pod实际上是在扮演传统基础设施里“虚拟机”的角色而容器则是这个虚拟机里运行的用户程序。
所以下一次当你需要把一个运行在虚拟机里的应用迁移到Docker容器中时一定要仔细分析到底有哪些进程组件运行在这个虚拟机里。
然后你就可以把整个虚拟机想象成为一个Pod把这些进程分别做成容器镜像把有顺序关系的容器定义为Init Container。这才是更加合理的、松耦合的容器编排诀窍也是从传统应用架构到“微服务架构”最自然的过渡方式。
>
注意Pod这个概念提供的是一种编排思想而不是具体的技术方案。所以如果愿意的话你完全可以使用虚拟机来作为Pod的实现然后把用户容器都运行在这个虚拟机里。比如Mirantis公司的[virtlet项目](https://github.com/Mirantis/virtlet)就在干这个事情。甚至你可以去实现一个带有Init进程的容器项目来模拟传统应用的运行方式。这些工作在Kubernetes中都是非常轻松的也是我们后面讲解CRI时会提到的内容。
相反的如果强行把整个应用塞到一个容器里甚至不惜使用Docker In Docker这种在生产环境中后患无穷的解决方案恐怕最后往往会得不偿失。
## 思考题
除了Network Namespace外Pod里的容器还可以共享哪些Namespace呢你能说出共享这些Namesapce的具体应用场景吗
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,233 @@
<audio id="audio" title="14 | 深入解析Pod对象基本概念" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/40/90/40f7fcf5f97b1f59b70cfbe5ccde9190.mp3"></audio>
你好我是张磊。今天我和你分享的主题是深入解析Pod对象之基本概念。
在上一篇文章中我详细介绍了Pod这个Kubernetes项目中最重要的概念。而在今天这篇文章中我会和你分享Pod对象的更多细节。
现在你已经非常清楚Pod而不是容器才是Kubernetes项目中的最小编排单位。将这个设计落实到API对象上容器Container就成了Pod属性里的一个普通的字段。那么一个很自然的问题就是到底哪些属性属于Pod对象而又有哪些属性属于Container呢
要彻底理解这个问题你就一定要牢记我在上一篇文章中提到的一个结论Pod扮演的是传统部署环境里“虚拟机”的角色。这样的设计是为了使用户从传统环境虚拟机环境向Kubernetes容器环境的迁移更加平滑。
而如果你能把Pod看成传统环境里的“机器”、把容器看作是运行在这个“机器”里的“用户程序”那么很多关于Pod对象的设计就非常容易理解了。
比如,**凡是调度、网络、存储以及安全相关的属性基本上是Pod 级别的。**
这些属性的共同特征是它们描述的是“机器”这个整体而不是里面运行的“程序”。比如配置这个“机器”的网卡Pod的网络定义配置这个“机器”的磁盘Pod的存储定义配置这个“机器”的防火墙Pod的安全定义。更不用说这台“机器”运行在哪个服务器之上Pod的调度
接下来我就先为你介绍Pod中几个重要字段的含义和用法。
**NodeSelector是一个供用户将Pod与Node进行绑定的字段**,用法如下所示:
```
apiVersion: v1
kind: Pod
...
spec:
nodeSelector:
disktype: ssd
```
这样的一个配置意味着这个Pod永远只能运行在携带了“disktype: ssd”标签Label的节点上否则它将调度失败。
**NodeName**一旦Pod的这个字段被赋值Kubernetes项目就会被认为这个Pod已经经过了调度调度的结果就是赋值的节点名字。所以这个字段一般由调度器负责设置但用户也可以设置它来“骗过”调度器当然这个做法一般是在测试或者调试的时候才会用到。
**HostAliases定义了Pod的hosts文件比如/etc/hosts里的内容**,用法如下:
```
apiVersion: v1
kind: Pod
...
spec:
hostAliases:
- ip: &quot;10.1.2.3&quot;
hostnames:
- &quot;foo.remote&quot;
- &quot;bar.remote&quot;
...
```
在这个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容器。
我在前面介绍容器基础时曾经讲解过什么是tty和stdin。而在Pod的YAML文件里声明开启它们俩其实等同于设置了docker run里的-it-i即stdin-t即tty参数。
如果你还是不太理解它们俩的作用的话可以直接认为tty就是Linux给用户提供的一个常驻小程序用于接收用户的标准输入返回操作系统的标准输出。当然为了能够在tty中输入信息你还需要同时开启stdin标准输入流
于是这个Pod被创建后你就可以使用shell容器的tty跟这个容器进行交互了。我们一起实践一下
```
$ kubectl create -f nginx.yaml
```
接下来我们使用kubectl attach命令连接到shell容器的tty上
```
$ kubectl attach -it nginx -c shell
```
这样我们就可以在shell容器里执行ps指令查看所有正在运行的进程
```
$ 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”了。而在上一篇文章中我还介绍过“Init Containers”。其实这两个字段都属于Pod对容器的定义内容也完全相同只是Init Containers的生命周期会先于所有的Containers并且严格按照定义的顺序执行。
Kubernetes项目中对Container的定义和Docker相比并没有什么太大区别。我在前面的容器技术概念入门系列文章中和你分享的Image镜像、Command启动命令、workingDir容器的工作目录、Ports容器要开发的端口以及volumeMounts容器要挂载的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: [&quot;/bin/sh&quot;, &quot;-c&quot;, &quot;echo Hello from the postStart handler &gt; /usr/share/message&quot;]
preStop:
exec:
command: [&quot;/usr/sbin/nginx&quot;,&quot;-s&quot;,&quot;quit&quot;]
```
这是一个来自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定义的操作从而实现了容器的“优雅退出”。
在熟悉了Pod以及它的Container部分的主要字段之后我再和你分享一下**这样一个的Pod对象在Kubernetes中的生命周期**。
Pod生命周期的变化主要体现在Pod API对象的**Status部分**这是它除了Metadata和Spec之外的第三个重要字段。其中pod.status.phase就是Pod的当前状态它有如下几种可能的情况
<li>
Pending。这个状态意味着Pod的YAML文件已经提交给了KubernetesAPI对象已经被创建并保存在Etcd当中。但是这个Pod里有些容器因为某种原因而不能被顺利创建。比如调度不成功。
</li>
<li>
Running。这个状态下Pod已经调度成功跟一个具体的节点绑定。它包含的容器都已经创建成功并且至少有一个正在运行中。
</li>
<li>
Succeeded。这个状态意味着Pod里的所有容器都正常运行完毕并且已经退出了。这种情况在运行一次性任务时最为常见。
</li>
<li>
Failed。这个状态下Pod里至少有一个容器以不正常的状态非0的返回码退出。这个状态的出现意味着你得想办法Debug这个容器的应用比如查看Pod的Events和日志。
</li>
<li>
Unknown。这是一个异常状态意味着Pod的状态不能持续地被kubelet汇报给kube-apiserver这很有可能是主从节点Master和Kubelet间的通信出现了问题。
</li>
更进一步地Pod对象的Status字段还可以再细分出一组Conditions。这些细分状态的值包括PodScheduled、Ready、Initialized以及Unschedulable。它们主要用于描述造成当前Status的具体原因是什么。
比如Pod当前的Status是Pending对应的Condition是Unschedulable这就意味着它的调度出现了问题。
而其中Ready这个细分状态非常值得我们关注它意味着Pod不仅已经正常启动Running状态而且已经可以对外提供服务了。这两者之间Running和Ready是有区别的你不妨仔细思考一下。
Pod的这些状态信息是我们判断应用运行情况的重要标准尤其是Pod进入了非“Running”状态后你一定要能迅速做出反应根据它所代表的异常情况开始跟踪和定位而不是去手忙脚乱地查阅文档。
## 总结
在今天这篇文章中我详细讲解了Pod API对象介绍了Pod的核心使用方法并分析了Pod和Container在字段上的异同。希望这些讲解能够帮你更好地理解和记忆Pod YAML中的核心字段以及这些字段的准确含义。
实际上Pod API对象是整个Kubernetes体系中最核心的一个概念也是后面我讲解各种控制器时都要用到的。
在学习完这篇文章后,我希望你能仔细阅读$GOPATH/src/k8s.io/kubernetes/vendor/k8s.io/api/core/v1/types.go里type Pod struct 尤其是PodSpec部分的内容。争取做到下次看到一个Pod的YAML文件时不再需要查阅文档就能做到把常用字段及其作用信手拈来。
而在下一篇文章中我会通过大量的实践帮助你巩固和进阶关于Pod API对象核心字段的使用方法敬请期待吧。
## 思考题
你能否举出一些Pod即容器的状态是Running但是应用其实已经停止服务的例子相信Java Web开发者的亲身体会会比较多吧。
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,602 @@
<audio id="audio" title="15 | 深入解析Pod对象使用进阶" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d2/e1/d2b23a32210eab0d30ed699428da80e1.mp3"></audio>
你好我是张磊。今天我和你分享的主题是深入解析Pod对象之使用进阶。
在上一篇文章中我深入解析了Pod的API对象讲解了Pod和Container的关系。
作为Kubernetes项目里最核心的编排对象Pod携带的信息非常丰富。其中资源定义比如CPU、内存等以及调度相关的字段我会在后面专门讲解调度器时再进行深入的分析。在本篇我们就先从一种特殊的Volume开始来帮助你更加深入地理解Pod对象各个重要字段的含义。
这种特殊的Volume叫作Projected Volume你可以把它翻译为“投射数据卷”。
>
备注Projected Volume是Kubernetes v1.11之后的新特性
这是什么意思呢?
在Kubernetes中有几种特殊的Volume它们存在的意义不是为了存放容器里的数据也不是用来进行容器和宿主机之间的数据交换。这些特殊Volume的作用是为容器提供预先定义好的数据。所以从容器的角度来看这些Volume里的信息就是仿佛是**被Kubernetes“投射”Project进入容器当中的**。这正是Projected Volume的含义。
到目前为止Kubernetes支持的Projected Volume一共有四种
<li>
Secret
</li>
<li>
ConfigMap
</li>
<li>
Downward API
</li>
<li>
ServiceAccountToken。
</li>
在今天这篇文章中我首先和你分享的是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
- &quot;86400&quot;
volumeMounts:
- name: mysql-cred
mountPath: &quot;/projected-volume&quot;
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字段却以Key-Value的格式保存了两份Secret数据。其中“user”就是第一份数据的Key“pass”是第二份数据的Key。
需要注意的是Secret对象要求这些数据必须是经过Base64转码的以免出现明文密码的安全隐患。这个转码操作也很简单比如
```
$ echo -n 'admin' | base64
YWRtaW4=
$ echo -n '1f2d1e2e67df' | base64
MWYyZDFlMmU2N2Rm
```
这里需要注意的是像这样创建的Secret对象它里面的内容仅仅是经过了转码而并没有被加密。在真正的生产环境中你需要在Kubernetes中开启Secret的加密插件增强数据的安全性。关于开启Secret加密插件的内容我会在后续专门讲解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。**
需要注意的是,这个更新可能会有一定的延时。所以**在编写应用程序时,在发起数据库连接的代码处写好重试和超时的逻辑,绝对是个好习惯。**
**与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的方式展示出来。
**接下来是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: [&quot;sh&quot;, &quot;-c&quot;]
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: &quot;labels&quot;
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=&quot;test-cluster1&quot;
rack=&quot;rack-22&quot;
zone=&quot;us-est-coast&quot;
```
目前Downward API支持的字段已经非常丰富了比如
```
1. 使用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['&lt;KEY&gt;'] - 指定&lt;KEY&gt;的Label值
metadata.annotations['&lt;KEY&gt;'] - 指定&lt;KEY&gt;的Annotation值
metadata.labels - Pod的所有Label
metadata.annotations - Pod的所有Annotation
2. 使用resourceFieldRef可以声明使用:
容器的CPU limit
容器的CPU request
容器的memory limit
容器的memory request
```
上面这个列表的内容随着Kubernetes项目的发展肯定还会不断增加。所以这里列出来的信息仅供参考你在使用Downward API时还是要记得去查阅一下官方文档。
不过需要注意的是Downward API能够获取到的信息**一定是Pod里的容器进程启动之前就能够确定下来的信息**。而如果你想要获取Pod容器运行后才会出现的信息比如容器进程的PID那就肯定不能使用Downward API了而应该考虑在Pod里定义一个sidecar容器。
其实Secret、ConfigMap以及Downward API这三种Projected Volume定义的信息大多还可以通过环境变量的方式出现在容器里。但是通过环境变量获取这些信息的方式不具备自动更新的能力。所以一般情况下我都建议你使用Volume文件的方式获取这些信息。
在明白了Secret之后我再为你讲解Pod中一个与它密切相关的概念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来使用这些自定义的授权信息。在后面讲解为Kubernetes开发插件的时候我们将会实践到这个操作。
接下来我们再来看Pod的另一个重要的配置容器健康检查和恢复机制。
在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”。这时如果这个文件存在这条命令的返回值就是0Pod就会认为这个容器不仅已经启动而且是健康的。这个健康检查在容器启动5 s后开始执行initialDelaySeconds: 5每5 s执行一次periodSeconds: 5
现在,让我们来**具体实践一下这个过程**。
首先创建这个Pod
```
$ kubectl create -f test-liveness-exec.yaml
```
然后查看这个Pod的状态
```
$ kubectl get pod
NAME READY STATUS RESTARTS AGE
test-liveness-exec 1/1 Running 0 10s
```
可以看到由于已经通过了健康检查这个Pod就进入了Running状态。
而30 s之后我们再查看一下Pod的Events
```
$ kubectl describe pod test-liveness-exec
```
你会发现这个Pod在Events报告了一个异常
```
FirstSeen LastSeen Count From SubobjectPath Type Reason Message
--------- -------- ----- ---- ------------- -------- ------ -------
2s 2s 1 {kubelet worker0} spec.containers{liveness} Warning Unhealthy Liveness probe failed: cat: can't open '/tmp/healthy': No such file or directory
```
显然,这个健康检查探查到/tmp/healthy已经不存在了所以它报告容器是不健康的。那么接下来会发生什么呢
我们不妨再次查看一下这个Pod的状态
```
$ kubectl get pod test-liveness-exec
NAME READY STATUS RESTARTS AGE
liveness-exec 1/1 Running 1 1m
```
这时我们发现Pod并没有进入Failed状态而是保持了Running状态。这是为什么呢
其实如果你注意到RESTARTS字段从0到1的变化就明白原因了这个异常的容器已经被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副本。这就是我在第12篇文章[《牛刀小试:我的第一个容器化应用》](https://time.geekbang.org/column/article/40008)最后给你留的思考题的答案即一个单Pod的Deployment与一个Pod最主要的区别。
而作为用户你还可以通过设置restartPolicy改变Pod的恢复策略。除了Always它还有OnFailure和Never两种情况
- Always在任何情况下只要容器不在运行状态就自动重启容器
- OnFailure: 只在容器 异常时才自动重启容器;
- Never: 从来不重启容器。
在实际使用时,我们需要根据应用运行的特性,合理设置这三种恢复策略。
比如一个Pod它只计算1+1=2计算完成输出结果后退出变成Succeeded状态。这时你如果再用restartPolicy=Always强制重启这个Pod的容器就没有任何意义了。
而如果你要关心这个容器退出后的上下文环境比如容器退出后的日志、文件和目录就需要将restartPolicy设置为Never。因为一旦容器被自动重新创建这些内容就有可能丢失掉了被垃圾回收了
值得一提的是Kubernetes的官方文档把restartPolicy和Pod里容器的状态以及Pod状态的对应关系[总结了非常复杂的一大堆情况](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#example-states)。实际上,你根本不需要死记硬背这些对应关系,只要记住如下两个基本的设计原理即可:
<li>
**只要Pod的restartPolicy指定的策略允许重启异常的容器比如Always那么这个Pod就会保持Running状态并进行容器重启**。否则Pod就会进入Failed状态 。
</li>
<li>
**对于包含多个容器的Pod只有它里面所有的容器都进入异常状态后Pod才会进入Failed状态**。在此之前Pod都是Running状态。此时Pod的READY字段会显示正常容器的个数比如
</li>
```
$ 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上来。
除了在容器中执行命令外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服务类的应用中非常常用。
在Kubernetes的Pod中还有一个叫readinessProbe的字段。虽然它的用法与livenessProbe类似但作用却大不一样。readinessProbe检查结果的成功与否决定的这个Pod是不是能被通过Service的方式访问到而并不影响Pod的生命周期。这部分内容我会在讲解Service时重点介绍。
在讲解了这么多字段之后想必你对Pod对象的语义和描述能力已经有了一个初步的感觉。
这时你有没有产生这样一个想法Pod的字段这么多我又不可能全记住Kubernetes能不能自动给Pod填充某些字段呢
这个需求实际上非常实用。比如开发人员只需要提交一个基本的、非常简单的Pod YAMLKubernetes就可以自动给对应的Pod对象加上其他必要的信息比如labelsannotationsvolumes等等。而这些信息可以是运维人员事先定义好的。
这么一来开发人员编写Pod YAML的门槛就被大大降低了。
所以这个叫作PodPresetPod预设置的功能 已经出现在了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
```
作为Kubernetes的初学者你肯定眼前一亮这不就是我最擅长编写的、最简单的Pod嘛。没错这个YAML文件里的字段想必你现在闭着眼睛也能写出来。
可是如果运维人员看到了这个Pod他一定会连连摇头这种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: &quot;6379&quot;
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: &quot;resource version&quot;
spec:
containers:
- name: website
image: nginx
volumeMounts:
- mountPath: /cache
name: cache-volume
ports:
- containerPort: 80
env:
- name: DB_PORT
value: &quot;6379&quot;
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要做的修改。而如果它们要做的修改有冲突的话这些冲突字段就不会被修改。
## 总结
在今天这篇文章中我和你详细介绍了Pod对象更高阶的使用方法希望通过对这些实例的讲解你可以更深入地理解Pod API对象的各个字段。
而在学习这些字段的同时你还应该认真体会一下Kubernetes“一切皆对象”的设计思想比如应用是Pod对象应用的配置是ConfigMap对象应用要访问的密码则是Secret对象。
所以也就自然而然地有了PodPreset这样专门用来对Pod进行批量化、自动化修改的工具对象。在后面的内容中我会为你讲解更多的这种对象还会和你介绍Kubernetes项目如何围绕着这些对象进行容器编排。
在本专栏中Pod对象相关的知识点非常重要它是接下来Kubernetes能够描述和编排各种复杂应用的基石所在希望你能够继续多实践、多体会。
## 思考题
在没有Kubernetes的时候你是通过什么方法进行应用的健康检查的Kubernetes的livenessProbe和readinessProbe提供的几种探测机制是否能满足你的需求
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,152 @@
<audio id="audio" title="16 | 编排其实很简单:谈谈“控制器”模型" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fc/da/fcc2beccf61378c5096f14bb16f6a6da.mp3"></audio>
你好,我是张磊。今天我和你分享的主题是:编排其实很简单之谈谈“控制器”模型。
在上一篇文章中我和你详细介绍了Pod的用法讲解了Pod这个API对象的各个字段。而接下来我们就一起来看看“编排”这个Kubernetes项目最核心的功能吧。
实际上,你可能已经有所感悟:**Pod这个看似复杂的API对象实际上就是对容器的进一步抽象和封装而已。**
说得更形象些,“容器”镜像虽然好用,但是容器这样一个“沙盒”的概念,对于描述应用来说,还是太过简单了。这就好比,集装箱固然好用,但是如果它四面都光秃秃的,吊车还怎么把这个集装箱吊起来并摆放好呢?
所以Pod对象其实就是容器的升级版。它对容器进行了组合添加了更多的属性和字段。这就好比给集装箱四面安装了吊环使得Kubernetes这架“吊车”可以更轻松地操作它。
而Kubernetes操作这些“集装箱”的逻辑都由控制器Controller完成。在前面的第12篇文章[《牛刀小试:我的第一个容器化应用》](https://time.geekbang.org/column/article/40008)中我们曾经使用过Deployment这个最基本的控制器对象。
现在我们一起来回顾一下这个名叫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=nginx标签的Pod的个数大于2的时候就会有旧的Pod被删除反之就会有新的Pod被创建。
这时你也许就会好奇究竟是Kubernetes项目中的哪个组件在执行这些操作呢
我在前面介绍Kubernetes架构的时候曾经提到过一个叫作kube-controller-manager的组件。
实际上这个组件就是一系列控制器的集合。我们可以查看一下Kubernetes项目的pkg/controller目录
```
$ cd kubernetes/pkg/controller/
$ ls -d */
deployment/ job/ podautoscaler/
cloud/ disruption/ namespace/
replicaset/ serviceaccount/ volume/
cronjob/ garbagecollector/ nodelifecycle/ replication/ statefulset/ daemon/
...
```
这个目录下面的每一个控制器都以独有的方式负责某种编排功能。而我们的Deployment正是这些控制器中的一种。
实际上这些控制器之所以被统一放在pkg/controller目录下就是因为它们都遵循Kubernetes项目中的一个通用编排模式控制循环control loop
比如现在有一种待编排的对象X它有一个对应的控制器。那么我就可以用一段Go语言风格的伪代码为你描述这个**控制循环**
```
for {
实际状态 := 获取集群中对象X的实际状态Actual State
期望状态 := 获取集群中对象X的期望状态Desired State
if 实际状态 == 期望状态{
什么都不做
} else {
执行编排动作,将实际状态调整为期望状态
}
}
```
**在具体实现中实际状态往往来自于Kubernetes集群本身**
比如kubelet通过心跳汇报的容器状态和节点状态或者监控系统中保存的应用监控数据或者控制器主动收集的它自己感兴趣的信息这些都是常见的实际状态的来源。
**而期望状态一般来自于用户提交的YAML文件**
比如Deployment对象中Replicas字段的值。很明显这些信息往往都保存在Etcd中。
接下来以Deployment为例我和你简单描述一下它对控制器模型的实现
<li>
Deployment控制器从Etcd中获取到所有携带了“app: nginx”标签的Pod然后统计它们的数量这就是实际状态
</li>
<li>
Deployment对象的Replicas字段的值就是期望状态
</li>
<li>
Deployment控制器将两个状态做比较然后根据比较结果确定是创建Pod还是删除已有的Pod具体如何操作Pod对象我会在下一篇文章详细介绍
</li>
可以看到一个Kubernetes对象的主要编排逻辑实际上是在第三步的“对比”阶段完成的。
这个操作通常被叫作调谐Reconcile。这个调谐的过程则被称作“Reconcile Loop”调谐循环或者“Sync Loop”同步循环
所以,如果你以后在文档或者社区中碰到这些词,都不要担心,它们其实指的都是同一个东西:控制循环。
而调谐的最终结果,往往都是对被控制对象的某种写操作。
比如增加Pod删除已有的Pod或者更新Pod的某个字段。**这也是Kubernetes项目“面向API对象编程”的一个直观体现。**
其实像Deployment这种控制器的设计原理就是我们前面提到过的“用一种对象管理另一种对象”的“艺术”。
其中这个控制器对象本身负责定义被管理对象的期望状态。比如Deployment里的replicas=2这个字段。
而被控制对象的定义则来自于一个“模板”。比如Deployment里的template字段。
可以看到Deployment这个template字段里的内容跟一个标准的Pod对象的API定义丝毫不差。而所有被这个Deployment管理的Pod实例其实都是根据这个template字段的内容创建出来的。
像Deployment定义的template字段在Kubernetes项目中有一个专有的名字叫作PodTemplatePod模板
这个概念非常重要因为后面我要讲解到的大多数控制器都会使用PodTemplate来统一定义它所要管理的Pod。更有意思的是我们还会看到其他类型的对象模板比如Volume的模板。
至此我们就可以对Deployment以及其他类似的控制器做一个简单总结了
<img src="https://static001.geekbang.org/resource/image/72/26/72cc68d82237071898a1d149c8354b26.png" alt="" />
如上图所示,**类似Deployment这样的一个控制器实际上都是由上半部分的控制器定义包括期望状态加上下半部分的被控制对象的模板组成的。**
这就是为什么在所有API对象的Metadata里都有一个字段叫作ownerReference用于保存当前这个API对象的拥有者Owner的信息。
那么对于我们这个nginx-deployment来说它创建出来的Pod的ownerReference就是nginx-deployment吗或者说nginx-deployment所直接控制的就是Pod对象么
这个问题的答案,我就留到下一篇文章时再做详细解释吧。
## 总结
在今天这篇文章中我以Deployment为例和你详细分享了Kubernetes项目如何通过一个称作“控制器模式”controller pattern的设计方法来统一地实现对各种不同的对象或者资源进行的编排操作。
在后面的讲解中我还会讲到很多不同类型的容器编排功能比如StatefulSet、DaemonSet等等它们无一例外地都有这样一个甚至多个控制器的存在并遵循控制循环control loop的流程完成各自的编排逻辑。
实际上跟Deployment相似这些控制循环最后的执行结果要么就是创建、更新一些Pod或者其他的API对象、资源要么就是删除一些已经存在的Pod或者其他的API对象、资源
但也正是在这个统一的编排框架下,不同的控制器可以在具体执行过程中,设计不同的业务逻辑,从而达到不同的编排效果。
这个实现思路正是Kubernetes项目进行容器编排的核心原理。在此后讲解Kubernetes编排功能的文章中我都会遵循这个逻辑展开并且带你逐步领悟控制器模式在不同的容器化作业中的实现方式。
## 思考题
你能否说出Kubernetes使用的这个“控制器模式”跟我们平常所说的“事件驱动”有什么区别和联系吗
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,436 @@
<audio id="audio" title="17 | 经典PaaS的记忆作业副本与水平扩展" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e4/bd/e443846dd934662f1084a0e8c4984abd.mp3"></audio>
你好我是张磊。今天我和你分享的主题是经典PaaS的记忆之作业副本与水平扩展。
在上一篇文章中我为你详细介绍了Kubernetes项目中第一个重要的设计思想控制器模式。
而在今天这篇文章中我就来为你详细讲解一下Kubernetes里第一个控制器模式的完整实现Deployment。
Deployment看似简单但实际上它实现了Kubernetes项目中一个非常重要的功能Pod的“水平扩展/收缩”horizontal scaling out/in。这个功能是从PaaS时代开始一个平台级项目就必须具备的编排能力。
举个例子如果你更新了Deployment的Pod模板比如修改了容器的镜像那么Deployment就需要遵循一种叫作“滚动更新”rolling update的方式来升级现有的容器。
而这个能力的实现依赖的是Kubernetes项目中的一个非常重要的概念API对象ReplicaSet。
ReplicaSet的结构非常简单我们可以通过这个YAML文件查看一下
```
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
```
从这个YAML文件中我们可以看到**一个ReplicaSet对象其实就是由副本数目的定义和一个Pod模板组成的**。不难发现它的定义其实是Deployment的一个子集。
**更重要的是Deployment控制器实际操纵的正是这样的ReplicaSet对象而不是Pod对象。**
还记不记得我在上一篇文章[《编排其实很简单:谈谈“控制器”模型》](https://time.geekbang.org/column/article/40583)中曾经提出过这样一个问题对于一个Deployment所管理的Pod它的ownerReference是谁
所以这个问题的答案就是ReplicaSet。
明白了这个原理我再来和你一起分析一个如下所示的Deployment
```
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.7.9
ports:
- containerPort: 80
```
可以看到这就是一个我们常用的nginx-deployment它定义的Pod副本个数是3spec.replicas=3
那么在具体的实现上这个Deployment与ReplicaSet以及Pod的关系是怎样的呢
我们可以用一张图把它描述出来:
<img src="https://static001.geekbang.org/resource/image/71/58/711c07208358208e91fa7803ebc73058.jpg" alt="">
通过这张图我们就很清楚地看到一个定义了replicas=3的Deployment与它的ReplicaSet以及Pod的关系实际上是一种“层层控制”的关系。
其中ReplicaSet负责通过“控制器模式”保证系统中Pod的个数永远等于指定的个数比如3个。这也正是Deployment只允许容器的restartPolicy=Always的主要原因只有在容器能保证自己始终是Running状态的前提下ReplicaSet调整Pod的个数才有意义。
而在此基础上Deployment同样通过“控制器模式”来操作ReplicaSet的个数和属性进而实现“水平扩展/收缩”和“滚动更新”这两个编排动作。
其中,“水平扩展/收缩”非常容易实现Deployment Controller只需要修改它所控制的ReplicaSet的Pod副本个数就可以了。
比如把这个值从3改成4那么Deployment所对应的ReplicaSet就会根据修改后的值自动创建一个新的Pod。这就是“水平扩展”了“水平收缩”则反之。
而用户想要执行这个操作的指令也非常简单就是kubectl scale比如
```
$ kubectl scale deployment nginx-deployment --replicas=4
deployment.apps/nginx-deployment scaled
```
那么,“滚动更新”又是什么意思,是如何实现的呢?
接下来我还以这个Deployment为例来为你讲解“滚动更新”的过程。
首先我们来创建这个nginx-deployment
```
$ kubectl create -f nginx-deployment.yaml --record
```
注意在这里我额外加了一个record参数。它的作用是记录下你每次操作所执行的命令以方便后面查看。
然后我们来检查一下nginx-deployment创建后的状态信息
```
$ kubectl get deployments
NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
nginx-deployment 3 0 0 0 1s
```
在返回结果中,我们可以看到四个状态字段,它们的含义如下所示。
<li>
DESIRED用户期望的Pod副本个数spec.replicas的值
</li>
<li>
CURRENT当前处于Running状态的Pod的个数
</li>
<li>
UP-TO-DATE当前处于最新版本的Pod的个数所谓最新版本指的是Pod的Spec部分与Deployment里Pod模板里定义的完全一致
</li>
<li>
AVAILABLE当前已经可用的Pod的个数既是Running状态又是最新版本并且已经处于Ready健康检查正确状态的Pod的个数。
</li>
可以看到只有这个AVAILABLE字段描述的才是用户所期望的最终状态。
而Kubernetes项目还为我们提供了一条指令让我们可以实时查看Deployment对象的状态变化。这个指令就是kubectl rollout status
```
$ kubectl rollout status deployment/nginx-deployment
Waiting for rollout to finish: 2 out of 3 new replicas have been updated...
deployment.apps/nginx-deployment successfully rolled out
```
在这个返回结果中“2 out of 3 new replicas have been updated”意味着已经有2个Pod进入了UP-TO-DATE状态。
继续等待一会儿我们就能看到这个Deployment的3个Pod就进入到了AVAILABLE状态
```
NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE
nginx-deployment 3 3 3 3 20s
```
此时你可以尝试查看一下这个Deployment所控制的ReplicaSet
```
$ 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在我们这个例子里就是3167673210。ReplicaSet会把这个随机字符串加在它所控制的所有Pod的标签里从而保证这些Pod不会与集群里的其他Pod混淆。
而ReplicaSet的DESIRED、CURRENT和READY字段的含义和Deployment中是一致的。所以**相比之下Deployment只是在ReplicaSet的基础上添加了UP-TO-DATE这个跟版本有关的状态字段。**
这个时候如果我们修改了Deployment的Pod模板“滚动更新”就会被自动触发。
修改Deployment有很多方法。比如我可以直接使用kubectl edit指令编辑Etcd里的API对象。
```
$ kubectl edit deployment/nginx-deployment
...
spec:
containers:
- name: nginx
image: nginx:1.9.1 # 1.7.9 -&gt; 1.9.1
ports:
- containerPort: 80
...
deployment.extensions/nginx-deployment edited
```
这个kubectl edit指令会帮你直接打开nginx-deployment的API对象。然后你就可以修改这里的Pod模板部分了。比如在这里我将nginx镜像的版本升级到了1.9.1。
>
备注kubectl edit并不神秘它不过是把API对象的内容下载到了本地文件让你修改完成后再提交上去。
kubectl edit指令编辑完成后保存退出Kubernetes就会立刻触发“滚动更新”的过程。你还可以通过kubectl rollout status指令查看nginx-deployment的状态变化
```
$ kubectl rollout status deployment/nginx-deployment
Waiting for rollout to finish: 2 out of 3 new replicas have been updated...
deployment.extensions/nginx-deployment successfully rolled out
```
这时你可以通过查看Deployment的Events看到这个“滚动更新”的流程
```
$ kubectl describe deployment nginx-deployment
...
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
```
可以看到首先当你修改了Deployment里的Pod定义之后Deployment Controller会使用这个修改后的Pod模板创建一个新的ReplicaSethash=1764197365这个新的ReplicaSet的初始Pod副本数是0。
然后在Age=24 s的位置Deployment Controller开始将这个新的ReplicaSet所控制的Pod副本数从0个变成1个“水平扩展”出一个副本。
紧接着在Age=22 s的位置Deployment Controller又将旧的ReplicaSethash=3167673210所控制的旧Pod副本数减少一个“水平收缩”成两个副本。
如此交替进行新ReplicaSet管理的Pod副本数从0个变成1个再变成2个最后变成3个。而旧的ReplicaSet管理的Pod副本数则从3个变成2个再变成1个最后变成0个。这样就完成了这一组Pod的版本升级过程。
像这样,**将一个集群中正在运行的多个Pod版本交替地逐一升级的过程就是“滚动更新”。**
在这个“滚动更新”过程完成之后你可以查看一下新、旧两个ReplicaSet的最终状态
```
$ kubectl get rs
NAME DESIRED CURRENT READY AGE
nginx-deployment-1764197365 3 3 3 6s
nginx-deployment-3167673210 0 0 0 30s
```
其中旧ReplicaSethash=3167673210已经被“水平收缩”成了0个副本。
**这种“滚动更新”的好处是显而易见的。**
比如在升级刚开始的时候集群里只有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的关系图了。
<img src="https://static001.geekbang.org/resource/image/bb/5d/bbc4560a053dee904e45ad66aac7145d.jpg" alt="">
如上所示Deployment的控制器实际上控制的是ReplicaSet的数目以及每个ReplicaSet的属性。
而一个应用的版本对应的正是一个ReplicaSet这个版本应用的Pod数量则由ReplicaSet通过它自己的控制器ReplicaSet Controller来保证。
通过这样的多个ReplicaSet对象Kubernetes项目就实现了对多个“应用版本”的描述。
而明白了“应用版本和ReplicaSet一一对应”的设计思想之后我就可以为你讲解一下Deployment对应用进行版本控制的具体原理了。
这一次,我会使用一个叫**kubectl set image**的指令直接修改nginx-deployment所使用的镜像。这个命令的好处就是你可以不用像kubectl edit那样需要打开编辑器。
不过这一次我把这个镜像名字修改成为了一个错误的名字比如nginx:1.91。这样这个Deployment就会出现一个升级失败的版本。
我们一起来实践一下:
```
$ kubectl set image deployment/nginx-deployment nginx=nginx:1.91
deployment.extensions/nginx-deployment image updated
```
由于这个nginx:1.91镜像在Docker Hub中并不存在所以这个Deployment的“滚动更新”被触发后会立刻报错并停止。
这时我们来检查一下ReplicaSet的状态如下所示
```
$ kubectl get rs
NAME DESIRED CURRENT READY AGE
nginx-deployment-1764197365 2 2 2 24s
nginx-deployment-3167673210 0 0 0 35s
nginx-deployment-2156724341 2 2 0 7s
```
通过这个返回结果我们可以看到新版本的ReplicaSethash=2156724341的“水平扩展”已经停止。而且此时它已经创建了两个Pod但是它们都没有进入READY状态。这当然是因为这两个Pod都拉取不到有效的镜像。
与此同时旧版本的ReplicaSethash=1764197365的“水平收缩”也自动停止了。此时已经有一个旧Pod被删除还剩下两个旧Pod。
那么问题来了, 我们如何让这个Deployment的3个Pod都回滚到以前的旧版本呢
我们只需要执行一条kubectl rollout undo命令就能把整个Deployment回滚到上一个版本
```
$ kubectl rollout undo deployment/nginx-deployment
deployment.extensions/nginx-deployment
```
很容易想到在具体操作上Deployment的控制器其实就是让这个旧ReplicaSethash=1764197365再次“扩展”成3个Pod而让新的ReplicaSethash=2156724341重新“收缩”到0个Pod。
更进一步地,如果我想回滚到更早之前的版本,要怎么办呢?
**首先我需要使用kubectl rollout history命令查看每次Deployment变更对应的版本**。而由于我们在创建这个Deployment的时候指定了record参数所以我们创建这些版本时执行的kubectl命令都会被记录下来。这个操作的输出如下所示
```
$ kubectl rollout history deployment/nginx-deployment
deployments &quot;nginx-deployment&quot;
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
```
可以看到我们前面执行的创建和更新操作分别对应了版本1和版本2而那次失败的更新操作则对应的是版本3。
当然你还可以通过这个kubectl rollout history指令看到每个版本对应的Deployment的API对象的细节具体命令如下所示
```
$ kubectl rollout history deployment/nginx-deployment --revision=2
```
**然后我们就可以在kubectl rollout undo命令行最后加上要回滚到的指定版本的版本号就可以回滚到指定版本了**。这个指令的用法如下:
```
$ kubectl rollout undo deployment/nginx-deployment --to-revision=2
deployment.extensions/nginx-deployment
```
这样Deployment Controller还会按照“滚动更新”的方式完成对Deployment的降级操作。
不过你可能已经想到了一个问题我们对Deployment进行的每一次更新操作都会生成一个新的ReplicaSet对象是不是有些多余甚至浪费资源呢
没错。
所以Kubernetes项目还提供了一个指令使得我们对Deployment的多次更新操作最后 只生成一个ReplicaSet。
具体的做法是在更新Deployment前你要先执行一条kubectl rollout pause指令。它的用法如下所示
```
$ kubectl rollout pause deployment/nginx-deployment
deployment.extensions/nginx-deployment paused
```
这个kubectl rollout pause的作用是让这个Deployment进入了一个“暂停”状态。
所以接下来你就可以随意使用kubectl edit或者kubectl set image指令修改这个Deployment的内容了。
由于此时Deployment正处于“暂停”状态所以我们对Deployment的所有修改都不会触发新的“滚动更新”也不会创建新的ReplicaSet。
而等到我们对Deployment修改操作都完成之后只需要再执行一条kubectl rollout resume指令就可以把这个Deployment“恢复”回来如下所示
```
$ kubectl rollout resume deployment/nginx-deployment
deployment.extensions/nginx-deployment resumed
```
而在这个kubectl rollout resume指令执行之前在kubectl rollout pause指令之后的这段时间里我们对Deployment进行的所有修改最后只会触发一次“滚动更新”。
当然我们可以通过检查ReplicaSet状态的变化来验证一下kubectl rollout pause和kubectl rollout resume指令的执行效果如下所示
```
$ kubectl get rs
NAME DESIRED CURRENT READY AGE
nginx-1764197365 0 0 0 2m
nginx-3196763511 3 3 3 28s
```
通过返回结果我们可以看到只有一个hash=3196763511的ReplicaSet被创建了出来。
不过即使你像上面这样小心翼翼地控制了ReplicaSet的生成数量随着应用版本的不断增加Kubernetes中还是会为同一个Deployment保存很多很多不同的ReplicaSet。
那么我们又该如何控制这些“历史”ReplicaSet的数量呢
很简单Deployment对象有一个字段叫作spec.revisionHistoryLimit就是Kubernetes为Deployment保留的“历史版本”个数。所以如果把它设置为0你就再也不能做回滚操作了。
## 总结
在今天这篇文章中我为你详细讲解了Deployment这个Kubernetes项目中最基本的编排控制器的实现原理和使用方法。
通过这些讲解你应该了解到Deployment实际上是一个**两层控制器**。首先,它通过**ReplicaSet的个数**来描述应用的版本;然后,它再通过**ReplicaSet的属性**比如replicas的值来保证Pod的副本数量。
>
备注Deployment控制ReplicaSet版本ReplicaSet控制Pod副本数。这个两层控制关系一定要牢记。
不过相信你也能够感受到Kubernetes项目对Deployment的设计实际上是代替我们完成了对“应用”的抽象使得我们可以使用这个Deployment对象来描述应用使用kubectl rollout命令控制应用的版本。
可是在实际使用场景中应用发布的流程往往千差万别也可能有很多的定制化需求。比如我的应用可能有会话黏连session sticky这就意味着“滚动更新”的时候哪个Pod能下线是不能随便选择的。
这种场景光靠Deployment自己就很难应对了。对于这种需求我在专栏后续文章中重点介绍的“自定义控制器”就可以帮我们实现一个功能更加强大的Deployment Controller。
当然Kubernetes项目本身也提供了另外一种抽象方式帮我们应对其他一些用Deployment无法处理的应用编排场景。这个设计就是对有状态应用的管理也是我在下一篇文章中要重点讲解的内容。
## 思考题
你听说过金丝雀发布Canary Deployment和蓝绿发布Blue-Green Deployment你能说出它们是什么意思吗
实际上有了Deployment的能力之后你可以非常轻松地用它来实现金丝雀发布、蓝绿发布以及A/B测试等很多应用发布模式。这些问题的答案都在[这个GitHub库](https://github.com/ContainerSolutions/k8s-deployment-strategies/tree/master/canary),建议你在课后实践一下。
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,292 @@
<audio id="audio" title="18 | 深入理解StatefulSet拓扑状态" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/89/12/896c974f179010713e58ada2f49d7c12.mp3"></audio>
你好我是张磊。今天我和你分享的主题是深入理解StatefulSet之拓扑状态。
在上一篇文章中我在结尾处讨论到了Deployment实际上并不足以覆盖所有的应用编排问题。
造成这个问题的根本原因在于Deployment对应用做了一个简单化假设。
它认为一个应用的所有Pod是完全一样的。所以它们互相之间没有顺序也无所谓运行在哪台宿主机上。需要的时候Deployment就可以通过Pod模板创建新的Pod不需要的时候Deployment就可以“杀掉”任意一个Pod。
但是,在实际的场景中,并不是所有的应用都可以满足这样的要求。
尤其是分布式应用,它的多个实例之间,往往有依赖关系,比如:主从关系、主备关系。
还有就是数据存储类应用,它的多个实例,往往都会在本地磁盘上保存一份数据。而这些实例一旦被杀掉,即便重建出来,实例与数据之间的对应关系也已经丢失,从而导致应用失败。
所以这种实例之间有不对等关系以及实例对外部数据有依赖关系的应用就被称为“有状态应用”Stateful Application
容器技术诞生后大家很快发现它用来封装“无状态应用”Stateless Application尤其是Web服务非常好用。但是一旦你想要用容器运行“有状态应用”其困难程度就会直线上升。而且这个问题解决起来单纯依靠容器技术本身已经无能为力这也就导致了很长一段时间内“有状态应用”几乎成了容器技术圈子的“忌讳”大家一听到这个词就纷纷摇头。
不过Kubernetes项目还是成为了“第一个吃螃蟹的人”。
得益于“控制器模式”的设计思想Kubernetes项目很早就在Deployment的基础上扩展出了对“有状态应用”的初步支持。这个编排功能就是StatefulSet。
StatefulSet的设计其实非常容易理解。它把真实世界里的应用状态抽象为了两种情况
<li>
**拓扑状态**。这种情况意味着应用的多个实例之间不是完全对等的关系。这些应用实例必须按照某些顺序启动比如应用的主节点A要先于从节点B启动。而如果你把A和B两个Pod删除掉它们再次被创建出来时也必须严格按照这个顺序才行。并且新创建出来的Pod必须和原来Pod的网络标识一样这样原先的访问者才能使用同样的方法访问到这个新Pod。
</li>
<li>
**存储状态**。这种情况意味着应用的多个实例分别绑定了不同的存储数据。对于这些应用实例来说Pod A第一次读取到的数据和隔了十分钟之后再次读取到的数据应该是同一份哪怕在此期间Pod A被重新创建过。这种情况最典型的例子就是一个数据库应用的多个存储实例。
</li>
所以,**StatefulSet的核心功能就是通过某种方式记录这些状态然后在Pod被重新创建时能够为新Pod恢复这些状态。**
在开始讲述StatefulSet的工作原理之前我就必须先为你讲解一个Kubernetes项目中非常实用的概念Headless Service。
我在和你一起讨论Kubernetes架构的时候就曾介绍过Service是Kubernetes项目中用来将一组Pod暴露给外界访问的一种机制。比如一个Deployment有3个Pod那么我就可以定义一个Service。然后用户只要能访问到这个Service它就能访问到某个具体的Pod。
那么这个Service又是如何被访问的呢
**第一种方式是以Service的VIPVirtual IP虚拟IP方式**。比如当我访问10.0.23.1这个Service的IP地址时10.0.23.1其实就是一个VIP它会把请求转发到该Service所代理的某一个Pod上。这里的具体原理我会在后续的Service章节中进行详细介绍。
**第二种方式就是以Service的DNS方式**。比如这时候只要我访问“my-svc.my-namespace.svc.cluster.local”这条DNS记录就可以访问到名叫my-svc的Service所代理的某一个Pod。
而在第二种Service DNS的方式下具体还可以分为两种处理方法
第一种处理方法是Normal Service。这种情况下你访问“my-svc.my-namespace.svc.cluster.local”解析到的正是my-svc这个Service的VIP后面的流程就跟VIP方式一致了。
而第二种处理方法正是Headless Service。这种情况下你访问“my-svc.my-namespace.svc.cluster.local”解析到的直接就是my-svc代理的某一个Pod的IP地址。**可以看到这里的区别在于Headless Service不需要分配一个VIP而是可以直接以DNS记录的方式解析出被代理Pod的IP地址。**
那么,这样的设计又有什么作用呢?
想要回答这个问题我们需要从Headless Service的定义方式看起。
下面是一个标准的Headless Service对应的YAML文件
```
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作为“头”。这也就是Headless的含义。所以这个Service被创建后并不会被分配一个VIP而是会以DNS记录的方式暴露出它所代理的Pod。
而它所代理的Pod依然是采用我在前面第12篇文章[《牛刀小试:我的第一个容器化应用》](https://time.geekbang.org/column/article/40008)中提到的Label Selector机制选择出来的所有携带了app=nginx标签的Pod都会被这个Service代理起来。
然后关键来了。
当你按照这样的方式创建了一个Headless Service之后它所代理的所有Pod的IP地址都会被绑定一个这样格式的DNS记录如下所示
```
&lt;pod-name&gt;.&lt;svc-name&gt;.&lt;namespace&gt;.svc.cluster.local
```
这个DNS记录正是Kubernetes项目为Pod分配的唯一的“可解析身份”Resolvable Identity
有了这个“可解析身份”只要你知道了一个Pod的名字以及它对应的Service的名字你就可以非常确定地通过这条DNS记录访问到Pod的IP地址。
那么StatefulSet又是如何使用这个DNS记录来维持Pod的拓扑状态的呢
为了回答这个问题现在我们就来编写一个StatefulSet的YAML文件如下所示
```
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
serviceName: &quot;nginx&quot;
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
```
这个YAML文件和我们在前面文章中用到的nginx-deployment的唯一区别就是多了一个serviceName=nginx字段。
这个字段的作用就是告诉StatefulSet控制器在执行控制循环Control Loop的时候请使用nginx这个Headless Service来保证Pod的“可解析身份”。
所以当你通过kubectl create创建了上面这个Service和StatefulSet之后就会看到如下两个对象
```
$ kubectl create -f svc.yaml
$ kubectl get service nginx
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
nginx ClusterIP None &lt;none&gt; 80/TCP 10s
$ kubectl create -f statefulset.yaml
$ kubectl get statefulset web
NAME DESIRED CURRENT AGE
web 2 1 19s
```
这时候如果你手比较快的话还可以通过kubectl的-w参数Watch功能实时查看StatefulSet创建两个有状态实例的过程
>
备注如果手不够快的话Pod很快就创建完了。不过你依然可以通过这个StatefulSet的Events看到这些信息。
```
$ 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
```
通过上面这个Pod的创建过程我们不难看到StatefulSet给它所管理的所有Pod的名字进行了编号编号规则是<statefulset name="">-<ordinal index=""></ordinal></statefulset>
而且这些编号都是从0开始累加与StatefulSet的每个Pod实例一一对应绝不重复。
更重要的是这些Pod的创建也是严格按照编号顺序进行的。比如在web-0进入到Running状态、并且细分状态Conditions成为Ready之前web-1会一直处于Pending状态。
>
备注Ready状态再一次提醒了我们为Pod设置livenessProbe和readinessProbe的重要性。
当这两个Pod都进入了Running状态之后你就可以查看到它们各自唯一的“网络身份”了。
我们使用kubectl exec命令进入到容器中查看它们的hostname
```
$ kubectl exec web-0 -- sh -c 'hostname'
web-0
$ kubectl exec web-1 -- sh -c 'hostname'
web-1
```
可以看到这两个Pod的hostname与Pod名字是一致的都被分配了对应的编号。接下来我们再试着以DNS的方式访问一下这个Headless Service
```
$ kubectl run -i --tty --image busybox:1.28.4 dns-test --restart=Never --rm /bin/sh
```
通过这条命令我们启动了一个一次性的Pod因为rm意味着Pod退出后就会被删除掉。然后在这个Pod的容器里面我们尝试用nslookup命令解析一下Pod对应的Headless Service
```
$ kubectl run -i --tty --image busybox:1.28.4 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地址而当访问web-1.nginx的时候解析到的则是web-1的IP地址。
这时候如果你在另外一个Terminal里把这两个“有状态应用”的Pod删掉
```
$ kubectl delete pod -l app=nginx
pod &quot;web-0&quot; deleted
pod &quot;web-1&quot; deleted
```
然后再在当前Terminal里Watch一下这两个Pod的状态变化就会发现一个有趣的现象
```
$ kubectl get pod -w -l app=nginx
NAME READY STATUS RESTARTS AGE
web-0 0/1 ContainerCreating 0 0s
NAME READY STATUS RESTARTS AGE
web-0 1/1 Running 0 2s
web-1 0/1 Pending 0 0s
web-1 0/1 ContainerCreating 0 0s
web-1 1/1 Running 0 32s
```
可以看到当我们把这两个Pod删除之后Kubernetes会按照原先编号的顺序创建出了两个新的Pod。并且Kubernetes依然为它们分配了与原来相同的“网络身份”web-0.nginx和web-1.nginx。
通过这种严格的对应规则,**StatefulSet就保证了Pod网络标识的稳定性**。
比如如果web-0是一个需要先启动的主节点web-1是一个后启动的从节点那么只要这个StatefulSet不被删除你访问web-0.nginx时始终都会落在主节点上访问web-1.nginx时则始终都会落在从节点上这个关系绝对不会发生任何变化。
所以如果我们再用nslookup命令查看一下这个新Pod对应的Headless 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.8
$ 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.8
```
我们可以看到在这个StatefulSet中这两个新Pod的“网络标识”比如web-0.nginx和web-1.nginx再次解析到了正确的IP地址比如web-0 Pod的IP地址10.244.1.8)。
通过这种方法,**Kubernetes就成功地将Pod的拓扑状态比如哪个节点先启动哪个节点后启动按照Pod的“名字+编号”的方式固定了下来**。此外Kubernetes还为每一个Pod提供了一个固定并且唯一的访问入口这个Pod对应的DNS记录。
这些状态在StatefulSet的整个生命周期里都会保持不变绝不会因为对应Pod的删除或者重新创建而失效。
不过相信你也已经注意到了尽管web-0.nginx这条记录本身不会变但它解析到的Pod的IP地址并不是固定的。这就意味着对于“有状态应用”实例的访问你必须使用DNS记录或者hostname的方式而绝不应该直接访问这些Pod的IP地址。
## 总结
在今天这篇文章中我首先和你分享了StatefulSet的基本概念解释了什么是应用的“状态”。
紧接着 我为你分析了StatefulSet如何保证应用实例之间“拓扑状态”的稳定性。
如果用一句话来总结的话,你可以这么理解这个过程:
>
StatefulSet这个控制器的主要作用之一就是使用Pod模板创建Pod的时候对它们进行编号并且按照编号顺序逐一完成创建工作。而当StatefulSet的“控制循环”发现Pod的“实际状态”与“期望状态”不一致需要新建或者删除Pod进行“调谐”的时候它会严格按照这些Pod编号的顺序逐一完成这些操作。
所以StatefulSet其实可以认为是对Deployment的改良。
与此同时通过Headless Service的方式StatefulSet为每个Pod创建了一个固定并且稳定的DNS记录来作为它的访问入口。
实际上,在部署“有状态应用”的时候,应用的每个实例拥有唯一并且稳定的“网络标识”,是一个非常重要的假设。
在下一篇文章中我将会继续为你剖析StatefulSet如何处理存储状态。
## 思考题
你曾经运维过哪些有拓扑状态的应用呢比如主从、主主、主备、一主多从等结构你觉得这些应用实例之间的拓扑关系能否借助这种为Pod实例编号的方式表达出来呢如果不能你觉得Kubernetes还应该为你提供哪些支持来管理这个拓扑状态呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,288 @@
<audio id="audio" title="19 | 深入理解StatefulSet存储状态" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bf/55/bfcecfb762d864ae92406cc8dd988355.mp3"></audio>
你好我是张磊。今天我和你分享的主题是深入理解StatefulSet之存储状态。
在上一篇文章中我和你分享了StatefulSet如何保证应用实例的拓扑状态在Pod删除和再创建的过程中保持稳定。
而在今天这篇文章中我将继续为你解读StatefulSet对存储状态的管理机制。这个机制主要使用的是一个叫作Persistent Volume Claim的功能。
在前面介绍Pod的时候我曾提到过要在一个Pod里声明Volume只要在Pod里加上spec.volumes字段即可。然后你就可以在这个字段里定义一个具体类型的Volume了比如hostPath。
可是,你有没有想过这样一个场景:**如果你并不知道有哪些Volume类型可以用要怎么办呢**
更具体地说作为一个应用开发者我可能对持久化存储项目比如Ceph、GlusterFS等一窍不通也不知道公司的Kubernetes集群里到底是怎么搭建出来的我也自然不会编写它们对应的Volume定义文件。
所谓“术业有专攻”这些关于Volume的管理和远程持久化存储的知识不仅超越了开发者的知识储备还会有暴露公司基础设施秘密的风险。
比如下面这个例子就是一个声明了Ceph RBD类型Volume的Pod
```
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: &quot;2&quot;
imagefeatures: &quot;layering&quot;
```
其一如果不懂得Ceph RBD的使用方法那么这个Pod里Volumes字段你十有八九也完全看不懂。其二这个Ceph RBD对应的存储服务器的地址、用户名、授权文件的位置也都被轻易地暴露给了全公司的所有开发人员这是一个典型的信息被“过度暴露”的例子。
这也是为什么,在后来的演化中,**Kubernetes项目引入了一组叫作Persistent Volume ClaimPVC和Persistent VolumePV的API对象大大降低了用户声明和使用持久化Volume的门槛。**
举个例子有了PVC之后一个开发人员想要使用一个Volume只需要简单的两步即可。
**第一步定义一个PVC声明想要的Volume的属性**
```
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: pv-claim
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
```
可以看到在这个PVC对象里不需要任何关于Volume细节的字段只有描述性的属性和定义。比如storage: 1Gi表示我想要的Volume大小至少是1 GiBaccessModes: ReadWriteOnce表示这个Volume的挂载方式是可读写并且只能被挂载在一个节点上而非被多个节点共享。
>
备注关于哪种类型的Volume支持哪种类型的AccessMode你可以查看Kubernetes项目官方文档中的[详细列表](https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes)。
**第二步在应用的Pod中声明使用这个PVC**
```
apiVersion: v1
kind: Pod
metadata:
name: pv-pod
spec:
containers:
- name: pv-container
image: nginx
ports:
- containerPort: 80
name: &quot;http-server&quot;
volumeMounts:
- mountPath: &quot;/usr/share/nginx/html&quot;
name: pv-storage
volumes:
- name: pv-storage
persistentVolumeClaim:
claimName: pv-claim
```
可以看到在这个Pod的Volumes定义中我们只需要声明它的类型是persistentVolumeClaim然后指定PVC的名字而完全不必关心Volume本身的定义。
这时候只要我们创建这个PVC对象Kubernetes就会自动为它绑定一个符合条件的Volume。可是这些符合条件的Volume又是从哪里来的呢
答案是它们来自于由运维人员维护的PVPersistent Volume对象。接下来我们一起看一个常见的PV对象的YAML文件
```
kind: PersistentVolume
apiVersion: v1
metadata:
name: pv-volume
labels:
type: local
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
rbd:
monitors:
# 使用 kubectl get pods -n rook-ceph 查看 rook-ceph-mon- 开头的 POD IP 即可得下面的列表
- '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
```
可以看到这个PV对象的spec.rbd字段正是我们前面介绍过的Ceph RBD Volume的详细定义。而且它还声明了这个PV的容量是10 GiB。这样Kubernetes就会为我们刚刚创建的PVC对象绑定这个PV。
所以Kubernetes中PVC和PV的设计**实际上类似于“接口”和“实现”的思想**。开发者只要知道并会使用“接口”PVC而运维人员则负责给“接口”绑定具体的实现PV。
这种解耦,就避免了因为向开发者暴露过多的存储系统细节而带来的隐患。此外,这种职责的分离,往往也意味着出现事故时可以更容易定位问题和明确责任,从而避免“扯皮”现象的出现。
而PVC、PV的设计也使得StatefulSet对存储状态的管理成为了可能。我们还是以上一篇文章中用到的StatefulSet为例你也可以借此再回顾一下[《深入理解StatefulSet拓扑状态》](https://time.geekbang.org/column/article/41017)中的相关内容):
```
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: web
spec:
serviceName: &quot;nginx&quot;
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里Pod模板PodTemplate的作用类似。也就是说凡是被这个StatefulSet管理的Pod都会声明一个对应的PVC而这个PVC的定义就来自于volumeClaimTemplates这个模板字段。更重要的是这个PVC的名字会被分配一个与这个Pod完全一致的编号。
这个自动创建的PVC与PV绑定成功后就会进入Bound状态这就意味着这个Pod可以挂载并使用这个PV了。
如果你还是不太理解PVC的话可以先记住这样一个结论**PVC其实就是一种特殊的Volume**。只不过一个PVC具体是什么类型的Volume要在跟某个PV绑定之后才知道。关于PV、PVC更详细的知识我会在容器存储部分做进一步解读。
当然PVC与PV的绑定得以实现的前提是运维人员已经在系统里创建好了符合条件的PV比如我们在前面用到的pv-volume或者你的Kubernetes集群运行在公有云上这样Kubernetes就会通过Dynamic Provisioning的方式自动为你创建与PVC匹配的PV。
所以我们在使用kubectl create创建了StatefulSet之后就会看到Kubernetes集群里出现了两个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都以“&lt;PVC名字&gt;-&lt;StatefulSet名字&gt;-&lt;编号&gt;”的方式命名并且处于Bound状态。
我们前面已经讲到过这个StatefulSet创建出来的所有Pod都会声明使用编号的PVC。比如在名叫web-0的Pod的volumes字段它会声明使用名叫www-web-0的PVC从而挂载到这个PVC所绑定的PV。
所以我们就可以使用如下所示的指令在Pod的Volume目录里写入一个文件来验证一下上述Volume的分配情况
```
$ for i in 0 1; do kubectl exec web-$i -- sh -c 'echo hello $(hostname) &gt; /usr/share/nginx/html/index.html'; done
```
如上所示通过kubectl exec指令我们在每个Pod的Volume目录里写入了一个index.html文件。这个文件的内容正是Pod的hostname。比如我们在web-0的index.html里写入的内容就是"hello web-0"。
此时如果你在这个Pod容器里访问`“http://localhost”`你实际访问到的就是Pod里Nginx服务器进程而它会为你返回/usr/share/nginx/html/index.html里的内容。这个操作的执行方法如下所示
```
$ for i in 0 1; do kubectl exec -it web-$i -- curl localhost; done
hello web-0
hello web-1
```
现在,关键来了。
如果你使用kubectl delete命令删除这两个Pod这些Volume里的文件会不会丢失呢
```
$ kubectl delete pod -l app=nginx
pod &quot;web-0&quot; deleted
pod &quot;web-1&quot; deleted
```
可以看到正如我们前面介绍过的在被删除之后这两个Pod会被按照编号的顺序被重新创建出来。而这时候如果你在新创建的容器里通过访问`“http://localhost”`的方式去访问web-0里的Nginx服务
```
# 在被重新创建出来的Pod容器里访问http://localhost
$ kubectl exec -it web-0 -- curl localhost
hello web-0
```
就会发现这个请求依然会返回hello web-0。也就是说原先与名叫web-0的Pod绑定的PV在这个Pod被重新创建之后依然同新的名叫web-0的Pod绑定在了一起。对于Pod web-1来说也是完全一样的情况。
**这是怎么做到的呢?**
其实我和你分析一下StatefulSet控制器恢复这个Pod的过程你就可以很容易理解了。
首先当你把一个Pod比如web-0删除之后这个Pod对应的PVC和PV并不会被删除而这个Volume里已经写入的数据也依然会保存在远程存储服务里比如我们在这个例子里用到的Ceph服务器
此时StatefulSet控制器发现一个名叫web-0的Pod消失了。所以控制器就会重新创建一个新的、名字还是叫作web-0的Pod来“纠正”这个不一致的情况。
需要注意的是在这个新的Pod对象的定义里它声明使用的PVC的名字还是叫作www-web-0。这个PVC的定义还是来自于PVC模板volumeClaimTemplates这是StatefulSet创建Pod的标准流程。
所以在这个新的web-0 Pod被创建出来之后Kubernetes为它查找名叫www-web-0的PVC时就会直接找到旧Pod遗留下来的同名的PVC进而找到跟这个PVC绑定在一起的PV。
这样新的Pod就可以挂载到旧Pod对应的那个Volume并且获取到保存在Volume里的数据。
**通过这种方式Kubernetes的StatefulSet就实现了对应用存储状态的管理。**
看到这里你是不是已经大致理解了StatefulSet的工作原理呢现在我再为你详细梳理一下吧。
**首先StatefulSet的控制器直接管理的是Pod**。这是因为StatefulSet里的不同Pod实例不再像ReplicaSet中那样都是完全一样的而是有了细微区别的。比如每个Pod的hostname、名字等都是不同的、携带了编号的。而StatefulSet区分这些实例的方式就是通过在Pod的名字里加上事先约定好的编号。
**其次Kubernetes通过Headless Service为这些有编号的Pod在DNS服务器中生成带有同样编号的DNS记录**。只要StatefulSet能够保证这些Pod名字里的编号不变那么Service里类似于web-0.nginx.default.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里的数据。
这么一看原本非常复杂的StatefulSet是不是也很容易理解了呢
## 总结
在今天这篇文章中我为你详细介绍了StatefulSet处理存储状态的方法。然后以此为基础我为你梳理了StatefulSet控制器的工作原理。
从这些讲述中我们不难看出StatefulSet的设计思想StatefulSet其实就是一种特殊的Deployment而其独特之处在于它的每个Pod都被编号了。而且这个编号会体现在Pod的名字和hostname等标识信息上这不仅代表了Pod的创建顺序也是Pod的重要网络标识在整个集群里唯一的、可被访问的身份
有了这个编号后StatefulSet就使用Kubernetes里的两个标准功能Headless Service和PV/PVC实现了对Pod的拓扑状态和存储状态的维护。
实际上在下一篇文章的“有状态应用”实践环节以及后续的讲解中你就会逐渐意识到StatefulSet可以说是Kubernetes中作业编排的“集大成者”。
因为几乎每一种Kubernetes的编排功能都可以在编写StatefulSet的YAML文件时被用到。
## 思考题
在实际场景中,有一些分布式应用的集群是这么工作的:当一个新节点加入到集群时,或者老节点被迁移后重建时,这个节点可以从主节点或者其他从节点那里同步到自己所需要的数据。
在这种情况下你认为是否还有必要将这个节点Pod与它的PV进行一对一绑定呢提示这个问题的答案根据不同的项目是不同的。关键在于重建后的节点进行数据恢复和同步的时候是不是一定需要原先它写在本地磁盘里的数据
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,609 @@
<audio id="audio" title="20 | 深入理解StatefulSet有状态应用实践" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2e/dd/2e127711ab3309ee42f1c47254612add.mp3"></audio>
你好我是张磊。今天我和你分享的主题是深入理解StatefulSet之有状态应用实践。
在前面的两篇文章中我详细讲解了StatefulSet的工作原理以及处理拓扑状态和存储状态的方法。而在今天这篇文章中我将通过一个实际的例子再次为你深入解读一下部署一个StatefulSet的完整流程。
今天我选择的实例是部署一个MySQL集群这也是Kubernetes官方文档里的一个经典案例。但是很多工程师都曾向我吐槽说这个例子“完全看不懂”。
其实这样的吐槽也可以理解相比于Etcd、Cassandra等“原生”就考虑了分布式需求的项目MySQL以及很多其他的数据库项目在分布式集群的搭建上并不友好甚至有点“原始”。
所以这次我就直接选择了这个具有挑战性的例子和你分享如何使用StatefulSet将它的集群搭建过程“容器化”。
>
备注在开始实践之前请确保我们之前一起部署的那个Kubernetes集群还是可用的并且网络插件和存储插件都能正常运行。具体的做法请参考第11篇文章[《从0到1搭建一个完整的Kubernetes集群》](https://time.geekbang.org/column/article/39724)的内容。
首先,用自然语言来描述一下我们想要部署的“有状态应用”。
<li>
是一个“主从复制”Maser-Slave Replication的MySQL集群
</li>
<li>
有1个主节点Master
</li>
<li>
有多个从节点Slave
</li>
<li>
从节点需要能水平扩展;
</li>
<li>
所有的写操作,只能在主节点上执行;
</li>
<li>
读操作可以在所有节点上执行。
</li>
这就是一个非常典型的主从模式的MySQL集群了。我们可以把上面描述的“有状态应用”的需求通过一张图来表示。
<img src="https://static001.geekbang.org/resource/image/bb/02/bb2d7f03443392ca40ecde6b1a91c002.png" alt=""><br>
在常规环境里部署这样一个主从模式的MySQL集群的主要难点在于如何让从节点能够拥有主节点的数据如何配置主MasterSlave节点的复制与同步。
所以在安装好MySQL的Master节点之后你需要做的第一步工作就是**通过XtraBackup将Master节点的数据备份到指定目录。**
>
备注XtraBackup是业界主要使用的开源MySQL备份和恢复工具。
这一步会自动在目标目录里生成一个备份信息文件名叫xtrabackup_binlog_info。这个文件一般会包含如下两个信息
```
$ cat xtrabackup_binlog_info
TheMaster-bin.000001 481
```
这两个信息会在接下来配置Slave节点的时候用到。
**第二步配置Slave节点**。Slave节点在第一次启动前需要先把Master节点的备份数据连同备份信息文件一起拷贝到自己的数据目录/var/lib/mysql下。然后我们执行这样一句SQL
```
TheSlave|mysql&gt; 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
**第三步启动Slave节点**。在这一步我们需要执行这样一句SQL
```
TheSlave|mysql&gt; START SLAVE;
```
这样Slave节点就启动了。它会使用备份信息文件中的二进制日志文件和偏移量与主节点进行数据同步。
**第四步在这个集群中添加更多的Slave节点**
需要注意的是新添加的Slave节点的备份数据来自于已经存在的Slave节点。
所以在这一步我们需要将Slave节点的数据备份在指定目录。而这个备份操作会自动生成另一种备份信息文件名叫xtrabackup_slave_info。同样地这个文件也包含了MASTER_LOG_FILE和MASTER_LOG_POS两个字段。
然后我们就可以执行跟前面一样的“CHANGE MASTER TO”和“START SLAVE” 指令来初始化并启动这个新的Slave节点了。
通过上面的叙述,我们不难看到,**将部署MySQL集群的流程迁移到Kubernetes项目上需要能够“容器化”地解决下面的“三座大山”**
<li>
Master节点和Slave节点需要有不同的配置文件不同的my.cnf
</li>
<li>
Master节点和Slave节点需要能够传输备份信息文件
</li>
<li>
在Slave节点第一次启动之前需要执行一些初始化SQL操作
</li>
而由于MySQL本身同时拥有拓扑状态主从节点的区别和存储状态MySQL保存在本地的数据我们自然要通过StatefulSet来解决这“三座大山”的问题。
**其中“第一座大山Master节点和Slave节点需要有不同的配置文件”很容易处理**我们只需要给主从节点分别准备两份不同的MySQL配置文件然后根据Pod的序号Index挂载进去即可。
正如我在前面文章中介绍过的这样的配置文件信息应该保存在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的文件。
>
备注如果你对ConfigMap的用法感到陌生的话可以稍微复习一下第15篇文章[《深入解析Pod对象使用进阶》](https://time.geekbang.org/column/article/40466)中我讲解Secret对象部分的内容。因为ConfigMap跟Secret无论是使用方法还是实现原理几乎都是一样的。
接下来我们需要创建两个Service来供StatefulSet以及用户使用。这两个Service的定义如下所示
```
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也就是所有的MySQL Pod。端口映射都是用Service的3306端口对应Pod的3306端口。
不同的是第一个名叫“mysql”的Service是一个Headless ServiceclusterIP= None。所以它的作用是通过为Pod分配DNS记录来固定它的拓扑状态比如“mysql-0.mysql”和“mysql-1.mysql”这样的DNS名字。其中编号为0的节点就是我们的主节点。
而第二个名叫“mysql-read”的Service则是一个常规的Service。
并且我们规定所有用户的读请求都必须访问第二个Service被自动分配的DNS记录“mysql-read”当然也可以访问这个Service的VIP。这样读请求就可以被转发到任意一个MySQL的主节点或者从节点上。
>
备注Kubernetes中的所有Service、Pod对象都会被自动分配同名的DNS记录。具体细节我会在后面Service部分做重点讲解。
而所有用户的写请求则必须直接以DNS记录的方式访问到MySQL的主节点也就是“mysql-0.mysql“这条DNS记录。
接下来我们再一起解决“第二座大山Master节点和Slave节点需要能够传输备份文件”的问题。
**翻越这座大山的思路我比较推荐的做法是先搭建框架再完善细节。其中Pod部分如何定义是完善细节时的重点。**
**所以首先我们先为StatefulSet对象规划一个大致的框架如下图所示**
<img src="https://static001.geekbang.org/resource/image/16/09/16aa2e42034830a0e64120ecc330a509.png" alt="">
在这一步我们可以先为StatefulSet定义一些通用的字段。
比如selector表示这个StatefulSet要管理的Pod必须携带app=mysql标签它声明要使用的Headless Service的名字是mysql。
这个StatefulSet的replicas值是3表示它定义的MySQL集群有三个节点一个Master节点两个Slave节点。
可以看到StatefulSet管理的“有状态应用”的多个实例也都是通过同一份Pod模板创建出来的使用的是同一个Docker镜像。这也就意味着如果你的应用要求不同节点的镜像不一样那就不能再使用StatefulSet了。对于这种情况应该考虑我后面会讲解到的Operator。
除了这些基本的字段外作为一个有存储状态的MySQL集群StatefulSet还需要管理存储状态。所以我们需要通过volumeClaimTemplatePVC模板来为每个Pod定义PVC。比如这个PVC模板的resources.requests.strorage指定了存储的大小为10 GiBReadWriteOnce指定了该存储的属性为可读写并且一个PV只允许挂载在一个宿主机上。将来这个PV对应的的Volume就会充当MySQL Pod的存储数据目录。
**然后我们来重点设计一下这个StatefulSet的Pod模板也就是template字段。**
由于StatefulSet管理的Pod都来自于同一个镜像这就要求我们在编写Pod时一定要保持清醒用“人格分裂”的方式进行思考
<li>
如果这个Pod是Master节点我们要怎么做
</li>
<li>
如果这个Pod是Slave节点我们又要怎么做。
</li>
想清楚这两个问题我们就可以按照Pod的启动过程来一步步定义它们了。
**第一步从ConfigMap中获取MySQL的Pod对应的配置文件。**
为此我们需要进行一个初始化操作根据节点的角色是Master还是Slave节点为Pod分配对应的配置文件。此外MySQL还要求集群里的每个节点都有一个唯一的ID文件名叫server-id.cnf。
而根据我们已经掌握的Pod知识这些初始化操作显然适合通过InitContainer来完成。所以我们首先定义了一个InitContainer如下所示
```
...
# template.spec
initContainers:
- name: init-mysql
image: mysql:5.7
command:
- bash
- &quot;-c&quot;
- |
set -ex
# 从Pod的序号生成server-id
[[ `hostname` =~ -([0-9]+)$ ]] || exit 1
ordinal=${BASH_REMATCH[1]}
echo [mysqld] &gt; /mnt/conf.d/server-id.cnf
# 由于server-id=0有特殊含义我们给ID加一个100来避开它
echo server-id=$((100 + $ordinal)) &gt;&gt; /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
```
在这个名叫init-mysql的InitContainer的配置中它从Pod的hostname里读取到了Pod的序号以此作为MySQL节点的server-id。
然后init-mysql通过这个序号判断当前Pod到底是Master节点序号为0还是Slave节点序号不为0从而把对应的配置文件从/mnt/config-map目录拷贝到/mnt/conf.d/目录下。
其中,文件拷贝的源目录/mnt/config-map正是ConfigMap在这个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配置文件已经出现在里面了。这跟我们之前介绍的Tomcat和WAR包的处理方法是完全一样的。
**第二步在Slave Pod启动前从Master或者其他Slave Pod里拷贝数据库数据到自己的目录下。**
为了实现这个操作我们就需要再定义第二个InitContainer如下所示
```
...
# template.spec.initContainers
- name: clone-mysql
image: gcr.io/google-samples/xtrabackup:1.0
command:
- bash
- &quot;-c&quot;
- |
set -ex
# 拷贝操作只需要在第一次启动时进行,所以如果数据已经存在,跳过
[[ -d /var/lib/mysql/mysql ]] &amp;&amp; exit 0
# Master节点(序号为0)不需要做这个操作
[[ `hostname` =~ -([0-9]+)$ ]] || exit 1
ordinal=${BASH_REMATCH[1]}
[[ $ordinal -eq 0 ]] &amp;&amp; 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
```
在这个名叫clone-mysql的InitContainer里我们使用的是xtrabackup镜像它里面安装了xtrabackup工具
而在它的启动命令里,我们首先做了一个判断。即:当初始化所需的数据(/var/lib/mysql/mysql 目录已经存在或者当前Pod是Master节点的时候不需要做拷贝操作。
接下来clone-mysql会使用Linux自带的ncat指令向DNS记录为“mysql-&lt;当前序号减一&gt;.mysql”的Pod也就是当前Pod的前一个Pod发起数据传输请求并且直接用xbstream指令将收到的备份数据保存在/var/lib/mysql目录下。
>
备注3307是一个特殊端口运行着一个专门负责备份MySQL数据的辅助进程。我们后面马上会讲到它。
当然这一步你可以随意选择用自己喜欢的方法来传输数据。比如用scp或者rsync都没问题。
你可能已经注意到,这个容器里的/var/lib/mysql目录**实际上正是一个名为data的PVC**,即:我们在前面声明的持久化存储。
这就可以保证哪怕宿主机宕机了我们数据库的数据也不会丢失。更重要的是由于Pod Volume是被Pod里的容器共享的所以后面启动的MySQL容器就可以把这个Volume挂载到自己的/var/lib/mysql目录下直接使用里面的备份数据进行恢复操作。
不过clone-mysql容器还要对/var/lib/mysql目录执行一句xtrabackup --prepare操作目的是让拷贝来的数据进入一致性状态这样这些数据才能被用作数据恢复。
至此我们就通过InitContainer完成了对“主、从节点间备份文件传输”操作的处理过程也就是翻越了“第二座大山”。
接下来我们可以开始定义MySQL容器,启动MySQL服务了。由于StatefulSet里的所有Pod都来自用同一个Pod模板所以我们还要“人格分裂”地去思考这个MySQL容器的启动命令在Master和Slave两种情况下有什么不同。
有了Docker镜像在Pod里声明一个Master角色的MySQL容器并不是什么困难的事情直接执行MySQL启动命令即可。
但是如果这个Pod是一个第一次启动的Slave节点在执行MySQL启动命令之前它就需要使用前面InitContainer拷贝来的备份数据进行初始化。
可是,别忘了,**容器是一个单进程模型。**
所以一个Slave角色的MySQL容器启动之前谁能负责给它执行初始化的SQL语句呢
这就是我们需要解决的“第三座大山”的问题如何在Slave节点的MySQL容器第一次启动之前执行初始化SQL。
你可能已经想到了我们可以为这个MySQL容器额外定义一个sidecar容器来完成这个操作它的定义如下所示
```
...
# template.spec.containers
- name: xtrabackup
image: gcr.io/google-samples/xtrabackup:1.0
ports:
- name: xtrabackup
containerPort: 3307
command:
- bash
- &quot;-c&quot;
- |
set -ex
cd /var/lib/mysql
# 从备份信息文件里读取MASTER_LOG_FILEM和MASTER_LOG_POS这两个字段的值用来拼装集群初始化SQL
if [[ -f xtrabackup_slave_info ]]; then
# 如果xtrabackup_slave_info文件存在说明这个备份数据来自于另一个Slave节点。这种情况下XtraBackup工具在备份的时候就已经在这个文件里自动生成了&quot;CHANGE MASTER TO&quot; 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 &quot;CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\
MASTER_LOG_POS=${BASH_REMATCH[2]}&quot; &gt; change_master_to.sql.in
fi
# 如果change_master_to.sql.in就意味着需要做集群初始化工作
if [[ -f change_master_to.sql.in ]]; then
# 但一定要先等MySQL容器启动之后才能进行下一步连接MySQL的操作
echo &quot;Waiting for mysqld to be ready (accepting connections)&quot;
until mysql -h 127.0.0.1 -e &quot;SELECT 1&quot;; do sleep 1; done
echo &quot;Initializing replication from clone position&quot;
# 将文件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 &lt;&lt;EOF
$(&lt;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端口。它的作用是在收到传输请求的时候直接执行&quot;xtrabackup --backup&quot;命令备份MySQL的数据并发送给请求者
exec ncat --listen --keep-open --send-only --max-conns=1 3307 -c \
&quot;xtrabackup --backup --slave-info --stream=xbstream --host=127.0.0.1 --user=root&quot;
volumeMounts:
- name: data
mountPath: /var/lib/mysql
subPath: mysql
- name: conf
mountPath: /etc/mysql/conf.d
```
可以看到,**在这个名叫xtrabackup的sidecar容器的启动命令里其实实现了两部分工作。**
**第一部分工作当然是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_slave_info文件、但是存在xtrabackup_binlog_info文件那就说明备份数据来自于Master节点。这种情况下sidecar容器就需要解析这个备份信息文件读取MASTER_LOG_FILE和MASTER_LOG_POS这两个字段的值用它们拼装出初始化SQL语句然后把这句SQL写入到change_master_to.sql.in文件中。
接下来sidecar容器就可以执行初始化了。从上面的叙述中可以看到只要这个change_master_to.sql.in文件存在那就说明接下来需要进行集群初始化操作。
所以这时候sidecar容器只需要读取并执行change_master_to.sql.in里面的“CHANGE MASTER TO”指令再执行一句START SLAVE命令一个Slave节点就被成功启动了。
>
需要注意的是Pod里的容器并没有先后顺序所以在执行初始化SQL之前必须先执行一句SQLselect 1来检查一下MySQL服务是否已经可用。
当然,上述这些初始化操作完成后,我们还要删除掉前面用到的这些备份信息文件。否则,下次这个容器重启时,就会发现这些文件存在,所以又会重新执行一次数据恢复和集群初始化的操作,这是不对的。
同理change_master_to.sql.in在使用后也要被重命名以免容器重启时因为发现这个文件存在又执行一遍初始化。
**在完成MySQL节点的初始化后这个sidecar容器的第二个工作则是启动一个数据传输服务。**
具体做法是sidecar容器会使用ncat命令启动一个工作在3307端口上的网络发送服务。一旦收到数据传输请求时sidecar容器就会调用xtrabackup --backup指令备份当前MySQL的数据然后把这些备份数据返回给请求者。这就是为什么我们在InitContainer里定义数据拷贝的时候访问的是“上一个MySQL节点”的3307端口。
值得一提的是由于sidecar容器和MySQL容器同处于一个Pod里所以它是直接通过Localhost来访问和备份MySQL容器里的数据的非常方便。
同样地我在这里举例用的只是一种备份方法而已你完全可以选择其他自己喜欢的方案。比如你可以使用innobackupex命令做数据备份和准备它的使用方法几乎与本文的备份方法一样。
至此我们也就翻越了“第三座大山”完成了Slave节点第一次启动前的初始化工作。
扳倒了这“三座大山”后我们终于可以定义Pod里的主角MySQL容器了。有了前面这些定义和初始化工作MySQL容器本身的定义就非常简单了如下所示
```
...
# template.spec
containers:
- name: mysql
image: mysql:5.7
env:
- name: MYSQL_ALLOW_EMPTY_PASSWORD
value: &quot;1&quot;
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: [&quot;mysqladmin&quot;, &quot;ping&quot;]
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
exec:
# 通过TCP连接的方式进行健康检查
command: [&quot;mysql&quot;, &quot;-h&quot;, &quot;127.0.0.1&quot;, &quot;-e&quot;, &quot;SELECT 1&quot;]
initialDelaySeconds: 5
periodSeconds: 2
timeoutSeconds: 1
```
在这个容器的定义里我们使用了一个标准的MySQL 5.7 的官方镜像。它的数据目录是/var/lib/mysql配置文件目录是/etc/mysql/conf.d。
这时候你应该能够明白如果MySQL容器是Slave节点的话它的数据目录里的数据就来自于InitContainer从其他节点里拷贝而来的备份。它的配置文件目录/etc/mysql/conf.d里的内容则来自于ConfigMap对应的Volume。而它的初始化工作则是由同一个Pod里的sidecar容器完成的。这些操作正是我刚刚为你讲述的大部分内容。
另外我们为它定义了一个livenessProbe通过mysqladmin ping命令来检查它是否健康还定义了一个readinessProbe通过查询SQLselect 1来检查MySQL服务是否可用。当然凡是readinessProbe检查失败的MySQL Pod都会从Service里被摘除掉。
至此一个完整的主从复制模式的MySQL集群就定义完了。
现在我们就可以使用kubectl命令尝试运行一下这个StatefulSet了。
**首先我们需要在Kubernetes集群里创建满足条件的PV**。如果你使用的是我们在第11篇文章《[从0到1搭建一个完整的Kubernetes集群》](https://time.geekbang.org/column/article/39724)里部署的Kubernetes集群的话你可以按照如下方式使用存储插件Rook
```
$ 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调用存储插件Rook创建对应的PV从而省去了我们手动创建PV的机械劳动。我在后续讲解容器存储的时候会再详细介绍这个机制。
>
备注在使用Rook的情况下mysql-statefulset.yaml里的volumeClaimTemplates字段需要加上声明storageClassName=rook-ceph-block才能使用到这个Rook提供的持久化存储。
**然后我们就可以创建这个StatefulSet了**,如下所示:
```
$ kubectl create -f mysql-statefulset.yaml
$ kubectl get pod -l app=mysql
NAME READY STATUS RESTARTS AGE
mysql-0 2/2 Running 0 2m
mysql-1 2/2 Running 0 1m
mysql-2 2/2 Running 0 1m
```
可以看到StatefulSet启动成功后会有三个Pod运行。
**接下来我们可以尝试向这个MySQL集群发起请求执行一些SQL操作来验证它是否正常**
```
$ kubectl run mysql-client --image=mysql:5.7 -i --rm --restart=Never --\
mysql -h mysql-0.mysql &lt;&lt;EOF
CREATE DATABASE test;
CREATE TABLE test.messages (message VARCHAR(250));
INSERT INTO test.messages VALUES ('hello');
EOF
```
如上所示我们通过启动一个容器使用MySQL client执行了创建数据库和表、以及插入数据的操作。需要注意的是我们连接的MySQL的地址必须是mysql-0.mysqlMaster节点的DNS记录。因为只有Master节点才能处理写操作。
而通过连接mysql-read这个Service我们就可以用SQL进行读操作如下所示
```
$ kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\
mysql -h mysql-read -e &quot;SELECT * FROM test.messages&quot;
Waiting for pod default/mysql-client to be running, status is Pending, pod ready: false
+---------+
| message |
+---------+
| hello |
+---------+
pod &quot;mysql-client&quot; deleted
```
在有了StatefulSet以后你就可以像Deployment那样非常方便地扩展这个MySQL集群比如
```
$ kubectl scale statefulset mysql --replicas=5
```
这时候你就会发现新的Slave Pod mysql-3和mysql-4被自动创建了出来。
而如果你像如下所示的这样直接连接mysql-3.mysql即mysql-3这个Pod的DNS名字来进行查询操作
```
$ kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\
mysql -h mysql-3.mysql -e &quot;SELECT * FROM test.messages&quot;
Waiting for pod default/mysql-client to be running, status is Pending, pod ready: false
+---------+
| message |
+---------+
| hello |
+---------+
pod &quot;mysql-client&quot; deleted
```
就会看到从StatefulSet为我们新创建的mysql-3上同样可以读取到之前插入的记录。也就是说我们的数据备份和恢复都是有效的。
## 总结
在今天这篇文章中我以MySQL集群为例和你详细分享了一个实际的StatefulSet的编写过程。这个YAML文件的[链接在这里](https://kubernetes.io/docs/tasks/run-application/run-replicated-stateful-application/#statefulset),希望你能多花一些时间认真消化。
在这个过程中,有以下几个关键点(坑)特别值得你注意和体会。
<li>
“人格分裂”在解决需求的过程中一定要记得思考该Pod在扮演不同角色时的不同操作。
</li>
<li>
“阅后即焚”很多“有状态应用”的节点只是在第一次启动的时候才需要做额外处理。所以在编写YAML文件时你一定要考虑“容器重启”的情况不要让这一次的操作干扰到下一次的容器启动。
</li>
<li>
“容器之间平等无序”除非是InitContainer否则一个Pod里的多个容器之间是完全平等的。所以你精心设计的sidecar绝不能对容器的顺序做出假设否则就需要进行前置检查。
</li>
最后相信你也已经能够理解StatefulSet其实是一种特殊的Deployment只不过这个“Deployment”的每个Pod实例的名字里都携带了一个唯一并且固定的编号。这个编号的顺序固定了Pod的拓扑关系这个编号对应的DNS记录固定了Pod的访问方式这个编号对应的PV绑定了Pod与持久化存储的关系。所以当Pod被删除重建时这些“状态”都会保持不变。
而一旦你的应用没办法通过上述方式进行状态的管理那就代表了StatefulSet已经不能解决它的部署问题了。这时候我后面讲到的Operator可能才是一个更好的选择。
## 思考题
如果我们现在的需求是所有的读请求只由Slave节点处理所有的写请求只由Master节点处理。那么你需要在今天这篇文章的基础上再做哪些改动呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,426 @@
<audio id="audio" title="21 | 容器化守护进程的意义DaemonSet" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/59/18/593cf97eb297671d472238e41d05f218.mp3"></audio>
你好我是张磊。今天我和你分享的主题是容器化守护进程的意义之DaemonSet。
在上一篇文章中我和你详细分享了使用StatefulSet编排“有状态应用”的过程。从中不难看出StatefulSet其实就是对现有典型运维业务的容器化抽象。也就是说你一定有方法在不使用Kubernetes、甚至不使用容器的情况下自己DIY一个类似的方案出来。但是一旦涉及到升级、版本管理等更工程化的能力Kubernetes的好处才会更加凸现。
比如如何对StatefulSet进行“滚动更新”rolling update
很简单。你只要修改StatefulSet的Pod模板就会自动触发“滚动更新”:
```
$ kubectl patch statefulset mysql --type='json' -p='[{&quot;op&quot;: &quot;replace&quot;, &quot;path&quot;: &quot;/spec/template/spec/containers/0/image&quot;, &quot;value&quot;:&quot;mysql:5.7.23&quot;}]'
statefulset.apps/mysql patched
```
在这里我使用了kubectl patch命令。它的意思是以“补丁”的方式JSON格式的修改一个API对象的指定字段也就是我在后面指定的“spec/template/spec/containers/0/image”。
这样StatefulSet Controller就会按照与Pod编号相反的顺序从最后一个Pod开始逐一更新这个StatefulSet管理的每个Pod。而如果更新发生了错误这次“滚动更新”就会停止。此外StatefulSet的“滚动更新”还允许我们进行更精细的控制比如金丝雀发布Canary Deploy或者灰度发布**这意味着应用的多个实例中被指定的一部分不会被更新到最新的版本。**
这个字段正是StatefulSet的spec.updateStrategy.rollingUpdate的partition字段。
比如现在我将前面这个StatefulSet的partition字段设置为2
```
$ kubectl patch statefulset mysql -p '{&quot;spec&quot;:{&quot;updateStrategy&quot;:{&quot;type&quot;:&quot;RollingUpdate&quot;,&quot;rollingUpdate&quot;:{&quot;partition&quot;:2}}}}'
statefulset.apps/mysql patched
```
其中kubectl patch命令后面的参数JSON格式的就是partition字段在API对象里的路径。所以上述操作等同于直接使用 kubectl edit命令打开这个对象把partition字段修改为2。
这样我就指定了当Pod模板发生变化的时候比如MySQL镜像更新到5.7.23那么只有序号大于或者等于2的Pod会被更新到这个版本。并且如果你删除或者重启了序号小于2的Pod等它再次启动后也会保持原先的5.7.2版本绝不会被升级到5.7.23版本。
StatefulSet可以说是Kubernetes项目中最为复杂的编排对象希望你课后能认真消化动手实践一下这个例子。
而在今天这篇文章中我会为你重点讲解一个相对轻松的知识点DaemonSet。
顾名思义DaemonSet的主要作用是让你在Kubernetes集群里运行一个Daemon Pod。 所以这个Pod有如下三个特征
<li>
这个Pod运行在Kubernetes集群里的每一个节点Node
</li>
<li>
每个节点上只有一个这样的Pod实例
</li>
<li>
当有新的节点加入Kubernetes集群后该Pod会自动地在新节点上被创建出来而当旧节点被删除后它上面的Pod也相应地会被回收掉。
</li>
这个机制听起来很简单但Daemon Pod的意义确实是非常重要的。我随便给你列举几个例子
<li>
各种网络插件的Agent组件都必须运行在每一个节点上用来处理这个节点上的容器网络
</li>
<li>
各种存储插件的Agent组件也必须运行在每一个节点上用来在这个节点上挂载远程存储目录操作容器的Volume目录
</li>
<li>
各种监控组件和日志组件,也必须运行在每一个节点上,负责这个节点上的监控信息和日志搜集。
</li>
更重要的是跟其他编排对象不一样DaemonSet开始运行的时机很多时候比整个Kubernetes集群出现的时机都要早。
这个乍一听起来可能有点儿奇怪。但其实你来想一下如果这个DaemonSet正是一个网络插件的Agent组件呢
这个时候整个Kubernetes集群里还没有可用的容器网络所有Worker节点的状态都是NotReadyNetworkReady=false。这种情况下普通的Pod肯定不能运行在这个集群上。所以这也就意味着DaemonSet的设计必须要有某种“过人之处”才行。
为了弄清楚DaemonSet的工作原理我们还是按照老规矩先从它的API对象的定义说起。
```
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管理的是一个fluentd-elasticsearch镜像的Pod。这个镜像的功能非常实用通过fluentd将Docker容器里的日志转发到ElasticSearch中。
可以看到DaemonSet跟Deployment其实非常相似只不过是没有replicas字段它也使用selector选择管理所有携带了name=fluentd-elasticsearch标签的Pod。
而这些Pod的模板也是用template字段定义的。在这个字段中我们定义了一个使用 fluentd-elasticsearch:1.20镜像的容器而且这个容器挂载了两个hostPath类型的Volume分别对应宿主机的/var/log目录和/var/lib/docker/containers目录。
显然fluentd启动之后它会从这两个目录里搜集日志信息并转发给ElasticSearch保存。这样我们通过ElasticSearch就可以很方便地检索这些日志了。
需要注意的是Docker容器里应用的日志默认会保存在宿主机的/var/lib/docker/containers/{{.容器ID}}/{{.容器ID}}-json.log文件里所以这个目录正是fluentd的搜集目标。
那么,**DaemonSet又是如何保证每个Node上有且只有一个被管理的Pod呢**
显然,这是一个典型的“控制器模型”能够处理的问题。
DaemonSet Controller首先从Etcd里获取所有的Node列表然后遍历所有的Node。这时它就可以很容易地去检查当前这个Node上是不是有一个携带了name=fluentd-elasticsearch标签的Pod在运行。
而检查的结果,可能有这么三种情况:
<li>
没有这种Pod那么就意味着要在这个Node上创建这样一个Pod
</li>
<li>
有这种Pod但是数量大于1那就说明要把多余的Pod从这个Node上删除掉
</li>
<li>
正好只有一个这种Pod那说明这个节点是正常的。
</li>
其中删除节点Node上多余的Pod非常简单直接调用Kubernetes API就可以了。
但是,**如何在指定的Node上创建新Pod呢**
如果你已经熟悉了Pod API对象的话那一定可以立刻说出答案用nodeSelector选择Node的名字即可。
```
nodeSelector:
name: &lt;Node名字&gt;
```
没错。
不过在Kubernetes项目里nodeSelector其实已经是一个将要被废弃的字段了。因为现在有了一个新的、功能更完善的字段可以代替它nodeAffinity。我来举个例子
```
apiVersion: v1
kind: Pod
metadata:
name: with-node-affinity
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: metadata.name
operator: In
values:
- node-geektime
```
在这个Pod里我声明了一个spec.affinity字段然后定义了一个nodeAffinity。其中spec.affinity字段是Pod里跟调度相关的一个字段。关于它的完整内容我会在讲解调度策略的时候再详细阐述。
而在这里我定义的nodeAffinity的含义是
<li>
requiredDuringSchedulingIgnoredDuringExecution它的意思是说这个nodeAffinity必须在每次调度的时候予以考虑。同时这也意味着你可以设置在某些情况下不考虑这个nodeAffinity
</li>
<li>
这个Pod将来只允许运行在“`metadata.name`”是“node-geektime”的节点上。
</li>
在这里你应该注意到nodeAffinity的定义可以支持更加丰富的语法比如operator: In部分匹配如果你定义operator: Equal就是完全匹配这也正是nodeAffinity会取代nodeSelector的原因之一。
>
备注其实在大多数时候这些Operator语义没啥用处。所以说在学习开源项目的时候一定要学会抓住“主线”。不要顾此失彼。
所以,**我们的DaemonSet Controller会在创建Pod的时候自动在这个Pod的API对象里加上这样一个nodeAffinity定义**。其中需要绑定的节点名字正是当前正在遍历的这个Node。
当然DaemonSet并不需要修改用户提交的YAML文件里的Pod模板而是在向Kubernetes发起请求之前直接修改根据模板生成的Pod对象。这个思路也正是我在前面讲解Pod对象时介绍过的。
此外DaemonSet还会给这个Pod自动加上另外一个与调度相关的字段叫作tolerations。这个字段意味着这个Pod会“容忍”Toleration某些Node的“污点”Taint
而DaemonSet自动加上的tolerations字段格式如下所示
```
apiVersion: v1
kind: Pod
metadata:
name: with-toleration
spec:
tolerations:
- key: node.kubernetes.io/unschedulable
operator: Exists
effect: NoSchedule
```
这个Toleration的含义是“容忍”所有被标记为unschedulable“污点”的Node“容忍”的效果是允许调度。
>
备注关于如何给一个Node标记上“污点”以及这里具体的语法定义我会在后面介绍调度器的时候做详细介绍。这里你可以简单地把“污点”理解为一种特殊的Label。
而在正常情况下被标记了unschedulable“污点”的Node是不会有任何Pod被调度上去的effect: NoSchedule。可是DaemonSet自动地给被管理的Pod加上了这个特殊的Toleration就使得这些Pod可以忽略这个限制继而保证每个节点上都会被调度一个Pod。当然如果这个节点有故障的话这个Pod可能会启动失败而DaemonSet则会始终尝试下去直到Pod启动成功。
这时,你应该可以猜到,我在前面介绍到的**DaemonSet的“过人之处”其实就是依靠Toleration实现的。**
假如当前DaemonSet管理的是一个网络插件的Agent Pod那么你就必须在这个DaemonSet的YAML文件里给它的Pod模板加上一个能够“容忍”`node.kubernetes.io/network-unavailable`“污点”的Toleration。正如下面这个例子所示
```
...
template:
metadata:
labels:
name: network-plugin-agent
spec:
tolerations:
- key: node.kubernetes.io/network-unavailable
operator: Exists
effect: NoSchedule
```
在Kubernetes项目中当一个节点的网络插件尚未安装时这个节点就会被自动加上名为`node.kubernetes.io/network-unavailable`的“污点”。
**而通过这样一个Toleration调度器在调度这个Pod的时候就会忽略当前节点上的“污点”从而成功地将网络插件的Agent组件调度到这台机器上启动起来。**
这种机制正是我们在部署Kubernetes集群的时候能够先部署Kubernetes本身、再部署网络插件的根本原因因为当时我们所创建的Weave的YAML实际上就是一个DaemonSet。
>
这里你也可以再回顾一下第11篇文章[《从0到1搭建一个完整的Kubernetes集群》](https://time.geekbang.org/column/article/39724)中的相关内容。
至此,通过上面这些内容,你应该能够明白,**DaemonSet其实是一个非常简单的控制器**。在它的控制循环中只需要遍历所有节点然后根据节点上是否有被管理Pod的情况来决定是否要创建或者删除一个Pod。
只不过在创建每个Pod的时候DaemonSet会自动给这个Pod加上一个nodeAffinity从而保证这个Pod只会在指定节点上启动。同时它还会自动给这个Pod加上一个Toleration从而忽略节点的unschedulable“污点”。
当然,**你也可以在Pod模板里加上更多种类的Toleration从而利用DaemonSet达到自己的目的**。比如在这个fluentd-elasticsearch DaemonSet里我就给它加上了这样的Toleration
```
tolerations:
- key: node-role.kubernetes.io/master
effect: NoSchedule
```
这是因为在默认情况下Kubernetes集群不允许用户在Master节点部署Pod。因为Master节点默认携带了一个叫作`node-role.kubernetes.io/master`的“污点”。所以为了能在Master节点上部署DaemonSet的Pod我就必须让这个Pod“容忍”这个“污点”。
在理解了DaemonSet的工作原理之后接下来我就通过一个具体的实践来帮你更深入地掌握DaemonSet的使用方法。
>
备注需要注意的是在Kubernetes v1.11之前由于调度器尚不完善DaemonSet是由DaemonSet Controller自行调度的即它会直接设置Pod的spec.nodename字段这样就可以跳过调度器了。但是这样的做法很快就会被废除所以在这里我也不推荐你再花时间学习这个流程了。
**首先创建这个DaemonSet对象**
```
$ kubectl create -f fluentd-elasticsearch.yaml
```
需要注意的是在DaemonSet上我们一般都应该加上resources字段来限制它的CPU和内存使用防止它占用过多的宿主机资源。
而创建成功后你就能看到如果有N个节点就会有N个fluentd-elasticsearch Pod在运行。比如在我们的例子里会有两个Pod如下所示
```
$ kubectl get pod -n kube-system -l name=fluentd-elasticsearch
NAME READY STATUS RESTARTS AGE
fluentd-elasticsearch-dqfv9 1/1 Running 0 53m
fluentd-elasticsearch-pf9z5 1/1 Running 0 53m
```
而如果你此时通过kubectl get查看一下Kubernetes集群里的DaemonSet对象
```
$ kubectl get ds -n kube-system fluentd-elasticsearch
NAME DESIRED CURRENT READY UP-TO-DATE AVAILABLE NODE SELECTOR AGE
fluentd-elasticsearch 2 2 2 2 2 &lt;none&gt; 1h
```
>
备注Kubernetes里比较长的API对象都有短名字比如DaemonSet对应的是dsDeployment对应的是deploy。
就会发现DaemonSet和Deployment一样也有DESIRED、CURRENT等多个状态字段。这也就意味着DaemonSet可以像Deployment那样进行版本管理。这个版本可以使用kubectl rollout history看到
```
$ kubectl rollout history daemonset fluentd-elasticsearch -n kube-system
daemonsets &quot;fluentd-elasticsearch&quot;
REVISION CHANGE-CAUSE
1 &lt;none&gt;
```
**接下来我们来把这个DaemonSet的容器镜像版本到v2.2.0**
```
$ kubectl set image ds/fluentd-elasticsearch fluentd-elasticsearch=k8s.gcr.io/fluentd-elasticsearch:v2.2.0 --record -n=kube-system
```
这个kubectl set image命令里第一个fluentd-elasticsearch是DaemonSet的名字第二个fluentd-elasticsearch是容器的名字。
这时候我们可以使用kubectl rollout status命令看到这个“滚动更新”的过程如下所示
```
$ kubectl rollout status ds/fluentd-elasticsearch -n kube-system
Waiting for daemon set &quot;fluentd-elasticsearch&quot; rollout to finish: 0 out of 2 new pods have been updated...
Waiting for daemon set &quot;fluentd-elasticsearch&quot; rollout to finish: 0 out of 2 new pods have been updated...
Waiting for daemon set &quot;fluentd-elasticsearch&quot; rollout to finish: 1 of 2 updated pods are available...
daemon set &quot;fluentd-elasticsearch&quot; successfully rolled out
```
注意由于这一次我在升级命令后面加上了record参数所以这次升级使用到的指令就会自动出现在DaemonSet的rollout history里面如下所示
```
$ kubectl rollout history daemonset fluentd-elasticsearch -n kube-system
daemonsets &quot;fluentd-elasticsearch&quot;
REVISION CHANGE-CAUSE
1 &lt;none&gt;
2 kubectl set image ds/fluentd-elasticsearch fluentd-elasticsearch=k8s.gcr.io/fluentd-elasticsearch:v2.2.0 --namespace=kube-system --record=true
```
有了版本号你也就可以像Deployment一样将DaemonSet回滚到某个指定的历史版本了。
而我在前面的文章中讲解Deployment对象的时候曾经提到过Deployment管理这些版本靠的是“一个版本对应一个ReplicaSet对象”。可是DaemonSet控制器操作的直接就是Pod不可能有ReplicaSet这样的对象参与其中。**那么,它的这些版本又是如何维护的呢?**
所谓,一切皆对象!
在Kubernetes项目中任何你觉得需要记录下来的状态都可以被用API对象的方式实现。当然“版本”也不例外。
Kubernetes v1.7之后添加了一个API对象名叫**ControllerRevision**专门用来记录某种Controller对象的版本。比如你可以通过如下命令查看fluentd-elasticsearch对应的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对象
```
$ 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: &lt;nil&gt;
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: &lt;none&gt;
```
就会看到这个ControllerRevision对象实际上是在Data字段保存了该版本对应的完整的DaemonSet的API对象。并且在Annotation字段保存了创建这个对象所使用的kubectl命令。
接下来我们可以尝试将这个DaemonSet回滚到Revision=1时的状态
```
$ kubectl rollout undo daemonset fluentd-elasticsearch --to-revision=1 -n kube-system
daemonset.extensions/fluentd-elasticsearch rolled back
```
这个kubectl rollout undo操作实际上相当于读取到了Revision=1的ControllerRevision对象保存的Data字段。而这个Data字段里保存的信息就是Revision=1时这个DaemonSet的完整API对象。
所以现在DaemonSet Controller就可以使用这个历史API对象对现有的DaemonSet做一次PATCH操作等价于执行一次kubectl apply -f “旧的DaemonSet对象”从而把这个DaemonSet“更新”到一个旧版本。
这也是为什么在执行完这次回滚完成后你会发现DaemonSet的Revision并不会从Revision=2退回到1而是会增加成Revision=3。这是因为一个新的ControllerRevision被创建了出来。
## 总结
在今天这篇文章中我首先简单介绍了StatefulSet的“滚动更新”然后重点讲解了本专栏的第三个重要编排对象DaemonSet。
相比于DeploymentDaemonSet只管理Pod对象然后通过nodeAffinity和Toleration这两个调度器的小功能保证了每个节点上有且只有一个Pod。这个控制器的实现原理简单易懂希望你能够快速掌握。
与此同时DaemonSet使用ControllerRevision来保存和管理自己对应的“版本”。这种“面向API对象”的设计思路大大简化了控制器本身的逻辑也正是Kubernetes项目“声明式API”的优势所在。
而且相信聪明的你此时已经想到了StatefulSet也是直接控制Pod对象的那么它是不是也在使用ControllerRevision进行版本管理呢
没错。在Kubernetes项目里ControllerRevision其实是一个通用的版本管理对象。这样Kubernetes项目就巧妙地避免了每种控制器都要维护一套冗余的代码和逻辑的问题。
## 思考题
我在文中提到在Kubernetes v1.11之前DaemonSet所管理的Pod的调度过程实际上都是由DaemonSet Controller自己而不是由调度器完成的。你能说出这其中有哪些原因吗
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,560 @@
<audio id="audio" title="22 | 撬动离线业务Job与CronJob" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4c/b0/4c14724d4047a3094b87fcec1f8239b0.mp3"></audio>
你好我是张磊。今天我和你分享的主题是撬动离线业务之Job与CronJob。
在前面的几篇文章中我和你详细分享了Deployment、StatefulSet以及DaemonSet这三个编排概念。你有没有发现它们的共同之处呢
实际上它们主要编排的对象都是“在线业务”Long Running Task长作业。比如我在前面举例时常用的Nginx、Tomcat以及MySQL等等。这些应用一旦运行起来除非出错或者停止它的容器进程会一直保持在Running状态。
但是有一类作业显然不满足这样的条件这就是“离线业务”或者叫作Batch Job计算业务。这种业务在计算完成后就直接退出了而此时如果你依然用Deployment来管理这种业务的话就会发现Pod会在计算结束后退出然后被Deployment Controller不断地重启而像“滚动更新”这样的编排功能更无从谈起了。
所以早在Borg项目中Google就已经对作业进行了分类处理提出了LRSLong Running Service和Batch Jobs两种作业形态对它们进行“分别管理”和“混合调度”。
不过在2015年Borg论文刚刚发布的时候Kubernetes项目并不支持对Batch Job的管理。直到v1.4版本之后社区才逐步设计出了一个用来描述离线业务的API对象它的名字就是Job。
Job API对象的定义非常简单我来举个例子如下所示
```
apiVersion: batch/v1
kind: Job
metadata:
name: pi
spec:
template:
spec:
containers:
- name: pi
image: resouer/ubuntu-bc
command: [&quot;sh&quot;, &quot;-c&quot;, &quot;echo 'scale=10000; 4*a(1)' | bc -l &quot;]
restartPolicy: Never
backoffLimit: 4
```
此时相信你对Kubernetes的API对象已经不再陌生了。在这个Job的YAML文件里你肯定一眼就会看到一位“老熟人”Pod模板即spec.template字段。
在这个Pod模板中我定义了一个Ubuntu镜像的容器准确地说是一个安装了bc命令的Ubuntu镜像它运行的程序是
```
echo &quot;scale=10000; 4*a(1)&quot; | bc -l
```
其中bc命令是Linux里的“计算器”-l表示我现在要使用标准数学库而a(1)则是调用数学库中的arctangent函数计算atan(1)。这是什么意思呢?
中学知识告诉我们:`tan(π/4) = 1`。所以,`4*atan(1)`正好就是π也就是3.1415926…。
>
备注:如果你不熟悉这个知识也不必担心,我也是在查阅资料后才知道的。
所以这其实就是一个计算π值的容器。而通过scale=10000我指定了输出的小数点后的位数是10000。在我的计算机上这个计算大概用时1分54秒。
但是跟其他控制器不同的是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: &lt;none&gt;
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: &lt;none&gt;
Events:
FirstSeen LastSeen Count From SubobjectPath Type Reason Message
--------- -------- ----- ---- ------------- -------- ------ -------
1m 1m 1 {job-controller } Normal SuccessfulCreate Created pod: pi-rq5rl
```
可以看到这个Job对象在创建后它的Pod模板被自动加上了一个controller-uid=&lt;一个随机字符串&gt;这样的Label。而这个Job对象本身则被自动加上了这个Label对应的Selector从而 保证了Job与它所管理的Pod之间的匹配关系。
而Job Controller之所以要使用这种携带了UID的Label就是为了避免不同Job对象所管理的Pod发生重合。需要注意的是**这种自动生成的Label对用户来说并不友好所以不太适合推广到Deployment等长作业编排对象上。**
接下来我们可以看到这个Job创建的Pod进入了Running状态这意味着它正在计算Pi的值。
```
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pi-rq5rl 1/1 Running 0 10s
```
而几分钟后计算结束这个Pod就会进入Completed状态
```
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pi-rq5rl 0/1 Completed 0 4m
```
这也是我们需要在Pod模板中定义restartPolicy=Never的原因离线计算的Pod永远都不应该被重启否则它们会再重新计算一遍。
>
事实上restartPolicy在Job对象里只允许被设置为Never和OnFailure而在Deployment对象里restartPolicy则只允许被设置为Always。
此时我们通过kubectl logs查看一下这个Pod的日志就可以看到计算得到的Pi值已经被打印了出来
```
$ kubectl logs pi-rq5rl
3.141592653589793238462643383279...
```
这时候,你一定会想到这样一个问题,**如果这个离线作业失败了要怎么办?**
比如,我们在这个例子中**定义了restartPolicy=Never那么离线作业失败后Job Controller就会不断地尝试创建一个新Pod**,如下所示:
```
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pi-55h89 0/1 ContainerCreating 0 2s
pi-tqbcz 0/1 Error 0 5s
```
可以看到这时候会不断地有新Pod被创建出来。
当然这个尝试肯定不能无限进行下去。所以我们就在Job对象的spec.backoffLimit字段里定义了重试次数为4backoffLimit=4而这个字段的默认值是6。
需要注意的是Job Controller重新创建Pod的间隔是呈指数增加的即下一次重新创建Pod的动作会分别发生在10 s、20 s、40 s …后。
而如果你**定义的restartPolicy=OnFailure那么离线作业失败后Job Controller就不会去尝试创建新的Pod。但是它会不断地尝试重启Pod里的容器**。这也正好对应了restartPolicy的含义你也可以借此机会再回顾一下第15篇文章[《深入解析Pod对象使用进阶》](https://time.geekbang.org/column/article/40466)中的相关内容)。
如前所述当一个Job的Pod运行结束后它会进入Completed状态。但是如果这个Pod因为某种原因一直不肯结束呢
在Job的API对象里有一个spec.activeDeadlineSeconds字段可以设置最长运行时间比如
```
spec:
backoffLimit: 5
activeDeadlineSeconds: 100
```
一旦运行超过了100 s这个Job的所有Pod都会被终止。并且你可以在Pod的状态里看到终止的原因是reason: DeadlineExceeded。
以上就是一个Job API对象最主要的概念和用法了。不过离线业务之所以被称为Batch Job当然是因为它们可以以“Batch”也就是并行的方式去运行。
接下来我就来为你讲解一下Job Controller对并行作业的控制方法。
在Job对象中负责并行控制的参数有两个
<li>
spec.parallelism它定义的是一个Job在任意时间最多可以启动多少个Pod同时运行
</li>
<li>
spec.completions它定义的是Job至少要完成的Pod数目即Job的最小完成数。
</li>
这两个参数听起来有点儿抽象,所以我准备了一个例子来帮助你理解。
现在我在之前计算Pi值的Job里添加这两个参数
```
apiVersion: batch/v1
kind: Job
metadata:
name: pi
spec:
parallelism: 2
completions: 4
template:
spec:
containers:
- name: pi
image: resouer/ubuntu-bc
command: [&quot;sh&quot;, &quot;-c&quot;, &quot;echo 'scale=5000; 4*a(1)' | bc -l &quot;]
restartPolicy: Never
backoffLimit: 4
```
这样我们就指定了这个Job最大的并行数是2而最小的完成数是4。
接下来我们来创建这个Job对象
```
$ kubectl create -f job.yaml
```
可以看到这个Job其实也维护了两个状态字段即DESIRED和SUCCESSFUL如下所示
```
$ kubectl get job
NAME DESIRED SUCCESSFUL AGE
pi 4 0 3s
```
其中DESIRED的值正是completions定义的最小完成数。
然后我们可以看到这个Job首先创建了两个并行运行的Pod来计算Pi
```
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
pi-5mt88 1/1 Running 0 6s
pi-gmcq5 1/1 Running 0 6s
```
而在40 s后这两个Pod相继完成计算。
这时我们可以看到每当有一个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状态。
这时由于所有的Pod均已经成功退出这个Job也就执行完了所以你会看到它的SUCCESSFUL字段的值变成了4
```
$ 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
$ kubectl get job
NAME DESIRED SUCCESSFUL AGE
pi 4 4 5m
```
通过上述Job的DESIRED和SUCCESSFUL字段的关系我们就可以很容易地理解Job Controller的工作原理了。
首先Job Controller控制的对象直接就是Pod。
其次Job Controller在控制循环中进行的调谐Reconcile操作是根据实际在Running状态Pod的数目、已经成功退出的Pod的数目以及parallelism、completions参数的值共同计算出在这个周期里应该创建或者删除的Pod数目然后调用Kubernetes API来执行这个操作。
以创建Pod为例。在上面计算Pi值的这个例子中当Job一开始创建出来时实际处于Running状态的Pod数目=0已经成功退出的Pod数目=0而用户定义的completions也就是最终用户需要的Pod数目=4。
所以在这个时刻需要创建的Pod数目 = 最终需要的Pod数目 - 实际在Running状态Pod数目 - 已经成功退出的Pod数目 = 4 - 0 - 0= 4。也就是说Job Controller需要创建4个Pod来纠正这个不一致状态。
可是我们又定义了这个Job的parallelism=2。也就是说我们规定了每次并发创建的Pod个数不能超过2个。所以Job Controller会对前面的计算结果做一个修正修正后的期望创建的Pod数目应该是2个。
这时候Job Controller就会并发地向kube-apiserver发起两个创建Pod的请求。
类似地如果在这次调谐周期里Job Controller发现实际在Running状态的Pod数目比parallelism还大那么它就会删除一些Pod使两者相等。
综上所述Job Controller实际上控制了作业执行的**并行度**,以及总共需要完成的**任务数**这两个重要参数。而在实际使用时你需要根据作业的特性来决定并行度parallelism和任务数completions的合理取值。
接下来我再和你分享三种常用的、使用Job对象的方法。
**第一种用法,也是最简单粗暴的用法:外部管理器+Job模板。**
这种模式的特定用法是把Job的YAML文件定义为一个“模板”然后用一个外部工具控制这些“模板”来生成Job。这时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: [&quot;sh&quot;, &quot;-c&quot;, &quot;echo Processing item $ITEM &amp;&amp; sleep 5&quot;]
restartPolicy: Never
```
可以看到我们在这个Job的YAML里定义了$ITEM这样的“变量”。
所以在控制这种Job时我们只要注意如下两个方面即可
<li>
创建Job时替换掉$ITEM这样的变量
</li>
<li>
所有来自于同一个模板的Job都有一个jobgroup: jobexample标签也就是说这一组Job使用这样一个相同的标识。
</li>
而做到第一点非常简单。比如你可以通过这样一句shell把$ITEM替换掉
```
$ mkdir ./jobs
$ for i in apple banana cherry
do
cat job-tmpl.yaml | sed &quot;s/\$ITEM/$i/&quot; &gt; ./jobs/job-$i.yaml
done
```
这样一组来自于同一个模板的不同Job的yaml就生成了。接下来你就可以通过一句kubectl create指令创建这些Job了
```
$ kubectl create -f ./jobs
$ kubectl get pods -l jobgroup=jobexample
NAME READY STATUS RESTARTS AGE
process-item-apple-kixwv 0/1 Completed 0 4m
process-item-banana-wrsf7 0/1 Completed 0 4m
process-item-cherry-dnfu9 0/1 Completed 0 4m
```
这个模式看起来虽然很“傻”但却是Kubernetes社区里使用Job的一个很普遍的模式。
原因很简单大多数用户在需要管理Batch Job的时候都已经有了一套自己的方案需要做的往往就是集成工作。这时候Kubernetes项目对这些方案来说最有价值的就是Job这个API对象。所以你只需要编写一个外部工具等同于我们这里的for循环来管理这些Job即可。
这种模式最典型的应用就是TensorFlow社区的KubeFlow项目。
很容易理解在这种模式下使用Job对象completions和parallelism这两个字段都应该使用默认值1而不应该由我们自行设置。而作业Pod的并行控制应该完全交由外部工具来进行管理比如KubeFlow
**第二种用法拥有固定任务数目的并行Job**
这种模式下我只关心最后是否有指定数目spec.completions个任务成功退出。至于执行时的并行度是多少我并不关心。
比如我们这个计算Pi值的例子就是这样一个典型的、拥有固定任务数目completions=4的应用场景。 它的parallelism值是2或者你可以干脆不指定parallelism直接使用默认的并行度1
此外你还可以使用一个工作队列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
```
我们可以看到它的completions的值是8这意味着我们总共要处理的任务数目是8个。也就是说总共会有8个任务会被逐一放入工作队列里你可以运行一个外部小程序作为生产者来提交任务
在这个实例中我选择充当工作队列的是一个运行在Kubernetes里的RabbitMQ。所以我们需要在Pod模板里定义BROKER_URL来作为消费者。
所以一旦你用kubectl create创建了这个Job它就会以并发度为2的方式每两个Pod一组创建出8个Pod。每个Pod都会去连接BROKER_URL从RabbitMQ里读取任务然后各自进行处理。这个Pod里的执行逻辑我们可以用这样一段伪代码来表示
```
/* job-wq-1的伪代码 */
queue := newQueue($BROKER_URL, $QUEUE)
task := queue.Pop()
process(task)
exit
```
可以看到每个Pod只需要将任务信息读取出来处理完成然后退出即可。而作为用户我只关心最终一共有8个计算任务启动并且退出只要这个目标达到我就认为整个Job处理完成了。所以说这种用法对应的就是“任务总数固定”的场景。
**第三种用法也是很常用的一个用法指定并行度parallelism但不设置固定的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(&quot;Queue empty, exiting&quot;)
exit
```
由于任务数目的总数不固定所以每一个Pod必须能够知道自己什么时候可以退出。比如在这个例子中我简单地以“队列为空”作为任务全部完成的标志。所以说这种用法对应的是“任务总数不固定”的场景。
不过在实际的应用中你需要处理的条件往往会非常复杂。比如任务完成后的输出、每个任务Pod之间是不是有资源的竞争和协同等等。
所以在今天这篇文章中我就不再展开Job的用法了。因为在实际场景里要么干脆就用第一种用法来自己管理作业要么这些任务Pod之间的关系就不那么“单纯”甚至还是“有状态应用”比如任务的输入/输出是在持久化数据卷里。在这种情况下我在后面要重点讲解的Operator加上Job对象一起可能才能更好地满足实际离线任务的编排需求。
最后我再来和你分享一个非常有用的Job对象叫作CronJob。
顾名思义CronJob描述的正是定时任务。它的API对象如下所示
```
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: hello
spec:
schedule: &quot;*/1 * * * *&quot;
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对象的控制器Controller
没错CronJob与Job的关系正如同Deployment与ReplicaSet的关系一样。CronJob是一个专门用来管理Job对象的控制器。只不过它创建和删除Job的依据是schedule字段定义的、一个标准的[Unix Cron](https://en.wikipedia.org/wiki/Cron)格式的表达式。
比如,"*/1 * * * *"。
这个Cron表达式里*/1中的*表示从0开始/表示“每”1表示偏移量。所以它的意思就是从0开始每1个时间单位执行一次。
那么,时间单位又是什么呢?
Cron表达式中的五个部分分别代表分钟、小时、日、月、星期。
所以上面这句Cron表达式的意思是从当前开始每分钟执行一次。
而这里要执行的内容就是jobTemplate定义的Job了。
所以这个CronJob对象在创建1分钟后就会有一个Job产生了如下所示
```
$ kubectl create -f ./cronjob.yaml
cronjob &quot;hello&quot; created
# 一分钟后
$ kubectl get jobs
NAME DESIRED SUCCESSFUL AGE
hello-4111706356 1 1 2s
```
此时CronJob对象会记录下这次Job执行的时间
```
$ kubectl get cronjob hello
NAME SCHEDULE SUSPEND ACTIVE LAST-SCHEDULE
hello */1 * * * * False 0 Thu, 6 Sep 2018 14:34:00 -070
```
需要注意的是由于定时任务的特殊性很可能某个Job还没有执行完另外一个新Job就产生了。这时候你可以通过spec.concurrencyPolicy字段来定义具体的处理策略。比如
<li>
concurrencyPolicy=Allow这也是默认情况这意味着这些Job可以同时存在
</li>
<li>
concurrencyPolicy=Forbid这意味着不会创建新的Pod该创建周期被跳过
</li>
<li>
concurrencyPolicy=Replace这意味着新产生的Job会替换旧的、没有执行完的Job。
</li>
而如果某一次Job创建失败这次创建就会被标记为“miss”。当在指定的时间窗口内miss的数目达到100时那么CronJob会停止再创建这个Job。
这个时间窗口可以由spec.startingDeadlineSeconds字段指定。比如startingDeadlineSeconds=200意味着在过去200 s里如果miss的数目达到了100次那么这个Job就不会被创建执行了。
## 总结
在今天这篇文章中我主要和你分享了Job这个离线业务的编排方法讲解了completions和parallelism字段的含义以及Job Controller的执行原理。
紧接着我通过实例和你分享了Job对象三种常见的使用方法。但是根据我在社区和生产环境中的经验大多数情况下用户还是更倾向于自己控制Job对象。所以相比于这些固定的“模式”掌握Job的API对象和它各个字段的准确含义会更加重要。
最后我还介绍了一种Job的控制器叫作CronJob。这也印证了我在前面的分享中所说的用一个对象控制另一个对象是Kubernetes编排的精髓所在。
## 思考题
根据Job控制器的工作原理如果你定义的parallelism比completions还大的话比如
```
parallelism: 4
completions: 2
```
那么这个Job最开始创建的时候会同时启动几个Pod呢原因是什么
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,456 @@
<audio id="audio" title="23 | 声明式API与Kubernetes编程范式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0b/d9/0b697c5bde2f28826ddfc0ebf386b2d9.mp3"></audio>
你好我是张磊。今天我和你分享的主题是声明式API与Kubernetes编程范式。
在前面的几篇文章中我和你分享了很多Kubernetes的API对象。这些API对象有的是用来描述应用有的则是为应用提供各种各样的服务。但是无一例外地为了使用这些API对象提供的能力你都需要编写一个对应的YAML文件交给Kubernetes。
这个YAML文件正是Kubernetes声明式API所必须具备的一个要素。不过是不是只要用YAML文件代替了命令行操作就是声明式API了呢
举个例子。我们知道Docker Swarm的编排操作都是基于命令行的比如
```
$ docker service create --name nginx --replicas 2 nginx
$ docker service update --image nginx:1.7.9 nginx
```
像这样的两条命令就是用Docker Swarm启动了两个Nginx容器实例。其中第一条create命令创建了这两个容器而第二条update命令则把它们“滚动更新”成了一个新的镜像。
对于这种使用方式,我们称为**命令式命令行操作**。
那么像上面这样的创建和更新两个Nginx容器的操作在Kubernetes里又该怎么做呢
这个流程相信你已经非常熟悉了我们需要在本地编写一个Deployment的YAML文件
```
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
ports:
- containerPort: 80
```
然后我们还需要使用kubectl create命令在Kubernetes里创建这个Deployment对象
```
$ kubectl create -f nginx.yaml
```
这样两个Nginx的Pod就会运行起来了。
而如果要更新这两个Pod使用的Nginx镜像该怎么办呢
我们前面曾经使用过kubectl set image和kubectl edit命令来直接修改Kubernetes里的API对象。不过相信很多人都有这样的想法我能不能通过修改本地YAML文件来完成这个操作呢这样我的改动就会体现在这个本地YAML文件里了。
当然可以。
比如我们可以修改这个YAML文件里的Pod模板部分把Nginx容器的镜像改成1.7.9,如下所示:
```
...
spec:
containers:
- name: nginx
image: nginx:1.7.9
```
而接下来我们就可以执行一句kubectl replace操作来完成这个Deployment的更新
```
$ kubectl replace -f nginx.yaml
```
可是上面这种基于YAML文件的操作方式是“声明式API”吗
并不是。
对于上面这种先kubectl create再replace的操作我们称为**命令式配置文件操作。**
也就是说它的处理方式其实跟前面Docker Swarm的两句命令没什么本质上的区别。只不过它是把Docker命令行里的参数写在了配置文件里而已。
**那么到底什么才是“声明式API”呢**
答案是kubectl apply命令。
在前面的文章中我曾经提到过这个kubectl apply命令并推荐你使用它来代替kubectl create命令你也可以借此机会再回顾一下第12篇文章[《牛刀小试:我的第一个容器化应用》](https://time.geekbang.org/column/article/40008)中的相关内容)。
现在我就使用kubectl apply命令来创建这个Deployment
```
$ kubectl apply -f nginx.yaml
```
这样Nginx的Deployment就被创建了出来这看起来跟kubectl create的效果一样。
然后我再修改一下nginx.yaml里定义的镜像
```
...
spec:
containers:
- name: nginx
image: nginx:1.7.9
```
这时候,关键来了。
在修改完这个YAML文件之后我不再使用kubectl replace命令进行更新而是继续执行一条kubectl apply命令
```
$ kubectl apply -f nginx.yaml
```
这时Kubernetes就会立即触发这个Deployment的“滚动更新”。
可是它跟kubectl replace命令有什么本质区别吗
实际上你可以简单地理解为kubectl replace的执行过程是使用新的YAML文件中的API对象**替换原有的API对象**而kubectl apply则是执行了一个**对原有API对象的PATCH操作**。
>
类似地kubectl set image和kubectl edit也是对已有API对象的修改。
更进一步地这意味着kube-apiserver在响应命令式请求比如kubectl replace的时候一次只能处理一个写请求否则会有产生冲突的可能。而对于声明式请求比如kubectl apply**一次能处理多个写操作并且具备Merge能力**。
这种区别可能乍一听起来没那么重要。而且正是由于要照顾到这样的API设计做同样一件事情Kubernetes需要的步骤往往要比其他项目多不少。
但是如果你仔细思考一下Kubernetes项目的工作流程就不难体会到这种声明式API的独到之处。
接下来我就以Istio项目为例来为你讲解一下声明式API在实际使用时的重要意义。
在2017年5月Google、IBM和Lyft公司共同宣布了Istio开源项目的诞生。很快这个项目就在技术圈儿里掀起了一阵名叫“微服务”的热潮把Service Mesh这个新的编排概念推到了风口浪尖。
而Istio项目实际上就是一个基于Kubernetes项目的微服务治理框架。它的架构非常清晰如下所示
<img src="https://static001.geekbang.org/resource/image/d3/1b/d38daed2fedc90e20e9d2f27afbaec1b.jpg" alt=""><br>
在上面这个架构图中我们不难看到Istio项目架构的核心所在。**Istio最根本的组件是运行在每一个应用Pod里的Envoy容器**。
这个Envoy项目是Lyft公司推出的一个高性能C++网络代理也是Lyft公司对Istio项目的唯一贡献。
而Istio项目则把这个代理服务以sidecar容器的方式运行在了每一个被治理的应用Pod中。我们知道Pod里的所有容器都共享同一个Network Namespace。所以Envoy容器就能够通过配置Pod里的iptables规则把整个Pod的进出流量接管下来。
这时候Istio的控制层Control Plane里的Pilot组件就能够通过调用每个Envoy容器的API对这个Envoy代理进行配置从而实现微服务治理。
我们一起来看一个例子。
假设这个Istio架构图左边的Pod是已经在运行的应用而右边的Pod则是我们刚刚上线的应用的新版本。这时候Pilot通过调节这两Pod里的Envoy容器的配置从而将90%的流量分配给旧版本的应用将10%的流量分配给新版本应用并且还可以在后续的过程中随时调整。这样一个典型的“灰度发布”的场景就完成了。比如Istio可以调节这个流量从90%-10%改到80%-20%再到50%-50%最后到0%-100%,就完成了这个灰度发布的过程。
更重要的是在整个微服务治理的过程中无论是对Envoy容器的部署还是像上面这样对Envoy代理的配置用户和应用都是完全“无感”的。
这时候你可能会有所疑惑Istio项目明明需要在每个Pod里安装一个Envoy容器又怎么能做到“无感”的呢
实际上,**Istio项目使用的是Kubernetes中的一个非常重要的功能叫作Dynamic Admission Control。**
在Kubernetes项目中当一个Pod或者任何一个API对象被提交给APIServer之后总有一些“初始化”性质的工作需要在它们被Kubernetes项目正式处理之前进行。比如自动为所有Pod加上某些标签Labels
而这个“初始化”操作的实现借助的是一个叫作Admission的功能。它其实是Kubernetes项目里一组被称为Admission Controller的代码可以选择性地被编译进APIServer中在API对象创建之后会被立刻调用到。
但这就意味着如果你现在想要添加一些自己的规则到Admission Controller就会比较困难。因为这要求重新编译并重启APIServer。显然这种使用方法对Istio来说影响太大了。
所以Kubernetes项目为我们额外提供了一种“热插拔”式的Admission机制它就是Dynamic Admission Control也叫作Initializer。
现在我给你举个例子。比如我有如下所示的一个应用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! &amp;&amp; sleep 3600']
```
可以看到这个Pod里面只有一个用户容器叫作myapp-container。
接下来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! &amp;&amp; sleep 3600']
- name: envoy
image: lyft/envoy:845747b88f102c0fd262ab234308e9e22f693a1
command: [&quot;/usr/local/bin/envoy&quot;]
...
```
可以看到被Istio处理后的这个Pod里除了用户自己定义的myapp-container容器之外多出了一个叫作envoy的容器它就是Istio要使用的Envoy代理。
那么Istio又是如何在用户完全不知情的前提下完成这个操作的呢
Istio要做的就是编写一个用来为Pod“自动注入”Envoy容器的Initializer。
**首先Istio会将这个Envoy容器本身的定义以ConfigMap的方式保存在Kubernetes当中**。这个ConfigMap名叫envoy-initializer的定义如下所示
```
apiVersion: v1
kind: ConfigMap
metadata:
name: envoy-initializer
data:
config: |
containers:
- name: envoy
image: lyft/envoy:845747db88f102c0fd262ab234308e9e22f693a1
command: [&quot;/usr/local/bin/envoy&quot;]
args:
- &quot;--concurrency 4&quot;
- &quot;--config-path /etc/envoy/envoy.json&quot;
- &quot;--mode serve&quot;
ports:
- containerPort: 80
protocol: TCP
resources:
limits:
cpu: &quot;1000m&quot;
memory: &quot;512Mi&quot;
requests:
cpu: &quot;100m&quot;
memory: &quot;64Mi&quot;
volumeMounts:
- name: envoy-conf
mountPath: /etc/envoy
volumes:
- name: envoy-conf
configMap:
name: envoy
```
相信你已经注意到了这个ConfigMap的data部分正是一个Pod对象的一部分定义。其中我们可以看到Envoy容器对应的containers字段以及一个用来声明Envoy配置文件的volumes字段。
不难想到Initializer要做的工作就是把这部分Envoy相关的字段自动添加到用户提交的Pod的API对象里。可是用户提交的Pod里本来就有containers字段和volumes字段所以Kubernetes在处理这样的更新请求时就必须使用类似于git merge这样的操作才能将这两部分内容合并在一起。
所以说在Initializer更新用户的Pod对象的时候必须使用PATCH API来完成。而这种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
```
我们可以看到这个envoy-initializer使用的envoy-initializer:0.0.1镜像就是一个事先编写好的“自定义控制器”Custom Controller我将会在下一篇文章中讲解它的编写方法。而在这里我要先为你解释一下这个控制器的主要功能。
我曾在第16篇文章[《编排其实很简单:谈谈“控制器”模型》](https://time.geekbang.org/column/article/40583)中和你分享过一个Kubernetes的控制器实际上就是一个“死循环”它不断地获取“实际状态”然后与“期望状态”作对比并以此为依据决定下一步的操作。
而Initializer的控制器不断获取到的“实际状态”就是用户新创建的Pod。而它的“期望状态”则是这个Pod里被添加了Envoy容器的定义。
我还是用一段Go语言风格的伪代码来为你描述这个控制逻辑如下所示
```
for {
// 获取新创建的Pod
pod := client.GetLatestPod()
// Diff一下检查是否已经初始化过
if !isInitialized(pod) {
// 没有?那就来初始化一下
doSomething(pod)
}
}
```
- 如果这个Pod里面已经添加过Envoy容器那么就“放过”这个Pod进入下一个检查周期。
- 而如果还没有添加过Envoy容器的话它就要进行Initialize操作了修改该Pod的API对象doSomething函数
这时候你应该立刻能想到Istio要往这个Pod里合并的字段正是我们之前保存在envoy-initializer这个ConfigMap里的数据它的data字段的值
所以在Initializer控制器的工作逻辑里它首先会从APIServer中拿到这个ConfigMap
```
func doSomething(pod) {
cm := client.Get(ConfigMap, &quot;envoy-initializer&quot;)
}
```
然后把这个ConfigMap里存储的containers和volumes字段直接添加进一个空的Pod对象里
```
func doSomething(pod) {
cm := client.Get(ConfigMap, &quot;envoy-initializer&quot;)
newPod := Pod{}
newPod.Spec.Containers = cm.Containers
newPod.Spec.Volumes = cm.Volumes
}
```
现在,关键来了。
Kubernetes的API库为我们提供了一个方法使得我们可以直接使用新旧两个Pod对象生成一个TwoWayMergePatch
```
func doSomething(pod) {
cm := client.Get(ConfigMap, &quot;envoy-initializer&quot;)
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)
}
```
**有了这个TwoWayMergePatch之后Initializer的代码就可以使用这个patch的数据调用Kubernetes的Client发起一个PATCH请求**
这样一个用户提交的Pod对象里就会被自动加上Envoy容器相关的字段。
当然Kubernetes还允许你通过配置来指定要对什么样的资源进行这个Initialize操作比如下面这个例子
```
apiVersion: admissionregistration.k8s.io/v1alpha1
kind: InitializerConfiguration
metadata:
name: envoy-config
initializers:
// 这个名字必须至少包括两个 &quot;.&quot;
- name: envoy.initializer.kubernetes.io
rules:
- apiGroups:
- &quot;&quot; // 前面说过, &quot;&quot;就是core API Group的意思
apiVersions:
- v1
resources:
- pods
```
这个配置就意味着Kubernetes要对所有的Pod进行这个Initialize操作并且我们指定了负责这个操作的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有没有执行过自己所负责的初始化操作的重要依据也就是前面伪代码中isInitialized()方法的含义)。
**这也就意味着当你在Initializer里完成了要做的操作后一定要记得将这个metadata.initializers.pending标志清除掉。这一点你在编写Initializer代码的时候一定要非常注意。**
此外除了上面的配置方法你还可以在具体的Pod的Annotation里添加一个如下所示的字段从而声明要使用某个Initializer
```
apiVersion: v1
kind: Pod
metadata
annotations:
&quot;initializer.kubernetes.io/envoy&quot;: &quot;true&quot;
...
```
在这个Pod里我们添加了一个Annotation写明 `initializer.kubernetes.io/envoy=true`。这样就会使用到我们前面所定义的envoy-initializer了。
以上就是关于Initializer最基本的工作原理和使用方法了。相信你此时已经明白**Istio项目的核心就是由无数个运行在应用Pod中的Envoy容器组成的服务代理网格**。这也正是Service Mesh的含义。
>
备注如果你对这个Demo感兴趣可以在[这个GitHub链接](https://github.com/resouer/kubernetes-initializer-tutorial)里找到它的所有源码和文档。这个Demo是我fork自Kelsey Hightower的一个同名的Demo。
而这个机制得以实现的原理正是借助了Kubernetes能够对API对象进行在线更新的能力这也正是**Kubernetes“声明式API”的独特之处**
- 首先所谓“声明式”指的就是我只需要提交一个定义好的API对象来“声明”我所期望的状态是什么样子。
- 其次“声明式API”允许有多个API写端以PATCH的方式对API对象进行修改而无需关心本地原始YAML文件的内容。
- 最后也是最重要的有了上述两个能力Kubernetes项目才可以基于对API对象的增、删、改、查在完全无需外界干预的情况下完成对“实际状态”和“期望状态”的调谐Reconcile过程。
所以说,**声明式API才是Kubernetes项目编排能力“赖以生存”的核心所在**,希望你能够认真理解。
此外不难看到无论是对sidecar容器的巧妙设计还是对Initializer的合理利用Istio项目的设计与实现其实都依托于Kubernetes的声明式API和它所提供的各种编排能力。可以说Istio是在Kubernetes项目使用上的一位“集大成者”。
>
要知道一个Istio项目部署完成后会在Kubernetes里创建大约43个API对象。
所以Kubernetes社区也看得很明白Istio项目有多火热就说明Kubernetes这套“声明式API”有多成功。这既是Google Cloud喜闻乐见的事情也是Istio项目一推出就被Google公司和整个技术圈儿热捧的重要原因。
而在使用Initializer的流程中最核心的步骤莫过于Initializer“自定义控制器”的编写过程。它遵循的正是标准的“Kubernetes编程范式”
>
**如何使用控制器模式同Kubernetes里API对象的“增、删、改、查”进行协作进而完成用户业务逻辑的编写过程。**
这,也正是我要在后面文章中为你详细讲解的内容。
## 总结
在今天这篇文章中我为你重点讲解了Kubernetes声明式API的含义。并且通过对Istio项目的剖析我为你说明了它使用Kubernetes的Initializer特性完成Envoy容器“自动注入”的原理。
事实上从“使用Kubernetes部署代码”到“使用Kubernetes编写代码”的蜕变过程正是你从一个Kubernetes用户到Kubernetes玩家的晋级之路。
如何理解“Kubernetes编程范式”如何为Kubernetes添加自定义API对象编写自定义控制器正是这个晋级过程中的关键点也是我要在后面几篇文章中分享的核心内容。
此外基于今天这篇文章所讲述的Istio的工作原理尽管Istio项目一直宣称它可以运行在非Kubernetes环境中但我并不建议你花太多时间去做这个尝试。
毕竟无论是从技术实现还是在社区运作上Istio与Kubernetes项目之间都是紧密的、唇齿相依的关系。如果脱离了Kubernetes项目这个基础那么这条原本就不算平坦的“微服务”之路恐怕会更加困难重重。
## 思考题
你是否对Envoy项目做过了解你觉得为什么它能够击败Nginx以及HAProxy等竞品成为Service Mesh体系的核心
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,434 @@
<audio id="audio" title="24 | 深入解析声明式APIAPI对象的奥秘" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ab/70/ab2cf4d567d3ced9caa349bd0b9dbe70.mp3"></audio>
你好我是张磊。今天我和你分享的主题是深入解析声明式API之API对象的奥秘。
在上一篇文章中我为你详细讲解了Kubernetes声明式API的设计、特点以及使用方式。
而在今天这篇文章中我就来为你讲解一下Kubernetes声明式API的工作原理以及如何利用这套API机制在Kubernetes里添加自定义的API对象。
你可能一直就很好奇当我把一个YAML文件提交给Kubernetes之后它究竟是如何创建出一个API对象的呢
这得从声明式API的设计谈起了。
在Kubernetes项目中一个API对象在Etcd里的完整资源路径是由GroupAPI组、VersionAPI版本和ResourceAPI资源类型三个部分组成的。
通过这样的结构整个Kubernetes里的所有API对象实际上就可以用如下的树形结构表示出来
<img src="https://static001.geekbang.org/resource/image/70/da/709700eea03075bed35c25b5b6cdefda.png" alt=""><br>
在这幅图中,你可以很清楚地看到**Kubernetes里API对象的组织方式其实是层层递进的。**
比如现在我要声明要创建一个CronJob对象那么我的YAML文件的开始部分会这么写
```
apiVersion: batch/v2alpha1
kind: CronJob
...
```
在这个YAML文件中“CronJob”就是这个API对象的资源类型Resource“batch”就是它的组Groupv2alpha1就是它的版本Version
当我们提交了这个YAML文件之后Kubernetes就会把这个YAML文件里描述的内容转换成Kubernetes里的一个CronJob对象。
那么Kubernetes是如何对Resource、Group和Version进行解析从而在Kubernetes项目里找到CronJob对象的定义呢
**首先Kubernetes会匹配API对象的组。**
需要明确的是对于Kubernetes里的核心API对象比如Pod、Node等是不需要Group的它们的Group是“”。所以对于这些API对象来说Kubernetes会直接在/api这个层级进行下一步的匹配过程。
而对于CronJob等非核心API对象来说Kubernetes就必须在/apis这个层级里查找它对应的Group进而根据“batch”这个Group的名字找到/apis/batch。
不难发现这些API Group的分类是以对象功能为依据的比如Job和CronJob就都属于“batch” 离线业务这个Group。
**然后Kubernetes会进一步匹配到API对象的版本号。**
对于CronJob这个API对象来说Kubernetes在batch这个Group下匹配到的版本号就是v2alpha1。
在Kubernetes中同一种API对象可以有多个版本这正是Kubernetes进行API版本化管理的重要手段。这样比如在CronJob的开发过程中对于会影响到用户的变更就可以通过升级新版本来处理从而保证了向后兼容。
**最后Kubernetes会匹配API对象的资源类型。**
在前面匹配到正确的版本之后Kubernetes就知道我要创建的原来是一个/apis/batch/v2alpha1下的CronJob对象。
这时候APIServer就可以继续创建这个CronJob对象了。为了方便理解我为你总结了一个如下所示流程图来阐述这个创建过程
<img src="https://static001.geekbang.org/resource/image/df/6f/df6f1dda45e9a353a051d06c48f0286f.png" alt=""><br>
**首先**当我们发起了创建CronJob的POST请求之后我们编写的YAML的信息就被提交给了APIServer。
而APIServer的第一个功能就是过滤这个请求并完成一些前置性的工作比如授权、超时处理、审计等。
**然后**请求会进入MUX和Routes流程。如果你编写过Web Server的话就会知道MUX和Routes是APIServer完成URL和Handler绑定的场所。而APIServer的Handler要做的事情就是按照我刚刚介绍的匹配过程找到对应的CronJob类型定义。
**接着**APIServer最重要的职责就来了根据这个CronJob类型定义使用用户提交的YAML文件里的字段创建一个CronJob对象。
而在这个过程中APIServer会进行一个Convert工作把用户提交的YAML文件转换成一个叫作Super Version的对象它正是该API资源类型所有版本的字段全集。这样用户提交的不同版本的YAML文件就都可以用这个Super Version对象来进行处理了。
**接下来**APIServer会先后进行Admission()和Validation()操作。比如我在上一篇文章中提到的Admission Controller和Initializer就都属于Admission的内容。
而Validation则负责验证这个对象里的各个字段是否合法。这个被验证过的API对象都保存在了APIServer里一个叫作Registry的数据结构中。也就是说只要一个API对象的定义能在Registry里查到它就是一个有效的Kubernetes API对象。
**最后**APIServer会把验证过的API对象转换成用户最初提交的版本进行序列化操作并调用Etcd的API把它保存起来。
由此可见声明式API对于Kubernetes来说非常重要。所以**APIServer这样一个在其他项目里“平淡无奇”的组件却成了Kubernetes项目的重中之重**。它不仅是Google Borg设计思想的集中体现也是Kubernetes项目里唯一一个被Google公司和RedHat公司双重控制、其他势力根本无法参与其中的组件。
此外由于同时要兼顾性能、API完备性、版本化、向后兼容等很多工程化指标所以Kubernetes团队在APIServer项目里大量使用了Go语言的代码生成功能来自动化诸如Convert、DeepCopy等与API资源相关的操作。这部分自动生成的代码曾一度占到Kubernetes项目总代码的20%~30%。
这也是为何在过去很长一段时间里在这样一个极其“复杂”的APIServer中添加一个Kubernetes风格的API资源类型是一个非常困难的工作。
不过在Kubernetes v1.7 之后这个工作就变得轻松得多了。这当然得益于一个全新的API插件机制CRD。
CRD的全称是Custom Resource Definition。顾名思义它指的就是允许用户在Kubernetes中添加一个跟Pod、Node类似的、新的API资源类型自定义API资源。
举个例子我现在要为Kubernetes添加一个名叫Network的API资源类型。
它的作用是一旦用户创建一个Network对象那么Kubernetes就应该使用这个对象定义的网络参数调用真实的网络插件比如Neutron项目为用户创建一个真正的“网络”。这样将来用户创建的Pod就可以声明使用这个“网络”了。
这个Network对象的YAML文件名叫example-network.yaml它的内容如下所示
```
apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
name: example-network
spec:
cidr: &quot;192.168.0.0/16&quot;
gateway: &quot;192.168.0.1&quot;
```
可以看到我想要描述“网络”的API资源类型是NetworkAPI组是`samplecrd.k8s.io`API 版本是v1。
那么Kubernetes又该如何知道这个API`samplecrd.k8s.io/v1/network`)的存在呢?
其实上面的这个YAML文件就是一个具体的“自定义API资源”实例也叫CRCustom Resource。而为了能够让Kubernetes认识这个CR你就需要让Kubernetes明白这个CR的宏观定义是什么也就是CRDCustom Resource Definition
这就好比,你想让计算机认识各种兔子的照片,就得先让计算机明白,兔子的普遍定义是什么。比如,兔子“是哺乳动物”“有长耳朵,三瓣嘴”。
所以接下来我就先编写一个CRD的YAML文件它的名字叫作network.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: samplecrd.k8s.io`”“`version: v1`”这样的API信息也指定了这个CR的资源类型叫作Network复数plural是networks。
然后我还声明了它的scope是Namespaced我们定义的这个Network是一个属于Namespace的对象类似于Pod。
这就是一个Network API资源类型的API部分的宏观定义了。这就等同于告诉了计算机“兔子是哺乳动物”。所以这时候Kubernetes就能够认识和处理所有声明了API类型是“`samplecrd.k8s.io/v1/network`”的YAML文件了。
接下来我还需要让Kubernetes“认识”这种YAML文件里描述的“网络”部分比如“cidr”网段“gateway”网关这些字段的含义。这就相当于我要告诉计算机“兔子有长耳朵和三瓣嘴”。
这时候呢,我就需要稍微做些代码工作了。
**首先我要在GOPATH下创建一个结构如下的项目**
>
备注在这里我并不要求你具有完备的Go语言知识体系但我会假设你已经了解了Golang的一些基本知识比如知道什么是GOPATH。而如果你还不了解的话可以在涉及到相关内容时再去查阅一些相关资料。
```
$ tree $GOPATH/src/github.com/&lt;your-name&gt;/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
└── types.go
```
其中pkg/apis/samplecrd就是API组的名字v1是版本而v1下面的types.go文件里则定义了Network对象的完整描述。我已经把这个项目[上传到了GitHub上](https://github.com/resouer/k8s-controller-custom-resource),你可以随时参考。
**然后我在pkg/apis/samplecrd目录下创建了一个register.go文件用来放置后面要用到的全局变量**。这个文件的内容如下所示:
```
package samplecrd
const (
GroupName = &quot;samplecrd.k8s.io&quot;
Version = &quot;v1&quot;
)
```
**接着我需要在pkg/apis/samplecrd目录下添加一个doc.go文件Golang的文档源文件**。这个文件里的内容如下所示:
```
// +k8s:deepcopy-gen=package
// +groupName=samplecrd.k8s.io
package v1
```
在这个文件中,你会看到+&lt;tag_name&gt;[=value]格式的注释这就是Kubernetes进行代码生成要用的Annotation风格的注释。
其中,+k8s:deepcopy-gen=package意思是请为整个v1包里的所有类型定义自动生成DeepCopy方法`+groupName=samplecrd.k8s.io`则定义了这个包对应的API组的名字。
可以看到这些定义在doc.go文件的注释起到的是全局的代码生成控制的作用所以也被称为Global Tags。
**接下来我需要添加types.go文件**。顾名思义它的作用就是定义一个Network类型到底有哪些字段比如spec字段里的内容。这个文件的主要内容如下所示
```
package v1
...
// +genclient
// +genclient:noStatus
// +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:&quot;,inline&quot;`
// ObjectMeta contains the metadata for the particular object, including
// things like...
// - name
// - namespace
// - self link
// - labels
// - ... etc ...
metav1.ObjectMeta `json:&quot;metadata,omitempty&quot;`
Spec networkspec `json:&quot;spec&quot;`
}
// networkspec is the spec for a Network resource
type networkspec struct {
Cidr string `json:&quot;cidr&quot;`
Gateway string `json:&quot;gateway&quot;`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
// NetworkList is a list of Network resources
type NetworkList struct {
metav1.TypeMeta `json:&quot;,inline&quot;`
metav1.ListMeta `json:&quot;metadata&quot;`
Items []Network `json:&quot;items&quot;`
}
```
在上面这部分代码里你可以看到Network类型定义方法跟标准的Kubernetes对象一样都包括了TypeMetaAPI元数据和ObjectMeta对象元数据字段。
而其中的Spec字段就是需要我们自己定义的部分。所以在networkspec里我定义了Cidr和Gateway两个字段。其中每个字段最后面的部分比如`json:"cidr"`指的就是这个字段被转换成JSON格式之后的名字也就是YAML文件里的字段名字。
>
如果你不熟悉这个用法的话可以查阅一下Golang的文档。
此外除了定义Network类型你还需要定义一个NetworkList类型用来描述**一组Network对象**应该包括哪些字段。之所以需要这样一个类型是因为在Kubernetes中获取所有X对象的List()方法,返回值都是<x>List类型而不是X类型的数组。这是不一样的。</x>
同样地在Network和NetworkList类型上也有代码生成注释。
其中,+genclient的意思是请为下面这个API资源类型生成对应的Client代码这个Client我马上会讲到。而+genclient:noStatus的意思是这个API资源类型定义里没有Status字段。否则生成的Client就会自动带上UpdateStatus方法。
如果你的类型定义包括了Status字段的话就不需要这句+genclient:noStatus注释了。比如下面这个例子
```
// +genclient
// Network is a specification for a Network resource
type Network struct {
metav1.TypeMeta `json:&quot;,inline&quot;`
metav1.ObjectMeta `json:&quot;metadata,omitempty&quot;`
Spec NetworkSpec `json:&quot;spec&quot;`
Status NetworkStatus `json:&quot;status&quot;`
}
```
需要注意的是,+genclient只需要写在Network类型上而不用写在NetworkList上。因为NetworkList只是一个返回值类型Network才是“主类型”。
而由于我在Global Tags里已经定义了为所有类型生成DeepCopy方法所以这里就不需要再显式地加上+k8s:deepcopy-gen=true了。当然这也就意味着你可以用+k8s:deepcopy-gen=false来阻止为某些类型生成DeepCopy。
你可能已经注意到,在这两个类型上面还有一句`+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object`的注释。它的意思是请在生成DeepCopy的时候实现Kubernetes提供的runtime.Object接口。否则在某些版本的Kubernetes里你的这个类型定义会出现编译错误。这是一个固定的操作记住即可。
不过,你或许会有这样的顾虑:这些代码生成注释这么灵活,我该怎么掌握呢?
其实上面我所讲述的内容已经足以应对99%的场景了。当然,如果你对代码生成感兴趣的话,我推荐你阅读[这篇博客](https://blog.openshift.com/kubernetes-deep-dive-code-generation-customresources/)它详细地介绍了Kubernetes的代码生成语法。
**最后我需要再编写一个pkg/apis/samplecrd/v1/register.go文件**
在前面对APIServer工作原理的讲解中我已经提到“registry”的作用就是注册一个类型Type给APIServer。其中Network资源类型在服务器端注册的工作APIServer会自动帮我们完成。但与之对应的我们还需要让客户端也能“知道”Network资源类型的定义。这就需要我们在项目里添加一个register.go文件。它最主要的功能就是定义了如下所示的addKnownTypes()方法:
```
package v1
...
// addKnownTypes adds our types to the API scheme by registering
// Network and NetworkList
func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(
SchemeGroupVersion,
&amp;Network{},
&amp;NetworkList{},
)
// register the type in the scheme
metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
return nil
}
```
有了这个方法Kubernetes就能够在后面生成客户端的时候“知道”Network以及NetworkList类型的定义了。
像上面这种**register.go文件里的内容其实是非常固定的你以后可以直接使用我提供的这部分代码做模板然后把其中的资源类型、GroupName和Version替换成你自己的定义即可。**
这样Network对象的定义工作就全部完成了。可以看到它其实定义了两部分内容
- 第一部分是自定义资源类型的API描述包括Group、版本Version、资源类型Resource等。这相当于告诉了计算机兔子是哺乳动物。
- 第二部分是自定义资源类型的对象描述包括Spec、Status等。这相当于告诉了计算机兔子有长耳朵和三瓣嘴。
接下来我就要使用Kubernetes提供的代码生成工具为上面定义的Network资源类型自动生成clientset、informer和lister。其中clientset就是操作Network对象所需要使用的客户端而informer和lister这两个包的主要功能我会在下一篇文章中重点讲解。
这个代码生成工具名叫`k8s.io/code-generator`,使用方法如下所示:
```
# 代码生成的工作目录,也就是我们的项目路径
$ ROOT_PACKAGE=&quot;github.com/resouer/k8s-controller-custom-resource&quot;
# API Group
$ CUSTOM_RESOURCE_NAME=&quot;samplecrd&quot;
# API Version
$ CUSTOM_RESOURCE_VERSION=&quot;v1&quot;
# 安装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 &quot;$ROOT_PACKAGE/pkg/client&quot; &quot;$ROOT_PACKAGE/pkg/apis&quot; &quot;$CUSTOM_RESOURCE_NAME:$CUSTOM_RESOURCE_VERSION&quot;
```
代码生成工作完成之后,我们再查看一下这个项目的目录结构:
```
$ 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
└── client
├── clientset
├── informers
└── listers
```
其中pkg/apis/samplecrd/v1下面的zz_generated.deepcopy.go文件就是自动生成的DeepCopy代码文件。
而整个client目录以及下面的三个包clientset、informers、 listers都是Kubernetes为Network类型生成的客户端库这些库会在后面编写自定义控制器的时候用到。
可以看到,到目前为止的这些工作,其实并不要求你写多少代码,主要考验的是“复制、粘贴、替换”这样的“基本功”。
而有了这些内容现在你就可以在Kubernetes集群里创建一个Network类型的API对象了。我们不妨一起来试验下。
**首先**使用network.yaml文件在Kubernetes中创建Network对象的CRDCustom Resource Definition
```
$ kubectl apply -f crd/network.yaml
customresourcedefinition.apiextensions.k8s.io/networks.samplecrd.k8s.io created
```
这个操作就告诉了Kubernetes我现在要添加一个自定义的API对象。而这个对象的API信息正是network.yaml里定义的内容。我们可以通过kubectl get命令查看这个CRD
```
$ kubectl get crd
NAME CREATED AT
networks.samplecrd.k8s.io 2018-09-15T10:57:12Z
```
**然后**我们就可以创建一个Network对象了这里用到的是example-network.yaml
```
$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network created
```
通过这个操作你就在Kubernetes集群里创建了一个Network对象。它的API资源路径是`samplecrd.k8s.io/v1/networks`
这时候你就可以通过kubectl get命令查看到新创建的Network对象
```
$ kubectl get network
NAME AGE
example-network 8s
```
你还可以通过kubectl describe命令看到这个Network对象的细节
```
$ kubectl describe network example-network
Name: example-network
Namespace: default
Labels: &lt;none&gt;
...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
```
当然 你也可以编写更多的YAML文件来创建更多的Network对象这和创建Pod、Deployment的操作没有任何区别。
## 总结
在今天这篇文章中我为你详细解析了Kubernetes声明式API的工作原理讲解了如何遵循声明式API的设计为Kubernetes添加一个名叫Network的API资源类型。从而达到了通过标准的kubectl create和get操作来管理自定义API对象的目的。
不过创建出这样一个自定义API对象我们只是完成了Kubernetes声明式API的一半工作。
接下来的另一半工作是为这个API对象编写一个自定义控制器Custom Controller。这样 Kubernetes才能根据Network API对象的“增、删、改”操作在真实环境中做出相应的响应。比如“创建、删除、修改”真正的Neutron网络。
而这正是Network这个API对象所关注的“业务逻辑”。
这个业务逻辑的实现过程以及它所使用的Kubernetes API编程库的工作原理就是我要在下一篇文章中讲解的主要内容。
## 思考题
在了解了CRD的定义方法之后你是否已经在考虑使用CRD或者已经使用了CRD来描述现实中的某种实体了呢能否分享一下你的思路举个例子某技术团队使用CRD描述了“宿主机”然后用Kubernetes部署了Kubernetes
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,492 @@
<audio id="audio" title="25 | 深入解析声明式API编写自定义控制器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d6/17/d67edba5c9541e954c18dfbaadbbfc17.mp3"></audio>
你好我是张磊。今天我和你分享的主题是深入解析声明式API之编写自定义控制器。
在上一篇文章中我和你详细分享了Kubernetes中声明式API的实现原理并且通过一个添加Network对象的实例为你讲述了在Kubernetes里添加API资源的过程。
在今天的这篇文章中我就继续和你一起完成剩下一半的工作为Network这个自定义API对象编写一个自定义控制器Custom Controller
正如我在上一篇文章结尾处提到的“声明式API”并不像“命令式API”那样有着明显的执行逻辑。这就使得**基于声明式API的业务功能实现往往需要通过控制器模式来“监视”API对象的变化比如创建或者删除Network然后以此来决定实际要执行的具体工作。**
接下来,我就和你一起通过编写代码来实现这个过程。这个项目和上一篇文章里的代码是同一个项目,你可以从[这个GitHub库](https://github.com/resouer/k8s-controller-custom-resource)里找到它们。我在代码里还加上了丰富的注释,你可以随时参考。
总得来说编写自定义控制器代码的过程包括编写main函数、编写自定义控制器的定义以及编写控制器里的业务逻辑三个部分。
首先我们来编写这个自定义控制器的main函数。
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(&quot;Error running controller: %s&quot;, err.Error())
}
}
```
可以看到这个main函数主要通过三步完成了初始化并启动一个自定义控制器的工作。
**第一步**main函数根据我提供的Master配置APIServer的地址端口和kubeconfig的路径创建一个Kubernetes的clientkubeClient和Network对象的clientnetworkClient
但是如果我没有提供Master配置呢
这时main函数会直接使用一种名叫**InClusterConfig**的方式来创建这个client。这个方式会假设你的自定义控制器是以Pod的方式运行在Kubernetes集群里的。
而我在第15篇文章[《深入解析Pod对象使用进阶》](https://time.geekbang.org/column/article/40466)中曾经提到过Kubernetes 里所有的Pod都会以Volume的方式自动挂载Kubernetes的默认ServiceAccount。所以这个控制器就会直接使用默认ServiceAccount数据卷里的授权信息来访问APIServer。
**第二步**main函数为Network对象创建一个叫作InformerFactorynetworkInformerFactory的工厂并使用它生成一个Network对象的Informer传递给控制器。
**第三步**main函数启动上述的Informer然后执行controller.Run启动自定义控制器。
至此main函数就结束了。
看到这你可能会感到非常困惑编写自定义控制器的过程难道就这么简单吗这个Informer又是个什么东西呢
别着急。
接下来,我就为你**详细解释一下这个自定义控制器的工作原理。**
在Kubernetes项目中一个自定义控制器的工作原理可以用下面这样一幅流程图来表示在后面的叙述中我会用“示意图”来指代它
<img src="https://static001.geekbang.org/resource/image/32/c3/32e545dcd4664a3f36e95af83b571ec3.png" alt="">
我们先从这幅示意图的最左边看起。
**这个控制器要做的第一件事是从Kubernetes的APIServer里获取它所关心的对象也就是我定义的Network对象**
这个操作依靠的是一个叫作Informer可以翻译为通知器的代码库完成的。Informer与API对象是一一对应的所以我传递给自定义控制器的正是一个Network对象的InformerNetwork Informer
不知你是否已经注意到我在创建这个Informer工厂的时候需要给它传递一个networkClient。
事实上Network Informer正是使用这个networkClient跟APIServer建立了连接。不过真正负责维护这个连接的则是Informer所使用的Reflector包。
更具体地说Reflector使用的是一种叫作**ListAndWatch**的方法来“获取”并“监听”这些Network对象实例的变化。
在ListAndWatch机制下一旦APIServer端有新的Network实例被创建、删除或者更新Reflector都会收到“事件通知”。这时该事件及它对应的API对象这个组合就被称为增量Delta它会被放进一个Delta FIFO Queue增量先进先出队列中。
而另一方面Informe会不断地从这个Delta FIFO Queue里读取Pop增量。每拿到一个增量Informer就会判断这个增量里的事件类型然后创建或者更新本地对象的缓存。这个缓存在Kubernetes里一般被叫作Store。
比如如果事件类型是Added添加对象那么Informer就会通过一个叫作Indexer的库把这个增量里的API对象保存在本地缓存中并为它创建索引。相反如果增量的事件类型是Deleted删除对象那么Informer就会从本地缓存中删除这个对象。
这个**同步本地缓存的工作是Informer的第一个职责也是它最重要的职责。**
而**Informer的第二个职责则是根据这些事件的类型触发事先注册好的ResourceEventHandler**。这些Handler需要在创建控制器的时候注册给它对应的Informer。
接下来,我们就来编写这个控制器的定义,它的主要内容如下所示:
```
func NewController(
kubeclientset kubernetes.Interface,
networkclientset clientset.Interface,
networkInformer informers.NetworkInformer) *Controller {
...
controller := &amp;Controller{
kubeclientset: kubeclientset,
networkclientset: networkclientset,
networksLister: networkInformer.Lister(),
networksSynced: networkInformer.Informer().HasSynced,
workqueue: workqueue.NewNamedRateLimitingQueue(..., &quot;Networks&quot;),
...
}
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
}
```
**我前面在main函数里创建了两个clientkubeclientset和networkclientset然后在这段代码里使用这两个client和前面创建的Informer初始化了自定义控制器。**
值得注意的是在这个自定义控制器里我还设置了一个工作队列work queue它正是处于示意图中间位置的WorkQueue。这个工作队列的作用是负责同步Informer和控制循环之间的数据。
>
实际上Kubernetes项目为我们提供了很多个工作队列的实现你可以根据需要选择合适的库直接使用。
**然后我为networkInformer注册了三个HandlerAddFunc、UpdateFunc和DeleteFunc分别对应API对象的“添加”“更新”和“删除”事件。而具体的处理操作都是将该事件对应的API对象加入到工作队列中。**
需要注意的是实际入队的并不是API对象本身而是它们的Key该API对象的`&lt;namespace&gt;/&lt;name&gt;`
而我们后面即将编写的控制循环则会不断地从这个工作队列里拿到这些Key然后开始执行真正的控制逻辑。
综合上面的讲述,你现在应该就能明白,**所谓Informer其实就是一个带有本地缓存和索引机制的、可以注册EventHandler的client**。它是自定义控制器跟APIServer进行数据同步的重要组件。
更具体地说Informer通过一种叫作ListAndWatch的方法把APIServer中的API对象缓存在了本地并负责更新和维护这个缓存。
其中ListAndWatch方法的含义是首先通过APIServer的LIST API“获取”所有最新版本的API对象然后再通过WATCH API来“监听”所有这些API对象的变化。
而通过监听到的事件变化Informer就可以实时地更新本地缓存并且调用这些事件对应的EventHandler了。
此外在这个过程中每经过resyncPeriod指定的时间Informer维护的本地缓存都会使用最近一次LIST返回的结果强制更新一次从而保证缓存的有效性。在Kubernetes中这个缓存强制更新的操作就叫作resync。
需要注意的是这个定时resync操作也会触发Informer注册的“更新”事件。但此时这个“更新”事件对应的Network对象实际上并没有发生变化新、旧两个Network对象的ResourceVersion是一样的。在这种情况下Informer就不需要对这个更新事件再做进一步的处理了。
这也是为什么我在上面的UpdateFunc方法里先判断了一下新、旧两个Network对象的版本ResourceVersion是否发生了变化然后才开始进行的入队操作。
以上就是Kubernetes中的Informer库的工作原理了。
接下来我们就来到了示意图中最后面的控制循环Control Loop部分也正是我在main函数最后调用controller.Run()启动的“控制循环”。它的主要内容如下所示:
```
func (c *Controller) Run(threadiness int, stopCh &lt;-chan struct{}) error {
...
if ok := cache.WaitForCacheSync(stopCh, c.networksSynced); !ok {
return fmt.Errorf(&quot;failed to wait for caches to sync&quot;)
}
...
for i := 0; i &lt; 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(&quot;error syncing '%s': %s&quot;, 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(&quot;Network does not exist in local cache: %s/%s, will delete it from Neutron ...&quot;,
namespace, name)
glog.Warningf(&quot;Network: %s/%s does not exist in local cache, will delete it from Neutron ...&quot;,
namespace, name)
// FIX ME: call Neutron API to delete this network by name.
//
// neutron.Delete(namespace, name)
return nil
}
...
return err
}
glog.Infof(&quot;[Neutron] Try to process network: %#v ...&quot;, 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
}
```
可以看到在这个执行周期里processNextWorkItem我们**首先**从工作队列里出队workqueue.Get了一个成员也就是一个KeyNetwork对象的namespace/name
**然后**在syncHandler方法中我使用这个Key尝试从Informer维护的缓存中拿到了它所对应的Network对象。
可以看到在这里我使用了networksLister来尝试获取这个Key对应的Network对象。这个操作其实就是在访问本地缓存的索引。实际上在Kubernetes的源码中你会经常看到控制器从各种Lister里获取对象比如podLister、nodeLister等等它们使用的都是Informer和缓存机制。
而如果控制循环从缓存中拿不到这个对象networkLister返回了IsNotFound错误那就意味着这个Network对象的Key是通过前面的“删除”事件添加进工作队列的。所以尽管队列里有这个Key但是对应的Network对象已经被删除了。
这时候我就需要调用Neutron的API把这个Key对应的Neutron网络从真实的集群里删除掉。
**而如果能够获取到对应的Network对象我就可以执行控制器模式里的对比“期望状态”和“实际状态”的逻辑了。**
其中自定义控制器“千辛万苦”拿到的这个Network对象**正是APIServer里保存的“期望状态”**用户通过YAML文件提交到APIServer里的信息。当然在我们的例子里它已经被Informer缓存在了本地。
**那么,“实际状态”又从哪里来呢?**
当然是来自于实际的集群了。
所以我们的控制循环需要通过Neutron API来查询实际的网络情况。
比如我可以先通过Neutron来查询这个Network对象对应的真实网络是否存在。
- 如果不存在这就是一个典型的“期望状态”与“实际状态”不一致的情形。这时我就需要使用这个Network对象里的信息比如CIDR和Gateway调用Neutron API来创建真实的网络。
- 如果存在那么我就要读取这个真实网络的信息判断它是否跟Network对象里的信息一致从而决定我是否要通过Neutron来更新这个已经存在的真实网络。
这样我就通过对比“期望状态”和“实际状态”的差异完成了一次调协Reconcile的过程。
至此一个完整的自定义API对象和它所对应的自定义控制器就编写完毕了。
>
备注与Neutron相关的业务代码并不是本篇文章的重点所以我仅仅通过注释里的伪代码为你表述了这部分内容。如果你对这些代码感兴趣的话可以自行完成。最简单的情况你可以自己编写一个Neutron Mock然后输出对应的操作日志。
接下来,我们就一起来把这个项目运行起来,查看一下它的工作情况。
你可以自己编译这个项目也可以直接使用我编译好的二进制文件samplecrd-controller。编译并启动这个项目的具体流程如下所示
```
# Clone repo
$ git clone https://github.com/resouer/k8s-controller-custom-resource$ cd k8s-controller-custom-resource
### Skip this part if you don't want to build
# Install dependency
$ go get github.com/tools/godep
$ godep restore
# Build
$ go build -o samplecrd-controller .
$ ./samplecrd-controller -kubeconfig=$HOME/.kube/config -alsologtostderr=true
I0915 12:50:29.051349 27159 controller.go:84] Setting up event handlers
I0915 12:50:29.051615 27159 controller.go:113] Starting Network control loop
I0915 12:50:29.051630 27159 controller.go:116] Waiting for informer caches to sync
E0915 12:50:29.066745 27159 reflector.go:134] github.com/resouer/k8s-controller-custom-resource/pkg/client/informers/externalversions/factory.go:117: Failed to list *v1.Network: the server could not find the requested resource (get networks.samplecrd.k8s.io)
...
```
你可以看到,自定义控制器被启动后,一开始会报错。
这是因为此时Network对象的CRD还没有被创建出来所以Informer去APIServer里“获取”ListNetwork对象时并不能找到Network这个API资源类型的定义
```
Failed to list *v1.Network: the server could not find the requested resource (get networks.samplecrd.k8s.io)
```
所以接下来我就需要创建Network对象的CRD这个操作在上一篇文章里已经介绍过了。
在另一个shell窗口里执行
```
$ kubectl apply -f crd/network.yaml
```
这时候,你就会看到控制器的日志恢复了正常,控制循环启动成功:
```
...
I0915 12:50:29.051630 27159 controller.go:116] Waiting for informer caches to sync
...
I0915 12:52:54.346854 25245 controller.go:121] Starting workers
I0915 12:52:54.346914 25245 controller.go:127] Started workers
```
接下来我就可以进行Network对象的增删改查操作了。
首先创建一个Network对象
```
$ cat example/example-network.yaml
apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
name: example-network
spec:
cidr: &quot;192.168.0.0/16&quot;
gateway: &quot;192.168.0.1&quot;
$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network created
```
这时候,查看一下控制器的输出:
```
...
I0915 12:50:29.051349 27159 controller.go:84] Setting up event handlers
I0915 12:50:29.051615 27159 controller.go:113] Starting Network control loop
I0915 12:50:29.051630 27159 controller.go:116] Waiting for informer caches to sync
...
I0915 12:52:54.346854 25245 controller.go:121] Starting workers
I0915 12:52:54.346914 25245 controller.go:127] Started workers
I0915 12:53:18.064409 25245 controller.go:229] [Neutron] Try to process network: &amp;v1.Network{TypeMeta:v1.TypeMeta{Kind:&quot;&quot;, APIVersion:&quot;&quot;}, ObjectMeta:v1.ObjectMeta{Name:&quot;example-network&quot;, GenerateName:&quot;&quot;, Namespace:&quot;default&quot;, ... ResourceVersion:&quot;479015&quot;, ... Spec:v1.NetworkSpec{Cidr:&quot;192.168.0.0/16&quot;, Gateway:&quot;192.168.0.1&quot;}} ...
I0915 12:53:18.064650 25245 controller.go:183] Successfully synced 'default/example-network'
...
```
可以看到我们上面创建example-network的操作触发了EventHandler的“添加”事件从而被放进了工作队列。
紧接着控制循环就从队列里拿到了这个对象并且打印出了正在“处理”这个Network对象的日志。
可以看到这个Network的ResourceVersion也就是API对象的版本号是479015而它的Spec字段的内容跟我提交的YAML文件一摸一样比如它的CIDR网段是192.168.0.0/16。
这时候我来修改一下这个YAML文件的内容如下所示
```
$ cat example/example-network.yaml
apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
name: example-network
spec:
cidr: &quot;192.168.1.0/16&quot;
gateway: &quot;192.168.1.1&quot;
```
可以看到我把这个YAML文件里的CIDR和Gateway字段修改成了192.168.1.0/16网段。
然后我们执行了kubectl apply命令来提交这次更新如下所示
```
$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network configured
```
这时候,我们就可以观察一下控制器的输出:
```
...
I0915 12:53:51.126029 25245 controller.go:229] [Neutron] Try to process network: &amp;v1.Network{TypeMeta:v1.TypeMeta{Kind:&quot;&quot;, APIVersion:&quot;&quot;}, ObjectMeta:v1.ObjectMeta{Name:&quot;example-network&quot;, GenerateName:&quot;&quot;, Namespace:&quot;default&quot;, ... ResourceVersion:&quot;479062&quot;, ... Spec:v1.NetworkSpec{Cidr:&quot;192.168.1.0/16&quot;, Gateway:&quot;192.168.1.1&quot;}} ...
I0915 12:53:51.126348 25245 controller.go:183] Successfully synced 'default/example-network'
```
可以看到这一次Informer注册的“更新”事件被触发更新后的Network对象的Key被添加到了工作队列之中。
所以接下来控制循环从工作队列里拿到的Network对象与前一个对象是不同的它的ResourceVersion的值变成了479062而Spec里的字段则变成了192.168.1.0/16网段。
最后,我再把这个对象删除掉:
```
$ kubectl delete -f example/example-network.yaml
```
这一次在控制器的输出里我们就可以看到Informer注册的“删除”事件被触发并且控制循环“调用”Neutron API“删除”了真实环境里的网络。这个输出如下所示
```
W0915 12:54:09.738464 25245 controller.go:212] Network: default/example-network does not exist in local cache, will delete it from Neutron ...
I0915 12:54:09.738832 25245 controller.go:215] [Neutron] Deleting network: default/example-network ...
I0915 12:54:09.738854 25245 controller.go:183] Successfully synced 'default/example-network'
```
以上,就是编写和使用自定义控制器的全部流程了。
实际上这套流程不仅可以用在自定义API资源上也完全可以用在Kubernetes原生的默认API对象上。
比如我们在main函数里除了创建一个Network Informer外还可以初始化一个Kubernetes默认API对象的Informer工厂比如Deployment对象的Informer。这个具体做法如下所示
```
func main() {
...
kubeInformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, time.Second*30)
controller := NewController(kubeClient, exampleClient,
kubeInformerFactory.Apps().V1().Deployments(),
networkInformerFactory.Samplecrd().V1().Networks())
go kubeInformerFactory.Start(stopCh)
...
}
```
在这段代码中,我们**首先**使用Kubernetes的clientkubeClient创建了一个工厂
**然后**我用跟Network类似的处理方法生成了一个Deployment Informer
**接着**我把Deployment Informer传递给了自定义控制器当然我也要调用Start方法来启动这个Deployment Informer。
而有了这个Deployment Informer后这个控制器也就持有了所有Deployment对象的信息。接下来它既可以通过deploymentInformer.Lister()来获取Etcd里的所有Deployment对象也可以为这个Deployment Informer注册具体的Handler来。
更重要的是,**这就使得在这个自定义控制器里面我可以通过对自定义API对象和默认API对象进行协同从而实现更加复杂的编排功能**。
比如用户每创建一个新的Deployment这个自定义控制器就可以为它创建一个对应的Network供它使用。
这些对Kubernetes API编程范式的更高级应用我就留给你在实际的场景中去探索和实践了。
## 总结
在今天这篇文章中我为你剖析了Kubernetes API编程范式的具体原理并编写了一个自定义控制器。
这其中,有如下几个概念和机制,是你一定要理解清楚的:
所谓的Informer就是一个自带缓存和索引机制可以触发Handler的客户端库。这个本地缓存在Kubernetes中一般被称为Store索引一般被称为Index。
Informer使用了Reflector包它是一个可以通过ListAndWatch机制获取并监视API对象变化的客户端封装。
Reflector和Informer之间用到了一个“增量先进先出队列”进行协同。而Informer与你要编写的控制循环之间则使用了一个工作队列来进行协同。
在实际应用中除了控制循环之外的所有代码实际上都是Kubernetes为你自动生成的pkg/client/{informers, listers, clientset}里的内容。
而这些自动生成的代码就为我们提供了一个可靠而高效地获取API对象“期望状态”的编程库。
所以,接下来,作为开发者,你就只需要关注如何拿到“实际状态”,然后如何拿它去跟“期望状态”做对比,从而决定接下来要做的业务逻辑即可。
以上内容就是Kubernetes API编程范式的核心思想。
## 思考题
请思考一下为什么Informer和你编写的控制循环之间一定要使用一个工作队列来进行协作呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,424 @@
<audio id="audio" title="26 | 基于角色的权限控制RBAC" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/db/d88c2fed2e06621a4af161aa391a54db.mp3"></audio>
你好我是张磊。今天我和你分享的主题是基于角色的权限控制之RBAC。
在前面的文章中我已经为你讲解了很多种Kubernetes内置的编排对象以及对应的控制器模式的实现原理。此外我还剖析了自定义API资源类型和控制器的编写方式。
这时候,你可能已经冒出了这样一个想法:控制器模式看起来好像也不难嘛,我能不能自己写一个编排对象呢?
答案当然是可以的。而且这才是Kubernetes项目最具吸引力的地方。
毕竟在互联网级别的大规模集群里Kubernetes内置的编排对象很难做到完全满足所有需求。所以很多实际的容器化工作都会要求你设计一个自己的编排对象实现自己的控制器模式。
而在Kubernetes项目里我们可以基于插件机制来完成这些工作而完全不需要修改任何一行代码。
不过你要通过一个外部插件在Kubernetes里新增和操作API对象那么就必须先了解一个非常重要的知识RBAC。
我们知道Kubernetes中所有的API对象都保存在Etcd里。可是对这些API对象的操作却一定都是通过访问kube-apiserver实现的。其中一个非常重要的原因就是你需要APIServer来帮助你做授权工作。
而**在Kubernetes项目中负责完成授权Authorization工作的机制就是RBAC**基于角色的访问控制Role-Based Access Control
如果你直接查看Kubernetes项目中关于RBAC的文档的话可能会感觉非常复杂。但实际上等到你用到这些RBAC的细节时再去查阅也不迟。
而在这里,我只希望你能明确三个最基本的概念。
<li>
Role角色它其实是一组规则定义了一组对Kubernetes API对象的操作权限。
</li>
<li>
Subject被作用者既可以是“人”也可以是“机器”也可以是你在Kubernetes里定义的“用户”。
</li>
<li>
RoleBinding定义了“被作用者”和“角色”的绑定关系。
</li>
而这三个概念其实就是整个RBAC体系的核心所在。
我先来讲解一下Role。
实际上Role本身就是一个Kubernetes的API对象定义如下所示
```
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
namespace: mynamespace
name: example-role
rules:
- apiGroups: [&quot;&quot;]
resources: [&quot;pods&quot;]
verbs: [&quot;get&quot;, &quot;watch&quot;, &quot;list&quot;]
```
首先这个Role对象指定了它能产生作用的Namepace是mynamespace。
Namespace是Kubernetes项目里的一个逻辑管理单位。不同Namespace的API对象在通过kubectl命令进行操作的时候是互相隔离开的。
比如kubectl get pods -n mynamespace。
当然这仅限于逻辑上的“隔离”Namespace并不会提供任何实际的隔离或者多租户能力。而在前面文章中用到的大多数例子里我都没有指定Namespace那就是使用的是默认Namespacedefault。
然后这个Role对象的rules字段就是它所定义的权限规则。在上面的例子里这条规则的含义就是允许“被作用者”对mynamespace下面的Pod对象进行GET、WATCH和LIST操作。
那么这个具体的“被作用者”又是如何指定的呢这就需要通过RoleBinding来实现了。
当然RoleBinding本身也是一个Kubernetes的API对象。它的定义如下所示
```
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: example-rolebinding
namespace: mynamespace
subjects:
- kind: User
name: example-user
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: example-role
apiGroup: rbac.authorization.k8s.io
```
可以看到这个RoleBinding对象里定义了一个subjects字段即“被作用者”。它的类型是User即Kubernetes里的用户。这个用户的名字是example-user。
可是在Kubernetes中其实并没有一个叫作“User”的API对象。而且我们在前面和部署使用Kubernetes的流程里既不需要User也没有创建过User。
**这个User到底是从哪里来的呢**
实际上Kubernetes里的“User”也就是“用户”只是一个授权系统里的逻辑概念。它需要通过外部认证服务比如Keystone来提供。或者你也可以直接给APIServer指定一个用户名、密码文件。那么Kubernetes的授权系统就能够从这个文件里找到对应的“用户”了。当然在大多数私有的使用环境中我们只要使用Kubernetes提供的内置“用户”就足够了。这部分知识我后面马上会讲到。
接下来我们会看到一个roleRef字段。正是通过这个字段RoleBinding对象就可以直接通过名字来引用我们前面定义的Role对象example-role从而定义了“被作用者Subject”和“角色Role”之间的绑定关系。
需要再次提醒的是Role和RoleBinding对象都是Namespaced对象Namespaced Object它们对权限的限制规则仅在它们自己的Namespace内有效roleRef也只能引用当前Namespace里的Role对象。
那么,**对于非NamespacedNon-namespaced对象比如Node或者某一个Role想要作用于所有的Namespace的时候我们又该如何去做授权呢**
这时候我们就必须要使用ClusterRole和ClusterRoleBinding这两个组合了。这两个API对象的用法跟Role和RoleBinding完全一样。只不过它们的定义里没有了Namespace字段如下所示
```
kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: example-clusterrole
rules:
- apiGroups: [&quot;&quot;]
resources: [&quot;pods&quot;]
verbs: [&quot;get&quot;, &quot;watch&quot;, &quot;list&quot;]
```
```
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
```
上面的例子里的ClusterRole和ClusterRoleBinding的组合意味着名叫example-user的用户拥有对所有Namespace里的Pod进行GET、WATCH和LIST操作的权限。
更进一步地在Role或者ClusterRole里面如果要赋予用户example-user所有权限那你就可以给它指定一个verbs字段的全集如下所示
```
verbs: [&quot;get&quot;, &quot;list&quot;, &quot;watch&quot;, &quot;create&quot;, &quot;update&quot;, &quot;patch&quot;, &quot;delete&quot;]
```
这些就是当前Kubernetesv1.11里能够对API对象进行的所有操作了。
类似地Role对象的rules字段也可以进一步细化。比如你可以只针对某一个具体的对象进行权限设置如下所示
```
rules:
- apiGroups: [&quot;&quot;]
resources: [&quot;configmaps&quot;]
resourceNames: [&quot;my-config&quot;]
verbs: [&quot;get&quot;]
```
这个例子就表示这条规则的“被作用者”只对名叫“my-config”的ConfigMap对象有进行GET操作的权限。
而正如我前面介绍过的在大多数时候我们其实都不太使用“用户”这个功能而是直接使用Kubernetes里的“内置用户”。
这个由Kubernetes负责管理的“内置用户”正是我们前面曾经提到过的ServiceAccount。
接下来我通过一个具体的实例来为你讲解一下为ServiceAccount分配权限的过程。
**首先我们要定义一个ServiceAccount**。它的API对象非常简单如下所示
```
apiVersion: v1
kind: ServiceAccount
metadata:
namespace: mynamespace
name: example-sa
```
可以看到一个最简单的ServiceAccount对象只需要Name和Namespace这两个最基本的字段。
**然后我们通过编写RoleBinding的YAML文件来为这个ServiceAccount分配权限**
```
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: example-rolebinding
namespace: mynamespace
subjects:
- kind: ServiceAccount
name: example-sa
namespace: mynamespace
roleRef:
kind: Role
name: example-role
apiGroup: rbac.authorization.k8s.io
```
可以看到在这个RoleBinding对象里subjects字段的类型kind不再是一个User而是一个名叫example-sa的ServiceAccount。而roleRef引用的Role对象依然名叫example-role也就是我在这篇文章一开始定义的Role对象。
**接着我们用kubectl命令创建这三个对象**
```
$ 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: &quot;409327&quot;
...
secrets:
- name: example-sa-token-vmfg6
```
可以看到Kubernetes会为一个ServiceAccount自动创建并分配一个Secret对象上述ServiceAcount定义里最下面的secrets字段。
这个Secret就是这个ServiceAccount对应的、用来跟APIServer进行交互的授权文件我们一般称它为Token。Token文件的内容一般是证书或者密码它以一个Secret对象的方式保存在Etcd当中。
这时候用户的Pod就可以声明使用这个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要使用的要使用的ServiceAccount的名字是example-sa。
等这个Pod运行起来之后我们就可以看到该ServiceAccount的token也就是一个Secret对象被Kubernetes自动挂载到了容器的/var/run/secrets/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操作。因为example-sa这个ServiceAccount的权限已经被我们绑定了Role做了限制。
此外我在第15篇文章[《深入解析Pod对象使用进阶》](https://time.geekbang.org/column/article/40466)中曾经提到过如果一个Pod没有声明serviceAccountNameKubernetes会自动在它的Namespace下创建一个名叫default的默认ServiceAccount然后分配给这个Pod。
但在这种情况下这个默认ServiceAccount并没有关联任何Role。也就是说此时它有访问APIServer的绝大多数权限。当然这个访问所需要的Token还是默认ServiceAccount对应的Secret对象为它提供的如下所示。
```
$kubectl describe sa default
Name: default
Namespace: default
Labels: &lt;none&gt;
Annotations: &lt;none&gt;
Image pull secrets: &lt;none&gt;
Mountable secrets: default-token-s8rbq
Tokens: default-token-s8rbq
Events: &lt;none&gt;
$ 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: &lt;none&gt;
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: &lt;TOKEN数据&gt;
```
可以看到Kubernetes会自动为默认ServiceAccount创建并绑定一个特殊的Secret它的类型是`kubernetes.io/service-account-token`它的Annotation字段声明了`kubernetes.io/service-account.name=default`即这个Secret会跟同一Namespace下名叫default的ServiceAccount进行绑定。
所以在生产环境中我强烈建议你为所有Namespace下的默认ServiceAccount绑定一个只读权限的Role。这个具体怎么做就当作思考题留给你了。
除了前面使用的“用户”UserKubernetes还拥有“用户组”Group的概念也就是一组“用户”的意思。如果你为Kubernetes配置了外部认证服务的话这个“用户组”的概念就会由外部认证服务提供。
而对于Kubernetes的内置“用户”ServiceAccount来说上述“用户组”的概念也同样适用。
实际上一个ServiceAccount在Kubernetes里对应的“用户”的名字是
```
system:serviceaccount:&lt;Namespace名字&gt;:&lt;ServiceAccount名字&gt;
```
而它对应的内置“用户组”的名字,就是:
```
system:serviceaccounts:&lt;Namespace名字&gt;
```
**这两个对应关系,请你一定要牢记。**
比如现在我们可以在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查看到它们。
一般来说这些系统ClusterRole是绑定给Kubernetes系统组件对应的ServiceAccount使用的。
比如其中一个名叫system:kube-scheduler的ClusterRole定义的权限规则是kube-schedulerKubernetes的调度器组件运行所需要的必要权限。你可以通过如下指令查看这些权限的列表
```
$ 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]
```
这个system:kube-scheduler的ClusterRole就会被绑定给kube-system Namesapce下名叫kube-scheduler的ServiceAccount它正是Kubernetes调度器的Pod声明使用的ServiceAccount。
除此之外Kubernetes还提供了四个预先定义好的ClusterRole来供用户直接使用
<li>
cluster-admin
</li>
<li>
admin
</li>
<li>
edit
</li>
<li>
view。
</li>
通过它们的名字你应该能大致猜出它们都定义了哪些权限。比如这个名叫view的ClusterRole就规定了被作用者只有Kubernetes API的只读权限。
而我还要提醒你的是上面这个cluster-admin角色对应的是整个Kubernetes项目中的最高权限verbs=*),如下所示:
```
$ 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
--------- ----------------- -------------- -----
*.* [] [] [*]
[*] [] [*]
```
所以请你务必要谨慎而小心地使用cluster-admin。
## 总结
在今天这篇文章中我主要为你讲解了基于角色的访问控制RBAC
其实你现在已经能够理解所谓角色Role其实就是一组权限规则列表。而我们分配这些权限的方式就是通过创建RoleBinding对象将被作用者subject和权限列表进行绑定。
另外与之对应的ClusterRole和ClusterRoleBinding则是Kubernetes集群级别的Role和RoleBinding它们的作用范围不受Namespace限制。
而尽管权限的被作用者可以有很多种比如User、Group等但在我们平常的使用中最普遍的用法还是ServiceAccount。所以Role + RoleBinding + ServiceAccount的权限分配方式是你要重点掌握的内容。我们在后面编写和安装各种插件的时候会经常用到这个组合。
## 思考题
请问如何为所有Namespace下的默认ServiceAccountdefault ServiceAccount绑定一个只读权限的Role呢请你提供ClusterRoleBinding或者RoleBinding的YAML文件。
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,585 @@
<audio id="audio" title="27 | 聪明的微创新Operator工作原理解读" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/a1/4d5b3efdb7687190bd4fefad136eb2a1.mp3"></audio>
你好我是张磊。今天我和你分享的主题是聪明的微创新之Operator工作原理解读。
在前面的几篇文章中我已经和你分享了Kubernetes项目中的大部分编排对象比如Deployment、StatefulSet、DaemonSet以及Job也介绍了“有状态应用”的管理方法还阐述了为Kubernetes添加自定义API对象和编写自定义控制器的原理和流程。
可能你已经感觉到在Kubernetes中管理“有状态应用”是一个比较复杂的过程尤其是编写Pod模板的时候总有一种“在YAML文件里编程序”的感觉让人很不舒服。
而在Kubernetes生态中还有一个相对更加灵活和编程友好的管理“有状态应用”的解决方案它就是Operator。
接下来我就以Etcd Operator为例来为你讲解一下Operator的工作原理和编写方法。
Etcd Operator的使用方法非常简单只需要两步即可完成
**第一步将这个Operator的代码Clone到本地**
```
$ git clone https://github.com/coreos/etcd-operator
```
**第二步将这个Etcd Operator部署在Kubernetes集群里。**
不过在部署Etcd Operator的Pod之前你需要先执行这样一个脚本
```
$ example/rbac/create_role.sh
```
不用我多说你也能够明白这个脚本的作用就是为Etcd Operator创建RBAC规则。这是因为Etcd Operator需要访问Kubernetes的APIServer来创建对象。
更具体地说上述脚本为Etcd Operator定义了如下所示的权限
<li>
对Pod、Service、PVC、Deployment、Secret等API对象有所有权限
</li>
<li>
对CRD对象有所有权限
</li>
<li>
对属于etcd.database.coreos.com这个API Group的CRCustom Resource对象有所有权限。
</li>
而Etcd Operator本身其实就是一个Deployment它的YAML文件如下所示
```
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
...
```
所以我们就可以使用上述的YAML文件来创建Etcd Operator如下所示
```
$ kubectl create -f example/deployment.yaml
```
而一旦Etcd Operator的Pod进入了Running状态你就会发现有一个CRD被自动创建了出来如下所示
```
$ 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
```
这个CRD名叫`etcdclusters.etcd.database.coreos.com` 。你可以通过kubectl describe命令看到它的细节如下所示
```
$ 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文件被提交上来你可一定要认识啊。
所以说通过上述两步操作你实际上是在Kubernetes里添加了一个名叫EtcdCluster的自定义资源类型。而Etcd Operator本身就是这个自定义资源类型对应的自定义控制器。
而当Etcd Operator部署好之后接下来在这个Kubernetes里创建一个Etcd集群的工作就非常简单了。你只需要编写一个EtcdCluster的YAML文件然后把它提交给Kubernetes即可如下所示
```
$ kubectl apply -f example/example-etcd-cluster.yaml
```
这个example-etcd-cluster.yaml文件里描述的是一个3个节点的Etcd集群。我们可以看到它被提交给Kubernetes之后就会有三个Etcd的Pod运行起来如下所示
```
$ 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
```
那么究竟发生了什么让创建一个Etcd集群的工作如此简单呢
我们当然还是得从这个example-etcd-cluster.yaml文件开始说起。
不难想到这个文件里定义的正是EtcdCluster这个CRD的一个具体实例也就是一个Custom ResourceCR。而它的内容非常简单如下所示
```
apiVersion: &quot;etcd.database.coreos.com/v1beta2&quot;
kind: &quot;EtcdCluster&quot;
metadata:
name: &quot;example-etcd-cluster&quot;
spec:
size: 3
version: &quot;3.2.13&quot;
```
可以看到EtcdCluster的spec字段非常简单。其中size=3指定了它所描述的Etcd集群的节点个数。而version=“3.2.13”则指定了Etcd的版本仅此而已。
而真正把这样一个Etcd集群创建出来的逻辑就是Etcd Operator要实现的主要工作了。
看到这里相信你应该已经对Operator有了一个初步的认知
**Operator的工作原理实际上是利用了Kubernetes的自定义API资源CRD来描述我们想要部署的“有状态应用”然后在自定义控制器里根据自定义API对象的变化来完成具体的部署和运维工作。**
所以编写一个Etcd Operator与我们前面编写一个自定义控制器的过程没什么不同。
不过考虑到你可能还不太清楚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进程组成了一个三节点的Etcd集群。
其中这些节点启动参数里的initial-cluster参数非常值得你关注。它的含义正是**当前节点启动时集群的拓扑结构。<strong>说得更详细一点,就是**当前这个节点启动时,需要跟哪些节点通信来组成集群</strong>
举个例子我们可以看一下上述infra2节点的initial-cluster的值如下所示
```
...
--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参数是由“&lt;节点名字&gt;=&lt;节点地址&gt;”格式组成的一个数组。而上面这个配置的意思就是当infra2节点启动之后这个Etcd集群里就会有infra0、infra1和infra2三个节点。
同时这些Etcd节点需要通过2380端口进行通信以便组成集群这也正是上述配置中listen-peer-urls字段的含义。
此外一个Etcd集群还需要用initial-cluster-token字段来声明一个该集群独一无二的Token名字。
像上述这样为每一个Ectd节点配置好它对应的启动参数之后把它们启动起来一个Etcd集群就可以自动组建起来了。
而我们要编写的Etcd Operator就是要把上述过程自动化。这其实等同于用代码来生成每个Etcd节点Pod的启动命令然后把它们启动起来。
接下来,我们一起来实践一下这个流程。
当然在编写自定义控制器之前我们首先需要完成EtcdCluster这个CRD的定义它对应的types.go文件的主要内容如下所示
```
// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type EtcdCluster struct {
metav1.TypeMeta `json:&quot;,inline&quot;`
metav1.ObjectMeta `json:&quot;metadata,omitempty&quot;`
Spec ClusterSpec `json:&quot;spec&quot;`
Status ClusterStatus `json:&quot;status&quot;`
}
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:&quot;size&quot;`
...
}
```
可以看到EtcdCluster是一个有Status字段的CRD。在这里我们可以不必关心ClusterSpec里的其他字段只关注SizeEtcd集群的大小字段即可。
Size字段的存在就意味着将来如果我们想要调整集群大小的话应该直接修改YAML文件里size的值并执行kubectl apply -f。
这样Operator就会帮我们完成Etcd节点的增删操作。这种“scale”能力也是Etcd Operator自动化运维Etcd集群需要实现的主要功能。
而为了能够支持这个功能我们就不再像前面那样在initial-cluster参数里把拓扑结构固定死。
所以Etcd Operator的实现虽然选择的也是静态集群但这个集群具体的组建过程是逐个节点动态添加的方式
**首先Etcd Operator会创建一个“种子节点”**<br />
**然后Etcd Operator会不断创建新的Etcd节点然后将它们逐一加入到这个集群当中直到集群的节点数等于size。**
这就意味着在生成不同角色的Etcd Pod时Operator需要能够区分种子节点与普通节点。
而这两种节点的不同之处就在于一个名叫initial-cluster-state的启动参数
- 当这个参数值设为new时就代表了该节点是种子节点。而我们前面提到过种子节点还必须通过initial-cluster-token声明一个独一无二的Token。
- 而如果这个参数值设为existing那就是说明这个节点是一个普通节点Etcd Operator需要把它加入到已有集群里。
那么接下来的问题就是每个Etcd节点的initial-cluster字段的值又是怎么生成的呢
由于这个方案要求种子节点先启动所以对于种子节点infra0来说它启动后的集群只有它自己initial-cluster=infra0=http://10.0.1.10:2380。
而对于接下来要加入的节点比如infra1来说它启动后的集群就有两个节点了所以它的initial-cluster参数的值应该是infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380。
其他节点,都以此类推。
现在你就应该能在脑海中构思出上述三节点Etcd集群的部署过程了。
首先只要用户提交YAML文件时声明创建一个EtcdCluster对象一个Etcd集群那么Etcd Operator都应该先创建一个单节点的种子集群Seed Member并启动这个种子节点。
以infra0节点为例它的IP地址是10.0.1.10那么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
--initial-cluster-token=4b5215fa-5401-4a95-a8c6-892317c9bef8
```
可以看到这个种子节点的initial-cluster-state是new并且指定了唯一的initial-cluster-token参数。
我们可以把这个创建种子节点(集群)的阶段称为:**Bootstrap**。
接下来,**对于其他每一个节点Operator只需要执行如下两个操作即可**以infra1为例。
第一步通过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
```
可以看到对于这个infra1成员节点来说它的initial-cluster-state是existing也就是要加入已有集群。而它的initial-cluster的值则变成了infra0和infra1两个节点的IP地址。
所以以此类推不断地将infra2等后续成员添加到集群中直到整个集群的节点数目等于用户指定的size之后部署就完成了。
在熟悉了这个部署思路之后我再为你讲解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, &amp;api.EtcdCluster{}, 0, cache.ResourceEventHandlerFuncs{
AddFunc: c.onAddEtcdClus,
UpdateFunc: c.onUpdateEtcdClus,
DeleteFunc: c.onDeleteEtcdClus,
}, cache.Indexers{})
ctx := context.TODO()
// TODO: use workqueue to avoid blocking
informer.Run(ctx.Done())
}
```
可以看到,**Etcd Operator启动要做的第一件事** c.initResource是创建EtcdCluster对象所需要的CRD前面提到的`etcdclusters.etcd.database.coreos.com`。这样Kubernetes就能够“认识”EtcdCluster这个自定义API资源了。
而**接下来Etcd Operator会定义一个EtcdCluster对象的Informer**。
不过需要注意的是由于Etcd Operator的完成时间相对较早所以它里面有些代码的编写方式会跟我们之前讲解的最新的编写方式不太一样。在具体实践的时候你还是应该以我讲解的模板为主。
比如,在上面的代码最后,你会看到有这样一句注释:
```
// TODO: use workqueue to avoid blocking
...
```
也就是说Etcd Operator并没有用工作队列来协调Informer和控制循环。这其实正是我在第25篇文章[《深入解析声明式API编写自定义控制器》](https://time.geekbang.org/column/article/42076)中,给你留的关于工作队列的思考题的答案。
具体来讲我们在控制循环里执行的业务逻辑往往是比较耗时间的。比如创建一个真实的Etcd集群。而Informer的WATCH机制对API对象变化的响应则非常迅速。所以控制器里的业务逻辑就很可能会拖慢Informer的执行周期甚至可能Block它。而要协调这样两个快、慢任务的一个典型解决方法就是引入一个工作队列。
>
备注如果你感兴趣的话可以给Etcd Operator提一个patch来修复这个问题。提PR修TODO是给一个开源项目做有意义的贡献的一个重要方式。
由于Etcd Operator里没有工作队列那么在它的EventHandler部分就不会有什么入队操作而直接就是每种事件对应的具体的业务逻辑了。
不过Etcd Operator在业务逻辑的实现方式上与常规的自定义控制器略有不同。我把在这一部分的工作原理提炼成了一个详细的流程图如下所示
<img src="https://static001.geekbang.org/resource/image/e7/36/e7f2905ae46e0ccd24db47c915382536.jpg" alt="" />
可以看到Etcd Operator的特殊之处在于它为每一个EtcdCluster对象都启动了一个控制循环“并发”地响应这些对象的变化。显然这种做法不仅可以简化Etcd Operator的代码实现还有助于提高它的响应速度。
以文章一开始的example-etcd-cluster的YAML文件为例。
当这个YAML文件第一次被提交到Kubernetes之后Etcd Operator的Informer就会立刻“感知”到一个新的EtcdCluster对象被创建了出来。所以EventHandler里的“添加”事件会被触发。
而这个Handler要做的操作也很简单在Etcd Operator内部创建一个对应的Cluster对象cluster.New比如流程图里的Cluster1。
这个Cluster对象就是一个Etcd集群在Operator内部的描述所以它与真实的Etcd集群的生命周期是一致的。
而一个Cluster对象需要具体负责的其实有两个工作。
**其中第一个工作只在该Cluster对象第一次被创建的时候才会执行。这个工作就是我们前面提到过的Bootstrap创建一个单节点的种子集群。**
由于种子集群只有一个节点所以这一步直接就会生成一个Etcd的Pod对象。这个Pod里有一个InitContainer负责检查Pod的DNS记录是否正常。如果检查通过用户容器也就是Etcd容器就会启动起来。
而这个Etcd容器最重要的部分当然就是它的启动命令了。
以我们在文章一开始部署的集群为例,它的种子节点的容器启动命令如下所示:
```
/usr/local/bin/etcd
--data-dir=/var/etcd/data
--name=example-etcd-cluster-mbzlg6sd56
--initial-advertise-peer-urls=http://example-etcd-cluster-mbzlg6sd56.example-etcd-cluster.default.svc:2380
--listen-peer-urls=http://0.0.0.0:2380
--listen-client-urls=http://0.0.0.0:2379
--advertise-client-urls=http://example-etcd-cluster-mbzlg6sd56.example-etcd-cluster.default.svc:2379
--initial-cluster=example-etcd-cluster-mbzlg6sd56=http://example-etcd-cluster-mbzlg6sd56.example-etcd-cluster.default.svc:2380
--initial-cluster-state=new
--initial-cluster-token=4b5215fa-5401-4a95-a8c6-892317c9bef8
```
上述启动命令里的各个参数的含义,我已经在前面介绍过。
可以看到在这些启动参数比如initial-clusterEtcd Operator只会使用Pod的DNS记录而不是它的IP地址。
这当然是因为在Operator生成上述启动命令的时候Etcd的Pod还没有被创建出来它的IP地址自然也无从谈起。
这也就意味着每个Cluster对象都会事先创建一个与该EtcdCluster同名的Headless Service。这样Etcd Operator在接下来的所有创建Pod的步骤里就都可以使用Pod的DNS记录来代替它的IP地址了。
>
备注Headless Service的DNS记录格式是<pod-name>.<svc-name>.<namespace>.svc.cluster.local。如果你记不太清楚了可以借此再回顾一下第18篇文章[《深入理解StatefulSet拓扑状态》](https://time.geekbang.org/column/article/41017)中的相关内容。
**Cluster对象的第二个工作则是启动该集群所对应的控制循环。**
这个控制循环每隔一定时间就会执行一次下面的Diff流程。
首先控制循环要获取到所有正在运行的、属于这个Cluster的Pod数量也就是该Etcd集群的“实际状态”。
而这个Etcd集群的“期望状态”正是用户在EtcdCluster对象里定义的size。
所以接下来,控制循环会对比这两个状态的差异。
如果实际的Pod数量不够那么控制循环就会执行一个添加成员节点的操作上述流程图中的addOneMember方法反之就执行删除成员节点的操作上述流程图中的removeOneMember方法
以addOneMember方法为例它执行的流程如下所示
<li>
生成一个新节点的Pod的名字比如example-etcd-cluster-v6v6s6stxd
</li>
<li>
调用Etcd Client执行前面提到过的etcdctl member add example-etcd-cluster-v6v6s6stxd命令
</li>
<li>
使用这个Pod名字和已经存在的所有节点列表组合成一个新的initial-cluster字段的值
</li>
<li>
使用这个initial-cluster的值生成这个Pod里Etcd容器的启动命令。如下所示
</li>
```
/usr/local/bin/etcd
--data-dir=/var/etcd/data
--name=example-etcd-cluster-v6v6s6stxd
--initial-advertise-peer-urls=http://example-etcd-cluster-v6v6s6stxd.example-etcd-cluster.default.svc:2380
--listen-peer-urls=http://0.0.0.0:2380
--listen-client-urls=http://0.0.0.0:2379
--advertise-client-urls=http://example-etcd-cluster-v6v6s6stxd.example-etcd-cluster.default.svc:2379
--initial-cluster=example-etcd-cluster-mbzlg6sd56=http://example-etcd-cluster-mbzlg6sd56.example-etcd-cluster.default.svc:2380,example-etcd-cluster-v6v6s6stxd=http://example-etcd-cluster-v6v6s6stxd.example-etcd-cluster.default.svc:2380
--initial-cluster-state=existing
```
这样当这个容器启动之后一个新的Etcd成员节点就被加入到了集群当中。控制循环会重复这个过程直到正在运行的Pod数量与EtcdCluster指定的size一致。
在有了这样一个与EtcdCluster对象一一对应的控制循环之后你后续对这个EtcdCluster的任何修改比如修改size或者Etcd的version它们对应的更新事件都会由这个Cluster对象的控制循环进行处理。
以上就是一个Etcd Operator的工作原理了。
如果对比一下Etcd Operator与我在第20篇文章[《深入理解StatefulSet有状态应用实践》](https://time.geekbang.org/column/article/41217)中讲解过的MySQL StatefulSet的话你可能会有两个问题。
**第一个问题是**在StatefulSet里它为Pod创建的名字是带编号的这样就把整个集群的拓扑状态固定了下来比如一个三节点的集群一定是由名叫web-0、web-1和web-2的三个Pod组成。可是**在Etcd Operator里为什么我们使用随机名字就可以了呢**
这是因为Etcd Operator在每次添加Etcd节点的时候都会先执行etcdctl member add &lt;Pod名字&gt;每次删除节点的时候则会执行etcdctl member remove &lt;Pod名字&gt;。这些操作其实就会更新Etcd内部维护的拓扑信息所以Etcd Operator无需在集群外部通过编号来固定这个拓扑关系。
**第二个问题是为什么我没有在EtcdCluster对象里声明Persistent Volume**
难道我们不担心节点宕机之后Etcd的数据会丢失吗
我们知道Etcd是一个基于Raft协议实现的高可用Key-Value存储。根据Raft协议的设计原则当Etcd集群里只有半数以下在我们的例子里小于等于一个的节点失效时当前集群依然可以正常工作。此时Etcd Operator只需要通过控制循环创建出新的Pod然后将它们加入到现有集群里就完成了“期望状态”与“实际状态”的调谐工作。这个集群是一直可用的 。
>
备注关于Etcd的工作原理和Raft协议的设计思想你可以阅读[这篇文章](http://www.infoq.com/cn/articles/etcd-interpretation-application-scenario-implement-principle)来进行学习。
但是当这个Etcd集群里有半数以上在我们的例子里大于等于两个的节点失效的时候这个集群就会丧失数据写入的能力从而进入“不可用”状态。此时即使Etcd Operator创建出新的Pod出来Etcd集群本身也无法自动恢复起来。
这个时候我们就必须使用Etcd本身的备份数据来对集群进行恢复操作。
在有了Operator机制之后上述Etcd的备份操作是由一个单独的Etcd Backup Operator负责完成的。
创建和使用这个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 = &lt;region&gt;
# 然后将上述授权信息制作成一个Secret
$ kubectl create secret generic aws --from-file=$AWS_DIR/credentials --from-file=$AWS_DIR/config
# 使用上述S3的访问信息创建一个EtcdBackup对象
$ sed -e 's|&lt;full-s3-path&gt;|mybucket/etcd.backup|g' \
-e 's|&lt;aws-secret&gt;|aws|g' \
-e 's|&lt;etcd-cluster-endpoints&gt;|&quot;http://example-etcd-cluster-client:2379&quot;|g' \
example/etcd-backup-operator/backup_cr.yaml \
| kubectl create -f -
```
需要注意的是每当你创建一个EtcdBackup对象[backup_cr.yaml](https://github.com/coreos/etcd-operator/blob/master/example/etcd-backup-operator/backup_cr.yaml)就相当于为它所指定的Etcd集群做了一次备份。EtcdBackup对象的etcdEndpoints字段会指定它要备份的Etcd集群的访问地址。
所以在实际的环境里我建议你把最后这个备份操作编写成一个Kubernetes的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|&lt;full-s3-path&gt;|mybucket/etcd.backup|g' \
-e 's|&lt;aws-secret&gt;|aws|g' \
example/etcd-restore-operator/restore_cr.yaml \
| kubectl create -f -
```
上面例子里的EtcdRestore对象[restore_cr.yaml](https://github.com/coreos/etcd-operator/blob/master/example/etcd-restore-operator/restore_cr.yaml)会指定它要恢复的Etcd集群的名字和备份数据所在的S3存储的访问信息。
而当一个EtcdRestore对象成功创建后Etcd Restore Operator就会通过上述信息恢复出一个全新的Etcd集群。然后Etcd Operator会把这个新集群直接接管过来从而重新进入可用的状态。
EtcdBackup和EtcdRestore这两个Operator的工作原理与Etcd Operator的实现方式非常类似。所以这一部分就交给你课后去探索了。
## 总结
在今天这篇文章中我以Etcd Operator为例详细介绍了一个Operator的工作原理和编写过程。
可以看到Etcd集群本身就拥有良好的分布式设计和一定的高可用能力。在这种情况下StatefulSet“为Pod编号”和“将Pod同PV绑定”这两个主要的特性就不太有用武之地了。
而相比之下Etcd Operator把一个Etcd集群抽象成了一个具有一定“自治能力”的整体。而当这个“自治能力”本身不足以解决问题的时候我们可以通过两个专门负责备份和恢复的Operator进行修正。这种实现方式不仅更加贴近Etcd的设计思想也更加编程友好。
不过如果我现在要部署的应用既需要用StatefulSet的方式维持拓扑状态和存储状态又有大量的编程工作要做那我到底该如何选择呢
其实Operator和StatefulSet并不是竞争关系。你完全可以编写一个Operator然后在Operator的控制循环里创建和控制StatefulSet而不是Pod。比如业界知名的[Prometheus项目的Operator](https://github.com/coreos/prometheus-operator),正是这么实现的。
此外CoreOS公司在被RedHat公司收购之后已经把Operator的编写过程封装成了一个叫作[Operator SDK](https://github.com/operator-framework/operator-sdk)的工具整个项目叫作Operator Framework它可以帮助你生成Operator的框架代码。感兴趣的话你可以试用一下。
## 思考题
在Operator的实现过程中我们再一次用到了CRD。可是你一定要明白CRD并不是万能的它有很多场景不适用还有性能瓶颈。你能列举出一些不适用CRD的场景么你知道造成CRD性能瓶颈的原因主要在哪里么
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。