产品战略专家梁宁确认出席AICon北京站,分享AI时代下的商业逻辑与产品需求 了解详情
写点什么

Docker 镜像及其相关技术的原理和本质

  • 2020-05-14
  • 本文字数:8862 字

    阅读完需:约 29 分钟

Docker镜像及其相关技术的原理和本质

作为云计算的当红明星,Docker 来势汹汹,成为了很多 IT 人员现时的必备技能。但是对于新手而言,在理解 Docker 命令时常常存在一些问题,尤其是在 Docker 镜像底层的工作原理和容器与容器镜像的关系上。


一般情况下只有真正理解了某门技术的原理才能真正掌握这一门技术,然后才能去深入地使用这门技术。在本文中,我们会由浅入深出地带大家了解下 Docker 镜像及其相关技术的原理和本质。

容器 VS.容器镜像

在说 Docker 镜像的原理知识之前,我们先看下 docker 容器和 docker 镜像的区别,因为这部分我们后面会涉及到。


简单说来,我们可以将 Docker 镜像看成是 Docker 容器的静态时,也可将 Docker 容器看成是 Docker 镜像的运行时。


从 Docker 的官方文档来看,Docker 容器的定义和 Docker 镜像的定义几乎是相同,Docker 容器和 Docker 镜像的区别主要在于 docker 容器多出了一个可写层。



容器中的进程就运行在这个可写层,这个可写层有两个状态,即运行态和退出态。当我们 docker run 运行容器后,docker 容器就进入了运行态,当我们停止正在运行中的容器时,docker 容器就进入了退出态。


我们将容器从运行态转为退出态时,期间发生的变更都会写入到容器的文件系统中(需要注意的是,此处不是写入到了 docker 镜像中),这方面的变化下文中我们还会再细说。

Docker 存储简介

简单说来 Docker 镜像就是一组只读的目录,或者叫只读的 Docker 容器模板,镜像中含有一个 Docker 容器运行所需要的文件系统,所以我们说 Docker 镜像是启动一个 Docker 容器的基础。


如果这样不是很好理解,我们可以通过一个图一起看下:



从图中可以看出除了最上面的一层为读写层之外,下面的其他的层都是只读的镜像层,并且除了最下面的一层外,其他的层都有会有一个指针指向自己下面的一层镜像。


虽然统一文件系统(union file system)技术将不同的镜像层整合成一个统一的文件系统,为构成一个完整容器镜像的层提供了一个统一的视角,隐藏了多个层的复杂性,对用户来说只存在一个文件系统,但图中的这些层并不是不能看到的,如果需要查看的话可以进入运行 Docker 的机器上进行查看,从这些层中可以看到 Docker 内部实现的一些细节,接下来我们一起看下。


一般刚接触 Docker 不久的话可能会不太清楚 Docker 的存储方式是怎样的,并且可能也不太清楚 Docker 容器的镜像到底存储在什么路径下,比较迷茫。


有些同学想了解下 Docker 的镜像数据存储在什么位置,然后谷歌了下几篇博文,说是在/var/lib/docker 下有个 aufs 目录,结果在自己机器上进到这个路径后发现没有 aufs 相关的目录,然后以为是版本的问题,其实不然。


以 Linux 服务器为例,其实 Docker 的容器镜像和容器本身的数据都存放在服务器的 /var/lib/docker/ 这个路径下。不过不同的 linux 发行版存储方式上有差别,比如,在 ubuntu 发行版上存储方式为 AUFS,CentOS 发行版上的存储方式为 device mapper。


/var/lib/docker 路径下的信息在不同的阶段会有变化,从笔者个人经验来看,了解这几个阶段中新增的数据以及容器与镜像的存储结构的变化非常有利于我们对 Docker 容器以及 Docker 镜像的理解。


在下文中,我们将一起看下 Docker 运行后、下载镜像后、运行容器后三个阶段中 Docker 存储的变化。


环境信息


系统发行版:CentOS7.2。


内核版本:3.10.0-327.36.1.el7.x86_64


Docker 版本:1.8


启动 Docker 后


在此我们假设大家已经安装好了 Docker 环境,具体安装的过程不再赘述。


