Docker overview

什么是Docker?

Docker是Docker公司推出一种容器化技术,除了docker以外,市面上还有其他的容器化技术,比如rkt。因此,docker并不是指代所有的容器化技术,之所以每当提到容器化技术时,很多人都会脱口而出docker一词是因为在众多的容器化产品中,docker做的是最好的。

docker诞生背景

从两个维度来看待:

PaaS

若干年前,以Cloud Foundry为代表的云服务厂商开启了以开源PaaS为核心构建平台层服务能力的变革。当时Docker公司的前身,dotCloud公司也加入了这个浪潮之中。

PaaS项目被很多采纳的一个主要原因是它提供了一种名为”应用托管“的能力。当时用户普遍都是租一批虚拟机,在虚拟机上面部署自己的应用,但是这个部署过程难免导致云端虚拟机和本地环境不一致的情况。而PaaS的出现就是为了解决这个问题而出现的最佳方案。

PaaS最核心的组件就是一套应用的打包和分发机制,也正是这个功能让用户使用时,费尽心思。这是因为如果用户需要将应用打包部署到PaaS平台上,需要为不同的语言,不同的框架等维护一个打好的包。打包没有任何章法可寻,打包以后还需要做很多的配置才能在PaaS成功运行,因此这个缺陷导致用户痛苦不堪。而dotCloud这个原本名不见经传,之所以横空出世,就是因为他推出了自己一套打包机制Docker镜像。Docker镜像的出现正好从根本上解决了打包的问题。

Docker的精髓就是docker镜像。docker镜像里面包含了完整的操作系统文件和目录,里面包含了应用运行需要的所有依赖。在本地测试以后,就是直接发送到云端进行部署。这个过程中不需要对打包的镜像做任何配置。这样一来完全解决了之前打包技术的痛点。成为了Docker公司的杀手锏。

Docker项目给PaaS的世界提供了一种非常便利的打包机制,这个机制直接打包了应用运行所需要的整个操作系统,从而保证了本地环境和云端环境的高度一致。避免了用户通过试错来匹配两种不同运行环境之间差异的痛苦过程。

docker和VM

docker比虚拟机更加轻量,它允许在相同硬件上运行更多数量的组件。每台虚拟机都需要运行自己的一组系统进程,这样就会产生除组件进程消耗以外的额外计算资源损耗。而docker仅仅是运行在宿主机操作系统中的单个进程,仅消耗应用容器消耗的资源,不会有其他进程的开销。

上图展示了分别使用虚拟机和docker来隔离应用。

上图展示了应用分别在虚拟机和docker主机上面运行的情况。

综合上面两幅图片,docker主机架构和虚拟机主机架构相比,少了hypervisor这一层结构。通常部署虚拟机首先需要在物理机上面安装一层hypervisor,然后在hypervisor上面部署虚拟机。hypervisor(VMM: 虚拟机管理系统)身其实也是一种软件系统,因此它本身也会带来一定cpu和内存等资源的消耗。由于VMM在客户操作系统和裸硬件之间用于工作协调, 一些受保护的指令必须由hypervisor来捕获和处理,因为操作系统是通过hypervisor来分享底层硬件。虚拟机的这种工作机制也导致了虚拟机的性能无法达到物理机的性能。而Docker并不需要hypervisor这样的管理系统去管理它,它直接运行在宿主机操作系统内核之中,因此它可以直接通过系统调用来与内核进行交互,运行速度非常快。

综合上面两个维度看待docker,docker既解决了PaaS平台应用打包的问题,也降低了使用虚拟机来部署应用的硬件成本和系统资源以及运行性能的开销。

Docker平台

Docker提供了在一个松散的隔离的被称为容器的环境中打包和运行应用的能力。可以在提供的主机上同时隔离并安全的运行多个容器。

容器非常轻量级,因为它没有额外的hypervisor负载,它直接运行在主机操作系统的内核之中。我们甚至可以在虚拟机上面运行容器。

Docker引擎

Docker引擎基于client-server模式的应用,它由下面几个组件构成:

  • server:一个长期运行的守护进程,被称为dockerd。

  • REST API:指定了一系列的API,程序可以利用这些API与Docker守护进程进行通信,来指挥容器去做我们想做的事情。

  • command line接口: 本质上也是客户端,一般的命令行工具就是docker。

CLI通过REST API来与docker守护进程进行通信,我们也可以通过脚本或直接使用CLI来与Docker守护进程进行通信。

Docker架构

Docker使用client-server架构。Docker客户端与Docker守护进程进行通信,后者负责构建、运行和分发我们的Docker容器。Docker客户端和Docker守护进程可以运行在同一个操作系统上面,或者我们也可以将Docker客户端连接到远程Docker的守护进程。Docker客户端和Docker守护进程在UNIX socket或一个网络接口之上使用 REST API 进行通信。

