深入剖析Kubernetes
第一章:从容器到容器云,谈谈 k8s 的本质
K8s全局架构:
在 K8s 项目中,kubelet 主要负责同容器运行时打交道。而这个交互所依赖的,是一个称为 CRI(Container Runtime Interface)
的远程调用接口,这个接口定义了容器运行时的各项核心操作,比如:启动一个容器所需要的所有参数。
容器运行时接口(CRI)是 kubelet 和容器运行时之间通信的主要协议。
K8s CRI 定义了主要 gRPC 协议,用于集群组件 kubelet 和容器运行时之间的通信。
而具体的容器运行时,比如 Docker 项目,则一般通过 OCI 这个容器运行时规范同底层的 Linux 操作系统进行交互,即:把 CRI 请求翻译成对 Linux 操作系统的调用(操作 Linux Namespace 和 Cgroups 等)。
此外 kubelet 还通过 gRPC 同一个叫做 Device Plugin 的插件进行交互。这个插件,是 K8s 项目用来管理 GPU 等宿主机物理设备的主要组件。
而 kubelet 的另一个重要功能,就是调用网络插件和存储插件为容器配置网络和持久化存储 这两个插件与 kubelet 进行交互的接口,分别是 CNI(Container Networking Interface) 和CSI(Container Storage Interface)。
K8s 项目最主要的设计思想是,从更宏观的角度,以统一的方式来定义任务之间的各种关系,并且为将来支持更多种类的关系留有余地
K8s 核心功能全景图:

按照这幅图的线索,我们从容器这个最基础的概念出发,首先遇到了容器间“紧密协作”关系的难题,于是就扩展到了 Pod;有了 Pod 之后,我们希望能一次启动多个应用的实例,这样就需要 Deployment 这个 Pod 的多实例管理器;而有了这样一组相同的 Pod 后,我们又需要通过一个固定的 IP 地址和端口以负载均衡的方式访问它,于是就有了 Service。
可是,如果现在两个不同 Pod 之间不仅有“访问关系”,还要求在发起时加上授权信息。最典型的例子就是 Web 应用对数据库访问时需要 Credential(数据库的用户名和密码)信息。那么,在 Kubernetes 中这样的关系又如何处理呢?
Kubernetes 项目提供了一种叫作 Secret 的对象,它其实是一个保存在 Etcd 里的键值对数据。这样,你把 Credential 信息以 Secret 的方式存在 Etcd 里,Kubernetes 就会在你指定的 Pod(比如,Web 应用的 Pod)启动时,自动把 Secret 里的数据以 Volume 的方式挂载到容器里。这样,这个 Web 应用就可以访问数据库了。
除了应用与应用之间的关系外,应用运行的形态是影响 “如何容器化这个应用”的第二个重要因素。
为此,Kubernetes 定义了新的、基于 Pod 改进后的对象。比如 Job,用来描述一次性运行的 Pod(比如,大数据任务);再比如 DaemonSet,用来描述每个宿主机上必须且只能运行一个副本的守护进程服务;又比如 CronJob,则用于描述定时任务等等。
第二章:容器编排和 K8s 作业管理
1.为什么我们需要 Pod
在一个操作系统中,进程是以进程组的形式组织在一起的。这些进程(或线程)可以共享文件、信号、数据内存甚至部分代码,从而紧密合作共同完成一个程序的职责。而 K8s 的 Pod 就是把进程组这个概念映射到了 Pod 中。
容器之间共享文件,相互依赖的紧密协作,可以称为 “超亲密关系”。拥有这种关系的容器组,就需要尽量放置在同一个 Pod 中。其他一些关系比较远的,比如 PHP 和 Mysql 虽然有访问关系,但是没必要,也不应该部署在同一个容器组内。
所以,为了满足进程组之间频繁的数据交换、使用 localhost 或者 Socket 文件进行本地通信,需要共享某些 Linux Namespace 这种需求,就可以使用 Pod 存储这写进程组,或者说容器组。
1.1容器设计模式
1.1.1 Pod 的实现原理
首先,我们应该知道的是,Pod 只是一个逻辑观念
也就是说,Kubernetes 真正处理的,还是宿主机操作系统上 Linux 容器的 Namespace 和 Cgroups,而并不存在一个所谓的 Pod 的边界或者隔离环境。
那么,Pod 又是怎么被“创建”出来的呢?
答案是:Pod,其实是一组共享了某些资源的容器。
具体的说:Pod 里的所有资源,共享同一个 Network Namespace,并且还可以声明共享同一个 Volume
为了实现共享同一个 Network Namespace ,且保持用户容器之间的关系是对等而不是拓扑关系,Pod 的实现借助了一个中间容器:Infra 容器。在一个 Pod 中,Infra 容器永远是第一个被创建的,而其他用户定义的容器,则是通过 Join Network Namespace 的方法,与 Infra 容器关联到一起。这样虽然用户容器跟 Infra 容器是拓扑关系,但是用户容器之间是平等的。示意图:

