gitbook/操作系统实战45讲/docs/408927.md
2022-09-03 22:05:03 +08:00

295 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 44 | 容器:如何理解容器的实现机制?
你好我是LMOS。
上节课我带你通过KVM技术打开了计算机虚拟化技术的大门KVM技术是基于内核的虚拟机同样的KVM和传统的虚拟化技术一样需要虚拟出一台完整的计算机对于某些场景来说成本会比较高其实还有比KVM更轻量化的虚拟化技术也就是今天我们要讲的容器。
这节课我会先带你理解容器的概念,然后把它跟虚拟机作比较,之后为你讲解容器的基础架构跟基础技术,虽然这样安排有点走马观花,但这些内容都是我精选的核心知识,相信会为你以后继续探索容器打下一个良好的基础。
## 什么是容器
容器的名词源于container但不得不说我们再次被翻译坑了。相比“容器”如果翻译成“集装箱”会更加贴切。为啥这么说呢
我们先从“可复用”说起,现实里我们如果有一个集装箱的模具和原材料,很容易就能批量生产出多个规格相同的集装箱。从功能角度看,集装箱可以用来打包和隔离物品。不同类型的物品放在不同的集装箱里,这样东西就不会混在一起。
而且,集装箱里的物品在运输过程中不易损坏,具体说就是不管集装箱里装了什么东西,被送到哪里,只要集装箱没破坏,再次开箱时放在里面的东西就是完好无损的。
因此,我们可以这样来理解,容器是这样一种工作模式:**轻量、拥有一个模具(镜像),既可以规模生产出多个相同集装箱(运行实例),又可以和外部环境(宿主机)隔离,最终实现对“内容”的打包隔离,方便其运输传送**。
如果把容器看作集装箱,那内部运行的进程/应用就应该是集装箱里的物品了,类比来看,容器的**目的就是提供一个独立的运行环境**。
## 和虚拟机的对比
![](https://static001.geekbang.org/resource/image/02/24/020aa8ec5243029e5ca5b6302a95e224.jpg?wh=3805x2423 "容器和传统虚拟机的比较
")
我们传统的虚拟化技术可以通过硬件模拟来实现也可以通过操作系统软件来实现比如上节课提到的KVM。
为了让虚拟的应用程序达到和物理机相近的效果我们使用了Hypervisor/VMM虚拟机监控器它允许多个操作系统共享一个或多个CPU但是却带来了很大的开销由于虚拟机中包括全套的OS调度与资源占用都非常重。
容器container是一种更加轻量级的操作系统虚拟化技术它将应用程序依赖包库文件等运行依赖环境打包到标准化的镜像中通过容器引擎提供进程隔离、资源可限制的运行环境实现应用与OS平台及底层硬件的解耦。
为了大大降低我们的计算成本,节省物理资源,提升计算机资源的利用率,让虚拟技术更加轻量化,容器技术应运而生。那么如何实现一个容器程序呢?我们需要先看看容器的基础架构。
## 看一看容器基础架构
![](https://static001.geekbang.org/resource/image/e2/85/e2af00ffbb32595474af408127918d85.jpg?wh=2755x2195 "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](https://registry.hub.docker.com/),我们也可以私有化部署到内网,通过局域网对镜像进行管理。
### 容器管理引擎进程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的实现中可以分为以下三类驱动。
* **graphdriver**负责容器镜像的管理,主要就是镜像的存储和获取,当镜像下载的时候,会将镜像持久化存储到本地的指定目录;
* **networkdriver**主要负责Docker容器网络环境的配置如Docker运行时进行IP分配端口映射以及启动时创建网桥和虚拟网卡
* **execdriver是**Docker的执行驱动通过操作Lxc或者libcontainer实现资源隔离。它负责创建管理容器运行命名空间、管理分配资源和容器内部真实进程的运行
**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的隔离资源和系统调用参数。
![](https://static001.geekbang.org/resource/image/ff/9f/ffa1b9666d93d69817971a2c8a3ef59f.jpg?wh=1732x939)
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\_NEWIPCIPC 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对应的文件
nstypeNameSpace类型
```
好了刚刚我给你简单讲了NameSpace的作用以及不同类型的NameSpace。这几种Namespace都是只为做一件事**隔离容器的运行环境,**此外NameSpace是和进程息息相关的NameSpace将全局共享的资源划分为多组进程间共享的资源当一个NameSpace下的进程全部退出NameSpace也会被销毁。
有了这么多的Namespace共同合作我们才最终实现了容器进程运行环境的隔离。
现在隔离的问题已经解决那么容器是怎么限制每个被隔离的容器的开销大小保证容器间不会存在打架互相争抢的问题呢这就要用到Linux内核的Cgroups技术了。
### Linux Cgroups
Linux CgroupsControl 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通过树状结构串起来通过虚拟文件系统的方式暴露给用户。
![](https://static001.geekbang.org/resource/image/91/9d/91475d4ea6f53de994f6ce66f752749d.jpg?wh=3055x2065 "cgroup示意图")
Linux内核提供了很多Cgroup **subsystem**参数,我们了解一下容器中常用的几类。:
![](https://static001.geekbang.org/resource/image/ff/fe/ff788dfd03886e9f7b4c39bb9d83b9fe.jpg?wh=2945x1688)
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
```
![](https://static001.geekbang.org/resource/image/d8/48/d8bd26523acaec8309a87b54e3b73f48.png?wh=1076x324)
可以看到我们生成了Cgroup的几个配置文件这些就是hierarchy根节点的配置文件。
**创建子Cgroup**
接下来我们要创建子Cgroup名为cgroup-cosmos-a。
```
cd cgroup-cosmos
mkdir cgroup-cosmos-a
tree
```
可以看到我们在根节点下新建一个目录会默认识别为一个子Cgroup而且它会继承父级的配置。
![](https://static001.geekbang.org/resource/image/00/49/0096af961a92d8a7e2c262c0b8072749.png?wh=792x456)
**在Cgroup中添加、移动进程**
目录建好了添加、移动进程的操作也很简单我们只要将当前的进程ID写入对应的cgroup文件即可代码如下。
```
echo $$ // 583
cat /proc/583/cgroup
```
![](https://static001.geekbang.org/resource/image/01/03/017f7ee9e52576a128a7c0148a624e03.png?wh=1010x572)
结合图里的代码我们发现当前的583进程是在cgroup-cosmos下现在我们将终端进程移动到cgroup-cosmos-a。
```
cd cgroup-cosmos-a
sh -c "echo $$ >> tasks"
```
![](https://static001.geekbang.org/resource/image/91/93/91574cb0703484402f7253bd48820693.png?wh=1070x476)
可以看到。现在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
```
![](https://static001.geekbang.org/resource/image/dd/2e/dd7f2f85fab07ed11870c032cc1bee2e.png?wh=1456x438)
先启动一个未限制的进程,代码如下。
```
stress --vm-bytes 200m --vm-keep -m 1
```
代码运行后的结果如下图所示。
![](https://static001.geekbang.org/resource/image/28/c0/2896f2b21b6a8f8ee028883c0e20a9c0.png?wh=1240x114)
现在我们设置最大内存并且将进程移动到当前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从而实现具体对容器进行的操作。
之后我们分析了一个容器如何通过各种内核提供的技术(**NameSpaceCgroupUnionFS**等技术)的组合运行起来,提供对外访问隔离功能。
其实容器的技术本身没有太大的技术难度,容器就本质上就是一种**特殊的进程**,利用了操作系统本身的资源限制和隔离能力,通过约束和修改进程的动态表现,从而为其创造出一个“边界”——也就是独立的"运行环境"有兴趣的同学可以深入了解Docker的源码并可以自己尝试重新实现一个简单的的容器。
## 思考题
在我们启动容器后,一旦容器退出,容器可写层的所有内容都会被删除。那么,如果用户需要持久化容器里的部分数据该怎么办呢?
欢迎你在留言区跟我交流,也欢迎你把这节课分享给朋友。
我是LMOS我们下节课见