Dockerfile 命令详解及最佳实践

Dockerfile 命令详解

  • FROM 指定基础镜像(必选)

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

    Docker hub上有非常多的高质量的官方镜像,有可以直接拿来使用的服务类的镜像,如 nginxredismongomysqlhttpdphptomcat 等;也有一些方便开发、构建、运行各种语言应用的镜像,如 nodeopenjdkpythonrubygolang 等。

    如果没有找到对应服务的镜像,官方镜像中还提供了一些更为基础的操作系统镜像,如 ubuntudebiancentosfedoraalpine 等。

    FROM命令语法:

    FROM <image>:<tag>
    

    如果tag没有选择,默认为latest

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

    FROM scratch
    ...
    

    如果你以scratch为基础镜像的话,意味着你不以任何镜像为基础,接下来所写的指令将作为镜像第一层开始存在。有的同学可能感觉很奇怪,没有任何基础镜像,我怎么去执行我的程序呢,其实对于 Linux 下静态编译的程序来说,并不需要有操作系统提供运行时支持,所需的一切库都已经在可执行文件里了,因此直接FROM scratch会让镜像体积更加小巧。使用 Go 语言 开发的应用很多会使用这种方式来制作镜像,这也是为什么有人认为 Go 是特别适合容器微服务架构的语言的原因之一。

    下面我们以一个go语言的helloworld为例:

    FROM scratch
    
    COPY helloworld /
    COPY hellowold2 /
    CMD ["./helloworld"]
    

    helloworld文件就是个go语言编译出来的可执行程序,只会打印出hello world

    docker build -t hello-go:v1 .
    
    docker run hello-go:v1
    
  • LABEL 设置镜像元数据

    使用LABEL指令,可以为镜像设置元数据,例如镜像创建者或者镜像说明。旧版的Dockerfile语法使用MAINTAINER指令指定镜像创建者,但是它已经被弃用了。

    LABEL命令语法:

    LABEL <key>=<value> <key>=<value> <key>=<value> ...
    

    一个Dockerfile种可以有多个LABEL,如下:

    LABEL maintainer="cerberus43@gmail.com"
    LABEL version="1.0"
    LABEL description="This is a test dockerfile"
    

    但是并不建议这样写,最好就写成一行,如太长需要换行的话则使用\符号。

    如下:

    LABEL maintainer="cerberus43@gmail.com" \
    version="1.0" \
    description="This is a test dockerfile"
    

    说明:LABEL会继承基础镜像种的LABEL,如遇到key相同,则值覆盖。

  • RUN 运行命令

    使用RUN指令,可以用来执行命令行的命令。

    RUN命令有两种语法:

    • shell格式:

      在linux操作系统上默认 /bin/sh -c

      RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html
      
    • exec格式:

      RUN ["可执行文件", "参数1", "参数2"]
      

    注意:多行命令不要写多个RUN,原因是Dockerfile中每一个指令都会建立一层,多少个RUN就构建了多少层镜像,会造成镜像的臃肿、多层,不仅仅增加了构件部署的时间,还容易出错。

    下面是一个使用apt-get安装多个包的例子:

    RUN apt-get update && apt-get install -y \  
     bzr \
     cvs \
     git \
     mercurial \
     subversion
    
  • COPY 复制文件

    COPY命令有两种语法格式:

    • COPY [--chown=<user>:<group>] <源路径>... <目标路径>
      
    • COPY [--chown=<user>:<group>] ["<源路径1>",... "<目标路径>"]
      

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

    说明:

    • 目标路径可以是容器内的绝对路径,也可以是相对于工作目录的相对路径(工作目录可以用WORKDIR指令来指定)。
    • 目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。
    • 使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。

    复制单个文件示例:

    COPY package.json /usr/src/app/
    

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

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

    复制src目录下内容到 /tmp 目录下:

    COPY src/ /tmp
    

    复制多个目录下内容到 /tmp 目录下:

    COPY src1/ src2/ /tmp
    

    上面的命令只会将文件夹内容复制到镜像目录下,复制整个src目录到/tmp目录下,如果源目录名不存在将自动逐级创建:

    COPY src/ /tmp/src
    

    指定文件权限

    在使用该指令的时候还可以加上 –chown=: 选项来改变文件的所属用户及所属组。

    COPY --chown=devuser:devgroup files* /mydir/
    
  • ADD 更高级的复制文件

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

    • 解压压缩文件并把它们添加到镜像中:

      WORKDIR /app
      ADD nginx.tar.gz .
      
    • 从 url 拷贝文件到镜像中:

      ADD http://example.com/big.tar.xz /usr/src/things/
      RUN tar -xJf /usr/src/things/big.tar.xz -C /usr/src/things
      RUN make -C /usr/src/things all
      

      但是在Dockerfile 最佳实践官方文档中却强烈建议不要这么用!官方建议我们当需要从远程复制文件时,最好使用curl或wget命令来代替ADD命令。原因是,当使用ADD命令时,会创建更多的镜像层,当然镜像也会变的更大。

      RUN mkdir -p /usr/src/things \
          && curl -SL http://example.com/big.tar.xz \
          | tar -xJC /usr/src/things \
          && make -C /usr/src/things all
      

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

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

  • WORKDIR 指定工作目录

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

    语法格式为:

    WORKDIR <工作目录路径>
    
    FROM centos:7.2
    
    #创建/usr/local/tomcat目录
    RUN mkdir /usr/local/tomcat
    
    #定位到tomcat下载目录
    WORKDIR /usr/local/tomcat
    
    #wget tomcat到/usr/local/tomcat目录
    RUN wget http://mirrors.hust.edu.cn/apache/tomcat/tomcat-7/v7.0.86/bin/apache-tomcat-7.0.86.tar.gz
    
  • ENV 指定容器的环境变量

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

    语法格式有两种:

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

    定义了环境变量,那么在后续的指令中,就可以使用这个环境变量。比如在官方 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 构建维护变得更轻松了。

  • ARG 指定Dockerfile中的环境变量

    ARGARG定义的变量用于构建Docker镜像,在把Dockerfile构建成镜像后,ARG定义的变量便不在起作用;

    ENVENV定义的变量用于容器的环境变量,在Dockerfile里定义后,在容器的运行时是可以使用这个变量的;

    上面可能读起来比较绕,看下这个实例就明白了:

    ARG VAR_A=1
    ENV VAR_B ${VAR_A}
    

    通过构建镜像并启动容器后,查看环境变量如下:

    $ docker exec ContainerID env
    VAR_B=1
    

    从实例可看出,ARG定义的变量在Dockerfile中使用,构建完镜像后,就下岗;而ENV定义的变量会带入容器的环境变量。

    《Dockerfile 命令详解及最佳实践》

    通常可以把ARG与ENV结合使用:

    ARG buildtime_variable=default_value
    ENV env_var_name=$buildtime_variable 
    

    使用这种方式可以解决Dockerfile硬编码的问题,比如在微服务下很多服务的情况下,构建一个镜像修改一次Dockerfile,而使用这种方式Dockerfile是不变的,只需要在docker build的时候加上参数值就可以。

  • CMD 指定镜像启动时的命令

    首先我们看官网对CMD的定义:

    The main purpose of a CMD is to provide defaults for an executing container. These defaults can include an executable, or they can omit the executable, in which case you must specify an ENTRYPOINT instruction as well.
    

    意思是,CMD给出的是一个容器的默认的可执行体。也就是容器启动以后,默认的执行的命令。重点就是这个默认。意味着,如果docker run没有指定任何的执行命令或者Dockerfile里面也没有ENTRYPOINT,那么,就会使用CMD指定的默认的执行命令执行。同时也从侧面说明了ENTRYPOINT的含义,它才是真正的容器启动以后要执行命令。

    所以这句话就给出了CMD命令的一个角色定位,它主要作用是默认的容器启动执行命令。(注意不是“全部”作用)

    这也是为什么大多数网上博客论坛说的“CMD会被覆盖”,其实为什么会覆盖?因为CMD的角色定位就是默认,如果你不额外指定,那么就执行CMD的命令,否则呢?只要你指定了,那么就不会执行CMD,也就是CMD会被覆盖。

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

    明白了CMD命令的主要用途。下面就看看具体用法:

    The CMD instruction has three forms:
     
    CMD ["executable","param1","param2"] (exec form, this is the preferred form)	#exec格式,首选方法
    CMD ["param1","param2"] (as default parameters to ENTRYPOINT)	#为ENTRYPOINT传参用法
    CMD command param1 param2 (shell form)	#shell格式
    

    因为还没有讲ENTRYPOINT,所以先不用看第二种用法。

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

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

    CMD echo $HOME
    

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

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

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

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

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

    如有人会把写成这样:

    CMD service nginx start
    

    然后发现容器执行后就立即退出了。这就是因为没有搞明白前台、后台的概念,没有区分容器和 虚拟机的差异,依旧在以传统虚拟机的角度去理解容器。

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

    而使用 service nginx start 命令,则是希望以后台守护进程形式启动 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的定义:

    An ENTRYPOINT allows you to configure a container that will run as an executable.
    

    也就是说ENTRYPOINT才是正统地用于定义容器启动以后的执行体的,其实我们从名字也可以理解,这个是容器的“入口”。

    它有两种用法:

    ENTRYPOINT has two forms:
     
    ENTRYPOINT ["executable", "param1", "param2"] (exec form, preferred)	#exec格式,首选方法
    ENTRYPOINT command param1 param2 (shell form)	#shell格式
    

    先看exec命令行模式,也就是带中括号的。如果docker run命令后面有东西,那么后面的全部都会作为ENTRYPOINT的参数。如果docker run后面没有额外的东西,但是CMD有,那么CMD的全部内容会作为ENTRYPOINT的参数,这同时是CMD的第二种用法。这也是网上说的ENTRYPOINT不会被覆盖。当然如果要在docker run里面覆盖,也是有办法的,使用--entrypoint即可。

    可能光看文字有点迷糊,下面看个例子:

    FROM alpine
    
    ENTRYPOINT ["echo"]
    
    CMD ["CMD"]
    
    docker build -t entrypoint-test:v1 .
    
    #会打印出CMD中定义的输出“CMD”
    docker run --rm entrypoint-test:v1
    $CMD
    
    #会打印出docker run中传入的“docker run”覆盖CMD中的定义
    docker run --rm entrypoint-test:v1 docker run
    $docker run
    

    第二种是shell模式的。在这种模式下,任何docker runCMD的参数都无法被传入到ENTRYPOINT里。所以官网推荐第一种用法。

    FROM alpine
    
    ENTRYPOINT echo
    
    CMD ["CMD"]
    
    docker build -t entrypoint-test:v2 .
    
    #不会打印出CMD中定义的“CMD”
    docker run --rm entrypoint-test:v2
    $
    
    #不会打印出docker run中传入的“docker run”
    docker run --rm entrypoint-test:v2 docker run
    $
    

    最后总结下一般该怎么使用:

    一般还是会用ENTRYPOINT的中括号形式作为docker 容器启动以后的默认执行命令,里面放的是不变的部分,可变部分比如命令参数可以使用CMD的形式提供默认版本,也就是执行docker run里面没有任何参数时使用的默认参数。如果我们想用默认参数,就直接docker run,如果想用其他参数,就在docker run后面加想要的参数。

    ENTRYPOINT ["python3", "manage.py", "runserver"]
    
    CMD ["0.0.0.0:8000"]
    
  • EXPOSE 暴露端口

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

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

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

  • VOLUME 定义匿名卷

    VOLUME指令用于暴露任何数据库存储文件,配置文件,或容器创建的文件和目录。强烈建议使用 VOLUME来管理镜像中的可变部分和用户可以改变的部分。

    两种使用方法的格式为:

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

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

    VOLUME /data
    

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

  • ONBUILD

    ONBUILD指令可以为镜像添加触发器。其参数是任意一个Dockerfile 指令。

    当我们在一个Dockerfile文件中加上ONBUILD指令,该指令对利用该Dockerfile构建镜像(A镜像)不会产生实质性影响。

    但是当我们编写一个新的Dockerfile文件来基于A镜像构建一个镜像(比如为B镜像)时,这时构造A镜像的Dockerfile文件中的ONBUILD指令就生效了,在构建B镜像的过程中,首先会执行ONBUILD指令指定的指令,然后才会执行其它指令。

    需要注意的是,如果是再利用B镜像构造新的镜像时,那个ONBUILD指令就无效了,也就是说只能再构建子镜像中执行,对孙子镜像构建无效。其实想想是合理的,因为在构建子镜像中已经执行了,如果孙子镜像构建还要执行,相当于重复执行,这就有问题了。

    利用ONBUILD指令,实际上就是相当于创建一个模板镜像,后续可以根据该模板镜像创建特定的子镜像,需要在子镜像构建过程中执行的一些通用操作就可以在模板镜像对应的Dockerfile文件中用ONBUILD指令指定。 从而减少Dockerfile文件的重复内容编写。

    例如:

    先编写个onbuild-test:a镜像:

    FROM alpine
    
    LABEL maintainer="cerberus43@gmail.com"
    
    ONBUILD RUN echo "onbuild" >> test.txt
    
    CMD ["cat", "test.txt"]
    
    $docker build -t onbuild-test:a .
    
    $docker run --rm onbuild-test:a
    

    再编写个onbuild-test:b镜像:

    FROM onbuild-test:a
    
    $docker build -t onbuild-test:b .
    
    $docker run --rm onbuild-test:b
    

Dockerfile最佳实践:

官方原文:Dockerfile最佳实践

  • 容器应该是短暂的

    通过 Dockerfile 构建的镜像所启动的容器应该尽可能短暂(生命周期短)。「短暂」意味着可以停止和销毁容器,并且创建一个新容器并部署好所需的设置和配置工作量应该是极小的。我们可以查看下12 Factor(12要素)应用程序方法的进程部分,可以让我们理解这种无状态方式运行容器的动机。

  • 理解上下文context

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

    首先我们要理解 docker build 的工作原理。Docker 在运行时分为 Docker引擎(也就是服务端守护进程)和客户端工具。Docker 的引擎提供了一组 REST API,被称为 Docker Remote API,而如 docker 命令这样的客户端工具,则是通过这组 APIDocker 引擎交互,从而完成各种功能。因此,虽然表面上我们好像是在本机执行各种 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
    ...
    

    理解构建上下文对于镜像构建是很重要的。context过大会造成docker build很耗时,镜像过大则会造成docker pull/push性能变差以及运行时容器体积过大浪费空间资源。

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

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

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

  • 使用.dockerignore文件

    使用 Dockerfile 构建镜像时最好是将 Dockerfile 放置在一个新建的空目录下。然后将构建镜像所需要的文件添加到该目录中。为了提高构建镜像的效率,你可以在目录下新建一个.dockerignore文件来指定要忽略的文件和目录。.dockerignore 文件的排除模式语法和 Git.gitignore 文件相似。

  • 使用多段构建

    多阶段构建从Docker 17.05及更高版本的守护进程与客户端的新功能, 对于那些努力优化Dockerfile同时保持可阅读性和可维护性的人来说,多阶段构建是非常有用的。

    一个Dockerfile用于开发环境,其中包含构建应用程序所需的一切, 另一个精简版的Dockerfile,只包含你的应用程序及运行所需的内容,用于生产环境, 这种情况实际上非常普遍,这被称为”构建器模式”。维护两个Dockerfile并不理想。

    下面是一个Dockerfile.buildDockerfile的示例,采用上面的构建器模式:

    Dockerfile.build

    FROM golang:1.7.3
    WORKDIR /go/src/github.com/alexellis/href-counter/
    RUN go get -d -v golang.org/x/net/html
    COPY app.go .
    RUN go get -d -v golang.org/x/net/html \
      && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
    

    Dockerfile

    FROM alpine:latest
    RUN apk --no-cache add ca-certificates
    WORKDIR /root/
    COPY app .
    CMD ["./app"]
    

    build.sh

    #!/bin/sh
    echo Building alexellis2/href-counter:build
    
    docker build --build-arg https_proxy=$https_proxy --build-arg http_proxy=$http_proxy \
        -t alexellis2/href-counter:build . -f Dockerfile.build
    
    docker create --name extract alexellis2/href-counter:build
    docker cp extract:/go/src/github.com/alexellis/href-counter/app ./app
    docker rm -f extract
    
    echo Building alexellis2/href-counter:latest
    
    docker build --no-cache -t alexellis2/href-counter:latest .
    rm ./app
    

    运行build.sh时,你需要先构建第一个镜像,创建一个容器以便将结果复制出来,然后构建第二个镜像。 两个镜像都会占用你的系统空间,并且在你的本地磁盘上依然有应用程序。

    在多阶段构建下,你可以在Dockerfile中使用多个FROM声明,每个FROM声明可以使用不同的基础镜像, 并且每个FROM都使用一个新的构建阶段。你可以选择性的将文件从一个阶段复制到另一个阶段, 删除你不想保留在最终镜像中的一切。我们来调整上面的Dockerfile以使用多阶段构建做个示例。

    FROM golang:1.7.3
    WORKDIR /go/src/github.com/alexellis/href-counter/
    RUN go get -d -v golang.org/x/net/html
    COPY app.go .
    RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
    
    FROM alpine:latest
    RUN apk --no-cache add ca-certificates
    WORKDIR /root/
    COPY --from=0 /go/src/github.com/alexellis/href-counter/app .
    CMD ["./app"]
    

    你只需要一个Dockerfile文件即可,也不需要单独的构建脚本,只需要运行docker build

    docker build -t alexellis2/href-counter:latest .
    

    最终的结果是与前面一样的极小的结果,但是复杂性大大降低,你不需要创建任何中间镜像, 也根本不需要将任何文件提取到本地系统。

    它是如何工作的?第二个FROM指令使用alpine:latest镜像作为基础开始一个新的构建阶段, COPY --from=0的行将前一个阶段的结果复制到新的阶段,GO SDK及所有中间产物被抛弃,并没有保存在最终镜像中。

    默认情况下,构建阶段没有命名,使用它们的整数编号引用它们,从第一个FORM0开始计数。 但是你可以使用给FORM指令添加一个as <NAME>为其构建阶段命名。

    FROM golang:1.7.3 as builder
    WORKDIR /go/src/github.com/alexellis/href-counter/
    RUN go get -d -v golang.org/x/net/html
    COPY app.go    .
    RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app .
    
    FROM alpine:latest
    RUN apk --no-cache add ca-certificates
    WORKDIR /root/
    COPY --from=builder /go/src/github.com/alexellis/href-counter/app .
    CMD ["./app"]
    
  • 避免安装不需要的包

    为了降低复杂性、减少依赖、减小文件大小和构建时间,应该避免安装额外的或者不必要的软件包。例如,不要在数据库镜像中包含一个文本编辑器。

  • 一个容器只做一件事

    应该保证在一个容器中只运行一个进程。将多个应用解耦到不同容器中,保证了容器的横向扩展和复用。例如一个 web 应用程序可能包含三个独立的容器:web应用、数据库、缓存,每个容器都是独立的镜像,分开运行。但这并不是说一个容器就只跑一个进程,因为有的程序可能会自行产生其他进程,比如Celery 就可以有很多个工作进程。虽然“每个容器跑一个进程”是一条很好的法则,但这并不是一条硬性的规定。我们主要是希望一个容器只关注意见事情,尽量保持干净和模块化。

    如果容器互相依赖,你可以使用Docker 容器网络来把这些容器连接起来,我们前面已经跟大家讲解过 Docker 的容器网络模式了。

  • 最小化镜像层数

    Docker 17.05 甚至更早 1.10之 前,尽量减少镜像层数是非常重要的,不过现在的版本已经有了一定的改善了:

    • 1.10 以后,只有RUN、COPY和ADD指令会创建层,其他指令会创建临时的中间镜像,但是不会直接增加构建的镜像大小了。
    • 到了 17.05 版本以后增加了多阶段构建的支持,允许我们把需要的数据直接复制到最终的镜像中,这就允许我们在中间阶段包含一些工具或者调试信息了,而且不会增加最终的镜像大小。

    当然减少RUNCOPYADD的指令仍然是很有必要的,但是我们也需要在 Dockerfile 可读性(也包括长期的可维护性)和减少层数之间做一个平衡。

  • 对多行参数排序

    只要有可能,就将多行参数按字母顺序排序(比如要安装多个包时)。这可以帮助你避免重复包含同一个包,更新包列表时也更容易,也更容易阅读和审查。建议在反斜杠符号 \ 之前添加一个空格,可以增加可读性。 下面是来自buildpack-deps镜像的例子:

    RUN apt-get update && apt-get install -y \
      bzr \
      cvs \
      git \
      mercurial \
      subversion
    
  • 构建缓存

    在镜像的构建过程中 docker 会遍历 Dockerfile 文件中的所有指令,顺序执行。对于每一条指令,docker 都会在缓存中查找是否已存在可重用的镜像,否则会创建一个新的镜像

    我们可以使用 docker build --no-cache 跳过缓存

    • ADDCOPY 将会计算文件的 checksum 是否改变来决定是否利用缓存
    • RUN 仅仅查看命令字符串是否命中缓存,如 RUN apt-get -y update 可能会有问题

    如一个 node 应用,可以先拷贝 package.json 进行依赖安装,然后再添加整个目录,可以做到充分利用缓存的目的。

    FROM node:10-alpine as builder
    
    WORKDIR /code
    
    ADD package.json /code
    # 此步将可以充分利用 node_modules 的缓存
    RUN npm install --production
    
    ADD . /code
    
    RUN npm run build 
    
点赞