Loading

[05] 容器基础(下)

《深入剖析 Kubernetes》张磊

1. 重新认识 Docker 容器

在前面的分享中,我分别从 Linux Namespace 的隔离能力、Linux Cgroups 的限制能力,以及基于 rootfs 的文件系统三个角度,为你剖析了一个 Linux 容器的核心实现原理。

【注】之所以要强调 Linux 容器,是因为比如 Docker on Mac,以及 Windows Docker(Hyper-V 实现),实际上是基于虚拟化技术实现的,跟我们这个专栏着重介绍的 Linux 容器完全不同。

1.1 应用部署案例

而在今天的分享中,我会通过一个实际案例,对“白话容器基础”系列的所有内容做一次深入的总结和扩展。在开始实践之前,你需要准备一台 Linux 机器,并安装 Docker。这个流程我就不再赘述了。

这一次,我要用 Docker 部署一个用 Python 编写的 Web 应用。这个应用的代码部分(app.py)非常简单:

from flask import Flask
import socket
import os

app = Flask(__name__)

@app.route('/')
def hello():
    html = "<h3>Hello {name}!</h3>" \
           "<b>Hostname:</b> {hostname}<br/>"
    return html.format(name=os.getenv("NAME", "world"), hostname=socket.gethostname())

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=80)

在这段代码中,我使用 Flask 框架启动了一个 Web 服务器,而它唯一的功能是:如果当前环境中有“NAME”这个环境变量,就把它打印在“Hello”后,否则就打印“Hello world”,最后再打印出当前环境的 hostname。

这个应用的依赖,则被定义在了同目录下的 requirements.txt 文件里,内容如下所示:

$ cat requirements.txt
Flask

而将这样一个应用容器化的第一步,是制作容器镜像。

不过,相较于我之前介绍的制作 rootfs 的过程,Docker 为你提供了一种更便捷的方式,叫作 Dockerfile,如下所示。

# 使用官方提供的 Python 开发镜像作为基础镜像
FROM python:2.7-slim

# 将工作目录切换为 /app
WORKDIR /app

# 将当前目录下的所有内容复制到 /app 下
ADD . /app

# 使用 pip 命令安装这个应用所需要的依赖
RUN pip install --trusted-host pypi.python.org -r requirements.txt

# 允许外界访问容器的 80 端口
EXPOSE 80

# 设置环境变量
ENV NAME World

# 设置容器进程为:python app.py,即:这个 Python 应用的启动命令
CMD ["python", "app.py"]

通过这个文件的内容,你可以看到 Dockerfile 的设计思想,是使用一些标准的原语(即大写高亮的词语),描述我们所要构建的 Docker 镜像。并且这些原语,都是按顺序处理的。

比如 FROM 原语,指定了“python:2.7-slim”这个官方维护的基础镜像,从而免去了安装 Python 等语言环境的操作。否则,这一段我们就得这么写了:

FROM ubuntu:latest
RUN apt-get update -yRUN apt-get install -y python-pip python-dev build-essential
...

其中,RUN 原语就是在容器里执行 shell 命令的意思。

而 WORKDIR,意思是在这一句之后,Dockerfile 后面的操作都以这一句指定的 /app 目录作为当前目录。

所以,到了最后的 CMD,意思是 Dockerfile 指定 python app.py 为这个容器的进程。这里,app.py 的实际路径是 /app/app.py。所以,CMD ["python", "app.py"] 等价于 docker run & python app.py

另外,在使用 Dockerfile 时,你可能还会看到一个叫作 ENTRYPOINT 的原语。实际上,它和 CMD 都是 Docker 容器进程启动所必需的参数,完整执行格式是 “ENTRYPOINT CMD”。

但是,默认情况下,Docker 会为你提供一个隐含的 ENTRYPOINT,即:/bin/sh -c。所以,在不指定 ENTRYPOINT 时,比如在我们这个例子里,实际上运行在容器里的完整进程是:/bin/sh -c "python app.py",即 CMD 的内容就是 ENTRYPOINT 的参数。

需要注意的是,Dockerfile 里的原语并不都是指对容器内部的操作。就比如 ADD,它指的是把当前目录(即 Dockerfile 所在的目录)里的文件,复制到指定容器内的目录当中。

读懂这个 Dockerfile 之后,我再把上述内容,保存到当前目录里一个名叫“Dockerfile”的文件中:

$ ls
Dockerfile  app.py   requirements.txt

接下来,我就可以让 Docker 制作这个镜像了,在当前目录执行:

$ docker build -t helloworld .

其中,-t 的作用是给这个镜像加一个 Tag,即:起一个好听的名字。docker build 会自动加载当前目录下的 Dockerfile 文件,然后按照顺序,执行文件中的原语。而这个过程,实际上可以等同于 Docker 使用基础镜像启动了一个容器,然后在容器中依次执行 Dockerfile 中的原语。

需要注意的是,Dockerfile 中的每个原语执行后,都会生成一个对应的镜像层。即使原语本身并没有明显地修改文件的操作(比如,ENV 原语),它对应的层也会存在。只不过在外界看来,这个层是空的

docker build 操作完成后,我可以通过 docker images 命令查看结果:

$ docker image ls

REPOSITORY            TAG                 IMAGE ID
helloworld         latest              653287cdf998

通过这个镜像 ID,你就可以使用之前讲过的方法,查看这些新增的层在 AuFS 路径下对应的文件和目录了。

接下来,我使用这个镜像,通过 docker run 命令启动容器:

$ docker run -p 4000:80 helloworld

在这一句命令中,镜像名 helloworld 后面,我什么都不用写,因为在 Dockerfile 中已经指定了 CMD。否则,我就得把进程的启动命令加在后面:

$ docker run -p 4000:80 helloworld python app.py

容器启动之后,我可以使用 docker ps 命令看到:

$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED
4ddf4638572d        helloworld       "python app.py"     10 seconds ago

同时,我已经通过 -p 4000:80 告诉了 Docker,请把容器内的 80 端口映射在宿主机的 4000 端口上。

这样做的目的是,只要访问宿主机的 4000 端口,我就可以看到容器里应用返回的结果:

$ curl http://localhost:4000
<h3>Hello World!</h3><b>Hostname:</b> 4ddf4638572d<br/>

否则,我就得先用 docker inspect 命令查看容器的 IP 地址,然后访问“http://< 容器 IP 地址 >:80”才可以看到容器内应用的返回。

