容器化

namespace

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

cgroups

对指定的进程做各种计算机资源的限制

容器进程

1号进程与信号

僵尸进程

两个系统调用可以回收掉僵尸进程:

进程停止

Docker 停止一个容器,会向容器的 init 进程发送一个 SIGTERM 信号,在 init 进程退出之后,容器内的其他进程也都立刻退出,其他进程收到的是 SIGKILL 信号

当 Linux 进程收到 SIGTERM 信号并且使进程退出,这时 Linux 内核对处理进程退出的入口点就是 do_exit() 函数,do_exit() 函数中会释放进程的相关资源,比如内存,文件句柄,信号量等,做完这些工作之后,它会调用一个 exit_notify() 函数,用来通知和这个进程相关的父子进程等

为了让子进程也能收到 SIGTERM 信号,init进程可以捕获 SIGTERM 信号,然后调用kill转发给子进程

容器CPU

CPU usage

20221222181330

类型具体含义
usUser,用户态CPU时间,不包括低优先级进程的用户态时间(nice值 1-19 )
sysSystem,内核态CPU时间
niNice, nice 值1 -19的进程用户态CPU时间
idldle,系统空闲CPU时间
walowait,系统等待I/O的CPU时间,这个时间不计入进程CPU时间
hiHardware irq,处理硬中断的时间,这个时间不计入进程CPU时间
siSoftirq,处理软中断的时间,这个时间不计入进程CPU时间
stSteal,表示同一个宿主机上的其他虚拟机抢走的CPU时间

在 k8s 中,通过limit可以限制CPU的使用

CPU cgroup:

cpu.cfs_quota_us 和 cpu.cfs_period_us 这两个值决定了每个控制组中所有进程的可使用 CPU 资源的最大值,cpu.shares 这个值决定了 CPU Cgroup 子系统下控制组可用 CPU 的相对比例

每个进程的 CPU Usage 只包含用户态(us 或 ni)和内核态(sy)两部分,其他的系统 CPU 开销并不包含在进程的 CPU 使用中

CPU使用率

在 /proc/[pid]/stat 下有 utime 和 stime:

$$进程的 ticks = 时间点2(utime + stime) - 时间点1(utime + stime)$$

$$进程的 CPU 使用率 =(进程的 ticks/ 单个 CPU 总 ticks)*100.0$$

对于系统的 CPU 使用率,需要读取 /proc/stat 文件,得到瞬时各项 CPU 使用率的 ticks 值,相加得到一个总值,单项值除以总值就是各项 CPU 的使用率

也可以从每个容器的 CPU Cgroup 控制组里的 cpuacct.stat 的统计值中得到整个容器的 CPU 使用率

load average

统计了这两种情况的进程:

  1. Linux 进程调度器中可运行队列(Running Queue)一段时间(1 分钟,5 分钟,15 分钟)的进程平均数
  2. Linux 进程调度器中休眠队列(Sleeping Queue)里的一段时间的 TASK_UNINTERRUPTIBLE 状态下的进程平均数

Load Average = 可运行队列进程平均数 + 休眠队列中不可打断的进程平均数

处于 D 状态(主要集中在 disk I/O 的访问和信号量(Semaphore)锁的访问)的进程即休眠队列中不可打断的进程过多就会增加负载,这是性能下降的表现

容器内存

OOM

在 Linux 系统里如果内存不足时,就需要杀死一个正在运行的进程来释放一些内存,内核里有一个 oom_badness() 函数用来计算进程被杀死的几率:

系统总的可用页面数乘以进程的 OOM 校准值 oom_score_adj,再加上进程已经使用的物理页面数

oom_score_adj 保存在 /proc/[pid]/oom_score_adj

memeroy cgroup

RSS与PageCache

Memory Cgroup 控制组里 RSS 内存和 Page Cache 内存的和,正好是 memory.usage_in_bytes 的值

要判断容器真实的内存使用量,不能用Memory Cgroup 里的 memory.usage_in_bytes,而需要用 memory.stat 里的 rss 值

memory.usage_in_bytes = memory.stat[rss] + memory.stat[cache] + memory.kmem.usage_in_bytes

swap

值为 100 的时候系统平等回收匿名内存和 Page Cache 内存;一般缺省值为 60,就是优先回收 Page Cache;即使 swappiness 为 0,也不能完全禁止 Swap 分区的使用,就是说在内存紧张的时候,也会使用 Swap 来回收匿名内存

在 memory cgroup 中,当 memory.swappiness = 0 的时候,对匿名页的回收是始终禁止的,也就是始终都不会使用 Swap 空间

容器存储

容器文件系统

为了减少相同镜像文件在同一个节点上的数据冗余,可以节省磁盘与下载带宽开销,使用容器文件系统,UnionFS:主要功能是把多个目录(处于不同的分区)一起挂载(mount)在一个目录下

OverlayFS是UnionFS的一种实现:

2022122413485

在merged做文件操作:

  1. 新建文件,这个文件会出现在 upper/ 目录中
  2. 删除文件,如果删除upper目录里的文件,文件会直接消失,如果删除lower里,会通过在upper目录中增加了一个特殊文件来告诉 OverlayFS 文件已被删除
  3. 修改文件同删除文件,如果修改upper里的文件,就可以直接修改,如果修改lower里的,则会在upper中创建一个新的替代旧的

Docker 容器镜像文件可以分成多个层(layer),每层可以对应 OverlayFS 里 lowerdir 的一个目录,lowerdir 支持多个目录,也就可以支持多层的镜像文件

Mount Namespace 允许进程创建一个独立的文件系统视图,再使用 chroot 或 pivot_root 系统调用修改进程的根目录视图

