Docker 镜像制作

作者: 索伦x | 来源:发表于2019-05-10 15:09 被阅读189次

    Docker 镜像加速器

    概述

    国内从 Docker Hub 拉取镜像有时会遇到困难,此时可以配置镜像加速器。Docker 官方和国内很多云服务商都提供了国内加速器服务,例如:

    • [Docker 官方提供的中国 registry mirror]
    • [阿里云加速器]
    • [DaoCloud 加速器]

    我们以 Docker 官方加速器为例进行介绍。

    Ubuntu 14.04、Debian 7 Wheezy

    对于使用 upstart 的系统而言,编辑/etc/default/docker文件,在其中的DOCKER_OPTS中配置加速器地址:

    DOCKER_OPTS="--registry-mirror=https://registry.docker-cn.com"
    

    重新启动服务。

    $ sudo service docker restart
    

    Ubuntu 16.04+、Debian 8+、CentOS 7

    对于使用 systemd 的系统,请在/etc/docker/daemon.json中写入如下内容(如果文件不存在请新建该文件)

    {
      "registry-mirrors": [
        "https://registry.docker-cn.com"
      ]
    }
    

    注意,一定要保证该文件符合 json 规范,否则 Docker 将不能启动。

    之后重新启动服务。

    $ sudo systemctl daemon-reload
    $ sudo systemctl restart docker
    

    Windows 10

    对于使用 Windows 10 的系统,在系统右下角托盘 Docker 图标内右键菜单选择Settings,打开配置窗口后左侧导航菜单选择Daemon。在Registry mirrors一栏中填写加速器地址https://registry.docker-cn.com,之后点击Apply保存后 Docker 就会重启并应用配置的镜像地址了。

    macOS

    对于使用 macOS 的用户,在任务栏点击 Docker for mac 应用图标 -> Perferences... -> Daemon -> Registry mirrors。在列表中填写加速器地址?https://registry.docker-cn.com。修改完成之后,点击Apply & Restart按钮,Docker 就会重启并应用配置的镜像地址了。

    检查加速器是否生效

    配置加速器之后,如果拉取镜像仍然十分缓慢,请手动检查加速器配置是否生效,在命令行执行docker info,如果从结果中看到了如下内容,说明配置成功。

    Registry Mirrors:
     https://registry.docker-cn.com/
    

    Docker 镜像

    使用 Docker 镜像

    在之前的介绍中,我们知道镜像是 Docker 的三大组件之一。

    Docker 运行容器前需要本地存在对应的镜像,如果本地不存在该镜像,Docker 会从镜像仓库下载该镜像。

    列出镜像

    概述

    要想列出已经下载下来的镜像,可以使用docker images命令。

    root@suoron:~# docker images
    REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
    hello-world         latest              fce289e99eb9        7 weeks ago         1.84kB
    <none>               <none>              00285df0df87        5 days ago          342 MB
    

    列表包含了仓库名标签镜像 ID创建时间以及所占用的空间

    其中仓库名、标签在之前的基础概念章节已经介绍过了。镜像 ID则是镜像的唯一标识,一个镜像可以对应多个标签。因此,在上面的例子中,我们可以看到ubuntu:16.04和?ubuntu:latest?拥有相同的 ID,因为它们对应的是同一个镜像。

    镜像体积

    如果仔细观察,会注意到,这里标识的所占用空间和在 Docker Hub 上看到的镜像大小不同。比如,ubuntu:16.04镜像大小,在这里是127 MB,但是在 Docker Hub 显示的却是50 MB。这是因为 Docker Hub 中显示的体积是压缩后的体积。在镜像下载和上传过程中镜像是保持着压缩状态的,因此 Docker Hub 所显示的大小是网络传输中更关心的流量大小。而docker image ls显示的是镜像下载到本地后,展开的大小,准确说,是展开后的各层所占空间的总和,因为镜像到本地后,查看空间的时候,更关心的是本地磁盘空间占用的大小。

    另外一个需要注意的问题是,docker image ls列表中的镜像体积总和并非是所有镜像实际硬盘消耗。由于 Docker 镜像是多层存储结构,并且可以继承、复用,因此不同镜像可能会因为使用相同的基础镜像,从而拥有共同的层。由于 Docker 使用 Union FS,相同的层只需要保存一份即可,因此实际镜像硬盘占用空间很可能要比这个列表镜像大小的总和要小的多。

    你可以通过以下命令来便捷的查看镜像、容器、数据卷所占用的空间。

    $ docker system df
    
    TYPE                TOTAL               ACTIVE              SIZE                RECLAIMABLE
    Images              24                  0                   1.992GB             1.992GB (100%)
    Containers          1                   0                   62.82MB             62.82MB (100%)
    Local Volumes       9                   0                   652.2MB             652.2MB (100%)
    Build Cache                                                 0B                  0B
    

    虚悬镜像

    上面的镜像列表中,还可以看到一个特殊的镜像,这个镜像既没有仓库名,也没有标签,均为<none>。:

    <none>               <none>              00285df0df87        5 days ago          342 MB
    

    这个镜像原本是有镜像名和标签的,原来为mongo:3.2,随着官方镜像维护,发布了新版本后,重新docker pull mongo:3.2时,mongo:3.2这个镜像名被转移到了新下载的镜像身上,而旧的镜像上的这个名称则被取消,从而成为了<none>。除了docker pull可能导致这种情况,docker build也同样可以导致这种现象。由于新旧镜像同名,旧镜像名称被取消,从而出现仓库名、标签均为<none>的镜像。这类无标签镜像也被称为虚悬镜像(dangling image),可以用下面的命令专门显示这类镜像:

    $ docker image ls -f dangling=true
    REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
    <none>              <none>              00285df0df87        5 days ago          342 MB
    

    一般来说,虚悬镜像已经失去了存在的价值,是可以随意删除的,可以用下面的命令删除。

    $ docker image prune
    

    中间层镜像

    为了加速镜像构建、重复利用资源,Docker 会利用中间层镜像。所以在使用一段时间后,可能会看到一些依赖的中间层镜像。默认的docker image ls列表中只会显示顶层镜像,如果希望显示包括中间层镜像在内的所有镜像的话,需要加-a参数。

    $ docker image ls -a
    

    这样会看到很多无标签的镜像,与之前的虚悬镜像不同,这些无标签的镜像很多都是中间层镜像,是其它镜像所依赖的镜像。这些无标签镜像不应该删除,否则会导致上层镜像因为依赖丢失而出错。实际上,这些镜像也没必要删除,因为之前说过,相同的层只会存一遍,而这些镜像是别的镜像的依赖,因此并不会因为它们被列出来而多存了一份,无论如何你也会需要它们。只要删除那些依赖它们的镜像后,这些依赖的中间层镜像也会被连带删除。

    列出部分镜像

    不加任何参数的情况下,docker image ls会列出所有顶级镜像,但是有时候我们只希望列出部分镜像。docker image ls有好几个参数可以帮助做到这个事情。

    根据仓库名列出镜像

    $ docker image ls ubuntu
    REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
    ubuntu              16.04               f753707788c5        4 weeks ago         127 MB
    ubuntu              latest              f753707788c5        4 weeks ago         127 MB
    ubuntu              14.04               1e0c3dd64ccd        4 weeks ago         188 MB
    

    列出特定的某个镜像,也就是说指定仓库名和标签

    $ docker image ls ubuntu:16.04
    REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
    ubuntu              16.04               f753707788c5        4 weeks ago         127 MB
    
    

    除此以外,docker image ls还支持强大的过滤器参数--filter,或者简写-f。之前我们已经看到了使用过滤器来列出虚悬镜像的用法,它还有更多的用法。比如,我们希望看到在mongo:3.2之后建立的镜像,可以用下面的命令:

    $ docker image ls -f since=mongo:3.2
    REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
    redis               latest              5f515359c7f8        5 days ago          183 MB
    nginx               latest              05a60462f8ba        5 days ago          181 MB
    

    想查看某个位置之前的镜像也可以,只需要把since换成before即可。
    此外,如果镜像构建时,定义了LABEL,还可以通过LABEL来过滤。

    $ docker image ls -f label=com.example.version=0.1
    ...
    

    以特定格式显示

    默认情况下,docker image ls会输出一个完整的表格,但是我们并非所有时候都会需要这些内容。比如,刚才删除虚悬镜像的时候,我们需要利用docker image ls把所有的虚悬镜像的 ID 列出来,然后才可以交给docker image rm命令作为参数来删除指定的这些镜像,这个时候就用到了-q参数。

    $ docker image ls -q
    5f515359c7f8
    05a60462f8ba
    fe9198c04d62
    00285df0df87
    f753707788c5
    f753707788c5
    1e0c3dd64ccd
    

    --filter配合-q产生出指定范围的 ID 列表,然后送给另一个docker命令作为参数,从而针对这组实体成批的进行某种操作的做法在 Docker 命令行使用过程中非常常见,不仅仅是镜像,将来我们会在各个命令中看到这类搭配以完成很强大的功能。因此每次在文档看到过滤器后,可以多注意一下它们的用法。

    另外一些时候,我们可能只是对表格的结构不满意,希望自己组织列;或者不希望有标题,这样方便其它程序解析结果等,这就用到了 Go 的模板语法

    比如,下面的命令会直接列出镜像结果,并且只包含镜像ID和仓库名:

    $ docker image ls --format "{{.ID}}: {{.Repository}}"
    5f515359c7f8: redis
    05a60462f8ba: nginx
    fe9198c04d62: mongo
    00285df0df87: <none>
    f753707788c5: ubuntu
    f753707788c5: ubuntu
    1e0c3dd64ccd: ubuntu
    

    或者打算以表格等距显示,并且有标题行,和默认一样,不过自己定义列:

    $ docker image ls --format "table {{.ID}}\t{{.Repository}}\t{{.Tag}}"
    IMAGE ID            REPOSITORY          TAG
    5f515359c7f8        redis               latest
    05a60462f8ba        nginx               latest
    fe9198c04d62        mongo               3.2
    00285df0df87        <none>              <none>
    f753707788c5        ubuntu              16.04
    f753707788c5        ubuntu              latest
    1e0c3dd64ccd        ubuntu              14.04
    

    删除本地镜像

    概述

    如果要删除本地的镜像,可以使用docker image rm命令,其格式为:

    $ docker image rm [选项] <镜像1> [<镜像2> ...]
    

    用 ID、镜像名、摘要删除镜像

    其中,<镜像>可以是镜像短 ID镜像长 ID镜像名或者镜像摘要

    比如我们有这么一些镜像:

    $ docker image ls
    REPOSITORY                  TAG                 IMAGE ID            CREATED             SIZE
    centos                      latest              0584b3d2cf6d        3 weeks ago         196.5 MB
    redis                       alpine              501ad78535f0        3 weeks ago         21.03 MB
    docker                      latest              cf693ec9b5c7        3 weeks ago         105.1 MB
    nginx                       latest              e43d811ce2f4        5 weeks ago         181.5 MB
    

    我们可以用镜像的完整 ID,也称为长 ID,来删除镜像。使用脚本的时候可能会用长 ID,但是人工输入就太累了,所以更多的时候是用短 ID来删除镜像。docker image ls默认列出的就已经是短 ID 了,一般取前3个字符以上,只要足够区分于别的镜像就可以了。

    比如这里,如果我们要删除redis:alpine镜像,可以执行:

    $ docker image rm 501
    Untagged: redis:alpine
    Untagged: redis@sha256:f1ed3708f538b537eb9c2a7dd50dc90a706f7debd7e1196c9264edeea521a86d
    Deleted: sha256:501ad78535f015d88872e13fa87a828425117e3d28075d0c117932b05bf189b7
    Deleted: sha256:96167737e29ca8e9d74982ef2a0dda76ed7b430da55e321c071f0dbff8c2899b
    Deleted: sha256:32770d1dcf835f192cafd6b9263b7b597a1778a403a109e2cc2ee866f74adf23
    Deleted: sha256:127227698ad74a5846ff5153475e03439d96d4b1c7f2a449c7a826ef74a2d2fa
    Deleted: sha256:1333ecc582459bac54e1437335c0816bc17634e131ea0cc48daa27d32c75eab3
    Deleted: sha256:4fc455b921edf9c4aea207c51ab39b10b06540c8b4825ba57b3feed1668fa7c7
    

    我们也可以用镜像名,也就是<仓库名>:<标签>,来删除镜像。

    $ docker image rm centos
    Untagged: centos:latest
    Untagged: centos@sha256:b2f9d1c0ff5f87a4743104d099a3d561002ac500db1b9bfa02a783a46e0d366c
    Deleted: sha256:0584b3d2cf6d235ee310cf14b54667d889887b838d3f3d3033acd70fc3c48b8a
    Deleted: sha256:97ca462ad9eeae25941546209454496e1d66749d53dfa2ee32bf1faabd239d38
    

    当然,更精确的是使用镜像摘要删除镜像。

    $ docker image ls --digests
    REPOSITORY                  TAG                 DIGEST                                                                    IMAGE ID            CREATED             SIZE
    node                        slim                sha256:b4f0e0bdeb578043c1ea6862f0d40cc4afe32a4a582f3be235a3b164422be228   6e0c4c8e3913        3 weeks ago         214 MB
    
    $ docker image rm node@sha256:b4f0e0bdeb578043c1ea6862f0d40cc4afe32a4a582f3be235a3b164422be228
    Untagged: node@sha256:b4f0e0bdeb578043c1ea6862f0d40cc4afe32a4a582f3be235a3b164422be228
    

    Untagged 和 Deleted

    如果观察上面这几个命令的运行输出信息的话,你会注意到删除行为分为两类,一类是Untagged,另一类是Deleted。我们之前介绍过,镜像的唯一标识是其 ID 和摘要,而一个镜像可以有多个标签。

    因此当我们使用上面命令删除镜像的时候,实际上是在要求删除某个标签的镜像。所以首先需要做的是将满足我们要求的所有镜像标签都取消,这就是我们看到的Untagged的信息。因为一个镜像可以对应多个标签,因此当我们删除了所指定的标签后,可能还有别的标签指向了这个镜像,如果是这种情况,那么Delete行为就不会发生。所以并非所有的docker image rm都会产生删除镜像的行为,有可能仅仅是取消了某个标签而已。

    当该镜像所有的标签都被取消了,该镜像很可能会失去了存在的意义,因此会触发删除行为。镜像是多层存储结构,因此在删除的时候也是从上层向基础层方向依次进行判断删除。镜像的多层结构让镜像复用变动非常容易,因此很有可能某个其它镜像正依赖于当前镜像的某一层。这种情况,依旧不会触发删除该层的行为。直到没有任何层依赖当前层时,才会真实的删除当前层。这就是为什么,有时候会奇怪,为什么明明没有别的标签指向这个镜像,但是它还是存在的原因,也是为什么有时候会发现所删除的层数和自己docker pull看到的层数不一样的源。

    除了镜像依赖以外,还需要注意的是容器对镜像的依赖。如果有用这个镜像启动的容器存在(即使容器没有运行),那么同样不可以删除这个镜像。之前讲过,容器是以镜像为基础,再加一层容器存储层,组成这样的多层存储结构去运行的。因此该镜像如果被这个容器所依赖的,那么删除必然会导致故障。如果这些容器是不需要的,应该先将它们删除,然后再来删除镜像。

    用 docker image ls 命令来配合

    像其它可以承接多个实体的命令一样,可以使用docker image ls -q来配合使用docker image rm,这样可以成批的删除希望删除的镜像。我们在“镜像列表”章节介绍过很多过滤镜像列表的方式都可以拿过来使用。

    比如,我们需要删除所有仓库名为redis的镜像:

    $ docker image rm $(docker image ls -q redis)
    

    或者删除所有在mongo:3.2之前的镜像:

    $ docker image rm $(docker image ls -q -f before=mongo:3.2)
    

    充分利用你的想象力和 Linux 命令行的强大,你可以完成很多非常赞的功能。

    CentOS/RHEL 的用户需要注意的事项

    在 Ubuntu/Debian 上有UnionFS可以使用,如aufs或者overlay2,而 CentOS 和 RHEL 的内核中没有相关驱动。因此对于这类系统,一般使用devicemapper驱动利用 LVM 的一些机制来模拟分层存储。这样的做法除了性能比较差外,稳定性一般也不好,而且配置相对复杂。Docker 安装在 CentOS/RHEL 上后,会默认选择devicemapper,但是为了简化配置,其devicemapper是跑在一个稀疏文件模拟的块设备上,也被称为loop-lvm。这样的选择是因为不需要额外配置就可以运行 Docker,这是自动配置唯一能做到的事情。但是loop-lvm的做法非常不好,其稳定性、性能更差,无论是日志还是docker info中都会看到警告信息。官方文档有明确的文章讲解了如何配置块设备给devicemapper驱动做存储层的做法,这类做法也被称为配置direct-lvm

    除了前面说到的问题外,devicemapper+loop-lvm还有一个缺陷,因为它是稀疏文件,所以它会不断增长。用户在使用过程中会注意到/var/lib/docker/devicemapper/devicemapper/data不断增长,而且无法控制。很多人会希望删除镜像或者可以解决这个问题,结果发现效果并不明显。原因就是这个稀疏文件的空间释放后基本不进行垃圾回收的问题。因此往往会出现即使删除了文件内容,空间却无法回收,随着使用这个稀疏文件一直在不断增长。

    所以对于 CentOS/RHEL 的用户来说,在没有办法使用UnionFS的情况下,一定要配置direct-lvmdevicemapper,无论是为了性能、稳定性还是空间利用率。

    或许有人注意到了 CentOS 7 中存在被 backports 回来的overlay驱动,不过 CentOS 里的这个驱动达不到生产环境使用的稳定程度,所以不推荐使用。

    使用 Dockerfile 定制镜像

    Dockerfile 定制镜像

    从刚才的docker commit的学习中,我们可以了解到,镜像的定制实际上就是定制每一层所添加的配置、文件。如果我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本,用这个脚本来构建、定制镜像,那么之前提及的无法重复的问题、镜像构建透明性的问题、体积的问题就都会解决。这个脚本就是 Dockerfile。

    Dockerfile 是一个文本文件,其内包含了一条条的指令(Instruction),每一条指令构建一层,因此每一条指令的内容,就是描述该层应当如何构建。

    还以之前定制nginx镜像为例,这次我们使用 Dockerfile 来定制。

    在一个空白目录中,建立一个文本文件,并命名为Dockerfile

    $ mkdir mynginx
    $ cd mynginx
    $ touch Dockerfile
    

    其内容为:

    FROM nginx
    RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
    

    这个 Dockerfile 很简单,一共就两行。涉及到了两条指令,FROMRUN

    FROM 指定基础镜像

    所谓定制镜像,那一定是以一个镜像为基础,在其上进行定制。就像我们之前运行了一个nginx镜像的容器,再进行修改一样,基础镜像是必须指定的。而FROM就是指定基础镜像,因此一个DockerfileFROM是必备的指令,并且必须是第一条指令。

    Docker Store 上有非常多的高质量的官方镜像,有可以直接拿来使用的服务类的镜像,如 nginxredismongomysqlhttpdphptomcat 等;也有一些方便开发、构建、运行各种语言应用的镜像,如 nodeopenjdkpythonrubygolang 等。可以在其中寻找一个最符合我们最终目标的镜像为基础镜像进行定制。

    如果没有找到对应服务的镜像,官方镜像中还提供了一些更为基础的操作系统镜像,如 ubuntudebiancentosfedoraalpine 等,这些操作系统的软件库为我们提供了更广阔的扩展空间。

    除了选择现有镜像为基础镜像外,Docker 还存在一个特殊的镜像,名为scratch。这个镜像是虚拟的概念,并不实际存在,它表示一个空白的镜像。

    FROM scratch
    ...
    

    如果你以scratch为基础镜像的话,意味着你不以任何镜像为基础,接下来所写的指令将作为镜像第一层开始存在。

    不以任何系统为基础,直接将可执行文件复制进镜像的做法并不罕见,比如 swarmcoreos/etcd。对于 Linux 下静态编译的程序来说,并不需要有操作系统提供运行时支持,所需的一切库都已经在可执行文件里了,因此直接FROM scratch会让镜像体积更加小巧。使用 Go 语言 开发的应用很多会使用这种方式来制作镜像,这也是为什么有人认为 Go 是特别适合容器微服务架构的语言的原因之一。

    RUN 执行命令

    RUN指令是用来执行命令行命令的。由于命令行的强大能力,RUN指令在定制镜像时是最常用的指令之一。其格式有两种:

    • shell格式:RUN <命令>,就像直接在命令行中输入的命令一样。刚才写的 Dockerfile 中的RUN指令就是这种格式。
    RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
    
    • exec 格式:RUN ["可执行文件", "参数1", "参数2"],这更像是函数调用中的格式。

    既然RUN就像 Shell 脚本一样可以执行命令,那么我们是否就可以像 Shell 脚本一样把每个命令对应一个 RUN 呢?比如这样:

    FROM debian:jessie
    
    RUN apt-get update
    RUN apt-get install -y gcc libc6-dev make
    RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.tar.gz"
    RUN mkdir -p /usr/src/redis
    RUN tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1
    RUN make -C /usr/src/redis
    RUN make -C /usr/src/redis install
    

    之前说过,Dockerfile 中每一个指令都会建立一层,RUN也不例外。每一个RUN的行为,就和刚才我们手工建立镜像的过程一样:新建立一层,在其上执行这些命令,执行结束后,commit这一层的修改,构成新的镜像。

    而上面的这种写法,创建了 7 层镜像。这是完全没有意义的,而且很多运行时不需要的东西,都被装进了镜像里,比如编译环境、更新的软件包等等。结果就是产生非常臃肿、非常多层的镜像,不仅仅增加了构建部署的时间,也很容易出错。 这是很多初学 Docker 的人常犯的一个错误。

    Union FS 是有最大层数限制的,比如 AUFS,曾经是最大不得超过 42 层,现在是不得超过 127 层。

    上面的Dockerfile正确的写法应该是这样:

    FROM debian:jessie
    
    RUN buildDeps='gcc libc6-dev make' \
        && apt-get update \
        && apt-get install -y $buildDeps \
        && wget -O redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.tar.gz" \
        && mkdir -p /usr/src/redis \
        && tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
        && make -C /usr/src/redis \
        && make -C /usr/src/redis install \
        && rm -rf /var/lib/apt/lists/* \
        && rm redis.tar.gz \
        && rm -r /usr/src/redis \
        && apt-get purge -y --auto-remove $buildDeps
    

    首先,之前所有的命令只有一个目的,就是编译、安装 redis 可执行文件。因此没有必要建立很多层,这只是一层的事情。因此,这里没有使用很多个RUN对一一对应不同的命令,而是仅仅使用一个RUN指令,并使用&&将各个所需命令串联起来。将之前的 7 层,简化为了 1 层。在撰写 Dockerfile 的时候,要经常提醒自己,这并不是在写 Shell 脚本,而是在定义每一层该如何构建。

    并且,这里为了格式化还进行了换行。Dockerfile 支持 Shell 类的行尾添加\的命令换行方式,以及行首#进行注释的格式。良好的格式,比如换行、缩进、注释等,会让维护、排障更为容易,这是一个比较好的习惯。

    此外,还可以看到这一组命令的最后添加了清理工作的命令,删除了为了编译构建所需要的软件,清理了所有下载、展开的文件,并且还清理了apt缓存文件。这是很重要的一步,我们之前说过,镜像是多层存储,每一层的东西并不会在下一层被删除,会一直跟随着镜像。因此镜像构建时,一定要确保每一层只添加真正需要添加的东西,任何无关的东西都应该清理掉。

    很多人初学 Docker 制作出了很臃肿的镜像的原因之一,就是忘记了每一层构建的最后一定要清理掉无关文件。

    构建镜像

    好了,让我们再回到之前定制的 nginx 镜像的 Dockerfile 来。现在我们明白了这个 Dockerfile 的内容,那么让我们来构建这个镜像吧。

    Dockerfile文件所在目录执行:

    $ docker build -t nginx:v3 .
    Sending build context to Docker daemon 2.048 kB
    Step 1 : FROM nginx
     ---> e43d811ce2f4
    Step 2 : RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
     ---> Running in 9cdc27646c7b
     ---> 44aa4490ce2c
    Removing intermediate container 9cdc27646c7b
    Successfully built 44aa4490ce2c
    
    

    从命令的输出结果中,我们可以清晰的看到镜像的构建过程。在Step 2中,如同我们之前所说的那样,RUN指令启动了一个容器9cdc27646c7b,执行了所要求的命令,并最后提交了这一层44aa4490ce2c,随后删除了所用到的这个容器9cdc27646c7b

    这里我们使用了docker build命令进行镜像构建。其格式为:

    docker build [选项] <上下文路径/URL/->
    

    在这里我们指定了最终镜像的名称-t nginx:v3,构建成功后,我们可以像之前运行nginx:v2那样来运行这个镜像,其结果会和nginx:v2一样。

    镜像构建上下文(Context)

    如果注意,会看到docker build命令最后有一个..表示当前目录,而Dockerfile就在当前目录,因此不少初学者以为这个路径是在指定Dockerfile所在路径,这么理解其实是不准确的。如果对应上面的命令格式,你可能会发现,这是在指定上下文路径。那么什么是上下文呢?

    首先我们要理解docker build的工作原理。Docker 在运行时分为 Docker 引擎(也就是服务端守护进程)和客户端工具。Docker 的引擎提供了一组 REST API,被称为 Docker Remote API,而如docker命令这样的客户端工具,则是通过这组 API 与 Docker 引擎交互,从而完成各种功能。因此,虽然表面上我们好像是在本机执行各种docker功能,但实际上,一切都是使用的远程调用形式在服务端(Docker 引擎)完成。也因为这种 C/S 设计,让我们操作远程服务器的 Docker 引擎变得轻而易举。

    当我们进行镜像构建的时候,并非所有定制都会通过RUN指令完成,经常会需要将一些本地文件复制进镜像,比如通过COPY指令、ADD指令等。而docker build命令构建镜像,其实并非在本地构建,而是在服务端,也就是 Docker 引擎中构建的。那么在这种客户端/服务端的架构中,如何才能让服务端获得本地文件呢?

    这就引入了上下文的概念。当构建的时候,用户会指定构建镜像上下文的路径,docker build命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。

    如果在Dockerfile中这么写:

    COPY ./package.json /app/
    

    这并不是要复制执行docker build命令所在的目录下的package.json,也不是复制Dockerfile所在目录下的package.json,而是复制上下文(context)目录下的package.json

    因此,COPY这类指令中的源文件的路径都是相对路径。这也是初学者经常会问的为什么COPY ../package.json /app或者COPY /opt/xxxx /app无法工作的原因,因为这些路径已经超出了上下文的范围,Docker 引擎无法获得这些位置的文件。如果真的需要那些文件,应该将它们复制到上下文目录中去。

    现在就可以理解刚才的命令docker build -t nginx:v3 .中的这个.,实际上是在指定上下文的目录,docker build命令会将该目录下的内容打包交给 Docker 引擎以帮助构建镜像。

    如果观察docker build输出,我们其实已经看到了这个发送上下文的过程:

    $ docker build -t nginx:v3 .
    Sending build context to Docker daemon 2.048 kB
    ...
    

    理解构建上下文对于镜像构建是很重要的,避免犯一些不应该的错误。比如有些初学者在发现COPY /opt/xxxx /app不工作后,于是干脆将Dockerfile放到了硬盘根目录去构建,结果发现docker build执行后,在发送一个几十 GB 的东西,极为缓慢而且很容易构建失败。那是因为这种做法是在让docker build打包整个硬盘,这显然是使用错误。

    一般来说,应该会将Dockerfile置于一个空目录下,或者项目根目录下。如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给 Docker 引擎,那么可以用.gitignore一样的语法写一个.dockerignore,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的。

    那么为什么会有人误以为.是指定Dockerfile所在目录呢?这是因为在默认情况下,如果不额外指定Dockerfile的话,会将上下文目录下的名为Dockerfile的文件作为 Dockerfile。

    这只是默认行为,实际上Dockerfile的文件名并不要求必须为Dockerfile,而且并不要求必须位于上下文目录中,比如可以用-f ../Dockerfile.php参数指定某个文件作为Dockerfile

    当然,一般大家习惯性的会使用默认的文件名Dockerfile,以及会将其置于镜像构建上下文目录中。

    其它docker build的用法

    直接用 Git repo 进行构建

    或许你已经注意到了,docker build还支持从 URL 构建,比如可以直接从 Git repo 中构建:

    $ docker build https://github.com/twang2218/gitlab-ce-zh.git#:8.14
    docker build https://github.com/twang2218/gitlab-ce-zh.git\#:8.14
    Sending build context to Docker daemon 2.048 kB
    Step 1 : FROM gitlab/gitlab-ce:8.14.0-ce.0
    8.14.0-ce.0: Pulling from gitlab/gitlab-ce
    aed15891ba52: Already exists
    773ae8583d14: Already exists
    ...
    

    这行命令指定了构建所需的 Git repo,并且指定默认的master分支,构建目录为/8.14/,然后 Docker 就会自己去git clone这个项目、切换到指定分支、并进入到指定目录后开始构建。

    用给定的 tar 压缩包构建

    $ docker build http://server/context.tar.gz
    

    如果所给出的 URL 不是个 Git repo,而是个tar压缩包,那么 Docker 引擎会下载这个包,并自动解压缩,以其作为上下文,开始构建。

    从标准输入中读取 Dockerfile 进行构建

    docker build - < Dockerfile
    

    cat Dockerfile | docker build -
    

    如果标准输入传入的是文本文件,则将其视为Dockerfile,并开始构建。这种形式由于直接从标准输入中读取 Dockerfile 的内容,它没有上下文,因此不可以像其他方法那样可以将本地文件COPY进镜像之类的事情。

    从标准输入中读取上下文压缩包进行构建

    $ docker build - < context.tar.gz
    

    如果发现标准输入的文件格式是?gzipbzip2?以及?xz?的话,将会使其为上下文压缩包,直接将其展开,将里面视为上下文,并开始构建。

    Dockerfile 指令

    我们已经介绍了FROMRUN,还提及了COPY,ADD,其实 Dockerfile 功能很强大,它提供了十多个指令。下面我们继续讲解其他的指令。

    COPY

    格式:

    • COPY <源路径>... <目标路径>
    • COPY ["<源路径1>",... "<目标路径>"]

    RUN指令一样,也有两种格式,一种类似于命令行,一种类似于函数调用。

    COPY指令将从构建上下文目录中<源路径>的文件/目录复制到新的一层的镜像内的<目标路径>位置。比如:

    COPY package.json /usr/src/app/
    

    <源路径>可以是多个,甚至可以是通配符,其通配符规则要满足 Go 的 filepath.Match 规则,如:

    COPY hom* /mydir/
    COPY hom.txt /mydir/
    

    <目标路径>可以是容器内的绝对路径,也可以是相对于工作目录的相对路径(工作目录可以用WORKDIR指令来指定)。目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。

    此外,还需要注意一点,使用COPY指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。这个特性对于镜像定制很有用。特别是构建相关文件都在使用 Git 进行管理的时候。

    ADD

    ADD指令和COPY的格式和性质基本一致。但是在COPY基础上增加了一些功能。

    比如<源路径>可以是一个URL,这种情况下,Docker 引擎会试图去下载这个链接的文件放到<目标路径>去。下载后的文件权限自动设置为600,如果这并不是想要的权限,那么还需要增加额外的一层RUN进行权限调整,另外,如果下载的是个压缩包,需要解压缩,也一样还需要额外的一层RUN指令进行解压缩。所以不如直接使用RUN指令,然后使用wget或者curl工具下载,处理权限、解压缩、然后清理无用文件更合理。因此,这个功能其实并不实用,而且不推荐使用。

    如果<源路径>为一个tar压缩文件的话,压缩格式为gzip,bzip2以及xz的情况下,ADD指令将会自动解压缩这个压缩文件到<目标路径>去。

    在某些情况下,这个自动解压缩的功能非常有用,比如官方镜像ubuntu中:

    FROM scratch
    ADD ubuntu-xenial-core-cloudimg-amd64-root.tar.gz /
    ...
    

    但在某些情况下,如果我们真的是希望复制个压缩文件进去,而不解压缩,这时就不可以使用ADD命令了。

    在 Docker 官方的Dockerfile 最佳实践文档中要求,尽可能的使用COPY,因为COPY的语义很明确,就是复制文件而已,而ADD则包含了更复杂的功能,其行为也不一定很清晰。最适合使用ADD的场合,就是所提及的需要自动解压缩的场合。

    另外需要注意的是,ADD指令会令镜像构建缓存失效,从而可能会令镜像构建变得比较缓慢。

    因此在COPYADD指令中选择的时候,可以遵循这样的原则,所有的文件复制均使用COPY指令,仅在需要自动解压缩的场合使用ADD

    CMD

    CMD指令的格式和RUN相似,也是两种格式:

    • shell格式:CMD <命令>
    • exec格式:CMD ["可执行文件", "参数1", "参数2"...]
    • 参数列表格式:CMD ["参数1", "参数2"...]。在指定了ENTRYPOINT指令后,用CMD指定具体的参数。

    之前介绍容器的时候曾经说过,Docker 不是虚拟机,容器就是进程。既然是进程,那么在启动容器的时候,需要指定所运行的程序及参数。CMD指令就是用于指定默认的容器主进程的启动命令的。

    在运行时可以指定新的命令来替代镜像设置中的这个默认命令,比如,ubuntu镜像默认的CMD/bin/bash,如果我们直接docker run -it ubuntu的话,会直接进入bash。我们也可以在运行时指定运行别的命令,如docker run -it ubuntu cat /etc/os-release。这就是用cat /etc/os-release命令替换了默认的/bin/bash命令了,输出了系统版本信息。

    在指令格式上,一般推荐使用exec格式,这类格式在解析时会被解析为 JSON 数组,因此一定要使用双引号",而不要使用单引号。

    如果使用shell格式的话,实际的命令会被包装为sh -c的参数的形式进行执行。比如:

    CMD echo $HOME
    

    在实际执行中,会将其变更为:

    CMD [ "sh", "-c", "echo $HOME" ]
    

    这就是为什么我们可以使用环境变量的原因,因为这些环境变量会被 shell 进行解析处理。

    提到CMD就不得不提容器中应用在前台执行和后台执行的问题。这是初学者常出现的一个混淆。

    Docker 不是虚拟机,容器中的应用都应该以前台执行,而不是像虚拟机、物理机里面那样,用 upstart/systemd 去启动后台服务,容器内没有后台服务的概念。

    一些初学者将CMD写为:

    CMD service nginx start
    

    然后发现容器执行后就立即退出了。甚至在容器内去使用systemctl命令结果却发现根本执行不了。这就是因为没有搞明白前台、后台的概念,没有区分容器和虚拟机的差异,依旧在以传统虚拟机的角度去理解容器。

    对于容器而言,其启动程序就是容器应用进程,容器就是为了主进程而存在的,主进程退出,容器就失去了存在的意义,从而退出,其它辅助进程不是它需要关心的东西。

    而使用service nginx start命令,则是希望 upstart 来以后台守护进程形式启动nginx服务。而刚才说了CMD service nginx start会被理解为CMD [ "sh", "-c", "service nginx start"],因此主进程实际上是sh。那么当service nginx start命令结束后,sh也就结束了,sh作为主进程退出了,自然就会令容器退出。

    正确的做法是直接执行nginx可执行文件,并且要求以前台形式运行。比如:

    CMD ["nginx", "-g", "daemon off;"]
    
    ENTRYPOINT

    ENTRYPOINT的格式和RUN指令格式一样,分为exec格式和shell格式。

    ENTRYPOINT的目的和CMD一样,都是在指定容器启动程序及参数。ENTRYPOINT在运行时也可以替代,不过比CMD要略显繁琐,需要通过docker run的参数--entrypoint来指定。

    当指定了ENTRYPOINT后,CMD的含义就发生了改变,不再是直接的运行其命令,而是将CMD的内容作为参数传给ENTRYPOINT指令,换句话说实际执行时,将变为:

    <ENTRYPOINT> "<CMD>"
    

    那么有了CMD后,为什么还要有ENTRYPOINT呢?这种<ENTRYPOINT> "<CMD>"有什么好处么?让我们来看几个场景。

    场景一:让镜像变成像命令一样使用

    假设我们需要一个得知自己当前公网 IP 的镜像,那么可以先用CMD来实现:

    FROM ubuntu:16.04
    RUN apt-get update \
        && apt-get install -y curl \
        && rm -rf /var/lib/apt/lists/*
    CMD [ "curl", "-s", "https://ip.cn" ]
    

    假如我们使用docker build -t myip .来构建镜像的话,如果我们需要查询当前公网 IP,只需要执行:

    $ docker run myip
    当前 IP:61.148.226.66 来自:北京市 联通
    

    嗯,这么看起来好像可以直接把镜像当做命令使用了,不过命令总有参数,如果我们希望加参数呢?比如从上面的CMD中可以看到实质的命令是curl,那么如果我们希望显示 HTTP 头信息,就需要加上-i参数。那么我们可以直接加-i参数给docker run myip么?

    $ docker run myip -i
    docker: Error response from daemon: invalid header field value "oci runtime error: container_linux.go:247: starting container process caused \"exec: \\\"-i\\\": executable file not found in $PATH\"\n".
    

    我们可以看到可执行文件找不到的报错,executable file not found。之前我们说过,跟在镜像名后面的是command,运行时会替换CMD的默认值。因此这里的-i替换了原来的CMD,而不是添加在原来的curl -s http://ip.cn后面。而-i根本不是命令,所以自然找不到。

    那么如果我们希望加入-i这参数,我们就必须重新完整的输入这个命令:

    $ docker run myip curl -s http://ip.cn -i
    

    这显然不是很好的解决方案,而使用ENTRYPOINT就可以解决这个问题。现在我们重新用ENTRYPOINT来实现这个镜像:

    FROM ubuntu:16.04
    RUN apt-get update \
        && apt-get install -y curl \
        && rm -rf /var/lib/apt/lists/*
    ENTRYPOINT [ "curl", "-s", "http://ip.cn" ]
    

    这次我们再来尝试直接使用docker run myip -i

    $ docker run myip
    当前 IP:61.148.226.66 来自:北京市 联通
    
    $ docker run myip -i
    HTTP/1.1 200 OK
    Server: nginx/1.8.0
    Date: Tue, 22 Nov 2016 05:12:40 GMT
    Content-Type: text/html; charset=UTF-8
    Vary: Accept-Encoding
    X-Powered-By: PHP/5.6.24-1~dotdeb+7.1
    X-Cache: MISS from cache-2
    X-Cache-Lookup: MISS from cache-2:80
    X-Cache: MISS from proxy-2_6
    Transfer-Encoding: chunked
    Via: 1.1 cache-2:80, 1.1 proxy-2_6:8006
    Connection: keep-alive
    
    当前 IP:61.148.226.66 来自:北京市 联通
    

    可以看到,这次成功了。这是因为当存在ENTRYPOINT后,CMD的内容将会作为参数传给ENTRYPOINT,而这里-i就是新的CMD,因此会作为参数传给curl,从而达到了我们预期的效果。

    场景二:应用运行前的准备工作

    启动容器就是启动主进程,但有些时候,启动主进程前,需要一些准备工作。

    比如mysql类的数据库,可能需要一些数据库配置、初始化的工作,这些工作要在最终的 mysql 服务器运行之前解决。

    此外,可能希望避免使用root用户去启动服务,从而提高安全性,而在启动服务前还需要以root身份执行一些必要的准备工作,最后切换到服务用户身份启动服务。或者除了服务外,其它命令依旧可以使用root身份执行,方便调试等。

    这些准备工作是和容器CMD无关的,无论CMD为什么,都需要事先进行一个预处理的工作。这种情况下,可以写一个脚本,然后放入ENTRYPOINT中去执行,而这个脚本会将接到的参数(也就是<CMD>)作为命令,在脚本最后执行。比如官方镜像redis中就是这么做的:

    FROM alpine:3.4
    ...
    RUN addgroup -S redis && adduser -S -G redis redis
    ...
    ENTRYPOINT ["docker-entrypoint.sh"]
    
    EXPOSE 6379
    CMD [ "redis-server" ]
    

    可以看到其中为了 redis 服务创建了 redis 用户,并在最后指定了ENTRYPOINTdocker-entrypoint.sh脚本。

    #!/bin/sh
    ...
    # allow the container to be started with `--user`
    if [ "$1" = 'redis-server' -a "$(id -u)" = '0' ]; then
        chown -R redis .
        exec su-exec redis "$0" "$@"
    fi
    
    exec "$@"
    

    该脚本的内容就是根据CMD的内容来判断,如果是redis-server的话,则切换到redis用户身份启动服务器,否则依旧使用root身份执行。比如:

    $ docker run -it redis id
    uid=0(root) gid=0(root) groups=0(root)
    
    ENV

    格式有两种:

    • ENV <key> <value>
    • ENV <key1>=<value1> <key2>=<value2>...

    这个指令很简单,就是设置环境变量而已,无论是后面的其它指令,如RUN,还是运行时的应用,都可以直接使用这里定义的环境变量。

    ENV VERSION=1.0 DEBUG=on \
        NAME="Happy Feet"
    

    这个例子中演示了如何换行,以及对含有空格的值用双引号括起来的办法,这和 Shell 下的行为是一致的。

    定义了环境变量,那么在后续的指令中,就可以使用这个环境变量。比如在官方node镜像Dockerfile中,就有类似这样的代码:

    ENV NODE_VERSION 7.2.0
    
    RUN curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" \
      && curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \
      && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \
      && grep " node-v$NODE_VERSION-linux-x64.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
      && tar -xJf "node-v$NODE_VERSION-linux-x64.tar.xz" -C /usr/local --strip-components=1 \
      && rm "node-v$NODE_VERSION-linux-x64.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt \
      && ln -s /usr/local/bin/node /usr/local/bin/nodejs
    

    在这里先定义了环境变量NODE_VERSION,其后的RUN这层里,多次使用$NODE_VERSION来进行操作定制。可以看到,将来升级镜像构建版本的时候,只需要更新7.2.0即可,Dockerfile构建维护变得更轻松了。

    下列指令可以支持环境变量展开:ADDCOPYENVEXPOSELABELUSERWORKDIRVOLUMESTOPSIGNALONBUILD

    可以从这个指令列表里感觉到,环境变量可以使用的地方很多,很强大。通过环境变量,我们可以让一份Dockerfile制作更多的镜像,只需使用不同的环境变量即可。

    VOLUME

    格式为:

    • VOLUME ["<路径1>", "<路径2>"...]
    • VOLUME <路径>

    之前我们说过,容器运行时应该尽量保持容器存储层不发生写操作,对于数据库类需要保存动态数据的应用,其数据库文件应该保存于卷(volume)中,后面的章节我们会进一步介绍 Docker 卷的概念。为了防止运行时用户忘记将动态文件所保存目录挂载为卷,在Dockerfile中,我们可以事先指定某些目录挂载为匿名卷,这样在运行时如果用户不指定挂载,其应用也可以正常运行,不会向容器存储层写入大量数据。

    VOLUME /data
    

    这里的/data目录就会在运行时自动挂载为匿名卷,任何向/data中写入的信息都不会记录进容器存储层,从而保证了容器存储层的无状态化。当然,运行时可以覆盖这个挂载设置。比如:

    docker run -d -v mydata:/data xxxx
    

    在这行命令中,就使用了mydata这个命名卷挂载到了/data这个位置,替代了Dockerfile中定义的匿名卷的挂载配置。

    EXPOSE

    格式为EXPOSE <端口1> [<端口2>...]

    EXPOSE指令是声明运行时容器提供服务端口,这只是一个声明,在运行时并不会因为这个声明应用就会开启这个端口的服务。在 Dockerfile 中写入这样的声明有两个好处,一个是帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射;另一个用处则是在运行时使用随机端口映射时,也就是docker run -P时,会自动随机映射EXPOSE的端口。

    此外,在早期 Docker 版本中还有一个特殊的用处。以前所有容器都运行于默认桥接网络中,因此所有容器互相之间都可以直接访问,这样存在一定的安全性问题。于是有了一个 Docker 引擎参数--icc=false,当指定该参数后,容器间将默认无法互访,除非互相间使用了--links参数的容器才可以互通,并且只有镜像中EXPOSE所声明的端口才可以被访问。这个--icc=false的用法,在引入了docker network后已经基本不用了,通过自定义网络可以很轻松的实现容器间的互联与隔离。

    要将EXPOSE和在运行时使用-p <宿主端口>:<容器端口>区分开来。-p,是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问,而EXPOSE仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射。

    WORKDIR

    格式为WORKDIR <工作目录路径>

    使用WORKDIR指令可以来指定工作目录(或者称为当前目录),以后各层的当前目录就被改为指定的目录,如该目录不存在,WORKDIR会帮你建立目录。

    之前提到一些初学者常犯的错误是把Dockerfile等同于 Shell 脚本来书写,这种错误的理解还可能会导致出现下面这样的错误:

    RUN cd /app
    RUN echo "hello" > world.txt
    

    如果将这个Dockerfile进行构建镜像运行后,会发现找不到/app/world.txt文件,或者其内容不是hello。原因其实很简单,在 Shell 中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响后一个命令;而在Dockerfile中,这两行RUN命令的执行环境根本不同,是两个完全不同的容器。这就是对Dockerfile构建分层存储的概念不了解所导致的错误。

    之前说过每一个RUN都是启动一个容器、执行命令、然后提交存储层文件变更。第一层RUN cd /app的执行仅仅是当前进程的工作目录变更,一个内存上的变化而已,其结果不会造成任何文件变更。而到第二层的时候,启动的是一个全新的容器,跟第一层的容器更完全没关系,自然不可能继承前一层构建过程中的内存变化。

    因此如果需要改变以后各层的工作目录的位置,那么应该使用WORKDIR指令。

    相关文章

      网友评论

        本文标题:Docker 镜像制作

        本文链接:https://www.haomeiwen.com/subject/kqeaoqtx.html