欢迎您的光临,本博所发布之文章皆为作者亲测通过,如有错误,欢迎通过各种方式指正。

文摘  Docker实战:Dockerfile编写最佳实践

Docker 本站 986 2评论

Docker 可以从 Dockerfile 中读取指令自动构建镜像,Dockerfile是一个包含构建指定镜像所有命令的文本文件。Docker坚持使用特定的格式并且使用特定的命令。你可以在 Dockerfile参考 页面学习基本知识。如果你刚接触Dockerfile 你应该从哪里开始学习。


本文囊括了Docker公司和Docker社区推荐的创建易于使用且实用的Dockerfile 的最佳实践和方法。我们强烈建议你遵循这些规范(事实上,如果你创建一个官方镜像,你必须坚持这些实践。)

你可以从 buildpack-deps Dockerifle看到许多这种实践和建议。

注:本文档提到的Dockerfile命令的更详细的解释见Dockerfile参考 页面。


一、实践总结


Dockerfile使用简单的语法来构建镜像。下面是一些建议和技巧以帮助你使用Dockerfile。


1.构建镜像


docker build 命令会根据 Dockerfile 文件及上下文构建新 Docker 镜像。构建上下文是指 Dockerfile 所在的本地路径或一个URL(Git仓库地址)。构建上下文环境会被递归处理,所以构建所指定的路径还包括了子目录,而URL还包括了其中指定的子模块。

将当前目录做为构建上下文时,可以像下面这样使用docker build命令构建镜像:

docker build .

Sending build context to Docker daemon  6.51 MB

...

说明:构建会在 Docker 后台守护进程(daemon)中执行,而不是CLI中。构建前,构建进程会将全部内容(递归)发送到守护进程。大多情况下,应该将一个空目录作为构建上下文环境,并将 Dockerfile 文件放在该目录下。

在构建上下文中使用的 Dockerfile 文件,是一个构建指令文件。为了提高构建性能,可以通过.dockerignore文件排除上下文目录下不需要的文件和目录。

在 Docker 构建镜像的第一步,docker CLI 会先在上下文目录中寻找.dockerignore文件,根据.dockerignore 文件排除上下文目录中的部分文件和目录,然后把剩下的文件和目录传递给 Docker 服务。

Dockerfile 一般位于构建上下文的根目录下,也可以通过-f指定该文件的位置:

docker build -f /path/to/a/Dockerfile .

构建时,还可以通过-t参数指定构建成镜像的仓库、标签。


docker build的流程

docker build的流程(这部分代码基本都在docker/builder中)

1.提取Dockerfile(evaluator.go/RUN)。

2.将Dockerfile按行进行分析(parser/parser.go/Parse) Dockerfile,每行第一个单词,如CMD、FROM等,这个叫做command。根据command,将之后的字符串用对应的数据结构进行接收。

3.根据分析的command,在dispatchers.go中选择对应的函数进行处理(dispatchers.go)。

4.处理完所有的命令,如果需要打标签,则给最后的镜像打上tag,结束。


在这里,我举一个例子来说明一下在第4步命令的执行过程。以CMD命令为例:

func cmd(b *Builder, args []string, attributes map[string]bool, original string) error {
cmdSlice := handleJsonArgs(args, attributes)
if !attributes["json"] {
  cmdSlice = append([]string{"/bin/sh", "-c"}, cmdSlice...)
}
b.Config.Cmd = runconfig.NewCommand(cmdSlice...)
if err := b.commit("", b.Config.Cmd, fmt.Sprintf("CMD %q", cmdSlice)); err != nil {
  return err
}
if len(args) != 0 {
  b.cmdSet = true
}
return nil
}

可以看到,b.Config.Cmd = runconfig.NewCommand(cmdSlice...)就是根据传入的CMD,更新了Builder里面的Config。然后进行b.commit。Builder这里的commit大致含义其实与docker/daemon的commit功能大同小异。不过这里commit是包含了以下的一个完整过程(参见internals.go/commit):

1.根据Config,create一个container出来。

2.然后将这个container通过commit(这个commit是指的docker的commit,与docker commit的命令是相同的)得到一个新的镜像。

3.不仅仅是CMD命令,几乎所有的命令(除了FROM外),在最后都是使用b.commit来产生一个新的镜像的。