创建,运行和共享一个容器镜像

安装并运行一个Hello World容器

首先我们需要在Linux主机上面安装Docker。如果不是Linux主机,则我们需要启动一个Linux虚拟机并在里面运行Docker。 如果是Mac或windows系统安装docker,docker会设置一个虚拟机并在里面运行docker daemon。安装Docker可以根据此链接中的教程进行安装http://docs.docker.com/engine/installation/

安装好镜像以后,通过busybox镜像来运行一个容器,如果不熟悉busybox,那么它其实是一个可执行文件,其中包含许多标准的UNIX命令行工具,例如echo,ls,gzip等。除了busybox镜像之外,我们还可以使用任何其他成熟的OS容器镜像,例如Fedora,Ubuntu或其他类似的镜像,只要它包含echo可执行文件即可。

我们通过下面的一条命令来运行一个容器:

$ docker run busybox echo "Hello world"
Unable to find image 'busybox:latest' locally
latest: Pulling from docker.io/busybox
9a163e0b8d13: Pull complete
fef924a0204a: Pull complete
Digest: sha256:97473e34e311e6c1b3f61f2a721d038d1e5eef17d98d1353a513007cf46ca6bd
Status: Downloaded newer image for docker.io/busybox:latest
Hello world

理解docker run幕后所发生的事

上图准确展示了执行docker run命令时发生的情况。首先,Docker检查本地计算机上是否已经存在busybox:latest镜像。假如本地不存在busybox镜像,则Docker从https://docker.io的Docker Hub Registry中拉取它。将镜像下载到本地主机后,Docker从该镜像创建了一个容器,并在其中运行命令。 echo命令将文本打印到STDOUT,然后进程终止,容器停止。

可以不指定命令直接启动容器镜像,也可以在镜像内部指定运行的命令,但是也可以被覆盖。

$ docker run <image>

镜像版本

镜像支持在同一个镜像名称下面拥有相同镜像的多个版本。每一个镜像必须拥有一个独一无二的tag。当使用一个镜像而没有明确指定镜像tag时,docker默认认为你想要启动标签为latest的镜像。运行不同版本的镜像需要明确指定tag:

$ docker run <image>:<tag>

构建一个镜像

一般情况下,我们通过Dockerfile将一个应用打包成镜像。如何使用Dockerfile请参考该教程中Dockerfile指令部分。写好Dockerfile以后,我们通过下面的命令构建一个镜像:

首先在自己的本地创建一个node.js应用,该应用每次向它发送请求时都会返回主机名,并且还会将发送请求的客户端的ip地址通过标准输出打印出来:

const http = require('http');
const os = require('os');

console.log("Kubia server starting...");

var handler = function(request, response) {
  console.log("Received request from " + request.connection.remoteAddress);
  response.writeHead(200);
  response.end("You've hit " + os.hostname() + "\n");
};

var www = http.createServer(handler);
www.listen(8080);

创建一个Dockerfile:

FROM node:7
ADD app.js /app.js
ENTRYPOINT ["node", "app.js"]

由于这里使用了node.js,因此我们需要引用一个包含node.js运行环境和相关命令的基础镜像。

构建镜像:

$ docker build -t kubia .

上图展示了镜像构建的整个流程。构建过程并非docker客户端执行,而是将需要打包的整个目录的内容上传至Docker daemon(守护进程)然后构建。构建完毕后,镜像存储在本地。

通过docker images列出本地存储的镜像

$ docker images
REPOSITORY   TAG      IMAGE ID           CREATED             VIRTUAL SIZE
kubia        latest   d30ecc7419e7       1 minute ago        637.1 MB
...

运行构建好的容器镜像

使用下面的命令运行一个容器:

$ docker run --name kubia-container -p 8080:8080 -d kubia

这条命令告诉Docker从kubia镜像运行一个名为kubia-container的新容器。容器将与控制台分离(-d标志),这意味着它将在后台运行。本地计算机上的端口8080将映射到容器内的端口8080(-p 8080:8080选项),因此我们可以通过http://localhost:8080访问该应用程序。

$ curl localhost:8080
You've hit 44d76963e8e1

列出所有运行的容器

$ docker ps
CONTAINER ID  IMAGE         COMMAND               CREATED        ...
44d76963e8e1  kubia:latest  "/bin/sh -c 'node ap  6 minutes ago  ...

...  STATUS              PORTS                    NAMES
...  Up 6 minutes        0.0.0.0:8080->8080/tcp   kubia-container

如果需要查看容器的详细信息,可以执行下面的命令:

$ docker inspect kubia-container

Docker将打印关于容器的JSON格式的信息。

探索容器内部

