Docker 那些事儿容器为什么傲娇?全靠镜像撑腰
Posted 飞向星的客机
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Docker 那些事儿容器为什么傲娇?全靠镜像撑腰相关的知识,希望对你有一定的参考价值。
文章目录
🌟 前言
Docker 镜像是 Docker 容器的基石,容器是镜像的运行实例,有了镜像才能启动容器。
Docker 镜像是一个只读的模板,一个独立的文件系统,包括运行一个容器所需的数据,可以用来创建容器。
1. base镜像
base(基础) 镜像是指完全从零开始构建的镜像, 它不会依赖其他镜像,甚至会成为被依赖的镜像,其他镜像以它为基础进行扩展。
通常 base 镜像都是 Linux 的系统镜像, 如 Ubuntu、CentOS、Debian 等。
下面通过 Docker 拉取一个 base 镜像并查看, 这里以 CentOS 为例, 示例代码如下:
从以上示例中可以看出,一个 CentOS 镜像大小只有 202MB,但在安装系统时,一个 CentOS 大概有几 GB,这与操作系统有关。
先观察 Linux 原本的操作系统结构,如图所示👇
Kernel 是内核空间。bootfs 文件系统在 Linux 启动时加载。rootfs 是包含操作命令的文件系统。
base 镜像的创建过程中,Kernel、 bootfs 与 rootfs 都会加载,然后 bootfs 文件系统 (包括 Kernel) 被卸载掉,镜像只保留 rootfs 文件系统,供用户进行操作。bootfs 与 Kernel 将与宿主机共享。
另外,为了增加 Docker 的灵活性,base 镜像提供的都是最小安装的 Linux 系统。
Linux 系统不同的发行版之间最大的区别就是 rootfs 的不同,例如,Ubuntu 系统的应用程序管理器是 apt,而 CentOS 是 yum。
由此可见,只要提供不同的 rootfs 文件系统就可以同时支持多种操作系统,如图所示👇
从上图中可以看到,两个不同的 Linux 发行版提供了各自的 rootfs 文件系统,而它们共用的是底层宿主机的 Kernel。
假设宿主机的系统是 Ubuntu 16.04,Kernel 版本是 4.4.0,无论 base 镜像原本的发行版 Kernel 版本如何,在这台宿主机上都是 4.4.0。
下面通过示例来验证,示例代码如下:
从上述示例中可以看出,base 镜像与宿主机的 Kernel 版本都是 3.10。
base 镜像的 Kernel 是与宿主机共享的,其版本与宿主机一致,并且不能进行修改。
2. 镜像的本质
Docker 镜像是一个只读的文件系统,由一层一层的文件系统组成,每一层仅镜像的本质包含前一层的差异部分,这种层级文件系统被称为 UnionFS。
大多数 Docker 镜像都在 base 镜像的基础上进行创建,每进行一次新的创建就会在镜像上构建一个新的 UnionFS。
查看 ubuntu:15.04 镜像的层级结构,示例代码如下:
通常,对 Docker 的操作命令都是以 “docker” 开头。pull 是下载镜像的命令,在英文中是 “拉” 的意思,所以下载镜像又叫作 拉取镜像。
以上示例中,第 5 行到第 8 行是每一层 UnionFS 的 ID 号,第 9 行是整个镜像的 ID 号,这个 ID 号可以用来操控镜像。
然后,查看镜像,示例代码如下:
在以上示例中,不仅可以看到先前下载的 Ubuntu15.04 镜像,还可以看到其他镜像,说明docker images
是查看本地所有镜像的命令。而查看到的信息中,除了镜像名称,还有版本号、镜像 ID 号、创建时间以及镜像大小。
接着,通过命令查看镜像的构建过程,示例代码如下:
这里使用 “history” 与镜像 ID 号组合的命令查看镜像构建过程,所显示的信息包括镜像 ID 号、创建时间、由什么命令创建以及镜像大小。
从以上示例中的信息可以看出,ubuntu:15.04 镜像由四个只读层 (Read Layer) 构建而成,每一层都是由一条命令构成的,最终得到 ID 号为 dlb55fd07600 的镜像,但以用户的视角只能看到最上层。
当用户将这个镜像放在容器中运行时,四层之上会创建出一个可读可写层(Read-Write Layer),用户对 Docker 的操作都通过可读可写层进行。如果用户修改了一个已存在的文件,那该文件将会从可读可写层下的只读层复制到可读可写层,该文件的只读版本仍然存在,只是已经被可读可写层中该文件的副本所隐藏。
可读可写层又叫作容器层,只读层又叫作镜像层,容器层之下均为镜像层,层级结构如图所示👇
镜像的这种分层机制最大的一个好处就是:共享资源。
例如,有很多个镜像都基于一个基础镜像构建而来,那么在本地的仓库中就只需要保存一份基础镜像,所有需要此基础镜像的容器都可以共享它,而且镜像的每一层都可以被共享,从而节省磁盘空间。
因为有了分层机制,本地保存的基础镜像都是只读的文件系统,不用担心对容器的操作会对镜像有什么影响。
为了将零星的数据整合起来,人们提出了镜像层 (Image Layer) 这个概念,如图所示👇
下图所示为一个镜像层,我们能够发现,一个层并不仅仅包含文件系统的改变,它还能包含其他重要的信息。
元数据 (Metadata) 就是关于这个层的额外信息,包括 Docker 运行时的信息与父镜像层的信息,并且只读层与可读可写层都包含元数据,如图所示👇
除此之外,每一层还有一个指向父镜像层的指针。如果没有这个指针,说明它处于最底层,是一个基础镜像,如图所示👇
3. 查找本地镜像
Docker 本地镜像通常是储存在服务器上的,下面验证本地镜像的储存路径,示例代码如下:
从以上示例中可以看到,Docker 本地镜像储存路径是/var/Iib/Docker
。
在本地查看镜像时,通常使用docker images
命令,示例代码如下:
从以上示例中可以看到,结果显示中有多项镜像信息,下面对信息进行解释。
🍇 REPOSITORY
镜像仓库,即一些关联镜像的集合。
例如,Ubuntu 的每个镜像对应着不同的版本。与 Docker Registry 不同,镜像仓库提供 Docker 镜像的存储服务。
即 Docker Registry 中有很多镜像仓库,镜像仓库中有很多镜像 (相互独立)。
🍇 TAG
镜像的标签,常用来区分不同的版本,默认标签为 latest。
🍇 IMAGE ID
镜像的ID号,镜像的唯一标识,常用于操作镜像 (默认值只列出前 12 位)。
🍇 CREATED
镜像创建的时间。
🍇 SIZE
镜像的大小。
🍇 参数用法
在 docker images
命令后加上不同的参数就形成了不同的查询方式,导致不同的查询结果。
下面介绍各参数的含义以及用法。
- -a
表示显示所有本地镜像,默认不显示中间层镜像,这是工作中经常使用到的参数,用来从本地镜像中寻找符合生产条件的镜像。
示例代码如下:
- -q
表示只显示本地所有镜像 ID 号。
示例代码如下:
- -no-trunc
表示使用不截断的模式显示,并显示完整的镜像 ID 号。
示例代码如下:
4. 构建镜像
Docker 的官方镜像库 Docker Hub 发布了成千上万的公共镜像供全球用户使用。用户可以直接拉取(下载)所需要的镜像,提高了工作效率。但是在很多工作环境中,一旦对镜像有特殊需求,就需要我们手动去构建镜像。
本文章将会介绍基于docker commit
命令与 Dockerfile 两种方式来构建自己的 Docker 镜像。
🍑 使用 docker commit 命令构建镜像
使用
docker commit
命令将容器的可读可写层转换为一个只读层,这样就把一个容器转换成了一个不可变的镜像,如图所示👇
下面我们给一个 Centos 的镜像安装一个 Vim 服务,设置开机启动,并将其构建成一个新的镜像,以免每次启动容器都要再次安装 Vim。
首先启动一个 Centos 的容器,示例代码如下:
从以上示例中可以看到,容器启动之后,主机名发生了改变,说明用户直接进入了容器,再进行操作就是对容器的操作。
然后,在容器中安装 Vim,示例代码如下:
安装完成之后,退出容器,示例代码如下:
使用 exit 命令退出容器之后, 该容器将默认关闭。
下面使用docker commit
命令在 CentOS 镜像的基础上创建新的镜像,示例代码如下:
在命令中需要用镜像 ID 号来指定基础镜像,并不需要将 ID 号都输入进去,只要输入几个字符使 ID 号与其他镜像不冲突即可。
此时可以看到刚刚构建的新镜像,代码如下:
从以上示例中可以看到, 新镜像的大小是 326MB,而此前的 CentOS 镜像只有 202MB, 这是因为在安装 Vim 时还安装了许多依赖包。
然后, 查看镜像中是否已经自动安装了 Vim, 示例代码如下:
从以上示例中可以看到,新镜像已经包含了 Vim。
这种构建新镜像的方式在工作中并不常见,原因如下。
(1)效率低下,如果要给 Ubuntu 镜像也添加一个Vim,需要将上述全部过程重复一遍。
(2)不透明,用户使用时不知道镜像是如何构建的,难以对镜像做出正确的判断。
🍑 使用 Dockerfile 构建镜像
镜像可以基于 Dockerfile 构建。Dockerfile 是一个描述文件,包含若干条命令,每条命令都会为基础文件系统创建新的层次结构,这正好弥补了
docker commit
构建镜像效率低下的缺点。
Dockerfile 定义容器内部环境中发生的事情。网络接口和磁盘驱动器等资源的访问在此环境内虚拟化,与系统的其余部分隔离。
Dockerfile 主要使用docker build
命令,根据 Dockerfile 文件中的指令,执行若干次docker commit
命令构建镜像,每次执行docker commit
命令时都会生成一个新的层,因此许多新的层会被创建,如图所示👇
🍑 Dockerfile常用命令
下面介绍 Dockerfile 中常用的命令,完整说明见官方文档。
- FROM
指定源镜像,必须是已经存在的镜像,必须是Dockerfile中第一条非注释的命令,因为其后的所有指令都使用该镜像。
- MAINTAINER
指定作者信息。
- RUN
在当前容器中运行指定的命令。
- EXPOSE
指定运行容器时要使用的端口。可以使用多个EXPOSE命令。
- CMD
指定容器启动时运行的命令,Dockerfile 可以出现多个 CMD 指令,但只有最后一个生效。CMD 可以被启动容器时添加的命令覆盖。
- ENTRYPOINT
CMD 或容器启动时添加的命令会被当做参数传递给 ENTRYPOINT。
- COPY
文件或目录复制到当前容器中。
- ADD
将文件或者目录复制到当前容器中,源文件如果是归档(压缩)文件,则会被自动解压到目标位置。
- VOLUME
为容器添加容器卷,可以存在于一个或多个目录,用来提供共享存储。该命令会在容器数据卷部分详细介绍。
- WORKDIR
在容器内设置工作目录。
- ENV
设置环境变量。
- USER
指定容器以什么用户身份运行,默认是 root。
🍑 运行一个Dockerfile
下面演示使用 Docker file 创建
centos/vim
,示例代码如下:
这里在宿主机的 root 目录下创建了一个 Dockerfile 文件。
接着,向 Docker file 文件中添加内容, 示例代码如下:
添加完成之后,保存并退出。
有了 Dockerfile 文件之后即可创建新的镜像,示例代码如下:
通过docker build
命令执行 Dockerfile 文件,-t 用来指定新镜像名为centos/vim-Dockerfile
,命令行末尾的.
表示 Dockerfile 文件在当前目录,Docker 默认从指定的目录寻找 Dockerfile 文件,也可以使用 -f 参数指定 Dockerfile 文件的位置。
构建完成之后,查看镜像是否构建成功,示例代码如下:
从以上示例中可以看到,新镜像已经构建成功。
使用 Dockerfile 构建镜像基本可以分为以下五步。
(1) 选择一个基础镜像,运行一个临时容器。
(2) 执行一条命令,对容器做修改。
(3) 执行类似docker commit
的操作,生成一个新的镜像。
(4) 删除临时容器,再基于刚刚构建好的新镜像运行一个临时容器。
(5) 重复 (2) (3) (4) 步,直到执行完 Dockerfile 中的所有指令。
centos/vim-Dockerfile
由 CentOS 基础镜像和RUN yum -y install vim
构成,现在两个镜像都包含了 ID 号为 lel148e4cc2c 的只读层,如图所示👇
以上结论可以使用docker history
命令验证,docker history
命令专门用来查看镜像的结构,示例代码如下:
这里可以看到 CentOS 镜像中确实包含了 ID 号为 1e1148e4cc2c 的只读层。
接着再查看新镜像centos/vim-Dockerfile
的结构,示例代码如下:
从以上示例中可以看到,两个镜像都含有一个相同的只读层,并且这个只读层是共享的。
Docker 构建镜像时有缓存机制,如果构建镜像层时该镜像层已经存在,就直接使用,无须重新构建。
下面为先前的 Dockerfile 文件添加一点内容,安装一个 ntp 服务,重新构建一个新的镜像,示例代码如下:
这里多加了一条安装 ntp 服务的命令。
添加完成后,开始创建镜像,示例代码如下:
在示例的第 6 行代码中可以看到,Docker 没有重新安装 Vim,而是直接使用了先前安装过的缓存。
Dockerfile 文件是从上至下依次执行的,上层依赖于下层。无论什么时候,只要某一层发生变化,其上面所有层的缓存都会失效。
改变先前的 Dockerfile 文件中两条 RUN 命令的上下顺序,观察 Docker 还会不会使用缓存机制,示例代码如下:
将 Dockerfile 中两条 RUN 命令的顺序互换之后,开始创建镜像,示例代码如下:
由以上验证可知,将两条 RUN 命令交换顺序导致镜像层次发生改变,Docker 会重建镜像层。由此可见 Docker 的镜像层级结构特性:只有下面的层次内容、顺序完全一致才会使用缓存机制。
如果在构建镜像时不想使用缓存,可以在docker build
命令中添加--no-cache
参数,否则默认使用缓存。
除了在使用 Dockerfile 构建镜像时有缓存机制,在从仓库拉取镜像时也会有缓存机制,即已经拉取到本地的镜像层可以被多个镜像共同使用,可以说是一次拉取多次使用,前提是下层镜像完全相同。
通常使用 Dockerfile 构建镜像时,如果由于某些原因镜像构建失败,我们能够得到前一个指令成功执行构建出的镜像,继而可以运行这个镜像查找指令失败的原因,这对调试 Dockerfile 有极大的帮助。
从 Docker Hub 拉取的 CentOS 镜像是最小化的,其中没有 vim 命令。下面测试错误构建 Docker 镜像的结果,示例代码如下:
将 Dockerfile 中任意一条 RUN 命令改为错误的,再开始创建镜像,示例代码如下:
在示例中,由于第三步报错,镜像没有创建成功。但也生成了一个新镜像,这个镜像是第二步操作构建的,通常可以通过这个新镜像排查错误,示例代码如下:
Docker 容器技术中,编写 Dockerfile 文件是非常重要的部分,下面总结编写 Dockerfile 文件的一些小技巧,相信可以帮助大家更好地使用 Docker 与 Dockerfile。
- (1)容器中只运行单个应用。
从技术角度讲,在一个容器中可以实现整个 LNMP (Linux+nginx+mysql+php) 架构。但这样做有很大的弊端。首先,镜像构建的时间会非常长,每次修改都要重新构建;
其次,镜像文件会非常大,大大降低容器的灵活性。
- (2)将多个 RUN 指令合并成一个。
众所周知,Docker 镜像是分层的,Dockerfile 中的每一条指令都会创建一个新的镜像层,镜像层是只读的。
Docker镜像层类似于洋葱,想要更改内层,需要将外层全部撕掉。
- (3)基础镜像的标签尽量不要使用 latest。
当镜像的标签没有指定时,默认使用 latest 标签。
当镜像更新时,latest 标签会指向不同的镜像,可能会对服务产生影响。
- (4)执行 RUN 命令后删除多余文件。
假设执行了更新 yum 源的命令,会自动下载解压一些软件包,但是在运行容器的时候不需要这些包。最好将它们删除,因为这些软件包会使镜像 SIZE 变大。
- (5)合理调整 COPY 与 RUN 的顺序。
将变化少的部分放在 Dockerfile 文件的前面,充分利用镜像缓存机制。
- (6)选择合适的基础镜像。
最好选择满足环境需要而且体积小巧的镜像,比如 Alpine 版本的 node 镜像。
Alpine 是一个极小化的 Linux 发行版,只有 5.5MB,非常适合作为基础镜像。
5. Docker Hub
🍑 docker search 命令
Docker Hub 上有许多镜像, 但并不都是 Docker 官方人员制作的, 一些镜像是由 Docker 用户上传并维护的。工作中可以通过
docker search
命令从镜像仓库中查找所需要的镜像。
示例代码如下:
以上示例中,查找的是与 CentOS 有关的镜像,Docker Hub 中有关 CentOS 的镜像远不止这些,这里显示的只是其中一部分。
下面对镜像的每项信息进行讲解。
- NDEX
镜像索引,这里代表镜像仓库。
- NAME
镜像名称。
- DESCRIPTION
关于镜像的描述,使用户可以有方向性地选择镜像。
- STARS
镜像的星标数,反映 Docker 用户对镜像的收藏情况,值越高代表使用的用户越多。
- OFFICIAL
有此标记的都是 Docker 官方维护的镜像,没有此标记的通常是用户上传的镜像。
- AUTOMATED
用来区分是否为自动化构建的镜像,有此标记的为自动化构建的镜像,否则不是。
🍑 docker search 参数运用
给
docker search
命令添加不同的参数,就会得到不同的查询结果,下面对各参数进行解释。
--automated
表示只列出自动化构建的镜像,示例代码如下:
--no-trunc
表示显示完整的镜像描述,示例代码如下:
-s
表示列出星标数不小于指定值的镜像,需要在参数后添加指定值,示例代码如下:
🍑 镜像推送
用户向 Docker Hub 推送镜像需要有一个 Docker Hub 账号。在 Docker 客户端使用
docker login
命令登录 Docker Hub 账号,如图所示👇
下面使用docker push
命令向 Docker Hub 推送镜像,示例代码如下:
这里第 6 行出现了报错,镜像没有推送成功,报错信息显示请求资源被拒绝。这是因为 DockerHub 上的镜像都有一个 tag 标签,用户上传镜像时需要给镜像添加 tag 标签。
下面使用 docker tag 命令给镜像添加 tag 标签,示例代码如下:
tag 标签添加完之后,再查看一遍镜像,确保准确无误,示例代码如下:
可以看到,先前的镜像并没有发生改变,Docker 只是在它的基础上又创建了一个新镜像。与先前的镜像相比,新镜像只是修改了名字,镜像大小不变。
有了新镜像就可以将其推送给 Docker Hub 了,示例代码如下:
镜像推送完成之后, 可以登录 Docker Hub 查看, 验证镜像是否推送成功, 如图所示👇
从上图可以看出,镜像已经推送成功,这个镜像在 Docker Hub 上可以供所有用户下载并使用。
尝试拉取先前推送的镜像,示例代码如下:
镜像拉取完成后,查看是否拉取成功,示例代码如下:
从以上示例中可以看出,已经成功拉取了先前推送的镜像,拉取的镜像与本地的镜像相比,ID 号和镜像大小都是相同的。这就体现出了 DockerHub 官方镜像库的方便之处,用户可以将镜像上传到云端,做到随用随取,也可以将自己优秀的镜像与广大开源爱好者共享。
Docker Hub 在给用户带来便利的同时,也暴露出了极大的安全问题。任何 Docker 用户都可以随时随地上传镜像到 Docker Hub,将其推送给其他 Docker 用户,任何用户都可以拉取使用这些镜像,但谁都无法辨别所下载的镜像是否包含恶意信息。
2018 年发生了一起安全事故,一名 Docker 用户上传了 17 个带有恶意软件的镜像,攻击者利用这些恶意 Docker 镜像在受害者的计算机上安装基于 XMRig 的门罗币挖矿软件。其中一些镜像已经安装超过一百万次,还有一些则被使用了数十万次。Docker 官方网站维护人员调查确认后删除了这些恶意镜像,风波才得以平息。
在使用 Docker 镜像时要先在测试环境中安装运行,最安全的方法是尽可能使用自制的 Docker 镜像或使用经过验证的镜像。
6. Docker镜像优化
Docker 镜像采用的是层级结构,一个镜像最多拥有 127 层 UnionFS。每条 Dockerfile 命令都会创建一个镜像层,增加镜像大小。在生产环境中使用 Docker 容器时,要尽可能地精简 Docker 镜像,减少 UnionFS 的层数。
精简镜像不仅能缩短新镜像的构建时间,还能减少磁盘用量。由于精简后的镜像更小,用户在拉取镜像时能节省时间,部署服务的效率也能得到提升。精简镜像包含的文件更少,更加不容易被攻击,提高了镜像的安全性。
🍑 base镜像优化
base 镜像优化就是在满足环境要求的前提下使用最小的 base 镜像。常用的 Linux base 镜像有 CentOS、Ubuntu、Alpine 等,其中一些比较小的 base 镜像适合作为精简镜像的基础镜像,如 Alpine、BusyBox 等。
下面分别拉取 Alpine 与 BusyBox 的镜像进行对比,示例代码如下:
Scratch 是一个空镜像,只能用于构建其他镜像,常用于执行一些包含了所有依赖的二进制文件。如果以 Scratch 为 base 镜像,意味着不以任何镜像为基础,下面的指令将作为镜像的第一层存在。
BusyBox 相对于 Scratch 多了一些常用的 Linux 命令,BusyBox 的官方镜像大小只有 1MB 多一点,非常适合构建小镜像。
Alpine 是一款高度精简又包含了基本工具的轻量级 Linux 发行版,官方 base 镜像只有 5MB 多一点,很适合当作 base 镜像使用。
🍑 Dockerfile 优化
用户在定义 Dockerfile 文件时,使用太多的 RUN 命令,会导致镜像非常靡肿,甚至超出可构建的最大层数。根据优化原则,应该将多条 RUN 命令合并为一条命令,精心设计每一个 RUN 命令,减小镜像体积,并且精心编排,最大化地利用缓存。
下面创建一个 Dockerfile 文件,示例代码如下:
接着, 使用这个 Dockerfile 构建一个新的镜像, 示例代码如下:
从以上示例中可以看到,整个镜像构建的过程是十分烦琐的。
查看新镜像的大小与 UnionFS 的层数,示例代码如下:
从以上示例中可以看到,新镜像centos/vim-bulky
的大小是 509MB,而镜像的 UnionFS 层数是 8 层。
这样编写 Dockerfile 导致新镜像非常庞大,既增加了构建部署的时间,也很容易出错。
下面对 Dockerfile 进行优化,示例代码如下:
在Dockerfile中使用 “&” 与 ‘’**" 将多条命令合成一条,"&&” 表示命令还没有结束,‘’**"表示换行。
下面通过优化过的 Dockerfile 构建新镜像,示例代码如下:
从以上示例可以看出,优化后的 Dockerfile 构建镜像的过程比优化前精简了一些。
查看并对比两个新镜像,示例代码如下:
从以上示例中可以看到,镜像centos/vim-portable
只有 305MB,比镜像centos/vim-bulky
节省了 204MB 的资源,甚至比源镜像centos/vim
还小了 21MB。
接着,查看镜像centos/vim-portable
的 UnionFS 层数,示例代码如下:
从以上示例中可以看到,镜像centos/vim-portable
的 UnionFS 只有 6 层,而先前的镜像centos/vim-bulky
有 8 层 UnionFS。
🍑 清理无用的文件
在 RUN 命令中使用 yum、apt、apk 等工具时,可以借助这些工具自带的参数进行优化。如执行
apt-get install-y
时添加--no-install-recommends
选项,就可以不安装建议性的依赖包,这些依赖包都是不必要的。
组件的安装和清理要放置在同一条命令里面,因为 Dockerfile 的每条命令都会产生一个新的镜像层,在执行下一条命令时,上一条命令所产生的镜像层已经为只读层,不可修改。Ubuntu 或 Debian 系统使用rm -rf/var/lib/apt/lists/*
清理镜像中的缓存文件,CentOS 等系统使用yum clean all
命令清理。
Docker 社区中还有许多优化镜像的工具,如压缩镜像的工具 Docker-squash,用起来简单方便。
7. 本章小结
本章介绍了 Docker 镜像的构成,采用了层级结构,层层递进;
然后介绍了镜像的仓库,包括官方公有仓库 Docker Hub 和 私有仓库;
最后详细讲解了如何构建 Docker 镜像,并对 Dockerfile 文件进行了简单的优化。
相信大家通过本章的学习,已经掌握了镜像的原理以及操作方式。
以上是关于Docker 那些事儿容器为什么傲娇?全靠镜像撑腰的主要内容,如果未能解决你的问题,请参考以下文章