学习资料:公众号cloudman
文章目录
Docker组件如何协作?
当我们执行docker run -d -p 80:80 httpd
如果是第一次执行,则会经历以下步骤:
- Docker客户端搜索本地镜像:发现没有httpd镜像
- Docker daemon从Docker Hub下载镜像
- 下载的镜像于是被保存在本地
- Docker daemon启动容器
docker images
:查看本地的镜像
docker ps
:显示容器
镜像的内部
以hello-world为例,它是Docker官方提供的一个镜像,通常用来验证Docker是否安装成功。
通过docker pull
下载
运行:docker run hello-world
Dockerfile
是镜像的描述文件,定义了如何构建Docker镜像。Dockerfile的语法简洁且可读性强。
hello-world的Dockerfile内容:
FROM scratch
COPY hello /
CMD ["/hello"]
只有短短三条指令。
- FROM scratch :此镜像从0开始构建
- COPY hello / :将文件"hello"复制到镜像的根目录
- CMD [“/hello”]:容器启动时,执行 /hello
镜像hello-world中只有一个可执行文件“hello",其功能就是打印出”Hello from Docker …"等信息
/hello 就是文件系统的全部内容,连最基本的 /bin, /usr, /lib, /dev都没有。
base镜像
通常来说,我们希望镜像能提供一个基本的操作系统环境,用户可以根据需要安装和配置软件。这样的镜像我们称作base镜像。
base镜像有两层含义:
- 不依赖于其他镜像,从scratch构建
- 其他镜像可以之为基础进行扩展。
所以,能称作base镜像的通常都是各种Linux发行版的Docker镜像,比如Ubuntu,Debian,CentOS
我们看一下Ubuntu的镜像信息:
docker images ubuntu
为什么这么小???
首先,Linux操作系统由内核空间和用户空间组成。如下图:
内核空间是 kernel,Linux 刚启动时会加载 bootfs 文件系统,之后 bootfs 会被卸载掉。
用户空间的文件系统是 rootfs,包含我们熟悉的 /dev, /proc, /bin 等目录。
对于 base 镜像来说,底层直接用 Host 的 kernel,自己只需要提供 rootfs 就行了。
base镜像提供的是最小安装的Linux发行版
下面是ubuntu镜像的Dockerfile的内容
FROM scratch
ADD ubuntu-jammy-oci-amd64-root.tar.gz /
CMD ["bash"]
第二行ADD指令添加到镜像的tar包就是Ubuntu的rootfs。在制作镜像时,这个tar包会自动解压到/目录下,生成/dev, /porc, /bin等目录。
注:可在Docker Hub的镜像描述页面中查看Dockerfile。
不同Linux发行版的区别主要就是tootfs。
比如 Ubuntu 14.04 使用 upstart 管理服务,apt 管理软件包;而 CentOS 7 使用 systemd 和 yum。这些都是用户空间上的区别,Linux kernel 差别不大。
所以 Docker 可以同时支持多种 Linux 镜像,模拟出多种操作系统环境。
需要说明的是:
- base镜像只是在用户空间与发行版一致,kernel版本与发行版是不同的。
- 容器只能使用Host的kernel,并且不能修改
Docker的分层结构
实际上,Docker Hub中99%的镜像都是通过在base镜像中安装和配置需要的软件构建出来的。比如我们现在构建一个新的镜像,Dockerfile如下:
FROM debian
RUN apt-get install emacs
RUN apt-get install apache2
CMD ["/bin/bash"]
在这里,
- 新镜像不是从scratch开始,而是直接在debian base镜像上构建
- 安装emacs编辑器
- 安装apache2
- 容器启动时运行bash
实际构建过程如下图所示:
可以看到,新镜像是从base镜像一层一层叠加生成的。每安装一个软件,就在现有镜像的基础上增加一层。
这种分层结构最大的好处就在于共享资源。
问:如果多个容器共享一份基础镜像,当某个容器修改了基础镜像的内容,比如/etc下的文件,这时其他容器的/etc是否也会被修改?
答:不会,修改会被限制在单个容器内。这就是我们接下来要学习的容器Copy-on-Write特性。
可写的容器层
当容器启动时,一个新的可写层被加载到镜像的顶部。这一层通常叫做“容器层”,“容器层”之下的都叫“镜像层”
所有对容器的改动,无论是添加、删除还是修改文件都只会发生在容器层。
只有容器层是可写的,容器层下面的所有镜像层都是只读的。
下面我们深入讨论容器层的细节。
镜像层数量可能会很多,所有镜像层会联合在一起组成一个统一的文件系统。如果不同层中有一个相同路径的文件,比如/a,上层的/a会覆盖下层的/a,也就是说用户只能访问到上层中的文件/a。在容器层中,用户看到的是一个叠加之后的文件系统。
-
添加文件
在容器中创建文件时,新文件被添加到容器层中。
-
读取文件
在容器中读取某个文件时,Docker会从上往下依次在各镜像层查找此文件。一旦找到,打开并读入内存。
-
修改文件
在容器中修改已存在的文件时,Docker会从上往下依次在各镜像层中查找此文件。一旦找到,立即将其复制到容器层,然后修改之。
-
删除文件
在容器删除文件时,Docker也是从上往下依次在镜像层中查找此文件。找到后,会在容器层中记录下此删除操作。
只有当修改时才复制一份数据,这种特性被称作Copy-on-Write。可见,容器层保存的是镜像变化的部分,不会对镜像本身进行任何修改。所以镜像可以被多个容器共享。
构建镜像
某些情况下我们不得不自己构建镜像,比如:
- 找不到现成的镜像,比如自己开发的应用程序
- 需要在镜像中加入特定功能,比如官方镜像几乎都不提供ssh
Docker提供了两种构建镜像的方法:
- docker commit命令
- Dockerfile 构建文件
docker commit
docker commit命令是创建新镜像最直观地方法,其过程包含三个步骤:
- 运行容器
- 修改容器
- 将容器保存为新的镜像
举个例子:在Ubuntu base镜像中安装vi并保存为新镜像
-
运行容器
-it
的作用是以交互模式进入容器,并打开终端 -
安装vi
- 保存为新镜像
执行 docker commit <ID/NAMES> <NEW_NAME>
命令将容器保存为镜像。
然鹅,Docker并不建议用户通过这种方式构建镜像,原因如下:
-
这是一种手工创建镜像的方式,容易出错,效率低且可重复性弱。比如要在 debian base 镜像中也加入 vi,还得重复前面的所有步骤。
-
更重要的:使用者并不知道镜像是如何创建出来的,里面是否有恶意程序。也就是说无法对镜像进行审计,存在安全隐患。
既然 docker commit 不是推荐的方法,我们干嘛还要花时间学习呢?
原因是:即便是用 Dockerfile(推荐方法)构建镜像,底层也 docker commit 一层一层构建新镜像的。学习 docker commit 能够帮助我们更加深入地理解构建过程和镜像的分层结构。
Dockerfile构建镜像
FROM ubuntu
RUN apt-get update && apt-get install -y vim
当执行docker build构建镜像:
qiqi_dell@dell-2:~/df$ docker build -t ubuntu-with-vi-dockerfile .
[+] Building 235.2s (6/6) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 100B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/ubuntu:latest 0.0s
=> [1/2] FROM docker.io/library/ubuntu 0.0s
=> [2/2] RUN apt-get update && apt-get install -y vim 234.5s
=> exporting to image 0.5s
=> => exporting layers 0.5s
=> => writing image sha256:eff9c63cbadf264c41d96d27da3f37d23c7cb8eff65315080b5bb0566f06f08b 0.0s
=> => naming to docker.io/library/ubuntu-with-vi-dockerfile 0.0s
当前目录为df,之下 有一个Dockerfile文件
-t
将新镜像命名为ubuntu-with-vi-dockerfile
.
指明build context为当前目录,Docker默认会从build context中查找Dockerfile文件,也可以通过-f
参数指定Dockerfile的位置。
[1/2] 执行第一层:将ubuntu作为base镜像
[2/2]执行RUN
,安装vim
查看镜像:
查看镜像分层结构:
docker history <IMAGE>
会显示镜像的构建历史,也就是Dockerfile的执行过程
镜像分层结构:
镜像的缓存特性
Docker会缓存已有镜像的镜像层,构建新镜像时,如果某镜像层已经存在,就直接使用,无需重新创建。
qiqi_dell@dell-2:~/df2$ docker build -t ubuntu-with-vi-dockerfile-2 .
[+] Building 0.2s (8/8) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 118B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/ubuntu:latest 0.0s
=> [1/3] FROM docker.io/library/ubuntu 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 55B 0.0s
=> CACHED [2/3] RUN apt-get update && apt-get install -y vim 0.0s
=> [3/3] COPY testfile / 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:a7dc8a695e28d29f6fc066df5083003a2ebed6756dc716d5dbca8bd0ff07ec66 0.0s
=> => naming to docker.io/library/ubuntu-with-vi-dockerfile-2 0.0s
CACHED
表明已经运行过相同的RUN指令,这次直接使用缓存中的镜像层。
镜像分层结构:
如果我们希望在构建镜像时不使用缓存,可以在 docker build
命令中加上 --no-cache
参数。
Dockerfile 中每一个指令都会创建一个镜像层,上层是依赖于下层的。无论什么时候,只要某一层发生变化,其上面所有层的缓存都会失效。
也就是说,如果我们改变 Dockerfile 指令的执行顺序,或者修改或添加指令,都会使缓存失效。
举例说明,比如交换前面 RUN 和 COPY 的顺序:
虽然在逻辑上这种改动对镜像的内容没有影响,但由于分层的结构特性,Docker 必须重建受影响的镜像层。
除了构建时使用缓存,Docker 在下载镜像时也会使用。
调试Dockerfile
所有脚本和程序都有可能出错,有错不可怕,但必须有办法排查。
回顾Dockerfile构建镜像的过程:
- 从base镜像运行一个容器
- 执行一条指令,对容器做修改
- 执行类似docker commit的操作,生成一个新的镜像层
- Docker再基于刚刚提交的镜像运行一个新容器。
- 重复2-4步,直到Dockerfile中的所有指令执行完毕。
我们来看一个调试的例子。Dockerfile内容如下:
FROM busybox
RUN touch tmpfile
RUN /bin/bash -c echo "continue to build..."
COPY testfile /
Dockerfile在执行第三步RUN指令时失败,并且告诉我们原因:
qiqi_dell@dell-2:~/debug$ docker build -t image-debug .
[+] Building 6.8s (7/8)
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 137B 0.0s
=> [internal] load .dockerignore 0.1s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/busybox:latest 4.4s
=> [internal] load build context 0.0s
=> => transferring context: 55B 0.0s
=> [1/4] FROM docker.io/library/busybox@sha256:6bdd92bf5240be1b5f3bf71324f5e371fe59f0e153b27fa1f1620f78ba16963c 1.1s
=> => resolve docker.io/library/busybox@sha256:6bdd92bf5240be1b5f3bf71324f5e371fe59f0e153b27fa1f1620f78ba16963c 0.0s
=> => sha256:6bdd92bf5240be1b5f3bf71324f5e371fe59f0e153b27fa1f1620f78ba16963c 2.29kB / 2.29kB 0.0s
=> => sha256:dacd1aa51e0b27c0e36c4981a7a8d9d8ec2c4a74bf125c0a44d0709497a522e9 527B / 527B 0.0s
=> => sha256:bc01a3326866eedd68525a4d2d91d2cf86f9893db054601d6be524d5c9d03981 1.46kB / 1.46kB 0.0s
=> => sha256:22b70bddd3acadc892fca4c2af4260629bfda5dfd11ebc106a93ce24e752b5ed 772.99kB / 772.99kB 0.9s
=> => extracting sha256:22b70bddd3acadc892fca4c2af4260629bfda5dfd11ebc106a93ce24e752b5ed 0.0s
=> [2/4] RUN touch tmpfile 0.5s
=> ERROR [3/4] RUN /bin/bash -c echo "continue to build..." 0.7s
------
> [3/4] RUN /bin/bash -c echo "continue to build...":
#6 0.629 /bin/sh: /bin/bash: not found
------
Dockerfile 常用指令
FROM:指定base镜像
MAINTAINER:设置镜像的作者,可以是任意字符串
COPY:将文件从build context复制到镜像
COPY 支持两种形式:
- COPY src dest
- COPY [“src”,“dest”]
注意:src只能指定build context中的文件或目录
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,ENTRYPOINT,ADD 或COPY指令设置镜像中的当前工作目录。
RUN:在容器中运行指定的命令。
CMD:容器启动时运行指定的命令。
Dockerfile 中可以有多个 CMD 指令,但只有最后一个生效。CMD 可以被 docker run 之后的参数替换。
ENTRYPOINT:设置容器启动时运行的命令。
Dockerfile 中可以有多个 ENTRYPOINT 指令,但只有最后一个生效。CMD 或 docker run 之后的参数会被当做参数传递给 ENTRYPOINT。
RUN vs CMD vs ENTRYPOINT
- RUN执行命令并创建新的镜像层,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
例如下面的Dockerfile片段:
ENV name Jqq
ENTRYPOINT echo "Hello, $name"
执行 docker run 将输出:
Hello, Jqq
这时环境变量已被值替换。
- Exec格式:
<instruction> ["exectutable","param1","param2",...]
例如:
RUN ["apt-get","install","python3"]
CMD ["/bin/echo","Hello world"]
ENTRYPOINT ["/bin/echo", "Hello world"]
当指令执行时,会直接调用,不会被shell解析,例如下面的Dockerfile片段:
ENV name Jqq
ENTRYPOINT ["/bin/echo","Hello, $name"]
运行容器将输出:
Hello, $name
注意环境变量"name"没有被替换。
如果希望使用环境变量,要显式指定参数
ENV name Jqq
ENTRYPOINT ["/bin/sh","-c","echo Hello $name"]
运行容器将输出:
Hello, Jqq
CMD和ENTRYPOINT都推荐使用Exec格式,因为指令可读性更强,更容易理解,RUN则两种格式都可以。
CMD
允许用户指定容器的默认执行的命令,此命令会在容器启动且docker run没有指定其他命令时运行。
- 如果docker run指定了其他命令,CMD指定的默认命令将被忽略
- 如果Dockerfile中有多个CMD指令,只有最后一个CMD有效
CMD有三种格式:
-
Exec格式: [“exectutable”,“param1”,“param2”,…]
这是CMD的推荐格式
-
CMD [“param1”,“param2”]为ENTRYPOINT提供额外的参数,此时ENTRYPOINT必须使用Exec格式
-
Shell格式:CMD command param1 param2
第二种格式要与Exec格式的ENTRYPOINT指令配合使用,其用途是为ENTRYPOINT设置默认的参数。
ENTRYPOINT
让容器以应用程序或者服务的形式运行。
ENTRYPOINT与CMD很小,不同之处在于ENTRYPOINT一定会被执行,即使运行docker run时指定了其他命令
使用ENTRYPOINT的两种格式——Shell和Exec(推荐格式)时需小心,因为这两种格式的效果差别很大。
Exec格式
ENTRYPOINT中的参数始终会被使用,而CMD的额外参数可以在容器启动时动态替换掉。
例如:
ENTRYPOINT ["/bin/echo", "Hello"]
CMD ["world"]
当容器通过docker run -it [image]启动时,输出为:
Hello world
而如果通过docker run -it [image] Jqq启动,输出为:
Hello Jqq
Shell 格式
ENTRYPOINT会忽略任何CMD或docker run提供的参数
建议
- 使用RUN指令安装应用和软件包,构建镜像
- 如果Docker镜像的用途是运行程序或服务,比如运行一个MySQL,应该优先使用Exec格式的ENTRYPOINT指令。CMD可为ENTRYPOINT提供额外的默认参数,同时可利用docker run 命令行替换默认参数。
- 如果想为容器设置默认的启动命令,可使用CMD指令。用户可在docker run命令行中替换此默认命令。
镜像命名的最佳实践
镜像的名字由两部分组成:
[image name]=[repository]:[tag]
默认tag为latest
借鉴软件版本命名方式能够让用户很好地使用镜像
当发布一个镜像myimage,版本为v1.9.1,我们可以给镜像打上四个tag:1.9.1、1.9、1和latest
docker tag myimage-v1.9.1 myimage:1
docker tag myimage-v1.9.1 myimage:1.9
docker tag myimage-v1.9.1 myimage:1.9.1
docker tag myimage-v1.9.1 myimage:latest
而当我们发布v1.9.2时,我们可以为其打上1.9.2的tag,并将1.9、1和latest标签从v1.9.1移到v1.9.2
docker tag myimage-v1.9.2 myimage:1
docker tag myimage-v1.9.2 myimage:1.9
docker tag myimage-v1.9.2 myimage:1.9.2
docker tag myimage-v1.9.2 myimage:latest
之后的版本以此类推
这种tag方案使镜像的版本很直观,使用户灵活选择。
使用公共Registry
保存和分发镜像最直接的方法就是使用Docker Hub
如何在Docker Hub上存取镜像:
-
在Docker Hub上注册一个账号
-
在Docker Host上登录
docker login -u captain_j
输入密码
-
修改镜像的repository
Docker Hub未来区分不同用户的同名镜像,镜像的registry中要包含用户名,完整格式为:[username]/xxx:tag
可以通过
docker tag <image> [username]/xxx:tag
重命名镜像 -
通过
docker push
将镜像上传到Docker HubDocker会上传镜像的每一层。但如果我们的镜像是基于base镜像的,也只有新增加的镜像层会被上传。
如果想上传统一repository中的所有镜像,忽略tag部分就可以了
-
登录https://hub.docker.com,在Public Repository中就可以看到上传的镜像
搭建本地Registry
Docker已经将Registry开源了,同时在Docker Hub上也有官方的镜像registry。下面我们就在docker中运行自己的registry
- 启动registry容器
docker run -d -p 5000:5000 -v /myregistry:/var/lib/registry registry:2
使用的镜像是registry:2
-d
是后台启动容器
-p
将服务器的5000端口映射到Host的5000端口。5000是registry服务端口。
-v
将容器/var/lib/registry目录映射到Host的/myregistry,用于存放镜像数据。
- 通过
docker tag
重命名镜像,使之与registry匹配
docker tag <imageName>:<tag> registry.example.net:5000/<username>/<imageName>:<tag>
我们在镜像的前面加上了运行regstry的主机名称和端口。
前面已经讨论了镜像名称由repository和tag两部分组成。而repository的完整格式为:[registry-host]:[port]/[username]/xxx
只有Docker Hub上的镜像可以省略[registry-host]:[port]
-
通过
docker push
上传镜像 -
通过
docker pull
从本地registry下载镜像除了镜像的名字长一些(包含registry host和port),使用方式完全一样。
Docker 镜像小结
常用操作:
命令 | 解释 |
---|---|
images | 显示镜像列表 |
history | 显示镜像构建历史 |
commit | 从容器创建新镜像 |
build | 从Dockerfile构建镜像 |
tag | 给镜像打标签 |
pull | 从registry下载镜像 |
push | 将镜像上传到registry |
rmi | 删除Docker host中的镜像 |
search | 搜索Docker Hub中的镜像 |