Jade Dungeon

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压缩文件且压缩格式为gzipbzip2 以及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-buildDockerfile里的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 ./
...