Podmon-DevOps-全-

Podmon DevOps(全)

原文:annas-archive.org/md5/ccc1448cc43fce17d5900ebf1b61b4dc

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

DevOps 最佳实践鼓励将容器作为云原生生态系统的基础。由于容器已经成为打包应用程序及其依赖项的新标准,了解如何实现、构建和管理它们已成为开发人员、系统管理员以及 SRE/运维团队的必要技能。Podman 及其配套工具 Buildah 和 Skopeo,构成了一个强大的工具集,有助于提升容器化应用程序的开发、执行和管理。从容器化及其底层技术的基本概念开始,本书将帮助你用 Podman 启动第一个容器。本书探索了完整的工具集,展示了新容器的开发、生命周期管理、故障排除和安全性方面。

到了《Podman for DevOps》一书的结尾,你将掌握将应用程序构建和打包到容器中,以及部署、管理和与系统服务集成所需的技能。

本书适用人群

本书面向希望学习如何在容器内构建和打包应用程序的云开发人员,以及希望将容器与系统服务和编排解决方案集成、部署和管理的系统管理员。本书详细比较了 Docker 和 Podman,帮助你快速学习 Podman。

本书内容

第一章容器技术介绍,涵盖了容器技术的关键概念、一些历史背景以及使容器技术得以运行的基础要素。

第二章Podman 与 Docker 的比较,带你了解 Docker 与 Podman 的架构,查看高层概念以及它们之间的主要区别。

第三章运行第一个容器,教你如何设置运行和管理第一个 Podman 容器的前提条件。

第四章管理运行中的容器,帮助你理解如何管理容器的生命周期,启动/停止/杀死容器,以正确管理服务。

第五章为容器的数据实现存储,涵盖了容器的存储需求基础、可用的各种存储选项,以及如何使用它们。

第六章认识 Buildah——从零构建容器,是你开始学习 Buildah 基本概念的章节,Buildah 是 Podman 的配套工具,帮助系统管理员和开发人员在容器创建过程中提供帮助。

第七章与现有应用构建过程集成,教你如何将 Buildah 集成到现有应用的构建过程中。

第八章选择容器基础镜像,详细讲解了容器基础镜像格式、可信源及其底层特性。

第九章将镜像推送到容器注册表,教你容器注册表是什么,如何认证它们,以及如何通过推送和拉取镜像进行操作。

第十章容器故障排查与监控,展示了如何检查正在运行或失败的容器,如何查找问题,并监控容器的健康状态。

第十一章容器安全性,深入探讨了容器的安全性、主要问题以及在运行时更新容器镜像的重要步骤。

第十二章实现容器网络概念,教你关于容器网络接口CNI),如何将容器暴露给外部世界,最后,如何在同一机器上互联多个容器。

第十三章Docker 迁移技巧,将带领你学习如何通过使用 Podman 的一些内置功能,以及一些可能在迁移过程中帮助的技巧,以最简单的方式从 Docker 迁移到 Podman。

第十四章与 systemd 和 Kubernetes 的互动,展示了如何将容器集成作为基础操作主机中的系统服务,从而使其能够通过常见的系统管理员工具进行管理。还将探讨 Podman 与 Kubernetes 的交互功能。

为了最大限度地利用本书

在本书中,我们将引导你安装和使用 Podman 3 或更高版本,以及其伴随工具 Buildah 和 Skopeo。书中使用的默认 Linux 发行版是 Fedora Linux 34 或更高版本,但也可以使用其他任何 Linux 发行版。所有命令和代码示例都是在 Fedora 34 或 35 和 Podman 3 或 4 上测试的,但它们也应适用于未来的版本发布。

如果你使用的是本书的数字版,我们建议你自己输入命令或从本书的 GitHub 仓库访问代码(链接将在下一节提供)。

这样做将帮助你避免与代码复制和粘贴相关的潜在错误。

下载示例代码文件

你可以从 GitHub 下载本书的示例代码文件,网址是github.com/PacktPublishing/Podman-for-DevOps。如果代码有更新,它将会在 GitHub 仓库中更新。

我们还提供了来自我们丰富书籍和视频目录的其他代码包,可以在github.com/PacktPublishing/查看!

下载彩色图像

我们还提供了一个 PDF 文件,其中包含本书中使用的截图和图表的彩色图像。你可以在这里下载:static.packt-cdn.com/downloads/9781803248233_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

文本中的代码:表示文本中的代码词、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入以及 Twitter 用户名。举个例子:“我们刚刚为我们的代码库定义了一个名字,ubi8-httpd,并选择将该代码库与 GitHub 代码库进行推送关联。”

一段代码设置如下:

[Unit]
Description=Podman API Socket
Documentation=man:podman-system-service(1)

当我们希望特别引起你对代码块中某部分的注意时,相关的行或项目会以粗体显示:

$ podman ps
CONTAINER ID IMAGE
COMMAND CREATED STATUS PORTS
NAMES
685a339917e7 registry.fedoraproject.org/f29/httpd:latest /
usr/bin/run-http... 3 minutes ago Up 3 minutes ago
clever_zhukovsky

任何命令行输入或输出都如下所示:

$ skopeo login -u admin -p p0dman4Dev0ps# --tls-verify=false localhost:5000
Login Succeeded!

粗体:表示新术语、重要单词或屏幕上出现的单词。例如,菜单或对话框中的词汇会以粗体显示。以下是一个例子:“…并且在收到 GET /请求时,打印一个带有Hello World!消息的 HTML 页面。”

提示或重要说明

显示如下。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果你对本书的任何方面有疑问,请通过电子邮件联系我们:customercare@packtpub.com,并在邮件主题中注明书名。

勘误表:尽管我们已尽一切努力确保内容的准确性,但错误难免发生。如果你在本书中发现错误,请向我们报告。请访问www.packtpub.com/support/errata并填写表单。

盗版:如果你在互联网上发现我们的作品的任何非法复制品,我们将非常感激你提供具体的位置或网站名称。请通过版权@packt.com 与我们联系,并提供相关资料的链接。

如果你有兴趣成为作者:如果你在某个领域有专业知识,并且有兴趣写书或为书籍做贡献,请访问authors.packtpub.com

分享你的想法

阅读后,我们希望听听你的想法!请点击这里直接跳转到本书的亚马逊评论页面并分享你的反馈。

你的评论对我们和技术社区非常重要,将帮助我们确保提供优质的内容。

第一部分:从理论到实践:使用 Podman 运行容器

本章将带领你了解容器技术的基本概念,Podman 及其配套工具的主要功能,Podman 与 Docker 的主要区别,并最终将容器的运行与管理理论付诸实践。

本书的内容包括以下章节:

  • 第一章容器技术简介

  • 第二章比较 Podman 和 Docker

  • 第三章运行第一个容器

  • 第四章管理正在运行的容器

  • 第五章为容器数据实现存储

第一章:容器技术简介

容器技术在操作系统历史中有着悠久的根基。例如,你知道容器技术的部分内容是在 1970 年代就诞生了吗?尽管它们的方式简单直观,但容器背后有许多值得深入分析的概念,只有完全理解和欣赏它们,才能明白它们是如何在 IT 行业中崭露头角的。

我们将探索这项技术,以更好地理解它的工作原理、背后的理论以及基本概念。了解工具背后的机制和技术将使你能够轻松地掌握并学习整个技术的关键概念。

然后,我们还将探讨容器技术的目的,以及它为何如今已经在每家公司得到广泛应用。你知道吗,现在世界上 50%的组织在生产环境中运行着一半以上的应用程序作为容器?

让我们深入了解这项伟大的技术!

在本章中,我们将提出以下问题:

  • 什么是容器?

  • 我为什么需要容器?

  • 容器来自哪里?

  • 今天容器在哪里被使用?

技术要求

本章不要求任何技术前提,因此可以放心阅读,无需担心在工作站上安装或设置任何软件!

无论如何,如果你是容器的新手,在这里你会发现许多技术概念有助于理解后续章节。我们建议仔细阅读,并在需要时再回来查看。了解 Linux 操作系统的基础知识有助于理解本书中的技术概念。

本书约定

在接下来的章节中,我们将通过实践示例学习许多新概念,这些示例将需要与 Linux shell 环境进行积极互动。在实践示例中,我们将使用以下约定:

  • 对于任何以$字符开头的 shell 命令,我们将使用 Linux 系统的标准用户(非 root 用户)。

  • 对于任何以#字符开头的 shell 命令,我们将使用 Linux 系统的 root 用户。

  • 任何输出或 shell 命令,如果代码块中显示的内容太长而无法在一行中显示,将会通过\字符中断,并继续到新的一行。

什么是容器?

本节从基础概念入手,逐步介绍容器技术,从进程、文件系统、系统调用、进程隔离,到容器引擎和运行时。该节的目的是描述容器如何实现进程隔离。我们还将描述容器与虚拟机的区别,并强调这两种场景的最佳使用案例。

在问容器是什么之前,我们应该先回答另一个问题:什么是进程?

根据《Linux 编程接口》(作者:Michael Kerrisk)一书,进程是一个正在执行的程序的实例。程序是一个包含执行进程所需信息的文件。程序可以动态链接外部库,或者可以在程序本身中静态链接(Go 编程语言默认使用这种方式)。

这引出了一个重要概念:进程在机器的 CPU 中执行,并分配一部分内存,包含程序代码和代码本身使用的变量。进程实例化在机器的用户空间中,其执行由操作系统内核协调。当进程被执行时,它需要访问不同的机器资源,如 I/O(磁盘、网络、终端等)或内存。当进程需要访问这些资源时,它会执行系统调用进入内核空间(例如,读取磁盘块或通过网络接口发送数据包)。

进程通过文件系统间接与主机磁盘交互,文件系统是一种多层存储抽象,方便对文件和目录的读写访问。

一台机器通常运行多少个进程?很多。它们由操作系统内核进行调度,通过复杂的调度逻辑使得这些进程像是在独立的 CPU 核心上运行一样,尽管同一个核心是多个进程共享的。

同一个程序可以实例化多个相同类型的进程(例如,在同一台机器上运行多个 Web 服务器实例)。冲突,如多个进程尝试访问相同的网络端口,必须相应地进行管理。

没有什么可以阻止我们在主机上运行同一个程序的不同版本,假设系统管理员需要承担管理潜在的二进制文件、库及其依赖项的冲突的任务。这可能会变得非常复杂,通常使用常见的实践并不容易解决。

这段简短的介绍是为了设定背景。

容器是一个简单而聪明的答案,解决了运行隔离进程实例的需求。我们可以安全地断言,容器是一种在多个层面上有效的应用隔离形式:

  • 文件系统隔离:容器化进程具有独立的文件系统视图,它们的程序是从隔离的文件系统中执行的。

  • 进程 ID 隔离:这是在独立的进程 IDPIDs)集下运行的容器化进程。

  • 用户隔离用户 IDUIDs)和组 IDGIDs)是容器内隔离的。一个进程的 UID 和 GID 在容器内可能不同,并且只能在容器内部以特权的 UID 或 GID 运行。

  • 网络隔离:这种隔离与主机网络资源相关,例如网络设备、IPv4 和 IPv6 栈、路由表和防火墙规则。

  • IPC 隔离:容器为主机的 IPC 资源提供隔离,例如 POSIX 消息队列或 System V IPC 对象。

  • 资源使用隔离:容器依赖于 Linux 控制组cgroups)来限制或监控某些资源的使用,如 CPU、内存或磁盘。我们将在本章后面讨论更多关于 cgroups 的内容。

从采纳的角度来看,容器的主要目的是,或者说最常见的用例,是在隔离的环境中运行应用程序。为了更好地理解这个概念,我们可以看一下下面的图示:

图 1.1 – 原生应用与容器化应用对比

图 1.1 – 原生应用与容器化应用对比

在不提供容器化功能的系统上本地运行的应用程序共享相同的二进制文件和库,以及相同的内核、文件系统、网络和用户。这可能在部署更新版本的应用程序时引发许多问题,尤其是冲突的库问题或未满足的依赖关系。

另一方面,容器为应用程序及其相关依赖提供了一致的隔离层,确保它们在同一主机上无缝共存。新的部署仅由执行新容器化版本组成,因为它不会与其他容器或原生应用程序互动或冲突。

Linux 容器通过不同的本地内核特性启用,其中最重要的是 Linux 命名空间。命名空间抽象了特定的系统资源(特别是前面描述的资源,如网络、文件系统挂载、用户等),并使它们对隔离的进程显得是唯一的。通过这种方式,进程看似在与主机资源交互,例如主机文件系统,实际上暴露的是一个替代的、隔离的版本。

目前,我们有总共八种命名空间:

  • PID 命名空间:这些命名空间将进程 ID 号隔离到一个独立的空间中,允许不同 PID 命名空间中的进程保持相同的 PID。

  • 用户命名空间:这些命名空间隔离用户和组 ID、根目录、密钥环和权限。这允许一个进程在容器内具有特权的 UID 和 GID,同时在命名空间外部具有非特权的 UID 和 GID。

  • UTS 命名空间:这些命名空间允许主机名和 NIS 域名的隔离。

  • 网络命名空间:这些命名空间允许隔离网络系统资源,如网络设备、IPv4 和 IPv6 协议栈、路由表、防火墙规则、端口号等。用户可以创建名为 veth 对 的虚拟网络设备,在网络命名空间之间构建隧道。

  • IPC 命名空间:这些命名空间隔离 IPC 资源,如 System V IPC 对象和 POSIX 消息队列。在 IPC 命名空间中创建的对象只能由命名空间中的进程访问。进程使用 IPC 在客户端-服务器机制中交换数据、事件和消息。

  • cgroup 命名空间:这些命名空间隔离 cgroup 目录,提供进程 cgroup 的虚拟化视图。

  • 挂载命名空间:这些命名空间提供了隔离进程在命名空间中看到的挂载点列表。

  • 时间命名空间:这些命名空间提供了系统时间的隔离视图,允许命名空间中的进程在与主机时间的偏移下运行。

现在,让我们继续讨论资源使用情况。

使用 cgroups 管理资源

cgroups 是 Linux 内核的本地功能,目的是将进程组织成一个层级树,并限制或监控它们的资源使用。

内核的 cgroups 接口,与 /proc 的情况类似,通过 cgroupfs 伪文件系统暴露出来。这个文件系统通常挂载在主机的 /sys/fs/cgroup 下。

cgroups 提供了一系列控制器(也叫子系统),可用于不同的目的,例如限制进程的 CPU 时间配额、内存使用、冻结和恢复进程等。

控制器的组织层次结构随着时间的推移发生了变化,目前有两个版本,V1 和 V2。在 cgroups V1 中,不同的控制器可以挂载到不同的层级上。而 cgroups V2 提供了一个统一的控制器层级结构,进程位于树的叶节点上。

cgroups 被容器用来限制 CPU 或内存的使用。例如,用户可以限制 CPU 配额,这意味着限制容器在给定时间段内可以使用的 CPU 微秒数,或者限制 CPU 配额,容器占用的 CPU 周期的加权比例。

现在我们已经展示了进程隔离的工作原理(无论是对于命名空间还是资源),接下来我们可以举几个基本示例。

运行隔离的进程

了解一个有用的事实是,GNU/Linux 操作系统提供了运行容器所需的所有功能。这个结果可以通过使用特定的系统调用(特别是 unshare()clone())以及如 unshare 命令等工具来实现。

例如,要在隔离的 PID 命名空间中运行一个进程(比如 /bin/sh),用户可以依赖 unshare 命令:

# unshare --fork --pid --mount-proc /bin/sh 

结果是在隔离的 PID 命名空间中执行一个新的 shell 进程。用户可以尝试监控该进程视图,并将获得类似以下的输出:

sh-5.0# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0 226164  4012 pts/4    S    22:56   0:00 /bin/sh
root           4  0.0  0.0 227968  3484 pts/4    R+   22:56   0:00 ps aux

有趣的是,前面例子中的 shell 进程正在以 PID 1 运行,这是正确的,因为它是在新的隔离命名空间中运行的第一个进程。

无论如何,PID 命名空间将是唯一被抽象的命名空间,而所有其他系统资源仍然保持为原主机资源。如果我们希望增加更多的隔离,例如在网络栈上,我们可以向之前的命令添加--net标志:

 # unshare --fork --net --pid --mount-proc /bin/sh

结果是一个在 PID 和网络命名空间上都被隔离的 shell 进程。用户可以检查网络 IP 配置,并意识到主机原生设备不再被 unshared 进程直接看到:

sh-5.0# ip addr show
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

前面的示例有助于理解一个非常重要的概念:容器与 Linux 原生特性紧密相关。操作系统提供了一个坚实而完整的接口,帮助容器运行时的开发,而隔离命名空间和资源的能力是开启容器应用的关键。容器运行时的作用是抽象底层隔离机制的复杂性,而挂载点隔离可能是其中最关键的。因此,它值得更详细的解释。

隔离挂载

到目前为止,我们看到的示例是没有影响挂载点和进程端文件系统视图的 unsharing。为了获得能够防止二进制文件和库冲突的文件系统隔离,用户需要为暴露的挂载点创建另一个抽象层。

这个结果是通过利用挂载命名空间和绑定挂载实现的。挂载命名空间最早在 2002 年随 Linux 内核 2.4.19 版本引入,它隔离了进程所看到的挂载点列表。每个挂载命名空间暴露一个独立的挂载点列表,从而使不同命名空间中的进程能够感知到不同的目录层级结构。

通过这种技术,可以向执行进程暴露一个包含所有必要的二进制文件和库的替代目录树。

尽管看似简单,挂载命名空间的管理并非直截了当,也不容易掌握。例如,用户需要处理来自不同发行版的目录树的不同归档版本,提取它们,并在独立的命名空间上进行绑定挂载。我们稍后会看到,Linux 中最初的容器方法遵循了这种方法。

容器的成功还与一种创新的、多层次的写时复制方法密切相关,该方法用于管理目录树,介绍了一种简单而快速的复制、部署和使用容器所需树的方法——容器镜像。

容器镜像来拯救

我们必须感谢 Docker 引入了这种存储容器数据的智能方法。随后,镜像将成为开放容器倡议OCI)标准规范的一部分(github.com/opencontainers/image-spec)。

镜像可以被视为一种文件系统捆绑包,它在运行容器之前会被下载(拉取)并解压到主机中。

镜像从名为quay.iodocker.io的仓库以及可以在客户私有基础设施、内部部署或云环境中执行的私有注册中心下载。

镜像可以由 DevOps 团队构建,以满足特殊需求或嵌入必须在主机上部署和执行的工件。

在镜像构建过程中,开发人员可以注入预构建的工件或源代码,这些代码可以在构建容器中进行编译。为了优化镜像大小,可以创建多阶段构建,第一阶段使用包含必要编译器和运行时的基础镜像来编译源代码,第二阶段则将构建好的工件注入到一个精简、轻量级的镜像中,优化启动速度和存储占用。

构建过程的配方定义在一个特殊的文本文件中,称为Dockerfile,它定义了组装最终镜像所需的所有步骤。

在构建完成后,用户可以将自己的镜像推送到公共或私有注册中心,供以后使用或进行复杂的编排部署。

以下图表总结了构建工作流:

图 1.2 – 镜像构建工作流

图 1.2 – 镜像构建工作流

我们将在本书后续部分更详细地讨论构建主题。

是什么让容器镜像如此特别?镜像的智能理念在于,它们可以被视为一种打包技术。当用户构建自己的镜像,并在操作系统目录树中安装所有二进制文件和依赖项时,他们实际上是在创建一个自洽的对象,可以在任何地方部署,而无需进一步的软件依赖。从这个角度来看,容器镜像是对长期争论的“它在我的机器上能运行”这一说法的答案。

开发团队喜欢它们,因为他们可以确保其应用程序的执行环境,而运维团队喜欢它们,因为它们通过消除维护和更新服务器库依赖的繁琐任务简化了部署过程。

容器镜像的另一个智能特点是它们的写时复制、多层次方法。镜像不是由单一的大型二进制归档组成,而是由多个名为blobslayerstar归档文件组成。各层通过镜像元数据组合在一起,并压缩成一个单一的文件系统视图。这个结果可以通过多种方式实现,但目前最常见的方式是使用联合文件系统

OverlayFSwww.kernel.org/doc/html/latest/filesystems/overlayfs.html)是目前使用最广泛的联合文件系统。它被维护在内核树中,尽管它并不完全符合 POSIX 标准。

根据内核文档,"一个覆盖文件系统结合了两个文件系统——一个是'上层'文件系统,另一个是'下层'文件系统。" 这意味着它可以结合多个目录树,提供一个独特的、压缩的视图。这些目录就是层,分别称为 lowerdirupperdir,用于定义低级目录和位于其上层的目录。统一视图被称为 merged。它最多支持 128 层。

OverlayFS 并不意识到容器镜像的概念;它仅作为一种基础技术,用于实现 OCI 镜像所使用的多层解决方案。

OCI 镜像也实现了 不可变性 的概念。镜像的所有层都是只读的,无法修改。更改下层内容的唯一方法是重新构建镜像并进行适当的修改。

不可变性是云计算方法的重要支柱。它意味着基础设施(如实例、容器或甚至复杂的集群)只能通过不同版本替换,而不能修改以实现目标部署。因此,我们通常不会修改正在运行的容器内部的任何内容(例如手动安装软件包或更新配置文件),即使在某些情况下可能是可行的。相反,我们会用新的更新版本替换其基础镜像。这也确保了每个运行中的容器副本与其他副本保持同步。

当容器执行时,会在镜像上创建一个新的读/写薄层。这个层是短暂的,因此在容器销毁后,所有在其上的更改都会丢失:

图 1.3 – 容器的层

图 1.3 – 容器的层

这引出了另一个重要的观点:我们不在容器内部存储任何东西。它们的唯一目的是为我们的应用程序提供一个稳定一致的运行时环境。数据必须通过外部访问,使用容器内部的绑定挂载或网络存储(如 网络文件系统 (NFS)、简单存储服务 (S3)、互联网小型计算机系统接口 (iSCSI) 等)。

容器的挂载隔离和镜像的分层设计提供了一致的、不可变的基础设施,但仍然需要更多的安全限制,以防止具有恶意行为的进程逃离容器沙箱,窃取主机的敏感信息或利用主机攻击其他机器。以下小节介绍了安全性考虑因素,展示容器运行时如何限制这些行为。

安全性考虑

从安全角度来看,有一个硬性事实需要分享:如果一个进程正在容器内运行,这并不意味着它比其他进程更安全。

恶意攻击者仍然能够通过主机的文件系统和内存资源进行攻击。为了实现更好的安全隔离,可以使用以下附加功能:

  • 强制访问控制SELinuxAppArmor 可用于加强容器与宿主机之间的隔离。这些子系统及其相关命令行工具使用基于策略的方法,在文件系统和网络访问方面更好地隔离正在运行的进程。

  • 0),它会根据进程凭证(其有效的 UID)进行权限检查。这些权限或特权称为能力,可以独立启用,赋予一个无特权进程有限的特权权限以访问特定资源。在运行容器时,我们可以添加或删除能力。

  • 安全计算模式Seccomp):这是一个原生内核特性,可用于限制进程从用户空间到内核空间所能发出的系统调用。通过识别进程运行所需的严格必要权限,管理员可以应用 seccomp 配置文件来限制攻击面。

手动应用前述安全特性并不总是容易和直接的,因为其中一些需要一定的学习曲线。自动化并简化(可能是声明式方式)这些安全约束的工具具有很高的价值。

本书稍后将更详细地讨论安全主题。

容器引擎和运行时

尽管从学习角度来看,手动运行和保护容器是可行且特别有用的,但这种方法不可靠且复杂。它在生产环境中难以重现和自动化,并且很容易导致不同主机之间的配置漂移。

这就是容器引擎和运行时诞生的原因——它们帮助自动化创建容器以及完成所有与容器运行相关的任务。

这两个概念是完全不同的,往往容易混淆,因此需要澄清:

  • 容器引擎 是一个软件工具,接受并处理来自用户的请求,创建一个包含所有必要参数和参数的容器。它可以被视为一种协调器,因为它负责执行所有必要的操作,使容器能够启动并运行;然而,它并不是容器的实际执行者(容器运行时的角色)。

引擎通常解决以下问题:

  • 提供命令行和/或 REST 接口供用户交互

  • 拉取并提取容器镜像(本书稍后会讨论)

  • 管理容器挂载点和绑定挂载提取的镜像

  • 处理容器元数据

  • 与容器运行时交互

我们已经指出,当实例化一个新容器时,会在镜像上方创建一个薄的读写层;这个任务由容器引擎完成,它负责向容器运行时呈现合并目录的工作堆栈。

容器生态系统提供了多种容器引擎选择。Docker 毫无疑问是最著名的(尽管不是第一个)引擎实现,还有 Podman(本书的核心主题)、CRI-OrktLXD 等。

容器运行时 是由容器引擎用于在主机上运行容器的低级软件。容器运行时提供以下功能:

在目标挂载点(通常由容器引擎提供)启动容器化进程,并设置一组自定义元数据

管理 cgroups 的资源分配

管理强制访问控制策略(SELinux 和 AppArmor)和能力

如今有许多容器运行时,它们中的大多数都实现了 OCI 运行时规范 参考(github.com/opencontainers/runtime-spec)。这是一个行业标准,定义了运行时应该如何行为以及它应实现的接口。

最常见的 OCI 运行时是 runc,它被大多数著名引擎使用,还有其他实现,如 crunkata-containersrailcarrktgVisor

这种模块化方法使得容器引擎可以根据需要更换容器运行时。例如,当 Fedora 33 发布时,它引入了一个新的默认 cgroups 层级,称为 cgroups V2。最初,runc 不支持 cgroups V2,而 Podman 只需将 runc 替换为另一个已兼容新层级的 OCI 兼容容器运行时(crun)。现在,runc 终于支持 cgroups V2,Podman 将能够安全地再次使用它,且对最终用户没有任何影响。

在介绍了容器运行时和引擎之后,是时候回答容器入门中最具争议和最常被问到的问题——容器和虚拟机之间的区别。

容器与虚拟机

到目前为止,我们讨论了通过原生操作系统特性和容器引擎与运行时增强实现的隔离。许多用户可能会被误导,认为容器是一种虚拟化形式。

这完全是误解;容器并不是虚拟机。

那么,容器和虚拟机之间的主要区别是什么呢?在回答之前,我们可以查看以下图示:

图 1.4 – 容器中的系统调用与内核的交互

图 1.4 – 容器中的系统调用与内核的交互

尽管容器是隔离的,但它持有一个直接与主机内核通过系统调用交互的进程。该进程可能不清楚主机的命名空间,但它仍然需要切换到内核空间进行 I/O 访问等操作。

另一方面,虚拟机总是运行在虚拟化管理程序(hypervisor)上,运行一个具有独立文件系统、网络、存储(通常是镜像文件)和内核的来宾操作系统。虚拟化管理程序是提供硬件抽象和虚拟化的软体,它使得在具备能力的硬件上运行的单一裸机可以实例化多个虚拟机。来宾操作系统内核看到的硬件大多是虚拟化的硬件,尽管有一些例外:

图 1.5 – 架构 – 虚拟化与容器

图 1.5 – 架构 – 虚拟化与容器

这意味着当一个进程在虚拟机内执行系统调用时,它总是会被指向来宾操作系统内核。

总结一下,我们可以肯定地说,容器与主机共享相同的内核,而虚拟机则拥有独立的来宾操作系统内核。

这一说法暗示了许多考量因素。

从安全角度来看,虚拟机提供了更好的隔离,以防止潜在的攻击。尽管如此,一些最新的基于 CPU 的攻击(例如 Spectre 或 Meltdown 等)可能会利用 CPU 漏洞来访问虚拟机的地址空间。

容器优化了隔离功能,并可以配置严格的安全策略(例如 CIS Docker、NIST、HIPAA 等),使其非常难以被利用。

从可扩展性角度来看,容器的启动速度比虚拟机快。如果镜像已经存在于主机中,启动一个新的容器实例只需几毫秒。这些快速的结果也是由于容器没有内核的特性实现的。而虚拟机必须启动内核和 initramfs,切换到根文件系统,运行某种 init(例如 systemd),并启动一定数量的服务。

虚拟机通常会消耗比容器更多的资源。要启动一个来宾操作系统,通常需要分配比启动容器更多的 RAM、CPU 和存储资源。

虚拟机与容器之间的另一个重要区别在于对工作负载的关注。容器的最佳实践是为每个特定的工作负载启动一个容器。另一方面,虚拟机可以同时运行多个工作负载。

想象一个 LAMP 或 WordPress 架构:在非生产环境或小规模生产环境中,将所有组件(Apache、PHP、MySQL 和 WordPress)安装在同一个虚拟机上并不罕见。这个设计可以拆分为一个多容器(或多层)架构,其中一个容器运行前端(Apache-PHP-WordPress),另一个容器运行 MySQL 数据库。运行 MySQL 的容器可以访问存储卷来持久化数据库文件。同时,扩展前端容器的规模也会更加容易。

现在我们已经理解了容器是如何工作的,以及它们与虚拟机的区别,我们可以进入下一个重要问题:我为什么需要一个容器?

我为什么需要一个容器?

本节描述了容器在现代 IT 系统中的好处和价值,以及容器如何为技术和业务带来好处。

上述问题可以重新表述为:在生产环境中采用容器的价值是什么?

IT 已经成为一个快速变化的市场驱动环境,其中变化由商业需求和技术提升所决定。在采用新兴技术时,企业总是关注其投资回报率ROI),同时力求将总拥有成本TCO)控制在合理的范围内。这并不总是容易实现的。

本节将尝试揭示最重要的几点。

开源

支撑容器技术的技术是开源的,并已成为许多厂商或社区广泛采用的开放标准。如今,大公司、厂商和云服务提供商都采用了开源软件,开源软件具有许多优点,并为企业提供了巨大的价值。开源软件通常与高价值和创新解决方案相关联——这就是事实!

首先,社区驱动的项目通常具有很大的演进动力,这有助于代码的成熟并不断带来新功能。开源软件对公众开放,且可以被检查和分析。这是一个极好的透明性特性,且对软件的可靠性产生影响,无论是在稳健性还是安全性方面。

其中一个关键方面是,它推动了一个进化模式,即只有最优秀的软件才会被采用、贡献和支持;容器技术是这一行为的完美例证。

可移植性

我们已经指出,容器是一种技术,它使用户能够将应用程序及其整个运行时环境打包并隔离开来,这意味着运行所需的所有文件。这个特性解锁了一个关键的好处——可移植性。

这意味着容器镜像可以在任何运行容器引擎的主机上拉取并执行,而不管其底层操作系统的分发版本。无论是从运行容器引擎的 Fedora 还是 Debian Linux 分发版中,CentOS 或 nginx 镜像都可以被无差别地拉取并以相同的配置执行。

再次,如果我们拥有许多相同的主机集群,我们可以选择将应用实例调度到其中一台主机上(例如,使用负载指标来选择最合适的主机),并且在运行容器时,可以确保得到相同的结果。

容器的可移植性还减少了厂商锁定,并提高了平台之间的互操作性。

DevOps 促进者

正如之前所述,容器帮助解决了开发团队和运维团队之间老旧的在我的机器上能运行模式,特别是在将应用部署到生产环境时。

作为应用程序的智能且简易打包解决方案,容器满足开发人员创建自包含的包的需求,其中包括运行工作负载所需的所有必要二进制文件和配置。作为一种自包含的方式来隔离进程并保证命名空间和资源使用的分离,容器受到运维团队的青睐,因为他们不再需要维护复杂的依赖关系约束或将每个应用程序单独隔离在虚拟机中。

从这个角度来看,容器可以被视为 DevOps 最佳实践的促进者,开发人员和运维人员可以更加紧密地合作,部署和管理应用程序,而不再有严格的隔离。

想要构建自己容器镜像的开发人员应当更加关注镜像中构建的操作系统层,并与运维团队密切合作,定义构建模板和自动化流程。

云就绪性

容器是为云环境构建的,设计时考虑了不可变的方式。不可变模式明确指出,基础设施(无论是单个容器还是复杂的集群)中的变更必须通过重新部署一个修改过的版本来应用,而不是通过修补当前版本。这有助于提高系统的可预测性和可靠性。

当必须推出新的应用版本时,它会被构建为一个新的镜像,并部署一个新的容器来替代旧版本。可以实现构建流水线来管理复杂的工作流程,从应用构建和镜像创建,到镜像注册表推送和标签,再到目标主机的部署。这种方式大大缩短了资源配置时间,同时减少了不一致性。

本书后续将看到,像 Kubernetes 这样的专用容器编排解决方案也提供了自动化调度大量主机的模式,使得容器化的工作负载可以轻松地部署、监控和扩展。

基础设施优化

与虚拟机相比,容器具有更轻量的占用,极大地提高了计算和内存资源的效率。通过提供简化工作负载执行的方式,容器的应用可以带来显著的成本节省。

通过减少应用的计算成本来实现 IT 资源优化;如果运行在虚拟机上的应用服务器可以被容器化,并与其他容器一起在主机上运行(具有专用资源限制和请求),计算资源可以节省并重复使用。

整个基础设施可以根据这一新范式重新调整;一个之前配置为虚拟化管理程序的裸金属机器可以重新分配为容器编排系统的工作节点,该节点简单地运行更细粒度的容器化应用。

微服务

微服务架构将应用程序拆分为多个执行精细功能的服务,这些服务是应用程序整体的一部分。

传统应用程序采用单体架构,其中所有功能都属于同一个实例。微服务的目的是将单体架构拆分成多个独立交互的小模块。

单体应用程序很适合容器,但微服务应用程序与容器的匹配更加理想。

为每个微服务创建一个容器有助于实现一些重要的好处,例如:

  • 微服务的独立可扩展性

  • 开发团队云访问程序的责任更加明确

  • 不同微服务可能采用不同技术栈的潜在可能

  • 对安全性方面(例如暴露的公共服务、mTLS 连接等)的更多控制

当面对复杂且庞大的架构时,协调微服务可能是一项艰巨的任务。采用如Kubernetes的编排平台、如IstioLinkerd的服务网格解决方案,以及如JaegerKiali的追踪工具,对于控制复杂性至关重要。

容器技术源自哪里?容器技术在计算机行业并不是一个新话题,正如我们将在接下来的段落中看到的,它深深植根于操作系统的历史中,甚至可能比我们还要古老!

本节将回顾容器在操作系统历史中的重要里程碑,从 Unix 到 GNU/Linux 系统。回顾过去有助于理解底层思想是如何随着时间演变的。

Chroot 和 Unix v7

如果我们想为容器历史中的旅行时间创建一个事件时间线,那么第一个也是最早的时间点是 1979 年——Unix V7 诞生的年份。那时,在 1979 年,Unix 内核引入了一个重要的系统调用——chroot 系统调用。

重要说明

系统调用(或 syscall)是应用程序用来向操作系统内核请求某些操作的一种方法。

这个系统调用允许应用程序更改其运行副本及其子进程的根目录,从而移除运行软件逃脱该监狱的能力。此功能使您可以禁止运行中的应用程序访问给定子树之外的任何文件或目录,这对于当时而言是一次真正的技术变革。

数年之后,在 1982 年,这一系统调用也在 BSD 系统中引入。

不幸的是,这一特性最初并未考虑到安全性,多年来,操作系统文档和安全文献强烈不推荐将chroot监狱作为实现隔离的安全机制。

Chroot 只是朝着在 *nix 系统中实现完全进程隔离迈出的第一个里程碑。从历史角度来看,下一个里程碑是 FreeBSD 监狱的引入。

FreeBSD 监狱

在我们历史之旅中迈出一些步伐,我们回到(或者前进,取决于我们从哪里看)2000 年,当时 FreeBSD 操作系统批准并发布了一个新概念,扩展了旧而有效的 chroot 系统调用——FreeBSD 监狱。

重要提示

FreeBSD 是一个自由开源的类似 Unix 的操作系统,首次发布于 1993 年,源自伯克利软件分发(BSD),最初基于研究 Unix。

正如我们之前简要提到的,chroot 在 80 年代是一个很棒的功能,但它创建的监狱很容易被逃逸,并且有很多限制,因此不适用于复杂的场景。为此,FreeBSD 监狱是在 chroot 系统调用的基础上构建的,目的是扩展和增加其功能集。

在标准的 chroot 环境中,运行进程的限制和隔离仅在文件系统层面;其他所有内容,例如运行的进程、系统资源、网络子系统和系统用户,都被 chroot 内的进程与主机系统的进程共享。

看 FreeBSD 监狱,它的主要特性是虚拟化网络子系统、系统用户和其进程;正如你可以想象的,这大大提高了该解决方案的灵活性和整体安全性。

让我们概括 FreeBSD 监狱的四个关键特性:

  • 一个目录子树:这就是我们在 chroot 监狱中也看过的内容。基本上,一旦被定义为子树,运行的进程就被限制在该范围内,并且无法逃逸出去。

  • 一个 IP 地址:这是一次伟大的革命;终于,我们可以为我们的监狱定义一个独立的 IP 地址,并让我们的运行进程即使与主机系统隔离也能正常工作。

  • 一个主机名:在监狱内使用,这当然不同于主机系统的主机名。

  • 一个命令:这是正在运行的可执行文件,并且有一个选项可以在系统监狱内运行。该可执行文件具有相对路径,并且是监狱内自包含的。

这种监狱的一个优点是,每个实例都有自己的用户和根账户,这些账户在其他监狱或底层主机系统上没有任何权限或权限。

FreeBSD 监狱的另一个有趣特性是我们有两种方式来安装/创建监狱:

  • 从二进制反射到我们可能会与底层操作系统一起安装的内容

  • 从源代码开始,构建最终应用所需的内容

Solaris 容器(也称为 Solaris 区域)

回到我们的时光机,我们必须只跳跃几年,确切地说是到 2004 年,才能最终遇到我们能识别的第一个术语——Solaris 容器。

重要提示

Solaris 是一个专有的 Unix 操作系统,源自 1993 年的 SunOS,最初由 Sun Microsystems 开发。

说实话,Solaris 容器只不过是 Solaris 区域的过渡名称,Solaris 区域是 Solaris 操作系统内置的一种虚拟化技术,同时也借助了一个特殊的文件系统 ZFS,该文件系统允许存储快照和克隆。

区域是从底层操作系统构建的虚拟化应用环境,它允许基础主机系统与在其他区域内运行的任何应用程序之间进行完全隔离。

Solaris Zones 引入的酷炫功能是品牌化区域的概念。品牌化区域与底层操作系统相比是完全不同的环境,可以容纳不同的二进制文件、工具包,甚至是一个不同的操作系统!

最后,为了确保隔离,Solaris 区域可以拥有自己的网络、用户,甚至自己的时区。

Linux 容器(LXC)

让我们再向前跳四年,认识一下Linux 容器LXC)。我们来到了 2008 年,那时 Linux 发布了首个完整的容器管理解决方案。

LXC 不能仅仅被简化为 Linux 容器的最早容器实现之一的管理工具,因为它的作者开发了许多现在也被用于其他 Linux 容器运行时的内核功能。

LXC 有自己的低级容器运行时,它的作者旨在提供一个尽可能接近虚拟机的隔离环境,但无需模拟硬件并运行全新的内核实例所需的开销。LXC 通过以下内核功能实现了这一目标和隔离性:

  • 命名空间

  • 强制访问控制

  • 控制组(也叫 cgroups)

让我们回顾一下本章之前看到的内核功能。

Linux 命名空间

命名空间将进程隔离开来,抽象出一个全局系统资源。如果一个进程在命名空间内对系统资源进行更改,这些更改仅对同一命名空间内的其他进程可见。命名空间功能的常见应用是实现容器。

强制访问控制

在 Linux 生态系统中,有多种 MAC 实现可供选择;最著名的项目是安全增强 LinuxSELinux),由美国的国家安全局NSA)开发。

重要说明

SELinux 是一种在 Linux 操作系统中使用的强制访问控制架构实现。它通过标签机制提供基于角色的访问控制和多级安全性。每个文件、设备和目录都有一个关联的标签(通常称为安全上下文),它扩展了常见文件系统的属性。

控制组

控制组cgroups)是 Linux 内核的内建功能,可以帮助以层级方式组织各种资源类型,包括进程。这些资源随后可以被限制和监控。与 cgroups 交互的常用接口是一个名为cgroupfs的伪文件系统。这个内核功能对于跟踪和限制进程的资源(如内存、CPU 等)非常有用。

来自这三种内核功能的 LXC 最主要和最强大的特性无疑是非特权容器

由于命名空间、MAC 和 cgroups,实际上,LXC 可以隔离一定数量的 UID 和 GID,并将它们映射到底层操作系统。这确保了容器中的 UID 0(实际上)会映射到主机系统上的更高 UID。

根据我们希望为容器分配的权限和功能集,我们可以从大量预构建的命名空间类型中选择,例如以下几种:

  • 网络:提供对网络设备、栈、端口等的访问

  • 挂载:提供对挂载点的访问

  • PID:提供对 PID 的访问

LXC 的下一次主要演进(毫无疑问,也是触发容器采用成功的关键因素)无疑就是 Docker。

Docker

仅仅五年后,在 2013 年,Docker 在容器领域崭露头角,并迅速变得非常流行。那么,那时使用了哪些特性呢?我们很容易发现,最早的 Docker 容器引擎之一就是 LXC!

经过一年的开发,Docker 团队推出了 libcontainer,并最终用自己的实现替换了 LXC 容器引擎。与其前身 LXC 相似,Docker 需要在基础主机系统上运行一个守护进程,以保持容器的正常运行。

最显著的特点之一(除了命名空间、MAC 和 cgroups 的使用)无疑是 OverlayFS,一种叠加文件系统,可以帮助将多个文件系统组合成一个单一的文件系统。

重要提示

OverlayFS 是一种 Linux 联合文件系统。它可以将多个挂载点合并为一个,创建一个包含所有底层文件和子目录的单一目录结构。

在高层次上,Docker 团队引入了容器镜像和容器注册表的概念,这实际上是功能上的重大变革。注册表和镜像的概念使得创建一个完整的生态系统成为可能,开发者、系统管理员或技术爱好者都可以在这个生态系统中合作,并贡献自己的自定义容器镜像。他们还创建了一种特殊的文件格式(Dockerfile),用于创建全新的容器镜像,从而轻松自动化从零开始构建容器镜像所需的步骤。

除了 Docker,还有另一个引擎/运行时项目引起了社区的兴趣——rkt。

rkt

在 Docker 出现的几年后,即 2014 和 2015 年,CoreOS 公司(后来被 Red Hat 收购)推出了自己的容器引擎实现,具有一个非常特别的主要特性——它是 无守护进程的。

这一选择产生了重要影响:与其让一个中央守护进程管理一堆容器,不如让每个容器都独立存在,就像我们在基础主机系统上启动的任何其他标准进程一样。

但是,rkt(发音为rocket)项目在 2017 年变得非常受欢迎,当时年轻的云原生计算基金会CNCF)决定将该项目纳入其支持范围,CNCF 的目标是帮助和协调与容器及云相关的项目,同时还将另一个由 Docker 本身捐赠的项目——containerd,也纳入了其支持。

简而言之,Docker 团队从其守护进程中提取了项目的核心运行时,并将其捐赠给了 CNCF,这是一个重要的步骤,激励并推动了围绕容器主题的强大社区的发展,也有助于开发和改进新兴的容器编排工具,如 Kubernetes。

重要提示

Kubernetes(来自希腊词κυβερνήτης,意为“舵手”),简称 K8s,是一个开源容器编排系统,用于简化多主机环境中的应用部署和管理。它由 Google 发布为开源项目,但现在由 CNCF 维护。

即使本书的主要主题是 Podman,我们也不能不提及现在以及接下来的章节中,容器化多机环境下的复杂项目编排需求的不断增长;正是这个场景中,Kubernetes 崛起为生态系统的领导者。

在 Red Hat 收购 CoreOS 后,rkt 项目被停止,但它的遗产并未丧失,反而影响了 Podman 项目的发展。在介绍本书的主要主题之前,让我们深入了解 OCI 规范。

OCI 和 CRI-O

如前所述,从 Docker 中提取 containerd 并将其捐赠给 CNCF,激励了开源社区开始认真研发可以在如 Kubernetes 这样的编排层下注入的容器引擎。

同时,在 2015 年,Docker 在许多其他公司(如 Red Hat、AWS、Google、Microsoft、IBM 等)的帮助下,在 Linux 基金会的支持下,启动了治理委员会,即开放容器倡议OCI)。

在这个倡议下,工作团队制定了运行时规范(runtime spec)和镜像规范(image spec),用于描述未来如何创建新容器引擎的 API 和架构。

同年,OCI 团队还发布了第一个符合 OCI 规范的容器运行时实现;该项目被命名为runc

OCI 不仅定义了运行独立容器的规范,还为更轻松地将 Kubernetes 层与底层容器引擎链接提供了基础。同时,Kubernetes 社区发布了容器运行时接口CRI),这是一种插件接口,旨在支持各种容器运行时的采用。

这就是 CR-I O 进入 2017 年的原因;它由 Red Hat 作为开源项目发布,是 Kubernetes 容器运行时接口(CRI)的首批实现之一,支持使用兼容 OCI 的运行时。CRI-O 提供了一个轻量级的替代方案,用于在 Kubernetes 中使用 Docker、rkt 或其他任何引擎作为运行时。

随着生态系统的不断增长,标准和规范的采用越来越广泛,推动了更广泛的容器生态系统发展。之前展示的 OCI 规范对 runc 容器运行时的开发至关重要,runc 被 Podman 项目采用。

Podman

我们终于来到了时间旅行的尽头;在上一段中,我们达到了 2017 年,同年,Podman 项目的第一次提交出现在 GitHub 上。

这个项目的名称揭示了它的目的——PODMAN = POD 管理器。我们现在准备好了解容器世界中pod的基本定义。

pod 是 Kubernetes 可管理的最小可部署计算单元;它可以由一个或多个容器组成。如果同一个 pod 中有多个容器,它们会在共享上下文中并排调度和运行。

Podman 管理容器及其镜像、存储卷以及由一个或多个容器组成的 pod,它是从零开始构建的,严格遵循 OCI 标准。

与其前身 rkt 一样,Podman 没有一个中央守护进程来管理容器,而是将它们作为标准的系统进程启动。它还定义了一个兼容 Docker 的 CLI 接口,以简化从 Docker 的过渡。

Podman 引入的一个重要特性是无根容器。通常,当我们想到 Linux 容器时,我们立刻想到的是一个系统管理员,需要在操作系统层面设置一些前提条件,以准备好环境,使我们的容器能够顺利启动。

无根容器可以轻松以普通用户身份运行,无需管理员权限。使用非特权用户运行 Podman 时,将启动没有任何特权的受限容器,就像运行它的用户一样。

毫无疑问,Podman 引入了更大的灵活性,它是一个高度活跃的项目,采用率不断增长。每次主要版本发布都会带来许多新特性;例如,3.0 版本引入了对 Docker Compose 的支持,这是一个备受期待的特性。这也是社区支持的一个良好健康指标。

让我们通过概述最常见的容器采用用例来结束这一章。

现在容器在哪里被使用?

这是一个开放性章节,目的是讲述容器在生产环境中如何以及在哪里被使用。该章节还介绍了容器编排的概念,尤其是 Kubernetes 这一全球最广泛使用的开源编排工具,它已被全球成千上万家公司采纳。容器的采用正在向每个行业的每个企业扩展。

但如果我们调查已经在使用容器或 Kubernetes 分发版的公司成功案例,我们会发现,容器化和容器编排正在加速项目的开发和交付,推动各行各业(从汽车到医疗保健)的新用例的创建。无论经济因素如何,这对计算机技术产生了重大影响。

公司正在将旧的虚拟机部署模型转向容器模型,以支持新应用程序。正如我们在前面简要介绍的那样,容器可以很容易地被视为一种新的应用程序打包方式。

回到虚拟机(VM),它们的主要目的是什么?就是为目标应用程序创建一个隔离的环境,并为其保留一定数量的资源。

随着容器的引入,企业公司意识到他们可以更好地优化基础设施,加速新服务的开发和部署,从而引入某种形式的创新。

回顾容器的采用历史及其使用情况,我们可以看到,最初它们作为传统单体应用程序运行时的打包方式,但随着云原生浪潮的兴起以及微服务等概念的流行,容器成为了下一代云原生应用程序打包的事实标准。

重要提示

云原生计算是一种软件开发实践,用于在公有云、私有云或混合云中构建和部署可扩展的应用程序。

另一方面,容器格式和编排工具受到了微服务开发和部署兴起的影响;这就是为什么今天我们在 Kubernetes 中发现了许多附加的服务和资源,例如服务网格和无服务器计算,这些在微服务架构中非常有用。

重要提示

微服务架构是一种基于松耦合、细粒度服务创建应用程序的实践,使用轻量级协议。

根据我们与客户的日常工作经验,客户开始将标准应用程序打包到容器中,并使用容器编排工具(如 Kubernetes)进行编排。但当新的开发模型引入开发团队后,容器及其编排工具开始越来越多地管理这种新型服务:

图 1.6 – 真实应用中的微服务架构

图 1.6 – 真实应用中的微服务架构

为了更好地理解微服务架构这一话题,考虑前面的图片,其中展示了一个使用微服务构建的简单网上商店应用。

如我们所见,根据我们使用的客户端类型(手机或网页浏览器),我们将能够与三个底层服务进行交互,这些服务都是解耦的,通过 REST API 进行通信。另一个重要的新特点是数据层的解耦;每个微服务都有自己的数据库和数据结构,使它们在开发和部署的每个阶段都相互独立。

现在,如果我们为架构中显示的每个微服务匹配一个容器,并且添加一个调度器,如 Kubernetes,我们会发现解决方案几乎完成了!得益于容器技术,每个服务可以拥有自己的容器基础镜像,只包含所需的运行时,这确保了一个轻量级的预构建包,其中包含服务启动后所需的所有资源。

另一方面,考虑到应用程序开发及其维护中的各种自动化流程,基于容器的架构也可以轻松集成到CI/CD工具中,从而自动化开发、测试和运行应用程序所需的所有步骤。

重要提示

CI/CD代表持续集成和持续交付/部署。这些实践试图弥合开发和运维活动之间的差距,通过增加构建、测试和部署应用程序过程中的自动化来提高效率。

我们可以说,容器技术是为了满足系统管理员的需求而诞生的,但最终却成为了开发人员的宠儿!这项技术在许多公司中代表了开发团队和运维团队之间的连接纽带,推动并加速了 DevOps 实践的采用,而 DevOps 之前是孤立的,旨在促进这两个团队之间的协作。

重要提示

DevOps 是一组帮助连接软件开发(Dev)和 IT 运维(Ops)的实践。DevOps 的目标是缩短应用程序的开发生命周期,并提高应用程序的交付发布速度。

尽管微服务和容器喜欢一起工作,但企业公司有许多应用程序、软件和解决方案并不是基于微服务架构,而是采用了以前的单体方法,例如使用集群应用服务器!但是我们不必太担心,因为容器及其调度器在同一时间也进化了,能够支持这种工作负载。

容器技术可以被视为一种进化的应用程序打包格式,可以优化用于容纳所有必要的库和工具,甚至是复杂的单体应用程序。多年来,基础容器镜像已经进化,以优化大小和内容,从而创建更小的运行时,能够改善整体管理,即使是复杂的单体应用程序。

如果我们查看 Red Hat Enterprise Linux 容器基础镜像的最小化版本,可以看到该镜像在下载时约为 30MB,提取后(当然是通过 Podman)在目标基础系统中只有 84MB。

即使是编排工具也采用了内部功能和资源来处理单体应用程序,这与云原生概念相去甚远。例如,Kubernetes 在平台核心中引入了一些功能,用于确保容器的状态持久性,以及持久存储的概念,用于保存本地缓存数据或应用程序的重要信息。

总结

在本章中,我们探讨了容器技术的基本功能,从进程隔离到容器运行时。接着,我们看了容器相较于虚拟机的主要用途和优势。然后,我们启动了我们的时光机,从 1979 年到今天回顾了容器的历史。最后,我们发现了当今的市场趋势以及企业公司对容器的采用情况。

本章介绍了容器技术及其历史。Podman 在可用性和 CLI 方面与 Docker 非常相似,下一章将从架构角度和用户体验角度讨论这两个项目的差异。

在介绍了 Docker 的高层架构后,本章将详细描述 Podman 的无守护进程架构,以帮助理解这一容器引擎如何在不需要运行守护进程的情况下管理容器。

进一步阅读

欲了解更多本章涉及的内容,请参考以下资料:

第二章:比较 Podman 与 Docker

正如我们在前一章中所了解到的,容器技术并不像我们想象的那样新,因此其实现和架构多年来一直在不断发展,最终形成了如今的状态。

在本章中,我们将回顾 Docker 和 Podman 容器引擎的历史和主要架构,并通过并排对比,帮助具备一定 Docker 经验的读者轻松理解两者之间的主要区别,然后再深入探索 Podman。

如果你对 Docker 没有太多经验,你可以轻松跳到下一章,并在准备好了解 Podman 与 Docker 容器引擎之间的区别时再回来阅读本章。

在本章中,我们将涵盖以下主要主题:

  • Docker 容器守护进程架构

  • Podman 无守护进程架构

  • Docker 与 Podman 之间的主要区别

技术要求

本章不需要任何技术先决条件;你可以放心地阅读它,而无需担心在工作站上安装或设置任何软件!

如果你想复制本章中将描述的一些示例,你需要在工作站上安装和配置 Podman 和 Docker。如前所述,你可以轻松跳到下一章,并在准备好学习 Podman 和 Docker 容器引擎之间的区别时再回来阅读本章。

请注意,在下一章中,你将接触到 Podman 的安装和配置,因此你很快就能复制本章和接下来章节中所见的任何示例。

Docker 容器守护进程架构

容器是一个简单且智能的解决方案,用于运行隔离的进程实例。我们可以安全地断言,容器是一种在多个层面上工作的应用隔离方式,比如文件系统、网络、资源使用、进程等等。

正如我们在第一章《容器技术介绍》中所看到的,在容器与虚拟机部分,容器与虚拟机的不同之处在于,容器与宿主共享相同的内核,而虚拟机则拥有自己的客操作系统内核。从安全角度来看,虚拟机提供了更好的攻击隔离,但虚拟机通常会比容器消耗更多的资源。要启动一个客操作系统,通常需要分配比启动容器更多的内存、CPU 和存储资源。

2013 年,Docker 容器引擎出现在容器领域,并迅速变得非常流行。

如我们之前所解释的,容器引擎是一种接受并处理用户请求以创建容器的软件工具;它可以看作是一种协调器。另一方面,容器运行时是容器引擎用来在主机上运行容器的底层软件,负责管理隔离、存储、网络等功能。

在早期阶段,Docker 容器引擎使用 LXC 作为容器运行时,但之后不久便用他们自己实现的 libcontainer 替代了它。

Docker 容器引擎由三个基本支柱组成:

  • Docker 守护进程

  • Docker REST API

  • Docker CLI

这三个支柱在以下架构中有所表示:

图 2.1 – Docker 架构

图 2.1 – Docker 架构

一旦 Docker 守护进程运行,如前图所示,你可以通过 Docker 客户端或远程 API 与其交互。Docker 守护进程负责许多本地容器活动,并与外部镜像仓库交互以拉取或推送容器镜像。

Docker 守护进程是架构中最关键的部分,它应该始终处于运行状态,否则你心爱的容器将无法生存太久!让我们在下一节中查看它的详细信息。

Docker 守护进程

守护进程是一个在后台运行的进程;它监督系统或为其他进程提供功能。

Docker 守护进程是负责以下工作的后台进程:

  • 监听 Docker API 请求

  • 处理、管理并检查正在运行的容器

  • 管理 Docker 镜像、网络和存储卷

  • 与外部/远程容器镜像仓库交互

所有这些操作应该通过客户端或调用其 API 来指示守护进程,但让我们看看如何与之进行通信。

与 Docker 守护进程交互

可以通过进程的套接字联系到 Docker 守护进程,通常可以在主机文件系统中找到:/var/run/docker.sock

根据你选择的 Linux 发行版,你可能需要为非 root 用户设置正确的权限,才能与 Docker 守护进程交互,或者仅需将非特权用户添加到 docker 组。

如你在以下命令中看到的,这些是为 Docker 守护进程在 Fedora 34 操作系统中设置的权限:

[root@fedora34 ~]# ls -la /var/run/docker.sock 
srw-rw----. 1 root docker 0 Aug 25 12:48 /var/run/docker.sock

默认情况下,Docker 守护进程没有其他类型的安全性或身份验证,因此请小心不要将守护进程公开暴露到不受信任的网络中。

Docker REST API

一旦 Docker 守护进程启动并运行,你可以通过客户端或直接通过 REST API 与其通信。通过 Docker API,你可以执行通过命令行工具进行的各种操作,例如以下操作:

  • 列出容器

  • 创建容器

  • 检查容器

  • 获取容器日志

  • 导出容器

  • 启动或停止容器

  • 强制停止容器

  • 重命名容器

  • 暂停容器

列表还在继续。通过查看这些 API 的其中一个,我们可以轻松发现它们的工作原理以及守护进程返回的示例输出是什么。

在以下命令中,我们将使用 Linux 命令行工具 curl 发出 HTTP 请求,以获取有关已存储在守护进程本地缓存中的任何容器镜像的详细信息:

[root@fedora34 ~]# curl --unix-socket /var/run/docker.sock \ http://localhost/v1.41/images/json | jq 
[
  {
    “Containers”: -1,
    “Created”: 1626187836,
    “Id”: “sha256:be72532cbd81ba4adcef7d8f742abe7632e6f5b35 bbd53251e5751a88813dd5f”,
    “Labels”: {
      “architecture”: “x86_64”,
      “build-date”: “2021-07-13T14:50:13.836919”,
      “com.redhat.build-host”: “cpt-1005.osbs.prod.upshift.rdu2.redhat.com”,
      “com.redhat.component”: “ubi7-minimal-container”,
      “com.redhat.license_terms”: “https://www.redhat.com/en/about/red-hat-end-user-license-agreements#UBI”,
      “description”: “The Universal Base Image Minimal is a stripped down image that uses microdnf as a package manager. This base image is freely redistributable, but Red Hat only supports Red Hat technologies through subscriptions for Red Hat products. This image is maintained by Red Hat and updated regularly.”,
      “distribution-scope”: “public”,
      “io.k8s.description”: “The Universal Base Image Minimal is a stripped down image that uses microdnf as a package manager. This base image is freely redistributable, but Red Hat only supports Red Hat technologies through subscriptions for Red Hat products. This image is maintained by Red Hat and updated regularly.”,
      “io.k8s.display-name”: “Red Hat Universal Base Image 7 Minimal”,
      “io.openshift.tags”: “minimal rhel7”,
      “maintainer”: “Red Hat, Inc.”,
      “name”: “ubi7-minimal”,
      “release”: “432”,
      “summary”: “Provides the latest release of the minimal Red Hat Universal Base Image 7.”,
      “url”: “https://access.redhat.com/containers/#/registry.access.redhat.com/ubi7-minimal/images/7.9-432”,
      “vcs-ref”: “8c60d5a9644707e7c4939980a221ec2927d9a88a”,
      “vcs-type”: “git”,
      “vendor”: “Red Hat, Inc.”,
      “version”: “7.9”
    },
    “ParentId”: “”,
    “RepoDigests”: [
      “registry.access.redhat.com/ubi7/ubi-minimal@sha256:73b4f78b569d178a48494496fe306dbefc3c0434c4b 872c7c9d7f23eb4feb909”
    ],
    “RepoTags”: [
      “registry.access.redhat.com/ubi7/ubi-minimal:latest”
    ],
    “SharedSize”: -1,
    “Size”: 81497870,
    “VirtualSize”: 81497870
  }
] 

如前面的命令所示,输出是 JSON 格式,非常详细,包含多个元数据,从容器镜像名称到其大小。在这个例子中,我们预先拉取了 RHEL Universal Base Image 版本 7 的最小版本,仅 80 MB!

当然,API 并不是为人类消费或交互而设计的;它们非常适合机器对机器的交互,因此它们通常用于软件集成。基于此,让我们探索一下命令行客户端的工作原理以及可用的选项。

Docker 客户端命令

Docker 守护进程有自己的伴侣,它指示并配置守护进程——一个命令行客户端。

Docker 命令行客户端有超过 30 个命令及其相应选项,能够让任何系统管理员或 Docker 用户指示和控制守护进程及其容器。以下是最常用命令的概述:

  • build: 从 Dockerfile 构建镜像

  • cp: 在容器和本地文件系统之间复制文件/文件夹

  • exec: 在运行中的容器中执行命令

  • images: 列出镜像

  • inspect: 返回关于 Docker 对象的低级信息

  • kill: 终止一个或多个正在运行的容器

  • load: 从 TAR 存档或标准输入加载镜像

  • login: 登录到 Docker 注册表

  • logs: 获取容器日志

  • ps: 列出正在运行的容器

  • pull: 从注册表拉取镜像或仓库

  • push: 将镜像或仓库推送到注册表

  • restart: 重启一个或多个容器

  • rm: 删除一个或多个容器

  • rmi: 删除一个或多个镜像

  • run: 在新容器中运行命令

  • save: 将一个或多个镜像保存到 TAR 存档中(默认通过 stdout 流式传输)

  • start: 启动一个或多个已停止的容器

  • stop: 停止一个或多个正在运行的容器

  • tag: 创建一个指向 SOURCE_IMAGETARGET_IMAGE 标签

列表还在继续。正如你从这个子集中看到的,管理容器镜像和运行中的容器有很多可用命令,甚至可以导出容器镜像或构建一个新的。

一旦你使用这些命令及其相应选项启动 Docker 客户端,客户端将联系 Docker 守护进程,并指示需要执行的操作。因此,守护进程是架构中的关键元素,必须保持运行,确保这一点之后再尝试使用 Docker 客户端或其任何 REST API。

Docker 镜像

Docker 镜像是一种由 Docker 引入的格式,用于管理二进制数据和元数据,作为容器创建的模板。Docker 镜像是用于运输和传输运行时、库以及运行某个特定进程所需的一切资源的封装。

正如我们在 第一章《容器技术简介》中提到的,在 容器来自哪里? 一节中,这种格式的创建确实是一个游戏规则的改变,与过去出现的其他容器技术显著不同。

从 1.12 版本开始,Docker 开始采用一种镜像规范,这种规范随着时间的推移,已经发展为符合OCI 镜像格式规范的当前版本。

第一个 Docker 镜像规范包含了许多现在已成为 OCI 镜像格式规范一部分的概念和字段,例如以下内容:

  • 层的列表

  • 创建日期

  • 操作系统

  • CPU 架构

  • 用于容器运行时的配置参数

Docker 镜像的内容(包括二进制文件、库、文件系统数据)是以层的形式组织的。每一层只是文件系统的更改集,不包含任何环境变量或特定命令的默认参数。这些数据存储在拥有配置参数的镜像清单中。

那么,这些层是如何在 Docker 镜像中创建并聚合的呢?答案并不简单。容器镜像中的层是通过使用镜像元数据组合在一起,并合并为单一的文件系统视图。这一结果可以通过多种方式实现,但正如前一章节所预测的那样,当前最常见的做法是使用联合文件系统——结合两个文件系统并提供一个独特的、压缩的视图。最后,当容器被执行时,会在镜像之上创建一个新的、可读写的临时层,该层在容器销毁后会丢失。

正如我们在本章早些时候所说,容器镜像及其分发是 Docker 容器的杀手级特性。因此,在下一节中,让我们来看看容器分发的关键元素——Docker 注册表

Docker 注册表

Docker 注册表只是一个 Docker 容器镜像的存储库,它保存容器镜像的元数据和层,以便将这些镜像提供给多个 Docker 守护进程使用。

Docker 守护进程通过 HTTP API 作为客户端与 Docker 注册表进行交互,根据 Docker 客户端的指令推送和拉取容器镜像。

使用容器注册表确实可以帮助在许多独立的机器上使用容器,这些机器可以被配置为在 Docker 守护进程的本地缓存中没有容器镜像时,向注册表请求容器镜像。Docker 守护进程设置中预配置的默认注册表是Dockerhub,这是一个由 Docker 公司在云端托管的软件即服务容器注册表。然而,Dockerhub 并不是唯一的注册表,近年来,许多其他容器注册表也相继出现。

几乎每个使用容器的公司或社区都创建了自己的容器注册表,并且它们有不同的 Web 界面。Dockerhub 的一个免费的替代服务是Quay.io,这是由 Red Hat 公司托管的一个软件即服务容器注册表。

一个很好的替代云服务的方案是本地 Docker 注册表,它可以通过在运行 Docker 守护进程的机器上通过一个命令创建容器来实现:

$ docker run -d -p 5000:5000 --restart=always --name registry registry:2

本书的目标不是详细讲解各种 Docker 选项和配置,但如果您想了解更多关于 Docker 注册表的信息,可以参考 Docker 官方文档:docs.docker.com/registry/deploying/

到目前为止,我们已经查看了很多内容,包括 Docker API、客户端、守护进程、镜像以及最终的注册表,但正如我们之前提到的,这一切都依赖于 Docker 守护进程的正确使用,守护进程应该始终健康并且正常运行。那么,现在让我们探索一下当它停止工作时会发生什么。

运行中的 Docker 架构是什么样的?

Docker 守护进程是整个 Docker 架构的核心关键元素。在这一部分,我们将探讨 Docker 守护进程和一堆运行中的容器的样子。

我们不会深入讨论安装和设置 Docker 守护进程所需的步骤;相反,我们将直接分析一个预配置的操作系统:

[root@fedora34 ~]# systemctl status docker
● docker.service - Docker Application Container Engine
     Loaded: loaded (/usr/lib/systemd/system/docker.service; disabled; vendor preset: disabled)
     Active: active (running) since Tue 2021-08-31 19:46:57 UTC; 1h 39min ago
TriggeredBy: ● docker.socket
       Docs: https://docs.docker.com
   Main PID: 20258 (dockerd)
      Tasks: 12
     Memory: 31.1M
        CPU: 1.946s
     CGroup: /system.slice/docker.service
             └─20258 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock

如您从前面的命令中看到的,我们刚刚验证了 Docker 守护进程正在运行,但它不是系统上唯一运行的容器服务。Docker 守护进程有一个我们在前面部分跳过的伙伴,为了保持描述的简洁性:Containerd

为了更好地理解工作流程,请查看下面的图表:

图 2.2 – 运行 Docker 容器

](https://github.com/OpenDocCN/freelearn-devops-pt4-zh/raw/master/docs/pdmn-dop/img/B17908_02_02.jpg)

图 2.2 – 运行 Docker 容器

Containerd 是一个将容器管理(包括与内核的交互)从 Docker 守护进程中解耦的项目,它还遵循 OCI 标准,并使用runc作为容器运行时。

那么,让我们检查一下在我们预配置的操作系统中 Containerd 的状态:

[root@fedora34 ~]# systemctl status containerd
● containerd.service - containerd container runtime
     Loaded: loaded (/usr/lib/systemd/system/containerd.service; disabled; vendor preset: disabled)
     Active: active (running) since Wed 2021-08-25 12:48:17 UTC; 6 days ago
       Docs: https://containerd.io
    Process: 4267 ExecStartPre=/sbin/modprobe overlay (code=exited, status=0/SUCCESS)
   Main PID: 4268 (containerd)
      Tasks: 43
     Memory: 44.1M
        CPU: 8min 36.291s
     CGroup: /system.slice/containerd.service
             ├─ 4268 /usr/bin/containerd
             ├─20711 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 3901d2600732ae1f2681cde0074f290c1839b1a4b0c63ac 9aaccdba4f646e06a -address /run/containerd/containe>
             ├─20864 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 78dc2eeb321433fc67cf910743c0c53e54d9f45cfee8d183 19d03a622dc56666 -address /run/containerd/containe>
             └─21015 /usr/bin/containerd-shim-runc-v2 -namespace moby -id 7433c0613412349833b927efa79a4f589916b12c942003cd 616d45ed7611fc31 -address /run/containerd/containe>

如您从前面的控制台输出中看到的,服务正在运行并已启动三个子进程:/usr/bin/containerd-shim-runc-v2。这与我们刚刚在图 2.2中看到的完全匹配!

现在,让我们检查一下与 Docker CLI 交互的运行中的容器:

[root@fedora34 ~]# docker ps
CONTAINER ID   IMAGE                            COMMAND                  CREATED          STATUS          PORTS                NAMES
7433c0613412   centos/httpd-24-centos7:latest   “container-entrypoin…”   26 minutes ago   Up 26 minutes   8080/tcp, 8443/tcp   funny_goodall
78dc2eeb3214   centos/httpd-24-centos7:latest   “container-entrypoin…”   26 minutes ago   Up 26 minutes   8080/tcp, 8443/tcp   wonderful_rubin
3901d2600732   centos/httpd-24-centos7:latest   “container-entrypoin…”   26 minutes ago   Up 26 minutes   8080/tcp, 8443/tcp   relaxed_heisenberg

如您所见,Docker 客户端确认我们的系统上有三个正在运行的容器,所有容器都是通过 runc 容器运行时启动的,由 Containerd 系统服务管理,并通过 Docker 守护进程配置。

现在我们已经介绍了这个新元素 Containerd,让我们在下一节深入了解它。

Containerd 架构

Containerd 架构由多个组件组成,这些组件被组织在子系统中。连接不同子系统的组件也被称为容器架构中的模块,如下图所示:

图 2.3 – Containerd 架构

图 2.3 – Containerd 架构

可用的两个主要子系统如下:

  • 从磁盘镜像中提取包的包服务

  • 执行包的运行时服务,创建运行时容器

使架构完全功能化的主要模块如下:

  • Executor 模块,执行前面架构中作为运行时块表示的容器运行时

  • Supervisor 模块,监控并报告容器状态,属于前面架构中的容器

  • Snapshot 模块,管理文件系统快照

  • Events 模块,收集和消耗事件

  • Metrics 模块,通过度量 API 导出多个指标

Containerd 将容器置于运行状态所需的步骤太复杂,无法在本节中详细描述,但我们可以将其总结如下:

  1. 通过分发控制器拉取元数据和内容。

  2. 使用Bundle 控制器解包获取的数据,创建将组成包的快照。

  3. 通过运行时控制器执行刚刚创建的容器:

图 2.4 – Containerd 数据流图

图 2.4 – Containerd 数据流图

在本节中,我们已经描述了 Docker 容器引擎的关键特性和设计原则,以及它以守护进程为中心的方式。现在我们可以继续分析 Podman 无守护进程架构。

Podman 无守护进程架构

Podman(POD MANager的缩写)是一个无守护进程的容器引擎,允许用户管理容器、镜像及其相关资源,如存储卷或网络资源。首次安装 Podman 的用户很快就会意识到,安装完成后没有需要启动的服务。运行容器时,Podman 不需要后台运行的守护进程!

安装后,Podman 二进制文件既充当命令行接口CLI),又充当一个容器引擎,协调容器运行时的执行。接下来的子节将详细介绍 Podman 的行为和构建块。

Podman 命令和 REST API

Podman CLI 提供了一个不断扩展的命令集。整理好的命令列表可在docs.podman.io/en/latest/Commands.html找到。

以下列表探讨了最常用命令的一个子集:

  • build: 从 Containerfile 或 Dockerfile 构建镜像

  • cp: 在容器和本地文件系统之间复制文件/文件夹

  • exec: 在正在运行的容器中运行命令

  • events: 显示 Podman 事件

  • generate: 生成结构化数据,如 Kubernetes YAML 或 systemd 单元

  • images: 列出本地缓存的镜像

  • inspect: 返回容器或镜像的低级信息

  • kill: 终止一个或多个正在运行的容器

  • load: 从容器 TAR 存档或标准输入加载镜像

  • login: 登录到容器镜像仓库

  • logs: 获取容器日志

  • pod: 管理 Pod

  • ps: 列出正在运行的容器

  • pull: 从镜像仓库拉取镜像或仓库

  • push: 将镜像或仓库推送到镜像仓库

  • restart: 重启一个或多个容器

  • rm: 删除一个或多个容器

  • rmi: 删除一个或多个镜像

  • run: 在新的容器中运行命令

  • save: 将一个或多个镜像保存到 TAR 存档(默认通过标准输出流)

  • start: 启动一个或多个已停止的容器

  • stop: 停止一个或多个正在运行的容器

  • system: 管理 Podman(磁盘使用、容器迁移、REST API 服务、存储管理和清理)

  • tag: 创建一个 TARGET_IMAGE 标签,指向 SOURCE_IMAGE

  • unshare: 在修改后的用户命名空间中运行命令

  • volume: 管理容器卷(列出、清理、创建、检查)

在本书的后续章节中,我们将更详细地介绍前述命令,并了解如何使用它们来管理整个容器生命周期。

已经使用 Docker 的用户会立即识别出与 Docker CLI 相同的命令。Podman CLI 命令与 Docker 命令兼容,有助于平滑过渡到这两个工具之间。

与 Docker 不同,Podman 不需要一个运行中的 Docker 守护进程来监听 Unix 套接字执行前述命令。用户仍然可以选择运行 Podman 服务,并使其监听 Unix 套接字以暴露本地 REST API。

通过运行以下命令,Podman 将在首选路径上创建一个套接字端点,并监听 API 调用:

$ podman system service -–time 0 unix://tmp/podman.sock

如果未提供,默认的套接字端点为 unix://run/podman/podman.sock(rootful 服务)和 unix://run/user/<UID>/podman/podman.sock(rootless 容器)。

结果,用户可以对套接字端点发出 REST API 调用。以下示例查询 Podman 以获取可用的本地镜像:

curl --unix-socket /tmp/podman.sock \ http://d/v3.0.0/libpod/images/json | jq .

Podman 项目在docs.podman.io/en/latest/_static/api.html提供符合 OpenAPI 的可用 REST API 调用文档。

前面示例中的管道jq命令有助于生成更易读的 JSON 格式输出。我们将在安装后定制部分的第三章运行第一个容器中,详细探讨 Podman REST API 和基于 systemd socket 的激活。接下来,我们将更详细地描述 Podman 的构建模块。

Podman 构建模块

Podman 旨在尽可能遵循开放标准;因此,大多数运行时、构建、存储和网络组件都依赖于社区项目和标准。以下列出的组件可以看作是 Podman 的主要构建模块:

)

  • 容器运行时基于 OCI 规范,OCI 兼容的运行时(如crunrunc)实现了这些规范。在本章中,我们将看到容器运行时的工作原理以及上述运行时之间的主要区别。

  • 同时,镜像管理通过containers/image库实现(github.com/containers/image)。这是一个 Go 语言库,既被容器引擎使用,也被容器注册中心使用。

  • 容器和镜像存储通过containers/storage库(github.com/containers/storage)实现,这是另一个 Go 语言库,用于在运行时管理文件系统层、容器镜像和容器卷。

  • 镜像构建通过 Buildah(github.com/containers/buildah)实现,Buildah 既是一个二进制工具,也是一个用于构建 OCI 镜像的库。我们将在本书稍后的章节介绍 Buildah。

  • 容器运行时监控和与引擎的通信通过Conmon工具实现,Conmon 是一个用于监控 OCI 运行时的工具,被 Podman 和CRI-Ogithub.com/containers/conmon)共同使用。

容器网络支持通过 Kubernetes bridge CNI 插件实现。更多插件列表可在以下仓库中找到:https://github.com/containernetworking/plugins。

如前所述,Podman 通过 libpod 库来协调容器生命周期,具体内容将在下一小节中描述。

libpod 库

Podman 的核心基础依赖于 libpod 库,其他开源项目如 CRI-O 也采用了该库。这个库包含了所有协调容器生命周期所需的逻辑,可以说这个库的开发是 Podman 项目诞生的关键。

该库是用 Go 编写的,因此作为Go 包访问,旨在实现引擎的所有高级功能。根据 libpod 和 Podman 文档,其范围包括以下内容:

  • 管理容器镜像格式,包括 OCI 和 Docker 镜像。这包括完整的镜像生命周期管理,从认证和从容器注册表拉取,存储镜像层和元数据的本地存储,到构建新镜像并推送到远程注册表。

  • 容器生命周期管理——从容器创建(包括所有必要的初步步骤)到运行容器,再到所有其他运行时功能,如停止、杀死、恢复、删除、在运行容器上执行进程以及日志记录。

  • 管理简单的容器和Pod,Pod 是共享命名空间的沙箱容器组(特别是 UTC、IPC、网络,最近还包括 PID),这些容器和 Pod 作为整体一起管理。

  • 支持无根容器和 Pod,这些容器和 Pod 可以由标准用户执行,无需特权提升。

  • 管理容器资源隔离。这在低层次上通过 CGroup 实现,但 Podman 用户可以在容器执行过程中使用 CLI 选项来管理内存和 CPU 的预留或限制存储设备的读写速率。

  • 支持一个可以用作 Docker 兼容替代品的 CLI。大多数 Podman 命令与 Docker CLI 中的命令相同。

  • 提供与 Docker 兼容的 REST API,通过本地 Unix 套接字(默认未启用)。Libpod REST API 提供 Podman CLI 所提供的所有功能。

lidpod 包在较低层次上与容器运行时、Conmon 以及如 container/storage、container/image、Buildah 和 CNI 等包进行交互。在下一节中,我们将重点关注容器运行时执行。

runc 和 crun OCI 容器运行时。

正如前一章节所示,容器引擎负责容器生命周期的高级编排,而创建和运行容器所需的低级操作由容器运行时提供。

在过去几年中,随着主要容器环境贡献者的帮助,OCI 运行时规范已成为行业标准。完整规范可以在github.com/opencontainers/runtime-spec上查看。

从这个代码库中,运行时和生命周期文档提供了容器运行时如何处理容器创建和执行的完整描述:github.com/opencontainers/runtime-spec/blob/master/runtime.md

Runc (github.com/opencontainers/runc) 是当前最广泛采用的 OCI 容器运行时。它的历史可以追溯到 2015 年,当时 Docker 宣布将所有基础设施模块拆分到一个名为 runC 的专用项目中。

RunC 完全支持 Linux 容器和 OCI 运行时规范。该项目仓库包含 libcontainer 包,这是一个用于创建带有命名空间、cgroups、能力和文件系统访问控制的容器的 Go 包。Libcontainer 曾是一个独立的 Docker 项目,当 runC 项目创建时,它被移到其主仓库中,以确保一致性和清晰性。

libcontainer 包定义了容器从零开始引导的内部逻辑和低级系统交互,从命名空间的初步隔离到容器内部二进制程序作为 PID 1 执行。

运行时回调 libcontainer 库以完成以下任务:

  • 消耗 Podman 提供的容器挂载点和容器元数据。

  • 与内核交互,使用 clone()unshare() 系统调用启动容器并执行隔离进程。

  • 设置 CGroup 资源预留。

  • 设置 SELinux 策略、Seccomp 和 AppArmor 规则。

除了运行进程,libcontainer 还处理命名空间和文件描述符的初始化,容器 rootFS 和绑定挂载的创建,导出容器进程的日志,通过 seccomp、SELinux 和 AppArmor 管理安全限制,以及创建和映射用户和组。

libcontainer 架构是本书中一个相当复杂的话题,显然需要进一步的研究以更好地理解其内部实现。

对于那些有兴趣查看代码并了解 Podman 内部实现的读者,符合 OCI 运行时规范的容器接口定义在 github.com/opencontainers/runc/blob/master/libcontainer/container.go 源文件中。

实现该接口的 Linux 操作系统方法定义在 github.com/opencontainers/runc/blob/master/libcontainer/container_linux.go 中。

使用 clone()unshare() 系统调用隔离进程命名空间的低级执行由 nsexec() 函数处理。这是一个嵌入在 Go 代码中的 C 函数,通过 cgo 实现。

nsexec() 的代码可以在这里找到:

github.com/opencontainers/runc/blob/master/libcontainer/nsenter/nsexec.c

)

runC一起,许多其他容器运行时也已经被创建。在本书中我们将讨论的一个替代运行时是crun,它旨在提供一个改进的 OCI 运行时,可以利用 C 语言设计方法,打造一个更清晰、更轻量的运行时。由于它们都是 OCI 运行时,runCcrun可以在容器引擎中互换使用。

例如,2019 年,Fedora 项目做出了一个大胆的决定,选择将 CGroup V2 作为默认选项发布 Fedora 31(www.redhat.com/sysadmin/fedora-31-control-group-v2)。在做出这个选择时,runC尚不支持在 CGroup V2 下管理容器\。

因此,Fedora 的 Podman 版本采用了crun作为默认运行时,因为它已经能够管理 CGroup V1 和 V2. 对最终用户来说,这一切几乎是无缝的,他们依旧使用 Podman,并保持相同的命令和行为。后来,runC终于从 v1.0.0-rc93 版本开始支持 CGroup V2,现在可以在较新的发行版上无缝使用。

然而,CGroup 主题并不是runCcrun之间唯一的区分点。

crun相比runC提供了一些有趣的优势,具体如下:

  • crun的构建文件大约比runC的构建文件小 50 倍。

  • 在相同的执行条件下,crun在容器执行时比runC更快。

  • crun消耗的内存不到runC的一半。较小的内存占用在处理大规模容器部署或物联网设备时非常有帮助。

crun还可以作为一个库使用,并集成到其他符合 OCI 标准的项目中。crunrunC都提供了 CLI,但并不打算由最终用户手动使用,最终用户应该使用容器引擎,如 Podman 或 Docker 来管理容器生命周期。

在 Podman 中切换这两种运行时有多容易?让我们看看以下示例。两个示例都使用–runtime标志来提供 OCI 运行时二进制路径。第一个示例使用runC运行容器:

podman --runtime /usr/bin/runc run --rm fedora echo “Hello World”

第二行使用crun二进制文件运行相同的容器:

podman --runtime /usr/bin/crun run --rm fedora echo “Hello World”

这些示例假设两个运行时已经在系统中安装。

crunrunC都支持eBPFCRIU

eBPF代表扩展伯克利数据包过滤器,是一种基于内核的技术,允许在 Linux 内核中执行用户定义的程序,向系统添加额外的功能,而无需重新编译内核或加载额外的模块。所有 eBPF 程序都在一个沙箱虚拟机内执行,其执行过程天生是安全的。今天,eBPF 正获得越来越多的关注,并吸引了行业的兴趣,尤其在网络、安全、可观察性和追踪等领域得到了广泛应用。

用户空间中的检查点恢复CRIU)是一款软件,它使用户能够冻结正在运行的容器并将其状态保存到磁盘以供后续恢复。内存中的数据结构会被转储并相应地恢复。

Podman 使用的另一个重要架构组件是 Conmon,一个用于监控容器运行时状态的工具。让我们在下一小节中更详细地探讨这个问题。

Conmon

我们可能仍然有一些关于运行时执行的问题。

Podman(容器引擎)和runC/crun(OCI 容器运行时)是如何互相交互的?哪一个负责启动容器运行时进程?有没有方法可以监控容器的执行?

让我们介绍一下 Conmon 项目(github.com/containers/conmon)。Conmon 是一个监控和通信工具,位于容器引擎和运行时之间。

每次创建新容器时,都会启动一个新的 Conmon 实例。它会从容器管理进程中分离出来,并以守护进程方式运行,启动容器运行时作为子进程。

如果我们附加一个追踪工具到 Podman 容器,我们可以看到它以以下顺序写入:

  1. 容器引擎运行 Conmon 进程,Conmon 进程会分离并以守护进程方式运行。

  2. Conmon 进程运行一个容器运行时实例,启动容器并退出。

  3. Conmon 进程继续运行,以提供监控接口,而管理器/引擎进程则已退出或分离。

下图展示了从 Podman 执行到运行容器的逻辑工作流:

图 2.5 – 运行 Podman 容器

图 2.5 – 运行 Podman 容器

在运行多个容器的系统上,用户会发现有许多 Conmon 进程实例,每个容器都有一个。换句话说,Conmon 充当容器的一个小型专用守护进程。

让我们看一个简单的示例,其中使用简单的 shell 循环创建三个相同的 nginx 容器:

[root@fedora34 ~]# for i in {1..3}; do podman run -d --rm docker.io/library/nginx; done
592f705cc31b1e47df18f71ddf922ea7e6c9e49217f00d1af8 cf18c8e5557bde
4b1e44f512c86be71ad6153ef1cdcadcdfa8bcfa8574f606a0832 c647739a0a2
4ef467b7d175016d3fa024d8b03ba44b761b9a75ed66b2050de3fe c28232a8a7
[root@fedora34 ~]# ps aux | grep conmon
root       21974  0.0  0.1  82660  2532 ?        Ssl  22:31   0:00 /usr/bin/conmon --api-version 1 -c 592f705cc31b1e47df18f71ddf922ea7e6c9e49217f00d1af8 cf18c8e5557bde -u 592f705cc31b1e47df18f71ddf922ea7e6c9e49217f00d1af8 cf18c8e5557bde -r /usr/bin/crun [..omitted output]
root       22089  0.0  0.1  82660  2548 ?        Ssl  22:31   0:00 /usr/bin/conmon --api-version 1 -c 4b1e44f512c86be71ad6153ef1cdcadcdfa8bcfa8574f606a0832 c647739a0a2 -u 4b1e44f512c86be71ad6153ef1cdcadcdfa8bcfa8574f606a0832 c647739a0a2 -r /usr/bin/crun [..omitted output] 
root       22198  0.0  0.1  82660  2572 ?        Ssl  22:31   0:00 /usr/bin/conmon --api-version 1 -c 4ef467b7d175016d3fa024d8b03ba44b761b9a75ed66b2050de3f ec28232a8a7 -u 4ef467b7d175016d3fa024d8b03ba44b761b9a75ed66b2050de3f ec28232a8a7 -r /usr/bin/crun [..omitted output]

在运行容器后,对ps aux命令输出应用一个简单的正则表达式模式,可以看到三个 Conmon 进程实例。

即使 Podman 不再运行(因为没有守护进程),仍然可以连接到 Conmon 进程并附加到容器。同时,Conmon 会将控制台套接字和容器日志暴露到日志文件或 systemd 日志中。

Conmon 是一个用 C 语言编写的轻量级项目。它还提供了 Go 语言绑定,用于在管理器和运行时之间传递配置结构。

无根容器

Podman 最有趣的特点之一是能够运行无根容器,这意味着没有提升权限的用户也可以运行自己的容器。

无根容器提供更好的安全隔离,并允许不同用户独立运行自己的容器实例。得益于fork/exec,Podman 采用无守护进程的方法,使得无根容器的管理变得非常简单。无根容器只需通过标准用户使用常规命令和参数运行,如以下示例所示:

$ podman run –d –-rm docker.io/library/nginx

当发出此命令时,Podman 创建一个新的用户命名空间,并使用man user_namespaces在两个命名空间之间映射 UID。此方法允许您例如在容器内拥有一个 root 用户,并将其映射为主机中的普通用户。

无根容器和镜像数据存储在用户的主目录下,通常在$HOME/.local/share/containers/storage下。

Podman 以不同于有根容器的方式管理无根容器的网络连接。关于无根容器和有根容器的深入技术比较,特别是从网络和安全的角度来看,将在本书后续章节中介绍。

在对运行时工作流进行深入分析之后,提供一个关于 Podman 使用的 OCI 镜像规格的概述是非常有用的。

OCI 镜像

Podman 和容器/镜像包实现了OCI 镜像格式规范。完整的规范可在 GitHub 上通过以下链接查看,并与 OCI 运行时规范配套使用:github.com/opencontainers/image-spec

一个 OCI 镜像由以下元素组成:

  1. 清单

  2. 一个镜像索引(可选)

  3. 镜像布局

  4. 一个文件系统层变更集归档,将被解压以创建最终的文件系统

  5. 一个镜像配置文档,用于定义层次顺序,以及应用程序参数和环境

让我们详细了解前面提到的最相关的元素管理的信息和数据种类。

清单

镜像清单规格应提供内容可寻址的镜像。镜像清单包含特定架构和操作系统(例如 Linux x86_64)的镜像层和配置。

规范:github.com/opencontainers/image-spec/blob/main/manifest.md

镜像索引

镜像索引是一个包含与不同架构(例如 amd64、arm64 或 386)和操作系统相关的镜像清单列表的对象,并附带自定义注释。

规范:github.com/opencontainers/image-spec/blob/main/image-index.md

镜像布局

OCI 镜像布局表示镜像块的目录结构。镜像布局还提供必要的清单位置引用,以及镜像索引(JSON 格式)和镜像配置。镜像的index.json包含指向镜像清单的引用,该清单作为块存储在 OCI 镜像包中。

规范: github.com/opencontainers/image-spec/blob/main/image-layout.md

文件系统层

在镜像内部,一个或多个层叠加在一起,创建一个容器可以使用的文件系统。

在低层次上,层被打包为 TAR 档案(具有 gzip 和 zstd 的压缩选项)。文件系统层实现了层叠的逻辑以及如何应用更改集层(包含文件更改的层)。

如前一章所述,写时复制(copy-on-write)或联合文件系统已成为管理图形方式叠加的标准。为了管理层叠,Podman 默认使用overlayfs作为图形驱动程序。

规范: github.com/opencontainers/image-spec/blob/main/layer.md

镜像配置

镜像配置定义了镜像层的组成及相应的执行参数,例如入口点、卷、执行参数或环境变量,以及附加的镜像元数据。

持有配置的镜像 JSON 是一个不可变的对象;更改它意味着创建一个新的衍生镜像。

规范: github.com/opencontainers/image-spec/blob/main/config.md

以下图示表示了 OCI 镜像的实现,包含镜像层、镜像索引和镜像配置:

图 2.6 – OCI 镜像实现

图 2.6 – OCI 镜像实现

让我们检查一个来自基础、轻量级alpine镜像的实际示例:

# tree alpine/
alpine/
├── blobs
│   └── sha256
│       ├── 03014f0323753134bf6399ffbe26dcd75e89c6a7429adfab 392d64706649f07b
│       ├── 696d33ca1510966c426bdcc0daf05f75990d68c4eb820f615 edccf7b971935e7
│       └── a0d0a0d46f8b52473982a3c466318f479767577551a53ffc9074 c9fa7035982e
├── index.json
└── oci-layout

目录布局包含一个index.json文件,内容如下:

{
  “schemaVersion”: 2,
  “manifests”: [
    {
      “mediaType”: “application/vnd.oci.image.manifest.v1+json”,
      “digest”: “sha256:03014f0323753134bf6399ffbe26dcd75e89c6a7429adfab 392d64706649f07b”,
      “size”: 348,
      “annotations”: {
        “org.opencontainers.image.ref.name”: “latest”
      }
    }
  ]
}

索引包含一个只包含一项的清单数组。对象摘要是 SHA256,并且与前面列出的 blob 之一的文件名对应。该文件是镜像清单,可以进行检查:

# cat alpine/blobs/sha256/03014f0323753134bf6399ffbe26dcd75e89c6a7429adfab392 d64706649f07b | jq
{
  “schemaVersion”: 2,
  “config”: {
    “mediaType”: “application/vnd.oci.image.config.v1+json”,
    “digest”: “sha256:696d33ca1510966c426bdcc0daf05f75990d 68c4eb820f615edccf7b971935e7”,
    “size”: 585
  },
  “layers”: [
    {
      “mediaType”: “application/vnd.oci.image.layer.v1.tar+gzip”,
      “digest”: “sha256:a0d0a0d46f8b52473982a3c466318f47976 7577551a53ffc9074c9fa7035982e”,
      “size”: 2814446
    }
  ]
}

清单包含对镜像配置和镜像层的引用。在这个特定的案例中,镜像只有一个层。同样,它们的摘要与前面列出的 blob 文件名相对应。

配置文件显示了镜像元数据、环境变量和命令执行。同时,它包含对镜像使用的层和镜像创建信息的DiffID引用:

# cat alpine/blobs/sha256/696d33ca1510966c426bdcc0daf05f75990 d68c4eb820f615edccf7b971935e7 | jq
{
  “created”: “2021-08-27T17:19:45.758611523Z”,
  “architecture”: “amd64”,
  “os”: “linux”,
  “config”: {
    “Env”: [
      “PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin”
    ],
    “Cmd”: [
      “/bin/sh”
    ]
  },
  “rootfs”: {
    “type”: “layers”,
    “diff_ids”: [
      “sha256:e2eb06d8af8218cfec8210147357a68b7e13f7c485b991c 288c2d01dc228bb68”
    ]
  },
  “history”: [
    {
      “created”: “2021-08-27T17:19:45.553092363Z”,
      “created_by”: “/bin/sh -c #(nop) ADD file:aad4290d27580 cc1a094ffaf98c3ca2fc5d699fe695dfb8e6e9fac 20f1129450 in / “
    },
    {
      “created”: “2021-08-27T17:19:45.758611523Z”,
      “created_by”: “/bin/sh -c #(nop)  CMD [\”/bin/sh\”]”,
      “empty_layer”: true
    }
  ]
}

镜像层是第三个 blob 文件。这是一个 TAR 档案,可以展开并进行检查。出于空间原因,本书中的示例仅限于检查文件类型:

# file alpine/blobs/sha256/a0d0a0d46f8b52473982a3c466318f47 9767577551a53ffc9074c9fa7035982e
alpine/blobs/sha256/a0d0a0d46f8b52473982a3c466318f479767577 551a53ffc9074c9fa7035982e: gzip compressed data, original size modulo 2³² 5865472

结果表明该文件是一个 TAR gz 压缩档案。

Docker 和 Podman 之间的主要区别

在前面的章节中,我们已经讨论了 Docker 和 Podman 的关键特性,深入探讨了底层结构,发现了使这两种工具在容器引擎角色中独特的开源项目,但现在是时候进行对比了。

正如我们之前看到的,两者之间的显著区别在于,Docker 采用的是以守护进程为中心的方法,而 Podman 则采用无守护进程架构。Podman 的二进制文件既是 CLI 也是容器引擎,并使用 Conmon 来协调和监控容器运行时。

深入了解两个项目的内部机制,我们还会发现许多其他的差异,但最终,一旦容器启动,它们都利用了 OCI 标准的容器运行时,只是存在一些差异:Docker 使用 runc,而 Podman 在大多数发行版中使用 crun,但也有一些例外;例如,在最为保守的 Red Hat Enterprise Linux 8 中,它仍然使用 runc,并且将 crun 作为可选项。

尽管在前一部分中描述了 crun 在性能上的优势,但本书的目标并不是对这两者进行详细的性能比较。无论如何,读者如果对这个话题感兴趣,将很容易找到有关这两个运行时性能差异的文献。

最近由 Docker 团队填补的另一个大空白是无根容器(rootless container)。Podman 是第一个推出这一出色功能的容器引擎,它增强了安全性并改善了容器在多个环境中的使用,但正如我们所提到的,这个功能现在也已经在 Docker 中可用。

但是在接下来的部分中,我们将更实际地进行比较,首先通过命令行并排比较,然后运行一个容器。

命令行接口比较

在这一部分,我们将进行一个并排比较,看看 Docker 和 Podman 的命令行接口。

查看两个命令行接口(CLI)提供的命令,很容易就能发现它们之间的许多相似之处。为了提高可读性,下面的表格被截断了:

表 2.1 – Docker 和 Podman 命令比较

表 2.1 – Docker 和 Podman 命令比较

正如我们在前面的部分以及上一章中多次提到的,Docker 于 2013 年诞生,而 Podman 则是在 2017 年才出现。Podman 的开发是在考虑到当时最著名的容器引擎 Docker 的基础上进行的。出于这个原因,Podman 开发团队决定尽量不改变命令行工具的外观和感觉,以帮助 Docker 用户顺利迁移到新生的 Podman。

事实上,在 Podman 发布初期曾有人声称,如果你有任何现有的运行 Docker 的脚本,你可以创建一个别名,它应该能正常工作(alias docker=podman)。同时还创建了一个包,在 /usr/bin 下放置一个 Docker 命令,它指向的是 Podman 的二进制文件。因此,如果你是 Docker 用户,一旦准备好转向 Podman,你可以期待一个平稳的过渡。

另一个重要的点是,通过 Docker 创建的镜像与 OCI 标准兼容,因此你可以轻松迁移或重新拉取你之前使用过的任何 Docker 镜像。

如果我们深入查看 Podman 可用的命令选项,你会注意到一些额外的命令在 Docker 中并不存在,而其他一些命令则缺失。

例如,Podman 可以管理 Pod(Podman 这个名字在这里非常具有提示性)。Pod 概念最初是在 Kubernetes 中引入的,表示 Kubernetes 集群中最小的执行单元。

使用 Podman,用户可以轻松创建空的 Pod,然后使用以下命令在其中运行容器:

$ podman pod create --name mypod
$ podman run –-pod mypod –d docker.io/library/nginx

在 Docker 中,这并不像 Podman 那样容易,在 Docker 中,用户必须首先运行一个容器,然后创建新的容器并连接到第一个容器的网络命名空间。

Podman 还具有一些额外功能,可以帮助用户将容器迁移到 Kubernetes 环境中。通过命令 podman generate kube,Podman 可以为正在运行的容器创建一个 Kubernetes YAML 文件,该文件可用于在 Kubernetes 集群中创建一个 Pod。

使用 podman generate systemd 命令将容器作为 systemd 服务运行同样简单,该命令会获取一个正在运行的容器或 Pod,并生成一个 systemd 单元文件,用于在系统启动时自动运行服务。

一个显著的例子:OpenStack 项目,一个开源云计算基础设施,在与 TripleO 部署时,将 Podman 作为其容器化服务的默认管理器。所有服务都由 Podman 执行,并由 systemd 在控制平面和计算节点中进行编排。

在检查了这些容器引擎的表面并查看了它们的命令行之后,让我们在下一节回顾一下它们在幕后的差异。

运行容器

如前所述,在 Docker 环境中运行容器,包含使用 Docker 命令行客户端与 Docker 守护进程进行通信,守护进程将执行所需的操作以启动容器。为了总结本章解释的概念,我们可以查看以下图示:

图 2.7 – Docker 简化架构

图 2.7 – Docker 简化架构

相比之下,Podman 直接与镜像注册表、存储以及通过容器运行时进程(不是守护进程)与 Linux 内核进行交互,Conmon 作为一个监控进程在 Podman 和 OCI 运行时之间执行,以下图示可以简要说明:

图 2.8 – Podman 简化架构

图 2.8 – Podman 简化架构

这两种架构之间的核心区别是以守护进程为中心的 Docker 视角与 Podman 的 fork/exec 方法。

本书并不深入讨论 Docker 守护进程架构和功能的优缺点。无论如何,我们可以肯定地说,许多 Docker 用户对于这种以守护进程为中心的方式存在许多担忧,原因有很多,例如:

  • 守护进程可能是单点故障。

  • 如果发生故障,可能会出现孤立的进程。

  • 守护进程拥有所有正在运行的容器,并将它们视为子进程。

尽管存在架构上的差异,以及前文提到的别名解决方案,可以轻松迁移项目而无需更改任何脚本,但无论是用 Docker 还是 Podman 从命令行运行容器,对最终用户来说几乎是一样的体验:

$ docker run –d -–rm docker.io/library/nginx
$ podman run –d -–rm docker.io/library/nginx

基于相同的原因,大多数命令行参数都尽量保持与 Docker 中原版本一致。

概述

在本章中,我们讨论了 Podman 和 Docker 之间的主要差异,既从架构角度,也从使用角度。我们描述了这两个容器引擎的主要构建模块,并突出了推动 Podman 项目的不同社区项目,特别是 OCI 规范以及 runCcrun 运行时。

本书的目的是探讨 Podman 是否比 Docker 更好的选择,而不是进行辩论。我们认为,所有使用容器的人都应当对 Docker 公司和社区所做的巨大贡献心存感激,正是他们将容器普及并从小众应用中解放出来。

与此同时,开源软件的演化方式促进了新项目的诞生,这些项目力图竞争并被采纳。从它诞生之日起,Podman 项目便呈指数级增长,并且日益获得更广泛的用户基础。

然而,理解引擎的内部结构仍然是一个重要任务。无论是为了故障排除、性能调优,还是仅仅出于好奇,投入时间去理解每个组件之间的关系,阅读代码并测试构建,是一个值得的选择,迟早会带来回报。

在接下来的章节中,我们将详细揭示这个优秀容器引擎的特点和行为。

进一步阅读

若想了解本章涉及的更多主题,您可以参考以下内容:

第三章:运行第一个容器

在前几章中,我们讨论了容器的历史、其采用情况以及促使其传播的各种技术,同时也看到了 DockerPodman 之间的主要区别。

现在,到了开始使用真实示例的时候:在本章中,我们将学习如何在你首选的 Linux 操作系统上安装并运行 Podman,以便我们可以启动我们的第一个容器。我们将了解各种安装方法,所有的先决条件,然后启动一个容器。

在本章中,我们将讨论以下主要主题:

  • 选择操作系统和安装方法

  • 准备环境

  • 运行第一个容器

技术要求

拥有良好的 Linux 操作系统管理经验将有助于理解本章提供的关键概念。

我们将介绍在各种 Linux 发行版上安装新软件的主要步骤,因此,作为一名 Linux 系统管理员拥有一定经验,在解决安装过程中可能出现的问题时会有所帮助。

此外,前几章中解释的一些理论概念有助于你理解本章描述的过程。

选择操作系统和安装方法

Podman 在不同的发行版和操作系统中得到支持。它非常容易安装,并且各个发行版现在提供了自己维护的安装包,可以通过它们各自的包管理器进行安装。

在本节中,我们将介绍最常见的 GNU/Linux 发行版的不同安装步骤,以及在 macOS 和 Windows 上的安装步骤,尽管本书的重点是 Linux 环境。

作为附加话题,我们还将学习如何从源代码直接构建 Podman。

在 Linux 发行版和其他操作系统之间的选择

在不同的 GNU/Linux 发行版之间的选择是由用户的偏好和需求决定的,这些偏好和需求通常受到本书范围外的几个因素的影响。

目前,许多高级用户选择 Linux 发行版作为他们的主要操作系统。然而,尤其在开发者中,仍然有一大部分人坚持将 macOS 作为标准操作系统。微软的 Windows 仍然在桌面工作站和笔记本电脑中占据着最大的市场份额。

如今,我们拥有一个庞大的 Linux 发行版生态系统,这些发行版从 Debian、Fedora、Red Hat 企业版 Linux、Gentoo、Arch 和 openSUSE 等少数核心历史发行版中发展而来。像 DistroWatch 这样的专业网站(distrowatch.com)追踪着 Linux 和 BSD 发行版的众多版本。

尽管运行的是 Linux 内核,不同的发行版在用户空间行为的架构方法上存在差异,例如文件系统结构、库或用于交付软件发布的打包系统。

另一个显著的区别与安全性和强制访问控制子系统有关:例如,Fedora、CentOS、Red Hat Enterprise Linux 及其所有衍生版依赖于SELinux作为其强制访问控制子系统。另一方面,Debian、Ubuntu 及其衍生版则基于类似的解决方案——AppArmor

Podman 与 SELinux 和 AppArmor 交互,以提供更好的容器隔离,但其底层接口是不同的。

重要提示

本书中的所有示例和源代码均使用Fedora Workstation 34作为参考操作系统编写和测试。

那些希望在实验室中尽可能复现本书环境的读者有不同的选择:

在公共云上运行实例是无法在本地运行虚拟机的用户的最佳选择。

提供商如 Amazon Web Services、Google Cloud Platform、Microsoft Azure 和 DigitalOcean 也提供基于 Fedora 的即用云实例,且小型实例的月租价格非常低。

价格可能随着时间和不同等级而变化,跟踪这些变化超出了本书的范围。几乎所有提供商都提供免费的学习或基础使用计划,且小型/微型套餐的价格非常低。

容器是基于 Linux 的,不同的容器引擎和运行时与 Linux 内核和库交互以运行。Windows 最近引入了对本地容器的支持,其隔离方法与前面描述的 Linux 命名空间概念非常相似。然而,仅支持 Windows 基础镜像的本地运行,并不是所有容器引擎都支持本地执行。

对 macOS 来说,同样的考虑事项也适用:它的架构并非基于 Linux,而是基于一种名为 XNU 的混合 Mach/BSD 内核。因此,它不提供运行容器所需的 Linux 内核特性。

对于 Windows 和 macOS,都需要一个虚拟化层来抽象出 Linux 机器,以运行本地 Linux 容器。

Podman 提供了适用于 Windows 和 macOS 的远程客户端功能,使用户能够连接到本地或远程的 Linux 主机。

Windows 用户也可以受益于基于 Windows Subsystem for Linux (WSL) 2.0 的替代方法,这是一种兼容层,通过 Hyper-V 虚拟化支持运行轻量级虚拟机,以提供 Linux 内核接口和 Linux 用户空间二进制文件。

以下章节将涵盖在最流行的 Linux 发行版、macOS 和 Windows 上安装 Podman 所需的步骤。

在 Fedora 上安装 Podman

Fedora 包由其广泛的社区维护,并使用 DNF 包管理器进行管理。要安装 Podman,请在终端中运行以下命令:

# dnf install –y podman 

该命令安装 Podman 并配置带有配置文件的环境(将在下一节中详细介绍)。它还安装了 systemd 单元,以提供额外的功能,如 REST API 服务或容器自动更新。

在 CentOS 上安装 Podman

Podman 可以安装在 CentOS 7、CentOS 8 和 CentOS Stream 上 (www.centos.org/)。在 CentOS 7 上安装的用户必须启用 Extras 仓库,而在 CentOS 8 和 Stream 上安装的用户必须从已经启用的 AppStream 仓库中获取 Podman 包。

要安装 Podman,请在终端中运行以下命令:

# yum install –y podman

与 Fedora 中一样,这个命令安装 Podman 及其所有依赖项,包括配置文件和 systemd 单元文件。

在 RHEL 上安装 Podman

要在 Red Hat Enterprise Linux (RHEL) (www.redhat.com/en/technologies/linux-platforms/enterprise-linux) 上安装 Podman,用户应根据 RHEL 7 和 RHEL 8 执行不同的安装程序。

在 RHEL 7 上,用户必须启用额外的渠道,然后安装 Podman 包:

# subscription-manager repos \
--enable=rhel-7-server-extras-rpms
# yum -y install podman

在 RHEL 8 上,Podman 包可通过一个名为 container-tools 的专用模块获取。模块是可以通过独立发布周期组织的 RPM 包的自定义集合:

# yum module enable -y container-tools:rhel8
# yum module install -y container-tools:rhel8

container-tools 模块安装了 Podman 和另外两个有用的工具,稍后在本书中会详细介绍:

  • Skopeo,一个用于管理 OCI 镜像和注册表的工具

  • Buildah,一个用于从 Dockerfiles 或从零开始构建自定义 OCI 镜像的专业工具

(不)在 Fedora CoreOS 和 Fedora Silverblue 上安装 Podman

本小节的标题有点开玩笑的成分。实际上,Podman 已经在这两个发行版上安装并成为运行容器化工作负载的关键工具。

Fedora CoreOSFedora SilverBlue 发行版是不可变的、原子化的操作系统,分别旨在用于服务器/云和桌面环境。

Fedora CoreOS (getfedora.org/en/coreos/) 是 Red Hat CoreOS 的上游版本,是运行 Red Hat OpenShift 和 OpenShift Kubernetes Distribution (OKD) 的操作系统,OKD 是用于构建 Red Hat OpenShift 的社区版本 Kubernetes 发行版。

Fedora Silverblue (silverblue.fedoraproject.org/) 是一个面向桌面的不可变操作系统,旨在提供稳定和舒适的桌面用户体验,特别是为从事容器开发的开发者提供支持。

因此,在 Fedora CoreOS 和 Fedora Silverblue 上,只需打开终端并运行 Podman。

在 Debian 上安装 Podman

Debian (www.debian.org/) 自版本 11 起提供 Podman,该版本的代号为 Bullseye(以《玩具总动员 2 和 3》中的著名玩具马命名)。

Debian 使用 apt-get 包管理工具来安装和升级系统软件包。

要在 Debian 系统上安装 Podman,请在终端中运行以下命令:

# apt-get –y install podman

前述命令安装了 Podman 二进制文件及其依赖项,以及其配置文件、systemd 单元和手册页。

在 Ubuntu 上安装 Podman

基于 Debian 构建的 Ubuntu (ubuntu.com/) 在软件包管理方面表现类似。要在 Ubuntu 20.10 或更高版本上安装 Podman,请运行以下命令:

# apt-get -y update
# apt-get -y install podman

这两个命令将更新系统软件包,然后安装 Podman 二进制文件及相关依赖项。

在 openSUSE 上安装 Podman

openSUSE 发行版 (www.opensuse.org/) 由 SUSE 支持,并提供两种不同的版本——滚动发布的 Tumbleweed 和 LTS 版本的 Leap。Podman 可以通过 openSUSE 仓库安装,使用以下命令:

# zypper install podman

Zypper 包管理器将下载并安装所有必要的软件包和依赖项。

在 Gentoo 上安装 Podman

Gentoo (www.gentoo.org/) 是一个巧妙的发行版,其特点是直接在目标机器上构建已安装的软件包,并提供可选的用户自定义功能。为此,它使用 Portage 包管理器,灵感来自 FreeBSD ports。

要在 Gentoo 上安装 Podman,请运行以下命令:

# emerge app-emulation/podman

emerge 工具将下载并自动在系统上构建 Podman 源代码。

在 Arch Linux 上安装 Podman

Arch Linux (archlinux.org/) 是一个滚动更新的 Linux 发行版,以高度可定制性而著称。它使用 pacman 包管理器从官方和用户自定义的仓库安装和更新软件包。

要在 Arch Linux 及其衍生发行版上安装 Podman,请在终端中运行以下命令:

# pacman –S podman

默认情况下,在 Arch Linux 上安装的 Podman 不支持无根容器。要启用该功能,请按照官方 Arch Wiki 指南操作:wiki.archlinux.org/title/Podman#Rootless_Podman

在 Raspberry Pi OS 上安装 Podman

著名的 Raspberry Pi 单板计算机在开发者、创客和爱好者中取得了巨大的成功。

它运行的是 Raspberry Pi OS(www.raspberrypi.org/software/operating-systems/#raspberry-pi-os-32-bit),该系统基于 Debian。

Podman 的 arm64 构建版本已发布,并且可以通过遵循前述在 Debian 发行版上的安装步骤进行安装。

在 macOS 上安装 Podman

使用 Apple 设备的用户可以安装并使用 Podman 作为远程客户端,而容器将在远程的 Linux 主机上执行。该 Linux 主机也可以是一个在 macOS 上执行并直接由 Podman 管理的虚拟机。

要通过 Homebrew 包管理器安装 Podman,请在终端中运行以下命令:

$ brew install podman

要初始化运行 Linux 虚拟机的环境,请运行以下命令:

$ podman machine init
$ podman machine start

用户也可以创建并连接到一个外部的 Linux 主机。

在 macOS 上,另一种创建快速、轻量级开发虚拟机的有效方法是 Vagrant。当 Vagrant 虚拟机创建完成后,用户可以手动或自动配置其他软件,如 Podman,并开始使用通过远程客户端配置的定制实例。

在 Windows 上安装 Podman

要作为远程客户端运行 Podman,只需从 GitHub 发布页面下载并安装最新版本(github.com/containers/podman/releases/)。将归档文件解压到合适的位置,并编辑 TOML 编码的 containers.conf 文件,配置远程 URI 以连接到 Linux 主机,或者传递其他选项。

以下代码片段显示了一个示例配置:

[engine]
remote_uri= " ssh://root@10.10.1.9:22/run/podman/podman.sock"

远程 Linux 主机会通过 systemd 单元管理的 UNIX 套接字暴露 Podman。我们将在本书后续章节中详细介绍这个话题。

要在 WSL 2.0 上运行 Podman,用户必须首先从 Microsoft Store 在 Windows 主机上安装一个 Linux 发行版。Microsoft 目录下有多种可用的发行版。

以下示例基于 Ubuntu 20.10:

# apt-get –y install podman
# mkdir -p /etc/containers
# echo -e "[registries.search]\nregistries = \
[‘docker.io’, ‘quay.io’]" | tee \ /etc/containers/registries.conf

上述命令安装了最新的 Podman 稳定版本,并配置了 /etc/containers/registries.conf 文件,以提供一个注册表白名单。

安装完成后,某些小的定制化操作是必需的,以使其适应 WSL 2.0 环境:

# cp /usr/share/containers/libpod.conf /etc/containers
# sed –i  ‘s/ cgroup_manager = "systemd"/ cgroup_manager = "cgroupfs"/g’ /etc/containers/libpod.conf
# sed –i ‘s/ events_logger = "journald"/ events_logger = "file"/g’ /etc/containers/libpod.conf

上述命令配置了日志记录和 CGroup 管理,以便在子系统中成功运行 rootful 容器。

从源代码构建 Podman

从源代码构建应用程序有许多优点:用户可以在构建之前检查和自定义代码,进行跨架构编译,或仅选择构建部分二进制文件。这也是深入了解项目结构和理解其演变的一个很好的学习机会。最后但同样重要的是,从源代码构建可以让用户获得最新的开发版本,带有炫酷的新特性,当然也包括一些 bugs。

以下步骤假设构建机器使用的是 Fedora 发行版。首先,我们需要安装编译 Podman 所需的必要依赖:

# dnf install -y \
  btrfs-progs-devel \
  conmon \
  containernetworking-plugins \
  containers-common \
  crun \
  device-mapper-devel \
  git \
  glib2-devel \
  glibc-devel \
  glibc-static \
  go \
  golang-github-cpuguy83-md2man \
  gpgme-devel \
  iptables \
  libassuan-devel \
  libgpg-error-devel \
  libseccomp-devel \
  libselinux-devel \
  make \
  pkgconfig

这个命令会花一些时间来安装所有的软件包及其级联的依赖关系。

安装完成后,选择一个工作目录并使用 git 命令克隆 Podman 仓库:

$ git clone https://github.com/containers/podman.git

此命令将在工作目录中克隆整个仓库。

切换到项目目录并开始构建:

$ cd podman
$ make package-install

make package-install 命令编译源代码,构建 RPM 文件,并将包本地安装。请记住,RPM 格式与 Fedora/CentOS/RHEL 发行版相关联,并由 dnfyum 包管理器管理。

构建过程将需要几分钟才能完成。要测试软件包是否成功安装,只需运行以下代码:

$ podman version
Version:      4.0.0-dev
API Version:  4.0.0-dev
Go Version:   go1.16.6
Git Commit:   cffc747fccf38a91be5cd106d2e507afaaa23e14
Built:        Sat Aug  4 00:00:00 2018
OS/Arch:      linux/amd64

有时候,将二进制文件在专用的构建主机上构建,再通过包管理器或简单的归档文件将其部署到其他机器上是很有用的。若只需要构建二进制文件,请运行以下命令:

$ make

构建完成后,二进制文件将位于 bin/ 文件夹中。要通过将编译后的二进制文件和配置文件直接复制到 Makefile 中定义的目标目录来本地安装,请运行以下命令:

$ make install

要创建类似于 .tar.gz 归档的二进制发布版本(该版本可在 GitHub 发布页面上获取),请运行以下命令:

$ make podman-release.tar.gz

git 命令。例如,要构建 v3.3.1,请使用以下命令:

$ git checkout v3.3.1

在这一部分中,我们学习了如何使用各自的包管理器在不同的发行版上安装 Podman 的二进制发布版本。我们还学习了如何在 macOS 和 Windows 上安装 Podman 远程客户端,并支持 Windows WSL 2.0 模式。最后,我们通过向你展示如何从源代码构建来结束这一部分。

在下一部分中,我们将学习如何通过准备系统环境来配置 Podman 进行首次运行。

准备你的环境

一旦安装了 Podman 包,Podman 就可以开箱即用了。然而,一些小的自定义配置可能对提高与外部注册表的互操作性或自定义运行时行为非常有用。

自定义容器注册表搜索列表

Podman 会从一系列受信任的容器注册表中搜索并下载镜像。/etc/containers/registries.conf 文件是一个 TOML 配置文件,可用于自定义允许搜索和使用作为镜像来源的白名单注册表,以及注册表镜像和未经 TLS 终止的不安全注册表。

在此配置文件中,unqualified-search-registries 键填充了一个未指定图像仓库和标签的不合格注册表数组。

在 Fedora 系统上,使用 Podman 的新安装中,此键具有以下内容:

unqualified-search-registries = ["registry.fedoraproject.org", "registry.access.redhat.com", "docker.io", "quay.io"]

用户可以向此数组添加或删除注册表,以让 Podman 从中搜索和拉取。

重要提示

在添加注册表时要非常谨慎,并且只使用可信的注册表,以避免拉取包含恶意代码的镜像。

默认列表足以搜索并运行所有的书本示例。那些已经运行私有注册表的用户可以尝试将它们添加到不合格搜索注册表数组中。

由于注册表既可以是私有的也可以是公共的,请记住,通常需要额外的身份验证才能访问私有注册表。可以使用 podman login 命令来实现这一点,这将在本书的后续部分介绍。

如果在用户的主目录中找到 $HOME/.config/containers/registries.conf 文件,则会覆盖 /etc/containers/registries.conf 文件。这样,同一系统上的不同用户将能够使用其自定义注册表白名单和镜像运行 Podman。

可选 - 启用基于套接字的服务

这是一个可选步骤,在没有特定需求的情况下,可以安全地跳过本节的内容。

正如前面提到的,Podman 是一种无守护进程的容器管理器,无需后台服务即可运行容器。但是,用户可能需要与 Podman 公开的 Libpod API 进行交互,特别是在从基于 Docker 的环境迁移时。

Podman 可以使用 UNIX 套接字(默认行为)或 TCP 套接字公开其 API。后者选项不太安全,因为它使 Podman 可以从外部访问,但在某些情况下是必需的,例如应由 Windows 或 macOS 工作站上的 Podman 客户端访问时。

重要提示

在将 API 服务在暴露于互联网的机器上使用 TCP 端点时要小心,因为该服务将全局可访问。

以下命令将在 UNIX 套接字上公开 Podman API:

$ sudo podman system service --time=0 \
  unix:///run/podman/podman.sock

运行此命令后,用户可以连接到 API 服务。

在终端窗口上运行此命令并不是一个方便的方法。而是最好的方法是使用 man systemd.socket)。

Socket 单位在 systemd 中是一种特殊类型的服务激活器:当请求到达套接字的预定义端点时,systemd 立即生成同名的服务。

当安装 Podman 时,会创建 podman.socketpodman.service 单元文件。podman.socket 具有以下内容:

# cat /usr/lib/systemd/system/podman.socket
[Unit]
Description=Podman API Socket
Documentation=man:podman-system-service(1) 
[Socket]
ListenStream=%t/podman/podman.sock
SocketMode=0660
[Install]
WantedBy=sockets.target

ListenStream 键保存套接字的相对路径,该路径扩展为 /run/podman/podman.sock

podman.service 包含以下内容:

# cat /usr/lib/systemd/system/podman.service
[Unit]
Description=Podman API Service
Requires=podman.socket
After=podman.socket
Documentation=man:podman-system-service(1)
StartLimitIntervalSec=0
[Service]
Type=exec
KillMode=process
Environment=LOGGING="--log-level=info"
ExecStart=/usr/bin/podman $LOGGING system service
[Install]
WantedBy=multi-user.target

ExecStart= 字段指示由服务启动的命令,这与我们之前展示的 podman system service 命令相同。

Requires= 字段指示 podman.service 单元需要激活 podman.socket

那么,当我们启用并启动 podman.socket 单元时会发生什么呢?systemd 处理该套接字并等待连接到套接字端点。当此事件发生时,它会立即启动 podman.service 单元。经过一段时间的空闲后,服务会再次停止。

要启用并启动套接字单元,请运行以下命令:

# systemctl enable --now podman.socket

我们可以使用一个简单的 curl 命令来测试结果:

# curl --unix-socket /run/podman/podman.sock \   
  http://d/v3.0.0/libpod/info

打印的输出将是一个 JSON 有效负载,其中包含容器引擎配置。

当我们点击 URL 时发生了什么?在后台,服务单元在发出连接时立即被套接字触发并启动。你们中的一些人可能注意到第一次执行命令时有轻微的延迟(大约 1/10 秒)。

在 5 秒钟的空闲时间后,podman.service 会再次停用。这是由于 podman system service 命令的默认行为,默认情况下它只运行 5 秒,除非传递 –time 选项来提供不同的超时(值为 0 表示永远运行)。

可选 – 自定义 Podman 的行为

Podman 的默认配置适用于大多数使用场景,但其配置非常灵活。以下配置文件可用于自定义其行为:

  • containers.conf:该 TOML 格式的文件包含 Podman 运行时配置,以及 conmon 和容器运行时二进制文件的搜索路径。默认情况下,它安装在 /usr/share/containers/ 路径下,并可以通过 /etc/containers/containers.conf$HOME/.config/containers/containers.conf 文件分别覆盖系统范围和用户范围的设置。

该文件可用于自定义引擎的行为。用户可以通过自定义设置(如日志记录、DNS 解析、环境变量、共享内存使用、Cgroup 管理等)来影响容器的创建及其生命周期。

有关完整设置列表,请查看随 Podman 包一起安装的相关 man 页面。(man containers.conf)

  • storage.conf:该 TOML 格式的文件用于自定义容器引擎使用的存储设置。特别地,该文件使你能够自定义默认的存储驱动程序以及容器存储的读/写目录(也称为图形根目录),这也是一个额外的驱动程序存储选项。默认情况下,驱动程序设置为 overlay

该文件的默认路径是/usr/share/containers/storage.conf,可以在/etc/containers/storage.conf下找到或创建覆盖文件,用于系统范围的自定义配置。

影响无根容器的用户范围配置可以在$XDG_CONFIG_HOME/containers/storage.conf$HOME/.config/containers/storage.conf下找到。

  • mounts.conf:此文件定义了在容器启动时应自动挂载的卷。这在自动传递容器内的密钥和证书等机密信息时非常有用。

它可以在/usr/share/containers/mounts.conf找到,并可以通过位于/etc/containers/mounts.conf的文件进行覆盖。

在无根模式下,覆盖文件可以放置在$HOME/.config/containers/mounts.conf下。

  • seccomp.json:这是一个 JSON 文件,允许用户自定义容器内进程可以执行的syscalls,并同时定义被阻止的syscalls。这个话题将在第十一章《容器安全》中再次讨论,届时会提供更深入的容器安全约束的理解。

该文件的默认路径是/usr/share/containers/seccomp.json。seccomp 手册页(man seccomp)提供了有关 seccomp 如何在 Linux 系统上工作的概述。

  • policy.json:这是一个 JSON 文件,用于定义 Podman 如何执行签名验证。该文件的默认路径是/etc/containers/policy.json,并可以通过用户范围的$HOME/.config/containers/policy.json进行覆盖。

该配置文件接受三种类型的策略:

  • insecureAcceptAnything:接受来自指定注册表的任何镜像。

  • reject:拒绝来自指定注册表的任何镜像。

  • signedBy:仅接受由特定已知实体签名的镜像。

默认配置是接受每个镜像(insecureAcceptAnything策略),但可以修改为只拉取可以通过签名验证的受信镜像。用户可以定义自定义 GPG 密钥来验证签名及其签名者的身份。有关可能的策略和配置示例的更多细节,请参阅相关的手册页(man containers-policy.json)。

在本节中,我们讨论了一些在 Podman 首次安装时需要了解的基本配置。下一节将介绍我们的第一个容器执行示例。

运行第一个容器

现在,终于可以运行我们的第一个容器了。

在上一节中,我们揭示了如何在我们喜欢的 Linux 发行版上安装 Podman,以及安装后包含的基本软件包内容。现在,我们可以开始使用我们的无守护进程容器引擎了。

在 Podman 中运行容器是通过podman run命令来处理的,该命令接受许多选项来控制刚刚运行的容器的行为、隔离性、通信、存储等。

运行全新容器的最简单和最短的 Podman 命令如下:

$ podman run <imageID>

我们必须将 imageID 字符串替换为我们想要运行的镜像名称/位置/标签。如果该镜像不在缓存中,或者我们之前没有下载过,Podman 将从相应的容器注册表为我们拉取镜像。

交互式和伪终端(pseudo-tty)

为了介绍这个命令及其选项,我们先从简单开始,执行以下命令:

$ podman run -i -t fedora /bin/bash 
Resolved "fedora" as an alias (/etc/containers/registries.conf.d/000-shortnames.conf)
Trying to pull registry.fedoraproject.org/fedora:latest...
Getting image source signatures
Copying blob ecfb9899f4ce done  
Copying config 37e5619f4a done  
Writing manifest to image destination
Storing signatures
[root@ec444ad299ab /]#

让我们看看在执行上一个命令后,Podman 做了什么:

  1. 它识别出镜像名称 fedora 作为最新 Fedora 容器镜像的别名。

  2. 它随后意识到,由于这是第一次尝试运行该镜像,镜像在本地缓存中是缺失的。

  3. 它从正确的注册表中拉取了镜像。它选择了 Fedora 项目注册表,因为它与注册表配置中包含的别名匹配。

  4. 最终,它启动了容器,并为我们提供了一个交互式 shell,执行了我们请求的 Bash shell 程序。

上一个命令启动了一个交互式 shell,这得益于我们可以分析的两个选项,如下所示:

  • --tty, -t:使用此选项,Podman 会为容器分配一个 man pty 并将其附加到容器的标准输入。

  • --interactive, -i:使用此选项,Podman 会保持 stdin 打开,并准备附加到之前的伪终端。

如前几章所述,当创建容器时,容器内部的隔离进程将在可写的根文件系统上运行,这是层叠叠加的结果。

这允许任何进程写入文件,但不要忘记,这些文件会一直存在,直到容器停止运行,因为容器默认是短暂的。

现在,你可以执行任何命令并检查我们刚刚启动的控制台中的输出:

[root@ec444ad299ab /]# dnf install -y iputils iproute
Last metadata expiration check: 0:01:50 ago on Mon Sep 13 08:54:20 2021.
Dependencies resolved.
=============================================================================================================================================================================
Package                                           Architecture                      Version                                        Repository                          Size
=============================================================================================================================================================================
Installing:
iproute                                           x86_64                            5.10.0-2.fc34                                  fedora                             679 k
iputils                                           x86_64                            20210202-2.fc34                                fedora                             170 k
Installing dependencies:
...
[root@ec444ad299ab /]# ip r
default via 10.0.2.2 dev tap0 
10.0.2.0/24 dev tap0 proto kernel scope link src 10.0.2.100 
[root@ec444ad299ab /]# ping -c2 10.0.2.2
PING 10.0.2.2 (10.0.2.2) 56(84) bytes of data.
64 bytes from 10.0.2.2: icmp_seq=1 ttl=255 time=0.030 ms
64 bytes from 10.0.2.2: icmp_seq=2 ttl=255 time=0.200 ms

--- 10.0.2.2 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1034ms
rtt min/avg/max/mdev = 0.030/0.115/0.200/0.085 ms

如你所见,在上面的示例中,我们安装了两个软件包,用于检查容器的网络配置,然后执行 ping 测试,目标是分配给我们运行容器的虚拟网络的默认路由器。同样,如果我们停止该容器,任何更改都将丢失。

要退出这个交互式 shell,我们只需要按下Ctrl + D 或执行 exit 命令。这样,容器将被终止,因为我们请求执行的主进程(/bin/bash)将停止!

现在,让我们看看一些我们可以与 podman run 命令一起使用的其他有用选项。

从运行中的容器中分离

正如我们之前学到的,Podman 让我们有机会将交互式 shell 附加到正在运行的容器中。然而,我们很快会发现,这并不是运行容器的首选方式。

一旦容器启动,我们可以轻松地从容器中分离,即使我们是用附加交互式 tty 启动的:

$ podman run -i -t registry.fedoraproject.org/f29/httpd
Trying to pull registry.fedoraproject.org/f29/httpd:latest...
Getting image source signatures
Copying blob aaf5ad2e1aa3 done  
Copying blob 7692efc5f81c done  
Copying blob d77ff9f653ce done  
Copying config 25c76f9dcd done  
Writing manifest to image destination
Storing signatures
=> sourcing 10-set-mpm.sh ...
=> sourcing 20-copy-config.sh ...
=> sourcing 40-ssl-certs.sh ...
AH00558: httpd: Could not reliably determine the server’s fully qualified domain name, using 10.0.2.100\. Set the ‘ServerName’ directive globally to suppress this message
[Tue Sep 14 09:26:05.691906 2021] [ssl:warn] [pid 1:tid 140416655523200] AH01882: Init: this version of mod_ssl was compiled against a newer library (OpenSSL 1.1.1b FIPS  26 Feb 2019, version currently loaded is OpenSSL 1.1.1 FIPS  11 Sep 2018) - may result in undefined or erroneous behavior
[Tue Sep 14 09:26:05.692610 2021] [ssl:warn] [pid 1:tid 140416655523200] AH01909: 10.0.2.100:8443:0 server certificate does NOT include an ID which matches the server name
AH00558: httpd: Could not reliably determine the server’s fully qualified domain name, using 10.0.2.100\. Set the ‘ServerName’ directive globally to suppress this message
[Tue Sep 14 09:26:05.752028 2021] [ssl:warn] [pid 1:tid 140416655523200] AH01882: Init: this version of mod_ssl was compiled against a newer library (OpenSSL 1.1.1b FIPS  26 Feb 2019, version currently loaded is OpenSSL 1.1.1 FIPS  11 Sep 2018) - may result in undefined or erroneous behavior
[Tue Sep 14 09:26:05.752806 2021] [ssl:warn] [pid 1:tid 140416655523200] AH01909: 10.0.2.100:8443:0 server certificate does NOT include an ID which matches the server name
[Tue Sep 14 09:26:05.752933 2021] [lbmethod_heartbeat:notice] [pid 1:tid 140416655523200] AH02282: No slotmem from mod_heartmonitor
[Tue Sep 14 09:26:05.755334 2021] [mpm_event:notice] [pid 1:tid 140416655523200] AH00489: Apache/2.4.39 (Fedora) OpenSSL/1.1.1 configured -- resuming normal operations
[Tue Sep 14 09:26:05.755346 2021] [core:notice] [pid 1:tid 140416655523200] AH00094: Command line: ‘httpd -D FOREGROUND’

现在怎么办?要从运行中的容器中分离,我们只需按下以下特殊快捷键:Ctrl + PCtrl + Q。通过这个组合,我们将返回到我们的 shell 提示符,同时容器会继续运行。

为了恢复我们分离的容器的tty,我们必须获取正在运行的容器列表:

$ podman ps
CONTAINER ID  IMAGE                                        COMMAND               CREATED        STATUS            PORTS       NAMES
685a339917e7  registry.fedoraproject.org/f29/httpd:latest  /usr/bin/run-http...  3 minutes ago  Up 3 minutes ago              clever_zhukovsky

我们将在下一章中更详细地探讨此命令,但目前,只需记下容器 ID,然后执行以下命令以重新连接到之前的tty

$ podman attach 685a339917e7

请注意,我们只需将-d选项添加到podman run命令中,就可以轻松启动一个容器并进入分离模式,如下所示:

$ podman run -d -i -t registry.fedoraproject.org/f29/httpd

在下一节中,我们将学习如何在特殊情况下使用分离选项。

网络端口发布

正如我们在前几章中提到的,Podman 像其他容器引擎一样,在容器运行时会附加一个虚拟网络,这个网络与原主机网络是隔离的。因此,如果我们想要轻松访问容器,甚至将它暴露到主机网络外部,我们需要指示 Podman 进行端口映射。

Podman 的-p选项将容器的端口发布到主机:

-p=ip:hostPort:containerPort

hostPortcontainerPort都可以是端口范围,如果主机 IP 未设置或设置为0.0.0.0,则端口将绑定到主机的所有 IP 地址。

如果我们回顾上一节使用的命令,它变成了以下内容:

$ podman run -p 8080:8080 -d -i -t \ registry.fedoraproject.org/f29/httpd

现在,我们可以记录下分配给我们正在运行的容器的容器 ID

$ podman ps
CONTAINER ID  IMAGE                                        COMMAND               CREATED         STATUS             PORTS                   NAMES
fc9d97642801  registry.fedoraproject.org/f29/httpd:latest  /usr/bin/run-http...  10 minutes ago  Up 10 minutes ago  0.0.0.0:8080->8080/tcp  confident_snyder

然后,我们可以查看我们刚才定义的端口映射:

$ podman port fc9d97642801
8080/tcp -> 0.0.0.0:8080

接下来,我们可以使用curl命令测试端口映射是否有效,curl是一个易于使用的 HTTP 客户端。或者,您也可以使用您喜欢的网页浏览器访问相同的 URL,如下所示:

$ curl –s 127.0.0.1:8080 | head
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
4
6<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
5    <head>
0         <title>Test Page for the Apache HTTP Server on Fedora</title>
          <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
          <style type="text/css">
1               /*<![CDATA*/
0               body {
0                     background-color: #fff;

在本章结束前,让我们看一下其他一些有趣的选项,这些选项可能在运行时管理配置和容器行为时非常有用。

配置和环境变量

podman run命令提供了大量的选项,允许我们在运行时配置容器的行为——在撰写本书时,大约有 120 个选项。

例如,我们有一个选项可以更改正在运行的容器的时区,也就是--tz

$ date
Tue Sep 14 17:44:59 CEST 2021
$ podman run --tz=Asia/Shanghai fedora date
Tue Sep 14 23:45:11 CST 2021

我们可以通过--dns选项更改我们新容器的 DNS:

$ podman run --dns=1.1.1.1 fedora cat /etc/resolv.conf
search lan
nameserver 1.1.1.1

我们还可以将一个主机添加到/etc/hosts文件中,以覆盖本地内部地址:

$ podman run --add-host=my.server.local:192.168.1.10 \
fedora cat /etc/hosts
127.0.0.1   localhost localhost.localdomain localhost4 localhost4.localdomain4
::1         localhost localhost.localdomain localhost6 localhost6.localdomain6
192.168.1.10 my.server.local

我们甚至可以添加一个 HTTP 代理,让容器使用代理进行 HTTP 请求。Podman 的默认行为是从主机传递许多环境变量,其中一些是http_proxyhttps_proxyftp_proxyno_proxy

另一方面,我们也可以定义自定义的环境变量,借助–env选项将它们传递给容器:

$ podman run --env MYENV=podman fedora printenv
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
TERM=xterm
container=oci
DISTTAG=f34container
FGC=f34
MYENV=podman
HOME=/root
HOSTNAME=93f2541180d2

使用环境变量并与我们的容器一起使用是传递配置参数给应用程序并从操作系统主机影响服务行为的最佳实践。正如我们在[第一章中看到的,容器技术简介,容器默认是不可变且短暂的。因此,出于这个原因,我们应该像前面的示例中那样利用环境变量,在运行时配置容器。

总结

在本章中,我们开始玩转 Podman 的基本命令,我们通过查看最有趣的选项学习了如何运行容器,现在我们准备进入下一个层次:容器管理。要在容器世界中作为系统管理员工作,我们必须理解并学习那些让我们检查和检查正在运行的容器化服务健康状况的管理命令;这正是我们在本章中看到的内容。

在下一章中,我们将深入学习容器管理,我们将学习如何使用 Podman 管理镜像和容器生命周期。我们将学习如何检查和提取正在运行的容器的日志,并且还将介绍 Pod,如何创建它们,以及如何在其中运行容器。

深入阅读

关于本章中涉及的主题的更多信息,请参考以下资源:

第四章:管理运行中的容器

在前一章中,我们学习了如何设置环境以便使用 Podman 运行容器,包括主要发行版的二进制安装、系统配置文件以及第一个示例容器的运行,以验证我们的设置是否正确。本章将提供容器执行的更详细概述,介绍如何管理和检查正在运行的容器,以及如何将容器分组到 Pod 中。本章对于获得正确的知识和技能,以便开始作为容器技术的系统管理员经验非常重要。

在本章中,我们将涵盖以下主要内容:

  • 管理容器镜像

  • 与运行中的容器进行操作

  • 检查容器信息

  • 捕获容器日志

  • 在运行的容器中执行进程

  • 在 Pod 中运行容器

技术要求

在继续本章及其练习之前,需要一台运行 Podman 实例的机器。如第三章所述,运行第一个容器,本书中的所有示例都在 Fedora 34 系统上执行,但也可以在您选择的操作系统OS)上复制。

最后,充分理解前几章中涵盖的主题有助于轻松掌握有关开放容器倡议OCI)镜像和容器执行的概念。

管理容器镜像

在本节中,我们将看到如何在本地系统中查找并拉取(下载)镜像,以及如何检查其内容。当容器第一次创建并运行时,Podman 会自动拉取相关镜像。然而,能够提前拉取和检查镜像具有一些宝贵的优势,第一个优势是,当镜像已经在机器的本地存储中时,容器执行速度更快。

如前几章所述,容器是一种将进程隔离在具有独立命名空间和资源分配的沙箱环境中的方式。

容器中挂载的文件系统由在第二章中描述的 OCI 镜像提供,比较 Podman 和 Docker

OCI 镜像由专门的服务称为容器镜像仓库进行存储和分发。容器镜像仓库存储镜像和元数据,并暴露简单的表述性状态转移REST应用程序编程接口API)服务,以便用户推送和拉取镜像。

基本上有两种类型的镜像仓库:公共和私有。公共仓库作为公共服务可以访问(有或没有认证)。主要的公共仓库,如docker.iogcr.ioquay.io,也被用作大型开源项目的镜像库。

私有注册表是在组织内部部署和管理的,可以更专注于安全性和内容过滤。目前的主要容器注册表项目已在云原生计算基金会CNCF)下毕业(landscape.cncf.io/card-mode?category=container-registry&grouping=category),并提供管理多租户、身份验证、基于角色的访问控制RBAC)等高级企业功能,以及图像漏洞扫描和图像签名。

第九章中,推送图像到容器注册表,我们将提供更多关于与容器注册表交互的细节和示例。

大部分公共和私有注册表暴露 Docker Registry HTTP API V2(docs.docker.com/registry/spec/api/)。使用curl命令或设计自己的定制客户端。

Podman 提供了一个命令行界面CLI),用于与公共和私有容器注册表进行交互,管理在需要注册表身份验证时的登录,按字符串模式搜索图像仓库,以及处理本地缓存的图像。

搜索图像

我们将学习的第一个用于在多个注册表中搜索图像的命令是podman search命令。以下示例展示了如何搜索 nginx 图像:

# podman search nginx 

上述命令将生成一个输出,其中包含来自所有白名单注册表的多个条目(参见第三章运行第一个容器部分,准备你的环境 | 自定义容器注册表的搜索列表)。输出会有点笨重,包含来自未知和不可靠仓库的多个条目。

一般来说,podman search命令接受以下模式:

podman search [options] TERM

在这里,TERM是搜索参数。搜索结果的输出包含以下字段:

  • INDEX:索引该图像的注册表

  • NAME:图像的完整名称,包括注册表名称和相关的命名空间

  • DESCRIPTION:图像角色的简短描述

  • STARS:用户评分的星级数(仅在支持此功能的注册表中可用,如docker.io

  • OFFICIAL:一个布尔值,用于指定图像是否为官方图像

  • AUTOMATED:如果图像是自动化的,该字段设置为OK

    重要说明

    永远不要信任未知的仓库,总是优先选择官方图像。拉取来自小众项目的图像时,尝试在运行之前了解图像的内容。记住,攻击者可能会在容器内部隐藏恶意代码。

    即使是受信任的仓库,也有可能在某些情况下被攻破。在企业场景中,实施图像签名验证以避免图像篡改。

可以对搜索应用过滤器并细化输出。例如,为了精确搜索并仅打印官方镜像,我们可以添加以下过滤选项,仅打印带有is-official标志的镜像:

# podman search nginx --filter=is-official

此命令将打印一行指向docker.io/library/nginx:latest。这个官方镜像由 nginx 社区维护,可以更放心地使用。

用户可以调整命令的输出格式。以下示例演示了如何仅打印镜像注册表和镜像名称:

# podman search fedora  \
  --filter is-official \
  --format "table {{.Index}} {{.Name}}"

INDEX       NAME
docker.io   docker.io/library/fedora

输出的镜像名称有一个标准的命名模式,值得详细描述。标准格式如下所示:

<registry>[:<port>]/[<namespace>/]<name>:<tag>

让我们详细描述前面提到的字段,如下所示:

  • registry:这包含存储镜像的注册表。在我们的示例中,nginx 镜像存储在docker.io公共注册表中。可选地,也可以为注册表指定自定义端口号。默认情况下,注册表会暴露5000 传输控制协议 (TCP) 端口。

  • namespace:此字段提供了一个层次结构,有助于区分镜像的上下文和提供者。命名空间可以代表父组织、仓库所有者的用户名或镜像角色。

  • name:这包含存储所有标签的私有/公共镜像仓库的名称。通常将其称为应用程序名称(即 nginx)。

  • tag:每个存储在注册表中的镜像都有一个唯一的标签,映射到:latest标签时,镜像名称中可以省略该标签。

通用搜索默认隐藏镜像标签。要显示给定仓库的所有可用标签,我们可以对给定的镜像名称使用–list-tags选项,如下所示:

# podman search quay.io/prometheus/prometheus --list-tags
NAME                           TAGquay.io/prometheus/prometheus  v2.5.0
quay.io/prometheus/prometheus  v2.6.0-rc.0
quay.io/prometheus/prometheus  v2.6.0-rc.1
quay.io/prometheus/prometheus  v2.6.0
quay.io/prometheus/prometheus  v2.6.1
quay.io/prometheus/prometheus  v2.7.0-rc.0
quay.io/prometheus/prometheus  v2.7.0-rc.1
quay.io/prometheus/prometheus  v2.7.0-rc.2
quay.io/prometheus/prometheus  v2.7.0
quay.io/prometheus/prometheus  v2.7.1
[...output omitted...]

这个选项对于在注册表中查找特定镜像标签非常有用,通常与应用程序/运行时的发布版本相关联。

重要提示

使用:latest标签可能会导致镜像版本控制问题,因为它不是描述性的标签。而且,通常期望它指向最新的镜像版本。不幸的是,这并不总是正确的,因为一个未标记的镜像可能会保留latest标签,而最新推送的镜像可能具有不同的标签。是否正确应用标签由仓库维护者决定。如果仓库使用语义版本控制,最好的选择是拉取最新版本标签。

拉取和查看镜像

一旦我们找到了所需的镜像,就可以使用podman pull命令下载,如下所示:

# podman pull docker.io/library/nginx:latest

注意运行 Podman 命令的根用户。在这种情况下,我们作为 root 用户拉取镜像,其层和元数据存储在/var/lib/containers/storage路径中。

我们可以通过在标准用户的 shell 中执行相同的命令,以标准用户身份运行该命令,如下所示:

$ podman pull docker.io/library/nginx:latest

在这种情况下,镜像将被下载到用户的主目录下,路径为$HOME/.local/share/containers/storage/,并可用于运行无根容器。

用户可以使用podman images命令检查所有本地缓存的镜像,如此处所示:

# podman images
REPOSITORY                  TAG         IMAGE ID      CREATED        SIZE
docker.io/library/nginx     latest      ad4c705f24d3  2 weeks ago    138 MB
docker.io/library/fedora    latest      dce66322d647  2 months ago   184 MB
[...omitted output...]

输出显示了镜像的仓库名称、标签、镜像标识符ID)、创建日期和镜像大小。这对于保持本地存储中的镜像的最新视图,并了解哪些镜像已过时非常有用。

podman images命令还支持许多选项(可以通过执行man podman-images命令获取完整列表)。其中一个更有趣的选项是–sort,它可以用于按大小、日期、ID、仓库或标签对镜像进行排序。例如,我们可以按创建日期排序,找出最过时的镜像,如下所示:

# podman images --sort=created

另一个非常有用的选项是–all(或–a)和–quiet(或–q)选项。它们可以组合使用,打印出所有本地存储的镜像的镜像 ID,包括中间层镜像。该命令的输出将类似于以下示例:

# podman images -qa
ad4c705f24d3
a56f85702a94
b5c5125e3fee
4d7fc5917f3e
625707533167
f881f1aa4d65
96ab2a326180

在系统中列出并显示已经拉取的镜像并不是最有趣的部分!让我们在下一节中探讨如何检查镜像的配置和内容。

检查镜像的配置和内容

要检查拉取镜像的配置,podman image inspect(或更短的podman inspect)命令可以帮助我们,如此处所示:

# podman inspect docker.io/library/nginx:latest

打印的输出将是一个JavaScript 对象表示法JSON)格式的对象,包含镜像配置、架构、层、标签、注释和镜像构建历史。

图像历史显示了每一层的创建历史,对于在没有 Dockerfile 或 Containerfile 的情况下,理解图像是如何构建的非常有用。

由于输出是一个 JSON 对象,我们可以提取单个字段来收集特定数据,或者将它们用作其他命令的输入参数。

以下示例打印出创建容器时执行的命令:

# podman inspect docker.io/library/nginx:latest \
--format "{{ .Config.Cmd }}"
[nginx -g daemon off;]

请注意,格式化输出是通过 Go 模板进行管理的。

有时,镜像的检查必须超越简单的配置检查。在某些情况下,我们需要检查镜像的文件系统内容。为了实现这一点,Podman 提供了有用的podman image mount命令。

以下示例挂载该镜像并打印其挂载路径:

# podman image mount docker.io/library/nginx
/var/lib/containers/storage/overlay/ba9d21492c3939befbecd5ec32f6f1b9d564ccf8b1b279e0fb5c186e8b7 967f2/merged 

如果我们在提供的路径中运行简单的ls命令,我们将看到由其各种合并层组成的镜像文件系统,如下所示:

# ls -al /var/lib/containers/storage/overlay/ba9d21492c3939befbecd5ec32f6f1b9d564ccf8b1b279e0fb5c186e8b7 967f2/merged
total 92
dr-xr-xr-x. 1 root root 4096 Sep 25 22:30 .
drwx------. 5 root root 4096 Sep 25 22:53 ..
drwxr-xr-x. 2 root root 4096 Sep  2 02:00 bin
drwxr-xr-x. 2 root root 4096 Jun 13 12:30 boot
drwxr-xr-x. 2 root root 4096 Sep  2 02:00 dev
drwxr-xr-x. 1 root root 4096 Sep  9 20:26 docker-entrypoint.d
-rwxrwxr-x. 1 root root 1202 Sep  9 20:25 docker-entrypoint.sh
drwxr-xr-x. 1 root root 4096 Sep  9 20:26 etc
drwxr-xr-x. 2 root root 4096 Jun 13 12:30 home
drwxr-xr-x. 1 root root 4096 Sep  9 20:26 lib
drwxr-xr-x. 2 root root 4096 Sep  2 02:00 lib64
drwxr-xr-x. 2 root root 4096 Sep  2 02:00 media
drwxr-xr-x. 2 root root 4096 Sep  2 02:00 mnt
drwxr-xr-x. 2 root root 4096 Sep  2 02:00 opt
drwxr-xr-x. 2 root root 4096 Jun 13 12:30 proc
drwx------. 2 root root 4096 Sep  2 02:00 root
drwxr-xr-x. 3 root root 4096 Sep  2 02:00 run
drwxr-xr-x. 2 root root 4096 Sep  2 02:00 sbin
drwxr-xr-x. 2 root root 4096 Sep  2 02:00 srv
drwxr-xr-x. 2 root root 4096 Jun 13 12:30 sys
drwxrwxrwt. 1 root root 4096 Sep  9 20:26 tmp
drwxr-xr-x. 1 root root 4096 Sep  2 02:00 usr
drwxr-xr-x. 1 root root 4096 Sep  2 02:00 var

要卸载镜像,只需运行podman image unmount命令,如下所示:

# podman image unmount docker.io/library/nginx

以 rootless 模式挂载镜像有点不同,因为此执行模式仅支持手动挂载,mount/unmount命令将无法工作。一个解决方法是先运行podman unshare命令,它将在一个新的命名空间中执行一个新的 shell 进程,其中当前的podman mount命令可以正常工作。让我们在这里看一个示例:

$ podman unshare
# podman image mount docker.io/library/nginx:latest \
/home/<username>/.local/share/containers/storage/overlay/ba9d21492c3939befbecd5ec32f6f1b9d564ccf8b1b279e0fb5c186e8b7967 f2/merged

请注意,挂载点现在位于<username>的主目录中。

要卸载,只需运行 podman unmount命令,如下所示:

# podman image unmount docker.io/library/nginx:latest
ad4c705f24d392b982b2f0747704b1c5162e45674294d5640cca7076eba2 865d
# exit

exit命令用于退出临时的未共享命名空间。

删除镜像

要删除本地存储的镜像,我们可以使用podman rmi命令。以下示例删除之前拉取的 nginx 镜像:

# podman rmi docker.io/library/nginx:latest
Untagged: docker.io/library/nginx:latest
Deleted: ad4c705f24d392b982b2f0747704b1c5162e45674294d5640cca7 076eba2865d

同样的命令在 rootless 模式下也有效,当标准用户在其主目录本地存储中执行时。

要删除所有缓存的镜像,请使用以下示例,该示例依赖于 Shell 命令扩展来获取完整的镜像 ID 列表:

# podman rmi $(podman images -qa)

注意行首的井号符号,这告诉我们命令是以 root 身份执行的。

下一个命令删除常规用户本地缓存中的所有镜像(注意行首的美元符号):

$ podman rmi $(podman images -qa)

重要提示

podman rmi命令无法删除当前在运行容器中使用的镜像。首先,停止使用被阻塞镜像的容器,然后再运行该命令。

Podman 还提供了一种更简单的方式来清理悬挂或未使用的镜像——podman image prune命令。它不会删除正在使用中的容器的镜像,因此如果你有正在运行或已停止的容器,对应的容器镜像将不会被删除。

以下示例删除所有未使用的镜像,且无需确认:

$ sudo podman image prune -af

相同的命令适用于 rootless 模式,只删除用户主目录本地存储中的镜像,如下代码片段所示:

$ podman image prune -af

通过这个,我们已经学习了如何管理我们机器上的容器镜像。接下来,让我们学习如何处理和检查正在运行的容器。

与正在运行的容器的操作

第二章比较 Podman 和 Docker中,我们在运行你的第一个容器部分学习了如何通过基本示例运行容器,涉及在 Fedora 容器内执行 Bash 进程和httpd服务器,这对于学习如何将容器暴露到外部也很有帮助。

接下来,我们将探索一组用于监控和检查正在运行的容器的命令,并获得有关它们行为的洞察。

查看和处理容器状态

让我们先运行一个简单的容器,并将其暴露在8080端口,使其能够从外部访问,如下所示:

$ podman run -d -p 8080:80 docker.io/library/nginx

上述示例在 rootless 模式下运行,但相同的操作也可以作为 root 用户应用,只需在命令前添加sudo。在这种情况下,实际上不需要以这种方式执行容器。

重要提示

Rootless 容器提供了额外的安全优势。如果恶意进程突破了容器的隔离,可能利用主机上的漏洞,那么它最多会获得启动 rootless 容器的用户的权限。

现在我们的容器已经启动并运行,并准备好提供服务,我们可以通过在本地主机上运行curl命令来测试它,应该会产生如下的超文本标记语言 (HTML) 默认输出:

$ curl localhost:8080
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and 
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>

显然,一个没有内容可提供的空 nginx 服务器是没有用的,但我们将在接下来的章节中学习如何通过使用卷或构建自定义镜像来提供自定义内容。

我们可以使用的第一个命令来检查容器状态的是podman ps。这个命令简单地打印出运行中容器的有用信息,并且可以自定义和排序输出。让我们在主机上运行此命令并查看输出,如下所示:

$ podman ps
CONTAINER ID  IMAGE                           COMMAND               CREATED         STATUS             PORTS                 NAMES
d8bbd5da64d0  docker.io/library/nginx:latest  nginx -g daemon o...  13 minutes ago  Up 13 minutes ago  0.0.0.0:8080->80/tcp  unruffled_saha

输出提供了一些关于正在运行容器的有趣信息,具体如下:

  • CONTAINER ID:每个新容器都会获得一个唯一的十六进制 ID。完整 ID 的长度为 64 个字符,podman ps输出中显示的是其中 12 个字符的简短版本。

  • IMAGE:容器使用的镜像。

  • COMMAND:容器内执行的命令。

  • CREATED:容器的创建日期。

  • STATUS:当前容器的状态。

  • PORTS:容器中打开的网络端口。当应用端口映射时,我们可以看到一个或多个主机ip:port对映射到容器端口,并带有箭头符号。例如,0.0.0.0:8080->80/tcp表示主机端口8080/tcp暴露在所有监听接口上,并映射到容器端口80/tcp

  • NAMES:容器名称。这个名称可以由用户指定,也可以由容器引擎随机生成。

    提示

    注意输出最后一列中的随机生成名称。Podman 延续了 Docker 的传统,使用名称左侧的形容词和右侧的著名科学家或黑客来生成随机名称。实际上,Podman 仍然使用相同的github.com/docker/docker/namesgenerator Docker 包,该包包含在项目的 vendor 目录中。

要获取正在运行和已停止容器的完整列表,我们可以在命令中添加–a选项。为了演示这一点,我们首先介绍podman stop命令。该命令将容器状态更改为停止状态,并向容器内运行的进程发送SIGTERM信号。如果容器没有响应,则会在 10 秒的超时后发送SIGKILL信号。

让我们尝试停止之前的容器,并通过执行以下代码检查其状态:

$ podman stop d8bbd5da64d0  
$ podman ps

这次,podman ps输出为空。这是因为容器的状态是停止的。要获取正在运行和已停止容器的完整列表,请运行以下命令:

$ podman ps –a
CONTAINER ID  IMAGE  COMMAND  CREATED   STATUS   PORT   NAMES
d8bbd5da64d0  docker.io/library/nginx:latest  nginx -g daemon o...  About a minute ago  Exited (0) About a minute ago  0.0.0.0:8080->80/tcp  unruffled_saha

注意容器的状态,它显示容器已退出,并且退出代码为0

停止的容器可以通过运行podman start命令恢复,如下所示:

$ podman start d8bbd5da64d0  

这个命令只是重新启动我们之前停止的容器。

如果我们现在再次检查容器状态,我们会看到它已经启动并正在运行,如下所示:

$ podman ps
CONTAINER ID  IMAGE  COMMAND  CREATED   STATUS   PORT   NAMES
d8bbd5da64d0  docker.io/library/nginx:latest  nginx -g daemon o...  8 minutes ago  Up 1 second ago  0.0.0.0:8080->80/tcp  unruffled_saha

Podman 在容器处于停止状态时会保留容器配置、存储和元数据。无论如何,当我们恢复容器时,容器内部会启动一个新进程。

更多选项,请参见相关的man podman-start

如果我们只需要重启一个正在运行的容器,可以使用podman restart命令,如下所示:

$ podman restart <Container_ID_or_Name>

这个命令的作用是立即重启容器内的进程,并赋予其新的进程 IDPID)。

podman start命令还可以用于启动之前创建但未运行的容器。要创建一个容器而不启动它,可以使用podman create命令。以下示例创建一个容器,但不启动它:

$ podman create -p 8080:80 docker.io/library/nginx

要启动它,请运行podman start命令,后跟已创建容器的 ID 或名称,如下所示:

$ podman start <Container_ID_or_Name>

这个命令在准备一个不运行的环境或挂载容器文件系统时非常有用,如下面的例子所示:

$ podman unshare
$ podman container mount <Container_ID_or_Name>
/home/<username>/.local/share/containers/storage/overlay/bf9d8df299436d80dece200a23e1b8b957f987a254a656ef94cdc5666982 3b5c/merged

现在让我们介绍一个非常常用的命令:podman rm。顾名思义,它用于从主机中删除容器。默认情况下,它会删除已停止的容器,但可以通过–f选项强制删除正在运行的容器。

使用之前示例中的容器,如果我们再次停止它并发出podman rm命令,如下面的代码片段所示,所有容器的存储、配置和元数据都会被丢弃:

$ podman stop d8bbd5da64d0
$ podman rm d8bbd5da64d0

如果我们现在再次运行podman ps命令,即使使用了–a选项,我们将获得一个空列表,如下所示:

$ podman ps –a
CONTAINER ID  IMAGE       COMMAND     CREATED     STATUS      PORTS       NAMES

欲了解更多详细信息,请查看命令手册页(man podman-rm)。

有时,这就像处理镜像一样,使用–q选项仅打印容器 ID 是很有用的。这个选项与–a选项结合使用时,可以打印主机上所有已停止和正在运行的容器列表。让我们在这里尝试另一个例子:

$ for i in {1..5}; do podman run -d docker.io/library/nginx; done

有趣的是,我们已经使用了一个 shell 循环来启动五个相同的容器,这次没有任何端口映射—仅是纯粹的 nginx 容器。我们可以通过以下命令检查它们的 ID:

$ podman ps –qa
b38ebfed5921
6204efc6d6b2
762967d87657
269f1affb699
1161072ec559

如何快速停止并删除所有正在运行的容器?我们可以利用 shell 扩展将其与其他命令结合,达到预期结果。Shell 扩展是一个强大的工具,它在圆括号内运行命令,并将输出字符串作为参数传递给外部命令,如下面的代码片段所示:

$ podman stop $(podman ps -qa)
$ podman rm $(podman ps -qa)

这两个命令停止了所有正在运行的容器,通过它们的 ID 进行标识,并将它们从主机上删除。

podman ps命令允许用户通过应用特定的过滤器来精细化输出。所有适用的过滤器的完整列表可以在podman-ps手册页中找到。一个简单而有用的应用是状态过滤器,它使用户能够仅打印处于特定状态的容器。可能的状态有createdexitedpausedrunningunknown

以下示例仅打印处于exited状态的容器:

$ podman ps --filter status=exited

同样,我们可以利用 shell 扩展的强大功能,仅删除已退出的容器,如下所示:

$ podman rm $(podman ps -qa --filter status=exited)

通过这里展示的更易记住的podman container prune命令,也可以获得类似的结果,该命令会移除(清理)主机上所有停止的容器:

$ podman container prune

排序是列出容器时生成有序输出的另一个有用选项。以下示例展示了如何按容器 ID 进行排序:

$ podman ps -q --sort id

podman ps命令支持使用 Go 模板进行格式化,以生成自定义输出。下一个示例仅打印容器 ID 和容器内执行的命令:

$ podman ps -a --format "{{.ID}}  {{.Command}}" --no-trunc 

另外,请注意,--no-trunc选项已添加,以避免截断命令输出。虽然这不是强制性的,但在容器内部执行长命令时,它是非常有用的。

如果我们仅希望提取正在运行的容器内部进程的主机 PID,可以运行以下示例:

$ podman ps --format "{{ .Pid }}"

如果我们需要查找关于隔离命名空间的信息,podman ps可以打印正在运行容器的克隆命名空间的详细信息。这是进行高级故障排除和检查的一个有用起点。你可以看到这里执行的命令:

$ podman ps --namespace
CONTAINER ID  NAMES                 PID         CGROUPNS    IPC         MNT         NET         PIDNS       USERNS      UTS
f2666ed4a46a  unruffled_hofstadter  437764      4026533088  4026533086  4026533083  4026532948  4026533087  4026532973  4026533085

本小节介绍了许多常见操作,用于控制和查看容器的状态。在下一节中,我们将学习如何暂停和恢复正在运行的容器。

暂停和恢复容器

本小节介绍了podman pausepodman unpause命令。尽管这是一个与容器状态处理相关的部分,但了解 Podman 和容器运行时如何利用控制组cgroups)来实现特定目的,仍然很有趣。

简单来说,pauseunpause命令的目的是暂停和恢复正在运行容器中的进程。现在,读者可能需要澄清pausestop命令之间的区别。

podman stop命令仅向容器中的父进程发送SIGTERM/SIGKILL信号时,podman pause命令则使用 cgroups 暂停进程而不终止它。当容器被恢复时,相同的进程将透明地继续运行。

提示

pause/unpause的低级逻辑是在容器运行时实现的——对于最为好奇的人,这是写作时在crun中的实现:

https://github.com/containers/crun/blob/7ef74c9330033cb884507c28fd8c267861486633/src/libcrun/cgroup.c#L1894-L1936

以下示例演示了podman pausepodman unpause命令。首先,让我们启动一个 Fedora 容器,该容器每 2 秒在一个无限循环中打印日期和时间字符串,如下所示:

$ podman run --name timer docker.io/library/fedora bash -c "while true; do echo $(date); sleep 2; done"

我们故意让容器在一个窗口中保持运行,并打开一个新的窗口/标签页来管理其状态。在发出pause命令之前,让我们通过执行以下代码检查 PID:

$ podman ps --format "{{ .Pid }}" --filter name=timer
816807

现在,让我们使用以下命令暂停正在运行的容器:

$ podman pause timer

如果我们回到timer容器,我们会看到输出刚刚被暂停,但容器并没有退出。此处看到的unpause操作将使其恢复:

$ podman unpause timer

unpause 操作之后,timer 容器将再次开始打印日期输出。查看此处的 PID,正如预期的那样,什么都没有改变:

$ podman ps --format "{{ .Pid }}" --filter name=timer
816807

我们可以检查暂停/未暂停容器的 cgroups 状态。在第三个标签页中,打开一个具有 root shell 的终端,并在替换为正确的容器 ID 后访问 cgroupfs 控制器层次结构,如下所示:

$ sudo –i
$ cd /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/user.slice/libpod-<CONTAINER_ID>.scope/container

现在,查看 cgroup.freeze 文件的内容。该文件包含一个布尔值,其状态会随着我们暂停/恢复容器的操作从 0 变为 1,反之亦然。尝试再次暂停和恢复容器以测试变化。

清理提示

由于 echo 循环是通过 bash -c 命令发出的,因此我们需要向进程发送 SIGKILL 信号。为此,我们可以停止容器并等待 10 秒超时,或者直接运行 podman kill 命令,如下所示:

$ podman kill timer

在本小节中,我们详细介绍了用于监视和修改容器状态的最常见命令。现在我们可以继续检查正在运行的容器内的进程。

检查容器内的进程

当容器正在运行时,容器内的进程在命名空间级别上被隔离,但用户仍然对正在运行的进程拥有完全控制权,并且可以检查它们的行为。进程检查有很多复杂的层次,但 Podman 提供了可以加速此任务的工具。

我们从 podman top 命令开始:它提供了容器内进程的完整视图。以下示例显示了在 nginx 容器内运行的进程:

$ podman top  f2666ed4a46a
USER        PID         PPID        %CPU        ELAPSED          TTY         TIME        COMMAND
root        1           0           0.000       3m26.540290427s  ?           0s          nginx: master process nginx -g daemon off; 
nginx       26          1           0.000       3m26.540547429s  ?           0s          nginx: worker process 
nginx       27          1           0.000       3m26.540788803s  ?           0s          nginx: worker process 
nginx       28          1           0.000       3m26.540914386s  ?           0s          nginx: worker process 
nginx       29          1           0.000       3m26.541040023s  ?           0s          nginx: worker process 
nginx       30          1           0.000       3m26.541161213s  ?           0s          nginx: worker process 
nginx       31          1           0.000       3m26.541297546s  ?           0s          nginx: worker process 
nginx       32          1           0.000       3m26.54141773s   ?           0s          nginx: worker process 
nginx       33          1           0.000       3m26.541564289s  ?           0s          nginx: worker process 
nginx       34          1           0.000       3m26.541685475s  ?           0s          nginx: worker process 
nginx       35          1           0.000       3m26.541808977s  ?           0s          nginx: worker process 
nginx       36          1           0.000       3m26.541932099s  ?           0s          nginx: worker process 
nginx       37          1           0.000       3m26.54205111s   ?           0s          nginx: worker process

结果非常类似于 ps 命令的输出,而不是由 Linux top 命令生成的交互式输出。

可以对输出应用自定义格式。以下示例只打印 PID、命令和参数:

$ podman top f2666ed4a46a pid comm args
PID         COMMAND     COMMAND
1           nginx       nginx: master process nginx -g daemon off; 
26          nginx       nginx: worker process 
27          nginx       nginx: worker process 
28          nginx       nginx: worker process 
29          nginx       nginx: worker process 
30          nginx       nginx: worker process 
31          nginx       nginx: worker process 
32          nginx       nginx: worker process 
33          nginx       nginx: worker process 
34          nginx       nginx: worker process 
35          nginx       nginx: worker process 
36          nginx       nginx: worker process 
37          nginx       nginx: worker process

我们可能需要更详细地检查容器进程。正如我们在第一章《容器技术简介》中讨论的那样,一旦启动一个全新的容器,它将从 0 开始分配 PID,同时在后台,容器引擎将该容器的 PID 与主机上的真实 PID 映射。因此,我们可以使用 podman ps --namespace 命令的输出,提取给定容器在主机上的原始 PID。有了这些信息,我们就可以进行高级分析。以下示例展示了如何附加 strace 命令,该命令用于检查进程的 系统调用 (syscalls),到容器内运行的进程:

$ sudo strace –p <PID>

strace 命令的使用细节超出了本书的范围。有关更高级的示例以及命令选项的更深入解释,请参见 man strace

另一个可以轻松应用于容器内运行的进程的有用命令是 pidstat。一旦我们获得了 PID,就可以通过以下方式检查资源使用情况:

$ pidstat –p <PID> [<interval> <count>]

最后的整数依次表示命令执行间隔和必须打印使用情况统计的次数。详见man pidstat以获取更多使用选项。

当容器中的进程变得无响应时,可以使用podman kill命令处理其突然终止。默认情况下,它向容器内部的进程发送SIGKILL信号。以下示例创建一个httpd容器,然后将其终止:

$ podman run --name custom-webserver -d docker.io/library/httpd
$ podman kill custom-webserver

我们可以使用--signal选项选择性地发送自定义信号(如SIGTERMSIGHUP)。请注意,终止的容器不会从主机中移除,而是继续存在、停止并处于退出状态。

第十章故障排除和监控容器中,我们将再次处理容器故障排除,并学习如何使用nsenter等高级工具来检查容器进程。现在我们继续进行基本容器统计命令,这些命令对于监视系统中所有运行容器的整体资源使用非常有用。

监控容器统计信息

当多个容器在同一主机上运行时,监控使用podman stats命令至关重要,如下所示:

$ podman stats

如果没有任何选项,该命令将打开类似 top 的自刷新窗口,显示所有运行容器的统计信息。默认打印的值列于此处:

  • ID:运行中的容器 ID

  • NAME:运行中的容器名称

  • CPU %:总 CPU 使用率百分比

  • MEM USAGE / LIMIT:相对于给定限制(由系统能力或 cgroups 驱动的限制)的内存使用

  • MEM %:内存使用率百分比

  • NET IO:网络输入/输出(I/O)操作

  • BLOCK IO:磁盘 I/O 操作

  • PIDS:容器内的 PID 数量

  • CPU TIME:总消耗的 CPU 时间

  • AVG CPU %:平均 CPU 使用率百分比

如果需要重定向,可以使用--no-stream选项避免流式自刷新输出,如下所示:

$ podman stats --no-stream

无论如何,这种类型的静态输出并不适合解析或摄入。更好的方法是应用 JSON 或 Go 模板格式化程序。以下示例以 JSON 格式打印统计信息:

$ podman stats --format=json
[
 {
  "id": "e263f68bbb83",
  "name": "infallible_sinoussi",
  "cpu_time": "33.518ms",
  "cpu_percent": "2.05%",
  "avg_cpu": "2.05%",
  "mem_usage": "19.3MB / 33.38GB",
  "mem_percent": "0.06%",
  "net_io": "-- / --",
  "block_io": "-- / --",
  "pids": "13"
 }
]

类似地,可以使用 Go 模板定制输出字段。以下示例仅打印容器 ID、CPU 使用率、总内存使用量(以字节为单位)和进程 ID:

$ podman stats -a --no-stream --format "{{ .ID }} {{ .CPUPerc }} {{ .MemUsageBytes }} {{ .PIDs }}"

在本节中,我们学习了如何监视运行中的容器及其隔离的进程。下一节将展示如何检查容器配置以进行分析和故障排除。

检查容器信息

运行中的容器暴露一组可供使用的配置数据和元数据。Podman 实现了podman inspect命令以打印所有容器配置和运行时信息。在其最简单形式下,我们只需传递容器 ID 或名称,如下所示:

$ podman inspect <Container_ID_or_Name>

该命令打印出包含所有容器配置的 JSON 输出。为了节省空间,我们将在这里列出一些最显著的字段:

  • Path:容器入口点路径。当我们分析 Dockerfile 时,我们将进一步探讨入口点。

  • Args:传递给入口点的参数。

  • State:容器的当前状态,包括关键的执行 PID、公共 PID、OCI 版本和健康检查状态等信息。

  • Image:用于运行容器的镜像 ID。

  • Name:容器名称。

  • MountLabel:容器的挂载标签,适用于增强型安全 LinuxSELinux)。

  • ProcessLabel:容器进程标签,适用于 SELinux。

  • EffectiveCaps:应用于容器的有效能力。

  • GraphDriver:存储驱动类型(默认是 overlayfs)以及覆盖的上层、下层和合并目录的列表。

  • Mounts:容器中的实际绑定挂载。

  • NetworkSettings:容器的整体网络设置,包括其内部互联网协议IP)地址、暴露的端口和端口映射。

  • Config:容器的运行时配置,包括环境变量、主机名、命令、工作目录、标签和注释。

  • HostConfig:主机配置,包括 cgroups 配额、网络模式和能力。

这是一大堆信息,大多数时候这些信息对于我们的需求来说过于冗长。当我们需要提取特定字段时,可以使用 --format 选项仅打印所选字段。以下示例仅打印容器内执行进程的主机绑定 PID:

$ podman inspect <ID or Name> --format "{{ .State.Pid }}"

结果采用 Go 模板格式。这使得我们可以灵活地自定义输出字符串。

podman inspect 命令对于理解容器引擎的行为以及在故障排除过程中获取有用信息也非常有帮助。

例如,当启动容器时,我们可以了解到 resolv.conf 文件是从在 {{ .ResolvConfPath }} 键中定义的路径挂载到容器内的。当容器在 rootless 模式下执行时,目标路径是 /run/user/<UID>/containers/overlay-containers/<Container_ID>/userdata/resolv.conf,在 rootful 模式下则是 /var/run/containers/storage/overlay-containers/<Container_ID>/userdata/resolv.conf

另一个有趣的信息是 overlayfs 管理的所有合并层的列表。让我们尝试运行一个新的容器,这次使用 rootful 模式,并查找有关合并层的信息,操作如下:

# podman run --name logger -d docker.io/library/fedora bash -c "while true; do echo test >> /tmp/test.log; sleep 5; done"

该容器运行一个简单的循环,每 5 秒将一个字符串写入文本文件。现在,让我们运行一个 podman inspect 命令,了解 MergedDir 的信息,这个目录是所有层通过 overlayfs 合并的地方。代码示例如下:

# podman inspect logger --format "{{ .GraphDriver.Data.MergedDir  }}"
/var/lib/containers/storage/overlay/27d89046485db7c775b108a80072eafdf9aa63d14ee1205946d746 23fc195314/merged

在这个目录内,我们可以找到 /tmp/test.log 文件,具体如下所示:

# cat /var/lib/containers/storage/overlay/27d89046485db7c775b108a80072eafdf9aa63d14ee1205946d746 23fc195314/merged/tmp/test.log
test
test
test
test
test
[...]

我们可以深入探讨——LowerDir目录包含基础镜像层的列表,如下代码片段所示:

# podman inspect logger \
--format "{{ .GraphDriver.Data.LowerDir}}"
 /var/lib/containers/storage/overlay/4c85102d65a59c6d478bfe6bc0bf32e8c79d9772689f62451c7196 380675d4af/diff

在这个示例中,基础镜像由一个层组成。我们会在这里找到日志文件吗?让我们来看看:

# cat /var/lib/containers/storage/overlay/4c85102d65a59c6d478bfe6bc0bf32e8c79d9772689f62451c7196 380675d4af/diff/tmp/test.log
cat: /var/lib/containers/storage/overlay/4c85102d65a59c6d478bfe6bc0bf32e8c79d9772689f62451c7196 380675d4af/diff/tmp/test.log: No such file or directory

我们在这一层中缺少日志文件。这是因为LowerDir目录是只读的,代表了只读镜像层。它与UpperDir目录合并,后者是容器的读写层。通过podman inspect,我们可以找出它所在的位置,如下所示:

# podman inspect logger --format "{{ .GraphDriver.Data.UpperDir }}"
/var/lib/containers/storage/overlay/27d89046485db7c775b108a80072eafdf9aa63d14ee1205946d746 23fc195314/diff

输出目录将仅包含自容器启动以来写入的一堆文件和目录,其中包括/tmp/test.log文件,如下所示:

# cat /var/lib/containers/storage/overlay/27d89046485db7c775b108a80072eafdf9aa63d14ee1205946d746 23fc195314/diff/tmp/test.log
test
test
test
test
test
[...]

我们现在可以通过运行以下命令来停止并删除日志容器:

# podman stop logger && podman rm logger

这个示例是为了预示将在第五章中讨论的容器存储话题,为容器的数据实现存储overlayfs机制,包含下层、上层和合并目录的概念,将被更详细地分析。

在本节中,我们学习了如何检查运行中的容器并收集运行时信息和配置。下一节将涵盖捕获容器日志的最佳实践。

捕获容器日志

正如本章前面所描述的,容器由一个或多个可能会失败的进程组成,这些进程会打印错误并在日志文件中描述其当前状态。那么,这些日志存储在哪里呢?

当然,容器中的进程也可以将日志消息写入临时文件系统中的某个文件,而该文件系统是容器引擎为它提供的(如果有的话)。但是,读写文件系统或运行中的容器中的任何权限限制又该怎么办呢?

容器暴露相关日志的最佳实践实际上是利用标准流的使用:STDOUT)和STDERR)。

需要知道的是

标准流是与操作系统中正在运行的进程互联的通信通道。当程序通过交互式 Shell 运行时,这些流会直接连接到用户的终端,以便在终端和进程之间传输输入、输出和错误信息,反之亦然。

根据我们运行全新容器时所使用的选项,Podman 将适当操作,将STDINSTDOUTSTDERR标准流连接到本地文件,用于存储日志。

第三章中,运行第一个容器,我们看到如何在后台运行容器,并从运行中的容器中分离。我们使用-d选项通过podman run命令以分离模式启动容器,如下所示:

$ podman run -d -i -t registry.fedoraproject.org/f29/httpd

使用之前的命令,我们指示 Podman 以分离模式(-d)启动容器,并将伪终端附加到STDIN流(-t),即使还没有附加终端,也保持标准输入流打开(-i)。

Podman 的标准行为是附加到STDOUTSTDERR流,并将任何容器发布的数据存储在主机文件系统中的日志文件中。

如果我们作为root用户使用 Podman 工作,可以通过执行以下步骤查看主机系统上的日志文件:

  1. 首先,我们需要启动容器并记录 Podman 返回的 ID,或者请求 Podman 列出容器并记录它们的 ID。实现此操作的代码如下所示:

    # podman run -d -i -t registry.fedoraproject.org/f29/httpd
    c6afe22eac7c22c35a303d5fed45bc1b6442a4cec4a9060f392362bc
    4cecb25d
    # .
    CONTAINER ID                                                      IMAGE                                        COMMAND             CREATED         STATUS             PORTS       NAMES
    c6afe22eac7c22c35a303d5fed45bc1b6442a4cec4a9060f392362bc4 cecb25d  registry.fedoraproject.org/f29/httpd:latest  /usr/bin/run-httpd  27 minutes ago  Up 27 minutes ago              gifted_allen
    
  2. 之后,我们可以查看/var/lib/containers/storage/overlay-containers/目录,并搜索与我们容器 ID 匹配的文件夹,具体如下:

    # cd /var/lib/containers/storage/overlay-containers/c6afe22eac7c22c35a303d5fed45bc1b6442a4cec4a9060f392362bc4 cecb25d/
    
  3. 最后,我们可以通过查看userdata目录下名为ctr.log的文件来检查正在运行的容器的日志,具体如下:

    # cat userdata/ctr.log 
    2021-09-27T15:42:46.925288013+00:00 stdout P => sourcing 10-set-mpm.sh ...
    2021-09-27T15:42:46.925604590+00:00 stdout F 
    2021-09-27T15:42:46.926882725+00:00 stdout P => sourcing 20-copy-config.sh ...
    2021-09-27T15:42:46.926920142+00:00 stdout F 
    2021-09-27T15:42:46.929405654+00:00 stdout P => sourcing 40-ssl-certs.sh ...
    2021-09-27T15:42:46.929460531+00:00 stdout F 
    2021-09-27T15:42:46.987174441+00:00 stdout P AH00558: httpd: Could not reliably determine the server's fully qualified domain name, using 10.88.0.9\. Set the 'ServerName' directive globally to suppress this message
    2021-09-27T15:42:46.987242961+00:00 stdout F 
    2021-09-27T15:42:46.996989350+00:00 stdout F [Mon Sep 27 15:42:46.996748 2021] [ssl:warn] [pid 1:tid 139708367605120] AH01882: Init: this version of mod_ssl was compiled against a newer library (OpenSSL 1.1.1b FIPS  26 Feb 2019, version currently loaded is OpenSSL 1.1.1 FIPS  11 Sep 2018) - may result in undefined or erroneous behavior
    ...
    2021-09-27T15:42:47.101066096+00:00 stdout F [Mon Sep 27 15:42:47.099445 2021] [core:notice] [pid 1:tid 139708367605120] AH00094: Command line: 'httpd -D FOREGROUND'
    

我们刚刚发现了 Podman 保存所有容器日志的秘密位置!

请注意,我们刚才介绍的过程只有在containers.conf文件中的log_driver字段设置为k8s-file值时才能正常工作。例如,在 Fedora Linux 发行版从版本 35 开始,维护者决定将k8s-file更改为journald。在这种情况下,您可以直接使用journalctl命令行工具查找日志。

如果您想查看默认的log_driver字段,可以查看以下路径:

# grep log_driver /usr/share/containers/containers.conf

这是否意味着每次需要分析容器的日志时,都需要执行整个复杂的过程?当然不是!

Podman 有一个内建的podman logs命令,可以轻松发现、抓取并打印最新的容器日志。考虑到前面的例子,我们可以通过执行以下命令轻松检查正在运行的容器的日志:

# podman logs c6afe22eac7c22c35a303d5fed45bc1b6442a4cec4a9060f 392362bc4cecb25d
=> sourcing 10-set-mpm.sh ...
=> sourcing 20-copy-config.sh ...
=> sourcing 40-ssl-certs.sh ...
AH00558: httpd: Could not reliably determine the server's fully qualified domain name, using 10.88.0.9\. Set the 'ServerName' directive globally to suppress this message
[Mon Sep 27 15:42:46.996748 2021] [ssl:warn] [pid 1:tid 13970 8367605120] AH01882: Init: this version of mod_ssl was compiled  against a newer library (OpenSSL 1.1.1b FIPS  26 Feb 2019, version currently loaded is OpenSSL 1.1.1 FIPS  11 Sep 2018) - may result in undefined or erroneous behavior
...
[Mon Sep 27 15:42:47.099445 2021] [core:notice] [pid 1:tid 139708367605120] AH00094: Command line: 'httpd -D FOREGROUND'

我们还可以获取正在运行的容器的短 ID,并将此 ID 传递给podman logs命令,具体如下:

# podman ps
CONTAINER ID  IMAGE                                        COMMAND               CREATED         STATUS             PORTS       NAMES
c6afe22eac7c  registry.fedoraproject.org/f29/httpd:latest  /usr/bin/run-http...  40 minutes ago  Up 40 minutes ago              gifted_allen
# podman logs --tail 2 c6afe22eac7c
[Mon Sep 27 15:42:47.099403 2021] [mpm_event:notice] [pid 1:tid 139708367605120] AH00489: Apache/2.4.39 (Fedora) OpenSSL/1.1.1 configured -- resuming normal operations
[Mon Sep 27 15:42:47.099445 2021] [core:notice] [pid 1:tid 1397 08367605120] AH00094: Command line: 'httpd -D FOREGROUND'

在之前的命令中,我们还使用了podman logs命令的一个很棒的选项:--tail选项,它允许我们仅输出容器日志中最新需要的行。在我们的例子中,我们请求了最新的两行。

正如我们在本节前面所看到的,Podman 将容器日志保存到主机文件系统中。默认情况下,这些文件没有大小限制,因此可能会发生,对于长时间运行并产生大量日志的容器,这些文件可能会变得非常大。

因此,我们通常谈论日志和日志文件时,一个重要的配置参数可以通过 Podman 全局配置文件来帮助减少日志文件的大小,该文件位于以下位置:/etc/containers/containers.conf

如果这个配置文件丢失,你可以轻松创建一个新的文件,插入以下几行来应用配置:

# vim /etc/containers/containers.conf
[containers]
log_size_max=10000000

通过之前的配置,我们将所有未来运行容器的日志文件限制为 10 兆字节MB)。如果你有一些正在运行的容器,必须重启它们以应用此新配置。

现在我们准备好进入下一部分,在那里我们将发现另一个有用的命令。

在运行中的容器内执行进程

第二章Podman 无守护进程架构 部分中,我们讨论了 Podman(与任何其他容器引擎一样)利用 Linux 命名空间功能来正确地将运行中的容器相互隔离,并且与操作系统主机隔离。

因此,仅仅因为 Podman 为每个运行中的容器创建了一个全新的命名空间,我们就不应该感到惊讶,我们可以附加到同一个正在运行的容器的 Linux 命名空间,像在完整操作环境中一样执行其他进程。

Podman 允许我们通过 podman exec 命令在运行中的容器内执行一个进程。

一旦执行,此命令将内部查找目标运行容器所附加的正确 Linux 命名空间。在找到该命名空间后,Podman 将执行传递给 podman exec 命令的相应进程,并将其附加到目标 Linux 命名空间。最终的进程将处于与原始进程伴随的相同环境中,并能够与其交互。

为了理解这个在实践中的工作原理,我们可以考虑以下示例:我们将首先运行一个容器,然后在现有进程旁边执行一个新进程:

# podman run -d -i -t registry.fedoraproject.org/f29/httpd
47fae73e4811a56d799f258c85bc50262901bec2f9a9cab19c01af89713 a1248
# podman exec -ti 47fae73e4811a56d799f258c85bc50262901bec2f9a9cab19c01af89713 a1248 /bin/bash
bash-4.4$ ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START    TIME COMMAND
default        1  0.6  0.6  20292 13664 pts/0    Ss+  13:37    0:00 httpd -D FOREGROUND
...

如你从之前的命令中看到的那样,我们获取了 Podman 提供的容器 ID,当容器启动后,我们将其作为参数传递给 podman exec 命令。

podman exec 命令对于故障排除、测试和与现有容器一起工作非常有用。在前面的示例中,我们附加了一个交互式终端,运行了 Bash 控制台,并启动了 ps 命令来检查当前 Linux 命名空间中可用的运行进程。

podman exec 命令有许多可用选项,类似于 podman run 命令提供的选项。如你从前面的示例中看到的那样,我们使用了一个选项来获取附加到 STDIN 流的伪终端(-t),即使没有终端附加时也保持标准输入流开启(-i)。

要了解更多可用选项,我们可以查看相应命令的手册,如下所示:

# man podman exec

我们在容器管理的旅程中不断前进,在下一节中,我们将进一步了解 Podman 提供的一些功能,这些功能可以在 Kubernetes 容器编排世界中启用容器化工作负载。

在 pod 中运行容器

如我们在第二章Docker 与 Podman 的主要区别部分中提到的,比较 Podman 和 Docker,Podman 提供了轻松开始采用事实上的容器编排器 Kubernetes(有时也称为k8s)的一些基本概念的能力。

pod 的概念是随着 Kubernetes 引入的,它代表了 Kubernetes 集群中的最小执行单元。使用 Podman,用户可以轻松创建空的 pod,然后在其中运行容器。

将两个或更多容器组合到一个 pod 中可以带来许多好处,如下所示:

  • 共享相同的网络命名空间,包括 IP 地址

  • 共享相同的存储卷以存储持久化数据

  • 共享相同的配置

此外,将两个或更多容器放在同一个 pod 中,实际上会使它们共享相同的进程间通信IPC)Linux 命名空间。这对于需要通过共享内存进行互相通信的应用程序非常有用。

创建 pod 并开始使用它的最简单方法是使用以下命令:

# podman pod create --name myhttp
3950703adb04c6bca7f83619ea28c650f9db37fd0060c1e263cf7ea34 dbc8dad
# podman pod ps
POD ID        NAME        STATUS      CREATED        INFRA ID       # OF CONTAINERS
3950703adb04  myhttp      Created     6 seconds ago  1bdc82 e77ba2  1

如前所示,我们创建了一个名为myhttp的新 pod,然后检查了主机系统上 pod 的状态:只有一个 pod 处于created状态。

我们现在可以如下启动 pod 并查看会发生什么:

# podman pod start myhttp
3950703adb04c6bca7f83619ea28c650f9db37fd0060c1e263cf7ea34 dbc8dad
# podman pod ps
POD ID        NAME        STATUS      CREATED             INFRA ID      # OF CONTAINERS
3950703adb04  myhttp      Running     About a minute ago  1bdc82e77ba2  1

pod 现在正在运行,但 Podman 实际上在运行什么呢?我们创建了一个没有容器的空 pod!让我们通过执行podman ps命令来看一下正在运行的容器,如下所示:

# podman ps
CONTAINER ID  IMAGE                 COMMAND     CREATED             STATUS            PORTS       NAMES
1bdc82e77ba2  k8s.gcr.io/pause:3.5              About a minute ago  Up 6 seconds ago              3950703adb04-infra

podman ps命令显示了一个名为pause的镜像正在运行的容器。Podman 默认将此容器作为infra容器运行。这种类型的容器什么也不做——它仅仅持有命名空间并允许容器引擎连接到 pod 内的任何其他正在运行的容器。

在揭开这个特殊容器在我们 pods 中的角色后,我们现在可以简要地了解启动一个多容器 pod 所需的步骤。

首先,让我们从在之前示例中创建的现有 pod 内部运行一个新容器开始,如下所示:

# podman run --pod myhttp -d -i -t registry.fedoraproject.org/f29/httpd
Cb75e65f10f6dc37c799a3150c1b9675e74d66d8e298a8d19eadfa125d ffdc53

然后,我们可以检查现有 pod 是否更新了它所包含的容器数量,如下所示的代码片段所示:

# podman pod ps
POD ID        NAME        STATUS      CREATED         INFRA ID       # OF CONTAINERS
3950703adb04  myhttp      Running     21 minutes ago  1bdc82e77ba2  2

最后,我们可以要求 Podman 列出正在运行的容器及其相关的 pod 名称,如下所示:

# podman ps -p
CONTAINER ID  IMAGE                                        COMMAND               CREATED         STATUS             PORTS       NAMES                POD ID        PODNAME
1bdc82e77ba2  k8s.gcr.io/pause:3.5                                               22 minutes ago  Up 20 minutes ago              3950703adb04-infra   3950703adb04  myhttp
cb75e65f10f6  registry.fedoraproject.org/f29/httpd:latest  /usr/bin/run-http...  4 minutes ago   Up 4 minutes ago               determined_driscoll  3950703adb04  myhttp

如你所见,运行的两个容器都与名为myhttp的 pod 相关联!

重要提示

请考虑在完成本章中的所有示例后定期清理实验环境。这可以帮助你节省资源,并避免在进行下一章示例时出现任何错误。为此,你可以参考本书 GitHub 仓库中 AdditionalMaterial 文件夹中的代码:https://github.com/PacktPublishing/Podman-for-DevOps/tree/main/AdditionalMaterial。

采用相同的方式,我们可以将越来越多的容器添加到同一个 pod 中,让它们共享之前描述的所有数据。

请注意,将容器放置在同一个 pod 中在某些情况下可能是有益的,但这对于容器技术来说是一个反模式。事实上,正如之前所提到的,Kubernetes 将pod视为在分布式节点上运行的最小计算单元。这意味着,一旦你将两个或更多容器组合在同一个 pod 中,它们将在同一节点上一起执行,而编排器无法将它们的工作负载平衡或分配到多台机器上。

我们将在接下来的章节中进一步探索 Podman 的功能,这些功能能帮助你通过 Kubernetes 进入容器编排的世界!

总结

在本章中,我们开始积累管理容器的经验,从容器镜像开始,然后使用运行中的容器。一旦我们的容器启动运行,我们还探讨了在 Podman 中可用的各种命令,用于检查日志、检查容器的状态和故障排除。监控和管理运行中的容器是任何容器管理员非常重要的操作。最后,我们还简要了解了 Podman 中与 Kubernetes 相关的概念,这些概念让我们可以将两个或更多容器组合在同一个 Linux 命名空间中。我们刚刚讲解的所有概念和示例将帮助我们开始作为容器技术的系统管理员的经验。

我们现在准备在下一章中探讨另一个重要主题:管理我们的容器存储!

第五章:为容器的数据实现存储

在之前的章节中,我们探讨了如何使用 Podman 运行和管理容器,但我们很快会意识到,在本章中,这些操作在某些场景下并不有用,特别是当容器中的应用程序需要以持久方式存储数据时。容器默认是短暂的,这是它们的主要特性之一,正如我们在本书的第一章中描述的那样,因此我们需要一种方法,将持久存储附加到运行中的容器,以保存容器的重要数据。

本章我们将讨论以下主要内容:

  • 为什么存储对容器很重要?

  • 容器的存储特性

  • 将文件复制进出容器

  • 将主机存储附加到容器

技术要求

在继续本章的讲解和示例之前,需要一台安装了 Podman 的工作机器。如第三章中所述,运行第一个容器,本书中的所有示例都在 Fedora 34 系统或更高版本上执行,但也可以在读者选择的操作系统上重现。

最后,充分理解第四章中讨论的主题,管理正在运行的容器,对于能够轻松掌握 OCI 镜像和容器执行的概念非常有用。

为什么存储对容器很重要?

在继续本章并回答这个有趣的问题之前,我们需要区分容器的两种存储类型:

  • 附加到正在运行的容器的外部存储,用于存储数据,使数据在容器重启时保持持久性

  • 用于容器根文件系统和容器镜像的底层存储

讨论外部存储,如我们在第一章中描述的那样,容器技术概述,容器是无状态的、短暂的,并且通常具有只读文件系统。这是因为该技术背后的理论认为,容器应该用于创建可扩展的、分布式的应用程序,这些应用程序必须进行横向扩展,而不是纵向扩展。

横向扩展应用程序意味着,当我们需要为正在运行的服务增加额外资源时,我们不会增加单个运行容器的 CPU 或 RAM,而是启动一个全新的容器,新的容器将与现有容器一起处理传入的请求。这与公有云中采用的著名范式相同。原则上,容器应该是短暂的,因为任何现有容器镜像的额外副本应该随时运行,以增强现有的运行服务。

当然,存在一些例外情况,可能发生正在运行的容器无法水平扩展,或者容器在启动时或运行时需要共享配置、缓存或与其他相同容器镜像副本相关的任何数据。

让我们通过一个现实生活中的例子来理解这一点。使用共享汽车服务,每次前往城市中的目的地时都可以获得一辆新车,这是一种非常有用且智能的出行方式,既能避免停车费、油费和其他问题的困扰。然而,另一方面,这项服务并不允许你将物品存放在停放的车内。因此,使用共享汽车服务时,我们可以在进入车内时拿出物品,但在离开车之前,我们必须把物品收拾好。同样的道理也适用于容器,我们必须为容器附加存储,以允许容器写入数据,但当容器停止时,我们应该卸载存储,以便新的容器在需要时使用。

这里有一个更技术性的例子:假设我们有一个标准的三层应用程序,包含 Web 服务、后端服务和数据库服务。这个应用程序的每一层可能都需要存储,并以各种方式使用这些存储。Web 服务可能需要一个地方来保存缓存、存储渲染后的网页、运行时的一些自定义图像等等。后端服务需要一个地方来存储配置数据,以及与其他运行中的后端服务之间的同步数据(如果有的话)等等。数据库服务肯定需要一个地方来存储数据库数据。

存储通常与低级基础设施相关联,但在容器中,存储变得对开发人员同样重要,开发人员需要规划存储附加的位置,以及其应用程序所需的功能。

如果我们将话题扩展到容器编排,那么存储就承担了战略性角色,因为它应该像我们可能使用的 Kubernetes 编排器一样具备弹性和可行性。在这种情况下,容器存储应更像是软件定义的存储——能够以自助服务的方式为开发人员和容器提供存储资源。

尽管本书将讨论本地存储,但值得注意的是,对于 Kubernetes 编排器来说,这还不够,因为容器应该能够根据定义的可用性和扩展规则,从一个主机迁移到另一个主机。这就是软件定义存储可能成为解决方案的地方!

从前面的例子中我们可以推断出,外部存储在容器中至关重要。其使用可能会根据我们容器中运行的应用程序不同而有所不同,但它是必需的。同时,另一个关键角色是由底层容器存储驱动的,它负责处理容器的正确存储以及容器镜像根文件系统的存储。选择正确、稳定且性能良好的底层本地存储,将确保更好的容器管理。

所以,让我们首先探索一下容器存储的理论,然后讨论如何使用它。

容器存储功能

在进入实际的示例和用例之前,我们应该首先深入了解容器存储与 容器存储接口CSI)之间的主要区别。

容器存储,之前被称为 底层容器存储,负责处理 写时复制COW)文件系统上的容器镜像。容器镜像需要传输并移动,直到容器引擎被指示运行它们,因此我们需要一种方法来存储镜像,直到它被运行。这就是容器存储的作用。

一旦我们开始使用 Kubernetes 等调度器,CSI 负责提供容器所需的块存储或文件存储,用于容器写入数据。

本章的下一部分,我们将重点讨论容器存储及其配置。稍后,我们将讨论容器的外部存储以及 Podman 提供的将主机本地存储暴露给运行中的容器的选项。

Podman 引入的一项重要创新是 containers/storage 项目(github.com/containers/storage),它为共享一种访问主机容器存储的底层通用方法提供了一个很好的方式。随着 Docker 的出现,我们不得不通过 Docker 守护进程来与容器存储进行交互。由于没有其他直接与底层存储交互的方式,Docker 守护进程只会将其隐藏在用户和系统管理员面前。

通过 containers/storage 项目,我们现在可以轻松地同时使用多个工具来分析、管理或处理容器存储。

这个低级软件的配置对于 Podman 以及其他 Podman 配套工具至关重要,可以通过其配置文件 /etc/containers/storage.conf 进行检查或编辑。

看一下配置文件,我们可以轻松发现,我们可以改变许多选项,来决定容器如何与底层存储交互。让我们检查一下最重要的选项——存储驱动程序。

存储驱动程序

配置文件作为其第一个选项之一,提供了选择默认 写时复制COW)容器存储驱动程序的机会。在本书写作时,当前版本的配置文件支持以下 COW 驱动程序:

  • overlay

  • vfs

  • devmapper

  • aufs

  • btrfs

  • zfs

这些驱动程序通常也被称为 图形驱动程序,因为它们大多数通过图形结构组织它们处理的层。

在 Fedora 34 或更高版本上使用 Podman 时,容器的存储配置文件默认使用 overlay 作为驱动程序。

另一个需要提到的重要点是,在本书写作时,overlay 文件系统有两个版本——版本 1 和版本 2。

原始的 Overlay 文件系统版本 1 最初由 Docker 容器引擎使用,但后来被版本 2 替代。这就是为什么 Podman 和容器的存储配置文件通常使用 overlay 这个名字,但实际使用的是新的版本 2。

在详细了解本章中的其他选项和实际示例之前,让我们进一步探讨这些 COW 文件系统驱动程序中的一个是如何工作的。

Overlay 联合文件系统从 Linux 内核版本 3.18 开始就已存在。它通常默认启用,并且在初始化挂载时动态激活。

这个文件系统背后的机制非常简单,但却非常强大——它允许将一个目录树叠加到另一个目录上,仅存储差异,但展示最新更新的、合并后的目录树。

通常,在容器世界里,我们会开始使用一个只读文件系统,添加一个或多个只读层,再次变为只读,直到运行中的容器将这些 合并 层作为其根文件系统。这时,将创建最后一个读写层,作为其他层的叠加。

让我们看看当我们用 Podman 拉取一个全新的容器镜像时,幕后发生了什么:

重要说明

如果你希望在测试机器上继续测试下面的示例,请确保删除任何正在运行的容器和容器镜像,这样可以更轻松地将镜像与 Podman 下载的层匹配。

# podman pull quay.io/centos7/httpd-24-centos7:latest
Trying to pull quay.io/centos7/httpd-24-centos7:latest...
Getting image source signatures
Copying blob 5f2e13673ac2 done  
Copying blob 8dd5a5013b51 done  
Copying blob b2cc5146c9c7 done  
Copying blob e17e89f32035 done  
Copying blob 1b6c93aa6be5 done  
Copying blob 6855d3fe68bc done  
Copying blob f974a2323b6c done  
Copying blob d620f14a5a76 done  
Copying config 3b964f33a2 done  
Writing manifest to image destination
Storing signatures
3b964f33a2bf66108d5333a541d376f63e0506aba8ddd4813f9d4e104 271d9f0

从之前命令的输出中,我们可以看到已经下载了多个层。这是因为我们拉取的容器镜像由多个层组成。

现在我们可以开始检查下载的层。首先,我们需要找到正确的目录,可以通过在配置文件中搜索来定位。或者,我们可以使用一种更简单的方法。Podman 有一个命令专门用于显示其运行配置和其他有用信息——podman info。让我们看看它是如何工作的:

# podman info | grep -A19 "store:"
store:
  configFile: /etc/containers/storage.conf
  containerStore:
    number: 0
    paused: 0
    running: 0
    stopped: 0
  graphDriverName: overlay
  graphOptions:
    overlay.mountopt: nodev,metacopy=on
  graphRoot: /var/lib/containers/storage
  graphStatus:
    Backing Filesystem: btrfs
    Native Overlay Diff: "false"
    Supports d_type: "true"
    Using metacopy: "true"
  imageStore:
    number: 1
  runRoot: /run/containers/storage
  volumePath: /var/lib/containers/storage/volumes

为了减少 podman info 命令的输出,我们使用了 grep 命令,仅匹配包含当前容器存储配置的 store 部分。

正如我们所看到的,使用的驱动程序是 overlay,要搜索层的根目录被报告为 graphRoot 目录:/var/lib/containers/storage;对于无根容器,相应的目录是 $HOME/.local/share/containers/storage。我们还可以看到其他路径,但这些路径将在本节后面讨论。graph 这个关键词来自我们之前介绍过的驱动类别。

让我们看看这个目录,看看里面到底有什么内容:

# cd /var/lib/containers/storage
# ls
libpod  mounts  overlay  overlay-containers  overlay-images  overlay-layers  storage.lock  tmp  userns.lock

我们可以看到几个可用的目录,它们的名称很直观。我们要寻找的是以下这些:

  • overlay-images:此目录包含下载的容器镜像的元数据。

  • overlay-layers:这里包含了每个容器镜像所有层的归档文件。

  • overlay:这是包含每个容器镜像解压层的目录。

让我们检查第一个目录overlay-images的内容:

# ls -l overlay-images/
total 8
drwx------. 1 root root  630 15 oct 18.36 3b964f33a2bf66108d5333a541d376f63e0506aba8ddd4813f9d4e10427 1d9f0
-rw-------. 1 root root 1613 15 oct 18.36 images.json
-rw-r--r--. 1 root root   64 15 oct 18.36 images.lock

正如我们所想,在这个目录中,我们可以找到我们拉取下来的唯一容器镜像的元数据,并且在那个 ID 非常长的目录中,我们将找到描述构成容器镜像层的清单文件。

现在让我们检查第二个目录overlay-layers的内容:

# ls -l overlay-layers/
total 1168
-rw-------. 1 root root   2109 15 oct 18.35 0099baae6cd3ca0ced38d658d7871548b32bd0e42118b788d818b76131ec 8e75.tar-split.gz
-rw-------. 1 root root 795206 15 oct 18.35 53498d66ad83a29fcd7c7bcf4abbcc0def4fc912772aa8a4483b51e232309 aee.tar-split.gz
-rw-------. 1 root root  52706 15 oct 18.35 6c26feaaa75c7bac1f1247acc06e73b46e8aaf2e741ad1b8bacd6774bffdf6 ba.tar-split.gz
-rw-------. 1 root root   1185 15 oct 18.35 74fa1495774e94d5cdb579f9bae4a16bd90616024a6f4b1ffd13344c367df1 f6.tar-split.gz
-rw-------. 1 root root 308144 15 oct 18.36 ae314017e4c2de17a7fb007294521bbe8ac1eeb004ac9fb57d1f1f03090f78 c9.tar-split.gz
-rw-------. 1 root root   1778 15 oct 18.36 beba3570ce7dd1ea38e8a1b919a377b6dc888b24833409eead446bff401d8f 6e.tar-split.gz
-rw-------. 1 root root    697 15 oct 18.36 e59e7d1e1874cc643bfe6f854a72a39f73f22743ab38eff78f91dc019cca91 f5.tar-split.gz
-rw-------. 1 root root   5555 15 oct 18.36 e5a13564f9c6e233da30a7fd86489234716cf80c317e52ff8261bf0cb34dc 7b4.tar-split.gz
-rw-------. 1 root root   3716 15 oct 18.36 layers.json
-rw-r--r--. 1 root root     64 15 oct 19.06 layers.lock

正如我们所看到的,我们刚刚找到了为容器镜像下载的所有层的归档文件,但它们被解压到哪里了呢?答案很简单——在第三个文件夹overlay中:

# ls -l overlay
total 0
drwx------. 1 root root  46 15 oct 18.35 0099baae6cd3ca0ced38d658d7871548b32bd0e42118b788d818b76131ec 8e75
drwx------. 1 root root  46 15 oct 18.35 53498d66ad83a29fcd7c7bcf4abbcc0def4fc912772aa8a4483b51e23230 9aee
drwx------. 1 root root  46 15 oct 18.35 6c26feaaa75c7bac1f1247acc06e73b46e8aaf2e741ad1b8bacd6774bffd f6ba
drwx------. 1 root root  46 15 oct 18.35 74fa1495774e94d5cdb579f9bae4a16bd90616024a6f4b1ffd13344c367d f1f6
drwx------. 1 root root  46 15 oct 18.35 ae314017e4c2de17a7fb007294521bbe8ac1eeb004ac9fb57d1f1f03090f 78c9
drwx------. 1 root root  46 15 oct 18.36 beba3570ce7dd1ea38e8a1b919a377b6dc888b24833409eead446bff401d 8f6e
drwx------. 1 root root  46 15 oct 18.36 e59e7d1e1874cc643bfe6f854a72a39f73f22743ab38eff78f91dc019cca 91f5
drwx------. 1 root root  46 15 oct 18.36 e5a13564f9c6e233da30a7fd86489234716cf80c317e52ff8261bf0cb34d c7b4
drwx------. 1 root root 416 15 oct 18.36 l

当查看最新目录内容时,可能会产生的第一个问题是,l(小写 L)目录的用途是什么?

为了回答这个问题,我们必须检查一个层目录的内容。我们可以从列表中的第一个开始:

# ls -la overlay/0099baae6cd3ca0ced38d658d7871548b32bd0e42118b788d818b 76131ec8e75/
total 8
drwx------. 1 root root   46 15 oct 18.35 .
drwx------. 1 root root 1026 15 oct 18.36 ..
dr-xr-xr-x. 1 root root   24 15 oct 18.35 diff
-rw-r--r--. 1 root root   26 15 oct 18.35 link
-rw-r--r--. 1 root root   86 15 oct 18.35 lower
drwx------. 1 root root    0 15 oct 18.35 merged
drwx------. 1 root root    0 15 oct 18.35 work

让我们理解这些文件和目录的用途:

  • diff:这个目录表示叠加层的上层,用于存储对该层的任何更改。

  • lower:这个文件报告所有下层的挂载情况,按从上到下的顺序排列。

  • merged:该目录是叠加层挂载到的目录。

  • work:该目录用于内部操作。

  • link:这个文件包含层的唯一字符串。

现在,回到我们的问题,l(小写 L)目录的用途是什么?

l目录下,有指向每个层的diff目录的符号链接。符号链接在lower文件中引用了下层。让我们检查一下:

# ls -la overlay/l/
total 32
drwx------. 1 root root  416 15 oct 18.36 .
drwx------. 1 root root 1026 15 oct 18.36 ..
lrwxrwxrwx. 1 root root   72 15 oct 18.35 A4ZYMM4AK5NM6JYJA7 EK2DLTGA -> ../74fa1495774e94d5cdb579f9bae4a16bd90616024a6f4 b1ffd13344c367df1f6/diff
lrwxrwxrwx. 1 root root   72 15 oct 18.35 D2WVDYIWL6I77ZOIXR VQKCXNG2 -> ../ae314017e4c2de17a7fb007294521bbe8ac1eeb004ac9fb57d1f1f03090 f78c9/diff
lrwxrwxrwx. 1 root root   72 15 oct 18.36 G4KXMAOCE56TIB252 ZMWEFRFHU -> ../beba3570ce7dd1ea38e8a1b919a377b6dc888b24833409eead446bff401 d8f6e/diff
lrwxrwxrwx. 1 root root   72 15 oct 18.35 JHHF5QA7YSKDSKRSC HNADBVKDS -> ../53498d66ad83a29fcd7c7bcf4abbcc0def4fc912772aa8a4483b51e2 32309aee/diff
lrwxrwxrwx. 1 root root   72 15 oct 18.36 KNCK5EDUAQJDAIDWQ6 TWDFQF5B -> ../e59e7d1e1874cc643bfe6f854a72a39f73f22743ab38eff78f91dc019cca 91f5/diff
lrwxrwxrwx. 1 root root   72 15 oct 18.35 LQUM7XDVWHIJRLIWAL CFKSMJTT -> ../0099baae6cd3ca0ced38d658d7871548b32bd0e42118b788d818b76131 ec8e75/diff
lrwxrwxrwx. 1 root root   72 15 oct 18.35 V6OV3TLBBLTATIJDCTU 6N72XQ5 -> ../6c26feaaa75c7bac1f1247acc06e73b46e8aaf2e741ad1b8bacd6774bf fdf6ba/diff
lrwxrwxrwx. 1 root root   72 15 oct 18.36 ZMKJYKM2VJEAYQHCI7SU Q2R3QW -> ../e5a13564f9c6e233da30a7fd86489234716cf80c317e52ff8261bf0cb34dc7 b4/diff

为了再次确认我们刚刚学到的内容,让我们找到容器镜像的第一层,并检查是否有lower文件。

让我们检查我们容器镜像的清单文件:

# cat overlay-images/3b964f33a2bf66108d5333a541d376f63e0506ab a8ddd4813f9d4e104271d9f0/manifest | head -15
{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
   "config": {
      "mediaType": "application/vnd.docker.container.image.v1 +json",
      "size": 16212,
      "digest": "sha256:3b964f33a2bf66108d5333a541d376f63e0506aba8ddd4813f9d4 e104271d9f0"
   },
   "layers": [
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 75867345,
         "digest": "sha256:b2cc5146c9c7855cb298ca8b77ecb153d37e3e5c69916ef42361 3a46a70c0503"
      },

然后,我们必须将压缩归档文件的校验和与我们下载的所有层的列表进行比较:

知识点

SHA-256 是一种算法,用于生成唯一的加密哈希值,可以用来验证文件的完整性(校验和)。

# cat overlay-layers/layers.json | jq | grep -B3 -A10 "sha256:b2cc5"
  {
    "id": "53498d66ad83a29fcd7c7bcf4abbcc0def4fc912772aa8a4483b51e23 2309aee",
    "created": "2021-10-15T16:35:49.782784856Z",
    "compressed-diff-digest": "sha256:b2cc5146c9c7855cb298ca8b77ecb153d37e3e5c69916ef423 613a46a70c0503",
    "compressed-size": 75867345,
    "diff-digest": "sha256:53498d66ad83a29fcd7c7bcf4abbcc0def4fc912772aa8a448 3b51e232309aee",
    "diff-size": 211829760,
    "compression": 2,
    "uidset": [
      0,
      192
    ],
    "gidset": [
      0,

我们刚刚分析的文件overlay-layers/layers.json没有进行缩进。因此,我们使用jq工具来格式化它,使其更易于人类阅读。

知识点

如果你在系统上找不到jq工具,可以通过操作系统的默认包管理器安装它。例如,在 Fedora 上,你可以运行dnf install jq来安装。

正如你所看到的,我们刚刚找到了根层的 ID。现在,让我们看看它的内容:

# ls -l overlay/53498d66ad83a29fcd7c7bcf4abbcc0def4fc912772aa8a448 3b51e232309aee/
total 4
dr-xr-xr-x. 1 root root 158 15 oct 18.35 diff
drwx------. 1 root root   0 15 oct 18.35 empty
-rw-r--r--. 1 root root  26 15 oct 18.35 link
drwx------. 1 root root   0 15 oct 18.35 merged
drwx------. 1 root root   0 15 oct 18.35 work

正如我们可以验证的那样,在层的目录中没有lower文件,因为这是我们容器镜像的第一层!

我们可能注意到的不同之处是有一个名为empty的目录。这是因为如果某一层没有父层,那么叠加系统将创建一个名为empty的虚拟下层目录,并跳过写入lower文件。

最后,作为我们实践示例的最后阶段,让我们运行容器并验证会创建一个新的diff层。我们预计这个层将只包含与下层的差异。

首先,我们运行我们刚刚分析的容器镜像:

# podman run -d quay.io/centos7/httpd-24-centos7
bd0eef7cd50760dd52c24550be51535bc11559e52eea7d782a1fa69 76524fa76

如您所见,我们通过-d选项在后台启动了容器,这样我们可以继续在系统主机上工作。之后,我们将在该 Pod 上执行一个新的 shell 来实际检查容器的根文件夹并在其中创建一个新文件:

# podman exec -ti bd0eef7cd50760dd52c24550be51535bc11559e52eea7d782a1fa69 76524fa76 /bin/bash
bash-4.2$ pwd
/opt/app-root/src
bash-4.2$ echo "this is my NOT persistent data" > tempfile.txt
bash-4.2$ ls
tempfile.txt

我们刚刚创建的这个新文件是临时性的,且只在容器的生命周期内存在。现在是时候找到我们主机系统上由覆盖驱动程序刚刚创建的diff层了。最简单的方法是分析正在运行的容器中使用的挂载点:

bash-4.2$ mount | head
overlay on / type overlay (rw,relatime,context="system_u:object_r:container_file_t:s0:c300,c861",lowerdir=/var/lib/containers/storage/overlay/l/ZMKJYKM2VJEAYQHCI7SUQ2R3QW:/var/lib/containers/storage/overlay/l/G4KXMAOCE56TIB252ZMWEFRFHU:/var/lib/containers/storage/overlay/l/KNCK5EDUAQJDAIDWQ6TWDF QF5B:/var/lib/containers/storage/overlay/l/D2WVDYIWL6I77ZOIX RVQKCXNG2:/var/lib/containers/storage/overlay/l/LQUM7XDVWHI JRLIWALCFKSMJTT:/var/lib/containers/storage/overlay/l/A4ZYMM4AK5NM6JYJA7EK2DLTGA:/var/lib/containers/storage/overlay/l/V6OV3TLBBLTATIJDCTU6N72XQ5:/var/lib/containers/storage/overlay/l/JHHF5QA7YSKDSKRSCHNADBVKDS,upperdir=/var/lib/containers/storage/overlay/b71e4bea5380ca233bf6b0c7a1c276179b841e263ee293e987c6cc54 af516f23/diff,workdir=/var/lib/containers/storage/overlay/b71e4bea5380ca233bf6b0c7a1c276179b841e263ee293e987c6cc54af 516f23/work,metacopy=on)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
tmpfs on /dev type tmpfs (rw,nosuid,context="system_u:object _r:container_file_t:s0:c300,c861",size=65536k,mode=755,inode64)

如您所见,列表中的第一个挂载点显示了一长串用冒号分隔的层路径。在这条长长的路径中,我们可以找到正在寻找的upperdir目录:

upperdir=/var/lib/containers/storage/overlay/b71e4bea5380ca233bf6b0c7a1c276179b841e263ee293e987c6cc54af5 16f23/diff

现在,我们可以检查这个目录的内容,并浏览可用的各种路径,以找到我们在前面命令中写入该文件的容器根目录:

# ls -la /var/lib/containers/storage/overlay/b71e4bea5380ca233bf6b0c7a1c276179b841e263ee293e987c6cc54af5 16f23/diff/opt/app-root/src/
total 12
drwxr-xr-x. 1 1001 root   58 16 oct 00.40 .
drwxr-xr-x. 1 1001 root   12 22 set 10.39 ..
-rw-------. 1 1001 root   81 16 oct 00.46 .bash_history
-rw-------. 1 1001 root 1024 16 oct 00.38 .rnd
-rw-r--r--. 1 1001 root   31 16 oct 00.39 tempfile.txt
# cat /var/lib/containers/storage/overlay/b71e4bea5380ca233bf6b0c7a1c276179b841e263ee293e987c6cc54af5 16f23/diff/opt/app-root/src/tempfile.txt 
this is my NOT persistent data

正如我们验证过的那样,数据存储在主机操作系统上,但它存储在一个临时层中,一旦容器被删除,它就会被删除!

现在,回到最初让我们走到这个覆盖存储驱动程序底层的小旅程的主题,我们在谈论的是/etc/containers/storage.conf。这个文件包含了所有关于containers/storage项目的配置,负责为在主机上访问容器存储提供共享的底层通用方法。

这个文件中可用的其他选项与存储驱动程序的定制以及更改内部存储目录的默认路径相关。

我们应该简要谈论的最后一点是runroot目录。在这个文件夹中,容器存储程序将存储容器生成的所有临时可写内容。

如果我们检查在前一个示例中启动容器时我们主机上的文件夹,我们会发现有一个以容器 ID 命名的文件夹,里面有多个已挂载到容器上的文件,这些文件替换了原始文件:

# ls -l /run/containers/storage/overlay-containers/bd0eef7cd50760dd52c24550be51535bc11559e52eea7d782a1fa69765 24fa76/userdata
total 20
-rw-r--r--. 1 root root   6 16 oct 00.38 conmon.pid
-rw-r--r--. 1 root root  12 16 oct 00.38 hostname
-rw-r--r--. 1 root root 230 16 oct 00.38 hosts
-rw-r--r--. 1 root root   0 16 oct 00.38 oci-log
-rwx------. 1 root root   6 16 oct 00.38 pidfile
-rw-r--r--. 1 root root  34 16 oct 00.38 resolv.conf
drwxr-xr-x. 3 root root  60 16 oct 00.38 run

如前所示,runroot路径下的容器文件夹包含了多个文件,这些文件已经直接挂载到容器中以进行定制。

总结一下,在之前的示例中,我们分析了容器镜像的结构,以及当我们从该镜像运行一个新容器时会发生什么。幕后技术令人惊叹,我们看到许多功能与操作系统提供的隔离能力相关。在这里,存储提供了其他重要功能,使得容器成为了我们现在所熟知的最伟大的技术。

复制文件进出容器

Podman 允许用户将文件进出运行中的容器。这一结果是通过使用podman cp命令实现的,该命令可以将文件和文件夹移动到容器内外。它的使用非常简单,接下来的示例将展示这一点。

首先,让我们启动一个新的 Alpine 容器:

$ podman run -d --name alpine_cp_test alpine sleep 1000

现在,让我们从容器中获取一个文件——我们选择了/etc/os-release文件,它提供了一些关于操作系统发行版及其版本 ID 的信息:

$ podman cp alpine_cp_test:/etc/os-release /tmp

文件已经被复制到主机的/tmp文件夹中,可以进行检查:

$ cat /tmp/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.14.2
PRETTY_NAME="Alpine Linux v3.14"
HOME_URL=https://alpinelinux.org/
BUG_REPORT_URL="https://bugs.alpinelinux.org/"

在相反的方向,我们可以将文件或文件夹从主机复制到正在运行的容器中:

$ podman cp /tmp/build_folder alpine_cp_test:/

这个示例将/tmp/build_folder文件夹及其所有内容复制到 Alpine 容器的根文件系统下。然后,我们可以使用podman exec命令结合ls工具来检查复制命令的结果。

与 overlayfs 交互

还有另一种从容器复制文件到主机的方法,那就是使用podman mount命令并直接与合并的覆盖层交互。

要挂载一个正在运行的无根容器的文件系统,我们首先需要运行podman unshare命令,这允许用户在修改后的用户命名空间内运行命令:

$ podman unshare

该命令在一个新用户命名空间中启动一个根 shell,并配置了UID 0GID 0。现在可以运行podman mount命令,并获取挂载点的绝对路径:

# cd $(podman mount alpine_cp_test)

上述命令使用了 Shell 扩展,切换到MergedDir的路径,正如其名所示,它将LowerDirUpperDir的内容合并,提供一个统一的视图,展示不同层的内容。从现在开始,我们可以复制文件到容器的根文件系统,也可以从容器复制文件。

之前的示例基于无根容器,但相同的逻辑适用于有根容器。让我们启动一个有根的 Nginx 容器:

$ sudo podman run -d \
  --name rootful_nginx docker.io/library/nginx 

要复制文件进出容器,我们需要在命令前加上sudo命令:

$ sudo podman cp \
  rootful_nginx:/usr/share/nginx/html/index.html /tmp

上述命令将默认的index.html页面复制到主机的/tmp目录。请记住,sudo会提升用户权限至 root,因此复制的文件将具有UID 0GID 0的所有权。

从容器中复制文件和文件夹的做法对于故障排除特别有用。将它们复制到正在运行的容器内的相反操作,对于更新和测试机密或配置文件也很有用。在这种情况下,我们可以选择保存这些更改,具体方法将在下一小节中描述。

使用podman commit持久化更改

之前的示例并不是一种永久定制正在运行的容器的方法,因为容器的不可变性意味着持久性修改应该通过镜像重建来实现。

然而,如果我们需要保存更改并生成一个新的镜像而不重新构建,可以使用podman commit命令将容器中的更改保存到一个新的镜像中。

提交(commit)概念在 Docker 和 OCI 镜像构建中非常重要。事实上,我们可以将 Dockerfile 的不同步骤视为构建过程中应用的一系列提交。

以下示例展示了如何将复制到运行中的容器中的文件持久化,并生成一个新镜像。假设我们想要更新 Nginx 容器的默认 index.html 页面:

$ echo "Hello World!" > /tmp/index.html
$ podman run --name custom_nginx -d -p \  
  8080:80 docker.io/library/nginx 
$ podman cp /tmp/index.html \
  custom_nginx:/usr/share/nginx/html/

让我们测试应用的更改:

$ curl localhost:8080
Hello World!

现在我们希望将更改后的 index.html 文件持久化到一个新镜像中,从运行中的容器使用 podman commit 开始:

$ podman commit -p custom_nginx hello-world-nginx

前述命令通过有效地创建一个包含更新文件和文件夹的新镜像层来持久化更改。

之前的容器现在可以在测试新自定义镜像之前安全地停止和删除:

$ podman stop custom_nginx && podman rm custom_nginx

让我们测试一下新的自定义镜像,并检查更改后的 index.html 文件:

$ podman run -d -p 8080:80 --name hello_world \
  localhost/hello-world-nginx
$ curl localhost:8080
Hello World!

在本节中,我们学会了如何将文件复制到运行中的容器以及如何通过生成新镜像即时提交更改。

在下一节中,我们将学习如何通过引入卷(volumes)绑定挂载(bind mounts)的概念将主机存储附加到容器。

将主机存储附加到容器

我们已经讨论过容器的不可变特性。从预构建镜像开始,当我们运行一个容器时,我们会在一层只读层的堆栈上实例化一个读/写层,采用写时复制(copy-on-write)的方法。

容器是基于有状态镜像的短暂对象。这意味着容器不应该存储数据在其中——如果容器崩溃或被删除,所有数据都会丢失。我们需要一种方法,将数据存储在一个独立的位置,并将其挂载到正在运行的容器中,在容器删除时保留数据,并准备好被新容器重用。

还有一个重要的警告不容忽视——机密配置文件。当我们构建一个镜像时,可以将所需的所有文件和文件夹传递到镜像中。然而,将机密如证书或密钥封装到构建中并不是一个好做法。如果我们需要,例如,轮换一个证书,我们必须从头开始重新构建整个镜像。同样,改变一个位于镜像内的配置文件也意味着每次更改设置时都需要重新构建。

由于这些原因,OCI 规范支持卷(volumes)绑定挂载(bind mounts)来管理附加到容器的存储。在接下来的章节中,我们将学习卷和绑定挂载是如何工作的,以及如何将它们附加到容器中。

管理和附加绑定挂载到容器

让我们从绑定挂载(bind mounts)开始,因为它们利用了 Linux 的原生功能。根据官方的 Linux 手册页,绑定挂载是一种将文件系统层次结构的某部分重新挂载到其他地方的方法。这意味着,通过使用绑定挂载,我们可以在主机的另一个挂载点下复制目录的视图。

在学习容器如何使用绑定挂载之前,先来看一个基本示例,我们将/etc目录绑定挂载到/mnt目录下:

$ sudo mount --bind /etc /mnt  

执行此命令后,我们将看到/mnt下的/etc目录的精确内容。要卸载,只需运行以下命令:

$ sudo umount /mnt

同样的概念可以应用于容器——Podman 可以将主机目录绑定挂载到容器内,并提供专门的 CLI 选项来简化挂载过程。

Podman 提供了两个可以用于绑定挂载的选项:-v|--volume–mount。我们将更详细地介绍这两个选项。

-v|--volume 选项

此选项使用紧凑的单字段参数来定义源主机目录和容器挂载点,模式为/HOST_DIR:/CONTAINER_DIR。以下示例将主机的/host_files目录挂载到容器内的/mnt挂载点:

$ podman run -v /host_files:/mnt docker.io/library/nginx

可以传递额外的参数来定义挂载行为;例如,将主机目录挂载为只读:

$ podman run –v /host_files:/mnt:ro \
   docker.io/library/nginx

使用-v|--volume选项的其他可行绑定挂载选项可以在运行命令的手册页(man podman-run)中找到。

--mount 选项

此选项更加详细,因为它使用key=value语法来定义源和目标,以及挂载类型和额外的参数。该选项接受不同的挂载类型(绑定挂载、卷、tmpfs、镜像和 devpts),格式为type=TYPE,source=HOST_DIR,destination=CONTAINER_DIR。源和目标键可以分别用更短的srcdst替代。之前的示例可以重写为:

$ podman run \
  --mount type=bind,src=/host_files,dst=/mnt \
  docker.io/library/nginx

我们还可以通过添加额外的逗号传递额外的选项;例如,将主机目录挂载为只读:

$ podman run \
  --mount type=bind,src=/host_files,dst=/mnt,ro=true \
  docker.io/library/nginx

尽管绑定挂载非常简单易用,但它也有一些限制,在某些情况下可能会影响容器的生命周期。主机文件和目录必须在运行容器之前存在,并且必须根据需要设置权限,使其可读或可写。另一个需要注意的重要问题是,如果绑定挂载由文件或目录填充,绑定挂载会始终遮掩容器内的底层挂载点。是绑定挂载的有用替代方案,接下来会详细描述。

管理和附加卷到容器

卷是由容器引擎直接创建和管理的目录,并挂载到容器内的挂载点。它们为持久化容器生成的数据提供了一个很好的解决方案。

卷可以通过 podman volume 命令进行管理,使用该命令可以列出、检查、创建和删除系统中的卷。让我们从一个基本的示例开始,Podman 会在 Nginx 文档根目录上自动创建一个卷:

$ podman run -d -p 8080:80  --name nginx_volume1 -v /usr/share/nginx/html docker.io/library/nginx

这次,–v选项有一个只有一个项目的参数——文档根目录。在这种情况下,Podman 会自动创建一个卷并将其绑定挂载到目标挂载点。

为了证明新卷已创建,我们可以检查容器:

$ podman inspect nginx_volume1
[...omitted output...]
"Mounts": [
          {
                "Type": "volume",
                "Name": "2ed93716b7ad73706df5c6f56bda262920accec59e7b6642d36f938e936 d36d9",
                "Source": "/home/packt/.local/share/containers /storage/volumes/2ed93716b7ad73706df5c6f56bda262920accec59e7b6 642d36f93 8e936d36d9/_data",
                "Destination": "/usr/share/nginx/html",
                "Driver": "local",
                "Mode": "",
                "Options": [
                    "nosuid",
                    "nodev",
                    "rbind"
                ],
                "RW": true,
                "Propagation": "rprivate"
            }
        ],
[…omitted output]

Mounts部分,我们列出了容器中挂载的对象。唯一的项目是一个volume类型的对象,具有生成的 UID 作为其Name,并且Source字段表示它在主机中的路径,而Destination字段则是容器内的挂载点。

我们可以通过podman volume ls命令再次检查卷是否存在:

$ podman volume ls
DRIVER VOLUME NAME
local  2ed93716b7ad73706df5c6f56bda262920accec59e7b6642d36f93 8e936d36d9

查看源路径时,我们会找到容器文档根目录中的默认文件:

$ ls -al 
/home/packt/.local/share/containers/storage/volumes/2ed93716b7ad73706df5c6f56bda262920accec59e7b6642d36f93 8e936d36d9/_data
total 16
drwxr-xr-x. 2 gbsalinetti gbsalinetti 4096 Sep  9 20:26 .
drwx------. 3 gbsalinetti gbsalinetti 4096 Oct 16 22:41 ..
-rw-r--r--. 1 gbsalinetti gbsalinetti  497 Sep  7 17:21 50x.html
-rw-r--r--. 1 gbsalinetti gbsalinetti  615 Sep  7 17:21 index.html

这演示了当创建一个空卷时,它会被填充到目标挂载点的内容中。当容器停止时,卷会保留下来并保存所有数据,在容器重新启动时可以被其他容器重新使用。

上述示例显示了一个具有生成 UID 的卷,但也可以选择附加卷的名称,如下例所示:

$ podman run -d -p 8080:80  --name nginx_volume2 -v nginx_vol:/usr/share/nginx/html docker.io/library/nginx

在上述示例中,Podman 创建了一个名为nginx_vol的新卷,并将其存储在默认的卷目录下。当创建命名卷时,Podman 无需生成 UID。

默认的卷目录在无根容器和有根容器中有不同的路径:

  • 对于无根容器,默认的卷存储路径是<USER_HOME>/.local/share/containers/storage/volumes

  • 对于有根容器,默认的卷存储路径是/var/lib/containers/storage/volumes

在这些路径中创建的卷在容器销毁后会被保留下来,并可以被其他容器重用。

要手动删除一个卷,可以使用podman volume rm命令:

$ podman volume rm nginx_vol

当处理多个卷时,podman volume prune命令会删除所有未使用的卷。以下示例会清理用户默认卷存储中的所有卷(即无根容器使用的存储):

$ podman volume prune

下一个示例显示了如何使用sudo前缀删除有根容器使用的卷:

$ sudo podman volume prune

重要提示

不要忘记监控主机上累积的卷,因为它们会消耗磁盘空间,可能会被回收,并定期清理未使用的卷,以避免主机存储杂乱无章。

用户还可以在运行容器之前预先创建并填充卷。以下示例使用podman create volume命令创建挂载到 Nginx 文档根目录的卷,然后用一个测试的index.html文件填充它:

$ podman volume create custom_nginx
$ echo "Hello World!" >> $(podman volume inspect custom_nginx –format "{{ .Mountpoint }}")/index.html

现在,我们可以使用预填充的卷运行一个新的 Nginx 容器:

$ podman run -d -p 8080:80  --name nginx_volume3 -v custom_nginx:/usr/share/nginx/html docker.io/library/nginx

HTTP 测试显示了更新后的内容:

$ curl localhost:8080
Hello World!

这次,卷在一开始并非为空,且它用其中的内容覆盖了容器目标目录。

使用--mount选项挂载卷

与绑定挂载一样,我们可以自由选择使用-v|--volume--mount选项。以下示例使用--mount标志运行 Nginx 容器:

$ podman run -d -p 8080:80  --name nginx_volume4 --mount type=volume,src=custom_nginx,dst=/usr/share/nginx/html docker.io/library/nginx

虽然-v|--volume选项简洁且广泛采用,但--mount选项的优点在于语法更清晰易懂,并且准确地说明了挂载类型。

卷驱动程序

之前的卷示例都基于同一个 /usr/share/containers/containers.conf 文件中的 [engine.volume_plugins] 部分,传递插件名称后跟文件或套接字路径。

本地卷驱动程序也可以用于挂载 /data/db 目录:

$ sudo podman volume create --driver local --opt type=nfs --opt o=addr=nfs-host.example.com,rw,context="system_u:object_r:container_file_t:s0" --opt device=:/opt/nfs-export nfs-volume
$ sudo podman run -d -v nfs-volume:/data/db docker.io/library/mongo

上述示例的前提是已经配置好 NFS 服务器,且该服务器能够被运行容器的主机访问。

构建中的卷

卷可以在镜像构建过程中预先定义。这让镜像维护者定义哪些容器目录会自动附加到卷上。为了理解这一概念,让我们检查一下这个最小的 Dockerfile:

FROM docker.io/library/nginx:latest
VOLUME /usr/share/nginx/html

docker.io/library/nginx 镜像唯一的修改是 VOLUME 指令,该指令定义了哪个目录应作为匿名卷在主机上外部挂载。这仅仅是元数据,卷仅会在容器从此镜像启动时创建。

如果我们构建镜像并运行一个基于示例 Dockerfile 的容器,我们可以看到一个自动创建的匿名卷:

$ podman build -t my_nginx .
$ podman run -d --name volumes_from_build my_nginx
$ podman inspect volumes_from_build --format "{{ .Mounts }}"
[{volume 4d6ac7edcb4f01add205523b7733d61ae4a5772786eacca68e49 72b20fd1180c /home/packt/.local/share/containers/storage/volumes/4d6ac7edcb4f01add205523b7733d61ae4a5772786eacca68e4972 b20fd1180c/_data /usr/share/nginx/html local  [nodev exec nosuid rbind] true rprivate}]

在没有明确创建卷选项的情况下,Podman 已经创建并挂载了容器卷。这种在构建时自动定义卷的做法,在所有需要持久化数据的容器中是常见的,比如数据库。

例如,docker.io/library/mongo 镜像已经配置好创建两个卷,一个用于 /data/configdb,另一个用于 /data/db。这种行为在最常见的数据库中也可以看到,包括 PostgreSQL、MariaDB 和 MySQL。

可以定义预先定义的匿名卷在容器启动时如何挂载。默认的 ID --image-volume 选项。以下示例启动一个 MongoDB 容器,并将其默认卷挂载为 tmpfs:

$ podman run -d --image-volume tmpfs docker.io/library/mongo

第六章 中,认识 Buildah - 从零构建容器,我们将更详细地介绍构建过程。我们现在用一个跨多个容器挂载卷的示例来结束这一小节。

跨容器挂载卷

卷的最大优点之一是它们的灵活性。例如,容器可以挂载来自已经运行的容器的卷,以共享相同的数据。为了实现这个结果,我们可以使用 --volumes-from 选项。以下示例启动一个 MongoDB 容器,并在 Fedora 容器上交叉挂载其卷:

$ podman run -d --name mongodb01 docker.io/library/mongo
$ podman run -it --volumes-from=mongodb01 docker.io/library/fedora

第二个容器启动一个交互式的 root shell,我们可以用来检查文件系统内容:

[root@c10420016687 /]# ls -al /data
total 20
drwxr-xr-t.  4 root root 4096 Oct 17 15:36 .
dr-xr-xr-x. 19 root root 4096 Oct 17 15:36 ..
drwxr-xr-x.  2  999  999 4096 Sep 20 22:20 configdb
drwxr-xr-x.  4  999  999 4096 Oct 17 15:36 db

正如预期的那样,我们可以在 Fedora 容器中找到挂载的 MongoDB 卷。如果我们停止甚至移除第一个 mongodb01 容器,卷依然会保持活跃并挂载在 Fedora 容器中。

到目前为止,我们已经看到了没有特定容器或挂载资源隔离的基本用例。如果主机启用了 SELinux 并且处于强制模式,必须应用一些额外的考虑。

SELinux 对挂载的考虑

SELinux 会递归地为文件和目录应用标签以定义它们的上下文。这些标签通常存储为扩展文件系统属性。SELinux 使用上下文来管理策略,并定义哪些进程可以访问特定资源。

ls 命令用于查看资源的类型上下文:

$ ls -alZ /etc/passwd
-rw-r--r--. 1 root root system_u:object_r:passwd_file_t:s0 2965 Jul 28 21:00 /etc/passwd

在前面的示例中,passwd_file_t 标签定义了 /etc/passwd 文件的类型上下文。根据类型上下文,程序可以或不能在 SELinux 处于强制模式时访问该文件。

进程也有其类型上下文——容器运行时标签为 container_t,并对标记为 container_file_t 类型上下文的文件和目录具有读/写访问权限,对标记为 container_share_t 的资源具有读/执行权限。

默认情况下,其他主机目录是可访问的,其中 /etc 以只读方式访问,/usr 以读/执行方式访问。此外,/var/lib/containers/overlay/ 下的资源被标记为 container_share_t

如果我们尝试挂载一个没有正确标记的目录会发生什么?

Podman 仍然会执行容器,而不会因标签错误而报错,但挂载的目录或文件将无法从容器内运行的进程访问,而这些进程是以 container_t 类型标签运行的。以下示例尝试在不考虑标签约束的情况下,为 Nginx 容器挂载一个自定义的文档根目录:

$ mkdir ~/custom_docroot
$ echo "Hello World!" > ~/custom_docroot/index.html
$ podman run -d \
   --name custom_nginx \
  -p 8080:80 \
   -v ~/custom_docroot:/usr/share/nginx/html \
   docker.io/library/nginx

显然,一切顺利——容器正常启动,内部的进程也在运行,但如果我们尝试联系 Nginx 服务器,我们会看到以下错误:

$ curl localhost:8080
<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx/1.21.3</center>
</body>
</html>

403 – Forbidden 表明 Nginx 进程无法访问 index.html 页面。为了解决这个错误,我们有两个选择——将 SELinux 设置为 宽松 模式,或者重新标记挂载的资源。将 SELinux 设置为宽松模式时,它会继续跟踪违规情况,但不会阻止它们。无论如何,这不是一种好习惯,只有在我们无法正确排查访问问题,并需要将 SELinux 排除在外时,才应使用这种方式。以下命令将 SELinux 设置为宽松模式:

$ sudo setenforce 0

重要说明

宽松模式并不等于完全禁用 SELinux。在这种模式下,SELinux 仍然会记录 AVC 拒绝日志,但不会阻止操作。系统管理员可以在无需重启的情况下立即在宽松模式和强制模式之间切换。而禁用 SELinux 则意味着需要重启整个系统。

第二个优选方案是简单地重新标记我们需要挂载的资源。为了实现这个目标,我们可以使用 SELinux 的命令行工具。作为快捷方式,Podman 提供了一种更简单的方法——在卷挂载参数中应用 :z:Z 后缀。这两个后缀之间的区别很微妙:

  • :z 后缀告诉 Podman 重新标记挂载的资源,以使所有容器都能够读写它。它适用于卷和绑定挂载。

  • :Z后缀告诉 Podman 重新标记挂载的资源,以便仅允许当前容器独占读取和写入。这对于卷和绑定挂载都适用。

为了测试差异,让我们再次尝试使用:z后缀运行容器,看看会发生什么:

$ podman run -d \
   --name custom_nginx \
  –p 8080:80 \
   -v ~/custom_docroot:/usr/share/nginx/html:z \
   docker.io/library/nginx

现在,HTTP 调用返回了预期的结果,因为进程能够访问index.html文件,并且没有被 SELinux 阻止:

$ curl localhost:8080
Hello World!

让我们看看自动应用于挂载目录的 SELinux 文件上下文:

$ ls -alZ ~/custom_docroot
total 20
drwxrwxr-x.  2 packt packt system_u:object_r:container_file_t:s0  4096 Oct 16 15:53 .
drwxrwxr-x. 74 packt packt unconfined_u:object_r:user_home_dir_t:s0 12288 Oct 16 16:32 ..
-rw-rw-r--.  1 packt packt system_u:object_r:container_file_t:s0  13 Oct 16 15:53 index.html

让我们关注system_u:object_r:container_file_t:s0标签。最终的s0字段表示一个s0敏感级别,它将能够以读写权限挂载资源。这也代表了一个安全问题,因为同一主机上的恶意容器可能会通过窃取或覆盖数据来攻击其他容器。

解决这个问题的方法称为多类别安全MCS)。SELinux 使用 MCS 来配置额外的类别,这些类别是与其他 SELinux 标签一起应用于资源的明文标签。MCS 标签的对象仅能被分配相同类别的进程访问。

当容器启动时,容器内的进程会被标记为 MCS 类别,格式为cXXX,cYYY,其中 XXX 和 YYY 是随机选择的整数。

当传递Z(大写)时,Podman 会自动将 MCS 类别应用于挂载的资源。为了测试这一行为,让我们再次使用:Z后缀运行 Nginx 容器:

$ podman run -d \
   --name custom_nginx \
  –p 8080:80 \
   -v ~/custom_docroot:/usr/share/nginx/html:Z \
   docker.io/library/nginx

我们可以立即看到挂载的文件夹已经被重新标记为 MCS 类别:

$ ls -alZ ~/custom_docroot
total 20
drwxrwxr-x.  2 packt packt system_u:object_r:container_file_t:s0:c16,c898  4096 Oct 16 15:53 .
drwxrwxr-x. 74 packt packt unconfined_u:object_r:user_home_dir_t:s0       12288 Oct 16 21:12 ..
-rw-rw-r--.  1 packt packt system_u:object_r:container_file_t:s0:c16,c898    13 Oct 16 15:53 index.html

一个简单的测试将返回预期的Hello World!文本,证明容器内的进程被允许访问目标资源:

$ curl localhost:8080
Hello World!

如果我们使用相同的方法再次运行第二个容器,并对相同的绑定挂载应用:Z,会发生什么?

$ podman run -d \
   --name custom_nginx2 \
  –p 8081:80 \
   -v ~/custom_docroot:/usr/share/nginx/html:Z \
   docker.io/library/nginx

这次,我们在端口8081上运行 HTTP 测试,HTTP GET仍然能够正常工作:

$ curl localhost:8081
Hello World!

然而,如果我们再次测试映射到端口8080的容器,我们会得到一个意外的403 Forbidden消息:

$ curl localhost:8080
<html>
<head><title>403 Forbidden</title></head>
<body>
<center><h1>403 Forbidden</h1></center>
<hr><center>nginx/1.21.3</center>
</body>
</html>

不出所料,第二个容器使用了:Z后缀并重新标记了目录,赋予了新的 MCS 类别,使得第一个容器无法访问之前可用的内容。

重要提示

前面的示例是通过绑定挂载进行的,但同样适用于卷。使用这些技术时要小心,以避免不必要的重新标签化绑定挂载的系统或主目录。

在这一小节中,我们展示了 SELinux 在管理容器和资源隔离方面的强大功能。让我们以对其他类型存储的概述来结束这一章。

将其他类型的存储附加到容器

除了绑定挂载和卷之外,还可以将其他类型的存储附加到容器,具体来说,包括tmpfsimagedevpts

附加 tmpfs 存储

有时,我们需要将存储附加到容器中,而这些存储不打算持久化(例如,缓存使用)。使用卷或绑定挂载会混乱主机本地磁盘(或如果使用不同的存储驱动则混乱任何其他后端)。在这些特定情况下,我们可以使用 tmpfs 卷。

tmpfs 是一个虚拟内存文件系统,这意味着它的所有内容都在主机的虚拟内存中创建。tmpfs 的一个优点是提供更快的 I/O,因为所有的读写操作大多数发生在 RAM 中。

要将 tmpfs 卷附加到容器中,我们可以使用 --mount 选项或 --tmpfs 选项。

--mount 标志的一个重要优点是,它在存储类型、来源、目标和额外挂载选项方面更加详细和具有表现力。以下示例运行一个附加了 tmpfs 卷的 httpd 容器:

$ podman run –d –p 8080:80 \ 
   --name tmpfs_example1 \
   --mount type=tmpfs,tmpfs-size=512M,destination=/tmp \
   docker.io/library/httpd

前面的命令创建了一个 512 MB 的 tmpfs 卷,并将其挂载到容器的 /tmp 文件夹中。我们可以通过在容器中运行 mount 命令来测试是否正确挂载:

$ podman exec -it tmpfs_example1 mount | grep '\/tmp'
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,relatime,context="system_u:object_r:container_file_t:s0:c375,c804",size=524288k,uid=1000,gid=1000,inode64

这表明 tmpfs 文件系统已在容器中正确挂载。停止容器时,tmpfs 会自动丢弃:

$ podman stop tmpfs_example1 

以下示例使用 --tmpfs 选项挂载一个 tmpfs 卷:

$ podman run –d –p 8080:80 \
   --name tmpfs_example2 \
   --tmpfs /tmp:rw,size= 524288k,mode=1777 \
   docker.io/library/httpd

这个示例与前一个示例的结果相同:一个运行中的容器,带有一个 512 MB 的 tmpfs 卷,挂载在 /tmp 目录上,具有读写模式和 1777 权限。

默认情况下,tmpfs 卷会以以下挂载选项挂载到容器中——rwnoexecnosuidnodev

另一个有趣的特性是 SELinux 的自动 MCS 标签。它提供了文件系统的自动隔离,并防止任何其他容器访问内存中的数据。

附加镜像

OCI 镜像是提供启动容器的层和元数据的基础,但它们也可以在运行时附加到容器文件系统。这对于故障排除或附加外部镜像中的可用二进制文件非常有用。当 OCI 镜像被挂载到容器中时,会创建一个额外的叠加层。这意味着即使镜像以读写权限挂载,用户也不会修改原始镜像,而仅修改上层叠加层。

以下示例在 Alpine 容器中挂载一个具有读写权限的 busybox 镜像:

$ podman run -it \
   --mount type=image,src=docker.io/library/busybox,dst=/mnt,rw=true \
   alpine

重要提示

挂载的镜像必须已经缓存到主机中。Podman 只有在创建容器时镜像可用时才会拉取基础容器镜像,但它期望挂载的镜像已经存在。提前拉取这些镜像将解决此问题。

附加 devpts

这个选项对于将主机的 /dev/ 附加到容器中非常有用,同时仍然可以创建一个终端。主机的 /dev 伪文件系统使容器能够直接访问机器的物理或虚拟设备。

要创建一个带有 /dev 文件系统并附加了 devpts 设备的容器,请运行以下命令:

$ sudo podman run -it \
  -v /dev/:/dev:rslave \
  --mount type=devpts,destination=/dev/pts \
  docker.io/library/fedora

要检查挂载选项的结果,我们需要在容器内使用一个额外的工具。为此,我们可以使用以下命令进行安装:

[root@034c8a61a4fc /]# dnf install -y toolbox

生成的容器在 /dev/pts 上挂载了一个额外的、非隔离的 devpts 设备:

# mount | grep '\/dev\/pts'
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,seclabel,gid=5,mode=620,ptmxmode=000)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,context="system_u:object_r:container_file_t:s0:c299,c741",gid=5,mode=620,ptmxmode=666)

上述输出是通过在容器内运行 mount 命令提取的。

总结

在本章中,我们完成了关于容器存储和 Podman 提供的操作功能的学习。本章的内容对于理解 Podman 如何管理临时数据和持久数据非常关键,并为用户提供了操作数据的最佳实践。

在第一部分,我们学习了容器存储的重要性,以及如何在单一主机和编排的多主机环境中正确管理它。

在第二部分,我们深入研究了容器存储特性和存储驱动,特别关注了 overlayfs。

在第三部分,我们学习了如何将文件复制到容器中或从容器中复制文件。我们还看到了如何将更改提交到新镜像。

第四部分描述了附加到容器的不同存储场景,涵盖了绑定挂载、卷、tmpfs、镜像和 devpts。本节也是讨论 SELinux 与存储管理交互的完美契机,展示了如何利用 SELinux 隔离同一主机上容器之间的存储资源。

在下一章,我们将学习一个对开发人员和运维团队都非常重要的话题,即如何使用 Podman 和 Buildah(一个高级专门的镜像构建工具)来构建 OCI 镜像。

进一步阅读

请参考以下资源以获取更多信息:

第二部分:使用 Buildah 从零开始构建容器

在这一部分,你将学习容器创建过程的基础知识,运用我们在上一章中看到的理论,并选择正确的安全基础镜像,从零开始构建新的容器镜像。

本书的这一部分包括以下章节:

  • 第六章, 认识 Buildah – 从零开始构建容器

  • 第七章, 与现有应用程序构建过程的集成

  • 第八章, 选择容器基础镜像

  • 第九章, 将镜像推送到容器注册中心

第六章:认识 Buildah – 从零构建容器

容器的巨大吸引力在于,它们允许我们将应用程序打包到不可变的镜像中,这些镜像可以在系统上部署并无缝运行。在本章中,我们将学习如何使用不同的技术和工具创建镜像。这包括了解镜像构建的底层原理以及如何从零开始创建镜像。

本章将涵盖以下主要主题:

  • 使用 Podman 构建基本镜像

  • 认识 Buildah,Podman 的构建伴侣工具

  • 准备我们的环境

  • 选择我们的构建策略

  • 从零开始构建镜像

  • 从 Dockerfile 构建镜像

技术要求

在继续本章之前,需要一台已安装 Podman 的工作机器。正如在第三章中所述,运行第一个容器,书中的所有示例都是在 Fedora 34 或更高版本的系统上执行的,但可以在读者选择的操作系统上重现。

理解第四章中涉及的内容,管理运行中的容器,对轻松理解与 开放容器倡议OCI)镜像相关的概念非常有帮助。

使用 Podman 构建基本镜像

容器的 OCI 镜像是一组不可变的层,按复制写入逻辑堆叠在一起。当镜像构建时,所有层会按照精确的顺序创建,然后推送到容器注册表,容器注册表以 tar 格式存储我们的层及附加的镜像元数据。

正如我们在第二章OCI 镜像部分中学到的,比较 Podman 和 Docker,这些清单对于正确地重新组合镜像层(镜像清单和镜像索引)以及将运行时配置传递给容器引擎(镜像配置)是必要的。

在继续学习使用 Podman 构建镜像的基本示例之前,我们需要了解镜像构建的一般工作原理,以便理解其背后那些简单但非常聪明的关键概念。

构建背后的原理

容器镜像可以通过不同的方式构建,但最常见的方法,可能也是容器巨大成功的关键之一,是基于 Dockerfile。

Dockerfile,顾名思义,是 Docker 构建的主要配置文件,它是构建过程中需要执行的操作的普通列表。

随着时间的推移,Dockerfile 成为了 OCI 镜像构建的标准,并且今天在许多使用场景中得到了采用。

重要提示

为了标准化并去除与品牌的关联,Containerfile 也被引入了;它们与 Dockerfile 具有完全相同的语法,并且 Podman 原生支持。在本书中,我们将交替使用 DockerfileContainerfile 这两个术语。

我们将在下一小节详细学习 Dockerfile 的语法。现在,先聚焦于一个概念——Dockerfile 是一组构建指令,构建工具将按顺序执行这些指令。我们来看这个例子:

FROM docker.io/library/fedora
RUN dnf install -y httpd && dnf clean all -y 
COPY index.html /var/www/html
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

这个基本的 Dockerfile 示例只有四个指令:

  • FROM 指令,定义将要使用的基础镜像

  • RUN 指令,在构建过程中执行一些操作(在此示例中,使用 dnf 包管理器安装软件包)

  • COPY 指令,用于将文件或目录从构建工作目录复制到镜像中

  • CMD 指令,定义容器启动时要执行的命令

当执行示例中的 RUNCOPY 操作时,会有新的层来缓存变更,这些变更被缓存到中间层,并通过临时容器表示。这是 Docker 中的一个原生特性,它的优点是当特定层没有请求变更时,可以在后续构建中重用缓存层。所有中间容器都会生成只读层,并通过叠加图驱动合并。

用户无需手动管理缓存层——引擎会通过创建临时容器、执行 Dockerfile 指令定义的操作,然后提交的方式自动实现必要的操作。通过对所有必要指令重复相同的逻辑,Podman 在基础镜像的层上创建一个包含额外层的新镜像。

可以将镜像层压缩成单一层,以避免对叠加层性能的负面影响。Podman 提供相同的功能,并允许用户选择是否缓存中间层。

并非所有 Dockerfile 指令都会改变文件系统,只有那些会改变文件系统的指令才会创建新的镜像层;所有其他指令,比如前面示例中的 CMD 指令,仅会生成一个包含元数据的空层,不会对叠加文件系统造成变化。

通常,唯一通过有效更改文件系统来创建新层的指令是 RUNCOPYADD 指令。Dockerfile 或 Containerfile 中的其他所有指令仅创建临时中间镜像,并不会影响最终镜像的文件系统。

这也是限制 Dockerfile 中 RUNCOPYADD 指令数量的一个好理由,因为镜像中包含过多层是不好的模式,并且会影响图驱动的性能。

我们可以检查镜像的历史以及对每个层应用的操作。以下示例展示了 podman inspect 命令的输出摘录,目标镜像是从之前的 Dockerfile 示例创建的潜在镜像:

$ podman inspect myhttpd
[...omitted output]
        "History": [
            {
                "created": "2021-04-01T17:59:37.09884046Z",
                "created_by": "/bin/sh -c #(nop)  LABEL maintainer=Clement Verna \u003ccverna@fedoraproject.org\u003e",
                "empty_layer": true
            },
            {
                "created": "2021-04-01T18:00:19.741002882Z",
                "created_by": "/bin/sh -c #(nop)  ENV DISTTAG=f34container FGC=f34 FBR=f34",
                "empty_layer": true
            },
            {
                "created": "2021-07-23T11:16:05.060688497Z",
                "created_by": "/bin/sh -c #(nop) ADD file:85d7 f2d8e4f31d81b27b8e18dfc5687b5dabfaafdb2408a3059e120e4c15307b in / "
            },
            {
                "created": "2021-07-23T11:16:05.833115975Z",
                "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/bash\"]",
                "empty_layer": true
            },
            {
                "created": "2021-10-24T21:27:18.783034844Z",
                "created_by": "/bin/sh -c dnf install -y httpd \u0026\u0026 dnf clean all -y  ",
                "comment": "FROM docker.io/library/fedora:latest"
            },
            {
                "created": "2021-10-24T21:27:21.095937071Z",
                "created_by": "/bin/sh -c #(nop) COPY file: 78c6e1dcd6f819581b54094fd38a3fd8f170a2cb768101e533c964e 04aacab2e in /var/www/html "
            },
            {
                "created": "2021-10-24T21:27:21.182063974Z",
                "created_by": "/bin/sh -c #(nop) CMD [\"/usr/sbin/httpd\", \"-DFOREGROUND\"]",
                "empty_layer": true
            }
        ]
[...omitted output]

查看镜像历史的最后三项,我们可以注意到 Dockerfile 中定义的确切指令,包括最后一个 CMD 指令,该指令不会创建新层,而是创建持久存在于镜像配置中的元数据。

以这种更深入了解镜像构建逻辑的角度来看,让我们在继续 Podman 构建示例之前,先探索一下最常用的 Dockerfile 指令。

Dockerfile 和 Containerfile 指令

如前所述,Dockerfile 和 Containerfile 共享相同的语法。那些文件中的指令应该被视为(并且确实是)传递给容器引擎或构建工具的命令。本小节提供了最常用指令的概述。

所有 Dockerfile/Containerfile 指令遵循相同的模式:

# Comment
INSTRUCTION arguments

以下列表提供了最常用指令的非详尽列表:

  • FROM <image>[:<tag>]语法,用于确定正确的镜像来使用。

  • RUN <command>语法。调用的二进制文件或脚本必须存在于基础镜像或先前的层中。

如前所述,RUN指令会创建一个新的镜像层;因此,常见做法是将多个命令连接到同一个RUN指令中,以避免创建太多层。

这个示例将三个命令压缩到同一个RUN指令中:

RUN dnf upgrade -y && \
     dnf install httpd -y && \
     dnf clean all -y
  • COPY <src>… <dest>语法,并且它有一个非常有用的选项,允许我们定义目标用户和组,而不是稍后手动更改所有权——--chown=<user>:<group>

  • ADD <src>… <dest>语法。此指令还支持从源自动提取 tar 文件并直接写入目标路径。

  • podman run <image> <arguments>)或来自CMD指令。

一个ENTRYPOINT镜像不能被命令行参数覆盖。支持的格式如下:

  • ENTRYPOINT ["command", "param1", "paramN"](也称为exec格式)

  • ENTRYPOINT command param1 paramNshell格式)

如果未设置,默认值为bash -c。当设置为默认值时,命令作为参数传递给bash进程。例如,如果在运行时或在 CMD 指令中传递ps aux命令,容器将执行bash -c "ps aux"

一个常见做法是用一个自定义的脚本替换默认的ENTRYPOINT命令,该脚本具有相同的行为,并且提供更细粒度的运行时控制。

  • ENTRYPOINT指令。它可以是完整的命令或一组传递给自定义脚本或二进制文件的纯参数,设置为ENTRYPOINT。它支持的格式如下:

    • CMD ["command", "param1", "paramN"]exec格式)

    • CMD ["param1", "paramN"]parameter格式,用于将参数传递给自定义的ENTRYPOINT

    • CMD command param1 paramNshell格式)

  • LABEL <key1>=<value1> … <keyN>=<valueN>语法。

  • EXPOSE <port>/<protocol>格式。

  • ENV <key1>=<value1>… <keyN>=<valueN>格式。

环境变量也可以在 RUN 指令中设置,其作用范围仅限于该指令本身。

  • VOLUME ["/path/to/dir"]

  • VOLUME /path/to/dir

另请参阅 第五章 中的 将主机存储附加到容器 部分,了解有关卷的更多详细信息。

  • RUNCMDENTRYPOINT 指令。GID 值不是必须的。

支持的格式如下:

  • USER <username>:[<groupname>]

  • USER <UID>:[<GID>]

  • WORKDIR /path/to/workdir 格式。

  • FROM 指令。其目的是允许在子容器镜像上执行某些最终命令。

支持的格式如下:

  • ONBUILD ADD . /opt/app

  • ONBUILD RUN /opt/bin/custom-build /opt/app/src

现在我们已经学习了最常见的指令,接下来让我们深入了解第一个使用 Podman 构建的示例。

使用 Podman 运行构建

好消息——Podman 提供了与 Docker 相同的构建命令和语法。如果你从 Docker 切换过来,使用 Podman 构建镜像不会有学习曲线。从技术层面上讲,选择 Podman 作为构建工具有一个显著优势——Podman 可以在无根模式下构建容器,使用的是 fork/exec 模型。

这是与 Docker 构建相比的一步进展,后者需要与监听 Unix 套接字的守护进程进行通信才能运行构建。

让我们从基于第一个 构建过程概述 子部分中说明的 httpd Dockerfile 运行一个简单的构建开始。我们将使用以下 podman build 命令:

$ podman build -t myhttpd .
STEP 1/4: FROM docker.io/library/fedora
STEP 2/4: RUN dnf install -y httpd && dnf clean all -y  
[...omitted output]
--> 50a981094eb
STEP 3/4: COPY index.html /var/www/html
--> 73f8702c5e0
STEP 4/4: CMD ["/usr/sbin/httpd", "-DFOREGROUND"]
COMMIT myhttpd
--> e773bfee6f2
Successfully tagged localhost/myhttpd:latest e773bfee6f289012b37285a9e559bc44962de3aeed001455231b5a8f2721b8f9

在前面的示例中,为了清晰和节省空间,省略了 dnf install 命令的输出。

命令按顺序执行指令,并在最终镜像提交和标记之前保留中间层。构建步骤被编号(1/44/4),其中一些步骤(如RUNCOPY)会产生非空的层,成为镜像的 lowerDirs 部分。

第一条 FROM 指令定义了基础镜像,如果主机上不存在,系统会自动拉取该镜像。

第二条指令是RUN,它执行dnf命令以安装 httpd 软件包,并在完成后清理系统。实际执行时,这一行会作为"bash -c 'dnf install -y httpd && dnf clean all -y'"执行。

第三条 COPY 指令只是将 index.html 文件复制到默认的 httpd 文档根目录。

最后,第四步定义了默认的容器 CMD 指令。由于没有设置 ENTRYPOINT 指令,这将转换为以下命令:

"bash -c '/usr/sbin/httpd -DFOREGROUND'"

下一个示例是一个自定义的 Dockerfile/Containerfile,用于构建一个自定义的 Web 服务器:

FROM docker.io/library/fedora
# Install required packages
RUN set -euo pipefail; \
    dnf upgrade -y; \
    dnf install httpd -y; \
    dnf clean all -y; \
    rm -rf /var/cache/dnf/*
# Custom webserver configs for rootless execution
RUN set -euo pipefail; \
    sed -i 's|Listen 80|Listen 8080|' \
           /etc/httpd/conf/httpd.conf; \
    sed -i 's|ErrorLog "logs/error_log"|ErrorLog /dev/stderr|' \
           /etc/httpd/conf/httpd.conf; \
    sed -i 's|CustomLog "logs/access_log" combined|CustomLog /dev/stdout combined|' \
           /etc/httpd/conf/httpd.conf; \
    chown 1001 /var/run/httpd

# Copy web content
COPY index.html /var/www/html
# Define content volume
VOLUME /var/www/html
# Copy container entrypoint.sh script
COPY entrypoint.sh /entrypoint.sh
# Declare exposed ports
EXPOSE 8080 
# Declare default user
USER 1001
ENTRYPOINT ["/entrypoint.sh"]
CMD ["httpd"]

这个示例是为了本书的目的设计的,用来说明一些特殊的元素:

  • 使用包管理器安装的软件包应保持在最小范围。安装完运行 Web 服务器所需的httpd软件包后,清理缓存以节省层空间。

  • 多个命令可以在单一的 RUN 指令中组合在一起。然而,如果其中一个命令失败,我们不希望继续构建。为了提供容错的 shell 执行,set -euo pipefail 命令被预先添加。此外,为了提高可读性,单个命令被使用 \ 字符拆分成更多行, \ 可以作为换行符或转义字符。

  • 为了避免以 root 用户身份运行隔离的进程,实施了一系列变通方法,使得 httpd 进程以通用的 1001 用户身份运行。这些变通方法包括更新特定目录的文件权限和组所有权,预计这些目录将被非 root 用户访问。这是一项安全最佳实践,能减少容器的攻击面。

  • 容器中的一个常见模式是将应用程序日志重定向到容器的 stdoutstderr。为了这个目的,常见的 httpd 日志流已经通过对 /etc/httpd/conf/httpd.conf 文件的正则表达式进行了修改。

  • Web 服务器端口通过 EXPOSE 指令声明为暴露端口。

  • CMD 指令是一个简单的 httpd 命令,没有其他参数。这是为了说明 ENTRYPOINT 如何与 CMD 参数交互。

容器的 ENTRYPOINT 指令通过一个自定义脚本进行了修改,该脚本为 CMD 指令的管理方式带来了更多灵活性。entrypoint.sh 文件会测试容器是否以 root 用户身份执行,并检查第一个 CMD 参数——如果参数是 httpd,则执行 httpd -DFOREGROUND 命令;否则,它允许你执行任何其他命令(例如一个 shell)。以下代码是 entrypoint.sh 脚本的内容:

#!/bin/sh
set -euo pipefail
if [ $UID != 0 ]; then
    echo "Running as user $UID"
fi
if [ "$1" == "httpd" ]; then
    echo "Starting custom httpd server"
    exec $1 -DFOREGROUND
else
    echo "Starting container with custom arguments"
    exec "$@"
fi

现在,让我们使用 podman build 命令来构建镜像:

$ podman build –t myhttpd .

新构建的镜像将可用在本地主机的缓存中:

$ podman images | grep myhttpd
localhost/myhttpd latest 6dc90348520c 2 minutes ago   248 MB

构建完成后,我们可以使用 v1.0 标签和最新标签:

$ podman tag localhost/myhttpd quay.io/<username>/myhttpd:v1.0

在标记之后,镜像将准备好推送到远程注册表。我们将在第九章中更详细地讨论与注册表的交互,推送镜像到容器注册表

示例镜像将由五个层组成,包括基础 Fedora 镜像层。我们可以通过运行 podman inspect 命令检查新镜像的层数:

$ podman inspect myhttpd --format '{{ .RootFS.Layers }}'
[sha256:b6d0e02fe431db7d64d996f3dbf903153152a8f8b857cb4829 ab3c4a3e484a72
sha256:f41274a78d9917b0412d99c8b698b0094aa0de74ec8995c88e5 dbf1131494912
sha256:e57dde895085c50ea57db021bffce776ee33253b4b8cb0fe909b bbac45af0e8c
sha256:9989ee85603f534e7648c74c75aaca5981186b787d26e0cae0bc 7ee9eb54d40d
sha256:ca402716d23bd39f52d040a39d3aee242bf235f626258958b889 b40cdec88b43]

可以使用 --layers=false 选项将当前的构建层压缩为单一层。生成的镜像将只有两个层——基础 Fedora 层和压缩后的层。以下示例重新构建镜像,不缓存中间层:

$ podman build -t myhttpd --layers=false .

现在,让我们再次检查输出镜像:

$ podman inspect myhttpd --format '{{ .RootFS.Layers }}'
[sha256:b6d0e02fe431db7d64d996f3dbf903153152a8f8b857cb 4829ab3c4a3e484a72
sha256:6c279ab14837b30af9360bf337c7f9b967676a61831eee9 1012fa67083f5dcf1]

这一次,最终镜像只包含了两个预期的层。

减少层数有助于保持镜像在叠加层方面的简洁。该方法的缺点是,每次配置更改时,我们必须重新构建整个镜像,无法利用缓存的层。

在隔离方面,Podman 可以安全地在无根模式下构建镜像。实际上,这是一个非常重要的特性,因为构建时无需以具有特权的用户(如 root)身份运行。如果必须使用 root 用户进行构建,也是完全可行并得到支持的。以下示例以 root 用户身份运行构建:

# podman build -t myhttpd .

生成的镜像将仅保存在系统镜像缓存中,其层次结构将存储在 /var/lib/containers/storage/ 目录下。

Podman 构建的灵活性与其配套工具Buildah密切相关,Buildah 是一个专门用于构建 OCI 镜像的工具,提供了更大的构建灵活性。在下一节中,我们将介绍 Buildah 的特性以及它如何管理镜像构建。

认识 Buildah,Podman 的构建配套工具

Podman 在使用 Dockerfile/Containerfile 进行常规构建时表现出色,并帮助团队保留之前实现的构建管道,而无需进行新的投资。

然而,当涉及到更专业的构建任务时,或者用户需要更多的构建流程控制并可以包含脚本逻辑时,Dockerfile/Containerfile 方法会暴露其局限性。社区一直在努力寻找能够克服 Dockerfile/Containerfile 固定工作流逻辑的替代构建方法。

开发 Podman 的同一个社区推出了 Buildah(发音为build-ah)项目,这是一个支持多种构建策略的 OCI 构建管理工具。使用 Buildah 创建的镜像完全可移植并与 Docker 兼容,所有引擎都符合 OCI 镜像和运行时规范。

Buildah 是一个开源项目,遵循 Apache 2.0 许可证发布。源代码可以在 GitHub 上找到,网址为:github.com/containers/buildah

Buildah 是 Podman 的补充工具,借用了其构建逻辑,通过将其库嵌入到 Podman 中,实现在 Dockerfile 和 Containerfile 上的基本构建功能。最终编译的 Podman 二进制文件是一个静态链接的 Go 单文件,其中嵌入了 Buildah 包来管理构建步骤。

Buildah 使用 containers/image 项目(github.com/containers/image)来管理镜像的生命周期及其与注册表的交互,并使用 containers/storage 项目(github.com/containers/storage)来管理镜像和容器的文件系统层。

Buildah 的高级构建策略基于对传统 Dockerfile/Containerfile 构建和由原生 Buildah 命令驱动的构建的并行支持,这些命令能够复制 Dockerfile 指令。

通过在标准命令中复制 Dockerfile 指令,Buildah 成为一个可编写脚本的工具,能够与自定义逻辑和原生 shell 构造(如条件语句、循环或环境变量)进行插值。例如,Dockerfile 中的 RUN 指令可以用 buildah run 命令替代。

如果团队需要保留之前 Dockerfile 中实现的构建逻辑,Buildah 提供了 buildah build(或其别名 buildah bud)命令,该命令通过读取提供的 Dockerfile/Containerfile 来构建镜像。

Buildah 可以平稳地以无 root 模式运行以构建镜像;从安全角度来看,这是一个宝贵且需求量大的特性。构建过程中不需要 Unix 套接字。在本章开头,我们解释了构建始终基于容器;Buildah 也不例外,所有构建都在工作容器内执行,从基础镜像开始。

以下列表提供了 Buildah 中最常用命令的非详尽描述:

  • buildah from [options] <image> 语法。该命令的示例是 $ buildah from fedora

  • Dockerfile 的 RUN 指令;它在工作容器内运行命令。此命令接受 buildah run [options] [--] <container> <command> 语法。--(双破折号)选项用于将潜在的选项与实际的容器命令分开。该命令的示例是 buildah run <containerID> -- dnf install -y nginx

  • buildah config [options] <container> 格式。此命令的选项与不修改文件系统层但设置容器元数据的各种 Dockerfile 指令相关,例如设置 entrypoint 容器。该命令的示例是 buildah config --entrypoint/entrypoint.sh <containerID>

  • Dockerfile 的 ADD 指令;它将文件、目录甚至 URL 添加到容器中。它支持 buildah add [options] <container> <src> [[src …] <dst> 语法,并允许在一个命令中复制多个文件。该命令的示例是 buildah add <containerID> index.php /var/www.html

  • COPY 指令;它将文件、URL 和目录添加到容器中。它支持 buildah copy [options] <container> <src> [[src …] <dst> 语法。该命令的示例是 buildah copy <containerID> entrypoint.sh /

  • buildah copy [options] <container> <image_name> 语法。通过此命令创建的容器镜像可以后续标记并推送到镜像仓库。该命令的示例是 buildah commit <containerID> <myhttpd>

  • buildah build [options] [context] 语法及 buildah bud 命令别名。该命令的示例是 buildah build –t <imageName> .

  • buildah lsbuildah ps。支持的语法是 buildah containers [options]。该命令的示例是 buildah containers

  • buildah delete 命令是等效的。支持的语法是 buildah rm <container>。此命令只有一个选项,即 –all, -a 选项,用于移除所有工作容器。此命令的示例如 buildah rm <containerID>

  • buildah mount [containerID … ]。当没有传递任何参数时,命令仅显示当前已挂载的容器。此命令的示例如 buildahmount<containerID>

  • buildah images [options] [image]。支持像 JSON 这样的自定义输出格式。此命令的示例如 buildah images --json

  • buildah tag <name> <new-name> 格式。此命令的示例如 buildah tag myapp quay.io/packt/myapp:latest

  • buildah push [options] <image> [destination]。此命令的示例包括 buildah push quay.io/packt/myapp:latestbuildah push <imageID> docker://<URL>/repository:tag,以及 buildah push <imageID> oci:</path/to/dir>:image:tag

  • buildah pull [options] <image>。此命令的示例包括 buildah pull <imageName>buildah pull docker://<URL>/repository:tag,以及 buildah pull dir:</path/to/dir>

所有之前描述的命令都有相应的 man 页面,遵循 man buildah-<command> 的模式。例如,要查看 buildah run 命令的文档细节,只需在终端输入 man buildah-run

下一个示例展示了基本的 Buildah 功能。一个 Fedora 基础镜像被定制以运行 httpd 进程:

$ container=$(buildah from fedora)
$ buildah run $container -- dnf install -y httpd; dnf clean all 
$ buildah config --cmd "httpd -DFOREGROUND" $container
$ buildah config --port 80 $container
$ buildah commit $container myhttpd
$ buildah tag myhttpd registry.example.com/myhttpd:v0.0.1

上述命令将生成一个符合 OCI 标准、可移植的镜像,具有从 Dockerfile 构建的镜像的相同功能,所有这些都可以在几行代码中实现,可以包含在一个简单的脚本中。

现在我们将重点关注第一个命令:

$ container=$(buildah from fedora)

buildah from 命令从允许的注册表之一拉取一个 Fedora 镜像,并从中创建一个工作容器,返回容器的名称。我们将不只是将它打印在标准输出上,而是使用 shell 扩展语法捕获名称。从现在起,我们可以将 $container 变量传递给后续的命令,$container 保存着生成的容器名称。因此,构建命令将在此工作容器内执行。这是一种相当常见的模式,特别适合在脚本中自动化 Buildah 命令。

重要说明

在 Buildah 和 Podman 中,容器的概念之间存在细微的差异。两者都采用相同的技术来创建容器,但 Buildah 容器是短生命周期的实体,旨在被修改并提交,而 Podman 容器则应该运行长期的工作负载。

这种方法的灵活性和可嵌入性是非常显著的——Buildah 命令可以包含在任何地方,用户可以选择完全自动化的构建过程,也可以选择更具互动性的构建过程。

例如,Buildah 可以轻松地与 Ansible(开源自动化引擎)集成,利用原生连接插件自动化构建,插件可以与工作容器进行通信。

你可以选择将 Buildah 纳入 CI 流水线(如 JenkinsTektonGitLab CI/CD),以便完全控制构建和集成任务。

Buildah 也包含在云原生社区的大型项目中,例如 Shipwright 项目 (github.com/shipwright-io/build)。

Shipwright 是一个可扩展的 Kubernetes 构建框架,提供了通过自定义资源定义和不同构建工具自定义镜像构建的灵活性。Buildah 是设计构建过程时可以选择的解决方案之一。

在接下来的子章节中,我们将看到更详细、更丰富的示例。现在我们已经了解了 Buildah 的功能和使用场景概述,让我们深入了解安装和环境准备步骤。

准备我们的环境

Buildah 可以在不同的发行版上使用,并且可以通过相应的包管理器进行安装。本节提供了一些主要发行版的安装示例,并非详尽无遗。为了清晰起见,需要重申的是,本书实验环境均基于 Fedora 34:

  • dnf 命令:

    $ sudo dnf -y install buildah
    
  • apt-get 命令:

    $ sudo apt-get update
    $ sudo apt-get -y install buildah
    
  • yum 命令:

    $ sudo yum install -y buildah
    
  • yum module 命令:

    $ sudo yum module enable -y container-tools:1.0
    $ sudo yum module install -y buildah
    
  • rhel-7-server-extras-rpms 仓库并使用 yum 安装:

    $ sudo subscription-manager repos --enable=rhel-7-server-extras-rpms
    $ sudo yum -y install buildah
    
  • pacman 命令:

    $ sudo pacman –S buildah
    
  • apt-get 命令:

    $ sudo apt-get -y update
    $ sudo apt-get -y install buildah
    
  • emerge 命令:

    $ sudo emerge app-emulation/libpod
    
  • 从源代码构建:Buildah 也可以从源代码构建。为了本书的目的,我们将专注于简单的部署方法,但如果你有兴趣,下面的指南将对你尝试自己的构建有帮助:github.com/containers/buildah/blob/main/install.md#building-from-scratch

最后,Buildah 可以作为容器部署,构建任务可以在其中使用嵌套方式执行。这个过程将在 第七章与现有应用构建流程集成 中详细讲解。

安装 Buildah 到我们的主机后,我们可以继续验证安装情况。

验证安装

安装 Buildah 后,我们现在可以运行一些基本的测试命令来验证安装情况。

要查看主机本地存储中所有可用的镜像,可以使用以下命令:

$ buildah images
# buildah images

镜像列表将与通过 podman images 命令打印的列表相同,因为它们共享相同的本地存储。

还需注意,两个命令分别作为非特权用户和 root 用户执行,分别指向用户的无特权本地存储和系统范围的本地存储。

我们可以运行一个简单的测试构建来验证安装情况。这是一个很好的机会来测试一个基本的构建脚本,其唯一目的是验证 Buildah 是否能够完全运行完整的构建。

本书的目的(以及为了好玩),我们创建了以下简单的测试脚本,用来创建一个最小的 Python 3 镜像:

#!/bin/bash
BASE_IMAGE=alpine
TARGET_IMAGE=python3-minimal
if [ $UID != 0 ]; then
    echo "### Running build test as unprivileged user"
else
    echo "### Running build test as root"
fi
echo "### Testing container creation"
container=$(buildah from $BASE_IMAGE)
if [ $? -ne 0 ]; then
    echo "Error initializing working container"
fi
echo "### Testing run command"
buildah run $container apk add --update python3 py3-pip
if [ $? -ne 0 ]; then
    echo "Error on run build action"
fi
echo "### Testing image commit"
buildah commit $container $TARGET_IMAGE
if [ $? -ne 0 ]; then
    echo "Error committing final image"
fi
echo "### Removing working container"
buildah rm $container
if [ $? -ne 0 ]; then
    echo "Error removing working container"
fi
echo "### Build test completed successfully!"
exit 0

同一个测试脚本可以由非特权用户和 root 用户执行。

我们可以通过运行一个简单的容器来验证新构建的镜像,该容器执行一个 Python shell:

$ podman run -it python3-minimal /usr/bin/python3
Python 3.9.5 (default, May 12 2021, 20:44:22) 
[GCC 10.3.1 20210424] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

在成功测试我们的新 Buildah 安装后,让我们检查 Buildah 使用的主要配置文件。

Buildah 配置文件

主要的 Buildah 配置文件与 Podman 使用的配置文件相同。它们可以被利用来自定义在构建过程中执行的工作容器的行为。

在 Fedora 上,这些配置文件是通过 containers-common 包安装的,我们已经在准备你的环境部分中涵盖了它们,见 第三章运行第一个容器

Buildah 使用的主要配置文件如下:

  • /usr/share/containers/mounts.conf:此配置文件定义了在 Buildah 工作容器内自动挂载的文件和目录。

  • /etc/containers/registries.conf:此配置文件的作用是管理可以访问的镜像仓库,用于镜像的搜索、拉取和推送。

  • /usr/share/containers/policy.json:此 JSON 配置文件定义了镜像签名验证行为。

  • /usr/share/containers/seccomp.json:此 JSON 配置文件定义了容器化进程允许和禁止的系统调用。

在本节中,我们了解了如何准备主机环境来运行 Buildah。在下一节中,我们将识别可以通过 Buildah 实现的可能构建策略。

选择我们的构建策略

基本上,我们可以使用 Buildah 的三种构建策略:

  • 从现有基础镜像开始构建容器镜像

  • 从零开始构建容器镜像

  • 从 Dockerfile 开始构建容器镜像

我们已经在遇见 Buildah,Podman 的伴侣部分提供了一个从现有基础镜像构建策略的示例。由于从工作流的角度来看,这种策略与从零开始构建非常相似,因此我们将重点展示最后一种策略,它提供了极大的灵活性来创建小体积且安全的镜像。

在下一节中深入了解各种技术细节之前,让我们从高层次开始探索所有这些策略。

尽管我们可以在最流行的公共容器仓库中找到许多预构建的容器镜像,有时我们可能找不到特定的配置、设置或工具和服务组合用于我们的容器;这就是为什么容器镜像创建成为我们需要实践的一个非常重要的步骤。

此外,安全约束通常要求我们实现具有减少攻击面特性的镜像,因此,DevOps 团队必须知道如何自定义构建过程中的每一步,以实现这一结果。

牢记这些知识后,我们开始介绍第一种构建策略。

从现有基础镜像开始构建容器镜像

假设我们找到一个非常出色的预构建容器镜像,用于我们公司广泛使用的某个应用服务器。这个容器镜像的所有配置都很合适,我们可以将存储附加到正确的挂载点以持久化数据等等,但迟早我们会意识到,容器镜像中缺少我们用于故障排除的某些特定工具,或者缺少某些应该包含的库!

在另一种场景中,我们可能对预构建的镜像感到满意,但仍然需要向其中添加自定义内容——例如,客户的应用程序。

在这些情况下,解决方案会是什么呢?

在这个第一个用例中,我们可以扩展现有的容器镜像,添加内容并编辑现有的文件以满足我们的目的。在前面的基本示例中,Fedora 和 Alpine 镜像被定制化以满足不同的需求。这些镜像是通用的操作系统文件系统,没有特定的用途,但同样的概念也可以应用于更复杂的镜像。

在第二个用例中,我们可以定制一个镜像——例如,默认库 Httpd。我们可以安装 PHP 模块,然后添加我们应用程序的 PHP 文件,生成一个包含我们自定义内容的新镜像。

接下来的章节中,我们将看到如何扩展现有的容器镜像。

让我们继续讨论第二种策略。

从零开始构建容器镜像

前面的策略对于许多常见情况已经足够,在这些情况下我们可以找到一个预构建的镜像来开始工作,但有时我们希望容器化的特定用例、应用或服务可能并不常见或广泛使用。

想象一下有一个自定义的遗留应用程序,需要一些旧的库和工具,而这些库和工具在最新的 Linux 发行版中已经不再包含,或者可能已被更新的版本所替代。在这种情况下,您可能需要从一个空的容器镜像开始,并一点点地添加所有必要的内容,以便运行您的遗留应用程序。

我们在这一章中学到,实际上,我们总是会从某种初始容器镜像开始,所以这个策略和前面的那个基本是相同的。

让我们继续讨论第三种也是最后一种策略。

从 Dockerfile 开始构建容器镜像

第一章《容器技术简介》中,我们讨论了容器技术的历史以及 Docker 如何在那个背景下获得动力。Podman 作为 Docker 帮助开发的伟大概念的替代进化项目诞生了。Docker 在其项目历史中所创造的伟大创新之一,毫无疑问,就是 Dockerfile。

从高层次来看,使用这种策略时,我们可以肯定,即使使用 Dockerfile,我们最终也会采用之前提到的某种构建策略。实际上,情况与我们之前的假设不谋而合,因为 Buildah 在后台会解析 Dockerfile,并构建我们之前简单介绍的容器,符合之前构建策略的要求。

总结一下,当选择默认的构建策略时,我们需要考虑是否有任何差异或优势?显然,这个问题没有最终答案。首先,我们应该始终查看容器社区,寻找一些可能帮助我们构建过程的预构建镜像;另一方面,我们总是可以回退到从头构建的过程。最后但同样重要的是,我们可以考虑使用 Dockerfile 来轻松地分发和共享我们的构建步骤,给我们的开发团队或更广泛的容器社区。

这就结束了我们快速的高层次介绍;现在我们可以继续实际的示例!

从头构建镜像

在进入本节的详细内容并学习如何从头构建容器镜像之前,让我们先做一些测试,以验证安装的 Buildah 是否正常工作。

首先,让我们检查我们的 Buildah 镜像缓存是否为空:

# buildah images
REPOSITORY   TAG   IMAGE ID   CREATED   SIZE
# buildah containers -a
CONTAINER ID  BUILDER  IMAGE ID     IMAGE NAME                       CONTAINER NAME

重要提示

Podman 和 Buildah 共享相同的容器存储;因此,如果你之前运行过本章或本书中的其他示例,你会发现你的容器存储缓存并不是那么空!

正如我们在上一节中学到的,我们可以利用 Buildah 会输出刚创建的工作容器的名称这一事实,将其轻松存储在环境变量中,并在需要时使用它。让我们从头创建一个全新的容器:

# buildah from scratch
# buildah images
REPOSITORY   TAG   IMAGE ID   CREATED   SIZE
# buildah containers
CONTAINER ID  BUILDER  IMAGE ID     IMAGE NAME                       CONTAINER NAME
af69b9547db9     *                  scratch                          working-container

如你所见,我们使用了特殊的from scratch关键字,它告诉 Buildah 创建一个没有任何数据的空容器。如果我们运行buildah images命令,我们会注意到这个特殊镜像没有被列出。

让我们检查容器是否真的为空:

# buildah run working-container bash
2021-10-26T20:15:49.000397390Z: executable file 'bash' not found in $PATH: No such file or directory
error running container: error from crun creating container for [bash]: : exit status 1
error while running runtime: exit status 1

在我们的空容器中没有找到可执行文件——真是一个惊喜!原因是工作容器是基于一个空的文件系统创建的。

让我们看看如何轻松填充这个空容器。在以下示例中,我们将直接与底层存储交互,使用主机系统的包管理器安装运行bash shell 所需的二进制文件和库,以构建我们的容器镜像。

首先,让我们指示 Buildah 挂载容器存储,并检查它的位置:

# buildah mount working-container
/var/lib/containers/storage/overlay/b5034cc80252b6f4af2155f 9e0a2a7e65b77dadec7217bd2442084b1f4449c1a/merged

了解一下

如果你在无根模式下开始构建,Buildah 会在不同的命名空间中运行挂载,因此,使用除 vfs 以外的驱动程序时,挂载的卷可能无法从主机访问。

太好了!现在我们已经找到了它,我们可以利用主机的包管理器将所有需要的包安装到这个root文件夹中,这将是我们容器镜像的root路径:

# scratchmount=$(buildah mount working-container)
# dnf install --installroot $scratchmount --releasever 34 bash coreutils --setopt install_weak_deps=false -y

重要提示

如果你在不同于版本 34 的 Fedora 版本上运行之前的命令(例如,版本 35),则需要导入 Fedora 34 的 GPG 公钥,或使用 --nogpgcheck 选项。

首先,我们将把非常长的目录路径保存在环境变量中,然后执行 dnf 包管理器,将刚刚获取的目录路径作为安装根目录,设置我们 Fedora 操作系统的发布版本,指定我们要安装的包(bashcoreutils),最后禁用弱依赖,接受所有对系统的更改。

命令应该以 Complete! 语句结束;完成后,让我们再次尝试之前在本节中看到的失败命令:

# buildah run working-container bash
bash-5.1# cat /etc/fedora-release
Fedora release 34 (Thirty Four)

成功了!我们刚刚在空容器中安装了一个 Bash shell。现在,让我们看看如何通过其他配置步骤完成镜像创建。首先,我们需要向最终的容器镜像中添加一个命令,一旦容器启动并运行时就会执行。因此,我们将创建一个 Bash 脚本文件,其中包含一些基本命令:

# cat command.sh 
#!/bin/bash
cat /etc/fedora-release
/usr/bin/date

我们创建了一个 Bash 脚本文件,打印容器的 Fedora 版本和系统日期。该文件在复制之前必须具有执行权限:

# chmod +x command.sh

现在,我们已经为基础容器存储填充了所有需要的基础包,我们可以卸载 working-container 存储并使用 buildah copy 命令将文件从主机注入到容器中:

# buildah unmount working-container
af69b9547db93a7dc09b96a39bf5f7bc614a7ebd29435205d358e09ac 99857bc
# buildah copy working-container ./command.sh /usr/bin
659a229354bdef3f9104208d5812c51a77b2377afa5ac819e3c3a1a2887eb9f7

buildah copy 命令使我们能够在不担心挂载它或在后台处理它的情况下,直接操作底层存储。

现在我们准备通过向容器镜像中添加一些元数据来完成它:

# buildah config --cmd /usr/bin/command.sh working-container
# buildah config --created-by "podman book example" working-container
# buildah config --label name=fedora-date working-container

我们从 cmd 选项开始,之后添加了一些描述性元数据。现在我们终于可以将 working-container 提交为镜像了!

# buildah commit working-container fedora-date
Getting image source signatures
Copying blob 939ac17066d4 done  
Copying config e24a2fafde done  
Writing manifest to image destination
Storing signatures
e24a2fafdeb5658992dcea9903f0640631ac444271ed716d7f749eea7a651487

让我们清理环境并检查主机中可用的容器镜像:

# buildah rm working-container
af69b9547db93a7dc09b96a39bf5f7bc614a7ebd29435205d358e09ac99857bc

我们现在可以检查刚刚创建的容器镜像的详细信息:

# podman images
REPOSITORY             TAG         IMAGE ID      CREATED             SIZE
localhost/fedora-date  latest      e24a2fafdeb5  About a minute ago  366 MB
# podman inspect localhost/fedora-date:latest
[...omitted output]        "Labels": {
            "io.buildah.version": "1.23.1",
            "name": "fedora-date"
        },
        "Annotations": {
            "org.opencontainers.image.base.digest": "",
            "org.opencontainers.image.base.name": ""
        },
        "ManifestType": "application/vnd.oci.image.manifest.v1+json",
        "User": "",
        "History": [
            {
                "created": "2021-10-26T21:16:48.777712056Z",
                "created_by": "podman book example"
            }
        ],
        "NamesHistory": [
            "localhost/fedora-date:latest"
        ]
    }
]

从之前的输出中可以看出,容器镜像有很多元数据,可以告诉我们许多细节。我们通过之前的命令设置了其中的一些,例如 created_bynameCmd 标签;其他标签则由 Buildah 自动填充。

最后,让我们用 Podman 运行我们全新的容器镜像!

# podman run -ti localhost/fedora-date:latest 
Fedora release 34 (Thirty Four)
Tue Oct 26 21:18:29 UTC 2021

这结束了我们从零开始创建容器镜像的过程。正如我们所看到的,这不是创建容器镜像的典型方法;在许多场景和各种用例中,从一个操作系统基础镜像(例如 from fedorafrom alpine)开始,然后添加所需的包,使用这些镜像中可用的包管理器就足够了。

需要了解

一些 Linux 发行版还提供 最小化 版本的基础容器镜像(例如 fedora-minimal),它们减少了安装的包的数量以及目标容器镜像的大小。有关更多信息,请参考 www.docker.com/quay.io/

现在让我们检查如何使用 Buildah 从 Dockerfile 构建镜像。

从 Dockerfile 构建镜像

如我们在本章前面描述的那样,Dockerfile 可以是创建和共享容器镜像构建步骤的简便选项,因此在互联网上很容易找到许多源 Dockerfile。

这项活动的第一步是构建一个简单的 Dockerfile 来使用。让我们创建一个 Dockerfile 来创建一个容器化的 Web 服务器:

# mkdir webserver
# cd webserver/
[webserver]# vi Dockerfile 
[webserver]# cat Dockerfile
# Start from latest fedora container base image
FROM fedora:latest
MAINTAINER podman-book  # this should be an email
# Update the container base image
RUN echo "Updating all fedora packages"; dnf -y update; dnf -y clean all
# Install the httpd package
RUN echo "Installing httpd"; dnf -y install httpd
# Expose the http port 80
EXPOSE 80
# Set the default command to run once the container will be started
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

从前面的输出来看,我们首先创建了一个新目录,并在其中创建了一个名为 Dockerfile 的文本文件。然后,我们插入了在定义全新 Dockerfile 时常用的各种关键字和步骤;每个步骤和关键字上方都有专门的描述注释,因此该文件应该容易阅读。

让我们回顾一下,这些是我们全新 Dockerfile 中包含的步骤:

  1. 从最新的 Fedora 容器基础镜像开始。

  2. 更新容器基础镜像中的所有包。

  3. 安装 httpd 包。

  4. 暴露 HTTP 端口 80

  5. 设置容器启动后要运行的默认命令。

如本章前面所述,Buildah 提供了一个专用的 buildah build 命令来从 Dockerfile 开始构建。

让我们看看它是如何工作的:

[webserver]# buildah build -f Dockerfile -t myhttpdservice .
STEP 1/6: FROM fedora:latest
Resolved "fedora" as an alias (/etc/containers/registries.conf.d/000-shortnames.conf)
Trying to pull registry.fedoraproject.org/fedora:latest...
Getting image source signatures
Copying blob 944c4b241113 done  
Copying config 191682d672 done  
Writing manifest to image destination
Storing signatures
STEP 2/6: MAINTAINER podman-book  # this should be an email
STEP 3/6: RUN echo "Updating all fedora packages"; dnf -y update; dnf -y clean all
Updating all fedora packages
Fedora 34 - x86_64                               16 MB/s |  74 MB     00:04  
...
STEP 4/6: RUN echo "Installing httpd"; dnf -y install httpd
Installing httpd
Fedora 34 - x86_64                               20 MB/s |  74 MB     00:03    
...
STEP 5/6: EXPOSE 80
STEP 6/6: CMD ["/usr/sbin/httpd", "-DFOREGROUND"]
COMMIT myhttpdservice
Getting image source signatures
Copying blob 7500ce202ad6 skipped: already exists  
Copying blob 51b52d291273 done  
Copying config 14a2226710 done  
Writing manifest to image destination
Storing signatures
--> 14a2226710e
Successfully tagged localhost/myhttpdservice:latest
14a2226710e7e18d2e4b6478e09a9f55e60e0666dd8243322402ecf6fd1eaa0d

从之前的输出中我们可以看到,我们将以下选项传递给 buildah build 命令:

  • -f:定义 Dockerfile 的名称。默认的文件名是 Dockerfile,所以在我们的例子中,我们可以省略此选项,因为我们将文件命名为默认名称。

  • -t:定义我们正在构建的镜像的名称和标签。在我们的例子中,我们只定义了名称。镜像将默认被标记为latest

  • 最后,作为最后一个选项,我们需要设置 Buildah 工作的目录并查找 Dockerfile。在我们的例子中,我们传递了当前的 . 目录。

当然,这些并不是 Buildah 给我们配置构建的唯一选项;稍后我们将在本节中看到其中的一些选项。

回到我们刚刚执行的命令,从输出中可以看到,Dockerfile 中定义的所有步骤都按书写的顺序执行,并且以一个给定的分数编号打印,显示中间步骤与总步骤的比例。总共执行了六个步骤。

我们可以通过使用 buildah images 命令列出镜像,来检查命令的执行结果:

[webserver]# buildah images
REPOSITORY                                  TAG      IMAGE ID       CREATED          SIZE
localhost/myhttpdservice                    latest   14a2226710e7   2 minutes ago   497 MB

如我们所见,我们的容器镜像已经使用 latest 标签创建;让我们尝试运行它:

# podman run -d localhost/myhttpdservice:latest
133584ab526faaf7af958da590e14dd533256b60c10f08acba6c1209ca05a885
# podman logs 133584ab526faaf7af958da590e14dd533256b60c10f08acba6c1209ca05a885
AH00558: httpd: Could not reliably determine the server's fully qualified domain name, using 10.88.0.4\. Set the 'ServerName' directive globally to suppress this message
# curl 10.88.0.4
<!doctype html>
<html>
  <head>
    <meta charset='utf-8'>
    <meta name='viewport' content='width=device-width, initial-scale=1'>
    <title>Test Page for the HTTP Server on Fedora</title>
    <style type="text/css">
...

从输出中可以看到,我们刚刚以分离模式运行了容器;之后,我们检查了日志,以找出需要作为curl测试命令参数传递的 IP 地址。

我们刚刚以 root 用户在工作站上运行了容器,容器在 Podman 的容器网络接口上分配了一个内部 IP 地址。我们可以通过运行以下命令来检查该 IP 地址是否属于该网络:

# ip a show dev cni-podman0
14: cni-podman0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether c6:bc:ba:7c:d3:0c brd ff:ff:ff:ff:ff:ff
    inet 10.88.0.1/16 brd 10.88.255.255 scope global cni-podman0
       valid_lft forever preferred_lft forever
    inet6 fe80::c4bc:baff:fe7c:d30c/64 scope link 
       valid_lft forever preferred_lft forever

如我们所见,容器的 IP 地址来自前面10.88.0.1/16输出中的网络。

如我们所预期,buildah build命令有许多其他选项,在开发和创建全新容器镜像时可能非常有用。让我们来探讨其中一个值得一提的选项——--layers

我们已经在本章之前学习了如何与 Podman 一起使用这个选项。从 Buildah 的 1.2 版本开始,开发团队添加了这个非常棒的选项,使我们能够启用或禁用层的缓存机制。默认配置将--layers选项设置为false,这意味着 Buildah 不会保留中间层,导致构建将所有更改合并为单一层。

也可以通过环境变量来设置层的管理——例如,要启用层缓存,可以运行export BUILDAH_LAYERS=true

显然,这种选项的缺点是保留的层实际上会占用系统主机的存储空间,但另一方面,如果我们需要重新构建某个镜像,只更改最新的层而不重建整个镜像,我们就可以节省计算能力!

总结

在本章中,我们探讨了容器管理的一个基本主题——它们的创建。如果我们希望定制、保持更新并正确管理容器基础设施,这一步是必须的。我们了解到,Podman 通常与另一个名为 Buildah 的工具搭配使用,Buildah 可以帮助我们进行容器镜像构建的过程。这个工具有很多选项,像 Podman 一样,并且与 Podman 共享许多选项(包括存储!)。最后,我们介绍了 Buildah 提供的构建新容器镜像的不同策略,其中一个实际上是 Docker 生态系统继承的——Dockerfile。

本章仅是容器镜像构建主题的介绍;我们将在下一章中探索更高级的技术!

深入阅读

第七章: 与现有应用构建流程的集成

学会了如何使用 Podman 和 Buildah 创建自定义容器镜像后,我们现在可以集中精力处理一些特殊的用例,从而使我们的构建工作流更加高效和可移植。例如,在企业环境中,小尺寸镜像是非常常见的需求,出于性能和安全性的考虑。我们将探讨如何通过将构建过程拆分为不同阶段来实现这一目标。

本章还将尝试揭示一些场景,其中 Buildah 不是直接在开发者机器上运行,而是由容器编排器驱动,或者嵌入在预期调用其库或命令行界面(CLI)的自定义应用程序中。

本章将覆盖以下主要主题:

  • 多阶段容器构建

  • 在容器中运行 Buildah

  • 将 Buildah 与自定义构建工具集成

技术要求

在继续本章内容之前,需要一台已安装并正常运行 Podman 的机器。如第三章《运行第一个容器》中所述,本书中的所有示例都是在 Fedora 34 或更高版本的系统上执行的,但也可以在读者选择的操作系统上重现。

对于第六章《了解 Buildah —— 从头开始构建容器》中的内容有一个良好的理解,将有助于轻松掌握构建相关的概念,无论是使用本地 Buildah 命令还是从 Dockerfile 中构建。

多阶段容器构建

到目前为止,我们已经学习了如何使用 Podman 和 Buildah 通过 Dockerfile 或本地 Buildah 命令来创建构建,从而释放潜在的高级构建技术。

还有一个我们尚未讨论的重要点——镜像的大小。

在创建新镜像时,我们应该始终关注其最终大小,这是总层数和其中变更的文件数量的结果。

小尺寸的最小镜像具有一个很大的优势,即能够更快地从注册表中拉取。然而,较大的镜像将占用主机本地存储中大量宝贵的磁盘空间。

我们已经展示了一些最佳实践的例子,以保持镜像紧凑小巧,例如从头开始构建、清理包管理器缓存,以及将 RUNCOPYADD 指令的数量减少到最少必要量。然而,当我们需要从源代码构建应用并创建包含最终制品的最终镜像时,情况会如何?

假设我们需要构建一个容器化的 Go 应用程序——我们应该从一个包含 Go 运行时的基础镜像开始,复制源代码,并通过一系列中间步骤进行编译以生成最终的二进制文件,最重要的是在镜像缓存中下载所有必要的 Go 包。在构建结束时,我们应该清理所有源代码和下载的依赖项,并将最终的二进制文件(Go 中静态链接)放在工作目录中。一切正常,但最终的镜像仍然包含基础镜像中的 Go 运行时,而这些在编译过程结束后已经不再需要。

当 Docker 被引入并且 Dockerfile 逐渐流行时,DevOps 团队通过不同的方法解决了这个问题,他们努力保持镜像的最小化。例如,二进制构建是一种将外部编译的最终产物注入到构建镜像中的方法。这种方法解决了镜像大小的问题,但消除了运行时/编译器镜像提供的标准化构建环境的优势。

更好的方法是通过容器之间共享卷,并让最终的容器镜像从第一个构建镜像中获取编译后的产物。

为了提供一种标准化的方法,Docker 和随后的 OCI 规范引入了 FROM 指令的概念,允许后续镜像从前一个镜像中抓取内容。

在接下来的子章节中,我们将探讨如何使用 Dockerfile/Containerfile 和 Buildah 的原生命令实现这一结果。

使用 Dockerfile 进行多阶段构建

多阶段构建的第一种方法是在单个 Dockerfile/Containerfile 中创建多个阶段,每个阶段以 FROM 指令开始。

构建阶段可以使用 --from 选项从前一个阶段复制文件和文件夹,以指定源阶段。

下列示例展示了如何为 Go 应用程序创建一个最小的多阶段构建,第一个阶段作为纯粹的构建上下文,第二个阶段将最终产物复制到一个最小的镜像中:

Chapter07/http_hello_world/Dockerfile

# Builder image
FROM docker.io/library/golang
# Copy files for build
COPY go.mod /go/src/hello-world/
COPY main.go /go/src/hello-world/
# Set the working directory
WORKDIR /go/src/hello-world
# Download dependencies
RUN go get -d -v ./...
# Install the package
RUN go build -v 
# Runtime image
FROM registry.access.redhat.com/ubi8/ubi-micro:latest
COPY --from=0 /go/src/hello-world/hello-world /
EXPOSE 8080
CMD ["/hello-world"]

第一个阶段将源 main.go 文件和 go.mod 文件复制过来,以管理 Go 模块依赖关系。下载依赖包(go get -d -v ./...)后,最终应用程序将被构建(go build -v ./...)。

第二个阶段抓取最终的产物(/go/src/hello-world/hello-world)并将其复制到新镜像的根目录下。为了指定源文件应从第一个阶段复制,使用 --from=0 语法。

在第一阶段,我们使用了官方的 docker.io/library/golang 镜像,其中包含 Go 编程语言的最新版本。在第二阶段,我们使用了 ubi-micro 镜像,这是来自 Red Hat 的一个最小化镜像,具有减少的体积,针对微服务和静态链接二进制文件进行了优化。通用基础镜像将在第八章中更详细地介绍,选择容器基础镜像

以下列出的 Go 应用程序是一个基础的 web 服务器,它监听 8080/tcp 端口,并在收到 GET / 请求时打印一个包含 "Hello World!" 消息的 HTML 页面:

重要说明

本书的目的并不要求能够编写或理解 Go 编程语言。然而,了解语言的基本语法和逻辑将非常有用,因为大部分与容器相关的软件(如 Podman、Docker、Buildah、Skopeo、Kubernetes 和 OpenShift)都是用 Go 编写的。

Chapter07/http_hello_world/main.go

package main
import (
       "log"
   "net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
     log.Printf("%s %s %s\n", r.RemoteAddr, r.Method, r.URL)
     w.Header().Set("Content-Type", "text/html")
     w.Write([]byte("<html>\n<body>\n"))
     w.Write([]byte("<p>Hello World!</p>\n"))
     w.Write([]byte("</body>\n</html>\n"))
}
func main() {
     http.HandleFunc("/", handler)
     log.Println("Starting http server")
     log.Fatal(http.ListenAndServe(":8080", nil))
}

应用程序可以使用 Podman 或 Buildah 构建。在此示例中,我们选择使用 Buildah 来构建应用程序:

$ cd http_hello_world
$ buildah build -t hello-world .

最后,我们可以检查结果镜像的大小:

$ buildah images --format '{{.Name}} {{.Size}}' \     localhost/hello-world
localhost/hello-world   45 MB

最终的镜像大小只有 45 MB!

我们可以通过使用 AS 关键字为基础镜像添加自定义名称,从而改进我们的 Dockerfile。以下示例是按照此方法重新制作的 Dockerfile,关键元素用粗体显示:

# Builder image
FROM docker.io/library/golang AS builder
# Copy files for build
COPY go.mod /go/src/hello-world/
COPY main.go /go/src/hello-world/
# Set the working directory
WORKDIR /go/src/hello-world
# Download dependencies
RUN go get -d -v ./...
# Install the package
RUN go build -v ./...
# Runtime image
FROM registry.access.redhat.com/ubi8/ubi-micro:latest AS srv
COPY --from=builder /go/src/hello-world/hello-world /
EXPOSE 8080
CMD ["/hello-world"]

在前面的示例中,构建器镜像的名称设置为 builder,而最终的镜像命名为 srv。有趣的是,COPY 指令现在可以使用 --from=builder 选项指定构建器并使用自定义名称。

Dockerfile/Containerfile 构建是最常见的方法,但在实现自定义构建工作流程时仍缺乏一定的灵活性。对于那些特殊的使用案例,Buildah 原生命令为我们提供了帮助。

使用 Buildah 原生命令的多阶段构建

如前所述,多阶段构建功能是一种很好的方法,可以生成小体积且减少攻击面图像。为了在构建过程中提供更大的灵活性,Buildah 原生命令派上了用场。正如我们在第六章中提到的,遇见 Buildah——从零开始构建容器,Buildah 提供了一系列命令,复制了 Dockerfile 指令的行为,从而在将这些命令包含在脚本或自动化过程中时,提供了更大的构建过程控制。

在多阶段构建中也适用相同的概念,我们还可以在阶段之间应用额外的步骤。例如,我们可以挂载构建容器的覆盖文件系统,并提取构建的工件以发布备用包,所有这些都可以在构建最终运行时镜像之前完成。

以下示例通过将之前的 Dockerfile 指令转换为本地 Buildah 命令,在一个简单的 shell 脚本中构建相同的 hello-world Go 应用程序:

#!/bin/bash
# Define builder and runtime images
BUILDER=docker.io/library/golang
RUNTIME=registry.access.redhat.com/ubi8/ubi-micro:latest
# Create builder container
container1=$(buildah from $BUILDER)
# Copy files from host
if [ -f go.mod ]; then 
    buildah copy $container1 'go.mod' '/go/src/hello-world/'
else
    exit 1
fi
if [ -f main.go ]; then 
    buildah copy $container1 'main.go' '/go/src/hello-world/'
else 
    exit 1
fi
# Configure and start build
buildah config --workingdir /go/src/hello-world $container1
buildah run $container1 go get -d -v ./...
buildah run $container1 go build -v ./...
# Create runtime container
container2=$(buildah from $RUNTIME)
# Copy files from the builder container
buildah copy --chown=1001:1001 \
    --from=$container1 $container2 \
    '/go/src/hello-world/hello-world' '/'
# Configure exposed ports
buildah config --port 8080 $container2
# Configure default CMD
buildah config --cmd /hello-world $container2
# Configure default user
buildah config --user=1001 $container2
# Commit final image
buildah commit $container2 hello-world
# Remove build containers
buildah rm $container1 $container2

在前面的示例中,我们突出了创建两个工作容器的命令以及相关的 container1container2 变量,这些变量存储了容器 ID。

同时,注意 buildah copy 命令,我们在其中通过 --from 选项定义了源容器,并使用 --chown 选项定义了复制资源的用户和组所有者。这种方法比基于 Dockerfile 的工作流更加灵活,因为我们可以用变量、条件和循环来丰富我们的脚本。

例如,我们在 Bash 脚本中测试了使用 if 条件来检查 go.modmain.go 文件是否存在,然后再将它们复制到专用于构建的工作容器内。

现在让我们向脚本添加一个额外的功能。在以下示例中,我们通过为构建添加语义化版本控制,并在开始构建最终运行时镜像之前创建版本归档,进一步改进了之前的脚本:

重要提示

语义化版本控制的概念旨在提供一种清晰且标准化的方式来管理软件版本和依赖关系管理。它是一套标准规则,其目的是定义如何应用软件发布版本,并遵循 X.Y.Z 版本模式,其中 X 是主版本号,Y 是次版本号,Z 是补丁版本号。有关更多信息,请查看官方规范:semver.org/

#!/bin/bash
# Define builder and runtime images
BUILDER=docker.io/library/golang
RUNTIME=registry.access.redhat.com/ubi8/ubi-micro:latest
RELEASE=1.0.0
# Create builder container
container1=$(buildah from $BUILDER)
# Copy files from host
if [ -f go.mod ]; then 
    buildah copy $container1 'go.mod' '/go/src/hello-world/'
else
    exit 1
fi
if [ -f main.go ]; then 
    buildah copy $container1 'main.go' '/go/src/hello-world/'
else 
    exit 1
fi
# Configure and start build
buildah config --workingdir /go/src/hello-world $container1
buildah run $container1 go get -d -v ./...
buildah run $container1 go build -v ./...
# Extract build artifact and create a version archive
buildah unshare --mount mnt=$container1 \
    sh -c 'cp $mnt/go/src/hello-world/hello-world .'
cat > README << EOF
Version $RELEASE release notes:
- Implement basic features
EOF
tar zcf hello-world-${RELEASE}.tar.gz hello-world README
rm -f hello-world README
# Create runtime container
container2=$(buildah from $RUNTIME)
# Copy files from the builder container
buildah copy --chown=1001:1001 \
    --from=$container1 $container2 \
    '/go/src/hello-world/hello-world' '/'
# Configure exposed ports
buildah config --port 8080 $container2
# Configure default CMD
buildah config --cmd /hello-world $container2
# Configure default user
buildah  config--user=1001 $container2
# Commit final image
buildah commit $container2 hello-world:$RELEASE
# Remove build containers
buildah rm $container1 $container2

脚本中的关键更改再次以粗体突出显示。首先,我们添加了一个 RELEASE 变量,用于跟踪应用程序的发布版本。然后,我们使用 buildah unshare 命令提取了构建产物,接着使用 --mount 选项传递了容器的挂载点。用户命名空间的 unshare 是必要的,以使脚本能够以无根(rootless)模式运行。

提取构建产物后,我们使用 $RELEASE 变量在归档文件名中创建了一个 gzipped 压缩文件,并删除了临时文件。

最后,我们开始构建运行时镜像,并使用 $RELEASE 变量再次提交,作为镜像标签。

在这一部分中,我们学习了如何使用 Buildah 运行多阶段构建,使用 Dockerfiles/Containerfiles 和本地命令。在下一部分,我们将学习如何在容器内隔离 Buildah 构建。

在容器内运行 Buildah

Podman 和 Buildah 遵循 fork/exec 方法,使得它们非常容易在容器内运行,包括无根容器场景。

有许多用例需要容器化构建。如今,其中一个最常见的应用场景是基于 Kubernetes 集群的应用构建工作流。

Kubernetes 基本上是一个容器编排器,管理通过控制平面调度容器,容器运行在与 容器运行时接口 (CRI) 兼容的容器引擎上。它的设计允许在网络、存储和运行时的定制上具有极大的灵活性,推动了许多侧项目的蓬勃发展,这些项目现在正在 云原生计算基金会 (CNCF) 内孵化或成熟。

Vanilla Kubernetes(即没有任何自定义或附加组件的基本社区版本)本身没有原生构建功能,但提供了实现此功能的适当框架。随着时间的推移,许多解决方案应运而生,试图解决这一需求。

例如,OpenShift(红帽的 Kubernetes 平台)在 Kubernetes 1.0 发布时就引入了自己的构建 API 和 Source-to-Image 工具包,用于直接在 OpenShift 集群上从源代码创建容器镜像。

另一个有趣的解决方案是谷歌的 kaniko,它是一个构建工具,可以在 Kubernetes 集群内创建容器镜像,并且每个构建步骤都在用户空间中运行。

除了使用已实现的解决方案外,我们还可以设计自己在 Kubernetes 编排下运行的 Buildah 容器。我们还可以利用无根设计实现安全的构建工作流。

可以在 Kubernetes 集群之上运行 CI/CD 流水线,并将容器化构建嵌入到流水线中。Tekton Pipelines 是一个非常有趣的 CNCF 项目,提供了一种云原生的方法来实现这一目标。Tekton 允许运行由 Kubernetes 自定义资源驱动的流水线——这些是扩展基本 API 集的特殊 API。

Tekton Pipelines 由许多不同的任务组成,用户可以创建自己的任务,也可以从 Tekton Hub (hub.tekton.dev/) 中获取任务,这是一个免费的仓库,提供了许多可以立即使用的预制任务,包括来自 Buildah 的示例 (hub.tekton.dev/tekton/task/buildah)。

前面的例子有助于理解为什么容器化构建如此重要。在本书中,我们将重点关注在容器中运行构建的细节,特别关注与安全相关的约束。

运行无根权限的 Buildah 容器并使用卷存储

在本小节中的例子将使用稳定的上游 quay.io/buildah/stable Buildah 镜像。此镜像已嵌入最新的稳定 Buildah 二进制文件。

让我们运行第一个示例,使用一个无根容器,它构建主机中 ~/build 目录的内容,并将输出存储在名为 storevol 的本地卷中:

$ podman run --device /dev/fuse \
    -v ~/build:/build:z \
    -v storevol:/var/lib/containers quay.io/buildah/stable \
    buildah build -t build_test1 /build

这个例子引入了一些值得注意的特殊选项,具体如下:

  • --device /dev/fuse 选项会在容器中加载 fuse 内核模块,这是运行 fuse-overlay 命令所必需的

  • -v ~/build:/build:z 选项,它将 /root/build 目录绑定挂载到容器内部,并通过 :z 后缀为其分配适当的 SELinux 标签。

  • -v storevol:/var/lib/containers 选项,它创建了一个新的卷并挂载到默认的容器存储路径,所有层都在该位置创建。

当构建完成后,我们可以使用相同的卷运行一个新容器,检查或操作已构建的镜像:

$ podman run --rm -v storevol:/var/lib/containers quay.io/buildah/stable buildah images
REPOSITORY                  TAG      IMAGE ID       CREATED          SIZE
localhost/build_test1             latest   cd36bf58daff   12 minutes ago   283 
docker.io/library/fedora    latest   b080de8a4da3   4 days ago       159 MB

我们已经成功构建了一个镜像,其层存储在 storevol 卷中。要递归列出存储内容,我们可以使用 podman volume inspect 命令提取卷的挂载点:

$ ls -alR \
$(podman volume inspect storevol --format '{{.Mountpoint}}')

从现在开始,可以启动一个新的 Buildah 容器来对远程注册表进行身份验证,并标记和推送镜像。在下一个示例中,Buildah 会标记生成的镜像、对远程注册表进行身份验证,并最终推送镜像:

$ podman run --rm -v storevol:/var/lib/containers \
  quay.io/buildah/stable \
  sh -c 'buildah tag build_test1 \
    registry.example.com/build_test1 \
    && buildah login -u=<USERNAME> -p=<PASSWORD> \
    registry.example.com && \
    buildah push registry.example.com/build_test1'

当镜像成功推送后,可以安全地移除该卷:

# podman volume rm storevol

尽管这种方法工作得非常完美,但它有一些值得讨论的限制。

我们可以注意到的第一个限制是存储卷没有隔离,因此其他任何容器都可以访问其内容。为了解决这个问题,我们可以使用 SELinux 的 :Z 后缀,应用类别标签到该卷,并使其仅对正在运行的容器可访问。

然而,由于第二个容器默认会使用不同的类别标签,我们需要获取卷的类别标签,并使用 --security-opt label=level:s0:<CAT1>,<CAT2> 选项来运行第二个标记/推送容器。

或者,我们可以在一个容器中运行构建、标记和推送命令,如以下示例所示:

$ podman run --device /dev/fuse \
    -v ~/build:/build \
    -v secure_storevol:/var/lib/containers:Z \
    quay.io/buildah/stable \
    sh -c 'buildah build -t test2 /build && \
      buildah tag test2 registry.example.com/build_test2 && \
      buildah login -u=<USERNAME> \
      -p=<PASSWORD> \
      registry.example.com && \
      buildah push registry.example.com/build_test2'

重要提示

在前面的示例中,我们通过直接在命令中传递用户名和密码来使用 Buildah 登录。毋庸置疑,这远远不是一种可接受的安全做法。

我们可以将包含有效会话令牌的认证文件作为一个卷挂载到容器内部,而不是通过命令行传递敏感数据。

下一个示例将一个有效的 auth.json 文件挂载到构建容器中,文件存储在 /run/user/<UID> 的 tmpfs 中,然后将 --authfile /auth.json 选项传递给 buildah push 命令:

$ podman run --device /dev/fuse \
    -v ~/build:/build \ 
    -v /run/user/<UID>/containers/auth.json:/auth.json:z \
    -v secure_storevol:/var/lib/containers:Z \
    quay.io/buildah/stable \
    sh -c 'buildah build -t test3 /build && \
      buildah tag test3 registry.example.com/build_test3 && \
      buildah push --authfile /auth.json \
      registry.example.com/build_test3'

最后,我们有一个有效的示例,避免了在传递给容器的命令中暴露明文凭据。

为了提供有效的认证文件,我们需要从将运行容器化构建的主机进行认证,或者复制一个有效的认证文件。为了通过 Podman 进行认证,我们将使用以下命令:

$ podman login –u <USERNAME> -p <PASSWORD> <REGISTRY>

如果认证过程成功,获取的令牌将存储在 /run/user/<UID>/containers/auth.json 文件中,该文件存储了一个 JSON 编码的对象,其结构类似于以下示例:

{
      "auths": {
           "registry.example.com": {
                "auth": "<base64_encoded_token>"
               }
    }
}

安全警告!

如果挂载到容器内的身份验证文件包含多个针对不同注册表的身份验证记录,它们将暴露在构建容器中。这可能导致潜在的安全问题,因为容器可以使用文件中指定的令牌在这些注册表上进行身份验证。

我们刚刚描述的基于卷的方法与本地主机构建相比,在性能上有一些小的影响,但由于采用无根执行和跨主机标准化构建环境,它提供了更好的构建过程隔离,减少了攻击面。

现在,让我们来看看如何使用绑定挂载的存储来运行容器化的构建。

使用绑定挂载存储运行 Buildah 容器

在最高隔离的场景下,当 DevOps 团队遵循零信任方法时,每个构建容器应该拥有自己独立的存储,该存储在构建开始时创建,在构建完成后销毁。隔离可以通过 SELinux MCS 安全轻松实现。

为了测试这种方法,首先创建一个临时目录,用于存放构建层。我们还需要为名称生成一个随机后缀,以便在不发生冲突的情况下托管多个构建:

# BUILD_STORE=/var/lib/containers-$(echo $RANDOM | md5sum | head -c 8)
# mkdir $BUILD_STORE

重要提示

上述示例和接下来的构建都是以 root 身份执行的。

现在,我们可以运行构建并将新目录绑定挂载到容器内的/var/lib/containers文件夹,并添加:Z后缀,以确保多类别的安全隔离:

# podman run --device /dev/fuse \
    -v ./build:/build:z \
    -v $BUILD_STORE:/var/lib/containers:Z \
    -v /run/containers/0/auth.json:/auth.json \ 
    quay.io/buildah/stable \
    bash -c 'set -euo pipefail; \
      buildah build -t registry.example.com/test4 /build; \
      buildah push --authfile /auth.json \
      registry.example.com/test4'

MCS 隔离确保与其他容器的隔离。每个构建容器将拥有自己的自定义存储,这意味着每次执行时都需要重新拉取基础镜像层,因为它们从未被缓存。

尽管在隔离方面是最安全的,但这种方法也因为在构建运行过程中持续拉取镜像而提供最慢的性能。

另一方面,较不安全的方法不期望任何存储隔离,所有构建容器都将默认主机存储挂载到/var/lib/containers下。这种方法提供更好的性能,因为它允许重用主机存储中的缓存层。

SELinux 不允许容器化的进程访问主机存储;因此,我们需要放宽 SELinux 安全限制,以使用--security-opt label=disable选项运行以下示例。

以下示例使用默认主机存储运行另一个构建:

# podman run --device /dev/fuse \
  -v ./build:/build:z 
  -v /var/lib/containers:/var/lib/containers \
  --security-opt label=disable \
  -v /run/containers/0/auth.json:/auth.json \
  quay.io/buildah/stable \
  bash -c 'set -euo pipefail; \
    buildah build -t registry.example.com/test5 /build; \
    buildah push --authfile /auth.json \
    registry.example.com/test5'

本示例中描述的方法与前一种方法相反——性能更好,但安全隔离较差。

两者之间的良好折中方法是使用一个次要的只读镜像存储来提供对缓存层的访问。Buildah 支持使用多个镜像存储,且/etc/containers/storage.conf文件在 Buildah 稳定镜像内已经配置了/var/lib/shared文件夹以实现此目的。

为了验证这一点,我们可以检查 /etc/containers/storage.conf 文件的内容,在其中定义了以下部分:

# AdditionalImageStores is used to pass paths to additional Read/Only image stores
# Must be comma separated list.
additionalimagestores = [
"/var/lib/shared",
]

这样做可以实现良好的隔离和更好的性能,因为来自主机的缓存镜像将已经可用于只读存储。只读存储可以预先填充以加快构建速度,或者可以从网络共享中挂载。

以下示例展示了这种方法,通过将只读存储绑定到容器并执行构建,利用重新拉取的镜像的优势:

# podman run --device /dev/fuse \
  -v ./build:/build:z \
  -v $BUILD_STORE:/var/lib/containers:Z \
  -v /var/lib/containers/storage:/var/lib/shared:ro \ 
  -v /run/containers/0/auth.json:/auth.json:z \
  quay.io/buildah/stable \
  bash -c 'set -euo pipefail; \
  buildah build -t registry.example.com/test6 /build; \
  buildah push --authfile /auth.json \
  registry.example.com/test6'

此子节中显示的示例也受到了 Dan Walsh(Buildah 和 Podman 项目的主要负责人之一)在 Red Hat Developer 博客上撰写的一篇出色技术文章的启发;请参阅 进一步阅读 部分以获取原始文章链接。让我们通过一个原生 Buildah 命令的示例来结束这一节。

在容器内运行原生 Buildah 命令

到目前为止,我们已经演示了使用 Dockerfiles/Containerfiles 的示例,但没有任何东西阻止我们运行容器化的原生 Buildah 命令。以下示例创建了一个基于 Fedora 基础映像构建的自定义 Python 镜像:

# BUILD_STORE=/var/lib/containers-$(echo $RANDOM | md5sum | head -c 8)# mkdir $BUILD_STORE 
# podman run --device /dev/fuse \
  -e REGISTRY=<USER_DEFINED_REGISTRY:PORT> \
  --security-opt label=disable \
  -v $BUILD_STORE:/var/lib/containers:Z \
  -v /var/lib/containers/storage:/var/lib/shared:ro \
  -v /run/containers/0:/run/containers/0 \
  quay.io/buildah/stable \
  bash -c 'set -euo pipefail; \
    container=$(buildah from fedora); \
    buildah run $container dnf install -y python3 python3; \
    buildah commit $container $REGISTRY/python_demo; \
    buildah push –authfile \
    /run/containers/0/auth.json $REGISTRY/python_demo'

从性能和构建过程的角度来看,与之前的示例相比,没有任何变化。如前所述,这种方法在构建操作上提供了更多的灵活性。

如果要传递的命令太多,一个很好的解决方法是创建一个 shell 脚本,并通过专用卷将其注入到 Buildah 镜像中:

# BUILD_STORE=/var/lib/containers-$(echo $RANDOM | md5sum | head -c 8)
# PATH_TO_SCRIPT=/path/to/script
# REGISTRY=<USER_DEFINED_REGISTRY:PORT>
# mkdir $BUILD_STORE 
# podman run --device /dev/fuse \
  -v $BUILD_STORE:/var/lib/containers:Z \
  -v /var/lib/containers/storage:/var/lib/shared:ro \
  -v /run/containers/0:/run/containers/0 \
  -v $PATH_TO_SCRIPT:/root:z \ 
  quay.io/buildah/stable /root/build.sh

build.sh 是包含所有构建自定义命令的 shell 脚本文件的名称。

在这一节中,我们学习了如何在容器中运行 Buildah,涵盖了卷挂载和绑定挂载。我们学习了如何运行无根构建容器,这些容器可以轻松集成到管道或 Kubernetes 集群中,以提供端到端的应用程序生命周期工作流程。这归功于 Buildah 的灵活性,出于同样的原因,将 Buildah 嵌入到自定义构建器中也非常容易,我们将在下一节中看到。

在自定义构建器中集成 Buildah

正如我们在本章的前一节中看到的那样,Buildah 是 Podman 容器生态系统的关键组件。Buildah 是一个动态和灵活的工具,可以适应不同的场景来构建全新的容器。它有多个选项和配置可用,但我们的探索还没有结束。

Podman 和围绕它开发的所有项目都考虑到了可扩展性,使得每个可编程接口都可以从外部世界重用。

例如,Podman 通过 podman build 命令继承了 Buildah 的能力,可以用相同的原则将 Buildah 接口及其引擎嵌入我们的自定义构建器中,用于构建全新的容器。

让我们看看如何在 Go 语言中构建自定义构建器;我们会发现过程相当简单,因为 Podman、Buildah 和这个生态系统中的许多其他项目实际上都是用 Go 语言编写的。

将 Buildah 包含到我们的 Go 构建工具中

作为第一步,我们需要准备我们的开发环境,下载并安装所有所需的工具和库来创建我们的自定义构建工具。

第三章运行第一个容器,我们看到了一些 Podman 的安装方法。在接下来的章节中,我们将使用类似的过程,进行从头开始构建 Buildah 项目的初步步骤,下载其源文件并将其包含在我们的自定义构建器中。

首先,让我们确保在我们的开发主机系统上安装了所有所需的包:

# dnf install -y golang git go-md2man btrfs-progs-devel \ gpgme-devel device-mapper-devel
Last metadata expiration check: 0:43:05 ago on mar 9 nov 2021, 17:21:23.
Package git-2.33.1-1.fc35.x86_64 is already installed.
Dependencies resolved.
=============================================================================================================================================================================
Package                                                 Architecture                     Version                                    Repository                         Size
=============================================================================================================================================================================
Installing:
btrfs-progs-devel                                       x86_64                            5.14.2-1.fc35                              updates                            50 k
device-mapper-devel                                     x86_64                           1.02.175-6.fc35                            fedora                             45 k
golang                                                  x86_64                           1.16.8-2.fc35                              fedora                            608 k
golang-github-cpuguy83-md2man                           x86_64                           2.0.1-1.fc35                               fedora                            818 k
gpgme-devel                                             x86_64                           1.15.1-6.fc35                              updates                           163 k
Installing dependencies:
[... omitted output]

安装了 Go 语言核心库和一些其他开发工具后,我们已准备好为我们的项目创建目录结构并初始化它:

$ mkdir ~/custombuilder
$ cd ~/custombuilder
[custombuilder]$ export GOPATH=`pwd`

如前所述的示例所示,我们按照以下步骤进行了操作:

  1. 创建了项目根目录

  2. 定义了我们将要使用的 Go 语言根路径

我们现在准备创建我们的 Go 模块,它将通过几个简单的步骤来创建我们定制的容器镜像。

为了加快示例并避免任何书写错误,我们可以从本书的官方 GitHub 仓库下载我们将要用于本次测试的 Go 语言代码:

  1. 访问 github.com/PacktPublishing/Podman-for-DevOps 或运行以下命令:

    $ git clone https://github.com/PacktPublishing/Podman-for-DevOps 
    
  2. 之后,将 Chapter07/* 目录中的文件复制到新创建的 ~/custombuilder/ 目录中。

到目前为止,你的目录中应该有以下文件:

$ cd ~/custombuilder/src/builder
$ ls -latotal 148
drwxrwxr-x. 1 alex alex     74 9 nov 15.22 .
drwxrwxr-x. 1 alex alex     14 9 nov 14.10 ..
-rw-rw-r--. 1 alex alex   1466 9 nov 14.10 custombuilder.go
-rw-rw-r--. 1 alex alex    161 9 nov 15.22 go.mod
-rw-rw-r--. 1 alex alex 135471 9 nov 15.22 go.sum
-rw-rw-r--. 1 alex alex    337 9 nov 14.17 script.js

此时,我们可以运行以下命令,让 Go 工具获取所有需要的依赖项,为执行模块做准备:

$ go mod tidy
go: finding module for package github.com/containers/storage/pkg/unshare
go: finding module for package github.com/containers/image/v5/storage
go: finding module for package github.com/containers/storage
go: finding module for package github.com/containers/image/v5/types
go: finding module for package github.com/containers/buildah/define
go: finding module for package github.com/containers/buildah
go: found github.com/containers/buildah in github.com/containers/buildah v1.23.1
go: found github.com/containers/buildah/define in github.com/containers/buildah v1.23.1
go: found github.com/containers/image/v5/storage in github.com/containers/image/v5 v5.16.1
go: found github.com/containers/image/v5/types in github.com/containers/image/v5 v5.16.1
go: found github.com/containers/storage in github.com/containers/storage v1.37.0
go: found github.com/containers/storage/pkg/unshare in github.com/containers/storage v1.37.0

工具分析了提供的 custombuilder.go 文件,找到了所有所需的库,并填充了 go.mod 文件。

重要提示

请注意,之前的命令将验证模块是否可用,如果不可用,工具将开始从互联网下载它。所以在此步骤中请耐心等待!

我们可以通过检查之前创建的目录结构来验证前面的命令是否下载了所有必需的包:

$ cd ~/custombuilder
[custombuilder]$ ls
pkg  src
[custombuilder]$ ls -la pkg/
total 0
drwxrwxr-x. 1 alex alex  28  9 nov 18.27 .
drwxrwxr-x. 1 alex alex  12  9 nov 18.18 ..
drwxrwxr-x. 1 alex alex  20  9 nov 18.27 linux_amd64
drwxrwxr-x. 1 alex alex 196  9 nov 18.27 mod
[custombuilder]$ ls -la pkg/mod/
total 0
drwxrwxr-x. 1 alex alex 196  9 nov 18.27 .
drwxrwxr-x. 1 alex alex  28  9 nov 18.27 ..
drwxrwxr-x. 1 alex alex  22  9 nov 18.18 cache
drwxrwxr-x. 1 alex alex 918  9 nov 18.27 github.com
drwxrwxr-x. 1 alex alex  24  9 nov 18.27 go.etcd.io
drwxrwxr-x. 1 alex alex   2  9 nov 18.27 golang.org
[... omitted output]
[custombuilder]$ ls -la pkg/mod/github.com/
[... omitted output]
drwxrwxr-x. 1 alex alex  98  9 nov 18.27  containerd
drwxrwxr-x. 1 alex alex  20  9 nov 18.27  containernetworking
drwxrwxr-x. 1 alex alex 184  9 nov 18.27  containers
drwxrwxr-x. 1 alex alex 110  9 nov 18.27  coreos
[... omitted output]

我们现在准备运行我们的自定义构建器模块,但在继续之前,让我们看一下 Go 源文件中包含的关键元素。

如果我们开始查看 custombuilder.go 文件,在定义包和要使用的库之后,我们定义了模块的主函数。

在主函数中,在定义的开始,我们插入了一个基本的代码块:

  if buildah.InitReexec() {
    return
  }
  unshare.MaybeReexecUsingUserNamespace(false)

这段代码使得能够使用 unshare 包,该包可通过 github.com/containers/storage/pkg/unshare 获取。

为了利用 Buildah 的构建功能,我们必须实例化 buildah.Builder。这个对象包含定义构建步骤、配置构建和最终运行构建的所有方法。

为了创建 Builder,我们需要一个叫做 storage.Store 的对象,它来自 github.com/containers/storage 包。这个元素负责存储中间的和最终的容器镜像。让我们来看一下我们正在讨论的代码块:

buildStoreOptions, err := storage.DefaultStoreOptions(unshare.IsRootless(), unshare.GetRootlessUID())
buildStore, err := storage.GetStore(buildStoreOptions)

从前面的示例中可以看到,我们获取了默认选项,并将其传递给 storage 模块来请求一个 Store 对象。

创建 Builder 所需的另一个元素是 BuilderOptions 对象。这个元素包含我们可能分配给 Buildah 的 Builder 的所有默认和自定义选项。让我们来看一下如何定义它:

builderOpts := buildah.BuilderOptions{
  FromImage:        "node:12-alpine", // Starting image
  Isolation:        define.IsolationChroot, // Isolation environment
  CommonBuildOpts:  &define.CommonBuildOptions{},
  ConfigureNetwork: define.NetworkDefault,
  SystemContext:    &types.SystemContext {},
}

在之前的代码块中,我们定义了一个 BuilderOptions 对象,其中包含以下内容:

  • 我们将用来构建目标容器镜像的初始镜像:

    • 在这个例子中,我们选择了基于 Alpine Linux 发行版的 Node.js 镜像。这是因为,在我们的示例中,我们正在模拟构建一个 Node.js 应用程序的过程。
  • 构建开始时采用的隔离模式。在这种情况下,我们将使用 chroot 隔离,它非常适合许多构建场景——隔离较少,但要求较低。

  • 一些构建、网络和系统上下文的默认选项:

    • SystemContext 对象定义了包含在配置文件中的信息作为参数。

现在我们已经拥有了所有必要的数据来实例化 Builder,让我们来做吧:

builder, err := buildah.NewBuilder(context.TODO(), buildStore, builderOpts)

如你所见,我们调用了 NewBuilder 函数,传入了在本节前面代码中创建的所有必需选项,以准备好 Builder 来创建我们的自定义容器镜像。

现在我们准备好给 Builder 指定所需的选项来创建自定义镜像,首先让我们将包含我们应用程序的 JavaScript 文件添加到容器镜像中,正是为了这个容器镜像我们正在进行创建:

err = builder.Add("/home/node/", false, buildah.AddAndCopyOptions{}, "script.js")

我们假设 JavaScript 主文件存储在我们正在编写并使用的 Go 模块旁边,并且我们将这个文件复制到 /home/node 目录中,这是基础容器镜像期望找到这种数据的默认路径。

我们将复制到容器镜像并用于此测试的 JavaScript 程序非常简单——让我们来查看一下:

var http = require("http");
http.createServer(function(request, response) {
  response.writeHead(200, {"Content-Type": "text/plain"});
  response.write("Hello Podman and Buildah friends. This page is provided to you through a container running Node.js version: ");
  response.write(process.version);
  response.end();
}).listen(8080);

在不深入探讨 JavaScript 语言语法及其概念的情况下,我们可以注意到,从这个 JavaScript 文件来看,我们使用了 HTTP 库来监听 8080 端口上的传入请求,并用一个默认的欢迎信息来响应这些请求:Hello Podman and Buildah friends. This page is provided to you through a container running Node.js。我们还将 Node.js 的版本号附加到响应字符串中。

重要提示

请注意,JavaScript,也称为JS,是一种即时编译的高级编程语言。正如我们之前所说,我们既不会深入探讨 JavaScript 语言的定义,也不会深入 Node.js 这个最著名的运行时环境。

之后,我们配置了默认命令,以便为我们的自定义容器镜像运行:

builder.SetCmd([]string{"node", "/home/node/script.js"})

我们刚刚设置了执行 Node.js 执行运行时的命令,指向我们刚刚添加到容器镜像中的 JavaScript 程序。

为了提交我们所做的更改,我们需要获取正在处理的镜像引用。同时,我们还将定义Builder将创建的容器镜像名称:

imageRef, err := is.Transport.ParseStoreReference(buildStore, "podmanbook/nodejs-welcome")

现在,我们已经准备好提交更改并调用Buildercommit函数:

imageId, _, _, err := builder.Commit(context.TODO(), imageRef, define.CommitOptions{})
fmt.Printf("Image built! %s\n", imageId)

正如我们所看到的,我们只是请求Builder提交了更改,传递了我们之前获得的镜像引用,然后我们最终将其作为引用打印出来。

我们现在准备好运行我们的程序了!让我们执行它:

[builder]$ go run custombuilder.go 
Image built! e60fa98051522a51f4585e46829ad6a18df704dde774634dbc010baae440 4849

现在,我们可以测试刚刚构建的自定义容器镜像:

[builder]$ podman run -dt -p 8080:8080/tcp podmanbook/nodejs-welcome:latest
747805c1b59558a70c4a2f1a1d258913cae5ffc08cc026c74ad3ac21aab1 8974
[builder]$ curl localhost:8080
Hello Podman and Buildah friends. This page is provided to you through a container running Node.js version: v12.22.7

正如我们在前面的代码块中看到的,我们正在运行我们刚刚创建的容器镜像,并使用以下选项:

  • -d:分离模式,在后台运行容器

  • -t:分配一个新的伪终端

  • -p:将容器端口发布到主机系统

  • podmanbook/nodejs-welcome:latest:我们自定义容器镜像的名称

最后,我们使用curl命令行工具来请求并打印我们的 JavaScript 程序提供的 HTTP 响应,该程序被容器化到我们创建的自定义容器镜像中!

重要提示

本节描述的示例仅是 Buildah Go 模块为我们自定义镜像构建器启用的所有强大功能的简要概述。要了解更多有关各种功能、变量和代码文档的信息,您可以参考pkg.go.dev/github.com/containers/buildah上的文档。

正如我们在本节中看到的,Buildah 是一个非常灵活的工具,凭借其库,它可以在许多不同的场景中支持自定义构建器。

如果我们在互联网上搜索,可以找到许多关于 Buildah 支持创建自定义容器镜像的示例。让我们来看看其中的一些。

Quarkus 原生可执行文件在容器中的运行

Quarkus被定义为一个 Kubernetes 原生 Java 栈,利用 OpenJDK(开放 Java 开发工具包)项目和 GraalVM 项目。GraalVM 是一个具有许多特殊功能的 Java 虚拟机,例如为快速启动和低内存占用编译 Java 应用程序。

重要提示

我们不会深入讨论 Quarkus、GraalVM 以及任何其他相关项目。我们将要深入研究的示例仅供参考。我们鼓励您通过浏览它们的网页和阅读相关文档来了解更多关于这些项目的信息。

如果我们查看 Quarkus 文档网页,我们很容易发现,在经过一个长时间的教程之后,我们可以学习如何构建一个 Quarkus 原生可执行文件,接着我们可以将该可执行文件打包并在容器镜像中执行。

Quarkus 文档中提供的步骤利用了带有特殊选项的 Maven 包装器。Maven 最初作为一个 Java 构建自动化工具诞生,但后来也扩展到了其他编程语言。如果我们快速查看这个命令,会注意到 Podman 的名字出现在其中:

$ ./mvnw package -Pnative -Dquarkus.native.container-build=true -Dquarkus.native.container-runtime=podman

这意味着 Maven 包装器程序将调用 Podman 构建来创建一个包含由 Quarkus 项目提供的预配置环境以及我们正在开发的二进制应用程序的容器镜像。

我们在选项中看到了 Podman 的名字。这是因为,正如我们在第六章中所看到的,了解 Buildah——从零构建容器,Podman 通过使用其库来借用 Buildah 的构建逻辑。

要进一步探索这个示例,我们可以查看quarkus.io/guides/building-native-image

Rust 语言的 Buildah 包装器

另一个通过 Buildah 库或 CLI 制作的酷工具示例是 Rust 编程语言的 Buildah 包装器。Rust 是一种类似 C++的编程语言,旨在提供性能和安全的并发。其主项目页面可以在以下网址找到:github.com/Dennis-Krasnov/Buildah-Rust

这个 Buildah 包装器利用 Rust 包管理器Cargo来下载所需的依赖项,将其编译成一个包,并使其可分发。

重要提示

我们不会深入 Rust、Cargo 以及其他相关项目的细节。我们将深入探讨的示例仅供参考。我们鼓励你通过浏览这些项目的网页并阅读相关文档,来了解更多关于这些项目的知识。

项目主页中的示例非常简单,如下所示的代码块:

$ cd examples/
$ cargo run --example nginx
$ podman run --rm -it -p 8080:80 nginx_rust

第一个命令,在选择名为examples的目录后,执行一个简单的代码块,创建容器所需的代码,而第二个命令则通过 Buildah 本身测试刚刚由 Buildah 包装器创建的容器镜像。

我们可以查看在之前代码块中的第一个命令使用的 Rust 代码。第一个命令执行的是nginx.rs文件中的一小段代码:

use buildah_rs::container::Container;
fn main() {
    let mut container = Container::from("nginx:1.21");
    container.copy("html", "/usr/share/nginx/html").unwrap();
    container.commit("nginx_rust").unwrap();
}

如前所述,我们不会深入探讨代码语法或库本身;无论如何,代码相当简单,它只是导入了 Buildah 包装器库,从nginx:1.21创建容器镜像,最后将本地的html目录复制到容器镜像的目标路径。

要进一步探索这个示例,查看github.com/Dennis-Krasnov/Buildah-Rust

本节到此结束。通过许多有用的示例,我们了解了如何在不同的场景中集成 Buildah,以支持我们项目的容器镜像自定义构建器。

总结

在本章中,我们学习了如何在一些高级场景中利用 Podman 的伙伴工具 Buildah 来支持我们的开发项目。

我们了解了如何使用 Buildah 进行多阶段容器镜像创建,这使得我们可以使用不同的 FROM 指令创建多个阶段的构建,随后拥有从之前阶段抓取内容的镜像。

然后,我们发现有许多使用场景需要容器化构建。如今,最常见的采用场景之一是应用构建工作流运行在 Kubernetes 集群之上。基于此,我们深入探讨了容器化 Buildah。

最后,通过大量有趣的示例,我们学习了如何集成 Buildah 来创建容器镜像的自定义构建器。正如本章所示,实际上有几种选择和方法可以使用 Podman 生态系统工具来构建容器镜像,而且大多数时候,我们通常是从基础镜像开始,定制和扩展前一个操作系统层,以适应我们的使用场景。

在下一章中,我们将进一步了解容器基础镜像,如何选择它们,以及在选择时需要注意的事项。

进一步阅读

第八章:选择容器基础镜像

学习容器并获得一些经验的最快和最简单方法是开始使用预构建的容器镜像,正如我们在前几章中看到的那样。在深入研究容器管理后,我们发现有时可用的服务、其配置或甚至应用程序版本并不是我们项目所需要的。接着,我们介绍了 Buildah 及其用于构建自定义容器镜像的功能。在本章中,我们将讨论另一个在社区和企业项目中常被提问的重要话题:容器基础镜像的选择。

选择正确的容器基础镜像是容器之旅中的一项重要任务:容器基础镜像是我们的系统服务、应用程序或代码所依赖的底层操作系统层。因此,我们应该选择一个符合安全性和更新最佳实践的镜像。

本章将涵盖以下主要内容:

  • 开放容器倡议镜像格式

  • 容器镜像来自哪里?

  • 可信的容器镜像来源

  • 介绍通用基础镜像

技术要求

要完成本章内容,您需要一台安装了 Podman 的工作机器。正如在第三章《运行第一个容器》中所述,本书中的所有示例都已在 Fedora 34 或更高版本的系统上执行,但也可以在您选择的操作系统上重现。

对我们在第四章《管理运行中的容器》中涉及的内容有一个良好的理解,将帮助您轻松掌握有关容器镜像的概念。

开放容器倡议镜像格式

正如我们在第一章《容器技术简介》中所描述的那样,早在 2013 年,Docker 就在容器领域推出并迅速变得非常流行。

从高层次来看,Docker 团队引入了容器镜像和容器注册表的概念,这改变了游戏规则。另一个重要的步骤是能够将 containerd 项目从 Docker 中提取并捐赠给 云原生计算基金会CNCF)。这激励了开源社区开始认真开发可以注入到编排层(如 Kubernetes)的容器引擎。

同样地,在 2015 年,Docker 在许多其他公司(如 Red Hat、AWS、Google、Microsoft、IBM 等)的帮助下,启动了 开放容器倡议OCI),该倡议隶属于 Linux 基金会。

这些贡献者开发了运行时规范(runtime-spec)和镜像规范(image-spec),用于描述未来新容器引擎的 API 和架构应该如何创建。

在几个月的工作后,OCI 团队发布了其第一个符合 OCI 规范的容器引擎实现;该项目被命名为runc

值得详细了解容器镜像规范,并复习一些理论背景,我们在第二章比较 Podman 和 Docker 中做了介绍。

该规范定义了一个 OCI 容器镜像,包含以下内容:

  • 清单:包含镜像内容和依赖项的元数据。这还包括识别一个或多个文件系统归档的能力,这些文件系统将被解压缩以获得最终的可运行文件系统。

  • 镜像索引(可选):表示一个清单和描述符列表,可以根据目标平台提供镜像的不同实现。

  • 文件系统层集:应合并的实际层集,以构建最终的容器文件系统。

  • 配置:包含容器运行时引擎有效运行应用程序所需的所有信息,例如参数、环境变量等。

我们不会深入探讨 OCI 镜像规范的每个元素,但镜像清单值得仔细研究。

OCI 镜像清单

镜像清单定义了一组层和配置,用于为特定架构和操作系统构建的单个容器镜像。

让我们通过查看以下示例来探索 OCI 镜像清单的详细信息:

{
  "schemaVersion": 2,
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "size": 7023,
    "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f 432a537bc7"
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "size": 32654,
      "digest": "sha256:9834876dcfb05cb167a5c24953eba58c4ac89b1adf57f28f2f9d09a f107ee8f0"
    }
  ],
  "annotations": {
    "com.example.key1": "value1",
    "com.example.key2": "value2"
  }
}

在这里,我们使用以下关键字:

  • schemaVersion:一个必须设置为2的属性,确保与 Docker 的向后兼容性。

  • config:引用容器配置的属性,通过摘要来标识:

    • mediaType:该属性定义了实际的配置格式(目前仅有一种)。
  • layers:该属性提供了一组描述符对象的数组:

    • MediaType:在这种情况下,该描述符应该是层描述符允许的媒体类型之一。
  • annotations:该属性定义了镜像清单的附加元数据。

总结来说,该规范的主要目标是创建可互操作的工具,用于构建、传输和准备容器镜像以运行。

镜像清单规范有三个主要目标:

  • 为了启用镜像配置的哈希处理,从而生成唯一的 ID

  • 允许多架构镜像,因为其高级清单(镜像索引)引用了针对特定平台的镜像清单版本。

  • 能够轻松将容器镜像转换为 OCI 运行时规范

现在,让我们来了解这些容器镜像的来源。

容器镜像从哪里来?

在前面的章节中,我们使用了预构建的镜像来运行、构建或管理容器,但这些容器镜像从哪里来呢?

我们如何深入了解它们的源命令或用于构建镜像的 Dockerfile/ContainerFile 呢?

正如我们之前提到的,Docker 引入了容器镜像和容器注册中心的概念,用于存储这些镜像——甚至是公开存储。最著名的容器注册中心是 Docker Hub,但在 Docker 推出之后,其他云容器注册中心也陆续发布。

我们可以在以下云容器注册中心之间进行选择:

  • Docker Hub:这是 Docker 公司提供的托管注册中心解决方案。该注册中心还托管官方仓库和经过安全验证的镜像,供一些流行的开源项目使用。

  • Quay:这是由 CoreOS 公司创建的托管注册中心解决方案,现已成为 Red Hat 的一部分。它提供私有和公有仓库、用于安全目的的自动扫描、镜像构建以及与流行 Git 公共仓库的集成。

  • Linux 发行版注册中心:流行的 Linux 发行版通常是社区驱动的,比如 Fedora Linux,或企业驱动的,比如 Red Hat Enterprise Linux (RHEL) 。它们通常提供公共容器注册中心,但这些注册中心通常只提供已经作为系统包发布的项目或软件包。这些注册中心不对最终用户开放,它们由 Linux 发行版的维护者提供支持。

  • 公共云注册中心:Amazon、Google、Microsoft 以及其他公共云服务提供商为他们的客户提供私有容器注册中心。

我们将在第九章《推送镜像到容器注册中心》中更详细地探讨这些注册中心。

Docker Hub 和 Quay.io 是公共容器注册中心,任何人都可以在这些注册中心中找到已创建的容器镜像。这些注册中心充满了有用的自定义镜像,我们可以将其作为快速轻松测试容器镜像的起点。

仅仅下载并运行容器镜像并不总是最佳选择——我们可能会遇到非常旧且过时的软件,这些软件可能存在已知的公共漏洞,或者更糟糕的是,我们可能会下载并执行某些恶意代码,从而危及整个基础设施的安全。

正因如此,Docker Hub 和 Quay.io 通常会提供一些功能,以强调这些镜像的来源。让我们检查一下它们。

Docker Hub 容器注册中心服务

正如我们之前介绍的,Docker Hub 是最著名的容器注册中心,提供多种容器镜像,涵盖社区和企业产品。

通过查看容器镜像的详细页面,我们可以轻松发现关于该项目及其容器镜像的所有必要信息。以下截图展示了 Alpine Linux 在 Docker Hub 上的页面:

图 8.1 – Docker Hub 上的 Alpine Linux 容器镜像

](https://github.com/OpenDocCN/freelearn-devops-pt4-zh/raw/master/docs/pdmn-dop/img/B17908_08_01.jpg)

图 8.1 – Docker Hub 上的 Alpine Linux 容器镜像

如您所见,在页面顶部,我们可以找到有用的信息,包括最新的标签、支持的架构以及指向项目文档和问题报告系统的有用链接。

在 Docker Hub 页面上,当镜像属于 Docker 官方镜像计划时,我们可以在镜像名称后找到 官方镜像 标签。此计划中的镜像由 Docker 团队与上游项目的维护者直接合作进行管理。

重要说明

如果您想更深入地查看此页面,请在网页浏览器中输入 hub.docker.com/_/alpine

Docker Hub 提供的另一个重要功能(不仅适用于官方镜像)是查看用于创建某个镜像的 Dockerfile。

如果我们点击容器镜像页面上的某个可用标签,我们可以轻松查看该容器镜像标签的 Dockerfile。

点击该页面上名为 20210804, edge 的标签将会将我们重定向到 docker-alpine 项目的 GitHub 页面,该项目的 Dockerfile 如下所示:github.com/alpinelinux/docker-alpine/blob/edge/x86_64/Dockerfile

我们应始终注意并优先选择官方镜像。如果没有官方镜像,或它不符合我们的需求,那么我们需要检查内容创建者发布的 Dockerfile 和容器镜像。

Quay 容器注册服务

Quay 是一个容器注册服务,2014 年被 CoreOS 收购,现在是 Red Hat 生态系统的一部分。

该注册表允许用户在选择容器镜像后更加小心,因为它提供了安全扫描软件。

Quay 采用 Clair 项目,这是一款领先的容器漏洞扫描工具,会在仓库标签网页上显示报告,如以下截图所示:

图 8.2 – Quay 漏洞安全扫描页面

图 8.2 – Quay 漏洞安全扫描页面

在此页面上,我们可以点击 Security Scan 来检查该安全扫描的详细信息。如果您想了解更多关于此功能的信息,请访问 quay.io/repository/openshift-release-dev/ocp-release?tab=tags

正如我们所看到的,使用一个提供安全扫描功能的公共注册表可以帮助确保我们选择到正确且最安全的容器镜像。

Red Hat 生态系统目录

Red Hat 生态系统目录是 Red Hat Enterprise Linux (RHEL) 和 Red Hat OpenShift 容器平台 (OCP) 用户的默认容器注册表。该注册表的网页界面对任何用户开放,无论他们是否经过身份验证,尽管几乎所有提供的镜像都仅限于付费用户(RHEL 或 OCP 用户)。

我们谈论这个注册表,是因为它结合了我们之前讨论的所有功能。这个注册表为用户提供以下功能:

  • Red Hat 官方容器镜像

  • ContainerFile/Dockerfile 源代码,用于检查镜像内容

  • 每个分发的容器镜像的安全报告(索引)

以下截图展示了在Red Hat 生态系统目录页面上,该信息的显示方式:

图 8.3 – Red Hat 生态系统目录中的 MariaDB 容器镜像描述页面

图 8.3 – Red Hat 生态系统目录中的 MariaDB 容器镜像描述页面

如我们所见,页面展示了我们选择的容器镜像(MariaDB 数据库)的描述、版本、可用架构以及可从相应下拉菜单中选择的各种标签。有些选项卡还提到了我们感兴趣的关键字:SecurityDockerfile

通过点击Security选项卡,我们可以看到针对该镜像标签执行的漏洞扫描状态,如以下截图所示:

图 8.4 – Red Hat 生态系统目录中的 MariaDB 容器镜像安全页面

图 8.4 – Red Hat 生态系统目录中的 MariaDB 容器镜像安全页面

如我们所见,在撰写本文时,针对这个最新的镜像标签,已经识别出一个影响三个软件包的安全漏洞。在右侧,我们可以找到 Red Hat 咨询 ID,并且该 ID 链接到公共的常见漏洞与暴露CVE)列表。

通过点击Dockerfile选项卡,我们可以查看用于构建该容器镜像的源 ContainerFile:

图 8.5 – Red Hat 生态系统目录中的 MariaDB 容器镜像 Dockerfile 页面

图 8.5 – Red Hat 生态系统目录中的 MariaDB 容器镜像 Dockerfile 页面

如我们所见,我们可以查看用于构建我们将拉取并运行的容器镜像的源 ContainerFile。这是一个很棒的功能,我们可以通过点击容器镜像描述页面来访问它。

如果我们仔细查看前面的截图,可以看到 MariaDB 容器镜像是使用一个非常特殊的容器基础镜像构建的:UBI8\。

UBI 代表通用基础镜像。它是由 Red Hat 推出的一个倡议,允许每个用户(无论是否是 Red Hat 客户)打开 Red Hat 容器镜像。这使得 Red Hat 生态系统能够通过利用 Red Hat 生态系统目录中提供的所有服务,并借助直接来自 Red Hat 的更新包,进一步扩展。

我们将在本章后面进一步讨论 UBI 及其容器镜像。

可信的容器镜像源

在上一节中,我们定义了镜像注册表在有效、可用镜像中的核心作用。接下来,我们要强调的是,采用来自可信来源的镜像的重要性。

OCI 镜像用于将二进制文件和运行时打包成结构化文件系统,目的是提供特定服务。当我们拉取该镜像并在系统上运行它时,如果没有任何控制措施,我们隐式地信任作者没有使用恶意组件篡改其内容。但如今,信任是无法轻易授予的。

正如我们在第十一章容器安全》中所看到的那样,容器中可能存在许多攻击用例和恶意行为:特权升级、数据外泄和挖矿只是其中的几个例子。当容器在 Kubernetes 集群(数以千计的集群)中运行时,这些行为可能会被放大,因为它们可以轻松地跨基础设施生成恶意的 pod。

为了帮助安全团队缓解这个问题,MITRE 公司定期发布MITRE ATT&CK 矩阵,旨在识别所有可能的攻击策略及其相关技术,提供实际案例,并列出检测和缓解的最佳实践。这些矩阵中有一部分专门针对容器,其中许多技术是基于不安全镜像实现的,在这些镜像中可以成功地执行恶意行为。

重要提示

你应该优先选择来自支持漏洞扫描的注册表的镜像。如果扫描结果可用,务必仔细检查,并避免使用发现严重漏洞的镜像。

考虑到这一点,创建安全的云原生基础设施的第一步是什么?答案是选择仅来自可信来源的镜像,而第一步是配置可信注册表和模式,以阻止不允许的注册表。我们将在接下来的小节中介绍这一点。

管理可信注册表

如在第三章运行第一个容器》的《准备环境》一节中所示,Podman 可以通过配置文件管理可信注册表。

/etc/containers/registries.conf 文件(如果存在,被用户相关的 $HOME/.config/containers/registries.conf 文件覆盖)管理着一个信任的注册表列表,Podman 可以安全地联系这些注册表来搜索和拉取镜像。

让我们来看一个文件示例:

unqualified-search-registries = ["docker.io", "quay.io"]

[[registry]]
location = "registry.example.com:5000"
insecure = false

这个文件帮助我们定义 Podman 可以使用的可信注册表,因此值得进行详细分析。

Podman 接受未完全限定完全限定的镜像。两者的区别非常简单,可以通过以下方式说明:

  • 完全限定的镜像包括注册表服务器 FQDN、命名空间、镜像名称和标签。例如,docker.io/library/nginx:latest 就是一个完全限定的镜像。它有一个完整的名称,无法与任何其他 Nginx 镜像混淆。

  • 未限定的镜像只包括镜像的名称。例如,nginx镜像在搜索的注册表中可能有多个实例。大多数通过基本命令podman search nginx得到的镜像都不是官方镜像,应当进行详细分析以确保它们是可信的。输出可以通过OFFICIAL标志和STARS数量(更多更好)进行筛选。

注册表配置文件的第一个全局设置是unqualified-search-registry数组,该数组定义了未限定镜像的注册表搜索列表。当用户运行podman search <image_name>命令时,Podman 会在此列表中定义的注册表中进行搜索。

通过从列表中移除一个注册表,Podman 将停止在该注册表中搜索。然而,Podman 仍然能够从外部注册表拉取完全限定的镜像。

为了管理单一注册表并为特定镜像创建匹配模式,我们可以使用[[registry]] Tom's Obvious, Minimal Language (TOML) 表格。 这些表格的主要设置如下:

  • prefix:用于定义镜像名称,支持多种格式。通常,我们可以通过遵循host[:port]/namespace[/_namespace_…]/repo(:_tag|@digest)模式来定义镜像,尽管也可以使用更简洁的模式,如host[:port]host[:port]/namespace,甚至[*.]host。按照这种方式,用户可以为注册表定义一个通用前缀,或为匹配特定镜像或标签定义一个更详细的前缀。给定一个完全限定的镜像,如果两个[[registry]]表格有一个部分匹配的前缀,则使用最长的匹配模式。

  • insecure:这是一个布尔值(truefalse),允许使用未加密的 HTTP 连接或基于不受信任证书的 TLS 连接。

  • blocked:这是一个布尔值(truefalse),用于定义被阻止的注册表。如果设置为 true,则与前缀匹配的注册表或镜像会被阻止。

  • location:该字段定义了注册表的位置。默认情况下,它等于prefix,但可以有不同的值。在这种情况下,匹配自定义前缀命名空间的模式将解析为location值。

除了主要的[[registry]]表格外,我们还可以定义一个[[registry.mirror]] TOML 表格数组,以提供通往主注册表或注册表命名空间的备用路径。

当提供多个镜像时,Podman 将首先在它们之间搜索,然后回退到主[[registry]]表格中定义的位置。

以下示例通过定义一个命名空间注册表条目及其镜像,扩展了之前的内容:

unqualified-search-registries = ["docker.io", "quay.io"]
[[registry]]
location = "registry.example.com:5000/foo"
insecure = false
[[registry.mirror]]
location = "mirror1.example.com:5000/bar"
[[registry.mirror]]
location = "mirror2.example.com:5000/bar"

根据此示例,如果用户尝试拉取标记为registry.example.com:5000/foo/app:latest的镜像,Podman 将首先尝试mirror1.example.com:5000/bar/app:latest,然后尝试mirror2.example.com:5000/bar/app:latest,如果失败,则回退到registry.example.com:5000/foo/app:latest

使用前缀提供更大的灵活性。在下面的示例中,所有匹配example.com/foo的镜像将被重定向到镜像位置,并在末尾回到主位置:

unqualified-search-registries = ["docker.io", "quay.io"]
[[registry]]
prefix = "example.com/foo"
location = "registry.example.com:5000/foo"
insecure = false
[[registry.mirror]]
location = "mirror1.example.com:5000/bar"
[[registry.mirror]]
location = "mirror2.example.com:5000/bar"

在这个示例中,当我们拉取example.com/foo/app:latest镜像时,Podman 将尝试拉取mirror1.example.com:5000/bar/app:latest,然后是mirror2.example.com:5000/bar/app:latestregistry.example.com:5000/foo/app:latest

可以更高级地使用镜像,例如在断开连接的环境中用私有镜像替换公共注册表。以下示例将docker.ioquay.io注册表重新映射到具有不同命名空间的私有镜像:

[[registry]]
prefix="quay.io"
location="mirror-internal.example.com/quay"
[[registry]]
prefix="docker.io"
location="mirror-internal.example.com/docker"

重要提示

镜像注册表应该与镜像仓库保持同步。因此,管理员或 SRE 团队应实施镜像同步策略以保持仓库更新。

最后,我们将学习如何阻止不被认为是可信的源。此行为可能会影响单个镜像、命名空间或整个注册表。

以下示例告诉 Podman 不要搜索或拉取来自阻止的注册表的镜像:

[[registry]]
location = "registry.rogue.io"
blocked = true

可以通过传递特定命名空间来细化阻止策略,而不是阻止整个注册表。在下面的示例中,定义在prefix字段中的每个镜像搜索或拉取与quay.io/foo命名空间模式匹配的操作将被阻止:

[[registry]]
prefix = "quay.io/foo/"
location = "docker.io"
blocked = true

根据此模式,如果用户尝试拉取名为quay.io/foo/nginx:latestquay.io/foo/httpd:v2.4的镜像,则匹配前缀并阻止拉取。当拉取quay.io/bar/fedora:latest镜像时不会执行任何阻止操作。

用户还可以使用与命名空间相同的方法为单个镜像或甚至单个标签定义非常具体的阻止规则。以下示例阻止特定镜像标签:

[[registry]]
prefix = "internal-registry.example.com/dev/app:v0.1"
location = "internal-registry.example.com "
blocked = true

可以结合多个阻止规则,并在其上添加镜像表。

重要提示

在一个运行多台 Podman 的复杂基础架构中(例如,开发者工作站),一个聪明的想法是使用配置管理工具保持注册表的配置文件更新,并声明性地应用注册表的过滤器。

完全限定的镜像名称可能会变得非常长,如果我们总结注册表的 FQDN、命名空间、仓库和标签。可以使用[aliases]表创建别名,以允许使用短镜像名称。这种方法可以简化镜像管理并减少人为错误。但是,别名不处理镜像标签或摘要。

以下示例定义了一系列常用镜像的别名:

[aliases]
"fedora" = "registry.fedoraproject.org/fedora"
"debian" = "docker.io/library/debian"

当别名匹配短名称时,立即使用,而不会搜索unqualified-search-registries列表中定义的注册表。

重要提示

我们可以在/etc/containers/registries.conf.d/文件夹内创建自定义文件,以定义别名,而不会使主配置文件臃肿。

通过这一点,我们已经学会了如何管理可信的来源,并屏蔽不需要的镜像、注册表或命名空间。这是一个安全最佳实践,但它并不能解除我们选择一个适合我们需求、可信赖且攻击面最小的有效镜像的责任。在我们构建新的应用程序时也是如此,基础镜像必须是轻量且安全的。Red Hat 的 UBI 镜像可以为这个问题提供一个有用的解决方案。

介绍通用基础镜像

在企业环境中工作时,许多用户和公司选择 RHEL 作为执行工作负载的操作系统,以保证可靠性和安全性。基于 RHEL 的容器镜像也可以使用,它们利用与操作系统版本相同的包版本。所有发布的 RHEL 安全更新会立即应用到 OCI 镜像上,使得这些镜像成为构建生产级应用程序的强大且安全的基础。

不幸的是,没有 Red Hat 订阅的用户无法公开访问 RHEL 镜像。已经激活有效订阅的用户可以在其 RHEL 系统上自由使用这些镜像,并在其上构建自定义镜像,但如果没有遵守 Red Hat 企业协议,不能自由再分发这些镜像。

那么,为什么要担心呢?有很多常用镜像可以替代它们。这确实是对的,但在可靠性和安全性方面,许多公司选择坚持使用企业级解决方案,容器领域也不例外。

基于这些原因,并且为了应对 RHEL 镜像的再分发限制,Red Hat 创建了通用基础镜像,也叫UBI。UBI 镜像是可以自由再分发的,可用于构建容器化的应用程序、中间件和实用工具,并且始终由 Red Hat 进行维护和升级。

UBI 镜像基于当前受支持的 RHEL 版本:在撰写本文时,UBI7UBI8镜像已经发布(分别基于 RHEL7 和 RHEL8),此外还有UBI9-beta镜像,它基于 RHEL9-beta。一般来说,我们可以将 UBI 镜像视为 RHEL 操作系统的一个子集。

所有 UBI 镜像都可以在公共的 Red Hat 注册表(registry.access.redhat.com)和 Docker Hub(docker.io)上找到。

目前有四种不同的 UBI 镜像,每种镜像都针对特定的使用场景进行了优化:

  • 标准版:这是标准的 UBI 镜像,具有最全面的功能和包支持。

  • 精简版:这是一个经过简化的标准镜像版本,具有最基本的包管理功能。

  • 微型版:这是一个精简版的 UBI 镜像,没有包管理器。

  • systemd初始化系统,使你可以在单个容器中管理多个服务的执行。

这些镜像免费使用和再分发,可以用于自定义镜像中。我们将详细描述每种镜像,首先介绍 UBI 标准镜像。

UBI Standard 镜像

UBI Standard 镜像是最完整的 UBI 镜像版本,也是最接近标准 RHEL 镜像的版本。它包含了 YUM 包管理器,这是在 RHEL 中可用的,并且可以通过安装其专用软件仓库中的包进行自定义;也就是说,ubi-8-baseosubi-8-appstream

以下示例展示了一个使用标准 UBI8 镜像来构建最小化 httpd 服务器的 Dockerfile/ContainerFile:

FROM registry.access.redhat.com/ubi8
# Update image and install httpd
RUN yum update -y && yum install -y httpd && yum clean all –y
# Expose the default httpd port 80
EXPOSE 80
# Run the httpd
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

UBI Standard 镜像是为通用应用程序和在 RHEL 上可用的包设计的,并且已经包含了一些基本系统工具(包括 curltarvisedgzip)和 OpenSSL 库,同时保持了小巧的体积(大约 230 MiB):更少的包意味着更轻量级的镜像和更小的攻击面。

如果 UBI Standard 镜像仍然被认为太大,UBI Minimal 镜像可能是一个不错的选择。

UBI Minimal 镜像

UBI Minimal 镜像是 UBI Standard 镜像的精简版本,专为自包含应用及其运行时(如 Python、Ruby、Node.js 等)设计。因此,它的体积更小,包的选择也更少,并且不包含 YUM 包管理器;它已被一个叫做 microdnf 的最小化工具所替代。UBI Minimal 镜像比 UBI Standard 镜像更小,大小大约是后者的一半。

以下示例展示了一个使用 UBI 8 Minimal 镜像来构建概念验证 Python Web 服务器的 Dockerfile/ContainerFile:

# Based on the UBI8 Minimal image
FROM registry.access.redhat.com/ubi8-minimal

# Upgrade and install Python 3.6
RUN microdnf upgrade && microdnf install python3

# Copy source code
COPY entrypoint.sh http_server.py /

# Expose the default httpd port 80
EXPOSE 8080
# Configure the container entrypoint
ENTRYPOINT ["/entrypoint.sh"]

# Run the httpd
CMD ["/usr/bin/python3", "-u", "/http_server.py"]

通过查看由容器执行的 Python Web 服务器的源代码,我们可以看到,当收到 HTTP GET 请求时,Web 服务器处理程序会打印出一个 Hello World! 字符串。该服务器还通过 Python signal 模块管理信号终止,允许容器优雅地停止:

#!/usr/bin/python3
import http.server
import socketserver
import logging
import sys
import signal
from http import HTTPStatus
port = 8080
message = b'Hello World!\n'
logging.basicConfig(
  stream = sys.stdout, 
  level = logging.INFO
)
def signal_handler(signum, frame):
  sys.exit(0)
class Handler(http.server.SimpleHTTPRequestHandler):
  def do_GET(self):
    self.send_response(HTTPStatus.OK)
    self.end_headers()
    self.wfile.write(message)

if __name__ == "__main__":
  signal.signal(signal.SIGTERM, signal_handler)
  signal.signal(signal.SIGINT, signal_handler)
  try:
    httpd = socketserver.TCPServer(('', port), Handler)
    logging.info("Serving on port %s", port)
    httpd.serve_forever()
  except SystemExit:
    httpd.shutdown()
    httpd.server_close()

最后,Python 可执行文件通过一个最小化的入口脚本被调用:

#!/bin/bash
set -e
exec $@

脚本会启动通过 CMD 指令中的数组传递的命令。还要注意,-u 选项被传递给 Python 可执行文件。这启用了无缓冲输出,并使容器实时打印访问日志。

让我们尝试构建并运行容器,看看会发生什么:

$ buildah build -t python_httpd .
$ podman run -p 8080:8080 python_httpd
INFO:root:Serving on port 8080

有了这些,我们的最小化 Python httpd 服务器已经准备好运行,并提供许多几乎没什么用处但温暖的 Hello World! 响应。

UBI Minimal 最适合这些用例。然而,可能还需要一个更小的镜像。这正是 UBI Micro 镜像的完美应用场景。

UBI Micro 镜像

UBI Micro 镜像是 UBI 系列中最新的成员。它的基本理念是提供一个无发行版的镜像,一个简化的包管理器,没有所有不必要的包,提供一个非常小的镜像,同时也能提供最小的攻击面。减少攻击面是实现安全、最小化镜像的必要条件,这样的镜像更难被利用。

UBI 8 Micro 镜像在多阶段构建中表现出色,其中第一阶段生成最终的制品,第二阶段将它们复制到最终镜像中。以下示例展示了一个基本的多阶段 Dockerfile/ContainerFile,在 UBI Standard 容器内构建一个最小的 Golang 应用程序,同时将最终的制品复制到 UBI Micro 镜像中:

# Builder image
FROM registry.access.redhat.com/ubi8-minimal AS builder
# Install Golang packages
RUN microdnf upgrade && \
    microdnf install golang && \
    microdnf clean all
# Copy files for build
COPY go.mod /go/src/hello-world/
COPY main.go /go/src/hello-world/
# Set the working directory
WORKDIR /go/src/hello-world
# Download dependencies
RUN go get -d -v ./...
# Install the package
RUN go build -v ./...
# Runtime image
FROM registry.access.redhat.com/ubi8/ubi-micro:latest
COPY --from=builder /go/src/hello-world/hello-world /
EXPOSE 8080
CMD ["/hello-world"]

构建的输出结果是一个大约 45 MB 的镜像。

UBI Micro 镜像没有内置的包管理器,但仍然可以使用 Buildah 原生命令安装额外的包。在 RHEL 系统上,这个方法效果良好,因为所有的 Red Hat GPG 证书都已安装。

以下示例展示了一个可以在 RHEL 8 上执行的构建脚本。它的目的是在 UBI Micro 镜像上使用主机的 yum 包管理器安装额外的 Python 包:

#!/bin/bash
set -euo pipefail
if [ $UID -ne 0 ]; then
    echo "This script must be run as root"
    exit 1
fi
container=$(buildah from registry.access.redhat.com/ubi8/ubi-micro)
mount=$(buildah mount $container)
yum install -y \
  --installroot $mount \
  --setopt install_weak_deps=false \
  --nodocs \ 
  --noplugins \
  --releasever 8 \
  python3
yum clean all --installroot $mount
buildah umount $container
buildah commit $container micro_httpd

注意,yum install 命令通过传递 --installroot $mount 选项来执行,这告诉安装程序使用工作容器挂载点作为临时根目录来安装包。

UBI Minimal 和 UBI Micro 镜像非常适合实现微服务架构,在这种架构中,我们需要将多个容器协调在一起,每个容器运行一个特定的微服务。

现在,让我们看看 UBI Init 镜像,它允许我们在容器内协调多个服务的执行。

UBI Init 镜像

容器开发中的一个常见模式是创建高度专业化的镜像,每个镜像内部运行一个单独的组件。

要实现多层应用程序,例如具有前端、中间件和后端的应用程序,最佳实践是创建并协调多个容器,每个容器运行一个特定的组件。目标是拥有最小化且高度专业化的容器,每个容器运行自己的服务/进程,同时遵循保持简单,傻瓜KISS)哲学,这一哲学自 UNIX 系统诞生以来便被实现。

尽管这种方法适用于大多数用例,但在某些特殊场景中,它并不总是合适,比如当需要协调多个进程时。举个例子,当我们需要共享所有容器命名空间给多个进程,或是当我们只想要一个单一的、超级镜像时。

容器镜像通常创建时没有初始化系统,容器内部执行的进程(由 CMD 指令调用)通常会获得PID 1

基于此,Red Hat 引入了 UBI Init 镜像,它运行一个最小的 1 来执行。

UBI Init 镜像比 Standard 镜像稍小,但比 Minimal 镜像提供更多的可用包。

默认的 CMD 设置为 /sbin/init,这对应于 Systemd 进程。Systemd 会忽略 SIGTERMSIGKILL 信号,这些信号通常用于通过 Podman 停止运行的容器。由于这个原因,镜像被配置为通过在镜像的 Dockerfile 中传递 STOPSIGNAL SIGRTMIN+3 指令来发送 SIGRTMIN+3 信号以进行终止。

以下示例展示了一个 Dockerfile/ContainerFile,该文件安装 httpd 包并配置一个 systemd 单元来运行 httpd 服务:

FROM registry.access.redhat.com/ubi8/ubi-init
RUN yum -y install httpd && \
         yum clean all && \ 
         systemctl enable httpd
RUN echo "Successful Web Server Test" > /var/www/html/index.html
RUN mkdir /etc/systemd/system/httpd.service.d/ && \
         echo -e '[Service]\nRestart=always' > /etc/systemd/system/httpd.service.d/httpd.conf
EXPOSE 80
CMD [ "/sbin/init" ]

注意 RUN 指令,在这里我们创建了 /etc/systemd/system/httpd.service.d/ 文件夹和 Systemd 单元文件。这个最小示例可以通过复制预先编辑的单元文件来替代,这在需要创建多个服务时特别有用。

我们可以构建并运行镜像,使用 ps 命令检查容器内 init 系统的行为:

$ buildah build -t init_httpd .
$ podman run -d --name httpd_init -p 8080:80 init_httpd 
$ podman exec -ti httpd_init /bin/bash
[root@b4fb727f1907 /]# ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.1  0.0  89844  9404 ?        Ss   10:30   0:00 /sbin/init
root          10  0.0  0.0  95552 10636 ?        Ss   10:30   0:00 /usr/lib/systemd/systemd-journald
root          20  0.1  0.0 258068 10700 ?        Ss   10:30   0:00 /usr/sbin/httpd -DFOREGROUND
dbus          21  0.0  0.0  54056  4856 ?        Ss   10:30   0:00 /usr/bin/dbus-daemon --system --address=systemd: --nofork --nopidfile --systemd-activation --syslog-only
apache        23  0.0  0.0 260652  7884 ?        S    10:30   0:00 /usr/sbin/httpd -DFOREGROUND
apache        24  0.0  0.0 2760308 9512 ?        Sl   10:30   0:00 /usr/sbin/httpd -DFOREGROUND
apache        25  0.0  0.0 2563636 9748 ?        Sl   10:30   0:00 /usr/sbin/httpd -DFOREGROUND
apache        26  0.0  0.0 2563636 9516 ?        Sl   10:30   0:00 /usr/sbin/httpd -DFOREGROUND
root         238  0.0  0.0  19240  3564 pts/0    Ss   10:30   0:00 /bin/bash
root         247  0.0  0.0  51864  3728 pts/0    R+   10:30   0:00 ps aux

请注意,/sbin/init 进程以 PID 1 执行,并且它会生成 httpd 进程。容器还执行了 dbus-daemon,这是 Systemd 用来暴露其 API 的进程,同时还执行了 systemd-journald 来处理日志。

通过这种方法,我们可以在同一容器中添加多个应该共同工作的服务,并让它们由 Systemd 协调。

到目前为止,我们已经了解了四个当前可用的 UBI 镜像,并演示了它们如何用于创建自定义应用程序。许多公共的 Red Hat 镜像是基于 UBI 的。让我们来看一下。

其他基于 UBI 的镜像

Red Hat 使用 UBI 镜像来生成许多预构建的专用镜像,特别是对于运行时。通常,它们不受再分发限制。

这使得可以为 Python、Quarkus、Golang、Perl、PDP、.NET、Node.js、Ruby 和 OpenJDK 等语言、运行时和框架创建运行时镜像。

UBI 也被用作 Source-to-Image (s2i) 框架的基础镜像,该框架用于在 OpenShift 中原生构建应用程序,而无需使用 Dockerfile。通过 s2i,用户可以从自定义脚本和应用程序源代码中组装镜像。

最后但同样重要的是,Red Hat 支持的 Buildah、Podman 和 Skopeo 的发行版本是使用 UBI 8 镜像打包的。

超越 Red Hat 的提供,其他供应商也使用 UBI 镜像来发布他们的镜像——Intel、IBM、Isovalent、Cisco、Aqua Security 等许多公司也采用 UBI 作为他们在 Red Hat Marketplace 上发布的官方镜像的基础。

总结

在本章中,我们学习了 OCI 镜像规范以及容器注册表的作用。

此外,我们学习了如何采用安全的镜像注册表,并通过自定义策略筛选出这些注册表,允许我们阻止特定的注册表、命名空间和镜像。

最后,我们介绍了 UBI 作为一种基于 RHEL 包创建轻量级、可靠且可再分发镜像的解决方案。

通过本章所学的知识,你应该能够更详细地理解 OCI 镜像规范,并安全地管理镜像注册表。

在下一章中,我们将探讨私有注册表与公共注册表之间的区别,以及如何在本地创建私有注册表。最后,我们将学习如何使用专门的Skopeo工具管理容器镜像。

进一步阅读

要了解更多本章所涉及的主题,请查看以下资源:

第九章:将镜像推送到容器注册表

在上一章中,我们探讨了容器基础镜像的一个非常重要的概念。如我们所见,为我们的容器明智地选择基础镜像非常重要,应该使用来自可信容器注册表和开发社区的官方容器镜像。

但是,一旦我们选择了首选的基础镜像并构建了最终的容器镜像,我们就需要一种方法将我们的工作进一步分发到计划运行的各种目标主机上。

分发容器镜像的最佳选项是将其推送到容器注册表,然后让所有目标主机拉取并运行该容器镜像。

因此,在本章中,我们将覆盖以下主要主题:

  • 什么是容器注册表?

  • 基于云的和本地的容器注册表

  • 使用 Skopeo 管理容器镜像

  • 运行本地容器注册表

技术要求

在继续本章及其示例之前,需要一台已安装并正常运行 Podman 的机器。如第三章《运行第一个容器》所述,本书中的所有示例都在 Fedora 34 或更高版本的系统上执行,但可以在你选择的操作系统上复制。

理解第四章《管理运行中的容器》和第八章《选择容器基础镜像》中的内容,将有助于轻松掌握有关容器注册表的概念。

什么是容器注册表?

容器注册表只是容器镜像仓库的集合,用于与需要动态拉取和运行容器镜像的系统配合使用。

容器注册表上可用的主要功能如下:

  • 仓库管理

  • 推送容器镜像

  • 标签管理

  • 拉取容器镜像

  • 身份验证管理

接下来我们将在以下各节中详细介绍每个特性。

仓库管理

容器注册表的最重要特性之一是通过仓库管理容器镜像。根据我们选择的容器注册表实现方式,我们肯定会找到一个网页界面或命令行界面,让我们处理创建类似于文件夹的容器镜像仓库。

根据开放容器倡议OCI)分发规范[1],容器镜像被组织在一个通过名称识别的仓库中。仓库名称通常由用户/组织名称和容器镜像名称组成,格式为:myorganization/mycontainerimage,并且必须符合以下正则表达式检查:

[a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*

重要定义

正则表达式regex)是一种由字符序列定义的搜索模式。这个模式定义利用了多种符号,允许用户详细定义在文本文件中查找的目标关键字、行或多行。

一旦我们在容器注册表上创建了一个仓库,我们应该能够开始推送、拉取并处理不同版本(由标签标识)的容器镜像。

推送容器镜像

将容器镜像推送到容器注册表的操作是由我们使用的容器工具处理的,该工具遵循 OCI 分发规范。

在此过程中,blob(即内容的二进制形式)会首先上传,通常最后上传的是清单。这个顺序并不是规范严格要求的,但如果清单引用了注册表不认识的 blob,注册表可能会拒绝该清单。

使用容器管理工具将容器镜像推送到注册表时,我们必须再次指定之前展示的仓库名称和我们想上传的容器镜像标签。

标签管理

正如我们在第四章《管理正在运行的容器》中介绍的那样,容器镜像通过名称和标签来标识。通过标签机制,我们可以在系统的本地缓存或容器注册表上存储多个不同版本的容器镜像。

容器注册表应能够公开内容发现功能,向请求的客户端提供容器镜像标签的列表。此功能使容器注册表的用户能够选择正确的容器镜像进行拉取和运行到目标系统。

拉取容器镜像

在拉取容器镜像的过程中,客户端应首先请求清单,以便了解需要拉取哪些 blob(即内容的二进制形式),从而获取最终的容器镜像。这个顺序是严格的,因为如果不拉取和解析容器镜像的清单文件,客户端将无法知道从注册表请求哪些二进制数据。

使用容器管理工具从注册表拉取容器镜像时,我们必须再次指定之前展示的仓库名称和我们想下载的容器镜像标签。

身份验证管理

所有上述操作可能都需要身份验证。在许多情况下,公共容器注册表可能允许匿名拉取和内容发现,但推送容器镜像时需要有效的身份验证。

根据选择的容器注册表,我们可能会发现基本或高级功能来进行身份验证,允许我们的客户端存储令牌,并在每次需要时使用它进行操作。

这就是我们对容器注册表理论的简短深入探讨。如果你想了解更多关于 OCI 分发规范的信息,可以参考本章末尾的进一步阅读部分中的 URL [1]

了解一下

OCI 分发规范还定义了一套符合性测试,任何人都可以运行这些测试,检查某个容器注册表的实现是否遵循了规范中定义的所有规则:github.com/opencontainers/distribution-spec/tree/main/conformance

网络上可用的各种容器注册表实现,除了我们之前描述的基本功能外,还添加了更多的功能,我们将在下一节中深入了解。

基于云和本地的容器注册表

正如我们在前面的章节中介绍的,OCI 定义了一个容器注册表遵循的标准。这个倡议促使了许多其他容器注册表的兴起,除了最初的 Docker Registry 及其在线服务 Docker Hub。

我们可以将可用的容器注册表分为两个主要类别:

  • 基于云的容器注册表

  • 本地容器注册表

让我们在以下小节中详细了解这两类容器注册表。

本地容器注册表

本地容器注册表通常用于创建企业用途的私有仓库。主要的使用场景包括:

  • 在私有或隔离网络中分发镜像

  • 在多个机器上大规模部署新的容器镜像

  • 将任何敏感数据存储在我们自己的数据中心

  • 使用内部网络提高拉取和推送镜像的速度

当然,运行本地注册表需要具备一些技能,以确保可用性、监控、日志记录和安全性。

这是一个可在本地安装的容器注册表的非全面列表:

  • Docker Registry:这是 Docker 的项目,目前版本为 2,提供前面章节描述的所有基本功能,我们将在本章最后一节运行本地容器注册表中学习如何运行它。

  • Harbor:这是一个 VMware 的开源项目,提供高可用性、镜像审计和与身份验证系统的集成。

  • GitLab Container Registry:这与 GitLab 产品紧密集成,因此只需最小的设置,但它依赖于主项目。

  • JFrog Artifactory:它不仅仅管理容器;它还提供任何制品的管理。

  • Quay:这是 Red Hat 产品 Quay 的开源版本。该项目提供了一个功能齐全的 Web 用户界面、镜像漏洞扫描服务、数据存储和保护功能。

我们不会深入讨论这些容器注册表的每个细节。我们可以确定地建议你要小心选择适合你使用案例和支持需求的产品或项目。许多这些产品都有支持计划或企业版本(需要许可),在发生灾难时,它们可以轻松帮助你解围。

现在让我们来看看那些基于云的容器注册表,它们可以通过提供完整的托管服务,简化我们的生活,使我们的操作技能降到零。

基于云的容器注册表

正如上一节所预期的,基于云的容器注册表可能是通过注册表开始使用容器镜像的最快方式。

如在第八章中所述,选择容器基础镜像,网上有多个基于云的容器注册表服务。我们将仅集中讨论其中的一小部分,排除掉由公共云提供商提供的服务和由 Linux 发行版提供的服务,这些通常只允许拉取由发行版维护者预装的镜像。

我们将看看这些云容器注册表:

  • Docker Hub:这是由 Docker 公司提供的托管注册表解决方案。该注册表还托管一些受安全验证的官方仓库和流行开源项目的镜像。

  • Quay:这是由 CoreOS 公司(现在是红帽公司的一部分)推出的托管注册表解决方案。它提供私有和公共仓库、自动化的安全扫描、镜像构建,以及与流行的 Git 公共仓库的集成。

Docker Hub 云注册表

Docker Hub 云注册表伴随着 Docker 项目的诞生而诞生,成为该项目及容器技术的最大亮点之一,给予了它们应有的关注。

说到功能,Docker Hub 有免费和付费计划:

  • 匿名访问:每 6 小时只有 100 次镜像拉取。

  • 使用免费套餐的注册用户账户:每 6 小时 200 次镜像拉取,并且可以拥有无限量的公共仓库。免费套餐不包括镜像构建或安全扫描。

  • 专业、团队和企业账户:每天成千上万的镜像拉取,自动化构建,安全扫描,RBAC 等等。

正如我们刚才所提到的,如果我们尝试用免费套餐的注册用户账户登录,我们只能创建公共仓库。这对于社区或个人开发者可能足够了,但一旦你开始在企业级使用时,你可能需要付费计划提供的附加功能。

为了避免在拉取镜像时遇到较大的限制,我们应该至少使用一个注册的用户账户,并通过我们钟爱的容器引擎:Podman,登录到 web 门户和容器注册表。接下来的章节将介绍如何认证到注册表,并确保每 6 小时可以拉取 200 次镜像,使用 Docker Hub。

Red Hat Quay 云注册表

Quay 云注册表是 Red Hat 的本地注册表,但作为软件即服务SaaS)提供。

Quay 云注册表像 Docker Hub 一样,提供付费计划以解锁更多功能。

好消息是,Quay 的免费套餐包含了许多功能:

  • 从 Dockerfile 构建,可以手动上传或通过 GitHub/Bitbucket/Gitlab 或任何 Git 仓库链接。

  • 对推送到注册表的镜像进行安全扫描。

  • 使用/审计日志。

  • 用于集成任何外部软件的机器人用户账户/令牌。

  • 镜像拉取没有限制。

另一方面,付费计划将解锁私有仓库和基于团队的权限。

让我们通过创建一个公共仓库并将其与 GitHub 上的一个仓库关联来查看 Quay 云注册表,在该仓库中我们推送了一个 Dockerfile 来构建目标容器镜像:

  1. 首先,我们需要在quay.io上注册或登录 Quay 门户。

之后,我们可以点击右上角的+ 创建新仓库按钮:

图 9.1 – Quay 创建新仓库按钮

  1. 完成后,网页门户会请求一些关于我们要创建的新仓库的基本信息:

    • 一个名称

    • 一个描述

    • 公有或私有(我们使用的是免费账户,所以公有即可)

    • 如何初始化仓库:

图 9.2 – 创建新仓库页面

我们刚刚为我们的仓库定义了一个名称ubi8-httpd,并选择将该仓库与 GitHub 仓库的推送进行链接。

  1. 确认后,Quay 注册云门户将重定向我们到 GitHub 进行授权,然后会要求我们选择正确的组织和 GitHub 仓库进行关联:

图 9.3 – 选择要与我们容器仓库关联的 GitHub 仓库

我们刚刚选择了默认组织和我们创建的包含 Dockerfile 的 Git 仓库。该 Git 仓库名为ubi8-httpd,可以在此处找到:github.com/alezzandro/ubi8-httpd

重要说明

本示例中使用的仓库属于作者自己的项目。您可以在 GitHub 上分叉该仓库,并使用读写权限创建自己的副本,以便能够进行更改并尝试提交和自动构建。

  1. 最后,它会要求我们进一步配置触发器:

图 9.4 – 构建触发器定制

我们只选择了默认选项,每次在 Git 仓库的任何分支和标签上进行推送时,都会触发一个新的构建。

  1. 完成后,我们将被重定向到主仓库页面:

图 9.5 – 主仓库页面

创建后,仓库为空,没有任何信息或活动,当然。

  1. 在左侧栏,我们可以轻松访问构建部分。它是从顶部开始的第四个图标。在下图中,我们刚刚在我们的 Git 仓库上执行了两次推送,触发了两次不同的构建:

图 9.6 – 容器镜像构建部分

  1. 如果我们尝试点击其中一个构建,云注册表将显示构建的详细信息:

图 9.7 – 容器镜像构建详情

如我们所见,构建按预期工作,连接到 GitHub 仓库,下载 Dockerfile 并执行构建,最后自动将镜像推送到容器注册表。Dockerfile 包含了几个命令,用于在 UBI8 基础镜像上安装 httpd 服务器,正如我们在第八章中所学的,选择容器基础镜像

  1. 最后,值得一提的最后一部分是包括的安全扫描功能。此功能可以通过点击左侧面板中第二个的标签图标来访问:

图 9.8 – 容器镜像标签页面

如你所见,有一个SECURITY SCAN列(第三列),它报告了与第一个列中所述的标签名称关联的特定容器镜像扫描的状态。点击该列的值(在前面的截图中为Passed),我们可以获得更多详情。

我们刚刚获得了一些使用作为托管服务提供的容器注册表的经验。这可能使我们的生活更轻松,减少我们的操作技能要求,但它们并不总是最适合我们的项目或公司。

在下一部分,我们将更详细地探讨如何使用 Podman 的配套工具 Skopeo 管理容器镜像,接着我们将学习如何在本地配置和运行容器注册表。

使用 Skopeo 管理容器镜像

到目前为止,我们已经了解了许多容器注册表的概念,包括私有和公共注册表之间的差异,它们是否符合 OCI 镜像规范,以及如何使用 Podman 和 Buildah 获取镜像并构建和运行容器。

然而,有时我们需要实现一些简单的镜像操作任务,例如将镜像从注册表移动到镜像库、检查远程镜像而无需将其拉取到本地,甚至为镜像签名。

诞生于 Podman 和 Buildah 的社区开发了另一个令人惊叹的工具,Skopeo (github.com/containers/skopeo),它完全实现了之前描述的功能。

Skopeo 被设计为一个用于 DevOps 团队的镜像和注册表操作工具,并不用于运行容器(Podman 的主要功能)或构建 OCI 镜像(Buildah 的主要功能)。相反,它提供了一个最小化且简洁的命令行界面,配备了基本的镜像操作命令,这在不同的上下文中将非常有用。

接下来,让我们在下一个子部分中查看一些最有趣的功能。

安装 Skopeo

Skopeo 是一个 Go 语言编写的二进制工具,已经打包并可用于许多发行版。也可以从源代码直接构建并安装。

本节提供了主要发行版的安装示例的非详尽清单。为了清晰起见,需要重申的是,本书的实验环境都是基于 Fedora 34 的:

  • dnf 命令:

    $ sudo dnf -y install skopeo
    
  • apt-get 命令:

    $ sudo apt-get update
    $ sudo apt-get -y install skopeo
    
  • dnf 命令:

    $ sudo dnf -y install skopeo
    
  • yum 命令:

    $ sudo yum -y install skopeo
    
  • Ubuntu: 要在 Ubuntu 20.10 及更新版本上安装 Skopeo,请运行以下命令:

    $ sudo apt-get -y update
    $ sudo apt-get -y install skopeo
    
  • pacman 命令:

    $ sudo pacman –S skopeo
    
  • zypper 命令:

    $ sudo zypper install skopeo
    
  • brew 命令:

    $ brew install skopeo
    
  • 从源代码构建: Skopeo 也可以从源代码构建。与 Buildah 类似,出于本书的目的,我们将专注于简单的部署方法,但如果你感兴趣,可以在主项目仓库中找到一个专门的安装章节,说明如何从源代码构建 Skopeo:github.com/containers/skopeo/blob/main/install.md#building-from-source

上面的链接展示了容器化和非容器化构建的示例。

  • podman 命令:

    $ podman run quay.io/skopeo/stable:latest <command> <options>
    
  • Windows: 在编写本书时,Microsoft Windows 尚无可用的构建版本。

Skopeo 使用与 Podman 和 Buildah 相同的系统和本地配置文件,因此我们可以直接关注安装验证和分析最常见的使用场景。

验证安装

要验证安装是否正确,只需运行 skopeo 命令,并加上 -h--help 选项查看所有可用的命令,如以下示例所示:

$ skopeo -h

预期输出将在工具选项中显示所有可用命令,每个命令都会附有命令范围的描述。所有命令的完整列表如下:

  • copy: 跨位置复制镜像,使用不同的传输方式,如 Docker 注册表、本地目录、OCI、tarball、OSTree 和 OCI 档案。

  • delete: 从目标位置删除镜像。

  • help: 打印帮助命令。

  • inspect: 检查目标位置镜像的元数据、标签和配置。

  • list-tags: 显示特定镜像仓库的可用标签。

  • login: 用于认证远程注册表。

  • logout: 从远程注册表注销。

  • manifest-digest: 为文件生成清单摘要。

  • standalone-sign: 一个调试工具,用于使用本地文件发布和签名镜像。

  • standalone-verify: 使用本地文件验证镜像签名。

  • sync: 在多个位置之间同步一个或多个镜像。

现在让我们更详细地检查一些最有趣的 Skopeo 命令。

跨位置复制镜像

Podman,像 Docker 一样,不仅可以用于运行容器,还可以拉取镜像到本地并将其推送到其他位置。然而,一个主要的注意事项是需要运行两个命令,一个用来拉取,另一个用来推送,而本地镜像存储会充满拉取下来的镜像。因此,用户应定期清理本地存储。

Skopeo 提供了一种更智能、更简单的方法来实现此目标,使用 skopeo copy 命令。该命令实现了以下语法:

skopeo copy [command options] SOURCE-IMAGE DESTINATION-IMAGE

在这个通用描述中,SOURCE-IMAGEDESTINATION-IMAGE 是属于本地或远程位置的镜像,可以通过以下传输方式之一访问:

  • docker://docker-reference:此传输方式与实现了Docker Registry HTTP API V2的注册表中存储的镜像相关。

此设置使用 /etc/containers/registries.conf$HOME/.config/containers/registries.conf 文件来获取进一步的注册表配置。

docker-reference 字段遵循 name[:tag|@digest] 的格式。

  • containers-storage:[[storage-specifier]]{image-id|docker-reference[@image-id]}:此设置指的是本地容器存储中的镜像。

storage-specifier 字段的格式为 [[driver@]root[+run-root][:options]]

  • dir:path:此设置指的是包含清单、层(以 tarball 格式)和签名的现有本地目录。

  • docker-archive:path[:{docker-reference|@source-index}]:此设置指的是通过 docker savepodman save 命令获得的 Docker 存档。

  • docker-daemon:docker-reference|algo:digest:此设置指的是 Docker 守护进程内部存储中的镜像存储。

  • oci:path[:tag]:此设置指的是存储在符合 OCI 布局规范的本地路径中的镜像。

  • oci-archive:path[:tag]:此设置指的是存储为 tarball 格式的符合 OCI 布局规范的镜像。

  • ostree:docker-reference[@/absolute/repo/path]:此设置指的是存储在本地 ostree 仓库中的镜像。OSTree 是一个管理多个版本文件系统树的工具。它允许你以原子和不可变的方式管理操作系统。有关更多细节,请查看 man ostree

让我们检查一下在实际场景中使用 skopeo copy 命令的一些示例。第一个示例展示了如何将镜像从一个远程注册表复制到另一个远程注册表:

$ skopeo copy \
   docker://docker.io/library/nginx:latest \
   docker://private-registry.example.com/lab/nginx:latest

前面的示例没有处理注册表身份验证,而身份验证通常是将镜像推送到远程仓库时的必要条件。在下一个示例中,我们展示了一个变体,其中源注册表和目标注册表都附加了认证选项:

$ skopeo copy \
   --src-creds USERNAME:PASSWORD \ 
   --dest-creds USERNAME:PASSWORD \
   docker://registry1.example.com/mirror/nginx:latest \
   docker://registry2.example.com/lab/nginx:latest

尽管前述方法可以正常工作,但它有一个限制,即将用户名和密码作为明文字符串传递。为了避免这种情况,我们可以使用 skopeo login 命令在运行 skopeo copy 之前对注册表进行身份验证。

第三个示例展示了在目标注册表进行预认证的情况,假设源注册表是公开可访问的,且支持拉取:

$ skopeo login private-registry.example.com
$ skopeo copy \
   docker://docker.io/library/nginx:latest \
   docker://private-registry.example.com/lab/nginx:latest

当我们登录源/目标注册表时,系统会将注册表提供的认证令牌保存在专用的认证文件中,之后可以重复使用这些令牌进行进一步访问。

默认情况下,Skopeo 会查看 ${XDG_RUNTIME_DIR}/containers/auth.json 路径,但我们可以为身份验证文件提供一个自定义位置。例如,如果我们之前使用过 Docker 容器运行时,我们可以在路径 ${HOME}/.docker/config.json 中找到它。此文件包含一个简单的 JSON 对象,保存了每个使用的注册中心在身份验证时获得的令牌。客户端(Podman、Skopeo 或 Buildah)将使用此令牌直接访问注册中心。

以下例子展示了如何使用自定义路径提供的身份验证文件:

$ skopeo copy \ 
   --authfile ${HOME}/.docker/config.json \
   docker://docker.io/library/nginx:latest \
   docker://private-registry.example.com/lab/nginx:latest

在与私有注册中心合作时,另一个常见问题是缺少已知 --dest-tls-verify--src-tls-verify 选项的证书,它们接受一个简单的布尔值。

以下例子展示了如何跳过目标注册中心的 TLS 验证:

$ skopeo copy \ 
   --authfile ${HOME}/.docker/config.json \ 
   --dest-tls-verify false \
   docker://docker.io/library/nginx:latest \
   docker://private-registry.example.com/lab/nginx:latest

到目前为止,我们已经看到了如何在公共和私有注册中心之间移动镜像,但我们可以使用 Skopeo 轻松地将镜像移动到本地存储并从中移动。例如,我们可以将 Skopeo 作为一个高度专业化的推送/拉取工具,用于我们的构建流水线中的镜像。

下一个例子展示了如何将本地构建的镜像推送到公共注册中心:

$ skopeo copy \
   --authfile ${HOME}/.docker/config.json \
   containers-storage:quay.io/<namespace>/python_httpd \
   docker://quay.io/<namespace>/python_httpd:latest

这是一种管理镜像推送的惊人方式,能够完全控制推送/拉取过程,并展示了三个工具——Podman、Buildah 和 Skopeo——如何在我们的 DevOps 环境中各自完成专门的任务,每个工具都在其设计的目的上发挥最大作用。

让我们看另一个例子,这次展示如何从远程注册中心将镜像拉取到符合 OCI 标准的本地存储中:

$ skopeo copy \
   --authfile ${HOME}/.docker/config.json \
   docker://docker.io/library/nginx:latest \
   oci:/tmp/nginx

输出文件夹符合 OCI 镜像规范,结构如下(由于布局原因,blob 哈希值已省略):

$ tree /tmp/nginx
/tmp/nginx/
├─ blobs
│ └─sha256
│   ├──21e0df283cd68384e5e8dff7e6be1774c86ea3110c1b1e932[...]
│   ├──44be98c0fab60b6cef9887dbad59e69139cab789304964a19[...]
│   ├──77700c52c9695053293be96f9cbcf42c91c5e097daa382933[...]
│   ├──81d15e9a49818539edb3116c72fbad1df1241088116a7363a[...]
│   ├──881ff011f1c9c14982afc6e95ae70c25e38809843bb7d42ab[...]
│   ├──d86da3a6c06fb46bc76d6dc7b591e87a73cb456c990d814fd[...]
│   ├──e5ae68f740265288a4888db98d2999a638fdcb6d725f42767[...]
│   └──ed835de16acd8f5821cf3f3ef77a66922510ee6349730d89a[...]
├─ index.json
└─ oci-layout

blobs/sha256 文件夹中的文件包括镜像清单(以 JSON 格式)和镜像层,以压缩 tarball 形式存在。

有趣的是,Podman 可以无缝地基于符合 OCI 镜像规范的本地文件夹运行容器。下一个例子展示了如何从之前下载的镜像运行 NGINX 容器:

$ podman run -d oci:/tmp/nginx
Getting image source signatures
Copying blob e5ae68f74026 done  
Copying blob 21e0df283cd6 done  
Copying blob ed835de16acd done  
Copying blob 881ff011f1c9 done  
Copying blob 77700c52c969 done  
Copying blob 44be98c0fab6 done  
Copying config 81d15e9a49 done  
Writing manifest to image destination
Storing signatures
90493fe89f024cfffda3f626acb5ba8735cadd827be6c26fa44971108e09b54f

注意镜像路径前的 oci: 前缀,这是为了指定该路径符合 OCI 标准。

此外,有趣的是,Podman 会将 blobs 复制并提取到其本地存储中(对于像示例中那样的无根容器,路径为 $HOME/.local/share/containers/storage)。

在学习了如何使用 Skopeo 复制镜像之后,让我们看看如何在不将镜像拉取到本地的情况下检查远程镜像。

检查远程镜像

有时,我们需要在拉取并在本地执行镜像之前验证其配置、标签或元数据。为此,Skopeo 提供了有用的 skopeo inspect 命令,用于检查支持的传输协议上的镜像。

第一个例子展示了如何检查官方 NGINX 镜像仓库:

$ skopeo inspect docker://docker.io/library/nginx

skopeo copy 命令会创建一个以 JSON 格式输出的文件,其中包含以下字段:

  • Name:镜像仓库的名称。

  • Digest:计算出的 SHA256 摘要。

  • RepoTags:仓库中所有可用镜像标签的完整列表。检查本地传输方式(如containers-storage:oci:)时,此列表将为空,因为它们将作为单个镜像进行引用。

  • Created:仓库或镜像的创建日期。

  • DockerVersion:用于创建镜像的 Docker 版本。对于使用 Podman、Buildah 或其他工具创建的镜像,此值为空。

  • Labels:在镜像构建时应用的附加标签。

  • Architecture:镜像为目标系统架构构建的架构。对于 x86-64 系统,此值为amd64

  • Os:镜像为目标操作系统构建的操作系统。

  • Layers:构成镜像的各层列表,以及它们的 SHA256 摘要。

  • Env:在镜像构建时定义的附加环境变量。

之前关于身份验证和 TLS 验证的相同考虑适用于skopeo inspect命令:在身份验证后,可以检查私有注册表中的镜像并跳过 TLS 验证。下一个示例展示了这一用例:

$ skopeo inspect \
   --authfile ${HOME}/.docker/config.json \ 
   --tls-verify false \ 
   registry.example.com/library/test-image

通过传递正确的传输方式,可以检查本地镜像。下一个示例展示了如何检查本地 OCI 镜像:

$ skopeo inspect oci:/tmp/custom_image 

此命令的输出将具有一个空的RepoTags字段。

此外,还可以使用--no-tags选项故意跳过仓库标签,如下例所示:

$ skopeo inspect --no-tags docker://docker.io/library/nginx

另一方面,如果我们只需要打印可用的仓库标签,可以使用skopeo list-tags命令。下一个示例打印官方 Nginx 仓库的所有可用标签:

$ skopeo list-tags docker://docker.io/library/nginx

我们将要分析的第三个用例是跨注册表和本地存储同步镜像。

同步注册表和本地目录

在处理断开连接的环境时,一个常见场景是需要将远程注册表中的仓库同步到本地。

为了实现这一目的,Skopeo 引入了skopeo sync命令,该命令帮助在源和目标之间同步内容,支持不同的传输类型。

我们可以使用此命令在源和目标之间同步整个仓库,包含其中的所有可用标签。或者,也可以仅同步特定的镜像标签。

第一个示例展示了如何将官方 busybox 仓库从私有注册表同步到本地文件系统。此命令会将远程仓库中包含的所有标签拉取到本地目标(目标目录必须已存在):

$ mkdir /tmp/images 
$ skopeo sync \
  --src docker --dest dir \
  registry.example.com/lab/busybox /tmp/images

请注意使用--src--dest选项来定义传输类型。支持的传输类型如下:

  • Sourcedockerdiryaml(将在本节后续内容中详细介绍)

  • Destinationdockerdir

默认情况下,Skopeo 会将仓库内容同步到目标,而不包含完整的镜像源路径。当我们需要从多个源同步具有相同名称的仓库时,这可能会带来限制。为了解决这个问题,我们可以添加 --scoped 选项,将完整的镜像源路径复制到目标树中。

第二个示例显示了对 busybox 仓库的作用域同步:

$ skopeo sync \
   --src docker --dest dir --scoped \
   registry.example.com/lab/busybox /tmp/images

目标目录中的最终路径将包含注册表名称和相关命名空间,并以镜像标签命名的新文件夹。

下一个示例显示了成功同步后的目标目录结构:

ls -A1 /tmp/images/docker.io/library/
busybox:1
busybox:1.21.0-ubuntu
busybox:1.21-ubuntu
busybox:1.23
busybox:1.23.2
busybox:1-glibc
busybox:1-musl
busybox:1-ubuntu
busybox:1-uclibc
[...omitted output...]

如果我们只需要同步特定的镜像标签,可以通过在源参数中指定标签名称,如下所示的第三个示例:

$ skopeo sync --src docker --dest dir docker.io/library/busybox:latest /tmp/images

我们可以使用 Docker 直接同步两个注册表,既作为源也作为目标传输。这在断网环境中非常有用,因为系统仅允许访问本地注册表。本地注册表可以从其他公共或私有注册表镜像仓库,并且可以定期安排任务以保持镜像更新。

下一个示例展示了如何将 UBI8 镜像及其所有标签从公共的 Red Hat 仓库同步到本地镜像注册表:

$ skopeo sync \
   --src docker --dest docker \
   --dest-tls-verify=false \
   registry.access.redhat.com/ubi8 \
   mirror-registry.example.com

上述命令将把所有 UBI8 镜像标签同步到目标注册表。

注意 --dest-tls-verify=false 选项,用于禁用目标上的 TLS 证书检查。

skopeo sync 命令非常适合在不同位置之间镜像仓库和单个镜像,但当涉及到镜像完整注册表或大量仓库时,我们需要多次运行该命令,并传递不同的源参数。

为了避免这个限制,源传输可以定义为一个 YAML 文件,包含所有注册表、仓库和镜像的详尽列表。还可以使用正则表达式来捕获仅选定的镜像标签子集。

以下是一个自定义的 YAML 文件示例,它将作为源参数传递给 Skopeo:

Chapter09/example_sync.yaml

docker.io:
  tls-verify: true
  images: 
    alpine: []
    nginx:
      - "latest"
  images-by-tag-regex:
    httpd: ²\.4\.[0-9]*-alpine$
quay.io: 
  tls-verify: true
  images:
    fedora/fedora:
      - latest
registry.access.redhat.com:
  tls-verify: true
  images:
    ubi8:
      - "8.4"
      - "8.5"

在上述示例中,定义了不同的镜像和仓库,因此文件内容需要详细说明。

整个 alpine 仓库将从 docker.io 拉取,同时还包括 nginx:latest 镜像标签。此外,还使用正则表达式来定义 httpd 镜像的标签模式,以便仅拉取基于 Alpine 的 2.4.z 版本镜像。

该文件还为存储在 quay.io/ 下的 fedora 镜像定义了特定标签(latest),并为存储在 registry.access.redhat.com 注册表下的 ubi8 镜像定义了 8.48.5 标签。

一旦定义,该文件将作为参数传递给 Skopeo,并与目标一起使用:

$ skopeo sync \
  --src yaml --dest dir \
  --scoped example_sync.yaml /tmp/images

example_sync.yaml 文件中列出的所有内容将根据之前提到的过滤规则复制到目标目录。

下一个示例展示了一个更大的镜像使用案例,应用于 OpenShift 版本的发布镜像。以下的 openshift_sync.yaml 文件定义了一个正则表达式,用于同步为 x86_64 架构构建的 OpenShift 4.9.z 版本的所有镜像:

Chapter09/openshift_sync.yaml

quay.io:
  tls-verify: true
  images-by-tag-regex:
    openshift-release-dev/ocp-release: ⁴\.9\..*-x86_64$

我们可以使用此文件将整个 OpenShift 小版本镜像同步到一个从断开环境中访问的内部注册表,并使用该镜像成功进行 OpenShift 容器平台的隔离安装。以下命令示例展示了这一用例:

$ skopeo sync \
  --src yaml --dest docker \
  --dest-tls-verify=false \
  --src-authfile pull_secret.json \
  openshift_sync.yaml mirror-registry.example.com:5000

值得注意的是,使用了拉取秘钥文件,并通过 --src-authfile 选项传递,以在 Quay 公共注册表上进行身份验证,并从 ocp-release 仓库中拉取镜像。

还有一个最终的 Skopeo 特性值得关注:远程删除镜像,具体内容将在下一小节中介绍。

删除镜像

可以将注册表看作是一个专门的对象存储,它实现了一组 HTTP API 来操作其内容,并以镜像层和元数据的形式推送/拉取对象。

GETPUTDELETEPOSTPATCH 方法。

这意味着我们可以使用任何能够正确处理请求的 HTTP 客户端与注册表进行交互,例如 curl 命令。

任何容器引擎在底层使用 HTTP 客户端库来执行对注册表的各种方法(例如,拉取镜像)。

Docker v2 协议也支持远程删除镜像,任何实现该协议的注册表都支持以下的 DELETE 请求来删除镜像:

DELETE /v2/<name>/manifests/<reference>

以下示例展示了一个理论上的删除命令,使用 curl 命令在本地注册表上执行:

$ curl -v --silent \
   -H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
   -X DELETE http://127.0.0.1:5000/v2/<name>/manifests/sha256:<image_tag_digest>

上面的示例故意没有包括授权令牌的管理,以提高可读性。

Podman 或 Docker 作为注册表引擎工作时,在其命令接口中并未实现远程删除功能。

幸运的是,Skopeo 提供了内建的 skopeo delete 命令,以简单且用户友好的语法来管理远程镜像删除。

以下示例在一个假设的内部 mirror-registry.example.com:5000 注册表上删除镜像:

$ skopeo delete \
  docker://mirror-registry.example.com:5000/foo:bar

该命令立即删除远程注册表中的镜像标签引用。

重要提示

在使用 Skopeo 删除镜像时,必须在远程镜像仓库中启用镜像删除功能,具体内容在下一节运行本地容器注册表中介绍。

在本节中,我们已经学习了如何使用 Skopeo 来复制、删除、检查和同步镜像,甚至跨不同的传输方式(包括私有本地注册表)同步整个仓库,从而掌握了日常镜像操作。

在下一节中,我们将学习如何运行和配置本地容器注册表,以便直接在我们的实验室或开发环境中管理镜像存储。

运行本地容器注册表

大多数公司和组织采用企业级注册表,以依赖于安全和韧性的容器镜像存储解决方案。大多数企业注册表还提供了高级功能,如 基于角色的访问控制 (RBAC)、镜像漏洞扫描、镜像、地理复制和高可用性,成为生产和关键任务环境的默认选择。

然而,有时运行一个简单的本地注册表是非常有用的,例如在开发环境或培训实验室中。本地注册表在断网环境中也可以用来镜像主要的公共或私有注册表。

本节旨在说明如何运行一个简单的本地注册表,以及如何应用基本的配置设置。

运行容器化注册表

像所有应用程序一样,本地注册表可以由管理员安装在主机上。另一种常见的做法是将注册表本身运行在一个容器内。

最常用的容器化注册表解决方案基于官方的 Docker Registry 2.0 镜像,它提供了基本注册表所需的所有功能,并且非常易于使用。

在运行本地注册表时,无论是否容器化,我们都必须定义一个目标目录来托管所有镜像层和元数据。下面的示例展示了首次执行容器化注册表时,创建并绑定挂载的 /var/lib/registry 文件夹以存储镜像数据:

# mkdir /var/lib/registry
# podman run -d \
   --name local_registry \
   -p 5000:5000 \
   -v /var/lib/registry:/var/lib/registry:z \
   --restart=always registry:2

注册表将在主机地址的 5000/tcp 端口上可访问,这也是该服务的默认端口。如果我们在本地工作站上运行注册表,它将可以通过 localhost:5000 访问,并且如果工作站/笔记本电脑通过本地 DNS 服务解析,它将通过分配的 IP 地址或其 完全限定域名 (FQDN) 对外暴露。

例如,如果主机的 IP 地址为 10.10.2.30,并且 FQDN registry.example.com 已通过 DNS 查询正确解析,那么该注册表服务将可通过 10.10.2.30:5000registry.example.com:5000 访问。

重要提示

如果主机运行着本地防火墙服务或位于企业防火墙后面,请不要忘记打开正确的端口以将注册表暴露到外部。

我们可以尝试构建并将测试镜像推送到新的注册表。以下的 Containerfile 构建了一个基于 UBI 的基本 httpd 服务器:

Chapter09/local_registry/minimal_httpd/Containerfile

FROM registry.access.redhat.com/ubi8:latest
RUN dnf install -y httpd && dnf clean all -y  
COPY index.html /var/www/html
RUN dnf install -y git && dnf clean all -y  
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

我们可以使用 Buildah 来构建新的镜像:

$ buildah build -t minimal_httpd .

要将镜像推送到本地注册表,我们可以使用 Podman 或其配套工具 Buildah 或 Skopeo。Skopeo 在这些用例中非常方便,因为我们甚至不需要将镜像名称与注册表名称进行限定。

下一个命令展示了如何将新镜像推送到注册表:

$ skopeo copy --dest-tls-verify=false \
   containers-storage:localhost/minimal_httpd \
   docker://localhost:5000/minimal_httpd

请注意 --dest-tls-verify=false 的使用:这是必要的,因为本地注册表默认提供 HTTP 传输。

尽管实现起来简单,默认的注册表配置存在一些必须解决的限制。为了说明其中的一个限制,让我们尝试删除刚上传的图片:

$ skopeo delete \
  --tls-verify=false \
  docker://localhost:5000/minimal_httpd
FATA[0000] Failed to delete /v2/minimal_httpd/manifests/sha256:f8c0c374cf124e728e20045f327de30ce1f3c552b307945de9b911cbee103522: {"errors":[{"code":"UNSUPPORTED","message":"The operation is unsupported."}]}
(405 Method Not Allowed)

如我们在之前的输出中所见,注册表没有允许我们删除图片,而是返回了一个HTTP 405错误消息。要改变这种行为,我们需要编辑注册表配置。

自定义注册表配置

注册表配置文件/etc/docker/registry/config.yml可以修改以改变其行为。该文件的默认内容如下:

version: 0.1
log:
  fields:
    service: registry
storage:
  cache:
    blobdescriptor: inmemory
  filesystem:
    rootdirectory: /var/lib/registry
http:
  addr: :5000
  headers:
    X-Content-Type-Options: [nosniff]
health:
  storagedriver:
    enabled: true
    interval: 10s
    threshold: 3

我们很快就意识到,这是一个极其基础的配置,没有身份验证,不允许删除图片,也没有 TLS 加密。我们的自定义版本将尝试解决这些限制。

重要提示

有关注册表配置的完整文档包含了许多选项,这里没有提及,因为超出了本书的范围。更多的配置选项可以在此链接找到:docs.docker.com/registry/configuration/

以下文件包含修改版的注册表config.yml

Chapter09/local_registry/customizations/config.yml

version: 0.1
log: 
  fields:
    service: registry
storage:
  cache:
    blobdescriptor: inmemory
  filesystem:
    rootdirectory: /var/lib/registry
  delete:
    enabled: true
auth: 
  htpasswd:
    realm: basic-realm
    path: /var/lib/htpasswd
http:
  addr: :5000
  headers:
    X-Content-Type-Options: [nosniff]
  tls:
    certificate: /etc/pki/certs/tls.crt
    key: /etc/pki/certs/tls.key
health:
  storagedriver:
    enabled: true
    interval: 10s
    threshold: 3

上一个示例中突出显示的部分强调了新增的功能:

  • 图片删除:默认情况下,此设置是禁用的。

  • htpasswd 文件。在开发和实验环境中,这种方法是可接受的,而基于令牌的身份验证依赖于外部颁发机构,最适合用于生产环境。

  • 使用自签名证书的HTTPS 传输

在使用我们的自定义配置重新运行注册表之前,我们需要生成一个htpasswd文件,其中至少包含一个有效的登录信息,以及用于 TLS 加密的自签名证书。我们从htpasswd文件开始——可以使用htpasswd工具生成,示例如下:

htpasswd -cBb ./htpasswd admin p0dman4Dev0ps#

-cBb 选项启用批处理模式(对于非交互式提供密码很有用),如果文件不存在则会创建文件,并启用密码为p0dman4Dev0ps#admin

最后,我们需要创建一个自签名的服务器证书及其相关的私钥,用于 HTTPS 连接。举个例子,将创建一个与localhost 常用名称CN)相关的证书。

重要提示

将证书绑定到localhost CN 是开发环境中的常见做法。然而,如果注册表需要暴露到外部,则CNSubjectAltName字段应该映射到主机的 FQDN 和备用名称。

以下示例展示了如何使用openssl工具创建自签名证书:

$ mkdir certs 
$ openssl req -newkey rsa:4096 -x509 -sha256 -nodes \ 
  -days 365 \
  -out certs/tls.crt \
  -keyout certs/tls.key \
  -subj '/CN=localhost' \
  -addext "subjectAltName=DNS:localhost"

该命令将执行非交互式证书生成,不会提供有关证书主题的额外信息。私钥tls.key使用 4096 位 RSA 算法生成。名为tls.crt的证书设置为 1 年后过期。密钥和证书都写入certs目录中。

要检查生成的证书内容,我们可以运行以下命令:

$ openssl x509 -in certs/tls.crt -text -noout

该命令将生成证书数据和有效性的可读性转储。

提示

对于本示例,自签名证书是可以接受的,但在生产环境中应避免使用。

Let's Encrypt等解决方案提供了免费的 CA 服务,可以可靠地为注册中心或任何其他 HTTPS 服务提供安全保障。欲了解更多详情,请访问letsencrypt.org/

我们现在具备了运行自定义注册中心的所有要求。在创建新的容器之前,请确保先停止并移除先前的实例:

# podman stop local_registry && podman rm local_registry

下一个命令展示了如何使用绑定挂载运行新的自定义注册中心,以传递证书文件夹、htpasswd文件、注册中心存储以及自定义配置文件:

# podman run -d --name local_registry \
   -p 5000:5000 \
   -v $PWD/htpasswd:/var/lib/htpasswd:z \
   -v $PWD/config.yml:/etc/docker/registry/config.yml:z \
   -v /var/lib/registry:/var/lib/registry:z \
   -v $PWD/certs:/etc/pki/certs:z \
   --restart=always \
   registry:2

我们现在可以测试使用先前定义的凭据登录远程注册中心:

$ skopeo login -u admin -p p0dman4Dev0ps# --tls-verify=false localhost:5000
Login Succeeded!

请注意--tls-verify=false选项,它用于跳过 TLS 证书验证。由于这是一个自签名证书,我们需要绕过检查,否则会产生错误信息x509: certificate signed by unknown authority

我们可以再次尝试删除之前推送的镜像:

$ skopeo delete \
  --tls-verify=false \
  docker://localhost:5000/minimal_httpd

这次,命令会成功执行,因为配置文件中启用了删除功能。

本地注册中心可用于从外部公共注册中心同步镜像。在下一小节中,我们将看到使用我们的本地注册中心以及选择的仓库和镜像集进行注册中心镜像的示例。

使用本地注册中心同步仓库

将镜像和仓库同步到本地注册中心在断开连接的环境中非常有用。这也可以用来保持所选镜像的异步副本,并在公共服务中断期间继续拉取它们。

下一个示例展示了使用skopeo sync命令进行简单的镜像同步,提供的镜像列表来自 YAML 文件,目标是我们的本地注册中心:

$ skopeo sync \
  --src yaml --dest docker \
  --dest-tls-verify=false \
  kube_sync.yaml localhost:5000

YAML 文件包含了组成特定版本 Kubernetes 控制平面的镜像列表。同样,我们利用正则表达式定制要拉取的镜像:

Chapter09/kube_sync.yaml

k8s.gcr.io: 
  tls-verify: true 
  images-by-tag-regex: 
    kube-apiserver: ^v1\.22\..*
    kube-controller-manager: ^v1\.22\..*
    kube-proxy: ^v1\.22\..*
    kube-scheduler: ^v1\.22\..*
    coredns/coredns: ^v1\.8\..*
    etcd: 3\.4.[0-9]*-[0-9]*

在同步远程和本地注册中心时,过程可能会镜像很多层。因此,监控注册中心使用的存储空间(在我们的示例中为/var/lib/registry)以避免填满文件系统非常重要。

当文件系统空间被填满时,使用 Skopeo 删除较旧且未使用的镜像还不够,还需要额外的垃圾回收操作来释放空间。下一小节将说明这一过程。

管理注册表的垃圾回收

当在容器注册表上执行删除命令时,它只会删除引用一组 Blob(这些 Blob 可以是层或其他清单)的镜像清单,同时保留这些 Blob 在文件系统中的存在。

如果某个 Blob 不再被任何清单引用,它就有可能被注册表的垃圾回收机制清除。垃圾回收过程通过一个专用命令 registry garbage-collect 来管理,该命令需要在注册表容器内执行。这不是一个自动化的过程,应该手动执行或安排定期执行。

在下一个示例中,我们将运行一个简单的垃圾回收。--dry-run 标志仅打印出那些不再被清单引用的 Blob,因此它们可以安全地删除:

# podman exec -it local_registry \
  registry garbage-collect --dry-run \
  /etc/docker/registry/config.yml

要删除 Blob,只需移除 --dry-run 选项:

# podman exec -it local_registry \
  registry garbage-collect /etc/docker/registry/config.yml

垃圾回收有助于清理注册表中的未使用 Blob,并节省空间。另一方面,我们必须记住,未被引用的 Blob 仍然可能会在未来被另一个镜像重新使用。如果被删除,可能最终需要重新上传它。

总结

在本章中,我们探讨了如何与容器注册表进行交互,容器注册表是我们存储镜像的基础服务。我们首先介绍了容器注册表是什么,它如何工作以及如何与容器引擎和工具进行交互。接着,我们详细描述了公共云注册表和私有注册表(通常在本地执行)之间的区别。特别有用的是,我们了解了两者的优缺点,帮助我们根据实际需求选择最佳方案。

为了管理注册表中的容器镜像,我们介绍了 Skopeo 工具,Skopeo 是 Podman 配套工具的一部分,展示了如何使用它来复制、同步、删除或简单地检查注册表中的镜像,从而为用户提供更高的镜像控制能力。

最后,我们学习了如何使用 Docker Registry v2 的官方社区镜像运行本地容器化注册表。在展示基本用法后,我们深入探讨了更高级的配置细节,介绍了如何启用身份验证、镜像删除和 HTTPS 加密。通过本地注册表,我们能够有效地同步本地镜像和远程注册表。还介绍了垃圾回收过程,确保注册表中的内容保持整洁。

通过本章获得的知识,你将能够更加清晰地管理注册表中的镜像,甚至能够管理本地注册表实例,并且对幕后发生的事情有更深的了解。容器注册表是成功采用容器策略的重要组成部分,因此需要非常透彻地理解:掌握本章的概念后,你还应该能够理解并设计最合适的解决方案,并深入掌控操作镜像的工具。

本章结束后,我们也完成了与容器管理相关的所有基础任务的探索。接下来,我们可以进入更高级的主题,如容器故障排除和监控,这将在下一章中讲解。

进一步阅读

第三部分:安全地管理和集成容器

本书的这一部分将教你如何对操作系统主机上运行的容器进行故障排除、监控和创建高级配置。

本书的这一部分包括以下章节:

  • 第十章容器故障排除与监控

  • 第十一章容器安全

  • 第十二章实现容器网络概念

  • 第十三章Docker 迁移技巧与窍门

  • 第十四章与 systemd 和 Kubernetes 交互

第十章:《故障排除和监控容器》

运行容器可能会被误认为是 DevOps 团队的最终目标,但实际上,这只是漫长旅程的第一步。系统管理员应该确保他们的系统正常运行,以保持服务的持续运行;同样,DevOps 团队应确保他们的容器正常工作。

在容器管理活动中,拥有正确的故障排除技术知识确实能帮助减少对最终服务的影响,减少停机时间。谈到问题和故障排除,一个好的做法是持续监控容器,以便轻松发现任何问题或错误,加快恢复速度。

本章将涵盖以下主要内容:

  • 故障排除正在运行的容器

  • 通过健康检查监控容器

  • 检查我们的容器构建结果

  • 使用nsenter进行高级故障排除

技术要求

在继续介绍章节信息和示例之前,需要一台已经安装并正常运行 Podman 的机器。正如在第三章《运行第一个容器》中所述,本书中的所有示例都在 Fedora 34 或更高版本的系统上执行,但也可以在你选择的操作系统上复现。

第四章《管理运行中的容器》和第五章《实现容器数据存储》中的内容有一个良好的理解,将有助于轻松掌握与容器注册中心相关的概念。

故障排除正在运行的容器

故障排除容器是一个重要的实践,我们需要通过经验解决常见问题并调查在容器层或容器内部运行的应用程序中可能遇到的任何 bug。

第三章《运行第一个容器》开始,我们已开始使用基本的 Podman 命令来运行和检查主机系统上的容器。我们看到了如何使用podman logs命令收集日志,我们还学习了如何使用podman inspect命令提供的信息。最后,我们还应考虑查看有用的podman system df命令的输出,该命令会报告容器和镜像的存储使用情况,还会显示有用的podman system info命令的输出,显示有关运行 Podman 的主机的有用信息。

通常,我们应始终考虑运行中的容器只是主机系统上的一个进程,因此我们始终可以使用所有用于故障排除底层操作系统及其可用资源的工具和命令。

故障排除容器的最佳实践可以采用自上而下的方法,首先分析应用层,然后转到容器层,最终到达基础主机系统。

在容器级别,我们可能遇到的许多问题已被 Podman 项目团队在项目页面上总结成一个综合列表。我们将在接下来的章节中介绍一些更有用的问题。

使用存储卷时权限被拒绝

我们在 RHEL、Fedora 或任何使用 SELinux 安全子系统的 Linux 发行版上,可能遇到的一个非常常见的问题与存储权限有关。以下描述的错误是在 SELinux 设置为 Enforcing 模式时触发的,这也是建议的做法,以完全保障 SELinux 强制访问安全特性。

我们可以尝试在 Fedora 工作站上测试这一点,首先创建一个目录,然后尝试将其用作容器中的卷:

$ mkdir ~/mycontent
$ podman run -v ~/mycontent:/content fedora \ 
touch /content/file
touch: cannot touch '/content/file': Permission denied

正如我们所见,touch 命令报告了 Permission denied 错误,因为实际上,它无法在文件系统中写入。

正如我们在第五章《实现容器数据存储》中详细看到的,SELinux 会递归地为文件和目录应用标签,以定义它们的上下文。这些标签通常存储为扩展文件系统属性。SELinux 使用上下文来管理策略并定义哪些进程可以访问特定的资源。

我们刚刚运行的容器有自己的 Linux 命名空间和一个与 Fedora 工作站上的本地用户完全不同的 SELinux 标签,这也是我们之前实际遇到错误的原因。

如果没有正确的标签,SELinux 系统会阻止容器中运行的进程访问内容。这是因为如果没有通过命令选项明确请求,Podman 不会更改操作系统设置的标签。

要让 Podman 更改容器的标签,我们可以在卷挂载时使用 :z:Z 两个后缀中的任意一个。这些选项告诉 Podman 对卷上的文件对象重新标记。

:z 选项用于指示 Podman 两个容器共享一个存储卷。因此,在这种情况下,Podman 会为内容打上共享内容标签,允许两个或更多容器在该卷上读取/写入内容。

:Z 选项用于指示 Podman 将卷的内容标记为私有的、非共享的标签,仅当前容器可以使用。

该命令的结果将类似于以下内容:

$ podman run -v ~/mycontent:/content:Z fedora \ 
touch /content/file

正如我们所见,命令没有报告任何错误;它执行成功了。

在无 root 容器中使用 ping 命令的问题

在某些强化的 Linux 系统上,ping 命令的执行可能仅限于一组受限用户。这可能导致容器中使用 ping 命令失败。

正如我们在 第三章 中看到的,运行第一个容器,启动容器时,基础操作系统会将一个与容器内使用的用户 ID 不同的用户 ID 关联到容器上。与容器关联的用户 ID 可能会超出允许使用 ping 命令的用户 ID 范围。

在 Fedora 工作站的安装中,默认配置将允许任何容器运行 ping 命令而不受限制。为了管理 ping 命令的使用限制,Fedora 使用 ping_group_range 内核参数,该参数定义了允许执行 ping 命令的系统组。

如果我们查看一个刚安装的 Fedora 工作站,默认范围如下:

$ cat /proc/sys/net/ipv4/ping_group_range
0      2147483647

所以,对于一个全新的 Fedora 系统,不必担心。但如果范围比这个小呢?

好的,我们通过一个简单的命令来更改允许的范围来测试这种行为。在这个示例中,我们将限制范围并看到 ping 命令实际会失败:

$ sudo sysctl -w "net.ipv4.ping_group_range=0 0"

以防范围小于前面输出中报告的范围,我们可以通过将一个包含 net.ipv4.ping_group_range=0 0 的文件添加到 /etc/sysctl.d 来使其持久化。

ping 组范围所做的更改将影响映射到容器内运行 ping 命令的用户权限。

让我们开始使用 Buildah 构建一个基于 Fedora 的镜像,并包含 iputils 包(默认未包含):

$ container=$(buildah from docker.io/library/fedora) && \
  buildah run $container -- dnf install -y iputils && \
  buildah commit $container ping_example

我们可以通过在容器内运行以下命令来进行测试:

$ podman run --rm ping_example ping -W10 -c1 redhat.com
PING redhat.com (209.132.183.105): 56 data bytes
--- redhat.com ping statistics ---
1 packets transmitted, 0 packets received, 100% packet loss

在一个限制范围的系统上执行该命令时,因 ping 命令无法通过原始套接字发送数据包,因此会产生 100% 的数据包丢失。

该示例演示了 ping_group_range 限制如何影响无根容器内 ping 的执行。通过将范围设置为足够大的值以包含用户私有组 GID(或用户的某个次要组),ping 命令将能够正确发送 ICMP 数据包。

重要提示

在继续进行下一个示例之前,请不要忘记恢复原始的 ping_group_range。在 Fedora 中,默认配置可以通过 sudo sysctl -w "net.ipv4.ping_group_range=0 2147483647" 命令恢复,并通过删除在练习过程中应用于 /etc/sysctl.d 下的任何持久配置来恢复。对于我们通过 Dockerfile 构建的基础容器镜像,可能需要添加一个新的用户,并为其分配较大的 UID/GID。这将导致创建一个较大且稀疏的 /var/log/lastlog 文件,并可能导致构建永远挂起。这个问题与 Go 语言相关,因为 Go 语言不正确地支持稀疏文件,导致在容器镜像中创建了这个巨大的文件。

需要知道的事项

/var/log/lastlog 文件是一个二进制稀疏文件,包含有关用户上次登录系统的时间的信息。通过 ls -l 查看时,稀疏文件的显示大小大于实际磁盘使用量。稀疏文件通过将表示空块的元数据写入磁盘,而不是将空闲空间存储在块中,从而更加高效地使用文件系统空间。这将节省更多的磁盘空间。

如果我们需要为基础容器镜像添加一个 UID 数字较高的新用户,最好的方法是将 --no-log-init 选项附加到 Dockerfile 命令中,如下所示:

RUN useradd --no-log-init -u 99999000 -g users myuser

此选项指示 useradd 命令停止创建 lastlog 文件,从而解决我们可能遇到的问题。

正如本节前几段所提到的,Podman 团队创建了一个很长但不全面的常见问题列表。如果遇到任何问题,我们强烈建议查看该列表:github.com/containers/podman/blob/main/troubleshooting.md

排查问题可能会有些棘手,但第一步始终是识别问题。因此,监控工具可以在出现问题时尽早发出警报。接下来我们将看看如何使用健康检查监控容器。

监控具有健康检查的容器

从 1.2 版本开始,Podman 支持为容器添加健康检查选项。在本节中,我们将深入探讨这些健康检查以及如何使用它们。

健康检查是 Podman 的一个功能,它可以帮助判断容器中运行的进程的健康状况或准备状态。它可以像检查容器进程是否在运行一样简单,也可以更复杂,比如使用网络连接等方式验证容器及其应用是否响应。

健康检查由五个核心组件组成。第一个是主要元素,它指示 Podman 执行特定的检查;其他组件用于配置健康检查的调度。让我们详细了解这些元素:

  • 0)或失败(其他退出代码)。

如果我们的容器提供一个 web 服务器,例如,我们的健康检查命令可以是一个非常简单的命令,比如 curl 命令,尝试连接到 web 服务器的端口,确保它是响应的。

  • 重试次数:此选项定义了 Podman 必须执行的连续失败命令数量,容器才会被标记为不健康。如果某个命令成功执行,Podman 会重置重试计数器。

  • 间隔时间:此选项定义了 Podman 执行健康检查命令的时间间隔。

找到合适的间隔时间可能非常困难,并且需要一些反复试验。如果我们设置为一个较小的值,那么系统可能会花费大量时间进行健康检查。但如果我们设置为较大的值,可能会出现超时问题。这个值可以使用广泛使用的时间格式来定义:30s1h5m

  • 启动周期:这描述了健康检查将由 Podman 启动的时间,在此期间,Podman 将忽略健康检查失败。

我们可以将其视为一个宽限期,用于让我们的应用成功启动并开始正确地响应任何客户端请求以及健康检查。

  • 超时:这定义了健康检查本身必须完成的时间段,超过该时间段即视为检查失败。

让我们看一个实际的例子,假设我们要为一个容器定义一个健康检查,并手动运行该健康检查:

$ podman run -dt --name healthtest1 --healthcheck-command \
'CMD-SHELL curl http://localhost || exit 1' \
--healthcheck-interval=0 quay.io/libpod/alpine_nginx:latest
Trying to pull quay.io/libpod/alpine_nginx:latest...
Getting image source signatures
Copying blob ac35fae19c6c done  
Copying blob 4c0d98bf9879 done  
Copying blob 5b0fccc9c35f done  
Copying config 7397e078c6 done  
Writing manifest to image destination
Storing signatures
1faae6c46839b9076f68bee467f9d56751db6ab45dd149f249b0790e05 c55b58
$ podman healthcheck run healthtest1
$ echo $?
0

从之前的代码块中可以看到,我们刚刚启动了一个名为checktest1的全新容器,定义了一个healthcheck-command,该命令将在目标容器内的localhost地址上运行curl命令。容器启动后,我们手动运行了healthcheck并验证退出代码为0,意味着检查成功完成,并且我们的容器是健康的。在之前的示例中,我们还使用了--healthcheck-interval=0选项,实际上禁用了运行间隔并将健康检查设置为手动。

Podman 使用cron来调度健康检查,但这些应该手动设置。

让我们通过创建一个具有间隔时间的健康检查来检查系统与 systemd 的自动集成是如何工作的:

$ podman run -dt --name healthtest2 --healthcheck-command 'CMD-SHELL curl http://localhost || exit 1' --healthcheck-interval=10s quay.io/libpod/alpine_nginx:latest
70e7d3f0b4363759fc66ae4903625e5f451d3af6795a96586bc1328c1b149 ce5
$ podman ps
CONTAINER ID  IMAGE                               COMMAND               CREATED        STATUS                      PORTS       NAMES
70e7d3f0b436  quay.io/libpod/alpine_nginx:latest  nginx -g daemon o...  7 seconds ago  Up 7 seconds ago (healthy)              healthtest2

从之前的代码块中可以看到,我们刚刚启动了一个名为checktest2的全新容器,定义了与前面示例相同的healthcheck-command,但现在指定了--healthcheck-interval=10s选项,实际上将检查任务每 10 秒调度一次。

在执行podman run命令之后,我们还运行了podman ps命令,实际上检查健康检查是否正常工作,正如我们在输出中看到的那样,我们为新容器显示了healthy状态。

但这个集成是如何工作的呢?让我们获取容器 ID,并在以下目录中搜索它:

$ ls /run/user/$UID/systemd/transient/70e*
/run/user/1000/systemd/transient/70e7d3f0b4363759fc66ae4903625 e5f451d3af6795a96586bc1328c1b149ce5.service
/run/user/1000/systemd/transient/70e7d3f0b4363759fc66ae4903625 e5f451d3af6795a96586bc1328c1b149ce5.timer

之前代码块中显示的目录包含了当前用户使用的所有 systemd 资源。特别地,我们查看了transient目录,它存放了当前用户的临时单元文件。

当我们启动一个带有健康检查和调度间隔的容器时,Podman 将执行一个临时设置的 systemd 服务和计时器单元文件。这意味着这些单元文件不是永久性的,并且在重启时可能会丢失。

让我们检查一下这些文件中定义的内容:

$ cat /run/user/$UID/systemd/transient/70e7d3f0b4363759fc66a e4903625e5f451d3af6795a96586bc1328c1b149ce5.service
# This is a transient unit file, created programmatically via the systemd API. Do not edit.
[Unit]
Description=/usr/bin/podman healthcheck run 70e7d3f0b4363759 fc66ae4903625e5f451d3af6795a96586bc1328c1b149ce5

[Service]
Environment="PATH=/home/alex/.local/bin:/home/alex/bin:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/var/lib/snapd/snap/bin"
ExecStart=
ExecStart="/usr/bin/podman" "healthcheck" "run" "70e7d3f0b 4363759fc66ae4903625e5f451d3af6795a96586bc1328c1b149ce5"
$ cat /run/user/$UID/systemd/transient/70e7d3f0b4363759fc66 ae4903625e5f451d3af6795a96586bc1328c1b149ce5.timer
# This is a transient unit file, created programmatically via the systemd API. Do not edit.
[Unit]
Description=/usr/bin/podman healthcheck run 70e7d3f0b4363759 fc66ae4903625e5f451d3af6795a96586bc1328c1b149ce5

[Timer]
OnUnitInactiveSec=10s
AccuracySec=1s
RemainAfterElapse=no

从之前的代码块中可以看到,服务单元文件包含了 Podman 健康检查命令,而计时器单元文件定义了调度间隔。

最后,如果我们想快速识别健康或不健康的容器,可以使用以下命令快速输出它们:

$ podman ps -a --filter health=healthy
CONTAINER ID  IMAGE                               COMMAND               CREATED         STATUS                                 PORTS       NAMES
1faae6c46839  quay.io/libpod/alpine_nginx:latest  nginx -g daemon o...  36 minutes ago  Exited (137) 19 minutes ago (healthy)              healthtest1
70e7d3f0b436  quay.io/libpod/alpine_nginx:latest  nginx -g daemon o...  13 minutes ago  Up 13 minutes ago (healthy)                        healthtest2

在这个示例中,我们使用了 --filter health=healthy 选项,通过 podman ps 命令仅显示健康的容器。

我们在前面的章节中学会了如何排查和监控容器,但容器构建过程又该如何处理呢?让我们在接下来的章节中更深入地了解容器构建检查。

检查容器构建结果

在前面的章节中,我们详细讨论了容器构建过程,并学习了如何使用 Dockerfile/Containerfile 或 Buildah 原生命令创建自定义镜像。我们还展示了第二种方法如何帮助更好地控制构建工作流程。

本节帮助提供了一些最佳实践,用于检查构建结果并理解可能相关的问题。

排查来自 Dockerfile 的构建问题

当使用 Podman 或 Buildah 根据 Dockerfile/Containerfile 运行构建时,构建过程会将所有指令的输出和相关错误打印到终端标准输出。对于所有 RUN 指令,执行命令时产生的错误会被传播并打印出来以供调试。

现在让我们尝试测试一些潜在的构建问题。这不是一个详尽无遗的错误清单;其目的是提供一种分析根本原因的方法。

第一个示例展示了一个最小构建,其中由于执行的命令出现错误,RUN 指令失败。RUN 指令中的错误可能涵盖广泛的情况,但一般的经验法则是:执行的命令返回一个退出码,如果该退出码非零,构建失败,错误和退出状态会被打印出来。

在下一个示例中,我们使用 yum 命令安装 httpd 包,但我们故意在包名中打了一个错别字,以产生一个错误。下面是 Dockerfile 的记录:

Chapter10/RUN_command_error/Dockerfile

FROM registry.access.redhat.com/ubi8
# Update image and install httpd
RUN yum install -y htpd && yum clean all –y
# Expose the default httpd port 80
EXPOSE 80
# Run the httpd
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

如果我们尝试执行该命令,将会遇到一个错误,错误由 yum 命令无法找到缺失的 httpd 包生成:

$ buildah build -t custom_httpd .
STEP 1/4: FROM registry.access.redhat.com/ubi8
STEP 2/4: RUN yum install -y htpd && yum clean all –y
Updating Subscription Management repositories.
Unable to read consumer identity
This system is not registered with an entitlement server. You can use subscription-manager to register.
Red Hat Universal Base Image 8 (RPMs) - BaseOS  3.9 MB/s | 796kB     00:00    
Red Hat Universal Base Image 8 (RPMs) - AppStre 6.2 MB/s | 2.6 MB     00:00    
Red Hat Universal Base Image 8 (RPMs) - CodeRea 171 kB/s |  16 kB     00:00    
No match for argument: htpd
Error: Unable to find a match: htpd
error building at STEP "RUN yum install -y htpd && yum clean all -y": error while running runtime: exit status 1
ERRO[0004] exit status 1                   

前两行打印出由 yum 命令生成的错误信息,类似于标准命令行环境中的输出。

接下来,Buildah(同样,Podman)会产生一条信息,告知我们产生错误的步骤。此信息由 imagebuildah 包中的阶段执行器管理,正如其名称所示,阶段执行器处理构建阶段的执行及其状态。源代码可以在 Buildah 的 GitHub 仓库中查看:github.com/containers/buildah/blob/main/imagebuildah/stage_executor.go

该信息包含 Dockerfile 指令和生成的错误,以及退出状态。

最后一行包括ERRO[0004]错误代码和与buildah命令执行相关的最终退出状态1

使用RUN指令包含出错的命令,并修复或排查该命令错误。

构建过程中的另一个常见失败原因是缺少父镜像。这可能与拼写错误的仓库名称、缺失的标签或无法访问的镜像仓库相关。

下一个示例展示了前一个 Dockerfile 的另一种变体,其中镜像仓库名称拼写错误,因此在远程仓库中找不到该镜像:

Chapter10/FROM_repo_not_found/Dockerfile

FROM registry.access.redhat.com/ubi_8
# Update image and install httpd
RUN yum install -y httpd && yum clean all –y
# Expose the default httpd port 80
EXPOSE 80
# Run the httpd
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

当从这个 Dockerfile 进行构建时,我们将遇到由于缺少镜像仓库而导致的错误,如下一个示例所示:

$ buildah build -t custom_httpd .
STEP 1/4: FROM registry.access.redhat.com/ubi_8
Trying to pull registry.access.redhat.com/ubi_8:latest...
error creating build container: initializing source docker://registry.access.redhat.com/ubi_8:latest: reading manifest latest in registry.access.redhat.com/ubi_8: name unknown: Repo not found
ERRO[0001] exit status 125                 

最后一行产生了不同的错误代码ERRO[0001]和退出状态125。这是一个非常容易排查的错误,只需将有效的仓库传递给FROM指令即可。

解决方案:修正仓库名称并重新启动构建过程。或者,验证目标仓库是否包含所需的仓库。

如果我们拼写错误了镜像标签,会发生什么呢?下一个 Dockerfile 片段显示了官方 Fedora 镜像的无效标签:

Chapter10/FROM_tag_not_found/Dockerfile

FROM docker.io/library/fedora:sometag
# Update image and install httpd
RUN dnf install -y httpd && dnf clean all –y
# Expose the default httpd port 80
EXPOSE 80
# Run the httpd
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

这一次,当我们构建镜像时,仓库会产生 404 错误,因为它无法找到sometag标签的关联清单:

$ buildah build -t custom_httpd .
STEP 1/4: FROM docker.io/library/fedora:sometag
Trying to pull docker.io/library/fedora:sometag...
error creating build container: initializing source docker://fedora:sometag: reading manifest sometag in docker.io/library/fedora: manifest unknown: manifest unknown
ERRO[0001] exit status 125

缺少标签会产生ERRO[0001]错误,退出状态再次设置为125

使用skopeo list-tags命令可以查找给定仓库中所有可用的标签。

有时,FROM指令捕获到的错误是由于尝试访问一个私有仓库而没有进行身份验证。这是一个非常常见的错误,只需要在进行构建操作之前,在目标仓库进行身份验证即可。

在下一个示例中,我们有一个使用来自通用私有仓库的镜像的 Dockerfile,该私有仓库通过 Docker Registry v2 APIs 运行:

Chapter10/FROM_auth_error/Dockerfile

FROM local-registry.example.com/ubi8
# Update image and install httpd
RUN yum install -y httpd && yum clean all –y
# Expose the default httpd port 80
EXPOSE 80
# Run the httpd
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

让我们尝试构建镜像并查看会发生什么:

$ buildah build -t test3 .
STEP 1/4: FROM local-registry.example.com/ubi8
Trying to pull local-registry.example.com/ubi8:latest...
error creating build container: initializing source docker://local-registry.example.com/ubi8:latest: reading manifest latest in local-registry.example.com/ubi8: unauthorized: authentication required
ERRO[0000] exit status 125

在这个用例中,错误非常明显。我们没有权限从目标仓库拉取镜像,因此我们需要使用有效的认证令牌进行身份验证才能访问它。

使用podman loginbuildah login命令登录仓库以获取令牌,或者提供包含有效令牌的身份验证文件。

到目前为止,我们已经检查了通过 Dockerfile 构建时产生的错误。接下来,让我们看看在使用 Buildah 命令行指令时,发生错误的情况。

使用 Buildah 原生命令排查构建问题

在运行 Buildah 命令时,常见的做法是将它们放入 shell 脚本或管道中。

在这个示例中,我们将使用 Bash 作为解释器。默认情况下,Bash 会一直执行脚本,直到结束,无论中间是否有错误。如果脚本中的 Buildah 指令失败,这种行为可能会产生意外的错误。因此,最佳实践是在脚本开头添加以下命令:

set -euo pipefail

结果配置是一种安全网,一旦遇到错误,它就会阻止脚本的执行,并避免常见的错误,比如未设置的变量。

set命令是一个 Bash 内部指令,用于配置脚本执行时的 Shell 环境。此指令中的-e选项告诉 Shell 在管道或单个命令失败时立即退出,–o pipefail选项则告诉 Shell 在失败管道的最右侧命令产生非零退出码时退出,并返回该命令的错误码。-u选项告诉 Shell 在参数扩展过程中将未设置的变量和参数视为错误。这能保护我们免受未设置变量未展开的影响。

下一个脚本嵌入了在 Fedora 镜像上构建一个简单httpd服务器的逻辑:

#!/bin/bash
set -euo pipefail
# Trying to pull a non-existing tag of Fedora official image
container=$(buildah from docker.io/library/fedora:non-existing-tag)
buildah run $container -- dnf install -y httpd; dnf clean all –y
buildah config --cmd "httpd -DFOREGROUND" $container
buildah config --port 80 $container
buildah commit $container custom-httpd
buildah tag custom-httpd registry.example.com/custom-httpd:v0.0.1

图像标签故意设置错误。让我们看看脚本执行的结果:

$ ./custom-httpd.sh 
Trying to pull docker.io/library/fedora:non-existing-tag...
initializing source docker://fedora:non-existing-tag: reading manifest non-existing-tag in docker.io/library/fedora: manifest unknown: manifest unknown
ERRO[0001] exit status 125

构建会产生一个manifest unknown错误,错误代码为ERRO[0001],退出状态为125,就像使用 Dockerfile 时的类似尝试。

从这个输出中,我们还可以了解到,Buildah(以及使用 Buildah 库进行构建实现的 Podman)生成的消息与使用 Dockerfile/Containerfile 进行标准构建时的消息相同,唯一的例外是没有提到构建步骤,这是显而易见的,因为我们在脚本中运行的是自由命令。

使用skopeo list-tags查找给定仓库中所有可用的标签。

在这一部分中,我们学习了如何分析和排除构建错误,但当错误发生在容器内的运行时,并且我们没有合适的工具来进行容器内故障排除时该怎么办?为此,我们有一个本地 Linux 工具,可以视为命名空间的真正瑞士军刀:nsenter

使用 nsenter 进行高级故障排除

我们从一句戏剧性的句子开始:在运行时排除故障有时可能是复杂的。

此外,理解和排除容器内的运行时问题需要理解容器在 GNU/Linux 中的工作原理。我们在第一章《容器技术简介》中解释了这些概念。

有时,故障排除非常简单,正如前面几部分所述,使用基本命令,如podman logspodman inspectpodman exec,再加上定制的健康检查,可以帮助我们获得完成分析所需的信息。

如今,镜像通常尽可能小。那么当我们需要更多专业的故障排除工具,而它们在镜像中不可用时怎么办?你可以考虑在容器内执行一个 Shell 进程,并安装缺失的工具,但有时(这也是一个日益增长的安全模式),容器镜像中没有包管理器,有时甚至没有curlwget命令!

我们可能会感到有些迷茫,但我们必须记住,容器是运行在专用命名空间和 cgroups 中的进程。如果我们有一个工具,可以在保持访问主机工具的同时,在一个或多个命名空间内执行命令,那会怎么样?这个工具就叫做nsenter(可以使用man nsenter访问手册页)。它与任何容器引擎或运行时无关,提供了一种简单的方法来在一个或多个进程的未共享命名空间内执行命令(即容器的主进程)。

在深入真实示例之前,让我们通过使用--help选项来运行nsenter,讨论一下它的主要选项和参数:

$ nsenter --help 
Usage:
 nsenter [options] [<program> [<argument>...]]
Run a program with namespaces of other processes.
Options:
-a, --all              enter all namespaces
-t, --target <pid>     target process to get namespaces from
-m, --mount[=<file>]   enter mount namespace
-u, --uts[=<file>]     enter UTS namespace (hostname etc)
-i, --ipc[=<file>]     enter System V IPC namespace
-n, --net[=<file>]     enter network namespace
-p, --pid[=<file>]     enter pid namespace
-C, --cgroup[=<file>]  enter cgroup namespace
-U, --user[=<file>]    enter user namespace
-T, --time[=<file>]    enter time namespace
-S, --setuid <uid>     set uid in entered namespace
-G, --setgid <gid>     set gid in entered namespace
     --preserve-credentials do not touch uids or gids
-r, --root[=<dir>]     set the root directory
-w, --wd[=<dir>]       set the working directory
-F, --no-fork          do not fork before exec'ing <program>
-Z, --follow-context   set SELinux context according to --target PID
-h, --help             display this help
-V, --version          display version
For more details see nsenter(1).

从此命令的输出中,可以轻松发现选项的数量与可用命名空间的数量相同。

感谢nsenter,我们可以捕获容器主进程的 PID,然后在相关的命名空间内执行命令(包括启动一个 Shell)。

要提取容器的主 PID,可以使用以下命令:

$ podman inspect <Container_Name> --format '{{ .State.Pid }}'

输出结果可以插入到变量中以便于访问:

$ CNT_PID=$(podman inspect <Container_Name> \
  --format '{{ .State.Pid }}')

提示

与进程相关的所有命名空间都表示在/proc/[pid]/ns目录中。该目录包含一系列符号链接,映射到命名空间类型及其对应的 inode 编号。

以下命令显示与容器执行的进程相关的命名空间:ls –al /proc/$CNT_PID/ns

我们将通过一个实际的例子学习如何使用nsenter。在接下来的子章节中,我们将尝试对一个数据库客户端应用进行网络故障排除,该应用返回 HTTP 内部服务器错误,但应用日志中没有任何有用信息。

使用 nsenter 对数据库客户端进行故障排除

在处理 alpha 应用时,遇到日志未正确实现或日志消息处理较差的情况并不少见。

以下示例是一个 Web 应用,从 Postgres 数据库中提取字段,并打印出包含所有出现项的 JSON 对象。应用日志的详细信息被故意设为最少,且没有产生连接或查询错误。

为了节省空间,书中不会打印应用的源代码;但是,源代码可以通过以下 URL 进行查看:github.com/PacktPublishing/Podman-for-DevOps/tree/main/Chapter10/students

文件夹中还包含一个 SQL 脚本,用于填充示例数据库。该应用是通过以下 Dockerfile 构建的:

Chapter10/students/Dockerfile

FROM docker.io/library/golang AS builder
# Copy files for build
RUN mkdir -p /go/src/students/models
COPY go.mod main.go /go/src/students
COPY models/main.go /go/src/students/models
# Set the working directory
WORKDIR /go/src/students
# Download dependencies
RUN go get -d -v ./...
# Install the package
RUN go build -v 
# Runtime image
FROM registry.access.redhat.com/ubi8/ubi-minimal:latest as bin
COPY --from=builder /go/src/students /usr/local/bin
COPY entrypoint.sh /
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]

一如既往,我们将使用 Buildah 构建容器:

$ buildah build -t students .

容器接受一组自定义标志来定义数据库、主机、端口和凭据。要查看帮助信息,只需运行以下命令:

$ podman run students students -help
%!(EXTRA string=students)  
-database string
      Default application database (default "students")
-host string     Default host running the database (default "localhost")
-password string      Default database password (default "password"
-port string    Default database port (default "5432")
-username string       Default database username (default "admin")

我们已获悉数据库正在pghost.example.com主机上的5432端口运行,用户名为students,密码为Podman_R0cks#

下一条命令使用自定义参数运行students网页应用程序:

$ podman run --rm -d -p 8080:8080 \
   --name students_app students \
   students -host pghost.example.com \
   -port 5432 \
   -username students \
   -password Podman_R0cks#

容器成功启动,打印的唯一日志消息如下:

$ podman logs students_app
2021/12/27 21:51:31 Connecting to host pghost.example.com:5432, database students

现在是时候测试应用程序并查看运行查询时会发生什么了:

$ curl localhost:8080/students
Internal Server Error

应用程序可能需要一些时间来响应,但过了一会儿,它会打印出一个内部服务器错误(500)HTTP 消息。我们将在接下来的段落中找到原因。日志没有什么用处,因为除了第一次启动消息之外没有其他输出。此外,容器是使用 UBI 最小镜像构建的,预装二进制文件的占用空间很小,且没有故障排除的工具。我们可以使用nsenter来检查容器的行为,特别是从网络角度,通过将当前的 Shell 程序附加到容器网络命名空间,同时保持对主机二进制文件的访问。

现在我们可以找出主进程的 PID,并将其值存入一个变量中:

$ CNT_PID=$(podman inspect students_app --format '{{ .State.Pid }}')

以下示例在容器网络命名空间中运行 Bash,同时保留其他所有主机命名空间(请注意使用sudo命令以提升权限运行):

$ sudo nsenter -t $CNT_PID -n /bin/bash

重要说明

可以直接从nsenter运行任何主机二进制文件。如下命令是完全合法的:$ sudo nsenter -t $CNT_PID -n ip addr show

为了证明我们确实在执行附加到容器网络命名空间的 Shell,我们可以启动ip addr show命令:

# ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: tap0: <BROADCAST,UP,LOWER_UP> mtu 65520 qdisc fq_codel state UNKNOWN group default qlen 1000
    link/ether fa:0b:50:ed:9d:37 brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.100/24 brd 10.0.2.255 scope global tap0
       valid_lft forever preferred_lft forever
    inet6 fe80::f80b:50ff:feed:9d37/64 scope link 
       valid_lft forever preferred_lft forever
# ip route
default via 10.0.2.2 dev tap0 
10.0.2.0/24 dev tap0 proto kernel scope link src 10.0.2.100

第一条命令,ip addr show,打印出 IP 配置,显示一个连接到主机的基本tap0接口和环回接口。

第二条命令,ip route,显示容器网络命名空间内的默认路由表。

我们可以使用ss工具查看活动连接,ss工具已经在我们的 Fedora 主机上可用:

# ss –atunp
Netid State     Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
tcp   TIME-WAIT 0      0         10.0.2.100:50728   10.0.2.100:8080
tcp   LISTEN    0      128                *:8080             *:*    usersL"("studen"s",pid=402788,fd=3))

我们立刻发现应用程序与数据库主机之间没有建立连接,这告诉我们问题可能与路由、防火墙规则或名称解析相关,这些因素阻止了我们正确访问主机。

下一步是尝试使用psql客户端工具手动连接到数据库,该工具来自postgresqlrpm 包:

# psql -h pghost.example.com
psql: error: could not translate host name "pghost.example.com" to address: Name or service not known

这个消息很清楚:主机无法通过 DNS 服务解析,导致应用程序失败。为了最终确认这一点,我们可以运行dig命令,它返回一个NXDOMAIN错误,这是 DNS 服务器典型的消息,表示域无法解析并不存在:

# dig pghost.example.com
; <<>> DiG 9.16.23-RH <<>> pghost.example.com
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 40669
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;pghost.example.com.        IN    A
;; Query time: 0 msec
;; SERVER: 192.168.200.1#53(192.168.200.1)
;; WHEN: Mon Dec 27 23:26:47 CET 2021
;; MSG SIZE  rcvd: 47

在与开发团队确认后,我们发现数据库名称中缺少了一个连字符并拼写错误,正确的名称是 pg-host.example.com。现在,我们可以通过使用正确的名称运行容器来修复问题。

我们现在期望再次启动查询时看到正确的结果:

$ curl localhost:8080/students
{"Id":10149,"FirstName":"Frank","MiddleName":"Vincent","LastName":"Zappa","Class":"3A","Course":"Composition"}

在这个示例中,我们集中讨论了网络命名空间故障排除,但通过简单地添加相关标志,我们也可以将当前的 shell 程序附加到多个命名空间。

我们还可以通过添加 -a 选项来模拟 podman exec

$ sudo nsenter -t $CNT_PID -a /bin/bash

该命令将进程附加到所有未共享的命名空间,包括挂载命名空间,从而提供与容器内进程看到的相同的文件系统树视图。

总结

在本章中,我们重点讨论了容器故障排除,旨在提供一套最佳实践和工具,用于在构建时或运行时查找并修复容器内的问题。

我们首先展示了在容器执行和构建阶段中的一些常见用例及其相关解决方案。

随后,我们介绍了健康检查的概念,并说明了如何在容器上实现稳健的探针以监控其状态,同时展示了其背后的架构概念。

在第三节中,我们学习了一系列与构建相关的常见错误场景,并展示了如何快速解决它们。

在最后一节中,我们介绍了 nsenter 命令,并模拟了一个需要网络故障排除的 Web 前端应用,以找出内部服务器错误的原因。通过这个示例,我们学会了如何在容器命名空间内部进行高级故障排除。

在下一章中,我们将讨论容器安全性,这是一个至关重要的概念,值得特别关注。我们将学习如何通过一系列最佳实践来保障容器安全,了解无根容器和有根容器之间的区别,并学习如何签名容器镜像以使其公开可用。

深入阅读

第十一章:保护容器安全

安全性正成为当今最热的话题。全球各地的企业和公司都在大量投资于安全实践和工具,以帮助保护其系统免受内部或外部攻击。

正如我们在第一章《容器技术简介》中简要看到的,容器及其宿主系统可以看作是执行并保持目标应用程序运行的一种媒介。安全性应该应用于服务架构的所有层面,从基础设施到目标应用程序代码,在穿越虚拟化或容器化层时,都应当得到保障。

在本章中,我们将探讨一些最佳实践和工具,帮助提高容器化层的整体安全性。特别是,我们将讨论以下主要主题:

  • 使用 Podman 运行无根容器

  • 不要以 UID 0 运行容器

  • 签名我们的容器镜像

  • 自定义 Linux 内核能力

  • SELinux 与容器的交互

技术要求

要完成本章的示例,你需要一台已经安装好 Podman 的机器。正如我们在第三章《运行第一个容器》中提到的,本书中的所有示例都在 Fedora 34 或更高版本的系统上执行,但你也可以在你选择的操作系统上复现这些示例。

第四章《管理正在运行的容器》,第五章《为容器的数据实现存储》,以及第九章《将镜像推送到容器注册表》所涵盖的主题有较好的理解,将帮助你更好地理解我们将在这里讨论的容器安全相关内容。

使用 Podman 运行无根容器

正如我们在第四章《管理正在运行的容器》中简要看到的,Podman 使得没有管理权限的标准用户也能在 Linux 主机上运行容器。这些容器通常被称为“无根容器”。

无根容器具有许多优势,包括以下几点:

  • 它们为容器引擎、运行时或编排器被攻破的情况下,增加了一层额外的安全保护,能够阻止攻击者尝试获取主机的根权限。

  • 它们允许多个没有特权的用户在同一主机上运行容器,从而充分利用高性能计算环境。

让我们思考任何 Linux 系统处理传统进程服务的方法。通常,软件包维护者会为调度和运行目标进程创建一个专用用户。如果我们尝试通过默认的软件包管理器在我们喜欢的 Linux 发行版上安装 Apache Web 服务器,那么我们会发现,安装的服务将通过一个名为“apache”的专用用户运行。

这种方法已经是多年的最佳实践,因为从安全角度来看,授权较少的权限可以提高安全性。

使用相同的方法,但采用无根容器,这样我们就可以在不需要额外权限提升的情况下运行容器进程。此外,Podman 是无守护进程的,因此它只会创建一个子进程。

在 Podman 中运行 rootless 容器非常简单,正如我们在前几章看到的,书中的许多示例都可以作为标准无特权用户运行。现在,让我们了解一下 rootless 容器执行背后的原理。

Podman 瑞士军刀——subuid 和 subgid

现代 Linux 发行版使用的 shadow-utils 包版本依赖于两个文件:/etc/subuid/etc/subgid。这些文件用于确定哪些 UIDs 和 GIDs 可以用于映射用户命名空间。

每个用户的默认分配是 65536 个 UID 和 65536 个 GID。

我们可以运行以下简单命令来检查 rootless 容器中 subuid 和 subgid 分配的工作原理:

$ id
uid=1000(alex) gid=1000(alex) groups=1000(alex),10(wheel) 
$ podman run alpine cat /proc/self/uid_map /proc/self/gid_map
Resolved "alpine" as an alias (/etc/containers/registries.conf.d/000-shortnames.conf)
Trying to pull docker.io/library/alpine:latest...
Getting image source signatures
Copying blob 59bf1c3509f3 done  
Copying config c059bfaa84 done  
Writing manifest to image destination
Storing signatures
         0       1000          1
         1     100000      65536
         0       1000          1
         1     100000      65536

如我们所见,两个文件都指示它们开始将 UID 和 GID 0 映射到我们刚刚使用来运行容器的当前用户的 UID/GID;即 1000。之后,它们将 UID 和 GID 1 从 100000 开始映射,并最终到达 165536。这是通过将起始点 100000 与默认范围 65536 相加计算得出的。

使用 rootless 容器并不是我们可以为容器环境实施的唯一最佳实践。在接下来的部分中,我们将了解为什么不应该使用 UID 0 运行容器。

不要使用 UID 0 运行容器

容器运行时可以被指示在容器内部执行与最初创建容器的用户 ID 不同的用户 ID 下的进程,类似于我们在 rootless 容器中看到的情况。以非 root 用户运行容器进程有助于安全性。例如,在容器中使用无特权用户可以限制容器内外的攻击面。

默认情况下,Dockerfile 和 Containerfile 可能会将默认用户设置为 root(即 UID=0)。为了避免这种情况,我们可以在这些构建文件中利用 USER 指令——例如,USER 1001——来指示 Buildah 或其他容器构建工具使用特定用户(UID 1001)来构建和运行容器镜像。

如果我们希望强制使用特定的 UID,需要调整我们计划与正在运行的容器一起使用的任何文件、文件夹或挂载点的权限。

现在,让我们学习如何调整现有镜像,使其可以以标准用户身份运行。

我们可以利用 DockerHub 上的一些预构建镜像,或者选择一个官方的 Nginx 容器镜像。首先,我们需要创建一个基本的 nginx 配置文件:

$ cat hello-podman.conf 
server {
    listen 80;

    location / {
        default_type text/plain;
        expires -1;
        return 200 'Hello Podman user!\nServer address: $server_addr:$server_port\n';
    }
}

nginx 配置文件非常简单:我们定义了监听端口(80)以及请求到达服务器时返回的内容消息。

然后,我们可以创建一个简单的 Dockerfile 来利用官方的 Nginx 容器镜像:

$ cat Dockerfile 
FROM docker.io/library/nginx:mainline-alpine
RUN rm /etc/nginx/conf.d/*
ADD hello-podman.conf /etc/nginx/conf.d/

Dockerfile 包含三个指令:

  • FROM:用于选择官方的 Nginx 镜像

  • RUN:用于清理配置目录中的任何默认配置示例

  • ADD:用于复制我们刚创建的配置文件

现在,让我们使用 Buildah 构建容器镜像:

$ buildah bud -t nginx-root:latest -f .
STEP 1/3: FROM docker.io/library/nginx:mainline-alpine
STEP 2/3: RUN rm /etc/nginx/conf.d/*
STEP 3/3: ADD hello-podman.conf /etc/nginx/conf.d/
COMMIT nginx-root:latest
Getting image source signatures
Copying blob 8d3ac3489996 done 
...
Copying config 21c5f7d8d7 done  
Writing manifest to image destination
Storing signatures
--> 21c5f7d8d70
Successfully tagged localhost/nginx-root:latest
21c5f7d8d709e7cfdf764a14fd6e95fb4611b2cde52b57aa46d43262a 6489f41

一旦构建了镜像,命名为 nginx-root。现在,我们准备运行我们的容器:

$ podman run --name myrootnginx -p 127.0.0.1::80 -d nginx-root 
364ec7f5979a5059ba841715484b7238db3313c78c5c577629364aa46b6d 9bdc

这里,我们使用了 –p 选项来发布端口并使其可以从主机访问。让我们找出在主机系统中随机选择的本地端口:

$ podman port myrootnginx 80
127.0.0.1:38029

最后,让我们启动我们的容器化 Web 服务器:

$ curl localhost:38029
Hello Podman user!
Server address: 10.0.2.100:80

容器终于启动了,但到底是哪个用户在使用我们的容器?让我们来看看:

$ podman ps | grep root
364ec7f5979a  localhost/nginx-root:latest  nginx -g daemon o...  55 minutes ago  Up 55 minutes ago  0.0.0.0:38029->80/tcp      myrootnginx
$ podman exec 364ec7f5979a id
uid=0(root) gid=0(root)

正如预期的那样,容器正在以 root 用户身份运行!

现在,让我们进行一些编辑以更改用户。首先,我们需要更改 Nginx 服务器配置中的监听端口:

$ cat hello-podman.conf 
server {
    listen 8080;

    location / {
        default_type text/plain;
        expires -1;
        return 200 'Hello Podman user!\nServer address: $server_addr:$server_port\n';
    }
}

在这里,我们将监听端口从 (80) 替换为 8080;我们不能在非特权用户下使用低于 1024 的端口。

接着,我们需要编辑我们的 Dockerfile:

$ cat Dockerfile 
FROM docker.io/library/nginx:mainline-alpine
RUN rm /etc/nginx/conf.d/*
ADD hello-podman.conf /etc/nginx/conf.d/

RUN chmod -R a+w /var/cache/nginx/ \
        && touch /var/run/nginx.pid \
        && chmod a+w /var/run/nginx.pid
EXPOSE 8080
USER nginx

如你所见,我们修复了 Nginx 服务器上主文件和文件夹的权限,暴露了新的 8080 端口,并将默认用户设置为 Nginx 用户。

现在,我们准备构建一个全新的容器镜像。我们将其命名为 nginx-user

$ buildah bud -t nginx-user:latest -f .
STEP 1/6: FROM docker.io/library/nginx:mainline-alpine
STEP 2/6: RUN rm /etc/nginx/conf.d/*
STEP 3/6: ADD hello-podman.conf /etc/nginx/conf.d/
STEP 4/6: RUN chmod -R a+w /var/cache/nginx/         && touch /var/run/nginx.pid         && chmod a+w /var/run/nginx.pid 
STEP 5/6: EXPOSE 8080
STEP 6/6: USER nginx
COMMIT nginx-user:latest
Getting image source signatures
Copying blob 8d3ac3489996 done  
... 
Copying config 7628852470 done  
Writing manifest to image destination
Storing signatures
--> 76288524704
Successfully tagged localhost/nginx-user:latest
762885247041fd233c7b66029020c4da8e1e254288e1443b356cbee4d73 adf3e

现在,我们可以运行容器了:

$ podman run --name myusernginx -p 127.0.0.1::8080 -d nginx-user 
299e0fb727f339d87dd7ea67eac419905b10e36181dc1ca7e35dc7d0a 9316243

找到关联的随机主机端口并检查 Web 服务器是否正常工作:

$ podman port myusernginx 8080
127.0.0.1:42209
$ curl 127.0.0.1:42209
Hello Podman user!
Server address: 10.0.2.100:8080

最后,让我们查看是否改变了在容器中运行目标进程的用户:

$ podman ps | grep user
299e0fb727f3  localhost/nginx-user:latest  nginx -g daemon o...  38 minutes ago  Up 38 minutes ago  127.0.0.1:42209->8080/tcp  myusernginx
$ podman exec 299e0fb727f3 id
uid=101(nginx) gid=101(nginx) groups=101(nginx)

如你所见,我们的容器以非特权用户身份运行,这正是我们所期望的。

如果你想查看一个现成的示例,请访问本书的 GitHub 仓库:github.com/PacktPublishing/Podman-for-DevOps

不幸的是,安全不仅仅关乎权限和用户——我们还需要关注基础镜像及其来源,并检查容器镜像的签名。我们将在下一节中学习这个。

签名我们的容器镜像

当我们处理从外部注册表拉取的镜像时,我们会有一些安全顾虑,特别是与潜在的攻击手段相关的安全问题(见 进一步阅读 部分中的 [1]),尤其是伪装技术,它帮助攻击者操控镜像组件使其看起来是合法的。这也可能是由于 中间人攻击MITM)在传输过程中被攻击者实施。

为了防止在管理容器时出现某些类型的攻击,最佳解决方案是使用分离的镜像签名来信任镜像提供者并确保其可靠性。

GNU 隐私保护工具GPG)是 OpenPGP 标准的一个自由实现,可以与 Podman 一起使用,用于签名镜像并在拉取后检查它们的有效签名。

当拉取镜像时,Podman 可以验证签名的有效性,并拒绝没有有效签名的镜像。

现在,让我们学习如何实现一个基本的镜像签名工作流。

使用 GPG 和 Podman 签名镜像

在本节中,我们将创建一个基本的 GPG 密钥对,并配置 Podman 来推送和签署镜像,同时将签名存储在一个临时存储库中。为了清晰起见,我们将使用基本的 Docker Registry V2 容器镜像来运行一个注册中心,而不做任何自定义。

在测试镜像拉取和签名验证工作流之前,我们将公开一个基本的 web 服务器以发布分离的签名。

要使用 GPG 创建镜像签名,我们需要创建一个有效的 GPG 密钥对,或者使用现有的密钥对。因此,我们将简要回顾 GPG 密钥对,以帮助你理解镜像签名是如何工作的。

密钥对由私钥和公钥组成。公钥可以广泛共享,而私钥则保持私密,绝不与任何人共享。接收者的公钥可以被发送者用于签署文件或消息。这样,只有私钥的拥有者(即接收者)才能解密消息。

我们可以轻松地将这一概念转化为容器镜像:将镜像推送到远程注册中心的镜像所有者可以使用密钥对进行签名,并将分离的签名存储在一个对用户公开可访问的存储库(从现在起称为sigstore)中。在这里,签名与镜像本身是分开的——注册中心会存储镜像的二进制数据,而 sigstore 则保存并公开镜像的签名。

拉取镜像的用户将能够使用之前共享的公钥来验证镜像的签名。

现在,让我们回到创建 GPG 密钥对的过程。我们将使用以下命令创建一个简单的密钥对:

$ gpg --full-gen-key

上述命令会要求你回答一系列问题,并提供一个密码短语来帮助生成密钥对。默认情况下,密钥对将存储在 $HOME/.gnupg 文件夹中。

密钥对的输出应类似于以下内容:

$ gpg --list-keys
/home/vagrant/.gnupg/pubring.kbx
pub   rsa3072 2022-01-05 [SC]
      2EA4850C32D29DA22B7659FEC38D92C0F18764AC
uid           [ultimate] Foo Bar foobar@example.com
sub   rsa3072 2022-01-05 [E]

也可以导出生成的密钥对。以下命令将公钥导出到一个文件:

$ gpg --armor --export foobar@example.com > pubkey.pem

该命令在稍后定义镜像签名的验证时会很有用。

以下命令可用于导出私钥:

$ gpg --armor \
  --export-secret-keys foobar@example.com > privkey.pem

在两个示例中,都使用了 --armor 选项将密钥导出为 隐私增强邮件PEM)格式。

一旦密钥对生成完成,我们可以创建一个基本的注册中心来托管我们的容器镜像。为此,我们将重新使用 第九章 中的基本示例,推送镜像到容器注册中心,并以 root 身份运行以下命令:

# mkdir /var/lib/registry
# podman run -d \
   --name local_registry \
   -p 5000:5000 \
   -v /var/lib/registry:/var/lib/registry:z \
   --restart=always registry:2

现在,我们有一个没有身份验证的本地注册中心,可以用来推送测试镜像。正如我们之前提到的,注册中心并不知道镜像的分离签名。

Podman 必须能够在 staging sigstore 上写入签名。/etc/containers/registries.d/default.yaml 文件中已经有默认配置,内容如下:

default-docker:
#  sigstore: file:///var/lib/containers/sigstore
  sigstore-staging: file:///var/lib/containers/sigstore

sigstore-staging 路径是 Podman 写入镜像签名的地方;它必须写入一个可写的文件夹。可以自定义此路径或保持默认配置不变。

如果我们想创建多个与用户相关的 sigstore,可以创建 $HOME/.config/containers/registries.d/default.yaml 文件,并在用户的主目录中定义一个自定义的 sigstore-staging 路径,遵循之前示例中展示的相同语法。这将允许用户以无根模式运行 Podman,并成功写入他们的 sigstore。

重要

不建议通过允许一般写权限来共享默认 sigstore 给所有用户。因为这样主机上的每个用户都能对现有签名进行写操作。

由于我们想使用默认的 sigstore,同时在用户的主目录下使用默认的 GPG 密钥对,我们将通过提升权限使用 sudo 运行 Podman,这是本书方法的一个例外。

以下示例展示了一个使用 UBI 8 构建的自定义 httpd 镜像的 Dockerfile:

Chapter11/image_signature/Dockerfile

FROM registry.access.redhat.com/ubi8
# Update image and install httpd
RUN yum install -y httpd && yum clean all –y
# Expose the default httpd port 80
EXPOSE 80
# Run the httpd
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

要构建镜像,我们可以运行以下命令:

$ cd Chapter11/image_signature 
$ sudo podman build -t custom_httpd .

现在,我们可以用本地注册表名称标记镜像:

$ sudo podman tag custom_httpd localhost:5000/custom_httpd

最后,是时候将镜像推送到临时注册表,并使用生成的密钥对进行签名。--sign-by 选项允许用户传递由用户邮箱标识的有效密钥对:

$ sudo GNUPGHOME=$HOME/.gnupg podman \
   push --tls-verify=false \
   --sign-by foobar@example.com \
   localhost:5000/custom_httpd
Getting image source signatures
Copying blob 3ba8c926eef9 done  
Copying blob a59107c02e1f done  
Copying blob 352ba846236b done  
Copying config 569b015109 done  
Writing manifest to image destination
Signing manifest
Storing signatures

前面的代码成功地将镜像 blob 推送到注册表,并存储了镜像签名。注意 GNUPGHOME 变量,它在命令开始时被传递,用于定义 Podman 访问的 GPG 密钥存储路径。

警告

远程 Podman 客户端不支持 --sign-by 选项。

为了验证镜像是否已正确签名,并且其签名是否被保存在 sigstore 中,我们可以检查 /var/lib/containers/sigstore 的内容:

$ ls -al /var/lib/containers/sigstore/
drwxr-xr-x. 6 root    root    4096 Jan  5 18:58  .
drwxr-xr-x. 5 root    root    4096 Jan  5 13:29  ..
drwxr-xr-x. 2 root    root    4096 Jan  5 18:58 'custom_httpd@sha256=573c1eb93857c0169a606f1820271b143ac5073456f844255c3c7a9e 308bf639'

如你所见,新目录中包含了镜像签名文件:

$ ls -al /var/lib/containers/sigstore/'custom_httpd@sha256=573c1eb93857c0169a606f1820271b143ac5073456f844255c3c7a9 e308bf639'
total 12
drwxr-xr-x. 2 root root 4096 Jan  5 18:58 .
drwxr-xr-x. 6 root root 4096 Jan  5 18:58 ..
-rw-r--r--. 1 root root  730 Jan  5 18:58 signature-1

这样,我们就成功推送并签署了镜像,使其在未来使用中更加安全。接下来,让我们学习如何配置 Podman 来检索签名镜像。

配置 Podman 拉取签名镜像

为了成功拉取签名镜像,Podman 必须能够从 sigstore 获取签名,并且能够访问公钥以验证签名。

在这里,我们处理的是分离的签名,并且我们已经知道注册表不包含任何有关镜像签名的信息。因此,我们需要通过一个公开可访问的 sigstore 让用户可以访问它:一个 Web 服务器(如 Nginx、Apache httpd 等)将是一个不错的选择。

由于签名主机将与用于测试镜像拉取的主机相同,我们将运行一个 Apache httpd 服务器,将 sigstore 暂存文件夹暴露为服务器文档根目录。在实际场景中,我们会将签名移动到专用的 Web 服务器上。

在这个示例中,我们将使用标准的 docker.io/library/httpd 镜像,并以 root 权限运行容器,以便访问 sigstore 文件夹:

# podman run -d -p 8080:80 \
  --name sigstore_server \
  -v /var/lib/containers/sigstore:/usr/local/apache2/htdocs:z \
  docker.io/library/httpd

现在,Web 服务器已在 http://localhost:8080 上可用,Podman 可以使用它来获取镜像签名。

现在,让我们配置 Podman 以进行镜像拉取。首先,我们必须配置默认的镜像 sigstore。我们已经定义了 Podman 用来写入签名的暂存 sigstore,现在,我们需要定义用于读取镜像签名的 sigstore。

为此,我们必须再次编辑 /etc/containers/registries.d/default.yaml 文件,并添加对运行在 http://localhost:8080 的默认 sigstore Web 服务器的引用:

default-docker:
  sigstore: http://localhost:8080
  sigstore-staging: file:///var/lib/containers/sigstore

前面的代码配置了 Podman 用于所有镜像的 sigstore。然而,通过填写文件的docker字段,我们可以为特定的镜像仓库添加更多的 sigstore。以下代码配置了公共 Red Hat 仓库的 sigstore:

docker:
  registry.access.redhat.com:
    sigstore: https://access.redhat.com/webassets/docker/content/sigstore

在测试镜像拉取之前,我们必须实现 Podman 用于验证签名的公钥。这个公钥必须存储在拉取镜像的主机上,并且属于用于签名镜像的密钥对。

用于定义公钥路径的配置文件是 /etc/containers/policy.json

以下代码显示了具有自定义配置的 /etc/containers/policy.json 文件,适用于注册表 localhost:5000

{
    "default": [
        {
            "type": "insecureAcceptAnything"
        }
    ],
    "transports": {
        "docker": {
            "localhost:5000": [
                {
                    "type": "signedBy",
                    "keyType": "GPGKeys",
                    "keyPath": "/tmp/pubkey.gpg"
                }
            ]
        },
        "docker-daemon": {
            "": [
                {
                    "type": "insecureAcceptAnything"
                }
            ]
        }
    }
}

要验证从 localhost:5000 拉取的镜像的签名,我们可以使用存储在 keyPath 字段定义的路径中的公钥。公钥必须存在于定义的路径中,并且可以被 Podman 读取。

如果我们需要从本节开头生成的示例密钥对中提取公钥,可以使用以下 GPG 命令:

$ gpg --armor --export foobar@example.com > /tmp/pubkey.gpg

现在,我们准备好测试镜像拉取并验证其签名了:

$ podman pull --tls-verify=false localhost:5000/custom_httpd
Getting image source signatures
Checking if image destination supports signatures
Copying blob 23fdb56daf15 skipped: already exists  
Copying blob d4f13fad8263 skipped: already exists  
Copying blob 96b0fdd0552f done  
Copying config 569b015109 done  
Writing manifest to image destination
Storing signatures
569b015109d457ae5fabb969fd0dc3cce10a3e6683ab60dc10505fc2d68 e769f

在使用提供的公钥进行签名验证后,镜像成功拉取到本地存储中。

现在,让我们看看当 Podman 无法正确验证签名时,它的表现如何。

测试签名验证失败

如果我们使 sigstore 无法访问,会怎么样?如果 Podman 无法验证签名,它还会成功拉取镜像吗?让我们尝试停止暴露 sigstore 的本地 httpd 服务器:

# podman stop sigstore_server

在再次拉取镜像之前,让我们删除之前缓存的镜像,以避免误报:

$ podman rmi localhost:5000/custom_httpd

现在,我们可以尝试再次拉取镜像:

$ podman pull --tls-verify=false localhost:5000/custom_httpd
Trying to pull localhost:5000/custom_httpd:latest...
WARN[0000] failed, retrying in 1s ... (1/3). Error: Source image rejected: Get "http://localhost:8080/custom_httpd@sha256=573c1eb93857c0169a606f1820271b143ac5073456f844255c3c7a9 e308bf639/signature-1": dial tcp [::1]:8080: connect: connection refused 
WARN[0001] failed, retrying in 1s ... (2/3). Error: Source image rejected: Get "http://localhost:8080/custom_httpd@sha256=573c1eb93857c0169a606f1820271b143ac5073456f844255c3c7a9 e308bf639/signature-1": dial tcp [::1]:8080: connect: connection refused 
WARN[0002] failed, retrying in 1s ... (3/3). Error: Source image rejected: Get "http://localhost:8080/custom_httpd@sha256=573c1eb93857c0169a606f1820271b143ac5073456f844255c3c7a9 e308bf639/signature-1": dial tcp [::1]:8080: connect: connection refused 
Error: Source image rejected: Get "http://localhost:8080/custom_httpd@sha256=573c1eb93857c0169a606f1820271b143ac5073456f 844255c3c7a9e308bf639/signature-1": dial tcp [::1]:8080: connect: connection refused

前面的错误表明 Podman 尝试连接暴露 sigstore 的 Web 服务器时失败了。此错误阻止了整个镜像拉取过程。

当我们用来验证签名的公共密钥无效或不属于用于签署镜像的密钥对时,会发生不同的错误。为了测试这一点,让我们用另一个密钥对中的公共密钥替换当前的公共密钥——在本示例中,我们使用的是从 /etc/pki/rpm-gpg 目录获取的公共 Fedora 34 RPM-GPG 密钥(也可以使用任何其他公共密钥):

$ mv /tmp/pubkey.gpg /tmp/pubkey.gpg.bak 
$ cp /etc/pki/rpm-gpg/RPM-GPG-KEY-fedora-34-x86_64 \
     /tmp/pubkey.gpg

之前停止的 httpd 服务器必须重新启动;我们要使签名可用,并专注于错误的公共密钥问题:

# podman start sigstore_server

现在,我们可以再次拉取镜像并检查生成的错误:

$ podman pull --tls-verify=false localhost:5000/custom_httpd
Trying to pull localhost:5000/custom_httpd:latest...
Error: Source image rejected: Invalid GPG signature: gpgme.Signature{Summary:128, Fingerprint:"2EA4850C32D29DA22B7659FEC38D92C0F18764AC", Status:gpgme.Error{err:0x9}, Timestamp:time.Time{wall:0x0, ext:63777026489, loc:(*time.Location)(0x560e17e5d680)}, ExpTimestamp:time.Time{wall:0x0, ext:62135596800, loc:(*time.Location)(0x560e17e5d680)}, WrongKeyUsage:false, PKATrust:0x0, ChainModel:false, Validity:0, ValidityReason:error(nil), PubkeyAlgo:1, HashAlgo:8}

在这里,Podman 产生了一个错误,这个错误是由于无效的 GPG 签名造成的,这是正确的,因为正在使用的公共密钥不属于正确的密钥对。

重要

在继续进行以下示例之前,请不要忘记恢复有效的公共密钥。

Podman 可以管理多个注册表和 sigstore,并且还提供专用命令来帮助您自定义安全策略,正如我们在下一小节中将看到的那样。

使用 Podman 镜像信任命令管理密钥

可以编辑 /etc/containers/policy.json 文件并修改其 JSON 对象,以添加或删除专用注册表的配置。然而,手动编辑容易出错,且不易自动化。

另外,我们可以使用 podman image trust 命令来转储或修改当前配置。

以下代码展示了如何使用 podman image trust show 命令打印当前的配置:

$ 
default         accept                                         
localhost:5000  signedBy                foobar@example.com  http://localhost:8080
                insecureAcceptAnything                         http://localhost:8080

也可以配置新的信任。例如,我们可以添加 Red Hat 公共 GPG 密钥,以检查 UBI 镜像的签名。

首先,我们需要下载 Red Hat 公共密钥:

$ sudo wget -O /etc/pki/rpm-gpg/RPM-GPG-KEY-redhat \ 
  https://www.redhat.com/security/data/fd431d51.txt

注意

Red Hat 的产品签名密钥,包括本示例中使用的那个,可以在 access.redhat.com/security/team/key 找到。

下载密钥后,我们必须使用 podman image trust set 命令为从 registry.access.redhat.com 拉取的 UBI 8 镜像配置镜像信任:

$ sudo podman image trust set -f /etc/pki/rpm-gpg/RPM-GPG-KEY-redhat registry.access.redhat.com/ubi8

运行上述命令后,/etc/containers/policy.json 文件将发生如下变化:

{
    "default": [
        {
            "type": "insecureAcceptAnything"
        }
    ],
    "transports": {
        "docker": {
            "localhost:5000": [
                {
                    "type": "signedBy",
                    "keyType": "GPGKeys",
                    "keyPath": "/tmp/pubkey.gpg"
                }
            ],
            "registry.access.redhat.com/ubi8": [
                {
                    "type": "signedBy",
                    "keyType": "GPGKeys",
                    "keyPath": "/etc/pki/rpm-gpg/RPM-GPG-KEY-redhat"
                }
            ]
        },
        "docker-daemon": {
            "": [
                {
                    "type": "insecureAcceptAnything"
                }
            ]
        }
    }

请注意,文件中已经添加了与 registry.access.redhat.com/ubi8 相关的条目,以及用于验证镜像签名的公共密钥。

为了完成配置,我们需要将 Red Hat sigstore 配置添加到 /etc/containers/registries.d/default.yaml 配置文件中:

docker:
  registry.access.redhat.com:
    sigstore: https://access.redhat.com/webassets/docker/content/sigstore

提示

可以在 /etc/containers/registries.d 文件夹中为不同的提供商创建自定义注册表配置文件。例如,前面的示例可以在专用的 /etc/containers/registries.d/redhat.yaml 文件中定义。这使得您可以轻松维护和版本化注册表 sigstore 配置。

从现在开始,每次从 registry.access.redhat.com 拉取 UBI8 镜像时,它的签名将从 Red Hat sigstore 中拉取,并使用提供的公共密钥进行验证。

到目前为止,我们已经查看了与 Podman 相关的密钥管理示例,但也可以使用 Skopeo 来管理签名验证。在下一个小节中,我们将查看一些基本示例。

使用 Skopeo 管理签名

当我们从有效的传输中拉取镜像时,可以使用 Skopeo 验证镜像签名。

以下示例使用 skopeo copy 命令将镜像从我们的注册表拉取到本地存储。此命令与使用 podman pull 命令具有相同效果,但允许对源和目标传输进行更多控制:

$ skopeo copy --src-tls-verify=false \
  docker://localhost:5000/custom_httpd \
  containers-storage:localhost:5000/custom_httpd

Skopeo 不需要进一步配置,因为先前修改过的配置文件已经定义了 sigstore 和公钥路径。

我们还可以使用 Skopeo 在将镜像复制到传输之前对其进行签名:

$ sudo GNUPGHOME=$HOME/.gnupg skopeo copy \
   --dest-tls-verify=false \
   --sign-by foobar@example.com \
   containers-storage:localhost:5000/custom_httpd \
   docker://localhost:5000/custom_httpd

再次强调,Podman 使用的配置文件对于 Skopeo 仍然有效,后者使用相同的 sigstore 来写入签名,并使用相同的 GPG 存储来检索生成签名所需的密钥。

在这一部分,我们了解了如何验证镜像签名并避免潜在的中间人攻击。在接下来的部分,我们将重点介绍如何通过自定义 Linux 内核能力来执行容器运行时。

自定义 Linux 内核能力

能力是 Linux 内核 2.2 中引入的特性,目的是将提升的特权拆分成单一单元,这些单元可以被任意分配给进程或线程。

我们可以通过为非特权进程分配一组特定的限制性能力,而不是以有效 UID 0 运行一个完全特权的进程。通过对进程执行的安全上下文进行更细粒度的控制,这种方法有助于缓解潜在的攻击手段。

在讨论容器的能力之前,我们先回顾一下它们在 Linux 系统中是如何工作的,这样我们就能理解它们的内部逻辑。

能力快速入门指南

能力是通过使用扩展属性(参见man xattr)与文件可执行文件关联的,并且会被通过execve()系统调用执行的进程自动继承。

可用能力的列表相当庞大,并且仍在增长;它包括线程可以执行的非常具体的操作。一些基本示例如下:

  • CAP_CHOWN:此能力允许线程修改文件的 UID 和 GID。

  • CAP_KILL:此能力允许你绕过权限检查,向进程发送信号。

  • mknod() 系统调用。

  • CAP_NET_ADMIN:此能力允许你对系统的网络配置执行各种特权操作,包括更改接口配置、启用/禁用接口的混杂模式、编辑路由表和启用/禁用多播。

  • CAP_NET_RAW:该功能允许线程使用 RAW 和 PACKET 套接字。程序如 ping 可以使用此功能发送 ICMP 数据包,而无需提升权限。

  • chroot() 系统调用和通过 setns() 系统调用更改挂载命名空间。

  • CAP_DAC_OVERRIDE:该功能允许绕过 自主访问控制DAC)检查,从而允许文件的读取、写入和执行操作。

更多详细信息以及可用功能的详细列表,请参阅相关的手册页(man capabilities)。

要将某个功能分配给可执行文件,我们可以使用 setcap 命令,如以下示例所示,其中 CAP_NET_ADMINCAP_NET_RAW 被授权给 /usr/bin/ping 可执行文件:

$ sudo setcap 'cap_net_admin,cap_net_raw+p' /usr/bin/ping

前述命令中的 '+p' 标志表示这些功能已设置为 Permitted

要检查文件的功能,我们可以使用 getcap 命令:

$ getcap /usr/bin/ping
/usr/bin/ping cap_net_admin,cap_net_raw=p

有关这些工具的更多详细信息,请参阅 man getcapman setcap

我们可以通过查看 /proc/<PID>/status 文件来检查运行中的进程的活动功能。在以下代码中,我们在设置 CAP_NET_ADMINCAP_NET_RAW 功能后启动 ping 命令。我们希望将进程置于后台并检查其当前功能:

$ ping example.com > /dev/null 2>&1 &
$ grep 'Cap.*' /proc/$(pgrep ping)/status
CapInh: 0000000000000000
CapPrm: 0000000000003000
CapEff: 0000000000000000
CapBnd: 000000ffffffffff
CapAmb: 0000000000000000

在这里,我们的兴趣在于评估 CapPrm 字段中的位图,该字段表示允许的功能。为了获得更易读的值,我们可以使用 capsh 命令解码位图的十六进制值:

$ capsh --decode=0000000000003000
0x0000000000003000=cap_net_admin,cap_net_raw

结果与 getcap 命令在 /usr/bin/ping 文件中的输出相似,表明执行该命令将文件的允许功能传播到了其进程实例。

要查看用于设置位图的常量及其对应的功能列表,请参阅以下内核头文件:github.com/torvalds/linux/blob/master/include/uapi/linux/capability.h

提示

像 RHEL 和 CentOS 这样的发行版使用上述配置,允许 ping 命令发送 ICMP 数据包,并且所有用户都可以访问,而无需将其作为特权进程执行,且不使用 setuid 0。这种做法不安全,攻击者可以利用可执行文件中的漏洞或错误来提升权限并获得系统控制。

Fedora 在 31 版本中引入了一种新的、更安全的方法,该方法基于使用 net.ipv4.ping_group_range Linux 内核参数。通过设置涵盖所有系统组的广泛范围,该参数允许用户发送 ICMP 数据包,而无需启用 CAP_NET_ADMINCAP_NET_RAW 功能。

更多详细信息,请参阅 Fedora 项目的以下 Wiki 页面:fedoraproject.org/wiki/Changes/EnableSysctlPingGroupRange

现在我们已经提供了关于 Linux 内核能力的高层次描述,让我们学习这些能力是如何应用到容器中的。

容器中的能力

能力可以应用于容器内,以便执行特定的操作。默认情况下,Podman 使用一组在/usr/share/containers/containers.conf文件中定义的 Linux 内核能力来运行容器。在撰写本文时,以下能力在该文件中已启用:

default_capabilities = [
    "CHOWN",
    "DAC_OVERRIDE",
    "FOWNER",
    "FSETID",
    "KILL",
    "NET_BIND_SERVICE",
    "SETFCAP",
    "SETGID",
    "SETPCAP",
    "SETUID",
    "SYS_CHROOT"
]

我们可以运行一个简单的测试来验证这些能力是否已有效应用于容器内运行的进程。对于这个测试,我们将使用官方的 Nginx 镜像:

$ podman run -d --name cap_test docker.io/library/nginx
$ podman exec -it cap_test sh -c 'grep Cap /proc/1/status'
CapInh: 00000000800405fb
CapPrm: 00000000800405fb
CapEff: 00000000800405fb
CapBnd: 00000000800405fb
CapAmb: 0000000000000000

在这里,我们提取了来自父进程 Nginx(在容器内以 PID 1 运行)的当前能力。现在,我们可以使用capsh工具检查位图:

$ capsh --decode=00000000800405fb
0x00000000800405fb=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_sys_chroot,cap_setfcap

上述能力列表与默认 Podman 配置中定义的列表相同。请注意,这些能力在无根模式和有根模式下都会应用。

注意

如果你感兴趣,容器化进程的能力是由容器运行时设置的,该运行时可以是runccrun,具体取决于发行版。

现在我们知道了能力是如何在容器内配置和应用的,让我们学习如何定制容器的能力。

定制容器的能力

我们可以在运行时或静态地添加或删除能力。

要静态更改默认能力,我们只需编辑/usr/share/containers/containers.conf文件中的default_capabilities字段,并根据需要添加或删除它们。

要在运行时修改能力,我们可以使用–cap-add–cap-drop选项,这两个选项都由podman run命令提供。

以下代码从容器中移除CAP_DAC_OVERRIDE能力:

$ podman run -d --name cap_test2 --cap-drop=DAC_OVERRIDE docker.io/library/nginx

如果我们再次查看能力位图,我们会看到它们已相应更新:

$ podman exec cap_test2 sh -c 'grep Cap /proc/1/status'
CapInh: 00000000800405f9
CapPrm: 00000000800405f9
CapEff: 00000000800405f9
CapBnd: 00000000800405f9
CapAmb: 0000000000000000
$ capsh --decode=00000000800405f9
0x00000000800405f9=cap_chown,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_net_bind_service,cap_sys_chroot,cap_setfcap

可以多次传递--cap-add--cap-drop选项:

$ podman run -d --name cap_test3 \
   --cap-drop=KILL \
   --cap-drop=DAC_OVERRIDE \
   --cap-add=NET_RAW \
   --cap-add=NET_ADMIN \
   docker.io/library/nginx

在处理能力时,我们必须小心删除默认能力。以下代码展示了在删除CAP_CHOWN能力时,Nginx 容器中的错误:

$ podman run --name cap_test4 \
  --cap-drop=CHOWN \
  docker.io/library/nginx
/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty, will attempt to perform configuration
/docker-entrypoint.sh: Looking for shell scripts in /docker-entrypoint.d/
/docker-entrypoint.sh: Launching /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
10-listen-on-ipv6-by-default.sh: info: Getting the checksum of /etc/nginx/conf.d/default.conf
10-listen-on-ipv6-by-default.sh: info: Enabled listen on IPv6 in /etc/nginx/conf.d/default.conf
/docker-entrypoint.sh: Launching /docker-entrypoint.d/20-envsubst-on-templates.sh
/docker-entrypoint.sh: Launching /docker-entrypoint.d/30-tune-worker-processes.sh
/docker-entrypoint.sh: Configuration complete; ready for start up
2022/01/06 23:19:39 [emerg] 1#1: chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)
nginx: [emerg] chown("/var/cache/nginx/client_temp", 101) failed (1: Operation not permitted)

这里容器失败了。从输出中,我们可以看到 Nginx 进程无法显示/var/cache/nginx/client_temp目录。这是因为CAP_CHOWN能力被移除所导致的直接后果。

并非所有能力都可以应用于无根容器。例如,如果我们尝试将CAP_MKNOD能力应用于无根容器,内核将不允许在无根容器内创建特殊文件:

$ podman run -it --cap-add=MKNOD \
  docker.io/library/busybox /bin/sh
/ # mkdir -p /test/dev
/ # mknod -m 666 /test/dev/urandom c 1 8
mknod: /test/dev/urandom: Operation not permitted

相反,如果我们以提升的 root 权限运行容器,则可以成功分配该能力:

# podman run -it --cap-add=MKNOD \
  docker.io/library/busybox /bin/sh
/ # mkdir -p /test/dev
/ # mknod -m 666 /test/dev/urandom c 1 8
/ # stat /test/dev/urandom
File: /test/dev/urandom
  Size: 0          Blocks: 0          IO Block: 4096   character special file
Device: 31h/49d Inode: 530019      Links: 1     Device type: 1,8
Access: (0666/crw-rw-rw-)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2022-01-06 23:50:06.056650747 +0000
Modify: 2022-01-06 23:50:06.056650747 +0000
Change: 2022-01-06 23:50:06.056650747 +0000

注意

通常,向容器添加能力意味着扩大潜在的攻击面,恶意攻击者可能利用这一点。如果不必要,保持默认能力并在分析潜在副作用后删除不需要的能力是一种良好的做法。

在这一部分,我们学习了如何管理容器中的能力。然而,能力并不是在保护容器时需要考虑的唯一安全方面。正如我们在下一部分将要学习的,SELinux 在确保容器隔离方面起着至关重要的作用。

SELinux 与容器的交互

在这一部分,我们将讨论 SELinux 策略,并介绍 Udica,这是一个用于为容器生成 SELinux 配置文件的工具。

SELinux 直接在内核空间中工作,并遵循最小特权模型来管理对象隔离,该模型包含一系列 策略,用于强制执行或例外处理。为了定义这些对象,SELinux 使用定义 类型 的标签。默认情况下,SELinux 处于 强制 模式,拒绝对资源的访问,并根据策略定义一系列例外。要禁用强制模式,可以将 SELinux 设置为 宽容 模式,在该模式下,违规行为仅会被审计,而不会被阻止。

安全警报

正如我们之前提到的,切换 SELinux 到宽容模式或完全禁用它是 不推荐的做法,因为这会让你面临潜在的安全威胁。与其这样做,用户应该创建自定义策略来管理必要的例外。

默认情况下,SELinux 使用 定向 策略类型,试图通过一组预定义的策略来针对并限制特定的对象类型(进程、文件、设备等)。

SELinux 允许多种类型的访问控制。它们可以总结如下:

  • 类型强制TE):这根据进程和文件类型控制对资源的访问。这是 SELinux 访问控制的主要应用场景。

  • 基于角色的访问控制RBAC):这通过 SELinux 用户(可以映射到真实系统用户)及其相关的 SELinux 角色来控制对资源的访问。

  • 多级安全MLS):这为所有具有相同敏感性级别的进程授予对资源的读/写访问权限。

  • 多类别安全MCS):这通过 类别 来控制访问,类别是应用于资源的纯文本标签。类别用于创建对象的隔离区,同时与其他 SELinux 标签一起使用。只有属于同一类别的进程才能访问某一特定资源。在 第五章中,为容器的数据实现存储,我们讨论了 MCS 以及如何将类别映射到容器访问过的资源。

通过类型强制,系统文件被赋予称为类型的标签,而进程则被赋予称为的标签。属于某个域的进程可以被允许访问属于特定类型的文件,这种访问可以由 SELinux 进行审核。

例如,根据 SELinux,标记有httpd_t域的 Apache httpd进程可以访问具有httpd_sys_content_t标签的文件或目录。

SELinux 类型策略基于以下模式:

POLICY DOMAIN TYPE:CLASS OPERATION;

在这里,POLICY是策略类型(例如,allowallowxpermauditallowneverallowdontaudit等),DOMAIN是进程域,TYPE是资源类型上下文,CLASS是对象类别(例如,filedirlnk_filechr_fileblk_filesock_filefifo_file),而OPERATION是由策略处理的操作列表(例如,openreaduselockgetattrrevc)。

以下示例展示了一个基本的allow规则:

allow myapp_t myapp_log_t:file { read_file_perms append_file_perms };

在此示例中,运行在myapp_t域中的进程被允许访问myapp_log_t类型的文件,并执行read_file_permsappend_file_perms操作。

SELinux 以模块化的方式管理策略,允许您动态加载和卸载策略模块,无需每次重新编译整个策略集。策略可以使用semodule实用程序加载和卸载,如以下示例所示,展示了加载自定义策略的示例:

# semodule -i custompolicy.pp

semodule实用程序也可以用于查看所有加载的策略:

# semodule -l

在 Fedora、CentOS、RHEL 以及衍生分发版上,当前的二进制策略被安装在/etc/selinux/targeted/policy目录下,文件名为polixy.XX,其中XX表示策略版本。

在相同的分发版本上,容器策略定义在container-selinux软件包中,该软件包包含已编译的 SELinux 模块。如果您希望更详细地查看,软件包的源代码可在 GitHub 上获取:github.com/containers/container-selinux

通过查看存储库的内容,我们将找到开发任何模块所需的三个最重要的策略源文件:

  • container.fc:此文件定义了与模块中定义的类型绑定的文件和目录。

  • container.te:此文件定义了策略规则、属性和别名。

  • container.if:此文件定义了模块接口。它包含一组由模块公开的宏函数。

在容器内运行的进程被标记为container_t域。它具有对被标记为container_file_t类型上下文的资源的读写访问权限,并且对被标记为container_share_t类型上下文的资源具有读取和执行访问权限。

当容器执行时,podman 进程、容器运行时以及 conmon 进程将以 container_runtime_t 域类型运行,并且只允许执行那些仅能过渡到特定类型的进程。这些类型被分组在 container_domain 属性中,并且可以使用 seinfo 工具(在 Fedora 上与 setools-console 包一起安装)进行检查,如以下代码所示:

$ seinfo -a container_domain -x
Type Attributes: 1
   attribute container_domain;
container_engine_t
container_init_t
container_kvm_t
container_logreader_t
container_t
container_userns_t
spc_t

container_domain 属性在 container-policy 仓库中的 container.te 源文件里通过 attribute 关键字声明:

attribute container_domain;
attribute container_user_domain;
attribute container_net_domain;

上述属性通过 typeattribute 声明映射到 container_t 类型:

typeattribute container_t container_domain, container_net_domain, container_user_domain;

使用这种方法,SELinux 确保了容器之间以及容器与主机之间的进程隔离。通过这种方式,逃离容器的进程(可能是利用漏洞)无法访问主机或其他容器中的资源。

当容器被创建时,镜像的只读层(即形成 OverlayFS 的 LowerDirs 集合)会被标记为 container_ro_file_t 类型,这会防止容器在这些目录中进行写操作。同时,MergedDir(LowerDirs 和 UpperDir 的总和)是可写的,并被标记为 container_file_t

为了证明这一点,让我们运行 c1c2 MCS 类别:

# podman run -d --name selinux_test1 --security-opt label=level:s0:c1,c2 nginx

现在,我们可以在主机文件系统中找到所有标记为 container_file_t:s0:c1,c2 的文件:

# find /var/lib/containers/storage/overlay -type f -context '*container_file_t:s0:c1,c2*' -printf '%-50Z%p\n'
system_u:object_r:container_file_t:s0:c1,c2       /var/lib/containers/storage/overlay/4b147975bb5c336b10e71d21c49fe88ddb00d0569b77ddab1 d7737f80056677b/merged/lib/x86_64-linux-gnu/libreadline.so.8.1
system_u:object_r:container_file_t:s0:c1,c2       /var/lib/containers/storage/overlay/4b147975bb5c336b10e71d21c49fe88ddb00d0569b77ddab1 d7737f80056677b/merged/lib/x86_64-linux-gnu/libhistory.so.8.1
system_u:object_r:container_file_t:s0:c1,c2       /var/lib/containers/storage/overlay/4b147975bb5c336b10e71d21c49fe88ddb00d0569b77ddab1 d7737f80056677b/merged/lib/x86_64-linux-gnu/libexpat.so.1.6.12
system_u:object_r:container_file_t:s0:c1,c2       /var/lib/containers/storage/overlay/4b147975bb5c336b10e71d21c49fe88ddb00d0569b77ddab1 d7737f80056677b/merged/lib/udev/rules.d/96-e2scrub.rules
system_u:object_r:container_file_t:s0:c1,c2       /var/lib/containers/storage/overlay/4b147975bb5c336b10e71d21c49fe88ddb00d0569b77ddab1 d7737f80056677b/merged/lib/terminfo/r/rxvt-unicode-256color
system_u:object_r:container_file_t:s0:c1,c2       /var/lib/containers/storage/overlay/4b147975bb5c336b10e71d21c49fe88ddb00d0569b77ddab1 d7737f80056677b/merged/lib/terminfo/r/rxvt-unicode
[…output omitted...]

如预期的那样,container_file_t 标签,关联了 c1c2 类别,被应用于 MergedDir 容器下的所有文件。

同时,我们可以演示容器的 LowerDirs 被标记为 container_ro_file_t。首先,我们需要提取容器的 LowerDirs 列表:

# podman inspect selinux_test1 \
  --format '{{.GraphDriver.Data.LowerDir}}'
/var/lib/containers/storage/overlay/9566cbcf1773eac59951c14c52156a6164db1b0d8026d015 e193774029db18a5/diff:/var/lib/containers/storage/overlay/24de59cced7931bbcc0c4a34d4369c15119a0b8b180f98a0434 fa76a6dfcd490/diff:/var/lib/containers/storage/overlay/1bb84245b98b7e861c91ed4319972ed3287bdd2ef02a8657c696 a76621854f3b/diff:/var/lib/containers/storage/overlay/97f26271fef21bda129ac431b5f0faa03ae0b2b50bda6af 969315308fc16735b/diff:/var/lib/containers/storage/overlay/768ef71c8c91e4df0aa1caf96764ceec999d7eb0aa584 e241246815c1fa85435/diff:/var/lib/containers/storage/overlay/2edcec3590a4ec7f40cf0743c15d78fb39d8326bc029073 b41ef9727da6c851f/diff

最右侧的目录代表容器的最底层,通常是镜像的基础文件系统树。让我们检查这个目录的类型上下文:

# ls -alZ /var/lib/containers/storage/overlay/2edcec3590a4ec7f40 cf0743c15d78fb39d8326bc029073b41ef9727da6c851f/diff
total 84
dr-xr-xr-x. 21 root root unconfined_u:object_r:container_ro_file_t:s0 4096 Jan  5 23:16 .
drwx------.  6 root root unconfined_u:object_r:container_ro_file_t:s0 4096 Jan  5 23:16 ..
drwxr-xr-x.  2 root root unconfined_u:object_r:container_ro_file_t:s0 4096 Dec 20 00:00 bin
drwxr-xr-x.  2 root root unconfined_u:object_r:container_ro_file_t:s0 4096 Dec 11 17:25 boot
drwxr-xr-x.  2 root root unconfined_u:object_r:container_ro_file_t:s0 4096 Dec 20 00:00 dev
drwxr-xr-x. 30 root root unconfined_u:object_r:container_ro_file_t:s0 4096 Dec 20 00:00 etc
drwxr-xr-x.  2 root root unconfined_u:object_r:container_ro_file_t:s0 4096 Dec 11 17:25 home
drwxr-xr-x.  8 root root unconfined_u:object_r:container_ro_file_t:s0 4096 Dec 20 00:00 lib
[...omitted output...]

上述输出还展示了另一个有趣的方面:由于 LowerDir 层在使用相同镜像的多个容器之间共享,我们不会在这里找到任何已应用的 MCS 类别。

容器无法读写未标记为 container_file_t 的文件或目录。之前,我们看到通过对挂载卷应用 :z 后缀,或者在运行容器之前手动重新标记它们,都是可以重新标记这些文件的。

然而,重新标记像 /home/var/logs 这样的关键目录是一个非常糟糕的主意,因为许多其他非容器化的进程将无法再访问它们。

唯一的解决方案是手动创建自定义策略来覆盖默认行为。然而,这对于日常使用和生产环境来说,管理起来过于复杂。

幸运的是,我们可以通过一个工具来解决这个限制,该工具为我们的容器生成自定义的 SELinux 安全配置文件:Udica

引入 Udica

Udica 是一个开源项目(github.com/containers/udica),由 Lukas Vrabec 创建,他是 SELinux 的倡导者,并且是 Red Hat SELinux 与安全专项工程团队的负责人。

Udica 旨在克服前面描述的严格策略限制,通过为容器生成 SELinux 配置文件,使它们能够访问通常会被container_t域阻止的资源。

在 Fedora 上安装 Udica,只需运行以下命令:

$ sudo dnf install -y udica setools-console container-selinux

在其他发行版中,可以通过运行以下命令从源代码安装 Udica:

$ sudo dnf install -y setools-console git container-selinux
$ git clone 
$ cd udica && sudo python3 ./setup.py install

为了演示 Udica 的工作原理,我们将创建一个容器,它会写入主机的/var/log目录,该目录在容器创建时会被绑定挂载。默认情况下,具有container_t域的进程无法写入标记为var_log_t类型的目录。

以下脚本已在容器内执行,它是一个无限循环,写入由当前日期和计数器组成的日志行:

Chapter11/custom_logger/logger.sh

#!/bin/bash
set -euo pipefail
trap "echo Exited; exit;" SIGINT SIGTERM
# Run an endless loop writing a simple log entry with date
count=1
while true; do
echo "$(date +%y/%m/%d_%H:%M:%S) - Line #$count" | tee -a /var/log/custom.log
  count=$((count+1))
  sleep 2
done

上述脚本使用了set -euo pipefail选项,以便在发生错误时立即退出,并使用tee工具,将标准输出和/var/log/custom.log文件追加模式同时写入。count变量在每个循环周期递增。

这个容器的 Dockerfile 保持简洁——它只复制日志记录脚本并在容器启动时执行:

Chapter11/custom_logger/Dockerfile

FROM docker.io/library/fedora
# Copy the logger.sh script
COPY logger.sh /
# Exec the logger.sh script
CMD ["/logger.sh"]

重要提示

logger.sh脚本必须在构建前执行,以便在容器启动时正确启动。

容器镜像是以custom_logger为名称构建的:

# cd /Chapter11/custom_logger
# buildah build -t custom_logger .

现在,是时候测试容器并查看其行为了。/var/log目录以rw权限与容器的/var/log进行绑定挂载,而不改变其类型上下文。我们应该保持执行在前台,以便查看即时输出:

# podman run -v /var/log:/var/log:rw \
  --name custom_logger1 custom_logger
tee: /var/log/custom.log: Permission denied
22/01/08_09:09:33 - Custom log event #1

正如预期的那样,脚本未能写入目标文件。我们可以通过将目录类型上下文更改为container_file_t来修复此问题,但正如我们之前所学,这并不是一个好主意,因为它会阻止其他进程写入它们的日志。

另外,我们可以使用 Udica 为容器生成一个自定义的 SELinux 安全配置文件。在以下代码中,容器规格被导出到一个container.json文件,然后由 Udica 解析生成一个名为custom_logger的自定义配置文件:

# podman inspect custom_logger1 > container.json
# udica -j container.json custom_logger
Policy custom_logger created!
Please load these modules using: 
# semodule -i custom_logger.cil /usr/share/udica/templates/{base_container.cil,log_container.cil}
Restart the container with: "--security-opt label=type:custom_logger.process" parameter

一旦配置文件生成,Udica 会输出配置容器的指令。首先,我们需要使用semodule工具加载新的自定义策略。生成的文件位于/usr/share/udica/templates/base_container.cil/usr/share/udica/templates/log_container.cil,其规则会继承到自定义容器策略文件中。

让我们使用推荐的命令加载模块:

# semodule -i custom_logger.cil /usr/share/udica/templates/{base_container.cil,log_container.cil}

在加载 SELinux 模块后,我们准备好运行带有自定义custom_logger.process标签的容器,将其作为参数传递给 Podman 的--security-opt选项。其他容器选项保持不变,除了名称已更新为custom_logger2,以便与先前的实例区分开来:

# podman run -v /var/log:/var/log:rw \
  --name custom_logger2 \
  --security-opt label=type:custom_logger.process \
  custom_logger
22/01/08_09:05:19 - Line #1
22/01/08_09:05:21 - Line #2
22/01/08_09:05:23 - Line #3
22/01/08_09:05:25 - Line #5
[...Omitted output...]

这次,脚本成功地写入了/var/log/custom.log文件,这得益于使用 Udica 生成的自定义配置文件。

请注意,容器进程不是在container_t域中运行,而是使用新的custom_logger.process超集,该超集包含在默认规则之上添加的额外规则。

我们可以通过在主机上运行以下命令来确认这一点:

# ps auxZ | grep 'custom_logger.process'
unconfined_u:system_r:container_runtime_t:s0-s0:c0.c1023 root 26546 0.1  0.6 1365088 53768 pts/0 Sl+ 09:16   0:00 podman run -v /var/log:/var/log:rw --security-opt label=type:custom_logger.process custom_logger system_u:system_r:custom_logger.process:s0:c159,c258 root 26633 0.0  0.0 4180 3136 ? Ss 09:16   0:00 /bin/bash /logger.sh
system_u:system_r:custom_logger.process:s0:c159,c258 root 26881 0.0  0.0 2640 1104 ? S 09:18   0:00 sleep 2 

Udica 通过解析 JSON 规范文件并查找容器挂载点、端口和能力来创建自定义策略。我们来看一下从示例中生成的custom_logger.cil文件的内容:

(block custom_logger
    (blockinherit container)
    (allow process process ( capability ( chown dac_override fowner fsetid kill net_bind_service setfcap setgid setpcap setuid sys_chroot ))) 
    (blockinherit log_rw_container)

CIL 语言语法超出了本书的范围,但我们仍然可以注意到一些有趣的内容:

  • custom_logger配置文件由block语句定义。

  • allow规则启用了容器的默认能力。

  • 策略通过blockinherit语句继承了containerlog_rw_container模块。

生成的 CIL 文件继承了在可用的 Udica 模板中已定义的模块,每个模块都专注于特定的操作。在 Fedora 中,这些模板通过container-selinux包安装,并可在/usr/share/udica/templates/文件夹中找到:

# ls -1 /usr/share/udica/templates/
base_container.cil
config_container.cil
home_container.cil
log_container.cil
net_container.cil
tmp_container.cil
tty_container.cil
virt_container.cil
x_container.cil

可用的模板为常见场景提供了实现,例如访问日志目录或用户主目录,甚至是打开网络端口。在这些模板中,base_container.cil模板始终由所有 Udica 生成的策略包含,作为生成自定义策略所用的基础构建块。

根据从规范文件派生的容器行为,其他模板被包含进来。例如,策略继承了来自log_container.cil模板的log_rw_container模块,以便让自定义日志容器访问/var/log目录。

Udica 是一个非常好的工具,能够解决容器隔离问题,帮助管理员通过克服手动编写规则的复杂性来解决 SELinux 限制的使用场景。

生成的安全配置文件也可以在 GitHub 仓库中进行版本控制,并在不同主机上的类似容器之间复用。

总结

在本章中,我们学习了如何开发和应用技术来提高基于容器的服务架构的整体安全性。我们了解了如何通过利用无根容器和避免 UID 0 来减少服务的攻击面。然后,我们学习了如何对容器镜像进行签名和信任,以避免中间人攻击。最后,我们深入探讨了容器工具的内部工作,查看了 Linux 内核的能力和 SELinux 子系统,这些可以帮助我们精细调整运行容器的各种安全方面。

经过深入的安全性分析后,我们已经准备好进入下一个章节,在那里我们将对容器的网络进行高级探讨。

进一步阅读

若想了解更多本章涉及的主题,请查看以下资源:

第十二章:实现容器网络概念

容器网络隔离利用网络命名空间为每个容器提供独立的网络栈。如果没有容器运行时,跨多个命名空间管理网络接口会变得非常复杂。Podman 提供了灵活的网络管理,允许用户自定义容器如何与外部容器以及同一主机内部的其他容器进行通信。

在本章中,我们将学习管理容器网络的常见配置实践,以及无根容器和有根容器之间的区别。

在本章中,我们将涵盖以下主要话题:

  • 容器网络与 Podman 配置

  • 互联两个或更多容器

  • 将容器暴露到我们底层主机之外

  • 无根容器网络行为

技术要求

要完成本章内容,你需要一台已安装并能正常运行 Podman 的机器。正如我们在第三章《运行第一个容器》中提到的,书中的所有示例都可以在 Fedora 34 或更高版本的系统上执行,但也可以在你选择的操作系统OS)上重现。本章中的示例将涉及 Podman v3.4.z 和 Podman v4.0.0,因为它们提供了不同的网络实现。

对于我们在第四章《管理运行中的容器》、第五章《实现容器数据存储》和第九章《推送镜像到容器注册表》中所覆盖的内容有良好的理解,将帮助你掌握我们将在本章中讨论的容器网络话题。

你还需要具备对基础网络概念的良好理解,以便理解路由、IP 协议、DNS 和防火墙等话题。

容器网络与 Podman 配置

在本节中,我们将讨论 Podman 的网络实现以及如何配置网络。Podman 4.0.0 对网络栈进行了重要更改。然而,Podman 3 在社区中仍然广泛使用。因此,我们将同时介绍这两种实现。

Podman 3 利用容器网络接口CNI)来管理主机上创建的本地网络。CNI 提供了一组标准的规范和库,用于在容器环境中创建和配置基于插件的网络接口。

CNI 规范是为 Kubernetes 创建的,提供了一种网络配置格式,容器运行时使用该格式来设置定义的插件,并且插件二进制文件与运行时之间有一个执行协议。这种基于插件的方法的一个重要优点是,供应商和社区可以开发符合 CNI 规范的第三方插件。

Podman 4 的网络堆栈基于一个全新的项目 Netavark,这是一个完全用 Rust 编写的容器原生网络实现,旨在与 Podman 配合使用。Rust 是一种非常适合开发系统和网络组件的编程语言,因其高效的内存管理和高性能,类似于 C 语言。Netavark 提供了对双栈网络(IPv4/IPv6)和容器间 DNS 解析的更好支持,并与 Podman 项目的开发路线图更加紧密地结合。

重要提示

从 Podman 3 升级到 Podman 4 的用户将继续默认使用 CNI 并保留他们之前的配置。新的 Podman 4 安装将默认使用 Netavark。用户可以通过升级 /usr/share/containers/containers.conf 文件中的 network_backend 字段,恢复使用 CNI 网络后端。

在下一小节中,我们将重点介绍 Podman 3 用来编排容器网络的 CNI 配置。

CNI 配置快速入门

一个典型的 CNI 配置文件定义了插件列表及其相关配置。以下示例展示了在 Fedora 上新安装的 Podman 的默认 CNI 配置:

Chapter12/podman_cni_conf.json

  "cniVersion": "0.4.0",
  "name": "podman",
  "plugins": [
    {
      "type": "bridge",
      "bridge": "cni-podman0",
      "isGateway": true,
      "ipMasq": true,
      "hairpinMode": true,
      "ipam": {
        "type": "host-local",
        "routes": [{ "dst": "0.0.0.0/0" }],
        "ranges": [
          [
            {
              "subnet": "10.88.0.0/16",
              "gateway": "10.88.0.1"
            }
          ]
        ]
      }
    },
    {
      "type": "portmap",
      "capabilities": {
        "portMappings": true
      }
    },
    {
      "type": "firewall"
    },
    {
      "type": "tuning"
    }
  ]
}

正如我们所见,这个文件中的 plugins 列表包含了一组由运行时用来编排容器网络的插件。

CNI 社区维护着一个参考插件库,这些插件可以被容器运行时使用。CNI 参考插件分为 接口创建IP 地址管理IPAM)和 Meta 插件。接口创建插件可以使用 IPAM 和 Meta 插件。

以下是一个非详尽的列表,描述了最常用的接口创建插件:

  • bridge:这个插件在主机上为网络创建一个专用的 Linux 桥接。容器接口连接到管理的桥接上,以便相互通信并与外部系统进行通信。这个插件目前被 Podman 和 podman network CLI 工具支持,是 Podman 安装时或创建新网络时配置的默认接口创建插件。

  • ipvlan:这个插件允许你将一个 IPVLAN 接口连接到容器。IPVLAN 解决方案是传统 Linux 桥接网络方案的一种替代方法,其中一个父接口在多个子接口之间共享,每个子接口都有一个 IP 地址。这个插件目前被 Podman 支持,但如果需要,你仍然可以手动创建和编辑 CNI 配置文件。

  • macvlan:这个插件允许配置 MACVLAN,它是一种类似于 IPVLAN 的方法,主要区别在于:在这种配置中,每个容器的子接口都会获取一个 MAC 地址。这个插件目前被 Podman 和 podman network CLI 工具支持。

  • host-device:此插件允许您直接将现有接口传递到容器中。目前 Podman 不支持此功能。

CNI IPAM 插件与容器内的 IP 地址管理相关联。只有三个参考 IPAM 插件:

  • dhcp:此插件允许您在主机上执行一个守护程序,管理运行容器的 dhcp 租约。这还意味着主机网络上已经运行了一个 dhcp 服务器。

  • host-local:此插件用于使用定义的地址范围为容器分配 IP 地址。分配的数据存储在主机文件系统中。它非常适合本地容器执行,并且是 Podman 在网络桥中使用的默认 IPAM 插件。

  • static:这是一个基本插件,管理分配给容器的静态地址列表。

NI Meta 插件用于配置主机中的特定行为,例如调整、防火墙规则和端口映射,并作为与创建接口插件一起链式执行的元插件。当前在参考插件仓库中维护的 Meta 插件如下:

  • portmap:此插件用于管理容器与主机之间的端口映射。它使用主机防火墙(iptables)应用配置,并负责创建 Source NAT (SNAT) 和 Destination NAT (DNAT) 规则。在 Podman 中,默认情况下启用此功能。

  • firewall:此插件配置防火墙规则以允许容器的入站和出站流量。在 Podman 中,默认情况下启用此功能。

  • tuning:此插件用于自定义系统调整(使用 sysctl 参数)和网络命名空间中的接口属性。在 Podman 中,默认情况下启用此功能。

  • bandwidth:此插件可用于使用 Linux 交通控制子系统在容器上配置流量速率限制。

  • sbr:此插件用于配置 /usr/libexec/cni 文件夹,并由 containernetworking-plugins 包提供,安装为 Podman 依赖项。

回到 CNI 配置示例,我们可以看到默认的 Podman 配置使用 bridge 插件和 host-local IP 地址管理,并且 portmaptuningfirewall 插件与之链接在一起。

在为 Podman 创建的默认网络中,用于容器网络的子网是 10.88.0.0/16,桥接器称为 cni-podman0,作为容器的默认网关在 10.88.0.1,这意味着容器的所有出站流量都会被定向到桥接器的接口。

重要说明

此配置仅适用于完整根容器。在本章后面,我们将了解到 Podman 为克服用户权限有限而使用了不同的网络方法来处理无根容器。我们将看到这种方法在主机接口和 IP 地址管理方面存在许多限制。

现在,让我们看看当创建一个新的 rootfull 容器时,主机上会发生什么。

Podman CNI 演练

在本小节中,我们将研究使用 CNI 作为网络后端时,创建新容器时发生的最特殊的网络事件。

重要提示

本小节中的所有示例均作为 root 用户执行。在查看网络接口和防火墙规则时,请确保清理掉现有的运行容器,以便能更清晰地看到相关配置。

我们将尝试使用 Nginx 容器,并将其默认的内部端口 80/tcp 映射到主机端口 8080/tcp

在我们开始之前,我们需要验证当前主机的 IP 配置:

# ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group
default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 52:54:00:a9:ce:df brd ff:ff:ff:ff:ff:ff
    altname enp0s5
    altname ens5
    inet 192.168.121.189/24 brd 192.168.121.255 scope global dynamic noprefixroute eth0
       valid_lft 3054sec preferred_lft 3054sec
    inet6 fe80::2fb:9732:a0d9:ac70/64 scope link noprefixroute        valid_lft forever preferred_lft forever
3: cni-podman0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default qlen 1000
    link/ether de:52:45:ae:1a:7f brd ff:ff:ff:ff:ff:ff
    inet 10.88.0.1/16 brd 10.88.255.255 scope global cni-podman0
       valid_lft forever preferred_lft forever
    inet6 fe80::dc52:45ff:feae:1a7f/64 scope link 
       valid_lft forever preferred_lft forever

除了主机的主要接口 eth0,我们还可以看到一个名为 cni-podman0 的桥接接口,地址为 10.88.0.1/16。另外,注意到该桥接的状态被设置为 DOWN

重要

如果用于测试的主机是全新安装,且 Podman 从未执行过,则不会列出 cni-podman0 桥接接口。这并不是问题——它会在首次创建 rootfull 容器时被创建。

如果主机上没有其他容器在运行,我们应该看不到任何接口附加到虚拟桥接上。为了验证这一点,我们将使用 bridge link show 命令,期望其输出为空:

# bridge link show cni-podman0

查看防火墙规则时,我们不应在 filternat 表中看到与容器相关的规则:

# iptables -L
# iptables -L -t nat 

重要提示

前面命令的输出为了简洁起见已被省略,但值得注意的是,filter 表应已包含两个与 CNI 相关的链,分别是 CNI-ADMINCNI-FORWARD

最后,我们想检查 cni-podman0 接口的路由规则:

# ip route show dev cni-podman0 
10.88.0.0/16 proto kernel scope link src 10.88.0.1 linkdown

该命令表示,所有发送到 10.88.0.0/16 网络的流量都会经过 cni-podman0 接口。

让我们运行 Nginx 容器,看看网络接口、路由和防火墙配置会发生什么:

# podman run -d -p 8080:80 \
  --name net_example docker.io/library/nginx

第一个也是最有趣的事件是一个新的网络接口的创建,正如 ip addr show 命令的输出所示:

# ip addr show
[...omitted output...]
3: cni-podman0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether de:52:45:ae:1a:7f brd ff:ff:ff:ff:ff:ff
    inet 10.88.0.1/16 brd 10.88.255.255 scope global cni-podman0
       valid_lft forever preferred_lft forever
    inet6 fe80::dc52:45ff:feae:1a7f/64 scope link 
       valid_lft forever preferred_lft forever
5: vethcf8b2132@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master cni-podman0 state UP group default 
    link/ether b6:4c:1d:06:39:5a brd ff:ff:ff:ff:ff:ff link-netns cni-df380fb0-b8a6-4f39-0d19-99a0535c2f2d
    inet6 fe80::90e3:98ff:fe6a:acff/64 scope link 
       valid_lft forever preferred_lft forever

这个新接口是 man 4 veth(虚拟以太网设备的一部分),它们充当本地隧道。Veth 对是 Linux 内核的虚拟接口,不依赖于容器运行时,可以应用于超出容器执行的使用场景。

veth 对的有趣之处在于它们可以跨多个网络命名空间生成,并且发送到对端的一个包会立即被接收。

vethcf8b2132@if2 接口与一个位于名为 cni-df380fb0-b8a6-4f39-0d19-99a0535c2f2d 网络命名空间中的设备相连。由于 Linux 提供了使用 ip netns 命令检查网络命名空间的选项,我们可以检查该命名空间是否存在并检查其网络堆栈:

# ip netns
cni-df380fb0-b8a6-4f39-0d19-99a0535c2f2d (id: 0)

提示

当创建新的网络命名空间时,会在/var/run/netns/下创建一个同名的文件。这个文件也有与/proc/<PID>/ns/net下符号链接指向的相同 inode 编号。当打开该文件时,返回的文件描述符将允许访问该命名空间。

上述命令确认了网络命名空间存在。现在,我们想检查在其中定义的网络接口:

# ip netns exec cni-df380fb0-b8a6-4f39-0d19-99a0535c2f2d ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: eth0@if5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether fa:c9:6e:5c:db:ad brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.88.0.3/16 brd 10.88.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::f8c9:6eff:fe5c:dbad/64 scope link 
       valid_lft forever preferred_lft forever 

这里,我们执行了一个ip addr show命令,该命令嵌套在ip netns exec命令中。输出显示了我们 veth 对端的接口。这也给了我们一些有价值的信息:容器的 IPv4 地址,设置为10.88.0.3

提示

如果你感兴趣,当使用 Podman 的默认网络并且使用host-local IPAM 插件时,容器的 IP 配置会被保存在/var/lib/cni/networks/podman文件夹中。在这里,会创建一个以分配的 IP 地址命名的文件,并写入容器生成的 ID。

如果创建了新的网络并且容器使用了该网络,它的配置将保存在/var/lib/cni/networks/<NETWORK_NAME>文件夹中。

我们还可以检查容器的路由表:

# ip netns exec cni-df380fb0-b8a6-4f39-0d19-99a0535c2f2d ip route
default via 10.88.0.1 dev eth0 
10.88.0.0/16 dev eth0 proto kernel scope link src 10.88.0.3

所有指向外部网络的出站流量都将通过10.88.0.1地址,它已分配给cni-podman0桥接器。

当创建新的容器时,firewallportmapper CNI 插件会在主机的过滤器和 NAT 表中应用必要的规则。在以下代码中,我们可以看到已应用到容器 IP 地址的nat表规则,其中应用了 SNAT、DNAT 和伪装规则:

# iptables -L -t nat -n | grep -B4 10.88.0.3
Chain POSTROUTING (policy ACCEPT)
target     prot opt source               destination         
CNI-HOSTPORT-MASQ  all  --  0.0.0.0/0            0.0.0.0/0            /* CNI portfwd requiring masquerade */
CNI-fb51a7bfa5365a8a89e764fd  all  --  10.88.0.3            0.0.0.0/0            /* name: "podman" id: "a5054cca3436a7bc4dbf78fe4b901ceef0569ced24181d2e7b118232123a5f e3" */
--
Chain CNI-DN-fb51a7bfa5365a8a89e76 (1 references)
target     prot opt source               destination         
CNI-HOSTPORT-SETMARK  tcp  --  10.88.0.0/16         0.0.0.0/0            tcp dpt:8080
CNI-HOSTPORT-SETMARK  tcp  --  127.0.0.1            0.0.0.0/0            tcp dpt:8080
DNAT       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:8080 to:10.88.0.3:80

更粗的那行显示了一个名为CNI-DN-fb51a7bfa5365a8a89e76的自定义链中的 DNAT 规则。这个规则表示,所有目标是主机上的8080/tcp端口的 TCP 数据包应该被重定向到10.88.0.3:80端口,这是容器暴露的网络套接字。这个规则与我们在容器创建时传递的–p 8080:80选项相匹配。

那么,容器是如何与外界通信的呢?让我们再次检查cni-podman0桥接器,看看是否有显著的变化:

# bridge link show cni-podman0
5: vethcf8b2132@eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master cni-podman0 state forwarding priority 32 cost 2

上述接口连接到虚拟桥接器,该桥接器也恰好分配了一个 IP 地址(10.88.0.1),作为所有容器的默认网关。

让我们尝试追踪一个来自容器到著名主机1.1.1.1(Cloudflare 公共 DNS)的 ICMP 包的路径。为此,我们必须使用ip netns exec命令从容器网络命名空间中运行traceroute工具:

# ip netns exec cni-df380fb0-b8a6-4f39-0d19-99a0535c2f2d traceroute -I 1.1.1.1
traceroute to 1.1.1.1 (1.1.1.1), 30 hops max, 60 byte packets
1  _gateway (10.88.0.1)  0.071 ms  0.025 ms  0.003 ms
2  192.168.121.1 (192.168.121.1)  0.206 ms  0.195 ms  0.189 ms
3  192.168.1.1 (192.168.1.1)  5.326 ms  5.323 ms  5.319 ms
4  192.168.50.6 (192.168.50.6)  17.598 ms  17.595 ms  17.825 ms
5  192.168.50.5 (192.168.50.5)  17.821 ms  17.888 ms  17.882 ms
6  10.177.21.173 (10.177.21.173)  17.998 ms  17.772 ms  24.777 ms
7  185.210.48.42 (185.210.48.42)  25.963 ms  7.604 ms  7.702 ms
8  185.210.48.43 (185.210.48.43)  7.906 ms  10.344 ms  10.984 ms
9  185.210.48.77 (185.210.48.77)  12.212 ms  12.030 ms  12.983 ms
10  1.1.1.1 (1.1.1.1)  12.524 ms  12.160 ms  12.649 ms

重要提示

默认情况下,主机上可能已安装 traceroute 程序。要在 Fedora 上安装它,请运行sudo dnf install traceroute命令。

上述输出显示了一系列10.88.0.1,接着是主机的网络堆栈。

第二跳是主机的默认网关(192.168.121.1),它被分配给虚拟化主机中的虚拟桥接,并连接到我们实验室的主机虚拟机。

第三跳是分配给物理路由器的私有网络默认网关(192.168.1.1),该路由器连接到实验室的虚拟化主机网络。

这展示了所有流量都通过cni-podman0桥接接口。

我们可以创建多个网络,无论是使用 Podman 原生命令,还是通过我们喜欢的编辑器直接管理 JSON 文件。

现在我们已经探索了 CNI 的实现和配置细节,让我们来看看 Podman 4 中的新 Netavark 实现。

Netavark 配置快速入门

Podman 4.0.0 版本引入了 Netavark 作为默认的网络后端。Netavark 的优势如下:

  • 支持双栈 IPv4/IPv6

  • 支持使用aardvark-dns伴随项目进行 DNS 原生解析

  • 支持 rootless 容器

  • 支持不同的防火墙实现,包括 iptables、firewalld 和 nftables

Netavark 使用的配置文件与为 CNI 所示的文件没有太大区别。Netavark 仍然使用 JSON 格式来配置网络;文件存储在/etc/containers/networks路径下,适用于 rootfull 容器;对于 rootless 容器,存储路径为~/.local/share/containers/storage/networks

以下配置文件展示了一个在 Netavark 下创建和管理的示例网络:

[
     {
          "name": "netavark-example",
          "id": "d98700453f78ea2fdfe4a1f77eae9e121f3cbf4b6160dab89edf9ce23c b924d7",
          "driver": "bridge",
          "network_interface": "podman1",
          "created": "2022-02-17T21:37:59.873639361Z",
          "subnets": [
               {
                    "subnet": "10.89.4.0/24",
                    "gateway": "10.89.4.1"
               }
          ],
          "ipv6_enabled": false,
          "internal": false,
          "dns_enabled": true,
          "ipam_options": {
               "driver": "host-local"
          }
     }
]

第一个显著的元素是配置文件的体积比 CNI 配置文件更紧凑。以下字段被定义:

  • name:网络的名称。

  • id:唯一的网络 ID。

  • driver:指定正在使用的网络驱动程序类型。默认值是bridge。Netavark 还支持 MACVLAN 驱动程序。

  • network_interface:这是与网络相关联的网络接口的名称。如果配置的驱动程序是bridge,那么这将是 Linux 桥接的名称。在前面的示例中,创建了一个名为podman1的桥接。

  • created:网络创建的时间戳。

  • subnets:提供子网和网关对象的列表。子网是自动分配的。不过,当你使用 Podman 创建新网络时,用户可以提供自定义 CIDR。Netavark 允许你在网络上管理多个子网和网关。

  • ipv6_enabled:可以使用此布尔值启用或禁用 Netavark 的 IPv6 原生支持。

  • internal:这个布尔值用于配置仅供内部使用的网络,并阻止外部路由。

  • dns_enabled:这个布尔值启用网络的 DNS 解析,由aardvark-dns守护进程提供服务。

  • ipam_options:此对象定义了一系列ipam参数。在前面的示例中,唯一的选项是 IPAM 驱动程序的类型,host-local,它的行为类似于 CNI 的 host-local 插件。

默认的 Podman 4 网络,名为 podman,实现了桥接驱动程序(桥接的名称是 podman0)。在这里,DNS 支持被禁用,类似于默认 CNI 配置的情况。

Netavark 也是一个可执行二进制文件,默认安装在 /usr/libexec/podman/netavark 路径下。它具有简单的 setupteardown 命令,将网络配置应用到给定的网络命名空间(参见 man netavark)。

现在,让我们看看使用 Netavark 创建一个新容器的效果。

Podman Netavark 使用指南

和 CNI 一样,Netavark 管理容器网络命名空间和主机网络命名空间中的网络配置创建,包括创建 veth 对和配置文件中定义的 Linux 桥接。

在默认的 Podman 网络中创建第一个容器之前,不会创建任何桥接,主机接口是唯一可用的,另外还有环回接口:

# ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 52:54:00:9a:ea:f4 brd ff:ff:ff:ff:ff:ff
    altname enp0s5
    altname ens5
    inet 192.168.121.15/24 brd 192.168.121.255 scope global dynamic noprefixroute eth0
       valid_lft 3293sec preferred_lft 3293sec
    inet6 fe80::d0fb:c0d1:159e:2d54/64 scope link noprefixroute 
       valid_lft forever preferred_lft forever

让我们运行一个新的 Nginx 容器,看看会发生什么:

# podman run -d -p 8080:80 \
  --name nginx-netavark 
  docker.io/library/nginx

当容器启动时,podman0 桥接和一个 veth 接口会出现:

# ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 52:54:00:9a:ea:f4 brd ff:ff:ff:ff:ff:ff
    altname enp0s5
    altname ens5
    inet 192.168.121.15/24 brd 192.168.121.255 scope global dynamic noprefixroute eth0
       valid_lft 3140sec preferred_lft 3140sec
    inet6 fe80::d0fb:c0d1:159e:2d54/64 scope link noprefixroute 
       valid_lft forever preferred_lft forever
3: veth2772d0ea@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master podman0 state UP group default qlen 1000
    link/ether fa:a3:31:63:21:60 brd ff:ff:ff:ff:ff:ff link-netns netns-61a5f9f9-9dff-7488-3922-165cdc6cd320
    inet6 fe80::f8a3:31ff:fe63:2160/64 scope link 
       valid_lft forever preferred_lft forever
8: podman0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether ea:b4:9d:dd:2c:d1 brd ff:ff:ff:ff:ff:ff
    inet 10.88.0.1/16 brd 10.88.255.255 scope global podman0
       valid_lft forever preferred_lft forever
    inet6 fe80::24ec:30ff:fe1a:2ca8/64 scope link 
       valid_lft forever preferred_lft forever

在网络命名空间、版本管理之间的上下文混合、防火墙规则或路由方面,最终用户没有与之前提供的 CNI 使用指南相比发生特别的变化。

再次为 nginx-netavark 容器创建了一个主机上的网络命名空间。让我们检查网络命名空间的内容:

# ip netns exec netns-61a5f9f9-9dff-7488-3922-165cdc6cd320 ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: eth0@if3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000    link/ether ae:9b:7f:07:3f:16 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.88.0.4/16 brd 10.88.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::ac9b:7fff:fe07:3f16/64 scope link 
       valid_lft forever preferred_lft forever

再次强调,可以找到分配给容器的内部 IP 地址。

如果容器以 rootless 模式运行,桥接和 veth 对将会在一个 rootless 网络命名空间中创建。

重要提示

在 Podman 4 中,可以使用 podman unshare --rootless-netns 命令检查 rootless 网络命名空间。

使用 Podman 3 和 CNI 的用户可以使用 --rootless-cni 选项来获得相同的结果。

在下一个子节中,我们将学习如何使用 Podman 提供的 CLI 工具来管理和定制容器网络。

使用 Podman 管理网络

podman network 命令提供了管理容器网络所需的工具。以下是可用的子命令:

  • create:创建一个新的网络

  • connect:连接到指定的网络

  • disconnect:断开与网络的连接

  • exists:检查网络是否存在

  • inspect:转储网络的 CNI 配置

  • prune:删除未使用的网络

  • reload:重新加载容器防火墙规则

  • rm:删除指定的网络

在本节中,你将学习如何创建一个新的网络并将容器连接到它。对于 Podman 3,所有生成的 CNI 配置文件都会写入主机上的 /etc/cni/net.d 文件夹。

对于 Podman 4,所有生成的 rootfull 网络的 Netavark 配置文件都会写入 /etc/containers/networks,而 rootless 网络的配置文件则写入 ~/.local/share/containers/storage/networks

以下命令创建一个名为 example1 的新网络:

# podman network create \
  --driver bridge \
  --gateway "10.89.0.1" \
  --subnet "10.89.0.0/16" example1

在这里,我们提供了子网和网关信息,以及与 CNI 接口创建插件对应的驱动类型。生成的网络配置根据网络后端类型写入上述路径,并可通过podman network inspect命令进行检查。

以下输出显示了一个 CNI 网络后端的配置:

# podman network inspect example1
[
    {
        "cniVersion": "0.4.0",
        "name": "example1",
        "plugins": [
            {
                "bridge": "cni-podman1",
                "hairpinMode": true,
                "ipMasq": true,
                "ipam": {
                    "ranges": [
                        [
                            {
                                "gateway": "10.89.0.1",
                                "subnet": "10.89.0.0/16"
                            }
                        ]
                    ],
                    "routes": [
                        {
                            "dst": "0.0.0.0/0"
                        }
                    ],
                    "type": "host-local"
                },
                "isGateway": true,
                "type": "bridge"
            },
            {
                "capabilities": {
                    "portMappings": true
                },
                "type": "portmap"
            },
            {
                "backend": "",
                "type": "firewall"
            },
            {
                "type": "tuning"
            },
            {
                "capabilities": {
                    "aliases": true
                },
                "domainName": "dns.podman",
                "type": "dnsname"
            }
        ]
    }
]

新的网络 CNI 配置显示会为此网络创建一个名为cni-podman1的桥接,并且容器将从10.89.0.0/16子网中分配 IP 地址。

配置的其他字段与默认配置非常相似,除了dnsname插件(项目的仓库:github.com/containers/dnsname),它用于启用容器内的名称解析。这个功能在容器间通信中提供了优势,我们将在下一小节中介绍。

以下输出显示了为 Netavark 网络后端生成的配置:

# podman network inspect example1
[
     {
          "name": "example1",
          "id": "a8ca04a41ef303e3247097b86d9048750e5f1aa819ec573b0e5f78e3cc8a 971b",
          "driver": "bridge",
          "network_interface": "podman1",
          "created": "2022-02-18T17:56:28.451701452Z",
          "subnets": [
               {
                    "subnet": "10.89.0.0/16",
                    "gateway": "10.89.0.1"
               }
          ],
          "ipv6_enabled": false,
          "internal": false,
          "dns_enabled": true,
          "ipam_options": {
               "driver": "host-local"
          }
     }
]

请注意,Netavark 的桥接命名约定略有不同,因为它使用podmanN模式,N >= 0

要列出所有现有的网络,我们可以使用podman network ls命令:

# podman network ls
NETWORK ID   NAME     VERSION  PLUGINS
2f259bab93aa podman   0.4.0    bridge,portmap,firewall,tuning
228b48a56dbc example1 0.4.0    bridge,portmap,firewall,tuning,dnsname

上面的输出显示了每个活动网络的名称、ID、CNI 版本和活动插件。

在 Podman 4 中,输出稍微简洁一些,因为没有 CNI 插件要显示:

# podman network ls
NETWORK ID    NAME        DRIVER
a8ca04a41ef3  example1    bridge
2f259bab93aa  podman      bridge

现在,是时候启动一个连接到新网络的容器了。以下代码创建一个附加到example1网络的 PostgreSQL 数据库:

# podman run -d -p 5432:5432 \
  --network example1 \
  -e POSTGRES_PASSWORD=password \
  --name postgres \
  docker.io/library/postgres
533792e9522fc65371fa6d694526400a3a01f29e6de9b2024e84895f354e d2bb

新的容器从10.89.0.0/16子网接收了一个地址,如podman inspect命令所示:

# podman inspect postgres --format '{{.NetworkSettings.Networks.example1.IPAddress}}'
10.89.0.3

当我们使用 CNI 网络后端时,可以通过查看新的/var/lib/cni/networks/example1文件夹内容来再次检查这些信息:

# ls -al /var/lib/cni/networks/example1/
total 20
drwxr-xr-x. 2 root root 4096 Jan 23 17:26 .
drwxr-xr-x. 5 root root 4096 Jan 23 16:22 ..
-rw-r--r--. 1 root root   70 Jan 23 16:26 10.89.0.3
-rw-r--r--. 1 root root    9 Jan 23 16:57 last_reserved_ip.0
-rwxr-x---. 1 root root    0 Jan 23 16:22 lock

查看10.89.0.3文件的内容时,我们发现了以下内容:

# cat /var/lib/cni/networks/example1/10.89.0.3
533792e9522fc65371fa6d694526400a3a01f29e6de9b2024e84895f354 ed2bb

该文件保存了我们postgres容器的 ID,用于跟踪与分配的 IP 地址之间的映射。如前所述,这一行为由host-local插件管理,这是 Podman 网络的默认 IPAM 选择。

重要提示

Netavark 网络后端跟踪/run/containers/networks/ipam.db文件中的 IPAM 配置,适用于 rootfull 容器。

我们还可以看到,已创建了一个新的 Linux 桥接(注意使用cni-前缀,这用于 CNI 网络后端):

# ip addr show cni-podman1
8: cni-podman1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether 56:ed:1d:a9:53:54 brd ff:ff:ff:ff:ff:ff
    inet 10.89.0.1/16 brd 10.89.255.255 scope global cni-podman1
       valid_lft forever preferred_lft forever
    inet6 fe80::54ed:1dff:fea9:5354/64 scope link 
       valid_lft forever preferred_lft forever

新设备连接到了 PostgreSQL 容器的一个 veth 对端:

# bridge link show
10: vethf03ed735@eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master cni-podman1 state forwarding priority 32 cost 2 
20: veth23ee4990@eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master cni-podman0 state forwarding priority 32 cost 2

在这里,我们可以看到vethf03ed735@eth0连接到了cni-podman1桥接。该接口有如下配置:

# ip addr show vethf03ed735
10: vethf03ed735@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master cni-podman1 state UP group default 
    link/ether 86:d1:8c:c9:8c:2b brd ff:ff:ff:ff:ff:ff link-netns cni-77bfb1c0-af07-1170-4cc8-eb56d15511ac
    inet6 fe80::f889:17ff:fe83:4da2/64 scope link 
       valid_lft forever preferred_lft forever

上面的输出还显示了 veth 对端的另一端位于容器的网络命名空间中——即cni-77bfb1c0-af07-1170-4cc8-eb56d15511ac。我们可以检查容器的网络配置并确认从新子网分配的 IP 地址:

# ip netns exec cni-77bfb1c0-af07-1170-4cc8-eb56d15511ac ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: eth0@if10: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether ba:91:9e:77:30:a1 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.89.0.3/16 brd 10.89.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::b891:9eff:fe77:30a1/64 scope link 
       valid_lft forever preferred_lft forever

重要提示

在 Podman 4 中,Netavark 后端的网络命名空间命名模式是 netns-<UID>

可以在不停止和重启容器的情况下,将运行中的容器连接到另一个网络。通过这种方式,容器将保留一个连接到原始网络的接口,同时会创建一个连接到新网络的第二个接口。这个功能在反向代理等使用场景中非常有用,可以通过 podman network connect 命令实现。让我们尝试运行一个新的 net_example 容器:

# podman run -d -p 8080:80 --name net_example docker.io/library/nginx 
# podman network connect example1 net_example

为了验证容器是否已经连接到新网络,我们可以运行 podman inspect 命令并查看网络信息:

# podman inspect net_example
[...omitted output...]
            "Networks": {
                "example1": {
                    "EndpointID": "",
                    "Gateway": "10.89.0.1",
                    "IPAddress": "10.89.0.10",
                    "IPPrefixLen": 16,
                    "IPv6Gateway": "",
                    "GlobalIPv6Address": "",
                    "GlobalIPv6PrefixLen": 0,
                    "MacAddress": "fa:41:66:0a:25:45",
                    "NetworkID": "example1",
                    "DriverOpts": null,
                    "IPAMConfig": null,
                    "Links": null
                },
                "podman": {
                    "EndpointID": "",
                    "Gateway": "10.88.0.1",
                    "IPAddress": "10.88.0.7",
                    "IPPrefixLen": 16,
                    "IPv6Gateway": "",
                    "GlobalIPv6Address": "",
                    "GlobalIPv6PrefixLen": 0,
                    "MacAddress": "ba:cd:eb:8d:19:b5",
                    "NetworkID": "podman",
                    "DriverOpts": null,
                    "IPAMConfig": null,
                    "Links": null
                }
            }
[…omitted output...]

在这里,我们可以看到容器现在已经有两个接口,分别连接到 podmanexample1 网络,并且从每个网络的子网中分配了 IP 地址。

要将容器从网络中断开,我们可以使用 podman network disconnect 命令:

# podman network disconnect example1 net_example

当网络不再需要且已与运行中的容器断开连接时,我们可以使用 podman network rm 命令删除它:

# podman network rm example1
example1

命令的输出显示了被移除的网络列表。在这里,网络的 CNI 配置从主机的 /etc/cni/net.d 目录中移除。

重要提示

如果网络上有关联的容器,无论这些容器是正在运行还是已停止,前一个命令会因 Error: "example1" has associated containers with it 而失败。为了绕过这个问题,请在使用命令之前移除或断开这些关联容器。

podman network rm 命令在我们需要删除特定网络时非常有用。要删除所有未使用的网络,podman network prune 命令是一个更好的选择:

# podman network prune
WARNING! This will remove all networks not used by at least one container.
Are you sure you want to continue? [y/N] y
example2
db_network

在本节中,我们学习了 CNI 规范以及 Podman 如何利用其接口简化容器网络设置。在多层或微服务场景中,我们需要让容器之间能够通信。在下一节中,我们将学习如何管理容器之间的通信。

连接两个或更多容器

运用我们在上一节学到的知识,我们应该知道,两个或更多在同一网络中创建的容器,可以在同一子网上互相通信,而无需外部路由。

同时,属于不同网络的两个或更多容器将能够通过它们各自的网络路由数据包,互相到达不同的子网。

为了演示这一点,让我们在相同的默认网络中创建几个 busybox 容器:

# podman run -d --name endpoint1 \
  --cap-add=net_admin,net_raw busybox /bin/sleep 10000
# podman run -d --name endpoint2 \
  --cap-add=net_admin,net_raw busybox /bin/sleep 10000

在我们的实验环境中,两个容器的地址分别是 10.88.0.14 (endpoint1) 和 10.88.0.15 (endpoint2)。这两个地址可能会变化,可以使用前面介绍的 podman inspectnsenter 命令收集这些地址。

关于能力的定制,我们添加了 CAP_NET_ADMINCAP_NET_RAW 能力,以便容器能够无缝运行 pingtraceroute 等命令。

让我们尝试从 endpoint1endpoint2 运行 traceroute 命令,以查看数据包的路径:

# podman exec -it endpoint1 traceroute 10.88.0.14
traceroute to 10.88.0.14 (10.88.0.14), 30 hops max, 46 byte packets
1  10.88.0.14 (10.88.0.14)  0.013 ms  0.004 ms  0.002 ms

正如我们所见,数据包停留在内部网络并直接到达节点,没有额外的跳数。

现在,让我们创建一个新的网络 net1,并将一个名为 endpoint3 的容器连接到该网络:

# podman network create --driver bridge --gateway "10.90.0.1" --subnet "10.90.0.0/16" net1
# podman run -d --name endpoint3 --network=net1 --cap-add=net_admin,net_raw busybox /bin/sleep 10000

我们实验中的容器获得了一个 IP 地址 10.90.0.2。让我们看看从 endpoint1endpoint3 的网络路径:

# podman exec -it endpoint1 traceroute 10.90.0.2
traceroute to 10.90.0.2 (10.90.0.2), 30 hops max, 46 byte packets
1  host.containers.internal (10.88.0.1)  0.003 ms  0.001 ms  0.006 ms
2  10.90.0.2 (10.90.0.2)  0.001 ms  0.002 ms  0.002 ms

这一次,数据包穿越了 endpoint1 容器的默认网关(10.88.0.1)并到达了 endpoint3 容器,该数据包从主机路由到关联的 net1 Linux 桥接。

在同一主机上的容器之间的连接非常容易管理和理解。然而,我们仍然缺少一个重要方面,即容器间通信:DNS 解析。

让我们学习如何通过 Podman 网络来利用这一功能。

容器 DNS 解析

尽管 DNS 解析有许多配置细节,但它是一个非常简单的概念:一个服务会被查询以提供与给定主机名关联的 IP 地址。虽然 DNS 服务器可以提供的信息远比这丰富,但在这个示例中,我们将重点关注简单的 IP 解析。

例如,假设有一个场景,其中一个名为 webapp 的容器上的 Web 应用程序需要对另一个名为 db 的容器上的数据库进行读写访问。DNS 解析使得 webapp 能在联系 db 之前查询其 IP 地址。

之前,我们学习过 Podman 的默认网络不提供 DNS 解析,而新创建的用户网络默认启用 DNS 解析。在 CNI 网络后端,dnsname 插件会自动配置一个 dnsmasq 服务,当容器连接到网络时,该服务会启动并提供 DNS 解析。在 Netavark 网络后端,DNS 解析由 aarvark-dns 提供。

为了测试此功能,我们将重新使用在 第十章 中展示的 students Web 应用程序,容器故障排除和监控,因为它提供了一个合适的客户端-服务器示例,包含一个最小的 REST 服务和基于 PostgreSQL 的数据库后端。

信息

源代码可以在本书的 GitHub 仓库中找到,网址是 github.com/PacktPublishing/Podman-for-DevOps/tree/main/Chapter10/students

在这个示例中,Web 应用程序通过 HTTP GET 打开一个查询 PostgreSQL 数据库的请求,结果会以 JSON 格式输出。为了演示,我们将在同一个网络上运行数据库和 Web 应用程序。

首先,我们必须创建 PostgreSQL 数据库 pod,同时提供一个通用的用户名和密码:

# podman run -d \
   --network net1 --name db \
   -e POSTGRES_USER=admin \
   -e POSTGRES_PASSWORD=password \
   -e POSTGRES_DB=students \
   postgres

接下来,我们必须将 students 文件夹中的 SQL 转储数据恢复到数据库中:

# cd Chapter10/students
# cat db.sql | podman exec -i db psql -U admin students

如果你之前没有在章节中构建过它,你需要构建students容器镜像并在主机上运行它:

# buildah build -t students .
# podman run -d \
   --network net1 \
   -p 8080:8080 \
   --name webapp \
   students \
   students -host db -port 5432 \
   -username admin -password

请注意命令中的高亮部分:students应用程序接受-host-port-username-password选项,用于自定义数据库的端点和凭证。

我们没有在主机字段中提供任何 IP 地址。相反,使用了 Postgres 容器名称db和默认的5432端口来标识数据库。

同时注意,db容器创建时没有进行任何端口映射:我们预计直接通过net1容器网络访问数据库,这两个容器都在该网络中创建。

让我们尝试调用students应用程序的 API,看看会发生什么:

# curl localhost:8080/students {"Id":10149,"FirstName":"Frank","MiddleName":"Vincent","LastName":"Zappa","Class":"3A","Course":"Composition"}

查询成功,这意味着应用程序成功地查询了数据库。那么,究竟是如何发生的呢?它是如何仅凭容器名称就解析出容器的 IP 地址的?在下一节中,我们将探讨在 CNI 和 Netavark 网络后端上的不同表现。

在 CNI 网络后端上的 DNS 解析

在带有 CNI 后端的 Podman 3 或 Podman 4 中,dnsname插件在net1网络中启用,并且会启动一个专门的dnsmasq服务,负责将容器名称解析为其分配的 IP 地址。我们先从查找容器的 IP 地址开始:

# podman inspect db --format '{{.NetworkSettings.Networks.net1.IPAddress}}'
10.90.0.2
# podman inspect webapp --format '{{.NetworkSettings.Networks.net1.IPAddress}}'
10.90.0.3

我们要查找系统中运行的dnsmasq进程:

# ps aux | grep dnsmasq
root        2703  0.0  0.0  26436  2384 ?        S    16:16   0:00 /usr/sbin/dnsmasq -u root --conf-file=/run/containers/cni/dnsname/net1/dnsmasq.conf
root        5577  0.0  0.0   6140   832 pts/0    S+   22:00   0:00 grep --color=auto dnsmasq

上面的输出显示了一个dnsmasq进程的实例,它的配置文件位于/run/containers/cni/dnsname/net1/目录下。我们来检查一下它的内容:

# ls -al /run/containers/cni/dnsname/net1/
total 12
drwx------. 2 root root 120 Jan 25 16:16 .
drwx------. 3 root root  60 Jan 25 16:16 ..
-rw-r--r--. 1 root root  30 Jan 25 16:28 addnhosts
-rwx------. 1 root root 356 Jan 25 16:16 dnsmasq.conf
-rwxr-x---. 1 root root   0 Jan 25 16:16 lock
-rw-r--r--. 1 root root   5 Jan 25 16:16 pidfile

/run/containers/cni/dnsname/net1/dnsmasq.conf定义了dnsmasq的配置:

# cat /run/containers/cni/dnsname/net1/dnsmasq.conf 
## WARNING: THIS IS AN AUTOGENERATED FILE
## AND SHOULD NOT BE EDITED MANUALLY AS IT
## LIKELY TO AUTOMATICALLY BE REPLACED.
strict-order
local=/dns.podman/
domain=dns.podman
expand-hosts
pid-file=/run/containers/cni/dnsname/net1/pidfile
except-interface=lo
bind-dynamic
no-hosts
interface=cni-podman1
addn-hosts=/run/containers/cni/dnsname/net1/addnhosts

该进程在cni-podman1接口(net1网络桥接,IP 地址为10.90.0.1)上监听,并且是dns.podman域的权威服务器。主机的记录保存在/run/containers/cni/dnsname/net1/addnhosts文件中,文件内容如下:

# cat /run/containers/cni/dnsname/net1/addnhosts 
10.90.0.2  db
10.90.0.3  webapp

net1网络中的容器尝试 DNS 解析时,它会使用/etc/resolv.conf文件来查找应该将查询指向哪个 DNS 服务器。webapp容器中的该文件内容如下:

# podman exec -it webapp cat /etc/resolv.conf
search dns.podman
nameserver 10.90.0.1

这表明容器与10.90.0.1地址(这也是容器的默认网关和cni-podman1桥接网络)进行了联系,以查询主机名解析。

搜索域允许进程查找db.dns.podman,并通过 DNS 服务正确解析。CNI 网络配置的搜索域可以通过编辑/etc/cni/net.d/下的相关配置文件来定制。net1配置中dnsname插件的默认配置如下:

{
         "type": "dnsname",
         "domainName": "dns.podman",
         "capabilities": {
            "aliases": true
         }
      }

当你更新 domainName 字段为新值时,变化不会立即生效。要重新生成更新后的 dnsmasq.conf,必须停止网络中的所有容器,以便 dnsname 插件清理当前的网络配置。当容器重新启动时,dnsmasq 配置将相应地重新生成。

在 Netavark 网络后端上进行 DNS 解析

如果前面的示例在使用 Netavark 网络后端的 Podman 4 上执行,aardvark-dns 守护进程将负责类似于 dnsmasq 的容器解析。

aardvark-dns 项目是 Netavark 的一个伴生项目,使用 Rust 编写。它是一个轻量级的权威 DNS 服务,可以同时支持 IPv4 A 记录和 IPv6 AAAA 记录。

当创建一个启用 DNS 解析的新网络时,系统将创建一个新的 aardvark-dns 进程,如以下代码所示:

# ps aux | grep aardvark-dns
root        9115  0.0  0.0 344732  2584 pts/0    Sl   20:15   0:00 /usr/libexec/podman/aardvark-dns --config /run/containers/networks/aardvark-dns -p 53 run
root       10831  0.0  0.0   6400  2044 pts/0    S+   23:36   0:00 grep --color=auto aardvark-dns

该进程在主机网络命名空间的 53/udp 端口上监听根容器的请求,在 rootless 网络命名空间的 53/udp 端口上监听无根容器的请求。

ps 命令的输出还显示了默认配置路径——/run/containers/networks/aardvark-dns 目录——这是 aardvark-dns 进程存储解析配置的地方,配置文件按相关网络命名。例如,对于 net1 网络,我们将找到类似以下内容:

# cat /run/containers/networks/aardvark-dns/net1
10.90.0.1
dc7fff2ef78e99a2a1a3ea6e29bfb961fc07cd6cf71200d50761e25df30 11636 10.90.0.2  db,dc7fff2ef78e
10c7bbb7006c9b253f9ebe1103234a9af41dced8f12a6d94b7fc46a9a97 5d8cc 10.90.0.2  webapp,10c7bbb7006c

该文件存储了每个容器的 IPv4 地址(如果有 IPv6 地址,也会存储)。在这里,我们可以看到容器的名称和短 ID 被解析为 IPv4 地址。

第一行告诉我们 aardvark-dns 正在监听传入请求的地址。它再次对应于网络的默认网关地址。

在同一网络中连接容器可以实现不同服务之间的快速和简单通信,尤其是在不同的网络命名空间中运行的服务。然而,也有一些使用案例要求容器共享相同的网络命名空间。Podman 提供了一种解决方案,轻松实现这一目标:Pod。

在 Pod 内部运行容器

Pod 的概念来源于 Kubernetes 架构。根据官方上游文档,“一个 Pod ... 是一个包含一个或多个容器的组,共享存储和网络资源,并有一个运行容器的规范。”

Pod 也是 Kubernetes 调度中最小的可部署单元。Pod 中的所有容器共享相同的网络、UTC、IPC 和(可选的)PID 命名空间。这意味着,在不同容器上运行的所有服务可以彼此通过 localhost 进行引用,而外部容器则继续联系 Pod 的 IP 地址。一个 Pod 接收一个 IP 地址,这个地址在所有容器之间共享。

有许多使用案例,其中一个非常常见的是边车容器:在这种情况下,一个反向代理或 OAuth 代理与主容器一起运行,以提供身份验证或服务网格功能。

Podman 提供了用于操作 Pod 的基本工具,通过 podman pod 命令。以下示例展示了如何创建一个包含两个容器的基本 Pod,并演示了 Pod 内容器之间共享网络命名空间的情况。

重要提示

为了理解以下示例,请停止并移除所有正在运行的容器和 Pod,然后从一个干净的环境开始。

podman pod create 从头开始初始化一个新的空 Pod:

# podman pod create --name example_pod

重要提示

当创建一个新的空 Pod 时,Podman 还会创建一个 infra 容器,用于在启动 Pod 时初始化命名空间。此容器基于 Podman 3 的 k8s.gcr.io/pause 镜像,对于 Podman 4 则基于本地构建的 podman-pause 镜像。

现在,我们可以在 Pod 中创建两个基本的 busybox 容器:

# podman create --name c1 --pod example_pod busybox sh -c 'sleep 10000'
# podman create --name c2 --pod example_pod busybox sh -c 'sleep 10000'

最后,我们可以使用 podman pod start 命令启动 Pod(及其关联的容器):

# podman pod start example_pod

这里,我们有一个正在运行的 Pod,其中包含两个容器(加一个 infra 容器)。要验证其状态,可以使用 podman pod ps 命令:

# podman pod ps
POD ID        NAME         STATUS      CREATED        INFRA ID      # OF CONTAINERS
8f89f37b8f3b  example_pod  Degraded    8 minutes ago  95589171284a  4

使用 podman pod top 命令,我们可以看到 Pod 中每个容器所消耗的资源:

# podman pod top example_pod
USER        PID         PPID        %CPU        ELAPSED          TTY         TIME        COMMAND
root        1           0           0.000       10.576973703s  ?           0s          sleep 1000 
0           1           0           0.000       10.577293395s  ?           0s          /catatonit -P 
root        1           0           0.000       9.577587032s   ?           0s          sleep 1000 

创建 Pod 后,我们可以检查网络的行为。首先,我们会看到系统中只创建了一个网络命名空间:

# ip netns
netns-17b9bb67-5ce6-d533-ecf0-9d7f339e6ebd (id: 0)

让我们检查一下此命名空间及其相关网络堆栈的 IP 配置:

# ip netns exec netns-17b9bb67-5ce6-d533-ecf0-9d7f339e6ebd ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host 
       valid_lft forever preferred_lft forever
2: eth0@if15: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether a6:1b:bc:8e:65:1e brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet 10.88.0.3/16 brd 10.88.255.255 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::a41b:bcff:fe8e:651e/64 scope link 
       valid_lft forever preferred_lft forever

为了验证 c1c2 容器共享相同的网络命名空间,并且它们使用 IP 地址 10.88.0.3 运行,我们可以使用 podman exec 命令,在容器内运行相同的 ip addr show 命令:

# podman exec -it c1 ip addr show
# podman exec -it c2 ip addr show

这两个容器预计将返回与 netns-17b9bb67-5ce6-d533-ecf0-9d7f339e6ebd 网络命名空间相同的输出。

示例 Pod 可以通过 podman pod stoppodman pod rm 命令分别停止和移除:

# podman pod stop example_pod
# podman pod rm example_pod

我们将在第十四章与 systemd 和 Kubernetes 交互中更详细地讨论 Pod,届时我们还将讨论名称解析和多 Pod 编排。

在本节中,我们专注于在同一主机或 Pod 内,两个或更多容器之间的通信,而不管涉及的网络数量和类型。然而,容器是一个可以运行通常由外部世界访问的服务的平台。因此,在下一节中,我们将探讨暴露容器到其主机外部的最佳实践,并使其服务能够被其他客户端/消费者访问。

将容器暴露到我们底层主机外部

在企业公司或社区项目中采用容器可能是一件困难的事情,需要时间。因此,在我们的采用过程中,可能并没有所有所需的服务都作为容器运行。这就是为什么将容器暴露到我们的底层主机外部,可能是一个不错的解决方案,能够将容器中的服务与传统世界中的服务互联。

正如我们在本章稍早的部分简要看到的,Podman 使用两种不同的网络堆栈,具体取决于容器:无根或有根。

尽管底层机制略有不同,具体取决于你使用的是无根容器还是有根容器,Podman 用于暴露网络端口的命令行选项对这两种容器类型是相同的。

好知道

请注意,我们在本节中将要看到的示例将作为根用户执行。这是必要的,因为本节的主要目的是向你展示一些可能在暴露容器服务到外部时必需的防火墙配置。

暴露容器从端口发布活动开始。我们将在下一节学习这是什么。

端口发布

端口发布包括指示 Podman 在容器端口和某些随机或自定义主机端口之间创建临时映射。

指示 Podman 发布端口的选项非常简单——它包括将-p--publish选项添加到run命令中。让我们看看它是如何工作的:

-p=ip:hostPort:containerPort

之前的选项将容器的端口或端口范围发布到主机。当我们为hostPortcontainerPort指定范围时,两个范围中的数字必须相等。

我们甚至可以省略ip。在这种情况下,端口将绑定到底层主机的所有 IP。如果我们没有设置主机端口,容器的端口将随机分配一个主机端口。

让我们看一个端口发布选项的示例:

# podman run -dt -p 80:80/tcp docker.io/library/httpd
Trying to pull docker.io/library/httpd:latest...
Getting image source signatures
Copying blob 41c22baa66ec done  
Copying blob dcc4698797c8 done  
Copying blob d982c879c57e done  
Copying blob a2abf6c4d29d done  
Copying blob 67283bbdd4a0 done  
Copying config dabbfbe0c5 done  
Writing manifest to image destination
Storing signatures
ea23dbbeac2ea4cb6d215796e225c0e7c7cf2a979862838ef4299d410c90 ad44

如你所见,我们告诉 Podman 从httpd基础镜像启动一个容器。然后,我们分配了一个伪终端(-t),并在分离模式(-d)下设置端口映射,将底层主机的端口80绑定到容器的端口80

现在,我们可以使用podman port命令查看实际的映射:

# podman ps
CONTAINER ID  IMAGE                           COMMAND           CREATED        STATUS            PORTS               NAMES
ea23dbbeac2e  docker.io/library/httpd:latest  httpd-foreground  3 minutes ago  Up 3 minutes ago  0.0.0.0:80->80/tcp  ecstatic_chaplygin
# podman port ea23dbbeac2e
80/tcp -> 0.0.0.0:80

首先,我们请求了正在运行的容器列表,然后将正确的容器 ID 传递给podman port命令。我们可以这样检查映射是否正常工作:

# curl localhost:80
<html><body><h1>It works!</h1></body></html>

在这里,我们从主机系统执行了curl命令,并且成功了——容器中运行的httpd进程刚好回应了我们。

如果我们有多个端口,并且不关心它们在底层主机系统上的分配,我们可以轻松地利用–P--publish-all选项,将容器镜像暴露的所有端口发布到主机接口上的随机端口。Podman 会通过容器镜像的元数据查找暴露的端口。这些端口通常在 Dockerfile 或 Containerfile 中使用EXPOSE指令定义,如下所示:

EXPOSE 80/tcp
EXPOSE 80/udp

使用前面的关键字,我们可以指示容器引擎运行最终容器时暴露并使用哪些网络端口。

然而,我们可以利用一种简单但不安全的替代方法,如下一节所示。

附加主机网络

要将容器服务暴露给外界,我们可以将整个主机网络附加到运行中的容器。正如你所想,这种方法可能会导致未经授权使用主机资源,因此不建议使用,应该谨慎操作。

正如我们预期的那样,将主机网络附加到运行中的容器是相当简单的。通过使用正确的 Podman 选项,我们可以轻松地消除任何网络隔离:

# podman run --network=host -dt docker.io/library/httpd
2cb80369e53761601a41a4c004a485139de280c3738d1b7131c241f4001 f78a6

在这里,我们使用了--network选项并指定了host值。这告诉 Podman,我们希望让容器附加到主机网络。

在运行之前的命令后,我们可以检查运行中的容器是否已经绑定到主机系统的网络接口,因为它可以访问所有接口:

# netstat -nap|grep ::80
tcp6       0      0 :::80                   :::*                    LISTEN      37304/httpd
# curl localhost:80
<html><body><h1>It works!</h1></body></html>

在这里,我们从主机系统执行了一个curl命令,并且它成功了——容器中运行的httpd进程向我们做出了响应。

暴露容器到底层主机外部的过程并没有就此停止。在下一节中,我们将学习如何完成这项工作。

主机防火墙配置

无论我们选择利用端口发布(Port Publishing)还是将主机网络附加到容器,暴露容器到底层主机外部的过程并不会就此停止——我们已经达到了主机机器的基础操作系统。在大多数情况下,我们还需要允许传入的连接流入主机的底层机器,这将与系统防火墙进行交互。

以下示例展示了一种非详尽的方式来与基础操作系统防火墙进行交互。如果我们使用的是 Fedora 操作系统或其他任何使用 Firewalld 作为防火墙守护进程管理器的 Linux 发行版,我们可以通过运行以下命令来允许端口80上的传入连接:

# firewall-cmd --add-port=80/tcp
success
# firewall-cmd --runtime-to-permanent
success

第一个命令编辑了实时系统规则,而第二个命令则以永久方式存储运行时规则,这些规则在系统重启或服务重启后仍然有效。

知识点提醒

Firewalld 是一个防火墙服务守护进程,它为我们提供了一种简便而快速的方式来定制系统防火墙。Firewalld 是动态的,这意味着它可以在不重启防火墙守护进程的情况下创建、修改和删除防火墙规则。

正如我们所看到的,暴露容器服务的过程相当简单,但应在一定的意识和注意下进行:将网络端口开放给外部时,必须小心谨慎。

无根容器网络行为

正如我们在前面的章节中看到的,Podman 依赖于 CNI 插件或 Netavark 来运行作为 root 的容器,并且有权限修改主机网络命名空间中的网络配置。对于无根容器,Podman 使用 slirp4netns 项目,该项目允许您在不需要 root 权限的情况下创建容器网络配置;网络接口是在无根网络命名空间中创建的,标准用户在该命名空间中拥有足够的权限。这种方法允许您透明且灵活地管理无根容器的网络。

在前面的章节中,我们看到了如何通过 veth 对将容器网络命名空间连接到一个桥接网络。能够在主机网络命名空间中创建 veth 对需要 root 权限,而标准用户是不允许拥有这些权限的。

在最简单的场景下,slirp4netns 旨在通过允许创建一个连接到用户模式网络命名空间的 tap 设备来克服这些权限限制。这个 tap 设备是在无根(rootless)网络命名空间中创建的。

对于每一个新的无根容器,主机上都会执行一个新的 slirp4netns 进程。该进程为容器创建一个网络命名空间,并创建一个 tap0 设备,并将其配置为 10.0.2.100/24 地址(来自默认的 slirp4netns 10.0.2.0/24 子网)。这防止了两个容器在同一网络中直接通信,因为会存在 IP 地址重叠的问题。

以下示例演示了一个无根 busybox 容器的网络行为:

$ podman run -i busybox sh -c 'ip addr show tap0'
2: tap0: <BROADCAST,UP,LOWER_UP> mtu 65520 qdisc fq_codel state UNKNOWN group default qlen 1000
    link/ether 2a:c7:86:66:e9:20 brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.100/24 brd 10.0.2.255 scope global tap0
       valid_lft forever preferred_lft forever
    inet6 fd00::28c7:86ff:fe66:e920/64 scope global dynamic mngtmpaddr 
       valid_lft 86117sec preferred_lft 14117sec
    inet6 fe80::28c7:86ff:fe66:e920/64 scope link 
       valid_lft forever preferred_lft forever 

可以检查无根网络命名空间并找到相应的 tap0 设备:

$ podman unshare --rootless-netns ip addr show tap0
2: tap0: <BROADCAST,UP,LOWER_UP> mtu 65520 qdisc fq_codel state UNKNOWN group default qlen 1000
    link/ether 1a:eb:82:6a:82:8d brd ff:ff:ff:ff:ff:ff
    inet 10.0.2.100/24 brd 10.0.2.255 scope global tap0
       valid_lft forever preferred_lft forever
    inet6 fd00::18eb:82ff:fe6a:828d/64 scope global dynamic mngtmpaddr 
       valid_lft 86311sec preferred_lft 14311sec
    inet6 fe80::18eb:82ff:fe6a:828d/64 scope link 
       valid_lft forever preferred_lft forever

由于无根容器没有独立的 IP 地址,我们有两种方式让两个或多个容器相互通信:

  • 最简单的方法可能是将所有容器放在同一个 Pod 中,这样容器就可以通过 localhost 接口进行通信,无需打开任何端口。

  • 第二种方法是将容器附加到一个自定义网络,并让它的接口在无根网络命名空间中进行管理。

  • 如果我们希望保持所有容器独立,我们可以使用端口映射技术发布所有必要的端口,然后通过这些端口让容器之间进行通信。

使用 Podman 4 网络后端,让我们快速关注第二种场景,其中两个 Pod 附加到一个无根网络。首先,我们需要创建网络并附加几个测试容器:

$ podman network create rootless-net
$ podman run -d --net rootless-net --name endpoint1 --cap-add=net_admin,net_raw busybox /bin/sleep 10000
$ podman run -d --net rootless-net --name endpoint2 --cap-add=net_admin,net_raw busybox /bin/sleep 10000

让我们尝试从 endpoint1 容器 ping endpoint2 容器:

$ podman exec -it endpoint1 ping -c1 endpoint1
PING endpoint1 (10.89.1.2): 56 data bytes
64 bytes from 10.89.1.2: seq=0 ttl=64 time=0.023 ms 
--- endpoint1 ping statistics ---
1 packets transmitted, 1 packets received, 0% packet loss
round-trip min/avg/max = 0.023/0.023/0.023 ms

这两个容器可以在公共网络上通信,并拥有不同的 IPv4 地址。为了验证这一点,我们可以检查无根容器的 aardvark-dns 配置内容:

$ cat /run/user/1000/containers/networks/aardvark-dns/rootless-net 
10.89.1.1
fe27f8d653384fc191d5c580d18d874d480a7e8ef74c2626ae21b118eedb f1e6 10.89.1.2  endpoint1,fe27f8d65338
19a4307516ce1ece32ce58753e70da5e5abf9cf70feea7b981917ae399ef 934d 10.89.1.3  endpoint2,19a4307516ce

最后,让我们展示自定义网络如何绕过tap0接口,并允许在 rootless 网络命名空间中创建专用的 veth 对和桥接。以下命令将显示rootless-net网络的 Linux 桥接和两个附加的 veth 对:

$ podman unshare --rootless-netns ip link | grep 'podman'
3: podman2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
4: vethdca7cdc6@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master podman2 state UP mode DEFAULT group default qlen 1000
5: veth912bd229@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master podman2 state UP mode DEFAULT group default qlen 1000

重要提示

如果你在 CNI 网络后端运行此代码,请使用podman unshare –rootless-cni命令。

根 less 容器的另一个限制与ping命令有关。通常,在 Linux 发行版中,标准的非 root 用户缺乏CAP_NET_RAW安全功能。这会阻止ping命令的执行,因为它需要发送/接收 ICMP 数据包。如果我们希望在 rootless 容器中使用ping命令,可以通过sysctl命令启用缺失的安全功能:

# sysctl -w "net.ipv4.ping_group_range=0 2000000"

请注意,这可能会允许任何在这些组中由用户执行的进程发送 ping 数据包。

最后,在使用 rootless 容器时,我们还需要考虑到端口发布技术只能用于1024以上的端口。这是因为,在 Linux 操作系统中,所有1024以下的端口是特权端口,标准的非 root 用户无法使用。

总结

本章中,我们学习了如何利用容器网络隔离,通过网络命名空间为每个正在运行的容器提供网络隔离。这些操作看起来复杂,但幸运的是,在容器运行时的帮助下,这些步骤几乎是自动化的。我们学习了如何使用 Podman 管理容器网络,以及如何连接两个或更多容器。最后,我们学习了如何将容器的网络端口暴露到基础主机外部,以及在 rootless 容器网络中可能遇到的限制。

在下一章中,我们将发现 Docker 和 Podman 之间的主要区别。这对于高级用户非常有用,同时也有助于新手理解通过比较这两个容器引擎可以期待什么。

深入阅读

想了解更多本章中涉及的主题,请查阅以下资源:

第十三章:Docker 迁移技巧和窍门

每项技术都有一个开创性的公司、项目和产品,它一旦被创建并宣布,便成为了一个真正的变革者,使其基本概念得以传播。对于容器来说,这就是 Docker。

正如我们在第一章《容器技术简介》中所学到的,Docker 提供了一种新的方法和伟大的想法,利用现有技术并创造出全新的技术。几年后,它成为了最常用的容器技术。

但是,正如开源项目常见的情况一样,社区和企业开始寻找改进、全新的架构和不同的实现方式。正是在这里,Podman 找到了生长的空间,并利用了开放容器倡议OCI)所提供的标准化。

Docker 曾是(并且仍然是)最常用的容器技术。因此,在本章中,我们将提供一些关于迁移过程的技巧和窍门。我们将涵盖以下主题:

  • 迁移现有镜像并使用命令别名

  • Podman 命令与 Docker 命令的对比

  • 使用 Docker Compose 与 Podman

技术要求

在继续阅读本章的讲解和示例之前,您需要一台安装了 Podman 的机器。正如我们在第三章《运行第一个容器》中提到的,书中的所有示例都在 Fedora 34 系统或更高版本上执行,但可以在您选择的操作系统OS)上重现。

对于已经掌握了第四章《管理正在运行的容器》,第五章《实现容器数据存储》,以及第九章《推送镜像到容器注册表》中的内容,将帮助您理解本章将涉及的关于容器的概念。

迁移现有镜像并使用命令别名

Podman 有一个伟大的功能,可以让任何之前使用 Docker 的用户轻松适应并切换到它——与 Docker 的命令行界面CLI)完全兼容。

让我们通过为docker命令创建一个命令别名,来演示这种与 Docker 的 CLI 兼容性:

# alias docker=podman
# docker
Error: missing command 'podman COMMAND'
Try 'podman --help' for more information.

如您所见,我们创建了一个命令别名,将podman命令绑定到docker命令上。如果在设置别名后尝试执行docker命令,返回的输出将来自podman命令。

让我们通过运行一个容器来尝试一下新创建的别名:

# docker run --rm -it docker.io/wernight/funbox nyancat

我们应该会看到一些非常有趣的东西——一个正在运行的猫,类似于以下截图所示:

图 13.1 – 运行测试容器时的有趣输出

图 13.1 – 运行测试容器时的有趣输出

让我们测试一些更有趣的内容。例如,Docker 提供了一个基于容器镜像暴露 Web 服务器的教程:

# docker run -dp 80:80 docker.io/docker/getting-started
Trying to pull docker.io/docker/getting-started:latest...
Getting image source signatures
Copying blob 97518928ae5f done  
Copying blob e0bae2ade5ec done  
Copying blob a2402c2da473 done  
Copying blob e362c27513c3 done  
Copying blob a4e156412037 done  
Copying blob 3f3577460f48 done  
Copying blob 69465e074227 done  
Copying blob eb65930377cd done  
Copying config 26d80cd96d done  
Writing manifest to image destination
Storing signatures
d44a2df41d76b3322e56971d45e92e75f4679e8b620198228fbd9 cc00fe9578f

在这里,我们继续使用带有通过守护进程运行选项–d和绑定 HTTP 端口选项–pdocker别名命令。

如果一切正常工作,我们可以将我们最喜欢的网页浏览器指向http://localhost

图 13.2 – Docker 教程主页

图 13.2 – Docker 教程主页

Dockerlabs 的第一页,开始使用,指定了刚刚运行的命令。从页面的左列,我们可以继续进行教程。

让我们继续教程,并在每个阶段仔细检查别名是否正常工作。

教程步骤非常简单,能够帮助你总结上一章节所分享的知识,从构建容器到使用多个容器应用创建专用网络。请在使用 Docker Compose部分之前停下来,因为我们稍后会更详细地讨论这一部分内容。

别忘了,我们正在使用别名,而且在幕后,Podman 正在积极工作以确保我们的容器按预期运行,确保 Docker CLI 兼容性。

那么,在用 Podman 替代 Docker 的情况下,容器迁移如何处理呢?

目前,直接将现有容器从 Docker 迁移到 Podman 的方式并不存在。建议你使用相应的容器镜像重新创建容器,并使用 Podman 重新附加任何卷。

容器镜像可以使用docker export命令导出,该命令将创建一个 TAR 归档文件,可以通过podman import命令导入到 Podman 中。如果你正在使用容器镜像注册表,可以跳过此步骤。

为了了解在使用为 Docker 编写的命令、示例和资源时,可能遇到的限制,我们可以比较各种 Podman 和 Docker 命令。

Podman 命令与 Docker 命令的对比

正如我们在上一部分所看到的,以及在第二章中提到的,比较 Podman 与 Docker,Podman CLI 基于 Docker CLI。然而,由于 Podman 不需要运行时守护进程,因此一些 Docker 命令可能无法直接使用,或者可能需要一些变通方法。

命令列表非常长,因此以下表格仅列出了一部分:

如你所见,命令的名称与将docker命令与podman命令进行比较时相同。然而,尽管名称相同,由于 Podman 和 Docker 在架构上的差异,一些特性或行为可能会有所不同。

Podman 与 Docker 之间的行为差异

以下命令是 Podman 开发团队故意以另一种方式实现的:

  • podman volume create:如果卷已经存在,此命令将失败。在 Docker 中,此命令是幂等的,这意味着如果同名卷已经存在,Docker 会跳过此指令。Docker 的实际行为与其他命令的实现不一致。

  • podman run -v /tmp/noexist:/tmp:如果源卷路径不存在,此命令将失败。相反,Docker 会在路径不存在时创建该文件夹。Podman 开发团队认为这是一个 bug 并已进行了修复。

  • podman run --restart:Podman 中的重启选项在系统重启后不会持久化。如果需要,我们可以通过 podman generate systemdsystemd.unit 文件中运行 Podman。

在下一节中,我们将看到 Podman 中缺少的命令,它们在 Docker 中是存在的。

Podman 中缺失的命令

下表展示了一些 Docker 命令的非全面列表,这些命令在撰写本文时在 Podman 中没有对应的命令:

现在,让我们看看 Docker 中缺少的命令。

Docker 中缺失的命令

类似于 Podman 缺少一些 Docker 命令,Docker 也缺少一些 Podman 命令。

Podman 中以下命令类别在 Docker 中没有对应命令:

  • podman container:此命令可用于管理容器。

  • podman generate:此命令可用于为容器、pod 或卷生成结构化输出(例如 YAML 文件)。

  • podman healthcheck:此命令提供了一组子命令,您可以使用它们来管理容器健康检查。

  • podman image:此命令可用于管理容器镜像。

  • podman init:此命令可用于初始化容器,完成所有必要的步骤,但不启动容器。

  • podman machine:此命令列出一组子命令,用于管理 macOS 上 Podman 的虚拟机。

  • podman mount:此命令将容器的根文件系统挂载到主机可以访问的某个位置。

  • podman network exists/prune/reload:此命令检查并管理容器网络的状态。

  • podman play:此命令根据结构化文件(例如 YAML 文件)输入创建容器、pod 或卷。

  • podman pod:此命令提供了一组子命令,用于管理 pod 或容器组。

  • podman system:此命令提供了一组子命令,用于管理 Podman 系统并检索信息。

  • podman unmount:此命令卸载工作容器的根文件系统。

  • podman unshare:此命令将在新的用户命名空间中启动进程(无根容器)。

  • podman untag:此命令用于移除一个或多个存储的镜像。

  • podman volume exists:此命令检查卷是否存在。

当然,如果缺少某个命令,这并不意味着 Docker 中缺少该功能。

Docker 中另一个有用的功能是 Compose。我们将在下一节中学习如何在 Podman 中使用它。

使用 Docker Compose 与 Podman

Docker 首次发布时,由于其直观的容器管理方式,迅速获得了共识。除了主要的容器引擎解决方案外,还引入了另一个伟大的功能,帮助用户在单一主机上编排多个容器:Docker Compose

Compose 的理念非常简单——它是一个用于编排多容器应用程序的工具,这些应用程序应该在单一主机上相互交互,并使用 YAML 格式的声明性文件进行配置。所有在 Compose 堆栈中执行的应用程序都定义为服务,这些服务可以通过透明的名称解析与堆栈中的其他容器进行通信。

配置文件名为 docker-compose.yaml,其语法简单,可以创建和启动一个或多个 服务 和相关的

开发团队可以利用该技术栈的自动化功能,在单一主机上快速测试应用程序。然而,如果我们需要在类似生产的多节点环境中运行应用程序,最佳做法是采用集群编排解决方案,如 Kubernetes。

当 Podman 首次发布时,其主要目的是实现 OCI 完全兼容性,并与 Docker CLI 命令达到功能对等,以成为一个无守护进程的有效替代方案,代替了它所启发的著名容器引擎。不幸的是,最初的两个主要版本并不支持该 Compose 兼容性。随后,podman-compose 项目被引入以弥补这一空白。该项目是一个独立的开发流,需要弥补 Podman 中缺乏原生 Compose 支持的问题。

在 Podman v3.0 中,终于引入了对 Docker Compose 的原生支持,使用户可以选择使用原始的 docker-compose 工具或新的 podman-compose 工具。

在本节中,我们学习了如何配置 Podman 使用 docker-compose 来编排多个容器,从而为从 Docker 迁移到 Podman 的用户提供完全兼容性。在下一小节中,我们将看一个使用 podman-compose 来实现无根容器编排的示例。

在深入了解如何设置 Podman 之前,让我们先看几个 Compose 文件的基本示例,以帮助理解它们是如何工作的。

Docker Compose 快速入门

Compose 文件可用于声明一个或多个在公共堆栈中执行的容器,还可定义自定义应用程序的构建指令。这种方法的优势在于,您可以完全自动化整个应用程序堆栈,包括前端、后端以及持久性服务(如数据库或内存缓存)。

重要提示

本节的目的是快速概述 Compose 文件,帮助您理解 Podman 如何处理它们。

要查看最新的 Compose 规范详细列表,请参考以下 URL:docs.docker.com/compose/compose-file/compose-file-v3/

更多的 Compose 示例可以在 Docker Awesome Compose 项目中找到,网址为github.com/docker/awesome-compose

以下是一个最小配置文件,定义了一个运行 Docker 注册表的单一容器:

Chapter13/registry/docker-compose.yaml

services:
  registry:
    ports:
      - "5000:5000"
    volumes:
      - registry_volume:/var/lib/registry
    image: docker.io/library/registry
volumes:
  registry_volume: {}

上面的例子可以看作是定义容器执行参数的一种更加结构化和声明化的方式。然而,Docker Compose 的真正价值在于其编排堆栈,这些堆栈由单个实例中的多个容器组成。

以下示例更加有趣,展示了一个 WordPress 应用的配置文件,该应用使用 MySQL 数据库作为后端:

Chapter13/wordpress/docker-compose.yaml

services:
  db:
    image: docker.io/library/mysql:latest
    command: '--default-authentication-plugin=mysql_native_password'
    volumes:
      - db_data:/var/lib/mysql
    restart: always
    environment:
      - MYSQL_ROOT_PASSWORD=wordpressroot
      - MYSQL_DATABASE=wordpress
      - MYSQL_USER=wordpress
      - MYSQL_PASSWORD=wordpress
    expose:
      - 3306
      - 33060
  wordpress:
    image: docker.io/library/wordpress:latest
    ports:
      - 8080:80
    restart: always
    environment:
      - WORDPRESS_DB_HOST=db
      - WORDPRESS_DB_USER=wordpress
      - WORDPRESS_DB_PASSWORD=wordpress
      - WORDPRESS_DB_NAME=wordpress
volumes:
  db_data:

在这里,我们可以看到两个主要的 YAML 对象——servicesvolumes。在services部分的代码中,我们有两个应用——dbwordpress。这些已被突出显示以便清晰展示。

services列表中,有一组配置值定义了容器的行为。具体如下:

  • image:容器使用的镜像。

  • command:传递给容器入口点的附加命令。

  • Volumes:要挂载到容器中的卷列表,以及它们的相关挂载点。除了新的专用卷外,主机中的现有目录还可以绑定挂载到容器的挂载点上。

  • restart:在发生错误时的容器重启选项。

  • expose:容器要暴露的端口列表。

  • ports:容器和主机之间的端口映射列表。

  • environment:要在容器中创建的环境变量列表。在这个例子中,WORDPRESS_DB_HOSTWORDPRESS_DB_USERWORDPRESS_DB_PASSWORDWORDPRESS_DB_NAME被注入到 WordPress 容器中,以提供连接数据库的参数。

与服务声明一起,我们有一个由 Compose 管理的卷列表。引擎可以在 Compose 过程中创建这些卷,或使用已标记为external的现有卷。

第三个也是最后一个例子是一个 Compose 文件,它构建了一个最小的 REST API 应用,该应用是用 Go 语言编写的,能够将数据写入并从 Redis 内存存储中检索数据:

Chapter13/golang-redis/docker-compose.yaml

services:
  web:
    build: 
      context: ./app
      labels: 
        - "com.example.description=Golang Redis App"
    ports: 
      - "8080:8080"
    environment:
      - REDIS_HOST=redis
    depends_on:
      - redis
  redis:
    image: docker.io/library/redis
    deploy:
      replicas: 1

在这个例子中,我们有一些值得注意的新元素:

  • 一个build对象,定义了要构建的镜像,并且还应用了自定义标签到构建中。

  • context键包含构建的路径。在这个例子中,./app文件夹包含所有源代码文件和用于构建镜像的 Dockerfile。

  • 一个labels列表,包含一组在构建过程中传递的标签,这些标签作为字符串传递。

  • 一个depends_on列表,指定了对于 Web 服务,其他被视为依赖的服务;在这种情况下是redis服务。

  • 一个environment列表,定义了 Web 应用程序使用的redis服务名称。

  • redis服务中的deploy对象,允许我们定义自定义配置参数,例如容器replicas的数量。

要使用 Docker 启动 Compose 应用程序,可以在compose文件所在的文件夹中运行以下命令:

$ docker-compose up

该命令会创建所有堆栈和相关的卷,并将输出打印到stdout

若要以分离模式运行,只需在命令中添加-d选项:

$ docker-compose up -d

以下命令构建必要的镜像并启动堆栈:

$ docker-compose up --build

或者,可以使用docker-compose build命令构建应用程序,而不启动它们。

要关闭前台运行的堆栈,只需按下Ctrl + C键盘组合。而要关闭分离模式下的应用程序,请运行以下命令:

$ docker-compose down

若要杀死一个无响应的容器,我们可以使用docker-compose kill命令:

$ docker-compose kill [SERVICE]

该命令支持通过-s SIGNAL选项传递多个信号。

现在我们已经覆盖了有关 Docker Compose 的基本概念,接下来让我们学习如何配置 Podman 以运行 Compose 文件。

配置 Podman 与 docker-compose 的交互

为了支持 Compose,Podman 需要通过本地 UNIX 套接字暴露其 REST API 服务。该服务支持 Docker 兼容的 API 和本地 Libpod API。

在 Fedora 发行版上,必须使用以下命令安装docker-compose(提供 Docker Compose 二进制文件)和podman-docker(为docker命令提供别名)软件包:

$ sudo dnf install docker-compose podman-docker

重要提示

docker-compose软件包在 Fedora 34 系统上安装时,安装的是 v1.28 版本(截至本文编写时),该版本是用 Python 编写的。最新版本 v2 完全用 Go 重写,并提供了显著的性能提升。它可以从 GitHub 发布页面下载:github.com/docker/compose/releases

安装完软件包后,我们可以启用并启动管理 UNIX 套接字服务的systemd单元:

$ sudo systemctl enable --now podman.socket

该命令启动一个监听/run/podman/podman.sock的套接字。

请注意,本地docker-compose命令默认会在/run/docker.sock路径下查找套接字文件。为此,podman-docker软件包会在该路径上创建一个符号链接,指向/run/podman/podman.sock,如下所示:

# ls -al /run/docker.sock
lrwxrwxrwx. 1 root root 23 Feb  3 21:54 /var/run/docker.sock -> /run/podman/podman.sock

Podman 暴露的 UNIX 套接字只能由具有 root 权限的进程访问。通过为系统中的所有用户打开文件访问权限或为自定义组允许自定义 ACLs,可以放宽安全限制。本章稍后将介绍如何使用podman-compose执行无 root 容器堆栈。

为了简便起见,在下一小节中,您将学习如何在 rootfull 模式下使用 Podman 运行docker-compose命令。

使用 Podman 和 docker-compose 运行 Compose 工作负载

为了帮助你学习如何操作 docker-compose 并在我们的主机上创建编排的多容器部署,我们将重用之前的 Go REST API 和 Redis 内存存储的示例。

我们已经检查了 docker-compose.yaml 文件,该文件构建了 Web 应用程序并部署了一个 Redis 容器实例。

让我们检查用于构建应用程序的 Dockerfile:

Chapter13/golang-redis/Dockerfile

FROM docker.io/library/golang AS builder
# Copy files for build
RUN mkdir -p /go/src/golang-redis
COPY go.mod main.go /go/src/golang-redis
# Set the working directory
WORKDIR /go/src/golang-redis
# Download dependencies
RUN go get -d -v ./...
# Install the package
RUN go build -v 
# Runtime image
FROM registry.access.redhat.com/ubi8/ubi-minimal:latest as bin
COPY --from=builder /go/src/golang-redis/golang-redis /usr/local/bin
COPY entrypoint.sh /
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]

在这里,我们可以看到 Go 应用程序在多阶段构建中被编译,并且 Go 二进制文件被复制到一个 UBI-Minimal 镜像中。

Web 前端是简化版的 – 它监听 8080/tcp 端口,并且仅实现了两个端点 – 一个 HTTP POST 方法和一个 HTTP GET 方法,用于允许客户端上传和检索包含用户姓名、电子邮件和 ID 的 JSON 对象。该 JSON 对象存储在 Redis 数据库中。

重要提示

如果你感兴趣,Go 服务器的源代码可以在 Chapter13/golang-redis/app/main.go 文件中找到。由于篇幅和可读性的原因,这本书中并没有展示。

要构建并运行应用程序,我们必须切换到项目目录并运行 docker-compose up 命令:

# cd Chapter13/golang-redis 
# docker-compose up --build -d
[...omitted output...]
Successfully tagged localhost/golang-redis_web:latest
6b330224010ed611baba11fc2d66b9e4cfc991312f5166b47b5fcd073 57c6325
Successfully built 6b330224010ed611baba11fc2d66b9e4cfc991312f5166b47b5fcd073 57c6325
Creating golang-redis_redis_1 ... done
Creating golang-redis_web_1   ... done

在这里,我们可以看到 docker-compose 创建了两个容器,它们的名称始终遵循 <project_name>_<service_name>_<instance_count> 的模式。

当服务部署中有多个副本时,实例数量会有所不同。

我们可以使用常规的 podman ps 命令检查正在运行的容器:

# podman ps 
CONTAINER ID  IMAGE                              COMMAND       CREATED         STATUS             PORTS                   NAMES
4a5421c9e7cd  docker.io/library/redis:latest     redis-server  20 seconds ago  Up 20 seconds ago                          golang-redis_redis_1
8a465d4724ab  localhost/golang-redis_web:latest                20 seconds ago  Up 20 seconds ago  0.0.0.0:8080->8080/tcp  golang-redis_web_1

更有趣的一点是,服务名称会自动被解析。

当创建 Compose 堆栈时,Podman 会创建一个新的网络,名称遵循 <project_name>_default 模式。

新的网络使用 dnsname 插件实例化一个 dnsmasq 进程,并将容器的 IP 地址解析为在服务名称之后创建的名称。

我们可以使用以下命令检查网络:

# podman network ls | grep golang-redis
49d5a3c3679c  golang-redis_default    0.4.0       bridge,portmap,firewall,tuning,dnsname

可以使用 ps 命令并通过 grep 过滤来找到 dnsmasq 服务:

# ps aux | grep dnsmasq | grep golang
root     2749495  0.0  0.0  26388  2416 ?        S    01:33   0:00 /usr/sbin/dnsmasq -u root --conf-file=/run/containers/cni/dnsname/golang-redis_default/dnsmasq.conf

/run/containers/cni/dnsname/golang-redis_default 目录保存了实例的配置。在 addnhosts 文件中,我们可以找到服务名称与分配的容器 IP 地址之间的映射:

# cat /run/containers/cni/dnsname/golang-redis_default/addnhosts
10.89.3.240     golang-redis_redis_1   4a5421c9e7cd   redis
10.89.3.241         golang-redis_web_1    4298ae9f29c5      web

这意味着容器内的进程可以通过标准的 DNS 查询解析服务名称。

当服务中有多个容器副本时,dnsmasq 提供的解析结果类似于 db 服务(例如),它将解析为与服务副本数量相同的不同 IP 地址。

让我们回到 docker-compose.yaml 文件。在 web 服务配置的环境部分,我们有以下变量:

    environment:
      - REDIS_HOST=redis

该变量被注入到正在运行的容器中,表示 redis 服务的名称。Go 应用程序使用它来创建连接字符串并初始化连接。当我们使用 DNS 解析的服务名称时,redis 服务的容器名称和 IP 地址对 Go 应用程序完全无关。

我们可以使用 docker-compose exec 命令来验证该变量是否正确注入到作为 web 服务运行的容器中:

# docker-compose exec web env
Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
TERM=xterm
container=oci
REDIS_HOST=redis
HOME=/root

env 命令输出容器中所有环境变量的完整列表。这样我们可以验证 REDIS_HOST 变量是否已正确创建。

重要提示

将连接字符串等配置存储为数据库中的常量并直接嵌入应用程序代码中是一个反模式,特别是对于现代云原生应用程序。正确的方法是确保应用程序逻辑与配置参数之间有严格的分离。

配置可以存储为环境变量或在运行时注入到运行应用程序的容器中的 config/secret 文件。

这些实践在 十二因素应用 模式规范中有明确定义,其 URL 可以在 进一步阅读 部分找到。

最后,我们可以通过发布几个 JSON 对象并使用 curl 命令检索其中一个来测试应用程序:

$ curl -X POST -d \
'{"name":"jim", "email":"jim@example.com", "id":"0001"}' \
localhost:8080
$ curl -X POST -d \
'{"name":"anna", "email":"anna@example.com", "id":"0002"}' \
localhost:8080

Web 容器成功写入 Redis 后端,我们可以通过运行 docker-compose logs 命令来查看这一点:

# docker-compose logs web
[...omitted output...]
2022/02/06 00:58:06 Storing data:  {"name":"jim","email":"jim@example.com","id":"0001"}
2022/02/06 00:58:10 Storing data:  {"name":"anna","email":"anna@example.com","id":"0002"}

上述命令捕获所有 web 服务后面的容器的日志。

最后,我们可以检索结果。Web 应用程序通过查看其 id 从 Redis 数据库中读取该对象:

$ curl -X GET -d '{"id": "0001"}' localhost:8080  
{"name":"jim","email":"jim@example.com","id":"0001"}

要关闭我们的应用程序,我们只需使用 docker-compose down 命令:

# docker-compose down

此命令销毁容器及其关联的资源,包括自定义网络,但不包括卷。要删除卷,必须在命令末尾添加 -v 选项。

docker-compose 工具是一个非常适合在单个主机上与 Podman 一起构建和部署的好伙伴。然而,在下一章中,我们将学习一些其他有用的解决方案,这些解决方案将帮助我们生成并执行 Kubernetes Pod 和 Service 资源,以及由 Systemd 单元执行的容器。在继续之前,让我们先了解一下替代工具 podman-compose,它支持无根容器。

使用 podman-compose

podman-compose 项目在 Podman 3.0 版本之前就开始了,旨在为需要使用 Compose 文件编排容器的用户提供兼容层。在这一小节中,我们将查看在 Fedora 上使用 podman-compose 的示例。

podman-compose 工具的 CLI 是用 Python 编写的。可以通过 dnf 安装该软件包,或者从相应的 GitHub 仓库获取最新版本(你可以在 进一步阅读 部分找到直接链接):

$ sudo dnf install -y podman-compose

或者,可以使用 Python 的包管理器pip3进行安装,它支持更广泛的操作系统和发行版:

$ pip3 install podman-compose

现在,我们可以通过podman-compose提供的无根权限方法运行与前面示例中相同的 Compose 堆栈。

以下是与docker-compose兼容的所有可用命令,以及它们的描述和由podman-compose help命令输出所做的一些较小更改:

  • help: 显示工具的帮助

  • version: 显示命令的版本

  • pull: 拉取堆栈镜像

  • push: 推送堆栈镜像

  • build: 构建堆栈镜像

  • up: 创建并启动整个堆栈或其中的一些服务

  • down: 拆除整个堆栈

  • ps: 显示运行容器的状态

  • run: 创建类似服务的容器,以运行一次性命令

  • exec: 在运行的容器中执行特定命令

  • start: 启动特定服务

  • stop: 停止特定服务

  • restart: 重启特定服务

  • logs: 显示服务的日志

以下命令从包含必要配置和docker-compose.yaml文件的目录创建堆栈:

$ podman-compose up

该命令的输出也与docker-compose提供的输出非常相似。

要关闭堆栈,只需运行以下命令:

$ podman-compose down

podman-compose项目仍未完全达到与docker-compose的功能对等。但是,这是一个非常有趣的项目,可以在未来独立演变以帮助实现一个支持 Podman 本地的实用程序。

总结

在本章中,我们学习了如何管理从 Docker 到 Podman 的完整迁移。

我们介绍了如何迁移镜像并创建命令别名,并检查了命令兼容性矩阵。在此,我们详细介绍了两种容器引擎(即 Docker 和 Podman)中特定命令的不同行为和不同的实现命令。

然后,我们通过说明原生 Podman 3.0 对docker-compose命令的支持以及podman-compose备选实用工具,学习了如何迁移 Docker Compose。

在本书的下一章,我们将学习如何通过生成自定义服务单元与 Systemd 进行交互,并将容器转换为在主机内自动启动的服务。然后,我们将探讨面向 Kubernetes 的编排,学习如何从运行的容器和 Pod 生成 Kubernetes 资源并在 Podman 或 Kubernetes 中运行它们。

进一步阅读

要了解本章涵盖的主题的更多信息,请查看以下资源:

第十四章:与 systemd 和 Kubernetes 交互

在之前的章节中,我们学习了如何初始化和管理容器,从简单的概念开始,逐步深入到更高级的内容。容器代表了最新 Linux 操作系统版本中应用开发的关键技术。因此,容器只是高级开发人员和系统管理员的起点。一旦这项技术在企业公司或技术项目中得到广泛应用,下一步将是将其与基础操作系统和系统编排平台进行集成。

在本章中,我们将涵盖以下主要内容:

  • 设置主机操作系统的先决条件

  • 创建 systemd 单元文件

  • 管理基于容器的 systemd 服务

  • 生成 Kubernetes YAML 资源

  • 在 Podman 中运行 Kubernetes 资源文件

  • 在 Kubernetes 中测试结果

技术要求

要完成本章,您需要一台已安装并正常运行 Podman 的机器。正如我们在第三章《运行第一个容器》中提到的,本书中的所有示例都是在 Fedora 34 或更高版本的系统上执行的,但可以在您选择的操作系统OS)上复现。

第四章《管理正在运行的容器》、第五章《为容器数据实现存储》以及第九章《推送镜像到容器注册表》中的内容有充分的理解,将有助于您掌握我们将在高级容器部分中讨论的内容。

您还应该对系统管理和 Kubernetes 容器编排有较好的理解。

对于与 Kubernetes 部分相关的示例,您需要 Podman 版本 4.0.0,因为版本 3.4.z 存在一个错误,该错误会阻止容器环境变量的创建(github.com/containers/podman/issues/12781)。这个错误在 v4.0.0 中已被修复,但在撰写本文时并未回溯到 Podman v3。

设置主机操作系统的先决条件

正如我们在第一章《容器技术简介》中看到的,容器诞生的初衷是帮助简化并创建可以在独立主机上分发的系统服务。

在接下来的章节中,我们将学习如何在容器中运行 MariaDB 和 GIT 服务,并像管理其他服务一样管理这些容器——也就是通过 Systemd 和 systemctl 命令。

首先,让我们介绍一下 systemd,它是 Linux 的系统和服务管理器,在启动时作为第一个进程运行(作为 PID 1),并充当 init 系统,启动和维护用户空间服务。一旦新的用户登录到主机系统,便会执行单独的实例以启动他们的服务。

systemd 守护进程启动服务,并通过一个名为单元的依赖系统来确保各个实体之间的优先级。共有 11 种不同类型的单元。

Fedora 34 及更高版本默认启用了并运行 systemd。我们可以使用以下命令检查它是否正常运行:

# systemctl is-system-running
running

在接下来的章节中,我们将处理service类型的系统单元文件。我们可以通过运行以下命令检查当前的单元文件:

# systemctl list-units --type=service | head
  UNIT                      LOAD   ACTIVE SUB     DESCRIPTION
  abrt-journal-core.service  loaded active running Creates ABRT problems from coredumpctl messages
  abrt-oops.service  loaded active running ABRT kernel log watcher
  abrt-xorg.service  loaded active running ABRT Xorg log watcher
  abrtd.service  loaded active running ABRT Automated Bug Reporting Tool

请注意

systemd 服务及其内部结构更为复杂,因此无法用几行总结。有关更多信息,请参阅相关的 Linux 手册。

在下一节中,我们将学习如何为操作系统上任何运行中的容器服务创建 systemd 单元文件。

创建 systemd 单元文件

我们系统上的单元文件定义了 systemd 如何启动和运行服务。

每个单元文件表示一个单独的组件,它是一个简单的文本文件,描述了其行为、需要先后运行的内容等。

单元文件存储在系统中的几个不同位置,systemd 按照以下顺序查找它们:

  1. /etc/systemd/system

  2. /run/systemd/system

  3. /usr/lib/systemd/system

位于较早目录中的单元文件会覆盖后面的单元文件。这使得我们可以在/etc目录中修改所需的配置文件,而将默认的配置文件保留在/usr目录中,例如。

但单元文件是什么样的呢?让我们来看看。

首先,我们可以通过询问 systemd 来获取默认单元文件的位置:

# systemctl status sshd
○ sshd.service - OpenSSH server daemon
     Loaded: loaded (/usr/lib/systemd/system/sshd.service; disabled; vendor preset: disabled)
     Active: inactive (dead)
       Docs: man:sshd(8)
             man:sshd_config(5)

在这里,我们执行了status命令,并传入sshd服务名称作为过滤条件。

在 systemd 的输出中,默认的单元文件路径可以通过以下示例命令来检查:

# cat /usr/lib/systemd/system/sshd.service

那 Podman 呢?实际上,Podman 通过其专用子命令使 systemd 集成更加简单:

# podman generate systemd -h
Generate systemd units.
Description:
  Generate systemd units for a pod or container.
  The generated units can later be controlled via systemctl(1).
Usage:
  podman generate systemd [options] {CONTAINER|POD}
...

podman generate systemd命令会输出一个文本文件,表示创建的单元文件。从帮助输出中我们可以看到,我们可以设置多个选项来调整设置。

我们应该始终保存生成的文件,并将其放在正确的路径上,如前面的输出中所描述的那样。我们将在下一节通过提供完整示例来进一步探索这个命令。

管理基于容器的 systemd 服务

在本节中,你将通过一个实际的例子学习如何使用podman generate systemd命令。我们将创建基于容器的两个系统服务来创建一个 GIT 仓库。

在这个例子中,我们将利用两个知名的开源项目:

  • Gitea:一个 GIT 仓库,还提供了一个漂亮的网页界面用于代码管理

  • MariaDB:SQL 数据库,用于存储 Gitea 服务生成的数据

让我们从例子开始。首先,我们需要为数据库用户生成一个密码:

# export MARIADB_PASSWORD=my-secret-pw
# podman secret create --env MARIADB_PASSWORD
53149b678d0dbd34fb56800cc

在这里,我们导出了包含秘密密码的环境变量,然后使用了一个我们之前没有介绍的有用的秘密管理命令:podman secret create。不幸的是,该命令以纯文本存储秘密,但对于我们的目的来说已经足够好。由于我们以 root 身份运行这些容器,这些秘密将以 root-only 权限存储在文件系统中。

我们可以使用以下命令检查秘密:

# podman secret ls
ID                         NAME              DRIVER      CREATED       UPDATED       
53149b678d0dbd34fb56800cc  MARIADB_PASSWORD  file        10 hours ago  10 hours ago  
# podman secret inspect 53149b678d0dbd34fb56800cc
[
    {
        "ID": "53149b678d0dbd34fb56800cc",
        "CreatedAt": "2022-02-16T00:54:21.01087091+01:00",
        "UpdatedAt": "2022-02-16T00:54:21.01087091+01:00",
        "Spec": {
            "Name": "MARIADB_PASSWORD",
            "Driver": {
                "Name": "file",
                "Options": {
                    "path": "/var/lib/containers/storage/secrets/filedriver"
                }
            }
        }
    }
]
# cat /var/lib/containers/storage/secrets/filedriver/secretsdata.json 
{
  "53149b678d0dbd34fb56800cc": "bXktc2VjcmV0LXB3"
}
# ls -l /var/lib/containers/storage/secrets/filedriver/secretsdata.json 
-rw-------. 1 root root 53 16 feb 00.54 /var/lib/containers/storage/secrets/filedriver/secretsdata.json

在这里,我们要求 Podman 列出并检查我们之前创建的秘密,并查看包含秘密的底层文件系统。

存储秘密的文件是 JSON 格式的文件,正如我们之前提到的,它是纯文本文件。该对字符串的第一个是秘密 ID,第二个字符串是 Base64 编码的值。如果我们尝试用BASE64算法解码,我们会看到它代表了我们刚刚添加的密码——即my-secret-pw

尽管密码是以纯文本存储的,但对于我们的示例来说足够好,因为我们正在使用 root 用户,而该文件存储只有 root 权限,正如我们从之前输出的最后一个命令中可以验证的那样。

现在,我们可以继续设置数据库容器。我们将从数据库设置开始,因为它是我们 GIT 服务器的依赖项。

我们必须在主机系统上创建一个本地文件夹,用于存储容器数据:

# mkdir -p /opt/var/lib/mariadb

我们还可以查看容器镜像的公共文档,找出正确的卷路径和启动容器时使用的各种环境变量:

# podman run -d --network host --name mariadb-service –v \
 /opt/var/lib/mariadb:/var/lib/mysql:Z –e \
 MARIADB_DATABASE=gitea -e MARIADB_USER=gitea –e \
 MARIADB_RANDOM_ROOT_PASSWORD=true \
--secret=MARIADB_PASSWORD,type=env docker.io/mariadb:latest
61ae055ef6512cb34c4b3fe1d8feafe6ec174a25547728873932f0649217 62d1

我们首先将容器作为独立实例运行并进行测试,以检查是否存在任何错误;然后,我们将其转变为系统服务。

在前面的 Podman 命令中,我们执行了以下操作:

  • 我们以分离模式运行了容器。

  • 我们为其指定了一个名称——即mariadb-service

  • 为了简化操作,我们暴露了主机网络;当然,我们也可以限制和过滤此连接。

  • 我们将存储卷映射到新创建的本地目录,同时指定了:Z选项,以正确分配 SELinux 标签。

  • 我们定义了容器进程在运行时使用的环境变量,并通过--secret选项提供了密码的秘密。

  • 我们使用了我们想要使用的容器镜像名称——即docker.io/mariadb:latest

我们还可以通过以下命令检查容器是否正在运行:

# podman ps
CONTAINER ID  IMAGE                             COMMAND     CREATED         STATUS             PORTS       NAMES
61ae055ef651  docker.io/library/mariadb:latest  mariadbd    56 seconds ago  Up 57 seconds ago              mariadb-service

现在,我们准备检查podman generate systemd命令的输出:

# podman generate systemd --name mariadb-service
...
[Unit]
Description=Podman container-mariadb-service.service
Documentation=man:podman-generate-systemd(1)
Wants=network-online.target
After=network-online.target
RequiresMountsFor=/run/containers/storage
[Service]
Environment=PODMAN_SYSTEMD_UNIT=%n
Restart=on-failure
TimeoutStopSec=70
ExecStart=/usr/bin/podman start mariadb-service
ExecStop=/usr/bin/podman stop -t 10 mariadb-service
ExecStopPost=/usr/bin/podman stop -t 10 mariadb-service
PIDFile=/run/containers/storage/overlay-containers/61ae055ef6512cb34c4b3fe1d8feafe6ec174a25547728873932f064921762d1/userdata/conmon.pid
Type=forking
[Install]
WantedBy=default.target

如你所见,输出已直接显示在控制台中。这里,我们使用了--name选项,指示 Podman 通过 systemd 管理容器并使用该名称。

Podman 生成了一个包含所有所需命令指令的单元文件,用于将容器集成到操作系统中。

[Unit]部分,我们可以看到它声明了该服务依赖于通过network-online.target单元连接的网络。它还声明了需要用于/run/containers/storage容器的存储挂载点。

[Service]部分,Podman 定义了描述如何启动和停止容器化服务的所有指令。

现在,让我们看看 GIT 服务。首先,我们将创建存储目录:

# mkdir -p /opt/var/lib/gitea/data

之后,我们可以查看项目文档,了解构建 Gitea 容器镜像所需的任何配置,并完成podman run命令:

# podman run -d --network host --name gitea-service \
-v /opt/var/lib/gitea/data:/data:Z \
docker.io/gitea/gitea:latest
ee96f8276038f750ee3b956cbf9d3700fe46e6e2bae93605a67e623717e 206dd

在前面的 Podman 命令中,我们做了以下操作:

  • 我们以分离模式运行了容器。

  • 我们为其指定了一个名称——即gitea-service

  • 为了简化操作,我们暴露了主机网络;当然,我们也可以限制并过滤这种连接。

  • 我们将存储卷映射到新创建的本地目录,并指定了:Z选项以正确分配 SELinux 标签。

最后,我们可以通过查看日志检查服务是否正常运行:

# podman logs gitea-service
Server listening on :: port 22.
Server listening on 0.0.0.0 port 22.
2022/02/16 00:01:55 cmd/web.go:102:runWeb() [I] Starting Gitea on PID: 12
...
2022/02/16 00:01:56 cmd/web.go:208:listen() [I] Listen: http://0.0.0.0:3000
2022/02/16 00:01:56 cmd/web.go:212:listen() [I] AppURL(ROOT_URL): http://localhost:3000/
2022/02/16 00:01:56 ...s/graceful/server.go:61:NewServer() [I] Starting new Web server: tcp:0.0.0.0:3000 on PID: 12

如我们所见,Gitea 服务正在3000端口上监听。让我们将网页浏览器指向http://localhost:3000,以使用所需的配置进行安装:

图 14.1 – Gitea 服务安装页面

图 14.1 – Gitea 服务安装页面

在前面的截图中,我们定义了数据库的类型、地址、用户名和密码,以完成安装。完成后,我们应该会被重定向到登录页面,如下所示:

图 14.2 – Gitea 服务登录页面

图 14.2 – Gitea 服务登录页面

配置完成后,我们可以生成并将 systemd 单元文件添加到正确的配置路径中:

# podman generate systemd --name gitea-service > /etc/systemd/system/container-gitea-service.service
# podman generate systemd --name mariadb-service > /etc/systemd/system/container-mariadb-service.service

然后,我们可以手动编辑 Gitea 服务单元文件,通过特殊的Requires指令将 MariaDB 服务的依赖顺序添加进去:

# cat /etc/systemd/system/container-gitea-service.service
...
[Unit]
Description=Podman container-gitea-service.service
Documentation=man:podman-generate-systemd(1)
Wants=network-online.target
After=network-online.target
RequiresMountsFor=/run/containers/storage
Requires=container-mariadb-service.service
...

由于Requires指令,systemd 会首先启动 MariaDB 服务,然后启动 Gitea 服务。

现在,我们可以通过启动 systemd 单元来停止容器:

# podman stop mariadb-service gitea-service
mariadb-service
gitea-service

不用担心数据——之前,我们已将两个容器映射到一个专用存储卷中,存放着数据。

我们需要让 systemd 守护进程知道我们刚刚添加的新单元文件。因此,首先,我们需要运行以下命令:

# systemctl daemon-reload

之后,我们可以通过 systemd 启动服务并检查其状态:

# systemctl start container-mariadb-service.service
# systemctl status container-mariadb-service.service
● container-mariadb-service.service - Podman container-mariadb-service.service
     Loaded: loaded (/etc/systemd/system/container-mariadb-service.service; disabled; vendor preset: disabled)
     Active: active (running) since Wed 2022-02-16 01:11:50 CET; 13s ago
...
# systemctl start container-gitea-service.service
# systemctl status container-gitea-service.service 
● container-gitea-service.service - Podman container-gitea-service.service
     Loaded: loaded (/etc/systemd/system/container-gitea-service.service; disabled; vendor preset: disabled)
     Active: active (running) since Wed 2022-02-16 01:11:57 CET; 18s ago
...

最后,我们可以启用该服务,使其在操作系统启动时自动启动:

# systemctl enable container-mariadb-service.service 
Created symlink /etc/systemd/system/default.target.wants/container-mariadb-service.service → /etc/systemd/system/container-mariadb-service.service.
# systemctl enable container-gitea-service.service 
Created symlink /etc/systemd/system/default.target.wants/container-gitea-service.service → /etc/systemd/system/container-gitea-service.service.

到此为止,我们已在主机操作系统上设置并启用了两个容器化的系统服务。这个过程简单,并且有助于利用容器的功能和特性,将其扩展到系统服务中。

现在,我们准备进入下一个高级主题,在那里我们将学习如何生成 Kubernetes 资源。

生成 Kubernetes YAML 资源

Kubernetes 已成为多节点容器协调的事实标准。Kubernetes 集群允许多个 pod 根据调度策略在节点间执行,这些策略反映了节点的负载、标签、能力或硬件资源(例如 GPU)。

我们已经描述了 pod 的概念 —— 它是一个或多个容器的执行组,这些容器共享公共命名空间(网络、IPC,以及可选的 PID 命名空间)。换句话说,我们可以把 pods 看作容器的沙箱。Pod 内的容器一起执行,因此它们的启动、停止或暂停是同时进行的。

Podman 引入的最有前景的功能之一是生成 YAML 格式的 Kubernetes 资源的能力。Podman 可以拦截正在运行的容器或 pod 的配置,并生成符合 Kubernetes API 规范的 Pod 资源。

除了 pods,我们还可以生成 ServicePersistentVolumeClaim 资源,它们反映了容器内挂载端口映射和卷的配置。

我们可以在 Podman 本身内部使用生成的 Kubernetes 资源,作为 Docker Compose 堆栈的替代品,或者将它们应用到 Kubernetes 集群中,来协调简单 Pods 的执行。

Kubernetes 有多种方式来协调工作负载的执行:DeploymentsStatefulSetsDaemonSetsJobsCronJobs。在每种情况下,Pod 是它们最小的工作负载执行单元,协调逻辑根据特定的行为进行变化。这意味着我们可以轻松地将 Podman 生成的 Pod 资源,适配到一个更复杂的对象中进行协调,例如 Deployments,它管理应用程序的副本和版本更新,或 DaemonSets,它确保为每个集群节点创建一个单例的 pod 实例。

现在,让我们学习如何使用 Podman 生成 Kubernetes YAML 资源。

从正在运行的容器生成基本的 Pod 资源

从 Podman 生成 Kubernetes 资源的基本命令是 podman generate kube,后面跟随各种选项和参数,如以下代码所示:

$ podman generate kube [options] {CONTAINER|POD|VOLUME}

我们可以将此命令应用于正在运行的容器、Pod 或现有的卷。该命令还允许您使用 -s, --service 选项生成 Service 资源,使用 -f, --filename 将内容导出到文件(默认是标准输出)。

让我们从一个基本的例子开始,演示如何从一个正在运行的容器生成 Pod 资源。首先,我们将启动一个无 root 权限的 Nginx 容器:

$ podman run –d \
  -p 8080:80 --name nginx \
  docker.io/library/nginx

当容器创建时,我们可以生成我们的 Kubernetes Pod 资源:

$ podman generate kube nginx
# Save the output of this file and use kubectl create -f to import
# it into Kubernetes.
#
# Created with podman-4.0.0-rc4
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: "2022-02-10T23:14:25Z"
  labels:
    app: nginxpod
  name: nginx_pod
spec:
  containers:
  - args:
    - nginx
    - -g
    - daemon off;
    image: docker.io/library/nginx:latest
    name: nginx
    ports:
    - containerPort: 80
      hostPort: 8080
    securityContext:
      capabilities:
        drop:
        - CAP_MKNOD
        - CAP_NET_RAW
        - CAP_AUDIT_WRITE

让我们描述一下生成的输出。每个新的 Kubernetes 资源总是由至少四个字段组成:

  • apiVersion:此字段描述资源的 API 版本架构。Pod 对象属于 Kubernetes 的 core API 中的 v1 版本。

  • kind:此字段定义了资源类型,在我们的示例中是 Pod

  • metadata:此字段是一个对象,包含一组资源元数据,通常包括 namenamespacelabelsannotations,以及在运行时创建的附加动态元数据,例如 creationTimestampresourceVersion 或资源的 uid

  • spec:此字段包含资源规范,并在不同的资源之间有所不同。例如,Pod 资源将包含一个 containers 列表,其中包括启动参数、卷、端口或安全上下文等内容。

嵌入在 Pod 资源中的所有信息足以在 Kubernetes 集群中启动该 pod。除了前面描述的字段外,Pod 运行时会动态创建第五个 status 字段,用于描述其执行状态。

从生成的输出中,我们可以注意到每个容器都有一个 args 列表,其中包含它们的启动命令、参数和选项。

当你从一个带有映射端口的容器生成一个 Pod 时,以下 ports 列表会被创建到 Pod 资源中:

ports:
    - containerPort: 80
      hostPort: 8080

这意味着端口 80 必须暴露给容器,端口 8080 必须在运行该容器的主机上暴露。Podman 会在我们使用 podman play kube 命令创建容器和 Pods 时使用这些信息,正如我们将在下一节中看到的那样。

securityContext 对象定义了必须为此容器丢弃的权限。这意味着在此配置中创建的 pod 将不会启用 CAP_MKNODCAP_NET_RAWCAP_AUDIT_WRITE 权限。

我们可以将 podman generate kube 命令的输出直接应用到 Kubernetes 集群中,或将其保存到文件中。要保存到文件,我们可以使用 -f 选项:

$ podman generate kube nginx –f nginx-pod.yaml

要将生成的输出应用于运行中的 Kubernetes 集群,我们可以使用 Kubernetes CLI 工具 kubectlkubectl create 命令会将资源对象应用到集群中:

$ podman generate kube nginx | kubectl create -f -

基本的 Pod 生成命令可以通过创建相关的 Kubernetes 服务来丰富,如下一个小节所述。

从运行中的容器生成 Pods 和服务

运行在 Kubernetes 集群中的 Pods 会在由默认 CNI 插件管理的软件定义网络上获得唯一的 IP 地址。

这些 IP 地址不会被外部路由 – 我们只能从集群内部访问 Pod 的 IP 地址。然而,我们需要一层来平衡同一 pod 的多个副本,并为单一的抽象前端提供 DNS 解析。换句话说,我们的应用程序必须能够查询给定的服务名称,并接收到一个独特的 IP 地址,这个地址抽象了 pods 的 IP 地址,无论副本的数量如何。

重要提示

在 Kubernetes 中,本地的集群范围 DNS 名称解析是通过CoreDNS服务实现的,该服务在集群控制平面引导时启动。CoreDNS 负责解析内部请求,并将外部名称的请求转发到集群外部的权威 DNS 服务器。

在 Kubernetes 中描述一个或多个 Pod 抽象的资源被称为Service

例如,我们可以在集群内运行三个 Nginx Pod 副本,并通过一个唯一的 IP 将它们暴露出来。它属于ClusterIP类型,在服务创建时分配其 IP 是动态的。ClusterIP服务是 Kubernetes 中的默认服务,其分配的 IP 仅在集群内部有效。

我们还可以创建使用网络地址转换NAT)的NodePort类型服务,以便从外部世界访问该服务。我们可以通过将服务 VIP 和端口映射到集群工作节点的本地端口来实现这一点。

如果我们的集群运行在允许动态负载均衡的基础设施上(例如公共云提供商),我们可以创建LoadBalancer类型服务,并让提供商为我们管理入口流量的负载均衡。

Podman 允许通过在podman generate kube命令中添加-s选项来创建带有 Pod 的服务。这使得它们能够在 Kubernetes 集群中潜在地被重用。以下示例是前一个示例的变体,它生成了与先前描述的 Pod 一起的 Service 资源:

$ podman generate kube -s nginx
# Save the output of this file and use kubectl create -f to import
# it into Kubernetes.
#
# Created with podman-4.0.0-rc4
apiVersion: v1
kind: Service
metadata:
  creationTimestamp: "2022-02-12T21:54:02Z"
  labels:
    app: nginxpod
  name: nginx_pod
spec:
  ports:
  - name: "80"
    nodePort: 30582
    port: 80
    targetPort: 80
  selector:
    app: nginxpod
  type: NodePort
---
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: "2022-02-12T21:54:02Z"
  labels:
    app: nginxpod
  name: nginx_pod
spec:
  containers:
  - args:
    - nginx
    - -g
    - daemon off;
    image: docker.io/library/nginx:latest
    name: nginx
    ports:
    - containerPort: 80
      hostPort: 8080
    securityContext:
      capabilities:
        drop:
        - CAP_MKNOD
        - CAP_NET_RAW
        - CAP_AUDIT_WRITE

生成的输出包含 Pod 资源,同时还包含一个 Service 资源,通过选择器字段暴露 Nginx Pod。该选择器匹配所有具有app: nginxpod标签的 Pod。

当服务在 Kubernetes 集群内创建时,会为该服务分配一个内部的、非路由的 VIP。由于这是一个NodePort类型的服务,它会被映射到30582并转发到服务 IP。

默认情况下,Podman 会生成NodePort类型的服务。每当容器或 Pod 使用端口映射时,Podman 会在清单文件中填充ports对象,列出端口及其相关的nodePort映射。

在我们的使用案例中,我们通过将 Nginx 容器的端口80映射到主机的端口8080来创建了 Nginx 容器。在这里,Podman 生成了一个 Service,将容器的端口80映射到集群节点的端口30582

重要提示

nodePort映射仅适用于 Kubernetes 集群节点,而不适用于运行 Podman 的独立主机。

从 Podman 创建 Kubernetes 服务和 Pod 的价值在于能够将其移植到 Kubernetes 平台。

在许多情况下,我们处理的是需要一起导出和重新创建的复合多层应用。Podman 允许我们将多个容器导出为一个单一的 Kubernetes Pod 对象,或者创建并导出多个 Pods,以便更好地控制我们的应用程序。在接下来的两个小节中,我们将看到这两种情况应用于 WordPress 应用,并尝试找出最佳方法。

在单个 Pod 中生成一个复合应用

在这个第一个场景中,我们将实现一个在单个 Pod 中的多层应用。这种方法的优势在于,我们可以将 Pod 作为一个单一单位来执行多个容器,并且容器之间的资源共享得到了简化。

我们将启动两个容器——一个用于 MySQL,一个用于 WordPress——并将它们导出为一个单一的 Pod 资源。稍后在运行测试时,我们将学习如何解决一些小的调整问题,使其顺利运行。

重要说明

以下示例是在无根环境中创建的,但也可以无缝应用于有根容器。

一些用于启动堆栈和生成的 Kubernetes YAML 文件的脚本,可以在本书的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Podman-for-DevOps/tree/main/Chapter14/kube

首先,我们必须创建两个卷,这些卷稍后将由 WordPress 和 MySQL 容器使用:

$ for vol in dbvol wpvol; do podman volume create $vol; done

接着,我们必须创建一个名为wordpress-pod的空 Pod,并进行必要的预定义端口映射:

$ podman pod create --name wordpress-pod -p 8080:80

现在,我们可以通过创建 WordPress 和 MySQL 容器来填充我们的 Pod。让我们从 MySQL 容器开始:

$ podman create \
  --pod wordpress-pod --name db \
  -v dbvol:/var/lib/mysql
  -e MYSQL_ROOT_PASSWORD=myrootpasswd \
  -e MYSQL_DATABASE=wordpress \
  -e MYSQL_USER=wordpress \
  -e MYSQL_PASSWORD=wordpress \
  docker.io/library/mysql

现在,我们可以创建 WordPress 容器:

$ podman create \
  --pod wordpress-pod --name wordpress \
  -v wpvol:/var/www/html
  -e WORDPRESS_DB_HOST=127.0.0.1 \
  -e WORDPRESS_DB_USER=wordpress \
  -e WORDPRESS_DB_PASSWORD=wordpress \
  -e WORDPRESS_DB_NAME=wordpress \
  docker.io/library/wordpress

在这里,我们可以看到WORDPRESS_DB_HOST变量已被设置为127.0.0.1(回环设备的地址),因为这两个容器将在同一个 Pod 中运行,并共享相同的网络命名空间。因此,我们让 WordPress 容器知道 MySQL 服务正在同一个回环设备上监听。

最后,我们可以使用podman pod start命令启动 Pod:

$ podman pod start wordpress-pod

我们可以使用podman ps来检查正在运行的容器:

$ podman ps
CONTAINER ID  IMAGE                                        COMMAND               CREATED            STATUS                PORTS                 NAMES
19bf706f0eb8  localhost/podman-pause:4.0.0-rc4-1643988335                        About an hour ago  Up About an hour ago  0.0.0.0:8080->80/tcp  0400f8770627-infra
f1da755a846c  docker.io/library/mysql:latest               mysqld                About an hour ago  Up About an hour ago  0.0.0.0:8080->80/tcp  db
1f28ef82d58f  docker.io/library/wordpress:latest           apache2-foregroun...  About an hour ago  Up About an hour ago  0.0.0.0:8080->80/tcp  wordpress

现在,我们可以将浏览器指向http://localhost:8080,并确认 WordPress 设置对话框的界面:

图 14.3 – WordPress 设置对话框界面

图 14.3 – WordPress 设置对话框界面

重要说明

该 Pod 还启动了一个第三个podman-pause镜像,用于初始化 Pod 的网络和我们示例的 IPC 命名空间。该镜像在第一次创建 Pod 时直接在主机后台构建,并执行一个catatonit进程,这是一个用 C 语言编写的init微型容器,旨在处理系统信号和收割僵尸进程。

该 Pod 的基础镜像行为直接继承自 Kubernetes 的设计。

现在,我们准备使用podman generate kube命令生成 Pod YAML 清单,并将其保存到文件中以供重用:

$ podman generate kube wordpress-pod \
  -f wordpress-single-pod.yaml

上述命令生成了一个包含以下内容的文件:

# Save the output of this file and use kubectl create -f to import
# it into Kubernetes.
#
# Created with podman-4.0.0-rc4
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: "2022-02-13T11:06:38Z"
  labels:
    app: wordpress-pod
  name: wordpress-pod
spec:
  containers:
  - args:
    - mysqld
    env:
    - name: MYSQL_PASSWORD
      value: wordpress
    - name: MYSQL_USER
      value: wordpress
    - name: MYSQL_ROOT_PASSWORD
      value: myrootpasswd
    - name: MYSQL_DATABASE
      value: wordpress
    image: docker.io/library/mysql:latest
    name: db
    ports:
    - containerPort: 80
      hostPort: 8080
    resources: {}
    securityContext:
      capabilities:
        drop:
        - CAP_MKNOD
        - CAP_NET_RAW
        - CAP_AUDIT_WRITE
    volumeMounts:
    - mountPath: /var/lib/mysql
      name: dbvol-pvc
  - args:
    - apache2-foreground
    env:
    - name: WORDPRESS_DB_HOST
      value: 127.0.0.1
    - name: WORDPRESS_DB_PASSWORD
      value: wordpress
    - name: WORDPRESS_DB_USER
      value: wordpress
    - name: WORDPRESS_DB_NAME
      value: wordpress
    image: docker.io/library/wordpress:latest
    name: wordpress
    resources: {}
    securityContext:
      capabilities:
        drop:
        - CAP_MKNOD
        - CAP_NET_RAW
        - CAP_AUDIT_WRITE
    volumeMounts:
    - mountPath: /var/www/html
      name: wpvol-pvc
  restartPolicy: Never
  volumes:
  - name: wpvol-pvc
    persistentVolumeClaim:
      claimName: wpvol
  - name: dbvol-pvc
    persistentVolumeClaim:
      claimName: dbvol
status: {}

我们的 YAML 文件包含一个单一的 Pod 资源,其中有两个容器。请注意,之前定义的环境变量已正确创建在我们的容器内(当使用 Podman v4.0.0 或更高版本时)。

此外,请注意,两个容器卷已映射到PersistentVolumeClaim对象,通常称为PVC对象。

PVC 是 Kubernetes 资源,用于请求(即声明)一个满足特定容量和消费模式的存储卷资源。附加的存储卷资源被称为PersistentVolumePV),可以通过手动创建或由符合 容器存储接口CSI)的存储驱动程序自动创建的StorageClass资源来创建。

当我们创建 PVC 时,StorageClass 会配置一个满足我们存储请求的PersistentVolume,并将这两个资源绑定在一起。这种方法将存储请求与存储配置解耦,使 Kubernetes 中的存储消费更具可移植性。

当 Podman 生成 Kubernetes YAML 文件时,默认情况下 PVC 资源不会被导出。然而,我们也可以通过podman generate kube <VOLUME_NAME>命令导出 PVC 资源,以便在 Kubernetes 中重新创建它们。

以下命令导出了 WordPress 应用程序及其卷定义,作为一个 PVC:

$ podman generate kube wordpress-pod wpvol dbvol 

以下是将dbvol卷转换为PersistentVolumeClaim的示例:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  annotations:
    volume.podman.io/driver: local
  creationTimestamp: "2022-02-13T14:51:05Z"
  name: dbvol
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
status: {}

这种方法的优点在于提供了必要的 PVC 定义,用于在 Kubernetes 集群中重建整个应用程序,但在 Podman 中并不需要重建卷资源:如果它们不存在,将自动创建一个具有相同名称的空卷。

为了重建 Kubernetes 集群中的所有资源依赖关系,我们还可以导出应用程序的Service资源。

以下命令导出了我们 WordPress 示例中的所有内容,包括 Pod、服务和卷:

$ podman generate kube -s wordpress-pod wpvol dbvol

在继续之前,让我们简要地探讨一下本小节中描述的单个 Pod 方法的逻辑,并看看它的优点和可能的局限性。

执行所有容器在一个单一 Pod 中的一个巨大优势是简化了网络配置——所有运行的容器共享一个网络命名空间。这也意味着我们不必为容器之间的通信创建一个专门的 Podman 网络。

另一方面,这种方法并没有反映出 Kubernetes 执行 Pod 的常见模式。在 Kubernetes 中,我们更倾向于将 WordPress Pod 和 MySQL Pod 分开,以便独立管理它们,并为它们关联不同的服务。更多的分离意味着更多的控制权,以及独立更新的机会。

在接下来的小节中,你将学习如何复制这种方法,并为每个应用层生成多个 Pod。

生成多个 Pod 的复合应用

Docker Compose 的一大特点是,你可以创建不同的独立容器,它们通过服务抽象概念互相通信,这个概念与容器的执行是解耦的。

Podman 社区(及其许多用户)认为,向 Kubernetes YAML 清单的标准化是描述复杂工作负载的一种有效方法,有助于我们更接近主流的编排解决方案。

因此,我们将在本节中描述的方法可以完全替代 Docker Compose,同时提供 Kubernetes 的可移植性。首先,我们将学习如何准备一个可以用于生成 YAML 清单的环境。之后,我们可以摆脱工作负载,只使用 Kubernetes YAML 来运行我们的工作负载。

以下示例可以在无根容器和网络环境中执行。

重要提示

在继续之前,请确保之前的示例 Pod 和容器已经完全移除,连同它们的卷一起删除,以避免端口分配或 WordPress 内容初始化出现问题。请参考本书 GitHub 仓库中的命令:github.com/PacktPublishing/Podman-for-DevOps/tree/main/AdditionalMaterial

首先,我们需要创建一个网络。我们选择了 kubenet 作为名称,方便识别,并且为了示范,保留默认配置:

$ podman network create kubenet

一旦网络创建完毕,必须创建两个 dbvolwpvol 卷:

$ for vol in wpvol dbvol; do podman volume create $vol; done

我们希望生成两个独立的 Pod —— 每个容器一个。首先,我们必须创建 MySQL Pod 及其相关容器:

$ podman pod create –p 3306:3306 \
  --network kubenet \
  --name mysql-pod 
$ podman create --name db \
  --pod mysql-pod \
  -v dbvol:/var/lib/mysql \
  -e MYSQL_ROOT_PASSWORD=myrootpasswd\
  -e MYSQL_DATABASE=wordpress \
  -e MYSQL_USER=wordpress \
  -e MYSQL_PASSWORD=wordpress \
  docker.io/library/mysql

请注意端口映射,我们可以用它从客户端访问 MySQL 服务,并稍后在 Kubernetes 服务中创建正确的端口映射。

现在,让我们创建 WordPress Pod 和容器:

$ podman pod create -p 8080:80 \
  --network kubenet \
  --name wordpress-pod
$ podman create --name wordpress \
  --pod wordpress-pod \
  -v wpvol:/var/www/html \
  -e WORDPRESS_DB_HOST=mysql-pod \
  -e WORDPRESS_DB_USER=wordpress \
  -e WORDPRESS_DB_PASSWORD=wordpress \
  -e WORDPRESS_DB_NAME=wordpress \
  docker.io/library/wordpress

在前面的命令中,有一个非常重要的变量,可以认为是该方法的关键:WORDPRESS_DB_HOST 被填充为 mysql-pod 字符串,这是分配给 MySQL pod 的名称。

在 Podman 中,Pod 的名称将充当应用的服务名称,与网络相关的 DNS 守护进程(在 Podman 3 中是 dnsmasq,在 Podman 4 中是 aardvark-dns)会直接将 Pod 名称解析为关联的 IP 地址。这是一个关键特性,使得多 Pod 应用成为 Compose 堆栈的完美替代品。

现在,我们可以启动这两个 Pod,并让所有容器都运行起来:

$ podman pod start mysql-pod && 
  podman pod start wordpress-pod

再次说一次,我们只需将浏览器指向 http://localhost:8080,应该会引导我们进入 WordPress 的首次设置页面(如果一切设置正确)。

现在,我们准备好导出 Kubernetes YAML 清单了。我们可以选择仅导出两个 Pod 资源,或创建一个完整的导出,其中还包括服务和卷。如果你需要将其导入到 Kubernetes 集群中,这将非常有用。

我们从基本版本开始:

$ podman generate kube \
  -f wordpress-multi-pod-basic.yaml \
  wordpress-pod \
  mysql-pod

上述代码的输出将仅包含两个 Pod 资源:

# Save the output of this file and use kubectl create -f to import
# it into Kubernetes.
#
# Created with podman-4.0.0-rc4
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: "2022-02-13T21:32:48Z"
  labels:
    app: wordpress-pod
  name: wordpress-pod
spec:
  containers:
  - args:
    - apache2-foreground
    env:
    - name: WORDPRESS_DB_NAME
      value: wordpress
    - name: WORDPRESS_DB_HOST
      value: mysql-pod
    - name: WORDPRESS_DB_PASSWORD
      value: wordpress
    - name: WORDPRESS_DB_USER
      value: wordpress
    image: docker.io/library/wordpress:latest
    name: wordpress
    ports:
    - containerPort: 80
      hostPort: 8080
    resources: {}
    securityContext:
      capabilities:
        drop:
        - CAP_MKNOD
        - CAP_NET_RAW
        - CAP_AUDIT_WRITE
    volumeMounts:
    - mountPath: /var/www/html
      name: wpvol-pvc
  restartPolicy: Never
  volumes:
  - name: wpvol-pvc
    persistentVolumeClaim:
      claimName: wpvol
status: {}
---
apiVersion: v1
kind: Pod
metadata:
  creationTimestamp: "2022-02-13T21:32:48Z"
  labels:
    app: mysql-pod
  name: mysql-pod
spec:
  containers:
  - args:
    - mysqld
    env:
    - name: MYSQL_ROOT_PASSWORD
      value: myrootpasswd
    - name: MYSQL_DATABASE
      value: wordpress
    - name: MYSQL_USER
      value: wordpress
    - name: MYSQL_PASSWORD
      value: wordpress
    image: docker.io/library/mysql:latest
    name: db
    ports:
    - containerPort: 3306
      hostPort: 3306
    resources: {}
    securityContext:
      capabilities:
        drop:
        - CAP_MKNOD
        - CAP_NET_RAW
        - CAP_AUDIT_WRITE
    volumeMounts:
    - mountPath: /var/lib/mysql
      name: dbvol-pvc
  restartPolicy: Never
  volumes:
  - name: dbvol-pvc
    persistentVolumeClaim:
      claimName: dbvol
status: {}

生成的文件也可以在本书的 GitHub 仓库中找到:

github.com/PacktPublishing/Podman-for-DevOps/blob/main/Chapter14/kube/wordpress-multi-pod-basic.yaml

正如我们将在下一节中看到的,这个 YAML 文件足以从头开始在 Podman 上重建一个完全可运行的 WordPress 应用程序。我们可以将其保存在源代码控制仓库(如 Git)中,并进行版本管理,以便将来重用。

以下代码导出了两个 Pod 资源,以及 PersistentVolumeClaimService 资源:

$ podman generate kube -s \
  -f wordpress-multi-pod-full.yaml \
  wordpress-pod \
  mysql-pod \
  dbvol \
  wpvol

此命令的输出也可以在本书的 GitHub 仓库中找到:

github.com/PacktPublishing/Podman-for-DevOps/blob/main/Chapter14/kube/wordpress-multi-pod-full.yaml

这个完整的清单对于在 Kubernetes 集群中导入和测试我们的应用程序非常有用,其中 ServicePersistentVolumeClaim 资源是必需的。

现在,我们准备好在 Podman 中测试生成的资源,并学习如何通过简单操作重现完整堆栈部署。

在 Podman 中运行 Kubernetes 资源文件

现在我们已经学会了如何生成包含必要资源的 Kubernetes YAML 文件,以部署我们的应用程序,我们希望在真实场景中进行测试。

本书中我们将再次使用 WordPress 应用程序,既使用其单个容器的简单形式,也使用其多 Pod 变体。

本书中的以下示例也可在 GitHub 仓库中找到——你可以选择使用从实验中生成的资源,或使用本书仓库中准备好的清单。

重要提示

测试 Podman 创建 Kubernetes 资源之前,请别忘了清理所有之前的工作负载。

对于所有示例,我们将使用 podman play kube 命令。它为我们提供了一个简单直观的接口,用于管理复杂堆栈的执行,并具有良好的自定义能力。

第一个示例将基于单个 Pod 清单:

$ podman play kube wordpress-single-pod.yaml

上述命令创建了一个名为 wordpress-pod 的 Pod,该 Pod 包含两个容器以及所需的卷。让我们检查一下结果,看看发生了什么:

$ podman pod ps
POD ID        NAME           STATUS      CREATED         INFRA ID      # OF CONTAINERS
5f8ecfe66acd  wordpress-pod  Running     4 minutes ago  46b4bdfe6a08  3

我们还可以检查正在运行的容器。在这里,我们预计会看到两个 WordPress 和 MySQL 容器以及第三个与基础设施相关的 podman-pause

$ podman ps
CONTAINER ID  IMAGE                                        COMMAND               CREATED         STATUS             PORTS                 NAMES
46b4bdfe6a08  localhost/podman-pause:4.0.0-rc4-1643988335                        4 minutes ago  Up 4 minutes ago  0.0.0.0:8080->80/tcp  5f8ecfe66acd-infra
ef88a5c8d1e5  docker.io/library/mysql:latest               mysqld                4 minutes ago  Up 4 minutes ago  0.0.0.0:8080->80/tcp  wordpress-pod-db
76c6b6328653  docker.io/library/wordpress:latest           apache2-foregroun...  4 minutes ago  Up 4 minutes ago  0.0.0.0:8080->80/tcp  wordpress-pod-wordpress

最后,我们可以验证是否已创建 dbvolwpvol 卷:

$ podman volume ls
DRIVER      VOLUME NAME
local       dbvol
local       wpvol

在查看更详细(且有趣的)多 Pod 示例之前,我们必须清理环境。我们可以手动执行此操作,也可以使用--down选项,通过podman play kube命令立即停止并删除正在运行的 Pod:

$ podman play kube --down wordpress-single-pod.yaml 
Pods stopped:
5f8ecfe66acd01b705f38cd175fad222890ab612bf572807082f30ab37fd 0b88
Pods removed:
5f8ecfe66acd01b705f38cd175fad222890ab612bf572807082f30ab37fd 0b88

重要提示

默认情况下,卷不会被删除,因为如果容器已在其上写入数据,保留它们可能会很有用。要删除未使用的卷,可以使用podman volume prune命令。

现在,让我们使用基本导出的清单运行多 Pod 示例:

$ podman play kube --network kubenet \
  wordpress-multi-pod-basic.yaml

请注意额外的--network参数,该参数用于指定 Pod 将连接到的网络。这是必要的信息,因为 Kubernetes YAML 文件中没有关于 Podman 网络的信息。我们的 Pod 将以无根模式执行,并连接到无根kubenet网络。

我们可以使用以下命令检查这两个 Pod 是否已正确创建:

$ podman pod ps
POD ID        NAME           STATUS      CREATED        INFRA ID      # OF CONTAINERS
c9d775da0379  mysql-pod      Running     8 minutes ago  71c93fa6080b  2
3b497cbaeebc  wordpress-pod  Running     8 minutes ago  0c52ee133f0f  2

现在,我们可以检查正在运行的容器。以下代码中突出显示的字符串代表主要工作负载,以便与基础设施容器区分开来:

$ podman ps --format "{{.Image }} {{.Names}}"
localhost/podman-pause:4.0.0-rc5-1644672408 3b497cbaeebc-infra
docker.io/library/wordpress:latest wordpress-pod-wordpress
localhost/podman-pause:4.0.0-rc5-1644672408 c9d775da0379-infra
docker.io/library/mysql:latest mysql-pod-db

podman volume ls命令确认了两个卷的存在:

$ podman volume ls
DRIVER      VOLUME NAME
local       dbvol
local       wpvol

可以使用podman unshare命令检查无根网络配置:

$ podman unshare --rootless-netns ip addr show

重要提示

--rootless-netns选项仅在 Podman 4 中可用,这是本章节推荐使用的版本。

最后,让我们检查 DNS 的行为。在 Podman 4 中,定制网络的名称解析由aardvark-dns守护进程管理,而在 Podman 3 中,则由dnsmasq管理。由于我们假设你在这些示例中使用的是 Podman 4,让我们来看一下它的 DNS 配置。对于无根网络,我们可以在/run/user/<UID>/containers/networks/aardvark-dns/<NETWORK_NAME>文件中找到管理的记录。

在我们的示例中,kubenet网络的配置如下:

$ cat /run/user/1000/containers/networks/aardvark-dns/kubenet
10.89.0.1
0c52ee133f0fec5084f25bd89ad8bd0f6af2fc46d696e2b8161864567b0a92 0b 10.89.0.4  wordpress-pod,0c52ee133f0f
71c93fa6080b6a3bfe1ebad3e164594c5fa7ea584e180113d2893eb67f6f3b 56 10.89.0.5  mysql-pod,71c93fa6080b

从这个输出中最令人惊讶的事情是确认名称解析现在在 Pod 级别工作,而不是在容器级别。如果我们考虑到 Pod 初始化了命名空间,包括网络命名空间,这也是合理的。因此,我们可以将 Podman 中的 Pod 名称视为服务名称。

在这里,我们演示了如何使用 Podman 生成的 Kubernetes 清单成为一个出色的替代方案,取代 Docker Compose 方法,同时更加便携。现在,让我们学习如何将生成的资源导入到一个测试 Kubernetes 集群中。

在 Kubernetes 中测试结果

在本节中,我们要将多 Pod YAML 文件导入到 Kubernetes 中,该文件已经添加了 Services 和 PVC 配置。

为了提供一个可重复的环境,我们将使用minikube(小写的 m),这是一种便携式解决方案,用于创建一个一体化的 Kubernetes 集群作为本地基础设施。

minikube 项目旨在为 Linux、Windows 和 macOS 提供本地 Kubernetes 集群。它使用主机虚拟化启动一个虚拟机,运行所有集群功能,或使用容器化创建一个控制平面,该平面运行在容器内部。它还提供了一整套附加组件,扩展集群功能,例如入口控制器、服务网格、注册表、日志记录等。

另一种广泛采用的启动本地 Kubernetes 集群的替代方法是 Kubernetes in DockerKinD)项目,本书未描述此方法。KinD 将 Kubernetes 控制平面运行在由 Docker 或 Podman 驱动的容器内。

要设置 minikube,用户需要虚拟化支持(KVM、VirtualBox、Hyper-V、Parallels 或 VMware)或容器运行时,例如 Docker 或 Podman。

为了简洁起见,我们将不涉及为不同操作系统配置虚拟化支持所需的技术步骤;相反,我们将使用一个 GNU/Linux 发行版。

重要提示

如果您已经拥有一个正在运行的 Kubernetes 集群,或者希望以其他方式设置一个集群,您可以跳过下一步的 minikube 配置快速启动,直接进入 在 Kubernetes 中运行生成的资源文件 子部分。

设置 minikube

运行以下命令下载并安装最新的minikube二进制文件:

$ curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
$ sudo install minikube-linux-amd64 /usr/local/bin/minikube

您可以选择使用虚拟化或容器化驱动程序运行 minikube。若要在 KVM 驱动程序上将 minikube 作为虚拟机运行,您必须安装 Qemu/KVMlibvirt 软件包。

在 Fedora 上,运行以下命令使用 @virtualization 软件包组安装所有强制性和默认的软件包:

$ sudo dnf install @virtualization

现在,启动并启用 libvirtd 服务:

$ sudo systemctl enable --now libvirtd

为了授予运行 minikube 的用户适当的权限,将其添加到 libvirt 补充组中(此操作需要重新登录以加载新组):

$ sudo usermod -aG libvirt $(whoami)

以下命令将静态配置 kvm2 驱动程序为默认驱动程序:

$ minikube config set driver kvm2

当执行上述命令时,minikube 会在启动虚拟机之前自动下载合适的 kvm2 驱动程序二进制文件。

或者,您可以选择将 minikube 作为 Docker 或 Podman 的容器化服务运行。假设 Podman 已经安装,我们只需要确保运行 minikube 的用户可以运行无密码的 sudo。这是必需的,因为 Kubernetes 集群必须在 rootfull 容器中运行,因此需要提升权限。要允许 Podman 无密码提升权限,请使用以下命令编辑 /etc/sudoers 文件:

$ sudo visudo

打开文件后,在文件末尾添加以下行,以便为 Podman 二进制文件授予无密码提升权限,并保存文件。记得将 <username> 替换为您的用户名:

<username> ALL=(ALL) NOPASSWD: /usr/bin/podman

以下命令将静态配置 podman 驱动程序为默认驱动程序:

$ minikube config set driver podman

重要提示

如果您的主机是运行在虚拟化平台(如 KVM)上的虚拟机,并且在主机上安装了 Podman,minikube 将会自动检测环境并将默认驱动程序设置为 podman

使用 minikube 时,用户还需要安装 Kubernetes CLI 工具 kubectl。以下命令用于下载并安装最新的 Linux 版本:

$ version=$(curl -L -s https://dl.k8s.io/release/stable.txt) curl -LO "https://dl.k8s.io/release/${version}/bin/linux/amd64/kubectl $ sudo install -o root -g root \
  -m 0755 kubectl \
  /usr/local/bin/kubectl

现在,我们已经准备好使用 minikube 运行我们的 Kubernetes 集群。

启动 minikube

要将 minikube 启动为虚拟机,请在 Kubernetes 集群内使用 CRI-O 容器运行时:

$ minikube start --driver=kvm2 --container-runtime=cri-o

如果 kvm2 已通过 minikube config set driver 命令配置为默认驱动程序,则不需要使用 --driver 选项。

要使用 Podman 启动 minikube,可以在集群内使用 CRI-O 容器运行时:

$ minikube start --driver=podman --container-runtime=cri-o

如果已通过 minikube config set driver 命令将 podman 配置为默认驱动程序,则 --driver 选项不是必需的。

为确保集群正确创建,使用 kubectl CLI 运行以下命令。所有 Pod 应该处于 Running 状态:

$ kubectl get pods –A
NAMESPACE     NAME                               READY   STATUS    RESTARTS   AGE
kube-system   coredns-64897985d-gqnrn            1/1     Running   0          19s
kube-system   etcd-minikube                      1/1     Running   0          27s
kube-system   kube-apiserver-minikube            1/1     Running   0          27s
kube-system   kube-controller-manager-minikube   1/1     Running   0          27s
kube-system   kube-proxy-sj7xn                   1/1     Running   0          20s
kube-system   kube-scheduler-minikube            1/1     Running   0          33s
kube-system   storage-provisioner                1/1     Running   0          30s

重要提示

如果一个或多个容器仍处于 ContainerCreating 状态,请稍等片刻,等待镜像拉取完成。

另外,请注意,如果您使用 Podman 驱动程序运行 minikube,输出可能会稍有不同。在这种情况下,会额外创建一个名为 kindnet 的 Pod,以帮助管理集群内的 CNI 网络。

这样,我们就为本地 Kubernetes 环境做好了所有设置,准备测试我们生成的清单。

在 Kubernetes 中运行生成的资源文件

生成包含多个 Pod 的复合应用程序 部分中,我们学习了如何从 Podman 导出包含 Pod 资源、Service 资源和 PersistentVolumeClaim 资源的清单文件。导出这些资源集的需求与 Kubernetes 处理工作负载、存储和暴露服务的方式有关。

Kubernetes 服务用于提供解析机制以及内部负载均衡。在我们的示例中,mysql-pod Pod 将映射到同名的 mysql-pod 服务。

PVCs(持久卷声明)用于定义一个存储声明,开始为我们的 Pod 配置持久卷。在 minikube 中,自动化配置由一个名为 minikube-hostpath 的本地 StorageClass 实现;它会在虚拟机/容器的文件系统中创建本地目录,随后将这些目录挂载到 Pod 的容器内。

我们可以通过使用 kubectl create 命令来部署我们的 WordPress 堆栈:

$ kubectl create –f wordpress-multi-pod-full.yaml

如果未指定,所有资源将会在 default Kubernetes 命名空间中创建。我们等待 Pod 进入 Running 状态,并检查结果。

首先,我们可以检查已经创建的 Pods 和服务:

$ kubectl get pods 
NAME            READY   STATUS    RESTARTS   AGE
mysql-pod       1/1     Running   0          48m
wordpress-pod   1/1     Running   0          48m
$ kubectl get svc
NAME            TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)          AGE
kubernetes      ClusterIP   10.96.0.1      <none>        443/TCP          53m
mysql-pod       NodePort    10.108.34.77   <none>        3306:30284/TCP   52m
wordpress-pod   NodePort    10.96.63.142   <none>        80:30408/TCP     52m

请注意,两个 mysql-podwordpress-pod 服务已经创建,并且类型为 NodePort,映射到了 30000 或更高范围的端口。我们将使用 30408 端口来测试 WordPress 前端。

Pods 由服务使用标签匹配逻辑进行映射。如果服务的 selector 字段中定义的标签在 pod 中存在,则该 pod 成为服务本身的 endpoint。让我们查看当前项目中的端点:

$ kubectl get endpoints
NAME            ENDPOINTS         AGE
kubernetes      10.88.0.6:8443    84m
mysql-pod       10.244.0.5:3306   4m9s
wordpress-pod   10.244.0.6:80     4m9s

重要提示

kubernetes 服务及其相关端点提供了对内部工作负载的 API 访问。然而,这不是本书示例的一部分,因此可以在此上下文中忽略。

让我们还检查一下声明和它们相关的卷:

$ kubectl get pvc
NAME    STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   AGE
dbvol   Bound    pvc-4d4a047b-bd20-4bef-879c-c3d80f96d712   1Gi        RWO            standard       54m
wpvol   Bound    pvc-accd7947-1499-44b5-bac8-9345da7edc23   1Gi        RWO            standard       54m
$ kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM           STORAGECLASS   REASON   AGE
pvc-4d4a047b-bd20-4bef-879c-c3d80f96d712   1Gi        RWO            Delete           Bound    default/dbvol   standard                60m
pvc-accd7947-1499-44b5-bac8-9345da7edc23   1Gi        RWO            Delete           Bound    default/wpvol   standard                60m

这两个 PVC 资源已经创建并绑定到两个动态配置的持久卷。只要 PVC 对象存在,相关的 PV 就会保持不变,即使 Pods 被销毁并重新创建。

现在,可以测试 WordPress 应用程序。默认情况下,minikube 不部署 ingress 控制器(尽管可以通过 minikube addons enable ingress 命令启用它),因此我们将使用简单的 NodePort 服务来测试我们应用程序的功能。

必须获取当前 minikube 虚拟机/容器的 IP 地址,才能访问暴露的 NodePort 服务。端口 30408wordpress-pod 服务关联,监听由以下命令生成的 IP 地址:

$ minikube ip
10.88.0.6

现在,我们可以在浏览器中访问 http://10.88.0.6:30408 并查看 WordPress 的首次设置页面。

要删除 WordPress 应用程序及其所有相关内容,可以在 YAML 清单文件中使用 kubectl delete 命令:

$ kubectl delete –f wordpress-multi-pod-full.yaml

此命令会删除文件中已定义的所有资源,包括生成的 PVs。

总结

到此为止,我们已完成关于 Podman 及其相关工具的本书内容。

首先,我们学习了如何生成 Systemd 单元文件并将容器化的工作负载作为 Systemd 服务进行控制,这使得我们可以例如在系统启动时自动执行容器。

之后,我们学习了如何生成 Kubernetes YAML 资源。从基本概念和示例开始,我们学习了如何使用单个 Pod 和多个 Pod 的方法生成复杂的应用栈,并展示了后者如何提供一个很好的替代方案(并符合 Kubernetes 标准)来代替 Docker Compose 方法。

最后,我们在 Podman 和通过 minikube 创建的本地 Kubernetes 集群上测试了我们的结果,展示了这种方法的出色可移植性。

本书的旅程在这里结束,但由于 Podman 在许多场景中的广泛应用和其充满活力且乐于助人的社区,Podman 的惊人演变仍在继续。

在继续之前,别忘了加入 IRC、Matrix 或 Discord 社区,并订阅相关的邮件列表。随时提出反馈和建议,并贡献力量帮助项目的成长。

感谢您的关注与投入。

深入阅读

要了解更多本章所涉及的主题,可以查看以下资源:

posted @ 2025-06-27 17:08  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报