技术: 深耕 Docker 生态圈(六){Dockerfile 文法}

疑问差不多解决了,是时候看看 Dockerfile 的语法规则了。(自己过滤了一遍,精华)

主要解决两个问题:

  • Docker build 的相关用法(包括不同的构建方式)
  • Dockerfile 的指令怎么写

参考: https://docs.docker.com/engine/reference/builder/#usage


Build

有趣的是,使用不同的构建方式可能得到的镜像的大小也不一样。可以参考下面 多钟构建方式

提一个问题: docker build -t nginx:v3 . 最后那个点是真的指定的 dockfile的目录么?

以为这个路径是在指定 Dockerfile 所在路径,这么理解其实是不准确的。如果对应上面的命令格式,你可能会发现,这是在指定上下文路径。

当我们进行镜像构建的时候,并非所有定制都会通过 RUN 指令完成,经常会需要将一些本地文件复制进镜像,比如通过 COPY 指令、ADD 指令等。而 docker build 命令构建镜像,其实并非在本地构建,而是在服务端,也就是 Docker 引擎中构建的。Docker 引擎要获取文件路径,那么这个时候就需要镜像上下文了。docker build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎收到这个上下文包后,展开就会获得构建镜像所需的一切文件。

1
2
3
4
## 可以看到发送上下文的过程
$ docker build -t nginx:v3 .
Sending build context to Docker daemon 2.048 kB
...

不信的话,可以试试 COPY 指令,这里借助的就是上下文,而不是当前 dockerfile 所在目录。

其实意思就是, docker 引擎拿到的是打包的目录,接收的目录 && 和原来那个 dockerfile文件所在目录已经没有关系了。

btw: .dockerignore 写入这个文件的内容,不会被打包进镜像。

此外 build -f 指定具体dockerfile ,此时可以指定某个文件作为指令文件,然后发送给 docker 引擎的就是该文件所在的目录文件。不指定的话默认发送的就是当前路径下的 Dockerfile。

Build 可以从 Dockfile进行,也可以从其他方式进行:

从一个 git repo :

1
$ docker build https://github.com/twang2218/gitlab-ce-zh.git

从 tar 文件:

1
$ docker build http://server/context.tar.gz

从 stdin :

1
2
3
4
5
$ docker build - < Dockerfile

或者

$ cat Dockerfile | docker build -

这种情况不能用 COPY 指令,因为没有上下文环境,除非 docker build - < context.tar.gz 这种情况会将压缩包展开,展开的目录视为上下文发送。

构建过程

说白了,不让我们手动 commit, 而是交给 docker引擎去 commit。

  • 从基础镜像运行一个容器
  • 执行一条指令,对容器作出修改 (容器可写)
  • 执行类似 docker commit 的操作,提交到一个新的镜像层
  • 以此新镜像运行一个新容器
  • 运行下一条指令,直至 dockerfile中全部指令执行完毕。

仔细看看:

dockerfile 如下:

1
2
3
4
5
6
7
8
9
10
11
12
# 说明该镜像以哪个镜像为基础
FROM ubuntu:latest

# 构建者的基本信息
MAINTAINER merlin

# 在 build 这个镜像时执行的操作
RUN apt-get update
RUN apt-get install -y git