# 启动Docker 服务[root@influxdb ~]# systemctl start docker
# 查看/var/lib/docker路径下的文件结构
[root@localhost docker]# tree .├── containers├── devicemapper│ ├── devicemapper│ │ ├── data│ │ └── metadata│ └── metadata│ ├── base│ ├── deviceset-metadata│ └── transaction-metadata├── graph├── linkgraph.db├── repositories-devicemapper├── tmp├── trust└── volumes
8 directories, 7 files
复制代码


注:必须启动 Docker 服务后查看,如果没有启动 Docker 进程则路径/var/lib/docker 不存在。


前文中我们已经提到过,CentOS 发行版中 Docker 服务使用的存储方式为 devicemapper,所以我们从前面 tree 命令的结果中可以看到出现了目录 devicemapper。


有些同学可能会问什么是 devicemapper?


太阳底下无新鲜事,devicemapper 并不是伴随着 Docker 才出现的,早在 linux2.6 版本的内核时其实就已经引入了 devicemapper,且当时是作为一个很重要的技术出现的。


简单说来 devicemapper 就是 Docker 服务的一个存储驱动,或者叫 Docker 服务的存储后端。Devicemapper 其实是一个基于内核的框架,这个框架中对 linux 上很多的功能进行了增强,比如对 linux 上高级卷管理功能的增强。


Devicemapper 存储驱动将我们的每个 docker 镜像和 docker 容器都存在在自己的虚拟设备中,devicemapper 的这些设备相当于我们常见的一般的写时复制快照设备的超配版本。通过上面的介绍,有些同学可能以为 devicemapper 存储驱动是工作在块级别的,但是 devicempper 实际是工作在文件级别的,也就是说 devicemapper 存储驱动和写时复制操作都是直接操作块,而不是对文件进行操作。


以上是我们关于 Docker 服务的 devicemapper 存储驱动的一个简单的介绍,Docker 官方文档中提供了很多的有关 Docker 存储驱动的介绍,感兴趣的同学可以自行查阅。


进一步的查看的话,可以看到路径/var/lib/docker/devicemapper 下面有两个目录,分别为 devicemapper 和 data,其中目录 devicemapper 下存在两个名为 data 和 metadata 的文件,两个文件从名称即可看出一个是镜像数据的存储池,一个为镜像相关的元数据。接下去我们会逐个看下这个路径下的文件。


进入上面提到的目录 metadata 下,可以看到这个目录中已经存在三个文件,分别为:base、deviceset-metadata 和 transction-metadata,这三个文分别用来存放上文中我们提到的元数据的 id、大小和 uuid 等信息。


[root@localhost metadata]# pwd/var/lib/docker/devicemapper/metadata[root@localhost metadata]# lsbase deviceset-metadata transaction-metadata
复制代码


除了上面提到的几个目录,上文中 tree 的结果中还有几个目录,分别为:containers、devicemapper、graph、linkgraph.db、repositories-devicemapper、tmp、trust 和 volumes。这几个文件对于 docker 的镜像存储来说都很重要,我们以文件 repositories-devicemapper 为例,看下这个文件对于镜像存储所起到的作用。


[root@localhost docker]# pwd/var/lib/docker[root@localhost docker]# lscontainers devicemapper graph linkgraph.db repositories-devicemapper tmp trust volumes
复制代码


我们可以先看下 repositories-devicemapper 这个文件中在当前的阶段中有什么:


root@localhost docker]# cat repositories-devicemapper{"Repositories":{}}[root@localhost docker]#
复制代码


从上面 cat 的结果中我们不难看出,文件 repositories-devicemapper 中其实记录的就是 docker 镜像的属性信息,比如镜像名称、镜像标签、镜像的 ID 等信息,如果镜像刚好没有标签的话默认会以 lastet 作为标签。


以上是对 Docker 服务运行后 pull 镜像之前/var/lib/docker 路径下数据的一个简单的解读,相信大家通过上面的描述已经对 docker 镜像有了一些更深入的认识。下面我们看下在我们 pull 自己的第一个 docker 镜像之后路径/var/lib/docker 之下的数据会发生怎样的变化。


Pull 镜像后


在此我们以一个 nginx 镜像为例一起看下这个阶段的变化。


