Docker-部署手册(全)

Docker 部署手册(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

微服务和容器已经成为必不可少的存在,在当今世界中,Docker 正成为可伸缩性的事实标准。将 Docker 部署到生产环境被认为是开发大规模基础设施的主要痛点之一,而在线文档的质量令人不满意。通过本书,您将接触到各种工具、技术和可用的解决方法,这些都是基于作者在自己的云环境中开发和部署 Docker 基础设施的真实经验。您将学到一切您想要了解的内容,以有效地扩展全球部署,并为自己构建一个具有弹性和可伸缩性的容器化云平台。

本书内容包括

第一章,容器-不只是另一个时髦词,探讨了部署服务的当前方法以及为什么容器和特别是 Docker 正在超越其他形式的基础设施部署。

第二章,动手实践,介绍了设置和运行基于 Docker 的小型本地服务的所有必要步骤。我们将介绍如何安装 Docker,运行它,并快速了解 Docker CLI。有了这些知识,我们将编写一个基本的 Docker 容器,并了解如何在本地运行它。

第三章,服务分解,介绍如何利用前一章的知识来创建和构建数据库和应用服务器容器的附加部分,以反映简单的分解微服务部署。

第四章,扩展容器,讨论了通过多个相同容器实例的水平扩展。我们将介绍服务发现,以及如何部署一个模块,使其对基础设施的其余部分透明,以及根据实现方式的各种利弊,快速了解水平节点扩展。

第五章,保持数据持久,介绍了容器的数据持久性。我们将介绍节点本地存储、瞬态存储和持久卷及其复杂性。我们还将花一些时间讨论 Docker 镜像分层和一些潜在问题。

第六章,高级部署主题,在集群中增加了隔离和消息传递,以增加服务的安全性和稳定性。本章还将涵盖 Docker 部署中的其他安全考虑及其权衡。

第七章,扩展的限制和解决方法,涵盖了您在超出基本 RESTful 服务需求时可能遇到的所有问题。我们将深入探讨您在默认部署中可能遇到的问题,以及如何通过最小的麻烦来解决它们,以及处理代码版本更改和更高级别的管理系统。

第八章,构建我们自己的平台,帮助我们在本章中构建我们自己的迷你平台即服务PaaS)。我们将涵盖从配置管理到在云环境中部署的一切内容,您可以使用它来启动您自己的云。

第九章,探索最大规模部署,涵盖了我们建立的内容,并延伸到 Docker 最大规模部署的理论和实际示例,还涵盖了读者应该留意的未来任何发展。

本书所需内容

在开始阅读本书之前,请确保您具备以下条件:

  • 基于 Intel 或 AMD 的 x86_64 机器

  • 至少 2GB 的 RAM

  • 至少 10GB 的硬盘空间

  • Linux(Ubuntu、Debian、CentOS、RHEL、SUSE 或 Fedora)、Windows 10、Windows Server 2016 或 macOS

  • 互联网连接

本书适合谁

本书面向系统管理员、开发人员、DevOps 工程师和软件工程师,他们希望获得使用 Docker 部署多层 Web 应用程序和容器化微服务的具体实践经验。它适用于任何曾经以某种方式部署服务并希望将其小规模设置提升到下一个数量级或者想要了解更多的人。

规范

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些样式的示例及其含义的解释。

文本中的代码词、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"如果您再次在浏览器中输入http://127.0.0.1:8080,您将看到我们的应用程序与以前一样工作!"

代码块设置如下:

    # Make sure we are fully up to date
    RUN apt-get update -q && \
    apt-get dist-upgrade -y && \
    apt-get clean && \
    apt-get autoclean

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

    # Make sure we are fully up to date
    RUN apt-get update -q && \
 apt-get dist-upgrade -y && \
    apt-get clean && \
    apt-get autoclean

任何命令行输入或输出都会按照以下方式书写:

$ docker swarm leave --force
Node left the swarm.

新术语重要单词以粗体显示。屏幕上显示的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:"为了下载新模块,我们将转到 文件 | 设置 | 项目名称 | 项目解释器。"

警告或重要提示会以这种方式出现。提示和技巧会以这种方式出现。

第一章:容器-不只是另一个时髦词汇

在技术领域,有时进步的跳跃很小,但就像容器化一样,这种跳跃是巨大的,完全颠覆了长期以来的实践和教学。通过这本书,我们将带你从运行一个微小的服务到使用 Docker 构建弹性可扩展的系统,Docker 是这场革命的基石。我们将通过基本模块进行稳定而一致的升级,重点关注 Docker 的内部工作,随着我们的继续,我们将尽量花费大部分时间在复杂部署及其考虑的世界中。

让我们来看看本章我们将涵盖的内容:

  • 什么是容器,为什么我们需要它们?

  • Docker 在容器世界中的地位

  • 以容器思维思考

容器的作用和意义

我们不能谈论 Docker 而不实际涵盖使其成为强大工具的想法。在最基本的层面上,容器是给定离散功能集的隔离用户空间环境。换句话说,这是一种将系统(或其中的一部分)模块化为更容易管理和维护的部分的方式,同时通常也非常耐用。

实际上,这种净收益从来都不是免费的,需要在采用和实施新工具(如 Docker)上进行一些投资,但这种变化在其生命周期内大大减少了开发、维护和扩展成本,为采用者带来了丰厚的回报。

在这一点上,你可能会问:容器究竟如何能够提供如此巨大的好处?要理解这一点,我们首先需要看一下在此类工具可用之前的部署情况。

在早期的部署中,部署服务的过程大致如下:

  1. 开发人员会编写一些代码。

  2. 运维团队会部署该代码。

  3. 如果部署中出现任何问题,运维团队会告诉开发人员修复一些东西,然后我们会回到第一步。

这个过程的简化看起来大致如下:

dev machine => code => ops => bare-metal hosts

开发人员必须等待整个过程为他们弹回,以尝试在出现问题时编写修复程序。更糟糕的是,运维团队通常必须使用各种古怪的魔法来确保开发人员给他们的代码实际上可以在部署机器上运行,因为库版本、操作系统补丁和语言编译器/解释器的差异都是高风险的失败,并且很可能在这个漫长的破坏-修补-部署尝试周期中花费大量时间。

部署演进的下一步是通过虚拟化裸机主机来改进这个工作流程,因为手动维护异构机器和环境的混合是一场完全的噩梦,即使它们只有个位数。早期的工具如chroot在 70 年代后期出现,但后来被(尽管没有完全)Xen、KVM、Hyper-V 等虚拟化技术所取代,这不仅减少了更大系统的管理复杂性,还为运维人员和开发人员提供了更一致、更计算密集的部署环境。

dev machine => code => ops => n hosts * VM deployments per host

这有助于减少管道末端的故障,但从开发人员到部署的路径仍然存在风险,因为虚拟机环境很容易与开发人员不同步。

从这里开始,如果我们真的试图找出如何使这个系统更好,我们已经可以看到 Docker 和其他容器技术是有机的下一步。通过使开发人员的沙盒环境尽可能接近生产环境,具有足够功能的容器系统的开发人员可以绕过运维步骤,确保代码在部署环境上能够运行,并防止由于多个团队交互的开销而导致的漫长重写周期:

dev machine => container => n hosts * VM deployments per host

随着运维主要在系统设置的早期阶段需要,开发人员现在可以直接将他们的代码从想法一直推送到用户,他们可以有信心地解决大部分问题。

如果你认为这是部署服务的新模式,那么现在理解为什么我们现在有了 DevOps 角色,为什么平台即服务(PaaS)设置如此受欢迎,以及为什么如此多的科技巨头可以在 15 分钟内通过开发人员的git push origin这样简单的操作对数百万人使用的服务进行更改,而无需与系统进行任何其他交互,是非常合理的。

但好处并不仅限于此!如果你到处都有许多小容器,如果你对某项服务的需求增加或减少,你可以增加或减少主机的一部分,如果容器编排做得当,那么在扩展或缩减时将会零停机和用户察觉不到的变化。这对需要在不同时间处理可变负载的服务提供商非常方便--以 Netflix 及其高峰观看时间为例。在大多数情况下,这些也可以在几乎所有云平台上自动化(即 AWS 自动扩展组,Google 集群自动缩放器和 Azure 自动缩放器),因此,如果发生某些触发器或资源消耗发生变化,服务将自动扩展和缩减主机数量以处理负载。通过自动化所有这些过程,你的 PaaS 基本上可以成为一个灵活的一劳永逸的层,开发人员可以在其上担心真正重要的事情,而不必浪费时间去弄清楚一些系统库是否安装在部署主机上。

现在不要误会我的意思;制作这些令人惊叹的 PaaS 服务绝非易事,而且道路上布满了无数隐藏的陷阱,但如果你想在夜间能够安然入睡,不受愤怒客户、老板或同事的电话骚扰,无论你是开发人员还是其他人,你都必须努力尽可能接近这些理想的设置。

Docker 的位置

到目前为止,我们已经谈了很多关于容器,但还没有提到 Docker。虽然 Docker 已经成为容器化的事实标准,但它目前是这个领域中许多竞争技术之一,今天相关的内容可能明天就不再适用。因此,我们将涵盖一些容器生态系统的内容,这样如果你看到这个领域发生变化,不要犹豫尝试其他解决方案,因为选择合适的工具几乎总是比试图“把方形钉子塞进圆孔”更好。

虽然大多数人知道 Docker 作为命令行界面CLI)工具,但 Docker 平台扩展到包括创建和管理集群的工具、处理持久存储、构建和共享 Docker 容器等等,但现在,我们将专注于该生态系统中最重要的部分:Docker 容器。

Docker 容器简介

Docker 容器本质上是一组文件系统层,这些层按顺序堆叠在一起,以创建最终的布局,然后由主机机器的内核在隔离的环境中运行。每个层描述了相对于其上一个父层添加、修改和/或删除的文件。例如,你有一个基本层,其中有一个文件/foo/bar,下一个层添加了一个文件/foo/baz。当容器启动时,它将按顺序组合层,最终的容器将同时拥有/foo/bar/foo/baz。对于任何新层,这个过程都会重复,以得到一个完全组成的文件系统来运行指定的服务或服务。

把镜像中文件系统层的安排想象成交响乐中复杂的层次:你有后面的打击乐器提供声音的基础,稍微靠前的吹奏乐器推动乐曲的发展,最前面的弦乐器演奏主旋律。一起,它创造了一个令人愉悦的最终结果。在 Docker 的情况下,通常有基本层设置主要的操作系统层和配置,服务基础设施层放在其上(解释器安装,辅助工具的编译等),最终运行的镜像最终是实际的服务代码。现在,这就是你需要知道的全部,但我们将在下一章节中更详细地涵盖这个主题。

实质上,Docker 在其当前形式下是一个平台,允许在容器内轻松快速地开发隔离的(或者取决于服务配置的)Linux 和 Windows 服务,这些容器是可扩展的,易于互换和分发的。

竞争

在我们深入讨论 Docker 本身之前,让我们也大致了解一下一些当前的竞争对手,并看看它们与 Docker 本身的区别。几乎所有这些竞争对手的有趣之处在于,它们通常是围绕 Linux 控制组(cgroups)和命名空间的一种抽象形式,这些控制组限制了 Linux 主机的物理资源的使用,并将进程组相互隔离。虽然这里提到的几乎所有工具都提供了某种资源的容器化,但在隔离深度、实现安全性和/或容器分发方面可能存在很大差异。

rkt

rkt,通常写作Rocket,是来自 CoreOS 的最接近的竞争应用容器化平台,它最初是作为更安全的应用容器运行时启动的。随着时间的推移,Docker 已经解决了许多安全问题,但与rkt不同的是,它以有限的权限作为用户服务运行,而 Docker 的主要服务以 root 权限运行。这意味着如果有人设法打破 Docker 容器,他们将自动获得对主机 root 的完全访问权限,从运营的角度来看,这显然是一个非常糟糕的事情,而使用rkt,黑客还需要提升他们的权限从有限用户。虽然从安全的角度来看,这里的比较并没有给 Docker 带来太大的光明,但如果其发展轨迹可以被推断,这个问题可能会在未来得到很大程度的缓解和/或修复。

另一个有趣的区别是,与 Docker 不同,它被设计为在容器内运行单个进程,rkt可以在一个容器内运行多个进程。这使得在单个容器内部部署多个服务变得更加容易。现在,话虽如此,你实际上可以在 Docker 容器内运行多个进程(我们将在本书的后面部分介绍),但是正确设置这一点是非常麻烦的,但我在实践中发现,保持基于单个进程的服务和容器的压力确实促使开发人员创建真正的微服务容器,而不是将它们视为迷你虚拟机,所以不一定认为这是一个问题。

虽然有许多其他较小的原因可以选择 Docker 而不是rkt,反之亦然,但有一件重要的事情是无法忽视的:采用速度。虽然rkt有点年轻,但 Docker 已经被几乎所有大型科技巨头采用,而且似乎没有任何停止这一趋势的迹象。考虑到这一点,如果您今天需要处理微服务,选择可能非常明确,但与任何技术领域一样,生态系统在一年甚至只是几个月内可能看起来大不相同。

系统级虚拟化

在对立的一面,我们有用于处理完整系统镜像而不是像 LXD、OpenVZ、KVM 和其他一些应用程序的平台。与 Docker 和rkt不同,它们旨在为您提供所有虚拟化系统服务的全面支持,但纯粹从定义上来说,资源使用成本要高得多。虽然在主机上拥有单独的系统容器对于诸如更好的安全性、隔离性和可能的兼容性之类的事情是必要的,但根据个人经验,几乎所有这些容器的使用都可以转移到应用级虚拟化系统,只需进行一些工作即可提供更好的资源使用配置文件和更高的模块化,而在创建初始基础设施时稍微增加成本。在这里要遵循的一个明智的规则是,如果您正在编写应用程序和服务,您可能应该使用应用级虚拟化,但如果您正在为最终用户提供 VM 或者希望在服务之间获得更高的隔离性,您应该使用系统级虚拟化。

桌面应用程序级虚拟化

Flatpak、AppImage、Snaps 和其他类似技术也为单应用级容器提供隔离和打包,但与 Docker 不同,它们都针对部署桌面应用程序,并且对容器的生命周期(启动、停止、强制终止等)没有如此精确的控制,也通常不提供分层镜像。相反,大多数这些工具都有很好的图形用户界面(GUI),并为安装、运行和更新桌面应用程序提供了显着更好的工作流程。虽然由于对所述 cgroups 和命名空间的相同依赖,大多数与 Docker 有很大的重叠,但这些应用级虚拟化平台通常不处理服务器应用程序(没有 UI 组件运行的应用程序),反之亦然。由于这个领域仍然很年轻,它们所覆盖的空间相对较小,你可能可以期待整合和交叉,因此在这种情况下,要么是 Docker 进入桌面应用程序交付领域,要么是其中一个或多个竞争技术尝试支持服务器应用程序。

何时应考虑容器化?

到目前为止,我们已经涵盖了很多内容,但有一个重要的方面我们还没有涵盖,但这是一个非常重要的事情要评估,因为在许多情况下容器化并不合理,无论这个概念有多大的关注度,所以我们将涵盖一些真正应该考虑(或不应该考虑)这种类型平台的一般用例。虽然从运营角度来看,容器化应该是大多数情况下的最终目标,并且在注入到开发过程中时可以带来巨大的回报,但将部署机器转变为容器化平台是一个非常棘手的过程,如果你无法从中获得实际的好处,那么你可能还不如把这段时间用在能为你的服务带来真正和实际价值的事情上。

让我们首先从覆盖缩放阈值开始。如果你的服务作为一个整体可以完全适应并在相对较小或中等虚拟机或裸金属主机上良好运行,并且你不预期突然的扩展需求,部署机器上的虚拟化将使你陷入痛苦的道路,在大多数情况下并不合理。即使是建立一个良性但健壮的虚拟化设置的高前期成本,通常也更好地花在该级别的服务功能开发上。

如果你看到一个由虚拟机或裸金属主机支持的服务需求增加,你可以随时将其扩展到更大的主机(垂直扩展)并重新聚焦你的团队,但除此之外,你可能不应该选择这条路。有许多情况下,一家企业花了几个月的时间来实施容器技术,因为它非常受欢迎,最终由于缺乏开发资源而失去了客户,不得不关闭他们的业务。

现在你的系统正在达到垂直可扩展性的极限,是时候添加诸如 Docker 集群之类的东西了吗?真正的答案是“可能”。如果你的服务在主机上是同质的和一致的,比如分片或集群数据库或简单的 API,在大多数情况下,现在也不是合适的时机,因为你可以通过主机镜像和某种负载均衡器轻松扩展这个系统。如果你想要更多的花样,你可以使用基于云的“数据库即服务”(DBaaS),比如 Amazon RDS、Microsoft DocumentDB 或 Google BigQuery,并根据所需的性能水平通过同一提供商(甚至是不同的提供商)自动扩展服务主机。

如果除此之外还有大量的服务种类预示着,需要从开发人员到部署的更短管道,不断增长的复杂性或指数级增长,你应该将这些都视为重新评估你的利弊的触发器,但没有明确的阈值会成为一个明确的切入点。然而,在这里一个很好的经验法则是,如果你的团队有一个缓慢的时期,探索容器化选项或提升你在这个领域的技能不会有害,但一定要非常小心,不要低估设置这样一个平台所需的时间,无论这些工具中的许多看起来多么容易入门。

有了这一切,什么是你需要尽快将容器纳入工作流程的明显迹象?这里可能有许多微妙的暗示,但以下清单涵盖了如果答案是肯定的话,应立即讨论容器主题的迹象,因为其好处大大超过了投入服务平台的时间:

  • 你的部署中是否有超过 10 个独特、离散且相互连接的服务?

  • 你是否需要在主机上支持三种或更多编程语言?

  • 你的运维资源是否不断部署和升级服务?

  • 你的任何服务需要“四个 9”(99.99%)或更高的可用性吗?

  • 你的部署中是否有服务经常在部署中出现故障的模式,因为开发人员没有考虑到服务将在其中运行的环境?

  • 你是否有一支才华横溢的开发或运维团队闲置着?

  • 你的项目是否在挥霍金钱?

好吧,也许最后一个有点玩笑,但它在清单中是为了以一种讽刺的语气来说明,写作时让 PaaS 平台运行、稳定和安全既不容易也不便宜,无论你的货币是时间还是金钱。许多人会试图欺骗你,让你认为你应该始终使用容器并使所有东西都 Docker 化,但保持怀疑的心态,并确保你仔细评估你的选择。

理想的 Docker 部署

既然我们已经完成了真实的谈话部分,让我们说我们真的准备好了来处理容器和 Docker 的虚构服务。我们在本章的前面已经涵盖了一些内容,但在这里,我们将明确定义我们的理想要求会是什么样子,如果我们有充足的时间来处理它们:

  • 开发人员应能够部署新服务,而无需任何运维资源

  • 系统可以自动发现正在运行的服务的新实例

  • 系统在上下都具有灵活的可扩展性

  • 在所需的代码提交上,新代码将在没有开发或运维干预的情况下自动部署

  • 你可以无缝地处理降级节点和服务,而不会中断。

  • 你能够充分利用主机上可用的资源(RAM、CPU 等)

  • 节点几乎不需要被开发人员单独访问

如果这些是要求,您会高兴地知道几乎所有这些要求在很大程度上都是可行的,我们将在本书中详细介绍几乎所有这些要求。对于其中的许多要求,我们需要更深入地了解 Docker,并超越大多数其他材料,但教授您无法应用到实际场景的部署是没有意义的,这些部署只会打印出“Hello World”。

在我们探索以下章节中的每个主题时,我们一定会涵盖任何潜在的问题,因为有许多这样复杂的系统交互。有些对您来说可能很明显,但许多可能不会(例如 PID1 问题),因为这个领域的工具在相对年轻,许多对 Docker 生态系统至关重要的工具甚至还没有达到 1.0 版本,或者最近才达到 1.0 版本。

因此,您应该考虑这个技术领域仍处于早期发展阶段,所以要现实一点,不要期望奇迹,预期会有一些小“陷阱”。还要记住,一些最大的科技巨头现在已经使用 Docker 很长时间了(红帽、微软、谷歌、IBM 等),所以也不要感到害怕。

要开始并真正开始我们的旅程,我们需要首先重新考虑我们对服务的思考方式。

容器思维

今天,正如我们在本章稍早已经涵盖的那样,今天部署的绝大多数服务都是一团杂乱的临时或手动连接和配置的部分,一旦其中一个部分发生变化或移动,整个结构就会分崩离析。很容易想象这就像一堆纸牌,需要更改的部分通常位于其中间,存在风险将整个结构拆除。小到中等规模的项目和有才华的开发和运维团队大多可以管理这种复杂性,但这真的不是一种可扩展的方法。

开发者工作流程

即使您不是在开发 PaaS 系统,考虑将服务的每个部分都视为应该在开发人员和最终部署主机之间具有一致的环境,能够在任何地方运行并进行最小的更改,并且足够模块化,以便在需要时可以用 API 兼容的类似物替换。对于许多这种情况,即使是本地 Docker 使用也可以在使部署更容易方面发挥作用,因为您可以将每个组件隔离成不随着开发环境的变化而改变的小部分。

为了说明这一点,想象一个实际情况,我们正在编写一个简单的 Web 服务,该服务与基于最新 Ubuntu 的系统上的数据库进行通信,但我们的部署环境是 CentOS 的某个迭代版本。在这种情况下,由于它们支持周期长度的巨大差异,协调不同版本和库将非常困难,因此作为开发人员,您可以使用 Docker 为您提供与 CentOS 相同版本的数据库,并且您可以在基于 CentOS 的容器中测试您的服务,以确保所有库和依赖项在部署时可以正常工作。即使真实的部署主机没有容器化,这个过程也会改善开发工作流程。

现在,我们将以稍微更加现实的方向来看待这个例子:如果您需要在所有当前支持的 CentOS 版本上无需修改代码即可运行您的服务呢?

使用 Docker,您可以为每个操作系统版本创建一个容器,以便测试服务,以确保不会出现任何意外。另外,您可以自动化一个测试套件运行程序,逐个(甚至更好的是并行)启动每个操作系统版本的容器,以便在任何代码更改时自动运行整个测试套件。通过这些小的调整,我们已经将一个经常在生产中出现故障的临时服务转变为几乎不需要担心的东西,因为您可以确信它在部署时会正常工作,这是一个非常强大的工具。

如果您扩展这个过程,您可以在本地创建 Docker 配方(Dockerfiles),我们将在下一章中详细介绍,其中包含从纯净的 CentOS 安装到完全能够运行服务所需的确切步骤。这些步骤可以由运维团队以最小的更改作为输入,用于他们的自动化配置管理(CM)系统,如 Ansible、Salt、Puppet 或 Chef,以确保主机具有运行所需的确切基线。由服务开发人员编写的端目标上所需的确切步骤的编码传递,这正是 Docker 如此强大的原因。

希望显而易见的是,Docker 作为一种工具不仅可以改善部署机器上的开发流程,而且还可以在整个过程中用于标准化您的环境,从而提高几乎每个部署流程的效率。有了 Docker,您很可能会忘记那句让每个运维人员感到恐惧的臭名昭著的短语:“在我的机器上运行良好!”这本身就足以让您考虑在部署基础设施不支持容器的情况下,插入基于容器的工作流程。

在这里我们一直在绕着弯子说的底线是,您应该始终考虑的是,使用当前可用的工具,将整个部署基础设施转变为基于容器的基础设施略微困难,但在开发流程的任何其他部分添加容器通常并不太困难,并且可以为您的团队提供指数级的工作流程改进。

总结

在本章中,我们沿着部署的历史走了一遍,并看了看 Docker 容器是如何让我们更接近微服务的新世界的。我们对 Docker 进行了审查,概述了我们最感兴趣的部分。我们涵盖了竞争对手以及 Docker 在生态系统中的定位和一些使用案例。最后,我们还讨论了何时应该考虑容器在基础架构和开发工作流程中,更重要的是,何时不应该考虑。

在下一章中,我们最终将动手并了解如何安装和运行 Docker 镜像,以及创建我们的第一个 Docker 镜像,所以一定要继续关注。

第二章:卷起袖子

在上一章中,我们看了容器是什么,它们在基础设施中可以扮演什么角色,以及为什么 Docker 是服务部署中的领头羊。现在我们知道了 Docker 是什么,也知道了它不是什么,我们可以开始基础知识了。在本章中,我们将涵盖以下主题:

  • 安装 Docker

  • 扩展一个容器

  • 构建一个容器

  • 调试容器

安装 Docker

Docker 的安装在操作系统之间有很大的差异,但对于大多数系统,都有详细的说明。通常有两个级别的 Docker 可用:社区版CE)和企业版EE)。虽然略有不同,但对于本书中我们将要处理的几乎所有内容来说,社区版都是完全功能的,完全够用。一旦你达到需要更高级功能的规模,比如安全扫描、LDAP 和技术支持,企业版可能是有意义的。不出所料,企业版是收费的,您可以查看www.docker.com/pricing来了解这些版本的区别。

对于本书中的示例和任何特定于操作系统的命令,从现在开始,我们将使用 Ubuntu 的长期支持LTS)版本,Ubuntu 目前是最流行的 Linux 发行版。 LTS 产品的最新版本是 16.04,这将是我们 CLI 交互和示例的基础,但在您阅读本书时,18.04 也可能已经推出。请记住,除了安装部分外,大多数代码和示例都是非常可移植的,通常可以在其他平台上运行,因此即使需要进行更改,也应该是最小的。也就是说,在非 Linux 平台上开发 Docker 服务可能不太精细或稳定,因为 Docker 通常用于在 Linux 机器上部署基于 Linux 的服务,尽管其他一些特殊情况也得到了一定程度的支持。自从微软试图推动他们自己的容器策略以来,他们在这个领域取得了重大进展,因此请密切关注他们的进展,因为它可能成为一个非常有竞争力的开发平台。

一些后续章节中的手动网络示例在 macOS 中可能无法完全工作,因为该平台对该子系统的实现不同。对于这些情况,建议您在虚拟机上使用 Ubuntu LTS 进行跟随操作。

因此,使用我们干净的 Ubuntu 16.04 LTS 机器、虚拟机或兼容的操作系统,让我们安装 Docker。虽然 Docker 软件包已经在分发中的apt仓库中可用,但我强烈不建议以这种方式安装,因为这些版本通常要旧得多。虽然对于大多数软件来说这不是问题,但对于像 Docker 这样快速发展的项目来说,这将使您在支持最新功能方面处于明显的劣势。因此,出于这个原因,我们将从 Docker 自己的 apt 仓库中安装 Docker:

警告!还有其他几种安装 Docker 的方法,但除非绝对必要,使用sudo curl -sSL https://somesite.com/ | sh模式或类似的方式进行安装是非常危险的,因为您在未检查脚本功能的情况下为网站的脚本授予了 root 权限。这种执行模式也几乎没有留下执行过程的证据。此外,中途出现的异常可能会损坏下载文件但仍然执行,部分造成损害,并且您只依赖传输层安全性TLS),全球数百家组织都可以创建伪造证书。换句话说,如果您关心您的机器,除非软件供应商对安全一无所知并且他们强迫您这样做,否则您绝对不应该以这种方式安装软件,那么您就完全受他们的支配。

$ # Install the pre-requisites
$ sudo apt install -y apt-transport-https \
                      curl

$ # Add Docker's signing key into our apt configuration to ensure they are the only ones that can send us updates. This key should match the one that the apt repository is using so check the online installation instruction if you see "NO_PUBKEY <key_id>" errors.
$ apt-key adv --keyserver hkp://p80.pool.sks-keyservers.net:80 \
              --recv-keys 58118E89F3A912897C070ADBF76221572C52609D

$ # Add the repository location to apt. Your URL may be different depending on if Xenial is your distribution.
$ echo "deb https://apt.dockerproject.org/repo ubuntu-xenial main" | sudo tee -a /etc/apt/sources.list.d/docker.list

$ # Update the apt listings and install Docker
$ sudo apt update
$ sudo apt install docker-engine

默认情况下,Docker 将要求在所有命令前加上sudo(或root)来运行,包括本书中未明确提到的命令。通常情况下,对于开发机器来说,这是一个很大的麻烦,所以我可能会提到,但强烈不建议,您也可以将当前用户添加到docker组中,这样您就不需要在每个 Docker 命令前加上sudo

  1. 使用usermod将用户添加到组中(例如$ sudo usermod -aG docker $USER)。

  2. 完全注销并重新登录(组仅在会话启动时进行评估)。

请记住,这是一个巨大的安全漏洞,可以允许本地用户轻松提升为根权限,因此在任何情况下都不要在任何将连接到互联网的服务器上执行此操作。

如果所有前面的命令都按预期工作,您将能够看到 Docker 是否已安装:

$ docker --version
Docker version 17.05.0-ce, build 89658be

安装了 Docker 但没有任何东西可运行是相当无用的,所以让我们看看是否可以获得一个可以在本地运行的镜像。我们的选择是要么从头开始制作自己的镜像,要么使用已经构建好的东西。鉴于 Docker 之所以能够达到如此高的采用率的一个重要原因是通过 Docker Hub(hub.docker.com/)轻松共享镜像,而我们刚刚开始,我们将延迟一点时间来创建自己的镜像,以探索这个站点,这是一个集中发布和下载 Docker 镜像的地方。

在这个非描述性和单调的页面背后是成千上万的 Docker 镜像的存储,由于我们目前不感兴趣发布镜像,我们可以点击页面右上角的“探索”按钮,看看有哪些可用的镜像:

正如您所看到的,这列出了写作时最受欢迎的镜像,但您也可以通过左上角的搜索框查找特定的镜像。目前,正如之前提到的,我们不会在这里花太多时间,但对于您来说,了解如何从 Docker Hub 运行镜像将是有价值的,因此我们将尝试拉取和运行其中一个来向您展示如何操作。

目前可用的顶级容器似乎是 NGINX,所以我们将尝试在我们的 Docker 环境中运行它。如果您以前没有使用过 NGINX,它是一个高性能的 Web 服务器,被许多互联网上的网站使用。在这个阶段,我们只是想要感受一下运行这些容器的感觉,让我们看看如何做到:

$ # Pull the image from the server to our local repository
$ docker pull nginx
Using default tag: latest
latest: Pulling from library/nginx
94ed0c431eb5: Pull complete
9406c100a1c3: Pull complete
aa74daafd50c: Pull complete
Digest: sha256:788fa27763db6d69ad3444e8ba72f947df9e7e163bad7c1f5614f8fd27a311c3
Status: Downloaded newer image for nginx:latest

pull命令拉取组成此镜像的任何和所有层。在这种情况下,NGINX 镜像基于三个堆叠的层,并且具有哈希值788fa277..27a311c3,由于我们没有指定我们想要的特定版本,我们得到了默认标签,即latest。通过这个单一的命令,我们已经从 Docker Hub 检索了 NGINX 镜像,以便我们可以在本地运行它。如果我们想使用不同的标签或从不同的服务器拉取,该命令将变得更加具有表现力,类似于docker pull <hostname_or_ip>:<port>/<tag_name>,但我们将在后面的章节中介绍这些高级用法。

现在,镜像已经存储在我们本地的 Docker 存储中(通常在/var/lib/docker中),我们可以尝试运行它。NGINX 有大量可能的选项,您可以在hub.docker.com/_/nginx/上进一步了解,但我们现在只对启动镜像感兴趣:

$ docker run nginx

您可能注意到什么都没有发生,但不要担心,这是预期的。遗憾的是,单独这个命令是不够的,因为 NGINX 将在前台运行,并且根本无法通过套接字访问,所以我们需要覆盖一些标志和开关,使其真正有用。所以让我们按下Ctrl + C关闭容器,然后再试一次,这次添加一些必要的标志:

$ docker run -d \
             -p 8080:80 \
             nginx
dd1fd1b62d9cf556d96edc3ae7549f469e972267191ba725b0ad6081dda31e74

-d标志以后台模式运行容器,这样我们的终端就不会被 NGINX 占用,而-p 8080:80标志将我们的本地端口8080映射到容器的端口80。容器通常会暴露特定的端口,而在这种情况下,是80,但如果没有映射,我们将无法访问它。命令返回的输出是一个唯一的标识符(容器 ID),可以用来在启动后跟踪和控制这个特定的容器。希望您现在能够看到 Docker 的端口白名单方法如何增加了额外的安全级别,因为只有您明确允许监听的东西才被允许。

现在,您可以打开浏览器访问http://localhost:8080,您应该会看到一个类似这样的页面:

但是我们究竟是如何知道端口80需要被监听的呢?确实,我们将在接下来的一秒钟内介绍这一点,但首先,因为我们以分离模式启动了这个容器,它仍然在后台运行,我们可能应该确保停止它。要查看我们正在运行的容器,让我们用docker ps来检查我们的 Docker 容器状态:

$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
dd1fd1b62d9c nginx "nginx -g 'daemon ..." 13 minutes ago Up 13 minutes 0.0.0.0:8080->80/tcp dazzling_swanson

我们在这里看到的是,我们的 NGINX 容器仍在运行,它已经将本地主机接口端口8080(包括外部可访问的端口)映射到容器的端口80,而且我们已经运行了13分钟。如果我们有更多的容器,它们都会在这里列出,因此这个命令对于处理 Docker 容器非常有用,通常用于调试和容器管理。

由于我们想要关闭这个容器,我们现在将实际执行。要关闭容器,我们需要知道容器 ID,这是docker run返回的值,也是docker ps的第一列显示的值(dd1fd1b62d9c)。可以使用 ID 的短或长版本,但为了简洁起见,我们将使用前者:

$ docker stop dd1fd1b62d9c
dd1fd1b62d9c

这将优雅地尝试停止容器并将使用的资源返回给操作系统,并在特定的超时后强制杀死它。如果容器真的卡住了,我们可以用kill替换stop来强制杀死进程,但这很少需要,因为如果进程没有响应,stop通常会做同样的事情。我们现在要确保我们的容器已经消失了:

$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES

是的,事情看起来正如我们所期望的那样,但请注意,虽然停止的容器不可见,但默认情况下它们并没有完全从文件系统中删除:

$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
dd1fd1b62d9c nginx "nginx -g 'daemon ..." 24 minutes ago Exited (137) 2 minutes ago dazzling_swanson

-a标志用于显示所有容器状态,而不仅仅是正在运行的容器,您可以看到系统仍然知道我们的旧容器。我们甚至可以使用docker start来恢复它!

$ docker start dd1fd1b62d9c
dd1fd1b62d9c

$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
dd1fd1b62d9c nginx "nginx -g 'daemon ..." 28 minutes ago Up About a minute 0.0.0.0:8080->80/tcp dazzling_swanson

要真正永久删除容器,我们需要明确地使用docker rm来摆脱它,如下所示,或者使用--rm开关运行docker run命令(我们将在接下来的几页中介绍这个):

$ docker stop dd1fd1b62d9c
dd1fd1b62d9c

$ docker rm dd1fd1b62d9c
dd1fd1b62d9c

$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES

成功!

现在让我们回到之前的问题,我们如何知道容器需要将端口 80 映射到它?我们有几种选项可以找到这些信息,最简单的一种是启动容器并在docker ps中检查未绑定的端口:

$ docker run -d \
             --rm \
             nginx
f64b35fc42c33f4af2648bf4f1dce316b095b30d31edf703e099b93470ab725a

$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f64b35fc42c3 nginx "nginx -g 'daemon ..." 4 seconds ago Up 3 seconds 80/tcp awesome_bell

我们在docker run中使用的新标志是--rm,我们刚刚提到过,它告诉 Docker 守护程序在停止后完全删除容器,这样我们就不必手动删除了。

如果您已经有一个要检查映射端口的容器,可以使用docker port <container_id>命令,但我们在这里省略了,因为它不能用于镜像,而只能用于容器。

虽然这是查看所需端口的最快方法,但在读取其 Dockerfile 和文档之外检查镜像的一般方法是通过docker inspect

$ # Inspect NGINX image info and after you match our query, return also next two lines
$ docker inspect nginx | grep -A2 "ExposedPorts"
"ExposedPorts": {
 "80/tcp": {}
},

此外,docker inspect还可以显示各种其他有趣的信息,例如以下内容:

  • 镜像的 ID

  • 标签名称

  • 镜像创建日期

  • 硬编码的环境变量

  • 容器在启动时运行的命令

  • 容器的大小

  • 镜像层 ID

  • 指定的卷

随时运行检查命令在任何容器或镜像上,并查看您可能在那里找到的宝石。大多数情况下,这个输出主要用于调试,但在镜像文档缺乏的情况下,它可以是一个无价的工具,让您在最短的时间内运行起来。

调试容器

通常在与容器一般工作中,您可能需要弄清楚正在运行的容器的情况,但docker ps并不能提供您需要弄清楚事情的所有信息。对于这些情况,要使用的第一个命令是docker logs。这个命令显示容器发出的任何输出,包括stdoutstderr流。对于以下日志,我从前面开始了相同的 NGINX 容器,并访问了它在localhost上托管的页面。

$ docker run -d \
             -p 8080:80 \
             nginx
06ebb46f64817329d360bb897bda824f932b9bcf380ed871709c2033af069118

$ # Access the page http://localhost:8080 with your browser

$ docker logs 06ebb46f
172.17.0.1 - - [02/Aug/2017:01:39:51 +0000] "GET / HTTP/1.1" 200 612 "-" "Mozilla/5.0 (Windows NT 6.3; rv:36.0) Gecko/20100101 Firefox/36.01" "-"
2017/08/02 01:39:51 [error] 6#6: *1 open() "/usr/share/nginx/html/favicon.ico" failed (2: No such file or directory), client: 172.17.0.1, server: localhost, request: "GET /favicon.ico HTTP/1.1", host: "localhost:8080"
172.17.0.1 - - [02/Aug/2017:01:39:51 +0000] "GET /favicon.ico HTTP/1.1" 404 169 "-" "Mozilla/5.0 (Windows NT 6.3; rv:36.0) Gecko/20100101 Firefox/36.01" "-"
172.17.0.1 - - [02/Aug/2017:01:39:52 +0000] "GET / HTTP/1.1" 200 612 "-" "Mozilla/5.0 (Windows NT 6.3; rv:36.0) Gecko/20100101 Firefox/36.01" "-"

您可以在这里看到,NGINX 记录了所有访问和相关的响应代码,这对于调试 Web 服务器非常宝贵。一般来说,输出可以因服务运行的内容而有很大的变化,但通常是开始搜索的好地方。如果您想要在日志被写入时跟踪日志,还可以添加-f标志,这在日志很大并且您试图过滤特定内容时非常有帮助。

看到容器看到的内容

当日志并不能真正解决问题时,要使用的命令是docker exec,以便在运行的容器上执行一个命令,可以包括访问完整的 shell:

$ docker run -d \
             -p 8080:80 \
             nginx
06ebb46f64817329d360bb897bda824f932b9bcf380ed871709c2033af069118

$ docker exec 06ebb46f ls -la /etc/nginx/conf.d/
total 12
drwxr-xr-x 2 root root 4096 Jul 26 07:33 .
drwxr-xr-x 3 root root 4096 Jul 26 07:33 ..
-rw-r--r-- 1 root root 1093 Jul 11 13:06 default.conf

在这种情况下,我们使用docker exec在容器中运行ls命令,但实际上这并不是一个强大的调试工具。如果我们尝试在容器内获取完整的 shell 并以这种方式进行检查呢?

$ docker exec -it \
              06ebb46f /bin/bash
root@06ebb46f6481:/# ls -la /etc/nginx/conf.d/
total 12
drwxr-xr-x 2 root root 4096 Jul 26 07:33 .
drwxr-xr-x 3 root root 4096 Jul 26 07:33 ..
-rw-r--r-- 1 root root 1093 Jul 11 13:06 default.conf
root@06ebb46f6481:/# exit
exit

$ # Back to host shell

这一次,我们使用了-it,这是-i-t标志的简写,结合起来设置了所需的交互式终端,然后我们使用/bin/bash在容器内运行 Bash。容器内的 shell 在这里是一个更有用的工具,但由于许多镜像会删除图像中的任何不必要的软件包,我们受制于容器本身--在这种情况下,NGINX 容器没有ps,这是一个非常有价值的用于查找问题原因的实用程序。由于容器通常是隔离的一次性组件,有时可能可以向容器添加调试工具以找出问题的原因(尽管我们将在后面的章节中介绍使用pid命名空间的更好方法):

$ docker exec -it 06ebb46f /bin/bash

root@06ebb46f6481:/# ps  # No ps on system
bash: ps: command not found

root@06ebb46f6481:/# apt-get update -q
Hit:1 http://security.debian.org stretch/updates InRelease
Get:3 http://nginx.org/packages/mainline/debian stretch InRelease [2854 B]
Ign:2 http://cdn-fastly.deb.debian.org/debian stretch InRelease
Hit:4 http://cdn-fastly.deb.debian.org/debian stretch-updates InRelease
Hit:5 http://cdn-fastly.deb.debian.org/debian stretch Release
Fetched 2854 B in 0s (2860 B/s)
Reading package lists...

root@06ebb46f6481:/# apt-get install -y procps
<snip>
The following NEW packages will be installed:
libgpm2 libncurses5 libprocps6 procps psmisc
0 upgraded, 5 newly installed, 0 to remove and 0 not upgraded.
Need to get 558 kB of archives.
After this operation, 1785 kB of additional disk space will be used.
<snip>

root@06ebb46f6481:/# ps
PID TTY TIME CMD
31 ? 00:00:00 bash
595 ? 00:00:00 ps

root@06ebb46f6481:/#

正如您所看到的,从上游分发的任何调试工具都很容易添加到容器中,但请注意,一旦找到问题,您应该启动一个新的容器并删除旧的容器,以清理掉剩下的垃圾,因为它浪费空间,而新的容器将从没有添加您新安装的调试工具的图像开始(在我们的情况下是procps)。

另一件事需要记住的是,有时镜像会阻止安装额外的软件包,因此对于这些情况,我们需要等到后面的章节来看看如何使用命名空间在这样受限制的环境中工作。

有时,容器被锁定在有限的用户 shell 中,因此您将无法访问或修改容器系统的其他部分。在这种配置中,您可以添加-u 0标志来将docker exec命令作为rootuser 0)运行。您也可以指定任何其他用户名或用户 ID,但通常如果您需要在容器上使用辅助用户,root是您想要的。

我们的第一个 Dockerfile

现在我们对如何操作容器有了一点了解,这是一个很好的地方来尝试创建我们自己的容器。要开始构建容器,我们需要知道的第一件事是,Docker 在构建镜像时查找的默认文件名是Dockerfile。虽然您可以为此主要配置文件使用不同的名称,但这是极不鼓励的,尽管在一些罕见的情况下,您可能无法避免 - 例如,如果您需要一个测试套件镜像和主镜像构建文件在同一个文件夹中。现在,我们假设您只有一个单一的构建配置,考虑到这一点,我们来看看这些基本Dockerfile是什么样子的。在您的文件系统的某个地方创建一个测试文件夹,并将其放入名为Dockerfile的文件中:

FROM ubuntu:latest

RUN apt-get update -q && \
 apt-get install -qy iputils-ping

CMD ["ping", "google.com"]

让我们逐行检查这个文件。首先,我们有FROM ubuntu:latest这一行。这行表示我们要使用最新的 Ubuntu Docker 镜像作为我们自己服务的基础。这个镜像将自动从 Docker Hub 中拉取,但这个镜像也可以来自自定义存储库、您自己的本地镜像,并且可以基于任何其他镜像,只要它为您的服务提供了一个良好的基础(即 NGINX、Apline Linux、Jenkins 等)。

接下来的一行非常重要,因为基本的 Ubuntu 镜像默认情况下几乎没有任何东西,所以我们需要通过其软件包管理器apt安装提供 ping 实用程序(iputils-ping)的软件包,就像我们在命令行上使用RUN指令给 Docker 一样。不过,在安装之前,我们还需要确保我们的更新索引是最新的,我们使用apt-get update来做到这一点。稍后,我们将详细介绍为什么使用&&来链接updateinstall命令,但现在我们将神奇地忽略它,以免我们的示例偏离太多。

CMD指令指示 Docker 默认情况下,每次启动容器时 Docker 都会运行"ping" "google.com",而无需进一步的参数。该指令用于在容器内启动服务,并将容器的生命周期与该进程绑定,因此如果我们的ping失败,容器将终止,反之亦然。您的 Dockerfile 中只能有一行CMD,因此要特别小心如何使用它。

现在我们已经配置好整个容器,让我们来构建它:

$ # Build using Dockerfile from current directory and tag our resulting image as "test_container"
$ docker build -t test_container . 
Sending build context to Docker daemon 1.716MB
Step 1/3 : FROM ubuntu:latest
---> 14f60031763d
Step 2/3 : RUN apt-get update -q && apt-get install -qy iputils-ping
---> Running in ad1ea6a6d4fc
Get:1 http://security.ubuntu.com/ubuntu xenial-security InRelease [102 kB]
<snip>
The following NEW packages will be installed:
iputils-ping libffi6 libgmp10 libgnutls-openssl27 libgnutls30 libhogweed4
libidn11 libnettle6 libp11-kit0 libtasn1-6
0 upgraded, 10 newly installed, 0 to remove and 8 not upgraded.
Need to get 1304 kB of archives.
<snip>
Setting up iputils-ping (3:20121221-5ubuntu2) ...
Processing triggers for libc-bin (2.23-0ubuntu9) ...
---> eab9729248d9
Removing intermediate container ad1ea6a6d4fc
Step 3/3 : CMD ping google.com
---> Running in 44fbc308e790
---> a719d8db1c35
Removing intermediate container 44fbc308e790
Successfully built a719d8db1c35
Successfully tagged test_container:latest

正如它所暗示的评论,我们在这里使用docker build -t test_container .构建了容器(使用默认的 Dockerfile 配置名称)在我们当前的目录,并用名称test_container标记了它。由于我们没有在test_container的末尾指定版本,Docker 为我们分配了一个称为latest的版本,正如我们可以从输出的末尾看到的那样。如果我们仔细检查输出,我们还可以看到对基本镜像的每个更改都会创建一个新的层,并且该层的 ID 然后被用作下一个指令的输入,每个层都会将自己的文件系统差异添加到镜像中。例如,如果我们再次运行构建,Docker 足够聪明,知道没有任何变化,它将再次使用这些层的缓存版本。将最终容器 ID(a719d8db1c35)与上一次运行的 ID 进行比较:

$ docker build -t test_container . 
Sending build context to Docker daemon 1.716MB
Step 1/3 : FROM ubuntu:latest
---> 14f60031763d
Step 2/3 : RUN apt-get update -q && apt-get install -qy iputils-ping
---> Using cache
---> eab9729248d9
Step 3/3 : CMD ping google.com
---> Using cache
---> a719d8db1c35
Successfully built a719d8db1c35
Successfully tagged test_container:latest

如果在 Dockerfile 的指令中检测到任何更改,Docker 将重建该层和任何后续层,以确保一致性。这种功能和选择性的“缓存破坏”将在以后进行介绍,并且它在管理您的存储库和镜像大小方面起着非常重要的作用。

容器构建完成后,让我们看看它是否真的有效(要退出循环,请按Ctrl + C):

$ # Run the image tagged "test_container"
$ docker run test_container 
PING google.com (216.58.216.78) 56(84) bytes of data.
64 bytes from ord30s21-in-f14.1e100.net (216.58.216.78): icmp_seq=1 ttl=52 time=45.9 ms
64 bytes from ord30s21-in-f14.1e100.net (216.58.216.78): icmp_seq=2 ttl=52 time=41.9 ms
64 bytes from ord30s21-in-f14.1e100.net (216.58.216.78): icmp_seq=3 ttl=52 time=249 ms
^C
--- google.com ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2002ms
rtt min/avg/max/mdev = 41.963/112.460/249.470/96.894 ms

又一个成功!你写了你的第一个运行 Docker 容器!

打破缓存

在我们刚刚写的容器中,我们有点忽略了这一行RUN apt-get update -q && apt-get install -qy iputils-ping,因为它需要在这里进行更深入的讨论。在大多数 Linux 发行版中,软件包的版本经常变化,但告诉我们在哪里找到这些软件包的索引列表是在创建原始 Docker 镜像时就已经固定了(在这种情况下是ubuntu:latest)。在大多数情况下,在我们安装软件包之前,我们的索引文件已经过时太久了(如果它们没有被完全删除),所以我们需要更新它们。将这个&&连接的行拆分成两个单独的行将适用于第一次构建:

RUN apt-get update -q
RUN apt-get install -qy iputils-ping

但是,当你以后在第二行添加另一个软件包时,会发生什么,就像下一行所示的那样?

RUN apt-get install -qy curl iputils-ping

在这种情况下,Docker 并不是很智能,它会认为 update 行没有改变,不会再次运行更新命令,因此它将使用缓存中的状态进行更新层,然后继续下一个尝试安装 curl 的命令(自上次构建以来已更改),如果仓库中的版本已经足够多次轮换,索引将再次过时,这很可能会失败。为了防止这种情况发生,我们使用 &&updateinstall 命令连接起来,这样它们将被视为一个指令并创建一个层,在这种情况下,更改两个连接命令中的任何部分都将破坏缓存并正确运行 update。不幸的是,随着您更多地涉足可扩展的 Docker 组件,使用这些奇技淫巧来管理缓存和进行选择性缓存破坏将成为您工作的重要部分。

一个更实用的容器。

这可能是我们开始与其他 Docker 材料有所不同的地方,其他材料几乎假设只要掌握了这些基本知识,其余的工作就像小菜一碟一样,但实际上并非如此。这并不是什么高深的科学,但这些简单的例子确实不足以让我们达到我们需要的地方,因此我们将使用一个实际的例子,基于我们之前使用 NGINX 的工作,并创建一个使用这个 Web 服务器镜像的容器,以提供和提供我们将嵌入到镜像中的内容。

本书中的这个例子和其他所有例子也可以在 GitHub 上找到 github.com/sgnn7/deploying_with_docker。您可以使用 git 或他们的 Web 界面来跟随这些例子,但我们将使用的所有代码示例也将直接包含在书中。

要开始创建我们的 Web 服务器,我们需要创建一个目录来放置我们所有的文件:

$ mkdir ~/advanced_nginx
$ cd ~/advanced_nginx

我们需要创建的第一个文件是我们将尝试在镜像中提供的虚拟文本文件:

$ echo "Just a test file" > test.txt

我们接下来需要的文件是所需的 NGINX 配置。将以下文本放入一个名为 nginx_main_site.conf 的文件中:

    server {
      listen 80;
      server_name _;
      root /srv/www/html;

      # Deny access to any files prefixed with '.'
      location ~/\. {
        deny all;
      }

      # Serve up the root path at <host>/
      location / {
        index index.html;
        autoindex on;
      }
    }

如果你从未使用过 NGINX,让我们看看这个文件做了什么。在第一个块中,我们创建了一个在镜像上以 /srv/www/html 为根的监听端口 80server。第二个块虽然不是严格必需的,并且对于更大的网站需要进行更改,但对于任何在 NGINX 上工作的人来说,这应该是一种肌肉记忆,因为它可以防止下载像 .htaccess.htpasswd 和许多其他不应该公开的隐藏文件。最后一个块只是确保任何以 / 开头的路径将从 root 中读取,并且如果没有提供索引文件,它将使用 index.html。如果没有这样的文件可用并且我们在一个目录中,autoindex 确保它可以向您显示一个目录的可读列表。

虽然这个 NGINX 配置是功能性的,但它还有很多不包括的东西(如 SSL 配置、日志记录、错误文件、文件查找匹配等),但这主要是因为这本书试图专注于 Docker 本身而不是 NGINX。如果您想了解如何完全和正确地配置 NGINX,您可以访问 nginx.org/en/docs/ 了解更多信息。

配置写好后,我们现在可以创建我们的 Dockerfile,它将获取我们的测试文件、配置文件和 NGINX 镜像,并将它们转换成一个运行 Web 服务器并提供我们的测试文件的 Docker 镜像。

FROM nginx:latest

# Make sure we are fully up to date
RUN apt-get update -q && \
 apt-get dist-upgrade -y

# Remove the default configuration
RUN rm /etc/nginx/conf.d/default.conf

# Create our website's directory and make sure
# that the webserver process can read it
RUN mkdir -p /srv/www/html && \
 chown nginx:nginx /srv/www/html

# Put our custom server configuration in
COPY nginx_main_site.conf /etc/nginx/conf.d/

# Copy our test file in the location that is
# being served up
COPY test.txt /srv/www/html/

这个 Dockerfile 可能看起来与第一个有很大不同,所以我们将花一些时间来深入了解我们在这里做了什么。

使用 FROM 扩展另一个容器

与我们上一个容器类似,我们的 FROM nginx:latest 行确保我们使用基础镜像的最新版本,但这里我们将使用 NGINX 作为基础,而不是 Ubuntu。latest 确保我们获取具有最新功能和通常也有补丁的镜像,但稍微存在未来破坏和 API 不兼容的风险。

在编写 Docker 容器时,您通常必须根据您的情况和稳定性要求做出这些权衡决定,但是 NGINX API 多年来一直非常稳定,因此在这种特定情况下,我们不需要命名标签提供的稳定性。如果我们想在这里使用其中一个带有标签的版本,latest只需更改为我们在 Docker Hub 上找到的所需版本,例如hub.docker.com/_/nginx/,因此像FROM nginx:1.13这样的东西也完全可以。

确保包含最新的补丁

我们的下一步,apt-get upgradeapt-get dist-upgrade,在当前的 Docker 世界中有点争议,但我认为它们是一个很好的补充,我会解释原因。在常规的基于deb软件包的 Linux 发行版(即 Debian,Ubuntu 等),这两个命令确保您的系统与当前发布的软件包完全保持最新。这意味着任何不是最新版本的软件包将被升级,任何过时的软件包将被替换为更新的软件包。由于 Docker 的一般准则是容器多多少是可丢弃的,以这种方式更新容器似乎有点不受欢迎,但它并非没有缺点。

由于 Docker Hub 上的大多数 Docker 镜像只有在基本源文件或 Dockerfile 本身发生更改时才构建,因此许多这些镜像具有较旧和/或未修补的系统库,因此当服务将它们用作动态库时,可能会受到已经修复的任何错误的影响。为了确保我们在这方面的安全加固工作不落后,我们确保在做任何其他事情之前更新系统。虽然由于系统 API 可能发生变化而导致服务中断的风险很小,并且由于应用了额外的更改而导致镜像大小增加,但在我看来,这种权衡不足以让服务处于无保护状态,但在这里请随意使用您的最佳判断。

应用我们的自定义 NGINX 配置

我们在系统更新后的指令(RUN rm /etc/nginx/conf.d/default.conf)是删除容器中默认的 web 服务器配置。您可以通过我们上一个提示中的链接了解更多关于 NGINX 配置的信息,但现在,我们可以说默认情况下,所有单独的站点配置文件都存储在/etc/nginx/conf.d中,NGINX Docker 镜像默认带有一个名为default.conf的简单示例文件,我们绝对不想使用。

虽然我们可以覆盖提到的文件,但我们将被困在名为default的名称中,这并不是很描述性的,因此对于我们的配置,我们将删除这个文件,并使用一个更好的文件名添加我们自己的文件。

接下来,我们需要确保我们将要提供文件的文件夹可以被网络服务器进程访问和读取。使用mkdir -p的第一个命令创建了所有相关的目录,但由于 NGINX 不以 root 身份运行,我们需要知道进程将以什么用户来读取我们想要提供的文件,否则我们的服务器将无法显示任何内容。我们可以通过显示包含在镜像中的系统范围 NGINX 配置的前几行来找到原始配置中的默认用户,该配置位于/etc/nginx/nginx.conf中。

$ # Print top 2 lines of main config file in NGINX image
$ docker run --rm \
             nginx /bin/head -2 /etc/nginx/nginx.conf

user nginx;

完美!现在,需要能够读取这个目录的用户是nginx,我们将使用chown nginx:nginx /srv/www/html来更改我们目标文件夹的所有者,但是我们刚刚使用了新的run Docker 命令来尝试找到这个信息,这是怎么回事?如果在指定镜像名称后包含一个命令,而不是在镜像中使用CMD指令,Docker 将用这个新命令替换它。在前面的命令中,我们运行了/bin/head可执行文件,并传入参数告诉它我们只想要从/etc/nginx/nginx.conf文件中获取前两行。由于这个命令一旦完成就退出了,容器就会停止并完全删除,因为我们使用了--rm标志。

随着默认配置的消失和我们的目录创建,我们现在可以使用COPY nginx_main_site.conf /etc/nginx/conf.d/将 NGINX 的主要配置放在指定位置。COPY参数基本上就是将当前构建目录中的文件复制到镜像中的指定位置。

非常小心地结束COPY指令的参数,如果不加斜杠,源文件会被放入目标文件,即使目标是一个目录。为了确保这种情况不会发生,始终在目标目录路径的末尾加上斜杠。

添加我们想要托管的主要test.txt文件是最后一部分,它遵循与其他COPY指令相同的步骤,但我们将确保将其放入我们的 NGINX 配置引用的文件夹中。由于我们为这个端点打开了autoindex标志,因此不需要采取其他步骤,因为文件夹本身是可浏览的。

构建和运行

现在我们已经讨论了整个构建配置,我们可以创建我们的镜像,看看我们刚刚做了什么:

$ docker build -t web_server . 
Sending build context to Docker daemon 17.41kB
Step 1/6 : FROM nginx:latest
 ---> b8efb18f159b
Step 2/6 : RUN apt-get update -q && apt-get dist-upgrade -yq
 ---> Running in 5cd9ae3712da
Get:1 http://nginx.org/packages/mainline/debian stretch InRelease [2854 B]
Get:2 http://security.debian.org stretch/updates InRelease [62.9 kB]
Get:3 http://nginx.org/packages/mainline/debian stretch/nginx amd64 Packages [11.1 kB]
Get:5 http://security.debian.org stretch/updates/main amd64 Packages [156 kB]
Ign:4 http://cdn-fastly.deb.debian.org/debian stretch InRelease
Get:6 http://cdn-fastly.deb.debian.org/debian stretch-updates InRelease [88.5 kB]
Get:7 http://cdn-fastly.deb.debian.org/debian stretch Release [118 kB]
Get:8 http://cdn-fastly.deb.debian.org/debian stretch Release.gpg [2373 B]
Get:9 http://cdn-fastly.deb.debian.org/debian stretch/main amd64 Packages [9497 kB]
Fetched 9939 kB in 40s (246 kB/s)
Reading package lists...
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
 ---> 4bbd446af380
Removing intermediate container 5cd9ae3712da
Step 3/6 : RUN rm /etc/nginx/conf.d/default.conf
 ---> Running in 39ad3da8979a
 ---> 7678bc9abdf2
Removing intermediate container 39ad3da8979a
Step 4/6 : RUN mkdir -p /srv/www/html && chown nginx:nginx /srv/www/html
 ---> Running in e6e50483e207
 ---> 5565de1d2ec8
Removing intermediate container e6e50483e207
Step 5/6 : COPY nginx_main_site.conf /etc/nginx/conf.d/
 ---> 624833d750f9
Removing intermediate container a2591854ff1a
Step 6/6 : COPY test.txt /srv/www/html/
 ---> 59668a8f45dd
Removing intermediate container f96dccae7b5b
Successfully built 59668a8f45dd
Successfully tagged web_server:latest

容器构建似乎很好,让我们来运行它:

$ docker run -d \
             -p 8080:80 \
             --rm \
             web_server 
bc457d0c2fb0b5706b4ca51b37ca2c7b8cdecefa2e5ba95123aee4458e472377

$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
bc457d0c2fb0 web_server "nginx -g 'daemon ..." 30 seconds ago Up 29 seconds 0.0.0.0:8080->80/tcp goofy_barti

到目前为止,一切都很顺利,似乎运行得很好。现在我们将在http://localhost:8080上用浏览器访问容器。

正如我们所希望的那样,我们的服务器正在工作,并显示/srv/www/html的内容,但让我们点击test.txt,确保它也在工作:

太好了,看起来我们的计划成功了,我们创建了一个高性能的静态网站托管服务器容器!当然,我们还可以添加许多其他东西,但我们扩展示例镜像以执行一些有用的操作的主要目标已经实现了!

从头开始的服务

我们上一个示例相当全面,但遗漏了一些重要的 Docker 命令,我们也应该知道,因此我们将使用另一个示例,尽管以略微不太理想的方式重新设计 Web 服务器解决方案,以展示它们的使用并解释它们的作用。在这个过程中,我们将深入一点,看看是否可以自己制作服务的许多部分。

我们将从创建一个干净的目录开始这个示例,并创建我们之前使用的相同的测试文件:

$ mkdir ~/python_webserver
$ cd ~/python_webserver

$ echo "Just a test file" > test.txt

现在我们将通过将以下内容放入Dockerfile来创建一个稍微复杂一点的基于 Python 的 Web 服务器容器。

FROM python:3

# Add some labels for cache busting and annotating
LABEL version="1.0"
LABEL org.sgnn7.name="python-webserver"

# Set a variable that we will keep reusing to prevent typos
ENV SRV_PATH=/srv/www/html

# Make sure we are fully up to date
RUN apt-get update -q && \
 apt-get dist-upgrade -y

# Let Docker know that the exposed port we will use is 8000
EXPOSE 8000

# Create our website's directory, then create a limited user
# and group
RUN mkdir -p $SRV_PATH && \
 groupadd -r -g 350 pythonsrv && \
 useradd -r -m -u 350 -g 350 pythonsrv

# Define ./external as an externally-mounted directory
VOLUME $SRV_PATH/external

# To serve things up with Python, we need to be in that
# same directory
WORKDIR $SRV_PATH

# Copy our test file
COPY test.txt $SRV_PATH/

# Add a URL-hosted content into the image
ADD https://raw.githubusercontent.com/moby/moby/master/README.md \
 $SRV_PATH/

# Make sure that we can read all of these files as a
# limited user
RUN chown -R pythonsrv:pythonsrv $SRV_PATH

# From here on out, use the limited user
USER pythonsrv

# Run the simple http python server to serve up the content
CMD [ "python3", "-m", "http.server" ]

在几乎所有情况下,使用 Python 内置的 Web 服务器都是极不推荐的,因为它既不可扩展,也没有任何显著的配置方式,但它可以作为一个通过 Docker 托管的服务的良好示例,并且几乎在所有安装了 Python 的系统上都可用。除非你真的知道自己在做什么,否则不要在真实的生产服务中使用它。

除了关于在生产中使用 python 的 web 服务器模块的注意之外,这仍然是我们没有涵盖的所有其他主要 Dockerfile 指令的一个很好的例子,现在您将学习如何使用它们。

标签

我们这里的第一个新指令是LABEL

LABEL version="1.0"
LABEL org.sgnn7.name="python-webserver"

LABEL <key>=<value>LABEL <key> <value>用于向正在构建的镜像添加元数据,稍后可以通过docker psdocker images进行检查和过滤,使用类似docker images --filter "<key>=<value>"的方式。键通常以reverse-dns表示法全部小写,但在这里您可以使用任何内容,version应该出现在每个镜像上,因此我们使用顶级版本键名称。但是,这里的版本不仅用于过滤图像,还用于在更改时打破 Docker 的缓存。如果没有这种缓存破坏或在构建过程中通过手动设置标志(docker build --no-cache),Docker 将一直重用缓存,直到最近更改的指令或文件,因此您的容器很可能会保持在冻结的软件包配置中。这种情况可能是您想要的,也可能不是,但是以防万一您有自动化构建工具,添加一个version层,可以在更改时打破缓存,使得容器非常容易更新。

使用 ENV 设置环境变量

ENV与其他一些命令不同,应该大部分是不言自明的:它在Dockerfile和容器中设置环境变量。由于我们需要在Dockerfile中不断重新输入/srv/www/html,为了防止拼写错误并确保对最终服务器目录目标的轻松更改,我们设置了SRV_PATH变量,稍后我们将不断重用$SRV_PATH。通常对于 Docker 容器,几乎所有的容器配置都是通过这些环境变量完成的,因此在后面的章节中可以预期会看到这个指令。

即使在这个示例中我们没有使用它,但是在CMD指令中直接使用环境变量时需要注意,因为它不会被展开,而是直接运行。您可以通过将其作为类似于这样的 shell 命令结构的一部分来确保您的变量在CMD中被展开:CMD [ "sh", "-c", "echo", "$SRV_PATH" ]

暴露端口

我们接下来的新指令是EXPOSE 8000。还记得我们如何使用docker info来找出 NGINX 容器使用的端口吗?这个指令填写了元数据中的信息,并且被 Docker 编排工具用来将传入端口映射到容器的正确入口端口。由于 Python 的 HTTP 服务器默认在端口8000上启动服务,我们使用EXPOSE来通知 Docker,使用这个容器的人应该确保他们在主机上映射这个端口。你也可以在这个指令中列出多个端口,但由于我们的服务只使用一个端口,所以现在不需要使用。

使用有限用户的容器安全层

我们Dockerfile中的以下新代码块可能有点复杂,但我们将一起学习:

RUN mkdir -p $SRV_PATH && \
 groupadd -r -g 350 pythonsrv && \
 useradd -r -m -u 350 -g 350 pythonsrv

这是我们需要在多个层面上扩展的内容,但你首先需要知道的是,默认情况下,Dockerfile 指令是以root用户执行的,如果稍后没有指定不同的USER,你的服务将以root凭据运行,从安全角度来看,这是一个巨大的漏洞,我们试图通过将我们的服务仅作为有限用户运行来修补这个漏洞。然而,如果没有定义用户和组,我们无法将上下文从root切换,因此我们首先创建一个pythonsrv组,然后创建附属于该组的pythonsrv用户。-r标志将用户和组标记为系统级实体,对于不会直接登录的组和用户来说,这是一个良好的做法。

说到用户和组,如果你将一个卷从主机挂载到以有限用户身份运行的 Docker 容器中,如果主机和容器对用户和组 ID(分别为uidgid)没有完全一致,你将无法从卷中读取或写入文件。为了避免这种情况,我们使用一个稳定的 UID 和 GID,即350,这个数字易于记忆,在大多数主机系统的常规 UID/GID 表中通常不会出现。这个数字大多是任意的,但只要它在主机 OS 的服务范围内,并且不会与主机上的用户或组冲突,就应该没问题。

到目前为止没有涵盖的最后一个标志是-m,它的作用是为用户创建主目录骨架文件。大多数情况下,你不需要这个,但如果任何后续操作尝试使用$HOME(比如npm或大量其他服务),除非你指定这个标志并且你的构建将失败,否则不会有这样的目录,所以我们确保通过为pythonsrv用户创建$HOME来避免这种情况。

为了完成这一切,我们将所有这些RUN命令链接在一起,以确保我们使用尽可能少的层。每一层都会创建额外的元数据,并增加你的镜像大小,所以就像 Docker 最佳实践文档所述,我们尝试通过堆叠这些命令来减少它们。虽然在所有情况下都不是最好的做法,因为调试这种风格的配置非常困难,但通常会显著减小容器的大小。

卷和存在于容器之外的数据

但是,如果我们想要添加存在于容器之外的文件,即使容器死亡时也需要持久存在的文件呢?这就是VOLUME指令发挥作用的地方。使用VOLUME,每次启动容器时,这个路径实际上被假定为从容器外部挂载,如果没有提供,将会自动为你创建并附加一个。

在这里,我们将我们的/srv/www/html/external路径分配给这个未命名的卷,但我们将保留大部分关于卷的详细讨论到后面的章节。

设置工作目录

由于 Python HTTP 服务器只能从其运行的当前目录中提供文件,如果不正确配置,我们的容器将显示/目录之外的文件。为了解决这个问题,我们在Dockerfile中包含了WORKDIR $SRV_ROOT,这将把我们的工作目录更改为包含我们想要提供的文件的目录。关于这个命令需要注意的一点是,你可以多次重用它,并且它适用于 Dockerfile 中的任何后续命令(如RUNCMD)。

从互联网添加文件

如果要尝试向容器中添加不在本地托管的文件和/或由于许可问题无法将它们包含在Dockerfile所在的存储库中,该怎么办?为了这个特定的目的,有ADD指令。这个命令会从提供的 URI 下载文件并将其放入容器中。如果文件是本地压缩存档,比如.tgz.zip文件,并且目标路径以斜杠结尾,它将被扩展到该目录中,这是一个非常有用的选项,与COPY相比。在我们写的例子中,我们将从 GitHub 中随机选择一个文件,并将其放入要包含的目录中。

ADD https://raw.githubusercontent.com/moby/moby/master/README.md \
 $SRV_PATH/

改变当前用户

我们已经解释了为什么需要将我们的服务运行为受限用户以及我们如何为其创建用户,但现在是永久切换上下文到pythonsrv的时候了。使用USER pythonsrv,任何进一步的命令都将以pythonsrv用户的身份执行,包括容器的CMD可执行命令,这正是我们想要的。就像WORKDIR一样,这个指令可以在Dockerfile中多次使用,但对于我们的目的来说,没有必要将其余的配置设置为非root。通常,将这个层语句尽可能放在Dockerfile中很高的位置是一个很好的做法,因为它很少会改变,也不太可能破坏缓存。然而,在这个例子中,我们不能将它移到更高的位置,因为我们之前的命令使用了chown,这需要root权限。

把所有东西放在一起

我们快要完成了!我们需要做的最后一件事是在容器启动时启动 Python 的内置 HTTP 服务器模块:

CMD [ "python3", "-m", "http.server" ]

一切就绪后,我们可以构建并启动我们的新容器:

$ docker build -t python_server . 
Sending build context to Docker daemon 16.9kB
Step 1/14 : FROM python:3
 ---> 968120d8cbe8
<snip>
Step 14/14 : CMD python3 -m http.server
 ---> Running in 55262476f342
 ---> 38fab9dca6cd
Removing intermediate container 55262476f342
Successfully built 38fab9dca6cd
Successfully tagged python_server:latest

$ docker run -d \
             -p 8000:8000 \
             --rm \
             python_server 
d19e9bf7fe70793d7fce49f3bd268917015167c51bd35d7a476feaac629c32b8

我们可以祈祷并通过访问http://localhost:8000来检查我们构建的内容:

成功了!点击test.txt显示了正确的Just a test字符串,当点击时,我们从 GitHub 下载的README.md也很好地显示出来。所有的功能都在那里,external/目录中有什么?

如果卷是空的,那么我们的目录也是空的并不奇怪。我们来看看是否可以将一些文件从我们的主机挂载到这个目录中:

$ # Kill our old container that is still running
$ docker kill d19e9bf7
d19e9bf7

$ # Run our image but mount our current folder to container's
$ # /srv/www/html/external folder
$ docker run -d \
             -p 8000:8000 \
             --rm \
             -v $(pwd):/srv/www/html/external \
             python_server 
9756b456074f167d698326aa4cbe5245648e5487be51b37b00fee36067464b0e

在这里,我们使用-v标志将我们的当前目录($(pwd))挂载到我们的/srv/www/html/external目标上。那么现在http://localhost:8000/external是什么样子呢?我们的文件可见吗?

确实是的 - 我们的服务正如我们所期望的那样工作!一个从头开始编写的真正的服务!

有了一个正常工作的服务,我们现在应该能够在下一章中继续我们的 Docker 之旅,通过扩展我们的容器。

摘要

在本章中,我们涵盖了从基本的 Docker 容器到扩展现有容器,一直到从头开始创建我们自己的服务的所有内容。在这个过程中,我们涵盖了最重要的 Docker 和 Dockerfile 命令以及如何使用它们,更重要的是在哪里为什么使用它们。虽然这并不是对该主题最深入的覆盖,但这正是我们在下一章开始扩展容器工作所需要的适当深度。

第三章:服务分解

本章将介绍如何利用上一章的知识来创建和构建数据库和应用服务器容器的附加部分,因为真实世界的服务通常是以这种方式组成的。一旦我们把它们都建立起来,我们将看到需要什么才能将它们组合成一个更可用的服务,并且深入了解 Docker 的更多内容。

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

  • Docker 命令的快速回顾

  • 使用以下内容编写一个真实的服务:

  • 一个 Web 服务器服务

  • 一个应用服务

  • 一个数据库

  • 介绍卷

  • 凭据传递的安全考虑

快速回顾

在我们开始之前,让我们回顾一下我们之前在一个单独的部分中涵盖的 Docker 和 Dockerfile 命令,以便您以后可以作为参考。

Docker 命令

以下是我们为 Docker 提供的所有命令,还添加了一些其他命令,如果您经常构建容器,可能会用到:

要获取每个参数所需的更深入信息,或者查看我们尚未涵盖的命令,请在终端中键入docker help,或者单独在终端中键入该命令。您还可以访问docs.docker.com/并查看文档,如果 CLI 输出提供的信息不够好,它可能包含更新的数据。

docker attach - Attach the shell's input/output/error stream to the container
docker build - Build a Docker image based on a provided Dockerfile
docker cp - Copy files between container and host
docker exec - Execute a command in a running container
docker images - List image available to your installation of docker
docker info - Display information about the system
docker inspect - Display information about Docker layers, containers, images, etc
docker kill - Forcefully terminate a container 
docker logs - Display logs from a container since it last started
docker pause - Pause all processes within a container
docker ps - List information about containers and their resource usage
docker pull - Pull an image from a remote repository into the local registry
docker push - Push an image from the local registry into a remote repository
docker rm - Remove a container
docker rmi - Remove an image from the local repository
docker run - Start a new container and run it
docker search - Search DockerHub for images
docker start - Start a stopped container
docker stop - Stop a running container nicely (wait for container to shut down)
docker tag - Create a tag for an image
docker top - Show running processes of a container
docker unpause - Resume all processes in a paused container
docker version - Show the Docker version

最近,Docker 命令已经开始被隔离到它们自己的 docker CLI 部分,比如docker container,以将它们与其他集群管理命令分开。要使用这种较新的语法,只需在任何命令前加上容器(即docker stop变成docker container stop)。您可以随意使用任何版本,但请注意,尽管新样式对于大多数 Docker 用法来说过于冗长,但您可能会发现旧样式在某个时候被弃用。

Dockerfile 命令

以下列表与之前类似,但这次我们涵盖了在 Dockerfile 中可以使用的命令,并按照在 Dockerfile 中工作时的顺序进行了排列:

FROM <image_name>[:<tag>]: 将当前镜像基于<image_name>

LABEL <key>=<value> [<key>=value>...]: 向镜像添加元数据

EXPOSE <port>: 指示应该映射到容器中的端口

WORKDIR <path>: 设置当前目录以便执行后续命令

RUN <command> [ && <command>... ]: 执行一个或多个 shell 命令

ENV <name>=<value>:将环境变量设置为特定值

VOLUME <path>:表示应该外部挂载<路径>的卷

COPY <src> <dest>:将本地文件、一组文件或文件夹复制到容器中

ADD <src> <dest>:与COPY相同,但可以处理 URI 和本地存档

USER <user | uid>:为此命令之后的命令设置运行时上下文为<user><uid>

CMD ["<path>", "<arg1>", ...]:定义容器启动时要运行的命令

由于几乎所有您想要构建的容器都可以使用这个集合构建,因此这个列表并不是 Docker 命令的全部超集,其中一些被有意地省略了。如果您对ENTRYPOINTARGHEALTHCHECK或其他内容感到好奇,可以在docs.docker.com/engine/reference/builder/上查看完整的文档。

编写一个真实的服务

到目前为止,我们已经花了时间制作了一些帮助我们建立 Docker 技能的假或模拟容器服务,但我们还没有机会去做一些类似真实世界服务的工作。一般来说,大多数在外部被使用的简单服务看起来会类似于高级别图表中所示的内容:

概述

在这里,我们将详细讨论每个服务。

Web 服务器

我们刚刚看到的图像中最右边的部分是一个 Web 服务器。Web 服务器充当高速 HTTP 请求处理程序,并且通常在这种情况下被使用如下:

  • 用于集群内资源、虚拟专用云(VPC)和/或虚拟专用网络(VPN)的反向代理端点

  • 加固的守门人,限制资源访问和/或防止滥用

  • 分析收集点

  • 负载均衡器

  • 静态内容交付服务器

  • 应用服务器逻辑利用的减少器

  • SSL 终止端点

  • 远程数据的缓存

  • 数据二极管(允许数据的入口或出口,但不能同时)

  • 本地或联合账户 AAA 处理程序

如果安全需求非常低,服务是内部的,处理能力充足,那么我们想象中的服务的这一部分并不总是严格要求的,但在几乎所有其他情况下,如果这些条件中的任何一个不满足,添加 Web 服务器几乎是强制性的。Web 服务器的一个很好的类比是你的家用路由器。虽然你不一定需要使用互联网,但专用路由器可以更好地共享你的网络,并作为你和互联网之间的专用安全设备。虽然我们在上一章中大部分时间都在使用 NGINX,但还有许多其他可以使用的(如 Apache、Microsoft IIS、lighttpd 等),它们通常在功能上是可以互换的,但要注意配置设置可能会有显著不同。

应用服务器

所以,如果 Web 服务器为我们做了所有这些,应用服务器又做什么呢?应用服务器实际上是您的主要服务逻辑,通常包装在一些可通过 Web 访问的端点或队列消费的守护程序中。这一部分可以这样使用:

  • 主要的网站框架

  • 数据操作 API 逻辑

  • 某种数据转换层

  • 数据聚合框架

应用服务器与 Web 服务器的主要区别在于,Web 服务器通常在静态数据上运行,并在流程中做出通常是刚性的决定,而应用服务器几乎所有的动态数据处理都是以非线性方式进行的。属于这一类的通常是诸如 Node.js、Ruby on Rails、JBoss、Tornado 等框架,用于运行可以处理请求的特定编程语言应用程序。在这里不要认为需要一个大型框架是必需的,因为即使是正确的 Bash 脚本或 C 文件也可以完成同样的工作,并且仍然可以作为应用服务器的资格。

我们将尽可能多地将工作推迟到 Web 服务器而不是应用服务器上,原因是由于框架开销,应用服务器通常非常慢,因此不适合执行简单、小型和重复的任务,而这些任务对于 Web 服务器来说是小菜一碟。作为参考,一个专门的 Web 服务器在提供静态页面方面的效率大约是一个完全成熟的应用服务器的一个数量级,因此比大多数应用服务器快得多。正如前面提到的,你可能可以单独或通过一些调整在应用服务器上处理低负载,但超过这个范围的任何负载都需要一个专用的反向代理。

数据库:一旦我们掌握了这种逻辑和静态文件处理,它们在没有实际数据进行转换和传递时基本上是无用的。与使用数据的任何软件一样,这是通过后备数据库完成的。由于我们希望能够扩展系统的任何部分并隔离离散的组件,数据库有了自己的部分。然而,在容器之前的世界中,我们依赖于提供了原子性一致性隔离性持久性ACID)属性的大型单片数据库,并且它们完成了它们的工作。然而,在容器世界中,我们绝对不希望这种类型的架构,因为它既不像可靠性那样强大,也不像可水平扩展的数据库那样可水平扩展。

然而,使用这种新式数据库,通常无法得到与旧式数据库相同的保证,这是一个重要的区别。与 ACID 相比,大多数容器友好的数据库提供的是基本可用软状态最终一致性BASE),这基本上意味着数据最终会正确,但在初始更新发送和最终状态之间,数据可能处于各种中间值的状态。

我们要构建什么

我们希望制作一个能够作为一个很好的示例但又不会太复杂的服务,以展示一个真实世界的服务可能看起来像什么。对于这个用例,我们将创建一个容器分组,可以在基本的 HTTP 身份验证后执行两个操作:

  • 将登陆页面上输入的字符串保存到数据库中。

  • 当我们登陆首页时,显示到目前为止保存的所有字符串的列表。

在这里,我们将尽量涵盖尽可能多的内容,同时构建一个基本现实的容器支持的网络服务的原型。请记住,即使使用可用的工具,制作一个像这样简单的服务也并不容易,因此我们将尽量减少复杂性,尽管我们的内容的难度从这里开始会逐渐增加。

实现部分

由于我们已经涵盖了通用服务架构中需要的三个主要部分,我们将把我们的项目分成相同的离散部分,包括一个 Web 服务器、一个应用服务器和一个数据库容器,并在这里概述构建它们所需的步骤。如前所述,如果你不想从这些示例中重新输入代码,你可以使用 Git 轻松地从 GitHub 上检出所有的代码,网址是github.com/sgnn7/deploying_with_docker

Web 服务器

我们可以在这里选择任何 Web 服务器软件,但由于我们之前已经使用过 NGINX,因此重用这个组件的一些部分是有道理的--这实际上就是容器架构的全部意义!Web 服务器组件将提供一些基本的身份验证、缓存数据,并作为其后面的应用服务器的反向代理。我们之前工作过的基本设置可以在这里使用,但我们将对其进行一些修改,使其不再直接提供文件,而是充当代理,然后使用我们将在Dockerfile中创建的凭据文件进行身份验证。让我们创建一个名为web_server的新文件夹,并将这些文件添加到其中:

nginx_main_site.conf:

server {
  listen  80;
  server_name    _;

  root /srv/www/html;

  location ~/\. {
    deny all;
  }

  location / {
    auth_basic           "Authentication required";
    auth_basic_user_file /srv/www/html/.htpasswd;

    proxy_pass           http://172.17.0.1:8000;
  }
}

这里有三个有趣的配置部分。第一个是包含auth_basic_命令,它们在此配置提供的所有端点上启用 HTTP 基本身份验证。第二个是,如果你足够留心新的以.开头的凭据文件,我们现在需要拒绝获取所有以.开头的文件,因为我们添加了.htpasswd。第三个也是最有趣的是使用了proxy_pass,它允许服务器将所有经过身份验证的流量路由到后端应用服务器。为什么我们使用http://172.17.0.1:8000作为目的地,这开始打开 Docker 网络的潘多拉魔盒,所以我们将在稍后解释为什么我们使用它,如果现在涵盖它,我们将使我们的服务构建偏离轨道。

警告!在大多数情况下,使用基本身份验证是一种安全的恶作剧,因为我们在这里使用它时没有 HTTPS,因为任何网络上的人都可以使用最简单的工具嗅探出您的凭据。在您的服务中,至少要求使用基本身份验证或在部署到具有直接互联网访问权限的任何服务之前依赖于更强大的凭据传递形式。

现在我们可以在同一个目录中添加我们的新Dockerfile,它将如下所示:

FROM nginx:latest
# Make sure we are fully up to date
RUN apt-get update -q && \
 apt-get dist-upgrade -y && \
 apt-get install openssl && \
 apt-get clean && \
 apt-get autoclean

# Setup any variables we need
ENV SRV_PATH /srv/www/html

# Get a variable defined for our password
ARG PASSWORD=test

# Remove default configuration
RUN rm /etc/nginx/conf.d/default.conf

# Change ownership of copied files
RUN mkdir -p $SRV_PATH && \
 chown nginx:nginx $SRV_PATH

# Setup authentication file
RUN printf "user:$(openssl passwd -1 $PASSWORD)\n" >> $SRV_PATH/.htpasswd

# Add our own configuration in
COPY nginx_main_site.conf /etc/nginx/conf.d/

正如您所看到的,我们在这里对上一章中的原始工作进行了一些更改。应该引起注意的初始事情是编写RUN apt-get行的新方法,我们在这里简要注释了一下:

RUN apt-get update -q && \         # Update our repository information
 apt-get dist-upgrade -y && \   # Upgrade any packages we already have
 apt-get install openssl && \   # Install dependency (openssl)
 apt-get clean && \             # Remove cached package files
 apt-get autoclean              # Remove any packages that are no longer needed on the system

与以前的图像不同,在这里,我们安装了openssl软件包,因为我们将需要它来为身份验证创建 NGINX 加密密码,但cleanautoclean行在这里是为了确保我们删除系统上的任何缓存的apt软件包并删除孤立的软件包,从而给我们一个更小的镜像,这是我们应该始终努力的目标。就像以前一样,我们以类似的方式组合所有行,以便以前和当前层之间的文件系统差异只是所需的更改,而不是其他任何东西,使其成为一个非常紧凑的更改。当编写自己的图像时,如果您发现自己需要更多的瘦身,许多其他东西都可以删除(例如删除文档文件,/var目录,不必要的可选软件包等),但在大多数情况下,这两个应该是最常用的,因为它们很简单并且在基于 Debian 的系统上运行得相当好。

身份验证

没有适当的身份验证,我们的服务器对任何访问它的人都是敞开的,所以我们添加了一个用户名/密码组合来充当我们服务的门卫:

ARG PASSWORD=test
...
RUN printf "user:$(openssl passwd -1 $PASSWORD)\n" >> $SRV_PATH/.htpasswd

ARG充当构建时替代ENV指令,并允许将密码作为构建参数传递给--build-arg <arg>。如果构建没有提供一个,它应该默认为等号后面的参数,在这种情况下是一个非常不安全的test。我们将在Dockerfile中稍后使用这个变量来为我们的用户创建一个具有特定密码的.htpasswd文件。

第二行使用我们之前安装的openssl来获取构建参数,并以 NGINX 和大多数其他 Web 服务器可以理解的格式(<username>:<hashed_password>)创建带有加密凭据的.htpasswd文件。

警告!请记住,-1算法比使用Salted SHA(SSHA)方法创建.htpasswd密码不够安全,但以这种方式创建它们将涉及更复杂的命令,这将分散我们在这里的主要目的,但您可以访问nginx.org/en/docs/http/ngx_http_auth_basic_module.html#auth_basic_user_file获取更多详细信息。还要注意,您不应该使用在线密码生成器,因为它们可能(并经常)窃取您输入的信息。

如果您以前没有使用过 Bash 子 shell,$(openssl ...)将在单独的 shell 中运行,并且输出将被替换为字符串变量,然后再进行评估,因此>>追加操作将只看到username:后的加密密码,与openssl无关。从这些事情中应该有些明显,如果我们不提供任何构建参数,容器将具有一个用户名user,密码设置为test

警告!此处使用的将凭据传递给镜像的方式仅用作示例,非常不安全,因为任何人都可以运行docker history并查看此变量设置为什么,或者启动镜像并回显PASSWORD变量。一般来说,传递此类敏感数据的首选方式是在启动容器时通过环境变量传递,将凭据文件挂载为容器的卷,使用docker secret或外部凭据共享服务。我们可能会在后面的章节中涵盖其中一些,但现在,您应该记住,出于安全考虑,不要在生产中使用这种特定的凭据传递方式。

web_server部分完成后,我们可以转移到下一个部分:数据库。

数据库

SQL 数据库在分片和集群方面已经取得了长足的进步,并且通常能够提供良好的性能,但许多面向集群的解决方案都是基于 NoSQL 的,并且在大多数情况下使用键/值存储;此外,它们已经在生态系统中与根深蒂固的 SQL 玩家竞争,逐年获得了越来越多的地位。为了尽快入门并付出最少的努力,我们将选择 MongoDB,这是一个轻而易举的工作,因为它是 NoSQL,我们也不需要设置任何类型的模式,大大减少了我们对棘手配置的需求!

警告!MongoDB 的默认设置非常容易做到,但默认情况下不会启用任何安全性,因此任何具有对该容器的网络访问权限的人都可以读取和写入任何数据库中的数据。在私有云中,这可能是可以接受的,但在任何其他情况下,这都不应该做,因此请记住,如果您计划部署 MongoDB,请确保至少设置了某种隔离和/或身份验证。

我们在这里的整个数据库设置将非常简单,如果我们不需要通过软件包更新来加固它,我们甚至不需要自定义一个:

FROM mongo:3

# Make sure we are fully up to date
RUN apt-get update -q && \
 apt-get dist-upgrade -y && \
 apt-get clean && \
 apt-get autoclean

当我们运行它时唯一需要考虑的是确保从主机将容器的数据库存储卷(/var/lib/mongodb)挂载到容器中,以便在容器停止时保留它,但是一旦我们开始启动容器组,我们可以担心这一点。

应用程序服务器

对于这个组件,我们将选择一个需要最少样板代码就能使服务运行的框架,大多数人今天会说是 Node.js 和 Express。由于 Node.js 是基于 JavaScript 的,而 JavaScript 最初是基于类似 Java 的语法的,大多数熟悉 HTML 的人应该能够弄清楚应用程序代码在做什么,但在我们到达那里之前,我们需要定义我们的 Node 包和我们的依赖项,所以在与web_server同级的目录下创建一个新的application_server目录,并将以下内容添加到一个名为package.json的文件中:

{
  "name": "application-server",
  "version": "0.0.1",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "⁴.15.4"
  }
}

这里真的没有什么神奇的东西;我们只是使用了一个 Node 包定义文件来声明我们需要 Express 作为一个依赖项,并且我们的npm start命令应该运行node index.js

让我们现在也制作我们的 Dockerfile:

FROM node:8

# Make sure we are fully up to date
RUN apt-get update -q && \
 apt-get dist-upgrade -y && \
 apt-get clean && \
 apt-get autoclean

# Container port that should get exposed
EXPOSE 8000

# Setup any variables we need
ENV SRV_PATH /usr/local/share/word_test

# Make our directory
RUN mkdir -p $SRV_PATH && \
 chown node:node $SRV_PATH

WORKDIR $SRV_PATH

USER node

COPY . $SRV_PATH/

RUN npm install

CMD ["npm", "start"]

这些东西对很多人来说应该非常熟悉,特别是对于熟悉 Node 的人来说。我们从node:8镜像开始,添加我们的应用程序代码,安装我们在package.json中定义的依赖项(使用npm install),然后最后确保应用程序在从docker CLI 运行时启动。

这里的顺序对于避免缓存破坏和确保适当的权限非常重要。我们将那些我们不指望会经常更改的东西(USERWORKDIREXPOSEmkdirchown)放在COPY上面,因为与应用程序代码相比,它们更不可能更改,并且它们大部分是可互换的,我们按照我们认为未来最不可能更改的顺序排列它们,以防止重建层和浪费计算资源。

这里还有一个特定于 Node.js 的图像优化技巧:由于npm install通常是处理 Node 应用程序代码更改中最耗时和 CPU 密集的部分,您甚至可以通过仅复制package.json,运行npm install,然后将其余文件复制到容器中来进一步优化这个 Dockerfile。以这种方式创建容器只会在package.json更改时执行昂贵的npm install,并且通常会大幅提高构建时间,但出于不希望通过特定于框架的优化来使我们的主要对话偏离主题的目的,本示例中将其排除在外。

到目前为止,我们实际上还没有定义任何应用程序代码,所以让我们也看看它是什么样子。首先,我们需要一个 HTML 视图作为我们的登陆页面,我们可以使用pug(以前也被称为jade)模板很快地创建一个。创建一个views/文件夹,并将其放在该文件夹中名为index.pug的文件中:

html
  head
    title Docker words
  body
    h1 Saved Words

    form(method='POST' action='/new')
        input.form-control(type='text', placeholder='New word' name='word')
        button(type='submit') Save

    ul
        for word in words
            li= word

您不必对这种模板样式了解太多,只需知道它是一个简单的 HTML 页面,在渲染时我们将显示传递给它的words数组中的所有项目,如果输入了一个新单词,将会有一个表单提交为POST请求到/new端点。

主要应用逻辑

这里没有简单的方法,但我们的主要应用逻辑文件index.js不会像其他配置文件那样简单:

'use strict'

// Load our dependencies
const bodyParser = require('body-parser')
const express = require('express');
const mongo = require('mongodb')

// Setup database and server constants
const DB_NAME = 'word_database';
const DB_HOST = process.env.DB_HOST || 'localhost:27017';
const COLLECTION_NAME = 'words';
const SERVER_PORT = 8000;

// Create our app, database clients, and the word list array
const app = express();
const client = mongo.MongoClient();
const dbUri = `mongodb://${DB_HOST}/${DB_NAME}`;
const words = [];

// Setup our templating engine and form data parser
app.set('view engine', 'pug')
app.use(bodyParser.urlencoded({ extended: false }))

// Load all words that are in the database
function loadWordsFromDatabase() {
    return client.connect(dbUri).then((db) => {
        return db.collection(COLLECTION_NAME).find({}).toArray();
    })
    .then((docs) => {
        words.push.apply(words, docs.map(doc => doc.word));
        return words;
    });
}

// Our main landing page handler
app.get('/', (req, res) => {
    res.render('index', { words: words });
});

// Handler for POSTing a new word
app.post('/new', (req, res) => {
    const word = req.body.word;

    console.info(`Got word: ${word}`);
    if (word) {
        client.connect(dbUri).then((db) => {
            db.collection(COLLECTION_NAME).insertOne({ word }, () => {
                db.close();
                words.push(word);
            });
        });
    }

    res.redirect('/');
});

// Start everything by loading words and then starting the server 
loadWordsFromDatabase().then((words) => {
    console.info(`Data loaded from database (${words.length} words)`);
    app.listen(SERVER_PORT, () => {
        console.info("Server started on port %d...", SERVER_PORT);
    });
});

这个文件一开始可能看起来令人生畏,但这可能是您可以从头开始制作的最小的完全功能的 API 服务。

如果您想了解更多关于 Node、Express 或 MongoDB 驱动程序的信息,您可以访问nodejs.org/en/expressjs.com/github.com/mongodb/node-mongodb-native。如果您不想打字,您也可以从github.com/sgnn7/deploying_with_docker/复制并粘贴此文件。

该应用的基本操作如下:

  • MongoDB数据库加载任何现有的单词

  • 保留该列表的副本在一个变量中,这样我们只需要从数据库中获取一次东西

  • 打开端口8000并监听请求

  • 如果我们收到/GET请求,返回渲染的index.html模板,并用单词列表数组填充它

  • 如果我们收到/newPOST请求:

  • 将值保存在数据库中

  • 更新我们的单词列表

  • 发送我们回到/

然而,这里有一部分需要特别注意:

const DB_HOST = process.env.DB_HOST || 'localhost:27017';

记得我们之前提到过,很多图像配置应该在环境变量中完成吗?这正是我们在这里要做的!如果设置了环境变量DB_HOST(正如我们期望在作为容器运行时设置),我们将使用它作为主机名,但如果没有提供(正如我们在本地运行时期望的那样),它将假定数据库在标准的 MongoDB 端口上本地运行。这提供了作为容器可配置的灵活性,并且可以在 Docker 之外由开发人员在本地进行测试。

主逻辑文件就位后,我们的服务现在应该有一个类似的文件系统布局:

$ tree ./
./
├── Dockerfile
├── index.js
├── package.json
└── views
    └── index.pug

1 directory, 4 files

由于这实际上是三个中最容易测试的部分,让我们在本地安装 MongoDB 并看看服务的表现。您可以访问docs.mongodb.com/manual/installation/获取有关如何在其他平台上手动安装的信息,但我已经包含了以下步骤来在 Ubuntu 16.04 上手动执行此操作:

$ # Install MongoDB
$ sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 0C49F3730359A14518585931BC711F9BA15703C6
$ echo "deb [ arch=amd64,arm64 ] http://repo.mongodb.org/apt/ubuntu xenial/mongodb-org/3.4 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-3.4.list

$ sudo apt-get update 
$ sudo apt-get install -y mongodb-org
$ sudo systemctl start mongodb

$ # Install our service dependencies
$ npm install
application-server@0.0.1 /home/sg/checkout/deploying_with_docker/chapter_3/prototype_service/application_server
<snip>
npm WARN application-server@0.0.1 No license field.

$ # Run the service</strong>
$ npm start
> application-server@0.0.1 start /home/sg/checkout/deploying_with_docker/chapter_3/prototype_service/application_server
> node index.js

Data loaded from database (10 words)
Server started on port 8000...

看起来工作正常:让我们通过访问http://localhost:8000来检查浏览器!

让我们在里面放几个词,看看会发生什么:

到目前为止,一切都很顺利!最后的测试是重新启动服务,并确保我们看到相同的列表。按下Ctrl + C退出我们的 Node 进程,然后运行npm start。您应该再次看到相同的列表,这意味着它按预期工作!

一起运行

因此,我们已经弄清楚了我们的web_serverapplication_serverdatabase容器。在继续之前,请验证您是否拥有所有与这些匹配的文件:

$ tree .
.
├── application_server
│   ├── Dockerfile
│   ├── index.js
│   ├── package.json
│   └── views
│       └── index.pug
├── database
│   └── Dockerfile
└── web_server
 ├── Dockerfile
 └── nginx_main_site.conf

4 directories, 7 files

我们的下一步是构建所有的容器:

 $ # Build the app server image
 $ cd application_server
 $ docker build -t application_server .
 Sending build context to Docker daemon 34.3kB
 Step 1/10 : FROM node:8
 <snip>
 Successfully built f04778cb3778
 Successfully tagged application_server:latest

 $ # Build the database image
 $ cd ../database
 $ docker build -t database .
 Sending build context to Docker daemon 2.048kB
 Step 1/2 : FROM mongo:3
 <snip>
 Successfully built 7c0f9399a152
 Successfully tagged database:latest

 $ # Build the web server image
 $ cd ../web_server
 $ docker build -t web_server .
 Sending build context to Docker daemon 3.584kB
 Step 1/8 : FROM nginx:latest
 <snip>
 Successfully built 738c17ddeca8
 Successfully tagged web_server:latest

这种顺序构建非常适合显示每个步骤需要做什么,但始终考虑自动化以及如何改进手动流程。在这种特殊情况下,这整个语句和执行块也可以从父目录中用这一行完成:for dir in *; do cd $dir; docker build -t $dir .; cd ..; done

启动

有了这三个相关的容器,我们现在可以启动它们。需要注意的是,它们需要按照我们的应用程序尝试读取数据库中的数据的顺序启动,如果应用程序不存在,我们不希望 Web 服务器启动,因此我们将按照这个顺序启动它们:database -> application_server -> web_server

$ docker run --rm \
             -d \
             -p 27000:27017 \
             database
3baec5d1ceb6ec277a87c46bcf32f3600084ca47e0edf26209ca94c974694009

$ docker run --rm \
             -d \
             -e DB_HOST=172.17.0.1:27000 \
             -p 8000:8000 \
             application_server
dad98a02ab6fff63a2f4096f4e285f350f084b844ddb5d10ea3c8f5b7d1cb24b

$ docker run --rm \
             -d \
             -p 8080:80 \
             web_server
3ba3d1c2a25f26273592a9446fc6ee2a876904d0773aea295a06ed3d664eca5d

$ # Verify that all containers are running
$ docker ps --format "table {{.Image}}\t{{.Status}}\t{{.ID}}\t{{.Ports}}"
IMAGE                STATUS              CONTAINER ID        PORTS
web_server           Up 11 seconds       3ba3d1c2a25f        0.0.0.0:8080->80/tcp
application_server   Up 26 seconds       dad98a02ab6f        0.0.0.0:8000->8000/tcp
database             Up 45 seconds       3baec5d1ceb6        0.0.0.0:27000->27017/tcp

这里有几件事需要注意:

  • 我们故意将本地端口27000映射到数据库27017,以避免与主机上已经运行的 MongoDB 数据库发生冲突。

  • 我们将神奇的172.17.0.1 IP 作为主机和端口27000传递给我们的应用服务器,用作数据库主机。

  • 我们将 Web 服务器启动在端口8080上,而不是80,以确保我们不需要 root 权限*。

如果您没有看到三个正在运行的容器,请使用docker logs <container id>检查日志。最有可能的罪魁祸首可能是容器上的 IP/端口与目的地之间的不匹配,因此只需修复并重新启动失败的容器,直到您有三个正在运行的容器。如果您遇到很多问题,请毫不犹豫地通过从我们使用的命令中删除-d标志来以非守护程序模式启动容器。* - 在*nix 系统上,低于1024的端口称为注册或特权端口,它们管理系统通信的许多重要方面。为了防止对这些系统端口的恶意使用,几乎所有这些平台都需要 root 级别的访问权限。由于我们并不真的关心我们将用于测试的端口,我们将通过选择端口 8080 来完全避免这个问题。

这个设置中的信息流大致如下:

Browser <=> localhost:8080 <=> web_server:80 <=> 172.17.0.1:8000 (Docker "localhost") <=> app_server <=> 172.17.0.1:27000 (Docker "localhost") <=> database:27017

测试

我们所有的部件都在运行,所以让我们在http://localhost:8080上试试看!

很好,我们的身份验证正在工作!让我们输入我们超级秘密的凭据(用户:user,密码:test)。

一旦我们登录,我们应该能够看到我们的应用服务器接管请求的处理,并给我们一个表单来输入我们想要保存的单词:

正如我们所希望的,一旦我们进行身份验证,应用服务器就会处理请求!输入一些单词,看看会发生什么:

恭喜!您已经创建了您的第一个容器化服务!

我们实现的限制和问题

我们应该花一分钟时间考虑如果要在真实系统中使用它,我们服务的哪些部分可能需要改进,以及最优/实际的缓解措施可能是什么。由于处理容器和云的关键部分是评估更大体系结构的利弊,这是您在开发新系统或更改现有系统时应该尝试做的事情。

从粗略的观察来看,这些是可以改进的明显事项,影响是什么,以及可能的缓解措施是什么:

  • 数据库没有身份验证

  • 类别:安全性,影响非常大

  • 缓解措施:私有云或使用身份验证

  • 数据库数据存储在 Docker 容器中(如果容器丢失,则数据也会丢失)

  • 类别:稳定性,影响严重

  • 缓解措施:挂载卷和/或分片和集群

  • 硬编码的端点

  • 类别:运维,影响非常大

  • 缓解措施:服务发现(我们将在后面的章节中介绍)

  • 应用服务器假设它是唯一更改单词列表的

  • 类别:扩展性,影响非常大

  • 缓解措施:在每次页面加载时刷新数据

  • 应用服务器在容器启动时需要数据库

  • 类别:扩展性/运维,中等影响

  • 缓解措施:延迟加载直到页面被点击和/或显示数据库不可用的消息

  • Web 服务器身份验证已经嵌入到镜像中

  • 类别:安全性,影响严重

  • 缓解措施:在运行时添加凭据

  • Web 服务器身份验证是通过 HTTP 完成的

  • 类别:安全性,影响非常大

  • 缓解措施:使用 HTTPS 和/或 OAuth

修复关键问题

由于我们在 Docker 的旅程中还处于早期阶段,现在我们只会涵盖一些最关键问题的解决方法,这些问题如下:

  • 数据库数据存储在 Docker 容器中(如果容器丢失,数据也会丢失)。

  • Web 服务器身份验证已经内置到镜像中。

使用本地卷

第一个问题是一个非常严重的问题,因为我们所有的数据目前都与我们的容器绑定,所以如果数据库应用停止,您必须重新启动相同的容器才能恢复数据。在这种情况下,如果容器使用--rm标志运行并停止或以其他方式终止,与其关联的所有数据将消失,这绝对不是我们想要的。虽然针对这个问题的大规模解决方案是通过分片、集群和/或持久卷来完成的,但我们只需直接将数据卷挂载到容器中的所需位置即可。如果容器发生任何问题,这样可以将数据保留在主机文件系统上,并且可以根据需要进一步备份或移动到其他地方。

将目录挂载到容器中的这个过程实际上相对容易,如果我们的卷是存储在 Docker 内部的一个命名卷的话:

$ docker run --rm -d -v local_storage:/data/db -p 27000:27017 database

这将在 Docker 的本地存储中创建一个名为local_storage的命名卷,它将无缝地挂载到容器中的/data/db(这是 MongoDB 镜像在 Docker Hub 中存储数据的地方)。如果容器死掉或发生任何事情,您可以将此卷挂载到另一个容器上并保留数据。

-v--volume和使用命名卷并不是为 Docker 容器创建卷的唯一方法。我们将在第五章中更详细地讨论为什么我们使用这种语法而不是其他选项(即--mount),该章节专门涉及卷的持久性。

让我们看看这在实际中是如何运作的(这可能需要在您的主机上安装一个 MongoDB 客户端 CLI):

$ # Start our container
$ docker run --rm \
             -d \
             -v local_storage:/data/db \
             -p 27000:27017 \
             database
16c72859da1b6f5fbe75aa735b539303c5c14442d8b64b733eca257dc31a2722

$ # Insert a test record in test_db/coll1 as { "item": "value" }
$ mongo localhost:27000
MongoDB shell version: 2.6.10
connecting to: localhost:27000/test

> use test_db
switched to db test_db
 > db.createCollection("coll1")
{ "ok" : 1 }
 > db.coll1.insert({"item": "value"})
WriteResult({ "nInserted" : 1 })
 > exit
bye

$ # Stop the container. The --rm flag will remove it.
$ docker stop 16c72859
16c72859

$ # See what volumes we have
$ docker volume ls
DRIVER              VOLUME NAME
local               local_storage

$ # Run a new container with the volume we saved data onto
$ docker run --rm \
             -d \
             -v local_storage:/data/db \
             -p 27000:27017 \
             database
a5ef005ab9426614d044cc224258fe3f8d63228dd71dee65c188f1a10594b356

$ # Check if we have our records saved
$ mongo localhost:27000
MongoDB shell version: 2.6.10
connecting to: localhost:27000/test

> use test_db
switched to db test_db
 > db.coll1.find()
{ "_id" : ObjectId("599cc7010a367b3ad1668078"), "item" : "value" }
 > exit

$ # Cleanup
$ docker stop a5ef005a
a5ef005a

正如您所看到的,我们的记录经过了原始容器的销毁而得以保留,这正是我们想要的!我们将在后面的章节中涵盖如何以其他方式处理卷,但这应该足以让我们解决我们小服务中的这个关键问题。

在运行时生成凭据

与数据库问题不同,这个特定问题不那么容易处理,主要是因为从安全角度来看,凭据是一个棘手的问题。如果你包含一个构建参数或内置的环境变量,任何有权访问镜像的人都可以读取它。此外,如果你在容器创建过程中通过环境变量传递凭据,任何具有 docker CLI 访问权限的人都可以读取它,所以你基本上只能将凭据挂载到容器的卷上。

还有一些其他安全地传递凭据的方法,尽管它们有点超出了本练习的范围,比如包含哈希密码的环境变量,使用代理秘密共享服务,使用特定于云的角色机制(即 AWS,IAM 角色,“用户数据”)等等,但对于本节来说,重要的是要理解在处理身份验证数据时应该尽量避免做哪些事情。

为了解决这个问题,我们将在主机上生成自己的凭据文件,并在容器启动时将其挂载到容器上。用你想要的任何用户名替换user123,用包含字母数字的密码替换password123

$ printf "user123:$(openssl passwd -1 password123)\n" >> ~/test_htpasswd

$ # Start the web_server with our password as the credentials source
$ docker run --rm \
             -v $HOME/test_htpasswd:/srv/www/html/.htpasswd \
             -p 8080:80 web_server
1b96c35269dadb1ac98ea711eec4ea670ad7878a933745678f4385d57e96224a

通过这个小改变,你的 Web 服务器现在将使用新的用户名和新的密码进行安全保护,并且配置也不会被能够运行 docker 命令的人所获取。你可以访问127.0.0.1:8080来查看新的用户名和密码是唯一有效的凭据。

引入 Docker 网络

在较早的时候,我们已经略微提到了在web_server代码中使用 IP172.17.0.1,这在其他材料中并没有得到很好的涵盖,但如果你想对 Docker 有一个扎实的理解,这是非常重要的事情。当在一台机器上启动 Docker 服务时,会向您的机器添加一些网络iptables规则,以允许容器通过转发连接到世界,反之亦然。实际上,您的机器变成了所有启动的容器的互联网路由器。除此之外,每个新容器都被分配一个虚拟地址(很可能在172.17.0.2+的范围内),它所进行的任何通信通常对其他容器是不可见的,除非创建了一个软件定义的网络,因此在同一台机器上连接多个容器实际上是一个非常棘手的任务,如果没有 Docker 基础设施中称为服务发现的辅助软件。

由于我们现在不想要这个服务发现的开销(我们稍后会更深入地介绍),并且我们不能使用localhost/127.0.0.1/::1,这根本行不通,我们需要给它 Docker 虚拟路由器 IP(几乎总是172.17.0.1),这样它就能找到我们的实际机器,其他容器端口已经绑定在那里。

请注意,由于 macOS 和 Windows 机器的网络堆栈实现方式,本节的大部分内容在这些系统上都无法工作。对于这些系统,我建议您使用 Ubuntu 虚拟机来跟随操作。

如果您想验证这一点,我们可以使用一些命令在 Docker 内外来真正看到发生了什么:

$ # Host's iptables. If you have running containers, DOCKER chain wouldn't be empty.
$ sudo iptables -L
<snip>
Chain FORWARD (policy DROP)
target     prot opt source               destination 
DOCKER-ISOLATION  all  --  anywhere             anywhere 
ACCEPT     all  --  anywhere             anywhere             ctstate RELATED,ESTABLISHED
DOCKER     all  --  anywhere             anywhere
ACCEPT     all  --  anywhere             anywhere
ACCEPT     all  --  anywhere             anywhere
<snip>
Chain DOCKER (1 references)
target     prot opt source               destination 

Chain DOCKER-ISOLATION (1 references)
target     prot opt source               destination 
RETURN     all  --  anywhere             anywhere 
<snip>

$ # Host's network addresses is 172.17.0.1
$ ip addr
<snip>
5: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
 link/ether 02:42:3c:3a:77:c1 brd ff:ff:ff:ff:ff:ff
 inet 172.17.0.1/16 scope global docker0
 valid_lft forever preferred_lft forever
 inet6 fe80::42:3cff:fe3a:77c1/64 scope link 
 valid_lft forever preferred_lft forever
<snip>

$ # Get container's network addresses
$ docker run --rm \
             -it \
             web_server /bin/bash
 root@08b6521702ef:/# # Install pre-requisite (iproute2) package
root@08b6521702ef:/# apt-get update && apt-get install -y iproute2
<snip>
 root@08b6521702ef:/# # Check the container internal address (172.17.0.2)
root@08b6521702ef:/# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
 inet 127.0.0.1/8 scope host lo
 valid_lft forever preferred_lft forever
722: eth0@if723: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
 link/ether 02:42:ac:11:00:02 brd ff:ff:ff:ff:ff:ff link-netnsid 0
 inet 172.17.0.2/16 scope global eth0
 valid_lft forever preferred_lft forever
 root@08b6521702ef:/# # Verify that our main route is through our host at 172.17.0.1
root@08b6521702ef:/# ip route
default via 172.17.0.1 dev eth0
172.17.0.0/16 dev eth0 proto kernel scope link src 172.17.0.2
 root@08b6521702ef:/# exit

正如您所看到的,这个系统有点奇怪,但它运行得相当不错。通常在构建更大的系统时,服务发现几乎是强制性的,因此您不必在现场担心这样的低级细节。

总结

在本章中,我们介绍了如何构建多个容器,以构建由 Web 服务器、应用服务器和数据库组成的基本服务,同时启动多个容器,并通过网络将它们连接在一起。我们还解决了连接服务时可能出现的最常见问题,以及这些基本构建模块的常见陷阱。还提到了一些关于未来主题的提示(卷、服务发现、凭据传递等),但我们将在以后的章节中深入讨论这些内容。在下一章中,我们将把我们的小服务转变成具有水平扩展组件的强大服务。

第四章:扩展容器

在本章中,我们将使用我们的服务,并尝试通过多个相同容器的实例来水平扩展它。我们将在本章中涵盖以下主题:

  • 编排选项及其优缺点

  • 服务发现

  • 状态协调

  • 部署自己的 Docker Swarm 集群

  • 将我们在上一章中的 word 服务部署到该集群上

服务发现

在我们进一步之前,我们真的需要深入了解概念上的 Docker 容器连通性,这在某种程度上与在非容器化世界中使用服务器构建高可用服务非常相似。因此,深入探讨这个主题不仅会扩展您对 Docker 网络的理解,还有助于通常构建出弹性服务。

Docker 网络的回顾

在上一章中,我们介绍了一些 Docker 网络布局,所以我们将在这里介绍主要内容:

  • 默认情况下,Docker 容器在主机上运行在一个隔离的虚拟网络中

  • 每个容器在该网络中都有自己的网络地址

  • 默认情况下,容器的 localhost 不是 主机机器的 localhost

  • 手动连接容器存在很高的人工工作开销

  • 容器之间的手动网络连接本质上是脆弱的

在设置本地服务器网络的并行世界中,Docker 连通性的基本体验非常类似于使用静态 IP 连接整个网络。虽然这种方法并不难以实现,但维护起来非常困难和费力,这就是为什么我们需要比这更好的东西。

深入了解服务发现

由于我们不想处理这种脆弱的保持和维护硬编码 IP 地址的系统,我们需要找出一种方法,使我们的连接灵活,并且不需要客户端进行任何调整,如果目标服务死掉或创建一个新的服务。如果每个对同一服务的连接在所有相同服务的实例之间平衡,那就更好了。理想情况下,我们的服务看起来应该是这样的:

对于互联网的这种确切用例,DNS 被创建出来,以便客户端可以在世界各地找到服务器,即使 IP 地址或网络发生变化。作为一个附加好处,我们有更容易记住的目标地址(DNS 名称,如google.com,而不是诸如https://123.45.67.89之类的东西),并且可以将处理分配给尽可能多的处理服务。

如果您没有深入研究 DNS,主要原则可以归纳为这些基本步骤:

  1. 用户(或应用程序)想要连接到一个服务器(即google.com)。

  2. 本地机器要么使用自己缓存的 DNS 答案,要么去 DNS 系统搜索这个名称。

  3. 本地机器得到应该用作目标的 IP 地址(123.45.67.89)。

  4. 本地机器连接到 IP 地址。

DNS 系统比这里提到的单个句子要复杂得多。虽然 DNS 是任何面向服务器的技术职位中了解的一件非常好的事情,在这里,只需要知道 DNS 系统的输入是主机名,输出是真正的目标(IP)就足够了。如果您想了解 DNS 系统实际上是如何工作的更多信息,我建议您在闲暇时访问en.wikipedia.org/wiki/Domain_Name_System

如果我们强迫几乎所有客户端中已实现的 DNS 处理作为一种自动发现服务的方式,我们可以使自己成为我们一直在寻找的服务发现机制!如果我们使它足够智能,它可以告诉我们正在运行的容器在哪里,平衡相同容器的所有实例,并为我们提供一个静态名称作为我们的目标使用。正如人们可能期望的那样,几乎所有容器服务发现系统都具有这种功能模式;只是通常有所不同,无论是作为客户端发现模式、服务器端发现模式,还是某种混合系统。

客户端发现模式

这种类型的模式并不经常使用,但它基本上涉及使用服务感知客户端来发现其他服务并在它们之间进行负载平衡。这里的优势在于客户端可以智能地决定连接到哪里以及以何种方式,但缺点是这种决策分布在每个服务上并且难以维护,但它不依赖于单一的真相来源(单一服务注册表),如果它失败,可能会导致整个集群崩溃。

体系结构通常看起来类似于这样:

服务器端发现模式

更常见的服务发现模式是集中式服务器端发现模式,其中使用 DNS 系统将客户端引导到容器。在这种特定的服务发现方式中,容器会从服务注册表中注册和注销自己,该注册表保存系统的状态。这种状态反过来用于填充 DNS 条目,然后客户端联系这些条目以找到它试图连接的目标。虽然这个系统通常相当稳定和灵活,但有时会遇到非常棘手的问题,通常会妨碍 DNS 系统的其他地方,比如 DNS 缓存,它使用过时的 IP 地址,直到生存时间(TTL)到期,或者应用程序本身缓存 DNS 条目而不管更新(NGINX 和 Java 应用程序以此著称)。

混合系统

这个分组包括我们尚未涵盖的所有其他组合,但它涵盖了使用工具 HAProxy 的最大部署类别,我们稍后将详细介绍。它基本上是将主机上的特定端口(即<host>:10101)与集群中的负载平衡目标绑定起来。

从客户端的角度来看,他们连接到一个单一且稳定的位置,然后 HAProxy 将其无缝地隧道到正确的目标。

这种设置支持拉取和推送刷新方法,并且非常有韧性,但我们将在后面的章节中深入探讨这种类型的设置。

选择(不)可用的选项

有了所有这些类型的服务发现可用,我们应该能够处理任何我们想要的容器扩展,但我们需要牢记一件非常重要的事情:几乎所有服务发现工具都与用于部署和管理容器的系统(也称为容器编排)紧密相关,因为容器端点的更新通常只是编排系统的实现细节。因此,服务发现系统通常不像人们希望的那样可移植,因此这种基础设施的选择通常由您的编排工具决定(偶尔会有一些例外)。

容器编排

正如我们稍早所暗示的,服务发现是在任何情况下部署基于容器的系统的关键部分。如果没有类似的东西,你可能会选择使用裸机服务器,因为使用容器获得的大部分优势都已经丧失了。要拥有有效的服务发现系统,你几乎必须使用某种容器编排平台,而幸运的是(或者可能是不幸的?),容器编排的选择几乎以惊人的速度不断涌现!总的来说,在撰写本书时(以及在我谦逊的意见中),流行且稳定的选择主要归结为以下几种:

  • Docker Swarm

  • Kubernetes

  • Apache Mesos/Marathon

  • 基于云的服务(Amazon ECS,Google Container Engine,Azure Container Service 等)

每个都有自己的词汇表和基础设施连接方式,因此在我们进一步之前,我们需要涵盖有关编排服务的相关词汇,这些词汇在所有这些服务之间大多是可重复使用的:

  • 节点:Docker 引擎的一个实例。通常仅在谈论集群连接的实例时使用。

  • 服务:由一个或多个运行中的相同 Docker 镜像实例组成的功能组。

  • 任务:运行服务的特定和唯一实例。通常是一个运行中的 Docker 容器。

  • 扩展:指定服务运行的任务数量。这通常决定了服务可以支持多少吞吐量。

  • 管理节点:负责集群管理和编排任务的节点。

  • 工作节点:指定为任务运行者的节点。

状态协调

除了我们刚学到的字典,我们还需要了解几乎所有编排框架的基本算法,状态协调,它值得在这里有自己的小节。这个工作的基本原则是一个非常简单的三步过程,如下:

  • 用户设置每个服务或服务消失的期望计数。

  • 编排框架看到了改变当前状态到期望状态所需的内容(增量评估)。

  • 执行任何需要将集群带到该状态的操作(称为状态协调)。

例如,如果我们当前在集群中为一个服务运行了五个任务,并将期望状态更改为只有三个任务,我们的管理/编排系统将看到差异为-2,因此选择两个随机任务并无缝地杀死它们。相反,如果我们有三个正在运行的任务,而我们想要五个,管理/编排系统将看到期望的增量为+2,因此它将选择两个具有可用资源的位置,并启动两个新任务。对两个状态转换的简要解释也应该有助于澄清这个过程:

Initial State: Service #1 (3 tasks), Service #2 (2 tasks)
Desired State: Service #1 (1 task),  Service #2 (4 tasks)

Reconciliation:
 - Kill 2 random Service #1 tasks
 - Start 2 Service #2 tasks on available nodes

New Initial State: Service #1 (1 tasks), Service #2 (4 tasks)

New Desired State: Service #1 (2 tasks), Service #2 (0 tasks)

Reconciliation:
 - Start 1 tasks of Service #1 on available node
 - Kill all 4 running tasks of Service #2

Final State: Service #1 (2 tasks), Service #2 (0 tasks)

使用这个非常简单但强大的逻辑,我们可以动态地扩展和缩小我们的服务,而不必担心中间阶段(在一定程度上)。在内部,保持和维护状态是一个非常困难的任务,大多数编排框架使用特殊的高速键值存储组件来为它们执行此操作(即etcdZooKeeperConsul)。

由于我们的系统只关心当前状态和需要的状态,这个算法也兼作建立弹性的系统,当一个节点死掉,或者容器减少了应用程序的当前任务计数,将自动触发状态转换回到期望的计数。只要服务大多是无状态的,并且你有资源来运行新的服务,这些集群对几乎任何类型的故障都是有弹性的,现在你可以希望看到一些简单的概念如何结合在一起创建这样一个强大的基础设施。

有了我们对管理和编排框架基础的新理解,我们现在将简要地看一下我们可用选项中的每一个(Docker Swarm,Kubernetes,Marathon),并看看它们如何相互比较。

Docker Swarm

Docker 默认包含一个编排框架和一个管理平台,其架构与刚才介绍的非常相似,称为 Docker Swarm。Swarm 允许以相对较快和简单的方式将扩展集成到您的平台中,而且几乎不需要时间来适应,而且它已经是 Docker 本身的一部分,因此您实际上不需要太多其他东西来在集群环境中部署一组简单的服务。作为额外的好处,它包含一个相当可靠的服务发现框架,具有多主机网络能力,并且在节点之间使用 TLS 进行通信。

多主机网络能力是系统在多台物理机器之间创建虚拟网络的能力,从容器的角度来看,这些物理机器是透明的。使用其中之一,您的容器可以彼此通信,就好像它们在同一个物理网络上一样,简化了连接逻辑并降低了运营成本。我们稍后将深入研究集群的这一方面。

Docker Swarm 的集群配置可以是一个简单的 YAML 文件,但缺点是,在撰写本文时,GUI 工具有些欠缺,尽管 Portainer(portainer.io)和 Shipyard(shipyard-project.com)正在变得相当不错,所以这可能不会是一个长期的问题。此外,一些大规模的运维工具缺失,似乎 Swarm 的功能正在大幅发展,因此处于不稳定状态,因此我的个人建议是,如果您需要快速在小到大规模上运行某些东西,可以使用这种编排。随着这款产品变得越来越成熟(并且由于 Docker Inc.正在投入大量开发资源),它可能会有显著改进,我期望它在许多方面能够与 Kubernetes 功能相匹敌,因此请密切关注其功能新闻。

Kubernetes

Kubernetes 是谷歌的云平台和编排引擎,目前在功能方面比 Swarm 提供了更多。Kubernetes 的设置要困难得多,因为你需要:一个主节点,一个节点(根据我们之前的词典,这是工作节点),以及 pod(一个或多个容器的分组)。Pod 始终是共同定位和共同调度的,因此处理它们的依赖关系会更容易一些,但你不会得到相同的隔离。在这里需要记住的有趣的事情是,pod 内的所有容器共享相同的 IP 地址/端口,共享卷,并且通常在相同的隔离组内。几乎可以将 pod 视为运行多个服务的小型虚拟机,而不是并行运行多个容器。

Kubernetes 最近一直在获得大量的社区关注,可能是最被部署的集群编排和管理系统,尽管要公平地说,找到确切的数字是棘手的,其中大多数被部署在私有云中。考虑到谷歌已经在如此大规模上使用这个系统,它有着相当成熟的记录,我可能会推荐它用于中大规模。如果你不介意设置一切的开销,我认为即使在较小规模上也是可以接受的,但在这个领域,Docker Swarm 非常容易使用,因此使用 Kubernetes 通常是不切实际的。

在撰写本书时,Mesos 和 Docker EE 都已经包含了支持 Kubernetes 的功能,所以如果你想要在编排引擎上打赌,这可能就是它!

Apache Mesos/Marathon

当你真的需要将规模扩大到 Twitter 和 Airbnb 的级别时,你可能需要比 Swarm 或 Kubernetes 更强大的东西,这就是 Mesos 和 Marathon 发挥作用的地方。Apache Mesos 实际上并不是为 Docker 而建立的,而是作为一种通用的集群管理工具,以一种一致的方式为在其之上运行的应用程序提供资源管理和 API。你可以相对容易地运行从脚本、实际应用程序到多个平台(如 HDFS 和 Hadoop)的任何东西。对于这个平台上基于容器的编排和调度,Marathon 是通用的选择。

正如稍早提到的,Kubernetes 支持现在又可以在 Mesos 上使用了,之前一段时间处于破碎状态,因此在您阅读本文时,对 Marathon 的建议可能会改变。

Marathon 作为 Mesos 上的应用程序(在非常宽松的意义上)运行作为容器编排平台,并提供各种便利,如出色的用户界面(尽管 Kubernetes 也有一个),指标,约束,持久卷(在撰写本文时为实验性质),以及其他许多功能。作为一个平台,Mesos 和 Marathon 可能是处理成千上万个节点的集群最强大的组合,但要将所有东西组合在一起,除非您使用预打包的 DC/OS 解决方案(dcos.io/),根据我的经验,与其他两种方法相比,真的非常棘手。如果您需要覆盖中等到最大规模,并增加灵活性以便在其上运行其他平台(如 Chronos),目前,我强烈推荐这种组合。

基于云的服务

如果所有这些似乎太麻烦,而且您不介意每个月支付高昂的费用,所有大型云服务提供商都有某种基于容器的服务提供。由于这些服务在功能和特性方面差异很大,任何放在这个页面上的内容在发布时可能已经过时,而我们更感兴趣的是部署我们自己的服务,因此我将为您提供适当的服务的链接,这些链接将提供最新的信息,如果您选择这条路线:

就我个人而言,我会推荐这种方法用于中小型部署,因为它易于使用并且经过测试。如果您的需求超出了这些规模,通常有一种方法是在虚拟私有云VPCs)上的可扩展虚拟机组上实施您的服务,因为您可以根据需求扩展自己的基础架构,尽管前期的 DevOps 成本不小,所以请据此决定。几乎任何云服务提供商提供的一个良好的经验法则是,通过提供易用的工具,您可以获得更快的部署速度,但代价是成本增加(通常是隐藏的)和缺乏灵活性/可定制性。

实施编排

通过我们新获得的对编排和管理工具的理解,现在是时候自己尝试一下了。在我们的下一个练习中,我们将首先尝试使用 Docker Swarm 来创建并在本地集群上进行一些操作,然后我们将尝试将上一章的服务部署到其中。

设置 Docker Swarm 集群

由于设置 Docker Swarm 集群的所有功能已经包含在 Docker 安装中,这实际上是一件非常容易的事情。让我们看看我们可以使用哪些命令:

$ docker swarm
<snip>
Commands:
 init        Initialize a swarm
 join        Join a swarm as a node and/or manager
 join-token  Manage join tokens
 leave       Leave the swarm
 unlock      Unlock swarm
 unlock-key  Manage the unlock key
 update      Update the swarm

这里有几件事情需要注意--有些比其他更明显:

  • 您可以使用 docker swarm init 创建一个集群

  • 您可以使用 docker swarm join 加入一个集群,该机器可以是工作节点、管理节点或两者兼而有之

  • 身份验证是使用令牌(需要匹配的唯一字符串)进行管理

  • 如果管理节点发生故障,例如重新启动或断电,并且您已经设置了自动锁定集群,您将需要一个解锁密钥来解锁 TLS 密钥

到目前为止,一切顺利,让我们看看我们是否可以设置一个同时作为管理节点和工作节点的集群,以了解其工作原理。

初始化 Docker Swarm 集群

要创建我们的集群,我们首先需要实例化它:

$ docker swarm init 
Swarm initialized: current node (osb7tritzhtlux1o9unlu2vd0) is now a manager.

To add a worker to this swarm, run the following command:

 docker swarm join \
 --token SWMTKN-1-4atg39hw64uagiqk3i6s3zlv5mforrzj0kk1aeae22tpsat2jj-2zn0ak0ldxo58d1q7347t4rd5 \
 192.168.4.128:2377

To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.

$ # Make sure that our node is operational
$ docker node ls
ID                           HOSTNAME  STATUS  AVAILABILITY  MANAGER STATUS
osb7tritzhtlux1o9unlu2vd0 *  feather2  Ready   Active        Leader

我们已经用那个命令创建了一个集群,并且我们自动注册为管理节点。如果您查看输出,添加工作节点的命令只是 docker swarm join --token <token> <ip>,但是我们现在只对单节点部署感兴趣,所以我们不需要担心这个。鉴于我们的管理节点也是工作节点,我们可以直接使用它来部署一些服务。

部署服务

我们最初需要的大多数命令都可以通过docker services命令访问:

$ docker service
<snip>
Commands:
 create      Create a new service
 inspect     Display detailed information on one or more services
 logs        Fetch the logs of a service or task
 ls          List services
 ps          List the tasks of one or more services
 rm          Remove one or more services
 scale       Scale one or multiple replicated services
 update      Update a service

正如你可能怀疑的那样,考虑到这些命令与管理容器的一些命令有多么相似,一旦你转移到编排平台而不是直接操作容器,你的服务的理想管理将通过编排本身完成。我可能会扩展这一点,并且会说,如果你在拥有编排平台的同时过多地使用容器,那么你没有设置好某些东西,或者你没有正确地设置它。

我们现在将尝试在我们的 Swarm 上运行某种服务,但由于我们只是在探索所有这些是如何工作的,我们可以使用一个非常简化(也非常不安全)的我们的 Python Web 服务器的版本。从第二章 Rolling Up the Sleeves。创建一个新文件夹,并将其添加到新的Dockerfile中:

FROM python:3

ENV SRV_PATH=/srv/www/html

EXPOSE 8000

RUN mkdir -p $SRV_PATH && \
 groupadd -r -g 350 pythonsrv && \
 useradd -r -m -u 350 -g 350 pythonsrv && \
 echo "Test file content" > $SRV_PATH/test.txt && \
 chown -R pythonsrv:pythonsrv $SRV_PATH

WORKDIR $SRV_PATH

CMD [ "python3", "-m", "http.server" ]

让我们构建它,以便我们的本地注册表有一个镜像可以从中拉取,当我们定义我们的服务时:

$ docker build -t simple_server .

有了这个镜像,让我们在我们的 Swarm 上部署它:

$ docker service create --detach=true \
 --name simple-server \
 -p 8000:8000 \
 simple_server 
image simple_server could not be accessed on a registry to record
its digest. Each node will access simple_server independently,
possibly leading to different nodes running different
versions of the image.

z0z90wgylcpf11xxbm8knks9m

$ docker service ls
ID           NAME          MODE       REPLICAS IMAGE         PORTS
z0z90wgylcpf simple-server replicated 1/1      simple_server *:8000->8000/tcp

所显示的警告实际上非常重要:我们构建时服务仅在我们本地机器的 Docker 注册表上可用,因此使用分布在多个节点之间的 Swarm 服务将会出现问题,因为其他机器将无法加载相同的镜像。因此,将镜像注册表从单一来源提供给所有节点对于集群部署是强制性的。随着我们在本章和接下来的章节中的进展,我们将更详细地讨论这个问题。

如果我们检查http://127.0.0.1:8000,我们可以看到我们的服务正在运行!让我们看看这个:

如果我们将这项服务扩展到三个实例,我们可以看到我们的编排工具是如何处理状态转换的:

$ docker service scale simple-server=3 
image simple_server could not be accessed on a registry to record
its digest. Each node will access simple_server independently,
possibly leading to different nodes running different
versions of the image.

simple-server scaled to 3

$ docker service ls
ID           NAME          MODE       REPLICAS IMAGE         PORTS
z0z90wgylcpf simple-server replicated 2/3      simple_server *:8000->8000/tcp

$ # After waiting a bit, let's see if we have 3 instances now
$ docker service ls
ID           NAME          MODE       REPLICAS IMAGE         PORTS
z0z90wgylcpf simple-server replicated 3/3      simple_server *:8000->8000/tcp

$ # You can even use regular container commands to see it
$ docker ps --format 'table {{.ID}}  {{.Image}}  {{.Ports}}'
CONTAINER ID  IMAGE  PORTS
0c9fdf88634f  simple_server:latest  8000/tcp
98d158f82132  simple_server:latest  8000/tcp
9242a969632f  simple_server:latest  8000/tcp

您可以看到这是如何调整容器实例以适应我们指定的参数的。如果我们现在在其中添加一些在现实生活中会发生的事情-容器死亡:

$ docker ps --format 'table {{.ID}}  {{.Image}}  {{.Ports}}'
CONTAINER ID  IMAGE  PORTS
0c9fdf88634f  simple_server:latest  8000/tcp
98d158f82132  simple_server:latest  8000/tcp
9242a969632f  simple_server:latest  8000/tcp

$ docker kill 0c9fdf88634f
0c9fdf88634f

$ # We should only now have 2 containers
$ docker ps --format 'table {{.ID}}  {{.Image}}  {{.Ports}}'
CONTAINER ID  IMAGE  PORTS
98d158f82132  simple_server:latest  8000/tcp
9242a969632f  simple_server:latest  8000/tcp

$ # Wait a few seconds and try again
$ docker ps --format 'table {{.ID}}  {{.Image}}  {{.Ports}}'
CONTAINER ID  IMAGE  PORTS
d98622eaabe5  simple_server:latest  8000/tcp
98d158f82132  simple_server:latest  8000/tcp
9242a969632f  simple_server:latest  8000/tcp

$ docker service ls
ID           NAME          MODE       REPLICAS IMAGE         PORTS
z0z90wgylcpf simple-server replicated 3/3      simple_server *:8000->8000/tcp

正如你所看到的,集群将像没有发生任何事情一样反弹回来,这正是容器化如此强大的原因:我们不仅可以在许多机器之间分配处理任务并灵活地扩展吞吐量,而且使用相同的服务,如果一些(希望很少)服务死掉,我们并不会太在意,因为框架会使客户端完全无缝地进行处理。借助 Docker Swarm 的内置服务发现,负载均衡器将把连接转移到任何正在运行/可用的容器,因此任何试图连接到我们服务器的人都不应该看到太大的差异。

清理

与我们完成的任何服务一样,我们需要确保清理我们迄今为止使用的任何资源。在 Swarm 的情况下,我们可能应该删除我们的服务并销毁我们的集群,直到我们再次需要它。您可以使用docker service rmdocker swarm leave来执行这两个操作:

$ docker service ls
ID           NAME          MODE       REPLICAS IMAGE         PORTS
z0z90wgylcpf simple-server replicated 3/3      simple_server *:8000->8000/tcp

$ docker service rm simple-server
simple-server

$ docker service ls
ID           NAME          MODE       REPLICAS IMAGE         PORTS

$ docker swarm leave --force
Node left the swarm.

我们在这里不得不使用--force标志的原因是因为我们是管理节点,也是集群中的最后一个节点,所以默认情况下,Docker 会阻止这个操作。在多节点设置中,通常不需要这个标志。

通过这个操作,我们现在回到了起点,并准备使用一个真正的服务。

使用 Swarm 来编排我们的单词服务

在上一章中,我们构建了一个简单的服务,用于添加和列出在表单上输入的单词。但是如果你记得的话,我们在连接服务时大量使用了一些实现细节,如果不是完全地拼凑在一起,那就会变得非常脆弱。有了我们对服务发现的新认识和对 Docker Swarm 编排的理解,我们可以尝试准备好我们的旧代码以进行真正的集群部署,并摆脱我们之前脆弱的设置。

应用服务器

从第三章 服务分解中复制旧的应用服务器文件夹到一个新文件夹,我们将更改我们的主处理程序代码(index.js),因为我们必须适应这样一个事实,即我们将不再是唯一从数据库中读取和写入的实例。

与往常一样,所有代码也可以在github.com/sgnn7/deploying_with_docker找到。这个特定的实现可以在chapter_4/clustered_application中找到。警告!当您开始考虑类似的容器并行运行时,您必须开始特别注意容器控制范围之外可能发生的数据更改。因此,在运行容器中保留或缓存状态通常是灾难和数据不一致的原因。为了避免这个问题,通常情况下,您应该尽量确保在进行任何转换或传递数据之前从上游源(即数据库)重新读取信息,就像我们在这里所做的那样。

index.js

这个文件基本上与上一章的文件相同,但我们将进行一些更改以消除缓存:

'use strict'

const bodyParser = require('body-parser')
const express = require('express');
const mongo = require('mongodb')

const DB_NAME = 'word_database';
const DB_HOST = process.env.DB_HOST || 'localhost:27017';
const COLLECTION_NAME = 'words';
const SERVER_PORT = 8000;

const app = express();
const client = mongo.MongoClient();
const dbUri = `mongodb://${DB_HOST}/${DB_NAME}`;

app.set('view engine', 'pug')
app.use(bodyParser.urlencoded({ extended: false }))

function loadWordsFromDatabase() {
    return client.connect(dbUri).then((db) => {
        return db.collection(COLLECTION_NAME).find({}).toArray();
    })
    .then((docs) => {
        return docs.map(doc => doc.word);
    });
}

app.get('/', (req, res) => {
    console.info("Loading data from database...");
    loadWordsFromDatabase().then(words => {
        console.info("Data loaded, showing the result...");
        res.render('index', { words: words });
    });
});

app.post('/new', (req, res) => {
    const word = req.body.word;

    console.info(`Got word: ${word}`);
    if (word) {
        client.connect(dbUri).then((db) => {
            db.collection(COLLECTION_NAME).insertOne({ word }, () => {
                db.close();
            });
        });
    }

    res.redirect('/');
});

app.listen(SERVER_PORT, () => {
    console.info("Server started on port %d...", SERVER_PORT);
});

如果您可能已经注意到,许多事情是相似的,但也有根本性的变化:

  • 我们不会在启动时预加载单词,因为列表可能会在服务初始化和用户请求数据之间发生变化。

  • 我们在每个GET请求中加载保存的单词,以确保我们始终获得新鲜数据。

  • 当我们保存单词时,我们只是将其插入到数据库中,并不在应用程序中保留它,因为我们将在GET重新显示时获得新数据。

使用这种方法,数据库中的数据由任何应用程序实例进行的更改将立即反映在所有实例中。此外,如果数据库管理员更改了任何数据,我们也将在应用程序中看到这些更改。由于我们的服务还使用环境变量作为数据库主机,我们不应该需要将其更改为支持服务发现。

注意!请注意,因为我们在每个GET请求中读取数据库,我们对支持集群的更改并不是免费的,并且会增加数据库查询,这可能会在网络、缓存失效或磁盘传输过度饱和时成为真正的瓶颈。此外,由于我们在显示数据之前读取数据库,后端处理数据库“find()”的减速将对用户可见,可能导致不良用户体验,因此在开发容器友好型服务时请牢记这些事情。

Web 服务器

我们的 Web 服务器更改会有点棘手,因为 NGINX 配置处理的一个怪癖/特性可能也会影响到您,如果您使用基于 Java 的 DNS 解析。基本上,NGINX 会缓存 DNS 条目,以至于一旦它读取配置文件,该配置中的任何新的 DNS 解析实际上根本不会发生,除非指定一些额外的标志(resolver)。由于 Docker 服务不断可变和可重定位,这是一个严重的问题,必须解决才能在 Swarm 上正常运行。在这里,您有几个选择:

  • 并行运行 DNS 转发器(例如dnsmasq)和 NGINX,并将其用作解析器。这需要在同一个容器中运行dnsmasq和 NGINX。

  • 使用envsubst从系统中填充 NGINX 配置容器的启动解析器,这需要所有容器在同一个用户定义的网络中。

  • 硬编码 DNS 解析器 IP(127.0.0.11):这也需要所有容器在同一个用户定义的网络中。

为了稳健性,我们将使用第二个选项,因此将 Web 服务器从上一章复制到一个新文件夹中,并将其重命名为nginx_main_site.conf.template。然后我们将为其添加一个解析器配置和一个名为$APP_NAME的变量,用于我们的代理主机端点:

server {
  listen         8080;
  server_name    _;  

  resolver $DNS_RESOLVERS;

  root /srv/www/html;

  location ~/\. {
    deny all;
  }

  location / { 
    auth_basic           "Authentication required";
    auth_basic_user_file /srv/www/html/.htpasswd;

    proxy_pass           http://$APP_NAME:8000;
  }
}

由于 NGINX 在配置文件中不处理环境变量替换,我们将在其周围编写一个包装脚本。添加一个名为start_nginx.sh的新文件,并在其中包含以下内容,以获取主机的解析器并生成新的 main_site 配置:

#!/bin/bash -e

export DNS_RESOLVERS=$(cat /etc/resolv.conf | grep 'nameserver' | awk '{ print $2 }' | xargs echo)

cat /etc/nginx/conf.d/nginx_main_site.conf.template | envsubst '$DNS_RESOLVERS $APP_NAME' > /etc/nginx/conf.d/nginx_main_site.conf

nginx -g 'daemon off;'

为了使其运行,我们最终需要确保我们使用此脚本启动 NGINX,而不是内置的脚本,因此我们还需要修改我们的Dockerfile

打开我们的 Dockerfile,并确保它具有以下内容:

FROM nginx:latest

RUN apt-get update -q && \
    apt-get dist-upgrade -y && \
    apt-get install openssl && \
    apt-get clean && \
    apt-get autoclean

EXPOSE 8080

ENV SRV_PATH /srv/www/html

ARG PASSWORD=test

RUN rm /etc/nginx/conf.d/default.conf

COPY start_nginx.sh /usr/local/bin/

RUN mkdir -p $SRV_PATH && \
    chown nginx:nginx $SRV_PATH && \
    printf "user:$(openssl passwd -crypt $PASSWORD)\n" >> $SRV_PATH/.htpasswd && \
    chmod +x /usr/local/bin/start_nginx.sh

COPY nginx_main_site.conf.template /etc/nginx/conf.d/

CMD ["/usr/local/bin/start_nginx.sh"]

在这里,主要的变化是启动脚本CMD的覆盖,并将配置转换为模板,其余基本保持不变。

数据库

与其他两个容器不同,由于一系列原因,我们将数据库留在一个容器中:

  • MongoDB 可以通过垂直扩展轻松扩展到高 GB/低 TB 数据集大小。

  • 数据库极其难以扩展,如果没有对卷的深入了解(在下一章中介绍)。

  • 数据库的分片和副本集通常足够复杂,以至于整本书都可以专门写在这个主题上。

我们可能会在以后的章节中涵盖这个主题,但在这里,这会让我们偏离我们学习如何部署服务的一般目标,所以现在我们只有我们在上一章中使用的单个数据库实例。

部署所有

就像我们为简单的 Web 服务器所做的那样,我们将开始创建另一个 Swarm 集群:

$ docker swarm init
Swarm initialized: current node (1y1h7rgpxbsfqryvrxa04rvcp) is now a manager.

To add a worker to this swarm, run the following command:

 docker swarm join \
 --token SWMTKN-1-36flmf9vnika6x5mbxx7vf9kldqaw6bq8lxtkeyaj4r5s461ln-aiqlw49iufv3s6po4z2fytos1 \
 192.168.4.128:2377

然后,我们需要为服务发现主机名解析创建覆盖网络才能工作。您不需要了解太多关于这个,除了它创建了一个隔离的网络,我们将把所有服务添加到其中:

$ docker network create --driver overlay service_network
44cyg4vsitbx81p208vslp0rx

最后,我们将构建和启动我们的容器:

$ cd ../database
$ docker build . -t local_database
$ docker service create -d --replicas 1 \
 --name local-database \
 --network service_network \
 --mount type=volume,source=database_volume,destination=/data/db \
                           local_database
<snip>
pilssv8du68rg0oztm6gdsqse

$ cd ../application_server
$ docker build -t application_server .
$ docker service create -d -e DB_HOST=local-database \
 --replicas 3 \
 --network service_network \
 --name application-server \
 application_server
<snip>
pue2ant1lg2u8ejocbsovsxy3

$ cd ../web_server
$ docker build -t web_server .
$ docker service create -d --name web-server \
 --network service_network \
 --replicas 3 \
 -e APP_NAME=application-server \
 -p 8080:8080 \
 web_server
<snip>
swi95q7z38i2wepmdzoiuudv7

$ # Sanity checks
$ docker service ls
ID           NAME               MODE       REPLICAS IMAGE                PORTS
pilssv8du68r local-database     replicated 1/1      local_database 
pue2ant1lg2u application-server replicated 3/3      application_server
swi95q7z38i2 web-server         replicated 3/3      web_server            *:8080->8080/tcp

$ docker ps --format 'table {{.ID}}  {{.Image}}\t  {{.Ports}}'
CONTAINER ID  IMAGE                         PORTS
8cdbec233de7  application_server:latest     8000/tcp
372c0b3195cd  application_server:latest     8000/tcp
6be2d6e9ce77  web_server:latest             80/tcp, 8080/tcp
7aca0c1564f0  web_server:latest             80/tcp, 8080/tcp
3d621c697ed0  web_server:latest             80/tcp, 8080/tcp
d3dad64c4837  application_server:latest     8000/tcp
aab4b2e62952  local_database:latest         27017/tcp 

如果您在启动这些服务时遇到问题,可以使用docker service logs <service_name>来查看日志,以找出出了什么问题。如果特定容器出现问题,还可以使用docker logs <container_id>

有了这些,我们现在可以检查我们的代码是否在http://127.0.0.1:8080上工作(用户名:user,密码:test):

看起来它正在工作!一旦我们输入凭据,我们应该被重定向到主应用程序页面:

如果我们输入一些单词,数据库是否能工作?

确实!我们真的创建了一个支持 Swarm 的 1 节点服务,并且它是可扩展的加负载平衡的!

Docker 堆栈

就像从前面几段文字中很明显的那样,手动设置这些服务似乎有点麻烦,所以在这里我们介绍一个新工具,可以帮助我们更轻松地完成这项工作:Docker Stack。这个工具使用一个 YAML 文件来轻松和重复地部署所有服务。

在尝试使用 Docker 堆栈配置之前,我们将清理旧的练习:

$ docker service ls -q | xargs docker service rm
pilssv8du68r
pue2ant1lg2u
swi95q7z38i2

$ docker network rm service_network
service_network

现在我们可以编写我们的 YAML 配置文件--您可以很容易地注意到 CLI 与此配置文件之间的相似之处:

您可以通过访问docs.docker.com/docker-cloud/apps/stack-yaml-reference/找到有关 Docker 堆栈 YAML 文件中所有可用选项的更多信息。通常,您可以使用 CLI 命令设置的任何内容,也可以在 YAML 配置中执行相同的操作。

version: "3"
services:
 local-database:
 image: local_database
 networks:
 - service_network
 deploy:
 replicas: 1
 restart_policy:
 condition: on-failure
 volumes:
 - database_volume:/data/db 
 application-server:
 image: application_server
 networks:
 - service_network
 depends_on:
 - local-database
 environment:
 - DB_HOST=local-database
 deploy:
 replicas: 3
 restart_policy:
 condition: on-failure 
 web-server:
 image: web_server
 networks:
 - service_network
 ports:
 - 8080:8080
 depends_on:
 - application-server
 environment:
 - APP_NAME=application-server
 deploy:
 replicas: 3
 restart_policy:
 condition: on-failure

networks:
 service_network:

volumes:
 database_volume:

启动我们的堆栈怎么样?这也很容易!堆栈几乎与docker services具有相同的命令:

$ docker stack deploy --compose-file swarm_application.yml swarm_test
Creating network swarm_test_service_network
Creating service swarm_test_local-database
Creating service swarm_test_application-server
Creating service swarm_test_web-server

$ # Sanity checks
$ docker stack ls
NAME        SERVICES
swarm_test  3

$ docker stack services swarm_test
ID           NAME                          MODE       REPLICAS            IMAGE                PORTS
n5qnthc6031k swarm_test_application-server replicated 3/3                 application_server 
v9ho17uniwc4 swarm_test_web-server         replicated 3/3                 web_server           *:8080->8080/tcp
vu06jxakqn6o swarm_test_local-database     replicated 1/1                 local_database

$ docker ps --format 'table {{.ID}}  {{.Image}}\t  {{.Ports}}'
CONTAINER ID  IMAGE                         PORTS
afb936897b0d  application_server:latest     8000/tcp
d9c6bab2453a  web_server:latest             80/tcp, 8080/tcp
5e6591ee608b  web_server:latest             80/tcp, 8080/tcp
c8a8dc620023  web_server:latest             80/tcp, 8080/tcp
5db03c196fda  application_server:latest     8000/tcp
d2bf613ecae0  application_server:latest     8000/tcp
369c86b73ae1  local_database:latest         27017/tcp

如果您再次在浏览器中输入http://127.0.0.1:8080,您会发现我们的应用程序就像以前一样工作!我们已经成功地使用 Docker Swarm 集群上的单个文件部署了整个集群的镜像!

清理

我们不会留下无用的服务,所以我们将删除我们的堆栈并停止我们的 Swarm 集群,为下一章做准备:

$ docker stack rm swarm_test
Removing service swarm_test_application-server
Removing service swarm_test_web-server
Removing service swarm_test_local-database
Removing network swarm_test_service_network

$ docker swarm leave --force
Node left the swarm.

我们不需要清理网络或运行的容器,因为一旦我们的堆栈消失,Docker 会自动将它们删除。完成这部分后,我们现在可以以全新的状态进入下一章关于卷。

总结

在本章中,我们涵盖了许多内容,比如:什么是服务发现以及为什么我们需要它,容器编排的基础知识和状态协调原则,以及编排世界中的一些主要参与者。有了这些知识,我们继续使用 Docker Swarm 实现了单节点完整集群,以展示类似这样的工作如何完成,最后我们使用 Docker stack 来管理一组服务,希望向您展示如何将理论转化为实践。

在下一章中,我们将开始探索 Docker 卷和数据持久性的复杂世界,所以请继续关注。

第五章:保持数据持久性

在本章中,我们将介绍如何通过涵盖 Docker 卷的所有内容来保持您的重要数据持久、安全并独立于您的容器。我们将涵盖各种主题,包括以下内容:

  • Docker 镜像内部

  • 部署您自己的存储库实例

  • 瞬态存储

  • 持久存储

  • 绑定挂载

  • 命名卷

  • 可移动卷

  • 用户和组 ID 处理

虽然我们不会涵盖所有可用的存储选项,特别是那些特定于编排工具的选项,但本章应该让您更好地了解 Docker 如何处理数据,以及您可以采取哪些措施来确保数据被保持在您想要的方式。

Docker 镜像内部

为了更好地理解为什么我们需要持久性数据,我们首先需要详细了解 Docker 如何处理容器层。我们在之前的章节中已经详细介绍了这个主题,但在这里,我们将花一些时间来了解底层发生了什么。我们将首先讨论 Docker 目前如何处理容器内部的写入数据。

镜像的分层方式

正如我们之前所介绍的,Docker 将组成镜像的数据存储在一组离散的只读文件系统层中,当您构建镜像时,这些层会堆叠在一起。对文件系统所做的任何更改都会像透明幻灯片一样堆叠在一起,以创建完整的树,任何具有更新内容的文件(包括完全删除的文件)都会用新层遮盖旧的文件。我们以前对此的理解深度可能已经足够用于基本的容器处理,但对于高级用法,我们需要了解数据的全部内部处理方式。

当您使用相同的基础镜像启动多个容器时,它们都会被赋予与原始镜像相同的一组文件系统层,因此它们从完全相同的文件系统历史开始(除了任何挂载的卷或变量),这是我们所期望的。然而,在启动过程中,会在镜像顶部添加一个额外的可写层,该层会保留容器内部写入的任何数据:

正如您所期望的那样,任何新文件都将写入此顶层,但是这个层实际上不是与其他层相同的类型,而是特殊的写时复制(CoW)类型。如果您在容器中写入的文件已经是底层层之一的一部分,Docker 将在新层中对其进行复制,掩盖旧文件,并从那时起,如果您读取或写入该文件,CoW 层将返回其内容。

如果您在不尝试保存这个新的 CoW 层或不使用卷的情况下销毁此容器,就像我们之前在不同的上下文中经历过的那样,这个可写层将被删除,并且容器写入文件系统的所有数据将被有效删除。实际上,如果您通常将容器视为具有薄且可写的 CoW 层的镜像,您会发现这种分层系统是多么简单而有效。

持久化可写的 CoW 层

在某个时候,您可能希望保存可写的容器层,以便以后用作常规镜像。虽然强烈不建议这种类型的镜像拼接,我大多数情况下也会同意,但您可能会发现在其他方式无法调查容器代码时,它可以为您提供一个宝贵的调试工具。要从现有容器创建镜像,有docker commit命令:

$ docker commit --help

Usage:  docker commit [OPTIONS] CONTAINER [REPOSITORY[:TAG]]

Create a new image from a container's changes

Options:
 -a, --author string    Author (e.g., "John Hannibal Smith <hannibal@a-team.com>")
 -c, --change list      Apply Dockerfile instruction to the created image
 --help             Print usage
 -m, --message string   Commit message
 -p, --pause            Pause container during commit (default true)

如您所见,我们只需要一些基本信息,Docker 会处理其余的部分。我们自己试一下如何:

$ # Run a new NGINX container and add a new file to it
$ docker run -d nginx:latest
2020a3b1c0fdb83c1f70c13c192eae25e78ca8288c441d753d5b42461727fa78
$ docker exec -it \
              2020a3b1 \
              /bin/bash -c "/bin/echo test > /root/testfile"

$ # Make sure that the file is in /root
$ docker exec -it \
              2020a3b1 \
              /bin/ls /root
testfile

$ # Check what this container's base image is so that we can see changes
$ docker inspect 2020a3b1 | grep Image
 "Image": "sha256:b8efb18f159bd948486f18bd8940b56fd2298b438229f5bd2bcf4cedcf037448",
 "Image": "nginx:latest",

$ # Commit our changes to a new image called "new_nginx_image"
$ docker commit -a "Author Name <author@site.com>" \
                -m "Added a test file" \
                2020a3b1 new_nginx_image
sha256:fda147bfb46277e55d9edf090c5a4afa76bc4ca348e446ca980795ad4160fc11

$ # Clean up our original container
$ docker stop 2020a3b1 && docker rm 2020a3b1
2020a3b1
2020a3b1

$ # Run this new image that includes the custom file
$ docker run -d new_nginx_image
16c5835eef14090e058524c18c9cb55f489976605f3d8c41c505babba660952d

$ # Verify that the file is there
$ docker exec -it \
              16c5835e \
              /bin/ls /root
testfile

$ # What about the content?
$ docker exec -it \
              16c5835e \
              /bin/cat /root/testfile
test

$ See what the new container's image is recorded as
$ docker inspect 16c5835e | grep Image
 "Image": "sha256:fda147bfb46277e55d9edf090c5a4afa76bc4ca348e446ca980795ad4160fc11",
 "Image": "new_nginx_image",

$ # Clean up
$ docker stop 16c5835e && docker rm 16c5835e
16c5835e
16c5835e

docker commit -c开关非常有用,并且像 Dockerfile 一样向镜像添加命令,并接受 Dockerfile 接受的相同指令,但由于这种形式很少使用,我们决定跳过它。如果您想了解更多关于这种特定形式和/或更多关于docker commit的信息,请随意在闲暇时探索docs.docker.com/engine/reference/commandline/commit/#commit-a-container-with-new-configurations

运行您自己的镜像注册表

在我们之前的章节中,在 Swarm 部署期间,我们收到了有关不使用注册表来存储我们的镜像的警告,而且理由充分。我们所做的所有工作都是基于我们的镜像仅对我们本地的 Docker 引擎可用,因此多个节点无法使用我们构建的任何镜像。对于绝对基本的设置,您可以使用 Docker Hub(hub.docker.com/)作为托管公共镜像的选项,但由于几乎每个虚拟私有云(VPC)集群都使用其自己的内部私有注册表实例来确保安全、速度和隐私,我们将把 Docker Hub 作为一个探索的练习留给您,如果您想探索它,我们将介绍如何在这里运行我们自己的注册表。

Docker 最近推出了一个名为 Docker Cloud 的服务(cloud.docker.com/),其中包括私有注册表托管和持续集成,可能涵盖了小规模部署的相当多的用例,尽管目前该服务在单个私有存储库之外并不免费。一般来说,建立可扩展的基于 Docker 的集群的最受欢迎的方式是使用私有托管的注册表,因此我们将专注于这种方法,但要密切关注 Docker Cloud 正在开发的功能集,因为它可能填补了集群中的一些运营空白,您可以在构建基础设施的其他部分时推迟处理这些空白。

为了在本地托管注册表,Docker 提供了一个 Docker Registry 镜像(registry:2),您可以将其作为常规容器运行,包括以下后端:

  • inmemory:使用本地内存映射的临时镜像存储。这仅建议用于测试。

  • filesystem:使用常规文件系统树存储镜像。

  • s3azureswiftossgcs:云供应商特定的存储后端实现。

让我们部署一个具有本地文件系统后端的注册表,并看看它如何被使用。

警告!以下部分不使用 TLS 安全或经过身份验证的注册表配置。虽然在一些孤立的 VPC 中,这种配置可能是可以接受的,但通常情况下,您希望使用 TLS 证书来保护传输层,并添加某种形式的身份验证。幸运的是,由于 API 是基于 HTTP 的,您可以在不安全的注册表上使用反向代理的 Web 服务器,就像我们之前使用 NGINX 一样。由于证书需要被您的 Docker 客户端评估为“有效”,而这个过程对于几乎每个操作系统来说都是不同的,因此在大多数配置中,这里的工作通常不具备可移植性,这就是为什么我们跳过它的原因。

$ # Make our registry storage folder
$ mkdir registry_storage

$ # Start our registry, mounting the data volume in the container
$ # at the expected location. Use standard port 5000 for it.
$ docker run -d \
 -p 5000:5000 \
 -v $(pwd)/registry_storage:/var/lib/registry \
 --restart=always \
 --name registry \
 registry:2 
19e4edf1acec031a34f8e902198e6615fda1e12fb1862a35442ac9d92b32a637

$ # Pull a test image into our local Docker storage
$ docker pull ubuntu:latest
latest: Pulling from library/ubuntu
<snip>
Digest: sha256:2b9285d3e340ae9d4297f83fed6a9563493945935fc787e98cc32a69f5687641
Status: Downloaded newer image for ubuntu:latest

$ # "Tag our image" by marking it as something that is linked to our local registry
$ # we just started
$ docker tag ubuntu:latest localhost:5000/local-ubuntu-image

$ # Push our ubuntu:latest image into our local registry under "local-ubuntu-image" name
$ docker push localhost:5000/local-ubuntu-image
The push refers to a repository [localhost:5000/local-ubuntu-image]
<snip>
latest: digest: sha256:4b56d10000d71c595e1d4230317b0a18b3c0443b54ac65b9dcd3cac9104dfad2 size: 1357

$ # Verify that our image is in the right location in registry container
$ ls registry_storage/docker/registry/v2/repositories/
local-ubuntu-image

$ # Remove our images from our main Docker storage
$ docker rmi ubuntu:latest localhost:5000/local-ubuntu-image
Untagged: ubuntu:latest
Untagged: localhost:5000/local-ubuntu-image:latest
<snip>

$ # Verify that our Docker Engine doesn't have either our new image
$ # nor ubuntu:latest
$ docker images
REPOSITORY                TAG                 IMAGE ID            CREATED             SIZE

$ # Pull the image from our registry container to verify that our registry works
$ docker pull localhost:5000/local-ubuntu-image
Using default tag: latest
latest: Pulling from local-ubuntu-image
<snip>
Digest: sha256:4b56d10000d71c595e1d4230317b0a18b3c0443b54ac65b9dcd3cac9104dfad2
Status: Downloaded newer image for localhost:5000/local-ubuntu-image:latest

$ # Great! Verify that we have the image.
$ docker images
REPOSITORY                          TAG                 IMAGE ID            CREATED             SIZE
localhost:5000/local-ubuntu-image   latest              8b72bba4485f        23 hours ago        120MB

如您所见,使用本地注册表似乎非常容易!这里引入的唯一新事物可能需要在注册表本身之外进行一些覆盖的是--restart=always,它确保容器在意外退出时自动重新启动。标记是必需的,以将图像与注册表关联起来,因此通过执行docker tag [<source_registry>/]<original_tag_or_id> [<target_registry>/]<new_tag>,我们可以有效地为现有图像标签分配一个新标签,或者我们可以创建一个新标签。正如在这个小的代码片段中所示,源和目标都可以以可选的存储库位置为前缀,如果未指定,则默认为docker.io(Docker Hub)。

遗憾的是,根据个人经验,尽管这个例子让事情看起来很容易,但实际的注册表部署绝对不容易,因为外表可能具有欺骗性,而在使用它时需要牢记一些事情:

  • 如果您使用不安全的注册表,要从不同的机器访问它,您必须将"insecure-registries" : ["<ip_or_dns_name>:<port>"]添加到将使用该注册表的图像的每个 Docker 引擎的/etc/docker/daemon.json中。

  • 注意:出于许多安全原因,不建议使用此配置。

  • 如果您使用无效的 HTTPS 证书,您还必须在所有客户端上将其标记为不安全的注册表。

  • 这种配置也不建议,因为它只比不安全的注册表稍微好一点,可能会导致传输降级中间人攻击(MITM)

我要给你的最后一条建议是,根据我的经验,注册表的云提供商后端文档一直都是错误的,并且一直(我敢说是故意的吗?)错误。我强烈建议,如果注册表拒绝了你的设置,你应该查看源代码,因为设置正确的变量相当不直观。你也可以使用挂载文件来配置注册表,但如果你不想在集群刚启动时构建一个新的镜像,环境变量是一个不错的选择。环境变量都是全大写的名称,用_连接起来,并与可用选项的层次结构相匹配:

parent
└─ child_option
 └─ some_setting

然后,注册表的这个字段将设置为-e PARENT_CHILD_OPTION_SOME_SETTING=<value>

有关可用注册表选项的完整列表,您可以访问github.com/docker/docker-registry/blob/master/config/config_sample.yml,并查看您需要运行注册表的选项。正如前面提到的,我发现docs.docker.com上的主要文档以及代码存储库本身的大部分文档在配置方面极不可靠,因此不要害怕阅读源代码以找出注册表实际期望的内容。

为了帮助那些将使用最有可能的后备存储(在“文件系统”之外)部署注册表的人,即s3,我将留下一个可用的(在撰写本文时)配置:

$ docker run -d \
 -p 5000:5000 \
 -v $(pwd)/registry_storage:/var/lib/registry \
             -e REGISTRY_STORAGE=s3 \
 -e REGISTRY_STORAGE_CACHE_BLOBDESCRIPTOR=inmemory \
 -e REGISTRY_STORAGE_S3_ACCESSKEY=<aws_key_id> \
 -e REGISTRY_STORAGE_S3_BUCKET=<bucket> \
 -e REGISTRY_STORAGE_S3_REGION=<s3_region> \
 -e REGISTRY_STORAGE_S3_SECRETKEY=<aws_key_secret> \
 --restart=always \
 --name registry \
 registry:2
 --name registry

底层存储驱动程序

这一部分对一些读者来说可能有点高级,并且并不严格要求阅读,但为了充分理解 Docker 如何处理镜像以及在大规模部署中可能遇到的问题,我鼓励每个人至少浏览一下,因为识别后备存储驱动程序问题可能会有用。另外,请注意,这里提到的问题可能随着 Docker 代码库的演变而变得不太适用,因此请查看他们的网站以获取最新信息。

与您可能从 Docker 守护程序期望的不同,本地图像层的处理实际上是以非常模块化的方式进行的,因此几乎可以将任何分层文件系统驱动程序插入到守护程序中。存储驱动程序控制着图像在您的 Docker 主机上的存储和检索方式,虽然从客户端的角度看可能没有任何区别,但每个驱动程序在许多方面都是独一无二的。

首先,我们将提到的所有可用存储驱动程序都是由 Docker 使用的底层容器化技术containerd提供的。虽然了解它之外的任何内容通常对大多数 Docker 用途来说都是多余的,但可以说它只是 Docker 用作图像处理 API 的底层模块之一。containerd提供了一个稳定的 API,用于存储和检索图像及其指定的层,以便构建在其之上的任何软件(如 Docker 和 Kubernetes)只需担心将其全部整合在一起。

您可能会在代码和/或文档中看到有关称为图形驱动程序的内容,这在学究式上是与存储驱动程序进行交互的高级 API,但在大多数情况下,当它被写入时,它用于描述实现图形驱动程序 API 的存储驱动程序;例如,当谈论新类型的存储驱动程序时,您经常会看到它被称为新的图形驱动程序。

要查看您正在使用的后备文件系统,可以输入docker info并查找Storage Driver部分:

$ docker info
<snip>
Storage Driver: overlay2
 Backing Filesystem: extfs
 Supports d_type: true
 Native Overlay Diff: true
<snip>

警告!在大多数情况下,更改存储驱动程序将删除您的计算机上由旧驱动程序存储的任何和所有图像和层的访问权限,因此请谨慎操作!此外,我相信通过更改存储驱动程序而不通过 CLI 手动清理图像和容器,或者通过从/var/lib/docker/中删除内容,将使这些图像和容器悬空,因此请确保在考虑这些更改时清理一下。

如果您想将存储驱动程序更改为我们将在此处讨论的任何选项,您可以编辑(或创建缺失的)/etc/docker/daemon.json并在其中添加以下内容,之后应重新启动 docker 服务:

{
  "storage-driver": "driver_name"
}

如果daemon.json不起作用,您还可以尝试通过向DOCKER_OPTS添加-s标志并重新启动服务来更改/etc/default/docker

DOCKER_OPTS="-s driver_name"

一般来说,Docker 正在从/etc/default/docker(取决于发行版的路径)过渡到/etc/docker/daemon.json作为其配置文件,因此,如果您在互联网或其他文档中看到引用了前者文件,请查看是否可以找到daemon.json的等效配置,因为我相信它将在将来的某个时候完全取代另一个(就像所有的书籍一样,可能是在这本书发布后的一周内)。

所以现在我们知道了存储驱动程序是什么,以及如何更改它们,我们可以在这里使用哪些选项呢?

aufs

aufs(也称为unionfs)是 Docker 可用的最古老但可能也是最成熟和稳定的分层文件系统。这种存储驱动程序通常启动快速,并且在存储和内存开销方面非常高效。如果您的内核已构建支持此驱动程序,Docker 将默认使用它,但通常情况下,除了 Ubuntu 并且只有安装了linux-image-extra-$(uname -r)软件包的情况下,大多数发行版都不会将该驱动程序添加到其内核中,也不会提供该驱动程序,因此您的计算机很可能无法运行它。您可以下载内核源代码并重新编译以支持aufs,但通常情况下,这是一个维护的噩梦,如果它不容易获得,您可能会选择不同的存储驱动程序。您可以使用grep aufs /proc/filesystems来检查您的计算机是否启用并可用aufs内核模块。

请注意,aufs驱动程序只能用于ext4xfs文件系统。

btrfs / zfs

这些在概念上不太像驱动程序,而更像是您在/var/lib/docker下挂载的实际文件系统,每个都有其自己的一套优缺点。一般来说,它们都会对性能产生影响,与其他选项相比具有较高的内存开销,但可能为您提供更容易的管理工具和/或更高密度的存储。由于这些驱动程序目前的支持有限,我听说仍然存在许多影响它们的关键错误,所以我不建议在生产环境中使用它们,除非您有非常充分的理由这样做。如果系统在/var/lib/docker下挂载了适当的驱动,并且相关的内核模块可用,Docker 将在aufs之后选择这些驱动程序。

请注意,这里的优先顺序并不意味着这两个存储驱动程序比本节中提到的其他存储驱动程序更可取,而纯粹是如果驱动器已挂载到适当(且不常见)的文件系统位置,则 Docker 将假定这是用户想要的配置。

overlay 和 overlay2

这些特定的存储驱动程序正在逐渐成为 Docker 安装的首选。它们与aufs非常相似,但实现速度更快,更简单。与aufs一样,overlayoverlay2都需要包含和加载内核叠加模块,一般来说应该在 3.18 及更高版本的内核上可用。此外,两者只能在ext4xfs文件系统上运行。overlayoverlay2之间的区别在于较新版本在内核 4.0 中添加了减少inode使用的改进,而较旧版本在领域中有更长的使用记录。如果您有任何疑问,overlay2几乎在任何情况下都是一个非常可靠的选择。

如果您以前没有使用过 inode,请注意它们包含有关文件系统上每个单独文件的元数据,并且在创建文件系统时大多数情况下都是硬编码的最大计数。虽然这种硬编码的最大值对于大多数一般用途来说是可以的,但也有一些边缘情况,您可能会用尽它们,这种情况下文件系统将在任何新文件创建时给出错误,即使您有可用空间来存储文件。如果您想了解更多关于这些结构的信息,您可以访问www.linfo.org/inode.htmloverlayoverlay2支持的存储驱动程序由于其内部处理文件复制的方式而被认为会导致大量的 inode 使用。虽然overlay2被宣传为不会出现这些问题,但我个人在使用默认 inode 最大值构建大型 Docker 卷时多次遇到 inode 问题。如果您曾经使用这些驱动程序并注意到磁盘已满但设备上仍有空间,请使用df -i检查 inode 是否已用尽,以确保不是 Docker 存储引起的问题。

devicemapper

这个驱动程序不是在文件级设备上工作,而是直接在 Docker 实例所在的块设备上操作。虽然默认设置通常会设置一个回环设备,并且在本地测试时大多数情况下都很好,但由于在回环设备中创建的稀疏文件,这种特定设置极不建议用于生产系统。对于生产系统,建议您将其与direct-lvm结合使用,但这种复杂的设置需要特别棘手且比overlay存储驱动慢,因此我通常不建议使用它,除非您无法使用aufsoverlay/overlay2

Docker 存储的清理

如果您使用 Docker 镜像和容器,您会注意到,一般来说,Docker 会相对快速地消耗您提供的任何存储空间,因此建议定期进行适当的维护,以确保您的主机上不会积累无用的垃圾或者某些存储驱动程序的 inode 用尽。

手动清理

首先是清理您运行过但忘记使用--rm的所有容器,使用docker rm

$ docker rm $(docker ps -aq)
86604ed7bb17
<snip>
7f7178567aba

这个命令有效地找到所有容器(docker ps),甚至是您停止的容器(-a标志),并且只返回它们的 ID(-q标志)。然后将其传递给docker rm,它将尝试逐个删除它们。如果有任何容器仍在运行,它将给出警告并跳过它们。一般来说,如果您的容器是无状态的或者具有在容器本身之外存储的状态,这通常是一个很好的做法,您可以随时执行。

接下来,尽管可能更具破坏性和节省空间,但要删除您积累的 Docker 镜像。如果您经常遇到空间问题,手动删除可能非常有效。一个经验法则是,任何标签为<none>的镜像(也称为悬空)通常可以使用docker rmi来删除,因为在大多数情况下,这些镜像表明该镜像已被Dockerfile的新版本取代:

$ docker images --filter "dangling=true"
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
<none>              <none>              873473f192c8        7 days ago          129MB
<snip>
registry            <none>              751f286bc25e        7 weeks ago         33.2MB

$ # Use those image IDs and delete them
$ docker rmi $(docker images -q --filter "dangling=true")
 Deleted: sha256:873473f192c8977716fcf658c1fe0df0429d4faf9c833b7c24ef269cacd140ff
<snip>
Deleted: sha256:2aee30e0a82b1a6b6b36b93800633da378832d623e215be8b4140e8705c4101f

自动清理

我们刚刚做的所有事情似乎都很痛苦,很难记住,所以 Docker 最近添加了docker image prune来帮助解决这个问题。通过使用docker image prune,所有悬空的镜像将被一条命令删除:

$ docker image prune 
WARNING! This will remove all dangling images.
Are you sure you want to continue? [y/N] y 
Deleted Images:
untagged: ubuntu@sha256:2b9285d3e340ae9d4297f83fed6a9563493945935fc787e98cc32a69f5687641
deleted: sha256:8b72bba4485f1004e8378bc6bc42775f8d4fb851c750c6c0329d3770b3a09086
<snip>
deleted: sha256:f4744c6e9f1f2c5e4cfa52bab35e67231a76ede42889ab12f7b04a908f058551

Total reclaimed space: 188MB

如果您打算清理与容器无关的所有镜像,还可以运行docker image prune -a。鉴于这个命令相当具有破坏性,除了在 Docker 从属节点上夜间/每周定时器上运行它以减少空间使用之外,在大多数情况下我不建议这样做。

需要注意的是,正如您可能已经注意到的,删除对镜像层的所有引用也会级联到子层。

最后但同样重要的是卷的清理,可以使用docker volume命令进行管理。我建议在执行此操作时要极度谨慎,以避免删除您可能需要的数据,并且只使用手动卷选择或prune

$ docker volume ls
DRIVER              VOLUME NAME
local               database_volume
local               local_storage
local               swarm_test_database_volume

$ docker volume prune 
WARNING! This will remove all volumes not used by at least one container.
Are you sure you want to continue? [y/N] y 
Deleted Volumes:
local_storage
swarm_test_database_volume
database_volume

Total reclaimed space: 630.5MB

作为参考,我在写这一章的那周对 Docker 的使用相当轻,清理了陈旧的容器、镜像和卷后,我的文件系统使用量减少了大约 3GB。虽然这个数字大部分是个人经验,并且可能看起来不多,但在具有小实例硬盘的云节点和添加了持续集成的集群上,保留这些东西会比你意识到的更快地耗尽磁盘空间,因此期望花一些时间手动执行这个过程,或者为您的节点自动化这个过程,比如使用systemd定时器或crontab

持久存储

既然我们已经讨论了瞬态本地存储,现在我们可以考虑当容器死亡或移动时,我们还有哪些选项可以保护数据安全。正如我们之前讨论过的,如果不能以某种方式将容器中的数据保存到外部源,那么当节点或容器在提供服务时意外死机时(比如您的数据库),您很可能会丢失其中包含的一些或全部数据,这绝对是我们想要避免的。使用一些形式的容器外部存储来存储您的数据,就像我们在之前的章节中使用挂载卷一样,我们可以开始使集群真正具有弹性,并且在其上运行的容器是无状态的。

通过使容器无状态,您可以放心地不用太担心容器在哪个 Docker 引擎上运行,只要它们可以拉取正确的镜像并使用正确的参数运行即可。如果您仔细考虑一下,您甚至可能会注意到这种方法与线程有很多相似之处,但是更加强大。您可以将 Docker 引擎想象成虚拟 CPU 核心,每个服务作为一个进程,每个任务作为一个线程。考虑到这一点,如果您的系统中的一切都是无状态的,那么您的集群也是无状态的,因此,您必须利用容器外的某种形式的数据存储来保护您的数据。

注意!最近,我注意到一些在线来源一直在建议您通过大规模复制服务、分片和集群化后端数据库来保留数据,而不将数据持久化在磁盘上,依赖于云提供商的分布式可用区和信任服务级别协议(SLA)来为您的集群提供弹性和自愈特性。虽然我同意这些集群在某种程度上是有弹性的,但如果没有某种形式的永久物理表示您的数据的存储,您可能会在数据完全复制之前遇到集群的级联故障,并且有风险丢失数据而无法恢复。在这里,我个人建议至少有一个节点在您的有状态服务中使用存储,这种存储是在出现问题时不会被擦除的物理介质(例如 NAS、AWS EBS 存储等)。

节点本地存储

这种存储类型是外部于容器的,专门用于将数据与容器实例分开,但仅限于在部署到同一节点的容器内使用。这种存储允许无状态容器设置,并具有许多面向开发的用途,例如隔离构建和读取配置文件,但对于集群部署来说,它受到严重限制,因为在其他节点上运行的容器将无法访问在原始节点上创建的数据。无论哪种情况,我们将在这里涵盖所有这些节点本地存储类型,因为大多数大型集群都使用节点本地存储和可重定位存储的某种组合。

绑定挂载

我们之前见过这些,但也许我们不知道它们是什么。绑定挂载将特定文件或文件夹挂载到容器沙箱中的指定位置,用:分隔。到目前为止,我们使用的一般语法应该类似于以下内容:

$ docker run <run_params> \
             -v /path/on/host:/path/on/container \
             <image>...

这个功能的新的 Docker 语法正在逐渐成为标准,其中-v--volume现在正在被--mount替换,所以你也应该习惯这种语法。事实上,从现在开始,我们将尽可能多地使用两种语法,以便你能够熟悉任何一种风格,但在撰写本书时,--mount还没有像替代方案那样完全功能,所以根据工作情况和不工作情况,可能会有一些交替。

特别是在这里,在这个时候,一个简单的绑定挂载卷,带有绝对路径源,与几乎所有我们迄今为止使用的--mount样式都不起作用,这就是为什么我们之前没有介绍这种形式的原因。

说了这么多,不像--volume--mount是一个<key>=<value>逗号分隔的参数列表:

  • 类型:挂载的类型,可以是bindvolumetmpfs

  • :挂载的源。

  • target:容器中源将被挂载的位置。

  • readonly:使挂载为只读。

  • volume-opt:卷的额外选项。可以输入多次。

这是我们用于--volume的比较版本:

$ docker run <run_params> \
             --mount source=/path/on/host,target=/path/on/container \
             <image>...

只读绑定挂载

我们之前没有真正涵盖的另一种绑定挂载类型是只读绑定挂载。当容器中挂载的数据需要保持只读时,这种配置非常有用,尤其是从主机向多个容器传递配置文件时。这种挂载卷的形式看起来有点像这样,适用于两种语法风格:

$ # Old-style
$ docker run <run_params> \
             -v /path/on/host:/path/on/container:ro \
             <image>...

$ # New-style
$ docker run <run_params> \
             --mount source=/path/on/host,target=/path/on/container,readonly \
             <image>...

正如稍早提到的,只读卷相对于常规挂载可以为我们提供一些东西,这是从主机传递配置文件到容器的。这通常在 Docker 引擎主机有一些影响容器运行代码的配置时使用(即,用于存储或获取数据的路径前缀,我们正在运行的主机,机器从/etc/resolv.conf使用的 DNS 解析器等),因此在大型部署中广泛使用,并且经常会看到。

作为一个很好的经验法则,除非你明确需要向卷写入数据,否则始终将其挂载为只读到容器中。这将防止从一个受损的容器传播到其他容器和主机本身的安全漏洞的意外打开。

命名卷

另一种卷挂载的形式是使用命名卷。与绑定挂载不同,命名数据卷(通常称为数据卷容器)提供了一种更便携的方式来引用卷,因为它们不依赖于对主机的任何了解。在底层,它们的工作方式几乎与绑定挂载完全相同,但由于使用更简单,它们更容易处理。此外,它们还有一个额外的好处,就是可以很容易地在容器之间共享,甚至可以由与主机无关的解决方案或完全独立的后端进行管理。

注意!如果命名的数据卷是通过简单地运行容器创建的,与字面上替换容器在挂载路径上的所有内容的绑定挂载不同,当容器启动时,命名的数据卷将把容器镜像在该位置的内容复制到命名的数据卷中。这种差异非常微妙,但可能会导致严重的问题,因为如果你忘记了这个细节或者假设它的行为与绑定挂载相同,你可能会在卷中得到意外的内容。

现在我们知道了命名数据卷是什么,让我们通过使用早期配置方法(而不是直接运行容器创建一个)来创建一个。

$ # Create our volume
$ docker volume create mongodb_data
mongodb_data

$ docker volume inspect mongodb_data
[
 {
 "Driver": "local",
 "Labels": {},
 "Mountpoint": "/var/lib/docker/volumes/mongodb_data/_data",
 "Name": "mongodb_data",
 "Options": {},
 "Scope": "local"
 }
]

$ # We can start our container now
$ # XXX: For non-bind-mounts, the new "--mount" option
$ #      works fine so we will use it here
$ docker run -d \
             --mount source=mongodb_data,target=/data/db \
             mongo:latest
888a8402d809174d25ac14ba77445c17ab5ed371483c1f38c918a22f3478f25a

$ # Did it work?
$ docker exec -it 888a8402 ls -la /data/db
total 200
drwxr-xr-x 4 mongodb mongodb  4096 Sep 16 14:10 .
drwxr-xr-x 4 root    root     4096 Sep 13 21:18 ..
-rw-r--r-- 1 mongodb mongodb    49 Sep 16 14:08 WiredTiger
<snip>
-rw-r--r-- 1 mongodb mongodb    95 Sep 16 14:08 storage.bson

$ # Stop the container
$ docker stop 888a8402 && docker rm 888a8402
888a8402
888a8402

$ # What does our host's FS have in the
$ # volume storage? (path used is from docker inspect output)
$ sudo ls -la /var/lib/docker/volumes/mongodb_data/_data
total 72
drwxr-xr-x 4  999 docker 4096 Sep 16 09:08 .
drwxr-xr-x 3 root root   4096 Sep 16 09:03 ..
-rw-r--r-- 1  999 docker 4096 Sep 16 09:08 collection-0-6180071043564974707.wt
<snip>
-rw-r--r-- 1  999 docker 4096 Sep 16 09:08 WiredTiger.wt

$ # Remove the new volume
$ docker volume rm mongodb_data
mongodb_data

在使用之前手动创建卷(使用docker volume create)通常是不必要的,但在这里这样做是为了演示这样做的长格式,但我们可以只是启动我们的容器作为第一步,Docker 将自行创建卷。

$ # Verify that we don't have any volumes
$ docker volume ls
DRIVER              VOLUME NAME

$ # Run our MongoDB without creating the volume beforehand
$ docker run -d \
             --mount source=mongodb_data,target=/data/db \
             mongo:latest
f73a90585d972407fc21eb841d657e5795d45adc22d7ad27a75f7d5b0bf86f69

$ # Stop and remove our container
$ docker stop f73a9058 && docker rm f73a9058
f73a9058
f73a9058

$ # Check our volumes
$ docker volume ls
DRIVER              VOLUME NAME
local               4182af67f0d2445e8e2289a4c427d0725335b732522989087579677cf937eb53
local               mongodb_data

$ # Remove our new volumes
$ docker volume rm mongodb_data 4182af67f0d2445e8e2289a4c427d0725335b732522989087579677cf937eb53
mongodb_data
4182af67f0d2445e8e2289a4c427d0725335b732522989087579677cf937eb53

你可能已经注意到,在这里,我们最终得到了两个卷,而不仅仅是我们预期的mongodb_data,如果你按照前面的例子进行了这个例子,你可能实际上有三个(一个命名,两个随机命名)。这是因为每个启动的容器都会创建Dockerfile中定义的所有本地卷,无论你是否给它们命名,而且我们的 MongoDB 镜像实际上定义了两个卷:

$ # See what volumes Mongo image defines
$ docker inspect mongo:latest | grep -A 3 Volumes
<snip>
            "Volumes": {
                "/data/configdb": {},
                "/data/db": {}
            },

我们只给第一个命名,所以/data/configdb卷收到了一个随机的名称。要注意这样的事情,因为如果你不够注意,你可能会遇到空间耗尽的问题。偶尔运行docker volume prune可以帮助回收空间,但要小心使用这个命令,因为它会销毁所有未绑定到容器的卷。

可移动卷

我们之前讨论的所有这些选项在单个主机上工作时都很好,但它们缺乏不同物理主机之间的真正数据可移植性。例如,当前的保持数据持久性的方法实际上可以扩展到但不能超出(没有一些极端的黑客行为)单个物理服务器与单个 Docker 引擎和共享附加存储。这对于强大的服务器可能还可以,但在真正的集群配置中开始缺乏任何形式的用途,因为您可能会处理未知数量的服务器,混合虚拟和物理主机,不同的地理区域等等。

此外,当容器重新启动时,您很可能无法轻易预测它将在何处启动,以便在其启动时为其提供卷后端。对于这种用例,有一些被称为可移动卷的东西。它们有各种不同的名称,比如“共享多主机存储”,“编排数据卷”等等,但基本上想法在各方面都是相同的:拥有一个数据卷,无论容器去哪里,它都会跟随。

为了举例说明,在这里,我们有三个主机,连接着两个有状态服务,它们都使用相同的可移动卷存储驱动程序:

  • 带有 卷 D有状态容器 1主机 1

  • 带有 卷 G有状态容器 2主机 3

为了这个例子,假设主机 3已经死机。在正常的卷驱动程序情况下,有状态 容器 2的所有数据都会丢失,但因为您将使用可移动存储:

  • 编排平台将通知您的存储驱动程序容器已经死亡。

  • 编排平台将指示它希望在具有可用资源的主机上重新启动被杀死的服务。

  • 卷驱动程序将将相同的卷挂载到将运行服务的新主机上。

  • 编排平台将启动服务,并将卷详细信息传递到新容器中。

在我们的假设示例中,新系统状态应该看起来有点像这样:

从外部观点来看,没有任何变化,数据无缝地过渡到新容器并保持其状态,这正是我们想要的。对于这个特定目的,有许多 Docker 卷驱动程序可供选择,每个驱动程序都有其自己的配置方法用于各种存储后端,但 Docker 预构建的 Azure 和 AWS 镜像中唯一包含的是 CloudStor,它仅适用于 Docker Swarm,使其非常特定且完全不可移植。

出于各种原因,包括技术的老化和 Docker 以及插件开发人员的支持不力,不得不进行这种类型的卷处理很可能会是在构建基础设施时您要花费大量时间的部分。我不想打击你的积极性,但在撰写本文时,无论易于教程可能让您相信的是,事实情况确实非常严峻。

您可以在docs.docker.com/engine/extend/legacy_plugins/#volume-plugins找到大多数驱动程序。配置后,如果您手动进行管理挂载而没有编排,可以按以下方式使用它们:

$ # New-style volume switch (--mount)
$ docker run --mount source=<volume_name>,target=/dest/path,volume-driver=<name> \
             <image>...

$ # Old-style volume switch
$ docker run -v <volume_name>:/dest/path \
             --volume-driver <name> \
             <image>...

供参考,目前我认为处理可移动卷最受欢迎的插件是 Flocker、REX-Ray (github.com/codedellemc/rexray)和 GlusterFS,尽管有许多可供选择的插件,其中许多具有类似的功能。如前所述,对于如此重要的功能,这个生态系统的状态相当糟糕,似乎几乎每个大型参与者都在运行他们的集群时要么分叉并构建自己的存储解决方案,要么他们自己制作并保持封闭源。一些部署甚至选择使用标签来避免完全避开这个话题,并强制特定容器去特定主机,以便它们可以使用本地挂载的卷。

Flocker 的母公司 ClusterHQ 因财务原因于 2016 年 12 月停止运营,虽然缺乏支持会给不予提及提供一点推动力,但在撰写本书时,它仍然是最受欢迎的一种卷管理方式。所有代码都在 GitHub 上开源github.com/ClusterHQ,因此即使没有官方支持,你也可以构建、安装和运行它。如果你想在企业环境中使用这个插件,并希望得到支持,一些原始开发人员可以通过一个名为 ScatterHQ 的新公司进行雇佣www.scatterhq.com/,他们在github.com/ScatterHQ上有自己的源代码库。GlusterFS 在其原始源中没有维护,就像 Flocker 一样,但你可以从源代码库github.com/calavera/docker-volume-glusterfs中构建、安装和运行完整的代码。如果你想要已经接收更新的代码版本,你可以在分支网络中找到一些github.com/calavera/docker-volume-glusterfs/network

除了所有这些生态系统的分裂,这种与 Docker 集成的特定方式开始被弃用,而更倾向于管理和安装这些插件的“docker 插件”系统,这些插件是从 Docker Hub 作为 Docker 镜像安装的,但由于这些新风格插件的可用性不足,根据你的具体用例,你可能需要使用遗留插件。

很遗憾,在撰写本书时,“docker 插件”系统像许多其他功能一样是全新的,几乎没有可用的插件。例如,在早期提到的遗留插件中,唯一使用这个新系统构建的插件是 REX-Ray,但最流行的存储后端(EBS)插件似乎无法干净地安装。当你阅读本书时,这里的情况可能已经改变,但请注意,在你自己的实现中,你可能会使用经过验证的遗留插件。

因此,在提到所有这些警告之后,让我们实际尝试获取唯一一个可以使用新的“docker 插件安装”系统找到的插件(sshfs):

要复制这项工作,您需要访问一个启用了 SSH 并且可以从 Docker 引擎运行的地方到达的辅助机器(尽管您也可以在回环上运行),因为它使用的是支持存储系统。您还需要在设备上创建目标文件夹ssh_movable_volume,可能还需要根据您的设置在sshfs卷参数中添加-o odmap=user

$ # Install the plugin
$ docker plugin install vieux/sshfs 
Plugin "vieux/sshfs" is requesting the following privileges:
 - network: [host]
 - mount: [/var/lib/docker/plugins/]
 - mount: []
 - device: [/dev/fuse]
 - capabilities: [CAP_SYS_ADMIN]
Do you grant the above permissions? [y/N] y
latest: Pulling from vieux/sshfs
2381f72027fc: Download complete 
Digest: sha256:72c8cfd1a6eb02e6db4928e27705f9b141a2a0d7f4257f069ce8bd813784b558
Status: Downloaded newer image for vieux/sshfs:latest
Installed plugin vieux/sshfs

$ # Sanity check
$ docker plugin ls
ID                  NAME                 DESCRIPTION               ENABLED
0d160591d86f        vieux/sshfs:latest   sshFS plugin for Docker   true

$ # Add our password to a file
$ echo -n '<password>' > password_file

$ # Create a volume backed by sshfs on a remote server with SSH daemon running
$ docker volume create -d vieux/sshfs \
 -o sshcmd=user@192.168.56.101/ssh_movable_volume \
 -o password=$(cat password_file) \
 ssh_movable_volume
ssh_movable_volume

$ # Sanity check
$ docker volume ls
DRIVER               VOLUME NAME
vieux/sshfs:latest   ssh_movable_volume

$ # Time to test it with a container
$ docker run -it \
 --rm \
 --mount source=ssh_movable_volume,target=/my_volume,volume-driver=vieux/sshfs:latest \
 ubuntu:latest \
 /bin/bash

root@75f4d1d2ab8d:/# # Create a dummy file
root@75f4d1d2ab8d:/# echo 'test_content' > /my_volume/test_file

root@75f4d1d2ab8d:/# exit
exit

$ # See that the file is hosted on the remote server
$ ssh user@192.168.56.101
user@192.168.56.101's password: 
<snip>
user@ubuntu:~$ cat ssh_movable_volume/test_file 
test_content

$ # Get back to our Docker Engine host
user@ubuntu:~$ exit
logout
Connection to 192.168.56.101 closed.

$ # Clean up the volume
$ docker volume rm ssh_movable_volume
ssh_movable_volume

由于卷的使用方式,这个卷大多是可移动的,并且可以允许我们需要的可移动特性,尽管大多数其他插件使用一个在 Docker 之外并行在每个主机上运行的进程来管理卷的挂载、卸载和移动,因此这些指令将大不相同。

可移动卷同步丢失

在这一部分中还必须提到的最后一件事是,大多数处理卷移动的插件通常只能处理连接到单个节点,因为卷被多个源写入通常会导致严重问题,因此大多数驱动程序不允许这样做。

然而,这与大多数编排引擎的主要特性相冲突,即对 Docker 服务的更改将使原始服务保持运行,直到新服务启动并通过健康检查,从而需要在旧服务和新服务任务上挂载相同的卷,实际上产生了一个鸡蛋-鸡的悖论。

在大多数情况下,这可以通过确保 Docker 在启动新服务之前完全终止旧服务来解决,但即使这样,您也可以预期偶尔旧卷将无法从旧节点快速卸载,因此新服务将无法启动。

卷的 UID/GID 和安全考虑

这一部分不像我在其他地方放置的小信息框那样,因为这是一个足够大的问题,足够棘手,值得有自己的部分。要理解容器用户 IDUID)和组 IDGID)发生了什么,我们需要了解主机系统权限是如何工作的。当你有一个带有组和用户权限的文件时,它们实际上都被映射为数字,而不是保留为用户名或组名,当你使用常规的ls开关列出东西时,你会看到它们:

$ # Create a folder and a file that we will mount in the container
$ mkdir /tmp/foo
$ cd /tmp/foo
$ touch foofile

$ # Let's see what we have. Take note of owner and group of the file and directory
$ ls -la
total 0
drwxrwxr-x  2 user user   60 Sep  8 20:20 .
drwxrwxrwt 56 root root 1200 Sep  8 20:20 ..
-rw-rw-r--  1 user user    0 Sep  8 20:20 foofile

$ # See what our current UID and GID are
$ id
uid=1001(user) gid=1001(user) <snip>

$ # How about we see the actual values that the underlying system uses
$  ls -na
total 0
drwxrwxr-x  2 1001 1001   60 Sep  8 20:20 .
drwxrwxrwt 56    0    0 1200 Sep  8 20:20 ..
-rw-rw-r--  1 1001 1001    0 Sep  8 20:20 foofile

当您执行ls时,系统会读取/etc/passwd/etc/group以显示权限的实际用户名和组名,这是 UID/GID 映射到权限的唯一方式,但底层值是 UID 和 GID。

正如你可能已经猜到的那样,这种用户到 UID 和组到 GID 的映射在容器化系统中可能无法很好地转换,因为容器将不具有相同的/etc/passwd/etc/group文件,但外部卷上的文件权限是与数据一起存储的。例如,如果容器有一个 GID 为1001的组,它将匹配我们的foofile上的组权限位-rw,如果它有一个 UID 为1001的用户,它将匹配我们文件上的-rw用户权限。相反,如果您的 UID 和 GID 不匹配,即使容器和主机上有相同名称的组或用户,您也不会拥有正确的 UID 和 GID 以进行适当的权限处理。是时候看看我们可以用这个做成什么样的混乱了:

$ ls -la
total 0
drwxrwxr-x  2 user user   60 Sep  8 21:16 .
drwxrwxrwt 57 root root 1220 Sep  8 21:16 ..
-rw-rw-r--  1 user user    0 Sep  8 21:16 foofile 
$ ls -na
total 0
drwxrwxr-x  2 1001 1001   60 Sep  8 21:16 .
drwxrwxrwt 57    0    0 1220 Sep  8 21:16 ..
-rw-rw-r--  1 1001 1001    0 Sep  8 21:16 foofile

$ # Start a container with this volume mounted
$ # Note: We have to use the -v form since at the time of writing this
$ #       you can't mount a bind mount with absolute path :(
$ docker run --rm \
             -it \
             -v $(pwd)/foofile:/tmp/foofile \
             ubuntu:latest /bin/bash

root@d7776ec7b655:/# # What does the container sees as owner/group?
root@d7776ec7b655:/# ls -la /tmp
total 8
drwxrwxrwt 1 root root 4096 Sep  9 02:17 .
drwxr-xr-x 1 root root 4096 Sep  9 02:17 ..
-rw-rw-r-- 1 1001 1001    0 Sep  9 02:16 foofile 
root@d7776ec7b655:/# # Our container doesn't know about our users
root@d7776ec7b655:/# # so it only shows UID/GID 
root@d7776ec7b655:/# # Let's change the owner/group to root (UID 0) and set setuid flag
root@d7776ec7b655:/# chown 0:0 /tmp/foofile 
root@d7776ec7b655:/# chmod +x 4777 /tmp/foofile 

root@d7776ec7b655:/# # See what the permissions look like now in container
root@d7776ec7b655:/# ls -la /tmp
total 8
drwxrwxrwt 1 root root 4096 Sep  9 02:17 .
drwxr-xr-x 1 root root 4096 Sep  9 02:17 ..
-rwsrwxrwx 1 root root    0 Sep  9 02:16 foofile

root@d7776ec7b655:/# # Exit the container
root@d7776ec7b655:/# exit
exit

$ # What does our unmounted volume looks like?
$ ls -la
total 0
drwxrwxr-x  2 user user   60 Sep  8 21:16 .
drwxrwxrwt 57 root root 1220 Sep  8 21:17 ..
-rwsrwxrwx  1 root root    0 Sep  8 21:16 foofile
$ # Our host now has a setuid file! Bad news! 

警告!在文件上设置setuid标志是一个真正的安全漏洞,它以文件所有者的权限执行文件。如果我们决定编译一个程序并在其上设置此标志,我们可能会对主机造成大量的破坏。有关此标志的更多信息,请参阅en.wikipedia.org/wiki/Setuid

正如你所看到的,如果我们决定更加恶意地使用我们的setuid标志,这可能是一个严重的问题。这个问题也延伸到我们使用的任何挂载卷,因此在处理它们时,请确保您要谨慎行事。

Docker 一直在努力使用户命名空间工作,以避免一些这些安全问题,它通过/etc/subuid/etc/subgid文件重新映射 UID 和 GID 到容器内的其他内容,以便在主机和容器之间没有root UID 冲突,但它们并不是没有问题的(在撰写本书时存在大量问题)。有关使用用户命名空间的更多信息,您可以在docs.docker.com/engine/security/userns-remap/找到更多信息。

加剧这个 UID/GID 问题的是另一个问题,即在这样的独立环境中会发生的问题:即使在两个容器之间以相同的顺序安装了所有相同的软件包,由于用户和组通常是按名称而不是特定的 UID/GID 创建的,你不能保证在容器运行之间这些一致,如果你想重新挂载已升级或重建的容器之间的相同卷,这是一个严重的问题。因此,你必须确保卷上的 UID 和 GID 是稳定的,方法类似于我们在一些早期示例中所做的,在安装包之前:

RUN groupadd -r -g 910 mongodb && \
 useradd -r -u 910 -g 910 mongodb && \
 mkdir -p /data/db && \
 chown -R mongodb:mongodb /data/db && \
 chmod -R 700 /data/db && \
 apt-get install mongodb-org

在这里,我们创建了一个 GID 为910的组mongodb和一个 UID 为910的用户mongodb,然后确保我们的数据目录由它拥有,然后再安装 MongoDB。通过这样做,当安装mongodb-org软件包时,用于运行数据库的组和用户已经存在,并且具有不会更改的确切 UID/GID。有了稳定的 UID/GID,我们可以在任何具有相同配置的构建容器上挂载和重新挂载卷,因为这两个数字将匹配,并且它应该在我们将卷移动到的任何机器上工作。

最后可能需要担心的一件事(在上一个示例中也是一个问题)是,挂载文件夹将覆盖主机上已创建的文件夹并替换其权限。这意味着,如果你将一个新文件夹挂载到容器上,要么你必须手动更改卷的权限,要么在容器启动时更改所有权。让我们看看我是什么意思:

$ mkdir /tmp/some_folder
$ ls -la /tmp | grep some_folder
drwxrwxr-x  2 sg   sg        40 Sep  8 21:56 some_folder

$ # Mount this folder to a container and list the content
$ docker run -it \
             --rm \
             -v /tmp/some_folder:/tmp/some_folder \
             ubuntu:latest \
             ls -la /tmp
total 8
drwxrwxrwt 1 root root 4096 Sep  9 02:59 .
drwxr-xr-x 1 root root 4096 Sep  9 02:59 ..
drwxrwxr-x 2 1000 1000   40 Sep  9 02:56 some_folder

$ # Somewhat expected but we will do this now by overlaying
$ # an existing folder (/var/log - root owned) in the container

$ # First a sanity chech
$ docker run -it \
             --rm \
             ubuntu:latest \
             ls -la /var | grep log
drwxr-xr-x 4 root root  4096 Jul 10 18:56 log 
$ # Seems ok but now we mount our folder here
$ docker run -it \
             --rm \
             -v /tmp/some_folder:/var/log \
             ubuntu:latest \
             ls -la /var | grep log
drwxrwxr-x 2 1000  1000   40 Sep  9 02:56 log

正如你所看到的,容器内文件夹上已经设置的任何权限都被我们挂载的目录卷完全覆盖了。如前所述,避免在容器中运行服务的有限用户出现权限错误的最佳方法是在容器启动时使用包装脚本更改挂载路径上的权限,或者使用挂载卷启动容器并手动更改权限,前者是更可取的选项。最简单的包装脚本大致如下:

#!/bin/bash -e

# Change owner of volume to the one we expect
chown mongodb:mongodb /path/to/volume

# Optionally you can use this recursive version too
# but in most cases it is a bit heavy-handed
# chown -R mongodb:mongodb /path/to/volume

su - <original limited user> -c '<original cmd invocation>'

将此脚本放在容器的/usr/bin/wrapper.sh中,并在Dockerfile中以 root 身份运行的地方添加以下代码片段应该足以解决问题:

<snip>
CMD [ "/usr/bin/wrapper.sh" ]

当容器启动时,卷将已经挂载,并且脚本将在将命令传递给容器的原始运行程序之前,更改卷的用户和组为正确的用户和组,从而解决了我们的问题。

从本节中最重要的收获应该是,在处理卷时,您应该注意用户权限,因为如果不小心,它们可能会导致可用性和安全性问题。当您开发您的服务和基础设施时,这些类型的陷阱可能会导致从轻微头痛到灾难性故障的一切,但是现在您对它们了解更多,我们希望已经预防了最坏的情况。

总结

在本章中,您已经学到了大量关于 Docker 数据处理的新知识,包括 Docker 镜像内部和运行自己的 Docker 注册表。我们还涵盖了瞬态、节点本地和可重定位数据存储以及相关的卷管理,这将帮助您有效地在云中部署您的服务。随后,我们花了一些时间来介绍卷编排生态系统,以帮助您在 Docker 卷驱动程序的不断变化中进行导航,因为在这个领域事情变化得很快。最后,我们还涵盖了各种陷阱(如 UID/GID 问题),以便您可以在自己的部署中避免它们。

在我们继续进入下一章时,我们将介绍集群加固以及如何以有序的方式在大量服务之间传递数据。

第六章:高级部署主题

我们已经花了相当多的时间讨论容器通信和安全性,但在本章中,我们将进一步探讨以下内容:

  • 高级调试技术。

  • 实现队列消息传递。

  • 运行安全检查。

  • 深入容器安全。

我们还将介绍一些其他工具和技术,帮助您更好地管理部署。

高级调试

在野外调试容器的能力是一个非常重要的话题,我们之前介绍了一些基本的技术,可以在这里派上用场。但也有一些情况下,docker psdocker exec并不够用,因此在本节中,我们将探讨一些可以添加到您的工具箱中的其他工具,可以帮助解决那些棘手的问题。

附加到容器的进程空间

有时容器正在运行的是极简的发行版,比如 Alpine Linux(www.alpinelinux.org/),而且容器本身存在一个你想要调试的进程问题,但缺乏基本的调试工具。默认情况下,Docker 会将所有容器隔离在它们各自的进程命名空间中,因此我们之前直接附加到容器并尝试使用非常有限的工具来找出问题的调试工作流在这里不会有太大帮助。

幸运的是,Docker 完全能够使用docker run --pid "container:<name_or_id>"标志加入两个容器的进程命名空间,这样我们就可以直接将调试工具容器附加到受影响的容器上:

$ # Start an NGINX container
$ docker run -d --rm nginx
650a1baedb0c274cf91c086a9e697b630b2b60d3c3f94231c43984bed1073349

$ # What can we see from a new/separate container?
$ docker run --rm \
 ubuntu \
 ps -ef 
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 16:37 ?        00:00:00 ps -ef

$ # Now let us try the same thing but attach to the NGINX's PID space
$ docker run --rm \
 --pid "container:650a1bae" \
 ubuntu \
 ps -ef 
UID      PID  PPID  C STIME TTY    TIME CMD
root       1     0  0 16:37 ?      00:00:00 nginx: master process nginx -g daemon off;
systemd+   7     1  0 16:37 ?      00:00:00 nginx: worker process
root       8     0  0 16:37 ?      00:00:00 ps -ef

正如你所看到的,我们可以将一个调试容器附加到相同的 PID 命名空间中,以这种方式调试任何行为异常的进程,并且可以保持原始容器不受调试工具的安装!使用这种技术,原始容器可以保持较小,因为工具可以单独安装,而且容器在整个调试过程中保持运行,因此您的任务不会被重新安排。也就是说,当您使用这种方法调试不同的容器时,要小心不要杀死其中的进程或线程,因为它们有可能会级联并杀死整个容器,从而停止您的调查。

有趣的是,这个pid标志也可以通过--pid host来调用,以共享主机的进程命名空间,如果你有一个在你的发行版上无法运行的工具,并且有一个 Docker 容器可以运行它(或者,如果你想要使用一个容器来管理主机的进程):

$ # Sanity check
$ docker run --rm \
 ubuntu \
 ps -ef 
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 16:44 ?        00:00:00 ps -ef

$ # Now we try to attach to host's process namespace
$ docker run --rm \
 --pid host \
 ubuntu \
 ps -ef 
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 15:44 ?        00:00:02 /sbin/init splash
root         2     0  0 15:44 ?        00:00:00 [kthreadd]
root         4     2  0 15:44 ?        00:00:00 [kworker/0:0H]
<snip>
root      5504  5485  3 16:44 ?        00:00:00 ps -ef

很明显,这个标志的功能对于运行和调试应用程序提供了多少能力,所以不要犹豫使用它。

警告!与容器共享主机的进程命名空间是一个很大的安全漏洞,因为恶意容器可以轻易地通过操纵进程来控制或者 DoS 主机,特别是如果容器的用户是以 root 身份运行的。因此,在使用--pid host时要格外小心,并确保只在你完全信任的容器上使用这个标志。

调试 Docker 守护程序

如果到目前为止这些技术都没有帮助到你,你可以尝试运行 Docker 容器,并使用docker system events来检查守护程序 API 正在执行的操作,该命令可以跟踪几乎所有在其 API 端点上触发的操作。你可以用它来进行审计和调试,但一般来说,后者是它的主要目的,就像你在下面的例子中所看到的那样。

在第一个终端上运行以下命令,并让它保持运行,这样我们就可以看到我们可以收集到什么信息:

$ docker system events

在另一个终端上,我们将运行一个新的容器:

$ docker run -it \
 --rm \
 ubuntu /bin/bash 
$ root@563ad88c26c3:/# exit
exit

在你完成了对容器的启动和停止之后,第一个终端中的events命令应该输出类似于这样的内容:

$ docker system events
2017-09-27T10:54:58.943347229-07:00 container create 563ad88c26c3ae7c9f34dfe05c77376397b0f79ece3e233c0ce5e7ae1f01004f (image=ubuntu, name=thirsty_mccarthy)
2017-09-27T10:54:58.943965010-07:00 container attach 563ad88c26c3ae7c9f34dfe05c77376397b0f79ece3e233c0ce5e7ae1f01004f (image=ubuntu, name=thirsty_mccarthy)
2017-09-27T10:54:58.998179393-07:00 network connect 1e1fd43bd0845a13695ea02d77af2493a449dd9ee50f2f1372f589dc4968410e (container=563ad88c26c3ae7c9f34dfe05c77376397b0f79ece3e233c0ce5e7ae1f01004f, name=bridge, type=bridge)
2017-09-27T10:54:59.236311822-07:00 container start 563ad88c26c3ae7c9f34dfe05c77376397b0f79ece3e233c0ce5e7ae1f01004f (image=ubuntu, name=thirsty_mccarthy)
2017-09-27T10:54:59.237416694-07:00 container resize 563ad88c26c3ae7c9f34dfe05c77376397b0f79ece3e233c0ce5e7ae1f01004f (height=57, image=ubuntu, name=thirsty_mccarthy, width=176)
2017-09-27T10:55:05.992143308-07:00 container die 563ad88c26c3ae7c9f34dfe05c77376397b0f79ece3e233c0ce5e7ae1f01004f (exitCode=0, image=ubuntu, name=thirsty_mccarthy)
2017-09-27T10:55:06.172682910-07:00 network disconnect 1e1fd43bd0845a13695ea02d77af2493a449dd9ee50f2f1372f589dc4968410e (container=563ad88c26c3ae7c9f34dfe05c77376397b0f79ece3e233c0ce5e7ae1f01004f, name=bridge, type=bridge)
2017-09-27T10:55:06.295496139-07:00 container destroy 563ad88c26c3ae7c9f34dfe05c77376397b0f79ece3e233c0ce5e7ae1f01004f (image=ubuntu, name=thirsty_mccarthy)

它的使用范围相当有限,但是这种跟踪方式,以及我们到目前为止讨论过的其他技巧,应该为你提供在基于 Docker 的集群上解决几乎任何类型的问题的工具。除了已经提到的一切之外,在我的个人经验中,也有几次需要使用gdb,还有几次问题最终被证明是上游 bug。因此,在扩展规模时,要做好准备,因为出现新问题的可能性也会增加。

高级网络

网络是 Docker 集群中最重要的事情之一,它需要在整个系统的集群上保持运行顺畅,以便系统能够以任何能力运行。考虑到这一点,我们有理由涵盖一些我们尚未讨论但在大多数实际部署中都很重要的主题。您很有可能会在自己的部署中遇到至少其中一个用例,因此我建议您全文阅读,但您的情况可能有所不同。

静态主机配置

在某些特定的配置中,您可能有一个需要映射或重新映射到容器尝试到达的特定 IP 地址的网络主机。这允许对命名服务器进行灵活配置,并且对于网络上没有良好的网络 DNS 服务器的静态主机来说,这可能是一个真正的救命稻草。

要将这样的主机映射添加到容器中,您可以使用docker run --add-host命令运行容器,并使用此标志,将在/etc/hosts中添加一个与您的输入匹配的条目,以便您可以正确地将请求路由到它:

$ # Show what the default /etc/hosts has
$ docker run --rm \
 -it \
 ubuntu \
 /bin/cat /etc/hosts 
127.0.0.1    localhost
::1    localhost ip6-localhost ip6-loopback
fe00::0    ip6-localnet
ff00::0    ip6-mcastprefix
ff02::1    ip6-allnodes
ff02::2    ip6-allrouters
172.17.0.2    3c46adb8a875

$ # We now will add our fake server1 host mapping
$ docker run --rm \
 -it \
 --add-host "server1:123.45.67.89" \
 ubuntu \
 /bin/cat /etc/hosts 
127.0.0.1    localhost
::1    localhost ip6-localhost ip6-loopback
fe00::0    ip6-localnet
ff00::0    ip6-mcastprefix
ff02::1    ip6-allnodes
ff02::2    ip6-allrouters
123.45.67.89    server1
172.17.0.2    dd4d7c6ef7b8

$ # What does the container see when we have an additional host?
$ docker run --rm \
 -it \
 --add-host "server1:123.45.67.89" \
 ubuntu /bin/bash 
root@0ade7f3e8a80:/# getent hosts server1
123.45.67.89    server1

root@0ade7f3e8a80:/# exit
exit

如前所述,当您有一个非容器化服务时,您可能不希望将 IP 硬编码到容器中,该服务也无法从互联网 DNS 服务器解析时,这可能非常有用。

DNS 配置

说到 DNS,我们可能应该稍微谈谈 Docker DNS 处理。默认情况下,Docker 引擎使用主机的 DNS 设置,但在一些高级部署设置中,集群所在的网络可能已经构建好,此时可能需要配置引擎或容器的自定义 DNS 设置或 DNS 搜索前缀(也称为域名)。在这种情况下,您可以通过向/etc/docker/daemon.json添加dns和/或dns-search参数并重新启动守护程序来轻松覆盖 Docker 引擎的默认 DNS 设置。这两个参数都允许多个值,并且相当容易理解。

{
...
        "dns": ["1.2.3.4", "5.6.7.8", ...],
        "dns-search": ["domain.com", ...],
...
}

在我曾经工作过的所有网络设置中,我从未见过覆盖 DNS 服务器 IP 或 DNS 搜索前缀是部署自己的 DHCP 服务器并设置适当的选项来设置 DNS 服务器(选项 6)和域名(选项 15)更好的选择,当初始化网络接口时,机器将会选择这些选项。如果您想了解更多关于这些 DHCP 标志的信息,我强烈建议您访问en.wikipedia.org/wiki/Dynamic_Host_Configuration_Protocol#DHCP_options并在使用我们之前阅读相关内容。注意!在某些情况下,引擎主机的 DNS 服务器指向localhost范围,就像大多数systemd-resolvednsmasq设置一样,容器无法访问主机的localhost地址,因此默认情况下,所有在该实例上运行的容器都会被替换为 Google 的 DNS 服务器(8.8.8.88.8.4.4)。如果您想在容器中保留主机的 DNS 设置,您必须确保配置中的 DNS 解析器不是localhost IP 范围之一,并且可以被容器网络访问。您可以在docs.docker.com/engine/userguide/networking/default_network/configure-dns/找到更多信息。

如果您对引擎范围的配置不感兴趣,只想覆盖单个容器的 DNS 设置,您可以通过向docker run命令添加--dns--dns-search选项来执行等效操作,这将替换相关容器中的默认/etc/resolv.conf设置。

$ # Since my default DNS is pointed to localhost, the default should be Google's DNS servers
$ docker run --rm \
 -it \
 ubuntu \
 /bin/cat /etc/resolv.conf 
# Dynamic resolv.conf(5) file for glibc resolver(3) generated by resolvconf(8)
#     DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN
# 127.0.0.53 is the systemd-resolved stub resolver.
# run "systemd-resolve --status" to see details about the actual nameservers.
nameserver 8.8.8.8
nameserver 8.8.4.4

$ # Now we will specify a custom DNS and DNS search prefix and see what the same file looks like
$ docker run --rm \
 -it \
 --dns 4.4.4.2 \
 --dns-search "domain.com" \
 ubuntu \
 /bin/cat /etc/resolv.conf 
search domain.com
nameserver 4.4.4.2

正如您所看到的,容器中的设置已经更改以匹配我们的参数。在我们的情况下,任何 DNS 解析都将流向4.4.4.2服务器,并且任何未经验证的主机名将首先尝试解析为<host>.domain.com

叠加网络

我们在《第四章》扩展容器中只是简单提及了这一点,但为了使我们的容器能够与 Swarm 服务发现一起工作,我们不得不创建这种类型的网络,尽管我们并没有花太多时间解释它是什么。在 Docker Swarm 的上下文中,一台机器上的容器无法访问另一台机器上的容器,因为它们的网络直接路由到下一个跳点,而桥接网络阻止了每个容器在同一节点上访问其邻居。为了在这种多主机设置中无缝地连接所有容器,您可以创建一个覆盖整个集群的 overlay 网络。遗憾的是,这种类型的网络只在 Docker Swarm 集群中可用,因此在编排工具中的可移植性有限,但您可以使用docker network create -d overlay network_name来创建一个。由于我们已经在《第四章》扩展容器中涵盖了使用这种类型网络的部署示例,您可以在那里查看它的运行情况。

注意!默认情况下,覆盖网络不会与其他节点安全地通信,因此在创建时使用--opt encrypted标志是非常鼓励的,特别是在网络传输不能完全信任的情况下。使用此选项将产生一些处理成本,并要求您在集群内允许端口50的通信,但在大多数情况下,打开它应该是值得的。

Docker 内置网络映射

在之前的章节中,我们大多数情况下都是使用默认网络设置的容器,大多数情况下都是使用bridge网络,因为这是默认设置,但这并不是容器可以使用的唯一类型的网络。以下是可用网络连接的列表,几乎所有这些连接都可以通过docker run --network参数进行设置:

  • bridge:如前几章所述,这种类型的网络在主机上创建了一个独立的虚拟接口,用于与容器通信,容器可以与主机和互联网通信。通常情况下,这种类型的网络会阻止容器之间的通信。

  • none:禁用容器的所有网络通信。这对于只包含工具的容器并且不需要网络通信的情况非常有用。

  • host:使用主机的网络堆栈,不创建任何虚拟接口。

  • <network_name_or_id>:连接到命名网络。当您创建一个网络并希望将多个容器放入相同的网络组时,此标志非常有用。例如,这对于连接多个喋喋不休的容器(如 Elasticsearch)到它们自己的隔离网络中将非常有用。

  • <container_name_or_id>:这允许您连接到指定容器的网络堆栈。就像--pid标志一样,这对于调试运行中的容器非常有用,而无需直接附加到它们,尽管根据使用的网络驱动程序,网络可能需要使用--attachable标志进行创建。

警告!使用host网络开关会使容器完全访问本地系统服务,因此在除测试之外的任何情况下使用都是一种风险。在使用此标志时要非常小心,但幸运的是,只有极少数情况(如果有的话)会有正当使用这种模式的情况。

Docker 通信端口

除非您正在运行 Docker Swarm,否则您可能永远不需要担心 Docker 用于通信的端口,但这是一个相对重要的参考点,如果您在现场遇到这样的配置或者您想在集群中部署这样的部署。列表非常简短,但每个端口对于大多数 Swarm 集群的操作非常重要:

2377 TCP - Used for Swarm node communication
4789 UDP - Container ingress network
7946 TCP/UDP - Container network discovery
50 IP - Used for secure communication of overlay networks if you use "--opt encrypted" when creating the overlay network

高可用性管道

以前,我们大部分时间都在集群中的节点之间进行基于套接字的通信,这通常是大多数人可以理解的事情,并且几乎每种编程语言都有围绕它构建的工具。因此,这是人们将经典基础架构转换为容器时通常会选择的第一个工具,但对于大规模及以上规模的纯数据处理,由于超出了处理管道其余阶段的容量而导致的背压,它根本不起作用。

如果您将每个集群服务想象为一系列连续的转换步骤,那么基于套接字的系统将经历类似于这些步骤的循环:

  • 打开一个监听套接字。

  • 永远循环执行以下操作:

  • 在套接字上等待来自上一阶段的数据。

  • 处理这些数据。

  • 将处理后的数据发送到下一阶段的套接字。

但是,如果下一个阶段已经达到最大容量,最后一步会发生什么呢?大多数基于套接字的系统要么会抛出异常并完全失败处理管道的这一特定数据,要么阻止执行继续并不断重试将数据发送到下一个阶段直到成功。由于我们不希望处理管道失败,因为结果并非错误,也不希望让我们的工作人员等待下一个阶段解除阻塞,我们需要一些可以按顺序保存阶段输入的东西,以便前一个阶段可以继续处理自己的新输入。

容器消息

对于我们刚刚讨论的情景,即个别处理阶段的背压导致级联回流停止的情况,消息队列(通常也称为发布/订阅消息系统)在这里为我们提供了我们需要的确切解决方案。消息队列通常将数据存储为先进先出(FIFO)队列结构中的消息,并通过允许发送方将所需的输入添加到特定阶段的队列("入队"),并允许工作人员(监听器)在该队列中触发新消息来工作。当工作人员处理消息时,队列会将其隐藏在其他工作人员之外,当工作人员完成并成功时,消息将永久从队列中删除。通过以异步方式处理结果,我们可以允许发送方继续处理自己的任务,并完全模块化数据处理管道。

为了看到队列的运作,假设我们有两个正在运行的容器,并且在很短的时间内,消息ABCD一个接一个地作为来自某个想象的处理步骤的输入到达(红色表示队列顶部):

在内部,队列跟踪它们的顺序,最初,容器队列监听器都没有注意到这些消息,但很快,它们收到通知,有新的工作要做,所以它们按接收顺序获取消息。消息队列(取决于确切的实现)将这些消息标记为不可用于其他监听器,并为工作人员设置一个完成的超时。在这个例子中,消息 A消息 B已被标记为可供可用工作人员处理:

在这个过程中,假设容器1 发生了灾难性故障并且它就这样死了。消息 A在队列中的超时时间到期,而它还没有完成,所以队列将其放回顶部,并使其再次可用于侦听器,而我们的另一个容器继续工作:

成功完成消息 B后,容器 2通知队列任务已完成,并且队列将其从列表中完全移除。完成这一步后,容器现在取出最顶部的消息,结果是未完成的消息 A,整个过程就像以前一样进行:

在这个集群阶段处理故障和过载的同时,将所有这些消息放入队列的上一个阶段继续处理其专用工作负载。即使在某个随机时间点,我们的处理能力的一半被强制移除,我们当前的阶段也没有丢失任何数据。

现在,工作人员的新伪代码循环会更像这样:

  • 在队列上注册为侦听器。

  • 永远循环执行以下操作:

  • 等待队列中的消息。

  • 处理队列中的数据。

  • 将处理后的数据发送到下一个队列。

有了这个新系统,如果管道中的某个阶段出现任何处理减速,那么这些过载阶段的队列将开始增长,但如果较早的阶段减速,队列将缩小直到为空。只要最大队列大小能够处理消息的数量,过载阶段能够处理平均需求,你就可以确定管道中的所有数据最终都会被处理,而且扩展阶段的触发器几乎就像是注意到不是由错误引起的更大的队列一样简单。这不仅有助于缓解管道阶段扩展的差异,而且还有助于在集群的某些部分出现故障时保留数据,因为队列在故障时会增长,然后在将基础设施恢复到正常工作时会清空 - 所有这些都将在不丢失数据的情况下发生。

如果这些好处的组合还不够积极,那么请考虑现在可以保证数据已经被处理,因为队列会保留数据,所以如果一个工作进程死掉,队列会(正如我们之前看到的)将消息放回队列,可能由另一个工作进程处理,而不像基于套接字的处理那样在这种情况下会悄然死去。处理密度的增加、故障容忍度的增加以及对突发数据的更好处理使队列对容器开发者非常有吸引力。如果你所有的通信也都是通过队列完成的,那么服务发现甚至可能不需要对这些工作进程进行除了告诉它们队列管理器在哪里之外的工作,因为队列正在为你做这项发现工作。

毫不奇怪,大多数队列都需要开发成本,这就是为什么它们没有像人们预期的那样被广泛使用的原因。在大多数情况下,你不仅需要将自定义队列客户端库添加到你的工作代码中,而且在许多类型的部署中,你还需要一个处理消息的主要队列仲裁者的进程或守护进程。事实上,我可能会说选择消息系统本身就是一个研究任务,但如果你正在寻找快速答案,一般来说,Apache Kafka(kafka.apache.org/)、RabbitMQ(www.rabbitmq.com/)和基于 Redis 的自定义实现(redis.io/)似乎在集群环境中更受欢迎,从最大的部署到最小的部署。

就像我们迄今为止一直在讨论的所有事物一样,大多数云提供商都提供了某种类型的服务(如 AWS SQS,Google Cloud Pub/Sub,Azure Queue Storage 等),这样你就不必自己构建它。如果你愿意多花一点钱,你可以利用这些服务,而不必担心自己托管守护进程。从历史上看,消息队列在内部维护和管理方面一直很难,所以我敢说,许多云系统使用这些服务,而不是部署自己的服务。

实现我们自己的消息队列

理论讲解完毕,让我们看看如何构建我们自己的小型队列发布者和监听者。在这个例子中,我们将使用基于 Redis 的较简单的消息系统之一,名为bullwww.npmjs.com/package/bull)。首先,我们将编写将运行整个系统的代码,并且为了简化操作,我们将同时使用相同的镜像作为消费者和生产者。

在一个新的目录中,创建以下内容:

作为提醒,这段代码也在 GitHub 存储库中,如果你不想输入完整的文本,可以在github.com/sgnn7/deploying_with_docker/tree/master/chapter_6/redis_queue查看或克隆它。

package.json

这个文件基本上只是我们旧的示例的副本,增加了bull包和名称更改:

{
  "name": "queue-worker",
  "version": "0.0.1",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "bull": "³.2.0"
  }
}

index.js

index.js是一个单文件应用程序,根据调用参数,每 1.5 秒要么向队列发送一个时间戳,要么从队列中读取。队列位置由QUEUE_HOST环境变量定义:

'use strict'

const Queue = require('bull');

const veryImportantThingsQueue = new Queue('very_important_things',
                                           { redis: { port: 6379,
                                                      host: process.env.QUEUE_HOST }});

// Prints any message data received
class Receiver {
    constructor () {
        console.info('Registering listener...');
        veryImportantThingsQueue.process(job => {
            console.info('Got a message from the queue with data:', job.data);
            return Promise.resolve({});
        });
    }
}

// Sends the date every 1.5 seconds
class Sender {
    constructor () {
        function sendMessage() {
            const messageValue = new Date();
            console.info('Sending a message...', messageValue);
            veryImportantThingsQueue.add({ 'key': messageValue });
        }

        setInterval(sendMessage, 1500);
    }
}

// Sanity check
if (process.argv.length < 2) {
    throw new Error(`Usage: ${process.argv.slice(2).join(' ')} <sender | receiver>`);
}

// Start either receiver or sender depending of CLI arg
console.info('Starting...');
if (process.argv[2] === 'sender') {
    new Sender();
} else if (process.argv[2] === 'receiver') {
    new Receiver();
} else {
    throw new Error(`Usage: ${process.argv.slice(0, 2).join(' ')} <sender | receiver>`);
}

Dockerfile

这里没有什么特别的:这个文件基本上是我们旧的 Node.js 应用程序的精简版本:

FROM node:8

# Make sure we are fully up to date
RUN apt-get update -q && \
 apt-get dist-upgrade -y && \
 apt-get clean && \
 apt-get autoclean

# Container port that should get exposed
EXPOSE 8000

ENV SRV_PATH /usr/local/share/queue_handler

# Make our directory
RUN mkdir -p $SRV_PATH && \
 chown node:node $SRV_PATH

WORKDIR $SRV_PATH

USER node

COPY . $SRV_PATH/

RUN npm install

CMD ["npm", "start"]

我们现在将构建镜像:

$ docker build -t queue-worker .
Sending build context to Docker daemon  7.168kB
<snip>
 ---> 08e33a32ba60
Removing intermediate container e17c836c5a33
Successfully built 08e33a32ba60
Successfully tagged queue-worker:latest

通过构建镜像,我们现在可以编写我们的堆栈定义文件:swarm_application.yml。我们基本上是在单个网络上创建队列服务器、队列监听器和队列发送器,并确保它们可以在这里找到彼此:

version: "3"
services:
 queue-sender:
 image: queue-worker
 command: ["npm", "start", "sender"]
 networks:
 - queue_network
 deploy:
 replicas: 1
 depends_on:
 - redis-server
 environment:
 - QUEUE_HOST=redis-server

 queue-receiver:
 image: queue-worker
 command: ["npm", "start", "receiver"]
 networks:
 - queue_network
 deploy:
 replicas: 1
 depends_on:
 - redis-server
 environment:
 - QUEUE_HOST=redis-server

 redis-server:
 image: redis
 networks:
 - queue_network
 deploy:
 replicas: 1
 networks:
 - queue_network
 ports:
 - 6379:6379

networks:
 queue_network:

在镜像构建和堆栈定义都完成后,我们可以启动我们的队列集群,看看它是否正常工作:

$ # We need a Swarm first
$ docker swarm init
Swarm initialized: current node (c0tq34hm6u3ypam9cjr1vkefe) is now a manager.
<snip>

$ # Now we deploy our stack and name it "queue_stack"
$ docker stack deploy \
               -c swarm_application.yml \
               queue_stack
Creating service queue_stack_queue-sender
Creating service queue_stack_queue-receiver
Creating service queue_stack_redis-server

$ # At this point, we should be seeing some traffic...
$ docker service logs queue_stack_queue-receiver
<snip>
queue_stack_queue-receiver.1.ozk2uxqnbfqz@machine    | Starting...
queue_stack_queue-receiver.1.ozk2uxqnbfqz@machine    | Registering listener...
queue_stack_queue-receiver.1.ozk2uxqnbfqz@machine    | Got a message from the queue with data: { key: '2017-10-02T08:24:21.391Z' }
queue_stack_queue-receiver.1.ozk2uxqnbfqz@machine    | Got a message from the queue with data: { key: '2017-10-02T08:24:22.898Z' }
<snip>

$ # Yay! It's working!

$ # Let's clean things up to finish up
$ docker stack rm queue_stack
Removing service queue_stack_queue-receiver
Removing service queue_stack_queue-sender
Removing service queue_stack_redis-server
Removing network queue_stack_redis-server
Removing network queue_stack_queue_network
Removing network queue_stack_service_network

$ docker swarm leave --force
Node left the swarm.

在这一点上,我们可以添加任意数量的发送者和监听者(在合理范围内),我们的系统将以非常异步的方式正常工作,从而增加两端的吞吐量。不过,作为提醒,如果你决定走这条路,强烈建议使用另一种队列类型(如 Kafka、SQS 等),但基本原则基本上是相同的。

高级安全

我们在之前的章节中已经涵盖了一些安全问题,但对于一些似乎经常被忽视的问题,我们需要更深入地讨论它们,而不仅仅是在文本中间的小信息框中看到它们,并了解为什么当不正确使用时它们会成为如此严重的问题。虽然在实施我们在各种警告和信息框中指出的所有事情可能会显得很费力,但是你提供给潜在入侵者的攻击面越小,从长远来看你就会越好。也就是说,除非你正在为政府机构部署这个系统,我预计会有一些妥协,但我敦促你强烈权衡每个方面的利弊,否则你就有可能会收到那个可怕的午夜电话,通知你发生了入侵。

具有讽刺意味的是,加固系统通常需要花费大量时间来开发和部署,以至于它们往往在投入生产环境时已经过时或提供的业务价值较小,并且由于它们精心组装的部件,它们很少(如果有的话)会更新为新功能,迅速应用补丁,或对源代码进行改进,因此它真的是一把双刃剑。没有*完美的解决方案,只有一系列你在某种程度上感到舒适的事情。从历史上看,我大多数情况下看到的是在两个极端之间的可怕执行,所以我在这里的建议是,如果可能的话,你应该寻求两者的结合。

将 Docker 套接字挂载到容器中

这绝对是开发人员在部署容器化解决方案时完全忽视的最严重的安全漏洞。对于与容器管理相关的各种事情,通常在互联网上的建议都倾向于将 Docker 套接字(/var/run/docker.sock)绑定到容器中,但很少提到的是这样做实际上会有效地将主机的根级访问权限赋予这样的容器。由于 Docker 的套接字实际上只是一个 API 端点,而 Docker 守护程序以 root 身份运行,容器可以通过在其上挂载主机系统文件夹并在其上执行任意命令来简单地逃离其封闭环境。

有关使用 Docker 套接字作为 RESTful 端点的更多信息,您可以查看源代码,或者通过 Docker Engine API 的文档进行探索docs.docker.com/engine/api/v1.31/。通常,您只需要通过诸如curl之类的工具添加--unix-socket <socket_path>,并且对于POST请求,可以选择添加-H "Content-Type: application/json"

Docker 一直在努力将其服务从根级别转变为用户空间级别,但到目前为止,这个功能还没有以任何实际的方式实现。尽管我个人对这种情况很怀疑,但请留意这个功能,因为在某个时候它可能会真正发布并成为一个可用的功能,这将是容器安全性的重大进步。

$ Start a "benign" container with the Docker socket mounted and run Bash
$ docker run --rm \
 -it \
 -v /var/run/docker.sock:/var/run/docker.sock \
 ubuntu /bin/bash 

root@686212135a17:/# # Sanity check - make sure that the socket is there
root@686212135a17:/# ls -la /var/run/docker.sock
srw-rw---- 1 root 136 0 Sep 20 05:03 /var/run/docker.sock

root@686212135a17:/# # Install curl but almost any other HTTP client will work
root@686212135a17:/# # Even a base Python can do this but curl is fine for brevity
root@686212135a17:/# apt-get update && apt-get install -y curl
<snip>
done

root@686212135a17:/# # Create a container through the socket and bind-mount root to it
root@686212135a17:/# # with a "malicious" touch command to run
root@686212135a17:/# curl -s \
 --unix-socket /var/run/docker.sock \
 -H "Content-Type: application/json" \
 -d '{"Image": "ubuntu", "Cmd": ["touch", "/mnt/security_breach"], "Mounts": [{"Type": "bind", "Source": "/", "Target":"/mnt", "RW": true}]}' \
 -X POST \
 http:/v1.29/containers/create 
{"Id":"894c4838931767462173678aacc51c3bb98f4dffe15eaf167782513305c72558","Warnings":null}

root@686212135a17:/# # Start our escaped container
root@686212135a17:/# curl --unix-socket /var/run/docker.sock \
 -X POST \
 http:/v1.29/containers/894c4838/start

root@686212135a17:/# # Exit out of our "benign" container back to host
root@686212135a17:/# exit
exit

$ # Let's see what happened on our host
$ ls -la / | grep breach
-rw-r--r--   1 root root       0 Sep 20 23:14 security_breach 
$ # Oops!

现在应该很明显了,良性容器是如何能够仅通过几个 CLI 命令就在主机上获得 root 权限的。虽然其中一些是基于容器进程以 root 身份运行,但如果 Docker 组 ID 与容器中的非特权组冲突,可能也会出现相同的情况,但是除了这些细微之处,可以说,挂载 Docker 套接字而不完全理解其影响可能导致非常痛苦的违规行为。考虑到这一点,这种技术也有(虽然很少)合法的用途,所以在这里要慎重使用。

现在我们已经了解了如何滥用 Docker 套接字的理论,接下来我们将跳出容器,尽管我们不会真的对系统造成任何破坏:主机安全扫描。

作为增加部署安全性的一部分,Docker 发布了一个工具,可以帮助轻松识别运行 Docker Engine 的主机上最常见的安全问题,称为Docker Bench for Security。这个工具将扫描和验证配置中的大量可能的弱点,并以非常易于阅读的列表形式呈现出来。您可以像在 Docker Hub 上使用其他常规容器一样下载和运行这个镜像。

警告!此安全扫描需要许多权限(--net host--pid host、Docker 套接字挂载等),我们已经讨论过这些权限通常是在主机上运行的一个非常糟糕的主意,因为它们为恶意行为者提供了一个相当大的攻击向量,但另一方面,扫描需要这些权限来检查您的设置。因此,我强烈建议在网络隔离的环境中克隆要测试的主机机器上运行这种类型的安全扫描,以防止扫描镜像被恶意修改而危及您的基础设施。

$ docker run --rm \
 -it \
 --net host \
 --pid host \
 --cap-add audit_control \
 -e DOCKER_CONTENT_TRUST=$DOCKER_CONTENT_TRUST \
 -v /var/lib:/var/lib \
 -v /var/run/docker.sock:/var/run/docker.sock \
 -v /usr/lib/systemd:/usr/lib/systemd \
 -v /etc:/etc \
 docker/docker-bench-security
# ------------------------------------------------------------------------------
# Docker Bench for Security v1.3.3
#
# Docker, Inc. (c) 2015-
#
# Checks for dozens of common best-practices around deploying Docker containers in production.
# Inspired by the CIS Docker Community Edition Benchmark v1.1.0.
# ------------------------------------------------------------------------------

Initializing Mon Oct  2 00:03:29 CDT 2017

[INFO] 1 - Host Configuration
[WARN] 1.1  - Ensure a separate partition for containers has been created
[NOTE] 1.2  - Ensure the container host has been Hardened
date: invalid date '17-10-1 -1 month'
sh: out of range
sh: out of range
[PASS] 1.3  - Ensure Docker is up to date
[INFO]      * Using 17.09.0 which is current
[INFO]      * Check with your operating system vendor for support and security maintenance for Docker
[INFO] 1.4  - Ensure only trusted users are allowed to control Docker daemon
[INFO]      * docker:x:999
[WARN] 1.5  - Ensure auditing is configured for the Docker daemon
[WARN] 1.6  - Ensure auditing is configured for Docker files and directories - /var/lib/docker
[WARN] 1.7  - Ensure auditing is configured for Docker files and directories - /etc/docker
[INFO] 1.8  - Ensure auditing is configured for Docker files and directories - docker.service
<snip>
[PASS] 2.10 - Ensure base device size is not changed until needed
[WARN] 2.11 - Ensure that authorization for Docker client commands is enabled
[WARN] 2.12 - Ensure centralized and remote logging is configured
[WARN] 2.13 - Ensure operations on legacy registry (v1) are Disabled
[WARN] 2.14 - Ensure live restore is Enabled
[WARN] 2.15 - Ensure Userland Proxy is Disabled
<snip>
[PASS] 7.9  - Ensure CA certificates are rotated as appropriate (Swarm mode not enabled)
[PASS] 7.10 - Ensure management plane traffic has been separated from data plane traffic (Swarm mode not enabled)

列表相当长,因此大部分输出行都被删除了,但你应该对这个工具的功能和如何使用有一个相当好的了解。请注意,这不是这个领域唯一的产品(例如,CoreOS 的 Clair 在 github.com/coreos/clair),因此尽量使用尽可能多的产品,以便了解基础设施的弱点所在。

只读容器

在我们之前的示例开发中,跨越了大部分章节,我们并没有真正关注容器在运行时是否改变了文件系统的状态。这对于测试和开发系统来说并不是问题,但在生产环境中,进一步加强锁定非常重要,以防止来自内部和外部来源的恶意运行时利用。为此,有一个 docker run --read-only 标志,它(不出所料地)将容器的根文件系统挂载为只读。通过这样做,我们确保除了使用卷挂载的数据外,所有数据都与构建镜像时一样纯净,确保一致性并保护您的集群。如果以这种方式运行容器,您唯一需要注意的是,容器在执行过程中极有可能需要临时存储文件的位置,例如 /run/tmp/var/tmp,因此这些挂载应该额外作为 tmpfs 卷挂载:

$ # Start a regular container
$ docker run -it \
 --rm \
 ubuntu /bin/bash 
root@79042a966943:/# # Write something to /bin
root@79042a966943:/# echo "just_a_test" > /bin/test

root@79042a966943:/# # Check if it's there
root@79042a966943:/# ls -la /bin | grep test
-rw-r--r-- 1 root root      12 Sep 27 17:43 test

root@79042a966943:/# exit
exit

$ # Now try a read-only container
$ docker run -it \
 --rm \
 --tmpfs /run \
 --tmpfs /tmp \
 --tmpfs /var/tmp \
 --read-only \
 ubuntu /bin/bash 
root@5b4574a46c09:/# # Try to write to /bin
root@5b4574a46c09:/# echo "just_a_test" > /bin/test
bash: /bin/test: Read-only file system

root@5b4574a46c09:/# # Works as expected! What about /tmp?
root@5b4574a46c09:/# echo "just_a_test" > /tmp/test
root@5b4574a46c09:/# ls /tmp
test

root@5b4574a46c09:/# exit
exit

如果您不希望容器在文件系统上做出任何更改,并且由于容器通常不需要写入 /usr 等路径,强烈建议在生产中使用此标志,因此如果可能的话,请在所有静态服务上广泛应用它。

基础系统(软件包)更新

我们之前已经谈到了这个问题,但似乎在大多数在线文档和博客中,Docker 容器环境中软件包更新的覆盖范围被严重忽视。虽然支持者有两种观点,但重要的是要记住,无法保证来自 Docker Hub 等地方可用的标记图像是否已经使用最新的更新构建,即使在这些情况下,标记图像可能已经建立了一段时间,因此不包含最新的安全补丁。

尽管在 Docker 容器中使用主机的内核来运行容器的上下文是真实的,但容器中任何支持库的安全漏洞通常会导致漏洞,这些漏洞经常会级联到主机和整个网络中。因此,我个人建议部署到生产环境的容器应该尽可能确保使用最新的库构建容器。手动升级一些基本镜像上的软件包存在明显的风险,这是由于升级时可能会出现库不兼容性,但总的来说,这是一个值得冒的风险。

在大多数情况下,为了进行这种升级,就像我们在大多数 Docker 示例中所介绍的那样,你基本上需要在Dockerfile中调用特定于镜像基本操作系统发行版的系统升级命令。对于我们默认的部署操作系统(Ubuntu LTS),可以使用apt-get updateapt-get dist-upgrade来完成此操作。

...
RUN apt-get update && apt-get -y dist-upgrade
...

注意!不要忘记,默认情况下,docker build将缓存所有未更改的Dockerfile指令的各个层,因此该命令在第一次使用时会按预期工作,但如果它之前的任何行都没有更改,那么在后续使用时将从缓存中提取其层,因为这行将保持不变,而不管上游包是否更改。如果要确保获取最新更新,必须通过更改Dockerfileapt-get上面的行或在docker build命令中添加--no-cache来打破缓存。此外,请注意,使用--no-cache将重新生成所有层,可能会导致较长的构建周期和/或注册表磁盘使用。

特权模式与--cap-add 和--cap-drop

在容器内可能需要执行的一些高级操作,例如Docker-in-Docker(DinD)、NTP、挂载回环设备等,都需要比默认情况下容器的根用户所拥有的更高权限。因此,需要为容器允许额外的权限,以便它能够无问题地运行,因此,对于这种情况,Docker 有一个非常简单但非常广泛的特权模式,它将主机的完整功能添加到容器中。要使用此模式,只需在docker run命令后附加--privileged

Docker-in-Docker(通常称为DinD)是容器的特殊配置,允许您在已在 Docker 引擎上运行的容器内运行 Docker 引擎,但不共享 Docker 套接字,这允许(如果采取预防措施)更安全和更可靠地在已容器化的基础架构中构建容器。这种配置的普及程度有些罕见,但在持续集成CI)和持续交付CD)设置的一部分时非常强大。

$ # Run an NTP daemon without the extra privileges and see what happens
$ docker run -it \
 --rm \
 cguenther/ntpd 
ntpd: can't set priority: Permission denied
reset adjtime failed: Operation not permitted
creating new /var/db/ntpd.drift
adjtimex failed: Operation not permitted
adjtimex adjusted frequency by 0.000000ppm
ntp engine ready
reply from 38.229.71.1: offset -2.312472 delay 0.023870, next query 8s
settimeofday: Operation not permitted
reply from 198.206.133.14: offset -2.312562 delay 0.032579, next query 8s
reply from 96.244.96.19: offset -2.302669 delay 0.035253, next query 9s
reply from 66.228.42.59: offset -2.302408 delay 0.035170, next query 7s
^C

$ And now with our new privileged mode
$ docker run -it \
 --rm \
 --privileged \
 cguenther/ntpd 
creating new /var/db/ntpd.drift
adjtimex adjusted frequency by 0.000000ppm
ntp engine ready
^C

正如您所看到的,添加此标志将从输出中删除所有错误,因为我们现在可以更改系统时间。

解释了此模式的功能后,我们现在可以谈论为什么在理想情况下,如果可能的话,您永远不应该使用特权模式。默认情况下,特权模式几乎允许访问主机系统的所有内容,并且在大多数情况下不够细粒度,因此在确定容器需要额外权限后,应该使用--cap-add有选择地添加它们。这些标志是标准的 Linux 功能标识符,您可以在man7.org/linux/man-pages/man7/capabilities.7.html等地方找到,并允许对所需的访问级别进行微调。如果我们现在将先前的 NTP 守护程序示例转换为这种新样式,它应该看起来更像这样:

$ # Sanity check
$ docker run -it \
 --rm \
 cguenther/ntpd 
ntpd: can't set priority: Permission denied
<snip>
settimeofday: Operation not permitted
<snip>
^C

$ # Now with the added SYS_TIME capability
$ docker run -it \
 --rm \
 --cap-add SYS_TIME \
 cguenther/ntpd 
ntpd: can't set priority: Permission denied
creating new /var/db/ntpd.drift
adjtimex adjusted frequency by 0.000000ppm
ntp engine ready
reply from 204.9.54.119: offset 15.805277 delay 0.023080, next query 5s
set local clock to Mon Oct  2 06:05:47 UTC 2017 (offset 15.805277s)
reply from 38.229.71.1: offset 0.005709 delay 31.617842, next query 9s
^C

如果您注意到,由于另一个缺失的功能,我们仍然有一个可见的错误,但settimeofday错误已经消失了,这是我们需要解决的最重要的问题,以便该容器能够运行。

有趣的是,我们还可以使用--cap-drop从容器中删除未被使用的功能,以增加安全性。对于这个标志,还有一个特殊的关键字ALL,可以用来删除所有可用的权限。如果我们使用这个来完全锁定我们的 NTP 容器,但一切正常运行,让我们看看会是什么样子:

docker run -it \
 --rm \
 --cap-drop ALL \
 --cap-add SYS_TIME \
 --cap-add SYS_CHROOT \
 --cap-add SETUID \
 --cap-add SETGID \
 --cap-add SYS_NICE \
 cguenther/ntpd 
creating new /var/db/ntpd.drift
adjtimex adjusted frequency by 0.000000ppm
ntp engine ready
reply from 216.229.0.49: offset 14.738336 delay 1.993620, next query 8s
set local clock to Mon Oct  2 06:16:09 UTC 2017 (offset 14.738336s)
reply from 216.6.2.70: offset 0.523095 delay 30.422572, next query 6s
^C

在这里,我们首先删除了所有的功能,然后再添加回运行容器所需的少数功能,正如您所看到的,一切都运行正常。在您自己的部署中,我强烈建议,如果您有多余的开发能力或者注重安全性,花一些时间以这种方式锁定正在运行的容器,这样它们将更加安全,您也将更加确信容器是在最小权限原则下运行的。

“最小权限原则”是计算机安全中的一个概念,它只允许用户或服务运行组件所需的最低权限。这个原则在高安全性实现中非常重要,但通常在其他地方很少见,因为管理访问的开销被认为很大,尽管这是增加系统安全性和稳定性的好方法。如果您想了解更多关于这个概念的信息,您应该去en.wikipedia.org/wiki/Principle_of_least_privilege查看一下。

总结

在本章中,我们学习了许多部署强大集群所需的高级工具和技术,例如以下内容:

  • 管理容器问题的额外调试选项。

  • 深入研究 Docker 的高级网络主题。

  • 实施我们自己的队列消息传递。

  • 各种安全加固技巧和窍门。

所有这些主题加上之前的材料应该涵盖了大多数集群的部署需求。但在下一章中,我们将看到当主机、服务和任务的数量达到通常不被期望的水平时,我们需要担心什么问题,以及我们可以采取什么措施来减轻这些问题。

第七章:扩展的限制和解决方法

当您扩展系统时,您使用的每个工具或框架都会达到一个破坏或不按预期运行的点。对于某些事物,这一点可能很高,对于某些事物,这一点可能很低,本章的目的是介绍在使用微服务集群时可能遇到的最常见的可扩展性问题的策略和解决方法。在本章中,我们将涵盖以下主题:

  • 增加服务密度和稳定性。

  • 避免和减轻大规模部署中的常见问题。

  • 多服务容器。

  • 零停机部署的最佳实践。

限制服务资源

到目前为止,我们并没有真正花时间讨论与服务可用资源相关的服务隔离,但这是一个非常重要的话题。如果不限制资源,恶意或行为不端的服务可能会导致整个集群崩溃,具体取决于严重程度,因此需要非常小心地指定个别服务任务应该使用的资源限额。

处理集群资源的通常接受的策略如下:

  • 任何资源如果超出预期值使用可能会导致其他服务出现错误或故障,强烈建议在服务级别上进行限制。这通常是 RAM 分配,但也可能包括 CPU 或其他资源。

  • 任何资源,特别是硬件资源,都应该在 Docker 容器中进行限制(例如,您只能使用 1-Gbps NAS 连接的特定部分)。

  • 任何需要在特定设备、机器或主机上运行的东西都应以相同的方式锁定到这些资源上。当只有一定数量的机器具有适合某项服务的正确硬件时,这种设置非常常见,比如在 GPU 计算集群中。

  • 通常应该对希望在集群中特别配给的任何资源施加限制。这包括降低低优先级服务的 CPU 时间百分比等事项。

  • 在大多数情况下,其余资源应该可以正常使用主机可用资源的正常分配。

通过应用这些规则,我们将确保我们的集群更加稳定和安全,资源的分配也更加精确。此外,如果指定了服务所需的确切资源,编排工具通常可以更好地决定在哪里安排新创建的任务,以便最大化每个引擎的服务密度。

RAM 限制

奇怪的是,尽管 CPU 可能被认为是最重要的计算资源,但由于 RAM 的过度使用可能会导致内存不足(OOM)进程和任务失败,因此对集群服务的 RAM 分配甚至更为重要。由于软件中内存泄漏的普遍存在,这通常不是“是否”而是“何时”的问题,因此设置 RAM 分配限制通常是非常可取的,在某些编排配置中甚至是强制性的。遇到这个问题通常会看到SIGKILL"进程被杀死"退出代码-9

请记住,这些信号很可能是由其他原因引起的,但最常见的原因是 OOM 失败。

通过限制可用的 RAM,而不是由 OOM 管理器杀死主机上的随机进程,只有有问题的任务进程将被定位为目标,因此可以更容易和更快地识别出有问题的代码,因为您可以看到来自该服务的大量失败,而您的其他服务将保持运行,增加了集群的稳定性。

OOM 管理是一个庞大的主题,比起在本节中包含它更明智,但如果您在 Linux 内核中花费大量时间,了解这一点非常重要。如果您对此主题感兴趣,我强烈建议您访问www.kernel.org/doc/gorman/html/understand/understand016.html并对其进行阅读。警告!在一些最流行的内核上,由于其开销,内存和/或交换 cgroups 被禁用。要在这些内核上启用内存和交换限制,您的主机内核必须以cgroup_enable=memoryswapaccount=1标志启动。如果您使用 GRUB 作为引导加载程序,您可以通过编辑/etc/default/grub(或者在最新系统上,/etc/default/grub.d/<name>),设置GRUB_CMDLINE_LINUX="cgroup_enable=memory swapaccount=1",运行sudo update-grub,然后重新启动您的机器来启用它们。

要使用限制 RAM 的cgroup配置,运行容器时使用以下标志的组合:

  • -m / --内存:容器可以使用的最大内存量的硬限制。超过此限制的新内存分配将失败,并且内核将终止容器中通常运行服务的主要进程。

  • --内存交换:容器可以使用的包括交换在内的内存总量。这必须与前一个选项一起使用,并且比它大。默认情况下,容器最多可以使用两倍于容器的允许内存的内存。将其设置为-1允许容器使用主机拥有的交换空间。

  • --内存交换倾向:系统将页面从物理内存移动到磁盘交换空间的渴望程度。该值介于0100之间,其中0表示页面将尽可能留在驻留 RAM 中,反之亦然。在大多数机器上,该值为80,将用作默认值,但由于与 RAM 相比,交换空间访问非常缓慢,我的建议是将此数字设置为尽可能接近0

  • --内存预留:服务的 RAM 使用的软限制,通常仅用于检测资源争用,以便编排引擎可以安排任务以实现最大使用密度。此标志不能保证将保持服务的 RAM 使用量低于此水平。

还有一些其他标志可以用于内存限制,但即使前面的列表可能比你需要担心的要详细一些。对于大多数部署,无论大小,你可能只需要使用-m并设置一个较低的值--memory-swappiness,后者通常是通过sysctl.d引导设置在主机上完成的,以便所有服务都将利用它。

你可以通过运行sysctl vm.swappiness来检查你的swappiness设置是什么。如果你想改变这个值,在大多数集群部署中你会这样做,你可以通过运行以下命令来设置这个值:

$ echo "vm.swappiness = 10" | sudo tee -a /etc/sysctl.d/60-swappiness.conf

要看到这一点的实际效果,我们首先将运行一个最资源密集的框架(JBoss),限制为 30 MB 的 RAM,看看会发生什么:

$ docker run -it \
             --rm \
             -m 30m \
             jboss/wildfly 
Unable to find image 'jboss/wildfly:latest' locally
latest: Pulling from jboss/wildfly
<snip>
Status: Downloaded newer image for jboss/wildfly:latest
=========================================================================

 JBoss Bootstrap Environment

 JBOSS_HOME: /opt/jboss/wildfly

 JAVA: /usr/lib/jvm/java/bin/java

 JAVA_OPTS:  -server -Xms64m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true -Djboss.modules.system.pkgs=org.jboss.byteman -Djava.awt.headless=true

=========================================================================

* JBossAS process (57) received KILL signal *

不出所料,容器使用了太多的 RAM,并立即被内核杀死。现在,如果我们尝试相同的事情,但给它 400 MB 的 RAM 呢?


$ docker run -it \
             --rm \
             -m 400m \
             jboss/wildfly
=========================================================================

 JBoss Bootstrap Environment

 JBOSS_HOME: /opt/jboss/wildfly

 JAVA: /usr/lib/jvm/java/bin/java

 JAVA_OPTS:  -server -Xms64m -Xmx512m -XX:MetaspaceSize=96M -XX:MaxMetaspaceSize=256m -Djava.net.preferIPv4Stack=true -Djboss.modules.system.pkgs=org.jboss.byteman -Djava.awt.headless=true

=========================================================================

14:05:23,476 INFO  [org.jboss.modules] (main) JBoss Modules version 1.5.2.Final
<snip>
14:05:25,568 INFO  [org.jboss.ws.common.management] (MSC service thread 1-6) JBWS022052: Starting JBossWS 5.1.5.Final (Apache CXF 3.1.6) 
14:05:25,667 INFO  [org.jboss.as] (Controller Boot Thread) WFLYSRV0060: Http management interface listening on http://127.0.0.1:9990/management
14:05:25,667 INFO  [org.jboss.as] (Controller Boot Thread) WFLYSRV0051: Admin console listening on http://127.0.0.1:9990
14:05:25,668 INFO  [org.jboss.as] (Controller Boot Thread) WFLYSRV0025: WildFly Full 10.1.0.Final (WildFly Core 2.2.0.Final) started in 2532ms - Started 331 of 577 services (393 services are lazy, passive or on-demand)

我们的容器现在可以无问题地启动了!

如果你在裸机环境中大量使用应用程序,你可能会问自己为什么 JBoss JVM 事先不知道它将无法在如此受限制的环境中运行并更早地失败。答案在于cgroups的一个非常不幸的怪癖(尽管我认为它可能被视为一个特性,取决于你的观点),它将主机的资源未经修改地呈现给容器,即使容器本身受到限制。如果你运行一个内存受限的容器并打印出可用的 RAM 限制,你很容易看到这一点:

$ # Let's see what a low allocation shows
$ docker run -it --rm -m 30m ubuntu /usr/bin/free -h
 total        used        free      shared  buff/cache   available
Mem:           7.6G        1.4G        4.4G         54M        1.8G        5.9G
Swap:            0B          0B          0B

$ # What about a high one?
$ docker run -it --rm -m 900m ubuntu /usr/bin/free -h
 total        used        free      shared  buff/cache   available
Mem:           7.6G        1.4G        4.4G         54M        1.8G        5.9G
Swap:            0B          0B          0B

正如你所想象的,这会导致在这样一个cgroup受限制的容器中启动的应用程序出现各种级联问题,主要问题是应用程序根本不知道有限制,因此它会尝试做它的工作,假设它可以完全访问可用的 RAM。一旦应用程序达到预定义的限制,应用程序进程通常会被杀死,容器也会死掉。这是一个巨大的问题,对于可以对高内存压力做出反应的应用程序和运行时来说,它们可能能够在容器中使用更少的 RAM,但因为它们无法确定它们正在受到限制,它们倾向于以比应该更高的速率吞噬内存。

遗憾的是,对于容器来说,情况甚至更糟。你不仅必须给服务一个足够大的 RAM 限制来启动它,还必须足够大,以便它可以处理在服务的整个持续时间内动态分配的内存。如果不这样做,同样的情况将在一个不太可预测的时间发生。例如,如果你只给一个 NGINX 容器 4MB 的 RAM 限制,它会正常启动,但在连接到它的几次后,内存分配将超过阈值,容器将死机。然后服务可能会重新启动任务,除非你有日志记录机制或你的编排提供了良好的工具支持,否则你最终会得到一个状态为“运行”的服务,但实际上它无法处理任何请求。

如果这还不够,你也真的不应该随意地分配高限制。这是因为容器的一个目的是在给定的硬件配置下最大化服务密度。通过设置几乎不可能被运行服务达到的限制,你实际上在浪费这些资源,因为它们无法被其他服务使用。从长远来看,这会增加基础设施的成本和维护所需的资源,因此有很大的动力来保持服务受到最低限度的限制,以确保安全运行,而不是使用非常高的限制。

编排工具通常可以防止资源过度分配,尽管 Docker Swarm 和 Kubernetes 都在支持这一特性方面取得了一些进展,你可以指定软限制(内存请求)与真实限制(内存限制)。然而,即使有了这些参数,调整 RAM 设置仍然是一个非常具有挑战性的任务,因为你可能会出现资源利用不足或持续重新调度的情况,因此这里涉及的所有主题仍然非常相关。关于编排特定处理资源过度分配的更多信息,我建议你阅读你特定编排工具的最新文档。

因此,当考虑所有必须牢记的事情时,调整限制更接近于一种艺术形式,而不是其他任何东西,因为它几乎就像著名的装箱问题的变体(en.wikipedia.org/wiki/Bin_packing_problem),但也增加了服务的统计组件,因为您可能需要找出最佳的服务可用性与由于宽松限制而浪费资源之间的平衡。

假设我们有一个以下分布的服务:

  • 每个物理主机的 RAM 为 2 GB(是的,这确实很低,但这是为了演示小规模问题)

  • 服务 1(数据库)的内存限制为 1.5 GB,有两个任务,并且有 1%的几率超过硬限制运行

  • 服务 2(应用程序)的内存限制为 0.5 GB,有三个任务,并且有 5%的几率超过硬限制运行

  • 服务 3(数据处理服务)的内存限制为 0.5 GB,有三个任务,并且有 5%的几率超过硬限制运行

调度程序可以按以下方式分配服务:

警告!您应该始终在集群上保留一定的容量以进行滚动服务更新,因此在实际情况下,配置与图表中所示的类似配置效果不佳。通常,这种额外的容量也是一个模糊值,就像 RAM 限制一样。通常,我的公式如下,但随时可以根据需要进行调整:

过剩容量=平均(服务大小)*平均(服务计数)*平均(最大滚动服务重启)

我们将在文本中进一步讨论这一点。

如果我们拿最后一个例子,现在说我们应该只在整体上以 1%的 OOM 故障率运行,将我们的服务 2服务 3的内存限制从 0.5 GB 增加到 0.75 GB,而不考虑也许在数据处理服务和应用程序任务上具有更高的故障率可能是可以接受的(甚至如果您使用消息队列,最终用户可能根本不会注意到)?

新的服务分布现在看起来是这样的:

我们的新配置存在大量明显的问题:

  • 服务密度减少 25%。这个数字应该尽可能高,以获得使用微服务的所有好处。

  • 硬件利用率减少了 25%。实际上,在这种设置中,可用硬件资源的四分之一被浪费。

  • 节点数量增加了 66%。大多数云服务提供商按照运行的机器数量收费,假设它们是相同类型的。通过进行这种改变,您实际上增加了 66%的云成本,并可能需要额外的运维支持来保持集群的运行。

尽管这个例子是故意操纵的,以便在调整时产生最大的影响,但显而易见的是,对这些限制进行轻微的更改可能会对整个基础设施产生巨大的影响。在实际场景中,这种影响将会减少,因为主机机器会更大,这将使它们更能够在可用空间中堆叠较小(相对于总容量)的服务,不要低估增加服务资源分配的级联效应。

CPU 限制

就像我们之前关于服务内存限制的部分一样,docker run也支持各种 CPU 设置和参数,以调整您的服务的计算需求:

  • -c/--cpu-shares:在高负载主机上,默认情况下所有任务的权重都是相等的。在任务或服务上设置此标志(从默认值1024)将增加或减少任务可以被调度的 CPU 利用率的百分比。

  • --cpu-quota:此标志设置任务或服务在默认的 100 毫秒(100,000 微秒)时间块内可以使用 CPU 的微秒数。例如,要仅允许任务最多使用单个 CPU 核心 50%的使用率,您将把此标志设置为50000。对于多个核心,您需要相应地增加此值。

  • --cpu-period:这会更改以微秒为单位的先前配额标志默认间隔,用于评估cpu-quota(100 毫秒/100,000 微秒),并将其减少或增加以反向影响服务的 CPU 资源分配。

  • --cpus:一个浮点值,结合了cpu-quotacpu-period的部分,以限制任务对 CPU 核分配的数量。例如,如果您只希望任务最多使用四分之一的单个 CPU 资源,您可以将其设置为0.25,它将产生与--cpu-quota 25000 --cpu-period 100000相同的效果。

  • --cpuset-cpus:此数组标志允许服务仅在从 0 开始索引的指定 CPU 上运行。如果您希望服务仅使用 CPU 0 和 3,您可以使用--cpuset-cpus "0,3"。此标志还支持将值输入为范围(即1-3)。

虽然可能看起来有很多选项需要考虑,但在大多数情况下,您只需要调整--cpu-shares--cpus标志,但有可能您需要更精细地控制它们提供的资源。

我们来看看--cpu-shares值对我们有什么作用?为此,我们需要模拟资源争用,在下一个示例中,我们将尝试通过在机器上的每个 CPU 上增加一个整数变量的次数来在 60 秒内尽可能多地模拟这一点。代码有点复杂,但其中大部分是为了使 CPU 在所有核心上达到资源争用水平。

将以下内容添加到名为cpu_shares.sh的文件中(也可在github.com/sgnn7/deploying_with_docker上找到):

#!/bin/bash -e

CPU_COUNT=$(nproc --all)
START_AT=$(date +%s)
STOP_AT=$(( $START_AT + 60 ))

echo "Detected $CPU_COUNT CPUs"
echo "Time range: $START_AT -> $STOP_AT"

declare -a CONTAINERS

echo "Allocating all cores but one with default shares"
for ((i = 0; i < $CPU_COUNT - 1; i++)); do
  echo "Starting container $i"
  CONTAINERS[i]=$(docker run \
                  -d \
                  ubuntu \
                  /bin/bash -c "c=0; while [ $STOP_AT -gt \$(date +%s) ]; do c=\$((c + 1)); done; echo \$c")
done

echo "Starting container with high shares"
  fast_task=$(docker run \
              -d \
              --cpu-shares 8192 \
              ubuntu \
              /bin/bash -c "c=0; while [ $STOP_AT -gt \$(date +%s) ]; do c=\$((c + 1)); done; echo \$c")

  CONTAINERS[$((CPU_COUNT - 1))]=$fast_task

echo "Waiting full minute for containers to finish..."
sleep 62

for ((i = 0; i < $CPU_COUNT; i++)); do
  container_id=${CONTAINERS[i]}
  echo "Container $i counted to $(docker logs $container_id)"
  docker rm $container_id >/dev/null
done

现在我们将运行此代码并查看我们标志的效果:

$ # Make the file executable
$ chmod +x ./cpu_shares.sh

$ # Run our little program
$ ./cpu_shares.sh
Detected 8 CPUs
Time range: 1507405189 -> 1507405249
Allocating all cores but one with default shares
Starting container 0
Starting container 1
Starting container 2
Starting container 3
Starting container 4
Starting container 5
Starting container 6
Starting container with high shares
Waiting full minute for containers to finish...
Container 0 counted to 25380
Container 1 counted to 25173
Container 2 counted to 24961
Container 3 counted to 24882
Container 4 counted to 24649
Container 5 counted to 24306
Container 6 counted to 24280
Container 7 counted to 31938

尽管具有较高--cpu-share值的容器没有得到预期的完全增加,但如果我们在更长的时间内使用更紧密的 CPU 绑定循环运行基准测试,差异将会更加明显。但即使在我们的小例子中,您也可以看到最后一个容器在机器上运行的所有其他容器中具有明显优势。

为了了解--cpus标志的作用,让我们看看在一个没有争用的系统上它能做什么:

$ # First without any limiting
$ time docker run -it \
 --rm \
 ubuntu \
 /bin/bash -c 'for ((i=0; i<100; i++)); do sha256sum /bin/bash >/dev/null; done'
real    0m1.902s
user    0m0.030s
sys    0m0.006s

$ # Now with only a quarter of the CPU available
$ time docker run -it \
 --rm \
 --cpus=0.25 \
 ubuntu \
 /bin/bash -c 'for ((i=0; i<100; i++)); do sha256sum /bin/bash >/dev/null; done'
real    0m6.456s
user    0m0.018s
sys    0m0.017s

正如您所看到的,--cpus标志非常适合确保任务不会使用超过指定值的 CPU,即使在机器上没有资源争用的情况下。

请记住,还有一些限制容器资源使用的选项,这些选项有些超出了我们已经涵盖的一般范围,但它们主要用于特定设备的限制(例如设备 IOPS)。如果您有兴趣了解如何将资源限制到任务或服务的所有可用方式,您应该能够在docs.docker.com/engine/reference/run/#runtime-constraints-on-resources找到它们。

避免陷阱

在大多数小型和中型部署中,您永远不会遇到与扩展超出它们时会开始遇到的相同问题,因此本节旨在向您展示您将遇到的最常见问题以及如何以最干净的方式解决它们。虽然这个列表应该涵盖您将遇到的大多数突出问题,但您自己的一些问题将需要自定义修复。您不应该害怕进行这些更改,因为几乎所有主机操作系统安装都不适合高负载多容器所需的配置。

警告!本节中的许多值和调整都是基于在云中部署 Docker 集群的个人经验。根据您的云提供商、操作系统分发和基础设施特定配置的组合,这些值可能不需要从默认值更改,有些甚至可能对系统造成损害,如果直接使用而不花时间学习它们的含义和如何修改。如果您继续阅读本节,请将示例仅用作更改值的示例,而不是直接复制/粘贴到配置管理工具中。

ulimits

ulimit设置对大多数 Linux 桌面用户来说是鲜为人知的,但在与服务器工作时,它们是一个非常痛苦且经常遇到的问题。简而言之,ulimit设置控制了进程资源使用的许多方面,就像我们之前介绍的 Docker 资源调整一样,并应用于已启动的每个进程和 shell。这些限制几乎总是在发行版上设置的,以防止一个杂乱的进程使您的机器崩溃,但这些数字通常是根据常规桌面使用而选择的,因此尝试在未更改的系统上运行服务器类型的代码几乎肯定会至少触及打开文件限制,可能还会触及其他一些限制。

我们可以使用ulimit -a来查看我们当前(也称为软限制)的设置:

$ 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) 29683
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) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 29683
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

正如您所看到的,这里只设置了一些东西,但有一项突出:我们的“打开文件”限制(1024)对于一般应用程序来说是可以的,但如果我们运行许多处理大量打开文件的服务(例如相当数量的 Docker 容器),这个值必须更改,否则您将遇到错误,您的服务将有效地停止运行。

您可以使用ulimit -S <flag> <value>来更改当前 shell 的值:

$ ulimit -n
1024

$ # Set max open files to 2048
$ ulimit -S -n 2048

$ # Let's see the full list again
$ ulimit -a
<snip>
open files                      (-n) 2048
<snip>

但是,如果我们尝试将其设置为非常高的值会怎样呢?

$ ulimit -S -n 10240
bash: ulimit: open files: cannot modify limit: Invalid argument

在这里,我们现在遇到了系统强加的硬限制。如果我们想要修改超出这些值,这个限制是需要在系统级别进行更改的。我们可以使用ulimit -H -a来检查这些硬限制是什么:

$ ulimit -H -a | grep '^open files'
open files                      (-n) 4096

因此,如果我们想要增加我们的打开文件数超过4096,我们确实需要更改系统级设置。此外,即使4086的软限制对我们来说没问题,该设置仅适用于我们自己的 shell 及其子进程,因此不会影响系统上的任何其他服务或进程。

如果你真的想要,你实际上可以使用util-linux软件包中的prlimit更改已运行进程的ulimit设置,但不鼓励使用这种调整值的方法,因为这些设置在进程重新启动期间不会持续,因此对于这个目的而言是相当无用的。话虽如此,如果你想要找出你的ulimit设置是否已应用于已经运行的服务,这个 CLI 工具是非常宝贵的,所以在这些情况下不要害怕使用它。

要更改此设置,您需要根据您的发行版进行一系列选项的组合:

  • 创建一个安全限制配置文件。你可以通过向/etc/security/limits.d/90-ulimit-open-files-increase.conf添加几行来简单地做到这一点。以下示例将root的打开文件软限制设置为65536,然后设置所有其他账户(*不适用于root账户)的限制。你应该提前找出你的系统的适当值是多少。
root soft nofile 65536
root hard nofile 65536
* soft nofile 65536
* hard nofile 65536
  • pam_limits模块添加到可插拔认证模块PAM)。这将影响所有用户会话以前的ulimit更改设置,因为一些发行版没有包含它,否则你的更改可能不会持续。将以下内容添加到/etc/pam.d/common-session
session required pam_limits.so
  • 或者,在一些发行版上,你可以直接在systemd中的受影响服务定义中添加设置到覆盖文件中:
LimitNOFILE=65536

覆盖systemd服务是本节中一个相当冗长和分散注意力的话题,但它是一个非常常见的策略,用于调整在具有该 init 系统的集群部署上运行的第三方服务,因此这是一个非常有价值的技能。如果您想了解更多关于这个话题的信息,您可以在askubuntu.com/a/659268找到该过程的简化版本,如果您想要详细版本,可以在www.freedesktop.org/software/systemd/man/systemd.service.html找到上游文档。注意!在第一个例子中,我们使用了*通配符,它影响了机器上的所有账户。通常,出于安全原因,您希望将此设置隔离到仅受影响的服务账户,如果可能的话。我们还使用了root,因为在一些发行版中,根值是通过名称专门设置的,这会由于更高的特异性而覆盖*通配符设置。如果您想了解更多关于限制的信息,您可以在linux.die.net/man/5/limits.conf找到更多信息。

最大文件描述符

就像我们对会话和进程有最大打开文件限制一样,内核本身对整个系统的最大打开文件描述符也有限制。如果达到了这个限制,就无法打开其他文件,因此在可能同时打开大量文件的机器上需要进行调整。

这个值是内核参数的一部分,因此可以使用sysctl命令查看:

$ sysctl fs.file-max
fs.file-max = 757778

虽然在这台机器上这个值似乎是合理的,但我曾经看到一些旧版本的发行版具有令人惊讶的低值,如果您在系统上运行了大量容器,很容易出现错误。

我们在这里和本章后面讨论的大多数内核配置设置都可以使用sysctl -w <key>="<value>"进行临时更改。然而,由于这些值在每次重新启动时都会重置为默认值,它们通常对我们没有长期用途,因此这里不会涉及到它们,但请记住,如果您需要调试实时系统或应用临时的时间敏感的修复,您可以使用这些技术。

要更改此值以使其在重新启动后保持不变,我们需要将以下内容添加到/etc/sysctl.d文件夹中(即/etc/sysctl.d/10-file-descriptors-increase.conf):

fs.file-max = 1000000

更改后,重新启动,您现在应该能够在机器上打开多达 100 万个文件句柄!

套接字缓冲区

为了提高性能,通常增加套接字缓冲区的大小非常有利,因为它们不再只是单台机器的工作,而是作为您在常规机器连接上运行的所有 Docker 容器的工作。为此,有一些设置您可能应该设置,以确保套接字缓冲区不会努力跟上所有通过它们传递的流量。在撰写本书时,大多数这些默认缓冲区设置在机器启动时通常非常小(在我检查过的一些机器上为 200 KB),它们应该是动态缩放的,但您可以强制从一开始就使它们变得更大。

在 Ubuntu LTS 16.04 安装中,默认的缓冲区设置如下(尽管您的设置可能有所不同):

net.core.optmem_max = 20480
net.core.rmem_default = 212992
net.core.rmem_max = 212992
net.core.wmem_default = 212992
net.core.wmem_max = 212992
net.ipv4.tcp_rmem = 4096 87380 6291456
net.ipv4.tcp_wmem = 4096 16384 4194304

我们将通过将以下内容添加到/etc/sysctl.d/10-socket-buffers.conf中,将这些值调整为一些合理的默认值,但请确保在您的环境中使用合理的值:

net.core.optmem_max = 40960
net.core.rmem_default = 16777216
net.core.rmem_max = 16777216
net.core.wmem_default = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 87380 16777216

通过增加这些值,我们的缓冲区变得更大,应该能够处理相当多的流量,并且具有更好的吞吐量,这是我们在集群环境中想要的。

临时端口

如果您不熟悉临时端口,它们是所有出站连接分配的端口号,如果未在连接上明确指定起始端口,那就是绝大多数端口。例如,如果您使用几乎每个客户端库进行任何类型的出站 HTTP 请求,您很可能会发现其中一个临时端口被分配为连接的返回通信端口。

要查看您的机器上一些示例临时端口的使用情况,您可以使用netstat

$ netstat -an | grep ESTABLISHED
tcp        0      0 192.168.56.101:46496     <redacted>:443      ESTABLISHED
tcp        0      0 192.168.56.101:45512     <redacted>:443      ESTABLISHED
tcp        0      0 192.168.56.101:42014     <redacted>:443      ESTABLISHED
<snip>
tcp        0      0 192.168.56.101:45984     <redacted>:443      ESTABLISHED
tcp        0      0 192.168.56.101:56528     <redacted>:443      ESTABLISHED

当您开发具有大量出站连接的多个服务的系统时(在使用 Docker 服务时几乎是强制性的),您可能会注意到您被允许使用的端口数量有限,并且可能会发现这些端口可能与一些内部 Docker 服务使用的范围重叠,导致间歇性且经常令人讨厌的连接问题。为了解决这些问题,需要对临时端口范围进行更改。

由于这些也是内核设置,我们可以使用sysctl来查看我们当前的范围,就像我们在之前的几个示例中所做的那样:

$ sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768    60999

您可以看到我们的范围在端口分配的上半部分,但在该范围内可能开始监听的任何服务都可能遇到麻烦。我们可能需要的端口数量也可能超过 28,000 个。

您可能会好奇如何获取或设置此参数的ipv6设置,但幸运的是(至少目前是这样),这个相同的设置键用于ipv4ipv6临时端口范围。在某个时候,这个设置名称可能会改变,但我认为至少还有几年的时间。

要更改此值,我们可以使用sysctl -w进行临时更改,或者使用sysctl.d进行永久更改:

$ # First the temporary change to get us up to 40000
$ # ports. For our services, we separately have to
$ # ensure none listen on any ports above 24999.
$ sudo sysctl -w net.ipv4.ip_local_port_range="25000 65000"
net.ipv4.ip_local_port_range = 25000 65000

$ # Sanity check
$ sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 25000    65000

$ # Now for the permanent change (requires restart)
$ echo "net.ipv4.ip_local_port_range = 25000 65000" | sudo tee /etc/sysctl.d/10-ephemeral-ports.conf

通过这个改变,我们有效地增加了我们可以支持的出站连接数量超过 30%,但我们也可以使用相同的设置来确保临时端口不会与其他运行中的服务发生冲突。

Netfilter 调整

很遗憾,到目前为止我们看到的设置并不是唯一需要调整的东西,随着对服务器的网络连接增加,您可能还会在dmesg和/或内核日志中看到nf_conntrack: table full错误。对于不熟悉netfilter的人来说,它是一个跟踪所有网络地址转换NAT)会话的内核模块,它将任何新连接添加到哈希表中,并在关闭连接并达到预定义的超时后清除它们,因此随着对单台机器的连接数量增加,您很可能会发现大多数相关设置都是默认的保守设置,需要进行调整(尽管您的发行版可能有所不同-请确保验证您的设置!):

$ sysctl -a | grep nf_conntrack
net.netfilter.nf_conntrack_buckets = 65536
<snip>
net.netfilter.nf_conntrack_generic_timeout = 600
<snip>
net.netfilter.nf_conntrack_max = 262144
<snip>
net.netfilter.nf_conntrack_tcp_timeout_close = 10
net.netfilter.nf_conntrack_tcp_timeout_close_wait = 60
net.netfilter.nf_conntrack_tcp_timeout_established = 432000
net.netfilter.nf_conntrack_tcp_timeout_fin_wait = 120
net.netfilter.nf_conntrack_tcp_timeout_last_ack = 30
net.netfilter.nf_conntrack_tcp_timeout_max_retrans = 300
net.netfilter.nf_conntrack_tcp_timeout_syn_recv = 60
net.netfilter.nf_conntrack_tcp_timeout_syn_sent = 120
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 120
net.netfilter.nf_conntrack_tcp_timeout_unacknowledged = 300
<snip>

其中有很多可以改变,但需要调整的错误通常是以下几种:

  • net.netfilter.nf_conntrack_buckets:控制连接的哈希表的大小。增加这个是明智的,尽管它可以用更激进的超时来替代。请注意,这不能使用常规的sysctl.d设置,而是需要使用内核模块参数进行设置。

  • net.netfilter.nf_conntrack_max:要保存的条目数。默认情况下,这是前一个条目值的四倍。

  • net.netfilter.nf_conntrack_tcp_timeout_established: 这将保持开放连接的映射长达五天之久(!)。通常情况下,必须减少这个时间以避免连接跟踪表溢出,但不要忘记它需要大于 TCP 的keepalive超时时间,否则会出现意外的连接中断。

要应用最后两个设置,您需要将以下内容添加到/etc/sysctl.d/10-conntrack.conf,并根据自己的基础架构配置调整值:

net.netfilter.nf_conntrack_tcp_timeout_established = 43200
net.netfilter.nf_conntrack_max = 524288

netfilter 是一个非常复杂的话题,在一个小节中涵盖不全,因此在更改这些数字之前,强烈建议阅读其影响和配置设置。要了解每个设置的情况,您可以访问www.kernel.org/doc/Documentation/networking/nf_conntrack-sysctl.txt并阅读相关内容。

对于桶计数,您需要直接更改nf_conntrack hashsize内核模块参数:

echo '131072' | sudo tee /sys/module/nf_conntrack/parameters/hashsize

最后,为了确保在加载 netfilter 模块时遵循正确的顺序,以便这些值正确地持久化,您可能还需要将以下内容添加到/etc/modules的末尾:

nf_conntrack_ipv4
nf_conntrack_ipv6

如果一切都正确完成,下次重启应该会设置所有我们讨论过的 netfilter 设置。

多服务容器

多服务容器是一个特别棘手的话题,因为 Docker 的整个概念和推荐的用法是您只在容器中运行单进程服务。因此,有相当多的隐含压力不要涉及这个话题,因为开发人员很容易滥用并误用它,而不理解为什么强烈不建议这种做法。

然而,话虽如此,有时您需要在一个紧密的逻辑分组中运行多个进程,而多容器解决方案可能没有意义,或者会过于笨拙,这就是为什么这个话题仍然很重要的原因。话虽如此,我再次强调,您应该将这种类型的服务共存作为最后的手段。

在我们写下一行代码之前,我们必须讨论一个架构问题,即在同一个容器内运行多个进程的问题,这被称为PID 1问题。这个问题的关键在于 Docker 容器在一个隔离的环境中运行,它们无法从主机的init进程中获得帮助来清理孤儿子进程。考虑一个例子进程父进程,它是一个基本的可执行文件,启动另一个进程称为子进程,但在某个时刻,如果相关的父进程退出或被杀死,你将会留下在容器中游荡的僵尸子进程,因为父进程已经消失,容器沙盒中没有其他孤儿收割进程在运行。如果容器退出,那么僵尸进程将被清理,因为它们都被包裹在一个命名空间中,但对于长时间运行的任务来说,这可能会对在单个镜像内运行多个进程造成严重问题。

这里的术语可能会令人困惑,但简单来说,每个进程在退出后都应该被从进程表中移除(也称为收割),要么是由父进程,要么是由层次结构中的其他指定进程(通常是init)来接管它以完成最终的清理。在这种情况下,没有运行父进程的进程被称为孤儿进程。

有些工具有能力收割这些僵尸进程(比如 Bash 和其他几个 shell),但即使它们也不是我们容器的良好 init 进程,因为它们不会将信号(如 SIGKILL、SIGINT 等)传递给子进程,因此停止容器或在终端中按下 Ctrl + C 等操作是无效的,不会终止容器。如果你真的想在容器内运行多个进程,你的启动进程必须进行孤儿收割和信号传递给子进程。由于我们不想从容器中使用完整的 init 系统,比如systemd,这里有几种替代方案,但在最近的 Docker 版本中,我们现在有--init标志,它可以使用真正的 init 运行器进程来运行我们的容器。

让我们看看这个过程,并尝试退出一个以bash为起始进程的程序:

$ # Let's try to run 'sleep' and exit with <Ctrl>-C
$ docker run -it \
 ubuntu \
 bash -c 'sleep 5000'
^C^C^C^C^C^C^C^C^C^C
<Ctrl-C not working>

$ # On second terminal
$ docker ps
CONTAINER ID IMAGE  COMMAND                CREATED            STATUS 
c7b69001271d ubuntu "bash -c 'sleep 5000'" About a minute ago Up About a minute

$ # Can we stop it?
$ docker stop c7b69001271d
<nothing happening>
^C

$ # Last resort - kill the container!
$ docker kill c7b69001271d
c7b69001271d

这次,我们将使用--init标志运行我们的容器:

$ docker run -it \
 --init \
 ubuntu \
 bash -c 'sleep 5000'
^C

$ # <Ctrl>-C worked just fine!

正如你所看到的,--init能够接收我们的信号并将其传递给所有正在监听的子进程,并且它作为一个孤儿进程收割者运行良好,尽管后者在基本容器中真的很难展示出来。有了这个标志及其功能,你现在应该能够使用诸如 Bash 之类的 shell 运行多个进程,或者升级到一个完整的进程管理工具,比如supervisordsupervisord.org/),而不会出现任何问题。

零停机部署

在每次集群部署时,您都会在某个时候需要考虑代码重新部署,同时最大程度地减少对用户的影响。对于小规模部署,有可能您会有一个维护期,在此期间您关闭所有内容,重建新的镜像,并重新启动服务,但这种部署方式实际上并不适合中等和大型集群的管理,因为您希望最小化维护集群所需的任何直接工作。事实上,即使对于小集群,以无缝的方式处理代码和配置升级对于提高生产率来说也是非常宝贵的。

滚动服务重启

如果新的服务代码没有改变它与其他服务交互的基本方式(输入和输出),通常唯一需要的就是重建(或替换)容器镜像,然后将其放入 Docker 注册表,然后以有序和交错的方式重新启动服务。通过交错重启,始终至少有一个任务可以处理服务请求,并且从外部观点来看,这种转换应该是完全无缝的。大多数编排工具会在您更改或更新服务的任何设置时自动为您执行此操作,但由于它们非常特定于实现,我们将专注于 Docker Swarm 作为我们的示例:

$ # Create a new swarm
$ docker swarm init
Swarm initialized: current node (j4p08hdfou1tyrdqj3eclnfb6) is now a manager.
<snip>

$ # Create a service based on mainline NGINX and update-delay
$ # of 15 seconds
$ docker service create \
 --detach=true \
 --replicas 4 \
 --name nginx_update \
 --update-delay 15s \
 nginx:mainline
s9f44kn9a4g6sf3ve449fychv

$ # Let's see what we have
$ docker service ps nginx_update
ID            NAME            IMAGE           DESIRED STATE  CURRENT STATE
rbvv37cg85ms  nginx_update.1  nginx:mainline  Running        Running 56 seconds ago
y4l76ld41olf  nginx_update.2  nginx:mainline  Running        Running 56 seconds ago
gza13g9ar7jx  nginx_update.3  nginx:mainline  Running        Running 56 seconds ago
z7dhy6zu4jt5  nginx_update.4  nginx:mainline  Running        Running 56 seconds ago

$ # Update our service to use the stable NGINX branch
$ docker service update \
 --detach=true \
 --image nginx:stable \
 nginx_update
nginx_update

$ # After a minute, we can now see the new service status
$ docker service ps nginx_update
ID            NAME               IMAGE           DESIRED STATE  CURRENT STATE
qa7evkjvdml5  nginx_update.1     nginx:stable    Running        Running about a minute ago
rbvv37cg85ms  \_ nginx_update.1  nginx:mainline  Shutdown       Shutdown about a minute ago
qbg0hsd4nxyz  nginx_update.2     nginx:stable    Running        Running about a minute ago
y4l76ld41olf  \_ nginx_update.2  nginx:mainline  Shutdown       Shutdown about a minute ago
nj5gcf541fgj  nginx_update.3     nginx:stable    Running        Running 30 seconds ago
gza13g9ar7jx  \_ nginx_update.3  nginx:mainline  Shutdown       Shutdown 31 seconds ago
433461xm4roq  nginx_update.4     nginx:stable    Running        Running 47 seconds ago
z7dhy6zu4jt5  \_ nginx_update.4  nginx:mainline  Shutdown       Shutdown 48 seconds ago

$ # All our services now are using the new image
$ # and were started staggered!

$ # Clean up
$ docker service rm nginx_update 
nginx_update 
$ docker swarm leave --force 
Node left the swarm.

正如你所看到的,应该很容易做到在没有任何停机时间的情况下进行自己的代码更改!

如果你想要能够一次重启多个任务而不是一个,Docker Swarm 也有一个--update-parallelism <count>标志,可以设置在一个服务上。使用这个标志时,仍然会观察--update-delay,但是不是单个任务被重启,而是以<count>大小的批次进行。

蓝绿部署

滚动重启很好,但有时需要应用的更改是在主机上,需要对集群中的每个 Docker Engine 节点进行更改,例如,如果需要升级到更新的编排版本或升级操作系统版本。在这些情况下,通常接受的做法是使用一种称为蓝绿部署的方法来完成,而不需要大量的支持团队。它通过在当前运行的集群旁边部署一个次要集群开始,可能与相同的数据存储后端相关联,然后在最合适的时间将入口路由切换到新集群。一旦原始集群上的所有处理都完成后,它将被删除,新集群将成为主要处理组。如果操作正确,用户的影响应该是不可察觉的,并且整个基础设施在此过程中已经发生了变化。

该过程始于次要集群的创建。在那时,除了测试新集群是否按预期运行外,没有实质性的变化:

(图片)

次要集群运行后,路由器交换端点,处理继续在新集群上进行:

(图片)

交换完成后,所有处理完成后,原始集群被废弃(或作为紧急备份留下):

(图片)

但是在完整集群上应用这种部署模式并不是它的唯一用途——在某些情况下,可以在同一集群内的服务级别上使用相同的模式来替换更高版本的组件,但是有一个更好的系统可以做到这一点,我们接下来会介绍。

蓝绿部署

在代码部署中,情况变得有些棘手,因为在输入或输出端或数据库架构上更改 API 可能会对具有交错代码版本的集群造成严重破坏。为了解决这个问题,有一种修改过的蓝绿部署模式称为蓝绿松石绿部署,其中尝试使代码与所有运行版本兼容,直到部署新代码后,然后通过删除兼容代码再次更新服务。

这里的过程非常简单:

  1. 使用 API 版本x的服务以滚动方式替换为支持 API 版本x和 API 版本(x+1)的新版本服务。这从用户的角度提供了零停机时间,但创建了一个具有更新的 API 支持的新服务。

  2. 在一切更新完成后,具有旧 API 版本x的服务将从代码库中删除。

  3. 对服务进行另一次滚动重启,以删除废弃 API 的痕迹,只留下 API 版本(x+1)的支持。

当您使用的服务需要持续可用时,这种方法非常有价值,在许多情况下,您可以轻松地将 API 版本替换为消息队列格式,如果您的集群基于队列。过渡是平稳的,但与一次硬交换相比,需要两次修改服务,但这是一个不错的权衡。当使用的服务涉及可能需要迁移的数据库时,这种方法也非常有价值,因此当其他方法不够好时,您应该使用这种方法。

摘要

在本章中,我们涵盖了各种工具和技术,这些工具和技术将在您将基础架构规模扩大到简单原型之外时需要。到目前为止,我们应该已经学会了如何限制服务访问主机资源,轻松处理最常见的问题,运行多个服务在一个容器中,并处理零停机部署和配置更改。

在下一章中,我们将花时间部署我们自己的平台即服务(PAAS)的迷你版本,使用我们迄今为止学到的许多知识。

第八章:构建我们自己的平台

在之前的章节中,我们花了很多时间在基础设施的各个部分上建立了一些孤立的小部分,但在本章中,我们将尝试将尽可能多的概念结合在一起,构建一个最小可行的平台即服务(PaaS)。在接下来的章节中,我们将涵盖以下主题:

  • 配置管理(CM)工具

  • 亚马逊网络服务(AWS)部署

  • 持续集成/持续交付(CI/CD)

在构建我们的服务核心时,我们将看到将一个小服务部署到真正的云中需要做些什么。

需要注意的一点是,本章仅作为云中实际部署的快速入门和基本示例,因为创建一个带有所有功能的 PaaS 基础设施通常是非常复杂的,需要大型团队花费数月甚至数年的时间来解决所有问题。更为复杂的是,解决方案通常非常具体地针对运行在其上的服务和编排工具的选择,因此,请将本章中看到的内容视为您自己部署中可使用的当前生态系统的样本,但其他工具可能更适合您的特定需求。

配置管理

对于每个依赖大量类似配置的机器的系统(无论是物理还是虚拟的),总会出现对简单易用的重建工具的需求,以帮助自动化大部分过去需要手动完成的任务。在 PaaS 集群的情况下,理想情况下,所有基础设施的部分都能够在最小用户干预的情况下被重建为所需的确切状态。对于裸金属 PaaS 服务器节点来说,这是至关重要的,因为任何需要手动操作的操作都会随着节点数量的增加而增加,因此优化这个过程对于任何生产就绪的集群基础设施来说都是至关重要的。

现在你可能会问自己,“为什么我们要关心 CM 工具?”事实上,如果您在容器基础设施周围没有适当的 CM,您将确保自己在各种问题上会在非工作时间接到紧急电话,例如:节点永远无法加入集群,配置不匹配,未应用的更改,版本不兼容,以及许多其他问题,这些问题会让您抓狂。因此,为了防止这一系列情况发生在您身上,我们将深入研究这个支持软件生态系统。

解释清楚并且了解清楚之后,我们可以看到一些可供选择的 CM 工具:

由于 Puppet 和 Chef 都需要基于代理的部署,而 SaltStack 在 Ansible 的流行度方面落后很多,因此在我们的工作中,我们将 Cover Ansible 作为首选的 CM 工具,但是您的需求可能会有所不同。根据自己的需求选择最合适的工具。

作为一个相关的侧面说明,从我与 DevOps 在线社区的互动中,似乎在撰写本材料时,Ansible 正在成为 CM 工具的事实标准,但它并非没有缺陷。虽然我很愿意推荐它的使用,因为它有许多出色的功能,但请预期更大模块的复杂边缘情况可能不太可靠,并且请记住,您可能会发现的大多数错误可能已经通过 GitHub 上的未合并的拉取请求进行了修复,您可能需要根据需要在本地应用。警告!选择配置管理工具不应该轻率对待,您应该在承诺使用某个工具之前权衡利弊,因为一旦您管理了一些机器,这个工具是最难更换的!虽然许多 IT 和 DevOps 专业人员几乎将这个选择视为一种生活方式(类似于vimemacs用户之间的极化),但请确保您仔细和理性地评估您的选择,因为在未来更换到不同的工具的成本很高。我个人从未听说过一家公司在使用某种工具一段时间后更换配置管理工具,尽管我相信有一些公司这样做了。

Ansible

如果您以前没有使用过 Ansible,它具有以下好处:

如果这个列表听起来不够好,整个 Ansible 架构是可扩展的,因此如果没有满足您要求的可用模块,它们相对容易编写和集成,因此 Ansible 能够适应您可能拥有或想要构建的几乎任何基础设施。在底层,Ansible 使用 Python 和 SSH 直接在目标主机上运行命令,但使用了一个更高级的领域特定语言DSL),使得对比直接通过类似 Bash 的方式编写 SSH 命令,编写服务器配置对某人来说非常容易和快速。

当前的 Ubuntu LTS 版本(16.04)带有 Ansible 2.0.0.2,这对大多数情况来说应该是足够的,但通常建议使用更接近上游版本的版本,既可以修复错误,也可以添加新的模块。如果选择后者,请确保固定版本以确保一致的工作部署。

安装

要在大多数基于 Debian 的发行版上安装 Ansible,通常过程非常简单:

$ # Make sure we have an accurate view of repositories
$ sudo apt-get update 
<snip>
Fetched 3,971 kB in 22s (176 kB/s) 
Reading package lists... Done

$ # Install the package
$ sudo apt-get install ansible 
Reading package lists... Done
Building dependency tree 
Reading state information... Done
The following NEW packages will be installed:
 ansible
0 upgraded, 1 newly installed, 0 to remove and 30 not upgraded.
<snip>
Setting up ansible (2.0.0.2-2ubuntu1) ...

$ # Sanity check
$ ansible --version 
ansible 2.0.0.2
 config file = /home/user/checkout/eos-administration/ansible/ansible.cfg
 configured module search path = /usr/share/ansible

基础知识

项目的标准布局通常分为定义功能切片的角色,其余的配置基本上只是支持这些角色。Ansible 项目的基本文件结构看起来像这样(尽管通常需要更复杂的设置):

.
├── group_vars
│   └── all
├── hosts
├── named-role-1-server.yml
└── roles
 ├── named-role-1
 │   ├── tasks
 │   │   └── main.yml
 │   ├── files
 │   │   └── ...
 │   ├── templates
 │   │   └── ...
 │   └── vars
 │       └── main.yml
 ...

让我们分解一下这个文件系统树的基本结构,并看看每个部分在更大的图景中是如何使用的:

  • group_vars/all:这个文件用于定义所有 playbooks 中使用的变量。这些变量可以在 playbooks 和模板中使用变量扩展("{{ variable_name }}")。

  • hosts/:这个文件或目录列出了您想要管理的主机和组,以及任何特定的连接细节,比如协议、用户名、SSH 密钥等。在文档中,这个文件通常被称为清单文件。

  • roles/:这里列出了可以以分层和分层方式应用于目标机器的角色定义列表。通常,它进一步细分为tasks/files/vars/和每个角色内的其他布局敏感结构:

  • <role_name>/tasks/main.yml:一个 YAML 文件,列出了作为角色一部分执行的主要步骤。

  • <role_name>/files/...:在这里,您将添加静态文件,这些文件将被复制到目标机器上,不需要任何预处理。

  • <role_name>/templates/...:在这个目录中,您将为与角色相关的任务添加模板文件。这些通常包含将带有变量替换的模板复制到目标机器上。

  • <role_name>/vars/main.yml:就像父目录暗示的那样,这个 YAML 文件保存了特定于角色的变量定义。

  • playbooks/:在这个目录中,您将添加所有顶层的辅助 playbooks,这些 playbooks 在角色定义中无法很好地适应。

用法

现在我们已经了解了 Ansible 的外观和操作方式,是时候用它做一些实际的事情了。我们现在要做的是创建一个 Ansible 部署配置,应用我们在上一章中介绍的一些系统调整,并在运行 playbook 后让 Docker 在机器上为我们准备好。

这个例子相对简单,但它应该很好地展示了一个体面的配置管理工具的易用性和强大性。Ansible 也是一个庞大的主题,像这样的一个小节无法覆盖我想要的那么详细,但文档相对不错,你可以在docs.ansible.com/ansible/latest/index.html找到它。如果你想跳过手工输入,可以在github.com/sgnn7/deploying_with_docker/tree/master/chapter_8/ansible_deployment找到这个例子(和其他例子);然而,这可能是一个很好的练习,做一次以熟悉 Ansible 的 YAML 文件结构。

首先,我们需要为保存文件创建我们的文件结构。我们将称我们的主要角色为swarm_node,由于我们整个机器只是一个 swarm 节点,我们将把我们的顶层部署 playbook 命名为相同的名称:

$ # First we create our deployment source folder and move there
$ mkdir ~/ansible_deployment
$ cd ~/ansible_deployment/

$ # Next we create the directories we will need
$ mkdir -p roles/swarm_node/files roles/swarm_node/tasks

$ # Make a few placeholder files
$ touch roles/swarm_node/tasks/main.yml \
        swarm_node.yml \
        hosts

$ # Let's see what we have so far
$ tree
.
├── hosts
├── roles
│   └── swarm_node
│       ├── files
│       └── tasks
│           └── main.yml
└── swarm_node.yml
4 directories, 3 files

现在让我们将以下内容添加到顶层的swarm_node.yml。这将是 Ansible 的主入口点,它基本上只定义了目标主机和我们想要在它们上运行的角色:

---
- name: Swarm node setup
 hosts: all

 become: True

 roles:
 - swarm_node

YAML 文件是以空格结构化的,所以在编辑这个文件时要确保不要省略任何空格。一般来说,所有的嵌套级别都比父级多两个空格,键/值用冒号定义,列表用-(减号)前缀列出。有关 YAML 结构的更多信息,请访问en.wikipedia.org/wiki/YAML#Syntax

我们在这里做的大部分都是显而易见的:

  • 主机:所有:在清单文件中定义的所有服务器上运行此命令。通常,这只是一个 DNS 名称,但由于我们只有一个单一的机器目标,all应该没问题。

  • become: True: 由于我们使用 SSH 在目标上运行命令,而 SSH 用户通常不是 root,我们需要告诉 Ansible 需要使用sudo提升权限来运行命令。如果用户需要密码来使用sudo,可以在调用 playbook 时使用ansible-playbook -K标志指定密码,但在本章后面我们将使用不需要密码的 AWS 实例。

  • roles: swarm_mode: 这是我们要应用于目标的角色列表,目前只有一个叫做swarm_node的角色。这个名称必须roles/中的文件夹名称匹配。

接下来要定义的是我们在上一章中涵盖的系统调整配置文件,用于增加文件描述符最大值、ulimits 等。将以下文件及其相应内容添加到roles/swarm_node/files/文件夹中:

  • conntrack.conf:
net.netfilter.nf_conntrack_tcp_timeout_established = 43200
net.netfilter.nf_conntrack_max = 524288
  • file-descriptor-increase.conf:
fs.file-max = 1000000
  • socket-buffers.conf:
net.core.optmem_max = 40960
net.core.rmem_default = 16777216
net.core.rmem_max = 16777216
net.core.wmem_default = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 87380 16777216
  • ulimit-open-files-increase.conf:
root soft nofile 65536
root hard nofile 65536
* soft nofile 65536
* hard nofile 65536

添加这些文件后,我们的目录结构应该看起来更像这样:

.
├── hosts
├── roles
│   └── swarm_node
│       ├── files
│       │   ├── conntrack.conf
│       │   ├── file-descriptor-increase.conf
│       │   ├── socket-buffers.conf
│       │   └── ulimit-open-files-increase.conf
│       └── tasks
│           └── main.yml
└── swarm_node.yml

大部分文件已经就位,现在我们终于可以转向主配置文件--roles/swarm_mode/tasks/main.yml。在其中,我们将使用 Ansible 的模块和 DSL 逐步列出我们的配置步骤:

  • apt-get dist-upgrade 更新镜像以提高安全性

  • 对机器配置文件进行各种改进,以便更好地作为 Docker 主机运行

  • 安装 Docker

为了简化理解以下的 Ansible 配置代码,也可以记住这个结构,因为它是我们将使用的每个离散步骤的基础,并且在看到几次后很容易理解:

- name: A descriptive step name that shows in output
 module_name:
 module_arg1: arg_value
 module_arg2: arg2_value
 module_array_arg3:
 - arg3_item1
 ...
 ...

您可以在主 Ansible 网站上找到我们在 playbook 中使用的所有模块文档(docs.ansible.com/ansible/latest/list_of_all_modules.html)。由于信息量巨大,我们将避免在此处深入研究模块文档,因为这通常会分散本节的目的。

您还可以在这里找到我们使用的特定模块的文档:

让我们看看主安装 playbook(roles/swarm_mode/tasks/main.yml)应该是什么样子的:

---
- name: Dist-upgrading the image
 apt:
 upgrade: dist
 force: yes
 update_cache: yes
 cache_valid_time: 3600

- name: Fixing ulimit through limits.d
 copy:
 src: "{{ item }}.conf"
 dest: /etc/security/limits.d/90-{{ item }}.conf
 with_items:
 - ulimit-open-files-increase

- name: Fixing ulimits through pam_limits
 lineinfile:
 dest: /etc/pam.d/common-session
 state: present
 line: "session required pam_limits.so"

- name: Ensuring server-like kernel settings are set
 copy:
 src: "{{ item }}.conf"
 dest: /etc/sysctl.d/10-{{ item }}.conf
 with_items:
 - socket-buffers
 - file-descriptor-increase
 - conntrack

# Bug: https://github.com/systemd/systemd/issues/1113
- name: Working around netfilter loading order
 lineinfile:
 dest: /etc/modules
 state: present
 line: "{{ item }}"
 with_items:
 - nf_conntrack_ipv4
 - nf_conntrack_ipv6

- name: Increasing max connection buckets
 command: echo '131072' > /sys/module/nf_conntrack/parameters/hashsize

# Install Docker
- name: Fetching Docker's GPG key
 apt_key:
 keyserver: hkp://pool.sks-keyservers.net
 id: 58118E89F3A912897C070ADBF76221572C52609D

- name: Adding Docker apt repository
 apt_repository:
 repo: 'deb https://apt.dockerproject.org/repo {{ ansible_distribution | lower }}-{{ ansible_distribution_release | lower }} main'
 state: present

- name: Installing Docker
 apt:
 name: docker-engine
 state: installed
 update_cache: yes
 cache_valid_time: 3600

警告!这个配置对于放在互联网上运行没有进行任何加固,所以在进行真正的部署之前,请小心并在这个 playbook 中添加任何您需要的安全步骤和工具。至少我建议安装fail2ban软件包,但您可能有其他策略(例如 seccomp、grsecurity、AppArmor 等)。

在这个文件中,我们按顺序一步一步地配置了机器,从基本配置到完全能够运行 Docker 容器的系统,使用了一些核心的 Ansible 模块和我们之前创建的配置文件。可能不太明显的一点是我们使用了{{ ansible_distribution | lower }}类型的变量,但在这些变量中,我们使用了有关我们正在运行的系统的 Ansible 事实(docs.ansible.com/ansible/latest/playbooks_variables.html),并通过 Ninja2 的lower()过滤器传递它们,以确保变量是小写的。通过对存储库端点执行此操作,我们可以在几乎任何基于 deb 的服务器目标上使用相同的配置而不会遇到太多麻烦,因为变量将被替换为适当的值。

在这一点上,我们需要做的唯一一件事就是将我们的服务器 IP/DNS 添加到hosts文件中,并使用ansible-playbook <options> swarm_node.yml运行 playbook。但由于我们想在亚马逊基础设施上运行这个,我们将在这里停下来,看看我们如何可以采取这些配置步骤,并从中创建一个亚马逊机器映像AMI),在这个映像上我们可以启动任意数量的弹性计算云EC2)实例,这些实例是相同的,并且已经完全配置好了。

亚马逊网络服务设置

要继续进行我们的 Amazon Machine Image (AMI)构建部分,我们必须先拥有一个可用的 AWS 账户和一个关联的 API 密钥,然后才能继续。为避免歧义,请注意几乎所有 AWS 服务都需要付费使用,您使用 API 可能会为您产生费用,即使是您可能没有预期的事情(如带宽使用、AMI 快照存储等),所以请谨慎使用。

AWS 是一个非常复杂的机器,比 Ansible 复杂得多,覆盖关于它的所有内容是不可能在本书的范围内完成的。但我们会在这里尽量为您提供足够相关的指导,让您有一个起点。如果您决定想了解更多关于 AWS 的信息,他们的文档通常非常好,您可以在aws.amazon.com/documentation/找到。

创建一个账户

虽然这个过程非常简单,但它已经在很多重要的方面发生了一些变化,因此在这里详细介绍整个过程并无法更新,这对您来说是一种伤害,所以为了创建账户,我将引导您到具有最新信息的链接,该链接是aws.amazon.com/premiumsupport/knowledge-center/create-and-activate-aws-account/。一般来说,这个过程的开始是在aws.amazon.com/,您可以通过单击屏幕右上角的黄色注册或创建 AWS 账户按钮并按照说明进行操作:

获取 API 密钥

创建了 AWS 账户后,我们现在需要获取 API 密钥,以便通过我们想要使用的各种工具访问和使用资源:

  1. 通过转到https://<account_id or alias>.signin.aws.amazon.com/console登录您的控制台。请注意,您可能需要最初以根账户身份登录(如下截图所示,在登录按钮下方有一个小蓝色链接),如果您注册账户时没有创建用户:

  1. 转到 IAM 页面console.aws.amazon.com/iam/,并单击屏幕左侧的用户链接。

  2. 单击“添加用户”以开始用户创建过程。

注意!确保选中“程序化访问”复选框,否则您的 AWS API 密钥将无法用于我们的示例。

  1. 对于权限,我们将为该用户提供完整的管理员访问权限。对于生产服务,您将希望将其限制为所需的访问级别:

  1. 按照向导的其余部分,并记录密钥 ID 和密钥秘钥,因为这些将是您的 AWS API 凭据:

使用 API 密钥

为了以最简单的方式使用 API 密钥,您可以在 shell 中导出变量,这些变量将被工具接收;但是,您需要在使用 AWS API 的每个终端上执行此操作:

$ export AWS_ACCESS_KEY_ID="AKIABCDEFABCDEF"
$ export AWS_SECRET_ACCESS_KEY="123456789ABCDEF123456789ABCDEF"
$ export AWS_REGION="us-west-1"

或者,如果您已安装awscli工具(sudo apt-get install awscli),您可以直接运行aws configure

$ aws configure
AWS Access Key ID [None]: AKIABCDEFABCEF
AWS Secret Access Key [None]: 123456789ABCDEF123456789ABCDEF
Default region name [None]: us-west-1
Default output format [None]: json

还有许多其他设置凭据的方法,例如通过配置文件,但这确实取决于您的预期使用情况。有关这些选项的更多信息,您可以参考官方文档docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html

有了可用并配置为 CLI 使用的密钥,我们现在可以继续使用 Packer 构建自定义 AMI 镜像。

HashiCorp Packer

正如我们之前所暗示的,如果我们必须在每次将新机器添加到集群或云基础架构中时运行 CM 脚本,那么我们的 CM 脚本实际上并不那么理想。虽然我们可以这样做,但我们真的不应该这样做,因为在理想的情况下,集群节点应该是一个灵活的群组,可以根据使用情况生成和销毁实例,最大程度地减少用户干预,因此要求手动设置每台新机器甚至在最小的集群规模下都是不可行的。通过 AMI 镜像创建,我们可以在制作镜像时预先制作一个带有 Ansible 的模板基本系统镜像。通过这样做,我们可以使用相同的镜像启动任何新机器,并且我们与运行中的系统的交互将被保持在最低限度,因为理想情况下一切都应该已经配置好。

为了创建这些机器映像,HashiCorp Packer (www.packer.io/) 允许我们通过应用我们选择的 CM 工具(Ansible)的配置运行,并为任何大型云提供商输出一个可供使用的映像。通过这样做,您可以将集群节点(或任何其他服务器配置)的期望状态永久地记录在映像中,对于集群的任何节点添加需求,您只需要基于相同的 Packer 映像生成更多的 VM 实例。

安装

由于 Packer 是用 Go 编程语言编写的,要安装 Packer,您只需要从他们的网站www.packer.io/downloads.html下载二进制文件。通常可以通过以下方式快速安装:

$ # Get the archive
$ wget -q --show-progress https://releases.hashicorp.com/packer/1.1.1/packer_<release>.zip
packer_<release>.zip 100%[==============================================>] 15.80M 316KB/s in 40s

$ # Extract our binary
$ unzip packer_<release>.zip
Archive: packer_<release>.zip
 inflating: packer

$ # Place the binary somewhere in your path
$ sudo mv packer /usr/local/bin/

$ packer --version
1.1.1

注意!Packer 二进制文件仅为其运行程序提供 TLS 身份验证,而没有任何形式的签名检查,因此,与 Docker 使用的 GPG 签名的apt存储库相比,程序由 HashiCorp 自己发布的保证要低得多;因此,在以这种方式获取它或从源代码构建时,请格外小心(github.com/hashicorp/packer)。

用法

在大多数情况下,使用 Packer 实际上相当容易,因为您只需要 Ansible 设置代码和一个相对较小的packer.json文件。将此内容添加到我们在早期部分的 Ansible 部署配置中的packer.json中:

{
  "builders": [
    {
      "ami_description": "Cluster Node Image",
      "ami_name": "cluster-node",
      "associate_public_ip_address": true,
      "force_delete_snapshot": true,
      "force_deregister": true,
      "instance_type": "m3.medium",
      "region": "us-west-1",
      "source_ami": "ami-1c1d217c",
      "ssh_username": "ubuntu",
      "type": "amazon-ebs"
    }
  ],
  "provisioners": [
    {
      "inline": "sudo apt-get update && sudo apt-get install -y ansible",
      "type": "shell"
    },
    {
      "playbook_dir": ".",
      "playbook_file": "swarm_node.yml",
      "type": "ansible-local"
    }
  ]
}

如果不明显,我们在此配置文件中有provisionersbuilders部分,它们通常对应于 Packer 的输入和输出。在我们之前的示例中,我们首先通过shell provisioner 安装 Ansible,因为下一步需要它,然后使用ansible-local provisioner 在基本 AMI 上运行我们当前目录中的main.yml playbook。应用所有更改后,我们将结果保存为新的弹性块存储EBS)优化的 AMI 映像。

AWS 弹性块存储EBS)是一项为 EC2 实例提供块设备存储的服务(这些实例基本上只是虚拟机)。对于机器来说,这些看起来像是常规的硬盘,可以格式化为任何你想要的文件系统,并用于在亚马逊云中以永久方式持久化数据。它们具有可配置的大小和性能级别;然而,正如你可能期望的那样,随着这两个设置的增加,价格也会上涨。唯一需要记住的另一件事是,虽然你可以像移动物理磁盘一样在 EC2 实例之间移动驱动器,但你不能跨可用性区域移动 EBS 卷。一个简单的解决方法是复制数据。"AMI 镜像"短语扩展为"Amazon Machine Image image",这是一个非常古怪的表达方式,但就像姐妹短语"PIN number"一样,在本节中使用这种方式会更流畅。如果你对英语语言的这种特殊性感到好奇,你可以查阅 RAS 综合症的维基页面en.wikipedia.org/wiki/RAS_syndrome

对于构建器部分,更详细地解释一些参数将会很有帮助,因为它们可能并不明显,无法从 JSON 文件中直接看出来:

- type: What type of image are we building (EBS-optimized one in our case).
- region: What region will this AMI build in.
- source_ami: What is our base AMI? See section below for more info on this.
- instance_type: Type of instance to use when building the AMI - bigger machine == faster builds.
- ami_name: Name of the AMI that will appear in the UI.
- ami_description: Description for the AMI.
- ssh_username: What username to use to connect to base AMI. For Ubuntu, this is usually "ubuntu".
- associate_public_ip_address: Do we want this builder to have an external IP. Usually this needs to be true.
- force_delete_snapshot: Do we want to delete the old block device snapshot if same AMI is rebuilt?
- force_deregister: Do we want to replace the old AMI when rebuilding?

您可以在www.packer.io/docs/builders/amazon-ebs.html找到有关此特定构建器类型及其可用选项的更多信息。

选择正确的 AMI 基础镜像

与我们在早期章节中介绍的选择要扩展的基础 Docker 镜像不同,选择正确的 AMI 来在 Packer 上使用是一个不简单的任务。一些发行版经常更新,因此 ID 会发生变化。ID 也是每个 AWS 区域独一无二的,您可能需要硬件或半虚拟化(HVM vs PV)。除此之外,您还需要根据您的存储需求选择正确的存储类型(在撰写本书时为instance-storeebsebs-ssd),这创建了一个绝对不直观的选项矩阵。

如果您没有使用过 Amazon 弹性计算云EC2)和 EBS,存储选项对新手来说可能有点令人困惑,但它们的含义如下:

  • instance-store:这种存储类型是本地的 EC2 VM,空间取决于 VM 类型(尽管通常很少),并且在 VM 终止时完全丢弃(停止或重新启动的 VM 保留其状态)。实例存储非常适合不需要保留任何状态的节点,但不应该用于希望保留数据的机器;但是,如果您想要持久存储并且利用无状态存储,您可以独立地将单独的 EBS 驱动器挂载到实例存储 VM 上。

  • ebs:每当使用特定镜像启动 EC2 实例时,此存储类型将创建并关联由旧的磁性旋转硬盘支持的 EBS 卷,因此数据始终保留。如果您想要持久保存数据或instance-store卷不够大,这个选项很好。不过,截至今天,这个选项正在被积极弃用,因此很可能在未来会消失。

  • ebs-ssd:这个选项基本上与前面的选项相同,但使用固态设备(SSD),速度更快,但每 GB 分配的成本更高。

我们需要选择的另一件事是虚拟化类型:

  • 半虚拟化/pv:这种虚拟化比较老,使用软件来链式加载您的镜像,因此能够在更多样化的硬件上运行。虽然很久以前它比较快,但今天通常比硬件虚拟化慢。

  • 硬件虚拟化/hvm:这种虚拟化使用 CPU 级指令在完全隔离的环境中运行您的镜像,类似于直接在裸机硬件上运行镜像。虽然它取决于特定的英特尔 VT CPU 技术实现,但通常比pv虚拟化性能更好,因此在大多数情况下,您应该优先使用它而不是其他选项,特别是如果您不确定选择哪个选项。

有了我们对可用选项的新知识,我们现在可以确定我们将使用哪个镜像作为基础。对于我们指定的操作系统版本(Ubuntu LTS),您可以使用辅助页面在cloud-images.ubuntu.com/locator/ec2/找到合适的镜像:

对于我们的测试构建,我们将使用us-west-1地区,Ubuntu 16.04 LTS 版本(xenial),64 位架构(amd64),hvm虚拟化和ebs-ssd存储,以便我们可以使用页面底部的过滤器来缩小范围:

正如您所看到的,列表收缩到一个选择,在我们的packer.json中,我们将使用ami-1c1d217c

由于此列表更新了具有更新的安全补丁的 AMI,很可能在您阅读本节时,AMI ID 在您的端上将是其他值。因此,如果您看到我们在这里找到的值与您在阅读本章时可用的值之间存在差异,请不要感到惊讶。

构建 AMI

警告!运行此 Packer 构建肯定会在您的 AWS 帐户上产生一些(尽管在撰写本书时可能只有几美元)费用,因为使用了非免费实例类型、快照使用和 AMI 使用,有可能是一些重复的费用。请参考 AWS 的定价文档来估算您将被收取的金额。另外,清理掉您在 AWS 对象上完成工作后不会保留的一切,也是一个良好的做法,因为这将确保您在使用此代码后不会产生额外的费用。有了packer.json,我们现在可以构建我们的镜像。我们将首先安装先决条件(python-botoawscli),然后检查访问权限,最后构建我们的 AMI:

$ # Install python-boto as it is a prerequisite for Amazon builders
$ # Also get awscli to check if credentials have been set correctly
$ sudo apt-get update && sudo apt-get install -y python-boto awscli
<snip>

$ # Check that AWS API credentials are properly set. 
$ # If you see errors, consult the previous section on how to do this
$ aws ec2 describe-volumes 
{
 "Volumes": [
 ]
}

$ # Go to the proper directory if we are not in it
$ cd ~/ansible_deployment

$ # Build our AMI and use standardized output format
$ packer build -machine-readable packer.json 
<snip>
1509439711,,ui,say,==> amazon-ebs: Provisioning with shell script: /tmp/packer-shell105349087
<snip>
1509439739,,ui,message, amazon-ebs: Setting up ansible (2.0.0.2-2ubuntu1) ...
1509439741,,ui,message, amazon-ebs: Setting up python-selinux (2.4-3build2) ...
1509439744,,ui,say,==> amazon-ebs: Provisioning with Ansible...
1509439744,,ui,message, amazon-ebs: Uploading Playbook directory to Ansible staging directory...
<snip>
1509439836,,ui,message, amazon-ebs: TASK [swarm_node : Installing Docker] ****************************************
1509439855,,ui,message, amazon-ebs: [0;33mchanged: [127.0.0.1]0m
1509439855,,ui,message, amazon-ebs:
1509439855,,ui,message, amazon-ebs: PLAY RECAP *******************************************************************
1509439855,,ui,message, amazon-ebs: [0;33m127.0.0.1[0m : [0;32mok[0m[0;32m=[0m[0;32m10[0m [0;33mchanged[0m[0;33m=[0m[0;33m9[0m unreachable=0 failed=0
1509439855,,ui,message, amazon-ebs:
1509439855,,ui,say,==> amazon-ebs: Stopping the source instance...
<snip>
1509439970,,ui,say,Build 'amazon-ebs' finished.
1509439970,,ui,say,--> amazon-ebs: AMIs were created:\nus-west-1: ami-a694a8c6\n

成功!通过这个新的镜像 ID(您可以在输出的末尾看到ami-a694a8c6),我们现在可以在 EC2 中使用这个 AMI 启动实例,并且它们将具有我们应用的所有调整以及预安装的 Docker!

部署到 AWS

只有裸露的镜像,没有虚拟机来运行它们,我们之前的 Packer 工作还没有完全实现自动化工作状态。为了真正实现这一点,我们现在需要用更多的 Ansible 粘合剂将所有东西联系在一起,以完成部署。不同阶段的封装层次应该在概念上看起来像这样:

![

从图表中可以看出,我们将采取分层的方法进行部署:

  • 在最内层,我们有 Ansible 脚本,将裸机、虚拟机或 AMI 转换为我们想要的配置状态。

  • Packer 封装了该过程,并生成了静态 AMI 镜像,这些镜像可以进一步在 Amazon EC2 云服务上使用。

  • 然后,Ansible 最终通过部署使用那些静态的、由 Packer 创建的镜像来封装之前提到的一切。

自动化基础设施部署的道路

现在我们知道我们想要什么,我们该如何做呢?幸运的是,如前面的列表所示,Ansible 可以为我们完成这部分工作;我们只需要编写一些配置文件。但是,由于 AWS 在这里非常复杂,所以它不会像只启动一个实例那样简单,因为我们想要一个隔离的 VPC 环境。但是,由于我们只管理一个服务器,我们对 VPC 之间的网络连接并不是很在意,所以这会让事情变得简单一些。

首先,我们需要考虑所有所需的步骤。其中一些对大多数人来说可能非常陌生,因为 AWS 相当复杂,大多数开发人员通常不会在网络上工作,但这些是必需的步骤,以便在不破坏帐户的默认设置的情况下拥有一个隔离的 VPC:

  • 为特定虚拟网络设置 VPC。

  • 创建并将子网绑定到它。如果没有这个,我们的机器将无法在上面使用网络。

  • 设置虚拟互联网网关并将其附加到 VPC,以便使用路由表解析地址。如果我们不这样做,机器将无法使用互联网。

  • 设置一个安全组(防火墙)白名单,列出我们希望能够访问我们服务器的端口(SSH 和 HTTP 端口)。默认情况下,所有端口都被阻止,因此这可以确保启动的实例是可访问的。

  • 最后,使用配置的 VPC 进行网络设置来提供 VM 实例。

要拆除所有内容,我们需要做同样的事情,只是相反。

首先,我们需要一些变量,这些变量将在部署和拆除 playbooks 之间共享。在与本章中我们一直在使用的大型 Ansible 示例相同的目录中创建一个group_vars/all文件:

# Region that will accompany all AWS-related module usages
aws_region: us-west-1

# ID of our Packer-built AMI
cluster_node_ami: ami-a694a8c6

# Key name that will be used to manage the instances. Do not
# worry about what this is right now - we will create it in a bit
ssh_key_name: swarm_key

# Define the internal IP network for the VPC
swarm_vpc_cidr: "172.31.0.0/16"

现在我们可以在与packer.json相同的目录中编写我们的deploy.yml,并使用其中一些变量:

这种部署的困难程度从我们之前的示例中显著增加,并且没有很好的方法来涵盖分散在数十个 AWS、网络和 Ansible 主题之间的所有信息,以简洁的方式描述它,但是这里有一些我们将使用的模块的链接,如果可能的话,您应该在继续之前阅读:

- hosts: localhost
 connection: local
 gather_facts: False

 tasks:
 - name: Setting up VPC
 ec2_vpc_net:
 region: "{{ aws_region }}"
 name: "Swarm VPC"
 cidr_block: "{{ swarm_vpc_cidr }}"
 register: swarm_vpc

 - set_fact:
 vpc: "{{ swarm_vpc.vpc }}"

 - name: Setting up the subnet tied to the VPC
 ec2_vpc_subnet:
 region: "{{ aws_region }}"
 vpc_id: "{{ vpc.id }}"
 cidr: "{{ swarm_vpc_cidr }}"
 resource_tags:
 Name: "Swarm subnet"
 register: swarm_subnet

 - name: Setting up the gateway for the VPC
 ec2_vpc_igw:
 region: "{{ aws_region }}"
 vpc_id: "{{ vpc.id }}"
 register: swarm_gateway

 - name: Setting up routing table for the VPC network
 ec2_vpc_route_table:
 region: "{{ aws_region }}"
 vpc_id: "{{ vpc.id }}"
 lookup: tag
 tags:
 Name: "Swarm Routing Table"
 subnets:
 - "{{ swarm_subnet.subnet.id }}"
 routes:
 - dest: 0.0.0.0/0
 gateway_id: "{{ swarm_gateway.gateway_id }}"

 - name: Setting up security group / firewall
 ec2_group:
 region: "{{ aws_region }}"
 name: "Swarm SG"
 description: "Security group for the swarm"
 vpc_id: "{{ vpc.id }}"
 rules:
 - cidr_ip: 0.0.0.0/0
 proto: tcp
 from_port: 22
 to_port: 22
 - cidr_ip: 0.0.0.0/0
 proto: tcp
 from_port: 80
 to_port: 80
 rules_egress:
 - cidr_ip: 0.0.0.0/0
 proto: all
 register: swarm_sg

 - name: Provisioning cluster node
 ec2:
 region: "{{ aws_region }}"
 image: "{{ cluster_node_ami }}"
 key_name: "{{ ssh_key_name }}"
 instance_type: "t2.medium"
 group_id: "{{ swarm_sg.group_id }}"
 vpc_subnet_id: "{{ swarm_subnet.subnet.id }}"
 source_dest_check: no
 assign_public_ip: yes
 monitoring: no
 instance_tags:
 Name: cluster-node
 wait: yes
 wait_timeout: 500

我们在这里所做的与我们之前的计划非常相似,但现在我们有具体的部署代码与之匹配:

  1. 我们使用ec2_vpc_net模块设置 VPC。

  2. 我们使用ec2_vpc_subnet模块创建子网并将其关联到 VPC。

  3. 为我们的云创建 Internet 虚拟网关使用ec2_vpc_igw

  4. 然后创建 Internet 网关以解析不在同一网络中的任何地址。

  5. 使用ec2_group模块启用入站和出站网络,但只允许端口22(SSH)和端口80(HTTP)。

  6. 最后,我们的 EC2 实例是在新配置的 VPC 中使用ec2模块创建的。

正如我们之前提到的,拆除应该非常类似,但是相反,并包含更多的state: absent参数。让我们把以下内容放在同一个文件夹中的destroy.yml中:

- hosts: localhost
 connection: local
 gather_facts: False

 tasks:
 - name: Finding VMs to delete
 ec2_remote_facts:
 region: "{{ aws_region }}"
 filters:
 "tag:Name": "cluster-node"
 register: deletable_instances

 - name: Deleting instances
 ec2:
 region: "{{ aws_region }}"
 instance_ids: "{{ item.id }}"
 state: absent
 wait: yes
 wait_timeout: 600
 with_items: "{{ deletable_instances.instances }}"
 when: deletable_instances is defined

 # v2.0.0.2 doesn't have ec2_vpc_net_facts so we have to fake it to get VPC info
 - name: Finding route table info
 ec2_vpc_route_table_facts:
 region: "{{ aws_region }}"
 filters:
 "tag:Name": "Swarm Routing Table"
 register: swarm_route_table

 - set_fact:
 vpc: "{{ swarm_route_table.route_tables[0].vpc_id }}"
 when: swarm_route_table.route_tables | length > 0

 - name: Removing security group
 ec2_group:
 region: "{{ aws_region }}"
 name: "Swarm SG"
 state: absent
 description: ""
 vpc_id: "{{ vpc }}"
 when: vpc is defined

 - name: Deleting gateway
 ec2_vpc_igw:
 region: "{{ aws_region }}"
 vpc_id: "{{ vpc }}"
 state: absent
 when: vpc is defined

 - name: Deleting subnet
 ec2_vpc_subnet:
 region: "{{ aws_region }}"
 vpc_id: "{{ vpc }}"
 cidr: "{{ swarm_vpc_cidr }}"
 state: absent
 when: vpc is defined

 - name: Deleting route table
 ec2_vpc_route_table:
 region: "{{ aws_region }}"
 vpc_id: "{{ vpc }}"
 state: absent
 lookup: tag
 tags:
 Name: "Swarm Routing Table"
 when: vpc is defined

 - name: Deleting VPC
 ec2_vpc_net:
 region: "{{ aws_region }}"
 name: "Swarm VPC"
 cidr_block: "{{ swarm_vpc_cidr }}"
 state: absent

如果部署 playbook 可读,则该 playbook 应该很容易理解,正如我们所提到的,它只是以相反的方式运行相同的步骤,删除我们已经创建的任何基础设施部分。

运行部署和拆除 playbooks

如果您还记得,在我们的group_vars定义中,我们有一个关键变量(ssh_key_name: swarm_key),在这一点上变得相对重要,因为没有工作密钥,我们既不能部署也不能启动我们的 VM,所以现在让我们这样做。我们将使用awsclijq--一个 JSON 解析工具,它将减少我们的工作量,但也可以通过 GUI 控制台完成。

$ # Create the key with AWS API and save the private key to ~/.ssh directory
$ aws ec2 create-key-pair --region us-west-1 \
 --key-name swarm_key | jq -r '.KeyMaterial' > ~/.ssh/ec2_swarm_key

$ # Check that its not empty by checking the header
$ head -1 ~/.ssh/ec2_swarm_key 
-----BEGIN RSA PRIVATE KEY-----

$ # Make sure that the permissions are correct on it
$ chmod 600 ~/.ssh/ec2_swarm_key

$ # Do a sanity check that it has the right size and permissions
$ ls -la ~/.ssh/ec2_swarm_key
-rw------- 1 sg sg 1671 Oct 31 16:52 /home/sg/.ssh/ec2_swarm_key

将密钥放置后,我们终于可以运行我们的部署脚本:

$ ansible-playbook deploy.yml 
 [WARNING]: provided hosts list is empty, only localhost is available

PLAY *************************************************************************

TASK [Setting up VPC] ********************************************************
ok: [localhost]

TASK [set_fact] **************************************************************
ok: [localhost]

TASK [Setting up the subnet] *************************************************
ok: [localhost]

TASK [Setting up the gateway] ************************************************
ok: [localhost]

TASK [Setting up routing table] **********************************************
ok: [localhost]

TASK [Setting up security group] *********************************************
ok: [localhost]

TASK [Provisioning cluster node] *********************************************
changed: [localhost]

PLAY RECAP *******************************************************************
localhost : ok=7 changed=1 unreachable=0 failed=0 

$ # Great! It looks like it deployed the machine! 
$ # Let's see what we have. First we need to figure out what the external IP is
$ aws ec2 describe-instances --region us-west-1 \
 --filters Name=instance-state-name,Values=running \
 --query 'Reservations[*].Instances[*].PublicIpAddress'
[
 [
 "52.53.240.17"
 ]
]

$ # Now let's try connecting to it
ssh -i ~/.ssh/ec2_swarm_key ubuntu@52.53.240.17 
<snip>
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '52.53.240.17' (ECDSA) to the list of known hosts.
<snip>

ubuntu@ip-172-31-182-20:~$ # Yay! Do we have Docker?
ubuntu@ip-172-31-182-20:~$ sudo docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES

ubuntu@ip-172-31-182-20:~$ # Create our single-server swarm
ubuntu@ip-172-31-182-20:~$ sudo docker swarm init
Swarm initialized: current node (n2yc2tedm607rvnjs72fjgl1l) is now a manager.
<snip>

ubuntu@ip-172-31-182-20:~$ # Here we can now do anything else that's needed
ubuntu@ip-172-31-182-20:~$ # Though you would normally automate everything

如果您看到类似于"没有处理程序准备好进行身份验证。已检查 1 个处理程序。['HmacAuthV4Handler']检查您的凭据"的错误,请确保您已正确设置 AWS 凭据。

看起来一切都在运行!在这一点上,如果我们愿意,我们可以部署我们之前构建的三层应用程序。由于我们已经完成了我们的示例,并且我们的迷你 PaaS 正在运行,我们可以返回并通过运行destroy.yml playbook 来清理事务:

ubuntu@ip-172-31-182-20:~$ # Get out of our remote machine
ubuntu@ip-172-31-182-20:~$ exit
logout
Connection to 52.53.240.17 closed.

$ # Let's run the cleanup script
ansible-playbook destroy.yml 
 [WARNING]: provided hosts list is empty, only localhost is available

PLAY *************************************************************************

TASK [Finding VMs to delete] *************************************************
ok: [localhost]

TASK [Deleting instances] ****************************************************
changed: [localhost] => <snip>

TASK [Finding route table info] **********************************************
ok: [localhost]

TASK [set_fact] **************************************************************
ok: [localhost]

TASK [Removing security group] ***********************************************
changed: [localhost]

TASK [Deleting gateway] ******************************************************
changed: [localhost]

TASK [Deleting subnet] *******************************************************
changed: [localhost]

TASK [Deleting route table] **************************************************
changed: [localhost]

TASK [Deleting VPC] **********************************************************
changed: [localhost]

PLAY RECAP *******************************************************************
localhost : ok=9 changed=6 unreachable=0 failed=0 

有了这个,我们可以使用单个命令自动部署和拆除我们的基础架构。虽然这个例子的范围相当有限,但它应该能给你一些关于如何通过自动扩展组、编排管理 AMI、注册表部署和数据持久化来扩展的想法。

持续集成/持续交付

随着您创建更多的服务,您会注意到来自源代码控制和构建的手动部署需要更多的时间,因为需要弄清楚哪些图像依赖关系属于哪里,哪个图像实际上需要重建(如果您运行的是单一存储库),服务是否发生了任何变化,以及许多其他辅助问题。为了简化和优化我们的部署过程,我们需要找到一种方法,使整个系统完全自动化,以便部署新版本的服务所需的唯一事情是提交代码存储库分支的更改。

截至今天,名为 Jenkins 的最流行的自动化服务器通常用于进行构建自动化和 Docker 镜像和基础架构的部署,但其他工具如 Drone、Buildbot、Concoure 等也在非常有能力的软件 CI/CD 工具排行榜上迅速上升,但迄今为止还没有达到行业的同等接受水平。由于 Jenkins 相对容易使用,我们可以快速演示其功能,虽然这个例子有点简单,但它应该很明显地表明了它可以用于更多的用途。

由于 Jenkins 将需要awscli、Ansible 和python-boto,我们必须基于 Docker Hub 上可用的 Jenkins 创建一个新的 Docker 镜像。创建一个新文件夹,并在其中添加一个Dockerfile,内容如下:

FROM jenkins

USER root
RUN apt-get update && \
 apt-get install -y ansible \
 awscli \
 python-boto

USER jenkins

现在我们构建并运行我们的服务器:

$ # Let's build our image
$ docker build -t jenkins_with_ansible 
Sending build context to Docker daemon 2.048kB
Step 1/4 : FROM jenkins
<snip>
Successfully tagged jenkins_with_ansible:latest

$ # Run Jenkins with a local volume for the configuration
$ mkdir jenkins_files
$ docker run -p 8080:8080 \
 -v $(pwd)/jenkins_files:/var/jenkins_home \
 jenkins_with_ansible

Running from: /usr/share/jenkins/jenkins.war
<snip>
Jenkins initial setup is required. An admin user has been created and a password generated.
Please use the following password to proceed to installation:

3af5d45c2bf04fffb88e97ec3e92127a

This may also be found at: /var/jenkins_home/secrets/initialAdminPassword
<snip>
INFO: Jenkins is fully up and running

在它仍在运行时,让我们转到主页并输入我们在镜像启动期间收到警告的安装密码。转到http://localhost:8080并输入日志中的密码:

在下一个窗口上点击“安装建议的插件”,然后在相关下载完成后,选择最后一个安装程序页面上的“以管理员身份继续”,这应该会带您到主要的登陆页面:

点击“创建新作业”,命名为redeploy_infrastructure,并将其设置为自由风格项目。

接下来,我们将使用我们的 Git 存储库端点配置作业,以便在主分支上的任何提交上构建:

作为我们的构建步骤,当存储库触发器激活时,我们将销毁并部署基础设施,有效地用新版本替换它。添加一个新的执行 Shell类型的构建步骤,并添加以下内容:

# Export needed AWS credentials
export AWS_DEFAULT_REGION="us-west-1"
export AWS_ACCESS_KEY_ID="AKIABCDEFABCDEF"
export AWS_SECRET_ACCESS_KEY="123456789ABCDEF123456789ABCDEF"

# Change to relevant directory
cd chapter_8/aws_deployment

# Redeploy the service by cleaning up the old deployment
# and deploying a new one
ansible-playbook destroy.yml
ansible-playbook deploy.yml

工作应该看起来与这个相似:

保存更改并点击“保存”,这应该会带您到构建的主页。在这里,点击“立即构建”按钮,一旦构建出现在左侧构建列表中,点击其进度条或名称旁边的下拉菜单,并选择“查看日志”:

成功!正如您所看到的,通过 Jenkins 和一些小的配置,我们刚刚实现了我们简单基础设施的自动部署。虽然粗糙但有效,通常情况下,您不希望重新部署所有内容,而只是更改了的部分,并且 Jenkins 生活在集群中,但这些都是一些更复杂的努力,将留给读者作为可能的改进点。

资源考虑

由于 Jenkins 在 Java 虚拟机上运行,它会以惊人的速度消耗可用的 RAM,并且通常是使用量最大的,也是我经验最丰富的内存不足OOM)罪魁祸首。即使在最轻量的使用情况下,计划为 Jenkins 工作节点分配至少 1GB 的 RAM,否则可能在构建流水线的最不合时宜的阶段出现各种故障。一般规则是,目前大多数 Jenkins 安装将不会在分配给它们 2GB 的 RAM 时出现太多问题,但由于 VM 实例中 RAM 的价格,您可以尝试缩减规模,直到达到可接受的性能水平。

另外需要注意的最后一件事是,相对而言,Jenkins 镜像也是一个庞大的镜像,重达约 800 MB,因此请记住,移动这个容器并不像我们之前使用的一些其他工具那样容易或快速。

首次部署的循环依赖

在集群中使用 Jenkins 作为 Docker 化服务来链接构建所有其他镜像时,我需要提到一个常见的陷阱,即您将不可避免地在新部署中遇到问题,因为 Jenkins 最初不可用,因为在集群初始化阶段,注册表中通常没有镜像可用,并且默认的 Jenkins Docker 镜像没有进行任何配置。除此之外,由于您经常需要一个已运行的 Jenkins 实例来构建更新的 Jenkins 镜像,您将陷入经典的进退两难的境地。您可能会有一种本能去手动构建 Jenkins 作为后续部署步骤,但如果您真的想要拥有大部分无需干预的基础设施,您必须抵制这种冲动。

解决这个问题的一般方法通常是在干净的集群上引导 Jenkins,通常是如下图所示的方式:

首先进行集群部署,以确保我们有一种构建引导映像的方法,然后使用 Docker Registry 存储构建后的映像。随后,在任何可用的 Docker Engine 节点上构建 Jenkins 映像,并将其推送到注册表,以便服务将具有正确的映像来启动。如果需要,然后使用相同的配置管理工具(如 Ansible)或编排工具启动所述服务,并等待自动启动作业,该作业将构建所有其他剩余的映像,这些映像应填充注册表以运行完整的集群所需的所有其他映像。这里的基本思想是通过 CM 工具进行初始引导,然后让 Jenkins 服务重新构建所有其他映像并(重新)启动任务。

在大规模部署中,还可以使用集群编排来安排和处理此引导过程,而不是使用 CM 工具,但由于每个编排引擎之间存在巨大差异,这些步骤可能在它们之间大相径庭。

进一步的通用 CI/CD 用途

像 Jenkins 这样的良好的 CI 工具可以做的事情远不止我们在这里介绍的内容;它们都需要大量的时间和精力来使其正常工作,但如果您能够实施它们,其好处是非常显著的:

  • 自构建:如前所述,当配置更改时,您可以让 Jenkins 构建自己的映像,并重新部署自己。

  • 仅部署已更改的 Docker 映像:如果使用 Docker 缓存,您可以检查新构建是否创建了不同的映像哈希,并且仅在确实创建了不同的映像时部署。这样做将防止无谓的工作,并使您的基础设施始终运行最新的代码。

  • 定时 Docker 清理:您可以在 Jenkins 上运行清理作业(或类似于cron的任何其他作业),以释放或管理您的 Docker 节点,以避免手动交互。

此列表还可以包括:自动发布、故障通知、构建跟踪,以及许多其他可以获得的东西,但可以说,您确实希望在任何非平凡的部署中都有一个可工作的 CI 流水线。

一个经验法则是,如果您需要手动完成某些可以通过一些定时器和 shell 脚本自动化的工作,大多数 CI 工具(如 Jenkins)都可以帮助您,所以不要害怕尝试不同和创造性的用法。通过本章中涵盖的一整套选项和其他工具,您可以放心地入睡,知道您的集群在一段时间内都不需要不断地看护。

摘要

在本章中,我们更多地介绍了如何真正部署 PaaS 基础架构以及为此所需的以下主题:使用 Ansible 进行配置管理工具化、使用 HashiCorp Packer 进行云镜像管理以及使用 Jenkins 进行持续集成。通过在这里获得的知识,您现在应该能够使用我们讨论过的各种工具,并为自己的服务部署创建自己的迷你 PaaS,再经过一些额外的工作,您可以将其转变为全面的 PaaS!

在下一章中,我们将看看如何将我们当前的 Docker 和基础架构工作扩大。我们还将探讨这个领域可能朝着什么方向发展,所以如果您想了解世界上最大规模的部署,敬请关注。

第九章:探索最大规模的部署

在前几章中,我们涵盖了部署 Docker 容器的许多不同方面,但是如果我们要将我们的例子转化为一个全球服务,能够承受每秒数百万请求的吞吐量,仍然有一些事情需要解决,这一章特别写作是为了详细讨论其中最重要的一些。由于这里涉及的主题实施将涉及足够的材料,可以单独成书,并且基础设施会根据多种因素大相径庭,因此这里的文本大部分将是理论性的,但是在本章之前我们对服务的理解应该足够好,可以给你一些关于如何以最少的痛苦进行下一步操作的想法。

在其核心,我们将要讨论的主题围绕选择合适的技术,然后遵循三个基本理念:

  • 一切都要自动化!

  • 真的,一切都要自动化!

  • 是的,甚至自动化那些你每隔几周做一次的事情

这可能是一个玩笑,但希望到现在为止应该清楚,所有这些工作的主要目的之一(除了隔离)是从你的系统中消除任何人为干预,以保持你的服务运行,这样你和你的团队就可以专注于实际开发服务,而不是浪费时间在部署上。

维护法定人数

在我们之前的例子中,我们大多数时间都是使用单节点管理器,但是如果你想要弹性,你必须确保最小的故障点不会导致整个基础架构崩溃,而单个编排管理节点绝对不足以支持生产服务,无论你使用 Swarm、Kubernetes、Marathon 还是其他编排工具。从最佳实践的角度来看,你至少需要在集群中拥有三个或更多的管理节点,这些节点分布在云的三个或更多的可用区AZ)或等效的分组中,以确保在规模上真正实现稳定性,因为数据中心的故障已经被证明会发生,并且给那些没有减轻这类情况的公司造成严重问题。

While in most orchestration platforms you can have any number of backing management nodes (or backing key-value stores in some cases), you will always have to balance resiliency vs speed due to the fact that with more nodes comes better capability to handle failures of larger parts of the system, but changes to this system (such as node additions and removals) must reach more points that will all have to agree, thus making it slower to process data. In most cases where this 3+ availability zone topology is required, we will need to go in details about quorums—the concept we lightly covered earlier, which is the backbone of all high availability (HA) systems.

Quorums in their basic sense are a grouping of the majority of management nodes, which together can decide whether updates to the cluster are going to be allowed or not. If the quorum is lost by the fact that half or more management nodes are unavailable, all changes to the cluster will be stopped to prevent your cluster infrastructure from having effectively split clusters. To properly divide your network topology for scale in this respect, you must make sure that you have a minimum of three nodes and/or availability zones as the quorum majority is lost with a single failure with less than that number. Taking this further, you will generally also want an odd number of nodes and availability zones since even numbers do not provide much additional protection for maintaining quorum, as we will see in a moment.

在大多数编排平台中,您可以拥有任意数量的后备管理节点(或在某些情况下是后备键值存储),但由于更多的节点意味着更好地处理系统更大部分的故障的能力,您总是必须在弹性和速度之间取得平衡,但对于这个系统的更改(如节点的添加和删除)必须达到更多的点,所有这些点都必须同意,因此处理数据的速度会变慢。在大多数需要 3 个或更多可用区域的拓扑结构中,我们需要详细了解法定人数——这是我们稍早轻描淡写提到的概念,它是所有高可用性HA)系统的支柱。

To start off, let's say that you have five management nodes. To maintain a quorum of this number, you must have three or more nodes available, but if you have only two availability zones, the best split you can do is 3-2, which will work fine if a connection is broken or the AZ with two management nodes goes down, but if the AZ with three nodes goes down, a quorum cannot be established since two is less than half of the total node count.

现在让我们看看在三个可用区域中我们可以获得什么样的弹性。使用五个管理节点的最佳布局将是2-2-1,如果你仔细观察任何一个区域失效时会发生什么,你会发现始终保持了法定人数,因为我们将从集群的其余部分获得3 (2+1)4 (2+2)个节点,确保我们的服务运行正常:

当然,展示偶数对效果的影响也是很好的,因为我们提到它们可能有点麻烦。有了四个可用区域,我们可以做出的最佳分割是在它们之间进行2-1-1-1的分配,根据这些数字,我们只能容忍两个区域不可用,如果它们都只包含一个节点。通过这种设置,我们有一半的几率,即两个不可用的区域将包括其中有两个节点的区域,使得不可用的总节点数超过 3 个,因此集群将完全离线:

对于集群中更多的可用区域和管理节点,跨更多可用区域的管理节点的分布会更加稳定,但是对于我们这里的简单示例,如果我们有五个管理节点和五个可用区域(1-1-1-1-1布局),我们可以看到这种效果。由于法定人数要求至少三个节点,如果五个区域中的任意两个不可用,我们仍将完全运行,从 3 个可用区域的拓扑结构中将故障容忍度提高了 100%;但是您可以假设可能在地理位置上相距很远的区域之间的通信会给任何更新增加大量的延迟。

希望通过这些例子,现在应该清楚了在尝试保持集群的弹性并且能够保持法定人数时,您将使用什么样的考虑和计算。虽然工具可能会有所不同,具体取决于编排工具(即etcd节点与 Zookeeper 节点),但原则在几乎所有情况下都是相对相同的,因此这一部分应该是相对可移植的。

节点自动化

当我们使用 Packer 制作 Amazon Machine Images(AMIs)时,我们已经看到了我们可以使用预先烘焙的实例映像做什么,但是只有当整个基础设施由它们组成时,它们的真正力量才能得到充分发挥。如果您的编排管理节点和工作节点有自己的系统映像,并且还有一些启动脚本通过 init 系统(例如,systemd 启动服务)烘焙进去,您可以使使用这些映像启动的实例在其预定义角色中在启动时自动加入到集群中。将这进一步提升到概念层面,如果我们将所有有状态的配置提取到映像配置中,将所有动态配置提取到一个对所有节点可访问的单独服务中,例如 EC2 user-data或 HashiCorp Vault,除了初始部署和映像构建之外,您的集群几乎完全自我配置。

通过拥有这种强大的自动加入功能,您可以消除与扩展集群的手动工作大部分相关的工作,因为除了启动它之外,无需与 VM 实例进行交互。这种架构的一个相当简单的示例如下图所示,其中编排和工作节点有各自的映像,并在启动时使用 VPC 内的共享配置数据提供程序进行自我配置:

注意!为了防止严重的安全漏洞,请确保将任何敏感信息分离和隔离,只能由此配置服务布局中的所需系统访问。正如我们在早期的章节中提到的,通过使用需要知道的最佳实践,可以确保单个点(很可能是工作节点)的妥协不会轻易传播到集群的其余部分。举个简单的例子,这将包括确保管理秘密对工作节点或其网络不可读。

反应式自动扩展

通过实施自动化的自我配置,我们可以开始自动启动实例,从而实现更大规模的目标。如果你还记得之前章节中提到的自动扩展组,即使在大多数云服务中,这也可以实现自动化。通过使用启动配置和预配置的镜像,就像我们刚刚讨论的那样,使用这种设置添加或移除节点将变得像拨号设置所需节点一样简单。自动扩展组将增加或减少工作实例的数量,因为镜像是自我配置的,这将是你所需的全部输入。通过这样简单的输入,你可以轻松地对基础架构进行扩展,并通过许多不同的方式完成。

在进一步的自动化步骤中,有一些云服务提供商可以基于其指标或类似cron的计划来触发自动扩展组中的这些操作。原则上,如果你的集群负载增加,你可以触发节点计数的增加,反之,如果集群或单个节点的负载下降到预定义的值以下,你可以激活服务排水并关闭一部分节点来根据需要扩展系统。对于周期性但可预测的需求变化(参见en.wikipedia.org/wiki/Internet_Rush_Hour了解更多信息),我们提到的计划的扩展变化可以确保你有足够的资源来处理预期的需求。

预测性自动扩展

如果你手动调整节点计数并根据计划或指标触发自动扩展,你仍然会遇到一些问题,因为服务需要一些时间才能上线、自我配置,并开始传播到网络中的各种负载均衡器。在这种架构下,很可能是你的用户发现你没有足够的容量,然后你的系统做出反应来补偿。如果你真的希望从你的服务中获得最佳的用户体验,有时你可能还需要在自动扩展触发器中添加一层,可以在实际需要之前预测你的服务何时需要更多资源,这就是所谓的预测性扩展

在非常广泛的范围内,要将预测层添加到基础架构中,您需要将过去x时间内收集的一些指标的一部分导入到诸如 TensorFlow(www.tensorflow.org/)之类的机器学习ML)工具中,并生成一个训练集,使该工具能够以一定的确定性预测您是否需要更多节点。通过使用这种方法,您的服务可以在需要之前就进行扩展,并且比简单的基于计划的方法更加智能。这些系统在正确整合到您的流水线中时可能会相当困难,但如果您正在全球范围内处理大量数据,并且简单的反应式自动扩展不足以满足需求,那么这可能是一个值得探索的途径。

在机器学习中,训练集指的只是一组训练数据(在我们的情况下,它将是我们长期指标的一部分),您可以使用它来教会神经网络如何正确预测您将需要的需求。就像最近章节中的许多主题一样,实际上有很多关于这个材料(机器学习)的书籍,它们的内容会远远超过这本书,并且对您的实用性也只会提供边际帮助。如果您想详细了解机器学习,这个维基百科页面对此有一个很好的入门介绍:en.wikipedia.org/wiki/Machine_learning,您也可以在www.tensorflow.org/get_started/get_started上尝试 TensorFlow。

最终,如果您成功实施了这些技术中的一些或全部,您几乎不需要对集群进行任何干预,以处理扩展或收缩。作为能够安心入睡的额外奖励,您还将节省资源,因为您将能够将处理资源与服务的实际使用情况紧密匹配,使您、您的预算和您的用户都感到满意。

监控

您在服务交付中依赖的任何服务理想情况下都应该有一种方式来通知您它是否出现了问题,我指的不是用户反馈。大多数服务开发现在都以令人难以置信的速度发展,监控就像备份一样,大多数开发人员在发生灾难性事件之前都不会考虑,因此我们应该稍微涉及一下。真正应该决定您如何处理这个问题的重要问题是,如果您的用户能够处理您在没有监控的情况下看不到的停机时间。

大多数小型服务可能对一些中断没有太大问题,但对于其他所有情况,这至少会导致用户发来一些愤怒的电子邮件,最坏的情况是您的公司失去了大部分用户,因此强烈鼓励在各个规模上进行监控。

尽管监控可能被认为是基础设施中那些无聊的部分之一,但在任何时候都能获得对云端正在进行的工作的洞察力绝对是管理多样化系统和服务的绝对必要部分。通过将监控添加到您的关键绩效指标(KPIs)中,您可以确保整个系统的性能符合预期,并通过向关键监控目标添加触发器,您可以立即收到可能影响用户的任何活动的警报。对基础设施的这种洞察力既可以帮助减少用户流失,也可以推动更好的业务决策。

当我们通过示例进行工作时,您可能已经想到了要监控的内容,但以下是一些常见的内容,它们一直被认为是最有用的:

  • 节点 RAM 利用率:如果您注意到您的节点没有使用分配的所有 RAM,您可以切换到较小的节点,反之亦然。如果您使用受内存限制的 Docker 容器,这个指标通常会变得不那么有用,但仍然是一个很好的指标,因为您希望确保您的节点从未达到系统级最大内存利用率,否则您的容器将以更慢的交换方式运行。

  • 节点 CPU 利用率:通过这个指标,您可以看到服务密度是否过低或过高,或者服务需求是否出现波动。

  • 节点意外终止:这个指标很好地跟踪确保您的 CI/CD 流水线没有创建错误的镜像,您的配置服务是在线的,以及可能导致服务中断的其他问题。

  • 服务意外终止:找出服务为何意外终止对于消除任何系统中的错误至关重要。看到这个值的增加或减少可能是代码质量的良好指标,尽管它们也可能表明一系列其他问题,无论是内部的还是外部的。

  • 消息队列大小:我们之前详细介绍了这一点,但膨胀的队列大小表明您的基础设施无法快速处理生成的数据,因此这个指标总是很有用的。

  • 连接吞吐量:准确了解您正在处理的数据量可以很好地指示服务负载。将其与其他收集的统计数据进行比较,还可以告诉您所见问题是内部还是外部造成的。

  • 服务延迟:仅仅因为没有故障并不意味着服务不可用。通过跟踪延迟,您可以详细了解需要改进的地方,或者哪些性能不符合您的期望。

  • 内核恐慌:虽然罕见但极其致命,内核恐慌可能对部署的服务造成严重影响。尽管监控这些情况相当棘手,但跟踪内核恐慌将在出现潜在的内核或硬件问题时向您发出警报,这将需要您开始解决。

显然,这并不是一个详尽的列表,但它涵盖了一些更有用的内容。随着基础设施的发展,您会发现在各处添加监控会更快地解决问题,并发现服务的可扩展性问题。因此,一旦将监控添加到基础设施中,不要害怕将其连接到系统的尽可能多的部分。最终,通过监控整个基础设施获得可见性和透明度,您可以做出更明智的决策并构建更好的服务,这正是我们想要的。

评估下一代技术

我个人感觉大多数关于容器(以及大多数其他技术主题)的文档和学习材料中都忽略了新兴技术的适当评估和风险评估。虽然选择一个基本有缺陷的音乐播放器的风险微不足道,但选择一个基本有缺陷的云技术可能会让你陷入多年的痛苦和开发中,而这些本来是你不需要的。随着云空间中工具的创建和发展速度飞快,良好的评估技术是你可能想要掌握的技能之一,因为它们可以在长远来看为你节省精力、时间和金钱。直觉很棒,但拥有一种坚实、可重复和确定性的评估技术的方式更有可能带来长期的成功。

请注意,尽管这里给出的建议对我和我职业生涯中接触过的其他人来说都有相当不错的记录,但你永远无法完全预测技术领域的发展方向,特别是当大多数科技初创公司可能随时关门(例如 ClusterHQ)时。因此,请记住,这些只是一些有趣的观点,而不是一个能让选择技术中最常见问题消失的神奇清单。

技术需求

这应该是一个非常明显的观点,但需要写下来。如果你需要一个工具提供的功能,而你又不想自己开发,那么你将别无选择,只能选择它并希望一切顺利。幸运的是,在大多数云技术和支持它们的工具模块中,通常至少有两个竞争对手在争夺相同的用户,所以事情并不像今天看起来那么可怕,尽管就在一年前,这个领域几乎所有东西的版本号都低于1.0。在评估竞争工具如何满足你的需求时,也要记住,即使它们解决了相同的问题,也并非每个工具都面向相同的目的。如果我们以当前的 Kubernetes 与 Marathon 为例,尽管它们都可以用来解决相同的服务部署问题,但 Kubernetes 主要面向单一目的,而 Marathon,例如,还可以用于调度和集群管理作为额外的功能,所以在谚语意义上,我们真的在比较苹果和橙子。

总的来说,你的服务基础设施需求将驱动你的工具需求,所以你通常不会最终使用你最喜欢的编程语言,拥有易于集成的接入点,或者使用一个合理的工具代码库,但集成一个可以节省数百或数千人时的工具绝不可轻视。有时可能通过改变系统架构的部分来完全规避技术要求,以避免给系统增加复杂性,但根据我的个人经验,这几乎从来不容易做到,所以你的情况可能有所不同。

流行度

这可能是考虑的最具争议性的维度之一,但也是处理新技术时要注意的最重要的维度之一。虽然绝对真实的是,流行并不等同于技术优点,但可以假设:

  • 更多使用特定工具的人将能够提供更好的集成帮助。

  • 更容易找到解决问题的方法。

  • 如果代码库是开源的,项目更有可能得到修复和功能的添加。

另一种描述这个问题的方式是,你能承担风险将几周/几个月/几年的集成工作投入到一个未经验证或在未来几年内可能被放弃的工具上吗?如果你是一个拥有庞大预算的大型企业,这可能不是一个问题,但在大多数情况下,你将没有机会尝试集成不同的竞争性技术,以找出最好的那个。虽然有时存在完全有效的情况,可以冒险尝试新工具,但由于云系统的复杂性和长期性,失败的代价非常高,因此一般建议采取务实的方法,但你的个人需求可能会有所不同,所以请相应选择。

要评估项目的这一方面,可以使用各种工具,但最简单和最容易的是 GitHub 项目的 forks/stars(对于开源项目)、Google 趋势(trends.google.com)预测,以及使用过该技术的人们的社交媒体反馈。通过观察这些价值的变化和转变,可以相对准确地推断长期的可行性,并结合对现有工具的比较,可以形成一个项目的总体脉搏的良好图景。上升的项目通常表明具有优越的技术基础,但在某些情况下,这是由于对现有工具的拒绝或大规模的营销推动,因此在评估工具时,不要总是认为流行的选项更好。

在上面的截图中,你可以看到 Kubernetes 的兴趣随时间的增加而明显增加,这在某种程度上反映了社区对该编排工具的采纳和接受。如果我们要自己实施这项技术,我们可以相当肯定,在一段时间内,我们将使用一种更容易使用并获得支持的工具。

当将 Kubernetes 与 Marathon 进行比较并使用相同的技术时,情况变得非常混乱,因为 Marathon 也是一种非常常见的长跑活动,因此结果会与不相关的谷歌查询混在一起。在下面的截图中,我们将结果与其他一些与云相关的关键词进行了叠加,你可以看到我们的数据有些问题:

然而,看一下它们的 GitHub 页面右上角以及 forks/stars,我们可以看到它们的比较情况(3,483 stars 和810 forks 对比28,444 stars 和10,167 forks):

将上述 GitHub 页面与以下页面进行比较:

在这个特定的例子中,很难看到长期的趋势,我们已经提到这两个工具解决的问题不同,而且这两个工具的设置复杂性迥然不同,因此很难进行适当的评估。

在我们继续下一个维度之前,有一件非常重要的事情需要提到:对于不成熟的工具(这种情况比你想象的更有可能),一个常见且强烈推荐的风险缓解措施是,如果您的开发人员有能力并且被允许在相关的上游项目上修复错误和添加功能。如果一个工具非常适合您的基础架构,并且您可以投入开发资源,那么它是否受欢迎并不重要,只要您可以使其按照您满意的方式工作。

作为参考数据点,在开发云实施过程中,我所在的团队无数次发现了上游项目中的错误和问题,我们很快就修复了这些问题,同时也帮助了该软件的所有其他用户,而不是潜在地等待上游开发人员花时间来修复它们。如果可能的话,我强烈鼓励这种回馈贡献的方式应用到你的工作场所,因为它有助于整个项目的社区,并间接地防止由于未修复的错误而导致项目动力的丧失。

团队的技术能力

新的工具往往有一个很好的初始想法,但由于糟糕的执行或架构,很快就变成了难以维护且容易出现错误的意大利面代码。如果设计和实施保持高标准,您可以更有把握地确保不会出现意外的故障,或者至少可以更容易地找到和修复错误。核心项目开发人员的能力在这方面起着巨大作用,由于大多数较新的工具都是开源的,因此查看代码库在这方面通常会非常有帮助。

评估涉及各种技术和系统的项目几乎不可能制定确切的指导方针,但有一些红旗应该被视为对未来可能出现的工具问题的警告信号:

  • 缺乏测试:没有测试,代码是否有效的保证几乎被消除,您只能希望进行更改的开发人员在实现新功能时足够小心,并且他们没有破坏当前功能。在我的生活中,我只见过少数几个开发人员可以像测试工具一样留意所有边缘情况,但我不会抱太大希望,你正在调查的项目是否有这样的人。

  • 聪明的代码:有时,一个项目会有一个或多个开发人员更关心展示他们的技能,而不是项目的可维护性,他们几乎总是会把他们接触的文件变成只有他们自己能够处理的代码,导致将来在添加功能或修复错误时出现问题。几乎总是这种改变是单向的,经过足够长的时间后,通常会导致项目的死亡(根据我的经验,这种情况更常见)。

  • 长时间开放的关键错误数量较高:对于任何项目,总会有一个时刻,你会遇到一个必须尽快修复的关键错误,通过观察修复所需的时间趋势,你可以看出团队是否有能力快速解决问题,或者是否关注更广泛的社区。虽然更多是主观指标,但随着服务的概况或安全姿态的增加,它变得极为重要。

您还可以使用其他评估指标,例如:旧的未合并的拉取请求,任意关闭的错误报告等,只要您能正确了解代码库的质量概念。有了这些知识,您可以正确评估候选工具的未来可能性,以及您的基础架构如何随之发展。

摘要

到此为止,我们已经到达了我们书的结尾!在这一章中,我们涵盖了各种你需要通过积极的自动化、将事物分割成多个可用区,并为你的基础设施添加监控来将你的小型服务变成全球化的事物。由于云技术也相对年轻,我们更重要地包括了一些关于如何客观评估新兴工具的建议,以确保你的项目在可预见的未来工具生态系统变化中具有最大的成功可能性。通过假设未来会发生变化,并拥有处理这些变化的工具,我们可以准备好接受任何被扔向我们的东西。

posted @ 2024-05-06 18:32  绝不原创的飞龙  阅读(60)  评论(0编辑  收藏  举报