梳理一下容器和 Docker 的基础知识

0. 引言

我们还是从最流行的也是最为大众熟知的容器技术产品 Docker 讲起。

在 2013 年的 PyCon 会议上,Solomon Hykes 利用“闪电演讲”环节,做了题为《The future of Linux Containers》的报告。我在B站上找到了当年的报告视频,如果有兴趣的话可以看一下 Docker 的诞生 。因为这个环节给每位演讲者的演讲时间只有五分钟,Solomon 只能非常仓促地展示了 docker,期间他还把 world 拼错了,最后还因为超时被主持人请下了台(尴尬)。

五分钟的演讲时间非常短暂,但他提到了很多新概念:容器、镜像、进程隔离等。这场会议过后,很多云服务厂商意识到这项技术可能会给应用打包、部署、运维提供很多便利,Docker 就这样流行了起来。最终发展到如今的 Kubernetes、服务网格等技术(应用)。

接下来我们尝试复现一下 Solomon 在 PyCon US 2013 上的演示:

首先使用 docker pull 命令拉取一个 busybox 镜像(image):

$ docker pull busybox

image

然后执行下面的命令,查看本地的所有镜像:

$ docker images

image

可以看到名为 busybox 的镜像已经被拉取到本地了

接下来我们就可以利用这个镜像启动容器了,我们要在容器里执行一个 echo 命令,让它输出一个字符串 hello world

$ docker run busybox echo "hello world!"

image

最后我们再使用 docker ps 命令查看所有容器,我们会在列表中找到我们刚才启动的容器,它的状态是 Exited (因为 echo 命令已经执行完毕,容器无事可做就自动退出了)

$ docker ps -a 

在这个演示里,Docker 好像并没有展现出什么特别的本事,但其实这与我们直接在 shell 中执行 echo 命令有很大的差别。

1. Docker 架构

Docker 文档中的这张图描述了 Docker 整套应用的角色和工作流程。

image

Docker 是典型的 C/S 架构(客户端/服务端)的应用。刚才我们使用的 docker 命令实际上是客户端(Docker Client),它会与 Docker Engine 的后台服务 Docker Daemon 通信,向它发送命令。Docker 镜像存储在远端的 Docker Registry 镜像仓库中。值得一提的是,使用 docker 命令并不能直接访问镜像仓库,它只能与 Docker Daemon 通信,告诉 Daemon 自己想要做什么,然后让 Daemon 完成操作并返回结果。

简要总结一下:

  • docker client 是命令行工具,是我们和 Docker Daemon 之间的中间人,它通过 build、pull、run、ps 等命令向 Daemon 发送请求

  • docker daemon 是 Docker Engine 的后台服务,负责管理容器和镜像;它和镜像仓库一同完成各种操作

Docker 官方还提供了一个 hello-world 镜像,向你展示 Docker 的实际工作流程,我们只需要运行下面的命令就能查看它运行的输出:

$ docker run hello-world

image

简要翻译一下:

  • Docker 客户端与 Docker daemon 通信,发送“运行 hello-world”的请求

  • Docker daemon 在本地镜像库找不到名为 “hello-world” 的镜像,就从 Docker Hub 镜像仓库拉取这个镜像

  • Docker daemon 通过 “hello-world” 镜像创建了一个容器,这个容器的输出就是你现在阅读的内容

  • Docker daemon 将输出传递给 docker 客户端,然后客户端将这些输出打印到终端上

到这里探索 Docker 架构的旅程就结束了,但还有值得一提的小知识:

本地的 Docker 的客户端和 Docker daemon 如何通信?

我们可以使用 docker context ls 命令来查看 docker 客户端在与哪个 Docker daemon 通信:

image

可以看到 DOCKER ENDPOINTunix:///var/run/docker.sock ,一个 unix 套接字。

2. 什么是容器

广义上来说:容器技术 = 动态的容器(狭义的容器) + 静态的镜像 + 远端的仓库。接下来我们就从狭义的容器开始探究容器技术。

2.1 容器:被隔离的进程

容器(Container)的字面意义是集装箱,而 Docker 的字面意义是码头工人。集装箱的作用是封装各种货物使其成为一个标准的运载单位,方便统计、存储、运输...相比运送散装货物,集装箱隔离了内外环境,防止集装箱内的货物影响外界,或者被外界影响。