Infra 会占用极少的资源:它使用了一个特殊镜像:k8s.gcr.io/pause。这个镜像是用汇编语言编写的,永远“暂停”的容器。
在 Infra 容器创建了 Network Namespace 后,用户容器就可以加入这个 ns 中了,可以在宿主机上的对应进程的 ns 目录下查看链接文件,发现他们都是指向同一个值:

这也就意味着,对于 Pod 里的容器 A 和容器 B 来说:
- 它们可以直接使用 localhost 进行通信;
- 它们看到的网络设备跟 Infra 容器看到的完全一样;
- 一个 Pod 只有一个 IP 地址,也就是这个 Pod 的 Network Namespace 对应的 IP 地址;
- 当然,其他的所有网络资源,都是一个 Pod 一份,并且被该 Pod 中的所有容器共享;
- Pod 的生命周期只跟 Infra 容器一致,而与容器 A 和 B 无关。
而对于同一个 Pod 里面的所有用户容器来说,它们的进出流量,也可以认为都是通过 Infra 容器完成的。这一点很重要,因为将来如果你要为 Kubernetes 开发一个网络插件时,应该重点考虑的是如何配置这个 Pod 的 Network Namespace,而不是每一个用户容器如何使用你的网络配置,这是没有意义的。
所以,当你想要给多个容器配置共享 Volume 就很简单了,K8s 项目只要把所有 Volume 的定义都设计在 Pod 层级即可。
例子:
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 文件的原因。
1.1.2容器的设计模式:
Pod 这种 “超亲密关系”容器的设计思想,实际上就是希望,当用户想在一个容器里跑多个功能并不相关的应用时,应该优先考虑它们是不是应该被描述为一个 Pod 里的多个容器。
比如,容器的日志收集:
比如,我现在有一个应用,需要不断地把日志文件输出到容器的 /var/log 目录中。
这时,我就可以把一个 Pod 里的 Volume 挂载到应用容器的 /var/log 目录上。
然后,我在这个 Pod 里同时运行一个 sidecar 容器,它也声明挂载同一个 Volume 到自己的 /var/log 目录上。
这样,接下来 sidecar 容器就只需要做一件事儿,那就是不断地从自己的 /var/log 目录里读取日志文件,转发到 MongoDB 或者 Elasticsearch 中存储起来。这样,一个最基本的日志收集工作就完成了。
Sidecar:是指我们可以在一个 Pod 中,启动一个辅助容器,来完成一些独立于主进程(主容器)之外的工作。
而这个例子中 sidecare 的主要工作就是使用共享的 Volume 来完成对文件的操作。
TIPS:
apiVersion: v1 kind: Pod metadata: name: javaweb-2 spec: initContainers: #在 Pod 中,所有 Init Container 定义的容器,都会比 spec.containers 定义的用户容器先启动。并且, - image: geektime/sample:v2#Init Container 容器会按顺序逐一启动,而直到它们都启动并且退出了,用户容器才会启动。 name: war command: ["cp", "/sample.war", "/app"] volumeMounts: - mountPath: /app name: app-volume
总结:
容器绝不是虚拟机,或者说 Pod 才是真正的虚拟机,容器是一个个进程。而运行在 Pod 里的容器们,可以是一堆关系亲密的进程成的进程组,也可以是相互独立的进程。
2.深入解析Pod对象(一):基本概念
宽泛地说,我们可以把 Pod 理解为虚拟机,容器理解为进程。所以,Pod 和 Container 的属性差别就很容易得知:
凡是调度、网络、存储以及安全相关的属性都是 Pod 级别的配置
还有跟容器的 Linux Namespace 相关的属性,也是 Pod 级别的
2.1 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"
...
注意,在 K8s 项目中,如果想修改 hosts 文件,必须通过这种方法,否则直接修改的话,Pod 重建后,kubelet 会自动覆盖被修改的内容
2.深入解析Pod对象(二):使用进阶
在 Kubernetes 中,有几种特殊的 Volume,他们存在的意义不是为了存放容器里的数据,也不是用来进行容器和宿主机之间的数据交换。这些特殊 Volume 的作用,是为容器提供预先定义好的数据。这些 Volume 的信息仿佛是被 “投射”(Project)进入容器中的。这就是 Projected Volume 的含义。
目前,K8s 支持的 Projected Volume 一共有 4 种:
- Secret
- ConfigMap
- Downward API
- ServiceAccountToken
2.1 Secret
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,一旦其对应的 Etcd里的数据被更新,这些 Volume 里的文件内容同样也会被更新。这是 Kubelet 组件在定时维护这些 Volume。
需要注意的是,这个更新可能会有一定的延时。所以在编写应用程序时,在发起数据库连接的代码处写好重试和超时的逻辑,绝对是个好习惯。
2.2 ConfigMap
与 Secret 类似的是 ConfigMap,它与 Secret 的区别在于,ConfigMap 保存的是不需要加密的、应用所需的配置信息。而 ConfigMap 的用法几乎与 Secret 完全相同:你可以使用 kubectl create configmap 从文件或者目录创建 ConfigMap,也可以直接编写 ConfigMap 对象的 YAML 文件。
2.3 Downward API
接下来是 Downward API,它的作用是:让 Pod 里的容器能够直接获取到这个 Pod API 对象本身的信息。Downward API 获取到的信息,一定是 Pod 里的容器进程启动之前就能确定下来的信息。
而如果你想要获取 Pod 容器运行后才会出现的信息,比如,容器进程的 PID,那就肯定不能使用 Downward API 了,而应该考虑在 Pod 里定义一个 sidecar 容器。
2.4 ServiceAccountToken
如何在一个 Pod 中创建一个 K8s Client 来访问 k8s API?
首先,需要解决 API Server 授权的问题:
Service Account 对象的作用:就是 k8s 内置的一种 “服务账户”,它是 K8s 进行权限分配的对象。比如 Service Account A,只允许对 K8s API 进行 Get 操作,而 Service Account B,具有所有权限。
类似这样 Service Account 的授权信息和文件,实际上保存在它所绑定的一个特殊的 Secret 对象里的,这个对象就叫 ServiceAccountToken。任何运行在 Kubernetes 集群上的应用,都必须使用这个 ServiceAccountToken 里保存的授权信息,也就是 Token,才可以合法地访问 API Server。
而这个 ServiceAccountToken 就是依赖 Projected Volume 机制自动挂载在一个固定目录里。
这个挂载目录一般是固定的:/var/run/secrets/kubernetes.io/serviceaccount
所以,只要你的程序直接加载这些授权文件,就可以访问并操作 K8s API。
这种把 K8s 客户端以容器的方式运行在集群中,然后使用 default Service Account 自动授权的方式,被称为“InClusterConfig”.
2.5 容器健康检查和恢复机制
你可以给容器定义一个健康检查探针(probe)。定义后,kubelet 会根据这个 probe 的返回值决定这个容器的状态, 而不是直接以容器进行时是否运行作为依据。这种机制是生产环境中保证应用健康存活的重要手段。
健康检查的字段为:livenessProbe
还有一个叫 readinessProbe 的字段,虽然用法类似,但作用不同。readinessProbe 检查结果的成功与否,决定这个 Pod 是否能通过 Service 的方式访问到,而不影响 Pod 的生命周期。
下面是 πmini里的coreDNS Pod 的详细信息:里面包括了这两种探针的信息:
iboot [/var/run]# kubectl describe pod coredns-d76bd69b-s29k7 -n kube-system
Name: coredns-d76bd69b-s29k7
Namespace: kube-system
Priority: 2000000000
Priority Class Name: system-cluster-critical
Node: iboot/192.168.64.51
Start Time: Thu, 29 Dec 2022 09:27:35 +0800
Labels: k8s-app=kube-dns
pod-template-hash=d76bd69b
Annotations: <none>
Status: Running
IP: 10.42.0.148
IPs:
IP: 10.42.0.148
Controlled By: ReplicaSet/coredns-d76bd69b
Containers:
coredns:
Container ID: containerd://513adc3e13d27a1509bdea0411bd56b70e5d532f9d0f28d95f55427e4399c78a
Image: rancher/mirrored-coredns-coredns:1.9.1
Image ID: docker.io/rancher/mirrored-coredns-coredns@sha256:35e38f3165a19cb18c65d83334c13d61db6b24905f45640aa8c2d2a6f55ebcb0
Ports: 53/UDP, 53/TCP, 9153/TCP
Host Ports: 0/UDP, 0/TCP, 0/TCP
Args:
-conf
/etc/coredns/Corefile
State: Running
Started: Tue, 10 Jan 2023 10:11:32 +0800
Last State: Terminated
Reason: Unknown
Exit Code: 255
Started: Mon, 09 Jan 2023 10:13:39 +0800
Finished: Tue, 10 Jan 2023 10:11:16 +0800
Ready: True
Restart Count: 7
Limits:
memory: 170Mi
Requests:
cpu: 100m
memory: 70Mi
Liveness: http-get http://:8080/health delay=60s timeout=1s period=10s #success=1 #failure=3
Readiness: http-get http://:8181/ready delay=0s timeout=1s period=2s #success=1 #failure=3
从上面可以看到,coreDNS的健康检查是通过监听容器的8080端口的/helth路径来确认是否健康,在执行第一次探测前应该等待 60 秒,探测超时后应该等待1s,执行探测的时间间隔为 2s。
在实践中可以发现,当健康检查报告容器已经不健康后,会自动重建一个 Pod 来替换不健康的 Pod,这也是 K8s 的 Restart 机制。
而在这个过程中,这个 Pod 的 status 一直是 Running 的。这个过程就是 k8s 的 Pod 恢复机制。 也叫 restartPolicy。
restartPolicy 是 Pod 的 Spec 部分的一个标准字段(pod.spec.restartPolicy),默认值为 Always,即:任何时候这个容器发生了异常,就一定会重启(重新创建)。
其他状态:
- Always:在任何情况下,只要容器不在运行状态,就自动重启容器;
- OnFailure: 只在容器 异常时才自动重启容器;
- Never: 从来不重启容器。
第三章:控制器模型
k8s 中控制器的主要作用就是将被编排的对象从实际状态不断调整到期望状态。
而控制器对某个对象的两个状态:实际状态(从 etcd 中读取)和期望状态(从 yaml 文件中读取)进行对比的过程通常被叫做调谐(Reconcile) 这个调谐的过程,则被称作"Reconcile Loop"(调谐循环)或者 "Sync Loop"(同步循环)。

浙公网安备 33010602011771号