至此,我已经使用容器完成了一个应用的开发与测试,如果现在想要把这个容器的镜像上传到 DockerHub 上分享给更多的人,我要怎么做呢?

1.2 docker push

为了能够上传镜像,我首先需要注册一个 Docker Hub 账号,然后使用 docker login 命令登录。

接下来,我要用 docker tag 命令给容器镜像起一个完整的名字:

$ docker tag helloworld geektime/helloworld:v1

其中,geektime 是我在 Docker Hub 上的用户名,它的“学名”叫镜像仓库(Repository);“/”后面的 helloworld 是这个镜像的名字,而“v1”则是我给这个镜像分配的版本号。

然后,我执行 docker push:

$ docker push geektime/helloworld:v1

这样,我就可以把这个镜像上传到 Docker Hub 上了。

此外,我还可以使用 docker commit 指令,把一个正在运行的容器,直接提交为一个镜像。一般来说,需要这么操作原因是:这个容器运行起来后,我又在里面做了一些操作,并且要把操作结果保存到镜像里,比如:

$ docker exec -it 4ddf4638572d /bin/sh
# 在容器内部新建了一个文件
root@4ddf4638572d:/app# touch test.txt
root@4ddf4638572d:/app# exit

# 将这个新建的文件提交到镜像中保存
$ docker commit 4ddf4638572d geektime/helloworld:v2

1.3 docker exec

这里,我使用了 docker exec 命令进入到了容器当中。在了解了 Linux Namespace 的隔离机制后,你应该会很自然地想到一个问题:docker exec 是怎么做到进入容器里的呢?

实际上,Linux Namespace 创建的隔离空间虽然看不见摸不着,但一个进程的 Namespace 信息在宿主机上是确确实实存在的,并且是以一个文件的方式存在。

比如,通过如下指令,你可以看到当前正在运行的 Docker 容器的进程号(PID)是 25686:

$ docker inspect --format '{{ .State.Pid }}'  4ddf4638572d
25686

这时,你可以通过查看宿主机的 proc 文件,看到这个 25686 进程的所有 Namespace 对应的文件:

$ ls -l  /proc/25686/ns
total 0
lrwxrwxrwx 1 root root 0 Aug 13 14:05 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 ipc -> ipc:[4026532278]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 mnt -> mnt:[4026532276]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 net -> net:[4026532281]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid_for_children -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 uts -> uts:[4026532277]

可以看到,一个进程的每种 Linux Namespace,都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件上。

有了这样一个可以“hold 住”所有 Linux Namespace 的文件,我们就可以对 Namespace 做一些很有意义事情了,比如:加入到一个已经存在的 Namespace 当中。

这也就意味着:一个进程,可以选择加入到某个进程已有的 Namespace 当中,从而达到“进入”这个进程所在容器的目的,这正是 docker exec 的实现原理。

而这个操作所依赖的,乃是一个名叫 setns() 的 Linux 系统调用。它的调用方法,我可以用如下一段小程序为你说明:

#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)

int main(int argc, char *argv[]) {
    int fd;

    fd = open(argv[1], O_RDONLY);
    if (setns(fd, 0) == -1) {
        errExit("setns");
    }
    execvp(argv[2], &argv[2]);
    errExit("execvp");
}

这段代码功能非常简单:它一共接收两个参数,第 1 个参数是 argv[1],即当前进程要加入的 Namespace 文件的路径,比如 /proc/25686/ns/net;而第 2 个参数,则是你要在这个 Namespace 里运行的进程,比如 /bin/bash。

这段代码的的核心操作,则是通过 open() 系统调用打开了指定的 Namespace 文件,并把这个文件的描述符 fd 交给 setns() 使用。在 setns() 执行后,当前进程就加入了这个文件对应的 Linux Namespace 当中了。

现在,你可以编译执行一下这个程序,加入到容器进程(PID=25686)的 Network Namespace 中:

$ gcc -o set_ns set_ns.c
$ ./set_ns /proc/25686/ns/net /bin/bash
$ ifconfig
eth0      Link encap:Ethernet  HWaddr 02:42:ac:11:00:02
          inet addr:172.17.0.2  Bcast:0.0.0.0  Mask:255.255.0.0
          inet6 addr: fe80::42:acff:fe11:2/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:12 errors:0 dropped:0 overruns:0 frame:0
          TX packets:10 errors:0 dropped:0 overruns:0 carrier:0
	   collisions:0 txqueuelen:0
          RX bytes:976 (976.0 B)  TX bytes:796 (796.0 B)
lo        Link encap:Local Loopback
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
	  collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

正如上所示,当我们执行 ifconfig 命令查看网络设备时,我会发现能看到的网卡“变少”了:只有两个。而我的宿主机则至少有四个网卡。这是怎么回事呢?

实际上,在 setns() 之后我看到的这两个网卡,正是我在前面启动的 Docker 容器里的网卡。也就是说,我新创建的这个 /bin/bash 进程,由于加入了该容器进程(PID=25686)的 Network Namepace,它看到的网络设备与这个容器里是一样的,即:/bin/bash 进程的网络设备视图,也被修改了。

而一旦一个进程加入到了另一个 Namespace 当中,在宿主机的 Namespace 文件上,也会有所体现。

在宿主机上,你可以用 ps 指令找到这个 set_ns 程序执行的 /bin/bash 进程,其真实的 PID 是 28499:

# 在宿主机上
$ ps aux | grep /bin/bash
root     28499  0.0  0.0 19944  3612 pts/0    S    14:15   0:00 /bin/bash

这时,如果按照前面介绍过的方法,查看一下这个 PID=28499 的进程的 Namespace,你就会发现这样一个事实:

$ ls -l /proc/28499/ns/net
lrwxrwxrwx 1 root root 0 Aug 13 14:18 /proc/28499/ns/net -> net:[4026532281]

$ ls -l  /proc/25686/ns/net
lrwxrwxrwx 1 root root 0 Aug 13 14:05 /proc/25686/ns/net -> net:[4026532281]

在 /proc/[PID]/ns/net 目录下,这个 PID=28499 进程,与我们前面的 Docker 容器进程(PID=25686)指向的 Network Namespace 文件完全一样。这说明这两个进程,共享了这个名叫 net:[4026532281] 的 Network Namespace。

此外,Docker 还专门提供了一个参数,可以让你启动一个容器并“加入”到另一个容器的 Network Namespace 里,这个参数就是 -net,比如:docker run -it --net container:4ddf4638572d busybox ifconfig

