疑问差不多解决了,是时候看看 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 | ## 可以看到发送上下文的过程 |
不信的话,可以试试 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 | $ docker build - < Dockerfile |
这种情况不能用 COPY 指令,因为没有上下文环境,除非 docker build - < context.tar.gz
这种情况会将压缩包展开,展开的目录视为上下文发送。
构建过程
说白了,不让我们手动 commit, 而是交给 docker引擎去 commit。
- 从基础镜像运行一个容器
- 执行一条指令,对容器作出修改 (容器可写)
- 执行类似 docker commit 的操作,提交到一个新的镜像层
- 以此新镜像运行一个新容器
- 运行下一条指令,直至 dockerfile中全部指令执行完毕。
仔细看看:
dockerfile 如下:
1 | # 说明该镜像以哪个镜像为基础 |
构建过程中生成的容器会被移除,但是中间的镜像是没有被移除的,可以运行看看其操作的结果:
中间层镜像可以用于调试,查找错误。
查看完整的构建过程,还可以使用
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 | RUN echo '<h1>Hello, Docker!</h1>' > /usr/share/nginx/html/index.html |
正确的写法,应该是合并为一个命令: (Union FS 是有最大层数限制的,比如 AUFS,曾经是最大不得超过 42 层,现在是不得超过 127 层)
1 | FROM debian:jessie |
这里要清除中间文件,缓存文件;写 RUN 指令,不是写 shell 脚本。
- COPY 指令
目标路径不需要事先创建,如果目录不存在会在复制文件前先行创建缺失目录。
使用 COPY 指令,源文件的各种元数据都会保留。比如读、写、执行权限、文件变更时间等。
容器构建过程中需要执行的指令。
- CMD 指令
CMD 的意思和 docker run 时候指定的 command 是一样的,表示容器运行之后,自动执行的命令。
一个 Dockerfile 可以包含多个RUN命令,但是只能有一个CMD命令。指定了CMD命令以后,docker run 命令就不能附加命令了(比如前面的/bin/bash),否则它会覆盖CMD命令。
- RUN 命令在 image 文件的构建阶段执行(构建新层),执行结果都会打包进入 image 文件
- CMD 命令则是在容器启动后执行
下面两种形式等价:
1 | CMD echo $HOME |
容器就是一个应用,不要把他当做虚拟机,还去跑什么后台服务。一个容器就是一个应用程序:
1 | ## (主进程仍然是 sh, 它执行完就退出了) |
CMD 命令一般是在 COPY,RUN 等命令之后执行(不管其位置?至少基于某个镜像是这样的),因为它是启动容器主进程的命令。
my-node镜像如下:
1 | FROM node:slim |
子镜像如下:
1 | FROM my-node |
- ENTRYPOINT
个人觉得这个命令主要是弥补 CMD 的一些限制。(被覆盖 & 只能用一次)
NTRYPOINT 的目的和 CMD 一样,都是在指定容器启动程序及参数。当指定了 ENTRYPOINT 后,CMD 的含义就发生了改变,不再是直接的运行其命令,而是将 CMD 的内容作为参数传给 ENTRYPOINT 指令:
1 | <ENTRYPOINT> "<CMD>" |
出现它的原因是 跟在镜像名后面的是 command,运行时会替换 CMD 的默认值;所以有必要先把 CMD 存储在 ENTRYPOINT。
动手实践一下: (对比下面的代码很容易发现问题)
1 | FROM ubuntu:16.04 |
运行时输入 docker run myip
,如果要增加命令参数,只能覆盖: docker run myip curl -s http://ip.cn -i
。
但是用 ENTRYPOINT 的情况:
1 | FROM ubuntu:16.04 |
增加参数,直接用就好了 docker run myip -i
。
ENTRYPOINT 在运行时也可以替代,不过比 CMD 要略显繁琐,需要通过 docker run 的参数 –entrypoint 来指定。(但是 CMD 是只要 docker run 后面跟着命令了,自动被替代)
另外的例子(redis 官方镜像):
1 | FROM alpine:3.4 |
docker-entrypoint.sh文件:
1 |
|
这里 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 | RUN cd /app |
在 Shell 中,连续两行是同一个进程执行环境,因此前一个命令修改的内存状态,会直接影响后一个命令;而在 Dockerfile 中,这两行 RUN 命令的执行环境根本不同,是两个完全不同的容器。每一个 RUN 都是启动一个容器、执行命令、然后提交存储层文件变更。第一层 RUN cd /app 的执行仅仅是当前进程的工作目录变更,一个内存上的变化而已,其结果不会造成任何文件变更。而到第二层的时候,启动的是一个全新的容器,跟第一层的容器更完全没关系,自然不可能继承前一层构建过程中的内存变化。
因此如果需要改变以后各层的工作目录的位置,那么应该使用 WORKDIR 指令。相当于 Cd,改变当前运行目录。
WORKDIR 应该使用容器内的绝对路径;如果使用相对路径,那么多次 WORKDIR 其实是继承前面的路径
- USER
WORKDIR 是改变工作目录,USER 则是改变之后层的执行 RUN, CMD 以及 ENTRYPOINT 这类命令的身份。
切换用户,改变环境状态并影响以后的层。(相当于 su 用户名 )
当然,和 WORKDIR 一样,USER 只是帮助你切换到指定用户而已,这个用户必须是事先建立好的,否则无法切换。
1 | RUN groupadd -r redis && useradd -r -g redis redis |
使用模式也有多种: (不指定,默认就是 root 用户)
注意:在镜像中,用户和用户组每次被分配的 UID/GID 都是不确定的,下次重新构建镜像时被分配到的 UID/GID 可能会不一样。如果要依赖确定的 UID/GID,你应该显示的指定一个 UID/GID。
在执行期间希望改变身份,建议使用 gosu;因为用 USER 需要麻烦的配置 (su/sudo 肯定不能用,容器不是 shell)
1 | # 建立 redis 用户,并使用 gosu 换另一个用户执行命令 |
- ONBUILD
ONBUILD 是一个特殊的指令,它后面跟的是其它指令,比如 RUN, COPY 等,而这些指令,在当前镜像构建时并不会被执行。只有当以当前镜像为基础镜像,去构建下一级镜像的时候才会被执行。(就像条件触发器)
例如有这样的一个 nodejs 环境配置:
1 | FROM node:slim |
由于不同项目的 node 依赖不同,所以当以此镜像为基础镜像构建时在执行 npm install
才好。
而子镜像的构建脚本可以是简单的一句话 FROM my-node
。
- MAINTAINER
具体的,例如:
1 | MAINTAINER merlinwizard "wizardmerlin945@gmail.com" |
- LABEL
你可以给镜像添加标签来帮助组织镜像、记录许可信息、辅助自动化构建等。每个标签一行,由 LABEL 开头加上一个或多个标签对。
例如:
1 | # Set multiple labels at once, using line-continuation characters to break long lines |
标签必须唯一且以键值对的方式。
理论上对于key没有要求,但是最好按照一定的格式,比如上面的情况,其他的可以参考 《Key format recommendations》。
而不是这样写:
1 | # Set one or more individual labels |
多种构建方式
- 单一 Dockerfile 构建(全部放入一个 Dockerfile 中,各种 Dockfile 指令混杂;构建的镜像大)
- 分散到多个 Dockerfile (单个dockerfile指令简单,但是部署复杂,需要些构建脚本;把工作转嫁到 shell 脚本上使用 docker 指令;不放入镜像)
- 多阶段构建 (一个 Docker 但是多个阶段&步骤构建; 其实是对第二种方式的优化,构建的镜像和第二种方式相差无几)
多阶段构建的案例:
1 | FROM golang:1.9-alpine |
然后构建语法 docker build go/helloworld:v1 .
,如果不这么做,那么多个 dockerfile 的话,还要写一个类似下列脚本的内容:
1 |
|
可以看到中途以上一阶段镜像为基础创建了一个临时容器,把容器内 app 拷贝到当前 app 目录下,然后删除临时容器,进行下一阶段构建,即创建一个容器来运行它。
导入导出
镜像和容器都可以导入导出
- 镜像导入导出到 tar 文件,save/load
- 容器导入导出到 tar 文件,import/export (相当于快照到本地文件)
有趣的是保存进行和加载镜像不一定是 tar 格式,也可以自己指定,比如:
1 | ## 保存 (不使用 -o 参数) |
高级玩法,生成镜像然后迁移 (不同于 scp ,这里直接在另一台装有 docker 环境的机器上load 了)
1 | ## pv 是 linux 的进度条查看工具 progress viewer |
(没有 pv 进度条的话可以不使用),我的玩法如下:
容器的快照导入导出我一般不用,因为导入之后还是按照镜像处理,参考如下:
1 | $ docker container ls -a |
并且导入为镜像之后,还丢失了所有的历史记录和元数据信息(即仅保存容器当时的快照状态),而镜像存储文件将保存完整记录(体积也要大)。
最佳实践
官方仓库里面有好多示例可以看,不过当前最推荐的还是这个《最佳实践》,我简单梳理如下:
上面已经多多少涉及到了,下面在补充一下:
- 容器的部署所需的配置和设置应该是极小的,方便随时开启和销毁容器
- 单一任务原则,一个容器只运行一个进程(多个容器互相协作,通过网络进行通信)
- 镜像的层数应该尽量少
- 多行参数时,分行写,特别是
RUN
指令 - 加快构建可以尝试使用缓存(默认生效;但要知道其失效的时机),如果不用可以使用 –no-cache=true 选项
- 通常这个过程由 docker 引擎控制(也只是检查本镜像和子镜像的构建指令)
- 缓存部分可以参考上面的
构建过程
部分
指令部分的建议不说了,上面已经说得好清晰了。
Merlin 2018.2 简单的东西,整理起来真的很耗时啊…