你好,我是LMOS。

上节课我带你通过KVM技术打开了计算机虚拟化技术的大门,KVM技术是基于内核的虚拟机,同样的KVM和传统的虚拟化技术一样,需要虚拟出一台完整的计算机,对于某些场景来说成本会比较高,其实还有比KVM更轻量化的虚拟化技术,也就是今天我们要讲的容器。

这节课我会先带你理解容器的概念,然后把它跟虚拟机作比较,之后为你讲解容器的基础架构跟基础技术,虽然这样安排有点走马观花,但这些内容都是我精选的核心知识,相信会为你以后继续探索容器打下一个良好的基础。

什么是容器

容器的名词源于container,但不得不说我们再次被翻译坑了。相比“容器”,如果翻译成“集装箱”会更加贴切。为啥这么说呢?

我们先从“可复用”说起,现实里我们如果有一个集装箱的模具和原材料,很容易就能批量生产出多个规格相同的集装箱。从功能角度看,集装箱可以用来打包和隔离物品。不同类型的物品放在不同的集装箱里,这样东西就不会混在一起。

而且,集装箱里的物品在运输过程中不易损坏,具体说就是不管集装箱里装了什么东西,被送到哪里,只要集装箱没破坏,再次开箱时放在里面的东西就是完好无损的。

因此,我们可以这样来理解,容器是这样一种工作模式:轻量、拥有一个模具(镜像),既可以规模生产出多个相同集装箱(运行实例),又可以和外部环境(宿主机)隔离,最终实现对“内容”的打包隔离,方便其运输传送

如果把容器看作集装箱,那内部运行的进程/应用就应该是集装箱里的物品了,类比来看,容器的目的就是提供一个独立的运行环境

和虚拟机的对比

我们传统的虚拟化技术可以通过硬件模拟来实现,也可以通过操作系统软件来实现,比如上节课提到的KVM。

为了让虚拟的应用程序达到和物理机相近的效果,我们使用了Hypervisor/VMM(虚拟机监控器),它允许多个操作系统共享一个或多个CPU,但是却带来了很大的开销,由于虚拟机中包括全套的OS,调度与资源占用都非常重。

容器(container)是一种更加轻量级的操作系统虚拟化技术,它将应用程序,依赖包,库文件等运行依赖环境打包到标准化的镜像中,通过容器引擎提供进程隔离、资源可限制的运行环境,实现应用与OS平台及底层硬件的解耦。

为了大大降低我们的计算成本,节省物理资源,提升计算机资源的利用率,让虚拟技术更加轻量化,容器技术应运而生。那么如何实现一个容器程序呢?我们需要先看看容器的基础架构。

看一看容器基础架构

容器概念的起源是哪里?其实是从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的实现中,可以分为以下三类驱动。

libcontainer

上面我们提到execdriver,通过调用libcontainer来完成对容器的操作,加载容器配置container,继而创建真正的Docker容器。libcontainer提供了访问内核中和容器相关的API,负责对容器进行具体操作。

容器可以创建出一个相对隔离的环境,就容器技术本身来说,容器的核心部分是利用了我们操作系统内核的虚拟化技术,那么libcontainer中到底用到了哪些操作系统内核中的基础能力呢?

容器基础技术

我们经常听到,Docker是一个基于Linux操作系统下的Namespace和Cgroups和UnionFS的虚拟化工具,下面我带你看一下这几个容器用到的内核中的基础能力。

Linux NameSpace

容器的一大特点就是创造出一个相对隔离的环境。在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下的进程全部退出,NameSpace也会被销毁。

有了这么多的Namespace共同合作,我们才最终实现了容器进程运行环境的隔离。

现在隔离的问题已经解决,那么容器是怎么限制每个被隔离的容器的开销大小,保证容器间不会存在打架,互相争抢的问题呢?这就要用到Linux内核的Cgroups技术了。

Linux Cgroups

Linux Cgroups(Control Groups)主要负责对指定的一组进程做资源限制,同时可以统计其资源使用。具体包括CPU、内存、存储、I/O、网络等资源。

我们有了Cgroups,就不用担心某一组容器进程突然将计算机的全部物理资源占满这种问题了,可以方便地限制和实时地监控某一组容器进程的资源占用。

Cgrpups包含几个核心概念,分别是Task (任务)、Control Groups(控制组)、subsystem(子系统)、hierarchy(层级数)

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

好,说到这儿相信你已经对 NamespaceCgroups 这两大技术建立了初步认知,它们是 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的源码,并可以自己尝试重新实现一个简单的的容器。

思考题

在我们启动容器后,一旦容器退出,容器可写层的所有内容都会被删除。那么,如果用户需要持久化容器里的部分数据该怎么办呢?

欢迎你在留言区跟我交流,也欢迎你把这节课分享给朋友。

我是LMOS,我们下节课见!

评论