镜像内部结构、构建镜像、镜像命名、Registry
第 3 章 Docker 镜像
3.1 镜像的内部结构
3.1.1 最小的镜像 hello-world
docker pull hello-world
docker images
docker run hello-world
Hello from Docker!
This message shows that your installation appears to be working correctly.
hello-world
的 Dockerfile 内容如下:
FROM scratch
COPY hello /
CMD ["/hello"]
FROM scratch
:此镜像是从白手起家,从 0 开始构建COPY hello /
:将文件hello
复制到镜像的根目录CMD ["/hello"]
:容器启动时,执行/hello
hello-world
虽然是一个完整的镜像,但它并没有什么实际用途。通常来说,我们希望镜像能提供一个基本的操作系统环境,用户可以根据需要安装和配置软件。这样的镜像我们称作 base 镜像。
3.1.2 base 镜像
base 镜像有两层含义:
- 不依赖其他镜像,从
scratch
构建 - 其他镜像可在其基础上进行扩展
所以,能称作 base 镜像的通常都是各种 Linux 发行版的 Docker 镜像,比如 Ubuntu, Debian, CentOS 等。
使用 docker pull centos
命令下载 centos
镜像并查看其镜像信息,发现大小仅为 200 MB
,为什么会这么小?
Linux 操作系统由内核空间和用户空间组成,如下图所示:
rootfs
内核空间是 Kernel,Linux 刚启动时会加载 bootfs 文件系统,之后 bootfs 会被卸载掉。
用户空间的文件系统是 rootfs,包含我们熟悉的 /dev
, /proc
, /bin
等目录。
对于 base 镜像而言,底层直接用 Host 的 kernel,自己只需提供 rootfs。
而对于一个精简的 OS,rootfs 可以很小,只需要包括最基本的命令、工具和程序库就可以了。
base 镜像提供最小安装的 Linux 发行版
CentOS 镜像的 Dockerfile 文件内容如下:
FROM scratch
ADD centos-7-docker.tar.xz /
CMD ["/bin/bash"]
第二行 ADD
指令添加到镜像的 tar 包就是 CentOS 7 的 rootfs。在制作镜像时,这个 tar 包会自动解压到 /
目录下,生成 /dev
, /porc
, /bin
等目录。
支持运行多种 Linux OS
不同 Linux 发行版的区别主要就是 rootfs。
比如 Ubuntu 14.04 使用 upstart
管理服务,apt
管理软件包;而 CentOS 7 使用 systemd
和 yum
。这些都是用户空间上的区别,Linux kernel 差别不大。
所以 Docker 可以同时支持多种 Linux 镜像,模拟出多种操作系统环境。
上图 Debian 和 BusyBox(一种嵌入式 Linux)上层提供各自的 rootfs,底层共用 Docker Host 的 kernel。
需要说明的是:
- base 镜像只是用户空间与发行版一致,Kernel 版本与发行版是不同的。
- 容器只能使用 Host 的 Kernel,并且不能修改。
3.1.3 镜像的分层结构
Docker 支持通过扩展现有镜像,创建新的镜像。
实际上,Docker Hub 中 99% 的镜像都是通过在 base 镜像中安装和配置需要的软件构建出来的。例如我们现在构建一个新的镜像,Dockerfile 如下:
FROM debian
RUN apt-get install emacs
RUN apt-get install apache2
CMD ["/bin/bash"]
构建过程如下图所示:
可以看到,新镜像是从 base 镜像一层一层叠加生成的。每安装一个软件,就在现有镜像的基础上增加一层。
Docker 镜像采用这种分层结构最大的好处就是 共享资源。
比如:有多个镜像都从相同的 base 镜像构建而来,那么 Docker Host 只需在磁盘上保存一份 base 镜像;同时内存中也只需加载一份 base 镜像,就可以为所有容器服务了。而且镜像的每一层都可以被共享,我们将在后面更深入地讨论这个特性。
如果多个容器共享一份基础镜像,当某个容器修改了基础镜像的内容,比如 /etc
目录下的文件时,修改会被限制在单个容器内,这就是容器的 Copy-on-Write 特性。
可写的容器层
当容器启动时,一个新的可写层会被加载到镜像的顶部。这一层通常被称作容器层,容器层之下的都叫镜像层。
所有对容器的改动——无论是添加、删除还是修改文件,都只会发生在容器层中。
并且,只有容器层是可写的,容器层下面的所有镜像层都是只读的。
镜像层数量可能会很多,所有镜像层会联合在一起组成一个统一的文件系统。在容器层中,用户看到的是一个叠加之后的文件系统。
- 添加文件:在容器中创建文件时,新文件被添加到容器层中。
- 读取文件:在容器中读取某个文件时,Docker 会从上往下依次在各镜像层中查找此文件。一旦找到,打开并读入内存。
- 修改文件:在容器中修改已存在的文件时,Docker 会从上往下依次在各镜像层中查找此文件。一旦找到,立即将其复制到容器层,然后修改之。
- 删除文件:在容器中删除文件时,Docker 也是从上往下依次在镜像层中查找此文件。找到后,会在容器层中记录下此删除操作。
Copy-on-Write
只有当需要修改时才复制一份数据,这种特性被称作 Copy-on-Write。可见,容器层保存的是镜像变化的部分,不会对镜像本身进行任何修改。
这样就解释了之前的问题:容器层记录对镜像的修改,所有镜像层都是只读的,不会被容器修改。所以镜像可以被多个容器共享。
3.2 构建镜像
对于 Docker 用户来说,最好的情况是不需要自己创建镜像。使用现成镜像的好处除了省去自己做镜像的工作量外,更重要的是可以利用前人的经验。
当然,某些情况下我们也不得不自己构建镜像,比如:
- 找不到现成的镜像,比如自己开发的应用程序
- 需要在镜像中加入特定的功能,比如官方镜像几乎都不提供
ssh
Docker 提供了两种构建镜像的方法:
- docker commit 命令
- Dockerfile 构建文件
3.2.1 docker commit
docker commit 命令是创建新镜像最直观的方法,其过程包含三个步骤:
- 运行容器
- 修改容器
- 将容器保存为新的镜像
(1) 运行容器
docker run -it ubuntu
(2) 安装 vi
vim
bash: vim: command not found
apt-get install vim
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following additional packages will be installed:
file libexpat1 libgpm2 libmagic-mgc libmagic1 libmpdec2 libpython3.6...
(3) 保存为新镜像
在新窗口中查看当前运行的容器:
docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1abe6e7341ca ubuntu "/bin/bash" 8 minutes ago Up 8 minutes laughing_leavitt
laughing_leavitt
是 Docker 为我们的容器随机分配的名字。
执行 docker commit
命令将容器保存为镜像:
docker commit laughing_leavitt ubuntu-with-vi
sha256:9d2fac08719de640df6a923bd6c1dc82d73817d29e9c287d024b6cd2a7235683
查看新镜像 ubuntu-with-vi
的属性:
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu-with-vi latest 9d2fac08719d About a minute ago 169MB
ubuntu latest ea4c82dcd15a 2 weeks ago 85.8MB
从 SIZE 属性看到镜像因为安装了软件而变大了。从新镜像启动容器,验证 vi
已经可以使用:
which vim
/usr/bin/vim
以上演示了如何通过 docker commit
创建新镜像。然而,Docker 并不建议用户通过这种方式构建镜像。原因如下:
- 这是一种手工创建镜像的方式,容易出错,效率低且可重复性弱。
- 更重要的是,使用者并不知道镜像是如何创建出来的,里面是否有恶意程序。也就是说无法对镜像进行审计,存在安全隐患。
3.2.2 Dockerfile
Dockerfile 是一个文本文件,记录了镜像构建的所有步骤。
第一个 Dockerfile
用 Dockerfile 创建上节的 ubuntu-with-vi
,其内容为:
FROM ubuntu
RUN apt-get update && apt-get install -y vim
下面运行 docker build
命令构建镜像,并分析其细节:
pwd (1)
/root
ls (2)
Dockerfile
docker build -t ubuntu-with-vi-dockerfile . (3)
Sending build context to Docker daemon 32.26 kB (4)
Step 1 : FROM ubuntu (5)
---> f753707788c5
Step 2 : RUN apt-get update && apt-get install -y vim (6)
---> Running in 9f4d4166f7e3 (7)
......
Setting up vim (2:7.4.1689-3ubuntu1.1) ...
---> 35ca89798937 (8)
Removing intermediate container 9f4d4166f7e3 (9)
Successfully built 35ca89798937 (10)
(1) 当前目录为 /root
。
(2) Dockerfile 准备就绪。
(3) 运行 docker build
命令,-t
命名新镜像,末尾的 .
表明 build context 为当前目录。Docker 默认会从 build context 中查找 Dockerfile 文件,也可以通过 -f
参数指定 Dockerfile 的位置。
(4) 从这步开始就是镜像真正的构建过程。首先 Docker 将 build context 中的所有文件发送给 Docker daemon。build context 为镜像构建提供所需要的文件或目录。
Dockerfile 中的 ADD
、COPY
等命令可以将 build context 中的文件添加到镜像。此例中,build context 为当前目录 /root
,该目录下的所有文件和子目录都会被发送给 Docker daemon。
注意不要将多余的文件放到 build context 中,特别不要把
/
、/usr
等目录作为 build context,否则构建过程会相当缓慢甚至失败。
(5) Step 1:执行 FROM
,将 Ubuntu 作为 base 镜像。
(6) Step 2:执行 RUN
,安装 vi,具体步骤为 (7) (8) (9)。
(7) 启动 ID 为 9f4d4166f7e3
的临时容器,在容器中通过 apt-get
安装 vim。
(8) 安装成功后,将容器保存为镜像,其 ID 为 35ca89798937
。这一步底层使用的是类似 docker commit
的命令。
(9) 删除临时容器 9f4d4166f7e3
。
(10) 镜像构建成功。
查看镜像分层结构
docker history
会显示镜像的构建历史,也就是 Dockerfile 的执行过程。
镜像的缓存特性
Docker 会缓存已有镜像的镜像层,构建新镜像时,如果某镜像层已经存在,就直接使用,无需重新创建。
调试 Dockerfile
如果 Dockerfile 由于某种原因执行到某个指令失败了,我们也将能够得到前一个指令成功执行构建出的镜像,这对调试 Dockerfile 非常有帮助。可以通过docker run -it
启动镜像的一个容器,手动执行目标命令,查看错误信息。
Dockerfile 常用指令
- FROM:指定 base 镜像
- MAINTAINER:设置镜像的作者,可以是任意字符串
- COPY:将文件从 build context 复制到镜像,支持
COPY src dest
或COPY ["src", "dest"]
- ADD:与 COPY 类似,从 build context 复制文件到镜像。不同的是,如果 src 是归档文件(tar、zip、tgz、xz 等),那么文件会被自动解压到 dest。
- ENV:设置环境变量,可以被后面的指令使用。例如:
ENV MY_VERSION 1.3
RUN apt-get install -y mypackage=$MY_VERSION
...
- EXPOSE:指定容器中的进程会监听某个端口,Docker 可以将该端口暴露出来。
- VOLUME:将文件或目录声明为 volume
- WORKDIR:设置镜像中的当前工作目录
- RUN:在容器中运行指定的命令
- CMD:容器启动时运行指定的命令。Dockerfile 中可以有多个 CMD 命令,但只有最后一个生效。CMD 可以被
docker run
之后的参数替换。 - ENTRYPOINT:设置容器启动时运行的命令。CMD 或
docker run
之后的参数会被当做参数传递给 ENTRYPOINT。
下面是一个较为全面的 Dockerfile:
# my dockerfile
FROM busybox
MAINTAINER abelsu7@gmail.com
WORKDIR /testdir
RUN touch tmpfile1
COPY ["tmpfile2", "."]
ADD ["bunch.tar.gz", "."]
ENV WELCOME "You are in my container, welcome!"
运行容器,验证镜像内容:
> docker run -it my-image
/testdir > ls
bunch tmpfile1 tmpfile2
/testdit > echo $WELCOME
You are in my container, welcome!
RUN vs CMD vs ENTRYPOINT
这三个 Dockerfile 指令看上去很类似,但也有不同之处。简单来说:
- RUN 执行命令并创建新的镜像层,经常用于安装软件包。
- CMD 设置容器启动后默认执行的命令及其参数,但 CMD 能够被
docker run
后面跟的命令行参数替换。 - ENTRYPOINT 配置容器启动时运行的命令
Shell 和 Exec 格式
可以用两种方式指定 RUN、CMD 和 ENTRYPOINT 要运行的命令:Shell 格式和 Exec 格式。
Shell 格式:
<instruction> <command>
RUN apt-get install python3
CMD echo "Hello World"
ENTRYPOINT echo "Hello World"
当指令执行时,shell 格式底层会调用/bin/sh -c <command>
。
Exec 格式:
<instruction> ["executable", "param1", "param2", ...]
RUN ["apt-get", "install", "python3"]
CMD ["/bin/echo", "Hello world"]
ENTRYPOINT ["/bin/ehco", "Hello world"]
当指令执行时,会直接调用<command>
,不会被 Shell 解析。
CMD 和 ENTRYPOINT 推荐使用 Exec 格式,因为指令可读性更强,更容易理解。RUN 则两种格式都可以。
RUN
RUN 指令通常用于安装应用和软件包。
RUN 在当前镜像的顶部执行命令,并创建新的镜像层。Dockerfile 中常常包含多个 RUN 指令。
注意:
apt-get update
和apt-get install
被放在一个 RUN 指令中执行,这样能够保证每次安装的是最新的包。如果apt-get install
在单独的 RUN 中执行,则会使用apt-get update
创建的镜像层,而这一层可能是很久之前缓存的。
CMD
CMD 指令允许用户指定容器默认执行的命令。
此命令会在容器启动且docker run
没有指定其他命令时运行。
- 如果
docker run
指定了其他命令,则 CMD 指定的默认命令将被忽略 - 如果 Dockerfile 中有多个 CMD 指令,只有最后一个 CMD 有效
ENTRYPOINT
ENTRYPOINT 指令可让容器以应用程序或服务的形式运行。
ENTRYPOINT 看上去与 CMD 很像,它们都可以指定要执行的命令及其参数。不同的地方在于 ENTRYPOINT 不会被忽略,一定会被执行,即使运行 docker run 时指定了其他命令。
ENTRYPOINT 的 Exec 格式用于设置要执行的命令及其参数,同时可通过 CMD 提供额外的参数。
例如下面的 Dockerfile 片段:
ENTRYPOINT ["/bin/echo", "Hello"]
CMD ["world"]
当容器通过docker run -it [image]
启动时,输出为:Hello world
。
当容器通过docker run -it [image] CloudMan
启动时,输出为:Hello CloudMan
ENTRYPOINT 的 Shell 格式会忽略任何 CMD 或
docker run
提供的参数。
最佳实践
- 使用 RUN 指令安装应用和软件包,构建镜像。
- 如果 Docker 镜像的用途是运行应用程序或服务,例如运行一个 MySQL 实例,则应该优先使用 Exec 格式的 ENTRYPOINT 指令。CMD 可为 ENTRYPOINT 提供额外的默认参数,同时利用
docker run
命令行替换默认参数。 - 如果想为容器设置默认的启动命令,可使用 CMD 指令。用户可在
docker run
命令行中替换此默认命令。
3.3 镜像命名
一个特定镜像的名字由两部分组成:repository 和 tag:
[image name] = [repository]:[tag]
如果执行docker build
时没有指定 tag,则会使用默认值 latest,其效果相当于:
docker build -t ubuntu-with-vi:latest
3.4 镜像仓库 Registry
3.4.1 使用公共 Registry:Docker Hub
Docker Hub 是 Docker 公司维护的公共 Registry。用户可以将自己的镜像保存到 Docker Hub 免费的 repository 中。如果不希望别人访问自己的镜像,也可以购买私有 repository。
除了 Docker Hub,quay.io 是另一个公共 Registry,提供与 Docker Hub 类似的服务。
通过 Docker Hub 存取镜像的步骤如下:
- 首先在 Docker Hub 上注册账号
- 在 Docker Host 上登录:
docker login -u [username]
- 修改镜像的 repository 使之与 Docker Hub 账号匹配。Docker Hub 为了区分不同用户的同名镜像,镜像的 registry 中要包含用户名,完整格式为:
[username]/xxx:tag
。通过docker tag
命令重命名镜像:docker tag httpd cloudman6/httpd:v1
- 通过
docker push
将镜像上传到 Docker Hub:docker push cloudman6/httpd:v1
。省略 tag 部分即可上传同一 repository 中的所有镜像 - 登录 https://hub.docker.com,在 Public Repository 中就可以看到上传的镜像
- 在其他 Docker Host 上可通过
docker pull
命令下载使用该镜像
3.4.2 搭建本地 Registry
Docker Hub 虽然非常方便,但还是有些限制:
- 需要互联网连接,而且下载上传速度较慢
- 上传到 Docker Hub 的镜像任何人都能够访问。虽然可以使用私有 repository,但不是免费的
- 安全原因使得很多组织不允许将镜像放到外网
解决方案就是搭建本地的 Registry。
Docker 已经将 Registry 开源了,同时在 Docker Hub 上也有官方的镜像 Registry。
1) 启动 registry 容器:
docker run -d -p 5000:5000 -v /myregistry:/var/lib/registry registry:2
2) 通过docker tag
重命名镜像,使之与 registry 匹配:
docker tag cloudman6/httpd:v1 registry.example.net:5000/cloudman6:httpd:v1
只有 Docker Hub 上的镜像可以省略
[registry-host]:[port]
3) 通过docker push
上传镜像:
docker push registry.example.net:5000/cloudman6/httpd:v1
4) 现在就可以通过docker pull
从本地 registry 下载镜像了:
docker pull registry.example.net:5000/cloudman6/httpd:v1
本地 registry 也支持认证、HTTPS 安全传输等特性,具体可参考官方文档。
3.5 Docker 镜像小结
镜像的常用操作子命令如下:
- images:显示镜像列表
- history:显示镜像构建历史
- commit:从容器创建新镜像
- build:从 Dockerfile 构建镜像
- tag:给镜像打 tag
- pull:从 registry 下载镜像
- push:将镜像上传到 registry
- rmi:删除 Docker Host 中的镜像
- search:搜索 Docker Hub 中的镜像
参考文章