前言:
初识Docker,本文涵盖内容:
- 什么是Docker?
- 如何安装Docker?
- Docker的基本使用
ps:多数内容来自《第一本Docker书》,但其中部分内容已经过于老旧了,由我(SilenceZheng66)进行了重新编写。
pps:本来就想写一个简单的便捷教程,没想到越来越长…
什么是Docker?
要了解什么是Docker,需要先了解什么是容器技术。
什么是容器技术?
相信本篇的读者一定接触过虚拟机,或者说管理程序虚拟化(hypervisor virtualization,HV)。HV通过中间层将一台或多台独立的机器虚拟运行于物理硬件之上,它解决的主要技术问题是,大多数物理硬件一次只能运行一个操作系统。这种限制往往导致资源浪费,因为单一的操作系统很少能充分利用硬件的能力。
而容器则是直接运行在操作系统内核之上的用户空间,因此,容器虚拟化也被称为“操作系统级虚拟化”,容器技术可以让多个独立的用户空间运行在同一台宿主机上。
由于“客居”于操作系统,容器只能只能运行于底层宿主机相同或相似的操作系统。尽管有诸多局限性,容器还是被广泛部署于各种场合,例如超大规模的多租户服务部署、轻量级沙盒以及对安全要求不太高的隔离环境中等等(chroot jail是一种容器)。
容器经常被认为是精益技术,因为它需要的开销有限。和传统虚拟化与半虚拟化(paravirtualization)相比,容器运行不需要模拟层(emulation layer)和管理层(hypervisor layer),而是使用操作系统的系统调用接口。这降低了运行单个容器所需的开销,也使得宿主机中可运行更多的容器。
Docker的诞生
Docker的出现是为了解决容器技术的复杂性:容器本身就比较复杂,不易安装、管理和自动化。
Docker是一个能够把开发的应用程序自动部署到容器的开源引擎。Docker的特别之处在于,它在虚拟化的容器执行环境中增加了一个应用程序部署引擎。该引擎的目标就是提供一个轻量、快速的环境,能够运行开发者的程序,并方便高效的将程序从开发者的笔记本部署到测试环境,然后再部署到生产环境。
使用Docker,开发人员只需要关心容器中运行的应用,而运维人员只需要关心如何管理容器。Docker设计的目的有许多:
- 加强开发人员写代码的开发环境与应用程序要部署的生产环境的一致性,从而降低那种“开发时一切都正常,肯定是运维的问题”的风险。
- 缩短代码从开发、测试到部署、上线的周期,使应用具备可移植性,易于构建并易于协作。
Docker还鼓励面向服务的架构和微服务架构,推荐单个容器只运行一个应用程序或者进程,这样就形成了一个分布式的应用程序模型。在这种模型下,应用程序或服务都可以表示为一系列内部互联的容器,从而使分布式部署应用程序,扩展或调试应用程序都变得非常简单,同时也提高了程序的内省性。当然,这种模式不是必须的。
Docker的核心组件
Docker的核心组件主要有四个:
- Docker客户端和服务器(Docker引擎)
- Docker镜像(Image)
- 镜像库(Registry)
- Docker容器(Container)
Docker客户端和服务器
Docker是一个客户端/服务器(C/S)架构的程序。Docker客户端只需向Docker服务器或守护进程发出请求,服务器或守护进程将完成所有工作并返回结果。Docker守护进程有时也称为Docker引擎。
Docker提供了一个命令行工具docker以及一整套RESTful API来与守护进程交互。用户可以在同一台宿主机上运行Docker守护进程和客户端,也可以从本地的Docker客户端连接到运行在另一台宿主机上的远程Docker守护进程。
Docker镜像(Image)
镜像是构建Docker世界的基石。用户基于镜像来运行自己的容器。镜像也是Docker生命周期中的“构建”部分。镜像是基于联合(Union)文件系统的一种层式的结构,由一系列指令一步一步构建出来。例如:
- 添加一个文件;
- 执行一个命令;
- 打开一个端口。
可以把镜像当作容器的“源代码”,也可以把镜像理解为可执行文件,容器则是执行后运行起来的进程。镜像体积很小,非常“便携”,易于分享、存储和更新。
以上是关于镜像的最简单和模糊的解释,但是在这里我想更进一步,说明一下到底什么是镜像,这有助于我们之后使用镜像。
Docker镜像是由文件系统叠加而成。最底端是一个引导文件系统,即bootfs,这很像典型的 Linux/Unix的引导文件系统。Docker用户几乎永远不会和引导文件系统有什么交互。实际上,当一个容器启动后,它将会被移到内存中,而引导文件系统则会被卸载(unmount),以留出更多的内存供initrd磁盘镜像使用。
到目前为止,Docker看起来还很像一个典型的Linux虚拟化栈。实际上,Docker镜像的第二层是root文件系统rootfs,它位于引导文件系统之上。rootfs可以是一种或多种操作系统(如Debian或者Ubuntu文件系统)。
在传统的Linux引导过程中,root文件系统会最先以只读的方式加载,当引导结束并完成了完整性检查之后,它才会被切换为读写模式。但是在Docker里,root文件系统永远只能是只读状态,并且Docker利用联合加载(union mount)技术又会在root文件系统层上加载更多的只读文件系统。联合加载指的是一次同时加载多个文件系统,但是在外面看起来只能看到一个文件系统。联合加载会将各层文件系统叠加到一起,这样最终的文件系统会包含所有底层的文件和目录。
Docker将这样的文件系统称为镜像。一个镜像可以放到另一个镜像的顶部(这会构成一种层叠结构)。位于下面的镜像称为父镜像(parent image),可以依次类推,直到镜像栈的最底部,最底部的镜像称为基础镜像(base image)。最后,当从一个镜像启动容器时,Docker会在该镜像的最顶层加载一个读写文件系统。我们想在Docker中运行的程序就是在这个读写层中执行的。
当Docker第一次启动一个容器时,初始的读写层是空的。当文件系统发生变化时,这些变化都会应用到这一层上。比如,如果想修改一个文件,这个文件首先会从该读写层下面的只读层复制到该读写层。该文件的只读版本依然存在,但是已经被读写层中的该文件副本所隐藏。
通常这种机制被称为写时复制(copy on write),这也是使Docker如此强大的技术之一。每个只读镜像层都是只读的,并且以后永远不会变化。当创建一个新容器时,Docker会构建出一个镜像栈,并在栈的最顶端添加一个读写层。这个读写层再加上其下面的镜像层以及一些配置数据,就构成了一个容器。在上一章我们已经知道,容器是可以修改的,它们都有自己的状态,并且是可以启动和停止的。容器的这种特点加上镜像分层框架(image-layering framework),使我们可以快速构建镜像并运行包含我们自己的应用程序和服务的容器。
镜像库(Registry)
Docker用Registry来保存用户构建的镜像,可以分为公共和私有两种。Docker公司运营的公共Registry为Docker Hub。用户可以在Docker Hub注册账号,分享并保存自己的镜像。
用户也可以在Docker Hub上保存自己的私有镜像。例如,包含源代码或专利信息等需要保密的镜像,或者只在团队或组织内部可见的镜像。
用户甚至可以架设自己的私有Registry。私有Registry可以受到防火墙的保护,将镜像保存在防火墙后面,以满足一些组织的特殊需求。
这里我将Registry称为镜像库是不太准确的,事实上镜像仓库存在于Registry中。
Docker容器(Container)
Docker可以帮用户构建和部署容器,用户只需要把自己的应用程序或服务打包放进容器即可。我们刚刚提到,容器是基于镜像启动起来的,容器中可以运行一个或多个进程。我们可以认为,镜像是Docker生命周期中的构建或打包阶段,而容器则是启动或执行阶段。
总结起来,Docker容器就是:
- 一个镜像格式;
- 一系列标准的操作;
- 一个执行环境。
Docker借鉴了标准集装箱的概念。标准集装箱将货物运往世界各地,Docker将这个模型运用到自己的设计哲学中,唯一不同的是:集装箱运输货物,而Docker运输软件。每个容器都包含一个软件镜像,也就是容器的“货物”,而且与真正的货物一样,
容器里的软件镜像可以进行一些操作。例如,镜像可以被创建、启动、关闭、重启以及销毁。和集装箱一样,Docker在执行上述操作时,并不关心容器中到底塞进了什么,它不管里面是Web服务器,还是数据库,或者是应用程序服务器什么的。所有容器都按照相同的方式将内容“装载”进去。
Docker能做什么?
容器可以为各种测试提供很好的沙盒环境。并且,容器本身就具有“标准性”的特征,非常适合为服务创建构建块。Docker的一些应用场景如下:
- 加速本地开发和构建流程,使其更加高效、更加轻量化。本地开发人员可以构建、运行并分享Docker容器。容器可以在开发环境中构建,然后轻松地提交到测试环境中,并最终进入生产环境。
- 能够让独立服务或应用程序在不同的环境中,得到相同的运行结果。这一点在面向服务的架构和重度依赖微型服务的部署中尤其实用。
- 用Docker创建隔离的环境来进行测试。例如,用Jenkins CI这样的持续集成工具启动一个用于测试的容器。
- Docker可以让开发者先在本机上构建一个复杂的程序或架构来进行测试,而不是一开始就在生产环境部署、测试。
- 构建一个多用户的平台即服务(PaaS)基础设施。
- 为开发、测试提供一个轻量级的独立沙盒环境,或者将独立的沙盒环境用于技术教学,如Unix shell的使用、编程语言教学。
- 提供软件即服务(SaaS)应用程序。
- 高性能、超大规模的宿主机部署。
Docker的技术组件
Docker可以运行于任何安装了现代Linux内核的x64主机上,现在也支持很多其他平台。Docker的开销比较低,可以用于服务器、台式机或笔记本。它包括以下几个部分。
- 一个原生的Linux容器格式,Docker中称为libcontainer。
- Linxu内核的命名空间(namespace),用于隔离文件系统、进程和网络。
- 文件系统隔离:每个容器都有自己的root文件系统。
- 进程隔离:每个容器都运行在自己的进程环境中。
- 网络隔离:容器间的虚拟网络接口和IP地址都是分开的。
- 资源隔离和分组:使用cgroups(即control group,Linux的内核特性之一)将CPU和内存之类的资源独立分配给每个Docker容器。
- 写时复制:文件系统都是通过写时复制创建的,这就意味着文件系统是分层 的、快速的,而且占用的磁盘空间更小。
- 日志:容器产生的STDOUT、STDERR和STDIN这些IO流都会被收集并记入日志, 用来进行日志分析和故障排错。
- 交互式shell:用户可以创建一个伪tty终端,将其连接到STDIN,为容器提供 一个交互式的shell。
如何安装Docker?
我会演示如何在Ubuntu中安装Docker,强烈建议用户在首次安装前阅读官方文档以了解目前官方支持的系统版本以及软硬件条件。
我会列举几种不同的安装方式,但不包括如何使用二进制文件安装 Docker。
检查系统版本
在使用任何安装方式前,都需要确定系统版本是否匹配,目前对于Ubuntu的系统要求如下:
To install Docker Engine, you need the 64-bit version of one of these Ubuntu versions:
- Ubuntu Kinetic 22.10
- Ubuntu Jammy 22.04 (LTS)
- Ubuntu Focal 20.04 (LTS)
- Ubuntu Bionic 18.04 (LTS)
在Ubuntu终端键入cat /etc/issue
,可以看到我的版本号为Ubuntu 18.04.6 LTS
,符合要求。
卸载旧版本Docker
确定系统符合要求后,还有一步前置工作就是删除掉可能已安装的旧版本Docker,它们的名字可能是docker, docker.io, or docker-engine
。
删除旧版本Docker:sudo apt-get remove docker docker-engine docker.io containerd runc
清除容器、镜像和Volumes:
1 | sudo rm -rf /var/lib/docker |
这里简单介绍一下Volume,Volume就是目录或文件,它可以绕过默认的联合文件系统,以正常的文件或目录的形式存在于宿主机。利用Volume可以将容器以及容器产生的新数据分离开来,这样使用docker rm 容器
删除容器时,不会影响相关数据。
从apt存储库安装Docker
该方法通过apt安装Docker,在首次安装时,需要先设置Docker apt repository。
1、更新apt包索引并安装包以允许apt通过 HTTPS 使用存储库:
1 | sudo apt-get update |
2、添加 Docker 的官方 GPG 密钥:
1 | sudo mkdir -p /etc/apt/keyrings |
3、设置存储库:
1 | echo \ |
4、安装Docker引擎(latest):
1 | sudo apt-get update |
5、通过运行镜像验证 Docker Engine 安装是否成功 :
1 | sudo docker run hello-world |
从软件包安装
该方法通过下载deb文件并手动安装。注意使用该方法安装,每次升级 Docker Engine 时都需要下载一个新文件。
1、到 https://download.docker.com/linux/ubuntu/dists/ 选择对应系统版本。
2、转到pool/stable/
并选择适用的架构(amd64、 armhf、arm64或s390x)。
3、下载下列软件包:
1 | containerd.io_<version>_<arch>.deb |
4、安装软件包:
1 | sudo dpkg -i ./containerd.io_<version>_<arch>.deb \ |
5、通过运行镜像验证 Docker Engine 安装是否成功 :
1 | sudo docker run hello-world |
从快捷脚本安装
Docker 在 https://get.docker.com/ 上提供了一个方便的脚本,用于以非交互方式将 Docker 安装到开发环境中。官方不建议将快捷脚本用于生产环境,但它对于创建适合用户需求的供应脚本很有用。
1、可以使用DRY_RUN=1选项运行脚本以了解脚本在调用时将运行哪些步骤:
1 | curl -fsSL https://get.docker.com -o get-docker.sh |
2、安装:
1 | curl -fsSL https://get.docker.com -o get-docker.sh |
安装之后
在进行完上述的安装方式后,我们只能通过sudo docker
来执行docker命令,你可能想要通过非 root 用户身份管理 Docker。
此外,在 Debian 和 Ubuntu 上,Docker 服务默认在启动时启动。对于其他Linux发行版,可以通过使用 systemd 来管理。
同时,Docker 提供日志记录驱动程序,用于收集和查看主机上运行的所有容器的日志数据。默认日志记录驱动程序json-file
将日志数据写入主机文件系统上的 JSON 格式文件。随着时间的推移,这些日志文件的大小会不断扩大,从而可能导致磁盘资源耗尽。 我们可以通过配置日志轮换或其他方式解决。
关于以上信息的详细内容可以参见官方教程。
Docker的基本使用
现在我们可以学习Docker的使用方式了,我将列举一些常见操作。
- 查看Docker功能是否正常
- 创建并使用容器
- 管理容器的基本操作
- 使用Docker镜像和仓库
- 用Dockerfile构建镜像
- 共享和发布镜像
查看Docker信息
Docker是基于客户端-服务器构架的。它有一个docker程序,既能作为客户端,也可以作为服务器端。作为客户端时,docker程序向Docker守护进程发送请求(如请求返回守护进程自身的信息),然后再对返回的请求结果进行处理。
下面的例子中我们调用了docker可执行程序的info命令。
1 | docker info |
该命令会返回所有容器和镜像的数量、Docker使用的执行驱动和存储驱动(execution and storage driver),以及Docker的基本配置。
创建并使用容器
现在,我会尝试启动一个Docker容器。我们可以使用docker run
命令来创建并运行容器。
docker create
命令仅用来创建容器而不会运行它,这让我们可以在自己的容器工作流中对其进行细粒度的控制。
运行一个Ubuntu容器
首先,我们运行docker run -it ubuntu /bin/bash
命令,-i
参数表示保证容器中STDIN是开启的,即有持久的标准输入,这是交互式shell的“半边天”。-t
参数则为要创建的容器分配一个伪tty终端,这样新建的容器才能提供一个交互式shell。若要在命令行下创建一个我们能与之进行交互的容器,而不是一个运行后台服务的容器,则这两个参数已经是最基本的参数了。
接下来我们告诉Docker基于什么镜像来创建容器,ubuntu
镜像是一个常备镜像,也可以称为“基础”(base)镜像,它由Docker公司提供,保存在Docker Hub Registry上。用户可以拿基础镜像为基础,在选择的操作系统上构建自己的镜像。
最后,我们告诉Docker在新容器中要运行什么命令,在本例中我们在容器中运行/bin/bash
命令启动了一个Bash shell。
1 | $ docker run -it ubuntu /bin/bash |
根据终端输出,我们可以了解Docker对于该命令的工作流程:首先Docker会检查本地是否存在ubuntu镜像,如果本地还没有该镜像的话,那么Docker就会连接官方维护的Docker Hub Registry,查看Docker Hub中是否有该镜像。Docker一旦找到该镜像,就会下载该镜像并将其保存到本地宿主机中。
随后,Docker在文件系统内部用这个镜像创建了一个新容器。该容器拥有自己的网络、IP地址,以及一个用来和宿主机进行通信的桥接网络接口。
最后,当容器创建完毕之后,Docker就会执行容器中的/bin/bash
命令,这时就可以看到我们处于容器内的shell了。
使用容器
现在我们已经进入到容器的交互式shell中,可以看到容器的ID为a3fefa7e42c4
,这是一个完整的Ubuntu系统,我们可以对其为所欲为了~
那么我们先来尝试对容器进行一些操作:
1、查看/etc/hosts
文件
1 | root@a3fefa7e42c4:/# cat /etc/hosts |
可以看到Docker已经在hosts文件中为该容器的IP添加了一条主机配置项172.17.0.2 a3fefa7e42c4
。
2、查看容器中运行的进程
1 | root@a3fefa7e42c4:/# ps -aux |
这里我意外退出了一次连接,于是当我再次进入容器后(使用exec命令),产生了两个
/bin/bash
进程。
3、安装一个软件包
1 | root@a3fefa7e42c4:/# apt-get update && apt-get install vim |
没什么区别,这只是一个Ubuntu系统。
4、退出
当我们完成工作,输入exit
即可退出容器的交互shell,返回宿主机的终端了。
此时,这个容器已经停止运行了!这是因为只有在我们创建容器时指定的/bin/bash
命令处于运行状态时,容器才会相应的处于运行状态,否则容器也会相应停止。
管理容器的基本操作
查看容器列表
我们可以使用docker ps -a
命令查看当前系统中的容器列表,-a
参数表示列出所有容器,否则docker ps
只会列出正在运行的容器。
1 | $ docker ps -a |
注意我们可以通过三种方式唯一指代容器:短UUID、长UUID和名称。
容器命名
Docker会为我们创建的每一个容器自动生成一个随机的名称。例如,上面我们刚刚 创建的容器就被命名为exciting_thompson。如果想为容器指定一个名称,而不是使用自动生成的名称,则可以用—name标志来实现。
1 | docker run --name NAME -it ubuntu /bin/bash |
在很多Docker命令中都可以使用名称来替代容器ID,设定特定的名称有利于我们区分和管理容器。容器的命名必须是唯一的。如果试图创建两个名称相同的容器,则命令将会失败。 如果要使用的容器名称已经存在,可以先用docker rm
命令删除已有的同名容器后,再来创建新的容器。
我们还有docker rename old_name new_name
命令用于重命名容器。
容器的启动和停止
我们可以通过docker start
命令来启动容器,当然它支持容器名称和ID。
1 | docker start a3fefa7e42c4 |
start
命令还有许多玩法,例如加入-i
参数或--interactive
参数连接回容器的交互式shell,或者一次启动多个容器。
1 | docker start -i a3fefa7e42c4 |
此外,我们还有docker restart
命令用于重启容器。
关于容器的停止,我们使用docker stop
命令。
连接到容器交互
当然,即便我们在启动容器忘记添加-i
参数,我们依然可以通过docker attach
命令连接回容器的回话上。
1 | docker attach a3fefa7e42c4 |
另外,我们还有docker exec
命令可以用于新建一个对容器的交互式shell。从广义上来说,该命令事实上是用于在正在运行的容器中运行新命令。
1 | docker exec -it a3fefa7e42c4 /bin/bash |
与attach
命令不同的是,以上命令会在容器中新建一个交互式shell进程,当该shell通过exit
退出时,如果容器中仍有在运行的/bin/bash
进程,容器就不会停止。
创建守护式容器
此前我们接触的容器称为交互式容器(interactive container),我们也可以创建长期运行的守护式容器(daemonized container),它们非常适合运行应用程序和服务。
1 | docker run --name daemon_dave -d ubuntu /bin/sh -c "while true; do echo hello world; sleep 1; done" |
上面的docker run
命令使用了-d
参数,因此Docker会将容器放到后台运行。我们还在容器要运行的命令里使用了一个while循环,该循环会一直打印hello world,直到容器或其进程停止运行。
现在我们已经有了一个守护式容器,那我们该如何查看容器内部正在进行的活动呢?可以用docker logs
命令来获取容器的日志。
此命令仅适用于使用 json-file 或 journald 日志记录驱动程序启动的容器。
1 | docker logs daemon_dave |
同样,该命令也有许多参数选项,例如-t
显示时间戳。
Docker支持多种日志驱动,例如包括Syslog,可以通过在启动守护进程时指定--log-driver
选项设置驱动。
查看容器内进程
除了容器的日志,还可以查看容器内部运行的进程,使用docker top
命令。
1 | docker top daemon_dave |
查看容器统计信息
除了docker top命令,还可以使用docker stats
命令,它用来显示一个或多个容器的统计信息。可以直接键入该命令,或后跟若干指定容器。
1 | docker stats |
我们能看到一个守护式容器的列表,以及它们的CPU、内存、网络I/O及存储I/O的性能和指标。这对快速监控一台主机上的一组容器非常有用。
在容器内部运行进程
前面已经介绍了利用docker exec
打开一个新的交互式shell,这实质上就是在容器内部运行了一个新的交互式shell进程。我们可以通过该命令在容器内部启动额外的新进程,包括后台任务和交互式任务。
1 | docker exec -d daemon_dave touch /etc/new_config |
上面两条命令分别创建了后台任务和交互式任务。第一条命令会在daemon_dave容器内创建了一个名为/etc/new_config
的空文件。通过docker exec后台命令,可以在正在运行的容器中进行维护、监控及管理任务。
第二条命令则会创建一个新会话,我们也可以通过该shell在容器中进行操作。
自动重启容器
如果由于某种错误而导致容器停止运行,还可以通过--restart
标志,让Docker自动重新启动该容器。--restart
标志会检查容器的退出代码,并据此来决定是否要重启容器。Docker默认不会重启容器。下面是一个在docker run
命令中使用该参数的例子。
1 | docker run --restart=always --name daemon_dave -d ubuntu /bin/sh -c "while true; do echo hello world; sleep 1; done" |
--restart
标志被设置为always
。无论容器的退出代码是什么,Docker 都会自动重启该容器。除了always
,还可以将这个标志设为on-failure
,这样,只有当容器的退出代码为非0值的时候,才会自动重启。另外,on-failure
还接受一个可选的重启次数参数,如--restart=on-failure:5
表示尝试重启5次。
获取容器的详细信息
可以使用docker inspect
来获得更多的容器信息,该命令会对容器进行详细的检查,然后返回其配置信息,包括名称、 命令、网络配置以及很多有用的数据。
结合-f
或--format
参数,可以选定查看结果,如:
1 | docker inspect --format='{{ .State.Running }}' daemon_dave |
上面的命令会返回容器的运行状态。我们还可以同时指定多个容器以返回每个容器的检查结果。
删除容器
当容器不再使用时,可以使用docker rm
命令删除它们。
1 | docker rm ubun |
我们可以通过-f
参数强制删除容器,无论容器是否在运行。还可以通过docker ps -a -q
返回所有容器id,并用rm
全部删除。
使用Docker镜像和仓库
这一部分主要介绍容器的构建基石:Docker镜像。之前我们了解了如何使用容器,现在我们来了解一下如何使用镜像。
列出本地镜像
我们可以使用docker images
命令列出Docker主机上可用的镜像。
1 | $ docker images |
可以看到,我们已经获得了一个镜像列表,现在里面只有一个镜像,来源于一个名为ubuntu的仓库。这个镜像的来历是先前我们执行docker run命令时,同时进行了镜像下载。
本地镜像都保存在Docker宿主机的/var/lib/docker
目录下。每个镜像都保存在Docker所采用的存储驱动目录下面,如aufs
或者devicemapper
。也可以在/var/lib/docker/containers
目录下面看到所有的容器。
镜像从仓库下载下来。镜像保存在仓库中,而仓库存在于Registry中。默认的Registry是由Docker公司运营的公共Registry服务,即Docker Hub。
Docker Registry的代码是开源的,所以我们也可以运行自己的私有Registry。同时,Docker公司也提供了一个商业版的Docker Hub,即Docker Trusted Registry,这是一个可以运行在公司防火墙内部的产品,之前被称为Docker Enterprise Hub。
你可能会注意到在一开始我们下载Docker引擎时,它的名字为docker-ce
,这表示它是社区版(Community Edition),Docker同时还提供付费的企业版。
在Registry(无论是Docker Hub还是私人Registry)中,镜像被保存在仓库中。可以将镜像仓库想象为类似Git仓库的东西。它包括镜像、层以及关于镜像的元数据 (metadata)。
深入Registry与镜像仓库
事实上,不仅Registry中可以包含多个仓库,每个镜像仓库都可以存放很多镜像(比如,ubuntu仓库包含了 Ubuntu 23.04、22.04、18.04、16.04…的镜像)。
让我们来分析docker images
命令的输出:
1 | REPOSITORY TAG IMAGE ID CREATED SIZE |
我们可以看到对于下载的ubuntu镜像,输出不仅指明了镜像的仓库为ubuntu
,同时还提供了一个标签。这就是镜像仓库能够存放许多镜像的秘诀—标签机制。
为了区分同一个仓库中的不同镜像,Docker提供了一种称为标签(tag)的功能。每个镜像在列出来时都带有一个标签,如18.04、22.04、quantal或者precise 等。每个标签对组成特定镜像的一些镜像层进行标记(比如,标签18.04就是对所有 Ubuntu 18.04镜像的层的标记)。
我们可以通过在仓库名后加上一个冒号和标签名的方式来指定该仓库中的某一镜像,如ubuntu:16.04
。
我们虽然称其为Ubuntu操作系统,但实际上它并不是一个完整的操作系统。它只是一个裁剪版,只包含最低限度的支持系统运行的组件。
一个镜像可以有多个标签。这使我们可以方便地对镜像进行打标签并且很容易查找镜像。在构建容器时指定仓库的标签也是一个很好的习惯。这样便可以准确地指定容器 源于哪里。不同标签的镜像会有不同,比如Ubutnu 18.04和22.04就不一样,指定镜像的标签会让我们确切知道自己使用的是ubuntu:22.04,这样我们就能准确知道自己在干什么。
除此之外,Registry中的仓库名称也有讲究,Docker Hub中有两种类型的仓库:用户仓库(user repository)和顶层仓库 (top-level repository)。用户仓库的镜像都是由Docker用户创建的,而顶层仓库则是由Docker内部的人来管理的。
用户仓库的命名由用户名和仓库名两部分组成,如jamtur01/puppet
。而顶层仓库就如ubuntu
一般,不存在/
分隔。
查找镜像
Docker Hub中通常包含很多镜像仓库,那么我们如何了解其中目前有哪些镜像仓库呢?
如果你可以使用浏览器,访问https://hub.docker.com
无疑是最方便的。如果想要查找ubuntu
相关的仓库,我们只需要访问https://hub.docker.com/search?q=ubuntu
。
或者,我们还可以使用docker search
命令查找仓库,但这种方式无法查看仓库中有何标签,也就是说无法了解镜像仓库中具体包含哪些镜像。这可以通过镜像仓库的RESTful API查询,这里不展开说明。
1 | $ docker search --limit 5 ubuntu |
上面的命令在Docker Hub上查找了所有带有ubuntu
的镜像,并返回前五条信息。我们可以看到信息包括如下内容:
- 仓库名;
- 镜像描述;
- 用户评价(Stars)—反应出一个镜像的受欢迎程度;
- 是否官方(Official)—由上游开发者管理的镜像(如fedora镜像由Fedora 团队管理);
- 自动构建(Automated)—表示这个镜像是由Docker Hub的自动构建 (Automated Build)流程创建的。
另外,现在除了Docker Official Image
外,还提供了两种较为可靠的信任等级Verified Publisher
和Sponsored OSS
。可以在官网查看。
用户贡献的镜像都是由Docker社区用户提供的,这些镜像并没有经过Docker公司的确认和验证,在使用这些镜像时需要自己承担相应的风险。
拉取镜像
用docker run
命令从镜像启动一个容器时,如果该镜像不在本地,Docker会先从Docker Hub下载该镜像。如果没有指定具体的镜像标签,那么Docker会自动下载latest
标签的镜像。
如果想单独下载镜像,则需要使用docker pull
命令。下面我们来拉取一个ubuntu:16.04
的镜像。
1 | $ docker pull ubuntu:16.04 |
现在,我们可以通过docker images
查看下载好的镜像:
1 | $ docker images |
用Dockerfile构建镜像
前面我们已经看到了如何拉取已经构建好的带有定制内容的Docker镜像,那么如何修改自己的镜像,并且更新和管理这些镜像呢?构建Docker镜像有以下两种方法。
- 使用
docker commit
命令。 - 使用
docker build
命令和Dockerfile文件。
现在我们并不推荐使用docker commit
命令,而应该使用更灵活、更强大的Dockerfile来构建Docker镜像。因此这里将重点介绍Docker所推荐的镜像构建方法:编写Dockerfile之后使用docker build
命令。
可以想象
docker commit
与git commit
十分类似,它仅提交创建容器的镜像与容器当前状态间的差异部分,因此十分轻量。该命令同样可以指定更多信息选项,包括提交信息、作者信息、设定标签等。最简单的使用只需要在退出容器后执行
docker commit 容器ID 目标仓库名
,例如docker commit id silencezheng/ubuntu:webserver
。
Dockerfile使用基本的基于 DSL(Domain Specific Language) 语法的指令来构建一个Docker镜像,推荐使用Dockerfile是因为通过它来构建镜像更具备可重复性、透明性以及幂等性。
一旦有了Dockerfile,我们就可以使用docker build
命令基于该Dockerfile中的指令构建一个新的镜像。
创建Dockerfile
首先我们将尝试创建一个包含简单Web服务器的Docker镜像。
1 | mkdir static_web && cd static_web && touch Dockerfile |
我们创建了一个名为static_web的目录用来保存Dockerfile,这个目录就是我们的构建环境(build environment),Docker则称此环境为上下文(context)或者构建上下文(build context)。Docker会在构建镜像时将构建上下文和该上下文中的文件和目录上传到Docker守护进程。这样Docker守护进程就能直接访问用户想在镜像中存储的任何代码、文件或者其他数据。
下面我们编写Dockerfile的内容,使其可以被构建为一个Web服务器镜像。
1 | # Version: 0.0.1 |
该Dockerfile由一系列指令和参数组成。每条指令,如FROM,都必须为大写字母,且后面要跟随一个参数:FROM ubuntu:16.04
。Dockerfile中的指令会按顺序从上到下执行,所以应该根据需要合理安排指令的顺序。
每条指令都会创建一个新的镜像层并对镜像进行提交。Docker大体上按照如下流程执行Dockerfile中的指令。
- Docker从基础镜像运行一个容器。
- 执行一条指令,对容器做出修改。
- 执行类似
docker commit
的操作,提交一个新的镜像层。 - Docker再基于刚提交的镜像运行一个新容器。
- 执行Dockerfile中的下一条指令,直到所有指令都执行完毕。
基于此,即便用户的Dockerfile由于某些原因(如某条指令失败了)没有正常结束,用户也将得到了一个可以使用的镜像。这对调试非常有帮助:可以基于该镜像运行一个具备交互功能的容器,使用最后创建的镜像对为什么用户的指
令会失败进行调试。
每个Dockerfile的第一条指令必须是FROM
。FROM
指令指定一个已经存在的镜像,后续指令都将基于该镜像进行,这个镜像被称为基础镜像(base iamge)。
在前面的Dockerfile示例里,我们指定了ubuntu:16.04
作为新镜像的基础镜像。基于这个Dockerfile构建的新镜像将以Ubuntu 16.04操作系统为基础。在运行一个容器时,必须要指明是基于哪个基础镜像在进行构建。
接着指定了MAINTAINER
指令,这条指令会告诉Docker该镜像的作者是谁,以及作者的电子邮件地址。这有助于标识镜像的所有者和联系方式。
在这些指令之后,我们指定了两条RUN
指令。RUN
指令会在当前镜像中运行指定的命令。在这个例子里,我们通过RUN
指令更新了已经安装的APT仓库,安装了nginx包,之后创建了/usr/share/nginx/html/index.html
文件,该文件有一些简单的示例文本。像前面说的那样,每条RUN
指令都会创建一个新的镜像层,如果该指令执行成功,就会将此镜像层提交,之后继续执行Dockerfile中的下一条指令。
默认情况下,RUN
指令会在shell里使用命令包装器/bin/sh -c
来执行。如果是在一个不支持shell的平台上运行或者不希望在shell中运行(比如避免shell字符串篡改),也可以使用exec格式的RUN
指令,如下:
1 | RUN ["apt-get", "install", "-y", "nginx"] |
在这种方式中,我们使用一个数组来指定要运行的命令和传递给该命令的每个参数。
接着我们设置了EXPOSE
指令,这条指令告诉Docker该容器内的应用程序将会使用容器的指定端口。这并不意味着可以自动访问任意容器运行中服务的端口(这里是80)。
出于安全的原因,Docker并不会自动打开该端口,而是需要用户在使用docker run
运行容器时来指定需要打开哪些端口。一会儿我们将会看到如何从这一镜像创建一个新容器。
可以指定多个EXPOSE
指令来向外部公开多个端口。
Docker也使用
EXPOSE
指令来帮助将多个容器链接。用户可以在运行时以docker run
命令通过--expose
选项来指定对外部公开的端口。
基于Dockerfile构建新镜像
执行docker build
命令时,Dockerfile中的所有指令都会被执行并且提交,并且在该命令成功结束后返回一个新镜像。
1 | $ docker build -t="silencezheng/static_web" . |
我们通过指定-t
选项为新镜像设置了仓库和名称,本例中仓库为silencezheng
,镜像名为static_web
。强烈建议各位为自己的镜像设置合适的名字以方便追踪和管理。也可以在构建镜像的过程中为镜像设置一个标签,其使用方法为-t="silencezheng/static_web:v1"
。
如果没有制定任何标签,Docker将会自动为镜像设置一个latest标签。
上面命令中最后的.
告诉Docker到本地目录中去找Dockerfile文件。也可以指定一个Git仓库的源地址来指定Dockerfile的位置,如用git@github.com:jamtur01/docker-static_web
替换它。Docker会假设该Git仓库根目录下存在Dockerfile。
在构建过程中,构建上下文已经上传到了Docker守护进程。如果在构建上下文的根目录下存在以.dockerignore
命名的文件的话,那么该文件内容会被按行进行分割,每一行都是一条文件过滤匹配模式。这非常像.gitignore
文件,该文件用来设置哪些文件不会被当作构建上下文的一部分,因此可以防止它们被上传到Docker守护进程中去。该文件中模式的匹配规则采用了Go语言中的filepath
(因为Docker是用GO编写的)。
现在,如果我们执行docker images
,就可以看到我们创建好的镜像。
1 | REPOSITORY TAG IMAGE ID CREATED SIZE |
虽然镜像相较于虚拟机已经缩小了几倍甚至数十倍,但对于256G硬盘的电脑(是的,MacBook)来说依然过于庞大,使用Dockerfile能够有效减少容量压力😆。
Dockerfile和构建缓存
由于每一步的构建过程都会将结果提交为镜像,所以Docker的构建镜像过程就显得非常聪明。它会将之前的镜像层看作缓存。比如,当我们构建的镜像在第4步出现问题时,我们不需要在第1步到第3步之间进行任何修改,因此Docker会将之前构建时创建的镜像当做缓存并作为新的开始点。实际上,当再次进行构建时,Docker会直接从第4步开始。当之前的构建步骤没有变化时,这会节省大量的时间。如果真的在第1步到第3步之间做了什么修改,Docker则会从第一条发生了变化的指令开始。
然而,有些时候需要确保构建过程不会使用缓存。比如,如果已经缓存了前面的第3步,即apt-get update
,那么Docker将不会再次刷新APT包的缓存。这时用户可能需要取得每个包的最新版本。要想略过缓存功能,可以使用docker build
的--no-cache
。
1 | docker build --no-cache -t="silencezheng/static_web" . |
基于构建缓存的Docker模版
构建缓存带来的一个好处就是,我们可以实现简单的Dockerfile模板(比如在Dockerfile文件顶部增加包仓库或者更新包,从而尽可能确保缓存命中)。James Turnbull一般都会在他的Dockerfile文件顶部使用相同的指令集模板,比如对Ubuntu,使用下方的模版。
1 | FROM ubuntu:14.04 |
在这个例子里,他通过ENV
指令来设置了一个名为REFRESHED_AT
的环境变量,这个环境变量用来表明该镜像模板最后的更新时间。最后,使用了RUN
指令来运行apt-get -qq update
命令。该指令运行时将会刷新APT包的缓存,用来确保我们能将要安装的每个软件包都更新到最新版本。
有了这个模板,如果想刷新一个构建,只需修改ENV
指令中的日期。这使Docker在命中ENV
指令时开始重置这个缓存,并运行后续指令而无须依赖该缓存。也就是说,RUN apt-get update
这条指令将会被再次执行,包缓存也将会被刷新为最新内容。可以扩展此模板,适配到不同的平台或者添加额外的需求。
查看新镜像
我们当然可以使用docker images
查看全部镜像的简略信息。
此外,如果想要深入了解镜像是如何被构建出来的,可以使用docker history
命令:
1 | $ docker history silencezheng/static_web |
从输出中可以看到指定镜像的每一层,以及创建这些层的Dockerfile指令。
从新镜像启动容器
现在来检查一下silencezheng/static_web
能否正常工作吧!
1 | docker run -d -p 80 --name static_web silencezheng/static_web nginx -g "daemon off;" |
上面的命令基于刚才构建的镜像的名字,启动了一个名为static_web
的新容器。我们同时指定了-d
选项,告诉Docker以分离(detached)的方式在后台运行。这种方式非常适合运行类似Nginx守护进程这样的需要长时间运行的进程。我们也指定了需要在容器中运行的命令:nginx -g "daemon off;"
。这将以前台运行的方式启动Nginx,来作为我们的Web服务器。
我们这里也使用了一个新的-p
标志,该标志用来控制Docker在运行时应该公开哪些网络端口给外部(宿主机)。运行一个容器时,Docker可以通过两种方法来在宿主机上分配端口。
- Docker可以在宿主机上随机选择一个位于
32768~61000
的一个比较大的端口号来映射到容器中的80端口上。 - 可以在Docker宿主机中指定一个具体的端口号来映射到容器中的80端口上。
docker run
命令将在Docker宿主机上随机打开一个端口,这个端口会连接到容器中的80端口上。下面使用docker ps
命令来看一下容器的端口分配情况。-l
选项用于显示最新创建的容器。
1 | $ docker ps -l |
可以看到,容器中的80端口被映射到了宿主机的63007上。我们也可以通过docker port
来查看容器的端口映射情况。
1 | $ docker port static_web |
-p
选项还为我们在将容器端口向宿主机公开时提供了一定的灵活性。比如,可以指定将容器中的端口映射到Docker宿主机的某一特定端口上,例如-p <宿主机端口>:<容器端口>
。
我们也可以将端口绑定限制在特定的网络接口(即IP地址)上,例如使用-p 127.0.0.1:80:80
,将容器内的80端口绑定到本地宿主机的127.0.0.1这个IP的80端口上。或者使用-p 127.0.0.1::80
将容器内的80端口绑定到本地宿主机的127.0.0.1这个IP的随机端口上。
此外,也可以通过在端口绑定时使用/udp
后缀来指定UDP端口。
Docker还提供了一个更简单的方式,即-P
参数,该参数可以用来对外公开在Dockerfile中通过EXPOSE
指令公开的所有端口。采用-P
参数会将通过EXPOSE
指令公开的所有端口绑定到宿主机的随机端口上。
现在,我们的Web服务器已经启动了,可以通过访问宿主机localhost访问它:
1 | $ curl localhost:63007 |
可以看到Nginx已经在正常运行了,但是我们配置的初始页并没有生效。
修改镜像
Nginx初始页不生效的原因是我们没有在配置文件中设置server块,于是访问时nginx返回默认起始页,该起始页位于var/www/html/
下。
Nginx安装完成后的目录情况:
/usr/sbin/nginx #主程序
/etc/nginx #存放配置文件
/usr/share/nginx #存放静态文件
/var/log/nginx #存放日志
于是我们可以在配置文件中增加如下内容:
1 | server{ |
调试成功后,修改Dockerfile为如下内容:
1 | # Version: 0.0.1 |
然后我们重新构建镜像,并打上fixed
标签:
1 | $ docker build -t="silencezheng/static_web:fixed" . |
由于存在缓存,本次构建只花费了0.3秒。下面我们基于这个版本的镜像启动容器:
1 | docker run -d -P --name static_web_fixed silencezheng/static_web:fixed nginx -g "daemon off;" |
这次我们通过-P
参数对外开放端口,启动了新的容器static_web_fixed
。测试:
1 | $ docker ps |
测试成功!我们修复了Dockerfile,并基于它构建了正确的镜像。现在我们得到了一个简单的基于Docker的Web服务器。
共享和发布镜像
在构建了我们自己的镜像后,我们总是希望能够共享和发布镜像,这样能够方便传递我们的工作成果。下面我将演示如何将镜像推送到Docker Hub上。
创建Docker Hub账号
到 https://hub.docker.com 注册账号并激活。
登录到Docker Hub
我们可以使用docker login
命令登录到Docker Hub。这条命令将会完成登录到Docker Hub的工作,并将认证信息保存起来以供后面使用。
1 | $ docker login |
用户的个人认证信息将会保存到
$HOME/.docker/config.json
。
当不需要保持登录状态时,可以使用docker logout
命令从一个Registry服务器退出。
将镜像推送到Docker Hub
镜像构建完毕之后,我们也可以将它上传到Docker Hub上面去,这样其他人就能使用这个镜像了。比如,我们可以在组织内共享这个镜像,或者完全公开这个镜像。
Docker Hub也提供了对私有仓库的支持,这是一个需要付费的功能,用户可以将镜像存储到私有仓库中,这样只有用户或者任何与用户共享这个私有仓库的人才能访问该镜像。
我们可以通过docker push
命令将镜像推送到Docker Hub。
1 | $ docker push silencezheng/static_web |
推送没有成功,这是因为我的Docker Hub账户名为silencezheng66
,而本地的镜像名为silencezheng/static_web
,虽然格式合法但用户名与实际不符。于是我需要修改镜像名称,使其与我的Docker Hub账户名一致。
1 | docker tag silencezheng/static_web:fixed silencezheng66/static_web |
这里我们使用了docker tag SOURCE_IMAGE[:TAG] TARGET_IMAGE[:TAG]
创建一个引用 SOURCE_IMAGE 的标签 TARGET_IMAGE。
接着我们重新推送镜像:
1 | $ docker push silencezheng66/static_web |
我们可以通过Docker Hub网页查看我们上传的镜像。
删除镜像
我们可以使用docker rmi
命令删除镜像,该命令也支持指定一个镜像名列表来删除多个。
1 | docker rmi silencezheng/static_web silencezheng66/static_web |
当存在基于镜像构建的容器时,将不能删除镜像,我们可以通过添加-f
参数强制删除镜像,但这不会删除对应的容器。
需要注意的是,当镜像同属一个仓库,但拥有不同标签时(一个镜像仓库中的多个镜像),需要通过指定标签删除,否则只会删除latest
镜像。
1 | $ docker rmi silencezheng/static_web:fixed |
参考文献
[1]https://www.nutanix.cn/info/hypervisor
[2]James Turnbull等. 第一本Docker书. 人民邮电出版社, 2015.
[3]https://zhuanlan.zhihu.com/p/35493900
[4]https://blog.csdn.net/oopxiajun2011/article/details/105029232
后记
首发于 silencezheng.top,转载请注明出处。
写文时的一点思考
在撰写本文时,我对写博客可能产生的知识产权问题也有了一些思考,本文的大部分内容实际上都摘自《第一本Docker书》,这是不是一种变相的“盗版书”散发呢?
从法律的角度讲,似乎这是的,虽然我没有以盈利为目的,但据知乎“平老虎(贾纪谦,青岛市北区人,身份证尾号0018)”的说法,这侵犯了著作权人的网络传播权和著作权。
得知这样的结果也确实有些无奈吧。Docker本身具有开源属性,我也仅是书写了简单的基础知识,相信原作者不至于会找我麻烦(当然也是因为我没流量,如果全网几百万粉丝,可能出版社闻着味就过来了,人怕出名猪怕壮)。
我的内容目前也只是学习笔记的性质,当我忘记的时候方便回看,当有人问我的时候,直接甩给他一篇我整理好的文章去读,十分方便。
做一项小而美的工作,也多是一件美事。