# 拷贝本地文件到镜像中
COPY ./* /usr/share/gitdir/

构建过程中生成的容器会被移除,但是中间的镜像是没有被移除的,可以运行看看其操作的结果:

中间层镜像可以用于调试,查找错误。

查看完整的构建过程,还可以使用 docker history 镜像id 命令。

缓存:

不使用缓存的话可以使用 --no-cache=true 选项,或者使用 ENV 设置环境变量 REFERESH_DATE,每次构建修改这个变量,那么构建则不会在使用缓存。

Dockfile指令

Dockerfile 中每一个指令都会建立一层,我们可以把每一层修改、安装、构建、操作的命令都写入一个脚本。

我下面偷个懒,直接列出作用:

名称 作用 说明
FROM 指定基础镜像 在基础镜像之上进行定制(基于官方镜像的好处是,官方镜像一般很小,比如 alpine)
RUN 用来执行命令行命令 支持 exec脚本, shell命令;一般是对镜像做一些环境部署
CMD 容器启动命令 容器启动后执行的命令,而不是针对镜像;RUN是针对于镜像
COPY 复制文件 注意指定发送的上下文环境,前者是相对路径,后者是容器内的绝对路径
ADD 复制文件 源文件可以是远端文件或者压缩包(该指令不实用,因为下载的文件同样需要 RUN 指令处理)
ENTRYPOINT 入口点命令 需要通过 docker run 的参数 –entrypoint 来指定
ENV 设置环境变量给指令用 环境变量可以在构建和容器运行期间使用;可以使用等号或者空格,多个环境变量也可以定义在同一行
ARG 设置环境变量 只用在构建阶段,容器运行期间则不存在了,只是build的过程有
EXPOSE 声明暴露的端口 将容器端口暴露出来,允许外部连接这个端口(仅仅是声明)
VOLUME 本地的路径的映射 容器存储层是不能写的,动态数据都保存在卷中,可以事先指定匿名卷,避免用户忘记挂载
WORKDIR 是执行的路径,也就是cmd entry point执行的路径 容器内部应用的执行路径,比如容器内部shell跑起来的当前目录
USER 指定当前用户 改变当前用户,影响之后层的执行(相当于切换用户,前提是用户一定要存在)
ONBUILD 子级构建时才执行 只有当以当前镜像为基础镜像,去构建下一级镜像的时候才会被执行
MAINTAINER 指定镜像作者信息 相当于 commit -a 选项
LABEL 给镜像添加多个标签 最好一次 LABEL 指令设置完所有标签

下面补充&详细说明:

  • hello-world 那个镜像? FROM SCRATCH

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

  • RUN指令

每一个 RUN 的行为,就和刚才我们手工建立镜像的过程一样:新建立一层,在其上执行这些命令,执行结束后,commit 这一层的修改,构成新的镜像。

1
2
3
4
5
RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html

或者 exec其他命令
RUN apt-get update
RUN make -C /usr/src/redis install

正确的写法,应该是合并为一个命令: (Union FS 是有最大层数限制的,比如 AUFS,曾经是最大不得超过 42 层,现在是不得超过 127 层)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM debian:jessie

RUN buildDeps='gcc libc6-dev make' \
&& apt-get update \
&& apt-get install -y $buildDeps \
&& wget -O redis.tar.gz "http://download.redis.io/releases/redis-3.2.5.tar.gz" \
&& mkdir -p /usr/src/redis \
&& tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
&& make -C /usr/src/redis \
&& make -C /usr/src/redis install \
&& rm -rf /var/lib/apt/lists/* \
&& rm redis.tar.gz \
&& rm -r /usr/src/redis \
&& apt-get purge -y --auto-remove $buildDeps

这里要清除中间文件,缓存文件;写 RUN 指令,不是写 shell 脚本。

  • COPY 指令

目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。
使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。

容器构建过程中需要执行的指令。

  • CMD 指令

CMD 的意思和 docker run 时候指定的 command 是一样的,表示容器运行之后,自动执行的命令。

一个 Dockerfile 可以包含多个RUN命令,但是只能有一个CMD命令。指定了CMD命令以后,docker run 命令就不能附加命令了(比如前面的/bin/bash),否则它会覆盖CMD命令。

  • RUN 命令在 image 文件的构建阶段执行(构建新层),执行结果都会打包进入 image 文件
  • CMD 命令则是在容器启动后执行

下面两种形式等价:

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

容器就是一个应用,不要把他当做虚拟机,还去跑什么后台服务。一个容器就是一个应用程序:

1
2
3
4
5
6
## (主进程仍然是 sh, 它执行完就退出了)
CMD service nginx start
CMD [ "sh", "-c", "service nginx start"]

## 主程序是 nginx
CMD ["nginx", "-g", "daemon off;"]

CMD 命令一般是在 COPY,RUN 等命令之后执行(不管其位置?至少基于某个镜像是这样的),因为它是启动容器主进程的命令。

my-node镜像如下:

1
2
3
4
FROM node:slim
RUN mkdir /app
WORKDIR /app
CMD [ "npm", "start" ]

子镜像如下:

1
2
3
4
FROM my-node
COPY ./package.json /app
RUN [ "npm", "install" ]
COPY . /app/
  • ENTRYPOINT

个人觉得这个命令主要是弥补 CMD 的一些限制。(被覆盖 & 只能用一次)
NTRYPOINT 的目的和 CMD 一样,都是在指定容器启动程序及参数。当指定了 ENTRYPOINT 后,CMD 的含义就发生了改变,不再是直接的运行其命令,而是将 CMD 的内容作为参数传给 ENTRYPOINT 指令:

1
<ENTRYPOINT> "<CMD>"

出现它的原因是 跟在镜像名后面的是 command,运行时会替换 CMD 的默认值;所以有必要先把 CMD 存储在 ENTRYPOINT。

动手实践一下: (对比下面的代码很容易发现问题)

1
2
3
4
5
FROM ubuntu:16.04
RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
CMD [ "curl", "-s", "http://ip.cn" ]

运行时输入 docker run myip,如果要增加命令参数,只能覆盖: docker run myip curl -s http://ip.cn -i

但是用 ENTRYPOINT 的情况:

1
2
3
4
5
FROM ubuntu:16.04
RUN apt-get update \
&& apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT [ "curl", "-s", "http://ip.cn" ]

增加参数,直接用就好了 docker run myip -i

ENTRYPOINT 在运行时也可以替代,不过比 CMD 要略显繁琐,需要通过 docker run 的参数 –entrypoint 来指定。(但是 CMD 是只要 docker run 后面跟着命令了,自动被替代)

另外的例子(redis 官方镜像):

1
2
3
4
5
6
7
8
FROM alpine:3.4
...
RUN addgroup -S redis && adduser -S -G redis redis
...
ENTRYPOINT ["docker-entrypoint.sh"]

EXPOSE 6379
CMD [ "redis-server" ]

docker-entrypoint.sh文件:

1
2
3
4
5
6
7
8
9
#!/bin/sh
...
# allow the container to be started with `--user`
if [ "$1" = 'redis-server' -a "$(id -u)" = '0' ]; then
chown -R redis .
exec su-exec redis "$0" "$@"
fi

exec "$@"

这里 CMD 中的内容就作为第一个参数了(第0个参数是脚本本身)。

总之就是,CMD 会被当做参数传递给 ENTRYPOINT,所以一旦有 ENTRYPOINT,那么 CMD 可以之存储命令参数。

CMD 应该在极少的情况下才能以 CMD [“param”, “param”] 的形式与 ENTRYPOINT 协同使用,除非你和你的镜像使用者都对 ENTRYPOINT 的工作方式十分熟悉。

  • VOLUME

运行时指定的 -v 参数可以覆盖 dockerfile 中指定的匿名卷配置,例如 dockerfile 写了:

1
VOLUME /data

构建生成镜像,然后运行时指定了 -v 参数

1
docker run -d -v mydata:/data 镜像id

就使用了 mydata 这个命名卷挂载到了 /data 这个位置,替代了 Dockerfile 中定义的匿名卷的挂载配置。

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

  • EXPOSE

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

此外,在早期 Docker 版本中还有一个特殊的用处。以前所有容器都运行于默认桥接网络中,因此所有容器互相之间都可以直接访问,这样存在一定的安全性问题。于是有了一个 Docker 引擎参数 –icc=false,当指定该参数后,容器间将默认无法互访,除非互相间使用了 –links 参数的容器才可以互通,并且只有镜像中 EXPOSE 所声明的端口才可以被访问。这个 –icc=false 的用法,在引入了 docker network 后已经基本不用了,通过自定义网络可以很轻松的实现容器间的互联与隔离。

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

  • WORKDIR

容器和直接运行 shell 命令是不一样的, 运行如下命令:

1
2
RUN cd /app
RUN echo "hello" > world.txt

在 Shell 中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响后一个命令;而在 Dockerfile 中,这两行 RUN 命令的执行环境根本不同,是两个完全不同的容器。每一个 RUN 都是启动一个容器、执行命令、然后提交存储层文件变更。第一层 RUN cd /app 的执行仅仅是当前进程的工作目录变更,一个内存上的变化而已,其结果不会造成任何文件变更。而到第二层的时候,启动的是一个全新的容器,跟第一层的容器更完全没关系,自然不可能继承前一层构建过程中的内存变化。

因此如果需要改变以后各层的工作目录的位置,那么应该使用 WORKDIR 指令。相当于 Cd,改变当前运行目录。

WORKDIR 应该使用容器内的绝对路径;如果使用相对路径,那么多次 WORKDIR 其实是继承前面的路径

  • USER

WORKDIR 是改变工作目录,USER 则是改变之后层的执行 RUN, CMD 以及 ENTRYPOINT 这类命令的身份。
切换用户,改变环境状态并影响以后的层。(相当于 su 用户名 )

当然,和 WORKDIR 一样,USER 只是帮助你切换到指定用户而已,这个用户必须是事先建立好的,否则无法切换。

1
2
3
RUN groupadd -r redis && useradd -r -g redis redis
USER redis
RUN [ "redis-server" ]

使用模式也有多种: (不指定,默认就是 root 用户)

注意:在镜像中,用户和用户组每次被分配的 UID/GID 都是不确定的,下次重新构建镜像时被分配到的 UID/GID 可能会不一样。如果要依赖确定的 UID/GID,你应该显示的指定一个 UID/GID。

在执行期间希望改变身份,建议使用 gosu;因为用 USER 需要麻烦的配置 (su/sudo 肯定不能用,容器不是 shell)

1
2
3
4
5
6
7
8
# 建立 redis 用户,并使用 gosu 换另一个用户执行命令
RUN groupadd -r redis && useradd -r -g redis redis
# 下载 gosu
RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.7/gosu-amd64" \
&& chmod +x /usr/local/bin/gosu \
&& gosu nobody true
# 设置 CMD,并以另外的用户执行
CMD [ "exec", "gosu", "redis", "redis-server" ]
  • ONBUILD

ONBUILD 是一个特殊的指令,它后面跟的是其它指令,比如 RUN, COPY 等,而这些指令,在当前镜像构建时并不会被执行。只有当以当前镜像为基础镜像,去构建下一级镜像的时候才会被执行。(就像条件触发器)

例如有这样的一个 nodejs 环境配置:

1
2
3
4
5
6
7
FROM node:slim
RUN mkdir /app
WORKDIR /app
ONBUILD COPY ./package.json /app
ONBUILD RUN [ "npm", "install" ]
ONBUILD COPY . /app/
CMD [ "npm", "start" ]

由于不同项目的 node 依赖不同,所以当以此镜像为基础镜像构建时在执行 npm install 才好。
而子镜像的构建脚本可以是简单的一句话 FROM my-node

  • MAINTAINER

具体的,例如:

1
MAINTAINER merlinwizard "wizardmerlin945@gmail.com"
  • LABEL

你可以给镜像添加标签来帮助组织镜像、记录许可信息、辅助自动化构建等。每个标签一行,由 LABEL 开头加上一个或多个标签对。

例如:

1
2
3
4
5
6
# Set multiple labels at once, using line-continuation characters to break long lines
LABEL vendor=ACME\ Incorporated \
com.example.is-beta= \
com.example.is-production="" \
com.example.version="0.0.1-beta" \
com.example.release-date="2015-02-12"

标签必须唯一且以键值对的方式。
理论上对于key没有要求,但是最好按照一定的格式,比如上面的情况,其他的可以参考 《Key format recommendations》

而不是这样写:

1
2
3
4
5
6
7
8
# Set one or more individual labels
LABEL com.example.version="0.0.1-beta"

LABEL vendor="ACME Incorporated"

LABEL com.example.release-date="2015-02-12"

LABEL com.example.version.is-production=""

多种构建方式

  • 单一 Dockerfile 构建(全部放入一个 Dockerfile 中,各种 Dockfile 指令混杂;构建的镜像大)
  • 分散到多个 Dockerfile (单个dockerfile指令简单,但是部署复杂,需要些构建脚本;把工作转嫁到 shell 脚本上使用 docker 指令;不放入镜像)
  • 多阶段构建 (一个 Docker 但是多个阶段&步骤构建; 其实是对第二种方式的优化,构建的镜像和第二种方式相差无几)

多阶段构建的案例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
FROM golang:1.9-alpine

RUN apk --no-cache add git

WORKDIR /go/src/github.com/go/helloworld/

RUN go get -d -v github.com/go-sql-driver/mysql

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/go/helloworld/app .

CMD ["./app"]

然后构建语法 docker build go/helloworld:v1 . ,如果不这么做,那么多个 dockerfile 的话,还要写一个类似下列脚本的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/bin/sh
echo Building go/helloworld:build

docker build -t go/helloworld:build . -f Dockerfile.build

## 以上面的镜像为基础构建临时容器
docker create --name extract go/helloworld:build
docker cp extract:/go/src/github.com/go/helloworld/app ./app
docker rm -f extract

echo Building go/helloworld:2

docker build --no-cache -t go/helloworld:2 . -f Dockerfile.copy
rm ./app

可以看到中途以上一阶段镜像为基础创建了一个临时容器,把容器内 app 拷贝到当前 app 目录下,然后删除临时容器,进行下一阶段构建,即创建一个容器来运行它。

导入导出

镜像和容器都可以导入导出

  • 镜像导入导出到 tar 文件,save/load
  • 容器导入导出到 tar 文件,import/export (相当于快照到本地文件)

有趣的是保存进行和加载镜像不一定是 tar 格式,也可以自己指定,比如:

1
2
3
4
5
## 保存 (不使用 -o 参数)
$ docker save alpine | gzip > alpine-latest.tar.gz

## 加载
$ docker load -i alpine-latest.tar.gz

高级玩法,生成镜像然后迁移 (不同于 scp ,这里直接在另一台装有 docker 环境的机器上load 了)

1
2
## pv 是 linux 的进度条查看工具 progress viewer
$ docker save <镜像名> | bzip2 | pv | ssh <用户名>@<主机名> 'cat | docker load'

(没有 pv 进度条的话可以不使用),我的玩法如下:

容器的快照导入导出我一般不用,因为导入之后还是按照镜像处理,参考如下:

1
2
3
4
5
6
7
8
9
$ docker container ls -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
7691a814370e ubuntu:14.04 "/bin/bash" 36 hours ago Exited (0) 21 hours ago test
$ docker export 7691a814370e > ubuntu.tar

$ cat ubuntu.tar | docker import - test/ubuntu:v1.0
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
test/ubuntu v1.0 9d37a6082e97 About a minute ago 171.3 MB

并且导入为镜像之后,还丢失了所有的历史记录和元数据信息(即仅保存容器当时的快照状态),而镜像存储文件将保存完整记录(体积也要大)。

最佳实践

官方仓库里面有好多示例可以看,不过当前最推荐的还是这个《最佳实践》,我简单梳理如下:

上面已经多多少涉及到了,下面在补充一下:

  • 容器的部署所需的配置和设置应该是极小的,方便随时开启和销毁容器
  • 单一任务原则,一个容器只运行一个进程(多个容器互相协作,通过网络进行通信)
  • 镜像的层数应该尽量少
  • 多行参数时,分行写,特别是 RUN 指令
  • 加快构建可以尝试使用缓存(默认生效;但要知道其失效的时机),如果不用可以使用 –no-cache=true 选项
    • 通常这个过程由 docker 引擎控制(也只是检查本镜像和子镜像的构建指令)
    • 缓存部分可以参考上面的 构建过程 部分

指令部分的建议不说了,上面已经说得好清晰了。


Merlin 2018.2 简单的东西,整理起来真的很耗时啊…

文章目录
  1. 1. Build
  2. 2. 构建过程
  3. 3. Dockfile指令
  4. 4. 多种构建方式
  5. 5. 导入导出
  6. 6. 最佳实践
|