在计算机世界里容器也发挥着同样的作用,它将进程与环境隔离开,让进程和系统的其他部分互不影响。

我们可以尝试启动一个 CentOS (操作系统)的容器,并打开它的 shell 尝试运行几个命令。我的宿主机运行的是 Ubuntu Jammy —— 也就是说,我们要在 Ubuntu 操作系统上运行一个 CentOS 操作系统的容器:

我们在容器内查看系统信息,以及正在运行的进程,并把它和宿主机上的运行结果做个对比

image

宿主机:

image

可以看到容器内的系统信息不再是 Ubuntu 而是 CentOS 7,使用 ps 命令查看进程也只能看到一个“干净”的运行环境,除了 bash shell 没有其他进程。

在容器内运行的程序完全看不到宿主机的痕迹,两个操作系统像是被“隔离”了。也就是说,容器是一个特殊的运行环境,在其中运行的进程只能访问到有限的资源和信息,无法对外界施加影响(当然不是绝对的“无法”)

容器的隔离有两层含义:

运行环境隔离

出于对系统安全的考虑,在计算机世界我们要对进程进行“隔离”。在 Linux 操作系统中,一个不受约束的应用程序是十分危险的:他可以访问任何文件,窃取重要信息,影响正常运行的程序,甚至把系统搞瘫痪。

利用容器技术,我们可以在系统中创造出一个沙箱(sandbox),给进程一个限定的运行环境,告诉它:你只许在这个环境内自由活动,但是不允许越界。这样我们就能保障容器外系统的安全。

这样做还有一个优点就是我们能够更方便地管理应用程序的依赖项,防止在同一台机器上运行的应用程序因为依赖项冲突而无法正常工作。

系统资源隔离

容器技术还可以为应用程序加上资源隔离。计算机里有各种各样的资源,CPU、内存、硬盘、网卡,这些资源是有限的,考虑到成本,也不允许某个应用程序无限制地占用大量系统资源。

我们可以为容器分配限定的系统资源,比如只能使用双核 CPU、2 GB 内存... 这样就可以避免进程过度消耗系统资源,让各个进程充分利用计算机硬件,同时提供稳定可靠的服务。

2.2 与虚拟机的区别

容器和虚拟机都使用虚拟化技术,但它们所在的层次(也就是说隔离程度)不同,我们可以通过 Docker 官网的说明一探究竟:

image

(*注:图示中容器并不运行在 Docker 之上,Docker 只是辅助建立隔离环境,让容器基于 Linux 操作系统运行)

  • 容器和虚拟机的目的都是隔离资源,保证系统安全,尽量提高资源使用率

  • 虚拟机通过 Hypervisor(虚拟机软件,KVM 等)将一台物理设备虚拟成多台逻辑设备,这些逻辑设备彼此独立,并且需要在虚拟硬件上安装操作系统才能使用;硬件虚拟化和操作系统会消耗大量的系统资源,但是它的好处就是隔离程度比较高

  • 容器则直接利用操作系统和硬件,比虚拟机少了一层,自然会节约 CPU 和内存这些资源,比虚拟机更加轻量,对系通过资源的利用也就更加高效;当然因为多个容器共用操作系统的内核,应用程序的隔离程度就没有那么高了

Ubuntu 虚拟机的启动时间可能是十几秒甚至数十秒,而一个 Ubuntu 容器只需要一秒左右便可以启动,更不用说它的镜像大小相较于完整的操作系统更小(只有70多MB),同时运行上百个容器也不在话下。

当然,这两种技术是可以同时使用的,我们可以在一台服务器上虚拟多个操作系统,然后在虚拟机中使用容器来快速运行应用程序。

2.3 容器隔离的实现

Docker 的隔离依靠 Linux 提供的三种技术 namespace cgroup chroot

  • namespace 用于创建独立的文件系统 主机名 进程号 还有网络等资源

  • cgroup 实现对进程的 CPU、内存等资源进行配额限制

  • chroot 则限制进程访问原有的文件系统*

*注:现在有更加现代化的 pivot_root,这里只是为了解释原理

namespace