所以这会导致的结果就是,Dockerfile里每一行,最后都会变为镜像中的一层。几乎是有多少有效行,就有多少层。


2.镜像标签


除非你正在用Docker做实验,否则你应当通过-t选项来docker build新的镜像以便于标记构建的镜像。一个简单的可读标签将帮助你管理每个创建的镜像。

docker build -t nginx/v3 .

注意,始终通过-t标记来构建镜像。


如果存在多个仓库下,或使用多个镜像标签,就可以使用多个-t参数:

docker build -t nginx/v3:1.0.2 -t nginx/v3:latest .

在 Docker 守护进程执行 Dockerfile 中的指令前,首先会对 Dockerfile 进行语法检查,有语法错误时会返回:

docker build -t nginx/v3 .

Sending build context to Docker daemon 2.048 kB

Error response from daemon: Unknown instruction: RUNCMD


3.使用缓存


Docker 守护进程会一条一条的执行 Dockerfile 中的指令,而且会在每一步提交并生成一个新镜像,最后会输出最终镜像的ID。生成完成后,Docker 守护进程会自动清理你发送的上下文。 

Dockerfile的每条指令都会将结果提交为新的镜像,下一个指令将会基于上一步指令的镜像的基础上构建,如果一个镜像存在相同的父镜像和指令(除了ADD),Docker将会使用镜像而不是执行该指令,即缓存。

Dockerfile文件中的每条指令会被独立执行,并会创建一个新镜像,RUN cd /tmp等命令不会对下条指令产生影响。 


Docker 会重用已生成的中间镜像,以加速docker build的构建速度。以下是一个使用了缓存镜像的执行过程:

$ docker build -t svendowideit/ambassador .

Sending build context to Docker daemon 15.36 kB
Step 1/4 : FROM alpine:3.2
 ---> 31f630c65071
Step 2/4 : MAINTAINER SvenDowideit@home.org.au
 ---> Using cache
 ---> 2a1c91448f5f
Step 3/4 : RUN apk update &&      apk add socat &&        rm -r /var/cache/
 ---> Using cache
 ---> 21ed6e7fbb73
Step 4/4 : CMD env | grep _TCP= | (sed 's/.*_PORT_\([0-9]*\)_TCP=tcp:\/\/\(.*\):\(.*\)/socat -t 100000000 TCP4-LISTEN:\1,fork,reuseaddr TCP4:\2:\3 \&/' && echo wait) | sh
 ---> Using cache
 ---> 7ea8aef582cc
Successfully built 7ea8aef582cc

构建缓存仅会使用本地父生成链上的镜像,如果不想使用本地缓存的镜像,也可以通过--cache-from指定缓存。指定后将不再使用本地生成的镜像链,而是从镜像仓库中下载。


为了有效地利用缓存,你需要保持你的Dockerfile一致,并且尽量在末尾修改。我所有的Dockerfile的前五行都是这样的:

FROM ubuntu

MAINTAINER Michael Crosby <michael@crosbymichael.com>

RUN echo "deb http://archive.ubuntu.com/ubuntu precise main universe" > /etc/apt/sources.list

RUN apt-get update

RUN apt-get upgrade -y

更改MAINTAINER指令会使Docker强制执行RUN指令来更新apt,而不是使用缓存。

所以,我们应该使用常用且不变的Dockerfile开始(译者注:上面的例子)指令来利用缓存。


4.寻找缓存的逻辑


Docker 寻找缓存的逻辑其实就是树型结构根据 Dockerfile 指令遍历子节点的过程。下图可以说明这个逻辑。

0.jpg

大部分指令可以根据上述逻辑去寻找缓存,除了 ADD 和 COPY 。这两个指令会复制文件内容到镜像内,除了指令相同以外,Docker 还会检查每个文件内容校验(不包括最后修改时间和最后访问时间),如果校验不一致,则不会使用缓存。

除了这两个命令,Docker 并不会去检查容器内的文件内容,比如 RUN apt-get -y update,每次执行时文件可能都不一样,但是 Docker 认为命令一致,会继续使用缓存。这样一来,以后构建时都不会再重新运行apt-get -y update。