# pull 示例镜像  [root@localhost docker]# docker pull nginx  Using default tag: latest  latest: Pulling from library/nginx  22f3bf58cd09: Pull complete  ea2fc476f5f0: Pull complete  81d728438afe: Pull complete  303a6dec1190: Pull complete  d43816b44a22: Pull complete  dc02db50a25a: Pull complete  6f650a34b308: Pull complete  a634e96a9de9: Pull complete  72f3ebe1e4d7: Pull complete  c2c9e418b22c: Pull complete  Digest: sha256:a82bbaf63c445ee9b854d182254c62e34e6fa92f63d7b4fdf6cea7e76665e06e  Status: Downloaded newer image for nginx:latest
# 查看镜像是否已经在本地
[root@localhost docker]# docker images REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE nginx latest c2c9e418b22c 2 weeks ago 109.3 MB [root@localhost docker]#
复制代码


在此我们没有指定 nginx 镜像的 tag,因此默认拉去了最新的版本。然后我们看下路径/var/lib/docker 下是否有变化:


[root@localhost docker]# tree ..├── containers├── devicemapper│   ├── devicemapper│   │   ├── data│   │   └── metadata……省略若干数据……│   │   ├── checksum│   │   ├── json│   │   ├── layersize│   │   ├── tar-data.json.gz│   │   └── v1Compatibility│   ├── 303a6dec11900c97f5d7555d31adec02d2e5e4eaa1a77537e7a5ebd45bb7fcd2│   │   ├── checksum│   │   ├── json│   │   ├── layersize│   │   ├── tar-data.json.gz│   │   └── v1Compatibility│   ├── 6f650a34b3083c96cf8b7babc7a391227c0f78e0d07067071c46e31bd834de3a│   │   ├── checksum│   │   ├── json│   │   ├── layersize│   │   ├── tar-data.json.gz│   │   └── v1Compatibility│   ├── 72f3ebe1e4d793a50836d4e070c94ef7497c80111d178e867014981f64696a39│   │   ├── checksum│   │   ├── json│   │   ├── layersize│   │   ├── tar-data.json.gz│   │   └── v1Compatibility│   ├── 81d728438afe98602e2e692c20299ecf41b93173fb12351c1b59820b17fb16b9│   │   ├── checksum│   │   ├── json│   │   ├── layersize│   │   ├── tar-data.json.gz│   │   └── v1Compatibility│   ├── a634e96a9de9f1e280efaecdd43c7273ac43e109a42ab6c76ab2d2261c8cdc50│   │   ├── checksum│   │   ├── json│   │   ├── layersize│   │   ├── tar-data.json.gz│   │   └── v1Compatibility│   ├──……省略若干数据……│   ├── ea2fc476f5f055f9e44963987903ecfe0cb480b7e387d8b5cb64006832110afc│   │   ├── checksum│   │   ├── json│   │   ├── layersize│   │   ├── tar-data.json.gz│   │   └── v1Compatibility│   └── _tmp├── linkgraph.db├── repositories-devicemapper├── tmp├── trust└── volumes 30 directories, 67 files[root@localhost docker]#
复制代码


从结尾的目录数和文件数也可以看出,在我们拉取镜像后/var/lib/docker 下多出了很多的文件(拉取镜像之前,只有 8 个目录,7 个文件),下面我们一步步的分析。


如果这个时候看下路径/var/lib/docker 下的文件的话,可以很容易的看到发生变化的主要是下面这三个文件:/var/lib/docker/devicemapper/metadata、/var/lib/docker/devicemapper/mnt 以及/var/lib/docker/graph。我们先看下 metadata 这个文件:



从结果可以看出除了上一个阶段中已经有的 base、deviceset-metadata 等几个文件外,还多出了一些名字较长的文件,我们挨个看下这几个文件中的数据:


[root@localhost metadata]# cat base{"device_id":1,"size":107374182400,"transaction_id":1,"initialized":true} [root@localhost metadata]# cat 22f3bf58cd0949b57df2dc161e7026a8cc77699b6a8be7d0e3085e252a5439c3{"device_id":2,"size":107374182400,"transaction_id":2,"initialized":false}[root@localhost metadata]# cat ea2fc476f5f055f9e44963987903ecfe0cb480b7e387d8b5cb64006832110afc{"device_id":3,"size":107374182400,"transaction_id":3,"initialized":false}
[root@localhost metadata]# cat 81d728438afe98602e2e692c20299ecf41b93173fb12351c1b59820b17fb16b9{"device_id":4,"size":107374182400,"transaction_id":4,"initialized":false} [root@localhost metadata]# cat d43816b44a2280148da8d9b6ce2f357bff9b2e59ef386181f36a4a433a9aad6c{"device_id":6,"size":107374182400,"transaction_id":6,"initialized":false} [root@localhost metadata]# cat 303a6dec11900c97f5d7555d31adec02d2e5e4eaa1a77537e7a5ebd45bb7fcd2{"device_id":5,"size":107374182400,"transaction_id":5,"initialized":false} [root@localhost metadata]# cat a634e96a9de9f1e280efaecdd43c7273ac43e109a42ab6c76ab2d2261c8cdc50{"device_id":9,"size":107374182400,"transaction_id":9,"initialized":false} [root@localhost metadata]# cat dc02db50a25a87ca227492197721e97d19f1822701fe3ec73533a0811a6393a7{"device_id":7,"size":107374182400,"transaction_id":7,"initialized":false} [root@localhost metadata]# cat 6f650a34b3083c96cf8b7babc7a391227c0f78e0d07067071c46e31bd834de3a{"device_id":8,"size":107374182400,"transaction_id":8,"initialized":false} [root@localhost metadata]# cat 72f3ebe1e4d793a50836d4e070c94ef7497c80111d178e867014981f64696a39{"device_id":10,"size":107374182400,"transaction_id":10,"initialized":false} [root@localhost metadata]# cat c2c9e418b22ca5a0b02ef0c2bd02c34ad792d1fc271e5580fdb3252979ccc092{"device_id":11,"size":107374182400,"transaction_id":11,"initialized":false}
复制代码


从上面的结果可以看出上面的几个文件中的 device_id 数值是按照顺序排列下来的,换句话说就是除了上一个阶段中已经存在的 base 文件,上面结果中其他的几个文件都是 nginx 镜像的中间镜像层,也就是我们经常执行的命令 docker images –a 的结果中看到的构成当前镜像的各个镜像层。


接下来我们再看一个变化较大的文件/var/lib/docker/graph。


  [root@localhost graph]# tree ..├── 22f3bf58cd0949b57df2dc161e7026a8cc77699b6a8be7d0e3085e252a5439c3│   ├── checksum│   ├── json│   ├── layersize│   ├── tar-data.json.gz│   └── v1Compatibility├── 303a6dec11900c97f5d7555d31adec02d2e5e4eaa1a77537e7a5ebd45bb7fcd2│   ├── checksum│   ├── json│   ├── layersize│   ├── tar-data.json.gz│   └── v1Compatibility├── 6f650a34b3083c96cf8b7babc7a391227c0f78e0d07067071c46e31bd834de3a│   ├── checksum│   ├── json│   ├── layersize│   ├── tar-data.json.gz│   └── v1Compatibility├── 72f3ebe1e4d793a50836d4e070c94ef7497c80111d178e867014981f64696a39│   ├── checksum│   ├── json│   ├── layersize│   ├── tar-data.json.gz│   └── v1Compatibility├── 81d728438afe98602e2e692c20299ecf41b93173fb12351c1b59820b17fb16b9│   ├── checksum│   ├── json│   ├── layersize│   ├── tar-data.json.gz│   └── v1Compatibility├── a634e96a9de9f1e280efaecdd43c7273ac43e109a42ab6c76ab2d2261c8cdc50│   ├── checksum│   ├── json│   ├── layersize│   ├── tar-data.json.gz│   └── v1Compatibility├── c2c9e418b22ca5a0b02ef0c2bd02c34ad792d1fc271e5580fdb3252979ccc092│   ├── checksum│   ├── json│   ├── layersize│   ├── tar-data.json.gz│   └── v1Compatibility├── d43816b44a2280148da8d9b6ce2f357bff9b2e59ef386181f36a4a433a9aad6c│   ├── checksum│   ├── json│   ├── layersize│   ├── tar-data.json.gz│   └── v1Compatibility├── dc02db50a25a87ca227492197721e97d19f1822701fe3ec73533a0811a6393a7│   ├── checksum│   ├── json│   ├── layersize│   ├── tar-data.json.gz│   └── v1Compatibility├── ea2fc476f5f055f9e44963987903ecfe0cb480b7e387d8b5cb64006832110afc│   ├── checksum│   ├── json│   ├── layersize│   ├── tar-data.json.gz│   └── v1Compatibility└── _tmp
11 directories, 50 files
复制代码


从上面的结果中可以很明显的看到我们的镜像 nginx 及其每一个 nginx 镜像的中间层对应的目录下都包含如下几个文件:checksum、json、layerrize、tar-data.json.gz 和 v1Compatibility。我们任意找一个文件看下这几个文件中存放了什么数据。


(1) Checksum



其实从文件名称即可看出每个镜像层中的 checksum 文件存放的是当前镜像层的 md5 值,用于核对当前镜像层的数据是否完整。


(2) json



从 cat 的结果中可以看出 json 文件中存放的数据较多,比如 Hostname、Domainname、User、Image 等信息,而且很多参数和我们经常接触的 Dockerfile 中的参数相似。相比较前面的 checksum 文件这个文件中的内容相对较复杂,在此我们也解释下。


此处的 json 文件中一般主要用于存放镜像中涉及的动态信息,但需要注意的是此处的 json 文件并不仅仅被用于存储 docker 镜像的动态信息(很多同学可能会认为此处的 json 文件只是被用来描述 Docker 容器的动态信息的),我们在使用 Dockerfile 构建镜像时,Dockerfile 构建过程中涉及到的所有操作基本都被记录到这个 json 文件中。


说到这儿,有些读者可能会问这个 json 是在什么阶段被使用到的,好问题。通过下面这个图我想大家应该就能看明白了:



从图中我们可以看出每个镜像层的 json 文件其实是由 Docker 守护进程进行解析的。Docker 守护进程通过 json 文件可以解析出运行容器需要的各种数据,比如环境变量、workdir 以及容器启动时需要执行的 ENTRYPOINT 或者 CMD 命令等。Docker 守护进程从 json 文件中获取到这些数据后,接下来就开始进行容器进程的初始化。


(3) layersize


从文件名称即可看出,这个文件中存放的为当前镜像层的占用空间大小:



(4) repositories-devicemapper


上一阶段中我们解释过这个文件中记录的为当前镜像层的属性信息,比如镜像名称信息、镜像标签信息、镜像的 ID 信息等:



以上是对 pull 镜像之后运行容器之前镜像存储信息的简单介绍,相信大家在看下之后对 docker 容器镜像已经有了更加深入的认识。下面我们看下本文中我们要说的最后一个阶段,即运行容器后 docker 的存储又发生了哪些变化。


运行容器后


我们运行下前面从 dockerhub pull 的镜像 nginx:latest:


[root@localhost metadata]# docker imagesREPOSITORY          TAG                 IMAGE ID            CREATED             VIRTUAL SIZEnginx               latest              c2c9e418b22c        2 weeks ago         109.3 MB[root@localhost metadata]#[root@localhost metadata]#[root@localhost metadata]#[root@localhost metadata]# docker run --name nginx -d nginx:latest814ec80839669e235c94978ed3d07eab0e2b2bebd7d7a64fd6488cddca51be41[root@localhost metadata]# docker psCONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS               NAMES814ec8083966        nginx:latest        "nginx -g 'daemon off"   3 seconds ago       Up 2 seconds        80/tcp              nginx
复制代码


按照惯例,然后我们看下/var/lib/docker 路径下的文件结构:



和上一阶段不同,这个阶段发生变化的文件主要是:/var/lib/docker/devicemapper/metadata、/var/lib/docker/devicemapper/mnt 以及/var/lib/docker/container,下面我们逐个看下。


(1) metadata


我们看下 metadata 这个目录下的文件:



从图中的结果可以看出,相比上一个阶段,当前阶段中 metadata 目录下多出了两个文件,即以 51be4e 和 51b44e-init 结尾的两个文件。


我们都知道 docker 借助容器镜像运行起容器之后,会在当前镜像的最顶层添加一个特殊的层,和其他的层相比这个层不但有可读的权限还有可写的权限。说到这,相信多出的两个文件的功能就不难理解了。


(2) mnt


在查看 mnt 下的数据之前,我们先看下这个目录下的文件结构:


对比上面说过的 metadata 目录,发现这两个目录下的文件是一样的,相比前一个阶段的话也是新增了两个文件,即以 51be4e 和 51b44e-init 结尾的两个文件。


