容器2

进程

单进程模型

容器中的1号进程对于宿主机而言就是一个普通的进程,它的父进程是runC,runC的父进程是containerd-shim。这个containerd-shim用于管理容器进程,类似于init或者systemd进程的作用(回收僵尸进程),当进程退出时,containerd-shim会通过runC重新将进程拉起。

容器的“单进程模型”意味着容器进程本身,虽然是1号进程,但是它并不具有通常意义上1号进程,如systemd或init所具有的进程管理能力,比如托管孤儿进程,回收僵尸进程等,它就是一个普通的应用进程。

当然也可以给这个1号进程赋予这种能力,如docker启动容器的时候,加上--init参数,起来的容器就强制使用 tini 作为 init 进程了。这种1号进程非应用容器,而是由专门的init进程拉起其他所有应用进程的做法,称为“富容器”(rich container)。富容器的好处是可以把容器当成虚拟机一样对待,方便和经典PaaS体系对接。

云原生提倡使用轻量级容器,因为只有当1号进程就是应用进程本身时,才能准确的向容器运行时暴露进程的实际状况,方便使用kubernetes探针,以及依赖这些探针的周边组件,如service等。

信号

缺省状态下,

  • C 语言程序里,一个信号 handler 都没有注册;
  • Golang 程序里,很多信号都注册了自己的 handler;
  • bash 程序里注册了两个 handler,bit 2 和 bit 17,也就是 SIGINTSIGCHLD

可以通过查看 /proc/$PID/status中的SigCgt 行来了解哪些信号被捕获了(注册了信号处理函数)。

虽然SIGTERM(15)的默认行为是终止进程,但是当1号进程没有为SIGTERM注册信号处理函数时,

  • 通过kubectl exec进入容器后,通过kill命令去优雅终止1号进程,是不会退出的
  • 通过宿主机kill $PID,进程也不会退出

此外,无论什么情况下,在容器中通过kill -9尝试强杀1号进程都不可能成功

具体原因是,kill 命令实际上调用了 kill() 这个系统调用,内核尝试将信号发送给1号进程之前,在 sig_task_ignored 函数中对一些特殊情况进行了过滤。

注册了信号处理函数后,1号进程又应该怎样处理 SIGTERM 呢?如果直接退出的话,1号进程会向同 Namespace 中的其他进程都发送一个 SIGKILL 信号。这会导致容器中的其他进程没有优雅退出。

所以 tini 的实现方式是:把除了 SIGCHILD 以外的其他所有信号都转发给它的子进程;自己则负责通过 waitpid 来回收子进程资源,避免僵尸进程的产生。

僵尸进程本质上是一个空的task_struct,它所拥有的资源(内存、文件句柄、信号量等)都已经被内核回收了,唯一消耗的资源是pid。

进程实际退出前的僵尸态是有必要的,它会通过SIGCHILD信号告诉父进程自己已经死了,让父进程知道子进程的终止状态,进行相应的处理,比如异常退出重新拉起。

僵尸进程过多会导致pid被占满,无法再运行新的进程。容器的最大进程数量由/sys/fs/cgroup/pids(pid cgroup)下的 pids.max 文件限制。

CPU

使用率

kubernetes中Pod的cpu资源的requestlimit字段限制的是cpu的使用率

top命令可以查看cpu的使用率,100%表示瞬时使用了1个CPU,200%表示2个。这个时间是从怎么来的?是从proc文件系统里拿到指标计算得来的。

进程cpu使用率的具体定义是:(进程用户态和内核态在cpu调度中获得的cpu ticks/ 单个 CPU 产生的总 ticks)*100%

tick:Linux 时钟周期性地(比如1/100秒)产生中断,每次中断都会触发 Linux 内核去做一次进程调度,而这一次中断就是一个 tick。

limit意味着最大cpu的使用率能达到多少,这个值是通过cpu cgroup的cpu.cfs_quota_us(一个调度周期里这个控制组被允许的运行时间)除以 cpu.cfs_period_us(CPU调度周期)得来的;

request表示即使当整个节点cpu被完全用满时,我的cpu利用率也能达到这么多,它是通过设置cpu.shares(节点上cgroup 可用cpu的相对比例)来实现的。

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

容器资源视图隔离

相比使用虚拟机,使用容器,最大的问题在于资源视图的隔离。由于容器没有对/proc,/sys等文件系统进行隔离,因此在容器中使用free、top等命令看到的其实是物理机的数据。此外,应用程序可能会从/sys/devices/system/cpu/online中获取cpu的核数,来决定默认线程数,比如GOMAXPROCS

我们可以通过lxcfs来对容器资源视图进行隔离,让容器“表现的”更像一台虚拟机。对于go程序,还可以通过automaxprocs这个包来在容器中正确设置GOMAXPROCS值。

内存

memory.usage_in_bytes

malloc()申请的其实是虚拟内存,容器根据进程的实际物理内存使用值memory.stat[rss]是否超过了memory.limit_in_bytes,再加上memory.oom_control来判断是否进行oom。

