Docker架构
Docker采用C/S架构,通过socket/RESTful API进行通信。
Docker daemon
Docker daemon作为服务端守护进程,具体来说,主要负责:
- 任务调度。将客户端传来的命令请求转化为特定的任务;
- 维护镜像数据。一个完整的镜像由许多子文件层组成,而这个镜像的依赖关系就是由Daemon来维护的;
- 容器虚拟化。具体来说就是资源分配和资源隔离;
- 容器生命周期。根据用户指令和容器自身状态来维护容器的生命周期。
这四项任务的关联关系如图。
1. Engine
Engine模块监听来自客户端的Restful API请求。默认监听本地unix:///var/run/docker.sock
套接字,只允许本地root访问。
也可以通过tcp://
的方式监听某一个端口,如果远程用户请求Docker daemon服务,则可以使用此方式(提供2376加密端口和2375非加密端口)。
通过-H来修改监听方式。
# 监听本地TCP 1234端口
$ sudo docker -H 0.0.0.0:1234 -d &
服务端默认启动配置文件在/etc/default/docker
。
2. Job Server
Engine将收到的命令依据存放在Daemon当中的Router路由表,每一个API都有一个唯一与之对应的Handler函数,此函数会有一系列的逻辑来处理客户端请求,逻辑函数在Daemon中以Job的形式运行。
Router、Handle和Job共同组成了JobServer。而Engine每接收到一次来自于客户端的处理请求,就会生出一个JobServer。当Job完成作业,并将数据返回给客户端之后,此JobServer也会随之销毁。
在Docker Daemon中,只有两种Job类型:Image Job和Contaniner Job。Image Job重点在于通过AUFS文件系统来维护镜像数据,而Container Job侧重于管理资源。
3. 资源管理
Daemon并不会参与资源分配和隔离的工作,这些工作都是由虚拟化驱动来完成的。目前由LXC,Libcontainer、Libvirt和nspawn。其中,Libcontainer时Docker自行开发的用于管理资源的驱动库,也是默认的。
Daemon通过驱动库来管理Cgroups、namespaces、SELinux等各个组件,然后再基于所创建的资源使用方案产生一个可用的容器。
所以说,Daemon参与所有Docker处理,不一定是由它直接完成,但一定是在它的监控下进行的。
Docker client
Docker客户端为用户提供一系列Rest API请求,与Docker daemon交互,客户端发送命令后,等待服务端返回,一旦收到返回后,立刻执行并退出。
默认通过本地unix:///var/run/docker.sock
套接字向服务端发送命令,如果服务端没有监听默认套接字,需要在客户端显式指定:
docker -H tcp://127.0.0.1:1234 version
Docker CLI为了安全,需要进行身份认证(证书双向验证)和确保请求不包含恶意。
Docker registries
Docker registries用于存储Docker images,Docker Hub是任何人都可以使用的公共registry,Docker默认的registry是Docker Hub。也可以注册私人registry。
当使用docker pull/run
或docker push
命令时,所需的image将从配置的registry中下载或上传。
Docker object
Docker中的images、containers、networks、volumes、plugins等都称为Docker object。
images
image是一个只读的的模板,它包含了创建Docker container的一些指令等说明。通常情况下,一个image是基于另一个image创建的,加上了一些特殊的定制环境。例如基础ubantu image和基于其创建的具有apache web服务的ubantu image。
可通过Dockerfile的方式创建镜像,Dockerfile创建镜像可理解为一个分层模型,Dockerfile中每一条指令都会创建一个层,当改变Dockerfile重建镜像时,只讲对应的层重建即可,这也是image轻量化的一个原因。
container
容器是一个镜像的可运行实例,可通过客户端对其进行创建,启动,停止或删除等一系列操作。一个容器可以连接一个或多个网络、一个或多个存储设备,当然也可以基于其当前状态创建一个新的镜像。
docker run -i -t ubantu /bin/bash
此命令可以创建并启动一个ubantu容器,它将执行以下操作:
- 如果本地没有ubantu镜像,docker将会从registry中下载一个ubantu镜像,和执行
docker pull ubantu
命令一样; - 创建一个容器,和执行
docker container create
命令一样; - docker为容器申请一个可读写文件系统,作为最后一层,这可以让运行中的容器在其本地文件系统中创建或修改文件和目录。
- 由于没有指定任何网络操作,docker会创建一个网络接口将容器连接到默认网络,包括为容器指定IP,默认情况下,容器可以通过宿主机网络连接到外部网络。
- docker启动容器并执行/bin/bash。
-i -t
的意思是以交互的方式运行容器并连接到当前终端,可以通过当前终端输入。 - 当输入
exit
到/bin/bash
,容器将会停止但不会删除。可以再次启动或删除。
Docker核心概念
1. LXC
众所周知, CPU、内存、IO、 网络等都称之为系统资源,而Linux内核有一套机制来管理其所拥有的这些资源,这套机制的核心被称之为CGroups和Namespaces.
CGroups可以限制、记录、调整进程组所使用的物理资源。比如说:使用CGroups可以给某项进程组多分配一些CPU使用周期。同样也可以通过CGroups限制某项进程组可使用的内存上限,且达到上限,内核就会发出Out Of Memory错误。同时CGroups也具有记录物理资源使用情况的功能,比如CGroups调用cpuacct 子系统就可以记录每个进程所使用的内存数量、CPU时间等数据。正因为Linux有了CGroups资源管理机制,内核虚拟化才变成了可能。
Namespaces 则是另外一个重要的资源隔离机制。Namespaces 将进程、进程组、IPC、网络、内存等资源都变得不再是全局性资源,而是将这些资源从内核层面属于某个特定的Namespace。在不同的Namespace之间,这些资源是相互透明、不可见的。比如说,A用户登录系统后,可以查看到B用户的进程PID。虽说A用户不能杀死B用户的进程,但A和B却能相互感知。但假如A用户在Namespace-A中,B用户在Namespace-B中,虽然A和B仍然共存于同一个Linux操作系统当中,但A却无法感知到B。在这种情况下,Linux 内核不但将Namespace相互隔离,而且将所分配的资源牢牢固定在各自空间之中。
而LXC就是基于Linux内核通过调用CGroups和Namespaces,来实现容器轻量级虚拟化的一项技术,与此同时,LXC也是一组面向Linux 内核容器的用户态API接口。用户通过LXC提供的资源限制和隔离功能,可以创建一套 完整并且相互隔离的虛拟应用运行环境。
本书后续章节提到的Docker,就是采用LXC技术来创建容器的工具,因此才说:LXC是Docker运行的基础,而Docker则是LXC的杀手级应用。
2. AUFS
AUFS的功能简单说就是,可以将分布在不同地方的目录挂载到同一个虚拟文件系统当中。
典型的Linux 启动时,首先加载bootfs ( Boot FileSystem)目录。这个目录里面包括Bootloader和kerenl. Bootloader 用来加载启动kerenl。当kerenl成功加载到内存中后,bootfts 就会释放掉,kerenl 随之开始加载rotfs。
roofs (Root File Systerm) 包含的是Linux系统中标准的/dev、/proc、 /bin、 /etc 等文件。因为rootfis是后续kerenl启动的基础,对于kerenl来说异常重要,因此此时kerenl将Rootfs 加锁-设为readonly.在只读权限下, kerenl 进行一系列的检查操作。 当kerenl确认rootfs包含的文件正确无误后,将readonly改为readwrite (可读可写),以后用户就可以按照正确的权限对这些目录进行操作了。
说到这里,就轮到到AUFS登场了。当Docker利用LXC虚拟化出来一个容器之后, 就相当于购买了一台裸机,有内存、CPU、硬盘,但没有操作系统。Docker 参考Linux 的启动过程,将一个readonly权限的bootfs挂载到容器文件系统中,然后通过AUFS,再将readonly权限的rootfs添加到bootfs之上,当rootfs检查完毕之后,再将用户所要使用的文件内容挂载到rootfs之上,同样是readonly权限。每次挂载一个FS文件层,并且每层之间只会挂载增量(在这里大家可以借助于SVN进行理解,相当每个FS层都是SVN提交上去的数据增量)。
这些文件层就是堆栈式文件系统中所保存的数据。将不同的文件层挂载到同一个文件系统中的文件系统,就是联合文件系统:而AUFS就是用来管理、使用这些文件层的文件系统,因此也称之为高级多层次统一文件 系统(Advanced Multi Layered Unification Filesystem)。
但是每个FS层都是readonly权限那么容器内部如何向这些文件写入数据呢?其实当Docker利用AUFS加载完最高一层之后,会在最上面再添加一个FS层,而这个层是readwrite权限。容器内部的应用,对当前文件系统所有的写操作(包括删除)都会保存在这个FS层当中,而当容器向Docker发出commit命令后,Docker 会将这个FS层中的数据作为单独一个文件层保存到AUFS之中。
而一个镜像(image) 就可以理解为:特定FS层的集合。所以可以看出镜像的层次关系,处于下层的image是上层image的父类,而没有父类image的就是baseimage.因此需要从image启动continer时,Docker 会依次加载baseimage和父类image,而用户所有的操作就都保存在最高层的readwrite的layer中。
通过将镜像“分隔”为AUFS的文件层,使得所有容器都可以共享文件层,且不会发生写冲突。但在Docker中,所有的镜像都是只读的,所有的镜像也都不保存用户信息,只会用于新建和复制。而对于容器而言,其所看到的所有文件都是可读写的,只不过所有的写操作都被保存在最上层的文件层当中,
Docker正是通过AUFS的这些特性,解决了容器初始化和写时复制问题,所以Docker 选择AUFS作为其第二个核心组件。
3. Namespace
Docker通过Namespace+cgroup作为虚拟化的方案。Namespace主要是完成资源的隔离。
创建容器时,docker为其创建一组命名空间。每个容器都可以拥有自己单独的命名空间,运行在其中的应用都像是在独立的操作系统中运行一样,保证了容器之间彼此互不影响。
而linux的Namespace模块可以支持容器所需的六项基本隔离。分别是:
- PID Namespace:隔离进程
- IPC Namespace:隔离进程间通信
- Network Namespace:隔离网络
- UTS Namespace:独立主机名
- Mount Namespace:隔离文件系统
- User Namespace:隔离用户和用户组
linux提供这六项Namespace的系统调用。
查看/proc/[pid]/ns
文件:
[root@master proc]# ls -l /proc/10/ns/
total 0
lrwxrwxrwx 1 root root 0 Apr 3 04:58 ipc -> ipc:[4026531839]
lrwxrwxrwx 1 root root 0 Apr 3 04:58 mnt -> mnt:[4026531840]
lrwxrwxrwx 1 root root 0 Apr 3 04:58 net -> net:[4026531956]
lrwxrwxrwx 1 root root 0 Apr 3 04:58 pid -> pid:[4026531836]
lrwxrwxrwx 1 root root 0 Apr 3 04:58 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Apr 3 04:58 uts -> uts:[4026531838]
可以看到文件指向不同的namespace号的文件,如果两个进程指向同一个namespace编号,那么说明他们都在同一个namespace下。
a. IPC Namespace
容器中进程交互还是采用了linux常见的进程间交互方法,包括信号量、消息队列和共享内存等。对于一个资源隔离的容器来说,容器内部的所有进程都只能访问主机分配给它的资源,换句话说,容器内部的进程通信,对于宿主机来说,就是具有相同PID的进程间通信。
IPC namespace的作用是使划分到不同IPC namespace的进程组通信上相互隔离,无法通过消息队列、共享内存、信号量方式通信。
PID命名空间和IPC命名空间可以组合起来一起使用,同一个IPC名字空间内的进程可以彼此可见,允许进行交互,不同空间的进程则无法交互。
b. PID Namespace
Pid namespace是对进程pid的容器虚拟化,从pid的维度实现容器间的隔离。即在一个容器中只能看到属于该pidns的pid,从而在某种程度上实现了进程间的隔离。
内核为所有的PID Namespace维护了一个树状结构,最顶层的是系统初始化创建的,被称为Root Namespace,由它创建的新的PID Namespace成为它的Child namespace,原先的PID Namespace成为新创建的Parent Namespace,这种情况下不同的PID Namespace形成一个等级体系:父节点可以看到子节点中的进程,可以通过信号对子节点的进程产生影响,反过来子节点无法看到父节点PID Namespace里面的进程。下面用一个图描述容器、进程pid、pid namespace关系:
通过箭头连接的,表示同一个进程在不同的pidns中的pid表示。
c. UTS Namespace
UTS(UNIX Time-sharing System)命名空间提供主机名和域名的隔离,从而可以虚拟出一个有独立主机名和网络空间的环境,使得每个容器在网络中都可被设为单独节点,而非宿主机环境中的单独进程。
在Docker中,每个镜像基本都以自身所提供的服务名称来命名镜像的hostname,不会对宿主机产生影响。
默认情况下,Docker容器的主机名就是返回的容器ID:
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
fdd435874ee2 mysql "docker-entrypoint..." 2 hours ago Up 15 minutes 0.0.0.0:3306->3306/tcp, 33060/tcp mysql-latest
d. Network Namespace
一个网络命名空间为进程提供了一个完全独立的网络协议栈的视图,包括IP协议栈,IP路由、端口信息、物理设备等。在linux系统中,一个物理设备最多只能被包括在一个network namespace中,但容器不止一个。为了解决此问题,Docker为每个容器的Network创建一堆虚拟网络设备,一个名为eth0,放置到容器当中,另一个名为vethN,放置到宿主机环境中。
这样每个容器的网络就能隔离开来。Docker采用虚拟网络设备的方式,将不同命名空间的网络设备连接到一起,默认情况下,容器中虚拟网卡被本地主机上的docker0网桥连接在一起。
e. User Namespace
同样一个用户的 user ID 和 group ID 在不同的 user namespace 中可以不一样(与 PID nanespace 类似)。换句话说,一个用户可以在一个 user namespace 中是普通用户,但在另一个 user namespace 中是超级用户。
User namespace 可以嵌套(目前内核控制最多32层),除了系统默认的 user namespace 外,所有的 user namespace 都有一个父 user namespace,每个 user namespace 都可以有零到多个子 user namespace。
为了支持这种行为,每个进程的UID实际上有两种值:容器中的是一种,容器外是另一种。Group ID和进程UID相似。这种对偶性通过维护每个 User namespace用户ID的映射完成的:每个用户命令空间有一张表,这张表中记录着宿主机中用户ID对应namespace 的用户ID。这种映射是通过读写/proc/PID/uid_map(/proc/PID/gid_map gid映射文件)伪文件来设置。
f. Mount Namespace
类似chroot,将一个进程方到一个特定的目录执行。挂载命名空间允许不同命名空间的进程看到文件结构不同,这样每个命名空间中的进程所看到的文件目录彼此被隔离。
4. Cgroup
命名空间隔离运行环境,使得容器中的进程看起来还想再地理的环境中运行,但光有环境隔离还不够,因为这些进程还是可以不受限制使用系统资源。为了防止进程占用太多资源而影响其他进程,Docker使用Cgroup对容器中的进程做限制,就是限制进程组使用的资源上限,包括 CPU,内存,磁盘,网络带宽。
控制组(CGroups)是Linux内核的一个特性,主要用来对共享资源进行隔离、限制、审计等。只有能控制分配到容器的资源,Docker才能避免多个容器同时运行时的系统资源竞争。
控制组的设计目标是为不同的应用情况提供统一接口,从控制单一进程到系统级虚拟化。
具体来看,控制组提供一下功能:
- 资源限制:组可以设置为不超过设定的内存限制。
- 优先级:通过优先级让一些组优先得到更多的CPU等资源。
- 资源审计:用来统计系统实际上把多少资源用到适合的目的上,可以使用cpuacct子系统记录某个进程组使用的CPU时间。
- 隔离:为组隔离名字空间,这样一个组不会看到另一个组的进程、网络连接和文件系统。
- 控制:挂起、恢复和重启动等操作。
安装Docker后可以在/sys/fs/cgroup/memory/docker/目录下看到对Docker组应用的各种限制项:
ls /sys/fs/cgroup/memory/docker
5. 联合文件系统
在容器内,应该看到完全独立的文件系统,而且不会受到宿主机以及其他容器的影响。这个独立的文件系统,就叫做容器镜像。它还有一个更专业的名字叫 rootfs.rootfs 中包含了一个操作系统所需要的文件,配置和目录,但并不包含系统内核。因为在 Linux 中,文件和内核是分开存放的,操作系统只有在开启启动时才会加载指定的内核。这也就意味着,所有的容器都会共享宿主机上操作系统的内核。
在 PaaS 时代,由于云端和本地的环境不同,应用打包的过程,一直是比较痛苦的过程。但有了 rootfs ,这个问题就被很好的解决了。因为在镜像内,打包的不仅仅是应用,还有所需要的依赖,都被封装在一起。这就解决了无论是在哪,应用都可以很好的运行的原因。
不光这样,rootfs 还解决了可重用性的问题,想象这个场景,你通过 rootfs 打包了一个包含 java 环境的 centos 镜像,别人需要在容器内跑一个 apache 的服务,那么他是否需要从头开始搭建 java 环境呢?docker 在解决这个问题时,引入了一个叫层的概念,每次针对 rootfs 的修改,都只保存增量的内容,而不是 fork 一个新镜像。
即联合文件系统是一个轻量级的分层文件系统,它支持将文件系统中的修改信息作为一次提交,并层层叠加,同时可以将不同目录挂载到同一个虚拟文件系统下。联合文件系统是实现Docker镜像的技术基础。镜像可以通过分层来进行继承。例如,用户基于基础镜像来制作各种不同的应用镜像。这些镜像共享同一个基础镜像层,提高了存储效率。此外,当用户改变了一个Docker镜像,则一个新的层会创建。因此,用户不用替换整个原镜像或重新建立,只需要添加新层即可。用户分发镜像的时候,也只需要分发被改动的新层内容。
层级的想法,同样来自于 Linux,一个叫 union file system (联合文件系统)。它最主要的功能就是将不同位置的目录联合挂载到同一个目录下。对应在 Docker 里面,不同的环境则使用了不同的联合文件系统。比如 centos7 下最新的版本使用的是 overlay2,而 Ubuntu 16.04 和 Docker CE 18.05 使用的是 AuFS.
可以通过 docker info 来查询使用的存储驱动:
docker info |grep 'Storage Driver'
Storage Driver: overlay2
当Docker利用镜像启动一个容器时,将利用镜像分配文件系统并且挂载一个新的可读写的层给容器,容器会在这个文件系统中创建,并且这个可读写的层被添加到镜像中。
6. 网络实现
Docker的网络实现其实就是利用了linux上的网络命名空间和虚拟网络设备。
- 基本原理
直观上看,要实现网络通信,机器需要至少一个网络接口(物理或虚拟接口)与外界相同,并可以收发数据包;此外,如果不同子网之间要进行通信,需要额外的路由机制。
Docker中的网络接口默认都是虚拟接口。虚拟接口的最大优势就是转发效率极高。这是因为linux通过在内核中进行数据复制来实现虚拟接口之间的数据转发,即发送接口的发送缓存中的数据包将被直接复制到接收接口缓存中,而无需通过外部物理网络设备进行交换。对于本地系统和容器内系统来看,虚拟接口跟一个正常的以太网卡相比并无区别,只是它速度快很多。
Docker容器网络就很好地利用了linux虚拟网络技术。它让本地主机和容器内分别创建一个虚拟接口,并让它们彼此连通。
- 网络创建过程
Docker创建一个容器的时候,会具体执行如下操作:
- 创建一对虚拟接口,分别放到本地主机和新容器的命名空间中。
- 本地主机一端的虚拟接口连接到默认的docker0网桥或指定网桥上,并具有一个以veth开头的唯一名字,如veth1234。
- 容器一端的虚拟接口将放到新创建容器中,并修改名字作为eth0。这个接口只在容器的命名空间可见
- 从网桥可用地址段中获取一个空闲地址分配给容器eth0,并配置默认路由网关为docker0网卡的内部接口docker0的IP地址。
完成这些之后,容器就可以使用它所能看到的eth0虚拟网卡来连接其他容器和访问外部网络。
容器的实质是进程,但与直接在宿主执行的实例进程不同,容器进程属于自己的独立的命名空间。因此容器可以拥有自己的root文件系统、自己的网络配置、自己的进程空间、甚至自己的用户ID。
这样看来,容器只是一种被限制了的特殊进程。