<audio id="audio" title="56 | 容器：大公司为保持创新，鼓励内部创业" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/a4/d1d6bd823b83801b5dfcb824bfd0bba4.mp3"></audio>

上一章，我们讲了虚拟化的原理。从一台物理机虚拟化出很多的虚拟机这种方式，一定程度上实现了资源创建的灵活性。但是你同时会发现，虚拟化的方式还是非常复杂的。这有点儿像，你去成立子公司，虽然说公司小，但毕竟是一些独立的公司，麻雀虽小，五脏俱全，因而就像上一章我们看到的那样，CPU、内存、网络、硬盘全部需要虚拟化，一个都不能偷懒。

那有没有一种更加灵活的方式，既可以隔离出一部分资源，专门用于某个进程，又不需要费劲周折的虚拟化这么多的硬件呢？毕竟最终我只想跑一个程序，而不是要一整个Linux系统。这就像在一家大公司搞创新，如果每一个创新项目都要成立一家子公司的话，那简直太麻烦了。一般方式是在公司内部成立一个独立的组织，分配独立的资源和人力，先做一段时间的内部创业。如果真的做成功了，再成立子公司也不迟。

在Linux操作系统中，有一项新的技术，称为容器，它就可以做到这一点。

容器的英文叫Container，Container的另一个意思是“集装箱”。其实容器就像船上的不同的集装箱装着不同的货物，有一定的隔离，但是隔离性又没有那么好，仅仅做简单的封装。当然封装也带来了好处，一个是打包，二是标准。

在没有集装箱的时代，假设我们要将货物从A运到B，中间要经过三个码头、换三次船。那么每次都要将货物卸下船来，弄得乱七八糟，然后还要再搬上船重新摆好。因此在没有集装箱的时候，每次换船，船员们都要在岸上待几天才能干完活。

有了尺寸全部都一样的集装箱以后，我们可以把所有的货物都打包在一起。每次换船的时候，把整个集装箱搬过去就行了，几个小时就能完成。船员换船时间大大缩短了。这是集装箱的“打包”和“标准”两大特点在生活中的应用。

其实容器的思想就是要变成软件交付的集装箱。那么容器如何对应用打包呢？

我们先来学习一下集装箱的打包过程。首先，我们得有个封闭的环境，将货物封装起来，让货物之间互不干扰，互相隔离，这样装货卸货才方便。

容器实现封闭的环境主要要靠两种技术，一种是看起来是隔离的技术，称为**namespace**（命名空间）。在每个namespace中的应用看到的，都是不同的 IP地址、用户空间、进程ID等。另一种是用起来是隔离的技术，称为**cgroup**（网络资源限制），即明明整台机器有很多的 CPU、内存，但是一个应用只能用其中的一部分。

有了这两项技术，就相当于我们焊好了集装箱。接下来的问题就是，如何“将这些集装箱标准化”，在哪艘船上都能运输。这里就要用到镜像了。

所谓**镜像**（Image），就是在你焊好集装箱的那一刻，将集装箱的状态保存下来。就像孙悟空说：“定！”，集装箱里的状态就被“定”在了那一刻，然后这一刻的状态会被保存成一系列文件。无论在哪里运行这个镜像，都能完整地还原当时的情况。

当程序员根据产品设计开发完毕之后，可以将代码连同运行环境打包成一个容器镜像。这个时候集装箱就焊好了。接下来，无论是在开发环境、测试环境，还是生产环境运行代码，都可以使用相同的镜像。就好像集装箱在开发、测试、生产这三个码头非常顺利地整体迁移，这样产品的发布和上线速度就加快了。

下面，我们就来体验一下这个Linux上的容器技术！

