Docker之二十:深入了解架构和核心概念
容器发展历史
2013 年诞生的 Docker 正在让容器技术得到全世界技术公司和开发人员的关注。
Docker 容器和虚拟机
虚拟机是用来进行硬件资源划分的解决方案,它利用硬件虚机化技术,例如 VT-x、AMD-V 或者 privilege(权限等级)会同时通过一个 hypervisor 层来实现资源的彻底隔离。
容器则是操作系统级别的虚拟化,利用的是内核的 Cgroup 和 Namespace 特性,此功能完全通过软件来实现,仅仅是进程本身就可以与其他进程隔开,不需要任何辅助。
Docker 容器与主机共享操作系统内核,不同的容器之间可以共享部分系统资源,因此容器更加轻量级,消耗的资源更少。而虚拟机会独占分配给自己的资源,几乎不存在资源共享,各个虚拟机之间近乎完全隔离,因此虚拟机更加重量级,也会消耗更多资源。
Docker 技术架构
Docker 基于容器技术的轻量级需计划,并没有传统虚拟化中的 Hypervisor 层。其虚拟化技术基于内核的 Cgroup 和 Namespace 技术。
通信上,Docker 并不直接与内核交换,它通过更底层工具 Libcontainer 与内核交互。Libcontainer 是真正意义上的容器引擎,它通过 clone 系统调用直接创建容器,通过 pivot_root 系统调用进入容器,且通过直接操作 cgroupfs 文件实现资源管控,Docker 本身侧重于处理更上层业务。
LXC
什么是 LXC ?目前代表两种含义:
- LXC 用户态工具(https://github.com/lxc/lxc)。
- Linux Container,即内核容器技术的简称。
这里长指第二种。Docker 在内核容器技术(Cgroup 和 Namespace)的基础上,提供了更高层的控制工具,改工具包含以下特性:
- 跨主机部署。LXC 实现了“进程沙盒”,是跨主机部署的前提条件。Docker 将目标程序运行依赖的主机特定配置,包括网络、存储、发行版等配置进行抽象,并与应用程序一同打包,所以可以保证在不同硬件、不同配置的机器上 Docker 容器中运行的程序和其所依赖的环境及配置是一致的。
- 以应用为中心。简化了应用程序的部署。
- 自动构建。Docker 提供了一套能够从源码自动构建镜像的工具。该工具可以灵活的使用 make、maven、chef、puppet、salt、debian 包、RPM 包和源码包形式,将应用程序的依赖、构建工具和安装包进行打包处理,而且当前机器的配置不会影响镜像的构建过程。
- 版本管理。
- 组件重用。任何容器都可以用作生成另一个组件的基础镜像。
- 共享。Docker 用户可以访问公共的镜像 Registry。
- 工具生态链。Docker 定义了一系列 API 来定制容器的创建和部署过程并实现自动话。有许多工具能够与 Docker 集成并扩展 Docker 的能力,包括类 PaaS 部署工具(Dokku、Deis 和 Flynn)、多节点编排工具(Maestro、Salt、Mesos、OpenStack nova)、管理面板(Docker-ui、OpenStack Horizon、Shipyard)、配置管理工具(Chef、Puppet)、持续集成工具(Jenkins、Strider、Travis)等。
Docker 容器
Docker 通过 Libcontainer 实现对容器生命周期的管理、信息的设置和查询,以及监控和通信等功能。容器以镜像为基础,同时又为镜像提供一个标准的和隔离的执行环境。
容器很好的诠释了 Docker 集装箱的理念,它可以安装任意的软件和库文件,做任意的运行环境。开发和运维人员在转移和部署应用的时候,并不用关心容器里面装什么软件,也不用了解他们是如何配置的。
容器的组成
容器 = cgroup + namespace + rootfs + 容器引擎(用户态工具)
- CGroup:资源控制。
- Namespace:访问控制。
- rootfs:文件系统隔离。
- 容器引擎:生命周期控制。
容器的创建原理
Step 1:通过 clone 系统调用,并传入各个 Namespace 对应的 clone flag,创建一个新的子进程,该进程拥有自己的 Namespace。
/* 由以下代码可知,该进程拥有自己的 pid、mount、user、net、ipc、uts、namspace */
pid = clone(fun, stack, flags, clone_arg);
(flags: CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUSER |
CLONE_NEWNET | CLONE_NEWIPC | CLONE_NEWUTS |
...)
Step 2:将 Strp 1 产生的进程 pid 写入各个 Cgroup 子系统中,这样该进程就可以受到相应 Cgroup 子系统的控制。
echo $pid > /sys/fs/cgroup/cpu/tasks
echo $pid > /sys/fs/cgroup/cpuset/tasks
echo $pid > /sys/fs/cgroup/blkio/tasks
echo $pid > /sys/fs/cgroup/memory/tasks
echo $pid > /sys/fs/cgroup/devices/tasks
echo $pid > /sys/fs/cgroup/freezer/tasks
Step 3:该 fun 函数由上面的新进程执行,在 fun 函数中,通过 privot_root 系统调用,使进程进入一个新的 rootfs,之后通过 exec 系统调用,在新的 Namespace、Cgroup、rootfs 中支持 “/bin/bash” 程序,
fun()
{
...
pivot_root("path_of_roofs/", path);
...
exec("/bin/bash");
...
}
通过上面的步骤,成功地在一个“容器”中运行了一个 bash 程序。
CGroup
概念
Cgroup 是 control group 的简写,属于 Linux 内核提供的一个特性,用于限制和隔离一组进程对系统资源(包括 CPU、内存*、block I/O* 和网络带宽)的使用,也就是做资源的 QoS。
从实现角度来看,Cgroup 实现了一个通用的进程分组的框架,而不同资源的具体管理则是各个 Cgroup 子系统实现的,Cgroup 实现的子系统及其作用如下:
- devices:设备权限控制。
- cpuset:分配指定的 CPU 和内存节点。
- cpu:控制 CPU 占用率。
- cpuacct:统计 CPU 使用情况。
- memory:限制内存的使用上限。
- greezer:冻结(暂停)Cgroup 中的进程。
- net_cls:配合 tc(traffic controller)限制网络带宽。
- net_prio:设置进程的网络流量优先级。
- huge_tlb:限制 HugeTLB的使用。
- perf_event:允许 Peft 工具基于 Cgroup 分组做性能检测。
Cgroup 的接口和使用
CGroup 的原生接口通过 cgroupfs 提供,类似于 procfs 和 sysfs,是一种虚拟文件系统。
# 1、挂载 cgroups
mount-t cgroup-o cpuset cpuset /sys/fs/cgroup/cpuset
# 2、查看 cggroups
# 列表中,以 cpuset 开头的控制文件都是 cpuset 子系统产生的,其他文件由 Cgropu 产生
# 列表中 tasks 文件记录了这个 Cgroup 的所有进程(包括线程)
ls /sys/fs/cgroup/cpuset
# 3、创建 Cgroup
mkdir /sys/fs/cgroup/cpuset/child
# 4、配置 Cgroup
echo 0 > /sys/fs/cgroup/cpuset/child/cpuset.cpus
echo 0 > /sys/fs/cgroup/cpuset/child/cpuset.mems
# 5、使能 Cgroup
echo $$ > /sys/fs/cgroup/cpuset/child/tasks
Cgroup 子系统
实际的资源的分配是由各个 Cgroup 子系统完成的。
-
cpuset 子系统
cpuset 可以为一组进程分配指定的 CPU 和内存节点,主要接口包括 cpuset.cpus(允许经常使用的 CPU 列表) 和 cpuset.mems(允许进程使用的内存节点列表)。
-
cpu 子系统
cpu 子系统用于限制进程的 CPU 占用率。CPU 比重分配(cpu.shares 接口)、CPU 带宽限制(cpu.cfs_period_us 和 cpu.cfs_quota_us 接口)、实时进程的 CPU 带宽限制(cpu.rt_period_us 和 cpu.rt_runtime_us 接口)。
-
cpuacct 子系统
cpuacct 子系统用来统计各个 Cgroup 的 CPU 使用情况。主要接口包括 cpuacct.stat(报告这个 Cgroup 分别在用户态和内核态消耗的 CPU 实际)、cpuacct.usage(报告这个 Cgroup 消耗的总 CPU 时间)、cpuacct.usage_percpu(报告这个 Cgroup 在各个 CPU 上消耗的 CPU 时间,总和就是 cpuacct.usage 的值)。
-
memory 子系统
memory 子系统用来控制 Cgroup 所能使用的内存上线。主要接口包括 memory.limit_in_bytes、memory.memsw.limit_in_bytes(设定内存加上交换分区的使用总量)、memory.oom_control(设置为 0,内存使用量超过上限时,系统会“杀死”进程)、memory.stat(汇报内存使用信息)。
-
blkio 子系统
blkio 子系统用来现在 Cgroup 的 block I/O 带宽。主要接口包括 bljio.weight(设置权重值,范围在 100 到 1000 之间)、bljio.weight_device(对具体的设备设置权重值,这个值会覆盖 bljio.weigh)、bljio.throttle.read_bps_device(对具体的设备设置每秒读磁盘的带宽)、bljio.throtle.white_bps_device(设置每秒写磁盘的带宽上限)、blkio.throttle.read_iops_device(对指定设备设置每秒读取磁盘的 IOPS 上限)、blkio.throttle.write_iops_device(对指定设备设置每秒写磁盘的 IOPS 上限)。
-
devices 子系统
device 子系统用来控制 Cgroup 的进程对哪些设备有访问权限。主要接口包括 devices.list(只读文件,显示目前允许被访问的设备列表,包括类型(a、c 和 b,分别表示所有设备、字符设备和块设备)、设备号(格式为 major:minor 的设备号)、权限(r、w 和 m,分别表示可读、可写、可创建设备节点 mkmod) 3 个条目,比如 “a*:*rmw” 表示所有设备都可以被访问)、devices.allow(只写文件,写入该文件可以允许相应的设备的访问权限)、devices.deny(只写文件,写入该文件可以禁止相应的设备的访问权限)。
Namespace
概念
Namespace 是将内核的全局资源做封装,使得每个 Namespace 都有一份独立的资源,因此不同的进程在各自的 Namespace 内对同一种资源的使用不会互相干扰。
目前 Linux 内核总共实现了 6 种 Namespace:
-
IPC:隔离 System V IPC 和 POSIX 消息队列(进程间通信隔离)。IPC Namespace 使使用相同的标识符(如消息队列的标识符)在两个 Namespace 中代表不同的消息队列,这样使得两个 Namespace 中的进程不能通过 IPC 进行通信了。
-
Nerwork:隔离网络资源。每个 Network Namespace 都有自己的网络设备、IP 地址、路由表、/proc/net 目录、端口号等。新创建的 Network Namespace 会有一个 loopback 设备,除此之外不会有任何网络设备。IP 工具已经支持 Network Namespace:
# 创建 Network Namespace ip netns add new_ns # 管理特定的 Namespace ip netns execnew_ns ip link list # 启用 loopback 网络接口 ip netns exec new-ns ip link set dev lo up # 测试 loopback 可用 ip netns exec new-ns ping 127.0.0.1 # 删除 Network Namespace ip netns delete new-ns
-
Mount:隔离文件系统挂载点。每个进程能看大的文件系统都记录在 /proc/$$/mounts 里。在创建了一个新的 Mount Namespace 后,进程系统对文件系统挂载/卸载的动作就不会影响到其他的 Namespace。
-
PID:隔离进程 ID,不同的 Namespace 里的进程 PID 可以相同。当创建一个 PID Namespace 时,第一个进程的 PID 号是 1,也就是 init进程。init 进程需要负责回收所有的孤儿进程的资源,且发送给 init 进程的任何信号都会别屏蔽,即使是 SIGKILL信号,也就是说容器无法“杀死” init 进程。
-
UTS:隔离主机名和域名,也就是 uname 系统调用使用的结构体 struct utsname 里面的 nodename 和 domainname 这两个字段。UTS 隔离是因为主机名可以用来代替 IP 地址,在网络中访问某台机器,如果不做隔离,这个机制在容器里面就会出问题。
-
User:隔离用户 ID 和组 ID。也就是说进程在 Namespace 里的用户和组 ID 与它在 host 里的 ID 可以不一样,host 的普通用户进程在容器里可以是 0 号用户,也就是 root 用户,这样,进程在容器内可以做各种特权操作。
Namespace 的接口和使用
对于 Namespace 的操作,主要通过 clone、setns 和 unshare 这 3 个 系统调用来完成。
clone 用来创建新的 Namespace。
unshare 为已有的进程创建新的 Namespace。
Docker 镜像
概念
如果说容器提供了一个完整的、隔离的运行环境,那么镜像则是这个运行环境的静态体现,是一个还没运行起来的“运行环境”。
Docker 镜像是一个可定制的 rootfs(Linux 根文件系统)。Docker 镜像的另一个创新是层级的并且是可复用的。多数基于相同发行版的镜像,在大多数文件的内容上都是一样的。
Docker 镜像的典型表示方法:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TcruyDXm-1614309269245)(.\res\image-11-docker image.png)]
Docker image 包含着数据及必要的元数据,数据由一层一层的 image layer 组成,元数据则是一些 JSON 文件,用来描述数据之间的关系以及一些配置信息。
通过 docker inspect 可以得到 image 的元数据,通过这些元数据信息可以得到某个 image 的所有 layer ,进而组合出容器的 rootfs,再加上元数据的配置信息(环境变量、启动参数、体系架构等)作为容器启动时的参数。
Docker 仓库
Registry 称为 Docker 仓库注册服务,它是存放仓库的地方,其上往往可以存放多个仓库。每个仓库集中存放某一类镜像,往往包括多个镜像文件,通过不同的标签(tag)来进行区分。
Docker 公司提供的官方 Registry 叫做 Docker Hub。
Registry 本身就是一个开源项目,任何人都可以下载后自己部署一个 Registry。
为了满足容灾需求,Docker 仓库后端采用分布式存储(亚马逊 S3、微软 Azure 和华为 UDS 等)。
Registry 内部结构: