Docker核心技术与实现原理

  在前文Docker基础入门总结中咱们已经学习了Docker相关的基础概念以及经常使用命令等,下面咱们开始深刻学习Docker的核心技术以及实现原理。html

  Docker 的出现必定是由于目前的后端在开发和运维阶段确实须要一种虚拟化技术解决开发环境和生产环境环境一致的问题,经过 Docker 咱们能够将程序运行的环境也归入到版本控制中,排除由于环境形成不一样运行结果的可能。可是上述需求虽然推进了虚拟化技术的产生,可是若是没有合适的底层技术支撑,那么咱们仍然得不到一个完美的产品。本文会介绍几种 Docker 使用的核心技术,若是咱们了解它们的使用方法和原理,就能清楚 Docker 的实现原理。python

         

1、Namespace

  命名空间(namespaces)是 Linux 为咱们提供的用于分离进程树、网络接口、挂载点以及进程间通讯等资源的方法。在平常使用 Linux 或者 macOS 时,咱们并无运行多个彻底分离的服务器的须要,可是若是咱们在服务器上启动了多个服务,这些服务其实会相互影响的,每个服务都能看到其余服务的进程,也能够访问宿主机器上的任意文件,这是不少时候咱们都不肯意看到的,咱们更但愿运行在同一台机器上的不一样服务能作到彻底隔离,就像运行在多台不一样的机器上同样。linux

         

  在这种状况下,一旦服务器上的某一个服务被入侵,那么入侵者就可以访问当前机器上的全部服务和文件,这也是咱们不想看到的,而 Docker 其实就经过 Linux 的 Namespaces 对不一样的容器实现了隔离。git

   Linux 的命名空间机制提供了如下七种不一样的命名空间,包括 CLONENEWCGROUP、CLONENEWIPC、CLONENEWNET、CLONENEWNS、CLONENEWPID、CLONENEWUSER 和 CLONE_NEWUTS,经过这七个选项咱们能在建立新的进程时设置新进程应该在哪些资源上与宿主机器进行隔离。github

2、进程

  进程是 Linux 以及如今操做系统中很是重要的概念,它表示一个正在执行的程序,也是在现代分时系统中的一个任务单元。在每个 *nix 的操做系统上,咱们都可以经过 ps 命令打印出当前操做系统中正在执行的进程,好比在 Ubuntu 上,使用该命令就能获得如下的结果:redis

$ ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 Apr08 ?        00:00:09 /sbin/init
root         2     0  0 Apr08 ?        00:00:00 [kthreadd]
root         3     2  0 Apr08 ?        00:00:05 [ksoftirqd/0]
root         5     2  0 Apr08 ?        00:00:00 [kworker/0:0H]
root         7     2  0 Apr08 ?        00:07:10 [rcu_sched]
root        39     2  0 Apr08 ?        00:00:00 [migration/0]
root        40     2  0 Apr08 ?        00:01:54 [watchdog/0]
...

  当前机器上有不少的进程正在执行,在上述进程中有两个很是特殊,一个是 pid 为 1 的 /sbin/init 进程,另外一个是 pid 为 2 的 kthreadd 进程,这两个进程都是被 Linux 中的上帝进程 idle 建立出来的,其中前者负责执行内核的一部分初始化工做和系统配置,也会建立一些相似 getty 的注册进程,然后者负责管理和调度其余的内核进程。docker

         

  若是咱们在当前的 Linux 操做系统下运行一个新的 Docker 容器,并经过 exec 进入其内部的 bash 并打印其中的所有进程,咱们会获得如下的结果:编程

root@iZ255w13cy6Z:~# docker run -it -d ubuntu
b809a2eb3630e64c581561b08ac46154878ff1c61c6519848b4a29d412215e79
root@iZ255w13cy6Z:~# docker exec -it b809a2eb3630 /bin/bash
root@b809a2eb3630:/# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 15:42 pts/0    00:00:00 /bin/bash
root         9     0  0 15:42 pts/1    00:00:00 /bin/bash
root        17     9  0 15:43 pts/1    00:00:00 ps -ef

  在新的容器内部执行 ps 命令打印出了很是干净的进程列表,只有包含当前 ps -ef 在内的三个进程,在宿主机器上的几十个进程都已经消失不见了。ubuntu

  当前的 Docker 容器成功将容器内的进程与宿主机器中的进程隔离,若是咱们在宿主机器上打印当前的所有进程时,会获得下面三条与 Docker 相关的结果:后端

