Back

容器

容器底层原理以及docker相关

[TOC]

底层原理

容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个边界。

Namespace - 隔离

进程只能看到被规定的视图,即 隔离,比如通过docker启动一个/bin/sh,再在容器里通过ps命令查看该/bin/sh进程的pid,会发现它的pid是1,但是实际上它在外部的宿主机里的pid是10,使得让在容器里运行的进程以为自己就在一个独立的空间里,实际上只是进行了逻辑的划分,本质还是依赖宿主机。

作用:在同一台宿主机上运行多个用户的容器,充分利用系统资源;不同用户之间不能访问对方的资源,保证安全。

常见的Namespace类型有:

  • PID Namespace:隔离不同容器的进程
  • Network Namespace:隔离不同容器间的网络
  • Mount Namespace:隔离不同容器间的文件系统

与虚拟化的区别:虚拟化是在操作系统和硬件上进行隔离,虚拟机上的应用需要经过虚拟机再经过宿主机,有两个内核,本身就有消耗,而容器化后的应用仅仅只是宿主机上的进程而已,只用到宿主机一个内核;

因为namespace隔离的并不彻底,由于内核共享,容器化应用仍然可以把宿主机的所有资源都吃掉,有些资源不能通过namespace隔离,比如修改了容器上的时间,宿主机上的时间也会被改变,因此需要Cgroups;

Cgroups - 资源限制

是用来制造约束的主要手段,即控制进程组的优先级,设置进程能够使用的资源上限,如CPU、内存、IO设备的流量等

比如,限定容器只能使用宿主机20%的CPU

1
docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash

Cgroups 通过不同的子系统限制了不同的资源,每个子系统限制一种资源。每个子系统限制资源的方式都是类似的,就是把相关的一组进程分配到一个控制组里,然后通过树结构进行管理,每个控制组都设有自己的资源控制参数。

相互关系

每个子系统是一个控制组,每个控制组可以被看作是一个树的节点,每个控制组下可以有多个子节点,比如我们在在CPU子系统中,创建一个DB控制组,然后把所有运行的数据库服务放在其中,然后再在该组下再创建 MySQL和MongoDB两个子组来,分别划分不同的使用资源,所以形成了一颗树,大致就如下图

/sys/fs/cgroup/cpu/{task}/目录表示task这个任务挂在了CPU Cgroup下,在这个目录下有很多的配置文件,比如cpu.cfd_quota_us、cgroup.procs等,文件内容是该task所属进程的PID;

/proc/{PID号}/cgroup文件表示这个进程涉及到的所有cgroup子系统的信息

常见的Cgroups子系统

  • CPU 子系统,用来限制一个控制组(一组进程,你可以理解为一个容器里所有的进程)可使用的最大 CPU,配合cfs(完全公平调度算法)实现CPU的分配和管理。

    cpu share:用于cfs中调度的权重,条件相同的情况下,cpushare值越高,分得的时间片越多。

    cpu set:主要用于设置CPU的亲和性,可以限制cgroup中的进程只能在指定的CPU上运行,或者不能在指定的CPU上运行,同时cpuset还能设置内存的亲和性。

  • memory 子系统,用来限制一个控制组最大的内存使用量。

  • pids 子系统,用来限制一个控制组里最多可以运行多少个进程。

  • cpuset 子系统, 这个子系统来限制一个控制组里的进程可以在哪几个物理 CPU 上运行。

Cgroups 有 v1 和 v2 两个版本,v1中每个进程在各个Cgroups子系统中独立配置,可以属于不同的group,比较灵活但因为每个子系统都是独立的,会导致对同一进程的资源协调困难,比如同一容器配置了Memory Cgroup和Blkio Cgroup,但是它们间无法相互协作。

v2针对此做了改进,使各个子系统可以协调统一管理资源。

Mount Namespace与rootfs(根文件系统)

挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,即容器镜像,也是容器的根文件系统。Mount Namespace保证每个容器都有自己独立的文件目录结构。

镜像可以理解为是容器的文件系统(一个操作系统的所有文件和目录),它是只读的,挂载在宿主机的一个目录上。同一台机器上的所有容器,都共享宿主机操作系统的内核,如果容器内应用修改了内核参数,会影响到所有依赖的应用。而虚拟机则都是独立的内核和文件系统,共享宿主机的硬件资源。

上面的读写层通常也称为容器层,下面的只读层称为镜像层,所有的增删查改操作都只会作用在容器层,相同的文件上层会覆盖掉下层。比如修改一个文件的时候,首先会从上到下查找有没有这个文件,找到,就复制到容器层中,进行修改,修改的结果就会作用到下层的文件,这种方式也被称为copy-on-write。

注意点

容器是“单进程模型”,单进程模型并不是指容器只能运行一个进程,而是指容器没有管理多个进程的能力,它只能管理一个进程,即如果在容器里启动了一个Web 应用和一个nginx,如果nginx挂了,你是不知道的。

另外,直到JDK 8u131以后,java应用才能很好的运用在docker中,在此之前可能因为docker隔离出的配置和环境,导致JVM初始化默认数值出错,因此如果使用以前的版本,需要显示设置默认配置,比如直接规定堆的最大值和最小值、线程数之类的

进程

Linux中的进程状态

活着的进程有两种状态:

  • 运行态(TASK_RUNNING):进程正在运行,或 处于run queue队列里等待
  • 睡眠态(TASK_INTERRUPTIBLE、TASK_UNINTERRUPTIBLE):因为需要等待某些资源而被放在了wait queue队列,该状态包括两个子状态:
    • 可被打断状态(TASK_INTERRUPTIBLE):此时ps查看的Stat的值为 S
    • 不可被打断状态(TASK_UNINTERRUPTIBLE):此时ps查看的Stat的值为 D

进程退出时会有两个状态:

  • EXIT_ZOMBIE状态:僵尸状态;之所以有这个状态是为了给父进程可以查看子进程PID、终止状态、资源使用信息的机会,如果子进程直接消失,父进程则没有机会掌握子进程具体的终止情况。

  • EXIT_DEAD状态:真正结束退出时一瞬间的状态

init进程

init进程也称1号进程,是第一个用户态进程,由它直接或间接创建了Namespace中的其他进程。Linux系统本身在启动后也是这么干的,会先执行内核态代码,然后根据缺省路径尝试执行1号进程的代码,从内核态切换到用户态,比如Systemd。

在容器中,无法使用SIGKILL(-9)和SIGTOP(19)这两个信号杀死1号进程,但对于其他kill信号(比如默认的kill信号是SIGTERM),如果init进程注册了自己处理该信号的handler,则1号进程可以做出响应。但是,如果SIGKILL和SIGTOP信号是从Host Namespace里发出的,则可以被响应,因为此时容器里的1号进程在宿主机上只是一个普通进程。

有时无法被kill掉的原因:

Linux执行kill命令,实际上只是发送了一个信号给到Linux进程,可被调度的进程在收到信号后,一般会从 **默认行为(每个信号都有)、忽略、捕获后处理(需要用户进程自己针对这个信号做handler)**中进行操作,但SIGKILL和SIGTOP这两个信号是特权信号,不允许被自行捕获处理也无法忽略,只能执行系统的默认行为。

执行kill命令时,会调用一系列内核函数进行处理,其中有一个函数sig_task_ignored会判断是否要忽略这个信号。Linux内核里的每个Namespace里的init进程,会忽略只有默认handler的信号,即如果我们的进程有处理相关信号的handler,就可以响应。可以通过cat /proc/{进程pid}/status | grep -i SigCgt查看该进程注册了哪些handler。

ZOMBIE进程

ps aux查看Linux进程,STAT里的状态是Z,ps后有defunct标记,表示该进程为僵尸进程,此时该进程不可被调度。僵尸进程是Linux进程退出状态的一种,进程处于此状态下,无法响应kill命令,虽然资源被释放,但是仍然占用着进程号

形成的原因:

父进程在创建完子进程后,没有对子进程进行后续的管理。

影响:

Linux内核在初始化系统时,会根据CPU数目限制进程最大数量,通过/proc/sys/kernel/pid_max查看,对Linux系统而言,容器是一组进程的集合,如果容器中的应用创建过多,就会出现fork bomb行为,不断建立新进程消耗系统资源,如果达到了Linux最大进程数,导致系统不可用。

解决方案:

因此对于容器,也要限制容器内的进程数量,通过pids Cgroup来完成,限制进程数目大小。在一个容器建立后,创建容器的服务会在/sys/fs/cgroup/pids下建立一个子目录作为控制组,里面的pids.max文件表示容器允许的最大进程数目。当容器内进程达到最大限制后再起新进程,会报错Resource temporarily unavailable

当出现僵尸进程后,父进程可以调用wait函数(该函数是同步阻塞)或者调用waitpid函数(该函数仅在调用时检查僵尸进程,如果没有则返回,不会阻塞等待)回收子进程资源,避免僵尸进程 的产生。或者kill掉僵尸进程的父进程,此时僵尸进程会归附到init进程下,利用init进程的能力回收僵尸进程的资源。

init进程是所有进程的父进程,init进程具备回收僵尸进程的能力。

或者把容器内的init进程替换为tini进程,该进程具备自动回收收子进程的能力。

进程的退出

当我们停止一个容器时,比如docker stop,容器内的init进程会收到SIGTERM信号,而其他进程会收到SIGKILL信号,这意味着只有init进程才能注册handler处理信号实现graceful shotdown,而其他进程不行,直接就退出了。

如果想要容器内的其他进程能收到SIGTERM信号,只能在init进程中注册一个Handler,将收到的信号转发到子进程中,在init进程退出之前把子进程都停掉,子进程就不会收到SIGKILL信号了。

CPU

CPU Cgroup

容器会使用CPU Cgroup来控制CPU的资源使用。CPU Cgroup只会对用户态us和ni、内核态sys做限制,不对wa、hi、si这些I/O或者中断相关做限制。

CPU Cgroup一般会通过一个虚拟文件系统挂载点的方式,挂载在/sys/fs/cgroup/cpu目录下,每个子目录为一个控制组,各个目录间是一个树状的层级关系。

对于普通调度类型,每个目录下有三个文件对应三个参数:

  • cpu.cfs_period_us:CFS的调度周期,单位微秒

  • cpu.cfs_quota_us:一个调度周期内该控制组被允许运行的时间,单位微秒,CPU最大配额 = cpu.cfs_quota_us / cpu.cfs_period_us

  • cpu.shares:CPU Cgroup对控制组之间的CPU分配比例,只有当控制组间的CPU配额超过了CPU可以资源的最大值,则会启用该参数进行配额分配。

Linux中 cpu.cfs_period_us 是个固定值,Kubernetes的pod中,限制容器CPU使用率的requestCPU和limitCPU是通过调整其余两个参数来实现。

在容器内使用top命令查看CPU使用率,显示的是宿主机的CPU使用率以及单个进程的CPU使用率,无法查到该容器的CPU使用率

因为top命令对于单个进程读取/proc/[pid]/stat里面包含进程用户态和内核态的ticks数目,对于整个节点读取的是/proc/stat里各个不同CPU类型的ticks数目,因为这些文件不属于任何一个Namespace,因此无法读取单个容器CPU的使用率,只能通过CPU Cgroup的控制组内的cpuacct.stat参数文件计算得到,该参数文件包含了这个控制组里所有进程的内核态ticks和用户态ticks的值,带入公式即可计算得到。

ticks是Linux操作系统中的一个时间单位,Linux通过自己的时钟周期性产生中断,每次中断会触发内核做一次进程调度,一次中断就是一个ticks

utime:表示进程在用户态部分在Linux调度中获得CPU的ticks,这个值会一直累加

stime:表示进程在内核态部分在Linux调度中获得CPU的ticks,这个值会一直累加

HZ:时钟频率

et:utime_1和utime_2这两个值的时间间隔

进程的 CPU 使用率 =((utime_2 – utime_1) + (stime_2 – stime_1)) * 100.0 / (HZ * et * CPU个数 )

Load Average平均负载

Load Average是指Linux进程调度器中一段时间内,可运行队列里的进程平均数 + 休眠队列中不可打断的进程平均数

所以,有可能在使用top命令时观察到 明明CPU空闲率很高,但是Load Average的数值也很高,CPU性能下降,因为此时休眠队列中有很多在等待的进程,这些进程的stat是D,这种状态可能是IO或者信号量锁的访问导致。

内存

Linux进程的内存申请策略:Linux允许进程在申请内存时overcommit,即允许进程申请超过实际物理内存上限的内存。因为申请只是申请内存的虚拟地址,只是一个地址范围,只有真正写入数据时,才能得到真实的物理内存,当物理内存不够时,Linux就会根据一定的策略杀死某个正在运行的进程,当容器内存使用率超过限制值时,容器就会出现OOM Killed。

OOM Killed的标准是通过oom_badness函数决定,通过以下两个值的乘积决定:

  • 进程已经使用的物理内存页面数
  • 每个进程的OOM校准值oom_score_adj,在/proc/[pid]/oom_score_adj文件中,该值用于调整进程被OOM Kill的几率

Linux的内存类型:有两类,一类是内核使用的内存,如页表、内核栈、slab等各种cache pool(匿名页);另一类是用户态使用的内存,如堆内存、栈内存、共享库内存、文件读写的Page Cache(文件页)。

RSS(Resident Set Size):进程真正申请到的物理页面的内存大小,RSS内存包括了进程的代码段内存,堆内存、栈内存、共享库内存,每一部分RSS内存的大小可查看/proc/[pid]/smaps

Page Cache:为了提高磁盘文件的读写性能,Linux在有空闲的内存时,默认会把读写过的页面放在Page Cache里,一旦进程需要更多的物理内存,但剩余的内存不够,则会使用(page frame reclaim)这种内存页面回收机制,根据系统里空闲物理内存是否低于某个阈值,决定是否回收Page Cache占用的内存。

Memory Cgroup

Memory Cgroup一般会通过一个虚拟文件系统挂载点的方式,挂载在/sys/fs/cgroup/memory目录下,每个子目录为一个控制组,各个目录间是一个树状的层级关系。每个目录有很多参数文件,跟OOM相关的有3个

  • memory.limit_in_bytes:控制控制组里进程可使用的内存最大值,父节点的控制组内该值可以限制它的子节点所有进程的内存使用,Kubernetes的limit改的就是该值,而request仅在调度时计算节点是否满足。
  • memory.oom_control:当控制组中的进程内存使用达到上限时,该参数决定是否触发OOM Killed,默认是会触发OOM Killed,只能杀死控制组内的进程,无法杀死节点上的其他进程。当值设置为1时,表示控制组内进程即使达到limit_in_bytes设置的值,也不会触发OOM Killed,但是这可能会影响控制组中正在申请物理内存页面的进程,造成该进程处于停止状态,无法继续运行。
  • memory.usage_in_bytes:表示当前控制组里所有进程实际使用的内存总和,与limit_in_bytes的值越接近,越容易触发OOM Killed。

当发送OOM killed时,可以查看内核日志 journalctl -k 或者 查看日志文件 /var/log/message

Memory Cgroup只统计了RSS和Page Cache这两部分内存,两者的和等于 memory.usage_in_bytes,当控制组里的进程要申请新的物理内存,但memory.usage_in_bytes已超过memory.limit_in_bytes,此时会启用page frame reclaim内存页面回收机制,回收Page Cache的内存。

判断容器真实的内存使用量,不能仅靠Memory Cgroup里的memory.usage_in_bytes,而需要memory.stat里的rss值,该值才真正反应了容器使用的真实物理内存的大小。

Swap

容器可以使用Swap空间,但会导致Memory Cgroup失效,比如在Swap空间足够的情况下,设置容器limit为512MB,然后容器申请了1G内存,是可以申请成功的,此时容器的RSS为512MB,Swap只剩下512MB空闲。

为了解决这个问题,可以使用swapiness,参数文件在/proc/sys/vm/swapiness,通过它来设置Swap使用的权重,用于定义Page Cache内存和匿名内存释放的比例值,取值范围是0到100,默认是60。100表示匿名内存和Page Cache内存的释放比例是100:100;60表示匿名内存和PageCache内存的释放比例是60:140,此时PageCache内存的释放优先于匿名内存;0不会完全禁止Swap的使用,在内存特别紧张的时候才会启用Swap来回收匿名内存。

swapiness也存在于Memory Cgroup控制组中,参数文件是memory.swappiness,该值的优先级会大于全局的swappiness,但有一点跟全局的swappiness不太一样,当memory.swappiness=0时,是完全不使用Swap空间的。

通过memory.swappiness参数可以使得需要使用Swap容器和不需要使用Swap的容器,同时运行在同一个节点上。

存储

Linux两种文件 I / O模式:

Direct I/O:用户进程写磁盘文件,通过Linux内核文件系统 -> 块设备层 -> 磁盘驱动 -> 磁盘硬件,直到落盘。

Buffer I/O:先把文件数据写入内存就返回,Linux内核有现成会把内存中的数据再写入磁盘,性能更佳。

当文件数据先写入内存时,存在内存的数据叫 dirty pages

当 dirty pages 数量超过 dirty_background_ratio(百分比,默认是10%) 对应的内存量的时候,内核 flush 线程就会开始把 dirty pages 写入磁盘 ;

当 dirty pages 数量超过 dirty_ratio (百分比,默认是20%)对应的内存量,这时候程序写文件的函数调用 write() 就会被阻塞住,直到这次调用的 dirty pages 全部写入到磁盘;

dirty_writeback_centisecs,时间值,单位是百分之一秒,默认是5秒,即每5s会唤醒内核的flush线程来处理dirty pages;

dirty_expire_centisecs,时间值,单位是百分之一秒,默认是30s,定义dirty pages在内存的存放的最长时间,如果超过该时间,就会唤醒内核的flush线程处理dirty pages;

UnionFS

将多个目录(处于不同的分区)一起挂载在一个目录下,实现多目录挂载。这种方式可以使得同一节点上,多个容器使用同一份基础镜像,减少磁盘冗余的镜像数据。OverlayFS是其中的一种实现。

比如容器A和容器B都使用了ubuntu作为基础镜像,放在目录/ubuntu上,容器A使用的额外应用程序放在appA目录,容器B的放在appB目录,将/ubuntu目录和appA目录同时挂载在containA目录下,作为容器A看到的文件系统,同理容器B也是,此时节点就只需要保存一份ubuntu镜像文件即可。

OverlayFS

OverlayFS是一种堆叠文件系统,依赖并建立在其他文件系统之上,如EXT4FS、XFS等,并不直接参与磁盘空间结构的划分,仅将原来底层文件系统中不同目录进行合并,用户见到的Overlay文件系统根目录下的内容就来自挂载时指定的不同目录的合集,OverlayFS会将挂载的文件进行分层。

  • lower:文件的最底层,不允许被修改,只读,OverlayFS支持多个lowerdir,最多500个;
  • upper:可读写层,文件的创建、修改、删除都会在这一层反应,只有一个;
  • merged:挂载点目录,用户实际对文件的操作都在这里进行;
  • work:存放临时文件的目录,如果OverlayFS中有文件修改,中间过程中会临时存放文件到这里;

上下层同名目录合并,上下层同名文件覆盖,lower层文件写时会拷贝(copy_up)到upper层进行修改,不影响lower层的文件。

比如,在merged目录里新建文件,这个文件会出现在upper目录中;删除文件,这个文件会在upper目录中消失,但在lower目录里不会有变化,只是upper目录中会增加一个特殊文件告诉OverlayFS该文件不能出现在merged目录里,表示它被删除;修改文件,会在upper目录中新建一个修改后的文件,而lower目录中原来的文件不会改变。

在Docker中,容器镜像文件可以分成多个层,每层对应lower dir的一个目录,容器启动后,对镜像文件中的修改会保存在upper dir里。Docker会将镜像层作为lower dir,容器层作为upper dir,最后挂载到容器的merged挂载点,即容器的根目录下。

OverlayFS本身没有限制文件写入量的功能,需要依赖底层文件系统,比如XFS文件系统的quota,限制upper目录的写入大小。

Blkio Cgroup

IOPS:每秒钟磁盘读写的次数

吞吐量:每秒钟磁盘读取的数据量,有时也称为带宽

两者的关系:吞吐量 = 数据块大小 * IOPS

Blkio Cgroup一般会通过一个虚拟文件系统挂载点的方式,挂载在/sys/fs/cgroup/blkio目录下,每个子目录为一个控制组,各个目录间是一个树状的层级关系。每个目录有很多参数文件,有4个主要参数来限制磁盘 I/O性能。

  • blkio.throttle.read_iops_device:磁盘读取IOPS限制
  • blkio.throttle.read_bps_device:磁盘读取吞吐量限制
  • blkio.throttle.write_iops_device:磁盘写入IOPS限制
  • blkio.throttle.write_bps_device:磁盘写入吞吐量限制

但在Cgroup V1,只有Direct I/O才能通过Blkio Cgroup限制,Buffered I/O不能,因为Buffered I/O会用到Page Cache,但V1版本各个Cgroup子系统相互独立,所以没办法做限制。

Cgroup V2才可以,通过配置Blkio Cgroup和Memory Cgroup即可解决。有个问题是,如果Memory Cgroup的memory.limit_in_bytes设置得比较小,而容器中进程有大量的IO、这样申请新的Page Cache内存时,又会不断释放老的内存页面,带来了额外的开销,可能会产生写入波动。

网络

容器网络一个Network Namespace网络栈包括:网卡、回环设备、路由表、iptables规则。

Network Namespace

Network Namespace主要用来隔离网络资源,如

  • 网络设备,比如 lo(回环设备)、eth0等,可以通过ip link命令查看
  • IPv4和IPv6协议栈,IP层以及上面的TCP和UDP协议栈都是每个Namespace独立工作,这些参数大都在/proc/sys/net目录下,包括TCP和UDP的port资源。
  • IP路由表,也是每个Namespace独立工作,使用ip route命令查看
  • 防火墙规则,即iptables,每个Namespace可独立配置
  • 网络状态信息,可以从/proc/net/sys/class/net里查看,包括了上面几种资源的状态信息

在宿主机上,可以使用lsns -t net命令查看系统已有的Network Namespace,使用nsenter或ip netns这个命令进入某个Network Namespace里查看具体的网络配置。

网络相关参数很大一部分放在了/proc/sys/net目录下,比如tcp相关的参数,可以直接修改,也可以使用sysctl命令修改。

出于安全考虑,对于非privileged容器,/proc/sys是read-only挂载的,容器启动后无法内部修改该目录下相关的网络参数,只能通过runC sysctl相关接口,在容器启动时对容器内的网络参数进行修改,比如

如果使用Docker,可以加上 --sysctl 参数名=参数值来修改,如果是K8s,则需要用到 allowed unsaft sysctl这个特性了。

可以使用系统函数clone()或unshare()来创建Namespace,比如Docker或者containerd启动容器时,是通过runC间接调用unshare函数启动容器的。

网络通信

容器与外界通信,总共分成两步:

  1. 数据包从容器的Network Namespace发送到Host Network Namespace

  2. 数据包从Host Network Namespace,从宿主机的eth0上发送出去

可以使用 tcpdump抓包工具 查看数据包在各个设备接口的日志

比如查看容器内eth0接收数据包的情况 ip netns exec [pid] tcpdump -i eth0 host [目标ip地址] -nn,查看veth_host或其他设备使用 tcpdump -i [设备名如veth_host、docker0、eth0] host [目标ip地址] -nn

同一节点下容器间的通信

在 Linux 中能够起到虚拟交换机作用的网络设备,是网桥。它是一个工作在数据链路层的设备,主要功能是根据 MAC 地址来将数据包转发到网桥的不同端口(Port)上,因此Docker 项目会默认在宿主机上创建一个名叫 docker0 的网桥,凡是连接在 docker0 网桥上的容器,就可以通过它来进行通信。

容器里会有一个eth0网卡,作为默认的路由设备,连接到宿主机上一个叫vethxxx的虚拟网卡,而vethxxx网卡又插在了docker0网桥上,这一套虚拟设备就叫做Veth Pair。每个容器对应一套VethPair设备,多个容器会将其Veth Pair注册到宿主机的docker0网桥上,即Veth Pair相当于是连接不同Network Namespace的网线,一端在容器,一端在宿主机。此时,数据包就能从容器的Network Namespace发送到Host Network Namespace上了。

docker0和容器会组成一个子网,docker0上的ip就是这个子网的网关ip。

网络请求实际上就是在这些虚拟设备上进行映射(经过路由表,IP转MAC,MAC转IP)和转发,到达目的地。

同一节点内,容器间通信一般流程:容器A往容器B的IP发出请求,请求先经过容器A的eth0网卡,发送一个ARP广播,找到容器B IP对应的MAC地址,宿主机上的docker0网桥,把广播转发到注册到其身上的其他容器的eth0,容器B收到该广播后把MAC地址发给docker0,docker0回传给容器A,容器A发送数据包给docker0,docker0接收到数据包后,根据数据包的目的MAC地址,将其转发到容器B的eth0,

同一节点内,宿主机与容器通信一般流程:宿主机往容器的IP发出请求,这个请求的数据包先根据路由规则到达docker0网桥,转发到对应的Veth Pair设备上,由Veth Pair转发给容器内的应用。

关于容器缺省使用的peer veth方案,由于从容器的veth0到宿主机的veth0会有一次软中断,带来了额外的开销,时延会高一些,可以使用 ipvlan/macvlan的网络接口替代,ipvlan/macvlan 直接在物理网络接口上虚拟出接口,容器发送网络数据包时直接从容器的eth0发送给宿主机的eth0,减少了转发次数,实延就降低了。但是该方案无法使用iptables规则,Kubernetes的service就无法使用该方案。

容器访问另一节点

一个节点内的容器访问另一个节点一般流程:先经过docker0网桥,出现在宿主机上,根据路由表或者nat的方式,知道目标节点是其他机器,则将数据转发到宿主机的eth0网卡上,再发往目标节点。

其实跟同一节点内,宿主机与容器通信类似,最终转化为节点间的通信。

所以当容器无法访问外网时,就可以检查docker0网桥是否能ping通,查看docker0和Veth Pair设备的iptables规则是否有异常。

不同节点下容器间的通信

默认配置下,不同节点间的容器、docker0网桥,是不知道彼此的,没有任何关联,想要跨主机容器通信,就需要在多主机间在建立一个公共网桥,所有节点的容器都往这个网桥注册,才能进行通信。通过每台宿主机上有一个特殊网桥来构成这个公用网桥,这个技术被称为overlay network(覆盖网络)。

常见的解决方案是Flannel、Calico等。

Docker的网络模型

  1. bridge模式(默认)

    Docker进程启动时,会在主机上创建一个名为docker0的虚拟网桥,此主机上启动的Docker容器会分配一个Network Namespace,通过eth0-veth虚拟设备连接到宿主机的docker0网桥上。

  2. host模式

    此模式下容器不会获得独立的Network Namespace,和宿主机共用一个Network Namespace,容器也不会虚拟自己的网卡和ip,而是用宿主机的ip和端口。

  3. container模式

    指定新创建的容器和一个已存在的容器共享一个Network Namespace,新创建的容器不会创建自己的网卡和IP,而是和指定的容器共享IP、端口范围

  4. none模式

    Docker容器拥有自己的Network Namespace,但并不为Docker容器进行任何网络配置,需要手动添加。

安全

进程的权限分为两类,特权用户进程(进程有效用户ID是0,root用户的进程)、非特权用户进程(进程有效用户ID非0,非root用户的进程),特权用户可以执行Linux系统上所有操作,而非特权用户执行这些操作会被内核限制。

在kernel2.2开始,Linux把特权用户的特权做了划分,每个被划分出来的单元称为capability,比如运行iptables命令,对应的进程需要有CAP_NET_ADMIN这个capability。非root用户启动的进程默认没有任何capabilities,root用户则包含了所有。子父进程的capabilities有继承属性。查看进程拥有的capability命令:cat /proc/[pid]/status | grep Cap

对于privileged容器,实际上就是拥有了所有capability,允许执行所有特权操作,比如docker容器启动时增加参数 --privileged。容器缺省启动时,是root用户,但只允许了15个capabilities。

一般只给容器所需操作的最小capabilities,而不是直接给privileged,因为privileged的权限太大,容器可以轻易获取宿主机上的所有资源,比如直接访问磁盘设备,修改宿主机上的文件。

为了保证容器运行不对宿主机造成安全影响,可以在容器中指定用户。

在docker中,启动容器命令后面加-u [uid]/[gid],或者在写Dockerfile时指定(这样启动容器就不用加-u),容器里的用户与宿主机上的用户共享,即容器上的uid实际上是宿主机上的uid(root用户也是,只是容器里capabilities只有15个,而宿主机上的root有全部),这样会产生限制,因为在Linux上每个用户的资源是有限的,比如打开文件数目、最大进程数等,如果有多个容器共享同一个uid,就会互相影响。

为了解决容器uid共享问题,可以使用User Namespace。

User Namespace本质上是将容器中的uid/gid与宿主机上的uid/gid建立映射,同时也支持嵌套映射。比如划分宿主机上的uid的范围1000-1999,对应容器uid的0到999,容器里uid也可以继续嵌套映射。

使用User Namespace,可以把容器中root用户映射称宿主机上的普通用户,解决uid冲突共享问题,目前Kebernetes还不支持User Namespace(当前时间20210920,不过github上有pr了)

runC 与 OCI

  • OCI:Open Container Initiatives,围绕容器格式和运行时制定一个开放的工业化标准,包含容器运行时标准 (runtime spec)和 容器镜像标准(image spec)。

    容器镜像标准 image spec:包含了文件系统、config文件、manifest文件、index文件;

    容器运行时标准 runtime spec:包含容器运行状态以及需要提供的命令;

  • runC:一个轻量工具,基于libcontainer库,由golang语言实现,不需要docker引擎,它根据 OCI 标准来创建和运行容器的,即runC是OCI运行时标准的一个实现,不包含镜像管理功能。

    runC成为容器运行时实现的标准,而containerd是Docker出的一个中间层,为runC提供接口,供上层Docker Daemon调用。

  • OCI bundle :包括容器的文件系统和一个 config.json 文件。有了容器的根文件系统后就可以通过 runc spec 命令来生成 config.json 文件,config.json 文件用于说明如何运行容器,包括要运行的命令、权限、环境变量等等内容

OCI 定义的容器状态:

  • creating:使用 create 命令创建容器,这个过程称为创建中。
  • created:容器已经创建出来,但是还没有运行,表示镜像文件和配置没有错误,容器能够在当前平台上运行。
  • running:容器里面的进程处于运行状态,正在执行用户设定的任务。
  • stopped:容器运行完成,或者运行出错,或者 stop 命令之后,容器处于暂停状态。这个状态,容器还有很多信息保存在平台中,并没有完全被删除。
  • paused:暂停容器中的所有进程,可以使用 resume 命令恢复这些进程的执行。

Docker

进程

  • dockerd :Docker Engine守护进程,直接面向操作用户。dockerd 启动时会启动 containerd 子进程,他们之前通过RPC进行通信。
  • containerd :dockerd和runc之间的一个中间交流组件。他与 dockerd 的解耦是为了让Docker变得更为的中立,而支持OCI 的标准 。
  • containerd-shim :用来真正运行的容器的,每启动一个容器都会起一个新的shim进程, 它主要通过指定的三个参数:容器id,boundle目录(containerd的对应某个容器生成的目录,一般位于:/var/run/docker/libcontainerd/containerID), 和运行命令(默认为 runc)来创建一个容器。
  • docker-proxy :用户级的代理路由。只要你用 ps -elf 这样的命令把其命令行打出来,你就可以看到其就是做端口映射的。如果不想要这个代理的话,可以在 dockerd 启动命令行参数上加上: –userland-proxy=false 这个参数。

Dockerfile

指令详解

  • RUN

后面一般接shell命令,但是会构建一层镜像

要注意RUN每执行一次指令都会在docker上新键一层,如果层数太多,镜像就会太过膨胀影响性能,虽然docker允许的最大层数是127层。

有多条命令可以使用&&连接

  • CMD

要注意CMD只允许有一条,如果有多条只有最后一条会生效

参考

极客时间-深入剖析k8s-张磊

极客时间-容器实战高手课

Linux资源管理之cgroups简介

Built with Hugo
Theme Stack designed by Jimmy