这样,我们新启动的这个容器,就会直接加入到 ID=4ddf4638572d 的容器,也就是我们前面的创建的 Python 应用容器(PID=25686)的 Network Namespace 中。所以,这里 ifconfig 返回的网卡信息,跟我前面那个小程序返回的结果一模一样,你也可以尝试一下。

而如果我指定 -net=host,就意味着这个容器不会为进程启用 Network Namespace。这就意味着,这个容器拆除了 Network Namespace 的“隔离墙”,所以,它会和宿主机上的其他普通进程一样,直接共享宿主机的网络栈。这就为容器直接操作和使用宿主机网络提供了一个渠道。

转了一个大圈子,我其实是为你详细解读了 docker exec 这个操作背后,Linux Namespace 更具体的工作原理。

这种通过操作系统进程相关的知识,逐步剖析 Docker 容器的方法,是理解容器的一个关键思路,希望你一定要掌握。

1.4 docker commit

现在,我们再一起回到前面提交镜像的操作 docker commit 上来吧。

docker commit,实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组成了一个新的镜像。当然,下面这些只读层在宿主机上是共享的,不会占用额外的空间。

而由于使用了联合文件系统,你在容器里对镜像 rootfs 所做的任何修改,都会被操作系统先复制到这个可读写层,然后再修改。这就是所谓的:Copy-on-Write。

而正如前所说,Init 层的存在,就是为了避免你执行 docker commit 时,把 Docker 自己对 /etc/hosts 等文件做的修改,也一起提交掉。

有了新的镜像,我们就可以把它推送到 Docker Hub 上了:docker push geektime/helloworld:v2

你可能还会有这样的问题:我在企业内部,能不能也搭建一个跟 Docker Hub 类似的镜像上传系统呢?

当然可以,这个统一存放镜像的系统,就叫作 Docker Registry。感兴趣的话,你可以查看 Docker 的官方文档,以及 VMware 的 Harbor 项目。

1.5 Volume(数据卷)

最后,我再来讲解一下 Docker 项目另一个重要的内容:Volume(数据卷)。

前面我已经介绍过,容器技术使用了 rootfs 机制和 Mount Namespace,构建出了一个同宿主机完全隔离开的文件系统环境。这时候,我们就需要考虑这样两个问题:

  1. 容器里进程新建的文件,怎么才能让宿主机获取到?
  2. 宿主机上的文件和目录,怎么才能让容器里的进程访问到?

这正是 Docker Volume 要解决的问题:Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。

在 Docker 项目里,它支持两种 Volume 声明方式,可以把宿主机目录挂载进容器的 /test 目录当中:

$ docker run -v /test ...
$ docker run -v /home:/test ...

而这两种声明方式的本质,实际上是相同的:都是把一个宿主机的目录挂载进了容器的 /test 目录。

只不过,在第一种情况下,由于你并没有显式声明宿主机目录,那么 Docker 就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的 /test 目录上。而在第二种情况下,Docker 就直接把宿主机的 /home 目录挂载到容器的 /test 目录上。

那么,Docker 又是如何做到把一个宿主机上的目录或者文件,挂载到容器里面去呢?难道又是 Mount Namespace 的黑科技吗?

实际上,并不需要这么麻烦。