如果 Docker 没有找到当前指令的缓存,则会构建一个新的镜像,并且之后的所有指令都不会再去寻找缓存。


5.公开端口


两个Docker的核心概念是可重复和可移植。镜像应该可以运行在任何主机上并且运行尽可能多的次数。在Dockerfile中你有能力映射私有和公有端口,但是你永远不要通过Dockerfile映射公有端口。通过映射公有端口到主机上,你将只能运行一个容器化应用程序实例。(译者注:运行多个端口不就冲突啦)

#private and public mapping

EXPOSE 80:8080

#private only

EXPOSE 80

如果镜像的使用者关心容器公有映射了哪个公有端口,他们可以在运行镜像时通过-p参数设置,否则,Docker会自动为容器分配端口。

切勿在Dockerfile映射公有端口。


6.CMD与ENTRYPOINT的语法


CMD和ENTRYPOINT指令都非常简单,但它们都有一个隐藏的容易出错的“功能”,如果你不知道的话可能会在这里踩坑,这些指令支持两种不同的语法。

CMD /bin/echo

#or

CMD ["/bin/echo"]

这看起来好像没什么问题,但仔细一看其实两种方式差距很大。如果你使用第二个语法:CMD(或ENTRYPOINT)是一个数组,它执行的命令完全像你期望的那样。如果使用第一种语法,Docker会在你的命令前面加上/bin/sh -c,我记得一直都是这样。

如果你不知道Docker修改了CMD命令,在命令前加上/bin/sh -c可能会导致一些意想不到的问题以及难以理解的功能。因此,在使用这两个指令时你应当使用数组语法,因为数组语法会确切地执行你打算执行的命令。

使用CMD和ENTRYPOINT时,请务必使用数组语法。


7.CMD和ENTRYPOINT 结合使用更好


docker run命令中的参数都会传递给ENTRYPOINT指令,而不用担心它被覆盖(跟CMD不同)。当与CMD一起使用时ENTRYPOINT的表现会更好。让我们来研究一下我的Rethinkdb Dockerfile,看看如何使用它。

#Dockerfile for Rethinkdb 
#http://www.rethinkdb.com/
FROM ubuntu
MAINTAINER Michael Crosby <michael@crosbymichael.com>
RUN echo "deb http://archive.ubuntu.com/ubuntu precise main universe" > /etc/apt/sources.list
RUN apt-get update
RUN apt-get upgrade -y
RUN apt-get install -y python-software-properties
RUN add-apt-repository ppa:rethinkdb/ppa
RUN apt-get update
RUN apt-get install -y rethinkdb
#Rethinkdb process
EXPOSE 28015
#Rethinkdb admin console
EXPOSE 8080
#Create the /rethinkdb_data dir structure
RUN /usr/bin/rethinkdb create
ENTRYPOINT ["/usr/bin/rethinkdb"]
CMD ["--help"]

这是Docker化Rethinkdb的所有配置文件。在开始我们有标准的5行来确保基础镜像是最新的、端口的公开等。当ENTRYPOINT指令出现时,我们知道每次运行该镜像,在docker run过程中传递的所有参数将成为ENTRYPOINT(/usr/bin/rethinkdb)的参数。


8.简单示例


接下来用一个简单的示例来感受一下 Dockerfile 是如何用来构建镜像启动容器。我们以定制 nginx 镜像为例,在一个空白目录中,建立一个文本文件,并命名为 Dockerfile:

mkdir mynginx

cd mynginx

vi Dockerfile


构建一个 Dockerfile 文件内容为:

FROM nginx

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

vi Dockerfile

这个 Dockerfile 很简单,一共就两行涉及到了两条指令:FROM 和 RUN,FROM 表示获取指定基础镜像,RUN 执行命令,在执行的过程中重写了 nginx 的默认页面信息,将信息替换为:Hello, Docker!。


在 Dockerfile 文件所在目录执行:

docker build -t nginx:v1 .

命令最后有一个. 表示当前目录


构建完成之后,使用 docker images 命令查看所有镜像,如果存在 REPOSITORY 为 nginx 和 TAG 是 v1 的信息,就表示构建成功。

docker images

REPOSITORY                      TAG                 IMAGE ID            CREATED             SIZE

nginx                           v1                  8c92471de2cc        6 minutes ago       108.6 MB


