容器的发展史
容器是一个通用性的技术,不指代某一个特定的软件产品。 由于Docker的诞生,带动了容器技术的大火,以至于后面很多时候说的容器都指向Docker,其实除了Docker,还有许多其他的容器技术,比如RKT,LXC,Podman等。 其实,在Docker诞生之前,容器技术就已经存在了。 可以通过下面这张图来直观的感受容器的发展历史。![[Docker] 容器技术剖析 - 图1](/uploads/projects/seekerzw@yaaygq/0b9c0aeea4a9d144785c1c07161a1ad2.png)
1999 年,FreeBSD 4.0 支持 jail,第一个商用化的 OS 虚拟化技术。
2004 年,Solaris 10 支持 Solaris Zone,第二个商用化的 OS 虚拟化技术。
2005 年,OpenVZ 发布,非常重要的 Linux OS 虚拟化技术先行者。
2004 年 ~ 2007 年,Google 内部大规模使用 Cgroups 等的 OS 虚拟化技术。
2006 年,Google 开源内部使用的 process container 技术,后续更名为 cgroup。
2008 年,Cgroups 进入了 Linux 内核主线。
2008 年,LXC(Linux Container)项目具备了 Linux 容器的雏型。
2011 年,CloudFoundry 开发 Warden 系统,一个完整的容器管理系统雏型。
2013 年,Google 通过 Let Me Contain That For You (LMCTFY) 开源内部容器系统。
2013 年,Docker 项目正式发布,让 Linux 容器技术逐步席卷天下。
2014 年,Kubernetes 项目正式发布,容器技术开始和编排系统齐头并进。
2015 年,由 Google,Redhat、Microsoft 及一些大型云厂商共同创立了 CNCF,云原生浪潮启动。
2016 年 - 2017 年,容器生态开始模块化、规范化。CNCF 接受 Containerd、rkt项目,OCI 发布 1.0,CRI/CNI 得到广泛支持。
2017 年 - 2018 年,容器服务商业化。AWS ECS,Google EKS,Alibaba ACK/ASK/ECI,华为 CCI,Oracle Container Engine for Kubernetes;VMware,Redhat 和 Rancher 开始提供基于 Kubernetes 的商业服务产品。
2017 年 - 2019 年,容器引擎技术飞速发展,新技术不断涌现。2017 年底 Kata Containers 社区成立,2018 年 5 月 Google 开源 gVisor 代码,2018 年 11 月 AWS 开源 firecracker,阿里云发布安全沙箱 1.0。
2020 年 - 202x 年,容器引擎技术升级,Kata Containers 开始 2.0 架构,,阿里云发布安全沙箱 2.0。 整理容器技术近 20 年的发展历史,大致可以将其分为四个历史阶段,如下:
![[Docker] 容器技术剖析 - 图2](/uploads/projects/seekerzw@yaaygq/2f171253d8849314106d765d0e920aa9.png)
![[Docker] 容器技术剖析 - 图3](/uploads/projects/seekerzw@yaaygq/44520abb315d1de0c15fd49e07366f2b.png)
技术迸发期
2013年之前,云计算行业一直在为云原生的正确打开姿势而操心,PAAS看起来是个不错的方向。PAAS确实推动了云计算的发展,但是并没有形成行业趋势。直到Docker的出现,大家才发现并不是方向不对,而是应用分发和交付的手段不行。 Docker 真正核心的创新是容器镜像(docker image),一种新型的应用打包、分发和运行机制。容器镜像将应用运行环境,包括代码、依赖库、工具、资源文件和元信息等,打包成一种操作系统发行版无关的不可变更软件包。- 容器镜像打包了整个容器运行依赖的环境,以避免依赖运行容器的服务器的操作系统,从而实现 “build once,run anywhere”。
- 容器镜像一旦构建完成,就变成 read only,成为不可变基础设施的一份子。
- 操作系统发行版无关,核心解决的是容器进程对操作系统包含的库、工具、配置的依赖,但是容器镜像无法解决容器进程对内核特性的特殊依赖。这个在实际使用容器的过程中也经常跳进这个大坑:
商用探索期
经过5年的技术发展,容器技术基本成熟,云原生体系也逐具雏型。 从2017年开始,各大云厂商开始试水容器和云原生服务。从目前的商业形态来看,容器相关的云服务大致可分为三类:- 通用容器编排服务**:在容器编排系统三国杀结果出来以前,基于多方下注策略构建的容器编排服务系统。其中 AWS 是自研的编排系统,Azure 的 ACS 同时支持 Docker Swarm、DC/OS 和 Kubernetes,阿里云 ACS 则是支持 Docker swarm 和 Kubernetes。Google 和华为则是坚定支持 Kubernetes 而未推出支持其它容器编排系统的容器服务。随着 Kubernetes 一统容器编排江湖,这条路线的容器服务日渐式微,Azure 更是在今年初直接终止了 ACS 服务。**
- Kubernetes容器编排服务**:Google 是理所当然最早试水 Kubernetes 容器编排服务的大厂,也较早开展了 K8s 容器编排服务。随着 2017 年各大厂在 CNCF 这张谈判桌上达成了 Kubernetes 兼容性认证流程,Kubernetes 编排服务市场迎来一轮大爆发,到 2018 年各大云厂商的 K8s 容器编排服务就完整就位了。**
- Serverless容器实例服务**:从 2017 年开始,行业开始试水 Serverless 容器实例服务,把用户从维护容器基础设施的繁重任务中解放出来从而聚焦业务本身。Google Cloud Run 核心目标是支持 Knative,所以其使用形态上附加了不少约束条件。**
- 行业对容器化的接受程度已经很高,容器化普及率也是逐年提升。
- 容器编排系统已经一战定江山,K8s 成为事实上的容器编排之王。
- Serverless 容器实例服务受到市场的欢迎,客户群体日益扩大。
- 长期看托管容器编排服务和 Serverless 容器实例服务将长期共存,协同满足客户对服务成本和弹性能力的需求。
![[Docker] 容器技术剖析 - 图6](/uploads/projects/seekerzw@yaaygq/3f8c6a0645833f8d9ac22686d371b90f.png)
- Container on VM。也就是按照分层设计思路,通过 IaaS + PaaS 的架构构建容器服务,这个是商用探索阶段的典型架构。基于各大云厂商成熟的 IaaS 基础设施生产虚拟机,在虚拟机里面部署容器服务组件。这种架构采用的是 lift and shift 策略,把容器服务的运维责任从用户转移到云厂商。采用和用户相同的软件组件,只是转移运维责任,有利于引导客户逐步上云、接受云原生思维。但是这个时期云厂商提供的服务是单纯的运维托管,相对用户自建容器服务并没有太明显的技术优势,甚至受多租户隔离的限制部分使用体验还不如用户自建容器服务。
- Container with hardware virtualization。如果沿用 Container on VM 的分层设计架构,云厂商很难构建独有的技术优势。对于 Serverless 容器实例服务,服务交付平面已经从 IaaS 的硬件接口上移到 OS Syscall,所以不要遵循 VM + 容器的分层设计思路。我们需要从需求本源出发,容器服务需要高性能、强隔离、够安全和低成本的容器引擎。
- 技术萌芽期:解决了容器运行环境的隔离问题
- 技术迸发期:解决了软件分发及容器编排问题
- 商用探索期:确认了容器的商用服务形态
- 商用拓展期:扩大适用场景和部署规模,通过技术创新提升产品竞争力
Docker
在上面已经大量的提到Docker,而且在很长的一段时间里,容器就是Docker,Docker就是容器。 其实,Docker只是基于容器技术实现的一个软件。 Docker是基于Linux内核的CGroup、Namespace以及UnionFS等技术,对进程进行封装隔离,属于操作系统层面的封装隔离,由于隔离的进程独立于宿主机和其他进程,因此也称其为容器。 其实Docker最初是基于LXC(Linux Container)实现的,从0.7版本之后去除LXC,转而使用自己研发的Libcontainer,从1.11版本开始,则进一步演进为使用RunC和Containerd。 Docker的诞生,极大的简化了容器的创建和维护,使得Docker技术比虚拟机技术更轻便、快捷。为什么要用Docker
在虚拟机时代,传统的部署方式如下:- 开发将编译好的制品上传到共享仓库
- 运维从共享仓库下载制品,上传到运行服务
- 运维启停服务,比如启停Tomcat
- 如果需要新服务,还需要创建虚拟机,安装基础环境,比如JDK,Tomcat等
- 开发通过Dockerfile将应用打包成镜像,上传到镜像仓库
- 运维从镜像仓库下载镜像,通过Docker run启动服务
- 更高效的利用系统资源
- 更快的启动时间
- 一致的运行环境
- 更轻松的迁移、维护和扩展
![[Docker] 容器技术剖析 - 图7](/uploads/projects/seekerzw@yaaygq/6330226b6bb6c3d0ac1a83629f2c46ba.jpeg)
特性 | 容器 | 虚拟机 |
---|---|---|
启动 | 秒级 | 分钟级 |
硬盘使用 | 一般为MB | 一般为GB |
性能 | 接近原生 | 弱于原生 |
系统支持量 | 单机支持上千容器 | 一般几十个 |
Docker的架构
Docker在1.11之前主要是通过docker daemon来处理client的请求,容器的相关操作都是通过docker daemon来完成。从1.11之后,并不是简简单单的通过docker daemon来处理了,它集成了Containerd、RunC等多个组件。这些组件之间相互协作来完成客户端请求和容器管理。 现在的架构图如下:![[Docker] 容器技术剖析 - 图8](/uploads/projects/seekerzw@yaaygq/081828c77c279e18e0c0466bad896efa.png)
- 主机**:用于承载Docker运行的底座**
- 镜像仓库**:存放镜像制品的地方**
Docker的技术底座
Docker除了容器镜像是革命性的创新,其他的算是新瓶装旧酒。 Docker是基于Linux内核的CGroups、Namespace以及UnionFS等技术来实现的,这些也是它的技术底座。![[Docker] 容器技术剖析 - 图9](/uploads/projects/seekerzw@yaaygq/e692b80beb39df89589c4cd0c6088757.jpeg)
- CLONE_NEWCGROUP
- CLONE_NEWIPC
- CLONE_NEWNET
- CLONE_NEWNS
- CLONE_NEWPID
- CLONE_NEWUSER
- CLONE_NEWUTS
Namespace | Flag | Page | Isolates |
---|---|---|---|
Cgroup | CLONE_NEWCGROUP | cgroup_namespaces | Cgroup root directory |
IPC | CLONE_NEWIPC | ipc_namespaces | System V IPC,POSIX message queues 隔离进程间通信 |
Network | CLONE_NEWNET | network_namespaces | Network devices,stacks, ports, etc. 隔离网络资源 |
Mount | CLONE_NEWNS | mount_namespaces | Mount points 隔离文件系统挂载点 |
PID | CLONE_NEWPID | pid_namespaces | Process IDs 隔离进程的ID |
Time | CLONE_NEWTIME | time_namespaces | Boot and monotonic clocks |
User | CLONE_NEWUSER | user_namespaces | User and group IDs 隔离用户和用户组的ID |
UTS | CLONE_NEWUTS | uts_namespaces | Hostname and NIS domain name 隔离主机名和域名信息 |
通过调用这个方法,就会创建一个独立的进程空间,它的Pid=1。 当然,Docker使用到的Namespace不仅仅只有Pid,还有其他的Namespace以提供不同层面的隔离。
int clone(int (*fn) (void *),void *child stack,
int flags, void *arg, . . .
/* pid_ t *ptid, void *newtls, pid_ t *ctid */ ) ;
- The pid namespace: 管理 PID 命名空间 (PID: Process ID).
- The net namespace: 管理网络命名空间(NET: Networking).
- The ipc namespace: 管理进程间通信命名空间(IPC: InterProcess Communication).
- The mnt namespace: 管理文件系统挂载点命名空间 (MNT: Mount).
- The uts namespace: Unix 时间系统隔离. (UTS: Unix Timesharing System).
- The user namespace: 管理系统用户.
PID Namespace
进程是Linux系统中非常重要的概念,它表示一个正在执行的程序。 在Linux操作系统上,可以通过ps -ef命令查看当前系统中正常执行的进程,如下在CentOS 7系统上查看到的进程信息。其中Pid=1和Pid=2的两个进程比较特殊,前者负责系统启动和配置管理的 ,后者负责管理和调度其他进程的。 PS:在其他操作系统上,1号进程可能叫init,在CentOS 7之前也叫init,但是在CentOS 7之后就改成systemd,主要是为了解决init进程启动时间长并且脚本复杂等问题。 如果我们在当前操作系统上创建一个容器,进容器查看进程,如下:
# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 Mar05 ? 04:50:01 /usr/lib/systemd/systemd --switched-root --system --deserialize 22
root 2 0 0 Mar05 ? 00:00:02 [kthreadd]
root 4 2 0 Mar05 ? 00:00:00 [kworker/0:0H]
root 6 2 0 Mar05 ? 00:13:29 [ksoftirqd/0]
root 7 2 0 Mar05 ? 00:02:39 [migration/0]
root 8 2 0 Mar05 ? 00:00:00 [rcu_bh]
root 9 2 0 Mar05 ? 01:08:08 [rcu_sched]
root 10 2 0 Mar05 ? 00:00:00 [lru-add-drain]
root 11 2 0 Mar05 ? 00:00:19 [watchdog/0]
root 12 2 0 Mar05 ? 00:00:15 [watchdog/1]
root 13 2 0 Mar05 ? 00:02:38 [migration/1]
root 14 2 0 Mar05 ? 00:11:02 [ksoftirqd/1]
root 16 2 0 Mar05 ? 00:00:00 [kworker/1:0H]
root 18 2 0 Mar05 ? 00:00:00 [kdevtmpfs]
root 19 2 0 Mar05 ? 00:00:00 [netns]
......
我们可以看到容器里的进程非常干净,并没有外部主机的那些进程。 这就是Pid Namespace的作用。
# docker run -d --name busybox busybox sleep 3000
b776c8105860a5d46046365ca03b71ec13dac9e1dbe4b8d392e468d49a42020f
# docker exec busybox ps -ef
PID USER TIME COMMAND
1 root 0:00 sleep 3000
7 root 0:00 ps -ef
- 不同的用户进程就是通过**Pid Namespace**进行隔离的,且不同的Namespace中可以有相同的Pid。
- 有了**Pid Namespace**,每个Namespace中的Pid能够相互隔离。
如果我们查看所有进程数,可以看到如下关系(删除了很多不必要的信息):
# ps -ef | grep b776c8105860
root 2862574 3699 0 16:20 ? 00:00:00 containerd-shim -namespace moby -workdir /var/lib/docker/containerd/daemon/io.containerd.runtime.v1.linux/moby/b776c8105860a5d46046365ca03b71ec13dac9e1dbe4b8d392e468d49a42020f -address /var/run/docker/containerd/containerd.sock -containerd-binary /usr/bin/containerd -runtime-root /var/run/docker/runtime-runc
root 2866529 2861401 0 16:21 pts/0 00:00:00 grep --color=auto b776c8105860
整体的管理逻辑如下:
systemd─┬
├─dockerd─┬─containerd─┬
│ │ ├─containerd-shim─┬─sleep
│ │ │ └─10*[{containerd-shim}]
![[Docker] 容器技术剖析 - 图10](/uploads/projects/seekerzw@yaaygq/540a8323ce5d57f7ea77d3a2375727eb.jpeg)
- Host:主机模式,使用主机网络
- Container:容器模式,和某个容器共享网络
- None:拥有自己的网络空间,但是不配置任何网络信息
- Bridge:桥接模式,通过veth桥接到主机上
每当在该主机上启动一个容器,会创建一对虚拟网卡veth设备,其中一端连接到docker0,另一端连接到容器中的网卡(例如eth0)上,并且网关地址是docker0的地址。 虚拟网桥的工作方式和物理交换机的工作方式类似,我们可以把主机当作是一个物理交换机,这样所有容器都通过交换机连接在了一个二层网络。
# ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
link/ether 52:54:00:e3:77:24 brd ff:ff:ff:ff:ff:ff
inet 10.0.4.9/22 brd 10.0.7.255 scope global eth0
valid_lft forever preferred_lft forever
inet6 fe80::5054:ff:fee3:7724/64 scope link
valid_lft forever preferred_lft forever
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
link/ether 02:42:6f:d4:33:49 brd ff:ff:ff:ff:ff:ff
inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
valid_lft forever preferred_lft forever
inet6 fe80::42:6fff:fed4:3349/64 scope link
valid_lft forever preferred_lft forever
可以看到正常通信,那具体是怎么通信的呢? 首先docker0和容器网卡eth0是veth对,两个属于直连,是可以直接通信的,那主要问题就在docker0怎么和宿主机通信的。 其实docker0是通过iptables和主机通信的,所有符合条件的的请求都会通过iptables转发到docker0并由网桥分发给对应的容器。
# ping 172.17.0.3
PING 172.17.0.3 (172.17.0.3) 56(84) bytes of data.
64 bytes from 172.17.0.3: icmp_seq=1 ttl=64 time=0.056 ms
64 bytes from 172.17.0.3: icmp_seq=2 ttl=64 time=0.056 ms
64 bytes from 172.17.0.3: icmp_seq=3 ttl=64 time=0.041 ms
Docker 通过 Linux 的Net命名空间实现了网络的隔离,又通过 iptables 进行数据包转发,让 Docker 容器能够优雅地为宿主机器或者其他容器提供服务。
# iptables -t nat -L
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
DOCKER all -- anywhere anywhere ADDRTYPE match dst-type LOCAL
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
DOCKER all -- anywhere !loopback/8 ADDRTYPE match dst-type LOCAL
Chain DOCKER (1 references)
target prot opt source destination
RETURN all -- anywhere anywhere
MNT Namespace
虽然我们已经通过 Linux 的命名空间解决了进程和网络隔离的问题,在 Docker 进程中我们已经没有办法访问宿主机器上的其他进程并且限制了网络的访问,但是 Docker 容器中的进程仍然能够访问或者修改宿主机器上的其他目录,这是我们不希望看到的。 在新的进程中创建隔离的挂载点命名空间需要在 clone 函数中传入 CLONE_NEWNS,这样子进程就能得到父进程挂载点的拷贝,如果不传入这个参数子进程对文件系统的读写都会同步回父进程以及整个主机的文件系统。 如果一个容器需要启动,那么它一定需要提供一个根文件系统(rootfs),容器需要使用这个文件系统来创建一个新的进程,所有二进制的执行都必须在这个根文件系统中。?![[Docker] 容器技术剖析 - 图13](/uploads/projects/seekerzw@yaaygq/d4186a7bbf048e95ae8b70ee7e69432f.jpeg)
IPC Namespace
IPC Namespace主要实现进程间通信隔离。 进程间通信涉及的IPC资源包括常见的信号量、消息队列和共享内存。申请IPC就是申请一个全局的32位ID,所以IPC Namespace中实际上包含了系统IPC标识符和实现消息队列的文件系统。 容器的进程间交互依然是采用Linux常见的交互方法,所以每个容器就需要独立的IPC标识符,所以在容器创建的时候就要传入CLONE_NEWIPC 参数,实现IPC资源隔离。UTS Namespace
UTS(UNIX Time-sharing System)namespace提供了主机名与域名的隔离,这样每个docke容器就可以拥有独立的主机名和域名了,在网络上可以被视为一个独立的节点,而非宿主机上的一个进程。docker中,每个镜像基本都以自身所提供的服务名称来命名镜像的hostname,且不会对宿主机产生任何影响,其原理就是使用了UTS namespaceUSER Namespace
user namespace主要隔离了安全相关的标识符(identifier)和属性(attribute),包括用户ID、用户组ID、root目录、key(指密钥)以及特殊权限。通俗地讲,一个普通用户的进程通过clone()创建的新进程在新user namespace中可以拥有不同的用户和用户组。这意味着一个进程在容器外属于一个没有特权的普通用户,但是它创建的容器进程却属于拥有所有权限的超级用户,这个技术为容器提供了极大的自由。 user namespace时目前的6个namespace中最后一个支持的,并且直到linux内核3.8版本的时候还未完全实现(还有部分文件系统不支持)。user namespace实际上并不算完全成熟,很多发行版担心安全问题,在编译内核的时候并未开启USER_NS。Docker在1.10版本中对user namespace进行了支持。只要用户在启动Docker daemon的时候制定了–user-remap,那么当用户运行容器时,容器内部的root用户并不等于宿主机的root用户,而是映射到宿主机上的普通用户。 Docker不仅使用了user namespace,还使用了在user namespace中涉及的Capability机制。从内核2.2版本开始,Linux把原来和超级用户相关的高级权限分为不同的单元,称为Capability。这样管理员就可以独立的对特定的Capability进行使用或禁止。Docker同时使用namespace和Capability,这很大程度上加强了容器的安全性。CGroups
Docker通过Linux Namespace实现了进程、文件系统、网络等隔离,但是Namespace并不能为其提供物理资源的隔离,比如CPU、Memory等。 如果其中的某一个容器正在执行 CPU 密集型的任务,那么就会影响其他容器中任务的性能与执行效率,导致多个容器相互影响并且抢占资源。如何对多个容器的资源使用进行限制就成了解决进程虚拟资源隔离之后的主要问题,而 Control Groups(简称 CGroups)就是能够隔离宿主机器上的物理资源,例如 CPU、内存等。? 每一个CGroup都是一组被相同的标准和参数限制的进程,不同的 CGroup 之间是有层级关系的,也就是说它们之间可以从父类继承一些用于限制资源使用的标准和参数。 Linux 的 CGroup 能够为一组进程分配资源,也就是我们在上面提到的 CPU、内存、网络带宽等资源,通过对资源的分配。 Linux 使用文件系统来实现 CGroup,我们可以直接使用下面的命令查看当前的 CGroup 中有哪些子系统: 如果没有lssubsys命令,CentOS可以通过“yum install libcgroup-tools”命令安装。
# lssubsys -m
cpuset /sys/fs/cgroup/cpuset
cpu,cpuacct /sys/fs/cgroup/cpu,cpuacct
memory /sys/fs/cgroup/memory
devices /sys/fs/cgroup/devices
freezer /sys/fs/cgroup/freezer
net_cls,net_prio /sys/fs/cgroup/net_cls,net_prio
blkio /sys/fs/cgroup/blkio
perf_event /sys/fs/cgroup/perf_event
hugetlb /sys/fs/cgroup/hugetlb
pids /sys/fs/cgroup/pids
- cpuset:如果是多核心CPU,这个子系统会为CGroup任务分配单独的CPU
- cpu:使用调度程序为CGroup提供CPU访问
- cpuacct:产生CGroup任务的CPU资源报告
- memory:设置每个CPU的内存限制以及产生内存资源报告
- devices:允许或拒绝CGroup任务对设备的访问
- freezer:暂停或恢复CGroup任务
- net_cls:标记每个网络包以供CGroup使用
- net_prio:针对每个网络接口设置cgroup的访问优先级
- blkio:设置限制每个块的输入输出,例如磁盘、光盘以及USB等
- perf_event:对CGroup进行性能监控
- hugetlb:限制cgroup的huge pages的使用量
- pids:限制一个cgroup及其子孙cgroup中的总进程数
CPU子系统
CPU子系统下主要的文件如下(可以通过ls /sys/fs/cgroup/cpu查看):文件 | 功能 |
---|---|
cpu.shares | 顾名思义,shares=分享。它的工作原理非常类似于进程的nice值。shares就代表软限。 |
cpu.cfs_period_us | 执行检测的周期,默认是100ms |
cpu.cfs_quota_us | 在一个检测周期内,容器能使用cpu的最大时间,该值就是硬限,默认是-1,即不设置硬限 |
cpu.state | 容器的状态:一共运行了多少个周期;一共被throttle了多少次;一共被throttle了多少时间 |
cpu.rt_period_us | 执行检测的周期,默认是1s |
cpu.rt_runtime_us | 在一个检测周期内,能使用的cpu最大时间,只作用于rt任务 |
软限制
软限制是通过设置cpu.shares来实现的。 比如说现在有两个容器,但是只有1颗CPU。当给A容器的cpu.shares配置为512,给B容器的cpu.shares配置为1024,这表示A和B容器使用CPU的时间片比例为1:2,也就是A能用33%的时间片,B能用66%的时间片。![[Docker] 容器技术剖析 - 图14](/uploads/projects/seekerzw@yaaygq/60ee21a7ff48daee59870ca78245a314.jpeg)
- 如果A不忙,没有使用到66%的CPU时间,那么剩余的CPU时间将会被系统分配给B,即B的CPU使用率可以超过33%
- 如果添加了一个新的cgroup C,且它的shares值是1024,那么A的限额变成了1024/(1204+512+1024)=40%,B的变成了20%
- 在闲的时候,shares基本上不起作用,只有在CPU忙的时候起作用,这是一个优点。
- 由于shares是一个绝对值,需要和其它cgroup的值进行比较才能得到自己的相对限额,而在一个部署很多容器的机器上,cgroup的数量是变化的,所以这个限额也是变化的,自己设置了一个高的值,但别人可能设置了一个更高的值,所以这个功能没法精确的控制CPU使用率。
硬限制
顾名思义,硬限制就是给你设置一个上限,你永远不能超过这个上限。 硬限制由两个参数控制:- cpu.cfs_period_us:用来配置时间周期长度,单位微秒,默认是100000微妙。
- cpu.cfs_quota_us:用来配置当前cgroup在设置的周期长度内所能使用的CPU时间数,单位微秒,取值大于1ms,-1代表不受限制。
开发一个简单的代码
# 创建工程
mkdir busyloop
cd busyloop
go mod init busyloop
这个代码会启动一个线程,默认也就占用一个CPU。 然后启动项目
package main
func main(){
for{}
}
使用top命令可以看到使用了一个CPU。
go build
./busyloop
![[Docker] 容器技术剖析 - 图15](/uploads/projects/seekerzw@yaaygq/194530c03cfcae5e4a73c43d494d6cc6.png)
这时候通过top命令可以看到busyloop进程使用0.5个CPU。
# 进入cpu 子系统
cd /sys/fs/cgroup/cpu
# 创建一个busyloop的目录
mkdir busyloop
# 该目录下会自动生成如下文件
ll
total 0
-rw-r--r-- 1 root root 0 Jun 9 17:19 cgroup.clone_children
--w--w--w- 1 root root 0 Jun 9 17:19 cgroup.event_control
-rw-r--r-- 1 root root 0 Jun 9 17:19 cgroup.procs
-r--r--r-- 1 root root 0 Jun 9 17:19 cpuacct.stat
-rw-r--r-- 1 root root 0 Jun 9 17:19 cpuacct.usage
-r--r--r-- 1 root root 0 Jun 9 17:19 cpuacct.usage_percpu
-rw-r--r-- 1 root root 0 Jun 9 17:19 cpu.cfs_period_us
-rw-r--r-- 1 root root 0 Jun 9 17:19 cpu.cfs_quota_us
-rw-r--r-- 1 root root 0 Jun 9 17:19 cpu.rt_period_us
-rw-r--r-- 1 root root 0 Jun 9 17:19 cpu.rt_runtime_us
-rw-r--r-- 1 root root 0 Jun 9 17:19 cpu.shares
-r--r--r-- 1 root root 0 Jun 9 17:19 cpu.stat
-rw-r--r-- 1 root root 0 Jun 9 17:19 notify_on_release
-rw-r--r-- 1 root root 0 Jun 9 17:19 tasks
# 首先在cgroup.procs中记录busyloop的进程
echo 13817 > cgroup.procs
# 这时候还并为做任何限制,所以CPU使用还是1颗
# 给cpu.cfs_quota_us配置50000微妙,也就是使用0.5个CPU
echo 50000 > cpu.cfs_quota_us
![[Docker] 容器技术剖析 - 图16](/uploads/projects/seekerzw@yaaygq/e6d7decfb3297b21bba16e82667cf35e.png)
文件 | 功能 |
---|---|
memory.usage_in_bytes | cgroup下进程使用的内存 |
memory.max_usage_in_bytes | cgroup下进程使用内存的最大值 |
memory.limit_in_bytes | 设置cgroup下进程最多能使用的内存 |
memory.soft_limit_in_bytes | 这个限制并不会阻止进程使用超过限额的内存,只是在系统内存足够时,会优先回收超过限额的内存,使之向限定值靠拢 |
memory.oom_control | 设置是否在 Cgroup 中使用 OOM(Out of Memory)Killer,默认为使用。当属于该 cgroup 的进程使用的内存超过最大的限定值时, 会立刻被 OOM Killer 处理。 |
补充:Linux进程调度
Linux Kernel默认提供了5个调度器,Linux Kernel使用struct_sched_class来对调度器进行抽象。- Stop调度器:stop_sched_class,优先级最高的调度类,可以抢占其他所有进程,不能被其他进程抢占。
- Deadline调度器:dl_sched_class,使用红黑树,把进程按绝对截止期限进行排序,选择最小进程进行调度允许。
- RT调度器:rt_sched_class,实时调度器,为每个优先级维护一个队列。
- CFS调度器:cfs_sched_class,完全公平调度器,采用完全公平调度算法,引入虚拟运行时间的概念。
- IDLE-TASK调度器:idle_sched_class,空闲调度器,每个CPU都有一个idle线程,当没有其他进程可以调度时,运行idle线程。
CFS调度器
CFS调度,是Completely Fair Scheduler的简称,即完全公平调度器。 CFS实现的主要思想是维护为任务提供处理器时间方面的平衡,这意味着应给进程分配相当数量的处理器。 当分给某个任务的时间失去平衡时,应该给失去平衡的任务分配时间,让其运行。 CFS通过虚拟运行时间(vruntime)来维护平衡,维护提供给某个任务的时间量。vruntime = 实际运行时间 * 1024 / 进程权重 进程按照各自不同的速率在物理时钟节拍里前进,优先级高则权重大,其虚拟时钟比真实时钟跑的慢,但获得比较多的运行时间。 CFS调度器没有将进程维护在运行队列中,而是维护了一个以虚拟时间为顺序的红黑树。红黑树主要特点有两个:- 自平衡,树上没有一条路径会比其他路径长出两倍
- O(log n) 时间复杂度,能够在树上进行快速高效地插入或删除进程。
UnionFS
UnionFS是Union File System的简称,也就是联合文件系统。 所谓UnionFS就是把不同物理位置的目录合并mount到同一个目录中,然后形成一个虚拟的文件系统。一个最典型的应用就是将一张CD/DVD和一个硬盘的目录联合mount在一起,然后用户就可以对这个只读的CD/DVD进行修改了。 Docker就是充分利用UnionFS技术,将镜像设计成分层存储,现在使用的就是OverlayFS文件系统,它是众多UnionFS中的一种。 OverlayFS只有lower和upper两层。顾名思义,upper层在上面,lower层在下面,upper层的优先级高于lower层。 在使用mount挂载overlay文件系统的时候,遵守以下规则。- lower和upper两个目录存在同名文件时,lower的文件将会被隐藏,用户只能看到upper的文件。
- lower低优先级的同目录同名文件将会被隐藏。
- 如果存在同名目录,那么lower和upper目录中的内容将会合并。
- 当用户修改merge中来自upper的数据时,数据将直接写入upper中原来目录中,删除文件也同理。
- 当用户修改merge中来自lower的数据时,lower中内容均不会发生任何改变。因为lower是只读的,用户想修改来自lower数据时,overlayfs会首先拷贝一份lower中文件副本到upper中。后续修改或删除将会在upper下的副本中进行,lower中原文件将会被隐藏。
- 如果某一个目录单纯来自lower或者lower和upper合并,默认无法进行rename系统调用。但是可以通过mv重命名。如果要支持rename,需要CONFIG_OVERLAY_FS_REDIRECT_DIR。
其中:
# # mkdir lower upper work merge
- lower**目录用于存放lower层文件**
- upper**目录用于存放upper层文件**
- work**目录用于存放临时或者间接文件**
- merge**目录就是挂载目录**
可以看到lower和upper目录中有相同名字的文件common-file,但是他们的内容不一样。 (3)将这两个目录进行挂载,命令如下:
# echo "From lower." > lower/common-file
# echo "From upper." > upper/common-file
# echo "From lower." > lower/lower-file
# echo "From upper." > upper/upper-file
# tree
.
├── lower
│ ├── common-file
│ └── lower-file
├── merge
├── upper
│ ├── common-file
│ └── upper-file
└── work
挂载的结果如下:
# mount -t overlay -o lowerdir=lower,upperdir=upper,workdir=work overlay merge
可以看到两者共同目录common-dir内容进行了合并,重复文件common-file为uppderdir中的common-file。 (4)在merge目录中创建一个文件,查看效果
# tree
.
├── lower
│ ├── common-file
│ └── lower-file
├── merge
│ ├── common-file
│ ├── lower-file
│ └── upper-file
├── upper
│ ├── common-file
│ └── upper-file
└── work
└── work
# cat merge/common-file
From upper.
可以看到lower层没有变化,新增的文件会新增到upper层。 (5)修改merge层的lower-file,效果如下
# echo "Add file from merge" > merge/merge-file
# tree
.
├── lower
│ ├── common-file
│ └── lower-file
├── merge
│ ├── common-file
│ ├── lower-file
│ ├── merge-file
│ └── upper-file
├── upper
│ ├── common-file
│ ├── merge-file
│ └── upper-file
└── work
└── work
可以看到lower层同样没有变化,所有的修改都发生在upper层。 从上面的实验就可以看到比较有意思的一点:不论上层怎么变,底层都不会变。
# echo "update lower file from merge" > merge/lower-file
# tree
.
├── lower
│ ├── common-file
│ └── lower-file
├── merge
│ ├── common-file
│ ├── lower-file
│ ├── merge-file
│ └── upper-file
├── upper
│ ├── common-file
│ ├── lower-file
│ ├── merge-file
│ └── upper-file
└── work
└── work
# cat upper/lower-file
update lower file from merge
# cat lower/lower-file
From lower.
Docker镜像怎么实现的
Docker镜像就是存在联合文件系统的,在构建镜像的时候,会一层一层的向上叠加,每一层构建完就不会再改变了,后一层上的任何改变都只会发生在自己的这一层,不会影响前面的镜像层。 我们通过一个例子来进行阐述,如下图。![[Docker] 容器技术剖析 - 图18](/uploads/projects/seekerzw@yaaygq/bc2cdc9bbc529f375b91466fb3ac8350.png)
- 基础L1层有file1和file2两个文件,这两个文件都有具体的内容。
- 到L2层的时候需要修改file2的文件内容并且增加file3文件。在修改file2文件的时候,系统会先判定这个文件在L1层有没有,从上图可知L1层是有file2文件,这时候就会把file2复制一份到L2层,然后修改L2层的file2文件,这就是用到了联合文件系统**写时复制**机制,新增文件也是一样。
- 到L3层修改file3的时候也会使用**写时复制**机制,从L2层拷贝file3到L3层 ,然后进行修改。
- 然后我们在视图层看到的file1、file2、file3都是最新的文件。
![[Docker] 容器技术剖析 - 图19](/uploads/projects/seekerzw@yaaygq/fdc4266a9b48f91a7a0b3b38899006ba.png)
Docker的常见操作
Docker现在越来越下沉,甚至很多用户不再使用Docker,在以Kubernetes为中心的容器服务中,Docker不再是必要的选择。 但是作为一款有时代意义的产品,Docker的基本操作对于技术人员来说还是有必要学习和了解的。Docker的常见指令
Docker分为客户端和服务端,这些常见指令是针对客户端的。 Docker客户端的指令有很多,可以通过docker -h来查看,这里只介绍一些比较常用的指令。- docker build
- docker ps
- docker pull
- docker push
- docker image
- docker login
- docker logs
- docker exec
- docker version
Docker镜像的最佳实践
认识Dockerfile
Docker的镜像是通过Dockerfile构建出来的,所以Dockerfile的操作是很重要的 。 先通过一个例子来看看Dockerfile是什么样子。其中FROM指令必须是开篇第一个非注释行,是必须存在的一个指令,后面所有的操作都是基于这个镜像的。后面的指令就是一些操作指令,指令的详情在后面介绍。最后是CMD指定,这个指令表示在容器运行是需要执行的命令。
FROM docker.io/centos
LABEL "auth"="joker" \
"mail"="unclejoker520@163.com"
ENV TIME_ZOME Asia/Shanghai
RUN yum install -y gcc gcc-c++ make openssl-devel prce-devel
ADD nginx-1.14.2.tar.gz /opt/
RUN cd /opt/nginx-1.14.2 && \
./configure --prefix=/usr/local/nginx && \
make -j 4 && \
make install
RUN rm -rf /opt/nginx* && \
yum clean all && \
echo "${TIME_ZOME}" > /etc/timezone && \
ln -sf /usr/share/zoneinfo/${TIME_ZOME} /etc/localtime
COPY nginx.conf /usr/local/nginx/conf/
WORKDIR /usr/local/nginx/
EXPOSE 80
CMD ["./sbin/nginx","-g","daemon off;"]
Dockerfile常用指令
指令 | 说明 |
---|---|
FROM | 指定基础镜像 |
LABEL | 指定标签 |
COPY | 复制文件到镜像中 |
ADD | 添加文件到镜像中,如果是压缩文件会自动解压 |
WORKDIR | 指定工作目录,进入容器的时候默认就在工作目录中 |
ENV | 指定环境变量 |
RUN | 指定运行的命令 |
ENTRYPOINT | 指定容器启动时运行的命令,可以接CMD命令 |
CMD | 指定容器启动时运行的命令,可被覆盖 |
ARG | 指定参数,一般在Build的时候使用 |
EXPOSE | 暴露容器端口 |
VOLUME | 在容器中创建挂载点 |
USER | 指定运行用户 |
最佳实践
只会Dockerfile的命令是不够的,有时候你会发现为什么别人的镜像那么小,为什么别人构建镜像那么快。这其中是有一些技巧的。优化构建上下文
什么是构建上下文? 当执行docker build的时候,执行该命令所在的工作目录就是构建上下文。 为什么要优化构建上下文呢? 当执行docker build构建镜像的时候,会把当前工作目录下的所有东西都加载到docker daemon中,如果没有对上下文进行优化,可能导致构建时间长,构建所需资源多,构建镜像大等问题。 应该如何优化呢? 1、创建单独的目录存放Dockerfile,保持该目录整洁干净。2、如果没有办法把Dockerfile单独存放到某个目录,可以通过在Dockerfile所在目录中添加.dockeringnore文件,在该文件中把不需要的文件填写进去,这样在加载上下文的时候就会把这些文件排除出去。
合理利用缓存
docker在构建镜像的时候,会依次读取Dockerfile中的指令并按顺序依次执行。在读取指令的过程中,会去判断缓存中是否有已存在的镜像,如果存在就不会再执行构建,而是直接使用缓存,这样会加快构建速度。 合理利用缓存,可以加快构建速度,所以在编写Dockerfile的时候把不会改变的指令放到前面,让起尽可能的使用到缓存。 注意:如果某一层得缓存失效,后续的所有缓存都会失效。(1)FROM 指令代表的是基础镜像,app1和app2都是用的同一个,所以在打包的时候,如果本地存在该镜像,就不会到dockerhub上拉取了。
(2)RUN指令执行的是定义的命令,docker会对比命令是否一样,如果一样就直接使用缓存。
(3)ADD指令是拷贝用户文件到镜像中,docker会判断该镜像每一个文件的内容并生成一个checksum,与现存镜像进行比较,如果checksum一致则使用缓存,否则缓存就失效。
合理的优化镜像体积
Docker的镜像是会在服务器与服务器之间、服务器和镜像仓库之间来回传递,如果镜像太大不仅影响传输速度、还占用服务器主机资源,所以合理的优化镜像体积有助于提升效率。 目前可以通过以下方法来优化镜像体积:(1)选用较小的基础镜像
(2)删除不必要的软件包
合理优化镜像层数
镜像的层级越多,镜像的体积相对来说也会越大,而且项目的可维护性越低,目前Docker只有RUN、ADD、COPY这三个命令会创建层级,所以优化镜像层数主要是合理使用这三个命令。 目前通用的解决办法是:(1)合并命令。如果一个Dokcerfile中有多个RUN命令,可以将它们合并成一个RUN。
(2)使用多阶段构建,减少不必要的层级。
合理选择初始化进程
如果一个镜像无法避免使用多进程,那么就应该合理的选择初始化进程。 初始化进程有有以下要求:- 能够捕获SIGTERM信号,并完成子进程的优雅终止
- 能够完成子进程的清退,避免产生僵尸进程
Containerd
Containerd是从Docker中分离的一个项目,旨在为Kubernetes提供容器运行时,负责管理镜像和容器的生命周期。 在kubernetes1.20后会逐步移除docker,不过现在docker和containerd都可以同时为Kubernetes提供运行时。- 如果是docker作为容器运行时,则调用关系是kubelet—>docker-shim—>dockerd—>containerd
- 如果是containerd作为容器运行时,则调用关系是kubelet—>cri-plugin—>containerd
镜像相关
镜像相关功能 | Docker | Containerd |
---|---|---|
显示本地镜像列表 | docker images | crictl images |
下载镜像 | docker pull | crictl pull |
上传镜像 | docker push | 无 |
删除本地镜像 | docker rmi | crictl rmi |
查看镜像详情 | docker inspect IMAGE-ID | crictl inspecti IMAGE-ID |
容器相关
容器相关功能 | Docker | Containerd |
---|---|---|
显示容器列表 | docker ps | crictl ps |
创建容器 | docker create | crictl create |
启动容器 | docker start | crictl start |
停止容器 | docker stop | crictl stop |
删除容器 | docker rm | crictl rm |
查看容器详情 | docker inspect | crictl inspect |
attach | docker attach | crictl attach |
exec | docker exec | crictl exec |
logs | docker logs | crictl logs |
stats | docker stats | crictl stats |
Pod相关
POD 相关功能 | Docker | Containerd |
---|---|---|
显示 POD 列表 | 无 | crictl pods |
查看 POD 详情 | 无 | crictl inspectp |
运行 POD | 无 | crictl runp |
停止 POD | 无 | crictl stopp |