通过结合使用 Mount Namespace 和 rootfs,容器就能够为进程构建出一个完善的文件系统隔离环境,为了让容器的根目录看起来更加真实,一般会在容器的根目录下挂载一个完整的操作系统的文件系统,挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就叫做rootfs

容量限制

在容器中对于 OverlayFS 中写入数据,其实就是往宿主机的一个目录(upperdir)里写数据

Linux 系统里的 XFS 文件系统缺省都有 Quota 的特性,可以用来限制写入文件系统的文件总量

Docker 可以通过加上一个参数 --storage-opt size=xxx 使用这个特性

blkio cgroup v1

IOPS 是 Input/Output Operations Per Second 的简称,也就是每秒钟磁盘读写的次数,吞吐量(Throughput)是指每秒钟磁盘中数据的读取量,一般以 MB/s 为单位

吞吐量 = 数据块大小 *IOPS

v1的blkio cgroup只能限制direct IO,即直接IO读写的场景,无法限制buffered IO读写经过内存缓冲的场景,Linux 里,由于考虑到性能问题,绝大多数的应用都会使用 Buffered I/O 模式

cgroup v2

v2 相比 v1 做的最大的变动就是一个进程属于一个控制组,而每个控制组里可以定义自己需要的多个子系统

打开 v2 的方法就是配置一个 kernel 参数"cgroup_no_v1=blkio,memory",这表示把 Cgroup v1 的 blkio 和 Memory 两个子系统给禁止,这样 Cgroup v2 的 io 和 Memory 这两个子系统就打开了

IO与PageCache

如果容器在做内存限制的时候,Cgroup 中 memory.limit_in_bytes 设置得比较小,而容器中的进程又有很大量的 I/O,这样申请新的 Page Cache 内存的时候,又会不断释放老的内存页面,这些操作就会带来额外的系统开销

对于控制page cache 有如下参数:

容器网络

docker 可以通过在启动容器时传递 –sysctl 参数来修改 / proc/sys/net 里面的配置参数

docker exec、kubectl exec、ip netns exec、nsenter 等命令原理相同,都是基于 setns 系统调用,切换至指定的一个或多个 namespace(s)

network namespace

资源,不同的namspace是隔离的:

操作:

容器网络配置

想让数据从容器 Netowrk Namespace 发送到 Host Network Namespace,可以用配置一对 veth 虚拟网络设备的方法实现。而让数据包从宿主机的 eth0 发送出去,就用可 bridge+nat 的方式完成

容器通常缺省使用 veth 虚拟网络接口,不过 veth 接口会有比较大的网络延时,在对外发送数据的时候,peer veth 接口都会 raise softirq 来完成一次收包操作,这样就会带来数据包处理的额外开销

如果要降低处理开销,使用ipvlan/macvlan 的网络接口,这两个直接在物理网络接口上虚拟出接口,在发送对外数据包的时候可以直接通过物理接口完成,没有节点内部类似 veth 的那种 softirq 的开销,但对于需要使用 iptables 规则的容器就不能工作了

通过 veth 接口从容器向外发送数据包,会触发 peer veth 设备去接收数据包,这个接收的过程就是一个网络的 softirq 的处理过程,在缺省的状况下(也就是没有 RPS 的情况下),enqueue_to_backlog() 把数据包放到了“当前运行的 CPU”(get_cpu())对应的数据队列中,这回增加发送数据出现乱序的几率导致 TCP 的快速重传

为了解决这个问题,如果配置网卡的RSS(Receive Side Scaling),get_cpu()就会变成get_rps_cpu(),每个硬件中断可以由一个 CPU 来处理,可以保证一个 TCP 流的数据始终在一个 RX 队列中

2022122716223

RPS(Receive Packet Steering)其实就是在软件层面实现类似的功能,通过对数据包四元素计算hash值,决定要分配到哪个CPU上,但RPS 的配置还是会带来额外的系统开销

容器安全

Linux capabilities

Linux capabilities 把 Linux root 用户原来所有的特权做了细化,可以更加细粒度地给进程赋予不同权限

对于任意一个进程,在做任意一个特权操作的时候,都需要有这个特权操作对应的 capability,普通 Linux 节点上,非 root 用户启动的进程缺省没有任何 Linux capabilities,而 root 用户启动的进程缺省包含了所有的 Linux capabilities

文件中可以设置 capabilities 参数值,并且这个值会影响到最后运行它的进程

容器 root 用户默认只赋予了 15 个 capabilities,启动时如果加上 privileged 参数则会包含所有的 Linux capabilities

容器指定用户

在启动时可以指定一个 -u 参数来指定容器的用户的uid

由于用户 uid 是整个节点中共享的,那么在容器中定义的 uid,也就是宿主机上的 uid,这样就很容易引起 uid 的冲突,每个用户下的资源是有限制的,共享的资源如果使用共享的用户很快就会消耗光这个uid下的资源

user namespace

Namespace 中的 uid/gid 的值与宿主机上的 uid/gid 值建立了一个映射关系。经过 User Namespace 的隔离,我们在 Namespace 中看到的进程的 uid/gid,就和宿主机 Namespace 中看到的 uid 和 gid 不一样

Docker 通过配置 daemon 使其创建默认的用户用于 user namespace

这样可以把容器中 root 用户(uid 0)映射成宿主机上的普通用户,同时对于用户在容器中自己定义普通用户 uid 的情况,我们只要为每个容器在节点上分配一个 uid 范围,就不会出现在宿主机上 uid 冲突的问题了

rootless container

指容器中以非 root 用户来运行进程,还指以非 root 用户来创建容器,管理容器。也就是说,启动容器的时候,Docker 或者 podman 是以非 root 用户来执行的