UID        PID  PPID  C STIME TTY          TIME CMD
root     29407     1  0 Nov16 ?        00:08:38 /usr/bin/dockerd --raw-logs
root      1554 29407  0 Nov19 ?        00:03:28 docker-containerd -l unix:///var/run/docker/libcontainerd/docker-containerd.sock --metrics-interval=0 --start-timeout 2m --state-dir /var/run/docker/libcontainerd/containerd --shim docker-containerd-shim --runtime docker-runc
root      5006  1554  0 08:38 ?        00:00:00 docker-containerd-shim b809a2eb3630e64c581561b08ac46154878ff1c61c6519848b4a29d412215e79 /var/run/docker/libcontainerd/b809a2eb3630e64c581561b08ac46154878ff1c61c6519848b4a29d412215e79 docker-runc

  在当前的宿主机器上,可能就存在由上述的不一样进程构成的进程树:

       

  这就是在使用 clone(2) 建立新进程时传入 CLONE_NEWPID 实现的,也就是使用 Linux 的命名空间实现进程的隔离,Docker 容器内部的任意进程都对宿主机器的进程一无所知。

containerRouter.postContainersStart
└── daemon.ContainerStart
    └── daemon.createSpec
        └── setNamespaces
            └── setNamespace

  Docker 的容器就是使用上述技术实现与宿主机器的进程隔离,当咱们每次运行 docker run 或者 docker start 时,都会在下面的方法中建立一个用于设置进程间隔离的 Spec:

func (daemon *Daemon) createSpec(c *container.Container) (*specs.Spec, error) {
    s := oci.DefaultSpec()
    // ...
    if err := setNamespaces(daemon, &s, c); err != nil {
        return nil, fmt.Errorf("linux spec namespaces: %v", err)
    }
    return &s, nil
}

  在 setNamespaces 方法中不只会设置进程相关的命名空间,还会设置与用户、网络、IPC 以及 UTS 相关的命名空间:

func setNamespaces(daemon *Daemon, s *specs.Spec, c *container.Container) error {
    // user
    // network
    // ipc
    // uts
    // pid
    if c.HostConfig.PidMode.IsContainer() {
        ns := specs.LinuxNamespace{Type: "pid"}
        pc, err := daemon.getPidContainer(c)
        if err != nil {
            return err
        }
        ns.Path = fmt.Sprintf("/proc/%d/ns/pid", pc.State.GetPID())
        setNamespace(s, ns)
    } else if c.HostConfig.PidMode.IsHost() {
        oci.RemoveNamespace(s, specs.LinuxNamespaceType("pid"))
    } else {
        ns := specs.LinuxNamespace{Type: "pid"}
        setNamespace(s, ns)
    }
    return nil
}

  全部命名空间相关的设置 Spec 最后都会做为 Create 函数的入参在建立新的容器时进行设置:

daemon.containerd.Create(context.Background(), container.ID, spec, createOptions)

  全部与命名空间的相关的设置都是在上述的两个函数中完成的,Docker 经过命名空间成功完成了与宿主机进程和网络的隔离。

3、网络

  若是 Docker 的容器经过 Linux 的命名空间完成了与宿主机进程的网络隔离,可是却有没有办法经过宿主机的网络与整个互联网相连,就会产生不少限制,因此 Docker 虽然能够经过命名空间建立一个隔离的网络环境,可是 Docker 中的服务仍然须要与外界相连才能发挥做用。每个使用 docker run 启动的容器其实都具备单独的网络命名空间,Docker 为咱们提供了四种不一样的网络模式,Host、Container、None 和 Bridge 模式。

      

  在这一部分,咱们将介绍 Docker 默认的网络设置模式:网桥模式。在这种模式下,除了分配隔离的网络命名空间以外,Docker 还会为全部的容器设置 IP 地址。当 Docker 服务器在主机上启动以后会建立新的虚拟网桥 docker0,随后在该主机上启动的所有服务在默认状况下都与该网桥相连。

      

  在默认状况下,每个容器在建立时都会建立一对虚拟网卡,两个虚拟网卡组成了数据的通道,其中一个会放在建立的容器中,会加入到名为 docker0 网桥中。咱们可使用以下的命令来查看当前网桥的接口:

$ brctl show
bridge name    bridge id       STP enabled interfaces
docker0        8000.0242a6654980   no      veth3e84d4f
                                        veth9953b75

  docker0 会为每个容器分配一个新的 IP 地址并将 docker0 的 IP 地址设置为默认的网关。网桥 docker0 经过 iptables 中的配置与宿主机器上的网卡相连,全部符合条件的请求都会经过 iptables 转发到 docker0 并由网桥分发给对应的机器。

$ iptables -t nat -L
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination
DOCKER     all  --  anywhere             anywhere             ADDRTYPE match dst-type LOCAL
Chain DOCKER (2 references)
target     prot opt source               destination
RETURN     all  --  anywhere             anywhere

  咱们在当前的机器上使用 docker run -d -p 6379:6379 redis 命令启动了一个新的 Redis 容器,在这以后咱们再查看当前 iptables 的 NAT 配置就会看到在 DOCKER 的链中出现了一条新的规则:

DNAT       tcp  --  anywhere             anywhere             tcp dpt:6379 to:192.168.0.4:6379 

  上述规则会将从任意源发送到当前机器 6379 端口的 TCP 包转发到 192.168.0.4:6379 所在的地址上。

   这个地址其实也是 Docker 为 Redis 服务分配的 IP 地址,若是咱们在当前机器上直接 ping 这个 IP 地址就会发现它是能够访问到的:

$ ping 192.168.0.4
PING 192.168.0.4 (192.168.0.4) 56(84) bytes of data.
64 bytes from 192.168.0.4: icmp_seq=1 ttl=64 time=0.069 ms
64 bytes from 192.168.0.4: icmp_seq=2 ttl=64 time=0.043 ms
^C
--- 192.168.0.4 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 999ms
rtt min/avg/max/mdev = 0.043/0.056/0.069/0.013 ms

  从上述的一系列现象,咱们就能够推测出 Docker 是如何将容器的内部的端口暴露出来并对数据包进行转发的了;当有 Docker 的容器须要将服务暴露给宿主机器,就会为容器分配一个 IP 地址,同时向 iptables 中追加一条新的规则。

      

  当咱们使用 redis-cli 在宿主机器的命令行中访问 127.0.0.1:6379 的地址时,通过 iptables 的 NAT PREROUTING 将 ip 地址定向到了 192.168.0.4,重定向过的数据包就能够经过 iptables 中的 FILTER 配置,最终在 NAT POSTROUTING 阶段将 ip 地址假装成 127.0.0.1,到这里虽然从外面看起来咱们请求的是 127.0.0.1:6379,可是实际上请求的已是 Docker 容器暴露出的端口了。

$ redis-cli -h 127.0.0.1 -p 6379 ping
PONG

  Docker 经过 Linux 的命名空间实现了网络的隔离,又经过 iptables 进行数据包转发,让 Docker 容器可以优雅地为宿主机器或者其余容器提供服务。

4、Libnetwork

  整个网络部分的功能都是经过 Docker 拆分出来的 libnetwork 实现的,它提供了一个链接不一样容器的实现,同时也可以为应用给出一个可以提供一致的编程接口和网络层抽象的容器网络模型。libnetwork 中最重要的概念,容器网络模型由如下的几个主要组件组成,分别是 Sandbox、Endpoint 和 Network:

      

  在容器网络模型中,每个容器内部都包含一个 Sandbox,其中存储着当前容器的网络栈配置,包括容器的接口、路由表和 DNS 设置,Linux 使用网络命名空间实现这个 Sandbox,每个 Sandbox 中均可能会有一个或多个 Endpoint,在 Linux 上就是一个虚拟的网卡 veth,Sandbox 经过 Endpoint 加入到对应的网络中,这里的网络可能就是咱们在上面提到的 Linux 网桥或者 VLAN。