你可以调整memory.oom_control参数,这样即使物理内存已经达到上限了,容器还是不会被cgroup干掉,可是这样的话,由于申请不到物理内存资源,进程会处于可中断睡眠状态。

cgroup当中的memory.usage_in_bytes实际上是由三部分组成:用户态物理内存(memory.stat[rss]) + 内核态内存(memory.kmem.usage_in_bytes) + page cache(memory.stat[cache]),即memory.usage_in_bytes = memory.stat[rss] + memory.stat[cache] + memory.kmem.usage_in_bytes

有时候我们发现容器的内存使用量memory.usage_in_bytes一直等于memory.limit_in_bytes,但是也不会发生OOM,是因为实际上每次以Buffered IO的方式读写磁盘时,Linux都会先将数据缓存到page cache当中来加快write/read系统调用的速度,也就是memory.stat[rss]值比较高,当进程需要物理内存时,操作系统会自动释放一部分page cache内存给rss内存使用。

swap

kubelet缺省不能在打开swap的节点上运行。配置--fail-swap-on=false,kubelet可以在swap enabled的节点上运行。

rss内存中大部分没有磁盘文件对应,这种内存称为匿名内存。swap用于在内存资源紧张时,释放部分匿名内存到磁盘的swap空间。

内核的/proc/sys/vm/swappiness参数作用是当系统存在swap空间时,是优先释放page cache还是优先释放匿名内存,即写入swap。

cgroup中的memory.swappiness和全局的/proc/sys/vm/swappiness作用差不多。唯一区别是设置memory.swappiness为0,可以让这个cgroup控制组里的内存禁止使用swap。

存储

容器文件系统

容器文件系统UnionFS,从原理上说,就是多个目录联合挂载到一个目录下,读/写这个目录就相当于读/写了对应目录中的内容。常用的有:aufs(没有合到linux kernel主干)、devicemapper和overlayFS。

以OverlayFS为例, OverlayFS有两层,分别是 lowerdir 和 upperdir。lowerdir 里是容器镜像中的文件,对于容器来说是只读的;upperdir 存放的是容器对文件系统里的所有改动,它是可读写的。lower和upper联合挂载到merged。

blkio cgroup

磁盘io的两个主要性能指标:

  • iops:每秒钟磁盘进行IO的次数
  • 吞吐量(带宽):以MB/s为单位,一次IO读写的数据块越大,吞吐量越大。即吞吐量 = IOPS * 数据块大小

cgroup v1的限制:每一个子系统都是独立的,对于某进程,只能独立的在各个cgroup子系统中限制它的资源使用。这样的问题在于,对于buffered I/O,它是先把数据写入page cache,再从page cache刷到磁盘;由于blkio和memory两个子系统相互独立,对于buffered I/O就无法限速了。

Cgroup v2的变化:一个进程属于一个控制组,在这个控制组里多个子系统可以协同运行。对某个进程,在控制组里同时限制memory + blkio就能对Buffered I/O 作磁盘读写的限速

网络

容器 Network Namespace 的网络参数并不是完全从宿主机 Host Namespace 里继承的,也不是完全在新的 Network Namespace 建立的时候重新初始化的。在内核函数 tcp_sk_init() 里,可以看到 tcp_keepalive 的三个参数都是重新初始化的,而 tcp_congestion_control 的值则是从 Host Namespace 里复制过来的。

在 Linux 中,管理员可以通过 sysctl 接口修改内核运行时的参数。在 /proc/sys/ 虚拟文件系统下存放许多内核参数。这些参数涉及了多个内核子系统,比如内核子系统(通常前缀为: kernel.)、网络子系统(通常前缀为: net.)等。通过 sysctl -a 可以获取所有内核参数列表。

在kubernetes中,如果对内核网络参数有特殊需求,可以通过 设置Pod的sysctl参数,或者在给init container赋予特权,并通过 sysctl 修改内核网络参数。

安全

capability

k8s没有对user namespace进行隔离,所以我们在容器里运行的是root用户。但是由于缺省启动容器时,系统只为1号进程开启了 15个capabilities。而通过kubectl exec -- sh进入到容器里,启动的sh进程(所有命令的父进程)和容器的1号进程的 capabilities 相同。

我们可以通过配置容器的 SecurityContext 里的capabilities,或者配置容器为privileged

user namespace

尽管容器中 root 用户的 Linux capabilities 已经减少了很多,但是在没有 User Namespace 的情况下,容器中 root 用户和宿主机上的 root 用户的 uid 是完全相同的,没有隔离。一旦有软件的漏洞,容器中的 root 用户就可以操控整个宿主机。

为了减少安全风险,业界都是建议在容器中以非 root 用户来运行进程。不过在kubernetes目前还不支持 User Namespace 的情况下,在容器中使用非 root 用户的话,对 uid 的管理和分配就比较麻烦了。