namespace 是 Linux 内核的一项功能,该功能对内核资源进行隔离,使得容器中的进程都可以在单独的命名空间中运行,并且只可以访问当前容器命名空间的资源。namespace 可以隔离进程 ID、主机名、用户 ID、文件名、网络访问和进程间通信等相关资源。

Docker 主要利用下面几种命名空间:

  • pid namespace:用于隔离进程 ID

  • net namespace:隔离网络接口,在虚拟的 net namespace 内用户可以拥有自己独立的 IP、路由、端口等

  • mnt namespace:文件系统挂载点隔离

  • ipc namespace:信号量、消息队列和共享内存的隔离

  • uts namespace:主机名和域名的隔离

Cgroup

Cgroups 是一个 Linux 内核功能,可以限制和隔离进程的资源使用情况(CPU、内存、磁盘 I/O、网络等)。在容器的实现中,Cgroups 通常用来限制容器的 CPU 和内存等资源的使用。

chroot

chroot 针对正在运作的软件进程和它的子进程,改变它外显的根目录。一个运行在这个环境下,经由 chroot 设置根目录的程序,不能够对这个指定根目录之外的文件进行访问动作,不能读取,也不能更改它的内容。

简单来说就是“伪造”一个文件系统来欺骗容器中的进程。用操作系统镜像文件挂载到容器进程的根目录下,变成容器的 rootfs,和真实系统目录一模一样。

综合运用这三种技术,一个具备完善隔离特性的容器就出现了。

2.4 镜像

和其他镜像一样,容器技术中的“镜像”也是只读的,它以标准格式存储了一系列的文件,然后在需要的时候再从中提取出数据运行起来。因为容器是由操作系统动态创建的,那么必然就可以用一种办法把它的初始环境给固化下来,保存成一个静态的文件,方便存放、传输、版本化管理

镜像是容器的静态形式,它打包了应用程序的所有运行依赖项,方便保存和传输。使用容器技术运行镜像,就形成了动态的容器,由于镜像只读不可修改,所以应用程序的运行环境总是一致的。

而容器化的应用就是指以镜像的形式打包应用程序,然后在容器环境里从镜像启动容器。

之前我们运行的命令 docker pull busybox ,就是获取了一个打包了 busybox 应用的镜像,里面固化了 busybox 程序和它所需的完整运行环境。

而 docker run busybox echo hello world ,就是提取镜像里的各种信息,运用 namespace、cgroup、chroot 技术创建出隔离环境,然后再运行 busybox 的 echo 命令,输出 hello world 的字符串。

这两个步骤,由于是基于标准的 Linux 系统调用和只读的镜像文件,所以,无论是在哪种操作系统上,或者是使用哪种容器实现技术,都会得到完全一致的结果

所谓的“容器化的应用”,或者“应用的容器化”,就是指应用程序不再直接和操作系统打交道,而是封装成镜像,再交给容器环境去运行

可以说,镜像就是静态的应用容器,容器就是动态的应用镜像,两者相互转化。

镜像的内部机制

镜像就是一个打包文件,里面包含了应用程序还有它运行所依赖的环境,例如文件系统、环境变量、配置参数等等。

容器镜像内部并不是一个平坦的结构,而是由许多的镜像层组成的,每层都是只读不可修改的一组文件,相同的层可以在镜像之间共享,然后多个层像搭积木一样堆叠起来,再使用一种叫“Union FS 联合文件系统”的技术把它们合并在一起,就形成了容器最终看到的文件系统。你可以用命令 docker inspect 来查看镜像的分层信息。

Docker 会检查是否有重复的层,如果本地已经存在就不会重复下载,如果层被其他镜像共享就不会删除,这样就可以节约磁盘和网络成本。

如果还想了解 UnionFS,可以参考这篇文章:结合docker命令理解镜像

2.5 远端镜像仓库

镜像仓库就非常简单了,顾名思义,它是存储镜像的地方。在构建好镜像后,开发者们通常会将镜像上传到 Registry 服务器上进行保存。这样可以保证不会因本机故障而导致镜像丢失,同时,其他开发者也能很方便地通过网络方式下载公开镜像仓库中的镜像,真正做到“开箱即用”。

这篇文章是基于罗剑锋老师的k8s课程入门部分改写而来的。

posted @ 2023-02-09 09:46  joexu01  阅读(164)  评论(0编辑  收藏  举报