5、挂载点  

  虽然咱们已经经过 Linux 的命名空间解决了进程和网络隔离的问题,在 Docker 进程中咱们已经没有办法访问宿主机器上的其余进程而且限制了网络的访问,可是 Docker 容器中的进程仍然可以访问或者修改宿主机器上的其余目录,这是咱们不但愿看到的。

  在新的进程中建立隔离的挂载点命名空间须要在 clone 函数中传入 CLONE_NEWNS,这样子进程就能获得父进程挂载点的拷贝,若是不传入这个参数子进程对文件系统的读写都会同步回父进程以及整个主机的文件系统。

  若是一个容器须要启动,那么它必定须要提供一个根文件系统(rootfs),容器须要使用这个文件系统来建立一个新的进程,全部二进制的执行都必须在这个根文件系统中。

       

  想要正常启动一个容器就须要在 rootfs 中挂载以上的几个特定的目录,除了上述的几个目录须要挂载以外咱们还须要创建一些符号连接保证系统 IO 不会出现问题。

      

  为了保证当前的容器进程没有办法访问宿主机器上其余目录,咱们在这里还须要经过 libcotainer 提供的 pivor_root 或者 chroot 函数改变进程可以访问个文件目录的根节点。

// pivor_root
put_old = mkdir(...);
pivot_root(rootfs, put_old);
chdir("/");
unmount(put_old, MS_DETACH);
rmdir(put_old);
// chroot
mount(rootfs, "/", NULL, MS_MOVE, NULL);
chroot(".");
chdir("/");

  到这里咱们就将容器须要的目录挂载到了容器中,同时也禁止当前的容器进程访问宿主机器上的其余目录,保证了不一样文件系统的隔离。

6、Chroot

  在这里不得不简单介绍一下 chroot(change root),在 Linux 系统中,系统默认的目录就都是以 / 也就是根目录开头的,chroot 的使用可以改变当前的系统根目录结构,经过改变当前系统的根目录,咱们可以限制用户的权利,在新的根目录下并不可以访问旧系统根目录的结构个文件,也就创建了一个与原系统彻底隔离的目录结构。

7、CGroups

  咱们经过 Linux 的命名空间为新建立的进程隔离了文件系统、网络并与宿主机器之间的进程相互隔离,可是命名空间并不可以为咱们提供物理资源上的隔离,好比 CPU 或者内存,若是在同一台机器上运行了多个对彼此以及宿主机器一无所知的『容器』,这些容器却共同占用了宿主机器的物理资源。

      

  若是其中的某一个容器正在执行 CPU 密集型的任务,那么就会影响其余容器中任务的性能与执行效率,致使多个容器相互影响而且抢占资源。如何对多个容器的资源使用进行限制就成了解决进程虚拟资源隔离以后的主要问题,而 Control Groups(简称 CGroups)就是可以隔离宿主机器上的物理资源,例如 CPU、内存、磁盘 I/O 和网络带宽。每个 CGroup 都是一组被相同的标准和参数限制的进程,不一样的 CGroup 之间是有层级关系的,也就是说它们之间能够从父类继承一些用于限制资源使用的标准和参数。

      

  Linux 的 CGroup 可以为一组进程分配资源,也就是咱们在上面提到的 CPU、内存、网络带宽等资源,经过对资源的分配,CGroup 可以提供如下的几种功能:

      

  在 CGroup 中,全部的任务就是一个系统的一个进程,而 CGroup 就是一组按照某种标准划分的进程,在 CGroup 这种机制中,全部的资源控制都是以 CGroup 做为单位实现的,每个进程均可以随时加入一个 CGroup 也能够随时退出一个 CGroup。

  Linux 使用文件系统来实现 CGroup,咱们能够直接使用下面的命令查看当前的 CGroup 中有哪些子系统:

$ lssubsys -m
cpuset /sys/fs/cgroup/cpuset
cpu /sys/fs/cgroup/cpu
cpuacct /sys/fs/cgroup/cpuacct
memory /sys/fs/cgroup/memory
devices /sys/fs/cgroup/devices
freezer /sys/fs/cgroup/freezer
blkio /sys/fs/cgroup/blkio
perf_event /sys/fs/cgroup/perf_event
hugetlb /sys/fs/cgroup/hugetlb 

  大多数 Linux 的发行版都有着很是类似的子系统,而之因此将上面的 cpuset、cpu 等东西称做子系统,是由于它们可以为对应的控制组分配资源并限制资源的使用。

  若是咱们想要建立一个新的 cgroup 只须要在想要分配或者限制资源的子系统下面建立一个新的文件夹,而后这个文件夹下就会自动出现不少的内容,若是你在 Linux 上安装了 Docker,你就会发现全部子系统的目录下都有一个名为 Docker 的文件夹:

$ ls cpu
cgroup.clone_children  
...
cpu.stat  
docker  
notify_on_release 
release_agent 
tasks
$ ls cpu/docker/
9c3057f1291b53fd54a3d12023d2644efe6a7db6ddf330436ae73ac92d401cf1 
cgroup.clone_children  
...
cpu.stat  
notify_on_release 
release_agent 
tasks

  9c3057xxx 其实就是咱们运行的一个 Docker 容器,启动这个容器时,Docker 会为这个容器建立一个与容器标识符相同的 CGroup,在当前的主机上 CGroup 就会有如下的层级关系:

      

  每个 CGroup 下面都有一个 tasks 文件,其中存储着属于当前控制组的全部进程的 pid,做为负责 cpu 的子系统,cpu.cfsquotaus 文件中的内容可以对 CPU 的使用做出限制,若是当前文件的内容为 50000,那么当前控制组中的所有进程的 CPU 占用率不能超过 50%。

  若是系统管理员想要控制 Docker 某个容器的资源使用率就能够在 Docker 这个父控制组下面找到对应的子控制组而且改变它们对应文件的内容,固然咱们也能够直接在程序运行时就使用参数,让 Docker 进程去改变相应文件中的内容。

$ docker run -it -d --cpu-quota=50000 busybox
53861305258ecdd7f5d2a3240af694aec9adb91cd4c7e210b757f71153cdd274
$ cd 53861305258ecdd7f5d2a3240af694aec9adb91cd4c7e210b757f71153cdd274/
$ ls
cgroup.clone_children  cgroup.event_control  cgroup.procs  cpu.cfs_period_us  cpu.cfs_quota_us  cpu.shares  cpu.stat  notify_on_release  tasks
$ cat cpu.cfs_quota_us
50000

  当咱们使用 Docker 关闭掉正在运行的容器时,Docker 的子控制组对应的文件夹也会被 Docker 进程移除,Docker 在使用 CGroup 时其实也只是作了一些建立文件夹改变文件内容的文件操做,不过 CGroup 的使用也确实解决了咱们限制子容器资源占用的问题,系统管理员可以为多个容器合理的分配资源而且不会出现多个容器互相抢占资源的问题。

8、UnionFS与AUFS

  Linux 的命名空间和控制组分别解决了不一样资源隔离的问题,前者解决了进程、网络以及文件系统的隔离,后者实现了 CPU、内存等资源的隔离,可是在 Docker 中还有另外一个很是重要的问题须要解决 - 也就是镜像。

  镜像究竟是什么,它又是如何组成和组织的是做者使用 Docker 以来的一段时间内一直比较让做者感到困惑的问题,咱们可使用 docker run 很是轻松地从远程下载 Docker 的镜像并在本地运行。

  Docker 镜像其实本质就是一个压缩包,咱们可使用下面的命令将一个 Docker 镜像中的文件导出:

$ docker export $(docker create busybox) | tar -C rootfs -xvf -
$ ls
bin  dev  etc  home proc root sys  tmp  usr  var

  你能够看到这个 busybox 镜像中的目录结构与 Linux 操做系统的根目录中的内容并无太多的区别,能够说 Docker 镜像就是一个文件。

  UnionFS 实际上是一种为 Linux 操做系统设计的用于把多个文件系统『联合』到同一个挂载点的文件系统服务。而 AUFS 即 Advanced UnionFS 其实就是 UnionFS 的升级版,它可以提供更优秀的性能和效率。AUFS 做为联合文件系统,它可以将不一样文件夹中的层联合(Union)到了同一个文件夹中,这些文件夹在 AUFS 中称做分支,整个『联合』的过程被称为联合挂载(Union Mount):

      

  每个镜像层或者容器层都是 /var/lib/docker/ 目录下的一个子文件夹;在 Docker 中,全部镜像层和容器层的内容都存储在 /var/lib/docker/aufs/diff/ 目录中:

