优势
- 快速移植
- 不需要手动安装依赖
- 比如Java需要jvm的依赖
- 开发和生产环境保持一致
- 不需要手动安装依赖
- 资源隔离
- 保持机器整洁
- 避免一个程序修改的环境变量等影响其他程序
- 减少因为端口等资源冲突导致的错误
- 保持机器整洁
- 安全
- 避免恶意程序影响其余程序
- 限制程序的资源占用(CPU,内存),避免物理机崩溃
缺陷
- 容器只能使用宿主机的kernel,且不能修改
- 如果某一应用只能依赖特定的kernel版本下运行,应该使用虚拟机
- 应用依赖内核是指程序直接进行内核调用,而不是仅仅使用库文件
- 不能在 Windows 宿主机上运行 Linux 容器,或者在低版本的 Linux 宿主机上运行依赖高版本的容器
- 如果某一应用只能依赖特定的kernel版本下运行,应该使用虚拟机
- 容器的安全性比虚拟机低
- 除了Device Mapper,其他文件系统无法限制容器使用的磁盘容量,可能导致一个容器把宿主机的磁盘空间耗尽
- 一个大流量IO程序容器可能会耗尽宿主机网络带宽
- 安全容器
gVisor
用 Go 实现了一个运行在用户态的操作系统内核,作为容器运行的Guest Kernel,每个容器都依赖独立的操作系统内核,实现了容器间安全隔离的目的KataContainers
为一个Pod对应启动一个轻量化虚拟机,Pod中的容器,就是运行在这个轻量级虚拟机里的进程。每个Pod都运行在独立的操作系统内核上,从而达到安全隔离的目的
架构
- 在命令行使用的是docker client, 真正提供服务的是docker daemon
- docker client把命令行命令转换为Restful API 通过 socks或者 TCP(https) 发送给 daemon
-
API Server用于接收来自docker client的请求,然后分发给不同模块
-
docker daemon的工作根目录位于
/var/lib/docker
-
命令执行过程
- 创建client实例
- 利用反射从用户命令匹配执行方法
- 例如 run 匹配 CmdRun
- 解析参数
- 获取与daemon通信的认证配置
- 发送POST, GET等请求给daemon
- 例如
docker run
:POST /containers/create?<containerValues>
POST /containers/<createResponse.ID>/start
- 例如
- daemon通过execdriver模块(封装了对OS资源操作的方法)指挥OS创建进程
- client读取daemon的返回结果并显示
-
容器可以看做由namespace,cgroups和rootfs构建出来的进程的隔离环境
容器启动流程
# 隔离(namespace)
- 使用虚拟化技术作为应用沙盒,就必须要由 Hypervisor 来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的 Guest OS 才能执行用户的应用进程
- 这就不可避免地带来了额外的资源消耗和占用
- 虚拟机自己就需要占用 100~200 MB 内存。此外,用户应用运行在虚拟机里面,它对宿主机操作系统的调用就不可避免地要经过虚拟化软件的拦截和处理,这本身又是一层性能损耗,尤其对计算资源、网络和磁盘 I/O 的损耗非常大
- 跟真实存在的虚拟机不同,在使用 Docker 的时候,并没有一个真正的Docker 容器运行在宿主机里面
- docker daemon 只是启动时用,运行时并不需要,真实进程(容器)是直接跑在宿主机上
- 只不过在创建这些进程时,Docker 为它们加上了各种各样的 Namespace 参数
- “敏捷”和“高性能”是容器相较于虚拟机最大的优势
- 容器本质上就是一个加了限定参数的进程
- 与其他所有进程之间是平等的关系
- 宿主机可以直接控制容器内的进程,包括杀掉容器(进程)
-
通过namespace技术, 容器中运行的进程其看不到其余进程,所以在容器中会重新计算进程号
- 但是实际上在原来的宿主机中,这个进程仍然有个原本分配的进程号
- 可以通过
docker top
查看进程的pid- 这个pid就是该进程在宿主机的真实pid
- 可以通过
- 但是实际上在原来的宿主机中,这个进程仍然有个原本分配的进程号
-
在容器内,除了pid=1的进程(init),其他进程是不受docker控制的
- 通过exec进去之后启动的进程,不受控制
- 控制指的是它们的回收和生命周期管理
- 除了init进程,其他进程挂掉了docker也感知不到
- init进程退出后,容器内其余进程也会强制退出,防止资源泄漏
- 通过exec进去之后启动的进程,不受控制
-
容器应该是一个单进程模型
- 希望容器和应用能够同生命周期
- 一旦出现类似于“容器是正常运行的,但是里面的应用早已经挂了”的情况,编排系统处理起来就非常麻烦了
-
linux系统中的init进程(pid=1)是所有节点的父进程
- 它维护了一张进程表,不断检查子进程状态,并负责回收孤儿进程的资源
- 因此如果要确实要在容器中运行多个进程,最先启动的进程应该有资源监控和回收的功能,例如systemd和shell脚本,其余的进程都是该进程的子进程
- 使用shell脚本启动需要在脚本末尾写死循环,否则脚本退出则容器退出
- systemd启动时由于此时容器管理的是 systemd而不是程序本身,无法从外部知道容器内的程序是否处于正常状态
- 容器的存活状态是靠一个前台进程维护的,同时跑多个应用时其余的都会在后台运行
- init进程如果没有编写处理某个信号的逻辑,那么其子进程发送给它的所有信号都会被忽略
- 这样做避免了init进程被误杀
- 父节点发送给其子节点init进程的信号除了SIGSTOP和SIGKILL外也会被忽略
- SIGSTOP和SIGKILL会被强制执行
-
使用
clone
函数创建拥有独立namespace的进程clone
时可以利用flags
参数控制使用多少功能- 例如是否与父进程共享虚拟内存等
- 通过位操作设定,例如
CLONE_NETNET|CLONE_NEWPID
-
每个进程所对应的各种namespace都在
/proc/<pid>/ns
目录下- namespace以文件描述符的形式存在
- 拥有相同namespace号的进程位于同一个namespace下
- 通过
setns()
可以让进程加入一个namespace
-
docker exec
就是通过setns()
工作的docker exec
启动的进程属于容器的namespace和cgroup, 但父进程是daemon而不是容器init进程docker exec
启动的进程如果被kill,则其子进程自动被容器的init进程接管- 如果init进程无法处理这些孤儿进程,则可能导致僵尸进程
具体的ns作用
-
UTS namespace提供了主机名和域名的隔离
- 使得容器在网络中被视为独立的节点,而不是宿主机上的一个进程
-
IPC namespace涉及信号量,消息队列和共享内存
- 不同IPC namespace的进程相互不可见
-
PID namespace是树状结构的,创建子进程也就是创建子节点
- 父节点可以看到子节点,并通过信号控制子节点
- 而子节点无法看到父节点,或对父节点产生影响
- 通过监控docker daemon的子节点并筛选,就可以从外部监控docker容器
ps aux
或者top
调用了/proc
目录下的文件内容- 因此只隔离PID是不够的,还需要隔离文件系统,重新挂载
/proc
目录
- 因此只隔离PID是不够的,还需要隔离文件系统,重新挂载
-
mount namespace通过隔离文件挂载点隔离了文件系统
- 挂载对象:
- 共享挂载的目录发生变量时可以自动传播到其他namespace中
- 从属挂载的目录父namespace的变化可以传播到子namespace,反之不行
- 私有挂载的目录相互之间不传播变动
- 挂载对象:
-
network namespace隔离了网络资源,包括IP协议栈,路由表,防火墙,套接字等
- 可以通过创建
veth pair
在不同network namespace创建通道,从而得以相互通信- 容器隔离网络的做法就是创建一个一头在宿主机的docker0网桥,一头在容器中(通常是
ETH0
)的veth pair
- 容器隔离网络的做法就是创建一个一头在宿主机的docker0网桥,一头在容器中(通常是
docker daemon
和容器的init
进程通过pipe通信
- 可以通过创建
-
user namespace主要限制了容器的用户权限
- 一个容器进程内的超级用户映射到容器外的普通用户
限制(cgroups)
- Cgroups 的全称是 Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等
- 只能限制上限,不能限制下限,容易被抢资,所以需要k8s的调度
- 功能
- 资源限制,如内存等
- 优先级分配,例如CPU优先级和IO带宽
- 资源统计,如CPU使用时长,内存用量等
- 任务控制,例如对任务的挂起和恢复
- API以伪文件系统实现,用户态程序可以通过文件操作实现管理
- 位于
/sys/fs/cgroup
目录 - docker daemon会在对应的资源目录下创建docker目录,并在其中为每个容器ID创建目录来控制资源
- 例如
/sys/fs/cgroup/cpu/docker/<container-ID>
- 例如
- 位于
- 资源限制仅通过 cgroups 限制了固定几种资源的使用不会超限,但是它既不能隔离被共享的硬件比如 L3 cache,也不能有效地防止容器逃逸的问题
文件系统(chroot +UnionFS)
镜像
-
镜像是容器的静态视角,容器时镜像的运行状态
- 不能迁移“有状态”的容器,是因为迁移的是容器的rootfs,但是一些动态视图是没有办法伴随迁移一同进行迁移的
-
优势
- 可以并行下载各层镜像,提高了分法效率
- 当本地存储上包含了一些底层镜像数据的时候,只需要下载本地没有的镜像数据即可,减少了传输数据量
- 因为底层镜像数据是共享的,因此可以节约大量的磁盘空间
-
容器进程在启动前会挂载根目录(
chroot
)到镜像提供的根文件系统(rootfs
)- 通过修改iNode
- 不同版本的linux OS公用相同的kernel,主要不同在于rootfs文件系统
- 镜像内不包括操作系统内核
-
由于
rootfs
里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起- 它只是一个操作系统的所有文件和目录,并不包含内核,最多也就几百兆
- 而相比之下,传统虚拟机的镜像大多是一个磁盘的“快照”,磁盘有多大,镜像就至少有多大
- 这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了
- 它只是一个操作系统的所有文件和目录,并不包含内核,最多也就几百兆
-
镜像的元数据与镜像文件是分开储存的
- repository元数据储存在
/var/lib/docker/image/<graph_driver>/repositories.json
文件中- 该文件中储存了镜像的名字、tag以及对应的镜像ID(采用SHA256算法计算)
- image元数据储存在
/var/lib/docker/image/<graph_driver>/imagedb/content/sha256/<images-ID>
文件中- 包括镜像架构(如amd64), 创建时间,环境变量等信息
- layer元数据储存在
/var/lib/docker/image/<graph_driver>/layerdb/sha256/<layer-ID>
文件中- 包括该层的构建信息以及父镜像层ID
- repository元数据储存在
-
镜像安全
- 通过镜像数字签名验证完整性
分层文件系统
-
当启动一个容器时,docker加载镜像的所有只读层,并在最上层加入init层和读写层
- init层专门用来存放
/etc/hosts
,/etc/hostname
等信息- 用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改
- 这些修改往往只对当前的容器有效,我们并不希望执行commit 时,把这些信息连同可读写层一起提交掉
- init层专门用来存放
-
使用联合文件系统(unionFS)对rootfs进行增量修改
- 读取: 从最上层找到最下层,直到找到或到底
- 写入: 如果文件不存在则在读写层新建,否则把文件复制到读写层并修改
- 写时复制机制
- 删除: 如果文件仅位于读写层,则直接删除;否则先删除读写层备份,然后创建
writeout
文件标志文件不存在- 不会删除只读层文件,所以反而镜像体积变大
- 新建: 如果只读层存在对应的
writeout
文件,则删除后再新建;否则直接在读写层新建
储存卷
- 环境变量和储存卷实现“多态”
- 密码等配置数据不用插入镜像中,而是通过环境变量或者配置文件动态载入
- 使得镜像可以复用
- 绑定挂载卷
- 把宿主机器上的文件或目录映射到容器中,避免不必要的拷贝
- 可以只挂载单个文件
- 可以设置为只读,避免容器的修改
- 共享储存卷
–-volumes-from <container>
参数可以共享储存卷- 不能更改原来绑定的路径,以及读写权限
- 可以通过在数据卷中使用cp命令复制到指定路径
- 如果从多个容器共享,且他们拥有相同给的挂载点,则只会共享最后一个
- 比如共有相同的配置文件路径
- 如果一个数据卷容器有多个挂载路径,那么某一个路径冲突的概率就会增加,所以最好一个数据卷一个挂载
- 本质上就是一个挂载在可读写层的宿主机目录
网络
bridge模式
-
网络栈
- 包括网卡,会换设备,路由表,iptables规则等
- 对于进程来说,上述要素构成了它发起和响应网络请求的基本环境
-
想要实现多台主机(容器)之间的通信,那就需要用网线,把它们连接在一台交换机上
- 在 Linux 中,能够起到虚拟交换机作用的网络设备是网桥
- 扮演了二层交换机的角色,会把来自一个容器网卡的ARP请求广播到其他容器网卡上
- 从而能够使得位于同一个网桥
- Docker 项目会默认在宿主机上创建一个名叫 docker0 的网桥,凡是连接在 docker0 网桥上的容器,就可以通过它来进行通信
- 在 Linux 中,能够起到虚拟交换机作用的网络设备是网桥
-
利用
veth pair
技术可以把容器连接到docker0网桥上- veth pair被创建出来后,总是以两张虚拟网卡的形式成对出现的
- 无论哪一个veth网卡接收到网络报文,都会将报文传输给另一方
- 在宿主机上创建两个虚拟网络接口设备,假设为veth和eth0
- 一个绑定到docker0,一个绑定到容器的namespace下
- 这样保证了宿主机的网络报文若发往veth0,则立即会被eth0接收,实现宿主机到Docker Container网络的联通性
- veth pair被创建出来后,总是以两张虚拟网卡的形式成对出现的
-
docker0网桥连通容器内部的网络栈与宿主机的网络栈
- 这个接口相当于一个网卡,拥有独立的IP地址(ifconfig可以查到)
- 所有bridge模式的容器都被挂载到了docker0的子网中
- 所有连接到docker 0 的接口都是同一个虚拟子网的一部分,可以通过IP地址互相通信
- 问题在于如何方便得知道对方的IP,这就需要
--link
或者加入同一自定义网络了- docker daemon 实现了一个内嵌的 DNS server,使容器可以直接通过容器名通信
- 使用 docker DNS 有个限制:只能在 user-defined 网络中使用。也就是说,默认的 bridge 网络是无法使用 DNS 的
- 问题在于如何方便得知道对方的IP,这就需要
- 如果
-p 1234:5678
,容器之间访问5678端口,外部服务访问1234端口- 因为容器之间属于同一个局域网中,而外部服务访问是通过NAT转换的
- 可以通过设置
-icc=false
禁止容器间通信
-
当遇到容器连不通“外网”的时候
- 先试试 docker0 网桥能不能 ping 通
- 然后查看一下跟 docker0 和 Veth Pair 设备相关的 iptables 规则是不是有异常
-
利用NAT实现与外网的通信
- 每个容器都有独立的IP,但是这个内网IP只有本机知道
- 容器访问外网
- 通过iptables的源地址转换,把IP包的源地址从容器IP修改为网卡IP
- 这样容器访问外网的流量,从外部看来就是从宿主机来的,感知不到容器存在
- 外网访问容器
- 容器使用-p指定映射的端口时,docker会通过iptables创建一条nat规则做目标地址转换,把宿主机打到映射端口的数据包通过转发到docker0的网关,docker0再通过广播找到对应ip的目标容器,把数据包转发到容器的端口上
- 容器使用-p指定映射的端口时,docker会通过iptables创建一条nat规则做目标地址转换,把宿主机打到映射端口的数据包通过转发到docker0的网关,docker0再通过广播找到对应ip的目标容器,把数据包转发到容器的端口上
-
每一个映射的端口,host 都会启动一个 docker-proxy 进程来处理访问容器的流量
-
容器默认可以访问外网,只是外网默认不能访问容器
- 容器通过 -P或者 -p参数启动连接时,默认连接地址为
0.0.0.0
,即接受所有地址的流量- 可以通过显式设置地址来指定允许访问的IP地址
- 容器通过 -P或者 -p参数启动连接时,默认连接地址为
-
bridge模式缺点:跨宿主机容器之间无法直接通信
host模式
- 不需要额外的网桥以及虚拟网卡,故不会涉及docker0以及veth pair
- 可以直接使用宿主机的IP地址与外界进行通信,若宿主机的eth0是一个公有IP,那么容器也拥有这个公有IP
- 容器内部将不再拥有所有的端口资源
- 部分端口资源已经被宿主机本身的服务占用,还有部分端口已经用以bridge网络模式容器的端口映射
joined模式(自定义网络)
- 虽然上图两个joined容器所在的网络和默认bridge网络存在连接,但是仍然无法通信
- 因为iptables DROP掉了网桥 docker0 与自建网络之间双向的流量
- 通过
docker network connect <net> <container>
可以让容器加入自定义网络
- 位于同一自定义网络中的容器之间可以互相通信
- link原理
- 使用
-link
选项关联容器,不但可以避免容器IP和端口暴露到外部导致的安全问题,还能避免容器在重启后IP地址变动导致的访问失败- 原理类似DNS的IP和域名映射
- 在接受容器(即设置了link参数的容器)中保存了设置了以下信息:
- 设置环境变量:源容器的名称、别名、IP、暴露的端口等
- 如果源容器重启后更换了IP,接受容器的环境变量并不会更新
- 更新
/etc/hosts
文件:添加源容器IP和别名的记录- 源容器重启后会自动更新接受容器的
/etc/hosts
文件
- 源容器重启后会自动更新接受容器的
- 设置环境变量:源容器的名称、别名、IP、暴露的端口等
- 接收容器必须在源容器后启动
- 这只针对位于默认网络中的容器
- 自定义网络中可以先定义接收容器
- 实际上自定义网络中的link不是通过配置
/etc/hosts
文件实现的,而是通过DNS解析
- 实际上自定义网络中的link不是通过配置
- 使用
none模式
- 不提供任何的网络环境,容器内部就只能使用loopback网络设备,需要自行添加网卡,IP,路由等信息
- 一般用来开发自定义网络环境