在前一章中,我们学习了如何构建Docker镜像以及在容器内运行生成的镜像所需的基本步骤。在本章中,我们首先将了解容器技术的历史,然后深入探讨运行容器以及探索Docker命令,这些命令用于控制容器的整体配置、资源和权限。
什么是容器?
您可能熟悉像VMware或KVM这样的虚拟化系统,它们允许您在虚拟化层之上运行完整的Linux内核和操作系统,通常称为hypervisor。这种方法在工作负载之间提供非常强大的隔离,因为每个虚拟机都托管其自己的操作系统内核,该内核位于硬件虚拟化层之上的单独内存空间中。
容器在本质上是不同的,因为它们共享一个单一的内核,并且在该内核内完全实现工作负载之间的隔离。这被称为操作系统虚拟化。
libcontainer的README提供了对容器的很好、简洁的定义: 容器是一个自包含的执行环境,它共享主机系统的内核,并且(可选地)与系统中的其他容器隔离。
容器的主要优势之一是资源效率,因为您不需要为每个隔离的工作负载创建完整的操作系统实例。由于您共享一个内核,所以在隔离任务和底层真实硬件之间只有一个较少的间接层。当进程在容器内运行时,只有很少的代码位于内核中来管理该容器。与之相比,虚拟机中将运行第二层。在虚拟机中,进程对硬件或hypervisor的调用将需要在处理器上在特权模式之间进行两次切换,从而明显减慢许多调用的速度。
然而,容器的方法意味着您只能运行与底层内核兼容的进程。例如,与VMware或KVM等技术提供的硬件虚拟化不同,Windows应用程序无法在Linux主机上的Linux容器中本地运行。但是,Windows应用程序可以在Windows主机上的Windows容器中运行。因此,容器最好被视为一种特定于操作系统的技术,您可以在其中运行与容器服务器内核兼容的任何喜爱的应用程序或守护程序。在考虑容器时,您应该尽力摒弃自己可能已经对虚拟机有的认识,而是将容器概念化为在服务器上运行的普通进程的封装。
容器的历史
通常,一项革命性的技术往往是一种老技术,最终才引起关注。技术发展呈波浪式,1960年代的一些想法现在又重新流行起来。类似地,Docker是一项较新的技术,其易用性使其迅速成为热门,但它并不是孤立存在的。Docker的许多基础来自过去30年在几个不同领域的工作。我们可以从1970年代末Unix内核中添加的一个简单系统调用开始追溯容器概念的演进,直到现代容器工具的形成,这些工具支持许多大型互联网公司,如Google、Twitter和Meta。花点时间快速了解技术的演进,并了解它是如何导致Docker的创建,这有助于将其置于您可能熟悉的其他事物的背景中。
容器并不是一个新的概念。它们是一种隔离和封装运行系统的方法。在这个领域,最早的技术包括最初的批处理系统。在使用这些早期计算机时,系统一次只能运行一个程序,一旦前一个程序完成或预定义的时间段过去,就切换到运行另一个程序。通过这种设计实现了强制隔离:您可以确保您的程序不会干扰其他程序,因为一次只能运行一件事。虽然现代计算机仍然不断地切换任务,但对大多数用户来说,这是极其快速且完全无感知的。
我们可以认为今天的容器的种子在1979年种下,当时在第7版Unix中添加了chroot系统调用。chroot将进程对底层文件系统的视图限制为单个子树。chroot系统调用通常用于保护操作系统免受不受信任的服务器进程(如FTP、BIND和Sendmail)的攻击,因为它们是公开暴露的,容易受到威胁。
在1980年代和1990年代,出于安全原因,各种Unix变体创建了强制访问控制(MAC)。这意味着您在同一Unix内核上运行紧密控制的域。每个域中的进程对系统的视图极其有限,不能在域之间进行交互。一个实现这个思想的受欢迎的商业版Unix是构建在BSDI Unix之上的Sidewinder防火墙,但这在大多数主流Unix实现中都不可行。
直到2000年,随着FreeBSD 4.0的发布,情况发生了改变,引入了一个新命令称为"jail",它旨在允许共享环境的托管提供商轻松而安全地在它们的进程与每个客户进程之间创建隔离。FreeBSD jail扩展了chroot的功能,并且还限制了进程对底层系统和其他受监禁进程的所有操作。
2004年,Sun发布了Solaris 10的早期版本,其中包含Solaris容器,后来演变为Solaris Zones。这是容器技术的第一个主要商业实现,今天仍被用于支持许多商业容器实现。2005年,Virtuozzo公司发布了Linux的OpenVZ,随后在2007年,HP发布了HP-UX的安全资源分区,后来更名为HP-UX容器。
像Google这样的公司,需要处理面向广泛互联网使用和/或托管不受信任的用户代码的应用程序,开始在2000年代初推动容器技术,以便可靠且安全地将其应用程序分布到全球数据中心。一些公司在内部使用自己维护的带有容器支持的Linux内核,但随着Linux社区对这些功能的需求变得更加明显,Google将其支持容器的一些工作贡献到了主线Linux内核中,2008年,Linux容器(LXC)在Linux内核的2.6.24版本中发布。Linux容器在社区中的蓬勃发展直到2013年才真正开始,这是因为在Linux内核的3.8版本中加入了用户命名空间,同时一个月后发布了Docker。
如今,容器几乎无处不在。Docker和OCI镜像为大量交付到生产环境的软件提供了打包格式,并为许多生产系统提供了基础,包括但不限于Kubernetes和大多数“无服务器”云技术。
创建容器
到目前为止,我们一直使用方便的docker container run命令来启动容器。但是docker container run实际上是一个将两个单独步骤包装在一起的便捷命令。它首先从底层镜像创建一个容器。我们可以使用docker container create命令单独完成这一步骤。其次,docker container run执行容器,我们也可以使用docker container start命令单独执行此步骤。
docker container create和docker container start命令都包含与如何初始设置容器相关的所有选项。在第4章中,我们演示了使用docker container run命令可以使用-p / --publish参数将底层容器中的网络端口映射到主机,并且可以使用-e / --env来将环境变量传递到容器中。
这只是初始创建容器时可以配置的各种选项的一小部分。让我们来看一些docker支持的选项。
基本配置
让我们首先探索一些创建容器时可以告诉Docker如何配置容器的方法。
容器名字
当您创建容器时,它是从底层镜像构建的,但各种命令行参数可以影响最终的设置。在Dockerfile中指定的设置始终用作默认值,但您可以在创建时覆盖其中的许多设置。 默认情况下,Docker会通过将形容词与著名人物的名字结合起来,随机为您的容器命名。这将产生像ecstatic-babbage和serene-albattani这样的名称。如果您想为容器指定一个特定的名称,您可以使用--name参数:
$ docker container create --name="awesome-service" ubuntu:latest sleep 120
创建了这个容器之后,您可以使用docker container start awesome-service来启动它。它会在120秒后自动退出,但是您可以在那之前通过运行docker container stop awesome-service来停止它。稍后在本章中,我们将更详细地介绍每个命令。
标签
正如在第4章中提到的,标签是可以应用于Docker镜像和容器的键值对,用作元数据。创建新的Linux容器时,它们会自动继承其父镜像的所有标签。
您也可以向容器添加新的标签,以便为该单个容器应用可能特定于该容器的元数据:
$ docker container run --rm -d --name has-some-labels \
-l deployer=Ahmed -l tester=Asako \
ubuntu:latest sleep 1000
然后,您可以使用像docker container ls这样的命令,根据这些元数据搜索和过滤容器:
$ docker container ls -a -f label=deployer=Ahmed
CONTAINER ID IMAGE COMMAND … NAMES
845731631ba4 ubuntu:latest "sleep 1000" … has-some-labels
您可以使用docker container inspect命令查看容器拥有的所有标签:
$ docker container inspect has-some-labels
…
"Labels": {
"deployer": "Ahmed",
"tester": "Asako"
},
…
该容器运行的是sleep 1000命令,因此在1000秒后它将停止运行。
主机名
默认情况下,当您启动一个容器时,Docker会将主机上的某些系统文件(包括/etc/hostname)复制到主机上容器的配置目录中,并使用绑定挂载将该文件的副本链接到容器中。我们可以这样启动一个没有特殊配置的默认容器:
$ docker container run --rm -ti ubuntu:latest /bin/bash
这个命令使用了docker container run命令,它在后台运行docker container create和docker container start。由于我们希望能够与我们要创建的容器进行交互,以进行演示,所以我们传递了一些有用的参数。--rm参数告诉Docker在容器退出时删除容器,-t参数告诉Docker分配一个伪终端(pseudo-TTY),-i参数告诉Docker这将是一个交互式会话,并且我们希望保持STDIN打开。如果镜像中没有定义ENTRYPOINT,那么命令中的最后一个参数将作为在容器内运行的可执行文件和命令行参数,而在这种情况下,是非常有用的/bin/bash。如果镜像中定义了ENTRYPOINT,那么最后一个参数将作为命令行参数列表传递给ENTRYPOINT进程。
如果我们现在在生成的容器内运行mount命令,我们将看到类似于这样的内容:
root@ebc8cf2d8523:/# mount
overlay on / type overlay (rw,relatime,lowerdir=…,upperdir=…,workdir…)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
tmpfs on /dev type tmpfs (rw,nosuid,mode=755)
shm on /dev/shm type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k)
mqueue on /dev/mqueue type mqueue (rw,nosuid,nodev,noexec,relatime)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,…,ptmxmode=666)
sysfs on /sys type sysfs (ro,nosuid,nodev,noexec,relatime)
/dev/sda9 on /etc/resolv.conf type ext4 (rw,relatime,data=ordered)
/dev/sda9 on /etc/hostname type ext4 (rw,relatime,data=ordered)
/dev/sda9 on /etc/hosts type ext4 (rw,relatime,data=ordered)
devpts on /dev/console type devpts (rw,nosuid,noexec,relatime,…,ptmxmode=000)
proc on /proc/sys type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/sysrq-trigger type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/irq type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/bus type proc (ro,nosuid,nodev,noexec,relatime)
tmpfs on /proc/kcore type tmpfs (rw,nosuid,mode=755)
root@ebc8cf2d8523:/#
容器中有相当多的绑定挂载,但在这种情况下,我们对这个感兴趣:
/dev/sda9 on /etc/hostname type ext4 (rw,relatime,data=ordered)
尽管每个容器的设备编号都不同,但我们关心的部分是挂载点为/etc/hostname。这将把容器的/etc/hostname链接到Docker为容器准备的hostname文件,默认情况下包含容器的ID,并且没有完全限定的域名。
我们可以在容器中通过运行以下命令来检查这一点:
root@ebc8cf2d8523:/# hostname -f
ebc8cf2d8523
root@ebc8cf2d8523:/# exit
要特别设置主机名,我们可以使用--hostname参数来传递一个更具体的值:
$ docker container run --rm -ti --hostname="mycontainer.example.com" \
ubuntu:latest /bin/bash
然后,在容器内部,我们会看到完全限定的主机名按要求定义:
root@mycontainer:/# hostname -f
mycontainer.example.com
root@mycontainer:/# exit
域名服务
就像/etc/hostname一样,配置域名服务(DNS)解析的resolv.conf文件也是通过主机和容器之间的绑定挂载进行管理的:
/dev/sda9 on /etc/resolv.conf type ext4 (rw,relatime,data=ordered)
默认情况下,这是Docker主机的resolv.conf文件的完全复制。如果您不想要这样的设置,您可以在容器中使用--dns和--dns-search参数的组合来覆盖此行为:
$ docker container run --rm -ti --dns=8.8.8.8 --dns=8.8.4.4 \
--dns-search=example1.com --dns-search=example2.com \
ubuntu:latest /bin/bash
在容器内部,您仍然会看到一个绑定挂载,但文件内容将不再反映主机的resolv.conf;而是会变成像这样:
root@0f887071000a:/# more /etc/resolv.conf
nameserver 8.8.8.8
nameserver 8.8.4.4
search example1.com example2.com
root@0f887071000a:/# exit
MAC地址
您还可以配置容器的媒体访问控制(MAC)地址,这也是另一个重要的配置信息。 如果没有任何配置,容器将收到一个以02:42:ac:11前缀开始的计算出的MAC地址。 如果您需要将其设置为特定的值,可以运行类似于以下的命令:
$ docker container run --rm -ti --mac-address="a2:11:aa:22:bb:33" \
ubuntu:latest /bin/bash
通常情况下,您不需要这样做。但有时候,您可能希望为您的容器保留一组特定的MAC地址,以避免与使用与Docker相同的私有地址块的其他虚拟化层冲突。
存储卷
有时候,容器默认分配的磁盘空间或容器的短暂性质并不适合当前的任务,因此您需要具有持久性的存储,可以在容器部署之间保持数据。
在这种情况下,您可以利用--mount/-v命令将主机服务器上的目录和单个文件挂载到容器中。在--mount/-v参数中使用完全限定路径是很重要的。以下示例将/mnt/session_data挂载到容器内的/data目录中:
$ docker container run --rm -ti \
--mount type=bind,target=/mnt/session_data,source=/data \
ubuntu:latest /bin/bash
root@0f887071000a:/# mount | grep data
/dev/sda9 on /data type ext4 (rw,relatime,data=ordered)
root@0f887071000a:/# exit
在此命令中,主机挂载点和容器中的挂载点都不需要事先存在,以使该命令正常工作。如果主机挂载点不存在,则将创建为目录。如果您尝试指向一个文件而不是一个目录,这可能会导致一些问题。
在挂载选项中,您可以看到文件系统按预期在/data上挂载为可读写。
如果容器应用程序被设计为写入/data目录,那么这些数据将在主机文件系统中的/mnt/session_data目录中可见,并且当该容器停止后,通过挂载相同的卷启动新的容器时,这些数据仍将保持可用。
您可以告诉Docker,容器的根卷应该以只读方式挂载,这样容器内的进程就无法向根文件系统写入任何内容。这可以防止生产环境中的开发人员不知道的日志文件填满容器的分配磁盘空间。当与挂载的卷一起使用时,可以确保数据只写入预期位置。
在前面的示例中,我们可以通过将--read-only=true添加到命令中来实现这一点:
$ docker container run --rm -ti --read-only=true -v /mnt/session_data:/data \
ubuntu:latest /bin/bash
root@df542767bc17:/# mount | grep " / "
overlay on / type overlay (ro,relatime,lowerdir=…,upperdir=…,workdir=…)
root@df542767bc17:/# mount | grep data
/dev/sda9 on /data type ext4 (rw,relatime,data=ordered)
root@df542767bc17:/# exit
仔细查看根目录的挂载选项,您会注意到它们是以ro选项挂载的,这使其变为只读。然而,/session_data挂载仍然使用rw选项挂载,以便我们的应用程序可以成功地写入到设计用于写入的卷。
有时候,即使容器的其余部分是只读的,也需要使像/tmp这样的目录可写。对于这种情况,您可以使用--mount type=tmpfs参数在docker container run中挂载tmpfs文件系统到容器中。tmpfs文件系统完全位于内存中,速度非常快,但它们也是暂时的,并将利用额外的系统内存。当容器停止时,这些tmpfs目录中的任何数据都将丢失。下面的示例展示了一个在/tmp处挂载了256 MB tmpfs文件系统的容器的启动:
$ docker container run --rm -ti --read-only=true \
--mount type=tmpfs,destination=/tmp,tmpfs-size=256M \
ubuntu:latest /bin/bash
root@25b4f3632bbc:/# df -h /tmp
Filesystem Size Used Avail Use% Mounted on
tmpfs 256M 0 256M 0% /tmp
root@25b4f3632bbc:/# grep /tmp /etc/mtab
tmpfs /tmp tmpfs rw,nosuid,nodev,noexec,relatime,size=262144k 0 0
root@25b4f3632bbc:/# exit
资源配额(Resource Quotas)
当人们讨论在云环境中工作时,他们经常需要应对的问题之一就是“嘈杂的邻居”(noisy neighbor)。这个术语指的基本问题是,与您在同一物理系统上运行的其他应用程序可能会对您的性能和资源可用性产生明显影响。
虚拟机(VMs)的优势在于您可以轻松且非常精细地控制为VM分配多少内存和CPU等资源。当使用Docker时,您必须利用Linux内核中的cgroup功能来控制为Linux容器分配的资源。docker container create和docker container run命令直接支持在创建容器时配置CPU、内存、交换空间和存储I/O限制。
这里有一个重要的注意事项。虽然Docker支持各种资源限制,但您必须在内核中启用这些功能,以便Docker利用它们。您可能需要在启动时将这些功能作为命令行参数添加到内核中。要确定您的内核是否支持这些限制,请运行docker system info命令。如果您缺少任何支持,您将在底部收到警告消息,类似于:
WARNING: No swap limit support
CPU份额 (CPU shares)
Docker有几种限制容器中应用程序的CPU使用方法。最初的方法仍然广泛使用,那就是CPU份额(CPU shares)的概念。我们将介绍其他选项。
系统中所有CPU核心的计算能力被认为是完整的份额池。Docker将数字1024分配为完整的份额池的表示。通过配置容器的CPU份额,您可以决定容器获得使用CPU的时间量。如果您希望容器最多能够使用系统计算能力的一半,则可以分配512份额。这些份额不是排他的,这意味着将所有1024份额分配给一个容器并不会阻止其他容器运行。相反,这是一个关于调度程序的提示,关于每次调度时每个容器应该运行的时间。如果我们有一个容器被分配了1024份额(默认值),而有两个容器被分配了512份额,它们将被调度相同次数。但是,如果每个进程的正常CPU时间是100微秒,具有512份额的容器每次运行时将运行50微秒,而具有1024份额的容器将运行100微秒。
让我们稍微探讨一下实际中的工作原理。对于以下示例,我们将使用一个包含stress命令的新Docker镜像,该命令可以将系统推到极限。
当我们不应用cgroup限制运行stress时,它将使用我们指定的所有资源。以下命令通过创建两个CPU密集型进程,一个I/O密集型进程和两个内存分配进程,创建了约为5的平均负载。对于以下所有示例,我们在一个有两个CPU的系统上运行。
请注意,在以下命令中,容器镜像名称后的所有内容与stress命令有关,而不是docker命令:
$ docker container run --rm -ti spkane/train-os \
stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 120s
如果在Docker主机上运行top或htop命令,在两分钟运行结束时,您可以看到stress程序创建的负载对系统的影响:
$ top -bn1 | head -n 15
top - 20:56:36 up 3 min, 2 users, load average: 5.03, 2.02, 0.75
Tasks: 88 total, 5 running, 83 sleeping, 0 stopped, 0 zombie
%Cpu(s): 29.8 us, 35.2 sy, 0.0 ni, 32.0 id, 0.8 wa, 1.6 hi, 0.6 si, 0.0 st
KiB Mem: 1021856 total, 270148 used, 751708 free, 42716 buffers
KiB Swap: 0 total, 0 used, 0 free. 83764 cached Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
810 root 20 0 7316 96 0 R 44.3 0.0 0:49.63 stress
813 root 20 0 7316 96 0 R 44.3 0.0 0:49.18 stress
812 root 20 0 138392 46936 996 R 31.7 4.6 0:46.42 stress
814 root 20 0 138392 22360 996 R 31.7 2.2 0:46.89 stress
811 root 20 0 7316 96 0 D 25.3 0.0 0:21.34 stress
1 root 20 0 110024 4916 3632 S 0.0 0.5 0:07.32 systemd
2 root 20 0 0 0 0 S 0.0 0.0 0:00.04 kthreadd
3 root 20 0 0 0 0 S 0.0 0.0 0:00.11 ksoftir…
如果您希望再次运行相同的stress命令,但只使用可用CPU时间的一半,您可以像这样执行:
$ docker container run --rm -ti --cpu-shares 512 spkane/train-os \
stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 120s
--cpu-shares 512 是完成这一操作的标志,将 512 个 CPU 份额分配给该容器。在系统不是非常繁忙的情况下,这个参数的效果可能不会明显。这是因为只有在系统资源受限时,容器才会被安排同样长度的时间片,每当它有工作要做。所以在我们的情况下,除非您运行更多的容器来让 CPU 处理其他工作,否则在主机系统上运行 top 命令的结果可能看起来是一样的。
CPU pinning(CPU固定)
还可以将容器固定到一个或多个CPU核心。这意味着该容器的工作将只在分配给该容器的核心上调度。如果您想要在应用程序之间硬分配CPU,或者如果您有需要固定到特定CPU的应用程序,例如缓存效率等方面,这将非常有用。
在以下示例中,我们运行一个stress容器,并将其固定到两个CPU中的第一个,使用512个CPU份额:
$ docker container run --rm -ti \
--cpu-shares 512 --cpuset-cpus=0 spkane/train-os \
stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 120s
如果再次运行 top 命令,您应该会注意到在用户空间(us)中花费的CPU时间百分比比以前低,因为我们将两个CPU密集型进程限制在一个CPU上:
%Cpu(s): 18.5 us, 22.0 sy, 0.0 ni, 57.6 id, 0.5 wa, 1.0 hi, 0.3 si, 0.0 st
在Linux内核中使用CPU Completely Fair Scheduler(CFS),您可以通过在使用docker container run启动容器时将--cpu-quota标志设置为有效值来改变给定容器的CPU配额。
简化CPU配额
虽然CPU份额是Docker中管理CPU限制的原始机制,但是Docker已经发展了很多,现在让用户的生活变得更加简单的其中一种方式是大大简化了如何设置CPU配额。现在,您不必自己尝试设置正确的CPU份额和配额,只需告诉Docker您希望为容器提供多少CPU,它将计算所需的数学运算,以正确设置底层的cgroups。
--cpus命令可以设置为介于0.01和Docker服务器上的CPU核心数之间的浮点数:
$ docker container run --rm -ti --cpus=".25" spkane/train-os \
stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 60s
如果您尝试设置值过高,您将收到来自Docker(而不是stress应用程序)的错误消息,其中将为您提供正确的CPU核心范围供您使用:
$ docker container run --rm -ti --cpus="40.25" spkane/train-os \
stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 60s
docker: Error response from daemon: Range of CPUs is from
0.01 to 4.00, as there are only 4 CPUs available.
See 'docker container run --help'.
docker container update命令可用于动态调整一个或多个容器的资源限制。例如,您可以同时调整两个容器的CPU分配,如下所示:
$ docker container update --cpus="1.5" 092c5dc85044 92b797f12af1
内存
我们可以控制容器可以访问多少内存,方式类似于限制CPU。然而,有一个基本的区别:虽然限制CPU只影响应用程序对CPU时间的优先级,但内存限制是一个硬限制。即使在具有96 GB可用内存的无限制系统上,如果我们告诉一个容器只能访问24 GB,那么它只能使用24 GB的内存,无论系统上有多少可用内存。由于Linux上的虚拟内存系统的工作方式,可以为容器分配比系统实际内存更多的内存。在这种情况下,容器将使用交换空间,就像普通的Linux进程一样。
让我们通过在docker container run命令中传递--memory选项来启动一个有内存限制的容器:
$ docker container run --rm -ti --memory 512m spkane/train-os \
stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 10s
当您仅使用--memory选项时,您设置了容器可以访问的RAM和交换空间的数量。因此,通过在这里使用--memory 512m,我们将容器限制为512 MB的RAM和512 MB的额外交换空间。Docker支持b、k、m或g,分别表示字节、千字节、兆字节或千兆字节。如果您的系统以某种方式运行Linux和Docker,并且具有多个TB的内存,那么不幸的是,您需要用GB来指定。
如果您想单独设置交换空间或完全禁用交换空间,您还需要使用--memory-swap选项。这定义了容器可用的总内存和交换空间。如果我们像这样重新运行之前的命令:
$ docker container run --rm -ti --memory 512m --memory-swap=768m \
spkane/train-os stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M \
--timeout 10s
然后我们告诉内核该容器可以访问512 MB的内存和256 MB的额外交换空间。将--memory-swap选项设置为-1将在容器内完全禁用交换空间。
那么,如果一个容器达到了它的内存限制会发生什么呢?好吧,让我们尝试一下,通过修改之前的命令并将内存降低到一个较低的值:
$ docker container run --rm -ti --memory 100m spkane/train-os \
stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 10s
在所有其他运行stress容器的情况下,最后一行的输出如下:
stress: info: [17] successful run completed in 10s
我们可以看到,这次运行很快失败,并显示类似于以下内容的行:
stress: FAIL: [1] (451) failed run completed in 0s
这是因为容器尝试分配超过其允许的内存,Linux OOM killer被调用并开始在cgroup内部杀死进程以回收内存。在这种情况下,我们的容器有一个单独的父进程,它生成了几个子进程,当OOM killer杀死其中一个子进程时,父进程清理所有内容并带有错误退出。
如果您访问Docker服务器,可以通过运行dmesg命令查看与此事件相关的内核消息。输出将类似于以下内容:
[ 4210.403984] stress invoked oom-killer: gfp_mask=0x24000c0 …
[ 4210.404899] stress cpuset=5bfa65084931efabda59d9a70fa8e88 …
[ 4210.405951] CPU: 3 PID: 3429 Comm: stress Not tainted 4.9 …
[ 4210.406624] Hardware name: BHYVE, BIOS 1.00 03/14/2014
…
[ 4210.408978] Call Trace:
[ 4210.409182] [<ffffffff94438115>] ? dump_stack+0x5a/0x6f
….
[ 4210.414139] [<ffffffff947f9cf8>] ? page_fault+0x28/0x30
[ 4210.414619] Task in /docker-ce/docker/5…3
killed as a result of limit of /docker-ce/docker/5…3
[ 4210.416640] memory: usage 102380kB, limit 102400kB, failc …
[ 4210.417236] memory+swap: usage 204800kB, limit 204800kB, …
[ 4210.417855] kmem: usage 1180kB, limit 9007199254740988kB, …
[ 4210.418485] Memory cgroup stats for /docker-ce/docker/5…3:
cache:0KB rss:101200KB rss_huge:0KB mapped_file:0KB dirty:0KB
writeback:11472KB swap:102420KB inactive_anon:50728KB
active_anon:50472KB inactive_file:0KB active_file:0KB unevictable:0KB
…
[ 4210.426783] Memory cgroup out of memory: Kill process 3429…
[ 4210.427544] Killed process 3429 (stress) total-vm:138388kB,
anon-rss:44028kB, file-rss:900kB, shmem-rss:0kB
[ 4210.442492] oom_reaper: reaped process 3429 (stress), now
anon-rss:0kB, file-rss:0kB, shmem-rss:0kB
这个OOM事件也会被Docker记录,并可以通过docker system events查看:
$ docker system events
2018-01-28T15:56:19.972142371-08:00 container oom \
d0d803ce32c4e86d0aa6453512a9084a156e96860e916ffc2856fc63ad9cf88b \
(image=spkane/train-os, name=loving_franklin)
块输入/输出(Block I/O)
许多容器仅是无状态应用程序,不需要块输入/输出(Block I/O)限制。但Docker也通过cgroups机制以几种不同的方式支持限制块I/O。
第一种方式是对容器的块设备I/O使用应用优先级。您可以通过操作blkio.weight cgroup属性的默认设置来启用此功能。该属性可以有一个值为0(禁用)或10到1,000之间的数字,默认值为500。这个限制有点像CPU shares,系统会将所有可用的I/O按1,000来划分给cgroup切片中的每个进程,分配的权重影响每个进程可用的I/O数量。
要在容器上设置此权重,您需要使用有效值在docker container run命令中传递--blkio-weight选项。您还可以使用--blkio-weight-device选项来针对特定设备进行设置。
与CPU shares一样,在实践中调整权重很难做到完美,但我们可以通过通过cgroup限制容器可用的最大字节数或每秒操作数来大大简化。以下设置可以让我们控制:
--device-read-bps Limit read rate (bytes per second) from a device
--device-read-iops Limit read rate (IO per second) from a device
--device-write-bps Limit write rate (bytes per second) to a device
--device-write-iops Limit write rate (IO per second) to a device
您可以通过运行以下一些命令来测试这些设置对容器性能的影响,这些命令使用Linux I/O测试工具bonnie:
$ time docker container run --rm -ti spkane/train-os:latest bonnie++ \
-u 500:500 -d /tmp -r 1024 -s 2048 -x 1
…
real 0m27.715s
user 0m0.027s
sys 0m0.030s
$ time docker container run -ti --rm --device-write-iops /dev/vda:256 \
spkane/train-os:latest bonnie++ -u 500:500 -d /tmp -r 1024 -s 2048 -x 1
…
real 0m58.765s
user 0m0.028s
sys 0m0.029s
$ time docker container run -ti --rm --device-write-bps /dev/vda:5mb \
spkane/train-os:latest bonnie++ -u 500:500 -d /tmp -r 1024 -s 2048 -x 1
…
在我们的经验中,--device-read-iops和--device-write-iops参数是设置块I/O限制最有效的方法,我们推荐使用它们。
ulimits(资源限制)
在Linux cgroups之前,还有一种方法可以限制进程可用的资源:通过ulimit命令应用用户限制。该机制仍然可用,并且对于所有传统用例仍然有用。
以下代码是您通常可以通过ulimit命令设置软限制和硬限制来限制的系统资源类型的列表:
$ ulimit -a
core file size (blocks, -c) 0
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 5835
max locked memory (kbytes, -l) 64
max memory size (kbytes, -m) unlimited
open files (-n) 1024
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 10240
cpu time (seconds, -t) unlimited
max user processes (-u) 1024
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited
您可以配置Docker守护进程,设置您想要应用于每个容器的默认用户限制。以下命令告诉Docker守护进程,将所有容器的软限制设置为50个打开文件,硬限制为150个打开文件
$ sudo dockerd --default-ulimit nofile=50:150
然后,您可以通过使用--ulimit参数在特定容器上覆盖这些ulimits值:
$ docker container run --rm -d --ulimit nofile=150:300 nginx
有一些其他高级命令可以在创建容器时使用,但本文涵盖了许多常见用例。Docker客户端文档列出了所有可用的选项,并且每次Docker发布时都会进行更新。
启动容器
在深入了解容器和约束之前,我们使用docker container create命令创建了一个容器。该容器只是静静地存在,没有进行任何操作。它有一个配置但没有运行的进程。当我们准备好启动容器时,可以使用docker container start命令来启动它。
假设我们需要运行一个Redis的副本,这是一个常见的键/值存储。我们不会对这个Redis容器进行任何操作,但它是一个轻量级的、长时间运行的进程,可以作为我们在实际环境中可能会执行的示例。我们可以首先创建该容器:
$ docker container create -p 6379:6379 redis:2.8
Unable to find image 'redis:7.0' locally
7.0: Pulling from library/redis
3f4ca61aafcd: Pull complete
…
20bf15ad3c24: Pull complete
Digest: sha256:8184cfe57f205ab34c62bd0e9552dffeb885d2a7f82ce4295c0df344cb6f0007
Status: Downloaded newer image for redis:7.0
092c5dc850446324e4387485df7b76258fdf9ed0aedcd53a37299d35fc67a042
该命令的结果是一些输出,其中最后一行是为容器生成的完整哈希值。我们可以使用这个长哈希值来启动容器,但如果我们忘记记录它,也可以使用以下命令列出系统上的所有容器,无论它们是否正在运行:
$ docker container ls -a --filter ancestor=redis:2.8
CONTAINER ID IMAGE COMMAND CREATED … NAMES
092c5dc85044 redis:7.0 "docker-entrypoint.s…" 46 seconds ago elegant_wright
我们可以通过筛选输出并检查容器的创建时间来确认我们的容器的身份。然后,我们可以使用以下命令启动容器:
$ docker container start 092c5dc85044
这应该已经启动了容器,但由于它在后台运行,我们不一定知道是否出现了问题。为了验证它是否正在运行,我们可以运行以下命令:
$ docker container ls
CONTAINER ID IMAGE COMMAND … STATUS …
092c5dc85044 redis:7.0 "docker-entrypoint.s…" … Up 2 minutes …
是的,它正在运行,一切正常。我们可以看到状态为“Up”并显示容器运行的时长。
自动重新启动容器
在许多情况下,我们希望容器在退出后重新启动。有些容器的生命周期很短暂,很快就会出现和消失。但对于生产应用程序,您希望它们在您启动后始终保持运行状态。如果您运行的是更复杂的系统,调度程序可能会代替您执行此操作。
在简单的情况下,我们可以通过在docker container run命令中传递--restart参数,让Docker代表我们管理重新启动。它有四个取值:no,always,on-failure或unless-stopped。如果restart设置为no,容器在退出后将不会重新启动。如果设置为always,容器无论以何种退出代码退出,都将重新启动。如果restart设置为on-failure,只有当容器以非零退出代码退出时,Docker才会尝试重新启动容器。如果我们将restart设置为on-failure:3,Docker将在放弃前尝试重新启动容器三次。unless-stopped是最常见的选择,它会在容器不被有意地停止(比如使用docker container stop)的情况下重新启动容器。
我们可以通过重新运行上次的受内存约束的stress容器,去掉--rm参数,但加上--restart参数来看到此功能的实际效果。
$ docker container run -ti --restart=on-failure:3 --memory 100m \
spkane/train-os stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M \
--timeout 120s
在这个例子中,我们会在第一次运行的输出在控制台上显示后立即终止。如果我们在容器终止后立即运行docker container ls命令,很可能会看到Docker已经重新启动了容器。
$ docker container ls
… IMAGE … STATUS …
… spkane/train-os … Up Less than a second …
由于我们没有为它提供足够的内存以正常运行,它将继续失败。经过三次尝试后,Docker将放弃,并且我们将在docker container ls的输出中看不到该容器。
停止容器
容器可以随时停止和启动。你可能认为停止和启动容器类似于暂停和恢复常规进程,但实际上并不完全相同。当停止时,进程不会暂停,而是退出。当容器停止时,它不再出现在正常的docker container ls命令的输出中。在重新启动时,Docker将尝试启动在关闭时运行的所有容器。如果你需要阻止容器执行任何其他工作,而不是真正停止该进程,则可以使用docker container pause和docker container unpause暂停Linux容器,这将在后面详细讨论。现在,可以继续停止我们之前启动的Redis容器:
$ docker container stop 092c5dc85044
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
现在我们已经停止了容器,运行容器列表中没有任何内容!我们可以使用容器ID将其重新启动,但是必须记住这个ID可能有点不方便。因此,docker container ls命令有一个额外的选项(-a),可以显示所有容器,而不仅仅是正在运行的容器:
$ docker container ls -a
CONTAINER ID IMAGE STATUS …
092c5dc85044 redis:7.0 Exited (0) 2 minutes ago …
…
现在,STATUS字段显示我们的容器以状态码0(无错误)退出。我们可以使用之前相同的配置重新启动它:
$ docker container start 092c5dc85044
092c5dc85044
$ docker container ls -a
CONTAINER ID IMAGE STATUS …
092c5dc85044 redis:7.0 Up 14 seconds …
…
现在,我们的容器已经重新启动,并且与之前配置的完全相同。
到目前为止,我们可能已经重点介绍了足够多次,即容器只是一组进程的树,它们与服务器上的任何其他进程基本上以相同的方式与系统交互。但是这里再次指出这一点很重要,因为这意味着我们可以向容器中的进程发送Unix信号,然后它们可以做出响应。在前面的docker container stop示例中,我们发送了一个SIGTERM信号给容器,并等待容器正常退出。容器遵循在Linux上接收任何其他进程组的信号传播规则。
通常,docker container stop会发送一个SIGTERM信号给进程。如果您希望在一定时间后强制终止容器,可以使用-t参数,如下所示:
$ docker container stop -t 25 092c5dc85044
这告诉Docker首先发送SIGTERM信号,就像以前一样,但如果容器在25秒内(默认值为10秒)未停止,它告诉Docker发送SIGKILL信号来强制终止它。 虽然stop是关闭容器的最佳方式,但有时它可能不起作用,您可能需要强制终止容器,就像可能需要对容器外的任何进程做的那样。
杀死容器
当进程行为不当时,docker container stop可能不足以解决问题。您可能希望容器立即退出。
在这种情况下,您可以使用docker container kill。如您所料,它与docker container stop非常相似:
$ docker container start 092c5dc85044
092c5dc85044
$ docker container kill 092c5dc85044
092c5dc85044
现在,docker container ls命令显示该容器不再运行,正如预期的那样:
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
虽然它被杀死而不是停止,但这并不意味着您不能再次启动它。您可以像对待一个正常停止的容器一样发出docker container start命令。有时,您可能希望向容器发送另一个信号,不是stop或kill信号。与Linux的kill命令类似,docker container kill支持发送任何Unix信号。假设我们想向容器发送一个USR1信号,以指示它执行一些操作,比如重新连接远程日志记录会话,我们可以执行以下操作:
$ docker container start 092c5dc85044
092c5dc85044
$ docker container kill --signal=USR1 092c5dc85044
092c5dc85044
如果我们的容器进程设计为对USR1信号做出响应,它现在将执行相应的操作。使用这种方法可以向容器发送任何标准的Unix信号。
暂停和取消暂停容器
有几个原因可能导致我们不想完全停止容器。我们可能想要暂停容器,保留其资源分配,并保留其在进程表中的条目。这可能是因为我们正在对其文件系统进行快照以创建新的镜像,或者仅仅是因为我们需要一些主机上的 CPU 资源。如果你习惯了正常的 Unix 进程处理,你可能会想知道这是如何工作的,因为容器化的进程只是普通的进程。
暂停使用了 cgroup freezer,它实际上只是阻止进程被调度,直到你解冻它为止。这将阻止容器执行任何操作,同时保持其整体状态,包括内存内容。与停止容器不同,停止容器的进程通过 SIGSTOP 信号被告知它们正在停止,而暂停容器不会向容器发送任何有关其状态变更的信息。这是一个重要的区别。几个 Docker 命令也在内部使用暂停和取消暂停。以下是如何暂停容器的方法:
$ docker container start 092c5dc85044
092c5dc85044
$ docker container pause 092c5dc85044
092c5dc85044
如果我们查看运行中的容器列表,现在我们将看到 Redis 容器的状态被列为 (Paused):
$ docker container ls
CONTAINER ID IMAGE … STATUS …
092c5dc85044 redis:7.0 … Up 25 seconds (Paused) …
我们试图在容器处于暂停状态时使用它会失败。它存在,但没有正在运行的内容。我们现在可以通过使用 docker container unpause 命令来恢复容器:
$ docker container unpause 092c5dc85044
092c5dc85044
$ docker container ls
CONTAINER ID IMAGE … STATUS …
092c5dc85044 redis:7.0 … Up 55 seconds …
它恢复运行了,docker container ls 正确地反映了新状态。现在显示 Up 55 秒,因为 Docker 即使在容器暂停时仍然认为容器正在运行。
清理容器和镜像
在运行了所有这些命令来构建镜像、创建容器和运行它们后,我们在系统上累积了许多镜像层和容器文件夹。 我们可以使用 docker container ls -a 命令列出系统上的所有容器,然后删除列表中的任何容器。在删除镜像本身之前,我们必须停止使用该镜像的所有容器。假设我们已经完成了这些操作,我们可以使用 docker container rm 命令删除容器,例如:
$ docker container stop 092c5dc85044
092c5dc85044ls
$ docker container rm 092c5dc85044
092c5dc85044
然后,我们可以使用以下命令列出系统上的所有镜像:
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
ubuntu latest 5ba9dab47459 3 weeks ago 188.3MB
redis 7.0 0256c63af7db 2 weeks ago 117MB
spkane/train-os latest 78fb082a4d65 4 months ago 254MB
然后,我们可以通过运行以下命令删除一个镜像以及所有关联的文件系统层:
$ docker image rm 0256c63af7db
有时,在开发周期中,完全清除系统中的所有镜像或容器是有意义的。最简单的方法是运行 docker system prune 命令:
$ docker system prune
WARNING! This will remove:
- all stopped containers
- all networks not used by at least one container
- all dangling images
- all build cache
Are you sure you want to continue? [y/N] y
Deleted Containers:
cbbc42acfe6cc7c2d5e6c3361003e077478c58bb062dd57a230d31bcd01f6190
…
Deleted Images:
deleted: sha256:bec6ec29e16a409af1c556bf9e6b2ec584c7fb5ffbfd7c46ec00b30bf …
untagged: spkane/squid@sha256:64fbc44666405fd1a02f0ec731e35881465fac395e7 …
…
Total reclaimed space: 1.385GB
要删除 Docker 主机上的所有容器,可以使用以下命令:
$ docker container rm $(docker container ls -a -q)
要删除 Docker 主机上的所有镜像,可以使用以下命令:
$ docker image rm $(docker images -q)
docker container ls 和 docker images 命令都支持 filter 参数,它可以帮助你在特定情况下轻松调整删除命令。 要删除所有状态为非零的容器,可以使用以下过滤器:
$ docker container rm $(docker container ls -a -q --filter 'exited!=0')
要删除所有没有标签的镜像,可以输入以下命令:
$ docker image rm $(docker images -q -f "dangling=true")
在频繁部署的生产系统中,可能会积累一些旧的容器或未使用的镜像,从而占用磁盘空间。可以编写脚本来定期运行 docker system prune 命令(例如,在 cron 或通过 systemd 定时器运行)。
Windows Containers(Windows 容器)
到目前为止,我们完全关注了 Linux 容器的 Docker 命令,因为这是最常见的用例,并且适用于所有 Docker 平台。然而,自从2016年以来,Microsoft Windows 平台已支持运行 Windows 容器,其中包括本地 Windows 应用程序,并可以使用常规的 Docker 命令进行管理。
由于 Windows 容器需要 Windows 特定的容器镜像,它们并不是本书的重点,因为它们在生产容器中仍只占很小一部分,并且与 Docker 生态系统的其余部分不完全兼容。然而,它们是 Docker 世界中日益增长和重要的一部分,所以我们将简要介绍它们的工作原理。实际上,除了容器的实际内容之外,几乎所有其他内容与 Linux 容器完全相同。在本节中,我们将快速示例演示如何在 Windows 10+ 平台上使用 Hyper-V 和 Docker 运行 Windows 容器。
首先,您需要将 Docker 从 Linux 容器切换到 Windows 容器。为此,请在任务栏中右键单击 Docker 鲸鱼图标,选择“切换到 Windows 容器...”,然后确认切换(见图5-1和图5-2)。
这个过程可能需要一些时间,尽管它通常几乎立即完成。不幸的是,没有通知显示切换已完成。如果您再次右键单击 Docker 图标,现在应该看到“切换到 Linux 容器...”取代了原来的选项。
我们可以通过打开 PowerShell 4 并尝试运行以下命令来测试一个简单的 Windows 容器:
PS C:\> docker container run --rm -it mcr.microsoft.com/powershell `
pwsh -command `
'Write-Host "Hello World from Windows `($IsWindows`)"'
Hello World from Windows (True)
这将下载并启动一个 PowerShell 的基础容器,然后使用脚本在 Windows 系统上打印 "Hello World from Windows (True)" 到屏幕上。
如果您想构建一个执行类似任务的 Windows 容器镜像,可以创建以下 Dockerfile:
# escape=`
FROM mcr.microsoft.com/powershell
SHELL ["pwsh", "-command"]
RUN Add-Content C:\helloworld.ps1 `
'Write-Host "Hello World from Windows"'
CMD ["pwsh", "C:\\helloworld.ps1"]
构建这个 Dockerfile 时,它将以 mcr.microsoft.com/powershell 为基础镜像,创建一个简单的 PowerShell 脚本,并配置镜像在用于启动容器时运行该脚本。
如果您现在构建这个 Dockerfile,您会看到类似于这样的输出:
PS C:\> docker image build -t windows-helloworld:latest .
Sending build context to Docker daemon 2.048kB
Step 1/4 : FROM mcr.microsoft.com/powershell
---> 7d8f821c04eb
Step 2/4 : SHELL ["pwsh", "-command"]
---> Using cache
---> 1987fb489a3d
Step 3/4 : RUN Add-Content C:\helloworld.ps1
'Write-Host "Hello World from Windows"'
---> Using cache
---> 37df47d57bf1
Step 4/4 : CMD ["pwsh", "C:\\helloworld.ps1"]
---> Using cache
---> 03046ff628e4
Successfully built 03046ff628e4
Successfully tagged windows-helloworld:latest
现在,如果您运行生成的镜像,您会看到这样的输出:
PS C:\> docker container run --rm -ti windows-helloworld:latest
Hello World from Windows
微软提供了关于Windows容器的详细文档,其中还包括构建一个启动.NET应用程序的容器的示例。
即使您计划主要使用Windows容器,在阅读本书的其余部分时,您应该切换回Linux容器,以确保所有示例按预期工作。当您阅读完毕,并准备深入构建容器时,您可以随时切换回Windows容器。
总结
在下一章中,我们将继续探索Docker的特点。现在,您可能希望进行一些自己的实验。我们建议您尝试一些我们在这里介绍过的容器控制命令,以便熟悉命令行选项和整体语法。现在是一个很好的时机,可以尝试设计和构建一个小型镜像,然后将其作为一个新的容器启动。当您准备好继续时,请继续阅读第六章!