镜像内部结构、构建镜像、镜像命名、Registry

《每天5分钟玩转Docker容器技术》
《每天5分钟玩转Docker容器技术》

第 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-worldDockerfile 内容如下:

FROM scratch
COPY hello /
CMD ["/hello"]
  1. FROM scratch:此镜像是从白手起家,从 0 开始构建
  2. COPY hello /:将文件 hello 复制到镜像的根目录
  3. 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 操作系统内核空间用户空间组成,如下图所示:

Linux 的内核空间与用户空间
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 使用 systemdyum。这些都是用户空间上的区别,Linux kernel 差别不大。

所以 Docker 可以同时支持多种 Linux 镜像,模拟出多种操作系统环境。

Debian 和 BusyBox 容器共用 Host Kernel
Debian 和 BusyBox 容器共用 Host Kernel

上图 Debian 和 BusyBox(一种嵌入式 Linux)上层提供各自的 rootfs,底层共用 Docker Host 的 kernel

需要说明的是:

  1. base 镜像只是用户空间与发行版一致,Kernel 版本与发行版是不同的
  2. 容器只能使用 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 提供了两种构建镜像的方法:

  1. docker commit 命令
  2. 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 中的 ADDCOPY 等命令可以将 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 destCOPY ["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:设置容器启动时运行的命令。CMDdocker 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 指令看上去很类似,但也有不同之处。简单来说:

  1. RUN 执行命令并创建新的镜像层,经常用于安装软件包
  2. CMD 设置容器启动后默认执行的命令及其参数,但 CMD 能够被docker run后面跟的命令行参数替换。
  3. 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 updateapt-get install被放在一个 RUN 指令中执行,这样能够保证每次安装的是最新的包。如果apt-get install在单独的 RUN 中执行,则会使用apt-get update创建的镜像层,而这一层可能是很久之前缓存的。

CMD

CMD 指令允许用户指定容器默认执行的命令

此命令会在容器启动docker run没有指定其他命令时运行

  1. 如果docker run指定了其他命令,则 CMD 指定的默认命令将被忽略
  2. 如果 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

ENTRYPOINTShell 格式忽略任何 CMD 或docker run提供的参数

最佳实践

  1. 使用 RUN 指令安装应用和软件包,构建镜像
  2. 如果 Docker 镜像的用途是运行应用程序或服务,例如运行一个 MySQL 实例,则应该优先使用 Exec 格式的 ENTRYPOINT 指令。CMD 可为 ENTRYPOINT 提供额外的默认参数,同时利用docker run命令行替换默认参数。
  3. 如果想为容器设置默认的启动命令,可使用 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 存取镜像的步骤如下:

  1. 首先在 Docker Hub 上注册账号
  2. 在 Docker Host 上登录:docker login -u [username]
  3. 修改镜像的 repository 使之与 Docker Hub 账号匹配。Docker Hub 为了区分不同用户的同名镜像,镜像的 registry 中要包含用户名,完整格式为:[username]/xxx:tag。通过docker tag命令重命名镜像:docker tag httpd cloudman6/httpd:v1
  4. 通过docker push将镜像上传到 Docker Hubdocker push cloudman6/httpd:v1。省略 tag 部分即可上传同一 repository 中的所有镜像
  5. 登录 https://hub.docker.com,在 Public Repository 中就可以看到上传的镜像
  6. 在其他 Docker Host 上可通过docker pull命令下载使用该镜像

3.4.2 搭建本地 Registry

Docker Hub 虽然非常方便,但还是有些限制:

  1. 需要互联网连接,而且下载上传速度较慢
  2. 上传到 Docker Hub 的镜像任何人都能够访问。虽然可以使用私有 repository,但不是免费的
  3. 安全原因使得很多组织不允许将镜像放到外网

解决方案就是搭建本地的 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 中的镜像

参考文章

  1. 《每天5分钟玩转 Docker 容器技术》教程目录 | CloudMan