容器
容器是一种基础工具。它泛指任何可以用于容纳其他物品的工具,可以部分或完全封闭,被用于容器、储存和运输物品。物品可以被放置在容器中,而容器则可以保护内容物。
而在IT领域中,传统的开发和运维之间的协作和认知方面,由于立场的不同而存在巨大的壁垒。因此出现DevOps概念。DevOps是一种重视软件开发人员和运维技术人员之间的沟通合作的文化,以自动化”软件交付“和”架构变更“为流程,使得构建、测试和发布软件能够更快捷和更高质量进行持续交付。
但由于团队架构,工具生态和技术架构等各因素,使得DevOps难以有范式的操作流程可供参考,更多的是在某些特定场景或者交付流程中实现了部分的最佳实践。而现代应用的容器技术的出现,才让DevOps终于有了范式化的路径。
微服务
过去的应用程序,比如商城,或者一个应用系统。因为业务复杂,代码日积月累,导致应用程序非常巨大,比如java中可能近500M的war包。如果发布如此大的包,对运维而言难度很大。
因此,单体巨石服务对敏捷开发概念相背离,因此在容器技术概念出来之后,巨石单体应用找到了突破口。
微服务的主要概念如下:
- 单一应用构成的小服务,各使用不同的语言和数据库元件
- 各服务之间彼此解耦,仅需要以HTTP API的方式通信
- 全自动方式部署
微服务可以完美匹配DevOps理念:
- 大型应用拆分为微服务,让责任边界更明确,从而引导交付流程变革
- 清晰的边界,独立的部署,为更快地迭代和交付流程自动化提供了可能
- 越小的业务边界使得实现的复杂性降低,可提高交付质量
但需要注意的是,微服务的内生复杂性降低,外生的复杂度提高了。比如可能要发布数百个微服务,每个微服务需要独立的迭代和发布,难度非常大。因此,可以总结出微服务的优点和缺点。
优点:
- 将大型的单体系统拆分为多个微服务,解决了应用复杂性的问题
- 每个服务都有专门的团队负责,可使用最合适的技术进行实现,使得架构演进更简单
- 微服务之间耦合度低,可独立更新
- 各微服务可独立部署,可根据业务的特征动态调整规模
缺点:
- 对既有应用进行分布式改造依赖相当程度的技术能力
- 拆分后的各程序职责不同,标准化工作将变得困难
- 各服务的通信成本(延迟)远大于单体内部组件间的通信
- 管理多个组件或者组件拓扑成为微服务的头等要务
- 数据库的设计与业务规划成为微服务中的难点
而容器和容器编排技术定义了新的交付方式,能解决上述缺点中的大部分问题!
容器技术LXC(Linux Container)
经典的容器指操作系统用户空间虚拟化技术,为应用程序及其运行环境提供隔离的沙箱。
- lxc时代的容器技术更像是“虚拟机”,可被称为操作系统容器
- docker时代的容器技术是应用容器,仅用于运行单个应用的进程及子进程
从虚拟化到容器
我们知道,主机级别的虚拟化,能虚拟出完整的物理硬件平台。分为两种类型:
-
类型一的虚拟化
如XenServer、Hpyer-V、VMware ESXi。在硬件上直接安装hypervisor,在hypervisor上跑VM。
-
类型二的虚拟化
如VMware Workstation、Virtual Box。宿主机上先安装host-OS,而后宿主机安装VMM,VMM之上创建VM。
上述虚拟化技术实现的基础:必须存在独立的底层硬件平台。即用户需要部署完整意义上的操作系统,包括了内核空间和用户空间。
然而内核并非部署的主要目的,它主要用于资源分配和管理。而用户空间的程序才能真正产生生产力的,但是内核不得不存在。现在的软件一般是针对内核调用和库调用而设计的,因此必须有内核才能运行应用程序。而进程间的协调也需要内核参与。
倘若,创建一台VM仅仅为了运行web服务器或者tomcat,却不得不为此安装内核,如此代价稍大。而在类型二的虚拟化中,为了运行一个进程甚至需要实现两级调度和资源分派,分别是vm内核的调度和host(hypervisor)的内核调度,开销巨大。因此,减少中间环节,成为有效提高效率的手段。
于是我们考虑,是否可以抽调虚拟机层,仅仅保留应用本身?虚拟机是为了环境隔离而存在,如果运行两个nginx,将会存在两个80端口,因此它们无法使用同一个套接字。而虚拟机的加入可以隔离此冲突。但是,将虚拟机层去掉,是我们所追求的环境。因此,现在面临的问题就是如何在不使用虚拟机的情况下,进行资源(环境)隔离。
我们知道,在虚拟化技术中,先构建底层的硬件平台,平台之上提供虚拟的隔离环境管理器,创建出多隔离环境。进程运行在隔离环境中。由于进程运行在用户空间,因此隔离的是用户空间。此时,焦点定格在用户空间如何隔离的问题上
一般情况下,一个内核空间对应一个用户空间,因此类似Xen一般会将第一个用户空间赋予特权,通过它来管理其他用户空间。
而在容器技术中, 进程直接启动在选择的某一个用户空间中,不同的用户空间共享内核功能,用户空间之间相互隔离。
尽管这种隔离没有VM隔离得那样彻底。即使进程出现bug或者崩溃,也不会影响容器之外的其他空间。因此,这是一种安全隔离的初衷。
应该隔离的组件和名称空间
单独的用户空间的目标是隔离环境的,而进程运行在此空间都认为自己是运行在此内核上的唯一用户空间的进程。而进程看到的其他进程也是内核之上的所有进程。
一个用户空间应该看到的组件如下,也就是Linux内核提供的Namespace名称空间或命名空间。
主机名和域名:UTS
UTS命名空间是Linux内核Namespace(命名空间)的一个子系统,主要用来完成对容器HOSTNAME和domain的隔离,同时保存内核名称、版本、以及底层体系结构类型等信息。
UTS命名空间是扁平化的结构,不同的命名空间之间没有层级关系。
UTS命名空间的用来隔离系统的这些信息,使得用户在容器中查看到的信息是当前容器的系统、版本。不同于主机的UTS,内核通过uts_namespace对当前系统中多个容器的这些信息进行统一管理,每一个容器对应有一个自己的uts_namespace,用来隔离容器的内核名称、版本等信息,不同容器查看到的都是属于自己的信息,相互间不能查看。
在主机场景的UTS中,用户态接口为uname命令。
根文件系统/挂载树:mount
Mount namespace用来隔离文件系统的挂载点, 使得不同的mount namespace拥有自己独立的挂载点信息,不同的namespace之间不会相互影响,这对于构建用户或者容器自己的文件系统目录非常有用。
当前进程所在mount namespace里的所有挂载信息可以在/proc/[pid]/mounts、/proc/[pid]/mountinfo和/proc/[pid]/mountstats里面找到。
Mount namespaces是第一个被加入Linux的namespace,由于当时没想到还会引入其它的namespace,所以取名为CLONE_NEWNS,而没有叫CLONE_NEWMOUNT。
每个mount namespace都拥有一份自己的挂载点列表,当用clone或者unshare函数创建新的mount namespace时,新创建的namespace将拷贝一份老namespace里的挂载点列表,但从这之后,他们就没有关系了,通过mount和umount增加和删除各自namespace里面的挂载点都不会相互影响。
进程间通信专用通道:IPC
IPC namespace 用来隔离 System V IPC 对象和 POSIX message queues。其中 System V IPC 对象包含共享内存、信号量和消息队列。
两个用户空间的进程是在同一段内存上运行的,它们的IPC通信是否正常?如果不正常则隔离的意义就不存在了,因此保证IPC独立。而在内核看来,自身上面的进程是可以任意使用IPC通信的,但如今必须分开。
进程树:PID
进程应该从属于某个父进程,或者INIT/systemd。进程的运行需要两棵树:进程树和文件系统树。如果进程认为自己是空间唯一的,需要制造一个假象:该进程是init或者进程需要附属另一个进程,否则用户空间的进程将无法被管理。因此,独立的空间都应该有自己的init。但是系统上的init,只能存在一个。因此必须做多个init来管理进程或者一个用户空间仅运行一个进程。一旦进程结束则用户空间消失。因此,进程树互相隔离,也就是需要伪装多个PID进程树。
用户:USER
进程必须以某个用户身份运行,第一个用户空间的用户和第二个用户空间的用户的ID可能是相同的UserID,那每个用户空间是否需要一个root?但是一个内核之上只能有一个root,如果拥有多个root,则root可以随意删除其他用户空间。因此,每个用户空间都需要伪装root,在真实的系统上映射为普通用户。而在所属用户空间可以为所欲为,同时需要将用户空间的属主属组都改为该普通用户,并且需要让进程看到会认为是root。因此,用户组需要隔离。
网络:Network
如果用户空间是独立的,该空间是否可以拥有独立的IP地址?因此每个用户空间都如同虚拟机,有专用的网卡,网络口,TCP/IP协议栈,端口空间0-65535。
namespace | 系统调用参数 | 隔离内容 | 内核版本 |
---|---|---|---|
UTS | CLONE_NEWUTS | 主机名和域名 | 2.6.19 |
IPC | CLONE_NEWIPC | 信号量、消息队列和共享内存 | 2.6.19 |
PID | CLONE_NEWPID | 进程编号 | 2.6.24 |
Network | CLONE_NEWNET | 网络设备、网络栈、端口 | 2.6.29 |
Mount | CLONE_NEWNS | 挂载点(文件系统) | 2.4.19 |
User | CLONE_NEWUSER | 用户和用户组 | 3.8 |
两个容器之间还需要相互通信,相当于两个独立的计算机通信。在内核中,TCP/IP协议栈只有一个,于是,需要在内核级进行切分隔离的区域,也就是namespace。UTS资源在名称空间之间相互隔离,并且分别各自使用而不影响主的空间,比如mount,IPC,User也可支持到名称空间中。
内核可以通过名称空间的机制,原生支持上述六种资源隔离。还能通过系统调用,向外输出。比如创建进程clone(),放置进程到某名称空间中setns(),从名称空间中取出unshared()
因此,容器技术就是依靠内核级别的六种name spaces + chroot实现的。
资源分配:CGroup(Control Group)
内核版本必须>3.8才支持上述六种名称空间。如此就可以将虚拟机的内核抽出,让多个用户空间从属于同一个内核,称为容器级别的虚拟化技术。其问题在于,虚拟机在创建时可以指定其资源限制,比如最多能使用2个CPU核心。而名称空间工作于同一个内核,如果其中的进程发生内存泄漏,导致消耗完内存后,其他进程因申请内存失败而发生OOM,内存耗尽而被kill,因为内存是一种非可压缩的资源。或者消耗CPU,导致其他任务一直挂起,分配不到CPU时间片,而CPU是一种可压缩的资源。这些问题会影响到整个系统的稳定运行,影响到其他用户空间。
因此,必须限制用户空间所有进程可使用的资源总量。比如,将CPU分配为1:2:1来分配给三个名称空间。如果此时只有第一个名称空间需要大量的CPU能力,可直接使用整个CPU,一旦第二个和第三个名称空间需要使用CPU,则按比例分配。
如果再添加一个用户空间,则CPU分配为1:2:1:1,如此以非常弹性的分配方式,是因为CPU能力是可压缩的,这就是按比例型分配。
另外一种分配方式:限制用户空间的所有进程,最多能使用多少个核心。这就是单一用户空间的CPU核心分配。
而内存的分配也是类似的,可指定该用户空间只能使用多少内存。
上述机制的实现,是由内核级别的cgroups实现的。而cgroup则由多个子系统构成。
Control Groups
分成多组(子系统),然后把每个组内的资源量指派分配到特定的用户空间的进程上。
- cpu 子系统,主要限制进程的 cpu 使用率。
- cpuacct 子系统,可以统计 cgroups 中的进程的 cpu 使用报告。
- cpuset 子系统,可以为 cgroups 中的进程分配单独的 cpu 节点或者内存节点。
- memory 子系统,可以限制进程的 memory 使用量。
- blkio 子系统,可以限制进程的块设备 io。
- devices 子系统,可以控制进程能够访问某些设备。
- net_cls 子系统,可以标记 cgroups 中进程的网络数据包,然后可以使用 tc 模块(traffic control)对数据包进行控制。
- net_prio — 这个子系统用来设计网络流量的优先级
- freezer 子系统,可以挂起或者恢复 cgroups 中的进程。
- ns 子系统,可以使不同 cgroups 下面的进程使用不同的 namespace
- hugetlb — 这个子系统主要针对于HugeTLB系统进行限制,这是一个大页文件系统。
可以理解为,一个大锅里,根据不同性质的分组来分配不同的粮条。一个系统上的不同进程分门别类。每一个类,为一个控制组。而资源针对不同的控制组来不同指派。上述所有的组,都从属于同一个组。组内还能再次划分为子组。而子组能继承组内的资源使用权限。如此,就可以使用容器了。
容器的隔离能力,相较于主机级别的虚拟化而言是差很多的。毕竟它共用了同一个内核,而非如主机虚拟化般本来就是内核隔离的。为了加强安全性,需要通过安全加强的机制来加固容器的边界,因此,需要启动selinux机制。然而大多数情况下都不愿意启动。
因此,chroot + name space + cgroup 支撑了容器技术。
容器发展的技术
初期发展的技术实现方式:
-
Jail
最早出现在FreeBSD,为了应用的安全运行。
-
vserver
将jail山寨到Linux平台中,称为vserver。它也能实现jail的效果,背后用到的功能是chroot,可以将子目录当做根来使用。但是这无法实现用户空间的隔离,仅仅是隔离看上去的空间。因为,空间是共享同一个内核的,如果其中一个空间的进程使用了特权指令,但实际上只是实际用户空间的一个普通用户执行了特权指令。
LXC
LinuX Container 完整的容器技术,用简易的技术和模板来简化容器技术的 。
- lxc-create:创建用户空间,有最基本的目录结构和基本的应用程序比如ls(可通过复制宿主机的程序实现)。如果底层用户空间是CentOS,创建的用户空间可以是ubuntu。(需要通过模板,或者一组脚本来实现,自动实现安装过程,指定的系统发行版仓库,下载软件包到本地并安装)
- 安装软件完成后,chroot进去,如同进入一个新的虚拟机。
LXC面临的问题
- LXC在一个名称空间中运行程序,肯定会生成许多数据,如果需要从一个宿主机的名称空间将数据迁移到另一个宿主机,如何迁移
- 如何批量创建容器
- 容器中的每个进程,可以使用宿主机的性能,基本无额外开销。但是迁移,分发,创建 依然没有很好的突破口。于是,出现了docker。
Docker
Docker是LXC的增强版,是容器技术的易用工具。容器是内核的功能,而docker将此功能更加简化和 易用化,而普及开来。
Docker是LXC的二次封装发行版,利用LXC做容器封装引擎,如容器的创建、停止、销毁等都是通过LXC进行。在使用容器时,使用镜像技术,也就是用户空间需要的组件编排好,整体打包成image文件,放在集中统一的仓库中。比如最小化的CentOS,最小化的Ubuntu,或者装好nginx的CentOS后打包成一个镜像并放入仓库中。以后要创建容器时,不会如LXC般使用lxc-create来激活模板安装,而是docker连接到镜像服务器上下载匹配容器需要的镜像到本地,并基于镜像启动容器。如此,极大简化了容器的使用难度。
使用的大多数镜像,docker仓库都有。当运行一个容器比如nginx时,直接使用docker run nginx即可。
后来docker开发runClibcontainer,替换掉LXC,并形成了标准:runC
一个用户空间当中可以运行一组或者一个进程,docker可以在一个容器内只运行一个进程(这是最佳实践),比如nginx运行在容器1,tomcat运行在容器2,它们之间的通信通过容器间通信。而不是如LXC在容器中运行多个进程。这样的好处是:一个内核上,倘若将进程都运行在同一个用户空间内,彼此不隔离。如果黑客劫持一个进程,将影响所有进程。
Docker将每个进程圈起来,每个进程都运行在自身的容器中,彼此不可见。容器内部的所有环境都为了该进程而定义,需要什么文件才存在什么文件。因此,一份文件可能存在多份占用更多的空间。不过,即使某个容器的文件被删除,也不会影响另外容器的文件。
如果进程都运行在自己的空间当中,则一些进程跟踪工具如ps,top,strace等都需要存在多份,因为自身的空间需要自此此工具来分析进程。
如果进程终止了,容器也将终止。因此,如果要调试进程是比较困难的。需要安装调试工具,进入容器中调试。对于运维而言,难度加大了;对于开发而言,难度小了。因为程序的分发,部署更容易,可以做到一次开发,到处运行。
底层的操作系统类型和容器里运行的应用是不相关的,只要存在docker,直接run镜像即可。
容器技术为运维带来的影响
- 如果使用编排工具来管理容器,会增加运维管理难度,降低程序员开发复杂度。
- 如果程序故障,调试更加困难
- 做镜像时,需要为镜像添加调试工具,体积变大
当需要批量创建容器时,直接用服务器下载镜像即可。
容器的组织方式
分层构建,联合挂载
比如:
- 底层创建纯净的CentOS镜像
- 基于此镜像,安装一个nginx镜像。Nginx镜像并不包含CentOS镜像,这里有两个镜像。
- 两个镜像叠加后,如同运行在CentOS上的nginx。如此使得镜像分发,体积不会太庞大。
- 如果需要运行nginx,则联合nginx镜像;需要运行tomcat,则联合tomcat镜像。底层镜像是共享的,而镜像是只读的。
- 如果需要修改镜像里的文件,则会在镜像之间附加一层可读写的层,通过cow或者设置为不标记的方式来修改或者删除文件,这种方式会带来性能开销。
在真正使用容器时,并不会在容器本地保存数据。而是在容器外部挂载一个共享的持久性存储来保存数据。如果服务器宕机了,则找另外一个主机重新运行服务,然后挂载存储的数据即可访问,如此容器就可以脱离了主机的束缚。
容器就好像变成一个进程,即使容器关闭了,也就是进程终止了,数据还存在外部存储上。即使把容器删除了,然后重新创建容器,数据依旧存在。这样一来,容器就存在了生命周期,并可以运行任何主机之上。
假如,底下有多台主机,主机上有docker。在底层硬件上坡上一层软件层,当需要运行容器时,则调度主机docker host来提供资源。而且多个应用之间的启动可能存在依赖关系,从属关系反应在启动和关闭的次序中。这就需要容器的编排工具,包括:
-
swarm + compose + docker machine
-
mesos + marathon
-
kubernetes (google推出)