接下来使用 docker run 命令来启动容器

docker run  --name docker_nginx_v1   -d -p 80:80 nginx:v1

这条命令会用 nginx 镜像启动一个容器,命名为docker_nginx_v1,并且映射了 80 端口,这样我们可以用浏览器去访问这个 nginx 服务器:http://192.168.0.54/,页面返回信息:

1.png

这样一个简单使用 Dockerfile 构建镜像,运行容器的示例就完成了!


修改容器内容


容器启动后,需要对容器内的文件进行进一步的完善,可以使用docker exec -it xx bash命令再次进行修改,以上面的示例为基础,修改 nginx 启动页面内容:

docker exec -it docker_nginx_v1   bash

root@3729b97e8226:/# echo '<h1>Hello, Docker neo!</h1>' > /usr/share/nginx/html/index.html

root@3729b97e8226:/# exit

exit

以交互式终端方式进入 docker_nginx_v1 容器,并执行了 bash 命令,也就是获得一个可操作的 Shell。然后,我们用<h1>Hello, Docker neo!</h1>覆盖了 /usr/share/nginx/html/index.html 的内容。


再次刷新浏览器,会发现内容被改变。

2.png

修改了容器的文件,也就是改动了容器的存储层,可以通过 docker diff 命令看到具体的改动。

docker diff docker_nginx_v1 

... 

这样 Dockerfile 使用方式就为大家介绍完了,关于Dockerfile的详细使用说明请参考文章:https://ittxx.cn/view/154


二、参考和建议


1.容器应该是临时性的

从你的Dockerfile定义的镜像启动的容器应该尽可能短暂。这里的『短暂』我们是说它可以被停止和销毁并且一个新容器的构建和替换可以绝对最小化的变更和配置下完成。你可能想看下 应用方法论的12个事实中进程 一节来了解以无状态方式运行容器的动机。


2.使用 .dockerignore文件

在大多数情况下,最好把Dockerfile放在一个空目录里。然后,只把构建Dockerfile需要的文件追加到该目录中。为了改进构建性能,你也可以增加一个.dockerignore 文件来排除文件和目录。该文件支持与 .gitignore 类似的排除模式。更多创建.dockerignore信息,见 .dockerignore


3.避免安装不需要的包

为了减少复杂性,依赖,文件大小,和构建时间,你应该避免仅仅因为他们很好用而安装一些额外或者不必要的包。例如,你不需要在一个数据库镜像中包含一个文本编辑器。


4.每个容器只关心一个问题

解耦应用为多个容器使水平扩容和复用容器更容易。例如,一个web应用栈会包含3个独立的容器,每个都有自己独立的镜像,以解耦的方式来管理web应用,数据库。

你可能听说过"一个容器一个进程"。这种说法有很好的意图,一个容器应该有一个操作系统进程并非真的必要。除此之外,事实上现在容器可以 被init进程启动, 一些程序可能会自己产生其他额外的进程。例如,Celery 可以产生多个工作进程,或者 Apache 可能为每个请求创建一个进程。当然"一个容器一个进程"通常是一个很好的经验法则,??但它不是一个很难和快速的规则(it is not a hard and fast rule)?? 用你最好的判断来保持容器尽可能的干净和模块化。

如果容器之间相关依赖,你可以使用 Docker容器网络 来取吧哦容器之间可以通信。


5.最小化层数

你需要在Dockerfile可读性(从而可以长时间维护)和它用的层数最小化之间找到平衡。Be strategic 关注你使用的层数(and cautious about the number of layers you use).


6.对多行参数排序

无论何时,以排序多行参数来缓解以后的变化(Whenever possible, ease later changes by sorting multi-line arguments alphanumerically. )。这将帮助你避免重复的包并且使里列表更容易更新。这也使得PR更容易阅读和审查。在反斜线(\)前加一个空格也很有帮助。


这里有个来自 buildpack-deps 镜像的实例:

RUN apt-get update && apt-get install -y \

  bzr \

  cvs \

  git \

  mercurial \

  subversion


7.不要开机初始化

容器模型是进程而不是机器。如果你认为你需要开机初始化,那么你就错了。


8.可信任构建

