Docker介绍
当我们提到微服务部署或者虚拟化的时候,通常会想到docker跟kubernetes。docker自2013年以来发展火热,现在已经是虚拟化技术的标准方案。
Docker基于Golang语言实现,能够在单台机器上部署多个互相隔离的应用,且能够控制应用之间不发生CPU、内存等资源抢占。
Docker跟以往的VM虚拟机有什么区别?
为什么我们经常说Docker是轻量级的虚拟机,它跟我们之前用的VMWare,VirtualBox等虚拟机有什么区别,轻在哪儿呢?让我们首先看看两者对比图:
从上图我们可以很直观地看到这两者的区别:
- VM虚拟机:从下到上,分别是Server(物理机,如Macbook)、Host OS(物理机的操作系统,如macOS)、Hypervisor(用于运行虚拟机负责虚拟化的管理软件,如VirtualBox)、Guest OS(在虚拟机上安装的操作系统,如Ubuntu,跟上层应用服务没有明显绑定关系)、Bins/Libs(App依赖的各种类库)、Apps(应用程序)。
其中我们可以很明显地看出来,App跟依赖库(libs)以及虚拟机操作系统之间没有很明显的绑定关系,App在能够到虚拟机操作系统上面运行之前,需要提前准备环境,增加不少运维成本,更重要的是,部署应用程序的时候,不能保证依赖的运行环境必然充分。 -
Docker:从下往上,分别是Server、Host OS(前二相同)、Docker Engine(Docker的运行引擎,守护进程,我们待会也会着重讲解这块的核心设计)、Containers(多个运行容器)。
Docker的精髓主要体现在Docker Engine层的资源隔离设计以及Container层的一体化打包方式上。另外,Docker比VM虚拟机轻的地方在于,由Docker Engine抽象出来的虚拟化技术,让上层的Container服务可以设计得非常轻量:稳定的镜像+空间节约+秒级启动。
Docker基本思想
基础设施即代码(Infrastructure as Code)
Docker的出现,主要是解决在开发、测试、线上运维的各个阶段,需要一种虚拟化技术来解决环境不一致的问题。
Docker把基础设施的构建过程通过dockerfile让我们将应用程序的运行环境依赖跟业务代码一起纳入到版本控制中,避免因为环境不一致造成运行结果不符预期的可能。这种方式就是我们常说的基础设施即代码的思想。
不可变基础设施(Immutable Infrastructure)
通过版本控制 + CI/CD过程 + Docker技术,我们可以在每次发布时构建出来一个不可变的镜像,这个镜像不管在什么平台上面运行,都能借助Docker Engine的能力,维持应用程序的环境稳定,减少运维维护成本。这就是“不可变基础设施(Immutable Infrastructure)”。
Docker的出现确实是应时代所需,那么,Docker的这种虚拟化技术的出现有哪些核心技术的支撑呢?
Docker核心实现
Docker的虚拟化技术借助了Linux系统的基础技术,如使用Namespace来隔离资源,使用cgroups来隔离执行单元,使用ufs来组合不同文件系统。
下面我们逐个对这些概念做下了解。
Namespace
Docker虚拟化的第一个问题,是如何在同一台机器上,把进程、内存、文件系统、网络这些基本要素隔离起来,让每一个容器之间互不影响?
解决方案是Namespace,目前,Linux内核里面提供了7种不同类型的namespace:
名称 宏定义 隔离内容
Cgroup CLONE_NEWCGROUP Cgroup root directory (since Linux 4.6)
IPC CLONE_NEWIPC System V IPC, POSIX message queues (since Linux 2.6.19)
Network CLONE_NEWNET Network devices, stacks, ports, etc. (since Linux 2.6.24)
Mount CLONE_NEWNS Mount points (since Linux 2.4.19)
PID CLONE_NEWPID Process IDs (since Linux 2.6.24)
User CLONE_NEWUSER User and group IDs (started in Linux 2.6.23 and completed in Linux 3.8)
UTS CLONE_NEWUTS Hostname and NIS domain name (since Linux 2.6.19)
其中,docker主要使用了其中的cgroups, ipc, network, mount, pid:
- 1、进程隔离(PID Namespace)
我们运行一个redis的docker容器,通过docker exec的方式进入容器内部,查看进程列表,可以看到:
# ps -ef
UID PID PPID C STIME TTY TIME CMD
redis 1 0 0 2018 ? 09:01:49 redis-server 0.0.0.0:6379
root 61599 0 1 15:18 ? 00:00:00 /bin/bash
root 61604 61599 0 15:18 ? 00:00:00 ps -ef
看到里面展示的只有这个Container的进程列表,却对宿主机的其他进程一无所知,而且这个进程列表的第一条进程的PID是1,也就是init进程,这就不是简单的进程过滤了,而是通过CLONE_NEWPID这个Namespace实现的,完全创建出来了一套独立的进程管理体系来实现进程隔离。
- 2、网络隔离(Network Namespace)
Docker中的服务,大部分是需要通过网络来实现与外界通信的,那如何让Container有自己的网络地址避免端口冲突,又能通过宿主机跟外界交互呢?
Linux Network Namespace能让进程拥有一个完全独立的网络协议栈视图,而Docker利用它为每个Container提供一个独立的虚拟网卡,并提供了4种网络隔离的方式给Container使用,它们分别是:- host模式,--net=host,使用跟宿主机一样的网络,不会分配独立的Network Namespace。
- container模式,--net=containerID,指定跟其他container共同使用同一个已创建的Network Namespace。
- none模式,--net=none,拥有空的独立Network Namespace,但不会创建独立虚拟网卡。
-
bridge模式,--net=bridge,是docker的默认方式,拥有独立的Network Namespace及虚拟网卡、独立IP,并通过虚拟网桥的方式连接到宿主机对外通信。
- 3、挂载点隔离(Mount Namespace)
Mount Namespace为Container提供了一个独立的文件系统挂载视图,跟Container的进程空间的一系列文件通过符号链接的方式关联起来,每个Container都只能看到自己mount namespace的文件系统挂载点下的内容,从而实现对文件系统的隔离。
当传入CLONE_NEWNS标志,使用clone函数创建一个mount namespace的时候,操作系统会从调用该函数的进程的mount namespace中拷贝一份出来,创建一个新的mount namespace,创建以后,两个namespace基本就相互隔离了,隔离以后,再使用chroot对子进程的系统根目录进行迁移,从而从根本上实现挂载点的隔离。
// 创建一个子进程,加入flag传入的namespace
int clone(int (*child_func)(void *), void *child_stac, int flags, void *arg);
- 4、进程间通信(IPC Namespace)
Linux进程间通信的方式主要有管道、消息队列、共享内存、信号量跟Socket套接字。Linux的IPC Namespace也是通过clone函数加上CLONE_NEWIPC参数创建,同一个IPC Namespace下的进程彼此可通信,与其他IPC Namespace下的进程则互相隔离。
Control groups
这里我们把cgroups单独拿出来讲,因为上面的namespace介绍的进程、网络、IPC、文件系统等隔离机制,实际上是抽象资源层面的隔离,各个Container之间虽然互不知道彼此的存在,但它们却真真实实地共享着同一个物理机器的物理资源,如CPU、内存、磁盘等。考虑一个场景,如果一个Container在调度的过程中抢占了大量的CPU资源,而其他Container在不发生任何变化的情况下却受到了影响,这种不稳定性对于生产级别的高可用应用程序来说,肯定是无法接受的。
而Linux的Control Groups,也就是我们常说的cgroups,就是用来对物理资源进行隔离的。
cgroups组包含了7个子模块,分别用来限制进程组使用的不同模块:
- cpu:限制CPU使用比重。
- cpuset:多核系统上分配及限制核心使用数量。
- cpuacct:生成CPU使用报告。
- blkio:块设备IO资源(磁盘、USB等)的读写次数、带宽等限制。
- devices:控制设备访问。
- freezer:cgroups的任务调度控制。
- memory:限制内存上限。
具体到使用中,我们看几个使用docker run的参数,为某个Container指定资源的限制:
memory子系统:
- 限制只能使用512MB内存
docker run --memory 512MB
- 限制内存最大使用512MB,且交换分区及内存总和限制1GB以内
docker run --memory 512MB --memory-swap 1G
- 限制内核内存使用最多100MB
docker run --kernel-memory 50M
- 设置当系统发现oom(内存溢出)时候不杀死容器(非常规场景使用)
docker run --oom-kill-disable=true
cpu子系统:
- 限制CPU使用周期,需结合cpu-period与cpu-quota一起使用,前者指定总量,后者指定占总量的比重,下面命令指定限制的CPU比重为50%
docker run --cpu-period=50000 --cpu-quota=25000
cpuset子系统:
- 限制CPU使用个数为2个。
docker run --cpuset-cpus 2
Docker镜像原理
上文讲到了Docker Engine的核心实现原理,但除了这部分的实现很精髓之外,Docker镜像的打包原理也同样非常优秀,下面我们来看看。
- 镜像分层
先看一个dockerfile的内容:
FROM debian:latest
RUN apt-get install emacs
RUN apt-get install apache2
上面的dockerfile的目标是打包出一个具备emacs编辑器以及Apache服务器的Docker基础镜像,对应的构建过程图是这样的:
-
Copy on Write
Docker的镜像是分层的,每一层都是只读的,上一层做的修改不会影响底层的基础设施。这样做的好处是,如果本地/镜像仓库里面已经有了镜像A,那么基于镜像A构建的其他镜像,拉取/提交的时候就不需要整个镜像仓库传输了,而只需要拉取差异化的部分内容,从而极大地提高了构建速度及传输效率。
当Docker镜像通过docker run指令被启动成一个容器(Container)的时候,会在只读的各个分层顶端创建一个读写层,运行容器的过程中我们对其做的任何修改,都只会作用在该读写层。
我们会发出疑问,基于这样的设计,那么当我们对文件进行增删改查的时候,实际上是如何操作的呢?
- 1)添加文件:直接在容器读写层对应目录增加。
- 2)删除文件:在容器读写层标记该文件已删除。
- 3)修改文件:自上而下逐层找到对应文件,复制到容器读写层,然后对文件进行修改。
- 4)查询文件:自上而下逐层找到对应文件,发起文件读取。
上述的过程,称之为Copy on Write。
总结
Docker作为虚拟化技术的实际标准,跟以往的VM虚拟机相比更轻量,具备稳定的镜像+空间节约+秒级启动的优点。
Docker借助了Linux平台的许多优秀设计,包括Namespce、Control Groups、UnionFS等,在理念、速度、稳定性、灵活性方面都远超以往的VM虚拟机。而随着docker虚拟化技术的普及,容器编排工具蓬勃发展,目前已经形成以kubernetes为事实标准的容器编排方案。
参考
Linux命名空间概述
Docker 核心技术与实现原理
Docker overview
Docker 背后的内核知识——Namespace 资源隔离
docker的cgroup篇
Docker 资源管理探秘:Docker 背后的内核 Cgroups 机制
10张图带你深入理解Docker容器和镜像
第八篇:Docker镜像结构原理