dockerfile
使用docker commit
来扩展一个镜像比较简单,但是不方便在一个团队中分享。
我们可以使用docker build
来创建一个新的镜像。
定制Dockerfile
Dockerfile 基本的语法是:
-
使用
#
来注释 -
使用
FROM
指定使用哪个镜像作为基础 -
使用
MAINTANER
注明维护者的信息 -
使用
RUN
开头的指令会在创建中运行,比如使用RUN apt-get
来安装了软件包。
例,新建一个目录和一个Dockerfile
:
$ mkdir sinatra $ cd sinatra $ touch Dockerfile
Dockerfile
中声明如何以现有的镜像为基础,执行定制操作:
# This is a comment FROM ubuntu:14.04 MAINTAINER Docker Newbee <newbee@docker.com> RUN apt-get -qq update RUN apt-get -qqy install ruby ruby-dev RUN gem install sinatra
RUN
指令
RUN:用于执行后面跟着的命令行命令。有以下俩种格式:
-
Shell格式:
RUN <Shell命令>
-
exec格式:
RUN ["可执行文件", "参数1", "参数2"]
例如:
# RUN ./test.php dev offline # 等价于 # RUN ["./test.php", "dev", "offline"]
镜像的层数
- 一个镜像不能超过 127 层
- Dockerfile 的指令每执行一次都会在 docker 上新建一层。所以过多无意义的层, 会造成镜像膨胀过大。例如:
FROM centos RUN yum install wget RUN wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" RUN tar -xvf redis.tar.gz
以上执行会创建 3 层镜像。可简化为以下格式:
FROM centos RUN yum install wget \ && wget -O redis.tar.gz "http://download.redis.io/releases/redis-5.0.3.tar.gz" \ && tar -xvf redis.tar.gz
如上,以&&
符号连接命令,这样执行后,只会创建 1 层镜像。
从Dockerfile
构建镜像
编写完成 Dockerfile 后可以使用docker build
来生成镜像。
docker build -t="ouruser/sinatra:v2" .
-
-t
:标记来添加 tag,指定新的镜像的用户信息。 -
.
是 Dockerfile 所在的路径(当前目录),也可以替换为一个具体的 Dockerfile 的路径。
输出的LOG中可以看到构建过程:
Uploading context 2.56 kB Uploading context Step 0 : FROM ubuntu:14.04 ---> 99ec81b80c55 Step 1 : MAINTAINER Newbee <newbee@docker.com> ---> Running in 7c5664a8a0c1 ---> 2fa8ca4e2a13 Removing intermediate container 7c5664a8a0c1 Step 2 : RUN apt-get -qq update ---> Running in b07cc3fb4256 ---> 50d21070ec0c Removing intermediate container b07cc3fb4256 Step 3 : RUN apt-get -qqy install ruby ruby-dev ---> Running in a5b038dd127e Selecting previously unselected package libasan0:amd64. (Reading database ... 11518 files and directories currently installed.) Preparing to unpack .../libasan0_4.8.2-19ubuntu1_amd64.deb ... Setting up ruby (1:1.9.3.4) ... Setting up ruby1.9.1 (1.9.3.484-2ubuntu1) ... Processing triggers for libc-bin (2.19-0ubuntu6) ... ---> 2acb20f17878 Removing intermediate container a5b038dd127e Step 4 : RUN gem install sinatra ---> Running in 5e9d0065c1f7 . . . Successfully installed rack-protection-1.5.3 Successfully installed sinatra-1.4.5 4 gems installed ---> 324104cde6ad Removing intermediate container 5e9d0065c1f7 Successfully built 324104cde6ad
可以看到 build 进程在执行操作。
- 第一件事情就是上传这个 Dockerfile 内容, 因为所有的操作都要依据 Dockerfile 来进行。
-
然后,Dockfile 中的指令被一条一条的执行。
每一步都创建了一个新的容器,在容器中执行指令并提交修改
(就跟之前介绍过的
docker commit
一样)。 -
当所有的指令都执行完毕之后,返回了最终的镜像
id
。 - 所有的中间步骤所产生的容器都被删除和清理了。
-t
指定多个TAG
使用多个-t选项保持多个tag
$ docker build -t nginx:v1 -t dockerhub.com/nginx:v2 . Sending build context to Docker daemon 1.583kB Step 1/2 : FROM nginx ---> 08b152afcfae Step 2/2 : run echo 123 ---> Using cache ---> 3b636c79fbfa Successfully built 3b636c79fbfa Successfully tagged nginx:v1 Successfully tagged dockerhub.com/nginx:v2
这样就构建两个不同tag的同一ID镜像
$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE dockerhub.com/nginx v2 3b636c79fbfa 23 minutes ago 133MB nginx v1 3b636c79fbfa 23 minutes ago 133MB
上下文路径
$ docker build -t nginx:test .
指令最后一个.
是把当前目录作为上下文路径,那么什么是上下文路径呢?
上下文路径,是指 docker 在构建镜像,有时候想要使用到本机的文件(比如复制),
docker build
命令得知这个路径后,会将路径下的所有内容打包。
解析:由于 docker 的运行模式是 C/S。我们本机是 C,docker 引擎是 S。 实际的构建过程是在 docker 引擎下完成的,所以这个时候无法用到我们本机的文件。 这就需要把我们本机的指定目录下的文件一起打包提供给 docker 引擎使用。
如果未说明最后一个参数,那么默认上下文路径就是Dockerfile
所在的位置。
注意:上下文路径下不要放无用的文件,因为会一起打包发送给 docker 引擎, 如果文件过多会造成过程缓慢。
指令详解
复制:COPY
复制指令,从上下文目录中复制文件或者目录到容器里指定路径。
格式:
COPY [--chown=<user>:<group>] <源路径1>... <目标路径> COPY [--chown=<user>:<group>] ["<源路径1>",... "<目标路径>"]
-
[--chown=<user>:<group>]
:可选参数,用户改变复制到容器内文件的拥有者和属组。 -
<源路径>
:源文件或者源目录,这里可以是通配符表达式, 因为是通过Go语言实现的,所以其通配符规则要满足Go语言的filepath.Match
规则。 -
<目标路径>
:容器内的指定路径,该路径不用事先建好,路径不存在的话,会自动创建。
源路径是通配符表达式,其通配符规则要满足 Go 的filepath.Match
规则。例如:
COPY hom* /mydir/ COPY hom?.txt /mydir/
复制:ADD
ADD
指令和COPY
的使用格式一致(同样需求下,官方推荐使用COPY
)。
功能也类似,不同之处如下:
-
ADD
的优点:在执行<源文件>
为tar
压缩文件且压缩格式为gzip
、bzip2
以及xz
的情况下,会自动复制并解压到<目标路径>
。 -
ADD
的缺点:在不解压的前提下,无法复制 tar 压缩文件。会令镜像构建缓存失效, 从而可能会令镜像构建变得比较缓慢。具体是否使用, 可以根据是否需要自动解压来决定。
一般使用中,ADD、COPY都遵守以下规则:
-
<src>
路径必须是内部语境的构建; 你不能COPY ../something /something
, 因为docker build
是将上下文目录(和子目录)发送到 docker 守护进程。 -
如果
<src>
是目录,则复制目录的全部内容,包括文件系统元数据。 -
如果
<src>
是任何其他类型的文件,则将其与其元数据一起单独复制。 在这种情况下,如果<dest>
以斜杠结尾/
,它将被视为一个目录, 其内容<src>
将被写入<dest>/base(<src>)
。 -
如果
<src>
直接指定了多个资源,或者由于使用了通配符, 则<dest>
必须是目录,并且必须以斜杠结尾/
。 -
如果
<dest>
不以斜杠结尾,则将其视为常规文件,并将其内容<src>
写入<dest>
-
如果
<dest>
不存在,则在其路径中创建所有丢失的目录。
特别的,当<src>
是可识别的压缩包如gzip、bzip2等tar包时,
首先会将包添加到镜像中,然后自动解压。
这可以说是与COPY命令在使用中的最大的区别。
容器默认运行程序:CMD
类似于RUN
指令,用于运行程序,但二者运行的时间点不同:
-
RUN
是在docker build
时。 -
CMD
是在docker run
时(日常运行默认启动)运行。
作用:
- 为启动的容器指定默认要运行的程序,程序运行结束,容器也就结束。
-
CMD
指令指定的程序可被docker run
命令行参数中指定要运行的程序所覆盖。
注意:如果Dockerfile
中如果存在多个CMD
指令,仅最后一个生效。
格式:
CMD <shell 命令> CMD ["<可执行文件或命令>","<param1>","<param2>",...] CMD ["<param1>","<param2>",...] # 该写法是为`ENTRYPOINT`指令指定的程序提供默认参数
推荐使用第二种格式,执行过程比较明确。
第一种格式实际上在运行的过程中也会自动转换成第二种格式运行,
并且默认可执行文件是sh
。
强制运行程序:ENTRYPOINT
类似于CMD
指令,但其不会被docker run
的命令行参数指定的指令所覆盖,
而且这些命令行参数会被当作参数送给ENTRYPOINT
指令指定的程序。
但是, 如果运行docker run
时使用了--entrypoint
选项,
此选项的参数可当作要运行的程序覆盖ENTRYPOINT
指令指定的程序。
-
优点:在执行
docker run
的时候可以指定ENTRYPOINT
运行所需的参数。 -
注意:如果
Dockerfile
中如果存在多个ENTRYPOINT
指令,仅最后一个生效。
格式:
ENTRYPOINT ["<executeable>","<param1>","<param2>",...]
可以搭配 CMD 命令使用:
一般是变参才会使用CMD
,这里的CMD
等于是在给ENTRYPOINT
传参,
以下示例会提到。
示例:
假设已通过Dockerfile
构建了nginx:test
镜像:
FROM nginx ENTRYPOINT ["nginx", "-c"] # 定参 CMD ["/etc/nginx/nginx.conf"] # 变参
1、不传参运行
$ docker run nginx:test
容器内会默认运行以下命令,启动主进程。
nginx -c /etc/nginx/nginx.conf
2、传参运行
$ docker run nginx:test -c /etc/nginx/new.conf
假设容器内已有/etc/nginx/new.conf
,则内会默认运行以下命令:
nginx -c /etc/nginx/new.conf
环境变量:ENV
设置环境变量,定义了环境变量,那么在后续的指令中,就可以使用这个环境变量。
格式:
ENV <key> <value> ENV <key1>=<value1> <key2>=<value2>...
以下示例设置NODE_VERSION = 7.2.0
, 在后续的指令中可以通过$NODE_VERSION
引用:
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"
构建参数:ARG
构建参数,与ENV
作用一至。不过作用域不一样。ARG
设置的环境变量仅对
Dockerfile
内有效,
也就是说只有docker build
的过程中有效,构建好的镜像内不存在此环境变量。
构建命令docker build
中可以用--build-arg <参数名>=<值>
来覆盖。
格式:
ARG <参数名>[=<默认值>]
元数据:LABEL
label
用于添加镜像的元数据,采用key-value
的形式。
LABEL <key>=<value>
比如我们添加如下LABEL
:
LABEL "miantainer"="iqsing.github.io" LABEL "version"="v1.2" LABEL "author"="waterman&&iqsing"
为了防止创建三层,我们最好通过一个标签来写。
LABEL "miantainer"="iqsing.github.io" \ "version"="v1.2" \ "author"="waterman&&iqsing"
我们通过docker inspect
来查看镜像label信息
$docker inspect centos_labels:v1 "Labels": { "author": "waterman&&iqsing", "miantainer": "iqsing.github.io", "org.label-schema.build-date": "20201204", "org.label-schema.license": "GPLv2", "org.label-schema.name": "CentOS Base Image", "org.label-schema.schema-version": "1.0", "org.label-schema.vendor": "CentOS", "version": "v1.2" }
匿名卷:VOLUME
定义匿名数据卷。在启动容器时忘记挂载数据卷,会自动挂载到匿名卷。
作用:
- 避免重要的数据,因容器重启而丢失,这是非常致命的。
- 避免容器不断变大。
格式:
VOLUME ["<路径1>", "<路径2>"...] VOLUME <路径>
在启动容器docker run
的时候,我们可以通过-v
参数修改挂载点。
例:
FROM centos RUN mkdir /volume RUN echo "hello world" > /volume/greeting VOLUME /volume
构建镜像后,创建一个容器
[root@localhost dockerfiles]# docker create --name centos_volume centos_volue:v1 [root@localhost dockerfiles]# docker inspect centos_volume "Mounts": [ { "Type": "volume", "Name": "494cdb193984680045c36a16bbc2b759cf568b55c7e9b0852ccf6dff8bf79c46", "Source": "/var/lib/docker/volumes/494cdb193984680045c36a16bbc2b759cf568b55c7e9b0852ccf6dff8bf79c46/_data", "Destination": "/volume", "Driver": "local", "Mode": "", "RW": true, "Propagation": "" } ],
这样我们就通过VOLUME
指令创建一个存储卷,
你可以通过--volumes-from
共享这个容器
声明端口:EXPOSE
仅仅只是声明端口,只是告诉dockerfile的阅读者,
我们构建的镜像需要暴露哪些端口,只是一个信息。
在容器中还是需要通过-p
选项来暴露端口。
作用:
- 帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射。
-
在运行时使用随机端口映射时,也就是
docker run -P
时, 会自动随机映射EXPOSE
的端口。
格式:
EXPOSE <端口1> [<端口2>...]
工作目录:WORKDIR
用WORKDIR
指定的工作目录,必须是提前创建好的,会在构建镜像的每一层中都存在。
docker build
构建镜像过程中的,每一个RUN
命令都是新建的一层。
只有通过WORKDIR
创建的目录才会一直存在。
格式:
WORKDIR <工作目录路径>
指定用户:USER
用于指定执行后续命令的用户和用户组,这边只是切换后续命令执行的用户 (用户和用户组必须提前已经存在)。
格式:
USER <用户名>[:<用户组>]
健康检查:HEALTHCHECK
用于指定某个程序或者指令来监控 docker 容器服务的运行状态。
格式:
HEALTHCHECK [选项] CMD <命令>:设置检查容器健康状况的命令 HEALTHCHECK NONE:如果基础镜像有健康检查指令,使用这行可以屏蔽掉其健康检查指令
延迟构建:ONBUILD
用于延迟构建命令的执行。简单的说,就是Dockerfile
里用ONBUILD
指定的命令,
在本次构建镜像的过程中不会执行(假设镜像为test-build
)。
当有新的Dockerfile
使用了之前构建的镜像FROM test-build
,
这是执行新镜像的Dockerfile
构建时候,
会执行test-build
的Dockerfile
里的ONBUILD
指定的命令。
格式:
ONBUILD <其它指令>
加速方法
优化Dockerfile
最简单的加速是改写Dockerfile
:
Dockerfile 中的一些命令(ADD
/COPY
/RUN
) 会产生新的层,
而 Docker 会自动跳过已经构建好的层。所以一般优化的原则基于以下几点:
- 变动越小的命令,越靠前,增加 cache 使用率。
- 合并目的相同的命令,减少 layer 层数。
- 使用国内源,或者内网服务加速构建。
- 少装些东西,不是代码依赖的就尽量别装了…记得加上合适的注释,
- 以便日后的维护。
改写以后的Dockerfile
可能长这样:
FROM python:3.8-buster WORKDIR /app # 默认使用上海时区 + 阿里源 RUN echo "Asia/Shanghai" > /etc/timezone && dpkg-reconfigure -f noninteractive tzdata && \ echo "deb http://mirrors.aliyun.com/debian/ buster main non-free contrib" > /etc/apt/sources.list # 预装必须的包,sentry-cli 是预先存入内网的 RUN apt-get update && apt-get -y dist-upgrade && apt-get -y install git && \ wget http://internal-nginx-service.domain.com/sentry.sh /usr/bin/sentry-cli && \ pip install pipenv # 装依赖,顺便祝 pipenv 早日发布新版本 COPY Pipfile Pipfile.lock ./ RUN pipenv sync # 代码频繁变更,放在文件底部,下面就别加更多命令了 COPY code /app/code
分布式加速
修改后的Dockerfile虽然能加速本地构建,但是用公司的分布式 gitlab runner 构建以后, 有时镜像没用到 cache,又跑了一遍漫长的构建过程。
分布式构建在 codebase 足够大的情况下,CI/CD 一般都是分布式多台机器的, 默认的 docker build 只会从本地寻找 cache layer,无法应对如此复杂的场面。
简单的办法是使用docker build --cache-from
指定镜像,我们会在 ci 脚本中这么写:
docker pull LKI/code:latest || true docker build . -t LKI/code:latest --cache-from LKI/code:latest docker push LKI/code:latest
但是这样手写的弊端是逻辑比较臃肿, 比如要完美适配多分支构建(dev/master/hotfix/release)的话, 往往就要自己实现一套判断究竟 cache from 哪个版本的逻辑。
更通用的办法是使用类似 GoogleContainerTools/kaniko 这样的工具来构建。 最适合 kaniko 的场景是 kaniko + kubernetes,但这个我们留到最后一章再讲, 我们顺着我们的工作流往下看。
使用 kaniko + docker 的构建, 我们可以把上面的 pull/build/push 三连改写为以下这样:
# 这个命令包括了 cache/build/push docker run \ -v "$CODE"/LKI/code:/workspace \ gcr.io/kaniko-project/executor:latest \ --cache=true \ --context dir:///workspace/ \ --destination LKI/code:latest
上面提到,kaniko 可以直接丢到 kubernetes 集群中构建:
apiVersion: v1 kind: Pod metadata: name: kaniko spec: containers: - name: kaniko image: gcr.io/kaniko-project/executor:latest args: ["--dockerfile=Dockerfile", # 没错,可以直接从 s3 里捞代码构建 "--context=s3:///bucket/code/", "--destination=LKI/code:latest"] volumeMounts: - name: kaniko-secret mountPath: /secret restartPolicy: Never volumes: - name: kaniko-secret secret: secretName: kaniko-secret
多阶段构建
不使用多阶段构建
Dockerfile中每新增一个指令都会在镜像中生产新的层, 一个高效的Dockerfile应该在继续下一层之前清除之前所有不需要的资源。
不使用多阶段构建时,我们通常会创建两dockerfile
:
- 一个用于开发及编译应用
- 另一个用于构建精简的生产镜像。这样能比较大限度的减小生产镜像的大小。
例,首先会创建一个dockerfile
用于编译应用:
FROM golang:1.16 WORKDIR /go/src COPY app.go ./ RUN go build -o myapp app.go
构建镜像:
$ docker build -t builder_app:v1 .
下一步就是构建生产镜像,采用scratch
这个空镜像,不仅可以减小容器尺寸,
还可以提高安全性:
FROM scratch WORKDIR /server COPY myapp ./ CMD ["./myapp"]
构建镜像
$ docker build --no-cache -t server_app:v1 .
看两个镜像大小:
$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE server_app v1 6ebc0833cad0 6 minutes ago 1.94MB builder_app v1 801f0b615004 23 minutes ago 921MB
不使用多阶段构建也可以构建出生产镜像。但是要维护两个dockerfile, 还需要将app遗留到本地,并且带来了更多存储空间开销。 在使用多阶段构建时能比较好的解决以上问题。
使用多阶段构建
在一个Dockerfile中使用多个FROM
指令,每个FROM
都可以使用不同的基镜像,
并且每条指令都将开始新阶段构建。
在多阶段构建中,可以将资源从一个阶段复制到另一个阶段, 在最终镜像中只保留我们所需要的内容。
将上面实例的两个Dockerfile合并为如下:
#阶段1 FROM golang:1.16 WORKDIR /go/src COPY app.go ./ RUN go build app.go -o myapp #阶段2 FROM scratch WORKDIR /server COPY --from=0 /go/src/myapp ./ CMD ["./myapp"]
构建镜像
$ docker build --no-cache -t server_app:v2 .
查看构建好的镜像
# docker images REPOSITORY TAG IMAGE ID CREATED SIZE server_app v2 20225cb1ea6b 12 seconds ago 1.94MB
无需创建额外镜像,以更简单的方式构建出了同样微小的目标镜像。
可以看到在多阶段构建dockerfile
中最关键的是:
COPY --from=0 /go/src/myapp ./
通过--from=0
指定我们资源来源,这里的0
即是指第一阶段。
命令构建阶段
默认情况下构建阶段没有名称,可以通过整数0~N
来引用,即第一个从0
开始。
其实还可以在FROM
指令中添加AS <NAME>
来命名构建阶段,
接着在COPY
指令中通过<NAME>
引用。我们对上面dockerfile修改如下:
#阶段1命名为builder FROM golang:1.16 as builder WORKDIR /go/src COPY app.go ./ RUN go build app.go -o myapp #阶段2 FROM scratch WORKDIR /server #通过名称引用 COPY --from=builder /go/src/myapp ./ CMD ["./myapp"]
只构建某个阶段
构建镜像时不一定需要构建整个 Dockerfile,通过--target
参数指定某个目标阶段构建
,比如开发阶段可以只构建builder
阶段进行测试:
$ docker build --target builder -t builder_app:v2 .
使用外部镜像
使用多阶段构建时,不仅可以使用从之前在 Dockerfile 中创建的阶段进行复制。
还可以使用COPY --from
指令从单独的镜像复制,如本地镜像名称、本地或
Dockerhub上可用的标签或标签ID。Docker客户端在必要时会拉取需要的镜像到本地。
COPY --from httpd:latest /usr/local/apache2/conf/httpd.conf ./httpd.conf
从上一阶段创建新的阶段
我们可以通过FROM指令来引用上一阶段作为新阶段的开始
#阶段1命名为builder FROM golang:1.16 as builder WORKDIR /go/src COPY app.go ./ RUN go build app.go -o myapp #阶段2 FROM builder as builder_ex ADD dest.tar ./ ...