$ ls /var/lib/docker/aufs/diff/00adcccc1a55a36a610a6ebb3e07cc35577f2f5a3b671be3dbc0e74db9ca691c       93604f232a831b22aeb372d5b11af8c8779feb96590a6dc36a80140e38e764d8
00adcccc1a55a36a610a6ebb3e07cc35577f2f5a3b671be3dbc0e74db9ca691c-init  93604f232a831b22aeb372d5b11af8c8779feb96590a6dc36a80140e38e764d8-init
019a8283e2ff6fca8d0a07884c78b41662979f848190f0658813bb6a9a464a90       93b06191602b7934fafc984fbacae02911b579769d0debd89cf2a032e7f35cfa
...

  而 /var/lib/docker/aufs/layers/ 中存储着镜像层的元数据,每个文件都保存着镜像层的元数据,最后的 /var/lib/docker/aufs/mnt/ 包含镜像或者容器层的挂载点,最终会被 Docker 经过联合的方式进行组装。

        

  上面的这张图片很是好的展现了组装的过程,每个镜像层都是创建在另外一个镜像层之上的,同时全部的镜像层都是只读的,只有每一个容器最顶层的容器层才能够被用户直接读写,全部的容器都创建在一些底层服务(Kernel)上,包括命名空间、控制组、rootfs 等等,这种容器的组装方式提供了很是大的灵活性,只读的镜像层经过共享也可以减小磁盘的占用。

9、存储驱动

  Docker 使用了一系列不一样的存储驱动管理镜像内的文件系统并运行容器,这些存储驱动与 Docker 卷(volume)有些不一样,存储引擎管理着可以在多个容器之间共享的存储。想要理解 Docker 使用的存储驱动,咱们首先须要理解 Docker 是如何构建而且存储镜像的,也须要明白 Docker 的镜像是如何被每个容器所使用的;Docker 中的每个镜像都是由一系列只读的层组成的,Dockerfile 中的每个命令都会在已有的只读层上建立一个新的层:

FROM ubuntu:15.04
COPY . /app
RUN make /app
CMD python /app/app.py

容器中的每一层都只对当前容器进行了很是小的修改,上述的 Dockerfile 文件会构建一个拥有四层 layer 的镜像:

       

当镜像被 docker run 命令建立时就会在镜像的最上层添加一个可写的层,也就是容器层,全部对于运行时容器的修改其实都是对这个容器读写层的修改。容器和镜像的区别就在于,全部的镜像都是只读的,而每个容器其实等于镜像加上一个可读写的层,也就是同一个镜像能够对应多个容器。

      

  AUFS 只是 Docker 使用的存储驱动的一种,除了 AUFS 以外,Docker 还支持了不一样的存储驱动,包括 aufs、devicemapper、overlay二、zfs 和 vfs 等等,在最新的 Docker 中,overlay2 取代了 aufs 成为了推荐的存储驱动,可是在没有 overlay2 驱动的机器上仍然会使用 aufs 做为 Docker 的默认驱动。

      

  不一样的存储驱动在存储镜像和容器文件时也有着彻底不一样的实现,有兴趣的读者能够在 Docker 的官方文档 Select a storage driver 中找到相应的内容。想要查看当前系统的 Docker 上使用了哪一种存储驱动只须要使用如下的命令就能获得相对应的信息:

$ docker info | grep Storage
Storage Driver: aufs

10、总结  

  Docker 目前已经成为了很是主流的技术,已经在不少成熟公司的生产环境中使用,可是 Docker 的核心技术其实已经有不少年的历史了,Linux Namespace、Cgroups和 UnionFS 三大技术支撑了目前 Docker 的实现,也是 Docker 可以出现的最重要缘由。

 

参考文献:

  1. https://github.com/docker/libnetwork/blob/master/docs/design.md

  2. https://github.com/opencontainers/runc/blob/master/libcontainer/SPEC.md

  3. https://forums.docker.com/t/does-the-docker-engine-use-chroot/25429

  4. https://www.quora.com/Do-Docker-containers-use-a-chroot-environment

  5. https://www.ibm.com/developerworks/cn/linux/l-cn-chroot/index.html

  6. https://www.ibm.com/developerworks/cn/linux/1506_cgroup/index.htm

  7. https://www.cnblogs.com/bakari/p/8560437.html

  8. https://www.cnblogs.com/bakari/p/8971602.html

  9. https://linux.cn/article-6975-1.html

 

                              --- END ---