即使你不喜欢这个题目但它是很棒的一个功能。我把大部分 GitHub 仓库添加到可信任构建,因此当我提交一个新镜像之后不久,就在等待索引。另外,我不必再创建单独的 Dockerfile 仓库与他人分享,它们都在同一个地方。

请记住,这不是你尝试新东西的试验场。在你推送之前,请在本地先构建一下。Docker 可以确保你在本地的构建和运行,与你推送到任何地方的构建和运行是一样的。本地开发和测试、提交和推送、以及等待索引上的官方镜像都是建立在可信任构建的基础之上的。


9.不要在构建中升级版本

更新将发生在基础镜像里,你不需要在你的容器内来apt-get upgrade更新。因为在隔离情况下,如果更新时试图修改 init 或改变容器内的设备,更新可能会经常失败。它还可能会产生不一致的镜像,因为你不再有你的应用程序该如何运行以及包含在镜像中依赖的哪种版本的正确源文件。

如果基础镜像需要安全更新,那么让上游的知道,让他们给大家更新,并确保你再次构建的一致性。


10.使用小型基础镜像

有些镜像比其他的更臃肿。我建议使用debian:jessie作为你的基础镜像。如果你熟悉Ubuntu,你将发现一个更轻量和巧妙的自制 debian,它足够小并且没有包含任何不需要的包。


11.使用特定的标签

对于你的基础镜像这是非常重要的。Dockerfile 中FROM应始终包含依赖的基础镜像的完整仓库名和标签。比如说FROM debian:jessie而不仅仅是FROM debian。


12.常见指令组合

您的apt-get update应该与apt-get install组合。此外,你应该采取\的优势使用多行来进行安装。

#Dockerfile for https://index.docker.io/u/crosbymichael/python/ 
FROM debian:jessie
RUN apt-get update && apt-get install -y \
git \
libxml2-dev \
python \
build-essential \
make \
gcc \
python-dev \
locales \
python-pip
RUN dpkg-reconfigure locales && \
locale-gen C.UTF-8 && \
/usr/sbin/update-locale.UTF-8
ENV LC_ALL C.UTF-8

谨记层和缓存都是不错的。不要害怕过多的层,因为缓存是大救星。当然,你应当尽量使用上游的包。


13.使用自己的基础镜像

我不是在谈论运行 debbootstrap 来制作自己的 debian。你不是 tianon(Tianon Gravi),没有人想要你认为别人需要你的 500mb 的狗屎垃圾基础镜像。我说的是,如果你要运行 python 应用程序需要有一个python基础镜像。前面示例中用于构建 crosbymichael/python 的 Dockerfile 也被用于其他很多构建 Python 应用程序的镜像。

FROM crosbymichael/python
RUN pip install butterfly
RUN echo "root\nroot\n" | passwd root
EXPOSE 9191
ENTRYPOINT ["butterfly.server.py"]
CMD ["--port=9191", "--host=0.0.0.0"]

另一个:

FROM crosbymichael/python
RUN pip install --upgrade youtube_dl && mkdir /download
WORKDIR /download
ENTRYPOINT ["youtube-dl"]
CMD ["--help"]

正如你看到的,这使得使用你的基础镜像非常小,从而使你集中精力在应用程序上。


参考网址:

https://itbilu.com/linux/docker/VyhM5wPuz.html 

https://github.com/qianlei90/Blog/issues/35 

http://www.ityouknow.com/docker/2018/03/12/docker-use-dockerfile.html 

http://dockone.io/article/131 

https://www.jianshu.com/p/9f15b81759bd 


官方仓库实例

这些官方仓库有典型的示范(These Official Repositories have exemplary Dockerfiles):

Go

Perl

Hy

Ruby


其他资源

Dockerfile Reference

More about Base Images

More about Automated Builds

Guidelines for Creating Official Repositories


转载请注明: ITTXX.CN--分享互联网 » Docker实战:Dockerfile编写最佳实践

最后更新:2021-07-06 09:14:49

赞 (3) or 分享 ()
游客 发表我的评论   换个身份
取消评论

表情
(2)个小伙伴在吐槽
  1. 333#1
    游客2021-07-06 09:14 (2个月前) 回复
  2. 333#2
    游客2021-07-06 09:14 (2个月前) 回复