kubernetes 系列随笔 02:docker 技术原理

618 阅读15分钟

从Docker学起

前面我们聊了整个云计算大致的发展历程,从现在开始,我们进入技术原理篇,先从docker说起。很多同学经常问我想学习kubernetes相关技术,应该如何学,我一般都建议先从docker开始。虽然业内关于容器的产品很多,docker仍然占据着很重要的地位。

如果仅仅是使用,它一点也不复杂,只需要简单的几个命令就能运行起来,网上随便找个安装教程,可能十分钟你就能跑起来一个nginx服务。docker 可以把程序、库文件、配置文件都打成了一个包,然后在任何服务器上都能够直接运行起来,这也就是那句话 Build once,Run anywhere。

Docker原理

关于docker如何安装、如何简单使用网上的文章实在太多了,我就不再讲解了,我相信爱好容器技术的你,一定简单使用过,运行过一些服务。比如运行一个nginx服务

docker run --name nginx-test -d nginx:1.19.0

上一篇文章提到过,docker的关键技术是Namespace 和 Cgroups、还有镜像。接下来我主要也会从这三个方面去一一讲解。

从上面启动的容器说起。这命令很简单,

  • run 的意思就是要启动一个容器,。
  • --name 是给这个容器起个名字叫 nginx-test 。
  • -d 参数里 d 是 Daemon 的首字母,也就是让容器在后台运行。
  • 最后一个参数 nginx:1.19.0 指定了具体要启动哪一个镜像,这里咱们启动的是 nginx 这个镜像的 1.19.0 版本。

那怎样来验证这个 nginx 容器是不是正常工作的呢?可以通过这两步来验证:

  • 第一步,我们可以进入容器的运行空间,查看 nginx 服务是不是启动了,80 端口是不是被监听了。
  • 第二步,在容器内用 curl 命令访问 80 端口,如果有正常的返回,就可以证明这个容器提供了我们预期的 nginx 服务。

运行 docker exec -it nginx-test bash 这个命令进入容器的运行空间,至于什么是容器的运行空间,它的标准说法是容器的命名空间(Namespace),这个概念我们等会儿再做介绍。

进入容器运行空间之后,需要先用 apt update && apt install -y procps net-tools 命令安装ps 和netstat  两个工具。

  • 执行 ps -ef 可以看到 nginx 的进程正在容器的空间中运行。
  • 执行 netstat -lntp 可以看到 80 端口被 nginx 进程监听了。
  • 执行 curl 127.0.0.1:80 可以看到有正常的返回。

通过这上面的这些操作练习,估计你已经初步感知到,容器的文件系统是独立的,运行的进程环境是独立的,网络的设置也是独立的。但是它们和宿主机上的文件系统,进程环境以及网络都已经分开了。我们刚才启动的容器,已经从宿主机环境里被分隔出来了。

那问题来了,容器的独立运行环境到底是怎么创造的呢?这就要提到 Namespace 这个概念了。

Namespace

还使用上面的容器来看看 Namespace 到底是什么?

在前面我们在容器中运行 ps -ef ,看到了两个 nginx 进程。直接在宿主机上运行 ps -ef | grep nginx 也可以看到两个 nginx 进程。

仔细对比可以发现容器和宿主机上的 nginx 进程的 PID 不一样。为什么 PID 会不一样呢?

Linux 在创建容器的时候,就会建出一个 PID Namespace,PID 其实就是进程的编号。这个 PID Namespace,就是指每建立出一个 Namespace,就会单独对进程进行 PID 编号,每个 Namespace 的 PID 编号都从 1 开始。同时在这个 PID Namespace 中也只能看到 Namespace 中的进程,而且看不到其他 Namespace 里的进程。

这也就是说,如果有另外一个容器,那么它也有自己的一个 PID Namespace,而这两个 PID Namespace 之间是不能看到对方的进程的,这里就体现出了 Namespace 的作用:相互隔离。

而在宿主机上的 Host PID Namespace,它是其他 Namespace 的父亲 Namespace,可以看到在这台机器上的所有进程,不过进程 PID 编号不是 Container PID Namespace 里的编号了,而是把所有在宿主机运行的进程放在一起,再进行编号。

讲了 PID Namespace 之后,我们了解到 Namespace 其实就是一种隔离机制,主要目的是隔离运行在同一个宿主机上的容器,让这些容器之间不能访问彼此的资源。

这种隔离有两个作用:第一是可以充分地利用系统的资源,也就是说在同一台宿主机上可以运行多个用户的容器;第二是保证了安全性,因为不同用户之间不能访问对方的资源。

除了 PID Namespace,还有其他常见的 Namespace 类型。

Linux 使用了六种 Namespace,分别对应六种资源:Mount、UTS、IPC、PID、Network 和 User,上面我们已经介绍了 PID Namespace ,下面我们分别讨论下其他五个 Namespace 。

Mount namespace

Mount namespace 让容器看上去拥有整个文件系统。

容器有自己的 / 目录,可以执行 mount 和 umount 命令。当然这些操作只在当前容器中生效,不会影响到宿主机和其他容器。