当一个容器启动以后,我们可以通过下面的命令进入容器。

$ docker exec -it kubia-container bash

这将在现有的kubia-container容器内运行bash。 bash进程具有与主容器进程相同的Linux命名空间。这使我们可以从内部浏览容器,并查看Node.js和应用在容器内运行时如何看待系统。 -it选项是两个选项的简写:

  • -i,确保标准输入打开。

  • -t, 分配一个伪终端(TTY)。

执行下面的命令来查看容器中运行的进程:

root@44d76963e8e1:/# ps aux
t   10  0.0  0.0  20216  1924 ?   Ss   12:31 0:00 bashroot   19  0.0  0.0  17492  1136 ?   R+   12:38 0:00 ps aux
USER  PID %CPU %MEM    VSZ   RSS TTY STAT START TIME COMMAND
root    1  0.0  0.1 676380 16504 ?   Sl   12:31 0:00 node app.js
...

我们只能看到三个进程而无法看到来自主机操作系统的其他进程。

打开本地终端,查看主机操作系统的进程信息:

$ ps aux | grep app.js
USER  PID %CPU %MEM    VSZ   RSS TTY STAT START TIME COMMAND
root  382  0.0  0.1 676380 16504 ?   Sl   12:31 0:00 node app.js

我们可以看到运行的容器进程。运行在docker中的进程实际是运行在主机系统上的。但是docker中的进程id与主机系统中的进程id不一样。这说明容器使用独立的PID namespace,拥有一个完全隔离的进程树。

容器的文件系统也是隔离的,在容器内部只能看到容器自身拥有的文件。

root@44d76963e8e1:/# ls /
app.js  boot  etc   lib    media  opt   root  sbin  sys  usr
bin     dev   home  lib64  mnt    proc  run   srv   tmp  var

停止和删除容器

$ docker stop kubia-container

上面的命令会停止容器内的进程,随后停止容器,因为容器中已经没有正在运行的进程了。通过docker ps -a选项可以看到停止的容器依然存在。-a选项可以打印出所有停止的和正在运行的容器。

通过下面的命令删除一个容器:

$ docker rm kubia-container

将镜像推送到远程仓库

首先重命名我们之前打包好的镜像:

$ docker tag kubia camelgem/kubia

docker tag不会重命名镜像的标签,而是为相同镜像创建一个额外的标签。这样两个不同的tag都指向同一个镜像ID。

$ docker images | head
REPOSITORY        TAG      IMAGE ID        CREATED             VIRTUAL SIZE
camelgem/kubia    latest   d30ecc7419e7    About an hour ago   654.5 MB
kubia             latest   d30ecc7419e7    About an hour ago   654.5 MB
docker.io/node    7.0      04c0ca2a8dad    2 days ago          654.5 MB
...

上面显示的信息证明了之前的说法。

通过下面的命令将镜像推送到远程仓库:

$ docker push camelgem/kubia

在推送镜像之前,一定要确保已经注册了dockerhub账号,命令行中的camelgem是自己的docker ID。如果没有安装本地docker desktop客户端,那么push前需要通过docker login命令输入自己的dockerhub用户名和密码,登录成功以后才能push镜像。

推送完镜像以后,我们就可以在任何主机拉取并运行它们了。

此外还可以使用 docker commit 指令,把一个正在运行的容器,直接提交为一个镜像。一般来说,需要这么操作原因是:这个容器运行起来后,我又在里面做了一些操作,并且要把操作结果保存到镜像里,比如:

$ docker exec -it 4ddf4638572d /bin/sh
# 在容器内部新建了一个文件
root@4ddf4638572d:/app# touch test.txt
root@4ddf4638572d:/app# exit

#将这个新建的文件提交到镜像中保存
$ docker commit 4ddf4638572d geektime/helloworld:v2

docker commit,实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组成了一个新的镜像。当然,下面这些只读层在宿主机上是共享的,不会占用额外的空间。

而由于使用了联合文件系统,你在容器里对镜像 rootfs 所做的任何修改,都会被操作系统先复制到这个可读写层,然后再修改。这就是所谓的:Copy-on-Write

在这里引申一个问题,docker exec是如何进入容器内部的?

实际上,Linux Namespace 创建的隔离空间虽然看不见摸不着,但一个进程的 Namespace 信息在宿主机上是确确实实存在的,并且是以一个文件的方式存在。比如,通过如下指令,你可以看到当前正在运行的 Docker 容器的进程号(PID)是 25686:

$ docker inspect --format '{{ .State.Pid }}'  4ddf4638572d
25686

这时,你可以通过查看宿主机的 proc 文件,看到这个 25686 进程的所有 Namespace 对应的文件:

$ ls -l  /proc/25686/ns
total 0
lrwxrwxrwx 1 root root 0 Aug 13 14:05 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 ipc -> ipc:[4026532278]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 mnt -> mnt:[4026532276]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 net -> net:[4026532281]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid_for_children -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 uts -> uts:[4026532277]

可以看到,一个进程的每种 Linux Namespace,都在它对应的 /proc/[进程号]/ns 下有一个对应的虚拟文件,并且链接到一个真实的 Namespace 文件上。

有了这样一个可以“hold 住”所有 Linux Namespace 的文件,我们就可以对 Namespace 做一些很有意义事情了,比如:加入到一个已经存在的 Namespace 当中。这也就意味着:一个进程,可以选择加入到某个进程已有的 Namespace 当中,从而达到“进入”这个进程所在容器的目的,这正是 docker exec 的实现原理。

而这个操作所依赖的,乃是一个名叫 setns() 的 Linux 系统调用。它的调用方法,可以用如下一段小程序为你说明:

#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)

int main(int argc, char *argv[]) {
    int fd;
    
    fd = open(argv[1], O_RDONLY);
    if (setns(fd, 0) == -1) {
        errExit("setns");
    }
    execvp(argv[2], &argv[2]); 
    errExit("execvp");
}

这段代码功能非常简单:它一共接收两个参数,第一个参数是 argv[1],即当前进程要加入的 Namespace 文件的路径,比如 /proc/25686/ns/net;而第二个参数,则是你要在这个 Namespace 里运行的进程,比如 /bin/bash。

这段代码的核心操作,则是通过 open() 系统调用打开了指定的 Namespace 文件,并把这个文件的描述符 fd 交给 setns() 使用。在 setns() 执行后,当前进程就加入了这个文件对应的 Linux Namespace 当中了。

编译执行一下这个程序,加入到容器进程(PID=25686)的 Network Namespace 中:

$ gcc -o set_ns set_ns.c 
$ ./set_ns /proc/25686/ns/net /bin/bash 
$ ifconfig
eth0      Link encap:Ethernet  HWaddr 02:42:ac:11:00:02  
          inet addr:172.17.0.2  Bcast:0.0.0.0  Mask:255.255.0.0
          inet6 addr: fe80::42:acff:fe11:2/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:12 errors:0 dropped:0 overruns:0 frame:0
          TX packets:10 errors:0 dropped:0 overruns:0 carrier:0
     collisions:0 txqueuelen:0 
          RX bytes:976 (976.0 B)  TX bytes:796 (796.0 B)

lo        Link encap:Local Loopback  
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
    collisions:0 txqueuelen:1000 
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

正如上所示,当我们执行 ifconfig 命令查看网络设备时,会发现能看到的网卡“变少”了:只有两个。而我的宿主机则至少有四个网卡。这是怎么回事呢?

实际上,在 setns() 之后我看到的这两个网卡,正是我在前面启动的 Docker 容器里的网卡。也就是说,我新创建的这个 /bin/bash 进程,由于加入了该容器进程(PID=25686)的 Network Namepace,它看到的网络设备与这个容器里是一样的,即:/bin/bash 进程的网络设备视图,也被修改了。

而一旦一个进程加入到了另一个 Namespace 当中,在宿主机的 Namespace 文件上,也会有所体现。

在宿主机上,你可以用 ps 指令找到这个 set_ns 程序执行的 /bin/bash 进程,其真实的 PID 是 28499:

# 在宿主机上
ps aux | grep /bin/bash
root     28499  0.0  0.0 19944  3612 pts/0    S    14:15   0:00 /bin/bash

这时查看一下这个 PID=28499 的进程的 Namespace,会发现这样一个事实:

$ ls -l /proc/28499/ns/net
lrwxrwxrwx 1 root root 0 Aug 13 14:18 /proc/28499/ns/net -> net:[4026532281]

$ ls -l  /proc/25686/ns/net
lrwxrwxrwx 1 root root 0 Aug 13 14:05 /proc/25686/ns/net -> net:[4026532281]

在 /proc/[PID]/ns/net 目录下,这个 PID=28499 进程,与我们前面的 Docker 容器进程(PID=25686)指向的 Network Namespace 文件完全一样。这说明这两个进程,共享了这个名叫 net:[4026532281]的 Network Namespace。

此外,Docker 还专门提供了一个参数,可以让你启动一个容器并“加入”到另一个容器的 Network Namespace 里,这个参数就是 -net,比如:

$ docker run -it --net container:4ddf4638572d busybox ifconfig

如果指定–net=host,就意味着这个容器不会为进程启用 Network Namespace。这就意味着,这个容器拆除了 Network Namespace 的“隔离墙”,所以,它会和宿主机上的其他普通进程一样,直接共享宿主机的网络栈。这就为容器直接操作和使用宿主机网络提供了一个渠道。

Last updated