首页 > 其他分享 >docker涉及到的一些原理

docker涉及到的一些原理

时间:2024-08-22 14:37:52浏览次数:4  
标签:容器 宿主机 sys 涉及 cgroup 原理 docker root cpu

本长文主要和namespace、cgroup、rootfs、unionfs和容器网络有关,仅做学习时的记录,以便之后回顾。
参考:https://www.lixueduan.com/categories/docker/page/2/

目录

深入理解 Docker 核心原理:Namespace、Cgroups 和 Rootfs

首先我们思考一个问题:容器与进程有何不同?

进程:就是程序运行起来后的计算机执行环境的总和。
即:计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。
容器:核心就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。
对于 Docker 等大多数 Linux 容器来说,Cgroups 技术是用来制造约束的主要手段,而 Namespace 技术则是用来修改进程视图的主要方法。

1.基于namespace的视图隔离

当我们通过docker run -it启动并进入一个容器之后,会发现不论是进程、网络还是文件系统,好像都被隔离了,就像这样:

[root@devops03 ~]# docker exec -it interesting_snyder  /bin/sh  
/ # ps
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh
   20 root      0:00 /bin/sh
   26 root      0:00 ps
/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
10: eth0@if11: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever
/ # ls
bin   dev   etc   home  proc  root  sys   tmp   usr   var
  • ps 命令看不到宿主机上的进程
  • ip 命令也只能看到容器内部的网卡
  • ls 命令看到的文件好像也和宿主机不一样
    这就是 Docker 核心之一,借助 Linux Namespace 技术实现了视图隔离。看起来容器和宿主机隔离开了.

在 Linux 下可以根据隔离的属性不同分为不同的 Namespace :

  • 1.PID Namespace
  • 2.Mount Namespace
  • 3.UTS Namespace
  • 4.IPC Namespace
  • 5.Network Namespace
  • 6.User Namespace

通过不同类型的 Namespace 就可以实现不同资源的隔离,比如前面通过ip a只能看到容器中的网卡信息,就是通过 Network Namespace进行了隔离。
不过 Linux Namespace 技术实际上修改了应用进程看待整个计算机“视图”,即它的“视线”被操作系统做了限制,只能“看到”某些指定的内容。
我们只需要进入到对应 namespace 就可以突破这个隔离了,演示一下:
首先启动一个 busybox,然后使用ip a查看网卡信息

docker run --rm -it busybox /bin/sh
/ # ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
14: eth0@if15: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue 
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever

容器中 ip 为 172.17.0.2
然后在新终端中通过nsenter进入到该容器 network namespace 试试:
首先通过docker inspect命令找到容器对应的 PID

[root@devops03 ~]# docker ps -a
CONTAINER ID   IMAGE     COMMAND     CREATED       STATUS       PORTS     NAMES
50e22f47ff5e   busybox   "/bin/sh"   4 hours ago   Up 4 hours             gracious_gould
[root@devops03 ~]# docker inspect -f '{{.State.Pid}}' 50e22f47ff5e
25038

然后使用nsenter --net命令进入该 PID 对应进程的 network namespace

[root@devops03 ~]# nsenter --target 25038 --net
[root@devops03 ~]# ip addr show eth0 
14: eth0@if15: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
       valid_lft forever preferred_lft forever

可以看到,此时我们执行ip a拿到的信息和在容器中执行是完全一致的。
说明 Docker 确实是使用 namespace 进行隔离的。
这里顺便提一下 Namespace 存在的问题,Namespace 最大的问题就是隔离得不彻底。

  • 首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。
    所以,也出现了像 Firecracker、gVisor、Kata 之类的沙箱容器,不使用共享内核来提升安全性。
  • 其次,在 Linux 内核中,有很多资源和对象是不能被 Namespace 化的,最典型的例子就是:时间。
    容器中修改了时间,实际修改的是宿主机的时间,会导致所有容器时间都被修改,因为是共享的。
2.基于 Cgroups 的资源限制

docker run启动容器时可以通过增加--cpus或者--memoryflag 来指定 cpu、内存限制。
就像这样:通过--cpus=0.5限制只能使用 0.5 个核心,然后执行一个 while 死循环,并查看 cpu 占用情况。

[root@devops03 ~]# docker run -d --cpus 0.5 busybox sh -c "while true; do :; done"
7b9e234dbcc0ed83690cc9cf643c8cd6f1dd8bc44770260b63fcc698c6f91ab4