(3) container


我们先看下当前目录下的文件结构:



Container 目录为容器本身的目录,此目录中存放了诸如容器的配置文件等文件。如果我们删掉这个目录( docker 进程 hang 死导致 docker rm、docker kill 杀不掉容器时常用此种方式处理 )的话正在运行的容器就会被删掉,我们看下这几个文件都存放了什么数据。


(1) xxx.json.log、config.json


从文件名称即可看出,这两个文件存放的为当前容器的配置信息及其数据:



(2) hosts


hosts 配置信息,在此不再赘述。


(3) hostname


容器 host 名称,可以 cat 查看后再进入容器查看 hostname,核对下看是否是一样的。


(4) resolv.conf


dns 配置信息。

结语

前面分析了那么多涉及到 docker 存储的文件,在查阅各个文件或者目录作用时可能不是很方便,在此我们给大家总结了一下各个文件的作用(每个文件都是在/var/lib/docker 路径下):


(1) devicemapper/devicemapper/data:存储存储池相关的数据


(2) devicemapper/devicemapper/metdata:存储元数据


(3) devicemapper/metadata/:存储 device_id、layersize 等信息


(4) devicemapper/mnt:存储挂载相关的信息


(5) container/:存储容器本身的信息


(6) graph/:存储各个镜像层的详细信息


(7) repositores-devicemapper:存储镜像的一些基本信息


(8) tmp:存储 docker 的临时目录


(9) trust:存储 docker 的信任目录


(10) volumes:存储 docker 的卷目录


2020-05-14 22:262698

评论

发布
暂无评论
发现更多内容

Flink CDC我吃定了耶稣也留不住他!| Flink CDC线上问题小盘点

王知无

Github首次开放,一天遭狂转 50w 次!大厂内部不外传的 100 万字 Java 面试手册!

编程菌

Java 编程 程序员 面试 计算机

Android SDK 版本属性

Changing Lin

8月日更

阿里“宝妈级”之作,这份Spring Security应用到源码手册,全是精华

Java spring 程序员 架构 计算机

老外为了在MacBook上玩原神,让M1支持了所有iOS应用 | Github每周精彩分享第一期

Zhendong

GitHub

在所有Spark模块中,我愿称SparkSQL为最强!

王知无

架构实战训练营模块五作业

NewBranSTONE

#架构实战营

浏览器数据库 IndexedDB(一) 概述

编程三昧

数据库 大前端 indexedDB 8月日更

架构设计总结

鲲哥

MinIO存储服务客户端使用指南(三)

liuzhen007

8月日更

现代分布式架构设计原则-伸缩性

余先生

可伸缩 伸缩 弹性扩容

没有银弹

escray

学习 极客时间 如何落地业务建模 8月日更

架构实战营 模块三

听闻

架构课程第4次作业

听闻

消息队列架构设计

thewangzl

蚂蚁金服+拼多多+抖音+天猫Java面经合集,金九银十Java开发校招社招福音!

编程菌

Java 编程 程序员 面试 计算机

QDS05 Prometheus

耳东@Erdong

Prometheus 8月日更

select、poll、epoll之间的区别

一个大红包

8月日更

总结

杰语

让我们一起开发【菜谱系统】吧,滚雪球学 Python 第三轮项目计划

梦想橡皮擦

8月日更

193篇文章暴揍Flink,这个合集你需要关注一下

王知无

架构实战0期毕业设计---电商秒杀系统

谢博琛

5000字阐述云原生消息中间件Apache Pulsar的核心特性和设计概览

王知无

JDK的泛型如何工作的

卢卡多多

Java泛型 8月日更

JavaScript new 关键词解析及原生实现 new

zhoulujun

JavaScript new

instanceof运算符的实质:Java继承链与JavaScript原型链

zhoulujun

JavaScript 继承 原型链 instanceof 继承链

序列化单例模式的实现————readResolve 源码解读

4ye

Java 源码 后端 序列化 8月日更

架构训练营毕业总结

Geek_e0c25c

架构实战营

秒杀架构设计

鲲哥

雷从九天临,暗由赤地生 - 你的对手只有时间

王知无

二叉查找树的迭代遍历

泽睿

二叉树

Docker镜像及其相关技术的原理和本质_文化 & 方法_Rancher_InfoQ精选文章