UTS namespace

简单的说,UTS namespace 让容器有自己的 hostname。 默认情况下,容器的 hostname 是它的短ID,可以通过 -h 或 --hostname  参数设置。

IPC namespace

IPC namespace 让容器拥有自己的共享内存和信号量(semaphore)来实现进程间通信,而不会与 host 和其他容器的 IPC 混在一起。

Network namespace

Network namespace 让容器拥有自己独立的网卡、IP、路由等资源。进入在文章开头启动的那个容器,执行 ifconfig 命令,可以看到容器的独立网卡和 IP 。

User namespace

User namespace 让容器能够管理自己的用户,host 不能看到容器中创建的用户。

在容器中创建了用户 hantao ,但是在宿主机上并不会创建相应的用户。

正是通过这些 Namespace,我们才隔离出一个容器,这里你也可以把它看作是一台“计算机”。那么这个“计算机”有多少 CPU,有多少 Memory 呢?Linux 如何为这些“计算机”来定义 CPU,定义 Memory 的容量呢?

Cgroups

想要定义“计算机”各种容量大小,就涉及到支撑容器的第二个技术 Cgroups (Control Groups)了。Cgroups 可以对指定的进程做各种计算机资源的限制,比如限制 CPU 的使用率,内存使用量,IO 设备的流量等等。

Cgroups 究竟有什么好处呢?在 Cgroups 出现之前,任意一个进程都可以创建出成百上千个线程,可以轻易地消耗完一台计算机的所有 CPU 资源和内存资源。但是有了 Cgroups 这个技术以后,我们就可以对一个进程或者一组进程的计算机资源的消耗进行限制了。Cgroups 通过不同的子系统限制了不同的资源,每个子系统限制一种资源。每个子系统限制资源的方式都是类似的,就是把相关的一组进程分配到一个控制组里,然后通过树结构进行管理,每个控制组都设有自己的资源控制参数。

我们只需要了解几种比较常用的 Cgroups 子系统:

  • CPU 子系统,用来限制一个控制组(一组进程,可以理解为一个容器里所有的进程)可使用的最大 CPU。
  • memory 子系统,用来限制一个控制组最大的内存使用量。
  • pids 子系统,用来限制一个控制组里最多可以运行多少个进程。
  • cpuset 子系统, 这个子系统来限制一个控制组里的进程可以在哪几个物理 CPU 上运行。

Cgroup 到底长什么样子呢?我们可以在 /sys/fs/cgroup  中找到它。

还是用例子来说明,启动一个容器,内存限制为 1G。

docker run --name nginx-test3 -m 1G -d nginx:1.19.0

容器启动后,会返回容器的长 ID ,如上图。在 /sys/fs/cgroup/memory/system.slice 目录中,Linux 会为每个容器创建一个 cgroup 目录,目录中包含所有与内存相关的 cgroup 配置,文件 memory.limit_in_bytes 保存的就是启动容器时 -m 1G 的配置,值为( 110241024*1024=1073741824 )。

通过 memory Cgroups 定义容器的 memory 可以使用的最大值,其他的子系统稍微复杂一些,但用法也和 memory 类似。

经过上述讲解,我们对容器有了一个大致的认识,简单来说容器其实就是 Namespace+Cgroups。Namespace 帮助容器实现各种计算资源的隔离,Cgroups 主要对容器使用某种资源量的多少做一个限制。

讲到这里,大家还记得上一节关于虚拟机和容器的架构图不:

看完了docker原理,是不是觉得如果单纯的把docker和虚拟机对比其实是不严谨的,不应该把 Docker Engine 或者任何容器管理工具放在跟 Hypervisor 相同的位置,因为它们并不像 Hypervisor 那样对应用进程的隔离环境负责,也不会创建任何实体的“容器”,真正对隔离环境负责的是宿主机操作系统本身

Docker 镜像

在上一章我们提到,使用容器技术的项目有很多,为什么 Docker 成为最流行的容器项目呢?事实上,Docker 项目确实与其他项目的容器在大部分功能和实现原理上都是一样的,可偏偏就是这剩下的一小部分不一样的功能,成了 Docker 项目接下来“呼风唤雨”的不二法宝。这个功能,就是 Docker 镜像。

rootfs

以 CentOS 的 Docker 镜像为例,先用 docker pull centos 将 centos 的 latest 版本的镜像下载下来,再执行 docker images 查看镜像的信息。

可以看到 centos 镜像大小才 209MB ,平时我们安装一个 CentOS 至少都有几个 GB,为什么这里只有 209MB ?

相信这是几乎所有 Docker 初学者都会有的疑问,下面我们来解释这个问题。

Linux 操作系统由内核空间和用户空间组成。如下图所示:

内核空间是 kernel,Linux 刚启动时会加载 bootfs 文件系统,之后 bootfs 会被卸载掉。

用户空间的文件系统是 rootfs,包含我们熟悉的 /dev, /proc, /bin 等目录。不同 Linux 发行版的区别主要就是 rootfs。

