Docker-启动指南第三版-全-

Docker 启动指南第三版(全)

原文:zh.annas-archive.org/md5/0c12b6992c5ce2718a32befa2d8b2d13

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

容器无处不在。从本地开发到持续集成,再到管理大规模生产工作负载,容器随处可见。这一现象是如何产生的?它将走向何方?作为读者,你需要了解这场已经占领我们行业的革命的相关信息。

许多旧技术提供了“一次编写,到处运行”的承诺。然而,并非所有的运行时都提供了这种功能,即使有的也仍然需要运行时(和任何额外的依赖项)可用才能运行应用程序。容器提供了“构建一次,到处运行”的承诺。它们允许您将应用程序、运行该应用程序所需的运行时、配置文件以及所有文件依赖项打包到一个构件中。只要目标机器上有容器运行时,您的应用程序就可以正常工作。这使得您的基础设施真正实现了应用程序无关性。“它在我的电脑上可以运行”的问题已经不复存在!

容器提供了标准的应用程序编程接口(API)来管理容器的生命周期及容器中打包的应用程序。这个 API 为本来异构的部署环境提供了一个统一的接口,使运维团队不再需要了解部署和运行应用程序的细节,从而能够集中精力做他们最擅长的事情——管理基础设施、执行安全和合规性,并保持系统运行。

这个接口也为大量创新奠定了基础。像 Kubernetes 和 Nomad 这样的容器编排器利用这个控制平台来提升抽象层级,使得在规模化管理容器化工作流程变得更加容易。服务网格技术,如 Istio,与编排器密切合作,将服务发现和安全等横切关注点与应用程序栈解耦。

所有标准接口的好处也向上游传递,使开发者的日常工作变得更加轻松。一条命令可以生成整个开发环境。在持续集成(CI)中,容器可以轻松启动以容纳数据库、队列或应用程序所需的任何依赖项,以进行集成、冒烟和端到端测试,以检查和验证您的工作。最后,容器的可移植性使得开发团队能够在生产环境中拥有自己的作品,使得 DevOps 的许多方面成为现实。

在运行时经常更新主要版本、团队和组织使用多种编程语言、蓝绿部署和金丝雀发布等 DevOps 实践已经成为常态,规模空前的今天,全球各地团队用来构建和部署应用程序的技术正是容器。容器不再是新鲜事物或新奇技术,而是代表了组织打包和部署应用程序的基本规则。

然而,使用容器并不容易。作为一个几乎十年来使用容器,并且在全球各地教授容器技术的人,我可以证明这个主题是多么细腻微妙。

Sean 和 Karl 将多年的经验融入到了一本非常易读且全面的使用 Docker 的指南中。您可以在这本书的页面中找到一切入门和生产化使用 Docker 所需的一切内容——从安装,理解如何使用和构建镜像,到处理容器,审视构建和运行时,以及将容器投入生产。

而且 Sean 和 Karl 并不畏惧深入微观细节——详细阐述了像 cgroups 和命名空间这样的简单 Linux 原语如何使得这种被称为容器的神奇事物成为现实。最后,Docker 生态系统不断增长和扩展——本书也会涵盖这个领域。

在《Docker: Up & Running》第二版的前言中,Laura Tacho 做出了一项敏锐的观察——像 VM 和容器这样的云原生技术并不是互斥的,而是相辅相成的。如今,这种说法再合适不过了——像Kata Containers这样的技术的兴起,结合了轻量级虚拟机来运行容器,因此既具有 VM 的隔离性又具备容器的可移植性,这证明了 Laura 的评论是正确的。

容器已经无处不在。千里之行始于足下——确实,深入理解容器的旅程是漫长的。如果这本书是你的第一步,那你做出了正确的选择。你将有两位经验丰富的向导指引你前行,虽然我知道你并不需要,但我仍然祝你一切顺利。

祝您容器化愉快。

Raju Gandhi

创始人,DefMacro Software, LLC,

以及《Head First Software Architecture》、《Head First Git》和《JavaScript Next》的作者

@looselytyped

俄亥俄州哥伦布市

2023 年 4 月

前言

本书适用于任何需要实际理解 Linux 容器及其如何改善开发和生产实践的人。大多数现代集成工作流程和生产系统需要开发人员和运维工程师深入了解 Linux 容器,以及如何利用它们显著提高系统的可重复性和可预测性。在这个过程中,我们将探讨如何在 Docker 生态系统中构建、测试、部署和调试 Linux 容器。我们还将介绍一些利用 Linux 容器的重要编排工具。最后,我们还将提供一些有关容器环境安全性和最佳实践的指导。

谁应该阅读本书

本书适用于任何希望解决开发和部署大规模软件到生产环境所涉及的复杂工作流程问题的人。如果你对 Linux 容器、Docker、Kubernetes、DevOps 和大规模可扩展软件基础设施感兴趣,那么这本书适合你。

为什么要阅读本书?

今天互联网上关于 Docker 有很多讨论、项目和文章,其中一些甚至开始预测 Docker 的衰落。

那么,为什么你要花宝贵的时间来阅读这本书呢?

虽然今天有其他选择,但 Docker 独自一人使 Linux 容器对所有工程师都变得可访问。在 Docker 创建容器镜像格式并帮助构建今天容器化系统中使用的许多核心库之前,Linux 容器非常难以使用,主要仍然是大型云托管公司的工具,这些公司需要在保护系统免受不受信任的用户代码的同时提供可扩展性。

Docker 彻底改变了这一切。

尽管关于 Docker 和 Linux 容器的信息很多,但这个领域仍在积极发展,并且最佳实践在不断变化。想象一下,你刚读了一篇四年前发布的关于 Docker 的博客文章。它可能仍然有效,但也许不再是最佳实践了。在我们撰写本书第一版期间,Docker 公司发布了四个版本的 Docker,还推出了几个主要工具到他们的生态系统中。在本书第一版和第三版之间的七年里,这个领域发生了巨大变化。Docker 已经稳定下来,现在有许多其他工具填补了类似的角色。现在不再是完全缺乏工具可用,几乎每个 DevOps 工作流程的方面都有许多健壮的选择。理解 Linux 容器和 Docker 提供的范围,理解它们如何融入你的工作流程,并正确处理所有的各种集成,这些都不是简单的任务。

我们与多家公司合作超过九年,构建和运营了多种生产 Linux 容器平台,包括 Docker、Mesos 和 Kubernetes。我们在 Docker 发布仅几个月后就开始在生产环境中使用,并可以通过分享我们在这些生产平台上积累的经验,让您受益。尽管 Docker 项目的在线文档 非常有用,但我们将努力为您呈现更全面的图景,并向您介绍我们学到的许多最佳实践。

完成本书后,您将获得足够的信息来理解 Linux 容器、Docker 提供的功能、它们的重要性以及如何利用它们从本地开发到生产的流程优化。这将是一个对一些非常实用技术进行有趣探索的旅程。

浏览本书

本书的结构如下:

  • 第 1 和第二章介绍了 Docker,并解释了它是什么以及如何使用它。

  • 第三章带您了解安装 Docker 所需的步骤。

  • 第 4 到第六章深入讨论了 Docker 客户端、镜像和容器,探索了它们的定义及如何操作。

  • 第七章讨论了如何调试您的镜像和容器。

  • 第八章介绍了 Docker Compose 及其如何显著简化开发复杂基于容器的服务的过程。

  • 第九章探讨了确保顺利过渡到生产环境的重要考虑因素。

  • 第十章深入探讨了在公共和私有云中规模化部署容器。

  • 第十一章深入探讨了一些高级主题,需要对 Docker 有一定了解,并且在您开始在生产环境中使用 Docker 时可能很重要。

  • 第十二章探讨了在容器化 Linux 环境中可能有用的几种替代工具。

  • 第十三章探索了关于如何设计下一代互联网规模生产软件的核心概念。

  • 第十四章总结了所有内容,并做了一个大致概述,包括涵盖的内容以及如何帮助您改进交付和扩展软件服务的方式。

我们意识到许多人不会从头到尾阅读技术书籍,前言等内容很容易被忽略,但如果您仍然在阅读中,这里有一个快速指南,介绍本书的不同阅读方法:

  • 如果您对 Linux 容器还不熟悉,请从头开始。前两章旨在帮助您了解 Docker 和 Linux 容器的基础知识,包括它们是什么、如何工作以及为什么值得关注。

  • 如果您想立即安装和运行 Docker 在您的工作站上,请跳到第 3 和第四章节,这些章节将指导您如何安装 Docker,创建和下载镜像,运行容器等等。

  • 如果您熟悉 Docker 基础知识,但希望了解如何在开发中使用它,请参阅第 5 至第八章,这些章节涵盖了许多使您日常使用 Docker 更轻松的技能,并最终深入探讨了 Docker Compose。

  • 如果您已经在开发中使用 Docker,但需要一些帮助将其投入生产环境,请考虑从第九章开始,并继续阅读至第十二章。这些章节深入探讨了部署容器、利用高级容器平台和其他高级主题。

  • 如果您是软件或平台架构师,您可能会发现第 13 章是一个有趣的探索地点,因为我们深入探讨了关于容器化应用和水平可扩展服务设计的当前思想。

本书使用的约定

本书使用以下排版约定:

斜体

表示新术语、URL、电子邮件地址、文件名和文件扩展名。

Constant width

用于程序清单,以及在段落中引用程序元素,例如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户应直接输入的命令或其他文本。

<尖括号中的常量宽度>

显示应由用户提供的值或由上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此元素指示警告或注意事项。

使用代码示例

补充材料(代码示例、练习等)可以从 https://github.com/bluewhalebook/docker-up-and-running-3rd-edition 下载。

本书旨在帮助您完成工作。一般而言,如果书中提供了代码,您可以在程序和文档中使用它。除非您复制了大量代码,否则无需获得我们的许可。例如,编写一个使用本书多个代码片段的程序不需要许可。出售或分发从 O’Reilly 图书中获取的示例集合需要许可。引用本书并引用示例代码回答问题无需许可。将本书大量示例代码整合到产品文档中需要许可。

我们感谢但不要求署名。一般而言,署名通常包括标题、作者、出版商和 ISBN。例如:“Docker: Up & Running,第 3 版,作者为 Sean P. Kane 和 Karl Matthias(O’Reilly)。版权所有 2023 年 Sean P. Kane 和 Karl Matthias,ISBN 978-1-098-13182-1。”

如果您认为您使用的代码示例超出了合理使用范围或上述授权,请随时通过permissions@oreilly.com与我们联系。

O’Reilly 在线学习

注意

超过 40 年来,O’Reilly Media提供技术和商业培训、知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深度学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的广泛文本和视频收藏。欲了解更多信息,请访问https://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大境内)。

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书设立了一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/docker-up-and-running-3e

发送邮件至bookquestions@oreilly.com以评论或询问有关本书的技术问题。

有关我们的图书和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

观看我们的 YouTube 频道:https://youtube.com/oreillymedia

致谢

我们要衷心感谢许多帮助使每一版本书成为可能的人们:

  • 尼克·本德斯、比约恩·弗里曼-本森和达纳·劳森在新遗迹公司,他们在支持第一版方面超出了职责范围,并确保我们有时间进行追求。

  • 罗兰·特里奇和 Nitro 软件公司支持卡尔在第二版的努力。

  • 奥莱利传媒的劳雷尔·鲁玛,最初联系我们撰写一本 Docker 书籍,还有迈克·卢基德斯帮助一切进展顺利。

  • 特别感谢我们的第一版编辑布莱恩·安德森,确保我们了解我们所从事的工作,并在每一个步骤中引导我们。

  • 尼基·麦克唐纳和弗吉尼亚·威尔逊,在我们创建急需的第二版书籍过程中给予了帮助和指导。

  • 以及约翰·戴文斯、米歇尔·克罗宁和伊丽莎白·法尔姆,他们非常努力确保这第三版得以问世。

  • 感谢《Terraform: Up & Running》的优秀作者叶夫根尼·(吉姆)·布里克曼,他慷慨地让我们大量参考了他之前作品的网站设计 https://dockerupandrunning.com

  • 简洁地向新技术引入新观众需要特别的才能。我们非常感谢拉尔斯·赫尔曼、劳拉·弗兰克·塔乔和拉朱·甘迪抽出时间为其中一次发布撰写序言。

  • 我们的草稿审阅者,在写作过程中的各个时刻帮助确保我们走在正确的轨道上:克塞尼娅·布拉查恩科,她给了我们第一次评审和全面技术审查;安德鲁·T·贝克,塞巴斯蒂安·戈阿斯格恩,亨利·戈麦斯,切尔西·弗兰克,拉希德·扎鲁阿利,沃纳·戴克曼,普雷德拉格·克涅热维奇和维什韦什·拉维·施里马利。

  • 特别感谢艾丽丝·戈德弗斯和汤姆·奥弗曼,在我们写第一版时给予了详细且始终有用的反馈;感谢米哈伊·托多尔在鼓励、技术审查和第二版全面反馈方面的支持。

  • 吉利安·麦加维、梅兰妮·亚伯罗、贾斯汀·比林、瑞秋·莫纳汉和索尼娅·萨鲁巴,在副本编辑手稿方面的努力使它看起来像是我们在高中英语课上认真听讲。517 个逗号已添加,仍在增加……

  • 苏·克莱夫斯塔德,她帮助我们确保第三版索引对所有读者都是有用的参考,还有温迪·卡塔拉诺和艾伦·特劳特曼在索引早期版本方面的努力。

  • 特别感谢尼克·亚当斯和奥莱利传媒幕后的所有工作人员,帮助确保在所有分发格式中一切都显示得恰到好处。

  • 我们在新遗迹和 Nitro 的所有同事们,一路陪伴我们走过整个 Docker 之旅。他们为我们提供了许多在这里反映的经验。

  • Grains of Wrath Brewery、World Cup Coffee、McMenamins Ringlers Pub、Old Town Pizza、A Beer at a Time!、Taylor's Three Rock pub 等,他们在我们用完餐具后仍慷慨地让我们使用他们的桌子和电源。

  • 我们的家人,在我们需要安静时间时给予了支持。

  • 最后,感谢在整个过程中鼓励我们、给予建议或以任何方式支持我们的每一位朋友。

第一章:介绍

Docker 是由当时被称为 dotCloud 公司的创始人兼 CEO Solomon Hykes 在 2013 年 3 月 15 日在加利福尼亚州圣克拉拉的Python 开发者大会上进行的一个五分钟的闪电演讲中首次向世界公开介绍的,没有任何预先公告和少量的热烈欢迎。在这一公告之时,除了 dotCloud 外,仅有大约 40 人有机会体验 Docker。

在此公告发布几周后,出乎意料地引起了大量媒体关注。源代码很快作为一个公共的、完全开源的项目,在GitHub上发布。在接下来的几个月里,越来越多的行业人士开始听说 Docker,以及它如何革新软件构建、交付和运行的方式。而在一年内,几乎没有人在行业内不知道 Docker,但许多人仍然不确定它究竟是什么,以及为什么人们对它如此兴奋。

Docker 是一个工具,承诺轻松封装创建任何应用程序的可分发工件的过程,在任何环境中进行规模部署,并优化敏捷软件组织的工作流程和响应能力。

Docker 的承诺

最初,许多对 Docker 不熟悉的人将其视为某种虚拟化平台,但实际上,它是第一个广泛可接触的基于一种称为容器化的新技术构建的工具。Docker 和 Linux 容器对包括 Vagrant、KVM、OpenStack、Mesos、Capistrano、Ansible、Chef、Puppet 等工具和技术在内的多个行业领域产生了重大影响。关于这些产品列表的情况,说明了很多事情,也许你已经发现了。看过这份列表后,大多数工程师会认识到,这些工具涵盖了很多不同的用例,但所有这些工作流程都已经被 Docker 彻底改变。这主要是因为 Docker 显著改变了每个人对持续集成和持续交付(CI/CD)工作流程应该如何运作的期望。与每个步骤都涉及由专家管理的耗时过程不同,大多数人希望 DevOps 流水线能够完全自动化,并在一个步骤到下一个步骤之间流畅无阻地进行。该列表中的技术通常也因其提高生产力的能力而广受好评,这正是使 Docker 如此受欢迎的原因。Docker 位于过去十年中一些最具推动力的技术的核心,并且可以显著改进流水线的几乎每一个步骤。

如果你要逐个功能比较 Docker 和任何单一领域的冠军(例如配置管理),Docker 很可能看起来像是一个中等竞争者。它在某些领域比其他领域更强大,但 Docker 带来的是一个跨越广泛工作流挑战的功能集。通过结合应用程序测试和部署工具(如 Vagrant 和 Capistrano)的易用性与管理虚拟化系统的便捷性,并提供易于实现的工作流自动化和编排界面,Docker 提供了一个非常有益的功能集。

许多新技术如风而来,对最新的热潮保持一些怀疑总是很健康的。当 Docker 还是一项新技术时,很容易将其视为仅解决开发人员或运维团队的一些特定问题的又一技术。如果你把 Docker 看作是一种伪虚拟化或仅仅是部署技术,它可能并不那么引人注目。但 Docker 远比表面上看起来的要复杂得多。

即使在较小的组织中,使不同团队之间的沟通和流程正确运作通常也很困难且经常代价高昂。然而,我们生活在一个要求团队之间传递详细信息才能成功的世界。发现并实施一种减少沟通复杂性同时有助于生产更强大软件的工具是一大收获。这也正是为何 Docker 值得深入研究的原因。它并非万能药,而在组织内实施 Docker 需要进行一些关键思考,但 Docker 和 Linux 容器提供了解决一些实际组织问题的良好方法,并帮助企业更快速地发布更好的软件。提供一个精心设计的 Linux 容器工作流程可以使技术团队更加愉快,并为组织的底线节省实际开支。

那么,公司最痛苦的地方在哪里呢?以今天的标准来看,以期望的速度发布软件确实很难做到,并且随着公司从一两名开发人员增长到多个开发团队,围绕发布新版本的沟通负担变得更加沉重和难以管理。开发人员必须了解他们将要发布软件的环境的复杂性,而生产运营团队则需要越来越深入地了解他们发布的软件的内部。这些通常都是很好的技能,因为它们有助于更好地理解整体环境,从而鼓励设计更加健壮的软件,但是这些技能在组织增长加速时很难有效扩展。

每个公司环境的细节通常需要大量沟通,这些沟通并不直接为涉及的团队带来价值。例如,要求开发者向运维团队请求特定库的 1.2.1 版本会拖慢他们的速度,并且对公司没有直接的业务价值。如果开发者可以简单地升级他们使用的库版本,编写他们的代码,使用新版本进行测试并进行发布,交付时间将会显著缩短,并且部署变更的风险将会减少。如果运维工程师可以在不需要与多个应用开发团队协调的情况下升级主机系统上的软件,他们可以更快地进行操作。Docker 有助于在软件中构建一层隔离,从而减少人类沟通的负担。

除了帮助解决沟通问题外,Docker 还在软件架构上持有一种鲜明的观点,鼓励构建更加健壮的应用程序。它的架构理念集中在原子或一次性容器上。在部署过程中,旧应用程序的整个运行环境会随之被丢弃。应用程序环境中的任何内容都不会比应用程序本身生存得更久,这是一个简单而有重大影响的理念。这意味着应用程序不太可能意外依赖于先前发布留下的遗物。这意味着短暂的调试更改不太可能会在未来的发布中继续存在,因为它们是从本地文件系统中获取的。这也意味着应用程序在服务器之间的可移植性很高,因为所有状态都必须直接包含在部署工件中并且是不可变的,或者发送到像数据库、缓存或文件服务器这样的外部依赖中。

所有这些都导致了不仅更具可扩展性而且更可靠的应用程序。应用程序容器的实例可以随时生成和销毁,几乎不会影响前端站点的运行时间。这些都是已经在非 Docker 应用程序中成功的架构选择,但是 Docker 强制执行的设计选择意味着容器化应用程序必须遵循这些最佳实践。而这是一件非常好的事情。

Docker 工作流的好处

要完整地归类 Docker 带来的所有优势是很困难的。当实施良好时,它在多方面为组织、团队、开发者和运维工程师带来益处。它使得架构决策更加简单,因为从托管系统的视角看,所有应用程序在外观上本质上都是相同的。它使得编写和在应用程序之间共享工具更加容易。在这个世界上没有什么是没有好处的,但 Docker 显然更倾向于带来益处。以下是使用 Docker 和 Linux 容器时获得的一些更多好处:

将软件打包成一种利用开发者已有技能的方式

许多公司不得不为发布和构建工程师创建职位,以便管理创建其支持的平台的软件包所需的所有知识和工具。像rpmmockdpkgpbuilder这样的 Linux 工具可能很难使用,并且每个工具必须独立学习。Docker 将所有您的要求捆绑到一个打包格式中,称为Open Container Initiative (OCI)标准。

将应用软件和所需的操作系统文件系统捆绑在一个单一的标准化镜像格式中。

以前,通常需要打包不仅是您的应用程序,还包括许多依赖项,如库和守护进程。然而,您永远无法确保 100%的执行环境是相同的。对于本地编译的代码,这意味着您的构建系统需要与生产环境具有完全相同的共享库版本。所有这些使得打包难以掌握,并且对许多公司来说很难可靠地完成。通常情况下,运行Scientific Linux的人会尝试部署在Red Hat Enterprise Linux上测试过的社区包,希望该包与他们所需的接近。使用 Docker,您可以部署您的应用程序以及运行它所需的每个文件。Docker 的分层镜像使这个过程高效化,确保您的应用程序在预期的环境中运行。

使用打包的工件来测试并将完全相同的工件交付给所有环境中的所有系统。

当开发人员向版本控制系统提交更改时,可以构建新的 Docker 镜像,该镜像可以通过整个测试过程并在不需要在任何步骤中重新编译或重新打包的情况下部署到生产环境,除非特别需要。

把软件应用从硬件中抽象出来,而不牺牲资源。

传统的企业虚拟化解决方案如 VMware 通常用于在物理硬件和运行在其上的软件应用程序之间创建一个抽象层,但会以资源为代价。管理虚拟机和每个虚拟机运行的内核的超级监视器会使用硬件系统资源的一部分,这些资源因此不再可用于托管的应用程序。相反,容器只是另一个进程,通常直接与底层 Linux 内核通信,因此可以利用更多资源,直到达到系统或基于配额的限制。

当 Docker 首次发布时,Linux 容器已经存在了很多年,Docker 构建在其上的许多其他技术并不全是新的。然而,Docker 强大的架构和工作流选择的独特组合,使其整体远比其各部分之和更为强大。Docker 独自使得自 2008 年公开以来一直存在的 Linux 容器变得易于接近和对所有计算机工程师有用。Docker 相对容易地将容器融入实际公司的现有工作流程和流程中。而前面讨论过的问题被如此多的人感受到,以至于对 Docker 项目的兴趣加速发展得比任何人都能合理预期的更快。

从 2013 年开始,Docker 经历了快速迭代,现在拥有庞大的功能集,并在全球广泛部署在大量生产基础设施中。它已经成为任何现代分布式系统的基础层之一,并激发了许多其他人扩展这一方法。许多公司现在利用 Docker 和 Linux 容器作为解决其应用交付过程中面临的严重复杂性问题的解决方案。

Docker 并非什么

Docker 可以用来解决广泛的挑战,传统工具类别通常被用来解决这些挑战;然而,Docker 的广泛功能经常意味着它在特定功能上缺乏深度。例如,一些组织在迁移到 Docker 后可能会完全移除其配置管理工具,但 Docker 的真正强大之处在于,虽然它可以替代某些更传统的工具的某些方面,但通常也与它们兼容甚至在组合使用时进行增强。在下面的列表中,我们探讨了一些 Docker 不能直接替代但通常可以与之配合使用以取得良好效果的工具类别:

企业虚拟化平台(VMware、KVM 等)

在传统意义上,容器不是虚拟机。虚拟机包含完整的操作系统,运行在由底层主机操作系统管理的 hypervisor 之上。Hypervisor 创建虚拟硬件层,使得可以在单个物理计算机系统上运行多个不同操作系统。这使得在单个主机上运行许多具有根本不同操作系统的虚拟机变得非常容易。而容器中,主机和容器共享同一个内核。这意味着容器利用更少的系统资源,但必须基于相同的底层操作系统(例如 Linux)。

云平台(OpenStack、CloudStack 等)

与企业虚拟化类似,容器工作流程在表面上与更传统的云平台有许多相似之处。两者传统上被利用以允许应用程序根据需求变化进行水平扩展。然而,Docker 并非云平台。它仅处理在预先存在的 Docker 主机上部署、运行和管理容器。它不允许您创建新的主机系统(实例)、对象存储、块存储以及通常使用云平台管理的其他资源。话虽如此,随着您开始扩展 Docker 工具,您应该开始体验到传统上与云关联的更多好处。

配置管理(Puppet,Chef 等)

尽管 Docker 可以显著改善组织管理应用程序及其依赖关系的能力,但它并不直接替代更传统的配置管理。Dockerfiles用于定义在构建时容器应该如何看起来,但它们不管理容器的持续状态,也不能用于管理 Docker 主机系统。然而,Docker 可以显著减少复杂的配置管理代码需求。随着越来越多的服务器简单地成为 Docker 主机,公司使用的配置管理代码库可以变得更小,Docker 可以用于将更复杂的应用程序要求打包到标准化的 OCI 镜像中。

部署框架(Capistrano,Fabric 等)

Docker 通过创建容器镜像来简化部署的多个方面,这些镜像封装了应用程序的所有依赖关系,可以在所有环境中部署,而无需更改。然而,Docker 本身无法用于自动化复杂的部署过程。通常还需要其他工具来将更大的工作流程连接在一起。话虽如此,由于 Docker 和其他 Linux 容器工具集(如 Kubernetes(k8s))提供了一个定义良好的部署接口,部署容器所需的方法在所有主机上都是一致的,一个单一的部署工作流程应该足以应对大多数,如果不是所有基于 Docker 的应用程序。

开发环境(Vagrant 等)

Vagrant 是开发者常用的虚拟机管理工具,通常用于模拟与将要部署应用程序的生产环境紧密相似的服务器堆栈。除此之外,Vagrant 还简化了在 macOS 和基于 Windows 的工作站上运行 Linux 软件的过程。由类似 Vagrant 管理的虚拟机协助的开发者试图避免常见的“在我的机器上可以运行”的情况,即开发者的软件能正常运行,但在其他地方可能运行不正常。然而,与之前的例子一样,当你开始充分利用 Docker 时,就不再需要在开发中模仿各种生产系统,因为大多数生产系统将只是 Linux 容器服务器,可以轻松地在本地复制。

工作负载管理工具(Mesos、Kubernetes、Swarm 等)。

必须使用一个编排层(包括内置的 Swarm 模式)来协调整个 Linux 容器主机池中的工作,跟踪所有主机及其资源的当前状态,并维护正在运行的容器清单。这些系统旨在自动化保持生产集群健康所需的常规任务,同时提供帮助人们更轻松地与容器化工作负载的高度动态性互动的工具。

每个部分指出了 Docker 和 Linux 容器颠覆和改进的重要功能。Linux 容器提供了在受控和隔离环境中运行软件的方式,而 Docker 引入的易于使用的命令行界面(CLI)工具和容器镜像标准使得使用容器变得更加简单,并确保了在整个服务器集群上构建软件的可重复方式。

重要术语

下面是我们将在整本书中继续使用的几个术语及其含义,你应该熟悉这些术语:

Docker 客户端

这是用于控制大部分 Docker 工作流并与远程 Docker 服务器通信的docker命令。

Docker 服务器

这是用于启动 Docker 服务器进程并通过客户端构建和启动容器的dockerd命令。

Docker 或 OCI 镜像

Docker 和 OCI 镜像由一个或多个文件系统层和一些重要的元数据组成,这些元数据代表运行容器化应用程序所需的所有文件。单个镜像可以复制到多个主机上。一个镜像通常具有仓库地址、名称和标签。标签通常用于标识镜像的特定版本(例如 docker.io/superorbital/wordchain:v1.0.1)。Docker 镜像是与 Docker 工具集兼容的任何镜像,而 OCI 镜像特指符合 Open Container Initiative 标准且保证与任何 OCI 兼容工具一起工作的镜像。

Linux 容器

这是从 Docker 或 OCI 镜像实例化的容器。一个特定的容器只能存在一次;然而,你可以轻松地从同一个镜像创建多个容器。术语Docker 容器是一个误称,因为 Docker 只是利用操作系统的容器功能。

原子或不可变主机

原子或不可变主机是一个小巧精细的操作系统镜像,比如Fedora CoreOS,支持容器托管和原子操作系统升级。

总结

当你没有强烈的参考框架时,完全理解 Docker 可能是具有挑战性的。在下一章中,我们将概述 Docker 的大致情况:它是什么,预期如何使用,以及在考虑所有这些因素时实施时带来的优势。

第二章:Docker 的概览

在你深入配置和安装 Docker 之前,需要进行广泛的调研来解释 Docker 的含义及其带来的益处。它是一项强大的技术,但在其核心并不是非常复杂。在这一章中,我们将涵盖 Docker 和 Linux 容器的工作原理,它们的强大之处,以及你可能使用它们的一些理由。如果你正在阅读本书,你可能已经有了使用容器的理由,但在你深入之前增加对其的理解从来不会有什么坏处。

别担心 —— 这一章不会占用你太长时间。在下一章中,我们将直接开始安装并在你的系统上运行 Docker。

过程简化

因为 Docker 是一款软件,也许不明显的是,如果它被采纳和实施得当,它也可能对公司和团队的流程产生重大积极影响。所以,让我们深入了解一下 Docker 和 Linux 容器如何简化工作流程和沟通。通常这从部署的故事开始。传统上,将一个应用程序部署到生产环境的周期通常看起来像下面这样(见 图 2-1):

  1. 应用程序开发者从运维工程师那里请求资源。

  2. 资源被配置并交给开发者。

  3. 开发者通过脚本和工具进行部署。

  4. 运维工程师和开发者反复调整部署。

  5. 开发者会发现额外的应用程序依赖关系。

  6. 运维工程师致力于安装额外的要求。

  7. 重复执行步骤 4 至 6 n 次。

  8. 应用程序已被部署。

一个非 Docker 的部署工作流程

图 2-1. 传统的部署工作流程(没有 Docker)

我们的经验表明,当你遵循传统流程时,将全新的应用程序部署到生产环境可能需要一周时间,特别是对于一个复杂的新系统。这并不高效,即使 DevOps 实践试图消除许多障碍,它通常仍然需要大量的工作和团队之间的沟通。这个过程既具有技术挑战,也很昂贵,更糟糕的是,它可能会限制开发团队未来承担的创新类型。如果部署新软件很困难、耗时且依赖于其他团队的资源,那么开发者可能会选择将所有内容都集成到现有的应用程序中,以避免承受新部署的惩罚,或者更糟的是,他们可能会避免解决需要新开发工作的问题。

Heroku这样的一键部署系统向开发人员展示了如果你能控制应用程序及其大部分依赖关系时世界会是什么样子。与开发人员讨论部署问题时,经常会提到在 Heroku 或类似系统上事情有多么容易。如果你是一名运维工程师,你可能听到过有关内部系统与“一键式”解决方案如 Heroku 在速度上有多慢的抱怨,后者建立在 Linux 容器技术之上。

Heroku 不仅仅是一个容器引擎,而是一个完整的环境。虽然 Docker 并不试图包含 Heroku 中包含的一切,但它提供了责任的清晰分离和依赖关系的封装,这导致了类似的生产力提升。Docker 还允许比 Heroku 更精细的控制,通过让开发人员控制一切,甚至到确切的文件和软件包版本,这些与他们的应用程序一起运行。一些构建在 Docker 之上的工具和编排器(例如 Kubernetes、Docker Swarm 模式和 Mesos)旨在复制像 Heroku 这样的系统的简单性。尽管这些平台包裹了更多的 Docker 功能,提供了更强大和复杂的环境,但仅使用 Docker 的简单平台仍然提供了所有核心流程优势,而不增加更大系统的复杂性。

作为一家公司,Docker 采用了“电池包含但可移除”的方法。这意味着它的工具包含了大多数人完成工作所需的一切,同时仍然是由可互换部件构建而成,可以轻松地替换以支持定制解决方案。通过使用镜像仓库作为交接点,Docker 允许将构建应用程序镜像的责任与容器的部署和运行分开。实际上,开发团队可以构建带有所有依赖关系的应用程序,在开发和测试环境中运行它,然后将完全相同的应用程序和依赖项捆绑包装到生产中。由于这些捆绑看起来在外观上都一样,运维工程师可以构建或安装标准工具来部署和运行应用程序。所描述的循环在图 2-1 中看起来类似于以下内容(在图 2-2 中有示例):

  1. 开发人员构建 Docker 镜像并将其推送到注册表。

  2. 运维工程师为容器提供配置细节并配置资源。

  3. 开发人员触发部署。

一个 Docker 部署工作流程

图 2-2. 一个 Docker 部署工作流程

Docker 允许在开发和测试周期中发现所有依赖问题,因此这种方式变为可能。当应用程序准备好进行首次部署时,这项工作已经完成。通常不需要在开发和运维团队之间频繁传递。在一个完善的流水线中,这完全可以消除除开发团队以外的任何人参与新服务的创建和部署的需要。这样做起来更加简单,也节省了很多时间。更好的是,它通过在发布前测试部署环境,带来了更加健壮的软件。

广泛的支持和采用

Docker 得到了广泛支持,在大多数大型公共云中都提供了一些直接支持。例如,Docker 和 Linux 容器已经通过多个产品在亚马逊网络服务 (AWS) 中得到了使用,比如亚马逊弹性容器服务 (Amazon ECS),亚马逊弹性 Kubernetes 服务 (Amazon EKS),亚马逊 Fargate 和亚马逊弹性 Beanstalk。Linux 容器也可以在 Google 应用引擎 (GAE),Google Kubernetes 引擎,红帽 OpenShift,IBM Cloud,Microsoft Azure 等平台上使用。在 DockerCon 2014 上,Google 的 Eric Brewer 宣布 Google 将支持 Docker 作为其主要的内部容器格式。这不仅仅是对这些公司的良好公关,对 Docker 社区来说意味着大量资金开始支持 Docker 平台的稳定性和成功。

进一步扩大其影响力,Docker 的 Linux 容器镜像格式已成为云服务提供商之间的通用语言,为“一次编写,到处运行”云应用程序提供了潜力。当 Docker 发布其 libswarm 开发库时,Orchard 的一名工程师演示了将 Linux 容器同时部署到不同云服务提供商的异构环境中。在此之前,这种编排并不容易,因为每个云服务提供商都提供了不同的 API 或工具集来管理实例,而这些实例通常是可以通过 API 管理的最小单位。2014 年 Docker 提出的承诺现在已经完全成为主流,因为大公司继续在平台、支持和工具上投入资金。由于大多数提供商在容器编排以及容器运行时本身方面都提供了某种形式的 Docker 和 Linux 容器支持,因此 Docker 在常见生产环境中几乎可以支持任何类型的工作负载。如果您所有的工具都围绕 Docker 和 Linux 容器构建,那么您的应用程序可以以与云无关的方式部署,从而提供了以前无法实现的新灵活性。

2017 年,Docker 将其 containerd 运行时捐赠给了 Cloud Native Computing Foundation (CNCF),并于 2019 年提升为成熟项目状态。

今天,在开发、交付和生产中使用 Linux 容器的规模比以往任何时候都要大。2022 年,我们看到 Docker 开始在服务器市场上失去份额,新版本的 Kubernetes 不再需要 Docker 守护程序,但即使这些 Kubernetes 的发布版本也非常依赖于最初由 Docker 开发的 containerd 运行时。Docker 在许多开发者和 CI/CD 工作流中仍然具有非常强大的影响力。

那么,操作系统供应商的支持和采用情况如何呢?Docker 客户端可以直接在大多数主流操作系统上运行,服务器可以在 Linux 或 Windows Server 上运行。绝大多数生态系统都围绕 Linux 服务器构建,但其他平台的支持越来越广泛。通行的道路是并且很可能将继续围绕运行 Linux 容器的 Linux 服务器展开。

注意

在 64 位版本的 Windows Server 2016+ 上,可以原生地(无需虚拟机)运行 Windows 容器。然而,64 位版本的 Windows 10+ 专业版仍然需要 Hyper-V 来提供用于 Windows 容器的 Windows Server 内核。我们将在 “Windows 容器” 中详细讨论这个问题。

还值得注意的是,Windows 可以通过利用 WSL 2(Windows Subsystem for Linux,版本 2)在虚拟机外运行 Linux 容器。

为了支持在开发环境中对 Docker 工具的不断增长的需求,Docker 发布了适用于 macOS 和 Windows 的易于使用的实现。这些实现看起来在本地运行,但仍然利用了一个小型的 Linux 虚拟机来提供 Docker 服务器和 Linux 内核。Docker 传统上是在 Ubuntu Linux 发行版上开发的,但现在几乎所有的 Linux 发行版和其他主要操作系统都在可能的情况下提供支持。例如,Red Hat 完全投入到容器中,其所有平台都对 Docker 提供一流的支持。随着 Linux 领域容器的普及,我们现在有了像 Red Hat 的 Fedora CoreOS 这样专门用于 Linux 容器工作负载的发行版。

在 Docker 发布后的最初几年,一些竞争对手和服务提供商对 Docker 的专有镜像格式表示了担忧。Linux 上的容器并没有一个标准的镜像格式,因此 Docker 公司根据其业务需求创建了自己的格式。

服务提供商和商业供应商特别不愿意建立可能受制于具有重叠利益的公司的平台。正因如此,Docker 作为一家公司在那段时间面临了一些公开挑战。为了获得一些善意并支持市场上更广泛的采用,Docker 公司决定在 2015 年 6 月帮助赞助Open Container Initiative (OCI)。从那次努力中发布的第一个完整规范是在 2017 年 7 月发布的,它在很大程度上基于 Docker 镜像格式的第 2 版。现在可以申请 OCI 认证,既适用于容器镜像又适用于容器运行时。

这是主要的高级 OCI 认证运行时:

  • containerd是现代版本的 Docker 和 Kubernetes 中的默认高级运行时。

这些低级 OCI 认证运行时可以被containerd用来管理和创建容器:

  • runc通常作为containerd的默认低级运行时使用。

  • crun是用 C 语言编写的,旨在快速且具有较小的内存占用。

  • Kata Containers来自 Intel、Hyper 和 OpenStack Foundation,是一个虚拟化运行时,可以运行容器和虚拟机的混合体。

  • 来自 Google 的gVisor是一个完全在用户空间实现的沙盒运行时。

  • Nabla Containers提供另一种沙盒运行时,旨在显著减少 Linux 容器的攻击面。

在部署容器和编排整个容器系统的空间也在继续扩展。其中许多是开源的,并且可以在本地部署,也可以作为云端或软件即服务(SaaS)提供商的提供物,无论是在他们的云端还是你的云端。鉴于继续投入 Linux 容器空间的资金量,很可能 Docker 将继续在现代互联网中发挥重要作用。

架构

Docker 是一项强大的技术,通常意味着具有高复杂性的工具和流程。在幕后,Docker 确实很复杂;然而,它面向用户的基本结构确实是一个简单的客户端/服务器模型。Docker API 后面有几个组件,包括 containerdrunc,但基本的系统交互是客户端通过 API 与服务器通信。在这个简单的外观背后,Docker 大量利用内核机制,如 iptables、虚拟桥接、Linux 控制组 (cgroups)、Linux 命名空间、Linux 能力、安全计算模式、各种文件系统驱动程序等。我们将在第十一章讨论其中一些。现在,我们将介绍客户端和服务器的工作方式,并对 Docker 中 Linux 容器下的网络层进行简要介绍。

客户端/服务器模型

最简单的是将 Docker 视为由两部分组成:客户端和服务器/守护进程(参见图 2-3)。可选地,还有一个称为注册表的第三组件,用于存储 Docker 映像及其元数据。服务器负责构建、运行和管理您的容器的持续工作,您可以使用客户端告诉服务器要做什么。Docker 守护进程可以在基础架构中的任意数量的服务器上运行,单个客户端可以连接任意数量的服务器。客户端驱动所有通信,但在被客户端告知时,Docker 服务器可以直接与镜像注册表通信。客户端负责告诉服务器要做什么,而服务器则专注于托管和管理容器化应用程序。

Docker 客户端/服务器模型

图 2-3. Docker 客户端/服务器模型

Docker 在结构上与一些其他客户端/服务器软件有所不同。它有一个 docker 客户端和一个 dockerd 服务器,但与其完全单片化不同,服务器背后还通过 containerd-shim-runc-v2 等组件代表客户端协调几个其他组件,用于与 runccontainerd 交互。然而,Docker 通过简单的服务器 API 干净地隐藏了任何复杂性,因此在大多数情况下,您可以将其视为一个简单的客户端和服务器。每个 Docker 主机通常会运行一个 Docker 服务器,可以管理任意数量的容器。然后,您可以使用 docker 命令行工具与服务器通信,无论是从服务器本身还是(如果正确安全)从远程客户端。我们稍后会详细讨论这一点。

网络端口和 Unix 套接字

docker 命令行工具和 dockerd 守护进程可以通过 Unix 套接字和网络端口进行通信。Docker 公司已向 互联网编号分配机构 (IANA) 注册了三个端口,供 Docker 守护程序和客户端使用:TCP 端口 2375 用于未加密流量,端口 2376 用于加密 SSL 连接,端口 2377 用于 Docker Swarm 模式。在需要使用不同设置的场景中,可以轻松配置不同的端口。Docker 安装程序的默认设置是仅使用 Unix 套接字与本地 Docker 守护程序进行通信。这确保系统默认采用可能的最安全安装。虽然这也是可以轻松配置的,但强烈建议不要在 Docker 中使用网络端口,因为 Docker 守护程序内部缺乏用户认证和基于角色的访问控制。Unix 套接字在不同操作系统上的路径可能不同,但在大多数情况下,可以在此找到:/var/run/docker.sock。如果您对其他位置有强烈偏好,通常可以在安装时指定此位置,或者稍后更改服务器配置并重新启动守护进程。如果没有特别要求,那么默认设置可能适合您。与大多数软件一样,如果不需要更改,遵循默认设置将节省大量麻烦。

提示

最近版本的 Docker Desktop 可能会在用户的主目录内的 .docker/run/ 中创建 docker.sock 文件,然后简单地将 _/var/run/docker.sock 链接到此位置。

强大的工具集

导致 Docker 广泛采用的许多因素之一是其简单而强大的工具集。自首次发布以来,由于 Docker 社区的努力,其功能已不断扩展。Docker 提供的工具支持构建 Docker 镜像、基本部署到单个 Docker 守护进程、称为 Swarm 模式的分布式模式,以及管理远程 Docker 服务器所需的所有功能。除了包含的 Swarm 模式外,社区还专注于管理整个 Docker 服务器群集,并调度和编排容器部署。

注意

当本书中谈论 Docker Swarm 或 Swarm 模式 时,我们指的是 Docker 客户端和服务器内置的 Swarm 功能,它利用了另一个称为 SwarmKit 的底层库。在搜索互联网文章时,您可能会发现有关旧版独立版 Docker Swarm 的引用,该版本现在通常被称为 Docker Swarm “Classic”

Docker 还推出了自己的编排工具集,包括ComposeDocker DesktopSwarm mode,为开发人员提供了一致的部署方案。尽管 Docker 在生产编排领域的产品被 Google 的 Kubernetes 遮掩,但需要注意的是,Kubernetes 在 2022 年初发布 v1.24 之前广泛依赖于 Docker。但 Docker 的编排工具仍然很有用,特别是 Compose 在本地开发中特别方便。

因为 Docker 提供了命令行工具和远程 REST API,所以很容易在任何语言中添加更多的工具。命令行工具非常适合 shell 脚本编写,客户端可以做的任何事情也可以通过 REST API 编程方式完成。Docker CLI 如此著名,以至于许多其他 Linux 容器 CLI 工具,如 podmannerdctl,模仿其参数以实现兼容性和易用性采纳。

Docker 命令行工具

命令行工具 docker 是大多数人与 Docker 交互的主要界面。Docker 客户端是一个Go 程序,可以在所有常见的架构和操作系统上编译和运行。这个命令行工具作为主要 Docker 发行版的一部分在各种平台上都可以使用,并且可以直接从 Go 源代码编译。你通常可以使用 Docker 命令行工具做以下一些事情,但不限于:

  • 构建容器镜像

  • 从注册表拉取镜像到 Docker 守护程序或将其推送到注册表

  • 在 Docker 服务器上启动容器,可以是前台或后台

  • 从远程服务器检索 Docker 日志

  • 在远程服务器上交互式地运行容器内正在运行的命令

  • 监控关于你的容器的统计数据

  • 获取容器中的进程列表

你可能已经看到这些如何组合成一个用于构建、部署和观察应用程序的工作流程。但 Docker 命令行工具并不是与 Docker 交互的唯一方式,也不一定是最强大的方式。

Docker 引擎 API

像许多现代软件的其他部分一样,Docker 守护程序具有 API。这实际上是 Docker 命令行工具用来与守护程序通信的方式。但由于 API 是公开文档的,外部工具直接使用 API 是相当常见的。这提供了一个方便的机制,允许任何工具创建、检查和管理 Docker 守护程序管理下的所有镜像和容器。虽然初学者可能不会最初想要直接与 Docker API 交互,但它是一个非常有用的工具。随着您的组织随着时间的推移越来越多地采用 Docker,您会发现 API 是这些工具的一个很好的集成点。

API的详细文档位于 Docker 网站上。随着生态系统的成熟,针对所有流行语言已经出现了稳健的 Docker API 库的实现。Docker 维护着 Python 和 Go 的SDKs,还有由第三方维护的其他值得考虑的库。例如,多年来我们使用了这些GoRuby库,并发现它们既稳健又在新版本的 Docker 发布时迅速更新。

大多数可以通过 Docker 命令行工具完成的事情都可以相对轻松地通过 API 支持。有两个显著的例外是需要流式处理或终端访问的端点:运行远程 shell 或以交互模式执行容器。在这些情况下,通常更容易使用这些可靠的客户端库或命令行工具。

容器网络

尽管 Linux 容器主要由在主机系统上运行的进程组成,但它们在网络层通常表现得与其他进程非常不同。Docker 最初支持了单一的网络模型,但现在支持了处理大多数应用需求的强大的配置组合。大多数人在默认配置下运行其容器,称为桥接模式。让我们看看它是如何工作的。

理解桥接模式最简单的方法是将您的每个 Linux 容器视为私有网络上的主机。Docker 服务器充当虚拟桥接,而容器则是其后的客户端。桥接只是一个网络设备,将一侧的流量转发到另一侧。因此,您可以将其视为一个小型虚拟网络,其中每个容器像连接到该网络的主机一样运行。实际实现(见 图 2-4)是每个容器都有一个连接到 Docker 桥接的虚拟以太网接口,并分配给虚拟接口的 IP 地址。Docker 允许您绑定和显露主机上的单个或一组端口给容器,以便外部世界可以通过这些端口访问您的容器。流量主要由 vpnkit 库管理。

Docker 从未使用的 RFC 1918 私有子网块中分配私有子网。它检测主机上未使用的网络块,并为虚拟网络分配其中之一。这通过服务器上的 docker0 接口桥接到主机的本地网络。这意味着,默认情况下,所有容器都在同一个网络上,并可以直接相互通信。但要访问主机或外部世界,则通过 docker0 虚拟桥接接口。

典型 Docker 服务器上的网络

图 2-4. 典型 Docker 服务器上的网络

有多种方式可以配置 Docker 的网络层,从分配您自己的网络块到配置自定义桥接接口。人们通常使用默认机制运行,但在需要更复杂或特定于应用程序的情况下,还有其他选择。您可以在 文档 中找到关于 Docker 网络的更多详细信息,我们将在 第 11 章 中涵盖更多细节。

注意

在开发 Docker 工作流程时,您应该从默认的网络方法开始。您可能会发现,您不希望或不需要这个默认的虚拟网络。每个容器的网络是可配置的,您可以通过使用 docker container run--net=host 开关完全关闭容器的整个虚拟网络层。在这种模式下运行时,Linux 容器使用主机自己的网络设备和地址,没有虚拟接口或桥接被预配。请注意,主机网络具有您可能需要考虑的安全性影响。还有其他可能性的网络拓扑,可以在 第 11 章 中讨论。

充分利用 Docker

像大多数工具一样,Docker 有许多优秀的用例,也有一些不那么好的用例。例如,你可以用锤子打开玻璃罐。但这也有其缺点。理解如何最好地使用这个工具,甚至简单地确定它是否是合适的工具,可以让你更快地找到正确的路径。

首先,Docker 的架构专注于无状态或状态外部化到数据存储(如数据库或缓存)的应用程序。这些是最容易容器化的。Docker 强制执行一些对于这类应用程序有益的开发原则,稍后我们将讨论这点的强大之处。但这意味着在 Docker 中放置数据库引擎之类的操作有点像逆水行舟。并不是说你不能这样做,甚至不应该这样做;只是这不是 Docker 的最明显的用例,所以如果你从这个开始,你可能会在早期感到失望。现在在 Docker 中运行良好的数据库通常是以这种方式部署的,但这并不是简单的路径。一些适合初学者使用 Docker 的好应用包括 Web 前端、后端 API 和短期运行的任务,比如通常由 cron 处理的维护脚本。

如果你首先专注于在容器中运行无状态或外部状态应用程序的理解,那么你将有一个基础来考虑其他用例。我们强烈建议先从无状态应用程序开始,并从中积累经验,然后再考虑其他用例。社区正在不断努力以更好地支持 Docker 中的有状态应用程序,并且在这个领域可能会有很多发展。

容器不是虚拟机。

一个很好的方法来开始塑造你对如何利用 Docker 的理解是将 Linux 容器视为非虚拟机(VMs),而是非常轻量级的包装器,包裹着一个单一的 Unix 进程。在实际实施中,这个进程可能会衍生出其他进程,但另一方面,一个静态编译的二进制文件可能是容器中全部的内容(查看“外部依赖”获取更多信息)。容器也是短暂的:它们可能会更容易地出现和消失,远比传统的虚拟机快。

虚拟机从设计上是真实硬件的替代品,你可以将其放入机架并在那里使用几年。由于它们抽象了真实服务器,虚拟机通常具有长期的生命周期。即使在云中,公司经常根据需求启动和关闭虚拟机,它们通常也会有几天或更长时间的运行生命周期。另一方面,特定的容器可能会存在几个月,或者它可能被创建,运行一分钟的任务,然后被销毁。所有这些都是可以接受的,但这与虚拟机通常用于的方式根本不同。

在帮助加深这种区分的过程中,如果您在 Mac 或 Windows 系统上运行 Docker,则利用 Linux 虚拟机来运行dockerd,即 Docker 服务器。然而,在 Linux 上,dockerd可以本地运行,因此系统中无需运行虚拟机(参见 图 2-5)。

典型的 Docker 安装

图 2-5. 典型的 Docker 安装

有限的隔离

容器彼此之间是隔离的,但这种隔离可能比您预期的要有限。虽然您可以限制它们的资源,但默认的容器配置仅使它们在主机系统上共享 CPU 和内存,就像您期望的那样与 Unix 进程共同放置。这意味着除非对其进行限制,否则容器可能会在您的生产机器上竞争资源。对 CPU 和内存使用的限制是通过 Docker 鼓励的,但在大多数情况下,它们不像虚拟机那样成为默认选项。

很多情况下,许多容器共享一个或多个常见的文件系统层。这是 Docker 中更强大的设计决策之一,但这也意味着,如果您更新了共享的映像,可能还需要重新构建和部署仍在使用旧映像的容器。

容器化进程仅仅是在 Docker 服务器上的进程。它们与主机操作系统上的 Linux 内核实例上运行的进程相同。所有容器进程都会显示在 Docker 服务器的正常ps输出中。这与虚拟化管理程序完全不同,后者通常包括为每个虚拟机运行一个完全独立的操作系统内核实例。

这种轻量级的封装可能会导致诱人的选项,即从主机公开更多资源,例如共享文件系统以允许状态存储。但是,在将主机资源进一步公开到容器之前,除非它们专门由容器使用,您应该认真考虑。我们将稍后讨论容器的安全性,但通常情况下,您可能会考虑通过应用安全增强型 Linux (SELinux)AppArmor策略来进一步强制执行隔离,而不是牺牲现有的屏障。

警告

默认情况下,许多容器使用 UID 0 来启动进程。因为容器是被包含的,这看起来很安全,但实际上并不是很安全。因为一切都在同一个内核上运行,许多类型的安全漏洞或简单的配置错误都可能导致容器的root用户未经授权地访问主机的系统资源、文件和进程。请参考“安全性”以讨论如何减轻这种情况。

容器轻量化

我们稍后会更详细地讨论这个工作原理,但创建一个新容器可能占用很少的磁盘空间。快速测试表明,从现有镜像创建的新容器只需 12 千字节的磁盘空间。这非常轻量。另一方面,从黄金镜像创建的新虚拟机可能需要数百或数千兆字节,因为至少需要完整的操作系统安装在该磁盘上。另一方面,新容器之所以如此小,是因为它只是对分层文件系统镜像的引用以及一些关于配置的元数据。默认情况下,不会为容器分配数据的副本。容器只是现有系统上的进程,可能只需要从磁盘读取信息,因此在容器独占使用数据之前,可能不需要复制任何数据。

容器的轻量性意味着你可以在创建另一个虚拟机太重或需要真正短暂性的情况下使用它们。例如,你可能不会启动整个虚拟机来从远程位置运行curl命令访问网站,但你可能会为此目的启动一个新的容器。

走向不可变基础设施

通过在容器内部部署大部分应用程序,你可以通过向不可变基础设施转移来简化配置管理故事,即组件完全被替换而不是在原地更改。在现实中,要维护一个真正幂等的配置管理代码库是多么困难,不难理解为何不可变基础设施的概念越来越受欢迎。随着配置管理代码库的增长,它可能变得像大型、单片式的遗留应用程序一样笨重和难以维护。

使用 Docker,可以部署一个非常轻量的 Docker 服务器,几乎不需要配置管理,或者在许多情况下根本不需要。你只需通过部署和重新部署容器来简单地处理所有应用管理。当服务器需要对 Docker 守护程序或 Linux 内核进行重要更新时,你可以简单地启动一个带有变更的新服务器,在那里部署你的容器,然后停用或重新安装旧服务器。

基于容器的 Linux 发行版如红帽的 Fedora CoreOS就是围绕这一原则设计的。但与其要求你停用实例不同,Fedora CoreOS 可以完全更新自身并切换到更新的操作系统。你的配置和工作负载主要留在容器中,你几乎不需要对操作系统进行太多配置。

由于在部署和配置服务器方面有着清晰的分离,许多基于容器的生产系统正在使用诸如HashiCorp 的 Packer 这样的工具来构建云虚拟服务器镜像,然后利用 Docker 几乎或完全避免配置管理系统。

无状态应用

一个容器化效果良好的应用的典型例子是将其状态保存在数据库中的 Web 应用程序。无状态应用通常设计为立即响应单个自包含请求,并且不需要在一个或多个客户端的请求之间跟踪信息。你也许会在容器中运行类似临时的Memcached 实例。不过,如果考虑你的 Web 应用程序,它可能有一些你依赖的本地状态,比如配置文件。这可能看起来不像是很多状态,但如果把这些配置嵌入到镜像中,意味着你限制了镜像的重用性,并且增加了在不同环境中部署的挑战,因为需要维护多个针对不同部署目标的镜像。

在许多情况下,将应用程序容器化的过程意味着将配置状态转移到可以在运行时传递给应用程序的环境变量中。而不是把配置信息固定到容器中,你可以在部署容器时应用配置。这使得你能够轻松地在生产环境或者演示环境中使用相同的容器运行应用程序。在大多数公司中,这些环境可能需要许多不同的配置设置,比如应用程序使用的各种外部服务的连接 URL。

使用容器,你可能会发现在优化下你的容器化应用程序的同时,不断减少其大小,使其仅包含运行所需的基本要素。我们发现,将需要以分布方式运行的任何东西视为容器可以引导出一些有趣的设计决策。例如,如果有一个收集数据、处理数据并返回结果的服务,你可以在许多服务器上配置容器来运行任务,然后在另一个容器上聚合响应。

外部化状态

如果 Docker 对于无状态应用程序效果最好,那么在需要时如何最好地存储状态呢?例如,配置通常通过环境变量传递。Docker 原生支持环境变量,并将它们存储在构成容器配置的元数据中。这意味着重新启动容器将确保每次都将相同的配置传递给您的应用程序。它还使得在运行时轻松观察容器的配置,这可以大大简化调试过程,尽管在环境变量中暴露秘密信息存在一些安全问题。还可以将应用程序配置存储和检索到外部数据存储中,比如ConsulPostgreSQL

数据库通常是扩展应用程序存储状态的地方,而 Docker 并不会干扰容器化应用程序进行这样的操作。然而,需要存储文件的应用程序面临一些挑战。将数据存储到容器的文件系统中性能不佳,会受到空间限制,并且在容器重新创建时不会保留状态。如果重新部署一个有状态的服务而不利用容器外部的存储,将会丢失所有状态。在将需要存储文件系统状态的应用程序放入 Docker 之前,应仔细考虑。如果您决定在这些情况下从 Linux 容器中受益,最好设计一个解决方案,其中状态可以存储在一个集中位置,无论容器运行在哪个主机上都可以访问。在某些情况下,这可能意味着使用像亚马逊简单存储服务(Amazon S3)、OpenStack Swift 或本地块存储,甚至在容器内挂载 EBS 卷或 iSCSI 磁盘。Docker 卷插件提供了一些额外的选项,并在第十一章中简要讨论了它们。

提示

尽管可以在主机的本地文件系统上外部化状态,但社区通常不建议这样做,并且应视为高级用例。强烈建议您首先使用不需要持久状态的应用程序。通常不鼓励这样做有多个原因,但几乎所有情况下的原因都是因为它引入了容器和主机之间的依赖关系,这些依赖关系会干扰 Docker 作为真正动态、横向可扩展的应用程序交付服务的使用。如果您的容器在本地主机文件系统上维护状态,则只能部署到托管该本地文件系统的系统上。可以动态挂载的远程卷是一个不错的解决方案,但也是一个高级用例。

Docker 工作流程

像许多工具一样,Docker 强烈推荐一种特定的工作流。这是一种非常有效的工作流,非常适合许多公司的组织方式,但可能与你或你的团队目前的做法有所不同。通过将我们自己组织的工作流程适应 Docker 方法,我们可以自信地说,这是对你组织中许多团队产生广泛积极影响的一种改变。如果工作流程实施得当,它可以帮助你实现减少团队间沟通开销的承诺。

修订控制

Docker 提供的第一个功能是两种形式的修订控制。其中一种用于跟踪每个 Docker 镜像所包含的文件系统层,另一种是针对这些镜像的标记系统。

文件系统层

Linux 容器由堆叠的文件系统层组成,每一层由唯一的哈希标识,构建过程中的每组新变更都叠加在先前的变更之上。这样做的好处在于,当你进行新的构建时,只需要重建跟随你部署变更的层。这样可以节省时间和带宽,因为容器以层的形式进行传输,你无需传输服务器已存储的层。如果你使用过许多传统部署工具进行部署,你会知道每次部署都可能重复向服务器传输数百兆相同的数据。这非常低效,更糟糕的是,你不能确定各个部署之间到底有什么变化。由于分层效应以及 Linux 容器包含所有应用程序依赖项的特性,使用 Docker,你可以更加自信地发布变更到生产环境。

简单来说,一个 Docker 镜像包含运行你的应用程序所需的所有内容。如果你修改了一行代码,肯定不希望浪费时间重新构建每个依赖项进入新的镜像中。通过利用构建缓存,Docker 可以确保只重新构建受代码更改影响的层。

镜像标签

Docker 提供的第二种修订控制方式使得回答一个重要问题变得简单:之前部署的应用程序版本是什么?这并不总是容易回答的问题。对于非容器化应用程序,有许多解决方案,从为每个发布创建 Git 标签,到部署日志,到带有标记的构建以供部署使用等等。例如,如果你正在使用 Capistrano 协调部署,它会通过在服务器上保留一定数量的先前版本,并使用符号链接将其中一个设为当前版本来处理这个问题。

但在任何规模化生产环境中,每个应用程序都有处理部署修订版的独特方式。其中许多应用程序做同样的事情,但有些可能不同。更糟糕的是,在异构语言环境中,应用程序之间的部署工具通常完全不同,并且很少共享。因此,“上一个版本是什么?”这个问题可能会因询问的人和所指的应用程序而有不同的答案。Docker 有一个内置机制来处理这个问题:使用镜像标签作为标准构建步骤。您可以轻松地在服务器上保留多个应用程序修订版,因此进行回滚变得微不足道。这不是火箭科学,也不是在其他部署工具中难以找到的功能,但是使用容器镜像,可以轻松地在所有应用程序中标准化,每个人都可以对应用程序的标记有相同的期望。这使得团队之间的沟通更容易,工具更简单,因为应用发布有一个真正的来源。

警告

在许多在线示例和本书中,您会看到人们为容器镜像使用latest标签。当您刚开始使用和编写示例时,这很有用,因为它始终会获取最新构建的镜像。但是由于这是一个浮动标签,在大多数生产工作流程中使用latest是一个非常糟糕的主意,因为您的依赖项可能会在您不知情的情况下更新,而且无法回滚到latest,因为旧版本不再是标记为latest的版本。它还使得很难验证是否在不同的服务器上运行相同的镜像。经验法则是:在生产环境中不要使用latest标签。甚至从上游镜像使用latest标签也不是一个好主意,原因如上。

强烈建议您使用能够唯一标识用于构建的确切源代码提交的东西来标记您的 CI/CD 构建。在git工作流程中,这可以是与提交相关的 git 哈希。一旦您准备释放一个镜像,建议使用语义化版本为您的镜像提供标签,如 1.4.3、2.0.0 等。

锁定版本需要更多的工作来保持其更新,但它也会防止在构建和部署过程中发生许多不幸和不合时宜的惊喜。

构建

在许多组织中,构建应用程序是一门黑艺术,只有少数人知道所有操作和参数来生成一个格式良好、可交付的工件。部署新应用程序的重大成本之一是确保构建正确。Docker 并不能解决所有这些问题,但它确实提供了标准化的工具配置和工具集来进行构建。这使得人们更容易学习如何构建您的应用程序,并使新构建能够快速运行起来。

Docker 命令行工具包含一个build标志,它将消耗一个Dockerfile并生成一个 Docker 镜像。Dockerfile中的每个命令都会在镜像中生成一个新的层,因此通过查看Dockerfile本身,很容易理解构建将要做什么。所有这些标准化的重要部分是,任何曾经使用过Dockerfile的工程师都可以直接参与并修改任何其他应用程序的构建。由于 Docker 镜像是一个标准化的构件,无论使用的开发语言或基础镜像是什么,或者需要多少层次,构建背后的所有工具都是相同的。Dockerfile通常被提交到版本控制系统中,这也意味着跟踪构建的变化变得简单。现代的多阶段 Docker 构建还允许您将构建环境与最终构件镜像分开定义,这就像为生产容器定义“配置能力”一样提供了巨大的便利性。

许多 Docker 构建只需一次调用docker image build命令并生成单个构件,即容器镜像。因为通常情况下构建的大部分逻辑完全包含在Dockerfile中,所以很容易为任何团队在构建系统(如Jenkins)中创建标准构建任务。作为进一步的构建流程标准化,许多公司(例如 eBay)已经将 Linux 容器标准化为从Dockerfile进行镜像构建的工具。像Travis CICodeShip这样的 SaaS 构建服务也全面支持 Docker 构建。

自动化创建支持不同底层计算架构(如 x86 和 ARM)的多个镜像也是可能的,这是通过在 Docker 中利用新的BuildKit支持实现的。

测试

虽然 Docker 本身不包括用于测试的内置框架,但构建容器的方式为使用 Linux 容器进行测试带来了一些优势。

测试生产应用程序可以采用多种形式,从单元测试到在半实时环境中进行完整集成测试。Docker 通过保证测试通过的构件就是部署到生产环境的构件来促进更好的测试。这可以得到保证,因为我们可以使用 Docker SHA 或自定义标签,以确保我们始终将应用程序的相同版本一致地部署到生产环境。

由于容器设计上包含了所有依赖关系,因此在容器上运行的测试非常可靠。如果一个单元测试框架表示在容器镜像上测试成功,你可以确信,在部署时不会出现例如基础库版本问题的情况。这对大多数其他技术来说并不容易,甚至 Java WAR 文件,例如,也不包括对应用服务器本身的测试。同样的 Java 应用在 Linux 容器中部署时通常也会包含像 Tomcat 这样的应用服务器,整个堆栈可以在进入生产之前进行烟火测试。

在 Linux 容器中运行应用程序的另一个好处是,在那些通过类似 API 远程通信的多个应用程序的地方,一个应用程序的开发人员可以轻松地针对当前标记为所需环境(如生产或分段)的另一个服务版本进行开发。每个团队的开发人员不必成为其他服务如何工作或部署的专家,只需开发自己的应用程序即可。如果将这个扩展到具有无数微服务的服务导向架构,Linux 容器对于需要处理微服务间 API 调用的开发人员或 QA 工程师来说是一个真正的生命线。

在生产环境中运行 Linux 容器的组织中,一个常见的做法是进行自动化集成测试,拉取一组经过版本化的不同服务的 Linux 容器,与当前部署的版本匹配。新服务随后可以与将要部署的完全相同版本进行集成测试。在异构语言环境中,这样做以前需要大量定制工具支持,但由于 Linux 容器提供的标准化,现在实现起来相当简单。

打包

Docker 构建生成的镜像可以看作是一个单一的构建产物,尽管在技术上它们可能由多个文件系统层组成。无论你的应用程序使用哪种语言编写,或者在哪个 Linux 发行版上运行,构建的结果都是一个分层的 Docker 镜像。所有这些都由 Docker 工具处理和构建。这种构建镜像就像 Docker 命名的船运集装箱隐喻:一个单一的、可传输的单元,通用工具可以处理,无论它包含什么内容。这是非常强大的,因为它大大促进了应用程序之间工具重用,意味着别人的现成容器工具也能与你的构建镜像一起工作。

传统上需要大量定制配置才能部署到新主机或开发系统的应用,在 Docker 上变得非常可移植。一旦构建了容器,它就可以轻松地部署到具有相同架构上运行 Docker 服务器的任何系统上。

部署

部署由不同商店中的许多种工具处理,这里不可能列出它们。其中一些工具包括 shell 脚本,CapistranoFabricAnsible 和内部定制工具。根据我们与多团队组织的经验,通常每个团队都有一两个人知道如何进行部署以使其正常工作。一旦出现问题,团队就依赖于他们来让它再次运行起来。正如您现在可能期望的那样,Docker 大部分时间都不成问题。内置工具支持简单的一行部署策略,以将构建部署到主机并启动。标准的 Docker 客户端一次仅处理到一个主机的部署,但有大量的工具可用,使得在 Docker 集群或其他兼容的 Linux 容器主机中部署变得非常简单。由于 Docker 提供的标准化,您的构建可以部署到这些系统中的任何一个,开发团队的复杂性很低。

Docker 生态系统

多年来,围绕 Docker 形成了一个广泛的社区,由开发人员和系统管理员共同推动。类似于 DevOps 运动,这促进了通过将代码应用于运维问题来改进工具。在 Docker 提供的工具中存在空白的地方,其他公司和个人也站了出来。其中许多工具也是开源的。这意味着它们是可扩展的,可以被任何其他公司修改以适应其需求。

注意

Docker 是一家商业公司,已将其大部分核心 Docker 源代码贡献给开源社区。强烈鼓励公司加入社区,并回馈开源努力。如果您正在寻找核心 Docker 工具的支持版本,可以在Docker 网站了解更多信息。

编排

Docker 核心发行版和 Linux 容器体验的第一个重要工具类别是增加功能的工具,包括编排和大规模部署工具。早期的大规模部署工具如 New Relic’s CenturionSpotify’s Helios,以及 Ansible Docker 工具集^(1) 仍然像传统部署工具一样工作,但利用容器作为分发工件。它们采用相对简单、易于实施的方法。你可以获得 Docker 的许多优点,而不增加太多复杂性,但这些工具中的许多已被更强大和更灵活的工具(比如 Kubernetes)取代。

KubernetesApache MesosMarathon 调度器 这样的全自动调度器是更强大的选择,它们几乎完全控制了一组主机的池。其他商业产品也广泛可用,如 HashiCorp’s NomadMesosphere’s DC/OS(数据中心操作系统),以及 Rancher^(2)。自由和商业选项的生态系统继续快速增长。

不可变的原子主机

另一个可以利用来增强 Docker 使用体验的想法是不可变的原子主机。传统上,服务器和虚拟机是组织精心组装、配置和维护的系统,以提供支持广泛使用模式的各种功能。更新通常必须通过非原子操作应用,主机配置可能会分歧,导致系统出现意外行为。在今天的世界中,大多数正在运行的系统是通过就地打补丁和更新来更新的。相比之下,在软件部署的世界中,大多数人部署整个应用的完整副本,而不是尝试向运行中的系统应用补丁。容器的吸引力部分在于它们帮助使应用比传统部署模型更加原子化。

如果你能将核心容器模式扩展到操作系统中,会怎样?与其依赖配置管理尝试更新、打补丁和合并 OS 组件的变更,不如简单地下载一个新的轻量级 OS 镜像并重新启动服务器。然后,如果出现问题,可以轻松地回滚到之前使用的确切镜像。

这是基于 Linux 的原子主机发行版(例如红帽的 Fedora CoreOSBottlerocket OS等)的核心理念之一。不仅应该能够轻松拆卸和重新部署应用程序,而且整个软件堆栈也应该适用相同的哲学。这种模式有助于为整个堆栈提供非常高的一致性和韧性。

不可变或原子主机的典型特征包括最小的足迹、专注于支持 Linux 容器和 Docker 的设计,以及可以通过多主机编排工具在裸金属和常见虚拟化平台上轻松控制的原子操作系统更新和回滚。

在第三章中,我们将讨论如何在开发过程中轻松使用这些不可变主机。如果您还将这些主机用作部署目标,则此过程在您的开发和生产环境之间创造了前所未有的软件堆栈对称性。

附加工具

Docker 不仅仅是一个独立的解决方案。它拥有庞大的功能集,但总会有一些情况需要比它本身提供的更多。有一个广泛的工具生态系统,可以改进或增强 Docker 的功能。一些优秀的生产工具利用 Docker API,比如Prometheus用于监控和Ansible用于简单编排。其他工具利用了 Docker 的插件架构。插件是符合规范的可执行程序,用于接收和返回数据给 Docker。

警告

许多 Docker 插件被认为是遗留的,并正在被更好的方法取代。在决定要使用的插件之前,请务必进行充分的研究,以确保它是最佳选项,并且不会不受支持或迅速被替换。

还有许多其他优秀的工具,它们要么与 API 交互,要么作为插件运行。这些工具在各种云提供商上使与 Docker 的集成更加轻松。随着社区的不断创新,生态系统也在不断增长。在这个领域,有新的解决方案和工具不断涌现。如果您发现在您的环境中遇到问题,请查看生态系统!

总结

这就是 Docker 的快速介绍。稍后我们将深入探讨 Docker 的架构,介绍更多社区工具的使用示例,并探索设计强大的容器平台背后的一些思考。但您可能已经迫不及待想要尝试它们了,所以下一章我们将安装并运行 Docker。

^(1) 完整网址:https://docs.ansible.com/ansible/latest/collections/community/docker/docsite/scenario_guide.html#ansible-collections-community-docker-docsite-scenario-guide

^(2) 其中一些商业产品提供其平台的免费版本。

第三章:安装 Docker

现在,您希望大致了解 Docker 是什么以及它不是什么,现在是动手工作的时候了。让我们安装 Docker,这样我们就可以开始使用它了。安装 Docker 所需的步骤因您用于开发的平台和用于生产中托管应用程序的 Linux 发行版而异。

在本章中,我们将讨论在大多数现代桌面操作系统上设置完全可工作的 Docker 开发环境所需的步骤。首先,我们将在您的本机开发平台上安装 Docker 客户端,然后在 Linux 上运行 Docker 服务器。最后,我们将测试安装以确保其按预期工作。

虽然 Docker 客户端可以在 Windows 和 macOS 上运行以控制 Docker 服务器,但 Linux 容器只能在 Linux 系统上构建和启动。因此,非 Linux 系统需要一个虚拟机或远程服务器来托管基于 Linux 的 Docker 服务器。本章后面将讨论 Docker 社区版、Docker 桌面版和 Vagrant,它们提供了一些解决这个问题的方法。在“Windows 容器”部分,我们也会具体讨论在 Windows 系统上原生运行 Windows 容器的可能性,但本书大部分关注点将放在 Linux 容器上。

注意

随着技术的发展,Docker 生态系统正在迅速变化,以变得更加强大并解决更广泛的问题。本书和其他地方讨论的一些功能可能会被弃用。要了解已标记为弃用并最终删除的功能,请参阅文档

提示

我们假设您在书中的大多数代码示例中使用传统的 Unix shell。您也可以使用 PowerShell,但请注意,某些命令可能需要调整以在该环境中运行。

如果您处于需要使用代理的环境,请确保为 Docker 正确配置了代理

Docker 客户端

Docker 客户端原生支持 64 位版本的 Linux、Windows 和 macOS。

大多数流行的 Linux 发行版可以追溯到 Debian 或 Red Hat。Debian 系统使用 deb 软件包格式和高级包工具 (apt)来安装大多数预打包软件。另一方面,Red Hat 系统依赖于 RPM 包管理器 (rpm) 文件和Yellowdog 更新程序修改版 (yum),或者Dandified yum (dnf)来安装类似的软件包。经常用于需要非常小的 Linux 占用空间的环境的 Alpine Linux,依赖于Alpine 软件包管理器 (apk)来管理软件包。

在 macOS 和 Microsoft Windows 上,本机 GUI 安装程序提供了安装和维护预打包软件的最简单方法。macOS 上的 HomebrewWindows 上的 Chocolatey 也是技术用户中非常流行的选择。

警告

在本节中,我们将讨论安装 Docker 的几种方法。请确保您在列表中选择最符合您需求的第一个。如果您不熟悉如何正确切换它们,安装多个可能会导致问题。

选择其中之一:Docker Desktop、Docker 社区版、OS 包管理器或 Vagrant。

您始终可以在 Docker 网站上找到最新的安装文档

Linux

强烈建议您在首选 Linux 发行版的现代版本上运行 Docker。尽管在一些旧版本上也可以运行 Docker,但稳定性可能是一个重大问题。通常需要 3.8 或更高版本的内核,并建议您使用所选发行版的最新稳定版本。以下步骤假设您正在使用 Ubuntu 或 Fedora Linux 发行版的最新稳定版本。

提示

尽管我们没有在此处详细介绍,Linux 上的 Docker Desktop 已发布,并可以在 Linux 上使用,如果您希望在本地虚拟机上运行 Docker 守护程序,而不是直接在系统上运行。

Ubuntu Linux 22.04 (64 位)

让我们看一下在 64 位版本的 Ubuntu Linux 22.04 上安装 Docker 所需的步骤。

注意

要获取最新的指令或涵盖其他版本的 Ubuntu,请参阅Docker 社区版适用于 Ubuntu

前两个命令将确保您不在运行旧版本的 Docker。由于软件包已经多次更名,因此您需要指定几种可能性:

$ sudo apt-get remove docker docker.io containerd runc
$ sudo apt-get remove docker-engine
注意

可以安全地忽略显示“无法定位软件包”或“软件包未安装”的apt-get错误。

接下来,您需要添加 Docker 社区版所需的软件依赖和apt存储库。这样可以帮助我们获取并安装 Docker 的软件包,并验证它们已签名:

$ sudo apt-get update
$ sudo apt-get install \
    ca-certificates \
    curl \
    gnupg \
    lsb-release
$ sudo mkdir -p /etc/apt/keyrings
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg |\
    sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
$ sudo chmod a+r /etc/apt/keyrings/docker.gpg
$ echo \
    "deb [arch=$(dpkg --print-architecture) \
 signed-by=/etc/apt/keyrings/docker.gpg] \
 https://download.docker.com/linux/ubuntu \
 $(lsb_release -cs) stable" |\
    sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

现在您已经设置了存储库,请运行以下命令来安装 Docker:

$ sudo apt-get update
$ sudo apt-get install \
    docker-ce \
    docker-ce-cli \
    containerd.io \
    docker-compose-plugin

假设您没有收到任何错误消息,现在 Docker 已经安装完成!

Fedora Linux 36 (64 位)

现在让我们看一下在 64 位版本的 Fedora Linux 36 上安装 Docker 所需的步骤。

注意

要获取最新的指令或涵盖其他版本的 Fedora,请参阅Docker 社区版适用于 Fedora

第一个命令将确保您不在运行旧版本的 Docker。由于在 Ubuntu 系统上,该软件包已经多次更名,因此您需要指定几种可能性:

$ sudo dnf remove -y \
    docker \
    docker-client \
    docker-client-latest \
    docker-common \
    docker-latest \
    docker-latest-logrotate \
    docker-logrotate \
    docker-selinux \
    docker-engine-selinux \
    docker-engine

接下来,您需要添加 Docker 社区版所需的软件依赖和dnf存储库:

$ sudo dnf -y install dnf-plugins-core
$ sudo dnf config-manager \
    --add-repo \
    https://download.docker.com/linux/fedora/docker-ce.repo

现在您可以安装当前版本的 Docker Community Edition:

$ sudo dnf install -y \
    docker-ce \
    docker-ce-cli \
    containerd.io \
    docker-compose-plugin

macOS, Mac OS X

要在 macOS 上安装 Docker,请使用官方的 Docker Desktop 安装程序。

图形界面安装程序

下载最新的 Docker Desktop for Mac 安装程序,然后双击下载的程序图标。按照安装程序的所有提示完成安装。Docker Desktop for macOS 依赖于xhyve项目和 Apple 的Hypervisor 框架,为 Linux 服务器组件提供本地轻量级虚拟化层,这是启动能够构建 Docker 镜像和运行容器的 Linux 虚拟机所必需的。

Homebrew 安装

您也可以使用流行的Homebrew包管理系统在 macOS 上安装 Docker CLI 工具。如果选择这种方式,建议您考虑安装 Vagrant 以创建和管理您的 Linux VM。我们将在“基于非 Linux VM 的服务器”中稍后讨论这一点。

Microsoft Windows 11

下面是在 Windows 11 上安装 Docker Desktop 的步骤。

提示

在安装 Docker Desktop 之前,强烈建议您先设置好Windows 子系统用于 Linux (WSL2),然后在 Docker Desktop 安装程序中选择任何可用选项,以启用并默认使用 WSL2。

Docker Desktop for Windows 可利用Hyper-V^(1)为 Linux 服务器组件提供本地虚拟化层,但在处理 Linux 容器时,WSL2应该能为您提供最顺畅的体验。

下载最新的 Docker Desktop for Windows 安装程序,然后双击下载的程序图标。按照安装程序的所有提示完成安装。

Chocolatey 安装

您也可以使用流行的Chocolatey包管理系统在 Windows 上安装 Docker CLI 工具。如果选择这种方式,建议您考虑安装 Vagrant 以创建和管理您的 Linux VM。我们将在“基于非 Linux VM 的服务器”中稍后讨论这一点。

注意

Docker 网站提供了其他环境的安装说明。

Docker 服务器

Docker 服务器是与客户端分离的单独二进制文件,用于管理 Docker 典型用途的大多数工作。接下来,我们将探讨管理 Docker 服务器的最常见方式。

注意

Docker Desktop 和 Docker 社区版已为您设置了服务器,因此如果您选择了这条路线,除了确保服务器(dockerd)正在运行外,您无需执行其他操作。在 Windows 和 macOS 上,这通常意味着启动 Docker 应用程序。在 Linux 上,您可能需要运行以下systemctl命令来启动服务器。

基于 systemd 的 Linux

当前的 Fedora 和 Ubuntu 版本使用systemd来管理系统上的进程。因为您已经安装了 Docker,您可以通过键入以下内容来确保服务器在每次启动系统时启动:

$ sudo systemctl enable docker

这告诉systemd启用docker服务,并在系统启动或切换到默认运行级别时启动它。要启动 Docker 服务器,请输入以下命令:

$ sudo systemctl start docker

非 Linux 虚拟机服务器

如果您在 Docker 工作流中使用 Microsoft Windows 或 macOS,则需要一个虚拟机,以便您可以设置一个用于测试的 Docker 服务器。Docker Desktop 非常方便,因为它使用这些平台上的本地虚拟化技术为您设置此虚拟机。如果您运行较旧版本的 Windows 或由于其他原因无法使用 Docker Desktop,则应该考虑使用Vagrant来帮助您创建和管理 Docker 服务器 Linux 虚拟机。

除了使用 Vagrant 之外,您还可以根据需要选择其他虚拟化工具,如Lima on macOS或任何标准虚拟化程序,来设置本地 Docker 服务器。

Vagrant

Vagrant 支持多个虚拟化平台,通常可用于模拟甚至最复杂的环境。

在 Docker 开发中利用 Vagrant 的常见用例是支持在与生产环境匹配的映像上进行测试。Vagrant 支持从广泛的发行版如Red Hat Enterprise LinuxUbuntu到精细的原子主机发行版如Fedora CoreOS

您可以通过下载一个自包含包来轻松在大多数平台上安装 Vagrant。

警告

此 Vagrant 示例并不安全,也不意味着推荐。相反,它只是演示了设置远程 Docker 服务器 VM 所需的基本要求。保护服务器至关重要。

在可能的情况下,使用 Docker Desktop 进行开发通常是更好的选择。

您需要在系统上完全安装一个如下所示的虚拟化程序(hypervisor):

  • VirtualBox

    • 免费提供

    • 支持大多数架构上的多平台

  • VMware Workstation Pro/Fusion^(2)

    • 商业软件

    • 支持大多数架构上的多平台

  • HyperV^(3)

    • 商业软件

    • 支持大多数架构的 Windows 系统

  • KVM

    • 免费提供

    • 支持大多数架构的 Linux 系统

默认情况下,Vagrant 假设你在使用 VirtualBox 虚拟化器,但你可以在使用vagrant命令时通过使用--provider标志来更改它。

在下面的示例中,你将创建一个基于 Ubuntu 的 Docker 主机运行 Docker 守护程序。然后,你将创建一个名为docker-host的主机目录,并进入该目录:

$ mkdir docker-host
$ cd docker-host

为了使用 Vagrant,你需要找到一个与你的配置工具和架构兼容的 Vagrant Box(VM 镜像)。在这个例子中,我们将使用适用于 Virtual Box 虚拟化器的 Vagrant Box。

注意

Virtual Box 仅在 Intel/AMD x86(64)系统上工作,我们使用的 Vagrant Box 专门为 AMD64 系统构建。

接下来,创建一个名为Vagrantfile的新文件,并添加以下内容:

puts (<<-EOT)
 -----------------------------------------------------------------
 [WARNING] This exposes an unencrypted Docker TCP port on the VM!!

 This is NOT secure and may expose your system to significant risk
 if left running and exposed to the broader network.
 -----------------------------------------------------------------

EOT

$script = <<-SCRIPT
echo \'{"hosts": ["tcp://0.0.0.0:2375", "unix:///var/run/docker.sock"]}\' | \
sudo tee /etc/docker/daemon.json
sudo mkdir -p /etc/systemd/system/docker.service.d
echo -e \"[Service]\nExecStart=\nExecStart=/usr/bin/dockerd\" | \
sudo tee /etc/systemd/system/docker.service.d/docker.conf
sudo systemctl daemon-reload
sudo systemctl restart docker
SCRIPT

Vagrant.configure(2) do |config|

  # Pick a compatible Vagrant Box
  config.vm.box = 'bento/ubuntu-20.04'

  # Install Docker if it is not already on the VM image
  config.vm.provision :docker

  # Configure Docker to listen on an unencrypted local port
  config.vm.provision "shell",
    inline: $script,
    run: "always"

  # Port-forward the Docker port to
  # 12375 (or another open port) on our host machine
  config.vm.network "forwarded_port",
    guest: 2375,
    host: 12375,
    protocol: "tcp",
    auto_correct: true

end

你可以通过运行以下命令来获取这个文件的完整副本:

$ git clone https://github.com/bluewhalebook/\
docker-up-and-running-3rd-edition.git --config core.autocrlf=input
$ cd docker-up-and-running-3rd-edition/chapter_03/vagrant
$ ls Vagrantfile
注意

你可能需要从git clone命令中移除“\”,并将 URL 重新组合成单行。这是因为命令在标准打印页面上过长,只要在标准 Unix shell 中没有前导或尾随空格,这应该是有效的。

确保你在带有Vagrantfile的目录中,并运行以下命令来启动 Vagrant 虚拟机。

警告

这个设置只是提供了一个简单的示例。它并不安全,不应在不确保服务器无法从广域网络访问的情况下保持运行。

Docker 提供了关于如何使用 SSH 或 TLS 客户端证书保护 Docker 端点的文档,并提供了有关Docker 守护程序攻击面的额外信息。

$ vagrant up
…
Bringing machine 'default' up with 'virtualbox' provider…
==> default: Importing base box 'bento/ubuntu-20.04'…
==> default: Matching MAC address for NAT networking…
==> default: Checking if box 'bento/ubuntu-20.04' version '…' is up to date…
==> default: A newer version of the box 'bento/ubuntu-20.04' for provider…
==> default: available! You currently have version '…'. The latest is version
==> default: '202206.03.0'. Run `vagrant box update` to update.
==> default: Setting the name of the VM: vagrant_default_1654970697417_18732
==> default: Clearing any previously set network interfaces…
…
==> default: Running provisioner: docker…
 default: Installing Docker onto machine…
==> default: Running provisioner: shell…
 default: Running: inline script
 default: {"hosts": ["tcp://0.0.0.0:2375", "unix:///var/run/docker.sock"]}
 default: [Service]
 default: ExecStart=
 default: ExecStart=/usr/bin/dockerd
提示

在 macOS 上,你可能会看到如下错误:

VBoxManage: error: Details: code NS_ERROR_FAILURE (0x80004005), component MachineWrap, interface IMachine

这是因为 macOS 的安全特性。快速搜索应该会引导你找到一个描述修复方法的在线帖子

一旦虚拟机运行起来,你应该可以通过运行以下命令连接到 Docker 服务器,并告诉 Docker 客户端应该用-H参数连接到哪里:

$ docker -H 127.0.0.1:12375 version
Client:
 Cloud integration: v1.0.24
 Version:           20.10.14
 API version:       1.41
…

Server: Docker Engine - Community
 Engine:
 Version:          20.10.17
 API version:      1.41 (minimum version 1.12)
…

输出将为你提供有关组成 Docker 客户端和服务器的各种组件版本信息。

每次运行 Docker 命令时传递 IP 地址和端口不是理想的方式,但幸运的是,通过使用 docker context 命令可以设置 Docker 以了解多个 Docker 服务器。首先,让我们检查并查看当前正在使用的上下文。请注意带有星号 (*) 的条目,该条目表示当前上下文:

$ docker context list
NAME       TYPE … DOCKER ENDPOINT             …
default *  moby … unix:///var/run/docker.sock …
…

您可以为 Vagrant VM 创建一个新的上下文,然后通过运行以下一系列命令使其激活:

$ docker context create vagrant --docker host=tcp://127.0.0.1:12375
vagrant
Successfully created context "vagrant"

$ docker context use vagrant
vagrant

如果现在重新列出所有上下文,您应该会看到类似于这样的输出:

$ docker context list
NAME       TYPE … DOCKER ENDPOINT             …
default    moby … unix:///var/run/docker.sock …
vagrant *  moby … tcp://127.0.0.1:12375       …
…

在当前设置为 vagrant 的上下文中,运行 docker version 而不使用额外的 -H 参数仍将连接到正确的 Docker 服务器,并返回与以前相同的信息。

要连接基于 Vagrant 的虚拟机上的 shell,您可以运行以下命令:

$ vagrant ssh
…
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-91-generic x86_64)
…
vagrant@vagrant:~$ exit

在有时间来确保此设置安全之前,最好先关闭虚拟机并将上下文设置回原始状态:

$ vagrant halt
…
==> default: Attempting graceful shutdown of VM…

$ docker version
Cannot connect to … daemon at tcp://127.0.0.1:12375\. Is the … daemon running?

$ docker context use default
default
提示

如果您使用的是 macOS,您可能想看看Colima,它可以非常方便地启动和管理灵活的 Docker 或 Kubernetes 虚拟机。

测试设置

一旦您设置好工作的客户端和服务器,您就可以准备测试一切是否正常工作。您应该能够在本地系统上运行以下任一命令之一,告诉 Docker 守护进程下载该发行版的最新官方容器,然后使用运行中的 Unix shell 进程启动它。

此步骤很重要,以确保所有组件都正确安装并按预期进行通信。它展示了 Docker 的一个特性:我们可以运行基于任何喜欢的 Linux 发行版的容器。在接下来的几步中,我们将运行基于 Ubuntu、Fedora 和 Alpine Linux 的 Linux 容器。您不需要运行它们所有来证明它可行;运行其中一个就足够了。

注意

如果您在 Linux 系统上使用 Docker 客户端,默认情况下可能只有root用户具有 Docker 访问权限,因此您可能需要在每个 docker 命令前加上 sudo

大多数 Docker 安装会创建一个 docker 组,用于管理谁可以访问 dockerd Unix 套接字。您可以将您的用户添加到该组,以便您不再需要使用sudo 命令

Ubuntu

让我们尝试使用最新的 Ubuntu Linux 基础镜像启动容器:

$ docker container run --rm -ti docker.io/ubuntu:latest /bin/bash

root@aa9b72ae1fea:/#
提示

使用 docker container run 的功能与使用 docker run 是相同的。

Fedora

在此示例中,我们使用最新的 Fedora Linux 基础镜像启动容器:

$ docker container run --rm -ti docker.io/fedora:latest /bin/bash

[root@5c97201e827b /]# exit

Alpine Linux

最后,我们可以测试使用最新的 Alpine Linux 基础镜像启动容器:

$ docker container run --rm -ti docker.io/alpine:latest /bin/sh

/ # exit
注意

docker.io/ubuntu:latestdocker.io/fedora:latestdocker.io/alpine:latest 都表示 Docker 镜像仓库,后跟镜像名称和镜像标签。

探索 Docker 服务器

虽然 Docker 服务器通常会自动安装、启用和运行,但了解到在 Linux 系统上手动运行 Docker 守护程序也是非常简单的,只需输入类似这样的命令:

$ sudo dockerd -H unix:///var/run/docker.sock \
  --config-file /etc/docker/daemon.json
注意

本节假设你正在实际运行 Docker 守护程序的 Linux 服务器或虚拟机上。如果你在 Windows 或 Mac 上使用 Docker Desktop,你将无法轻松地与 dockerd 可执行文件交互,因为它是有意隐藏的,但我们马上会向你展示一个小技巧。

此命令启动 Docker 守护程序,创建并监听一个 Unix 域套接字 (-H unix:///var/run/docker.sock),并从 /etc/docker/daemon.json 中读取其余的配置。你不太可能需要自己启动 Docker 服务器,但这就是幕后发生的事情。在非 Linux 系统上,你通常会有一个托管 Docker 服务器的基于 Linux 的虚拟机。Docker Desktop 在后台为你设置了这个虚拟机。

注意

如果你已经在运行 Docker,再次执行守护程序将失败,因为不能两次使用同一网络端口。

在大多数情况下,很容易通过 SSH 进入你的新 Docker 服务器并查看周围的情况,但 Docker Desktop 在非 Linux 系统上的无缝体验意味着很难意识到 Docker Desktop 正在利用本地虚拟机运行 Docker 守护程序。由于 Docker Desktop VM 设计得非常小且非常稳定,它不运行 SSH 守护程序,因此访问起来有些棘手。

如果你好奇或者确实有需要访问底层虚拟机,可以做到,但需要一些高级知识。我们稍后会详细讨论nsenter命令,但现在,如果你想查看虚拟机(或底层主机),可以运行以下命令:

$ docker container run --rm -it --privileged --pid=host debian \
  nsenter -t 1 -m -u -n -i sh

/ # cat /etc/os-release
PRETTY_NAME="Docker Desktop"

/ # ps | grep dockerd
 1540 root      1:05 /usr/local/bin/dockerd
 --containerd /var/run/desktop-containerd/containerd.sock
 --pidfile /run/desktop/docker.pid
 --swarm-default-advertise-addr=eth0
 --host-gateway-ip 192.168.65.2

/ # exit

此命令使用特权的 Debian 容器,其中包含 nsenter 命令,用于操作 Linux 内核命名空间,以便我们可以访问底层虚拟机或主机的文件系统。

警告

此容器具有特权,允许我们访问底层主机,但你不应该养成在添加单个能力或系统调用权限时使用特权容器的习惯。我们将在“安全性”中进一步讨论这个问题。

如果你能使用 Docker 服务器端点,此命令将让你访问底层主机。

Docker 守护程序的配置通常存储在 /etc/docker/daemon.json 中,但你可能会注意到在 Docker Desktop VM 中的某个地方像 /containers/services/docker/rootfs/etc/docker/daemon.json。Docker 对所有设置使用合理的默认值,因此此文件可能非常小或者完全不存在。如果你正在使用 Docker Desktop,可以通过单击 Docker 图标并选择 Preferences… → Docker Engine,在图 3-3 中显示的方式编辑此文件。

Docker Desktop 服务器配置

图 3-3. Docker Desktop 服务器配置

总结

现在你已经有一个运行中的 Docker 设置,可以开始查看更多超出基本安装机制的内容。在下一章中,你将探索如何构建和管理 Docker 镜像,这些镜像是你使用 Docker 启动的每个容器的基础。

提示

在本书的其余部分,当你在命令行上看到 docker,假设你需要正确的配置,无论是作为 Docker 上下文、环境变量,还是通过 -H 命令行标志告诉 docker 客户端如何连接到 dockerd 服务器进程。

^(1) 完整网址: https://learn.microsoft.com/en-us/virtualization/hyper-v-on-windows/about

^(2) 完整网址: https://www.vmware.com/products/workstation-pro.html

^(3) 完整网址: https://docs.microsoft.com/en-us/virtualization/hyper-v-on-windows/quick-start/enable-hyper-v

第四章:使用 Docker 镜像

每个 Linux 容器都基于一个镜像。镜像是被重新组合成运行容器的基本定义,就像启动虚拟磁盘时会形成一个虚拟机一样。Docker 或者 开放容器倡议(OCI) 的镜像提供了你将使用 Docker 部署和运行的所有内容的基础。要启动一个容器,你必须下载一个公共镜像或者创建自己的镜像。你可以将镜像视为一个主要表示容器文件系统的单个资产。然而,实际上,每个镜像由一个或多个链接的文件系统层组成,这些层通常直接一对一地映射到创建该镜像所使用的每个构建步骤。

因为镜像是从单独的层构建起来的,它们对 Linux 内核提出了特殊要求,这需要提供 Docker 运行存储后端所需的驱动程序。对于镜像管理,Docker 在很大程度上依赖于这个存储后端,它与底层的 Linux 文件系统通信,以构建和管理组合成一个可用镜像的多个层。支持的主要存储后端包括以下内容:

每个存储后端都为镜像管理提供了快速的写时复制(CoW)系统。我们将在 第十一章 中讨论各种后端的具体情况。目前,我们将使用默认后端并探索镜像的工作原理,因为它们几乎构成了你在 Docker 中做的所有其他工作的基础,包括以下内容:

  • 构建镜像

  • 将镜像上传(推送)到镜像注册表

  • 从镜像注册表下载(拉取)镜像

  • 从镜像创建和运行容器

Dockerfile 的解剖

要使用默认工具创建自定义 Docker 镜像,你需要熟悉 Dockerfile。这个文件描述了创建镜像所需的所有步骤,通常包含在应用程序源代码库的根目录中。

典型的 Dockerfile 可能看起来像这里显示的这个,它创建了一个基于 Node.js 的应用程序容器:

FROM node:18.13.0

ARG email="anna@example.com"
LABEL "maintainer"=$email
LABEL "rating"="Five Stars" "class"="First Class"

USER root

ENV AP /data/app
ENV SCPATH /etc/supervisor/conf.d

RUN apt-get -y update

# The daemons
RUN apt-get -y install supervisor
RUN mkdir -p /var/log/supervisor

# Supervisor Configuration
COPY ./supervisord/conf.d/* $SCPATH/

# Application Code
COPY *.js* $AP/

WORKDIR $AP

RUN npm install

CMD ["supervisord", "-n"]

解剖这个 Dockerfile 将提供对控制如何组装镜像的多个可能指令的初步了解。Dockerfile 中的每一行都会创建一个由 Docker 存储的新镜像层。这个层包含因发出该命令而产生的所有更改。这意味着当你构建新镜像时,Docker 只需构建与之前构建不同的层:你可以重用所有未更改的层。

尽管您可以从一个普通的基础 Linux 镜像构建一个 Node 实例,但您也可以在Docker Hub上探索官方的 Node 镜像。Node.js 社区维护了一系列Docker 镜像和标签,让您可以快速确定可用的版本。如果您想将镜像锁定到 Node 的特定点发布版本,您可以指向类似node:18.13.0的内容。以下基础镜像将为您提供一个运行 Node 11.11.x 的 Ubuntu Linux 镜像:

FROM docker.io/node:18.13.0

ARG参数提供了一种方式,让您设置变量及其默认值,这些值仅在镜像构建过程中可用:

ARG email="anna@example.com"

为镜像和容器应用标签允许您通过键/值对添加元数据,以便以后用于搜索和识别 Docker 镜像和容器。您可以使用docker image inspect命令查看应用于任何镜像的标签。对于维护者标签,我们正在利用在Dockerfile的前一行中定义的email构建参数的值。这意味着每当我们构建此镜像时,此标签都可以更改:

LABEL "maintainer"=$email
LABEL "rating"="Five Stars" "class"="First Class"

默认情况下,Docker 在容器内以root身份运行所有进程,但您可以使用USER指令来更改这一点:

USER root
注意

尽管容器在一定程度上提供了与底层操作系统的隔离,但它们仍在主机内核上运行。由于潜在的安全风险,生产容器几乎总是应以非特权用户的身份运行。

ARG指令不同,ENV指令允许您设置 shell 变量,这些变量可以被您运行的应用程序用于配置,同时也可在构建过程中使用。ENVARG指令可用于简化Dockerfile并帮助保持 DRY(不要重复自己):

ENV AP /data/app
ENV SCPATH /etc/supervisor/conf.d

在下面的代码中,您将使用一系列RUN指令来启动和创建所需的文件结构,并安装一些必需的软件依赖:

RUN apt-get -y update

# The daemons
RUN apt-get -y install supervisor
RUN mkdir -p /var/log/supervisor
警告

虽然我们在这里为了简单起见演示,但不建议在应用程序的Dockerfile中运行类似apt-get -y updatednf -y update这样的命令。这是因为每次运行构建时都需要爬取存储库索引,这意味着您的构建不能保证重复性,因为软件包版本可能在构建之间发生变化。相反,考虑基于已应用这些更新并且版本处于已知状态的另一个镜像构建您的应用程序镜像。这样会更快速和更可重复。

COPY 指令用于将文件从本地文件系统复制到镜像中。最常见的情况是将应用程序代码和任何所需的支持文件包括在内。由于 COPY 将文件复制到镜像中,一旦构建完成,您就不再需要访问本地文件系统来访问这些文件。您还将开始使用您在上一节中定义的构建变量,以节省一些工作并帮助您避免打字错误:

# Supervisor Configuration
COPY ./supervisord/conf.d/* $SCPATH/

# Application Code
COPY *.js* $AP/
提示

每个指令都会创建一个新的 Docker 镜像层,因此通常将一些逻辑上分组的命令组合成一行是有意义的。甚至可以在 Dockerfile 中仅使用两个命令结合 COPY 指令和 RUN 指令将复杂脚本复制到镜像中并执行该脚本。

使用 WORKDIR 指令,您可以更改镜像中的工作目录,以便为其余的构建指令和默认进程启动设置任何生成的容器:

WORKDIR $AP

RUN npm install
警告

Dockerfile 中的命令顺序对持续构建时间有很大影响。您应该尝试将每次构建都会改变的事物放在靠近底部的位置。这意味着添加您的代码和类似步骤应该推迟到最后。当您重建镜像时,每个引入变更的第一个层之后的所有层都需要重新构建。

最后,使用 CMD 指令定义启动容器内所需运行的进程的命令:

CMD ["supervisord", "-n"]
注意

虽然没有硬性规定,但通常认为在容器内只运行单个进程是最佳实践。核心思想是容器应提供单一功能,以便轻松地在体系结构中水平扩展各个功能。在示例中,您正在使用 supervisord 作为进程管理器来提高容器内的节点应用程序的可靠性,并确保其保持运行状态。这在开发过程中也很有用,因此您可以重新启动服务而不必重新启动整个容器。

您也可以通过在 docker container run 命令行参数中使用 --init 命令达到类似效果,我们在 “控制进程” 中进行了讨论。

构建镜像

要构建您的第一个镜像,请继续克隆包含名为 docker-node-hello 的示例应用程序的 Git 存储库,如下所示:^(2)

$ git clone https://github.com/spkane/docker-node-hello.git \
    --config core.autocrlf=input
Cloning into 'docker-node-hello'…
remote: Counting objects: 41, done.
remote: Total 41 (delta 0), reused 0 (delta 0), pack-reused 41
Unpacking objects: 100% (41/41), done.

$ cd docker-node-hello
注意

Git 经常安装在 Linux 和 macOS 系统上,但如果您尚未安装 Git,可以从 git-scm.com 下载一个简单的安装程序。

我们使用的 --config core.autocrlf=input 选项有助于确保行尾不会意外地从预期的 Linux 标准更改。

这将下载一个名为docker-node-hello的目录中的工作Dockerfile和相关的源代码文件。 如果您忽略 Git 仓库目录查看内容,您应该会看到以下内容:

$ tree -a -I .git
.
├── .dockerignore
├── .gitignore
├── Dockerfile
├── index.js
├── package.json
└── supervisord
 └── conf.d
 ├── node.conf
 └── supervisord.conf

让我们回顾一下仓库中最相关的文件。

Dockerfile 应该与您刚刚审查过的文件相同。

.dockerignore文件允许您定义在构建镜像时不想上传到 Docker 主机的文件和目录。 在这个例子中,.dockerignore文件包含以下内容:

.git

这条命令指示docker image build排除了包含整个源代码库的.git目录。 其余文件反映了当前检出分支上源代码的当前状态。 您不需要.git目录的内容来构建 Docker 镜像,而且由于随着时间的推移它可能会变得很大,您不希望每次构建时都浪费时间复制它。 package.json定义了 Node.js 应用程序并列出了它所依赖的任何依赖项。 index.js是应用程序的主要源代码。

supervisord目录包含用于启动和监视应用程序的supervisord的配置文件。

注意

在这个示例中使用supervisord来监视应用程序可能有些过度,但它旨在为您提供一些有关在容器中使用一些技术来更好地控制应用程序及其运行状态的洞察。

正如我们在第三章中讨论的那样,您需要确保 Docker 服务器正在运行,并且客户端已正确设置以与其进行通信,然后才能构建 Docker 镜像。 假设一切正常运行,您应该能够通过运行即将提供的命令启动一个新的构建,该命令将基于当前目录中的文件构建并标记一个镜像。

下面的输出中标识的每个步骤直接映射到Dockerfile中的一行,并且每个步骤都基于前一个步骤创建一个新的镜像层。 第一次构建将需要几分钟,因为您需要下载基础的 node 镜像。 除非发布了我们基础镜像标签的新版本,否则后续构建应该会快得多。

注意

下面的输出来自 Docker 中包含的新 BuildKit。 如果您看到明显不同的输出,则您可能仍在使用旧的镜像构建代码。

您可以通过将DOCKER_BUILDKIT环境变量设置为1来在您的环境中启用 BuildKit。

您可以在Docker 网站上找到更多详细信息。

build 命令的末尾,你会注意到一个句点。这是指构建上下文,告诉 Docker 应该上传哪些文件到服务器,以便它可以构建我们的镜像。在许多情况下,你将只看到 build 命令的末尾有一个 .,因为一个句点代表当前目录。这个构建上下文就是 .dockerignore 文件正在过滤的内容,以便我们不会上传更多不必要的文件。

提示

Docker 假设 Dockerfile 在当前目录中,但如果不在,可以直接使用 -f 参数指向它。

让我们来运行构建:

$ docker image build -t example/docker-node-hello:latest .

 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 37B
 => [internal] load .dockerignore
 => => transferring context: 34B
 => [internal] load metadata for docker.io/library/node:18.13.0
 => CACHED [1/8] FROM docker.io/library/node:18.13.0@19a9713dbaf3a3899ad…
 => [internal] load build context
 => => transferring context: 233B
 => [2/8] RUN apt-get -y update
 => [3/8] RUN apt-get -y install supervisor
 => [4/8] RUN mkdir -p /var/log/supervisor
 => [5/8] COPY ./supervisord/conf.d/* /etc/supervisor/conf.d/
 => [6/8] COPY *.js* /data/app/
 => [7/8] WORKDIR /data/app
 => [8/8] RUN npm install
 => exporting to image
 => => exporting layers
 => => writing image sha256:991844271ca5b984939ab49d81b24d4d53137f04a1bd…
 => => naming to docker.io/example/docker-node-hello:latest
提示

为了提高构建速度,当 Docker 认为安全时会使用本地缓存。这有时可能会导致意外问题,因为它并不总是注意到某些更低层发生了变化。在前面的输出中,你会注意到像 ⇒ [2/8] RUN apt-get -y update 这样的行。如果你看到 ⇒ CACHED [2/8] RUN apt-get -y update,你就知道 Docker 决定使用缓存。你可以通过在 docker image build 命令中使用 --no-cache 参数来禁用构建时的缓存。

如果你在一个同时用于其他进程的系统上构建你的 Docker 镜像,可以通过使用我们将在第五章中讨论的许多相同的 cgroup 方法来限制你的构建可用资源。你可以在官方文档中找到关于 docker image build 参数的详细文档。

提示

使用 docker image build 在功能上与使用 docker build 是相同的。

如果在正确运行构建时遇到任何问题,你可能需要跳过并阅读本章中的 “多阶段构建” 和 “故障排除破损构建” 部分。

运行你的镜像

一旦成功构建了镜像,你可以使用以下命令在你的 Docker 主机上运行它:

$ docker container run --rm -d -p 8080:8080 example/docker-node-hello:latest

这个命令告诉 Docker 从带有 example/docker-node-hello:latest 标签的镜像中在后台创建一个运行中的容器,并将容器中的端口 8080 映射到 Docker 主机的端口 8080。如果一切顺利,新的 Node.js 应用程序应该在主机上的容器中运行。你可以通过运行 docker container ls 来验证这一点。要查看运行中的应用程序,请打开一个网页浏览器并指向 Docker 主机上的端口 8080。通常可以通过检查带有星号标记的 docker context list 条目或检查 DOCKER_HOST 环境变量的值来确定 Docker 主机的 IP 地址。如果 DOCKER ENDPOINT 设置为 Unix 套接字,则 IP 地址很可能是 127.0.0.1

$ docker context list
NAME      TYPE … DOCKER ENDPOINT             …
default * moby … unix:///var/run/docker.sock …
…

获取 IP 地址,并输入类似http://127.0.0.1:8080/(或者如果它与此不同,则输入您的远程 Docker 地址)到您的网络浏览器地址栏,或者使用类似curl的命令行工具。您应该看到以下文本:

Hello World. Wish you were here.

构建参数

如果你检查我们构建的镜像,你将看到maintainer标签被设置为anna@example.com

$ docker image inspect \
  example/docker-node-hello:latest | grep maintainer
 "maintainer": "anna@example.com",

如果我们想要更改maintainer标签,我们可以简单地重新运行构建,并通过--build-arg命令行参数提供email ARG的新值,就像这样:

$ docker image build --build-arg email=me@example.com \
    -t example/docker-node-hello:latest .

…
 => => naming to docker.io/example/docker-node-hello:latest

构建完成后,我们可以通过重新检查新镜像来检查结果:

$ docker image inspect \
  example/docker-node-hello:latest | grep maintainer
 "maintainer": "me@example.com",

ARGENV指令可以帮助使Dockerfile非常灵活,同时避免许多难以保持更新的重复值。

作为配置的环境变量

如果你阅读index.js文件,你会注意到文件的一部分引用了变量$WHO,该应用程序用于确定要向谁说 Hello:

var DEFAULT_WHO = "World";
var WHO = process.env.WHO || DEFAULT_WHO;

app.get('/', function (req, res) {
  res.send('Hello ' + WHO + '. Wish you were here.\n');
});

让我们快速介绍如何在启动应用程序时通过传递环境变量来配置此应用程序。首先,你需要使用两个命令停止现有容器。第一个命令将为你提供容器 ID,你需要在第二个命令中使用它:

$ docker container ls
CONTAINER ID  IMAGE                             STATUS       …
b7145e06083f  example/centos-node-hello:latest  Up 4 minutes …
注意

你可以通过使用Go 模板格式化docker container ls的输出,以便只看到你关心的信息。在上述示例中,你可能决定运行类似docker container ls --format "table {{.ID}}\t{{.Image}}\t{{.Status}}"来限制输出到你关心的三个字段。此外,运行docker container ls --quiet且没有格式选项将仅限制输出到容器 ID。

然后,使用前面输出的容器 ID,您可以输入以下命令来停止运行的容器:

$ docker container stop b7145e06083f
b7145e06083f
提示

使用docker container ls功能上等同于使用docker container listdocker container psdocker ps

使用docker container stop也等同于使用docker stop

在前述docker container run命令中添加单个--env参数后,你可以重新启动容器:

$ docker container run --rm -d \
    --publish mode=ingress,published=8080,target=8080 \
    --env WHO="Sean and Karl" \
    example/docker-node-hello:latest

如果重新加载你的网络浏览器,你应该看到网页上的文本现在如下所示:

Hello Sean and Karl. Wish you were here.
注意

如果你希望,你可以将前述的docker命令简化为以下命令:

$ docker container run --rm -d -p 8080:8080 \
    -e WHO="Sean and Karl" \
    example/docker-node-hello:latest

你可以通过使用docker container stop并传递正确的容器 ID 来停止此容器。

自定义基础镜像

基础镜像是其他 Docker 镜像将构建在其上的最底层镜像。通常情况下,这些基础镜像基于像 Ubuntu、Fedora 或 Alpine Linux 这样的 Linux 发行版的最小安装,但它们也可以小得多,只包含单个静态编译的二进制文件。对于大多数人来说,使用他们喜欢的发行版或工具的官方基础镜像是一个很好的选择。

但是,有时候构建自己的基础镜像而不是使用他人创建的镜像更可取。其中一个原因是为了在硬件、虚拟机和容器的所有部署方法中保持一致的操作系统镜像。另一个原因是大幅减小镜像大小。例如,如果你的应用程序是一个静态构建的 C 或 Go 应用程序,那就没有必要传输整个 Ubuntu 发行版了。你可能发现你只需要用于调试的工具和其他一些 Shell 命令和二进制文件。努力构建这样的镜像可能会带来更好的部署时间和更容易的应用程序分发。

在这两种方法之间的常见折衷方案是使用 Alpine Linux 构建镜像,该系统设计非常小巧,并且作为 Docker 镜像的基础非常受欢迎。为了保持发行版的小巧,Alpine Linux 基于现代轻量级的musl 标准库,而不是传统的GNU C 库(glibc)。总体而言,这并不是一个大问题,因为许多软件包支持musl,但这也需要注意。对基于 Java 的应用程序和 DNS 解析影响最大。然而,由于其小巧的镜像大小,Alpine Linux 在生产中被广泛使用。Alpine Linux 经过高度优化,其原因在于它默认只包含/bin/sh而不是/bin/bash。但是,如果需要的话,你也可以在 Alpine Linux 中安装glibc 和 bash,这在 JVM 容器的情况下经常会这样做。

在官方的 Docker 文档中,有一些关于如何在各种Linux 发行版上构建基础镜像的良好信息。

存储图像

现在你已经创建了一个令你满意的 Docker 镜像,你会希望把它存储在某个地方,以便任何你想要部署它的 Docker 主机都能轻松访问它。这也是构建镜像和将其存储在某处以便未来部署的正常交接点。通常情况下,你不会在生产服务器上构建镜像然后运行它们。这个过程在我们讨论应用程序部署团队之间的交接时有描述过。通常情况下,部署是从存储库中拉取镜像并在一个或多个 Linux 服务器上运行它的过程。有几种方法可以将您的镜像存储到一个中央存储库中以便轻松检索。

公共注册表

Docker 为公共镜像提供了一个 镜像注册表,社区希望共享的。这些包括用于 Linux 发行版的官方镜像、即用型的 WordPress 容器等。

如果您有可以在互联网上发布的镜像,最佳位置是公共注册表,比如 Docker Hub。但是也有其他选择。在核心 Docker 工具首次获得流行之时,Docker Hub 并不存在。为填补社区中的这个明显空白,创建了 Quay.io。此后,Quay.io 经历了几次收购,现在由 Red Hat 拥有。谷歌等云供应商和 GitHub 等 SaaS 公司也有自己的注册表服务。这里我们将仅讨论其中的两个。

Docker Hub 和 Quay.io 都提供了集中式的 Docker 镜像注册表,可以从互联网的任何地方访问,并提供了存储私有镜像的方法,除了公共镜像。两者都有友好的用户界面,并具有分离团队访问权限和管理用户的能力。它们也都为私有 SaaS 托管提供了合理的商业选项,类似于 GitHub 在其系统上销售私有注册表。如果您对 Docker 感兴趣,但还没有发布足够的代码以需要内部托管解决方案,这可能是正确的第一步。

对于大量使用 Docker 的公司来说,这些注册表的最大缺点之一是它们不在部署应用程序的网络内部。这意味着每个部署的每个层可能需要通过互联网拉取。互联网的延迟对软件部署有着非常实际的影响,影响这些注册表的故障可能会对公司的顺利部署能力产生非常不利的影响。通过良好的图像设计可以减轻这种影响,其中您制作的每一层都很薄,便于在互联网上移动。

私有注册表

许多公司考虑的另一种选择是内部托管某种类型的 Docker 镜像注册表,它可以与 Docker 客户端交互,支持推送、拉取和搜索镜像。开源项目 Distribution 提供了大多数其他注册表构建在其上的基本功能。

在私有注册表领域的其他强大竞争者包括 HarborRed Hat Quay。除了基本的 Docker 注册表功能外,这些产品还具有坚实的 GUI 界面和许多附加功能,如图像验证。

认证到注册表

与存储容器镜像的注册表进行通信是使用 Docker 的日常生活的一部分。对于许多注册表来说,这意味着您需要进行身份验证才能访问镜像。但 Docker 还尝试使自动化变得更容易,以便在您请求像下载私有镜像这样的事物时,它可以存储您的登录信息并代表您使用。默认情况下,Docker 假设注册表将是由 Docker,Inc.托管的公共存储库 Docker Hub。

提示

尽管有点更高级,但值得注意的是,您还可以配置 Docker 守护程序以使用自定义注册表镜像(3)或[拉取通过镜像缓存](https://oreil.ly/2Am1f).(4)

创建一个 Docker Hub 帐户

对于这些示例,您将在 Docker Hub 上创建一个帐户。您不需要帐户来下载公开共享的镜像,但为了避免速率限制并上传任何构建的容器,您需要登录。

要创建您的帐户,请使用您选择的 Web 浏览器导航到Docker Hub

从这里,您可以通过现有帐户登录或根据您的电子邮件地址创建新的登录。在创建帐户时,Docker Hub 会向您在注册时提供的地址发送验证电子邮件。您应立即登录您的电子邮件帐户,并点击邮件中的验证链接以完成验证过程。

此时,您已经创建了一个公共注册表,可以向其上传新镜像。在您的个人资料图片下的帐户设置选项中有一个默认隐私部分,允许您将您的注册表默认可见性更改为私有,如果这是您需要的。

警告

为了更好的安全性,您应该使用有限权限个人访问令牌创建并登录到 Docker Hub。

登录到注册表

现在让我们使用我们的帐户登录到 Docker Hub 注册表:

$ docker login
Login with your Docker ID to push and pull images from Docker Hub. If you
don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: <hub_username>
Password: <hub_password/token>
Login Succeeded
注意

命令docker login功能上等同于docker login docker.io

当您从服务器获得登录成功时,您就知道您可以从注册表中拉取镜像了。但是幕后发生了什么?事实证明,Docker 已经在您的主目录中为您写了一个点文件来缓存此信息。权限设置为 0600,以防其他用户读取您的凭据。您可以使用类似以下的内容检查文件:

$ ls -la ${HOME}/.docker/config.json
-rw-------@ 1 …  158 Dec 24 10:37 /Users/someuser/.docker/config.json

$ cat ${HOME}/.docker/config.json

在 Linux 上,您将看到类似于这样的东西:

{
    "auths": {
    "https://index.docker.io/v1/": {
      "auth":"cmVsaEXamPL3hElRmFCOUE=",
      "email":"someuser@example.com"
    }
  }
}
注意

Docker 不断发展,并且已经添加了对许多操作系统本地秘密管理系统的支持,例如 macOS Keychain 或 Windows Credential Manager。因此,您的config.json文件可能与示例大不相同。还有一组不同平台的凭证管理器,可以在这里为您简化生活。

警告

Docker 客户端配置文件中的 auth 值仅经过 base64 编码。它 加密。这通常只在多用户 Linux 系统上是一个重要问题,因为没有一个默认的系统范围的凭据管理器可以正常工作,系统上的其他特权用户很可能可以读取你的 Docker 客户端配置文件并访问这些秘密。在 Linux 上可以配置 gpgpass 来加密这些文件。

在这里,你可以看到 \({HOME}/.docker/config.json* 文件中以 JSON 格式包含 `docker.io` 凭据的用户 `someuser@example.com`。此配置文件支持存储多个注册表的凭据。在这种情况下,你只有一个条目,用于 Docker Hub,但如果需要的话,你可以有更多条目。从现在开始,当注册表需要认证时,Docker 将查看 *\){HOME}/.docker/config.json,看看你是否为此主机名存储了凭据。如果是,它将提供这些凭据。你会注意到这里完全缺少一个值:时间戳。这些凭据将永久缓存,或者直到你告诉 Docker 将它们删除为止。

与登录类似,如果你不再希望缓存凭据,也可以注销注册表:

$ docker logout
Removing login credentials for https://index.docker.io/v1/
$ cat ${HOME}/.docker/config.json
{
  "auths": {
  }
}

在这里,你已经移除了缓存的凭据,它们不再被 Docker 存储。某些版本的 Docker 可能会在文件为空时甚至删除此文件。如果你尝试登录到除 Docker Hub 注册表之外的其他地方,你可以在命令行上提供主机名:

$ docker login someregistry.example.com

这将在你的 ${HOME}/.docker/config.json 文件中添加另一个 auth 条目。

推送镜像到仓库

推送镜像所需的第一步是确保你已登录到打算使用的 Docker 仓库。在本例中,我们将专注于 Docker Hub,因此请确保你已使用首选凭据登录到 Docker Hub:

$ docker login
Login with your Docker ID to push and pull images from Docker Hub. If you
don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: <hub_username>
Password: <hub_password/token>
Login Succeeded

Logging in with your password grants your terminal complete access to
your account.

一旦你登录,你就可以上传一个镜像。之前,你使用命令 docker image build -t example/docker-node-hello:latest . 来构建 docker-node-hello 镜像。

实际上,Docker 客户端,以及出于兼容性原因,许多其他容器工具,实际上将 example/docker-node-hello:latest 解释为 docker.io/example/docker-node-hello:latest。这里,docker.io 表示镜像注册表主机名,而 example/docker-node-hello 是注册表内包含相关镜像的仓库。

当你在本地构建镜像时,注册表和仓库名称可以是任何你想要的。然而,当你要将你的镜像上传到真实的注册表时,你需要与登录匹配。

你可以通过运行以下命令并将 ${<myuser>} 替换为你的 Docker Hub 用户名,轻松编辑已创建的镜像的标签:

$ docker image tag example/docker-node-hello:latest \
    docker.io/${<myuser>}/docker-node-hello:latest

如果你需要使用新的命名约定重建镜像或只是想试试,你可以在docker-node-hello工作目录中运行以下命令,该目录是在本章早期执行 Git 检出时生成的。

注意

对于接下来的示例,你需要在所有示例中用你在 Docker Hub 创建的用户替换${<myuser>}。如果你使用不同的注册表,你还需要用你使用的注册表的主机名替换docker.io

$ docker image build -t docker.io/${<myuser>}/docker-node-hello:latest .
…

第一次构建时会花费一些时间。如果重新构建镜像,可能会发现速度非常快。这是因为大多数,如果不是所有层已经存在于你的 Docker 服务器中,来自前一个构建。我们可以通过运行docker image ls ${<myuser>}/docker-node-hello快速验证我们的镜像确实在服务器上:

$ docker image ls ${<myuser>}/docker-node-hello
REPOSITORY                 TAG      IMAGE ID       CREATED             SIZE
myuser/docker-node-hello   latest   f683df27f02d   About an hour ago   649MB
提示

你可以使用docker image ls --format="table {{.ID}}\t{{.Repository}}"这样的格式化输出docker image ls来使输出更加简洁。

此时,你可以使用docker image push命令将镜像上传到 Docker 仓库:

$ docker image push ${<myuser>}/docker-node-hello:latest
Using default tag: latest
The push refers to repository [docker.io/myuser/docker-node-hello]
5f3ee7afc69c: Pushed
…
5bb0785f2eee: Mounted from library/node
latest: digest: sha256:f5ceb032aec36fcacab71e468eaf0ba8a832cfc8244fbc784d0…

如果此镜像已上传到公共仓库,则任何人都可以通过运行docker image pull命令轻松下载它。

提示

如果你将镜像上传到私有仓库,则用户必须使用docker login命令登录具有访问权限的凭据,然后才能将镜像拉取到他们的本地系统中。

$ docker image pull ${<myuser>}/docker-node-hello:latest
Using default tag: latest
latest: Pulling from myuser/docker-node-hello
Digest: sha256:f5ceb032aec36fcacab71e468eaf0ba8a832cfc8244fbc784d040872be041cd5
Status: Image is up to date for myuser/docker-node-hello:latest
docker.io/myuser/docker-node-hello:latest

探索 Docker Hub 中的镜像

除了简单地使用Docker Hub 网站浏览可用的镜像外,你还可以使用docker search命令找到可能有用的镜像。

运行docker search node将返回一个包含名称或描述中包含node关键词的镜像列表:

$ docker search node
NAME                     DESCRIPTION                 STARS OFFICIAL AUTOMATED
node                     Node.js is a JavaScript-ba… 12267 [OK]
mongo-express            Web-based MongoDB admin in… 1274  [OK]
nodered/node-red         Low-code programming for e… 544
nodered/node-red-docker  Deprecated - older Node-RE… 356            [OK]
circleci/node            Node.js is a JavaScript-ba… 130
kindest/node             sigs.k8s.io/kind node imag… 78
bitnami/node             Bitnami Node.js Docker Ima… 69             [OK]
cimg/node                The CircleCI Node.js Docke… 14
opendronemap/nodeodm     Automated build for NodeOD… 10             [OK]
bitnami/node-exporter    Bitnami Node Exporter Dock… 9              [OK]
appdynamics/nodejs-agent Agent for monitoring Node.… 5
wallarm/node             Wallarm: end-to-end API se… 5              [OK]
…

OFFICIAL标头告诉你该镜像是Docker Hub 上的官方精选镜像之一。通常意味着该镜像由维护该应用程序的公司或官方开发社区维护。AUTOMATED表示该镜像是通过 CI/CD 流程自动构建和上传的,通过提交到底层源代码仓库触发。官方镜像始终是自动化的。

运行私有注册表

符合开源社区精神,Docker 鼓励社区通过 Docker Hub 默认分享 Docker 镜像。然而,由于商业、法律、镜像保留或可靠性问题,有时这不是可行的选择。

在这些情况下,建立内部私有注册表是有意义的。设置基本注册表并不难,但是对于生产使用,您应该花时间熟悉开源 Docker Registry(分发)的所有可用配置选项。

对于本示例,我们将使用 SSL 和 HTTP 基本认证创建一个非常简单的安全注册表。

首先,在我们的 Docker 服务器上创建几个目录和文件。如果您使用虚拟机或云实例来运行 Docker 服务器,则需要 SSH 到该服务器执行接下来的命令。如果您使用 Docker Desktop 或 Community Edition,则应该能够在本地系统上运行这些命令。

提示

Windows 用户可能需要下载额外的工具,例如 htppaswd,或者修改非 Docker 命令以在本地系统上完成相同的任务。

首先让我们克隆一个包含设置简单认证 Docker 注册表所需基本文件的 Git 仓库:

$ git clone https://github.com/spkane/basic-registry \
  --config core.autocrlf=input
Cloning into 'basic-registry'…
remote: Counting objects: 10, done.
remote: Compressing objects: 100% (8/8), done.
remote: Total 10 (delta 0), reused 10 (delta 0), pack-reused 0
Unpacking objects: 100% (10/10), done.

一旦文件下载到本地,您可以切换目录并查看刚刚下载的文件:

$ cd basic-registry
$ ls
Dockerfile          config.yaml.sample  registry.crt.sample
README.md           htpasswd.sample     registry.key.sample

Dockerfile 简单地从 Docker Hub 的上游注册表镜像中获取,并将一些本地配置和支持文件复制到新镜像中。

用于测试时,您可以使用一些包含的示例文件,但是请不要在生产环境中使用这些文件。

如果您的 Docker 服务器通过 localhost (127.0.0.1) 可用,则可以通过简单复制每个文件来使用这些文件而无需修改,如下所示:

$ cp config.yaml.sample config.yaml
$ cp registry.key.sample registry.key
$ cp registry.crt.sample registry.crt
$ cp htpasswd.sample htpasswd

然而,如果您的 Docker 服务器位于远程 IP 地址上,则需要做一些额外的工作。

首先,将 config.yaml.sample 复制到 config.yaml

$ cp config.yaml.sample config.yaml

然后编辑 config.yaml 并将 127.0.0.1 替换为您的 Docker 服务器的 IP 地址,以便:

http:
  host: https://127.0.0.1:5000

变成类似这样:

http:
  host: https://172.17.42.10:5000
注意

使用完全合格的域名(FQDN)如 my-registry.example.com 创建注册表非常容易,但是为了本示例,使用 IP 地址更加简单,因为不需要 DNS。

接下来,您需要为注册表的 IP 地址创建 SSL 密钥对。

一种方法是使用以下 OpenSSL 命令。请注意,您需要在命令的这一部分 /CN=172.17.42.10 中设置 IP 地址,以匹配您的 Docker 服务器的 IP 地址:

$ openssl req -x509 -nodes -sha256 -newkey rsa:4096 \
  -keyout registry.key -out registry.crt \
  -days 14 -subj '{/CN=172.17.42.10}'

最后,您可以通过复制使用示例 htpasswd 文件:

$ cp htpasswd.sample htpasswd

或者您可以通过使用以下命令创建自己的用户名和密码对进行身份验证,替换 ${<username>}${<password>} 为您首选的值:

$ docker container run --rm --entrypoint htpasswd g \
  -Bbn ${<username>} ${<password>} > htpasswd

如果再次查看目录列表,现在应该是这样的:

$ ls
Dockerfile          config.yaml.sample  registry.crt        registry.key.sample
README.md           htpasswd            registry.crt.sample
config.yaml         htpasswd.sample     registry.key

如果缺少任何这些文件,请回顾前面的步骤,确保您没有遗漏任何一个文件,然后继续。

如果一切看起来正确,那么您应该准备构建和运行注册表:

$ docker image build -t my-registry .
$ docker container run --rm -d -p 5000:5000 --name registry my-registry
$ docker container logs registry
提示

如果你看到类似“docker: Error response from daemon: Conflict. The container name “/registry” is already in use.”的错误,那么你需要更改前面的容器名称或删除具有该名称的现有容器。你可以通过运行 docker container rm registry 来删除容器。

测试私有仓库

现在仓库正在运行,你可以进行测试。你需要做的第一件事就是对其进行身份验证。你需要确保 docker login 中的 IP 地址与运行仓库的 Docker 服务器的 IP 地址匹配。

注意

myuser 是默认用户名,myuser-pw! 是默认密码。如果你生成了自己的 htpasswd,那么这些将是你选择的任何内容。

$ docker login 127.0.0.1:5000
Username: <registry_username>
Password: <registry_password>
Login Succeeded
警告

这个仓库容器有一个嵌入式 SSL 密钥,并且没有使用任何外部存储,这意味着它包含一个秘密,当你删除运行中的容器时,所有你的镜像也将被删除。这是设计上的考虑。

在生产环境中,你将希望让你的容器从一个秘密管理系统中拉取秘密,并使用某种类型的冗余外部存储,比如对象存储。如果你想在容器之间保留开发仓库镜像,你可以在 docker container run 命令中添加类似 --mount type=bind,source=/tmp/registry-data,target=/var/lib/registry 的内容,将仓库数据存储在 Docker 服务器上。

现在,让我们看看你是否可以将刚刚构建的镜像推送到你的本地私有仓库。

提示

在所有这些命令中,请确保使用正确的 IP 地址来访问你的仓库。

$ docker image tag my-registry 127.0.0.1:5000/my-registry
$ docker image push 127.0.0.1:5000/my-registry
Using default tag: latest
The push refers to repository [127.0.0.1:5000/my-registry]
f09a0346302c: Pushed
…
4fc242d58285: Pushed
latest: digest: sha256:c374b0a721a12c41d5b298930d11e658fbd37f22dc2a0fac7d6a2…

然后,你可以尝试从你的仓库中拉取相同的镜像:

$ docker image pull 127.0.0.1:5000/my-registry
Using default tag: latest
latest: Pulling from my-registry
Digest: sha256:c374b0a721a12c41d5b298930d11e658fbd37f22dc2a0fac7d6a2ecdc0ba5490
Status: Image is up to date for 127.0.0.1:5000/my-registry:latest
127.0.0.1:5000/my-registry:latest
提示

值得注意的是,Docker Hub 和 Docker Distribution 都暴露了一个可供查询有用信息的 API 端点。你可以通过 官方文档 了解更多关于 API 的信息。

如果你没有遇到任何错误,那么你已经拥有一个可用于开发的仓库,并可以在此基础上构建生产仓库。此时,你可能希望暂时停止仓库。你可以通过运行以下命令轻松实现:

$ docker container stop registry
提示

随着你对 Docker Distribution 的熟悉程度增加,你可能还想考虑探索云原生计算基金会(CNCF)的开源项目 Harbor,它通过许多安全和可靠性功能扩展了 Docker Distribution。

优化镜像

当你花费一点时间使用 Docker 后,你很快会注意到,保持镜像大小小和构建时间快可以极大地减少构建和部署新软件版本所需的时间。在本节中,我们将讨论设计镜像时应始终牢记的一些考虑因素,以及一些可以帮助你实现这些目标的技术。

保持镜像小巧

在大多数现代企业中,从互联网上的远程位置下载一个 1 GB 的单个文件并不是人们经常担心的事情。在互联网上找到软件非常容易,人们通常会依赖重新下载它,而不是为未来保留本地副本。当你确实需要在单个服务器上的单个副本时,这可能是可以接受的,但当你需要在 100+ 节点上安装相同的软件并且每天部署新版本时,它很快会成为一个扩展性问题。下载这些大文件可能会迅速导致网络拥塞和更慢的部署周期,这对生产环境有真正的影响。

为了方便起见,许多 Linux 容器继承自一个包含最小 Linux 发行版的基础镜像。尽管这是一个简单的起点,但并非必需。容器只需要包含在主机内核上运行应用程序所需的文件,而不需要其他内容。最好的解释方法是探索一个非常精简的容器。

Go 是一种编译型编程语言,可以轻松生成静态编译的二进制文件。在这个例子中,我们将使用一款非常小的用 Go 编写的网络应用程序,可以在 GitHub 找到。

让我们来试试这个应用程序,看看它的作用。运行以下命令,然后打开一个 Web 浏览器,将其指向你的 Docker 主机的 8080 端口(例如,http://127.0.0.1:8080 适用于 Docker Desktop 和 Community Edition):

$ docker container run --rm -d -p 8080:8080 spkane/scratch-helloworld

如果一切顺利,你应该在 Web 浏览器中看到以下消息:“Hello World from Go in minimal Linux container.” 现在让我们来看看这个容器包含哪些文件。可以合理地假设,至少包括一个工作中的 Linux 环境和编译 Go 程序所需的所有文件,但你很快会发现情况并非如此。

在容器仍在运行时,执行以下命令来确定容器的 ID 是什么。以下命令返回你创建的最后一个容器的信息:

$ docker container ls -l
CONTAINER ID IMAGE                     COMMAND       CREATED           …
ddc3f61f311b spkane/scratch-helloworld "/helloworld" 4 minutes ago     …

然后,你可以使用之前运行命令获取的容器 ID 来将容器中的文件导出为一个 tarball,这样可以很容易地进行检查:

$ docker container export ddc3f61f311b -o web-app.tar

使用 tar 命令,你现在可以查看导出时容器的内容:

$ tar -tvf web-app.tar
-rwxr-xr-x  0 0      0           0 Jan  7 15:54 .dockerenv
drwxr-xr-x  0 0      0           0 Jan  7 15:54 dev/
-rwxr-xr-x  0 0      0           0 Jan  7 15:54 dev/console
drwxr-xr-x  0 0      0           0 Jan  7 15:54 dev/pts/
drwxr-xr-x  0 0      0           0 Jan  7 15:54 dev/shm/
drwxr-xr-x  0 0      0           0 Jan  7 15:54 etc/
-rwxr-xr-x  0 0      0           0 Jan  7 15:54 etc/hostname
-rwxr-xr-x  0 0      0           0 Jan  7 15:54 etc/hosts
lrwxrwxrwx  0 0      0           0 Jan  7 15:54 etc/mtab -> /proc/mounts
-rwxr-xr-x  0 0      0           0 Jan  7 15:54 etc/resolv.conf
-rwxr-xr-x  0 0      0     3604416 Jul  2  2014 helloworld
drwxr-xr-x  0 0      0           0 Jan  7 15:54 proc/
drwxr-xr-x  0 0      0           0 Jan  7 15:54 sys/

你可能会注意到这个容器里几乎没有文件,几乎所有文件的大小都是零字节。所有长度为零的文件都需要存在于每个 Linux 容器中,并在首次创建容器时自动从主机进行 绑定挂载 到容器中。除了 .dockerenv 之外的所有这些文件都是内核需要正常工作的关键文件。在这个容器中唯一具有实际大小并与我们的应用程序相关的文件是静态编译的 helloworld 二进制文件。

这次练习的要点是,你的容器只需要包含在底层内核上运行所需的内容。其他都是不必要的。由于在故障排除时通常需要访问容器中的工作 shell,人们经常会妥协并使用像 Alpine Linux 这样非常轻量的 Linux 发行版构建他们的镜像。

小贴士

如果你发现自己经常探索镜像文件,你可能想看看工具 dive,它提供了一个良好的 CLI 接口来理解一个镜像包含的内容。

为了更深入地探讨一下,让我们再次看看同一个容器,以便我们可以深入研究底层文件系统,并将其与流行的 alpine 基础镜像进行比较。

尽管我们可以简单地通过运行 docker container run -ti alpine:latest /bin/sh 来探索 alpine 镜像,但我们无法对 spkane/scratch-helloworld 镜像进行此操作,因为它不包含 shell 或 SSH。这意味着我们无法使用 sshnsenterdocker container exec 来检查它,尽管在 “调试无 Shell 容器” 中讨论了一个高级技巧。此前,我们利用了 docker container export 命令创建了一个 .tar 文件,其中包含容器中所有文件的副本,但这一次我们将通过直接连接到 Docker 服务器并查看容器的文件系统来检查容器的文件系统。为此,我们需要找出镜像文件在服务器磁盘上的位置。

要确定服务器上实际存储我们文件的位置,请在 alpine:latest 镜像上运行 docker image inspect

$ docker image inspect alpine:latest
[
    {
        "Id": "sha256:3fd…353",
        "RepoTags": [
            "alpine:latest"
        ],
        "RepoDigests": [
            "alpine@sha256:7b8…f8b"
        ],
…
        "GraphDriver": {
            "Data": {
                "MergedDir":
                "/var/lib/docker/overlay2/ea8…13a/merged",
                "UpperDir":
                "/var/lib/docker/overlay2/ea8…13a/diff",
                "WorkDir":
                "/var/lib/docker/overlay2/ea8…13a/work"
            },
            "Name": "overlay2"
…
        }
    }
…
]

然后在 spkane/scratch-helloworld:latest 镜像上:

$ docker image inspect spkane/scratch-helloworld:latest
[
    {
        "Id": "sha256:4fa…06d",
        "RepoTags": [
            "spkane/scratch-helloworld:latest"
        ],
        "RepoDigests": [
            "spkane/scratch-helloworld@sha256:46d…a1d"
        ],
…
        "GraphDriver": {
            "Data": {
                "LowerDir":
                "/var/lib/docker/overlay2/37a…84d/diff:
 /var/lib/docker/overlay2/28d…ef4/diff",
                "MergedDir":
                "/var/lib/docker/overlay2/fc9…c91/merged",
                "UpperDir":
                "/var/lib/docker/overlay2/fc9…c91/diff",
                "WorkDir":
                "/var/lib/docker/overlay2/fc9…c91/work"
            },
            "Name": "overlay2"
…
        }
    }
…
]
注意

在这个特定的例子中,我们将使用运行在 macOS 上的 Docker Desktop,但这种一般方法也适用于大多数 Docker 服务器。但是,你可以通过最简单的方法访问你的 Docker 服务器。

由于我们使用的是 Docker Desktop,我们需要使用我们的 nsenter 技巧进入无 SSH 的虚拟机并探索文件系统:

$ docker container run --rm -it --privileged --pid=host debian \
  nsenter -t 1 -m -u -n -i sh

/ #

在虚拟机中,我们现在应该能够探索 docker image inspect 命令的 GraphDriver 部分中列出的各种目录。

在这个例子中,如果我们查看alpine镜像的第一个条目,我们会看到它标记为MergedDir并列出了文件夹/var/lib/docker/overlay2/ea86408b2b15d33ee27d78ff44f82104705286221f055ba1331b58673f4b313a/merged。如果我们列出那个目录,会得到一个错误,但从列出父目录开始,我们很快发现我们实际上想要查看diff目录:

/ # ls -lFa /var/lib/docker/overlay2/ea…3a/merged

ls: /var/lib/docker/overlay2/ea..3a/merged: No such file or directory

/ # ls -lF /var/lib/docker/overlay2/ea…3a/

total 8
drwxr-xr-x   18 root     root          4096 Mar 15 19:27 diff/
-rw-r--r--    1 root     root            26 Mar 15 19:27 link

/ # ls -lF /var/lib/docker/overlay2/ea…3a/diff

total 64
drwxr-xr-x    2 root     root          4096 Jan  9 19:37 bin/
drwxr-xr-x    2 root     root          4096 Jan  9 19:37 dev/
drwxr-xr-x   15 root     root          4096 Jan  9 19:37 etc/
drwxr-xr-x    2 root     root          4096 Jan  9 19:37 home/
drwxr-xr-x    5 root     root          4096 Jan  9 19:37 lib/
drwxr-xr-x    5 root     root          4096 Jan  9 19:37 media/
drwxr-xr-x    2 root     root          4096 Jan  9 19:37 mnt/
dr-xr-xr-x    2 root     root          4096 Jan  9 19:37 proc/
drwx------    2 root     root          4096 Jan  9 19:37 root/
drwxr-xr-x    2 root     root          4096 Jan  9 19:37 run/
drwxr-xr-x    2 root     root          4096 Jan  9 19:37 sbin/
drwxr-xr-x    2 root     root          4096 Jan  9 19:37 srv/
drwxr-xr-x    2 root     root          4096 Jan  9 19:37 sys/
drwxrwxrwt    2 root     root          4096 Jan  9 19:37 tmp/
drwxr-xr-x    7 root     root          4096 Jan  9 19:37 usr/
drwxr-xr-x   11 root     root          4096 Jan  9 19:37 var/

/ # du -sh  /var/lib/docker/overlay2/ea…3a/diff
4.5M    /var/lib/docker/overlay2/ea…3a/diff

现在,alpine碰巧是一个非常小的基础镜像,仅有 4.5 MB,非常适合在其上构建容器。但是,在我们开始构建任何内容之前,我们可以看到这个容器中仍然有很多内容。

现在,让我们来看看spkane/scratch-helloworld镜像中的文件。在这种情况下,我们要查看docker image inspect输出中LowerDir条目的第一个目录,你还会注意到这个目录也以名为diff的目录结尾:

/ # ls -lFh /var/lib/docker/overlay2/37…4d/diff

total 3520
-rwxr-xr-x    1 root     root        3.4M Jul  2  2014 helloworld*

/ # exit

你会注意到该目录中只有一个文件,大小为 3.4 MB。这个helloworld二进制文件是此容器中唯一的文件,并且比alpine镜像的起始大小小,这还没有添加任何应用程序文件。

注意

你可以在 Docker 服务器上的该目录中运行helloworld应用程序,因为它不需要任何其他文件。但除了开发环境外,你真的不想这样做,但它可以帮助强调这类静态编译应用程序的实用性。

多阶段构建

在许多情况下,有一种方法可以将容器限制在更小的大小范围内:多阶段构建。这是我们建议您构建大多数生产容器的方式。您无需过多担心引入额外的资源来构建应用程序,并且仍然可以运行一个精简的生产容器。多阶段容器还鼓励在 Docker 内部进行构建,这是构建系统中重复性的一个很好的模式。

正如scratch-helloworld 应用程序的原始作者所写,Docker 本身对多阶段构建支持的发布使得创建小容器的过程比过去更容易。过去,要做多阶段提供几乎免费的同样事情,你需要构建一个编译代码的镜像,提取生成的二进制文件,然后构建第二个镜像,而不包含所有构建依赖项,然后将该二进制文件注入其中。这通常很难设置,并且在标准部署流水线中不总是能正常工作。

如今,你可以使用一个如下简单的Dockerfile来实现类似的结果:

# Build container
FROM docker.io/golang:alpine as builder
RUN apk update && \
    apk add git && \
    CGO_ENABLED=0 go install -a -ldflags '-s' \
    github.com/spkane/scratch-helloworld@latest

# Production container
FROM scratch
COPY --from=builder /go/bin/scratch-helloworld /helloworld
EXPOSE 8080
CMD ["/helloworld"]

第一件你会注意到的关于这个Dockerfile的事情是它看起来很像两个Dockerfile合并在一起。实际上确实如此,但还有更多。FROM命令已经扩展,以便你可以在构建阶段命名镜像。在这个例子中,第一行是FROM docker.io/golang as builder,意味着你想要基于golang镜像进行构建,并在构建图像/阶段中引用它为builder

在第四行,你会看到另一个FROM行,这在多阶段构建引入之前是不允许的。这个FROM行使用一个特殊的镜像名称scratch,告诉 Docker 从一个空镜像开始,其中不包含任何附加文件。接下来的一行,即COPY --from=builder /go/bin/scratch-helloworld /helloworld,允许你直接将在builder镜像中构建的二进制文件复制到当前镜像中。这将确保你最终得到尽可能小的容器。

EXPOSE 8080行是文档,旨在通知用户服务监听的端口(s)和协议(TCP 是默认协议)。

让我们尝试构建并查看结果。首先,创建一个工作目录,然后使用你喜欢的文本编辑器,将前面示例中的内容粘贴到名为Dockerfile的文件中:

$ mkdir /tmp/multi-build
$ cd /tmp/multi-build
$ vi Dockerfile
提示

你可以从GitHub下载此Dockerfile的副本。^(5)

现在我们可以开始多阶段构建:

$ docker image build .
[+] Building 9.7s (7/7) FINISHED
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 37B
 => [internal] load .dockerignore
 => => transferring context: 2B
 => [internal] load metadata for docker.io/library/golang:alpine
 => CACHED [builder 1/2] FROM docker.io/library/golang:alpine@sha256:7cc6257…
 => [builder 2/2] RUN apk update && apk add git && CGO_ENABLED=0 go install …
 => [stage-1 1/1] COPY --from=builder /go/bin/scratch-helloworld /helloworld
 => exporting to image
 => => exporting layers
 => => writing image sha256:bb853f23418161927498b9631f54692cf11d84d6bde3af2d…

你会注意到输出看起来像大多数其他构建,并最终报告成功创建了我们的最终极简镜像。

警告

如果你在本地系统上编译使用共享库的二进制文件,你需要小心确保这些共享库的正确版本也对容器内的进程可用。

你不限于两个阶段,实际上,这些阶段不需要彼此相关。它们将按顺序运行。例如,你可以基于公共 Go 镜像创建一个阶段,构建你的基础 Go 应用程序以提供 API,还可以基于 Angular 容器创建另一个阶段,构建你的前端 Web UI。最终阶段可以组合来自两者的输出。

提示

当你开始构建更复杂的镜像时,你可能会发现仅限于单个构建上下文是有挑战性的。我们在本章末讨论的docker-buildx插件能够支持多个构建上下文,可以支持一些非常先进的工作流程。

层次是累加的

直到深入挖掘镜像构建方式的更多细节之前,可能不明显的一点是构成您镜像的文件系统层是严格累加的设计。虽然您可以在先前的层中遮蔽/掩盖文件,但不能删除这些文件。实际上,这意味着您不能通过简单地删除在较早步骤中生成的文件来减小镜像的大小。

注意

如果在您的 Docker 服务器上启用了实验性功能,可以使用docker image build --squash将一堆层压缩成单个层。这将导致所有在中间层中删除的文件实际上从最终镜像中消失,并且因此可以恢复大量浪费的空间,但这也意味着每个需要该层的系统都必须下载整个层,即使只更新了一行源代码,因此在使用这种方法时存在实际的权衡。

解释镜像层次累加性质最简单的方法是使用一些实际的例子。在一个新目录中,下载或创建以下文件,这将生成一个在 Fedora Linux 上运行 Apache Web 服务器的镜像:

FROM docker.io/fedora
RUN dnf install -y httpd
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

然后像这样构建它:

$ docker image build .
[+] Building 63.5s (6/6) FINISHED
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 130B
 => [internal] load .dockerignore
 => => transferring context: 2B
 => [internal] load metadata for docker.io/library/fedora:latest
 => [1/2] FROM docker.io/library/fedora
 => [2/2] RUN dnf install -y httpd
 => exporting to image
 => => exporting layers
 => => writing image sha256:543d61c956778b8ea3b32f1e09a9354a864467772e6…

让我们继续标记生成的镜像,这样您就可以在后续命令中轻松引用它:

$ docker image tag sha256:543d61c956778b8ea3b32f1e09a9354a864467772e6… size1

现在让我们用docker image history命令来查看我们的镜像。这个命令将为我们提供有关文件系统层和构建步骤的一些见解:

$ docker image history size1
IMAGE        CREATED            CREATED BY                            SIZE  …
543d61c95677 About a minute ago CMD ["/usr/sbin/httpd" "-DFOREGROU…"] 0B
<missing>    About a minute ago RUN /bin/sh -c dnf install -y httpd … 273MB
<missing>    6 weeks ago        /bin/sh -c #(nop)  CMD ["/bin/bash"]… 0B
<missing>    6 weeks ago        /bin/sh -c #(nop) ADD file:58865512c… 163MB
<missing>    3 months ago       /bin/sh -c #(nop)  ENV DISTTAG=f36co… 0B
<missing>    15 months ago      /bin/sh -c #(nop)  LABEL maintainer=… 0B

您会注意到三个层未增加我们最终镜像的大小,但两个层大大增加了大小。163 MB 的层次是有道理的,因为这是包含最小 Linux 发行版的基本 Fedora 镜像;然而,273 MB 的层次令人惊讶。Apache Web 服务器不应该那么大,到底发生了什么?

如果您有使用apkaptdnfyum等包管理器的经验,那么您可能知道大多数工具都严重依赖于一个包含平台上所有可安装软件包详细信息的大缓存。这个缓存占用了大量空间,并且在安装完您需要的软件包后就完全没有用了。最明显的下一步是简单地删除缓存。在 Fedora 系统上,您可以通过编辑您的Dockerfile来实现这一点,使其看起来像这样:

FROM docker.io/fedora
RUN dnf install -y httpd
RUN dnf clean all
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

然后构建、标记和检查生成的镜像:

$ docker image build .
[+] Building 0.5s (7/7) FINISHED
…
 => => writing image sha256:b6bf99c6e7a69a1229ef63fc086836ada20265a793cb8f2d…

$ docker image tag sha256:b6bf99c6e7a69a1229ef63fc086836ada20265a793cb8f2d17…
IMAGE        CREATED            CREATED BY                            SIZE  …
b6bf99c6e7a6 About a minute ago CMD ["/usr/sbin/httpd" "-DFOREGROU…"] 0B
<missing>    About a minute ago RUN /bin/sh -c dnf clean all # build… 71.8kB
<missing>    10 minutes ago     RUN /bin/sh -c dnf install -y httpd … 273MB
<missing>    6 weeks ago        /bin/sh -c #(nop)  CMD ["/bin/bash"]… 0B
<missing>    6 weeks ago        /bin/sh -c #(nop) ADD file:58865512c… 163MB
<missing>    3 months ago       /bin/sh -c #(nop)  ENV DISTTAG=f36co… 0B
<missing>    15 months ago      /bin/sh -c #(nop)  LABEL maintainer=… 0B

如果仔细观察docker image history命令的输出,您会注意到已创建了一个新层,将71.8kB添加到镜像中,但并未减少问题层的大小。到底发生了什么?

重要的是要理解图像层的严格增量性质。一旦创建了一个层,就无法从中删除任何内容。这意味着您不能通过删除后续层中的文件来缩小图像中的较早层。当您删除或编辑后续层中的文件时,您只是用新层中的修改或移除版本掩盖了旧版本。这意味着您可以通过在保存层之前删除文件来使层变小的唯一方法。

处理这个问题的最常见方法是将命令串联在一个单独的Dockerfile行上。您可以通过利用&&运算符来轻松做到这一点。此运算符充当布尔AND语句,并且基本上可以翻译为“如果前一个命令成功运行,则运行此命令”。除此之外,您还可以利用\运算符,该运算符用于指示命令在换行后继续。这可以提高长命令的可读性。

拥有这些知识后,你可以像这样重写Dockerfile

FROM docker.io/fedora
RUN dnf install -y httpd && \
    dnf clean all
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

现在,您可以重新构建图像,看看这种更改如何影响包含http守护程序的层的大小:

$ docker image build .
[+] Building 0.5s (7/7) FINISHED
…
 => => writing image sha256:14fe7924bb0b641ddf11e08d3dd56f40aff4271cad7a421fe…

$ docker image tag sha256:14fe7924bb0b641ddf11e08d3dd56f40aff4271cad7a421fe9b…
IMAGE        CREATED            CREATED BY                            SIZE   …
14fe7924bb0b About a minute ago CMD ["/usr/sbin/httpd" "-DFOREGROUN"]… 0B
<missing>    About a minute ago RUN /bin/sh -c dnf install -y httpd &… 44.8MB
<missing>    6 weeks ago        /bin/sh -c #(nop)  CMD ["/bin/bash"] … 0B
<missing>    6 weeks ago        /bin/sh -c #(nop) ADD file:58865512ca… 163MB
<missing>    3 months ago       /bin/sh -c #(nop)  ENV DISTTAG=f36con… 0B
<missing>    15 months ago      /bin/sh -c #(nop)  LABEL maintainer=C… 0B

在前两个示例中,涉及的层大小为 273 MB,但现在您已删除了许多不必要的文件,这些文件被添加到该层中,因此可以将该层缩小至 44.8 MB。这是非常大的空间节省,特别是考虑到在任何给定部署期间可能有多少服务器在下载镜像。

利用层缓存

我们将在这里介绍的最终构建技术与尽可能保持构建时间快速相关。DevOps 运动的一个重要目标是尽可能保持反馈循环紧凑。这意味着重要的是尽快发现和报告问题,以便在人们仍然完全专注于相关代码而未转向其他无关任务时进行修复。

在任何标准构建过程中,Docker 使用层缓存来尝试避免重建它已经构建的任何图像层,而这些层不包含任何显著的更改。由于这个缓存,您在Dockerfile中执行操作的顺序可能会对平均构建时间产生显著影响。

首先,让我们从之前的示例中获取Dockerfile,稍作定制,使其看起来像这样。

提示

除了其他示例,您还可以在GitHub上找到这些文件。

FROM docker.io/fedora
RUN dnf install -y httpd && \
    dnf clean all
RUN mkdir -p /var/www && \
    mkdir -p /var/www/html
ADD index.html /var/www/html
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

现在,在同一个目录中,让我们还创建一个名为index.html的新文件,其内容如下:

<html>
  <head>
    <title>My custom Web Site</title>
  </head>
  <body>
    <p>Welcome to my custom Web Site</p>
  </body>
</html>

对于第一次测试,让我们通过使用以下命令,在完全不使用 Docker 缓存的情况下计时构建:

$ time docker image build --no-cache .
time docker image build --no-cache .
[+] Building 48.3s (9/9) FINISHED
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 238B
 => [internal] load .dockerignore
 => => transferring context: 2B
 => [internal] load metadata for docker.io/library/fedora:latest
 => CACHED [1/4] FROM docker.io/library/fedora
 => [internal] load build context
 => => transferring context: 32B
 => [2/4] RUN dnf install -y httpd &&     dnf clean all
 => [3/4] RUN mkdir -p /var/www &&     mkdir -p /var/www/html
 => [4/4] ADD index.html /var/www/html
 => exporting to image
 => => exporting layers
 => => writing image sha256:7f94d0d6492f2d2c0b8576f0f492e03334e6a535cac85576c…

real  1m21.645s
user  0m0.428s
sys   0m0.323s
提示

Windows 用户应该能够在 WSL2 会话中运行此命令,或者使用 PowerShell Measure-Command^(6) 函数替换这些示例中使用的 Unix time 命令。

time 命令的输出可以看出,没有使用缓存的构建大约花了 1 分钟 21 秒,只从层缓存中获取了基础镜像。如果立即之后重新构建镜像并允许 Docker 使用缓存,你会发现构建速度非常快:

$ time docker image build .
[+] Building 0.1s (9/9) FINISHED
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 37B
 => [internal] load .dockerignore
 => => transferring context: 2B
 => [internal] load metadata for docker.io/library/fedora:latest
 => [1/4] FROM docker.io/library/fedora
 => [internal] load build context
 => => transferring context: 32B
 => CACHED [2/4] RUN dnf install -y httpd &&     dnf clean all
 => CACHED [3/4] RUN mkdir -p /var/www &&     mkdir -p /var/www/html
 => CACHED [4/4] ADD index.html /var/www/html
 => exporting to image
 => => exporting layers
 => => writing image sha256:0d3aeeeeebd09606d99719e0c5197c1f3e59a843c4d7a21af…

real  0m0.416s
user  0m0.120s
sys   0m0.087s

由于没有任何层发生更改,并且可以完全利用所有四个构建步骤的缓存,构建只需不到一秒钟即可完成。现在,让我们对 index.html 文件进行小改进,使其看起来像这样:

<html>
  <head>
    <title>My custom Web Site</title>
  </head>
  <body>
    <div align="center">
      <p>Welcome to my custom Web Site!!!</p>
    </div>
  </body>
</html>

然后让我们再次计算重建时间:

$ time docker image build .
[+] Building 0.1s (9/9) FINISHED
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 37B
 => [internal] load .dockerignore
 => => transferring context: 2B
 => [internal] load metadata for docker.io/library/fedora:latest
 => [internal] load build context
 => => transferring context: 214B
 => [1/4] FROM docker.io/library/fedora
 => CACHED [2/4] RUN dnf install -y httpd &&     dnf clean all
 => CACHED [3/4] RUN mkdir -p /var/www &&     mkdir -p /var/www/html
 => [4/4] ADD index.html /var/www/html
 =>  ADD index.html /var/www/html
 => exporting to image
 => => exporting layers
 => => writing image sha256:daf792da1b6a0ae7cfb2673b29f98ef2123d666b8d14e0b74…

real  0m0.456s
user  0m0.120s
sys   0m0.068s

如果你仔细查看输出,你会发现大部分构建都使用了缓存。直到第 4/4 步,当 Docker 需要复制 index.html 时,缓存才会失效,需要重新创建层。因为大部分构建可以使用缓存,所以构建仍然没有超过一秒钟。

但是如果你改变 Dockerfile 中命令的顺序,使其看起来像这样,会发生什么:

FROM docker.io/fedora
RUN mkdir -p /var/www && \
    mkdir -p /var/www/html
ADD index.html /var/www/html
RUN dnf install -y httpd && \
    dnf clean all
CMD ["/usr/sbin/httpd", "-DFOREGROUND"]

让我们快速计时另一个不使用缓存的测试构建,以获得一个基准:

$ time docker image build --no-cache .
[+] Building 51.5s (9/9) FINISHED
…
 => => writing image sha256:1cc5f2c5e4a4d1cf384f6fb3a34fd4d00e7f5e7a7308d5f1f…

real  0m51.859s
user  0m0.237s
sys   0m0.159s

在这种情况下,构建过程需要 51 秒才能完成:因为我们使用了 --no-cache 参数,我们知道除了基础镜像外,没有从层缓存中获取任何内容。从第一次测试开始到现在的时间差完全是由于网络速度波动引起的,与你对 Dockerfile 所做的更改无关。

现在,让我们再次编辑 index.html,如下所示:

<html>
  <head>
    <title>My custom Web Site</title>
  </head>
  <body>
    <div align="center" style="font-size:180%">
      <p>Welcome to my custom Web Site</p>
    </div>
  </body>
</html>

现在,让我们在使用缓存的情况下再次计时镜像重建:

$ time docker image build .
[+] Building 43.4s (9/9) FINISHED
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 37B
 => [internal] load .dockerignore
 => => transferring context: 2B
 => [internal] load metadata for docker.io/library/fedora:latest
 => [1/4] FROM docker.io/library/fedora
 => [internal] load build context
 => => transferring context: 233B
 => CACHED [2/4] RUN mkdir -p /var/www &&     mkdir -p /var/www/html
 => [3/4] ADD index.html /var/www/html
 => [4/4] RUN dnf install -y httpd &&     dnf clean all
 => exporting to image
 => => exporting layers
 => => writing image sha256:9a05b2d01b5870649e0ad1d7ad68858e0667f402c8087f0b4…

real  0m43.695s
user  0m0.211s
sys   0m0.133s

第一次编辑 index.html 文件后重新构建镜像时,仅花费了 0.456 秒,但这次花费了 43.695 秒,几乎与完全不使用缓存构建整个镜像的时间相同。

这是因为你修改了 Dockerfile,使得 index.html 文件在构建过程中很早就被复制到镜像中。这样做的问题是 index.html 文件经常更改,并且通常会使缓存失效。另一个问题是它不必要地放在我们 Dockerfile 中一个非常耗时的步骤之前:安装 Apache Web 服务器。

从所有这些中获得的重要教训是顺序很重要,一般来说,你应该尽量将 Dockerfile 的最稳定和耗时的部分放在最前面,并尽可能晚地添加你的代码。

对于需要使用像npmbundle等工具根据您的代码安装依赖项的项目,建议您对优化这些平台上的 Docker 构建进行一些研究。这通常包括锁定依赖版本并将它们与代码一起存储,以便不需要在每次构建时重新下载它们。

目录缓存

BuildKit 为镜像构建体验增加的众多功能之一是目录缓存。目录缓存是一个极其有用的工具,可以加快构建时间,同时避免将对运行时不必要的大量文件保存到您的映像中。本质上,它允许您将目录内容保存在映像内的一个特殊层中,在构建时可以将其绑定挂载,然后在生成映像快照之前卸载。通常用于处理诸如 Linux 软件安装程序(aptapkdnf等)和语言依赖管理器(npmbundlerpip等)下载其数据库和归档文件的目录。

Tip

如果您不熟悉绑定挂载及其用途,可以在 Docker 文档中找到绑定挂载概述

要使用目录缓存,您必须启用 BuildKit。在大多数情况下,这应该已经实现了,但是您可以通过设置环境变量DOCKER_BUILDKIT=1来强制从客户端进行操作:

$ export DOCKER_BUILDKIT=1

让我们通过检出以下 git 仓库来探索目录缓存,并看看如何利用目录缓存可以显著改善连续构建的效率,同时保持生成的映像大小更小:

$ git clone https://github.com/spkane/open-mastermind.git \
  --config core.autocrlf=input

$ cd open-mastermind
$ cat Dockerfile
FROM python:3.9.15-slim-bullseye
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN pip install -r requirements.txt
WORKDIR /app/mastermind
CMD ["python", "mastermind.py"]

这个代码库有一个非常通用的Dockerfile提交到仓库中。让我们继续看看构建此映像所需的时间,以及是否使用层缓存,还要检查生成的映像大小:

$ time docker build --no-cache -t docker.io/spkane/open-mastermind:latest .

[+] Building 67.5s (12/12) FINISHED
…
 => => naming to docker.io/spkane/open-mastermind:latest                  0.0s

real    0m28.934s
user    0m0.222s
sys     0m0.248s

$ docker image ls --format "{{ .Size }}" spkane/open-mastermind:latest
293MB

$ time docker build -t docker.io/spkane/open-mastermind:latest .

[+] Building 1.5s (12/12) FINISHED
…
 => => naming to docker.io/spkane/open-mastermind:latest                  0.0s

real    0m1.083s
user    0m0.098s
sys     0m0.095s

从此输出中,我们可以看到,如果完全利用层缓存,则构建此映像仅需不到 2 秒,而不使用层缓存则需要将近 29 秒。生成的映像总大小为 293 MB。

Tip

BuildKit 最终支持修改或完全禁用输出中使用的颜色。对于那些在终端中使用黑色背景的用户来说,这尤其方便。您可以通过设置类似于export BUILDKIT_COLORS=run=green:warning=yellow:error=red:cancel=cyan这样的方式来配置这些颜色,或者通过设置export NO_COLOR=true来完全禁用颜色。

请注意,各种docker组件和第三方工具中使用的 BuildKit 版本仍在更新中,因此在每种情况下可能尚不起作用。

如果您想测试构建,请运行它:

$ docker container run -ti --rm docker.io/spkane/open-mastermind:latest

这将启动一个基于终端的开源版本的猜数字游戏(Mastermind)。屏幕上有游戏的说明,如果需要,您可以随时通过键入 Ctrl-C 退出。

由于这是一个 Python 应用程序,它使用 requirements.txt 文件列出应用程序所需的所有库,然后在 Dockerfile 中使用 pip 应用程序安装这些依赖项。

注意

我们安装了一些不必要的依赖项,只是为了更明显地展示目录缓存的好处。

请打开 requirements.txt 文件并添加一行,内容为 log-symbols,使其看起来像这样:

colorama
# These are not required - but are used for demonstration purposes
pandas
flask
log-symbols

现在让我们重新运行构建:

$ time docker build -t docker.io/spkane/open-mastermind:latest \
  --progress=plain .

#1 [internal] load build definition from Dockerfile
…
#9 [5/6] RUN pip install -r requirements.txt
#9 sha256:82dbc10f1bb9fa476d93cc0d8104b76f46af8ece7991eb55393d6d72a230919e
#9 1.954 Collecting colorama
#9 2.058   Downloading colorama-0.4.5-py2.py3-none-any.whl (16 kB)
…
real    0m16.379s
user    0m0.112s
sys     0m0.082s

如果您查看第 5/6 步的完整输出,您会注意到所有依赖项都再次下载,尽管 pip 通常会将大多数依赖项缓存在 /root/.cache 中。这种低效是由于构建器看到我们已经做出了影响此层的更改,因此完全重新创建了该层,因此我们失去了该缓存,尽管我们已将其存储在图像层中。

让我们继续改善这种情况。为此,我们需要利用 BuildKit 目录缓存,并且我们需要对 Dockerfile 进行一些更改,使其看起来像这样:

# syntax=docker/dockerfile:1
FROM python:3.9.15-slim-bullseye
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN --mount=type=cache,target=/root/.cache pip install -r requirements.txt
WORKDIR /app/mastermind
CMD ["python", "mastermind.py"]

其中有两个重要的更改。首先,我们添加了以下行:

# syntax=docker/dockerfile:1

这告诉 Docker 我们将使用一个更新版本的 Dockerfile 前端,这为我们提供了访问 BuildKit 的新特性。

然后我们编辑了 RUN 行,使其看起来像这样:

RUN --mount=type=cache,target=/root/.cache pip install -r requirements.txt

此行告诉 BuildKit 在本次构建步骤中将一个缓存层挂载到容器的 /root/.cache 中。这将为我们达成两个目标。它将从生成的图像中删除该目录的内容,并且在连续的构建中,它也将被重新挂载并可供 pip 使用。

现在让我们使用这些更改对图像进行完整重建,以生成初始缓存目录内容。如果您跟随输出,您将看到 pip 下载了所有依赖项,正如以前一样:

$ time docker build --no-cache -t docker.io/spkane/open-mastermind:latest .

[+] Building 15.2s (15/15) FINISHED
…
 => => naming to docker.io/spkane/open-mastermind:latest                  0.0s
…
real    0m15.493s
user    0m0.137s
sys     0m0.096s

现在让我们打开 requirements.txt 文件并添加一行,内容为 py-events

colorama
# These are not required - but are used for demonstration purposes
pandas
flask
log-symbols
py-events

这就是更改的收益所在。现在,当我们重新构建图像时,我们将看到只有 py-events 及其依赖项被下载;其他所有内容都使用了我们上一个构建的现有缓存,这些缓存已经被挂载到本次构建步骤的图像中:

$ time docker build -t docker.io/spkane/open-mastermind:latest \
  --progress=plain .

#1 [internal] load build definition from Dockerfile
…
#14 [stage-0 5/6] RUN --mount=type=cache,target=/root/.cache pip install …
#14 sha256:9bc72441fdf2ec5f5803d4d5df43dbe7bc6eeef88ebee98ed18d8dbb478270ba
#14 1.711 Collecting colorama
#14 1.714   Using cached colorama-0.4.5-py2.py3-none-any.whl (16 kB)
…
#14 2.236 Collecting py-events
#14 2.356   Downloading py_events-0.1.2-py3-none-any.whl (5.8 kB)
…
#16 DONE 1.4s

real    0m12.624s
user    0m0.180s
sys     0m0.112s

$ docker image ls --format "{{ .Size }}" spkane/open-mastermind:latest
261MB

由于不再需要每次重新下载所有内容,构建时间已经缩短,而且图像大小也减小了 32 MB,尽管我们已向图像添加了新的依赖项。这仅仅是因为缓存目录不再直接存储在包含应用程序的图像中。

BuildKit 和新的Dockerfile前端为镜像构建过程带来了许多非常有用的功能,您会希望了解这些功能。我们强烈建议您花时间阅读参考指南,并熟悉所有可用的功能。

故障排除断裂构建

通常情况下,我们期望构建工作正常,特别是当我们已经将其脚本化时,但在现实世界中,事情可能会出错。让我们花点时间讨论一下您可以做些什么来排查 Docker 构建失败的问题。在本节中,我们将探讨两个选项:一个适用于使用传统构建方法的镜像构建,另一个适用于 BuildKit。

为了演示,我们将重复使用本章早期的docker-hello-node仓库。如果需要,您可以再次克隆它,就像这样:

$ git clone https://github.com/spkane/docker-node-hello.git \
    --config core.autocrlf=input
Cloning into 'docker-node-hello'…
remote: Counting objects: 41, done.
remote: Total 41 (delta 0), reused 0 (delta 0), pack-reused 41
Unpacking objects: 100% (41/41), done.

$ cd docker-node-hello

调试传统构建方法的镜像

我们需要一个病人进行接下来的一系列练习,因此让我们创建一个失败的构建。为此,请编辑Dockerfile,使其读取:

RUN apt-get -y update

现在读取:

RUN apt-get -y update-all
警告

如果您在 Windows 上使用 PowerShell,则在运行以下docker image build命令之前,可能需要设置禁用 BuildKit 的环境变量,并在之后重新设置:

PS C:\> $env:DOCKER_BUILDKIT = 0
PS C:\> docker image build `
 -t example/docker-node-hello:latest `
 --no-cache .
PS C:\> $env:DOCKER_BUILDKIT = 1

如果您现在尝试构建镜像,应该会收到以下错误:

$ DOCKER_BUILDKIT=0 docker image build -t example/docker-node-hello:latest \
  --no-cache .

Sending build context to Docker daemon  9.216kB
Step 1/14 : FROM docker.io/node:18.13.0
 ---> 9ff38e3a6d9d
…
Step 6/14 : ENV SCPATH /etc/supervisor/conf.d
 ---> Running in e903367eaeb8
Removing intermediate container e903367eaeb8
 ---> 2a236efc3f06
Step 7/14 : RUN apt-get -y update-all
 ---> Running in c7cd72f7d9bf
E: Invalid operation update-all
The command '/bin/sh -c apt-get -y update-all' returned a non-zero code: 100

那么,我们如何进行排查,特别是如果我们不是在 Linux 系统上开发的情况下?这里真正的技巧是记住几乎所有 Docker 镜像都是基于其他 Docker 镜像构建的,您可以从任何镜像启动容器。虽然这一层面的含义并不明显,但如果您查看Step 6的输出,您将看到:

Step 6/14 : ENV SCPATH /etc/supervisor/conf.d
 ---> Running in e903367eaeb8
Removing intermediate container e903367eaeb8
 ---> 2a236efc3f06

第一行读取的Running in e903367eaeb8告诉您构建过程已经启动了一个新的容器,基于Step 5中创建的镜像。接下来的一行读取Removing intermediate container e903367eaeb8告诉您 Docker 现在正在移除容器,在Step 6的指令基础上修改了它。在这种情况下,它仅仅通过ENV SCPATH /etc/supervisor/conf.d添加了一个默认的环境变量。最后一行读取的--→ 2a236efc3f06是我们真正关心的,因为这给出了由Step 6生成的镜像 ID。您需要此 ID 来排查构建问题,因为它是构建中最后一个成功步骤生成的镜像。

有了这些信息,可以运行一个交互式容器,这样您可以尝试确定为何您的构建无法正常工作。请记住,每个容器镜像都基于其下面的镜像层。其中一个巨大的好处是,我们可以直接将较低的层作为一个容器本身运行,使用 shell 来查看周围的情况!

$ docker container run --rm -ti 2a236efc3f06 /bin/bash
root@b83048106b0f:/#

从容器内部,您现在可以运行任何您可能需要执行的命令,以确定导致构建失败的原因及您需要执行的操作以修复您的Dockerfile

root@b83048106b0f:/# apt-get -y update-all
E: Invalid operation update-all

root@b83048106b0f:/# apt-get --help
apt 1.4.9 (amd64)
…

Most used commands:
 update - Retrieve new lists of packages
…

root@b83048106b0f:/# apt-get -y update
Get:1 http://security.debian.org/debian-security stretch/updates … [53.0 kB]
…
Reading package lists… Done

root@b83048106b0f:/# exit
exit

一旦确定了根本原因,Dockerfile可以修复,这样RUN apt-get -y update-all现在读作RUN apt-get -y update,然后重新构建镜像应该会成功:

$ DOCKER_BUILDKIT=0 docker image build -t example/docker-node-hello:latest .
Sending build context to Docker daemon  15.87kB
…
Successfully built 69f5e83bb86e
Successfully tagged example/docker-node-hello:latest

调试 BuildKit 镜像

当使用 BuildKit 时,我们必须采用稍微不同的方法来获取构建失败点的访问权限,因为构建容器中的任何中间构建层都不会导出到 Docker 守护程序。

调试 BuildKit 的选项随着我们的前进几乎肯定会发生变化,但让我们看看现在有效的一种方法。

假设Dockerfile已经恢复到原始状态,让我们修改读取的那一行:

RUN npm install

现在它应该是:

RUN npm installer

然后尝试构建镜像。

提示

确保已启用 BuildKit!

$ docker image build -t example/docker-node-hello:debug --no-cache .

[+] Building 51.7s (13/13) FINISHED
 => [internal] load build definition from Dockerfile                      0.0s
…
 => [7/8] WORKDIR /data/app                                               0.0s
 => ERROR [8/8] RUN npm installer                                         0.4s
______
 > [8/8] RUN npm installer:
#13 0.399
#13 0.399 Usage: npm <command>
…
#13 0.402 Did you mean one of these?
#13 0.402     install
#13 0.402     install-test
#13 0.402     uninstall
______
executor failed running [/bin/sh -c npm installer]: exit code: 1

正如我们预期的那样看到错误,但我们如何获取访问权限以便进行故障排除?

有效的一种方法是利用多阶段构建和docker image build--target参数。

让我们从两个地方修改Dockerfile。更改这一行:

FROM docker.io/node:18.13.0

现在它应该是:

FROM docker.io/node:18.13.0 as deploy

然后在导致错误的那一行之前立即添加一个新的FROM行:

FROM deploy
RUN npm installer

通过这样做,我们正在创建一个多阶段构建,其中第一阶段包含我们知道正在工作的所有步骤,第二阶段从我们的有问题的步骤开始。

如果我们尝试使用与之前相同的命令重新构建它,它仍然会失败:

$ docker image build -t example/docker-node-hello:debug .

[+] Building 51.7s (13/13) FINISHED
…
executor failed running [/bin/sh -c npm installer]: exit code: 1

因此,与其这样做,让我们告诉 Docker 我们只想在我们的多阶段Dockerfile中构建第一个镜像:

$ docker image build -t example/docker-node-hello:debug --target deploy .

[+] Building 0.8s (12/12) FINISHED
 => [internal] load build definition from Dockerfile                   0.0s
 => => transferring dockerfile: 37B                                    0.0s
…
 => exporting to image                                                 0.1s
 => => exporting layers                                                0.1s
 => => writing image sha256:a42dfbcfc7b18ee3d30ace944ad4134ea2239a2c0  0.0s
 => => naming to docker.io/example/docker-node-hello:debug             0.0s

现在,我们可以从这个镜像创建一个容器,并进行我们需要的任何测试:

$ docker container run --rm -ti docker.io/example/docker-node-hello:debug \
  /bin/bash

root@17807997176e:/data/app# ls
index.js  package.json

root@17807997176e:/data/app# npm install
…
added 18 packages from 16 contributors and audited 18 packages in 1.248s
…

root@17807997176e:/data/app# exit
exit

然后一旦我们理解了Dockerfile有什么问题,我们可以恢复我们的调试更改,并修复npm行,以便整个构建按预期工作。

多架构构建

自从 Docker 发布以来,AMD64/X86_64架构一直是大多数容器所针对的主要平台。然而,这种情况已经开始显著改变。越来越多的开发人员正在使用基于 ARM64/AArch64 的系统,云公司也开始通过其平台提供基于 ARM 的 VM,这是因为 ARM 平台具有更低的计算成本。

这可能会对需要构建和维护面向多个架构的镜像的任何人造成一些有趣的挑战。在支持所有这些不同目标的同时,如何保持单一,简化的代码库和流水线?

幸运的是,Docker 已经发布了一个名为buildxdocker CLI 插件,可以帮助简化这个过程。在许多情况下,docker-buildx已经安装在您的系统上,您可以像这样验证:

$ docker buildx version
github.com/docker/buildx v0.9.1 ed00243a0ce2a0aee75311b06e32d33b44729689
提示

如果需要安装插件,可以按照从Github 仓库的说明进行操作。

默认情况下,docker-buildx将利用基于 QEMU 的虚拟化binfmt_misc来支持与基础系统不同的架构。这可能已经在您的 Linux 系统上设置好了,但以防万一,建议在首次设置新的 Docker 服务器时运行以下命令,以确保 QEMU 文件已正确注册并更新到最新状态:

$ docker container run --rm --privileged multiarch/qemu-user-static \
    --reset -p yes

Setting /usr/bin/qemu-alpha-static as binfmt interpreter for alpha
Setting /usr/bin/qemu-arm-static as binfmt interpreter for arm
Setting /usr/bin/qemu-armeb-static as binfmt interpreter for armeb
…
Setting /usr/bin/qemu-aarch64-static as binfmt interpreter for aarch64
Setting /usr/bin/qemu-aarch64_be-static as binfmt interpreter for aarch64_be
…

与原始嵌入式 Docker 构建功能不同,BuildKit 在构建镜像时可以利用一个构建容器,这意味着可以提供许多功能灵活性。在下一步中,我们将创建一个名为builder的默认buildx容器。

提示

如果您已经存在名为此名称的buildx容器,您可以通过运行docker buildx rm builder来删除它,或者您可以在即将到来的docker buildx create命令中更改名称。

使用下面的两个命令,我们将创建构建容器,并将其设置为默认值,然后启动它:

$ docker buildx create --name builder --driver docker-container --use
builder

$ docker buildx inspect --bootstrap
[+] Building 9.6s (1/1) FINISHED
 => [internal] booting buildkit                                           9.6s
 => => pulling image moby/buildkit:buildx-stable-1                        8.6s
 => => creating container buildx_buildkit_builder0                        0.9s
Name:   builder
Driver: docker-container

Nodes:
Name:      builder0
Endpoint:  unix:///var/run/docker.sock
Status:    running
Buildkit:  v0.10.5
Platforms: linux/amd64, linux/amd64/v2, linux/arm64, linux/riscv64,
 linux/ppc64le, linux/s390x, linux/386, linux/mips64le,
 linux/mips64, linux/arm/v7, linux/arm/v6

例如,让我们继续下载包含一个有用工具的wordchain Git 存储库,该工具可以生成随机和确定性的单词序列,以帮助动态命名需求:

$ git clone https://github.com/spkane/wordchain.git \
  --config core.autocrlf=input
$ cd wordchain

让我们继续查看附带的Dockerfile。您会注意到它是一个相当正常的多阶段Dockerfile,并且没有任何与平台架构相关的特殊内容:

FROM golang:1.18-alpine3.15 AS build

RUN apk --no-cache add \
    bash \
    gcc \
    musl-dev \
    openssl

ENV CGO_ENABLED=0

COPY . /build
WORKDIR /build

RUN go install github.com/markbates/pkger/cmd/pkger@latest && \
    pkger -include /data/words.json && \
    go build .

FROM alpine:3.15 AS deploy

WORKDIR /
COPY --from=build /build/wordchain /

USER 500
EXPOSE 8080

ENTRYPOINT ["/wordchain"]
CMD ["listen"]

在第一步中,我们将构建我们的静态编译的 Go 二进制文件,然后在第二步中,我们将把它打包成一个小的部署镜像。

注意

Dockerfile中的ENTRYPOINT指令是一个高级指令,允许您将容器运行的默认进程(ENTRYPOINT)与传递给该进程的命令行参数(CMD)分开。当Dockerfile中缺少ENTRYPOINT时,预期CMD指令将包含进程及其所有必需的命令行参数。

我们可以继续构建此镜像,并通过运行以下命令将其侧加载到我们的本地 Docker 服务器中:

$ docker buildx build --tag wordchain:test --load .

[+] Building 2.4s (16/16) FINISHED
 => [internal] load .dockerignore                                         0.0s
 => => transferring context: 93B                                          0.0s
 => [internal] load build definition from Dockerfile                      0.0s
 => => transferring dockerfile: 461B                                      0.0s
…
 => exporting to oci image format                                         0.3s
 => => exporting layers                                                   0.0s
 => => exporting manifest sha256:4bd1971f2ed820b4f64ffda97707c27aac3e8eb7 0.0s
 => => exporting config sha256:ce8f8564bf53b283d486bddeb8cbb074ff9a9d4ce9 0.0s
 => => sending tarball                                                    0.2s
 => importing to docker                                                   0.0s

我们可以通过运行以下命令快速测试该镜像:

$ docker container run wordchain:test random

witty-stack

$ docker container run wordchain:test random -l 3 -d .

odd.goo

$ docker container run wordchain:test --help

wordchain is an application that can generate a readable chain
 of customizable words for naming things like
 containers, clusters, and other objects.
…

只要您在第一个两个命令中收到了一些随机单词对返回的结果,那么一切都按预期运行。

现在,要为多个架构构建此镜像,只需在我们的构建中添加--platform参数即可。

注意

通常情况下,我们会将 --load 替换为 --push,这样会将所有生成的镜像推送到标记的仓库。但在这种情况下,我们只需简单地移除 --load,因为 Docker 服务器目前无法加载多个平台的镜像,并且我们也没有设置推送这些镜像的仓库。如果我们有一个仓库并正确标记了这些镜像,那么我们可以轻松地使用如下命令一步构建和推送所有生成的镜像:

docker buildx build --platform linux/amd64,linux/arm64 --tag docker.io/spkane/wordchain:latest --push .

你可以像这样为 linux/amd64 和 linux/arm64 平台构建此镜像:

$ docker buildx build --platform linux/amd64,linux/arm64 \
    --tag wordchain:test .

[+] Building 114.9s (23/23) FINISHED
…
 => [linux/arm64 internal] load metadata for docker.io/library/alpine:3.1 2.7s
 => [linux/amd64 internal] load metadata for docker.io/library/alpine:3.1 2.7s
 => [linux/arm64 internal] load metadata for docker.io/library/golang:1.1 3.0s
 => [linux/amd64 internal] load metadata for docker.io/library/golang:1.1 2.8s
…
 => CACHED [linux/amd64 build 5/5] RUN go install github.com/markbates/pk 0.0s
 => CACHED [linux/amd64 deploy 2/3] COPY --from=build /build/wordchain /  0.0s
 => [linux/arm64 build 5/5] RUN go install github.com/markbates/pkger/c 111.7s
 => [linux/arm64 deploy 2/3] COPY --from=build /build/wordchain /         0.0s
WARNING: No output specified with docker-container driver. Build result will
 only remain in the build cache. To push result image into registry
 use --push or to load image into docker use --load
注意

在为非本地架构构建镜像时,由于需要进行仿真,你可能会注意到某些步骤比正常情况下花费更多时间。这是可以预期的,因为额外的计算开销来自仿真过程。

可以配置 Docker,使其在具有匹配架构的工作节点上构建每个镜像,这在很多情况下可以显著加快构建速度。有关此内容的更多信息可以在这篇Docker 博客文章中找到。

在构建输出中,你会注意到以类似 => [linux/amd64 *]=> [linux/arm64 *] 开头的行。每行代表了构建步骤在指定平台上的工作状态。许多这样的步骤会并行运行,由于缓存和其他考虑因素,每个构建的进度可能会不同步。

由于我们没有在构建中添加 --push,所以你还会注意到在构建结束时收到了一个警告。这是因为构建器使用的 docker-container 驱动器只是将一切留在构建缓存中,这意味着我们不能运行生成的镜像;在这一点上,我们只能确信构建是有效的。

提示

有一些build 参数是 Docker 自动设置的,特别是在进行多架构构建时,这些参数可以非常有帮助。例如,TARGETARCH 经常用于确保给定的构建步骤下载当前镜像平台的正确预构建二进制文件。

所以,当我们将这个镜像上传到仓库时,Docker 如何知道要使用哪个镜像来适配本地平台?这些信息通过称为 镜像清单 的东西提供给 Docker 服务器。我们可以通过以下命令查看 docker.io/spkane/workdchain 的清单:

$ docker manifest inspect docker.io/spkane/wordchain:latest
{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
   "manifests": [
      {
         "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
         "size": 739,
         "digest": "sha256:4bd1…bfc0",
         "platform": {
            "architecture": "amd64",
            "os": "linux"
         }
      },
      {
…
         "platform": {
            "architecture": "arm64",
            "os": "linux"
         }
      },
…
   ]
}

如果您查看输出,您会看到每个平台支持的图像都需要识别的块。这通过单独的digest条目完成,然后与一个platform块配对。服务器在需要图像时下载此清单文件,然后引用清单后,服务器将为本地平台下载正确的图像。这就是我们的Dockerfile能够正常工作的原因。每个FROM行列出了我们要使用的基础图像,但是 Docker 服务器利用此清单文件来确定为构建所针对的每个平台下载哪个图像。

总结

此时,您应该对为 Docker 创建图像感到非常自在,并且应该对许多可以利用来简化构建流水线的核心工具和功能有了牢固的理解。在下一章中,我们将开始探讨如何使用您的图像为项目创建容器化进程。

^(1) 完整网址: https://github.com/torvalds/linux/commit/e9be9d5e76e34872f0c37d72e25bc27fe9e2c54c

^(2) 此代码最初是从GitHub分支出来的。

^(3) 完整网址: https://docs.docker.com/registry/recipes/mirror/#configure-the-docker-daemon

^(4) 完整网址: https://docs.docker.com/registry/recipes/mirror/#run-a-registry-as-a-pull-through-cache

^(5) 完整网址: https://github.com/bluewhalebook/docker-up-and-running-3rd-edition/blob/main/chapter_04/multistage/Dockerfile

^(6) 完整网址: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility/measure-command?view=powershell-7.3

第五章:使用容器

在上一章中,我们学习了如何构建一个 Docker 镜像以及在容器中运行结果图像所需的基本步骤。在这一章中,我们首先将了解容器技术的历史,然后深入探讨运行容器和探索 Docker 命令的详细配置、资源和权限的内容。

什么是容器?

你可能对像 VMware 或 KVM 这样的虚拟化系统很熟悉,它们允许你在虚拟化层上运行完整的 Linux 内核和操作系统,通常称为hypervisor。这种方法提供了非常强大的工作负载隔离,因为每个虚拟机都托管着自己的操作系统内核,该内核位于硬件虚拟化层之上的一个单独的内存空间中。

容器在根本上是不同的,因为它们都共享一个单一的内核,并且工作负载之间的隔离完全在该内核中实现。这被称为操作系统虚拟化

libcontainer README 提供了一个很好的、简短的容器定义:

容器是一个自包含的执行环境,它共享主机系统的内核,并且(可选地)与系统中的其他容器隔离开来。

容器的一个主要优点是资源效率,因为对于每个隔离的工作负载,你不需要一个完整的操作系统实例。由于你共享一个内核,所以在隔离的任务和底层的真实硬件之间少了一层间接。当一个进程在容器内运行时,只有一小部分代码坐落在内核中管理容器。与虚拟机相比,这有很大的不同。在虚拟机中,第二层将会运行。在虚拟机中,进程对硬件或 hypervisor 的调用将需要在处理器上特权模式之间进行两次跳转,从而明显地减慢许多调用。

libcontainer 是一个 Go 库,旨在为应用程序提供管理 Linux 容器的标准接口。

但是容器的方法意味着你只能运行与底层内核兼容的进程。例如,与 VMware 或 KVM 等技术提供的硬件虚拟化不同,Windows 应用程序无法在 Linux 主机上的 Linux 容器中本地运行。但是,Windows 应用程序可以在 Windows 主机上的 Windows 容器中运行。因此,容器最好被视为一种特定于操作系统的技术,其中你可以运行与容器服务器的内核兼容的任何你喜欢的应用程序或守护进程。在考虑容器时,你应该尽量放弃你可能已经了解的有关虚拟机的知识,并将容器概念化为在服务器上运行的正常进程的封装。

注意

除了能在虚拟机内运行容器外,完全可以在容器内运行虚拟机。如果这样做,确实可以在运行在 Linux 容器内的 Windows 虚拟机中运行 Windows 应用程序。

容器的历史

通常情况下,一项革命性技术往往是一个老技术终于引起关注的结果。技术发展如波浪般起伏,上世纪六十年代的一些概念如今再度流行起来。同样,Docker 是一项较新的技术,其易用性使其迅速成为热门,但它并非孤立存在。Docker 的许多基础理念源自过去三十年在几个不同领域的工作。我们可以轻松追溯容器的概念演变,从上世纪七十年代末添加到 Unix 内核的一个简单系统调用,到如今支持许多大型互联网公司如谷歌、Twitter 和 Meta 的现代容器工具。快速了解这项技术如何演进并导致 Docker 的诞生是值得的,因为理解这一点有助于将其置于你熟悉的其他技术背景中。

容器并非新概念。它们是隔离和封装正在运行系统的一部分的一种方法。在这一领域,最古老的技术包括最早的批处理系统。在使用这些早期计算机时,系统一次只能运行一个程序,直到前一个程序完成或预定义的时间段结束后,才切换到运行另一个程序。这种设计强制实现了隔离:你可以确保你的程序不会干扰其他程序,因为一次只能运行一件事。虽然现代计算机仍在不断切换任务,但对大多数用户来说,这是非常快速且完全不可察觉的。

我们认为今天容器的种子在 1979 年种下,当时在 Version 7 Unix 中加入了 chroot 系统调用。chroot 限制了进程对底层文件系统的视图,仅限于单个子树。chroot 系统调用通常用于保护操作系统免受像 FTP、BIND 和 Sendmail 这样的不受信任的服务器进程的影响,这些进程可能会公开暴露并易受到威胁。

在 1980 年代和 1990 年代,出于安全原因,各种 Unix 变种创建了强制访问控制。^(1) 这意味着你可以在同一个 Unix 内核上运行具有严格控制域的系统。每个域中的进程对系统的视图极其有限,防止它们在域之间进行交互。一个实现了这一理念的流行商业 Unix 版本是基于 BSDI Unix 的 Sidewinder 防火墙,但大多数主流 Unix 实现无法实现这一点。

2000 年,随着 FreeBSD 4.0 的发布,引入了一个名为jail的新命令,旨在允许共享环境托管提供商轻松且安全地在其进程和属于每个客户的进程之间创建隔离。FreeBSD 的jail扩展了chroot的能力,并限制了进程在底层系统和其他受限进程中的操作。

2004 年,Sun 发布了 Solaris 10 的早期版本,其中包括 Solaris 容器,后来演变为 Solaris Zones。这是容器技术的第一个主要商业实现,并且今天仍然用于支持许多商业容器实现。2005 年,Virtuozzo 公司发布了用于 Linux 的 OpenVZ,随后在 2007 年 HP 发布了用于 HP-UX 的安全资源分区(后来更名为 HP-UX 容器)。

像谷歌这样的公司,必须处理广泛互联网消费和/或托管不受信任的用户代码,从 2000 年代初开始推动容器技术,以确保可靠和安全地在全球数据中心分发其应用程序。少数公司在内部使用自己维护的带有容器支持的修补过的 Linux 内核,但随着 Linux 社区内对这些特性需求的显现,谷歌将其支持容器的一些工作贡献到了主流 Linux 内核中,2008 年,在 Linux 内核的 2.6.24 版本中发布了 Linux 容器(LXC)。Linux 容器的显著增长直到 2013 年才真正开始,当时在 Linux 内核的 3.8 版本中包含了用户命名空间,并在一个月后发布了 Docker。

如今,容器几乎无处不在。Docker 和 OCI 图像提供了一个重要且不断增长的软件打包格式,用于交付到生产环境,并为许多生产系统提供基础,包括但不限于 Kubernetes 和大多数“无服务器”云技术。

注意

所谓的无服务器技术实际上并不是真正的无服务器;它们只是依赖于其他人的服务器来完成工作,这样应用程序所有者就不必担心硬件和操作系统的管理。

创建一个容器

到目前为止,我们一直使用方便的docker container run命令来启动容器。但是,docker container run实际上是一个方便的命令,将两个单独的步骤合并为一个。它的第一步是从底层镜像创建一个容器。我们可以使用docker container create命令单独完成这一步。docker container run的第二步是执行容器,我们也可以使用docker container start命令单独执行这一步。

docker container createdocker container start 命令都包含了有关如何初始设置容器的所有选项。在 第四章 中,我们演示了使用 docker container run 命令可以使用 -p/--publish 参数将底层容器中的网络端口映射到主机上,并且可以使用 -e/--env 将环境变量传递到容器中。

这只是开始接触到在创建容器时可以配置的一系列事物。所以让我们看看一些 docker 支持的选项。

基本配置

让我们从探索一些告诉 Docker 在创建容器时如何配置的方式开始。

容器名称

当你创建一个容器时,它是从底层镜像构建的,但各种命令行参数可以影响最终的设置。在 Dockerfile 中指定的设置始终作为默认值使用,但你可以在创建时覆盖其中的许多设置。

默认情况下,Docker 随机为你的容器命名,通常是将一个形容词与一个名人的名字结合起来。这样会生成诸如 ecstatic-babbageserene-albattani 这样的名称。如果你想为你的容器指定一个特定的名称,可以使用 --name 参数:

$ docker container create --name="awesome-service" ubuntu:latest sleep 120

创建此容器后,你可以使用 docker container start awesome-service 启动它。它将在 120 秒后自动退出,但你可以在此之前通过运行 docker container stop awesome-service 停止它。我们稍后将在本章更深入地介绍每个命令。

警告

在 Docker 主机上只能有一个具有特定名称的容器。如果连续两次运行上述命令,将会出错。你必须使用 docker container rm 删除先前的容器,或者更改新容器的名称。

标签

正如在 第四章 中提到的,标签是可以作为元数据应用于 Docker 镜像和容器的键/值对。当创建新的 Linux 容器时,它们会自动继承其父镜像的所有标签。

你也可以向容器添加新的标签,以便为单个容器应用可能特定于其的元数据:

$ docker container run --rm -d --name has-some-labels \
  -l deployer=Ahmed -l tester=Asako \
  ubuntu:latest sleep 1000

之后,你可以使用像 docker container ls 这样的命令基于这些元数据搜索和过滤容器:

$ docker container ls -a -f label=deployer=Ahmed
CONTAINER ID  IMAGE         COMMAND       … NAMES
845731631ba4  ubuntu:latest "sleep 1000"  … has-some-labels

你可以使用 docker container inspect 命令查看容器的所有标签:

$ docker container inspect has-some-labels
…
        "Labels": {
            "deployer": "Ahmed",
            "tester": "Asako"
        },

此容器运行命令 sleep 1000,因此在 1,000 秒后将停止运行。

主机名

默认情况下,当启动容器时,Docker 会将主机上的某些系统文件(包括/etc/hostname)复制到主机上容器的配置目录中,然后使用绑定挂载将该文件的副本链接到容器中。我们可以像这样启动一个默认容器,没有特殊配置:

$ docker container run --rm -ti ubuntu:latest /bin/bash

此命令使用docker container run命令,在后台运行docker container createdocker container start。由于我们希望能够与将要为演示目的创建的容器进行交互,我们传入了一些有用的参数。--rm参数告诉 Docker 在退出时删除容器,-t参数告诉 Docker 分配一个伪 TTY,-i参数告诉 Docker 这将是一个交互式会话,并且我们希望保持 STDIN 打开。如果镜像中没有定义ENTRYPOINT,那么命令中的最后一个参数将是我们希望在容器内运行的可执行文件和命令行参数,本例中是常用的/bin/bash。如果镜像中定义了ENTRYPOINT,那么最后一个参数将作为命令行参数列表传递给ENTRYPOINT进程的命令。

注意

你可能已经注意到上一段提到了-i-t,但命令中使用的是-ti参数。这背后有很多 Unix 历史可以解释为什么会这样,但如果你感兴趣,可以在网上找到一个快速概述

如果现在在生成的容器中运行mount命令,我们将看到类似这样的结果:

root@ebc8cf2d8523:/# mount
overlay on / type overlay (rw,relatime,lowerdir=…,upperdir=…,workdir…)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
tmpfs on /dev type tmpfs (rw,nosuid,mode=755)
shm on /dev/shm type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k)
mqueue on /dev/mqueue type mqueue (rw,nosuid,nodev,noexec,relatime)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,…,ptmxmode=666)
sysfs on /sys type sysfs (ro,nosuid,nodev,noexec,relatime)
/dev/sda9 on /etc/resolv.conf type ext4 (rw,relatime,data=ordered)
/dev/sda9 on /etc/hostname type ext4 (rw,relatime,data=ordered)
/dev/sda9 on /etc/hosts type ext4 (rw,relatime,data=ordered)
devpts on /dev/console type devpts (rw,nosuid,noexec,relatime,…,ptmxmode=000)
proc on /proc/sys type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/sysrq-trigger type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/irq type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/bus type proc (ro,nosuid,nodev,noexec,relatime)
tmpfs on /proc/kcore type tmpfs (rw,nosuid,mode=755)
root@ebc8cf2d8523:/#
注意

当你看到任何类似root@hashID的提示示例时,意味着你正在容器内运行命令,而不是在本地主机上。

有时容器将配置为使用不同的主机名(例如,在 CLI 上使用--name参数),但默认情况下是容器 ID 的哈希值。

使用--user选项可以更改容器内部使用的用户,但默认情况下将使用root用户。

容器中有很多绑定挂载,但在这种情况下,我们对此感兴趣:

/dev/sda9 on /etc/hostname type ext4 (rw,relatime,data=ordered)

虽然每个容器的设备编号都不同,但我们关心的部分是挂载点为/etc/hostname。这将容器的/etc/hostname链接到 Docker 为容器准备的主机名文件,默认情况下包含容器的 ID,并且没有完全合格的域名。

我们可以通过运行以下命令在容器中检查这一点:

root@ebc8cf2d8523:/# hostname -f
ebc8cf2d8523
root@ebc8cf2d8523:/# exit
注意

完成后不要忘记通过exit命令退出容器 Shell 返回本地主机。

要特别设置主机名,我们可以使用--hostname参数传递更具体的值:

$ docker container run --rm -ti --hostname="mycontainer.example.com" \
    ubuntu:latest /bin/bash

然后,在容器内部,我们将看到完全合格的主机名定义如请求:

root@mycontainer:/# hostname -f
mycontainer.example.com
root@mycontainer:/# exit

域名服务

就像 /etc/hostname 一样,配置域名服务(DNS)解析的 resolv.conf 文件是通过主机和容器之间的绑定挂载来管理的:

/dev/sda9 on /etc/resolv.conf type ext4 (rw,relatime,data=ordered)
注意

您可以在 在线 找到有关 resolve.conf 文件的详细信息。

默认情况下,这是 Docker 主机的 resolv.conf 文件的精确副本。如果您不希望这样,可以在容器中使用 --dns--dns-search 参数的组合来覆盖此行为:

$ docker container run --rm -ti --dns=8.8.8.8 --dns=8.8.4.4 \
    --dns-search=example1.com --dns-search=example2.com \
    ubuntu:latest /bin/bash
注意

如果您希望完全不设置搜索域,那么可以使用 --dns-search=.

在容器内部,您仍然会看到一个绑定挂载,但文件内容不再反映主机的 resolv.conf;相反,它现在看起来是这样的:

root@0f887071000a:/# more /etc/resolv.conf
nameserver 8.8.8.8
nameserver 8.8.4.4
search example1.com example2.com
root@0f887071000a:/# exit

MAC 地址

您可以配置容器的媒体访问控制(MAC)地址,这也是另一个重要的信息。

没有任何配置时,容器将获得一个以 02:42:ac:11 前缀开头的计算出的 MAC 地址。

如果您需要明确将其设置为一个值,可以运行类似这样的命令:

$ docker container run --rm -ti --mac-address="a2:11:aa:22:bb:33" \
  ubuntu:latest /bin/bash

通常情况下,您不需要这样做。但有时您希望为您的容器保留一组特定的 MAC 地址,以避免与使用与 Docker 相同的私有块的其他虚拟化层冲突。

警告

在自定义 MAC 地址设置时要非常小心。如果两个系统广播相同的 MAC 地址,可能会导致网络上的 ARP 冲突。如果您确实有这样的强烈需求,请尽量将本地管理的地址范围保持在一些官方范围内,例如 x2-xx-xx-xx-xx-xxx6-xx-xx-xx-xx-xxxA-xx-xx-xx-xx-xxxE-xx-xx-xx-xx-xx(其中 x 是任何有效的十六进制字符)。

存储卷

有时,分配给容器的默认磁盘空间或容器的临时性质并不适合手头的工作,因此您需要一种可以在容器部署之间持久存在的存储。

警告

通常不建议从 Docker 主机挂载存储,因为这会将您的容器与特定的 Docker 主机绑定在一起以获取其持久状态。但对于临时缓存文件或其他半临时状态的情况,这是有道理的。

在这种情况下,您可以利用 --mount/-v 命令将主机服务器上的目录和单个文件挂载到容器中。在 --mount/-v 参数中使用完全限定路径是很重要的。以下示例将 /mnt/session_data 挂载到容器内部的 /data

$ docker container run --rm -ti \
  --mount type=bind,target=/mnt/session_data,source=/data \
  ubuntu:latest /bin/bash

root@0f887071000a:/# mount | grep data
/dev/sda9 on /data type ext4 (rw,relatime,data=ordered)
root@0f887071000a:/# exit
提示

对于绑定挂载,您可以使用 -v 参数来缩短命令。在使用 -v 参数时,您会注意到源文件和目标文件/目录之间用冒号(:)分隔。

还需注意,默认情况下挂载的卷是读写的。您可以通过在--mount参数末尾添加,readonly或在-v参数末尾添加:ro来轻松使docker挂载文件或目录为只读。

$ docker container run --rm -ti \
  -v /mnt/session_data:/data:ro \
  ubuntu:latest /bin/bash

此命令正常工作时,主机挂载点和容器内的挂载点都无需预先存在。如果主机挂载点尚不存在,则将其创建为目录。如果您尝试指向文件而不是目录,则可能会遇到问题。

在挂载选项中,可以看到文件系统像预期的那样以读写方式挂载到/data

如果容器应用程序设计为写入/data,那么这些数据将在/mnt/session_data中对主机文件系统可见,并且在停止此容器并使用相同卷挂载启动新容器时,这些数据仍然可用。

可以告诉 Docker,容器的根卷应该以只读方式挂载,这样容器内的进程就无法向根文件系统写入任何内容。这可以防止像日志文件这样的东西在生产环境中填满容器分配的磁盘,开发人员可能对此不了解。与挂载卷一起使用时,可以确保数据仅写入到预期位置。

在上一个示例中,我们只需在命令中添加--read-only=true即可实现此目的:

$ docker container run --rm -ti --read-only=true -v /mnt/session_data:/data \
    ubuntu:latest /bin/bash

root@df542767bc17:/# mount | grep " / "
overlay on / type overlay (ro,relatime,lowerdir=…,upperdir=…,workdir=…)
root@df542767bc17:/# mount | grep data
/dev/sda9 on /data type ext4 (rw,relatime,data=ordered)
root@df542767bc17:/# exit

如果仔细查看根目录的挂载选项,您会注意到它们以ro选项挂载,这使其为只读。但是,/session_data挂载仍然以rw选项挂载,以便我们的应用程序可以成功写入其设计为写入的一个卷。

有时需要将/tmp等目录设置为可写,即使容器的其余部分是只读的。对于这种用例,您可以使用docker container run命令中的--mount type=tmpfs参数,以便将tmpfs文件系统挂载到容器中。tmpfs文件系统完全存储在内存中。它们非常快,但也是临时的,并且会利用额外的系统内存。这些tmpfs目录中的任何数据在停止容器时都会丢失。以下示例显示使用 256 MB tmpfs文件系统在/tmp处挂载容器:

$ docker container run --rm -ti --read-only=true \
  --mount type=tmpfs,destination=/tmp,tmpfs-size=256M \
  ubuntu:latest /bin/bash

root@25b4f3632bbc:/# df -h /tmp
Filesystem      Size  Used Avail Use% Mounted on
tmpfs           256M     0  256M   0% /tmp
root@25b4f3632bbc:/# grep /tmp /etc/mtab
tmpfs /tmp tmpfs rw,nosuid,nodev,noexec,relatime,size=262144k 0 0
root@25b4f3632bbc:/# exit
警告

尽可能设计容器为无状态是很重要的。管理存储会创建不必要的依赖关系,并且可以使部署场景变得更加复杂。

资源配额

当人们讨论在云中工作时必须经常应对的问题类型时,“喧闹的邻居”通常是问题列表中的前几位。这个术语所指的基本问题是,与您的物理系统上运行的其他应用程序相比,它们对您的性能和资源可用性可能会产生显著影响。

虚拟机的优点在于您可以轻松而且非常严格地控制分配给虚拟机的内存、CPU 等资源。当使用 Docker 时,您必须利用 Linux 内核中的 cgroup 功能来控制可用于 Linux 容器的资源。docker container createdocker container run命令直接支持在创建容器时配置 CPU、内存、交换空间和存储 I/O 限制。

注意

约束通常在创建容器时应用。如果您需要更改它们,可以使用docker container update命令或部署一个具有调整的新容器。

这里有一个重要的警告。虽然 Docker 支持各种资源限制,但您必须在内核中启用这些功能,以便 Docker 利用它们。您可能需要在启动时将这些功能添加为命令行参数到您的内核中。要确定您的内核是否支持这些限制,请运行docker system info。如果您缺少任何支持,底部将会收到警告信息,例如:

WARNING: No swap limit support
注意

关于为您的内核配置 cgroup 支持的详细信息因发行版而异,因此如果您需要帮助配置,请参阅Docker 文档^(3)。

CPU 份额

Docker 有几种方法可以限制容器中应用程序的 CPU 使用率。最初的方法仍然广泛使用,称为CPU shares。我们还将介绍其他选项。

系统中所有 CPU 核心的计算能力被视为完整的份额池。Docker 分配数字 1024 来表示完整的池。通过配置容器的 CPU 份额,您可以决定容器可以使用 CPU 的时间。如果您希望容器最多使用系统计算能力的一半,则分配 512 份额。这些不是排他性份额,这意味着将所有 1024 份额分配给一个容器并不会阻止其他所有容器运行。而是一个提示调度程序关于每次调度时每个容器应该运行多长时间的提示。如果我们有一个分配了 1024 份额(默认)的容器和两个分配了 512 份额的容器,它们将同样次数被调度。但是如果每个进程的正常 CPU 时间为 100 微秒,那么分配 512 份额的容器每次运行 50 微秒,而分配 1024 份额的容器每次运行 100 微秒。

让我们稍微探讨一下这在实践中是如何运作的。在接下来的例子中,我们将使用一个包含stress命令的新 Docker 镜像来推动系统的极限。

当我们在没有 cgroup 约束的情况下运行stress时,它将使用我们指定的所有资源。以下命令通过创建两个 CPU 绑定进程、一个 I/O 绑定进程和两个内存分配进程来创建大约五的负载平均数。在所有以下示例中,我们都在一个拥有两个 CPU 的系统上运行。

请注意,在以下命令中,容器映像名称之后的所有内容都与stress命令相关,而不是docker命令:

$ docker container run --rm -ti spkane/train-os \
  stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 120s
警告

这应该是在任何现代计算机系统上运行的合理命令,但请注意,它将会对主机系统造成压力。因此,请不要在无法承受额外负载甚至可能由于资源匮乏导致的可能故障的地方执行此操作。

如果您在 Docker 主机上运行tophtop命令,在两分钟运行结束时,您可以看到由stress程序创建的负载对系统的影响:

$ top -bn1 | head -n 15
top - 20:56:36 up 3 min,  2 users,  load average: 5.03, 2.02, 0.75
Tasks:  88 total,   5 running,  83 sleeping,   0 stopped,   0 zombie
%Cpu(s): 29.8 us, 35.2 sy, 0.0 ni, 32.0 id, 0.8 wa, 1.6 hi, 0.6 si, 0.0 st
KiB Mem:   1021856 total,   270148 used,   751708 free,    42716 buffers
KiB Swap:        0 total,        0 used,        0 free.    83764 cached Mem

 PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
 810 root      20   0    7316     96      0 R  44.3  0.0   0:49.63 stress
 813 root      20   0    7316     96      0 R  44.3  0.0   0:49.18 stress
 812 root      20   0  138392  46936    996 R  31.7  4.6   0:46.42 stress
 814 root      20   0  138392  22360    996 R  31.7  2.2   0:46.89 stress
 811 root      20   0    7316     96      0 D  25.3  0.0   0:21.34 stress
 1 root      20   0  110024   4916   3632 S   0.0  0.5   0:07.32 systemd
 2 root      20   0       0      0      0 S   0.0  0.0   0:00.04 kthreadd
 3 root      20   0       0      0      0 S   0.0  0.0   0:00.11 ksoftir…
注意

在非 Linux 系统上使用 Docker Desktop 的用户可能会发现 Docker 已将 VM 文件系统设置为只读,并且不包含许多用于监视 VM 的实用工具。对于这些演示,您希望能够监视各种进程的资源使用情况,可以通过执行类似以下操作来解决:

$ docker container run --rm -it --pid=host alpine sh
/ # apk update
/ # apk add htop
/ # htop -p $(pgrep stress | tr '\n' ',')
/ # exit

注意,在启动htop时,前面的htop命令会报错,除非在启动htop时正在运行stress,因为pgrep命令不会返回任何进程。

每次运行新的stress实例时,您也需要退出并重新运行htop

如果您想再次运行相同的stress命令,并且只使用半数可用的 CPU 时间,可以像这样操作:

$ docker container run --rm -ti --cpu-shares 512 spkane/train-os \
  stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 120s

--cpu-shares 512是执行魔术的标志,为该容器分配了 512 个 CPU 分享。在不是非常繁忙的系统上,可能看不到这个参数的效果。这是因为除非系统资源受到限制,否则容器在有工作要做时会继续被调度为相同的时间片长度。因此,在我们的情况下,在主机系统上运行top命令的结果可能看起来相同,除非您运行了更多的容器来给 CPU 其他事务处理。

警告

与虚拟机不同,Docker 基于 cgroup 的 CPU 分享限制可能会产生意想不到的后果。它们不是硬限制;它们是相对限制,类似于nice命令。例如,一个容器被限制为一半的 CPU 分享,但在一个不是很忙碌的系统上。由于 CPU 不忙,对 CPU 分享的限制只会有限的影响,因为调度池中没有竞争。当部署第二个使用大量 CPU 的容器到同一系统时,突然间第一个容器的限制效果就会显现出来。在约束容器和分配资源时要仔细考虑这一点。

CPU 固定

也可以将容器固定到一个或多个 CPU 核心上。这意味着此容器的工作仅在已分配给此容器的核心上调度。如果您想要在应用程序之间硬分配 CPU,或者如果您有需要固定到特定 CPU 的应用程序,比如缓存效率等,这是非常有用的。

在以下示例中,我们正在运行一个固定到两个 CPU 中的第一个 CPU 核心的 stress 容器,并设置了 512 CPU 分享:

$ docker container run --rm -ti \
  --cpu-shares 512 --cpuset-cpus=0 spkane/train-os \
  stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 120s
警告

--cpuset-cpus 参数是从零开始索引的,因此您的第一个 CPU 核心是 0。如果您告诉 Docker 使用主机系统上不存在的 CPU 核心,您将收到 Cannot start container 错误。在一个双 CPU 示例主机上,您可以通过使用 --cpuset-cpus=0-2 进行测试。

如果您再次运行 top 命令,您应该注意到用户空间中 CPU 时间百分比(us)比以前低,因为我们已将两个 CPU 密集型进程限制到一个单独的 CPU 上:

%Cpu(s): 18.5 us, 22.0 sy, 0.0 ni, 57.6 id, 0.5 wa, 1.0 hi, 0.3 si, 0.0 st
注意

当您使用 CPU pinning 时,容器上的额外 CPU 共享限制仅考虑运行在相同核心集上的其他容器。

使用 Linux 内核中的 CPU 完全公平调度器(CFS),您可以通过在使用 docker container run 启动容器时将 --cpu-quota 标志设置为有效值来改变给定容器的 CPU 配额。

简化 CPU 配额

虽然 CPU 分享是 Docker 中管理 CPU 限制的原始机制,但 Docker 自进化以来已经有了很大发展,现在它使用户生活更轻松的一种方式是极大地简化了如何设置 CPU 配额。现在,您不再需要自己尝试设置正确的 CPU 分享和配额,只需告诉 Docker 您希望容器可用多少 CPU,它将执行必要的数学运算来正确设置底层的 cgroups。

--cpus 命令可以设置为介于 0.01 和 Docker 服务器上 CPU 核心数之间的浮点数:

$ docker container run --rm -ti --cpus=".25" spkane/train-os \
  stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 60s

如果您尝试设置一个过高的值,您将收到 Docker(而不是 stress 应用程序)的错误消息,该消息将给出您必须使用的正确 CPU 核心范围:

$ docker container run --rm -ti --cpus="40.25" spkane/train-os \
  stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 60s
docker: Error response from daemon: Range of CPUs is from
 0.01 to 4.00, as there are only 4 CPUs available.
See 'docker container run --help'.

docker container update 命令可用于动态调整一个或多个容器的资源限制。例如,您可以同时调整两个容器的 CPU 分配,如下所示:

$ docker container update --cpus="1.5" 092c5dc85044 92b797f12af1
提示

Docker 与 Linux 相同视 CPU。超线程和核心由 Linux 解释,并通过特殊文件 /proc/cpuinfo 公开。当您在 Docker 中使用 --cpus 命令时,您指的是希望容器访问的此文件中的条目数,无论它们是指标准核心还是超线程核心。

内存

我们可以控制容器可以访问的内存量,方式与限制 CPU 类似。然而,有一个基本区别:尽管限制 CPU 只影响应用程序对 CPU 时间的优先级,但内存限制是一个限制。即使在一个没有限制的系统上,有 96 GB 的空闲内存,如果我们告诉一个容器只能访问 24 GB,那么它只能使用 24 GB,而不管系统上的空闲内存有多少。由于 Linux 上虚拟内存系统的工作方式,可以为容器分配比系统实际 RAM 更多的内存。在这种情况下,容器将像普通的 Linux 进程一样使用交换空间。

让我们通过将--memory选项传递给docker container run命令来启动一个带有内存约束的容器:

$ docker container run --rm -ti --memory 512m spkane/train-os \
  stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 10s

当您仅使用--memory选项时,您正在设置容器将访问的 RAM 和交换空间量。因此,通过在这里使用--memory 512m,我们将容器限制为 512 MB 的 RAM 和 512 MB 的额外交换空间。Docker 支持bkmg,分别表示字节、千字节、兆字节或千兆字节。如果您的系统在某种方式下运行 Linux 和 Docker,并且具有多个 TB 的内存,则不幸的是,您将不得不以 GB 为单位指定它。

如果您希望单独设置交换空间或完全禁用它,则还需要使用--memory-swap选项。这定义了容器可用的总内存和交换空间量。如果我们重新运行以前的命令,如下所示:

$ docker container run --rm -ti --memory 512m --memory-swap=768m \
    spkane/train-os stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M \
    --timeout 10s

然后我们告诉内核,此容器可以访问 512 MB 的内存和 256 MB 的额外交换空间。将--memory-swap选项设置为-1将允许容器使用底层系统上可用的所有交换空间,并且如果--memory-swap--memory设置为相同的正值,则容器将无法访问交换空间。

警告

与 CPU 份额不同,内存是一个硬限制!这很好,因为约束对容器的影响不会突然在系统部署另一个容器时显现。但这意味着您需要小心,确保限制与容器的需求紧密匹配,因为没有任何余地。内存不足的容器会导致内核表现得就像系统内存不足一样。它将尝试找到一个进程以杀死它,以便释放空间。这是一个常见的失败案例,其中容器的内存限制设置得太低。此问题的显著标志是容器退出码为 137,以及在 Docker 服务器的dmesg输出中看到的内核内存不足(OOM)消息。

那么,如果容器达到其内存限制会发生什么?好吧,让我们试试修改我们以前的某个命令,并显著降低内存:

$ docker container run --rm -ti --memory 100m spkane/train-os \
  stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 10s

虽然我们的其他stress容器运行结束时会显示如下一行:

stress: info: [17] successful run completed in 10s

我们看到,此次运行迅速失败,并显示类似于以下内容的行:

stress: FAIL: [1] (451) failed run completed in 0s

这是因为容器尝试分配超出允许的内存,Linux 的 OOM 杀手被调用并开始终止 cgroup 内的进程以释放内存。在此情况下,我们的容器具有一个单父进程生成了几个子进程,当 OOM 杀手终止其中一个子进程时,父进程将清理所有内容并以错误退出。

警告

Docker 具有一些功能,允许您通过使用docker container run命令的--oom-kill-disable--oom-score-adj参数来调整和禁用 Linux 的 OOM 杀手,但几乎不推荐用于任何用例。

如果您访问 Docker 服务器,可以通过运行dmesg查看与此事件相关的内核消息。输出将类似于以下内容:

[ 4210.403984] stress invoked oom-killer: gfp_mask=0x24000c0 …
[ 4210.404899] stress cpuset=5bfa65084931efabda59d9a70fa8e88 …
[ 4210.405951] CPU: 3 PID: 3429 Comm: stress Not tainted 4.9 …
[ 4210.406624] Hardware name:   BHYVE, BIOS 1.00 03/14/2014
…
[ 4210.408978] Call Trace:
[ 4210.409182]  [<ffffffff94438115>] ? dump_stack+0x5a/0x6f
….
[ 4210.414139]  [<ffffffff947f9cf8>] ? page_fault+0x28/0x30
[ 4210.414619] Task in /docker-ce/docker/5…3
killed as a result of limit of /docker-ce/docker/5…3
[ 4210.416640] memory: usage 102380kB, limit 102400kB, failc …
[ 4210.417236] memory+swap: usage 204800kB, limit 204800kB,  …
[ 4210.417855] kmem: usage 1180kB, limit 9007199254740988kB, …
[ 4210.418485] Memory cgroup stats for /docker-ce/docker/5…3:
cache:0KB rss:101200KB rss_huge:0KB mapped_file:0KB dirty:0KB
writeback:11472KB swap:102420KB inactive_anon:50728KB
active_anon:50472KB inactive_file:0KB active_file:0KB unevictable:0KB
…
[ 4210.426783] Memory cgroup out of memory: Kill process 3429…
[ 4210.427544] Killed process 3429 (stress) total-vm:138388kB,
anon-rss:44028kB, file-rss:900kB, shmem-rss:0kB
[ 4210.442492] oom_reaper: reaped process 3429 (stress), now
anon-rss:0kB, file-rss:0kB, shmem-rss:0kB

此 OOM 事件也将由 Docker 记录,并可以通过docker system events查看:

$ docker system events
2018-01-28T15:56:19.972142371-08:00 container oom \
 d0d803ce32c4e86d0aa6453512a9084a156e96860e916ffc2856fc63ad9cf88b \
 (image=spkane/train-os, name=loving_franklin)

块 I/O

许多容器只是无状态应用程序,并且不需要块 I/O 限制。但是,Docker 还通过 cgroups 机制支持几种不同的限制块 I/O 的方式。

第一种方法是对容器的块设备 I/O 使用应用一些优先级。您可以通过操纵blkio.weight cgroup 属性的默认设置来启用此功能。此属性可以设置为 0(禁用)或介于 10 和 1,000 之间的数字,默认为 500。此限制有点像 CPU 份额,系统将所有可用的 I/O 分割为每个 cgroup 切片内的每个进程/容器的权重总和除以 1,000,并根据分配的权重决定每个进程/容器可用的 I/O 量。

要在容器上设置此权重,您需要通过docker container run命令传递--blkio-weight参数,并指定有效值。您还可以使用--blkio-weight-device选项针对特定设备进行设置。

与 CPU 份额一样,实际调整权重是难以正确实现的,但通过限制容器通过其 cgroup 可用的每秒最大字节数或操作数,我们可以大大简化这一过程。以下设置使我们能够控制:

--device-read-bps     Limit read rate (bytes per second) from a device
--device-read-iops    Limit read rate (IO per second) from a device
--device-write-bps    Limit write rate (bytes per second) to a device
--device-write-iops   Limit write rate (IO per second) to a device

您可以通过运行以下一些使用 Linux I/O 测试工具bonnie的命令来测试这些对容器性能的影响:

$ time docker container run --rm -ti spkane/train-os:latest bonnie++ \
    -u 500:500 -d /tmp -r 1024 -s 2048 -x 1
…
real  0m27.715s
user  0m0.027s
sys   0m0.030s

$ time docker container run -ti --rm --device-write-iops /dev/vda:256 \
    spkane/train-os:latest bonnie++ -u 500:500 -d /tmp -r 1024 -s 2048 -x 1
…
real  0m58.765s
user  0m0.028s
sys   0m0.029s

$ time docker container run -ti --rm --device-write-bps /dev/vda:5mb \
    spkane/train-os:latest bonnie++ -u 500:500 -d /tmp -r 1024 -s 2048 -x 1
…
提示

PowerShell 用户应该能够使用Measure-Command函数来替代这些示例中使用的 Unix time命令。

根据我们的经验,--device-read-iops--device-write-iops参数是设置块 I/O 限制的最有效方式,也是我们推荐的方式。

ulimits

在 Linux cgroups 出现之前,有另一种方法可以对进程可用的资源施加限制:通过ulimit命令应用用户限制。这种机制仍然可用,并且对于传统上使用它的所有用例仍然很有用。

下面的代码列出了通过设置软限制和硬限制可以通常约束的系统资源类型,通过ulimit命令:

$ ulimit -a
core file size (blocks, -c) 0
data seg size (kbytes, -d) unlimited
scheduling priority (-e) 0
file size (blocks, -f) unlimited
pending signals (-i) 5835
max locked memory (kbytes, -l) 64
max memory size (kbytes, -m) unlimited
open files (-n) 1024
pipe size (512 bytes, -p) 8
POSIX message queues (bytes, -q) 819200
real-time priority (-r) 0
stack size (kbytes, -s) 10240
cpu time (seconds, -t) unlimited
max user processes (-u) 1024
virtual memory (kbytes, -v) unlimited
file locks (-x) unlimited

可以配置 Docker 守护程序以应用您希望应用于每个容器的默认用户限制。以下命令告诉 Docker 守护程序以 50 个打开文件的软限制和 150 个打开文件的硬限制启动所有容器:

$ sudo dockerd --default-ulimit nofile=50:150

然后,您可以通过使用--ulimit参数传递值来覆盖特定容器上的这些 ulimits:

$ docker container run --rm -d --ulimit nofile=150:300 nginx

在创建容器时,还有一些其他高级命令可供使用,但这涵盖了许多更常见的用例。Docker 客户端文档列出了所有可用选项,并且在每个 Docker 发布中都会更新。

启动容器

在深入讨论容器和约束细节之前,我们使用docker container create命令创建了我们的容器。该容器只是闲置在那里,没有做任何事情。有一个配置但没有运行的进程。当我们准备启动容器时,可以使用docker container start命令来启动。

假设我们需要运行 Redis 的副本,这是一个常见的键/值存储。我们不会对这个 Redis 容器做任何操作,但它是一个轻量级的长期运行进程,并且作为我们在实际环境中可能进行的操作的示例。我们可以首先创建容器:

$ docker container create -p 6379:6379 redis:2.8
Unable to find image 'redis:7.0' locally
7.0: Pulling from library/redis
3f4ca61aafcd: Pull complete
…
20bf15ad3c24: Pull complete
Digest: sha256:8184cfe57f205ab34c62bd0e9552dffeb885d2a7f82ce4295c0df344cb6f0007
Status: Downloaded newer image for redis:7.0
092c5dc850446324e4387485df7b76258fdf9ed0aedcd53a37299d35fc67a042

命令的结果是一些输出,其中最后一行是为容器生成的完整哈希值。我们可以使用这个长哈希来启动它,但如果我们没有记下它,我们也可以使用以下命令列出系统上的所有容器,无论它们是否正在运行:

$ docker container ls -a --filter ancestor=redis:2.8
CONTAINER ID IMAGE     COMMAND                CREATED        … NAMES
092c5dc85044 redis:7.0 "docker-entrypoint.s…" 46 seconds ago elegant_wright

我们可以通过按照我们使用的镜像过滤输出并检查容器的创建时间来确认我们容器的身份。然后,我们可以使用以下命令启动容器:

$ docker container start 092c5dc85044
注意

大多数 Docker 命令将使用容器名称、完整哈希、短哈希,甚至只需要足够的哈希来使其唯一。在上一个示例中,容器的完整哈希是092c5dc850446324e…a37299d35fc67a042,但大多数命令输出中显示的短哈希是092c5dc85044。这个短哈希由完整哈希的前 12 个字符组成。在前一个示例中,运行docker container start 6b7也可以正常工作。

应该已经启动了容器,但由于其在后台运行,我们不一定知道是否出了问题。为了验证它是否在运行,我们可以运行以下命令:

$ docker container ls
CONTAINER ID  IMAGE      COMMAND                …  STATUS       …
092c5dc85044  redis:7.0  "docker-entrypoint.s…" …  Up 2 minutes …

然后,就是它:如预期运行。我们可以通过状态显示 Up 以及容器运行的时间长短来判断。

自动重新启动容器

在许多情况下,我们希望容器在退出后重新启动。某些容器生命周期非常短暂,快速启动和停止。但是对于生产应用程序来说,例如,您希望告诉它们运行后始终处于运行状态。如果您运行的是更复杂的系统,调度程序可能会为您执行此操作。

在简单情况下,我们可以通过将 --restart 参数传递给 docker container run 命令来告诉 Docker 代表我们管理重启。它接受四个值:noalwayson-failureunless-stopped。如果将 restart 设置为 no,则容器在退出后永远不会重新启动。如果设置为 always,则容器在退出时将无条件重新启动,不考虑退出代码。如果 restart 设置为 on-failure,则每当容器以非零退出代码退出时,Docker 将尝试重新启动容器。如果将 restart 设置为 on-failure:3,Docker 将尝试在放弃之前重新启动容器三次。unless-stopped 是最常见的选择,除非通过像 docker container stop 这样的方式明确停止容器,否则将重新启动容器。

我们可以通过重新运行上次的内存受限压力容器(不带 --rm 参数,但带 --restart 参数)来实现此目的:

$ docker container run -ti --restart=on-failure:3 --memory 100m \
  spkane/train-os stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M \
  --timeout 120s

在这个示例中,我们将看到第一次运行的输出在控制台上出现后再消失。如果容器死掉后立即运行 docker container ls,我们很可能会看到 Docker 已经重新启动了容器:

$ docker container ls
…  IMAGE           …  STATUS                …
…  spkane/train-os …  Up Less than a second …

它将继续失败,因为我们没有为其提供足够的内存以正确运行。经过三次尝试后,Docker 将放弃,我们将看到 docker container ls 输出中的容器消失。

停止容器

容器可以随时停止和启动。您可能会认为启动和停止容器类似于暂停和恢复正常进程,但实际上并非完全相同。停止时,进程不是暂停状态,而是退出状态。当容器停止时,它将不再显示在正常的 docker container ls 输出中。在重新启动时,Docker 将尝试启动关闭时运行的所有容器。如果需要阻止容器继续执行任何额外工作而不实际停止进程,则可以使用 docker container pauseunpause 暂停 Linux 容器,稍后将更详细地讨论。现在,让我们停止一下之前启动的 Redis 容器:

$ docker container stop 092c5dc85044
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES

现在我们已经停止了容器,在运行的容器列表中什么都没有了!我们可以通过容器 ID 将其重新启动,但记住这点会很不方便。因此,docker container ls 还有一个附加选项 (-a),显示所有容器,而不仅仅是运行中的容器:

$ docker container ls -a
CONTAINER ID  IMAGE     STATUS                   …
092c5dc85044  redis:7.0 Exited (0) 2 minutes ago …
…

现在STATUS字段显示我们的容器以状态码 0(无错误)退出。我们可以使用相同的配置重新启动它:

$ docker container start 092c5dc85044
092c5dc85044

$ docker container ls -a
CONTAINER ID  IMAGE     STATUS        …
092c5dc85044  redis:7.0 Up 14 seconds …
…

啊,我们的容器已经重新启动并配置好了,就像之前一样。

注意

请记住,即使未启动,容器作为 Docker 系统中的一块配置存在。这意味着只要容器未被删除,您可以重新启动它而无需重新创建它。尽管内存和临时文件系统(tmpfs)内容已丢失,但容器的所有其他文件系统内容和元数据,包括环境变量和端口绑定,在重新启动容器时仍然保存并将保持不变。

到目前为止,我们可能已经大谈特谈过容器只是与服务器上的任何其他进程基本相同地交互的进程树的概念。但在这里再次指出这一点是很重要的,因为这意味着我们可以向容器中的进程发送 Unix 信号,然后它们可以响应。在前面的docker container stop示例中,我们发送给容器一个SIGTERM信号,并等待容器正常退出。容器遵循与 Linux 上任何其他进程组接收到的相同的进程组信号传播。

正常情况下,docker container stop会发送SIGTERM给进程。如果你想在一定时间后强制终止容器,可以使用-t参数,像这样:

$ docker container stop -t 25 092c5dc85044

这告诉 Docker 首先像以前一样发送SIGTERM信号,但如果容器在 25 秒内未停止(默认为 10 秒),则告诉 Docker 发送SIGKILL信号来强制终止它。

尽管stop是关闭容器的最佳方式,但有时它不起作用,你需要强制结束容器,就像你可能需要对容器外的任何进程做的那样。

终止容器

当一个进程表现不良时,docker container stop可能无法解决问题。你可能希望容器立即退出。

在这些情况下,您可以使用docker container kill。正如您所期望的那样,它看起来与docker container stop非常相似:

$ docker container start 092c5dc85044
092c5dc85044

$ docker container kill 092c5dc85044
092c5dc85044

现在,docker container ls命令显示容器已停止运行,正如预期的那样:

$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES

只因为它被杀死而不是停止,并不意味着你不能再次启动它。你可以像对待一个正常停止的容器一样,发出docker container start命令。有时候,你可能想向容器发送另一个信号,而不是stopkill。与 Linux 的kill命令一样,docker container kill支持发送任何 Unix 信号。假设我们想发送一个USR1信号给我们的容器,告诉它执行一些像重新连接远程日志会话之类的操作。我们可以这样做:

$ docker container start 092c5dc85044
092c5dc85044

$ docker container kill --signal=USR1 092c5dc85044
092c5dc85044

如果我们的容器进程设计为使用USR1信号执行某些操作,现在将执行该操作。可以使用此方法向容器发送任何标准 Unix 信号。

暂停和取消暂停容器

有几个原因可能不希望完全停止容器。我们可能希望将其暂停,保留其分配的资源,并在进程表中保留其条目。这可能是因为我们正在对其文件系统进行快照以创建新镜像,或者只是因为我们需要主机上一些 CPU 一段时间。如果您习惯于正常的 Unix 进程处理方式,您可能会想知道这是如何工作的,因为容器化的进程只是进程。

暂停利用cgroup freezer,基本上只是阻止进程被调度,直到您取消冻结。这将防止容器执行任何操作,同时保持其整体状态,包括内存内容。与停止容器不同,停止时进程会通过SIGSTOP信号得知它们正在停止,而暂停容器不会向容器发送任何关于其状态变化的信息。这是一个重要的区别。几个 Docker 命令也会在内部使用暂停和取消暂停。以下是如何暂停容器:

$ docker container start 092c5dc85044
092c5dc85044

$ docker container pause 092c5dc85044
092c5dc85044
注意

若要在 Windows 中暂停和取消暂停容器,必须使用 Hyper-V 或 WSL2 作为底层虚拟化技术。

如果查看正在运行的容器列表,现在可以看到 Redis 容器状态显示为(Paused)

$ docker container ls
CONTAINER ID  IMAGE     … STATUS                  …
092c5dc85044  redis:7.0 … Up 25 seconds (Paused)  …

尝试在此暂停状态下使用容器将失败。它存在,但没有任何运行中的内容。现在我们可以使用docker container unpause命令恢复容器:

$ docker container unpause 092c5dc85044
092c5dc85044

$ docker container ls
CONTAINER ID  IMAGE     … STATUS        …
092c5dc85044  redis:7.0 … Up 55 seconds …

现在恢复运行,并且docker container ls正确地反映了新状态。现在它显示Up 55 seconds,因为即使容器处于暂停状态,Docker 仍然认为它在运行。

清理容器和镜像

在运行所有这些命令来构建镜像、创建容器并运行它们之后,我们在系统上积累了大量的镜像层和容器文件夹。

我们可以使用docker container ls -a命令列出系统上的所有容器,然后删除列表中的任何容器。在删除镜像本身之前,必须停止使用该镜像的所有容器。假设我们已经完成了这些操作,可以使用docker container rm命令删除它:

$ docker container stop 092c5dc85044
092c5dc85044ls

$ docker container rm 092c5dc85044
092c5dc85044
注意

使用docker container rm命令和-f--force标志可以删除正在运行的容器。

我们可以通过以下方式列出系统上的所有镜像:

$ docker image ls
REPOSITORY       TAG     IMAGE ID      CREATED       SIZE
ubuntu           latest  5ba9dab47459  3 weeks ago   188.3MB
redis            7.0     0256c63af7db  2 weeks ago   117MB
spkane/train-os  latest  78fb082a4d65  4 months ago  254MB

然后,我们可以通过运行以下命令来删除一个镜像及其所有相关的文件系统层:

$ docker image rm 0256c63af7db
警告

如果尝试删除正在容器中使用的镜像,将会收到Conflict, cannot delete错误。您应该先停止和删除容器。

有时,在开发周期中特别是在完全清除系统中所有镜像或容器时,这是有意义的。运行 docker system prune 命令是最简单的方法:

$ docker system prune
WARNING! This will remove:
 - all stopped containers
 - all networks not used by at least one container
 - all dangling images
 - all build cache
Are you sure you want to continue? [y/N] y
Deleted Containers:
cbbc42acfe6cc7c2d5e6c3361003e077478c58bb062dd57a230d31bcd01f6190
…
Deleted Images:
deleted: sha256:bec6ec29e16a409af1c556bf9e6b2ec584c7fb5ffbfd7c46ec00b30bf …
untagged: spkane/squid@sha256:64fbc44666405fd1a02f0ec731e35881465fac395e7 …
…
Total reclaimed space: 1.385GB
提示

若要删除所有未使用的镜像,而不仅仅是悬空的镜像,请尝试 docker system prune -a

也可以编写更具体的命令来实现类似的目标。

若要删除 Docker 主机上的所有容器,请使用以下命令:

$ docker container rm $(docker container ls -a -q)

要删除 Docker 主机上的所有镜像,可以使用以下命令完成任务:

$ docker image rm $(docker images -q)

docker container lsdocker images 命令都支持 filter 参数,可以轻松调整删除命令以适应特定情况。

若要删除所有退出状态为非零的容器,可以使用此过滤器:

$ docker container rm $(docker container ls -a -q --filter 'exited!=0')

若要删除所有未标记的镜像,可以键入以下内容:

$ docker image rm $(docker images -q -f "dangling=true")
注意

您可以阅读 官方 Docker 文档 来探索过滤选项。目前,可以选择的过滤器非常少,但随着时间的推移可能会增加更多过滤器。

您还可以通过使用管道(|)和其他类似技术将命令串联起来,制作自己非常创造性的过滤器。

在经常部署的生产系统中,有时会出现旧容器或未使用的镜像仍然存在并占用磁盘空间的情况。将 docker system prune 命令脚本化以按计划运行(例如在 cron 下运行或通过 systemd 定时器运行)可能会很有用。

Windows 容器

到目前为止,我们完全专注于 Linux 容器的 Docker 命令,因为这是最常见的用例,并在所有 Docker 平台上都能工作。然而,自 2016 年以来,Microsoft Windows 平台已经支持运行包括本机 Windows 应用程序的 Windows 容器,并可以通过常规的 Docker 命令集进行管理。

本书不专注于 Windows 容器,因为它们在生产容器中仍然只占很小一部分,并且与 Docker 生态系统的其他部分不完全兼容,因为它们需要 Windows 特定的容器映像。然而,它们是 Docker 世界中增长和重要的一部分,所以我们将简要介绍它们的工作原理。事实上,除了容器的实际内容之外,几乎所有其他内容都与 Linux 容器相同。在本节中,我们将快速演示如何在 Windows 10+ 上通过 Hyper-V 和 Docker 运行 Windows 容器的示例。

提示

要使此功能正常工作,您必须在兼容的 64 位版 Windows 10 或更高版本上使用 Docker Desktop。

您需要做的第一件事是将 Docker 从 Linux 容器切换到 Windows 容器。为此,请右键单击任务栏中的 Docker 鲸鱼图标,选择“切换到 Windows 容器…”,然后确认切换(见图 5-1 和 5-2)。

切换到 Windows 容器

图 5-1. 切换到 Windows 容器

切换到 Windows 容器确认

图 5-2. 切换到 Windows 容器确认

这个过程可能需要一些时间,尽管通常几乎是立即完成的。不幸的是,没有通知显示切换已完成。如果再次右键单击 Docker 图标,现在应该看到“切换到 Linux 容器…”替换了原始选项。

注意

如果第一次右键单击 Docker 图标时,它显示“切换到 Linux 容器…”,那么你已经配置为 Windows 容器。

我们可以通过打开 PowerShell^(4) 并尝试运行以下命令来测试一个简单的 Windows 容器:

PS C:\> docker container run --rm -it mcr.microsoft.com/powershell `
 pwsh -command `
 'Write-Host "Hello World from Windows `($IsWindows`)"'

Hello World from Windows (True)

这将下载并启动一个 PowerShell 基础容器,然后使用脚本打印 Hello World from Windows (True) 到屏幕上。

注意

如果前面命令的输出打印 Hello World from Windows (false),那么你还没有切换到 Windows 容器模式,或者你正在非 Windows 平台上运行此命令。

如果你想构建一个完成大致相同任务的 Windows 容器镜像,可以创建以下的 Dockerfile

# escape=`
FROM mcr.microsoft.com/powershell
SHELL ["pwsh", "-command"]

RUN Add-Content C:\helloworld.ps1 `
      'Write-Host "Hello World from Windows"'

CMD ["pwsh", "C:\\helloworld.ps1"]

当你构建这个 Dockerfile 时,它将基于 mcr.microsoft.com/ powershell 创建镜像,创建一个小的 PowerShell 脚本,然后配置镜像以在启动容器时运行该脚本。

警告

你可能注意到,在前述 DockerfileCMD 行中,我们不得不用额外的反斜杠(\)转义反斜杠。这是因为 Docker 的根源是 Unix,而反斜杠在 Unix shell 中有特殊意义。因此,尽管我们已经通过 SHELL 指令Dockerfile 的转义字符设置为与 PowerShell 默认使用的相匹配的字符,我们仍然需要转义一些反斜杠,以确保 Docker 不会误解它们。

如果现在构建这个 Dockerfile,你会看到类似于这样的结果:

PS C:\> docker image build -t windows-helloworld:latest .

Sending build context to Docker daemon  2.048kB
Step 1/4 : FROM mcr.microsoft.com/powershell
 ---> 7d8f821c04eb
Step 2/4 : SHELL ["pwsh", "-command"]
 ---> Using cache
 ---> 1987fb489a3d
Step 3/4 : RUN Add-Content C:\helloworld.ps1
 'Write-Host "Hello World from Windows"'
 ---> Using cache
 ---> 37df47d57bf1
Step 4/4 : CMD ["pwsh", "C:\\helloworld.ps1"]
 ---> Using cache
 ---> 03046ff628e4
Successfully built 03046ff628e4
Successfully tagged windows-helloworld:latest

现在,如果你运行生成的镜像,你会看到这个:

PS C:\> docker container run --rm -ti windows-helloworld:latest

Hello World from Windows

Microsoft 维护了关于 Windows 容器的良好文档^(5),其中还包括一个 构建启动 .NET 应用程序容器的示例.^(6)

提示

在 Windows 平台上,还需要了解,通过在专用且非常轻量级的 Hyper-V VM 内启动容器,可以获得改进的隔离性。你只需简单地在 docker container createdocker container run 命令中添加 --isolation=hyperv 选项即可完成此操作。尽管这样做会稍微降低性能和资源效率,但显著提高了容器的隔离性。你可以在文档中了解更多详情。

即使你计划主要使用 Windows 容器,在本书的其余部分中,为了确保所有示例的正常运行,请切换回 Linux 容器。阅读完毕并准备好开始构建容器后,你随时可以再次切换回来。

小贴士

请记住,你可以右键点击 Docker 图标,然后选择“切换到 Linux 容器…”来重新启用 Linux 容器。

总结

在下一章中,我们将继续探讨 Docker 带来的内容。目前,值得进行一些自己的实验。我们建议你练习一些我们在这里介绍的容器控制命令,以便熟悉命令行选项和整体语法。现在是一个很好的时机,尝试设计和构建一个小镜像,然后将其作为新容器启动。当你准备好继续时,请前往第六章!

^(1) SELinux 是当前的一种实现方式。

^(2) 通常位于 /var/lib/docker/containers 下。

^(3) 完整网址:https://docs.docker.com/engine/install/linux-postinstall/#your-kernel-does-not-support-cgroup-swap-limit-capabilities

^(4) 完整网址:https://learn.microsoft.com/en-us/powershell/scripting/overview?view=powershell-7.3&viewFallbackFrom=powershell-6

^(5) 完整网址:https://learn.microsoft.com/en-us/virtualization/windowscontainers/about

^(6) 完整网址:https://learn.microsoft.com/en-us/virtualization/windowscontainers/quick-start/building-sample-app

第六章:探索 Docker

现在您已经有了一些使用容器和镜像的经验,我们可以探索 Docker 的其他能力。在本章中,我们将继续使用 docker 命令行工具与您配置的运行中的 dockerd 服务器交互,同时介绍一些其他基本命令。

Docker 提供了几个额外的命令来轻松执行一些其他任务:

  • 打印 Docker 版本

  • 查看服务器信息

  • 下载镜像更新

  • 检查容器

  • 进入运行中的容器

  • 返回结果

  • 查看日志

  • 监控统计信息

  • 还有很多其他内容……

让我们一起查看这些以及一些增强 Docker 本地功能的额外社区工具。

打印 Docker 版本

如果您已经完成了上一章,那么您的 Linux 服务器或虚拟机上已经安装了一个可工作的 Docker 守护程序,并且您已经启动了一个基础容器来确保一切正常运行。如果您还没有完成这些设置,并且想要尝试本书剩余部分的步骤,您需要先按照第三章中的安装步骤进行设置。

使用 Docker 最简单的事情之一是打印各个组件的版本。这听起来可能不是很重要,但这是一个有用的工具,因为 Docker 是由多个组件构建而成,它们的版本直接决定了您能够使用的功能。知道如何显示版本还将帮助您解决客户端和服务器之间某些类型的连接问题。例如,Docker 客户端可能会给您一个关于不匹配 API 版本的神秘消息,了解 Docker 版本后,您就能知道哪个组件需要更新。这个命令与远程 Docker 服务器通信,所以如果客户端因任何原因无法连接服务器,客户端将报告错误,然后仅打印客户端版本信息。如果发现您遇到连接问题,您应该重新查看上一章的步骤。

注意

如果您在解决问题或者不想使用 docker 客户端连接到远程系统时,您可以直接登录到 Docker 服务器,并从服务器上的 shell 运行 docker 命令。在大多数 Docker 服务器上,这将需要 root 权限或者连接到 Docker 正在监听的 Unix 域套接字的 docker 组成员身份。

由于我们刚刚同时安装了所有 Docker 组件,当我们运行 docker version 时,我们应该看到所有版本都匹配:

$ docker version
Client:
 Cloud integration: v1.0.24
 Version:           20.10.17
 API version:       1.41
 Go version:        go1.17.11
 Git commit:        100c701
 Built:             Mon Jun  6 23:04:45 2022
 OS/Arch:           darwin/amd64
 Context:           default
 Experimental:      true

Server: Docker Desktop 4.10.1 (82475)
 Engine:
 Version:          20.10.17
 API version:      1.41 (minimum version 1.12)
 Go version:       go1.17.11
 Git commit:       a89b842
 Built:            Mon Jun  6 23:01:23 2022
 OS/Arch:          linux/amd64
 Experimental:     false
 containerd:
 Version:          1.6.6
 GitCommit:        10c12954828e7c7c9b6e0ea9b0c02b01407d3ae1
 runc:
 Version:          1.1.2
 GitCommit:        v1.1.2-0-ga916309
 docker-init:
 Version:          0.19.0
 GitCommit:        de40ad0

注意我们有不同部分表示客户端和服务器。在这种情况下,我们有一个匹配的客户端和服务器,因为我们刚刚一起安装它们。但是需要注意的是,这种情况并不总是如此。希望在您的生产系统中,您能够确保大多数系统运行相同版本。但是在开发环境和构建系统中,具有稍微不同版本的情况并不罕见。

API 客户端和库通常可以在大量 Docker 版本上工作,具体取决于它们所需的 API 版本。在Server部分中,我们可以看到当前 API 版本是 1.41,并且它将服务的最低 API 版本是 1.12。这些信息在您与第三方客户端合作时非常有用,现在您知道如何验证这些信息了。

服务器信息

通过 Docker 客户端,我们还可以了解很多关于 Docker 服务器的信息。稍后我们会详细讨论所有这些内容的含义,但是您可以了解到 Docker 服务器正在运行哪种文件系统后端,它的内核版本是多少,它运行的操作系统是什么,安装了哪些插件,正在使用哪种运行时,以及当前存储了多少容器和镜像。docker system info将为您提供类似于以下内容的信息,为了简洁起见,这里进行了缩写:

$ docker system info
Client:
…
 Plugins:
 buildx: Docker Buildx (Docker Inc., v0.8.2)
 compose: Docker Compose (Docker Inc., v2.6.1)
 extension: Manages Docker extensions (Docker Inc., v0.2.7)
 sbom: View the packaged-based Software Bill Of Materials (SBOM) …
 scan: Docker Scan (Docker Inc., v0.17.0)

Server:
 Containers: 11
…
 Images: 6
 Server Version: 20.10.17
 Storage Driver: overlay2
…
 Plugins:
 Volume: local
 Network: bridge host ipvlan macvlan null overlay
 Log: awslogs fluentd gcplogs gelf journald json-file local logentries …
…
 Runtimes: io.containerd.runc.v2 io.containerd.runtime.v1.linux runc
 Default Runtime: runc
…
 Kernel Version: 5.10.104-linuxkit
 Operating System: Docker Desktop
 OSType: linux
 Architecture: x86_64
…

根据您的 Docker 守护程序设置方式,显示可能会有所不同。不必担心这一点;这只是为了给您一个示例。在这里,我们可以看到我们的服务器是运行 5.10.104 Linux 内核和支持overlay2文件系统驱动的 Docker Desktop 版本。我们在服务器上还有一些镜像和容器。在全新安装时,这个数字应该是零。

这里需要指出插件的信息。它告诉我们这个 Docker 安装支持的所有内容。在刚安装时,情况看起来会差不多,这取决于 Docker 随附的新插件。Docker 本身由许多不同的插件组成,它们共同工作。这很强大,因为这意味着也可以安装社区成员贡献的其他几个插件。即使您只想确保 Docker 已识别最近添加的插件,了解安装了哪些插件也很有用。

在大多数安装中,/var/lib/docker 将是用于存储镜像和容器的默认根目录。如果需要更改此设置,可以编辑 Docker 启动脚本以启动守护进程,并使用--data-root参数指向新的存储位置。要手动测试这一点,可以运行类似以下命令:

$ sudo dockerd \
    -H unix:///var/run/docker.sock \
    --data-root="/data/docker"
注意

默认情况下,Docker 服务器的配置文件^(1)可以在/etc/docker/daemon.json中找到。我们讨论的大多数传递给dockerd的参数可以在这个文件中永久设置。如果你使用的是 Docker Desktop,建议你在 Docker Desktop 的用户界面中修改这个文件。

我们稍后会详细讨论运行时,但在这里你可以看到我们安装了三个运行时。runc运行时是默认的 Docker 运行时。如果你考虑 Linux 容器,你通常是在考虑runc构建的容器类型。在这台服务器上,我们还安装了io.containerd.runc.v2io.containerd.runtime.v1.linux运行时。我们将在第十一章进一步讨论一些其他运行时。

下载镜像更新

我们将在以下示例中使用一个 Ubuntu 基础镜像。即使你已经拉取过ubuntu:latest基础镜像一次,你可以再次pull它,它将自动获取自上次运行以来发布的任何更新。

这是因为latest是一个标签,按照约定应该表示容器的最新构建版本。然而,latest标签备受争议,因为它并没有永久固定到特定的镜像上,并且在不同项目中可能有不同的含义。有些人使用它指向最新的稳定版本,有些人用它指向他们 CI/CD 系统产生的最后一次构建,还有些人干脆拒绝给他们的镜像打latest标签。尽管如此,它仍然被广泛使用,并且在预生产环境中使用它的便利性超过了真实版本提供的保证的缺失:

调用docker image pull看起来是这样的:

$ docker image pull ubuntu:latest

latest: Pulling from library/ubuntu
405f018f9d1d: Pull complete
Digest: sha256:b6b83d3c331794420340093eb706a6f152d9c1fa51b262d9bf34594887c2c7ac
Status: Downloaded newer image for ubuntu:latest
docker.io/library/ubuntu:latest

这个命令只会下载自上次运行命令以来发生变化的层级。根据你上次拉取镜像的时间、注册表中推送的变更以及目标镜像包含的层级数量,你可能会看到一个更长或更短的列表,甚至是一个空列表。

提示

值得记住的是,即使你拉取了latest,Docker 也不会自动为你保持本地镜像的更新。你需要自己负责更新。然而,如果你部署了基于更新版的ubuntu:latest的镜像,Docker 客户端会在部署过程中下载缺失的层级,就像你期望的那样。请记住,这是 Docker 客户端的行为,其他库或 API 工具可能不会以这种方式行为。强烈建议你始终使用固定版本标签而不是latest标签来部署生产代码。这有助于确保你获得你期望的版本,并且没有意外的惊喜。

除了通过latest标签或其他版本号标签引用注册表中的项目外,你还可以通过它们的内容可寻址标签引用它们,看起来像这样:

sha256:b6b83d3c331794420340093eb706a6f152d9c1fa51b262d9bf34594887c2c7ac

这些哈希是基于镜像内容的哈希总和生成的,是非常精确的标识符。这是确保获取您期望的确切版本时,远远比版本标签更安全的方式,因为这些标签不能像版本标签那样被移动。从注册表中拉取它们的语法非常相似,但请注意标签中的 @

$ docker image pull ubuntu@sha256:b6b83d3c331794420340093eb706a6f152d…

与大多数 Docker 命令不同,您可能会缩短哈希,但在这里不能使用 SHA-256 哈希。您必须使用完整的哈希。

检查一个容器

一旦您创建了一个容器,无论是正在运行还是没有运行,现在可以使用 docker 命令来查看它的配置。这通常在调试中非常有用,还提供了一些其他可以用来识别容器的信息。

作为这个例子,继续启动一个容器:

$ docker container run --rm -d -t ubuntu /bin/bash
3c4f916619a5dfc420396d823b42e8bd30a2f94ab5b0f42f052357a68a67309b

我们可以使用 docker container ls 列出所有正在运行的容器,以确保一切都按预期运行,并复制容器 ID:

$ docker container ls
CONTAINER ID  IMAGE         COMMAND     … STATUS        …  NAMES
3c4f916619a5  ubuntu:latest "/bin/bash" … Up 31 seconds …  angry_mestorf

在这种情况下,我们的 ID 是 3c4f916619a5。我们还可以使用 angry_mestorf,这是分配给我们的容器的动态名称。然而,许多底层工具需要唯一的容器 ID,所以养成首先查看它的习惯是很有用的。正如我们之前提到的,如显示的那样,ID 是截断(或短)版本,但 Docker 在长版本和短版本之间是可以互换使用的。与许多版本控制系统一样,这个哈希只是更长哈希的前缀。内核在内部使用一个 64 字节的哈希来标识容器。但是这对人类来说是痛苦的,所以 Docker 支持缩短的哈希。

docker container inspect 的输出非常详细,所以我们将在下面的代码块中精简为几个值值得注意。您应该查看完整的输出,以查看您认为有趣的其他内容:

$ docker container inspect 3c4f916619a5
[{
    "Id": "3c4f916619a5dfc420396d823b42e8bd30a2f94ab5b0f42f052357a68a67309b",
    "Created": "2022-07-17T17:26:53.611762541Z",
    …
    "Args": [],
    …
    "Image": "sha256:27941809078cc9b2802deb2b0bb6feed6c…7f200e24653533701ee",
    …
    "Config": {
        "Hostname": "3c4f916619a5",
        …
        "Env": [
          "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
        ],
        "Cmd": [
            "/bin/bash"
        ],
        …
        "Image": "ubuntu",
        …
    },
    …
}]

注意那个长长的 "Id" 字符串。那是这个容器的完整唯一标识符。幸运的是,我们可以使用短版本,即使这仍然不是特别方便。我们还可以看到容器创建的确切时间比 docker container ls 给出的时间要精确得多。

这里还显示了一些其他有趣的东西:容器中的顶层命令、在创建时传递给它的环境、基于的镜像以及容器内部的主机名。所有这些在容器创建时都是可配置的,如果需要的话。例如,通过环境变量传递配置给容器的通常方法,所以通过 docker container inspect 查看容器配置的能力,在调试时可以揭示很多信息。

您可以通过运行类似 docker container stop 3c4f916619a5 的命令停止当前容器。

探索 Shell

让我们只用一个交互式的 bash shell 来运行一个容器,这样我们可以四处看看。我们将像之前那样运行以下类似的内容:

$ docker container run --rm -it ubuntu:22.04 /bin/bash

这将会运行一个 Ubuntu 22.04 LTS 容器,并将 bash shell 设置为顶层进程。通过指定 22.04 标签,我们可以确保获得特定版本的镜像。那么,当我们启动该容器时,会运行哪些进程呢?

root@35fd1ad27228:/# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 17:45 pts/0    00:00:00 /bin/bash
root         9     1  0 17:47 pts/0    00:00:00 ps -ef

哇,这真不多,是吧?事实证明,当我们告诉 docker 启动 bash 时,除此之外我们什么也没有得到。我们身处一个完整的 Linux 发行版镜像中,但没有其他进程会自动为我们启动。我们只得到了我们所请求的内容。在今后的操作中,记住这一点是很重要的。

警告

Linux 容器默认不像完整的虚拟机那样在后台启动任何东西。它们比虚拟机轻量得多,因此不会启动 init 系统。当然,如果需要,你可以运行一个完整的 init 系统,或者内置在 Docker 中的 tini init 系统,但你必须明确请求。我们将在第七章中更详细地讨论这个问题。

这就是我们在容器中运行 shell 的方式。随意探索并看看容器内还有什么其他有趣的东西。你可能只能使用有限的一组命令。不过,由于你位于基础的 Ubuntu 发行版中,你可以通过 apt-get update,然后 apt-get install… 来下载更多的软件包。但是这些应用程序只会存在于此容器的生命周期内。你修改的是容器的顶层层,而不是基础镜像!容器本质上是短暂的,因此你在容器内所做的任何操作都不会超出其生命周期。

当你在容器中完成操作后,确保使用 exit 命令退出 shell,这样容器就会自然停止:

root@35fd1ad27228:/# exit

返回结果

如果为了运行一个命令并获取结果而启动一个完整的虚拟机,效率会有多低呢?通常情况下,你不会希望这样做,因为这将非常耗时,并且需要启动一个完整的操作系统来执行一个简单的命令。但是 Docker 和 Linux 容器的工作方式不同于虚拟机:容器非常轻量化,不需要像操作系统那样启动。运行像是快速后台作业并等待退出代码的操作是 Linux 容器的正常用例。你可以把它看作是一种远程访问容器化系统的方式,并且可以访问容器内的任何单个命令,并能够将数据传输到其中并返回退出代码。

这在许多场景下非常有用:例如,您可能会远程运行系统健康检查,或者有一系列通过 Docker 启动以处理工作负载并返回的机器上的进程。docker 命令行工具会代理结果到本地机器。如果以前台模式运行远程命令并且没有指定其他操作,docker 将重定向其 stdin 到远程进程,并将远程进程的 stdoutstderr 输出到您的终端。要实现此功能,我们只需在前台运行命令,并在远程不分配 TTY 的情况下。这也是默认配置!不需要任何命令行选项。

当我们运行这些命令时,Docker 创建一个新的容器,在容器的命名空间和 cgroups 中执行我们请求的命令,然后删除容器,以便在调用之间不会留下任何正在运行或占用不必要的磁盘空间。以下代码应该让您对可以执行的类型有一个大致的了解:

$ docker container run --rm ubuntu:22.04 /bin/false
$ echo $?
1
$ docker container run --rm ubuntu:22.04 /bin/true
$ echo $?
0
$ docker container run --rm ubuntu:22.04 /bin/cat /etc/passwd

root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
…
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin

$ docker container run --rm ubuntu:22.04 /bin/cat /etc/passwd | wc -l

19

在这里,我们在远程服务器上执行了 /bin/false,它将始终以 1 的状态退出。请注意 docker 如何将该结果代理到我们的本地终端。为了证明它返回其他结果,我们还运行了 /bin/true,它将始终返回 0。就是这样。

然后我们实际上要求 docker 在远程容器上运行 cat /etc/passwd。我们得到的是包含在该容器文件系统中 /etc/passwd 文件的打印输出。因为这只是标准输出的常规输出,我们可以像处理其他任何东西一样将其管道传递到本地命令中。

警告

前面的代码将输出通过管道传递给本地的 wc 命令,而不是容器中的 wc 命令。管道本身不会传递到容器中。如果您想将整个命令(包括管道)传递到服务器端,您需要在远程端调用一个完整的 shell 并传递带引号的命令,如 bash -c "<your command> | <something else>"。在前面的代码中,这将是 docker container run ubuntu:22.04 /bin/ bash -c " /bin/cat /etc/passwd | wc -l"

进入运行中的容器

您可以很容易地在基于几乎任何镜像的新容器中运行 shell,就像我们之前使用 docker container run 所演示的那样。但是,在已经运行您的应用程序的现有容器内部获取新的 shell 并不相同。每次使用 docker container run,都会得到一个新的容器。但是,如果您有一个正在运行应用程序的现有容器,并且需要从容器内部调试它,您需要其他方法。

使用 docker container exec 是 Docker 的本地方式在容器中获取新的交互式进程,但还有一种更适合 Linux 的方式,称为 nsenter。我们将在本节中查看 docker container exec,稍后在 nsenter 中介绍 nsenter

注意

你可能会想知道为什么要这样做。在开发中,当你积极地构建和测试你的应用程序时,这是非常有用的。这是开发容器在像Visual Studio Code这样的 IDE 中使用的机制。

在生产环境中,SSH 登录到生产服务器并不被认为是一个好的做法,这大致相同;但在某些情况下,看看实际环境内发生了什么非常重要,这时可以帮助你解决问题。

docker container exec

首先,让我们看看最简单和最佳的进入运行中容器的方法。dockerd服务器和docker命令行工具支持通过docker container exec命令在运行中的容器中远程执行新进程。因此,让我们在后台模式下启动一个容器,然后使用docker container exec进入它并调用一个 shell。

你调用的命令不一定要是一个 shell:可以在容器内运行单独的命令,并在容器外查看它们的结果,使用docker container exec。但如果你想进入容器查看情况,shell 是最简单的方法。

要运行docker container exec,我们需要我们容器的 ID。对于这个演示,让我们创建一个只运行sleep命令 600 秒的容器:

$ docker container run -d --rm  ubuntu:22.04 sleep 600
9f09ac4bcaa0f201e31895b15b479d2c82c30387cf2c8a46e487908d9c285eff

这个容器的短 ID 是9f09ac4bcaa0。现在我们可以使用它来使用docker container exec进入容器。这个命令行,毫不奇怪地,看起来非常像docker container run的命令行。我们请求一个交互式会话和一个伪终端,使用-i-t标志:

$ docker container exec -it 9f09ac4bcaa0 /bin/bash
root@9f09ac4bcaa0:/#

请注意,我们得到了一个命令行回显,告诉我们我们正在运行的容器的 ID。这对于跟踪我们所在位置非常有用。现在我们可以运行一个普通的 Linux ps命令,看看我们的容器内还有什么正在运行。我们应该能看到在容器最初启动时创建的sleep进程:

root@9f09ac4bcaa0:/# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 20:22 ?        00:00:00 sleep 600
root         7     0  0 20:23 pts/0    00:00:00 /bin/bash
root        15     7  0 20:23 pts/0    00:00:00 ps -ef

当完成时,键入exit以退出容器。

警告

你也可以通过docker container exec在后台运行额外的进程。你可以像使用docker container run一样使用-d选项。但是除了调试之外,你应该仔细考虑这样做,因为这会导致镜像部署的可重复性丧失。其他人将不得不知道如何通过docker container exec传递参数以获得所需的功能。如果你想这样做,重建容器镜像以可重复地启动这两个进程可能会带来更大的收益。如果需要通知容器内的软件执行某些操作,比如旋转日志或重新加载配置,最好使用docker container kill -s <SIGNAL>与标准 Unix 信号名称传递信息给容器内的进程。

docker volume

Docker 支持一个 volume 子命令,可以列出存储在根目录中的所有卷,然后发现关于它们的附加信息,包括它们在服务器上物理存储的位置。

这些卷不是绑定挂载的;相反,它们是提供一种持久化数据的有用方法的特殊数据容器。

如果我们运行一个普通的 docker 命令来绑定挂载一个目录,我们会注意到它不会创建任何 Docker 卷:

$ docker volume ls
DRIVER              VOLUME NAME

$ docker container run --rm -d -v /tmp:/tmp ubuntu:latest sleep 120
6fc97c50fb888054e2d01f0a93ab3b3db172b2cd402fc1cd616858b2b5138857

$ docker volume ls
DRIVER              VOLUME NAME

但是,您可以轻松地通过类似这样的命令创建一个新的卷:

$ docker volume create my-data

如果然后列出所有的卷,您应该会看到类似这样的内容:

$ docker volume ls

DRIVER              VOLUME NAME
local               my-data

$ docker volume inspect my-data
[
    {
        "CreatedAt": "2022-07-31T16:19:42Z",
        "Driver": "local",
        "Labels": {},
        "Mountpoint": "/var/lib/docker/volumes/my-data/_data",
        "Name": "my-data",
        "Options": {},
        "Scope": "local"
    }
]

现在,您可以通过运行以下命令启动一个附加了此数据卷的容器:

 $ docker container run --rm \
     --mount source=my-data,target=/app \
     ubuntu:latest touch /app/my-persistent-data

该容器在数据卷中创建了一个文件,然后立即退出。

如果我们现在将该数据卷挂载到另一个容器,我们将看到我们的数据仍然存在:

$ docker container run --rm \
    --mount source=my-data,target=/app \
    fedora:latest ls -lFa /app/my-persistent-data

-rw-r--r-- 1 root root 0 Jul 31 16:24 /app/my-persistent-data

最后,当您完成时,可以通过运行以下命令删除数据卷:

$ docker volume rm my-data

my-data
注意

如果您尝试删除一个被容器使用的卷(无论它是正在运行还是未运行),您将会收到如下错误:

Error response from daemon: unable to remove volume:
    remove my-data: volume is in use - [
    d0763e6e8d79e55850a1d3ab21e9d…,
    4b40d52978ea5e784e66ddca8bc22…]

这些命令应该帮助您详细探索您的容器。一旦我们在第十一章中更详细地解释了命名空间,您将更好地理解所有这些部分如何交互和组合以创建一个容器。

日志记录

日志记录是任何生产应用程序的关键部分。当出现问题时,日志可以是恢复服务的关键工具,因此需要做好。有一些常见的方式,我们期望在 Linux 系统上与应用程序日志交互,有些更好,有些不如。如果您在一台机器上运行应用程序进程,您可能期望输出被记录到一个本地日志文件中,您可以通过它来阅读。或者也许您期望输出简单地记录到内核缓冲区中,从那里可以读取 dmesg。或者,就像许多现代 Linux 发行版中使用 systemd 一样,您可能期望从 journalctl 中获取日志。由于容器的限制以及 Docker 的构造方式,如果没有您的一些配置,这些方式都不会起作用。但这没关系,因为日志记录在 Docker 中有一流的支持。

Docker 在几个关键方面使日志记录变得更加简单。首先,它捕获了容器中应用程序的所有普通文本输出。任何发送到容器中的 stdoutstderr 的内容都会被 Docker 守护程序捕获,并流式传输到可配置的日志后端。其次,像 Docker 的许多其他部分一样,这个系统是可插拔的,有许多强大的选项作为插件供您选择。但是,让我们暂时不要深入讨论。

docker container logs

我们将从最简单的 Docker 使用案例开始:默认的日志记录机制。虽然这种机制有一些限制,我们马上会解释,但对于大多数常见的用例,它工作得很好,并且非常方便。如果您在开发中使用 Docker,这可能是您唯一使用的日志策略。此日志方法从一开始就存在,并且得到了很好的理解和支持。该机制是 json-file 方法。docker container logs 命令向大多数用户公开了这一点。

顾名思义,当您运行默认的 json-file 日志插件时,Docker 守护程序将您的应用程序日志流式传输到每个容器的一个 JSON 文件中。这使我们可以随时检索任何容器的日志。

我们可以通过启动 nginx 容器来显示一些日志:

$ docker container run --rm -d --name nginx-test --rm nginx:latest

然后:

$ docker container logs nginx-test
…
2022/07/31 16:36:05 [notice] 1#1: using the "epoll" event method
2022/07/31 16:36:05 [notice] 1#1: nginx/1.23.1
2022/07/31 16:36:05 [notice] 1#1: built by gcc 10.2.1 20210110 (Debian 10.2.1-6)
2022/07/31 16:36:05 [notice] 1#1: OS: Linux 5.10.104-linuxkit
…

这很好,因为 Docker 允许您随时从命令行远程获取日志。这对低容量日志记录非常有用。

注意

要将日志输出限制为更近期的日志,请使用 --since 选项,仅显示指定 RFC 3339 日期(例如,2002-10-02T10:00:00-05:00)、Unix 时间戳(例如,1450071961)、标准时间戳(例如,20220731)或 Go 时长字符串(例如,5m45s)。您还可以使用 --tail 后跟您希望追踪的行数。

支持此日志的实际文件位于 Docker 服务器本身,默认情况下在 /var/lib/docker/containers/<container_id>/,其中 *<container_id>* 将被实际的容器 ID 替换。如果您查看名为 *<container_id>*-json.log 的文件,您将看到它是一个每行代表一个 JSON 对象的文件。它看起来会像这样:

{"log":"2022/07/31 16:36:05 [notice] 1#1: using the \"epoll\" event method\n",
  "stream":"stderr","time":"2022-07-31T16:36:05.189234362Z"}

那个 log 字段就是发送到所讨论进程的 stdout 的内容;stream 字段告诉我们这是 stdout 而不是 stderr,Docker 守护程序接收到它的精确时间在 time 字段中提供。这是一种不常见的日志格式,但它是结构化的而不是原始流,如果以后要对日志做任何处理,则非常有益。

就像日志文件一样,您也可以使用 docker container logs -f 实时追踪 Docker 日志:

$ docker container logs -f nginx-test
…
2022/07/31 16:36:05 [notice] 1#1: start worker process 35
2022/07/31 16:36:05 [notice] 1#1: start worker process 36
2022/07/31 16:36:05 [notice] 1#1: start worker process 37
2022/07/31 16:36:05 [notice] 1#1: start worker process 38

这看起来与通常的 docker container logs 没什么两样,但客户端会继续等待并显示从服务器接收到的新消息,就像 Linux 命令行中的 tail -f。您可以随时键入 Ctrl-C 退出日志流:

---
$ docker container stop nginx-test
---
提示

通过配置类似于 --log-opt tag="{{.ImageName}}/{{.ID}}" 的标签日志选项,可以将默认的日志标签(每行日志都以此标签开头)更改为更有用的内容。默认情况下,Docker 日志将以容器 ID 的前 12 个字符作为标签。

对于单主机日志记录,这种机制效果相当不错。它的缺点在于日志轮转、一旦轮转后远程访问日志、以及高容量日志的磁盘空间使用。尽管是由 JSON 文件支持,但如果这种解决方案适合你,大多数生产应用程序可以使用这种方式记录日志。但是,如果你有一个更复杂的环境,你可能需要更强大和具有集中日志功能的解决方案。

警告

默认设置中,dockerd 不会自动启用日志轮转。如果在生产环境中运行,建议通过命令行或者 daemon.json 配置文件指定 --log-opt max-size--log-opt max-file 设置。这些设置分别限制了日志轮转前的最大文件大小和要保留的日志文件的最大数量。除非你同时设置了 max-size 来告诉 Docker 何时轮转日志,否则 max-file 是无效的。启用后,docker container logs 机制将仅返回当前日志文件中的数据。

更高级的日志记录

在默认机制不足以应对的情况下(在大规模使用时,可能会出现这种情况),Docker 还支持可配置的日志后端。这些插件列表正在不断增加。目前支持的包括我们早些描述的 json-file,以及 syslogfluentdjournaldgelfawslogssplunkgcplogslocallogentries,它们用于将日志发送到各种流行的日志框架和服务。

这里列出了一个庞大的插件列表。目前支持的最简单的选项,用于在大规模上运行 Docker,是直接将容器日志通过 syslog 发送。你可以在 Docker 命令行中使用 --log-driver=syslog 选项指定它,或者在 daemon.json 文件中为所有容器设置为默认值。

提示

daemon.json 文件是 dockerd 服务器的配置文件。通常可以在服务器的 /etc/docker/ 目录中找到这个文件。对于 Docker Desktop 用户,可以在 UI 的 Preferences → Docker Engine 中编辑此文件。如果更改了此文件,需要重新启动 Docker Desktop 或 dockerd 守护进程。

同样有几个第三方插件可用。我们从第三方插件中看到了不同的结果,主要是因为它们使 Docker 的安装和维护变得复杂。但是,你可能会发现有一个适合你系统的第三方实现,值得安装和维护的麻烦。

警告

所有日志驱动程序都适用一些注意事项。例如,Docker 一次只能支持一个日志驱动程序。这意味着你可以使用 sysloggelf 日志驱动程序,但不能与 json-file 驱动程序同时使用。除非你运行 json-filejournald,否则将失去使用 docker container logs 命令的能力!这可能会出乎意料,并且在更改驱动程序时是一个重要的考虑因素。

一些插件设计用于将日志发送到远程端点,并保留本地的 JSON 复制以供 docker container logs 命令使用,但您需要确定要使用的插件是否支持此功能。对于每个驱动程序来说,要注意很多潜在问题,但您应该牢记日志的可靠传递与可能破坏 Docker 部署之间的权衡。建议使用基于 UDP 的解决方案或其他非阻塞选项。

传统上,大多数 Linux 系统都有某种形式的 syslog 接收器,无论是 syslogrsyslog 还是其他许多选项。这种协议以其各种形式已存在很长时间,并且在大多数部署中得到了相当好的支持。从传统的 Linux 或 Unix 环境迁移到 Docker 时,许多公司已经拥有 syslog 基础设施,这通常也是最简单的迁移路径。

注意

许多较新的 Linux 发行版基于 systemd 初始化系统,因此默认使用 journald 进行日志记录,这与 syslog 不同。

尽管 syslog 是一种传统解决方案,但它也存在问题。Docker syslog 驱动支持 TLS、TCP 和 UDP 连接选项,听起来很不错,但您应该对通过 TCP 或 TLS 从 Docker 流式传输日志到远程日志服务器持谨慎态度。问题在于它们都在基于连接的 TCP 会话之上运行,并且 Docker 尝试在容器启动时连接到远程日志服务器。如果连接失败,它将阻塞容器启动。如果您将其作为默认的日志记录机制运行,这可能随时影响到任何部署。

这对生产系统来说并不是特别可用的状态,因此建议如果打算使用 syslog 驱动,则使用 UDP 选项进行 syslog 日志记录。这意味着您的日志不会加密,并且不能保证传递。关于日志记录有各种哲学观点,您需要在日志需求与系统可靠性之间取得平衡。我们倾向于建议在可靠性方面出错,但如果您在安全审计环境中运行,则可能有不同的优先级。

提示

您可以通过设置类似于 --log-opt syslog-address=udp://192.168.42.42:123 的日志选项 syslog-address,直接将日志记录到单个容器的远程 syslog 兼容服务器。

关于大多数日志插件需要注意的最后一个警告是:它们默认是阻塞的,这意味着日志回压可能会影响您的应用程序。您可以通过设置--log-opt mode=non-blocking来更改此行为,然后设置日志的最大缓冲区大小为--log-opt max-buffer-size=4m之类的值。一旦设置了这些参数,当缓冲区填满时,应用程序将不再阻塞。相反,内存中最旧的日志行将被丢弃。同样,可靠性需要与您的业务需要接收所有日志进行权衡。

警告

一些第三方库和程序会因各种(有时是意外的)原因写入文件系统。如果您试图设计不直接向容器文件系统写入的清洁容器,则应考虑使用我们在第四章讨论的--read-only--mount type=tmpfs选项来运行docker container。不推荐在容器内部写入日志。这使得日志难以获取,阻止它们在容器生命周期之外被保留,并且可能会对 Docker 文件系统后端造成严重影响。

监控 Docker

对于生产系统来说,最重要的要求之一是它们必须是可观察和可测量的。如果你对系统行为一无所知,那么这样的生产系统将无法为你服务。在现代运维环境中,我们希望监控所有有意义的事物,并尽可能报告尽可能多的有用统计信息。Docker 支持容器健康检查,并通过docker container statsdocker system events提供一些基本的报告功能。我们将会展示这些功能,然后看看来自 Google 的社区提供的一些漂亮的图形输出,然后我们将查看 Docker 的一个当前实验性特性,该特性将容器指标导出到 Prometheus 监控系统中。

容器统计信息

让我们从 Docker 自带的 CLI 工具开始。docker CLI 具有一个端点,用于查看运行容器的重要统计信息。这个命令行工具可以从该端点流式传输,并每隔几秒报告一次一个或多个列出的容器的基本统计信息,显示正在发生的事情。docker container stats类似于 Linux 的top命令,接管当前终端并更新屏幕上的相同行以显示当前信息。这很难在打印中显示,所以我们只给出一个例子,但默认情况下每隔几秒更新一次。

命令行统计信息

启动一个活动容器:

$ docker container run --rm -d --name stress \
    docker.io/spkane/train-os:latest \
    stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 60s

然后运行stats命令查看新容器:

$ docker container stats stress
CONTAINER ID NAME   CPU %   MEM USAGE/LIMIT   MEM % NET I/O   BLOCK I/O PIDS
1a9f52f0855f stress 476.50% 36.09MiB/7.773GiB 0.45% 1.05kB/0B 0B/0B     6

您可以随时输入 Ctrl-C 退出stats流。

提示

您可以使用--no-stream选项获取一个单点时间的统计信息集,该选项不会更新,并在命令完成后将您返回到命令行。

让我们把这个相当密集的输出分解成几个易于处理的部分。我们有以下内容:

  • 容器的 ID(但不包括名称)。

  • 它当前消耗的 CPU 量。百分之百相当于一个完整的 CPU 核心。

  • 它正在使用的内存量,以及它被允许使用的最大量。

  • 网络和块 I/O 统计。

  • 容器内部活跃进程的数量。

其中一些对于调试来说会比其他的更有用,所以让我们看看你可以用它们做些什么。

这里更有帮助的一个输出部分是内存使用百分比与为容器设置的限制之间的比例。运行生产容器时的一个常见问题是过于激进的内存限制可能导致 Linux 内核的 OOM 杀手一遍又一遍地停止容器。stats 命令可以帮助你识别和解决这类问题。

关于 I/O 统计,如果你将所有应用程序都运行在容器中,那么这个摘要可以非常清楚地显示系统的 I/O 流向何处。在容器出现之前,这个任务要困难得多!

容器内部活跃进程的数量也有助于调试。如果你有一个应用程序在生成子进程但没有清理它们,这很快就能暴露出来。

docker container stats 的一个很棒的特性是它可以在单个摘要中展示所有容器,而不仅仅是一个。即使在你认为你了解它们在做什么的主机上,这可能会非常具有启发性。

这一切都非常有用且易于理解,因为它是以人类可读的方式格式化并在命令行上可用。但是 Docker API 上还有一个额外的端点提供的信息比客户端显示的要多得多。到目前为止,我们在本书中避免直接使用 API,但在这种情况下,API 提供的数据比客户端更丰富,所以我们将使用 curl 发送一个 API 请求来看看我们的容器在做什么。它不像阅读起来那么美好,但是提供了更多的详细信息。

注意

记住,docker 客户端可以做的基本上所有事情都可以直接通过 Docker API 完成。这意味着如果有需要,你可以在你的应用程序中以编程方式做非常类似的事情。

“统计 API 端点” 中的例子是直接调用 API 的一个很好的介绍。

统计 API 端点

我们将在 API 上命中的 /stats/ 端点会继续向我们流式传输统计信息,只要我们保持连接打开。由于作为人类我们不容易解析 JSON,我们只会请求一行,然后使用工具 jq 来“漂亮打印”它。为了使此命令正常工作,你需要安装 jq(版本 2.6 或更高)。如果你没有安装 jq,但仍然想看到 JSON 输出,可以跳过到 jq 的管道,但会得到简单且难看的 JSON。如果你已经有一个喜欢的 JSON 美化打印程序,随意使用那个代替。

大多数 Docker 守护程序只会在 Unix 域套接字上提供 API,而不会在 TCP 上发布。因此,我们将从 Docker 服务器主机本身使用curl调用 API。如果您计划在生产环境中监视此端点,您需要在 TCP 端口上公开 Docker API。这不是我们推荐的做法,但Docker 文档会指导您完成这一步骤。

注意

如果您不在 Docker 服务器上或者在本地使用 Docker Desktop,您可能需要检查DOCKER_HOST环境变量的内容,使用类似echo $DOCKER_HOST的命令,以发现您正在使用的 Docker 服务器的主机名或 IP 地址。

首先,启动一个容器,您可以从中读取统计信息:

$ docker container run --rm -d --name stress \
    docker.io/spkane/train-os:latest \
    stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 60s

现在容器正在运行,您可以通过运行类似curl加上您容器的名称或哈希来获取有关容器的 JSON 格式的持续统计信息流。

注意

在以下示例中,我们正在针对 Docker 套接字运行curl,但如果可用的话,您也可以轻松地针对 Docker 端口运行它。

$ curl --no-buffer -XGET --unix-socket /var/run/docker.sock \
    http://docker/containers/stress/stats
注意

这个 JSON 统计流不会自行停止。因此,现在我们可以使用 Ctrl-C 键组合来停止它。

要获取一组统计信息,我们可以运行类似于这样的命令:

$ curl -s -XGET --unix-socket /var/run/docker.sock \
    http://docker/containers/stress/stats | head -n 1 | jq

最后,如果我们有jq或另一个能够漂亮打印 JSON 的工具,我们可以使这个输出变得人类可读,如下所示:

$ curl -s -XGET --unix-socket /var/run/docker.sock \
    http://docker/containers/stress/stats | head -n 1 | jq
{
  "read": "2022-07-31T17:41:59.10594836Z",
  "preread": "0001-01-01T00:00:00Z",
  "pids_stats": {
    "current": 6,
    "limit": 18446744073709552000
  },
  "blkio_stats": {
    "io_service_bytes_recursive": [
      {
        "major": 254,
        "minor": 0,
        "op": "read",
        "value": 0
      },
…
    ]
  },
  "num_procs": 0,
  "storage_stats": {},
  "cpu_stats": {
    "cpu_usage": {
      "total_usage": 101883204000,
      "usage_in_kernelmode": 43818021000,
      "usage_in_usermode": 58065183000
…
    },
  },
  "memory_stats": {
    "usage": 183717888,
    "stats": {
      "active_anon": 0,
      "active_file": 0,
…
    },
    "limit": 8346021888
  },
  "name": "/stress",
  "id": "9be7c9de26864ac97e07fc3d8e3ffb5bb52cc2ba49f569d4ba8d407f8747851f",
  "networks": {
    "eth0": {
      "rx_bytes": 1046,
      "rx_packets": 9,
…
    }
  }
}

其中包含大量的信息。我们已经削减了内容,以防止浪费任何不必要的树木或电子,但即便如此,仍有很多需要消化的内容。主要思想是让您看到 API 关于每个容器的可用数据量。我们不会花太多时间详细介绍,但您可以获得相当详细的内存使用信息,以及块 I/O 和 CPU 使用信息。

如果您正在进行自己的监控,这也是一个很好的端点。然而,缺点是每个容器只有一个端点,因此您无法通过一次调用获取有关所有容器的统计信息。

容器健康检查

与任何其他应用程序一样,当您启动一个容器时,它有可能会启动并运行,但实际上永远不会进入一个健康状态,从而无法接收流量。生产系统也会出现故障,您的应用程序在其生命周期中的某个时刻可能会变得不健康,因此您需要能够处理这种情况。

许多生产环境都有标准化的方法来检查应用程序的健康状况。不幸的是,跨组织之间并没有明确的标准来执行这项任务,许多公司也不太可能以相同的方式执行。因此,监控系统已经被构建来处理这种复杂性,以便它们可以在许多不同的生产系统中工作。这是一个明显的地方,标准化将是一个巨大的胜利。

为了帮助简化这个复杂性并标准化一个通用接口,Docker 添加了一个健康检查机制。遵循航运集装箱的隐喻,Linux 容器无论内部是什么,对外界看起来应该都是一样的,所以 Docker 的健康检查机制不仅标准化了容器的健康检查,还保持了容器内外部分离。这意味着来自 Docker Hub 或其他共享存储库的容器可以实现一个标准化的健康检查机制,在任何设计用于运行生产容器的 Docker 环境中都可以工作。

健康检查是构建时的配置项,可以在Dockerfile中使用HEALTHCHECK定义创建。这个指令告诉 Docker 守护程序可以在容器内运行哪个命令来确保容器处于健康状态。只要命令以零(0)的代码退出,Docker 就会认为容器是健康的。任何其他退出代码将向 Docker 表明容器处于非健康状态,此时调度程序或监控系统可以采取适当的措施。

我们将在接下来的几章中使用以下项目来探索 Docker Compose。但是目前,它包含了一个有关 Docker 健康检查的实用示例。继续下载代码的副本,然后进入rocketchat-hubot-demo/mongodb/docker/目录:

$ git clone https://github.com/spkane/rocketchat-hubot-demo.git \
    --config core.autocrlf=input
$ cd rocketchat-hubot-demo/mongodb/docker

在这个目录中,你会看到一个Dockerfile和一个名为docker-healthcheck的脚本。如果查看Dockerfile,你会看到以下内容:

FROM docker.io/bitnami/mongodb:4.4
# Newer Upstream Dockerfile:
# https://github.com/bitnami/containers/blob/
# f9fb3f8a6323fb768fd488c77d4f111b1330bd0e/bitnami/mongodb
# /5.0/debian-11/Dockerfile

COPY docker-healthcheck /usr/local/bin/

# Useful Information:
# https://docs.docker.com/engine/reference/builder/#healthcheck
# https://docs.docker.com/compose/compose-file/#healthcheck
HEALTHCHECK CMD ["docker-healthcheck"]

它非常简短,因为我们是基于上游 Mongo 镜像^(2),而我们的镜像继承了许多东西,包括入口点、默认命令和要公开的端口。

注意

Bitnami 在 2023 年初显著重构了他们的容器存储库,因此这个链接指向一个稍新的Dockerfile版本,针对的是 MongoDB 5.0。在这个示例中,我们使用的是 MongoDB 4.4,但这个链接仍然能够传达出重要的信息。

EXPOSE 27017
ENTRYPOINT [ "/opt/bitnami/scripts/mongodb/entrypoint.sh" ]
CMD [ "/opt/bitnami/scripts/mongodb/run.sh" ]
提示

注意,即使容器和底层进程仍在启动过程中,Docker 也会将流量转发到容器的端口。

因此,在我们的Dockerfile中,我们只添加了一个能够健康检查我们容器的脚本,并定义了一个运行该脚本的健康检查命令。

你可以像这样构建容器:

$ docker image build -t mongo-with-check:4.4 .
 => [internal] load build definition from Dockerfile                      0.0s
 => => transferring dockerfile: 37B                                       0.0s
 => [internal] load .dockerignore                                         0.0s
 => => transferring context: 2B                                           0.0s
 => [internal] load metadata for docker.io/bitnami/mongodb:4.4            0.5s
 => [internal] load build context                                         0.0s
 => => transferring context: 40B                                          0.0s
 => CACHED [1/2] FROM docker.io/bitnami/mongodb:4.4@sha256:9162…ae209     0.0s
 => [2/2] COPY docker-healthcheck /usr/local/bin/                         0.0s
 => exporting to image                                                    0.0s
 => => exporting layers                                                   0.0s
 => => writing image sha256:a6ef…da808                                    0.0s
 => => naming to docker.io/library/mongo-with-check:4.4                   0.0s

然后运行容器并查看docker container ls的输出:

$ docker container run -d --rm --name mongo-hc mongo-with-check:4.4
5a807c892428ab0641232c82bd477fc8d1142c9e15c27d5946b8bfe7056e2695

$ docker container ls
… IMAGE                   … STATUS                      PORTS     …
… mongo-with-check:4.4 … Up 1 second (health: starting) 27017/tcp …

您应该注意到STATUS列现在在括号中有一个health部分。最初,这将显示health: starting,因为容器正在启动。您可以使用docker container run--health-start-period参数更改 Docker 等待容器初始化的时间。一旦容器启动并且健康检查成功,状态将变为healthy。这个容器可能需要 40 秒以上才能转换为健康状态:

$ docker container ls
… IMAGE                   … STATUS               PORTS     …
… mongo-with-check:4.4 … Up 32 seconds (healthy) 27017/tcp …

您可以直接查询此状态,使用docker container inspect命令:

$ docker container inspect --format='{{.State.Health.Status}}' mongo-hc
healthy

$ docker container inspect --format='{{json .State.Health}}' mongo-hc | jq
{
  "Status": "healthy",
  "FailingStreak": 0,
  "Log": [
    …
  ]
}

如果您的容器开始失败其健康检查,状态将变为unhealthy,然后您可以确定如何处理这种情况:

$ docker container ls
… IMAGE                   … STATUS                PORTS     …
… mongo-with-check:4.4 … Up 9 minutes (unhealthy) 27017/tcp …

此时,您可以通过简单运行docker container stop mongo-hc来停止容器。

提示

与大多数系统一样,您可以配置关于健康检查的许多细节,包括 Docker 检查健康的频率(--health-interval)、需要多少次失败才会导致容器标记为不健康(--health-retries)等等。如果需要,甚至可以完全禁用健康检查(--no-healthcheck)。

这个功能非常有用,您应该强烈考虑在所有容器中使用它。这将帮助您提高环境的可靠性和您对其运行情况的可见性。它也受到许多生产调度程序和监控系统的支持,因此应该很容易实现。

警告

像往常一样,健康检查的实用性很大程度上取决于其编写质量以及准确地确定服务状态的能力。

docker 系统事件

dockerd守护程序在容器生命周期周围内部生成一个事件流。这是系统各部分了解其他部分正在发生什么的方式。您还可以利用这个流来查看 Docker 服务器上容器的生命周期事件。正如您现在可能期望的那样,这是在dockerCLI 工具中作为另一个命令行参数实现的。运行此命令时,它将阻塞并持续向您传送消息。在幕后,这是对 Docker API 的一个长时间的 HTTP 请求,以 JSON 块的形式返回消息。dockerCLI 工具解码它们并将一些数据打印到终端上。

这个事件流在监控场景或触发额外操作方面非常有用,比如想在作业完成时收到警报。出于调试目的,它允许您查看容器何时死亡,即使 Docker 后来重新启动它。在未来,这可能是您直接针对 API 实现一些工具的地方。

在一个终端中,继续运行events命令:

$ docker system events

您会注意到什么都没有发生。

在另一个终端中,继续启动以下短暂存在的容器:

$ docker container run --rm --name sleeper debian:latest sleep 5

在运行events命令的原始终端中,您现在应该看到类似于这样的内容:

…09:59.606… container create d6… (image=debian:latest, name=sleeper)
…09:59.610… container attach d6… (image=debian:latest, name=sleeper)
…09:59.631… network connect ea… (container=d60b…, name=bridge, type=bridge)
…09:59.827… container start d6… (image=debian:latest, name=sleeper)
…10:04.854… container die d6… (exitCode=0, image=debian:latest, name=sleeper)
…10:04.907… network disconnect ea… (container=d60b…, name=bridge, type=bridge)
…10:04.922… container destroy d6… (image=debian:latest, name=sleeper)

你可以随时输入 Ctrl-C 来退出事件流。

提示

与 Docker 统计信息一样,你可以使用curl通过类似curl --no-buffer -XGET --unix-socket /var/run/docker.sock [*http://docker/events*](http://docker/events)的命令访问 Docker 系统事件。

在这个例子中,我们运行了一个短暂存在的容器,只是简单地计数了 5 秒,然后退出。

container createcontainer attachnetwork connectcontainer start事件是将容器置于运行状态所需的所有步骤。当容器退出时,事件流将记录container dienetwork disconnectcontainer destroy消息。每一个步骤都标志着完全拆除容器的一部分。Docker 还友好地告诉我们容器正在运行的镜像的 ID。例如,这对于将部署与事件相关联非常有用,因为部署通常涉及新的镜像。

如果你的服务器上的容器无法持续运行,那么docker system events流对于查看发生了什么以及何时发生的情况非常有帮助。但是如果你当时没有在观看它,Docker 会非常友好地缓存一些事件,并且你仍然可以在一段时间后获取它们。你可以使用--since选项来显示一段时间后的事件,或者使用--until选项来显示一段时间前的事件。你也可以同时使用两者来限制窗口,以便在调查的问题可能发生的狭窄时间范围内使用。这两个选项都接受 ISO 时间格式,就像前面的例子中的格式一样(例如,2018-02-18T14:03:31-08:00)。

提示

有一些特定的事件类型,你应该特别注意监控:

container oom

当容器内存耗尽时出现

container exec_create

container exec_start

container exec_die

当有人使用docker container exec进入容器时出现,这可能表示安全事件

cAdvisor

docker container statsdocker system events很有用,但是它们目前还不能为我们生成图表。而当我们试图查看趋势时,图表非常有帮助。当然,其他人已经填补了部分这方面的空白。当你开始探索监控 Docker 的选项时,你会发现许多主要的监控工具现在都提供了一些功能,帮助你提高对容器性能和持续状态的可见性。

除了 Datadog、GroundWork 和 New Relic 等公司提供的商业工具外,还有许多免费的开源工具可供选择,例如 Prometheus 或者甚至是 Nagios。我们将在“Prometheus 监控”中讨论 Prometheus。在 Docker 推出后不久,Google 将其内部的容器监控工具作为一个维护良好的开源项目发布在 GitHub 上,称为cAdvisor。虽然 cAdvisor 可以在 Docker 之外运行,但现在你可能不会感到意外,cAdvisor 最简单的实现方式是将其作为 Linux 容器简单运行。

要在大多数 Linux 系统上安装 cAdvisor,您只需运行此代码。

警告

此命令旨在直接在 Linux Docker 服务器上运行。当从 Windows 或 macOS 系统运行时,其功能将无法正常工作。

$ docker container run \
  --volume=/:/rootfs:ro \
  --volume=/var/run:/var/run:ro \
  --volume=/sys:/sys:ro \
  --volume=/var/lib/docker/:/var/lib/docker:ro \
  --volume=/dev/disk/:/dev/disk:ro \
  --publish=8080:8080 \
  --detach=true \
  --name=cadvisor \
  --privileged \
  --rm \
  --device=/dev/kmsg \
  gcr.io/cadvisor/cadvisor:latest

Unable to find image 'cadvisor/cadvisor:latest' locally
Pulling repository cadvisor/cadvisor
f0643dafd7f5: Download complete
…
ba9b663a8908: Download complete
Status: Downloaded newer image for cadvisor/cadvisor:latest
f54e6bc0469f60fd74ddf30770039f1a7aa36a5eda6ef5100cddd9ad5fda350b
注意

在基于 Red Hat Enterprise Linux (RHEL) 的系统上,您可能需要在这里显示的docker container run命令中添加以下行:--volume=/cgroup:/cgroup \

完成这些步骤后,您就可以访问端口为 8080 的 Docker 主机,查看 cAdvisor 的 Web 界面(例如,http://172.17.42.10:8080/)以及主机和各个容器的各种详细图表(参见图 6-1)。

cAdvisor CPU 图表

图 6-1. cAdvisor CPU 图表(示例)

cAdvisor 提供了一个 REST API 端点,您可以通过监控系统轻松查询详细信息:

$ curl http://172.17.42.10:8080/api/v2.1/machine/

您可以在官方文档中找到有关 cAdvisor API 的详细信息。

cAdvisor 提供的详细信息应该足以满足您的大部分图形和监控需求。

Prometheus 监控

Prometheus 监控系统已经成为监控分布式系统的流行解决方案。它主要基于拉取模型,定期从端点获取统计信息。Docker 提供了一个为 Prometheus 构建的端点,可以轻松地将容器统计信息集成到 Prometheus 监控系统中。在撰写本文时,该端点目前处于试验阶段,并且未在dockerd服务器中默认启用。我们简要体验了一下,看起来效果不错,是一个相当不错的解决方案,我们会为您展示。需要指出的是,这个解决方案是用于监控dockerd服务器,与其他暴露容器信息的解决方案有所不同。

要将指标导出到 Prometheus,我们需要重新配置dockerd服务器以启用实验功能,并将指标监听器暴露在我们选择的端口上。这很好,因为我们无需在 TCP 监听器上暴露整个 Docker API 来获取系统的指标,这在安全性上是一种胜利,虽然需要稍微多一点的配置。为此,我们可以在命令行上提供--experimental--metrics-addr=选项,或者我们可以将它们放入 daemon.json 文件中,该文件用于配置守护程序自身。由于许多当前的发行版运行systemd,并且在那里更改配置高度依赖于您的安装方式,我们将使用 daemon.json 选项,因为它更具可移植性。我们将在 Ubuntu Linux 22.04 LTS 上演示此操作。在这个发行版上,通常初始时该文件是不存在的。因此,让我们使用您喜欢的编辑器放置一个。

提示

如前所述,可以在 UI 的 Preferences → Docker Engine 中编辑 Docker Desktop 的 daemon.json 文件。如果更改了此文件,则需要重新启动 Docker Desktop 或 dockerd 守护程序。

调整或添加以下行到 daemon.json 文件中:

{
  "experimental": true,
  "metrics-addr": "0.0.0.0:9323"
}

现在你应该有一个只包含你刚刚粘贴的内容,没有别的内容的文件。

警告

当你在网络上提供服务时,你需要考虑可能引入的安全风险。我们认为公开指标的好处值得权衡,但你应该仔细考虑在你的场景中可能带来的后果。例如,在公共互联网上公开指标在几乎所有情况下可能都不是一个好主意。

当我们重新启动 Docker 时,我们现在将在端口 9323 上的所有地址上拥有一个监听器。这就是 Prometheus 将连接以获取指标的地方。但首先,我们需要重新启动 dockerd 服务器。Docker Desktop 会自动为您处理重启,但如果您在 Linux Docker 服务器上,则可以运行类似 sudo systemctl restart docker 来重新启动守护程序。如果您在重启过程中没有收到任何错误返回,则说明您在 daemon.json 文件中可能设置了一些不正确的内容。

现在,您可以使用 curl 测试指标端点:

$ curl -s http://localhost:9323/metrics | head -15

# HELP builder_builds_failed_total Number of failed image builds
# TYPE builder_builds_failed_total counter
builder_builds_failed_total{reason="build_canceled"} 0
builder_builds_failed_total{reason="build_target_not_reachable_error"} 0
builder_builds_failed_total{reason="command_not_supported_error"} 0
builder_builds_failed_total{reason="dockerfile_empty_error"} 0
builder_builds_failed_total{reason="dockerfile_syntax_error"} 0
builder_builds_failed_total{reason="error_processing_commands_error"} 0
builder_builds_failed_total{reason="missing_onbuild_arguments_error"} 0
builder_builds_failed_total{reason="unknown_instruction_error"} 0
# HELP builder_builds_triggered_total Number of triggered image builds
# TYPE builder_builds_triggered_total counter
builder_builds_triggered_total 0
# HELP engine_daemon_container_actions_seconds The number of seconds it
# takes to process each container action
# TYPE engine_daemon_container_actions_seconds histogram

如果您在本地运行此操作,则应该会得到非常相似的输出。它可能不完全相同,但只要您得到的不是错误消息,那就没问题。

现在我们有一个地方,Prometheus 可以获取我们的统计数据了。但我们需要在某个地方运行 Prometheus,对吧?我们可以轻松地通过启动一个容器来实现这一点。但首先,我们需要写一个简单的配置。我们将其放在 /tmp/prometheus/prometheus.yaml 中。你可以使用你喜欢的编辑器将以下内容放入文件中:

# Scrape metrics every 5 seconds and name the monitor 'stats-monitor'
global:
  scrape_interval: 5s
  external_labels:
    monitor: 'stats-monitor'

# We're going to name our job 'DockerStats' and we'll connect to the docker0
# bridge address to get the stats. If your docker0 has a different IP address
# then use that instead. 127.0.0.1 and localhost will not work.
scrape_configs:
  - job_name: 'DockerStats'
    static_configs:
    - targets: ['172.17.0.1:9323']
注意

对于 Docker Desktop,你也可以使用 host.docker.internal:9323gateway.docker.internal:9323 来替换这里显示的 172.17.0.1:9323。这两个主机名将指向容器的 IP 地址。

如文件中所述,在这里你应该使用你的 docker0 桥接口的 IP 地址,或者你的 ens3eth0 接口的 IP 地址,因为 localhost127.0.0.1 从容器中不可路由。我们在这里使用的地址是 docker0 的通常默认地址,所以这可能是适合你的正确地址。

现在我们已经写好了,我们需要使用这个配置启动容器:

$ docker container run --rm -d -p 9090:9090 \
    -v /tmp/prometheus/prometheus.yaml:/etc/prometheus.yaml \
    prom/prometheus --config.file=/etc/prometheus.yaml

这将运行容器并将我们制作的配置文件挂载到容器中,以便它能找到监视我们的 Docker 端点所需的设置。如果它干净地启动,您现在应该能够打开浏览器并导航到主机的端口 9090。在那里,你将得到一个 Prometheus 窗口,类似于 Figure 6-2。

在下图中,你会看到我们选择了一个指标,engine_daemon_events_total,并在短时间内对其进行了图示。你可以轻松查询下拉菜单中的任何其他指标。进一步的工作和探索 Prometheus 将允许你基于这些指标定义警报和警报策略。同样,监控的内容不仅仅是 dockerd 服务器。你还可以从你的应用程序中暴露 Prometheus 指标。如果你感兴趣并想了解更高级的内容,可能会看看 dockprom,它利用 Grafana 创建漂亮的仪表板,还能查询 Docker API /stats 端点中的容器指标。

Prometheus web UI

图 6-2. Prometheus 事件图(示例)

探索

这应该能为你提供开始运行容器所需的所有基础知识。你可能值得从 Docker Hub 注册中心下载一个或两个容器,并自己探索一下,以便习惯我们刚学到的命令。使用 Docker,你可以做的事情还有很多,包括但不限于以下几点:

  • 使用 docker container cp 在容器内外复制文件

  • 使用 docker image save 将镜像保存为 tarball

  • 使用 docker image import 从 tarball 加载镜像

Docker 拥有庞大的功能集,随着时间的推移,你会逐渐熟悉这些功能。每个新版本都会增加更多功能。我们将在后面详细介绍许多其他命令和功能,但请记住,Docker 的全部功能集非常庞大。

总结

在下一章中,我们将深入探讨 Docker 的更多技术细节,了解 Docker 是如何工作的,以及如何利用这些知识来调试你的容器化应用程序。

^(1) 完整网址:https://docs.docker.com/engine/reference/commandline/dockerd/#daemon-configuration-file

^(2) 完整网址:https://github.com/bitnami/containers/blob/f9fb3f8a6323fb768fd488c77d4f111b1330bd0e/bitnami/mongodb/5.0/debian-11/Dockerfile

第七章:调试容器

一旦你将应用程序部署到生产环境,总会有一天它不如预期般工作。提前了解那一天到来时可以期待什么总是很好的。在继续进行更复杂的部署之前,了解调试容器的基础知识也是很重要的。没有调试技能,将很难看出编排系统出了什么问题。因此,让我们来看看调试容器的内容。

总的来说,调试容器化应用程序与调试系统上的普通进程并没有太大的区别,只是工具有些不同而已。Docker 提供了一些非常好的工具来帮助你!其中一些工具映射到常规系统工具,而一些工具则更进一步。

还很重要的一点是要理解,你的应用程序并不是在与其他 Docker 进程分离的系统中运行。它们共享一个内核,并且根据容器的配置,它们可能会共享其他内容,如存储子系统和网络接口。这意味着你可以从系统中获取关于容器正在执行的操作的大量信息。

如果你习惯于在虚拟机环境中调试应用程序,你可能会认为需要进入容器来检查应用程序的内存或 CPU 使用情况,或者调试其系统调用。但事实并非如此!尽管在许多方面感觉像虚拟化层,但容器中的进程只是 Linux 主机本身上的进程。如果你想查看机器上所有 Linux 容器的进程列表,你可以登录服务器并以你喜欢的命令行选项运行ps命令。但是,你可以从任何地方使用docker container top命令来查看正在运行的容器中的进程列表,从底层 Linux 内核的视角来看。让我们更详细地看看在调试容器化应用程序时可以做的一些事情,这些事情不需要使用docker container execnsenter

进程输出

在调试容器时,你首先想知道的是容器内部正在运行什么。正如我们之前提到的,Docker 有一个内置命令可以做到这一点:docker container top。这不是查看容器内部情况的唯一方法,但是是最简单的方法。让我们看看它是如何工作的:

$ docker container run --rm -d --name nginx-debug --rm nginx:latest
796b282bfed33a4ec864a32804ccf5cbbee688b5305f094c6fbaf20009ac2364

$ docker container top nginx-debug

UID   PID  PPID C STIME TTY TIME  CMD
root  2027 2002 0 12:35 ?   00:00 nginx: master process nginx -g daemon off;
uuidd 2085 2027 0 12:35 ?   00:00 nginx: worker process
uuidd 2086 2027 0 12:35 ?   00:00 nginx: worker process
uuidd 2087 2027 0 12:35 ?   00:00 nginx: worker process
uuidd 2088 2027 0 12:35 ?   00:00 nginx: worker process
uuidd 2089 2027 0 12:35 ?   00:00 nginx: worker process
uuidd 2090 2027 0 12:35 ?   00:00 nginx: worker process
uuidd 2091 2027 0 12:35 ?   00:00 nginx: worker process
uuidd 2092 2027 0 12:35 ?   00:00 nginx: worker process

$ docker container stop nginx-debug

要运行docker container top命令,我们需要向其传递容器的名称或 ID,然后我们会得到一个很好的列表,显示容器内部正在运行的内容,按 PID 排序,就像我们从 Linux 的ps命令输出所期望的那样。

但这里有一些奇怪之处。主要的一个是用户 ID 和文件系统的命名空间。

重要的是要理解,特定用户 ID(UID)的用户名在每个容器和主机系统之间可能完全不同。甚至可能会出现某个特定 UID 在容器或主机的/etc/passwd文件中没有任何关联的情况。这是因为 Unix 不要求 UID 必须与命名用户关联,而我们在“命名空间”中详细讨论的 Linux 命名空间提供了一些容器对有效用户的概念与基础主机之间的隔离。

让我们看一个更具体的例子。考虑一个运行 Ubuntu 22.04 的生产 Docker 服务器以及其中运行一个内部有 Ubuntu 发行版的容器。如果你在 Ubuntu 主机上运行以下命令,你会看到 UID 7 被命名为lp

$ id 7

uid=7(lp) gid=7(lp) groups=7(lp)
注意

这里使用的 UID 号并没有什么特别之处。你不需要特别注意它。之所以选择它,只是因为它在两个平台上默认使用,但代表着不同的用户名。

如果我们然后进入该 Docker 主机上的标准 Fedora 容器,你会看到 UID 7 在/etc/passwd中被设置为halt。通过运行以下命令,您可以看到容器对 UID 7 有完全不同的看法:

$ docker container run --rm -it fedora:latest /bin/bash

root@c399cb807eb7:/# id 7
uid=7(halt) gid=0(root) groups=0(root)

root@c399cb807eb7:/# grep x:7: /etc/passwd
halt:x:7:0:halt:/sbin:/sbin/halt

root@409c2a8216b1:/# exit

如果我们在理论上的 Ubuntu Docker 服务器上以 UID 7(-u 7)运行ps aux,我们会看到 Docker 主机显示容器进程由lp而不是halt运行:

$ docker container run --rm -d -u 7 fedora:latest sleep 120

55…c6

$ ps aux | grep sleep

lp          2388  0.2  0.0   2204   784 ?     … 0:00 sleep 120
vagrant     2419  0.0  0.0   5892  1980 pts/0 … 0:00 grep --color=auto sleep

这可能特别令人困惑,特别是如果像nagiospostgres这样的知名用户在主机系统上配置好了,但在容器中没有配置,而容器却使用相同的 ID 运行其进程。这种命名空间可能会导致ps命令的输出看起来非常奇怪。例如,如果你不仔细观察,可能会看到你的 Docker 主机上的nagios用户正在运行容器内启动的postgresql守护进程。

提示

其中一个解决方案是为您的容器分配一个非零的 UID。在您的 Docker 服务器上,您可以创建一个 UID 为 5000 的container用户,然后在基础容器镜像中创建相同的用户。如果您然后将所有容器作为 UID 5000(-u 5000)运行,不仅可以通过在 Docker 主机上显示所有运行容器进程的container用户来提高系统安全性,而且还可以使ps命令的输出更易于解析。一些系统使用nobodydaemon用户来达到相同的目的,但我们更喜欢container以提升清晰度。关于这种工作方式的更多细节可以在“命名空间”中找到。

同样,由于进程对文件系统有不同的视图,ps输出中显示的路径是相对于容器而不是主机的。在这些情况下,知道它在容器中是一大优势。

这就是你使用 Docker 工具来查看容器中正在运行的内容的方法。但这并不是唯一的方式,在调试情况下,这可能不是最佳方式。如果你进入 Docker 服务器并运行普通的 Linux ps 命令来查看正在运行的内容,你会得到一个包含所有容器化和非容器化内容的完整列表,就像它们都是等效进程一样。有一些方法可以查看进程输出以使事情更加清晰。例如,你可以通过查看 Linux ps 输出的树形式来促进调试,以便你可以看到所有从 Docker 派生的进程。当你使用 BSD 命令行标志查看当前运行两个容器的系统时,这可能是你会看到的样子;我们将只截取我们关心的部分输出。

注意

Docker Desktop 的虚拟机包含大多数 Linux 工具的最小版本,其中一些命令的输出可能与将标准 Linux 服务器用作 Docker 守护程序主机时获得的输出不同。

$ ps axlfww

… /usr/bin/containerd
…
… /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
… \_ /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 \
 -container-ip 172.17.0.2 -container-port 8080
… \_ /usr/bin/docker-proxy -proto tcp -host-ip :: -host-port 8080 \
 -container-ip 172.17.0.2 -container-port 8080
…
… /usr/bin/containerd-shim-runc-v2 -namespace moby -id 97…3d -address /run/…
… \_ sleep 120
…
… /usr/bin/containerd-shim-runc-v2 -namespace moby -id 69…7c -address /run/…
注意

在本示例中,许多 ps 命令仅适用于具有完整 ps 命令的 Linux 发行版。某些精简版的 Linux,如 Alpine,运行 BusyBox shell,它不支持完整的 ps 并且不会显示某些输出。我们建议在主机系统上运行类似 Ubuntu 或 Fedora CoreOS 的完整发行版。

在这里,你可以看到我们正在运行一个 containerd 实例,它是 Docker 守护程序使用的主要容器运行时。dockerd 目前有两个 docker-proxy 子进程运行,我们将在 “网络检查” 中详细讨论它们。

每个使用 containerd-shim-runc-v2 的进程表示一个单独的容器以及在该容器中运行的所有进程。在这个例子中,我们有两个容器。它们显示为 containerd-shim-runc-v2,后面跟着有关该进程的一些额外信息,包括容器 ID。在这种情况下,我们正在一个实例中运行 Google 的 cadvisor 和另一个容器中的 sleep。每个映射端口的容器将至少有一个 docker-proxy 进程,用于在容器和主机 Docker 服务器之间映射所需的网络端口。在本示例中,两个 docker-proxy 进程都与 cadvisor 相关联。其中一个映射 IPv4 地址的端口,另一个映射 IPv6 地址的端口。

由于 ps 的树形输出,很明显可以看出哪些进程运行在哪些容器中。如果你更喜欢 Unix SysV 命令行标志,可以使用 ps -ejH 获得类似但不够美观的树形输出:

$ ps -ejH

… containerd
…
… dockerd
…   docker-proxy
…   docker-proxy
…
… containerd-shim
…   cadvisor
…
… containerd-shim
…   sleep

通过使用 pstree 命令,可以更简洁地查看 docker 进程树。在这里,我们将使用 pidof 将其范围限定为属于 docker 的树:

$ pstree `pidof dockerd`

dockerd─┬─docker-proxy───7*[{docker-proxy}]
 ├─docker-proxy───6*[{docker-proxy}]
 └─10*[{dockerd}]

这不会显示 PIDs,因此只能用于了解连接方式。但是,当主机上运行大量进程时,这个概念上清晰的输出会更加简洁,提供了一个很好的高级地图,展示了事物如何连接。在这里,我们可以看到与先前的ps输出中显示的相同的容器,但是树被折叠了,所以当有七个重复的进程时会得到7*这样的倍增器。

如果我们运行pstree,我们可以得到一个带有 PIDs 的完整树,如下所示:

$ pstree -p `pidof dockerd`

dockerd(866)─┬─docker-proxy(3050)─┬─{docker-proxy}(3051)
 │                    ├─{docker-proxy}(3052)
 │                    ├─{docker-proxy}(3053)
 │                    ├─{docker-proxy}(3054)
 │                    ├─{docker-proxy}(3056)
 │                    ├─{docker-proxy}(3057)
 │                    └─{docker-proxy}(3058)
 ├─docker-proxy(3055)─┬─{docker-proxy}(3059)
 │                    ├─{docker-proxy}(3060)
 │                    ├─{docker-proxy}(3061)
 │                    ├─{docker-proxy}(3062)
 │                    ├─{docker-proxy}(3063)
 │                    └─{docker-proxy}(3064)
 ├─{dockerd}(904)
 ├─{dockerd}(912)
 ├─{dockerd}(913)
 ├─{dockerd}(914)
 ├─{dockerd}(990)
 ├─{dockerd}(1014)
 ├─{dockerd}(1066)
 ├─{dockerd}(1605)
 ├─{dockerd}(1611)
 └─{dockerd}(2228)

此输出为我们提供了一个非常好的视角,查看 Docker 上所有的进程及其运行情况。

如果你想要检查单个容器及其进程,你可以确定容器的主进程 ID,然后使用pstree来查看所有相关的子进程:

$ ps aux | grep containerd-shim-runc-v2
root    3072  … /usr/bin/containerd-shim-runc-v2 -namespace moby -id 69…7c …
root    4489  … /usr/bin/containerd-shim-runc-v2 -namespace moby -id f1…46 …
vagrant 4651  … grep --color=auto shim

$ pstree -p 3072
containerd-shim(3072)─┬─cadvisor(3092)─┬─{cadvisor}(3123)
 │                ├─{cadvisor}(3124)
 │                ├─{cadvisor}(3125)
 │                ├─{cadvisor}(3126)
 │                ├─{cadvisor}(3127)
 │                ├─{cadvisor}(3128)
 │                ├─{cadvisor}(3180)
 │                ├─{cadvisor}(3181)
 │                └─{cadvisor}(3182)
 ├─{containerd-shim}(3073)
 ├─{containerd-shim}(3074)
 ├─{containerd-shim}(3075)
 ├─{containerd-shim}(3076)
 ├─{containerd-shim}(3077)
 ├─{containerd-shim}(3078)
 ├─{containerd-shim}(3079)
 ├─{containerd-shim}(3080)
 ├─{containerd-shim}(3121)
 └─{containerd-shim}(3267)

进程检查

如果你登录到 Docker 服务器,你可以使用所有标准的调试工具来检查运行中的进程。像strace这样的常见调试工具按预期工作。在下面的代码中,我们将检查运行在容器中的nginx进程:

$ docker container run --rm -d --name nginx-debug --rm nginx:latest

$ docker container top nginx-debug

UID      PID   PPID  … CMD
root     22983 22954 … nginx: master process nginx -g daemon off;
systemd+ 23032 22983 … nginx: worker process
systemd+ 23033 22983 … nginx: worker process

$ sudo strace -p 23032

strace: Process 23032 attached
epoll_pwait(10,
警告

如果你运行strace,你需要输入 Ctrl-C 来退出strace进程。

你可以看到,我们得到的输出与主机上非容器化进程的输出相同。同样,lsof显示了进程中打开的文件和套接字的预期工作方式:

$ sudo lsof -p 22983
COMMAND   PID USER … NAME
nginx   22983 root … /
nginx   22983 root … /
nginx   22983 root … /usr/sbin/nginx
nginx   22983 root … /usr/sbin/nginx (stat: No such file or directory)
nginx   22983 root … /lib/aarch64-linux-gnu/libnss_files-2.31.so (stat: …
nginx   22983 root … /lib/aarch64-linux-gnu/libc-2.31.so (stat: …
nginx   22983 root … /lib/aarch64-linux-gnu/libz.so.1.2.11 (path inode=…)
nginx   22983 root … /usr/lib/aarch64-linux-gnu/libcrypto.so.1.1 (stat: …
nginx   22983 root … /usr/lib/aarch64-linux-gnu/libssl.so.1.1 (stat: …
nginx   22983 root … /usr/lib/aarch64-linux-gnu/libpcre2-8.so.0.10.1 (stat: …
nginx   22983 root … /lib/aarch64-linux-gnu/libcrypt.so.1.1.0 (path …
nginx   22983 root … /lib/aarch64-linux-gnu/libpthread-2.31.so (stat: …
nginx   22983 root … /lib/aarch64-linux-gnu/libdl-2.31.so (stat: …
nginx   22983 root … /lib/aarch64-linux-gnu/ld-2.31.so (stat: …
nginx   22983 root … /dev/zero
nginx   22983 root … /dev/null
nginx   22983 root … pipe
nginx   22983 root … pipe
nginx   22983 root … pipe
nginx   22983 root … protocol: UNIX-STREAM
nginx   22983 root … pipe
nginx   22983 root … pipe
nginx   22983 root … protocol: TCP
nginx   22983 root … protocol: TCPv6
nginx   22983 root … protocol: UNIX-STREAM
nginx   22983 root … protocol: UNIX-STREAM
nginx   22983 root … protocol: UNIX-STREAM

请注意,文件的路径都是相对于容器对后备文件系统的视图,这与主机视图不同。因此,如果你在主机系统上,可能无法轻松地找到正在运行的容器中的特定文件。在大多数情况下,最好使用docker container exec进入容器,以便使用与其中进程具有相同视图的方式查看文件。

只要你是root并且有适当的权限,就可以像这样运行 GNU 调试器(gdb)和其他进程检查工具。

值得一提的是,也可以运行一个新的调试容器,可以查看现有容器的进程,从而提供额外的工具来调试问题。我们稍后将在“命名空间”和“安全性”中讨论该命令的底层细节。

$ docker container run -ti --rm --cap-add=SYS_PTRACE \
    --pid=container:nginx-debug spkane/train-os:latest bash

[root@e4b5d2f3a3a7 /]# ps aux
USER PID %CPU %MEM … TIME COMMAND
root   1  0.0  0.2 … 0:00 nginx: master process nginx -g daemon off;
101   30  0.0  0.1 … 0:00 nginx: worker process
101   31  0.0  0.1 … 0:00 nginx: worker process
root 136  0.0  0.1 … 0:00 bash
root 152  0.0  0.2 … 0:00 ps aux

[root@e4b5d2f3a3a7 /]# strace -p 1
strace: Process 1 attached
rt_sigsuspend([], 8

[Control-C]
strace: Process 1 detached
<detached …>

[root@e4b5d2f3a3a7 /]# exit

$ docker container stop nginx-debug
警告

你需要输入 Ctrl-C 来退出strace进程。

控制进程

当你直接在 Docker 服务器上有一个 shell 时,你可以通过多种方式处理容器化的进程,就像处理系统上运行的任何其他进程一样。如果你是远程的,你可能会使用docker container kill发送信号,因为这样做更方便。但是如果你已经登录到一个 Docker 服务器进行调试会话,或者因为 Docker 守护进程没有响应,你可以像处理任何其他进程一样直接kill这个进程。

除非您终止容器中顶级进程(容器内部的 PID 1),否则终止一个进程不会终止容器本身。如果终止一个失控的进程可能是有意义的,但这可能会使容器处于意外的状态。开发人员可能期望看到他们的容器在 docker container ls 中列出所有进程都在运行。这也可能会让像 Mesos 或 Kubernetes 这样的调度器或其他健康检查您应用程序的系统感到困惑。请记住,容器对外界表现为一个单一的整体。如果需要终止容器内部的某些内容,最好是替换整个容器。容器提供了一个工具可以进行交互的抽象。它们期望容器内部是可预测和一致的。

终止进程并不是发送信号的唯一原因。由于容器化的进程在许多方面都像普通进程一样,因此它们可以接收到 Linux kill 命令手册中列出的整套 Unix 信号。许多 Unix 程序在接收到特定的预定义信号时会执行特殊操作。例如,当接收到 SIGUSR1 信号时,nginx 会重新打开其日志。使用 Linux kill 命令,您可以向本地服务器上的容器进程发送任何 Unix 信号。

因为容器的工作方式与其他进程类似,所以了解它们如何与您的应用程序进行交互是非常重要的,特别是在一些会生成后台子进程的情况下,即任何进行分叉和守护化以使父进程不再管理子进程生命周期的情况。Jenkins 构建容器就是人们常见的一个例子,他们在这种情况下可能会出现问题。当守护进程分叉到后台时,它们会成为 Unix 系统上 PID 1 的子进程。进程 1 是特殊的,通常是某种 init 进程。

PID 1 负责确保子进程被回收。在您的容器中,默认情况下,您的主进程将是 PID 1。由于您可能不会处理应用程序中子进程的回收,因此可能会在容器中产生僵尸进程。对于这个问题有几种解决方案。首先是在容器中运行一个您自己选择的 init 系统,这个系统能够处理 PID 1 的责任。像 s6runit 以及前面笔记中描述的其他系统可以很容易地在容器内部使用。

但是 Docker 本身提供了一个更简单的选项,仅解决这一个情况而不需要具备完整 init 系统的所有功能。如果您在 docker container run 命令中提供 --init 标志,Docker 将启动一个非常小的 init 进程,基于 tini 项目,该进程将在容器启动时作为 PID 1 运行。无论您在 Dockerfile 中指定的 CMD 是什么,都将传递给 tini 并且工作方式与您期望的相同。然而,它会取代您在 DockerfileENTRYPOINT 部分可能设置的任何内容。

如果您在启动 Linux 容器时没有使用 --init 标志,您的进程列表中会得到类似这样的内容:

$ docker container run --rm -it alpine:3.16 sh
/ # ps -ef

PID   USER     TIME   COMMAND
 1 root       0:00 sh
 5 root       0:00 ps -ef

/ # exit

注意,在这种情况下,我们启动的 CMD 是 PID 1。这意味着它负责子进程的收割。如果我们启动的容器中这一点很重要,我们可以传递 --init 来确保当父进程退出时,子进程被收割:

$ docker container run --rm -it --init alpine:3.16 sh
/ # ps -ef

PID   USER     TIME   COMMAND
 1 root       0:00 /sbin/docker-init -- sh
 5 root       0:00 sh
 6 root       0:00 ps -ef

/ # exit

这里,您可以看到 PID 1 进程是 /sbin/docker-init。这反过来启动了作为命令行指定的 shell 二进制文件。因为现在容器内部有一个 init 系统,PID 1 的责任落在它身上,而不是我们用来调用容器的命令。在大多数情况下,这是您想要的。您可能不需要一个 init 系统,但是在生产环境中至少考虑在您的容器内部包含 tini

一般来说,如果您在容器内运行多个父进程或者有不正确响应 Unix 信号的进程,可能只有在容器内部需要一个 init 进程。

网络检查

与进程检查相比,调试容器化应用程序在网络层面可能更加复杂。与运行在主机上的传统进程不同,Linux 容器可以通过多种方式连接到网络。如果您正在运行默认设置,就像绝大多数人一样,那么您的所有容器都通过 Docker 创建的默认桥接网络连接到网络。这是一个虚拟网络,其中主机是通往世界其他地方的网关。我们可以使用 Docker 提供的工具检查这些虚拟网络。您可以通过调用 docker network ls 命令来显示存在的网络:

$ docker network ls

NETWORK ID     NAME      DRIVER    SCOPE
f9685b50d57c   bridge    bridge    local
8acae1680cbd   host      host      local
fb70d67499d3   none      null      local

这里我们可以看到默认的桥接网络、主机网络,用于任何以 host 网络模式运行的容器(参见 “主机网络”),以及禁用容器完全的 none 网络。如果您使用 docker compose 或其他编排工具,它们可能会创建额外的网络,并使用不同的名称。

但是看到存在哪些网络并不能使我们更容易地看到这些网络上存在什么。因此,您可以使用docker network inspect命令查看连接到任何特定命名网络的容器。这会产生相当多的输出。它会显示连接到指定网络的所有容器以及有关网络本身的许多详细信息。让我们来看看默认桥接网络:

$ docker network inspect bridge
[
    {
        "Name": "bridge",
        …
        "Driver": "bridge",
        "EnableIPv6": false,
        …
        "Containers": {
            "69e9…c87c": {
                "Name": "cadvisor",
                …
                "IPv4Address": "172.17.0.2/16",
                "IPv6Address": ""
            },
            "a2a8…e163": {
                "Name": "nginx-debug",
                …
                "IPv4Address": "172.17.0.3/16",
                "IPv6Address": ""
            }
        },
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            …
            "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
            "com.docker.network.bridge.name": "docker0",
            …
        },
        "Labels": {}
    }
]

为了减少输出量,我们在这里省略了一些细节。但是我们可以看到的是,在桥接网络上有两个容器,它们附加到主机上的docker0桥接上。我们还可以看到每个容器的 IP 地址(IPv4AddressIPv6Address)以及它们绑定到的主机网络地址(host_binding_ipv4)。这在您试图理解桥接网络的内部结构时非常有用。如果您的容器位于不同的网络上,根据网络的配置方式,它们可能无法相互连通。

提示

一般情况下,我们建议保留容器在默认桥接网络上,直到有充分理由或者运行docker compose或者自动管理容器网络的调度程序为止。此外,以某种可识别的方式命名您的容器在这里也是有帮助的,因为我们无法看到镜像信息。在这个输出中,名称和 ID 是我们唯一能够在docker container ls列表中找到的参考信息。一些调度程序在命名容器方面做得不好,这真是太糟糕了,因为这对调试非常有帮助。

正如我们所见,容器通常会有自己的网络堆栈和 IP 地址,除非它们在主机网络模式下运行,我们将在“网络”中进一步讨论这一点。但是当我们从主机机器自身查看它们时会怎么样?因为容器有自己的网络和地址,它们不会出现在主机上所有netstat输出中。但我们知道,您映射到容器的端口是绑定到主机的。

在 Docker 服务器上运行netstat -an正常工作,如下所示:

$ sudo netstat -an

Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address           Foreign Address        State
tcp        0      0 0.0.0.0:8080            0.0.0.0:*              LISTEN
tcp        0      0 127.0.0.53:53           0.0.0.0:*              LISTEN
tcp        0      0 0.0.0.0:22              0.0.0.0:*              LISTEN
tcp        0      0 192.168.15.158:22       192.168.15.120:63920   ESTABLISHED
tcp6       0      0 :::8080                 :::*                   LISTEN
tcp6       0      0 :::22                   :::*                   LISTEN
udp        0      0 127.0.0.53:53           0.0.0.0:*
udp        0      0 192.168.15.158:68       0.0.0.0:*
raw6       0      0 :::58                   :::*                   7
…

在这里,我们可以看到我们正在监听的所有接口。我们的容器绑定到 IP 地址0.0.0.0的端口8080上。这显示出来了。但是当我们要求netstat显示绑定到端口的进程名称时会发生什么?

$ sudo netstat -anp

Active Internet connections (servers and established)
Proto  … Local Address           Foreign Address      … PID/Program name
tcp    … 0.0.0.0:8080            0.0.0.0:*            … 1516/docker-proxy
tcp    … 127.0.0.53:53           0.0.0.0:*            … 692/systemd-resolve
tcp    … 0.0.0.0:22              0.0.0.0:*            … 780/sshd: /usr/sbin
tcp    … 192.168.15.158:22       192.168.15.120:63920 … 1348/sshd: vagrant
tcp6   … :::8080                 :::*                 … 1522/docker-proxy
tcp6   … :::22                   :::*                 … 780/sshd: /usr/sbin
udp    … 127.0.0.53:53           0.0.0.0:*            … 692/systemd-resolve
udp    … 192.168.15.158:68       0.0.0.0:*            … 690/systemd-network
raw6   … :::58                   :::*                 … 690/systemd-network

我们看到相同的输出,但注意绑定到端口的是什么:docker-proxy。这是因为在其默认配置中,Docker 有一个用 Go 编写的代理,位于所有容器和外部世界之间。这意味着当我们查看这个输出时,所有通过 Docker 运行的容器都将与docker-proxy关联。请注意,这里没有任何线索表明docker-proxy正在处理哪个特定的容器。幸运的是,docker container ls显示了绑定到哪些端口的容器,所以这并不是什么大问题。但这并不明显,在你调试生产故障之前,你可能希望意识到这一点。然而,通过向netstat传递p标志有助于识别与容器关联的端口。

注意

如果你在容器中使用主机网络,则会跳过此层。没有docker-proxy,容器中的进程可以直接绑定到端口。它还显示为netstat -anp输出中的正常进程。

其他网络检查命令的工作大部分如预期,包括tcpdump,但重要的是要记住docker-proxy在这里,在主机的网络接口和容器之间,并且容器在虚拟网络上有它们自己的网络接口。

镜像历史

当你构建和部署单个容器时,很容易追踪它的来源以及它位于哪些镜像之上。但是当你运送许多由不同团队构建和维护的镜像的容器时,这很快变得难以管理。你怎么知道哪些层实际上是你的容器运行在其上面的?你的容器的镜像标签有希望清楚地表明你正在运行的应用程序的哪个构建,但是镜像标签并不透露任何关于构建你的应用程序的镜像层的信息。docker image history正是为此而设。你可以看到检查的镜像中存在的每一层,每一层的大小以及用于构建它们的命令:

$ docker image history redis:latest

IMAGE        … CREATED BY                                      SIZE    COMMENT
e800a8da9469 … /bin/sh -c #(nop)  CMD ["redis-server"]         0B
<missing>    … /bin/sh -c #(nop)  EXPOSE 6379                  0B
<missing>    … /bin/sh -c #(nop)  ENTRYPOINT ["docker-entry…   0B
<missing>    … /bin/sh -c #(nop) COPY file:e873a0e3c13001b5…   661B
<missing>    … /bin/sh -c #(nop) WORKDIR /data                 0B
<missing>    … /bin/sh -c #(nop)  VOLUME [/data]               0B
<missing>    … /bin/sh -c mkdir /data && chown redis:redis …   0B
<missing>    … /bin/sh -c set -eux;   savedAptMark="$(apt-m…   32.4MB
<missing>    … /bin/sh -c #(nop)  ENV REDIS_DOWNLOAD_SHA=f0…   0B
<missing>    … /bin/sh -c #(nop)  ENV REDIS_DOWNLOAD_URL=ht…   0B
<missing>    … /bin/sh -c #(nop)  ENV REDIS_VERSION=7.0.4      0B
<missing>    … /bin/sh -c set -eux;  savedAptMark="$(apt-ma…   4.06MB
<missing>    … /bin/sh -c #(nop)  ENV GOSU_VERSION=1.14        0B
<missing>    … /bin/sh -c groupadd -r -g 999 redis && usera…   331kB
<missing>    … /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>    … /bin/sh -c #(nop) ADD file:6039adfbca55ed34a…   74.3MB

使用docker image history可以很有用,例如,当你试图确定最终镜像大小比预期大得多的原因时。层按顺序列出,最底部的是列表的第一层,最顶部的是列表的最后一层。

在一些情况下,我们可以看到命令输出已被截断。对于长命令,向docker image history命令添加--no-trunc选项将让您看到用于构建每个层的完整命令。只要注意--no-trunc会使输出在大多数情况下变得更大且更难以视觉扫描。

检查容器

在 第四章 中,我们向您展示了如何查看 docker container inspect 输出以查看容器的配置。但在此之下是主机磁盘上专用于容器的目录。通常情况下,这是 /var/lib/docker/containers。如果查看该目录,其中包含非常长的 SHA 哈希,如下所示:

$ sudo ls /var/lib/docker/containers

106ead0d55af55bd803334090664e4bc821c76dadf231e1aab7798d1baa19121
28970c706db0f69716af43527ed926acbd82581e1cef5e4e6ff152fce1b79972
3c4f916619a5dfc420396d823b42e8bd30a2f94ab5b0f42f052357a68a67309b
589f2ad301381b7704c9cade7da6b34046ef69ebe3d6929b9bc24785d7488287
959db1611d632dc27a86efcb66f1c6268d948d6f22e81e2a22a57610b5070b4d
a1e15f197ea0996d31f69c332f2b14e18b727e53735133a230d54657ac6aa5dd
bad35aac3f503121abf0e543e697fcade78f0d30124778915764d85fb10303a7
bc8c72c965ebca7db9a2b816188773a5864aa381b81c3073b9d3e52e977c55ba
daa75fb108a33793a3f8fcef7ba65589e124af66bc52c4a070f645fffbbc498e
e2ac800b58c4c72e240b90068402b7d4734a7dd03402ee2bce3248cc6f44d676
e8085ebc102b5f51c13cc5c257acb2274e7f8d1645af7baad0cb6fe8eef36e24
f8e46faa3303d93fc424e289d09b4ffba1fc7782b9878456e0fe11f1f6814e4b

这有点令人生畏。但这些只是容器长形式的容器 ID。如果要查看特定容器的配置,只需使用 docker container ls 查找其短 ID,然后找到匹配的目录即可:

$ docker container ls

CONTAINER ID   IMAGE                                   COMMAND              …
c58bfeffb9e6   gcr.io/cadvisor/cadvisor:v0.44.1-test   "/usr/bin/cadvisor…" …

你可以从 docker container ls 中查看短 ID,然后将其与 ls /var/lib/docker/containers 输出匹配,查看以 c58bfeffb9e6 开头的目录。命令行的选项卡完成在这里非常有帮助。如果需要精确匹配,可以执行 docker container inspect c58bfeffb9e6 并从输出中获取长 ID。此目录包含与容器相关的一些非常有趣的文件:

$ cd /var/lib/docker/containers/\
c58bfeffb9e6e607f3aacb4a06ca473535bf9588450f08be46baa230ab43f1d6

$ ls -la

total 48
drwx--x---  4 root root 4096 Aug 20 10:38 .
drwx--x--- 30 root root 4096 Aug 20 10:25 ..
-rw-r-----  1 root root  635 Aug 20 10:34 c58bf…f1d6-json.log
drwx------  2 root root 4096 Aug 20 10:24 checkpoints
-rw-------  1 root root 4897 Aug 20 10:38 config.v2.json
-rw-r--r--  1 root root 1498 Aug 20 10:38 hostconfig.json
-rw-r--r--  1 root root   13 Aug 20 10:24 hostname
-rw-r--r--  1 root root  174 Aug 20 10:24 hosts
drwx--x---  2 root root 4096 Aug 20 10:24 mounts
-rw-r--r--  1 root root  882 Aug 20 10:24 resolv.conf
-rw-r--r--  1 root root   71 Aug 20 10:24 resolv.conf.hash

正如我们在 第五章 中讨论的,此目录包含一些直接绑定到容器中的文件,如 hostsresolv.confhostname。如果您正在运行默认的日志记录机制,那么此目录也是 Docker 存储显示在 docker container logs 命令中的日志的 JSON 文件的地方,支持 docker container inspect 输出的 JSON 配置文件 (config.v2.json),以及容器的网络配置 (hostconfig.json)。resolv.conf.hash 文件由 Docker 使用,用于确定容器的文件与主机上当前文件有何不同,以便进行更新。

即使我们无法进入容器,或者 docker 不响应时,此目录在严重故障事件中也非常有帮助。了解容器的配置方式也非常有用。请记住,修改这些文件并不是一个好主意。Docker 期望它们包含现实情况,如果您改变这种现实,会带来麻烦。但这是了解容器内发生的事情的另一途径。

文件系统检查

无论实际使用的后端是什么,Docker 都具有分层文件系统,允许跟踪任何给定容器中的更改。这就是在构建时如何组装镜像的方式,但当您试图确定 Linux 容器是否有任何更改时,它也很有用。容器化应用的一个常见问题是它们可能会继续向容器文件系统写入东西。通常情况下,您不希望您的容器这样做,尽可能地,并且弄清楚您的进程是否一直在容器中写入东西可以帮助调试。有时这对于找出容器中存在的杂乱日志文件也很有帮助。与大多数核心工具一样,这种检查方式内置于docker命令行工具和 API 中。让我们看看这给我们展示了什么。让我们启动一个快速容器,并使用它的名称来探索这个问题:

$ docker container run --rm -d --name nginx-fs nginx:latest
1272b950202db25ee030703515f482e9ed576f8e64c926e4e535ba11f7536cc4

$ docker container diff nginx-fs
C /run
A /run/nginx.pid
C /var
C /var/cache
C /var/cache/nginx
A /var/cache/nginx/scgi_temp
A /var/cache/nginx/uwsgi_temp
A /var/cache/nginx/client_temp
A /var/cache/nginx/fastcgi_temp
A /var/cache/nginx/proxy_temp
C /etc
C /etc/nginx
C /etc/nginx/conf.d
C /etc/nginx/conf.d/default.conf

$ docker container stop nginx-fs
nginx-fs

每一行都以AC开头,分别代表新增更改。我们可以看到该容器正在运行nginxnginx配置文件已被写入,并且在名为/var/cache/nginx的新目录中创建了一些临时文件。当您试图优化和加固容器的文件系统使用时,了解容器文件系统的使用方式非常有用。

进一步详细的检查需要使用docker container exportdocker container execnsenter等方法来探索容器中的确切内容。但是,docker container diff给了您一个很好的起点。

Wrap-Up

到目前为止,您应该已经对如何在开发和生产中部署和调试单个容器有了很好的了解,但是如何开始为更大的应用程序生态系统扩展呢?在下一章中,我们将介绍一个较简单的 Docker 编排工具:Docker Compose。这个工具是单个 Linux 容器和生产编排系统之间的良好桥梁。它在开发环境中提供了很多价值,并贯穿整个 DevOps 流水线。

第八章:探索 Docker Compose

到此为止,您应该已经对docker命令有了良好的掌握,并且知道如何使用它来构建、启动、监视和调试您的应用程序。一旦您熟悉了单个容器的工作方式,不久您将想要分享您的项目,并开始构建更复杂的项目,这些项目需要多个容器才能正常运行。特别是在开发环境中,运行一整套容器可以轻松地模拟许多生产环境在您的本地机器上。

然而,如果您正在运行一整套容器,每个容器都需要使用正确的设置来确保底层应用程序被正确配置并按预期运行。每次都正确设置这些参数可能很具有挑战性,特别是当您不是最初编写应用程序的人时。为了在开发过程中提供帮助,人们通常会尝试编写可以一致构建和运行多个容器的 shell 脚本。尽管这样做是有效的,但对于新手来说可能难以理解,并且随着项目的变化难以维护。在不同项目之间也不一定可重复。

为了解决这个问题,Docker 公司发布了一个主要面向开发人员的工具,称为 Docker Compose。该工具包含在 Docker Desktop 中,但您也可以按照在线安装说明安装它。

注意

Docker Compose 最初是一个用 Python 编写的独立应用程序,通过docker-compose命令运行。这个命令被称为 Docker Compose 版本 1,并最近被 Docker Compose 版本 2 取代。Docker Compose v2 完全重写为 Go 语言,作为 Docker 客户端插件。如果docker compose version返回结果,则表示您已安装了插件。如果没有,请务必立即安装。

Docker Compose 是一个非常有用的工具,可以简化传统上非常繁琐和容易出错的各种开发任务。它可以轻松帮助开发人员快速启动复杂的应用程序堆栈,编译应用程序而无需设置复杂的本地开发环境,等等。

在本章中,我们将介绍如何最大限度地利用 Compose。在所有后续示例中,我们将使用 GitHub 存储库。如果您想在我们进行示例时运行示例,请运行以下命令下载代码,如果您尚未在第六章中执行此操作:

$ git clone https://github.com/spkane/rocketchat-hubot-demo.git \
    --config core.autocrlf=input
注意

在这个例子中,下面的一些行被截断以适应页面边距的 shell 脚本和docker-compose.yaml文件。如果您打算自己尝试这些示例,请确保使用此 Git 存储库中的文件。

这个仓库包含我们启动完整 Web 服务所需的配置,包括一个 MongoDB 数据存储、开源的 Rocket.Chat 通讯服务器、一个 Hubot ChatOps 机器人,以及一个 zmachine-api 实例,提供一些令人惊喜的娱乐价值。

配置 Docker Compose

在我们深入使用 docker compose 命令之前,看一下使用 shell 脚本构建和部署本地服务的即兴工具是很有用的。这个输出很长,也很详细,但重要的是要证明 Docker Compose 为什么是对 shell 脚本的巨大飞跃。

警告

我们不建议运行这个 shell 脚本。这只是一个示例,在你的环境中可能无法正常工作,或者会使事情变得很奇怪。

#!/bin/bash

# This is here just to keep people from really running this.
exit 1

# The actual script
#
# Note: This has not been updated to directly mirror the docker-compose file
#       since it is just intended to make a point.

set -e
set -u

if [ $# -ne 0 ] && [ ${1} == "down" ]; then
  docker rm -f hubot || true
  docker rm -f zmachine || true
  docker rm -f rocketchat || true
  docker rm -f mongo-init-replica || true
  docker rm -f mongo || true
  docker network rm botnet || true
  echo "Environment torn down…"
  exit 0
fi

# Global Settings
export PORT="3000"
export ROOT_URL="http://127.0.0.1:3000"
export MONGO_URL="mongodb://mongo:27017/rocketchat"
export MONGO_OPLOG_URL="mongodb://mongo:27017/local"
export MAIL_URL="smtp://smtp.email"
export RESPOND_TO_DM="true"
export HUBOT_ALIAS=". "
export LISTEN_ON_ALL_PUBLIC="true"
export ROCKETCHAT_AUTH="password"
export ROCKETCHAT_URL="rocketchat:3000"
export ROCKETCHAT_ROOM=""
export ROCKETCHAT_USER="hubot"
export ROCKETCHAT_PASSWORD="bot-pw!"
export BOT_NAME="bot"
export EXTERNAL_SCRIPTS="hubot-help,hubot-diagnostics,hubot-zmachine"
export HUBOT_ZMACHINE_SERVER="http://zmachine:80"
export HUBOT_ZMACHINE_ROOMS="zmachine"
export HUBOT_ZMACHINE_OT_PREFIX="ot"

docker build -t spkane/mongo:4.4 ./mongodb/docker

docker push spkane/mongo:4.4
docker pull spkane/zmachine-api:latest
docker pull rocketchat/rocket.chat:5.0.4
docker pull rocketchat/hubot-rocketchat:latest

docker rm -f hubot || true
docker rm -f zmachine || true
docker rm -f rocketchat || true
docker rm -f mongo-init-replica || true
docker rm -f mongo || true

docker network rm botnet || true

docker network create -d bridge botnet

docker container run-d \
  --name=mongo \
  --network=botnet \
  --restart unless-stopped \
  -v $(pwd)/mongodb/data/db:/data/db \
  spkane/mongo:4.4 \
  mongod --oplogSize 128 --replSet rs0
sleep 5
docker container run-d \
  --name=mongo-init-replica \
  --network=botnet \
  spkane/mongo:4.4 \
  'mongo mongo/rocketchat --eval "rs.initiate({ _id: ''rs0'', members: [ { … '
sleep 5
docker container run-d \
  --name=rocketchat \
  --network=botnet \
  --restart unless-stopped  \
  -v $(pwd)/rocketchat/data/uploads:/app/uploads \
  -p 3000:3000 \
  -e PORT=${PORT} \
  -e ROOT_URL=${ROOT_URL} \
  -e MONGO_URL=${MONGO_URL} \
  -e MONGO_OPLOG_URL=${MONGO_OPLOG_URL} \
  -e MAIL_URL=${MAIL_URL} \
  rocketchat/rocket.chat:5.0.4
docker container run-d \
  --name=zmachine \
  --network=botnet \
  --restart unless-stopped  \
  -v $(pwd)/zmachine/saves:/root/saves \
  -v $(pwd)/zmachine/zcode:/root/zcode \
  -p 3002:80 \
  spkane/zmachine-api:latest
docker container run-d \
  --name=hubot \
  --network=botnet \
  --restart unless-stopped  \
  -v $(pwd)/hubot/scripts:/home/hubot/scripts \
  -p 3001:8080 \
  -e RESPOND_TO_DM="true" \
  -e HUBOT_ALIAS=". " \
  -e LISTEN_ON_ALL_PUBLIC="true" \
  -e ROCKETCHAT_AUTH="password" \
  -e ROCKETCHAT_URL="rocketchat:3000" \
  -e ROCKETCHAT_ROOM="" \
  -e ROCKETCHAT_USER="hubot" \
  -e ROCKETCHAT_PASSWORD="bot-pw!" \
  -e BOT_NAME="bot" \
  -e EXTERNAL_SCRIPTS="hubot-help,hubot-diagnostics,hubot-zmachine" \
  -e HUBOT_ZMACHINE_SERVER="http://zmachine:80" \
  -e HUBOT_ZMACHINE_ROOMS="zmachine" \
  -e HUBOT_ZMACHINE_OT_PREFIX="ot" \
  rocketchat/hubot-rocketchat:latest
echo "Environment setup…"
exit 0

到了这一步,你可能可以轻松地跟随这个脚本的大部分内容。正如你可能已经注意到的,这读起来很麻烦,不太灵活,编辑起来也很痛苦,在几个地方可能会意外失败。如果我们遵循 shell 脚本的最佳实践,并在此处理所有可能的错误,以确保它是可重复的,那么它的长度也会是现在的两到三倍。而且,如果不花费大量工作来提取常见功能进行错误处理,每次像这样有一个新项目时,你也需要重写大部分逻辑。这并不是处理你每次使用时都需要工作的流程的好方法。这就是好工具的作用所在。你可以通过 Docker Compose 实现相同的效果,同时使其更加可重复,并且更易于阅读、理解和维护。

与这个混乱的、很容易重复出错的 shell 脚本相比,Docker Compose 通常通过一个单一的、声明性的 YAML 文件来配置每个项目,命名为 docker-compose.yaml。这个配置文件非常容易阅读,并且能够以非常可重复的方式工作,使得每个用户在运行时都有相同的体验。在这里,你可以看到一个示例 docker-compose.yaml 文件,可以用来替换上述脆弱的 shell 脚本:

version: '3'
services:
  mongo:
    build:
      context: ../mongodb/docker
    image: spkane/mongo:4.4
    restart: unless-stopped
    environment:
      MONGODB_REPLICA_SET_MODE: primary
      MONGODB_REPLICA_SET_NAME: rs0
      MONGODB_PORT_NUMBER: 27017
      MONGODB_INITIAL_PRIMARY_HOST: mongodb
      MONGODB_INITIAL_PRIMARY_PORT_NUMBER: 27017
      MONGODB_ADVERTISED_HOSTNAME: mongo
      MONGODB_ENABLE_JOURNAL: "true"
      ALLOW_EMPTY_PASSWORD: "yes"
    # Port 27017 already exposed by upstream
    # See the newer upstream Dockerfile:
    # https://github.com/bitnami/containers/blob/
    # f9fb3f8a6323fb768fd488c77d4f111b1330bd0e/bitnami/
    # mongodb/5.0/debian-11/Dockerfile#L52
    networks:
      - botnet
  rocketchat:
    image: rocketchat/rocket.chat:5.0.4
    restart: unless-stopped
    labels:
      traefik.enable: "true"
      traefik.http.routers.rocketchat.rule: Host(`127.0.0.1`)
      traefik.http.routers.rocketchat.tls: "false"
      traefik.http.routers.rocketchat.entrypoints: http
    volumes:
      - "../rocketchat/data/uploads:/app/uploads"
    environment:
      ROOT_URL: http://127.0.0.1:3000
      PORT: 3000
      MONGO_URL: "mongodb://mongo:27017/rocketchat?replicaSet=rs0"
      MONGO_OPLOG_URL: "mongodb://mongo:27017/local?replicaSet=rs0"
      DEPLOY_METHOD: docker
    depends_on:
      mongo:
        condition: service_healthy
    ports:
      - 3000:3000
    networks:
      - botnet
  zmachine:
    image: spkane/zmachine-api:latest
    restart: unless-stopped
    volumes:
      - "../zmachine/saves:/root/saves"
      - "../zmachine/zcode:/root/zcode"
    depends_on:
      - rocketchat
    expose:
      - "80"
    networks:
      - botnet
  hubot:
    image: rocketchat/hubot-rocketchat:latest
    restart: unless-stopped
    volumes:
      - "../hubot/scripts:/home/hubot/scripts"
    environment:
      RESPOND_TO_DM: "true"
      HUBOT_ALIAS: ". "
      LISTEN_ON_ALL_PUBLIC: "true"
      ROCKETCHAT_AUTH: "password"
      ROCKETCHAT_URL: "rocketchat:3000"
      ROCKETCHAT_ROOM: ""
      ROCKETCHAT_USER: "hubot"
      ROCKETCHAT_PASSWORD: "bot-pw!"
      BOT_NAME: "bot"
      EXTERNAL_SCRIPTS: "hubot-help,hubot-diagnostics,hubot-zmachine"
      HUBOT_ZMACHINE_SERVER: "http://zmachine:80"
      HUBOT_ZMACHINE_ROOMS: "zmachine"
      HUBOT_ZMACHINE_OT_PREFIX: "ot"
    depends_on:
      - zmachine
    ports:
      - 3001:8080
    networks:
      - botnet
networks:
  botnet:
    driver: bridge

docker-compose.yaml 文件使得描述每个服务的所有重要需求及其彼此之间如何通信变得非常容易。而且,我们得到了很多验证和逻辑检查,这些甚至在我们的 shell 脚本中也没有时间编写,而且我们可能会偶尔出错,无论我们多么小心。

那么,我们在那个 YAML 文件中告诉 Compose 做了什么呢?我们文件的第一行简单地告诉 Docker Compose 这个文件是为哪个版本的 Compose 配置语言 设计的:

version: '3'

我们的文档其余部分分为两个部分:servicesnetworks

首先,让我们快速查看 networks 部分。在这个 docker-compose.yaml 文件中,我们定义了一个单一命名的 Docker 网络:

networks:
  botnet:
    driver: bridge

这是一个非常简单的配置,告诉 Docker Compose 创建一个名为 botnet 的单一网络,使用(默认的)桥接驱动程序,它将 Docker 网络与主机的网络堆栈桥接。

services 部分是配置的最重要部分,告诉 Docker Compose 您要启动哪些应用程序。在这里,services 部分定义了五个服务:mongomongo-init-replicarocketchatzmachinehubot。然后,每个命名服务都包含部分,告诉 Docker 如何构建、配置和启动该服务。

如果您查看 mongo 服务,您会发现第一个子节称为 build,其中包含一个 context 键。这告知 Docker Compose 可以构建此镜像,并且构建所需的文件位于 ../../mongodb/docker 目录中,这是比包含 docker-compose.yaml 文件的目录高两级的目录:

    build:
      context: ../../mongodb/docker

如果您查看 mongodb/docker 目录中的 Dockerfile,您将看到这个:

FROM mongo:4.4

COPY docker-healthcheck /usr/local/bin/

# Useful Information:
# https://docs.docker.com/engine/reference/builder/#healthcheck
# https://docs.docker.com/compose/compose-file/#healthcheck
HEALTHCHECK CMD ["docker-healthcheck"]

看一下 HEALTHCHECK 行。这告诉 Docker 应运行哪个命令来检查容器的健康状况。Docker 不会根据此健康检查采取行动,但它会报告健康状况,以便其他组件可以利用这些信息。如果您感兴趣,请随时查看 mongodb/docker 目录中的 docker-healthcheck 脚本。

接下来的设置 image 定义了要应用于构建或下载(如果不构建镜像)并运行的镜像标签:

    image: spkane/mongo:4.4

通过 restart 选项,您告诉 Docker 您希望它何时重新启动您的容器。在大多数情况下,您希望 Docker 在您没有明确停止它们时重新启动您的容器:

    restart: unless-stopped

接下来,您将看到一个 environment 部分。这是您可以定义要传递到容器中的任何环境变量的地方:

    environment:
      MONGODB_REPLICA_SET_MODE: primary
      MONGODB_REPLICA_SET_NAME: rs0
      MONGODB_PORT_NUMBER: 27017
      MONGODB_INITIAL_PRIMARY_HOST: mongodb
      MONGODB_INITIAL_PRIMARY_PORT_NUMBER: 27017
      MONGODB_ADVERTISED_HOSTNAME: mongo
      MONGODB_ENABLE_JOURNAL: "true"
      ALLOW_EMPTY_PASSWORD: "yes"

对于 mongo 服务的最后一个子节 networks,告诉 Docker Compose 应将此容器附加到哪个网络:

    networks:
      - botnet

现在,让我们转到 rocketchat 服务。此服务没有 build 子节;相反,它只定义了一个镜像标签,告诉 Docker Compose 不能构建此镜像,必须尝试拉取并启动具有定义标签的现有 Docker 镜像。

您将在此服务中注意到的第一个新子节称为 volumes

大多数服务至少有一些数据在开发过程中应该是持久的,尽管容器的临时性质。为了实现这一点,最简单的方法是将本地目录挂载到容器中。volumes 部分允许您列出所有想要挂载到容器中的本地目录,并定义它们的目标位置。此命令将把 ../rocketchat/data/uploads 绑定挂载到容器内部的 /app/uploads

    volumes:
      - "../rocketchat/data/uploads:/app/uploads"
警告

您可能已经注意到,我们没有为 MongoDB 定义一个 volume,这可能看起来有点违反直觉。尽管绑定挂载的卷对于存储数据库文件很有用,但 MongoDB 将无法写入到原生的 Windows 文件系统中,因此我们将其排除在外,以实现最广泛的兼容性,并让数据库在此开发用例中写入容器内部。

这样做的主要结果是,当您使用像 docker compose down 这样的命令删除容器时,MongoDB 实例中的所有数据都将丢失。

我们可以通过使用 数据卷容器 轻松解决这个 MongoDB 存储问题,但这个示例特别使用绑定挂载来处理卷。

提示

在几乎所有情况下,您不应在生产环境中使用基于主机的本地存储来存储容器。这在开发中可能非常方便,因为您使用的是单个主机,但在生产环境中,您的容器通常会部署到有空间和资源的任何节点,并且无法访问存储在单个主机文件系统上的文件。在生产环境中,如果需要有状态存储,您必须利用网络存储、Kubernetes 持久卷等。

rocketchat 服务的 environment 部分,您会看到 MONGO_URL 的值不使用 IP 地址或完全限定域名。这是因为所有这些服务都在同一个 Docker 网络上运行,并且 Docker Compose 配置每个容器,使其可以通过其服务名称找到其他容器。这意味着我们可以轻松地配置像这样的 URL,只需简单地指向我们需要连接的容器的服务名称和内部端口。而且,如果重新排列这些内容,这些名称将继续指向我们堆栈中正确的容器。它们也很好,因为它们使读者非常明确地了解该容器的依赖关系是什么:

    environment:
      …
      MONGO_URL: "mongodb://mongo:27017/rocketchat?replicaSet=rs0"
      …
提示

docker-compose.yaml 文件还可以使用 ${VARIABLE_NAME} 格式引用环境变量,这样可以在不实际存储它们在此文件中的情况下引入秘密。Docker Compose 还支持一个 .env 文件,这对于处理在开发者之间变化的秘密和环境变量非常有用。

depends_on 部分定义了一个容器,必须在此容器启动之前才能启动。默认情况下,docker compose 仅确保容器正在运行,而不是健康运行;然而,您可以利用 Docker 中的 HEALTHCHECK 功能以及 Docker Compose 中的条件语句,要求依赖的服务在 Docker Compose 启动新服务之前处于健康状态。重要的是要记住,这只影响启动阶段。Docker 将报告稍后变得不健康的服务,但除非容器退出,否则不会采取任何措施来纠正情况,在这种情况下,如果配置为这样做,Docker 将重新启动容器:

    depends_on:
      mongo:
        condition: service_healthy
注意

我们在 “容器健康检查” 中详细讨论了 Docker 的健康检查功能。您还可以在 DockerDocker Compose 的文档中找到更多信息。

ports 子部分允许您定义要从容器映射到主机的所有端口:

    ports:
      - 3000:3000

zmachine 服务仅使用了一个名为 expose 的新子部分。该部分允许我们告诉 Docker,我们希望将此端口暴露给 Docker 网络上的其他容器,而不暴露给底层主机。这就是为什么您不提供主机端口来映射此端口的原因:

    expose:
      - "80"

您可能注意到,尽管我们为 zmachine 暴露了一个端口,但我们没有在 mongo 服务中暴露端口。暴露 mongo 端口不会有害,但我们不需要这样做,因为上游的 mongo Dockerfile 已经暴露了它。这有时会有点难以理解。在构建的镜像上运行 docker image history 可以帮助理解这一点。

这里我们使用了一个足够复杂的示例,以便让您了解 Docker Compose 的一些强大功能,但这并不是详尽无遗的。在 docker-compose.yaml 文件中,您可以配置很多其他内容,包括安全设置、资源配额等等。您可以在官方 Docker Compose 文档中找到关于 Compose 配置的详细信息。

启动服务

我们在 YAML 文件中为我们的应用程序配置了一组服务。这告诉 Compose 我们要启动什么以及如何配置它。因此,让我们开始运行它!要运行我们的第一个 Docker Compose 命令,我们需要确保我们与 docker-compose.yaml 文件位于同一目录中:

$ cd rocketchat-hubot-demo/compose

一旦您进入正确的目录,您可以通过运行以下命令确认配置是否正确:

$ docker compose config

如果一切正常,该命令将打印出您的配置文件。如果有问题,命令将打印出详细的错误信息,如下所示:

services.mongo Additional property builder is not allowed

您可以使用 build 选项构建所需的任何容器。将跳过使用镜像的任何服务:

$ docker compose build

 => [internal] load build definition from Dockerfile                      0.0s
 => => transferring dockerfile: 32B                                       0.0s
 => [internal] load .dockerignore                                         0.0s
 => => transferring context: 2B                                           0.0s
 => [internal] load metadata for docker.io/bitnami/mongodb:4.4            1.2s
 => [auth] bitnami/mongodb:pull token for registry-1.docker.io            0.0s
 => [internal] load build context                                         0.0s
 => => transferring context: 40B                                          0.0s
 => [1/2] FROM docker.io/bitnami/mongodb:4.4@sha256:9162…ae209            0.0s
 => CACHED [2/2] COPY docker-healthcheck /usr/local/bin/                  0.0s
 => exporting to image                                                    0.0s
 => => exporting layers                                                   0.0s
 => => writing image sha256:a6ef…da808                                    0.0s
 => => naming to docker.io/spkane/mongo:4.4                               0.0s

可以通过运行以下命令在后台启动您的网络服务:

$ docker compose up -d

[+] Running 5/5
 ⠿ Network compose_botnet                  Created                        0.0s
 ⠿ Container compose-mongo-1               Healthy                       62.0s
 ⠿ Container compose-rocketchat-1          Started                       62.3s
 ⠿ Container compose-zmachine-1            Started                       62.5s
 ⠿ Container compose-hubot-1               Started                       62.6s

Docker Compose 将网络和容器名称以项目名称作为前缀。默认情况下,这是包含您的 docker-compose.yaml 文件的目录名称。由于此命令在名为 compose 的目录中运行,您可以看到所有内容以compose作为项目名称开头。

警告

Windows 用户:当您首次启动服务时,Windows 可能会提示您授权 vpnkit,而 Docker Desktop for Windows 也可能会提示您分享磁盘。您必须点击“允许访问”和“分享”按钮,以确保网络和卷共享正常工作,并且一切正常启动。

一旦所有服务启动完成,我们可以快速查看所有服务的日志(参见图 8-1):

$ docker compose logs

docker compose logs 输出

图 8-1. docker compose logs 输出

在此处打印的日志不易看清,但如果您在跟随操作,请注意所有日志都按服务进行了颜色编码,并且按 Docker 接收日志行的时间进行了交织。这样可以更轻松地跟踪发生的事情,即使多个服务同时记录消息。

Rocket.Chat 可能需要一些时间来设置数据库并准备接受连接。一旦 Rocket.Chat 日志打印包含 SERVER RUNNING 的行,一切就准备就绪了:

$ docker compose logs rocketchat | grep "SERVER RUNNING"

compose-rocketchat-1  | |                SERVER RUNNING                |

此时,我们已成功启动了一个相当复杂的应用程序,它由一堆容器堆栈组成。我们现在来看一下这个简单的应用程序,这样您就可以看到我们构建的内容,并更全面地了解 Compose 工具。虽然下一部分与 Docker 本身没有直接关系,但旨在向您展示使用 Docker Compose 设置复杂且功能齐全的网络服务有多么容易。

探索 Rocket.Chat

注意

在本节中,我们将暂时偏离 Docker,转而看一看 Rocket.Chat。我们将花几页时间介绍它,以便您了解足够多的信息,希望您能开始感受到使用 Docker Compose 设置复杂环境有多么容易。如果愿意,可以跳转到 “使用 Docker Compose 进行练习”。

我们很快将深入探讨设置背后发生的情况。但为了有效地做到这一点,我们现在应该花一点时间探索我们构建的应用程序堆栈。Rocket.Chat,我们使用 Docker Compose 启动的主要应用程序,是一个开源的聊天客户端/服务器应用程序。要查看其工作原理,让我们启动一个网络浏览器,并导航至 http://127.0.0.1:3000

当您到达目标时,您会看到 Rocket.Chat 的管理信息屏幕(参见图 8-2)。

Rocket.Chat 管理信息屏幕

图 8-2. Rocket.Chat 管理信息界面

填写表格如下:

  • 全名:student

  • 用户名:student

  • 电子邮件:student@example.com

  • 密码:student-pw!

然后点击蓝色的“下一步”按钮。

然后你会看到组织信息界面(参见图 8-3)。

Rocket.Chat 组织信息界面

图 8-3. Rocket.Chat 组织信息界面

此表格的具体内容并不关键,但你可以填写类似以下的内容:

  • 组织名称:培训

  • 组织类型:社区

  • 组织行业:教育

  • 组织规模:1-10 人

  • 国家:美国

然后点击蓝色的“下一步”按钮。

此时,您会看到注册您的服务器界面(参见图 8-4)。

Rocket.Chat 注册您的服务器界面

图 8-4. Rocket.Chat 注册您的服务器界面

你可以简单地删除和取消所有选择,然后点击小蓝色的“继续作为独立站点”链接。接下来,你会看到独立服务器配置界面(参见图 8-5)。

Rocket.Chat 独立服务器配置界面

图 8-5. Rocket.Chat 独立服务器配置界面

点击蓝色的“确认”按钮。

警告

如果你使用的是 localhost 或者其他非 127.0.0.1 来访问 Rocket.Chat,可能会弹出一个窗口询问是否要更新 SITE_URL。在大多数情况下,可以允许它更新该值,以便与你使用的匹配。

恭喜你,现在你已经成功登录到一个完全功能的聊天客户端,但你的工作还没有完成。Docker Compose 配置启动了一个 Hubot 聊天助手和神秘的 zmachine,让我们来看看它们。

由于 Rocket.Chat 服务器是全新的,我们的机器人还没有可用的用户。让我们来解决这个问题。

首先点击左侧边栏顶部,那里有一个紫色盒子里面带有字母 S。点击弹出菜单中的管理(参见图 8-6)。

Rocket.Chat 管理侧边栏

图 8-6. Rocket.Chat 管理侧边栏

在管理面板中,点击用户(参见图 8-7)。

Rocket.Chat 用户界面

图 8-7. Rocket.Chat 用户界面

在屏幕右上角,点击“新建”按钮以显示添加用户界面(参见图 8-8)。

Rocket.Chat 添加用户界面

图 8-8. Rocket.Chat 添加用户界面

填写表格如下:

  • 名称:hubot

  • 用户名:hubot

  • 电子邮件:hubot@example.com

  • 点击:已验证(蓝色)

  • 密码:bot-pw!

  • 角色:机器人

  • 禁用:发送欢迎电子邮件(灰色)

点击保存以创建用户。

为了确保机器人能够登录,我们还需要禁用默认启用的双因素身份验证。为此,请在浏览器左侧的管理侧边栏底部单击“设置”(图 8-9)。

Rocket.Chat 管理设置

图 8-9. Rocket.Chat 管理设置

显示设置屏幕(图 8-10)。

Rocket.Chat 帐户设置

图 8-10. Rocket.Chat 帐户设置

在新的文本搜索栏中,输入 **totp**,然后在帐户下点击打开按钮。

您现在应该看到一个长长的设置列表(图 8-11)。

Rocket.Chat TOTP 设置

图 8-11. Rocket.Chat TOTP 设置

滚动到双因素身份验证部分,展开它,然后取消选择“启用双因素身份验证”选项。

完成此操作后,请单击“保存更改”。

在管理面板左侧顶部,单击 X 关闭面板(图 8-12)。

Rocket.Chat 关闭管理面板

图 8-12. Rocket.Chat 关闭管理面板

在左侧面板的“频道”下,点击“general”(图 8-13)。

Rocket.Chat 通用频道

图 8-13. Rocket.Chat 通用频道

最后,如果您在频道中尚未看到消息“Hubot 已加入频道”,请告诉 Docker Compose 重新启动 Hubot 容器。这将强制 Hubot 再次尝试登录聊天服务器,现在已经有一个用户可以使用该服务:

$ docker compose restart hubot
Restarting unix_hubot_1 … done

如果一切按计划进行,您现在应该能够返回网页浏览器并在聊天窗口中发送命令给 Hubot。

注意

当 Hubot 登录服务器时,应自动加入“General”频道,但以防万一,您可以在“General”频道中发送以下消息,显式邀请 Hubot:

/invite @hubot

您可能会收到来自内部管理员 rocket.cat 的消息,内容是“@hubot 已经在这里了。” 这完全正常。

用于配置 Hubot 的环境变量将其别名定义为一个句点。现在您可以尝试输入 . help 来测试机器人是否响应。如果一切正常,您应该会收到机器人理解并响应的命令列表:

> . help
. adapter - Reply with the adapter
. echo <text> - Reply back with <text>
. help - Displays all of the help commands that this bot knows about.
. help <query> - Displays all help commands that match <query>.
. ping - Reply with pong
. time - Reply with current time
…

最后,请尝试输入以下内容:

. ping

Hubot 应该会回复 PONG

如果您键入:

. time

然后 Hubot 将告诉您服务器上的时间设置。

所以,作为最后的转移,尝试通过在聊天窗口中键入 /create zmachine 创建一个新的聊天频道。现在,您应该能够在左侧边栏中点击新的 zmachine 频道,并使用聊天命令 /invite @hubot 邀请 Hubot。

注意

当您这样做时,Hubot 可能会说:

There's no game for zmachine!

这没有什么好担心的。

接下来,尝试在聊天窗口中输入以下命令,以玩一个基于聊天的著名游戏 巨洞探险 的版本:

. z start adventure

more
look
go east
examine keys
get keys

. z save firstgame
. z stop
. z start adventure
. z restore firstgame

inventory
警告

交互式小说可能会让人上瘾,是一个巨大的时间消耗。您已经被警告了。话虽如此,如果您对此感兴趣并希望了解更多信息,请查看以下一些资源:

现在您已经看到了如何配置、启动和管理复杂的 Web 服务的简单方法,这些服务需要多个组件来完成其工作,使用 Docker Compose。在接下来的部分中,我们将探讨 Docker Compose 包含的更多功能。

注意

通过提供 MongoDB 与预配置的 Rocket.Chat 数据库,您可以避免大部分 Rocket.Chat 设置,但是去除此示例中的任何魔术是很重要的,以便更清楚地展示所有组件如何配合。

使用 Docker Compose 进行练习

现在,您已经运行了完整的 Rocket.Chat 堆栈,并了解了应用程序的运行方式,我们可以深入挖掘一些服务运行的细节。一些常见的 Docker 命令也暴露为 Compose 命令,但是针对的是特定的堆栈,而不是主机上的单个容器或所有容器。您可以运行 docker compose top 来查看您的容器及其内运行的进程的概述:

$ docker compose top

compose-hubot-1
UID  PID   … CMD
1001 73342 … /usr/bin/qemu-x86_64 /bin/sh /bin/sh -c node -e "console.l…"
1001 73459 … /usr/bin/qemu-x86_64 /usr/local/bin/node node node_modules/…

compose-mongo-1
UID  PID   … CMD
1001 71243 … /usr/bin/qemu-x86_64 /opt/bitnami/mongodb/bin/mongod /opt/…

compose-rocketchat-1
UID   PID   … CMD
65533 71903 … /usr/bin/qemu-x86_64 /usr/local/bin/node node main.js

compose-zmachine-1
UID  PID   … CMD
root 71999 … /usr/bin/qemu-x86_64 /usr/local/bin/node node /root/src/server.js
root 75078 … /usr/bin/qemu-x86_64 /root/src/../frotz/dfrotz /root/src/…

类似于您通常使用docker container exec命令进入运行中的 Linux 容器一样,您可以通过 Docker Compose 工具使用docker compose exec命令在容器内运行命令。由于docker compose是一个较新的工具,它提供了一些方便的快捷方式来替代标准的docker命令。对于docker compose exec来说,您不需要传入-i -t,而且可以使用 Docker Compose 服务名称,而不是记住容器的 ID 或名称:

$ docker compose exec mongo bash

I have no name!@0078134f9370:/$ mongo
MongoDB shell version v4.4.15
connecting to: mongodb://127.0.0.1:27017/?compressors=disabled&…
Implicit session: session { "id" : UUID("daec9543-bb9c-4e8c-ba6b…") }
MongoDB server version: 4.4.15
…
rs0:PRIMARY> exit
bye
I have no name!@0078134f9370:/$ exit
exit
提示

docker compose logsdocker compose exec 可能是用于故障排除最有用的命令。如果 Docker Compose 无法构建您的映像或根本无法启动您的容器,您将需要回退到标准的 docker 命令来调试您的映像和容器,正如我们在 “故障排除破损的构建” 和 “进入正在运行的容器” 中讨论的那样。

您还可以使用 Docker Compose startstop,在大多数环境中,pauseunpause 一个单独的容器或所有容器,具体取决于您的需求:

$ docker compose stop zmachine
[+] Running 1/1
 ⠿ Container compose-zmachine-1  Stopped                                  0.3s
$ docker compose start zmachine
[+] Running 2/2
 ⠿ Container compose-mongo-1     Healthy                                  0.5s
 ⠿ Container compose-zmachine-1  Started                                  0.4s
$ docker compose pause
[+] Running 4/0
 ⠿ Container compose-mongo-1       Paused                                 0.0s
 ⠿ Container compose-zmachine-1    Paused                                 0.0s
 ⠿ Container compose-rocketchat-1  Paused                                 0.0s
 ⠿ Container compose-hubot-1       Paused                                 0.0s
$ docker compose unpause
[+] Running 4/0
 ⠿ Container compose-zmachine-1    Unpaused                               0.0s
 ⠿ Container compose-hubot-1       Unpaused                               0.0s
 ⠿ Container compose-rocketchat-1  Unpaused                               0.0s
 ⠿ Container compose-mongo-1       Unpaused                               0.0s

最后,当您想要清理并删除 Docker Compose 创建的所有容器时,可以运行以下命令:

$ docker compose down
[+] Running 5/5
 ⠿ Container compose-hubot-1       Removed                               10.4s
 ⠿ Container compose-zmachine-1    Removed                                0.1s
 ⠿ Container compose-rocketchat-1  Removed                                0.6s
 ⠿ Container compose-mongo-1       Removed                                0.9s
 ⠿ Network compose_botnet          Removed                                0.1s
警告

当您使用docker compose down命令删除 MongoDB 容器时,MongoDB 实例中的所有数据都将丢失。

管理配置

Docker Compose 提供了几个重要的功能,可以帮助您显著提高docker-compose.yaml文件的灵活性。在本节中,我们将探讨如何避免将许多配置值硬编码到您的docker-compose.yaml文件中,同时仍然使它们默认情况下易于使用。

默认值

如果我们查看docker-compose.yaml文件中services:rocketchat:environment部分,我们将看到类似于以下内容:

    environment:
      RESPOND_TO_DM: "true"
      HUBOT_ALIAS: ". "
      LISTEN_ON_ALL_PUBLIC: "true"
      ROCKETCHAT_AUTH: "password"
      ROCKETCHAT_URL: "rocketchat:3000"
      ROCKETCHAT_ROOM: ""
      ROCKETCHAT_USER: "hubot"
      ROCKETCHAT_PASSWORD: "bot-pw!"
      BOT_NAME: "bot"
      EXTERNAL_SCRIPTS: "hubot-help,hubot-diagnostics,hubot-zmachine"
      HUBOT_ZMACHINE_SERVER: "http://zmachine:80"
      HUBOT_ZMACHINE_ROOMS: "zmachine"
      HUBOT_ZMACHINE_OT_PREFIX: "ot"

现在,如果我们查看同一目录中的docker-compose-defaults.yaml文件,我们将看到该部分看起来像这样:

    environment:
      RESPOND_TO_DM: ${HUBOT_RESPOND_TO_DM:-true}
      HUBOT_ALIAS: ${HUBOT_ALIAS:-. }
      LISTEN_ON_ALL_PUBLIC: ${HUBOT_LISTEN_ON_ALL_PUBLIC:-true}
      ROCKETCHAT_AUTH: ${HUBOT_ROCKETCHAT_AUTH:-password}
      ROCKETCHAT_URL: ${HUBOT_ROCKETCHAT_URL:-rocketchat:3000}
      ROCKETCHAT_ROOM: ${HUBOT_ROCKETCHAT_ROOM:-}
      ROCKETCHAT_USER: ${HUBOT_ROCKETCHAT_USER:-hubot}
      ROCKETCHAT_PASSWORD: ${HUBOT_ROCKETCHAT_PASSWORD:-bot-pw!}
      BOT_NAME: ${HUBOT_BOT_NAME:-bot}
      EXTERNAL_SCRIPTS: ${HUBOT_EXTERNAL_SCRIPTS:-hubot-help,
                          hubot-diagnostics,hubot-zmachine}
      HUBOT_ZMACHINE_SERVER: ${HUBOT_ZMACHINE_SERVER:-http://zmachine:80}
      HUBOT_ZMACHINE_ROOMS: ${HUBOT_ZMACHINE_ROOMS:-zmachine}
      HUBOT_ZMACHINE_OT_PREFIX: ${HUBOT_ZMACHINE_OT_PREFIX:-ot}

这是一种称为变量插值的技术,Docker Compose 直接从许多常见的 Unix shell(如bash)中借用了它。

在原始文件中,环境变量ROCKETCHAT_PASSWORD硬编码为值"bot-pw!"

      ROCKETCHAT_PASSWORD: "bot-pw!"

通过使用这种新方法,我们声明希望将ROCKETCHAT_PASSWORD设置为用户环境中设置了HUBOT_ROCKETCHAT_PASSWORD变量的值,如果没有设置,则ROCKETCHAT_PASSWORD应该设置为默认值bot-pw!

      ROCKETCHAT_PASSWORD: ${HUBOT_ROCKETCHAT_PASSWORD:-bot-pw!}

这为我们提供了很大的灵活性,因为我们现在几乎可以使所有内容都可配置,同时为最常见的用例提供合理的默认值。我们可以通过使用新文件运行docker compose up来轻松测试这一点:

$ docker compose -f docker-compose-defaults.yaml up -d

[+] Running 5/5
 ⠿ Network compose_botnet          Created                                0.0s
 ⠿ Container compose-mongo-1       Healthy                               31.0s
 ⠿ Container compose-rocketchat-1  Started                               31.2s
 ⠿ Container compose-zmachine-1    Started                               31.5s
 ⠿ Container compose-hubot-1       Started                               31.8s

默认情况下,这将导致我们之前启动的完全相同的堆栈。但是,现在我们可以通过在运行我们的docker compose命令之前在终端中简单地设置一个或多个环境变量来轻松进行更改:

$ docker compose -f docker-compose-defaults.yaml down
…

$ docker compose -f docker-compose-defaults.yaml config | \
    grep ROCKETCHAT_PASSWORD

 ROCKETCHAT_PASSWORD: bot-pw!

$ HUBOT_ROCKETCHAT_PASSWORD="my-unique-pw" docker compose \
    -f docker-compose-defaults.yaml config | \
    grep ROCKETCHAT_PASSWORD

 ROCKETCHAT_PASSWORD: my-unique-pw
提示

在这些示例中,Docker Compose 将空环境变量与设置为空字符串的环境变量完全相同对待。如果空字符串是您用例中的有效值,则您将希望修改变量替换行的格式,使其看起来像这样:${VARIABLE_NAME-default-value}。我们建议阅读此功能的文档,以便了解所有可能性。

这非常好,但是如果我们不想提供任何默认值,而是想强制用户设置某些内容,该怎么办呢?我们也可以很容易地做到这一点。

警告

有些读者可能对我们将密码作为命令行的一部分传递感到不适,因为这些密码可能在系统进程列表中可见等等,但不用担心——我们将在几分钟内解决这个问题。

强制值

要设置一个必填值,我们只需稍微修改变量替换行。看起来将默认密码传递进去似乎是一个坏主意,所以让我们继续并要求该值为必填项。

docker-compose-defaults.yaml文件中,ROCKETCHAT_PASSWORD被定义为这样:

      ROCKETCHAT_PASSWORD: ${HUBOT_ROCKETCHAT_PASSWORD:-bot-pw!}

在更新的docker-compose-env.yaml文件中,我们可以看到它被定义为这样:

      ROCKETCHAT_PASSWORD:
        ${HUBOT_ROCKETCHAT_PASSWORD:?HUBOT_ROCKETCHAT_PASSWORD must be set!}

而不是包含默认值,这种方法在变量未在环境中设置为非空字符串时定义了一个错误字符串。如果我们现在尝试简单地启动这些服务,将会收到错误消息:

$ docker compose -f docker-compose-env.yaml up -d

invalid interpolation format for
 services.hubot.environment.ROCKETCHAT_PASSWORD.
You may need to escape any $ with another $.
required variable HUBOT_ROCKETCHAT_PASSWORD is missing a value:
 HUBOT_ROCKETCHAT_PASSWORD must be set!

输出为我们提供了一些关于可能出错的提示,但最后两行非常清晰,最终消息正是我们定义的确切错误消息,因此可以根据情况设置为最有意义的内容。

如果我们继续输入我们自己的密码,那么一切都会正常启动:

$ HUBOT_ROCKETCHAT_PASSWORD="a-b3tt3r-pw" docker compose \
    -f docker-compose-env.yaml up -d

[+] Running 5/5
 ⠿ Network compose_botnet          Created                      0.0s
 ⠿ Container compose-mongo-1       Healthy                     31.0s
 ⠿ Container compose-rocketchat-1  Started                     31.3s
 ⠿ Container compose-zmachine-1    Started                     31.5s
 ⠿ Container compose-hubot-1       Started                     31.8s

$ docker compose -f docker-compose-env.yaml down
…

dotenv 文件

传递单个环境变量并不难,但如果您需要传递许多自定义值,甚至一个真正的秘密,那么在本地终端中设置它们并不理想。这就是.env(dotenv)文件非常有用的地方。

.env文件是一个特殊文件标准,旨在被需要特定于本地环境的附加配置信息的程序解析。

在上述用例中,我们必须设置一个密码来启动我们的 Docker Compose 环境。我们可以每次传入环境变量,但至少有几个理由不是很理想。如果我们能以某种对单用户环境合理安全且使我们的生活更轻松且减少错误的方式设置它,那将是很好的。

本质上,.env文件只是一个键值对列表。由于这个文件是特定于本地环境的,通常至少包含一个秘密,因此我们应该首先确保永远不会意外地将这些文件提交到我们的版本控制系统中。要做到这一点,使用git,我们只需确保我们的.gitignore文件包含.env,在这种情况下,它已经包含了:

$ grep .env ../.gitignore
.env

假设我们在单用户系统上,现在可以在包含我们的docker-compose.yaml文件的同一目录中安全地创建一个.env文件。

作为示例,让我们继续并让我们的.env文件的内容看起来像这样:

HUBOT_ROCKETCHAT_PASSWORD=th2l@stPW!

我们可以在这个文件中添加许多键值对,但为了保持简单,我们只关注这个密码。如果您在创建这个文件后运行git status,您应该注意到git完全忽略了新文件,这正是我们想要的:

$ git status
On branch main
Your branch is up to date with 'origin/main'.

nothing to commit, working tree clean
注意

.env文件不是 Unix shell 脚本。在这种格式与您可能在标准 shell 脚本中定义变量的方式之间有微妙但重要的区别。最重要的区别是,在大多数情况下,您不应该用引号括起值。

在上一节中,当我们运行docker compose -f docker-compose-env.yaml up -d时,没有设置HUBOT_ROCKETCHAT_PASSWORD,我们遇到了一个错误,但是如果我们在创建了.env文件后再试一次,一切应该都会正常工作:

$ docker compose -f docker-compose-env.yaml up -d

[+] Running 5/5
 ⠿ Network compose_botnet          Created                      0.0s
 ⠿ Container compose-mongo-1       Healthy                     31.1s
 ⠿ Container compose-rocketchat-1  Started                     31.3s
 ⠿ Container compose-zmachine-1    Started                     31.5s
 ⠿ Container compose-hubot-1       Started                     31.8s

让我们确认已经分配给ROCKETCHAT_PASSWORD的值是否与我们在.env文件中设置的值相同:

$ docker compose \
    -f docker-compose-env.yaml config | \
    grep ROCKETCHAT_PASSWORD

 ROCKETCHAT_PASSWORD: th2l@stPW!

我们可以看到该值确实设置为我们在.env文件中定义的值。这是因为 Docker Compose 始终会读取位于与我们正在使用的docker-compose.yaml文件相同目录中的.env文件中定义的键/值对。

在此处影响优先级的理解非常重要。Docker Compose 首先会读取docker-compose.yaml文件中设置的所有默认值。然后它会读取.env文件,并覆盖文件中定义的任何默认值。最后,它会查看在本地环境中设置的任何环境变量,并使用这些变量覆盖先前定义的值。

这意味着文件中的默认设置应该是最常见的设置,然后每个用户可以在本地.env文件中定义他们的常见更改,并在需要对特定用例进行不寻常更改时依赖于本地环境变量。使用这些功能与 Docker Compose 一起可以确保您可以构建一个非常可重复的流程,同时仍具有足够的灵活性来涵盖大多数常见工作流程。

提示

Docker Compose 还有一些我们没有涵盖的附加功能,比如覆盖文件。随着您对 Docker Compose 的使用越来越多,值得您花时间查阅文档,以了解可能对项目有用的任何其他功能。

总结

现在您应该对使用 Docker Compose 可以实现的功能有了很好的了解,以及如何使用这个工具来减少重复操作并增加开发环境的可重复性。

在下一章中,我们将探讨一些工具,这些工具可帮助您在数据中心内部和云中扩展 Docker。

^(1) 完整 URL:https://ifarchive.org/indexes/if-archiveXgamesXzcode.html

第九章:通往生产容器的道路

现在,我们已经探讨了在单个主机上启动一堆容器的工具,接下来我们需要看看在大规模生产环境中如何做到这一点。在本章中,我们的目标是向您展示如何根据我们自己的经验将容器引入生产环境。您可能需要根据您的应用程序和环境进行多方面的调整,但这应该为您提供一个坚实的起点,帮助您理解 Docker 的实际理念。

达到生产环境

将应用程序从构建和配置的阶段部署到运行在生产系统上的阶段,是从零到生产过程中最充满挑战的步骤之一。传统上这一过程很复杂,但是通过货运集装箱模型大大简化了。如果你能想象在集装箱出现之前,把货物装上要过海的船是什么样子,你就能体会到大多数传统部署系统的样子。在那种旧的航运模式中,各种大小不一的箱子、板条箱、桶和各种其他包装物都是手工装载到船上的。然后必须由人手动卸载,以便知道哪些部分需要先卸载,以防整堆物品像 Jenga 拼图一样倒塌。

这一切都因为货运集装箱的出现而改变:现在我们有了标准化的、尺寸已知的箱子。这些集装箱可以按照逻辑顺序打包和卸载,整组物品可以按时一起到达。航运业建立了高效管理这些箱子的机制。Docker 的部署模型非常类似。所有的 Linux 容器支持相同的外部接口,工具只需将它们放在它们应该放置的服务器上,而不必关心里面装的是什么。

在新模型中,当我们有了运行中的应用程序构建时,我们不需要编写太多定制工具来启动部署。如果我们只想将它发送到一个服务器,docker 命令行工具将为我们处理大部分工作。如果我们想要将其发送到更多的服务器,则需要查看更广泛的容器生态系统中更高级的工具。无论哪种情况,您的应用程序都需要了解一些事项和考虑一些问题,然后才能将您的容器化应用程序带到生产环境。

在使用 Docker 将应用程序部署到生产环境时,您将遵循以下步骤:

  1. 在开发机上本地构建和测试 Docker 镜像。

  2. 为测试和部署构建您的官方镜像,通常使用持续集成(CI)或构建系统。

  3. 将镜像推送到注册表。

  4. 将您的 Docker 镜像部署到服务器,然后配置和启动容器。

随着你的工作流程的发展,最终你将把所有这些步骤合并成一个流畅的工作流程:

  1. 组织构建、测试和镜像存储以及将容器部署到生产服务器。

但故事远不止于此。在最基本的层面上,一个生产故事必须包括三个方面:

  • 这必须是一个可重复的过程。每次调用它时,它都需要做同样的事情。理想情况下,它将为所有你的应用做同样的事情。

  • 它需要为你处理配置。你必须能够在特定环境中定义应用程序的配置,然后保证它会在每次部署时传送该配置。

  • 它必须提供一个可启动的可执行构件。

要实现这一点,有几件事情你需要考虑。我们将通过提供一个框架来帮助你思考你的应用在其环境中的情况。

Docker 在生产环境中的角色

我们已经介绍了 Docker 带来的许多功能,并讨论了一些通用的生产策略。在我们深入探讨生产容器之前,让我们看看 Docker 如何适应传统和更现代的生产环境。如果你正在从更传统的系统转向 Docker,你可以选择将哪些部分委托给 Docker,部署工具,或者更大的平台,比如 Kubernetes 或基于云的容器系统,或者甚至决定留在更传统的基础设施上。我们已成功地将多个系统从传统部署转换为容器化系统,并有许多好的解决方案。但了解所需的组件以及现代和更传统变体的构成将使你做出明智的选择。

在图 9-1 中,我们描述了生产系统中需要考虑的几个问题,以及解决这些问题的现代组件,以及它们可能在更传统环境中替代的系统。我们将这些问题分为 Docker 本身解决的问题和我们所谓的平台所解决的问题。平台是一个通常包围着一组服务器的系统,并为 Linux 容器管理提供一个通用接口。这可能是一个统一的系统,如 Kubernetes 或 Docker Swarm,或者它可能由组件组成,这些组件结合在一起形成一个平台。在过渡到具有调度程序的完全容器化系统期间,平台可能同时扮演多种角色。因此,让我们看看每个问题是如何相互关联的。

Docker 在生产中的角色

图 9-1. Docker 在生产系统中的角色

在 图 9-1 中,您可以看到应用程序位于堆栈顶部。在生产系统中,它依赖于其下的所有关注点。在某些情况下,您的环境可能会明确地调用这些关注点,而在其他情况下,它们可能由您并非认为填充该关注点的东西来处理。但您的生产应用程序将以某种方式依赖于这些关注点中的大多数,并且需要在生产环境中加以解决。如果您希望从现有环境过渡到基于 Linux 容器的环境,则需要考虑如何提供当前的这些解决方案以及如何在新系统中进行处理。

我们将从熟悉的领域开始,然后从底部到顶部进行。那个熟悉的领域就是您的应用程序。您的应用程序位于顶部!其他所有内容都在那里以向您的应用程序提供功能。毕竟,应用程序提供业务价值,而其他所有内容都旨在使这成为可能,在规模和可靠性上进行支持,并在应用程序之间标准化其工作方式。尽管底部项目的顺序是有意的,但并非每个层次都向上面的层次提供功能。它们都是向应用程序本身提供功能。

因为 Linux 容器和 Docker 可以简化许多这些功能,容器化您的系统将使许多这些选择变得更加容易。随着我们接近堆栈的平台部分,我们将有更多需要考虑的内容,但了解位于其下的所有内容将使这个过程变得更加可管理。

让我们从应用程序作业控制开始。

作业控制

作业控制是现代部署的基本要求。这是关注点图中蓝色块的一部分。基本上,没有任何形式的系统都无法进行作业控制。这是我们传统上更多地留给操作系统或者更具体地说是 Linux 初始化系统(如 systemd、System V initrunit、BSD rc 脚本等)的一部分。我们告诉操作系统要运行一个进程,然后我们配置在重新启动它、重新加载其配置和管理应用程序生命周期时的行为。

当我们希望启动或停止应用程序时,我们依赖这些系统来处理。在某些情况下,我们还依赖它们更强大地保持应用程序的运行,例如在应用程序崩溃时重新启动它。不同的应用程序需要不同的作业控制。在传统的 Linux 系统中,您可能使用 cron 定时启动和停止作业。systemd 可能负责在应用程序崩溃时重新启动您的应用程序。但是,系统如何处理这些操作取决于该系统的具体情况,有许多不同的实现方法可供选择,这并不理想。

如果我们正在转向集装箱模型,我们希望能够从外部更多或更少地以相同的方式处理所有作业。我们可能需要一些关于它们的元数据来使它们做正确的事情,但我们不想查看容器内部。Docker 引擎提供了一组强大的围绕作业控制的基元,例如docker container startdocker container stopdocker container rundocker container kill,这些映射到应用程序生命周期中的大多数关键步骤。所有围绕 Docker 容器构建的平台,包括 Kubernetes,在这些生命周期行为上也遵循这些行为。我们将这放在关注点堆栈的底部,因为这基本上是 Docker 为您的应用程序提供的最低抽象。即使我们不使用 Docker 的任何其他部分,这也是一个巨大的胜利,因为对于所有应用程序和运行 Docker 容器的所有平台来说都是相同的。

资源限制

位于作业控制之上的是资源限制。在 Linux 系统中,如果需要的话,可以直接使用Linux 控制组(cgroups)来管理资源限制,一些生产环境确实已经这样做了。但更传统的做法是依赖于像ulimit和应用运行环境的不同设置,比如 Java、Ruby 或 Python 虚拟机。在云系统中,一个早期的成功之处是我们可以启动单独的虚拟服务器来限制单个业务应用程序周围的资源。这是一个很好的创新:不再有吵闹的邻居应用程序。然而,与容器相比,这是一种相当粗糙的控制。

使用 Linux 容器,您可以通过 cgroups 轻松地为您的容器应用一系列广泛的资源控制。在生产环境中,您可以自行决定是否限制应用程序对内存、磁盘空间或 I/O 的访问。然而,我们强烈建议您一旦熟悉了应用程序的需求,就花时间做这些。如果不这样做,您将无法利用容器化应用程序的核心功能之一:在同一台机器上运行多个应用程序,基本上不会相互干扰。正如我们所讨论的,Docker 为您提供了这一功能,这是使容器有价值的核心部分。您可以查看 Docker 用于管理这些资源的具体参数在第五章。

网络

有关 Docker 网络的详细信息在第十一章中有很多内容,我们在这里不会过多触及,但你的容器化系统需要管理连接网络上的应用程序。Docker 提供了丰富的网络配置选项。你应该在生产环境中选择一种机制,并在所有容器中标准化使用该机制。试图混合使用这些机制不是一条通往成功的容易路径。如果你正在运行像 Kubernetes 这样的平台,那么这些决策中的一些将会被代替。但好消息是,通常情况下,网络如何构建的复杂性不是容器中应用程序关注的问题。请考虑 Docker 或更大的平台会为你提供这些功能,只要你遵循一些规则,你的应用程序在本地机器上作为容器内部运行时,与在生产环境中的运行方式基本相同:

  1. 依赖于 Docker 或你的平台动态映射端口,并告知应用程序它们映射到哪里。这通常以环境变量的形式提供给应用程序。

  2. 避免使用像 FTP 或 RTSP 这样映射随机端口用于返回流量的协议。在容器化平台中支持这一点非常困难。

  3. 依赖于 Docker 或生产运行时为你的容器提供的 DNS。

如果你遵循这些规则,那么通常你的应用程序可以相对独立于其部署位置。大多数生产环境都会提供定义实际配置并在运行时应用它们的能力。Docker Compose、Docker Swarm 模式、Kubernetes 和云服务提供的运行时(如 ECS),都会为你处理这些事务。

配置

所有应用程序都需要以某种方式访问它们的配置。对于一个应用程序,有两个级别的配置。最低级别是它期望其周围的 Linux 环境如何配置。容器通过提供一个Dockerfile来处理这个问题,我们可以重复构建相同的环境。在更传统的系统中,我们可能会使用像 Chef、Puppet 或 Ansible 这样的配置管理系统来做这件事。在容器化的世界中,你仍然可以使用这些系统,但通常不会用它们来为应用程序提供依赖项。这项工作归 Docker 和Dockerfile负责。即使Dockerfile的内容因应用程序的不同而不同,但机制和工具都是一样的——这是一个巨大的胜利。

配置的下一级是直接应用于应用程序的配置。我们之前详细讨论过这个问题。Docker 的本地机制是使用环境变量,这在所有现代平台上都适用。一些系统特别是使依赖于更传统的配置文件更加容易。特别是 Kubernetes,它使得依赖于文件相对容易,但如果您真正希望一个可移植的、容器本地化的应用程序,我们建议不要这样做。我们发现这可能会显著影响应用程序的可观察性,并劝阻您依赖这个支架。有关环境变量背后推理的更多内容请参阅第十三章。

包装和交付

我们在这里将包装和交付放在一起讨论。这是一个容器化系统比传统系统具有重大优势的领域。在这里,我们无需费尽想象力就能看到与运输集装箱模型的相似之处:我们有一个一致的包装,即容器镜像,以及一个标准化的方式将它们传送到目的地——Docker 的注册中心以及image pullimage push功能。在更传统的系统中,我们可能已经构建了手工制作的部署工具,其中一些工具希望我们能够在应用程序中进行标准化。但如果我们需要一个多语言环境,这将会带来麻烦。在您的容器化环境中,您需要考虑如何将您的应用程序打包成镜像以及如何存储这些镜像。

对于后者来说,最简单的路径是订阅托管的商业镜像注册表服务。如果您的公司可以接受这一点,那么您应该考虑这一点。包括亚马逊在内的几个云提供商都有您可以部署在您环境内的镜像托管服务,这是另一个很好的选择。当然,您也可以构建和维护内部私有注册表,就像我们在“运行私有注册表”中所讨论的那样。提供给您的生态系统中有广泛的服务提供商可供选择,您应该调查您的选择。

日志记录

日志记录位于你可以依赖 Docker 在容器化环境中提供的关注点和平台需要管理的关注点的边界。这是因为,正如我们在第六章详细说明的那样,Docker 可以收集所有容器的日志并将其发送至某个地方。但默认情况下,这个地方甚至不在本地系统之外。对于规模有限的环境来说可能很好,如果本地主机存储足够好的话,你可以在那里停止考虑这个问题。但是你的平台将负责处理来自大量应用程序和多个系统的日志,因此你可能希望将这些日志集中到一个显著提高可见性和简化故障排除的系统中。在设计时,请参考第六章以获取更多关于日志记录的详细信息。一些系统如 Kubernetes,在收集日志方面持有自己的观点。但从应用程序的角度来看,你只需要确保它们发送到stdoutstderr,让 Docker 或平台处理其余部分即可。

监控

系统的第一部分并非由 Docker 或 Linux 容器一般整齐地捆绑起来,但仍然通过 Docker 带来的标准化得到改进。像在第六章讨论的那样,以标准化的方式进行应用程序健康检查意味着简化了监控应用程序健康状况的流程。在许多系统中,平台本身处理监控,调度程序将动态关闭不健康的容器,并可能将工作负载移至不同服务器或重新启动同一系统上的工作负载。在旧系统中,容器通常由现有系统如 Nagios、Zabbix 或其他传统监控系统监控。正如我们在第六章展示的那样,还有一些新的选项,包括像 Prometheus 这样的系统。应用程序性能监控(APM)供应商如 New Relic、Datadog 或 Honeycomb,都对 Linux 容器及其所包含的应用程序提供了一流的支持。因此,如果你的应用程序已经由其中之一进行监控,那么你可能不需要做太多改动。

在旧系统中,通常是工程师被调度并响应问题,并做出如何处理失败应用程序的决策。在动态系统中,这项工作通常转移到属于平台内部的更自动化的过程中。在过渡期间,你的系统可能会同时拥有两者,同时向自动化系统转移,只有当平台真的无法干预时,工程师才会被调度。无论如何,人类仍然需要作为最后的防线。但是当事情出错时,容器化系统要处理起来要容易得多,因为这些机制在应用程序间是标准化的。

调度

你如何决定哪些服务运行在哪些服务器上?由于 Docker 提供了很好的机制,容器易于移动。这打开了更好资源利用、更好的可靠性、自愈服务和动态扩展的多种可能性。但是,某些东西必须做出这些决策。

在旧系统中,通常使用专用服务器处理每个服务。你经常会在部署脚本中配置一系列服务器,并且每次部署时,同一组服务器都会接收新的应用程序。每服务器一服务的模型推动了私有数据中心中早期虚拟化的发展。云系统通过将服务器切割成商品化虚拟服务器,鼓励了每服务器一服务的模型。像 AWS 中的自动扩展处理了这种动态行为的部分。但是,如果你转向容器,许多服务可能在同一虚拟服务器上运行,服务器级别的扩展和动态行为就不再适用了。

分布式调度器

分布式调度器利用 Docker 让你几乎可以像操作单个计算机一样思考整个服务器网络。这里的想法是,你定义一些关于如何运行应用程序的策略,然后让系统决定在哪里运行以及运行多少个实例。如果服务器或应用程序出现问题,你可以让调度器在任何可用的健康资源上重新启动,以满足应用程序的要求。这更符合 Docker 公司创始人Solomon Hykes对 Docker 的最初愿景:一种无需担心如何运输应用程序的方式。通常,在这种模型中的零停机部署是通过蓝绿部署风格完成的,即在旧一代应用程序旁边启动新一代应用程序,然后逐步将工作从旧堆栈迁移到新堆栈。

现在,使用了由 Kelsey Hightower 所提出的著名隐喻,调度器就像是为你玩俄罗斯方块,动态地在服务器上放置服务,以达到最佳匹配。

尽管 Kubernetes 不是第一个(Mesos 和 Cloud Foundry 等平台荣耀归于它们),但是今天,Kubernetes,这个来自 Google 于 2014 年的项目,无疑是基于容器的调度器的领导者。早期的 Kubernetes 发布借鉴了 Google 从其内部 Borg 系统中学到的经验,并将其带给了开源社区。它从一开始就建立在 Docker 和 Linux 容器上,不仅支持 Docker 的 containerd,还支持几种其他容器运行时——所有这些都使用 Docker 容器。Kubernetes 是一个庞大的系统,有许多组件在运作。有许多不同的商业和基于云的 Kubernetes 发行版。云原生计算基金会为确保每个发行版在更广泛的 Kubernetes 社区内符合某些标准提供认证。这个领域仍在迅速变化,虽然 Kubernetes 功能强大,但它是一个积极发展的目标,难以跟进。如果你正在从头开始构建一个全新的系统,你可能会强烈考虑 Kubernetes。如果没有其他经验,如果你在云上运行,你的提供者的实现可能是最简单的路径。虽然我们鼓励你考虑它用于任何复杂系统,但 Kubernetes 不是唯一的选择。

Docker Swarm 模式于 2015 年由 Docker, Inc. 推出,并从头开始构建为 Docker 本地系统。如果你正在寻找一个非常简单的编排工具,完全基于 Docker 平台,并由单一供应商支持,那么它可能是一个吸引人的选择。Docker Swarm 模式在市场上并没有得到广泛采用,由于 Docker 在其工具中大量整合 Kubernetes,这条路线可能不像以前那样清晰了。

编排

当我们谈论调度程序时,我们通常不仅谈论它们匹配作业到资源的能力,还谈论它们的编排能力。通过这样,我们指的是能够在整个系统中命令和组织应用程序和部署的能力。你的调度程序可能会动态移动作业,或允许你在每个服务器上专门运行任务。在旧系统中,这更常见地由特定的编排工具处理。

在大多数现代容器系统中,包括调度在内的所有编排任务都由核心集群软件处理,无论是 Kubernetes、Swarm、云提供商的专有容器管理系统,还是其他系统。

在平台提供的所有功能中,调度无疑是最强大的功能。当将应用程序迁移到容器中时,调度对应用程序的影响最大。许多传统应用程序没有设计为在服务发现和资源分配发生变化时正常运行,并且需要进行大量修改才能在真正动态的环境中正常工作。因此,您迁移到容器化系统不一定意味着最初就转向调度平台。通常,将应用程序容器化并在传统系统内运行,然后逐步转向更动态的调度系统是通往生产容器的最佳路径。这可能意味着最初在当前部署应用程序的服务器上以容器的形式运行您的应用程序,一旦运行良好,再引入调度器。

服务发现

您可以将服务发现看作是应用程序在网络上找到所有其他所需服务和资源的机制。几乎没有不依赖于任何其他内容的应用程序。无状态的静态网站可能是唯一不需要任何服务发现的系统。几乎所有其他系统都需要了解周围系统的信息,并需要一种发现这些信息的方式。大多数情况下,这涉及多个系统,但它们通常紧密耦合。

在传统系统中,你可能不会把它们看作这样,但负载均衡器是服务发现的主要手段之一。负载均衡器用于可靠性和扩展性,同时也跟踪与特定服务相关的所有终端点。有时这是手动配置的,有时更加动态,但其他系统找到服务的终端点的方式是使用负载均衡器的已知地址或名称。这是一种服务发现的形式,在旧系统中普遍采用负载均衡器来实现这一点。即使在现代环境中,它们也经常用于此目的,即使它们看起来与传统的负载均衡器差异很大。在旧系统中进行服务发现的其他方式包括静态数据库配置或应用程序配置文件。

正如您在图 9-1 中看到的那样,Docker 在您的环境中不解决服务发现问题,除非使用 Docker Swarm 模式。对于绝大多数系统,服务发现留给平台处理。这意味着这是您需要在更动态的系统中解决的第一件事情。容器本质上容易移动,这可能会破坏围绕静态部署应用程序构建的传统系统。每个平台处理这个问题的方式不同,您需要了解什么对您的系统最有效。

注意

Docker Swarm(经典 Swarm)Docker Swarm mode 并不相同。我们将在 第十章 中更详细地讨论 Docker Swarm mode。

你可能熟悉的一些服务发现机制示例包括以下内容:

这是一个庞大的列表,比这更多的选项还有很多。其中一些系统不仅仅是服务发现,这可能会使问题变得更加复杂。一个可能更接近你理解这个概念的服务发现例子是 Docker Compose 在 第八章 中使用的链接机制。该机制依赖于 dockerd 服务器提供的 DNS 系统,允许 Docker Compose 中的一个服务引用另一个对等服务的名称并返回正确的容器 IP 地址。在其最简单的形式下,Kubernetes 也有类似的系统,可以通过注入环境变量来实现。但这些都是现代系统中最简单的发现形式。

通常情况下,你会发现这些系统的接口依赖于为服务设置的众所周知的名称和/或端口。你可能会调用 http://service-a.example.com 来访问一个众所周知的服务名为 service A。或者你可能会调用 http://services.example.com:service-a-port 来访问相同的服务名和端口。现代环境通常会以不同的方式处理这些问题。通常,在新系统中,这个过程会被管理得非常无缝。对于新应用程序从平台向传统系统调用可能不太容易。通常来说,最佳的初始系统(虽然不一定是长期系统)是为旧环境中的系统提供动态配置的易于访问的负载均衡器。如果你在使用 Kubernetes,它提供了 Ingress 路由,这可能是一种值得考虑的路径。

其中一些示例包括以下内容:

如果您正在运行混合现代和传统系统,将流量引入新的容器化系统通常是更难解决的问题,也是您应该首先考虑的问题。

生产环境总结

许多人将从使用简单的 Docker 编排工具开始。然而,随着容器数量的增加以及部署容器的频率增加,分布式调度器的吸引力很快就会显现出来。像 Kubernetes 这样的工具允许您将单个服务器和整个数据中心抽象为资源池,以运行基于容器的任务。

毫无疑问,在部署领域还有许多其他值得关注的项目。但这些项目是目前引用最多并且具有最多公开信息的。这是一个快速发展的领域,因此值得四处寻找,看看有哪些新工具正在推出。

无论如何,您应该首先启动一个 Linux 容器基础设施,然后再查看外部工具。Docker 的内置工具可能对您来说已经足够好了。我们建议使用最轻量级的工具来完成工作,但是具有灵活性是一个很好的处境,并且 Linux 容器正逐渐得到越来越强大的支持。

Docker 和 DevOps 流水线

因此,一旦我们考虑并实现了所有这些功能,我们的生产环境应该非常稳健。但是我们怎么知道它是否有效?Docker 的一个关键承诺是能够在与生产环境完全相同的操作环境中测试您的应用程序及其所有依赖项。它不能保证您已经正确测试了像数据库这样的外部依赖项,也不提供任何神奇的测试框架,但它可以确保您的库和其他代码依赖项都一起进行了测试。更改底层依赖关系是事情出错的一个关键地方,即使是对于具有强大测试纪律的组织也是如此。通过 Docker,您可以构建镜像,在开发环境中运行它,然后在持续集成管道中测试相同的镜像,然后再将其部署到生产服务器上。

测试您的容器化应用程序并不比测试应用程序本身复杂得多,只要您的测试环境设计用于管理 Linux 容器工作负载。接下来,让我们来看一个如何实现这一点的示例。

快速概述

让我们为一个虚构公司绘制一个生产环境的示例。我们将尝试描述与许多公司类似的环境,并加入 Docker 以进行说明。

我们虚构公司的环境拥有一组运行 Docker 守护程序和各种应用程序的生产服务器。有多个构建和测试工作器与管道协调服务器绑定。目前我们会忽略部署,一旦我们的虚构应用程序经过测试并准备好发布,我们再来讨论它。

图 9-2 展示了测试容器化应用程序的常见工作流程,包括以下步骤:

  1. 通过外部手段触发构建,例如从源代码仓库的 Webhook 调用或开发人员手动触发。

  2. 构建服务器启动容器镜像构建。

  3. 镜像是在本地服务器上创建的。

  4. 镜像被标记为构建或版本号或提交哈希。

  5. 基于新构建的镜像配置的新容器,用于运行测试套件。

  6. 测试套件针对容器运行,并且结果由构建服务器捕获。

  7. 构建标记为通过或失败。

  8. 通过的构建被发送到镜像注册表或其他存储机制。

你会注意到,这与测试应用程序的常见模式并没有太大不同。至少,你需要有一个能够启动测试套件的作业。我们在这里添加的步骤只是首先创建一个容器镜像并在容器内部调用测试套件。

使用 Docker 进行测试时的典型工作流程

图 9-2. Docker 测试工作流程图

让我们看看这在我们虚构公司部署的应用程序中是如何工作的。我们刚刚更新了我们的应用程序,并将最新的代码推送到我们的 Git 仓库。我们有一个提交后钩子,每次提交时都会触发构建,因此该作业在运行dockerd守护进程的构建服务器上启动,该服务器也在运行。作业在构建服务器上分配任务给测试工作者。工作者没有运行dockerd,但已安装了docker命令行工具。因此,我们对远程dockerd守护进程运行我们的docker image build,在远程 Docker 服务器上生成新的镜像。

注意

你应该像在生产中一样构建你的容器镜像。如果需要为测试做出让步,它们应该是外部提供的开关,可以通过环境变量或命令行参数提供。整个想法是测试你将要发布的确切构建,因此这一点至关重要。

一旦镜像构建完成,我们的测试作业将创建并运行一个基于新生产镜像的新容器。我们的镜像配置为在生产中运行应用程序,但是我们需要为测试运行不同的命令。没问题!Docker 让我们可以简单地在docker container run命令的末尾提供命令来做到这一点。在生产中,我们的虚拟容器将启动supervisor,然后启动nginx实例和一些 Ruby Unicorn Web 服务器实例。但是对于测试,我们不需要nginx,也不需要运行我们的 Web 应用程序。相反,我们的构建作业像这样调用容器:

$ docker container run -e ENVIRONMENT=testing -e API_KEY=12345 \
    -it awesome_app:version1 /opt/awesome_app/test.sh

我们调用了docker container run,但这里我们也做了一些额外的事情。我们将一些环境变量传递到容器中:ENVIRONMENTAPI_KEY。这些可以是新的变量,也可以是 Docker 已为我们导出的变量的覆盖。我们还请求了一个特定的标签—在本例中是version1。这将确保我们在正确的镜像上构建,即使另一个构建正在同时运行。然后,我们重写了容器在DockerfileCMD行中配置的启动命令。相反,我们调用我们的测试脚本/opt/awesome_app/test.sh。虽然在这个例子中不是必需的,但是请注意,在某些情况下,您需要覆盖DockerfileENTRYPOINT--entrypoint)以运行与该容器的默认命令不同的内容。

提示

始终将精确的 Docker 标签(通常是版本或提交哈希)传递到测试作业中。如果始终使用latest,则无法保证另一个作业在您的构建启动后不会移动该标签。如果使用尽可能精确的标签,那么您可以确保测试的是正确的应用程序构建。

这里要强调的一个关键点是,docker container run将退出与在容器中调用的命令的退出状态相同。这意味着我们只需查看退出状态即可查看我们的测试是否成功。如果您的测试套件设计良好,这可能就足够了。如果您需要运行多个步骤,或者不能依赖退出代码,处理这种情况的一种方法是将测试运行的所有输出捕获到文件中,然后筛选输出以查找状态消息。我们的虚构构建系统正是这样做的。我们将测试套件的输出写入文件,并且我们的test.sh在最后一行上打印出Result: SUCCESS!Result: FAILURE!来表示我们的测试是否通过。如果您需要依赖此机制,请确保查找一些在您正常的测试套件输出中不会偶然出现的输出字符串。例如,如果我们需要查找“success”,那么我们应该限制在文件的最后一行查找,并且可能还要确保整行匹配我们通常期望的确切输出。在这种情况下,我们只查看文件的最后一行,并找到了我们的成功字符串,因此我们标记构建为通过。

在容器特定步骤中,还有一个额外的步骤。我们希望获取已通过的构建并将该镜像推送到我们的注册表。注册表是构建和部署之间的交换点。它还允许我们与同行和可能构建在其之上的其他构建共享镜像。但现在,让我们将其视为我们放置和标记成功构建的地方。我们的构建脚本现在将执行docker image tag来给镜像打上正确的构建标签(可能包括latest),然后执行docker image push将构建推送到注册表。

就是这样!正如你所见,与测试普通应用程序相比,并没有太多的复杂性。我们利用了 Docker 的客户端/服务器模型,在不同的服务器上调用测试,并将我们的测试打包成一个整合的 shell 脚本来生成我们的输出状态。总体来说,这与大多数其他现代构建系统方法非常相似。

最关键的一点是,我们虚构公司的系统确保他们只发布那些在相同 Linux 发行版、相同库和相同构建设置下通过测试套件的应用程序。然后,该容器还可能被测试以确保没有模拟的数据库或缓存等外部依赖。这些并不能保证成功,但它们让我们比那些没有建立在容器技术上的生产部署系统更接近成功。

注意

如果你正在使用 Jenkins 进行持续集成,或者正在寻找一个测试 Docker 扩展性的好方法,那么值得研究的 Docker、Mesos 和 Kubernetes 的插件有很多。现在许多托管的商业平台也提供容器化的 CI 环境,包括CircleCIGitHub Actions

外部依赖

那么我们忽略的外部依赖怎么办?像数据库、或者我们需要在容器中运行测试的 Memcached 或 Redis 实例?如果我们虚构的公司的应用程序需要数据库来运行,或者需要 Memcached 或 Redis 实例,我们需要解决这些外部依赖以获得一个干净的测试环境。使用容器模型来支持这些依赖将是一个不错的选择。通过像Docker Compose这样的工具,你可以做到这一点,我们在第八章中详细描述了这一点。在 Docker Compose 中,我们的构建作业可以表达一些容器之间的依赖关系,然后 Compose 会无缝地将它们连接起来。

能够在类似应用程序将要生存的环境中测试您的应用程序是一个巨大的优势。Compose 使得这一点非常容易设置。您仍然需要依赖于您自己语言的测试框架进行测试,但是环境确实很容易编排。

总结

现在我们已经调查了容器化应用程序如何与外部环境交互,以及在每个领域的边界在哪里,我们已经准备好探讨如何构建 Docker 集群来支持许多现代技术运营的全球、始终在线和按需的特性。

^(1) 完整网址:https://kubernetes.io/docs/concepts/services-networking/ingress

^(2) 完整网址:https://doc.traefik.io/traefik/providers/kubernetes-ingress

第十章:规模化容器

容器的一个主要优势是其能够将底层硬件和操作系统抽象化,使得您的应用程序不受限于任何特定主机或环境。这有助于在您的数据中心内水平扩展无状态应用程序,也可以跨多个云提供商进行扩展,而无需遇到许多传统障碍。与运输容器的隐喻一致,在一个云上的容器看起来像在另一个云上的容器一样。

许多组织发现 Linux 容器的即插即用云部署吸引人,因为他们可以获得可扩展容器平台的许多即时好处,而无需完全自行构建。尽管如此,构建自己的云平台或数据中心的门槛实际上相当低,我们将很快介绍一些可选方案。

所有主要的公共云提供商都致力于原生支持 Linux 容器在其服务中的运行。一些最大的努力包括以下内容:

许多相同的公司还拥有强大的托管 Kubernetes 服务,比如这些:

在公共云中的 Linux 实例上安装 Docker 很简单。但将 Docker 部署到服务器通常只是创建完整生产环境的第一步。您可以完全自行操作,或者使用来自主要云服务提供商、Docker 公司以及更广泛的容器社区的许多工具。大部分工具在公共云或您自己的数据中心中同样有效。

在调度器和更复杂的工具系统领域,我们有很多选择可以复制大部分来自公共云提供商的功能。即使在公共云中运行,有一些令人信服的理由可以选择在自己的 Linux 容器环境中运行,而不是使用现成的服务。

在本章中,我们将介绍一些运行 Linux 容器的选择,首先是更简单的 Docker Swarm 模式,然后深入一些更高级的工具,比如 Kubernetes 以及一些较大的云服务提供商。所有这些示例都应该让您了解如何利用 Docker 为应用程序工作负载提供极其灵活的平台。

Docker Swarm 模式

在构建 Docker 引擎形式的容器运行时之后,Docker 的工程师们开始解决编排一组独立的 Docker 主机并有效地装载这些主机的容器的问题。从这项工作中演变出来的第一个工具被称为 Docker Swarm。正如我们之前解释的那样,有些令人困惑的是,现在有两个被称为“Swarm”的东西,都来自 Docker 公司。

最初的独立 Docker Swarm 现在通常被称为Docker Swarm(经典版),但是有第二个“Swarm”实现,更具体地称为Swarm 模式。这不是一个独立的产品,而是内置在 Docker 客户端中的功能。内置的 Swarm 模式比原始的 Docker Swarm 能力更强大,意图完全替代它。Swarm 模式的主要优势是不需要您单独安装任何东西。您已经在运行 Docker 的系统中具备了这种集群功能!这是我们将在这里关注的 Docker Swarm 实现。希望现在您知道有两个不同的 Docker Swarm 实现,您不会被互联网上的矛盾信息所困扰了。

Docker Swarm 模式背后的理念是向 docker 客户端工具呈现一个统一的界面,但这个界面由整个集群支持,而不是单个 Docker 守护程序。Swarm 主要用于通过 Docker 工具管理集群计算资源。自首次发布以来,它已经有了很大的发展,现在包含多个调度器插件,具有不同的容器分配策略,并且内置了一些基本的服务发现功能。但它仍然只是更复杂解决方案中的一个构建模块。

Swarm 集群可以包含一个或多个管理器,它们充当 Docker 集群的中央管理中心。最好设置一个奇数数量的管理器。每次只有一个管理器会充当集群的领导者。当您向 Swarm 添加更多节点时,您将它们合并成一个统一的集群,可以轻松使用 Docker 工具进行控制。

让我们启动一个 Swarm 集群。首先,您需要三个或更多可以在网络上相互通信的 Linux 服务器。每个服务器都应该运行来自官方 Docker 软件仓库的最新版本的 Docker Community Edition。

提示

参考第三章获取关于在 Linux 上安装 docker-ce 包的详细信息。

对于本示例,我们将使用三台运行 docker-ce 的 Ubuntu 服务器。您需要做的第一件事就是 ssh 到您想要用作 Swarm 管理器的服务器上,然后使用 Swarm 管理器的 IP 地址运行 swarm init 命令:

$ ssh 172.17.4.1
…

ubuntu@172.17.4.1:$ sudo docker swarm init --advertise-addr 172.17.4.1

Swarm initialized: current node (hypysglii5syybd2zew6ovuwq) is now a manager.

To add a worker to this swarm, run the following command:

 docker swarm join --token SWMTKN-1-14……a4o55z01zq 172.17.4.1:2377

To add a manager to this swarm, run 'docker swarm join-token manager'
and follow the instructions.
警告

有一些步骤是你必须采取的,以便安全地配置 Docker Swarm 模式集群,这里我们不涉及。在任何长期运行的系统上运行 Docker Swarm 模式之前,确保你了解选项并采取了适当的措施来保护环境。

提示

在本章的许多示例中,你必须使用正确的 IP 地址来管理和工作节点。

这个步骤将初始化 Swarm 管理器,并为想要加入集群的节点提供所需的令牌。请将此令牌保存在安全的地方,比如密码管理器中。如果你丢失了这个令牌,也不用太担心;你总可以通过在管理器上运行以下命令再次获取它:

sudo docker swarm join-token --quiet worker

你可以通过运行本地的docker客户端,指向新管理节点的 IP 地址,来检查到目前为止的进展:

$ docker -H 172.17.4.1 system info
…
Swarm: active
  NodeID: l9gfcj7xwii5deveu3raf4782
  Is Manager: true
  ClusterID: mvdaf2xsqwjwrb94kgtn2mzsm
  Managers: 1
  Nodes: 1
  Default Address Pool: 10.0.0.0/8
  SubnetSize: 24
  Data Path Port: 4789
  Orchestration:
   Task History Retention Limit: 5
  Raft:
   Snapshot Interval: 10000
   Number of Old Snapshots to Retain: 0
   Heartbeat Tick: 1
   Election Tick: 10
  Dispatcher:
   Heartbeat Period: 5 seconds
  CA Configuration:
   Expiry Duration: 3 months
   Force Rotate: 0
  Autolock Managers: false
  Root Rotation In Progress: false
  Node Address: 172.17.4.1
  Manager Addresses:
   172.17.4.1:2377
…

你还可以使用以下命令列出当前集群中的所有节点:

$ docker -H 172.17.4.1 node ls

ID      HOSTNAME      STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
l9…82 * ip-172-17-4-1 Ready  Active       Leader         20.10.7

目前,你可以将这两个额外的服务器添加为 Swarm 集群的工作节点。如果你要扩展生产环境,Swarm 使这变得非常简单:

$ ssh 172.17.4.2 \
    "sudo docker swarm join --token SWMTKN-1-14……a4o55z01zq 172.17.4.1:2377"

This node joined a swarm as a worker.

$ ssh 172.17.4.3 \
    "sudo docker swarm join --token SWMTKN-1-14……a4o55z01zq 172.17.4.1:2377"

This node joined a swarm as a worker.
提示

添加额外的管理节点很重要,操作与添加工作节点一样简单。你只需传入管理节点加入令牌,而不是工作节点加入令牌。你可以通过在任何活动节点上运行docker swarm join-token manager来获取此令牌。

如果你重新运行docker node ls,你现在应该会看到你的集群中总共有三个节点,只有一个节点被标记为Leader

$ docker -H 172.17.4.1 node ls

ID      HOSTNAME      STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
l9…82 * ip-172-17-4-1 Ready  Active       Leader         20.10.7
3d…7b   ip-172-17-4-2 Ready  Active                      20.10.7
ip…qe   ip-172-17-4-3 Ready  Active                      20.10.7

这就是在 Swarm 模式下启动和运行 Swarm 集群所需的全部步骤(图 10-1)!

简单的 Docker Swarm 集群

图 10-1. 简单的 Docker Swarm 模式集群

接下来你应该创建一个网络供你的服务使用。Swarm 中有一个名为ingress的默认网络,但为了更好的隔离,创建额外的网络非常简单:

$ docker -H 172.17.4.1 network create --driver=overlay default-net

ckwh5ph4ksthvx6843ytrl5ik

$ docker -H 172.17.4.1 network ls

NETWORK ID     NAME              DRIVER    SCOPE
494e1a1bf8f3   bridge            bridge    local
xqgshg0nurzu   default-net       overlay   swarm
2e7d2d7aaf0f   docker_gwbridge   bridge    local
df0376841891   host              host      local
n8kjd6oa44fr   ingress           overlay   swarm
b4720ea133d6   none              null      local

到目前为止,我们只是启动了基本组件,到目前为止我们还没有部署任何实际的业务逻辑。现在让我们将第一个服务部署到集群中。你可以使用如下命令:

$ docker -H 172.17.4.1 service create --detach=true --name quantum \
    --replicas 2 --publish published=80,target=8080 --network default-net \
    spkane/quantum-game:latest

tiwtsbf270mh83032kuhwv07c

我们启动的服务会启动容器,托管量子游戏。这是一款基于浏览器的解谜游戏,使用真实的量子力学。我们希望这个例子比另一个 Hello World 更有趣!

警告

虽然在许多示例中我们使用了latest标签,但在生产环境中你永远不应该使用这个标签。对于本书来说,使用这个标签非常方便,因为我们可以轻松地更新代码,但这个标签是不稳定的,无法在长时间内固定在特定版本上。这意味着如果你使用latest,你的部署将无法重复!它还可能导致一个情况,即在所有服务器上运行的应用程序版本不同。

让我们通过运行docker service ps命令来查看那些容器最终在哪里。

$ docker -H 172.17.4.1 service ps quantum

ID    NAME      IMAGE       NODE          DESIRED… CURRENT… ERROR PORTS
rk…13 quantum.1 spkane/qua… ip-172-17-4-1 Running  Running…
lz…t3 quantum.2 spkane/qua… ip-172-17-4-2 Running  Running…

Swarm 模式在节点之间使用路由网格自动将流量路由到能够处理请求的容器上。当您在docker service create命令中指定一个发布端口时,网格使得可以在三个节点的任何一个上命中这个端口,并将您路由到 Web 应用程序。请注意,尽管您只运行了两个实例,但我们说的是任何个节点都可以。传统上,您可能还需要设置一个单独的反向代理层来完成这一点,但在 Swarm 模式中它已经包含在内了。

为了验证这一点,您现在可以通过将 Web 浏览器指向任何一个节点的 IP 地址来测试服务:

http://172.17.4.1/

如果一切按预期工作,您应该会看到《量子游戏》的第一个拼图板:

To get a list of all the services, we can use +service ls+:
$ docker -H 172.17.4.1 service ls

ID    NAME    MODE       REPLICAS IMAGE                      PORTS
iu…9f quantum replicated 2/2      spkane/quantum-game:latest *:80->8080/tcp

这为我们提供了最常需要的信息的摘要视图,但有时这还不够。就像 Docker 为容器保留了很多其他元数据一样,它也为服务保留了很多其他详细信息。我们可以使用service inspect来获取关于服务的详细信息:

$ docker -H 172.17.4.1 service inspect --pretty quantum
ID:    iuoh6oxrec9fk67ybwuikutqa
Name:    quantum
Service Mode:  Replicated
 Replicas:  2
Placement:
UpdateConfig:
 Parallelism:  1
 On failure:  pause
 Monitoring Period: 5s
 Max failure ratio: 0
 Update order:      stop-first
RollbackConfig:
 Parallelism:  1
 On failure:  pause
 Monitoring Period: 5s
 Max failure ratio: 0
 Rollback order:    stop-first
ContainerSpec:
 Image:    spkane/quantum-game:latest@sha256:1f57…4a8c
 Init:    false
Resources:
Networks: default-net
Endpoint Mode:  vip
Ports:
  PublishedPort = 80
  Protocol = tcp
  TargetPort = 8080
  PublishMode = ingress

这里有很多信息,所以让我们指出一些更重要的事情。首先,我们可以看到这是一个具有两个副本的复制服务,就像我们在service ls命令中看到的一样。我们还可以看到 Docker 每隔 5 秒对服务进行健康检查。运行对服务的更新将使用stop-first方法,这意味着它将首先将我们的服务减少到N−1,然后启动一个新实例将我们带回N。您可能希望始终在N+1 模式下运行,以便在生产环境中进行更新时不会出现节点宕机。您可以使用service update命令的--update-order=start-first选项来更改这一点。在回滚场景中,它将表现出相同的行为,我们可以使用--rollback-order=start-first来同样更改。

在实际场景中,我们不仅需要能够启动我们的服务,还需要能够对其进行扩展和缩减。如果我们不得不重新部署来完成这些操作,那将是一件很糟糕的事情,更不用说可能会引入任意数量的其他问题了。幸运的是,Swarm 模式使得通过一个简单的命令就能轻松扩展我们的服务变得很容易。要将运行的实例数量从两个增加到四个,您只需运行以下命令:

$ docker -H 172.17.4.1 service scale --detach=false quantum=4

quantum scaled to 4
overall progress: 4 out of 4 tasks
1/4: running   [==================================================>]
2/4: running   [==================================================>]
3/4: running   [==================================================>]
4/4: running   [==================================================>]
verify: Service converged
注意

我们在上一个命令中使用了--detach=false,这样更容易看到正在发生的事情。

现在,我们可以使用service ps来显示 Swarm 已经按我们的要求执行了操作。这是我们之前运行的同一个命令,但现在我们应该有更多的副本在运行了!但等等,我们不是要求比节点数更多的副本吗?

$ docker -H 172.17.4.1 service ps quantum

ID    NAME      IMAGE        NODE          DESIRED… CURRENT… ERROR PORTS
rk…13 quantum.1 spkane/quan… ip-172-17-4-1 Running  Running…
lz…t3 quantum.2 spkane/quan… ip-172-17-4-2 Running  Running…
mh…g8 quantum.3 spkane/quan… ip-172-17-4-3 Running  Running…
cn…xb quantum.4 spkane/quan… ip-172-17-4-1 Running  Running…

您会注意到您在同一台主机上运行了两个服务。您是否预期到了这一点?这对于主机的弹性可能不是理想的选择,但默认情况下,Swarm 会优先确保您获得您请求的实例数量,而不是在可能的情况下跨主机分布单个容器。如果节点不足,您将在每个节点上获取多个副本。在实际情况下,当您失去整个节点时,您需要仔细考虑放置和扩展。您的应用程序在减少规模时是否仍然为用户服务?

当您需要部署软件的新版本时,您将希望使用docker service update命令。此命令有很多选项,但以下是一个示例:

$ docker -H 172.17.4.1 service update --update-delay 10s \
    --update-failure-action rollback --update-monitor 5s \
    --update-order start-first --update-parallelism 1 \
    --detach=false \
    --image spkane/quantum-game:latest-plus quantum

quantum
overall progress: 4 out of 4 tasks
1/4: running   [==================================================>]
2/4: running   [==================================================>]
3/4: running   [==================================================>]
4/4: running   [==================================================>]
verify: Service converged

运行此命令将导致 Swarm 逐个更新您的服务容器,在每次更新之间暂停。完成后,您应该能够在新的私人或无痕浏览会话中打开服务的 URL(以避开浏览器的本地缓存),并看到游戏背景现在是绿色而不是蓝色。

很好,您现在已成功应用了更新,但如果出现问题怎么办?我们可能需要部署先前的版本以恢复工作正常。现在,您可以使用我们之前稍作提到的service rollback命令,回滚到先前的版本,正确的蓝色背景如下:

$ docker -H 172.17.4.1 service rollback quantum

quantum
rollback: manually requested rollback
overall progress: rolling back update: 4 out of 4 tasks
1/4: running   [>                                                  ]
2/4: running   [>                                                  ]
3/4: running   [>                                                  ]
4/4: running   [>                                                  ]
verify: Service converged

对于无状态服务来说,这几乎是一个理想的回滚机制。您无需跟踪先前的版本;Docker 会为您做这些事情。您只需告诉它回滚,它就会从其内部存储中提取先前的元数据并执行回滚。就像在部署期间一样,Docker 可以健康检查您的容器,以确保回滚操作正常工作。

注意

此回滚机制将始终返回到上次部署的版本,因此如果连续多次运行它,它将在两个版本之间切换。

基于docker service构建的命令称为docker stack,它使您能够将特别设计的docker-compose.yaml文件部署到 Docker Swarm 模式或 Kubernetes 集群中。如果您返回并检查我们在第八章中使用的 Git 存储库,我们可以将该容器堆栈的修改版本部署到当前的 Swarm 模式集群中:

$ git clone https://github.com/spkane/rocketchat-hubot-demo.git \
    --config core.autocrlf=input

在该存储库内部有一个名为stack的目录,其中包含我们之前使用过的docker-compose.yaml文件的修改版本:

$ cd rocketchat-hubot-demo/stack

如果您希望在 Swarm 模式集群中启动此设置,可以运行以下命令:

$ docker -H 172.17.4.1 stack deploy --compose-file docker-compose-stack.yaml \
    rocketchat

Creating network rocketchat_default
Creating service rocketchat_hubot
Creating service rocketchat_mongo
Creating service rocketchat_rocketchat
Creating service rocketchat_zmachine

现在,您可以列出集群中的堆栈,并查看堆栈添加了哪些服务:

$ docker -H 172.17.4.1 stack ls

NAME         SERVICES   ORCHESTRATOR
rocketchat   4          Swarm

$ docker -H 172.17.4.1 service ls

ID    NAME         …  …  IMAGE                              PORTS
iu…9f quantum      … 2/2 spkane/quantum-game:latest         *:80->8080/tcp
nh…jd …_hubot      … 1/1 rocketchat/hubot-rocketchat:latest *:3001->8080/tcp
gw…qv …_mongo      … 1/1 spkane/mongo:4.4
m3…vd …_rocketchat … 1/1 rocketchat/rocket.chat:5.0.4       *:3000->3000/tcp
lb…91 …_zmachine   … 1/1 spkane/zmachine-api:latest
注意

这个 stack 是为基本演示目的而设计的,并没有经过对此用例的充分测试;不过,它应该能让您了解如何组装类似的东西。

你可能注意到,所有容器启动需要一些时间,并且 Hubot 将继续重启。这是预期的,因为 Rocket.Chat 还没有配置好。有关 Rocket.Chat 的设置详见 第八章。

此时,你可以将你的网页浏览器指向 Swarm 节点上的 3000 端口(例如,在这些示例中为 http://172.17.4.1:3000/),你应该能看到 Rocket.Chat 的初始设置页面。

你可以查看由堆栈管理的所有容器,使用docker stack ps

$ docker -H 172.17.4.1 stack ps -f "desired-state=running" rocketchat

ID    NAME           IMAGE                    NODE … CURRENT STATE           …
b5…1h …_hubot.1      rocketchat/hubot-rocket… …-1  … Running 14 seconds ago
eq…88 …_mongo.1      spkane/mongo:4.4         …-2  … Running 11 minutes ago
5x…8u …_rocketchat.1 rocketchat/rocket.chat:… …-3  … Running 11 minutes ago
r5…x4 …_zmachine.1   spkane/zmachine-api:lat… …-4  … Running 12 minutes ago

当你完成后,可以执行以下命令来销毁这个堆栈:

$ docker -H 172.17.4.1 stack rm rocketchat

Removing service rocketchat_hubot
Removing service rocketchat_mongo
Removing service rocketchat_rocketchat
Removing service rocketchat_zmachine
Removing network rocketchat_default
注意

如果你试图立即重新启动所有服务,可能会遇到一些意外错误。只需稍等片刻,等集群完成对旧网络的拆除,问题就会解决。

那么,如果你的其中一台服务器遇到问题需要下线怎么办?在这种情况下,你可以使用docker node update命令的--availability选项轻松地将单个节点上的所有服务排空。

让我们再次查看集群中的节点:

 docker -H 172.17.4.1 node ls

ID      HOSTNAME      STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
l9…82 * ip-172-17-4-1 Ready  Active       Leader         20.10.7
3d…7b   ip-172-17-4-2 Ready  Active                      20.10.7
ip…qe   ip-172-17-4-3 Ready  Active                      20.10.7

让我们也检查一下我们的容器目前在哪里运行:

$ docker -H 172.17.4.1 service ps -f "desired-state=running" quantum

ID    NAME        IMAGE       NODE          DESIRED… CURRENT… ERROR   PORTS
sc…1h quantum.1   spkane/qua… ip-172-17-4-1 Running  Running…
ax…om quantum.2   spkane/qua… ip-172-17-4-2 Running  Running…
p4…8h quantum.3   spkane/qua… ip-172-17-4-3 Running  Running…
g8…tw quantum.4   spkane/qua… ip-172-17-4-1 Running  Running…
提示

在前面的命令中,我们使用了一个过滤器,以便输出仅显示当前运行的进程。默认情况下,Docker 还会以树形格式显示先前运行的容器,以便您可以在输出中看到更新和回滚等情况。

如果你确定 172.17.4.3 上的服务器需要停机,你可以通过修改 Swarm 中的availability状态为drain来排空该节点的任务,并将它们迁移到另一台主机:

$ docker -H 172.17.4.1 node update --availability drain ip-172-17-4-3

ip-172-17-4-3

如果我们检查节点,可以看到可用性现在已设置为drain

$ docker -H 172.17.4.1 node inspect --pretty ip-172-17-4-3
ID:      ipohyw73hvf70td9licnls9qe
Hostname:                ip-172-17-4-3
Joined at:               2022-09-04 16:59:52.922451346 +0000 utc
Status:
 State:      Ready
 Availability:           Drain
 Address:    172.17.4.3
Platform:
 Operating System:  linux
 Architecture:    x86_64
Resources:
 CPUs:      2
 Memory:    7.795GiB
Plugins:
 Log:    awslogs, fluentd, gcplogs, gelf, journald, json-file, local,
         logentries, splunk, syslog
 Network:    bridge, host, ipvlan, macvlan, null, overlay
 Volume:    local
Engine Version:    20.10.7
TLS Info:
 TrustRoot:
…

 Issuer Subject:  …
 Issuer Public Key:  …

你可能想知道这对服务有什么影响。我们告诉其中一个节点停止运行服务的副本,它们要么消失了,要么迁移到其他地方。它做了什么呢?我们可以再次查看服务的详细信息,看到该主机上的所有运行容器已经移动到另一个节点:

$ docker -H 172.17.4.1 service ps -f "desired-state=running" quantum

ID    NAME        IMAGE       NODE          DESIRED… CURRENT… ERROR   PORTS
sc…1h quantum.1   spkane/qua… ip-172-17-4-1 Running  Running…
ax…om quantum.2   spkane/qua… ip-172-17-4-2 Running  Running…
p4…8h quantum.3   spkane/qua… ip-172-17-4-2 Running  Running…
g8…tw quantum.4   spkane/qua… ip-172-17-4-1 Running  Running…

此时,安全地关闭节点并进行所需的任何工作,使其恢复健康。当你准备好将节点重新添加到 Swarm 集群时,可以运行以下命令:

$ docker -H 172.17.4.1 node update --availability active ip-172-17-4-3

ip-172-17-4-3

目前我们不再检查节点,但如果你想看看这是什么样子,随时可以重新运行node inspect命令。

警告

当你将节点重新添加到集群时,容器不会自动平衡!不过,新的部署或更新应该会导致容器均匀分布在节点上。

当你完成后,可以使用以下命令删除你的服务和网络:

$ docker -H 172.17.4.1 service rm quantum

quantum

$ docker -H 172.17.4.1 network rm default-net

default-net

然后验证它们确实完全消失了:

$ docker -H 172.17.4.1 service ps quantum

no such service: quantum

$ docker -H 172.17.4.1 network ls

NETWORK ID     NAME              DRIVER    SCOPE
494e1a1bf8f3   bridge            bridge    local
2e7d2d7aaf0f   docker_gwbridge   bridge    local
df0376841891   host              host      local
n8kjd6oa44fr   ingress           overlay   swarm
b4720ea133d6   none              null      local

目前就这些了!此时,如果你不再需要,可以安全地关闭所有作为 Swarm 集群一部分的服务器。

那是一场风暴之旅,但它涵盖了在 Docker Engine 中使用 Swarm 模式的基础知识,并应该帮助你开始构建自己的 Docker 集群,无论你决定在哪里使用它们。

Kubernetes

现在让我们花些时间来了解一下 Kubernetes。自从 2014 年DockerCon期间向公众发布以来,Kubernetes 迅速发展,现在可能是最广泛采用的容器平台之一。它虽然不是今天最古老或最成熟的产品——这一荣誉归 Mesos,它在 2009 年首次推出,当时容器尚未广泛使用——但 Kubernetes 是专为容器化工作负载而构建的,具有丰富的功能组合,功能不断发展,并且拥有一个非常强大的社区,包括许多早期的 Docker 和 Linux 容器采用者。这种组合在多年来显著增加了其受欢迎程度。在 2017 年的 DockerCon EU 上,Docker, Inc.宣布 Kubernetes 支持将会整合到 Docker Engine 工具本身。Docker Desktop 能够快速部署单节点 Kubernetes 集群,并且客户端可以为开发目的部署容器堆栈。这为那些在本地使用 Docker 但要部署到 Kubernetes 的开发人员提供了一个很好的桥梁。

像 Linux 本身一样,Kubernetes 有几种发行版,包括免费和商业版本。现在有各种各样的发行版可用,并且受到不同程度的支持。Kubernetes 的广泛采用意味着现在它有一些非常好的本地开发安装工具。

提示

本书中关于 Kubernetes 的覆盖旨在提供一些关于如何将你的 Linux 容器工作流与 Kubernetes 集成的基本指导,但我们在这里不会详细介绍 Kubernetes 生态系统。我们强烈推荐阅读Kubernetes: Up & Running, 作者是 Brendan Burns 等人(O’Reilly),或者其他一些优秀的资料,以熟悉所有相关的概念和术语。

Minikube

Minikube 是最早用于管理本地 Kubernetes 安装的工具之一,也是我们将在这里重点介绍的第一个工具。在使用 Minikube 时学到的大部分概念可以应用于任何 Kubernetes 实现,包括我们将在 Minikube 之后讨论的选项,因此这是一个很好的起点。

提示

运行本地 Kubernetes 集群的其他选项有很多。我们从minikube开始,因为它所生成的容器或虚拟机是一个标准的单节点 Kubernetes 安装。除了本节中将讨论的工具之外,我们强烈推荐探索k3sk3dk0smicrok8s

什么是 Minikube?

Minikube 是 Kubernetes 的一个完整分发版本,用于单个实例。它管理你计算机上的一个容器或虚拟机,提供一个可用的 Kubernetes 安装,并允许你使用与生产系统相同的所有工具。从范围上讲,它有点像 Docker Compose:它允许你在本地启动一个完整的堆栈。不过,它比 Compose 更进一步,因为它具有所有的生产 API。因此,如果你在生产中运行 Kubernetes,你可以在你的桌面上拥有一个在功能上相对接近,虽然不是在规模上相同的环境。

Minikube 相当独特,因为所有的分发都受一个单独的二进制文件控制,你下载并在本地运行它。它会自动检测你本地的容器化或虚拟机管理器,并设置和运行一个包含所有必要 Kubernetes 服务的容器或虚拟机。这意味着开始使用它非常简单。

所以让我们安装它!

安装 Minikube

大多数安装在所有平台上都是相同的,因为一旦你安装了工具,它们将是你访问运行 Kubernetes 安装的虚拟机的入口。要开始,请直接跳转到适用于你操作系统的部分。一旦你启动并运行了工具,你就可以按照共享的文档进行操作了。

为了有效使用 Minikube,我们需要两个工具:minikubekubectl。对于我们简单的安装过程,我们将利用这两个命令都是静态二进制文件且没有外部依赖的事实,这使得它们易于安装。

注意

安装 Minikube 还有几种其他方法。我们将展示在每个平台上我们认为是最简单的路径。如果你对如何安装这些应用程序有强烈的偏好,请随意使用你喜欢的方法。例如,在 Windows 上,你可能更喜欢使用Chocolatey 包管理器,或者在 Linux 上使用Snap 包系统

macOS

就像在第三章中一样,你需要在你的系统上安装 Homebrew。如果没有,请回到第三章并确保你已经设置好了。一旦你做到了,安装minikube客户端就非常简单:

$ brew install minikube

这将导致 Homebrew 下载并安装 Minikube。根据你的配置,它会看起来像这样:

==> Downloading https://ghcr.io/v2/homebrew/core/kubernetes-cli/…/1.25.0
Already downloaded: …/Homebrew/downloads/…kubernetes-cli…manifest.json
==> Downloading https://ghcr.io/v2/homebrew/core/kubernetes-cli/blobs/sha256…
Already downloaded: …/Homebrew/downloads/…kubernetes-cli--1.25…bottle.tar.gz
==> Downloading https://ghcr.io/v2/homebrew/core/minikube/manifests/1.26.1
Already downloaded: …/Homebrew/downloads/…minikube-1.26.1.…_manifest.json
==> Downloading https://ghcr.io/v2/homebrew/core/minikube/blobs/sha256:…
Already downloaded: …/Homebrew/downloads/…minikube--1.26.1…bottle.tar.gz
==> Installing dependencies for minikube: kubernetes-cli
==> Installing minikube dependency: kubernetes-cli
==> Pouring kubernetes-cli--1.25.0.arm64_monterey.bottle.tar.gz  /opt/homebrew/Cellar/kubernetes-cli/1.25.0: 228 files, 52.8MB
==> Installing minikube
==> Pouring minikube--1.26.1.arm64_monterey.bottle.tar.gz
==> Caveats
Bash completion has been installed to:
  /opt/homebrew/etc/bash_completion.d
==> Summary  /opt/homebrew/Cellar/minikube/1.26.1: 9 files, 70.6MB
==> Running `brew cleanup minikube`…
Disable this behavior by setting HOMEBREW_NO_INSTALL_CLEANUP.
Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`).
==> Caveats
==> minikube
Bash completion has been installed to:
  /opt/homebrew/etc/bash_completion.d

那就是了!让我们测试一下,确保它在你的路径中:

$ which minikube
/opt/homebrew/bin/minikube
注意

arm64系统上,Homebrew 安装到/opt/homebrew/bin而不是/usr/local/bin

如果你没有得到响应,你需要确保你的PATH环境变量中包含/usr/local/bin/opt/homebrew/bin。假设通过了,现在你已经安装了minikube工具。

kubectl应该已经自动安装了,因为它是minikube的一个依赖项,但你也可以使用brew显式地安装它。通常情况下,Homebrew 中的kubectl版本将与当前minikube的发布版本匹配,因此使用brew install应该有助于避免不匹配:

$ brew install kubernetes-cli

我们将像测试minikube一样测试它:

$ which kubectl
/opt/homebrew/bin/kubectl

一切就绪!

Windows

与在 Windows 上安装 Docker Desktop 一样,你可能想要安装 Hyper-V 或另一个支持的虚拟化平台来运行 Kubernetes VM。要安装minikube,你只需下载二进制文件并将其放置在你的PATH中的一个位置,以便你可以在命令行上执行它。你可以从 GitHub 上下载minikube 的最新发布版本。你会想要将你下载的 Windows 可执行文件重命名为minikube.exe,否则你可能需要进行比你想象中更多的输入!

提示

你可以在Minikube 安装文档中找到有关 Windows 安装过程和该二进制可执行文件的更多详细信息。

然后,你需要获取最新的 Kubernetes CLI 工具kubectl来与你的分发版交互。不幸的是,没有一个/latest路径用于下载它。因此,为了确保你有最新版本,你需要从网站上获取最新版本,然后将其插入到 URL 中,就像这样:

https://storage.googleapis.com/kubernetes-release/release//bin/windows/amd64/kubectl.exe.

下载完成后,你需要确保它可以从你的PATH中访问,以便于后续的探索工作。

Linux

在 Linux 上,你需要安装 Docker,并考虑安装 KVM(Linux 的基于内核的虚拟机)或 VirtualBox,这样minikube就可以为你创建和管理一个 Kubernetes 虚拟机。因为minikube只是一个单一的二进制文件,一旦安装了它,就不需要安装任何其他包。而且,由于minikube是一个静态链接的二进制文件,它应该可以在你想要运行它的任何发行版上工作。尽管我们可以将所有安装工作放在一行中进行,但我们将其分解成几个步骤,以便更容易理解和排查故障。请注意,在撰写本文时,二进制文件托管在googleapis上,通常会保持非常稳定的 URL。所以,我们开始吧:

# Download the file, save as 'minikube'
$ curl -Lo minikube \
  https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64

# Make it executable
$ chmod +x minikube

# Move it to /usr/local/bin
$ sudo mv minikube /usr/local/bin/

你需要确保/usr/local/bin在你的路径中。既然我们已经有了minikube,我们还需要获取kubectl,我们可以这样做:

# Get the latest version number
$ KUBE_VERSION=$(curl -s \
    https://storage.googleapis.com/kubernetes-release/release/stable.txt)

# Fetch the executable
$ curl -LO \
    https://storage.googleapis.com/kubernetes-release/\
release/$(KUBE_VERSION)/bin/linux/amd64/kubectl

# Make it executable
$ chmod +x kubectl

# Move it to /usr/local/bin
$ sudo mv kubectl /usr/local/bin/
注意

示例中的一个 URL 已经延续到下一行,以适应页面的边界。你可能需要重新组装 URL 并删除反斜杠,以使命令在你的环境中正常工作。

安装完成,我们已经准备好了。

运行 Kubernetes

现在我们有了minikube工具,可以用它来启动我们的 Kubernetes 集群。这通常相当简单。通常情况下,您不需要事先进行任何配置。在这个例子中,您会看到minikube决定使用docker driver,尽管还有其他可以选择的驱动程序。

要启动minikube,请继续运行以下命令:

$ minikube start

  minikube v1.26.1 on Darwin 12.5.1 (arm64)
  Automatically selected the docker driver. Other choices: parallels, ssh, …
  Using Docker Desktop driver with root privileges
  Starting control plane node minikube in cluster minikube
  Pulling base image …
  Downloading Kubernetes v1.24.3 preload …
    > preloaded-images-k8s-v18-v1…: 342.82 MiB / 342.82 MiB  100.00% 28.22 M
    > gcr.io/k8s-minikube/kicbase: 348.00 MiB / 348.00 MiB  100.00% 18.13 MiB
    > gcr.io/k8s-minikube/kicbase: 0 B [________________________] ?% ? p/s 16s
  Creating docker container (CPUs=2, Memory=4000MB) …
  Preparing Kubernetes v1.24.3 on Docker 20.10.17 …
    ▪ Generating certificates and keys …
    ▪ Booting up control plane …
    ▪ Configuring RBAC rules …
  Verifying Kubernetes components…
    ▪ Using image gcr.io/k8s-minikube/storage-provisioner:v5
  Enabled addons: storage-provisioner, default-storageclass
  Done! kubectl is now configured to use "minikube" cluster and
     "default" namespace by default

那么我们刚刚做了什么?Minikube 在一个命令中包含了很多内容。在这种情况下,我们启动了一个单独的 Linux 容器,在我们的本地系统上提供了一个功能齐全的 Kubernetes 安装。如果我们使用了minikube的虚拟化驱动程序之一,那么我们将在单个容器上创建一个运行 Kubernetes 的完整虚拟机,而不是一个完整的 VM。

它随后在主机上的 Linux 容器内运行 Kubernetes 的所有必要组件。您可以轻松探索minikube容器或 VM,看看您得到了什么:

$ minikube ssh

docker@minikube:~$

在您的 Kubernetes 集群上,您可能不经常使用 SSH 进入命令行。但是我们想要查看安装了什么,并了解到当我们运行minikube时,我们正在控制运行许多进程的环境。让我们看看在我们的 Kubernetes 集群上 Docker 实例上正在运行什么:

docker@minikube:~$ docker container ls

…ID   IMAGE       COMMAND               …  NAMES
48…cf ba…57      "/storage-provisioner" … k8s_storage-provisioner_storage-…
4e…8d ed…e8      "/coredns -conf /etc…" … k8s_coredns_coredns-6d4b75cb6d-…
1d…3d …pause:3.6 "/pause"               … k8s_POD_coredns-6d4b75cb6d-…
82…d3 7a…dc      "/usr/local/bin/kube…" … k8s_kube-proxy_kube-proxy-…
27…10 …pause:3.6 "/pause"               … k8s_POD_kube-proxy-zb6w2_kube-…
15…ce …pause:3.6 "/pause"               … k8s_POD_storage-provisioner_kube-…
ff…3d f9…55      "kube-controller-man…" … k8s_kube-controller-manager_kube-…
33…c5 …pause:3.6 "/pause"               … k8s_POD_kube-controller-manager-…
30…97 a9…df      "etcd --advertise-cl…" … k8s_etcd_etcd-minikube_kube-…
f5…41 53…a6      "kube-apiserver --ad…" … k8s_kube-apiserver_kube-apiserver-…
5b…08 8f…73      "kube-scheduler --au…" … k8s_kube-scheduler_kube-scheduler-…
87…cc …pause:3.6 "/pause"               … k8s_POD_kube-apiserver-…
5a…14 …pause:3.6 "/pause"               … k8s_POD_etcd-minikube_kube-…
6f…0c …pause:3.6 "/pause"               … k8s_POD_kube-scheduler-…

我们不会深入探讨每个组件是什么,但是现在您应该可以看到机制是如何工作的了。此外,由于这些组件只是容器,具有版本控制,并且可以从上游容器存储库中拉取,因此升级这些组件也非常容易。

请继续退出您在 Minikube 系统上的 shell:

docker@minikube:~$ exit

minikube 命令

出于空间和时间的考虑,我们不会列出所有minikube命令。我们鼓励您在不使用任何选项的情况下运行它,查看输出并尝试使用可用的功能。话虽如此,让我们快速浏览一些最有趣的命令。在安装应用程序堆栈的过程中,我们稍后会涵盖更多内容,但是这里有一个快速概述。

为了查看系统内部发生了什么,我们之前使用了minikube ssh,这对于直接调试或检查系统非常有用。如果没有直接访问 Minikube 系统,我们始终可以使用另一个minikube命令检查集群状态:

$ minikube status

minikube
type: Control Plane
host: Running
kubelet: Running
apiserver: Running
kubeconfig: Configured

这显示我们的一切看起来都很好。另外两个有用的命令包括:

Command Action
minikube ip 检索 Minikube VM 的 IP 地址。
minikube update-check 检查您的 Minikube 版本是否与最新版本兼容。

要应用升级,您只需使用与最初安装相同的机制。

至关重要的是,minikube status命令还向我们显示kubeconfig已正确配置。我们将需要它来让kubectl知道如何连接到我们的集群。

我们使用minikube start启动了 Kubernetes 集群。正如您所期望的那样,遵循 Docker CLI 参数的风格,minikube stop将停止所有 Kubernetes 组件和 Linux 容器或虚拟机。要完全清理您的环境,您也可以通过运行minikube delete来删除集群。

Kubernetes 仪表板

现在我们已经启动并运行了 Minikube,我们不仅可以使用命令行工具进行交互,还安装了一个完整的 Kubernetes 仪表板供我们探索。我们可以通过minikube dashboard命令访问它。请继续运行它——它应该会在您的 Web 浏览器中打开指向 Kubernetes 仪表板的正确 IP 地址和端口!仪表板上有很多内容,我们无法覆盖所有内容,但请随意点击并探索。根据您之前对 Kubernetes 的接触经验,仪表板侧边栏中的一些术语可能对您来说很熟悉,但其中许多可能完全陌生。如果您没有电脑在身边,图 10-2 显示了在仪表板侧栏的服务链接中看到的空 Minikube 安装的屏幕截图。

Kubernetes 仪表板

图 10-2. Kubernetes 仪表板(示例)

如果您在左侧边栏的集群下面的 Nodes 链接中探索,您应该会看到集群中的单个节点,名为minikube。这是我们启动的容器或虚拟机,而仪表板,就像其他组件一样,是托管在我们之前连接到 Minikube 系统时看到的其中一个容器中的。

注意

Kubernetes 通过kubectl命令暴露了几乎所有在仪表板上看到的内容,这使得它非常适合使用 shell 脚本进行脚本化。

例如,运行kubectl get serviceskubectl get nodes应该会显示与您在仪表板上看到的相同的信息。

在浏览时,您可能会注意到 Kubernetes 本身显示为系统中的一个组件,就像您的应用程序一样。

注意

您需要键入 Ctrl-C 来退出minikube dashboard进程并返回到您的终端提示符。

Kubernetes 容器和 Pod

现在我们已经搭建并运行了一个 Kubernetes 集群,并且您已经看到在本地执行此操作有多么简单,我们需要停下来讨论 Kubernetes 在容器抽象之上增加的一个概念。Kubernetes 出自 Google 运行其庞大平台的经验。Google 遇到了几乎所有可能出现在生产平台中的情况,并且不得不解决管理大规模安装时遇到的各种问题。在这个过程中,Google 创建了一套复杂的新抽象概念。Kubernetes 采纳了其中许多概念,因此有着自己的完整术语表。我们不打算深入所有这些内容,但理解其中最核心的新抽象概念至关重要——这个概念位于容器之上的层次,被称为 pod

Pod 这个术语的来源是因为 Docker 的吉祥物是鲸鱼 Moby,而鲸鱼的群体被称为 pod

在 Kubernetes 的术语中,一个 pod 是一个或多个共享相同 cgroups 和命名空间的容器。您还可以使用 cgroups 和命名空间将同一 pod 内的容器彼此隔离。Pod 的目的是封装所有需要一起部署以创建一个功能单元的进程或应用程序,调度器随后可以管理这些单元。Pod 中的所有容器可以在 localhost 上彼此通信,这消除了彼此发现的任何需求。那么,为什么不只部署一个包含所有应用程序的大容器呢?与大容器相比,Pod 的优势在于您仍然可以单独限制每个应用程序的资源,并利用公共 Linux 容器库来构建您的应用程序。

另外,Kubernetes 管理员经常利用 pod 抽象,在 pod 启动时让一个容器运行,以确保其他容器的配置正确,维护共享资源或向其他人通告应用程序等。这使您可以比将所有内容组合到同一个容器中更精细地管理容器。Pod 抽象的另一个好处是能够共享挂载卷。

pod 的生命周期很像 Linux 容器。它们基本上是暂时的,并且根据应用程序或其运行的主机的生命周期可以重新部署到新主机上。在面向外部世界时,pod 中的容器甚至共享相同的 IP 地址,这意味着它们在网络级别看起来像一个单一实体。就像你每个容器只运行一个应用程序实例一样,你通常在一个 pod 内运行给定容器的一个实例。想要简单理解 pod 的最简单方法是,它们是一组 Linux 容器,它们一起工作,就像它们是一个容器一样,多数情况下。如果只需要一个容器,那么你仍然会得到一个由 Kubernetes 部署的 pod,但该 pod 只包含一个容器。这样做的好处是,从 Kubernetes 调度程序的角度来看,只有一个抽象:pod。容器由构建 pod 的运行时组件以及你用来定义它们的配置管理。

pod 和容器之间的一个关键区别是,你不会在构建步骤中构建 pod。它们是一个运行时的抽象,在 JSON 或 YAML 清单中定义,并且仅存在于 Kubernetes 内部。因此,你构建你的 Linux 容器并将它们发送到注册表,然后使用 Kubernetes 定义和部署你的 pods。实际上,你通常也不会直接描述一个 pod;工具会通过部署的概念为你生成它。但是 pod 是 Kubernetes 集群中执行和调度的单位。这其中还有很多内容,但这是基本概念,通过一个简单的例子可能更容易理解。pod 的抽象比以个别容器的形式思考你的系统更复杂,但它确实非常强大。

让我们部署一些东西

当在 Kubernetes 中处理 pod 时,我们通常通过部署的抽象来管理它们。部署只是一个 pod 定义,带有一些额外的信息,包括健康监控和复制配置。它包含了 pod 的定义以及一些关于它的元数据。所以让我们看一个基本的部署,并让它运行起来。

在 Kubernetes 上可以部署的最简单的东西是一个只包含一个容器的 pod。我们将使用httpbin 应用程序来探索 Kubernetes 上部署的基础知识,并将我们的部署命名为 hello-minikube

我们曾使用过 minikube 命令,但要在 Kubernetes 上完成任务,我们现在需要利用之前安装的 kubectl 命令:

$ kubectl create deployment hello-minikube \
    --image=kennethreitz/httpbin:latest --port=80

deployment.apps/hello-minikube created

要查看它对我们的影响,我们可以使用 kubectl get all 命令来列出现在我们集群中最重要的对象:

$ kubectl get all

NAME                                 READY   STATUS    RESTARTS   AGE
pod/hello-minikube-ff49df9b8-svl68   1/1     Running   0          2m39s

NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
service/kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   98m

NAME                             READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/hello-minikube   1/1     1            1           2m39s

NAME                                       DESIRED   CURRENT   READY   AGE
replicaset.apps/hello-minikube-ff49df9b8   1         1         1       2m39s

使用这个命令,Kubernetes 创建了一个部署,一个 ReplicaSet 来管理扩展,以及一个 pod。我们希望确保我们的 pod 显示 STATUSRunning。如果你的 pod 没有运行,请耐心等待并运行几次命令,直到看到状态变化。service/kubernetes 条目是代表 Kubernetes 本身的正在运行的服务。但是我们的服务在哪里?我们还不能到达它。实质上,它处于 Linux 容器的相同状态,如果你没有告诉它暴露任何端口的话。因此,我们需要告诉 Kubernetes 为我们做这件事:

$ kubectl expose deployment hello-minikube --type=NodePort
service/hello-minikube exposed

现在这已经创建了一个我们可以访问和交互的服务。服务 是一个应用程序的一个或多个部署的包装器,并可以告诉我们如何联系该应用程序。在这种情况下,我们得到一个 NodePort,它在集群中的每个节点上暴露一个端口,可以路由到底层的 pods。让我们让 Kubernetes 告诉我们如何到达它:

$ kubectl get services

NAME           TYPE      CLUSTER-IP     EXTERNAL-IP PORT(S)        AGE
hello-minikube NodePort  10.105.184.177 <none>      80:32557/TCP   8s
kubernetes     ClusterIP 10.96.0.1      <none>      443/TCP        107m

您可能认为现在可以连接到 http://10.105.184.177:8080 来访问我们的服务。但是由于 Minikube 运行的容器或虚拟机的原因,这些地址无法从您的主机系统访问。因此,我们需要让 minikube 告诉我们在哪里找到这个服务:

$ minikube service hello-minikube --url
http://192.168.99.100:30616
提示

在某些配置中,您可能会看到这样的消息:

 Because you are using a Docker driver on darwin,
   the terminal needs to be open to run it.

这表明从主机到 Kubernetes 服务的网络透明地连线目前不可能,而且在探索应用程序时,您需要保持命令的运行。您可以使用本地的 Web 浏览器或打开另一个终端来运行诸如 curl 的命令。

当您完成时,您可以在原始终端会话中键入 Ctrl-C 来终止 minikube service 命令。

这个命令的好处,像许多其他 Kubernetes 命令一样,在正常情况下它是可脚本化和命令行友好的。如果我们想要在命令行上用 curl 打开它,通常只需在我们的请求中包含 minikube 命令调用即可:

$ curl -H foo:bar $(minikube service hello-minikube --url)/get
{
  "args": {},
  "headers": {
    "Accept": "*/*",
    "Foo": "bar",
    "Host": "127.0.0.1:56695",
    "User-Agent": "curl/7.85.0"
  },
  "origin": "172.17.0.1",
  "url": "http://127.0.0.1:56695/get"
}

httpbin 是一个简单的 HTTP 请求和响应 API,可用于测试和确认 HTTP 服务。虽然不是世界上最令人兴奋的应用程序,但可以看到我们能够通过 curl 联系到我们的服务并从中获取响应。

这是最简单的用例。我们没有进行太多配置,依赖 Kubernetes 使用其默认设置。在下一步中,我们将查看更复杂的内容。但首先,让我们关闭我们的新服务和部署。这需要两个命令:一个用于移除服务,另一个用于删除它:

$ kubectl delete service hello-minikube
service "hello-minikube" deleted

$ kubectl delete deployment hello-minikube
deployment.apps "hello-minikube" deleted

$ kubectl get all

NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
service/kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   138m

部署一个现实的堆栈

现在让我们部署一个更接近生产环境的东西。我们将部署一个应用程序,该应用程序可以从 S3 存储桶中获取 PDF 文档,在本地磁盘上缓存它们,并根据请求将单个页面转换为 PNG 图像,使用缓存的文档。为了运行这个应用程序,我们希望将缓存文件写入容器之外的某个地方。我们希望它们放在一个更加永久和稳定的地方。这一次,我们希望能够重复操作,这样我们就不需要通过一系列需要记住并希望每次都能正确执行的命令来部署我们的应用程序。Kubernetes,与 Docker Compose 类似,让我们能够在一个或多个 YAML 文件中定义我们的堆栈,这些文件包含我们关心的所有定义。这是您在生产环境中所需的,并且类似于您在其他生产工具中看到的内容。

我们现在将创建的服务将被称为lazyraster(即“按需光栅化”),每当您在 YAML 定义中看到它时,您将知道我们指的是我们的应用程序。我们的持久卷将被称为cache-data。同样,Kubernetes 拥有一个我们无法完全涵盖的庞大词汇表,但为了清楚地说明我们正在查看的内容,我们需要介绍另外两个概念:PersistentVolumePersistentVolumeClaimPersistentVolume 是我们在集群内部署的物理资源。Kubernetes 支持多种类型的卷,从节点上的本地存储到 AWS 上的 Amazon Elastic Block Store (Amazon EBS) volumes 和其他云提供商上的类似卷,还支持 Network File System (NFS) 和其他更现代的网络文件系统。PersistentVolume 存储具有与我们的应用程序或部署独立的生命周期的数据。这使我们能够存储在应用程序部署之间持续存在的数据。对于我们的缓存来说,这就是我们将要使用的内容。PersistentVolumeClaimPersistentVolume 物理资源与需要使用它的应用程序之间的链接。我们可以在索赔上设置策略,允许单个读/写索赔或多个读取索赔。对于我们的应用程序,我们只需要一个单个的读/写索赔到我们的 cache-data PersistentVolume

提示

如果您想了解我们在这里谈论的一些概念的更多细节,Kubernetes 项目维护了一个术语表,其中包含操作 Kubernetes 所涉及的所有术语的 词汇表。这可能非常有帮助。术语表中的每个条目也链接到其他页面上更详细的详细信息。

您可以通过运行以下命令查看本节中将要使用的文件:

$ git clone \
    https://github.com/bluewhalebook/\
docker-up-and-running-3rd-edition.git --config core.autocrlf=input

Cloning into 'docker-up-and-running-3rd-edition'…
…

$ cd docker-up-and-running-3rd-edition/chapter_10/kubernetes
注意

示例中的 URL 已经延续到下一行,以适应页边距。您可能需要重新组装 URL 并删除反斜杠,以使命令正常工作。

我们将从名为 lazyraster-service.yaml 的清单 YAML 文件开始。完整清单包含多个由 --- 分隔的 YAML 文档。我们将在此单独讨论每个部分。

服务定义

apiVersion: v1
kind: Service
metadata:
  name: lazyraster
  labels:
    app: lazyraster
spec:
  type: NodePort
  ports:
    - port: 8000
      targetPort: 8000
      protocol: TCP
  selector:
    app: lazyraster

第一部分定义了我们的 Service。稍后我们将看到的第二部分和第三部分分别定义了我们的 PersistentVolumeClaim 和实际的 Deployment。我们告诉 Kubernetes 我们的服务将被称为 lazyraster,并且将暴露在端口 8000 上,这映射到容器中的实际 8000 端口。我们用 NodePort 机制暴露了这一点,它简单地确保我们的应用在每个主机上的同一端口上暴露,类似于 docker container run--publish 标志。这在 minikube 中非常有帮助,因为我们将只运行一个实例,而 NodePort 类型使我们可以像之前一样从我们的计算机访问它变得容易。与 Kubernetes 的许多部分一样,除了 NodePort 外还有几个选项,您可能会找到适合生产环境的理想机制。NodePort 对于 minikube 来说很好,但对于更静态配置的负载均衡器也可能很有效。

回到我们的 Service 定义。Service 将通过 selectorDeployment 连接,我们在 spec 部分中应用它。Kubernetes 广泛使用标签作为推理类似组件和帮助将它们全部绑在一起的方式。标签是任意定义的键值对,然后可以查询以识别系统中的部件。这里的 selector 告诉 Kubernetes 查找具有标签 app: lazyrasterDeployment。注意,我们也将相同的标签应用于 Service 本身。如果以后我们想要识别所有组件,这非常有帮助,但是 selector 部分将 Deployment 与我们的 Service 绑定在一起。所以现在我们有了一个 Service,但它还没有做任何事情。我们需要更多的定义来使 Kubernetes 做我们想要的事情。

持久卷声明定义

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: cache-data-claim
  labels:
    app: lazyraster
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 100Mi

接下来的部分定义了我们的PersistentVolumeClaim,同样也定义了支持它的PersistentVolumePersistentVolumeClaim是一种命名卷并声明您有权以特定方式访问该特定卷的方式。请注意,我们这里没有定义PersistentVolume。这是因为 Kubernetes 正在为我们做这项工作,使用它所谓的动态卷分配。在我们的情况下,使用非常简单:我们想要对一个卷进行读/写声明,让 Kubernetes 为我们把它放在一个卷容器中。但是,您可以想象一种情况,即应用程序将部署到云提供商中,并且动态分配将真正发挥其作用。在这种情况下,我们不希望必须单独调用以使我们的卷在云中为我们创建。我们希望 Kubernetes 处理这些事务。这就是动态卷分配的全部内容。在这里,它只是为我们创建一个容器来保存我们的持久数据,并在我们声明要求时将其挂载到我们的 pod 中。在这一部分,我们没有做太多事情,除了命名它,要求 100 MB 的数据,并告诉 Kubernetes 它是一个只读/写的一次性挂载卷。

注意

Kubernetes 中有许多可能的卷提供程序。哪些提供程序适合您部分取决于您正在运行的提供程序或云服务。当您准备投入生产时,您应该看一看,看看哪些选项对您最有意义。

部署定义

apiVersion: apps/v1
kind: Deployment
metadata:
  name: lazyraster
  labels:
    app: lazyraster
spec:
  selector:
    matchLabels:
      app: lazyraster
  strategy:
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: lazyraster
    spec:
      containers:
      - image: relistan/lazyraster:demo
        name: lazyraster
        env:
        - name: RASTER_RING_TYPE
          value: memberlist
        - name: RASTER_BASE_DIR
          value: /data
        ports:
        - containerPort: 8000
          name: lazyraster
        volumeMounts:
        - name: cache-data
          mountPath: /data
      volumes:
      - name: cache-data
        persistentVolumeClaim:
          claimName: cache-data-claim

Deployment为我们创建了 pod,并使用 Linux 容器来运行我们的应用程序。我们为应用程序定义了一些元数据,包括其名称和一个标签,就像我们为其他定义所做的那样。我们还在这里应用了另一个selector来查找我们绑定的其他资源。在strategy部分,我们说我们希望进行RollingUpdate,这是一种策略,它导致我们的 pod 在部署过程中一个接一个地被循环。我们还可以选择Recreate,它将简单地销毁所有现有的 pod,然后在之后创建新的 pod。

template部分,我们定义了如何生成此部署的副本。容器定义包括 Docker 镜像名称、要映射的端口、要挂载的卷以及lazyraster应用程序需要的一些环境变量。spec的最后一部分要求我们有一个名为cache-data-claimPersistentVolumeClaim

应用程序定义就是这样了。现在让我们启动它吧!

注意

这里有许多更多的选项和丰富的指令集,您可以在这里指定,告诉 Kubernetes 如何处理您的应用程序。我们已经介绍了一些简单的选项,但我们鼓励您探索 Kubernetes 文档以了解更多信息。

部署应用程序

在继续之前,让我们使用kubectl命令来查看我们的 Kubernetes 集群中有什么:

$ kubectl get all

NAME                 TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
service/kubernetes   ClusterIP   10.96.0.1    <none>        443/TCP   160m

目前我们只定义了一个名为service/kubernetes的服务。在 Kubernetes 中广泛使用的命名约定是使用对象Kind的类型作为对象的前缀,有时会缩写为两个或三个字母的缩写。有时你会看到service表示为svc。如果你好奇的话,可以通过运行命令kubectl api-resources来查看所有资源及其简称。所以让我们继续将我们的服务、部署和卷放入集群中吧!

$ kubectl apply -f ./lazyraster-service.yaml

service/lazyraster created
persistentvolumeclaim/cache-data-claim created
deployment.apps/lazyraster created

那个输出看起来像我们预期的:我们有一个服务、一个持久卷索赔和一个部署。所以现在让我们看看集群中有什么:

$ kubectl get all

NAME                              READY   STATUS    RESTARTS   AGE
pod/lazyraster-644cb5c66c-zsjxd   1/1     Running   0          17s

NAME               TYPE      CLUSTER-IP     EXTERNAL-IP PORT(S)        AGE
service/kubernetes ClusterIP 10.96.0.1      <none>      443/TCP        161m
service/lazyraster NodePort  10.109.116.225 <none>      8000:32544/TCP 17s

NAME                         READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/lazyraster   1/1     1            1           17s

NAME                                    DESIRED   CURRENT   READY   AGE
replicaset.apps/lazyraster-644cb5c66c   1         1         1       17s

你可以看到幕后发生了更多的事情。还有,我们的卷或持久卷索赔在哪里?我们必须单独请求它:

$ kubectl get pvc

NAME             STATUS VOLUME    CAPACITY ACCESS MODES STORAGECLASS AGE
cache-data-claim Bound  pvc-1a…41 100Mi    RWO          standard     65s
提示

kubectl get all并不做任何这样的事情。更恰当地说,它应该被命名为get all-of-the-most-common-resources,但你可以获取几个其他资源。Kubernetes 项目提供了一个方便的速查表,以使其更易于发现。

那么get all输出中出现的replicaset.apps是什么呢?那是一个 ReplicaSet。ReplicaSet 是 Kubernetes 的一部分,负责确保我们的应用始终运行正确数量的实例,并保持它们的健康状态。通常我们不需要担心 ReplicaSet 内部发生的事情,因为我们创建的部署会为我们管理它。如果需要的话,你可以自己管理 ReplicaSet,但大多数情况下你不需要或者不想这样做。

我们没有告诉kubectl要多少个实例,所以我们得到了一个。我们可以看到期望状态和当前状态都匹配。我们稍后再看看这个。但首先,让我们连接到我们的应用程序,看看我们有什么:

$ minikube service --url lazyraster
http://192.168.99.100:32185

你可能会得到一个不同的 IP 地址和端口。没关系!这是非常动态的东西。这就是为什么我们使用minikube命令来为我们管理它。

还记得minikube会在你探索lazyraster服务时警告你需要保持service命令运行。所以获取返回的地址,打开你的网页浏览器,并将其粘贴到 URL 栏中,像这样:http://<192.168.99.100:32185>/documents/docker-up-and-running-public/sample.pdf?page=1。你需要将 IP 和端口替换到 URL 中以使其对你有效。

您需要连接到互联网,因为 lazyraster 应用程序将会访问互联网,从公共 S3 存储桶获取一个 PDF 文档,然后进行所谓的 光栅化 过程,将文档的第一页渲染为 PNG 图像。如果一切正常,您应该能看到这本书早期版本的封面的副本!这本特定的 PDF 有两页,因此您可以尝试将参数更改为 ?page=2。如果这样做,您可能会注意到渲染速度比第一页要快得多。这是因为应用程序正在使用我们的持久卷缓存数据。您还可以指定 width=2048,或者请求 JPEG 格式而不是 PNG 格式。您可以像这样将封面渲染为一个非常大的 JPEG:

http://<192.168.99.100:32185>/documents/docker-up-and-running-public/sample.pdf?page=1&imageType=image/jpeg&width=2048

如果您有一个公共 S3 存储桶,并且其中还有其他 PDF 文档,您可以简单地在 URL 中将存储桶名称替换为 docker-up-and-running-public,以访问您的存储桶。如果您想进一步测试这个应用程序,请查看 GitHub 上 Nitro/lazyraster 仓库

扩展操作

在现实生活中,您不仅仅部署应用程序;还要运行它们。定时工作负载的一个巨大优势是能够根据系统可用的资源约束随意扩展或缩减它们。在我们的情况下,我们只有一个 Minikube 节点,但是我们仍然可以扩展我们的服务,以更好地处理负载并在部署期间提供更多可靠性。您可以想象,Kubernetes 允许轻松地扩展和缩减。对于我们的服务,我们只需一个命令即可完成。然后我们将再次查看 kubectl 输出以及我们之前介绍的 Kubernetes 仪表板,以证明服务已扩展。

在 Kubernetes 中,我们要扩展的不是服务,而是部署。这是其样子:

$ kubectl scale --replicas=2 deploy/lazyraster
deployment.apps/lazyraster scaled

太好了,这次有反应了!但我们得到了什么呢?

$ kubectl get deployment/lazyraster

NAME         READY   UP-TO-DATE   AVAILABLE   AGE
lazyraster   2/2     2            2           16m

现在我们有两个应用程序实例在运行。让我们看看日志内容:

$ kubectl logs deployment/lazyraster

Found 2 pods, using pod/lazyraster-644cb5c66c-zsjxd
Trying to clear existing Lazyraster cached files (if any) in the background…
Launching Lazyraster service…
time="2022-09-10T21:14:16Z" level=info msg="Settings -----------------…
time="2022-09-10T21:14:16Z" level=info msg="  * BaseDir: /data"
time="2022-09-10T21:14:16Z" level=info msg="  * HttpPort: 8000"
…
time="2022-09-10T21:14:16Z" level=info msg="  * LoggingLevel: info"
time="2022-09-10T21:14:16Z" level=info msg="--------------------------…
…
time="2022-09-10T21:14:16Z" level=info msg="Listening on tcp://:6379"
…

我们要求部署的日志,但 Kubernetes 告诉我们有两个 Pod 正在运行,所以它只是选择其中一个来显示日志。我们可以看到复制正在启动。如果我们想指定一个特定的实例来查看,我们可以使用 kubectl get pods 的输出来找到那个 Pod,并使用类似 kubectl logs pod/lazyraster-644cb5c66c-zsjxd 的命令获取该 Pod 的日志。

现在我们有几个应用程序副本在运行。这在 Kubernetes 仪表板上是什么样子?让我们通过 minikube dashboard 导航到那里。一旦到达那里,我们将从左侧边栏选择“工作负载 - 部署”,然后点击 lazyraster 部署,这将显示一个看起来像 Figure 10-3 的屏幕。

Lazyraster 服务仪表板

图 10-3. lazyraster 服务仪表板(示例)

我们鼓励你在 Kubernetes 仪表板中多点击几下,看看还有什么其他信息。有了你在这里学到的概念,很多事情现在应该更清晰了,你可能还可以自行探索更多。同样,kubectl 还有许多其他可用选项,其中许多在真实生产系统中可能会用到。我们之前讨论的速查表在这里确实是一个救命稻草!

如常,你可以随时键入 Ctrl-C 退出运行中的 minikube dashboard 命令。

kubectl API

我们还没有向你展示 API,正如我们之前与 Docker 讨论的那样,拥有一个简单的 API 以供脚本编写、编程和其他一般操作需求非常有用。你可以编写程序直接与 Kubernetes API 交互,但对于本地开发和其他简单用例,你可以使用 kubectl 作为 Kubernetes 的一个良好代理,并提供了一个可以通过 curl 和 JSON 命令行工具访问的清晰 API。以下是一个你可以做的示例:

$ kubectl proxy
Starting to serve on 127.0.0.1:8001

现在我们已经让 kubectl 本身在本地系统上提供了一个 Web API!你需要进一步了解可能性,但让我们让它显示 lazyraster 应用程序的各个实例。我们可以通过在浏览器中打开以下 URL 或在另一个终端窗口中使用 curl 来实现:http://localhost:8001/api/v1/namespaces/default/endpoints/lazyraster

这里输出很多,但我们关心的部分是 subsets 部分:

{
…
  "subsets": [
    {
      "addresses": [
        {
          "ip": "172.17.0.5",
          "nodeName": "minikube",
          "targetRef": {
            "kind": "Pod",
            "namespace": "default",
            "name": "lazyraster-644cb5c66c-zsjxd",
            "uid": "9631395d-7e68-47fa-bb9f-9641d724d8f7"
          }
        },
        {
          "ip": "172.17.0.6",
          "nodeName": "minikube",
          "targetRef": {
            "kind": "Pod",
            "namespace": "default",
            "name": "lazyraster-644cb5c66c-pvcmj",
            "uid": "e909d424-7a91-4a74-aed3-69562b74b422"
          }
        }
      ],
      "ports": [
        {
          "port": 8000,
          "protocol": "TCP"
        }
      ]
    }
  ]
}

这里有趣的地方在于我们可以看到两个实例都在 Minikube 主机上运行,并且它们具有不同的 IP 地址。如果我们正在构建一个需要知道应用程序的其他实例运行位置的云原生应用程序,这将是一个很好的方法。

你可以随时键入 Ctrl-C 退出运行中的 kubectl proxy 进程,然后可以通过运行以下命令来删除部署及其所有组件。Kubernetes 可能需要一两分钟来删除所有内容并返回到终端提示符:

$ kubectl delete -f ./lazyraster-service.yaml

service "lazyraster" deleted
persistentvolumeclaim "cache-data-claim" deleted
deployment.apps "lazyraster" deleted

最后,如果你目前已经完成了 Minikube 集群中的所有工作,你可以继续删除它:

$ minikube delete

  Deleting "minikube" in docker …
  Deleting container "minikube" …
  Removing /Users/spkane/.minikube/machines/minikube …
  Removed all traces of the "minikube" cluster.
提示

Kubernetes 是一个非常庞大的系统,有着广泛的社区参与。我们仅仅展示了 Minikube 的冰山一角,但如果你感兴趣,还有许多其他 Kubernetes 发行版和工具可以探索。

Docker Desktop-集成 Kubernetes

Docker Desktop 自带对一个集成的单节点 Kubernetes 集群的支持,可以通过在应用程序首选项中简单启用选项来运行。

集成的 Kubernetes 集群不易配置,但对于那些只需验证当前 Kubernetes 安装的基本功能的用户来说,它提供了一个非常便捷的选项。

要启用 Docker Desktop 内置的 Kubernetes 功能,请启动 Docker Desktop,然后从任务栏/菜单栏中的 Docker 鲸鱼图标打开首选项。然后选择 Kubernetes 选项卡,点击启用 Kubernetes,最后点击“应用并重启”按钮以对 VM 进行所需的更改。第一次这样做时,Docker 将利用kubeadm命令来设置 Kubernetes 集群。

注意

如果你对 Docker Desktop 集成的 Kubernetes 如何设置感兴趣,Docker 有一篇很好的博文,详细介绍了其中一些细节。

这将创建一个名为docker-desktop的新kubectl上下文,并应自动切换到该上下文。

你可以通过运行以下命令来确认当前设置的上下文:

$ kubectl config current-context

docker-desktop

如果你需要更改当前的上下文,可以像这样操作:

$ kubectl config use-context docker-desktop --namespace=default

Switched to context "docker-desktop".

最后,如果你想完全取消当前上下文,可以使用以下命令:

$ kubectl config unset current-context

Property "current-context" unset.

一旦这个集群运行起来,你可以通过kubectl命令与它交互,就像任何其他 Kubernetes 集群一样。每当你关闭 Docker Desktop 时,这也会关闭 Kubernetes 集群。

如果你想完全禁用这个 Kubernetes 集群,回到首选项面板,选择 Kubernetes 选项卡,并取消选择启用 Kubernetes。

类型

我们最后要讨论的选项是kind,这是一个非常简单但非常有用的工具,允许你管理一个由一个或多个在 Docker 中运行的 Linux 容器组成的 Kubernetes 集群。工具名称kind是一个首字母缩略词,意思是“Kubernetes in Docker”,但也指的是 Kubernetes 中的对象类型在 API 中由一个名为Kind的字段标识。

注意

你会发现在网络上搜索这个工具可能有点困难,但你总是可以在其主要网站上找到该工具和文档。

kind提供了一个很好的折中方案,介于嵌入到 Docker VM 中的简单化 Kubernetes 集群和有时过于复杂的minikube VM 之间。kind作为一个单一的二进制文件分发,并可以通过你喜欢的包管理器安装,或者简单地访问kind项目发布页面,下载最新的适合你系统的版本。如果你手动下载二进制文件,请确保将其重命名为kind,复制到路径中的某个目录,并确保它具有正确的权限,以便用户可以运行它。

一旦kind安装完成,你可以通过运行以下命令尝试用它创建你的第一个集群:

$ kind create cluster --name test

Creating cluster "test" …
 ✓ Ensuring node image (kindest/node:v1.25.3) 
 ✓ Preparing nodes 
 ✓ Writing configuration 
 ✓ Starting control-plane 
 ✓ Installing CNI 
 ✓ Installing StorageClass 
Set kubectl context to "kind-test"
You can now use your cluster with:

kubectl cluster-info --context kind-test

Thanks for using kind!  

默认情况下,这个命令将启动一个表示单节点 Kubernetes 集群的单个 Docker 容器,使用kind当前支持的最新稳定 Kubernetes 版本。

kind已将 Kubernetes 当前上下文设置为指向集群,因此我们可以立即开始运行kubectl命令:

$ kubectl cluster-info

Kubernetes control plane is running at https://127.0.0.1:56499
CoreDNS is running at
https://127.0.0.1:56499/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

通过运行以下命令,您可以查看kubectl连接到 Kubernetes 服务器时使用的信息的摘要版本:

$ kubectl config view --minify
apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: DATA+OMITTED
    server: https://127.0.0.1:56499
  name: kind-test
contexts:
- context:
    cluster: kind-test
    user: kind-test
  name: kind-test
current-context: kind-test
kind: Config
preferences: {}
users:
- name: kind-test
  user:
    client-certificate-data: REDACTED
    client-key-data: REDACTED

kind具有一些高级功能,通常可以通过在启动集群时使用--config参数传入配置文件来控制。

您可能会发现以下某些功能很有用:

  • 更改正在使用的 Kubernetes 版本

  • 启动多个工作节点

  • 启动多个控制平面节点进行高可用性测试

  • 映射 Docker 与本地主机系统之间的端口

  • 启用和禁用Kubernetes 功能开关

  • 使用kind export logs导出控制平面组件日志

  • 更多内容

提示

当使用kind时需要记住的一件事情是,Kubernetes 正在一个或多个容器内运行,当您使用类似 Docker Desktop 的东西时,这些容器可能运行在 Linux 虚拟机内。这可能意味着在启动集群时需要设置一些额外的端口转发。这可以通过kind配置中的extraPortMappings设置来完成。

在这一点上,您可以通过运行以下命令删除集群:

$ kind delete cluster --name test

Deleting cluster "test" …

Amazon ECS 和 Fargate

最受欢迎的云提供商之一是亚马逊,通过他们的 AWS 提供的服务。自 2014 年中期以来,AWS Elastic Beanstalk就支持原生运行容器。但是该服务仅将单个容器分配给亚马逊实例,这意味着对于短暂或轻量级容器并非理想选择。然而,亚马逊的 Elastic Compute Cloud (Amazon EC2)本身是托管自己的 Docker 环境的绝佳平台,由于 Docker 功能强大,您不一定需要在实例之上添加太多内容来提高工作效率。但是,亚马逊花费了大量工程时间建立了一项服务,将容器视为一等公民:Amazon Elastic Container Service (Amazon ECS)。近年来,亚马逊通过 Elastic Kubernetes Services (EKS)和 AWS Fargate 等产品进一步增强了对此支持。

注意

Fargate 只是亚马逊用于 ECS 功能的一个市场标签,使 AWS 能够自动管理容器集群中的所有节点,从而让您专注于部署服务。

ECS 是一组工具,协调多个 AWS 组件。使用 ECS,您可以选择是否在其上运行 Fargate 工具。如果选择运行 Fargate,则无需处理太多工作。如果不运行 Fargate,则除了集群节点处理工作负载外,还需要向运行 Docker 和 Amazon 特殊 ECS 代理的集群添加一个或多个 EC2 实例。无论哪种情况,您都会启动集群,然后将容器推送到其中。

我们刚提到的Amazon ECS 代理与 ECS 服务配合工作,协调您的集群并将容器调度到您的主机上。当您管理传统的非 Fargate ECS 集群时,您将直接接触到这一点。

核心 AWS 设置

本节的其余部分假设您可以访问 AWS 账户并对服务有一些熟悉。您可以在https://aws.amazon.com/free了解定价并创建新账户。Amazon 提供免费服务层,如果您尚未拥有付费账户,则可能足够您进行实验。设置完 AWS 账户后,您将需要至少一个管理用户、一个密钥对、一个 Amazon 虚拟私有云(AWS VPC)和环境中的默认安全组。如果您尚未设置这些内容,请按照Amazon 文档中的说明进行操作。

IAM 角色设置

Amazon 的身份和访问管理(Amazon IAM)角色用于控制用户在您的云环境中可以执行的操作。在继续使用 ECS 之前,我们需要确保可以授予访问正确操作的权限。要使用 ECS,您必须创建一个名为ecsInstanceRole的角色,并附加AmazonEC2ContainerServiceRole托管角色。最简单的方法是登录到AWS 控制台,然后导航到身份和访问管理

提示

检查确保您尚未具有适当的角色。如果已存在,则应再次检查其是否已正确设置,因为这些方向在多年间已有所更改。

  1. 在左侧边栏中,点击角色。

  2. 然后,点击“创建角色”按钮。

  3. 在 AWS 服务下,选择弹性容器服务。

  4. 在“选择您的用例”下,选择弹性容器服务。

  5. 点击“下一步:权限”。

  6. 点击“下一步:审核”。

  7. 在角色名称中,键入ecsInstanceRole

  8. 点击“创建角色”。

如果您有兴趣将容器配置存储在 S3 对象存储桶中,请查看 Amazon ECS 容器代理配置文档

AWS CLI 设置

Amazon 提供了命令行工具,使得与其基于 API 的基础设施工作变得容易。您需要安装最新版本的 AWS CLI 工具。Amazon 提供了详细文档来覆盖其工具的安装,但基本步骤如下。

安装

这里我们将涵盖在几种不同的操作系统上的本地安装,但请注意,您也可以通过Docker 容器来运行 AWS CLI!您可以随意跳到您关心的部分。如果您好奇或者只是喜欢安装说明,请务必将它们全部阅读!

macOS

在第三章中,我们讨论了安装 Homebrew。如果您之前已经这样做了,您可以使用以下命令安装 AWS CLI:

$ brew update
$ brew install awscli

Windows

Amazon 为 Windows 提供了一个标准的 MSI 安装程序,可从 Amazon S3 下载适合您体系结构的版本:

其他

Amazon CLI 工具是用 Python 编写的。因此,在大多数平台上,您可以通过 Python 的pip软件包管理器运行以下命令来安装这些工具:

$ pip install awscli --upgrade --user

一些平台默认情况下不会安装pip。在这种情况下,您可以使用easy_install软件包管理器,如下所示:

$ easy_install awscli

配置

快速验证您的 AWS CLI 版本至少为 1.7.0,运行以下命令:

$ aws --version

aws-cli/1.14.50 Python/3.6.4 Darwin/17.3.0 botocore/1.9.3

要配置 AWS CLI 工具,请确保您可以访问您的 AWS 访问密钥 ID 和 AWS 秘密访问密钥,然后运行configure命令。系统会提示您输入身份验证信息和一些首选默认值:

$ aws configure

AWS Access Key ID [None]: EXAMPLEEXAMPLEEXAMPLE
AWS Secret Access Key [None]: ExaMPleKEy/7EXAMPL3/EXaMPLeEXAMPLEKEY
Default region name [None]: us-east-1
Default output format [None]: json

在此时,测试 CLI 工具是否正常工作是一个非常好的主意。您可以通过运行以下命令轻松实现,列出您帐户中的 IAM 用户:

$ aws iam list-users

假设一切按计划进行,并且您选择了 JSON 作为默认输出格式,您应该会得到类似这样的输出:

{
    "Users": [
        {
            "Path": "/",
            "UserName": "administrator",
            "UserId": "ExmaPL3ExmaPL3ExmaPL3Ex",
            "Arn": "arn:aws:iam::936262807352:user/myuser",
            "CreateDate": "2021-04-08T17:22:23+00:00",
            "PasswordLastUsed": "2022-09-05T15:56:21+00:00"
        }
    ]
}

容器实例

安装所需工具后,首先要做的事情是创建至少一个集群,以便您的 Docker 主机在上线时可以注册到该集群中。

注意

默认集群被形象地命名为default。如果您保留此名称,您在接下来的许多命令中不需要指定--cluster-name

安装所需工具后,首先要做的事情是在容器服务中创建一个集群。一旦集群启动运行,您将在集群中启动您的任务。针对这些示例,您应该首先创建一个名为fargate-testing的集群:

$ aws ecs create-cluster --cluster-name fargate-testing
{
    "cluster": {
        "clusterArn": "arn:aws:ecs:us-east-1:1…2:cluster/fargate-testing",
"clusterName": "fargate-testing",
        "status": "ACTIVE",
        "registeredContainerInstancesCount": 0,
        "runningTasksCount": 0,
        "pendingTasksCount": 0,
        "activeServicesCount": 0,
        "statistics": [],
        "tags": [],
        "settings": [
            {
                "name": "containerInsights",
                "value": "disabled"
            }
        ],
        "capacityProviders": [],
        "defaultCapacityProviderStrategy": []
    }
}

在 AWS Fargate 发布之前,您需要创建运行dockerecs-agent的 AWS EC2 实例,并将它们添加到您的集群中。如果您愿意,您仍然可以使用这种方法(EC2 launch type),但是 Fargate 可以更轻松地运行动态集群,可以根据工作负载流畅地进行扩展。

任务

现在我们的容器集群已经设置好,我们需要开始让它工作。为此,我们需要创建至少一个任务定义。Amazon ECS 将术语任务定义定义为一组组合在一起的容器列表。

要创建您的第一个任务定义,请打开您喜欢的编辑器,复制以下 JSON,并将其保存为webgame-task.json,如下所示:

{
  "containerDefinitions": [
    {
      "name": "web-game",
      "image": "spkane/quantum-game",
      "cpu": 0,
      "portMappings": [
        {
          "containerPort": 8080,
          "hostPort": 8080,
          "protocol": "tcp"
        }
      ],
      "essential": true,
      "environment": [],
      "mountPoints": [],
      "volumesFrom": []
    }
  ],
  "family": "fargate-game",
  "networkMode": "awsvpc",
  "volumes": [],
  "placementConstraints": [],
  "requiresCompatibilities": [
    "FARGATE"
  ],
  "cpu": "256",
  "memory": "512"
}
提示

您还可以通过运行以下内容查看这些文件及其他几个文件:

git clone \
 https://github.com/bluewhalebook/\
docker-up-and-running-3rd-edition.git \
 --config core.autocrlf=input

URL 已经继续到下一行,以适应页面边缘。您可能需要重新组合 URL 并删除反斜杠,以使命令正常工作。

在这个任务定义中,我们表示我们要创建一个名为fargate-game的任务系列,运行一个名为web-game的单个容器,该容器基于Quantum web game

提示

Fargate 限制了您可以在此配置中设置的一些选项,包括networkMode以及cpumemory设置。您可以从官方AWS 文档中了解有关任务定义选项的更多信息。

在这个任务定义中,我们定义了一些有关容器内存和 CPU 使用的约束,除此之外,还告诉 Amazon 此容器是否对任务至关重要。当您在任务中定义了多个容器且并非所有容器都是成功任务的必需部分时,essential标志就非常有用。如果essential为 true 并且容器无法启动,则任务中定义的所有容器都将被终止,并且任务将被标记为失败。我们还可以使用任务定义来定义几乎所有包含在Dockerfiledocker container run命令行中的典型变量和设置。

要将此任务定义上传到 Amazon,您需要运行类似于以下内容的命令:

$ aws ecs register-task-definition --cli-input-json file://./webgame-task.json
{
    "taskDefinition": {
        "taskDefinitionArn": "arn:aws:ecs:…:task-definition/fargate-game:1",
        "containerDefinitions": [
            {
                "name": "web-game",
                "image": "spkane/quantum-game",
                "cpu": 0,
                "portMappings": [
                    {
                        "containerPort": 8080,
                        "hostPort": 8080,
                        "protocol": "tcp"
                    }
                ],
                "essential": true,
                "environment": [],
                "mountPoints": [],
                "volumesFrom": []
            }
        ],
        "family": "fargate-game",
        "networkMode": "awsvpc",
        "revision": 1,
        "volumes": [],
        "status": "ACTIVE",
        "requiresAttributes": [
            {
                "name": "com.amazonaws.ecs.capability.docker-remote-api.1.18"
            },
            {
                "name": "ecs.capability.task-eni"
            }
        ],
        "placementConstraints": [],
        "compatibilities": [
            "EC2",
            "FARGATE"
        ],
        "requiresCompatibilities": [
            "FARGATE"
        ],
        "cpu": "256",
        "memory": "512",
        "registeredAt": "2022-09-05T09:10:18.184000-07:00",
        "registeredBy": "arn:aws:iam::…:user/me"
    }
}

然后,我们可以通过运行以下内容列出所有我们的任务定义:

$ aws ecs list-task-definitions
{
    "taskDefinitionArns": [
        "arn:aws:ecs:us-east-1:…:task-definition/fargate-game:1",
    ]
}

现在,您可以通过运行接下来显示的命令来创建您集群中的第一个任务。命令中的count参数允许您定义要部署到集群中的此任务的副本数量。对于此工作,一个副本就足够了。

您需要修改以下命令以引用来自您的 AWS VPC 的有效子网 ID 和安全组 ID。 您可以在 AWS 控制台 或使用 AWS CLI 命令 aws ec2 describe-subnetsaws ec2 describe-security-groups 中找到这些信息。 您还可以告知 AWS 使用类似以下的网络配置为您的任务分配公共 IP 地址:

awsvpcConfiguration={subnets=[subnet-abcd1234],
                     securityGroups=[sg-abcd1234],
                     assignPublicIp=ENABLED}

如果您使用公共子网,则可能需要分配公共 IP 地址:

$ aws ecs create-service --cluster fargate-testing --service-name \
    fargate-game-service --task-definition fargate-game:1 --desired-count 1 \
    --launch-type "FARGATE" --network-configuration \
    "awsvpcConfiguration={subnets=[subnet-abcd1234],\
 securityGroups=[sg-abcd1234]}"
{
    "service": {
        "serviceArn": "arn:aws:ecs:…:service/fargate-game-service",
        "serviceName": "fargate-game-service",
        "clusterArn": "arn:aws:ecs:…:cluster/fargate-testing",
        "loadBalancers": [],
        "serviceRegistries": [],
        "status": "ACTIVE",
        "desiredCount": 1,
        "runningCount": 0,
        "pendingCount": 0,
        "launchType": "FARGATE",
        "platformVersion": "LATEST",
        "platformFamily": "Linux",
        "taskDefinition": "arn:aws:ecs:…:task-definition/fargate-game:1",
        "deploymentConfiguration": {
            "deploymentCircuitBreaker": {
                "enable": false,
                "rollback": false
            },
            "maximumPercent": 200,
            "minimumHealthyPercent": 100
        },
        "deployments": [
            {
                "id": "ecs-svc/…",
                "status": "PRIMARY",
                "taskDefinition": "arn:aws:ecs:…definition/fargate-game:1",
                "desiredCount": 1,
                "pendingCount": 0,
                "runningCount": 0,
                "failedTasks": 0,
                "createdAt": "2022-09-05T09:14:51.653000-07:00",
                "updatedAt": "2022-09-05T09:14:51.653000-07:00",
                "launchType": "FARGATE",
                "platformVersion": "1.4.0",
                "platformFamily": "Linux",
                "networkConfiguration": {
…
                },
                "rolloutState": "IN_PROGRESS",
                "rolloutStateReason": "ECS deployment ecs-svc/… in progress."
            }
        ],
        "roleArn": "…aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS",
        "events": [],
        "createdAt": "2022-09-05T09:14:51.653000-07:00",
        "placementConstraints": [],
        "placementStrategy": [],
        "networkConfiguration": {
…
        },
        "schedulingStrategy": "REPLICA",
        "createdBy": "arn:aws:iam::…:user/me",
        "enableECSManagedTags": false,
        "propagateTags": "NONE",
        "enableExecuteCommand": false
    }
}
提示

Fargate 和 awsvpc 网络要求您为 ECS 拥有服务链接角色。 在先前的输出中,您应该看到以此结束的一行:

"role/aws-service-role/ecs.amazonaws.com/
AWSServiceRoleForECS"

大多数情况下,这将为您自动生成,但您可以使用以下命令手动创建它:

$ aws iam create-service-linked-role \
    --aws-service-name ecs.amazonaws.com

您现在可以使用以下命令列出集群中的所有服务:

$ aws ecs list-services --cluster fargate-testing
{
    "serviceArns": [
        "arn:aws:ecs:us-west-2:…:service/fargate-testing/fargate-game-service"
    ]
}

要检索有关服务的所有详细信息,请运行以下命令:

$ aws ecs describe-services --cluster fargate-testing \
    --services fargate-game-service
{
    "services": [
        {
…
            "deployments": [
                {
                    "id": "ecs-svc/…",
                    "status": "PRIMARY",
                    "taskDefinition": "arn:…:task-definition/fargate-game:1",
                    "desiredCount": 1,
                    "pendingCount": 1,
                    "runningCount": 0,
                    "createdAt": "2022-09-05T09:14:51.653000-07:00",
                    "updatedAt": "2022-09-05T09:14:51.653000-07:00",
                    "launchType": "FARGATE",
                    "platformVersion": "1.4.0",
                    "platformFamily": "Linux",
                    "networkConfiguration": {
…
                    },
                    "rolloutState": "IN_PROGRESS",
                    "rolloutStateReason": "ECS deployment ecs-svc/…progress."
                }
            ],
            "roleArn": "…role/ecs.amazonaws.com/AWSServiceRoleForECS",
            "events": [
                {
                    "id": "83bd5c2eed5d4866bb7ec8c3c938666c",
                    "createdAt": "2022-09-05T09:14:54.950000-07:00",
                    "message": "(…game-service) has started 1 tasks: (…)."
                }
            ],
…
        }
    ],
    "failures": []
}

此输出将向您展示有关服务中所有任务的大量信息。 在本例中,我们目前只运行一个单一任务。

注意

task-definition 值是一个名称,后跟一个数字(fargate-game:1)。 数字是修订版。 如果您编辑任务并使用 aws ecs register-task-definition 命令重新注册它,您将获得一个新的修订版,这意味着您将希望在各种命令中引用该新修订版,例如 aws ecs update-service。 如果您不更改该数字,则将继续使用较旧的 JSON 启动容器。 这种版本控制使得回滚更改和测试新修订版而不影响所有未来实例变得非常容易。

如果要查看集群中正在运行的各个任务,可以运行以下命令:

$ aws ecs list-tasks --cluster fargate-testing
{
    "taskArns": [
        "arn:aws:ecs:…:task/fargate-testing/83bd5c2eed5d4866bb7ec8c3c938666c"
    ]
}

由于您目前的集群只有一个单一任务,因此此列表非常小。

要获取有关单个任务的更多详细信息,您可以在从您的集群中正确替换任务 ID 后运行以下命令:

$ aws ecs describe-tasks --cluster fargate-testing \
  --task 83bd5c2eed5d4866bb7ec8c3c938666c
{
    "tasks": [
        {
            "attachments": [
                {
…
                    "details": [
…
                        {
                            "name": "networkInterfaceId",
                            "value": "eni-00a40225208c9411a"
                        },
…
                        {
                            "name": "privateIPv4Address",
                            "value": "172.31.42.184"
                        }
                    ]
                }
            ],
            "attributes": [
…
            ],
            "availabilityZone": "us-west-2b",
            "clusterArn": "arn:aws:ecs:us-west-2:…:cluster/fargate-testing",
            "connectivity": "CONNECTED",
            "connectivityAt": "2022-09-05T09:23:46.929000-07:00",
            "containers": [
                {
                    "containerArn": "arn:…:container/fargate-testing/…",
                    "taskArn": "arn:…:task/fargate-testing/…",
                    "name": "web-game",
                    "image": "spkane/quantum-game",
                    "runtimeId": "83bd…998",
                    "lastStatus": "RUNNING",
                    "networkInterfaces": [
                        {
                            "attachmentId": "ddab…373a",
                            "privateIpv4Address": "172.31.42.184"
                        }
                    ],
                    "healthStatus": "UNKNOWN",
                    "cpu": "0"
                }
            ],
            "cpu": "256",
            "createdAt": "2022-09-05T09:23:42.700000-07:00",
            "desiredStatus": "RUNNING",
            "enableExecuteCommand": false,
            "group": "service:fargate-game-service",
            "healthStatus": "UNKNOWN",
            "lastStatus": "RUNNING",
            "launchType": "FARGATE",
            "memory": "512",
            "overrides": {
                "containerOverrides": [
                    {
                        "name": "web-game"
                    }
                ],
                "inferenceAcceleratorOverrides": []
            },
            "platformVersion": "1.4.0",
            "platformFamily": "Linux",
            "pullStartedAt": "2022-09-05T09:59:36.554000-07:00",
            "pullStoppedAt": "2022-09-05T09:59:46.361000-07:00",
            "startedAt": "2022-09-05T09:59:48.546000-07:00",
            "startedBy": "ecs-svc/…",
            "tags": [],
            "taskArn": "arn:aws:…:task/fargate-testing/83bd…666c",
            "taskDefinitionArn": "arn:aws:…:task-definition/fargate-game:1",
            "version": 4,
            "ephemeralStorage": {
                "sizeInGiB": 20
            }
        }
    ],
    "failures": []
}

如果注意到 lastStatus 键显示值为 PENDING,这很可能意味着您的服务仍在启动中。 您可以再次描述任务以确保其已完成过渡到 RUNNING 状态。 验证 lastStatus 键已设置为 RUNNING 后,您应该能够测试您的容器。

提示

根据网络设置不同,您的任务可能无法下载映像。 如果您看到此类错误:

"stoppedReason": "CannotPullContainerError: inspect image has been retried 5 time(s): failed to resolve ref \"docker.io/spkane/quantum-game:latest\": failed to do request: Head [*https://registry-1.docker.io/v2/spkane/quantum-game/manifests/latest*](https://registry-1.docker.io/v2/spkane/quantum-game/manifests/latest): dial tcp 54.83.42.45:443: i/o timeout"

然后,您应该阅读此 故障排除指南.^(1)

测试任务

您需要在系统上安装一个现代网络浏览器,以连接到容器并测试网络游戏。

在先前的输出中,您会注意到示例任务的privateIPv4Address列为172.31.42.184。您的地址可能会有所不同。

小贴士

如果您需要关于任务的网络设置和它正在运行的 EC2 实例的更多信息,您可以从aws ecs describe-tasks输出中获取networkInterfaceId,然后将其附加到aws ec2 describe-network-interfaces --network-interface-ids命令,以获取您所需的所有信息,包括如果您为该服务配置了PublicIp值。

确保您连接到可以访问主机的公共或私有 IP 地址的网络,然后启动您的网络浏览器并导航到该 IP 地址的端口 8080。

在示例中,这个私有 URL 将如下所示:

http://172.31.42.184:8080/

如果一切正常,您将会看到Quantum Game的谜题板。

游戏的官方版本可以在https://quantumgame.io找到。

注意

如果您在这一点上分心并停止阅读几个小时来解决一些谜题并同时学习一些量子力学,我们完全理解。书本不会介意!放下它,玩些谜题,稍后再拾起。

停止任务

对了,我们有一个正在运行的任务。现在让我们来看看如何停止它。要做到这一点,您需要知道任务 ID。获取任务 ID 的一种方法是重新列出在您的集群中运行的所有任务:

$ aws ecs list-tasks --cluster fargate-testing
{
    "taskArns": [
        "arn:aws:ecs:…:task/fargate-testing/83bd5c2eed5d4866bb7ec8c3c938666c"
    ]
}

您还可以从服务信息中获取它:

$ aws ecs describe-services --cluster fargate-testing \
    --services fargate-game-service
{
…
                {
                    "id": "6b7f…0384",
                    "createdAt": "2022-09-05T09:59:23.917000-07:00",
                    "message": "…: (task 83bd5c2eed5d4866bb7ec8c3c938666c)."
                }
…
}

最后,我们可以通过运行以下带有正确任务 ID 的命令来停止任务:

$ aws ecs stop-task --cluster fargate-testing \
    --task 83bd5c2eed5d4866bb7ec8c3c938666c
{
        "desiredStatus": "STOPPED",
…
        "lastStatus": "RUNNING",
…
        "stopCode": "UserInitiated",
        "stoppedReason": "Task stopped by user",
        "stoppingAt": "2022-09-05T10:29:05.110000-07:00",
…
}

如果您再次使用相同的任务 ID 描述任务,则现在应该看到lastStatus键设置为STOPPED

$ aws ecs describe-tasks --cluster fargate-testing \
    --task 83bd5c2eed5d4866bb7ec8c3c938666c
{
…
            "desiredStatus": "STOPPED",
…
            "lastStatus": "STOPPED",
…
}

列出我们集群中的所有任务应该返回一个空集:

$ aws ecs list-tasks --cluster fargate-testing
{
    "taskArns": []
}

此时,您可以开始创建更复杂的任务,将多个容器联系在一起,并依赖 ECS 和 Fargate 工具来根据需要启动主机并将任务部署到您的集群中。

如果您想要拆除其余的 ECS 环境,可以运行以下命令:

$ aws ecs delete-service --cluster fargate-testing \
  --service fargate-game-service  --force
…

$ aws ecs delete-cluster --cluster fargate-testing
…

总结

在本章中,我们确实向您展示了许多选项!您可能永远不会需要使用所有这些选项,因为其中许多选项重叠。但是,每个选项都对应着关于生产系统应该如何构建以及哪些问题最重要的独特视角。在探索所有这些工具之后,您应该对可以选择的各种选项来构建生产 Linux 容器环境有一个相当不错的理解。

所有这些工具的基础是 Docker 的高度可移植的 Linux 容器镜像格式,以及它可以将底层 Linux 系统的许多内容抽象化,这使得您可以轻松地在数据中心和任意多个云提供商之间流畅地迁移应用程序。现在,您只需选择最适合您和您的组织的方法,然后实施它。

与此同时,让我们跳到下一章,探索 Docker 生态系统中一些最技术性的主题,包括安全性、网络和存储。

^(1) 完整网址:https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_cannot_pull_image.html

第十一章:高级主题

在这一章中,我们将快速浏览一些更高级的主题。我们假设您对 Docker 已经有了相当好的掌握,并且已经将其投入生产或者至少是常规用户。我们将详细讨论容器的工作原理以及 Docker 安全性、Docker 网络、Docker 插件、可交换运行时以及其他高级配置的一些方面。

本章的一部分涵盖了您可以对 Docker 安装进行的可配置更改。这些可能很有用,但 Docker 有良好的默认设置,所以像大多数软件一样,除非您有足够的理由更改它们并且已经了解这些更改对您意味着什么,否则应该坚持使用操作系统的默认设置。为了使您的环境中的安装正确,可能需要一些试验、调整和随时间的调整。然而,在充分理解它们之前修改默认设置并不建议。

容器详解

尽管我们通常将 Linux 容器称为一个单一实体,它们实际上是通过几个内建于 Linux 内核中的单独机制实现的,这些机制共同工作:控制组(cgroups)、命名空间、安全计算模式(seccomp)以及 SELinux 或 AppArmor,它们都用于限制进程。cgroups 提供资源限制,命名空间允许进程使用相同命名的资源并将它们从系统其他部分的视图中隔离出来,安全计算模式限制进程可以使用哪些系统调用,而 SELinux 或 AppArmor 则为进程提供了额外的强安全隔离。那么,首先,cgroups 和命名空间为您做了什么?

在我们进入详细内容之前,类比可能有助于您理解这些子系统如何影响容器工作方式。想象一下,典型的计算机就像是一个大型的开放仓库,充满了工人(进程)。仓库充满了空间和资源,但工人很容易互相干扰,大多数资源只是由先得到它们的人使用。

当您运行 Docker 并使用 Linux 容器来处理工作负载时,就像那个仓库已经被转换成一个办公楼,每个工人现在都有自己的独立办公室。每个办公室都有工人完成工作所需的所有正常事物,总体上,他们现在可以在不太担心其他人(进程)正在做什么的情况下工作。

命名空间构成了办公室的隔离墙,并确保进程不能以任何未经特别允许的方式与相邻进程交互。控制组有点像支付租金以获取公共设施。当进程首次启动时,它被分配在 CPU 和存储子系统上的时间,每个周期都会被允许使用,在任何时刻都可以使用的内存量。这有助于确保工作者(进程)拥有所需的资源,而不允许它们使用为他人保留的资源或空间。想象一下最糟糕的吵闹邻居,你会突然真正欣赏到良好而坚固的办公室隔离。最后,安全计算模式、SELinux 和 AppArmor 有点像办公室安全,确保即使发生意外或不良事件,也不太可能引起更多麻烦,只是填写文书工作和提交事故报告的头痛。

cgroups

传统的分布式系统设计要求将每个密集型任务分配到自己的虚拟服务器上运行。例如,您不会在数据库服务器上运行应用程序,因为它们具有竞争的资源需求,它们的资源使用可能会无限增长,并开始主导服务器,从而使数据库的性能受到影响。

在实际硬件系统上,这可能非常昂贵,因此像虚拟服务器这样的解决方案非常诱人,部分原因是您可以在竞争应用程序之间共享昂贵的硬件,而虚拟化层将处理您的资源分区。但是虽然可以节省成本,如果不需要虚拟化提供的所有其他隔离,这仍然是一种相当昂贵的方法,因为运行多个内核会对应用程序产生合理的额外开销。维护 VM 也不是最便宜的解决方案。尽管如此,云计算已经证明了它的强大性,并且在正确的工具支持下非常有效。

但是,如果您需要的唯一隔离方式是资源分区,那么如果您可以在同一内核上获得这些功能而无需运行另一个操作系统实例,那不是很棒吗?多年来,您可以为进程分配“亲和性”值,这将为调度程序提供有关您希望如何处理此进程与其他进程关系的提示。但是,不可能像使用 VM 那样强制实施硬限制。而且亲和性并不是非常精细:您不能使某些进程在 I/O 方面比其他进程更多,而在 CPU 方面更少。当然,这种精细控制是 Linux 容器的承诺之一,它们用来提供此功能的机制是 cgroups,早在 Docker 出现之前就已经发明,目的就是解决这个问题。

控制组 允许您为进程及其子进程设置资源限制。这是 Linux 内核用于控制内存、交换、CPU、存储和网络 I/O 资源限制的机制。cgroups 已经内建于内核中,并最早在 Linux 2.6.24 中发布于 2007 年。官方的 内核文档 将其定义为“一种按层次结构组织进程并以受控和可配置的方式分配系统资源的机制。” 需要注意的是,这个设置适用于一个进程及其所有的子进程。这正是容器的结构所在。

注意

值得一提的是,Linux 控制组至少已经有两个主要版本发布:v1v2。确保您知道正在生产中使用的版本,以便充分利用其提供的所有功能。

每个 Linux 容器都被分配了一个唯一的 cgroup。容器中的所有进程将位于同一组中。这意味着可以轻松地为每个容器作为整体控制资源,而无需担心其中可能运行的内容。如果重新部署一个容器并添加了新进程,您可以让 Docker 分配相同的策略,并且它将适用于整个容器及其内部的所有进程容器。

我们之前谈到了 Docker 通过其 API 公开的 cgroups 钩子。该接口允许您控制内存、交换和磁盘使用情况。但是,还有许多其他可以使用 cgroups 管理的东西,包括标记容器的网络数据包,以便您可以使用这些标记来优先处理流量。您可能会发现,在您的环境中,您需要使用这些杠杆来控制您的容器,有几种方法可以做到这一点。由于 cgroups 的本质,它们需要对每个组使用的资源进行大量的资源核算。这意味着当您使用它们时,内核有关于每个进程使用多少 CPU、RAM、磁盘 I/O 等方面的有趣统计数据。因此,Docker 不仅使用 cgroups 限制资源,还用于报告这些资源。例如,这些指标中的许多是您在 docker container stats 输出中看到的。

文件系统 /sys

控制 cgroups 的主要方式是以精细的方式控制,即使您已经通过 Docker 进行了配置,也可以自行管理它们。这是最强大的方法,因为更改不仅发生在容器创建时,还可以在运行时进行。

在带有systemd的系统上,有像systemctl这样的命令行工具可以用来执行此操作。但由于 cgroups 内建于内核中,适用于所有地方的方法是通过/sys文件系统直接与内核交互。如果您对/sys不熟悉,它是一个直接公开几个内核设置和输出的文件系统。您可以使用它来告诉内核您希望它如何行动的简单命令行工具。

仅在 Docker 服务器上直接使用此方法配置容器的 cgroups 控制才有效,因此不通过任何 API 远程使用。如果您使用此方法,您需要找出如何为您的环境编写脚本。

警告

在任何 Docker 配置之外自行更改 cgroups 值会破坏 Docker 部署的某些可重复性。除非您在部署过程中实施更改,否则容器替换时设置将恢复为其默认值。一些调度程序会为您处理这些事务,因此如果您在生产中运行一个调度程序,您可能需要查看文档以了解如何最好地重复应用这些更改。

让我们使用一个例子来改变我们刚刚启动的容器的 CPU cgroups 设置。我们需要获取容器的长 ID,然后我们需要在/sys文件系统中找到它。这是它的样子:

$ docker container run -d spkane/train-os \
  stress -v --cpu 2 --io 1 --vm 2 --vm-bytes 128M --timeout 360s

dcbb…8e86f1dc0a91e7675d3c93895cb6a6d83371e25b7f0bd62803ed8e86

在这里,我们已经让docker container run在输出中给我们长 ID,并且我们想要的 ID 是dcbb…8e86f1dc0a91e7675d3c93895cb6a6d83371e25b7f0bd62803ed8e86。你可以看到为什么 Docker 通常会截断这个 ID。

注意

在示例中,我们可能需要截断 ID 以使其适应标准页面的约束条件。但请记住,你需要使用长 ID!

现在我们有了 ID,我们可以在/sys文件系统中找到我们容器的 cgroup。/sys布局使得每种类型的设置都被分组到一个模块中,而该模块可能在/sys文件系统中的不同位置暴露。因此,当我们查看 CPU 设置时,例如我们不会看到blkio设置。你可以在/sys周围看看还有什么。但现在我们对 CPU 控制器感兴趣,所以让我们检查一下这给我们带来了什么。你需要在系统上有root权限来执行此操作,因为你正在操作内核设置。

提示

记住我们最初在第三章中讨论的nsenter技巧。你可以运行docker container run --rm -it --privileged --pid=host debian nsenter -t 1 -m -u -n -i sh来访问 Docker 主机,即使无法通过 SSH 连接到服务器。

$ ls /sys/fs/cgroup/docker/dcbb…8e86

cgroup.controllers        cpuset.cpus.partition     memory.high
cgroup.events             cpuset.mems               memory.low
cgroup.freeze             cpuset.mems.effective     memory.max
cgroup.max.depth          hugetlb.2MB.current       memory.min
cgroup.max.descendants    hugetlb.2MB.events        memory.oom.group
cgroup.procs              hugetlb.2MB.events.local  memory.stat
cgroup.stat               hugetlb.2MB.max           memory.swap.current
cgroup.subtree_control    hugetlb.2MB.rsvd.current  memory.swap.events
cgroup.threads            hugetlb.2MB.rsvd.max      memory.swap.high
cgroup.type               io.bfq.weight             memory.swap.max
cpu.max                   io.latency                pids.current
cpu.stat                  io.max                    pids.events
cpu.weight                io.stat                   pids.max
cpu.weight.nice           memory.current            rdma.current
cpuset.cpus               memory.events             rdma.max
cpuset.cpus.effective     memory.events.local
注意

此处的确切路径可能会有所变化,这取决于您的 Docker 服务器运行的 Linux 发行版以及您的容器的哈希值。

你可以看到,在 cgroups 下,有一个包含此主机上所有正在运行的 Linux 容器的 docker 目录。你不能为未运行的东西设置 cgroups,因为它们只适用于正在运行的进程。这是一个你应该考虑的重要点。Docker 在启动和停止容器时会重新应用 cgroup 设置。没有这种机制,你会有些自己摸索。

让我们来检查一下这个容器的 CPU 权重。请记住,我们在 第五章 中通过 docker container run--cpus 命令行参数探索了设置一些 CPU 值。但对于一个没有传递任何设置的普通容器,这个设置是默认的:

$ cat /sys/fs/cgroup/docker/dcbb…8e86/cpu.weight
100

100 CPU 权重意味着我们完全没有限制。让我们告诉内核,这个容器应该被限制在这一半:

$ echo 50 > /sys/fs/cgroup/docker/dcbb…8e86/cpu.weight
$ cat /sys/fs/cgroup/docker/dcbb…8e86/cpu.weight
50
警告

在生产环境中,不应该使用此方法动态调整 cgroups,但我们在这里演示它,以便你了解使所有这些工作的底层机制。如果你想要在运行中的容器上调整这些设置,请查看 docker container update。你可能还会发现 docker container run--cgroup-parent 选项很有趣。

就是这样。我们已经在运行中动态更改了容器的设置。这种方法非常强大,因为它允许你为容器设置任何 cgroups 设置。但正如我们之前提到的,它完全是暂时的。当容器停止并重新启动时,设置会恢复为默认值:

$ docker container stop dcbb…8e86
dcbb…8e86

$ cat /sys/fs/cgroup/docker/dcbb…8e86/cpu.weight
cat: /sys/fs/…/cpu.weight: No such file or directory

你可以看到,由于容器已经停止,目录路径甚至不复存在。当我们重新启动它时,目录会回来,但设置会回到 100

$ docker container start dcbb…8e86
dcbb…8e86

$ cat /sys/fs/cgroup/docker/dcbb…8e86/cpu.weight
100

如果你要直接通过 /sys 文件系统在生产系统中更改这些设置,你需要直接管理它。例如,可以通过监听 docker system events 流并在容器启动时更改设置的守护进程。

注意

可以在 Docker 外部创建自定义的 cgroups,然后使用 docker container create--cgroup-parent 参数将新容器附加到该 cgroup。这种机制也被调度程序用于在同一个 cgroup 中运行多个容器(例如 Kubernetes pods)。

命名空间

在每个容器内部,你会看到一个文件系统、网络接口、磁盘和其他资源,尽管与系统上所有其他进程共享内核,但它们都看起来是唯一的容器。例如,实际机器上的主要网络接口是一个共享资源。但在你的容器内部,它看起来像是拥有整个网络接口。这是一个非常有用的抽象:它使你的容器感觉像是一个独立的机器。这是通过 Linux 命名空间在内核中实现的。命名空间将传统上的全局资源提供给容器,使其拥有自己独特且不共享的版本。

注意

命名空间不能像 cgroups 那样在文件系统上轻松探索,但大多数细节可以在 /proc//ns/** 和 /proc//task/*/ns/** 层次结构下找到。在较新的 Linux 发行版中,lsns 命令也可能非常有用。

然而,默认情况下,容器不仅仅有一个命名空间,而是在内核中当前命名空间的每个资源上都有一个命名空间:挂载、UTS、IPC、PID、网络和用户命名空间,还有部分实现的时间命名空间。基本上,当你谈论一个容器时,你在谈论 Docker 为你设置的几个不同的命名空间。那么它们都做什么呢?

挂载命名空间

Linux 主要用这些来使你的容器看起来像拥有自己的整个文件系统。如果你曾经使用过 chroot 狱,那么这是其更强大的相对。它看起来很像 chroot 狱,但是它深入到内核的最深层,甚至 mountunmount 系统调用也是命名空间的。如果你使用 docker container execnsenter 来进入容器,你会看到一个以 / 根目录为根的文件系统。但我们知道这并不是系统的实际根分区。是挂载命名空间使这一切成为可能。

UTS 命名空间

以其命名空间命名的内核结构,UTS(Unix Time Sharing System)命名空间为你的容器提供了自己的主机名和域名。这也被旧系统如 NIS 使用来确定主机属于哪个域。当你进入一个容器并看到一个与其运行的机器不同的主机名时,正是这个命名空间让这种情况发生。

提示

要使容器使用其主机的 UTS 命名空间,可以在使用 docker container run 启动容器时指定 --uts=host 选项。其他命名空间也有类似的命令用于共享。

IPC 命名空间

这些将你的容器的 System V IPC 和 POSIX 消息队列系统与主机的隔离开来。一些 IPC 机制使用像命名管道这样的文件系统资源,这些资源由挂载命名空间覆盖。IPC 命名空间涵盖的是诸如共享内存和信号量这样的不是文件系统资源但不应越过容器墙的东西。

PID 命名空间

我们已经展示了您可以在主机 Linux 服务器上通过 Linux 的 ps 输出看到容器中的所有进程。但在容器内部,进程具有不同的 PID。这是 PID 命名空间的作用。进程在每个命名空间中都有一个唯一的 PID。如果在容器内部查看 /proc 或运行 ps,您只会看到容器 PID 命名空间内的进程。

网络命名空间

这就是使您的容器拥有自己的网络设备、端口等的原因。当您运行 docker container ls 并查看容器的绑定端口时,您会看到来自两个命名空间的端口。在容器内部,您的 nginx 可能绑定到端口 80,但这是在命名空间网络接口上。这个命名空间使得容器的网络堆栈看起来是完全独立的成为可能。

用户命名空间

这些提供了在容器内部用户和组 ID 与 Linux 主机上的用户和组 ID 之间的隔离。早些时候,当我们在容器外部和容器内部分别查看 ps 输出时,我们看到了不同的用户 ID;这就是它发生的方式。容器内的新用户不是 Linux 主机主命名空间上的新用户,反之亦然。不过,这里有一些微妙之处。例如,在用户命名空间中,UID 0(root)并不等同于主机上的 UID 0,尽管在容器内作为 root 运行会增加潜在的安全漏洞风险。关于安全泄露的问题我们稍后会讨论,这也是像无根容器之类的东西变得越来越受欢迎的原因。

控制组命名空间

此命名空间于 2016 年的 Linux 内核 4.6 中引入,旨在隐藏进程所属的控制组的身份。检查任何进程属于哪个控制组时,会看到一个相对于创建时设置的控制组路径,隐藏了其真实的控制组位置和身份。

时间命名空间

由于时间对 Linux 内核如此重要,因此历史上它并没有被命名空间化,提供完整的命名空间化将非常复杂。然而,随着 2020 年 Linux 内核 5.6 的发布,支持添加了一个时间命名空间,允许容器具有自己独特的时钟偏移。

注意

在撰写本文时,Docker 仍然不直接支持设置时间偏移,但像其他所有内容一样,如果需要,可以直接设置。

因此,通过结合所有这些命名空间,Linux 可以提供视觉上的以及在许多情况下的功能隔离,使得容器看起来像是在同一个内核上运行的虚拟机。让我们更详细地探讨刚刚描述的一些命名空间是什么样子。

注意

目前有大量的工作致力于使容器更加安全。社区正在积极寻找改进支持无根容器(允许普通用户在本地创建、运行和管理容器而无需特殊权限)的方法。在 Docker 中,可以通过无根模式实现。新的容器运行时如Google gVisor也在探索更安全地创建容器沙盒的方式,同时保留容器化工作流程的大部分优势。

探索命名空间

最容易演示的命名空间之一是 UTS,所以让我们使用 docker container exec 进入容器并查看一下。在 Docker 服务器内运行以下命令:

$ hostname

docker-desktop
提示

同样要记住,即使无法通过 SSH 连接到服务器,你仍然可以使用我们在第三章讨论过的命令 docker container run --rm -it --privileged --pid=host debian nsenter -t 1 -m -u -n -i sh 来访问 Docker 主机。

然后在你的本地系统上运行以下命令:

$ docker container run -ti --rm ubuntu \
    bash -c 'echo "Container hostname: $(hostname)"'

Container hostname: 4cdb66d4495b

那个 docker container run 命令行让我们进入一个交互式会话(-ti),然后通过 /bin/bash 在容器内执行 hostname 命令。由于 hostname 命令在容器的命名空间内运行,我们会得到默认的短容器 ID 作为主机名。这是一个相当简单的例子,但它应该清楚地表明我们不在与主机相同的命名空间中。

另一个易于理解和演示的例子涉及 PID 命名空间。让我们创建一个新的容器:

$ docker container run -d --rm --name pstest spkane/train-os sleep 240
6e005f895e259ed03c4386b5aeb03e0a50368cc173078007b6d1beaa8cd7dded

$ docker container exec -ti pstest ps -ef

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 15:33 ?        00:00:00 sleep 240
root        13     0  0 15:33 pts/0    00:00:00 ps -ef

现在让 Docker 显示我们从主机视角看到的进程 ID:

$ docker container top pstest

UID   PID    PPID   C  STIME  TTY  TIME      CMD
root  31396  31370  0  15:33 ?     00:00:00  sleep 240

我们可以看到,在我们的容器内部,由 Docker 启动的原始命令是 sleep 240,并且它在容器内被分配了 PID 1。你可能还记得,在 Unix 系统上,这是 init 进程通常使用的 PID。在这种情况下,我们用来启动容器的 sleep 240 命令是第一个进程,所以它获得了 PID 1。但在 Docker 服务器的主命名空间中,我们可以看到那里的 PID 不是 1,而是 31396,并且它是进程 ID 31370 的子进程。

如果你感兴趣,你可以运行像这样的命令来确定 PID 31370 是什么:

$ docker container run --pid=host ubuntu ps -p 31370
PID    TTY  TIME      CMD
31370  ?    00:00:00  containerd-shim

现在我们可以继续通过运行以下命令来删除上一个示例中启动的容器:

 $ docker container rm -f pstest

其他命名空间基本上以相同的方式工作,到这里你可能已经理解了。值得指出的是,当我们首次在第三章讨论使用 nsenter 时,我们在运行它以从 Docker 服务器进入容器时,不得不传递一些看起来相当深奥的参数。让我们继续看看命令 docker container run --rm -it --privileged --pid=host debian nsenter -t 1 -m -u -n -i sh 中的 nsenter 部分。

结果发现,nsenter -t 1 -m -u -n -i shnsenter --target 1 --mount --uts --net -ipc sh完全相同。因此,这个命令实际上是说,查看 PID 为1的进程,然后在该进程的mountutsnetipc命名空间中打开一个 shell。

现在我们已经详细解释了命名空间,这可能对您来说更加清晰了。使用nsenter尝试进入一次性容器中的不同命名空间集合来查看您可以得到什么,简单地探索更多细节也可能会增长您的见识。

当谈到容器时,命名空间是使容器看起来像容器的主要因素。结合控制组,您可以在同一内核上实现相对强大的进程隔离。

安全性

现在我们花了不少篇幅讨论 Docker 如何为应用程序提供容器化,允许您限制资源利用,并使用命名空间为容器提供独特的视角。我们还简要提到了像安全计算模式、SELinux 和 AppArmor 这样的技术的必要性。容器的一个优势是能够在多种用例中替代虚拟机。因此,让我们看看我们默认获得了哪些隔离以及哪些没有。

您现在无疑已经意识到,容器提供的隔离不如虚拟机强大。从本书开始我们一直强调容器只是在 Linux 服务器上运行的进程。尽管命名空间提供了隔离,但容器的安全性并不如您想象的那么高,特别是如果您仍然在精神上将它们与轻量级虚拟机进行比较的话。

对于容器的性能显著提升之一,以及使其轻量化的因素之一,是它们共享 Linux 服务器的内核。这也是围绕 Linux 容器最大的安全关注点。这种关注的主要原因在于内核中并非所有内容都有命名空间。我们已经讨论了所有存在的命名空间及容器对世界视角的限制是如何运作的。然而,内核中仍有许多地方没有真正的隔离,而命名空间只有在容器没有权限要求内核让其访问不同命名空间时才对其进行约束。

容器化应用比非容器化应用更安全,因为控制组(cgroups)和标准命名空间(namespaces)提供了对主机核心资源的重要隔离。但是,你不应认为容器可以替代良好的安全实践。如果你考虑如何在生产系统上运行应用程序,那么你在所有容器中的运行方式应该与此相同。如果你的应用程序在服务器上通常以非特权用户身份运行,那么它在容器内部也应以相同方式运行。告诉 Docker 以非特权用户身份运行容器进程非常简单,在几乎所有情况下,这都是你应该做的。

提示

--userns-remap参数传递给dockerd命令以及无根模式都使得所有容器都可以在主机系统上非特权的用户和组上下文中运行。这些方法有助于保护主机免受许多潜在的安全漏洞的影响。

有关userns-remap的更多信息,请阅读官方功能Docker 守护程序文档。

您可以在“无根模式”部分了解更多关于无根模式的信息。

让我们看一些常见的安全风险和控制措施。

UID 0

容器中的第一个和最普遍的安全风险是,除非你使用了无根模式(rootless mode)或者 Docker 守护程序中的userns-remap功能,否则容器中的root用户实际上是系统中的root用户。在容器中,root用户有额外的约束条件,并且命名空间可以很好地将容器中的root/proc/sys文件系统中最危险的部分隔离开来。但是,如果你的 UID 是 0,你就有root访问权限,因此如果你以某种方式访问了文件挂载或者命名空间外的受保护资源,那么内核将把你视为root并且允许你访问该资源。除非另有配置,Docker 会以root用户身份启动所有容器中的服务,这意味着你需要像在任何标准 Linux 系统上一样管理应用程序的权限。让我们探讨一些root访问权限的限制,并看看一些明显的漏洞。这并不打算详尽阐述容器安全性的声明,而是试图让你对某些安全风险类别有一个健康的理解。

首先,让我们启动一个容器,并使用下面代码中显示的公共 Ubuntu 镜像获取一个bash shell。然后,我们将看看我们在安装了一些我们想要运行的工具之后拥有哪些访问权限:

$ docker container run --rm -ti ubuntu /bin/bash

root@808a2b8426d1:/# apt-get update
…
root@808a2b8426d1:/# apt-get install -y kmod
…
root@808a2b8426d1:/# lsmod
Module                             Size  Used by
xfrm_user                         36864  1
xfrm_algo                         16384  1 xfrm_user
shiftfs                           28672  0
grpcfuse                          16384  0
vmw_vsock_virtio_transport        16384  2
vmw_vsock_virtio_transport_common 28672  1 vmw_vsock_virtio_transport
vsock                             36864  9 vmw_vsock_virtio_transport_common…

在 Docker Desktop 中,你可能只能看到列表中的几个模块,但在普通的 Linux 系统上,这个列表可能非常长。使用lsmod,我们刚刚要求内核告诉我们加载了哪些模块。从容器内获取这个列表并不奇怪,因为普通用户总是可以这样做。如果你在 Docker 服务器本身上运行此列表,结果将是相同的,这加强了容器正在与服务器上运行的相同 Linux 内核交互的事实。因此,我们可以看到内核模块;如果我们尝试卸载floppy模块会发生什么?

root@808a2b8426d1:/# rmmod shiftfs

rmmod: ERROR: ../libkmod/libkmod-module.c:799 kmod_module_remove_module() …
rmmod: ERROR: could not remove module shiftfs: Operation not permitted

root@808a2b8426d1:/# exit

如果我们是非特权用户尝试告诉内核删除一个模块,我们将会得到相同的错误消息。这应该让你明白内核正在尽最大努力阻止我们做不应该做的事情。因为我们在有限的命名空间中,我们不能让内核让我们访问顶级命名空间。我们基本上是依赖于这样一个希望:内核中没有允许我们在容器内提升特权的漏洞。因为如果我们成功做到了,我们就是root,这意味着如果内核允许的话,我们将能够进行更改。

我们可以通过在容器中启动一个bash shell 来制造一个简单的错误示例,该容器已将 Docker 服务器的/etc绑定到容器的命名空间中。请记住,任何可以在你的 Docker 服务器上启动容器的人都可以随时像我们即将做的事情一样做,因为你无法配置 Docker 来阻止这种操作,所以你必须依赖像 SELinux 这样的外部工具来避免这样的漏洞利用。

注意

此示例假定你在运行docker CLI 的 Linux 系统上,该系统有/etc/shadow文件。在运行 Docker Desktop 等 Windows 或 macOS 主机上,此文件将不存在。

$ docker container run --rm -it -v /etc:/host_etc ubuntu /bin/bash

root@e674eb96bb74:/# more /host_etc/shadow
root:!:16230:0:99999:7:::
daemon:*:16230:0:99999:7:::
bin:*:16230:0:99999:7:::
sys:*:16230:0:99999:7:::
…
irc:*:16230:0:99999:7:::
nobody:*:16230:0:99999:7:::
libuuid:!:16230:0:99999:7:::
syslog:*:16230:0:99999:7:::
messagebus:*:16230:0:99999:7:::
kmatthias:$1$aTAYQT.j$3xamPL3dHGow4ITBdRh1:16230:0:99999:7:::
sshd:*:16230:0:99999:7:::
lxc-dnsmasq:!:16458:0:99999:7:::

root@e674eb96bb74:/# exit

在这里,我们使用了-v开关告诉 Docker 将主机路径挂载到容器中。我们选择的路径是/etc,这是一件非常危险的事情。但这证明了一个观点:我们在容器中是root,而root在此路径下具有文件权限。因此,我们可以查看 Linux 服务器上的/etc/shadow文件,其中包含所有用户的加密密码。这里还有许多其他操作,但关键是,默认情况下你只受到部分限制。

警告

使用 UID 0 来运行你的容器进程是一个不好的主意。这是因为任何允许进程以某种方式逃离其命名空间的漏洞都会使你的主机系统暴露给完全特权的进程。你应该始终以非特权 UID 运行标准容器。

处理在容器内使用 UID 0 可能带来的潜在问题最简单的方法是始终告诉 Docker 为你的容器使用不同的 UID。

您可以通过传递 -u 参数给 docker container run 来做到这一点。在下一个示例中,我们运行 whoami 命令以显示默认情况下我们是 root,并且我们可以读取此容器内部的 /etc/shadow 文件:

$ docker container run --rm spkane/train-os:latest whoami
root

$ docker container run --rm spkane/train-os:latest cat /etc/shadow
root:!locked::0:99999:7:::
bin:*:18656:0:99999:7:::
daemon:*:18656:0:99999:7:::
adm:*:18656:0:99999:7:::
lp:*:18656:0:99999:7:::
…

在这个例子中,当您添加 -u 500 时,您会看到我们成为了一个新的非特权用户,不能再读取相同的 /etc/shadow 文件:

$ docker container run --rm -u 500 spkane/train-os:latest whoami
user500

$ docker container run --rm -u 500 spkane/train-os:latest cat /etc/shadow
cat: /etc/shadow: Permission denied

另一个强烈推荐的方法是在您的 Dockerfile 中添加 USER 指令,以便从它们创建的容器将默认使用非特权用户启动:

FROM fedora:34
RUN useradd -u 500 -m myuser
USER 500:500
CMD ["whoami"]

如果您创建了这个 Dockerfile,然后构建并运行它,您将看到 whoami 返回的是 myuser 而不是 root

$ docker image build -t user-test .

[+] Building 0.5s (6/6) FINISHED
 => [internal] load build definition from Dockerfile                      0.0s
 => => transferring dockerfile: 36B                                       0.0s
 => [internal] load .dockerignore                                         0.0s
 => => transferring context: 2B                                           0.0s
 => [internal] load metadata for docker.io/library/fedora:34              0.4s
 => [1/2] FROM docker.io/library/fedora:34@sha256:321d…2697               0.0s
 => CACHED [2/2] RUN useradd -u 500 -m myuser                             0.0s
 => exporting to image                                                    0.0s
 => => exporting layers                                                   0.0s
 => => writing image sha256:4727…30d5                                     0.0s
 => => naming to docker.io/library/user-test                              0.0s

$ docker container run --rm user-test
myuser

无根模式

容器的一个主要安全挑战是,它们通常需要一些特权进程来启动和管理。即使您使用 Docker 守护程序的 --userns-remap 功能,守护程序本身仍然作为特权进程运行,尽管它启动的容器不会。

使用 无根模式,可以在不需要 root 权限的情况下运行守护程序和所有容器,这可以大大提高底层系统的安全性。

Rootless 模式需要一个 Linux 系统,Docker 推荐使用 Ubuntu,因此让我们通过一个新的 Ubuntu 22.04 系统的示例来运行。

注意

这些步骤假定您正在以普通非特权用户登录,并且您已经安装了 Docker Engine

我们首先需要确保安装了 dbus-user-sessionuidmap。如果尚未安装 dbus-user-session,则在运行以下命令后需要注销并重新登录:

$ sudo apt-get install -y dbus-user-session uidmap
…
dbus-user-session is already the newest version (1.12.20-2ubuntu4).
…
Setting up uidmap (1:4.8.1-2ubuntu2) …
…

尽管不是必须的,但如果系统范围内设置了 Docker 守护程序来运行,最好是将其禁用,然后重新启动:

$ sudo systemctl disable --now docker.service docker.socket

Synchronizing state of docker.service with SysV service script with
 /lib/systemd/systemd-sysv-install.
Executing: /lib/systemd/systemd-sysv-install disable docker
Removed /etc/systemd/system/sockets.target.wants/docker.socket.
Removed /etc/systemd/system/multi-user.target.wants/docker.service.

$ sudo shutdown -r now

系统恢复后,您可以作为普通用户 SSH 回到服务器,并确认 /var/run/docker.sock 不再存在于系统上:

$ ls /var/run/docker.sock
ls: cannot access '/var/run/docker.sock': No such file or directory

下一步是运行无根模式安装脚本,该脚本由 Docker 安装程序安装在 /usr/bin 中:

$ dockerd-rootless-setuptool.sh install

[INFO] Creating /home/me/.config/systemd/user/docker.service
[INFO] starting systemd service docker.service
+ systemctl --user start docker.service
+ sleep 3
+ systemctl --user --no-pager --full status docker.service
● docker.service - Docker Application Container Engine (Rootless)
 Loaded: loaded (/home/me/.config/systemd/user/docker.service; …)
…
+ DOCKER_HOST=unix:///run/user/1000/docker.sock /usr/bin/docker version
Client: Docker Engine - Community
 Version:           20.10.18
…
Server: Docker Engine - Community
 Engine:
 Version:          20.10.18
…
+ systemctl --user enable docker.service
Created symlink /home/me/.config/systemd/user/default.target.wants/
 docker.service → /home/me/.config/systemd/user/docker.service.
[INFO] Installed docker.service successfully.

[INFO] To control docker.service, run:
 `systemctl --user (start|stop|restart) docker.service`
[INFO] To run docker.service on system startup, run:
 `sudo loginctl enable-linger me`

[INFO] Creating CLI context "rootless"
Successfully created context "rootless"

[INFO] Make sure the following environment variables are set
 (or add them to ~/.bashrc):
export PATH=/usr/bin:$PATH
export DOCKER_HOST=unix:///run/user/1000/docker.sock
注意

在这里 DOCKER_HOST 变量中的 UID` 应该与运行脚本的用户的 UID 匹配。在这种情况下,UID1000

此脚本运行了一些检查以确保我们的系统已准备就绪,然后安装并启动了一个用户范围的 systemd 服务文件到 ${HOME}/.config/systemd/user/docker.service。系统上的每个用户都可以根据需要执行相同的操作。

用户 Docker 守护程序可以像大多数 systemd 服务一样进行控制。这里展示了一些基本示例:

$ systemctl --user restart docker.service
$ systemctl --user stop docker.service
$ systemctl --user start docker.service

要允许用户 Docker 守护程序在用户未登录时运行,用户需要使用 sudo 来启用 systemd 的一个名为 linger 的功能,然后还可以使 Docker 守护程序在系统启动时启动:

$ sudo loginctl enable-linger $(whoami)
$ systemctl --user enable docker

现在是时候继续将这些环境变量添加到我们的 shell 启动文件中了,但至少我们需要确保这两个环境变量在我们当前的终端中设置:

$ export PATH=/usr/bin:$PATH
$ export DOCKER_HOST=unix:///run/user/1000/docker.sock

我们可以轻松运行一个标准容器:

$ docker container run --rm hello-world

Hello from Docker!
This message shows that your installation appears to be working correctly.
…
For more examples and ideas, visit:
 https://docs.docker.com/get-started/

然而,您会注意到,在较早的章节中使用的某些更高特权的容器在这种环境下无法工作:

$ docker container run --rm -it --privileged --pid=host debian nsenter \
    -t 1 -m -u -n -i sh

docker: Error response from daemon: failed to create shim task: OCI runtime
create failed: runc create failed: unable to start container process: error
during container init: error mounting "proc" to rootfs at "/proc":
mount proc:/proc (via /proc/self/fd/7), flags: 0xe:
operation not permitted: unknown.

这是因为,在无根模式下,容器不能比运行容器的用户拥有更多的特权,即使在表面上,容器似乎仍然具有完整的root特权:

$ docker container run --rm spkane/train-os:latest whoami
root

让我们再深入探讨一下,通过启动一个运行sleep 480s的小型容器:

$ docker container run -d --rm --name sleep spkane/train-os:latest sleep 480s
1f8ccec0a834537da20c6e07423f9217efe34c0eac94f0b0e178fb97612341ef

如果我们查看容器内部的进程,我们会看到它们似乎都是以用户root运行的:

$ docker container exec sleep ps auxwww
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.1  0.0   2400   824 ?        Ss   17:51   0:00 sleep 480s
root           7  0.0  0.0   7780  3316 ?        Rs   17:51   0:00 ps auxwww

但是,如果我们查看 Linux 系统上的进程,我们会发现sleep命令实际上是由本地用户me而不是root来运行的:

$ ps auxwww | grep sleep
me   3509 0.0 0.0  2400  824 ?     Ss 10:51 0:00 sleep 480s
me   3569 0.0 0.0 17732 2360 pts/0 S+ 10:51 0:00 grep --color=auto sleep

在无根容器内部的root用户实际上映射到用户本身。容器进程无法使用守护程序运行用户尚未具有的任何特权,因此,这是允许多用户系统上的用户运行容器的非常安全方式,而无需在系统上授予他们任何提升的特权。

小贴士

有关在 Docker 网站上卸载无根模式的说明。

特权容器

有时您需要容器具有特殊的内核功能,通常容器将被拒绝。这些功能可能包括挂载 USB 驱动器、修改网络配置或创建新的 Unix 设备。

在下面的代码中,我们尝试更改容器的 MAC 地址:

$ docker container run --rm -ti spkane/train-os /bin/bash

[root@280d4dc16407 /]# ip link ls
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode …
 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN mode DEFAULT …
 link/ipip 0.0.0.0 brd 0.0.0.0
3: ip6tnl0@NONE: <NOARP> mtu 1452 qdisc noop state DOWN mode DEFAULT …
 link/tunnel6 :: brd :: permaddr 12b5:6f1b:a7e9::
22: eth0@if23: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue …
 link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0

[root@fc4589fb8778 /]# ip link set eth0 address 02:0a:03:0b:04:0c
RTNETLINK answers: Operation not permitted

[root@280d4dc16407 /]# exit

正如您所见,这是行不通的。这是因为底层的 Linux 内核阻止非特权容器执行此操作,这正是我们通常希望的。但是,假设我们需要此功能使容器按预期工作,通过使用--privileged=true参数启动容器是显著扩展容器特权的最简单方法。

警告

我们不建议在下一个示例中运行ip link set eth0 address命令,因为这会更改容器网络接口的 MAC 地址。我们展示它是为了帮助你理解机制。请自行承担风险。

$ docker container run -ti --rm --privileged=true spkane/train-os /bin/bash

[root@853e0ef5dd63 /]# ip link ls
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode …
 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: tunl0@NONE: <NOARP> mtu 1480 qdisc noop state DOWN mode DEFAULT …
 link/ipip 0.0.0.0 brd 0.0.0.0
3: ip6tnl0@NONE: <NOARP> mtu 1452 qdisc noop state DOWN mode DEFAULT …
 link/tunnel6 :: brd :: permaddr 12b5:6f1b:a7e9::
22: eth0@if23: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue …
 link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0

[root@853e0ef5dd63 /]# ip link set eth0 address 02:0a:03:0b:04:0c

[root@853e0ef5dd63 /]# ip link show eth0
26: eth0@if27: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue …
 link/ether 02:0a:03:0b:04:0c brd ff:ff:ff:ff:ff:ff link-netnsid 0

[root@853e0ef5dd63 /]# exit

在先前的输出中,您会注意到我们不再收到错误,并且eth0link/ether条目已更改。

使用--privileged=true参数的问题在于,您为容器提供了非常广泛的特权,在大多数情况下,您可能只需要一两个内核功能就能完成工作。

如果我们进一步探索我们的特权容器,我们会发现我们拥有一些与更改 MAC 地址无关的能力。我们甚至可以执行可能导致 Docker 和主机系统出现问题的操作。在以下代码中,我们将从底层主机系统挂载一个磁盘分区,列出系统上所有基于 Docker 的 Linux 容器,并探索其中一些关键文件:

$ docker container run -ti --rm --privileged=true spkane/train-os /bin/bash

[root@664a896983d7 /]# mount /dev/vda1 /mnt && \
                         ls -F /mnt/docker/containers | \
                         head -n 10

047df420f6d1f227a26667f83e477f608298c25b0cdad2e149a781587aae5e11/
0888b9f97b1ecc4261f637404e0adcc8ef0c8df291b87c9160426e42dc9b5dea/
174ea3ec35cd3a576bed6f475b477b1a474d897ece15acfc46e61685abb3101d/
1eddad26ee64c4b29eb164b71d56d680739922b3538dc8aa6c6966fce61125b0/
22b2aa38a687f423522dd174fdd85d578eb21c9c8ec154a0f9b8411d08f6fd4b/
23879e3b9cd6a42a1e09dc8e96912ad66e80ec09949c744d1177a911322e7462/
266fe7da627d2e8ec5429140487e984c8d5d36a26bb3cc36a88295e38216e8a7/
2cb6223e115c12ae729d968db0d2f29a934b4724f0c9536e377e0dbd566f1102/
306f00e86122b69eeba9323415532a12f88360a1661f445fc7d64c07249eb0ce/
333b85236409f873d07cd47f62ec1a987df59f688a201df744f40f98b7e4ef2c/

[root@664a896983d7 /]# ls -F /mnt/docker/containers/047d…5e11/

047df420f6d1f227a26667f83e477f608298c25b0cdad2e149a781587aae5e11-json.log
checkpoints/
config.v2.json
hostconfig.json
hostname
hosts
mounts/
resolv.conf
resolv.conf.hash

[root@664a896983d7 /]# cat /mnt/docker/containers/047d…5e11/047…e11-json.log
{"log":"047df420f6d1\r\n","stream":"stdout","time":"2022-09-14T15:18:29.…"}
…
[root@664a896983d7 /]# exit
警告

不要更改或删除这些文件。这可能会对容器或底层 Linux 系统产生不可预测的影响。

因此,正如我们所见,人们可以在完全特权的容器中运行命令并访问不应访问的内容。

要更改 MAC 地址,我们唯一需要的内核能力是CAP_NET_ADMIN。我们可以通过在启动 Linux 容器时使用--cap-add参数,给予容器这个特权,而不是给予它完整的特权集,如下所示:

$ docker container run -ti --rm --cap-add=NET_ADMIN spkane/train-os /bin/bash

[root@087c02a3c6e7 /]# ip link show eth0
36: eth0@if37: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue …
 link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0

[root@087c02a3c6e7 /]# ip link set eth0 address 02:0a:03:0b:04:0c

[root@087c02a3c6e7 /]# ip link show eth0
36: eth0@if37: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue …
 link/ether 02:0a:03:0b:04:0c brd ff:ff:ff:ff:ff:ff link-netnsid 0

[root@087c02a3c6e7 /]# exit

你还应该注意,尽管我们可以更改 MAC 地址,但我们无法在容器内部再使用mount命令:

$ docker container run -ti --rm --cap-add=NET_ADMIN spkane/train-os /bin/bash

[root@b84a06ddaa0d /]# mount /dev/vda1 /mnt
mount: /mnt: permission denied.

[root@b84a06ddaa0d /]# exit

也可以从容器中删除特定的能力。想象一下,你的安全团队要求在所有容器中禁用tcpdump,当你测试一些容器时,你发现tcpdump已安装并且可以轻松运行:

$ docker container run -ti --rm spkane/train-os:latest tcpdump -i eth0

dropped privs to tcpdump
tcpdump: verbose output suppressed, use -v[v]… for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
15:40:49.847446 IP6 fe80::23:6cff:fed6:424f > ff02::16: HBH ICMP6, …
15:40:49.913977 ARP, Request who-has _gateway tell 5614703ffee2, length 28
15:40:49.914048 ARP, Request who-has _gateway tell 5614703ffee2, length 28
15:40:49.914051 ARP, Reply _gateway is-at 02:49:9b:d9:49:4e (oui Unknown), …
15:40:49.914053 IP 5642703bbff2.45432 > 192.168.75.8.domain: 44649+ PTR? …
…

你可以从你的镜像中删除tcpdump,但很少有什么能阻止别人重新安装它。解决这个问题的最有效方法是确定tcpdump运行所需的能力,并从容器中移除。在这种情况下,你可以通过在docker container run命令中添加--cap-drop=NET_RAW来实现:

$ docker container run -ti --rm --cap-drop=NET_RAW spkane/train-os:latest \
  tcpdump -i eth0

tcpdump: eth0: You don't have permission to capture on that device
(socket: Operation not permitted)

通过在docker container run中使用--cap-add--cap-drop参数,你可以精确控制容器的Linux 内核能力

注意

请注意,除了提供系统调用的访问权限之外,启用特定的 Linux 能力实际上还可以提供其他一些功能。这可能包括查看系统上所有设备的可见性或更改系统时间的能力。

安全计算模式

当 Linux 内核版本 2.6.12 在 2005 年发布时,它包含了一个名为安全计算模式(Secure Computing Mode)的新安全特性,简称为seccomp。这个特性使得进程可以单向转换到一个特殊状态,在这种状态下,它只能执行系统调用exit()sigreturn(),以及对已打开文件描述符的read()write()

作为seccomp的扩展,称为seccomp-bpf,利用 Linux 版本的伯克利数据包过滤器(BPF)规则,允许你创建一个策略,明确列出一个进程在安全计算模式下可以使用的系统调用列表。Docker 对安全计算模式的支持使用seccomp-bpf,让用户可以创建非常精细化的配置文件,控制他们的容器化进程可以执行哪些内核系统调用。

注意

默认情况下,所有容器都使用安全计算模式,并且附加了默认配置文件。你可以在文档中了解更多安全计算模式的信息,以及默认配置文件阻止哪些系统调用。你还可以查看默认策略的 JSON 文件来了解策略的具体定义。

要看看如何使用这个功能,让我们使用strace程序来跟踪在尝试使用umount命令卸载文件系统时进程所做的系统调用。

警告

这些示例是为了证明一个观点,但显然你不应该在不确切知道会发生什么的情况下在容器中卸载文件系统。

$ docker container run -ti --rm spkane/train-os:latest umount /sys/fs/cgroup
umount: /sys/fs/cgroup: must be superuser to unmount.

$ docker container run -ti --rm spkane/train-os:latest \
  strace umount /sys/fs/cgroup

execve("/usr/bin/umount", ["umount", "/sys/fs/cgroup"], 0x7fff902ddbe8 …
…
umount2("/sys/fs/cgroup", 0)            = -1 EPERM (Operation not permitted)
write(2, "umount: ", 8umount: )                 = 8
write(2, "/sys/fs/cgroup: must be superuse"…,
 45/sys/fs/cgroup: must be superuser to unmount.) = 45
write(2, "\n", 1
)                       = 1
dup(1)                                  = 3
close(3)                                = 0
dup(2)                                  = 3
close(3)                                = 0
exit_group(32)                          = ?
+++ exited with 32 +++

我们已经知道,在标准权限的容器中,与挂载相关的命令不起作用,strace清楚地表明,在umount命令尝试使用umount2系统调用时,系统返回一个“操作不允许”的错误消息。

你可以通过给容器添加SYS_ADMIN能力来潜在地解决这个问题,如下所示:

$ docker container run -ti --rm --cap-add=SYS_ADMIN spkane/train-os:latest \
    strace umount /sys/fs/cgroup

execve("/usr/bin/umount", ["umount", "/sys/fs/cgroup"], 0x7ffd3e4452b8 …
…
umount2("/sys/fs/cgroup", 0)            = 0
dup(1)                                  = 3
close(3)                                = 0
dup(2)                                  = 3
close(3)                                = 0
exit_group(0)                           = ?
+++ exited with 0 +++

然而,请记住,使用--cap-add=SYS_ADMIN将使我们能够做很多其他事情,包括使用类似这样的命令来挂载系统分区:

$ docker container run -ti --rm --cap-add=SYS_ADMIN spkane/train-os:latest \
  mount /dev/vda1 /mnt

你可以通过使用更加专注的方法,使用一个seccomp配置文件来解决这个问题。与seccomp不同,--cap-add将启用一整套系统调用和一些额外的权限,你几乎肯定不需要它们全部。CAP_SYS_ADMIN特别强大,并提供了比任何一个能力应具有的权限更多。然而,使用seccomp配置文件,你可以非常明确地指定要启用或禁用的系统调用。

如果我们看一下默认的seccomp配置文件,会看到类似这样的内容:

{
    "defaultAction": "SCMP_ACT_ERRNO",
    "defaultErrnoRet": 1,
    "archMap": [
        {
            "architecture": "SCMP_ARCH_X86_64",
            "subArchitectures": [
                "SCMP_ARCH_X86",
                "SCMP_ARCH_X32"
            ]
        },
…
    ],
    "syscalls": [
        {
            "names": [
                "accept",
                "accept4",
                "access",
                "adjtimex",
…
                "waitid",
                "waitpid",
                "write",
                "writev"
            ],
            "action": "SCMP_ACT_ALLOW"
        },
        {
            "names": [
                "bpf",
                "clone",
…
                "umount2",
                "unshare"
            ],
            "action": "SCMP_ACT_ALLOW",
            "includes": {
                "caps": [
                    "CAP_SYS_ADMIN"
                ]
            }
        },
…
    ]
}

这个 JSON 文件提供了一个受支持的体系结构列表、一个默认的规则集和每个能力范围内的系统调用组。在这种情况下,默认操作是SCMP_ACT_ERRNO,如果尝试未指定的调用,将生成错误。

如果你详细检查默认配置文件,你会注意到CAP_SYS_ADMIN控制对 37 个系统调用的访问,这个数字相当巨大,甚至比大多数其他能力中包含的 4-6 个系统调用还要多。

在当前使用案例中,我们实际上需要 CAP_SYS_ADMIN 提供的一些特殊功能,但我们不需要所有这些系统调用。为确保只添加我们需要的一个额外系统调用,我们可以基于 Docker 提供的默认策略创建自己的安全计算模式策略。

首先,拉取默认策略并创建其副本:

$ wget https://raw.githubusercontent.com/moby/moby/master/\
profiles/seccomp/default.json

$ cp default.json umount2.json
注意

URL 已经延续到下一行以适应页面边界。你可能需要重新组装 URL 并移除反斜杠,以使命令在你的环境中正常工作。

然后编辑文件并移除 CAP_SYS_ADMIN 通常提供的一堆系统调用。在这种情况下,我们实际上需要保留两个系统调用以确保 straceumount 正常工作。

我们正在针对文件的这一部分,它以这个 JSON 块结束:

            "includes": {
                "caps": [
                    "CAP_SYS_ADMIN"
                ]
            }

这个 diff 显示了在这个使用案例中需要进行的确切更改:

$ diff -u -U5 default.json umount2.json
diff -u -U5 default.json umount2.json
--- default.json        2022-09-25 13:23:57.000000000 -0700
+++ umount2.json        2022-09-25 13:38:31.000000000 -0700
@@ -575,34 +575,12 @@
                                ]
                        }
                },
                {
                        "names": [
-                               "bpf",
                                "clone",
-                               "clone3",
-                               "fanotify_init",
-                               "fsconfig",
-                               "fsmount",
-                               "fsopen",
-                               "fspick",
-                               "lookup_dcookie",
-                               "mount",
-                               "mount_setattr",
-                               "move_mount",
-                               "name_to_handle_at",
-                               "open_tree",
-                               "perf_event_open",
-                               "quotactl",
-                               "quotactl_fd",
-                               "setdomainname",
-                               "sethostname",
-                               "setns",
-                               "syslog",
-                               "umount",
-                               "umount2",
-                               "unshare"
+                               "umount2"
                        ],
                        "action": "SCMP_ACT_ALLOW",
                        "includes": {
                                "caps": [
                                        "CAP_SYS_ADMIN"

现在,你已经准备好测试你的新调优的 seccomp 配置文件,确保它可以运行 umount 但不能运行 mount

$ docker container run -ti --rm --security-opt seccomp=umount2.json \
  --cap-add=SYS_ADMIN spkane/train-os:latest /bin/bash

[root@15b8a26b6cfe /]# strace umount /sys/fs/cgroup
execve("/usr/bin/umount", ["umount", "/sys/fs/cgroup"], 0x7ffece9ebc38 …
close(3)                                = 0
exit_group(0)                           = ?
+++ exited with 0 +++

[root@15b8a26b6cfe /]# mount /dev/vda1 /mnt
mount: /mnt: permission denied.

[root@15b8a26b6cfe /]# exit

如果一切按计划进行,你对 umount 程序的 strace 应该已经完美运行,而 mount 命令应该已被阻止。在现实世界中,考虑重新设计你的应用程序,以避免需要这些特殊权限会更安全,但在无法避免时,你可以使用这些工具来帮助确保你的容器在尽可能保持安全的同时仍然能够正常工作。

警告

可以通过设置 --security-opt seccomp=unconfined 完全禁用默认的安全计算模式配置;然而,通常来说在未限制的情况下运行容器是一个非常糟糕的主意,并且可能只在你试图确定在配置文件中需要定义哪些系统调用时才有用。

安全计算模式的优势在于它允许用户更加精确地选择容器在底层 Linux 内核上可以做什么和不能做什么。大多数容器不需要自定义配置文件,但在需要精心制作强大容器并确保系统整体安全性时,它们是非常有用的工具。

SELinux 和 AppArmor

早些时候,我们讨论了容器主要利用 cgroups 和命名空间来实现其功能。SELinuxAppArmor 是 Linux 生态系统中的安全层,可进一步增强容器的安全性。在本节中,我们将稍微讨论这两个系统。SELinux 和 AppArmor 允许您应用超出 Unix 系统正常支持的安全控制。SELinux 起源于美国国家安全局,得到了红帽公司的强力支持,并支持非常精细的控制。AppArmor 则是一个旨在实现许多相同目标的努力,比 SELinux 更加用户友好。

默认情况下,Docker 在支持这些系统的平台上默认启用合理的配置文件。您可以进一步配置这些配置文件以启用或阻止各种功能,如果您在生产环境中运行 Docker,则应进行风险分析,以确定是否有额外的考虑因素需要注意。我们将简要概述您从这些系统中获得的好处。

这两个系统都提供强制访问控制,这是一种安全系统类别,系统范围的安全策略授予用户(或“发起者”)对资源(或“目标”)的访问权限。这使您可以防止任何人,包括root,访问他们不应该访问的系统部分。您可以将策略应用于整个容器,以约束所有进程。为了清晰和详细地概述如何配置这些系统,需要涉及多个章节。默认配置文件执行的任务包括阻止访问容器中可能危险的/proc/sys文件系统的部分,尽管它们出现在容器的命名空间中。默认配置文件还提供了更窄范围的挂载访问,以防止容器获取不应看到的挂载点。

如果您考虑在生产环境中使用 Linux 容器,值得认真考虑在这些系统上启用 AppArmor 或 SELinux。在很大程度上,这两个系统基本等效。但在 Docker 环境中,SELinux 的一个显著限制是它只在支持文件系统元数据的系统上完全工作,这意味着它不适用于所有 Docker 存储驱动程序。另一方面,AppArmor 不使用文件系统元数据,因此适用于所有 Docker 后端。您使用哪个系统在某种程度上取决于发行版,因此您可能被迫选择一个也支持您使用的安全系统的文件系统后端。

Docker 守护程序

从安全的角度来看,Docker 守护程序及其组件是您引入基础设施的唯一完全新的风险。您的容器化应用程序并不比在容器之外部署时更不安全,至少它们比不运行 dockerd 的情况稍微安全一些。但是如果没有容器,您将无法运行 Docker 守护程序。您可以运行 Docker 以使其不在网络上暴露任何端口。这是极为推荐的,并且是大多数 Docker 安装的默认设置。

大多数发行版上,默认的 Docker 配置将 Docker 与网络隔离开来,只暴露一个本地 Unix 套接字。因此,当 Docker 配置为这种方式时,无法远程管理 Docker 是很常见的情况,通常人们会简单地将非加密端口 2375 添加到配置中。这对于开始使用 Docker 可能很有帮助,但在任何关心系统安全的环境中都不应该这样做。除非有非常好的理由,否则不应该完全向外界开放 Docker。如果确实需要这样做,还应该承诺要正确地保护它。大多数调度系统在每个节点上运行其服务,并期望通过 Unix 域套接字而不是网络端口与 Docker 进行通信。

如果确实需要将守护程序暴露给网络,您可以采取一些措施,在大多数生产环境中都是有意义的方式来加固 Docker。但无论您做什么,都依赖于 Docker 守护程序本身对抗缓冲区溢出和竞争条件等威胁的抵抗能力,这是任何网络服务的真实情况。Docker 守护程序的风险要高得多,因为它通常以 root 用户身份运行,可以在您的系统上运行任何内容,并且没有集成的基于角色的访问控制。

锁定 Docker 的基础知识与许多其他网络守护程序类似:加密您的流量并对用户进行身份验证。第一个方法在 Docker 上设置起来相对容易;第二个方法则不那么容易。如果您有 SSL 证书可用于保护主机的 HTTP 流量,例如您域的通配符证书,您可以打开 TLS 支持来加密所有到 Docker 服务器的流量,使用端口 2376。这是一个良好的第一步。Docker 文档 将指导您完成这一过程。

认证用户更加复杂。Docker 不提供任何细粒度的授权:您要么有权限,要么没有。但它提供的认证控制——签名证书——是相当强大的。不幸的是,这也意味着,如果需要,您不能从没有认证到部分认证实现廉价过渡,而不设置证书颁发机构。如果您的组织已经有一个,那么您很幸运。在任何组织中,证书管理都需要小心实施,既要保证证书安全,又要高效地分发它们。因此,这里是基本步骤:

  1. 设置生成和签署证书的方法。

  2. 为服务器和客户端生成证书。

  3. 配置 Docker 使用 --tlsverify 要求证书。

包括在 Docker 文档 中的详细设置服务器和客户端的说明,以及简单的证书颁发机构。

警告

由于它几乎总是以特权运行的守护程序,并且因为它直接控制您的应用程序,直接将 Docker 暴露在互联网上是一个坏主意。如果您需要从网络外部访问您的 Docker 主机,请考虑使用像 VPN 或者安全跳板主机的 SSH 隧道等方法。

高级配置

Docker 有一个非常干净的外部接口,在表面上看起来相当单块化。但实际上,后台有很多可以配置的事情,我们在 “日志” 中描述的日志后端就是一个很好的例子。您还可以做一些事情,例如更换整个守护程序的容器映像存储后端,使用完全不同的运行时,或者配置单独的容器以在不同的网络配置下运行。这些都是强大的开关,在打开它们之前,您需要了解它们的作用。首先,我们将讨论网络配置,然后我们将涵盖存储后端,最后,我们将尝试使用替换 Docker 默认提供的 runc 的完全不同的容器运行时。

网络配置

早些时候,我们描述了 Linux 容器与真实网络之间的网络层。让我们更仔细地看看它是如何工作的。Docker 支持丰富的网络配置,但让我们从默认设置开始。图 11-1 显示了典型 Docker 服务器的图示,在右侧显示了三个容器在它们的私有网络上运行。其中一个容器具有在 Docker 服务器上公开的公共端口(TCP 端口 10520)。我们将跟踪入站请求如何到达 Linux 容器,以及 Linux 容器如何建立与外部网络的出站连接。

典型 Docker 服务器上的网络

图 11-1. 典型 Docker 服务器上的网络

如果我们在网络的某个地方有一个客户端想要与容器 1 内运行的 TCP 端口 80 上的nginx服务器进行通信,请求将进入 Docker 服务器上的eth0接口。因为 Docker 知道这是一个公共端口,它已经启动了一个docker-proxy实例来监听端口 10520。因此,我们的请求被传递给docker-proxy进程,然后转发到私有网络上正确的容器地址和端口。请求的返回流量通过相同的路径流动。

容器的出站流量遵循不同的路径,完全不涉及docker-proxy。在这种情况下,Container 3 希望联系公共互联网上的服务器。它在私有网络上有一个地址为 172.16.23.1,并且其默认路由是docker0接口 172.16.23.7。因此,它将流量发送到那里。Docker 服务器现在看到这个流量是出站的,并且启用了流量转发。由于虚拟网络是私有的,它希望使用其公共地址发送流量。因此,请求经过内核的网络地址转换(NAT)层,并通过服务器上的eth0接口放入外部网络。返回流量通过相同的路径。NAT 是单向的,因此虚拟网络上的容器将在响应数据包中看到真实的网络地址。

您可能已经注意到,这不是一个简单的配置。这是相当复杂的一部分,但它使得 Docker 看起来非常透明。它还有助于 Docker 堆栈的安全性姿态,因为容器被命名空间化到各自的网络命名空间中,位于各自的私有网络上,并且无法访问诸如主系统的 DBus(桌面总线)或 iptables 等内容。

让我们更详细地查看发生的情况。在 Linux 容器中,在ifconfigip addr show中显示的接口实际上是 Docker 服务器内核上的虚拟以太网接口。然后将它们映射到容器的网络命名空间,并赋予你在容器内看到的名称。让我们看一下在 Docker 服务器上运行ip addr show时可能看到的内容。为了清晰起见,我们将输出稍作缩短和调整,如下所示:

$ ip addr show

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group …
 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
 inet 127.0.0.1/8 brd 127.255.255.255 scope host lo
 valid_lft forever preferred_lft forever
 inet6 ::1/128 scope host
 valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state …
 link/ether 02:50:00:00:00:01 brd ff:ff:ff:ff:ff:ff
 inet 172.16.168.178/24 brd 192.168.65.255 scope global dynamic …
 valid_lft 4908sec preferred_lft 3468sec
 inet6 fe80::50:ff:fe00:1/64 scope link
 valid_lft forever preferred_lft forever
…
7: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue …
 link/ether 02:42:9c:d2:89:4f brd ff:ff:ff:ff:ff:ff
 inet 172.17.42.1/16brd 172.17.255.255 scope global docker0
 valid_lft forever preferred_lft forever
 inet6 fe80::42:9cff:fed2:894f/64 scope link
 valid_lft forever preferred_lft forever
…
185: veth772de2a@if184: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc …
 link/ether 9a:a9:24:b7:5a:31 brd ff:ff:ff:ff:ff:ff link-netnsid 1
 inet6 fe80::98a9:24ff:feb7:5a31/64 scope link
 valid_lft forever preferred_lft forever

这告诉我们有正常的回环接口,我们真实的以太网接口eth0,以及我们之前描述的 Docker 桥接口docker0。所有 Linux 容器的流量都从这里捕获并路由到虚拟网络之外。在这个输出中令人惊讶的是veth772de2a接口。当 Docker 创建一个容器时,它会创建两个虚拟接口,一个位于服务器端,附加到docker0桥接口,另一个附加到容器的命名空间。我们在这里看到的是服务器端的接口。你注意到它没有显示分配的 IP 地址吗?那是因为这个接口只是加入到了桥接接口。这个接口在容器的命名空间中也会有一个不同的名称。

就像 Docker 的许多部分一样,你可以用不同的实现来替换代理。为此,你可以使用--userland-proxy-path=<path>设置,但除非你有一个非常专业的网络,否则可能没有太多好的理由这样做。然而,将--userland-proxy=false标志传递给dockerd将完全禁用userland-proxy,而是依赖于 hairpin NAT 功能来在本地容器之间路由流量。如果你需要更高吞吐量的服务,这可能适合你。

注意

Hairpin NAT 通常用于描述位于 NAT 网络内部的服务,这些服务使用它们的公共 IP 地址相互寻址。这会导致来自源服务的流量路由到互联网,命中 NAT 路由器的外部接口,然后再被路由回原始网络到达目标服务。流量的形状类似字母 U 或标准的 hairpin。

主机网络

正如我们所指出的,默认实现涉及许多复杂性。但是,你可以在不使用 Docker 提供的整个网络配置的情况下运行容器。docker-proxy也可以通过要求所有网络流量通过docker-proxy进程传输后才传递给容器,来限制非常高流量数据服务的吞吐量。那么如果我们关闭 Docker 网络层,会出现什么情况呢?从一开始,Docker 就允许你在每个容器的基础上使用--net=host命令行开关来执行此操作。有时候,比如当你想运行高吞吐量应用程序时,你可能会想这么做。但是这样做会失去 Docker 的一些灵活性。让我们来看看这个机制是如何工作的。

警告

就像我们在本章讨论的其他内容一样,这不是一个你应该轻率对待的设置。它具有可能超出你容忍水平的操作和安全影响。这可能是正确的做法,但你应该了解其后果。

让我们使用--net=host启动一个容器,看看会发生什么:

$ docker container run --rm -it --net=host spkane/train-os bash

[root@docker-desktop /]# docker container run --rm -it --net=host \
                         spkane/train-os ip addr show

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group
 default qlen 1000
 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
 inet 127.0.0.1/8 brd 127.255.255.255 scope host lo
 valid_lft forever preferred_lft forever
 inet6 ::1/128 scope host
 valid_lft forever preferred_lft forever
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast
 state UP group default qlen 1000
 link/ether 02:50:00:00:00:01 brd ff:ff:ff:ff:ff:ff
 inet 192.168.65.3/24 brd 192.168.65.255 scope global dynamic
 noprefixroute eth0
 valid_lft 4282sec preferred_lft 2842sec
 inet6 fe80::50:ff:fe00:1/64 scope link
 valid_lft forever preferred_lft forever
…
7: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue
 state DOWN group default
 link/ether 02:42:9c:d2:89:4f brd ff:ff:ff:ff:ff:ff
 inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
 valid_lft forever preferred_lft forever
 inet6 fe80::42:9cff:fed2:894f/64 scope link
 valid_lft forever preferred_lft forever
8: br-340323d07310: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc
 noqueue state DOWN group default
 link/ether 02:42:56:24:42:b8 brd ff:ff:ff:ff:ff:ff
 inet 172.22.0.1/16 brd 172.22.255.255 scope global br-340323d07310
 valid_lft forever preferred_lft forever
11: br-01f7537b9475: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc
 noqueue state DOWN group default
 link/ether 02:42:ed:14:67:61 brd ff:ff:ff:ff:ff:ff
 inet 172.18.0.1/16 brd 172.18.255.255 scope global br-01f7537b9475
 valid_lft forever preferred_lft forever
 inet6 fc00:f853:ccd:e793::1/64 scope global
 valid_lft forever preferred_lft forever
 inet6 fe80::42:edff:fe14:6761/64 scope link
 valid_lft forever preferred_lft forever
 inet6 fe80::1/64 scope link
 valid_lft forever preferred_lft forever

那看起来应该很熟悉。这是因为当我们使用主机网络选项运行容器时,容器同时在主机服务器的网络和 UTS 命名空间中运行。我们的服务器主机名是 docker-desktop,从 shell 提示符可以看出,我们的容器具有相同的主机名:

[root@docker-desktop /]# hostname
docker-desktop

运行 mount 命令查看已挂载的内容时,我们可以看到 Docker 仍在维护我们的 /etc/resolv.conf/etc/hosts/etc/hostname 目录。预期中,/etc/hostname 目录只包含服务器的主机名:

[root@docker-desktop /]# mount

overlay on / type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/…)
…
/dev/vda1 on /etc/resolv.conf type ext4 (rw,relatime)
/dev/vda1 on /etc/hostname type ext4 (rw,relatime)
/dev/vda1 on /etc/hosts type ext4 (rw,relatime)
…

[root@docker-desktop /]# cat /etc/hostname
docker-desktop

为了证明我们可以看到 Docker 服务器上的所有正常网络,让我们查看 ss 的输出,看看是否可以看到 Docker 正在利用的套接字:

root@852d18f5c38d:/# ss | grep docker

u_str  ESTAB  0  0  /run/guest-services/docker.sock  18086  * 16860
…
u_str  ESTAB  0  0  /var/run/docker.sock             21430  * 21942
注意

如果 Docker 守护程序正在监听 TCP 端口,比如 2375,你也可以查找该端口。请随意查找你知道正在使用的服务器端口上的另一个 TCP 端口。

如果在普通容器的输出中搜索 docker,您会注意到没有结果:

$ docker container run --rm -it spkane/train-os bash -c "ss | grep docker"

所以我们确实处于服务器的网络命名空间中。所有这些意味着,如果我们要启动一个高吞吐量的网络服务,我们可以期望它的网络性能基本上与本机相同。但这也意味着我们可能会尝试绑定与服务器上冲突的端口,因此如果您这样做,应该小心如何分配端口分配。

配置网络

网络配置不仅限于默认网络或主机网络。docker network 命令允许您创建由不同驱动程序支持的多个网络。它还允许您查看和操作 Docker 网络层及其如何附加到正在系统上运行的容器上。

使用以下命令轻松列出 Docker 视角中可用的网络:

$ docker network ls

NETWORK ID      NAME      DRIVER    SCOPE
5840a6c23373    bridge    bridge    local
1c22b4582189    host      host      local
c128bfdbe003    none      null      local

您可以使用 docker network inspect 命令和网络 ID 查找有关任何单个网络的更多详细信息:

$ docker network inspect 5840a6c23373
[
    {
        "Name": "bridge",
        "Id": "5840…fc94",
        "Created": "2022-09-23T01:21:55.697907958Z",
        "Scope": "local",
        "Driver": "bridge",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "172.17.0.0/16",
                    "Gateway": "172.17.0.1"
                }
            ]
        },
        "Internal": false,
        "Attachable": false,
        "Ingress": false,
        "ConfigFrom": {
            "Network": ""
        },
        "ConfigOnly": false,
        "Containers": {},
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            "com.docker.network.bridge.enable_icc": "true",
            "com.docker.network.bridge.enable_ip_masquerade": "true",
            "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
            "com.docker.network.bridge.name": "docker0",
            "com.docker.network.driver.mtu": "1500"
        },
        "Labels": {}
    }
]

Docker 网络可以使用 network 子命令创建和移除,并与单个容器附加和分离。

到目前为止,我们设置了一个桥接网络、无 Docker 网络以及一个具有 hairpin NAT 的桥接网络。还有一些其他驱动程序可用于使用 Docker 创建不同的拓扑,其中 overlaymacvlan 驱动程序最常见。让我们简要看看它们能为您做些什么:

overlay

此驱动程序用于 Swarm 模式,用于在 Docker 主机之间生成网络覆盖层,从而在实际网络之上为所有运行的容器创建私有网络。这对 Swarm 是有用的,但对于非 Swarm 容器的一般使用不适用。

macvlan

该驱动程序为每个容器创建一个真实的 MAC 地址,然后通过您选择的接口将它们暴露在网络上。这要求您在交换机上支持每个物理端口多个 MAC 地址。结果是所有容器直接出现在底层网络上。当您从传统系统转移到基于容器的系统时,这可能是一个非常有用的步骤。这里存在一些缺点,例如在调试时更难识别流量真正来自哪个主机,网络交换机中 MAC 表的溢出,容器主机的过多 ARP 请求以及其他底层网络问题。因此,除非您对底层网络有很好的理解并能有效地管理它,否则不建议使用macvlan驱动程序。

这里可能有几组配置选项,但基本设置很容易配置:

$ docker network create -d macvlan \
    --subnet=172.16.16.0/24 \
    --gateway=172.16.16.1  \
    -o parent=eth0 ourvlan

$ docker network ls
NETWORK ID     NAME            DRIVER    SCOPE
5840a6c23373   bridge          bridge    local
1c22b4582189   host            host      local
c128bfdbe003   none            null      local
8218c0ecc9e2   ourvlan         macvlan   local

$ docker network rm 8218c0ecc9e2
提示

您可以通过将它们指定为命名的辅助地址来阻止 Docker 分配特定地址,--aux-address="my-router=172.16.16.129"

Docker 网络层有很多可以配置的地方。但是,默认设置、主机网络和无代理用户空间模式是您最有可能使用或在实际应用中遇到的选项。您可以配置的其他一些选项包括容器的 DNS 名称服务器、解析器选项和默认网关,等等。Docker 文档的网络部分概述了如何进行部分配置。

注意

对于 Docker 的高级网络配置,请查看Weave——一个受到良好支持的覆盖网络工具,可以跨多个 Docker 主机扩展容器,类似于overlay驱动程序,但更可配置且无需 Swarm 要求。另一个选择是Project Calico。如果您正在运行 Kubernetes,它有自己的网络配置,您可能还想熟悉Container Network Interface (CNI),然后看看提供容器强大基于 eBPF 的网络功能的Cilium

存储

在您的 Docker 服务器上支持所有镜像和容器的是处理所有这些数据的存储后端。Docker 对其存储后端有一些严格的要求:它必须支持分层,这是 Docker 跟踪更改并减少容器占用的磁盘空间以及部署新镜像所需传输的数据量的机制。使用写时复制策略,Docker 可以从现有镜像启动新容器,而无需复制整个镜像。存储后端支持这一点。存储后端使得将镜像导出为一组层次变化成为可能,并且还允许您保存运行中容器的状态。在大多数情况下,您需要内核的帮助才能高效地完成这些操作。这是因为容器中的文件系统视图通常是其下所有层次的联合,这些层次实际上并未复制到容器中。相反,它们对容器可见,只有在进行更改时才会将任何内容写入到容器的文件系统中。此分层机制向您公开的一个场景是从像 Docker Hub 这样的注册表上传或下载新镜像时。Docker 守护进程将单独推送或拉取每个层,并且如果某些层与其已存储的其他层相同,则会使用缓存层。在向注册表推送时,它有时甚至会告诉您它们从哪个镜像挂载而来。

Docker 依赖于一系列可能的内核驱动程序来处理分层。Docker 代码库包含能够处理与许多这些后端的交互的代码,您可以在守护进程重新启动时配置使用哪一个。所以让我们看看有哪些可用的选项以及每个选项的优缺点。

不同的后端具有可能使它们成为您最佳选择的不同限制。在某些情况下,您可以使用的后端选项受到 Linux 发行版支持的限制。始终使用与您发行版一起提供的内核中内置的驱动程序将是最简单的方法。通常最好保持在经过充分测试的路径上。自 Docker 发布以来,我们已经看到了各种各样的来自不同后端的奇特现象。通常情况下,常见情况总是得到最好的支持。不同的后端还通过 Docker 远程 API(/info 端点)报告不同的统计信息。这对于监视您的 Docker 系统非常有用。然而,并非所有后端都是平等的,因此让我们看看它们的区别:

Overlay

Overlay(以前称为 OverlayFS)是一个联合文件系统,多个层被一起挂载,因此它们看起来像一个单一的文件系统。Overlay 文件系统是目前 Docker 存储的最推荐选择,在大多数主要发行版上都可以工作。如果你运行的是早于 4.0 版本的 Linux 内核(或者 RHEL 的 3.10.0-693 版本),那么你将无法利用这个后端。其可靠性和性能足够好,以至于可能值得更新你的操作系统以支持 Docker 主机,即使你公司的标准是较旧的发行版。Overlay 文件系统已成为主线 Linux 内核的一部分,并随着时间的推移变得越来越稳定。作为主线的一部分意味着长期支持几乎是有保证的,这是另一个很好的优势。Docker 支持 Overlay 后端的两个版本,overlayoverlay2。正如你所预期的那样,强烈建议使用overlay2,因为它更快速,更有效地使用 inode,并且更加稳健。

注意

Docker 社区频繁改进对各种文件系统后端的支持。有关支持的文件系统的详细信息,请查看官方文档

AuFS

虽然在撰写本文时不再推荐使用,aufs是 Docker 的原始后端。AuFS (Advanced multilayered unification filesystem)是一个具有合理支持的联合文件系统驱动,在各种流行的 Linux 发行版上。然而,它从未被接受进入主线内核,这限制了它在各种发行版上的可用性。例如,它不支持最近版本的 Red Hat 或 Fedora。它没有在标准的 Ubuntu 发行版中提供,但在 Ubuntu 的linux-image-extra包中提供。

它作为内核中的二等公民的地位导致了现在可用的许多其他后端的开发。如果你运行支持 AuFS 的旧发行版,你可以考虑它,但是你应该升级到原生支持 Overlay 或 Btrfs 的内核版本,接下来会讨论 Btrfs。

Btrfs

B-Tree 文件系统(Btrfs)基本上是一个写时复制文件系统,这意味着它非常适合 Docker 镜像模型。与aufs类似,但不同于devicemapper,Docker 在使用此后端时是按照其预期的方式使用的。这意味着在生产环境中非常稳定,性能也很好。它在同一系统上合理扩展到成千上万个容器。对于基于 Red Hat 的系统的一个缺点是,Btrfs 不支持 SELinux。如果可以使用btrfs后端,那么在overlay2驱动程序之后探索另一个选项是值得的。在 Linux 容器中运行btrfs后端的一种流行方式是,不必将整个卷交给此文件系统,而是在文件中创建 Btrfs 文件系统,并使用诸如mount -o loop file.btrfs /mnt之类的方法进行回环挂载。通过这种方法,即使在基于云的系统上,您也可以构建一个 50 GB 的 Linux 容器存储文件系统,而无需将所有宝贵的本地存储空间都交给 Btrfs。

Device Mapper

最初由 Red Hat 编写以支持其发行版,因 Docker 早期缺乏 AuFS 而成为所有基于 Red Hat 的 Linux 发行版的默认后端。根据您使用的 Red Hat Linux 版本,这可能是您唯一的选择。Device Mapper 本身已经内置于 Linux 内核中很长时间,并且非常稳定。然而,Docker 守护程序使用它的方式有点不寻常,过去这个后端并不太稳定。由于这种多舛的过去,我们建议尽可能选择其他后端。如果您的发行版仅支持devicemapper驱动程序,那么您可能会很满意。但是考虑到使用overlay2btrfs也是值得的。默认情况下,devicemapper使用loop-lvm模式,它不需要任何配置,但非常慢,通常仅适用于开发环境。如果决定使用devicemapper驱动程序,则必须确保它配置为在所有非开发环境中使用direct-lvm模式。

注意

使用 Docker 中的各种devicemapper模式的更多信息,请参阅官方文档。2014 年的博客文章还介绍了各种 Docker 存储后端的有趣历史。

VFS

在支持的驱动程序中,虚拟文件系统(vfs)驱动程序是启动最简单、但最慢的。它实际上不支持写时复制,而是创建一个新目录并复制所有现有数据。最初是用于测试和挂载主机卷。vfs 驱动程序在创建新容器时非常慢,但运行时性能是本地的,这是一个真正的优点。它的机制非常简单,意味着出错的可能性较小。Docker, Inc. 不建议在生产环境中使用它,因此如果您认为它适合您的生产环境,请谨慎使用。

ZFS

ZFS 是由 Sun Microsystems 创建的最先进的开源文件系统,在 Linux 上可用。由于许可限制,它不会随主线 Linux 发布。然而,ZFS on Linux 项目 已经让其安装变得相当容易。然后 Docker 可以在 ZFS 文件系统上运行,并使用其先进的写时复制功能实现分层。考虑到 ZFS 不在主线内核中,并且在主要商业发行版中不可用,选择这条路线需要一些额外的努力。然而,如果您已经在生产中运行 ZFS,这可能是您最好的选择。

警告

存储后端对容器的性能有很大影响。如果在 Docker 服务器上切换后端,所有现有的映像将消失。它们并未丢失,但在切换驱动程序后将无法看到。建议谨慎操作。

您可以使用 docker system info 查看您的系统正在运行哪个存储后端:

$ docker system info
…
 Storage Driver: overlay2
  Backing Filesystem: extfs
  Supports d_type: true
  Native Overlay Diff: true
  userxattr: false
…

正如您所见,Docker 也会告诉您如果有底层或“后备”文件系统。因为我们在这里运行的是 overlay2,我们可以看到它是由 ext 文件系统支持的。在某些情况下,如在原始分区上的 devicemapperbtrfs 上,可能不会有不同的底层文件系统。

存储后端可以通过 daemon-json 配置文件或在启动时通过 dockerd 命令行参数进行交换。如果我们想要将我们的 Ubuntu 系统从 aufs 切换到 devicemapper,我们可以这样做:

$ dockerd --storage-driver=devicemapper

几乎任何能够支持 Docker 的 Linux 系统都可以使用 devicemapper,因为它几乎总是存在的。对于现代 Linux 内核上的 overlay2 也是如此。但是,您需要确保其他驱动程序的实际底层依赖项已经就位。例如,如果内核中没有 aufs(通常通过内核模块),Docker 将无法启动 aufs 作为存储驱动程序;对于 Btrfs 或 ZFS 也是如此。

在将 Docker 投入生产时,选择适合您系统和部署需求的适当存储驱动是其中更重要的技术点之一。要保守:确保您选择的路径在您的内核和发行版中得到良好支持。从历史上看,这曾是一个痛点,但大多数驱动程序已经达到了合理的成熟度。然而,在这个领域继续变化时,对于任何新出现的后端,仍需保持谨慎。根据我们的经验,使新的后端驱动程序在生产系统中可靠运行需要相当长的时间。

nsenter

nsenter,即“命名空间进入”,允许您进入任何 Linux 命名空间,并且是来自kernel.org核心util-linux包的一部分。使用nsenter,我们可以从服务器本身进入 Linux 容器,即使在dockerd服务器无响应且无法使用docker container exec的情况下也可以。它还可以用作在服务器上以root身份操作容器中否则无法完成的操作的工具。在调试时,这非常有用。大多数情况下,docker container exec就足够了,但您应该在工具箱中备有nsenter

大多数 Linux 发行版都提供了足够新的util-linux包,其中包含nsenter。如果您使用的发行版没有它,获取nsenter的最简单方法是通过第三方的Linux 容器进行安装。

此容器通过从 Docker Hub 注册表拉取 Docker 镜像,然后运行一个 Linux 容器,该容器将nsenter命令行工具安装到/usr/local/bin中。这乍一看可能有些奇怪,但这是一种聪明的方法,允许您远程使用仅仅使用docker命令将nsenter安装到任何 Docker 服务器中。

与可以远程运行的docker container exec不同,nsenter要求您直接在服务器上运行它,或通过容器间接运行。为了我们的目的,我们将使用一个特制的容器来运行nsenter。与docker container exec的示例类似,我们需要运行一个容器:

$ docker container run -d --rm  ubuntu:22.04 sleep 600
fd521174d66dc32650d165e0ce7dd97255c7b3624c34cb1d119d955284382ddf

docker container exec相当简单,但使用nsenter有些麻烦。它需要知道您容器中实际顶层进程的 PID,而这并不明显。让我们手动运行nsenter,看看发生了什么。

首先,我们需要找出正在运行的容器的 ID,因为nsenter需要知道这一点才能访问它。我们可以通过docker container ls轻松获取这个:

$ docker container ls

CONTAINER ID  IMAGE          COMMAND      …  NAMES
fd521174d66d   ubuntu:22.04  "sleep 1000" …  angry_albattani

我们想要的 ID 是第一个字段,fd521174d66d。有了这个,我们现在可以找到我们需要的 PID,就像这样:

$ docker container inspect --format \{{.State.Pid\}} fd521174d66d
2721
提示

您还可以通过运行docker container top命令,后跟容器 ID,获取容器中进程的真实 PID。在我们的示例中,这将如下所示:

$ docker container top fd521174d66d

UID   PID   PPID  C  STIME  TTY  TIME      CMD
root  2721  2696  0  20:37  ?    00:00:00  sleep 600

确保在下面的命令中更新--target参数为前一个命令得到的进程 ID,然后继续调用nsenter

$ docker container run --rm -it --privileged --pid=host debian \
    nsenter --target 2721 --all

# ps -ef

UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 20:37 ?        00:00:00 sleep 600
root        11     0  0 20:51 ?        00:00:00 -sh
root        15    11  0 20:51 ?        00:00:00 ps -ef
# exit

如果结果看起来很像docker container exec,那是因为它在底层几乎做了相同的事情!

命令行参数--all告诉nsenter我们想进入由--target指定的进程使用的所有命名空间。

调试无 Shell 的容器

如果你想调试一个没有 Unix Shell 的容器,那么事情就会变得有些棘手,但仍然是可能的。例如,我们可以运行一个只有单个可执行文件的容器:

$ docker container run --rm -d --name outyet-small \
    --publish mode=ingress,published=8090,target=8080 \
    spkane/outyet:1.9.4-small
4f6de24d4c9c794c884afa758ef5b33ea38c01f8ec9314dcddd9fadc25c1a443

让我们快速看一下运行在这个容器中的进程:

$ docker container top outyet-small

UID  PID   PPID  C STIME TTY TIME     CMD
root 61033 61008 0 22:43 ?   00:00:00 /outyet -version 1.9.4 -poll 600s …

如果你尝试在容器中启动 Unix Shell,你会收到一个错误:

$ docker container exec -it outyet-small /bin/sh

OCI runtime exec failed: exec failed: unable to start container process: exec:
 "/bin/sh": stat /bin/sh: no such file or directory: unknown

我们可以启动第二个包含 Shell 和其他一些有用工具的容器,这样新容器就可以看到第一个容器中的进程,使用与第一个容器相同的网络堆栈,并具有一些额外的权限,这对我们的调试将很有帮助:

$ docker container run --rm -it --pid=container:outyet-small \
  --net=container:outyet-small --cap-add sys_ptrace \
  --cap-add sys_admin spkane/train-os /bin/sh

sh-5.1#

如果你在这个容器中输入ls,你会在文件系统中看到spkane/train-os镜像,其中包含/bin/sh和所有我们的调试工具,但不包含任何outyet-small容器中的文件:

sh-5.1# ls

bin   dev  home  lib64       media  opt   root  sbin  sys  usr
boot  etc  lib   lost+found  mnt    proc  run   srv   tmp  var

但是,如果你输入ps -ef,你会注意到你看到了所有原始容器中的进程。这是因为我们告诉 Docker 附加到outyet-small容器的命名空间,通过传递--pid=container:outyet-small

sh-5.1# ps -ef

UID  PID PPID C STIME TTY   TIME     CMD
root   1    0 0 22:43 ?     00:00:00 /outyet -version 1.9.4 -poll 600s …
root  29    0 0 22:47 pts/0 00:00:00 /bin/sh
root  36   29 0 22:49 pts/0 00:00:00 ps -ef

并且因为我们使用相同的网络堆栈,你甚至可以curl第一个容器中outyet服务绑定的端口:

sh-5.1# curl localhost:8080
<!DOCTYPE html><html><body><center>
  <h2>Is Go 1.9.4 out yet?</h2>
  <h1>

    <a href="https://go.googlesource.com/go/&#43;/go1.9.4">YES!</a>

  </h1>
  <p>Hostname: 155914f7c6cd</p>
</center></body></html>

此时,你可以使用strace或任何其他你想用来调试应用程序的工具,最后退出新的调试容器,保留你的原始容器在服务器上继续运行。

警告

如果你运行strace,你需要输入 Ctrl-C 来退出strace进程。

sh-5.1# strace -p 1

strace: Process 1 attached
futex(0x963698, FUTEX_WAIT, 0, NULL^Cstrace: Process 1 detached
 <detached …>

sh-5.1# exit
exit

你会注意到在这种情况下我们无法看到文件系统。如果你需要查看或复制容器中的文件,你可以使用docker container export命令获取容器文件系统的 tarball:

$ docker container export outyet-small -o export.tar

然后你可以使用tar来查看或提取文件:

$ tar -tvf export.tar

-rwxr-xr-x  0 0   0         0 Jul 17 16:04 .dockerenv
drwxr-xr-x  0 0   0         0 Jul 17 16:04 dev/
-rwxr-xr-x  0 0   0         0 Jul 17 16:04 dev/console
drwxr-xr-x  0 0   0         0 Jul 17 16:04 dev/pts/
drwxr-xr-x  0 0   0         0 Jul 17 16:04 dev/shm/
drwxr-xr-x  0 0   0         0 Jul 17 16:04 etc/
-rwxr-xr-x  0 0   0         0 Jul 17 16:04 etc/hostname
-rwxr-xr-x  0 0   0         0 Jul 17 16:04 etc/hosts
lrwxrwxrwx  0 0   0         0 Jul 17 16:04 etc/mtab -> /proc/mounts
-rwxr-xr-x  0 0   0         0 Jul 17 16:04 etc/resolv.conf
drwxr-xr-x  0 0   0         0 Apr 24  2021 etc/ssl/
drwxr-xr-x  0 0   0         0 Apr 24  2021 etc/ssl/certs/
-rw-r--r--  0 0   0    261407 Mar 13  2018 etc/ssl/certs/ca-certificates.crt
-rwxr-xr-x  0 0   0   5640640 Apr 24  2021 outyet
drwxr-xr-x  0 0   0         0 Jul 17 16:04 proc/
drwxr-xr-x  0 0   0         0 Jul 17 16:04 sys/

完成后,继续删除export.tar,然后用docker container stop outyet-small停止outyet-small容器。

注意

你可以通过直接导航到服务器存储系统上文件系统所在的位置,从 Docker 服务器探索容器的文件系统。这通常看起来像/var/lib/docker/overlay/fd5…,但会根据 Docker 设置、存储后端和容器哈希而变化。你可以通过运行docker system info来确定你的 Docker 根目录。

Docker 的结构

我们所认为的 Docker 由五个主要的服务器端组件组成,通过 API 提供一个共同的前端。这些部分是 dockerdcontainerdrunccontainerd-shim-runc-v2,以及我们在 “Networking” 中描述的 docker-proxy。我们花了很多时间与 dockerd 以及它呈现的 API 进行交互。事实上,它负责编排组成 Docker 的整套组件。但是当它启动一个容器时,Docker 依赖于 containerd 来处理容器的实例化。所有这些过去都是在 dockerd 进程本身中处理的,但这种设计存在几个缺点:

  • dockerd 承担了大量的工作。

  • 一个单片运行时阻止了任何组件的轻松替换。

  • dockerd 必须监督容器本身的生命周期,而且如果没有丢失所有正在运行的容器,它就不能重新启动或升级。

另一个 containerd 的主要动机是,正如我们刚才所展示的,容器不仅仅是一个单一的抽象。在 Linux 平台上,它们是涉及命名空间、cgroups 和安全规则(如 AppArmor 或 SELinux)的进程。但 Docker 也可以运行在 Windows 上,未来可能会在其他平台上运行。containerd 的理念是向外界呈现一个标准层,在这个层面上,无论实现方式如何,开发人员都可以思考关于容器、任务和快照的高级概念,而不必担心特定的 Linux 系统调用。这极大地简化了 Docker 守护程序,并使得像 Kubernetes 这样的平台可以直接集成到 containerd 中,而不是使用 Docker API。多年来,Kubernetes 依赖于 Docker 的代理,但现在它直接使用 containerd

让我们看看这些组件(显示在 图 11-2 中)并了解它们各自的作用:

dockerd

每台服务器一个。提供 API 服务,构建容器镜像,并进行高级网络管理,包括卷、日志、统计报告等。

docker-proxy

每个端口转发规则一个。每个实例处理从定义的主机 IP 和端口到定义的容器 IP 和端口的指定协议流量(TCP/UDP)的转发。

containerd

每台服务器一个。管理生命周期、执行、写时复制文件系统和低级网络驱动程序。

containerd-shim-runc-v2

每个容器一个。处理传递给容器的文件描述符(如 stdin/out)并报告退出状态。

runc

构建容器并执行它,收集统计信息,并报告生命周期事件。

Docker 结构

图 11-2. Docker 结构

dockerdcontainerd 通过一个套接字进行通信,通常是一个 Unix 套接字,使用 gRPC API。在这种情况下,dockerd 是客户端,而 containerd 是服务器!runc 是一个 CLI 工具,它从磁盘上的 JSON 配置中读取配置,并由 containerd 执行。

当我们启动一个新容器时,dockerd 将确保镜像存在或将其从镜像名中指定的仓库拉取(将来,这个责任可能会转移到已支持镜像拉取的 containerd 上)。Docker 守护进程还会处理大部分围绕容器的其他设置,比如启动 docker-proxy 来设置端口转发。然后它与 containerd 进行通信,并要求其运行容器。containerd 将获取镜像,并将从 dockerd 传递的容器配置应用于生成一个 OCI bundle,供 runc 执行。^(1) 然后它将执行 containerd-shim-runc-v2 来启动容器。这将反过来执行 runc 来构建并启动容器。但是,runc 不会保持运行状态,containerd-shim-runc-v2 将成为新容器进程的实际父进程。

如果我们启动一个容器,然后查看 Docker 服务器上 ps axlf 命令的输出,我们可以看到各个进程之间的父子关系。PID 1 是 /sbin/init,是 containerddockerdcontainerd-shim-runc-v2 的父进程。

注意

Docker Desktop 的虚拟机包含大多数 Linux 工具的最小版本,其中一些命令的输出可能与使用标准 Linux 服务器作为 Docker 守护进程主机时不同。

$ docker container run --rm -d \
  --publish mode=ingress,published=8080,target=80 \
  --name nginx-test --rm nginx:latest
08b5cffed7baaf32b3af50498f7e5c5fa7ed35e094fa6045c205a88746fe53dd

$ ps axlf
… PID  PPID COMMAND
…
… 5171 1    /usr/bin/containerd
… 5288 1    /usr/bin/dockerd -H fd:// --containerd=/run/cont…/containerd.…
… 5784 5288 \_ /usr/bin/docker-proxy -proto tcp -host-ip … -host-port 8080
… 5791 5288 \_ /usr/bin/docker-proxy -proto tcp -host-ip :: -host-port …
… 5807 1    /usr/bin/containerd-shim-runc-v2 -namespace moby -id …
… 5829 5807  \_ nginx: master process nginx -g daemon off;
… 5880 5829      \_ nginx: worker process
… 5881 5829      \_ nginx: worker process
… 5882 5829      \_ nginx: worker process
… 5883 5829      \_ nginx: worker process
…

那么 runc 发生了什么?它的任务是构建容器并启动它运行,然后它离开并且它的子进程被其父进程 containerd-shim-runc-v2 继承。这样在内存中保留了管理文件描述符和 containerd 退出状态所需的最小代码量。

为了帮助您理解这里发生的情况,让我们深入了解启动容器时发生的情况。为此,我们将重复使用我们已经在运行的 nginx 容器,因为它非常轻量级且在后台运行时容器保持运行状态:

$ docker container ls

CONTAINER ID IMAGE        COMMAND        … PORTS                  NAMES
08b5cffed7ba nginx:latest "/docker-ent…" … 0.0.0.0:8080->80/tcp … nginx-test

让我们使用 runc 运行时 CLI 工具来查看系统的视图。我们可以从 containerd 的 CLI 客户端 ctr 中看到类似的视图,但 runc 更容易使用,并且它处于更低的层级上:

$ sudo runc --root /run/docker/runtime-runc/moby list

ID         PID   …  BUNDLE                                          … OWNER
08b5…53dd  5829  …  …/io.containerd.runtime.v2.task/moby/08b5…53dd  … root

我们通常需要 root 权限来运行这个命令。与 Docker CLI 不同,我们不能依赖 Docker 守护进程的权限来让我们访问更低级别的功能。使用 runc,我们需要直接访问这些权限。从 runc 的输出中我们能看到我们的容器!这是实际的 OCI 运行时捆绑包,代表我们的容器,并与之共享一个 ID。注意它还给出了容器的 PID;这是容器内运行的应用程序在主机上的 PID:

$ ps -edaf | grep 5829

root      5829  5807  …  nginx: master process nginx -g daemon off;
systemd+  5880  5829  …  nginx: worker process
systemd+  5881  5829  …  nginx: worker process
systemd+  5882  5829  …  nginx: worker process
systemd+  5883  5829  …  nginx: worker process

如果我们在捆绑包中查看,我们会看到一组命名管道用于我们的容器:

$ sudo ls -la /run/docker/containerd/08b5…53dd

total 0
drwxr-xr-x 2 root root 80 Oct  1 08:49 .
drwxr-xr-x 3 root root 60 Oct  1 08:49 ..
prwx------ 1 root root  0 Oct  1 08:49 init-stderr
prwx------ 1 root root  0 Oct  1 08:49 init-stdout

您可以在 /run/containerd/io.containerd.runtime.v2.task/moby 下找到与您的容器相关的许多附加文件:

$ sudo ls -la /run/containerd/io.containerd.runtime.v2.task/moby/08b5…53dd/

total 32
drwx------ 3 root root  240 Oct  1 08:49 .
drwx--x--x 3 root root   60 Oct  1 08:49 ..
-rw-r--r-- 1 root root   89 Oct  1 08:49 address
-rw-r--r-- 1 root root 9198 Oct  1 08:49 config.json
-rw-r--r-- 1 root root    4 Oct  1 08:49 init.pid
prwx------ 1 root root    0 Oct  1 08:49 log
-rw-r--r-- 1 root root    0 Oct  1 08:49 log.json
-rw------- 1 root root   82 Oct  1 08:49 options.json
drwx--x--x 2 root root   40 Oct  1 08:49 rootfs
-rw------- 1 root root    4 Oct  1 08:49 runtime
-rw------- 1 root root   32 Oct  1 08:49 shim-binary-path
lrwxrwxrwx 1 root root  119 Oct  1 08:49 work -> /var/lib/containerd/io…

config.json文件是docker container inspect所显示内容的一个非常冗长的等价版本。由于尺寸较大,我们不会在这里进行复制,但我们鼓励你深入挖掘并查看配置文件中的内容。例如,你可能会注意到其中涉及到的所有关于“安全计算模式”的条目。

如果你想进一步探索runc,可以尝试使用其 CLI 工具进行实验。大部分功能在 Docker 中已经提供,通常在比runc更高级和更有用的层级上。但是,通过探索runc,你可以更好地理解容器和 Docker 堆栈是如何组合在一起的。此外,观察runc报告的运行容器事件也是很有趣的。我们可以使用runc events命令进行监听。在运行容器的正常操作过程中,事件流中并不会有太多的活动。但runc定期报告运行时统计信息,我们可以以 JSON 格式看到这些信息。

$ sudo runc --root /run/docker/runtime-runc/moby events 08b5…53dd
{"type":"stats","id":"08b5…53dd","data":{"cpu":{"usage":{"…"}}}}

为了节省空间,我们已经从前一个命令的输出中删除了大部分内容,但现在你可能已经很熟悉docker container stats所显示的内容了。猜猜 Docker 默认从哪里获取这些统计信息。没错,就是runc

现在,你可以运行docker container stop nginx-test来停止示例容器。

切换运行时环境

如我们在第二章中提到的,还有一些其他本地的 OCI 兼容运行时可以替代runc。例如,有一个叫做crun的项目,它自称是“一个快速且低内存占用的 OCI 容器运行时,完全由 C 语言编写”。一些其他的本地运行时选择,如railcarrkt,已经被弃用并基本被放弃。在接下来的章节中,我们将会讨论谷歌的一个沙箱运行时,称为gVisor,它为不受信任的代码提供了一个用户空间运行时环境。

小贴士

Kata Containers是一个非常有趣的开源项目,它提供了一个能够利用虚拟机作为容器隔离层的运行时环境。截至目前,Kata 的第三版可以与 Kubernetes 一起使用,但无法与 Docker 兼容。Kata 的开发者正在与 Docker 的开发者合作(链接至此处),试图改善这种情况并创建更好的文档。当 Docker 22.06 公开发布时,这个问题可能会得到解决。

gVisor

2018 年中期,Google 发布了 gVisor,这是一种全新的运行时方式。它符合 OCI 规范,因此也可以与 Docker 一起使用。但是,gVisor 也在用户空间运行,并通过在那里实现系统调用来隔离应用程序,而不是依赖于内核隔离机制。它不会将调用重定向到内核,而是使用内核调用自行实现。这种方法的最明显优势是安全隔离,因为 gVisor 本身运行在用户空间,因此与内核隔离开来。任何安全问题仍然被困在用户空间中,并且我们提到的所有内核安全控制仍然适用。缺点是它通常性能比基于内核或虚拟机的解决方案差。

如果您有一些不需要大规模扩展但需要高度安全隔离的流程,gVisor 可能是您的理想选择。gVisor 的一个常见用例是当您的容器将运行由最终用户提供的代码,且您无法保证该代码是良性的时候。让我们快速演示一下,这样您就可以看到 gVisor 的工作原理。

安装过程详见gVisor 文档。它是用 Go 语言编写的,作为一个单独的可执行文件提供,无需其他包。一旦安装完成,您可以使用runsc运行时启动容器。为了演示 gVisor 提供的不同隔离级别,我们将使用它运行一个 shell,并与使用标准容器运行的情况进行比较。

首先,让我们在 gVisor 上启动一个 shell,稍微查看一下:

$ docker container run --rm --runtime=runsc -it alpine /bin/sh

这将使我们进入一个运行在 Alpine Linux 容器中的 shell。当您查看mount命令的输出时,会发现一个非常明显的差异:

$ docker container run --rm --runtime=runsc -it alpine /bin/sh -c "mount"

none on / type 9p (rw,trans=fd,rfdno=4,wfdno=4,aname=/,…)
none on /dev type tmpfs (rw,mode=0755)
none on /sys type sysfs (ro,noexec,dentry_cache_limit=1000)
none on /proc type proc (rw,noexec,dentry_cache_limit=1000)
none on /dev/pts type devpts (rw,noexec)
none on /dev/shm type tmpfs (rw,noexec,mode=1777,size=67108864)
none on /etc/hosts type 9p (rw,trans=fd,rfdno=7,wfdno=7,…)
none on /etc/hostname type 9p (rw,trans=fd,rfdno=6,wfdno=6,…)
none on /etc/resolv.conf type 9p (rw,trans=fd,rfdno=5,wfdno=5,…)
none on /tmp type tmpfs (rw,mode=01777)

没有什么内容!与使用runc启动的传统容器的输出相比:

$ docker container run --rm -it alpine /bin/sh -c "mount"

overlay on / type overlay (rw,relatime,…)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
tmpfs on /dev type tmpfs (rw,nosuid,size=65536k,mode=755,inode64)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,…)
sysfs on /sys type sysfs (ro,nosuid,nodev,noexec,relatime)
cgroup on /sys/fs/cgroup type cgroup2 (ro,nosuid,nodev,noexec,relatime)
mqueue on /dev/mqueue type mqueue (rw,nosuid,nodev,noexec,relatime)
shm on /dev/shm type tmpfs (rw,nosuid,nodev,noexec,relatime,…)
/dev/sda3 on /etc/resolv.conf type ext4 (rw,relatime,errors=remount-ro)
…
devpts on /dev/console type devpts (rw,nosuid,noexec,relatime,gid=5,…)
proc on /proc/bus type proc (ro,nosuid,nodev,noexec,relatime)
…
tmpfs on /proc/asound type tmpfs (ro,relatime,inode64)
…

此输出长达 24 行,因此我们对其进行了大幅截断。这里的系统细节非常丰富,它代表了以某种方式向容器暴露的内核足迹。与 gVisor 的非常短的输出形成鲜明对比,这应该可以让您了解到不同的隔离级别。我们不会花太多时间来深入讨论这一点,但查看ip addr show的输出也是值得的。在 gVisor 上:

$ docker container run --rm --runtime=runsc alpine ip addr show

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65522
 link/loopback 00:00:00:00:00:00 brd ff:ff:ff:ff:ff:ff
 inet 127.0.0.1/8 scope global dynamic
2: eth0: <UP,LOWER_UP> mtu 1500
 link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
 inet 172.17.0.2/16 scope global dynamic

而在一个正常的 Linux 容器中:

$ docker container run --rm alpine ip addr show

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN qlen 1000
 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
 inet 127.0.0.1/8 scope host lo
 valid_lft forever preferred_lft forever
44: eth0@if45: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc
 noqueue state UP
 link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff
 inet 172.17.0.2/16 brd 172.17.255.255 scope global eth0
 valid_lft forever preferred_lft forever

即使是 Linux 的/proc文件系统在 gVisor 容器中也会暴露得少得多:

$ docker container run --rm --runtime=runsc alpine ls -C /proc

1               filesystems     net             sys
cgroups         loadavg         self            thread-self
cmdline         meminfo         sentry-meminfo  uptime
cpuinfo         mounts          stat            version

再次将其与正常的 Linux 容器进行比较:

$ docker container run --rm alpine ls -C /proc

1                  fb                 mdstat             stat
acpi               filesystems        meminfo            swaps
asound             fs                 misc               sys
bootconfig         interrupts         modules            sysrq-trigger
buddyinfo          iomem              mounts             sysvipc
bus                ioports            mpt                thread-self
cgroups            irq                mtd                timer_list
cmdline            kallsyms           mtrr               tty
consoles           kcore              net                uptime
cpuinfo            key-users          pagetypeinfo       version
crypto             keys               partitions         version_signature
devices            kmsg               pressure           vmallocinfo
diskstats          kpagecgroup        schedstat          vmstat
dma                kpagecount         scsi               zoneinfo
driver             kpageflags         self
dynamic_debug      loadavg            slabinfo
execdomains        locks              softirqs

除了更加隔离之外,在 gVisor 容器内部的体验非常有趣,因为它看起来更像是您在一个隔离环境中期望看到的样子。像 gVisor 这样的沙箱运行时通过在应用程序和底层内核之间提供更强大的隔离屏障,为安全运行不受信任的工作负载提供了很多潜力。

总结

这只是 Docker 的一些更高级概念的快速导览。希望它扩展了你对幕后发生的事情的知识,并为你继续探索打开了一些途径。当你建立和维护一个生产平台时,这些背景知识应该为你提供足够广阔的视角,让你知道在需要自定义系统时从哪里开始。

^(1) 引用 OCI 网站的话:“开放容器倡议(OCI)是一个轻量级的开放治理结构(项目),由 Linux 基金会支持,旨在围绕容器格式和运行时创建开放行业标准。OCI 由 Docker、CoreOS 和容器行业的其他领导者于 2015 年 6 月 22 日推出。”

第十二章:扩展的景观

可用于与 Linux 容器交互的工具的格局在不断发展,特别是随着多年来 Kubernetes 的显著采纳。

在本章中,我们将快速浏览一些受 Docker 启发但通常专注于改善特定用例的工具。这并不意味着要全面列出,而是简单地让您体验一些可供探索的类别和选项。

客户端工具

在本节中,我们将介绍三个命令行工具:nerdctlpodmanbuildah。所有这些工具对于熟悉 Docker 及其常见工作流程的人都可能很有用。

nerdctl

尽管在许多基于containerd环境中默认安装了crictl^(1),nerdctl是一个易于使用的 Docker 兼容 CLI,适用于containerd,值得一试。这意味着nerdctl可以为使用 Docker 但需要支持未运行 Docker 守护程序的containerd系统的人和脚本提供非常简便的迁移路径。

举个快速的例子,如果你用我们在“Kind”中讨论过的kind快速启动一个小型 Kubernetes 集群,你将会得到一个基于containerd的 Kubernetes 集群,但它并不直接兼容docker命令行界面:

$ kind create cluster --name nerdctl
Creating cluster "nerdctl" …
…

$ docker container exec -ti nerdctl-control-plane /bin/bash

现在你应该已经在kind/Kubernetes 容器内部了。

注意

在接下来的curl命令中,你必须确保下载适合你架构的正确版本。你需要用你的系统架构${ARCH}替换为amd64arm64。同时,可以尝试下载最新版本nerdctl

一旦你编辑了以下的curl命令,并将其重新组装成一行,你就可以下载并解压nerdctl客户端,然后尝试几个命令:

root@nerdctl-control-plane:/# curl -s -L \
  "https://github.com/containerd/nerdctl/releases/download/v0.23.0/\
nerdctl-0.23.0-linux-${ARCH}.tar.gz" -o /tmp/nerdctl.tar.gz

root@nerdctl-control-plane:/# tar -C /usr/local/bin -xzf /tmp/nerdctl.tar.gz

root@nerdctl-control-plane:/# nerdctl namespace list

NAME      CONTAINERS    IMAGES    VOLUMES    LABELS
k8s.io    18            24        0

root@nerdctl-control-plane:/# nerdctl --namespace k8s.io container list

CONTAINER ID IMAGE                                  … NAMES
07ae69902d11 registry.k8s.io/pause:3.7              … k8s://kube-system/core…
0b241db0485f registry.k8s.io/coredns/coredns:v1.9.3 … k8s://kube-system/core…
…

root@nerdctl-control-plane:/# nerdctl --namespace k8s.io container run --rm \
                              --net=host debian sleep 5

docker.io/library/debian:latest:  resolved       |+++++++++++++++++++++++++++|
index-sha256:e538…4bff:           done           |+++++++++++++++++++++++++++|
manifest-sha256:9b0e…2f7d:        done           |+++++++++++++++++++++++++++|
config-sha256:d917…d33c:          done           |+++++++++++++++++++++++++++|
layer-sha256:f606…5ddf:           done           |+++++++++++++++++++++++++++|
elapsed: 6.4 s                    total:  52.5 M (8.2 MiB/s)

root@nerdctl-control-plane:/# exit

在大多数情况下,nerdctl几乎可以无需修改地使用docker命令。唯一可能突出的变化是通常需要提供一个命名空间值。这是因为containerd提供了完全命名空间化的 API,我们需要指定我们想要与之交互的命名空间。

一旦退出kind容器,你可以继续删除它:

$ kind delete cluster --name nerdctl

Deleting cluster "nerdctl" …

podman 和 buildah

podmanbuildah是 Red Hat 提供的一组工具,早期创建的目的是提供一个不依赖于像 Docker 那样的守护进程的容器工作流程。它在 Red Hat 社区内被广泛使用,并重新思考了镜像构建和容器运行管理的方式。

小贴士

您可以在 Red Hat 博客上找到一个适合 Docker 用户的podmanbuildah介绍

$ kind create cluster --name podman
Creating cluster "podman" …
…

$ docker container exec -ti podman-control-plane /bin/bash
提示

安装和使用kind的概述可以在“Kind”中找到。

现在您应该已经进入kind/Kubernetes 容器:

root@podman-control-plane:/# apt update
Get:1 http://security.ubuntu.com/ubuntu jammy-security InRelease [110 kB]
…

root@podman-control-plane:/# apt install -y podman
Reading package lists… Done
…

root@podman-control-plane:/# podman container run -d --rm \
                             --name test debian sleep 120
9b6b333313c0d54e2da6cda49f2787bc5213681d90dac145a9f64128f3e18631

root@podman-control-plane:/# podman container list

CONTAINER ID  IMAGE                            COMMAND    …  NAMES
548a2f709785  docker.io/library/debian:latest  sleep 120  …  test

root@podman-control-plane:/# podman container stop test
test

docker(与 Docker 守护程序接口)和nerdctl(与containerd接口)不同,podman跳过容器引擎,直接与底层容器运行时(如runc)接口。

尽管podman build也可以用于构建容器,但buildah提供了一个高级接口用于图像构建,可以脚本化整个图像构建过程,并消除对Dockerfile格式(或podman称为的Containerfile)的依赖。

我们在这里不会深入探讨buildah的细节,但您可以在kind容器中尝试一个非常简单的示例,如果您对传统的Dockerfile方法的替代方法或 BuildKit 的新替代方法(通过LBB 接口)感兴趣,您可以通过GitHubRed Hat 博客在线阅读更多关于buildah的信息。

要在kind容器中尝试运行buildah脚本,请继续运行以下命令:

root@podman-control-plane:/# cat > apache.sh <<"EOF"
#!/usr/bin/env bash

set -x

ctr1=$(buildah from "${1:-fedora}")

## Get all updates and install the apache server
buildah run "$ctr1" -- dnf update -y
buildah run "$ctr1" -- dnf install -y httpd

## Include some buildtime annotations
buildah config --annotation "com.example.build.host=$(uname -n)" "$ctr1"

## Run our server and expose the port
buildah config --cmd "/usr/sbin/httpd -D FOREGROUND" "$ctr1"
buildah config --port 80 "$ctr1"

## Commit this container to an image name
buildah commit "$ctr1" "${2:-myrepo/apache}"
EOF

root@podman-control-plane:/# chmod +x apache.sh
root@podman-control-plane:/# ./apache.sh

++ buildah from fedora
+ ctr1=fedora-working-container-1
+ buildah run fedora-working-container-1 -- dnf update -y
…
Writing manifest to image destination
Storing signatures
037c7a7c532a47be67f389d7fd3e4bbba64670e080b120d93744e147df5adf26

root@podman-control-plane:/# exit

退出kind容器后,可以继续删除它:

$ kind delete cluster --name podman

Deleting cluster "podman" …

一体化开发工具

尽管 Docker Desktop 是一个非常有用的工具,但 Docker 的许可证变更和更广泛的技术格局导致一些人和组织寻找替代工具。在本节中,我们将简要介绍 Rancher Desktop 和 Podman Desktop 以及它们如何提供部分 Docker Desktop 功能,并带来一些自己的有趣特性。

Rancher Desktop

Rancher Desktop专为提供与 Docker Desktop 非常相似的体验而设计,重点是 Kubernetes 集成。它使用k3s提供认证的轻量级 Kubernetes 后端,并且可以使用containerddockerdmoby)作为容器运行时。

提示

在尝试运行 Rancher Desktop 之前,您可能应该退出 Docker(和/或 Podman)Desktop,因为它们都会启动一个消耗系统资源的虚拟机。

下载、安装和启动 Rancher Desktop 后,您将拥有一个本地的 Kubernetes 集群,默认情况下使用containerd,并可以通过nerdctl进行交互。

注意

Rancher Desktop 安装nerdctl二进制文件的确切位置可能会根据您使用的操作系统而有所不同。您应该首先确保使用的是与 Rancher Desktop 打包的版本。

$ ${HOME}/.rd/bin/nerdctl --namespace k8s.io image list

REPOSITORY     TAG     IMAGE ID      …  PLATFORM     SIZE       BLOB SIZE
moby/buildkit  v0.8.3  171689e43026  …  linux/amd64  119.2 MiB  53.9 MiB
moby/buildkit  <none>  171689e43026  …  linux/amd64  119.2 MiB  53.9 MiB
…

当您完成使用 Rancher Desktop 后,请不要忘记退出;否则虚拟机将继续运行并消耗额外的资源。

Podman Desktop

Podman Desktop专注于提供一个无守护进程的容器工具,仍然能够提供开发者在所有主要操作系统上习惯的无缝体验。

提示

在尝试 Podman Desktop 之前,您应该可能退出正在运行的 Docker(和/或 Rancher)Desktop,因为它们都会启动一个会消耗系统资源的虚拟机。

下载、安装并启动 Podman Desktop 后,您将在主页选项卡上看到一个应用程序窗口。如果 Podman Desktop 在您的系统上未检测到podman CLI,则会提示您通过一个标有“安装”的按钮安装它。这应该会引导您完成podman客户端的安装。当未启动 Podman Desktop VM(可通过podman machine命令从命令行控制)时,请单击“运行 Podman”开关,然后稍等片刻。开关应该会消失,您会看到“Podman 正在运行”的消息。

注意

Podman Desktop 安装podman二进制文件的确切位置可能会有所不同,这取决于您使用的操作系统。您应该首先确保使用的是通过 Podman Desktop 安装的版本。

若要测试系统,请尝试这样做:

$ podman run quay.io/podman/hello

!… Hello Podman World …!

 .--"--.
 / -     - \
 / (O)   (O) \
 ~~~| -=(,Y,)=- |
 .---. /   \   |~~
 ~/  o  o \~~~~.----. ~~
 | =(X)= |~  / (O (O) \
 ~~~~~~~  ~| =(Y_)=-  |
 ~~~~    ~~~|   U      |~~

Project:   https://github.com/containers/podman
Website:   https://podman.io
Documents: https://docs.podman.io
Twitter:   @Podman_io

当您完成探索 Podman Desktop 后,可以转到“偏好设置”选项卡,选择“资源”→“Podman”→“Podman Machine”,然后单击“停止”按钮关闭虚拟机。

此时,您可以继续退出 Podman Desktop 应用程序。

提示

您还可以使用podman machine startpodman machine stop命令启动和停止 Podman VM。

总结

Docker 在技术历史上的地位已经得到确认。毫无疑问,Docker 的引入扩展了现有的 Linux 容器技术,并通过镜像格式使概念和技术变得全球工程师都可以接触到。

我们可以辩论关于今天是否比 Linux 容器和 Docker 出现之前更好,并且我们可以讨论哪些工具和工作流程更好,但最终,这取决于每个工具的使用方式以及这些工作流程的设计。

没有工具可以奇迹般地解决所有问题,任何工具都可能实施得如此糟糕,以至于比之前更糟。这就是为什么花费大量时间思考工作流程如此重要的原因,至少从三个角度考虑:首先,我们需要工作流程支持哪些输入和输出?其次,对于每天需要使用它或仅需每年使用一次的人员来说,工作流程有多容易?第三,确保系统始终平稳和安全运行的人员需要运行和维护工作流程有多容易?

一旦你对自己想要实现的目标有了清晰的认识,你就可以开始选择能够帮助你实现这些目标的工具。

^(1) 完整网址:https://github.com/kubernetes-sigs/cri-tools/blob/master/docs/crictl.md

第十三章:容器平台设计

在将任何技术投入生产时,通过设计一个能够抵御不可避免出现的意外问题的弹性平台,您通常可以最大化技术的应用价值。Docker 可以是一个强大的工具,但需要对整个平台的细节进行关注。作为一个正在经历快速增长的技术,它很可能在构成您的容器平台的各个组件之间产生令人沮丧的错误。

如果您不仅仅是将 Docker 部署到现有环境中,而是花时间构建一个以 Docker 作为核心组件之一的精心设计的容器平台,您可以享受基于容器的工作流程的诸多好处,同时保护自己免受在这种高速项目中可能存在的某些尖锐边缘的影响。

像所有其他技术一样,Docker 并不能神奇地解决所有问题。要实现其真正的潜力,组织必须对何时以及如何使用它做出非常慎重的决策。对于小项目来说,可以简单地使用 Docker;然而,如果您计划支持一个能够按需扩展的大型项目,那么设计应用程序和平台非常关键。这确保您可以最大化对技术投资的回报。花时间有意识地设计您的平台,还将使您能够随着时间的推移轻松修改生产工作流程。一个精心设计的容器平台和部署流程将尽可能地简单明了,同时仍支持满足所有技术和合规要求的必要功能。经过深思熟虑的设计将有助于确保您的软件运行在一个能够轻松升级的动态基础上,以适应技术和公司流程的发展。

在这一章中,我们将探讨两个开放文档,“十二要素应用”“反应式宣言”(作为 “反应式原则” 的附属文档),并讨论它们与 Docker 及构建健壮容器平台的关系。这两个文档包含了许多思想,应该帮助您设计和实施容器平台,并确保更多的弹性和可支持性。

十二要素应用

在 Docker 发布之前的 2011 年 11 月,Heroku 的联合创始人亚当·威金斯及其同事发布了一篇名为 “十二要素应用” 的文章。该文档从 Heroku 工程师的经验中提炼出了一系列 12 个实践,用于设计能够在现代基于容器的 SaaS 环境中蓬勃发展的应用程序。

虽然不是必需的,但是以这 12 个步骤为基础构建的应用程序是 Docker 工作流的理想候选者。在本章中,我们将探讨以下每个步骤,并解释为什么这些做法可以在多方面帮助改进您的开发周期:

  • 代码库

  • 依赖关系

  • 配置

  • 后备服务

  • 构建、发布、运行

  • 进程

  • 端口绑定

  • 并发性

  • 可处置

  • 开发/生产一致性

  • 日志

  • 管理流程

代码库

一个代码库由版本控制跟踪。

在任何给定时间可能会有多个应用程序实例在运行,但它们都应该来自同一个代码仓库。给定应用程序的每个 Docker 镜像都应该由一个单一的源代码仓库构建,该仓库包含构建 Linux 容器所需的所有代码。这确保了代码可以轻松重建,并且所有第三方要求在仓库中都有明确定义,并且在构建过程中将自动拉取。

这意味着构建应用程序不应该需要从多个源代码库中拼凑代码。这并不意味着你不能依赖来自另一个仓库的构件。但是这意味着应该有一个清晰的机制来确定在构建应用程序时哪些代码片段已经被包含进去了。如果构建应用程序需要拉取多个源代码库并将这些片段拼凑在一起,那么 Docker 简化依赖管理的能力就显得不太有用。如果你必须知道一个魔法咒语才能让构建正确工作,那么它也不太可重复。

一个很好的测试可能是给你公司的新开发者一台干净的笔记本电脑和一段说明文字,然后看看他们是否能在一个小时内成功构建你的应用程序。如果不能,那么这个流程可能需要进一步优化和简化。

依赖关系

明确声明和隔离依赖项

永远不要依赖于某种依赖项将通过其他途径(如操作系统安装)提供。你的应用程序需要的任何依赖项应该在代码库中被明确定义,并且由构建过程拉取。这将有助于确保你的应用程序在部署时能够运行,而不依赖于其他人或过程安装的库。这在容器内尤为重要,因为容器的进程与主机操作系统的其余部分隔离,并且通常无法访问主机的内核和容器镜像的文件系统之外的任何内容。

Dockerfile 和类似 Node 的 package.json 或 Ruby 的 Gemfile 这样的语言相关配置文件应定义应用程序所需的每一个非外部依赖项。这确保了你的镜像能在部署到任何系统时正确运行。不再出现你尝试在生产环境中部署和运行应用程序,却发现重要的库缺失或安装的版本错误的情况。这种模式具有极大的可靠性和可重复性优势,并对系统安全性有非常积极的影响。如果为了修复安全问题,你更新了容器化应用程序所使用的 OpenSSL 或 libyaml 库,那么可以确保无论在哪里部署该特定应用程序,它始终会使用该版本运行。

还需要注意的是,许多 Docker 基础镜像比它们实际需要的要大。记住,你的应用程序进程将在共享内核上运行,你镜像中唯一需要的文件是进程运行所需的那些文件。基础镜像如此易于获取固然是好事,但有时可能掩盖了隐藏的依赖关系。尽管人们经常从 Alpine、Ubuntu 或 Fedora 的最小安装开始,这些镜像仍包含大量操作系统文件和你的进程几乎肯定不需要的应用程序,或者你的应用程序可能在使用一些你没有意识到的文件,例如在 Alpine 中使用 musl 系统库与其他许多基础镜像中使用的 glibc 系统库来编译你的应用程序。即使在容器化你的应用程序时,你也需要充分了解你的依赖关系。此外,还要考虑在你的镜像中是否包含了哪些支持工具,因为在使调试变得更加简单的同时,也可能增加你的应用程序和环境的安全攻击面。

理清镜像内所需文件的一个好方法是比较“小型”基础镜像与使用像 Go 或 C 这样的语言编写的静态链接程序镜像。这些应用程序可以设计为直接在 Linux 内核上运行,而不需要任何额外的库或文件。

为了更好地阐明这一点,回顾一下 “Keeping Images Small” 中的练习可能会有所帮助,我们在那里探讨了一个这样的超轻量级容器 spkane/scratch-helloworld,然后稍微深入地研究了底层文件系统,并将其与流行的 alpine 基础镜像进行了比较。

除了注意如何管理镜像中的文件系统层,保持镜像仅包含最基本的必需品也是保持一切简洁并且快速执行docker image pull命令的另一种好方法。使用解释性语言编写的应用程序将需要更多文件,因为通常需要安装大型运行时和依赖图,但是您应尽量保持最小化的基础层以便为您的用例进行推理。Docker 帮助您打包它们,但仍然需要您负责推理它们。

配置

将配置存储在环境变量中,而不是检入代码库的文件中。

这使得在不同环境(如测试和生产)中部署相同的代码库变得非常简单,而无需在代码中维护复杂的配置或为每个环境重新构建容器。通过将像数据库名称和密码等特定于环境的信息保持在源代码库之外,可以使代码库更加干净。更重要的是,这意味着您不会将部署环境的假设编码到存储库中,因此可以非常轻松地将应用程序部署到任何可能有用的地方。您还希望能够测试将要发布到生产环境的相同镜像。如果您必须为每个环境构建已经烘焙好了所有配置的镜像,那么这是做不到的。

如第四章所讨论的,您可以通过启动使用-e命令行参数的docker container run命令来实现这一点。使用-e APP_ENV= *production*告诉 Docker 在新启动的容器内将环境变量APP_ENV设置为值production

举个实际例子,假设我们拉取了安装了Rocket.Chat适配器的聊天机器人 Hubot 的镜像。我们会发出以下类似的命令来让它运行:

$ docker container run \
  --rm --name hubot -d \
  -e ENVIRONMENT="development" \
  -e ROCKETCHAT_URL='rocketchat:3000' \
  -e ROCKETCHAT_ROOM='general' \
  -e RESPOND_TO_DM=true \
  -e ROCKETCHAT_USER=bot \
  -e ROCKETCHAT_PASSWORD=bot \
  -e ROCKETCHAT_AUTH=password \
  -e BOT_NAME=bot \
  -e EXTERNAL_SCRIPTS=hubot-pugme,hubot-help \
  docker.io/rocketchat/hubot-rocketchat:latest

在这里,我们在创建容器时传递了一整套环境变量。当进程在容器中启动时,它将访问这些环境变量,以便在运行时正确配置自身。这些配置项现在是一个可以在运行时注入的外部依赖项。

注意

将这些数据提供给容器的其他方式还有很多,包括使用像etcdconsul这样的键值存储。环境变量只是一个作为大多数项目非常好的起点的通用选项。它们是容器配置的简单途径,因为它们得到了平台和所有常用编程语言的广泛支持。它们还有助于应用程序的可观察性,因为可以轻松地使用docker container inspect来检查配置。

对于像 hubot 这样的 Node.js 应用程序,您可以编写以下代码,根据这些环境变量做出决策:

switch(process.env.ENVIRONMENT){
        case 'development':
            console.log('[INFO] Running in development');

        case 'staging':
            console.log('[INFO] Running in staging');

        case 'production':
            console.log('[INFO] Running in production');

        default:
            console.log('[WARN] Environment value is unknown');
    }
注意

用于将配置数据传递到容器中的确切方法将因您为项目选择的具体工具而异,但几乎所有工具都会轻松确保每次部署包含该环境的正确设置。

将特定配置信息从源代码中分离出来,可以轻松地在多个环境中部署完全相同的容器,无需修改,也无需将敏感信息提交到源代码库中。关键是,在部署到生产环境之前,通过允许在所有环境中使用相同的镜像来彻底测试容器映像。

$ docker container stop hubot
提示

如果需要管理需要提供给您的容器的秘密的过程,您可能需要查看 docker secret 命令的文档,该命令适用于 Docker Swarm 模式,以及 HashiCorp 的 Vault

后备服务

将后备服务视为附加资源。

本地数据库不比第三方服务更可靠,应以此为对待。应用程序应优雅地处理所附资源的丢失。通过在应用程序中实施优雅降级,并永不假设包括文件系统空间在内的任何资源可用,您可以确保即使外部资源不可用时,应用程序仍将尽可能执行其许多功能。

Docker 并不直接提供这种功能,尽管编写健壮的服务始终是个好主意,但在使用容器时更为重要。在使用容器时,通常通过水平扩展和滚动部署实现高可用性,而不是依赖于传统虚拟机上长时间运行进程的实时迁移。这意味着服务的特定实例经常会随时间的推移而来来去去,而你的服务应能优雅地处理这一点。

另外,由于 Linux 容器具有有限的文件系统资源,您不能简单地依赖于某些本地存储的可用性。您需要计划进入应用程序的依赖项,并显式地处理它。

构建、发布、运行

严格分离构建和运行阶段。

构建代码,使用正确的配置发布,然后部署它。这确保您控制整个过程,并可以执行任何单个步骤,而不会触发整个工作流程。通过确保每个步骤都在独立的进程中自包含,您可以缩短反馈循环,并更快地对部署流程中的任何问题做出反应。

在设计 Docker 工作流程时,你希望清晰地分隔部署过程中的每个步骤。可以有一个单独的按钮来构建容器,测试它,然后部署它,这是完全可以接受的,前提是你信任你的测试过程,但你不希望被迫重新构建容器,只是为了将其部署到另一个环境。

Docker 在这一领域很好地支持了 12 因素理想,因为镜像注册表提供了在构建镜像和将其推送到生产环境之间进行清晰交接的点。如果你的构建过程生成镜像并将其推送到注册表,那么部署就可以简单地将镜像拉到服务器上并运行它。

进程

将应用程序作为一个或多个无状态进程执行。

所有共享数据必须通过有状态的后备存储访问,以便可以轻松地重新部署应用程序实例而不会丢失任何重要的会话数据。你不希望在短暂的容器或其进程的内存中保留任何关键状态。容器化应用程序应始终被视为短暂的。真正动态的容器环境需要在瞬间销毁和重新创建容器的能力。这种灵活性有助于支持现代敏捷工作流所需的快速部署周期和故障恢复。

尽可能地,最好编写不需要保持状态时间超过处理和响应单个请求所需时间的应用程序。这样做可以确保停止应用程序池中的任何容器的影响非常小。当必须保持状态时,最佳方法是使用像 Redis、PostgreSQL、Memcache 甚至 Amazon S3 这样的远程数据存储,具体取决于你的弹性需求。

端口绑定

通过端口绑定导出服务。

你的应用程序需要通过特定的端口可寻址。应用程序应直接绑定到端口以公开服务,而不应依赖外部守护程序(如inetd)来处理这些事务。确保当你连接到该端口时,你正在与你的应用程序通信。大多数现代网络平台都能直接绑定到端口并为其自己的请求提供服务。

要从你的容器中暴露一个端口,如在第四章中讨论的,你可以使用docker container run命令,该命令使用--publish命令行参数。例如,使用--publish mode=ingress,published=80,target=8080将告诉 Docker 代理主机端口 80 上容器的端口 8080。

我们在“保持镜像小”中讨论的静态链接的 Go Hello World 容器是一个很好的例子,因为容器中除了用于提供内容的应用程序外,没有包含任何其他 Web 服务器。我们不需要包含任何额外的 Web 服务器,这将需要进一步的配置,引入额外的复杂性,并增加系统中潜在故障点的数量。

并发性

通过进程模型进行横向扩展。

设计您的应用程序以支持并发和横向扩展。增加现有实例的资源可能会很困难且难以逆转。根据规模波动添加和删除实例要容易得多,这有助于保持基础设施的灵活性。在新服务器上启动另一个容器的成本非常低廉,相比于为底层虚拟或物理系统添加资源所需的工作和费用,设计横向扩展使平台能够更快地响应资源需求的变化。

举个例子,在第十章中,你看到了如何通过运行以下命令轻松地使用 Docker Swarm 模式来扩展服务:

$ docker service scale myservice=8

这就是像 Docker Swarm 模式、Mesos 和 Kubernetes 这样的工具真正开始发挥作用的地方。一旦您实施了具有动态调度程序的 Docker 集群,就可以很容易地将三个容器实例添加到集群中以应对负载增加,然后在负载开始减少时轻松地从集群中移除两个应用程序实例。

可处置性

通过快速启动和优雅关闭来最大化鲁棒性。

服务应设计为短暂的。在讨论容器的外部状态时,我们已经稍微提到了这一点。对动态水平扩展、滚动部署和意外问题的良好响应要求应用程序能够快速且轻松地启动或关闭。服务应能够从操作系统接收SIGTERM信号并且能够自信地处理硬件故障。最重要的是,我们不应该关心应用程序中的任何容器是否正在运行。只要能够提供服务,开发者就无需担心系统中任何单个组件的健康状态。如果单个节点表现不佳,关闭它或重新部署它应该是一个简单的决定,不需要长时间的规划会议和对其余集群健康状态的担忧。

如第七章所述,Docker 在停止或杀死容器时向其发送标准的 Unix 信号;因此,任何容器化应用程序都可以检测这些信号并采取适当的步骤以优雅地关闭。

开发/生产环境的一致性

尽可能使开发、测试和生产环境尽可能相似。

所有环境中构建、测试和部署服务应使用相同的流程和工件。同一组人员应在所有环境中进行工作,并且环境的物理特性应尽可能相似。可重复性非常重要。几乎在生产中发现的任何问题都指向流程失败。生产环境与暂存环境分歧的每个领域都是引入风险的领域。这些不一致会使您对可能发生在生产环境中的某些问题变瞎,直到为时已晚才能主动处理它们。

在许多方面,这些建议基本上重复了早期的一些建议。然而,这里的具体观点是,任何环境分歧都会引入风险,尽管这些差异在许多组织中很常见,但在容器化环境中却不太必要。Docker 服务器通常可以创建为在所有环境中都相同,环境配置更改通常只应影响服务连接到的端点,而不会明确更改应用程序的行为。

日志

将日志视为事件流。

服务不应关注路由或存储日志。相反,事件应被流式传输,无缓冲,到STDOUTSTDERR以供托管进程处理。在开发中,STDOUTSTDERR可以轻松查看,而在暂存和生产中,流可以路由到任何地方,包括中央日志服务。不同环境对日志处理有不同的例外情况。这种逻辑不应硬编码到应用程序中。将所有内容流式传输到STDOUTSTDERR使得顶级进程管理器能够通过最适合环境的方法处理日志,从而使应用程序开发人员能够专注于核心功能。

在第六章中,我们讨论了docker container logs命令,该命令收集容器的STDOUTSTDERR的输出并记录为日志。如果将日志写入容器文件系统中的随机文件,您将无法轻松访问它们。还可以配置 Docker 将日志发送到本地或远程日志系统,使用类似rsyslogjournaldfluentd的工具。

如果您在服务器上使用进程管理器或初始化系统,如systemdupstart,通常很容易将所有进程输出定向到STDOUTSTDERR,然后让您的进程监视器捕获它们并将它们发送到远程日志主机。

管理流程

将管理任务作为一次性流程运行。

一次性管理任务应通过与应用程序使用相同的代码库和配置来运行。这有助于避免同步问题和代码/架构漂移问题。管理工具往往存在于一次性脚本中,或者完全存在于不同的代码库中。在应用程序的代码库内构建管理工具并利用相同的库和函数执行所需的工作,这样做可以显著提高这些工具的可靠性,确保它们利用与应用程序核心功能相同的代码路径。

这意味着你不应该依赖类似于cron的随机脚本来执行管理和维护功能。相反,应将所有这些脚本和功能包含在你的应用程序代码库中。假设这些不需要在你的应用程序的每个实例上运行,你可以启动一个特殊的短暂容器,或者在需要运行维护作业时使用docker container exec与现有容器一起。这个命令可以触发所需的作业,在某处报告其状态,然后退出。

十二要素总结

虽然“十二要素应用”并非专门为 Docker 而写的宣言,但几乎所有这些建议都可以应用于在 Docker 平台上编写和部署应用程序。部分原因是因为该文章深刻影响了 Docker 的设计,部分原因是因为宣言本身明确了现代软件架构师推广的许多最佳实践。

响应式宣言

与“十二要素应用”并驾齐驱的是另一份相关文档,由 Typesafe 联合创始人兼 CTO Jonas Bonér 在 2013 年 7 月发布,题为“响应式宣言”。Jonas 最初与一小组贡献者共同工作,以明确阐述一份关于如何预测性地应对各种交互形式(包括事件、用户、负载和故障)的宣言,讨论了应用程序弹性的期望如何在过去几年里演变,以及应该如何设计应用程序以可预测地对各种交互做出反应。

“响应式宣言”指出,“响应式系统”具备响应性、弹性、伸缩性和消息驱动性。

响应性

如果可能,系统应及时响应

总体而言,这意味着应用程序应该非常快速地响应请求。用户根本不想等待,而且很少有充分的理由让他们等待。如果你有一个容器化的服务来渲染大型 PDF 文件,设计它可以立即响应“作业已提交”的消息,这样用户可以继续他们的工作,然后提供一个消息或横幅,告知他们作业何时完成以及从哪里下载生成的 PDF。

弹性

系统在面对故障时保持响应

当你的应用由于任何原因失败时,如果变得无响应,情况将会变得更糟。最好是优雅地处理失败,并动态降低应用的功能,甚至向用户显示简单而清晰的问题消息,同时内部报告问题。

弹性

系统在不同工作负载下保持响应性。

使用 Docker,你通过动态部署和下架容器来实现这一点,以便在需求和负载波动时,你的应用始终能够快速处理服务器请求,而不需要部署大量未充分利用的资源。

消息驱动

响应式系统依赖于异步消息传递来建立组件之间的边界,确保松耦合、隔离和位置透明性。

虽然 Docker 并未直接处理这一点,但这里的想法是,有时应用程序可能会变得繁忙或不可用。如果你在服务之间利用异步消息传递,可以帮助确保你的服务不会丢失请求,并且请求将尽快被处理。

总结

在“响应式宣言”中的四个设计特性都要求应用开发者设计优雅的降级,并在其应用中明确划分责任。通过将所有依赖视为设计良好的、连接的资源,动态容器环境允许你轻松地在应用堆栈中保持 N+2 的状态,可靠地扩展环境中的个别服务,并快速替换不健康的节点。

一个服务的可靠性取决于其最不可靠的依赖,因此将这些理念融入到平台的每个组件中至关重要。

“响应式宣言”中的核心思想与“十二要素应用”及 Docker 工作流非常契合。这些文档成功地总结了关于如何在行业中满足新期望的许多重要讨论。Docker 工作流为在任何组织中以完全可接近的方式实施这些思想提供了实用的方法。

第十四章:结论

到目前为止,你已经对 Docker 生态系统有了扎实的了解,并且看到了 Docker 和 Linux 容器如何能够使你和你的组织受益的许多示例。我们试图列出一些常见的陷阱,并传授多年来在生产中运行 Linux 容器时积累的一些智慧。我们的经验表明,Docker 的承诺是完全可以实现的,作为结果,我们的组织也看到了显著的好处。像其他强大的技术一样,Docker 也不是没有妥协的,但总体结果对我们、我们的团队和我们的组织都是极大的积极影响。如果你将 Docker 工作流程实施并整合到你组织已有的流程中,有充分的理由相信你也能从中获得显著的益处。

在本章中,我们将花一点时间考虑 Docker 在技术景观中不断演变的位置,然后快速回顾 Docker 设计帮助你解决的问题以及它带来的一些强大功能。

未来之路

毫无疑问,容器将长期存在,但有些人长期以来一直预测 Docker 的最终消亡。其中很大一部分原因只是因为 Docker 这个词在很多人心目中代表了 很多不同的东西.^(1) 你是在谈论那家在 2019 年被卖给 Mirantis 的公司,两年后重组后报告的每年 5000 万美元的营收吗?还是 docker 客户端工具,其源代码可以被 下载,任何需要的人都可以修改并构建?这很难说。人们经常喜欢试图预测未来,但现实往往隐藏在中间,埋藏在常常被忽视的细节之中。

2020 年,Kubernetes 宣布废弃 dockershim,并且随着 Kubernetes v1.24 版本的发布完全生效。当时,许多人认为这意味着 Docker 已经死了,但许多人忽略的一点是,Docker 始终主要是开发工具,而不是生产组件。当然,它可以出于各种原因在生产系统上使用,但其真正的力量在于能够将大部分软件打包和测试工作流程整合到一个统一的工具集中。Kubernetes 使用容器运行时接口(CRI),而 Docker 并未实现这一接口,因此需要他们维护另一个称为dockershim的包装软件来通过 CRI 支持使用 Docker 引擎。此公告并不意味着要表达 Docker 在生态系统中的位置,而只是为了简化维护一个大型志愿驱动的开源项目。Docker 可能无法在您的 Kubernetes 服务器上运行,但在大多数情况下,这对软件的开发和发布周期没有任何影响。除非您是一个使用docker CLI 直接查询运行在 Kubernetes 节点上的容器的 Kubernetes 操作员,否则在此过渡期间,您不太可能注意到任何变化。

此外,正如事实证明的那样,Docker 的母公司已经开发并继续支持一个名为cri-dockerd的新的 shim,允许那些需要支持此工作流程的人继续与 Docker 进行接口交互。

有趣的是,Docker 还在进入非容器技术领域多样化,比如WebAssembly(Wasm),这可以补充容器技术,同时改善开发者体验。

所以,作为一款开发者友好的工具集,Docker 很可能会长期存在,但这并不意味着生态系统中没有其他工具可以补充甚至取代它,如果这是你想要或需要的。像 OCI 这样存在的各种标准的美妙之处在于它们被广泛采纳,许多工具可以与其他工具生成和管理的相同镜像和容器进行互操作。

Docker 解决的挑战

在传统的部署工作流程中,通常需要大量步骤,这些步骤显著增加了团队的整体痛苦感。每增加一个应用程序部署过程中的步骤都会增加将其推向生产环境的风险。Docker 将工作流程与简单的工具集结合在一起,直接针对这些问题进行了处理。在这过程中,它直接指向了行业最佳实践,其自主的方法往往会导致更好的沟通和更加健壮的应用程序设计。

Docker 和 Linux 容器可以帮助缓解的一些具体问题包括以下几点:

  • 避免在部署环境之间出现显著的分歧。

  • 要求应用开发人员在应用程序中重新创建配置和日志记录逻辑。

  • 使用过时的构建和发布流程,需要在开发和运维团队之间进行多层次交接。

  • 需要复杂且脆弱的构建和部署过程。

  • 管理需要共享同一硬件的应用程序所需的不同依赖版本。

  • 在同一组织中管理多个 Linux 发行版。

  • 为每个投入生产的应用程序构建一次性部署流程。

  • 在处理补丁和审计安全漏洞时,需要将每个应用程序视为独特的代码库。

  • 和更多。

通过将注册表用作交接点,Docker 简化了操作团队与开发团队之间,或同一项目中的多个开发团队之间的沟通。通过将一个应用程序的所有依赖项捆绑成一个交付物,Docker 消除了开发人员想要在哪个 Linux 发行版上工作、他们需要使用哪些库的版本以及如何编译其资产或捆绑其软件的担忧。它将操作团队与构建过程隔离开来,并让开发人员负责他们的依赖关系。

Docker 工作流

Docker 的工作流帮助组织解决真正困难的问题——一些与 DevOps 过程旨在解决的相同问题。将 DevOps 成功地整合到公司的流程中的一个主要问题是,许多人不知道从何处开始。工具经常被错误地呈现为解决基本上是过程问题的方案。向环境中添加虚拟化、自动化测试、部署工具或配置管理套件通常只是改变了问题的性质,而没有提供解决方案。

将 Docker 只视为另一个工具,它做出了无法实现的承诺,这样的看法过于狭隘。Docker 的强大之处在于其自然的工作流程允许应用程序在一个生态系统内完成其整个生命周期,从概念到退役。与其他通常只针对 DevOps 管道的单个方面的工具不同,Docker 几乎改进了流程的每一个步骤。该工作流程通常是有主张的,但它简化了采纳 DevOps 核心原则的过程。它鼓励开发团队理解其应用程序的整个生命周期,并允许操作团队在同一运行时环境上支持更多种类的应用程序。这为各方面带来了价值。

最小化部署工件

Docker 减轻了常常由庞大的部署产物引发的痛苦。它通过将构建结果定义为单个构件,即 Docker 镜像来实现。镜像包含了您的 Linux 应用程序运行所需的一切,并在受保护的运行时环境中执行。容器可以轻松部署在现代 Linux 发行版上。但由于 Docker 客户端和服务器之间的清晰分离,开发人员可以在非 Linux 系统上构建其应用程序,同时仍能远程参与 Linux 容器环境。

利用 Docker,软件开发人员可以创建 Docker 镜像,从最初的概念验证开始,就可以在本地运行、通过自动化工具进行测试,并部署到集成或生产环境,而无需重新构建。这确保了在生产中启动的应用与经过测试的应用完全一致。在部署工作流程中不需要重新编译或重新打包任何内容,这显著降低了通常在大多数部署过程中固有的风险。同时,单一的构建步骤取代了通常涉及多个复杂组件编译和打包的容易出错的过程。

Docker 镜像还简化了应用程序的安装和配置。每个现代 Linux 内核上运行所需的软件都包含在镜像中,消除了传统环境中可能出现的依赖冲突。这使得在同一台服务器上运行依赖不同版本核心系统软件的多个应用程序变得轻而易举。

优化存储和检索

Docker 利用文件系统层次结构允许容器由多个镜像组合而成。通过仅传输重要变更,大大节省了许多部署过程中的时间和精力。它还通过允许多个容器基于相同的底层基础镜像,然后利用写时复制过程将新文件或修改后的文件写入顶层,节省了大量的磁盘空间。这也有助于通过允许在同一台服务器上启动更多应用的副本来扩展应用。

为了支持图像检索,Docker 利用镜像注册表来托管镜像。尽管表面看并不革命性,但注册表有助于根据 DevOps 原则明确划分团队责任。开发人员可以构建他们的应用程序,测试它,将最终镜像发布到注册表,并将镜像部署到生产环境,而运维团队可以专注于构建优秀的部署和集群管理工具,从注册表中提取镜像,确保其可靠运行,并确保环境健康。运维团队可以在构建时提供反馈给开发人员,并查看所有测试运行的结果,而不是等到应用程序被部署到生产环境时才发现问题。这使得两个团队都可以专注于各自擅长的领域,而无需进行多阶段的交接流程。

优势

随着团队对 Docker 及其工作流的信心增强,他们往往会意识到容器在所有软件组件和底层操作系统之间创建了一个强大的抽象层。组织可以开始摆脱为大多数应用程序创建定制物理服务器或虚拟机的做法,而是部署一批相同的 Docker 主机作为资源池,动态地将他们的应用程序部署到其中,这是以前无法想象的便捷方式。

当这些流程变化成功时,对软件工程组织的文化影响可能是显著的。开发人员对完整的应用程序堆栈拥有更多的所有权,包括通常由完全不同的团队处理的最小细节。与此同时,运维团队同时摆脱了尝试打包和部署复杂依赖树的负担,而几乎没有关于应用程序的详细知识。

在良好设计的 Docker 工作流中,开发人员编译和打包应用程序,这使得他们能够更轻松地确保应用程序在所有环境中正常运行,而不必担心运维团队引入的重大环境变化。与此同时,运维团队不再花费大部分时间支持应用程序,可以专注于为应用程序创建一个强大稳定的平台。这种动态创建了一个非常健康的环境,团队在应用交付过程中拥有更清晰的所有权和责任,团队之间的摩擦显著减少。

正确处理流程对公司和客户都有巨大的好处。通过消除组织内部的摩擦,提高了软件质量,优化了流程,使代码更快地交付到生产环境。所有这些都有助于组织更多地投入提供令人满意的客户体验和直接交付更广泛的业务目标。一个良好实施的基于 Docker 的工作流程可以极大地帮助组织实现这些目标。

最后的话

现在,你应该已经掌握了帮助你过渡到现代基于容器的构建和部署流程的知识。我们鼓励你在笔记本电脑或虚拟机上小规模地尝试使用 Docker,以进一步理解所有组件如何配合,然后考虑如何开始为你的组织实施。每家公司或个人开发者都会根据自己的需求和能力选择不同的路径。如果你想要关于如何开始的指导,我们发现先用简单的工具解决部署问题,然后再转向诸如服务发现和分布式调度等任务,通常会取得成功。Docker 可以变得非常复杂,但像任何事物一样,从简单开始通常会更有回报。

我们希望你现在可以运用这些新获得的知识,实现 Docker 和 Linux 容器为你自己带来的种种承诺。

^(1) 完整网址:https://www.tutorialworks.com/difference-docker-containerd-runc-crio-oci

posted @ 2025-11-14 20:38  绝不原创的飞龙  阅读(14)  评论(0)    收藏  举报