首先，我们要安装一个目前最主流的容器技术的实现Docker。假设我们的操作系统是CentOS，你可以参考[https://docs.docker.com/install/linux/docker-ce/centos/](https://docs.docker.com/install/linux/docker-ce/centos/)这个官方文档，进行安装。

第一步，删除原有版本的Docker。

```
yum remove docker \
                  docker-client \
                  docker-client-latest \
                  docker-common \
                  docker-latest \
                  docker-latest-logrotate \
                  docker-logrotate \
                  docker-engine

```

第二步，安装依赖的包。

```
yum install -y yum-utils \
  device-mapper-persistent-data \
  lvm2

```

第三步，安装Docker所属的库。

```
yum-config-manager \
    --add-repo \
    https://download.docker.com/linux/centos/docker-ce.repo


```

第四步，安装Docker。

```
yum install docker-ce docker-ce-cli containerd.io

```

第五步，启动Docker。

```
systemctl start docker

```

Docker安装好之后，接下来我们就来运行一个容器。

就像上面我们讲过的，容器的运行需要一个镜像，这是我们集装箱封装的那个环境，在[https://hub.docker.com/](https://hub.docker.com/)上，你能找到你能想到的几乎所有环境。

最基础的环境就是操作系统。

咱们最初讲命令行的时候讲过，每种操作系统的命令行不太一样，就像黑话一样。有时候我们写一个脚本，需要基于某种类型的操作系统，例如，Ubuntu或者centOS。但是，Ubuntu或者centOS不同版本的命令也不一样，需要有一个环境尝试一下命令是否正确。

最常见的做法是有几种类型的操作系统，就弄几台物理机。当然这样一般人可玩不起，但是有了虚拟机就好一些了。你可以在你的笔记本电脑上创建多台虚拟机，但是这个时候又会有另一个苦恼，那就是，虚拟机往往需要比较大的内存，一般一台笔记本电脑上无法启动多台虚拟机，所以做起实验来要经常切换虚拟机，非常麻烦。现在有了容器，好了，我们可以在一台虚拟机上创建任意的操作系统环境了。

比方说，你可以在[https://hub.docker.com/](https://hub.docker.com/)上搜索Ubuntu。点开之后，找到Tags。镜像都有Tag，这是镜像制作者自己任意指定的，多用于表示这个镜像的版本号。

<img src="https://static001.geekbang.org/resource/image/16/fb/160b839adb2bd7390c16c4591204befb.png" alt="">

如果仔细看这些Tags，我们会发现，哪怕非常老版本的Ubuntu，这里面都有，例如14.04。如果我们突然需要一个基于Ubuntu 14.04的命令，那就不需要费劲去寻找、安装一个这么老的虚拟机，只要根据命令下载这个镜像就可以了。

```
# docker pull ubuntu:14.04
14.04: Pulling from library/ubuntu
a7344f52cb74: Pull complete 
515c9bb51536: Pull complete 
e1eabe0537eb: Pull complete 
4701f1215c13: Pull complete 
Digest: sha256:2f7c79927b346e436cc14c92bd4e5bd778c3bd7037f35bc639ac1589a7acfa90
Status: Downloaded newer image for ubuntu:14.04

```

下载完毕之后，我们可以通过下面的命令查看镜像。

```
# docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
ubuntu              14.04               2c5e00d77a67        2 months ago        188MB

```

有了镜像，我们就可以通过下面的启动一个容器啦。

启动一个容器需要一个叫entrypoint的东西，也就是入口。一个容器启动起来之后，会从这个指令开始运行，并且只有这个指令在运行，容器才启动着。如果这个指令退出，整个容器就退出了。

因为我们想尝试命令，所以这里entrypoint要设置为bash。通过cat /etc/lsb-release，我们可以看出，这里面已经是一个老的Ubuntu 14.04的环境。

```
# docker run -it --entrypoint bash ubuntu:14.04
root@0e35f3f1fbc5:/# cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=14.04
DISTRIB_CODENAME=trusty
DISTRIB_DESCRIPTION=&quot;Ubuntu 14.04.6 LTS&quot;

```

如果我们想尝试centOS 6，也是没问题的。

```
# docker pull centos:6
6: Pulling from library/centos
ff50d722b382: Pull complete 
Digest: sha256:dec8f471302de43f4cfcf82f56d99a5227b5ea1aa6d02fa56344986e1f4610e7
Status: Downloaded newer image for centos:6

# docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
ubuntu              14.04               2c5e00d77a67        2 months ago        188MB
centos              6                   d0957ffdf8a2        4 months ago        194MB

# docker run -it --entrypoint bash centos:6
[root@af4c8d598bdf /]# cat /etc/redhat-release 
CentOS release 6.10 (Final)

```

除了可以如此简单地创建一个操作系统环境，容器还有一个很酷的功能，就是镜像里面带应用。这样的话，应用就可以像集装箱一样，到处迁移，启动即可提供服务。而不用像虚拟机那样，要先有一个操作系统的环境，然后再在里面安装应用。

我们举一个最简单的应用的例子，也就是nginx。我们可以下载一个nginx的镜像，运行起来，里面就自带nginx了，并且直接可以访问了。

```
# docker pull nginx
Using default tag: latest
latest: Pulling from library/nginx
fc7181108d40: Pull complete 
d2e987ca2267: Pull complete 
0b760b431b11: Pull complete 
Digest: sha256:48cbeee0cb0a3b5e885e36222f969e0a2f41819a68e07aeb6631ca7cb356fed1
Status: Downloaded newer image for nginx:latest

# docker run -d -p 8080:80 nginx
73ff0c8bea6e169d1801afe807e909d4c84793962cba18dd022bfad9545ad488

# docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                  NAMES
73ff0c8bea6e        nginx               &quot;nginx -g 'daemon of…&quot;   2 minutes ago       Up 2 minutes        0.0.0.0:8080-&gt;80/tcp   modest_payne

# curl http://localhost:8080
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Welcome to nginx!&lt;/title&gt;

```

这次nginx镜像运行的方式和操作系统不太一样，一个是-d，因为它是一个应用，不需要像操作系统那样有交互命令行，而是以后台方式运行，-d就是daemon的意思。

另外一个就是端口-p 8080:80。容器这么容易启动，每台机器上可以启动N个nginx。大家都监听80端口，不就冲突了吗？所以我们要设置端口，冒号后面的80是容器内部环境监听的端口，冒号前面的8080是宿主机上监听的端口。

一旦容器启动起来之后，通过docker ps就可以查看都有哪些容器正在运行。

接下来，我们通过curl命令，访问本机的8080端口，可以打印出nginx的欢迎页面。

docker run一下，应用就启动起来了，是不是非常方便？nginx是已经有人打包好的容器镜像，放在公共的镜像仓库里面。如果是你自己开发的应用，应该如何打包成为镜像呢？

因为Java代码比较麻烦，我们这里举一个简单的例子。假设你自己写的HTML的文件就是代码。

```
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;title&gt;Welcome to nginx Test 7!&lt;/title&gt;
    &lt;style&gt;
      body {
        width: 35em;
        margin: 0 auto;
        font-family: Tahoma, Verdana, Arial, sans-serif;
      }
    &lt;/style&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;Test 7&lt;/h1&gt;
    &lt;p&gt;If you see this page, the nginx web server is successfully installed and
    working. Further configuration is required.&lt;/p&gt;
    &lt;p&gt;For online documentation and support please refer to
    &lt;a href=&quot;http://nginx.org/&quot;&gt;nginx.org&lt;/a&gt;.&lt;br/&gt;
    Commercial support is available at
    &lt;a href=&quot;http://nginx.com/&quot;&gt;nginx.com&lt;/a&gt;.&lt;/p&gt;
    &lt;p&gt;&lt;em&gt;Thank you for using nginx.&lt;/em&gt;&lt;/p&gt;
  &lt;/body&gt;
&lt;/html&gt;

```

那我们如何将这些代码放到容器镜像里面呢？要通过Dockerfile，Dockerfile的格式应该包含下面的部分：

- FROM 基础镜像
- RUN 运行过的所有命令
- COPY 拷贝到容器中的资源
- ENTRYPOINT 前台启动的命令或者脚本

按照上面说的格式，可以有下面的Dockerfile。

```
FROM ubuntu:14.04
RUN echo &quot;deb http://archive.ubuntu.com/ubuntu trusty main restricted universe multiverse&quot; &gt; /etc/apt/sources.list
RUN echo &quot;deb http://archive.ubuntu.com/ubuntu trusty-updates main restricted universe multiverse&quot; &gt;&gt; /etc/apt/sources.list
RUN apt-get -y update
RUN apt-get -y install nginx
COPY test.html /usr/share/nginx/html/test.html
ENTRYPOINT nginx -g &quot;daemon off;&quot;

```

将代码、Dockerfile、脚本，放在一个文件夹下，以上面的Dockerfile为例子。

```
[nginx]# ls
Dockerfile  test.html

```

现在我们编译这个Dockerfile。

```
docker build -f Dockerfile -t testnginx:1 .

```

编译过后，我们就有了一个新的镜像。

```
# docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
testnginx           1                   3b0e5da1a384        11 seconds ago      221MB
nginx               latest              f68d6e55e065        13 days ago         109MB
ubuntu              14.04               2c5e00d77a67        2 months ago        188MB
centos              6                   d0957ffdf8a2        4 months ago        194MB

```

接下来，我们就可以运行这个新的镜像。

```
# docker run -d -p 8081:80 testnginx:1
f604f0e34bc263bc32ba683d97a1db2a65de42ab052da16df3c7811ad07f0dc3
# docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                  NAMES
f604f0e34bc2        testnginx:1         &quot;/bin/sh -c 'nginx -…&quot;   2 seconds ago       Up 2 seconds        0.0.0.0:8081-&gt;80/tcp   youthful_torvalds
73ff0c8bea6e        nginx               &quot;nginx -g 'daemon of…&quot;   33 minutes ago      Up 33 minutes       0.0.0.0:8080-&gt;80/tcp   modest_payne

```

我们再来访问我们在nginx里面写的代码。

```
[root@deployer nginx]# curl http://localhost:8081/test.html
&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;
    &lt;title&gt;Welcome to nginx Test 7!&lt;/title&gt;

```

看，我们的代码已经运行起来了。是不是很酷？

其实这种运行方式有更加酷的功能。

第一就是持续集成。

想象一下，你写了一个程序，然后把它打成了上面一样的镜像。你在本地一运行docker run就把他运行起来了。接下来，你交给测试的就不是一个“程序包+配置+手册”了，而是一个容器镜像了。测试小伙伴同样通过docker run也就运行起来了，不存在“你这里跑的起来，他那里跑不起来的情况”。测试完了再上生产，交给运维的小伙伴也是这样一个镜像，同样的运行同样的顺畅。这种模式使得软件的交付效率大大提高，可以一天发布多次。

第二就是弹性伸缩。

想象一下，你写了一个程序，平时用的人少，只需要10个副本就能够扛住了。突然有一天，要做促销，需要100个副本，另外90台机器创建出来相对比较容易，用任何一个云都可以做到，但是里面90个副本的应用如何部署呢？一个个上去手动部署吗？有了容器就方便多了，只要在每台机器上docker run一下就搞定了。

第三就是跨云迁移。

如果你不相信任何一个云，怕被一个云绑定，怕一个云挂了自己的应用也就挂了。那我们想一想，该怎么办呢？你只能手动在一个云上部署一份，在另外一个云上也部署一份。有了容器了之后，由于容器镜像对于云是中立的，你在这个云上docker run，就在这个云上提供服务，等哪天想用另一个云了，不用怕应用迁移不走，只要在另外一个云上docker run一下就解决了。

到现在，是不是能够感受到容器的集装箱功能了，这就是看起来隔离的作用。

你可能会问，多个容器运行在一台机器上，不会相互影响吗？如何限制CPU和内存的使用呢？

Docker本身提供了这样的功能。Docker可以限制对于CPU的使用，我们可以分几种的方式。

- Docker允许用户为每个容器设置一个数字，代表容器的 CPU share，默认情况下每个容器的 share 是 1024。这个数值是相对的，本身并不能代表任何确定的意义。当主机上有多个容器运行时，每个容器占用的 CPU 时间比例为它的 share 在总额中的比例。Docker为容器设置CPU share 的参数是 -c --cpu-shares。
- Docker提供了 --cpus 参数可以限定容器能使用的 CPU 核数。
- Docker可以通过 --cpuset 参数让容器只运行在某些核上

Docker会限制容器内存使用量，下面是一些具体的参数。

- -m --memory：容器能使用的最大内存大小。
- –memory-swap：容器能够使用的 swap 大小。
- –memory-swappiness：默认情况下，主机可以把容器使用的匿名页swap出来，你可以设置一个 0-100 之间的值，代表允许 swap 出来的比例。
- –memory-reservation：设置一个内存使用的 soft limit，如果 docker 发现主机内存不足，会执行 OOM (Out of Memory)操作。这个值必须小于 --memory 设置的值。
- –kernel-memory：容器能够使用的 kernel memory 大小。
- –oom-kill-disable：是否运行 OOM (Out of Memory)的时候杀死容器。只有设置了 -m，才可以把这个选项设置为 false，否则容器会耗尽主机内存，而且导致主机应用被杀死。

这就是用起来隔离的效果。

那这些看起来隔离和用起来隔离的技术，到内核里面是如何实现的呢？我们下一节仔细分析。

## 总结时刻

这里我们来总结一下这一节的内容。无论是容器，还是虚拟机，都依赖于内核中的技术，虚拟机依赖的是KVM，容器依赖的是namespace和cgroup对进程进行隔离。

为了运行Docker，有一个daemon进程Docker Daemon用于接收命令行。

为了描述Docker里面运行的环境和应用，有一个Dockerfile，通过build命令称为容器镜像。容器镜像可以上传到镜像仓库，也可以通过pull命令从镜像仓库中下载现成的容器镜像。

通过Docker run命令将容器镜像运行为容器，通过namespace和cgroup进行隔离，容器里面不包含内核，是共享宿主机的内核的。对比虚拟机，虚拟机在qemu进程里面是有客户机内核的，应用运行在客户机的用户态。

<img src="https://static001.geekbang.org/resource/image/5a/c5/5a499cb50a1b214a39ddf19cbb63dcc5.jpg" alt="">

## 课堂练习

请你试着用Tomcat的容器镜像启动一个Java网站程序，并进行访问。

欢迎留言和我分享你的疑惑和见解，也欢迎收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。