在之前的分享中,我已经介绍过,当容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 chroot(或者 pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。

而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。这个镜像的各个层,保存在 /var/lib/docker/aufs/diff 目录下,在容器进程启动后,它们会被联合挂载在 /var/lib/docker/aufs/mnt 目录中,这样容器所需的 rootfs 就准备好了。

所以,我们只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。

更重要的是,由于执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破。

【注】这里提到的“容器进程”,是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。

而这里要使用到的挂载技术,就是 Linux 的绑定挂载(bind mount)机制。它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

其实,如果你了解 Linux 内核的话,就会明白,绑定挂载实际上是一个 inode 替换的过程。在 Linux 操作系统中,inode 可以理解为存放文件内容的“对象”,而 dentry,也叫目录项,就是访问这个 inode 所使用的“指针”。

正如上图所示,mount --bind /home /test 会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry,重定向到了 /home 的 inode。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为何,一旦执行 umount 命令,/test 目录原先的内容就会恢复:因为修改真正发生在的,是 /home 目录里。

所以,在一个正确的时机,进行一次绑定挂载,Docker 就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。这样,进程在容器里对这个 /test 目录进行的所有操作,都实际发生在宿主机的对应目录(比如 /home 或者 /var/lib/docker/volumes/[VOLUME_ID]/_data)里,而不会影响容器镜像的内容。

那么,这个 /test 目录里的内容,既然挂载在容器 rootfs 的可读写层,它会不会被 docker commit 提交掉呢?

也不会。

这个原因其实我们前面已经提到过。容器的镜像操作,比如 docker commit,都是发生在宿主机空间的。而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录(/var/lib/docker/aufs/mnt/[可读写层 ID]/test),始终是空的。

不过,由于 Docker 一开始还是要创建 /test 这个目录作为挂载点,所以执行了 docker commit 之后,你会发现新产生的镜像里,会多出来一个空的 /test 目录。毕竟,新建目录操作,又不是挂载操作,Mount Namespace 对它可起不到“障眼法”的作用。

结合以上的讲解,我们现在来亲自验证一下。

首先,启动一个 helloworld 容器,给它声明一个 Volume,挂载在容器里的 /test 目录上:

$ docker run -d -v /test helloworld
cf53b766fa6f

容器启动之后,我们来查看一下这个 Volume 的 ID:

$ docker volume ls
DRIVER              VOLUME NAME
local               cb1c2f7221fa9b0971cc35f68aa1034824755ac44a034c0c0a1dd318838d3a6d

然后,使用这个 ID,可以找到它在 Docker 工作目录下的 volumes 路径:

$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/

这个 _data 文件夹,就是这个容器的 Volume 在宿主机上对应的临时目录了。

接下来,我们在容器的 Volume 里,添加一个文件 text.txt:

$ docker exec -it cf53b766fa6f /bin/sh
cd test/
touch text.txt

这时,我们再回到宿主机,就会发现 text.txt 已经出现在了宿主机上对应的临时目录里:

$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/
text.txt

可是,如果你在宿主机上查看该容器的可读写层,虽然可以看到这个 /test 目录,但其内容是空的(关于如何找到这个 AuFS 文件系统的路径,请参考我上一次分享的内容):

$ ls /var/lib/docker/aufs/mnt/6780d0778b8a/test

可以确认,容器 Volume 里的信息,并不会被 docker commit 提交掉;但这个挂载点目录 /test 本身,则会出现在新的镜像当中。

以上内容,就是 Docker Volume 的核心原理了。

1.6 小结

在今天的这次分享中,我用了一个非常经典的 Python 应用作为案例,讲解了 Docker 容器使用的主要场景。熟悉了这些操作,你也就基本上摸清了 Docker 容器的核心功能。

更重要的是,我着重介绍了如何使用 Linux Namespace、Cgroups,以及 rootfs 的知识,对容器进行了一次庖丁解牛似的解读。

借助这种思考问题的方法,最后的 Docker 容器,我们实际上就可以用下面这个“全景图”描述出来:

这个容器进程“python app.py”,运行在由 Linux Namespace 和 Cgroups 构成的隔离环境里;而它运行所需要的各种文件,比如 python、app.py 以及整个操作系统文件,则由多个联合挂载在一起的 rootfs 层提供。

这些 rootfs 层的最下层,是来自 Docker 镜像的只读层。在只读层之上,是 Docker 自己添加的 Init 层,用来存放被临时修改过的 /etc/hosts 等文件。而 rootfs 的最上层是一个可读写层,它以 Copy-on-Write 的方式存放任何对只读层的修改,容器声明的 Volume 的挂载点,也出现在这一层。

2. 谈谈 K8S 的本质

在前面的四节,我以 Docker 项目为例,一步步剖析了 Linux 容器的具体实现方式。通过这些讲解你应该能够明白:一个“容器”,实际上是一个由 Linux Namespace、Linux Cgroups 和 rootfs 三种技术构建出来的进程的隔离环境。

从这个结构中我们不难看出,一个正在运行的 Linux 容器,其实可以被“一分为二”地看待:

  1. 一组联合挂载在 /var/lib/docker/aufs/mnt 上的 rootfs,这一部分我们称为“容器镜像”(Container Image),是容器的静态视图;
  2. 一个由 Namespace+Cgroups 构成的隔离环境,这一部分我们称为“容器运行时”(Container Runtime),是容器的动态视图。

更进一步地说,作为一名开发者,我并不关心容器运行时的差异。因为,在整个“开发 - 测试 - 发布”的流程中,真正承载着容器信息进行传递的是容器镜像,而不是容器运行时。

这个重要假设,正是容器技术圈在 Docker 项目成功后不久,就迅速走向了“容器编排”这个“上层建筑”的主要原因:作为一家云服务商或者基础设施提供商,我只要能够将用户提交的 Docker 镜像以容器的方式运行起来,就能成为这个非常热闹的容器生态图上的一个承载点,从而将整个容器技术栈上的价值,沉淀在我的这个节点上。

更重要的是,只要从我这个承载点向 Docker 镜像制作者和使用者方向回溯,整条路径上的各个服务节点,比如 CI/CD、监控、安全、网络、存储等等,都有我可以发挥和盈利的余地。这个逻辑,正是所有云计算提供商如此热衷于容器技术的重要原因:通过容器镜像,它们可以和潜在用户(即,开发者)直接关联起来。

从一个开发者和单一的容器镜像,到无数开发者和庞大的容器集群,容器技术实现了从“容器”到“容器云”的飞跃,标志着它真正得到了市场和生态的认可。

这样,容器就从一个开发者手里的小工具,一跃成为了云计算领域的绝对主角;而能够定义容器组织和管理规范的“容器编排”技术,则当仁不让地坐上了容器技术领域的“头把交椅”。

这其中,最具代表性的容器编排工具,当属 Docker 公司的 Compose+Swarm 组合,以及 Google 与 RedHat 公司共同主导的 Kubernetes 项目。

我在前面介绍容器技术发展历史的四篇预习文章中,已经对这两个开源项目做了详细地剖析和评述。所以,在今天的这次分享中,我会专注于本专栏的主角 Kubernetes 项目,谈一谈它的设计与架构。

2.1 Borg

跟很多基础设施领域先有工程实践、后有方法论的发展路线不同,Kubernetes 项目的理论基础则要比工程实践走得靠前得多,这当然要归功于 Google 公司在 2015 年 4 月发布的 Borg 论文了。

Borg 系统,一直以来都被誉为 Google 公司内部最强大的“秘密武器”。虽然略显夸张,但这个说法倒不算是吹牛。

因为,相比于 Spanner、BigTable 等相对上层的项目,Borg 要承担的责任,是承载 Google 公司整个基础设施的核心依赖。在 Google 公司已经公开发表的基础设施体系论文中,Borg 项目当仁不让地位居整个基础设施技术栈的最底层。

上面这幅图,来自于 Google Omega 论文的第一作者的博士毕业论文。它描绘了当时 Google 已经公开发表的整个基础设施栈。在这个图里,你既可以找到 MapReduce、BigTable 等知名项目,也能看到 Borg 和它的继任者 Omega 位于整个技术栈的最底层。

正是由于这样的定位,Borg 可以说是 Google 最不可能开源的一个项目。而幸运地是,得益于 Docker 项目和容器技术的风靡,它却终于得以以另一种方式与开源社区见面,这个方式就是 Kubernetes 项目。

所以,相比于“小打小闹”的 Docker 公司、“旧瓶装新酒”的 Mesos 社区,Kubernetes 项目从一开始就比较幸运地站上了一个他人难以企及的高度:在它的成长阶段,这个项目每一个核心特性的提出,几乎都脱胎于 Borg/Omega 系统的设计与经验。更重要的是,这些特性在开源社区落地的过程中,又在整个社区的合力之下得到了极大的改进,修复了很多当年遗留在 Borg 体系中的缺陷和问题。

所以,尽管在发布之初被批评是“曲高和寡”,但是在逐渐觉察到 Docker 技术栈的“稚嫩”和 Mesos 社区的“老迈”之后,这个社区很快就明白了:Kubernetes 项目在 Borg 体系的指导下,体现出了一种独有的“先进性”与“完备性”,而这些特质才是一个基础设施领域开源项目赖以生存的核心价值。

为了更好地理解这两种特质,我们不妨从 Kubernetes 的顶层设计说起。

2.2 顶层设计

首先,Kubernetes 项目要解决的问题是什么?

编排?调度?容器云?还是集群管理?

实际上,这个问题到目前为止都没有固定的答案。因为在不同的发展阶段,Kubernetes 需要着重解决的问题是不同的。但是,对于大多数用户来说,他们希望 Kubernetes 项目带来的体验是确定的:现在我有了应用的容器镜像,请帮我在一个给定的集群上把这个应用运行起来。

更进一步地说,我还希望 Kubernetes 能给我提供路由网关、水平扩展、监控、备份、灾难恢复等一系列运维能力。

等一下,这些功能听起来好像有些耳熟?这不就是经典 PaaS(比如 Cloud Foundry)项目的能力吗?

而且,有了 Docker 之后,我根本不需要什么 Kubernetes、PaaS,只要使用 Docker 公司的 Compose+Swarm 项目,就完全可以很方便地 DIY 出这些功能了!

所以说,如果 Kubernetes 项目只是停留在拉取用户镜像、运行容器,以及提供常见的运维功能的话,那么别说跟“原生”的 Docker Swarm 项目竞争了,哪怕跟经典的 PaaS 项目相比也难有什么优势可言。

而实际上,在定义核心功能的过程中,Kubernetes 项目正是依托着 Borg 项目的理论优势,才在短短几个月内迅速站稳了脚跟,进而确定了一个如下图所示的全局架构:

我们可以看到,Kubernetes 项目的架构,跟它的原型项目 Borg 非常类似,都由 Master 和 Node 两种节点组成,而这两种角色分别对应着控制节点和计算节点。

其中,控制节点,即 Master 节点,由三个紧密协作的独立组件组合而成,它们分别是负责 API 服务的 kube-apiserver、负责调度的 kube-scheduler,以及负责容器编排的 kube-controller-manager。整个集群的持久化数据,则由 kube-apiserver 处理后保存在 Etcd 中。

而计算节点上最核心的部分,则是一个叫作 kubelet 的组件。

在 Kubernetes 项目中,kubelet 主要负责同容器运行时(比如 Docker 项目)打交道。而这个交互所依赖的,是一个称作 CRI(Container Runtime Interface)的远程调用接口,这个接口定义了容器运行时的各项核心操作,比如:启动一个容器需要的所有参数。

这也是为何,Kubernetes 项目并不关心你部署的是什么容器运行时、使用的什么技术实现,只要你的这个容器运行时能够运行标准的容器镜像,它就可以通过实现 CRI 接入到 Kubernetes 项目当中。

而具体的容器运行时,比如 Docker 项目,则一般通过 OCI 这个容器运行时规范同底层的 Linux 操作系统进行交互,即:把 CRI 请求翻译成对 Linux 操作系统的调用(操作 Linux Namespace 和 Cgroups 等)。

此外,kubelet 还通过 gRPC 协议同一个叫作 Device Plugin 的插件进行交互。这个插件,是 Kubernetes 项目用来管理 GPU 等宿主机物理设备的主要组件,也是基于 Kubernetes 项目进行机器学习训练、高性能作业支持等工作必须关注的功能。

而 kubelet 的另一个重要功能,则是调用网络插件和存储插件为容器配置网络和持久化存储。这两个插件与 kubelet 进行交互的接口,分别是 CNI(Container Networking Interface)和 CSI(Container Storage Interface)。

实际上,kubelet 这个奇怪的名字,来自于 Borg 项目里的同源组件 Borglet。不过,如果你浏览过 Borg 论文的话,就会发现,这个命名方式可能是 kubelet 组件与 Borglet 组件的唯一相似之处。因为 Borg 项目,并不支持我们这里所讲的容器技术,而只是简单地使用了 Linux Cgroups 对进程进行限制。

这就意味着,像 Docker 这样的“容器镜像”在 Borg 中是不存在的,Borglet 组件也自然不需要像 kubelet 这样考虑如何同 Docker 进行交互、如何对容器镜像进行管理的问题,也不需要支持 CRI、CNI、CSI 等诸多容器技术接口。

可以说,kubelet 完全就是为了实现 Kubernetes 项目对容器的管理能力而重新实现的一个组件,与 Borg 之间并没有直接的传承关系。

【注】虽然不使用 Docker,但 Google 内部确实在使用一个包管理工具,名叫 Midas Package Manager (MPM),其实它可以部分取代 Docker 镜像的角色。

那么,Borg 对于 Kubernetes 项目的指导作用又体现在哪里呢?

答案是,Master 节点。

虽然在 Master 节点的实现细节上 Borg 项目与 Kubernetes 项目不尽相同,但它们的出发点却高度一致,即:如何编排、管理、调度用户提交的作业?

所以,Borg 项目完全可以把 Docker 镜像看做是一种新的应用打包方式。这样,Borg 团队过去在大规模作业管理与编排上的经验就可以直接“套”在 Kubernetes 项目上了。

这些经验最主要的表现就是,从一开始,Kubernetes 项目就没有像同时期的各种“容器云”项目那样,把 Docker 作为整个架构的核心,而仅仅把它作为最底层的一个容器运行时实现。

而 Kubernetes 项目要着重解决的问题,则来自于 Borg 的研究人员在论文中提到的一个非常重要的观点:“运行在大规模集群中的各种任务之间,实际上存在着各种各样的关系。这些关系的处理,才是作业编排和管理系统最困难的地方。”

事实也正是如此。

其实,这种任务与任务之间的关系,在我们平常的各种技术场景中随处可见。比如,一个 Web 应用与数据库之间的访问关系,一个负载均衡器和它的后端服务之间的代理关系,一个门户应用与授权组件之间的调用关系。

更进一步地说,同属于一个服务单位的不同功能之间,也完全可能存在这样的关系。比如,一个 Web 应用与日志搜集组件之间的文件交换关系。

而在容器技术普及之前,传统虚拟机环境对这种关系的处理方法都是比较“粗粒度”的。你会经常发现很多功能并不相关的应用被一股脑儿地部署在同一台虚拟机中,只是因为它们之间偶尔会互相发起几个 HTTP 请求。

更常见的情况则是,一个应用被部署在虚拟机里之后,你还得手动维护很多跟它协作的守护进程(Daemon),用来处理它的日志搜集、灾难恢复、数据备份等辅助工作。

但容器技术出现以后,你就不难发现,在“功能单位”的划分上,容器有着独一无二的“细粒度”优势:毕竟容器的本质,只是一个进程而已。

也就是说,只要你愿意,那些原先拥挤在同一个虚拟机里的各个应用、组件、守护进程,都可以被分别做成镜像,然后运行在一个个专属的容器中。它们之间互不干涉,拥有各自的资源配额,可以被调度在整个集群里的任何一台机器上。而这,正是一个 PaaS 系统最理想的工作状态,也是所谓“微服务”思想得以落地的先决条件。

当然,如果只做到“封装微服务、调度单容器”这一层次,Docker Swarm 项目就已经绰绰有余了。如果再加上 Compose 项目,你甚至还具备了处理一些简单依赖关系的能力,比如:一个“Web 容器”和它要访问的数据库“DB 容器”。

在 Compose 项目中,你可以为这样的两个容器定义一个“link”,而 Docker 项目则会负责维护这个“link”关系,其具体做法是:Docker 会在 Web 容器中,将 DB 容器的 IP 地址、端口等信息以环境变量的方式注入进去,供应用进程使用,比如:

DB_NAME=/web/db
DB_PORT=tcp://172.17.0.5:5432
DB_PORT_5432_TCP=tcp://172.17.0.5:5432
DB_PORT_5432_TCP_PROTO=tcp
DB_PORT_5432_TCP_PORT=5432
DB_PORT_5432_TCP_ADDR=172.17.0.5

而当 DB 容器发生变化时(比如,镜像更新,被迁移到其他宿主机上等等),这些环境变量的值会由 Docker 项目自动更新。这就是平台项目自动地处理容器间关系的典型例子。

可是,如果我们现在的需求是,要求这个项目能够处理前面提到的所有类型的关系,甚至还要能够支持未来可能出现的更多种类的关系呢?

这时,“link”这种单独针对一种案例设计的解决方案就太过简单了。如果你做过架构方面的工作,就会深有感触:一旦要追求项目的普适性,那就一定要从顶层开始做好设计。

所以,Kubernetes 项目最主要的设计思想是,从更宏观的角度,以统一的方式来定义任务之间的各种关系,并且为将来支持更多种类的关系留有余地。

比如,Kubernetes 项目对容器间的“访问”进行了分类,首先总结出了一类非常常见的“紧密交互”的关系,即:这些应用之间需要非常频繁的交互和访问;又或者,它们会直接通过本地文件进行信息交换。

在常规环境下,这些应用往往会被直接部署在同一台机器上,通过 Localhost 通信,通过本地磁盘目录交换文件。而在 Kubernetes 项目中,这些容器则会被划分为一个“Pod”,Pod 里的容器共享同一个 Network Namespace、同一组数据卷,从而达到高效率交换信息的目的。

Pod 是 Kubernetes 项目中最基础的一个对象,源自于 Google Borg 论文中一个名叫 Alloc 的设计。在后续的章节中,我们会对 Pod 做更进一步地阐述。

而对于另外一种更为常见的需求,比如 Web 应用与数据库之间的访问关系,Kubernetes 项目则提供了一种叫作“Service”的服务。像这样的两个应用,往往故意不部署在同一台机器上,这样即使 Web 应用所在的机器宕机了,数据库也完全不受影响。可是,我们知道,对于一个容器来说,它的 IP 地址等信息不是固定的,那么 Web 应用又怎么找到数据库容器的 Pod 呢?

所以,Kubernetes 项目的做法是给 Pod 绑定一个 Service 服务,而 Service 服务声明的 IP 地址等信息是“终生不变”的。这个Service 服务的主要作用,就是作为 Pod 的代理入口(Portal),从而代替 Pod 对外暴露一个固定的网络地址。

这样,对于 Web 应用的 Pod 来说,它需要关心的就是数据库 Pod 的 Service 信息。不难想象,Service 后端真正代理的 Pod 的 IP 地址、端口等信息的自动更新、维护,则是 Kubernetes 项目的职责。

像这样,围绕着容器和 Pod 不断向真实的技术场景扩展,我们就能够摸索出一幅如下所示的 Kubernetes 项目核心功能的“全景图”。

按照这幅图的线索,我们从容器这个最基础的概念出发,首先遇到了容器间“紧密协作”关系的难题,于是就扩展到了 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,则用于描述定时任务等等。

如此种种,正是 Kubernetes 项目定义容器间关系和形态的主要方法。

可以看到,Kubernetes 项目并没有像其他项目那样,为每一个管理功能创建一个指令,然后在项目中实现其中的逻辑。这种做法,的确可以解决当前的问题,但是在更多的问题来临之后,往往会力不从心。

相比之下,在 Kubernetes 项目中,我们所推崇的使用方法是:

首先,通过一个“编排对象”,比如 Pod、Job、CronJob 等,来描述你试图管理的应用;

然后,再为它定义一些“服务对象”,比如 Service、Secret、Horizontal Pod Autoscaler(自动水平扩展器)等。这些对象,会负责具体的平台级功能。

这种使用方法,就是所谓的“声明式 API”。这种 API 对应的“编排对象”和“服务对象”,都是 Kubernetes 项目中的 API 对象(API Object)。

这就是 Kubernetes 最核心的设计理念,也是接下来我会重点剖析的关键技术点。

最后,我来回答一个更直接的问题:Kubernetes 项目如何启动一个容器化任务呢?

比如,我现在已经制作好了一个 Nginx 容器镜像,希望让平台帮我启动这个镜像。并且,我要求平台帮我运行两个完全相同的 Nginx 副本,以负载均衡的方式共同对外提供服务。

如果是自己 DIY 的话,可能需要启动两台虚拟机,分别安装两个 Nginx,然后使用 keepalived 为这两个虚拟机做一个虚拟 IP。

而如果使用 Kubernetes 项目呢?你需要做的则是编写如下这样一个 YAML 文件(比如名叫 nginx-deployment.yaml):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80

在上面这个 YAML 文件中,我们定义了一个 Deployment 对象,它的主体部分(spec.template 部分)是一个使用 Nginx 镜像的 Pod,而这个 Pod 的副本数是 2(replicas=2)。

然后执行:kubectl create -f nginx-deployment.yaml,这样,两个完全相同的 Nginx 容器副本就被启动了。

不过,这么看来,做同样一件事情,Kubernetes 用户要做的工作也不少嘛。别急,在后续的讲解中,我会陆续介绍 Kubernetes 项目这种“声明式 API”的种种好处,以及基于它实现的强大的编排能力。

2.3 小结

首先,我和你一起回顾了容器的核心知识,说明了容器其实可以分为两个部分:容器运行时和容器镜像。

然后,我重点介绍了 Kubernetes 项目的架构,详细讲解了它如何使用“声明式 API”来描述容器化业务和容器间关系的设计思想。

实际上,过去很多的集群管理项目(比如 Yarn、Mesos,以及 Swarm)所擅长的,都是把一个容器,按照某种规则,放置在某个最佳节点上运行起来。这种功能,我们称为“调度”。而 Kubernetes 项目所擅长的,是按照用户的意愿和整个系统的规则,完全自动化地处理好容器之间的各种关系。这种功能,就是我们经常听到的一个概念:编排。

所以说,Kubernetes 项目的本质,是为用户提供一个具有普遍意义的容器编排工具。

不过,更重要的是,Kubernetes 项目为用户提供的不仅限于一个工具。它真正的价值,乃在于提供了一套基于容器构建分布式系统的基础依赖。关于这一点,相信你会在今后的学习中,体会的越来越深。

3. K8S 的基本架构

操作系统的一个重要功能就是抽象,从繁琐的底层事务中抽象出一些简洁的概念,然后基于这些概念去管理系统资源。

Kubernetes 也是这样,它的管理目标是大规模的集群和应用,必须要能够把系统抽象到足够高的层次,分解出一些松耦合的对象,才能简化系统模型,减轻用户的心智负担。

所以,Kubernetes扮演的角色就如同一个“大师级别”的系统管理员,具有丰富的集群运维经验,独创了自己的一套工作方式,不需要太多的外部干预,就能够自主实现原先许多复杂的管理工作。

下面我们就来看看这位资深管理员的“内功心法”。

左侧是一个官方提供的名为 kubectl 的 CLI (Command Line Interface)工具,用于使用 K8S 开放的 API 来管理集群和操作对象等。位于集群之外,理论上不属于集群。

右侧则是 K8S 集群的后端服务及开放出的 API 等。

Kubernetes 采用了现今流行的“控制面/数据面”(Control Plane / Data Plane)架构,集群里的计算机被称为“节点”(Node),可以是实机也可以是虚机,少量的节点用作“控制面”来执行集群的管理维护工作,其他的大部分节点都被划归“数据面”,用来跑业务应用。

“控制面”的节点在 Kubernetes 里叫做 Master Node,一般简称为 Master,它是整个集群里最重要的部分,可以说是 Kubernetes 的大脑和心脏。

注意,Master 是一种角色(Role),表示在这个 Node 上包含着管理集群的一些必要组件(它也可以实际工作),并且,这里只画出了一个 Master,在生产环境中为了保障集群的高可用,我们通常会部署多个 Master 。

“数据面”的节点叫做 Worker Node,一般就简称为 Worker 或者 Node,相当于 Kubernetes 的手和脚,在 Master 的指挥下干活。

Node 的数量非常多,构成了一个资源池,Kubernetes 就在这个池里分配资源,调度应用。因为资源被“池化”了,所以管理也就变得比较简单,可以在集群中任意添加或者删除节点。

Kubernetes 的节点内部也具有复杂的结构,是由很多的模块构成的,这些模块又可以分成组件(Component)和插件(Addon)两类。

组件实现了 Kubernetes 的核心功能特性,没有这些组件 Kubernetes 就无法启动,而插件则是 Kubernetes 的一些附加功能,属于“锦上添花”,不安装也不会影响 Kubernetes 的正常运行。

接下来我先来讲讲 Master 和 Node 里的组件,然后再捎带提一下插件(Addon),理解了它们的工作流程,你就会明白为什么 Kubernetes 有如此强大的自动化运维能力。

3.1 Master

下面我们来逐层分解, 首先是 Master ,这里我们只介绍其管理集群的相关组件。Master 是整个 K8S 集群的“大脑”,与大脑类似,它有几个重要的功能:

  • 接收:外部的请求和集群内部的通知反馈
  • 发布:对集群整体的调度和管理
  • 存储:存储集群的有关信息

这些功能,也通过一些组件来共同完成,通常情况下,我们将其称为 Control Plane 。如下图所示:

这 4 个组件也都被容器化了,运行在集群的 Pod 里,我们可以用 kubectl 来查看它们的状态,使用命令:kubectl get pod -n kube-system

a. Etcd

存储集群所有需持久化的状态,并且提供 watch 的功能支持,可以快速的通知各组件的变更等操作。

因为目前 Kubernetes 的存储层选择是 etcd ,所以一般情况下,大家都直接以 etcd 来代表集群状态存储服务。即:将所有状态存储到 etcd 实例中。

etcd 是一个高可用的分布式 Key-Value 数据库,用来持久化存储系统里的各种资源对象和状态,相当于 Kubernetes 里的【配置管理员】。注意它只与 API Server 有直接联系,也就是说任何其他组件想要读写 etcd 里的数据都必须经过 API Server。

刚才我们说 Master 相当于是 K8S 集群的大脑,更细化来看,etcd 则是大脑中的核心,为什么这么说?可以参考后面详细剖析的章节,本章我们先从更高的层次来看集群的整体架构。

你可能会问, etcd 是必须的吗?就目前而言,etcd 是必须的,这主要是 Kubernetes 的内部实现。

而早在 2014 年左右,社区就一直在提议将存储层抽象出来,后端的实际存储作为一种插件化的存在。呼声比较大的是另一种提供 k/v 存储功能的 Consul 。

不过得益于 etcd 的开发团队较为活跃,而且根据 K8S 社区的反馈做了相当大的改进,并且当时 K8S 团队主要的关注点也不在此,所以直到现在 etcd 仍不是一个可选项。

如果现在去看下 Kubernetes 的源代码,你会发现存储层的代码还比较简洁清晰,后续如果有精力也许将此处插件化也不是不可能。

b. API Server

API Server 是 Master 节点,同时也是整个 Kubernetes 系统的唯一入口,它对外公开了一系列的 RESTful API,并且加上了验证、授权等功能,所有其他组件都只能和它直接通信,可以说是 Kubernetes 里的【联络员】。

实际处理逻辑比三次握手简单的多:

  1. 请求 API Server:“嗨,我有些东西要放到 etcd 里面。”
  2. API Server 收到请求:“你是谁?我为啥要听你的?”
  3. 从请求中,拿出自己的身份凭证(一般是证书):“是我啊~你的 Master,给我把这些东西放进去。”
  4. 这时候就要看是些什么内容了,如果这些内容 API Server 能理解,那就放入 etcd 中:“好的,Master 我放进去了。”;如果不能理解,“抱歉, Master 我理解不了”。

可以看到,它提供了认证相关的功能,用于判断是否有权限进行操作。当然 API Server 支持多种认证方法,不过一般情况下,我们都使用 x509 证书进行认证。

API Server 的目标是成为一个极简的 server,只提供 REST 操作,更新 etcd ,并充当着集群的网关。至于其他的业务逻辑之类的,通过插件或者在其他组件中完成。

c. Scheduler

顾名思义,Scheduler 是集群的调度器,负责容器的编排工作,它会持续的关注集群中未被调度的 Pod ,并根据各种条件,比如资源的可用性、节点的亲和性或者其他的一些限制条件,通过绑定的 API 将 Pod 调度/绑定到最适合的 Node 上运行,相当于【部署人员】。

在这个过程中,调度程序一般只考虑调度开始时 Node 的状态,而不考虑在调度过程中 Node 的状态变化。

因为节点状态和 Pod 信息都存储在 etcd 里,所以 Scheduler 必须通过 API Server 才能获得。

d. Controller Manager

Controller Manager 大概是 K8S 集群中最繁忙的部分,它在后台运行着许多不同的控制器进程,用来调节集群的状态。

负责维护容器和节点等资源的状态,实现故障检测、服务迁移、应用伸缩等功能,相当于【监控运维人员】。同样地,它也必须通过 API Server 获得存储在 etcd 里的信息,才能够实现对资源的各种操作。

当集群的配置发生变更,控制器就会朝着预期的状态开始工作。

3.2 Node

Node 的概念我们在上节已经提过了,这里不再过多赘述,简单点理解为加入集群中的机器即可。

Master 里的 API Server、Scheduler 等组件需要获取节点的各种信息才能够作出管理决策,那这些信息该怎么来呢?

这就需要 Node 里的 3 个组件了,分别是 kubelet、kube-proxy、container-runtime。

a. Kubelet

Kubelet 实现了集群中最重要的关于 Node 和 Pod 的控制功能,如果没有 Kubelet 的存在,那 Kubernetes 很可能就只是一个纯粹的通过 API Server CRUD 的应用程序。

kubelet 是 Node 的代理,负责管理 Node 相关的绝大部分操作,Node 上只有它能够与 API Server 通信,实现状态报告、命令下发、启停容器等功能,相当于是 Node 上的一个【小管家】。

K8S 原生的执行模式是操作应用程序的容器,而不像传统模式那样,直接操作某个包或者是操作某个进程。基于这种模式,可以让应用程序之间相互隔离,互不影响。此外,由于是操作容器,所以应用程序可以说和主机也是相互隔离的,毕竟它不依赖于主机,在任何的容器运行时(比如 Docker)上都可以部署和运行。

Pod 是对容器的再封装,Pod 可以是一组容器(也可以包含存储卷),K8S 将 Pod 作为可调度的基本单位, 分离开了构建时和部署时的关注点:

  • 构建时,重点关注某个容器是否能正确构建,如何快速构建;
  • 部署时,关心某个应用程序的服务是否可用,是否符合预期,依赖的相关资源是否都能访问到。

这种隔离的模式,可以很方便的将应用程序与底层的基础设施解耦,极大的提高集群扩/缩容,迁移的灵活性。

在前面,我们提到了 Master 节点的 Scheduler 组件,它会调度未绑定的 Pod 到符合条件的 Node 上,而至于最终该 Pod 是否能运行于 Node 上,则是由 Kubelet 来裁定的。关于 Kubelet 的具体原理,后面有详细剖析的章节。

b. Container Runtime

这个我们就比较熟悉了,它是容器和镜像的实际使用者,在 kubelet 的指挥下创建容器,管理 Pod 的生命周期,是真正干活的“苦力”。

容器运行时最主要的功能是下载镜像和运行容器,我们最常见的实现可能是 Docker , 目前还有其他的一些实现,比如 rkt、cri-o。

K8S 提供了一套通用的容器运行时接口 CRI (Container Runtime Interface),凡是符合这套标准的容器运行时实现,均可在 K8S 上使用。

c. Kube Proxy

kube-proxy 的作用有点特别,它是 Node 的网络代理,只负责管理容器的网络通信,简单来说就是为 Pod 转发 TCP/UDP 数据包,相当于是专职的【小邮差】。

我们都知道,想要访问某个服务,那要么通过域名,要么通过 IP。而每个 Pod 在创建后都会有一个虚拟 IP,K8S 中有一个抽象的概念叫做 Service ,kube-proxy 便是提供一种代理的服务,让你可以通过 Service 访问到 Pod。

实际的工作原理是在每个 Node 上启动一个 kube-proxy 的进程,通过编排 iptables 规则来达到此效果。深入的解析,在后面有对应的章节。

3.3 Addon

只要服务器节点上运行了apiserver、scheduler、kubelet、kube-proxy、container-runtime 等组件,就可以说是一个功能齐全的 Kubernetes 集群了。

不过就像 Linux 一样,操作系统提供的基础功能虽然“可用”,但想达到“好用”的程度,还是要再安装一些附加功能,这在 Kubernetes 里就是插件(Addon)。

由于 Kubernetes 本身的设计非常灵活,所以就有大量的插件用来扩展、增强它对应用和集群的管理能力。

插件中我个人认为比较重要的有两个:DNS 和 Dashboard。

DNS 你应该比较熟悉吧,它在 Kubernetes 集群里实现了域名解析服务,能够让我们以域名而不是 IP 地址的方式来互相通信,是服务发现和负载均衡的基础。由于它对微服务、服务网格等架构至关重要,所以基本上是 Kubernetes 的必备插件。

Dashboard 就是仪表盘,为 Kubernetes 提供了一个图形化的操作界面,非常直观友好,虽然大多数 Kubernetes 工作都是使用命令行 kubectl,但有的时候在 Dashboard 上查看信息也是挺方便的。

3.4 小结

  • Kubernetes 能够在集群级别管理应用和服务器,可以认为是一种集群操作系统。它使用“控制面/数据面”的基本架构,Master 节点实现管理控制功能,Worker 节点运行具体业务;
  • Kubernetes 由很多模块组成,可分为核心的组件和选配的插件两类;
  • Master 里有 4 个组件,分别是 apiserver、etcd、scheduler、controller-manager;
  • Node 里有 3 个组件,分别是 kubelet、kube-proxy、container-runtime;
  • 通常必备的插件有 DNS 和 Dashboard。

【组件交互原理】通过以下所有组件的分工协作,最终实现了 K8S 对容器的编排和调度。

posted @ 2021-09-21 12:43  tree6x7  阅读(41)  评论(0编辑  收藏  举报