对于 Docker 镜像来说,底层直接用宿主机的 kernel,自己只需要提供 rootfs 就行了。所以 Docker 可以同时支持多种 Linux 镜像,模拟出多种操作系统环境。

而对于一个精简的 OS,rootfs 可以很小,只需要包括最基本的命令、工具和程序库就可以了。相比其他 Linux 发行版,CentOS 的 rootfs 已经算臃肿的了,alpine 还不到 10MB。我们平时安装的 CentOS 除了 rootfs 还会选装很多软件、服务等,需要好几个 GB 就不足为奇了。

正是由于 rootfs 的存在,容器才有了一个被反复宣传至今的重要特性:一致性。

什么是容器的“一致性”呢?

由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。

事实上,大多数开发者对应用依赖的理解,一直局限在编程语言层面。但实际上,一个一直以来很容易被忽视的事实是,对一个应用来说,操作系统本身才是它运行所需要的最完整的“依赖库”。

有了 Docker 镜像“打包操作系统”的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的 Docker 镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了。 镜像的分层结构

Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。

我们用 Dockerfile 构建一个新镜像,来看下 Docker 镜像的分层结构。Dockerfile 是镜像的描述文件,定义了如何构建 Docker 镜像。

Dockerfile 内容如下:

① 新镜像是在 debian 镜像上构建,debian 在这里是 base 镜像。

② 安装 emacs 编辑器。

③ 安装 apache2。

④ 容器启动时运行 bash。

构建过程如下图所示:

新镜像是从 base 镜像一层一层叠加生成的。每安装一个软件,就在现有镜像的基础上增加一层。

问什么 Docker 镜像要采用这种分层结构呢?其中一个好处是资源共享。

例如有多个镜像都从相同的 base 镜像构建而来,那么 Docker 宿主机只需要在磁盘上保存一份 base 镜像,就可以为所有容器服务了,而且镜像的每一层都可以被共享。

Copy-on-Write

现在思考一个问题,如果多个容器共享一份基础镜像,当某个容器修改了基础镜像的内容,例如 /etc 下的文件,这时其他容器的 /etc 是否也会被修改 ?

答案是不会!修改会被限制在单个容器内。这就是我们接下来要学习的容器 Copy-on-Write 特性。

当容器启动时,一个新的可写层被加载到镜像的顶部。

这一层通常被称作“容器层”,“容器层”之下的都叫“镜像层”。

所有对容器的改动,无论添加、删除、还是修改文件都只会发生在容器层中。只有容器层是可写的,容器层下面的所有镜像层都是只读的。

镜像层数量可能会很多,所有镜像层会联合在一起组成一个统一的文件系统。如果不同层中有一个相同路径的文件,比如 /a,上层的 /a 会覆盖下层的 /a,也就是说用户只能访问到上层中的文件 /a。在容器层中,用户看到的是一个叠加之后的文件系统。

  • 添加文件。在容器中创建文件时,新文件被添加到容器层中。
  • 读取文件。在容器中读取某个文件时,Docker 会从上往下依次在各镜像层中查找此文件。一旦找到,打开并读入内存。
  • 修改文件。在容器中修改已存在的文件时,Docker 会从上往下依次在各镜像层中查找此文件。一旦找到,立即将其复制到容器层,然后修改之。
  • 删除文件。在容器中删除文件时,Docker 也是从上往下依次在镜像层中查找此文件。找到后,会在容器层中记录下此删除操作。

只有当需要修改时才复制一份数据,这种特性被称作 Copy-on-Write。可见,容器层保存的是镜像变化的部分,不会对镜像本身进行任何修改。

这样就解释了我们前面提出的问题:容器层记录对镜像的修改,所有镜像层都是只读的,不会被容器修改,所以镜像可以被多个容器共享。

结语

本篇介绍了容器的核心技术 Namespace 和 Cgroups,以及 Docker 镜像的知识。

Namespace 帮助容器实现各种计算资源的隔离,Cgroups 主要对容器使用某种资源量的多少做一个限制。

而在 rootfs 的基础上,Docker 公司创新性地提出了使用多个增量 rootfs 联合挂载一个完整 rootfs 的方案,这就是容器镜像中“层”的概念。

通过“分层镜像”的设计,以 Docker 镜像为核心,来自不同公司、不同团队的技术人员被紧密地联系在了一起。而且,由于容器镜像的操作是增量式的,这样每次镜像拉取、推送的内容,比原本多个完整的操作系统的大小要小得多;而共享层的存在,可以使得所有这些容器镜像需要的总空间,也比每个镜像的总和要小。这样就使得基于容器镜像的团队协作,要比基于动则几个 GB 的虚拟机磁盘镜像的协作要敏捷得多。

更重要的是,一旦这个镜像被发布,那么你在全世界的任何一个地方下载这个镜像,得到的内容都完全一致,可以完全复现这个镜像制作者当初的完整环境。这,就是容器技术“强一致性”的重要体现。

而这种价值正是支撑 Docker 公司在 2014~2016 年间迅猛发展的核心动力。容器镜像的发明,不仅打通了“开发 - 测试 - 部署”流程的每一个环节,更重要的是:容器镜像将会成为未来软件的主流发布方式。