You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

387 lines
24 KiB
Markdown

2 years ago
# 01 | 认识容器:容器的基本操作和实现原理
你好我是程远。作为一名工程师我猜在过去的几年时间里你肯定用过或者听人提起过容器Container
说实话,容器这东西一点都不复杂,如果你只是想用的话,那跟着[Docker官网](https://docs.docker.com/get-started/)的说明,应该十来分钟就能搞定。
简单来说,它就是个小工具,可以把你想跑的程序,库文件啊,配置文件都一起“打包”。
然后,我们在任何一个计算机的节点上,都可以使用这个打好的包。有了容器,一个命令就能把你想跑的程序跑起来,做到了**一次打包,就可以到处使用。**
今天是咱们整个课程的第一讲,我想和你来聊聊容器背后的实现机制。
当然,空讲原理也没什么感觉,所以我还是会先带着你启动一个容器玩玩,然后咱们再一起来探讨容器里面的两大关键技术—— Namespace和Cgroups。基本上理解了这两个概念你就能彻底搞懂容器的核心原理了。
## 做个镜像
话不多说咱们就先动手玩一玩。启动容器的工具有很多在这里我们还是使用Docker这个最常用的容器管理工具。
如果你之前根本没用过Docker的话那我建议你先去[官网](https://docs.docker.com/)看看文档,一些基础的介绍我就不讲了,那些内容你随便在网上一搜就能找到。
安装完Docker之后咱们先来用下面的命令运行一个httpd服务。
```
# docker run -d centos/httpd:latest
```
这命令也很简单run的意思就是要启动一个容器 `-d` 参数里d是Daemon的首字母也就是让容器在后台运行。
最后一个参数 `centos/httpd:latest` 指定了具体要启动哪一个镜像比如这里咱们启动的是centos/httpd这个镜像的latest版本。
镜像是Docker公司的创举也是一个伟大的发明。你想想在没有容器之前你想安装httpd的话会怎么做是不是得运行一连串的命令甚至不同的系统上操作方法也不一样
但你看,有了容器之后,你只要运行一条命令就搞定了。其实所有的玄机都在这个镜像里面。
镜像这么神奇,那它到底是怎么一回事呢?其实,镜像就是一个特殊的文件系统,
**它提供了容器中程序执行需要的所有文件。**具体来说,就是应用程序想启动,需要三类文件:相关的程序可执行文件、库文件和配置文件,这三类文件都被容器打包做好了。
这样,在容器运行的时候就不再依赖宿主机上的文件操作系统类型和配置了,做到了想在哪个节点上运行,就可以在哪个节点上立刻运行。
那么我们怎么来做一个容器镜像呢?
刚才的例子里,我们用的 `centos/httpd:latest` 这个镜像是 **Docker镜像库**里直接提供的。当然我们也可以自己做一个提供httpd服务的容器镜像这里仍然可以用Docker这个工具来自定义镜像。
Docker为用户自己定义镜像提供了一个叫做Dockerfile的文件在这个Dockerfile文件里你可以设定自己镜像的创建步骤。
如果我们自己来做一个httpd的镜像也不难举个例子我们可以一起来写一个Dockerfile体会一下整个过程。用Dockerfile build image的 Dockerfile 和对应的目录我放在[这里](http://github.com/chengyli/training/tree/main/image/demo)了。
操作之前我们首先要理解这个Dockerfile做了什么其实它很简单只有下面这5行
```shell
# cat Dockerfile
FROM centos:8.1.1911
RUN yum install -y httpd
COPY file1 /var/www/html/
ADD file2.tar.gz /var/www/html/
CMD ["/sbin/httpd", "-D", "FOREGROUND"]
```
我们看下它做了哪几件事在一个centos的基准镜像上安装好httpd的包然后在httpd提供文件服务的配置目录下把需要对外提供的文件file1和file2拷贝过去最后指定容器启动以后需要自动启动的httpd服务。
有了这个镜像我们希望容器启动后就运行这个httpd服务让用户可以下载file1还有file2这两个文件。
我们具体来看这个Dockerfile的每一行第一个大写的词都是Dockerfile专门定义的指令也就是 `FROM`、`RUN`、`COPY`、`ADD`、`CMD`这些指令都很基础所以我们不做详细解释了你可以参考Dockerfile的[官方文档](https://docs.docker.com/engine/reference/builder/)。
我们写完这个Dockerfile之后想要让它变成一个镜像还需要执行一下 `docker build` 命令。
下面这个命令中 `-f ./Dockerfile` 指定Dockerfile文件`-t registry/httpd:v1` 指定了生成出来的镜像名,它的格式是"name:tag",这个镜像名也是后面启动容器需要用到的。
```
# docker build -t registry/httpd:v1 -f ./Dockerfile .
```
`docker build` 执行成功之后,我们再运行 `docker images` 这个命令,就可以看到生成的镜像了。
```shell
# docker images
REPOSITORY TAG IMAGEID CREATED SIZE
registry/httpd v1 c682fc3d4b9a 4 seconds ago 277MB
```
## 启动一个容器 (Container)
做完一个镜像之后,你就可以用这个镜像来启动一个容器了,我们刚才做的镜像名字是 `registry/httpd:v1`,那么还是用 `docker run` 这个命令来启动容器。
```
# docker run -d registry/httpd:v1
```
容器启动完成后,我们可以用 `docker ps` 命令来查看这个已经启动的容器:
```shell
# docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c5a9ff78d9c1 registry/httpd:v1 "/sbin/httpd -D FORE…" 2 seconds ago Up 2 seconds loving_jackson
```
在前面介绍Dockerfile的时候我们说过做这个镜像是用来提供HTTP服务的也就是让用户可以下载file1、file2这两个文件。
那怎样来验证我们建起来的容器是不是正常工作的呢?可以通过这两步来验证:
* 第一步我们可以进入容器的运行空间查看httpd服务是不是启动了配置文件是不是正确的。
* 第二步对于HTTP文件服务如果我们能用 `curl` 命令下载文件就可以证明这个容器提供了我们预期的httpd服务。
我们先来做第一步验证,我们可以运行 `docker exec` 这个命令进入容器的运行空间至于什么是容器的运行空间它的标准说法是容器的命名空间Namespace这个概念我们等会儿再做介绍。
进入容器运行空间之后我们怎么确认httpd的服务进程已经在容器里启动了呢
我们运行下面这个 `docker exec` 命令,也就是执行 `docker exec c5a9ff78d9c1 ps -ef` 可以看到httpd的服务进程正在容器的空间中运行。
```shell
# docker exec c5a9ff78d9c1 ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
apache 6 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
apache 7 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
apache 8 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
apache 9 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
```
这里我解释一下,在这个 `docker exec` 后面紧跟着的ID表示容器的ID这个ID就是我们之前运行 `docker ps` 查看过那个容器容器的ID值是 `c5a9ff78d9c1` 。在这个ID值的后面就是我们要在容器空间里运行的 `ps -ef` 命令。
接下来我们再来确认一下httpd提供文件服务的目录中file1和file2文件是否存在。
我们同样可以用 `docker exec` 来查看一下容器的文件系统中httpd提供文件服务的目录 `/var/www/html` 是否有这两个文件。
很好我们可以看到file1、file2这两个文件也都放在指定目录中了。
```
# docker exec c5a9ff78d9c1 ls /var/www/html
file1
file2
```
到这里我们完成了第一步的验证进入到容器的运行空间里验证了httpd服务已经启动配置文件也是正确的。
那下面我们要做第二步的验证,用 `curl` 命令来验证是否可以从容器的httpd服务里下载到文件。
如果要访问httpd服务我们就需要知道这个容器的IP地址。容器的网络空间也是独立的有一个它自己的IP。我们还是可以用 `docker exec` 进入到容器的网络空间查看一下这个容器的IP。
运行下面的这条 `docker exec c5a9ff78d9c1 ip addr` 命令我们可以看到容器里网络接口eth0上配置的IP是 `172.17.0.2`
这个IP目前只能在容器的宿主机上访问在别的机器上目前是不能访问的。关于容器网络的知识我们会在后面的课程里介绍。
```shell
# docker exec c5a9ff78d9c1 ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
168: eth0@if169: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
valid_lft forever preferred_lft forever
```
好了获取了httpd服务的IP地址之后我们随便下载一个文件试试比如选file2。
我们在宿主机上运行 `curl` 就可以下载这个文件了操作如下。很好文件下载成功了这证明了我们这个提供httpd服务的容器正常运行了。
```
# curl -L -O http://172.17.0.2/file2
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 6 100 6 0 0 1500 0 --:--:-- --:--:-- --:--:-- 1500
# ls
file2
```
上面的步骤完成之后,我们的第二步验证,用 `curl` 下载httpd服务提供的文件也成功了。
好了,我们刚才自己做了容器镜像,用这个镜像启动了容器,并且用 `docker exec` 命令检查了容器运行空间里的进程、文件和网络设置。
通过这上面的这些操作练习,估计你已经初步感知到,容器的文件系统是独立的,运行的进程环境是独立的,网络的设置也是独立的。但是它们和宿主机上的文件系统,进程环境以及网络感觉都已经分开了。
我想和你说,这个感觉没错,的确是这样。我们刚才启动的容器,已经从宿主机环境里被分隔出来了,就像下面这张图里的描述一样。
![](https://static001.geekbang.org/resource/image/4b/3a/4b67ff2f9070afbc2d0966464a67b83a.jpeg?wh=3200*1800)
从用户使用的角度来看容器和一台独立的机器或者虚拟机没有什么太大的区别但是它和虚拟机相比却没有各种复杂的硬件虚拟层没有独立的Linux内核。
容器所有的进程调度,内存访问,文件的读写都直接跑在宿主机的内核之上,这是怎么做到的呢?
## 容器是什么
要回答这个问题,你可以先记住这两个术语 **Namespace和Cgroups**。如果有人问你Linux上的容器是什么最简单直接的回答就是Namesapce和Cgroups。Namespace和Cgroups可以让程序在一个资源可控的独立隔离环境中运行这个就是容器了。
我们现在已经发现容器的进程、网络还有文件系统都是独立的。那问题来了容器的独立运行环境到底是怎么创造的呢这就要提到Namespace这个概念了。所以接下来就先从我们已经有点感觉的Namespace开始分析。
### Namespace
接着前面的例子我们正好有了一个正在运行的容器那我们就拿这个运行的容器来看看Namespace到底是什么
在前面我们运行 `docker exec c5a9ff78d9c1 ps -ef`看到了5个httpd进程而且也只有这5个进程。
```shell
# docker exec c5a9ff78d9c1 ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
apache 6 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
apache 7 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
apache 8 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
apache 9 1 0 01:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
```
如果我们不用 `docker exec`,直接在宿主机上运行 ps -ef就会看到很多进程。如果我们运行一下 `grep httpd` 同样可以看到这5个httpd的进程
```shell
# ps -ef | grep httpd
UID PID PPID C STIME TTY TIME CMD
root 20731 20684 0 18:59 ? 00:00:01 /sbin/httpd -D FOREGROUND
48 20787 20731 0 18:59 ? 00:00:00 /sbin/httpd -D FOREGROUND
48 20788 20731 0 18:59 ? 00:00:06 /sbin/httpd -D FOREGROUND
48 20789 20731 0 18:59 ? 00:00:05 /sbin/httpd -D FOREGROUND
48 20791 20731 0 18:59 ? 00:00:05 /sbin/httpd -D FOREGROUN
```
这两组输出结果到底有什么差别呢,你可以仔细做个对比,最大的不同就是**进程的PID不一样。**那为什么PID会不同呢或者说运行 `docker exec c5a9ff78d9c1 ps -ef``ps -ef` 实质的区别在哪里呢?
如果理解了PID为何不同我们就能搞清楚Linux Namespace的概念了为了方便后文的讲解我们先用下面这张图来梳理一下我们看到的PID。
![](https://static001.geekbang.org/resource/image/88/7a/888c00e0e8fe40edce3f1a9f6yye717a.jpeg?wh=3200*1800)
Linux在创建容器的时候就会建出一个PID NamespacePID其实就是进程的编号。这个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类型比如我们之前运行了 `docker exec c5a9ff78d9c1 ip addr` 这个命令去查看容器内部的IP地址这里其实就是在查看Network Namespace。
在Network Namespace中都有一套独立的网络接口比如这里的loeth0还有独立的TCP/IP的协议栈配置。
```shell
# docker exec c5a9ff78d9c1 ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
168: eth0@if169: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
valid_lft forever preferred_lft forever
```
我们还可以运行 `docker exec c5a9ff78d9c1 ls/` 查看容器中的根文件系统rootfs。然后你会发现它和宿主机上的根文件系统也是不一样的。**容器中的根文件系统,其实就是我们做的镜像。**
那容器自己的根文件系统完全独立于宿主机上的根文件系统,这一点是怎么做到的呢?其实这里依靠的是**Mount Namespace**Mount Namespace保证了每个容器都有自己独立的文件目录结构。
Namespace的类型还有很多我们查看"Linux Programmer's Manual"可以看到Linux中所有的Namespacecgroup/ipc/network/mount/pid/time/user/uts。
在这里呢,你需要记住的是 **Namespace 是Linux中实现容器的两大技术之一它最重要的作用是保证资源的隔离。**在后面的课程讲解到具体问题时我会不断地提到Namespace这个概念。
![](https://static001.geekbang.org/resource/image/5b/d2/5bbf4ac2fa9f81066732yy6f6202b8d2.jpg?wh=7301*2905)
好了我们刚才说了Namespace这些Namespace尽管类型不同其实都是为了隔离容器资源**PID Namespace负责隔离不同容器的进程Network Namespace又负责管理网络环境的隔离Mount Namespace管理文件系统的隔离。**
正是通过这些Namespace我们才隔离出一个容器这里你也可以把它看作是一台“计算机”。
既然是一台“计算机”你肯定会问这个“计算机”有多少CPU有多少Memory啊那么Linux如何为这些“计算机”来定义CPU定义Memory的容量呢
### Cgroups
想要定义“计算机”各种容量大小,就涉及到支撑容器的第二个技术**Cgroups Control Groups**了。Cgroups可以对指定的进程做各种计算机资源的限制比如限制CPU的使用率内存使用量IO设备的流量等等。
Cgroups究竟有什么好处呢要知道在Cgroups出现之前任意一个进程都可以创建出成百上千个线程可以轻易地消耗完一台计算机的所有CPU资源和内存资源。
但是有了Cgroups这个技术以后我们就可以对一个进程或者一组进程的计算机资源的消耗进行限制了。
Cgroups通过不同的子系统限制了不同的资源每个子系统限制一种资源。每个子系统限制资源的方式都是类似的就是把相关的一组进程分配到一个控制组里然后通过**树结构**进行管理,每个控制组都设有自己的资源控制参数。
完整的Cgroups子系统的介绍你可以查看[Linux Programmer's Manual](https://man7.org/linux/man-pages/man7/cgroups.7.html) 中Cgroups的定义。
这里呢我们只需要了解几种比较常用的Cgroups子系统
* CPU子系统用来限制一个控制组一组进程你可以理解为一个容器里所有的进程可使用的最大CPU。
* memory子系统用来限制一个控制组最大的内存使用量。
* pids子系统用来限制一个控制组里最多可以运行多少个进程。
* cpuset子系统 这个子系统来限制一个控制组里的进程可以在哪几个物理CPU上运行。
因为memory子系统的限制参数最简单所以下面我们就用memory子系统为例一起看看Cgroups是怎么对一个容器做资源限制的。
对于启动的每个容器都会在Cgroups子系统下建立一个目录在Cgroups中这个目录也被称作控制组比如下图里的"docker-<id1>""docker-<id2>"等。然后我们设置这个控制组的参数,通过这个方式,来限制这个容器的内存资源。
![](https://static001.geekbang.org/resource/image/61/63/6193bba2757e5cc34bb023b13cac7663.jpeg?wh=3200*1800)
还记得我们之前用Docker创建的那个容器吗在每个Cgroups子系统下对应这个容器就会有一个目录docker-**c5a9ff78d9c1……**这个容器的ID号容器中所有的进程都会储存在这个控制组中 cgroup.procs 这个参数里。
你看下面的这些进程号是不是很熟悉呢没错它们就是前面我们用ps看到的进程号。
我们实际看一下这个例子里的memory Cgroups它可以控制Memory的使用量。比如说我们将这个控制组Memory的最大用量设置为2GB。
具体操作是这样的我们把2\* 1024 \* 1024 \* 1024 = 2147483648这个值写入memory Cgroup控制组中的memory.limit\_in\_bytes里**这样设置后cgroup.procs里面所有进程Memory使用量之和最大也不会超过2GB。**
```shell
# cd /sys/fs/cgroup/memory/system.slice/docker-c5a9ff78d9c1fedd52511e18fdbd26357250719fa0d128349547a50fad7c5de9.scope
# cat cgroup.procs
20731
20787
20788
20789
20791
# echo 2147483648 > memory.limit_in_bytes
# cat memory.limit_in_bytes
2147483648
```
刚刚我们通过memory Cgroups定义了容器的memory可以使用的最大值。其他的子系统稍微复杂一些但用法也和memory类似我们在后面的课程中会结合具体的实例来详细解释其他的Cgroups。
这里我们还要提一下 **Cgroups有v1和v2两个版本**
Cgroups v1在Linux中很早就实现了各种子系统比较独立每个进程在各个Cgroups子系统中独立配置可以属于不同的group。
虽然这样比较灵活,但是也存在问题,会导致对**同一进程的资源协调比较困难**比如memory Cgroup与blkio Cgroup之间就不能协作。虽然v1有缺陷但是在主流的生产环境中大部分使用的还是v1。
Cgroups v2 做了设计改进,**解决了v1的问题使各个子系统可以协调统一地管理资源。**
不过Cgroups v2在生产环境的应用还很少因为该版本很多子系统的实现需要较新版本的Linux内核还有无论是主流的Linux发行版本还是容器云平台比如Kubernetes对v2的支持也刚刚起步。
所以啊我们在后面Cgroups的讲解里呢主要还是用 **Cgroups v1这个版本**在磁盘I/O的这一章中我们也会介绍一下Cgroups v2。
好了上面我们解读了Namespace和Cgroups两大技术它们是Linux下实现容器的两个基石后面课程中要讨论的容器相关问题或多或少都和Namespace或者Cgroups相关我们会结合具体问题做深入的分析。
目前呢,你只需要先记住这两个技术的作用,**Namespace帮助容器来实现各种计算资源的隔离Cgroups主要限制的是容器能够使用的某种资源量。**
## 重点总结
这一讲,我们对容器有了一个大致的认识,包括它的“形”,**一些基本的容器操作**;还有它的“神”,也就是**容器实现的原理**。
启动容器的基本操作是这样的首先用Dockerfile来建立一个容器的镜像然后再用这个镜像来启动一个容器。
那启动了容器之后,怎么检验它是不是正常工作了呢?
我们可以运行 `docker exec` 这个命令进入容器的运行空间,查看进程是否启动,检查配置文件是否正确,检验我们设置的服务是否能够正常提供。
我们用 `docker exec` 命令查看了容器的进程,网络和文件系统,就能体会到容器的文件系统、运行的进程环境和网络的设置都是独立的,所以从用户使用的角度看,容器和一台独立的机器或者虚拟机没有什么太大的区别。
最后我们一起学习了Namespace和Cgroups它们是Linux的两大技术用于实现容器的特性。
具体来说,**Namespace帮助容器实现各种计算资源的隔离Cgroups主要对容器使用某种资源量的多少做一个限制。**
所以我们在这里可以直接记住:**容器其实就是Namespace+Cgroups。**
## 思考题
用Dockerfile为你最熟悉的应用程序做个镜像然后用Docker命令启动这个容器。
欢迎在留言区分享你的疑惑和见解。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。