如何编写最佳的Dockerfile

MeghanWickh 2年前
   <p>译者按:Dockerfile的语法非常简单,然而如何加快镜像构建速度,如何减少Docker镜像的大小却不是那么直观,需要积累实践经验。这篇博客可以帮助你快速掌握编写Dockerfile的技巧。</p>    <p>为了保证可读性,本文采用意译而非直译。另外,本文版权归原作者所有,翻译仅用于学习。</p>    <p>我已经使用Docker有一段时间了,其中编写Dockerfile是非常重要的一部分工作。在这篇博客中,我打算分享一些建议,帮助大家编写更好的Dockerfile。</p>    <h3>目标:</h3>    <ul>     <li>更快的构建速度</li>     <li>更小的Docker镜像大小</li>     <li>更少的Docker镜像层</li>     <li>充分利用镜像缓存</li>     <li>增加Dockerfile可读性</li>     <li>让Docker容器使用起来更简单</li>    </ul>    <h3>总结</h3>    <ul>     <li>编写.dockerignore文件</li>     <li>容器只运行单个应用</li>     <li>将多个RUN指令合并为一个</li>     <li>基础镜像的标签不要用latest</li>     <li>每个RUN指令后删除多余文件</li>     <li>选择合适的基础镜像(alpine版本最好)</li>     <li>设置WORKDIR和CMD</li>     <li>使用ENTRYPOINT (可选)</li>     <li>在entrypoint脚本中使用exec</li>     <li>COPY与ADD优先使用前者</li>    </ul>    <ul>     <li>合理调整COPY与RUN的顺序</li>     <li>设置默认的环境变量,映射端口和数据卷</li>     <li>使用LABEL设置镜像元数据</li>     <li>添加HEALTHCHECK</li>    </ul>    <h3>示例</h3>    <p>示例Dockerfile犯了几乎所有的错(当然我是故意的)。接下来,我会一步步优化它。假设我们需要使用Docker运行一个Node.js应用,下面就是它的Dockerfile(CMD指令太复杂了,所以我简化了,它是错误的,仅供参考)。</p>    <pre>  FROM ubuntu    ADD. /app    RUNapt-get update  RUNapt-get upgrade -y  RUNapt-get install -y nodejs ssh mysql  RUNcd /app && npm install    # this should start three processes, mysql and ssh  # in the background and node app in foreground  # isn't it beautifully terrible? <3  CMDmysql & sshd & npm start  </pre>    <p>构建镜像:</p>    <pre>  docker build -t wtf .  </pre>    <h3>1. 编写.dockerignore文件</h3>    <p>构建镜像时,Docker需要先准备 context ,将所有需要的文件收集到进程中。默认的 context 包含Dockerfile目录中的所有文件,但是实际上, <strong>我们并不需要.git目录,node_modules目录等内容</strong> 。 .dockerignore 的作用和语法类似于 .gitignore ,可以忽略一些不需要的文件,这样可以有效加快镜像构建时间,同时减少Docker镜像的大小。示例如下:</p>    <pre>  .git/  node_modules/  </pre>    <h3>2. 容器只运行单个应用</h3>    <p>从技术角度讲,你可以在Docker容器中运行多个进程。你可以将数据库,前端,后端,ssh,supervisor都运行在同一个Docker容器中。但是,这会让你非常痛苦:</p>    <ul>     <li>非常长的构建时间(修改前端之后,整个后端也需要重新构建)</li>     <li>非常大的镜像大小</li>     <li>多个应用的日志难以处理(不能直接使用stdout,否则多个应用的日志会混合到一起)</li>     <li>横向扩展时非常浪费资源(不同的应用需要运行的容器数并不相同)</li>     <li>僵尸进程问题 - 你需要选择合适的init进程</li>    </ul>    <p>因此,我建议大家为每个应用构建单独的Docker镜像,然后使用 <a href="/misc/goto?guid=4958869857673473301" rel="nofollow,noindex">Docker Compose</a> 运行多个Docker容器。</p>    <p>现在,我从Dockerfile中删除一些不需要的安装包,另外,SSH可以用 <a href="/misc/goto?guid=4959748760570364194" rel="nofollow,noindex">docker exec</a> 替代。示例如下:</p>    <pre>  FROM ubuntu    ADD. /app    RUNapt-get update  RUNapt-get upgrade -y    # we should remove ssh and mysql, and use  # separate container for database  RUNapt-get install -y nodejs # ssh mysql  RUNcd /app && npm install    CMDnpm start  </pre>    <h3>3. 将多个RUN指令合并为一个</h3>    <p>Docker镜像是分层的,下面这些知识点非常重要:</p>    <ul>     <li>Dockerfile中的每个指令都会创建一个新的镜像层。</li>     <li>镜像层将被缓存和复用</li>     <li>当Dockerfile的指令修改了,复制的文件变化了,或者构建镜像时指定的变量不同了,对应的镜像层缓存就会失效</li>     <li>某一层的镜像缓存失效之后,它之后的镜像层缓存都会失效</li>     <li>镜像层是不可变的,如果我们再某一层中添加一个文件,然后在下一层中删除它,则镜像中依然会包含该文件(只是这个文件在Docker容器中不可见了)。</li>    </ul>    <p>Docker镜像类似于洋葱。它们都有很多层。为了修改内层,则需要将外面的层都删掉。记住这一点的话,其他内容就很好理解了。</p>    <p>现在,我们 <strong> 将所有的 <a href="/misc/goto?guid=4959748760684228627" rel="nofollow,noindex">RUN</a> 指令合并为一个 </strong> 。同时把 apt-get upgrade 删除,因为它会使得镜像构建非常不确定(我们只需要依赖基础镜像的更新就好了)</p>    <pre>  FROM ubuntu    ADD. /app    RUNapt-get update \   && apt-get install -y nodejs \   && cd /app \   && npm install    CMDnpm start  </pre>    <p>记住一点,我们只能将变化频率一样的指令合并在一起。将node.js安装与npm模块安装放在一起的话,则每次修改源代码,都需要重新安装node.js,这显然不合适。因此,正确的写法是这样的:</p>    <pre>  FROM ubuntu    RUNapt-get update && apt-get install -y nodejs  ADD. /app  RUNcd /app && npm install    CMDnpm start  </pre>    <h3>4. 基础镜像的标签不要用latest</h3>    <p>当镜像没有指定标签时,将默认使用 latest 标签。因此, FROM ubuntu 指令等同于 FROM ubuntu:latest 。当时,当镜像更新时,latest标签会指向不同的镜像,这时构建镜像有可能失败。如果你的确需要使用最新版的基础镜像,可以使用latest标签,否则的话,最好指定确定的镜像标签。</p>    <p>示例Dockerfile应该使用 16.04 作为标签。</p>    <pre>  FROM ubuntu:16.04 # it's that easy!    RUNapt-get update && apt-get install -y nodejs  ADD. /app  RUNcd /app && npm install    CMDnpm start  </pre>    <h3>5. 每个RUN指令后删除多余文件</h3>    <p>假设我们更新了apt-get源,下载,解压并安装了一些软件包,它们都保存在 /var/lib/apt/lists/ 目录中。但是,运行应用时Docker镜像中并不需要这些文件。我们最好将它们删除,因为它会使Docker镜像变大。</p>    <p>示例Dockerfile中,我们可以删除 /var/lib/apt/lists/ 目录中的文件(它们是由apt-get update生成的)。</p>    <pre>  FROM ubuntu:16.04    RUNapt-get update \   && apt-get install -y nodejs \   # added lines   && rm -rf /var/lib/apt/lists/*    ADD. /app  RUNcd /app && npm install    CMDnpm start  </pre>    <h3>6. 选择合适的基础镜像(alpine版本最好)</h3>    <p>在示例中,我们选择了 ubuntu 作为基础镜像。但是我们只需要运行node程序,有必要使用一个通用的基础镜像吗? node 镜像应该是更好的选择。</p>    <pre>  FROM node    ADD. /app  # we don't need to install node  # anymore and use apt-get  RUNcd /app && npm install    CMDnpm start  </pre>    <p>更好的选择是alpine版本的 node 镜像。alpine是一个极小化的Linux发行版,只有4MB,这让它非常适合作为基础镜像。</p>    <pre>  FROM node:7-alpine    ADD. /app  RUNcd /app && npm install    CMDnpm start  </pre>    <p><a href="/misc/goto?guid=4959748760803308577" rel="nofollow,noindex">apk</a> 是Alpine的包管理工具。它与 apt-get 有些不同,但是非常容易上手。另外,它还有一些非常有用的特性,比如 no-cache 和 --virtual 选项,它们都可以帮助我们减少镜像的大小。</p>    <h3>7. 设置WORKDIR和 CMD</h3>    <p><a href="/misc/goto?guid=4959748760935805385" rel="nofollow,noindex">WORKDIR</a> 指令可以设置默认目录,也就是运行 RUN / CMD / ENTRYPOINT 指令的地方。</p>    <p><a href="/misc/goto?guid=4959664216290813610" rel="nofollow,noindex">CMD</a> 指令可以设置容器创建是执行的默认命令。另外,你应该讲命令写在一个数组中,数组中每个元素为命令的每个单词(参考 <a href="/misc/goto?guid=4959664216290813610" rel="nofollow,noindex">官方文档</a> )。</p>    <pre>  FROM node:7-alpine    WORKDIR/app  ADD. /app  RUNnpm install    CMD["npm", "start"]  </pre>    <h3>8. 使用ENTRYPOINT (可选)</h3>    <p><a href="/misc/goto?guid=4959664216216193649" rel="nofollow,noindex">ENTRYPOINT</a> 指令并不是必须的,因为它会增加复杂度。 ENTRYPOINT 是一个脚本,它会默认执行,并且将指定的命令错误其参数。它通常用于构建可执行的Docker镜像。entrypoint.sh如下:</p>    <pre>  #!/usr/bin/env sh  # $0 is a script name,  # $1, $2, $3 etc are passed arguments  # $1 is our command  CMD=$1    case "$CMD" in    "dev" )   npm install   export NODE_ENV=development   exec npm run dev   ;;     "start" )   # we can modify files here, using ENV variables passed in   # "docker create" command. It can't be done during build process.   echo "db: $DATABASE_ADDRESS" >> /app/config.yml   export NODE_ENV=production   exec npm start   ;;     * )   # Run custom command. Thanks to this line we can still use   # "docker run our_image /bin/bash" and it will work   exec $CMD ${@:2}   ;;  esac  </pre>    <p>示例Dockerfile:</p>    <pre>  FROM node:7-alpine    WORKDIR/app  ADD. /app  RUNnpm install    ENTRYPOINT["./entrypoint.sh"]  CMD["start"]  </pre>    <p>可以使用如下命令运行该镜像:</p>    <pre>  # 运行开发版本  docker run our-app dev     # 运行生产版本  docker run our-app start     # 运行bash  docker run -it our-app /bin/bash  </pre>    <h3>9. 在entrypoint脚本中使用exec</h3>    <p>在前文的entrypoint脚本中,我使用了 exec 命令运行node应用。不使用 exec 的话,我们则不能顺利地关闭容器,因为SIGTERM信号会被bash脚本进程吞没。 exec 命令启动的进程可以取代脚本进程,因此所有的信号都会正常工作。</p>    <h3>10. COPY与ADD优先使用前者</h3>    <p><a href="/misc/goto?guid=4959748761168332278" rel="nofollow,noindex">COPY</a> 指令非常简单,仅用于将文件拷贝到镜像中。 <a href="/misc/goto?guid=4959748761287084058" rel="nofollow,noindex">ADD</a> 相对来讲复杂一些,可以用于下载远程文件以及解压压缩包(参考 <a href="/misc/goto?guid=4959748761410662871" rel="nofollow,noindex">官方文档</a> )。</p>    <pre>  FROM node:7-alpine    WORKDIR/app    COPY. /app  RUNnpm install    ENTRYPOINT["./entrypoint.sh"]  CMD["start"]  </pre>    <h3>11. 合理调整COPY与RUN的顺序</h3>    <p>我们应该 <strong>把变化最少的部分放在Dockerfile的前面</strong> ,这样可以充分利用镜像缓存。</p>    <p>示例中,源代码会经常变化,则每次构建镜像时都需要重新安装NPM模块,这显然不是我们希望看到的。因此我们可以先拷贝 package.json ,然后安装NPM模块,最后才拷贝其余的源代码。这样的话,即使源代码变化,也不需要重新安装NPM模块。</p>    <pre>  FROM node:7-alpine    WORKDIR/app    COPYpackage.json /app  RUNnpm install  COPY. /app    ENTRYPOINT["./entrypoint.sh"]  CMD["start"]  </pre>    <h3>12. 设置默认的环境变量,映射端口和数据卷</h3>    <p>运行Docker容器时很可能需要一些环境变量。在Dockerfile设置默认的环境变量是一种很好的方式。另外,我们应该在Dockerfile中设置映射端口和数据卷。示例如下:</p>    <pre>  FROM node:7-alpine    ENV PROJECT_DIR=/app    WORKDIR$PROJECT_DIR    COPYpackage.json $PROJECT_DIR  RUNnpm install  COPY. $PROJECT_DIR    ENV MEDIA_DIR=/media \    NODE_ENV=production \   APP_PORT=3000    VOLUME$MEDIA_DIR  EXPOSE $APP_PORT    ENTRYPOINT["./entrypoint.sh"]  CMD["start"]  </pre>    <p><a href="/misc/goto?guid=4959748761547392780" rel="nofollow,noindex">ENV</a> 指令指定的环境变量在容器中可以使用。如果你只是需要指定构建镜像时的变量,你可以使用 <a href="/misc/goto?guid=4959748761675952526" rel="nofollow,noindex">ARG</a> 指令。</p>    <h3>13. 使用LABEL设置镜像元数据</h3>    <p>使用 <a href="/misc/goto?guid=4959748761788100665" rel="nofollow,noindex">LABEL</a> 指令,可以为镜像设置元数据,例如 <strong>镜像创建者</strong> 或者 <strong>镜像说明</strong> 。旧版的Dockerfile语法使用 <a href="/misc/goto?guid=4959748761897210146" rel="nofollow,noindex">MAINTAINER</a> 指令指定镜像创建者,但是它已经被弃用了。有时,一些外部程序需要用到镜像的元数据,例如 <a href="/misc/goto?guid=4959748762016049445" rel="nofollow,noindex">nvidia-docker</a> 需要用到 com.nvidia.volumes.needed 。示例如下:</p>    <pre>  FROM node:7-alpine   LABELmaintainer "jakub.skalecki@example.com"  ...  </pre>    <h3>14. 添加HEALTHCHECK</h3>    <p>运行容器时,可以指定 --restart always 选项。这样的话,容器崩溃时,Docker守护进程(docker daemon)会重启容器。对于需要长时间运行的容器,这个选项非常有用。但是,如果容器的确在运行,但是不可(陷入死循环,配置错误)用怎么办?使用 <a href="/misc/goto?guid=4959748762150621416" rel="nofollow,noindex">HEALTHCHECK</a> 指令可以让Docker周期性的检查容器的健康状况。我们只需要指定一个命令,如果一切正常的话返回0,否则返回1。对HEALTHCHECK感兴趣的话,可以参考 <a href="/misc/goto?guid=4959748762280105007" rel="nofollow,noindex">这篇博客</a> 。示例如下:</p>    <pre>  FROM node:7-alpine   LABELmaintainer "jakub.skalecki@example.com"    ENV PROJECT_DIR=/app   WORKDIR$PROJECT_DIR    COPYpackage.json $PROJECT_DIR  RUNnpm install  COPY. $PROJECT_DIR    ENV MEDIA_DIR=/media \    NODE_ENV=production \   APP_PORT=3000    VOLUME$MEDIA_DIR  EXPOSE $APP_PORT   HEALTHCHECKCMD curl --fail http://localhost:$APP_PORT || exit 1    ENTRYPOINT["./entrypoint.sh"]  CMD["start"]  </pre>    <p>当请求失败时, curl --fail 命令返回非0状态。</p>    <p>来自:https://kiwenlau.com/2017/05/15/write-excellent-dockerfile/</p>    <p> </p>