查看cpu占用情况

[root@devops03 ~]# top
top - 14:21:28 up 286 days, 21:53,  2 users,  load average: 1.54, 1.20, 1.06
Tasks: 144 total,   2 running,  92 sleeping,   0 stopped,   0 zombie
%Cpu(s): 12.8 us,  0.2 sy,  0.0 ni, 86.9 id,  0.1 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem :  8143308 total,  4119264 free,  1939744 used,  2084300 buff/cache
KiB Swap:        0 total,        0 free,        0 used.  5533880 avail Mem 

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND                                                                                                              
21464 root      20   0    1320      4      0 R  50.2  0.0   0:20.68 sh   

可以看到,因为限制了 cpu 为 0.5,因此只占用了 0.5 核心,也就是 top 命令中看到的 50。
这就是 Docker 另一个核心功能,基于 Linux Cgroups 技术实现的资源限制。
Linux Cgroups 就是 Linux 内核中用来为进程设置资源限制的一个重要功能。
Linux Cgroups 的全称是 Linux Control Group。
它最主要的作用,就是限制一个进程组能够使用的资源上限,包括 CPU、内存、磁盘、网络带宽等等。
在 Linux 中,Cgroups 给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的/sys/fs/cgroup路径下,可以使用`mount -t cgroup`` 命令进行查看,大概长这样:

[root@devops03 ~]# mount -t cgroup
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpu,cpuacct)
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_cls,net_prio)
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
cgroup on /sys/fs/cgroup/rdma type cgroup (rw,nosuid,nodev,noexec,relatime,rdma)

可以看到,在/sys/fs/cgroup下面有很多诸如 cpuset、cpu、 memory 这样的子目录,也叫子系统。
即:这台机器当前可以被 Cgroups 进行限制的资源种类。
比如,对 CPU 子系统来说,我们就可以看到如下几个配置文件:

[root@devops03 ~]# ls /sys/fs/cgroup/cpu
cgroup.clone_children  cpu.cfs_period_us  cpu.rt_runtime_us  cpuacct.stat       cpuacct.usage_percpu       cpuacct.usage_sys   notify_on_release  tasks
cgroup.procs           cpu.cfs_quota_us   cpu.shares         cpuacct.usage      cpuacct.usage_percpu_sys   cpuacct.usage_user  release_agent      user.slice
cgroup.sane_behavior   cpu.rt_period_us   cpu.stat           cpuacct.usage_all  cpuacct.usage_percpu_user  docker              system.slice

这些配置文件定义了如何对 CPU 进行限制,以及需要对哪些进程进行限制。
那么配置文件又如何使用呢?下面我们来演示一下具体使用。

例子:限制CPU使用

你需要在对应的子系统下面创建一个目录,比如,我们现在进入/sys/fs/cgroup/cpu目录下创建一个名为 container 的目录:

[root@devops03 ~]# cd /sys/fs/cgroup/cpu
[root@devops03 /sys/fs/cgroup/cpu]# mkdir container

这个目录就是一个“控制组”。
你会发现,操作系统会在你新创建的 container 目录下,自动生成该子系统对应的资源限制文件。

[root@devops03 /sys/fs/cgroup/cpu/container]# ls
cgroup.clone_children  cpu.cfs_period_us  cpu.rt_period_us   cpu.shares  cpuacct.stat   cpuacct.usage_all     cpuacct.usage_percpu_sys   cpuacct.usage_sys   notify_on_release
cgroup.procs           cpu.cfs_quota_us   cpu.rt_runtime_us  cpu.stat    cpuacct.usage  cpuacct.usage_percpu  cpuacct.usage_percpu_user  cpuacct.usage_user  tasks

接下来我们就通过修改配置文件对 CPU 进行限制,这里就用前面创建的 container 这个“控制组”。

主要通过以下三个文件来实现

  • cpu.cfs_quota_us:每个控制周期内,进程可以使用的 cpu 时间,默认为 -1,即不做限制。
  • cpu.cfs_period_us:控制周期,默认为 100 ms
  • tasks:记录被限制进程的 PID 列表
    cgroups 会限制所有在 tasks 中的进程,在 cpu.cfs_period_us 周期内,最多只能使用 cpu.cfs_quota_us 的 cpu 资源。
    比如,100ms 能限制只能使用 20ms,即最多占用 0.2 核心
    首先,我们在后台执行这样一条脚本:
[root@devops03 /sys/fs/cgroup/cpu]# while : ; do : ; done &
[1] 7320

显然,它执行了一个死循环,可以把计算机的 CPU 吃到 100%。根据它的输出,我们可以看到这个脚本在后台运行的进程号(PID)是 7320。
执行 Top 查看一下 CPU 占用,可以看到这个 7320 进程占用了差不多 100% 的 CPU,把一个核心占满了。

top - 16:57:07 up 287 days, 29 min,  2 users,  load average: 1.99, 1.75, 1.65
Tasks: 140 total,   4 running,  91 sleeping,   0 stopped,   0 zombie
%Cpu(s): 37.7 us,  0.2 sy,  0.0 ni, 62.0 id,  0.1 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem :  8143308 total,  4102144 free,  1943080 used,  2098084 buff/cache
KiB Swap:        0 total,        0 free,        0 used.  5530364 avail Mem 

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND                                                                                                              
 7320 root      20   0   15348   5460   1408 R  99.7  0.1   1:14.43 bash 

此时,我们就可以通过配置 cgroups 来实现对该进程的 CPU 使用情况进行限制。
默认情况下 container 控制组里的 CPU quota 还没有任何限制(即:-1),CPU period 则是默认的 100 ms(100000 us),因此上述进程可以占用整个 CPU。

[root@devops03 /sys/fs/cgroup/cpu]# cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us 
-1
[root@devops03 /sys/fs/cgroup/cpu]# cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us 
100000

接下来,我们可以通过修改这配置文件来设置 CPU 限制。比如,向 container 组里的 cfs_quota 文件写入 20 ms(20000 us)来做限制。

[root@devops03 /sys/fs/cgroup/cpu]# echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us 
[root@devops03 /sys/fs/cgroup/cpu]# cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us             
20000

最后则是将进程 PID 写入 tasks 文件里,是配置生效。

[root@devops03 /sys/fs/cgroup/cpu]# echo 7320 > /sys/fs/cgroup/cpu/container/tasks 
[root@devops03 /sys/fs/cgroup/cpu]# cat /sys/fs/cgroup/cpu/container/tasks 
7320

然后查看是否生效:

[root@devops03 ~]# top
top - 17:13:50 up 287 days, 45 min,  2 users,  load average: 1.33, 2.20, 2.16
Tasks: 142 total,   2 running,  91 sleeping,   0 stopped,   0 zombie
%Cpu(s):  5.4 us,  0.2 sy,  0.0 ni, 94.3 id,  0.1 wa,  0.0 hi,  0.1 si,  0.0 st
KiB Mem :  8143308 total,  4111668 free,  1932516 used,  2099124 buff/cache
KiB Swap:        0 total,        0 free,        0 used.  5540916 avail Mem 

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND                                                                                                              
 7320 root      20   0   15348   5460   1408 R  19.6  0.1  16:47.62 bash 

可以看到,果然 7320 CPU 被限制到了 20%。

除 CPU 子系统外,Cgroups 的每一个子系统都有其独有的资源限制能力,比如:

  • blkio,为块设备设定I/O 限制,一般用于磁盘等设备;
  • cpuset,为进程分配单独的 CPU 核和对应的内存节点;
  • memory,为进程设定内存使用的限制。
    Linux Cgroups 的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合。
    而对于 Docker 等 Linux 容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的 PID 填写到对应控制组的 tasks 文件中就可以了。
    而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行docker run时的参数指定了,比如这样一条命令:
docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash

在启动这个容器后,我们可以通过查看 Cgroups 文件系统下,CPU 子系统中,“docker”这个控制组里的资源限制文件的内容来确认:

[root@devops03 /sys/fs/cgroup/cpu]# cat /sys/fs/cgroup/cpu/docker/7a3877479689cb6d370404b26e8a63e98b8c522816f77b9c436bfaeb5b907c00/cpu.cfs_period_us 
100000
[root@devops03 /sys/fs/cgroup/cpu]# cat /sys/fs/cgroup/cpu/docker/7a3877479689cb6d370404b26e8a63e98b8c522816f77b9c436bfaeb5b907c00/cpu.cfs_quota_us  
20000
3.容器镜像的秘密

这部分主要解释以下三个问题

  • 1.为什么在容器中修改了文件宿主机不受影响?
  • 2.容器中的文件系统是哪儿来的?
  • 3.docker 镜像又是怎么实现的?
    这也是 Docker 的第三个核心功能:容器镜像(rootfs),将运行环境打包成镜像,从而避免环境问题导致应用无法运行。

1.文件系统
容器中的文件系统是什么样子的?
因为容器中的文件系统经过 Mount Namespace 隔离,所以应该是独立的。
其中 Mount Namespace 修改的,是容器进程对文件系统“挂载点”的认知。只有在“挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。
不难想到,我们可以在容器进程启动之前重新挂载它的整个根目录“/”。而由于 Mount Namespace 的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。
Linux 中 chroot 命令(change root file system)就能很方便的完成上述工作。
而 Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操作系统里的第一个 Namespace。
至此,第一个问题 为什么在容器中修改了文件宿主机不受影响?有答案了,因为使用 Mount Namespace 隔离了。

2.rootfs
上文提到 Mount Namespace 会修改容器进程对文件系统挂载点的认知,而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。
rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。

所以说,rootfs 只包括了操作系统的“躯壳”,并没有包括操作系统的“灵魂”。实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核。
这也是容器相比于虚拟机的主要缺陷之一:毕竟后者不仅有模拟出来的硬件机器充当沙盒,而且每个沙盒里还运行着一个完整的 Guest OS 给应用随便折腾。
不过,正是由于 rootfs 的存在,容器才有了一个被反复宣传至今的重要特性:一致性。由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。
第二个问题:容器中的文件系统是哪儿来的?实际上是我们构建镜像的时候打包进去的,然后容器启动时挂载到了根目录下。

3.镜像层(Layer)
Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。
通过引入层(layer)的概念,实现了 rootfs 的复用。不必每次都重新创建一个 rootfs,而是基于某一层进行修改即可。
Docker 镜像层用到了一种叫做联合文件系统(Union File System)的能力。Union File System 也叫 UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。
例如将目录 A 和目录 B 挂载到目录 C 下面,这样目录 C 下就包含目录 A 和目录 B 的所有文件。
由于看不到目录 A 和 目标 B 的存在,因此就好像 C 目录就包含这么多文件一样
Docker 镜像分为多个层,然后使用 UFS 将这多个层挂载到一个目录下面,这样这个目录就包含了完整的文件了。
UnionFS 在不同系统有各自的实现,所以 Docker 的不同发行版使用的也不一样,可以通过 docker info 查看。常见有 aufs(ubuntu 常用)、overlay2(centos 常用)
就像下图这样:union mount 在最上层,提供了统一的视图,用户看起来好像整个系统只有一层一样,实际上下面包含了很多层。

镜像只包含了静态文件,但是容器会产生实时数据,所以容器的 rootfs 在镜像的基础上增加了可读写层和 Init 层。
即容器 rootfs 包括:只读层(镜像rootfs)+ init 层(容器启动时初始化修改的部分数据) + 可读写层(容器中产生的实时数据)。
只读层(镜像rootfs)
它是这个容器的 rootfs 最下面的几层,即镜像中的所有层的总和,它们的挂载方式都是只读的(ro+wh,即 readonly+whiteout)

可读写层(容器中产生的实时数据)
它是这个容器的 rootfs 最上面的一层,它的挂载方式为:rw,即 read write。在没有写入文件之前,这个目录是空的。
而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中,删除操作实现比较特殊(类似于标记删除)。
AUFS 的 whiteout 的实现是通过在上层的可写的目录下建立对应的 whiteout 隐藏文件来实现的。
为了实现删除操作,aufs(UnionFS 的一种实现) 会在可读写层创建一个 whiteout 文件,把只读层里的文件“遮挡”起来。
比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件“遮挡”起来,“消失”了。

init 层(容器启动时初始化修改的部分数据)
它是一个以“-init”结尾的层,夹在只读层和读写层之间,Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。

为什么需要 init 层?
比如 hostname 这样的数据,原本是属于镜像层的一部分,要修改的话只能在可读写层进行修改,但是又不想在 docker commit 的时候把这些信息提交上去,所以使用 init 层来保存这些修改。
可以理解为提交代码的时候一般也不会把各种配置信息一起提交上去。
docker commit 只会提交 只读层和可读写层。

最后一个问题:docker 镜像又是怎么实现的?通过引入 layer 概念进行分层,借助 联合文件系统(Union File System)进行叠加,最终构成了完整的镜像。
这里只是镜像的主要内容,具体怎么把这些内容打包成 image 格式就是 OCI 规范了

4.小结
至此,我们大致清楚了 Docker 容器的实现主要使用了如下 3 个功能:

  • 1.Linux Namespace 的隔离能力
  • 2.Linux Cgroups 的限制能力
  • 3.基于 rootfs 的文件系统

容器网络实现分析

1.概述

一个 Linux 容器能看见的“网络栈”,实际上是被隔离在它自己的 Network Namespace 当中的。
而所谓“网络栈”,就包括了:网卡(Network Interface)、回环设备(Loopback Device)、路由表(Routing Table)和 iptables 规则。对于一个进程来说,这些要素,其实就构成了它发起和响应网络请求的基本环境。

需要指出的是,作为一个容器,它可以声明直接使用宿主机的网络栈(–net=host),即:不开启 Network Namespace,比如:

docker run –d –net=host --name nginx-host nginx

在这种情况下,这个容器启动后,直接监听的就是宿主机的 80 端口。
像这样直接使用宿主机网络栈的方式,虽然可以为容器提供良好的网络性能,但也会不可避免地引入共享网络资源的问题,比如端口冲突。
所以,在大多数情况下,我们都希望容器进程能使用自己 Network Namespace 里的网络栈,即:拥有属于自己的 IP 地址和端口。
这时候,一个显而易见的问题就是:这个被隔离的容器进程,该如何跟其他 Network Namespace 里的容器进程进行交互呢?

2.网桥

我们可以把每一个容器看做一台主机,它们都有一套独立的“网络栈”。
如果你想要实现两台主机之间的通信,最直接的办法,就是把它们用一根网线连接起来;
而如果你想要实现多台主机之间的通信,那就需要用网线,把它们连接在一台交换机上。
在 Linux 中,能够起到虚拟交换机作用的网络设备,是网桥(Bridge)。它是一个工作在数据链路层(Data Link)的设备,主要功能是根据 MAC 地址学习来将数据包转发到网桥的不同端口(Port)上。
此处的端口指的是交换机的物理接口。
而为了实现上述目的,Docker 项目会默认在宿主机上创建一个名叫 docker0 的网桥,凡是连接在 docker0 网桥上的容器,就可以通过它来进行通信。
可是,我们又该如何把这些容器“连接”到 docker0 网桥上呢?
这时候,我们就需要使用一种名叫Veth Pair的虚拟设备了。
Veth Pair 设备的特点是:它被创建出来后,总是以两张虚拟网卡(Veth Peer)的形式成对出现的。并且,从其中一个“网卡”发出的数据包,可以直接出现在与它对应的另一张“网卡”上,哪怕这两个“网卡”在不同的 Network Namespace 里。
这就使得 Veth Pair 常常被用作连接不同 Network Namespace 的“网线”

3.例子

比如,现在我们启动了一个叫作 nginx-1 的容器:

docker run -d --name nginx-1 nginx

然后进入到这个容器中查看一下它的网络设备:

# 在宿主机上
[root@devops03 ~]# docker exec -it nginx-1 /bin/bash
# 在容器里
# 默认没有ifconfig命令,此处需要手动安装工具包net-toolsapt-get update && apt-get install net-tools -y
root@2f1d11080e0d:/# ifconfig 
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.17.0.2  netmask 255.255.0.0  broadcast 172.17.255.255
        ether 02:42:ac:11:00:02  txqueuelen 0  (Ethernet)
        RX packets 4163  bytes 9761361 (9.3 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 3594  bytes 249992 (244.1 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

# 路由规则
root@2f1d11080e0d:/# route
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
default         172.17.0.1      0.0.0.0         UG    0      0        0 eth0
172.17.0.0      0.0.0.0         255.255.0.0     U     0      0        0 eth0

可以看到,这个容器里有一张叫作 eth0 的网卡,它正是一个 Veth Pair 设备在容器里的这一端。
同时通过 route 命令查看 nginx-1 容器的路由表,我们可以看到,这个 eth0 网卡是这个容器里的默认路由设备;所有对 172.17.0.0/16 网段的请求,也会被交给 eth0 来处理(第二条 172.17.0.0 路由规则)。

而这个 Veth Pair 设备的另一端,则在宿主机上。你可以通过查看宿主机的网络设备看到它,如下所示:

[root@devops03 ~]# ifconfig 
docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.17.0.1  netmask 255.255.0.0  broadcast 172.17.255.255
        inet6 fe80::42:4ff:fe47:1f80  prefixlen 64  scopeid 0x20<link>
        ether 02:42:04:47:1f:80  txqueuelen 0  (Ethernet)
        RX packets 3598  bytes 199870 (195.1 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 4170  bytes 9762370 (9.3 MiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

vethf2f2053: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet6 fe80::f47a:14ff:fe74:8b56  prefixlen 64  scopeid 0x20<link>
        ether f6:7a:14:74:8b:56  txqueuelen 0  (Ethernet)
        RX packets 3598  bytes 250242 (244.3 KiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 4169  bytes 9761820 (9.3 MiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

# 需要安装bridge-utils工具包yum install bridge-utils -y
[root@devops03 ~]# brctl show
bridge name     bridge id               STP enabled     interfaces
docker0         8000.024204471f80       no              vethf2f2053

通过 ifconfig 命令的输出,你可以看到,nginx-1 容器对应的 Veth Pair 设备,在宿主机上是一张虚拟网卡。它的名字叫作 vethf2f2053。并且,通过 brctl show 的输出,你可以看到这张网卡被“插”在了 docker0 上。
这时候,如果我们再在这台宿主机上启动另一个 Docker 容器,比如 nginx-2:

[root@devops03 ~]# docker run -d --name nginx-2 nginx
[root@devops03 ~]# brctl show
bridge name     bridge id               STP enabled     interfaces
docker0         8000.024204471f80       no              veth80c838e
                                                        vethf2f2053

你就会发现一个新的、名叫 vethf2f2053 的虚拟网卡,也被“插”在了 docker0 网桥上。
这时候,如果你在 nginx-1 容器里 ping 一下 nginx-2 容器的 IP 地址(172.17.0.3),就会发现同一宿主机上的两个容器默认就是相互连通的。
具体IP信息可以通过这个 docker network inspect bridge 命令查看

# 进入容器 nginx-1
docker exec -it nginx-1 /bin/bash
# 安装 ping 工具
# apt install iputils-ping
root@2f1d11080e0d:/# ping 172.17.0.3
PING 172.17.0.3 (172.17.0.3) 56(84) bytes of data.
64 bytes from 172.17.0.3: icmp_seq=1 ttl=64 time=0.597 ms
64 bytes from 172.17.0.3: icmp_seq=2 ttl=64 time=0.708 ms
64 bytes from 172.17.0.3: icmp_seq=3 ttl=64 time=1.19 ms
4.原理

这其中的原理其实非常简单。
当你在 nginx-1 容器里访问 nginx-2 容器的 IP 地址(比如 ping 172.17.0.3)的时候,这个目的 IP 地址会匹配到 nginx-1 容器里的第二条路由规则。
而这条路由规则的网关(Gateway)是 0.0.0.0,这就意味着这是一条直连规则,即:凡是匹配到这条规则的 IP 包,应该经过本机的 eth0 网卡,通过二层网络直接发往目的主机。
而要通过二层网络到达 nginx-2 容器,就需要有 172.17.0.3 这个 IP 地址对应的 MAC 地址。所以 nginx-1 容器的网络协议栈,就需要通过 eth0 网卡发送一个 ARP 广播,来通过 IP 地址查找对应的 MAC 地址。
我们前面提到过,这个 eth0 网卡,是一个 Veth Pair,它的一端在这个 nginx-1 容器的 Network Namespace 里,而另一端则位于宿主机上(Host Namespace),并且被“插”在了宿主机的 docker0 网桥上。
一旦一张虚拟网卡被“插”在网桥上,它就会变成该网桥的“从设备”。从设备会被“剥夺”调用网络协议栈处理数据包的资格,从而“降级”成为网桥上的一个端口。而这个端口唯一的作用,就是接收流入的数据包,然后把这些数据包的“生杀大权”(比如转发或者丢弃),全部交给对应的网桥。
所以,在收到这些 ARP 请求之后,docker0 网桥就会扮演二层交换机的角色,把 ARP 广播转发到其他被“插”在 docker0 上的虚拟网卡上。这样,同样连接在 docker0 上的 nginx-2 容器的网络协议栈就会收到这个 ARP 请求,从而将 172.17.0.3 所对应的 MAC 地址回复给 nginx-1 容器。
有了这个目的 MAC 地址,nginx-1 容器的 eth0 网卡就可以将数据包发出去。
而根据 Veth Pair 设备的原理,这个数据包会立刻出现在宿主机上的 vethf2f2053 虚拟网卡上。不过,此时这个 vethf2f2053 网卡的网络协议栈的资格已经被“剥夺”,所以这个数据包就直接流入到了 docker0 网桥里。
docker0 处理转发的过程,则继续扮演二层交换机的角色。此时,docker0 网桥根据数据包的目的 MAC 地址(也就是 nginx-2 容器的 MAC 地址),在它的 CAM 表(即交换机通过 MAC 地址学习维护的端口和 MAC 地址的对应表)里查到对应的端口(Port)为:veth80c838e,然后把数据包发往这个端口。
而这个端口,正是 nginx-2 容器“插”在 docker0 网桥上的另一块虚拟网卡,当然,它也是一个 Veth Pair 设备。这样,数据包就进入到了 nginx-2 容器的 Network Namespace 里。所以,nginx-2 容器看到的情况是,它自己的 eth0 网卡上出现了流入的数据包。
这样,nginx-2 的网络协议栈就会对请求进行处理,最后将响应(Pong)返回到 nginx-1。以上,就是同一个宿主机上的不同容器通过 docker0 网桥进行通信的流程了。

熟悉了 docker0 网桥的工作方式,你就可以理解,在默认情况下,被限制在 Network Namespace 里的容器进程,实际上是通过 Veth Pair 设备 + 宿主机网桥的方式,实现了跟同其他容器的数据交换。
与之类似地,当你在一台宿主机上,访问该宿主机上的容器的 IP 地址时,这个请求的数据包,也是先根据路由规则到达 docker0 网桥,然后被转发到对应的 Veth Pair 设备,最后出现在容器里。这个过程的示意图,如下所示:

同样地,当一个容器试图连接到另外一个宿主机时,比如:ping 10.168.0.3,它发出的请求数据包,首先经过 docker0 网桥出现在宿主机上。然后根据宿主机的路由表里的直连路由规则(10.168.0.0/24 via eth0)),对 10.168.0.3 的访问请求就会交给宿主机的 eth0 处理。所以接下来,这个数据包就会经宿主机的 eth0 网卡转发到宿主机网络上,最终到达 10.168.0.3 对应的宿主机上。当然,这个过程的实现要求这两台宿主机本身是连通的。这个过程的示意图,如下所示:

所以:当你遇到容器连不通“外网”的时候,你都应该先试试 docker0 网桥能不能 ping 通,然后查看一下跟 docker0 和 Veth Pair 设备相关的 iptables 规则是不是有异常,往往就能够找到问题的答案了。

5.跨主通信

如果在另外一台宿主机(比如:10.168.0.3)上,也有一个 Docker 容器。那么,我们的 nginx-1 容器又该如何访问它呢?这个问题,其实就是容器的跨主通信问题。
在 Docker 的默认配置下,一台宿主机上的 docker0 网桥,和其他宿主机上的 docker0 网桥,没有任何关联,它们互相之间也没办法连通。所以,连接在这些网桥上的容器,自然也没办法进行通信了。如果我们通过软件的方式,创建一个整个集群“公用”的网桥,然后把集群里的所有容器都连接到这个网桥上,不就可以相互通信了吗?

可以看到,构建这种容器网络的核心在于:我们需要在已有的宿主机网络上,再通过软件构建一个覆盖在已有宿主机网络之上的、可以把所有容器连通在一起的虚拟网络。所以,这种技术就被称为:Overlay Network(覆盖网络)。我们只需要让宿主机收到网络包后能转发到正确的节点,节点收到发给自己的网络包后能转发给正确的 Container 就行了。

6.小结

本文介绍了在本地环境下,单机容器网络的实现原理和 docker0 网桥的作用。这里的关键在于,容器要想跟外界进行通信,它发出的 IP 包就必须从它的 Network Namespace 里出来,来到宿主机上。而解决这个问题的方法就是:为容器创建一个一端在容器里充当默认网卡、另一端在宿主机上的 Veth Pair 设备。从容器A中的Veth Pair 设备传递到宿主机的docker0网桥,然后再次通过Veth Pair设备传入容器B。

标签:容器,宿主机,sys,涉及,cgroup,原理,docker,root,cpu
From: https://www.cnblogs.com/even160941/p/18360719

相关文章

  • 【花雕动手做】腿机构十一种:盘点机器人行走背后的连杆机械原理
    机器人概念已经红红火火好多年了,目前确实有不少公司已经研制出了性能非常优越的机器人产品,我们比较熟悉的可能就是之前波士顿动力的“大狗”和会空翻的机器人了,还有国产宇树科技的机器狗等,这些机器人动作那么敏捷,背后到底隐藏了什么高科技呢,控制技术太过复杂,一般不太容易了......
  • Java线程池实现原理及在美团业务中的实践
    Java线程池实现原理及在美团业务中的实践随着计算机行业的飞速发展,摩尔定律逐渐失效,多核CPU成为主流。使用多线程并行计算逐渐成为开发人员提升服务器性能的基本武器。J.U.C提供的线程池:ThreadPoolExecutor类,帮助开发人员管理线程并方便地执行并行任务。了解并合理使用线程池,是一......
  • 智商测试原理探微:心理学、统计学与测试科学的交融
    简介智力测试就是对智力的科学测试,它主要测验一个人的思维能力、学习能力和适应环境的能力。现代心理学界对智力有不同的看法。所谓智力就是指人类学习和适应环境的能力。智力包括观察能力、记忆能力、想象能力、思维能力等等。智商测试的原理心理学理论:智商测试的设计和......
  • LLM | 一文带你揭秘大语言模型ChatGPT的原理
    本文包含大量AI干货预计阅读时间:10分钟本文学习目标:定义语言模型和大型语言模型(LLM)。介绍关键的LLM概念,包括TransFormer和自注意力机制。介绍LLM提示工程、微调和Rag,以及当今热门的大语言模型应用。前言在当今的科技时代,大型语言模型(LLM)正以惊人的速度发展并......
  • 深入理解 Vue 2 的双向绑定原理与实现
    在Vue2中,双向绑定是Vue的核心功能之一,它通过数据响应式系统使得数据的变化自动反映在视图上,同时用户在视图上做的更改也能够同步回数据模型。这种双向绑定是通过数据劫持(DataHijacking)和发布-订阅模式(Publish-SubscribePattern)实现的。以下是双向绑定原理及实现方式......
  • Docker容器迁移
    推荐方法一、docker镜像,容器等信息通常是默认存储在/var/lib/docker目录下的,而/var对应的磁盘空间一般都不是很大,需要我们将/var/lib/docker迁移到空间足够的挂载盘中去。停掉正在运行的docker服务:systemctlstopdocker将docker存储目录拷贝到要迁移的最够大目录中去,e......
  • Docker常用命令
    本篇针对在初步了解Docker基础知识之后对实操的进一步提升一、帮助启动类命令启动docker:systemctlstartdocker停止docker:systemctlstopdocker重启docker:systemctlrestartdocker查看docker状态:systemctlstatusdocker开机启动:systemctlenabledocker......
  • Synchronized重量级锁原理和实战(五)
    在JVM中,每个对象都关联这一个监视器,这里的对象包含可Object实例和Class实例.监视器是一个同步工具,相当于一个凭证,拿到这个凭证就可以进入临界区执行操作,没有拿到凭证就只能阻塞等待.重量级锁通过监视器的方式保证了任何时间内只允许一个线程通过监视器保护的临界区代码.......
  • Docker受限?试试Podman,手动搭建Ubuntu容器镜像
    Docker受限?试试Podman,手动搭建Ubuntu容器镜像最近,我打算用Docker来搭建一个开发环境,但遗憾的是,我发现DockerHub无法使用,甚至国内的镜像源也无法访问。这让我有些头疼,但好在我在寻找解决方案的过程中,发现了一个Docker的替代方案:Podman。Podman的使用方法与Docker几乎一模......
  • 字符串信息检测原理代码剖析
    想要用单片机识别一长串字符并执行对应指令,有两种办法:数组法和循环法错误的实例:if(RXDATE=='L') { if(RXDATE=='E') { if(RXDATE=='D') { if(RXDATE=='1') { LED1=0; } if(RXDATE......