操作系统:容器 -- 如何理解容器的实现机制
KVM 技术是基于内核的虚拟机,同样的 KVM 和传统的虚拟化技术一样,需要虚拟出一台完整的计算机,对于某些场景来说成本会比较高,其实还有比 KVM 更轻量化的虚拟化技术,也就是今天我们要讲的容器。
什么是容器
容器的名词源于 container,但不得不说我们再次被翻译坑了。相比“容器”,如果翻译成“集装箱”会更加贴切。为啥这么说呢?
我们先从“可复用”说起,现实里我们如果有一个集装箱的模具和原材料,很容易就能批量生产出多个规格相同的集装箱。从功能角度看,集装箱可以用来打包和隔离物品。不同类型的物品放在不同的集装箱里,这样东西就不会混在一起。
而且,集装箱里的物品在运输过程中不易损坏,具体说就是不管集装箱里装了什么东西,被送到哪里,只要集装箱没破坏,再次开箱时放在里面的东西就是完好无损的。
因此,我们可以这样来理解,容器是这样一种工作模式:轻量、拥有一个模具(镜像),既可以规模生产出多个相同集装箱(运行实例),又可以和外部环境(宿主机)隔离,最终实现对“内容”的打包隔离,方便其运输传送。
如果把容器看作集装箱,那内部运行的进程 / 应用就应该是集装箱里的物品了,类比来看,容器的目的就是提供一个独立的运行环境。
和虚拟机的对比
容器和传统虚拟机的比较:
我们传统的虚拟化技术可以通过硬件模拟来实现,也可以通过操作系统软件来实现,比如上节课提到的 KVM。
为了让虚拟的应用程序达到和物理机相近的效果,我们使用了 Hypervisor/VMM(虚拟机监控器),它允许多个操作系统共享一个或多个 CPU,但是却带来了很大的开销,由于虚拟机中包括全套的 OS,调度与资源占用都非常重。
容器(container)是一种更加轻量级的操作系统虚拟化技术,它将应用程序,依赖包,库文件等运行依赖环境打包到标准化的镜像中,通过容器引擎提供进程隔离、资源可限制的运行环境,实现应用与 OS 平台及底层硬件的解耦。
为了大大降低我们的计算成本,节省物理资源,提升计算机资源的利用率,让虚拟技术更加轻量化,容器技术应运而生。那么如何实现一个容器程序呢?我们需要先看看容器的基础架构。
容器 基础架构
Docker架构示意图:
容器概念的起源是哪里?其实是从 UNIX 系统的 chroot 这个系统调用开始的。
在 Linux 系统上,LXC 是第一个比较完整的容器,但是功能上还存在一定的不足,例如缺少可移植性,不够标准化。后面 Docker 的出现解决了容器标准化与可移植性问题,成为现在应用最广泛的容器技术。
Docker 是最经典,使用范围最广,最具有代表性的容器技术。所以我们就以它为例,先对容器架构进行分析,Docker 应用是一种 C/S 架构,包括 3 个核心部分。
容器客户端(Client)
首先来看 Docker 的客户端,其主要任务是接收并解析用户的操作指令和执行参数,收集所需要的配置信息,根据相应的 Docker 命令通过 HTTP 或 REST API 等方式与 Docker daemon(守护进程)进行交互,并将处理结果返回给用户,实现 Docker 服务使用与管理。
当然我们也可以使用其他工具通过 Docker 提供的 API 与 daemon 通信。
容器镜像仓库(Registry)
Registry 就是存储容器镜像的仓库,在容器的运行过程中,Client 在接受到用户的指令后转发给 Host 下的 Daemon,它会通过网络与 Registry 进行通信,例如查询镜像(search),下载镜像(pull),推送镜像(push)等操作。
镜像仓库可以部署在公网环境,如Docker Hub,我们也可以私有化部署到内网,通过局域网对镜像进行管理。
容器管理引擎进程(Host)
容器引擎进程是 Docker 架构的核心,包括运行 Docker Daemon(守护进程)、Image(镜像)、驱动(Driver)、Libcontainer(容器管理)等。
接下来,我们详细说说守护进程、镜像、驱动和容器管理这几个模块的运作机制 / 实现原理。
Docker Daemon 详解
首先来看 Docker Daemon 进程,它是一个常驻后台的系统进程,也是 Docker 架构中非常重要的一环。Docker Daemon 负责监听客户端请求,然后执行后续的对应逻辑,还能管理 Docker 对象(容器、镜像、网络、磁盘等)。
可以将Daemon分为三部分:Server、Job、Engine
**Server **负责接收客户端发来的请求(由 Daemon 在后台启动 Server)。接受请求以后 Server 通过路由与分发调度找到相应的 Handler 执行请求,然后与容器镜像仓库交互(查询、拉取、推送)镜像并将结果返回给 Docker Client。
而 Engine 是 Daemon 架构中的运行引擎,同时也是 Docker 运行的核心模块。Engine 扮演了 Docker container 存储仓库的角色。Engine 执行的每一项工作,都可以拆解成多个最小动作——Job,这是 Engine 最基本的工作执行单元。
其实,Job 不光能用在 Engine 内部,Docker 内部每一步操作,都可以抽象为一个 Job。Job 负责执行各项操作时,如储存拉取的镜像,配置容器网络环境等,会使用下层的 Driver(驱动)来完成。
Docker Driver
Driver 顾名思义就是 Docker 中的驱动。设计驱动这一层依旧是解耦,将容器管理的镜像、网络和隔离执行逻辑从 Docker Daemon 的逻辑中剥离。
在 Docker Driver 的实现中,可以分为以下三类驱动。
-
1、graphdriver 负责容器镜像的管理,主要就是镜像的存储和获取,当镜像下载的时候,会将镜像持久化存储到本地的指定目录;
-
2、networkdriver 主要负责 Docker 容器网络环境的配置,如 Docker 运行时进行 IP 分配端口映射以及启动时创建网桥和虚拟网卡;
-
3、execdriver 是Docker 的执行驱动,通过操作 Lxc 或者 libcontainer 实现资源隔离。它负责创建管理容器运行命名空间、管理分配资源和容器内部真实进程的运行;
libcontainer
上面我们提到 execdriver,通过调用 libcontainer 来完成对容器的操作,加载容器配置 container,继而创建真正的 Docker 容器。libcontainer 提供了访问内核中和容器相关的 API,负责对容器进行具体操作。
容器可以创建出一个相对隔离的环境,就容器技术本身来说,容器的核心部分是利用了我们操作系统内核的虚拟化技术,那么 libcontainer 中到底用到了哪些操作系统内核中的基础能力呢?
容器基础技术
容器的一大特点就是创造出一个相对隔离的环境。在 Linux 内核中,实现各种资源隔离功能的技术就叫** Linux Namespace**,它可以隔离一系列的系统资源,比如 PID(进程 ID)、UID(用户 ID)、Network 等。
看到这里,你很容易就会想到开头讲的 chroot 系统调用。类似于 chroot 把当前目录变成被隔离出的根目录,使得当前目录无法访问到外部的内容,Namespace 在基于 chroot 扩展升级的基础上,也可以分别将一些资源隔离起来,限制每个进程能够访问的资源。
Linux 内核提供了 7 类 Namespace,以下是不同 Namespace 的隔离资源和系统调用参数。
-
1、PID Namespace:保障进程隔离,每个容器都以 PID=1 的 init 进程来启动。PID Namespace 使用了的参数 CLONE_NEWPID。类似于单独的 Linux 系统一样,每个 NameSpace 都有自己的初始化进程,PID 为 1,作为所有进程的父进程,父进程拥有很多特权。其他进程的 PID 会依次递增,子 NameSpace 的进程映射到父 NameSpace 的进程上,父 NameSpace 可以拿到全部子 NameSpace 的状态,但是每个子 NameSpace 之间是互相隔离的。
-
2、User Namespace:用于隔离容器中 UID、GID 以及根目录等。User Namespace 使用了 CLONE_NEWUSER 的参数,可配置映射宿主机和容器中的 UID、GID。某一个 UID 的用户,虚拟化出来一个 Namespace,在当前的 Namespace 下,用户是具有 root 权限的。但是,在宿主机上面,他还是那个用户,这样就解决了用户之间隔离的问题。
-
3、UTS Namespace:保障每个容器都有独立的主机名或域名。UTS Namespace 使用了的参数 CLONE_NEWUTS,用来隔离 hostname 和 NIS Domain name 两个系统标识,在 UTS Namespace 里面,每个 Namespace 允许有自己的主机名,作用就是可以让不同 namespace 中的进程看到不同的主机名。
-
4、Mount Namespace: 保障每个容器都有独立的目录挂载路径。Mount Namespace 使用了的参数 CLONE_NEWNS,用来隔离各个进程看到的挂载点视图,Mount Namespace 非常类似于我们前面提到的的 chroot 系统调用。
-
5、NET Namespace:保障每个容器有独立的网络栈、socket 和网卡设备。NET Namespace 使用了参数 CLONE_NEWNET,隔离了和网络有关的资源,如网络设备、IP 地址端口等。NET Namespace 可以让每个容器拥有自己独立的(虚拟的)网络设备,而且容器内的应用可以绑定到自己的端口,每个 Namespace 内的端口都不会互相冲突。
-
6、IPC Namespace:保障每个容器进程 IPC 通信隔离。IPC Namespace 使用了的参数 CLONE_NEWIPC,IPC Namespace 用来隔离 System V IPC 和 POSIX message queues,只有在相同 IPC 命名空间的容器进程之间才可以共享内存、信号量、消息队列通信。
-
7、Cgroup Namespace:保障容器容器中看到的 cgroup 视图,像宿主机一样以根形式来呈现,同时让容器内使用 cgroup 变得更安全
上面讲了这么多类 Namespace,我们可以先从共性入手熟悉它们,7 类 Namespace 主要使用如下 3 个系统调用函数,其实也就是和进程有关的调用函数。
- 1.clone:创建新进程,根据传入上面的不同 NameSpace 类型,来创建不同的 NameSpace 进行隔离,同样的,对应的子进程也会被包含到这些 Namespace 中。
int clone(int (*child_func)(void *), void *child_stac, int flags, void *arg);
flags就是标志用来描述你需要从父进程继承哪些资源,这里flags参数为将要创建的NameSpace类型,可以为一个或多个
- 2.unshare:将进程移出某个指定类型的 Namespace,并加入到新创建的 NameSpace 中, 容器中 NameSpace 也是通过 unshare 系统调用创建的。
int unshare(int flags);
flags同上
- 3.setns:将进程加入到 Namespace 中。
int setns(int fd, int nstype);
fd: 加入的NameSpace,指向/proc/[pid]/ns/目录里相应NameSpace对应的文件,
nstype:NameSpace类型
这几种 Namespace 都是只为做一件事,隔离容器的运行环境,此外,NameSpace 是和进程息息相关的,NameSpace 将全局共享的资源划分为多组进程间共享的资源,当一个 NameSpace 下的进程全部退出,NameSpace 也会被销毁。
有了这么多的 Namespace 共同合作,我们才最终实现了容器进程运行环境的隔离。
现在隔离的问题已经解决,那么容器是怎么限制每个被隔离的容器的开销大小,保证容器间不会存在打架,互相争抢的问题呢?这就要用到 Linux 内核的 Cgroups 技术了。
Linux Cgroups
Linux Cgroups(Control Groups)主要负责对指定的一组进程做资源限制,同时可以统计其资源使用。具体包括 CPU、内存、存储、I/O、网络等资源
有了 Cgroups,就不用担心某一组 容器进程突然将计算机的全部物理资源占满这种问题了,可以方便地限制和实时地监控某一组容器进程的资源占用。
Cgrpups 包含几个核心概念,分别是 Task (任务)、Control Groups(控制组)、subsystem(子系统)、hierarchy(层级数)。
- Task: 任务,在 Cgroup 中,任务同样是一个进程。
- Control Groups:控制组,Cgroups 的一组进程,并可以在这个 Cgroups 通过参数,将一组进程和一组 linux subsystem 关联起来。
- subsystem:子系统,是一组资源控制模块,subsystem 作用于 hierarchy 的 Cgroup 节点,并控制节点中进程的资源占用。
- hierarchy:层级树 Cgroups,将 Cgroup 通过树状结构串起来,通过虚拟文件系统的方式暴露给用户。
cgroup示意图:
Linux 内核提供了很多 Cgroup subsystem 参数,我们了解一下容器中常用的几类。:
Linux 内核提供了很多 Cgroup 驱动,容器中常用的是下面两种。
-
1.Cgroupfs 驱动:需要限制 CPU 或内存使用时,直接把容器进程的 PID 写入相应的 CPU 或内存的 cgroup。
-
2.systemdcgroup 驱动:提供 cgroup 管理,所有的 cgroup 写操作需要通过 systemd 的接口来完成,不能手动修改。
了解了 Cgroups subsystem 的类型,那么容器到底要怎么调用内核才能配置 Cgroups 呢?我们动手实验下,才会有更深的体会。
新建 Cgroup 挂载文件
先我们试下新建一个 Cgroup,名为 cgroup-cosmos,我们先创建一个 hierarchy,再进行挂载代码如下。
mkdir cgroup-cosmos
sudo mount -t cgroup -o none,name=cgroup-cosmos cgroup-cosmos cgroup-cosmos/
ll ./cgroup-cosmos
可以看到我们生成了 Cgroup 的几个配置文件,这些就是 hierarchy 根节点的配置文件
创建子 Cgroup
接下来,我们要创建子 Cgroup,名为 cgroup-cosmos-a。
cd cgroup-cosmos
mkdir cgroup-cosmos-a
tree
可以看到,我们在根节点下新建一个目录,会默认识别为一个子 Cgroup,而且它会继承父级的配置。
在 Cgroup 中添加、移动进程
目录建好了,添加、移动进程的操作也很简单,我们只要将当前的进程 ID 写入对应的 cgroup 文件即可,代码如下。
echo $$ // 583
cat /proc/583/cgroup
结合图里的代码我们发现,当前的 583 进程是在 cgroup-cosmos 下,现在我们将终端进程移动到 cgroup-cosmos-a。
cd cgroup-cosmos-a
sh -c "echo $$ >> tasks"
可以看到。现在 583 进程已经移动到 group-cosmos: /group-cosmos-a 目录下了。
限制 Cgroup 中进程的资源
前面我们曾经说过 Cgroup 可以限制资源,那具体要怎么操作呢?
前面我们创建的 hierarchy 其实并没有关联到任何的 subsystem,所以没办法通过它来限制资源使用。但是系统给每个 hierarchy 都制定了默认的 subsystem,我们看一下具体代码。
# 查看hierarchy的subsystem,为/sys/fs/cgroup/memory/
mount | grep memory
cd /sys/fs/cgroup/memory/
sudo mkdir cosmos-limit-memory
cd cosmos-limit-memory
ls
先启动一个未限制的进程,代码如下。
stress --vm-bytes 200m --vm-keep -m 1
代码运行后的结果如下图所示。
现在我们设置最大内存,并且将进程移动到当前 Cgroup 中,在此运行一个进程。这样操作以后,我们就可以通过 top 命令看到已经将 stress 的最大内存限制到 100m 了。
# 设置最大内存占用
sh -c "echo "100m" > memory.limit_in_bytes"
# 移动到这个cgroup内
sh -c "echo $$ >> tasks"
# 再启动一个机型
stress --vm-bytes 200m --vm-keep -m 1
top
好,说到这儿相信你已经对 Namespace 和 Cgroups 这两大技术建立了初步认知,它们是 Linux 操作系统下开发容器的最基本的技术。
小结
首先我们了解了到底什么是容器,以 Docker 为蓝本分析了容器的基础功能架构,包括客户端(Client)、管理进程(Host)、镜像仓库(Registry)三大部分。
引擎进程(Host)是 Docker 的核心,包括引擎进程(Daemon)、驱动(Driver)、容器管理包(Libcontainer)、镜像(Images)。
用户通过 Client 与 Daemon 建立通信,并发送请求给后者;而 Daemon 作为 Docker 架构中的核心部分,其中的 Server 负责接收 Client 发送的请求,而后 Engine 执行 Docker 内部的一系列工作,每一项工作都是以一个 Job 的形式的存在;在执行 Job 的过程中,我们会使用下层的 Driver(驱动)来完成工作,driver 通过 libcontainer 来访问内核中与容器相关的 API,从而实现具体对容器进行的操作。
之后我们分析了一个容器如何通过各种内核提供的技术(NameSpace,Cgroup,UnionFS 等技术)的组合运行起来,提供对外访问隔离功能。
其实容器的技术本身没有太大的技术难度,容器就本质上就是一种特殊的进程,利用了操作系统本身的资源限制和隔离能力,通过约束和修改进程的动态表现,从而为其创造出一个“边界”——也就是独立的"运行环境",有兴趣的同学可以深入了解 Docker 的源码,并可以自己尝试重新实现一个简单的的容器。