Docker-容器终极之书第三版-全-
Docker 容器终极之书第三版(全)
原文:
annas-archive.org/md5/951792ab738574a4713e2995dc6f7c0c
译者:飞龙
前言
在今天这个快节奏的世界里,开发人员面临着不断的压力,需要快速高效地构建、修改、测试和部署高度分布的应用程序。运维工程师需要一个一致的部署策略,以应对日益增长的应用程序组合,而利益相关者希望保持低成本。Docker 容器,结合 Kubernetes 等容器编排工具,为这些挑战提供了强有力的解决方案。
Docker 容器简化了构建、交付和运行高度分布式应用程序的过程。它们加强了 CI/CD 流水线,并允许公司在 Kubernetes 等单一部署平台上进行标准化。容器化应用程序更加安全,并且可以在任何能够运行容器的平台上运行,无论是在本地还是云端。借助 Docker 容器,开发人员、运维工程师和利益相关者能够实现他们的目标,并保持领先地位。
本书适合谁
本书旨在为任何希望了解 Docker 及其功能的人提供指导。无论你是系统管理员、运维工程师、DevOps 工程师、开发人员还是业务相关人员,本书将带领你从零开始学习 Docker 的基础。
通过清晰的解释和实际示例,你将全面了解这项技术的所有功能,最终使你能够在云中部署和运行高度分布的应用程序。如果你希望提升自己的技能并利用 Docker 的强大功能,那么本书就是为你而写的。
本书涵盖的内容
第一章,什么是容器,为什么我应该使用它们? 重点介绍了软件供应链及其中的摩擦。然后,它将容器作为减少这种摩擦并在其上提供企业级安全性的手段。在这一章中,我们还将探讨容器及其周围生态系统的构建方式。我们特别指出了上游开源组件(Moby)与 Docker 和其他厂商下游产品之间的区别。
第二章,设置工作环境,详细讨论了如何为开发人员、DevOps 和运维人员设置一个理想的环境,以便在使用 Docker 容器时能够高效工作。
第三章,掌握容器,教你如何启动、停止和删除容器。本章还会教你如何检查容器,获取容器中的额外元数据。此外,它还解释了如何运行额外的进程以及如何附加到已经运行的容器中的主进程。最后,本章介绍了容器的内部工作原理,包括 Linux 命名空间和组等内容。
第四章,创建和管理容器镜像,介绍了创建容器镜像的不同方法,容器镜像作为容器的模板。它介绍了镜像的内部结构以及如何构建镜像。本章还展示了如何将现有的遗留应用程序“迁移并部署”到容器中运行。
第五章,数据卷和配置,讨论了可以被容器中运行的有状态组件使用的数据卷。本章还展示了如何为容器内部运行的应用程序定义个别环境变量,以及如何使用包含完整配置设置的文件。
第六章,容器中运行代码的调试,介绍了常用的技术,这些技术使你能够在容器中运行时演化、修改、调试和测试代码。掌握这些技术后,你将能够享受无摩擦的开发过程,使容器中运行的应用程序的开发过程类似于本地开发应用程序时的体验。
第七章,容器中运行应用程序的测试,讨论了在容器中运行的应用程序和应用服务的测试。你将了解各种测试类型,并理解如何在使用容器时,最优化地实施和执行这些测试。本章解释了所有测试如何在开发者的机器上本地运行,或者作为完全自动化的 CI/CD 流水线的单独质量关卡执行。
第八章,通过 Docker 技巧和窍门提高生产力,展示了一些在容器化复杂分布式应用程序时有用的各种技巧、窍门和概念,或者在使用 Docker 自动化复杂任务时的技巧。你还将学习如何利用容器将整个开发环境运行在容器中。
第九章,了解分布式应用架构,介绍了分布式应用架构的概念,并讨论了运行分布式应用程序所需的各种模式和最佳实践。最后,它讨论了在生产环境中运行这种应用程序时需要满足的附加要求。
第十章,使用单主机网络,介绍了 Docker 容器的网络模型及其在单主机上的实现形式——桥接网络。章节还介绍了软件定义网络(SDNs)的概念,以及它们如何用于保护容器化应用程序。它还涵盖了如何打开容器端口对外公开,从而使容器化的组件能够从外部世界访问。最后,它介绍了 Traefik,一个反向代理,用于在容器之间启用复杂的 HTTP 应用层路由。
第十一章,使用 Docker Compose 管理容器,介绍了由多个服务组成的应用程序的概念,每个服务都运行在一个容器中,并解释了 Docker Compose 如何通过声明性方法帮助我们轻松构建、运行和扩展这样的应用程序。
第十二章,传输日志和监控容器,展示了如何收集容器日志并将其发送到中央位置,在那里聚合的日志可以被解析以提取有用的信息。你还将学习如何为应用程序添加监控,使其暴露出度量数据,以及如何将这些度量数据抓取并再次发送到中央位置。最后,本章教你如何将收集到的度量数据转换为图形化的仪表板,用于监控容器化的应用程序。
第十三章,介绍容器编排,详细阐述了容器编排工具的概念。它解释了为什么需要编排工具,以及它们的概念性工作原理。本章还将概述最流行的编排工具,并列出它们各自的优缺点。
第十四章,介绍 Docker Swarm,介绍了 Docker 的本地编排工具 SwarmKit。它详细阐述了 SwarmKit 用于在本地或云环境中部署和运行分布式、弹性、稳健且高可用的应用程序的所有概念和对象。
第十五章,在 Docker Swarm 上部署和运行分布式应用程序,介绍了路由网格,并演示了如何将由多个服务组成的第一个应用程序部署到 Swarm 上。
第十六章,介绍 Kubernetes,介绍了目前最流行的容器编排工具 Kubernetes。它介绍了核心的 Kubernetes 对象,这些对象用于在集群中定义和运行分布式、弹性、稳健且高可用的应用程序。最后,它介绍了 minikube,作为本地部署 Kubernetes 应用程序的一种方式,并且涵盖了 Kubernetes 与 Docker Desktop 的集成。
第十七章,使用 Kubernetes 部署、更新和保护应用程序,教你如何将应用程序部署、更新和扩展到 Kubernetes 集群。它还展示了如何使用活性和就绪探针来为应用程序服务提供支持,以便 Kubernetes 进行健康检查和可用性检查。此外,本章还解释了如何实现零停机部署,从而实现不中断的更新和回滚关键任务应用程序。最后,它介绍了 Kubernetes Secrets,作为配置服务和保护敏感数据的一种方式。
第十八章,在云中运行容器化应用程序,概述了在云中运行容器化应用程序的几种最流行的方式。讨论了微软 Azure、亚马逊 AWS 和谷歌云引擎上的完全托管服务。我们将分别在每个云平台上创建一个托管的 Kubernetes 集群,并将一个简单的分布式应用程序部署到这些集群中。我们还将比较这三种服务的设置和使用的简便性。
第十九章,监控和故障排除生产环境中的应用程序,涵盖了用于监控和仪表化单个服务或整个分布式应用程序的不同技术,应用程序运行在 Kubernetes 集群中。你将了解基于关键指标的告警概念。本章还展示了如何在不更改集群或集群节点的情况下,故障排除生产环境中运行的应用程序服务。
为了最大化本书的学习效果
本书中涵盖的软件/硬件 | 操作系统要求 |
---|---|
Docker v23.x | Windows、macOS 或 Linux |
Docker Desktop | |
Kubernetes | |
Docker SwarmKit |
如果你正在使用本书的数字版,建议你自己输入代码或从本书的 GitHub 仓库访问代码(下节会提供链接)。这样可以帮助你避免与复制和粘贴代码相关的潜在错误。
下载示例代码文件
你可以从 GitHub 下载本书的示例代码文件,网址为 https://github.com/PacktPublishing/The-Ultimate-Docker-Container-Book/。如果代码有更新,它将会在 GitHub 仓库中更新。
我们还有其他来自我们丰富目录的代码包,涵盖了书籍和视频内容,网址:https://github.com/PacktPublishing/。快来看看吧!
使用的约定
本书中使用了多种文本约定。
文中的代码
:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名。例如:“一旦 Chocolatey 安装完成,可以通过 choco
--version
命令来测试它。”
代码块设置如下:
while :do
curl -s http://jservice.io/api/random | jq '.[0].question'
sleep 5
done
当我们希望您特别关注代码块中的某一部分时,相关的行或项目会以粗体显示:
…secrets: demo-secret: "<<demo-secret-value>>"
other-secret: "<<other-secret-value>>"
yet-another-secret: "<<yet-another-secret-value>>"
…
任何命令行输入或输出都如下所示:
$ docker version $ docker container run hello-world
粗体:表示一个新术语、一个重要的词或屏幕上显示的词。例如,菜单或对话框中的词会以粗体显示。举个例子:“从菜单中选择仪表盘。”
提示或重要说明
显示如下。
联系我们
我们始终欢迎读者的反馈。
一般反馈:如果您对本书的任何部分有疑问,请通过 email us at customercare@packtpub.com,并在邮件主题中提及书名。
勘误表:虽然我们已经尽力确保内容的准确性,但错误还是会发生。如果您在本书中发现错误,我们将非常感激您报告给我们。请访问 www.packtpub.com/support/errata 填写表格。
盗版:如果您在互联网上发现我们作品的任何非法副本,我们将非常感激您提供其所在地址或网站名称。请通过 copyright@packt.com 联系我们,并附上相关链接。
如果您有兴趣成为作者:如果您在某个领域拥有专业知识,并且有兴趣撰写或为书籍做贡献,请访问 authors.packtpub.com。
分享您的想法
一旦您阅读了《终极 Docker 容器书》,我们非常希望听到您的想法!请点击这里直接访问亚马逊的书籍评价页面并分享您的反馈。
您的评价对我们以及技术社区非常重要,它将帮助我们确保提供优质的内容。
下载本书的免费 PDF 版
感谢您购买本书!
您喜欢在旅途中阅读,但无法随身携带纸质书籍吗?您的电子书购买是否与您选择的设备不兼容?
不用担心,现在每本 Packt 图书,您都可以免费获得该书的 DRM-free PDF 版本。
在任何地方、任何设备上阅读。搜索、复制并将您最喜欢的技术书籍中的代码直接粘贴到您的应用程序中。
福利不仅仅是这样,您还可以独享折扣、新闻通讯以及每天将免费精彩内容送到您的邮箱。
按照以下简单步骤,获取这些福利:
- 扫描二维码或访问以下链接
https://packt.link/free-ebook/9781804613986
-
提交您的购买证明
-
就这样!我们会直接通过电子邮件将您的免费 PDF 和其他福利发送给您。
第一部分:简介
第一部分的目标是向您介绍容器的概念,并解释为什么它们在软件行业中如此有用。您还将了解如何准备您的工作环境,以便使用 Docker。
本部分包括以下章节:
-
第一章,什么是容器以及为什么我应该使用它们?
-
第二章, 设置工作环境
第一章:1
什么是容器,为什么我应该使用它们?
本章将带你进入容器及其编排的世界。本书从最基础的地方开始,假设你对容器的了解有限,因此将为你提供一个非常实用的介绍。
本章将重点讨论软件供应链及其内部的摩擦。接着,我们将介绍容器,它们用于减少这些摩擦,并在此基础上增加企业级安全性。我们还将探讨容器及其周围生态系统是如何组装的。我们将特别指出,作为 Docker 和其他供应商下游产品构建块的上游 开源软件 (OSS) 组件,这些组件统一在代号 Moby 下。
本章涵盖以下主题:
-
什么是容器?
-
为什么容器很重要?
-
使用容器对我或我的公司有什么好处?
-
Moby 项目
-
Docker 产品
-
容器架构
完成本章后,你将能够做到以下几点:
-
使用类比,例如物理容器,用简短的几句话向感兴趣的外行解释容器是什么
-
通过类比,例如物理容器与传统运输、或者公寓与独栋住宅等,向感兴趣的外行解释为什么容器如此重要
-
至少列举出 Docker 产品(如 Docker Desktop)使用的四个上游开源组件
-
绘制一个 Docker 容器架构的高级草图
开始吧!
什么是容器?
软件容器是一个相当抽象的概念,因此从一个大多数人都很熟悉的类比开始可能会有所帮助。这个类比来自运输行业的集装箱。历史上,人们曾通过各种方式将货物从一个地方运送到另一个地方。在发明轮子之前,货物很可能是通过袋子、篮子或箱子由人类肩背运输,或者使用驴、骆驼或大象等动物运输。随着轮子的发明,运输效率有所提高,因为人类建造了可以推动手推车的道路,可以一次运输更多的货物。当第一台蒸汽驱动机和后来的汽油驱动引擎被引入时,运输变得更加高效。如今,我们通过飞机、火车、船只和卡车运输大量货物。同时,货物种类变得越来越多样,有时也变得更加复杂。在这几千年里,有一件事没有改变,那就是必须在目标地点卸货,并可能需要将货物重新装载到另一种运输工具上。举个例子,比如一个农民把装满苹果的手推车运到中央火车站,然后这些苹果与其他许多农民的苹果一起被装上火车。或者想象一个酿酒师将酒桶装载到卡车上运送到港口,然后这些酒桶被卸下并转移到船上,运往海外。
这种从一种运输方式卸货并装载到另一种运输方式的过程,实际上是一个非常复杂且繁琐的过程。每种类型的产品都有其特定的包装方式,因此必须按照特定的方式进行处理。此外,散装货物面临着被不道德的工人盗窃或在处理过程中损坏的风险。
图 1.1 – 水手从船上卸货
然后,集装箱出现了,它们彻底改变了运输行业。集装箱只是一个具有标准化尺寸的金属箱子。每个集装箱的长度、宽度和高度都是相同的。这一点非常重要。如果世界各国没有达成统一的标准尺寸,集装箱的使用就不会像现在这样成功。现在,借助标准化的集装箱,那些希望将货物从 A 地运输到 B 地的公司,会将货物装入这些集装箱中。然后,他们会联系运输公司,运输公司使用标准化的运输工具。这可以是可以装载集装箱的卡车,或者是每个车厢能够运输一个或多个集装箱的火车。最后,我们有专门运输大量集装箱的船只。运输公司从来不需要拆包和重新包装货物。对运输公司而言,集装箱只是一个黑箱,他们不关心里面是什么,通常情况下也无需关心。它只是一个具有标准尺寸的大铁箱子。将货物包装进集装箱的工作现在完全委托给了那些希望运输货物的一方,他们应该知道如何处理和包装这些货物。由于所有集装箱都有相同的约定形状和尺寸,运输公司可以使用标准化工具来处理集装箱;也就是说,起重机可以将集装箱从火车或卡车上卸下,并将它们装载到船上,反之亦然。只需一种类型的起重机就能处理所有随时间到来的集装箱。而且,运输工具也可以标准化,比如集装箱船、卡车和火车。正是由于这些标准化,运输货物的所有流程得以标准化,因此比集装箱出现之前更加高效。
图 1.2 – 集装箱船在港口装货
现在,你应该对为什么集装箱如此重要以及它们如何彻底改变整个运输行业有了深入的理解。我有意选择了这个类比,因为我们将在这里介绍的软件容器在所谓的软件供应链中,发挥的作用与集装箱在实体商品供应链中的作用完全相同。
那么,我们来看看将这整件事翻译到 IT 行业和软件开发中意味着什么,好吗?在过去,开发人员会开发新的应用程序。一旦应用程序在他们眼中完成,他们就会将该应用程序交给运维工程师,后者需要将其安装到生产服务器上并使其运行。如果运维工程师运气好,他们甚至能从开发人员那里得到一份相对准确的安装文档。到目前为止,一切都很好,生活也很简单。但当一个企业中有多个开发团队,各自开发出不同类型的应用程序,而这些应用程序又都需要安装到同一台生产服务器并保持运行时,事情就有点失控了。通常,每个应用程序都有一些外部依赖项,比如它是基于哪个框架构建的,使用了哪些库,等等。有时,两个应用程序使用的是相同的框架,但版本不同,可能兼容,也可能不兼容。随着时间的推移,我们的运维工程师的生活变得更加艰难。他们必须非常有创造性地思考如何在不破坏任何东西的情况下将不同的应用程序加载到他们的服务器上。现在,安装某个应用程序的新版本本身已经是一个复杂的项目,往往需要几个月的规划和测试。换句话说,软件供应链中充满了摩擦。
但是如今,公司越来越依赖软件,发布周期需要变得越来越短。公司已经不能再只在一年或两次发布应用更新了。应用需要在几周甚至几天内进行更新,有时甚至一天内更新多次。不遵守这一点的公司,可能会因为缺乏灵活性而面临倒闭风险。那么,解决办法是什么呢?最早的解决方案之一是使用虚拟机(VMs)。公司不是在同一台服务器上运行多个应用,而是将每个应用打包并在每个虚拟机上运行。这样,所有兼容性问题就消失了,生活似乎又变得美好了。不幸的是,这种幸福并没有持续多久。虚拟机本身相当笨重,因为它们每个都包含一个完整的操作系统,如 Linux 或 Windows Server,而这一切仅仅是为了运行一个单一的应用。这就好比在运输行业中,你为了运送一车香蕉,却使用了一整艘船。这简直是浪费!这种方式是永远无法盈利的。解决这个问题的终极方案是提供一种比虚拟机更轻量的解决方案,同时还能完美封装它需要运输的货物。这里的货物就是我们的开发人员编写的实际应用程序,此外——这一点很重要——还有应用程序的所有外部依赖,如框架、库、配置等。这种软件打包机制的“圣杯”便是Docker 容器。
开发人员将他们的应用程序、框架和库打包成 Docker 容器,然后将这些容器交给测试人员或运维工程师。对于测试人员和运维工程师来说,容器就像一个黑匣子。它是一个标准化的黑匣子。不过,所有的容器,无论内部运行什么应用,都可以被同等对待。工程师知道,只要任何一个容器能在他们的服务器上运行,那么其他容器也应该能运行。除了某些边缘情况(这些情况总是存在的),这一点基本成立。因此,Docker 容器是一种标准化的方式,用于打包应用及其依赖项。Docker 由此创造了“构建(Build)、运输(ship)和随处运行(run anywhere)”这一口号。
为什么容器如此重要?
现在,应用程序的新版本发布之间的时间变得越来越短,但软件本身并没有变得更简单。相反,软件项目的复杂性不断增加。因此,我们需要一种方法来驯服这个“野兽”,简化软件供应链。此外,我们每天都听到网络攻击不断上升的消息。许多知名公司受到了安全漏洞的影响。敏感的客户数据在这些事件中被盗,例如社会安全号码、信用卡信息、健康相关信息等等。但不仅仅是客户数据受到威胁——敏感的公司机密也被盗取。容器可以以多种方式提供帮助。在一份发布的报告中,Gartner 发现,运行在容器中的应用程序比没有运行在容器中的同类应用程序更加安全。容器使用 Linux 安全原语,例如 Linux 内核命名空间(namespaces),来将在同一台计算机上运行的不同应用程序进行沙箱隔离,并使用控制组(cgroups)来避免噪音邻居问题,即某个不良应用程序占用了服务器的所有可用资源,导致其他应用程序无法使用资源。由于容器镜像是不可变的,正如我们稍后将了解的那样,扫描它们以查找常见漏洞和暴露(CVEs)非常容易,这样可以提高我们应用程序的整体安全性。确保我们的软件供应链更加安全的另一种方法是让我们的容器使用内容信任(content trust)。内容信任确保容器镜像的作者就是他们所说的人,并且容器镜像的消费者可以确保该镜像在传输过程中没有被篡改。后者被称为中间人攻击(MITM)。
我刚才说的所有内容,当然,在不使用容器的情况下也能技术上实现,但由于容器引入了一个全球接受的标准,它们让实现这些最佳实践并强制执行变得更加容易。好的,但安全性并不是容器重要的唯一原因。还有其他原因。一个原因是,容器让开发者即使在自己的笔记本电脑上也能轻松模拟生产环境。如果我们能够将任何应用程序容器化,那么我们也可以将像 Oracle、PostgreSQL 或 MS SQL Server 这样的数据库容器化。现在,任何曾经在计算机上安装过 Oracle 数据库的人都知道,这并不是一件容易的事,而且会占用很多宝贵的硬盘空间。你肯定不想仅仅为了测试你开发的应用程序是否真正能端到端地工作,就把这个数据库安装在你的开发笔记本上。有了容器,我们可以像说“一、二、三”一样轻松地在容器中运行一个完整的关系型数据库。当我们完成测试时,只需停止并删除容器,数据库就会消失,且不会在我们的电脑上留下任何痕迹。由于容器相比虚拟机非常精简,因此在开发者的笔记本电脑上同时运行多个容器而不至于让笔记本电脑不堪重负是很常见的。容器重要的第三个原因是,运维人员终于可以集中精力做他们擅长的事——提供基础设施、运行和监控生产中的应用程序。当他们必须在生产系统上运行的所有应用程序都已经容器化时,运维人员就可以开始标准化他们的基础设施。每台服务器都变成了另一个Docker 主机。这些服务器上无需安装特殊的库或框架——只需要操作系统和像 Docker 这样的容器运行时。此外,运维人员也不再需要深入了解应用程序的内部结构,因为这些应用程序都是自包含在容器中,对于他们来说,这些容器就像运输行业人员看到的集装箱一样,应该是黑箱。
使用容器对我或我的公司有什么好处?
曾有人说过“…今天每个规模较大的公司都必须承认,它们需要成为一家软件公司…” 从这个角度来看,现代银行是一家专注于金融业务的软件公司。软件支撑着所有的业务,毫无疑问。随着每家公司都变成了软件公司,建立一个软件供应链变得至关重要。为了保持竞争力,公司必须确保其软件供应链既安全又高效。通过彻底的自动化和标准化,可以实现效率。但在安全性、自动化和标准化这三个领域中,容器技术已经证明了其优势。许多大型知名企业报告称,在将现有传统应用程序容器化并建立一个基于容器的完全自动化软件供应链后,它们能够将这些关键应用程序的维护成本降低 50%到 60%,并且能够将这些传统应用程序的新版本发布周期缩短至最多 90%。也就是说,容器技术的采用为这些公司节省了大量资金,同时加速了开发进程,缩短了上市时间。
Moby 项目
最初,当 Docker(公司)推出 Docker 容器时,一切都是开源的。当时,Docker 并没有任何商业产品。Docker Engine 是公司开发的一个单体软件,它包含了许多逻辑组件,例如容器运行时、网络库、RESTful(REST)API、命令行接口等。其他供应商或项目,如 Red Hat 或 Kubernetes,也在他们自己的产品中使用 Docker Engine,但大多数情况下,他们仅使用了其部分功能。例如,Kubernetes 并没有使用 Docker Engine 的网络库,而是提供了自己的网络方式。Red Hat 则不频繁更新 Docker Engine,倾向于在旧版本的 Docker Engine 上应用非官方补丁,但他们仍然称之为 Docker Engine。
由于种种原因,Docker 意识到必须采取措施,明确区分 Docker 的开源部分和商业部分。此外,公司还希望防止竞争对手利用 Docker 这个名称谋取利益。这也是 Moby 项目诞生的主要原因。它作为大多数 Docker 开发并继续开发的开源组件的伞形项目。这些开源项目不再使用 Docker 名称。Moby 项目提供了用于镜像管理、密钥管理、配置管理以及网络和资源配置的组件。此外,Moby 项目还包括一些特殊的 Moby 工具,例如用于将组件组装成可运行工件的工具。某些技术上属于 Moby 项目的组件已经由 Docker 捐赠给了 云原生计算基金会 (CNCF),因此不再出现在组件列表中。最著名的有 notary、containerd 和 runc,其中 notary 用于内容信任,后两者构成了容器运行时。
根据 Docker 的说法,“... Moby 是由 Docker 创建的一个开放框架,用于组装专门的容器系统,而无需重新发明轮子。它提供了一个“乐高积木”式的多个标准组件,以及一个将它们组装成 自定义平台 的框架....”*
Docker 产品
过去,直到 2019 年,Docker 将其产品线分为两个部分。一个是 社区版 (CE),它是闭源的,但完全免费;另一个是 企业版 (EE),同样是闭源的,并且需要每年授权。这些企业产品提供 24/7 支持,并且有缺陷修复支持。
2019 年,Docker 认为他们的业务实际上是两个非常不同的部分。因此,他们将 EE 部分分拆,并将其出售给了 Mirantis。Docker 本身希望重新聚焦于开发者,并为他们提供构建容器化应用程序所需的最佳工具和支持。
Docker Desktop
Docker 提供的产品包括 Docker Toolbox 和 Docker Desktop,其中 Docker Desktop 还提供适用于 Mac、Windows 和 Linux 的版本。这些产品主要面向开发者。Docker Desktop 是一款易于安装的桌面应用程序,可以在 macOS、Windows 或 Linux 机器上用于构建、调试和测试 Docker 化的应用程序或服务。Docker Desktop 是一个完整的开发环境,与各自底层操作系统的虚拟化框架、网络和文件系统深度集成。这些工具是运行 Docker 的最快、最可靠的方式。
注意
Docker Toolbox 已被弃用,不再进行积极开发。Docker 推荐改用 Docker Desktop。
Docker Hub
Docker Hub 是最受欢迎的容器镜像查找和共享服务。可以创建个人用户账户和组织账户,用户可以在这些账户下上传并共享 Docker 镜像,可以在团队、组织或更广泛的公众之间共享。公共账户是免费的,而私有账户需要购买多种商业许可证之一。在本书后续部分,我们将使用 Docker Hub 来下载现有的 Docker 镜像,并上传和分享我们自己的自定义 Docker 镜像。
Docker 企业版
Docker EE——现在由 Mirantis 拥有——由Universal Control Plane(UCP)和Docker Trusted Registry(DTR)组成,这两个组件都运行在 Docker Swarm 之上。它们都是 Swarm 应用程序。Docker EE 构建在 Moby 项目的上游组件之上,并添加了企业级功能,如基于角色的访问控制(RBAC)、多租户、混合集群的 Docker Swarm 和 Kubernetes、基于 Web 的 UI、内容信任,以及图像扫描等。
Docker Swarm
Docker Swarm 提供了一个强大而灵活的平台,用于在生产环境中部署和管理容器。它提供了构建、部署和管理应用程序所需的工具和功能,使您能够轻松自信地进行操作。
容器架构
现在,让我们讨论一下一个可以运行 Docker 容器的系统如何在高层次上进行设计。下图展示了安装了 Docker 的计算机的结构。请注意,安装了 Docker 的计算机通常被称为 Docker 主机,因为它可以运行或托管 Docker 容器:
图 1.3 – Docker 引擎的高级架构图
在前面的图中,我们可以看到三个关键部分:
-
在底部,我们有Linux 操作系统
-
在中间,我们有容器运行时
-
在顶部,我们有Docker 引擎
容器之所以能够存在,是因为 Linux 操作系统提供了一些基本原语,如命名空间、控制组、层级功能等,所有这些都被容器运行时和 Docker 引擎以特定的方式使用。Linux 内核命名空间,如进程 ID(pid
)命名空间或网络(net
)命名空间,使得 Docker 能够封装或沙箱化在容器内运行的进程。控制组确保容器不会遭遇噪声邻居效应(noisy-neighbor syndrome),即在一个容器中运行的单个应用可能会消耗整个 Docker 主机的大部分或所有可用资源。控制组允许 Docker 限制每个容器分配的资源,如 CPU 时间或内存量。Docker 主机上的容器运行时由 containerd 和 runc 组成。runc 是容器运行时的低级功能,如容器创建或管理,而 containerd 基于 runc 提供更高级的功能,如镜像管理、网络能力或通过插件扩展功能。两者都是开源的,并由 Docker 捐赠给 CNCF。容器运行时负责容器的整个生命周期。它从注册表中拉取容器镜像(即容器的模板),如果需要的话,基于该镜像创建容器,初始化并运行容器,最终在需要时停止并从系统中移除容器。Docker 引擎提供了容器运行时之上的额外功能,如网络库或对插件的支持。它还提供了一个 REST 接口,通过该接口可以自动化所有容器操作。本书中我们将经常使用的 Docker 命令行接口就是这个 REST 接口的消费者之一。
总结
在本章中,我们探讨了容器如何大幅减少软件供应链中的摩擦,并在此基础上使供应链变得更加安全。在下一章,我们将熟悉容器的基本概念。我们将学习如何运行、停止和删除容器以及如何操作容器。我们还将对容器的结构有一个相当好的概览。我们将第一次真正动手操作容器,亲自体验这些容器。所以,敬请期待!
进一步阅读
以下是一些链接,提供了更多关于我们在本章中讨论的主题的详细信息:
-
Docker overview:
docs.docker.com/engine/docker-overview/
-
The Moby project:
mobyproject.org/
-
Docker products:
www.docker.com/get-started
-
Docker Desktop:
www.docker.com/products/docker-desktop/
-
Cloud-Native Computing Foundation:
www.cncf.io/
-
containerd:
containerd.io/
-
Docker 企业版入门 3.1:
www.mirantis.com/blog/getting-started-with-docker-enterprise-3-1/
问题
请回答以下问题,以评估你的学习进度:
-
哪些说法是正确的(可以有多个答案)?
-
容器是一种轻量级虚拟机
-
容器只能在 Linux 主机上运行
-
容器只能运行一个进程
-
容器中的主要进程总是拥有 PID 1
-
容器是由 Linux 命名空间封装并由 cgroups 限制的一个或多个进程
-
-
请用自己的话,借助类比,解释什么是容器。
-
为什么容器被认为是 IT 领域的游戏规则改变者?列出三到四个理由。
-
当我们说“如果容器在某个平台上运行,那么它可以在任何地方运行”时,这是什么意思?列出两到三条原因,解释为什么这是正确的。
-
以下说法是对还是错:Docker 容器仅适用于基于微服务的现代绿地应用程序?请说明你的理由。
-
当企业将其传统应用程序容器化时,通常能节省多少成本?
-
20%
-
33%
-
50%
-
75%
-
-
容器基于 Linux 的两个核心概念是什么?
-
Docker Desktop 可以在哪些操作系统上使用?
答案
-
正确答案是 D 和 E。
-
Docker 容器对 IT 的意义,就像集装箱对运输行业的意义一样。它定义了如何包装货物的标准。在这种情况下,货物就是开发人员编写的应用程序。供应商(即开发者)负责将货物打包进容器,并确保一切如预期般适合。一旦货物被打包进容器,就可以进行运输。由于它是标准化的容器,运输商可以标准化其运输工具,如卡车、火车或船只。运输商并不关心容器内的具体内容。此外,从一种运输工具(如火车到船)转运的装卸过程可以高度标准化。这大大提高了运输效率。类比到 IT 中,运维工程师可以将开发人员构建的软件容器运送到生产系统,并以高度标准化的方式运行,而无需担心容器中的内容。它将正常运行。
-
容器被认为是游戏规则改变者的一些原因如下:
-
容器是自包含的,因此如果它们在一个系统上运行,就可以在任何可以运行 Docker 容器的地方运行。
-
容器可以在本地和云端运行,也可以在混合环境中运行。这对于今天的典型企业至关重要,因为它使得从本地到云的平滑过渡成为可能。
-
容器镜像是由最了解它们的人——开发者——构建或打包的。
-
容器镜像是不可变的,这对于良好的发布管理非常重要。
-
容器是基于封装(使用 Linux 命名空间和 cgroups)、机密管理、内容信任和镜像漏洞扫描的安全软件供应链的推动者。
-
-
容器可以在任何能够托管容器的系统上运行。之所以可能,是因为以下原因:
-
容器是自包含的黑匣子。它们不仅封装了应用程序,还封装了所有的依赖项,如库和框架、配置数据、证书等。
-
容器基于广泛接受的标准,如 OCI。
-
-
答案是错误的。容器对现代应用程序非常有用,也适用于将传统应用程序容器化。当企业进行后者时,其收益是巨大的。维护传统应用程序的成本节省可达到 50%或更多。据报道,这些传统应用程序的新版本发布周期可减少最多 90%。这些数字已经由真实的企业客户公开报道。
-
50% 或更多。
-
容器基于 Linux 命名空间(网络、进程、用户等)和 cgroups。前者帮助隔离在同一台机器上运行的进程,而后者用于限制特定进程可以访问的资源,如内存或网络带宽。
-
Docker Desktop 可在 macOS、Windows 和 Linux 上使用。
第二章:2
设置工作环境
在上一章中,我们了解了 Docker 容器是什么以及它们为何重要。我们了解了容器在现代软件供应链中解决了哪些问题。在本章中,我们将准备个人或工作环境,以便与 Docker 高效、有效地协作。我们将详细讨论如何为开发人员、DevOps 和操作员设置一个理想的工作环境,这个环境可以在使用 Docker 容器时进行使用。
本章涵盖以下主题:
-
Linux 命令行 Shell
-
Windows 的 PowerShell
-
安装和使用包管理器
-
安装 Git 并克隆代码仓库
-
选择和安装代码编辑器
-
在 macOS 或 Windows 上安装 Docker Desktop
-
安装 Docker Toolbox
-
在 Docker Desktop 上启用 Kubernetes
-
安装 minikube
-
安装 Kind
技术要求
本章内容需要你拥有一台安装了 macOS 或 Windows(最好是 Windows 11)操作系统的笔记本电脑或工作站。你还需要有免费的互联网接入权限来下载应用程序,并且需要有权限在笔记本电脑上安装这些应用程序。如果你使用的是 Linux 发行版操作系统(例如 Ubuntu 18.04 或更新版本),也可以跟随本书进行学习。我会尽量标明在哪些地方命令和示例与 macOS 或 Windows 上的有所不同。
Linux 命令行 Shell
Docker 容器最初是在 Linux 上为 Linux 开发的。因此,使用 Docker 时的主要命令行工具,也称为 Shell,是 Unix Shell;记住,Linux 来源于 Unix。大多数开发人员使用 Bash Shell。在一些轻量级的 Linux 发行版中,如 Alpine,Bash 没有安装,因此你必须使用更简单的 Bourne Shell,称为 sh
。每当我们在 Linux 环境中工作时,例如在容器内或在 Linux 虚拟机中,我们将根据可用性使用 /bin/bash
或 /bin/sh
。
尽管 Apple 的 macOS 不是 Linux 操作系统,但 Linux 和 macOS 都是 Unix 的变种,因此支持相同的一组工具。其中包括 Shell。因此,当在 macOS 上工作时,你很可能会使用 Bash 或 zsh Shell。
本书假设你已熟悉 Bash 和 PowerShell 中最基本的脚本命令,特别是在 Windows 环境下。如果你是完全的新手,我们强烈建议你先熟悉以下备忘单:
-
Linux 命令行备忘单 由 Dave Child 编写,可在
bit.ly/2mTQr8l
查阅 -
PowerShell 基本备忘单 可在
bit.ly/2EPHxze
查阅
Windows 的 PowerShell
在 Windows 计算机、笔记本电脑或服务器上,我们有多种命令行工具可用。最常见的是命令提示符,它在任何 Windows 计算机上都可用已有几十年了。它是一个非常简单的 shell。对于更高级的脚本,微软开发了 PowerShell,PowerShell 功能强大,并且在 Windows 上工作的工程师中非常流行。最后,在 Windows 10 或更高版本中,我们有所谓的 Windows 子系统 Linux,它允许我们使用任何 Linux 工具,例如 Bash 或 Bourne shell。除此之外,还有其他工具能在 Windows 上安装 Bash shell,例如 Git Bash shell。在本书中,所有命令都将使用 Bash 语法。大多数命令也能在 PowerShell 中运行。
因此,我们建议你使用 PowerShell 或任何其他 Bash 工具在 Windows 上使用 Docker。
安装和使用包管理器
在 Linux、macOS 或 Windows 笔记本电脑上安装软件的最简单方法是使用一个好的包管理器。在 macOS 上,大多数人使用 Homebrew,而在 Windows 上,Chocolatey 是一个不错的选择。如果你使用的是基于 Debian 的 Linux 发行版,例如 Ubuntu,那么大多数人选择使用默认安装的包管理器 apt
。
在 macOS 上安装 Homebrew
Homebrew 是 macOS 上最流行的包管理器,使用简单且非常多功能。在 macOS 上安装 Homebrew 很简单;只需按照 brew.sh/
上的说明操作:
-
简而言之,打开一个新的终端窗口并执行以下命令来安装 Homebrew:
$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
-
安装完成后,通过在终端输入
brew --version
来测试 Homebrew 是否正常工作。你应该会看到类似这样的输出:$ brew --versionHomebrew 3.6.16Homebrew/homebrew-core (git revision 025fe79713b; last commit 2022-12-26)Homebrew/homebrew-cask (git revision 15acb0b64a; last commit 2022-12-26)
-
现在,我们准备使用 Homebrew 安装工具和实用程序。如果我们想安装标志性的 Vi 文本编辑器(请注意,这不是本书中会使用的工具,它只是作为一个示例),我们可以这样做:
$ brew install vim
这将为你下载并安装编辑器。
在 Windows 上安装 Chocolatey
Chocolatey 是一个流行的 Windows 包管理器,基于 PowerShell 构建。要安装 Chocolatey 包管理器,请按照 chocolatey.org/
上的说明进行操作,或以管理员模式打开一个新的 PowerShell 窗口并执行以下命令:
PS> Set-ExecutionPolicy Bypass -Scope Process -Force; iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))
注意
重要的是以管理员身份运行前面的命令;否则,安装将不会成功。还需要注意的是,前面的命令是一个单独的命令,这里之所以分成多行,是因为行宽有限。
安装完 Chocolatey 后,使用 choco --version
命令进行测试。你应该看到类似于以下的输出:
PS> choco --version0.10.15
要安装一个应用程序,例如 Vi 编辑器,使用以下命令:
PS> choco install -y vim
-y
参数确保在安装过程中 Chocolatey 不会再次要求确认。正如之前提到的,我们在练习中不会使用 Vim;它仅作为示例使用。
注意
一旦 Chocolatey 安装了一个应用程序,你可能需要打开一个新的 PowerShell 窗口才能使用该应用程序。
安装 Git 并克隆代码库
我们将使用 Git 从 GitHub 仓库克隆本书附带的示例代码。如果你已经在电脑上安装了 Git,可以跳过这一部分:
-
在 macOS 上安装 Git,打开一个终端窗口并使用以下命令:
$ brew install git
-
在 Windows 上安装 Git,打开一个 PowerShell 窗口并使用 Chocolatey 安装它:
PS> choco install git -y
-
最后,在 Debian 或 Ubuntu 机器上,打开一个 Bash 控制台并执行以下命令:
$ sudo apt update && sudo apt install -y git
-
安装完 Git 后,验证它是否正常工作。在所有平台上,使用以下命令:
$ git --version
这将输出已安装的 Git 版本。在作者的 MacBook Air 上,输出如下:
git version 2.39.1
注意
如果你看到的是旧版本,那么你可能正在使用 macOS 默认安装的版本。通过运行 $ brew
install git
来使用 Homebrew 安装最新版本。
-
现在 Git 已经安装完毕,我们可以从 GitHub 克隆本书附带的源代码。执行以下命令:
$ cd ~$ git clone https://github.com/PacktPublishing/The-Ultimate-Docker-Container-Book
这将把主分支的内容克隆到你的本地文件夹 ~/The-Ultimate-Docker-Container-Book
中。这个文件夹现在将包含我们将在本书中一起完成的实验室的所有示例解决方案。如果你遇到困难,请参考这些示例解决方案。
现在我们已经安装了基础环境,让我们继续安装代码编辑器。
选择并安装代码编辑器
使用一个好的代码编辑器对于高效地使用 Docker 至关重要。当然,哪个编辑器最好是一个高度争议的话题,这取决于个人偏好。许多人使用 Vim,或者像 Emacs、Atom、Sublime、Visual Studio Code(VS Code)等其他编辑器,仅举几例。VS Code 是一个完全免费的轻量级编辑器,但它非常强大,并且支持 macOS、Windows 和 Linux。根据 Stack Overflow 的数据,它目前是最受欢迎的代码编辑器。如果你还没有选择其他编辑器,我强烈建议你试试 VS Code。
但如果你已经有了喜欢的代码编辑器,请继续使用它。只要你能编辑文本文件,就可以继续。如果你的编辑器支持 Dockerfile、JSON 和 YAML 文件的语法高亮,那就更好了。唯一的例外是 第六章,在容器中调试代码。该章中的示例将重点针对 VS Code 进行定制。
在 macOS 上安装 VS Code
按照以下步骤进行安装:
-
打开一个新的终端窗口并执行以下命令:
$ brew cask install visual-studio-code
-
一旦 VS Code 安装成功,导航到你的主目录:
$ cd ~
-
现在,从这个文件夹中打开 VS Code:
$ code The-Ultimate-Docker-Container-Book
VS 会启动并打开 The-Ultimate-Docker-Container-Book
文件夹,这是你刚刚下载的包含本书源代码的代码库,作为工作文件夹。
注意
如果你已经安装了 VS Code,而没有使用 brew,那么可以按照 code.visualstudio.com/docs/setup/mac#_launching-from-the-command-line
中的指南,将 code 添加到你的路径中。
- 使用 VS Code 探索你刚刚打开的文件夹中的代码。
在 Windows 上安装 VS Code
按照以下步骤进行安装:
-
以 管理员模式 打开一个新的 PowerShell 窗口并执行以下命令:
PS> choco install vscode -y
-
关闭你的 PowerShell 窗口并打开一个新的窗口,以确保 VS Code 已加入你的路径。
-
现在,导航到你的主目录:
PS> cd ~
-
现在,从该文件夹中打开 VS Code:
PS> code The-Ultimate-Docker-Container-Book
VS 将启动并打开 The-Ultimate-Docker-Container-Book
文件夹,这里就是你刚刚下载的包含本书源代码的仓库所在的工作文件夹。
- 使用 VS Code 探索你刚刚打开的文件夹中的代码。
在 Linux 上安装 VS Code
按照以下步骤进行安装:
-
在基于 Debian 或 Ubuntu 的 Linux 机器上,你可以使用 Snap 安装 VS Code。打开 Bash 终端并执行以下语句来安装 VS Code:
$ sudo snap install --classic code
-
如果你使用的 Linux 发行版不是基于 Debian 或 Ubuntu 的,请按照以下链接获取更多详细信息:
code.visualstudio.com/docs/setup/linux
。 -
一旦成功安装 VS Code,导航到你的主目录:
$ cd ~
-
现在,从该文件夹中打开 VS Code:
$ code The-Ultimate-Docker-Container-Book
VS 将启动并打开 The-Ultimate-Docker-Container-Book
文件夹,这里就是你刚刚下载的包含本书源代码的仓库所在的工作文件夹。
- 使用 VS Code 探索你刚刚打开的文件夹中的代码。
安装 VS Code 扩展
扩展是使 VS Code 成为如此多功能编辑器的关键。在所有三个平台(macOS、Windows 和 Linux)上,你可以以相同的方式安装 VS Code 扩展:
-
打开一个 Bash 控制台(或 Windows 中的 PowerShell),执行以下一组命令,安装我们将在本书接下来示例中使用的最基本扩展:
code --install-extension vscjava.vscode-java-packcode --install-extension ms-dotnettools.csharpcode --install-extension ms-python.pythoncode --install-extension ms-azuretools.vscode-dockercode --install-extension eamodio.gitlens
我们正在安装一些扩展,这些扩展可以使我们在使用 Java、C#、.NET 和 Python 时更加高效。我们还在安装一个旨在增强我们使用 Docker 的体验的扩展。
-
在成功安装上述扩展后,重新启动 VS Code 以激活这些扩展。你现在可以点击 VS Code 左侧活动面板中的 扩展 图标,查看所有已安装的扩展。
-
要获取已安装的所有扩展列表,可以使用以下命令:
$ code --list-extensions
接下来,我们安装 Docker Desktop。
在 macOS 或 Windows 上安装 Docker Desktop
如果你使用的是 macOS 或者已经在笔记本电脑上安装了 Windows 10 或更高版本,我们强烈推荐你安装 Docker Desktop。从 2022 年初开始,Docker 还发布了适用于 Linux 的 Docker Desktop 版本。Docker Desktop 在使用容器时能提供最佳体验。请按照以下步骤为你的系统安装 Docker Desktop:
- 无论你使用的是哪个操作系统,请访问 Docker 入门 页面:
www.docker.com/get-started
:
图 2.1 – 开始使用 Docker
-
在视图的右侧,你会看到一个蓝色的 注册 按钮,点击此按钮,如果你还没有 Docker Hub 账号,请创建一个。这个过程是免费的,但你需要一个账号来下载软件。
-
在视图的左侧,你会看到一个蓝色的按钮,标有 为 <你的操作系统> 下载,其中 <你的操作系统> 可以是 Linux、Mac 或 Windows,取决于你使用的操作系统。在作者的例子中,显示的是 Mac 作为目标操作系统,但由于作者使用的是配备苹果 M1 芯片的 Mac,因此显示的 CPU 类型错误。
点击按钮右侧的小下拉三角形,可以查看完整的可用下载列表:
图 2.2 – Docker Desktop 目标列表
选择适合你的版本,并观察安装包的下载过程。
- 下载完成后,继续安装,通常通过双击下载的安装包进行安装。
测试 Docker 引擎
现在你已经成功安装了 Docker Desktop,接下来让我们进行测试。我们将从命令行直接运行一个简单的 Docker 容器:
-
打开终端窗口,执行以下命令:
$ docker version
你应该会看到类似这样的内容:
图 2.3 – Docker Desktop 的 Docker 版本
在前面的输出中,我们可以看到它由两部分组成——一个客户端和一个服务器。在这里,服务器对应于 Docker 引擎,负责托管和运行容器。在撰写本文时,Docker 引擎的版本是20.10.21
。
-
为了检查是否能够运行容器,请在终端窗口中输入以下命令并按下 Enter:
$ docker container run hello-world
如果一切顺利,你的输出应该类似于以下内容:
图 2.4 – 在 macOS 上通过 Docker Desktop 运行 Hello-World
如果你仔细阅读前面的输出,你会注意到 Docker 没有找到名为 hello-world:latest
的镜像,因此决定从 Docker 镜像注册表下载它。下载完成后,Docker 引擎会根据该镜像创建一个容器并运行它。应用程序在容器内运行,并输出所有文本,开头是 Hello
from Docker!
。
这证明 Docker 已经正确安装并在你的机器上正常工作。
-
让我们尝试另一个有趣的测试镜像,通常用于检查 Docker 安装。运行以下命令:
$ docker container run rancher/cowsay Hello
你应该会看到以下或类似的输出:
图 2.5 – 从 Rancher 运行 cowsay 镜像
很棒 – 我们已经确认 Docker 引擎在本地计算机上正常工作。现在,让我们确保 Docker Desktop 也同样工作正常。
测试 Docker Desktop
根据你所使用的操作系统(Linux、Mac 或 Windows),你可以在不同的区域访问 Docker Desktop 的上下文菜单。无论如何,你要寻找的符号是那个携带容器的小鲸鱼。以下是 Mac 上找到的符号:
-
Mac:你将在屏幕顶部菜单栏的右侧找到该图标。
-
Windows:你将在 Windows 系统托盘中找到该图标。
-
通过 应用程序 菜单打开
Docker Desktop
,这将启动 Docker 菜单图标并打开 Docker 仪表盘,显示 Docker Desktop 的状态。
找到 Docker Desktop 的上下文菜单后,请按照以下步骤操作:
- 点击 鲸鱼 图标以显示 Docker Desktop 的上下文菜单。在作者的 Mac 上,它看起来是这样的:
图 2.6 – Docker Desktop 的上下文菜单
- 从菜单中选择 仪表盘。Docker Desktop 的仪表盘将打开:
图 2.7 – Docker Desktop 的仪表盘
我们可以看到仪表盘有多个标签,位于视图的左侧。目前,hello-world
和 rancher/cowsay
Docker 镜像的状态都是 Exited。
请花些时间探索一下这个仪表盘。如果你迷路了也不用担心,随着我们继续本书的各章内容,一切会变得更加清晰。
- 探索完成后,关闭仪表盘窗口。
注意
关闭仪表盘并不会停止 Docker Desktop。应用程序和 Docker 引擎会继续在后台运行。如果由于某种原因你想完全停止系统上的 Docker,可以从 步骤 1 中显示的上下文菜单中选择 退出 Docker Desktop。
恭喜你,已经成功在工作电脑上安装并测试了 Docker Desktop!接下来,我们将继续介绍一些其他有用的工具。
安装 Docker Toolbox
Docker Toolbox 已经推出几年,面向开发者使用。它在 Docker Desktop 等更新工具之前推出。Toolbox 允许用户在任何 macOS 或 Windows 计算机上优雅地操作容器。容器必须在 Linux 主机上运行。Windows 和 macOS 都无法原生运行容器。因此,我们需要在笔记本电脑上运行一个 Linux 虚拟机,在该虚拟机上运行容器。Docker Toolbox 在我们的笔记本上安装了 VirtualBox,用于运行我们所需的 Linux 虚拟机。
注意
Docker Toolbox 最近已被弃用,因此我们将不再进一步讨论它。不过,在某些特定场景下,它仍可能具有一定的参考价值,因此我们在此提及它。
在 Docker Desktop 上启用 Kubernetes
Docker Desktop 自带 Kubernetes 的集成功能。
什么是 Kubernetes?
Kubernetes 是一个强大的平台,用于自动化容器化应用程序的部署、扩展和管理。无论你是开发者、DevOps 工程师还是系统管理员,Kubernetes 都提供了你所需的工具和抽象层,帮助你以可扩展和高效的方式管理容器和应用程序。
该支持默认是关闭的。但不用担心——它非常容易开启:
-
打开 Docker Desktop 的仪表板。
-
在左上角,选择齿轮图标。这样会打开设置页面。
-
在左侧,选择Kubernetes选项卡,然后勾选启用 Kubernetes复选框:
图 2.8 – 在 Docker Desktop 上启用 Kubernetes
- 点击应用并重启按钮。
现在,你需要耐心等待,因为 Docker 正在下载所有支持基础设施,并启动 Kubernetes。
一旦 Docker 重启完成,你就可以开始使用 Kubernetes。请参阅安装 minikube部分,了解如何测试 Kubernetes。
安装 minikube
如果你使用的是 Docker Desktop,那么你可能根本不需要 minikube,因为 Docker Desktop 已经原生支持 Kubernetes。如果你不能使用 Docker Desktop,或者由于某些原因,你只能使用不支持 Kubernetes 的旧版本工具,那么安装 minikube 会是一个不错的选择。minikube 会在你的工作站上配置一个单节点的 Kubernetes 集群,并且可以通过 kubectl 访问,kubectl 是用于与 Kubernetes 交互的命令行工具。
在 Linux、macOS 和 Windows 上安装 minikube
要在 Linux、macOS 或 Windows 上安装 minikube,请访问以下链接:kubernetes.io/docs/tasks/tools/install-minikube/
。
请仔细遵循指示。具体来说,请执行以下操作:
- 确保你已经安装了一个虚拟化管理程序,详细信息请参见这里:
图 2.9 – minikube 的先决条件
- 在 1 安装 下,选择适合你的组合。例如,你可以看到作者选择的目标机器是 MacBook Air M1 笔记本电脑:
图 2.10 – 选择配置
使用 Homebrew 在 MacBook Air M1 上安装 minikube
按照以下步骤操作:
-
在终端窗口中,执行之前显示的步骤。在作者的案例中,步骤如下:
$ brew install minikube
-
使用以下命令测试安装:
$ brew versionminikube version: v1.28.0commit: 986b1ebd987211ed16f8cc10aed7d2c42fc8392f
-
现在,我们准备开始创建集群。让我们从默认配置开始:
$ minikube start
注意
minikube 允许你定义单节点和多节点集群。
-
第一次执行时,由于 minikube 需要下载所有的 Kubernetes 二进制文件,可能会花费一些时间。完成后,屏幕上的最后一行输出应该类似于:
Done! kubectl is now configured to use "minikube" cluster and "default" namespace by default
太好了,我们已经成功在系统上安装了 minikube!让我们尝试通过创建一个集群并在其中运行我们的第一个容器应用来玩一下 minikube。如果以下命令对你来说不太明白,别担心,我们将在接下来的章节中详细讨论。
测试 minikube 和 kubectl
让我们开始吧。请仔细按照以下步骤操作:
-
让我们尝试使用 kubectl 访问我们的集群。首先,我们需要确保 kubectl 选择了正确的上下文。如果你之前安装了 Docker Desktop,现在又安装了 minikube,可以使用以下命令:
$ kubectl config get-contexts
你应该看到这个:
图 2.11 – 安装 minikube 后 kubectl 的上下文列表
紧跟在名为 minikube 的上下文旁边的星号告诉我们,这是当前的上下文。因此,在使用 kubectl 时,我们将操作由 minikube 创建的新集群。
-
现在,让我们使用这个命令查看集群中有多少个节点:
$ kubectl get nodes
你应该看到类似这样的输出。请注意,显示的版本可能与你的情况有所不同:
图 2.12 – 显示 minikube 集群的节点列表
这里我们有一个单节点集群。该节点的角色是控制平面,这意味着它是主节点。一个典型的 Kubernetes 集群包含几个主节点和多个工作节点。我们在这里使用的 Kubernetes 版本是 v1.25.3
。
-
现在,让我们尝试在这个集群上运行一些应用。我们将使用 Nginx,这是一个流行的 Web 服务器。如果你之前已经克隆了这本书附带的 GitHub 仓库到你主目录下的
The-Ultimate-Docker-Container-Book
文件夹中,那么你应该会在这个文件夹中找到一个包含.yaml
文件的子文件夹,我们将用这个文件进行测试:-
打开一个新的终端窗口。
-
导航到
The-Ultimate-Docker-Container-Book
文件夹:
$ cd ~/The-Ultimate-Docker-Container-Book
- 使用以下命令创建一个运行 Nginx 的 pod:
$ kubectl apply -f setup/nginx.yaml
-
你应该看到这个输出:
pod/nginx created
- 我们可以通过 kubectl 再次检查 pod 是否正在运行:
$ kubectl get pods
我们应该看到这个:
NAME READY STATUS RESTARTS AGEnginx 1/1 Running 0 11m
这表明我们有一个正在运行 Nginx 的 pod,且它已重启 0 次。
-
为了访问 Nginx 服务器,我们需要用以下命令公开在 pod 中运行的应用程序:
$ kubectl expose pod nginx --type=NodePort --port=80
这是我们从笔记本访问 Nginx 的唯一方式——例如,通过浏览器。通过上述命令,我们正在创建一个 Kubernetes 服务,输出中已显示该命令的生成情况:
service/nginx exposed
-
我们可以使用 kubectl 列出集群中定义的所有服务:
$ kubectl get services
我们应该看到这个:
图 2.13 – minikube 集群上的服务列表
在上述输出中,我们可以看到刚刚创建的第二个服务 Nginx。该服务属于 NodePort
类型;pod 的端口 80
已被映射到我们 minikube 中 Kubernetes 集群节点的端口 30373
。
-
现在,我们可以使用 minikube 创建一个隧道连接到我们的集群,并用正确的 URL 打开浏览器访问 Nginx web 服务器。使用以下命令:
$ minikube service nginx
在终端窗口中的输出将如下所示:
图 2.14 – 打开对 minikube 上 Kubernetes 集群的访问
上述输出显示,minikube 为 nginx 服务创建了一个隧道,将节点端口 30373
映射到主机上的端口 64171
,主机就是我们的笔记本。
- 一个新的浏览器标签页 应该已自动打开,并将你导航至
http://127.0.0.1:64171
。你应该能看到 Nginx 的欢迎页面:
图 2.15 – 在 minikube 上运行 Kubernetes 集群的 Nginx 欢迎页面
太棒了,我们已经成功地在 minikube 上的小型单节点 Kubernetes 集群上运行并访问了 Nginx web 服务器!一旦完成实验,到了清理的时候:
-
通过在终端窗口中按 Ctrl + C 停止到集群的隧道连接。
-
删除集群中的 nginx 服务和 pod:
$ kubectl delete service nginx$ kubectl delete pod nginx
-
使用以下命令停止集群:
$ minikube stop
-
你应该看到这个:
图 2.16 – 停止 minikube
使用多节点 minikube 集群
有时,单节点集群的测试并不够。别担心——minikube 会帮你解决这个问题。按照以下说明,在 minikube 中创建一个真正的多节点 Kubernetes 集群:
-
如果我们想在 minikube 中使用包含多个节点的集群,可以使用以下命令:
$ minikube start --nodes 3 –p demo
上述命令创建了一个包含三个节点的集群,并将其命名为 demo
。
-
使用 kubectl 列出所有集群节点:
$ kubectl get nodesNAME STATUS ROLES AGE VERSIONdemo Ready control-plane 84s v1.25.3demo-m02 Ready <none> 45s v1.25.3demo-m03 Ready <none> 22s v1.25.3
我们有一个包含 3 个节点的集群,其中 demo
节点是主节点,剩下的两个节点是工作节点。
-
我们在这里不打算继续这个示例,所以使用以下命令来停止集群:
$ minikube stop -p demo
-
使用以下命令删除系统上的所有集群:
$ minikube delete --all
这将删除默认集群(在我们案例中名为 minikube)和演示集群。
有了这个,我们将进入下一个在容器和 Kubernetes 工作中非常有用的工具。你应该在工作计算机上安装并随时准备好使用它。
安装 Kind
Kind 是另一个流行的工具,可以在本地机器上运行一个多节点 Kubernetes 集群。它非常容易安装和使用。让我们开始吧:
-
使用适合你平台的包管理器来安装 Kind。你可以在这里找到更详细的安装过程信息:
kind.sigs.k8s.io/docs/user/quick-start/
:- 在 MacOS 上,使用 Homebrew 通过以下命令安装 Kind:
$ brew install kind
- 在 Windows 机器上,使用 Chocolatey 通过以下命令来做同样的事情:
$ choco install kind -y
- 最后,在 Linux 机器上,你可以使用以下脚本从其二进制文件安装 Kind:链接
$ curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.17.0/kind-linux-amd64$ chmod +x ./kind$ sudo mv ./kind /usr/local/bin/kind
-
安装 Kind 后,使用以下命令进行测试:
$ kind version
如果你使用的是 Mac,它应该会输出类似这样的内容:
kind v0.17.0 go1.19.2 darwin/arm64
-
现在,尝试创建一个由一个主节点和两个工作节点组成的简单 Kubernetes 集群。使用以下命令来实现:
$ kind create cluster
一段时间后,你应该会看到以下输出:
图 2.17 – 使用 Kind 创建集群
-
要验证集群是否已创建,请使用以下命令:
$ kind get clusters
上述输出显示,正好有一个名为kind的集群,这是默认名称。
-
我们可以使用
--name
参数创建一个具有不同名称的额外集群,如下所示:$ kind create cluster --name demo
-
列出集群后将显示如下:
$ kind show clustersKinddemo
这按预期工作。
测试 Kind
现在我们已经使用 Kind 创建了两个示例集群,让我们使用 kubectl 来操作其中一个集群,并在其上运行第一个应用程序。我们将使用 Nginx,就像我们在 minikube 中做的那样:
-
现在我们可以使用kubectl访问并操作我们刚刚创建的集群。在创建集群时,Kind 也更新了我们的 kubectl 配置文件。我们可以使用以下命令进行再次确认:
$ kubectl config get-contexts
它应该会产生以下输出:
图 2.18 – 为 kubectl 定义的上下文列表
你可以看到,kind 和 demo 集群是已知集群列表的一部分,并且 demo 集群是当前 kubectl 的上下文。
-
如果星号显示的是另一个集群作为当前集群,使用以下命令将 demo 集群设置为当前集群:
$ kubectl config use-context kind-demo
-
让我们列出
sample-cluster
集群的所有节点:$ kubectl get nodes
输出应该是这样的:
图 2.19 – 显示 Kind 集群上节点列表
-
现在,让我们尝试在这个集群上运行第一个容器。我们将像之前一样使用我们信任的 Nginx Web 服务器。使用以下命令来运行它:
$ kubectl apply -f setup/nginx.yaml
输出应如下所示:
pod/nginx created
-
要访问 Nginx 服务器,我们需要使用 kubectl 进行端口转发。使用以下命令实现:
$ kubectl port-forward nginx 8080 80
输出应如下所示:
Forwarding from 127.0.0.1:8080 -> 80Forwarding from [::1]:8080 -> 80
- 打开一个新的浏览器标签页,访问
http://localhost:8080
;你应该能看到 Nginx 的欢迎界面:
图 2.20 – 在 Kind 集群上运行的 Nginx 欢迎界面
-
一旦你完成了对 Nginx 的操作,使用以下命令从集群中删除该 Pod:
$ kubectl delete –f setup/nginx.yaml
-
在我们继续之前,让我们清理并删除刚刚创建的两个集群:
$ kind delete cluster --name kind$ kind delete cluster --name demo
通过这些步骤,我们已经安装了成功在本地机器上与容器一起工作的所有工具。
总结
本章中,我们设置并配置了我们的个人或工作环境,以便能够高效地与 Docker 容器工作。这同样适用于开发人员、DevOps 和运维工程师。
我们从一个应该是每个严肃工程师手边的包管理器开始。它使得安装和管理应用程序及工具变得更加容易。接下来,我们确保使用了一个良好的脚本 shell —— 一个强大的编辑器。然后,我们确保安装了 Docker Desktop,利用它我们可以原生运行和测试容器。最后,我们在机器上安装并快速测试了 minikube 和 Kind。这些工具可以用来在本地 Kubernetes 集群上运行和测试容器。
在下一章中,我们将学习容器的关键概念。例如,我们将探讨如何运行、停止、列出和删除容器,但更重要的是,我们还将深入了解容器的结构。
进一步阅读
考虑以下链接以便进一步阅读:
-
通过 Docker Machine 在 Hyper-V 上运行 Docker:
bit.ly/2HGMPiI
问题
基于本章所涵盖的内容,请回答以下问题:
-
为什么我们要在本地计算机上安装和使用包管理器?
-
使用 Docker Desktop,您可以开发和运行 Linux 容器。
-
正确
-
错误
-
-
为什么良好的脚本编写技能(如 Bash 或 PowerShell)对于高效使用容器至关重要?
-
请列举三到四个 Docker 认证可以运行的 Linux 发行版。
-
你在系统上安装了 minikube。你会用这个工具来做哪些任务?
答案
以下是本章问题的答案:
-
Linux 系统中的
apk
、apt
或yum
包管理器,macOS 中的 Homebrew,以及 Windows 中的 Chocolatey,都使得自动化安装应用程序、工具和库变得更加容易。相比用户在安装过程中需要点击一系列视图进行交互的过程,自动化安装更具可重复性。 -
答案是 正确。是的,使用 Docker Desktop,你可以开发和运行 Linux 容器。虽然本书未涉及,但使用此版本的 Docker Desktop,也可以开发和运行原生的 Windows 容器。使用 macOS 和 Linux 版本时,你只能开发和运行 Linux 容器。
-
脚本用于自动化流程,从而避免人为错误。构建、测试、共享和运行 Docker 容器这些任务应始终进行自动化,以提高其可靠性和可重复性。
-
以下 Linux 发行版已认证可以运行 Docker:Red Hat Linux(RHEL)、CentOS、Oracle Linux、Ubuntu 等。
-
minikube 使得在本地计算机上(如开发者的笔记本)定义和运行单节点或多节点集群成为可能。通过 minikube,你可以在本地机器上运行和测试容器化应用程序,无需依赖像 AWS、Microsoft Azure 或 Google Cloud 上运行的远程 Kubernetes 集群。
第二部分:容器化基础
本部分将教你如何启动、停止和删除容器,并如何检查容器以获取更多的元数据。此外,它还解释了如何运行额外的进程,以及如何附加到已运行容器中的主进程。还涵盖了如何从容器中检索日志信息,这些信息是由容器内运行的进程生成的。最后,本部分介绍了容器的内部工作原理,包括 Linux 命名空间和组等内容。
-
第三章,掌握容器
-
第四章,创建和管理容器镜像
-
第五章,数据卷和配置
-
第六章,调试运行在容器中的代码
-
第七章,测试 运行在容器中的应用程序
-
第八章,通过 Docker 小技巧提高生产力
第三章:3
掌握容器
在上一章中,你学会了如何为 Docker 的高效、无摩擦使用优化你的工作环境。本章我们将动手实践,了解在使用容器时必须掌握的一切重要知识。
以下是本章将要涵盖的主题:
-
运行第一个容器
-
启动、停止和删除容器
-
检查容器
-
进入正在运行的容器
-
连接到一个正在运行的容器
-
获取容器日志
-
容器的结构
完成本章后,你将能够做以下事情:
-
基于现有镜像运行、停止和删除容器,例如 Nginx、BusyBox 或 Alpine
-
列出系统上所有的容器
-
检查运行中或停止的容器的元数据
-
获取容器内运行的应用程序产生的日志
-
在已经运行的容器中运行像
/bin/sh
这样的进程 -
连接终端到一个已运行的容器
-
用你自己的话向一位感兴趣的外行解释容器的基础原理
技术要求
本章中,你应该已经在你的 Linux 工作站、macOS 或 Windows 电脑上安装了 Docker Desktop。如果你使用的是旧版本的 Windows 或 Windows 10 家庭版,你应该安装并准备好使用 Docker Toolbox。在 macOS 上,使用终端应用程序,在 Windows 上,使用 PowerShell 控制台或 Git Bash 来尝试你将学习的命令。
运行第一个容器
在我们开始之前,我们需要确保 Docker 已经正确安装并准备好接受你的命令。打开一个新的终端窗口,输入以下命令(注意:不要输入 $
符号,它只是你的提示符占位符):
$ docker version
如果一切正常,你应该在终端窗口中看到已安装的 Docker 客户端和服务器版本的输出。写作时,它是这样的:
图 3.1 – docker version 命令的输出
如你所见,我在我的 MacBook Air M1 笔记本上安装的版本是 20.10.20
。
如果这对你不起作用,那么你的安装可能有问题。请确保你按照上一章的说明,正确地在系统上安装了 Docker Desktop。
好的,现在准备好看到一些操作了。请在终端窗口中输入以下命令并按下 回车 键:
$ docker container run alpine echo "Hello World"
当你第一次运行前面的命令时,你应该在终端窗口看到类似这样的输出:
图 3.2 – 第一次运行 Alpine 容器
哇,这么简单!我们再试一次运行同样的命令:
$ docker container run alpine echo "Hello World"
第二次、第三次或第 N 次运行前面的命令时,你应该只在终端看到以下输出:
Hello World
尝试推理为什么第一次运行命令时,输出与后续所有时间的输出不同。但如果你无法理解,也不用担心;我们将在本章接下来的部分详细解释原因。
启动、停止和删除容器
你在上一节成功地运行了一个容器。现在,我们想要详细探讨一下究竟发生了什么以及为什么发生。让我们再次看看我们使用的命令:
$ docker container run alpine echo "Hello World"
这个命令包含多个部分。首先,最重要的是我们有单词 docker
。这是 Docker container
的名称,表示我们正在使用的上下文,比如 container
、image
或 volume
。由于我们想要运行一个容器,因此我们的上下文是 container
。接下来是我们想要在给定上下文中执行的实际命令,即 run
。
让我总结一下 – 到目前为止,我们有 docker container run
,意思是,“嘿,Docker,我们想要运行一个容器。”
现在我们还需要告诉 Docker 运行哪个容器。在本例中,这是所谓的 alpine
容器。
Alpine Linux
alpine
是基于 Alpine Linux 的最小 Docker 镜像,具有完整的软件包索引,大小仅约 5 MB。它是一个由 Alpine 开源项目和 Docker 官方支持的镜像。
最后,我们需要定义在容器运行时将执行的进程或任务。在我们的例子中,这是命令的最后一部分,echo "``Hello World"
。
以下图示可能帮助你更好地理解整个过程:
图 3.3 – 解释 docker run 命令
现在我们已经理解了运行容器的各种命令部分,让我们尝试运行另一个容器,并在其中执行不同的进程。在终端中输入以下命令:
$ docker container run centos ping -c 5 127.0.0.1
你应该在终端窗口中看到类似以下的输出:
图 3.4 – 在 CentOS 容器中运行 ping 命令
更改的是这一次我们使用的容器镜像是 centos
,并且在 centos
容器中执行的进程是 ping -c 5 127.0.0.1
,它会 ping 本地回环 IP 地址(127.0.0.1
)五次,直到停止。
CentOS
centos
是 CentOS Linux 的官方 Docker 镜像,CentOS 是一个社区支持的发行版,源代码由 Red Hat 为 Red Hat Enterprise Linux (RHEL) 免费提供给公众。
让我们详细分析输出。第一行如下:
Unable to find image 'centos:latest' locally
这告诉我们 Docker 在本地缓存中没有找到名为 centos:latest
的镜像。因此,Docker 知道它必须从某个存储容器镜像的注册表中拉取镜像。默认情况下,你的 Docker 环境配置为从 Docker Hub (docker.io
) 拉取镜像。第二行表示如下:
latest: Pulling from library/centos
接下来的三行输出如下:
52f9ef134af7: Pull completeDigest: sha256:a27fd8080b517143cbbbab9dfb7c8571c4...
Status: Downloaded newer image for centos:latest
这告诉我们 Docker 已经成功地从 Docker Hub 拉取了 centos:latest
镜像。输出的后续所有行都是我们在容器内运行的进程生成的,案例中的进程是 ping
工具。如果你到目前为止一直很专心,你可能已经注意到 latest
关键字出现了好几次。每个镜像都有一个版本(也称为 tag
),如果我们没有明确指定版本,Docker 会默认认为它是 latest
。
如果我们再次在系统上运行之前的容器,输出的前五行将会缺失,因为这时 Docker 会发现容器镜像已在本地缓存,因此无需重新下载。试试看,验证一下我刚才说的内容。
运行随机小知识问题容器
在本章接下来的部分,我们需要一个持续在后台运行并输出一些有趣内容的容器。这就是为什么我们选择了一个生成随机小知识问题的算法。提供免费的随机小知识 API 可以在 jservice.io/
上找到。
现在,目标是让一个进程在容器内运行,每隔 5 秒钟生成一个新的随机小知识问题,并将问题输出到 STDOUT
。以下脚本将实现这一目标:
while :do
curl -s http://jservice.io/api/random | jq '.[0].question'
sleep 5
done
如果你使用 PowerShell,前面的命令可以转换为以下内容:
while ($true) { Invoke-WebRequest -Uri "http://jservice.io/api/random" -Method GET -UseBasicParsing |
Select-Object -ExpandProperty Content |
ConvertFrom-Json |
Select-Object -ExpandProperty 0 |
Select-Object -ExpandProperty question
Start-Sleep -Seconds 5
}
提示
ConvertFrom-Json
cmdlet 要求导入 Microsoft.PowerShell.Utility
模块。如果尚未导入,你需要在运行脚本之前执行 Import-Module Microsoft.PowerShell.Utility
。
在终端窗口中尝试。通过按 Ctrl + C 停止脚本。输出应该类似于以下内容:
图 3.5 – 输出随机小知识
每个响应都是一个不同的小知识问题。你可能需要首先在 Linux、macOS 或 Windows 电脑上安装 jq
。jq
是一个非常方便的工具,常用于优雅地过滤和格式化 JSON 输出,增加其在屏幕上的可读性。如果需要,使用你的包管理器安装 jq
。在 Windows 上,使用 Chocolatey 时,命令如下:
$ choco install jq
在 Mac 上使用 Homebrew 时,你需要输入以下命令:
$ brew install jq
现在,让我们在 alpine
容器中运行这段逻辑。因为这不仅仅是一个简单的命令,我们希望将前面的脚本封装在一个脚本文件中并执行它。为了简化操作,我创建了一个名为 fundamentalsofdocker/trivia
的 Docker 镜像,包含了所有必要的逻辑,因此我们可以直接在这里使用它。稍后,当我们介绍 Docker 镜像时,我们将进一步分析这个容器镜像。现在,让我们直接使用它。执行以下命令将容器作为后台服务运行。在 Linux 中,后台服务也称为守护进程(daemon):
$ docker container run --detach \ --name trivia fundamentalsofdocker/trivia:ed2
重要提示
我们使用了 \
字符来允许在单个逻辑命令中换行,因为它在一行上无法完全显示。这是我们使用的 Shell 脚本的一个特性。在 PowerShell 中,使用反引号(`
)代替。
还要注意,在 zsh
中,你可能需要按 Shift + Enter,而不仅仅是 Enter,在 \
字符后开始新的一行。否则,你会收到错误信息。
在前面的表达式中,我们使用了两个新的命令行参数,--detach
和 --name
。现在,--detach
告诉 Docker 将容器中的进程作为 Linux 守护进程运行。
--name
参数则可以用来为容器指定一个明确的名称。在前面的示例中,我们选择的名称是 trivia
。如果在运行容器时没有指定明确的容器名称,Docker 会自动为容器分配一个随机但唯一的名称。这个名称将由一个著名科学家的名字和一个形容词组成。例如,boring_borg
或 angry_goldberg
。Docker 的工程师们真是太幽默了,不是吗?
最后,我们正在运行的容器是从 fundamentalsofdocker/trivia:ed2
镜像派生出来的。注意,我们还为容器使用了一个标签 ed2
。这个标签只是告诉我们,这个镜像最初是为本书的第二版创建的。
一个重要的要点是,容器的名称在系统上必须是唯一的。让我们确保 trivia
容器已经启动并运行:
$ docker container ls -l
这应该会给我们类似以下内容:
图 3.6 – 最后运行的容器详情
前面输出中的一个重要部分是 STATUS
列,在这种情况下是 Up 6 minutes
。也就是说,容器已经运行了 6 分钟。
如果你还不熟悉之前的 Docker 命令,不必担心;我们将在下一节中再讲到它。
为了完成这一部分,让我们使用以下命令停止并删除 trivia
容器:
$ docker rm --force trivia
前面的命令虽然强制删除了我们系统中的 trivia
容器,但输出中只会显示容器的名称 trivia
。
现在,是时候学习如何列出我们系统上运行或悬挂的容器了。
列出容器
随着时间的推移,我们不断运行容器,系统中会有很多容器。为了找出当前在主机上运行的容器,我们可以使用 container ls
命令,如下所示:
$ docker container ls
这将列出所有当前正在运行的容器。这样的列表可能类似于以下内容:
图 3.7 – 系统上所有正在运行的容器列表
默认情况下,Docker 输出七列,含义如下:
列 | 描述 |
---|---|
Container ID |
这是容器唯一 ID 的简短版本。它是一个 SHA-256 值,SHA-256(安全哈希算法 256 位)是一种广泛使用的加密哈希函数,它接收一个输入并生成固定大小(256 位)的输出,称为哈希值。完整的 ID 是 64 个字符长。 |
Image |
这是用来实例化此容器的容器镜像的名称。 |
Command |
这是用于在容器中运行主进程的命令。 |
Created |
这是容器创建的日期和时间。 |
Status |
这是容器的状态(已创建、重启中、运行中、移除中、暂停中、已退出或已死)。 |
Ports |
这是已映射到主机的容器端口列表。 |
Names |
这是分配给容器的名称(注意:同一个容器可以有多个名称)。 |
表格 3.1 – docker container ls 命令列出的列的描述
如果我们不仅要列出当前正在运行的容器,而是要列出系统中定义的所有容器,我们可以使用-a
或--all
命令行参数,如下所示:
$ docker container ls --all
这将列出所有状态下的容器,例如created
(已创建)、running
(正在运行)或exited
(已退出)。
有时,我们只想列出所有容器的 ID。为此,我们可以使用-q
或--quiet
参数:
$ docker container ls --quiet
你可能会想,这个命令什么时候有用呢?我将在这里展示一个非常有用的命令:
$ docker container rm --force $(docker container ls --all --quiet)
放松一下,深呼吸。然后,尝试找出前面的命令做了什么。在你找到答案或放弃之前,不要继续阅读。
这里是解决方案:上述命令强制删除系统上当前定义的所有容器,包括已停止的容器。rm
命令代表“移除”,稍后会进行解释。
在上一节中,我们在列表命令中使用了-l
参数,也就是docker container ls -l
。尝试使用docker help
命令找出-l
参数的含义。你可以如下调用帮助信息:
$ docker container ls --help
既然你已经知道如何列出系统上已创建、正在运行或已停止的容器,接下来我们来学习如何停止和重启容器。
停止和启动容器
有时,我们希望(暂时)停止一个正在运行的容器。让我们尝试使用之前的 trivia 容器来进行操作:
-
使用此命令再次运行容器:
$ docker container run -d --name trivia fundamentalsofdocker/trivia:ed2
-
现在,如果我们想停止这个容器,可以通过执行以下命令来实现:
$ docker container stop trivia
当你尝试停止 trivia 容器时,可能会注意到这个命令执行时需要一些时间。准确地说,大约需要 10 秒钟。为什么会这样?
Docker 会向容器内运行的主进程发送 Linux 的SIGTERM
信号。如果该进程没有对信号作出响应并自行终止,Docker 会等待 10 秒钟,然后发送SIGKILL
,强制终止进程并停止容器。
在之前的命令中,我们使用了容器的名称来指定要停止的容器。但我们也可以使用容器 ID 来代替。
我们如何获取容器的 ID?有几种方法可以做到这一点。手动方法是列出所有正在运行的容器,并在列表中找到我们正在寻找的那个容器。从那里,我们复制它的 ID。更自动化的方式是使用一些 shell 脚本和环境变量。例如,如果我们想获取 trivia 容器的 ID,可以使用以下表达式:
$ export CONTAINER_ID=$(docker container ls -a | grep trivia | awk '{print $1}')
在 PowerShell 中,等效的命令如下所示:
$ CONTAINER_ID = docker container ls -a | Select-String "trivia" | Select-Object -ExpandProperty Line | ForEach-Object { $_ -split ' ' } | Select-Object -First 1$ Write-Output $CONTAINER_ID
注意
我们使用了-a
(或--all
)参数与docker container ls
命令来列出所有容器,包括已停止的容器。由于我们刚才停止了 trivia 容器,所以在这种情况下是必要的。
现在,我们可以不使用容器名称,而是在表达式中使用$CONTAINER_ID
变量:
$ docker container stop $CONTAINER_ID
一旦我们停止了容器,它的状态将变为Exited
。
如果容器已停止,可以使用docker container start
命令重新启动它。让我们用trivia
容器做这个操作。重新启动它是个好主意,因为在本章接下来的部分我们还会用到它:
$ docker container start $CONTAINER_ID
我们也可以通过使用容器名称来启动它:
$ docker container start trivia
现在是时候讨论如何处理那些我们不再需要的已停止容器了。
移除容器
当我们运行docker container ls -a
命令时,可以看到许多处于Exited
状态的容器。如果我们不再需要这些容器,最好将它们从内存中移除;否则,它们会不必要地占用宝贵的资源。移除容器的命令如下:
$ docker container rm <container ID>
这里,<container ID>
代表我们想要移除的容器 ID——一个 SHA-256 代码。移除容器的另一种方式如下:
$ docker container rm <container name>
这里,我们使用容器的名称。
挑战
尝试使用容器的 ID 来移除一个已停止的容器。
有时,移除容器可能会失败,因为它仍在运行。如果我们想强制移除容器,无论容器当前的状态如何,我们可以使用-f
或--force
命令行参数:
$ docker container rm <container ID> --force
现在我们已经学会了如何从系统中移除容器,接下来让我们学习如何检查系统中存在的容器。
检查容器
容器是镜像的运行时实例,具有许多与其行为相关的特征数据。为了获取有关特定容器的更多信息,我们可以使用inspect
命令。像往常一样,我们必须提供容器 ID 或名称,以标识我们想要获取数据的容器。那么,让我们检查一下我们的示例容器。如果它还没有运行,我们必须先启动它:
$ docker container run --name trivia fundamentalsofdocker/ trivia:ed2
然后,使用此命令来检查它:
$ docker container inspect trivia
响应是一个包含详细信息的大型 JSON 对象。它看起来类似于这样:
图 3.8 – 检查 trivia 容器
请注意,前面的截图只显示了更长输出的第一部分。
请花点时间分析一下你所拥有的信息。你应该看到如下信息:
-
容器的 ID
-
容器的创建日期和时间
-
容器是从哪个镜像构建的
输出的许多部分,如 Mounts
和 NetworkSettings
,现在可能没有太大意义,但我们将在本书的后续章节中讨论这些内容。你在这里看到的数据也被称为容器的 元数据。在本书的余下部分,我们将经常使用 inspect
命令作为信息来源。
有时,我们只需要一小部分整体信息,为了实现这一点,我们可以使用 grep
工具或过滤器。前者方法并不总是能得到预期的答案,因此让我们看看后者的方法:
$ docker container inspect -f "{{json .State}}" trivia \ | jq .
-f
或 --filter
参数用于定义 "{{json .State}}"
过滤器。过滤器表达式本身使用 Go 模板语法。在这个例子中,我们只想看到整个输出中的状态部分,格式为 JSON。为了更好地格式化输出,我们将结果通过管道传递给 jq
工具:
图 3.9 – inspect 输出的状态节点
在我们学会如何检索关于容器的大量重要且有用的元信息后,我们希望调查如何在一个运行中的容器中执行它。
执行进入正在运行的容器
有时候,我们希望在一个已经运行的容器内执行另一个进程。一个典型的原因可能是尝试调试一个行为异常的容器。我们如何做到这一点呢?首先,我们需要知道容器的 ID 或名称,然后我们可以定义我们想要运行的进程以及如何运行它。再次,我们使用当前正在运行的 trivia
容器,并通过以下命令在其中交互式地运行一个 shell:
$ docker container exec -i -t trivia /bin/sh
-i
(或 --interactive
)标志表示我们希望交互式地运行额外的进程,-t
(或 --tty
)告诉 Docker 我们希望它为我们提供一个 TTY(终端模拟器)以运行该命令。最后,我们在容器内运行的进程是 /bin/sh
。
如果我们在终端执行上述命令,我们将看到一个新的提示符 /app #
。我们现在处于 trivia 容器内的 Bourne shell 中。我们可以通过执行 ps
命令轻松证明这一点,ps
会列出该上下文中的所有运行进程:
/app # ps
结果应该看起来与此类似:
图 3.10 – 进入正在运行的 trivia 容器
我们可以清楚地看到,PID 为 1
的进程是我们定义在 trivia 容器中运行的命令。PID 为 1
的进程也被称为主进程。
按Ctrl + D退出容器。我们不仅可以在容器中交互执行其他进程。请考虑以下命令:
$ docker container exec trivia ps
显然,输出看起来与前面的输出非常相似:
图 3.11 – 在小知识容器内运行的进程列表
不同之处在于,我们没有使用额外的进程来运行 shell,而是直接执行了ps
命令。我们甚至可以使用-d
标志以守护进程的方式运行进程,并使用-e
或--env
标志变量在容器内定义环境变量,如下所示:
-
运行以下命令,在一个小知识(trivia)容器内启动一个 shell,并定义一个名为
MY_VAR
的环境变量,该变量在该容器内有效:$ docker container exec -it \ -e MY_VAR="Hello World" \ trivia /bin/sh
-
您将发现自己在
trivia
容器内。输出MY_VAR
环境变量的内容,如下所示:/app # echo $MY_VAR
-
您应该在终端中看到这个输出:
Hello World
图 3.12 – 运行一个小知识容器并定义一个环境变量
-
要退出小知识容器,请按Ctrl + D:
/app # <CTRL-d>
很好,我们学会了如何进入运行中的容器并执行其他进程。但是还有另一种重要的与运行中容器一起工作的方法。
附加到正在运行的容器
我们可以使用attach
命令将我们终端的标准输入、输出或错误(或三者的任意组合)附加到正在运行的容器中,使用容器的 ID 或名称。让我们为我们的小知识容器执行这个操作:
- 打开一个新的终端窗口。
提示
对于这个练习,您可能希望使用比 VS Code 集成终端更好的另一个终端,因为它似乎会导致我们将要使用的键组合出现问题。在 Mac 上,例如,使用终端应用程序。
-
在交互模式下运行
trivia
Docker 镜像的新实例:$ docker container run -it \ --name trivia2 fundamentalsofdocker/trivia:ed2
-
打开另一个终端窗口,并使用这个命令将其附加到容器上:
$ docker container attach trivia2
在这种情况下,我们将看到,大约每 5 秒,输出中会出现一个新的引用。
-
要退出容器而不停止或杀死它,我们可以使用Ctrl + P
+
Ctrl + Q键组合。这样可以从容器中分离出来,同时让它在后台运行。 -
强制停止并删除容器:
$ docker container rm --force trivia2
让我们再运行另一个容器,这次是一个 Nginx web 服务器:
-
按以下步骤运行 Nginx web 服务器:
$ docker run -d --name nginx -p 8080:80 nginx:alpine
提示
在这里,我们作为守护进程运行 Alpine 版本的 Nginx,在名为nginx
的容器中。-p 8080:80
命令行参数在主机上打开端口8080
,以便访问运行在容器内的 Nginx web 服务器。不用担心这里的语法问题,因为我们将在第十章,使用单主机网络中更详细地解释这个特性。
在 Windows 上,你需要批准一个 Windows 防火墙弹出的提示。你必须在防火墙中允许 Docker Desktop。
-
让我们看看能否通过运行以下命令使用
curl
工具访问 Nginx:$ curl -4 localhost:8080
如果一切正常,你应该看到 Nginx 的欢迎页面:
图 3.13 – Nginx Web 服务器的欢迎消息
-
现在,让我们将终端附加到 Nginx 容器,观察发生了什么:
$ docker container attach nginx
-
一旦你附加到容器,你最初不会看到任何内容。但现在,打开另一个终端,在这个新终端窗口中重复运行
curl
命令几次,例如,使用以下脚本:$ for n in {1..10} do; curl -4 localhost:8080 done;
或者,在 PowerShell 中使用以下命令:
PS> 1..10 | ForEach-Object {C:\ProgramData\chocolatey\bin\curl.exe -4 localhost:8080}
你应该看到 Nginx 的日志输出,类似于以下内容:
图 3.14 – Nginx 输出
-
按 Ctrl + C 退出容器。这将分离你的终端,同时停止 Nginx 容器。
-
要清理,请使用以下命令删除 Nginx 容器:
$ docker container rm nginx
在接下来的章节中,我们将学习如何处理容器日志。
检索容器日志
任何好的应用程序的最佳实践是生成一些日志信息,开发人员和操作人员都可以用来了解应用程序在特定时间的运行情况,以及是否有任何问题,以帮助确定问题的根本原因。
在容器内运行时,应用程序最好将日志条目输出到 STDOUT
和 STDERR
,而不是文件。如果日志输出被定向到 STDOUT
和 STDERR
,则 Docker 可以收集这些信息并准备好供用户或任何其他外部系统使用:
-
在
detach
模式下运行一个 trivia 容器:$ docker container run --detach \ --name trivia fundamentalsofdocker/trivia:ed2
让它运行一分钟左右,这样它就有时间生成几个 trivia 问题。
-
要访问给定容器的日志,我们可以使用
docker container logs
命令。例如,如果我们想检索trivia
容器的日志,可以使用以下表达式:$ docker container logs trivia
这将检索从应用程序存在之初生成的完整日志。
注意
停一下,等一会儿——我刚才说的并不完全正确。默认情况下,Docker 使用所谓的 json-file
日志驱动程序。该驱动程序将日志信息存储在文件中。如果定义了文件滚动策略,那么 docker container logs
仅会检索当前活动日志文件中的内容,而不会检索可能仍然存在于主机上的以前滚动过的文件。
-
如果我们只想获取一些最新的条目,可以使用
-t
或--tail
参数,如下所示:$ docker container logs --tail 5 trivia
这将仅检索容器内部进程生成的最后五条记录。
-
有时,我们希望跟踪容器产生的日志。这可以通过使用
-f
或--follow
参数来实现。以下表达式将输出最后五个日志项,并在容器化进程产生日志时继续跟踪:$ docker container logs --tail 5 --follow trivia
-
按 Ctrl + C 停止跟踪日志。
-
清理环境并使用以下命令移除不必要的容器:
$ docker container rm --force trivia
通常,使用容器日志的默认机制不足以满足需求。我们需要另一种日志记录方式。接下来的章节将讨论这个问题。
日志驱动程序
Docker 包含多种日志机制,帮助我们从运行中的容器中获取信息。这些机制称为日志驱动程序。可以在 Docker 守护进程级别配置使用哪个日志驱动程序。默认的日志驱动程序是 json-file
。目前原生支持的驱动程序如下:
驱动程序 | 描述 |
---|---|
none |
不会为特定容器产生日志输出。 |
json-file |
这是默认的驱动程序。日志信息存储在文件中,并以 JSON 格式呈现。 |
journald |
如果主机上正在运行 journald 守护进程,我们可以使用此驱动程序。它会将日志转发到 journald 守护进程。 |
syslog |
如果主机上运行着 syslog 守护进程,我们可以配置此驱动程序,它将把日志消息转发到 syslog 守护进程。 |
gelf |
使用此驱动程序时,日志消息将写入 Graylog 扩展日志格式(GELF) 端点。常见的此类端点包括 Graylog 和 Logstash。 |
fluentd |
假设主机系统上已安装 fluentd 守护进程,此驱动程序会将日志消息写入该守护进程。 |
awslogs |
awslogs 日志驱动程序允许 Docker 将日志数据发送到 Amazon CloudWatch Logs。 |
splunk |
Splunk 日志驱动程序允许 Docker 将日志数据发送到 Splunk,这是一个流行的日志管理和分析平台。 |
表 3.2 – 日志驱动程序列表
注意
如果更改日志驱动程序,请注意,docker container logs
命令仅适用于 json-file
和 journald
驱动程序。Docker 20.10 及更高版本引入了 双重日志记录,它使用本地缓冲区,使您可以为任何日志驱动程序使用 docker container logs
命令。
使用特定容器的日志驱动程序
日志驱动程序可以在 Docker 守护进程配置文件中全局设置。但我们也可以为每个容器单独定义日志驱动程序。在以下示例中,我们运行一个 busybox
容器,并使用 --logdriver
参数配置 none
日志驱动程序:
-
运行一个
busybox
Docker 镜像实例,并在其中执行一个简单脚本,输出三次Hello
消息:$ docker container run --name test -it \ --log-driver none \ busybox sh -c 'for N in 1 2 3; do echo "Hello $N"; done'
我们应该看到以下内容:
Hello 1Hello 2
Hello 3
-
现在,让我们尝试获取前面容器的日志:
$ docker container logs test
输出结果如下:
Error response from daemon: configured logging driver does not support reading
这是可以预期的,因为 none
驱动程序不会产生任何日志输出。
-
让我们清理并移除
test
容器:$ docker container rm test
在结束关于日志的这一部分时,我们将讨论一个相对高级的话题,即如何更改默认的日志驱动程序。
高级话题 – 更改默认日志驱动程序
让我们更改 Linux 主机的默认日志驱动程序。最简单的方式是在真实的 Linux 主机上执行此操作。为此,我们将使用带有 Ubuntu 镜像的 Vagrant。Vagrant 是由 HashiCorp 开发的一个开源工具,通常用于构建和维护便携的虚拟软件开发环境。请按照以下说明进行操作:
-
打开一个新的终端窗口。
-
如果你之前没有做过,在你的 Mac 和 Windows 机器上,你可能需要首先安装一个虚拟机管理程序(如 VirtualBox)。如果你使用的是 Windows 的专业版,也可以使用 Hyper-V:
-
在带有 Intel CPU 的 Mac 上安装 VirtualBox,可以通过以下 Homebrew 命令:
$ brew install --cask virtualbox
-
在 Windows 上,使用 Chocolatey,执行以下操作:
$ choco install -y virtualbox
-
注意
在带有 M1/M2 CPU 的 Mac 上,在撰写时,你需要安装 VirtualBox 的开发者预览版。请按照这里的说明进行操作:www.virtualbox.org/wiki/Downloads
。
-
使用你的包管理器(如 Windows 上的 Chocolatey 或 Mac 上的 Homebrew)在计算机上安装 Vagrant。在我的 MacBook Air M1 上,命令如下:
$ brew install --cask vagrant
在 Windows 机器上,相应的命令如下:
$ choco install -y vagrant
-
安装成功后,使用以下命令确保 Vagrant 可用:
$ vagrant –version
在撰写时,Vagrant 会回复以下内容:
Vagrant 2.3.2
-
在终端中,执行以下命令以初始化一个带有 Vagrant 的 Ubuntu 22.04 虚拟机:
$ vagrant init bento/ubuntu-22.04
这是生成的输出:
图 3.15 – 基于 Ubuntu 22.04 初始化 Vagrant 虚拟机
Vagrant 会在当前文件夹中创建一个名为 Vagrantfile
的文件。你可以选择使用编辑器分析该文件的内容。
注意
在带有 M1/M2 CPU 的 Mac 上,在撰写时,bento/ubuntu-22.4
镜像无法使用。一个似乎有效的替代方案是 illker/ubuntu-2004
。
-
现在,使用 Vagrant 启动该虚拟机:
$ vagrant up
-
使用安全外壳协议(
ssh
)从笔记本电脑连接到虚拟机:$ vagrant ssh
完成后,你将进入虚拟机并可以在其中开始使用 Docker。
-
一旦进入 Ubuntu 虚拟机,我们需要编辑 Docker 守护进程配置文件并触发 Docker 守护进程重新加载该配置文件:
- 导航到
/etc/docker
文件夹:
$ cd /etc/docker
- 按如下方式运行
vi
:
$ vi daemon.json
- 输入以下内容:
{ "Log-driver": "json-log", "log-opts": { "max-size": "10m", "max-file": 3 }}
-
上述定义指示 Docker 守护进程使用
json-log
驱动程序,最大日志文件大小为 10 MB,在文件被滚动之前,并且系统上可以存在的最大日志文件数为三个,超出后最旧的文件将被清除。 -
通过首先按 Esc,然后输入
:w:q
(表示 保存并退出),最后按 Enter 键来保存并退出vi
。 -
现在,我们必须向 Docker 守护进程发送一个
SIGHUP
信号,以便它能够读取配置文件中的更改:
$ sudo kill -SIGHUP $(pidof dockerd)
- 请注意,前面的命令只会重新加载配置文件,而不会重新启动守护进程。
- 导航到
-
通过运行一些容器并分析日志输出,来测试你的配置。
-
完成以下实验后,记得清理系统:
$ vagrant destroy [name|id]
很好!上一节是一个高级主题,展示了如何在系统级别更改日志驱动程序。现在,让我们谈谈容器的结构。
容器的结构
许多人错误地将容器与虚拟机进行比较。然而,这种比较是值得怀疑的。容器不仅仅是轻量级的虚拟机。那么,容器的正确描述是什么呢?
容器是运行在宿主系统上的特别封装和安全的进程。容器利用了 Linux 操作系统中许多可用的特性和原语。最重要的特性是命名空间和控制组(简称 cgroups)。所有在容器中运行的进程仅共享底层宿主操作系统的同一个 Linux 内核。这与虚拟机有本质的不同,因为每个虚拟机都包含其自己的完整操作系统。
一个典型容器的启动时间可以用毫秒来衡量,而虚拟机(VM)通常需要几秒钟到几分钟才能启动。虚拟机通常是长时间运行的。每个运维工程师的主要目标之一就是最大化虚拟机的正常运行时间。与此相反,容器是短暂的。它们来得快,去得也快。
首先,让我们高层次地概述一下使我们能够运行容器的架构。
架构
这里是一个架构图,展示了这一切是如何组合在一起的:
图 3.16 – Docker 的高层架构
在前面图表的下半部分,我们有 Linux 操作系统及其 cgroups、命名空间和层次能力,以及其他操作系统功能,这些我们在这里不需要明确提及。接下来,是一个中间层,包含 containerd
和 runc
。在所有这些之上,现在是 Docker 引擎。Docker 引擎提供了一个 RESTful 接口,外部世界可以通过任何工具访问,如 Docker CLI、Docker Desktop 或 Kubernetes 等。
现在,让我们更详细地描述一下主要的构建块。
命名空间
Linux 的命名空间在 Docker 使用容器之前就已经存在了多年。命名空间是对全局资源的抽象,例如文件系统、网络访问、进程树(也称为 PID 命名空间)或系统组 ID 和用户 ID。Linux 系统在初始化时会创建每种命名空间类型的单一实例。初始化后,可以创建或加入额外的命名空间。
Linux 命名空间起源于 2002 年的 2.4.19 内核。在 3.8 版本内核中引入了用户命名空间,随之而来的是命名空间可以被容器使用。
如果我们将一个正在运行的进程,比如说在文件系统命名空间中,那么这将提供一个错觉,即进程拥有自己完整的文件系统。当然,这不是真的;这只是一个虚拟文件系统。从主机的角度看,包含的进程获得了整体文件系统的受保护的子部分。就像一个文件系统中的文件系统:
图 3.17 – 命名空间解释
同样适用于所有其他全局资源,例如存在命名空间的用户 ID 命名空间就是另一个例子。现在我们有了用户命名空间,我们可以在系统上多次定义 jdoe
用户,只要它们位于各自的命名空间中。
PID 命名空间是使得一个容器内的进程无法看到或与另一个容器内的进程交互的机制。一个进程在容器内可能具有看似的 PID 1,但如果我们从主机系统上检查它,它将具有普通的 PID,例如 334
:
图 3.18 – Docker 主机上的进程树
在每个命名空间中,我们可以运行一对多的进程。当我们谈论容器时,这一点非常重要,这一点我们已经在已运行的容器中执行另一个进程时体验到了。
控制组
Linux 控制组(cgroups)用于限制、管理和隔离在系统上运行的一组进程的资源使用。资源包括 CPU 时间、系统内存、网络带宽或这些资源的组合。
谷歌工程师最初在 2006 年实现了这一功能。控制组功能在 Linux 内核主线中被合并到 2.6.24 版本中,该版本于 2008 年 1 月发布。
使用 cgroups,管理员可以限制容器可以消耗的资源。通过这种方式,我们可以避免例如经典的吵闹邻居问题,即运行在容器中的恶意进程占用所有 CPU 时间或大量内存,并因此使得所有运行在主机上的其他进程无法正常运行,无论它们是否被容器化。
联合文件系统
联合文件系统 (unionfs) 构成了所谓容器镜像的基础。在下一章中,我们将详细讨论容器镜像。目前,我们只想稍微了解一下 unionfs 是什么,以及它是如何工作的。unionfs 主要用于 Linux,允许将不同文件系统中的文件和目录叠加在一起,形成一个单一的连贯文件系统。在这种情况下,单独的文件系统被称为分支。当合并的分支中有相同路径的目录时,这些目录的内容将在新的虚拟文件系统中的单一合并目录中一起显示。在合并分支时,会指定分支之间的优先级。通过这种方式,当两个分支包含相同的文件时,最终文件系统中会显示优先级更高的文件。
容器管道
Docker Engine 构建的基础由两个组件组成,runc
和 containerd
。
最初,Docker 是以单体方式构建的,并包含运行容器所需的所有功能。随着时间的推移,这种方式变得过于僵化,Docker 开始将一些功能拆分为独立的组件。两个重要的组件是runc
和containerd
。
runc
runc 是一个轻量级、可移植的容器运行时。它全面支持 Linux 命名空间,并原生支持 Linux 上所有的安全特性,如 SELinux、AppArmor、seccomp 和 cgroups。
runC 是一个根据开放容器倡议 (OCI) 规范生成和运行容器的工具。它是一个正式指定的配置格式,由开放容器项目 (OCP) 在 Linux 基金会的支持下管理。
containerd
runC 是容器运行时的低级实现;containerd 在其之上构建,添加了更高级的功能,如镜像传输与存储、容器执行与监控,以及网络和存储附加。通过这些,containerd 管理容器的完整生命周期。containerd 是 OCI 规范的参考实现,是目前最流行和广泛使用的容器运行时。
Containerd 于 2017 年捐赠并被 CNCF 接受。OCI 规范有其他的实现方式,其中一些包括 CoreOS 的rkt
、Red Hat 的 CRI-O 和 Linux Containers 的 LXD。然而,containerd 目前仍然是最流行的容器运行时,并且是 Kubernetes 1.8 及更高版本和 Docker 平台的默认运行时。
概要
在这一章中,你学习了如何使用基于现有镜像的容器。我们展示了如何运行、停止、启动和删除容器。接着,我们检查了容器的元数据,提取了它的日志,并了解了如何在一个已经运行的容器中运行任意进程。最后,我们深入探讨了容器的工作原理以及它们利用底层 Linux 操作系统的哪些特性。
在下一章中,你将学习什么是容器镜像,以及我们如何构建和分享自己的自定义镜像。我们还将讨论构建自定义镜像时常用的最佳实践,例如最小化镜像大小和利用镜像缓存。敬请期待!
进一步阅读
以下文章提供了与本章讨论主题相关的更多信息:
-
在
docs.docker.com/get-started/
开始使用容器 -
获取 Docker 容器命令的概览,详见
dockr.ly/2iLBV2I
-
了解如何通过用户命名空间来隔离容器,详见
dockr.ly/2gmyKdf
-
了解如何限制容器的资源,详见
dockr.ly/2wqN5Nn
问题
为了评估你的学习进度,请回答以下问题:
-
哪两个重要的 Linux 概念是容器的启用因素?
-
容器可能处于哪些状态?
-
哪个命令帮助我们查找当前在 Docker 主机上运行的容器?
-
哪个命令用于仅列出所有容器的 ID?
答案
这是本章提出问题的一些示例答案:
-
Linux 首先引入了命名空间和cgroups来使容器成为可能。容器广泛使用这两个概念。命名空间用于封装并保护容器内部定义和/或运行的资源。cgroups 用于限制容器内部进程可使用的资源,如内存、带宽或 CPU。
-
Docker 容器的可能状态如下:
-
created
:已创建但尚未启动的容器 -
restarting
:正在重启的容器 -
running
:当前正在运行的容器 -
paused
:进程已暂停的容器 -
exited
:已运行并完成的容器 -
dead
:Docker 引擎尝试并未成功停止的容器
-
-
我们可以使用以下命令(或旧的较短版本
docker ps
):$ docker container ls
这用于列出当前在我们的 Docker 主机上运行的所有容器。请注意,这不会列出已停止的容器,若要查看这些容器,需要额外使用--all
(或-a
)参数。
-
要列出所有容器的 ID(无论是运行中还是已停止),我们可以使用以下命令:
$ docker container ls -a -q
这里,-q
表示仅输出 ID,-a
告诉 Docker 我们要查看所有容器,包括已停止的容器。
第四章:4
创建和管理容器镜像
在上一章中,我们学习了什么是容器,以及如何运行、停止、删除、列出和检查容器。我们提取了一些容器的日志信息,运行了其他进程在已经运行的容器内,最后我们深入了解了容器的结构。每次我们运行容器时,都会使用容器镜像来创建它。在本章中,我们将熟悉这些容器镜像。我们将学习它们是什么,如何创建它们,以及如何分发它们。
本章将涵盖以下主题:
-
什么是镜像?
-
创建镜像
-
提升与迁移 —— 将传统应用程序容器化
-
共享或运输镜像
完成本章后,你将能够做到以下几点:
-
列举容器镜像的三个最重要特征
-
通过交互式更改容器层并提交它来创建自定义镜像
-
编写一个简单的 Dockerfile 来生成自定义镜像
-
使用
docker image save
导出现有镜像,并使用docker image load
将其导入到另一个 Docker 主机中 -
编写一个两步的 Dockerfile,通过仅将最终结果包含在最终镜像中,来最小化生成镜像的大小
什么是镜像?
在 Linux 中,一切皆文件。整个操作系统是一个包含文件和文件夹的文件系统,这些文件存储在本地磁盘上。在查看容器镜像时,记住这一点非常重要。正如我们将看到的,镜像是一个包含文件系统的大 tar 包。更具体地说,它包含一个分层文件系统。
tar 包
tar 包(也称为.tar
归档文件)是一个包含多个文件或目录的单一文件。它是一种常见的归档格式,用于分发软件包和其他文件集合。.tar
归档文件通常使用 gzip 或其他压缩格式进行压缩,以减小文件大小。tar 包广泛应用于类 Unix 操作系统,包括 Linux 和 macOS,并可以通过tar
命令解压。
分层文件系统
容器镜像是创建容器的模板。这些镜像不仅仅由一个单一的块组成,而是由许多层次构成。镜像中的第一层也称为基础层。我们可以在下图中看到这一点:
图 4.1 – 镜像作为层叠堆栈
每一层都包含文件和文件夹。每一层只包含相对于底层的文件系统的变化。Docker 使用联合文件系统——如在第三章《容器基础》一书中所讨论的——从一组层中创建一个虚拟文件系统。存储驱动程序处理这些层之间相互作用的细节。不同的存储驱动程序在不同情况下有各自的优缺点。
容器镜像的各个层都是不可变的。不可变意味着一旦生成,该层就无法更改。唯一可能影响层的操作是其物理删除。层的这种不可变性非常重要,因为它打开了大量的机会,正如我们将要看到的。
在下图中,我们可以看到一个自定义的 Web 应用程序的图片,使用 Nginx 作为 Web 服务器:
图 4.2 – 基于 Alpine 和 Nginx 的示例自定义镜像
我们这里的基础层是 Alpine Linux 发行版。然后,在其上,我们有一个添加 Nginx层,在 Alpine 的基础上添加了 Nginx。最后,第三层包含构成 Web 应用程序的所有文件,例如 HTML、CSS 和 JavaScript 文件。
正如之前所述,每个镜像都从一个基础镜像开始。通常,这个基础镜像是 Docker Hub 上找到的官方镜像之一,如 Linux 发行版 Alpine、Ubuntu 或 CentOS。然而,也可以从零开始创建一个镜像。
注意
Docker Hub 是一个公共的容器镜像注册表。它是一个理想的中央枢纽,非常适合分享公共容器镜像。该注册表可以在这里找到:hub.docker.com/
。
每个层只包含相对于前一组层的更改增量。每个层的内容都映射到主机系统上的一个特殊文件夹中,通常是 /var/lib/docker/
的子文件夹。
由于层是不可变的,它们可以被缓存而永远不会过期。这是一个巨大的优势,正如我们将要看到的。
可写的容器层
正如我们所讨论的,容器镜像由一堆不可变或只读的层组成。当 Docker 引擎从这样的镜像创建容器时,它在这堆不可变层的顶部添加一个可写的容器层。我们的堆栈现在如下所示:
图 4.3 – 可写的容器层
容器层被标记为读/写 (r/w)。镜像层的不可变性的另一个优点是,它们可以在从该镜像创建的许多容器之间共享。所需的只是为每个容器创建一个薄薄的可写容器层,如下图所示:
图 4.4 – 多个容器共享相同的镜像层
当然,这种技术极大地减少了消耗的资源。此外,这有助于减少容器的加载时间,因为只有在将镜像层加载到内存中后才需要创建薄薄的容器层,而这仅在第一个容器中发生。
写时复制
Docker 在处理镜像时使用写时复制技术。写时复制是一种共享和复制文件的高效策略。如果某一层使用了低层中的某个文件或文件夹,那么它会直接使用该文件。如果某一层想要修改低层中的某个文件,那么它首先会将该文件复制到目标层,然后再进行修改。在下图中,我们可以看到这意味着什么:
图 4.5 – 使用写时复制的 Docker 镜像
第二层需要修改基础层中的文件 2。因此,它将文件复制到当前层并进行修改。现在,假设我们站在前面图示中的顶层。这个层将使用基础层中的文件 1以及第二层中的文件 2和文件 3。
图形驱动程序
图形驱动程序是实现联合文件系统的关键。图形驱动程序也称为存储驱动程序,用于处理分层容器镜像。图形驱动程序将多个镜像层合并为容器的挂载命名空间的根文件系统。换句话说,驱动程序控制镜像和容器在 Docker 主机上的存储和管理方式。
Docker 支持多种不同的图形驱动程序,采用可插拔架构。首选的驱动程序是overlay2,其次是overlay。
现在我们了解了镜像是什么,我们将学习如何自己创建 Docker 镜像。
创建 Docker 镜像
在系统上创建新的容器镜像有三种方式。第一种是通过交互式地构建一个容器,容器中包含你想要的所有修改和新增内容,然后将这些更改提交到一个新的镜像中。第二种,也是最重要的方式,是使用 Dockerfile 描述新镜像的内容,然后利用该 Dockerfile 作为清单来构建镜像。最后,第三种创建镜像的方式是通过从 tarball 导入镜像到系统中。
现在,让我们详细了解这三种方式。
交互式镜像创建
我们创建自定义镜像的第一种方式是通过交互式构建一个容器。也就是说,我们从想要作为模板的基础镜像开始,并交互式地运行该容器。假设我们使用的是 Alpine 镜像:
-
运行该容器的命令如下:
$ docker container run -it \ --name sample \ alpine:3.17 /bin/sh
上述命令基于 alpine:3.17
镜像运行一个容器。
- 我们通过附加
-it
参数以交互模式运行容器,通过--name
参数将其命名为sample
,最后在容器内运行一个 shell,命令是/bin/sh
。
在你运行上述命令的终端窗口中,应该能看到类似以下内容:
图 4.6 – 交互模式下的 Alpine 容器
默认情况下,Alpine 容器没有安装curl
工具。假设我们想创建一个新的自定义镜像,其中安装了curl
。
-
在容器内,我们可以运行以下命令:
/ # apk update && apk add curl
前述命令首先更新了 Alpine 包管理器apk
,然后安装了curl
工具。该命令的输出应该大致如下:
图 4.7 – 在 Alpine 上安装 curl
- 现在,我们确实可以使用
curl
,如下所示的代码片段所示:
图 4.8 – 在容器内使用 curl
使用前述命令,我们已访问了 Google 首页,并通过-I
参数告诉curl
只输出响应头。
-
一旦我们完成了自定义配置,可以通过在提示符下输入
exit
或按 Ctrl + D 来退出容器。 -
现在,如果我们使用
docker container ls -a
命令列出所有容器,我们会看到我们的示例容器的状态为Exited
,但仍然存在于系统中,如下所示的代码块所示:$ docker container ls -a | grep sample
-
这应该输出类似于以下内容的内容:
5266d7da377c alpine:3.17 "/bin/sh" 2 hours ago Exited (0) 48 seconds ago
-
如果我们想查看相对于基础镜像我们的容器发生了哪些变化,可以使用
docker container diff
命令,如下所示:$ docker container diff sample
-
输出应展示在容器文件系统上所做的所有修改列表,如下所示:
图 4.9 – docker diff 命令的输出(截断)
我们已将前述输出进行了简化,以提高可读性。在列表中,A
代表添加,C
代表更改。如果有任何删除的文件,它们会以D
为前缀。
-
现在,我们可以使用
docker container commit
命令来保存我们的修改,并从中创建一个新镜像,如下所示:$ docker container commit sample my-alpine
前述命令在作者计算机上生成的输出如下:
sha256:5287bccbb3012ded35e7e992a5ba2ded9b8b5d0...
使用前述命令时,我们指定了新镜像的名称为my-alpine
。前述命令生成的输出对应于新生成镜像的 ID。
-
我们可以通过列出系统上的所有镜像来验证这一点,如下所示:
$ docker image ls
我们可以如下所示查看该镜像的 ID:
图 4.10 – 列出所有 Docker 镜像
我们可以看到名为my-alpine
的镜像具有预期的 ID 5287bccbb301
(对应完整哈希码的前半部分),并自动获得了latest
标签,因为我们没有显式地定义标签。在这种情况下,Docker 总是默认为latest
标签。
-
如果我们想查看自定义镜像的构建过程,可以使用
history
命令,如下所示:$ docker image history my-alipine
这将打印出我们镜像所包含的所有层,具体如下:
图 4.11 – my-alpine Docker 镜像的历史
上面输出中的顶层 —— 用红色标记 —— 就是我们刚刚通过添加 curl
包所创建的层。其他两行来自原始构建的 Alpine 3.17 Docker 镜像。它是在 4 天前创建并上传的。
现在我们已经看到如何交互式地创建 Docker 镜像,让我们看看如何使用 Dockerfile 以声明式的方式做到这一点。
使用 Dockerfiles
手动创建自定义镜像,如本章前面所示,在进行探索、创建原型或编写可行性研究时非常有用。但它有一个严重的缺点:这是一个手动过程,因此不可重复或不可扩展。它也容易出错,就像任何其他由人类手动执行的任务一样。一定有更好的方法。
这就是所谓的 Dockerfile 发挥作用的地方。Dockerfile
是一个文本文件,默认名称为 Dockerfile
。它包含了构建自定义容器镜像的指令。这是一种声明式的镜像构建方式。
声明式与命令式
一般来说,在计算机科学中,尤其是在 Docker 中,你通常使用声明式的方式来定义任务。你描述预期的结果,并让系统去决定如何实现这个目标,而不是一步步地给系统提供实现该目标的指令。后者是命令式方法。
让我们看一个示例 Dockerfile,如下所示:
FROM python:3.12RUN mkdir -p /app
WORKDIR /app
COPY ./requirements.txt /app/
RUN pip install -r requirements.txt
CMD ["python", "main.py"]
这是一个 Dockerfile,用于将 Python 3.12 应用程序容器化。如我们所见,文件有六行,每一行都以 FROM
、RUN
或 COPY
等关键字开头。
注意
通常约定将关键字写成大写字母,但这并不是强制要求。
Dockerfile 的每一行都会在生成的镜像中产生一个层。在下图中,镜像的展示方式与本章前面的图有所不同,显示为一个层叠的镜像堆栈。这里,基础层显示在顶部。不要被这个图形弄混淆。实际上,基础层总是在堆栈中位于最底部:
图 4.12 – Dockerfile 与镜像中的层之间的关系
现在,让我们更详细地看看每个关键字。
FROM 关键字
每个 Dockerfile 都以 FROM
关键字开头。通过它,我们定义了从哪个基础镜像开始构建我们的自定义镜像。例如,如果我们想从 CentOS 7 开始构建,我们会在 Dockerfile 中加入以下这一行:
FROM centos:7
在 Docker Hub 上,所有主要的 Linux 发行版都有经过精心挑选或官方发布的镜像,此外,还有很多重要的开发框架或语言的镜像,如 Python、Node.js、Ruby、Go 等等。根据我们的需求,我们应该选择最合适的基础镜像。
例如,如果我想将一个 Python 3.12 应用程序容器化,我可能会选择相关的官方python:3.12
镜像。
如果我们想从头开始,也可以使用以下语句:
FROM scratch
这在构建超简小镜像时非常有用,这些镜像仅包含一个二进制文件——例如——实际的静态链接可执行文件,如Hello-World
。scratch
镜像是一个空的基础镜像。
FROM scratch
实际上在 Dockerfile 中是一个无操作命令,因此不会在最终的容器镜像中生成层。
RUN 关键字
下一个重要的关键字是RUN
。RUN
的参数是任何有效的 Linux 命令,例如以下内容:
RUN yum install -y wget
上述命令使用yum
CentOS 包管理器将wget
包安装到正在运行的容器中。假设我们的基础镜像是 CentOS 或红帽企业 Linux(RHEL)。如果我们的基础镜像是 Ubuntu,那么命令会类似如下:
RUN apt-get update && apt-get install -y wget
它会像这样,因为 Ubuntu 使用apt-get
作为包管理器。同样,我们也可以使用RUN
定义一个类似的命令,如下所示:
RUN mkdir -p /app && cd /app
我们也可以这样做:
RUN tar -xJC /usr/src/python --strip-components=1 -f python.tar.xz
这里,前者在容器中创建一个/app
文件夹并切换到该目录,后者将一个文件解包到指定位置。完全可以,并且推荐你使用多行物理行来格式化 Linux 命令,例如这样:
RUN apt-get update \ && apt-get install -y --no-install-recommends \
ca-certificates \
libexpat1 \
libffi6 \
libgdbm3 \
libreadline7 \
libsqlite3-0 \
libssl1.1 \
&& rm -rf /var/lib/apt/lists/*
如果我们使用多行命令,我们需要在行末加上反斜杠(\
),以指示 shell 命令将在下一行继续。
提示
尝试找出前面的命令在做什么。
COPY 和 ADD 关键字
COPY
和ADD
关键字非常重要,因为最终我们想在现有的基础镜像上添加一些内容,使其成为一个自定义镜像。通常,这些内容可能是一些源文件——比如说——一个 Web 应用程序,或者一些已编译应用程序的二进制文件。
这两个关键字用于将文件和文件夹从主机复制到我们正在构建的镜像中。两个关键字非常相似,唯一的区别是ADD
关键字还允许我们复制并解包TAR
文件,并可以提供 URI 作为复制文件和文件夹的源。
让我们看一些这两个关键字使用的示例,如下所示:
COPY . /appCOPY ./web /app/web
COPY sample.txt /data/my-sample.txt
ADD sample.tar /app/bin/
ADD http://example.com/sample.txt /data/
在前面的代码行中,以下内容适用:
-
第一行将当前目录中的所有文件和文件夹递归地复制到容器镜像中的
app
文件夹。 -
第二行将
web
子文件夹中的所有内容复制到目标文件夹/app/web
。 -
第三行将单个文件
sample.txt
复制到目标文件夹/data
,同时将其重命名为my-sample.txt
。 -
第四条语句将
sample.tar
文件解包到目标文件夹/app/bin
。 -
最后,最后一条语句将远程文件
sample.txt
复制到目标文件/data
。
在源路径中允许使用通配符。例如,下面的语句将所有以sample
开头的文件复制到镜像中的mydir
文件夹:
COPY ./sample* /mydir/
从安全角度来看,重要的是要知道,默认情况下,镜像中的所有文件和文件夹都会执行ADD
和COPY
,我们可以使用可选的--chown
标志来更改文件在镜像中的所有权,如下所示:
ADD --chown=11:22 ./data/web* /app/data/
上述语句将复制所有以web
开头的文件,并将它们放入镜像中的/app/data
文件夹,同时将用户 11 和组 22 赋予这些文件。
我们不仅可以使用数字,也可以使用用户名和组名,但这些实体必须已经在镜像的根文件系统中的/etc/passwd
和/etc/group
中定义;否则,镜像的构建将失败。
WORKDIR
关键字
WORKDIR
关键字定义了当从我们的自定义镜像运行容器时使用的工作目录或上下文。所以,如果我想将上下文设置为镜像中的/app/bin
文件夹,我在 Dockerfile 中的表达式应该如下所示:
WORKDIR /app/bin
在前面的行之后,镜像内发生的所有活动都将使用这个目录作为工作目录。需要特别注意的是,下面两段来自 Dockerfile 的代码并不相同:
RUN cd /app/binRUN touch sample.txt
将前面的代码与以下代码进行对比:
WORKDIR /app/binRUN touch sample.txt
前者将在镜像文件系统的根目录创建文件,而后者将在预期位置/app/bin
文件夹中创建文件。只有WORKDIR
关键字会在镜像的各个层之间设置上下文。单独使用cd
命令不会在层之间持久化。
注意
在 Dockerfile 中多次更改当前工作目录是完全可以的。
CMD
和ENTRYPOINT
关键字
CMD
和ENTRYPOINT
关键字是特殊的。其他所有 Dockerfile 中定义的关键字都是在 Docker 构建器构建镜像时执行的,而这两个关键字定义的是当容器从我们定义的镜像启动时将发生的操作。当容器运行时,它需要知道容器内将运行的进程或应用程序。这正是CMD
和ENTRYPOINT
的用途——告诉 Docker 启动进程是什么,以及如何启动该进程。
现在,CMD
和ENTRYPOINT
之间的区别非常微妙,说实话,大多数用户并不完全理解它们或以预期的方式使用它们。幸运的是,在大多数情况下,这不是问题,容器仍然会运行;只是处理起来没有想象中的那么直接。
为了更好地理解如何使用这两个关键字,让我们分析一下一个典型的 Linux 命令或表达式是什么样的。我们以ping
工具为例,如下所示:
$ ping -c 3 8.8.8.8
在前面的表达式中,ping
是命令,-c 3 8.8.8.8
是该命令的参数。我们在这里看另一个表达式:点击这里查看:
$ wget -O - http://example.com/downloads/script.sh
在前面的表达式中,wget
是命令,-O - http://example.com/downloads/script.sh
是参数。
处理完这个之后,我们可以回到 CMD
和 ENTRYPOINT
。ENTRYPOINT
用于定义表达式的命令,而 CMD
用于定义命令的参数。因此,一个使用 Alpine 作为基础镜像并将 ping
定义为容器中运行的进程的 Dockerfile 可能如下所示:
FROM alpine:3.17ENTRYPOINT [ "ping" ]
CMD [ "-c", "3", "8.8.8.8" ]
对于 ENTRYPOINT
和 CMD
,其值格式化为一个字符串的 JSON 数组,其中每个项对应于通过空格分隔的表达式中的各个令牌。这是定义 CMD
和 ENTRYPOINT
的推荐方式,也称为 exec 形式。
或者,我们可以使用所谓的 shell 形式,如下所示:
CMD command param1 param2
我们现在可以根据前面的 Dockerfile 构建一个名为 pinger
的镜像,如下所示:
$ docker image build -t pinger .
这是前面命令生成的输出:
图 4.13 – 构建 pinger Docker 镜像
然后,我们可以从刚才创建的 pinger 镜像中运行一个容器,像这样:
$ docker container run --rm -it pinger
图 4.14 – pinger 容器的输出
在前面的命令中,我们使用了 --rm
参数,定义了容器在其中的应用程序结束后会自动删除。
这样做的好处是,现在我可以在创建新容器时,通过在 docker container run
表达式的末尾添加新值来覆盖我在 Dockerfile 中定义的 CMD 部分(记住,它是 ["-c", "3","8.8.8.8"]
),像这样:
$ docker container run --rm -it pinger -w 5 127.0.0.1
这将使容器对回环 IP 地址(127.0.0.1
)进行 5 秒的 ping 操作。
如果我们想覆盖 Dockerfile 中 ENTRYPOINT
定义的内容,我们需要在 docker container run
表达式中使用 --entrypoint
参数。假设我们想在容器中执行一个 shell,而不是 ping
命令。我们可以使用以下命令:
$ docker container run --rm -it --entrypoint /bin/sh pinger
然后,我们将进入容器内。输入 exit
或按 Ctrl + D 退出容器。
如我之前提到的,我们不一定要遵循最佳实践,通过 ENTRYPOINT
定义命令,通过 CMD
定义参数;相反,我们可以将整个表达式作为 CMD
的值,它也能正常工作,如以下代码块所示:
FROM alpine:3.17CMD wget -O - http://www.google.com
在这里,我甚至使用了 shell 形式来定义 CMD。但如果 ENTRYPOINT
未定义,发生什么情况呢?如果你不定义 ENTRYPOINT
,它将默认为 /bin/sh -c
,无论 CMD
的值是什么,它都会作为字符串传递给 shell 命令。前面的定义因此会导致进入以下代码来在容器内运行该进程:
/bin/sh -c "wget -O - http://www.google.com"
因此,/bin/sh
是容器内运行的主进程,它将启动一个新的子进程来运行 wget
工具。
一个复杂的 Dockerfile
到目前为止,我们已经讨论了在 Dockerfile 中常用的最重要的关键字。现在,让我们看一个现实且稍微复杂的 Dockerfile 示例。对感兴趣的朋友来说,可能会注意到它看起来与我们在本章中展示的第一个 Dockerfile 非常相似。以下是它的内容:
FROM node:19-buster-slimRUN mkdir -p /app
WORKDIR /app
COPY package.json /app/
RUN npm install
COPY . /app
ENTRYPOINT ["npm"]
CMD ["start"]
好的,那么这里到底发生了什么?这是一个用于构建 Node.js 应用程序镜像的 Dockerfile;我们可以从使用的基础镜像 node:19-buster-slim
推断出这一点。接着,第二行是一个指令,用于在镜像的文件系统中创建一个 /app
文件夹。第三行定义了镜像中的工作目录或上下文为这个新的 /app
文件夹。然后,在第四行,我们将一个 package.json
文件复制到镜像中的 /app
文件夹内。此后,在第五行,我们在容器内执行 npm install
命令;记住,我们的上下文是 /app
文件夹,所以 npm
会在那里找到我们在第四行复制的 package.json
文件。
一旦所有的 Node.js 依赖项安装完毕,我们将从主机的当前文件夹中复制其余的应用程序文件到镜像的 /app
文件夹内。
最后,在最后两行中,我们定义了从这个镜像运行容器时的启动命令。在我们的例子中,它是 npm start
,将启动 Node.js 应用程序。
构建镜像
让我们看一个具体的示例并构建一个简单的 Docker 镜像,如下所示:
-
导航到示例代码库。通常它应该位于你的主文件夹中:
$ cd ~/The-Ultimate-Docker-Container-Book
-
为第四章创建一个新的子文件夹并进入:
$ mkdir ch04 && cd ch04
-
在前面的文件夹中,创建一个
sample1
子文件夹并进入,如下所示:$ mkdir sample1 && cd sample1
-
使用你喜欢的编辑器在这个示例文件夹中创建一个名为
Dockerfile
的文件,内容如下:FROM centos:7RUN yum install -y wget
-
保存文件并退出编辑器。
-
回到终端窗口,我们现在可以使用前面的 Dockerfile 作为清单或构建计划来构建一个新的容器镜像,如下所示:
$ docker image build -t my-centos .
请注意,前面的命令末尾有一个句点(.
):
图 4.15 – 从 CentOS 构建我们的第一个自定义镜像
上一个命令的意思是 Docker 构建器使用当前目录中的 Dockerfile 创建一个名为 my-centos
的新镜像。这里,命令末尾的句点指定了当前目录。我们也可以将前面的命令写成以下形式,结果是一样的:
$ docker image build -t my-centos -f Dockerfile .
在这里,我们可以省略 -f
参数,因为构建器假定 Dockerfile 的文件名就是 Dockerfile
。只有当我们的 Dockerfile 名称不同或不在当前目录时,才需要使用 -f
参数。
让我们分析一下在 图 4.15 中显示的输出。这些输出是由 Docker 构建工具生成的:
-
首先,我们有以下一行:
[+] Building 21.7s (7/7) FINISHED
这一行是在构建过程结束时生成的,尽管它看起来是第一行。它告诉我们构建大约花费了 22 秒,并且执行了 7 个步骤。
-
现在,让我们跳过接下来的几行,直到我们到达这一行:
=> [1/2] FROM docker.io/library/centos:7@sha256:c73f51...
这一行告诉我们构建器当前正在执行 Dockerfile 的哪一行(第 1 行,共 2 行)。我们可以看到这就是 Dockerfile 中的 FROM centos:7
语句。这是声明基础镜像,我们希望在其上构建我们的自定义镜像。接下来,构建器会从 Docker Hub 拉取该镜像,如果它在本地缓存中尚不存在的话。
-
现在,继续执行下一步。我已经将它缩短得比前一步还要简洁,专注于最关键的部分:
=> [2/2] RUN yum install -y wget
这是我们在 Dockerfile 中的第二行,我们希望使用 yum
包管理器来安装 wget
工具。
-
最后几行如下所示:
=> exporting to image 0.1s=> => exporting layers 0.1s=> => writing image sha256:8eb6daefac9659b05b17740...=> => naming to docker.io/library/my-centos
在这里,构建器完成了镜像的构建,并提供了该镜像的 sha256
代码 8eb6daefac9...
。
这告诉我们,生成的自定义镜像已被赋予 ID 8eb6daefac9...
,并且已标记为 my-centos:latest
。
现在我们已经分析了 Docker 镜像构建过程的工作原理以及涉及的步骤,让我们谈谈如何通过引入多步骤构建来进一步优化这个过程。
多步骤构建
为了演示为什么一个包含多个构建步骤的 Dockerfile 有用,让我们做一个示例 Dockerfile。让我们以一个用 C 语言编写的 Hello World 应用程序为例:
-
打开一个新的终端窗口并导航到本章的文件夹:
$ cd The-Ultimate-Docker-Container-Book/ch04
-
在你的章节文件夹中创建一个名为
multi-step-build
的新文件夹:$ mkdir multi-step-build
-
打开 VS Code 来处理这个文件夹:
$ code multi-step-build
-
在这个文件夹中创建一个名为
hello.c
的文件,并添加以下代码:#include <stdio.h>int main (void){ printf ("Hello, world!\n"); return 0;}
-
现在,我们要将这个应用程序容器化,并编写一个包含以下内容的 Dockerfile:
FROM alpine:3.12RUN apk update && \ apk add --update alpine-sdkRUN mkdir /appWORKDIR /appCOPY . /appRUN mkdir binRUN gcc -Wall hello.c -o bin/helloCMD /app/bin/hello
-
接下来,让我们构建这个镜像:
$ docker image build -t hello-world .
由于构建器需要安装 Alpine 软件开发工具包(SDK),其中包括我们需要的 C++ 编译器来构建应用程序,因此这会产生相当长的输出。
-
构建完成后,我们可以列出镜像并查看显示的大小,如下所示:
$ docker image ls | grep hello-world
在作者的情况下,输出如下所示:
hello-world latest 42c0c7086fbf 2 minutes ago 215MB
生成的镜像大小为 215 MB,显然太大了。最终,它只是一个 Hello World 应用程序。镜像之所以如此之大,是因为它不仅包含了 Hello World 的二进制文件,还包括了所有编译和链接应用程序所需的工具。但是在生产环境中运行应用程序时,这种做法并不理想。理想情况下,我们只希望镜像中包含最终的二进制文件,而不是整个 SDK。
正因为如此,我们应该将 Dockerfile 定义为多阶段构建。我们有一些阶段用于构建最终的工件,然后在最终阶段中,我们使用最小必要的基础镜像并将工件复制到其中。这将生成非常小的 Docker 镜像。让我们来做一下:
-
在你的文件夹中创建一个新的 Dockerfile,命名为
Dockerfile.multi-step
,并添加以下内容:FROM alpine:3.12 AS buildRUN apk update && \ apk add --update alpine-sdkRUN mkdir /appWORKDIR /appCOPY . /appRUN mkdir binRUN gcc hello.c -o bin/helloFROM alpine:3.12COPY --from=build /app/bin/hello /app/helloCMD /app/hello
在这里,我们有第一个阶段,别名为 build
,用于编译应用程序;然后,第二个阶段使用相同的 alpine:3.12
基础镜像,但没有安装 SDK,只是从 build
阶段复制二进制文件,使用 --from
参数,将其复制到最终的镜像中。
-
让我们按照如下方式再次构建镜像:
$ docker image build -t hello-world-small \ -f Dockerfile.multi-step .
-
让我们通过以下命令来比较镜像的大小:
$ docker image ls | grep hello-world
在这里,我们得到了以下输出:
hello-world-small latest 72c... 20 seconds ago 5.34MBhello-world latest 42c... 10 minutes ago 215
我们已经将镜像的大小从 215 MB 降到了 5.34 MB。这相当于减少了约 40 倍的大小。更小的镜像有许多优势,比如为黑客提供更小的攻击面、更低的内存和磁盘消耗、更快的容器启动时间,以及减少从镜像仓库(如 Docker Hub)下载镜像所需的带宽。
Dockerfile 最佳实践
在编写 Dockerfile 时,有一些推荐的最佳实践需要考虑,具体如下:
-
首先,我们需要考虑的是,容器本身是暂时性的。所谓暂时性,是指容器可以被停止和销毁,接着可以构建一个新的容器并用最少的设置和配置投入使用。这意味着我们应该尽量减少初始化容器内应用程序所需的时间,以及终止或清理应用程序所需的时间。
-
下一个最佳实践告诉我们,我们应该按照一定顺序排列 Dockerfile 中的各个命令,以便尽可能利用缓存。构建镜像的一个层可能会花费相当长的时间——有时需要几秒钟,甚至几分钟。在开发应用程序时,我们需要多次构建应用程序的容器镜像。我们希望将构建时间保持在最短。
当我们重新构建一个先前构建的镜像时,只有那些发生变化的层才会重新构建,但如果某一层需要重新构建,那么所有后续的层也需要重新构建。这一点非常重要,请记住。考虑以下示例:
FROM node:19RUN mkdir -p /app
WORKIR /app
COPY . /app
RUN npm install
CMD ["npm", "start"]
在这个例子中,Dockerfile 中第五行的npm install
命令通常需要较长的时间。一个经典的 Node.js 应用程序有许多外部依赖,这些依赖都在此步骤中下载并安装。完成这个步骤可能需要几分钟。因此,我们希望避免每次重建镜像时都运行npm install
,但开发人员在应用程序开发过程中会经常更改源代码。这意味着第四行的COPY
命令每次都会发生变化,因此这个层必须重新构建。但正如我们之前讨论的,这也意味着所有后续层必须重新构建,其中包括npm install
命令。为避免这种情况,我们可以稍微修改 Dockerfile,得到如下内容:
FROM node:19RUN mkdir -p /app
WORKIR /app
COPY package.json /app/
RUN npm install
COPY . /app
CMD ["npm", "start"]
在第四行,我们仅复制了npm install
命令需要的单个文件作为源文件,即package.json
文件。在典型的开发过程中,这个文件很少发生变化。因此,npm install
命令也只在package.json
文件变化时执行。所有其他频繁变化的内容都会在执行npm install
命令之后添加到镜像中。
另一个最佳实践是保持构成镜像的层数相对较小。镜像的层数越多,图形驱动程序需要处理的工作就越多,必须将这些层合并成一个根文件系统,供相应的容器使用。当然,这会耗费时间,因此,镜像的层数越少,容器的启动时间就越快。
那么,我们如何保持层数较低呢?记住,在 Dockerfile 中,每个以FROM
、COPY
或RUN
等关键字开头的行都会创建一个新层。减少层数的最简单方法是将多个独立的RUN
命令合并为一个。例如,假设我们的 Dockerfile 中有如下内容:
...RUN apt-get update
RUN apt-get install -y ca-certificates
RUN rm -rf /var/lib/apt/lists/*
...
我们可以将它们合并成一个单一的连接表达式,如下所示:
...RUN apt-get update \
&& apt-get install -y ca-certificates \
&& rm -rf /var/lib/apt/lists/*
...
前者将在生成的镜像中产生三个层,而后者只会创建一个层。
接下来的三条最佳实践都会导致更小的镜像。这为什么很重要呢?较小的镜像减少了从注册表下载镜像所需的时间和带宽。它们还减少了在 Docker 主机上本地存储镜像副本所需的磁盘空间以及加载镜像所需的内存。最后,更小的镜像也意味着更小的攻击面,降低了黑客攻击的风险。以下是提到的最佳实践:
-
有助于减小镜像大小的第一个最佳实践是使用
.dockerignore
文件。我们希望避免将不必要的文件和文件夹复制到镜像中,保持镜像尽可能精简。.dockerignore
文件的作用与.gitignore
文件类似,熟悉 Git 的人会很容易理解。在.dockerignore
文件中,我们可以配置模式,排除某些文件或文件夹在构建镜像时被包含在上下文中。 -
下一项最佳实践是避免向镜像的文件系统中安装不必要的软件包。再次强调,这是为了保持镜像尽可能精简。
-
最后但同样重要的是,建议使用多阶段构建,这样生成的镜像会尽可能小,并且只包含运行应用程序或应用服务所需的最基本内容。
在下一节中,我们将学习如何从先前保存的镜像创建 Docker 镜像。实际上,这可能看起来像是在恢复一个镜像。
保存和加载镜像
创建新容器镜像的第三种方式是通过从文件导入或加载它。容器镜像不过是一个 tarball。为了演示这一点,我们可以使用 docker image save
命令将现有镜像导出为 tarball,像这样:
$ mkdir backup$ docker image save -o ./backup/my-alpine.tar my-alpine
上述命令会将我们之前构建的 my-alpine
镜像导出到名为 ./backup/my-alpine.tar
的文件中:
图 4.16 – 将镜像导出为 tarball
另一方面,如果我们已经有一个现有的 tarball 并希望将其导入系统作为镜像,我们可以使用 docker image load
命令,如下所示:
$ docker image load -i ./backup/my-alpine.tar
上述命令的输出应如下所示:
Loaded image: my-alpine:latest
通过这些内容,我们已经学会了三种不同的构建 Docker 镜像的方法。我们可以通过交互方式、通过定义 Dockerfile,或者通过从 tarball 导入到我们的系统中来实现。
在下一节中,我们将讨论如何为现有的传统应用程序创建 Docker 镜像,从而在容器中运行它们,并从中受益。
提升与迁移 – 将传统应用容器化
我们不总是能从头开始开发一个全新的应用程序。更多时候,我们会面临一个巨大的传统应用程序组合,这些应用程序已经在生产环境中运行,并且为公司或公司客户提供关键价值。这些应用程序通常是有机发展起来的,结构非常复杂。文档稀缺,没人愿意去触碰这样的应用程序。常常,“永远不要碰一个正在运行的系统”这句话适用。然而,市场需求发生变化,因此需要更新或重写这些应用程序。由于资源和时间的限制,或者因为成本过高,完全重写往往不可行。我们该如何处理这些应用程序呢?我们是否可以将它们 Docker 化,并从容器带来的好处中受益?
结果证明我们可以做到。在 2017 年,Docker 向其企业客户推出了一款名为现代化传统应用(MTA)的程序,本质上承诺帮助客户将他们现有的或传统的 Java 和.NET 应用程序容器化,而无需更改一行代码。MTA 的重点是 Java 和.NET 应用程序,因为它们在典型企业中的传统应用程序占据了大部分份额。但它也可以用于任何用其他语言和平台编写的应用程序,例如 C、C++、Python、Node.js、Ruby、PHP 或 Go 等等。
让我们假设一下这样的遗留应用程序。假设我们有一个 10 年前编写的旧 Java 应用程序,并且在接下来的 5 年中不断进行更新。该应用程序基于 2006 年 12 月发布的 Java SE 6。它使用环境变量和属性文件进行配置。像用户名和密码这样的机密信息,通常通过一个机密库(例如 HashiCorp Vault)来提取,用于数据库连接字符串中。
现在,让我们更详细地描述一下提升和迁移遗留应用程序的每个必要步骤。
分析外部依赖
现代化过程的第一步之一是发现并列出遗留应用程序的所有外部依赖:
-
它使用数据库吗?如果使用,是什么数据库?连接字符串是什么样子的?
-
它是否使用外部 API,如信用卡审批或地理定位 API?API 密钥和密钥密码是什么?
-
它是从企业服务总线(ESB)消费数据,还是向其发布数据?
这些只是想到的几个可能的依赖项,实际上还有很多其他依赖。这些是应用程序与外部世界的连接点,我们需要意识到它们并创建一个清单。
源代码和构建指令
下一步是定位所有源代码和其他资产,例如图像、CSS 和 HTML 文件,这些文件都是应用程序的一部分。理想情况下,它们应该位于一个文件夹中。这个文件夹将成为我们的项目根目录,并且可以根据需要包含多个子文件夹。这个项目根目录将在我们为遗留应用程序创建容器镜像时作为上下文。请记住,Docker 构建器只会包括属于该上下文的文件;在我们的例子中,根项目文件夹就是上下文。
但是,确实有一个选项,可以在构建过程中从不同位置下载或复制文件,使用COPY
或ADD
命令。请参考在线文档,了解如何使用这两个命令的详细信息。如果遗留应用程序的源代码无法轻松包含在一个本地文件夹中,则此选项非常有用。
一旦我们了解了所有组成最终应用程序的部分,我们需要调查应用程序是如何构建和打包的。在我们的案例中,这很可能是通过使用make
来完成的。
再次提醒,让我们扩展我们的清单并记录下使用的确切构建命令。当我们编写 Dockerfile 时,需要使用这些信息。
配置
应用程序需要进行配置。在配置过程中提供的信息可能包括——例如——使用的应用程序日志类型、连接数据库的连接字符串,以及服务的主机名,例如企业服务总线(ESBs)或外部 API 的 URI,仅举几例。
我们可以区分几种配置类型,如下所示:
-
构建时:这是在构建应用程序及/或其 Docker 镜像时需要的信息。在创建 Docker 镜像时必须提供这些信息。
-
开发
与预发布
或生产
。这种配置在启动容器时应用于应用程序——例如,在生产环境中。 -
运行时:这是应用程序在运行时检索到的信息,例如访问外部 API 的机密信息。
机密信息
每个关键任务的企业应用程序都需要以某种形式处理机密信息。最常见的机密信息是访问数据库所需的连接信息,这些数据库用于持久化应用程序生成或使用的数据。其他机密信息包括访问外部 API 所需的凭证,例如信用评分查询 API。需要注意的是,在这里我们谈论的是应用程序本身需要向其使用或依赖的服务提供商提供的机密,而不是由应用程序用户提供的机密。此处的行动者是我们的应用程序,它需要经过外部权威和服务提供商的身份验证和授权。
传统应用程序获取机密信息的方式有多种。最差且最不安全的提供机密信息的方式是硬编码机密或从配置文件或环境变量中读取机密信息,在这些地方它们是明文可见的。更好的方式是在运行时从一个专门的机密存储读取这些机密,该存储会加密机密信息并通过安全连接提供给应用程序,例如传输层 安全性(TLS)。
再次提醒,我们需要创建一个清单,列出我们的应用程序使用的所有机密信息以及它们的获取方式。因此,我们需要问自己:我们的机密信息从哪里来?是通过环境变量或配置文件,还是通过访问外部密钥存储,如 HashiCorp Vault、AWS Secrets Manager 或 Azure Key Vault?
编写 Dockerfile
一旦我们完成了前几节讨论的所有项目的完整清单,我们就准备编写 Dockerfile 了。但我想提醒你:不要期望这是一项一次性完成的任务。你可能需要几轮修改,直到最终制作出你的 Dockerfile。Dockerfile 可能会很长且看起来不太美观,但这不是问题,只要我们能够得到一个有效的 Docker 镜像。等我们有了一个可工作的版本后,随时可以对 Dockerfile 进行微调。
基础镜像
让我们从确定想要使用的基础镜像开始,并从中构建我们的镜像。是否有一个符合我们要求的官方 Java 镜像?记住,我们的应用程序基于 Java SE 6。如果有这样的基础镜像,那么我们应该使用它。否则,我们将希望从一个 Linux 发行版开始,比如 Red Hat、Oracle 或 Ubuntu。在后者的情况下,我们将使用该发行版的合适包管理器(yum
、apt
或其他)来安装所需版本的 Java 和 Maven。为此,我们可以在 Dockerfile 中使用RUN
关键字。记住,RUN
允许我们在构建过程中执行镜像中的任何有效 Linux 命令。
汇总源代码
在这一步,我们确保所有成功构建应用程序所需的源文件和其他工件都成为镜像的一部分。这里,我们主要使用 Dockerfile 中的两个关键字:COPY
和ADD
。最初,镜像内部源文件的结构应该与主机上的结构相同,以避免任何构建问题。理想情况下,你应该有一个COPY
命令,它将主机上的所有根项目文件夹复制到镜像中。对应的 Dockerfile 代码片段可以像这样简单:
WORKDIR /appCOPY . .
注意
别忘了提供一个.dockerignore
文件,该文件位于项目的根文件夹,列出了所有不应成为构建上下文一部分的项目根文件夹中的文件和(子)文件夹。
如前所述,你也可以使用ADD
关键字将源代码和其他工件下载到 Docker 镜像中,这些源文件和工件不在构建上下文中,而是位于一个通过 URI 可以访问的地方,如下所示:
ADD http://example.com/foobar ./
这将会在镜像的工作目录中创建一个foobar
文件夹,并从 URI 中复制所有内容。
构建应用程序
在这一步,我们确保创建最终的工件,这些工件构成我们可执行的遗留应用程序。通常,这个工件是一个JAR
或WAR
文件,可能包含一些附加的 JAR 文件,也可能不包含。Dockerfile 的这一部分应模仿你在容器化之前传统的构建应用程序方式。因此,如果你使用的是 Maven 作为构建自动化工具,那么对应的 Dockerfile 代码片段可能看起来像这样简单:
RUN mvn --clean install
在这个步骤中,我们可能还希望列出应用使用的环境变量,并提供合理的默认值。但绝不要为提供给应用的机密环境变量(例如数据库连接字符串)设置默认值!使用ENV
关键字定义你的变量,像这样:
ENV foo=barENV baz=123
同时,声明应用正在监听的所有端口,并且这些端口需要通过EXPOSE
关键字从容器外部访问,像这样:
EXPOSE 5000EXPOSE 15672/tcp
接下来,我们将解释start
命令。
定义启动命令
通常,如果是独立应用,Java 应用会使用类似java -jar <mainapplication jar>
的命令启动。如果是 WAR 文件,那么start
命令可能会有所不同。因此,我们可以定义ENTRYPOINT
或CMD
来使用此命令。最终的 Dockerfile 中的语句可能如下所示:
ENTRYPOINT java -jar pet-shop.war
但通常情况下,这种做法过于简单,我们需要执行一些预先运行的任务。在这种情况下,我们可以编写一个脚本文件,包含需要执行的一系列命令,用来准备环境并运行应用。这样的文件通常叫做docker-entrypoint.sh
,但你可以根据自己的需要自由命名。确保文件是可执行的——例如,在主机上运行以下命令:
chmod +x ./docker-entrypoint.sh
Dockerfile 的最后一行将如下所示:
ENTRYPOINT ./docker-entrypoint.sh
既然你已经得到了关于如何容器化遗留应用的提示,那么现在是时候总结一下,问问自己,值得付出这番努力吗?
为什么要费劲?
在这一点上,我能看到你正在挠头,心里想:为什么要费劲呢?为什么要花这么大的力气去容器化一个遗留应用呢?这样做有什么好处?
原来,投资回报率(ROI)是巨大的。Docker 的企业客户在 2018 年和 2019 年的 DockerCon 等会议上公开披露,他们在容器化传统应用时看到了以下两个主要的好处:
-
维护成本节省超过 50%
-
新版本部署之间的时间减少最多可达 90%
通过减少维护开销节省的成本可以直接再投资,用于开发新特性和新产品。在传统应用的新版本发布过程中节省的时间使得企业变得更加灵活,能够更快速地响应不断变化的客户需求或市场需求。
现在我们已经详细讨论了如何构建 Docker 镜像,是时候学习如何通过软件交付管道的各个阶段来传输这些镜像了。
共享或传输镜像
为了能够将我们自定义的镜像传输到其他环境,我们需要给它一个全局唯一的名称。这个操作通常称为镜像标签。然后,我们需要将镜像发布到一个中央位置,其他感兴趣或有权限的方可以从该位置拉取镜像。这些中央位置称为镜像注册表。
在接下来的章节中,我们将更详细地描述这一过程是如何工作的。
镜像标记
每个镜像都有一个所谓的标签。标签通常用于为镜像版本化,但它的用途远不止版本号。如果我们在操作镜像时没有明确指定标签,那么 Docker 会默认使用最新标签。这在从 Docker Hub 拉取镜像时非常重要,如以下示例所示:
$ docker image pull alpine
上述命令将从 Docker Hub 拉取alpine:latest
镜像。如果我们想明确指定标签,可以这样做:
$ docker image pull alpine:3.5
这将拉取带有标签3.5
的 Alpine 镜像。
解密镜像命名空间
到目前为止,我们已经拉取了各种镜像,并没有太多关心这些镜像的来源。你的 Docker 环境已配置好,默认情况下,所有镜像都从 Docker Hub 拉取。我们也只是从 Docker Hub 拉取了所谓的官方镜像,例如 alpine
或 busybox
。
现在,是时候拓宽视野,学习如何命名镜像的命名空间了。定义镜像的最通用方法是使用其完全限定名,格式如下:
<registry URL>/<User or Org>/<name>:<tag>
让我们更详细地看一下:
命名空间部分 | 描述 |
---|
| <``registry URL>
| 这是我们希望从中拉取镜像的注册表的 URL。默认情况下,这是docker.io
。更一般来说,这可以是registry.acme.com
。除了 Docker Hub,还有许多公共注册表可以从中拉取镜像。以下是一些示例,顺序不分先后:
-
Google,网址为
cloud.google.com/container-registry
-
Amazon AWS Amazon 弹性容器注册表(ECR),网址为
aws.amazon.com/ecr/
-
Microsoft Azure,网址为
azure.microsoft.com/en-us/services/container-registry/
-
Red Hat,网址为
access.redhat.com/containers/
-
Artifactory,网址为
jfrog.com/integration/artifactorydocker-registry/
|
<User> 或 <Org> |
这是在 Docker Hub 上定义的个人或组织的私有 Docker ID —— 或者在任何其他注册表上,例如 microsoft 或 oracle 。 |
---|---|
<``name> |
这是镜像的名称,通常也称为仓库。 |
<``tag> |
这是镜像的标记。 |
让我们来看一个示例,如下所示:
https://registry.acme.com/engineering/web-app:1.0
这里,我们有一个镜像 web-app
,它被标记为版本 1.0
,并属于 engineering
组织,存储在私有注册表 https://registry.acme.com
上。
现在,有一些特殊的约定:
-
如果我们省略注册表 URL,Docker Hub 会被自动采用
-
如果我们省略标签,则会自动使用
latest
标签 -
如果它是 Docker Hub 上的官方镜像,则无需用户或组织命名空间
下面是一些表格形式的示例:
镜像 | 描述 |
---|---|
alpine |
Docker Hub 上的官方 alpine 镜像,标签为 latest 。 |
ubuntu:22.04 |
Docker Hub 上的官方 ubuntu 镜像,标签或版本为 22.04 。 |
hashicorp/vault |
Docker Hub 上一个名为 hashicorp 的组织的 vault 镜像,标签为 latest 。 |
acme/web-api:12.0 |
与 acme 组织关联的 web-api 镜像版本 12.0 ,该镜像位于 Docker Hub 上。 |
gcr.io/jdoe/sample-app:1.1 |
Google 容器注册表中属于 ID 为 jdoe 的个人的 sample-app 镜像,标签为 1.1 。 |
现在我们已经知道了 Docker 镜像的完全限定名称是如何定义的,以及它的各个部分,那么接下来我们来讨论一些我们可以在 Docker Hub 上找到的特殊镜像。
解释官方镜像
在前面的表格中,我们提到了“官方镜像”几次。这个需要做一下解释。
镜像存储在 Docker Hub 注册表的仓库中。官方仓库是一组托管在 Docker Hub 上的仓库,这些仓库由个人或组织策划,这些个人或组织同时也负责镜像中打包的软件。我们来看看这个意味着什么。Ubuntu Linux 发行版背后有一个官方组织。这个团队还提供包含他们的 Ubuntu 发行版的官方 Docker 镜像版本。
官方镜像旨在提供基本的操作系统仓库、流行编程语言运行时镜像、常用数据存储和其他重要服务的镜像。
Docker 赞助了一个团队,负责审核并发布所有在 Docker Hub 公共仓库中策划的镜像。此外,Docker 还会扫描所有官方镜像的漏洞。
将镜像推送到注册表
创建自定义镜像是完全没问题的,但总有那么一刻,我们希望将镜像共享或部署到目标环境中,比如测试、质量保证(QA)或生产系统。为此,我们通常会使用容器注册表。最流行的公共注册表之一是 Docker Hub。它是你 Docker 环境中的默认注册表,也是我们迄今为止拉取所有镜像的注册表。
在注册表中,我们通常可以创建个人或组织账户。例如,作者在 Docker Hub 上的账户是 gnschenker
。个人账户适合个人使用。如果我们想要专业使用这个注册表,可能会想要在 Docker Hub 上创建一个组织账户,比如 acme
。后者的优势在于,组织可以有多个团队,并且各个团队可以拥有不同的权限。
为了能够将镜像推送到我在 Docker Hub 上的账户,我需要相应地给它打标签。假设我想将最新版本的 Alpine 镜像推送到我的账户,并为其添加标签 1.0
。我可以按照以下方式操作:
-
使用以下命令为现有镜像
alpine:latest
打标签:$ docker image tag alpine:latest gnschenker/alpine:1.0
在这里,Docker 不会创建一个新镜像,而是创建对现有镜像alpine:latest
的新引用,并将其命名为gnschenker/alpine:1.0
。
-
现在,为了能够推送镜像,我需要按照如下方式登录我的账户:
$ docker login -u gnschenker -p <my secret password>
-
确保将
gnschenker
替换为你自己的 Docker Hub 用户名,并将<my secret password>
替换为你的密码。 -
登录成功后,我可以像这样推送镜像:
$ docker image push gnschenker/alpine:1.0
我将在终端窗口中看到类似这样的输出:
The push refers to repository [docker.io/gnschenker/alpine]04a094fe844e: Mounted from library/alpine
1.0: digest: sha256:5cb04fce... size: 528
对于每个我们推送到 Docker Hub 的镜像,系统会自动创建一个仓库。一个仓库可以是私有的也可以是公开的。任何人都可以从公开仓库拉取镜像。而从私有仓库拉取镜像,只有在你登录到注册表并且拥有必要权限的情况下,才能进行拉取。
总结
在本章中,我们讨论了什么是容器镜像,以及我们如何构建和发布它们。正如我们所看到的,镜像可以通过三种不同的方式创建——手动创建、自动创建,或者通过将 tarball 导入系统。我们还学习了构建自定义镜像时常用的一些最佳实践。最后,我们简要介绍了如何通过将镜像上传到容器镜像注册表(如 Docker Hub)来分享或发布自定义镜像。
在下一章中,我们将介绍 Docker 卷,它们可以用于持久化容器的状态。我们还将展示如何为容器内运行的应用程序定义单独的环境变量,以及如何使用包含整个配置设置的文件。
问题
请尝试回答以下问题以评估你的学习进度:
-
如何创建一个从 Ubuntu 版本 22.04 继承的 Dockerfile,并且安装
ping
并在容器启动时运行ping
?默认的 ping 地址应为127.0.0.1
。 -
如何创建一个新的容器镜像,使用
alpine:latest
作为基础镜像,并在其上安装curl
?将新镜像命名为my-alpine:1.0
。 -
创建一个 Dockerfile,使用多个步骤来创建一个最小大小的 Hello World 应用镜像,使用 C 或 Go 编写。
-
请列出 Docker 容器镜像的三个基本特征。
-
你想将名为
foo:1.0
的镜像推送到你的jdoe
个人账户上的 Docker Hub。以下哪个解决方案是正确的?-
$ docker container
push foo:1.0
-
$ docker image tag
foo:1.0 jdoe/foo:1.0
-
$ docker image
push jdoe/foo:1.0
-
$ docker login -u jdoe -p <``your password>
-
$ docker image tag
foo:1.0 jdoe/foo:1.0
-
$ docker image
push jdoe/foo:1.0
-
$ docker login -u jdoe -p <``your password>
-
$ docker container tag
foo:1.0 jdoe/foo:1.0
-
$ docker container
push jdoe/foo:1.0
-
$ docker login -u jdoe -p <``your password>
-
$ docker image push
foo:1.0 jdoe/foo:1.0
-
答案
以下是本章问题的可能答案:
-
Dockerfile 可能如下所示:
FROM ubuntu:22.04RUN apt-get update && \apt-get install -y iputils-pingCMD ping 127.0.0.1
请注意,在 Ubuntu 中,ping
工具是 iputils-ping
包的一部分。你可以使用以下命令构建名为 pinger 的镜像——例如:
$ docker image build -t mypinger .
-
Dockerfile 可能是这样的:
FROM alpine:latestRUN apk update && \apk add curl
使用以下命令构建镜像:
$ docker image build -t my-alpine:1.0 .
-
一个 Go 应用的 Dockerfile 可能是这样的:
FROM golang:alpineWORKDIR /appADD . /appRUN go env -w GO111MODULE=offRUN cd /app && go build -o goappENTRYPOINT ./goapp
你可以在 ~/The-Ultimate-Docker-Container-Book/sample-solutions/ch04/answer03
文件夹中找到完整的解决方案。
-
一个 Docker 镜像具有以下特性:
-
它是不可变的
-
它由一个或多个层组成
-
它包含运行打包应用所需的文件和文件夹
-
-
正确答案是 C。首先,你需要登录到 Docker Hub;然后,你必须使用用户名正确地标记镜像。最后,你必须推送镜像。
第五章:5
数据卷和配置
在上一章中,我们学习了如何构建和共享我们的容器镜像。重点放在如何构建尽可能小的镜像,只包含容器化应用程序所需的工件。
在本章中,我们将学习如何使用有状态容器——即那些消耗和生成数据的容器。我们还将学习如何在运行时和镜像构建时,通过使用环境变量和配置文件来配置容器。
以下是我们将讨论的主题列表:
-
创建和挂载数据卷
-
容器之间的数据共享
-
使用主机卷
-
定义图像中的卷
-
配置容器
完成本章内容后,您将能够执行以下操作:
-
创建、删除和列出数据卷
-
将现有数据卷挂载到容器中
-
使用数据卷从容器内部创建持久数据
-
使用数据卷在多个容器之间共享数据
-
使用数据卷将任何主机文件夹挂载到容器中
-
在访问数据卷中的数据时,为容器定义访问模式(读/写或只读)
-
配置在容器中运行的应用程序的环境变量
-
通过使用构建参数化 Dockerfile
技术要求
对于本章内容,您需要在机器上安装 Docker Desktop。本章没有随附代码。
在开始之前,我们需要在代码库中为第五章创建一个文件夹:
-
使用此命令导航到您从 GitHub 检出代码的文件夹:
$ cd ~/The-Ultimate-Docker-Container-Book
注意
如果您没有在默认位置检出 GitHub 仓库,则前面的命令可能会有所不同。
-
为第五章创建一个子文件夹并进入该文件夹:
$ mkdir ch05 && cd ch05
让我们开始吧!
创建和挂载数据卷
所有有意义的应用程序都消耗或生成数据。然而,理想情况下,容器应该是无状态的。我们该如何处理这个问题呢?一种方法是使用 Docker 卷。卷允许容器消耗、生成和修改状态。卷的生命周期超出了容器的生命周期。当使用卷的容器终止时,卷仍然存在。这对状态的持久性非常有利。
修改容器层
在深入讨论卷之前,先来讨论一下如果容器中的应用程序更改了容器文件系统中的某些内容会发生什么。在这种情况下,所有的更改都发生在我们在第四章中介绍的可写容器层中,创建和管理容器镜像。让我们快速演示一下:
-
运行一个容器并在其中执行一个脚本,脚本创建一个新文件,如下所示:
$ docker container run --name demo \ alpine /bin/sh -c 'echo "This is a test" > sample.txt'
-
上述命令创建了一个名为
demo
的容器,并在该容器内创建了一个名为sample.txt
的文件,文件内容为This is a test
。容器在执行echo
命令后退出,但仍保留在内存中,供我们进行调查。 -
让我们使用
diff
命令,找出与原始镜像的文件系统相比,容器的文件系统发生了哪些变化,如下所示:$ docker container diff demo
输出应该类似于下图所示:
A /sample.txt
- 一个新文件,如字母
A
所示,已被添加到容器的文件系统中,这符合预期。由于所有来自底层镜像(在本例中为 Alpine)的层都是不可变的,更改只能发生在可写的容器层中。
与原始镜像相比发生变化的文件将标记为 C
,而被删除的文件将标记为 D
。
现在,如果我们从内存中移除容器,它的容器层也会被删除,所有的更改将不可逆地丢失。如果我们希望我们的更改能够持续存在,即使容器已经被销毁,这就不是一个解决方案。幸运的是,我们有更好的选择,那就是 Docker 卷。让我们来了解一下它们。
创建卷
当在 macOS 或 Windows 计算机上使用 Docker Desktop 时,容器并不是直接在 macOS 或 Windows 上运行,而是在 Docker Desktop 创建的(隐藏的)虚拟机中运行。
为了展示底层数据结构是如何以及在何处创建到相应的文件系统(macOS 或 Windows)中的,我们需要发挥一些创造力。另一方面,如果我们在 Linux 计算机上做相同的事情,一切都会简单直接。
让我们先做一个简单的练习来创建一个卷:
-
打开一个新的终端窗口并输入以下命令:
$ docker volume create sample
你应该会看到这样的响应:
sample
在这里,创建的卷的名称将是输出内容。
默认的卷驱动程序是所谓的本地驱动程序,它将数据存储在主机文件系统中。
-
查找数据在主机上存储位置的最简单方法是使用
docker volume inspect
命令检查我们刚创建的卷。实际位置可能因系统而异,因此这是找到目标文件夹的最安全方法。让我们使用这个命令:$ docker volume inspect sample
我们应该看到类似这样的内容:
图 5.1 – 检查名为 sample 的 Docker 卷
主机文件夹可以在输出中的 Mountpoint
下找到。在我们的例子中,文件夹是 /var/lib/docker/volumes/sample/_data
。
-
另外,我们可以通过 Docker Desktop 的仪表板来创建卷:
-
打开 Docker Desktop 的仪表板。
-
在左侧,选择 Volumes 标签。
-
在右上角,点击 Create 按钮,如下图所示:
-
图 5.2 – 使用 Docker Desktop 创建一个新的 Docker 卷
- 输入
sample-2
作为新卷的名称,并点击创建。你现在应该看到这个:
图 5.3 – 在 Docker Desktop 中显示的 Docker 卷列表
还有一些第三方的卷驱动程序,以插件的形式提供。我们可以在create
命令中使用--driver
参数来选择不同的卷驱动程序。
其他卷驱动程序使用不同类型的存储系统来支持卷,比如云存储、网络文件系统(NFS)驱动、软件定义存储等。不过,其他卷驱动程序的正确使用讨论超出了本书的范围。
挂载卷
一旦我们创建了一个命名卷,我们可以通过以下步骤将其挂载到容器中:
-
为此,我们可以在
docker container run
命令中使用--volume
或-v
参数,如下所示:$ docker container run --name test -it \ -v sample:/data \ alpine /bin/sh
如果你在一个干净的 Docker 环境中工作,那么此命令生成的输出应该类似于这个:
Unable to find image 'alpine:latest' locally latest:Pulling from library/alpine
050382585609: Pull complete
Digest: sha256: 8914eb54f968791faf6a86...
Status: Downloaded newer image for alpine:latest
/ #
否则,你应该只会看到运行在 Alpine 容器中的 Bourne shell 提示符:
/ #
前面的命令将示例卷挂载到容器内的/data
文件夹。
-
在容器内,我们现在可以在
/data
文件夹中创建文件,如下所示:/ # cd /data/ # echo "Some data" > data.txt/ # echo "Some more data" > data2.txt
-
如果我们导航到包含该卷数据的主机文件夹并列出其内容,我们应该能够看到容器中刚刚创建的两个文件。但这在 Mac 或 Windows 电脑上会稍微复杂一些,将在访问 Docker 卷一节中详细解释。敬请期待。
-
通过按Ctrl + D退出工具容器。
-
现在,让我们删除悬挂的
test
容器:$ docker container rm test
-
接下来,我们必须基于 CentOS 运行另一个命令。这一次,我们甚至将卷挂载到另一个容器文件夹
/app/data
,如下所示:$ docker container run --name test2 -it --rm \ -v sample:/app/data \ centos:7 /bin/bash
你应该看到类似于这样的输出:
Unable to find image 'centos:7' locally7: Pulling from library/centos
8ba884070f61: Pull complete
Digest: sha256:a799dd8a2ded4a83484bbae769d9765...
Status: Downloaded newer image for centos:7
[root@275c1fe31ec0 /]#
前面的输出的最后一行表明我们处于运行在 CentOS 容器中的 Bash shell 提示符下。
-
一旦进入 CentOS 容器,我们可以导航到我们已挂载卷的
/app/data
文件夹,并列出其内容,如下所示:[root@275c1fe31ec0 /]# cd /app/data[root@275c1fe31ec0 /]# ls –l
如预期的那样,我们应该看到这两个文件:
-rw-r--r-- 1 root root 10 Dec 4 14:03 data.txt-rw-r--r-- 1 root root 15 Dec 4 14:03 data2.txt
这就是数据在 Docker 卷中持久化超出容器生命周期的决定性证据,此外,卷可以被其他容器(甚至与第一个使用它的容器不同的容器)重复使用。
需要注意的是,我们在容器中挂载 Docker 卷的文件夹被排除在联合文件系统之外。也就是说,任何在此文件夹及其子文件夹中的更改都不会成为容器层的一部分,而是会被持久化到由卷驱动程序提供的支持存储中。这一点非常重要,因为当容器停止并从系统中删除时,容器层会被删除。
- 使用Ctrl + D退出 CentOS 容器。
太好了——我们已经学会了如何将 Docker 卷挂载到容器中!接下来,我们将学习如何从系统中删除现有的卷。
删除卷
卷可以使用 docker volume rm
命令删除。重要的是要记住,删除卷会不可逆地销毁其中的所有数据,因此这是一个危险命令。在这方面,Docker 给我们提供了一些帮助,因为它不允许我们删除正在被容器使用的卷。始终确保在删除或移除卷之前,要么备份好其中的数据,要么确认不再需要这些数据。接下来,让我们按照以下步骤学习如何删除卷:
-
以下命令删除我们之前创建的示例卷:
$ docker volume rm sample
-
执行前面的命令后,请再次确认主机上的文件夹是否已删除。你可以使用以下命令列出系统上定义的所有卷:
$ docker volume ls
确保 sample
卷已经被删除。
-
现在,亦请从系统中删除
sample-2
卷。 -
要删除所有正在运行的容器以清理系统,请运行以下命令:
$ docker container rm -v -f $(docker container ls -aq)
-
请注意,使用
-v
或--volume
标志在移除容器时,可以要求系统同时删除与该容器关联的匿名卷。当然,这只有在该卷仅被该容器使用时才有效。
在接下来的部分中,我们将展示如何在使用 Docker Desktop 时访问卷的底层文件夹。
访问 Docker 卷
现在,让我们假设我们正在使用 macOS 操作系统。这个操作系统不是基于 Linux,而是基于其他 Unix 变种。让我们看看是否能找到 sample
和 sample-2
卷的数据结构,正如 docker volume inspect
命令告诉我们的那样:
-
首先,让我们创建两个命名的 Docker 卷,可以使用命令行或通过 Docker Desktop 仪表板执行相同的操作:
$ docker volume create sample$ docker volume create sample-2
-
在你的终端中,尝试导航到该文件夹:
$ cd /var/lib/docker/volumes/sample/_data
在作者的 MacBook Air 上,执行前面的命令后返回了以下响应:
cd: no such file or directory: /var/lib/docker/volumes/sample/_data
由于 Docker 并非在 Mac 上本地运行,而是运行在一个精简的虚拟机内,正如本章前面提到的,因此这种情况是可以预期的。
同样,如果你使用的是 Windows 机器,你将无法在 inspect
命令所指示的位置找到数据。
事实证明,在 Mac 上,Docker 创建的虚拟机的数据可以在 ~/Library/Containers/com.docker.docker/Data/vms/0
文件夹中找到。
要访问这些数据,我们需要以某种方式进入这个虚拟机。在 Mac 上,我们有两种方式可以实现。第一种是使用 terminal screen
命令,但这非常特定于 macOS,因此我们在这里不讨论。第二种方式是通过特殊的 nsenter
命令访问 Mac 上 Docker 的文件系统,该命令应在 Linux 容器(如 Debian)内执行。这在 Windows 上也有效,因此我们将展示使用第二种方式所需的步骤。
-
要运行一个可以检查系统上底层主机文件系统的容器,请使用以下命令:
$ docker container run -it --privileged --pid=host \ debian nsenter -t 1 -m -u -n -i sh
在运行容器时,我们在容器内执行以下命令:
nsenter -t 1 -m -u -n -i sh
如果这听起来很复杂,不用担心;随着我们继续阅读这本书,你会逐渐明白。如果有一个要记住的要点,那就是要意识到正确使用容器有多么强大。
- 在这个容器中,我们现在可以列出所有定义的卷,使用
/ # ls -l /var/lib/docker/volumes
。我们得到的结果应该类似于此:
图 5.4 – 通过 nsenter 列出 Docker 卷
-
接下来,导航到表示卷挂载点的文件夹:
/ # cd /var/lib/docker/volumes/sample/_data
-
然后列出其内容,如下所示:
/var/lib/docker/volumes/sample/_data # ls –l
这应该输出如下内容:
total 0
文件夹目前是空的,因为我们还没有在卷中存储任何数据。
-
类似地,对于我们的
sample-2
卷,我们可以使用以下命令:/ # cd /var/lib/docker/volumes/sample-2/_data/var/lib/docker/volumes/sample-2/ # ls –l
这应该输出如下内容:
total 0
再次,这表示文件夹目前是空的。
-
接下来,让我们从 Alpine 容器中在
sample
卷中生成两个带数据的文件。首先,打开一个新的终端窗口,因为另一个窗口被我们的nsenter
会话阻塞。 -
要运行容器并将
sample
卷挂载到容器的/data
文件夹中,请使用以下代码:$ docker container run --rm -it \ -v sample:/data alpine /bin/sh
-
在容器内的
/data
文件夹中生成两个文件,如下所示:/ # echo "Hello world" > /data/sample.txt/ # echo "Other message" > /data/other.txt
-
通过按Ctrl + D退出 Alpine 容器。
-
回到
nsenter
会话,使用以下命令再次尝试列出示例卷的内容:/ # cd /var/lib/docker/volumes/sample/_data/ # ls -l
这一次,你应该看到如下内容:
total 8-rw-r--r-- 1 root root 10 Dec 4 14:03 data.txt
-rw-r--r-- 1 root root 15 Dec 4 14:03 data2.txt
这表示我们已将数据写入主机的文件系统。
-
让我们尝试在这个特殊容器中创建一个文件,然后列出文件夹的内容,如下所示:
/ # echo "I love Docker" > docker.txt
-
现在,让我们看看我们得到了什么:
/ # ls –l
这给我们带来了类似于这样的结果:
total 12-rw-r--r-- 1 root root 10 Dec 4 14:03 data.txt
-rw-r--r-- 1 root root 15 Dec 4 14:03 data2.txt
-rw-r--r-- 1 root root 14 Dec 4 14:25 docker.txt
-
让我们看看能否从挂载示例卷的容器中看到这个新文件。在一个新的终端窗口中运行此命令:
$ docker container run --rm \ -v sample:/data \ centos:7 ls -l /data
这应该输出如下内容:
total 12-rw-r--r-- 1 root root 10 Dec 4 14:03 data.txt
-rw-r--r-- 1 root root 15 Dec 4 14:03 data2.txt
-rw-r--r-- 1 root root 14 Dec 4 14:25 docker.txt
上述输出显示我们可以直接向支持卷的主机文件夹添加内容,然后从挂载该卷的容器中访问它。
- 要退出我们的特殊特权容器,可以按Ctrl + D两次。
我们现在已经使用两种不同的方法创建了数据:
-
从一个挂载了示例卷的容器中
-
使用特殊特权文件夹访问 Docker Desktop 使用的隐藏虚拟机,并直接写入示例卷的后备文件夹
在接下来的部分中,我们将学习如何在容器之间共享数据。
容器之间共享数据
容器就像是运行在其中的应用程序的沙盒。这大多数时候是有益的并且是我们期望的,它可以保护运行在不同容器中的应用程序相互隔离。它还意味着,容器内应用程序可见的整个文件系统对于该应用程序来说是私有的,其他运行在不同容器中的应用程序无法干扰它。
然而,有时我们希望在容器之间共享数据。比如,运行在容器 A中的应用程序生成了一些数据,而这些数据将被运行在容器 B中的另一个应用程序使用。我们如何实现这个目标呢?我相信你已经猜到了——我们可以为此目的使用 Docker 卷。我们可以创建一个卷并将其挂载到容器 A,以及容器 B。这样,应用程序 A 和 B 都可以访问相同的数据。
现在,正如每次多个应用程序或进程同时访问数据时一样,我们必须非常小心,以避免数据不一致。为了避免并发问题,如竞争条件,理想情况下我们应该只有一个应用程序或进程在创建或修改数据,而所有其他并发访问这些数据的进程只能读取它。
竞争条件
竞争条件是计算机编程中可能发生的一种情况,当程序或进程的输出受到事件的顺序和时机的影响,以一种不可预测或意外的方式。在竞争条件中,程序的两个或多个部分同时尝试访问或修改相同的数据或资源,结果取决于这些事件的时机。这可能导致不正确或不一致的输出、错误或崩溃。
我们可以强制容器内的进程只能读取卷中的数据,通过将该卷挂载为只读模式。下面是我们如何做到这一点:
-
执行以下命令:
$ docker container run -it --name writer \ -v shared-data:/data \ .alpine /bin/sh
在这里,我们创建了一个名为writer
的容器,并在默认的读写模式下挂载了一个卷shared-data
。
-
尝试在此容器中创建一个文件,像这样:
# / echo "I can create a file" > /data/sample.txt
它应该成功。
-
按下Ctrl + D 或输入
exit
并按Enter键退出此容器。 -
然后,执行以下命令:
$ docker container run -it --name reader \ -v shared-data:/app/data:ro \ ubuntu:22.04 /bin/bash
这里我们有一个名为reader
的容器,它将相同的卷挂载为只读(ro)。
-
首先,确保你可以在第一个容器中看到创建的文件,像这样:
$ ls -l /app/data
这应该会给你类似这样的结果:
total 4-rw-r--r-- 1 root root 20 Jan 28 22:55 sample.txt
-
然后,尝试创建一个文件,像这样:
# / echo "Try to break read/only" > /app/data/data.txt
它将失败,并显示以下消息:
bash: /app/data/data.txt: Read-only file system
这是预期的,因为该卷被挂载为只读模式。
-
让我们通过在命令提示符下输入
exit
来退出容器。回到主机后,让我们清理所有容器和卷,如下所示:$ docker container rm -f $(docker container ls -aq)$ docker volume rm $(docker volume ls -q)
练习:仔细分析之前的命令,尝试理解它们到底做了什么以及如何工作。
接下来,我们将展示如何将来自 Docker 主机的任意文件夹挂载到容器中。
使用主机卷
在某些场景中,例如开发新的容器化应用程序或当容器化应用程序需要使用某个特定文件夹中的数据(例如由遗留应用程序生成的数据)时,使用挂载特定主机文件夹的卷非常有用。让我们来看以下示例:
$ docker container run --rm -it \ -v $(pwd)/src:/app/src \
alpine:latest /bin/sh
上述表达式交互式地启动一个带有 shell 的 Alpine 容器,并将当前目录中的src
子文件夹挂载到容器的/app/src
目录。我们需要使用$(pwd)
(或者pwd
),即当前目录,因为在使用卷时,我们始终需要使用绝对路径。
开发人员在工作时经常使用这些技术,尤其是当他们在容器中运行应用程序时,希望确保容器始终包含代码的最新更改,而无需在每次更改后重新构建镜像和重新运行容器。
让我们做一个示例来展示它是如何工作的。假设我们要创建一个简单的静态网站,并使用 Nginx 作为我们的 Web 服务器,如下所示:
-
首先,让我们在主机上创建一个新的子文件夹。最好将它创建在我们在本章开始时创建的章节文件夹中。在那里,我们将放置我们的 Web 资产,如 HTML、CSS 和 JavaScript 文件。使用以下命令来创建子文件夹并导航到它:
$ cd ~/The-Ultimate-Docker-Container-Book/ch05$ mkdir my-web && cd my-web
-
然后,创建一个简单的网页,像这样:
$ echo "<h1>Personal Website</h1>" > index.xhtml
-
现在,添加一个 Dockerfile,里面包含构建包含我们示例网站的镜像的指令。向文件夹中添加一个名为
Dockerfile
的文件,并写入以下内容:FROM nginx:alpineCOPY . /usr/share/nginx/html
Dockerfile 从最新的 Alpine 版本的 Nginx 开始,然后将当前主机目录中的所有文件复制到容器的/usr/share/nginx/html
文件夹中。这是 Nginx 期望 Web 资产所在的位置。
-
现在,让我们使用以下命令构建镜像:
$ docker image build -t my-website:1.0 .
请不要忘记在上面的命令末尾加上句点(.
)。此命令的输出将类似于以下内容:
图 5.5 – 为示例 Nginx Web 服务器构建 Docker 镜像
-
最后,我们将从这个镜像运行一个容器。我们将以分离模式运行该容器,如下所示:
$ docker container run -d \ --name my-site \ -p 8080:80 \ my-website:1.0
注意-p 8080:80
参数。我们还没有讨论这个,但我们将在第十章中详细讲解,使用 单主机网络。目前,只需要知道它将 Nginx 监听传入请求的容器端口80
映射到你笔记本电脑的8080
端口,这样你就可以访问应用程序了。
-
现在,打开浏览器标签页并导航到
http://localhost:8080/index.xhtml
;你应该能看到你的网站,目前它只有一个标题,个人网站。 -
现在,使用你最喜欢的编辑器编辑
index.xhtml
文件,使其看起来像这样:<h1>Personal Website</h1><p>This is some text</p>
-
现在,保存并刷新浏览器。哦!那不行。浏览器仍然显示
index.xhtml
文件的上一个版本,只包含标题。所以,让我们停止并删除当前容器,然后重建镜像并重新运行容器,如下所示:$ docker container rm -f my-site$ docker image build -t my-website:1.0 .$ docker container run -d \ --name my-site \ -p 8080:80 \ my-website:1.0
-
再次刷新浏览器。这次,新内容应该会显示出来。好吧,这次行得通了,但过程中的摩擦太大。想象一下,每次对你的网站进行简单更改时都需要这样做。这是不可持续的。
-
现在是时候使用主机挂载的卷了。再次删除当前容器,并使用卷挂载重新运行它,像这样:
$ docker container rm -f my-site$ docker container run -d \ --name my-site \ -v $(pwd):/usr/share/nginx/html \ -p 8080:80 \ my-website:1.0
注意
如果你在 Windows 上工作,将会显示一个弹出窗口,提示 Docker 需要访问硬盘,你需要点击 共享 访问 按钮。
-
现在,将一些内容附加到
index.xhtml
文件中并保存。然后,刷新浏览器。你应该能看到变化。这正是我们想要实现的效果;我们也称之为编辑并继续体验。你可以在网页文件中做任意更改,并立即在浏览器中看到结果,而不需要重新构建镜像或重新启动包含你网站的容器。 -
当你玩完你的网页服务器并希望清理系统时,可以使用以下命令删除容器:
$ docker container rm -f my-site
重要的是要注意,现在的更新是双向传播的。如果你在主机上进行更改,它们会传播到容器中,反之亦然。同样重要的是,当你将当前文件夹挂载到容器的目标文件夹/usr/share/nginx/html
时,已经存在的内容会被主机文件夹的内容替换。
在下一节中,我们将学习如何定义在 Docker 镜像中使用的卷。
在镜像中定义卷
如果我们回顾一下在第四章《创建与管理容器镜像》中学到的内容,我们会看到:每个容器的文件系统,在启动时,是由基础镜像的不可变层以及特定于该容器的可写容器层组成。容器内运行的进程对文件系统所做的所有更改都会保存在这个容器层中。一旦容器停止并从系统中删除,相应的容器层也会从系统中删除并不可恢复地丢失。
一些应用程序,如运行在容器中的数据库,需要将其数据持久化,超出容器的生命周期。在这种情况下,它们可以使用卷。为了更加明确,我们来看一个具体的例子。MongoDB 是一个流行的开源文档数据库。许多开发者将 MongoDB 作为他们应用程序的存储服务。MongoDB 的维护者创建了一个镜像并发布在 Docker Hub 上,可以用来在容器中运行数据库实例。这个数据库会产生需要长期持久化的数据,但 MongoDB 的维护者并不知道谁在使用这个镜像以及如何使用它。因此,他们无法影响数据库用户启动容器时使用的 docker container run
命令。那么,他们如何定义卷呢?
幸运的是,有一种在 Dockerfile 中定义卷的方法。用于定义卷的关键字是 VOLUME
,我们可以添加一个单独文件夹的绝对路径,或是一个用逗号分隔的路径列表。这些路径代表容器文件系统中的文件夹。让我们来看几个卷定义的示例,如下所示:
VOLUME /app/dataVOLUME /app/data, /app/profiles, /app/config
VOLUME ["/app/data", "/app/profiles", "/app/config"]
上面代码片段中的第一行定义了一个单一卷,并将其挂载到 /app/data
。第二行定义了三个卷,作为一个逗号分隔的列表。最后一行定义的内容与第二行相同,但这次其值是以 JSON 数组的格式表示的。
当容器启动时,Docker 会自动为 Dockerfile 中定义的每个路径创建一个卷,并将其挂载到容器的相应目标文件夹。由于每个卷都是由 Docker 自动创建的,它将有一个 SHA-256 作为其 ID。
在容器运行时,Dockerfile 中定义为卷的文件夹将被排除在联合文件系统之外,因此这些文件夹中的任何更改不会影响容器层,而是持久化到相应的卷中。现在,操作工程师的责任是确保卷的后端存储得到了妥善的备份。
我们可以使用 docker image inspect
命令来获取 Dockerfile 中定义的卷的信息。让我们通过以下步骤看看 MongoDB 给我们提供了什么:
-
首先,我们将使用以下命令拉取镜像:
$ docker image pull mongo:5.0
-
然后,我们将检查这个镜像,并使用
--format
参数只提取大量数据中的关键部分,如下所示:$ docker image inspect \ --format='{{json .ContainerConfig.Volumes}}' \ mongo:5.0 | jq .
注意命令末尾的 | jq .
。我们将 docker image inspect
的输出通过管道传输到 jq
工具,该工具将输出格式化得非常整齐。
提示
如果你还没有在系统上安装 jq
,可以在 macOS 上使用 brew install jq
或在 Windows 上使用 choco install jq
来安装它。
上面的命令将返回如下结果:
{ "/data/configdb": {},
"/data/db": {}
}
如我们所见,MongoDB 的 Dockerfile 定义了两个卷,分别位于 /data/configdb
和 /data/db
。
-
现在,让我们在后台以守护进程的方式运行一个 MongoDB 实例,如下所示:
$ docker run --name my-mongo -d mongo:5.0
-
现在我们可以使用
docker container inspect
命令获取有关已创建的卷的其他信息。使用此命令仅获取卷信息:$ docker inspect --format '{{json .Mounts}}' my-mongo | jq .
上述命令应该会输出类似于以下内容的结果(已缩短):
图 5.6 – 检查 MongoDB 卷
Source
字段提供了主机目录的路径,MongoDB 在容器内产生的数据将存储在该路径下。
离开之前,使用以下命令清理 MongoDB 容器:
$ docker rm -f my-mongo
关于卷的内容暂时就这些。在下一节中,我们将探讨如何配置运行在容器中的应用程序以及容器镜像构建过程本身。
配置容器
很多时候,我们需要为容器内部运行的应用程序提供一些配置。配置通常用于允许同一个容器在非常不同的环境中运行,例如开发、测试、预发布或生产环境。在 Linux 中,配置值通常通过环境变量提供。
我们已经了解到,运行在容器中的应用程序与其主机环境完全隔离。因此,我们在主机上看到的环境变量与容器内看到的环境变量是不同的。
让我们通过查看主机上定义的内容来证明这一点:
-
使用此命令显示为您的终端会话定义的所有环境变量列表:
$ export
在作者的 macOS 上,输出大致如下(已缩短):
...COLORTERM=truecolor
COMMAND_MODE=unix2003
...
HOME=/Users/gabriel
HOMEBREW_CELLAR=/opt/homebrew/Cellar
HOMEBREW_PREFIX=/opt/homebrew
HOMEBREW_REPOSITORY=/opt/homebrew
INFOPATH=/opt/homebrew/share/info:/opt/homebrew/...:
LANG=en_GB.UTF-8
LESS=-R
LOGNAME=gabriel
...
-
接下来,让我们在 Alpine 容器中运行一个 Shell:
- 使用以下命令运行容器:
$ docker container run --rm -it alpine /bin/sh
提醒一下,我们使用了--rm
命令行参数,这样在停止容器后我们就不必手动删除悬空的容器。
- 然后,使用以下命令列出我们可以看到的环境变量:
/ # export
这应该会产生以下输出:
export HOME='/root'export HOSTNAME='91250b722bc3'
export PATH='/usr/local/sbin:/usr/local/bin:...'
export PWD='/'
export SHLVL='1'
export TERM='xterm'
上述输出与我们在主机上直接看到的内容不同。
- 按 Ctrl + D 离开并停止 Alpine 容器。
接下来,我们为容器定义环境变量。
为容器定义环境变量
现在,好消息是我们可以在容器启动时传递一些配置值。我们可以使用--env
(或简写形式-e
)参数,格式为--env <key>=<value>
,其中<key>
是环境变量的名称,<value>
是该变量的值。假设我们希望在容器中运行的应用程序可以访问名为LOG_DIR
的环境变量,其值为/var/log/my-log
。我们可以通过以下命令实现:
$ docker container run --rm -it \ --env LOG_DIR=/var/log/my-log \
alpine /bin/sh
/ #
上述代码启动了一个 Alpine 容器中的 Shell,并在运行的容器内定义了所请求的环境变量。为了证明这一点,我们可以在 Alpine 容器内执行以下命令:
/ # export | grep LOG_DIR
输出应如下所示:
export LOG_DIR='/var/log/my-log'
输出结果如预期那样。现在我们可以在容器中使用所请求的环境变量,并且它们具有正确的值。当然,我们在运行容器时可以定义多个环境变量。只需要重复使用--env
(或-e
)参数。请查看以下示例:
$ docker container run --rm -it \ --env LOG_DIR=/var/log/my-log \
--env MAX_LOG_FILES=5 \
--env MAX_LOG_SIZE=1G \
alpine /bin/sh
运行前面的命令后,我们会停留在 Alpine 容器中的命令提示符下:
/ #
使用以下命令列出环境变量:
/ # export | grep LOG
我们将看到以下内容:
export LOG_DIR='/var/log/my-log'export MAX_LOG_FILES='5'
export MAX_LOG_SIZE='1G'
现在,让我们来看一下在需要配置多个环境变量的情况下如何操作。
使用配置文件
复杂的应用程序可能有许多环境变量需要配置,因此,我们运行对应容器的命令可能会变得非常繁琐。为此,Docker 允许我们通过文件传递一组环境变量定义。我们在docker container run
命令中使用--env-file
参数来实现这一点。
让我们试一下,如下所示:
-
进入我们在本章开始时创建的
chapter 5
源文件夹:$ cd ~/The-Ultimate-Docker-Container-Book/ch05
-
创建一个名为
config-file
的子文件夹并进入该文件夹,如下所示:$ mkdir config-file && cd config-file
-
使用你喜欢的编辑器,在此文件夹中创建一个名为
development.config
的文件。将以下内容添加到文件中并保存,如下所示:LOG_DIR=/var/log/my-logMAX_LOG_FILES=5MAX_LOG_SIZE=1G
请注意我们如何按<key>=<value>
格式,在每行中定义一个环境变量,其中,<key>
是环境变量的名称,<value>
表示要与该变量关联的值。
-
现在,在
config-file
子文件夹中,让我们运行一个 Alpine 容器,将该文件作为环境文件传递,并在容器内运行export
命令,以验证文件中列出的变量是否已确实作为环境变量创建在容器内,如下所示:$ docker container run --rm -it \ --env-file ./development.config \ alpine sh -c "export | grep LOG"
确实,变量已经定义,我们可以在生成的输出中看到:
export LOG_DIR='/var/log/my-log'export MAX_LOG_FILES='5'
export MAX_LOG_SIZE='1G'
这正是我们预期的结果。
接下来,让我们看看如何为所有给定 Docker 镜像的容器实例定义环境变量的默认值。
在容器镜像中定义环境变量
有时,我们希望为环境变量定义一些默认值,确保这些变量在每个容器实例中都存在。我们可以通过以下步骤,在用于创建该镜像的 Dockerfile 中进行定义:
-
进入我们在本章开始时创建的
chapter 5
源文件夹:$ cd ~/The-Ultimate-Docker-Container-Book/ch05
-
创建一个名为
config-in-image
的子文件夹并进入该文件夹,如下所示:$ mkdir config-in-image && cd config-in-image
-
使用你喜欢的编辑器,在
config-in-image
子文件夹中创建一个名为Dockerfile
的文件。将以下内容添加到文件中并保存:FROM alpine:latestENV LOG_DIR=/var/log/my-logENV MAX_LOG_FILES=5ENV MAX_LOG_SIZE=1G
-
使用前面的 Dockerfile 创建一个名为
my-alpine
的容器镜像,如下所示:$ docker image build -t my-alpine .
注意
别忘了前一行末尾的句号!
-
从该镜像运行一个容器实例,并输出容器内部定义的环境变量,如下所示:
$ docker container run --rm -it \ my-alpine sh -c "export | grep LOG"
你应该在输出中看到以下内容:
export LOG_DIR='/var/log/my-log'export MAX_LOG_FILES='5'
export MAX_LOG_SIZE='1G'
这正是我们所预期的。
-
不过,值得庆幸的是,我们完全不需要拘泥于这些变量值。我们可以通过在
docker container run
命令中使用--env
参数来覆盖其中一个或多个变量。使用此命令:$ docker container run --rm -it \ --env MAX_LOG_SIZE=2G \ --env MAX_LOG_FILES=10 \ my-alpine sh -c "export | grep LOG"
-
现在,看看以下命令及其输出:
export LOG_DIR='/var/log/my-log'export MAX_LOG_FILES='10'export MAX_LOG_SIZE='2G'
-
我们还可以通过使用环境文件和
--env-file
参数在docker container run
命令中覆盖默认值。请自己尝试一下。
在下一节中,我们将介绍在 Docker 镜像构建时使用的环境变量。
构建时的环境变量
有时,我们希望能够定义一些在构建容器镜像时有效的环境变量。假设你想定义一个BASE_IMAGE_VERSION
环境变量,然后在 Dockerfile 中作为参数使用。想象一下以下的 Dockerfile:
ARG BASE_IMAGE_VERSION=12.7-stretchFROM node:${BASE_IMAGE_VERSION}
WORKDIR /app
COPY packages.json .
RUN npm install
COPY . .
CMD npm start
我们使用ARG
关键字来定义一个默认值,该默认值将在每次从前面的 Dockerfile 构建镜像时使用。在本例中,这意味着我们的镜像使用的是node:12.7-stretch
基础镜像。
现在,如果我们想为某些特定目的(例如测试)创建一个特殊的镜像,我们可以在镜像构建时通过使用--build-arg
参数来覆盖这个变量,如下所示:
$ docker image build \ --build-arg BASE_IMAGE_VERSION=12.7-alpine \
-t my-node-app-test .
在这种情况下,生成的my-node-test:latest
镜像将从node:12.7-alpine
基础镜像构建,而不是从node:12.7-stretch
默认镜像构建。
总结来说,通过--env
或--env-file
定义的环境变量在容器运行时有效。通过 Dockerfile 中的ARG
或docker container build
命令中的--build-arg
定义的变量在容器镜像构建时有效。前者用于配置容器内运行的应用程序,而后者用于参数化容器镜像的构建过程。
到此为止,我们已经完成了本章的内容。
总结
在本章中,我们介绍了 Docker 卷,它可以用于持久化容器产生的状态,并使其具有持久性。我们还可以使用卷为容器提供来自各种来源的数据。我们学习了如何创建、挂载和使用卷。我们还学习了多种定义卷的技术,如通过名称、挂载主机目录或在容器镜像中定义卷。
在本章中,我们还讨论了如何配置环境变量,以供运行在容器内的应用程序使用。我们展示了如何在docker container run
命令中显式地逐个定义这些变量,或者将它们作为配置文件中的集合来定义。最后,我们学习了如何通过使用构建参数来参数化容器镜像的构建过程。
在下一章中,我们将介绍一些常用的技术,这些技术可以让开发人员在容器中运行时对其代码进行演进、修改、调试和测试。
进一步阅读
以下文章提供了更深入的信息:dockr.ly/2EUjTml
-
使用 卷:
dockr.ly/2EUjTml
-
管理 Docker 中的数据:
dockr.ly/2EhBpzD
-
Play with Docker(PWD)上的Docker 卷:
bit.ly/2sjIfDj
-
nsenter
—Linux 手册页,链接:bit.ly/2MEPG0n
-
设置环境 变量:
dockr.ly/2HxMCjS
-
理解 ARG 和 FROM 如何交互:
dockr.ly/2OrhZgx
问题
请尝试回答以下问题,以评估您的学习进度:
-
如何使用默认驱动程序创建一个名为
my-products
的数据卷? -
如何使用 Alpine 镜像运行容器,并将
my-products
卷以只读模式挂载到/data
容器文件夹中? -
如何找到与
my-products
卷关联的文件夹并导航到它?同时,如何创建一个包含内容的sample.txt
文件? -
如何运行另一个 Alpine 容器,将
my-products
卷挂载到/app-data
文件夹中,且为读写模式?在该容器内,导航到/app-data
文件夹并创建一个包含内容的hello.txt
文件。 -
如何将主机卷(例如,
~/my-project
)挂载到容器中? -
如何从系统中删除所有未使用的卷?
-
容器中运行的应用程序所看到的环境变量列表与应用程序直接在主机上运行时是相同的。
-
正确
-
错误
-
-
您的应用程序将在容器中运行,需要大量的环境变量进行配置。运行容器并提供所有这些信息的最简单方法是什么?
答案
以下是本章问题的答案:
-
要创建一个命名卷,请运行以下命令:
$ docker volume create my-products
-
执行以下命令:
$ docker container run -it --rm \ -v my-products:/data:ro \ alpine /bin/sh
-
要实现此结果,请执行以下操作:
- 要获取卷在主机上的路径,请使用此命令:
$ docker volume inspect my-products | grep Mountpoint
- 这应该会产生以下输出:
"Mountpoint": "/var/lib/docker/volumes/my-products/_data"
- 现在,执行以下命令运行一个容器并在其中执行
nsenter
:
$ docker container run -it --privileged --pid=host \ debian nsenter -t 1 -m -u -n -i sh
- 导航到包含
my-products
卷数据的文件夹:
/ # cd /var/lib/docker/volumes/my-products/_data
- 在此文件夹中创建一个包含文本
"I love Docker"
的文件:
/ # echo "I love Docker" > sample.txt
-
按Ctrl + D退出
nsenter
及其容器。 -
执行以下命令以验证在主机文件系统中生成的文件是否确实是卷的一部分,并且可以访问我们将挂载此卷的容器:
$ docker container run --rm \ --volume my-products:/data \ alpine ls -l /data
前述命令的输出应类似于以下内容:
total 4-rw-r--r-- 1 root root 14 Dec 4 17:35 sample.txt
我们确实可以看到该文件。
-
可选:运行修改版的命令,输出
sample.txt
文件的内容。 -
执行以下命令:
$ docker run -it --rm -v my-products:/data:ro alpine /bin/sh/ # cd /data/data # cat sample.txt
在另一个终端中执行此命令:
$ docker run -it --rm -v my-products:/app-data alpine /bin/sh/ # cd /app-data
/app-data # echo "Hello other container" > hello.txt
/app-data # exit
-
执行以下命令:
$ docker container run -it --rm \ -v $HOME/my-project:/app/data \ alpine /bin/sh
-
退出两个容器,然后在主机上执行以下命令:
$ docker volume prune
-
答案是False(B)。每个容器都是一个沙盒,因此有其独立的环境。
-
在配置文件中收集所有环境变量及其相应的值,然后通过
docker container run
命令中的--env-file
命令行参数提供给容器,如下所示:$ docker container run --rm -it \ --env-file ./development.config \ alpine sh -c "export"
第六章:6
调试在容器中运行的代码
在上一章中,我们学习了如何使用有状态容器——即那些消费和产生数据的容器。我们还学习了如何使用环境变量和配置文件,在运行时和镜像构建时配置容器。
本章中,我们将介绍一些常用技术,这些技术可以帮助开发人员在容器中运行代码时进行演进、修改、调试和测试。掌握这些技巧后,你将在容器中开发应用时享受无摩擦的开发过程,类似于开发本地运行的应用程序。
以下是我们将要讨论的主题列表:
-
发展和测试运行在容器中的代码
-
代码变更后的自动重启
-
在容器内逐行调试代码
-
在代码中插入日志,以产生有意义的日志信息
-
使用 Jaeger 进行监控和故障排除
完成本章后,你将能够做以下事情:
-
挂载在主机上的源代码到运行中的容器
-
配置运行在容器中的应用程序,在代码更改后自动重启
-
配置Visual Studio Code(VS Code)逐行调试在容器内运行的 Java、Node.js、Python 或.NET 应用程序
-
从应用代码中记录重要事件
-
使用 OpenTracing 标准和像 Jaeger 这样的工具,为你的多组件应用程序配置分布式追踪
技术要求
本章中,如果你想跟随代码进行操作,你需要在 macOS 或 Windows 上安装 Docker Desktop 和一个代码编辑器——最好是 VS Code。样例代码也可以在安装了 Docker 和 VS Code 的 Linux 机器上运行。
为了准备接下来的动手实验,请按照以下步骤操作:
-
请导航到你克隆示例代码库所在的文件夹。通常情况下,这应该是
~/The-Ultimate-Docker-Container-Book
,因此请执行以下操作:$ cd ~/The-Ultimate-Docker-Container-Book
-
创建一个名为
ch06
的新子文件夹并导航到它:$ mkdir ch06 && cd ch06
本章中讨论的所有示例的完整样本解决方案可以在sample-solutions/ch06
文件夹中找到,或者直接在 GitHub 上查看:github.com/PacktPublishing/The-Ultimate-Docker-Container-Book/tree/main/sample-solutions/ch06
。
发展和测试运行在容器中的代码
在继续之前,确保你的计算机已安装 Node.js 和npm
。在 Mac 上,使用以下命令:
$ brew install node
在 Windows 上,使用以下命令:
$ choco install -y nodejs
当开发最终将运行在容器中的代码时,最好的做法通常是从一开始就将代码运行在容器中,以确保不会有任何意外情况。但我们必须以正确的方式做这件事,以免为我们的开发过程引入不必要的摩擦。首先,让我们看看我们可以如何在容器中运行和测试代码的一个简单方法。我们可以使用一个基本的 Node.js 样本应用程序来做到这一点:
-
创建一个新的项目文件夹并导航到该文件夹:
$ mkdir node-sample && cd node-sample
-
让我们使用
npm
来创建一个新的 Node.js 项目:$ npm init
-
接受所有默认设置。注意,
package.json
文件已经创建,内容如下:
图 6.1 – 样本 Node.js 应用程序的 package.json 文件内容
-
我们希望在 Node 应用程序中使用
Express.js
库,因此,使用npm
来安装它:$ npm install express –save
这将安装我们机器上最新版本的Express.js
,并且因为–save
参数的存在,它会将类似下面的引用添加到我们的package.json
文件中:
"dependencies": { "express": "⁴.18.2"
}
请注意,在你的情况下,express
的版本号可能不同。
-
从这个文件夹中启动 VS Code:
$ code .
-
在 VS Code 中,创建一个名为
index.js
的新文件并将此代码片段添加到其中。别忘了保存:
图 6.2 – 样本 Node.js 应用程序的 index.js 文件内容
-
在终端窗口中,启动应用程序:
$ node index.js
注意
在 Windows 和 Mac 上,当你第一次执行上述命令时,会弹出一个窗口,要求你在防火墙上批准它。
你应该看到这样的输出:
Application listening at 0.0.0.0:3000
这意味着应用程序正在运行,并准备在0.0.0.0:3000
端点监听。
提示
你可能会想知道0.0.0.0
这个主机地址的含义是什么,为什么我们选择了它。稍后当我们在容器中运行应用程序时会再提到这个问题。现在,只需要知道0.0.0.0
是一个具有特殊意义的保留 IP 地址,类似于回环地址127.0.0.1
。0.0.0.0
地址的含义是本地机器上的所有 IPv4 地址。如果主机有两个 IP 地址,例如52.11.32.13
和10.11.0.1
,而主机上的服务器监听0.0.0.0
,那么它将在这两个 IP 上都可以访问。
- 现在,在你喜欢的浏览器中打开一个新标签页并导航到
http://localhost:3000
。你应该会看到这个:
图 6.3 – 样本 Node.js 应用程序在浏览器中运行
很好 – 我们的 Node.js 应用程序在开发者机器上运行。通过在终端按下Ctrl + C来停止应用程序。
- 现在,我们希望通过在容器内运行应用程序来测试目前为止开发的应用程序。为此,我们必须创建一个 Dockerfile,以便我们可以构建一个容器镜像,然后从该镜像运行容器。让我们再次使用 VS Code,向项目文件夹中添加一个名为
Dockerfile
的文件,并为其提供以下内容:
图 6.4 – 示例 Node.js 应用程序的 Dockerfile
-
然后,我们可以使用这个 Dockerfile 构建一个名为
sample-app
的镜像,如下所示:$ docker image build -t sample-app .
基础镜像下载并在其上构建你的自定义镜像需要几秒钟。
-
构建完成后,使用以下命令在容器中运行应用程序:
$ docker container run --rm -it \ --name my-sample-app \ -p 3000:3000 \ sample-app
输出将如下所示:
Application listening at 0.0.0.0:3000
注意
上述命令会从sample-app
容器镜像运行一个名为my-sample-app
的容器,并将容器的端口3000
映射到对应的宿主端口。这个端口映射是必要的,否则我们无法从容器外部访问容器内部运行的应用程序。我们将在第十章中详细了解端口映射,使用 单主机网络。这与我们直接在宿主机上运行应用程序时是类似的。
-
刷新之前的浏览器标签页(或者如果你已经关闭了,打开一个新的浏览器标签页并导航到
localhost:3000
)。你应该能看到应用程序仍然在运行,并产生与本地运行时相同的输出。这是好的。我们已经展示了我们的应用程序不仅可以在宿主机上运行,也可以在容器内部运行。 -
在终端中按Ctrl + C停止并移除容器。
-
现在,让我们修改代码并添加一些额外的功能。我们将在
/hobbies
处定义另一个HTTP GET
端点。请将以下代码片段添加到index.js
文件的末尾:const hobbies = [ 'Swimming', 'Diving', 'Jogging', 'Cooking', 'Singing'];app.get('/hobbies', (req,res)=>{ res.send(hobbies);})
-
我们可以通过运行以下命令在宿主机上测试新功能:
$ node index.js
然后,我们可以在浏览器中导航到http://localhost:3000/hobbies
。我们应该能在浏览器窗口看到预期的输出——一个包含爱好列表的 JSON 数组。完成测试后,别忘了按Ctrl + C停止应用程序。
-
接下来,我们需要测试代码在容器内运行时的表现。所以,首先,我们必须创建一个新的容器镜像版本:
$ docker image build -t sample-app .
这一次,构建应该比第一次更快,因为基础镜像已经在我们的本地缓存中。
-
接下来,我们必须从这个新镜像运行一个容器:
$ docker container run --rm -it \ --name my-sample-app \ -p 3000:3000 \ sample-app
-
现在,我们可以在浏览器中导航到
http://localhost:3000/hobbies
,并确认应用程序在容器内也能正常工作。 -
再次提醒,完成后不要忘记按Ctrl + C停止容器。
我们可以针对每个新增的特性或改进的现有特性,反复执行这组任务。事实证明,与所有我们开发的应用程序直接在主机上运行的时代相比,这种方法增加了不少摩擦。
然而,我们可以做得更好。在下一节中,我们将探讨一种技巧,允许我们消除大部分的摩擦。
将变化中的代码挂载到运行中的容器
如果在代码更改后,我们不需要重建容器镜像并重新运行容器,那会怎么样呢?如果在像 VS Code 这样的编辑器中保存更改时,变化能够立即反映到容器内部,那不是更好吗?实际上,通过卷映射,这是可能的。在上一章中,我们学习了如何将任意的主机文件夹映射到容器内部的任意位置。在本节中,我们将利用这一点。在第五章,数据卷和配置中,我们学习了如何将主机文件夹映射为容器中的卷。例如,如果我们想将主机文件夹/projects/sample-app
挂载到容器的/app
路径,语法如下:
$ docker container run --rm -it \ --volume /projects/sample-app:/app \
alpine /bin/sh
注意--volume <host-folder>:<container-folder>
这一行。主机文件夹的路径需要是绝对路径,在这个例子中是/projects/sample-app
。
现在,如果我们想从sample-app
容器镜像运行一个容器,并且我们从项目文件夹中进行操作,我们可以将当前文件夹映射到容器的/app
文件夹,具体命令如下:
$ docker container run --rm -it \ --volume $(pwd):/app \
-p 3000:3000 \
sample-app
注意
请注意使用$(pwd)
代替主机文件夹路径。$(pwd)
代表当前文件夹的绝对路径,非常方便。
现在,如果我们使用上面的卷映射参数,那么sample-app
容器镜像中/app
文件夹中的任何内容将被映射主机文件夹中的内容所覆盖,在我们的例子中就是当前文件夹。这正是我们想要的——我们希望当前源文件从主机映射到容器中。让我们测试一下是否有效:
-
如果你已经启动了容器,可以通过按Ctrl + C来停止它。
-
然后,将以下代码片段添加到
index.js
文件的末尾:app.get('/status', (req,res)=>{ res.send('OK');})
别忘了保存。
-
然后,再次运行容器——这次,不需要先重建镜像——观察发生了什么:
$ docker container run --rm -it \ --name my-sample-app \ --volume $(pwd):/app \ -p 3000:3000 \ sample-app
-
在浏览器中,导航到
http://localhost:3000/status
。你会在浏览器窗口中看到OK
输出。或者,你可以在另一个终端窗口中使用curl
来探测/status
端点,如下所示:$ curl localhost:3000/statusOK
注意
对于所有在 Windows 和/或 Windows 版 Docker Desktop 上工作的用户,你可以使用 PowerShell 的Invoke-WebRequest
命令,或简称iwr
,代替curl
。在这种情况下,前面的命令的等效命令是PS> iwr -Url http://localhost:3000/status
。
-
暂时让应用在容器中运行,并做另一次修改。当导航到
/status
时,我们希望返回的消息为OK, all good
,而不仅仅是返回OK
。进行修改并保存更改。 -
然后,再次执行
curl
命令,或者如果你使用了浏览器,刷新页面。你看到了什么?对——什么都没发生。我们所做的更改没有反映在运行中的应用程序中。 -
好的,让我们再次检查更改是否已经传播到运行中的容器中。为此,我们执行以下命令:
$ docker container exec my-sample-app cat index.js
这将在我们已运行的容器中执行cat index.js
命令。我们应该看到类似如下的内容——我已经简化了输出以便于阅读:
...app.get('/hobbies', (req,res)=>{
res.send(hobbies);
})
app.get('/status', (req,res)=>{
res.send('OK, all good');
})
...
如我们所见,修改已经按预期传播到容器中。那么,为什么这些更改没有反映在运行中的应用中呢?原因很简单:要使更改应用到应用程序中,必须重启 Node.js 示例应用程序。
-
让我们尝试一下。通过按Ctrl + C停止运行的容器。然后,重新执行前面的
docker container run
命令,并使用curl
探测http://localhost:3000/status
端点。这次,应该会显示以下新消息:localhost:3000/status
$ curl http://localhost:3000/statusOK, all good
有了这个,我们通过将源代码映射到运行中的容器中,显著减少了开发过程中的摩擦。现在我们可以添加新代码或修改现有代码并进行测试,而无需首先构建容器镜像。然而,还是留下了一点摩擦。每次我们想测试新代码或修改过的代码时,都必须手动重新启动容器。我们能自动化这一过程吗?答案是肯定的!我们将在下一节中演示如何做到这一点。
代码更改后自动重启
在上一节中,我们展示了如何通过将源代码文件夹映射到容器中,极大地减少了摩擦,避免了每次都重建容器镜像并一遍遍重新运行容器。然而,我们仍然感觉到一些摩擦。容器内部运行的应用程序在代码更改时不会自动重启。因此,我们必须手动停止并重新启动容器,以使这些更改生效。
在这一节中,我们将学习如何将我们用多种语言编写的应用程序,如 Node.js、Java、Python 和.NET 容器化,并在检测到代码更改时自动重启它们。让我们从 Node.js 开始。
Node.js 自动重启
如果你已经编程一段时间了,你肯定听说过一些有用的工具,它们可以运行你的应用程序,并在发现代码库中的更改时自动重启它们。对于 Node.js 应用程序,最流行的工具是nodemon
。让我们来看看:
-
我们可以使用以下命令在系统上全局安装
nodemon
:$ npm install -g nodemon
-
现在
nodemon
已经可用,不再需要像在主机上那样启动我们的应用程序(例如node index.js
),只需执行nodemon
即可,我们应该看到以下输出:
图 6.5 – 使用 nodemon 运行我们的 Node.js 示例应用程序
注意
正如我们所看到的,通过解析我们的package.json
文件,nodemon
已经识别出应该将node index.js
作为启动命令。
-
现在,尝试更改一些代码。例如,在
index.js
的末尾添加以下代码片段,然后保存文件:app.get('/colors', (req,res)=>{ res.send(['red','green','blue']);})
-
查看终端窗口。看到了什么变化吗?你应该看到以下附加输出:
[nodemon] restarting due to changes...[nodemon] starting `node index.js`Application listening at 0.0.0.0:3000
这表示nodemon
已经检测到了一些更改,并自动重新启动了应用程序。
-
在浏览器中尝试这个,通过访问
localhost:3000/colors
。你应该在浏览器中看到以下预期的输出:["red", "green", "blue"]
这很酷 – 你可以在不手动重启应用程序的情况下获得这个结果。这使我们的生产力又提高了一点。现在,在容器内部能否做到同样的效果?
是的,我们可以。但是,我们不会使用在我们的 Dockerfile 最后一行定义的启动命令node index.js
:
CMD node index.js
我们将使用nodemon
。
我们是否需要修改我们的 Dockerfile?或者我们需要两个不同的 Dockerfile,一个用于开发,一个用于生产?
我们的原始 Dockerfile 创建了一个不幸没有包含nodemon
的镜像。因此,我们需要创建一个新的 Dockerfile:
- 创建一个新文件。我们称之为
Dockerfile.dev
。它的内容应该如下所示:
图 6.6 – 用于开发我们的 Node.js 应用程序的 Dockerfile
将这与我们的原始Dockerfile
进行比较,我们在第二行添加了nodemon
的安装。我们还修改了最后一行,现在使用nodemon
作为启动命令。
-
让我们构建我们的开发镜像,如下所示:
$ docker image build \ -f Dockerfile.dev \ -t node-demo-dev .
请注意命令行参数-f Dockerfile.dev
。由于我们使用的是非标准命名的 Dockerfile,必须使用这个参数。
-
运行一个容器,就像这样:
$ docker container run --rm -it \ -v $(pwd):/app \ -p 3000:3000 \ node-demo-dev
-
现在,当应用程序在容器中运行时,改变一些代码,保存它,并注意到容器内的应用程序已经自动重新启动了。通过这种方式,我们在容器中运行时也实现了减少摩擦的效果,就像直接在主机上运行一样。
-
当你完成时,按下Ctrl + C退出你的容器。
-
使用以下命令清理系统并删除所有正在运行或悬挂的容器:
$ docker container rm -f $(docker container ls -aq)
你可能会想,这只适用于 Node.js 吗?不,幸运的是,许多流行的编程语言都支持类似的概念。
Java 和 Spring Boot 的自动重新启动
Java 和 Spring Boot 仍然是开发业务线(LOB)类型应用程序时最受欢迎的编程语言和库。让我们学习如何在开发这种应用程序并进行容器化时尽可能减少摩擦。
为了使此示例正常工作,你必须在电脑上安装 Java。写本文时,推荐的版本是 Java 17。你可以使用你喜欢的包管理工具进行安装,比如 Mac 上的 Homebrew 或 Windows 上的 Chocolatey。
你可能还需要确保你已经为 VS Code 安装了Microsoft 的 Java 扩展包。你可以在此处找到更多详细信息:marketplace.visualstudio.com/items?itemName=vscjava.vscode-java-pack
。
一旦你在电脑上安装并准备好 Java 17 SDK,请按照以下步骤操作:
- 启动 Spring Boot 应用程序最简单的方法是使用
Spring Web
,并选择它(不要选择Spring Reactive Web)。
你的页面应该如下所示:
图 6.7 – 使用 Spring Initializr 引导新的 Java 项目
-
点击
ch06/java-springboot-demo
。 -
导航到此文件夹:
$ cd ch06/java-springboot-demo
-
使用以下命令从此文件夹中打开 VS Code:
$ code .
-
找到项目的主文件,名为
DemoApplication.java
,然后点击第 9 行的main
方法,如下图所示:
图 6.8 – 启动 Java Spring Boot 应用程序
- 请注意,应用程序已经被编译,并且打开了一个终端窗口。将显示如下类似的内容:
图 6.9 – 运行 Spring Boot 应用程序生成的输出
-
在前面输出的倒数第二行,我们可以看到应用程序使用了 Tomcat Web 服务器,并且正在监听端口
8080
。 -
现在,让我们添加一个端点,之后可以尝试访问它:
-
用
@RestController
注解装饰DemoApplication
类。 -
添加一个返回字符串列表的
getSpecies
方法 -
用以下注解装饰该方法:
-
@GetMapping("/species")
不要忘记添加所需的import
语句。完整的代码如下所示:
图 6.10 – Spring Boot 示例的完整演示代码
- 使用
curl
或/species
端点:
图 6.11 – 使用 Thunder Client 插件测试 Java 演示应用程序
-
要为我们的 Java Spring Boot 应用程序添加自动重启支持,我们需要添加所谓的开发工具:
-
在你的 Java 项目中找到
pom.xml
文件并在编辑器中打开它。 -
将以下代码段添加到文件的依赖部分:
-
图 6.12 – 添加对 Spring Boot 开发工具的引用
请注意,依赖项定义中的版本节点可以省略,因为项目使用了spring-boot-starter-parent
作为父级。
-
停止并重新运行应用程序。
-
修改
DemoApplication
类的第 20 行,添加Crocodile
作为返回给调用者的第四个物种。 -
保存你的更改,并观察应用程序自动重新构建并重启。
-
使用
curl
或 Thunder Client 再次访问/species
端点。这次应该返回一个包含刚刚添加的Crocodile
在内的四个物种的列表。
很棒——我们有一个 Java Spring Boot 应用程序,当我们更改代码时,它会自动重新编译并重启。现在,我们需要像在 Node.js 示例中那样将整个应用容器化:
- 将以下内容添加到项目根目录的
Dockerfile
中:
图 6.13 – Java Spring Boot 示例的 Dockerfile
注意
我们在这个示例中使用了eclipse-temurin
镜像和17-jdk-focal
标签,因为在撰写本文时,该镜像适用于现代 MacBook 上使用的 M1 或 M2 处理器。
-
使用以下命令创建一个镜像,使用前面的 Dockerfile:
$ docker image build -t java-demo .
-
使用以下命令从这个 Docker 镜像创建一个容器:
$ docker container run --name java-demo --rm \ -p 8080:8080 -v $(pwd)/.:/app java-demo
注意
第一次运行容器时,由于需要下载所有 Maven 依赖项,因此编译会花费一些时间。
-
尝试像之前一样访问
/species
端点。 -
现在,修改一些代码——例如,向
getSpecies
方法中添加一个第五个物种,比如Penguin
,然后保存更改。 -
观察容器内运行的应用程序是如何被重新构建的。通过再次访问
/species
端点并确认返回五个物种,包括Penguin
,来验证更改是否已被纳入。 -
完成操作后,可以通过 Docker Desktop 的仪表盘或 VS Code 中的 Docker 插件停止容器。
嗯,这不是很简单吗?不过让我告诉你,这种设置开发环境的方式可以通过消除许多不必要的摩擦,让开发容器化应用变得更加愉快。
挑战
尝试找出如何将本地的 Maven 缓存映射到容器中,以进一步加速容器的首次启动。
接下来,我们将向你展示如何轻松地在 Python 中完成相同的操作,敬请期待。
Python 的自动重启
让我们看看相同的操作在 Python 中是如何工作的。
前提条件
为了使这个示例能够正常工作,你需要在电脑上安装 Python 3.x。你可以通过你喜欢的包管理工具来安装,例如 Mac 上的 Homebrew 或 Windows 上的 Chocolatey。
在你的 Mac 上,使用此命令安装最新的 Python 版本:
$ brew install python
在你的 Windows 计算机上,使用此命令进行相同操作:
$ choco install python
使用此命令验证安装是否成功:
$ python3 --version
在作者的案例中,输出看起来是这样的:
Python 3.10.8
让我们开始吧:
-
首先,为我们的示例 Python 应用程序创建一个新的项目文件夹,并导航到该文件夹:
$ mkdir python-demo && cd python-demo
-
使用以下命令从此文件夹中打开 VS Code:
$ code .
-
我们将创建一个使用流行 Flask 库的示例 Python 应用程序。因此,向此文件夹中添加一个名为
requirements.txt
的文件,内容如下:flask
-
接下来,添加一个
main.py
文件,并将以下内容填入其中:
图 6.14 – 我们的示例 Python 应用程序的 main.py 文件内容
这是一个简单的 Hello World 类型的 应用,它在 http://localhost:5000/
实现了一个 RESTful 端点。
注意
在 app.run
命令中的 host="0.0.0.0"
参数是必要的,这样我们才能将 Python 应用监听的端口(5000
)暴露给主机。稍后我们会用到这个。
请注意,有些人在 Mac 上运行时,使用端口 5000
时,出现错误提示:“地址已在使用中。端口 5000
已被另一个程序占用...”。遇到这种情况时,只需尝试使用不同的端口,例如 5001
。
-
在我们运行并测试此应用程序之前,我们需要安装必要的依赖项——在我们这个例子中是 Flask。在终端中运行以下命令:
$ pip3 install -r requirements.txt
这应该会在主机上安装 Flask。我们现在可以开始了。
-
在使用 Python 时,我们还可以使用
nodemon
使我们的应用在代码有任何更改时自动重启。例如,假设你启动 Python 应用的命令是python main.py
,在这种情况下,你只需像这样使用nodemon
:$ nodemon --exec python3 main.py
你应该看到以下输出:
图 6.15 – 使用 nodemon 自动重启 Python 3 应用程序
-
使用
nodemon
启动并监控 Python 应用程序时,我们可以使用curl
测试应用程序。打开另一个终端窗口,并输入以下命令:$ curl localhost:5000
你应该在输出中看到以下内容:
Hello World!
-
现在,让我们通过在
/
端点的定义后(即第 5 行后)添加以下代码片段来修改代码,并保存:from flask import jsonify@app.route("/colors")def colors(): return jsonify(["red", "green", "blue"])
nodemon
会发现更改并重启 Python 应用程序,如终端中输出的内容所示:
图 6.16 – nodemon 发现 Python 代码中的变化
-
再次强调,信任是好的,但测试更好。因此,让我们再次使用我们的好朋友
curl
来探测新的端点,看看能得到什么:$ curl localhost:5000/colors
输出应该如下所示:
["red", "green", "blue"]
很好 – 它工作正常!到此为止,我们已经覆盖了 Python 部分。
- 现在,到了将这个应用容器化的时候了。将一个名为
Dockerfile
的文件添加到项目中,内容如下:
图 6.17 – 样例 Python 应用的 Dockerfile
请注意,在第 1 行,我们使用了一个包含 Python 和 Node.js 代码的特殊基础镜像。然后,在第 2 行,我们安装了 nodemon
工具,接着将 requirements.txt
文件复制到容器中并执行 pip install
命令。接下来,我们将所有其他文件复制到容器中,并定义每次创建该镜像实例(即容器)时的启动命令。
-
使用以下命令构建 Docker 镜像:
$ docker image build -t python-sample .
-
现在,我们可以使用以下代码从此镜像启动一个容器:
$ docker container run --rm \ -p 5000:5000 \ -v $(pwd)/.:/app \ python-sample
我们应该看到与在 步骤 6 中运行应用程序时,容器内部产生的输出类似,那时我们是本地运行应用程序的:
图 6.18 – 运行容器化的 Python 样例应用
请注意,我们如何将容器端口 5000
映射到相应的主机端口,以便我们可以从外部访问该应用程序。我们还将主机上的样例目录内容映射到正在运行的容器中的 /app
文件夹。这样,我们就可以更新代码,容器化的应用程序会自动重启。
-
尝试更改应用程序代码,并在访问
/colors
端点时返回第四种颜色。保存更改并观察容器内运行的应用程序如何重新启动。 -
使用
curl
命令验证返回的是否是包含四种颜色的数组。 -
当你玩完这个示例后,按下终端窗口中运行容器的 Ctrl + C 来停止应用程序和容器。
有了这个示例,我们展示了一个完整的 Python 工作示例,帮助你在开发过程中大大减少与容器相关的摩擦。
.NET 是另一个流行的平台。让我们看看在 .NET 上开发 C# 应用程序时,是否可以做到类似的操作。
.NET 的自动重启
我们的下一个候选示例是一个用 C# 编写的 .NET 应用程序。让我们看看动态代码更新和自动重启在 .NET 中是如何工作的。
前提条件
如果你之前没有安装,请在你的笔记本或工作站上安装 .NET。你可以使用你喜欢的包管理器,例如 Mac 上的 Homebrew 或 Windows 上的 Chocolatey。
在 Mac 上,使用以下命令安装 .NET 7 SDK:
$ brew install --cask dotnet-sdk
在 Windows 机器上,你可以使用以下命令:
$ choco install -y dotnet-sdk
最后,使用此命令验证你的安装:
$ dotnet –version
在作者的机器上,输出如下:
7.0.100
让我们开始:
-
在一个新的终端窗口中,导航到本章文件夹:
$ cd ~/The-Ultimate-Docker-Container-Book/ch06
-
在该文件夹中,使用
dotnet
工具创建一个新的 Web API,并将其放置在dotnet
子文件夹中:$ dotnet new webapi -o csharp-sample
-
转到这个新项目文件夹:
$ cd csharp-sample
-
从该文件夹中打开 VS Code:
$ code .
注意
如果这是你第一次在 VS Code 中打开 .NET 项目,编辑器可能会显示一个弹窗,询问你是否需要添加缺失的依赖项。此时,点击 是 按钮:
图 6.19 – 请求加载 .NET 示例应用程序的缺失资产
- 在 VS Code 的项目资源管理器中,你应该看到以下内容:
图 6.20 – 在 VS Code 项目资源管理器中查看 .NET 示例应用程序
-
请注意包含
WeatherForecastController.cs
文件的Controllers
文件夹。打开这个文件并分析其内容。它包含WeatherForecastController
类的定义,该类实现了一个简单的 RESTful 控制器,提供/WeatherForecast
的 GET 端点。 -
在终端中,运行应用程序
dotnet run
。你应该看到如下所示的内容:
图 6.21 – 在主机上运行 .NET 示例 Web API
请注意上面输出中的第四行,.NET 告诉我们应用程序正在 http://localhost:5080
监听。在你的情况下,端口可能不同。请使用为你报告的端口进行后续所有操作。
-
我们可以使用
curl
来测试应用程序,方法如下:$ curl http://localhost:5080/WeatherForecast
这将输出一个包含五个随机天气数据的 JSON 对象数组:
图 6.22 – .NET 示例应用程序生成的天气数据
-
现在我们可以尝试修改
WeatherForecastController.cs
中的代码,返回 10 条数据而不是默认的 5 条。将第 24 行修改为如下所示:... return Enumerable.Range(1, 10).Select(......
-
保存你的更改并重新运行
curl
命令。请注意,结果中不包含新添加的值。这是我们在 Node.js 和 Python 中观察到的相同问题。为了查看最新的返回值,我们需要(手动)重新启动应用程序。 -
因此,在终端中,按 Ctrl + C 停止应用程序,然后使用
dotnet run
重新启动它。再次尝试curl
命令。此时,结果应反映你所做的更改。 -
幸运的是,
dotnet
工具有一个watch
命令。按 Ctrl + C 停止应用程序,然后执行这个稍作修改的命令:$ dotnet watch run
你应该看到类似以下的输出(已缩短):
图 6.23 – 使用 watch 任务运行 .NET 示例应用程序
注意前面输出的第一行,它显示正在运行的应用程序现在已经开启监视以检测更改。
- 在
WeatherForecastController.cs
中做另一个更改;例如,让GET
端点方法返回 100 个天气项,然后保存更改。观察终端中的输出。它应该像这样:
图 6.24 – 自动重启正在运行的.NET Core 示例应用
-
通过在更改代码后自动重启应用,结果立即可用,我们可以通过运行以下
curl
命令轻松测试它:$ curl http://localhost:5080/WeatherForecast
这次应该输出 100 个天气项目,而不是 10 个。
- 现在我们已经在主机上启用了自动重启,我们可以为容器内运行的应用编写一个
Dockerfile
来实现相同的功能。在 VS Code 中,向项目中添加一个名为Dockerfile-dev
的新文件,并将以下内容添加到该文件中:
图 6.25 – .NET 示例应用的 Dockerfile
请注意第 6 行中的--urls
命令行参数。这个参数明确告诉应用在容器内所有端点上监听端口5000
(由特殊的0.0.0.0
IP 地址表示)。如果我们保留默认的localhost
,则无法从容器外部访问该应用。
端口已被占用
请注意,有人报告说,在 Mac 上使用端口5000
时,会触发错误消息“地址已被使用。端口5000
已被其他程序占用...”。这种情况下,只需尝试使用不同的端口,例如5001
。
现在,我们准备好构建容器镜像:
-
使用以下命令为.NET 示例构建容器镜像:
$ docker image build -f Dockerfile-dev \ -t csharp-sample .
-
一旦镜像构建完成,我们可以从中运行一个容器:
$ docker container run --rm \ --name csharp-sample \ -p 5000:5000 \ -v $(pwd):/app \ csharp-sample
我们应该看到一个与原生运行时类似的输出。
-
让我们用我们的朋友
curl
来测试应用:$ curl localhost:5000/weatherforecast
我们应该获得天气预报项的数组。没有意外 – 它按预期工作。
- 现在,让我们在控制器中进行代码更改并保存。观察终端窗口中发生的情况。我们应该能看到类似如下的输出:
图 6.26 – 热重载容器内运行的.NET 示例应用
好吧,这正是我们预期的。通过这个,我们已经消除了在开发.NET 应用时使用容器引入的大部分摩擦。
-
当你完成 .NET 示例应用程序的调试后,打开 Docker Desktop 应用程序的仪表盘。定位到
csharp-sample
容器并选择它。然后,点击红色的删除按钮,将其从系统中移除。这是最简单的方式,因为不幸的是,仅仅在你运行容器的终端窗口中按下 Ctrl + C 并不起作用。另一种方法是,你可以打开一个新的终端窗口并使用以下命令来移除容器:$ docker container rm --force csharp-sample
暂时就到这里。在本节中,我们探讨了在开发容器化应用程序时,如何减少与 Node.js、Python、Spring Boot、Java 或 .NET 编写的应用程序之间的摩擦。接下来,我们将学习如何逐行调试运行在容器中的应用程序。
容器内逐行调试代码
在我们深入探讨如何逐行调试容器内运行的代码之前,我要做一个免责声明。你将在本节中学到的内容通常应作为最后的手段,前提是其他方法无效。理想情况下,当你在开发应用程序时采用测试驱动的方法,代码通常是有保障的,因为你已经为其编写了单元测试和集成测试,并且在容器内运行它们。如果单元测试或集成测试未能提供足够的洞见,且你需要逐行调试代码,你可以直接在主机上运行代码,从而利用像 VS Code、Eclipse 或 IntelliJ 这样的开发环境的支持,以上只是其中的一些 IDE。
做好所有这些准备后,你应该很少需要手动调试容器内运行的代码。话虽如此,让我们看看无论如何你如何进行调试!
在本节中,我们将专注于如何使用 VS Code 调试。其他编辑器和 IDE 可能会提供类似或不提供此功能。
调试 Node.js 应用程序
我们从最简单的开始——一个 Node.js 应用程序。我们将使用在 ~/The-Ultimate-Docker-Container-Book/ch06/node-sample
文件夹中的示例应用程序,这个文件夹是我们在本章之前使用过的:
-
打开一个新的终端窗口,并确保你导航到这个项目文件夹:
$ cd ~/The-Ultimate-Docker-Container-Book/ch06/node-sample
-
从容器内打开 VS Code:
$ code .
-
在终端窗口中,从项目文件夹内运行一个包含我们示例 Node.js 应用程序的容器:
$ docker container run --rm -it \ --name node-sample \ -p 3000:3000 \ -p 9229:9229 \ -v $(pwd):/app \ node-demo-dev node --inspect=0.0.0.0 index.js
注意
在前面的命令中,我们将端口 9229
映射到主机。该端口由 Node.js 调试器使用,VS Studio 将通过该端口与我们的 Node 应用程序进行通信。因此,打开这个端口是很重要的——但仅限于调试会话期间!另外,请注意,我们覆盖了在 Dockerfile 中定义的标准启动命令(记得它原本只是 node index.js
),用的是 node --inspect=0.0.0.0 index.js
。--inspect=0.0.0.0
命令行参数告诉 Node 以调试模式运行,并监听容器中的所有 IPv4 地址。
现在,我们准备为当前场景定义一个 VS Code 启动任务——也就是让我们的代码在容器内运行。
- 在你的项目中添加一个名为
.vscode
的文件夹(请注意文件夹名称前的点)。在这个文件夹内,添加一个名为launch.json
的文件,内容如下:
图 6.27 – 调试 Node.js 应用的启动配置
-
要打开
launch.json
文件,按 cmd + Shift + P(Windows 上为 Ctrl + Shift + P)打开命令面板;搜索launch.json
文件,应该会在编辑器中打开。 -
打开
index.js
文件,然后点击左侧边栏第 25 行来设置一个断点:
图 6.28 – 在我们的 Node.js 示例应用中设置断点
-
按 cmd + Shift + D(Windows 上为 Ctrl + Shift + D)打开 VS Code 的调试视图。
-
确保你在视图顶部绿色启动按钮旁边的下拉菜单中选择了正确的启动任务。选择
launch.json
文件,应该如下所示:
图 6.29 – 选择正确的启动任务来调试我们的 Node.js 应用
-
接下来,点击绿色的启动按钮,将 VS Code 附加到容器内运行的 Node.js 应用。
-
在另一个终端窗口中,使用
curl
访问/colors
端点:$ curl localhost:3000/colors
观察代码执行在断点处停止:
图 6.30 – 代码执行在断点处停止
在前面的截图中,我们可以看到一个黄色的条形,表示代码的执行已在断点处停止。在右上角,我们有一个工具栏,可以逐步导航代码。左侧则是 VARIABLES、WATCH 和 CALL STACK 窗口,我们可以用它们来观察正在运行的应用的细节。我们正在调试容器内运行的代码这一事实,可以通过我们在启动容器的终端窗口中看到输出调试器已附加来验证,这是我们在 VS Code 中启动调试的那一刻产生的。
-
要停止容器,可以在终端窗口中输入以下命令:
$ docker container rm --force node-sample
-
如果我们想使用
nodemon
获得更多的灵活性,则需要稍微修改container run
命令:$ docker container run --rm \ --name node-sample \ -p 3000:3000 \ -p 9229:9229 \ -v $(pwd):/app \ node-sample-dev nodemon --inspect=0.0.0.0 index.js
请注意我们是如何使用启动命令 nodemon --inspect=0.0.0.0 index.js
的。这将带来一个好处:每当代码发生更改时,容器内运行的应用将会自动重启,正如我们在本章前面所学的那样。你应该看到如下内容:
图 6.31 – 使用 nodemon 启动 Node.js 应用程序并打开调试功能
-
不幸的是,应用程序重启的结果是调试器与 VS Code 失去连接。但别担心——我们可以通过在
launch.json
文件中的启动任务中添加"restart": true
来减轻这个问题。修改任务,使其如下所示:{ "type": "node", "request": "attach", "name": "Docker: Attach to Node", "remoteRoot": "/app", "restart": true},
-
保存更改后,通过点击调试窗口中的绿色启动按钮启动 VS Code 中的调试器。在终端中,你应该能看到调试器已附加,并显示一条消息作为输出。除此之外,VS Code 会在底部显示一个橙色状态栏,表示编辑器处于调试模式。
-
在另一个终端窗口中,使用
curl
并尝试导航到localhost:3000/colors
,以测试逐行调试是否仍然有效。确保代码执行在你设置的任何断点处停下来。 -
一旦你验证调试仍然有效,尝试修改一些代码;例如,修改返回颜色的数组并添加另一个颜色。保存你的更改。观察
nodemon
如何重启应用程序,并且调试器自动重新附加到容器内运行的应用程序:
图 6.32 – nodemon 重启应用程序并且调试器自动重新附加到应用程序
到此为止,我们已经完成所有的设置,现在可以像在宿主机上运行代码一样,在容器内运行的代码进行操作。我们几乎去除了容器给开发过程带来的所有摩擦。现在我们可以尽情享受将代码部署到容器中的好处。
- 清理时,在你启动容器的终端窗口中按下Ctrl + C以停止容器。
现在你已经学会了如何逐行调试运行在容器中的 Node.js 应用程序,让我们学习如何对 .NET 应用程序做同样的操作。
调试 .NET 应用程序
在这一部分,我们将快速演示如何逐行调试一个 .NET 应用程序。我们将使用本章前面创建的示例 .NET 应用程序:
-
导航到项目文件夹并从其中打开 VS Code:
$ cd ~/The-Ultimate-Docker-Container-Book/ch06/csharp-sample
-
然后,通过以下命令打开 VS Code:
$ code .
-
要使用调试器,我们可以完全依赖 VS Code 命令的帮助。按cmd + Shift + P(Windows 上是Shift + Ctrl + P)来打开命令面板。
-
搜索
Docker: 将 Docker 文件添加到工作区
并选择它:- 选择
5000
。
- 选择
一旦你输入了所有必需的信息,Dockerfile
和 .dockerignore
文件将被添加到项目中。花点时间查看这两个文件。注意,这个Dockerfile
是定义为多阶段构建的。
上述命令还将launch.json
和tasks.json
文件添加到项目中新创建的.vscode
文件夹中。这些文件将由 VS Code 使用,帮助它定义在我们要求其调试示例应用程序时应该执行的操作。
-
让我们在
WeatherForecastController.cs
文件的第一个GET
请求处设置一个断点。 -
找到项目中的
.vscode/launch.json
文件并打开它。 -
找到 Docker .NET Core 启动调试配置,并将红色矩形框标记的代码片段添加到其中:
图 6.33 – 修改 Docker 启动配置
launch.json
文件中的dockerServerReadyAction
属性用于指定在 Docker 容器准备好接受请求时应采取的操作。
- 切换到 VS Code 的调试窗口(在 Linux 或 Windows 上分别使用Command + Shift + D或Ctrl + Shift + D打开)。确保已选择正确的调试启动任务——它的名称是 Docker .NET Core Launch:
图 6.34 – 在 VS Code 中选择正确的调试启动任务
- 现在,点击绿色的启动按钮以启动调试器。VS Code 将构建 Docker 镜像,运行容器,并配置容器进行调试。输出将在 VS Code 的终端窗口中显示。浏览器窗口将打开并导航到
localhost:5000/wetherforecast
,因为这是我们在启动配置中定义的内容(第 6 步)。与此同时,应用程序控制器中的断点被触发,如下所示:
图 6.35 – 逐行调试在容器中运行的.NET Core 应用程序
-
现在我们可以逐步执行代码、定义观察点或分析应用程序的调用栈,类似于我们在示例 Node.js 应用程序中所做的那样。点击调试工具栏上的继续按钮,或按F5继续执行代码。
-
要停止应用程序,点击调试工具栏中的红色停止按钮,该按钮位于前述截图的右上角。
现在我们知道如何逐行调试在容器中运行的代码,是时候为我们的代码加入日志记录功能,以便它能够生成有意义的日志信息。
为代码加入日志记录功能以生成有意义的日志信息
一旦应用程序在生产环境中运行,交互式调试应用程序几乎是不可能的,或者强烈不建议这样做。因此,当系统表现异常或出现错误时,我们需要找到其他方法来查找根本原因。最好的方法是让应用程序生成详细的日志信息,开发人员可以使用这些信息来追踪错误。由于日志记录是一个常见的任务,所有相关的编程语言或框架都提供了使得在应用程序中生成日志信息的库,从而使得这一任务变得简单。
将应用程序输出的信息按日志的严重性级别进行分类是常见的做法。以下是这些严重性级别的列表,并附有每个级别的简短描述:
日志级别 | 描述 |
---|---|
跟踪 | 非常详细的信息。在这个级别,你会捕捉应用程序行为的每一个可能细节。 |
调试 | 相对详细且主要用于诊断的信息,帮助你定位潜在的问题。 |
信息 | 正常的应用程序行为或里程碑,比如启动或关闭信息。 |
警告 | 应用程序可能遇到问题,或者你检测到了一个异常情况。 |
错误 | 应用程序遇到了严重问题。这很可能表示某个重要的应用程序任务失败。 |
致命 | 应用程序的灾难性故障。建议立即关闭应用程序。 |
表 6.1 – 生成日志信息时使用的严重性级别列表
日志库通常允许开发人员定义不同的日志目的地 —— 即日志信息的去向。常见的目的地有文件目的地或控制台输出流。当处理容器化应用程序时,强烈建议你将日志输出始终定向到控制台或 STDOUT
。Docker 随后会通过 docker container logs
命令提供这些信息。其他日志收集器,如 Logstash、Fluentd、Loki 等,也可以用来抓取这些信息。
为 Python 应用程序添加日志功能
让我们尝试为现有的 Python 示例应用程序添加日志功能:
-
首先,在你的终端中,导航到项目文件夹并打开 VS Code:
$ cd ~/The-Ultimate-Docker-Container-Book/ch06/python-demo
-
使用以下命令打开 VS Code:
$ code .
-
打开
main.py
文件,并将以下代码片段添加到文件顶部:
图 6.36 – 为我们的 Python 示例应用程序定义一个日志记录器
在第 1 行,我们导入了标准的日志库。然后,在第 3 行,我们为我们的示例应用程序定义了一个日志记录器。在第 4 行,我们定义了用于日志记录的过滤器。在这个例子中,我们将其设置为 WARN
。这意味着应用程序产生的所有日志消息,其严重性级别等于或高于 WARN
的都会被输出到定义的日志处理器或接收器中,这就是我们在本节开头所说的。在我们的例子中,只有日志级别为 WARN
、ERROR
或 FATAL
的消息会被输出。
在第 6 行,我们创建了一个日志处理器或接收器。在我们的例子中,它是 StreamHandler
,输出到 STDOUT
。然后,在第 8 行,我们定义了日志记录器输出消息的格式。这里,我们选择的格式会输出时间和日期、应用程序(或日志记录器)名称、日志严重性级别,最后是我们开发者在代码中定义的实际消息。在第 9 行,我们将格式化器添加到日志处理器,而在第 10 行,我们将处理器添加到日志记录器。
注意
我们可以为每个日志记录器定义多个处理程序。
现在,我们已经准备好使用日志记录器了。
- 让我们工具化
hello
函数,该函数在我们导航到/
端点时被调用:
图 6.37 – 使用日志记录工具化一个方法
如前所示的截图中,我们在前面的代码段中添加了第 3 行,使用 logger
对象生成了一个 INFO
级别的日志消息。消息是 "访问
端点 '/'
"。
- 让我们工具化另一个函数并输出一条
WARN
级别的消息:
图 6.38 – 生成一个警告
这一次,我们在 colors
函数的第 3 行生成了一条 WARN
级别的日志消息。到目前为止,一切顺利——这并不难!
-
现在,让我们运行应用程序并查看我们得到的输出:
$ python3 main.py
-
然后,在浏览器中,首先导航到
localhost:5000/
,然后导航到localhost:5000/colors
。你应该看到类似以下的输出:
图 6.39 – 运行已工具化的示例 Python 应用程序
如你所见,只有警告消息被输出到控制台;INFO
消息没有显示。这是由于我们在定义日志记录器时设置的过滤器。此外,注意我们的日志消息是如何格式化的,开头包含日期和时间,然后是日志记录器的名称、日志级别,最后是第 3 行中定义的消息,如 图 6.39 中所示。
- 完成后,按 Ctrl + C 停止应用程序。
现在我们已经学会了如何对 Python 应用程序进行工具化,接下来让我们学习如何对 .NET 做同样的事情。
对 .NET C# 应用程序进行工具化
让我们对示例 C# 应用程序进行工具化:
-
首先,导航到项目文件夹,从这里打开 VS Code:
$ cd ~/The-Ultimate.Docker-Container-Book/ch06/csharp-sample
-
使用以下命令打开 VS Code:
$ code .
-
接下来,我们需要将包含日志库的 NuGet 包添加到项目中:
$ dotnet add package Microsoft.Extensions.Logging
这应该将以下行添加到你的dotnet.csproj
项目文件中:
<PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
-
打开
Program.cs
类,并注意到我们在第 1 行有以下语句:var builder = WebApplication.CreateBuilder(args);
默认情况下,此方法调用会向应用程序添加几个日志提供程序,其中包括控制台日志提供程序。这非常方便,并且使我们不必进行任何复杂的配置。你当然可以随时使用自己的设置覆盖默认设置。
-
接下来,打开
Controllers
文件夹中的WeatherForecastController.cs
文件,并添加以下内容:-
添加一个类型为
ILogger
的实例变量logger
。 -
添加一个构造函数,具有
ILogger<WeatherForecastController>
类型的参数。将此参数分配给logger
实例变量:
-
图 6.40 – 为 Web API 控制器定义日志记录器
- 现在,我们准备在控制器方法中使用日志记录器。让我们在
Get
方法中添加一个info消息(如下代码中的第 4 行):
图 6.41 – 从 API 控制器记录 INFO 消息
- 现在,让我们在
Get
方法后添加一个实现/warning
端点的方法,并进行日志记录(此处为第 4 行):
图 6.42 – 使用 WARN 日志级别记录消息
-
让我们通过使用以下命令运行应用程序:
$ dotnet run
-
在新的浏览器标签页中,我们应该看到以下输出。为此,我们必须导航到
localhost:3000/weatherforecast
,然后是localhost:3000/warning
:
图 6.43 – 我们示例.NET 应用程序的日志输出
我们可以看到日志消息的输出,标有红色箭头,分别是info
和warn
类型。所有其他日志项均由 ASP.NET 库生成。如果需要调试应用程序,您可以看到有很多有用的信息。
- 完成后,按Ctrl + C结束应用程序。
现在,我们已经学习了如何为代码添加日志记录,简化了在生产环境中查找问题根源的方式,接下来,我们将了解如何使用 Open Tracing 标准进行分布式追踪,并使用 Jaeger 作为工具来监控分布式应用程序。
使用 Jaeger 进行监控和故障排除
当我们想要在复杂的分布式系统中监控和故障排除事务时,我们需要比刚刚学到的东西更强大的工具。当然,我们可以并且应该继续通过有意义的日志消息来插桩我们的代码,但我们需要在此基础上再加上更多的功能。这个更多就是能够端到端地跟踪单个请求或事务,它穿越了由多个应用服务组成的系统。理想情况下,我们还希望捕获其他有趣的指标,例如每个组件花费的时间与请求总时长的对比。
幸运的是,我们不必从头开始发明轮子。有许多经过实战检验的开源软件帮助我们实现上述目标。一个这样的基础设施组件或软件的例子是 Jaeger(www.jaegertracing.io/
)。使用 Jaeger 时,您运行一个中央 Jaeger 服务器组件,每个应用组件使用一个 Jaeger 客户端,将调试和跟踪信息透明地转发到 Jaeger 服务器组件。Jaeger 为所有主要编程语言和框架提供了客户端,例如 Node.js、Python、Java 和.NET。
本书中不会详细讨论如何使用 Jaeger 的所有细节,但我们会提供一个概念上的高级概述:
-
首先,我们必须定义一个 Jaeger 跟踪器对象。这个对象协调了在我们分布式应用中跟踪请求的整个过程。我们可以使用这个跟踪器对象,并从中创建一个 logger 对象,应用代码可以使用它来生成日志项,类似于我们在之前的 Python 和.NET 示例中所做的。
-
接下来,我们必须使用 Jaeger 所称的 span 来包装每个我们希望跟踪的代码方法。这个 span 有一个名称,并为我们提供了一个作用域对象。
-
让我们来看一些 C#伪代码来说明这一点:
图 6.44 – 在 Jaeger 中定义一个 span – 伪代码
如您所见,我们正在对SayHello
方法进行插桩。通过using
语句创建一个 span,我们将整个应用代码包装在这个方法中。我们将这个 span 命名为sayhello
;这将是我们在 Jaeger 生成的跟踪日志中识别该方法的 ID。
请注意,该方法调用了另一个嵌套方法FormatString
。这个方法与所需的插桩代码非常相似。
我们的跟踪器对象在该方法中构建的 span 将是调用方法的子 span。这个子 span 被称为format-string
。另外,请注意,在前面的代码中我们使用了 logger 对象,显式地生成了一个INFO
日志级别的日志项:
图 6.45 – 在 Jaeger 中创建子 span – 伪代码
在本章提供的代码中,你可以找到一个完整的 Java 和 Spring Boot 应用程序示例,其中包含一个 Jaeger 服务器容器和两个名为api
和inventory
的应用容器,它们使用 Jaeger 客户端库来对代码进行仪表化。按照以下步骤重建这个解决方案:
- 导航到
api
,如下所示:

我们将在 第十一章中详细解释什么是docker-compose
文件,使用 Docker Compose 管理容器。
-
使用以下命令运行 Jaeger:
$ docker compose up -d
-
在一个新的浏览器标签中,导航到 Jaeger UI,地址是
http://localhost:16686
。 -
在你的 VS Code 中找到
api
和inventory
项目的两个pom.xml
文件。通过将以下代码片段添加到它们的dependencies
部分,将 Jaeger 集成组件添加到每个文件:
图 6.48 – 将 Jaeger 集成添加到 Java 项目
-
在
inventory
项目中,找到启动类InventoryApplication
,并为其添加一个生成RestTemplate
实例的 bean。我们将使用它来访问外部 API 下载一些数据。代码片段应该如下所示:@BeanRestTemplate restTemplate() { return new RestTemplate();}
-
在
api
项目的启动类ApiApplication
中做相同的操作。 -
现在,让我们回到
inventory
项目。添加一个名为Todo.java
的新文件,与启动类平级。文件内容如下:
图 6.49 – Jaeger 示例中的 API 项目中的 Todo 类
这是一个非常简单的 POJO 类,我们将其用作数据容器。
-
在
api
项目中做相同的操作。 -
转到
inventory
项目,添加一个名为TodosController.java
的新文件,内容如下:
图 6.50 – Jaeger 演示的 TodosController 类
注意,在第 19 行,我们通过公共JSONPlaceholder API来下载待办事项列表,并在第 20 行将这些项目返回给调用者。这里没有什么特别的。
- 对于
api
项目,添加一个名为HelloController.java
的新文件,内容如下:
图 6.51 – Jaeger 演示的 HelloController 类
注意,第一个方法监听/hello
端点,它只是返回一个字符串。然而,第二个端点监听/todos
端点,它连接到api
服务及其端点/api/todos
。api
服务会返回它从 JSON Placeholder API 下载的待办事项列表。这样,我们就有了一个真正的分布式应用程序,准备展示 Jaeger 和 Open Tracing 的强大功能。
-
我们还没有完全完成。我们需要通过各自的
applications.properties
文件来配置这两个项目: -
在
api
项目中找到application.properties
文件,并向其中添加以下行:spring.application.name=jaeger-demo:api
上述代码定义了服务的名称以及 Jaeger 如何报告该服务。
-
在
inventory
项目中找到相同的文件,并向其中添加以下两行:server.port=8090spring.application.name=jaeger-demo:inventory
-
第一行确保库存服务监听在端口
8090
,而不是默认的8080
端口,以避免与将运行在默认端口的api
服务发生冲突。
第二行定义了服务的名称以及 Jaeger 如何报告该服务。
-
现在,在 VS Code 中点击各自启动类的
main
方法,启动inventory
和api
项目。 -
使用
curl
或 Thunder Client 访问库存服务的暴露端点,地址为http://localhost:8090/api/todos
。你也可以在新的浏览器标签页中执行相同操作。你应该会收到 100 条随机待办事项列表。 -
现在,尝试访问
api
服务的该链接 ,它位于http://localhost:8080/todos
端点。应返回相同的待办事项列表,但这次,它们应该来自api
服务,而不是直接来自 JSON Placeholder API。 -
现在,回到你打开 Jaeger UI 的浏览器标签页。
-
确保你在搜索标签页上。
-
从服务下拉列表中选择jaeger-demo:api。
-
点击查找跟踪。你应该能看到类似这样的内容:
图 6.52 – api 服务的 Jaeger 跟踪
- 点击跟踪以展开它。你应该会看到如下内容:
图 6.53 – api 服务的 Jaeger 跟踪详细信息
在这里,我们可以看到 api
服务如何调用 inventory
服务。我们还可以看到每个组件花费的时间。
-
为了清理,停止 Jaeger 服务器容器:
$ docker compose down
-
同时,使用 Ctrl + C 停止 API。
在本演示中,我们看到仅仅通过添加一个将我们的 Spring Boot 应用与 Jaeger 和 Open Tracing 集成的组件,无需任何特殊代码,我们便获得了大量的洞察。然而,我们只是刚刚触及可能性的表面。
api
和 inventory
服务使用类似的 Dockerfile
,正如我们在本章 Java 演示应用中所做的那样。各自的 Dockerfile
应位于 api
和 inventory
项目的根目录中。
然后,修改 docker-compose.yml
文件。完成后,使用以下命令运行整个应用:
$ docker compose up -d
如果你还不熟悉 Docker Compose,不用担心。我们将在 第十一章 中详细讨论这个非常有用的工具,使用 Docker Compose 管理容器。
总结
在这一章节中,我们学习了如何在容器内运行和调试 Node.js、Python、Java 和 .NET 代码。我们从将源代码从主机挂载到容器开始,以避免每次代码更改时都重新构建容器镜像。接着,我们通过启用容器内的自动应用重启功能,使得代码更改后能够更顺畅地进行开发。然后,我们学习了如何配置 VS Code,以便在容器内运行代码时启用完整的交互式代码调试。
最后,我们学习了如何为我们的应用添加监控,以便它们生成日志信息,帮助我们对生产环境中表现异常的应用或应用服务进行根本原因分析。我们首先通过使用日志库对代码进行监控。接着,我们使用 Open Tracing 标准进行分布式追踪,并使用 Jaeger 工具对 Java 和 Spring Boot 应用进行监控,从而获得应用内部运行的宝贵洞察。
在下一章节中,我们将展示如何利用 Docker 容器提升自动化,从在容器中运行简单的自动化任务,到使用容器构建 CI/CD 管道。
问题
尝试回答以下问题,以评估你的学习进度:
-
请列举两种通过使用容器来减少开发过程中的摩擦的方法。
-
如何在容器内实现实时代码?
-
在容器内运行时,何时以及为何要逐行调试代码?
-
为什么为代码添加良好的调试信息至关重要?
答案
这是本章节问题的答案:
-
可能的答案:
-
将源代码挂载到容器中
-
使用一个工具,自动重启容器内运行的应用,当检测到代码更改时
-
配置你的容器以进行远程调试
-
-
你可以将包含源代码的文件夹从主机挂载到容器中。
-
如果你无法通过单元测试或集成测试轻松覆盖某些场景,并且当应用程序在主机上运行时无法重现观察到的行为。另一种情况是由于缺少必要的语言或框架,无法直接在主机上运行应用程序。
-
一旦应用程序投入生产环境,我们作为开发者通常无法轻易访问它。如果应用程序出现意外行为甚至崩溃,日志通常是我们唯一的可用信息来源,帮助我们重现情况并找出 bug 的根本原因。
第七章:7
测试运行在容器中的应用程序
在前面的章节中,我们已经学习了如何将用任何语言编写的应用程序容器化,例如 Node.js、Python、Java、C# 和 .NET。我们都知道,仅仅编写代码并将其部署到生产环境是不够的。我们还需要确保代码没有错误,并且能够按预期执行。这通常被归纳为质量保证,简称 QA。
实践中反复证明,修复生产中发现的应用程序 bug 要比在开发过程中发现并修复 bug 成本高昂。我们希望避免这种情况。最具成本效益的方法是让编写代码的开发人员同时编写自动化测试,确保新代码或修改后的代码具有高质量,并且能够完全按照业务需求或功能规格中的验收标准执行。
以下是我们将在本章中讨论的主题列表:
-
在容器中运行应用程序的测试好处
-
不同类型的测试
-
常用的工具和技术
-
设置测试环境的最佳实践
-
调试和排查问题的技巧
-
在容器中运行应用程序时测试的挑战和注意事项
-
案例研究
阅读完本章后,你将能够做以下事情:
-
向对容器中运行的应用程序感兴趣的外行解释测试的好处
-
设置一个生产环境,允许你为运行在容器中的应用程序或服务编写和执行测试
-
为运行在容器中的代码开发单元测试和集成测试
-
在容器中运行单元测试和集成测试,测试目标是应用程序代码
-
运行一个专门的容器,进行功能测试,作为黑盒作用于你的应用程序
-
管理应用程序的依赖关系并创建测试数据
技术要求
在本章中,你需要在你的 Mac、Windows 或 Linux 机器上安装 Docker Desktop、终端和 VS Code。因为我们将与代码进行交互,所以你应该在你从 GitHub 克隆的代码仓库中准备一个章节文件夹:
-
导航到你克隆的与本书配套的 GitHub 仓库所在的文件夹。通常,你可以按以下方式操作:
$ cd ~/The-Ultimate-Docker-Container-Book
-
在该目录中创建一个章节文件夹并导航到它:
$ mkdir ch07 && cd ch07
和往常一样,你可以在 sample-solutions/ch07
子文件夹中找到本章所有练习的完整示例解决方案。
在容器中测试应用程序的好处
在本节中,我们将讨论在容器中测试应用程序的好处,包括能够复制生产环境、配置和设置的简便性以及更快的测试执行速度。
但在我们开始之前,让我们稍微停一下,问问自己,为什么我们要进行测试?
为什么我们要进行测试?
每个在软件开发领域工作的人员都知道,需要以快速的节奏实施和发布新的或更改过的应用功能。总是有持续的压力要求快速实现新代码并将其发布到生产环境中。但编写功能规格的业务分析师和编写实际代码来实现这些规格的软件工程师,毕竟也是普通人。在巨大的压力下工作的人容易犯错误。这些错误可能是微妙的,也可能是相当严重的。这些错误最终会在生产环境中运行的应用程序中表现出来。我们的客户将会发现这些问题,这将产生后果。
手动测试与自动化测试
大多数编写商业应用程序的公司都会有一支手动软件测试团队。这些人员会拿到产品工程团队为他们准备的最新版本的应用程序,并对该应用程序执行一套手动回归测试。如果手动测试人员发现缺陷,他们会在类似 Jira 的工具中报告这个缺陷,并理想地写下所有开发人员修复该缺陷时需要的详细信息。这包括被测试应用程序的确切版本、测试人员发现缺陷之前所做的步骤,以及缺陷的一些证据,例如截图、错误信息、堆栈跟踪和日志条目。这些由手动测试人员编写的缺陷报告将成为产品工程团队的待办事项的一部分。
然后,产品工程团队会与测试人员一起,定期(例如每日)对所有新的缺陷报告进行筛查,并决定需要多快解决某个特定的缺陷。通常会使用 P1、P2、P3 和 P4 的分类,其中 P1 是需要立即修复的最高严重性缺陷,P4 则是优先级较低、可以在团队有空时处理的缺陷。
如果应用程序是典型的企业应用,包含多个在云端运行的服务,那么测试人员就需要一个特殊的环境来进行回归测试。这个环境通常被称为用户验收测试,简称UAT。这种企业应用的完整测试套件通常包含几百个测试用例。执行一个单独的测试用例需要手动测试人员花费相当多的时间。并且并不罕见,专门的手动测试团队需要几周时间才能完成一次完整的测试。在此期间,UAT 环境会被占用。无法将新版本部署到此环境,因为否则测试人员将不得不重新开始他们的回归测试。应用程序中的每一个变更都可能引入新的缺陷,只有在每个新版本上执行完整的回归测试套件,我们才能确保捕捉到所有问题。
只有在手动测试人员完成所有回归测试,并且没有发现更严重的 bug 后,当前版本的应用程序才能发布到生产环境。
我敢打赌,你可以想象,UAT(用户验收测试)被阻塞好几周会给软件开发过程带来一些重大问题。你的许多产品工程团队将积累大量新的代码,包括新功能和修复的漏洞,而这些代码由于手动测试人员还在测试旧版本,导致无法发布到生产环境。但是,积累大量代码变更的同时,也增加了风险。发布一段经历过许多变更的软件,比起我们持续发布包含最小变更的新版本,风险要大得多。
解决这个问题的唯一真正方法是缩短回归测试周期。我们需要将其从几周缩短到几分钟或几个小时。这样,我们就可以以持续的方式测试和发布小批量的变更。但没有人类能测试得这么快。解决方案就是完全依赖自动化测试。没错,我是这么说的:我们应该完全依赖自动化的回归和验收测试。
我们学到了什么?手动测试不可扩展,且非常枯燥,因为测试人员必须一遍又一遍地重复相同的测试,并且容易出错,因为人类所做的一切并非自动化,因此每次都无法做到完全重复。
这是否意味着我们必须解雇所有的手动测试人员?不一定。手动测试人员不应该执行验收测试和回归测试,而应执行探索性测试。手动测试人员是人类,他们应该利用这一事实以及他们的创造力,去发现应用程序中尚未被发现的潜在缺陷。正如探索性测试一词所暗示的那样,这些测试并不遵循特定的脚本,而是随机进行,仅由测试人员的专业经验和他们对应用程序所在业务领域的理解所指导。如果测试人员发现了一个 bug,他们会为其写一个工单,之后这个工单将被整理并加入开发团队的待办事项中。
为什么我们要在容器中测试?
运行容器中测试有几个常见的好处:
-
隔离:在容器中运行测试可以为测试环境与主机系统之间提供一定的隔离级别,这对于确保测试结果的一致性和可重复性非常有用。
-
环境一致性:容器允许你将整个测试环境(包括依赖、库和配置)打包成一个独立的单元,这可以帮助确保测试环境在不同的开发环境中保持一致。
-
易用性:容器可以简化设置和运行测试的过程,因为你不必手动在主机系统上安装和配置所有必要的依赖和库。
-
可移植性:容器可以轻松地在不同环境之间迁移,这对于在不同环境或平台上运行测试非常有用。
-
可扩展性:容器通过允许并行运行测试或在多台机器上运行测试,使得扩展测试基础设施变得更加容易。
总体而言,在容器中运行测试有助于提高测试过程的可靠性、一致性和可扩展性,并使得设置和维护与主机系统隔离的测试环境变得更加容易。
不同类型的测试
本节概述了在容器中运行的应用程序上可以执行的不同类型的测试,包括单元测试、集成测试和验收测试。
单元测试
单元测试的主要目标是验证单元,即代码中的一个小的、孤立的部分的功能。为了检查代码是否准确且按预期工作,开发者通常会在创建或修改代码时编写单元测试。这些测试随后会作为开发过程的一部分定期执行。
单元测试不依赖于其他资源或组件,旨在测试代码的独立部分。这使得开发者能够快速发现并解决代码中的错误,同时使得测试运行快速且简单。
通常,促进单元测试创建、运行和报告的工具和测试框架用于生成单元测试。这些工具通常提供自动测试发现、测试执行和测试结果报告等功能,并使开发者能够使用特定的语法或结构来创建单元测试。
完善的测试方法应包括单元测试,因为它们使开发者能够验证代码在最细粒度级别上是有效的,并按预期工作。通常,单元测试作为持续集成(CI)过程的一部分执行,这是一个每次代码变更提交到版本控制系统时自动执行代码变更的工作流程。
集成测试
被称为集成测试的软件测试用于检查不同系统或组件如何作为一个整体协同工作。它通常在单元测试之后进行,涉及检查应用程序或系统的各个部分如何相互交互。
集成测试旨在检查不同单元或组件如何协同工作。它们通常用于确认应用程序或系统的各个组件是否能够按预期工作。测试多个软件组件的集成或软件程序与外部资源(如数据库或 API)的集成是这类测试的例子。
由于执行测试需要设置和配置多个组件或系统,因此集成测试通常比单元测试更加复杂和耗时。为了使测试能够执行并生成报告,它们还可能需要使用专门的测试工具和框架。
与单元测试一样,集成测试是全面测试方法中的一个重要组成部分,因为它使开发人员能够确认多个系统或组件能够按预期一起工作。
接受测试
这种软件测试被称为接受测试,它确保系统或应用程序适合其预定用途,并满足所有要求。它通常是在所有其他类型的测试(如单元测试和集成测试)之后进行,是测试过程中的最后一步。
接受测试通常由一个不同的团队或测试小组进行,这个小组的任务是从最终用户的角度评估系统或应用程序。这些测试的目的是确保系统或程序易于使用,符合预期用户的需求,并且具有良好的用户体验。
功能测试(确保应用程序或系统正确执行所需功能)、可用性测试(确保应用程序或系统易于使用)和性能测试只是接受测试中可能包括的几种测试类型示例(用于验证应用程序或系统在不同负载条件下的表现)。
接受测试是软件开发过程中的一个关键步骤,因为它使开发人员能够确认系统或应用程序已经准备好部署,并满足预定客户的需求。尽管强烈建议采用自动化的接受测试技术来辅助测试过程,但通常它是由测试人员手动进行的。
在本章中,我们将讨论一种特殊类型的接受测试,称为黑盒测试。与单元测试和集成测试相比,黑盒测试的主要区别在于,它们从一个明显以业务为导向的角度来审视被测试系统。理想情况下,接受测试,以及其中的黑盒测试,应该反映出由业务分析师或产品负责人编写的功能规格中的接受标准。通常,接受测试的编写方式是将要测试的组件视为一个黑盒。该组件的内部实现不重要,也不应当重要。测试代码只能通过组件的公共接口访问该组件或系统。通常,公共接口是 API 或组件消费或生成的消息。
图 7.1 – 接受测试与被测试系统交互
在前述图中,我们可以看到测试代码是如何以流行的Arrange-Act-Assert(AAA)格式组织的。首先,我们设置边界条件(arrange)。接下来,我们指定要在被测试系统上执行的操作(act)。最后,我们验证操作的结果是否符合预期(assert)。被测试系统(SUT)是具有公共接口的组件,可以是 REST API,或者是它从消息总线中消费的消息。大多数情况下,SUT 还有一个数据库,用于存储其状态。
在下一节中,我们将介绍用于测试的工具和技术。
常用工具和技术
现在让我们讨论一些常用的工具和技术,这些工具和技术用于测试运行在容器中的应用程序,如 Docker、Kubernetes 和持续集成与交付(CI/CD)平台。
实现示例组件
在本节中,我们将实现一个示例组件,稍后我们将使用它来演示如何编写和执行测试,特别是如何结合自动化测试和使用 Docker 容器的优势。我们将使用最新版本的 Java 和 Spring Boot 来实现该示例组件。
这个示例组件代表了一个简单的 REST API,背后有一些 CRUD 逻辑。创建和管理动物物种及相关种群的任务相对简单,不需要更复杂的建模。为了简单起见,我们使用内存数据库 H2。这意味着每次组件重启时,之前的数据会被清除。如果你想改变这一点,可以配置 H2 使用后备文件来进行持久化:
- 使用spring initializr页面
start.spring.io
来启动 Java 项目。配置完成后,页面应该如下所示:
图 7.2 – 启动图书馆项目
注意我们已在前述图右侧添加了四个依赖项。
-
下载启动代码并将文件解压到章节文件夹
.../ch07
中。现在,你应该能看到一个名为library
的子文件夹,里面包含我们可以用来实现 API 的代码起始点。 -
在 VS Code 中打开该项目。
-
在
src/main/java/com/example/library
文件夹中找到LibraryApplication.java
文件。它是一个典型的启动类,包含 Spring Boot 基础的 Java 应用程序的main
函数。 -
在此文件夹内,分别创建三个子文件夹,命名为
controllers
、models
和repositories
。它们将包含我们图书馆的逻辑。
图 7.3 – 图书馆 API 的项目结构
-
首先,我们定义应用程序中使用的模型。向
models
文件夹添加以下简单数据类:- 向名为
Race.java
的文件中添加以下内容:
- 向名为
图 7.4 – 种族数据类
- 向名为
Species.java
的文件中添加以下内容:
图 7.5 – 种类数据类
注意,我们使用@Entity
注解来标记这些类为(数据库)实体,并且我们用@Id
注解装饰它们各自的id
属性,以告诉 Spring Boot 该属性表示每个实体的唯一 ID。
-
接下来,我们将实现用于持久化数据和从数据库中检索数据的仓库。向
repositories
文件夹中添加以下内容:- 一个名为
RaceRepository.java
的文件,内容如下:
- 一个名为
图 7.6 – 种族仓库的代码
注意第 10 行,我们添加了一个自定义的findBySpeciesId
方法,它将允许我们检索所有分配给特定speciesId
的种族。
- 一个名为
SpeciesRepository.java
的文件,内容如下:
图 7.7 – 种族仓库的代码
-
然后,我们定义了两个 REST 控制器,通过这些控制器我们可以与应用程序进行交互。向
controllers
文件夹中添加以下内容:- 一个名为
RacesController.java
的文件,内容如下:
- 一个名为
图 7.8 – 种族控制器的代码
- 一个名为
SpeciesController.java
的文件,代码如下:
图 7.9 – 种族控制器的代码
- 最后,我们需要进行一些应用程序配置。我们可以在
application.properties
文件中进行配置,你可以在src/main/resources
文件夹中找到此文件。将以下内容添加到该文件中,这将配置我们在本示例中将使用的数据库:
图 7.10 – 应用程序配置
我们使用的是 H2 内存数据库,用户名为sa
,没有密码。我们还确保在应用程序中启用了 H2 控制台,以便通过浏览器轻松检查数据(第 6 行)。
- 现在打开
LibraryApplication
类,并点击主方法上方的Run链接以启动应用程序。观察终端中生成的输出:
图 7.11 – 记录运行中库应用程序的输出
阅读日志输出并尽量理解每一行。前述输出的倒数第二行告诉我们,应用程序可以通过端口8080
访问,这是 Spring Boot 应用程序的默认端口。还请注意有一行显示H2 console available at ‘/h2-console’. Database available at ‘jdbc:h2:mem:inventory’
,这表明我们现在可以在浏览器中打开localhost:8080/h2-console
来访问 H2 控制台,并通过它访问我们的内存数据库。
-
使用 VS Code 中的 Thunder 客户端、Postman,或者终端中的
curl
命令向数据库添加物种。这里我们使用curl
:$ curl -X POST -d '{"id": 1, "name": "Elephant"}' \ -H 'Content-Type: application/json' \ localhost:8080/species
响应应如下所示:
{"id":1,"name":"Elephant","description":null}
-
再次使用
curl
(或任何其他工具)列出存储在系统中的物种:$ curl localhost:8080/species
输出应如下所示:
[{"id":1,"name":"Elephant","description":null}]
它是一个包含一个元素的 JSON 数组。
-
尝试所有我们实现的两个控制器支持的其他
REST
调用,例如使用PUT
更新现有物种,以及GET
、POST
和PUT
用于/races
端点。 -
完成后,确保停止应用程序。
接下来,我们需要将应用程序打包成容器并运行:
- 在库项目的根目录下添加一个 Dockerfile,内容如下:
图 7.12 – 库组件的 Dockerfile
-
使用以下命令在
ch07
文件夹中创建一个 Docker 镜像:$ docker image build -t library library
-
使用以下命令运行容器:
$ docker container run -d --rm \ -p 8080:8080 library
-
通过使用与上一节相同的命令,测试当前在容器中运行的组件是否仍按预期工作。
-
完成后,停止包含库组件的容器。我们建议使用 VS Code 的 Docker 插件来操作,或者使用 Docker Desktop 的仪表板。
现在我们有了一个可工作的示例应用程序,可以继续讨论如何使用单元测试、集成测试和黑盒测试来测试这个 REST API。我们从单元测试和/或集成测试开始。
实现和运行单元测试与集成测试
现在我们已经有了一个可以工作的组件,是时候为它编写一些测试了。在这一部分,我们重点关注单元测试和集成测试。Spring Boot 使得入门变得非常简单:
- 在
src/test/java/com/example/library
文件夹中,添加一个LibraryUnitTests.java
文件,内容如下:
图 7.13 – 为库项目编写的示例单元测试
请注意,我们在我们的 Test
类中添加了一个私有的 Calculator
类。这仅用于演示目的,并且更容易展示如何编写单元测试。通常,应该测试代码库中的类和方法。
提示
始终建议按照类似的方式组织您的测试,这样可以更轻松地让其他人(包括自己)阅读和理解这些测试。在这种情况下,我们选择了由 Arrange、Act 和 Assert 组成的 AAA 语法。或者,您也可以使用 Given-When-Then 语法。
- 如果您在 VS Code 编辑器上安装了 Test Runner for Java 扩展程序,现在应该在测试方法旁边看到一个绿色的三角形(上述图像中的第 19 行)。单击它来运行测试。结果应该如下所示:
图 7.14 – 第一次测试运行的结果
注意
或者,您可以使用以下命令从命令行运行测试:
$ ./``mvnw test
- 现在让我们添加一个示例集成测试。为此,在放置单元测试的同一文件夹中添加一个名为
LibraryIntegrationTests.java
的文件。我们将使用 Spring Boot 提供的MockMvc
辅助类来模拟我们的应用程序运行在 web 服务器上,并通过其 REST 端点进行访问。向测试类中添加以下内容:
图 7.15 – 为库项目编写的示例集成测试
- 以与单元测试相同的方式运行上述测试。确保测试通过。
我们已经完成了准备工作,现在可以将组件打包到一个容器中,并在同一个容器中运行单元测试和集成测试。为此,请按照以下步骤操作:
- 现在让我们在我们的库项目根目录下添加一个 Dockerfile,并加入以下内容。这个内容和我们之前的 Java 示例中使用的是一样的:
图 7.16 – 用于库项目的 Dockerfile
-
然后,让我们使用这个 Dockerfile 构建一个镜像:
$ docker image build -t library .
-
使用以下命令在容器中运行测试:
$ docker container run --rm \ -v $HOME/.m2:/root/.m2 library ./mvnw test
请注意我们使用的卷映射。我们正在将本地 Maven 仓库(位于 $HOME/.m2
)与容器共享,因此在构建应用程序时,Maven 不必首先下载所有依赖项,因为它们已经存在于我们的本地缓存中。这显著改善了整体体验。
还请注意,我们在 Dockerfile 中覆盖了 CMD
命令(在上述图像中的第 8 行),使用 ./mvnw test
来运行测试,而不是运行应用程序。
- 观察生成的输出。输出的最后几行应该像这样,表明已经运行了测试:
图 7.17 – 容器内测试运行的输出
就像你现在在本地笔记本电脑上在容器内运行单元测试和集成测试一样,你也可以在 CI/CD 管道的 CI 阶段运行它。一个简单的 Shell 脚本就足以自动化你刚才手动做的事情。
实现并运行黑盒测试
由于黑盒测试必须将被测试单元(SUT)视为一个封闭系统,因此测试不应在与组件相同的容器内运行。建议将测试代码放入自己的专用测试容器中运行。
还建议不要将黑盒测试和组件的代码混合在一起,而是严格分开。我们将通过使用与组件不同的语言来编写测试来展示这一点。这次,我们将使用 C#。任何语言都可以,例如 Kotlin、Node.js 或 Python。
在这个例子中,我们将使用.NET 和 C#来实现组件测试:
-
在
ch07
文件夹中,执行以下命令来创建测试项目:$ dotnet new xunit -o library-component-tests
这将使用流行的xunit
测试库在library-component-tests
子文件夹中创建一个测试项目。
-
尝试使用以下命令运行测试:
$ dotnet test library-component-tests
(缩短后的)输出应如下所示:
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, …
这表明所有测试都通过了。当然,默认情况下,项目中此时只有一个空的示例测试。
-
使用以下命令在 VS Code 中打开该项目:
$ code library-component-tests
-
定位并打开
UnitTest1.cs
文件。在文件顶部,添加以下语句:using System.Text.Json;
-
在
namespace
声明后,添加以下记录定义:public record Species(int id, string name, string description);
-
现在添加一个名为
can_add_species
的新方法,如下所示:
图 7.18 – 添加物种的组件测试
在这里,我们使用HttpClient
类将类型为Species
的数据对象发布到/species
端点。然后,我们断言操作的 HTTP 响应代码为OK (200)
。注意我们是如何使用 AAA 规范来构造我们的测试的。
- 添加另一个名为
can_get_a_species_by_id
的方法,内容如下:
图 7.19 – 按 ID 读取物种的组件测试
-
在继续并运行测试之前,请确保
library
组件正在运行并监听端口8080
。否则,测试将失败,因为预期的端点没有人监听。使用以下命令:$ docker container run --rm \ -v $HOME/.m2:/root/.m2 library
-
使用以下命令运行测试:
$ dotnet test
确保两个测试都通过。
-
library
组件。 -
完成后,停止
library
组件。
接下来,我们将展示如何在容器中运行测试:
- 向 .NET 测试项目的根目录添加一个包含以下内容的 Dockerfile:
图 7.20 – 组件测试的 Dockerfile
-
使用此 Dockerfile 创建一个镜像。在
ch07
文件夹内,使用以下命令:$ docker image build -t library-component-tests \ library-component-tests
-
再次检查是否已经为库组件创建了 Docker 镜像。如果没有,可以在
ch07
文件夹中使用以下命令来创建:$ docker image build -t library library
-
现在我们已经为库组件和组件测试分别创建了 Docker 镜像,接下来需要分别运行它们的容器:
- 要运行库组件,请使用以下命令:
$ docker container run -d --rm \ -p 8080:8080 library
- 要运行组件测试,请使用以下命令:
$ docker container run library-component-tests
请注意,测试已经执行并且全部通过。
- 完成后,移除这两个容器。可以使用 VS Code 中的 Docker 插件或 Docker Desktop 的仪表板来执行此操作。
好吧,这真是一次很棒的操作。我们展示了如何为使用 Spring Boot 3 编写的 Java 组件编写单元测试和集成测试。我们在本地笔记本电脑上以及在容器内运行了这些测试。然后我们展示了如何在 .NET 7 中创建一些黑盒测试,使用 C# 编写并在我们的库组件上运行它们。我们同样在本地笔记本电脑上执行了这些操作,然后将组件和黑盒测试分别运行在各自的容器中。
接下来,我们将讨论如何最佳地设置测试环境。
设置测试环境的最佳实践
本节中,我们希望列出一些设置容器中应用程序测试环境的最佳实践,包括网络隔离、数据管理和资源约束等方面的考虑:
-
使用独立的测试环境:一般来说,使用独立的测试环境来运行容器中的测试,而不是在与生产环境相同的主机上运行测试,是个好主意。这样可以避免潜在的问题或中断影响生产环境。
-
隔离测试网络:为了确保测试环境与生产环境相互隔离,使用独立的网络进行测试是一个好主意。可以通过使用独立的虚拟网络,或者在容器运行时使用网络命名空间或覆盖网络来实现。
-
谨慎管理测试数据:在容器中测试应用程序时,重要的是要小心管理测试数据,以确保测试的可靠性和可重复性。这可以通过使用测试数据生成工具、快照测试数据或使用独立的测试数据库来实现。
-
使用资源约束:为了确保测试的可靠性和一致性,使用资源约束(例如 CPU、内存)来限制容器可用资源是一个好主意。这有助于防止资源争用,并确保测试不受外部因素(如主机系统的负载)影响。
-
使用容器编排工具:为了管理大量容器并确保它们的一致部署和扩展,使用如 Kubernetes 或 Docker Swarm 等容器编排工具是一个好主意。这些工具可以帮助自动化容器的部署和扩展过程,并提供自动回滚和自愈等功能。
-
监控测试环境:为了确保你的测试环境运行顺利,并发现可能出现的问题,使用监控工具来跟踪容器的性能和资源使用情况是一个好主意。这可以帮助你识别并修复可能影响测试可靠性的问题。
在测试过程中,你可能会遇到一些麻烦和难以解释的测试失败情况。在接下来的部分,我们将提供一些在这种情况下可以采取的应对措施。
调试和排除故障的技巧
在我们容器化环境中运行自动化测试时,可能会时不时遇到一些看似奇怪的行为和神秘失败的测试。以下是一些调试和排除故障的技巧,用于解决测试应用程序时可能出现的问题:
-
docker container logs
查看特定容器的日志。 -
使用调试器:如果错误信息或日志输出不足以诊断问题,你可以使用调试器在运行时检查应用程序的状态。许多集成开发环境(IDE),如我们常用的 VS Code、Visual Studio 和 IntelliJ,都内置了对在容器中运行应用程序调试的支持。
-
docker container exec
在容器内运行命令并检查其环境。 -
使用容器运行时调试器:一些容器运行时(如 Docker)提供了调试容器本身问题的工具(例如资源使用和网络问题)。这些工具对于诊断容器运行时特定的问题非常有帮助。
-
使用容器化调试环境:如果你在本地开发环境中难以复现问题,可以使用容器化的调试环境(例如调试器容器)来更接近地复制生产环境。
-
检查已知问题:如果你在应用程序中使用了第三方库或依赖项,值得检查是否存在任何已知问题或错误,这些问题可能会导致出现问题。许多库和依赖项在其网站或文档中维护已知问题和解决方法的列表。
-
寻求帮助:如果你无法自行诊断问题,不要犹豫,向社区寻求帮助,例如 Stack Overflow 或你使用的库和工具的维护者。网上有许多资源可以利用。
现在让我们讨论一下测试过程中可能出现的一些挑战,以及我们在测试时应该考虑的事项。
测试在容器中运行的应用程序时的挑战和注意事项
除了测试在容器中运行的应用程序所带来的诸多优势,我们还需要简要讨论一下这种测试方式中涉及的挑战和注意事项,例如如何处理依赖关系和管理测试数据:
-
隔离:在容器中测试应用程序可以提供一定程度的隔离,将测试环境与主机系统分开,这有助于确保测试结果的一致性和可重复性。然而,这种隔离也可能使调试问题和识别根本原因变得更加困难,因为你可能无法访问主机系统及其资源。
-
环境一致性:在使用容器时,确保不同开发环境之间的测试环境一致性可能是一个挑战。主机系统、容器运行时和网络配置的差异都会影响应用程序的行为和测试结果。
-
数据管理:在容器化环境中管理测试数据可能是一个挑战,因为你可能需要确保测试数据在所有容器之间一致并可用,或者确保数据得到适当的隔离,且在测试之间不被共享。
-
资源限制:在容器中测试应用程序可能会消耗大量资源,因为你可能需要同时运行多个容器以测试不同的场景。这可能导致资源竞争,并可能需要仔细的资源管理,以确保测试的可靠性和一致性。
-
集成测试:测试多个容器之间的集成可能具有挑战性,因为你可能需要协调多个容器的启动和关闭,并确保它们能够相互通信。
-
性能测试:测试运行在容器中的应用程序的性能可能会很困难,因为性能可能会受到主机系统、容器运行时和网络配置的影响。
总体而言,在容器中测试应用程序需要精心规划和考虑,以确保测试环境的一致性和可靠性,并确保测试结果具有意义且可操作。
在本章结束之前,让我们来看几个公司使用容器化测试的案例研究。
案例研究
在本章的最后一部分,我们展示了一些成功实施容器中应用程序测试策略的组织的案例研究和实例:
-
一家知名在线商店引入了一种自动化测试技术,以提高其软件开发过程的有效性和效率。通过自动化执行功能测试、集成测试和验收测试,该公司显著减少了测试应用程序所需的时间和精力。因此,它能够更迅速且可靠地为客户提供新功能和升级。
-
一家金融服务公司使用自动化测试来提高其交易平台的可靠性和稳定性。通过自动执行单元、集成和验收测试,该公司能够在开发初期发现并修复问题,从而减少了停机风险并提升了客户满意度。
-
一家医疗保健组织使用自动化测试来确保其电子病历(EMR)系统的准确性和可靠性。通过自动执行功能性和可接受性测试,该公司能够快速识别并解决问题,提高了 EMR 系统的可靠性和可信度,并降低了错误和患者伤害的风险。
自动化测试的优势,如更好的质量、更快的开发和部署周期、更高的可靠性和更高的客户满意度,都是通过这些案例研究来说明的。
总结
在本章中,我们了解了容器中运行应用程序的测试的好处,讨论了不同类型的测试,介绍了一些常用的测试工具和技术,并提供了设置测试环境的最佳实践。我们还列出了调试和故障排除的技巧,讨论了在容器中测试应用程序时的挑战和注意事项,并以案例研究总结本章内容。
在下一章中,我们将介绍一些在容器化复杂分布式应用程序时,或使用 Docker 自动化复杂任务时,有用的杂项技巧、窍门和概念。
问题
为了评估你的学习,请在继续下一章之前尝试回答以下问题:
-
我们如何在容器内运行单元测试?
-
我们在生产环境中使用的 Docker 镜像是否应该包含测试代码?请给出理由。
-
我们通常在哪些地方运行容器内的单元测试和集成测试?
-
列举在容器中运行单元测试和集成测试的一些优势。
-
如果在容器中运行测试,你可能会遇到哪些挑战?
答案
以下是本章问题的示例答案:
-
我们已经学习了如何在容器中运行应用。我们看到了用 Node.js、Python、Java 和 .NET C# 编写的示例。我们了解了如何构建 Dockerfile 来创建镜像。具体来说,我们已经学习了如何定义在从该镜像创建容器时要执行的启动命令。对于 Java 应用,这可能如下所示:
CMD java -jar /app/my-app.jar
对于一个 Node.js 应用,可能如下所示:
CMD node index.js
要运行应用的单元测试,我们只需要使用不同的启动命令。
-
我们强烈建议不要将测试代码发布到生产环境中。测试会膨胀 Docker 镜像,带来一些负面副作用,例如:
-
提供了更大的攻击面,容易遭受黑客攻击
-
容器启动时间较长,因为从存储加载镜像到容器主机的内存需要更长时间。
-
由于镜像的体积增大,下载时间较长,网络使用量也更高。
-
-
单元测试和集成测试通常在开发者将代码推送到如 GitHub 这样的代码仓库之前,在开发者的本地机器上运行。一旦代码被推送到 GitHub 或任何其他远程代码仓库,通常 CI/CD 管道就会启动,并执行 CI 阶段。该阶段的一部分是对应用程序执行所有单元测试和集成测试。通常,这些测试是在所谓的构建代理上执行的。在许多情况下,这是一个沙盒环境,可以运行 Docker 容器。因此,CI 阶段使用与开发者在本地运行测试相同的技术来在构建代理中运行测试。需要注意的是,除了某些特殊的冒烟测试外,其他测试绝不会在生产环境中运行,因为这样可能会产生不良的副作用。
-
在容器中运行测试的最重要优势之一是隔离性。我们可以在任何能够运行容器的环境中运行测试,无需担心首先在主机上安装框架或库。
另一个重要的优势是,容器中的测试本身是开箱即用的可重复的。每次启动包含应用代码和测试的容器时,边界条件都是相同的。通过这种方式,我们保证了测试执行的一致性。如果我们在主机上本地运行测试,我们将更难保证这一点。
-
我们在容器中运行测试时可能面临的挑战如下:
-
排查和调试失败的测试可能更加困难。
-
当需要多个容器进行必要的设置时,集成测试可能更加具有挑战性。
-
容器化环境中的资源(如 CPU、内存和网络带宽)可以受到限制(通过 cgroup 设置),从而对测试运行产生负面影响。
-
第八章:8
提高生产力的 Docker 技巧与窍门
本章介绍了在容器化复杂分布式应用程序或使用 Docker 自动化复杂任务时有用的各种技巧、窍门和概念。你还将学习如何利用容器运行整个开发环境。以下是我们将要讨论的主题列表:
-
保持 Docker 环境的整洁
-
使用
.dockerignore
文件 -
在容器中执行简单的管理员任务
-
限制容器的资源使用
-
避免以
root
用户身份运行容器 -
在 Docker 内部运行 Docker
-
优化构建过程
-
扫描漏洞和密钥
-
在容器中运行开发环境
阅读完本章后,你将学会如何完成以下操作:
-
成功地恢复被彻底破坏的 Docker 环境
-
使用
.dockerignore
文件来加速构建,减少镜像大小并增强安全性 -
运行各种工具以执行计算机上的任务,无需安装它们
-
限制容器化应用程序在运行时使用的资源数量
-
通过避免以 root 用户身份运行容器来加固系统
-
通过在 Docker 容器内运行 Docker,启用高级场景
-
加速并改进自定义 Docker 镜像的构建过程
-
扫描你的 Docker 镜像,查找常见的漏洞、暴露点以及不小心包含的密钥
-
在本地或远程运行的容器中运行完整的开发环境
让我们开始吧!
技术要求
在本章中,如果你想跟着代码一起操作,你需要在本地机器上安装 Docker Desktop 和 Visual Studio Code 编辑器。
在开始之前,我们先创建一个文件夹,用于存放在本书这部分内容中将要使用的示例文件。打开一个新的终端窗口,并导航到你克隆示例代码的文件夹。通常,该文件夹是 ~/The-Ultimate-Docker-Container-Book
:
$ cd ~/The-Ultimate-Docker-Container-Book
创建一个名为 ch08
的新子文件夹,并进入该文件夹: 第八章 :
$ mkdir ch08 && cd ch08
现在你已经准备好了,我们开始介绍如何保持 Docker 环境整洁的技巧与窍门。
你可以在此找到示例代码:github.com/PacktPublishing/The-Ultimate-Docker-Container-Book/tree/main/sample-solutions/ch08
。
保持 Docker 环境的整洁
首先,我们要学习如何删除悬挂镜像。悬挂的 Docker 镜像是指没有与任何标签镜像或容器关联的未使用镜像。它通常发生在使用与现有镜像相同标签构建新镜像时。Docker 会保留旧镜像而不是删除它,但会移除标签引用,从而使镜像没有正确的标签。
悬空镜像(Dangling images)是指没有被任何容器或标签镜像引用的镜像,它们占用磁盘空间却没有提供任何好处。特别是在频繁构建和更新镜像的环境中,悬空镜像会随着时间积累。因此,最好定期删除它们。以下是执行此操作的命令:
$ docker image prune -f
请注意,我们在 prune
命令中添加了 -f
(或 --force
)参数。这是为了防止 CLI 在执行时询问你是否确定删除那些多余的层。
停止的容器也可能浪费宝贵的资源。如果你确定这些容器不再需要,应该将它们删除。你可以使用以下命令单独删除它们:
$ docker container rm <container-id|container-name>
你也可以通过使用以下命令批量删除它们:
$ docker container prune --force
在删除容器时的命令中,<container-id|container-name>
表示我们可以使用容器的 ID 或名称来标识该容器。
未使用的 Docker 卷也可能很快占满磁盘空间。在开发环境或 持续集成(CI)环境中,创建大量大多是临时的卷时,养成清理卷的好习惯非常重要。但我必须警告你,Docker 卷用于存储数据,通常这些数据需要比容器的生命周期更长。特别是在生产或类似生产的环境中,这些数据往往至关重要。因此,使用以下命令清理 Docker 主机上的卷时,请确保自己 100%确信操作是正确的:
$ docker volume prune WARNING! This will remove all local volumes not used by at least one container.
Are you sure you want to continue? [y/N]
我们建议使用没有 -f
(或 --force
)标志的命令。这个标志是一个危险且终极的操作,最好给自己一个重新考虑的机会。如果没有该标志,CLI 会输出你在前一个命令中看到的警告。你必须明确确认,输入 y
然后按下 Enter 键。
在生产或类似生产的系统中,你应该避免使用前面的命令,而是通过以下命令逐一删除不需要的卷:
$ docker volume rm <volume-name>
我还应该提到,有一个命令可以清理 Docker 网络。但由于我们尚未正式介绍网络功能,因此我将在 第十章中讲解,使用 单主机网络。
在下一节中,我们将展示如何排除一些文件夹和文件,使其不包含在 Docker 镜像的构建上下文中。
使用 .dockerignore
文件
.dockerignore
文件是一个文本文件,告诉 Docker 在从 Dockerfile 构建 Docker 镜像时忽略某些文件和目录。这类似于 Git 中的 .gitignore
文件。
使用 .dockerignore
文件的主要好处是它可以显著加速 Docker 构建过程。当 Docker 构建一个镜像时,它首先将当前目录中的所有文件(即“构建上下文”)发送到 Docker 守护进程。如果该目录包含不必要的巨大文件或文件夹(如日志文件、本地环境变量、缓存文件等),这些文件可以被忽略,从而加速构建过程。
此外,使用 .dockerignore
文件有助于提高安全性并保持干净的代码实践。例如,它可以防止可能包含敏感信息的文件(如包含私钥的 .env
文件)被包含在 Docker 镜像中。它还可以通过避免不必要的文件来保持 Docker 镜像的最小化大小,这在部署镜像或跨网络传输时尤其有利。
这是一个 .dockerignore
文件的示例:
# Ignore everything**
# Allow specific directories
!my-app/
!scripts/
# Ignore specific files within allowed directories
my-app/*.log
scripts/temp/
在这个示例中,除了 my-app/
和 scripts/
目录中的文件外,其他所有文件都被忽略。然而,my-app/
目录中的日志文件和 scripts/temp/
子目录中的所有文件都被忽略。这种粒度的控制为开发者提供了对 Docker 构建上下文中包含内容的精细控制。
总结来说,使用 .dockerignore
文件是 Docker 构建的最佳实践,它可以加速构建、减小镜像大小,并通过排除不必要或敏感的文件来提高安全性。在下一节中,我们将展示如何在 Docker 容器内执行简单的管理任务。
在容器内执行简单的管理任务
在本节中,我们将提供一些示例,展示你可能希望在容器内而不是在本地计算机上执行的任务。
运行 Perl 脚本
假设你需要从一个文件中去除所有前导空格,你找到以下这个非常实用的 Perl 脚本来完成这个任务:
$ cat sample.txt | perl -lpe 's/^\s*//'
结果发现,你的工作机器上没有安装 Perl。你该怎么办?安装 Perl 吗?嗯,这当然是一个选择,实际上大多数开发者或系统管理员都会这么做。但等一下,你的机器上已经安装了 Docker。我们不能利用 Docker 来避免安装 Perl 吗?而且我们能在任何支持 Docker 的操作系统上做到这一点吗?没错,我们可以。接下来就是这样做的:
-
进入章节的代码文件夹:
$ cd ~/The-Ultimate-Docker-Container-Book/ch08
-
创建一个名为
simple-task
的新子文件夹,并进入该文件夹:$ mkdir simple-task && cd simple-task
-
从该文件夹中打开 VS Code:
$ code .
-
在这个文件夹中,创建一个名为
sample.txt
的文件,并添加以下内容:1234567890 This is some text another line of text more textfinal line
请注意每行开头的空格。保存文件。
-
现在,我们可以运行一个已安装 Perl 的容器。幸运的是,Docker Hub 上有一个官方的 Perl 镜像。我们将使用该镜像的 slim 版本。正常的 Perl Docker 镜像与 slim 版本的主要区别在于它们的大小和镜像中包含的组件。两个镜像都提供 Perl 运行环境,但它们针对不同的使用场景进行了优化:
$ docker container run --rm -it \ -v $(pwd):/usr/src/app \ -w /usr/src/app \ perl:slim sh -c "cat sample.txt | perl -lpe 's/^\s*//'"
前述命令交互式地运行一个 Perl 容器(perl:slim
),将当前文件夹的内容映射到容器的 /usr/src/app
文件夹,并将容器内部的工作目录设置为 /usr/src/app
。在容器内执行的命令如下:
sh -c "cat sample.txt | perl -lpe 's/^\s*//'"
它基本上启动一个 Bourne shell 并执行我们所需的 Perl 命令。
-
分析前述命令生成的输出。它应该如下所示:
1234567890This is some textanother line of textmore textfinal line
也就是说,所有尾随空格已被删除。
我们无需在机器上安装 Perl,就能够实现我们的目标。好的一点是,脚本运行完后,容器会从系统中被移除,不会留下任何痕迹,因为我们在 docker container run
命令中使用了 --rm
标志,这会自动删除已停止的容器。
提示
如果这还不能说服你,因为如果你在 macOS 上,你已经安装了 Perl,那么考虑一下你正在运行一个名为 your-old-perl-script.pl
的 Perl 脚本,这个脚本是旧版的,并且与你系统上安装的最新版本的 Perl 不兼容。你会尝试在机器上安装多个版本的 Perl 并可能破坏某些东西吗?不,你只需运行一个容器,容器中包含与脚本兼容的(旧版)Perl,如下所示:
$ docker container run -it --``rm \
-v $(``pwd):/usr/src/app \
-w /``usr/src/app \
perl:<old-version>
perl your-old-perl-script.p
l
这里,<old-version>
对应于你需要运行脚本的 Perl 版本标签。
在下一节中,我们将演示如何运行 Python 脚本。
运行 Python 脚本
很多人使用快速且粗糙的 Python 脚本或小型应用程序来自动化一些用 Bash 等语言不易编码的任务。如果 Python 脚本是用 Python 3.x 编写的,而你机器上仅安装了 Python 2.7 或根本没有安装任何版本,那么最简单的解决方法就是在容器中执行该脚本。假设有一个简单的例子,Python 脚本用于计算给定文件中的行数、单词数和字母数,并将结果输出到控制台:
- 仍然在
simple-task
文件夹中,添加一个stats.py
文件,并添加以下内容:
图 8.1 – 用于计算示例文本统计数据的 Python 脚本
-
保存文件后,你可以使用以下命令运行它:
$ docker container run --rm -it \ -v $(pwd):/usr/src/app \ -w /usr/src/app \ python:3-alpine python stats.py sample.txt
-
请注意,在此示例中,我们正在重用之前 运行 Perl 脚本 部分中的
sample.txt
文件。在我的例子中,输出如下:Lines: 5Words: 13Letters: 121
这种方法的优点是,之前的 Perl 脚本和最后的 Python 脚本现在可以在任何安装了操作系统的计算机上运行,只要该机器是 Docker 主机,并且能够运行容器。
接下来,我们将学习如何限制系统上运行的容器可以消耗的资源数量。
限制容器的资源使用
容器的一个重要特性,除了封装应用程序进程外,还包括限制单个容器最多可以消耗的资源量。这包括 CPU 和内存消耗。让我们来看看如何限制内存(RAM)使用量:
$ docker container run --rm -it \ --name stress-test \
--memory 512M \
ubuntu:22.04 /bin/bash
进入容器后,安装 stress
工具,我们将用它来模拟内存压力:
/# apt-get update && apt-get install -y stress
打开另一个终端窗口,执行 docker stats
命令,观察所有正在运行的 Docker 容器的资源消耗。你应该会看到如下内容:
图 8.2 – 显示资源受限容器的 Docker stats
查看 MEM USAGE
和 LIMIT
。目前,容器仅使用了 36.57MiB
的内存,并且限制为 512MiB
。后者对应我们为该容器配置的内存限制。现在,让我们使用 stress
工具来模拟三个工人,它们将使用 malloc()
函数按 256MiB
的块分配内存。在容器内运行以下命令:
/# stress -m 3
上述命令通过创建三个子进程来给系统内存施加压力,这些进程会使用malloc()
分配内存并持续触碰内存,直到系统内存耗尽。在运行 docker stats
的终端中,观察 MEM USAGE
的值如何接近但从不超过 LIMIT
。这正是我们期望从 Docker 中看到的行为。Docker 使用 Linux cgroups 来强制执行这些限制。
什么是 cgroups?
Linux cgroups,即控制组,是一种内核级特性,允许将进程组织成层次结构的组,并在这些组之间分配、限制和监控系统资源,如 CPU、内存、磁盘 I/O 和网络。Cgroups 提供了一种管理和限制进程资源使用的方法,确保资源公平分配,并防止单个进程独占系统资源。
我们也可以类似地使用 --cpu
选项限制容器可以消耗的 CPU 数量。
通过此操作,工程师可以避免在繁忙的 Docker 主机上出现“噪声邻居”问题,在这种问题中,单个容器通过消耗过多的资源而导致所有其他容器处于资源匮乏状态。
避免以 root 身份运行容器
大多数在容器内运行的应用程序或服务不需要 root
权限。在这种情况下,为了提高安全性,运行这些进程时使用最小必要权限是非常有帮助的。这些应用程序不应该以 root
身份运行,也不应假设它们具有 root
级别的权限。
再次通过一个示例来说明我们的意思。假设我们有一个包含机密内容的文件。我们希望使用 chmod
工具在基于 Unix 的系统上保护该文件,使得只有具有 root
权限的用户可以访问它。假设我以 demo
用户身份登录开发主机,因此我的提示符是 demo@dev $
。我可以使用 sudo su
来模拟超级用户身份。不过,我需要输入超级用户密码:
demo@dev $ sudo su Password: <root password>
root@dev $
现在,作为 root
用户,我可以创建名为 top-secret.txt
的文件并保护它:
root@dev $ echo "You should not see this." > top-secret.txtroot@dev $ chmod 600 ./top-secret.txt
root@dev $ exit
demo@dev $
如果我以 demo
用户身份尝试访问该文件,将发生以下情况:
cat: ./top-secret.txt: Permission denied
我收到一条 Permission denied
消息,这正是我们想要的。除了 root
用户外,没有其他用户可以访问此文件。现在,让我们构建一个包含此加密文件的 Docker 镜像,当从中创建容器时,尝试输出 secrets
文件的内容。Dockerfile 可能如下所示:
FROM ubuntu:22.04COPY ./top-secret.txt /secrets/
# simulate use of restricted file
CMD cat /secrets/top-secret.txt
我们可以使用以下命令(作为 root
用户!)从该 Dockerfile 构建镜像:
demo@dev $ sudo suPassword: <root password>
root@dev $ docker image build -t demo-image .
root@dev $ exit
demo@dev $
然后,通过运行上一步骤构建的镜像,我们得到以下结果:
demo@dev $ docker container run demo-image
上述命令将生成以下输出:
You should not see this.
好的,尽管我在主机上模拟了 demo
用户并且在该用户帐户下运行容器,但容器内运行的应用程序自动以 root
身份运行,因此具有对受保护资源的完全访问权限。这很糟糕,所以让我们解决这个问题!我们不使用默认设置,而是定义容器内的一个明确用户。修改后的 Dockerfile 如下所示:
FROM ubuntu:22.04RUN groupadd -g 3000 demo-group |
&& useradd -r -u 4000 -g demo-group demo-user
USER demo-user
COPY ./top-secret.txt /secrets/
# simulate use of restricted file
CMD cat /secrets/top-secret.txt
我们使用 groupadd
工具定义一个新的组 demo-group
,组 ID 为 3000
。然后,使用 useradd
工具将一个新的用户 demo-user
添加到该组中。该用户在容器内的 ID 为 4000
。最后,通过 USER demo-user
语句,我们声明所有后续操作都应该以 demo-user
身份执行。
重新构建镜像——再次以 root
身份——然后尝试从中运行容器:
demo@dev $ sudo suPassword: <root password>
root@dev $ docker image build -t demo-image .
root@dev $ exit
demo@dev $ docker container run demo-image \
cat: /secrets/top-secret.txt:
Permission denied
正如你在最后一行看到的,容器内运行的应用程序具有受限权限,无法访问需要 root 级别权限的资源。顺便问一下,如果我以 root
身份运行容器,你觉得会发生什么?试试看!
在接下来的部分,我们将展示如何从容器内部自动化 Docker 操作。
在容器内运行 Docker
有时,我们可能希望运行一个托管应用程序的容器,该应用程序自动化某些 Docker 任务。我们如何实现呢?Docker 引擎和 Docker CLI 安装在宿主机上,但应用程序运行在容器内。其实,Docker 一开始就提供了从宿主机将 Linux 套接字绑定挂载到容器中的方法。在 Linux 中,套接字是进程间非常高效的数据通信端点,通常在同一宿主机上运行的进程间进行通信。Docker CLI 使用套接字与 Docker 引擎进行通信,通常称为 Docker 套接字。如果我们能让容器内的应用程序访问 Docker 套接字,那么我们可以在这个容器内安装 Docker CLI,然后就可以使用这个本地安装的 Docker CLI 来自动化容器特定的任务。
重要提示
在这里,我们不是在讨论在容器内运行 Docker 引擎,而仅仅是运行 Docker CLI,并将 Docker 套接字从宿主机绑定挂载到容器中,以便 CLI 可以与宿主机上运行的 Docker 引擎进行通信。这是一个重要的区别。
在容器内运行 Docker 引擎通常不建议使用,原因有很多,包括安全性、稳定性和潜在的性能问题。这种做法通常称为 Docker-in-Docker 或 DinD。主要的关注点如下:
-
安全性:在容器内运行 Docker 引擎需要提升的权限,如在特权模式下运行容器或挂载 Docker 套接字。这可能会使宿主系统面临潜在的安全风险,因为被攻破的容器可能会控制宿主机的 Docker 守护进程,并提升权限,从而影响其他容器和宿主机本身。
-
稳定性:容器设计上是为了实现隔离、轻量和短暂的运行。将 Docker 引擎运行在容器内可能会产生复杂的依赖关系,增加冲突或失败的可能性,尤其是在管理宿主机和嵌套容器环境之间的存储、网络和进程命名空间时。
-
性能:在容器内运行 Docker 引擎可能会引入性能开销,因为它增加了另一层虚拟化,特别是在存储和网络方面。这可能导致延迟增加和吞吐量下降,特别是在管理大量容器或处理高性能应用程序时。
-
资源管理:Docker-in-Docker 可能会使有效管理和分配资源变得具有挑战性,因为嵌套容器可能无法继承其父容器的资源限制和约束,这可能导致资源争用或宿主机上资源过度分配。
为了说明这一概念,让我们看一个使用前述技术的示例。我们将使用上一章中构建的library
组件的副本(见第七章)来进行演示:
-
导航到章节文件夹:
$ cd ~/The-Ultimate-Docker-Container-Book/ch08
-
将
library
组件从ch07
目录复制到此文件夹:$ cp -r ../ch07/library .
-
在 VS Code 中打开该组件:
$ code library
-
向项目根目录添加一个名为
pipeline.sh
的新文件,并将以下代码添加到其中,该代码实现了 Docker 镜像的构建、测试和推送自动化:
图 8.3 – 构建、测试并推送 Java 应用的脚本
请注意,我们使用了四个环境变量:$HUB_USER
和$HUB_PWD
是 Docker Hub 的凭证,$REPOSITORY
和$TAG
是我们希望构建的 Docker 镜像的名称和标签。最终,我们将需要在docker container run
命令中传递这些环境变量的值,以便它们可以在容器内的任何进程中使用。
-
保存文件并使其可执行:
$ chmod +x ./pipeline.sh
我们希望在构建器容器中运行pipeline.sh
脚本。由于该脚本使用 Docker CLI,因此我们的构建器容器必须安装 Docker CLI,并且为了访问 Docker 引擎,构建器容器必须绑定挂载 Docker 套接字。
让我们开始为构建器容器创建 Docker 镜像:
- 向项目根目录添加一个名为
Dockerfile.builder
的文件,并将以下内容添加到其中:
图 8.4 – 构建器的 Dockerfile
请注意从第 3 行开始的长RUN
命令。此命令用于在容器中安装 Docker。有关此命令的更多详细信息,您可以参考 Docker 在线文档:docs.docker.com/engine/install/ubuntu/
。
-
使用这个 Dockerfile 构建 Docker 镜像非常简单:
$ docker image build -f Dockerfile.builder -t builder .
-
我们现在准备好尝试使用真实 Java 应用程序的
builder
命令了;例如,我们可以使用在ch08/library
文件夹中定义的示例应用程序。确保用您自己的 Docker Hub 凭证替换<user>
和<password>
:
图 8.5 – 构建器的 Docker 运行命令
请注意,在前面的命令中,我们通过-v /var/run/docker.sock:/var/run/docker.sock
将 Docker 套接字挂载到容器中。如果一切顺利,您应该已经为示例应用程序构建了容器镜像,测试已经运行,并且镜像已经推送到 Docker Hub。这只是许多非常有用的使用场景之一,在这些场景中,将 Docker 套接字绑定挂载非常重要。
特别提示给那些想在 Windows 计算机上尝试 Windows 容器的用户:在 Windows 上的 Docker Desktop 中,你可以通过绑定挂载 Docker 的 命名管道 来创建一个类似的环境,而不是套接字。在 Windows 上,命名管道大致相当于 Unix 系统上的套接字。假设你正在使用 PowerShell 终端,当运行托管 Jenkins 的 Windows 容器时,绑定挂载命名管道的命令如下所示:
PS> docker container run ` --name jenkins `
-p 8080:8080 `
-v \\.\pipe\docker_engine:\\.\pipe\docker_engine `
friism/jenkins
请注意特殊语法\\.\pipe\docker_engine
,用于访问 Docker 的命名管道。
在本节中,我们展示了如何通过将 Docker 套接字挂载到相应的容器中,从容器内运行 Docker。
接下来,我们将重新探讨如何使你的 Docker 构建尽可能快速,以减少开发周期中的摩擦。
优化构建过程
Docker 构建过程可以且应该得到优化。这将减少软件开发生命周期中的很多摩擦。
许多 Docker 初学者在编写第一个 Dockerfile 时会犯以下错误:
图 8.6 – 未优化的 Node.js 应用程序 Dockerfile
你能发现这个典型的 Node.js 应用程序 Dockerfile 中的薄弱点吗?在第四章《创建和管理容器镜像》中,我们了解到,一个镜像由一系列的层组成。Dockerfile 中的每一行(逻辑行)都会创建一个层,CMD
和/或 ENTRYPOINT
关键字的行除外。我们还了解到,Docker 构建器会尽力通过缓存层并在后续构建中重用未发生变化的层来优化构建过程。但缓存机制仅使用发生变化之前的缓存层,所有之后的层都需要重新构建。也就是说,Dockerfile 的上述结构会使得镜像层缓存失效——或者我们常听到的说法是,破坏了镜像层缓存!
为什么?嗯,根据经验,你肯定知道 npm install
命令在一个典型的 Node.js 应用程序中,尤其是有很多外部依赖的情况下,可能是一个非常耗时的操作。执行这个命令的时间可能从几秒钟到好几分钟不等。也就是说,每当其中一个源文件发生变化,而我们知道在开发过程中这种情况经常发生,Dockerfile 中的第 3 行会导致相应的镜像层发生变化。因此,Docker 构建器不能从缓存中重用该层,也不能重用由 RUN npm install
创建的后续层。代码的任何细微变化都会导致 npm install
被完全重新执行。这是可以避免的。包含外部依赖列表的 package.json
文件很少发生变化。根据这些信息,让我们来修复 Dockerfile:
图 8.7 – 优化后的 Node.js 应用程序 Dockerfile
这次,在第 3 行,我们只将 package.json
文件复制到容器中,该文件很少发生变化。因此,随后的 npm install
命令也很少需要执行。第 5 行的 COPY
命令是一个非常快速的操作,因此在代码发生变化后,只需要重新构建这一层,重建镜像的时间缩短到几乎为零。
同样的原则适用于大多数语言或框架,如 Python、.NET 或 Java。避免破坏你的镜像层缓存!
扫描漏洞和机密信息
究竟什么是漏洞,或者更准确地说,常见漏洞与暴露(CVE)是什么?
一个公开已知的信息安全问题的数据库称为 常见漏洞与暴露。每个漏洞在数据库中的条目都有一个唯一的标识符。这个列表由专家不断审查和更新,任何新的漏洞或暴露都会在发现后立即加入。
现在,我们可以使用专业软件(如 Snyk)扫描 Docker 镜像的各个层,以查找已知存在 CVE 的软件库。如果我们发现镜像存在缺陷,我们应该通过切换到更新版本的有问题的库来修复问题,然后重新构建镜像。
但我们的工作还没有完成。安全专家经常会发现新的 CVE,正如前面提到的那样。因此,之前安全的软件库可能因为新公开的 CVE 而突然变得脆弱。
这意味着我们必须确保定期检查所有活跃的 Docker 镜像,通知我们的开发人员和安全专家这个问题,并采取其他措施确保问题尽快解决。
有几种方法可以扫描 Docker 镜像中的漏洞和机密信息:
-
使用像 Clair、Anchore 或 Trivy 这样的漏洞扫描工具。这些工具可以扫描 Docker 镜像,并将其与已知漏洞的数据库进行对比,以识别潜在的安全风险。
-
使用如 Aquasec 或 Sysdig 等工具扫描镜像中的机密信息。这些工具可以检测并提醒敏感信息,如私钥、密码以及可能无意中提交到镜像中的其他敏感数据。
-
结合使用两种工具,例如 Docker Bench for Security,它检查部署 Docker 容器到生产环境中时的常见最佳实践。
-
使用如 OpenSCAP 等工具,它可以执行漏洞扫描、安全配置评估以及对 Docker 镜像的合规性检查。
需要注意的是,保持镜像更新并只使用官方和可信的镜像始终是一种良好的实践。
在接下来的章节中,我们将研究如何发现 Docker 镜像中的漏洞。
使用 Snyk 扫描 Docker 镜像
Snyk 是一个安全平台,可用于扫描 Docker 镜像中的漏洞。以下是使用 Snyk 扫描 Docker 镜像漏洞的示例:
-
首先,我们需要在机器上安装 Snyk CLI。我们可以通过运行以下命令来完成:
$ npm install -g snyk
-
一旦安装了 Snyk,我们可以通过运行以下命令并按照提示完成身份验证:
$ snyk auth
-
接下来,我们可以运行以下命令来扫描特定的 Docker 镜像漏洞:
$ snyk test --docker <image-name>
上述命令将对指定的 Docker 镜像进行漏洞扫描,并在控制台中打印结果。结果将显示发现的漏洞数量、每个漏洞的严重性,以及受影响的包和版本。
-
我们还可以使用
--file
标志来扫描一个 Dockerfile,而不是扫描已经构建好的镜像:$ snyk test --file=path/to/Dockerfile
-
此外,如果我们是多个组织的成员,我们还可以使用
--org
标志来指定一个组织:$ snyk test --docker <image-name> --org=my-org
-
最后,我们可以使用
--fix
标志来自动修复扫描中发现的漏洞,方法是运行以下命令:$ snyk protect --docker <image-name>
请注意,此功能仅适用于通过 Dockerfile 构建的镜像,它将更新 Dockerfile 中的新包版本,并且你需要重新构建镜像才能利用此修复。
注意
Snyk 的免费计划有限制扫描次数,并且不包括Protect功能。你需要升级到付费计划才能访问此功能。
使用 docker scan 扫描 Docker 镜像中的漏洞
在本节中,我们将再次使用 Snyk 扫描 Docker 镜像中的漏洞。Snyk 应该已经随 Docker Desktop 的安装包一同安装:
-
使用以下命令进行检查:
$ docker scan --version
输出结果应类似于此:
Version: v0.22.0Git commit: af9ca12
Provider: Snyk (1.1054.0)
-
让我们尝试扫描作者 Docker Hub 账户中的示例
whoami
应用。首先,确保你在本地缓存中有whoami
镜像:$ docker image pull gnschenker/whoami:1.0
-
扫描镜像中的漏洞:
$ docker scan gnschenker/whoami:1.0
-
你将被询问以下内容:
Docker Scan relies upon access to Snyk, a third party provider, do you consent to proceed using Snyk? (y/N)
请回答y
。
上述扫描的结果在我的电脑上看起来是这样的:
图 8.8 – 扫描 gnschenker/whoami:1.0 Docker 镜像
如你所见,在该版本的镜像中发现了三处漏洞:其中一个为中等级别,一个为高级别,一个为严重级别。显然,我们应尽快解决严重漏洞。让我们现在就来做:
-
首先,我们复制原始的
whoami
项目,包括用于构建该镜像的 Dockerfile。你可以在~/``The-Ultimate-Docker-Container-Book/sample-solutions/``ch14
文件夹中找到该副本。 -
打开 Dockerfile 并检查它。我们为.NET SDK 和运行时都使用了版本
6.0-alpine
。让我们看看 Microsoft 是否已经更新了这个版本中的漏洞。 -
导航到
…/``ch08/whoami
文件夹。 -
使用此命令构建一个新的 Docker 镜像版本:
$ docker image build -t gnschenker/whoami:1.0.1 .
请注意,你可能需要将 gnschenker
替换为你自己的 Docker 账户名。
-
扫描新镜像:
$ docker scan gnschenker/whoami:1.0.1
这次,输出应该如下所示:
图 8.9 – 扫描重建的 whoami Docker 镜像
如你所见,这次镜像不含任何漏洞。我们现在应该指示 DevOps 使用这个新版本的镜像。我们可以在生产环境中进行滚动更新,应该不会有问题,因为应用本身并未更改。
在接下来的章节中,我们将学习如何在容器中运行一个完整的开发环境。
在容器中运行你的开发环境
假设你只有一个安装了 Docker Desktop 的工作站,但无法在该工作站上添加或更改其他任何内容。现在你想进行一些概念验证并使用 Java 编写一些示例应用程序。不幸的是,Java 和 SpringBoot 并未安装在你的电脑上。你该怎么办?如果你能在容器中运行一个完整的开发环境,包括代码编辑器和调试器,会怎么样?同时,你还能将代码文件保留在主机上呢?
容器真是太棒了,天才工程师们为这种问题提供了解决方案。
注
微软和社区一直在不断更新 VS Code 和插件。因此,你的 VS Code 版本可能比本书编写时使用的版本更新。因此,可能会有稍微不同的体验。有关如何使用 Dev 容器的更多细节,请参考官方文档:code.visualstudio.com/docs/devcontainers/containers
。
我们将使用 Visual Studio Code,这个我们最喜欢的代码编辑器,来演示如何在容器中运行一个完整的 Java 开发环境:
- 但首先,我们需要安装必要的 VS Code 扩展。打开 VS Code 并安装名为 Remote Development 的扩展:
图 8.10 – 将 Remote Development 扩展添加到 VS Code
- 然后,点击 Visual Studio Code 窗口左下角的绿色快速操作状态栏项。在弹出的对话框中,选择 Remote-Containers | 在容器中打开文件夹...:
图 8.11 – 在容器中打开文件夹
-
选择你想在容器中使用的项目文件夹。在我们的例子中,我们选择了
~/``The-Ultimate-Docker-Container-Book/ch08/library
文件夹。 -
会弹出一个对话框,询问你如何创建开发容器。在列表中,选择 From ‘Dockerfile’:
图 8.12 – 选择创建开发容器的方法
- 当要求你添加其他要安装的功能时,只需点击 OK 继续。此时,我们不需要任何特别的功能。
VS Code 现在将开始准备环境,首次启动可能需要几分钟时间。
-
一旦环境准备好,你应该注意到左下角的提示符已经变成如下所示:
Dev Container: Existing Dockerfile @ <folder-path>
这表明,VS Code 确实已根据 library
文件夹中的 Dockerfile 运行了一个容器,并允许你在其中工作。
- 由于 VS Code 识别这是一个 Java 项目,你将被要求安装 Java 扩展包。点击
dev
容器,注意,只有 UI 仍然在你的笔记本电脑上运行,因此扩展包将被安装在容器内部的引擎中。你会在打开 EXTENSIONS 面板时注意到,在 DEV CONTAINER 下列出了远程扩展。在我们的例子中,安装 Java 扩展包后,我们现在安装了以下八个远程扩展:
图 8.13 – 在开发容器中安装的远程扩展
-
在 VS Code 内打开终端,按 Shift + Ctrl + ‘,并注意到提示符显示我们正处于
dev
容器内,而不是直接在 Docker 主机上运行:root@c96b82891be7:/workspaces/.../ch08/library#
请注意,为了提高可读性,我们已简化了前面的提示。
- 现在,尝试通过在
LibraryApplication
类中找到main
方法并点击方法上方的 Run 链接来运行 Java 应用程序。应用程序应该像往常一样启动,但请注意,我们的上下文是在开发容器内,而不是直接在工作机器上。
或者,我们也可以通过命令行使用以下命令来启动应用程序:
$ ./mvnw spring-boot:run
- 现在,向
controllers
文件夹添加一个名为DefaultController.java
的文件,并添加以下内容:
图 8.14 – 在开发容器内添加默认控制器 工作中
-
重启应用程序,并在浏览器中打开
http://localhost:8080
。应该会显示Library component
消息,符合预期。 -
实验完成后,点击 VS Code 左下角的绿色区域,并从弹出菜单中选择 Open folder locally,退出开发容器并将项目本地打开。
-
注意到项目中添加了一个新的文件夹
.devcontainer
,其中包含一个devcontainer.json
文件。该文件包含从此项目运行开发容器所需的配置。请阅读 VS Code 的文档,了解此文件为你提供的功能。
这些是一些对专业人士有用的技巧和窍门,适用于容器的日常使用。还有更多内容,可以去 Google 查找,值得一试。
总结
在本章中,我们介绍了在容器化复杂的分布式应用程序时或使用 Docker 自动化复杂任务时有用的各种技巧、窍门和概念。我们还学习了如何利用容器在其中运行完整的开发环境。
在下一章中,我们将介绍分布式应用架构的概念,并讨论成功运行分布式应用所需的各种模式和最佳实践。
问题
这里有一些你应该尝试回答的问题,以评估你的进展:
-
列出你希望在容器内运行完整开发环境的原因。
-
为什么应该避免以
root
用户身份在容器内运行应用程序? -
为什么你会将 Docker 套接字绑定到容器中?
-
在清理 Docker 资源以腾出空间时,为什么需要特别小心处理卷?
-
为什么你会希望在 Docker 容器内运行某些管理员任务,而不是在主机机器上本地运行?
答案
以下是本章问题的示例答案:
-
你可能正在使用资源或能力有限的工作站,或者你的工作站可能被公司锁定,禁止安装任何未经官方批准的软件。有时,你可能需要使用尚未被公司批准的语言或框架来做概念验证或实验(但如果概念验证成功,未来可能会被批准)。
-
将 Docker 套接字绑定到容器内是推荐的方法,当容器化应用程序需要自动化某些与容器相关的任务时。这可以是像 Jenkins 这样的自动化服务器,用于构建、测试和部署 Docker 镜像。
-
大多数业务应用程序不需要
root
级别的权限就能完成工作。因此,从安全的角度来看,强烈建议以最少必要的访问权限运行此类应用程序。任何不必要的提升权限可能会被黑客在恶意攻击中利用。通过以非root
用户身份运行应用程序,你可以使潜在黑客更难以侵入你的系统。 -
卷包含数据,而数据的生命周期通常需要超出容器或应用程序的生命周期。数据通常是关键任务,需要安全存储数天、数月甚至数年。当你删除一个卷时,你会不可逆地删除与之关联的数据。因此,在删除卷时,请确保你知道自己在做什么。
-
你可能希望在 Docker 容器内运行某些管理员任务,而不是在主机机器上本地运行,有几个原因:
-
隔离性:容器提供了与宿主机的隔离,这样在容器中运行管理任务可以帮助防止与宿主机上其他进程或依赖项的冲突。
-
可移植性:容器设计为轻量级和可移植,允许在不同环境中轻松部署管理任务。这对于需要在多个环境或多台机器上运行的任务尤其有用。
-
一致性:容器为运行管理任务提供了一个一致的环境,无论宿主机的配置如何。这有助于确保任务以可预测和可重复的方式运行,从而减少错误并提高效率。
-
版本管理:容器允许对管理任务进行轻松的版本控制,这使得任务可以回滚或向前推进。这对于测试、故障排除和生产环境非常有用。
-
安全性:在容器中运行管理任务有助于通过将任务与宿主机隔离,并且更容易限制任务的权限和访问,从而提高安全性。
-
可扩展性:容器可以轻松地进行水平扩展和缩减,允许你增加或减少管理任务所需的资源。
-
请注意,这并不是一个详尽无遗的列表,不同的使用场景可能需要不同的方法。在容器中运行管理任务与直接在宿主机上运行之间,权衡其利弊是非常重要的,你需要选择最适合特定使用场景的方法。
第三部分:编排基础
到了第三部分结束时,你将熟悉 Docker 化的分布式应用程序和容器编排器的概念,并能够使用 Docker Swarm 部署和运行你的应用程序。
-
第九章,学习分布式应用架构
-
第十章,使用单主机网络
-
第十一章,使用 Docker Compose 管理容器
-
第十二章,传输日志和监控容器
-
第十三章,介绍容器编排
-
第十四章,介绍 Docker Swarm
-
第十五章,在 Docker Swarm 上部署和运行分布式应用程序
第九章:9
学习分布式应用架构
本章介绍了分布式应用架构的概念,并讨论了成功运行分布式应用所需的各种模式和最佳实践。它还将讨论在生产环境中运行这种应用所需要满足的额外要求。你可能会想,这和 Docker 容器有什么关系?你问得对,乍一看,这两者似乎没有什么关系。但正如你将很快看到的,当你引入托管应用或应用服务的容器时,你的应用很快就会由多个容器组成,这些容器将运行在计算机集群或虚拟机的不同节点上;于是,你就开始处理一个分布式应用了。我们认为,了解分布式应用带来的复杂性并帮助你避免最常见的陷阱是很有意义的。
下面是我们将要讨论的主题列表:
-
什么是分布式应用架构?
-
模式与最佳实践
-
在生产环境中运行
阅读本章后,你将能够完成以下任务:
-
草拟一个分布式应用的高层架构图,并指出关键的设计模式
-
识别设计不当的分布式应用可能带来的陷阱
-
列举常见的分布式系统问题处理模式
-
至少列举出四种需要在生产就绪的分布式应用中实现的模式
让我们开始吧!
什么是分布式应用架构?
在本节中,我们将解释当我们谈论分布式应用架构时的含义。首先,我们需要确保我们使用的所有单词或缩写都有明确的含义,并且大家都在讲相同的语言。
定义术语
在本章及后续章节中,我们将讨论一些可能对每个人来说都不太熟悉的概念。为了确保我们大家都在讲相同的语言,下面我们将简要介绍并描述这些概念或词汇中最重要的内容:
关键词 | 描述 |
---|---|
虚拟机 | 虚拟机(VM)是一个在主机计算机上运行的物理计算机的软件模拟。它提供一个独立的操作系统和资源,允许多个操作系统在单一物理机器上运行。 |
集群 | 集群是一个由多个相互连接的服务器组成的群体,它们作为一个整体协作工作,为应用提供高可用性、可扩展性和更高的性能。集群中的节点通过网络连接,共享资源,从而提供统一的、高可用的解决方案。 |
节点 | 集群节点是集群计算系统中的一台单独服务器。它提供计算资源,并与其他节点一起协作执行任务,作为一个统一的系统,为应用提供高可用性和可扩展性。 |
网络 | 网络是一组互联的设备,可以交换数据和信息。网络可以用于连接计算机、服务器、移动设备和其他类型的设备,并允许它们相互通信并共享资源,如打印机和存储设备。在我们这个例子中,更具体地说,这些是集群中各个节点之间以及这些节点上运行的程序之间的物理和软件定义的通信路径。 |
端口 | 端口是网络连接设备中的通信端点,如计算机或服务器。它允许设备通过特定的网络协议(如 TCP 或 UDP)接收和发送数据到网络中的其他设备。每个端口都有一个唯一的编号,用于标识它,不同的服务和应用程序使用特定的端口进行通信。 |
服务 | 不幸的是,这是一个非常含糊的术语,其真正含义取决于使用的上下文。如果我们在应用程序的上下文中使用“服务”一词,比如应用服务,它通常意味着这是一个实现有限功能集的软件,其他应用程序部分将使用这些功能。随着本书的进展,其他类型的服务也会被讨论,这些服务具有稍微不同的定义。 |
简单来说,分布式应用架构是单体应用架构的对立面,但首先看一下单体架构也并不无道理。传统上,大多数业务应用程序都是以一种方式编写的,结果可以被看作是一个运行在数据中心某个命名服务器上的单一、紧密耦合的程序。它的所有代码被编译成一个单一的二进制文件,或者是几个需要共同部署的紧密耦合的二进制文件。应用程序运行所在的服务器——或者更广泛地说——主机的名称或静态 IP 地址在这个上下文中也很重要。让我们来看一下下面的图,它更精确地说明了这种类型的应用架构:
图 9.1 – 单体应用架构
在前面的图示中,我们可以看到一个名为 blue-box-12a
的服务器,IP 地址为 172.52.13.44
,它运行着一个名为 pet-shop
的应用程序,这是一个由主模块和几个紧密耦合的库组成的单体架构。
现在,让我们看看以下图示:
图 9.2 – 分布式应用架构
在这里,突然间,我们不再只有一个命名服务器;相反,我们有许多服务器,它们没有人类友好的名称,而是一些独特的 ID,如 pet-api
、pet-web
和 pet-inventory
。此外,每个服务在这个服务器或主机的集群中运行多个实例。
你可能会想,为什么我们在一本关于 Docker 容器的书中讨论这些内容,你问得很对。虽然我们将要探讨的所有话题同样适用于容器尚不存在的世界,但重要的是要意识到,容器和容器编排引擎能够以更高效和更直接的方式解决所有这些问题。在容器化的世界中,许多曾经在分布式应用架构中非常难以解决的问题变得相当简单。
模式和最佳实践
分布式应用架构有许多显著的优点,但与单体应用架构相比,它也有一个非常重要的缺点——前者远比后者复杂。为了驯服这种复杂性,业界提出了一些重要的最佳实践和模式。在接下来的章节中,我们将更详细地探讨其中一些最重要的内容。
松散耦合的组件
处理复杂主题的最佳方法一直是将其拆分成更容易管理的小问题。例如,一次性建造一座房子会非常复杂。将房子从简单的部件构建出来,然后将这些部件组合成最终结果会容易得多。
同样的原则也适用于软件开发。如果我们将一个非常复杂的应用程序拆分成多个较小的组件,这些组件可以互相协作,共同构成整个应用程序,那么开发这些组件会变得容易得多。如果这些组件之间是松散耦合的,那么开发它们将更加容易。意味着组件 A 对组件 B 和 C 的内部工作没有任何假设,它只关心如何通过一个定义良好的接口与这两个组件进行通信。
如果每个组件都有一个定义良好且简单的公共接口,通过该接口与系统中的其他组件以及外部世界进行通信,那么这将使我们能够单独开发每个组件,而不依赖于其他组件。在开发过程中,系统中的其他组件可以通过存根或模拟对象轻松替换,从而使我们能够测试我们的组件。
有状态与无状态
每个有意义的业务应用程序都会创建、修改或使用数据。在 IT 中,数据的同义词是状态。一个创建或修改持久数据的应用服务称为有状态组件。典型的有状态组件包括数据库服务或创建文件的服务。另一方面,不创建或修改持久数据的应用组件称为无状态组件。
在分布式应用架构中,无状态组件比有状态组件要容易处理得多。无状态组件可以轻松地进行横向扩展和收缩。此外,它们可以在集群中的完全不同节点上迅速且无痛地被销毁并重新启动——这一切都因为它们没有与之相关的持久数据。
鉴于此,设计一个大多数应用服务是无状态的系统是非常有帮助的。最好将所有有状态的组件推到应用程序的边界,并限制其使用数量。管理有状态的组件很困难。
服务发现
当我们构建由多个独立组件或服务组成的应用程序,并且这些组件相互通信时,我们需要一种机制来允许各个组件在集群中找到彼此。相互找到通常意味着你需要知道目标组件运行在哪个节点上,以及它在哪个端口上监听通信。通常,节点通过IP 地址和端口来标识,端口只是一个在定义范围内的数字。
从技术上讲,我们可以告诉服务 A,它希望与目标服务 B进行通信,目标的 IP 地址和端口是什么。例如,这可以通过配置文件中的一项条目来实现:
图 9.3 – 组件是硬接线的
虽然在一个运行在一个或仅几个知名且精心管理的服务器上的单体应用程序中,这种方式可能非常有效,但在分布式应用架构中,它就会崩溃。首先,在这种情况下,我们有很多组件,手动跟踪它们变成了一场噩梦。这是不可扩展的。此外,通常,服务 A 应该或者永远不应该知道其他组件运行在哪个集群节点上。它们的位置甚至可能不稳定,因为组件 B 可能会由于与应用无关的各种原因,从节点X迁移到另一个节点Y。因此,我们需要另一种方式,允许服务 A 定位服务 B,或者任何其他服务。通常,会使用一个外部权威来感知系统在任何给定时刻的拓扑结构。
这个外部权威或服务知道当前属于集群的所有节点及其 IP 地址;它知道所有正在运行的服务以及它们的运行位置。通常,这种服务被称为DNS 服务,其中 DNS 代表域名系统。正如我们将看到的,Docker 在其底层引擎中实现了一个 DNS 服务。Kubernetes——我们将在第十三章中讨论的第一大容器编排系统——也使用 DNS 服务来促进集群中组件之间的通信:
图 9.4 – 组件咨询外部定位服务
在上面的图中,我们可以看到服务 A 如何想要与服务 B 进行通信,但不能直接进行。首先,它必须查询外部权限,即注册服务(这里称为DNS 服务),以获取服务 B 的位置信息。注册服务会返回请求的信息,并提供服务 A 可以用来访问服务 B 的 IP 地址和端口号。服务 A 随后使用这些信息并与服务 B 建立通信。当然,这只是一个低层次的简单示意图,但它有助于我们理解服务发现的架构模式。
路由
路由是将数据包从源组件发送到目标组件的机制。路由分为不同的类型。所谓的 OSI 模型(更多信息请参考本章最后的进一步阅读部分)用于区分不同类型的路由。在容器和容器编排的上下文中,第 2 层、第 3 层、第 4 层和第 7 层的路由是相关的。我们将在后续章节中更详细地探讨路由。现在,我们只需知道,第 2 层路由是最低级别的路由,它将一个 MAC 地址连接到另一个 MAC 地址,而第 7 层路由,也称为应用层路由,是最高级别的路由。例如,应用层路由用于将带有目标标识符的请求——也就是像acme.com/pets
这样的 URL——路由到系统中的适当目标组件。
负载均衡
负载均衡在服务 A需要与服务 B进行通信时使用,例如在请求-响应模式中,但后者运行在多个实例中,如下图所示:
图 9.5 – 服务 A 的请求正在被负载均衡到服务 B
如果我们的系统中有多个服务实例(例如,服务 B)在运行,我们希望确保这些实例能够平等地分配工作负载。这个任务是一个通用任务,这意味着我们不希望调用方去做负载均衡,而是希望有一个外部服务来拦截调用并决定将请求转发到哪个目标服务实例。这个外部服务称为负载均衡器。负载均衡器可以使用不同的算法来决定如何将传入的请求分配给目标服务实例。最常用的算法叫做轮询算法。这个算法会重复地分配请求,从实例 1 开始,然后是实例 2,一直到实例n。当最后一个实例被处理完后,负载均衡器会重新从实例 1 开始。
在前面的示例中,负载均衡器也有助于高可用性,因为来自服务 A 的请求会被转发到健康的服务 B 实例。负载均衡器还负责定期检查每个 B 实例的健康状况。
防御性编程
在为分布式应用开发服务时,重要的是要记住,服务不是独立的,它依赖于其他应用服务,甚至依赖于第三方提供的外部服务,如信用卡验证服务或股票信息服务,仅举两例。所有这些其他服务都是我们正在开发的服务之外的。我们无法控制它们的正确性或在任何给定时刻的可用性。因此,在编写代码时,我们需要始终假设最坏的情况,并期望最好的结果。假设最坏的情况意味着我们必须明确处理潜在的故障。
重试
当存在外部服务可能暂时不可用或响应不足的情况时,可以使用以下程序。当调用其他服务失败或超时时,调用代码应以一种结构化的方式进行,使得在短暂等待时间后重复同一调用。如果调用再次失败,等待时间应比上次稍长,再次尝试。调用应重复直到达到最大次数,每次增加等待时间。之后,服务应放弃并提供降级服务,具体可能意味着返回一些过时的缓存数据或根本不返回数据,具体取决于情况。
日志记录
对服务执行的重要操作应始终进行日志记录。日志信息需要进行分类,以便具有实际价值。常见的分类包括调试、信息、警告、错误和致命。日志信息应由一个中央日志聚合服务收集,而不是存储在集群的单个节点上。聚合日志易于解析和过滤相关信息。这些信息对于快速定位分布式系统中多部件运行时的故障或异常行为的根本原因至关重要。
错误处理
如前所述,分布式应用中的每个应用服务都依赖于其他服务。作为开发人员,我们应该始终预期最坏的情况,并做好适当的错误处理。最重要的最佳实践之一是尽早失败。编写服务时,要确保无法恢复的错误尽早被发现,并且一旦检测到此类错误,服务应立即失败。但不要忘记将有意义的信息记录到STDERR
或STDOUT
,以供开发人员或系统操作员在以后跟踪系统故障时使用。此外,还应向调用者返回一个有帮助的错误信息,尽可能准确地说明调用失败的原因。
一个快速失败的示例是始终检查调用者提供的输入值。值是否在预期范围内并且完整?如果不是,就不要尝试继续处理,而是立即终止操作。
冗余
一个关键任务系统必须始终可用,全天候、全年无休。停机是不可接受的,因为它可能会导致公司巨大的机会损失或声誉损害。在一个高度分布式的应用中,至少有一个组件发生故障的可能性是不可忽视的。我们可以说,问题不在于某个组件是否会发生故障,而是故障何时发生。
为了避免当系统中的某个组件发生故障时导致停机,每个系统部分都需要冗余。这不仅包括应用组件,还包括所有基础设施部分。这意味着,如果我们有一个支付服务作为应用的一部分,那么我们需要对该服务进行冗余部署。最简单的方式是在集群的不同节点上运行该服务的多个实例。同样的原则也适用于边缘路由器或负载均衡器。我们不能承受这些服务出现故障。因此,路由器或负载均衡器必须是冗余的。
健康检查
我们多次提到,在分布式应用架构中,由于其包含多个组件,单个组件发生故障的可能性非常高,而且这只是时间问题。因此,我们必须对系统中的每个组件进行冗余部署。负载均衡器会将流量分配到服务的各个实例上。
但现在,另一个问题出现了。负载均衡器或路由器如何知道某个服务实例是否可用?它可能已经崩溃,或者可能没有响应。为了解决这个问题,我们可以使用所谓的健康检查。负载均衡器,或者代表它的其他系统服务,会定期轮询所有服务实例并检查它们的健康状况。基本问题是,你还在吗?你健康吗? 每个问题的答案要么是是,要么是否,如果实例不再响应,则健康检查会超时。
如果组件的回答是否或发生超时,系统将终止对应实例并启动一个新的实例替代。如果所有这些都是自动化完成的,我们就可以说系统实现了自愈功能。负载均衡器不再周期性地轮询组件的状态,责任可以反过来,组件可以要求定期向负载均衡器发送存活信号。如果某个组件在预定的较长时间内未发送存活信号,则假定其处于不健康状态或已经死亡。
有些情况下,上述描述的方式中的任何一种都更为合适。
熔断器模式
断路器是一种机制,用于避免由于多个关键组件的级联故障而导致分布式应用程序崩溃。断路器帮助我们避免一个失败的组件通过多米诺效应拖垮其他依赖服务,就像电气系统中的断路器保护房屋免于由于故障的插入式电器而导致燃烧,中断电源线一样,分布式应用程序中的断路器在服务 A 到服务 B 的连接不响应或者故障时会中断连接。
这可以通过将受保护的服务调用封装在一个断路器对象中来实现。该对象会监控失败情况。一旦失败次数达到某个阈值,断路器将被触发。所有后续对断路器的调用都将返回错误,而根本不会进行受保护的调用:
图 9.6 – 断路器模式
在上图中,我们有一个断路器,在调用服务 B 时接收到第二个超时后被触发。
速率限制器
在断路器的背景下,速率限制器是一种控制系统或服务处理请求速率的技术。通过限制在特定时间窗口内允许的请求数量,速率限制器有助于防止过载,并确保服务的稳定性和可用性。这种机制在减少突发流量冲击的影响、保护后端系统免受压倒性影响以及避免分布式系统中级联故障方面非常有用。通过将速率限制与断路器结合使用,系统能够有效地保持最佳性能,并优雅地处理需求的突然增加。
防火墙
除此之外,在断路器的背景下,防火墙是一种用于隔离系统中的组件或资源的弹性模式,确保一个区域的故障不会对整个系统造成级联效应。通过将资源分区并将操作隔离到独立的单元中,防火墙有助于防止单点故障导致整个服务崩溃。这种机制有助于维持系统稳定性,提高容错能力,并确保关键操作即使在本地化故障时也能继续正常运行。与断路器结合使用时,防火墙有助于构建更加健壮和弹性的系统,能够处理故障并保持整体系统性能。
在生产中运行
要成功地在生产环境中运行分布式应用程序,我们需要考虑一些超出前述部分介绍的最佳实践和模式的特定领域。一个具体的领域就是自省和监控。让我们详细讨论最重要的方面。
日志记录
一旦分布式应用进入生产环境,就无法进行实时调试。但我们如何找出应用故障的根本原因呢?解决这个问题的方法是,应用在运行时会生成大量且有意义的日志信息。我们在前面的章节中简要讨论了这个话题,但由于其重要性,值得再次强调。开发人员需要在其应用服务中添加必要的代码,以便输出有用的信息,例如发生错误时或遇到潜在的异常或不希望出现的情况时。通常,这些信息会输出到STDOUT
和STDERR
,然后由系统守护进程收集,写入本地文件或转发到中心化日志聚合服务。
如果日志中有足够的信息,开发人员可以利用这些日志来追踪系统错误的根本原因。
在分布式应用架构中,由于其包含许多组件,日志记录的重要性比单体应用中更为突出。单个请求在应用各个组件中的执行路径可能非常复杂。同时,记住这些组件是分布在多个节点集群中的。因此,记录所有重要信息是合理的,并且需要在每个日志条目中添加一些内容,比如事件发生的确切时间、事件发生的组件以及运行该组件的节点,等等。此外,日志信息应该聚合到一个中心位置,以便开发人员和系统运维人员能够方便地进行分析。
跟踪
跟踪(Tracing)用于找出单个请求是如何在分布式应用中流转的,以及该请求在每个组件上花费了多少时间。如果收集了这些信息,它可以作为仪表盘的一个数据源,显示系统的行为和健康状态。
监控
运维工程师喜欢通过仪表盘查看系统的实时关键指标,这些指标可以让他们一眼看到应用的整体健康状态。这些指标可以是非功能性指标,如内存和 CPU 使用率、系统或应用组件的崩溃次数、节点的健康状态等,也可以是功能性、因此是应用特定的指标,如订单系统中的结账次数或库存服务中的缺货商品数量。
最常见的用于汇总仪表盘数据的基本数据来源是日志信息。这些数据可以是系统日志,主要用于非功能性指标,或是应用级日志,用于功能性指标。
应用更新
一家公司的竞争优势之一是能够迅速应对市场变化。部分原因在于能够快速调整应用程序,以满足新的和变化的需求或添加新功能。我们能够多快地更新应用程序,就越有优势。如今,许多公司每天会推出多次新功能或修改功能。
由于应用程序更新频繁,这些更新必须是无干扰的。在升级时,我们不能让系统停机进行维护。所有的更新都必须无缝且透明地进行。
滚动更新
更新应用程序或应用服务的一种方式是使用滚动更新。这里的假设是需要更新的软件运行在多个实例上。只有这样,我们才能使用这种更新方式。
发生的情况是,系统停止当前服务的一个实例,并用新服务的实例替换它。一旦新实例准备好,它将开始接收流量。通常,新实例会被监控一段时间,以检查它是否按预期工作;如果成功,当前服务的下一个实例将被停用并替换为新的实例。这个模式会一直重复,直到所有服务实例都被替换。
由于在任何给定时间总是会有一些实例在运行,无论是当前的还是新的,应用程序始终处于可用状态。无需停机时间。
蓝绿部署
在蓝绿部署中,当前版本的应用服务,称为蓝色,处理所有应用流量。接着,我们在生产系统上安装新版本的应用服务,称为绿色。这个新服务还没有和应用的其他部分连接。
一旦绿色服务安装完成,我们可以对这个新服务执行冒烟测试。如果测试通过,路由器可以配置为将所有之前发送到蓝色服务的流量转发到新的绿色服务。然后,密切观察绿色服务的表现,如果所有成功标准都满足,蓝色服务可以被淘汰。但如果由于某些原因,绿色服务出现了意外或不希望出现的行为,路由器可以重新配置,将所有流量返回到蓝色服务。然后可以删除绿色服务并进行修复,之后可以使用修复后的版本重新执行蓝绿部署。
图 9.7 – 蓝绿部署
接下来,我们来看一下金丝雀发布。
金丝雀发布
金丝雀发布是指在系统中并行安装当前版本的应用服务和新版本的发布。它类似于蓝绿部署。最初,所有流量仍然会通过当前版本路由。然后,我们配置路由器,使其将总体流量的一个小百分比(例如 1%)引导到新版本的应用服务。随后,密切监控新服务的行为,以确定它是否按预期工作。如果所有成功标准都满足,则将路由器配置为将更多流量(例如这次是 5%)引导到新服务。同样,继续密切监控新服务的行为,如果成功,将更多流量引导到新服务,直到达到 100%。一旦所有流量都被引导到新服务并且其稳定了一段时间,旧版本的服务可以被停用。
为什么我们称之为金丝雀发布?它的名字来源于煤矿工人,他们会使用金丝雀作为矿井中的早期警报系统。金丝雀对有毒气体特别敏感,如果金丝雀死了,矿工们就知道他们必须立即撤离矿井。
不可逆的数据变更
如果我们的更新过程的一部分是执行不可逆的状态变更,例如在后端关系型数据库中进行不可逆的架构变更,那么我们需要特别小心处理。只要采用正确的方法,就可以在没有停机的情况下执行此类变更。需要特别注意的是,在这种情况下,我们不能同时部署需要新数据结构的代码变更和数据变更。相反,整个更新过程必须分为三个不同的步骤。在第一步,我们推出向后兼容的架构和数据变更。如果这一步成功,我们将在第二步推出新代码。再次,如果第二步成功,我们将在第三步清理架构,移除向后兼容性。
图 9.8 – 推出不可逆的数据或架构变更
上面的图表展示了数据及其结构如何更新,应用代码如何更新,以及数据和数据结构如何清理。
大规模改变数据结构
随着时间的推移,应用程序可能会生成大量数据。在大规模数据结构更改中,指的是改变存储在数据库或其他类型数据存储系统中大量数据的格式、组织或布局的过程。这可能涉及添加、删除或修改数据结构中的字段、表或其他元素。目标是针对特定的用例或业务需求优化数据,同时保持数据的准确性和完整性。这个过程通常涉及分析现有的数据结构,规划和测试更改,然后以受控方式执行更新。在大规模的数据结构更改中,必须有一个明确定义的策略、强有力的测试和验证流程,以及足够的资源,包括技术专长和备份系统,以最小化在迁移过程中发生数据丢失或损坏的风险。
在动态数据迁移场景中,数据在使用过程中会实时更新,这使得迁移过程变得更加复杂和具有挑战性。这种类型的迁移需要更复杂的方法,以确保在整个迁移过程中数据的一致性和完整性。解决方案应能够跟踪源系统中对数据所做的更改,并在目标系统中复制这些更改,同时最小化停机时间和数据丢失。这可能涉及使用专门的工具,如数据复制或镜像软件,或采用多步骤过程,包括数据同步和对账。此外,必须制定强有力的测试和验证程序,并且有清晰的回滚计划,以最小化在迁移过程中发生数据丢失或损坏的风险。
回滚与前进
如果我们的应用服务在生产环境中频繁更新,迟早会出现某个更新的问题。也许在修复某个 bug 时,开发人员引入了新的 bug,这个问题没有被所有的自动化测试,甚至可能是手动测试,捕获到,因此应用程序出现异常。在这种情况下,我们必须将服务回滚到之前的正常版本。在这方面,回滚就是从灾难中恢复。
再次强调,在分布式应用架构中,问题不是是否需要回滚,而是何时需要回滚。因此,我们需要确保始终能够回滚到构成我们应用程序的任何服务的先前版本。回滚不能是事后想到的,它必须是我们部署过程中的一个经过测试和验证的部分。
如果我们使用蓝绿部署来更新服务,那么回滚应该相对简单。我们所需要做的就是将路由器从新的绿色版本切换回先前的蓝色版本。
如果我们遵循持续交付,并且我们的代码主分支始终处于可部署状态,那么我们还可以考虑进行向前滚动部署,而不是回滚部署。通常,解决生产问题并立即推出修复要比试图将系统回滚到先前的状态更快。特别是在先前的变更引入了某些向后不兼容时,向前滚动部署技术显得尤为重要。
总结
本章中,我们学习了什么是分布式应用架构,以及哪些模式和最佳实践对成功运行分布式应用是有帮助或必要的。我们还讨论了在生产环境中运行这种应用所需要的内容。
在下一章中,我们将深入探讨仅限单一主机的网络连接。我们将讨论如何让位于同一主机上的容器相互通信,以及如何在必要时让外部客户端访问容器化应用程序。
进一步阅读
以下文章提供了更深入的信息,涉及本章中所涉及的内容:
-
电路 断路器:
bit.ly/1NU1sgW
-
OSI 模型 解释:
bit.ly/1UCcvMt
-
蓝绿 部署:
bit.ly/2r2IxNJ
问题
请回答以下问题,以评估你对本章内容的理解:
-
为什么分布式应用架构中的每个部分都必须具备冗余性?请用几句话简要解释。
-
为什么我们需要 DNS 服务?请用三到五句话解释。
-
什么是电路断路器,为什么需要它?
-
单体应用和分布式或多服务应用之间有哪些重要的区别?
-
什么是蓝绿部署?
答案
以下是本章问题的可能答案:
-
在分布式应用架构中,软件和基础设施的每个部分在生产环境中都需要具备冗余性,因为应用的持续运行时间至关重要。一个高度分布式的应用由许多部分组成,随着部分数量的增加,某个部分发生故障或异常的可能性也会增加。可以确保的是,给定足够的时间,每个部分最终都会发生故障。为了避免应用出现中断,我们需要对每个部分进行冗余处理,无论是服务器、网络交换机,还是在容器中的集群节点上运行的服务。
-
在高度分布式、可扩展且具有容错能力的系统中,应用的单个服务可能会因为扩展需求或组件故障而移动。因此,我们不能将不同的服务硬性耦合在一起。需要访问服务 B 的服务 A 不应该需要知道关于服务 B 的细节,如其 IP 地址。它应该依赖外部提供者来获取这些信息。DNS 就是这样一个位置信息提供者。服务 A 只需要告诉 DNS 它想与服务 B 进行通信,DNS 服务将处理具体细节。
-
熔断器是一种防止分布式应用中的组件出现级联故障的手段。如果某个组件失败或行为异常,类似于电路中的熔断器,软件驱动的熔断器会切断客户端与失败服务之间的通信。如果失败的服务被调用,熔断器会直接向客户端组件报告错误。这允许系统从故障中恢复或自我修复。
-
单体应用比多服务应用更容易管理,因为它只包含一个单一的部署包。另一方面,单体应用通常更难以扩展,以应对某一特定领域的需求增加。在分布式应用中,每个服务可以单独扩展,每个服务可以运行在优化的基础设施上,而单体应用需要在适应所有或大多数功能的基础设施上运行。然而,随着时间的推移,这已不再是一个大问题,因为所有主要的云提供商都提供了非常强大的服务器和/或虚拟机。这些服务器相对便宜,并且可以轻松处理大多数普通业务线或 Web 应用的负载。
如果单体应用没有很好地模块化,维护和更新要比多服务应用更困难,后者的每个服务都可以独立更新和部署。单体应用通常是一个庞大、复杂且紧耦合的代码堆积。即使是小的修改,也可能产生意想不到的副作用。(微)服务,理论上是自包含的、简单的组件,像黑盒子一样运行。依赖的服务不知道该服务的内部工作原理,因此不依赖它。
注意
现实往往没有那么理想——在许多情况下,微服务是紧耦合的,并表现得像分布式巨型单体应用。遗憾的是,后者是团队或公司最糟糕的状态,因为它结合了两者世界的缺点,一方面是单体应用,另一方面是分布式应用。
- 蓝绿部署是一种软件部署方式,允许在不停止服务的情况下部署应用程序或应用服务的新版本。如果,假设服务 A 需要更新新版本,那么当前运行的版本称为蓝色版本。新版本的服务会被部署到生产环境中,但尚未与其他应用程序集成。这一新版本被称为绿色版本。一旦部署成功,并且冒烟测试显示它已经准备好上线,负责将流量引导至蓝色版本的路由器将重新配置,切换到绿色版本。绿色版本的行为会被观察一段时间,如果一切正常,蓝色版本将被停用。另一方面,如果绿色版本出现问题,路由器可以简单地切换回蓝色版本,绿色版本可以进行修复并稍后重新部署。
第十章:10
使用单主机网络
在上一章中,我们学习了分布式应用架构中使用的最重要的架构模式和最佳实践。
在本章中,我们将介绍 Docker 容器网络模型及其在桥接网络中的单主机实现。本章还介绍了软件定义网络(SDN)的概念,以及它们如何用于保护容器化的应用程序。此外,我们将演示如何将容器端口公开,从而使容器化的组件可以被外界访问。最后,我们将介绍 Traefik,一个反向代理,它可以用来在容器之间启用复杂的 HTTP 应用层路由。
本章涵盖以下主题:
-
解剖容器网络模型
-
网络防火墙
-
使用桥接网络
-
主机和空网络
-
在现有网络命名空间中运行
-
管理容器端口
-
使用反向代理进行 HTTP 层路由
完成本章后,你将能够完成以下任务:
-
创建、检查和删除自定义桥接网络
-
运行一个附加到自定义桥接网络的容器
-
通过将容器运行在不同的桥接网络上来隔离容器
-
将容器端口发布到你选择的主机端口
-
添加 Traefik 作为反向代理以启用应用层路由
技术要求
对于本章,你所需的唯一条件是一个能够运行 Linux 容器的 Docker 主机。你可以使用安装了 Docker Desktop 的笔记本电脑来完成这个任务。
首先,让我们创建一个用于本章的文件夹,在其中存储我们示例的代码:
-
导航到你克隆本书所附仓库的文件夹。通常,这是以下路径:
$ cd ~/The-Ultimat-Docker-Container-Book
-
创建一个用于本章的子文件夹并导航到它:
$ mkdir ch10 && cd ch10
让我们开始吧!
解剖容器网络模型
到目前为止,我们主要处理的是单个容器,但实际上,一个容器化的业务应用程序由多个容器组成,这些容器需要协同工作以实现目标。因此,我们需要一种方式使得各个容器之间能够通信。这是通过建立路径来实现的,我们可以利用这些路径在容器之间来回发送数据包。这些路径被称为网络。Docker 定义了一个非常简单的网络模型,即所谓的容器网络模型(CNM),以指定任何实现容器网络的软件必须满足的要求。以下是 CNM 的图形表示:
图 10.1 – Docker CNM
CNM 有三个元素——沙箱、端点和网络:
-
网络沙箱:沙箱完全隔离容器与外界的连接。沙箱容器不允许任何外部网络连接进入,但如果容器与外界完全没有任何通信,它在系统中几乎没有任何价值。为了弥补这一点,我们引入了第二个元素,即端点。
-
端点:端点是从外部世界到网络沙箱的受控网关,沙箱用来保护容器。端点将网络沙箱(而不是容器)连接到模型的第三个元素——网络。
-
网络:网络是传输通信实例数据包的路径,从端点到端点,或者最终从容器到容器。
需要注意的是,一个网络沙箱可以有零个到多个端点,换句话说,每个位于网络沙箱中的容器可以没有连接到任何网络,或者可以同时连接到多个不同的网络。在上面的示意图中,三个网络沙箱中的中间一个通过端点同时连接到网络 1和网络 2。
这种网络模型非常通用,并没有指定通过网络进行通信的各个容器运行的位置。例如,所有容器可以运行在同一主机上(本地),也可以分布在多个主机的集群中(全球)。
当然,CNM 仅仅是一个描述容器之间如何实现网络连接的模型。为了能够在容器中使用网络,我们需要 CNM 的实际实现。对于本地和全球范围,我们有多种 CNM 实现。在下表中,我们简要概述了现有实现及其主要特性。列表没有特定顺序:
网络 | 公司 | 范围 | 描述 |
---|---|---|---|
Bridge | Docker | 本地 | 基于 Linux 桥接的简单网络,允许在单一主机上进行网络连接 |
Macvlan | Docker | 本地 | 在单一物理主机接口上配置多个二层(即 MAC)地址 |
Overlay | Docker | 全球 | 基于虚拟可扩展 局域网(VXLan)的多节点容器网络 |
Weave Net | Weaveworks | 全球 | 简单、弹性、支持多主机的 Docker 网络 |
Contiv 网络插件 | Cisco | 全球 | 开源容器网络 |
表 10.1 – 网络类型
所有 Docker 未直接提供的网络类型都可以作为插件添加到 Docker 主机中。
在下一节中,我们将描述网络防火墙的工作原理。
网络防火墙
Docker 一直秉持着“安全第一”的原则。这个理念直接影响了单主机和多主机 Docker 环境中网络的设计与实现。SDN(软件定义网络)既容易且便宜创建,又能够完美地将连接到此网络的容器与其他未连接的容器,以及外部世界隔离开来。所有属于同一网络的容器可以自由地相互通信,而其他容器则无法做到这一点。
在下图中,我们有两个网络,分别名为 front 和 back。容器 c1 和 c2 附加到 front 网络,容器 c3 和 c4 附加到 back 网络。c1 和 c2 可以自由地相互通信,c3 和 c4 也可以自由通信,但 c1 和 c2 无法与 c3 或 c4 进行通信,反之亦然:
图 10.2 – Docker 网络
那么,如果我们有一个由三个服务组成的应用程序:webAPI
、productCatalog
和 database
,该怎么办呢?我们希望webAPI
能够与productCatalog
进行通信,但不能与数据库通信,同时我们希望productCatalog
能够与数据库服务进行通信。我们可以通过将webAPI
和数据库放在不同的网络上,并将productCatalog
连接到这两个网络来解决这个问题,如下图所示:
图 10.3 – 容器连接到多个网络
由于创建 SDN 很便宜,而且每个网络通过隔离资源以防止未授权访问提供了额外的安全性,强烈建议您设计并运行应用程序,使其使用多个网络,并且仅在绝对需要相互通信的服务才在同一网络上运行。在前面的例子中,webAPI
组件完全不需要与 database
服务直接通信,因此我们将它们放在了不同的网络上。如果最坏的情况发生,黑客侵入了 webAPI
,他们也无法通过它访问数据库,除非黑客同时攻破了 productCatalog
服务。
现在我们可以讨论 CNM(容器网络模型)的第一个实现——桥接网络。
使用桥接网络
Docker 桥接网络是我们将详细探讨的 CNM 的第一个实现。这个网络实现基于 Linux 桥接。
当 Docker 守护进程第一次运行时,它会创建一个 Linux 桥接并将其命名为 docker0
。这是默认行为,可以通过更改配置来进行修改。
然后,Docker 使用这个 Linux 桥接创建一个网络,并将其称为网络桥接。我们在 Docker 主机上创建的所有容器,如果没有显式绑定到其他网络,都会导致 Docker 自动将这些容器附加到该桥接网络上。
为了验证我们确实在主机上定义了一个名为bridge
的桥接类型网络,我们可以使用以下命令列出主机上的所有网络:
$ docker network ls
这应提供类似以下的输出:
图 10.4 – 列出默认情况下所有可用的 Docker 网络
在你的情况下,ID 会不同,但其余的输出应保持一致。我们确实有一个名为bridge
的第一个网络,使用的是bridge
驱动程序。local
范围意味着该类型的网络仅限于单个主机,不能跨多个主机。在第十四章,介绍 Docker Swarm中,我们还将讨论其他具有全局范围的网络类型,意味着它们可以跨多个主机群集。
现在,让我们更深入地了解这个桥接网络的具体内容。为此,我们将使用 Docker 的inspect
命令:
$ docker network inspect bridge
执行时,这将输出关于相关网络的大量详细信息。这些信息应如下所示:
图 10.5 – 检查 Docker 桥接网络时生成的输出
我们在列出所有网络时看到了ID
、Name
、Driver
和Scope
值,所以这并不新鲜,但让我们来看一下IP 地址管理(IPAM)块。
IPAM 是一款用于跟踪计算机上使用的 IP 地址的软件。IPAM 块中的重要部分是带有子网和网关值的config节点。桥接网络的子网默认定义为172.17.0.0/16
。这意味着所有连接到此网络的容器将获得由 Docker 分配的 IP 地址,该地址来自给定的范围,即 172.17.0.2
至 172.17.255.255
。172.17.0.1
地址保留给该网络的路由器,在这种网络类型中,路由器由 Linux 桥接担当。我们可以预期,Docker 首次将附加到该网络的容器将获得172.17.0.2
地址。所有后续容器将获得更高的编号;下图说明了这一点:
图 10.6 – 桥接网络
在前面的图中,我们可以看到主机的网络命名空间,其中包括主机的eth0
端点,如果 Docker 主机运行在裸机上,它通常是一个 NIC;如果 Docker 主机是虚拟机,它通常是一个虚拟 NIC。所有到主机的流量都通过eth0
。Linux 桥接负责在主机的网络和桥接网络的子网之间路由网络流量。
什么是 NIC?
网络接口卡(NIC),有时也被称为网络接口连接器,是一种硬件组件,允许计算机或设备连接到网络。它作为计算机与网络之间的接口,允许数据的传输和接收。NIC 通常是主板上的内置组件,或者作为扩展卡安装,并支持各种类型的网络连接,例如以太网、Wi-Fi 或光纤连接。
默认情况下,只有出口流量被允许,所有入口流量都会被阻止。这意味着,虽然容器化应用程序可以访问互联网,但它们不能被任何外部流量访问。每个连接到网络的容器都获得自己的虚拟以太网(veth)连接到桥接网络。下图对此进行了说明:
图 10.7 – 桥接网络的详细信息
上面的图示从主机的角度展示了整个情况。我们稍后将在本节中探索从容器内部看到的情况。我们不仅限于使用桥接网络,因为 Docker 允许我们定义自己的自定义桥接网络。这不仅是一个“有用的功能”,它也是一种推荐的最佳实践——不要将所有容器都运行在同一个网络上。相反,我们应使用额外的桥接网络,进一步隔离那些不需要相互通信的容器。要创建一个名为 sample-net
的自定义桥接网络,请使用以下命令:
$ docker network create --driver bridge sample-net
如果我们这样做,就可以查看 Docker 为这个新的自定义网络创建了什么子网,如下所示:
$ docker network inspect sample-net | grep Subnet
这将返回以下值:
"Subnet": "172.18.0.0/16",
显然,Docker 刚刚为我们的新自定义桥接网络分配了下一个空闲的 IP 地址块。如果由于某些原因,我们希望在创建网络时指定自己的子网范围,可以通过使用 --subnet
参数来实现:
$ docker network create --driver bridge --subnet "10.1.0.0/16" test-net
注意
为了避免因重复的 IP 地址而发生冲突,请确保避免创建具有重叠子网的网络。
现在我们已经讨论了桥接网络是什么,以及如何创建自定义桥接网络,我们接下来要了解的是如何将容器连接到这些网络。
首先,让我们互动地运行一个 Alpine 容器,但不指定要连接的网络:
$ docker container run --name c1 -it --rm alpine:latest /bin/sh
在另一个终端窗口中,让我们检查 c1
容器:
$ docker container inspect c1
在大量输出中,让我们暂时集中关注提供网络相关信息的部分。这可以在NetworkSettings
节点下找到。我在以下输出中列出了它:
图 10.8 – 容器元数据中的 NetworkSettings 部分
在前面的输出中,我们可以看到容器确实已连接到桥接网络,因为NetworkID
等于d172692...
,我们可以从前面的代码中看到它是桥接网络的 ID。我们还可以看到容器被分配了预期的 IP 地址172.17.0.2
,并且网关位于172.17.0.1
。
请注意,容器也有一个与之关联的MacAddress
。这一点很重要,因为 Linux 桥接会使用MacAddress
来进行路由。
到目前为止,我们是从容器网络命名空间之外的角度来处理的。现在,让我们看看当我们不仅在容器内部,而且在容器的网络命名空间内部时,情况会是什么样的。在c1
容器内,我们使用ip
工具检查正在发生的事情。运行ip addr
命令并观察生成的输出,如下所示:
图 10.9 – 容器命名空间,IP 工具视图
前面输出中有趣的部分是54:
,即eth0
端点。Linux 桥接在容器命名空间外部创建的veth0
端点映射到容器内部的eth0
。Docker 总是将容器网络命名空间的第一个端点映射为eth0
,从命名空间内部来看。如果网络命名空间附加到其他网络,那么该端点将映射为eth1
,依此类推。
由于此时我们并不关心除了eth0
之外的任何端点,我们可以使用该命令的更具体版本,这样会得到以下输出:
图 10.10 – 从容器内部看到的 eth0 端点
在输出中,我们还可以看到此容器网络命名空间由 Docker 分配的 MAC 地址(02:42:ac:11:00:02
)和 IP 地址(172.17.0.2
)。
我们还可以使用ip
route
命令获取一些关于请求如何路由的信息:
/ # ip route
这会给我们以下输出:
default via 172.17.0.1 dev eth0172.17.0.0/16 dev eth0 scope link src 172.17.0.2
这个输出告诉我们,所有到达172.17.0.1
网关的流量都通过eth0
设备进行路由。
现在,让我们在同一网络上以detach
模式运行另一个名为c2
的容器:
$ docker container run --name c2 -d --rm alpine:latest ping 127.0.0.1
c2
容器也会被附加到桥接网络上,因为我们没有指定其他网络。它的 IP 地址将是子网中的下一个空闲地址,即172.17.0.3
,我们可以通过以下命令轻松测试:
$ docker container inspect --format "{{.NetworkSettings.IPAddress}}" c2
这将产生以下输出:
172.17.0.3
现在,我们有两个容器连接到桥接网络。我们可以再次尝试检查这个网络,在输出中找到所有连接到它的容器列表:
$ docker network inspect bridge
这些信息可以在Containers
节点下找到:
图 10.11 – Docker 网络检查bridge
网络输出中的容器部分
再次,我们已经缩短了输出,只保留相关部分以便于阅读。
现在,让我们创建两个额外的容器c3
和c4
,并将它们附加到我们之前创建的sample-net
网络。为此,我们将使用--network
参数:
$ docker container run --name c3 --rm -d \ --network sample-net \
alpine:latest ping 127.0.0.1
$ docker container run --name c4 --rm -d \
--network sample-net \
alpine:latest ping 127.0.0.1
让我们检查一下sample-net
网络,并确认c3
和c4
确实已连接到该网络:
$ docker network inspect sample-net
这将给我们以下的Containers
部分输出:
图 10.12 – Docker 网络检查test-net
命令输出中的容器部分
接下来,我们要问自己一个问题,即c3
和c4
容器是否可以自由地相互通信。为了证明这一点,我们可以通过exec
进入c3
容器:
$ docker container exec -it c3 /bin/sh
一旦进入容器,我们可以尝试通过名称和 IP 地址ping
容器c4
:
/ # ping c4
我们应该会看到这个输出:
PING c4 (172.20.0.3): 56 data bytes64 bytes from 172.20.0.3: seq=0 ttl=64 time=3.092 ms
64 bytes from 172.20.0.3: seq=1 ttl=64 time=0.481 ms
...
在这里,我们使用的是c4
的 IP 地址,而不是容器的名称:
/ # ping 172.20.0.3
我们应该会看到以下结果:
PING 172.20.0.3 (172.20.0.3): 56 data bytes64 bytes from 172.20.0.3: seq=0 ttl=64 time=0.200 ms
64 bytes from 172.20.0.3: seq=1 ttl=64 time=0.172 ms
...
在这两种情况下,答案都证实了同一网络上附加的容器之间的通信正常。我们甚至可以使用想要连接的容器的名称,这表明 Docker 的 DNS 服务在该网络中起作用。
现在,我们希望确保bridge
网络和sample-net
网络是相互隔离的。为了证明这一点,我们可以尝试从c3
容器ping
c2
容器,可以通过名称或 IP 地址。让我们从按名称 ping 开始:
/ # ping c2
这将产生以下输出:
ping: bad address 'c2'
以下是使用c2
容器的 IP 地址进行 ping 测试的结果:
/ # ping 172.17.0.3
它给了我们以下输出:
PING 172.17.0.3 (172.17.0.3): 56 data bytes^C
--- 172.17.0.3 ping statistics ---
11 packets transmitted, 0 packets received, 100% packet loss
前面的命令一直处于挂起状态,我不得不用Ctrl + C终止命令。从 pingc2
的输出中,我们还可以看到,跨网络的名称解析并不工作。这是预期的行为。网络为容器提供了额外的隔离层,从而增强了安全性。
之前我们学习了,容器可以附加到多个网络。让我们首先创建一个名为test-net
的网络。请注意,以下命令没有定义网络的驱动程序;因此,使用的是默认驱动程序,恰好是 bridge 驱动程序:
$ docker network create test-net
然后,我们将c5
容器附加到sample-net
网络:
$ docker container run --name c5 --rm -d \ --network sample-net
alpine:latest ping 127.0.0.1
然后,我们将c6
容器同时附加到sample-net
和test-net
网络:
$ docker container run --name c6 --rm -d \ --network sample-net \
alpine:latest ping 127.0.0.1
$ docker network connect test-net c6
现在,我们可以测试c5
容器(附加在test-net
网络上)是否能够从中访问c6
,以及sample-net
网络上附加的c3
容器是否能够访问c6
。结果将表明连接确实有效。
如果我们想删除一个现有的网络,可以使用docker network rm
命令,但请注意,不能意外删除已经附加了容器的网络:
$ docker network rm test-net
结果是这个输出:
Error response from daemon: network test-net id 455c922e... has active endpoints
在继续之前,让我们清理并删除所有容器:
$ docker container rm -f $(docker container ls -aq)
现在,我们可以删除我们创建的两个自定义网络:
$ docker network rm sample-net$ docker network rm test-net
或者,我们可以使用 prune
命令删除所有没有容器连接的网络:
$ docker network prune --force
我在这里使用了 --force
(或 -f
)参数,以防止 Docker 再次确认我是否确实要删除所有未使用的网络。
使用 docker network ls
命令再次确认,只剩下 Docker 提供的三个默认网络。
接下来我们要检查的网络类型是 host
和 null
网络类型。
主机网络和空网络
在本节中,我们将讨论两种预定义的、稍微独特的网络类型:主机网络和空网络。我们先从前者开始。
主机网络
有时我们需要在主机的网络命名空间中运行容器。这在需要在容器中运行用于分析或调试主机网络流量的软件时可能是必要的,但请记住,这些情况是非常特定的。在容器中运行业务软件时,通常没有理由将容器连接到主机的网络。出于安全考虑,强烈建议你不要在生产或类似生产的环境中将任何容器连接到主机网络。
话虽如此,我们如何在主机的网络命名空间中运行容器呢?只需将容器附加到 host
网络即可:
-
运行一个 Alpine 容器,并将其连接到
host
网络:$ docker container run --rm -it \ --network host \ alpine:latest /bin/sh
-
使用
ip
工具从容器内部分析网络命名空间。你会发现,我们得到的结果和在主机上直接运行ip
工具时完全一样。例如,我用以下命令检查了我的笔记本电脑上的eth0
设备:/ # ip addr show eth0
结果是,我得到了这个:
图 10.13 – 从容器内部查看 eth0 设备
在这里,我可以看到 192.168.65.3
是主机分配的 IP 地址,显示的 MAC 地址也对应于主机的地址。
-
我们还可以检查路由:
/ # ip route
在我的 MacBook Air M1 上,我得到的是:
图 10.14 – 从容器内部查看的路由
在我们进入本章的下一部分之前,我再次强调,因潜在的安全漏洞和冲突,将容器运行在主机网络上可能是危险的:
-
安全风险:通过使用主机网络,容器拥有与主机相同的网络访问权限。这意味着,如果容器内运行的应用程序存在漏洞并被利用,攻击者可能会获得对主机网络的访问权限,进而危及其他服务或数据。
-
端口冲突:当容器使用主机网络时,它与主机共享相同的网络命名空间。这意味着,如果你的容器化应用程序和主机上的应用程序在同一端口上监听,就会发生冲突。
-
隔离性:使用 Docker 的主要好处之一是它在各个层面(进程、文件系统或网络)提供的隔离性。通过使用主机网络,你会失去这一层隔离,这可能会导致一些不可预见的问题。
因此,通常建议在运行 Docker 容器时使用用户定义的网络,而不是主机网络,因为它提供更好的隔离性,并减少冲突和安全漏洞的风险。
空网络
有时,我们需要运行一些不需要任何网络连接即可执行任务的应用服务或作业。强烈建议你在附加到 none
网络的容器中运行这些应用程序。这样,容器将完全隔离,从而避免任何外部访问。让我们运行这样一个容器:
$ docker container run --rm -it \ --network none \
alpine:latest /bin/sh
进入容器后,我们可以验证是否没有 eth0
网络端点可用:
/ # ip addr show eth0ip: can't find device 'eth0'
也没有可用的路由信息,正如我们可以通过以下命令演示的那样:
/ # ip route
这不返回任何内容。
在接下来的章节中,我们将学习如何在另一个容器的现有网络命名空间中运行一个容器。
在现有网络命名空间中运行
通常,Docker 为我们运行的每个容器创建一个新的网络命名空间。容器的网络命名空间对应于我们之前描述的容器网络模型中的沙箱。当我们将容器附加到网络时,我们定义一个端点,将容器网络命名空间与实际网络连接起来。这样,我们每个网络命名空间都有一个容器。
Docker 为我们提供了另一种方法来定义容器运行的网络命名空间。在创建新容器时,我们可以指定它应该附加到(或者我们应该说是包含在)一个现有容器的网络命名空间中。通过这种技术,我们可以在单一网络命名空间中运行多个容器:
图 10.15 – 在单一网络命名空间中运行多个容器
在上面的图中,我们可以看到,在最左侧的网络命名空间中,我们有两个容器。由于这两个容器共享相同的命名空间,它们可以在 localhost
上相互通信。然后,网络命名空间(而不是单独的容器)被附加到 front 网络。
当我们想要调试现有容器的网络而不在该容器内运行其他进程时,这是非常有用的。我们可以简单地将一个特殊的实用容器附加到容器的网络命名空间中进行检查。这个特性也被 Kubernetes 在创建 Pod 时使用。我们将在本书后续章节中进一步了解 Kubernetes 和 Pod。
现在,让我们演示一下这是如何工作的:
-
首先,我们创建一个新的
bridge
网络:$ docker network create --driver bridge test-net
-
接下来,我们运行一个附加到这个网络的容器:
$ docker container run --name web -d \ --network test-net \ nginx:alpine
-
最后,我们运行另一个容器并将其附加到我们 web 容器的网络中:
$ docker container run -it --rm \ --network container:web \ alpine:latest /bin/sh
特别要注意如何定义网络:--network container:web
。这告诉 Docker 我们的新容器将使用与名为web
的容器相同的网络命名空间。
-
由于新容器与运行 nginx 的 web 容器处于相同的网络命名空间中,我们现在可以在
localhost
上访问 nginx 了!我们可以通过使用 Alpine 容器中的wget
工具来证明这一点。我们应该看到以下内容:/ # wget -qO – localhost<!DOCTYPE html><html><head><title>Welcome to nginx!</title>...</html>
请注意,为了便于阅读,我们已经缩短了输出。还要注意,运行两个附加到同一网络的容器和运行在相同网络命名空间中的两个容器之间有一个重要的区别。在这两种情况下,容器可以自由地相互通信,但在后一种情况下,通信是通过localhost
进行的。
-
要清理容器和网络,我们可以使用以下命令:
$ docker container rm --force web$ docker network rm test-net
在接下来的部分,我们将学习如何在容器主机上公开容器端口。
管理容器端口
现在我们知道了如何通过将它们放置在不同网络上来隔离防火墙容器,以及如何使容器附加到多个网络,我们还有一个未解决的问题。如何将应用服务暴露给外部世界?想象一个运行 Web 服务器并托管我们以前的webAPI
的容器。我们希望来自互联网的客户能够访问这个 API。我们设计它为一个可公开访问的 API。为了实现这一点,我们需要象征性地打开我们防火墙中的一扇门,通过这扇门,我们可以将外部流量引导到我们的 API。出于安全考虑,我们不仅仅想要敞开大门;我们希望有一个单一的可控门,流量通过它流动。
我们可以通过将容器端口映射到主机上的一个可用端口来创建这种类型的门。我们也称这个容器端口发布为端口。请记住,容器有自己的虚拟网络堆栈,主机也有。因此,容器端口和主机端口完全独立存在,并且默认情况下根本没有任何共同点,但现在我们可以通过这个链接将容器端口与空闲的主机端口连接起来,并引导外部流量,就像下图所示的那样:
图 10.16 – 将容器端口映射到主机端口
但现在是时候展示如何实际将容器端口映射到宿主机端口了。这是在创建容器时完成的。我们有不同的方式来做到这一点:
-
首先,我们可以让 Docker 决定我们的容器端口应该映射到哪个宿主机端口。Docker 会从
32xxx
范围内选择一个空闲的宿主机端口。这个自动映射是通过使用-P
参数完成的:$ docker container run --name web -P -d nginx:alpine
上述命令在容器中运行一个 nginx 服务器。nginx 在容器内监听端口 80
。通过 -P
参数,我们告诉 Docker 将所有暴露的容器端口映射到 32xxx
范围内的一个空闲端口。我们可以通过使用 docker container
port
命令来找出 Docker 正在使用的宿主机端口:
$ docker container port web80/tcp -> 0.0.0.0:32768
nginx 容器只暴露端口 80
,我们可以看到它已经映射到宿主机端口 32768
。如果我们打开一个新的浏览器窗口并访问 localhost:32768
,我们应该会看到以下界面:
图 10.17 – nginx 欢迎页面
-
另一种了解 Docker 为我们的容器使用哪个宿主机端口的方法是检查容器。宿主机端口是
NetworkSettings
节点的一部分:$ docker container inspect web | grep HostPort "HostPort": "32768"
-
最后,获取这些信息的第三种方法是列出容器:
$ docker container lsCONTAINER ID IMAGE ... PORTS NAMES56e46a14b6f7 nginx:alpine ... 0.0.0.0:32768->80/tcp web
请注意,在前面的输出中,/tcp
部分告诉我们该端口已为 TCP 协议的通信打开,但未为 UDP 协议打开。TCP 是默认协议,如果我们希望明确指定打开端口用于 UDP 协议,我们必须显式地指定这一点。映射中的特殊(IP)地址 0.0.0.0
告诉我们,来自任何主机 IP 地址的流量现在可以访问 web 容器的端口 80
。
-
有时,我们希望将容器端口映射到一个非常特定的宿主机端口。我们可以通过使用
-p
参数(或--publish
)来实现。让我们来看一下如何通过以下命令实现:$ docker container run --name web2 -p 8080:80 -d nginx:alpine
-p
参数的值采用 <host port>:<container port>
的形式。因此,在上述案例中,我们将容器端口 80
映射到宿主机端口 8080
。一旦 web2
容器启动,我们可以通过访问 localhost:8080
在浏览器中进行测试,我们应该会看到与之前自动端口映射示例中相同的 nginx 欢迎页面。
当使用 UDP 协议在某个端口上进行通信时,publish 参数的格式如下:-p 3000:4321/udp
。请注意,如果我们希望在同一端口上同时支持 TCP 和 UDP 协议的通信,我们必须分别为每个协议映射端口。
在接下来的部分,我们将讨论使用反向代理进行 HTTP 路由。
使用反向代理进行 HTTP 层路由
假设你被指派将一个单体应用容器化。这个应用在多年的演化过程中变成了一个难以维护的庞然大物。即便是对源代码中的一个小功能进行修改,也可能由于代码库中紧密耦合的关系而破坏其他功能。由于复杂性,发布非常稀少,并且需要整个团队的支持。每次发布都需要停机,这会导致公司因错失商机而付出大量金钱,更别提它们的声誉损失了。
管理层已决定结束这一恶性循环,并通过将单体应用容器化来改善局势。仅此一步就能显著缩短发布周期,行业内已有实例证明了这一点。作为后续步骤,公司希望将单体应用中的每一部分功能拆分出来,并实现为微服务。这个过程将持续进行,直到单体应用完全被“饿死”。
但正是这个第二个问题让参与团队头疼不已。我们如何在不影响单体应用众多客户端的情况下,将单体应用拆分为松耦合的微服务呢?尽管单体应用的公共 API 非常复杂,但它具有良好的结构设计。公共 URI 是经过精心设计的,任何情况下都不应更改。例如,应用中实现了一个产品目录功能,可以通过acme.com/catalog?category=bicycles
来访问,从而获取公司提供的自行车列表。
另一方面,还有一个名为https://acme.com/checkout
的 URL,可以用来启动客户购物车的结账流程,等等。我希望你能理解我们想表达的意思。
容器化单体应用
让我们从单体应用开始。我准备了一个简单的代码库,它是用 Python 3.7 实现的,并使用 Flask 来实现公共的 REST API。这个示例应用并不是真正的完整应用,只是足够复杂,允许进行一些重新设计。示例代码可以在ch10/e-shop
文件夹中找到。在这个文件夹内有一个名为monolith
的子文件夹,里面包含了 Python 应用程序。请按照以下步骤操作:
-
在新的终端窗口中,进入该文件夹,安装所需的依赖,并运行该应用:
$ cd ~/The-Ultimate-Docker-Container-Book$ cd ch10/e-shop/monolith$ pip install -r requirements.txt$ export FLASK_APP=main.py$ flask run
该应用将启动并在本地主机的5000
端口上监听:
图 10.18 – 运行 Python 单体应用
-
我们可以使用
curl
来测试这个应用。打开另一个终端窗口,并使用以下命令获取公司提供的所有自行车列表:$ curl localhost:5000/catalog?type=bicycle
这将产生以下输出:
[{"id": 1, "name": "Mountanbike Driftwood 24", "unitPrice": 199},{"id": 2, "name": "Tribal 100 Flat Bar Cycle Touring Road Bike",
"unitPrice": 300}, {"id": 3, "name": "Siech Cycles Bike (58 cm)",
"unitPrice": 459}]
这里,我们有一个 JSON 格式的自行车类型列表。好吧,至此一切顺利。
-
现在,让我们修改
hosts
文件,添加acme.com
的条目,并将其映射到127.0.0.1
(回送地址)。这样,我们可以模拟一个真实客户端通过http://acme.com/catalog?type=bicycle
访问应用程序,而不是使用localhost
。你需要使用sudo
来编辑 macOS 或 Linux 上的/etc/hosts
文件。你应该在hosts
文件中添加一行,如下所示:127.0.0.1 acme.com
Windows 主机文件
在 Windows 上,你可以通过例如以管理员身份运行记事本,打开 c:\Windows\System32\Drivers\etc\hosts
文件并进行修改,来编辑该文件。
-
保存你的更改,并通过 ping
acme.com
来验证它是否有效:$ ping acme.comPING acme.com (127.0.0.1): 56 data bytes64 bytes from 127.0.0.1: icmp_seq=0 ttl=55 time<1 ms64 bytes from 127.0.0.1: icmp_seq=1 ttl=55 time<1 ms64 bytes from 127.0.0.1: icmp_seq=2 ttl=55 time<1 ms...
完成这一切之后,是时候将应用程序容器化了。我们需要对应用程序进行的唯一更改是确保应用程序的 Web 服务器监听 0.0.0.0
,而不是 localhost
。
-
我们可以通过修改应用程序,并在
main.py
文件末尾添加以下启动逻辑来轻松完成此操作:if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)
-
然后,我们可以按照以下方式启动应用程序:
$ python main.py.
-
现在,在 monolith 文件夹中添加一个 Dockerfile,内容如下:
图 10.19 – Monolith 的 Dockerfile
-
在你的终端窗口中,从 monolith 文件夹内执行以下命令以为应用程序构建一个 Docker 镜像:
$ docker image build -t acme/eshop:1.0 .
-
镜像构建完成后,尝试运行应用程序:
$ docker container run --rm -it \ --name eshop \ -p 5000:5000 \ acme/eshop:1.0
注意,现在在容器中运行的应用程序输出与直接在主机上运行应用程序时的输出无法区分。我们现在可以使用两个 curl
命令来访问目录和结账逻辑,从而测试应用程序是否仍然像以前一样工作:
图 10.20 – 在容器中运行时测试 monolith
显然,monolith 仍然完全按照之前的方式工作,即使使用正确的 URL,即 http://acme.com
。太棒了!现在,让我们将 monolith 的一部分功能拆分为一个 Node.js 微服务,并将其单独部署。
提取第一个微服务
团队经过一些头脑风暴后决定,产品目录是一个很好的候选功能,可以从 monolith 中提取出来,既是一个紧密集成的功能,又足够自包含。他们决定将产品目录作为一个微服务实现,并使用 Node.js 来实现。
你可以在项目文件夹中的 catalog
子文件夹(即 e-shop
)中找到他们编写的代码和 Dockerfile。它是一个简单的 Express.js 应用程序,复制了以前在 monolith 中提供的功能。让我们开始吧:
-
在你的终端窗口中,从
catalog
文件夹内为这个新的微服务构建 Docker 镜像:$ docker image build -t acme/catalog:1.0 .
-
然后,从你刚刚构建的新镜像运行一个容器:
$ docker run --rm -it --name catalog -p 3000:3000 \ acme/catalog:1.0
-
从另一个终端窗口,尝试访问微服务并验证它返回与 monolith 相同的数据:
$ curl http://acme.com:3000/catalog?type=bicycle
请注意,与访问单体应用相同功能时的 URL 比较,在这里,我们访问的是 3000
端口的微服务(而不是 5000
端口)。
但是我们说过,我们不想更改访问我们电子商店应用的客户端。我们该怎么办?幸运的是,这类问题有解决方案。我们需要重新路由传入的请求。我们将在下一部分展示如何做到这一点。
使用 Traefik 重新路由流量
在上一部分中,我们意识到,我们必须将目标 URL 开头为 http://acme.com:5000/catalog
的传入流量重新路由到类似 product-catalog:3000/catalog
的替代 URL。我们将使用 Traefik 来完成这个任务。
Traefik 是一个云原生的边缘路由器,并且是开源的,这对我们的特定场景非常有用。它甚至有一个不错的 web UI,您可以用它来管理和监控您的路由。Traefik 可以非常直接地与 Docker 配合使用,稍后我们会看到。
为了与 Docker 进行良好的集成,Traefik 依赖于每个容器或服务中的元数据。这些元数据可以以包含路由信息的标签形式应用:
-
首先,让我们看一下如何运行
catalog
服务。以下是 Docker 的run
命令:$ docker container run --rm -d \ --name catalog \ --label traefik.enable=true \ --label traefik.port=3000 \ --label traefik.priority=10 \ --label traefik.http.routers.catalog.rule=\ "Host(\"acme.com\") && PathPrefix(\"/catalog\")" \ acme/catalog:1.0
让我们快速看一下我们定义的四个标签:
-
traefik.enable=true
:这告诉 Traefik 该特定容器应包含在路由中(默认值为false
)。 -
traefik.port=3000
:路由器应将请求转发到端口3000
(这是 Express.js 应用程序监听的端口)。 -
traefik.priority=10
:这为该路由提供了较高的优先级。我们稍后会看到原因。 -
traefik.http.routers.catalog.rule="Host(\"acme.com\") && PathPrefix(\"/catalog\")"
:该路由必须包含主机名acme.com
,并且路径必须以/catalog
开头,才能被重新路由到该服务。例如,acme.com/catalog?type=bicycles
将符合此规则。 -
请注意第四个标签的特殊形式。它的一般形式是
traefik.http.routers.<服务名称>.rule
。
-
现在,让我们看看如何运行
eshop
容器:$ docker container run --rm -d \ --name eshop \ --label traefik.enable=true \ --label traefik.port=5000 \ --label traefik.priority=1 \ --label traefik.http.routers.eshop.rule=\ "Host(\"acme.com\")" \ acme/eshop:1.0
在这里,我们将所有匹配的请求转发到端口 5000
,该端口对应于 eshop
应用程序监听的端口。请注意优先级,设置为 1
(低)。这一点与目录服务的高优先级配合使用,允许我们将所有以 /catalog
开头的 URL 过滤出来,并将它们重定向到 catalog
服务,而其他所有 URL 将转到 eshop
服务。
-
现在,我们终于可以将 Traefik 作为边缘路由器运行,作为我们应用程序前端的反向代理。这就是我们启动它的方式:
$ docker run -d \ --name traefik \ -p 8080:8080 \ -p 80:80 \ -v /var/run/docker.sock:/var/run/docker.sock \ traefik:v2.0 --api.insecure=true --providers.docker
请注意我们如何使用 -v
(或 --volume
)参数将 Docker 套接字挂载到容器中,这样 Traefik 就能与 Docker 引擎交互。我们能够将 Web 流量发送到 Traefik 的端口 80
,然后根据我们在参与容器的元数据中定义的路由规则将流量重新路由。此外,我们还可以通过端口 8080
访问 Traefik 的 Web UI。
-
现在一切都在运行,也就是单体应用、名为
catalog
的第一个微服务,以及 Traefik,我们可以测试一切是否按预期工作。再次使用curl
来测试:$ curl http://acme.com/catalog?type=bicycles$ curl http://acme.com/checkout
正如我们之前提到的,现在我们将所有流量发送到端口 80
,这是 Traefik 监听的端口。这个代理会将流量重新路由到正确的目的地。
-
在继续之前,请停止并删除所有容器:
$ docker container rm -f traefik eshop catalog
本章内容到此为止。
总结
在本章中,我们学习了如何让运行在单一主机上的容器互相通信。首先,我们介绍了 CNM,它定义了容器网络的要求,然后我们看了 CNM 的几种实现方式,如桥接网络。接着,我们详细了解了桥接网络的工作原理,以及 Docker 为我们提供的关于网络和附加到这些网络的容器的各种信息。我们还学到了从容器内外两个不同的视角来观察容器网络。
在下一章中,我们将介绍 Docker Compose。我们将学习如何创建一个由多个服务组成的应用,每个服务都运行在一个容器中,以及 Docker Compose 如何通过声明式方法帮助我们轻松构建、运行和扩展这样的应用。
进一步阅读
这里有一些文章,详细描述了本章中介绍的主题:
-
Docker 网络 概述:
dockr.ly/2sXGzQ
n -
什么是 桥接?:
bit.ly/2HyC3Od
-
使用桥接 网络:
dockr.ly/2BNxjRr
-
使用 Macvlan 网络:
dockr.ly/2ETjy2x
-
使用主机 网络进行网络连接:
dockr.ly/2F4aI59
问题
为了评估你在本章中获得的技能,请尝试回答以下问题:
-
容器网络 模型(CNM)的三个核心要素是什么?
-
如何创建一个名为
frontend
的自定义bridge
网络? -
如何运行两个附加到前端网络的
nginx:alpine
容器? -
对于前端网络,获取以下内容:
-
所有附加容器的 IP 地址
-
与网络相关的子网
-
-
host
网络的作用是什么? -
请举出一到两个使用
host
网络适合的场景。 -
none
网络的作用是什么? -
在什么场景下应该使用
none
网络? -
为什么我们要将反向代理(如 Traefik)与容器化应用一起使用?
答案
以下是本章问题的示例答案:
-
Docker CNM 的三个核心元素如下:
-
沙盒:容器的网络命名空间,其中容器的网络栈存在
-
端点:将容器与网络连接的接口
-
网络:一组可以直接相互通信的端点
-
-
要创建一个名为
frontend
的自定义 Dockerbridge
网络,可以使用docker network create
命令,并将--driver
标志设置为bridge
(这是默认驱动程序),同时使用--subnet
标志指定网络的子网。以下是示例命令:$ docker network create --driver bridge \ --subnet 172.25.0.0/16 frontend
这将创建一个名为 frontend
的桥接网络,子网为 172.25.0.0/16
。然后,你可以在启动容器时使用 --network
选项来使用此网络:
$ docker run --network frontend <docker-image>
-
要运行两个附加到我们之前创建的
frontend
网络的nginx:alpine
容器,可以使用以下docker
run
命令:$ docker run --name nginx1 --network frontend -d nginx:alpine$ docker run --name nginx2 --network frontend -d nginx:alpine
这些命令将启动两个名为 nginx1
和 nginx2
的容器,使用 nginx:alpine
镜像,并将它们连接到 frontend
网络。-d
标志使容器在后台以守护进程方式运行。然后,你可以通过容器名称(nginx1
和 nginx2
)或它们在 frontend
网络中的 IP 地址来访问容器。
-
这是解决方案:
- 要获取所有附加到
frontend
Docker 网络的容器的 IP 地址,可以使用docker network inspect
命令,后跟网络名称。以下是示例命令:
$ docker network inspect frontend --format='{{range .Containers}}{{.IPv4Address}} {{end}}'
-
这将输出所有附加到 frontend 网络的容器的 IPv4 地址,地址之间以空格分隔。
-
要获取与
frontend
网络关联的子网,可以再次使用docker network inspect
命令,后跟网络名称。以下是示例命令:
$ docker network inspect frontend --format='{{json .IPAM.Config}}' | jq -r '.[].Subnet'
- 这将输出与
frontend
网络关联的子网,采用 CIDR 表示法(例如172.25.0.0/16
)。这里使用jq
命令来解析docker network inspect
命令的输出并提取子网信息。
- 要获取所有附加到
-
Docker
host
网络是一种网络模式,允许 Docker 容器使用主机的网络堆栈,而不是创建一个单独的网络命名空间。换句话说,在host
网络模式下运行的容器可以直接访问 Docker 主机的网络接口和端口。
使用 host
网络模式的目的是提高网络性能,因为它避免了容器化和网络虚拟化的开销。此模式通常用于需要低延迟网络通信或需要监听大量端口的应用程序。
然而,使用 host
网络模式也可能带来安全风险,因为它将容器的服务直接暴露在 Docker 主机的网络接口上,这可能使它们能够被同一网络上的其他容器或主机访问。
-
Docker 的
host
网络模式适用于网络性能至关重要且不要求网络隔离的场景。例如,见以下内容:- 在容器化应用程序需要与主机机器上运行的其他服务(如数据库或缓存服务)通信的情况下,使用
host
网络模式可以通过消除网络配置管理的复杂性来提高性能,允许容器使用与主机机器相同的网络接口和 IP 地址,而无需管理容器与主机网络命名空间之间的端口映射。
- 在容器化应用程序需要与主机机器上运行的其他服务(如数据库或缓存服务)通信的情况下,使用
-
Docker 的
none
网络模式的目的是完全禁用容器的网络功能。当容器以none
网络模式启动时,它没有任何网络接口,也无法访问主机机器的网络栈。这意味着该容器无法与外部世界或其他容器通信。
none
网络模式适用于容器不需要网络连接的场景,例如运行一个批处理过程或一个只执行特定任务并随后退出的单次使用容器。它也可以出于安全目的使用,将容器与网络隔离,以防止潜在的网络攻击。
需要注意的是,当容器以none
网络模式启动时,它仍然可以访问自己的文件系统以及挂载到其上的任何卷。然而,如果容器之后需要网络访问,它必须被停止并以不同的网络模式重新启动。
-
Docker 的
none
网络模式适用于容器不需要网络连接的场景,例如以下情况:-
运行一个批处理过程或一个只执行特定任务并随后退出的单次使用容器
-
运行一个不需要与其他容器或主机通信的容器
-
运行一个不需要外部网络访问的容器,例如一个仅用于测试或调试的容器
-
运行一个需要高安全性并与网络隔离的容器
-
-
我们可能会将反向代理(如 Traefik)与容器化应用程序一起使用的原因有几个:
-
负载均衡:反向代理可以将传入流量分配到运行在不同容器上的多个应用实例,确保没有单一实例因请求过多而被压垮。
-
路由:使用反向代理,我们可以根据 URL 或域名将传入的请求路由到适当的容器。这允许我们在同一主机上运行多个应用程序,每个应用程序都有自己独特的域名或 URL。
-
SSL/TLS 终止:反向代理可以终止 SSL/TLS 连接并处理证书管理,从而消除我们应用程序需要自行处理这些事务的需求。这可以简化我们的应用代码,并降低安全漏洞的风险。
-
安全性:反向代理可以充当我们应用程序与公共互联网之间的缓冲区,提供额外的安全层。例如,它可以阻止某些类型的流量或过滤掉恶意请求。
-
可扩展性:通过使用如 Traefik 这样的反向代理,我们可以通过添加或移除容器,快速而轻松地扩展应用程序。反向代理可以自动将流量路由到适当的容器,使得管理应用程序的基础设施变得更加容易。
-
第十一章:11
使用 Docker Compose 管理容器
在上一章中,我们学到了很多关于容器网络如何在单一 Docker 主机上工作的知识。我们介绍了容器网络模型(CNM),它构成了所有 Docker 容器之间网络通信的基础,随后我们深入探讨了 CNM 的不同实现,特别是桥接网络。最后,我们介绍了 Traefik,一个反向代理,用于在容器之间实现复杂的 HTTP 应用级路由。
本章介绍了由多个服务组成的应用程序的概念,每个服务都运行在一个容器中,以及 Docker Compose 如何通过声明式方法让我们轻松构建、运行和扩展这样的应用程序。
本章涵盖以下主题:
-
解密声明式与命令式的容器编排方式
-
运行一个多服务应用程序
-
使用 Docker Compose 构建镜像
-
使用 Docker Compose 运行应用程序
-
扩展服务
-
构建和推送一个应用程序
-
使用 Docker Compose 覆盖
完成本章后,你将能够做到以下几点:
-
简要解释定义和运行应用程序时,命令式方法与声明式方法的主要区别
-
用你自己的话描述容器和 Docker Compose 服务之间的区别
-
为一个简单的多服务应用程序编写 Docker Compose YAML 文件
-
使用 Docker Compose 构建、推送、部署和拆除一个简单的多服务应用程序
-
使用 Docker Compose 扩展应用程序服务的规模
-
使用覆盖定义环境特定的 Docker Compose 文件
技术要求
本章配套的代码可以在这里找到:github.com/PacktPublishing/The-Ultimate-Docker-Container-Book/tree/main/sample-solutions/ch11
。
在开始之前,让我们确保你已经准备好一个文件夹,用来存放你将在本章实现的代码:
-
进入你之前克隆的与本书配套的代码仓库所在的文件夹。通常这个文件夹是你主文件夹中的
The-Ultimate-Docker-Container-Book
文件夹:$ cd ~/The-Ultimate-Docker-Container-Book
-
创建一个名为
ch11
的子文件夹并进入该文件夹:$ mkdir ch11 && cd ch11
过去,你需要在系统上安装一个单独的 docker-compose 工具。但现在不再需要,因为 Docker CLI 最近进行了扩展,已经包含了 docker-compose 工具之前提供的所有功能及更多功能。
如果你感兴趣,你可以在这里找到旧版 docker-compose
工具的详细安装说明:docs.docker.com/compose/install/
。
解密声明式与命令式的容器编排方式
Docker Compose 是 Docker 提供的一个工具,主要用于在单个 Docker 主机上运行和协调容器。它的应用场景包括但不限于开发、持续集成(CI)、自动化测试、手动 QA 或演示。最近,Docker Compose 已经被嵌入到正常的 Docker CLI 中。
Docker Compose 使用 YAML 格式的文件作为输入。默认情况下,Docker Compose 期望这些文件被命名为docker-compose.yml
,但也可以使用其他名称。docker-compose.yml
文件的内容被称为描述和运行一个可能包含多个容器的容器化应用程序的声明式方式。
那么,声明式是什么意思?
首先,声明式是命令式的反义词。嗯,这并没有太大帮助。既然我介绍了另一个定义,那么我需要解释一下这两个词:
- 命令式:这是一种通过指定系统必须遵循的精确步骤来解决问题的方法。
如果我以命令式的方式告诉系统(例如 Docker 守护进程)如何运行一个应用程序,那么这意味着我必须一步一步地描述系统需要做什么,并且如果发生一些意外情况,系统应该如何反应。我必须非常明确和精确地给出指示,覆盖所有的边界情况,并且说明如何处理它们。
- 声明式:这是一种不要求程序员指定精确步骤来解决问题的方法。
声明式方法意味着我告诉 Docker 引擎我希望应用程序达到的目标状态,而它必须自行弄清楚如何实现这个目标状态,并且在系统偏离目标状态时如何将其恢复。
Docker 明确推荐在处理容器化应用程序时采用声明式方法。因此,Docker Compose 工具也采用了这种方法。
运行多服务应用程序
在大多数情况下,应用程序并不只包含一个单一的整体模块,而是由多个协同工作的应用服务组成。在使用 Docker 容器时,每个应用服务运行在自己的容器中。当我们想要运行这样一个多服务的应用程序时,当然可以使用大家熟悉的docker container run
命令来启动所有参与的容器,这也是我们在前几章中做过的。但这最多只能算是低效的做法。有了 Docker Compose 工具,我们可以通过声明式的方式,在一个使用 YAML 格式的文件中定义应用程序。
让我们创建并分析一个简单的docker-compose.yml
文件:
-
在本章的文件夹(
ch11
)内,创建一个名为step1
的子文件夹并进入:$ mkdir step1 && cd step1
-
在此文件夹内,添加一个名为
docker-compose.yml
的文件,并将以下代码片段添加到文件中:
图 11.1 – 简单的 Docker Compose 文件
文件中的各行解释如下:
-
第 1 行:
version
– 在这一行中,我们指定了要使用的 Docker Compose 格式版本。在撰写本文时,这是版本3.8
。 -
第 2 至第 21 行:
services
– 在这一部分,我们在services
块中指定了构成我们应用程序的服务。在我们的示例中,我们有两个应用程序服务,分别命名为db
和pgadmin
。 -
第 3 至第 11 行:
db
–db
服务使用的镜像名称为postgres:alpine
,这是基于 Alpine Linux 的 PostgreSQL 数据库的最新版本:-
第 4 行:
image
– 在这里,我们定义了用于该服务的 Docker 镜像。如前所述,我们使用的是带有alpine
标签的精选postgres
镜像。由于没有指定版本号,它将使用基于 Alpine 的 PostgreSQL 镜像的最新稳定版本。 -
第 5 至第 8 行:
environment
– 在这里,我们定义了在运行中的 PostgreSQL 服务内可以访问的环境变量。在此,我们定义了默认的用户名、密码和数据库名称。 -
第 9 至第 11 行:
volumes
– 我们定义了两个卷映射。 -
第 10 行:我们将一个名为
pg-data
的卷映射到/var/lib/postgresql/data
容器文件夹。这是 PostgreSQL 默认存储数据的位置。这样,数据将被持久化到pg-data
卷中,并且在db
服务重启后仍然存在。 -
第 11 行:在这种情况下,我们将主机文件夹
./db
映射到一个名为/docker-entrypoint-initdb.d
的容器文件夹。这是 PostgreSQL 在首次启动数据库时期望运行的初始化文件所在的文件夹。在我们的例子中,我们将使用它来定义一个名为init-db.sql
的数据库初始化脚本。
-
-
第 13 至第 21 行:
pgadmin
–pgadmin
服务使用的 Docker 镜像包含了一个流行的 PostgreSQL 和类似数据库的管理工具Pg4Admin
。我们将一个名为pgadmin-data
的卷挂载到db
服务的容器中:-
第 14 行:
image
– 此服务使用的是dpage/pgadmin4
镜像。请注意,我们没有为该镜像定义任何标签,因此它将自动使用最新版本。 -
第 15 至第 16 行:
ports
– 在这里,我们定义了要映射到主机的容器端口。在这种情况下,我们将默认的Pg4Admin
端口80
映射到主机端口5050
。这样,我们就可以通过浏览器窗口访问这个端口的管理工具,稍后我们将看到这一点。 -
第 17 至第 19 行:
environment
– 在这里,我们定义了在运行中的Pg4Admin
工具容器内可以访问的环境变量。这是我们登录工具时所需的电子邮件和密码。 -
第 20 至第 21 行:
volumes
– 我们将一个名为pgadmin-data
的 Docker 卷映射到容器内的/var/lib/pgadmin
文件夹。这是该工具存储其数据的位置,使得即使工具容器重启,数据也能得以保留。
-
-
第 23–25 行:
volumes
– 任何服务使用的卷必须在此部分声明。在我们的示例中,这是文件的最后一部分。当应用程序首次运行时,Docker 将创建名为pg-data
和pgadmin-data
的卷,然后,在后续运行中,如果卷仍然存在,它们将被重用。如果应用程序由于某种原因崩溃并且必须重新启动,则之前的数据仍然可用,并准备好供重新启动的数据库服务使用。
- 在
step1
文件夹中创建一个名为db
的文件夹,并添加一个名为init-db.sql
的文件,其内容如下:
图 11.2 – 数据库初始化脚本
如果你不想键入所有前面的内容,你可以在这里找到该文件:github.com/PacktPublishing/The-Ultimate-Docker-Container-Book/blob/main/sample-solutions/ch11/step1/db/init-db.sql
。稍后你会看到,这个文件将用于初始化我们的数据库,并添加一些初始架构和数据。
-
接下来,让我们看看如何通过 Docker Compose 运行服务。请从包含
docker-compose.yml
文件的step1
文件夹中执行以下命令:$ docker compose up
让我们分析上一个命令生成的输出:
-
前几行告诉我们,Docker 正在拉取
db
和pgadmin
服务的镜像 -
接下来的几行指示 Docker 正在自动创建一个名为
step1_default
的新网络 -
两个名为
step1_pgadmin-data
和step1-pg-data
的卷 -
两个容器实例分别称为
step1-db-1
和step1-pgadmin-1
图 11.3 – 为 Docker Compose 应用程序创建资源
注意以上所有资源都添加了step1_
前缀。这是docker-compose.yml
所在的文件夹名称,应用程序启动的地方,与下划线字符结合使用。
- 现在,让我们看一下蓝色输出中的第三部分。在这里,数据库正在启动:
图 11.4 – 启动数据库
- 数据库初始化的第二部分看起来像这样:
图 11.5 – 使用提供的脚本初始化数据库
-
我们已经简化了输出的第二部分。它展示了数据库如何完成其初始化过程。我们可以具体看到我们的初始化脚本
init-db.sql
是如何应用的,它定义了一个数据库并添加了一些数据。 -
输出的最后一行告诉我们,PostgreSQL 数据库现在已经启动并运行,准备接受传入的连接。连接预计会发生在端口
5432
,这一点可以从前面输出中的倒数第四和倒数第五行看出。
提示
如果由于某些原因你的卷映射不起作用——可能在卷映射部分有拼写错误等——你可以通过使用 docker compose down -v
命令重新开始,其中 -v
参数指示 Docker 删除与此应用程序相关的所有卷。
- 最后是
pgadmin
工具的初始化,显示为黄色:
图 11.6 – 启动 pgadmin 工具
- 现在我们准备好了。让我们打开一个新的浏览器窗口并访问
http://localhost:5050
。当系统提示时,使用在docker-compose.yml
文件中为pgadmin
工具定义的用户名(邮箱)和密码登录,即邮箱为admin@acme.com
,密码为admin
。添加一个名为demo
的服务器,并填写以下连接详情:
图 11.7 – Pg4Admin 连接详情
请注意,主机名/地址 db
对应于我们在 docker-compose 文件中定义的数据库服务名称。端口 5432
是 PostgreSQL 数据库使用的默认端口,用户名和密码也对应我们在 docker-compose 文件中为数据库定义的内容。
-
一旦连接到数据库,确保你能找到
pets
数据库及其中的images
表。使用该工具检索images
表中的所有记录。你应该能找到我们在init-db.sql
初始化脚本中定义的 12 条记录。 -
在继续之前,你可以通过在终端窗口中按下 Ctrl + C 停止应用程序,确保该应用程序仍在运行。然后,运行以下命令:
$ docker compose down -v
这是为了确保应用容器、网络和卷被移除。你应该在终端中看到以下输出:
[+] Running 5/5⠿ Container step1-pgadmin-1 Removed 0.0s
⠿ Container step1-db-1 Removed 0.0s
⠿ Volume step1_pgadmin-data Removed 0.0s
⠿ Volume step1_pg-data Removed 0.1s
⠿ Network step1_default Removed 0.1s
特别要注意使用 -v
命令行参数,它告诉 Docker 强制删除由应用程序创建和使用的卷。请谨慎使用此参数,因为它会销毁所有已持久化到这些卷中的数据。
很棒,成功了。你已经学会了如何使用 docker-compose.yml
文件声明式地定义由多个服务组成的应用程序,并通过简单的 docker compose
up
命令启动它。
接下来,你将学习如何使用 Docker Compose 构建自定义镜像。
使用 Docker Compose 构建镜像
为了演示如何使用 Docker Compose 构建 Docker 镜像,我们需要一个小型应用程序。请按以下步骤操作:
-
在章节文件夹(
ch11
)中,创建一个子文件夹step2
并进入该文件夹:mkdir step2 && cd step2
-
从之前的练习中,复制包含数据库初始化脚本的
db
文件夹到step2
文件夹,并且也复制docker-compose.yml
文件:$ cp -r ../step1/db .$ cp ../docker-compose.yml .
-
在
step2
文件夹中创建一个名为web
的文件夹。这个文件夹将包含一个简单的 Express.js 网页应用程序。 -
在这个文件夹中添加一个名为
package.json
的文件,并添加以下内容:
图 11.8 – 示例网页应用程序的 package.json 文件
注意
如果你不喜欢自己打字,总是可以从样例解决方案中下载文件:github.com/PacktPublishing/The-Ultimate-Docker-Container-Book/tree/main/sample-solutions/ch11/step2
。
-
在
web
文件夹中创建一个名为src
的文件夹。 -
在
src
文件夹中添加一个名为server.js
的文件,并添加以下内容:
图 11.9 – 示例网页应用程序的 server.js 文件
这个文件包含了我们简单网页应用程序的完整逻辑。特别感兴趣的是第 32 至 40 行关于/animal
端点的逻辑。还请注意,我们使用类型为Pool
的常量pool
连接到 PostgreSQL 数据库(第 15 至 21 行)。用户名、密码和数据库名称应与我们为数据库定义的相匹配。
- 在
src
文件夹中添加另一个名为index.xhtml
的文件,并添加以下内容:
图 11.10 – 示例网页应用程序的 index.xhtml 文件
这个文件用作展示野生动物图片的模板。
-
在
web
文件夹中添加一个名为public/css
的文件夹:mkdir -p public/css
-
在这个
public/css
文件夹中添加一个名为main.css
的文件,我们将用它来为我们的示例网页应用程序添加样式。将以下内容添加到文件中:
图 11.11 – 示例网页应用程序的 main.css 文件
-
现在我们需要一些真实的图片来展示。最简单的方法是从 GitHub 复制我们的示例图片:
-
在
public
文件夹中创建一个名为images
的文件夹。 -
然后,将所有图片下载到这个
images
文件夹中,你可以在这里找到:github.com/PacktPublishing/The-Ultimate-Docker-Container-Book/tree/main/sample-solutions/ch11/step2/web/public/images
。
-
-
现在我们需要对从
step1
文件夹复制过来的docker-compose.yml
文件进行小小的修改。在step2
文件夹中找到docker-compose.yml
文件,打开它,在第 4 行之后添加这个片段:ports:- 5432:5432
结果应该看起来像这样:
图 11.12 – 向 db 服务添加主机端口映射
这样,我们就可以从主机上运行的任何应用程序访问数据库。我们将在接下来的步骤中使用这个功能。
-
现在,我们准备好运行并测试这个应用程序了:
- 使用 docker-compose 文件和以下命令运行数据库:
$ docker compose up db --detach
我们告诉 Docker Compose 仅启动 db 服务,并以
detach
模式运行,--detach
参数表示这一点。- 导航到
web
文件夹:
$ cd web
- 使用以下命令安装所有依赖:
$ npm install
- 使用以下命令运行应用程序:
$ npm run start
你应该看到这个:
图 11.13 – 本地运行 web 应用程序
- 打开浏览器标签并访问
localhost:3000/animal
,你应该看到类似的内容:
图 11.14 – Web 应用程序正在运行并显示一只野生动物
-
刷新浏览器几次,注意每次都会显示一个新的随机动物。
-
在你离开之前,确保停止 web 应用程序并使用
docker
compose down
停止其他容器。
很棒,现在我们可以进入下一步,我们将 Docker 化 web 应用程序,并使用 Docker Compose 构建镜像:
- 向
web
文件夹中添加一个名为Dockerfile
的文件,并添加以下代码段:
图 11.15 – Web 应用程序的 Dockerfile
分析这个 Dockerfile,尝试理解它的具体作用。如有需要,请参考你在 第四章 中学到的内容,创建和管理容器镜像。
- 打开
step2
文件夹中的docker-compose.yml
文件,并在db
和pgadmin
服务之后、volumes
部分之前(即第 24 行之后)添加web
服务的定义。要添加的代码段如下所示:
图 11.16 – 在 docker-compose.yml 文件中定义名为 web 的服务
确保在第 2 行,将 gnschenker
用户名替换为你自己的 Docker Hub 用户名。
-
使用此命令构建镜像:
$ docker compose build web
前面的命令假设你在 step2
文件夹中,并且该文件夹中有一个 docker-compose.yml
文件。
在构建镜像时,Docker 会查找并使用 web
文件夹中的 Dockerfile,如前面代码段第 3 行中的 build: web
指令所示。
使用 Docker Compose 构建镜像,使用以下指令:
-
打开一个终端窗口。
-
确保你在
The-Ultimate-Docker-Container-Book
文件夹的ch11/step2
子文件夹中:$ cd ~/ The-Ultimate-Docker-Container-Book/ch11/step2
-
然后,构建镜像:
$ docker compose build
如果我们输入上述命令,工具会假定当前目录下必须有一个名为 docker-compose.yml
的文件,并会使用该文件来运行。在我们的案例中,确实如此,工具将构建镜像。
- 观察终端窗口中的输出。你应该看到类似这样的内容:
图 11.17 – 为 web 服务构建 Docker 镜像
在前面的截图中,你可以看到 docker-compose 首先从 Docker Hub 下载了基础镜像 node:19.7-alpine
,这是我们为 web
镜像构建所用的镜像。随后,它使用位于 web
文件夹中的 Dockerfile 来构建镜像,并将其命名为 gnschenker/ch11-web:2.0
。
在为 web
服务构建 Docker 镜像之后,我们已准备好使用 Docker Compose 来运行整个多服务应用程序。
使用 Docker Compose 运行应用程序
一旦我们构建了镜像,就可以使用 Docker Compose 启动应用程序:
$ docker compose up
输出内容与我们在上一节中讨论的 db
和 pgadmin
服务的输出类似,web
服务的输出如下:
step2-web-1 | DB_HOST: dbstep2-web-1 | Application listening on port 3000
这表明容器化的 web
服务已准备好,并正在监听端口 3000
。巧合的是,我们在 docker-compose.yml
文件中已将容器端口 3000
映射到主机上的相同端口 3000
。因此,我们只需打开一个新的浏览器标签页并导航到 URL localhost:3000/animal
;我们应该再次看到一只野生动物展示出来。
刷新浏览器几次以查看其他动物图像。应用程序会从存储在数据库中的 12 张图像的 URL 集合中随机选择当前的图像。
由于应用程序正在以交互模式运行,因此我们运行 Docker Compose 的终端被阻塞,我们可以通过按 Ctrl + C 来取消应用程序。如果我们这样做,我们将看到以下内容:
^CGracefully stopping... (press Ctrl+C again to force)Aborting on container exit...
[+] Running 3/3
⠿ Container step2-pgadmin-1 Stopped 1.4s
⠿ Container step2-web-1 Stopped 10.2s
⠿ Container step2-db-1 Stopped 0.1s
canceled
我们会注意到,db
和 web
服务会立即停止。然而,有时一些服务可能需要大约 10 秒钟才能停止。出现这种情况的原因是 db
和 web
服务会监听并响应 Docker 发送的 SIGTERM
信号,而其他服务则可能不会,因此 Docker 会在 10 秒的预定义超时间隔后强制终止它们。
如果我们再次使用 docker compose up
启动应用程序,启动速度将会更快,因为数据库不需要从头开始初始化,而是只重新使用之前运行时存储在 pg-data
卷中的数据。
我们也可以将应用程序在后台运行。所有容器将作为守护进程运行。为此,我们只需要使用 -d
参数,如下所示的代码:
$ docker compose up -d
Docker Compose 提供的命令远不止 up
。我们还可以使用该工具列出作为应用程序一部分的所有服务:
$ docker compose ps
我们应该看到以下内容:
图 11.18 – docker compose ps 的输出
这个命令与 docker container ls
类似,唯一的区别是 docker-compose 只列出属于应用程序的容器或服务。
要停止并清理应用程序,我们使用 docker compose
的 down
命令:
$ docker compose down
这应该会输出类似于下面的内容:
⠿ Container step2-web-1 Removed 10.2s⠿ Container step2-pgadmin-1 Removed 0.9s
⠿ Container step2-db-1 Removed 0.1s
⠿ Network step2_default Removed 0.1s
如果我们还想移除数据库的卷,可以使用以下命令:
$ docker volume rm step2_pd-data step2_pgadmin-data
或者,除了使用两个命令 docker compose down
和 docker volume rm <volume name>
,我们可以将它们合并为一个命令:
$ docker compose down -v
这里,参数 -v
(或 --volumes
)移除 Docker Compose 文件 volumes
部分声明的命名卷和附加到容器的匿名卷。
为什么卷的名称中有一个 step2
前缀?在 docker-compose.yml
文件中,我们调用了 pg-data
和 pgadmin-data
作为要使用的卷。但正如我们之前提到的,Docker Compose 会在所有名称前加上 docker-compose.yml
文件所在父文件夹的名称,并加上下划线。在这个例子中,父文件夹名为 step2
。如果你不喜欢这种方式,可以显式地定义项目名称,例如,像这样:
$ docker compose --project-name demo up --detach
这样,所有资源(容器、网络和卷)将以项目名称为前缀,在本例中为 demo
。
扩展服务
现在,让我们暂时假设我们的示例应用程序已经上线并且非常成功。很多人想查看我们可爱的动物图片。但现在我们面临一个问题:我们的应用程序开始变得缓慢。为了解决这个问题,我们希望运行多个 web
服务实例。使用 Docker Compose,这可以轻松实现。
运行更多实例也称为横向扩展。我们可以使用这个工具将 web
服务扩展到,例如,三实例:
$ docker compose up --scale web=3
如果我们这样做,会有一个惊喜等着我们。输出将会像下图所示:
图 11.19 – docker-compose --scale 的输出
第二个和第三个 web
服务实例无法启动。错误信息告诉我们为什么不能多次使用相同的主机端口 3000
。当实例 2 和 3 尝试启动时,Docker 发现端口 3000
已被第一个实例占用。我们该怎么办?嗯,我们可以让 Docker 自动选择每个实例使用的主机端口。
如果在 Docker Compose 文件的 ports
部分,我们只指定容器端口而省略主机端口,那么 Docker 会自动选择一个临时端口。我们就来做这个操作:
-
首先,让我们拆除应用程序:
$ docker compose down
-
然后,我们修改
docker-compose.yml
文件。web
服务的端口映射原本是这样的:ports:- 3000:3000
我们将其更改为如下所示:
ports:– 3000
这样,Docker 将动态分配主机端口。
-
现在,我们可以重新启动应用程序并立即扩展它:
$ docker compose up -d$ docker compose up -d --scale web=3
我们应该看到如下内容:
[+] Running 5/5⠿ Container step2-pgadmin-1 Started 0.3s
⠿ Container step2-db-1 Started 0.3s
⠿ Container step2-web-1 Started 0.6s
⠿ Container step2-web-3 Started 0.8s
⠿ Container step2-web-2 Started 1.0s
- 如果我们现在执行
docker compose ps
,我们应该看到如下截图中的内容:
图 11.20 – docker compose ps 命令的输出
请注意,在你的情况下,主机端口可能与前面截图中显示的不同。那里,我们为三个web
实例映射了端口590076
、59078
和59077
。
-
我们可以尝试检查那些端口映射是否有效,例如,使用
curl
。让我们测试第三个实例step2_web-3
:$ curl -4 localhost:59077
我们应该看到如下内容:
Wild Animals of Massai Mara National Park%
答案告诉我们,实际上我们的应用程序仍然按预期工作。为确保正确,请对其他两个实例进行测试。
在接下来的章节中,我们将学习如何构建并随后将应用程序容器镜像推送到镜像仓库。
构建和推送应用程序
我们之前看到,我们也可以使用docker-compose build
命令仅构建基础docker-compose
文件中定义的应用程序镜像。但要使其生效,我们必须将构建信息添加到docker-compose
文件中:
-
打开一个新的浏览器窗口并导航到章节文件夹(
ch11
):$ cd ~/The-Ultimate-Docker-Container-Book/ch11
-
创建一个名为
step3
的子文件夹,并导航到该文件夹:$ mkdir step3 && cd step3
-
将
docker-compose.yml
文件从step2
文件夹复制到此新文件夹:$ cp ../step2/docker-compose.yml .
-
打开复制的文件,请注意以下截图中第 3 行 web 服务的
build
键。该键的值指示 Docker 期望在何处找到 Dockerfile 来构建相应的镜像。
图 11.21 – docker-compose.yml 文件中 web 服务的 build 键
请注意第 8 和第 9 行中的depends_on
部分。这指示 Docker Compose 确保db
服务在web
服务之前启动。
-
如果我们想使用一个名称不同的 Dockerfile,例如
Dockerfile.dev
,用于web
服务,那么docker-compose
文件中的build
块将如下所示:build: context: web dockerfile: Dockerfile.dev
- 将
step2
文件夹中的web
和db
文件夹及其内容复制到step3
文件夹中:
$ cp -r ../step2/web .$ cp -r ../step2/db .
- 在
web
文件夹中创建一个名为Dockerfile.dev
的文件,它只是现有 Dockerfile 的副本:
$ cp web/Dockerfile web/Dockerfile.dev
- 在
step3
文件夹中添加一个新文件docker-compose.dev.yml
,它是docker-compose.yml
文件的副本:
$ cp ../step2/docker-compose.yml docker-compose.dev.yml
- 根据前面的代码片段修改
build
块。
- 将
-
现在我们使用那个替代的
docker-compose.dev.yml
文件:$ docker-compose -f docker-compose.dev.yml build
-f
参数将告诉 Docker Compose 应用程序使用哪个 Docker Compose 文件。
-
要将所有镜像推送到 Docker Hub,我们可以使用
docker compose push
:- 我们需要登录到 Docker Hub,才能使其成功;否则,在推送时会出现身份验证错误。因此,在我的情况下,我进行如下操作:
$ docker login -u gnschenker -p <password>
- 假设登录成功,我就可以推送以下代码:
$ docker-compose -f docker-compose.dev.yml push
根据您的互联网连接带宽,这可能需要一段时间。在推送过程中,您的屏幕可能会显示如下:
图 11.22 – 使用 docker-compose 将镜像推送到 Docker Hub
上述命令将 web
服务的 Docker 镜像推送到 Docker Hub 上的 gnschenker
账户。Docker Compose 文件中的另外两个服务 db
和 pgadmin
将被忽略。
注意
确保你使用的是自己的 Docker Hub 账户,而不是 gnschenker
。
我们要讨论的 Docker Compose 的最后一个功能是覆盖设置。
使用 Docker Compose 覆盖设置
有时,我们需要在不同的环境中运行应用程序,这些环境需要特定的配置设置。Docker Compose 提供了一个便捷的功能来解决这个问题。
让我们通过一个具体的示例来说明:
-
打开一个新的浏览器窗口,导航到本章的文件夹(
ch11
):$ cd ~/The-Ultimate-Docker-Container-Book/ch11
-
创建一个名为
step4
的子文件夹并进入该文件夹:$ mkdir step4 && cd step4
-
将
step2
文件夹中的web
和db
文件夹及其内容复制到step4
文件夹中:$ cp -r ../step2/web .$ cp -r ../step2/db .
-
我们可以定义一个基础的 Docker Compose 文件,然后定义针对不同环境的覆盖设置。假设我们有一个名为
dockercompose.base.yml
的文件,其内容如下:
图 11.23 – 用作基础的 Docker Compose 文件
这仅定义了在所有环境中应该相同的部分。所有特定的设置都已被提取出来。
- 现在假设我们要在
docker-compose.ci.yml
中运行该应用程序,并添加以下代码片段:
图 11.24 – 用于 CI 的 Docker Compose 文件
-
我们可以使用以下命令运行该应用程序:
$ docker compose -f docker-compose.base.yml \ -f docker-compose.ci.yml up \ -d --build
请注意,在第一个 -f
参数中,我们提供了基础的 Docker Compose 文件,而在第二个参数中,我们提供了覆盖文件。--build
参数指示 Docker Compose 在启动服务之前重新构建所有 Docker 镜像。
警告
使用环境变量时,请注意以下几点:
-
在 Docker 文件中声明它们定义了一个默认值
-
在 Docker Compose 文件中声明相同的变量会覆盖 Dockerfile 中的值
通过这一点,我们已经完成了关于 Docker Compose 的本章内容。但在结束之前,我们有一个小提示要给你。
提示
如果我们遵循标准命名约定,将基础文件命名为 docker-compose.yml
,并将覆盖文件命名为 docker-compose.override.yml
,那么我们就可以使用 docker-compose up -d
启动应用程序,而无需显式指定 Docker Compose 文件。在其他情况下,我们需要使用以下完整且更详细的语法:
$ docker compose -f <基础 compose 文件> -f <覆盖 compose 文件> up
总结
在本章中,我们介绍了 docker-compose 工具。该工具主要用于在单个 Docker 主机上运行和扩展多服务应用程序。通常,开发人员和 CI 服务器使用单一主机,而这两者是 Docker Compose 的主要用户。该工具使用 YAML 文件作为输入,文件以声明的方式描述应用程序。
该工具还可以用于构建和推送镜像,以及许多其他有用的任务。
在下一章中,我们将讨论为什么日志记录和监控非常重要,并展示如何收集容器日志并将其发送到一个中央位置,在那里可以解析聚合的日志以获取有用的信息。
你还将学习如何为应用程序添加监控功能,使其能够暴露度量数据,以及如何抓取这些度量数据并再次将其发送到中央位置。最后,你将学习如何将收集到的度量数据转换为图形化的仪表盘,用于监控容器化的应用程序。
进一步阅读
以下链接提供了本章讨论主题的更多信息:
-
官方 YAML 网站:
www.yaml.org/
-
Docker Compose 文档:
dockr.ly/1FL2VQ6
-
Docker Compose 文件版本 2 参考资料:
docs.docker.com/compose/compose-file/compose-file-v2/
-
Docker Compose 文件版本 3 参考资料:
docs.docker.com/compose/compose-file/compose-file-v3/
-
在文件和 项目之间共享 Docker Compose 配置:
docs.docker.com/compose/extends/
问题
为了评估你对 Docker Compose 的学习,请回答以下问题:
-
Docker Compose 是什么,它的用途是什么?
-
什么是 Docker Compose 文件,它可能包含哪些关键元素?
-
如何使用 Docker Compose 启动和停止一个应用程序,并且一些关键的命令行选项有哪些?
-
使用 Docker Compose 来管理多容器应用程序有什么一些好处?
-
如何使用 docker-compose 以守护进程模式运行一个应用程序?
-
如何使用 docker-compose 显示正在运行的服务的详细信息?
-
如何将特定的
web
服务扩展到三实例?
答案
下面是一些 第十一章 问题的示例答案:
-
Docker Compose 是一个用于定义和运行多容器 Docker 应用程序的工具。它允许你在一个 YAML 文件中定义构成应用程序的服务,然后通过一个命令来运行和管理这些服务。
-
Docker Compose 文件是一个 YAML 文件,定义了构成 Docker 应用程序的服务,以及任何相关的配置选项。Docker Compose 文件可能包含的一些关键元素如下:
-
version
: 要使用的 Docker Compose 文件语法的版本。此字段是必需的。 -
services
: 应用程序所包含的服务列表,以及任何相关的配置选项。 -
ports
: 需要为相应服务打开并映射到相应容器端口的端口列表。 -
networks
: 需要为应用程序创建的任何自定义网络。请注意,服务可以同时连接到多个网络。 -
volumes
: 需要为应用程序创建的任何卷。
-
下面是一个使用多个网络的(缩短的)示例 docker-compose.yml
文件:
services: web:
image: <some image>
network:
- front
accounting:
image: <some other image>
network:
- front
- back
db:
image: postgres:latest
network:
- back
networks:
front:
back:
-
要使用 Docker Compose 启动应用程序,可以使用
docker compose up
命令。该命令读取 Docker Compose 文件,创建任何必要的容器,并启动服务。要停止应用程序,可以使用docker compose down
命令。这些命令可以与以下关键命令行选项一起使用:-
-d
或--detach
: 在后台运行容器并打印新的容器名称。 -
-p
或--project-name
: 指定替代项目名称。 -
--build
: 在启动容器之前构建镜像。
-
-
使用 Docker Compose 管理多容器应用程序的一些好处包括以下内容:
-
简化部署过程:Docker Compose 允许您在单个文件中定义应用程序的服务和配置,这可以简化部署过程,并使复杂应用程序的管理变得更加容易。
-
促进协作:通过使用 Docker Compose 文件定义应用程序,开发人员可以轻松与他人共享其开发环境,并更有效地进行协作。
-
增强可移植性:Docker Compose 允许您以便携方式定义应用程序的环境和依赖关系,这可以使应用程序在不同的环境和基础设施提供商之间更容易移动。
-
-
要以守护程序(或
detach
)模式运行应用程序服务,请使用以下命令:$ docker compose up --detach
-
要显示 Docker Compose 应用程序中运行服务的详细信息,请使用以下命令:
$ docker compose ps
-
例如,要将 Docker Compose 应用程序的
web
服务扩展到三个实例,请使用以下命令:$ docker compose up web --scale 3
第十二章:12
发送日志和监控容器
在上一章中,我们介绍了 Docker Compose 工具。我们了解到,该工具主要用于在单一 Docker 主机上运行和扩展多服务应用。通常,开发人员和 CI 服务器使用单主机,它们是 Docker Compose 的主要用户。我们看到,该工具使用 YAML 文件作为输入,文件以声明式方式描述应用。我们探讨了该工具可用于的许多有用任务,例如构建和推送镜像,只是列举其中最重要的一些。
本章讨论了日志记录和监控为何如此重要,并展示了如何收集容器日志并将其发送到中央位置,在那里聚合的日志可以被解析以提取有用信息。
你还将学习如何为应用添加监控,使其暴露指标,以及如何抓取并再次将这些指标发送到中央位置。最后,你将学习如何将这些收集到的指标转换为图形仪表盘,用于监控容器化应用。
我们将使用 Filebeat 作为示例,从 Docker 将日志默认指向的/var/lib/docker/containers
位置收集日志。在 Linux 上这非常简单。幸运的是,在生产环境或类似生产的系统中,我们通常会选择 Linux 作为操作系统。
在 Windows 或 Mac 机器上收集指标,较之 Linux 机器,稍微复杂一些。因此,我们将生成一个特殊的 Docker Compose 堆栈,包括 Filebeat,可以通过将标准日志输出重定向到一个文件,并将该文件的父文件夹映射到 Docker 卷来在 Mac 或 Windows 计算机上运行。这个卷随后会挂载到 Filebeat 上,Filebeat 再将日志转发到 Elasticsearch。
本章涵盖以下主题:
-
为什么日志记录和监控如此重要?
-
发送容器和 Docker 守护进程日志
-
查询集中日志
-
收集和抓取指标
-
监控容器化应用
阅读完本章后,你应该能够完成以下操作:
-
为你的容器定义日志驱动
-
安装代理以收集并发送容器和 Docker 守护进程日志
-
在聚合日志中执行简单的查询,找出有趣的信息
-
为你的应用服务添加监控,使其暴露基础设施和业务指标
-
将收集到的指标转换为仪表盘以监控你的容器
技术要求
本章相关的代码可以在github.com/PacktPublishing/The-Ultimate-Docker-Container-Book/tree/main/sample-solutions/ch12
找到。
在我们开始之前,确保你已经准备好一个文件夹,用于存放你将在本章中实现的代码。
进入你克隆的代码库所在的文件夹,这个文件夹通常是位于你home
文件夹中的The-Ultimate-Docker-Container-Book
文件夹:
$ cd ~/The-Ultimate-Docker-Container-Book
创建一个名为ch12
的子文件夹并进入该文件夹:
$ mkdir ch12 && cd ch12
不再多说,让我们深入探讨第一个话题:集装箱和守护进程日志。
为什么日志记录和监控很重要?
在处理生产环境或任何类似生产环境的分布式关键任务应用程序时,获取尽可能多的应用内部运行状况的洞察是至关重要的。你是否有机会调查过飞机的驾驶舱或核电站的指挥中心?飞机和电厂都是高度复杂的系统,提供关键任务服务。如果飞机坠毁或电厂意外停运,至少可以说会有很多人受到负面影响。因此,驾驶舱和指挥中心充满了仪器,显示着系统某些部分的当前状态或过去的状态。你在这里看到的,是一些放置在系统关键部分的传感器的视觉表现,这些传感器不断地收集诸如温度或流量等数据。
类似于飞机或电厂,我们的应用程序需要配备“传感器”,这些传感器能够感知我们应用服务或其运行基础设施的“温度”。我将“温度”一词加上了引号,因为它只是一个占位符,代表应用中真正重要的事物,比如某个 RESTful 接口每秒的请求数,或是对同一接口的请求的平均延迟。
我们收集到的结果值或读数,比如请求的平均延迟,通常被称为指标。我们的目标应该是暴露尽可能多的应用服务的有意义的指标。指标可以是功能性指标或非功能性指标。功能性指标是与应用服务的业务相关的值,例如,如果服务是电子商务应用的一部分,则每分钟的结账次数,或者如果我们谈论的是流媒体应用,则过去 24 小时内最受欢迎的 5 首歌曲。
非功能性指标是一些重要的值,这些值与应用程序用于的业务类型无关,例如某个特定 Web 请求的平均延迟、某个接口每分钟返回的 4xx 状态码数量,或者某个服务消耗的 RAM 或 CPU 周期数。
在一个分布式系统中,每个部分都暴露着指标,应该有一个总服务定期收集并聚合来自各个组件的值。或者,每个组件应该将其指标转发到一个中央指标服务器。只有当我们高度分布式系统中所有组件的指标可以在一个中央位置进行检查时,它们才有价值。否则,监控系统就变得不可能。这就像飞机驾驶员在飞行过程中不需要亲自检查飞机的每个重要部件一样;所有必要的读数都会收集并显示在驾驶舱中。
今天,最流行的服务之一是Prometheus,它用于暴露、收集和存储指标。它是一个开源项目,并已捐赠给云原生计算基金会(CNCF)。Prometheus 与 Docker 容器、Kubernetes 以及许多其他系统和编程平台具有一流的集成。在本章中,我们将使用 Prometheus 演示如何为一个简单的服务添加指标暴露功能。
在下一节中,我们将向您展示如何将容器和 Docker 守护进程日志发送到一个中央位置。
发送容器和 Docker 守护进程日志
在容器化的世界中,了解 Docker 环境生成的日志对于保持系统健康和正常运行至关重要。本节将概述您将遇到的两种关键日志类型:发送的容器日志和Docker 守护进程日志。
发送容器日志
当应用程序在容器中运行时,它们会生成日志信息,这些信息提供了有关其性能和潜在问题的宝贵洞察。
可以使用docker logs
命令来访问容器日志,后面跟上容器的 ID 或名称。这些日志可以帮助开发人员和系统管理员诊断问题、监控容器活动,并确保已部署应用程序的顺利运行。集中管理和分析容器日志对于优化资源使用、识别性能瓶颈以及排除应用程序问题至关重要。
管理运输容器日志的一些最佳实践包括以下内容:
-
配置日志轮换和保留策略以防止过度使用磁盘空间
-
使用日志管理系统将多个容器的日志集中管理
-
设置日志过滤和警报机制,以识别关键事件和异常
让我们详细了解这些建议,从日志轮换和保留策略开始。
配置日志轮换和保留策略
配置容器日志的日志轮换和保留策略对于防止过度使用磁盘空间并保持最佳性能非常重要。以下是如何为 Docker 容器日志设置这些策略的逐步指南。
配置日志驱动程序
Docker 支持多种日志驱动程序,如 json-file
、syslog
、journald
等。要配置日志驱动程序,你可以选择全局设置整个 Docker 守护进程的日志驱动程序,或者为每个容器单独设置。在此示例中,我们将使用 json-file
日志驱动程序,这是 Docker 的默认驱动程序。
全局设置日志驱动程序
要全局设置日志驱动程序,请编辑 /etc/docker/daemon.json
配置文件(如果文件不存在,则创建它),并执行以下操作:
- 打开 Docker Desktop 的仪表板并导航至 设置,然后选择 Docker 引擎。你应该看到类似于以下内容的界面:
图 12.1 – Docker 守护进程配置
-
分析现有的配置,并在其中添加以下键值对(如果尚未存在):
"log-driver": "json-file"
这里,(简化后的)结果将如下所示:
{ ...
"experimental": true,
"features": {
"buildkit": true
},
"metrics-addr": "127.0.0.1:9323",
"log-driver": "json-file"
}
- 重新启动 Docker 守护进程以应用更改。
本地设置日志驱动程序
如果你更倾向于为单个容器设置日志驱动程序而不是全局设置,请在启动容器时使用 --log-driver
选项:
docker run --log-driver=json-file <image_name>
现在,让我们学习如何指定日志轮换和保留策略。
设置日志轮换和保留策略
我们可以通过为日志驱动程序指定 max-size
和 max-file
选项来配置日志轮换和保留策略:
-
max-size
:此选项限制每个日志文件的大小。当日志文件达到指定大小时,Docker 会创建一个新文件并开始记录。例如,要将每个日志文件限制为 10 MB,设置max-size=10m
。 -
max-file
:此选项限制要保留的日志文件数量。当达到限制时,Docker 会删除最旧的日志文件。例如,要只保留最近的五个日志文件,设置max-file=5
。
要全局设置这些选项,请将它们添加到 /etc/docker/daemon.json
配置文件中。我们可以在之前添加的 log-driver
节点后面添加 log-opts
部分:
{ ...
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "5"
}
}
我们建议你通过 Docker Desktop 的仪表板再次修改守护进程配置。修改配置后,请重新启动 Docker 守护进程以应用更改。
要为单个容器设置这些选项,请在启动容器时使用 --log-opt
选项:
docker run --log-driver=json-file \ --log-opt max-size=10m \
--log-opt max-file=5 \
<image_name>
通过配置日志轮换和保留策略,你可以防止磁盘空间的过度使用,并保持 Docker 环境的正常运行。记得根据你的具体使用情况和存储容量选择合适的 max-size
和 max-file
值。
使用日志管理系统
使用日志管理系统将多个容器的日志集中管理,对于在 Docker 环境中进行高效监控和故障排除至关重要。这使得你可以将所有容器的日志集中分析,找出模式或问题。在本章中,我们将使用Elasticsearch, Logstash 和 Kibana(ELK)Stack 作为示例日志管理系统。
ELK Stack
ELK Stack,也称为 Elastic Stack,是一组开源软件产品,旨在促进大规模数据的摄取、存储、处理、搜索和可视化。
ELK 是 Elasticsearch、Logstash 和 Kibana 的缩写,它们是该堆栈的主要组件。
Elasticsearch:Elasticsearch 是一个分布式的、基于 REST 的搜索和分析引擎,建立在 Apache Lucene 之上。它提供了一个可扩展的、近实时的搜索平台,具备强大的全文搜索功能,同时支持聚合和分析。Elasticsearch 通常用于日志和事件数据分析、应用程序搜索,以及各种需要高性能搜索和索引功能的用例。
Logstash:Logstash 是一个灵活的服务器端数据处理管道,能够摄取、处理并将数据转发到多个输出,包括 Elasticsearch。Logstash 支持多种输入源,如日志文件、数据库和消息队列,并可以在转发数据前使用过滤器进行转换和增强。Logstash 通常用于收集和规范化来自不同源的日志和事件,使得在 Elasticsearch 中分析和可视化数据更加容易。
Kibana:Kibana 是一个基于 Web 的数据可视化和探索工具,提供了与 Elasticsearch 数据交互的用户界面。Kibana 提供多种可视化类型,如柱状图、折线图、饼图和地图,并支持创建自定义仪表板来展示和分析数据。Kibana 还包括 Dev Tools 用于 Elasticsearch 查询测试、监控和警报功能,并支持机器学习集成。
请注意,以下描述适用于 Linux 系统。如果你恰好是那些在开发机器上原生运行 Linux 的幸运人之一,那就直接开始第 1 步 – 在 Linux 上设置 ELK Stack吧。
如果你使用的是 Mac 或 Windows 机器进行工作,我们已经创建了详细的步骤说明,教你如何测试设置。特别需要注意的是第 2 步 – 安装和配置 Filebeat。请查看与你的设置相匹配的部分并尝试一下。
第 1 步 – 在 Linux 上设置 ELK Stack
使用 Docker 容器部署 ELK,或将其直接安装在你的系统上。详细的安装说明,请参考官方的 ELK Stack 文档:www.elastic.co/guide/index.xhtml
。
确保 Elasticsearch 和 Kibana 配置正确并正在运行。通过使用 web 浏览器访问 Kibana 仪表板来验证这一点。
步骤 2 – 安装并配置 Filebeat
Filebeat 是一个轻量级的日志传输工具,可以将日志从 Docker 容器转发到 ELK Stack。你可以在 Docker 主机上安装 Filebeat,并配置它以收集容器日志:
-
使用官方安装指南安装 Filebeat,针对你的操作系统进行安装。你可以在这里找到相关文档:
www.elastic.co/guide/en/beats/filebeat/current/filebeat-installation-configuration.xhtml
。 -
通过编辑
filebeat.yml
配置文件来配置 Filebeat(通常位于 Linux 系统的/etc/filebeat
中)。添加以下配置以收集 Docker 容器日志:filebeat.inputs:- type: container paths: - '/var/lib/docker/containers/*/*.log'
-
配置输出将日志转发到 Elasticsearch。将
<elasticsearch_host>
和<elasticsearch_port>
替换为适当的值:output.elasticsearch: hosts: ["<elasticsearch_host>:<elasticsearch_port>"]
-
保存配置文件并启动 Filebeat:
$ sudo systemctl enable filebeat$ sudo systemctl start filebeat
请注意,这种配置仅适用于 Linux 系统。在 Mac 或 Windows 上,由于 Docker 在两个系统上都运行在虚拟机中,因此访问这个虚拟机中的 Docker 日志稍微复杂一些。如果你希望在 Mac 或 Windows 机器上本地安装 Filebeat,请查阅相关文档,因为这超出了本书的范围。
或者,我们可以将 Filebeat 运行在容器中,与 ELK Stack 并行使用。
这是一个完整的 Docker Compose 文件,将在 Linux 计算机上运行 ELK Stack 和 Filebeat:
图 12.2 – ELK Stack 和 Filebeat 的 Docker Compose 文件
现在我们已经学习了如何在 Linux 计算机或服务器上运行 Filebeat,接下来我们想展示如何在 Mac 或 Windows 计算机上使用 Filebeat,这在开发过程中非常重要。
在 Mac 或 Windows 计算机上运行示例
上面的示例无法在 Mac 或 Windows 计算机上运行,因为 Docker 是透明地运行在虚拟机中,因此 Docker 日志文件将无法在 /var/lib/docker/containers
中找到。
我们可以通过一种变通方法来解决这个问题:我们可以配置所有容器将各自的日志写入一个属于 Docker 卷的文件中。然后,我们可以将这个卷挂载到 Filebeat 容器中,而不是在前面的 Docker Compose 文件的第 44 行做的那样。
这是一个示例,使用一个简单的 Node.js/Express.js 应用程序来演示这个过程。请按照以下步骤操作:
-
在
ch12
章节文件夹中创建一个名为mac-or-windows
的文件夹。 -
在这个文件夹内,创建一个名为
app
的子文件夹,并进入该文件夹。 -
在
app
文件夹内,使用以下命令初始化 Node.js 应用程序:$ npm init
接受所有默认设置。
-
使用以下命令安装 Express.js:
$ npm install --save express
-
修改
package.json
文件,并添加一个名为start
的脚本,值设置为node index.js
。 -
向文件夹中添加一个名为
index.js
的文件,内容如下:
图 12.3 – index.js 应用文件
这个简单的 Express.js 应用程序有两个路由,/
和 /test
。它还包含中间件,用于记录传入的请求,并在处理特定路由或出现 404 Not
Found
错误时记录日志。
- 向文件夹中添加一个名为
entrypoint.sh
的脚本文件,内容如下:
图 12.4 – 示例应用的 entrypoint.sh 文件
该脚本将用于运行我们的示例应用程序,并将其日志重定向到指定的 LOGGING_FILE
。
使用以下命令将前面的文件设为可执行文件:
$ chmod +x ./entrypoint.sh
- 向文件夹中添加一个 Dockerfile,内容如下:
图 12.5 – 示例应用的 Dockerfile
- 向
mac-or-windows
文件夹中添加一个名为docker-compose.yml
的文件,内容如下:
图 12.6 – Mac 或 Windows 使用场景的 Docker Compose 文件
请注意第 9 行中的环境变量,它定义了由 Node.js/Express.js 应用生成的日志文件的名称和位置。还请注意第 11 行中的卷映射,这将确保日志文件被导入到 Docker 的 app_logs
卷中。然后,这个卷会挂载到第 25 行的 filebeat
容器中。通过这种方式,我们确保 Filebeat 能够收集日志并将其转发到 Kibana。
- 此外,向
mac-or-windows
文件夹中添加一个名为filebeat.yml
的文件,包含以下 Filebeat 配置:
图 12.7 – Mac 或 Windows 上的 Filebeat 配置
-
在
docker-compose.yml
文件所在的文件夹内,使用以下命令构建 Node.js 应用镜像:$ docker compose build app
-
现在,你已经准备好运行整个栈了,像这样:
$ docker compose up --detach
-
使用 REST 客户端访问
http://localhost:3000
和http://localhost:3000/test
端点几次,以使应用生成一些日志输出。
现在,我们准备好在 Kibana 中集中查看收集到的日志了。
第 3 步 – 在 Kibana 中可视化日志
通过 Web 浏览器访问 Kibana 仪表板,网址为 http://localhost:5601
。
如需更多详细信息,请参阅本章后面关于 查询集中日志 部分的内容。这里是一个简要概述。
进入 filebeat-*
)开始分析收集到的日志。
进入 Discover 部分,搜索、筛选并可视化来自 Docker 容器的日志。
配置好 Kibana 仪表板后,你应该会看到如下内容:
图 12.8 – 由 Filebeat 提供的 Kibana 中的应用日志
按照这些步骤,你将拥有一个集中式日志管理系统,能够汇总来自多个 Docker 容器的日志,帮助你高效地分析和监控容器化应用程序。需要注意的是,还有其他日志管理系统和日志传输工具,如 Splunk、Graylog 和 Fluentd,设置这些系统的过程类似,但可能需要不同的配置步骤。
设置日志过滤和警报机制
设置日志过滤和警报机制有助于你集中精力处理重要的日志信息,减少噪音,并主动响应潜在问题。在这里,我们将使用 ELK Stack 配合 ElastAlert 插件来演示日志过滤和警报。
第 1 步 – 设置 Elastic Stack
首先,按照 设置 ELK Stack 部分提供的说明,设置 Elastic Stack 进行集中式日志记录。这包括在 Docker 容器中运行 Elasticsearch、Logstash 和 Kibana。
第 2 步 – 使用 Logstash 设置日志过滤
配置 Logstash 根据特定条件(如日志级别、关键词或模式)过滤日志。更新你的 logstash.conf
文件,在 filter
部分添加适当的过滤器。例如,要根据日志级别过滤日志,你可以使用以下配置:
filter { if [loglevel] == "ERROR" {
mutate {
add_tag => ["error"]
}
}
}
此配置检查日志级别是否为 ERROR
,并将 error
标签添加到日志事件中。重启 Logstash 容器以应用新配置:
docker restart logstash
第 3 步 – 配置 ElastAlert 以进行警报
ElastAlert 是一个简单的框架,用于警报在 Elasticsearch 存储的数据中发现的异常、峰值或其他感兴趣的模式。让我们来设置它:
-
克隆 ElastAlert 仓库并导航到 ElastAlert 目录:
git clone https://github.com/Yelp/elastalert.gitcd elastalert
-
安装 ElastAlert:
pip install elastalert
-
为 ElastAlert 创建一个配置文件
config.yaml
,并使用以下内容更新它:es_host: host.docker.internales_port: 9200rules_folder: rulesrun_every: minutes: 1buffer_time: minutes: 15alert_time_limit: days: 2
-
创建一个
rules
目录,并定义你的警报规则。例如,要为带有error
标签的日志创建警报,可以在rules
目录中创建一个名为error_logs.yaml
的文件,内容如下:name: Error Logsindex: logstash-*type: frequencynum_events: 1timeframe: minutes: 1filter:- term: tags: "error"alert:- "email"email:- "you@example.com"
这个规则会在 1 分钟内,如果至少有一个带有 error
标签的日志事件,触发邮件警报。
-
启动 ElastAlert:
elastalert --config config.yaml --verbose
现在,ElastAlert 将根据你定义的规则监控 Elasticsearch 数据,并在满足条件时发送警报。
第 4 步 – 监控和响应警报
配置好日志过滤和警报机制后,你可以集中精力处理关键日志信息,并主动响应潜在问题。监控你的电子邮件或其他配置的通知渠道,接收警报并调查根本原因,以提高应用程序的可靠性和性能。
不断完善你的 Logstash 过滤器和 ElastAlert 规则,以减少噪音,检测重要的日志模式,并更有效地响应潜在问题。
在下一节中,我们将讨论如何传输 Docker 守护进程日志。
传输 Docker 守护进程日志
Docker 守护进程日志涉及 Docker 平台的整体功能。Docker 守护进程负责管理所有 Docker 容器,其日志记录了系统范围的事件和消息。这些日志有助于识别与 Docker 守护进程本身相关的问题,如网络问题、资源分配错误和容器编排挑战。
根据操作系统的不同,Docker 守护进程日志的位置和配置可能有所不同。例如,在 Linux 系统上,守护进程日志通常位于/var/log/docker.log
,而在 Windows 系统上,它们位于%programdata%\docker\logs\daemon.log
。
注意
Mac 上的守护进程日志将在下一节中介绍。
要有效管理 Docker 守护进程日志,可以考虑以下最佳实践:
-
定期查看守护进程日志,以识别潜在问题和异常
-
设置日志轮换和保留策略以管理磁盘空间使用
-
使用日志管理系统集中管理并分析日志,以更好地查看整体 Docker 环境。
总之,运输容器和 Docker 守护进程日志在监控和维护健康的 Docker 环境中起着至关重要的作用。通过有效地管理这些日志,系统管理员和开发人员可以确保最佳性能,最小化停机时间,并及时解决问题。
Mac 上的 Docker 守护进程日志
在安装了 Docker Desktop 的 Mac 上,你可以使用 macOS 日志工具提供的log stream
命令查看 Docker 守护进程日志。按照以下步骤操作:
-
打开终端应用程序。
-
运行以下命令:
log stream --predicate 'senderImagePath CONTAINS "Docker"'
该命令将显示与 Docker Desktop 相关的日志的实时流,包括 Docker 守护进程日志。你可以通过按Ctrl + C来停止日志流。
-
或者,你可以使用以下命令以文件格式查看 Docker 守护进程日志:
log show --predicate 'senderImagePath CONTAINS "Docker"' \ --style syslog --info \ --last 1d > docker_daemon_logs.log
该命令将在当前目录创建一个名为docker_daemon_logs.log
的文件,文件包含过去 1 天的 Docker 守护进程日志。你可以更改--last 1d
选项来指定不同的时间范围(例如,--last 2h
表示过去 2 小时)。使用任何文本编辑器打开docker_daemon_logs.log
文件以查看日志。
请注意,执行这些命令可能需要管理员权限。如果遇到权限问题,请在命令前加上sudo
。
Windows 计算机上的 Docker 守护进程日志
在安装了 Docker Desktop 的 Windows 11 机器上,Docker 守护进程日志以文本文件的形式存储。你可以通过以下步骤访问这些日志:
-
打开文件资源管理器。
-
导航到以下目录:
C:\ProgramData\DockerDesktop\service
在该目录中,你将找到包含 Docker 守护进程日志的DockerDesktopVM.log
文件。
- 使用任何文本编辑器打开
DockerDesktopVM.log
文件以查看日志。
请注意,C:\ProgramData
文件夹可能默认是隐藏的。要在文件资源管理器中显示隐藏的文件夹,请点击查看选项卡并勾选隐藏的项目复选框。
另外,您可以使用 PowerShell 阅读日志:
-
打开 PowerShell。
-
执行以下命令:
Get-Content -Path "C:\ProgramData\DockerDesktop\service\DockerDesktopVM.log" -Tail 50
此命令将显示 Docker 守护进程日志文件的最后 50 行。您可以更改-Tail
后的数字来显示不同数量的行。
接下来,我们将学习如何查询集中式日志。
查询集中式日志
一旦您的容器化应用程序日志被收集并存储在 ELK Stack 中,您就可以使用 Elasticsearch 的查询领域特定语言(DSL)查询集中式日志,并在 Kibana 中可视化结果。
第 1 步 - 访问 Kibana
Kibana 提供了一个用户友好的界面来查询和可视化 Elasticsearch 数据。在提供的 docker-compose.yml
文件中,Kibana 可以通过端口 5601
进行访问。打开您的浏览器并导航到 http://localhost:5601
。
第 2 步 - 设置索引模式
在查询日志之前,您需要在 Kibana 中创建一个索引模式,以识别包含日志数据的 Elasticsearch 索引。按照以下步骤创建索引模式:
-
第一次访问 Kibana 时,系统会要求您添加集成。由于我们使用 Filebeat 来发送日志,因此可以安全地忽略此请求。
-
相反,请在视图的左上角找到“汉堡菜单”,并点击它。
-
在左侧导航菜单中找到管理选项卡并选择堆栈管理:
图 12.9 - Kibana 中的管理选项卡
- 在Kibana部分,点击索引模式:
图 12.10 - Kibana 的索引模式条目
-
点击创建索引 模式按钮。
-
输入与您的 Logstash 索引匹配的索引模式。例如,如果您的 Logstash 配置使用
logstash-%{+YYYY.MM.dd}
索引模式,请在名称字段中输入logstash-*
。 -
在
@``timestamp
字段中。 -
点击创建 索引模式。
现在,我们已经准备好查询我们的容器日志。
第 3 步 - 在 Kibana 中查询日志
现在,您已经准备好使用 Kibana 的发现功能来查询日志。按照以下步骤操作:
-
再次在视图的左上角找到“汉堡菜单”,并点击它。
-
找到分析选项卡并选择发现。
-
从左上角的下拉菜单中选择您之前创建的索引模式。
-
使用右上角的时间过滤器选择一个特定的时间范围进行查询。
-
要搜索特定的日志条目,请在搜索框中输入查询并按Enter。Kibana 使用 Elasticsearch 查询 DSL 执行搜索。
以下是一些示例查询:
-
要查找包含
error
一词的日志:error
-
要查找具有特定字段值的日志:
container.name: "my-container"
-
要使用通配符搜索(例如,查找以“
app
”开头的container.name
日志):container.name: "app*"
-
要使用布尔运算符进行更复杂的查询:
error
ANDcontainer.name: "my-container"
第 4 步 – 可视化日志
您可以在 Kibana 中创建可视化和仪表盘,以更有效地分析日志。要创建可视化,请按以下步骤操作:
-
点击左侧导航菜单中的可视化选项卡。
-
点击创建 可视化按钮。
-
选择一个可视化类型(例如,饼图、条形图、折线图等)。
-
选择您之前创建的索引模式。
-
通过选择字段和聚合类型来配置可视化。
-
点击保存以保存您的可视化。
您可以创建多个可视化并将它们添加到仪表盘,以全面查看您的日志数据。要创建一个仪表盘,请执行以下操作:
-
点击左侧导航菜单中的仪表盘选项卡。
-
点击创建 仪表盘按钮。
-
点击添加以将可视化添加到仪表盘。
-
根据需要调整可视化的大小和重新排列位置。
-
点击保存以保存您的仪表盘。
现在,您可以集中查看容器化应用程序的日志,并可以使用 Kibana 查询、分析和可视化这些日志。
在接下来的章节中,我们将学习如何收集和抓取 Docker 和您的应用程序暴露的指标。
收集和抓取指标
要从运行在安装了 Docker Desktop 的系统上的容器中收集和抓取指标,您可以使用 Prometheus 和容器顾问(cAdvisor)。Prometheus 是一个强大的开源监控和告警工具集,而 cAdvisor 为容器用户提供有关其运行容器的资源使用情况和性能特征的理解。
在本节中,我们将提供逐步指南,帮助您设置 Prometheus 和 cAdvisor,从容器中收集和抓取指标。
第 1 步 – 在 Docker 容器中运行 cAdvisor
cAdvisor 是一个由 Google 开发的工具,用于收集、处理和导出容器指标。让我们来看看:
-
在章节文件夹
ch12
中,创建一个名为metrics
的新子文件夹:mkdir metrics
-
在此文件夹中,创建一个名为
docker-compose.yml
的文件,并将以下代码片段添加到其中:version: '3.8'services: cadvisor: image: gcr.io/cadvisor/cadvisor:v0.45.0 container_name: cadvisor restart: always ports: - 8080:8080 volumes: - /:/rootfs:ro - /var/run:/var/run:rw - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro
-
使用以下命令在 Docker 容器中运行 cAdvisor:
docker compose up cadvisor --detach
将v0.45.0
替换为 cAdvisor 仓库中最新的版本。
该命令挂载主机系统所需的目录,并在端口8080
上暴露 cAdvisor 的 Web 界面。
注意
版本低于此处显示的版本将无法运行,例如,在配备 M1 或 M2 处理器的 Mac 上。
- 您可以通过在浏览器中导航到
http://localhost:8080
访问 cAdvisor 的 Web 界面。
第 2 步 – 设置并运行 Prometheus
接下来,让我们按照以下逐步说明设置 Prometheus:
-
在
metrics
文件夹中创建一个名为prometheus
的子文件夹。 -
在这个新文件夹中,创建一个名为
prometheus.yml
的配置文件,内容如下:global: scrape_interval: 15sscrape_configs: - job_name: 'prometheus' static_configs: - targets: ['localhost:9090'] - job_name: 'cadvisor' static_configs: - targets: ['host.docker.internal:8080']
此配置指定了全局抓取间隔和两个抓取作业:一个用于 Prometheus 本身,另一个用于运行在端口8080
上的 cAdvisor。
-
在
docker-compose.yml
文件的末尾添加以下片段:prometheus: image: prom/prometheus:latest container_name: prometheus restart: always ports: - 9090:9090 volumes: - ./prometheus:/etc/prometheus - prometheus_data:/prometheus
此指令挂载了prometheus.yml
配置文件,并在端口9090
上公开了 Prometheus。
-
前述的
prometheus
服务使用了名为prometheus_data
的卷。要定义这一点,请将以下两行添加到docker-compose.yml
文件的末尾:volumes: prometheus_data:
-
您可以通过浏览器访问
http://localhost:9090
来访问 Prometheus Web 界面。
一旦 Prometheus 启动并运行,您可以验证它是否成功从 cAdvisor 获取指标:
-
在
http://localhost:9090
打开 Prometheus Web 界面。 -
在顶部导航栏中点击状态,然后选择目标。
-
确保
prometheus
和cadvisor
目标都列为UP
。
现在,Prometheus 可以收集和存储运行在您的 Docker Desktop 系统上的容器的指标。您可以使用 Prometheus 内置的表达式浏览器查询指标或设置 Grafana 进行高级可视化和仪表板:
-
在
query text
字段中输入类似container_start_time_seconds
的内容,以获取所有容器的启动时间值。 -
要细化查询并仅获取 cAdvisor 容器的值,请输入
container_start_time_seconds{job="cadvisor"}
。
请注意,在query text
字段中,您可以获得智能感知(IntelliSense),当您不记得命令及其参数的所有细节时,这非常方便。
在继续之前,请使用以下命令停止 cAdvisor 和 Prometheus:
docker compose down -v
在本章的最后一节,您将学习如何使用 Grafana 等工具监控容器化应用程序。
监控容器化应用程序
监控容器化应用程序对理解应用程序的性能、资源使用情况和潜在瓶颈至关重要。本节将详细介绍使用 Prometheus、Grafana 和 cAdvisor 监控容器化应用程序的逐步过程。
第一步 – 设置 Prometheus
按照上一节的说明设置 Prometheus 和 cAdvisor,以从运行在 Docker Desktop 上的容器中收集和抓取指标。
第二步 – 使用 Prometheus 指标为您的应用程序进行仪表化
要监控容器化应用程序,您需要使用 Prometheus 指标为应用程序进行仪表化。这涉及向应用程序代码添加 Prometheus 客户端库,并在 HTTP 端点(通常为/metrics
)上公开指标。
从官方列表prometheus.io/docs/instrumenting/clientlibs/
中选择适合您应用程序编程语言的适当 Prometheus 客户端库。
在遵循库的文档和示例时,将库添加到您的应用程序中。
暴露/metrics
端点,这将由 Prometheus 进行抓取。
使用 Kotlin 和 Spring Boot 的示例
要从 Kotlin 和 Spring Boot API 暴露 Prometheus 指标,您需要遵循以下步骤:
-
创建一个新的 Kotlin Spring Boot 项目。
-
添加必要的依赖项。
-
实现 API 并暴露 Prometheus 指标。
-
暴露 actuator 端点。
-
创建一个 Dockerfile。
-
与 Docker Compose 文件集成。
步骤 1 – 创建一个新的 Kotlin Spring Boot 项目
你可以使用 Spring Initializr(start.spring.io/
)来创建一个新的 Kotlin Spring Boot 项目。将构件命名为kotlin-api
,然后选择 Kotlin 作为语言,选择打包类型(JAR 或 WAR),并添加必要的依赖项。对于此示例,在依赖项部分选择Web、Actuator和Prometheus。
下载生成的项目并解压缩。
步骤 2 – 验证必要的依赖项
在你的build.gradle.kts
文件中,确保包含以下依赖项:
implementation("org.springframework.boot:spring-boot-starter-web")implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
步骤 3 – 实现 API 并暴露 Prometheus 指标
定位到src/main/kotlin/com/example/kotlinapi/
子文件夹中的 Kotlin KotlinApiApplication.kt
文件,并将其现有内容替换为以下内容:
图 12.11 – KotlinApiApplication.kt 文件中的代码
如果你不想自己输入示例代码,也可以在sample-solutions/ch12/kotlin-api
子文件夹中找到这段代码。
在此示例中,实现了一个简单的 REST API,只有一个端点/
。该端点递增计数器并将计数作为 Prometheus 指标api_requests_total
暴露。
将以下行添加到application.properties
文件,以使用不同于默认端口8080
的端口,8080
端口已被我们堆栈中的 cAdvisor 占用。在我们的示例中,端口为7000
:
server.port=7000
步骤 4 – 暴露指标
将以下行添加到application.properties
文件:
management.endpoints.web.exposure.include=health,info,metrics,prometheus
注意
上述配置应全部放在一行上。由于空间限制,这里显示为两行。
这将暴露相应的指标,在/actuator/health
、/actuator/info
、/actuator/metrics
和/actuator/prometheus
端点上。
步骤 5 – 创建 Dockerfile
在项目根目录中创建一个multistage
Dockerfile,内容如下:
图 12.12 – Kotlin API 的 Dockerfile
在这个multistage
Dockerfile 中,我们有两个阶段:
-
使用
gradle:jdk17
基础镜像来构建 Kotlin Spring Boot 应用程序。它设置工作目录,复制源代码,并运行 Gradlebuild
命令。此阶段使用AS
关键字命名为build
。 -
openjdk:17-oracle
基础镜像用于运行时环境,它是一个没有 JDK 的较小镜像。它从构建阶段复制构建的 JAR 文件,并将入口点设置为运行 Spring Boot 应用程序。
这个多阶段的 Dockerfile 允许你一次性构建 Kotlin Spring Boot 应用程序并创建最终的运行时镜像。它还通过排除不必要的构建工具和工件,帮助减少最终镜像的大小。
第六步 – 与 Docker Compose 文件集成
更新你现有的docker-compose.yml
文件,以便它包含 Kotlin Spring Boot API 服务,该服务位于kotlin-api
子文件夹中:
version: '3.8'services:
# ... other services (Elasticsearch, Logstash, Kibana, etc.) ...
kotlin-spring-boot-api:
build: ./kotlin-api
container_name: kotlin-spring-boot-api
ports:
- 7000:7000
现在,你可以运行docker compose up -d
来构建并启动 Kotlin Spring Boot API 服务以及其他服务。API 将通过8080
端口访问,Prometheus 的度量标准可以被收集。
接下来,我们将配置 Prometheus 以抓取我们设置中的所有度量数据,包括我们刚创建的 Kotlin API。
第三步 – 配置 Prometheus 抓取你的应用程序指标
更新你在前一部分中提到的prometheus.yml
配置文件,以便它包括一个新的抓取任务,针对你的应用程序。例如,由于我们的 Kotlin API 示例应用程序在 Docker 容器中运行并在7000
端口暴露度量标准,我们将以下内容添加到scrape_configs
部分:
- job_name: 'kotlin-api' static_configs:
- targets: ['host.docker.internal:7000']
metrics_path: /actuator/prometheus
第四步 – 设置 Grafana 进行可视化
Grafana 是一个流行的开源可视化和分析工具,可以与 Prometheus 集成,创建适用于你的容器化应用程序的交互式仪表板:
-
在前一部分的
docker-compose.yml
中,添加以下代码段以定义 Grafana 服务:grafana: image: grafana/grafana:latest container_name: grafana restart: always ports: - 3000:3000 volumes: - grafana_data:/var/lib/grafana
-
在
volumes:
部分,添加一个名为grafana_data
的卷。 -
使用以下命令运行 cAdvisor、Prometheus 和 Grafana:
docker compose up --detach
-
通过在浏览器中导航到
http://localhost:3000
,你可以访问 Grafana。默认的用户名是admin
,默认的密码也是admin
。 -
添加Prometheus作为数据源。
-
点击左侧边栏中的齿轮图标(Configuration)。
-
选择Data Sources,然后点击Add data source。
-
选择
http://host.docker.internal:9090
作为 URL。 -
点击Save & Test以验证连接。
-
创建仪表板和面板以可视化你的应用程序指标。
-
点击左侧边栏中的+图标(Create),然后选择Dashboard。
-
点击Add new panel开始为你的度量数据创建面板。
-
使用查询编辑器基于你的应用程序指标构建查询,并自定义可视化类型、外观和其他设置。
-
点击右上角的磁盘图标保存仪表板。
使用 Grafana,你可以创建交互式仪表板,提供容器化应用程序的实时性能、资源使用情况和其他关键指标的洞察。
第五步 – 设置告警(可选)
Grafana 和 Prometheus 可以根据你的应用程序指标设置告警。这可以帮助你在问题影响用户之前主动处理问题:
-
在 Grafana 中,创建一个新面板或编辑现有面板。
-
在面板编辑器中切换到Alert标签页。
-
点击创建警报并配置警报规则、条件和通知设置。
-
保存面板和仪表板。
您可能还需要配置 Grafana 的通知渠道,通过电子邮件、Slack、PagerDuty 或其他支持的服务发送警报。要做到这一点,请按照以下步骤操作:
-
在 Grafana 中,点击左侧边栏的铃铛图标(警报)。
-
选择通知渠道并点击添加渠道。
-
填入您偏好的通知服务所需的信息,然后点击保存。
现在,当您的面板中指定的警报条件满足时,Grafana 将通过配置的渠道发送通知。
第 6 步 – 监控您的容器化应用程序
配置了 Prometheus、Grafana 和 cAdvisor 后,您现在可以有效地监控您的容器化应用程序。请密切关注您的 Grafana 仪表板,设置适当的警报规则,并利用收集的数据识别性能瓶颈,优化资源使用,并改善应用程序的整体健康状况。
记得通过不断完善您的监控设置来持续迭代和改进,精炼应用程序的仪表化,调整警报规则,并随着应用程序的发展和增长,向仪表板中添加新的可视化内容。
总结
在本章中,我们了解了为什么记录日志并将其发送到中央位置是很重要的。接着我们展示了如何在本地计算机上设置 ELK Stack,它可以作为日志的集线器。我们生成了这个堆栈的一个特殊版本,其中包括 Filebeat,它可以通过重定向标准日志输出到一个文件并将其父文件夹映射到 Docker 卷,进而在 Mac 或 Windows 计算机上运行。在生产或类生产系统中,应用程序运行在 Linux 服务器或虚拟机上,因此 Filebeat 可以直接从 Docker 将日志收集到默认位置 /var/lib/docker/containers
。
我们还学习了如何使用 Prometheus 和 Grafana 来抓取、收集并集中显示您应用程序的指标,并在仪表板上展示这些数据。我们使用了一个简单的 Kotlin 应用程序,暴露了一个计数器来演示这一过程。
最后,我们简要提到了如何根据收集的指标值定义警报。
在下一章中,我们将介绍容器编排器的概念。它将教我们为什么需要编排器,以及编排器的工作原理。该章节还将概述最流行的编排器,并列出它们的一些优缺点。
问题
这里有几个问题,您应该尝试回答它们以自我评估您的学习进度:
-
Docker 容器日志是什么?它们为什么重要?
-
Docker 中的守护进程日志是什么?它与容器日志有何不同?
-
如何监控 Docker 容器?
-
如何查看正在运行的 Docker 容器的日志?
-
对于记录和监控 Docker 容器,有哪些最佳实践?
-
如何从多个 Docker 容器收集日志?
答案
这里是本章问题的一些示例答案:
-
Docker 容器日志是由容器内运行的应用程序生成的事件和消息记录。它们对于监控性能、故障排除问题以及确保 Docker 容器中部署的应用程序平稳运行至关重要。
-
Docker 中的守护程序日志指的是由管理 Docker 容器的 Docker 守护程序生成的日志文件。这些日志记录了与 Docker 平台整体功能相关的系统范围事件和消息。相比之下,容器日志是针对单个容器及其应用程序的特定日志。
-
可以通过多种方法监控 Docker 容器,包括命令行工具如 docker stats、第三方监控解决方案如 Prometheus,以及 Docker 的内置 API。这些工具帮助跟踪资源使用情况、性能指标和容器的健康状态。
-
您可以使用
docker logs
命令查看运行中 Docker 容器的日志,后面跟上容器的 ID 或名称。该命令检索容器生成的日志消息,有助于诊断问题或监视容器的活动。 -
记录和监控 Docker 容器的一些最佳实践包括以下几点:
-
使用日志管理系统集中日志
-
配置日志轮转和保留策略
-
设置日志过滤和警报机制
-
使用内置和第三方工具组合监控容器
-
定期检查异常的日志和指标
-
-
要从多个 Docker 容器收集日志,您可以使用日志管理系统,如 ELK Stack 或 Splunk。您还可以使用 Fluentd 或 Logspout 等工具,将所有容器的日志聚合并转发到集中的日志管理系统,进行分析和可视化。
第十三章:13
介绍容器编排
在上一章中,我们展示了如何收集容器日志并将其发送到集中位置,在那里聚合的日志可以解析出有用信息。我们还学习了如何对应用程序进行监控,使其暴露出指标,并且这些指标可以被抓取并再次发送到集中位置。最后,本章教我们如何将这些收集到的指标转换为图形化的仪表盘,来监控容器化的应用程序。
本章介绍了编排器的概念。它教我们为什么需要编排器,以及它们是如何在概念上工作的。本章还将概述一些最流行的编排器,并列出它们的优缺点。
本章将涵盖以下内容:
-
什么是编排器,我们为什么需要它们?
-
编排器的任务
-
流行的编排器概述
完成本章后,你将能够做以下事情:
-
列举三到四个编排器负责的任务
-
列出两到三个最流行的编排器
-
用你自己的话向一个感兴趣的外行解释,并通过适当的类比说明为什么我们需要容器编排器
什么是编排器,我们为什么需要它们?
在第九章《学习分布式应用架构》中,我们了解了常用的构建、运输和运行高度分布式应用的模式和最佳实践。现在,如果我们的分布式应用是容器化的,那么我们将面临与非容器化分布式应用相同的问题或挑战。这些挑战中有一些是在那一章中讨论过的,诸如服务发现、负载均衡、扩展等。
类似于 Docker 对容器所做的事情——通过引入容器标准化软件的打包和运输——我们希望有某种工具或基础设施软件来处理所有或大多数已经提到的挑战。这款软件就是我们所说的容器编排器,或者我们也称它们为编排引擎。
如果我刚才说的内容对你来说还没有太大意义,那么让我们换个角度来看。想象一下一个演奏乐器的艺术家。他们可以单独为观众演奏美妙的音乐——只是艺术家和他们的乐器。但现在,假设有一支由多位音乐家组成的管弦乐队。把他们都放在一个房间里,给他们交代交响乐的乐谱,让他们演奏,并且离开房间。如果没有指挥,这群非常有才华的音乐家将无法和谐地演奏这首曲子;它听起来更像是一种杂音。只有当管弦乐队有一个指挥,来编排这些音乐家,乐队的音乐才能让我们的耳朵享受。
现在,我们不再是音乐家,而是容器;不再是各种乐器,而是具有不同运行需求的容器主机。与音乐在不同速度下演奏不同,我们的容器也以特定的方式相互通信,并且需要根据对应用程序的负载变化进行扩展或缩减。就此而言,容器调度器的角色与乐团指挥非常相似。它确保集群中的容器和其他资源协调一致地工作。
我希望你现在能更清楚地理解容器调度器是什么,以及为什么我们需要它。假设你已经明白了这个问题,我们现在可以问自己,调度器如何实现预期的结果,即确保集群中的所有容器和谐工作。答案是,调度器必须执行非常具体的任务,类似于乐团指挥也有一套任务,用来驾驭并同时提升乐团的表现。
调度器的任务
那么,我们期望一个值得投资的调度器为我们执行哪些任务呢?让我们详细看看。以下列表展示了在撰写时,企业用户通常期望从调度器获得的最重要任务。
调整期望状态
使用调度器时,你会告诉它(最好是以声明性方式)如何运行给定的应用程序或应用服务。我们在第十一章《使用 Docker Compose 管理容器》中学习了什么是声明性和命令式的区别。描述我们要运行的应用服务的声明性方式包括元素,例如使用哪个容器镜像、运行多少实例、打开哪些端口等。我们称之为应用服务的声明属性,这就是所谓的期望状态。
所以,当我们首次告诉调度器基于声明创建一个新的应用服务时,调度器将确保根据请求在集群中调度足够多的容器。如果容器镜像在目标节点上尚不可用,调度器将确保从镜像仓库下载它们。接下来,容器将按照所有设置(如网络连接或暴露的端口)启动。调度器会尽力使集群的实际情况与声明完全匹配。
一旦我们的服务按要求启动并运行,也就是说,它已经在期望状态下运行,那么调度器将继续监控它。每当调度器发现服务的实际状态与期望状态不一致时,它将尽力再次调整,使实际状态与期望状态保持一致。
那么,实际状态和期望状态之间可能出现什么样的差异呢?假设服务的一个副本,也就是其中一个容器,由于 bug 等原因崩溃了;那么编排器会发现实际状态与期望状态在副本数量上有所不同:少了一个副本。编排器会立即在另一个集群节点上调度一个新的实例来替代崩溃的实例。另一种差异可能是,如果服务被缩减了规模,应用服务可能会运行过多实例。在这种情况下,编排器会随机杀死需要的实例,以实现实际实例数量和期望数量之间的平衡。还有一种差异是,当编排器发现应用服务的某个实例正在运行一个错误的(可能是旧的)底层容器镜像版本时。到现在为止,你应该能理解了,对吧?
因此,取而代之的是我们主动监控集群中运行的应用服务并纠正任何与期望状态的偏差,我们将这一繁琐的任务委托给编排器。这在我们使用声明性方式而非命令式方式描述应用服务的期望状态时效果最好。
复制和全局服务
在由编排器管理的集群中,我们可能会想要运行两种截然不同类型的服务。它们分别是复制服务和全局服务。复制服务是指要求在特定数量的实例上运行的服务,比如 10 个实例。全局服务则是指要求在集群中的每个工作节点上恰好运行一个实例的服务。我在这里使用了“工作节点”这个术语。在由编排器管理的集群中,通常有两种类型的节点:管理节点和工作节点。
管理节点通常是由编排器专门用于管理集群的,不运行任何其他工作负载。而工作节点则运行实际的应用程序。因此,编排器会确保对于全局服务,无论工作节点有多少,每个节点上都将运行一个实例。我们不关心实例的数量,而只关心每个节点上都必须保证运行一个实例。
我们可以再次完全依赖编排器来处理这个问题。在一个复制服务中,我们始终可以确保找到精确所需数量的实例,而在全局服务中,我们可以确保每个工作节点上都会运行恰好一个实例。编排器将始终尽最大努力来保证这个期望的状态。在 Kubernetes 中,全局服务也被称为DaemonSet。
服务发现
当我们以声明性方式描述一个应用服务时,我们绝不应该告诉协调器不同实例应运行在哪些集群节点上。我们将让协调器决定哪个节点最适合执行这个任务。
当然,从技术上讲,可以指示协调器使用非常确定的放置规则,但这将是一种反模式,除非在非常特殊的边缘情况下,否则根本不推荐使用。
所以,如果我们现在假设协调引擎完全自由地决定应用服务的各个实例放置的位置,而且实例可能会崩溃并由协调器重新调度到不同的节点,那么我们就会意识到,试图追踪各个实例在任何给定时刻的运行位置是徒劳的。更好的是,我们根本不应该尝试去了解这一点,因为这并不重要。
好吧,你可能会说,如果我有两个服务,A 和 B,而服务 A 依赖于服务 B;那么服务 A 的任何实例不应该知道它可以在哪里找到服务 B 的实例吗?
在这里,我必须大声而清楚地说——不,应该不了。这种知识在高度分布式和可扩展的应用程序中并不理想。相反,我们应该依赖于协调器来提供我们所需的信息,以便访问我们依赖的其他服务实例。这有点像电话时代早期,我们不能直接拨打朋友的电话,而是必须拨打电话公司的总机,由接线员将我们转接到正确的目的地。在我们的案例中,协调器充当了接线员的角色,将来自服务 A 实例的请求路由到可用的服务 B 实例。整个过程被称为服务发现。
路由
到目前为止,我们已经了解,在一个分布式应用程序中,我们有许多交互的服务。当服务 A 与服务 B 进行交互时,是通过数据包的交换来实现的。这些数据包需要以某种方式从服务 A 传输到服务 B。将数据包从源头传输到目的地的过程也称为路由。作为应用程序的作者或操作员,我们期望调度器来承担这个路由任务。正如我们在后续章节中看到的,路由可以在不同的层级上进行。这就像现实生活中的情况。假设你在一家公司的一栋办公楼里工作。现在,你有一份文件需要转交给公司里的另一位员工。内部邮政服务会将文件从你的发件箱中取走,并送到同一建筑物内的邮局。如果目标人工作在同一栋楼,那么文件可以直接转交给该人。如果目标人则工作在同一区块的另一栋楼,文件将转交给目标楼的邮局,然后由内部邮政服务将其分发给收件人。第三种情况是,如果文件是送给在不同城市甚至不同国家的公司分支机构的员工,那么文件将被转交给外部邮政服务(如 UPS),它将文件运输到目标地点,从那里,再次由内部邮政服务接管并将其送达收件人。
在容器中运行的应用服务之间路由数据包时,也会发生类似的事情。源容器和目标容器可以位于同一个集群节点上,这相当于两名员工在同一栋建筑物中工作。
目标容器可能运行在不同的集群节点上,这相当于两名员工在同一区块的不同建筑物中工作。最后,第三种情况是,当数据包来自集群外部,必须路由到集群内部运行的目标容器。
所有这些情况,以及更多的情况,都必须由调度器来处理。
负载均衡
在高可用的分布式应用中,所有组件都必须是冗余的。这意味着每个应用服务必须以多个实例运行,这样即使一个实例失败,整个服务仍然能够正常运行。
为确保所有服务实例都在实际工作,而不是空闲着,你需要确保服务请求被均等地分配到所有实例。将工作负载分配到服务实例的过程称为负载均衡。存在多种算法来决定如何分配工作负载。
通常,负载均衡器使用所谓的轮询算法,确保工作负载在实例间均匀分配,采用循环算法进行分配。我们再次期望调度器处理来自一个服务到另一个服务或从外部源到内部服务的负载均衡请求。
扩展性
当我们在由调度器管理的集群中运行容器化的分布式应用时,我们也希望能够轻松处理预期的或意外的工作负载增加。为了应对增加的工作负载,我们通常只是调度该服务的额外实例来应对增加的负载。负载均衡器随后会自动配置,以便在更多的可用目标实例上分配工作负载。
但是在实际场景中,工作负载会随时间变化。如果我们看看像亚马逊这样的购物网站,它可能在晚上的高峰时段负载很高,因为那时每个人都在家在线购物;在像“黑色星期五”这样的特殊日子,负载可能会异常巨大;而在清晨,流量可能非常少。因此,服务不仅需要能够扩展,当工作负载减少时,也需要能够缩减。
我们还期望调度器在扩展时能够合理地分配服务实例。当扩展时,将所有服务实例调度到同一个集群节点上并不是明智之举,因为如果该节点宕机,整个服务都会宕机。负责容器部署的调度器需要考虑避免将所有实例都放置在同一个计算机机架上,因为如果机架的电源供应出现故障,整个服务也会受到影响。此外,关键服务的实例应分布在多个数据中心,以避免因故障而导致服务中断。所有这些决策,以及更多的决策,都由调度器负责。
在云中,通常使用“可用区”一词,而不是计算机机架。
自愈
如今,调度器已经非常复杂,可以为我们维护健康的系统做很多工作。调度器监控集群中所有运行的容器,并且会自动用新的实例替换崩溃或未响应的容器。调度器监控集群节点的健康状况,如果某个节点变得不健康或宕机,它会将该节点从调度循环中移除。原本在这些节点上的工作负载会自动重新调度到其他可用节点上。
所有这些活动,调度器监控当前状态并自动修复损坏或调节到期望状态,导致了所谓的自愈系统。
在大多数情况下,我们不需要主动干预和修复损坏。调度器会自动为我们完成这一任务。然而,有一些情况是调度器在没有我们帮助的情况下无法处理的。设想一个场景,我们有一个服务实例运行在容器中。容器已经启动并运行,从外部看起来完全健康,但内部运行的应用程序处于不健康状态。应用程序没有崩溃;它只是无法按原设计正常工作了。调度器怎么可能知道这一点呢?它根本无法知道!每个应用服务的健康或无效状态意味着完全不同的事情。换句话说,健康状态是依赖于服务的。只有服务的开发者或运维人员才知道在该服务的上下文中,什么才算健康。
现在,调度器定义了接缝或探针,应用服务可以通过这些与调度器通信,告知其所处的状态。探针主要有两种基本类型:
-
服务可以告知调度器它是否健康
-
服务可以告知调度器它是否准备就绪或暂时不可用
服务如何确定前述问题的答案完全取决于该服务。调度器只定义了它将如何询问,比如通过 HTTP GET
请求,或它期望什么类型的答案,比如 OK
或 NOT OK
。
如果我们的服务实现了逻辑来回答前述的健康或可用性问题,那么我们就拥有了一个真正的自愈系统,因为调度器可以终止不健康的服务实例,并用新的健康实例替换它们,且可以将暂时不可用的服务实例从负载均衡器的轮询中移除。
数据持久化和存储管理
数据持久化和存储管理是容器编排中的关键环节。它们确保数据在容器重启和故障后得以保留,使得应用能够维持其状态,并按预期继续运行。
在容器化环境中,数据存储可以分为两大类——临时存储和持久存储:
-
临时存储:这种存储与容器的生命周期相关。当容器终止或失败时,存储在临时存储中的数据将丢失。临时存储适用于临时数据、缓存或其他可以重新生成的非关键性信息。
-
持久存储:持久存储将数据与容器的生命周期解耦,使得数据即使在容器终止或失败后仍能持久存在。这种存储类型对于保存关键应用数据至关重要,比如用户生成的内容、数据库文件或配置数据。
容器编排引擎通过提供将持久存储附加到容器的机制来处理数据持久性和存储管理。这些机制通常涉及存储卷的创建和管理,存储卷可以根据需要挂载到容器中。
大多数容器编排引擎支持多种类型的存储后端,包括块存储、文件存储和对象存储。它们还提供与流行存储解决方案的集成,如基于云的存储服务、网络附加存储和分布式存储系统(如 Ceph 或 GlusterFS)。
另外,容器编排引擎处理存储的提供和管理,自动化执行如卷创建、调整大小和删除等任务。它们还允许用户定义存储类和策略,使得在分布式环境中管理存储资源变得更加容易。
总之,容器编排引擎中的数据持久性和存储管理确保应用程序在容器重启和故障期间保持其状态。它们提供将持久存储附加到容器的机制,并自动化存储提供和管理任务,从而简化了在容器化环境中管理存储资源的过程。
零停机时间部署
如今,对于需要更新的关键任务应用程序,停机时间越来越难以 justify。不仅意味着错失机会,还可能导致公司声誉受损。使用该应用程序的客户已不再愿意接受不便,并会迅速流失。
此外,我们的发布周期越来越短。在过去,我们每年只有一到两个新版本发布,而如今,许多公司每周甚至每天都会更新应用程序多次。
解决这个问题的方法是提出一个零停机时间的应用更新策略。编排器需要能够批量更新单个应用服务。这也被称为滚动更新。在任何给定时间,只有某个服务的一个或少数几个实例被停用,并由该服务的新版本替换。只有在新实例正常运行,且没有出现任何意外错误或不良表现的情况下,才会更新下一批实例。这个过程会一直重复,直到所有实例都被替换为新版本。如果由于某种原因更新失败,我们期望编排器能自动将更新后的实例回滚到先前的版本。
其他可能的零停机时间部署方式包括蓝绿部署和金丝雀发布。在这两种情况下,服务的新版本将与当前的活跃版本并行安装。但最初,新版本仅在内部可访问。操作人员可以对新版本进行冒烟测试,当新版本似乎运行良好时,在蓝绿部署的情况下,路由器将从当前的蓝色版本切换到新的绿色版本。在一段时间内,新的绿色版本将受到密切监控,如果一切正常,旧的蓝色版本可以被停用。另一方面,如果新的绿色版本没有按预期工作,那么只需将路由器切换回旧的蓝色版本,就可以实现完全回滚。
在金丝雀发布的情况下,路由器的配置方式是将 1%的总体流量引导到服务的新版本,而 99%的流量仍然经过旧版本。新版本的行为会被密切监控,并与旧版本的行为进行比较。如果一切正常,经过新服务的流量百分比会稍微增加。这个过程会重复,直到 100%的流量都通过新服务。如果新服务运行了一段时间且一切正常,旧服务可以被停用。
大多数编排器至少支持开箱即用的滚动更新类型的零停机时间部署。蓝绿部署和金丝雀发布通常很容易实现。
亲和性和位置感知
有时,某些应用服务需要在其运行的节点上有专用硬件的支持。例如,I/O 密集型服务需要附加高性能固态硬盘(SSD)的集群节点,而一些用于机器学习等服务需要加速处理单元(APU)。
编排器允许我们为每个应用服务定义节点亲和性。然后,编排器将确保其调度器仅在满足所需条件的集群节点上调度容器。
应避免在特定节点上定义亲和性;这样做会引入单点故障,从而影响高可用性。始终将多个集群节点定义为应用服务的目标。
一些调度引擎还支持所谓的定位感知或地理感知。这意味着你可以要求调度器在一组不同的位置之间均匀分配服务实例。例如,你可以定义一个数据中心标签,可能包括西部、中心和东部值,并将该标签应用于所有集群节点,标签值对应各节点所在的地理区域。然后,你可以指示调度器使用该标签来实现特定应用服务的地理感知。在这种情况下,如果你请求该服务的九个副本,调度器将确保三个副本分别部署到三个数据中心的节点——西部、中心和东部。
地理感知甚至可以分层定义;例如,你可以将数据中心作为顶级区分符,然后是可用区。
地理感知或位置感知用于减少由于电力供应故障或数据中心停机而导致的故障概率。如果应用实例分布在节点、可用区甚至数据中心之间,那么一切都同时宕机的可能性极小。总会有一个区域保持可用。
安全
目前,IT 安全是一个非常热门的话题。网络战争已经达到前所未有的高峰。大多数高知名度公司都曾成为黑客攻击的受害者,且代价非常高昂。
每个首席信息官(CIO)或首席技术官(CTO)最怕的噩梦之一,就是早晨醒来听到新闻报道说他们的公司成为黑客攻击的受害者,敏感信息被盗或被泄露。
为了应对大多数安全威胁,我们需要建立一个安全的软件供应链,并加强深度安全防御。让我们来看一下你可以期待企业级调度器的一些任务。
安全通信和加密节点身份
首先,我们要确保由调度器管理的集群是安全的。只有受信任的节点才能加入集群。每个加入集群的节点都会获得一个加密节点身份,节点之间的所有通信必须加密。为此,节点可以使用互信传输层安全(MTLS)。为了相互验证集群节点,使用证书。这些证书会定期自动轮换,或者根据请求轮换,以防止证书泄露时保护系统。
集群中发生的通信可以分为三种类型。你可以说有三种通信平面——管理平面、控制平面和数据平面:
-
管理平面由集群管理器或主节点使用,用于例如调度服务实例、执行健康检查,或创建和修改集群中的任何其他资源,如数据卷、密钥或网络。
-
控制平面用于在集群中的所有节点之间交换重要的状态信息。例如,这些信息会用来更新集群中的本地 IP 表,以便进行路由。
-
数据平面是实际的应用服务相互通信和交换数据的地方。
通常,编排器主要关注于确保管理和控制平面的安全。数据平面的安全则交由用户负责,尽管编排器可能会协助这一任务。
安全网络和网络策略
在运行应用服务时,并非每个服务都需要与集群中的其他所有服务进行通信。因此,我们希望能够将服务进行沙箱化,只在同一网络沙箱中运行那些必须互相通信的服务。所有其他服务以及来自集群外部的所有网络流量都应该无法访问这些沙箱化的服务。
至少有两种方式可以实现基于网络的沙箱化。我们可以使用软件定义网络(SDN)将应用服务分组,或者我们可以拥有一个平坦的网络,并使用网络策略来控制谁可以访问特定的服务或服务组。
基于角色的访问控制(RBAC)
编排器必须履行的最重要任务之一(仅次于安全性)是为集群及其资源提供 RBAC,以便其达到企业级的可用性。
RBAC 定义了系统中的主体、用户或用户组(按团队等组织方式)如何访问和操作系统资源。它确保未授权的人员无法对系统造成任何损害,也不能看到他们不应该知道或不应访问的系统资源。
一个典型的企业可能有如开发、QA、生产等用户组,并且每个组可能有一个或多个用户。开发人员 John Doe 是开发组的成员,因此他可以访问专属于开发团队的资源,但他不能访问生产团队的资源,比如 Ann Harbor 的资源。反过来,Ann 也无法干预开发团队的资源。
实现 RBAC 的一种方式是通过定义授权。授权是主体、角色和资源集合之间的关联。这里,角色包含一组对资源的访问权限。这些权限可以是创建、停止、删除、列出或查看容器;部署新的应用服务;列出集群节点或查看集群节点的详细信息;以及其他许多操作。
资源集合是集群中一组逻辑上相关的资源,例如应用服务、机密、数据卷或容器。
机密
在我们的日常生活中,我们有很多秘密。秘密是指那些不应公开的资料,比如你用来访问在线银行账户的用户名和密码组合,或者你手机的密码或健身房储物柜的密码。
在编写软件时,我们也经常需要使用秘密。例如,我们需要一个证书来认证我们的应用服务与我们想要访问的外部服务,或者我们需要一个令牌来认证和授权我们的服务在访问某些 API 时。
过去,为了方便,开发者通常会将这些值硬编码在代码中,或者放在一些外部配置文件中以明文形式存储。这样,这些非常敏感的信息就能被广泛的用户访问,而实际上,这些人根本不应该有机会看到这些秘密。
幸运的是,如今的编排工具提供了所谓的“秘密”功能,以一种高度安全的方式处理敏感信息。秘密可以由授权或信任的人员创建。这些秘密的值会被加密并存储在高可用的集群状态数据库中。由于秘密是加密的,因此它们在静态存储时是安全的。一旦授权的应用服务请求一个秘密,该秘密只会被转发到实际运行该特定服务实例的集群节点,并且秘密值永远不会存储在节点上,而是以 tmpfs 基于 RAM 的卷的形式挂载到容器中。
只有在各自的容器内部,秘密值才以明文形式可用。我们已经提到过,秘密在静态存储时是安全的。一旦服务请求它们,集群管理器(或主节点)会解密秘密并通过网络将其发送到目标节点。那么,秘密在传输过程中如何保持安全呢?好吧,我们之前学习过,集群节点使用 MTLS 进行通信,因此,虽然秘密以明文传输,但由于数据包会被 MTLS 加密,秘密依然是安全的。因此,秘密在静态存储和传输过程中都是安全的。只有被授权使用秘密的服务才能访问这些秘密值。
Kubernetes 中的秘密
请注意,尽管 Kubernetes 中使用的秘密相对安全,但文档仍然建议将其与更加安全的服务结合使用,即像 AWS Secrets Manager 或 Hashicorp 的 Vault 这样的秘密管理器。
内容信任
为了增强安全性,我们希望确保只有受信任的镜像在我们的生产集群中运行。一些编排器允许我们配置集群,以便它只能运行签名的镜像。内容信任和镜像签名都关乎确保镜像的作者是我们期望的人,即我们信任的开发者或者更好的情况是我们信任的 CI 服务器。此外,通过内容信任,我们希望确保我们得到的镜像是新鲜的,并且不是旧的,可能存在漏洞的镜像。最后,我们希望确保在传输过程中镜像不能被恶意黑客篡改。后者通常称为中间人攻击(MITM攻击)。
通过在源头签署镜像并在目标处验证签名,我们可以保证我们想要运行的镜像没有被篡改。
反向正常运行时间
在安全上下文中,我想讨论的最后一点是反向正常运行时间。这是什么意思呢?想象一下,你已经配置并确保了一个生产集群的安全性。在这个集群上,你正在运行公司的几个关键应用程序。现在,一个黑客成功找到了你软件堆栈中的一个安全漏洞,并且已经获取了对一个集群节点的根访问权限。单单这已经够糟糕的了,更糟糕的是,这个黑客现在可以掩盖他们在这个节点上的存在,并假装是机器的根用户,然后利用它作为基础攻击集群中的其他节点。
在 Linux 或任何 Unix 类型的操作系统中,根访问权限意味着你可以在该系统上执行任何操作。这是一个人能够拥有的最高级别的访问权限。在 Windows 中,相当于这个角色的是管理员角色。
但是如果我们利用容器是短暂的,集群节点可以快速配置,通常情况下只需几分钟就能完成?我们只需在每个集群节点运行一定正常运行时间后,例如一天,就杀死它们。编排器被指示排空节点,然后将其从集群中排除。一旦节点离开集群,它将被拆除并由新配置的节点替换。
这样,黑客就失去了他们的基础,问题已被消除。尽管这个概念目前还没有广泛推广,但对我来说,这似乎是提高安全性的一个巨大步骤,并且据我与在这一领域工作的工程师讨论,实施起来并不困难。
内省
到目前为止,我们已经讨论了很多编排器的责任和它完全自主执行的任务。然而,还需要人员操作者能够查看和分析集群上当前运行的内容,以及个别应用程序的状态或健康情况。为了做到这一点,我们需要 introspection 的可能性。编排器需要以易于消化和理解的方式展示关键信息。
协调器应当从所有集群节点收集系统指标,并使操作员可以访问这些数据。指标包括 CPU、内存和磁盘使用情况、网络带宽消耗等。这些信息应该易于在每个节点的基础上获取,也可以以聚合的形式提供。
我们还希望协调器能让我们访问由服务实例或容器生成的日志。更进一步,如果我们拥有正确的授权,协调器应该为我们提供exec
权限,让我们可以访问每个容器。通过 exec 访问容器后,我们就可以调试表现异常的容器。
在高度分布式的应用程序中,每个请求都会通过多个服务,直到完全处理完成,追踪请求是一个非常重要的任务。
理想情况下,协调器应支持我们实施追踪策略,或者为我们提供一些良好的指南。
最后,人类操作员在监控系统时,最好能使用一个图形化展示所有收集的指标、日志和跟踪信息的界面。这里我们指的是仪表盘。每个合格的协调器都应该提供至少一个基本的仪表盘,图形化展示最关键的系统参数。
然而,人类操作员并不是唯一关注自省的人。我们还需要能够将外部系统与协调器连接,以便消耗这些信息。需要有一个可用的 API,通过它外部系统可以访问集群状态、指标和日志等数据,并使用这些信息做出自动化决策,比如创建呼叫器或电话警报、发送电子邮件,或者在系统超过某些阈值时触发警报。
流行编排器概述
截至目前,市面上有许多编排引擎在使用,但也有一些明显的赢家。第一的位置无疑是 Kubernetes,它遥遥领先。第二位是 Docker 的 SwarmKit,其后是 Apache Mesos、AWS 的Elastic Container Service(ECS)和微软的Azure Container Service(ACS)。
Kubernetes
Kubernetes 最初由谷歌设计,后来捐赠给了Cloud Native Computing Foundation(CNCF)。Kubernetes 的设计借鉴了谷歌的专有系统 Borg,Borg 已经在超大规模的环境中运行容器多年。Kubernetes 是谷歌尝试重新开始的结果,彻底从头开始设计一个系统,结合了 Borg 中所有学到的经验教训。
与 Borg(一个专有技术)不同,Kubernetes 从一开始就开源了。谷歌做出这个选择非常明智,因为它吸引了大量来自公司外部的贡献者,并且在短短几年内,围绕 Kubernetes 形成了一个更为庞大的生态系统。你可以理直气壮地说,Kubernetes 是容器编排领域的宠儿。没有其他编排工具能够产生如此大的热度,并吸引如此多的有才华的人们,他们愿意以贡献者或早期采纳者的身份为项目的成功做出有意义的贡献。
在这方面,Kubernetes 在容器编排领域给我的感觉很像 Linux 在服务器操作系统领域的地位。Linux 已经成为服务器操作系统的事实标准。所有相关的公司,如微软、IBM、亚马逊、Red Hat,甚至 Docker,都已经接受了 Kubernetes。
有一点是不可否认的:Kubernetes 从一开始就是为大规模可扩展性而设计的。毕竟,它的设计是基于 Google Borg 的。
一个可能对 Kubernetes 提出的负面意见是,它仍然很复杂,至少在写作时是这样。对于新手来说,存在一个显著的门槛。第一步陡峭,但一旦你与这个编排工具合作了一段时间,所有一切都会变得清晰。整体设计经过深思熟虑,并且执行得非常好。
在 Kubernetes 1.10 版本中,其正式发布(GA)是在 2018 年 3 月,解决了与其他编排工具(如 Docker Swarm)相比的大多数初期不足之处。例如,安全性和保密性现在不仅仅是事后考虑,而是系统的核心部分。
新功能以惊人的速度被实现。新版本大约每三个月发布一次,确切地说,大约每 100 天发布一次。大多数新功能都是由需求驱动的,也就是说,使用 Kubernetes 编排其关键应用的公司可以提出他们的需求。这使得 Kubernetes 成为企业级可用的工具。如果认为这个编排工具仅适合初创公司,而不适合风险规避的大型企业,那就是错误的。恰恰相反。我的这个观点是基于这样的事实:像微软、Docker 和 Red Hat 这样的公司,其客户大多是大型企业,已经完全接受了 Kubernetes,并为其提供企业级支持,特别是在其产品中使用并集成 Kubernetes。
Kubernetes 支持 Linux 和 Windows 容器。
Docker Swarm
众所周知,Docker 推广并使软件容器商品化。Docker 没有发明容器,但标准化了容器并使其广泛可用,尤其是通过提供免费的镜像仓库——Docker Hub。最初,Docker 主要关注开发者和开发生命周期。然而,开始使用并喜爱容器的公司,很快也希望不仅在新应用的开发或测试阶段使用它们,还希望在生产环境中运行这些应用。
起初,Docker 在这个领域没有什么可以提供的,因此其他公司填补了这个空白,为用户提供帮助。但不久之后,Docker 认识到,市场上对一个简单而强大的编排工具有着巨大的需求。Docker 的第一次尝试是推出名为 Classic Swarm 的产品。它是一个独立的产品,使用户能够创建一个 Docker 主机集群,用于以高度可用和自我修复的方式运行和扩展其容器化应用。
然而,Docker Classic Swarm 的设置过程非常困难,涉及很多复杂的手动步骤。客户非常喜欢这个产品,但却因其复杂性而感到困惑。因此,Docker 决定做得更好。它重新开始设计并提出了 SwarmKit。
SwarmKit 于 2016 年 DockerCon 在西雅图发布,并成为 Docker Engine 最新版本的一个组成部分。是的,你没听错,SwarmKit 曾经是并且直到今天依然是 Docker Engine 的核心组成部分。因此,如果你安装了一个 Docker 主机,那么你就自动获得了 SwarmKit。
SwarmKit 的设计考虑了简易性和安全性。它的核心理念是,设置一个 Swarm 应该几乎是微不足道的,而且 Swarm 在默认情况下必须具有高度的安全性。Docker Swarm 假设最小权限原则。
安装一个完整的、高可用性的 Docker Swarm 实际上非常简单,只需在集群中的第一个节点上运行 docker swarm init
,该节点将成为所谓的领导节点,然后在其他所有节点上运行 docker swarm join <join-token>
。<join-token>
是在初始化时由领导节点生成的。在最多 10 个节点的 Swarm 中,整个过程不到 5 分钟。如果自动化执行,所需时间更短。
正如我之前提到的,当 Docker 设计和开发 SwarmKit 时,安全性是最重要的需求之一。容器通过依赖 Linux 内核命名空间和 cgroups,以及 Linux 系统调用白名单 (seccomp) 和支持 Linux 能力以及 Linux 安全模块 (LSM) 来提供安全性。现在,在这些基础上,SwarmKit 添加了 MTLS 和在静态和传输过程中都加密的密钥。
此外,Swarm 定义了所谓的 容器网络模型 (CNM),该模型支持软件定义网络(SDN),为运行在 Swarm 上的应用服务提供沙箱功能。Docker SwarmKit 支持 Linux 和 Windows 容器。
Apache Mesos 和 Marathon
Apache Mesos 是一个开源项目,最初旨在让一群服务器或节点从外部看起来像一个单一的大型服务器。Mesos 是一款简化计算机集群管理的软件。Mesos 的用户不需要关心单个服务器,而只需假设他们拥有一个巨大的资源池,这些资源池对应着集群中所有节点的资源总和。
在 IT 术语中,Mesos 已经算是相当老旧了,至少与其他调度器相比是这样。它最早在 2009 年公开展示,但那时当然并没有设计用于运行容器,因为 Docker 当时还不存在。类似于 Docker 对容器的处理,Mesos 使用 Linux 的 cgroups 来隔离资源,如 CPU、内存或磁盘 I/O,以供单个应用程序或服务使用。
Mesos 实际上是其他有趣的服务的底层基础设施,许多有意思的服务都是建立在它之上的。从容器的角度来看,Marathon 非常重要。Marathon 是一个运行在 Mesos 之上的容器调度器,能够扩展到数千个节点。
Marathon 支持多种容器运行时,例如 Docker 或它自有的 Mesos 容器。它不仅支持无状态的应用服务,还支持有状态的应用服务,例如 PostgreSQL 或 MongoDB 等数据库。类似于 Kubernetes 和 Docker SwarmKit,它支持本章前面提到的许多特性,如高可用性、健康检查、服务发现、负载均衡和位置感知等,这些只是其中一些最重要的功能。
尽管 Mesos,以及在某种程度上 Marathon,是相当成熟的项目,但它们的应用范围相对有限。它似乎在大数据领域最为流行,也就是用于运行数据处理服务,如 Spark 或 Hadoop。
亚马逊 ECS
如果你正在寻找一个简单的调度器,并且已经深度融入 AWS 生态系统,那么亚马逊的 ECS 可能是适合你的选择。值得指出的是 ECS 有一个非常重要的限制:如果你选择了这个容器调度器,那么你就会被锁定在 AWS 平台中。你将无法轻松地将运行在 ECS 上的应用程序移植到其他平台或云端。
亚马逊将其 ECS 服务推广为一个高度可扩展、快速的容器管理服务,使得在集群中运行、停止和管理 Docker 容器变得更加容易。除了运行容器外,ECS 还提供直接访问许多其他 AWS 服务,这些服务可以从运行在容器内部的应用程序服务中访问。这种与许多流行 AWS 服务的紧密无缝集成,使得 ECS 对于那些寻找简便方法以在一个强大且高度可扩展环境中启动和运行其容器化应用的用户非常有吸引力。亚马逊还提供了其自有的私人镜像注册表。
使用 AWS ECS,您可以通过 Fargate 完全管理底层基础设施,使您能够专注于部署容器化应用程序,且无需关心如何创建和管理节点集群。ECS 支持 Linux 和 Windows 容器。
总结来说,ECS 简单易用、可扩展性强,并且与其他流行的 AWS 服务集成得很好,但它不如 Kubernetes 或 Docker SwarmKit 那样强大,而且仅在亚马逊 AWS 平台上可用。
AWS EKS
亚马逊弹性 Kubernetes 服务(EKS)是 AWS 提供的一项托管 Kubernetes 服务。它简化了使用 Kubernetes 进行容器化应用程序的部署、管理和扩展,使开发人员和运维团队能够专注于构建和运行应用程序,而无需管理 Kubernetes 控制平面的负担。
EKS 与各种 AWS 服务无缝集成,例如弹性负载均衡、Amazon RDS 和 Amazon S3,使得构建一个完全托管、可扩展且安全的容器化应用程序基础设施变得轻而易举。它还支持 Kubernetes 生态系统,允许用户利用现有的工具、插件和扩展来管理和监控他们的应用程序。
使用亚马逊 EKS,Kubernetes 控制平面由 AWS 自动管理,确保高可用性、自动更新和安全补丁。用户只需负责管理其工作节点,这些节点可以通过 Amazon EC2 实例或 AWS Fargate 进行部署。
微软 ACS 和 AKS
与我们之前提到的 ECS 类似,我们可以对微软的 ACS 作出同样的评价。它是一个简单的容器编排服务,如果你已经深度投资于 Azure 生态系统,那么它是有意义的。我应该和我提到亚马逊 ECS 时一样说:如果你选择了 ACS,那么你就把自己锁定在微软的产品中。从 ACS 迁移容器化应用程序到其他平台或云环境将并非易事。
ACS 是微软的容器服务,支持多种编排器,如 Kubernetes、Docker Swarm 和 Mesos DC/OS。随着 Kubernetes 的越来越流行,微软的关注焦点显然已转向该编排器。微软甚至重新品牌化其服务,并将其命名为Azure Kubernetes Service(AKS),以便将焦点集中在 Kubernetes 上。
AKS 在 Azure 中为您管理托管的 Kubernetes、Docker Swarm 或 DC/OS 环境,使您能够专注于您希望部署的应用程序,无需担心配置基础设施。微软用自己的话说如下:
“AKS 使得部署和管理容器化应用程序变得快速且简单,无需容器编排的专业知识。它还通过按需提供、升级和扩展资源,消除了持续运营和维护的负担,而无需让您的应用程序停机。”
总结
本章展示了为何首先需要编排器,以及它们是如何在概念上工作的。它指出了在写作时最突出的编排器,并讨论了不同编排器之间的主要共性和差异。
下一章将介绍 Docker 的原生编排器 SwarmKit。它将详细讲解 SwarmKit 用来在集群中部署和运行分布式、弹性、稳健和高可用应用的所有概念和对象——无论是在本地还是在云中。
深入阅读
以下链接提供了一些关于编排相关主题的深入见解:kubernetes.Io/
-
Kubernetes—生产级 编排:
kubernetes.io/
-
Docker Swarm 模式概述:
docs.docker.com/engine/swarm/
-
Mesosphere—容器编排 服务:
bit.ly/2GMpko3
-
容器和编排 解析:
bit.ly/2DFoQgx
问题
回答以下问题以评估你的学习进度:
-
什么是容器编排引擎?
-
为什么我们需要容器编排引擎?
-
容器编排引擎的主要任务是什么?
-
一些流行的容器编排引擎有哪些?
-
容器编排如何提高应用的可靠性?
-
容器编排引擎如何帮助应用扩展?
-
Kubernetes 和 Docker Swarm 之间的主要区别是什么?
-
容器编排引擎如何处理服务发现?
答案
下面是一些问题的可能答案:
-
容器编排引擎是一个自动化部署、扩展、管理和网络化容器的系统。它帮助开发人员和运维团队管理大量容器,确保它们在分布式环境中跨多个主机高效可靠地运行。
-
随着应用中容器和服务数量的增加,手动管理变得越来越困难。容器编排引擎自动化了容器管理的过程,能够实现高效的资源利用、高可用性、容错能力以及容器化应用的无缝扩展。
-
容器编排引擎的主要任务包括以下几个方面:
-
容器部署:根据资源需求和约束将容器部署到合适的主机
-
扩展:根据应用需求自动增加或减少容器的数量
-
负载均衡:将网络流量分配到各个容器,确保最佳性能
-
服务发现:使容器能够找到并与彼此通信
-
健康监控:监控容器健康状况并自动替换不健康的容器
-
数据持久性和存储管理:管理存储卷并确保数据在容器重启和故障后保持持久性。
-
安全性和访问控制:管理容器安全、网络策略和访问控制。
-
-
一些流行的容器编排引擎包括 Kubernetes、Docker Swarm、Apache Mesos、Microsoft ACS、Microsoft AKS 和 Amazon ECS。
-
容器编排引擎通过确保容器部署在适当的主机上、监控容器健康状况,并自动替换不健康或失败的容器,从而提高应用程序的可靠性。它们还通过在容器之间分配网络流量来帮助保持应用程序的可用性,使系统能够优雅地处理故障和流量高峰。
-
容器编排引擎可以根据需求、资源使用情况和预定义规则,自动扩展应用程序,添加或移除容器。这确保了应用程序能够处理不同级别的流量和工作负载,同时优化资源使用。
-
Kubernetes 和 Docker Swarm 都是容器编排引擎,但它们有一些关键区别:
-
Kubernetes 功能更丰富且灵活,提供广泛的功能和可扩展性。Docker Swarm 更简单,易于设置,注重易用性并与 Docker 生态系统的集成。
-
Kubernetes 使用声明式方法,允许用户描述系统的期望状态,而 Docker Swarm 则使用更具命令式的方法。
-
相比 Docker Swarm,Kubernetes 的学习曲线更陡峭,而 Docker Swarm 的学习曲线较浅,对于已经熟悉 Docker 的用户来说更加简单直接。
-
相比 Docker Swarm,Kubernetes 拥有更大的社区、更广泛的文档和更多的第三方集成。
-
-
容器编排引擎通过提供容器间发现和通信的机制来处理服务发现。它们通常为容器分配唯一的网络地址或主机名,并维护这些地址的注册表。容器可以使用这些地址与应用程序中的其他服务进行通信。一些编排引擎还提供内置的负载均衡和基于 DNS 的服务发现,简化这一过程。
第十四章:14
介绍 Docker Swarm
在上一章中,我们介绍了编排工具。就像乐团中的指挥,编排工具确保我们所有的容器化应用服务能够和谐地一起工作,并为共同的目标做出贡献。这些编排工具有很多责任,我们已经详细讨论过。最后,我们简要概述了市场上最重要的容器编排工具。
本章介绍了 Docker 的原生编排工具SwarmKit。详细阐述了 SwarmKit 用来在集群中部署和运行分布式、弹性、稳健和高可用应用程序的所有概念和对象,不论是在本地环境还是云环境中。本章还介绍了 SwarmKit 如何通过使用软件定义网络(SDN)来隔离容器,从而确保应用程序的安全。我们将学习如何在本地、一个叫做Play with Docker(PWD)的特殊环境中,及在云中创建 Docker Swarm。最后,我们将部署一个由多个与 Docker Swarm 相关的服务组成的应用程序。
本章将讨论以下主题:
-
Docker Swarm 架构
-
堆栈、服务和任务
-
多主机网络
-
创建 Docker Swarm
-
部署第一个应用程序
完成本章后,你将能够做以下事情:
-
在白板上勾画出高可用 Docker Swarm 的关键部分
-
用两三句简单的语言向感兴趣的外行解释什么是(Swarm)服务
-
在 AWS、Azure 或 GCP 上创建一个高可用性的 Docker Swarm,其中包括三个管理节点和两个工作节点
-
成功部署一个像 Nginx 这样的复制服务到 Docker Swarm 中
-
扩展和缩减运行中的 Docker Swarm 服务
-
检索复制的 Docker Swarm 服务的聚合日志
-
为一个由至少两个交互服务组成的示例应用程序编写一个简单的堆栈文件
-
将堆栈部署到 Docker Swarm
让我们开始吧!
Docker Swarm 架构
从 30,000 英尺的高度来看,Docker Swarm 的架构由两部分组成:一个奇数个管理节点的 Raft 共识组,以及一个通过 Gossip 网络相互通信的工作节点组,这个网络也被称为控制平面。下图展示了这一架构:
图 14.1 – Docker Swarm 的高级架构
管理节点负责管理 Swarm,而工作节点则执行部署到 Swarm 中的应用程序。每个管理节点都有 Swarm 完整状态的副本,存储在本地的 Raft 存储中。管理节点同步地相互通信,它们的 Raft 存储始终保持同步。
另一方面,工作节点为了可扩展性原因是异步地相互通信的。在一个 Swarm 中,工作节点的数量可以达到数百,甚至数千个。
现在我们对 Docker Swarm 有了一个高层次的概览,接下来让我们更详细地描述 Docker Swarm 的所有组成部分。
Swarm 节点
Swarm 是一个节点集合。我们可以将节点分类为物理计算机或虚拟机(VM)。如今,物理计算机通常被称为裸金属。人们用“裸金属”来区分与虚拟机上的运行。
当我们在这样的节点上安装 Docker 时,我们称这个节点为 Docker 主机。以下图示更清楚地展示了节点和 Docker 主机的概念:
图 14.2 – Docker Swarm 节点的裸金属和虚拟机类型
要成为 Docker Swarm 的成员,节点必须是 Docker 主机。Docker Swarm 中的节点可以有两种角色:它可以是管理节点,也可以是工作节点。管理节点做它名字所暗示的工作;它们管理 Swarm。而工作节点则执行应用程序负载。
从技术上讲,管理节点也可以是工作节点,因此可以运行应用程序负载——尽管不推荐这样做,特别是当 Swarm 是一个运行关键任务应用程序的生产系统时。
Swarm 管理节点
每个 Docker Swarm 至少需要包含一个管理节点。出于高可用性的考虑,我们应该在 Swarm 中有多个管理节点。这对于生产环境或类似生产的环境尤为重要。如果我们有多个管理节点,那么这些节点将使用 Raft 共识协议一起工作。Raft 共识协议是一种标准协议,通常用于多个实体需要共同工作,并始终需要就接下来执行哪个操作达成一致的场景。
为了良好运作,Raft 共识协议要求在所谓的共识组中有一个奇数数量的成员。因此,我们应该始终有 1、3、5、7 等个管理节点。在这样的共识组中,总是会有一个领导者。在 Docker Swarm 中,第一个启动 Swarm 的节点最初会成为领导者。如果领导者离开,剩余的管理节点会选举出一个新的领导者。共识组中的其他节点被称为跟随者。
Raft 领导者选举
Raft 使用心跳机制来触发领导者选举。当服务器启动时,它们会首先作为跟随者存在。只要服务器接收到来自领导者或候选者的有效远程过程调用(RPCs),它就保持在跟随者状态。领导者会定期向所有跟随者发送心跳,以维持其权威。如果跟随者在一段时间内没有收到任何通信(该时间段称为选举超时),它就假设没有可行的领导者,并开始选举一个新的领导者。在选举过程中,每台服务器都会启动一个随机选择的计时器。当计时器触发时,服务器将自己从跟随者变为候选者。同时,它会增加 term 值,并向所有对等节点发送投票请求,等待回应。
在 Raft 共识算法的上下文中,“term”对应于一次选举轮次,并作为系统的逻辑时钟,使 Raft 能够检测到过时的信息,如过期的领导者。每次发起选举时,term 值都会增加。
当服务器收到投票请求时,只有在候选者的 term 值较高或候选者的 term 与自身相同时,服务器才会投票。否则,投票请求将被拒绝。每个对等节点每个 term 只能投给一个候选者,但如果它收到的投票请求的 term 值比之前投票的候选者更高,它将放弃之前的投票。
在 Raft 和许多其他分布式系统的上下文中,“日志”指的是状态机日志或操作日志,而不是传统的应用程序日志。
如果候选者在下一个计时器触发前没有获得足够的票数,当前投票将作废,候选者将以更高的 term 值开始新一轮选举。一旦候选者获得大多数对等节点的票数,它就会将自己从候选者转为领导者,并立即广播其权威,以防止其他服务器开始领导者选举。领导者会定期广播这一信息。现在,假设我们因为维护原因关闭了当前的领导者节点,剩余的管理节点将选举新的领导者。当之前的领导者节点重新上线时,它将变为跟随者,而新的领导者将继续担任领导者职务。
所有共识组成员彼此之间同步通信。每当共识组需要做出决策时,领导者会向所有跟随者请求同意。如果大多数管理节点给予肯定回答,领导者就会执行任务。这意味着,如果我们有三个管理节点,那么至少一个跟随者必须同意领导者。如果我们有五个管理节点,那么至少两个跟随者必须同意领导者。
由于所有管理节点必须与领导节点同步通信以在集群中做出决策,随着我们形成共识组的管理节点数量增加,决策过程变得越来越慢。Docker 的推荐做法是在开发、演示或测试环境中使用一个管理节点。在小型到中型 Swarm 中使用三个管理节点,在大型到超大型 Swarm 中使用五个管理节点。在 Swarm 中使用超过五个管理节点几乎是没有必要的。
管理节点不仅负责管理 Swarm,还负责维护 Swarm 的状态。我们说的“状态”是什么意思?当我们谈论 Swarm 的状态时,我们指的是关于它的所有信息——例如,Swarm 中有多少个节点,每个节点的属性是什么,如名称或 IP 地址。我们还指的是哪些容器在 Swarm 中的哪个节点上运行等等。而 Swarm 的状态中不包含的是由在 Swarm 上运行的容器中的应用服务所产生的数据。这些被称为应用数据,绝对不属于由管理节点管理的状态:
图 14.3 – 一个 Swarm 管理节点共识组
所有的 Swarm 状态都存储在每个管理节点上的高性能键值存储(kv-store)中。没错,每个管理节点都存储整个 Swarm 状态的完整副本。这种冗余使 Swarm 具有高度可用性。如果一个管理节点发生故障,其余的管理节点都可以快速访问完整的状态。
如果一个新的管理节点加入共识组,那么它会与该组的现有成员同步 Swarm 状态,直到它拥有完整的副本。在典型的 Swarm 中,这种复制通常非常快速,但如果 Swarm 很大并且上面运行着许多应用程序,它可能需要一些时间。
Swarm 工作节点
正如我们之前提到的,Swarm 工作节点的任务是托管和运行包含实际应用服务的容器,这些是我们希望在集群中运行的服务。它们是 Swarm 的“工作马”。理论上,管理节点也可以是工作节点。但正如我们所说的,这在生产系统中并不推荐。在生产系统中,我们应该让管理节点专职管理。
工作节点通过所谓的控制平面相互通信。它们使用 gossip 协议进行通信。这种通信是异步的,这意味着在任何给定的时刻,不是所有工作节点都处于完美同步状态。
现在,你可能会问——工作节点交换哪些信息?这些信息主要是服务发现和路由所需的信息,也就是关于哪些容器正在运行在什么节点上的信息,等等:
图 14.4 – 工作节点之间的通信
在前面的示意图中,你可以看到工作节点之间是如何相互通信的。为了确保在大规模 Swarm 中传播(gossip)能够良好扩展,每个工作节点仅与三个随机邻居同步自己的状态。对于熟悉大 O 符号的人来说,这意味着使用传播协议的工作节点同步的扩展是 O(0)。
大 O 符号解释
大 O 符号是一种描述给定算法的速度或复杂度的方式。它告诉你一个算法将执行多少次操作。它用于传达一个算法的速度,这在评估他人的算法和自己算法时都非常重要。
例如,假设你有一个数字列表,并且你想在列表中查找一个特定的数字。你可以使用不同的算法来完成这个任务,比如简单查找或二分查找。简单查找会逐个检查列表中的数字,直到找到你要找的数字。另一方面,二分查找则会反复将列表分成两半,直到找到你要找的数字。
现在,假设你有一个包含 100 个数字的列表。对于简单查找,最坏情况下,你需要检查所有 100 个数字,所以需要 100 次操作。而对于二分查找,最坏情况下,你只需检查大约 7 个数字(因为 log2(100) 大约是 7),所以只需要 7 次操作。
在这个例子中,二分查找比简单查找要快。但如果你有一个包含 10 亿个数字的列表呢?简单查找需要进行 10 亿次操作,而二分查找只需要大约 30 次操作(因为 log2(10 亿) 大约是 30)。因此,随着列表的增大,二分查找比简单查找要快得多。
大 O 符号用于描述算法之间速度的差异。在大 O 符号中,简单查找被描述为 O(n),这意味着操作的数量随着列表大小(n)的增长呈线性增长。二分查找被描述为 O(log n),这意味着操作的数量随着列表大小的增长呈对数增长。
工作节点是被动的。它们除了运行由管理节点分配的工作负载外,通常不会主动做其他任何事情。不过,工作节点会确保以其最大能力运行这些工作负载。在本章稍后部分,我们将详细了解管理节点分配给工作节点的具体工作负载。
现在我们知道了 Docker Swarm 中的主节点和工作节点,我们将介绍堆栈、服务和任务。
堆栈、服务和任务
使用 Docker Swarm 而非单个 Docker 主机时,出现了范式的变化。我们不再谈论运行进程的单个容器,而是将其抽象为表示每个进程副本集合的服务,通过这种方式实现高可用性。我们也不再谈论拥有固定名称和 IP 地址的单个 Docker 主机来部署容器;现在我们将谈论部署服务的主机集群。我们不再关心单个主机或节点,我们不再给它赋予有意义的名称;每个节点对我们来说只是一个数字。
我们现在不再关心单个容器以及它们的部署位置——我们只关心通过服务定义的期望状态。我们可以尝试通过下面的图示来表示这一点:
图 14.5 – 容器被部署到已知的服务器上
与前面图示中的做法不同,之前我们将 web 容器部署到 IP 地址为 52.120.12.1
的 alpha 服务器,将支付容器部署到 IP 为 52.121.24.33
的 beta 服务器,现在我们切换到这个新的服务和 Swarm(或者更广义的集群)范式:
图 14.6 – 服务被部署到 Swarm 集群
在前面的图示中,我们看到一个 web 服务和一个库存服务都被部署到由多个节点组成的 Swarm 集群中。每个服务都有一定数量的副本:web 服务有五个副本,库存服务有七个副本。我们并不关心这些副本会在哪个节点上运行;我们只关心所请求的副本数量始终在 Swarm 调度器决定将它们放在哪些节点上时保持运行。
话虽如此,现在让我们介绍一下在 Docker Swarm 中服务的概念。
服务
Swarm 服务是一个抽象概念。它是我们希望在 Swarm 中运行的应用程序或应用服务的期望状态描述。Swarm 服务就像一个清单,描述以下内容:
-
服务的名称
-
用于创建容器的镜像
-
运行的副本数量
-
服务容器所连接的网络
-
应映射的端口
拥有这个服务清单后,Swarm 管理器会确保如果实际状态与期望状态发生偏差时,始终会将它们调整回期望状态。所以,例如,如果某个服务的实例崩溃了,Swarm 管理器上的调度器就会在有空闲资源的节点上调度该服务的一个新实例,以便重新建立期望的状态。
那么,任务是什么呢?这就是我们接下来要学习的内容。
任务
我们已经了解到,服务对应的是应用服务应始终处于的期望状态的描述。该描述的一部分是服务应运行的副本数量。每个副本由一个任务表示。在这方面,Swarm 服务包含一个任务集合。在 Docker Swarm 中,任务是一个原子部署单元。服务的每个任务都由 Swarm 调度器部署到一个工作节点。任务包含工作节点运行基于镜像的容器所需的所有信息,而镜像是服务描述的一部分。在任务和容器之间,存在一对一的关系。容器是运行在工作节点上的实例,而任务是容器作为 Swarm 服务一部分的描述。
最后,让我们在 Docker Swarm 的背景下讨论一下栈。
栈
现在我们对 Swarm 服务和任务有了很好的了解,接下来我们可以介绍栈。栈用于描述一组相关的 Swarm 服务,它们很可能是因为属于同一个应用程序而关联的。从这个意义上讲,我们也可以说栈描述的是一个由一个或多个服务组成的应用程序,我们希望在 Swarm 上运行这些服务。
通常,我们在一个使用 YAML 格式的文本文件中声明一个栈,并且该文件使用与已知的 Docker Compose 文件相同的语法。这导致了一种情况,人们有时会说栈是由 Docker Compose 文件描述的。更好的说法是,栈是在一个使用与 Docker Compose 文件相似语法的栈文件中描述的。
让我们尝试通过以下图示来说明栈、服务和任务之间的关系,并将其与栈文件的典型内容联系起来:
图 14.7 – 显示栈、服务和任务之间关系的图示
在前面的图示中,我们可以看到右侧是一个示例栈的声明式描述。该栈包含三个服务,分别是 web
、payments
和 inventory
。我们还看到,web
服务使用的是 example/web:1.0
镜像,并且有四个副本。在图示的左侧,我们看到栈包含了前面提到的三个服务。每个服务又包含了若干任务,副本数目就是任务的数量。在 web
服务的情况下,我们有一个包含四个任务的集合。每个任务包含将从其启动容器的镜像名称,一旦任务被调度到 Swarm 节点上,容器便会启动。
现在,既然你已经对 Docker Swarm 的主要概念有了很好的理解,比如节点、栈、服务和任务,让我们更仔细地看看在 Swarm 中使用的网络。
多主机网络
在第十章,使用单主机网络中,我们讨论了容器如何在单一 Docker 主机上进行通信。现在,我们有一个由多个节点或 Docker 主机构成的 Swarm 集群。位于不同节点上的容器需要能够相互通信。许多技术可以帮助我们实现这个目标。Docker 选择为 Docker Swarm 实现一个覆盖网络驱动程序。这个覆盖网络允许连接到同一覆盖网络的容器相互发现并自由通信。以下是覆盖网络工作原理的示意图:
图 14.8 – 覆盖网络
我们有两个节点或 Docker 主机,IP 地址分别为172.10.0.15
和172.10.0.16
。我们选择的 IP 地址值并不重要;重要的是这两个主机有不同的 IP 地址,并且通过物理网络(网络电缆)连接,这个物理网络被称为底层网络。
在左侧的节点上,我们有一个运行中的容器,IP 地址为10.3.0.2
;在右侧的节点上,我们有另一个容器,IP 地址为10.3.0.5
。现在,前者容器想要与后者容器通信。这个过程怎么实现呢?在第十章,使用单主机网络中,我们已经看到当两个容器位于同一节点时,如何通过使用 Linux 桥接来实现这种通信。但 Linux 桥接仅在本地运行,无法跨节点工作。所以,我们需要其他机制。此时,Linux VXLAN 来到救援。VXLAN 从容器技术出现之前就已在 Linux 中可用。
VXLAN 解释
VXLAN,即虚拟扩展局域网,是一种网络协议,它通过使用 UDP 协议在 IP 网络上创建虚拟的二层域。它的设计目的是解决 IEEE 802.1q 中 VLAN ID 数量有限(4,096)的难题,通过将标识符的大小扩展到 24 位(16,777,216)。
简而言之,VXLAN 允许创建可以跨越不同物理位置的虚拟网络。例如,某些运行在不同主机上的虚拟机可以通过 VXLAN 隧道进行通信。这些主机可以位于不同的子网,甚至在全球不同的数据中心。从虚拟机的角度来看,同一 VXLAN 中的其他虚拟机在同一个二层域内。
当 图 14.8 中左侧的容器发送数据包时,桥接器意识到该数据包的目标不在此主机上。现在,每个参与 Overlay 网络的节点都会获得一个所谓的 VXLAN 隧道端点(VTEP)对象,它会拦截数据包(此时的数据包是 OSI 第 2 层的数据包),并用一个包含目标主机的 IP 地址的标头将其包装起来(这将其转变为 OSI 第 3 层的数据包),然后通过 VXLAN 隧道发送。隧道另一端的 VTEP 会解包数据包并将其转发给本地桥接器,本地桥接器再将其转发给目标容器。
Overlay 驱动程序包含在 SwarmKit 中,并且在大多数情况下是 Docker Swarm 推荐的网络驱动程序。还有其他可以支持多节点的第三方网络驱动程序,可以作为插件安装在每个参与的 Docker 主机中。经过认证的网络插件可以从 Docker 商店获得。
很好,我们已经掌握了关于 Docker Swarm 的所有基础知识。那么,接下来我们就来创建一个。
创建 Docker Swarm
创建 Docker Swarm 几乎是微不足道的。它非常简单,以至于如果你了解编排器的工作原理,可能会觉得这几乎难以置信。但这是真的,Docker 在使 Swarm 变得简单而优雅方面做得非常出色。同时,Docker Swarm 已被证明在大企业使用时非常稳健且可扩展。
创建一个本地单节点 Swarm
所以,够了,别再想象了——让我们演示一下如何创建一个 Swarm。在最简单的形式下,一个完全运行的 Docker Swarm 只包含一个节点。如果你使用 Docker Desktop,甚至是 Docker Toolbox,那么你的个人电脑或笔记本电脑就是这样的一个节点。因此,我们可以从这里开始,并演示一些 Swarm 的最重要功能。
让我们初始化一个 Swarm。在命令行中,只需输入以下命令:
$ docker swarm init
经过极短的时间后,你应该看到如下输出:
Swarm initialized: current node (zqzxn4bur43lywp55fysnymd4) is now a manager.To add a worker to this swarm, run the following command:
docker swarm join --token SWMTKN-1-57ayqfyc8cdg09hi9tzuztzcg2gk2rd6abu71ennaide3r20q5-21j3wpm8scytn9u5n1jrvlbzf 192.168.0.13:2377
To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.
我们的计算机现在是一个 Swarm 节点。它的角色是管理者,并且是领导者(在管理者中是领导者,因为目前只有一个管理者)。虽然 docker swarm init
命令仅用了非常短的时间就完成,但在此期间,该命令做了很多事情。以下是其中的一些:
-
它创建了一个根 证书 授权中心(CA)
-
它创建了一个 kv-store,用来存储整个 Swarm 的状态
现在,在前面的输出中,我们可以看到一个命令,可以用来将其他节点加入我们刚刚创建的 Swarm。该命令如下:
$ docker swarm join --token <join-token> <IP address>:2377
这里,我们有以下内容:
-
<join-token>
是 Swarm 领导者在初始化 Swarm 时生成的令牌 -
<IP 地址>
是领导者的 IP 地址
尽管我们的集群保持简单,因为它只包含一个成员,但我们仍然可以要求 Docker CLI 列出 Swarm 的所有节点,使用 docker node ls
命令。这将类似于以下截屏:
图 14.9 – 列出 Docker Swarm 的节点
在这个输出中,我们首先看到赋予节点的 ID。跟随 ID 的星号(*
)表示这是执行了 docker node ls
命令的节点—基本上表明这是活动节点。然后,我们有节点的(人类可读的)名称及其状态、可用性和管理状态。正如前面提到的,这个 Swarm 的第一个节点自动成为了领导者,这在前面的截屏中已经显示出来了。最后,我们看到我们正在使用的 Docker Engine 版本。
要获取关于节点的更多信息,我们可以使用 docker node inspect
命令,如下截断输出所示:
$ docker node inspect node1[
{
"ID": "zqzxn4bur43lywp55fysnymd4",
"Version": {
"Index": 9
},
"CreatedAt": "2023-04-21T06:48:06.434268546Z",
"UpdatedAt": "2023-04-21T06:48:06.955837213Z",
"Spec": {
"Labels": {},
"Role": "manager",
"Availability": "active"
},
"Description": {
"Hostname": "node1",
"Platform": {
"Architecture": "x86_64",
"OS": "linux"
},
"Resources": {
"NanoCPUs": 8000000000,
"MemoryBytes": 33737699328
},
"Engine": {
"EngineVersion": "20.10.17",
"Plugins": [
{
"Type": "Log",
"Name": "awslogs"
},
...
}
]
该命令生成了大量信息,因此我们只呈现了输出的缩短版本。例如,在需要排除集群节点行为不端时,此输出非常有用。
在继续之前,请不要忘记使用以下命令关闭或解散该群集:
$ docker swarm leave --force
在接下来的部分,我们将使用 PWD 环境来生成和使用 Docker Swarm。
使用 PWD 生成 Swarm
要试验 Docker Swarm 而不必在本地计算机上安装或配置任何东西,我们可以使用 PWD。PWD 是一个可以通过浏览器访问的网站,提供了创建最多五个节点的 Docker Swarm 的能力。正如其名称所示,它绝对是一个游乐场,并且我们可以使用它的时间限制为每个会话四个小时。我们可以打开任意多个会话,但每个会话在四小时后会自动结束。除此之外,它是一个完全功能的 Docker 环境,非常适合玩弄 Docker 或展示一些功能。
现在访问该网站。在浏览器中导航至网站 labs.play-with-docker.com
。您将看到一个欢迎和登录界面。使用您的 Docker ID 登录。成功登录后,您将看到一个类似以下截屏的界面:
图 14.10 – PWD 窗口
我们立即可以看到一个大计时器,从四小时开始倒计时。这就是我们在此会话中可以使用的时间。此外,我们看到一个+ 添加新实例链接。点击它以创建一个新的 Docker 主机。当你这样做时,你的屏幕应该如下截屏所示:
图 14.11 – PWD 带有一个新节点
在左侧,我们可以看到新创建的节点及其 IP 地址(192.168.0.13
)和名称(node1
)。在右侧,上半部分显示了该新节点的一些附加信息,底部是一个终端窗口。是的,这个终端窗口用于在我们刚创建的节点上执行命令。该节点已安装 Docker CLI,因此我们可以在其上执行所有熟悉的 Docker 命令,比如 Docker 版本命令。试试看。
但现在我们想创建一个 Docker Swarm。在浏览器的终端中执行以下命令:
$ docker swarm init --advertise-addr=eth0
前述命令生成的输出与我们在创建本地 Docker Swarm 时看到的类似。需要注意的是 join
命令,它是我们希望用来将其他节点加入到刚创建的集群中的命令。
你可能注意到我们在 Swarm init
命令中指定了 --advertise-addr
参数。为什么在这里需要这个呢?原因是 PWD 生成的节点关联了多个 IP 地址。我们可以通过在节点上执行 ip
命令轻松验证这一点。这个命令会显示出确实存在两个端点,eth0
和 eth1
。因此,我们必须明确指定给新的 Swarm 管理节点使用哪个 IP 地址。在我们的例子中,是 eth0
。
在 PWD 中创建四个额外的节点,通过点击 node2
、node3
、node4
和 node5
四次,它们将会在左侧列出。如果你点击左侧的某个节点,右侧将显示该节点的详细信息以及一个终端窗口。
选择每个节点(2 到 5),并在相应的终端中执行你从主节点(node1
)复制过来的 docker swarm join
命令:
$ docker swarm join --token SWMTKN-1-4o1ybxxg7cv... 192.168.0.13:2377
这个节点作为工作节点加入了 Swarm。
一旦你将所有四个节点加入到 Swarm,切换回 node1
并列出所有节点:
$ docker node ls
这,毫不意外地,生成了如下输出(为了可读性稍作重新格式化):
ID HOSTNAME STATUS AVAIL. MANAGER ST. ENGINE VER.Nb16ey2p... * node1 Ready Active Leader 20.10.17
Kdd0yv15... node2 Ready Active 20.10.17
t5iw0clx... node3 Ready Active 20.10.17
Nr6ngsgs... node4 Ready Active 20.10.17
thbiwgft... node5 Ready Active 20.10.17
仍然在 node1
上,我们现在可以提升,比如将 node2
和 node3
提升为 Swarm 管理节点,以实现高度可用:
$ docker node promote node2 node3
这将生成以下输出:
Node node2 promoted to a manager in the swarm.Node node3 promoted to a manager in the swarm.
有了这个,我们在 PWD 上的 Swarm 就可以接受工作负载了。我们创建了一个高度可用的 Docker Swarm,包含三个管理节点,它们组成一个 Raft 共识组,以及两个工作节点。
在云中创建 Docker Swarm
到目前为止,我们创建的所有 Docker Swarm 都非常适合用于开发、实验或演示目的。不过,如果我们想创建一个可以作为生产环境的 Swarm,用来运行我们至关重要的应用程序,那么我们需要在云端或本地创建一个——我敢说——真正的 Swarm。在本书中,我们将演示如何在 AWS 中创建一个 Docker Swarm。
我们可以通过 AWS 控制台手动创建一个 Swarm:
-
登录到你的 AWS 账户。如果你还没有账户,可以创建一个免费的。
-
首先,我们创建一个 AWS
aws-docker-demo-sg
:-
导航到你的默认 VPC。
-
在左侧,选择
aws-docker-demo-sg
,如前所述,并添加描述,例如用于我们的 Docker 演示的 SG
。 -
现在,点击
sg-030d0...
-
类型:自定义 UDP,协议:UDP,端口范围:7946,来源:自定义
-
在值的选项中,选择刚刚创建的 SG。
-
类型:自定义 TCP,协议:TCP,端口范围:7946,来源:自定义
-
在值的选项中,选择刚刚创建的 SG。
-
类型:自定义 TCP,协议:TCP,端口范围:4789,来源:自定义
-
在值的选项中,选择刚刚创建的 SG。
-
类型:自定义 TCP,协议:TCP,端口范围:22,来源:我的 IP
-
这条规则是为了能够通过 SSH 从你的主机访问实例。
-
Docker Swarm 端口
TCP 端口 2377:这是 Swarm 模式的主要通信端口。Swarm 管理和编排命令通过此端口进行通信。它用于节点之间的通信,并在 Raft 共识算法中扮演着至关重要的角色,确保 Swarm 中的所有节点作为一个单一系统进行操作。
TCP 和 UDP 端口 7946:此端口用于节点之间的通信(容器网络发现)。它帮助 Swarm 中的节点交换有关在每个节点上运行的服务和任务的信息。
UDP 端口 4789:此端口用于覆盖网络流量。当你为服务创建覆盖网络时,Docker Swarm 使用此端口进行容器之间的数据流量传输。
图 14.12 – AWS SG 的入站规则
-
完成后,点击 保存规则。
-
进入 EC2 控制面板。
-
首先,我们为接下来要创建的所有 EC2 实例创建一个密钥对:
-
定位并点击
aws-docker-demo
。 -
确保私钥文件的格式为
.pem
。 -
点击
.pem
文件并保存在安全位置。
-
-
回到 EC2 控制面板,使用以下设置启动一个新的 EC2 实例:
-
将实例命名为
manager1
。 -
选择
t2.micro
作为实例类型。 -
使用我们之前创建的密钥对,名为
aws-docker-demo
。 -
选择我们之前创建的现有 SG,
aws-docker-demo-sg
。 -
然后,点击 启动 按钮。
-
-
重复前一步骤,创建两个工作节点,分别命名为
worker1
和worker2
。 -
进入 EC2 实例列表。你可能需要等待几分钟,直到它们都准备好。
-
从
manager1
实例开始,选择它并点击ssh
。仔细按照这些指令操作。 -
一旦连接到
manager1
实例,让我们安装 Docker:$ sudo apt-get update && sudo apt -y install docker.io
这可能需要几分钟时间才能完成。
-
现在,确保你可以在不使用
sudo
命令的情况下使用 Docker:$ sudo usermod -aG docker $USER
-
为了应用前面的命令,你需要快速退出 AWS 实例:
$ exit
然后,立即使用 步骤 6 中的 ssh
命令重新连接。
-
回到 EC2 实例,确保你可以通过以下命令访问 Docker:
$ docker version
如果一切安装和配置正确,您应该会看到 Docker 客户端和引擎的版本信息。
-
现在对另外两个 EC2 实例
worker1
和worker2
重复步骤 6到步骤 10。 -
现在回到您的
manager1
实例并初始化 Docker Swarm:$ docker swarm init
输出应该与您在本地或 PWD 上创建 Swarm 时看到的情况相同。
-
从前面的输出中复制
docker swarm join
命令。 -
转到每个工作节点并运行该命令。节点应该会返回以下内容:
This node joined a swarm as a worker.
-
返回
manager1
节点并运行以下命令以列出 Swarm 的所有节点:$ docker node ls
您应该看到的内容类似于此:
图 14.13 – AWS 上的 Swarm 节点列表
现在我们在(AWS)云中有了一个 Docker Swarm,让我们向其中部署一个简单的应用程序。
部署第一个应用程序
我们已经在各种平台上创建了一些 Docker Swarm。一旦创建,Swarm 在任何平台上的行为都是相同的。我们在 Swarm 上部署和更新应用程序的方式与平台无关。避免在使用 Swarm 时发生供应商锁定,一直是 Docker 的主要目标之一。Swarm 就绪的应用程序可以毫不费力地从本地运行的 Swarm 迁移到基于云的 Swarm。例如,技术上完全可以将 Swarm 的一部分运行在本地,另一部分运行在云端。当然,这样做时,我们必须考虑到地理上远距离节点之间可能带来的较高延迟。
现在我们已经有了一个高可用的 Docker Swarm,接下来是时候在上面运行一些工作负载了。我正在使用刚刚在 AWS 上创建的 Swarm。我们将首先通过创建一个服务来开始。为此,我们需要通过 SSH 连接到一个管理节点。我选择了manager1
实例上的 Swarm 节点:
$ ssh -i "aws-docker-demo.pem" <public-dns-name-of-manager1>
我们通过创建一个服务来启动第一个应用程序的部署。
创建服务
服务可以作为堆栈的一部分或直接使用 Docker CLI 创建。让我们先来看一个定义单个服务的示例堆栈文件:
-
使用 Vi 编辑器创建一个名为
stack.yml
的新文件,并添加以下内容:version: "3.7"services: whoami: image: training/whoami:latest networks: - test-net ports: - 81:8000 deploy: replicas: 6 update_config: parallelism: 2 delay: 10s labels: app: sample-app environment: prod-southnetworks: test-net: driver: overlay
-
通过先按Esc键,然后输入
:wq
,再按Enter键退出 Vi 编辑器。这将保存代码片段并退出 vi。
注意
如果您不熟悉 Vi 编辑器,也可以使用 nano 编辑器。
在前面的示例中,我们可以看到名为whoami
的服务的期望状态:
-
它基于
training/whoami:latest
镜像 -
服务的容器连接到
test-net
网络 -
容器端口
8000
已发布到端口81
-
它运行了六个副本(或任务)
-
在滚动更新期间,单个任务按批次更新,每批次包含两个任务,并且每个成功批次之间有 10 秒的延迟。
-
服务(及其任务和容器)被分配了两个标签,
app
和environment
,值分别为sample-app
和prod-south
还有许多其他设置可以为服务定义,但上述设置是一些更为重要的设置。大多数设置都有有意义的默认值。例如,如果我们未指定副本数量,则 Docker 默认为 1
。服务的名称和镜像当然是必需的。请注意,服务的名称在 Swarm 中必须是唯一的。
-
要创建上述服务,我们使用
docker stack deploy
命令。假设包含上述内容的文件名为stack.yaml
,我们有以下内容:$ docker stack deploy -c stack.yaml sample-stack
这里,我们创建了一个名为 sample-stack
的堆栈,包含一个服务,whoami
。
-
我们可以列出在我们的 Swarm 中所有的堆栈:
$ docker stack ls
完成后,我们应该会看到如下内容:
NAME SERVICESsample-stack 1
-
我们可以列出在 Swarm 中定义的服务,如下所示:
$ docker service ls
我们会得到如下输出:
图 14.14 – 列出在 Swarm 中运行的所有服务
在输出中,我们可以看到目前只有一个服务在运行,这是预期的结果。该服务有一个 ID。与您之前用于容器、网络或卷的 ID 格式不同,该 ID 是字母数字组合(而之前的 ID 格式总是 SHA-256)。我们还可以看到,服务名称是我们在堆栈文件中定义的服务名称与堆栈名称的组合,堆栈名称作为前缀使用。这是合理的,因为我们希望能够通过相同的堆栈文件将多个(不同名称的)堆栈部署到我们的 Swarm 中。为了确保服务名称唯一,Docker 决定将服务名称和堆栈名称组合起来。
在第三列中,我们看到模式是“复制模式”。副本的数量显示为 6/6
。这告诉我们,六个副本中有六个正在运行。这对应于期望的状态。在输出中,我们还可以看到服务使用的镜像和服务的端口映射。
检查服务及其任务
在上述输出中,我们看不到已创建的六个副本的详细信息。
为了深入了解这一点,我们可以使用 docker service ps <service-id>
命令。如果我们对我们的服务执行此命令,将获得如下输出:
图 14.15 – whoami 服务的详细信息
在上面的输出中,我们可以看到六个任务的列表,这些任务对应我们请求的六个 whoami
服务副本。在 NODE 列中,我们还可以看到每个任务被部署到的节点。每个任务的名称是服务名称加上递增的索引。此外,注意到类似于服务本身,每个任务也会分配一个字母数字组合的 ID。
就我而言,显然任务 3 和 6,名称分别是 sample-stack_whoami.3
和 sample-stack_whoami.6
,已经部署到 ip-172-31-32-21
,这是我们 Swarm 的领导节点。因此,我应该会在此节点上找到一个正在运行的容器。让我们看看如果列出 ip-172-31-32-21
上的所有容器会得到什么:
图 14.16 – 节点 ip-172-31-32-21 上的容器列表
正如预期的那样,我们发现一个容器正在运行,来自 training/whoami:latest
镜像,容器名称是其父任务名称和 ID 的组合。我们可以尝试可视化在部署我们的示例堆栈时生成的所有对象层次结构:
图 14.17 – Docker Swarm 堆栈的对象层次结构
一个堆栈可以由一个或多个服务组成。每个服务都有一组任务。每个任务与一个容器一一对应。堆栈和服务是在 Swarm 管理节点上创建和存储的。任务随后被调度到 Swarm 工作节点,在工作节点上创建相应的容器。我们还可以通过检查服务来获取更多关于服务的信息。执行以下命令:
$ docker service inspect sample-stack_whoami
这提供了关于服务所有相关设置的丰富信息。这些包括我们在 stack.yaml
文件中明确定义的设置,但也包括我们没有指定的设置,因此它们被分配了默认值。我们不会在此列出完整的输出,因为它太长,但我鼓励你在自己的机器上检查。我们将在 第十五章 的 Swarm 路由网格 部分中详细讨论部分信息。
测试负载均衡
为了查看 Swarm 如何将传入请求负载均衡到我们的示例 whoami
应用程序,我们可以使用 curl
工具。多次执行以下命令并观察答案的变化:
$ for i in {1..7}; do curl localhost:81; done
这会产生如下输出:
I'm ae8a50b5b058I'm 1b6b507d900c
I'm 83864fb80809
I'm 161176f937cf
I'm adf340def231
I'm e0911d17425c
I'm ae8a50b5b058
请注意,在第六项之后,序列开始重复。这是因为 Docker Swarm 使用轮询算法来进行负载均衡。
服务日志
在之前的章节中,我们处理过容器生成的日志。在这里,我们专注于服务。记住,最终,一个具有多个副本的服务会运行多个容器。因此,我们可以预计,如果我们请求该服务的日志,Docker 会返回该服务所有容器日志的汇总。事实上,当我们使用 docker service
logs
命令时,我们会看到这一点:
$ docker service logs sample-stack_whoami
这是我们得到的结果:
图 14.18 – whoami 服务的日志
此时日志中没有太多信息,但足以讨论我们得到的内容。日志中每一行的第一部分总是包含容器的名称,并与日志条目来源的节点名称结合。然后,通过竖线(Listening
on :8000
)分隔。
使用docker service logs
命令获取的聚合日志没有按特定方式排序。因此,如果事件的关联发生在不同的容器中,你应该在日志输出中添加能使这种关联成为可能的信息。
通常,这个时间戳是每个日志条目的时间标记。但是这必须在源头进行;例如,生成日志条目的应用程序也需要确保添加时间戳。
我们还可以通过提供任务 ID 来查询服务中单个任务的日志,而不是服务 ID 或名称。所以,假设我们通过以下方式查询任务 6 的日志:
$ docker service logs w90b8
这给我们以下输出:
sample-stack_whoami.6.w90b8xmkdw53@ip-172-31-32-21 | Listening on :8000
在下一节中,我们将研究 Swarm 如何重新协调所需状态。
重新协调所需状态
我们已经了解到,Swarm 服务是我们希望应用程序或应用服务运行在的所需状态的描述或清单。现在,让我们看看 Docker Swarm 如何在我们做出一些操作导致服务的实际状态与所需状态不同时,重新协调这个所需状态。最简单的方式就是强制终止服务的一个任务或容器。
让我们使用已调度到node-1
的容器来执行这个操作:
$ docker container rm -f sample-stack_whoami.3\. nqxqs...
如果我们这么做,然后立刻运行docker service ps
,我们将看到以下输出:
图 14.19 – Docker Swarm 在一个任务失败后重新协调所需状态
我们看到任务 2 因退出代码137
失败,并且 Swarm 立即通过将失败的任务重新调度到具有空闲资源的节点上,协调了所需状态。在这种情况下,调度器选择了与失败任务相同的节点,但这并不总是如此。所以,在没有干预的情况下,Swarm 完全修复了问题,并且由于服务以多个副本运行,服务在任何时候都没有停机。
让我们尝试另一个失败场景。这一次,我们将关闭整个节点,看看 Swarm 如何反应。我们以节点ip-172-31-47-124
为例,因为它上面运行了两个任务(任务 1 和任务 4)。为此,我们可以前往 AWS 控制台,在 EC2 仪表板中,停止名为ip-172-31-47-124
的实例。
注意,我必须进入每个工作节点的详细信息,以找出哪个节点的主机名是ip-172-31-47-124
;在我的案例中,它是worker2
。
回到主节点,我们现在可以再次运行docker service ps
来查看发生了什么:
图 14.20 – Swarm 重新调度所有失败节点的任务
在前面的截图中,我们可以看到,任务 1 已立即在节点ip-172-31-32-189
上重新调度,而任务 4 则在节点ip-172-31-32-21
上重新调度。即使是这种更为严重的故障,Docker Swarm 也能优雅地处理。
需要注意的是,如果节点ip-172-31-47-124
在 Swarm 中重新上线,之前在其上运行的任务不会自动转移回该节点。
但是现在该节点已经准备好接受新的工作负载。
删除服务或堆栈
如果我们想从 Swarm 中删除一个特定的服务,可以使用docker service rm
命令。另一方面,如果我们想从 Swarm 中删除一个堆栈,我们可以类比地使用docker stack rm
命令。该命令会删除堆栈定义中的所有服务。以whoami
服务为例,它是通过使用堆栈文件创建的,因此我们将使用后者命令:
$ docker stack rm sample-stack
这给出了以下输出:
Removing service sample-stack_whoamiRemoving network sample-stack_test-net
前面的命令将确保每个服务的所有任务都被终止,并且相应的容器在首先发送SIGTERM
信号后被停止,如果失败,则在 10 秒超时后发送SIGKILL
信号。
需要注意的是,停止的容器不会从 Docker 主机上删除。
因此,建议定期清理工作节点上的容器,以回收未使用的资源。可以使用docker container purge -f
命令来实现这一目的。
问题:为什么在工作节点上保留停止或崩溃的容器而不自动删除它们是合理的?
部署多服务堆栈
在第十一章,《使用 Docker Compose 管理容器》中,我们使用了一个由两个服务组成的应用程序,这些服务在 Docker Compose 文件中以声明方式描述。我们可以使用这个 Compose 文件作为模板,创建一个堆栈文件,使我们能够将相同的应用程序部署到 Swarm 中:
-
创建一个名为
pets-stack.yml
的新文件,并将以下内容添加到其中:version: "3.7"services: web: image: fundamentalsofdocker/ch11-web:2.0 networks: - pets-net ports: - 3000:3000 deploy: replicas: 3 db: image: fundamentalsofdocker/ch11-db:2.0 networks: - pets-net volumes: - pets-data:/var/lib/postgresql/datavolumes: pets-data:networks: pets-net: driver: overlay
我们请求将 Web 服务配置为具有三个副本,并且两个服务都连接到覆盖网络pets-net
。
-
我们可以使用
docker stack
的deploy
命令来部署此应用程序:$ docker stack deploy -c pets-stack.yml pets
这将产生以下输出:
Creating network pets_pets-netCreating service pets_db
Creating service pets_web
Docker 创建了pets_pets-net
覆盖网络,然后创建了两个服务,pets_web
和pets_db
。
- 然后,我们可以列出
pets
堆栈中的所有任务:
图 14.21 – 宠物堆栈中的所有任务列表
- 最后,让我们使用
curl
测试该应用程序,以获取一个包含宠物的 HTML 页面。确实,应用程序按预期工作,返回了期望的页面:
图 14.22 – 使用 curl 测试宠物应用程序
容器 ID 会出现在输出中,类似于 Delivered to you by container d01e2f1f87df
。如果你多次运行 curl
命令,ID 应该在三个不同的值之间循环。这些是我们为 Web 服务请求的三个容器(或副本)的 ID。
- 一旦我们完成操作,就可以使用
docker stack rm pets
命令移除该 stack。
一旦我们在 AWS 上完成了 Swarm 的操作,就可以将其移除。
移除 AWS 中的 Swarm
为了清理 AWS 云中的 Swarm 并避免产生不必要的费用,我们可以使用以下命令:
$ for NODE in `seq 1 5`; do docker-machine rm -f aws-node-${NODE}
done
接下来,让我们总结一下本章的内容。
总结
在本章中,我们介绍了 Docker Swarm,它是仅次于 Kubernetes 的第二大容器编排工具。我们研究了 Swarm 的架构,讨论了 Swarm 中运行的各种资源类型,如服务、任务等,并在 Swarm 中创建了服务。我们学习了如何在本地、在名为 PWD 的特殊环境中以及在云中创建 Docker Swarm。最后,我们部署了一个由多个与 Docker Swarm 相关的服务组成的应用程序。
在下一章中,我们将介绍路由网格,它提供了 Docker Swarm 中的第 4 层路由和负载均衡。之后,我们将演示如何将由多个服务组成的第一个应用程序部署到 Swarm 中。我们还将学习如何在更新 Swarm 中的应用程序时实现零停机时间,最后,我们将了解如何在 Swarm 中存储配置数据,以及如何使用 Docker secrets 保护敏感数据。敬请关注。
问题
为了评估你的学习进度,请尝试回答以下问题:
-
什么是 Docker Swarm?
-
Docker Swarm 的主要组件有哪些?
-
如何初始化 Docker Swarm?
-
如何向 Docker Swarm 中添加节点?
-
在 Docker Swarm 中,Docker 服务是什么?
-
如何在 Docker Swarm 中创建和更新服务?
-
什么是 Docker Stack,它与 Docker Swarm 有什么关系?
-
如何在 Docker Swarm 中部署 Docker Stack?
-
Docker Swarm 中的网络选项有哪些?
-
Docker Swarm 如何处理容器的扩展和故障容忍?
答案
以下是前面问题的示例答案:
-
Docker Swarm 是一个内置于 Docker 引擎的原生容器编排工具,它允许你创建、管理和扩展一组 Docker 节点,负责协调多个主机上容器的部署、扩展和管理。
-
Docker Swarm 由两个主要组件组成:管理节点,负责管理集群的状态、协调任务并保持服务的期望状态;以及工作节点,执行任务并运行容器实例。
-
你可以通过在 Docker 主机上运行
docker swarm init
命令来初始化 Docker Swarm,该主机将成为 Swarm 的第一个管理节点。该命令会提供一个令牌,可以用来将其他节点加入到 Swarm 中。 -
要向 Docker Swarm 添加节点,请在新节点上使用
docker swarm join
命令,并提供令牌和现有管理节点的 IP 地址。 -
Docker Service 是一个高级抽象,代表 Docker Swarm 中的容器化应用程序或微服务。它定义了应用程序的所需状态,包括容器镜像、副本数、网络和其他配置选项。
-
你可以使用
docker service create
命令创建一个新的服务,使用docker service update
命令更新现有服务,并随后指定所需的配置选项。 -
Docker Stack 是一组一起部署并共享依赖项的服务,这些依赖项在 Docker Compose 文件中定义。Docker Stack 可以在 Docker Swarm 中部署,以管理和编排多服务应用程序。
-
要在 Docker Swarm 中部署 Docker Stack,请使用
docker stack deploy
命令,后跟堆栈名称和 Docker Compose 文件的路径。 -
Docker Swarm 支持多种网络选项,包括用于负载均衡和路由的默认入口网络、用于跨节点容器间通信的覆盖网络,以及用于特定用例的自定义网络。
-
Docker Swarm 会通过调整副本数来自动管理容器的扩展,以符合在服务定义中指定的所需状态。它还会监控容器的健康状况,并替换任何失败的实例以保持容错性。
第十五章:15
在 Docker Swarm 上部署和运行分布式应用程序
在上一章中,我们详细介绍了 Docker 的原生调度器 SwarmKit。SwarmKit 是 Docker 引擎的一部分,一旦在系统中安装了 Docker,无需额外安装。我们了解了 SwarmKit 用于在集群中部署和运行分布式、弹性、稳健且高可用的应用程序的概念和对象,这些应用程序可以运行在本地或云端。我们还展示了 Docker 的调度器如何使用软件定义网络(SDN)保护应用程序。我们学习了如何在名为“Play with Docker”的特殊环境中以及在云端本地创建 Docker Swarm。最后,我们发现如何将一个由多个相关服务组成的应用程序部署到 Docker Swarm 上。
在本章中,我们将介绍路由网格,它提供第 4 层路由和负载均衡。接下来,我们将演示如何将一个由多个服务组成的第一个应用程序部署到 Swarm 中。我们还将学习如何在 Swarm 中更新应用程序时实现零停机,最后将介绍如何在 Swarm 中存储配置数据,以及如何使用 Docker 秘密保护敏感数据。
本章我们将讨论以下主题:
-
Swarm 路由网格
-
零停机部署
-
在 Swarm 中存储配置数据
-
使用 Docker Secrets 保护敏感数据
完成本章后,您将能够做到以下几点:
-
列出两到三种常见的部署策略,用于在不造成停机的情况下更新服务
-
在不引起服务中断的情况下批量更新服务
-
定义一个回滚策略,以防更新失败时用于恢复服务
-
使用 Docker 配置存储非敏感配置数据
-
使用 Docker 秘密与服务配合使用
-
更新秘密的值而不引起停机
让我们开始吧!
Swarm 路由网格
如果你注意到的话,可能会在上一章中看到一些有趣的现象。我们已经部署了 pets
应用程序,并且该应用程序在三个节点——node-1
、node-2
和 node-3
上安装了 Web 服务的实例。
然而,我们能够通过 localhost
访问 node-1
上的 Web 服务,并且从那里访问每个容器。这是如何实现的呢?这正是所谓的 Swarm 路由网格的功劳。路由网格确保,当我们发布服务的端口时,该端口会在 Swarm 的所有节点上发布。因此,任何访问 Swarm 节点并请求使用特定端口的网络流量,都将通过路由网格转发到其中一个服务容器。让我们看一下下面的图示,了解它是如何工作的:
图 15.1 – Docker Swarm 路由网格
在这种情况下,我们有三个节点,分别为172.10.0.15
、172.10.0.17
和172.10.0.33
。在图的左下角,我们看到创建了一个包含两个副本的网页服务的命令。相应的任务已经被调度到Host B和Host C上。task1 被安排在Host B,而task2 被安排在Host C。
当在 Docker Swarm 中创建服务时,它会自动获得一个10.2.0.1
。
如果现在有一个来自外部的8080
端口的请求,IP 表中会发现这个请求对应的是网页服务的 VIP。
现在,由于 VIP 并非真实目标,IPVS 服务将对与该服务关联的任务的 IP 地址进行负载均衡。在我们的案例中,它选择了10.2.0.3
。最后,Ingress 网络(Overlay) 被用来将请求转发到Host C上的目标容器。
需要注意的是,外部负载均衡器将外部请求转发到哪个 Swarm 节点并不重要。路由网格始终会正确处理请求并将其转发到目标服务的某个任务。
我们已经学到了很多关于 Docker swarm 网络的知识。接下来我们要学习的主题是如何在不造成系统停机的情况下部署应用程序。
零停机部署
需要频繁更新的关键任务应用程序中,最重要的方面之一就是能够以不产生任何停机时间的方式进行更新。我们称之为零停机部署。更新中的应用程序必须始终保持完全可用。
常见的部署策略
实现这一目标有多种方法,其中一些方法如下:
-
滚动更新
-
蓝绿部署
-
金丝雀发布
Docker Swarm 开箱即支持滚动更新。其他两种部署方式需要我们付出额外的努力才能实现。
滚动更新
在关键任务应用中,每个应用服务必须运行多个副本。根据负载,这个数量可以少至两到三个实例,也可以多至几十、几百甚至上千个实例。在任何给定时刻,我们希望所有运行中的服务实例中有明确的大多数。所以,如果我们有三个副本,我们希望至少有两个副本始终运行。如果我们有 100 个副本,我们可以接受最少 90 个副本可用。通过这种方式,我们可以定义在升级时可以停机的副本的批次大小。在第一个案例中,批次大小为 1,而在第二个案例中,批次大小为 10。
当我们停用副本时,Docker Swarm 会自动将这些实例从负载均衡池中移除,所有流量将会被负载均衡到剩余的活动实例上。因此,这些剩余的实例将会经历流量的轻微增加。在下面的图示中,在滚动更新开始之前,如果任务 A3想要访问服务 B,它可以通过 SwarmKit 将流量负载均衡到服务 B的任意一个三个任务中。一旦滚动更新开始,SwarmKit 就会停用任务 B1进行更新。
自动地,这个任务会被从目标池中移除。所以,如果任务 A3现在请求连接到服务 B,负载均衡将只从剩余的任务中选择,也就是任务 B2和任务 B3。因此,这两个任务可能会暂时经历更高的负载:
图 15.2 – 任务 B1 被停用以进行更新
停止的实例随后会被等量的新版本应用服务实例替代。一旦新实例启动并运行,我们可以让 Swarm 在给定时间内监控它们,确保它们是健康的。如果一切正常,那么我们可以继续通过停用下一批实例,并用新版本的实例替换它们。这个过程会一直重复,直到所有的应用服务实例都被替换。
在下面的图示中,我们可以看到服务 B的任务 B1已经更新到版本 2。任务 B1的容器被分配了一个新的 IP 地址,并且它被部署到了一个具有空闲资源的另一个工作节点:
图 15.3 – 在滚动更新中,第一批任务正在更新
需要理解的是,当服务的任务被更新时,在大多数情况下,它会被部署到与原来不同的工作节点上,但只要相应的服务是无状态的,这应该是没问题的。如果我们有一个有状态的服务,它是位置或节点感知的,并且我们想要更新它,那么我们就必须调整方法,但这超出了本书的范围。
现在,让我们来看一下如何实际指示 Swarm 执行应用服务的滚动更新。当我们在stack
文件中声明服务时,我们可以定义多个与此上下文相关的选项。让我们来看一个典型的stack
文件片段:
version: "3.5"services:
web:
image: nginx:alpine
deploy:
replicas: 10
update_config:
parallelism: 2
delay: 10s
...
在这个片段中,我们可以看到一个名为update_config
的部分,其中包含parallelism
和delay
属性。parallelism
定义了滚动更新时每次更新多少副本的批量大小。delay
定义了 Docker Swarm 在更新单个批次之间将等待多长时间。在前面的例子中,我们有 10 个副本,每次更新 2 个实例,并且在每次成功更新后,Docker Swarm 会等待 10 秒。
让我们测试一下这样的滚动更新。导航到我们 sample-solutions
文件夹下的 ch14
子文件夹,使用 web-stack.yaml
文件创建一个已配置滚动更新的 web 服务。该服务使用基于 Alpine 的 Nginx 镜像,版本为 1.12-alpine
。我们将把服务更新到更新版本,即 1.13-alpine
。
首先,我们将把这个服务部署到我们在 AWS 上创建的 Swarm 集群中。
让我们来看一下:
-
通过 SSH 登录到 AWS 上 Docker Swarm 的
master1
实例:$ ssh -i "aws-docker-demo.pem" <public-dns-of-manager1-instance>
-
使用
vi
或nano
创建一个名为web-stack.yml
的文件,并包含以下内容:version: "3.7"services: whoami: image: nginx:1.12-alpine ports: - 81:80 deploy: replicas: 10 update_config: parallelism: 2 delay: 10s
-
现在,我们可以使用
stack
文件来部署服务:$ docker stack deploy -c web-stack.yaml web
上述命令的输出如下所示:
Creating network web_defaultCreating service web_web
-
一旦服务部署完成,我们可以使用以下命令来监控它:
$ watch docker stack ps web
我们将看到以下输出:
图 15.4 – 在 Swarm 中运行的 web 堆栈的 web 服务,包含 10 个副本
之前的命令将持续更新输出,并为我们提供滚动更新过程中发生的情况的概览。
-
现在,我们需要打开第二个终端并为我们 Swarm 的管理节点配置远程访问。完成后,我们就可以执行
docker
命令,更新stack
的 web 服务镜像,也叫web
:$ docker service update --image nginx:1.13-alpine web_web
上述命令会产生以下输出,表示滚动更新的进度:
图 15.5 – 显示滚动更新进度的屏幕
前面的输出表示前两批任务(每批有两个任务)已经成功,第三批即将准备好。
在第一个终端窗口中,我们正在查看 stack
,现在我们应该能看到 Docker Swarm 如何每 10 秒更新一次服务,每批更新后看起来应该像以下截图所示:
图 15.6 – Docker Swarm 中服务的滚动更新
在前面的截图中,我们可以看到任务 2 和任务 10 的第一批已更新。Docker Swarm 正在等待 10 秒钟,以便继续进行下一批任务。
有趣的是,在这个特定案例中,SwarmKit 将新版本的任务部署到与之前版本相同的节点上。这是偶然的,因为我们有五个节点,每个节点上有两个任务。SwarmKit 总是尽力平衡节点之间的工作负载。
所以,当 SwarmKit 停止一个任务时,相应的节点的工作负载会比其他节点小,因此新的实例会被调度到该节点。通常,你无法期望在同一个节点上找到一个新的任务实例。你可以通过删除 stack
(命令:docker stack rm web
)并将副本数更改为七来尝试一下,然后重新部署并更新它。
一旦所有任务更新完成,我们的 docker stack ps web
命令的输出将类似于以下截图:
图 15.7 – 所有任务已成功更新
请注意,SwarmKit 不会立即从相应节点中移除前版本任务的容器。这是有道理的,因为我们可能需要从这些容器中获取日志进行调试,或者可能需要使用 docker container inspect
获取它们的元数据。SwarmKit 会保留最近四个终止的任务实例,在清除较旧的实例之前,以避免系统被未使用的资源阻塞。
我们可以使用 --update-order
参数指示 Docker 在停止旧容器之前先启动新容器副本。这可以提高应用程序的可用性。有效值为 start-first
和 stop-first
。
后者是默认设置。
一旦完成,我们可以使用以下命令拆除 stack
:
$ docker stack rm web
尽管使用 stack
文件来定义和部署应用程序是推荐的最佳实践,但我们也可以在 service create
语句中定义更新行为。如果我们只想部署一个单独的服务,这可能是更优的做法。让我们看看这样的 create
命令:
$ docker service create --name web \ --replicas 10 \
--update-parallelism 2 \
--update-delay 10s \
nginx:alpine
这个命令定义了与前面的stack
文件相同的期望状态。我们希望服务运行 10 个副本,并且希望滚动更新一次更新两项任务,并且每批任务之间有 10 秒的间隔。
健康检查
为了做出明智的决策,例如在 Swarm 服务的滚动更新过程中,判断刚安装的新服务实例批次是否正常运行,或者是否需要回滚,SwarmKit 需要一种方式来了解系统的整体健康状况。就其本身而言,SwarmKit(和 Docker)能够收集相当多的信息,但也有一定的限制。想象一下,一个包含应用程序的容器。从外部看,容器可能看起来非常健康并且运行良好,但这并不意味着容器内部运行的应用程序也同样运行良好。应用程序可能处于无限循环或损坏状态,但仍然在运行。然而,只要应用程序还在运行,容器就会继续运行,从外部看一切看起来都很完美。
因此,SwarmKit 提供了一个接口,我们可以通过它为 SwarmKit 提供一些帮助。我们,作为在 Swarm 中运行的容器应用服务的开发者,最清楚我们的服务是否处于健康状态。SwarmKit 让我们有机会定义一个命令来测试我们的应用服务的健康状态。这个命令具体执行什么操作对 Swarm 来说并不重要;它只需要返回 OK、NOT OK 或 time out。后两种情况,即 NOT OK 或超时,会告诉 SwarmKit 正在调查的任务可能不健康。
这里,我故意用了“可能”这个词,稍后我们会看到为什么:
FROM alpine:3.6…
HEALTHCHECK --interval=30s \
--timeout=10s
--retries=3
--start-period=60s
CMD curl -f http://localhost:3000/health || exit 1
...
在前面的 Dockerfile 代码片段中,我们可以看到 HEALTHCHECK
关键字。它有几个选项或参数以及一个实际的命令,即 CMD
。我们来讨论一下这些选项:
-
--interval
:定义健康检查之间的等待时间。因此,在我们的案例中,调度器每 30 秒执行一次检查。 -
--timeout
:此参数定义了如果健康检查未响应,Docker 应该等待多久才会超时并返回错误。在我们的示例中,设置为 10 秒。现在,如果某个健康检查失败,SwarmKit 会重试几次,直到放弃并将相应的任务标记为不健康,并允许 Docker 终止该任务并用新实例替换它。 -
重试次数由
--retries
参数定义。在前面的代码中,我们希望进行三次重试。 -
接下来是启动周期。某些容器启动需要一些时间(虽然这不是推荐的模式,但有时是不可避免的)。在此启动时间内,服务实例可能无法响应健康检查。通过启动周期,我们可以定义 SwarmKit 在执行第一次健康检查之前应等待多长时间,从而为应用程序初始化提供时间。为了定义启动时间,我们使用
--start-period
参数。在我们的例子中,我们在 60 秒后进行第一次检查。启动周期需要多长时间取决于应用程序及其启动行为。建议从一个较小的值开始,如果出现大量假阳性并且任务被多次重启,您可能希望增加时间间隔。 -
最后,我们在最后一行使用
CMD
关键字定义实际的探测命令。在我们的例子中,我们定义了一个向localhost
上的/health
端点(端口为3000
)发送请求的探测命令。这个调用预计会有三种可能的结果:-
命令成功
-
命令失败
-
命令超时
-
后两个任务会被 SwarmKit 以相同的方式处理。这是调度器在告诉我们相应的任务可能处于不健康状态。我故意用了可能这个词,因为 SwarmKit 并不会立刻假设最坏的情况,而是认为这可能只是任务的暂时异常,任务会从中恢复。这也是我们需要--retries
参数的原因。在这里,我们可以定义 SwarmKit 在假设任务确实不健康之前应尝试多少次,然后它会终止该任务并重新调度一个新实例到另一个空闲节点,以便恢复服务的期望状态。
为什么我们可以在探测命令中使用localhost
?这是一个非常好的问题,原因是 SwarmKit 在探测一个运行在 Swarm 中的容器时,会在容器内部执行该探测命令(也就是说,它会执行类似docker container exec <containerID> <probing command>
的操作)。因此,命令会在与容器内部应用程序相同的网络命名空间中执行。在下面的示意图中,我们可以看到一个服务任务从开始到结束的生命周期:
图 15.8 – 服务任务与暂时性健康失败
首先,SwarmKit 会等到启动期结束后才开始探测。然后,我们进行第一次健康检查。不久之后,任务在探测时失败。它连续两次失败,但随后恢复。因此,健康检查 4是成功的,SwarmKit 保持任务运行。
在这里,我们可以看到一个任务永久失败的情况:
图 15.9 – 任务的永久失败
我们刚刚学习了如何在其镜像的 Dockerfile 中定义服务的健康检查,但这并不是我们能做到的唯一方式。我们还可以在我们用来将应用程序部署到 Docker Swarm 中的stack
文件中定义健康检查。以下是一个stack
文件的简短示例:
version: "3.8"services:
web:
image: example/web:1.0
healthcheck:
test: ["CMD", "curl", "-f", http://localhost:3000/health]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
...
在前面的代码片段中,我们可以看到如何在stack
文件中定义与健康检查相关的信息。首先,要明确的一点是,我们需要为每个服务单独定义健康检查。在应用程序层面或全局层面并没有健康检查。
与我们之前在 Dockerfile 中定义的相似,SwarmKit 用来执行健康检查的命令是curl -f http://localhost:3000/health
。我们还定义了interval
、timeout
、retries
和start_period
,这四个键值对与我们在 Dockerfile 中使用的对应参数意义相同。如果在镜像中定义了与健康检查相关的设置,那么在stack
文件中定义的设置将覆盖 Dockerfile 中的设置。
现在,让我们尝试使用一个已定义健康检查的服务:
-
使用
vi
或nano
创建一个名为stack-health.yml
的文件,内容如下:version: "3.8"services: web: image: nginx:alpine deploy: replicas: 3 healthcheck: test: ["CMD", "wget", "-qO", "-", "http://localhost"] interval: 5s timeout: 2s retries: 3 start_period: 15s
-
让我们部署这个:
$ docker stack deploy -c stack-health.yml myapp
-
我们可以使用
docker stack ps myapp
查看每个集群节点上单个任务的部署情况。因此,在任何特定节点上,我们可以列出所有容器,找到我们堆栈中的一个。在我的示例中,任务 3 被部署到了节点ip-172-31-32-21
,该节点恰好是主节点。 -
现在,列出该节点上的容器:
图 15.10 – 显示正在运行的任务实例的健康状态
这个截图中有趣的地方是状态(STATUS)列。Docker,或者更准确地说,SwarmKit,已经识别到该服务定义了健康检查功能,并正在使用它来确定服务中每个任务的健康状况。
接下来,让我们看看如果发生问题会怎么样。
回滚
有时,事情并不会按预期进行。一个临时修复可能无意中引入了一个新 bug,或者新版本可能显著降低了组件的吞吐量,等等。在这种情况下,我们需要有一个计划 B,这通常意味着能够将更新回滚到先前的良好版本。
与更新一样,回滚必须发生,以确保应用程序没有中断;它需要实现零停机时间。从这个意义上说,回滚可以看作是一个反向更新。我们正在安装一个新版本,但这个新版本实际上是之前的版本。
与更新行为一样,我们可以在stack
文件中或者在 Docker service create
命令中声明,系统在需要执行回滚时应该如何行为。在这里,我们使用了之前的stack
文件,不过这次添加了一些与回滚相关的属性:
version: "3.8"services:
web:
image: nginx:1.12-alpine
ports:
- 80:80
deploy:
replicas: 10
update_config:
parallelism: 2
delay: 10s
failure_action: rollback
monitor: 10s
healthcheck:
test: ["CMD", "wget", "-qO", "-", http://localhost]
interval: 2s
timeout: 2s
retries: 3
start_period: 2s
我们可以创建一个名为stack-rollback.yaml
的堆栈文件,并将前面的内容添加到其中。在这个内容中,我们定义了滚动更新的细节、健康检查和回滚时的行为。健康检查被定义为在初始等待时间 2 秒后,调度器开始每 2 秒轮询一次http://localhost
上的服务,并在认为任务不健康之前重试 3 次。
如果我们算一下时间,那么至少需要 8 秒钟,如果任务因为 bug 而不健康,它才会被停止。所以,在deploy
下,我们有一个新的条目叫做monitor
。该条目定义了新部署的任务应该监控健康状况多长时间,以及是否继续进行滚动更新的下一个批次。在这个示例中,我们设定了 10 秒钟。这比我们计算出来的发现一个缺陷服务已被部署需要的 8 秒钟稍多一些,所以这是合适的。
我们还定义了一个新的条目,failure_action
,它定义了在滚动更新过程中遇到故障(例如服务不健康)时编排器将采取的措施。默认情况下,该操作是停止整个更新过程,并将系统置于中间状态。系统并不会完全宕机,因为这是滚动更新,至少一些健康的实例仍然在运行,但运维工程师会更适合检查并修复问题。
在我们的案例中,我们已将该操作定义为回滚。因此,如果发生故障,SwarmKit 将自动将所有已更新的任务恢复到先前的版本。
蓝绿部署
在第九章,学习分布式应用架构中,我们以抽象的方式讨论了蓝绿部署是什么。结果发现,在 Docker Swarm 中,我们无法真正为任意服务实现蓝绿部署。Docker Swarm 中两项服务之间的服务发现和负载均衡是 Swarm 路由网格的一部分,无法(轻松)自定义。
如果服务 A想调用服务 B,Docker 会隐式地完成这一操作。Docker 会根据目标服务的名称,使用 Docker DNS 服务将该名称解析为 VIP 地址。当请求定向到 VIP 时,Linux IPVS 服务将再次在 Linux 内核 IP 表中查找 VIP,并将请求负载均衡到 VIP 所表示的服务任务的物理 IP 地址之一,如下图所示:
图 15.11 – Docker Swarm 中服务发现和负载均衡是如何工作的
不幸的是,没有简单的方法来拦截这个机制并用自定义行为替换它,但这是实现服务 B的真正蓝绿部署所需要的,就如我们在第十七章中看到的那样,使用 Kubernetes 部署、更新和保护应用程序,Kubernetes 在这方面更为灵活。
话虽如此,我们始终可以以蓝绿方式部署面向公众的服务。我们可以使用interlock 2产品及其 7 层路由机制来实现真正的蓝绿部署。
金丝雀发布
从技术上讲,滚动更新是一种金丝雀发布(canary release),但由于它们缺乏插入自定义逻辑的接缝,滚动更新仅仅是金丝雀发布的一个非常有限的版本。
真正的金丝雀发布要求我们对更新过程有更细粒度的控制。此外,真正的金丝雀发布在 100%的流量都已经通过新版本时,才会停止使用旧版本的服务。从这个角度来看,它们像蓝绿部署(blue-green deployments)一样被处理。
在金丝雀发布场景中,我们不仅希望使用健康检查等因素作为决定是否将更多流量导向新版本服务的决定因素;我们还希望在决策过程中考虑外部输入,如通过日志聚合器收集和汇总的度量数据或追踪信息。作为决策依据的一个例子是是否符合服务水平协议(SLA),即新版本的服务响应时间是否超出了容忍带。如果我们向现有服务添加新功能,但这些新功能降低了响应时间,就可能发生这种情况。
现在我们知道如何实现零停机时间部署应用程序,接下来我们想讨论如何在 Swarm 中存储应用程序使用的配置数据。
在 Swarm 中存储配置数据
如果我们想要在 Docker Swarm 中存储非敏感数据,比如配置文件,那么我们可以使用 Docker 配置。Docker 配置与 Docker 密钥非常相似,后者我们将在下一节讨论。主要的区别是配置值在静态时不会加密,而密钥会。像 Docker 密钥一样,Docker 配置只能在 Docker Swarm 中使用——也就是说,它们不能在非 Swarm 开发环境中使用。Docker 配置会直接挂载到容器的文件系统中。配置值可以是字符串或二进制值,最大支持 500 KB 大小。
使用 Docker 配置,您可以将配置与 Docker 镜像和容器分离。这样,您的服务就可以轻松地使用特定环境的值进行配置。生产环境的 Swarm 配置与临时环境的 Swarm 配置不同,临时环境的配置与开发或集成环境的配置也不同。
我们可以将配置添加到服务中,也可以从正在运行的服务中移除它们。配置甚至可以在 Swarm 中运行的不同服务之间共享。
现在,让我们创建一些 Docker 配置:
-
首先,我们从一个简单的字符串值开始:
$ echo "Hello world" | docker config create hello-config –
请注意 docker config create
命令末尾的连字符。这意味着 Docker 期望从标准输入获取配置的值。这正是我们通过将 Hello world
值通过管道传递给 create
命令所做的。
上述命令的输出结果如下所示:
941xbaen80tdycup0wm01nspr
上述命令创建了一个名为 hello-config
的配置,值为“Hello world
”。该命令的输出是此新配置在 Swarm 中存储的唯一 ID。
-
让我们看看结果,并使用
list
命令查看:$ docker config ls
这将输出以下内容(已被缩短):
ID NAME CREATED UPDATEDrrin36.. hello-config About a minute ago About a minute ago
list
命令的输出将显示我们刚创建的配置的 ID
和 NAME
信息,以及其 CREATED
和(最后)更新时间。然而,配置是非机密的。
-
因此,我们可以做更多的操作,甚至输出配置的内容,像这样:
$ docker config inspect hello-config
输出看起来像这样:
[ {
"ID": "941xbaen80tdycup0wm01nspr",
"Version": {
"Index": 557
},
"CreatedAt": "2023-05-01T15:58:15.873515031Z",
"UpdatedAt": "2023-05-01T15:58:15.873515031Z",
"Spec": {
"Name": "hello-config",
"Labels": {},
"Data": "SGVsbG8gd29ybGQK"
}
}
]
嗯,有趣。在上述 JSON 格式的输出的Spec
子节点中,我们有一个Data
键,其值为SGVsbG8gd29ybGQK
。我们刚刚说配置数据并没有在静止时加密?
-
结果证明,该值只是我们的字符串编码为
base64
,我们可以轻松验证:$ echo 'SGVsbG8gd29ybGQK' | base64 --decode
我们得到以下结果:
Hello world
到目前为止,一切都很顺利。
现在,让我们定义一个稍微复杂一些的 Docker 配置。假设我们正在开发一个 Java 应用程序。Java 传递配置数据到应用程序的首选方式是使用所谓的properties
文件。properties
文件只是一个包含键值对列表的文本文件。让我们看一下:
-
让我们创建一个名为
my-app.properties
的文件,并添加以下内容:username=pguserdatabase=productsport=5432dbhost=postgres.acme.com
-
保存文件并从中创建名为
app.properties
的 Docker 配置:$ docker config create app.properties ./my-app.properties
这给我们一个类似于这样的输出:
2yzl73cg4cwny95hyft7fj80u
-
为准备下一个命令,首先安装
jq
工具:$ sudo apt install –y jq
-
现在,我们可以使用这个(有点牵强的)命令来获取我们刚刚创建的配置的明文值:
$ docker config inspect app.properties | jq .[].Spec.Data | xargs echo | base64 --decode
我们得到了这个输出:
username=pguserdatabase=products
port=5432
dbhost=postgres.acme.com
这正是我们预期的。
-
现在,让我们创建一个使用上述配置的 Docker 服务。为了简单起见,我们将使用
nginx
镜像来实现:$ docker service create \ --name nginx \ --config source=app.properties,target=/etc/myapp/conf/app.properties,mode=0440 \ nginx:1.13-alpine
这导致类似以下的输出:
svf9vmsjdttq4tx0cuy83hpgfoverall progress: 1 out of 1 tasks
1/1: running [==================================================>]
verify: Service converged
在上述的service create
命令中,有趣的部分是包含--config
参数的那一行。通过这一行,我们告诉 Docker 使用名为app.properties
的配置,并将其挂载为一个文件到容器内的/etc/myapp/conf/app.properties
。此外,我们希望该文件被赋予0440
的权限模式,以赋予所有者(root)和组读取权限。
让我们看看我们得到了什么:
$ docker service ps nginxID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
pvj nginx.1 nginx:1.13-alpine ip-172-31-32-21 Running Running 2 minutes ago
在上述输出中,我们可以看到唯一一个服务实例正在节点ip-172-31-32-21
上运行。在这个节点上,我现在可以列出容器以获取nginx
实例的 ID:
$ docker container lsCONTAINER ID IMAGE COMMAND CREATED STATUS PORTS …
44417e1a70a1 nginx:1.13-alpine "nginx -g 'daemon of…" 5 minutes ago Up 5 minutes 80/tcp …
最后,我们可以exec
进入该容器,并输出位于/etc/myapp/conf/app.properties
文件中的值:
$ docker exec 44417 cat /etc/my-app/conf/app.properties
请注意,在上述命令中,44417
代表容器哈希的第一部分。
然后这将给我们预期的值:
username=pguserdatabase=products
port=5432
dbhost=postgres.acme.com
这一点毫不奇怪;这正是我们预期的。
Docker 配置当然也可以从集群中移除,但前提是它们没有被使用。如果我们尝试移除之前正在使用的配置,而没有先停止并移除服务,我们将会得到以下输出:
$ docker config rm app.properties
哦不,这不起作用,我们可以从以下输出看到:
Error response from daemon: rpc error: code = InvalidArgument desc = config 'app.properties' is in use by the following service: nginx
我们得到一个错误消息,Docker 告诉我们该配置正在被我们称为 nginx 的服务使用。这种行为在使用 Docker 卷时我们已经习惯了。
因此,首先我们需要移除服务,然后才能移除配置:
$ docker service rm nginxnginx
现在应该可以工作了:
$ docker config rm app.propertiesapp.properties
再次强调,非常重要的一点是 Docker 配置不应该用于存储诸如密码、访问密钥或关键秘密等机密数据。
在下一节中,我们将讨论如何处理机密数据。
使用 Docker 秘密保护敏感数据
秘密用于以安全的方式处理机密数据。Swarm 的秘密在静止时和传输中都是安全的。也就是说,当在管理节点上创建一个新秘密时,并且它只能在管理节点上创建,其值会被加密并存储在 raft 一致性存储中。这就是为什么它在静止时是安全的原因。如果一个服务被分配了秘密,那么管理节点会从存储中读取该秘密,解密后将其转发给所有请求该秘密的 swarm 服务实例的容器。由于 Docker Swarm 中的节点间通信使用 tmpFS
将数据传递到容器内。默认情况下,秘密会挂载到容器中的 /run/secrets
目录,但你可以将其更改为任何自定义文件夹。
需要注意的是,秘密在 Windows 节点上不会被加密,因为 Windows 上没有类似 tmpfs
的概念。为了实现与 Linux 节点相同的安全级别,管理员应该加密相应 Windows 节点的磁盘。
创建秘密
首先,让我们看看如何实际创建一个秘密:
$ echo "sample secret value" | docker secret create sample-secret -
该命令创建了一个名为 sample-secret
的秘密,其值为 sample secret value
。请注意,docker secret create
命令末尾有一个连字符。这意味着 Docker 期望从标准输入中获取秘密的值。这正是我们通过将 sample secret value
管道传递给 create
命令所做的。
或者,我们也可以使用文件作为秘密值的来源:
-
创建一个
secret-value.txt
文件,如下所示:$ echo "other secret" > secret-value.txt
-
使用以下命令从这个文件创建 Docker 秘密:
$ docker secret create other-secret ./secret-value.txt
在这里,名为 other-secret
的秘密值是从名为 ./secret-value.txt
的文件中读取的。
- 一旦秘密被创建,就无法访问其值。例如,我们可以列出所有的秘密,得到以下输出:
图 15.12 – 所有秘密的列表
在这个列表中,我们只能看到秘密的 ID
和 NAME
信息,以及一些其他元数据,但秘密的实际值是不可见的。
- 我们还可以使用
inspect
命令来查看一个秘密的更多信息,例如查看other-secret
:
图 15.13 – 检查 swarm 秘密
即使在这里,我们也无法得到秘密的值。这显然是故意的:秘密就是秘密,因此必须保持机密。如果我们愿意,我们可以为秘密分配标签,甚至可以使用不同的驱动程序来加密和解密秘密,如果我们不满意 Docker 默认提供的加密方法。
使用秘密
秘密由在 swarm 中运行的服务使用。通常,秘密在创建服务时分配给该服务。因此,如果我们想运行一个名为 web
的服务并为其分配一个秘密,比如 api-secret-key
,语法如下所示:
$ docker service create --name web \ --secret api-secret-key \
--publish 8000:8000 \
training/whoami:latest
该命令基于fundamentalsofdocker/whoami:latest
镜像创建一个名为web
的服务,将容器端口8000
发布到所有集群节点的8000
端口,并分配给它名为api-secret-key
的机密。
这只有在api-secret-key
机密已在集群中定义的情况下才有效;否则,将生成以下错误信息:
secret not found: api-secret-key.
因此,让我们现在创建这个机密:
$ echo "my secret key" | docker secret create api-secret-key -
现在,如果我们重新运行service create
命令,它将成功执行。
现在,我们可以使用docker service ps web
查找唯一的服务实例部署在哪个节点上,然后exec
进入这个容器。在我的情况下,实例已部署到ip-172-31-32-21
节点,这恰好是我正在使用的manager1
EC2 实例。否则,我需要先 SSH 到另一个节点。
然后,我使用docker container ls
列出该节点上的所有容器,找到属于我的服务的实例并复制其容器 ID。接下来,我们可以运行以下命令,确保机密确实在容器内以包含机密值的预期文件名可用:
$ docker exec -it <container ID> cat /run/secrets/api-secret-key
再次说明,在我的情况下,生成的输出如下所示:
my secret key
这显然是我们预期的结果。我们可以看到机密以明文形式显示。
如果由于某种原因,Docker 在容器内挂载机密的默认位置不符合你的要求,你可以定义一个自定义位置。在以下命令中,我们将机密挂载到/app/my-secrets
:
$ docker service create --name web \ --name web \
-p 8000:8000 \
--secret source=api-secret-key,target=/run/my-secrets/api-secret-key \
fundamentalsofdocker/whoami:latest
在这个命令中,我们使用了扩展语法来定义一个包括目标文件夹的机密。
在开发环境中模拟机密
在开发过程中,我们通常在机器上没有本地集群。然而,机密仅在集群中有效。那么,我们该怎么办呢?幸运的是,这个答案非常简单。
由于机密被当作文件处理,我们可以轻松地将包含机密的卷挂载到容器的预期位置,默认情况下,该位置是/run/secrets
。
假设我们在本地工作站上有一个名为./dev-secrets
的文件夹。对于每个机密,我们都有一个与机密名称相同的文件,文件内容是该机密的未加密值。例如,我们可以通过在工作站上执行以下命令,模拟一个名为demo-secret
、值为demo secret value
的机密:
$ echo "demo secret value" > ./dev-secrets/sample-secret
然后,我们可以创建一个挂载此文件夹的容器,如下所示:
$ docker container run -d --name whoami \ -p 8000:8000 \
-v $(pwd)/dev-secrets:/run/secrets \
fundamentalsofdocker/whoami:latest
容器内运行的进程将无法区分这些挂载的文件和来源于机密的文件。因此,例如,demo-secret
作为文件/run/secrets/demo-secret
出现在容器内,并具有预期的值demo secret value
。让我们在接下来的步骤中更详细地了解这一点:
-
为了测试这一点,我们可以在前面的容器中
exec
一个 shell:$ docker container exec -it whoami /bin/bash
-
现在,我们可以导航到
/run/secrets
文件夹并显示demo-secret
文件的内容:/# cd /run/secrets/# cat demo-secretdemo secret value
接下来,我们将讨论秘密和遗留应用程序。
秘密和遗留应用程序
有时,我们想要容器化一个我们无法轻易更改,或者不想更改的遗留应用程序。这个遗留应用程序可能希望一个秘密值作为环境变量可用。那么现在我们该怎么处理呢?Docker 将秘密呈现为文件,但该应用程序期望它们以环境变量的形式存在。
在这种情况下,定义一个在容器启动时运行的脚本(所谓的入口点或启动脚本)是非常有帮助的。这个脚本将从相应的文件中读取秘密值,并定义一个与文件同名的环境变量,将读取到的值分配给该变量。对于名为demo-secret
的秘密,其值应作为名为DEMO_SECRET
的环境变量可用,启动脚本中的必要代码片段可以如下所示:
export DEMO_SECRET=$(cat /run/secrets/demo-secret)
类似地,假设我们有一个遗留应用程序,期望秘密值作为条目存在于位于/app/bin
文件夹中的 YAML 配置文件中,名为app.config
,其相关部分如下所示:
…secrets:
demo-secret: "<<demo-secret-value>>"
other-secret: "<<other-secret-value>>"
yet-another-secret: "<<yet-another-secret-value>>"
…
现在,我们的初始化脚本需要从秘密文件中读取秘密值,并将配置文件中相应的占位符替换为秘密值。对于demo_secret
,可以如下所示:
file=/app/bin/app.confdemo_secret=$(cat /run/secret/demo-secret)
sed -i "s/<<demo-secret-value>>/$demo_secret/g" "$file"
在前面的代码片段中,我们使用sed
工具来替换占位符,将其替换为实际值。我们可以对配置文件中的另外两个秘密使用相同的技巧。
我们将所有初始化逻辑放入一个名为entrypoint.sh
的文件中,使其可执行,并且例如将其添加到容器文件系统的根目录中。然后,我们在 Dockerfile 中将此文件定义为ENTRYPOINT
,或者我们也可以在docker container
的run
命令中覆盖镜像的现有ENTRYPOINT
。
让我们做个示例。假设我们有一个在容器中运行的遗留应用程序,该容器由fundamentalsofdocker/whoami:latest
镜像定义,并且该应用程序需要在名为whoami.conf
的文件中定义一个名为db_password
的秘密。
让我们来看看这些步骤:
-
我们可以在本地机器上定义一个名为
whoami.conf
的文件,其中包含以下内容:database: name: demo db_password: "<<db_password_value>>"others: val1=123 val2="hello world"
重要的是这个代码片段的第 3 行。它定义了启动脚本必须将秘密值放置的位置。
-
让我们向本地文件夹添加一个名为
entrypoint.sh
的文件,并包含以下内容:file=/app/whoami.confdb_pwd=$(cat /run/secret/db-password)sed -i "s/<<db_password_value>>/$db_pwd/g" "$file" /app/http
前面脚本的最后一行源自原始 Dockerfile 中使用的启动命令。
-
现在,改变该文件的权限,使其可执行:
$ sudo chmod +x ./entrypoint.sh
-
现在,我们定义一个从
fundamentalsofdocker/whoami:latest
镜像继承的 Dockerfile。向当前文件夹添加一个名为Dockerfile
的文件,其中包含以下内容:FROM fundamentalsofdocker/whoami:latestCOPY ./whoami.conf /app/COPY ./entrypoint.sh /CMD ["/entrypoint.sh"]
-
让我们从这个 Dockerfile 中构建镜像:
$ docker image build -t secrets-demo:1.0 .
-
一旦镜像构建完成,我们可以从中运行服务,但在此之前,我们需要在 Swarm 中定义密钥:
$ echo "passw0rD123" | docker secret create demo-secret -
-
现在,我们可以创建一个使用以下密钥的服务:
$ docker service create --name demo \--secret demo-secret \secrets-demo:1.0
更新密钥
有时,我们需要更新正在运行的服务中的密钥,因为密钥可能会被泄露或被恶意人员(如黑客)窃取。在这种情况下,一旦我们的机密数据被泄露给不可信的实体,它就必须被视为不安全的,因此我们需要更改它。
更新密钥与任何其他更新一样,都需要实现零停机时间。Docker SwarmKit 在这方面提供了支持。
首先,我们在 swarm 中创建一个新的密钥。建议在创建时使用版本控制策略。在我们的示例中,我们将版本作为密钥名称的后缀。我们最初使用名为 db-password
的密钥,现在这个密钥的新版本叫做 db-password-v2
:
$ echo "newPassw0rD" | docker secret create db-password-v2 -
假设原始服务使用密钥时是这样创建的:
$ docker service create --name web \ --publish 80:80
--secret db-password
nginx:alpine
容器内运行的应用程序可以访问位于 /run/secrets/db-password
的密钥。现在,SwarmKit 不允许我们在运行的服务中更新现有密钥,因此我们必须先删除现有的过时版本密钥,然后再添加新的版本。让我们使用以下命令开始删除:
$ docker service update --secret-rm db-password web
现在,我们可以使用以下命令添加新密钥:
$ docker service update \ --secret-add source=db-password-v2,target=db-password \
web
请注意 --secret-add
命令的扩展语法,其中包含源和目标参数。
总结
在本章中,我们介绍了路由网格,它为 Docker Swarm 提供了第 4 层路由和负载均衡。然后,我们学习了 SwarmKit 如何在不需要停机的情况下更新服务。此外,我们还讨论了 SwarmKit 在零停机部署方面的当前限制。接着,我们展示了如何在 Swarm 中存储配置数据,在本章的最后部分,我们介绍了使用密钥作为提供机密数据给服务的安全方式。
在下一章中,我们将介绍目前最流行的容器编排工具——Kubernetes。我们将讨论在 Kubernetes 集群中定义和运行分布式、具有韧性、稳健性和高可用性的应用程序所使用的对象。此外,本章还将帮助我们了解 MiniKube,这是一个用于在本地部署 Kubernetes 应用程序的工具,并展示 Kubernetes 与 Docker Desktop 的集成。
问题
为了评估你的学习进度,请尝试回答以下问题:
-
用简洁的几句话向感兴趣的外行解释什么是零停机部署。
-
SwarmKit 如何实现零停机部署?
-
与传统(非容器化)系统不同,为什么在 Docker Swarm 中回滚操作能够顺利进行?用简短的几句话解释一下。
-
描述 Docker 密钥的两到三项特性。
-
你需要推出一个新的库存服务版本。你的命令会是什么样子?以下是一些额外的信息:
-
新的镜像名为
acme/inventory:2.1
-
我们希望使用滚动更新策略,批次大小为两个任务
-
我们希望系统在每批次之后等待一分钟
-
-
你需要通过 Docker 机密更新一个名为
inventory
的现有服务,新的密码通过 Docker 机密提供。新的机密名为MYSQL_PASSWORD_V2
。服务中的代码期望机密名为MYSQL_PASSWORD
。更新命令是什么样子的?(请注意,我们不希望更改服务代码!)
答案
以下是前面问题的示例答案:
-
零停机部署意味着分布式应用中的一个服务的新版本可以更新到新的版本,而不需要应用停止工作。通常,使用 Docker SwarmKit 或 Kubernetes(如我们将看到的那样)是以滚动方式完成的。一个服务由多个实例组成,这些实例按批次更新,以确保大多数实例始终处于运行状态。
-
默认情况下,Docker SwarmKit 使用滚动更新策略来实现零停机部署。
-
容器是自包含的部署单元。如果部署了一个新的服务版本且未按预期工作,我们(或系统)只需回滚到之前的版本。之前的服务版本也是以自包含的容器形式进行部署的。从概念上讲,向前推进(更新)或向后回滚(撤销)没有区别。一个版本的容器被另一个版本替代。主机本身不会受到这种变化的影响。
-
Docker 机密在静态存储时是加密的。它们仅传输到使用这些机密的服务和容器。由于 swarm 节点之间的通信使用 mTLS,因此机密是加密传输的。机密永远不会物理存储在工作节点上。
-
实现这一目标的命令如下:
$ docker service update \ --image acme/inventory:2.1 \ --update-parallelism 2 \ --update-delay 60s \ inventory
-
首先,我们需要从服务中删除旧的机密,然后我们需要将新版本的机密添加进去(直接更新机密是不可能的):
$ docker service update \ --secret-rm MYSQL_PASSWORD \ inventory$ docker service update \ --secret-add source=MYSQL_PASSWORD_V2, target=MYSQL_PASSWORD \ inventory
第四部分:Docker、Kubernetes 和云
这一部分介绍了当前最流行的容器编排工具。它介绍了用于在集群中定义和运行分布式、弹性、可靠和高可用性应用的核心 Kubernetes 对象。最后,它介绍了 minikube 作为一种在本地部署 Kubernetes 应用的方式,并涵盖了 Kubernetes 与 Mac 的 Docker 和 Docker Desktop 的集成。
-
第十六章,介绍 Kubernetes
-
第十七章,使用 Kubernetes 部署、更新和保护应用
-
第十八章,在云中运行容器化应用
-
第十九章,在生产环境中监控与排查应用程序故障
第十六章:16
介绍 Kubernetes
本章介绍当前最流行的容器编排工具。它介绍了用于定义和运行分布式、弹性、稳健和高度可用应用程序的 Kubernetes 核心对象。最后,它介绍了 minikube 作为本地部署 Kubernetes 应用程序的一种方式,以及 Kubernetes 与 Docker Desktop 的集成。
我们将讨论以下主题:
-
理解 Kubernetes 架构
-
Kubernetes 主节点
-
集群节点
-
Play with Kubernetes 简介
-
Kubernetes 在 Docker Desktop 中的支持
-
Pod 简介
-
Kubernetes ReplicaSets
-
Kubernetes 部署
-
Kubernetes 服务
-
基于上下文的路由
-
比较 SwarmKit 和 Kubernetes
阅读完本章后,你应该掌握以下技能:
-
在餐巾纸上草拟 Kubernetes 集群的高级架构
-
解释 Kubernetes Pod 的三到四个主要特性
-
用两到三句话描述 Kubernetes ReplicaSets 的作用
-
解释 Kubernetes 服务的两到三个主要职责
-
在 minikube 中创建一个 Pod
-
配置 Docker Desktop 以使用 Kubernetes 作为编排工具
-
在 Docker Desktop 中创建一个 Deployment
-
创建一个 Kubernetes 服务来暴露应用程序服务(内部或外部)到集群中
技术要求
在本章中,如果你想跟随代码示例,你需要安装 Docker Desktop 和一个代码编辑器——最好是 Visual Studio Code:
-
请导航到你克隆示例代码库的文件夹。通常,这应该是
~/The-Ultimate-Docker-Container-Book
:$ cd ~/The-Ultimate-Docker-Container-Book
-
创建一个名为
ch16
的新子文件夹,并进入该文件夹:$ mkdir ch16 && cd ch16
本章讨论的所有示例的完整解决方案可以在sample-solutions/ch16
文件夹中找到,或者直接访问 GitHub:github.com/PacktPublishing/The-Ultimate-Docker-Container-Book/tree/main/sample-solutions/ch16
。
理解 Kubernetes 架构
一个 Kubernetes 集群由一组服务器组成。这些服务器可以是虚拟机或物理服务器,后者也叫裸金属服务器。集群的每个成员都可以有两种角色之一。它要么是 Kubernetes 主节点,要么是(工作)节点。前者用于管理集群,而后者则运行应用程序工作负载。我将工作节点放在括号中,因为在 Kubernetes 术语中,只有在谈到运行应用程序工作负载的服务器时才会提到节点。但在 Docker 和 Swarm 的术语中,相当于工作节点。我认为“工作节点”这一概念更好地描述了服务器的角色,而不仅仅是一个简单的节点。
在一个集群中,你有一个小且奇数数量的主节点,以及根据需要的多个工作节点。小型集群可能只有几个工作节点,而更现实的集群可能有几十个甚至上百个工作节点。从技术上讲,集群可以拥有的工作节点数量没有限制。然而,实际上,当处理成千上万个节点时,某些管理操作可能会出现显著的性能下降。
在 Kubernetes 工作节点上,我们运行的是 Pods。这是一个在 Docker 或 Docker Swarm 中没有的概念。Pod 是 Kubernetes 集群中的原子执行单元。在很多情况下,一个 Pod 只包含一个容器,但一个 Pod 也可以由多个容器共同运行。我们将在本节稍后对 Pods 进行更详细的描述。
集群中的所有成员需要通过物理网络连接,所谓的底层网络。Kubernetes 为整个集群定义了一个平面网络。Kubernetes 本身并不提供任何网络实现,而是依赖于第三方的插件。
Kubernetes 只是定义了容器网络接口(CNI),并将实现留给其他人。CNI 非常简单。它规定,集群中运行的每个 Pod 必须能够与集群中任何其他 Pod 相互连接,而不会发生任何网络地址转换(NAT)。同样的要求也适用于集群节点和 Pods 之间,即,直接在集群节点上运行的应用程序或守护进程必须能够访问集群中的每个 Pod,反之亦然。
下图展示了 Kubernetes 集群的高级架构:
图 16.1 – Kubernetes 的高级架构图
上图的解释如下:
在顶部框中间,我们有一个 etcd
节点集群。etcd
节点是一个分布式键值存储,在 Kubernetes 集群中用于存储集群的所有状态。etcd
节点的数量必须是奇数,正如 Raft 一致性协议所要求的那样,该协议指定了哪些节点用于相互协调。我们谈论集群状态时,并不包括由运行在集群中的应用程序产生或消耗的数据。相反,我们指的是关于集群拓扑、运行的服务、网络设置、使用的密钥等所有信息。也就是说,这个 etcd
集群对整个集群至关重要,因此,我们永远不应在生产环境或任何需要高可用性的环境中仅运行单个 etcd
服务器。
然后,我们有一个 Kubernetes 主节点集群,它们也在彼此之间形成一个共识组,类似于etcd
节点。主节点的数量也必须是奇数。我们可以运行一个单主节点的集群,但在生产环境或关键任务系统中,我们绝不应该这样做。在这种情况下,我们应该始终至少有三个主节点。由于主节点用于管理整个集群,因此我们也在讨论管理平面。
主节点使用etcd
集群作为其后端存储。将负载均衡器(LB)放在主节点前面,并使用一个知名的完全限定域名(FQDN),如admin.example.com
,是一种良好的实践。所有用于管理 Kubernetes 集群的工具应该通过这个负载均衡器访问,而不是直接使用其中一个主节点的公共 IP 地址。这在前面图的左上方有展示。
在图的底部,我们有一个工作节点集群。节点的数量可以少到一个,并且没有上限。
Kubernetes 主节点和工作节点彼此通信。这是一种双向通信方式,不同于我们在 Docker Swarm 中看到的那种通信方式。在 Docker Swarm 中,只有管理节点与工作节点通信,而不会有反向通信。所有访问集群中运行的应用程序的入口流量都应该通过另一个负载均衡器。
这是应用程序负载均衡器或反向代理。我们永远不希望外部流量直接访问任何工作节点。
现在我们对 Kubernetes 集群的高层架构有了一个大致的了解,让我们更深入地探讨 Kubernetes 主节点和工作节点。
Kubernetes 主节点
Kubernetes 主节点用于管理 Kubernetes 集群。以下是这样的主节点的高层次图示:
图 16.2 – Kubernetes 主节点
在前面的图的底部,我们有基础设施,它可以是本地或云端的虚拟机,或者本地或云端的服务器(通常称为裸金属)。
目前,Kubernetes 主节点仅在 Linux 上运行。支持最流行的 Linux 发行版,如 RHEL、CentOS 和 Ubuntu。在这台 Linux 机器上,我们至少有以下四个 Kubernetes 服务在运行:
-
kubectl
用于管理集群和集群中的应用程序。 -
控制器:控制器,或者更准确地说是控制器管理器,是一个控制循环,它通过 API 服务器观察集群的状态并进行更改,尝试将当前状态或有效状态调整为所需状态,如果它们不同的话。
-
调度器:调度器是一项服务,它尽力在工作节点上调度 Pods,同时考虑各种边界条件,如资源需求、策略、服务质量要求等。
-
用于存储集群状态所有信息的
etcd
。更准确地说,作为集群存储的etcd
不一定需要与其他 Kubernetes 服务安装在同一个节点上。有时,Kubernetes 集群配置为使用独立的etcd
服务器集群,如图 16.1所示。但选择使用哪种变体是一个高级管理决策,超出了本书的范围。
我们至少需要一个主节点,但为了实现高可用性,我们需要三个或更多主节点。这与我们在学习 Docker Swarm 的管理节点时学到的非常相似。在这方面,Kubernetes 主节点相当于 Swarm 管理节点。
Kubernetes 主节点从不运行应用工作负载。它们的唯一目的是管理集群。Kubernetes 主节点构建了一个 Raft 共识组。Raft 协议是一种标准协议,通常用于一组成员需要做出决策的场景。它被许多知名的软件产品所使用,如 MongoDB、Docker SwarmKit 和 Kubernetes。有关 Raft 协议的更详细讨论,请参见进一步阅读部分中的链接。
在主节点上运行工作负载
有时,特别是在开发和测试场景中,使用单节点 Kubernetes 集群是有意义的,这样它自然就成了主节点和工作节点。但这种场景应该避免在生产环境中使用。
如前所述,Kubernetes 集群的状态存储在etcd
节点中。如果 Kubernetes 集群需要高可用性,则etcd
节点也必须配置为 HA 模式,通常意味着我们至少在不同的节点上运行三个etcd
实例。
我们再一次声明,整个集群状态存储在etcd
节点中。这包括所有关于集群节点的信息、所有 ReplicaSets、Deployments、Secrets、网络策略、路由信息等。因此,拥有一个强大的备份策略来保护这个键值存储至关重要。
现在,让我们看看将实际运行集群工作负载的节点。
集群节点
集群节点是 Kubernetes 调度应用工作负载的节点。它们是集群的工作马。一个 Kubernetes 集群可以有几个、几十个、几百个,甚至几千个集群节点。Kubernetes 从一开始就为高扩展性而构建。别忘了,Kubernetes 是以 Google Borg 为模型构建的,后者已经运行了数万个容器多年:
图 16.3 – Kubernetes 工作节点
工作节点——它是集群节点,就像主节点一样——可以在虚拟机、裸金属、内部部署或云中运行。最初,工作节点只能配置在 Linux 上。但自 Kubernetes 1.10 版本以来,工作节点也可以在 Windows Server 2010 或更高版本上运行。拥有包含 Linux 和 Windows 工作节点的混合集群是完全可以接受的。
在每个节点上,我们需要运行以下三项服务:
-
YAML
或JSON
格式,它们声明性地描述了一个 Pod。我们将在下一部分了解 Pod 是什么。PodSpecs
主要通过 API 服务器提供给 Kubelet。 -
从版本 1.9 起,
containerd
被用作容器运行时。在此之前,它使用的是 Docker 守护进程。还可以使用其他容器运行时,如rkt
或CRI-O
。容器运行时负责管理和运行 Pod 中的各个容器。 -
kube-proxy:最后是 kube-proxy。它作为一个守护进程运行,是一个简单的网络代理和负载均衡器,用于所有在该节点上运行的应用服务。
现在我们已经了解了 Kubernetes 的架构以及主节点和工作节点,接下来是介绍我们可以用来开发针对 Kubernetes 应用的工具。
Play with Kubernetes 介绍
Play with Kubernetes 是一个由 Docker 赞助的免费沙盒,用户可以在其中学习如何使用 Docker 容器并将其部署到 Kubernetes:
-
使用您的 GitHub 或 Docker 凭据登录。
-
成功登录后,通过点击屏幕左侧的+ 添加新实例按钮,创建第一个集群节点或实例。
-
按照屏幕上的指示创建您的 Kubernetes 沙盒集群的第一个主节点。
-
使用终端窗口中步骤 1中指示的命令初始化集群主节点。最好直接从那里复制命令。命令应如下所示:
$ kubeadm init --apiserver-advertise-address \ $(hostname -i) --pod-network-cidr 10.5.0.0/16
第一个命令参数使用主机名称来广告 Kubernetes API 服务器的地址,第二个命令定义了集群应使用的子网。
-
接下来,如控制台中的步骤 2 所示,在我们的 Kubernetes 集群中初始化网络(注意,以下命令应为单行):
$ kubectl apply -f https://raw.githubusercontent.com/cloudnativelabs/kube-router/master/daemonset/kubeadm-kuberouter.yaml
-
通过再次点击添加新实例按钮,创建第二个集群节点。
-
一旦节点准备就绪,运行在步骤 4中输出的
join
命令,其中<token-1>
和<token-2>
是特定于您集群的:$ kubeadm join 192.168.0.13:6443 --token <token-1> \> --discovery-token-ca-cert-hash <token-2>
最好直接从 Play with Kubernetes 中的命令行复制正确的命令。
-
一旦第二个节点加入集群,请在第一个节点上运行以下命令,该节点是您初始化集群的地方,用于列出新集群中的节点集合:
$ kubectl get nodes
输出应该类似于以下内容:
NAME STATUS ROLES AGE VERSIONnode1 Ready control-plane,master 6m28s v1.20.1
node2 Ready <none> 32s v1.20.1
请注意,撰写本文时,Play with Kubernetes 使用的是 Kubernetes 1.20.1 版本,这个版本现在已经比较旧了。目前可用的最新稳定版本是 1.27.x。但不用担心,我们示例使用的 1.20.x 版本已经足够。
现在,让我们尝试在这个集群上部署一个 pod。暂时不用担心 pod 是什么,我们将在本章后面详细讲解。此时,只需要按现状理解即可。
-
在你的章节代码文件夹中,创建一个名为
sample-pod.yaml
的新文件,并添加以下内容:apiVersion: v1kind: Podmetadata: name: nginx labels: app: nginxspec: containers: - name: nginx image: nginx:alpine ports: - containerPort: 80 - containerPort: 443
-
现在,为了在 Play with Kubernetes 上运行前述的 pod,我们需要复制前面的
yaml
文件内容,并在我们集群的node1
上创建一个新文件: -
使用
vi
创建一个名为sample-pod.yaml
的新文件。 -
按下 I(字母 “i”)进入
vi
编辑器的插入模式。 -
将复制的代码片段用 Ctrl + V(或在 Mac 上使用 Command + V)粘贴到此文件中。
-
按下 Esc 键进入
vi
的命令模式。 -
输入
:wq
并按 Enter 键保存文件并退出vi
。
提示
为什么在示例中使用 Vi 编辑器?它是任何 Linux(或 Unix)发行版中都已安装的编辑器,因此始终可用。你可以在这里找到 Vi 编辑器的快速教程:www.tutorialspoint.com/unix/unix-vi-editor.htm
。
-
现在让我们使用名为
kubectl
的 Kubernetes CLI 来部署这个 pod。kubectl
CLI 已经安装在你 Play with Kubernetes 集群的每个节点上:$ kubectl create -f sample-pod.yaml
这样做会产生以下输出:
pod/nginx created
-
现在列出所有的 pods:
$ kubectl get pods
我们应该看到以下内容:
NAME READY STATUS RESTARTS AGEnginx 1/1 Running 0 51s
-
为了能够访问这个 pod,我们需要创建一个 Service。让我们使用
sample-service.yaml
文件,其中包含以下内容:apiVersion: v1kind: Servicemetadata: name: nginx-servicespec: type: NodePort selector: app: nginx ports: - name: nginx-port protocol: TCP port: 80 targetPort: http-web-svc
再次提醒,不必担心此时Service究竟是什么,我们稍后会解释。
-
让我们创建这个 Service:
$ kubectl create -f sample-service.yaml
-
现在让我们看看 Kubernetes 创建了什么,并列出集群上定义的所有服务:
$ kubectl get services
我们应该看到类似这样的内容:
图 16.4 – 服务列表
请注意 PORT(S)
列。在我的情况下,Kubernetes 将 Nginx 的 80
容器端口映射到 31384
节点端口。我们将在下一条命令中使用这个端口。确保你使用的是系统上分配的端口号!
-
现在,我们可以使用
curl
访问该服务:$ curl -4 http://localhost:31384
我们应该收到 Nginx 欢迎页面作为回应。
-
在继续之前,请删除你刚才创建的两个对象:
$ kubectl delete po/nginx$ kubectl delete svc/nginx-service
请注意,在前述命令中,po
快捷方式相当于 pod
或 pods
。kubectl
工具非常灵活,允许使用这样的缩写。同样,svc
是 service
或 services
的缩写。
在接下来的部分,我们将使用 Docker Desktop 及其对 Kubernetes 的支持,运行与本部分相同的 pod 和服务。
Docker Desktop 中的 Kubernetes 支持
从 18.01-ce 版本开始,Docker Desktop 开始支持开箱即用的 Kubernetes。开发人员如果希望将容器化应用程序部署到 Kubernetes 中,可以使用这个编排工具,而不是 SwarmKit。Kubernetes 支持默认是关闭的,需要在设置中启用。第一次启用 Kubernetes 时,Docker Desktop 需要一些时间来下载创建单节点 Kubernetes 集群所需的所有组件。与 minikube(它也是单节点集群)不同,Docker 工具提供的版本使用了所有 Kubernetes 组件的容器化版本:
图 16.5 – Docker Desktop 中的 Kubernetes 支持
上述图表大致展示了 Kubernetes 支持是如何被添加到 Docker Desktop 中的。macOS 上的 Docker Desktop 使用 hyperkit 来运行基于 LinuxKit 的虚拟机。Windows 上的 Docker Desktop 使用 Hyper-V 来实现这一结果。在虚拟机内部,安装了 Docker 引擎。引擎的一部分是 SwarmKit,它启用了 Swarm 模式。Docker Desktop 使用 kubeadm
工具在虚拟机中设置和配置 Kubernetes。以下三个事实值得一提:Kubernetes 将其集群状态存储在 etcd
中;因此,我们在这个虚拟机上运行了 etcd
。接着,我们有构成 Kubernetes 的所有服务,最后,还有一些支持从 Docker CLI 部署 Docker 堆栈到 Kubernetes 的服务。这个服务不是 Kubernetes 官方发行版的一部分,但它是特定于 Docker 的。
所有 Kubernetes 组件都在 LinuxKit 虚拟机中的容器中运行。这些容器可以通过 Docker Desktop 中的设置进行隐藏。稍后我们将在本节中提供一份完整的 Kubernetes 系统容器列表,前提是你已启用 Kubernetes 支持。
启用 Kubernetes 的 Docker Desktop 相对于 minikube 的一个大优势是,前者允许开发人员使用单一工具构建、测试和运行面向 Kubernetes 的容器化应用程序。甚至可以使用 Docker Compose 文件将多服务应用部署到 Kubernetes 中。
现在让我们动手操作:
- 首先,我们需要启用 Kubernetes。在 macOS 上,点击菜单栏中的 Docker 图标。在 Windows 上,前往任务栏并选择首选项。在弹出的对话框中,选择Kubernetes,如以下截图所示:
图 16.6 – 在 Docker Desktop 中启用 Kubernetes
-
然后,勾选启用 Kubernetes复选框。还需要勾选显示系统容器(高级)**复选框。
-
然后,点击应用并重启按钮。安装和配置 Kubernetes 需要几分钟时间。是时候休息一下,享受一杯好茶了。
-
安装完成后(Docker 会通过在
kubectl
中显示绿色状态图标来通知我们),以便访问后者。 -
首先,让我们列出我们拥有的所有上下文。我们可以使用以下命令来完成:
$ kubectl config get-contexts
在作者的笔记本电脑上,我们得到以下输出:
图 16.7 - kubectl 的上下文列表
在这里,我们可以看到,在作者的笔记本电脑上,我们有三个上下文,其中两个来自于他使用kind
。目前,名为kind-demo
的kind
上下文仍然处于活动状态,通过CURRENT
列中的星号标记。
-
我们可以使用以下命令切换到
docker-desktop
上下文:$ kubectl config use-context docker-desktop
执行此操作后,会得到以下输出:
Switched to context "docker-desktop"
-
现在我们可以使用
kubectl
访问 Docker Desktop 刚创建的集群:$ kubectl get nodes
我们应该看到类似以下的内容:
NAME STATUS ROLES AGE VERSIONnode1 Ready control-plane 6m28s v1.25.9
好的,这看起来很熟悉。它与我们在使用 Play with Kubernetes 时看到的几乎相同。作者的 Docker Desktop 使用的 Kubernetes 版本是 1.25.9。我们还可以看到节点是一个master
节点,由control-plane
角色指示。
-
如果我们列出当前在 Docker Desktop 上运行的所有容器,我们会得到以下截图所示的列表(注意,我们使用了
--format
参数来输出容器 ID 和容器名称):$ docker container list --format "table {{.ID}\t{{.Names}}"
这将导致以下输出:
图 16.8 - Kubernetes 系统容器列表
在前面的列表中,我们可以识别出所有组成 Kubernetes 的现在熟悉的组件,如下所示:
-
API 服务器
-
etcd
-
kube-proxy
-
DNS 服务
-
kube-controller
-
kube-scheduler
通常,我们不希望将这些系统容器混入我们的容器列表中。因此,我们可以在 Kubernetes 的设置中取消选中显示系统容器(高级)复选框。
现在,让我们尝试将 Docker Compose 应用程序部署到 Kubernetes。
-
进入我们
~/``The-Ultimate-Docker-Container-Book
文件夹的ch16
子文件夹。 -
将
docker-compose.yml
文件从示例解决方案复制到此位置:$ cp ../sample-solutions/ch16/docker-compose.yml .
-
按照 https://kompose.io/installation/上的说明,在你的机器上安装
kompose
工具:-
在 Mac 上,可以通过
$ brew
install kompose
安装 -
在 Windows 上,使用
$ choco
install kubernetes-kompose
-
-
按照以下方式运行
kompose
工具:$ kompose convert
该工具应该创建四个文件:
-
db-deployment.yaml
-
pets-data-persistentvolumeclaim.yaml
-
web-deployment.yaml
-
web-service.yaml
-
打开
web-service.yaml
文件,在第 11 行(spec
条目)后,添加NodePort
条目类型,使其如下所示:...spec: type: NodePort ports: - name: "3000"...
-
现在我们可以使用
kubectl
将这四个资源部署到我们的 Kubernetes 集群:$ kubectl apply –f '*.yaml'
我们应该看到这个:
deployment.apps/db createdpersistentvolumeclaim/pets-data created
deployment.apps/web created
service/web created
-
我们需要找出 Kubernetes 将
3000
服务端口映射到哪个主机端口。使用以下命令来实现:$ kubectl get service
你应该看到类似以下内容:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEkubernetes ClusterIP 10.96.0.1 <none> 443/TCP 10d
web NodePort 0.111.98.154 <none> 3000:32134/TCP 5m33s
在我的例子中,我们可以看到服务 web 将 3000
端口映射到 32134
主机(或节点)端口。在下面的命令中,我必须使用这个端口。在你的情况下,端口号可能会不同。使用你从上一条命令中得到的数字!
-
我们可以使用
curl
测试应用程序:$ curl localhost:32134/pet
我们将看到它按预期运行:
图 16.9 – 宠物应用程序在 Docker Desktop 上的 Kubernetes 环境中运行
现在,让我们看看在前面的部署之后,Kubernetes 上到底有哪些资源。
-
我们可以使用
kubectl
来查看:$ kubectl get all
这给我们带来了以下输出:
图 16.10 – 列出 Docker stack deploy 创建的所有 Kubernetes 对象
Docker 为 web
服务和 db
服务创建了一个 Deployment。它还自动为 web
创建了一个 Kubernetes 服务,以便在集群内访问。
这可以说相当酷,极大地减少了面向 Kubernetes 作为编排平台的团队在开发过程中遇到的摩擦。
-
在继续之前,请从集群中删除该堆栈:
$ kubectl delete –f '*.yaml'
现在,我们已经了解了可以用来开发最终将在 Kubernetes 集群中运行的应用程序的工具,接下来是时候了解所有重要的 Kubernetes 对象,这些对象用于定义和管理这样的应用程序。我们将从 pod 开始。
Pod 介绍
与 Docker Swarm 中的可能性相反,你不能直接在 Kubernetes 集群中运行容器。在 Kubernetes 集群中,你只能运行 pod。Pod 是 Kubernetes 中 Deployment 的基本单元。一个 pod 是一个或多个共址容器的抽象,这些容器共享相同的内核命名空间,例如网络命名空间。Docker SwarmKit 中没有类似的概念。多个容器可以共址并共享相同的网络命名空间是一个非常强大的概念。下图展示了两个 pod:
图 16.11 – Kubernetes pods
在前面的图示中,我们有两个 pod,10.0.12.3
和 10.0.12.5
。这两个 pod 都是由 Kubernetes 网络驱动管理的私有子网的一部分。
一个 pod 可以包含一个或多个容器。所有这些容器共享相同的 Linux 内核命名空间,特别是它们共享网络命名空间。这一点通过围绕容器的虚线矩形表示。由于在同一个 pod 中运行的所有容器共享网络命名空间,每个容器需要确保使用自己的端口,因为在一个网络命名空间中不允许重复端口。在这种情况下,在 Pod 1 中,主容器使用的是 80
端口,而辅助容器使用的是 3000
端口。
来自其他 Pod 或节点的请求可以使用 Pod 的 IP 地址结合相应的端口号来访问单个容器。例如,你可以通过 10.0.12.3:80
访问运行在 Pod 1 主容器中的应用程序。
比较 Docker 容器和 Kubernetes Pod 网络
现在,让我们比较 Docker 的容器网络与 Kubernetes 的 Pod 网络。在下面的图示中,左边是 Docker,右边是 Kubernetes:
图 16.12 – Pod 中共享同一网络命名空间的容器
当创建 Docker 容器且未指定特定网络时,Docker Engine 会创建一个虚拟以太网(veth
)端点。第一个容器获得 veth0
,下一个获得 veth1
,以此类推。这些虚拟以太网端点连接到 Docker 在安装时自动创建的 Linux 桥接器 docker0
。流量从 docker0
桥接器路由到每个连接的 veth
端点。每个容器都有自己的网络命名空间。没有两个容器使用相同的命名空间。这是故意的,目的是将容器内运行的应用程序彼此隔离。
对于 Kubernetes Pod,情况则不同。当创建一个新 Pod 时,Kubernetes 首先创建一个所谓的 pause
容器,其目的是创建和管理 Pod 将与所有容器共享的命名空间。除此之外,它没有做任何实际的工作;它只是处于休眠状态。pause
容器通过 veth0
连接到 docker0
桥接器。任何后续加入 Pod 的容器都会使用 Docker Engine 的特殊功能,允许它重用现有的网络命名空间。实现的语法如下所示:
$ docker container create --net container:pause ...
重要部分是 --net
参数,其值为 container:<container name>
。如果我们以这种方式创建一个新容器,那么 Docker 不会创建一个新的 veth
端点;该容器将使用与暂停容器相同的 veth
端点。
多个容器共享相同网络命名空间的另一个重要后果是它们相互通信的方式。我们来考虑以下情况:一个 Pod 中包含两个容器,一个监听 80
端口,另一个监听 3000
端口:
图 16.13 – Pod 中的容器通过 localhost 进行通信
当两个容器使用相同的 Linux 内核网络命名空间时,它们可以通过 localhost
相互通信,类似于当两个进程在同一主机上运行时,它们也可以通过 localhost
进行通信。
这一点在前面的图示中得到了说明。从 main
容器中,容器化的应用程序可以通过 http://localhost:3000 访问支持容器内运行的服务。
共享网络命名空间
在了解了这些理论之后,你可能会想知道 Kubernetes 实际上是如何创建一个 Pod 的。
Kubernetes 仅使用 Docker 提供的功能。那么,这种网络命名空间共享是如何工作的呢?首先,Kubernetes 创建了前面提到的所谓 pause
容器。
这个容器的唯一作用就是为该 Pod 保留内核命名空间,并保持它们的存活,即使 Pod 内没有其他容器运行。接下来,让我们模拟创建一个 Pod。我们从创建 pause 容器开始,并使用 Nginx 来实现:
$ docker container run –detach \ --name pause nginx:alpine
现在我们添加第二个容器,命名为 main
,并将其连接到与 pause 容器相同的网络命名空间:
$ docker container run --name main \ -d -it \ --net container:pause \
alpine:latest ash
由于 pause
和示例 containers
都是同一网络命名空间的一部分,它们可以通过 localhost
互相访问。为了证明这一点,我们必须进入主容器执行操作:
$ docker exec -it main /bin/sh
现在我们可以测试连接到运行在 pause 容器中并监听 80
端口的 Nginx。使用 wget
工具进行测试时,我们会得到如下结果:
/ # wget -qO – localhost
这样做会给我们以下输出:
图 16.14 – 两个容器共享相同的网络命名空间
输出结果显示我们确实可以在 localhost
上访问 Nginx。这证明了这两个容器共享相同的命名空间。如果这还不够,我们可以使用 ip
工具在两个容器中显示 eth0
,并且会得到完全相同的结果,具体来说,就是相同的 IP 地址,这是 Pod 的一个特征:所有容器共享相同的 IP 地址:
/ # ip a show eth0
这将显示以下输出:
图 16.15 – 使用 ip 工具显示 eth0 的属性
我们使用以下命令检查 bridge
网络:
$ docker network inspect bridge
之后,我们可以看到只列出了 pause 容器:
[ {
"Name": "bridge",
"Id": "c7c30ad64...",
"Created": "2023-05-18T08:22:42.054696Z",
"Scope": "local",
"Driver": "bridge",
…
"Containers": {
"b7be6946a9b...": {
"Name": "pause",
"EndpointID": "48967fbec...",
"MacAddress": "02:42:ac:11:00:02",
"IPv4Address": "172.17.0.2/16",
"IPv6Address": ""
}
},
...
}
]
上面的输出已被简化以提高可读性。
由于 main 容器复用了 pause 容器的端点,因此它没有在 Containers
列表中出现。
在继续之前,请删除两个 pause
和 main
容器:
$ docker container rm pause main
接下来,我们将讨论 Pod 的生命周期。
Pod 生命周期
本书前面提到过,容器有生命周期。容器首先初始化,运行,然后最终退出。当容器退出时,它可以通过退出代码零优雅地退出,或者通过非零退出代码终止,后者相当于发生了错误。
同样,Pod 也有生命周期。由于一个 Pod 可以包含多个容器,因此其生命周期比单一容器的生命周期稍微复杂一些。Pod 的生命周期可以在下图中看到:
图 16.16 – Kubernetes Pod 的生命周期
当 Pod 在集群节点上创建时,它首先进入 待处理 状态。一旦 Pod 的所有容器都启动并运行,Pod 就进入 运行中 状态。只有当所有容器成功运行时,Pod 才会进入此状态。如果要求 Pod 终止,它将请求所有容器终止。如果所有容器以退出代码零终止,则 Pod 进入 成功 状态。这是理想路径。
现在,让我们看看一些导致 Pod 处于 失败 状态的场景。可能有三种情况:
-
如果在 Pod 启动期间,至少有一个容器无法运行并失败(即它退出时返回非零退出代码),则 Pod 会从待处理状态转入失败状态。
-
如果 Pod 处于 运行中 状态,而其中一个容器突然崩溃或以非零退出代码退出,则 Pod 将从 运行中 状态转换为 失败 状态。
-
如果要求 Pod 终止,并且在关闭过程中,至少有一个容器以非零退出代码退出,则 Pod 也会进入 失败 状态。
现在让我们看看 Pod 的规范。
Pod 规范
在 Kubernetes 集群中创建 Pod 时,我们可以使用命令式或声明式的方法。我们在本书前面已经讨论过这两种方法的区别,但为了重新表述最重要的方面,使用声明式方法意味着我们编写一个描述我们想要实现的最终状态的清单。我们将省略协调器的细节。我们想要实现的最终状态也被称为期望状态。一般来说,声明式方法在所有成熟的协调器中都受到强烈推荐,Kubernetes 也不例外。
因此,在本章中,我们将专注于声明式方法。Pod 的清单或规范可以使用 YAML
或 JSON
格式编写。在本章中,我们将专注于 YAML
,因为它对我们人类来说更易于阅读。让我们来看一个示例规范。以下是 pod.yaml
文件的内容,该文件可以在我们 labs
文件夹的 ch16
子文件夹中找到:
apiVersion: v1kind: Pod
metadata:
name: web-pod
spec:
containers:
- name: web
image: nginx:alpine
ports:
- containerPort: 80
Kubernetes 中的每个规范都以版本信息开头。Pod 已经存在了一段时间,因此 API 版本是 v1
。第二行指定了我们想要定义的 Kubernetes 对象或资源类型。显然,在这个例子中,我们想要指定一个 pod。接下来是一个包含元数据的块。最基本的要求是给 pod 起个名字。这里我们称其为 web-pod
。接下来的块是 spec
块,包含 pod 的规范。最重要的部分(也是这个简单示例中唯一的部分)是列出所有属于该 pod 的容器。我们这里只有一个容器,但也可以有多个容器。我们为容器选择的名字是 web
,容器镜像是 nginx:alpine
。最后,我们定义了容器暴露的端口列表。
一旦我们编写了这样的规范,就可以使用 Kubernetes CLI kubectl
将其应用到集群中:
-
打开一个新的终端窗口,导航到
ch16
子文件夹:$ cd ~/The-Ultimate-Docker-Contianer-Book/ch16
-
在这个示例中,我们将使用 Docker Desktop 的 Kubernetes 集群。因此,确保你正在使用正确的
kubectl
CLI 上下文:$ kubectl config use-context docker-desktop
这将切换上下文到由 Docker Desktop 提供的 Kubernetes 集群。
-
在此文件夹中,创建一个名为
pod.yml
的新文件,并将提到的 pod 规范添加到该文件中。保存该文件。 -
执行以下命令:
$ kubectl create -f pod.yaml
这将回应 pod "web-pod" created
。
-
然后我们可以列出集群中的所有 pod:
$ kubectl get pods
这样做将为我们提供以下输出:
NAME READY STATUS RESTARTS AGEweb-pod 1/1 Running 0 2m
正如预期的那样,我们有一个处于 Running
状态的 pod,名称为 web-pod
,正如定义的那样。
-
我们可以通过使用
describe
命令来获取有关运行中的 pod 的更详细信息:$ kubectl describe pod/web-pod
这会给我们类似这样的输出:
图 16.17 – 描述运行在集群中的 pod
注意
前面部分的 pod/web-pod
表示法包括 describe
命令。其他变体也是可能的。例如,pods/web-pod
、po/web-pod
、pod
和 po
都是 pods
的别名。
kubectl
工具定义了许多别名,以使我们的生活更加轻松。
describe
命令为我们提供了大量有关 pod 的有价值的信息,其中之一是发生并影响该 pod 的事件列表。该列表会显示在输出的最后。
Containers
部分中的信息与我们在 docker container
inspect
输出中找到的非常相似。
我们还可以看到一个 Volumes
部分,其中有一个 Projected
条目类型。它包含集群的根证书作为机密。我们将在下一章讨论 Kubernetes 的机密。另一方面,卷将在接下来讨论。
Pod 和卷
在关于容器的章节中,我们了解了卷及其作用:访问和存储持久数据。由于容器可以挂载卷,因此 pod 也可以。实际上,真正挂载卷的是 pod 中的容器,但这只是一个语义上的细节。首先,让我们看看如何在 Kubernetes 中定义一个卷。Kubernetes 支持各种卷类型,因此我们不会深入探讨这个话题。
让我们通过定义一个名为my-data-claim
的PersistentVolumeClaim
声明,隐式地创建一个本地卷:
-
创建一个名为
volume-claim.yaml
的文件,并将以下规范添加到文件中:apiVersion: v1kind: PersistentVolumeClaimmetadata: name: my-data-claimspec: accessModes: - ReadWriteOnce resources: requests: storage: 2Gi
我们定义了一个请求 2GB 数据的声明。
-
让我们创建这个声明:
$ kubectl create -f volume-claim.yaml
这将产生如下输出:
persistentvolumeclaim/my-data-claim created
-
我们可以使用
kubectl
列出声明(pvc
是PersistentVolumeClaim
的快捷方式),命令如下:$ kubectl get pvc
这将产生如下输出:
图 16.18 – 集群中 PersistentStorageClaim 对象的列表
在输出中,我们可以看到该声明已经隐式地创建了一个名为pvc-<ID>
的卷。
-
在继续之前,请先移除该 pod:
$ kubectl delete pod/web-pod
或者,使用定义 pod 的原始文件,命令如下:
$ kubectl delete -f pod.yaml
我们现在可以在 pod 中使用该声明创建的卷了。让我们使用之前使用的修改版 pod 规范:
-
创建一个名为
pod-with-vol.yaml
的文件,并将以下规范添加到文件中:apiVersion: v1kind: Podmetadata: name: web-podspec: containers: - name: web image: nginx:alpine ports: - containerPort: 80 volumeMounts: - name: my-data mountPath: /data volumes: - name: my-data persistentVolumeClaim: claimName: my-data-claim
在最后四行的volumes
块中,我们定义了一个我们希望在该 pod 中使用的卷列表。我们在这里列出的卷可以被 pod 的任何容器使用。在我们的例子中,我们只有一个卷。我们指定了一个名为my-data
的卷,它是一个持久卷声明,声明名称就是我们刚刚创建的那个。
然后,在容器规范中,我们有volumeMounts
块,在这里我们定义了要使用的卷,以及容器内卷将挂载的(绝对)路径。在我们的例子中,我们将卷挂载到容器文件系统的/data
文件夹。
-
让我们创建这个 pod:
$ kubectl create -f pod-with-vol.yaml
我们也可以使用声明式的方式:
$ kubectl apply -f pod-with-vol.yaml
-
然后,我们可以
exec
进入容器,通过导航到/data
文件夹,创建一个文件并使用以下命令退出容器,以检查卷是否已经挂载:$ kubectl exec -it web-pod -- /bin/sh/ # cd /data/data # echo "Hello world!" > sample.txt/data # exit
如果我们没错的话,那么这个容器中的数据应该在 pod 的生命周期结束后仍然存在。
-
因此,让我们删除这个 pod:
$ kubectl delete pod/web-pod
-
然后,我们将重新创建它:
$ kubectl create -f pod-with-vol.yaml
-
然后,我们将
exec
进入 pod 的容器:$ kubectl exec -it web-pod -- ash
-
最后,我们输出数据:
/ # cat /data/sample.txt
这是前面命令产生的输出:
Hello world!
这是我们所预期的。
-
按Ctrl + D退出容器。
-
在继续之前,请删除 pod 和持久卷声明。到现在为止,你应该知道怎么做。如果不知道,请回头查看步骤 4。
现在我们已经对 Pods 有了较好的理解,让我们研究一下 ReplicaSets 如何帮助管理这些 Pods。
Kubernetes ReplicaSets
在一个对高可用性有要求的环境中,仅有一个 Pod 是远远不够的。如果 Pod 崩溃了怎么办?如果我们需要更新 Pod 内部的应用程序,但又不能承受任何服务中断怎么办?这些问题表明仅有 Pods 是不足够的,我们需要一个更高级的概念来管理多个相同的 Pod 实例。在 Kubernetes 中,ReplicaSet 用于定义和管理在不同集群节点上运行的多个相同 Pod 的集合。ReplicaSet 定义了容器在 Pod 中运行时使用的容器镜像,以及在集群中运行的 Pod 实例数量等。这些属性以及其他许多属性被称为期望状态。
ReplicaSet 负责始终确保实际状态与期望状态的一致性,如果实际状态偏离期望状态。以下是一个 Kubernetes ReplicaSet:
图 16.19 – Kubernetes ReplicaSet
在前面的示意图中,我们可以看到一个 ReplicaSet 管理着多个 Pods。这些 Pods 被称为pod-api
。ReplicaSet 负责确保在任何给定时间,始终有期望数量的 Pods 在运行。如果某个 Pod 因为某种原因崩溃,ReplicaSet 会在一个有空闲资源的节点上调度一个新的 Pod 替代它。如果 Pod 的数量超过了期望的数量,ReplicaSet 会杀死多余的 Pod。通过这种方式,我们可以说 ReplicaSet 保证了一个自愈且可扩展的 Pod 集合。ReplicaSet 可以包含的 Pod 数量没有上限。
ReplicaSet 规格
类似于我们对 Pods 的学习,Kubernetes 也允许我们以命令式或声明式的方式定义和创建 ReplicaSet。由于在大多数情况下声明式方法是最推荐的方式,我们将专注于这种方法。让我们来看看一个 Kubernetes ReplicaSet 的示例规格:
-
创建一个名为
replicaset.yaml
的新文件,并在其中添加以下内容:apiVersion: apps/v1kind: ReplicaSetmetadata: name: rs-webspec: selector: matchLabels: app: web replicas: 3 template: metadata: labels: app: web spec: containers: - name: nginx image: nginx:alpine ports: - containerPort: 80
这看起来与我们之前介绍的 Pod 规格非常相似。那么,我们来集中注意其区别。首先,在第 2 行,我们看到的是kind
,之前是 Pod,现在是ReplicaSet
。接着,在第 6 到第 8 行,我们有一个选择器,它决定哪些 Pods 将成为 ReplicaSet 的一部分。在这个例子中,它选择所有标签为app
且值为web
的 Pods。然后,在第 9 行,我们定义了希望运行的 Pod 副本数量;在这个例子中是三个副本。最后,我们有template
部分,它首先定义了元数据,然后定义了规格,其中包含运行在 Pod 内部的容器。在我们的例子中,我们有一个使用nginx:alpine
镜像并暴露80
端口的单一容器。
其中真正重要的元素是副本数和选择器,选择器指定了由 ReplicaSet 管理的 Pod 集合。
-
让我们使用这个文件来创建 ReplicaSet:
$ kubectl create -f replicaset.yaml
这将产生以下结果:
replicaset "rs-web" created
-
现在我们列出集群中所有的 ReplicaSets(
rs
是 ReplicaSet 的快捷方式):$ kubectl get rs
我们得到了以下结果:
NAME DESIRED CURRENT READY AGErs-web 3 3 3 51s
在前面的输出中,我们看到有一个名为rs-web
的 ReplicaSet,其期望状态是三个(Pod)。当前状态也显示了三个 Pod,并告诉我们所有三个 Pod 都已准备就绪。
-
我们还可以列出系统中的所有 Pod:
$ kubectl get pods
这将生成以下输出:
NAME READY STATUS RESTARTS AGErs-web-nbc8m 1/1 Running 0 4m
rs-web-6bxn5 1/1 Running 0 4m
rs-web-lqhm5 1/1 Running 0 4m
在这里,我们可以看到我们预期的三个 Pod。Pod 的名称使用了ReplicaSet
的名称,并附加了一个唯一的 ID。在READY
列中,我们可以看到 Pod 中定义了多少个容器以及它们中有多少个已准备就绪。在我们的案例中,每个 Pod 只有一个容器,并且每个容器都已准备好。因此,Pod 的整体状态是Running
。我们还可以看到每个 Pod 被重启了多少次。在我们的例子中,没有任何 Pod 被重启。
接下来,让我们看看 ReplicaSet 是如何帮助我们实现自愈的。
自愈
现在,让我们通过随机杀死其中一个 Pod 来测试自愈 ReplicaSet 的魔力,并观察会发生什么:
-
让我们删除前面列表中的第一个 Pod。确保将 Pod 的名称(
rs-web-nbc8m
)替换为您自己示例中的名称:$ kubectl delete po/rs-web-nbc8m
上一个命令生成了以下输出:
pod "rs-web-nbc8m" deleted
-
现在,让我们再次列出所有 Pod。我们期望只看到两个 Pod,对吗?你错了:
NAME READY STATUS RESTARTS AGErs-web-4r587 1/1 Running 0 5srs-web-6bxn5 1/1 Running 0 4m30srs-web-lqhm5 1/1 Running 0 4m30
好的,显然,列表中的第一个 Pod 已经被重新创建,正如我们从AGE
列中看到的那样。这是自愈功能在起作用。
-
让我们看看描述 ReplicaSet 时会发现什么:
$ kubectl describe rs
这将给我们以下输出:
图 16.20 – 描述 ReplicaSet
结果,我们在Events
下找到了一个条目,告诉我们 ReplicaSet 创建了一个名为rs-web-4r587
的新 Pod。
-
在继续之前,请删除 ReplicaSet:
$ kubectl delete rs/rs-web
现在是时候讨论 Kubernetes 的 Deployment 对象了。
Kubernetes 部署
Kubernetes 非常重视单一职责原则。所有 Kubernetes 对象都被设计为执行一项任务,而且只执行这一项任务,而且它们的设计目标是非常出色地完成这项任务。在这方面,我们必须理解 Kubernetes 的 ReplicaSets 和 Deployments。正如我们所学,ReplicaSet 负责实现和协调应用服务的期望状态。这意味着 ReplicaSet 管理一组 Pod。
部署(Deployment)通过在 ReplicaSet 基础上提供滚动更新和回滚功能来增强 ReplicaSet。在 Docker Swarm 中,Swarm 服务结合了 ReplicaSet 和 Deployment 的功能。从这个角度来看,SwarmKit 比 Kubernetes 更加单体化。以下图示展示了 Deployment 与 ReplicaSet 的关系:
图 16.21 – Kubernetes 部署
在前面的图示中,ReplicaSet 定义并管理一组相同的 pods。ReplicaSet 的主要特点是自我修复、可扩展,并始终尽最大努力使其状态与期望状态一致。而 Kubernetes 部署(Deployment)则在此基础上增加了滚动更新和回滚功能。在这方面,Deployment 是 ReplicaSet 的封装对象。
我们将在 第十七章 中深入学习滚动更新和回滚,使用 Kubernetes 部署、更新和保护应用程序。
在接下来的章节中,我们将深入了解 Kubernetes 服务以及它们如何实现服务发现和路由。
Kubernetes 服务
一旦我们开始处理由多个应用服务组成的应用程序,就需要服务发现。以下图示说明了这个问题:
图 16.22 – 服务发现
在前面的图示中,我们有一个 Web API
服务,需要访问另外三个服务:payments
、shipping
和 ordering
。Web API
服务不应关心如何以及在哪里找到这三个服务。在 API 代码中,我们只需要使用我们想要访问的服务名称和其端口号。一个示例是以下 URL,payments:3000
,它用于访问 payments
服务的一个实例。
在 Kubernetes 中,支付应用服务由一个 ReplicaSet 的 pods 表示。由于高度分布式系统的特性,我们不能假设 pods 拥有稳定的端点。pod 可以随时出现或消失。如果我们需要从内部或外部客户端访问相应的应用服务,这将是个问题。如果我们不能依赖 pod 端点的稳定性,我们还能做什么呢?
这就是 Kubernetes 服务 发挥作用的地方。它们旨在为 ReplicaSets 或 Deployments 提供稳定的端点,如下所示:
图 16.23 – Kubernetes 服务为客户端提供稳定的端点
在前面的图示中,我们可以看到一个 Kubernetes 服务。它提供了一个可靠的集群级 IP 地址,也叫做 app=web
;也就是说,所有具有名为 app
且值为 web
的标签的 pod 都会被代理。
在接下来的章节中,我们将深入了解基于上下文的路由以及 Kubernetes 如何减轻这一任务。
基于上下文的路由
我们经常需要为 Kubernetes 集群配置基于上下文的路由。Kubernetes 提供了多种方式来实现这一点。目前,首选且最具可扩展性的方法是使用 IngressController。以下图示尝试说明这个 ingress 控制器是如何工作的:
图 16.24 – 使用 Kubernetes Ingress 控制器的基于上下文的路由
在前面的图中,我们可以看到当使用 IngressController(如 Nginx)时,基于上下文(或第七层)路由是如何工作的。在这里,我们有一个名为 web
的应用服务的部署。这个应用服务的所有 Pod 都有以下标签:app=web
。然后,我们有一个名为 web
的 Kubernetes 服务,它为这些 Pod 提供一个稳定的端点。该服务的虚拟 IP 是 52.14.0.13
,并且暴露了 30044
端口。也就是说,如果有请求到达 Kubernetes 集群的任何节点,并请求 web
名称和 30044
端口,那么这个请求会被转发到这个服务。然后,服务会将请求负载均衡到其中一个 Pod。
到目前为止,一切顺利,但如何将来自客户端的 ingress 请求路由到 http[s]://example.com/web
URL 并定向到我们的 Web 服务呢?首先,我们必须定义从基于上下文的请求到相应的 <服务名>/<端口>
请求的路由。这是通过 Ingress 对象实现的:
-
在 Ingress 对象中,我们将 Host 和 Path 定义为源,(服务) 名称和端口为目标。当 Kubernetes API 服务器创建这个 Ingress 对象时,作为 sidecar 运行的 IngressController 进程会拾取这个变化。
-
修改 Nginx 反向代理的配置文件。
-
通过添加新路由,要求 Nginx 重新加载其配置,因此,它将能够正确地将任何传入的请求路由到
http[s]://example.com/web
。
在下一节中,我们将通过对比每种调度引擎的一些主要资源,来比较 Docker SwarmKit 和 Kubernetes。
比较 SwarmKit 和 Kubernetes
现在我们已经了解了 Kubernetes 中一些最重要资源的许多细节,接下来比较这两种调度器 SwarmKit 和 Kubernetes 时,通过匹配重要资源来帮助理解。让我们来看看:
SwarmKit | Kubernetes | 描述 |
---|---|---|
Swarm | 集群 | 由各自的调度器管理的服务器/节点集。 |
节点 | 集群成员 | 作为 Swarm/集群成员的单个主机(物理或虚拟)。 |
管理节点 | 主节点 | 管理 Swarm/集群的节点。这是控制平面。 |
工作节点 | 节点 | 运行应用工作负载的 Swarm/集群成员。 |
容器 | 容器** | 运行在节点上的容器镜像实例。**注:在 Kubernetes 集群中,我们不能直接运行容器。 |
任务 | Pod | 运行在节点上的服务实例(Swarm)或副本集(Kubernetes)。一个任务管理一个容器,而一个 Pod 包含一个或多个容器,这些容器共享相同的网络命名空间。 |
服务 | 副本集 | 定义并协调由多个实例组成的应用服务的期望状态。 |
服务 | 部署 | 部署是带有滚动更新和回滚功能的 ReplicaSet。 |
路由网格 | 服务 | Swarm 路由网格提供基于 IPVS 的 L4 路由和负载均衡。Kubernetes 服务是一个抽象,定义了一组逻辑上的 pods 和一种可用于访问它们的策略。它是一个稳定的端点,指向一组 pods |
堆栈 | 堆栈** | 应用程序的定义由多个(Swarm)服务组成。**注意:虽然堆栈在 Kubernetes 中并不是原生支持的,但 Docker 工具 Docker Desktop 会将它们转换为 Kubernetes 集群的部署 |
网络 | 网络策略 | Swarm 软件定义网络(SDNs)用于防火墙容器。Kubernetes 只定义了一个单一的扁平网络。除非显式定义网络策略来约束 pod 之间的通信,否则每个 pod 都可以访问其他 pod 和/或节点 |
这就结束了我们对 Kubernetes 的介绍,它目前是最流行的容器编排引擎。
总结
在本章中,我们学习了 Kubernetes 的基础知识。我们概述了其架构,并介绍了用于在 Kubernetes 集群中定义和运行应用程序的主要资源。我们还介绍了 minikube 和 Docker Desktop 中的 Kubernetes 支持。
在下一章中,我们将把应用程序部署到 Kubernetes 集群中。然后,我们将使用零停机策略更新该应用程序的某个服务。最后,我们将使用密钥对在 Kubernetes 中运行的应用程序服务进行敏感数据的加密。敬请期待!
进一步阅读
以下是包含有关我们在本章中讨论的各种主题的详细信息的文章列表:
-
Raft 共识 算法:
raft.github.Io/
-
Kubernetes 文档:
kubernetes.io/docs/home/
问题
请回答以下问题以评估您的学习进度:
-
Kubernetes 集群的高层次架构是什么?
-
用几句话简要解释 Kubernetes master 的角色。
-
列出每个 Kubernetes(工作节点)节点上需要具备的元素。
-
我们无法在 Kubernetes 集群中运行单独的容器。
-
正确
-
错误
-
-
Kubernetes pod 的三个主要特性是什么?
-
解释为什么 pod 中的容器可以使用
localhost
相互通信。 -
pod 中所谓的
pause
容器的作用是什么? -
Bob 告诉你:“我们的应用程序由三个 Docker 镜像组成:
web
、inventory
和db
。由于我们可以在 Kubernetes pod 中运行多个容器,所以我们打算将应用程序的所有服务部署到一个 pod 中。”列出三到四个原因,解释为什么这是一个不好的主意。 -
用您自己的话解释为什么我们需要 Kubernetes ReplicaSets。
-
在什么情况下我们需要 Kubernetes Deployments?
-
Kubernetes 服务的主要职责是什么?
-
列出至少三种 Kubernetes 服务类型,并解释它们的目的及其差异。
-
如何创建一个 Kubernetes 服务,将应用程序服务内部暴露在集群中?
答案
以下是本章中提出问题的一些示例答案:
-
Kubernetes 集群由控制平面(Kubernetes Master)和多个工作节点组成。控制平面负责维持集群的期望状态,例如正在运行的应用程序和它们使用的容器镜像。工作节点是应用程序部署和运行的服务器。
-
Kubernetes master 负责管理集群。所有创建对象、重新调度 Pod、管理 ReplicaSet 等请求都发生在 master 上。master 不在生产环境或类似生产环境的集群中运行应用程序工作负载。
-
在每个工作节点上,我们有 kubelet、代理和容器运行时。
-
答案是 A. 正确。你不能在 Kubernetes 集群上运行独立的容器。Pod 是该集群中部署的最小单元。
-
Kubernetes Pod 是 Kubernetes 中最小的可部署单元。它可以运行一个或多个共址的容器。以下是三个主要特点:
-
一个 Pod 可以封装多个紧密耦合且需要共享资源的容器。
-
Pod 中的所有容器共享相同的网络命名空间,这意味着它们可以使用
localhost
相互通信。 -
每个 Pod 在集群内都有一个独特的 IP 地址。
-
-
所有在 Pod 内运行的容器共享相同的 Linux 内核网络命名空间。因此,这些容器内运行的所有进程可以通过
localhost
互相通信,类似于在主机上直接运行的进程或应用程序如何通过localhost
进行通信。 -
pause
容器的唯一作用是为在 Pod 中运行的容器保留命名空间。 -
这是一个不好的想法,因为一个 Pod 的所有容器是共址的,这意味着它们运行在同一个集群节点上。而且,如果多个容器运行在同一个 Pod 中,它们只能一起扩展或缩减。然而,应用程序的不同组件(即
web
、inventory
和db
)通常在可扩展性或资源消耗方面有非常不同的需求。web
组件可能需要根据流量进行扩展和缩减,而db
组件则有其他组件没有的存储特殊需求。如果我们将每个组件都运行在各自的 Pod 中,我们在这方面会更具灵活性。 -
我们需要一种机制来在集群中运行多个 Pod 实例,并确保实际运行的 Pod 数量始终与期望数量相符,即使个别 Pod 由于网络分区或集群节点故障而崩溃或消失。ReplicaSet 是提供任何应用程序服务可扩展性和自愈能力的机制。
-
当我们希望在 Kubernetes 集群中更新应用服务而不导致服务停机时,需要使用 Deployment 对象。Deployment 对象为 ReplicaSets 增加了滚动更新和回滚功能。
-
Kubernetes 服务是一种抽象方式,用于将运行在一组 Pods 上的应用暴露为网络服务。Kubernetes 服务的主要职责包括以下几点:
-
为一组 Pods 提供稳定的 IP 地址和 DNS 名称,帮助发现服务,并支持负载均衡。
-
路由网络流量,将其分发到一组 Pods 上,从而提供相同的功能。
-
如有必要,允许将服务暴露给外部客户端。
-
-
Kubernetes 服务对象用于使应用服务参与服务发现。它们为一组 Pods 提供稳定的端点(通常由 ReplicaSet 或 Deployment 管理)。Kubernetes 服务是定义逻辑 Pods 集合和访问策略的抽象。Kubernetes 服务有四种类型:
-
每个集群节点上的
30000
到32767
。 -
LoadBalancer:此类型通过云服务提供商的负载均衡器(如 AWS 上的 ELB)将应用服务暴露到外部。
-
ExternalName:当需要为集群的外部服务(例如数据库)定义代理时使用。
-
-
创建 Kubernetes 服务时,通常会创建一个服务配置文件(
YAML
或JSON
),该文件指定所需的服务类型(例如,ClusterIP 用于内部通信),以及选择器标签以识别目标 Pods 和网络流量的端口。然后使用kubectl apply
命令应用此文件。这将创建一个服务,将流量路由到匹配选择器标签的 Pods。
第十七章:17
使用 Kubernetes 部署、更新和保护应用
在上一章中,我们学习了关于容器编排器 Kubernetes 的基础知识。我们对 Kubernetes 的架构进行了概览,并了解了 Kubernetes 用来定义和管理容器化应用的许多重要对象。
在本章中,我们将学习如何将应用程序部署、更新和扩展到 Kubernetes 集群中。我们还将解释如何实现零停机部署,以便无干扰地更新和回滚关键任务应用。最后,我们将介绍 Kubernetes 秘密,作为配置服务和保护敏感数据的一种手段。
本章涵盖以下主题:
-
部署我们的第一个应用
-
定义存活性和就绪性
-
零停机部署
-
Kubernetes 秘密
完成本章后,你将能够完成以下任务:
-
将一个多服务应用部署到 Kubernetes 集群中
-
为你的 Kubernetes 应用服务定义存活探针和就绪探针
-
更新在 Kubernetes 中运行的应用服务,而不会造成停机
-
在 Kubernetes 集群中定义秘密
-
配置应用服务以使用 Kubernetes 秘密
技术要求
在本章中,我们将使用本地计算机上的 Docker Desktop。有关如何安装和使用 Docker Desktop 的更多信息,请参阅 第二章,设置工作环境。
本章的代码可以在这里找到:main/sample-solutions/ch17
。
请确保你已经按照 第二章 中描述的方式克隆了本书的 GitHub 仓库。
在你的终端中,导航到 ~/The-Ultimate-Docker-Container-Book
文件夹,并创建一个名为 ch17
的子文件夹并进入它:
$ mkdir ch17 & cd ch17
部署我们的第一个应用
我们将把我们的宠物应用——我们在 第十一章 中首次介绍的,使用 Docker Compose 管理容器——部署到 Kubernetes 集群中。我们的集群将使用 Docker Desktop,它提供了一个单节点的 Kubernetes 集群。然而,从部署的角度来看,集群的规模和集群位于云端、公司数据中心或你的工作站并不重要。
部署 Web 组件
提醒一下,我们的应用程序由两个应用服务组成:基于 Node 的 Web 组件和后台 PostgreSQL 数据库。在上一章中,我们学习了需要为每个我们想要部署的应用服务定义一个 Kubernetes 部署对象。我们将首先为 Web 组件执行此操作。和本书中一贯的做法一样,我们将选择声明式方式来定义我们的对象:
- 我们将使用由 Docker Desktop 提供的本地 Kubernetes 单节点集群。确保你的 Docker Desktop 安装中已启用 Kubernetes:
图 17.1 – 在 Docker Desktop 上运行 Kubernetes
- 在您的代码子文件夹(
ch17
)中,添加一个名为web-deployment.yaml
的文件,内容如下:
图 17.2 – web
组件的 Kubernetes 部署定义
前面的部署定义可以在sample-solutions/ch17
子文件夹中的web-deployment.yaml
文件中找到。它包含了部署web
组件所需的指令。代码行如下:
-
第 7 行:我们将
Deployment
对象的名称定义为web
。 -
第 9 行:我们声明希望运行一个
web
组件的实例。 -
第 11 到 13 行:通过
Selector
,我们定义了哪些 Pods 将成为我们部署的一部分,即那些具有app
和service
标签,且值分别为pets
和web
的 Pods。 -
第 14 行:在从第 11 行开始的 Pod 模板中,我们定义了每个 Pod 将应用
app
和service
标签。 -
从第 20 行开始:我们定义了将在 Pod 中运行的唯一容器。容器的镜像是我们熟悉的
fundamentalsofdocker/ch11-web:2.0
镜像,容器的名称将为web
。 -
第 23 行和第 24 行:值得注意的是,我们声明容器将端口
3000
暴露给传入流量。
-
请确保您已将
kubectl
的上下文设置为 Docker Desktop。有关如何设置的详细信息,请参见第二章,《设置工作环境》。使用以下命令:$ kubectl config use-context docker-desktop
您将收到以下输出:
Switched to context "docker-desktop".
-
我们可以使用以下命令部署此
Deployment
对象:$ kubectl create -f web-deployment.yaml
前面的命令输出如下信息:
deployment.apps/web created
-
我们可以通过 Kubernetes CLI 再次确认该部署是否已创建:
$ kubectl get all
我们应该看到以下输出:
图 17.3 – 列出所有在 Kind 中运行的资源
在前面的输出中,我们可以看到 Kubernetes 创建了三个对象——部署(deployment)、相关的ReplicaSet
,以及一个 Pod(记住我们指定了只需要一个副本)。当前状态与这三个对象的期望状态一致,所以到目前为止我们没问题。
- 现在,web 服务需要公开给外部访问。为此,我们需要定义一个 Kubernetes 类型为
NodePort
的Service
对象。创建一个名为web-service.yaml
的新文件,并向其中添加以下代码:
图 17.4 – 我们的 web 组件的 Service 对象定义
再次提醒,相同的文件可以在sample-solutions/ch17
子文件夹中的web-service.yaml
文件中找到。
前面的代码行如下:
-
第 7 行:我们将此
Service
对象的名称设置为web
。 -
第 9 行:我们定义了使用的
Service
对象类型。由于web
组件必须能够从集群外部访问,因此不能是ClusterIP
类型的Service
对象,必须是NodePort
或LoadBalancer
类型。在前一章中我们讨论了 Kubernetes 服务的各种类型,因此这里不再详细说明。在我们的示例中,我们使用的是NodePort
类型的服务。 -
第 10 到 13 行:我们指定希望通过 TCP 协议暴露端口
3000
供访问。Kubernetes 会自动将容器端口3000
映射到 30,000 到 32,768 范围内的一个空闲主机端口。Kubernetes 最终选择的端口可以通过在服务创建后使用kubectl get service
或kubectl describe
命令来确定。 -
第 14 到 16 行:我们定义了此服务将作为稳定端点的 pods 的过滤条件。在这种情况下,它是所有具有
app
和service
标签且值分别为pets
和web
的 pods。
-
现在我们已经有了
Service
对象的规格说明,我们可以使用kubectl
来创建它:$ kubectl apply -f web-service.yaml
-
我们可以列出所有服务,以查看前面命令的结果:
$ kubectl get services
上述命令会产生以下输出:
图 17.5 – 为 Web 组件创建的 Service 对象
在前面的输出中,我们可以看到一个名为web
的服务已被创建。该服务被分配了一个唯一的ClusterIP
值10.96.195.255
,并且容器端口3000
已在所有集群节点的端口30319
上发布。
-
如果我们想测试这个部署,可以使用
curl
:$ curl localhost:30319/
这将导致以下输出:
Pets Demo Application
正如我们所看到的,响应是Pets Demo Application
,这是我们预期的结果。Web 服务已在 Kubernetes 集群中启动并运行。接下来,我们将部署数据库。
部署数据库
数据库是一个有状态组件,必须与无状态组件(如我们的 Web 组件)不同对待。我们在第九章《学习分布式应用架构》和第三章《容器编排介绍》中详细讨论了分布式应用架构中有状态和无状态组件的区别。
Kubernetes 为有状态组件定义了一种特殊类型的ReplicaSet
对象,这种对象叫做StatefulSet
。我们使用这种类型的对象来部署数据库。
- 创建一个名为
db-stateful-set.yaml
的新文件,并将以下内容添加到该文件中:
图 17.6 – 用于 DB 组件的 StatefulSet 对象
定义也可以在sample-solutions/ch17
子文件夹中找到。
好的,看起来有点吓人,但其实不是。它比 web 组件的部署定义稍长,因为我们还需要定义一个卷,用于 PostgreSQL 数据库存储数据。卷索赔定义在第 25 至 33 行。
我们想要创建一个名为 pets-data
的卷,其最大大小为 100 MB。在第 22 至 24 行,我们使用此卷,并将其挂载到容器中的 /var/lib/postgresql/data
,这是 PostgreSQL 期望的位置。在第 21 行,我们还声明 PostgreSQL 正在端口 5432
上监听。
-
像往常一样,我们使用
kubectl
部署我们的StatefulSet
:$ kubectl apply -f db-stateful-set.yaml
-
现在,如果我们列出集群中的所有资源,我们将能够看到创建的额外对象:
图 17.7 – StatefulSet 及其 pod
在这里,我们可以看到已创建了 StatefulSet
和一个 pod。对于两者来说,当前状态与期望状态相符,因此系统是健康的,但这并不意味着此时 web
组件可以访问数据库。服务发现不起作用。请记住,web
组件希望使用 db
服务的名称来访问 db
。我们在 server.js
文件中硬编码了 db
主机名。
- 为了使集群内的服务发现正常工作,我们还必须为数据库组件定义一个 Kubernetes
Service
对象。由于数据库应仅能从集群内部访问,因此我们需要的Service
对象类型是ClusterIP
。
创建一个名为 db-service.yaml
的新文件,并将以下规范添加到其中。它可以在 sample-solutions/ch17
子文件夹中找到:
图 17.8 – 为数据库定义的 Kubernetes Service 对象
数据库组件将由此 Service
对象表示。它可以通过名称 db
进行访问,这是服务的名称,如第 4 行所定义。数据库组件不必公开访问,因此我们决定使用 ClusterIP
类型的 Service
对象。第 10 至 12 行的选择器定义了该服务代表具有必要标签的所有 pod 的稳定端点 – 即 app: pets
和 service: db
。
-
让我们使用以下命令部署此服务:
$ kubectl apply -f db-service.yaml
-
现在,我们应该准备好测试该应用程序了。这次我们可以使用浏览器,欣赏肯尼亚马赛马拉国家公园美丽的动物图像:
图 17.9 – 在 Kubernetes 中运行 pets 应用程序的测试
在这种情况下,端口号 30317
是 Kubernetes 自动为我的 web
Service
对象选择的端口号。请将此数字替换为 Kubernetes 分配给您的服务的端口号。您可以使用 kubectl get services
命令获取该数字。
这样,我们就成功将宠物应用程序部署到了 Docker Desktop 提供的单节点 Kubernetes 集群中。我们需要定义四个构件才能完成这一操作,它们如下所示:
-
Deployment
和Service
对象用于web
组件 -
StatefulSet
和Service
对象用于database
组件
要从集群中移除应用程序,我们可以使用以下小脚本:
kubectl delete svc/webkubectl delete deploy/web
kubectl delete svc/db
kubectl delete statefulset/db
kubectl delete pvc/pets-data-db-0
请注意该脚本的最后一行。我们正在删除 Kubernetes 自动为 db
部署创建的持久卷声明。当我们删除 db
部署时,这个声明不会被自动删除!持久卷声明与 Docker 卷有点相似(但请注意,它们并不相同)。
使用 kubectl get pvc
命令查看机器上所有声明的列表。
接下来,我们将优化部署。
精简部署过程
到目前为止,我们已经创建了四个需要部署到集群中的构件。这只是一个非常简单的应用程序,由两个组件组成。试想如果是一个更加复杂的应用程序,它会迅速变成一场维护噩梦。幸运的是,我们有几个方法可以简化部署。我们将在这里讨论的方法是,将组成 Kubernetes 应用程序的所有组件定义在一个文件中。
本书未涉及的其他解决方案包括使用包管理器,例如 Helm(helm.sh/
)或 Kustomize(kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/
),这是 Kubernetes 的原生解决方案。
如果我们的应用程序包含多个 Kubernetes 对象,例如 Deployment
和 Service
对象,那么我们可以将它们都保存在一个文件中,并通过三个破折号分隔各个对象定义。例如,如果我们想在一个文件中包含 web
组件的 Deployment
和 Service
定义,文件内容将如下所示:
图 17.10 – 单个文件中的 web 组件部署和服务
您可以在 sample-solutions/ch17/install-web.yaml
文件中找到此文件。
接下来,我们将所有四个对象定义收集到 sample-solutions/ch17/install-pets.yaml
文件中,并可以一次性部署该应用程序:
$ kubectl apply -f install-pets.yaml
这将给出如下输出:
deployment "web" createdservice "web" created
deployment "db" created
service "db" created
类似地,我们创建了一个名为 sample-solutions/ch17/remove-pets.sh
的脚本,用于从 Kubernetes 集群中删除所有宠物应用程序的构件。请注意,该文件在使用之前已通过 chmod +x ./remove-pets.sh
命令设置为可执行文件。现在,我们可以使用以下命令:
$ ./remove-pets.sh
这将产生如下输出:
deployment.apps "web" deletedservice "web" deleted
statefulset.apps "db" deleted
service "db" deleted
persistentvolumeclaim "pets-data-db-0" deleted
或者,您可以使用以下命令:
$ kubectl delete -f install-pets.yaml
这将删除除持久卷声明外的所有资源,而持久卷声明需要手动删除:
$ kubectl delete pvc/pets-data-db-0
在这一部分,我们已经使用在第十一章《使用 Docker Compose 管理容器》中介绍的宠物应用程序,定义了将此应用程序部署到 Kubernetes 集群中所需的所有 Kubernetes 对象。在每个步骤中,我们确保得到了预期的结果,并且一旦所有的工件存在于集群中,我们展示了运行中的应用程序。
定义存活性和就绪性
像 Kubernetes 和 Docker Swarm 这样的容器编排系统大大简化了部署、运行和更新高度分布式、关键任务应用程序的过程。编排引擎自动化了许多繁琐的任务,例如上下扩展、确保所需状态始终得到维护等。
然而,编排引擎不能自动完成所有事情。有时,我们开发人员需要提供一些只有我们才能了解的信息来支持引擎。那么,我说的是什么意思呢?
我们来看一个单一的应用服务。假设它是一个微服务,我们称之为服务 A。如果我们将服务 A 容器化并运行在 Kubernetes 集群上,那么 Kubernetes 可以确保我们在服务定义中要求的五个实例始终运行。如果一个实例崩溃,Kubernetes 可以快速启动一个新实例,从而保持所需状态。但是,如果一个服务实例没有崩溃,而是不健康或还没有准备好处理请求呢?Kubernetes 应该知道这两种情况。但它不能,因为从应用服务的角度来看,健康与否超出了编排引擎的知识范畴。只有我们应用程序的开发人员知道我们的服务何时健康,何时不健康。
比如,应用服务可能正在运行,但由于某些 bug 其内部状态可能已经损坏,可能处于无限循环中,或者可能处于死锁状态。
类似地,只有我们这些应用程序开发人员才知道我们的服务是否准备好工作,或者它是否还在初始化中。虽然强烈建议将微服务的初始化阶段尽可能缩短,但如果某些服务需要较长的时间才能准备好工作,通常也无法避免。在初始化状态下并不意味着不健康。初始化阶段是微服务或任何其他应用服务生命周期中的预期部分。
因此,如果我们的微服务处于初始化阶段,Kubernetes 不应尝试杀死它。但是,如果我们的微服务不健康,Kubernetes 应该尽快将其杀死并替换为一个新的实例。
Kubernetes 有探针的概念,提供了协调引擎和应用开发者之间的连接。Kubernetes 使用这些探针来获取有关当前应用服务内部状态的更多信息。探针在每个容器内本地执行。服务的健康状况探针(也叫存活探针)、启动探针和服务的就绪探针都有对应的定义。我们逐一来看它们。
Kubernetes 存活探针
Kubernetes 使用存活探针来决定何时杀死一个容器,以及何时启动另一个实例来替代它。由于 Kubernetes 在 Pod 层面上操作,如果至少有一个容器报告为不健康,则相应的 Pod 会被杀死。
或者,我们可以换个角度来说:只有当一个 Pod 中的所有容器都报告健康时,Pod 才会被视为健康。
我们可以在 Pod 的规格说明中定义存活探针,如下所示:
apiVersion: v1kind: Pod
metadata:
…
spec:
containers:
- name: liveness-demo
image: postgres:12.10
…
livenessProbe:
exec:
command: nc localhost 5432 || exit –1
initialDelaySeconds: 10
periodSeconds: 5
相关部分在 livenessProbe
部分。首先,我们定义一个 Kubernetes 会在容器内执行的命令作为探针。在我们的例子中,我们有一个 PostreSQL
容器,使用 netcat
Linux 工具来探测 5432
端口的 TCP。命令 nc localhost 5432
成功时,表示 Postgres 已经开始监听此端口。
另外两个设置项,initialDelaySeconds
和 periodSeconds
,定义了 Kubernetes 在启动容器后应该等待多长时间才执行第一次探针,以及之后探针应该以多频繁的间隔执行。在我们的例子中,Kubernetes 等待 10 秒钟后执行第一次探针,然后每隔 5 秒执行一次探针。
还可以使用 HTTP 端点来替代命令进行探测。假设我们运行一个来自镜像 acme.com/my-api:1.0
的微服务,且该 API 的端点 /api/health
返回状态 200 (OK)
表示微服务健康,返回 50x (Error)
表示微服务不健康。在这种情况下,我们可以这样定义存活探针:
apiVersion: v1kind: Pod
metadata:
…
spec:
containers:
- name: liveness
image: acme.com/my-api:1.0
…
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 3
在上面的代码片段中,我定义了存活探针,使其使用 HTTP 协议,并对 localhost
的 5000
端口上的 /api/health
端点执行 GET
请求。记住,探针是在容器内执行的,这意味着我可以使用 localhost。
我们还可以直接使用 TCP 协议来探测容器上的端口。但稍等一下——我们不就是在第一个例子中使用了基于命令的通用存活探针吗?没错,我们确实使用了,但是我们依赖的是容器中是否存在 netcat
工具。我们不能假设这个工具总是存在。因此,依赖 Kubernetes 本身来为我们执行基于 TCP 的探测会更好。修改后的 Pod 规格如下:
apiVersion: v1kind: Pod
metadata:
…
spec:
containers:
- name: liveness-demo
image: postgres:12.10
…
livenessProbe:
tcpSocket:
port: 5432
initialDelaySeconds: 10
periodSeconds: 5
这个看起来非常相似。唯一的变化是,探针的类型从 exec
更改为 tcpSocket
,并且我们不再提供命令,而是提供要探测的端口。
请注意,我们也可以在这里使用 Kubernetes 的 livenessProbe
配置项中的 failureThreshold
。在 Kubernetes 中,livenessProbe
的失败阈值是指容器重启前必须连续发生的最小失败次数。默认值是 3
。最小值是 1
。如果处理程序返回失败代码,kubelet
会杀死容器并重新启动它。任何大于或等于 200
且小于 400
的代码表示成功,其他任何代码表示失败。
让我们试试这个:
-
将
sample-solutions/ch17
文件夹中的probes
子文件夹复制到你的ch17
文件夹中。 -
使用以下命令构建 Docker 镜像:
$ docker image build -t demo/probes-demo:2.0 probes
-
使用
kubectl
部署在probes-demo.yaml
中定义的示例 pod:$ kubectl apply -f probes/probes-demo.yaml
-
描述 pod,并具体分析输出中的日志部分:
$ kubectl describe pods/probes-demo
在大约前半分钟内,你应该看到以下输出:
图 17.11 – 健康 pod 的日志输出
- 等待至少 30 秒,然后再次描述 pod。这时,你应该看到以下输出:
图 17.12 – pod 状态变为不健康后的日志输出
标记的行表示探针失败,并且 pod 即将被重启。
-
如果你获取 pod 列表,你会看到 pod 已经重启了多次:
$ kubectl get pods
这将导致以下输出:
NAME READY STATUS RESTARTS AGEprobes-demo 1/1 Running 5 (49s ago) 7m22s
-
完成示例后,使用以下命令删除 pod:
$ kubectl delete pods/probes-demo
接下来,我们将查看 Kubernetes 的就绪探针(readiness probe)。
Kubernetes 就绪探针(readiness probes)
Kubernetes 使用就绪探针来决定服务实例——即容器——何时准备好接收流量。现在,我们都知道 Kubernetes 部署和运行的是 pod 而非容器,因此讨论 pod 的就绪状态是有意义的。只有当 pod 中的所有容器都报告为“就绪”时,pod 才会被认为是“就绪”的。如果 pod 报告为“未就绪”,Kubernetes 将会把它从服务负载均衡器中移除。
就绪探针的定义与存活探针相同:只需将 pod 配置中的 livenessProbe
键切换为 readinessProbe
。以下是使用我们之前的 pod 配置的示例:
…spec:
containers:
- name: liveness-demo
image: postgres:12.10
…
livenessProbe:
tcpSocket:
port: 5432
failureThreshold: 2
periodSeconds: 5
readinessProbe:
tcpSocket:
port: 5432
initialDelaySeconds: 10
periodSeconds: 5
请注意,在这个例子中,由于我们现在有了就绪探针(readiness probe),我们不再需要为存活探针设置初始延迟。因此,我已将存活探针的初始延迟条目替换为一个名为 failureThreshold
的条目,表示在发生故障时 Kubernetes 应该重复探测多少次,直到它认为容器不健康。
Kubernetes 启动探针(startup probes)
对 Kubernetes 来说,知道一个服务实例何时启动通常是很有帮助的。如果我们为容器定义了启动探针,那么只要容器的启动探针未成功,Kubernetes 就不会执行存活探针或就绪探针。一旦所有 Pod 容器的启动探针成功,Kubernetes 就会开始执行容器的存活探针和就绪探针。
鉴于我们已经有了存活探针和就绪探针,什么时候我们需要使用启动探针?可能有一些情况需要考虑异常长的启动和初始化时间,例如在将传统应用程序容器化时。我们本可以通过配置就绪探针或存活探针来解决这个问题,但那样做会违背这些探针的目的。后者的探针旨在快速反馈容器的健康状况和可用性。如果我们配置了长时间的初始延迟或持续时间,反而会影响预期效果。
不出所料,启动探针的定义与就绪探针和存活探针相同。以下是一个示例:
spec: containers:
...
startupProbe:
tcpSocket:
port: 3000
failureThreshold: 30
periodSeconds: 5
...
确保你定义了failureThreshold * periodSeconds
的乘积,以便它足够大,能够应对最差的启动时间。
在我们的示例中,最大启动时间不应超过 150 秒。
零停机部署
在关键任务环境中,应用程序必须始终保持运行。这些天,我们已经无法容忍停机了。Kubernetes 为我们提供了多种实现这一目标的方法。对集群中的应用程序进行更新而不导致停机被称为零停机部署。在本节中,我们将介绍实现这一目标的两种方法,具体如下:
-
滚动更新
-
蓝绿部署
让我们从讨论滚动更新开始。
滚动更新
在上一章中,我们了解到 Kubernetes 的Deployment
对象与ReplicaSet
对象的区别在于,它在后者的功能基础上增加了滚动更新和回滚功能。让我们使用我们的 Web 组件来演示这一点。我们将需要修改 Web 组件的部署清单或描述。
我们将使用与上一节相同的部署定义,唯一的区别是——我们将运行web
组件。以下定义也可以在sample-solutions/ch17/web-deployment-rolling-v1.yaml
文件中找到:
图 17.13 – 带有五个副本的 Web 组件部署
现在,我们可以像往常一样创建这个部署,同时也创建使我们的组件可访问的服务:
$ kubectl apply -f web-deployment-rolling-v1.yaml$ kubectl apply -f web-service.yaml
一旦我们部署了 Pod 和服务,就可以测试我们的 Web 组件。首先,我们可以使用以下命令获取分配的节点端口:
$ PORT=$(kubectl get svc/web -o jsonpath='{.spec.ports[0].nodePort}')
接下来,我们可以在curl
语句中使用$PORT
环境变量:
$ curl localhost:${PORT}/
这将提供预期的输出:
Pets Demo Application
如我们所见,应用程序已经启动并运行,返回了预期的消息,Pets
Demo Application
。
我们的开发人员已经创建了 Web 组件的新版本 2.1。新版本的代码可以在sample-solutions/ch17/web
文件夹中找到,唯一的变化位于server.js
文件的第 12 行:
图 17.14 – Web 组件版本 2.0 的代码更改
我们现在可以按如下方式构建新的镜像(将demo
替换为你的 GitHub 用户名):
$ docker image build -t demo/ch17-web:2.1 web
随后,我们可以将镜像推送到 Docker Hub,步骤如下(将demo
替换为你的 GitHub 用户名):
$ docker image push demo/ch17-web:2.1
现在,我们希望更新由属于web
Deployment
对象的 pod 使用的镜像。我们可以通过使用kubectl
的set image
命令来实现:
$ kubectl set image deployment/web \ web=demo/ch17-web:2.1
如果我们再次测试该应用程序,我们将得到一个确认,证明更新确实已发生:
$ curl localhost:${PORT}/
输出显示现在已经安装了版本 2:
Pets Demo Application v2
那么,我们怎么知道在这次更新过程中没有任何停机时间呢?更新是以滚动方式进行的吗?滚动更新到底是什么意思呢?让我们来探讨一下。首先,我们可以通过使用rollout
status
命令,从 Kubernetes 获取确认,确保部署确实已成功完成:
$ kubectl rollout status deploy/web
命令将返回以下响应:
deployment "web" successfully rolled out
如果我们使用kubectl describe deploy/web
描述web
部署对象,在输出的末尾,我们将看到以下事件列表:
图 17.15 – 在 Web 组件部署描述输出中找到的事件列表
第一个事件告诉我们,在创建部署时,创建了一个名为web-769b88f67
的ReplicaSet
对象,包含五个副本。然后,我们执行了update
命令。事件列表中的第二个事件告诉我们,这意味着创建了一个新的ReplicaSet
对象,名为web-55cdf67cd
,最初只有一个副本。因此,在那个特定时刻,系统上存在六个 pod:五个初始 pod 和一个新的版本的 pod。但是,由于Deployment
对象的期望状态要求只有五个副本,Kubernetes 现在将旧的ReplicaSet
对象缩减为四个实例,这一点可以从第三个事件中看到。
然后,新的ReplicaSet
对象被扩展到两个实例,随后,旧的ReplicaSet
对象被缩减到三个实例,依此类推,直到我们得到了五个新的实例,并且所有旧的实例都被淘汰。尽管我们无法看到发生这些变化的具体时间(除了 3 分钟),但事件的顺序告诉我们,整个更新过程是以滚动方式进行的。
在短时间内,一些 web 服务的调用会从旧版本的组件中得到响应,而另一些调用则会从新版本的组件中得到响应,但服务在任何时候都不会中断。
我们还可以列出集群中的 ReplicaSet
对象,以确认我在前面提到的内容:
图 17.16 – 列出集群中的所有 ReplicaSet 对象
在这里,我们可以看到新的 ReplicaSet
对象有五个实例正在运行,而旧的 ReplicaSet
对象已缩减为零实例。旧的 ReplicaSet
对象仍然存在的原因是 Kubernetes 允许我们回滚更新,在这种情况下,它会重用该 ReplicaSet
。
如果在更新镜像时出现一些未检测到的 bug 渗入新代码,我们可以使用 rollout
undo
命令回滚更新:
$ kubectl rollout undo deploy/web
这将输出以下内容:
deployment.apps/web rolled back
我们可以像这样测试回滚是否成功:
$ curl localhost:${PORT}/
如我们所见,输出显示了这一点:
Pets Demo Application
如果我们列出 ReplicaSet
对象,我们将看到以下输出:
图 17.17 – 回滚后列出 ReplicaSet 对象
这确认了旧的 ReplicaSet
(web-9d66cd994
)对象已被重用,而新的 ReplicaSet
对象已缩减为零实例。
在继续之前,请删除部署和服务:
$ kubectl delete deploy/web$ kubectl delete service/web
但是,有时我们无法或不想容忍旧版本与新版本共存的混合状态。我们希望采取全有或全无的策略。这时,蓝绿部署就派上用场了,我们将在接下来的内容中讨论。
蓝绿部署
如果我们想为宠物应用程序的 web
组件进行蓝绿式部署,可以通过巧妙地使用标签来实现。首先,让我们回顾一下蓝绿部署是如何工作的。以下是一个大致的步骤指南:
-
将
web
组件的第一个版本作为blue
部署。我们将为 pods 添加color: blue
的标签来实现这一点。 -
为这些带有
color: blue
标签的 pods 在selector
部分部署 Kubernetes 服务。 -
现在,我们可以部署版本 2 的 web 组件,但这次,pods 会有一个
color: green
的标签。 -
我们可以测试服务的绿色版本,以检查它是否按预期工作。
-
现在,我们可以通过更新 Kubernetes 服务来将流量从
blue
切换到green
,我们将修改选择器,使其使用color:
green
标签。
让我们为版本 1 定义一个 Deployment
对象,标记为 blue
:
图 17.18 – 为 web 组件指定蓝色部署
上述定义可以在sample-solutions/ch17/web-deployment-blue.yaml
文件中找到。
请注意第 8 行,在那里我们将部署的名称定义为web-blue
,以便与即将到来的web-green
部署区分开来。另外,请注意我们在第 7、15 和 21 行添加了color: blue
标签。其他内容与之前相同。
现在,我们可以为网页组件定义Service
对象。它将与我们之前使用的相同,但有一个小的改动,如下图所示:
图 17.19 – 支持蓝绿部署的网页组件 Kubernetes 服务
关于本章早些时候使用的服务定义,唯一的区别是第 17 行,它将color: blue
标签添加到了选择器中。我们可以在sample-solutions/ch17/web-service-blue-green.yaml
文件中找到上述定义。
然后,我们可以使用以下命令部署蓝色版本的web
组件:
$ kubectl apply -f web-deploy-blue.yaml
我们可以使用此命令部署其服务:
$ kubectl apply -f web-service-blue-green.yaml
一旦服务启动并运行,我们可以确定其 IP 地址和端口号并进行测试:
$ PORT=$(kubectl get svc/web -o jsonpath='{.spec.ports[0].nodePort}')
然后,我们可以使用curl
命令访问它:
$ curl localhost:${PORT}/
这将给我们预期的结果:
Pets Demo Application
现在,我们可以部署web
组件的绿色版本。其Deployment
对象的定义可以在sample-solutions/ch17/web-deployment-green.yaml
文件中找到,内容如下:
图 17.20 – 网页组件绿色部署规范
有趣的行如下:
-
第 8 行:命名为
web-green
,以便与web-blue
区分,并支持并行安装 -
第 7、15 和 21 行:颜色为绿色
-
第 24 行:现在使用的是本章前面构建的网页镜像版本 2.1
请不要忘记在第 24 行将‘’demo‘’
改为你自己的 GitHub 用户名。
现在,我们准备部署这个绿色版本的服务。它应与蓝色服务分开运行:
$ kubectl apply -f web-deployment-green.yaml
我们可以确保两个部署并存,如下所示:
图 17.21 – 显示集群中运行的部署对象列表
如预期的那样,我们有蓝色和绿色两个版本在运行。我们可以验证蓝色仍然是活跃的服务:
$ curl localhost:${PORT}/
我们应该仍然收到以下输出:
Pets Demo Application
现在是有趣的部分:我们可以通过编辑现有的网页组件服务,将流量从blue
切换到green
。为此,请执行以下命令:
$ kubectl edit svc/web
将标签的颜色值从blue
更改为green
。然后,保存并退出编辑器。Kubernetes CLI 将自动更新服务。现在,当我们再次查询网页服务时,将得到如下内容:
$ curl localhost:${PORT}/
这时,我们应该得到以下输出:
Pets Demo Application v2
这证明了流量确实已经切换到web
组件的绿色版本(注意curl
命令响应末尾的v2
)。
注意
如果我们希望坚持声明式的形式,那么最好更新web-service-blue-green.yaml
文件并应用新版本,这样所需的状态仍然保存在文件中,避免现实与文件之间可能的不匹配。然而,为了说明,展示的方式是可以接受的。
如果我们意识到绿色部署出现了问题,新版本有缺陷,我们可以通过再次编辑 web 服务并将color
标签的值替换为蓝色来轻松切换回蓝色版本。这个回滚是瞬时的,并且应该总是有效。然后,我们可以删除有缺陷的绿色部署并修复组件。一旦我们修复了问题,就可以再次部署绿色版本。
一旦组件的绿色版本按照预期运行并且性能良好,我们可以停用蓝色版本:
$ kubectl delete deploy/web-blue
当我们准备部署新版本 3.0 时,这个版本将成为蓝色版本。我们必须相应地更新ch17/web-deployment-blue.yaml
文件并部署它。然后,我们必须将 web 服务从green
切换到blue
,依此类推。
通过这一点,我们成功地展示了如何在 Kubernetes 集群中实现蓝绿部署,使用的是我们宠物应用程序的web
组件。
接下来,我们将学习如何处理 Kubernetes 中应用程序使用的秘密。
Kubernetes 秘密
有时,我们希望在 Kubernetes 集群中运行的服务必须使用机密数据,比如密码、API 密钥或证书,仅举几例。我们希望确保只有授权或专用服务能够查看这些敏感信息。集群中的所有其他服务不应访问这些数据。
出于这个原因,Kubernetes 引入了秘密管理。一个秘密是一个键值对,其中键是秘密的唯一名称,值是实际的敏感数据。秘密存储在etcd
中。Kubernetes 可以配置为在静态存储时加密秘密——也就是在etcd
中——以及在传输时加密秘密——也就是当秘密从主节点传输到工作节点,这些节点上运行着使用此秘密的服务的 pod 时。
手动定义秘密
我们可以像创建 Kubernetes 中的任何其他对象一样声明式地创建一个秘密。以下是这样一个秘密的 YAML 配置:
apiVersion: v1kind: Secret
metadata:
name: pets-secret
type: Opaque
data:
username: am9obi5kb2UK
password: c0VjcmV0LXBhc1N3MHJECg==
上面的定义可以在sample-solutions/ch17/pets-secret.yaml
文件中找到。现在,你可能会想知道这些值是什么。这些是实际的(未加密的)值吗?不是的。它们也不是加密值,而只是base64
编码的值。
因此,它们并不完全安全,因为base64
编码的值可以轻松还原为明文值。我是如何获得这些值的?这很简单——只需按照以下步骤操作:
-
使用
base64
工具按如下方式编码值:$ echo "john.doe" | base64
这将导致以下输出:
am9obi5kb2UK
另外,尝试以下操作:
$ echo "sEcret-pasSw0rD" | base64
这将给我们带来以下输出:
c0VjcmV0LXBhc1N3MHJECg==
-
使用前面的值,我们可以创建密钥:
$ kubectl create -f pets-secret.yaml
在这里,命令输出如下:
secret/pets-secret created
-
我们可以使用以下命令描述密钥:
$ kubectl describe secrets/pets-secret
前面的命令输出如下:
图 17.22 – 创建和描述 Kubernetes 密钥
-
在密钥描述中,值被隐藏,只有它们的长度会显示。所以,也许密钥现在是安全的了。其实不然。我们可以通过
kubectl
的get
命令轻松解码这个密钥:$ kubectl get secrets/pets-secret -o yaml
输出如下:
图 17.23 – 解码 Kubernetes 密钥
如前面的截图所示,我们已经恢复了原始的密钥值。
-
解码你之前得到的值:
$ echo "c0VjcmV0LXBhc1N3MHJECg==" | base64 –decode
这将导致以下输出:
sEcret-pasSw0rD
因此,结果是这种创建 Kubernetes 密钥的方法只适用于开发环境,在那里我们处理的是非敏感数据。在所有其他环境中,我们需要一种更好的方式来处理密钥。
使用 kubectl 创建密钥
定义密钥的更安全方式是使用 kubectl
。首先,我们必须创建包含 base64 编码密钥值的文件,类似于前一节所做的,但这次,我们必须将值存储在临时文件中:
$ echo "sue-hunter" | base64 > username.txt$ echo "123abc456def" | base64 > password.txt
现在,我们可以使用 kubectl
从这些文件创建密钥,如下所示:
$ kubectl create secret generic pets-secret-prod \ --from-file=./username.txt \
--from-file=./password.txt
这将导致以下输出:
secret "pets-secret-prod" created
然后,密钥可以像手动创建的密钥一样使用。
你可能会问,为什么这种方法比另一种更安全?首先,没有 YAML 文件定义密钥,并且它存储在某些源代码版本控制系统中,例如 GitHub,很多人都可以访问这些系统,因此他们可以看到并解码这些密钥。
只有授权了解密钥的管理员才能看到密钥的值,并使用它们直接在(生产)集群中创建密钥。集群本身受到基于角色的访问控制保护,因此没有授权的人无法访问它,也无法解码集群中定义的密钥。
现在,让我们看看如何使用我们定义的密钥。
在 pod 中使用密钥
假设我们要创建一个 Deployment
对象,其中 web
组件使用我们在前一节中介绍的密钥 pets-secret
。我们可以使用以下命令在集群中创建密钥:
$ kubectl apply -f pets-secret.yaml
在 sample-solutions/ch17/web-deployment-secret.yaml
文件中,我们可以找到 Deployment
对象的定义。我们需要将从第 23 行开始的部分添加到原始的 Deployment
对象定义中:
图 17.24 – 带有密钥的 Web 组件的部署对象
在第 29 到 32 行,我们定义了一个名为secrets
的卷,该卷来自我们的密钥pets-secret
。然后,我们按照第 25 到 28 行的描述,在容器中使用该卷。
我们将密钥挂载到容器文件系统的/etc/secrets
路径,并以只读模式挂载该卷。因此,密钥值将作为文件提供给容器,并存放在该文件夹中。文件名将对应于键名,文件内容将是对应键的值。密钥值将以未加密的形式提供给运行在容器内的应用程序。
使用以下命令应用部署:
$ kubectl apply -f web-deployment-secret.yaml
在我们的例子中,由于密钥中有用户名和密码的键,我们将在容器文件系统的/etc/secrets
文件夹中找到两个文件,分别名为username
和password
。username
文件应该包含john.doe
值,password
文件应该包含sEcret-pasSw0rD
值。让我们确认一下:
-
首先,我们将获取 Pod 的名称:
$ kubectl get pods
这将给我们以下输出:
图 17.25 – 查找 Pod 的名称
- 使用 Pod 的名称,我们可以执行以下屏幕截图中显示的命令来获取密钥:
图 17.26 – 确认容器内可以访问密钥
在前面的输出的第 1 行,我们exec
进入运行web
组件的容器。然后,在第 2 到 5 行,我们列出了/etc/secrets
文件夹中的文件,最后,在最后 3 行,我们展示了两个文件的内容,毫无意外地,显示了明文的密钥值。
由于任何语言编写的应用程序都可以读取简单的文件,因此使用密钥的这种机制非常向后兼容。即使是一个旧的 Cobol 应用程序也能从文件系统中读取明文文件。
离开之前,请删除 Kubernetes 部署:
$ kubectl delete deploy/web
然而,有时应用程序期望密钥在环境变量中可用。
让我们看看 Kubernetes 在这种情况下为我们提供了什么。
环境变量中的密钥值
假设我们的 Web 组件期望PETS_USERNAME
环境变量中有用户名,PETS_PASSWORD
环境变量中有密码。如果是这样,我们可以修改部署的 YAML 文件,使其如下所示:
图 17.27 – 部署映射密钥值到环境变量
在第 25 到 35 行,我们定义了两个环境变量PETS_USERNAME
和PETS_PASSWORD
,并将pets-secret
中的相应键值对映射到它们。
应用更新后的部署:
$ kubectl apply -f web-deployment-secret.yaml
请注意,我们不再需要使用卷;相反,我们直接将 pets-secret
的各个密钥映射到容器内有效的环境变量。以下命令序列显示了秘密值确实可用,并且已经映射到相应的环境变量中:
图 17.28 – 秘密值已经映射到环境变量
在本节中,我们展示了如何在 Kubernetes 集群中定义秘密,以及如何在作为部署的一部分运行的容器中使用这些秘密。我们展示了秘密如何在容器内部映射的两种变体——使用文件和使用环境变量。
总结
在本章中,我们学习了如何将应用程序部署到 Kubernetes 集群,并为该应用程序设置应用层路由。此外,我们还学习了如何在不引起任何停机的情况下更新 Kubernetes 集群中运行的应用服务。最后,我们使用秘密为集群中运行的应用服务提供敏感信息。
在下一章中,我们将学习不同的技术,这些技术用于监控在 Kubernetes 集群中运行的单个服务或整个分布式应用程序。我们还将学习如何在不更改集群或服务所在集群节点的情况下,排查生产环境中运行的应用服务问题。敬请期待。
进一步阅读
这里有一些链接,提供了本章讨论主题的更多信息:
-
执行滚动 更新:
bit.ly/2o2okEQ
-
蓝绿 部署:
bit.Ly/2r2IxNJ
-
Kubernetes 中的秘密:
bit.ly/2C6hMZF
问题
为了评估你的学习进度,请回答以下问题:
-
你有一个由两个服务组成的应用程序,第一个是 Web API,第二个是数据库,如 MongoDB。你想将这个应用程序部署到 Kubernetes 集群中。用简短的几句话解释你会如何进行。
-
在 Kubernetes 应用服务的上下文中,liveness 和 readiness 探针是什么?
-
请用自己的话描述你需要哪些组件来为你的应用程序建立第七层(或应用层)路由。
-
简要列出实施蓝绿部署所需的主要步骤。避免过多细节。
-
列举三到四种你会通过 Kubernetes 秘密提供给应用服务的信息类型。
-
列出 Kubernetes 在创建秘密时接受的来源。
-
如何配置应用服务以使用 Kubernetes 秘密?
答案
下面是本章问题的答案:
-
假设我们在注册表中有一个用于两个应用服务的 Docker 镜像——网页 API 和 MongoDB——我们需要做如下操作:
-
使用
StatefulSet
对象定义 MongoDB 的部署;我们将这个部署命名为db-deployment
。StatefulSet
对象应该有一个副本(复制 MongoDB 稍微复杂一些,超出了本书的范围)。 -
定义一个名为
db
的 Kubernetes 服务,类型为ClusterIP
,用于db-deployment
。 -
定义一个网页 API 的部署,命名为
web-deployment
。 -
我们将这个服务扩展为三个实例。
-
定义一个名为
api
的 Kubernetes 服务,类型为NodePort
,用于web-deployment
。 -
如果我们使用密钥,那么直接在集群中通过
kubectl
定义这些密钥。 -
使用
kubectl
部署应用。
-
-
存活探针和就绪探针是 Kubernetes 为容器提供的健康检查。存活探针检查容器是否仍在运行,如果没有,Kubernetes 会自动重启它。就绪探针检查容器是否准备好接受请求。如果容器未通过就绪检查,它不会被移除,但在通过就绪探针之前,不会接收任何传入请求。
-
为了实现应用的第 7 层路由,我们理想情况下使用
IngressController
。这是一种反向代理,如 Nginx,它有一个侧车容器监听 Kubernetes 服务器 API 的相关变化,并在检测到变化时更新反向代理的配置并重启它。然后,我们需要在集群中定义 Ingress 资源,定义路由,例如从基于上下文的路由如https://example.com/pets
到<服务名称>/<端口>
或类似api/32001
的配对。当 Kubernetes 创建或更改此Ingress
对象时,IngressController
的侧车容器会捕捉并更新代理的路由配置。 -
假设这是一个集群内部的库存服务,那么我们做如下操作:
-
部署 1.0 版本时,我们定义一个名为
inventory-deployment-blue
的部署,并将 Pods 标记为color:blue
。 -
我们为前述部署部署一个类型为
ClusterIP
的 Kubernetes 服务,名为inventory
,并且选择器包含color:blue
。 -
当我们准备部署新版本的
payments
服务时,我们定义一个该服务的 2.0 版本的部署,并将其命名为inventory-deployment-green
。我们给 Pods 添加一个color:green
标签。 -
现在我们可以对“绿色”服务进行冒烟测试,当一切正常时,我们可以更新库存服务,使选择器包含
color:green
。
-
-
一些形式的信息是机密的,因此应该通过 Kubernetes 密钥提供给服务,包括密码、证书、API 密钥 ID、API 密钥秘密和令牌。
-
密钥值的来源可以是文件或 base64 编码的值。
-
要配置应用程序使用 Kubernetes 密钥,必须创建一个包含敏感数据的
Secret
对象。然后,必须修改你的Pod
规格,使其包含对Secret
对象的引用。此引用可以作为容器规格中的环境变量,或者作为卷挂载,这样你的应用程序就可以使用这些密钥数据。
第十八章:18
在云中运行容器化应用程序。
在上一章中,我们学习了如何将应用程序部署、更新和扩展到 Kubernetes 集群中。我们了解了如何实现零停机部署,以实现不中断的更新和回滚关键应用程序。最后,我们介绍了 Kubernetes 机密作为配置服务和保护敏感数据的手段。
在本章中,我们将概述在云中运行容器化应用程序的三种最流行的方式。我们将探讨每种托管解决方案,并讨论它们的优缺点。
以下是本章中我们将讨论的主题:
-
为什么选择托管 Kubernetes 服务?
-
在 Amazon Elastic Kubernetes Service (Amazon EKS) 上运行一个简单的容器化应用程序。
-
探索 Microsoft 的 Azure Kubernetes Service (AKS)。
-
理解 Google Kubernetes Engine (GKE)。
阅读完本章后,您将能够执行以下操作:
-
分析托管 Kubernetes 服务与自管理 Kubernetes 集群相比的优缺点。
-
在 Amazon EKS 中部署并运行一个简单的分布式应用程序。
-
部署并在 Microsoft 的 AKS 上运行一个简单的分布式应用程序。
-
在 GKE 上部署并运行一个简单的分布式应用程序。
技术要求
我们将在本章节中使用 亚马逊网络服务 (AWS),Microsoft Azure 和 Google Cloud;因此,每个平台都需要有一个账户。如果您没有现有账户,可以申请这些云服务提供商的试用账户。
我们还将使用我们实验室在 GitHub 存储库的 ~/The-Ultimate-Docker-Container-Book/sample-solutions/ch18
文件夹中的文件,网址为 github.com/PacktPublishing/The-Ultimate-Docker-Container-Book/tree/main/sample-solutions/ch18
。
准备放置您自己代码的文件夹。首先,导航至源文件夹,如下所示:
$ cd ~/The-Ultimate-Docker-Container-Book
然后,创建一个 ch18
子文件夹并导航至该文件夹,如下所示:
$ mkdir ch18 & cd ch18
为什么选择托管 Kubernetes 服务?
目前,AWS、Microsoft Azure 和 Google Cloud 是最受欢迎的三大云服务提供商,每个都提供了托管 Kubernetes 服务,如下所述:
-
Amazon EKS:Amazon EKS 是一个托管服务,使您能够在 AWS 上运行 Kubernetes,无需安装、操作和维护自己的 Kubernetes 控制平面或节点。
-
AKS:AKS 是 Microsoft 的托管 Kubernetes 服务。它提供了与 持续集成和持续部署 (CI/CD) 能力以及 Kubernetes 工具集成的开发人员生产力。它还具有完整的容器 CI/CD 平台的 Azure DevOps 项目。
-
GKE:Google 是 Kubernetes 的原始创造者,GKE 是市场上第一个可用的托管 Kubernetes 服务。它提供了先进的集群管理功能,并与 Google Cloud 服务集成。
其他提供商也提供 Kubernetes 即服务(KaaS),例如 IBM Cloud Kubernetes 服务、Oracle Kubernetes 容器引擎和 DigitalOcean Kubernetes(DOKS)。鉴于云市场发展迅速,查看最新的产品和功能始终是一个好主意。
管理一个 Kubernetes 集群,无论是在本地还是在云中,都涉及相当复杂的操作工作,并且需要专业知识。以下是使用托管 Kubernetes 服务通常是首选解决方案的一些原因:
-
设置和管理的简易性:托管 Kubernetes 服务处理底层基础设施,减少了管理 Kubernetes 集群的操作负担。它们会自动处理 Kubernetes 控制平面的供应、升级、补丁和扩展。
-
高可用性(HA)和高可扩展性:托管服务通常为你的应用提供开箱即用的高可用性和高可扩展性。它们处理必要的协调工作,以将应用分布到不同的节点和数据中心。
-
安全与合规性:托管服务通常包括内置的安全功能,如网络策略、基于角色的访问控制(RBAC)和与云提供商 身份与访问管理(IAM)服务的集成。它们还负责 Kubernetes 软件本身的安全更新。
-
监控与诊断:托管的 Kubernetes 服务通常包括与监控和日志服务的集成,使得观察和排除应用程序故障变得更加容易。
-
成本:虽然使用托管服务会产生一定的费用,但其成本通常低于为高效、安全地运营一个 Kubernetes 集群所需的专职人员和基础设施成本。
-
支持:使用托管 Kubernetes 服务时,你将能够获得云服务提供商的支持。如果你在运行生产工作负载并需要快速解决任何问题,这尤其有价值。
相比之下,运行你自己的 Kubernetes 集群涉及大量的设置和维护工作。从 Kubernetes 的安装和配置,到集群升级、安全补丁、节点供应和扩展的持续任务,再到设置监控和告警,你都需要负责。
管理自己的集群虽然提供了更多的控制和灵活性,但需要大量的时间、资源和专业知识投入。对于许多组织来说,托管服务的好处远远超过了自主管理集群所带来的控制力提升。
在 Amazon EKS 上运行一个简单的容器化应用程序
在这一部分,我们希望在 Amazon EKS 上使用 Fargate 创建一个完全托管的 Kubernetes 集群。创建新集群的过程在 AWS 文档中有详细描述,我们将参考相关页面,以避免重复过多信息。话虽如此,让我们从以下步骤开始。
什么是 Fargate?
AWS Fargate 是由 AWS 提供的无服务器计算引擎,用于容器。它消除了管理底层服务器的需要,让你可以专注于设计和构建应用程序。Fargate 处理容器的部署、扩展和管理,使你可以在无需担心基础设施的情况下启动应用程序。
让我们首先处理一些前提条件,如下所示:
-
确保你可以访问一个 AWS 账户。如果没有,你可以在这里获得一个免费的 1 年试用账户:
aws.amazon.com/free
。 -
登录到你的 AWS 账户。
-
为你的账户创建一对新的访问密钥和访问密钥秘密,你将用它们来配置你的 AWS CLI,从而可以通过命令行访问你的账户。
-
在屏幕右上角找到你的个人资料,从下拉菜单中选择安全凭证。
选择访问密钥(访问密钥 ID 和秘密访问密钥),然后点击创建 访问密钥:
图 18.1 – 将访问密钥 ID 和秘密配对记录在安全位置
-
打开一个新的终端。
-
确保你已安装 AWS CLI。
在 Mac 上,使用以下命令:
$ brew install awscli
在 Windows 上,使用以下命令:
$ choco install awscli
-
在两种情况下,都可以使用以下命令来测试安装是否成功:
$ aws --version
-
配置你的 AWS CLI。为此,你需要你在前面步骤 3中创建的AWS 访问密钥 ID和AWS 秘密访问密钥,以及你的默认区域。
然后,使用以下命令:
$ aws configure
在被询问时输入适当的值。对于默认输出格式,选择 JSON
,如下所示:
图 18.2 – 配置 AWS CLI
-
尝试使用如下命令访问你的账户:
$ aws s3 ls
这应该列出为你的账户定义的所有简单存储服务(S3)存储桶。你的列表可能为空。这里需要注意的是,命令成功执行即可。
-
最后,运行以下命令来再次检查是否已安装
kubectl
:$ kubectl version
现在,我们已经准备好创建 Amazon EKS 集群。按照以下步骤操作:
-
定义几个环境变量,以便后续使用,如下所示:
$ export AWS_REGION=eu-central-1$ export AWS_STACK_NAME=animals-stack$ export AWS_CLUSTER_ROLE=animals-cluster-role
确保将 eu-central-1
替换为离你最近的 AWS 区域。
-
现在,你可以使用以下命令创建所需的 AWS 堆栈,其中包括 VPC、私有和公共子网以及安全组—为了简化操作,使用 AWS 提供的一个示例 YAML 文件:
$ aws cloudformation create-stack --region $AWS_REGION \ --stack-name $AWS_STACK_NAME \ --template-url https://s3.us-west-2.amazonaws.com/amazon-eks/cloudformation/2020-10-29/amazon-eks-vpc-private-subnets.yaml
请花点时间下载并查看前面的 YAML 文件,以了解该命令具体在配置什么内容。
-
在接下来的几个步骤中,您需要定义正确的设置,以授予集群所需的访问权限:
- 首先使用以下命令创建一个 IAM 角色:
$ aws iam create-role \ --role-name $AWS_CLUSTER_ROLE \ --assume-role-policy-document file://"eks-cluster-role-trust-policy.json"
- 继续通过此命令将必要的 Amazon EKS 管理的 IAM 策略附加到刚刚创建的角色:
$ aws iam attach-role-policy \ --policy-arn arn:aws:iam::aws:policy/AmazonEKSClusterPolicy \ --role-name $AWS_CLUSTER_ROLE
-
现在,我们继续进行一些交互步骤,使用 Amazon EKS 控制台:
console.aws.amazon.com/eks/home#/clusters
。
注意
确保控制台右上角显示的 AWS 区域是您要创建集群的 AWS 区域(例如,在作者的案例中是eu-central-1
)。如果不是,请选择 AWS 区域名称旁边的下拉菜单并选择您要使用的 AWS 区域。
-
若要创建集群,请选择添加集群命令,然后选择创建。如果您没有看到此选项,请首先在左侧导航窗格中选择集群。
-
在
animals-cluster
上。 -
选择
animals-cluster-role
。 -
所有其他设置可以保持为默认值。
-
选择下一步。
- 在
vpc-00x0000x000x0x000 | animals-stack-VPC
上。注意名称的后缀,表示它是我们刚刚定义的那个。同样,您可以保持其他设置为默认值。选择下一步继续。我们无需更改配置日志记录页面上的任何内容,因此请选择下一步。同样的情况适用于选择插件页面,因此选择下一步。再一次,在配置已选择的插件设置页面上,无需做任何操作,因此请选择下一步。最后,在审查并创建页面,选择创建。*在集群名称的右侧,集群状态为创建中,持续几分钟,直到集群配置过程完成,如下图所示。在状态变为活动之前,请勿继续进行下一步:
图 18.3 – 创建 EKS 集群
-
不幸的是,我们还没有完成。我们需要创建一个信任策略并将其附加到我们的集群。为此,按照以下步骤操作:
- 首先创建一个
pod-execution-role-trust-policy.json
文件,并将以下内容添加到其中:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Condition": { "ArnLike": { "aws:SourceArn": "arn:aws:eks:<region-code>:<account-no>:fargateprofile/animals-cluster/*" } }, "Principal": { "Service": "eks-fargate-pods.amazonaws.com" }, "Action": "sts:AssumeRole" } ]}
- 首先创建一个
在前面的代码中,将<region-code>
替换为您的 AWS 区域代码(在我的案例中是eu-central-1
),将<account-no>
替换为您的账户号码。您可以在 AWS 控制台左上角的个人资料中找到后者。
- 使用刚刚配置的信任策略,使用以下命令创建一个Pod 执行 IAM 角色:
$ aws iam create-role \ --role-name AmazonEKSFargatePodExecutionRole \
--assume-role-policy-document file://"pod-execution-role-trust-policy.json"
- 最后,使用以下命令将所需的角色和策略连接在一起:
$ aws iam attach-role-policy \ --policy-arn arn:aws:iam::aws:policy/AmazonEKSFargatePodExecutionRolePolicy \
--role-name AmazonEKSFargatePodExecutionRole
-
在
animals-cluster
集群上。 -
在
animals-cluster
页面上,执行以下操作:-
选择
animals-profile
。 -
对于在前一步创建的
AmazonEKSFargatePodExecutionRole
角色。 -
选择子网下拉框,并取消选择名称中包含Public的任何子网。仅支持在 Fargate 上运行的 Pods 使用私有子网。
-
选择下一步。
-
- 在
default
下。* 然后选择下一步。* 在审查并创建页面,审查你的 Fargate 配置文件的信息,并选择创建。* 几分钟后,Fargate 配置文件配置部分的状态将从创建中变为活动。在状态变为活动之前,不要继续执行下一步。* 如果你计划将所有 Pods 部署到 Fargate(不使用 Amazon EC2 节点),请按以下步骤创建另一个 Fargate 配置文件并在 Fargate 上运行默认的名称解析器(CoreDNS)。
注意
如果你不这样做,目前将不会有任何节点。
-
在
animals-profile
下。 -
在Fargate 配置文件下,选择添加 Fargate 配置文件。
-
在名称字段中输入CoreDNS。
-
对于你在步骤 13中创建的
AmazonEKSFargatePodExecutionRole
角色。 -
单击其名称中的
Public
。Fargate 仅支持私有子网中的 Pods。 -
选择下一步。
-
在
kube-system
下。 -
选择匹配标签,然后选择添加标签。
-
在值字段中输入
k8s-app
作为kube-dns
。这是必要的,以便将默认的名称解析器(CoreDNS)部署到 Fargate。 -
选择下一步。
-
在审查并创建页面,审查 Fargate 配置文件的信息并选择创建。
-
运行以下命令,删除 CoreDNS Pods 上的默认
eks.amazonaws.com/compute-type : ec2
注解:kubectl patch deployment coredns \ -n kube-system \ --type json \ -p='[{"op": "remove", "path": "/spec/template/metadata/annotations/eks.amazonaws.com~1compute-type"}]'
注意
系统会根据你添加的 Fargate 配置文件标签创建并部署两个节点。你不会在节点组中看到任何列出的内容,因为 Fargate 节点不适用,但你将在计算标签中看到新的节点。
若需更详细的解释,可以按照以下链接中的逐步指南来创建集群:
docs.aws.amazon.com/eks/latest/userguide/getting-started-console.xhtml
(开始使用 Amazon EKS – AWS 管理控制台和 AWS CLI)
当你的集群准备好后,可以继续执行以下步骤:
-
配置
kubectl
以访问 AWS 上的新集群,如下所示:$ aws eks update-kubeconfig --name animals-cluster
响应应类似于以下内容:
Added new context arn:aws:eks:eu-central-...:cluster/animals-cluster to /Users/<user-name>/.kube/config
这里,<user-name>
对应于你正在使用的机器上的用户名。
-
双重检查
kubectl
是否使用了正确的上下文——即刚为 AWS 上的集群创建并添加到你的~/.kube/config
文件中的上下文:$ kubectl config current-context
答案应类似于以下内容:
arn:aws:eks:eu-central-...:cluster/animals-cluster
如果另一个上下文是活动状态,请使用kubectl config use-context
命令,并结合正确的 AWS 上下文。
-
使用
kubectl
列出集群上的所有资源,像这样:$ kubectl get all
此时的答案应如下所示:
图 18.4 – Amazon EKS – kubectl get all
-
要查看集群的节点,请使用以下命令:
$ kubectl get nodes
然后你应该看到类似以下的内容:
图 18.5 – EKS 集群中节点的列表
-
导航到本章的
ch18
文件夹,创建一个aws-eks
子文件夹,然后进入该文件夹:$ cd ~/The-Ultimate-Docker-Container-Book/ch18$ mkdir aws-eks && cd aws-eks
-
在此子文件夹中,创建一个名为
deploy-nginx.yaml
的文件,内容如下:
图 18.6 – 在 Amazon EKS 上部署 nginx 的规范
-
使用
kubectl
将我们的部署部署到集群,如下所示:$ kubectl apply -f deploy-nginx.yaml
-
使用以下命令观察 Pod 的创建过程:
$ kubectl get pods -w
然后等待它们准备就绪:
图 18.7 – 列出部署到 AWS 的 Pods
-
等待它们在
1/1
的值。 -
在 AWS 控制台中,导航到你的集群。
-
在
web
Pods 和两个coredns
Pods 已创建。 -
在计算选项卡中,观察到已创建多个 Fargate 节点。
-
深入到节点以查看已部署到其上的 Pod。
-
进一步深入到 Pod,并观察其详细信息视图中显示的事件列表。
恭喜你——你已在 AWS 上创建了一个完全托管的 Kubernetes 集群,并使用kubectl
在其上创建了第一个部署!正如你所知,这是一项相当了不起的成就。结果表明,在讨论的所有云提供商中,AWS 需要远远比其他更多的步骤才能运行一个 Kubernetes 集群。
在离开之前,并为了避免意外费用,请确保清理掉在此练习期间创建的所有资源。为此,请按照以下步骤操作:
-
使用
kubectl
删除先前的部署:$ kubectl delete -f deploy-nginx.yaml
-
定位你的
animals-cluster
集群并选择它。 -
在
animals-profile
和CoreDNS
配置文件中删除它们。 -
当删除这两个配置文件时(可能需要几分钟),然后点击删除集群按钮以摆脱该集群。
-
删除你创建的 VPC AWS CloudFormation 堆栈。
-
打开AWS CloudFormation控制台,网址为
console.aws.amazon.com/cloudformation
。 -
选择
animals-stack
堆栈,然后选择删除。 -
在删除 animals-stack确认对话框中,选择删除堆栈。
-
删除你创建的 IAM 角色。
-
打开 IAM 控制台,网址为
console.aws.amazon.com/iam/
。 -
在左侧导航窗格中,选择角色。
-
从列表中选择你创建的每个角色(
myAmazonEKSClusterRole
,以及AmazonEKSFargatePodExecutionRole
或myAmazonEKSNodeRole
)。选择删除,输入请求的确认文本,然后选择删除。
或者,按照 AWS 文档中第 5 步:删除资源部分的步骤执行:
docs.aws.amazon.com/eks/latest/userguide/getting-started-console.xhtml
这是一次相当了不起的成就!创建和管理一个 EKS 集群需要比我们预期更多的细节知识。我们将看到,其他提供商在这方面更加用户友好。
现在我们大致了解了 Amazon EKS 的功能,接下来让我们看看全球第二大云服务提供商的产品组合。
探索微软的 AKS
要在 Azure 中实验微软的容器相关服务,我们需要一个 Azure 账户。你可以创建一个试用账户或使用现有账户。你可以在这里获得免费试用账户:azure.microsoft.com/en-us/free/
。
微软在 Azure 上提供了不同的容器相关服务。最易使用的可能是 Azure 容器实例,它承诺是运行容器的最快和最简单方式,无需配置任何虚拟机(VMs),也不需要采用更高级的服务。如果你只想在托管环境中运行单个容器,这项服务非常有用。设置非常简单。在 Azure 门户(portal.azure.com
)中,你首先创建一个新的资源组,然后创建一个 Azure 容器实例。你只需要填写一个简短的表格,填写容器名称、使用的镜像和要打开的端口等属性。容器可以通过公共或私有 IP 地址提供,并且如果容器崩溃,它会自动重启。这里有一个不错的管理控制台,例如用于监控资源消耗,如 CPU 和内存。
第二个选择是Azure 容器服务(ACS),它提供了一种简化集群虚拟机创建、配置和管理的方式,这些虚拟机经过预配置可运行容器化应用。ACS 使用 Docker 镜像,并提供三种编排工具的选择:Kubernetes、Docker Swarm 和分布式云操作系统(DC/OS)(由 Apache Mesos 提供支持)。微软声称其服务能够扩展到数万个容器。ACS 是免费的,只有计算资源会收费。
在这一部分,我们将重点讨论基于 Kubernetes 的最流行的产品。它叫做 AKS,可以在这里找到:azure.microsoft.com/en-us/services/kubernetes-service/
。AKS 使你可以轻松地在云中部署应用并在 Kubernetes 上运行它们。所有复杂和繁琐的管理任务都由微软处理,你可以完全专注于你的应用程序。这意味着你永远不必处理安装和管理 Kubernetes、升级 Kubernetes 或升级底层 Kubernetes 节点操作系统等任务。这些都由 Microsoft Azure 的专家处理。此外,你永远不必处理 etc
或 Kubernetes 主节点。这些都被隐藏起来,你唯一需要交互的是运行你应用的 Kubernetes 工作节点。
准备 Azure CLI
话虽如此,让我们开始。我们假设你已经创建了一个免费试用账户,或者正在使用 Azure 上的现有账户。有多种方式可以与 Azure 账户进行交互。我们将使用在本地计算机上运行的 Azure CLI。我们可以将 Azure CLI 下载并安装到本地计算机,或者在本地 Docker Desktop 上的容器内运行它。由于本书的主题是容器,我们选择后者。
最新版本的 Azure CLI 可以在 Docker Hub 上找到。让我们拉取它:
$ docker image pull mcr.microsoft.com/azure-cli:latest
我们将从这个 CLI 运行一个容器,并在这个容器内部的 shell 中执行所有后续命令。现在,我们需要克服一个小问题——这个容器中没有安装 Docker 客户端。但是我们还需要运行一些 Docker 命令,因此我们必须创建一个从前面提到的镜像派生的自定义镜像,其中包含 Docker 客户端。为此所需的 Dockerfile 可以在 sample-solutions/ch18
子文件夹中找到,其内容如下:
FROM mcr.microsoft.com/azure-cli:latestRUN apk update && apk add docker
在 第 2 行,我们仅使用 Alpine 包管理器 apk
来安装 Docker。然后我们可以使用 Docker Compose 来构建并运行这个自定义镜像。对应的 docker-compose.yml
文件如下:
version: "2.4"services:
az:
image: fundamentalsofdocker/azure-cli
build: .
command: tail -F anything
working_dir: /app
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- .:/app
注意
tail -F anything
命令用于保持容器运行,并且用于挂载 Docker 套接字和当前文件夹到 volumes
部分。
提示
如果你在 Windows 上运行 Docker Desktop,则需要定义 COMPOSE_CONVERT_WINDOWS_PATHS
环境变量,才能挂载 Docker 套接字。你可以在 Bash shell 中使用 export COMPOSE_CONVERT_WINDOWS_PATHS=1
,或在运行 PowerShell 时使用 $Env:COMPOSE_CONVERT_WINDOWS_PATHS=1
。更多详情请参见以下链接:github.com/docker/compose/issues/4240
。
现在,让我们构建并运行这个容器,步骤如下:
$ docker compose up --build -d
接下来,让我们进入 az
容器并在其中运行 Bash shell,使用以下命令:
$ docker compose exec az /bin/bash
你应该会看到如下输出:
376f1e715919:/app #
注意,你的哈希码(376f1e...
)代表容器内的主机名将会不同。为了简化后续命令的阅读,我们将省略哈希码。
正如你所注意到的,我们发现自己正在容器内的 Bash shell 中运行。首先,我们来检查 CLI 的版本:
# az --version
这将生成类似如下的输出:
azure-cli 2.49.0core 2.49.0
telemetry 1.0.8
Dependencies:
msal 1.20.0
azure-mgmt-resource 22.0.0
Python location '/usr/local/bin/python'
Extensions directory '/root/.azure/cliextensions'
Python (Linux) 3.10.11 (main, May 11 2023, 23:59:31) [GCC 12.2.1 20220924]
Legal docs and information: aka.ms/AzureCliLegal
Your CLI is up-to-date.
好的——我们运行的版本是 2.49.0。接下来,我们需要登录我们的账户。执行此命令:
# az login
你将看到以下消息:
To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code <code> to authenticate.
按照指示通过浏览器登录。一旦成功认证了 Azure 账户,你可以返回终端并且应该已成功登录,输出结果会显示如下:
[ {
"cloudName": "AzureCloud",
"id": "<id>",
"isDefault": true,
"name": "<account name>",
"state": "Enabled",
"tenantId": "<tenant-it>",
"user": {
"name": <your-email>,
"type": "user"
}
}
]
现在,我们已经准备好将容器镜像首先迁移到 Azure。
在 Azure 上创建容器注册表
首先,我们创建一个名为 animal-rg
的新资源组。在 Azure 中,资源组用于逻辑上将一组相关资源归为一类。为了获得最佳的云体验并保持低延迟,选择一个靠近您的数据中心位置非常重要。请按照以下步骤操作:
-
你可以使用以下命令列出所有区域:
# az account list-locations
输出应如下所示:
[ {
"displayName": "East Asia",
"id": "/subscriptions/186760.../locations/eastasia",
"latitude": "22.267",
"longitude": "114.188",
"name": "eastasia",
"subscriptionId": null
},
...
]
这将给你一长串所有可供选择的区域。使用名称——例如,eastasia
——来标识你选择的区域。在我的例子中,我将选择 westeurope
。请注意,并非所有列出的区域都有效用于资源组。
-
创建资源组的命令很简单;我们只需要为组指定名称和位置,如下所示:
# az group create --name animals-rg --location westeurope{ "id": "/subscriptions/186.../resourceGroups/animals-rg", "location": "westeurope", "managedBy": null, "name": "animals-rg", "properties": { "provisioningState": "Succeeded" }, "tags": null, "type": "Microsoft.Resources/resourceGroups"}
确保你的输出显示 "``provisioningState": "Succeeded"
。
注意
在生产环境中运行容器化应用时,我们希望确保能够从容器注册表中自由地下载相应的容器镜像。到目前为止,我们一直从 Docker Hub 下载镜像,但这通常是不可行的。出于安全原因,生产系统的服务器通常无法直接访问互联网,因此无法连接到 Docker Hub。让我们遵循这一最佳实践,并假设我们即将创建的 Kubernetes 集群也面临相同的限制。
那么,我们该怎么办呢?解决方案是使用一个接近我们集群并且处于相同安全上下文中的容器镜像注册表。在 Azure 中,我们可以创建一个 Azure 容器注册表(ACR)实例并在其中托管我们的镜像,接下来我们将执行以下操作:
-
让我们首先创建一个注册表,如下所示:
# az acr create --resource-group animals-rg \ --name <acr-name> --sku Basic
请注意 <acr-name>
必须是唯一的。在我的例子中,我选择了 gnsanimalsacr
这个名称。缩短后的输出如下所示:
Registration succeeded.{
"adminUserEnabled": false,
"creationDate": "2023-06-04T10:31:14.848776+00:00",
...
"id": "/subscriptions/186760ad...",
"location": "westeurope",
"loginServer": "gnsanimalsacr.azurecr.io",
"name": " gnsanimalsacr ",
...
"provisioningState": "Succeeded",
-
成功创建容器注册表后,我们需要使用以下命令登录该注册表:
# az acr login --name <acr-name>
对前述命令的响应应为:
Login Succeeded
一旦我们成功登录到 Azure 上的容器注册表,我们需要正确标记我们的容器,以便我们能够将其推送到 ACR。接下来将描述如何标记和推送镜像到 ACR。
将我们的镜像推送到 ACR
一旦成功登录到 ACR,我们可以标记我们的镜像,以便它们可以推送到注册表中。为此,我们需要知道 ACR 实例的 URL。它如下所示:
<acr-name>.azurecr.io
我们现在使用前面提到的 URL 来标记我们的镜像:
# docker image tag fundamentalsofdocker/ch11-db:2.0 \ <acr-name>.azurecr.io/db:2.0
# docker image tag fundamentalsofdocker/ch11-web:2.0 \
<acr-name>.azurecr.io/web:2.0
然后,我们可以将其推送到我们的 ACR 实例:
# docker image push <acr-name>.azurecr.io/db:2.0# docker image push <acr-name>.azurecr.io/web:2.0
为了确认我们的镜像确实位于 ACR 实例中,我们可以使用此命令:
# az acr repository list --name <acr-name> --output table
这应该会给你以下输出:
Result--------
Db
web
事实上,我们刚刚推送的两个镜像已列出。
到此,我们已准备好创建 Kubernetes 集群。
创建一个 Kubernetes 集群
我们将再次使用自定义的 Azure CLI,运行在 Docker 容器中来创建 Kubernetes 集群。我们需要确保集群能够访问我们刚刚创建的 ACR 实例,镜像就在其中。所以,创建名为 animals-cluster
的集群,并配置两个工作节点的命令如下所示:
# az aks create \ --resource-group animals-rg \
--name animals-cluster \
--node-count 2 \
--generate-ssh-keys \
--attach-acr <acr-name>
这个命令需要一些时间,但几分钟后,我们应该会收到一份 JSON 格式的输出,包含有关新创建集群的所有详细信息。
要访问集群,我们需要 kubectl
。我们可以通过以下命令轻松地在 Azure CLI 容器中安装它:
# az aks install-cli
安装了 kubectl
后,我们需要必要的凭证来使用该工具操作我们在 Azure 上的新 Kubernetes 集群。我们可以通过以下命令获取所需的凭证:
# az aks get-credentials --resource-group animals-rg \ --name animals-cluster
命令应返回以下内容:
Merged "animals-cluster" as current context in /root/.kube/config
在前面命令成功执行后,我们可以列出集群中的所有节点,如下所示:
# kubectl get nodes
这将为我们提供以下列表:
NAME STATUS ROLES AGE VERSIONaks-nodepool1-12528297-vmss000000 Ready agent 4m38s v1.25.68
aks-nodepool1-12528297-vmss000001 Ready agent 4m32s v1.25.68
正如预期的那样,我们有两个工作节点正在运行。这些节点上运行的 Kubernetes 版本是 v1.25.68
。
我们现在准备将应用程序部署到这个集群。在接下来的部分,我们将学习如何将应用程序部署到 Kubernetes。
将我们的应用程序部署到 Kubernetes 集群
为了部署应用程序,我们可以使用 kubectl
apply
命令:
# kubectl apply -f animals.yaml
上述命令的输出应该类似于此:
deployment.apps/web createdservice/web created
deployment.apps/db created
service/db created
现在,我们要测试应用程序。记住,我们为 Web 组件创建了一个类型为 LoadBalancer
的服务。该服务将应用程序暴露到互联网。
该过程可能需要一些时间,因为 AKS 需要为此服务分配一个公共 IP 地址,这只是其中的一项任务。我们可以通过以下命令来观察:
# kubectl get service web --watch
请注意,上述命令中的 --watch
参数。它允许我们监控命令的执行进度。最初,我们应该看到类似这样的输出:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEweb LoadBalancer 10.0.38.189 <pending> 3000:32127/TCP 5s
公共 IP 地址标记为 pending
。几分钟后,它应该会变成这样:
图 18.8 – Microsoft AKS 上动物应用的 LoadBalancer 服务
现在我们的应用已经准备好,可以通过 IP 地址 20.76.160.79
和端口号 3000
访问。
请注意,负载均衡器将内部端口 32127
映射到外部端口 3000
;这点我第一次并未注意到。
让我们来看看。在新的浏览器标签页中,访问 http://20.76.160.79:3000/pet
,你应该能够看到我们熟悉的应用:
图 18.9 – 我们的示例应用在 AKS 上运行
至此,我们已成功将分布式应用部署到 Azure 托管的 Kubernetes 上。我们无需担心安装或管理 Kubernetes,可以专注于应用本身。
请注意,你还可以通过 Azure 门户 portal.azure.com/
管理你的 Azure 资源组、容器注册表和集群。它的界面与此类似:
图 18.10 – 显示动物资源组的 Microsoft Azure 门户
请熟悉该门户,并尝试深入了解集群、节点和部署情况。
现在我们已经完成了应用的实验,不应忘记删除 Azure 上的所有资源,以避免产生不必要的费用。我们可以通过删除资源组来删除所有已创建的资源,操作如下:
# az group delete --name animal-rg --yes --no-wait
Azure 在容器工作负载方面有一些很有吸引力的服务,并且由于 Azure 主要提供开源的编排引擎,如 Kubernetes、Docker Swarm、DC/OS 和 Rancher,其锁定效应不像 AWS 那么明显。
从技术角度看,如果我们最初在 Azure 上运行容器化应用,后来决定迁移到其他云服务提供商,我们依然可以保持灵活性。成本应该是有限的。
注意
值得注意的是,当你删除资源组时,AKS 集群使用的Azure Active Directory(AAD)服务主体并不会被删除。
有关如何删除服务主体的详细信息,请参阅在线帮助页面。你可以在这里找到相关信息:learn.microsoft.com/en-us/powershell/module/azuread/remove-azureadserviceprincipal?view=azureadps-2.0
。
接下来是 Google 的 GKE 服务。
了解 GKE
Google 是 Kubernetes 的发明者,并且至今仍是其背后的推动力。因此,你可以合理预期,Google 会提供一个吸引人的托管 Kubernetes 服务。
现在让我们快速看一下。要继续,你需要有一个 Google Cloud 账户,或者在此处创建一个测试账户:console.cloud.google.com/freetrial
。请按照以下步骤操作:
-
在主菜单中,选择Kubernetes 引擎。第一次操作时,它会花费几分钟初始化 Kubernetes 引擎。
-
接下来,创建一个新项目并命名为
massai-mara
;这可能需要一些时间。 -
一旦准备好,我们可以通过点击弹出窗口中的创建集群来创建一个集群。
-
在
animals-cluster
上选择离你最近的区域。在作者的例子中,这是europe-west1
。然后点击下一步:网络。 -
保持所有设置为默认值,并点击下一步: 高级设置。
-
再次保持所有设置为默认值,然后点击下一步:审核 并创建。
-
审核你的集群设置,如果一切看起来正常,就点击创建集群,如下图所示:
图 18.11 – GKE 集群创建向导的审核和创建视图
这将再次花费一些时间来为我们配置集群。
- 集群创建完毕后,我们可以通过点击视图右上角的云端终端图标来打开 Cloud Shell。它应该是这样显示的:
图 18.12 – 第一个 Kubernetes 集群已准备好,并且 GKE 中打开了 Cloud Shell
-
现在我们可以通过以下命令将实验室的 GitHub 仓库克隆到这个环境中:
$ git clone https://github.com/PacktPublishing/The-Ultimate-Docker-Container-Book.git ~/src
-
切换到正确的文件夹,你将在其中找到示例解决方案:
$ cd ~/src/sample-solutions/ch18/gce
现在你应该能在当前文件夹中找到一个animals.yaml
文件,你可以使用它将animals
应用部署到我们的 Kubernetes 集群中。
-
通过运行以下命令查看文件内容:
$ less animals.yaml
它与我们在上一章中使用的相同文件几乎内容一致。两者的区别在于:
-
我们使用
LoadBalancer
类型的服务(而不是NodePort
)来公开web
组件。请注意,我们在 Azure AKS 上也做了相同的操作。 -
我们没有为 PostgreSQL 数据库使用卷,因为在 GKE 上正确配置
StatefulSet
比在 Minikube 或 Docker Desktop 这样的产品中更复杂。其结果是,如果db
Pod 崩溃,我们的animals
应用将不会持久化状态。如何在 GKE 上使用持久卷超出了本书的范围。
同时请注意,我们没有使用Google 容器注册表(GCR)来托管容器镜像,而是直接从 Docker Hub 拉取它们。这非常简单——就像我们在关于 AKS 的章节中学到的内容一样——在 Google Cloud 中创建这样的容器注册表非常容易。
-
在继续之前,我们需要设置
gcloud
和kubectl
凭证。以下是我们需要执行的代码:$ gcloud container clusters \ get-credentials animals-cluster --zone <zone>
请将<zone>
替换为你在第 5 步创建集群时选择的相同区域。
前面命令的响应应该是这样的:
Fetching cluster endpoint and auth data.kubeconfig entry generated for animals-cluster.
-
让我们通过运行以下命令查看为该集群创建了哪些节点:
$ kubectl get nodes
你应该会看到类似这样的内容:
图 18.13 – GCE 上的集群节点
我们可以看到在集群中创建了两个节点,并且部署的 Kubernetes 版本显然是v1.25.8
。
-
完成这些之后,是时候部署应用程序了,运行以下命令:
$ kubectl apply -f animals.yaml
输出应如下所示:
图 18.14 – 在 GKE 上部署应用程序
-
一旦对象创建完成,我们可以观察
LoadBalancer web
服务,直到它分配到一个公共 IP 地址,如下所示:$ kubectl get svc/web –watch
前面的命令输出如下:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGEweb LoadBalancer 10.57.129.72 <pending> 3000: 32384/TCP 32s
web LoadBalancer 10\. 57.129.72 35.195.160.243 3000: 32384/TCP 39s
输出中的第二行显示了负载均衡器创建仍在待处理状态时的情况,而第三行则显示了最终状态。按Ctrl + C退出–watch
命令。显然,我们已经分配到了公共 IP 地址35.195.160.243
,端口是3000
。
-
然后,我们可以使用这个 IP 地址并导航到
http://<IP 地址>:3000/pet
,我们应该会看到熟悉的动物图片。 -
花点时间,使用你熟悉的各种
kubectl
命令来分析 GKE 集群中的情况。 -
同时,花点时间使用 GCE 的网页门户,深入查看你的集群详情。特别是,查看集群的可观测性标签。
-
一旦你完成了与应用程序的交互,删除 Google Cloud 控制台中的集群和项目,以避免不必要的费用。
-
你可以在 Cloud Shell 中使用
gcloud
命令行界面来删除集群,如下所示:$ gcloud container clusters delete animals-cluster
这会花费一点时间。或者,你也可以通过网页门户进行相同的操作。
-
接下来列出你所有的项目,如下所示:
$ gcloud projects list
-
接下来,你可以使用以下命令删除之前创建的项目:
$ gcloud projects delete <project-id>
在这里,你应该从之前的list
命令中获得正确的<project-id>
值。
我们在 GKE 上创建了一个托管的 Kubernetes 集群。然后,我们使用通过 GKE 门户提供的 Cloud Shell,首先克隆我们实验室的 GitHub 仓库,然后使用kubectl
工具将animals
应用程序部署到 Kubernetes 集群中。
在查看托管的 Kubernetes 解决方案时,GKE 是一个极具吸引力的选择。它让你轻松启动项目,并且由于 Google 是 Kubernetes 的主要推动者,我们可以放心,始终能利用 Kubernetes 的全部功能。
总结
本章首先介绍了如何在 Amazon EKS 上使用 Fargate 创建一个完全托管的 Kubernetes 集群,并在该集群上部署一个简单的应用程序。然后,你学习了如何在 Azure AKS 上创建托管的 Kubernetes 集群,并运行animals
应用程序,随后又进行了相同的操作来使用 Google 的托管 Kubernetes 解决方案——GKE。
你准备好解锁保持生产环境健康的秘密了吗?在下一章中,我们将深入探讨监控和排查在生产环境中运行的应用程序。我们将探索多种技术,用于对单个服务和整个分布式应用程序进行监控,尤其是它们在 Kubernetes 集群上运行时的情况。但这还不是全部——你还将学习如何基于关键指标创建警报。而当事情出现问题时,我们将指导你如何在不干扰集群或节点的情况下,排查运行中的应用程序。敬请期待,因为这一章将为你提供必要的工具,让你自信地在大规模环境中维护应用程序。
问题
为了评估你的知识,请回答以下问题:
-
列出几个你会选择托管 Kubernetes 服务(如 Amazon EKS、Microsoft 的 AKS 或 Google 的 GKE)来运行应用程序的原因。
-
列举使用托管 Kubernetes 解决方案(如 Amazon EKS、Azure AKS 或 Google GKE)时,考虑将容器镜像托管在相应云服务提供商的容器注册表中的两个原因。
答案
以下是本章问题的一些示例答案:
-
以下是考虑托管 Kubernetes 服务的一些原因:
-
你不想,或者没有资源来安装和管理 Kubernetes 集群。
-
你希望集中精力在为你的业务带来价值的事情上,而大多数情况下,这些事情是应该在 Kubernetes 上运行的应用程序,而不是 Kubernetes 本身。
-
你更倾向于选择按需付费的成本模型。
-
你的 Kubernetes 集群节点会自动修补和更新。
-
升级 Kubernetes 版本且不产生停机时间是简单且直接的。
-
-
将容器镜像托管在云服务提供商的容器注册表(例如 Microsoft Azure 上的 ACR)的两个主要原因如下:
-
镜像离你的 Kubernetes 集群地理位置较近,因此延迟和传输网络成本最低。
-
生产或类似生产的集群理想情况下应该与互联网隔离,因此 Kubernetes 集群节点无法直接访问 Docker Hub。
-
第十九章:19
监控和排查在生产环境中运行的应用程序
在上一章中,我们概览了在云中运行容器化应用程序的三种最流行方式——AWS EKS、Azure AKS 和 Google GKE。然后我们探索了每个托管解决方案,并讨论了它们的优缺点。
本章介绍了用于为在 Kubernetes 集群上运行的单个服务或整个分布式应用程序进行监控和添加监控的方法。你将学习基于关键指标进行警报的概念。本章还展示了如何在不改变集群或集群节点的情况下,排查生产环境中运行的应用服务问题。
以下是我们将在本章中讨论的主题:
-
监控单个服务
-
使用 OpenTracing 进行分布式追踪
-
利用 Prometheus 和 Grafana 监控分布式应用程序
-
基于关键指标定义警报
-
排查在生产环境中运行的服务故障
阅读本章并仔细完成练习后,你将掌握以下技能:
-
使用 OpenTracing 为你的服务添加监控
-
配置应用程序级别的服务监控
-
使用 Prometheus 收集并集中聚合相关的应用程序指标
-
使用 Grafana 监控应用程序
-
定义并连接基于关键指标规则触发的警报
-
使用特殊工具容器排查在生产环境中运行的服务故障
不再多说,让我们直接进入本章内容。
技术要求
在本章中,我们将使用 Docker Desktop 及其单节点 Kubernetes 集群。确保你已按照第二章《设置工作环境》的说明正确安装并配置 Docker Desktop。
我们还将使用来自 GitHub 实验室仓库中~/The-Ultimate-Docker-Container-Book/sample-solutions/ch19
文件夹的文件,访问链接:github.com/PacktPublishing/The-Ultimate-Docker-Container-Book/tree/main/sample-solutions/ch19
。
监控单个服务
对分布式、关键任务应用程序的有效监控至关重要,这类似于核电站或飞机驾驶舱中的仪表。我们的应用服务和基础设施需要类似“传感器”的设备来收集重要数据,这些传感器的功能类似于监控复杂系统中温度或流量的传感器。
这些“传感器”收集数值——或指标——以提供我们应用性能的洞察。指标可以是功能性指标,即提供与业务相关的数据,也可以是非功能性指标,即无论应用程序的业务类型如何,均能提供系统性能的洞察。
功能性指标可能包括在电子商务平台上的每分钟结账数量,或者在过去 24 小时内音乐流媒体服务中播放次数最多的五首歌曲。非功能性指标可能显示网页请求的平均延迟、返回的 4xx 状态码数量,或资源使用情况,如 RAM 或 CPU 周期。
在分布式系统中,需要一个集中式服务来汇总这些指标。这类似于飞机驾驶舱整合所有必要的读数,消除了飞行员在飞行过程中检查飞机各个部件的需要。
Prometheus 是一个开源项目,捐赠给了云原生计算基金会(CNCF),它是一个流行的服务,用于指标的暴露、收集和存储。它与 Docker 容器、Kubernetes 以及许多其他系统兼容良好。在本章中,我们将使用 Prometheus 来演示如何为服务添加指标工具。
使用 OpenTracing 进行分布式追踪
OpenTracing 是一种用于分布式追踪的开放标准,它提供了一个厂商中立的 API 和分布式系统的工具。 在 OpenTracing 中,追踪记录了事务或工作流在分布式系统中传播的过程。追踪的概念借用了科学界的一种工具,称为有向无环图(DAG),它将一个过程的各个部分从清晰的开始到清晰的结束进行分阶段。
分布式追踪是一种跟踪单个请求并记录请求在通过我们基础设施中的所有服务时的方式。它可以帮助我们了解每个服务处理请求所需的时间,并识别系统中的瓶颈。它还可以帮助我们识别在出现问题时,哪个服务是导致问题的根源。
使用 OpenTracing 进行分布式追踪可以帮助我们深入了解分布式系统,理解请求是如何在其中流动的。它还可以帮助我们识别性能问题,并更快地排查问题。
一个 Java 示例
让我们创建一个最简单的 Java 示例,使用 Spring Boot 示例来实现 OpenTracing:
-
从导航到你的源代码文件夹开始:
$ cd ~/The-Ultimate-Docker-Container-Book
-
然后创建一个子文件夹
ch19
并导航到它:$ mkdir ch19 && cd ch19
-
访问
start.spring.io/
创建一个SpringBoot
应用程序。 -
使用
Gradle – Groovy
作为项目,Java
作为语言。 -
保持其他所有默认设置。
-
创建应用程序并下载 ZIP 文件。
-
将其解压到
ch19/java
子文件夹中。 -
修改你的
build.gradle
文件,使其看起来像这样:
图 19.1 – 使用 OpenTracing 时的 build.gradle 文件
- 修改你的
DemoApplication.java
文件,使其看起来像这样:
图 19.2 – 演示 OpenTracing 的 DemoApplication.java 文件
-
通过点击
DemoApplication
类的main
方法来运行应用程序。 -
在终端窗口中,使用
curl
访问 http://localhost:8080 端点。响应应该是Hello, World!
。 -
观察 VS Code 终端窗口中的输出。你应该会看到类似这样的内容:
图 19.3 – 在一个简单的 Java 和 Spring Boot 应用中使用 OpenTracing
这表明一个 span 已被创建并上报。
接下来,让我们看看如何为 Node.js 服务添加监控。
为 Node.js 服务添加监控
在本节中,我们将学习如何通过以下步骤为 Node.js 编写的微服务添加监控:
-
进入你的源代码文件夹:
$ cd ~/The-Ultimate-Docker-Container-Book/ch19
-
创建一个名为
node
的新文件夹,并进入该文件夹:$ mkdir node && cd node
-
在此文件夹中运行
npm init
,并接受所有默认选项,除了入口点,将其从默认的index.js
更改为server.js
。 -
我们需要使用以下命令将
express
添加到项目中:$ npm install --save express
注意
从 npm 5.0.0 版本开始,你不再需要使用这个选项。现在,npm 默认会将所有已安装的包作为依赖项保存。
-
现在,我们需要使用以下命令安装 Prometheus 适配器到 Node Express 中:
$ npm install --save prom-client
-
向文件夹中添加一个名为
server.js
的文件,内容如下:const app = require("express")();app.get('/hello', (req, res) => { const { name = 'World' } = req.query; res.json({ message: `Hello, ${name}!` });});app.listen(port=3000, () => { console.log('Example api is listening on http://localhost:3000');});
这是一个非常简单的 Node Express 应用,只有一个端点 – /``hello
。
-
在前面的代码中,第 1 行之后添加以下代码片段来初始化 Prometheus 客户端:
const client = require("prom-client");const register = client.register;const collectDefaultMetrics = client.collectDefaultMetrics;collectDefaultMetrics({ register });
-
接下来,添加一个端点来暴露指标。你可以在定义
/``hello
端点之后直接添加它:app.get('/metrics', (req, res) => { res.set('Content-Type', register.contentType); res.end(register.metrics());});
-
现在让我们运行这个示例微服务:
$ npm start
你应该会看到类似这样的输出:
> node@1.0.0 start> node server.js
Example api is listening on http://localhost:3000
我们可以在前面的输出中看到该服务正在监听3000
端口。
-
现在,让我们尝试访问我们在代码中定义的
/metrics
端点的指标。为此,打开一个新的终端窗口,并使用以下命令:$ curl localhost:3000/metrics
你应该会看到类似这样的输出:
# HELP process_cpu_user_seconds_total Total user CPU time spent in seconds.# TYPE process_cpu_user_seconds_total counter
process_cpu_user_seconds_total 0.081801
# HELP process_cpu_system_seconds_total Total system CPU time spent in seconds.
# TYPE process_cpu_system_seconds_total counter
process_cpu_system_seconds_total 0.02082
# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.
# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 0.102621
…
请注意,前面的输出已被缩短以便于阅读。我们得到的输出是一个相当长的指标列表,准备供 Prometheus 服务器使用。
这其实很简单,不是吗?通过添加一个 Node 包并在我们的应用程序启动时添加几行简单的代码,我们就能访问到大量的系统指标。
现在让我们定义我们自己的自定义指标。我们将它定义为一个counter
对象:
-
将以下代码片段添加到
server.js
中,以定义一个名为my_hello_counter
的自定义计数器:const helloCounter = new client.Counter({ name: 'my_hello_counter', help: 'Counts the number of hello requests',});
-
在现有的
/hello
端点中,添加代码来增加计数器。修改后的端点应该如下所示:app.get('/hello', (req, res) => { helloCounter.inc(); const name = req.query.name || 'World'; res.json({ message: `Hello, ${name}!` });});
-
使用
npm start
重新运行应用程序。 -
为了测试新的计数器,让我们两次访问我们的
/hello
端点:$ curl localhost:3000/hello?name=Sue$ curl localhost:3000/hello?name=Marc
-
访问
/``metrics
端点时,我们将得到这个输出:$ curl localhost:3000/metrics
分析前面命令生成的输出,并在输出的末尾找到类似这样的内容:
…# HELP my_hello_counter Counts the number of hello requests
# TYPE my_hello_counter counter
my_hello_counter 2
...
我们在代码中定义的计数器显然起作用,并且输出了我们添加的HELP
文本。
现在我们知道如何为 Node Express 应用程序添加指标,接下来我们做同样的操作,为基于.NET 的微服务添加指标。
为.NET 服务添加指标
让我们从创建一个基于 Web API 模板的简单.NET 微服务开始:
-
导航到你的源代码文件夹:
$ cd ~/The-Ultimate-Docker-Container-Book/ch19
-
创建一个新的
dotnet
文件夹,并导航到该文件夹:$ mkdir dotnet && cd dotnet
-
使用
dotnet
工具来搭建一个名为sample-api
的新微服务:$ dotnet new webapi --output sample-api
-
我们将使用用于.NET 的 Prometheus 适配器,它是作为一个名为
prometheus-net.AspNetCore
的 NuGet 包提供的。使用以下命令将该包添加到sample-api
项目中:$ dotnet add sample-api package prometheus-net.AspNetCore
-
打开你喜欢的代码编辑器;例如,使用 VS Code 时,执行以下操作:
$ code .
-
定位到
Program.cs
文件并打开它。在文件的开始处,添加一个using
语句:using Prometheus;
-
然后,在文件的代码中,紧跟
app.MapControllers()
命令后面,添加app.MapMetrics()
命令。你的代码应该如下所示:…app.UseAuthorization();app.MapControllers();app.MapMetrics();app.Run();
请注意,上述内容适用于 7.x 版本或更新版本的.NET。如果你使用的是早期版本,配置可能略有不同。有关更多详细信息,请查阅github.com/prometheus-net/prometheus-net
。
-
这样,Prometheus 组件将开始发布 ASP.NET 的请求指标。让我们试试吧。首先,使用以下命令启动应用程序:
$ dotnet run --project sample-api
上面命令的输出应该如下所示:
Building...info: Microsoft.Hosting.Lifetime[14]
Now listening on:
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: /Users/.../ch19/dotnet/sample-api
...
上述输出告诉我们,微服务正在监听 http://localhost:5204。
-
现在,我们可以使用
curl
来调用服务的指标端点:$ curl http://localhost:5204/metrics
上面命令的(简化)输出类似于以下内容:
# HELP process_private_memory_bytes Process private memory size# TYPE process_private_memory_bytes gauge
process_private_memory_bytes 55619584
# HELP process_virtual_memory_bytes Virtual memory size in bytes.
# TYPE process_virtual_memory_bytes gauge
process_virtual_memory_bytes 2221930053632
# HELP process_working_set_bytes Process working set
# TYPE process_working_set_bytes gauge
process_working_set_bytes 105537536
…
prometheus_net_metric_families{metric_type="histogram"} 0
prometheus_net_metric_families{metric_type="summary"} 0
prometheus_net_metric_families{metric_type="counter"} 3
prometheus_net_metric_families{metric_type="gauge"} 12
我们得到的是微服务的系统指标列表。过程非常简单:我们只需要添加一个 NuGet 包和一行代码,就能让我们的服务实现指标采集!
如果我们想要添加我们自己的(功能性)指标呢?这同样简单。假设我们想测量对.NET scaffolding
为我们创建的/weatherforecast
端点的并发访问数量。为此,我们定义一个仪表,并使用它将适当端点的逻辑包装起来。
指标类型
Prometheus 支持四种类型的指标:
-
计数器:一个累积型指标,表示一个单一的单调递增计数器,其值只能增加或在重启时重置为零。
-
仪表:一个表示单一数值的指标,该值可以任意上升或下降。仪表通常用于衡量温度或当前内存使用量等值。
-
直方图:一种采样观察结果的指标(通常是请求时长或响应大小等),并将其计数在可配置的桶中。它还提供所有观察值的总和。
-
总结:类似于直方图,摘要会对观察结果进行采样。它不仅提供了观察结果的总计数和所有观察值的总和,还会计算在滑动时间窗口上的可配置分位数。
我们可以通过以下步骤定义我们自己的量表:
-
在
Controllers
文件夹中找到WeatherForecastController.cs
类。 -
在文件顶部添加
using Prometheus;
。 -
在
WeatherForecastController
类中定义一个私有的Gauge
类型变量callsInProgress
:private static readonly Gauge callsInProgress = Metrics .CreateGauge("myapp_calls_in_progress", "Number of weather forecast operations ongoing.");
-
用
using
语句包裹Get
方法的逻辑:[HttpGet]public IEnumerable<WeatherForecast> Get(){ using(callsInProgress.TrackInProgress()) { // code of the Get method }}
-
重启微服务。
-
使用
curl
调用/weatherforecast
端点几次:$ curl http://localhost:5204/weatherforecast
-
使用
curl
获取度量,方法和本节前面所做的相同:$ curl http://localhost:5204/metrics
你应该看到类似以下的输出(已简化):
...# HELP myapp_calls_in_progress Number of weather forecast operations ongoing.
# TYPE myapp_calls_in_progress gauge
myapp_weather_forecasts_in_progress 0
...
你会注意到,列表中现在有一个新的度量叫做myapp_weather_forecasts_in_progress
。它的值为零,因为当前你没有对跟踪的端点发出任何请求,量表类型的度量仅衡量正在进行的请求数量。
恭喜,你刚刚定义了第一个功能性度量!这只是一个开始;还有许多更复杂的可能性等待你探索。
基于 Node.js 或 .NET 的应用服务并不特殊。对用其他语言编写的服务(如 Kotlin、Python 或 Go)进行监控同样简单直接。
在学习如何对应用服务进行监控,以便它暴露出重要的度量后,让我们来看看如何使用 Prometheus 来收集和聚合这些值,以便我们监控一个分布式应用。
利用 Prometheus 和 Grafana 监控分布式应用
现在我们已经学习了如何对应用服务进行监控,使其暴露 Prometheus 度量,接下来是展示如何收集这些度量并将它们转发到 Prometheus 服务器,在那里所有度量将被聚合并存储。然后,我们可以使用 Prometheus 的(简单)Web UI 或者更复杂的解决方案,如 Grafana,在仪表盘上显示重要的度量。
与大多数用于收集应用服务和基础设施组件度量的工具不同,Prometheus 服务器承担了工作负载并定期抓取所有定义的目标。这样,应用程序和服务就无需担心转发数据。你也可以把它描述为拉取度量,而不是推送度量。
这使得 Prometheus 服务器非常适合我们的案例。我们现在将讨论如何将 Prometheus 部署到 Kubernetes,然后是我们两个示例应用服务。最后,我们将把 Grafana 部署到集群中,并使用它在仪表盘上显示我们的自定义度量。
架构
让我们快速概述一下计划系统的架构。如前所述,我们有微服务、Prometheus 服务器和 Grafana。此外,一切将部署到 Kubernetes。以下图示显示了它们之间的关系:
图 19.4 – 使用 Prometheus 和 Grafana 进行监控的应用程序概览
在图表的顶部中央,我们有 Prometheus,它定期从 Kubernetes 中抓取指标,如左侧所示。它还定期从服务中抓取指标,在我们的案例中是从之前章节中创建和配置的 Node.js 和 .NET 示例服务中抓取。最后,在图表的右侧,我们有 Grafana,它定期从 Prometheus 拉取数据,然后将其显示在图形化仪表板上。
将 Prometheus 部署到 Kubernetes
如上所示,我们首先将 Prometheus 部署到 Kubernetes。让我们首先定义一个 Kubernetes YAML 文件,用于部署 Prometheus。我们需要定义一个 Kubernetes Deployment,它将创建一个 Prometheus 服务器实例的 ReplicaSet,然后我们将定义一个 Kubernetes 服务,将 Prometheus 暴露给我们,这样我们就可以从浏览器标签页中访问它,或者 Grafana 可以访问它。让我们开始吧:
-
导航到源文件夹:
$ cd ~/The-Ultimate-Docker-Container-Book/ch19
-
创建一个名为
kube
的文件夹,并进入该文件夹:$ mkdir -p ch19/kube && cd ch19/kube
-
向此文件夹添加一个名为
prometheus.yaml
的文件。 -
将以下代码片段添加到此文件中;它定义了 Prometheus 的 Deployment:
图 19.5 – Prometheus 部署
我们正在定义一个包含两个 Prometheus 实例的 ReplicaSet。每个实例都被分配了两个标签,app: prometheus
和purpose: monitoring-demo
,用于标识。值得注意的是,在容器规格的volumeMounts
部分。我们在此处将一个名为prometheus-cm
的 Kubernetes ConfigMap
对象挂载到容器中,这个对象包含 Prometheus 配置,挂载位置是 Prometheus 期望其配置文件存在的地方。ConfigMap
类型的卷在前面代码片段的最后四行中进行了定义。
请注意,我们稍后会定义 ConfigMap。
- 现在,让我们定义 Prometheus 的 Kubernetes 服务。将这个片段追加到之前的文件中:
图 19.6 – Prometheus 服务
请注意,片段开头的三个破折号(---
)是必须的,用于在 YAML 文件中分隔各个对象定义。
我们将服务命名为prometheus-svc
,并将其设置为NodePort
(而不仅仅是ClusterIP
类型的服务),以便能够从主机访问 Prometheus 的 Web UI。
-
现在我们可以为 Prometheus 定义一个简单的配置文件。此文件基本上指示 Prometheus 服务器从哪些服务抓取指标,以及抓取的频率。首先,创建一个名为
ch19/kube/config
的子文件夹:$ mkdir config
-
向
config
文件夹添加一个名为prometheus.yml
的文件,并向其中添加以下内容:
图 19.7 – Prometheus 配置
在前面的文件中,我们为 Prometheus 定义了三个作业:
-
第一个任务,名为
prometheus
,每五秒从 Prometheus 服务器本身抓取一次指标。它在localhost:9090
目标处找到这些指标。请注意,默认情况下,指标应该暴露在/metrics
端点上。 -
第二个任务,名为
dotnet
,从位于dotnet-api-svc:80
的服务抓取指标,这将是我们之前定义和配置的 .NET Core 服务。 -
最后,第三个任务对我们的 Node 服务做了相同的操作。请注意,我们还为该任务添加了一个名为
'production'
的标签。这样可以进一步对任务进行分组。
-
现在我们可以在 Kubernetes 集群中使用下一个命令定义
ConfigMap
对象。从ch19/kube
文件夹中执行以下命令:$ kubectl create configmap prometheus-cm \--from-file config/prometheus.yml
什么是 Kubernetes ConfigMap?
Kubernetes ConfigMap 是一个 API 对象,用于以键值对的形式存储非机密的配置数据。这可以包括环境特定的 URL、命令行参数或任何其他应用程序运行所需的参数。
ConfigMap 的主要优点是它允许你将配置细节与应用程序代码解耦。这有助于使你的应用程序更具可移植性,并更易于扩展。
ConfigMap 可以通过多种方式被 Pods 消耗:作为环境变量、作为容器的命令行参数,或作为卷中的配置文件。这种灵活性使得开发人员能够根据具体情况选择最合适的方法。
-
现在我们可以使用以下命令将 Prometheus 部署到我们的 Kubernetes 服务器:
$ kubectl apply -f prometheus.yaml
这会返回以下响应:
deployment.apps/prometheus-deployment createdservice/prometheus-svc created
-
让我们再次确认部署是否成功:
$ kubectl get all
下面是前面命令的输出:
图 19.8 – 在 Kubernetes 集群上创建的 Prometheus 资源
密切关注 Pods 列表,确保它们都已启动并正在运行。请注意 prometheus-svc
对象的端口映射。在作者的案例中,9090
端口映射到 31421
主机端口。在你的情况下,后者可能会有所不同,但它也会在 3xxxx 范围内。
- 现在我们可以访问 Prometheus 的 web UI。打开一个新的浏览器标签页,导航到
http://localhost:<port>/targets
,其中<port>
在作者的案例中是31421
。你应该会看到类似下面的内容:
图 19.9 – Prometheus web UI 显示已配置的目标
在前面的截图中,我们看到为 Prometheus 定义了三个目标。列表中的第三个目标处于运行状态,并且 Prometheus 可以访问它。它是我们在配置文件中为抓取来自 Prometheus 本身指标的任务定义的端点。其他两个服务目前未运行,因此它们的状态为关闭。
-
现在通过点击 UI 顶部菜单中的相应链接,导航到 Graph 页面。
-
在搜索框中开始输入,已知的指标列表将会显示。检查 Prometheus 找到的所有列出的指标。在这种情况下,它仅是 Prometheus 服务器本身定义的指标列表:
图 19.10 – Prometheus web UI 显示可用的指标
这样,我们就准备好将之前创建的.NET 和 Node 示例服务部署到 Kubernetes 了。
将我们的应用服务部署到 Kubernetes
在我们能够使用之前创建的示例服务并将其部署到 Kubernetes 之前,必须为它们创建 Docker 镜像,并将其推送到容器注册中心。在我们的案例中,我们将它们推送到 Docker Hub。
我们从.NET Core 示例开始:
-
向
ch19/dotnet/sample-api
项目文件夹添加一个包含以下内容的 Dockerfile:FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-envWORKDIR /appCOPY *.csproj ./RUN dotnet restoreCOPY . ./RUN dotnet publish -c Release -o outFROM mcr.microsoft.com/dotnet/aspnet:7.0WORKDIR /appCOPY --from=build-env /app/out .ENTRYPOINT ["dotnet", "sample-api.dll"]
-
使用此命令在
dotnet/sample-api
项目文件夹内创建 Docker 镜像:$ docker image build -t fundamentalsofdocker/ch19-dotnet-api:2.0 .
请注意,您可能需要将前面的和后续命令中的fundamentalsofdocker
替换为您自己的 Docker Hub 用户名。
-
确保您已登录 Docker。如果没有,请使用以下命令进行登录:
$ docker login
-
将镜像推送到 Docker Hub:
$ docker image push fundamentalsofdocker/ch19-dotnet-api:2.0
现在我们对 Node 示例 API 做同样的操作:
-
向
ch19/node
项目文件夹添加一个包含以下内容的 Dockerfile:FROM node:ltsWORKDIR /appCOPY package.json ./RUN npm ci --only=productionCOPY . .EXPOSE 3000CMD ["node", "server.js"]
-
使用此命令在
ch19/node
项目文件夹内创建 Docker 镜像:$ docker image build -t fundamentalsofdocker/ch19-node-api:2.0 .
再次提醒,您可能需要将前面和后续命令中的fundamentalsofdocker
替换为您自己的 Docker Hub 用户名。
-
将镜像推送到 Docker Hub:
$ docker image push fundamentalsofdocker/ch19-node-api:2.0
通过此操作,我们已经准备好定义部署这两个服务所需的 Kubernetes 对象。定义内容较长,可以在仓库中的sample-solutions/ch19/kube/app-services.yaml
文件中找到。
请打开那个文件并分析其内容。
我们使用这个文件来部署服务:
-
确保您在
kube
子文件夹内。 -
使用以下命令来部署这两个服务:
$ kubectl apply -f app-services.yaml
这是输出结果:
deployment.apps/dotnet-api-deployment createdservice/dotnet-api-svc created
deployment.apps/node-api-deployment created
service/node-api-svc created
-
使用
kubectl get all
命令再次检查服务是否正常运行。确保 Node 和.NET 示例 API 服务的所有 Pod 都已启动并运行。 -
列出所有 Kubernetes 服务,以找出每个应用服务的主机端口:
$ kubectl get services
输出应如下所示:
图 19.11 – kubectl get services 输出
在作者的案例中,.NET API 映射到端口30211
,Node API 映射到端口30663
。您的端口可能会有所不同。
-
使用
curl
访问.NET 服务的/metrics
端点:$ curl localhost:30211/metrics
输出应如下所示:
# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds.# TYPE process_cpu_seconds_total counter
process_cpu_seconds_total 0.4
# HELP prometheus_net_meteradapter_instruments_connected Number of instruments that are currently connected to the adapter.
# TYPE prometheus_net_meteradapter_instruments_connected gauge
prometheus_net_meteradapter_instruments_connected 0
# HELP prometheus_net_exemplars_recorded_total Number of exemplars that were accepted into in-memory storage in the prometheus-net SDK.
# TYPE prometheus_net_exemplars_recorded_total counter
prometheus_net_exemplars_recorded_total 0
...
-
现在对 Node 服务做同样的操作:
$ curl localhost:30663/metrics
这次,输出结果如下所示:
# HELP process_cpu_user_seconds_total Total user CPU time spent in seconds.# TYPE process_cpu_user_seconds_total counter
process_cpu_user_seconds_total 1.0394399999999997 1578294999302
# HELP process_cpu_system_seconds_total Total system CPU time spent in seconds.
# TYPE process_cpu_system_seconds_total counter
process_cpu_system_seconds_total 0.3370890000000001 1578294999302
...
- 在 Prometheus 中再次检查
/targets
端点,确保这两个微服务现在可以访问:
图 19.12 – Prometheus 显示所有目标都在运行
-
为了确保我们为 Node.js 和.NET 服务定义并暴露的自定义指标已经生效,我们需要至少访问一次每个服务。因此,使用
curl
多次访问各自的端点:# access the /weatherforecast endpoint in the .NET service$ curl localhost:30211/weatherforecast# and access the /hello endpoint in the Node service$ curl localhost:30663/hello
-
我们还可以在 Prometheus 的图形视图中看到这两个指标:
图 19.13 – Prometheus 中的自定义指标
最后一步是将 Grafana 部署到 Kubernetes,以便我们能够创建复杂且具有图形化吸引力的仪表盘,显示应用服务和/或基础设施组件的关键指标。
将 Grafana 部署到 Kubernetes
现在让我们也将 Grafana 部署到我们的 Kubernetes 集群中,这样我们就可以像管理我们分布式应用的其他组件一样管理这个工具。作为允许我们创建用于监控应用的仪表盘的工具,Grafana 可以被视为关键任务工具,因此需要这样处理。
将 Grafana 部署到集群是非常直接的。我们可以按以下步骤操作:
-
在
ch19/kube
文件夹中添加一个新的文件,命名为grafana.yaml
。 -
在此文件中,添加 Grafana 的 Kubernetes Deployment 定义:
图 19.14 – grafana.yaml 文件的内容
如果你不想自己输入代码,可以在你的仓库的sample-solutions/ch19/kube
子文件夹中找到该文件。
该定义没有什么意外。在这个示例中,我们运行的是单实例的 Grafana,并且它使用app
和purpose
标签进行标识,类似于我们为 Prometheus 使用的标签。这次不需要特殊的卷映射,因为我们只使用默认配置。
- 我们还需要暴露 Grafana,因此需要将以下代码片段附加到前面的文件中,以定义 Grafana 的服务:
图 19.15 – Grafana 的 Kubernetes 服务
我们再次使用了NodePort
类型的服务,以便从主机访问 Grafana UI。
-
现在我们可以使用以下命令部署 Grafana:
$ kubectl apply -f grafana.yaml
这将产生以下输出:
deployment.apps/grafana-deployment createdservice/grafana-svc created
-
让我们找出可以访问 Grafana 的端口号:
$ kubectl get services/grafana-svc
这将给我们带来以下内容:
图 19.16 – 获取 Grafana 服务的详细信息
- 打开一个新的浏览器标签,导航至
http://localhost:<port>
,其中<port>
是你在上一阶段识别的端口号,在我的情况下是32736
。你应该看到类似这样的内容:
图 19.17 – Grafana 的登录界面
-
使用默认用户名
admin
登录,密码也是admin
。当系统提示更改密码时,暂时点击跳过链接。你将被重定向到首页仪表盘。 -
在首页仪表盘上,点击创建你的第一个数据源,然后从数据源列表中选择Prometheus。
-
添加
http://prometheus-svc:9090
作为 Prometheus 的 URL,并点击绿色的保存 & 测试按钮。 -
在 Grafana 中,返回到首页仪表盘,然后选择新建 仪表盘链接。
-
点击添加查询,然后从指标下拉菜单中选择我们在 .NET 示例服务中定义的自定义指标:
图 19.18 – 在 Grafana 中选择 .NET 自定义指标
-
将相对时间的值从1 小时改为5 分钟(5m)。
-
更改仪表盘的刷新率,刷新率位于视图的右上角,设置为5 秒(5s)。
-
对 Node 示例服务中定义的自定义指标做相同的操作,这样你将在新仪表盘上有两个面板。
-
根据
grafana.com/docs/grafana/latest/guides/getting_started/
的文档修改仪表盘及其面板,以便根据自己的喜好进行定制。 -
使用
curl
访问示例服务的两个端点,并观察仪表盘。它可能看起来像这样:
图 19.19 – 带有我们两个自定义指标的 Grafana 仪表盘
总结来说,我们可以说 Prometheus 非常适合用来监控我们的微服务,因为我们只需要暴露一个指标端口,因此不需要增加太多复杂性或运行额外的服务。Prometheus 然后负责定期抓取配置好的目标,这样我们的服务就不需要担心这些指标的输出。
基于关键指标定义警报
如果你认为仅仅收集日志和指标并在吸引人的仪表盘中显示它们就足够了,你会感到失望。如果我们只是使用仪表盘,一些支持人员将需要不断站在大量监视器前,全天候、每年 365 天待命,以防万一。委婉地说,这份工作是枯燥的。如果那个人打瞌睡了怎么办?我们必须调整方法。让我们从定义什么是指标开始。
指标
指标作为警报规则的输入值。必须识别出关键指标,如果它们超过预设值并持续一段时间,就需要触发警报。例如,考虑 CPU 使用率。
基于关键指标定义警报是监控和维护 Docker 和 Kubernetes 系统健康的重要部分。警报使我们能够基于指标定义条件,并在这些条件满足时发送通知,从而使我们能够迅速响应潜在问题。
在 Kubernetes 中,我们可以使用像 Prometheus 这样的工具,根据 PromQL 表达式定义警报规则。这些规则允许我们基于从集群收集的度量数据指定条件,并在这些条件满足时向外部服务发送通知。例如,我们可以定义一个警报,当集群节点的 CPU 或内存利用率超过某个阈值时触发。
在 Docker 中,我们可以使用像 cAdvisor 或 Docker stats 这样的工具来收集容器的度量数据,然后使用监控和警报工具根据这些度量数据定义警报。例如,我们可以定义一个警报,当运行的容器数量超过某个阈值时触发。
在定义警报时,我们需要遵循最佳实践,确保警报既有效又可操作。关于 Kubernetes 的一些警报最佳实践包括以下内容:
-
基于症状的警报:警报应基于那些具有显著影响的症状,而不是基于度量数据中出现的异常值。
-
基于主机或 Kubernetes 节点层的警报:监控主机和节点的健康状况,以确保集群平稳运行。
-
基于 Kubernetes 基础设施的警报:监控 Kubernetes 控制平面和其他内部服务的健康状况。
-
基于 Kubernetes 上运行的服务的警报:监控在 Kubernetes 上运行的应用程序的健康状况。
-
基于应用层度量的警报:监控应用程序特定的度量数据,以确保应用程序顺利运行。
现在让我们来讨论当发生异常情况时的警报处理。
警报
让我们来定义警报,当出现异常情况时发送警报。我们可以通过不同的方式进行警报通知。如果你正在值班,可能会收到寻呼信息、短信、电子邮件,甚至是激活警报音和闪烁的警告灯。一切都取决于具体的使用场景。我们只需要说明,作者参与了多个程序,这些程序采用了上述所有的警报通知方式。
以 CPU 使用情况为例。当一个 Kubernetes 集群节点的 CPU 使用率超过 95% 且持续超过一分钟时,系统可靠性工程师(SRE)需要收到通知。
但是,你可能会想,谁来制定这些指南呢?运营团队——或者更准确地说,是 SRE 们——负责确定哪些非功能性度量数据是重要的,并在需要时决定何时通知他们,即使是在深夜。公司必须明确规定功能性度量数据及其容忍度或其他标准,这些标准将触发每个度量的警报。
定义警报
仅仅收集和展示度量数据是不够的,无论它们是与基础设施相关还是与业务相关。为了为这些度量数据制定服务水平目标(SLOs)和服务水平协议(SLAs),我们首先必须确定真正定义系统状态的关键指标。随后,我们制定指导方针,规定一个度量数据超出适当的 SLO 或 SLA 的频率和持续时间。如果违反这些规则,我们会发送警报。
让我们定义几个潜在的警报候选项来了解这一点。第一个示例是系统级统计数据,而第二个是功能性或与业务相关的度量标准。你能区分它们吗?
-
我们将银行应用程序中使用的总 CPU 百分比定义为一个统计数据。该比例不应超过 99%可能是 SLO。规则可能是:如果 CPU 百分比在一分钟内超过 99%超过 50%,则应发送警报。
-
我们可以将为客户提供报价的时间指定为一个关键统计数据,适用于提供人寿保险的应用程序。该度量的 SLA 可能是:99%的报价请求必须在 50 毫秒内处理完毕。没有请求可以超过 1,000 毫秒。如果 SLA 在一个小时内被违反超过三次,根据警报规则,应该发送警报。
前者是一个基础设施度量,而后者是一个商业度量。
选定的目标人员,例如 SRE 或开发人员,可以通过多种渠道接收警报,包括电子邮件、短信、自动电话、Slack 消息、音频警报、光学警报等。
一旦我们创建并配置了这些警报,服务人员就可以进行其他活动,而不必主动监控系统。如果发生任何重大或异常情况,他们将确保得到通知并作出响应。
运行手册
假设已经触发了一个警报。那么接下来怎么办?运行手册可以在这种情况下提供帮助。运行手册概述了每个警报需要通知谁,该人员需要做什么来解决潜在问题,如果问题无法解决,应将问题升级到谁那里。创建运行手册是一个困难的过程,不容小觑。然而,它们是企业的关键工具。SRE 的能力是有限的。有些生产问题非常严重,以至于必须通知 C 级管理人员。假设你经营一个在线商店,由于支付服务提供商(PSP)故障,无法在平台上处理支付,这意味着你的应用程序现在缺少一个关键的需求。实质上,在问题修复之前,你无法开展业务;你不认为 CTO 应该知道这个情况吗?
让我们谈谈一个当前的热门话题:生产系统中出现的问题。我们需要迅速找出问题的根本原因。
在生产环境中排查服务问题
最佳实践是创建仅包含绝对必要内容的最小化生产镜像。这包括通常用于调试和排查应用程序的常见工具,如 netcat
、iostat
、ip
等。理想情况下,生产系统仅在集群节点上安装容器编排软件(如 Kubernetes)和一个最小化操作系统(如 CoreOS)。而应用容器理想情况下只包含运行所必需的二进制文件。这可以最小化攻击面,并降低处理漏洞的风险。此外,小型镜像有一个优势,即下载速度快,占用的磁盘和内存空间少,启动速度更快。
但如果我们在 Kubernetes 集群中运行的某个应用服务表现异常,甚至崩溃,这可能会成为一个问题。有时候我们无法仅通过生成并收集的日志来找到问题的根本原因,这时我们可能需要直接在集群节点上排查该组件。
我们可能会想通过 SSH 连接到指定的集群节点,并运行一些诊断工具。但这是不可能的,因为集群节点仅运行一个最小化的 Linux 发行版,并未安装这些工具。作为开发者,我们现在可以请求集群管理员安装所有我们打算使用的 Linux 诊断工具。但这并不是一个好主意。首先,这会为集群节点上可能存在的易受攻击的软件敞开大门,危及在该节点上运行的所有其他 pod,也会为集群本身敞开一道门,黑客可能会利用这一点。此外,给予开发者对生产集群节点的直接访问始终是一个不好的主意,无论你多么信任他们。只有有限数量的集群管理员才应该有权限这么做。
更好的解决方案是让集群管理员代表开发者运行一个所谓的堡垒容器。这个堡垒容器或排错容器已经安装了我们需要的所有工具,帮助我们准确找出应用服务中 bug 的根本原因。它也可以在主机的网络命名空间中运行,因此它将能够完全访问容器主机的所有网络流量。
netshoot 容器
前 Docker 员工 Nicola Kabar 创建了一个实用的 Docker 镜像,名为 nicolaka/netshoot
,这是 Docker 的现场工程师常用来排查在 Kubernetes 或 Docker Swarm 上运行的应用程序问题的工具。正如创建者所言,这个容器的目的如下:
“目的:Docker 和 Kubernetes 网络故障排除可能会变得复杂。通过正确理解 Docker 和 Kubernetes 网络工作原理以及使用正确的工具,你可以进行故障排除并解决这些网络问题。netshoot 容器具有一套强大的网络故障排除工具,可以用于故障排除 Docker 网络问题。”
- Nicola Kabar
若要使用此容器进行调试,我们可以按如下步骤进行:
-
使用以下命令启动一个临时的堡垒容器,在 Kubernetes 上进行调试:
$ kubectl run tmp-shell --rm -i --tty \ --image nicolaka/netshoot
你将看到以下提示:
bash-5.0#
-
你现在可以在这个容器中使用像
ip
这样的工具:bash-5.0# ip a
在我的机器上,如果 pod 在 Docker Desktop 上运行,输出结果类似于以下内容:
图 19.20 – 使用 netshoot 容器运行 ip a
命令的输出
-
要退出这个故障排除容器,只需按 Ctrl + D 或键入
exit
然后按 Enter。 -
如果我们需要更深入地了解并在与 Kubernetes 主机相同的网络命名空间中运行容器,那么我们可以使用这个命令:
$ kubectl run tmp-shell --rm -i --tty \ --overrides='{"spec": {"hostNetwork": true}}' \ --image nicolaka/netshoot
-
如果我们在这个容器中再次运行
ip
命令,我们也会看到容器主机所看到的一切,例如所有的veth
端点。
netshoot
容器安装了工程师解决网络相关问题时所需的所有常用工具。更熟悉的一些工具有 ctop
、curl
、dhcping
、drill
、ethtool
、iftop
、iperf
和 iproute2
。
总结
在本书的最后一章,我们探讨了用于为单个服务或整个分布式应用程序添加监控和仪表化的不同技术,尤其是在 Kubernetes 集群上运行时。你已经了解了基于关键指标的告警概念。此外,我们还展示了如何在不改变集群或运行服务的集群节点的情况下,故障排除生产环境中运行的应用服务。
随着本书的结束,我们想感谢你对本书的兴趣并坚持读到最后。我们希望提供的信息和示例有助于加深你对 Docker 和 Kubernetes 的理解。这些技术是构建和部署现代应用程序的强大工具,我们希望本书能帮助你获得有效使用它们的知识和信心。再次感谢你的阅读,祝你未来一切顺利!
问题
为了评估你的学习进度,请回答以下问题:
-
为什么为你的应用服务添加监控是重要的?
-
你能向一个感兴趣的外行人描述 Prometheus 是什么吗?
-
导出 Prometheus 指标非常简单。你能用简单的语言描述如何为 Node.js 应用程序做这件事吗?
-
你需要调试一个在 Kubernetes 上运行的生产服务。不幸的是,仅凭该服务生成的日志无法提供足够的信息来定位根本原因。你决定直接在相应的 Kubernetes 集群节点上进行故障排查。你该如何进行?
答案
以下是前面问题的示例答案:
-
由于性能和安全原因,我们不能在生产系统上进行任何实时调试,包括交互式或远程调试。然而,应用服务可能会因为代码缺陷或其他基础设施相关问题(例如网络故障或外部服务不可用)而表现出异常行为。为了快速确定服务异常或失败的原因,我们需要尽可能多的日志信息。这些信息应该为我们提供线索,引导我们找到错误的根本原因。当我们对服务进行监控时,我们正是通过这种方式操作——以日志条目和已发布的度量标准的形式生成尽可能多的信息。
-
Prometheus 是一个用于收集其他基础设施服务和最重要的应用服务提供的功能性或非功能性度量标准的服务。由于 Prometheus 本身会定期从所有配置的服务中拉取这些度量标准,因此服务本身无需担心发送数据。Prometheus 还定义了生产者呈现度量标准的格式。
-
为了对基于 Node.js 的应用服务进行监控,我们需要执行以下四个步骤:
-
向项目中添加一个 Prometheus 适配器。Prometheus 的维护者推荐使用一个名为
siimon/prom-client
的库。 -
在应用程序启动时配置 Prometheus 客户端。这包括定义一个度量标准注册表。
-
暴露一个 HTTP GET 端点/metrics,在此端点返回在度量标准注册表中定义的度量标准集合。
-
最后,定义自定义的计数器、仪表盘或直方图类型的度量标准,并在我们的代码中使用它们;例如,我们每次调用某个端点时,都会增加一个计数器类型的度量标准。
-
-
通常在生产环境中,Kubernetes 集群节点仅包含最小化的操作系统,以保持尽可能小的攻击面并避免浪费宝贵的资源。因此,我们不能假设在相应主机上可以使用通常用于排查应用程序或进程问题的工具。一个强大且推荐的排查方法是在临时 Pod 中运行一个特殊的工具或排查容器。然后可以使用这个容器作为堡垒,帮助我们调查受影响服务的网络和其他问题。许多 Docker 领域工程师在客户现场成功使用的容器是
nicolaka/netshoot
。