mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 14:43:42 +08:00
del
This commit is contained in:
@@ -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对应的imklog,imuxsock和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: ["/bin/sh"]
|
||||
args: ["-c", "echo Hello from the debian container > /pod-data/index.html"]
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,debian-container和nginx-container都声明挂载了shared-data这个Volume。而shared-data是hostPath类型。所以,它对应在宿主机上的目录就是:/data。而这个目录,其实就被同时绑定挂载进了上述两个容器当中。
|
||||
|
||||
这就是为什么,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: ["cp", "/sample.war", "/app"]
|
||||
volumeMounts:
|
||||
- mountPath: /app
|
||||
name: app-volume
|
||||
containers:
|
||||
- image: geektime/tomcat:7.0
|
||||
name: tomcat
|
||||
command: ["sh","-c","/root/apache-tomcat-7.0.42-v2/bin/start.sh"]
|
||||
volumeMounts:
|
||||
- mountPath: /root/apache-tomcat-7.0.42-v2/webapps
|
||||
name: app-volume
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
hostPort: 8001
|
||||
volumes:
|
||||
- name: app-volume
|
||||
emptyDir: {}
|
||||
|
||||
```
|
||||
|
||||
在这个Pod中,我们定义了两个容器,第一个容器使用的镜像是geektime/sample:v2,这个镜像里只有一个WAR包(sample.war)放在根目录下。而第二个容器则使用的是一个标准的Tomcat镜像。
|
||||
|
||||
不过,你可能已经注意到,WAR包容器的类型不再是一个普通容器,而是一个Init Container类型的容器。
|
||||
|
||||
在Pod中,所有Init Container定义的容器,都会比spec.containers定义的用户容器先启动。并且,Init Container容器会按顺序逐一启动,而直到它们都启动并且退出了,用户容器才会启动。
|
||||
|
||||
所以,这个Init Container类型的WAR包容器启动后,我执行了一句"cp /sample.war /app",把应用的WAR包拷贝到/app目录下,然后退出。
|
||||
|
||||
而后这个/app目录,就挂载了一个名叫app-volume的Volume。
|
||||
|
||||
接下来就很关键了。Tomcat容器,同样声明了挂载app-volume到自己的webapps目录下。
|
||||
|
||||
所以,等Tomcat容器启动时,它的webapps目录下就一定会存在sample.war文件:这个文件正是WAR包容器启动时拷贝到这个Volume里面的,而这个Volume是被这两个容器共享的。
|
||||
|
||||
像这样,我们就用一种“组合”方式,解决了WAR包与Tomcat容器之间耦合关系的问题。
|
||||
|
||||
实际上,这个所谓的“组合”操作,正是容器设计模式里最常用的一种模式,它的名字叫:sidecar。
|
||||
|
||||
顾名思义,sidecar指的就是我们可以在一个Pod中,启动一个辅助容器,来完成一些独立于主进程(主容器)之外的工作。
|
||||
|
||||
比如,在我们的这个应用Pod中,Tomcat容器是我们要使用的主容器,而WAR包容器的存在,只是为了给它提供一个WAR包而已。所以,我们用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的具体应用场景吗?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -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: "10.1.2.3"
|
||||
hostnames:
|
||||
- "foo.remote"
|
||||
- "bar.remote"
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
在这个Pod的YAML文件中,我设置了一组IP和hostname的数据。这样,这个Pod启动后,/etc/hosts文件的内容将如下所示:
|
||||
|
||||
```
|
||||
cat /etc/hosts
|
||||
# Kubernetes-managed hosts file.
|
||||
127.0.0.1 localhost
|
||||
...
|
||||
10.244.135.10 hostaliases-pod
|
||||
10.1.2.3 foo.remote
|
||||
10.1.2.3 bar.remote
|
||||
|
||||
```
|
||||
|
||||
其中,最下面两行记录,就是我通过HostAliases字段为Pod设置的。需要指出的是,在Kubernetes项目中,如果要设置hosts文件里的内容,一定要通过这种方法。否则,如果直接修改了hosts文件的话,在Pod被删除重建之后,kubelet会自动覆盖掉被修改的内容。
|
||||
|
||||
除了上述跟“机器”相关的配置外,你可能也会发现,**凡是跟容器的Linux Namespace相关的属性,也一定是Pod 级别的**。这个原因也很容易理解:Pod的设计,就是要让它里面的容器尽可能多地共享Linux Namespace,仅保留必要的隔离和限制能力。这样,Pod模拟出的效果,就跟虚拟机里程序间的关系非常类似了。
|
||||
|
||||
举个例子,在下面这个Pod的YAML文件中,我定义了shareProcessNamespace=true:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: nginx
|
||||
spec:
|
||||
shareProcessNamespace: true
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx
|
||||
- name: shell
|
||||
image: busybox
|
||||
stdin: true
|
||||
tty: true
|
||||
|
||||
```
|
||||
|
||||
这就意味着这个Pod里的容器要共享PID Namespace。
|
||||
|
||||
而在这个YAML文件中,我还定义了两个容器:一个是nginx容器,一个是开启了tty和stdin的shell容器。
|
||||
|
||||
我在前面介绍容器基础时,曾经讲解过什么是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: ["/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"]
|
||||
preStop:
|
||||
exec:
|
||||
command: ["/usr/sbin/nginx","-s","quit"]
|
||||
|
||||
```
|
||||
|
||||
这是一个来自Kubernetes官方文档的Pod的YAML文件。它其实非常简单,只是定义了一个nginx镜像的容器。不过,在这个YAML文件的容器(Containers)部分,你会看到这个容器分别设置了一个postStart和preStop参数。这是什么意思呢?
|
||||
|
||||
先说postStart吧。它指的是,在容器启动后,立刻执行一个指定的操作。需要明确的是,postStart定义的操作,虽然是在Docker容器ENTRYPOINT执行之后,但它并不严格保证顺序。也就是说,在postStart启动时,ENTRYPOINT有可能还没有结束。
|
||||
|
||||
当然,如果postStart执行超时或者错误,Kubernetes会在该Pod的Events中报出该容器启动失败的错误信息,导致Pod也处于失败的状态。
|
||||
|
||||
而类似地,preStop发生的时机,则是容器被杀死之前(比如,收到了SIGKILL信号)。而需要明确的是,preStop操作的执行,是同步的。所以,它会阻塞当前的容器杀死流程,直到这个Hook定义操作完成之后,才允许容器被杀死,这跟postStart不一样。
|
||||
|
||||
所以,在这个例子中,我们在容器成功启动之后,在/usr/share/message里写入了一句“欢迎信息”(即postStart定义的操作)。而在这个容器被删除之前,我们则先调用了nginx的退出指令(即preStop定义的操作),从而实现了容器的“优雅退出”。
|
||||
|
||||
在熟悉了Pod以及它的Container部分的主要字段之后,我再和你分享一下**这样一个的Pod对象在Kubernetes中的生命周期**。
|
||||
|
||||
Pod生命周期的变化,主要体现在Pod API对象的**Status部分**,这是它除了Metadata和Spec之外的第三个重要字段。其中,pod.status.phase,就是Pod的当前状态,它有如下几种可能的情况:
|
||||
|
||||
<li>
|
||||
Pending。这个状态意味着,Pod的YAML文件已经提交给了Kubernetes,API对象已经被创建并保存在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开发者的亲身体会会比较多吧。
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -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
|
||||
- "86400"
|
||||
volumeMounts:
|
||||
- name: mysql-cred
|
||||
mountPath: "/projected-volume"
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: mysql-cred
|
||||
projected:
|
||||
sources:
|
||||
- secret:
|
||||
name: user
|
||||
- secret:
|
||||
name: pass
|
||||
|
||||
```
|
||||
|
||||
在这个Pod中,我定义了一个简单的容器。它声明挂载的Volume,并不是常见的emptyDir或者hostPath类型,而是projected类型。而这个 Volume的数据来源(sources),则是名为user和pass的Secret对象,分别对应的是数据库的用户名和密码。
|
||||
|
||||
这里用到的数据库的用户名、密码,正是以Secret对象的方式交给Kubernetes保存的。完成这个操作的指令,如下所示:
|
||||
|
||||
```
|
||||
$ cat ./username.txt
|
||||
admin
|
||||
$ cat ./password.txt
|
||||
c1oudc0w!
|
||||
|
||||
$ kubectl create secret generic user --from-file=./username.txt
|
||||
$ kubectl create secret generic pass --from-file=./password.txt
|
||||
|
||||
```
|
||||
|
||||
其中,username.txt和password.txt文件里,存放的就是用户名和密码;而user和pass,则是我为Secret对象指定的名字。而我想要查看这些Secret对象的话,只要执行一条kubectl get命令就可以了:
|
||||
|
||||
```
|
||||
$ kubectl get secrets
|
||||
NAME TYPE DATA AGE
|
||||
user Opaque 1 51s
|
||||
pass Opaque 1 51s
|
||||
|
||||
```
|
||||
|
||||
当然,除了使用kubectl create secret指令外,我也可以直接通过编写YAML文件的方式来创建这个Secret对象,比如:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: mysecret
|
||||
type: Opaque
|
||||
data:
|
||||
user: YWRtaW4=
|
||||
pass: MWYyZDFlMmU2N2Rm
|
||||
|
||||
```
|
||||
|
||||
可以看到,通过编写YAML文件创建出来的Secret对象只有一个。但它的data字段,却以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: ["sh", "-c"]
|
||||
args:
|
||||
- while true; do
|
||||
if [[ -e /etc/podinfo/labels ]]; then
|
||||
echo -en '\n\n'; cat /etc/podinfo/labels; fi;
|
||||
sleep 5;
|
||||
done;
|
||||
volumeMounts:
|
||||
- name: podinfo
|
||||
mountPath: /etc/podinfo
|
||||
readOnly: false
|
||||
volumes:
|
||||
- name: podinfo
|
||||
projected:
|
||||
sources:
|
||||
- downwardAPI:
|
||||
items:
|
||||
- path: "labels"
|
||||
fieldRef:
|
||||
fieldPath: metadata.labels
|
||||
|
||||
```
|
||||
|
||||
在这个Pod的YAML文件中,我定义了一个简单的容器,声明了一个projected类型的Volume。只不过这次Volume的数据来源,变成了Downward API。而这个Downward API Volume,则声明了要暴露Pod的metadata.labels信息给容器。
|
||||
|
||||
通过这样的声明方式,当前Pod的Labels字段的值,就会被Kubernetes自动挂载成为容器里的/etc/podinfo/labels文件。
|
||||
|
||||
而这个容器的启动命令,则是不断打印出/etc/podinfo/labels里的内容。所以,当我创建了这个Pod之后,就可以通过kubectl logs指令,查看到这些Labels字段被打印出来,如下所示:
|
||||
|
||||
```
|
||||
$ kubectl create -f dapi-volume.yaml
|
||||
$ kubectl logs test-downwardapi-volume
|
||||
cluster="test-cluster1"
|
||||
rack="rack-22"
|
||||
zone="us-est-coast"
|
||||
|
||||
```
|
||||
|
||||
目前,Downward API支持的字段已经非常丰富了,比如:
|
||||
|
||||
```
|
||||
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['<KEY>'] - 指定<KEY>的Label值
|
||||
metadata.annotations['<KEY>'] - 指定<KEY>的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”。这时,如果这个文件存在,这条命令的返回值就是0,Pod就会认为这个容器不仅已经启动,而且是健康的。这个健康检查,在容器启动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 YAML,Kubernetes就可以自动给对应的Pod对象加上其他必要的信息,比如labels,annotations,volumes等等。而这些信息,可以是运维人员事先定义好的。
|
||||
|
||||
这么一来,开发人员编写Pod YAML的门槛,就被大大降低了。
|
||||
|
||||
所以,这个叫作PodPreset(Pod预设置)的功能 已经出现在了v1.11版本的Kubernetes中。
|
||||
|
||||
举个例子,现在开发人员编写了如下一个 pod.yaml文件:
|
||||
|
||||
```
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: website
|
||||
labels:
|
||||
app: website
|
||||
role: frontend
|
||||
spec:
|
||||
containers:
|
||||
- name: website
|
||||
image: nginx
|
||||
ports:
|
||||
- containerPort: 80
|
||||
|
||||
```
|
||||
|
||||
作为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: "6379"
|
||||
volumeMounts:
|
||||
- mountPath: /cache
|
||||
name: cache-volume
|
||||
volumes:
|
||||
- name: cache-volume
|
||||
emptyDir: {}
|
||||
|
||||
```
|
||||
|
||||
在这个PodPreset的定义中,首先是一个selector。这就意味着后面这些追加的定义,只会作用于selector所定义的、带有“role: frontend”标签的Pod对象,这就可以防止“误伤”。
|
||||
|
||||
然后,我们定义了一组Pod的Spec里的标准字段,以及对应的值。比如,env里定义了DB_PORT这个环境变量,volumeMounts定义了容器Volume的挂载目录,volumes定义了一个emptyDir的Volume。
|
||||
|
||||
接下来,我们假定运维人员先创建了这个PodPreset,然后开发人员才创建Pod:
|
||||
|
||||
```
|
||||
$ kubectl create -f preset.yaml
|
||||
$ kubectl create -f pod.yaml
|
||||
|
||||
```
|
||||
|
||||
这时,Pod运行起来之后,我们查看一下这个Pod的API对象:
|
||||
|
||||
```
|
||||
$ kubectl get pod website -o yaml
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: website
|
||||
labels:
|
||||
app: website
|
||||
role: frontend
|
||||
annotations:
|
||||
podpreset.admission.kubernetes.io/podpreset-allow-database: "resource version"
|
||||
spec:
|
||||
containers:
|
||||
- name: website
|
||||
image: nginx
|
||||
volumeMounts:
|
||||
- mountPath: /cache
|
||||
name: cache-volume
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
- name: DB_PORT
|
||||
value: "6379"
|
||||
volumes:
|
||||
- name: cache-volume
|
||||
emptyDir: {}
|
||||
|
||||
```
|
||||
|
||||
这个时候,我们就可以清楚地看到,这个Pod里多了新添加的labels、env、volumes和volumeMount的定义,它们的配置跟PodPreset的内容一样。此外,这个Pod还被自动加上了一个annotation表示这个Pod对象被PodPreset改动过。
|
||||
|
||||
需要说明的是,**PodPreset里定义的内容,只会在Pod API对象被创建之前追加在这个对象本身上,而不会影响任何Pod的控制器的定义。**
|
||||
|
||||
比如,我们现在提交的是一个nginx-deployment,那么这个Deployment对象本身是永远不会被PodPreset改变的,被修改的只是这个Deployment创建出来的所有Pod。这一点请务必区分清楚。
|
||||
|
||||
这里有一个问题:如果你定义了同时作用于一个Pod对象的多个PodPreset,会发生什么呢?
|
||||
|
||||
实际上,Kubernetes项目会帮你合并(Merge)这两个PodPreset要做的修改。而如果它们要做的修改有冲突的话,这些冲突字段就不会被修改。
|
||||
|
||||
## 总结
|
||||
|
||||
在今天这篇文章中,我和你详细介绍了Pod对象更高阶的使用方法,希望通过对这些实例的讲解,你可以更深入地理解Pod API对象的各个字段。
|
||||
|
||||
而在学习这些字段的同时,你还应该认真体会一下Kubernetes“一切皆对象”的设计思想:比如应用是Pod对象,应用的配置是ConfigMap对象,应用要访问的密码则是Secret对象。
|
||||
|
||||
所以,也就自然而然地有了PodPreset这样专门用来对Pod进行批量化、自动化修改的工具对象。在后面的内容中,我会为你讲解更多的这种对象,还会和你介绍Kubernetes项目如何围绕着这些对象进行容器编排。
|
||||
|
||||
在本专栏中,Pod对象相关的知识点非常重要,它是接下来Kubernetes能够描述和编排各种复杂应用的基石所在,希望你能够继续多实践、多体会。
|
||||
|
||||
## 思考题
|
||||
|
||||
在没有Kubernetes的时候,你是通过什么方法进行应用的健康检查的?Kubernetes的livenessProbe和readinessProbe提供的几种探测机制,是否能满足你的需求?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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项目中有一个专有的名字,叫作PodTemplate(Pod模板)。
|
||||
|
||||
这个概念非常重要,因为后面我要讲解到的大多数控制器,都会使用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使用的这个“控制器模式”,跟我们平常所说的“事件驱动”,有什么区别和联系吗?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
@@ -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副本个数是3(spec.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 -> 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模板,创建一个新的ReplicaSet(hash=1764197365),这个新的ReplicaSet的初始Pod副本数是:0。
|
||||
|
||||
然后,在Age=24 s的位置,Deployment Controller开始将这个新的ReplicaSet所控制的Pod副本数从0个变成1个,即:“水平扩展”出一个副本。
|
||||
|
||||
紧接着,在Age=22 s的位置,Deployment Controller又将旧的ReplicaSet(hash=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
|
||||
|
||||
```
|
||||
|
||||
其中,旧ReplicaSet(hash=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
|
||||
|
||||
```
|
||||
|
||||
通过这个返回结果,我们可以看到,新版本的ReplicaSet(hash=2156724341)的“水平扩展”已经停止。而且此时,它已经创建了两个Pod,但是它们都没有进入READY状态。这当然是因为这两个Pod都拉取不到有效的镜像。
|
||||
|
||||
与此同时,旧版本的ReplicaSet(hash=1764197365)的“水平收缩”,也自动停止了。此时,已经有一个旧Pod被删除,还剩下两个旧Pod。
|
||||
|
||||
那么问题来了, 我们如何让这个Deployment的3个Pod,都回滚到以前的旧版本呢?
|
||||
|
||||
我们只需要执行一条kubectl rollout undo命令,就能把整个Deployment回滚到上一个版本:
|
||||
|
||||
```
|
||||
$ kubectl rollout undo deployment/nginx-deployment
|
||||
deployment.extensions/nginx-deployment
|
||||
|
||||
```
|
||||
|
||||
很容易想到,在具体操作上,Deployment的控制器,其实就是让这个旧ReplicaSet(hash=1764197365)再次“扩展”成3个Pod,而让新的ReplicaSet(hash=2156724341)重新“收缩”到0个Pod。
|
||||
|
||||
更进一步地,如果我想回滚到更早之前的版本,要怎么办呢?
|
||||
|
||||
**首先,我需要使用kubectl rollout history命令,查看每次Deployment变更对应的版本**。而由于我们在创建这个Deployment的时候,指定了–record参数,所以我们创建这些版本时执行的kubectl命令,都会被记录下来。这个操作的输出如下所示:
|
||||
|
||||
```
|
||||
$ kubectl rollout history deployment/nginx-deployment
|
||||
deployments "nginx-deployment"
|
||||
REVISION CHANGE-CAUSE
|
||||
1 kubectl create -f nginx-deployment.yaml --record
|
||||
2 kubectl edit deployment/nginx-deployment
|
||||
3 kubectl set image deployment/nginx-deployment nginx=nginx:1.91
|
||||
|
||||
```
|
||||
|
||||
可以看到,我们前面执行的创建和更新操作,分别对应了版本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),建议你在课后实践一下。
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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的VIP(Virtual 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记录,如下所示:
|
||||
|
||||
```
|
||||
<pod-name>.<svc-name>.<namespace>.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: "nginx"
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.9.1
|
||||
ports:
|
||||
- containerPort: 80
|
||||
name: web
|
||||
|
||||
```
|
||||
|
||||
这个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 <none> 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 "web-0" deleted
|
||||
pod "web-1" 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还应该为你提供哪些支持来管理这个拓扑状态呢?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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: "2"
|
||||
imagefeatures: "layering"
|
||||
|
||||
```
|
||||
|
||||
其一,如果不懂得Ceph RBD的使用方法,那么这个Pod里Volumes字段,你十有八九也完全看不懂。其二,这个Ceph RBD对应的存储服务器的地址、用户名、授权文件的位置,也都被轻易地暴露给了全公司的所有开发人员,这是一个典型的信息被“过度暴露”的例子。
|
||||
|
||||
这也是为什么,在后来的演化中,**Kubernetes项目引入了一组叫作Persistent Volume Claim(PVC)和Persistent Volume(PV)的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 GiB;accessModes: 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: "http-server"
|
||||
volumeMounts:
|
||||
- mountPath: "/usr/share/nginx/html"
|
||||
name: pv-storage
|
||||
volumes:
|
||||
- name: pv-storage
|
||||
persistentVolumeClaim:
|
||||
claimName: pv-claim
|
||||
|
||||
```
|
||||
|
||||
可以看到,在这个Pod的Volumes定义中,我们只需要声明它的类型是persistentVolumeClaim,然后指定PVC的名字,而完全不必关心Volume本身的定义。
|
||||
|
||||
这时候,只要我们创建这个PVC对象,Kubernetes就会自动为它绑定一个符合条件的Volume。可是,这些符合条件的Volume又是从哪里来的呢?
|
||||
|
||||
答案是,它们来自于由运维人员维护的PV(Persistent 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: "nginx"
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.9.1
|
||||
ports:
|
||||
- containerPort: 80
|
||||
name: web
|
||||
volumeMounts:
|
||||
- name: www
|
||||
mountPath: /usr/share/nginx/html
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: www
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
|
||||
```
|
||||
|
||||
这次,我们为这个StatefulSet额外添加了一个volumeClaimTemplates字段。从名字就可以看出来,它跟Deployment里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,都以“<PVC名字>-<StatefulSet名字>-<编号>”的方式命名,并且处于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) > /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 "web-0" deleted
|
||||
pod "web-1" 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进行一对一绑定呢?(提示:这个问题的答案根据不同的项目是不同的。关键在于,重建后的节点进行数据恢复和同步的时候,是不是一定需要原先它写在本地磁盘里的数据)
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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集群的主要难点在于:如何让从节点能够拥有主节点的数据,即:如何配置主(Master)从(Slave)节点的复制与同步。
|
||||
|
||||
所以,在安装好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> 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> 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 Service(即:clusterIP= 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还需要管理存储状态。所以,我们需要通过volumeClaimTemplate(PVC模板)来为每个Pod定义PVC。比如,这个PVC模板的resources.requests.strorage指定了存储的大小为10 GiB;ReadWriteOnce指定了该存储的属性为可读写,并且一个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
|
||||
- "-c"
|
||||
- |
|
||||
set -ex
|
||||
# 从Pod的序号,生成server-id
|
||||
[[ `hostname` =~ -([0-9]+)$ ]] || exit 1
|
||||
ordinal=${BASH_REMATCH[1]}
|
||||
echo [mysqld] > /mnt/conf.d/server-id.cnf
|
||||
# 由于server-id=0有特殊含义,我们给ID加一个100来避开它
|
||||
echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf
|
||||
# 如果Pod序号是0,说明它是Master节点,从ConfigMap里把Master的配置文件拷贝到/mnt/conf.d/目录;
|
||||
# 否则,拷贝Slave的配置文件
|
||||
if [[ $ordinal -eq 0 ]]; then
|
||||
cp /mnt/config-map/master.cnf /mnt/conf.d/
|
||||
else
|
||||
cp /mnt/config-map/slave.cnf /mnt/conf.d/
|
||||
fi
|
||||
volumeMounts:
|
||||
- name: conf
|
||||
mountPath: /mnt/conf.d
|
||||
- name: config-map
|
||||
mountPath: /mnt/config-map
|
||||
|
||||
```
|
||||
|
||||
在这个名叫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
|
||||
- "-c"
|
||||
- |
|
||||
set -ex
|
||||
# 拷贝操作只需要在第一次启动时进行,所以如果数据已经存在,跳过
|
||||
[[ -d /var/lib/mysql/mysql ]] && exit 0
|
||||
# Master节点(序号为0)不需要做这个操作
|
||||
[[ `hostname` =~ -([0-9]+)$ ]] || exit 1
|
||||
ordinal=${BASH_REMATCH[1]}
|
||||
[[ $ordinal -eq 0 ]] && exit 0
|
||||
# 使用ncat指令,远程地从前一个节点拷贝数据到本地
|
||||
ncat --recv-only mysql-$(($ordinal-1)).mysql 3307 | xbstream -x -C /var/lib/mysql
|
||||
# 执行--prepare,这样拷贝来的数据就可以用作恢复了
|
||||
xtrabackup --prepare --target-dir=/var/lib/mysql
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/mysql
|
||||
subPath: mysql
|
||||
- name: conf
|
||||
mountPath: /etc/mysql/conf.d
|
||||
|
||||
```
|
||||
|
||||
在这个名叫clone-mysql的InitContainer里,我们使用的是xtrabackup镜像(它里面安装了xtrabackup工具)。
|
||||
|
||||
而在它的启动命令里,我们首先做了一个判断。即:当初始化所需的数据(/var/lib/mysql/mysql 目录)已经存在,或者当前Pod是Master节点的时候,不需要做拷贝操作。
|
||||
|
||||
接下来,clone-mysql会使用Linux自带的ncat指令,向DNS记录为“mysql-<当前序号减一>.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
|
||||
- "-c"
|
||||
- |
|
||||
set -ex
|
||||
cd /var/lib/mysql
|
||||
|
||||
# 从备份信息文件里读取MASTER_LOG_FILEM和MASTER_LOG_POS这两个字段的值,用来拼装集群初始化SQL
|
||||
if [[ -f xtrabackup_slave_info ]]; then
|
||||
# 如果xtrabackup_slave_info文件存在,说明这个备份数据来自于另一个Slave节点。这种情况下,XtraBackup工具在备份的时候,就已经在这个文件里自动生成了"CHANGE MASTER TO" SQL语句。所以,我们只需要把这个文件重命名为change_master_to.sql.in,后面直接使用即可
|
||||
mv xtrabackup_slave_info change_master_to.sql.in
|
||||
# 所以,也就用不着xtrabackup_binlog_info了
|
||||
rm -f xtrabackup_binlog_info
|
||||
elif [[ -f xtrabackup_binlog_info ]]; then
|
||||
# 如果只存在xtrabackup_binlog_inf文件,那说明备份来自于Master节点,我们就需要解析这个备份信息文件,读取所需的两个字段的值
|
||||
[[ `cat xtrabackup_binlog_info` =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1
|
||||
rm xtrabackup_binlog_info
|
||||
# 把两个字段的值拼装成SQL,写入change_master_to.sql.in文件
|
||||
echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\
|
||||
MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in
|
||||
fi
|
||||
|
||||
# 如果change_master_to.sql.in,就意味着需要做集群初始化工作
|
||||
if [[ -f change_master_to.sql.in ]]; then
|
||||
# 但一定要先等MySQL容器启动之后才能进行下一步连接MySQL的操作
|
||||
echo "Waiting for mysqld to be ready (accepting connections)"
|
||||
until mysql -h 127.0.0.1 -e "SELECT 1"; do sleep 1; done
|
||||
|
||||
echo "Initializing replication from clone position"
|
||||
# 将文件change_master_to.sql.in改个名字,防止这个Container重启的时候,因为又找到了change_master_to.sql.in,从而重复执行一遍这个初始化流程
|
||||
mv change_master_to.sql.in change_master_to.sql.orig
|
||||
# 使用change_master_to.sql.orig的内容,也是就是前面拼装的SQL,组成一个完整的初始化和启动Slave的SQL语句
|
||||
mysql -h 127.0.0.1 <<EOF
|
||||
$(<change_master_to.sql.orig),
|
||||
MASTER_HOST='mysql-0.mysql',
|
||||
MASTER_USER='root',
|
||||
MASTER_PASSWORD='',
|
||||
MASTER_CONNECT_RETRY=10;
|
||||
START SLAVE;
|
||||
EOF
|
||||
fi
|
||||
|
||||
# 使用ncat监听3307端口。它的作用是,在收到传输请求的时候,直接执行"xtrabackup --backup"命令,备份MySQL的数据并发送给请求者
|
||||
exec ncat --listen --keep-open --send-only --max-conns=1 3307 -c \
|
||||
"xtrabackup --backup --slave-info --stream=xbstream --host=127.0.0.1 --user=root"
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/mysql
|
||||
subPath: mysql
|
||||
- name: conf
|
||||
mountPath: /etc/mysql/conf.d
|
||||
|
||||
```
|
||||
|
||||
可以看到,**在这个名叫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之前,必须先执行一句SQL(select 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: "1"
|
||||
ports:
|
||||
- name: mysql
|
||||
containerPort: 3306
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/mysql
|
||||
subPath: mysql
|
||||
- name: conf
|
||||
mountPath: /etc/mysql/conf.d
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["mysqladmin", "ping"]
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
exec:
|
||||
# 通过TCP连接的方式进行健康检查
|
||||
command: ["mysql", "-h", "127.0.0.1", "-e", "SELECT 1"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 2
|
||||
timeoutSeconds: 1
|
||||
|
||||
```
|
||||
|
||||
在这个容器的定义里,我们使用了一个标准的MySQL 5.7 的官方镜像。它的数据目录是/var/lib/mysql,配置文件目录是/etc/mysql/conf.d。
|
||||
|
||||
这时候,你应该能够明白,如果MySQL容器是Slave节点的话,它的数据目录里的数据,就来自于InitContainer从其他节点里拷贝而来的备份。它的配置文件目录/etc/mysql/conf.d里的内容,则来自于ConfigMap对应的Volume。而它的初始化工作,则是由同一个Pod里的sidecar容器完成的。这些操作,正是我刚刚为你讲述的大部分内容。
|
||||
|
||||
另外,我们为它定义了一个livenessProbe,通过mysqladmin ping命令来检查它是否健康;还定义了一个readinessProbe,通过查询SQL(select 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 <<EOF
|
||||
CREATE DATABASE test;
|
||||
CREATE TABLE test.messages (message VARCHAR(250));
|
||||
INSERT INTO test.messages VALUES ('hello');
|
||||
EOF
|
||||
|
||||
```
|
||||
|
||||
如上所示,我们通过启动一个容器,使用MySQL client执行了创建数据库和表、以及插入数据的操作。需要注意的是,我们连接的MySQL的地址必须是mysql-0.mysql(即:Master节点的DNS记录)。因为,只有Master节点才能处理写操作。
|
||||
|
||||
而通过连接mysql-read这个Service,我们就可以用SQL进行读操作,如下所示:
|
||||
|
||||
```
|
||||
$ kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\
|
||||
mysql -h mysql-read -e "SELECT * FROM test.messages"
|
||||
Waiting for pod default/mysql-client to be running, status is Pending, pod ready: false
|
||||
+---------+
|
||||
| message |
|
||||
+---------+
|
||||
| hello |
|
||||
+---------+
|
||||
pod "mysql-client" 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 "SELECT * FROM test.messages"
|
||||
Waiting for pod default/mysql-client to be running, status is Pending, pod ready: false
|
||||
+---------+
|
||||
| message |
|
||||
+---------+
|
||||
| hello |
|
||||
+---------+
|
||||
pod "mysql-client" 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节点处理。那么,你需要在今天这篇文章的基础上再做哪些改动呢?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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='[{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value":"mysql:5.7.23"}]'
|
||||
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 '{"spec":{"updateStrategy":{"type":"RollingUpdate","rollingUpdate":{"partition":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节点的状态都是NotReady(NetworkReady=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: <Node名字>
|
||||
|
||||
```
|
||||
|
||||
没错。
|
||||
|
||||
不过,在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 <none> 1h
|
||||
|
||||
```
|
||||
|
||||
>
|
||||
备注:Kubernetes里比较长的API对象都有短名字,比如DaemonSet对应的是ds,Deployment对应的是deploy。
|
||||
|
||||
|
||||
就会发现DaemonSet和Deployment一样,也有DESIRED、CURRENT等多个状态字段。这也就意味着,DaemonSet可以像Deployment那样,进行版本管理。这个版本,可以使用kubectl rollout history看到:
|
||||
|
||||
```
|
||||
$ kubectl rollout history daemonset fluentd-elasticsearch -n kube-system
|
||||
daemonsets "fluentd-elasticsearch"
|
||||
REVISION CHANGE-CAUSE
|
||||
1 <none>
|
||||
|
||||
```
|
||||
|
||||
**接下来,我们来把这个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 "fluentd-elasticsearch" rollout to finish: 0 out of 2 new pods have been updated...
|
||||
Waiting for daemon set "fluentd-elasticsearch" rollout to finish: 0 out of 2 new pods have been updated...
|
||||
Waiting for daemon set "fluentd-elasticsearch" rollout to finish: 1 of 2 updated pods are available...
|
||||
daemon set "fluentd-elasticsearch" successfully rolled out
|
||||
|
||||
```
|
||||
|
||||
注意,由于这一次我在升级命令后面加上了–record参数,所以这次升级使用到的指令就会自动出现在DaemonSet的rollout history里面,如下所示:
|
||||
|
||||
```
|
||||
$ kubectl rollout history daemonset fluentd-elasticsearch -n kube-system
|
||||
daemonsets "fluentd-elasticsearch"
|
||||
REVISION CHANGE-CAUSE
|
||||
1 <none>
|
||||
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: <nil>
|
||||
Labels:
|
||||
Name: fluentd-elasticsearch
|
||||
Spec:
|
||||
Containers:
|
||||
Image: k8s.gcr.io/fluentd-elasticsearch:v2.2.0
|
||||
Image Pull Policy: IfNotPresent
|
||||
Name: fluentd-elasticsearch
|
||||
...
|
||||
Revision: 2
|
||||
Events: <none>
|
||||
|
||||
```
|
||||
|
||||
就会看到,这个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。
|
||||
|
||||
相比于Deployment,DaemonSet只管理Pod对象,然后通过nodeAffinity和Toleration这两个调度器的小功能,保证了每个节点上有且只有一个Pod。这个控制器的实现原理简单易懂,希望你能够快速掌握。
|
||||
|
||||
与此同时,DaemonSet使用ControllerRevision,来保存和管理自己对应的“版本”。这种“面向API对象”的设计思路,大大简化了控制器本身的逻辑,也正是Kubernetes项目“声明式API”的优势所在。
|
||||
|
||||
而且,相信聪明的你此时已经想到了,StatefulSet也是直接控制Pod对象的,那么它是不是也在使用ControllerRevision进行版本管理呢?
|
||||
|
||||
没错。在Kubernetes项目里,ControllerRevision其实是一个通用的版本管理对象。这样,Kubernetes项目就巧妙地避免了每种控制器都要维护一套冗余的代码和逻辑的问题。
|
||||
|
||||
## 思考题
|
||||
|
||||
我在文中提到,在Kubernetes v1.11之前,DaemonSet所管理的Pod的调度过程,实际上都是由DaemonSet Controller自己而不是由调度器完成的。你能说出这其中有哪些原因吗?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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就已经对作业进行了分类处理,提出了LRS(Long 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: ["sh", "-c", "echo 'scale=10000; 4*a(1)' | bc -l "]
|
||||
restartPolicy: Never
|
||||
backoffLimit: 4
|
||||
|
||||
```
|
||||
|
||||
此时,相信你对Kubernetes的API对象已经不再陌生了。在这个Job的YAML文件里,你肯定一眼就会看到一位“老熟人”:Pod模板,即spec.template字段。
|
||||
|
||||
在这个Pod模板中,我定义了一个Ubuntu镜像的容器(准确地说,是一个安装了bc命令的Ubuntu镜像),它运行的程序是:
|
||||
|
||||
```
|
||||
echo "scale=10000; 4*a(1)" | 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: <none>
|
||||
Parallelism: 1
|
||||
Completions: 1
|
||||
..
|
||||
Pods Statuses: 0 Running / 1 Succeeded / 0 Failed
|
||||
Pod Template:
|
||||
Labels: controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495
|
||||
job-name=pi
|
||||
Containers:
|
||||
...
|
||||
Volumes: <none>
|
||||
Events:
|
||||
FirstSeen LastSeen Count From SubobjectPath Type Reason Message
|
||||
--------- -------- ----- ---- ------------- -------- ------ -------
|
||||
1m 1m 1 {job-controller } Normal SuccessfulCreate Created pod: pi-rq5rl
|
||||
|
||||
```
|
||||
|
||||
可以看到,这个Job对象在创建后,它的Pod模板,被自动加上了一个controller-uid=<一个随机字符串>这样的Label。而这个Job对象本身,则被自动加上了这个Label对应的Selector,从而 保证了Job与它所管理的Pod之间的匹配关系。
|
||||
|
||||
而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字段里定义了重试次数为4(即,backoffLimit=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: ["sh", "-c", "echo 'scale=5000; 4*a(1)' | bc -l "]
|
||||
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: ["sh", "-c", "echo Processing item $ITEM && sleep 5"]
|
||||
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 "s/\$ITEM/$i/" > ./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("Queue empty, exiting")
|
||||
exit
|
||||
|
||||
```
|
||||
|
||||
由于任务数目的总数不固定,所以每一个Pod必须能够知道,自己什么时候可以退出。比如,在这个例子中,我简单地以“队列为空”,作为任务全部完成的标志。所以说,这种用法,对应的是“任务总数不固定”的场景。
|
||||
|
||||
不过,在实际的应用中,你需要处理的条件往往会非常复杂。比如,任务完成后的输出、每个任务Pod之间是不是有资源的竞争和协同等等。
|
||||
|
||||
所以,在今天这篇文章中,我就不再展开Job的用法了。因为,在实际场景里,要么干脆就用第一种用法来自己管理作业;要么,这些任务Pod之间的关系就不那么“单纯”,甚至还是“有状态应用”(比如,任务的输入/输出是在持久化数据卷里)。在这种情况下,我在后面要重点讲解的Operator,加上Job对象一起,可能才能更好地满足实际离线任务的编排需求。
|
||||
|
||||
最后,我再来和你分享一个非常有用的Job对象,叫作:CronJob。
|
||||
|
||||
顾名思义,CronJob描述的,正是定时任务。它的API对象,如下所示:
|
||||
|
||||
```
|
||||
apiVersion: batch/v1beta1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: hello
|
||||
spec:
|
||||
schedule: "*/1 * * * *"
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: hello
|
||||
image: busybox
|
||||
args:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- date; echo Hello from the Kubernetes cluster
|
||||
restartPolicy: OnFailure
|
||||
|
||||
```
|
||||
|
||||
在这个YAML文件中,最重要的关键词就是**jobTemplate**。看到它,你一定恍然大悟,原来CronJob是一个Job对象的控制器(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 "hello" 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呢?原因是什么?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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! && 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! && sleep 3600']
|
||||
- name: envoy
|
||||
image: lyft/envoy:845747b88f102c0fd262ab234308e9e22f693a1
|
||||
command: ["/usr/local/bin/envoy"]
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
可以看到,被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: ["/usr/local/bin/envoy"]
|
||||
args:
|
||||
- "--concurrency 4"
|
||||
- "--config-path /etc/envoy/envoy.json"
|
||||
- "--mode serve"
|
||||
ports:
|
||||
- containerPort: 80
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
cpu: "1000m"
|
||||
memory: "512Mi"
|
||||
requests:
|
||||
cpu: "100m"
|
||||
memory: "64Mi"
|
||||
volumeMounts:
|
||||
- name: envoy-conf
|
||||
mountPath: /etc/envoy
|
||||
volumes:
|
||||
- name: envoy-conf
|
||||
configMap:
|
||||
name: envoy
|
||||
|
||||
```
|
||||
|
||||
相信你已经注意到了,这个ConfigMap的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, "envoy-initializer")
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,把这个ConfigMap里存储的containers和volumes字段,直接添加进一个空的Pod对象里:
|
||||
|
||||
```
|
||||
func doSomething(pod) {
|
||||
cm := client.Get(ConfigMap, "envoy-initializer")
|
||||
|
||||
newPod := Pod{}
|
||||
newPod.Spec.Containers = cm.Containers
|
||||
newPod.Spec.Volumes = cm.Volumes
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在,关键来了。
|
||||
|
||||
Kubernetes的API库,为我们提供了一个方法,使得我们可以直接使用新旧两个Pod对象,生成一个TwoWayMergePatch:
|
||||
|
||||
```
|
||||
func doSomething(pod) {
|
||||
cm := client.Get(ConfigMap, "envoy-initializer")
|
||||
|
||||
newPod := Pod{}
|
||||
newPod.Spec.Containers = cm.Containers
|
||||
newPod.Spec.Volumes = cm.Volumes
|
||||
|
||||
// 生成patch数据
|
||||
patchBytes := strategicpatch.CreateTwoWayMergePatch(pod, newPod)
|
||||
|
||||
// 发起PATCH请求,修改这个pod对象
|
||||
client.Patch(pod.Name, patchBytes)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**有了这个TwoWayMergePatch之后,Initializer的代码就可以使用这个patch的数据,调用Kubernetes的Client,发起一个PATCH请求**。
|
||||
|
||||
这样,一个用户提交的Pod对象里,就会被自动加上Envoy容器相关的字段。
|
||||
|
||||
当然,Kubernetes还允许你通过配置,来指定要对什么样的资源进行这个Initialize操作,比如下面这个例子:
|
||||
|
||||
```
|
||||
apiVersion: admissionregistration.k8s.io/v1alpha1
|
||||
kind: InitializerConfiguration
|
||||
metadata:
|
||||
name: envoy-config
|
||||
initializers:
|
||||
// 这个名字必须至少包括两个 "."
|
||||
- name: envoy.initializer.kubernetes.io
|
||||
rules:
|
||||
- apiGroups:
|
||||
- "" // 前面说过, ""就是core API Group的意思
|
||||
apiVersions:
|
||||
- v1
|
||||
resources:
|
||||
- pods
|
||||
|
||||
```
|
||||
|
||||
这个配置,就意味着Kubernetes要对所有的Pod进行这个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:
|
||||
"initializer.kubernetes.io/envoy": "true"
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
在这个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体系的核心?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -0,0 +1,434 @@
|
||||
<audio id="audio" title="24 | 深入解析声明式API(一):API对象的奥秘" 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里的完整资源路径,是由:Group(API组)、Version(API版本)和Resource(API资源类型)三个部分组成的。
|
||||
|
||||
通过这样的结构,整个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”就是它的组(Group),v2alpha1就是它的版本(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: "192.168.0.0/16"
|
||||
gateway: "192.168.0.1"
|
||||
|
||||
```
|
||||
|
||||
可以看到,我想要描述“网络”的API资源类型是Network;API组是`samplecrd.k8s.io`;API 版本是v1。
|
||||
|
||||
那么,Kubernetes又该如何知道这个API(`samplecrd.k8s.io/v1/network`)的存在呢?
|
||||
|
||||
其实,上面的这个YAML文件,就是一个具体的“自定义API资源”实例,也叫CR(Custom Resource)。而为了能够让Kubernetes认识这个CR,你就需要让Kubernetes明白这个CR的宏观定义是什么,也就是CRD(Custom 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/<your-name>/k8s-controller-custom-resource
|
||||
.
|
||||
├── controller.go
|
||||
├── crd
|
||||
│ └── network.yaml
|
||||
├── example
|
||||
│ └── example-network.yaml
|
||||
├── main.go
|
||||
└── pkg
|
||||
└── apis
|
||||
└── samplecrd
|
||||
├── register.go
|
||||
└── v1
|
||||
├── doc.go
|
||||
├── register.go
|
||||
└── 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 = "samplecrd.k8s.io"
|
||||
Version = "v1"
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
**接着,我需要在pkg/apis/samplecrd目录下添加一个doc.go文件(Golang的文档源文件)**。这个文件里的内容如下所示:
|
||||
|
||||
```
|
||||
// +k8s:deepcopy-gen=package
|
||||
|
||||
// +groupName=samplecrd.k8s.io
|
||||
package v1
|
||||
|
||||
```
|
||||
|
||||
在这个文件中,你会看到+<tag_name>[=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:",inline"`
|
||||
// ObjectMeta contains the metadata for the particular object, including
|
||||
// things like...
|
||||
// - name
|
||||
// - namespace
|
||||
// - self link
|
||||
// - labels
|
||||
// - ... etc ...
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec networkspec `json:"spec"`
|
||||
}
|
||||
// networkspec is the spec for a Network resource
|
||||
type networkspec struct {
|
||||
Cidr string `json:"cidr"`
|
||||
Gateway string `json:"gateway"`
|
||||
}
|
||||
|
||||
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
|
||||
|
||||
// NetworkList is a list of Network resources
|
||||
type NetworkList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata"`
|
||||
|
||||
Items []Network `json:"items"`
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面这部分代码里,你可以看到Network类型定义方法跟标准的Kubernetes对象一样,都包括了TypeMeta(API元数据)和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:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec NetworkSpec `json:"spec"`
|
||||
Status NetworkStatus `json:"status"`
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,+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,
|
||||
&Network{},
|
||||
&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="github.com/resouer/k8s-controller-custom-resource"
|
||||
# API Group
|
||||
$ CUSTOM_RESOURCE_NAME="samplecrd"
|
||||
# API Version
|
||||
$ CUSTOM_RESOURCE_VERSION="v1"
|
||||
|
||||
# 安装k8s.io/code-generator
|
||||
$ go get -u k8s.io/code-generator/...
|
||||
$ cd $GOPATH/src/k8s.io/code-generator
|
||||
|
||||
# 执行代码自动生成,其中pkg/client是生成目标目录,pkg/apis是类型定义目录
|
||||
$ ./generate-groups.sh all "$ROOT_PACKAGE/pkg/client" "$ROOT_PACKAGE/pkg/apis" "$CUSTOM_RESOURCE_NAME:$CUSTOM_RESOURCE_VERSION"
|
||||
|
||||
```
|
||||
|
||||
代码生成工作完成之后,我们再查看一下这个项目的目录结构:
|
||||
|
||||
```
|
||||
$ tree
|
||||
.
|
||||
├── controller.go
|
||||
├── crd
|
||||
│ └── network.yaml
|
||||
├── example
|
||||
│ └── example-network.yaml
|
||||
├── main.go
|
||||
└── pkg
|
||||
├── apis
|
||||
│ └── samplecrd
|
||||
│ ├── constants.go
|
||||
│ └── v1
|
||||
│ ├── doc.go
|
||||
│ ├── register.go
|
||||
│ ├── types.go
|
||||
│ └── zz_generated.deepcopy.go
|
||||
└── 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对象的CRD(Custom 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: <none>
|
||||
...API Version: samplecrd.k8s.io/v1
|
||||
Kind: Network
|
||||
Metadata:
|
||||
...
|
||||
Generation: 1
|
||||
Resource Version: 468239
|
||||
...
|
||||
Spec:
|
||||
Cidr: 192.168.0.0/16
|
||||
Gateway: 192.168.0.1
|
||||
|
||||
```
|
||||
|
||||
当然 ,你也可以编写更多的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)
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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("Error running controller: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,这个main函数主要通过三步完成了初始化并启动一个自定义控制器的工作。
|
||||
|
||||
**第一步**:main函数根据我提供的Master配置(APIServer的地址端口和kubeconfig的路径),创建一个Kubernetes的client(kubeClient)和Network对象的client(networkClient)。
|
||||
|
||||
但是,如果我没有提供Master配置呢?
|
||||
|
||||
这时,main函数会直接使用一种名叫**InClusterConfig**的方式来创建这个client。这个方式,会假设你的自定义控制器是以Pod的方式运行在Kubernetes集群里的。
|
||||
|
||||
而我在第15篇文章[《深入解析Pod对象(二):使用进阶》](https://time.geekbang.org/column/article/40466)中曾经提到过,Kubernetes 里所有的Pod都会以Volume的方式自动挂载Kubernetes的默认ServiceAccount。所以,这个控制器就会直接使用默认ServiceAccount数据卷里的授权信息,来访问APIServer。
|
||||
|
||||
**第二步**:main函数为Network对象创建一个叫作InformerFactory(即:networkInformerFactory)的工厂,并使用它生成一个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对象的Informer(Network 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 := &Controller{
|
||||
kubeclientset: kubeclientset,
|
||||
networkclientset: networkclientset,
|
||||
networksLister: networkInformer.Lister(),
|
||||
networksSynced: networkInformer.Informer().HasSynced,
|
||||
workqueue: workqueue.NewNamedRateLimitingQueue(..., "Networks"),
|
||||
...
|
||||
}
|
||||
networkInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
|
||||
AddFunc: controller.enqueueNetwork,
|
||||
UpdateFunc: func(old, new interface{}) {
|
||||
oldNetwork := old.(*samplecrdv1.Network)
|
||||
newNetwork := new.(*samplecrdv1.Network)
|
||||
if oldNetwork.ResourceVersion == newNetwork.ResourceVersion {
|
||||
return
|
||||
}
|
||||
controller.enqueueNetwork(new)
|
||||
},
|
||||
DeleteFunc: controller.enqueueNetworkForDelete,
|
||||
return controller
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**我前面在main函数里创建了两个client(kubeclientset和networkclientset),然后在这段代码里,使用这两个client和前面创建的Informer,初始化了自定义控制器。**
|
||||
|
||||
值得注意的是,在这个自定义控制器里,我还设置了一个工作队列(work queue),它正是处于示意图中间位置的WorkQueue。这个工作队列的作用是,负责同步Informer和控制循环之间的数据。
|
||||
|
||||
>
|
||||
实际上,Kubernetes项目为我们提供了很多个工作队列的实现,你可以根据需要选择合适的库直接使用。
|
||||
|
||||
|
||||
**然后,我为networkInformer注册了三个Handler(AddFunc、UpdateFunc和DeleteFunc),分别对应API对象的“添加”“更新”和“删除”事件。而具体的处理操作,都是将该事件对应的API对象加入到工作队列中。**
|
||||
|
||||
需要注意的是,实际入队的并不是API对象本身,而是它们的Key,即:该API对象的`<namespace>/<name>`。
|
||||
|
||||
而我们后面即将编写的控制循环,则会不断地从这个工作队列里拿到这些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 <-chan struct{}) error {
|
||||
...
|
||||
if ok := cache.WaitForCacheSync(stopCh, c.networksSynced); !ok {
|
||||
return fmt.Errorf("failed to wait for caches to sync")
|
||||
}
|
||||
|
||||
...
|
||||
for i := 0; i < threadiness; i++ {
|
||||
go wait.Until(c.runWorker, time.Second, stopCh)
|
||||
}
|
||||
|
||||
...
|
||||
return nil
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,启动控制循环的逻辑非常简单:
|
||||
|
||||
- 首先,等待Informer完成一次本地缓存的数据同步操作;
|
||||
- 然后,直接通过goroutine启动一个(或者并发启动多个)“无限循环”的任务。
|
||||
|
||||
而这个“无限循环”任务的每一个循环周期,执行的正是我们真正关心的业务逻辑。
|
||||
|
||||
所以接下来,我们就来编写这个自定义控制器的业务逻辑,它的主要内容如下所示:
|
||||
|
||||
```
|
||||
func (c *Controller) runWorker() {
|
||||
for c.processNextWorkItem() {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) processNextWorkItem() bool {
|
||||
obj, shutdown := c.workqueue.Get()
|
||||
|
||||
...
|
||||
|
||||
err := func(obj interface{}) error {
|
||||
...
|
||||
if err := c.syncHandler(key); err != nil {
|
||||
return fmt.Errorf("error syncing '%s': %s", key, err.Error())
|
||||
}
|
||||
|
||||
c.workqueue.Forget(obj)
|
||||
...
|
||||
return nil
|
||||
}(obj)
|
||||
|
||||
...
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Controller) syncHandler(key string) error {
|
||||
|
||||
namespace, name, err := cache.SplitMetaNamespaceKey(key)
|
||||
...
|
||||
|
||||
network, err := c.networksLister.Networks(namespace).Get(name)
|
||||
if err != nil {
|
||||
if errors.IsNotFound(err) {
|
||||
glog.Warningf("Network does not exist in local cache: %s/%s, will delete it from Neutron ...",
|
||||
namespace, name)
|
||||
|
||||
glog.Warningf("Network: %s/%s does not exist in local cache, will delete it from Neutron ...",
|
||||
namespace, name)
|
||||
|
||||
// FIX ME: call Neutron API to delete this network by name.
|
||||
//
|
||||
// neutron.Delete(namespace, name)
|
||||
|
||||
return nil
|
||||
}
|
||||
...
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
glog.Infof("[Neutron] Try to process network: %#v ...", network)
|
||||
|
||||
// FIX ME: Do diff().
|
||||
//
|
||||
// actualNetwork, exists := neutron.Get(namespace, name)
|
||||
//
|
||||
// if !exists {
|
||||
// neutron.Create(namespace, name)
|
||||
// } else if !reflect.DeepEqual(actualNetwork, network) {
|
||||
// neutron.Update(namespace, name)
|
||||
// }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,在这个执行周期里(processNextWorkItem),我们**首先**从工作队列里出队(workqueue.Get)了一个成员,也就是一个Key(Network对象的: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里“获取”(List)Network对象时,并不能找到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: "192.168.0.0/16"
|
||||
gateway: "192.168.0.1"
|
||||
|
||||
$ 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: &v1.Network{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"example-network", GenerateName:"", Namespace:"default", ... ResourceVersion:"479015", ... Spec:v1.NetworkSpec{Cidr:"192.168.0.0/16", Gateway:"192.168.0.1"}} ...
|
||||
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: "192.168.1.0/16"
|
||||
gateway: "192.168.1.1"
|
||||
|
||||
```
|
||||
|
||||
可以看到,我把这个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: &v1.Network{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"example-network", GenerateName:"", Namespace:"default", ... ResourceVersion:"479062", ... Spec:v1.NetworkSpec{Cidr:"192.168.1.0/16", Gateway:"192.168.1.1"}} ...
|
||||
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的client(kubeClient)创建了一个工厂;
|
||||
|
||||
**然后**,我用跟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和你编写的控制循环之间,一定要使用一个工作队列来进行协作呢?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "watch", "list"]
|
||||
|
||||
```
|
||||
|
||||
首先,这个Role对象指定了它能产生作用的Namepace是:mynamespace。
|
||||
|
||||
Namespace是Kubernetes项目里的一个逻辑管理单位。不同Namespace的API对象,在通过kubectl命令进行操作的时候,是互相隔离开的。
|
||||
|
||||
比如,kubectl get pods -n mynamespace。
|
||||
|
||||
当然,这仅限于逻辑上的“隔离”,Namespace并不会提供任何实际的隔离或者多租户能力。而在前面文章中用到的大多数例子里,我都没有指定Namespace,那就是使用的是默认Namespace:default。
|
||||
|
||||
然后,这个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对象。
|
||||
|
||||
那么,**对于非Namespaced(Non-namespaced)对象(比如:Node),或者,某一个Role想要作用于所有的Namespace的时候,我们又该如何去做授权呢?**
|
||||
|
||||
这时候,我们就必须要使用ClusterRole和ClusterRoleBinding这两个组合了。这两个API对象的用法跟Role和RoleBinding完全一样。只不过,它们的定义里,没有了Namespace字段,如下所示:
|
||||
|
||||
```
|
||||
kind: ClusterRole
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
metadata:
|
||||
name: example-clusterrole
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["get", "watch", "list"]
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
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: ["get", "list", "watch", "create", "update", "patch", "delete"]
|
||||
|
||||
```
|
||||
|
||||
这些就是当前Kubernetes(v1.11)里能够对API对象进行的所有操作了。
|
||||
|
||||
类似地,Role对象的rules字段也可以进一步细化。比如,你可以只针对某一个具体的对象进行权限设置,如下所示:
|
||||
|
||||
```
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["configmaps"]
|
||||
resourceNames: ["my-config"]
|
||||
verbs: ["get"]
|
||||
|
||||
```
|
||||
|
||||
这个例子就表示,这条规则的“被作用者”,只对名叫“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: "409327"
|
||||
...
|
||||
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没有声明serviceAccountName,Kubernetes会自动在它的Namespace下创建一个名叫default的默认ServiceAccount,然后分配给这个Pod。
|
||||
|
||||
但在这种情况下,这个默认ServiceAccount并没有关联任何Role。也就是说,此时它有访问APIServer的绝大多数权限。当然,这个访问所需要的Token,还是默认ServiceAccount对应的Secret对象为它提供的,如下所示。
|
||||
|
||||
```
|
||||
$kubectl describe sa default
|
||||
Name: default
|
||||
Namespace: default
|
||||
Labels: <none>
|
||||
Annotations: <none>
|
||||
Image pull secrets: <none>
|
||||
Mountable secrets: default-token-s8rbq
|
||||
Tokens: default-token-s8rbq
|
||||
Events: <none>
|
||||
|
||||
$ kubectl get secret
|
||||
NAME TYPE DATA AGE
|
||||
kubernetes.io/service-account-token 3 82d
|
||||
|
||||
$ kubectl describe secret default-token-s8rbq
|
||||
Name: default-token-s8rbq
|
||||
Namespace: default
|
||||
Labels: <none>
|
||||
Annotations: kubernetes.io/service-account.name=default
|
||||
kubernetes.io/service-account.uid=ffcb12b2-917f-11e8-abde-42010aa80002
|
||||
|
||||
Type: kubernetes.io/service-account-token
|
||||
|
||||
Data
|
||||
====
|
||||
ca.crt: 1025 bytes
|
||||
namespace: 7 bytes
|
||||
token: <TOKEN数据>
|
||||
|
||||
```
|
||||
|
||||
可以看到,Kubernetes会自动为默认ServiceAccount创建并绑定一个特殊的Secret:它的类型是`kubernetes.io/service-account-token`;它的Annotation字段,声明了`kubernetes.io/service-account.name=default`,即这个Secret会跟同一Namespace下名叫default的ServiceAccount进行绑定。
|
||||
|
||||
所以,在生产环境中,我强烈建议你为所有Namespace下的默认ServiceAccount,绑定一个只读权限的Role。这个具体怎么做,就当作思考题留给你了。
|
||||
|
||||
除了前面使用的“用户”(User),Kubernetes还拥有“用户组”(Group)的概念,也就是一组“用户”的意思。如果你为Kubernetes配置了外部认证服务的话,这个“用户组”的概念就会由外部认证服务提供。
|
||||
|
||||
而对于Kubernetes的内置“用户”ServiceAccount来说,上述“用户组”的概念也同样适用。
|
||||
|
||||
实际上,一个ServiceAccount,在Kubernetes里对应的“用户”的名字是:
|
||||
|
||||
```
|
||||
system:serviceaccount:<Namespace名字>:<ServiceAccount名字>
|
||||
|
||||
```
|
||||
|
||||
而它对应的内置“用户组”的名字,就是:
|
||||
|
||||
```
|
||||
system:serviceaccounts:<Namespace名字>
|
||||
|
||||
```
|
||||
|
||||
**这两个对应关系,请你一定要牢记。**
|
||||
|
||||
比如,现在我们可以在RoleBinding里定义如下的subjects:
|
||||
|
||||
```
|
||||
subjects:
|
||||
- kind: Group
|
||||
name: system:serviceaccounts:mynamespace
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
|
||||
```
|
||||
|
||||
这就意味着这个Role的权限规则,作用于mynamespace里的所有ServiceAccount。这就用到了“用户组”的概念。
|
||||
|
||||
而下面这个例子:
|
||||
|
||||
```
|
||||
subjects:
|
||||
- kind: Group
|
||||
name: system:serviceaccounts
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
|
||||
```
|
||||
|
||||
就意味着这个Role的权限规则,作用于整个系统里的所有ServiceAccount。
|
||||
|
||||
最后,值得一提的是,**在Kubernetes中已经内置了很多个为系统保留的ClusterRole,它们的名字都以system:开头**。你可以通过kubectl get clusterroles查看到它们。
|
||||
|
||||
一般来说,这些系统ClusterRole,是绑定给Kubernetes系统组件对应的ServiceAccount使用的。
|
||||
|
||||
比如,其中一个名叫system:kube-scheduler的ClusterRole,定义的权限规则是kube-scheduler(Kubernetes的调度器组件)运行所需要的必要权限。你可以通过如下指令查看这些权限的列表:
|
||||
|
||||
```
|
||||
$ 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下的默认ServiceAccount(default ServiceAccount),绑定一个只读权限的Role呢?请你提供ClusterRoleBinding(或者RoleBinding)的YAML文件。
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
@@ -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的CR(Custom 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 Resource(CR)。而它的内容非常简单,如下所示:
|
||||
|
||||
```
|
||||
apiVersion: "etcd.database.coreos.com/v1beta2"
|
||||
kind: "EtcdCluster"
|
||||
metadata:
|
||||
name: "example-etcd-cluster"
|
||||
spec:
|
||||
size: 3
|
||||
version: "3.2.13"
|
||||
|
||||
```
|
||||
|
||||
可以看到,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参数是由“<节点名字>=<节点地址>”格式组成的一个数组。而上面这个配置的意思就是,当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:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
Spec ClusterSpec `json:"spec"`
|
||||
Status ClusterStatus `json:"status"`
|
||||
}
|
||||
|
||||
type ClusterSpec struct {
|
||||
// Size is the expected size of the etcd cluster.
|
||||
// The etcd-operator will eventually make the size of the running
|
||||
// cluster equal to the expected size.
|
||||
// The vaild range of the size is from 1 to 7.
|
||||
Size int `json:"size"`
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到,EtcdCluster是一个有Status字段的CRD。在这里,我们可以不必关心ClusterSpec里的其他字段,只关注Size(即:Etcd集群的大小)字段即可。
|
||||
|
||||
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, &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-cluster)里,Etcd 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 <Pod名字>;每次删除节点的时候,则会执行etcdctl member remove <Pod名字>。这些操作,其实就会更新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 = <region>
|
||||
|
||||
# 然后,将上述授权信息制作成一个Secret
|
||||
$ kubectl create secret generic aws --from-file=$AWS_DIR/credentials --from-file=$AWS_DIR/config
|
||||
|
||||
# 使用上述S3的访问信息,创建一个EtcdBackup对象
|
||||
$ sed -e 's|<full-s3-path>|mybucket/etcd.backup|g' \
|
||||
-e 's|<aws-secret>|aws|g' \
|
||||
-e 's|<etcd-cluster-endpoints>|"http://example-etcd-cluster-client:2379"|g' \
|
||||
example/etcd-backup-operator/backup_cr.yaml \
|
||||
| kubectl create -f -
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,每当你创建一个EtcdBackup对象([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|<full-s3-path>|mybucket/etcd.backup|g' \
|
||||
-e 's|<aws-secret>|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性能瓶颈的原因主要在哪里么?
|
||||
|
||||
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user