# 容器

\[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

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

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

**相互关系**：

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

![](https://github.com/Nixum/Java-Note/raw/master/picture/Cgroup%E7%9B%B8%E4%BA%92%E5%85%B3%E7%B3%BB.png)

`/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中的进程状态

![](https://github.com/Nixum/Java-Note/raw/master/picture/Linux%E8%BF%9B%E7%A8%8B%E7%8A%B6%E6%80%81.jpeg)

活着的进程有两种状态：

* 运行态(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

![](https://github.com/Nixum/Java-Note/raw/master/picture/Linux_CPU%E4%BD%BF%E7%94%A8%E5%88%86%E7%B1%BB.jpeg)

### 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就无法使用该方案。

![](https://github.com/Nixum/Java-Note/raw/master/picture/%E5%90%8C%E4%B8%80%E8%8A%82%E7%82%B9%E4%B8%8B%E7%9A%84%E5%AE%B9%E5%99%A8%E9%80%9A%E4%BF%A1.png)

#### 容器访问另一节点

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

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

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

![](https://github.com/Nixum/Java-Note/raw/master/picture/%E5%AE%B9%E5%99%A8%E8%AE%BF%E9%97%AE%E5%8F%A6%E4%B8%80%E8%8A%82%E7%82%B9.png)

#### 不同节点下容器间的通信

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

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

![](https://github.com/Nixum/Java-Note/raw/master/picture/%E5%AE%B9%E5%99%A8%E7%BD%91%E7%BB%9C.png)

#### 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

[指令详解](https://www.cnblogs.com/panwenbin-logs/p/8007348.html)

* RUN

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

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

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

* CMD

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

## 参考

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

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

[Linux资源管理之cgroups简介](https://tech.meituan.com/2015/03/31/cgroups.html)


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://nixum.gitbook.io/note/rong-qi.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
