Kubernetes-基于Dockerfile构建docker镜像实践

d0dwreplica 6年前
   <p>1、Dockerfile文件和核心指令</p>    <p>在Kubernetes中运行容器的前提是已存在构建好的镜像文件,而通过Dockerfile文件构建镜像是最好方式。Dockerfile是一个文本文件,在此文件中的可以设置各种指令,以通过docker build命令自动构建出需要的镜像。Dockerfile文件必需以FROM命令开始,然后按照文件中的命令顺序逐条进行执行。在文件以#开始的内容会被看做是对相关命令的注释。</p>    <pre>  <code class="language-dos"># Comment     INSTRUCTION arguments  </code></pre>    <p>下面是一个典型的Dockerfile文件,此Dockerfile用于构建一个docker镜像仓库的镜像。Dockerfile文件的格式如下,在文件中对于大小写是不敏感的。但是为了方便的区分命令和参数,一般以大写的方式编写命令。此镜像的基础镜像为alpine:3.4,构建一个docker镜像仓库的镜像:</p>    <pre>  <code class="language-dos"># Build a minimal distribution container    FROM alpine:3.4    RUN set -ex \    && apk add --no-cache ca-certificates apache2-utils    COPY ./registry/registry /bin/registry    COPY ./registry/config-example.yml /etc/docker/registry/config.yml    VOLUME ["/var/lib/registry"]    EXPOSE 5000    COPY docker-entrypoint.sh /entrypoint.sh    ENTRYPOINT ["/entrypoint.sh"]    CMD ["/etc/docker/registry/config.yml"]  </code></pre>    <p>1.1 FROM:设置基础镜像</p>    <p>FROM命令为后续的命令设置基础镜像,它是Dockerfile文件的第一条命令,FROM命令的格式如下:</p>    <p>FROM <image>[:<tag>] [AS <name>]</p>    <p>1.2 RUN:设置构建镜像时执行的命令</p>    <p>RUN命令有两种格式,下面是shell格式的RUN命令,在Linux中RUN的默认命令是/bin/sh;在Windows中默认命令为cmd /S /C:</p>    <p>RUN <command></p>    <p>下面是exec格式的RUN命令:</p>    <p>RUN ["executable", "param1", "param2"]</p>    <p>RUN指令将会在当前镜像顶部的新层中执行任何命令,并提交结果。提交的结果镜像将用于Dockerfile文件的下一步。分层RUN指令和生成提交符合Docker的核心概念,容器可以从镜像历史中的任何点镜像创建,非常类似于源代码管理。</p>    <p>1.3 CMD:设置容器的默认执行命令</p>    <p>CMD指令的主要目的是为容器提供一个默认的执行命令,在一个Dockerfile只能有一条CMD指令,如果设置多条CMD指令,只有最后一条CMD指令会生效。The CMD指令有如下三种格式:</p>    <p>exec格式,这是推荐的格式:</p>    <p>CMD ["executable","param1","param2"]</p>    <p>为ENTRYPOINT提供参数:</p>    <p>CMD ["param1","param2"]</p>    <p>shell格式:</p>    <p>CMD command param1 param2</p>    <p>如果在Dockerfile中,CMD被用来为ENTRYPOINT指令提供参数,则CMD和ENTRYPOINT指令都应该使用exec格式。当基于镜像的容器运行时,将会自动执行CMD指令。如果在docker run命令中指定了参数,这些参数将会覆盖在CMD指令中设置的参数。</p>    <p>1.4 ENTRYPOINT:设置容器为可执行文件</p>    <p>通过ENTRYPOINT指令可以将容器设置作为可执行的文件,ENTRYPOINT 有两种格式:</p>    <p>exec格式,这是推荐的格式:</p>    <p>ENTRYPOINT ["executable", "param1", "param2"]</p>    <p>shell格式:</p>    <p>ENTRYPOINT command param1 param2</p>    <p>下面是是启动一个nginx的例子,端口为80:</p>    <p>docker run -i -t --rm -p 80:80 nginx</p>    <p>docker run <image>命令行参数将会被追加到exec格式的ENTRYPOINT所有元素之后,并将会覆盖使用CMD指定的所有元素。这就允许江参数传递到入口点,例如,docker run <Image> -d 将通过-d 参数传递到入口点。可以使用docker run --entrypoint 字段覆盖“ENTRYPOINT ”指令。如果在Dockerfile文件设置了多条ENTRYPOINT指令,则只会生效最后的一条指令。</p>    <p>1.4.1 ENTRYPOINT指令exec格式示例:</p>    <p>可以使用ENTRYPOINT 的exec形式来设置相对稳定的默认命令和参数,然后使用任何形式的CMD指令来设置可能发生变化的参数。</p>    <p>FROM ubuntu</p>    <p>ENTRYPOINT ["top", "-b"]</p>    <p>CMD ["-c"]</p>    <p>当运行容器是,可以看到只有一个top进程在运行:</p>    <p>$ docker run -it --rm --name test top -H</p>    <p>top - 08:25:00 up 7:27, 0 users, load average: 0.00, 0.01, 0.05</p>    <p>Threads: 1 total, 1 running, 0 sleeping, 0 stopped, 0 zombie</p>    <p>%Cpu(s): 0.1 us, 0.1 sy, 0.0 ni, 99.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st</p>    <p>KiB Mem: 2056668 total, 1616832 used, 439836 free, 99352 buffers</p>    <p>KiB Swap: 1441840 total, 0 used, 1441840 free. 1324440 cached Mem</p>    <p>PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND</p>    <p>1 root 20 0 19744 2336 2080 R 0.0 0.1 0:00.04 top</p>    <p>通过docker exec命令,能够参考容器的更多信息。</p>    <p>$ docker exec -it test ps aux</p>    <p>USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND</p>    <p>root 1 2.6 0.1 19752 2352 ? Ss+ 08:24 0:00 top -b -H</p>    <p>root 7 0.0 0.1 15572 2164 ? R+ 08:25 0:00 ps aux</p>    <p>下面的Dockerfile显示使用ENTRYPOINT在前台运行Apache:</p>    <p>FROM debian:stable</p>    <p>RUN apt-get update && apt-get install -y --force-yes apache2</p>    <p>EXPOSE 80 443</p>    <p>VOLUME ["/var/www", "/var/log/apache2", "/etc/apache2"]</p>    <p>ENTRYPOINT ["/usr/sbin/apache2ctl", "-D", "FOREGROUND"]</p>    <p>1.4.2 ENTRYPOINT指令的shell格式</p>    <p>通过为ENTRYPOINT指定文本格式的参数,此参数将在/bin /sh -c 中进行执行。这个形式将使用shell处理,而不是shell环境变量,并且将忽略任何的CMD或docker run运行命令行参数。</p>    <p>FROM ubuntu</p>    <p>ENTRYPOINT exec top -b</p>    <p>1.4.3 CMD和ENTRYPOINT交互</p>    <p>CMD和ENTRYPOINT指令都可以定义容器运行时所执行的命令,下面是它们之间协调的一些规则:</p>    <p>1)在Dockerfile至少需要设置一条CMD或者ENTRYPOINT指令;</p>    <p>2)当将容器作为可执行文件使用时,建议定义ENTRYPOINT指令;</p>    <p>3)CMD作为为ENTRYPOINT命令定义默认参数的一种方式;</p>    <p>4)当使用带有参数的命令运行容器时,CMD将会被覆盖。</p>    <p>下表是显示了不同的ENTRYPOINT / CMD指令组合的命令执行情况:</p>    <p>No ENTRYPOINT ENTRYPOINT exec_entry p1_entry ENTRYPOINT [“exec_entry”, “p1_entry”]</p>    <p>No CMD 报错,这种情况不运行出现 /bin/sh -c exec_entry p1_entry exec_entry p1_entry</p>    <p>CMD [“exec_cmd”, “p1_cmd”] exec_cmd p1_cmd /bin/sh -c exec_entry p1_entry exec_entry p1_entry exec_cmd p1_cmd</p>    <p>CMD [“p1_cmd”, “p2_cmd”] p1_cmd p2_cmd /bin/sh -c exec_entry p1_entry exec_entry p1_entry p1_cmd p2_cmd</p>    <p>CMD exec_cmd p1_cmd /bin/sh -c exec_cmd p1_cmd /bin/sh -c exec_entry p1_entry exec_entry p1_entry /bin/sh -c exec_cmd p1_cmd</p>    <p>1.5 ENV:设置环境变量</p>    <p>Env指令通过<键>和<值>对设置环境变量。此值将在环境中用于生成阶段中的所有后续指令,并且也可以在许多情况下被替换为内联。</p>    <p>“Env”指令有两种形式。第一种形式,即ENV <Key>  < value >,将一个变量设置为一个值。第一个空间之后的整个字符串将被处理为“<值>”,包括空白字符。</p>    <p>ENV <key> <value></p>    <p>第二种形式,即ENV <Key>=Value>…,允许一次设置多个变量。注意,第二个表单在语法中使用等号(=),而第一个表单则不使用。与命令行解析一样,引用和反斜杠可用于在值内包含空格。</p>    <p>ENV <key>=<value> ...</p>    <p>例如:</p>    <p>ENV myName="John Doe" myDog=Rex\ The\ Dog \</p>    <p>myCat=fluffy</p>    <p>和:</p>    <p>ENV myName John Doe</p>    <p>ENV myDog Rex The Dog</p>    <p>ENV myCat fluffy</p>    <p>1.6 ADD:添加内容到容器中</p>    <p>ADD指令用于从当前机器或远程URL中的<src>中拷贝文件、目录,并将它们添加到镜像文件系统的<dest>中。在指令中能够设置多个<src>,--chown仅仅在构建Linux容器镜像时起作用,ADD指令有两种格式:</p>    <p>ADD [--chown=<user>:<group>] <src>... <dest></p>    <p>下面的ADD指令格式可以运行源和目标路径包含空格。</p>    <p>ADD [--chown=<user>:<group>] ["<src>",... "<dest>"]</p>    <p><src>可以包含通配符,例如:</p>    <p>ADD hom* /mydir/ # 添加所有以"hom"开头的文件到镜像中的/mydir目录下。</p>    <p>ADD hom?.txt /mydir/ # ? is replaced with any single character, e.g., "home.txt"</p>    <p><dest>是容器一个绝对路径,或者是一个相对于WORKDIR的相对路径,</p>    <p>ADD test relativeDir/ # 添加"test"到容器中 WORKDIR /relativeDir/</p>    <p>ADD test /absoluteDir/ # 添加"test"到容器中的/absoluteDir/</p>    <p>ADD指令遵循下面的规则:</p>    <p><src>路径必需在构建的上下文中;不能使用 ADD ../someting /someting,这是因为docker build的第一步就是发送上下文目录给docker daemon。</p>    <p>如果<src>是一个URL,并且<dest>不是以斜线结束的情况,则会从URL中下载一个文件,并将其拷贝到<dest>;</p>    <p>如果<src>是一个URL,并且<dest>以斜线结束,则会然后从URL中导出文件名,并将文件下载到<dest>/<filename>中。例如:ADD <a href="/misc/goto?guid=4959757906062506370" rel="nofollow,noindex">http://example.com/foobar</a> /,则会在容器的/目录下创建foobar文件,并将URL中foobar文件中的内容复制到容器中/foobar文件中。</p>    <p>如果<src>是一个目录,那么将会拷贝整个目录下的内容,并包括文件系统的元数据。需要注意的时,拷贝时,并不会拷贝目录本身,而只是拷贝目录下内容。</p>    <p>如果<src>是本地的一个压缩(例如:gzip、bzip2、xz等格式)文件,则会对其进行解压缩。对于来自于远程的URL,则不会进行解压缩。</p>    <p>如果<src>是一个普通文件,将会直接将文件和它的元数据拷贝到镜像的<dest>目录下。</p>    <p>如果指定了多个<src>,如果这些<src>中存在目录或使用了通配符,则<Dest>必须是一个目录,并且必须以斜杠/结尾。</p>    <p>如果<dest>不是以斜杠/结尾,它将被认为是一个文件,那么<src>的内容将被写到<dest>中。</p>    <p>1.7 COPY:拷贝内容到镜像中</p>    <p>COPY指令用于从<src>中拷贝文件或目录,并将其添加到镜像文件系统的<path>目录下。在指令中可以指定多个< src>资源,但是文件和目录的路径将被解释为相对于当前构建上下文的资源。COPY指令与ADD指令的功能基本上相似,但ADD能够从远程拷贝,以及解压缩文件。COPY指令有两种格式:</p>    <p>COPY [--chown=<user>:<group>] <src>... <dest></p>    <p>当目录中存在空格时,请使用下面的格式:</p>    <p>COPY [--chown=<user>:<group>] ["<src>",... "<dest>"]</p>    <p>1.8 WORKDIR:设置当前工作目录</p>    <p>WORKDIR指令用于为RUN、CMD、ENTRYPOINT、COPY和ADD指令设置当前的工作目录。如果WORKDIR不存在,则会自动创建一个,即使后续不使用。</p>    <p>WORKDIR /path/to/workdir</p>    <p>在Dockerfile文件中,可以设置多个WORKDIR指令。如果给定了一个相对路径,则后续WORKDIR设置的路径是相对于上一个相对路径的路径:</p>    <p>WORKDIR /a</p>    <p>WORKDIR b</p>    <p>WORKDIR c</p>    <p>RUN pwd</p>    <p>在Dockerfile中,最后的pwd命令输出的为:/a/b/c</p>    <p>1.9 EXPOSE:设置暴露的端口</p>    <p>EXPOSE指令告知docker,容器在运行时将监听指定哪个指定的网络端口。并可以指定端口的协议是TCP或UDP,如果没有指定协议,则默认为TCP协议。EXPOSE指令的格式如下:</p>    <p>EXPOSE <port> [<port>/<protocol>...]</p>    <p>“EXPOSE”指令实际上并不发布端口,它在构建镜像的人员和运行容器的人员之间起着文档告知的作用。要在运行容器时实际发布端口,则需要通过在docker run命令使用-p和-P来发布和映射一个或者多个端口。</p>    <p>1.10 LABEL:设置镜像的元数据信息</p>    <p>LABEL指令拥有为镜像添加一些描述的元数据。LABEL是一系列的键值对,它的格式如下:</p>    <p>LABEL <key>=<value> <key>=<value> <key>=<value> ...</p>    <p>下面是LABEL指令的示例:</p>    <p>LABEL "com.example.vendor"="ACME Incorporated"</p>    <p>LABEL com.example.label-with-value="foo"</p>    <p>LABEL version="1.0"</p>    <p>LABEL description="This text illustrates \</p>    <p>that label-values can span multiple lines."</p>    <p>通过docker inspect命令,可以查看镜像中的标签信息:</p>    <p>"Labels": {</p>    <p>"com.example.vendor": "ACME Incorporated"</p>    <p>"com.example.label-with-value": "foo",</p>    <p>"version": "1.0",</p>    <p>"description": "This text illustrates that label-values can span multiple lines.",</p>    <p>"multi.label1": "value1",</p>    <p>"multi.label2": "value2",</p>    <p>"other": "value3"</p>    <p>},</p>    <p>1.12 VOLUME:设置存储卷</p>    <p>VOLUME指令用于创建一个带有指定名称的挂载点,并将其标记为来自于本地主机或其他容器的存储卷。该值可以是JSON数组、VOLUME ["/var/log/“],或者是具有多个参数的普通字符串,例如VOLUME /var/log 或 VOLUME /var/log /var/db。</p>    <p>VOLUME ["/data"]</p>    <p>2、构建镜像</p>    <p>在定义后Dockerfile文件,并准备好相关的内容后,就可以通过docker build命令从Dockerfile和上下文构建docker镜像。构建的上下文是位于指定路径或URL中的文件集合。构建过程可以引用上下文中的任何文件。例如,您的构建可以使用复制指令来引用上下文中的文件。</p>    <p>docker build [OPTIONS] PATH | URL | -</p>    <p>2.1 命令选项</p>    <p>名称 默认值 描述</p>    <p>--add-host   添加定制 host-to-IP映射(host:ip)</p>    <p>--build-arg   设置构建时的变量</p>    <p>--cache-from   考虑被作为缓存源的镜像</p>    <p>--cgroup-parent   容器的可选父cgroup</p>    <p>--compress   使用gzip压缩构建上下文</p>    <p>--cpu-period   限制CPU CFS(完全公平调度程序)周期</p>    <p>--cpu-quota   限制CPU CFS(完全公平调度程序)配额</p>    <p>--cpu-shares , -c   CPU份额(相对权重)</p>    <p>--cpuset-cpus   允许执行的CPU(0-3,0,1)</p>    <p>--cpuset-mems   允许执行的内存(0-3,0,1)</p>    <p>--disable-content-trust true 忽略镜像验证</p>    <p>--file , -f   Dockerfile文件的名称(默认值为”PATH/Dockerfile“)</p>    <p>--force-rm   总是移除中间容器</p>    <p>--iidfile   将镜像ID写入文件</p>    <p>--isolation   容器隔离技术</p>    <p>--label   为镜像设置元数据</p>    <p>--memory , -m   内存限制</p>    <p>--memory-swap   Swap限制等于内存加swap:“-1”允许无限swap</p>    <p>--network   在构建期间,为RUN指令设置联网模式</p>    <p>--no-cache   在构建镜像时不使用缓存</p>    <p>--platform   如果服务器是多平台能力的,设置平台</p>    <p>--pull   一直尝试拉取镜像的最新版本</p>    <p>--quiet , -q   抑制构建输出和打印镜像ID</p>    <p>--rm true 成功构建后,移除中间容器</p>    <p>--security-opt   安全选项</p>    <p>--shm-size   /dev/shm的大小</p>    <p>--squash   将新建的层挤压成一个新的层</p>    <p>--stream   流连接到服务器,以协商构建的上下文</p>    <p>--tag , -t   为构建的镜像以”name:tab“格式打上标签</p>    <p>--target   设置目标构建阶段进行构建</p>    <p>--ulimit   Ulimit选项</p>    <p>1.2 URL参数</p>    <p>URL参数可以引用三种资源:Git存储库、预打包的tabball上下文和纯文本文件,本文主要描述如何使用Git仓库构建镜像。当 URL 参数指向一个Git仓库的位置,仓库将作为构建的上下文。系统的递归获取库及其子模块,提交历史不保存。仓库是首先被拉取到本地主机的临时目录。成功后,此临时目录被发送给Docker daemon作为构建上下文。</p>    <p>Git URL接受的上下文配置,由冒号分隔:进行分割。第一部分表示Git将签出的引用,可以是分支、标签或远程引用。第二部分表示存储库内的子目录,该目录将用作构建上下文。</p>    <p>例如:使用container分支的docker目录构建镜像:</p>    <p>$ docker build <a href="/misc/goto?guid=4959757906145954641" rel="nofollow,noindex">https://github.com/docker/root ... ocker</a></p>    <p>下面是通过git构建镜像的合法表达:</p>    <p>建立语法后缀 提交使用 构建上下文使用</p>    <p>myrepo.git refs/heads/master /</p>    <p>myrepo.git#mytag refs/tags/mytag /</p>    <p>myrepo.git#mybranch refs/heads/mybranch /</p>    <p>myrepo.git#pull/42/head refs/pull/42/head /</p>    <p>myrepo.git#:myfolder refs/heads/master /myfolder</p>    <p>myrepo.git#master:myfolder refs/heads/master /myfolder</p>    <p>myrepo.git#mytag:myfolder refs/tags/mytag /myfolder</p>    <p>myrepo.git#mybranch:myfolder refs/heads/mybranch /myfolder</p>    <p>1.3 构建示例</p>    <p>下面是通过本地路径构建一个私有镜像仓库镜像的示例,在此示例中,通过-t设置了镜像的标签为registry:latest;构建上下文为当前执行命令所在的目录,Dockerfile为当前上下文中的文件。</p>    <p>$ docker build -t registry:latest .</p>    <p>下面是通过Git仓库构建镜像的示例:</p>    <p>$ docker build -t regiestry:latest <a href="/misc/goto?guid=4959757906232917945" rel="nofollow,noindex">https://github.com/docker/dist ... e.git</a></p>    <p>3、最佳实践</p>    <p>1)不安装不必要的包</p>    <p>为了减少复杂性、依赖性、文件大小和构建时间,避免安装额外的或不必要的包。</p>    <p>2)最小化层的数量</p>    <p>在旧版本的Docker中,最小化镜像中的层数是非常重要,这样可以确保它们的性能。添加以下特征能够减少这种限制:</p>    <p>在docker 1.10和更高版本中,只有RUN、COPY和ADD会创建层。其他指令仅会创建临时的中间镜像,并且不直接增加构建的大小。</p>    <p>在docker17.05和更高版本中,您可以进行多阶段构建,只将需要的工件复制到最终镜像中。这允许您在中间构建阶段中包含工具和调试信息,而不增加最终镜像的大小。</p>    <p>3)解耦应用</p>    <p>每个容器应该只关注一个业务问题。将应用程序分解到多个容器中,从而可以更容易地进行水平扩容和重用。例如,Web应用程序栈可能由三个单独的容器组成,每个容器都有自己的镜像,以解耦的方式管理Web应用程序、数据库和内存缓存。尽最大的努力使容器尽可能保持清晰和模块化。如果容器相互依赖,可以使用docker容器的网络来确保这些容器可以进行通信。</p>    <p>4)排序多行参数</p>    <p>只要有可能,尽量按字母顺序排序多行参数,可以减轻以后的变化。这有助于避免重复包,并使列表更容易更新。</p>    <p>下面是buildpack-deps镜像的一个例子:</p>    <p>RUN apt-get update && apt-get install -y \</p>    <p>bzr \</p>    <p>cvs \</p>    <p>git \</p>    <p>mercurial \</p>    <p>subversion</p>    <p>5)利用构建缓存</p>    <p>在构建镜像时,Docker会通过Dockerfile文件中的指令,并按指定的顺序执行每一个指令。在检查每个指令时,Docker会在缓存中寻找可重用的现有图像,而不是创建新的(重复的)图像。</p>    <p>如果您根本不想使用缓存,可以在docker构建命令上使用--no-cache=true选项。但是,如果让Docker使用缓存,则需要了解它何时能找到匹配的镜像。docker遵循的基本规则如下:</p>    <p>从已经存在于缓存中的父镜像开始,将下一条指令与从该基础镜像派生的所有子镜像进行比较,以查看其中是否使用完全相同的指令构建了其中的一个子镜像。如果没有,则缓存无效。</p>    <p>在大多数情况下,简单地将Dockerfile文件中的指令与其中一个子镜像中指令进行比较就足够了。然而,某些指令需要更多的检查和解释。</p>    <p>对于ADD和COPY指令,检查镜像中文件的内容,并为每个文件计算校验和。这些校验和中未考虑文件的最后修改和上次访问时间。在缓存查找期间,将校验和与现有镜像中的校验和进行比较。如果文件中的任何内容(如内容和元数据)发生变化,则缓存被无效。</p>    <p>除了ADD和COPY命令之外,缓存检查并不查看容器中的文件来确定缓存匹配情况。例如,在处理RUN apt-get -y update更新命令时,不检查容器中更新的文件,以确定是否存在缓存命中。在这种情况下,仅使用命令字符串本身来查找匹配项。</p>    <p>一旦缓存失效,所有后续Dockerfile命令都生成新的图像,并且不使用缓存。</p>    <p>6)尽量使用官方的alphine镜像作为基础镜像</p>    <p>只要有可能,使用当前官方的镜像基础。建议使用alpine镜像,因为它尺寸会被严格控制(目前低于5 MB),但仍然是一个完整的Linux发行版。</p>    <p>7)ADD和COPY的使用</p>    <p>虽然ADD和COPY功能类似,一般来说,优先使用COPY,那是因为COPY比ADD更透明。COPY只支持将本地文件拷贝到容器中</p>    <p>如果需要将构建上下文中多个文件拷贝到镜像中,请使用COPY指令分开进行拷贝。</p>    <p>参考资料</p>    <p>1.《docker build》地址: <a href="/misc/goto?guid=4959757906313744873" rel="nofollow,noindex">https://docs.docker.com/engine ... uild/</a></p>    <p>2.《Dockerfile reference》地址: <a href="/misc/goto?guid=4958983813834696015" rel="nofollow,noindex">https://docs.docker.com/engine/reference/builder/</a></p>    <p>3.《Best practices for writing Dockerfiles》地址: <a href="/misc/goto?guid=4959757906423501083" rel="nofollow,noindex">https://docs.docker.com/develo ... ices/</a></p>    <p>作者简介:</p>    <p>季向远,北京神舟航天软件技术有限公司产品经理。本文版权归原作者所有。</p>    <p> </p>    <p>来自:http://dockone.io/article/5998</p>    <p> </p>