Python Docker 微服务实用指南(一)
原文:
zh.annas-archive.org/md5/50389059E7B6623191724DBC60F2DDF3译者:飞龙
前言
软件的演进意味着系统变得越来越庞大和复杂,使得一些传统的处理技术变得无效。近年来,微服务架构作为一种有效的处理复杂 Web 服务的技术,获得了广泛的认可,使更多的人能够在同一个系统上工作而不会相互干扰。简而言之,它创建了小型的 Web 服务,每个服务解决一个特定的问题,并通过明确定义的 API 进行协调。
在本书中,我们将详细解释微服务架构以及如何成功运行它,使您能够在技术层面上理解架构以及理解架构对团队和工作负载的影响。
对于技术方面,我们将使用精心设计的工具,包括以下内容:
-
Python,用于实现 RESTful Web 服务
-
Git源代码控制,跟踪实现中的更改,以及GitHub,共享这些更改
-
Docker容器,以标准化每个微服务的操作
-
Kubernetes,用于协调多个服务的执行
-
云服务,如 Travis CI 或 AWS,利用现有的商业解决方案来解决问题
我们还将涵盖在微服务导向环境中有效工作的实践和技术,其中最突出的是以下内容:
-
持续集成,以确保服务具有高质量并且可以部署
-
GitOps,用于处理基础设施的变更
-
可观察性实践,以正确理解实时系统中发生的情况
-
旨在改善团队合作的实践和技术,无论是在单个团队内还是跨多个团队之间
本书围绕一个传统的单体架构需要转移到微服务架构的示例场景展开。这个示例在《第一章》进行迁移-设计、计划、执行中有描述,并贯穿整本书。
本书适合对象
本书面向与复杂系统打交道并希望能够扩展其系统开发的开发人员或软件架构师。
它还面向通常处理已经发展到难以添加新功能并且难以扩展开发的单体架构的开发人员。本书概述了传统单体系统向微服务架构的迁移,提供了覆盖所有不同阶段的路线图。
本书涵盖的内容
《第一部分》微服务简介介绍了微服务架构和本书中将使用的概念。它还介绍了一个示例场景,贯穿全书。
《第一章》进行迁移-设计、计划、执行,探讨了单体架构和微服务之间的差异,以及如何设计和规划从前者到后者的迁移。
《第二部分》设计和操作单个服务-创建 Docker 容器,讨论了构建和操作微服务,涵盖了从设计和编码到遵循良好实践以确保其始终高质量的完整生命周期。
《第二章》使用 Python 创建 REST 服务,介绍了使用 Python 和高质量模块实现单个 Web RESTful 微服务。
《第三章》使用 Docker 构建、运行和测试您的服务,向您展示如何使用 Docker 封装微服务,以创建标准的、不可变的容器。
第四章《创建管道和工作流程》教你如何自动运行测试和其他操作,以确保容器始终具有高质量并且可以立即使用。
第三部分《使用多个服务:通过 Kubernetes 操作系统》转向下一个阶段,即协调每个单独的微服务,使它们作为一个整体在一致的 Kubernetes 集群中运行。
第五章《使用 Kubernetes 协调微服务》介绍了 Kubernetes 的概念和对象,包括如何安装本地集群。
第六章《使用 Kubernetes 进行本地开发》让您在本地 Kubernetes 集群中部署和操作您的微服务。
第七章《配置和保护生产系统》深入探讨了在 AWS 云中部署的生产 Kubernetes 集群的设置和操作。
第八章《使用 GitOps 原则》详细描述了如何使用 Git 源代码控制来控制 Kubernetes 基础设施定义。
第九章《管理工作流程》解释了如何在微服务中实现新功能,从设计和实施到部署到向世界开放的现有 Kubernetes 集群系统。
第四部分《生产就绪系统:使其在现实环境中运行》讨论了在现实集群中成功操作的技术和工具。
第十章《监控日志和指标》是关于监控活动集群的行为,以主动检测问题和改进。
第十一章《处理系统中的变更、依赖和秘密》关注如何有效地处理在集群中共享的配置,包括正确管理秘密值和依赖关系。
第十二章《跨团队协作和沟通》关注独立团队之间的团队合作挑战以及如何改善协作。
为了充分利用本书
本书使用 Python 进行编码,并假定读者能够熟练阅读这种编程语言,尽管不需要专家级水平。
本书中始终使用 Git 和 GitHub 进行源代码控制和跟踪更改。假定读者熟悉使用它们。
熟悉 Web 服务和 RESTful API 对于理解所呈现的不同概念是有用的。
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。
您可以按照以下步骤下载代码文件:
-
登录或注册www.packt.com。
-
选择“支持”选项卡。
-
单击“代码下载”。
-
在搜索框中输入书名并按照屏幕上的说明操作。
下载文件后,请确保使用以下最新版本解压或提取文件夹:
-
Windows 系统使用 WinRAR/7-Zip
-
Mac 系统使用 Zipeg/iZip/UnRarX
-
Linux 系统使用 7-Zip/PeaZip
本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Docker-for-Microservices-with-Python。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还有其他代码包,来自我们丰富的图书和视频目录,可在**github.com/PacktPublishing/** 上找到。去看看吧!
下载彩色图片
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:static.packt-cdn.com/downloads/9781838823818_ColorImages.pdf。
代码实例
您可以在此处查看本书的代码实例视频:bit.ly/34dP0Fm。
使用的约定
本书中使用了许多文本约定。
CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“这将生成两个文件:key.pem 和 key.pub,带有私钥/公钥对。”
代码块设置如下:
class ThoughtModel(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(50))
text = db.Column(db.String(250))
timestamp = db.Column(db.DateTime, server_default=func.now())
当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:
# Create a new thought
new_thought = ThoughtModel(username=username, text=text, timestamp=datetime.utcnow())
db.session.add(new_thought)
任何命令行输入或输出都以以下方式书写:
$ openssl rsa -in key.pem -outform PEM -pubout -out key.pub
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“从管理面板中选择系统信息。”
警告或重要说明会以这种方式出现。
提示和技巧会以这种方式出现。
第一部分:微服务简介
本节涵盖了书的第一章。它介绍了微服务架构以及它旨在解决的经典单体系统的问题。
第一章,迁移-设计、计划、执行,描述了单体系统的典型情况,它的问题,以及如何将其迁移到微服务可以提高开发速度和独立功能实现。它还提出了一项分步计划,以便从最初的独特单体迁移到多个 RESTful 微服务。它还介绍了使用 Docker 来将不同的微服务实现为容器。
在本节中,我们描述了本书中将用作示例系统的示例系统,以便真实地演示从单体应用到微服务架构的过程。
本节包括以下章节:
- 第一章,迁移-设计、计划和执行
第一章:迁移 - 设计、规划和执行
随着 Web 服务变得越来越复杂,软件服务公司的规模也在增长,我们需要新的工作方式来适应并提高变化的速度,同时确保高质量标准。微服务架构已经成为控制大型软件系统的最佳工具之一,得益于容器和编排器等新工具的支持。我们将首先介绍传统单体架构和微服务架构之间的区别,以及迁移到后者的优势。我们将介绍如何构建架构迁移以及如何计划成功完成这一困难的过程。
在本书中,我们将处理 Web 服务器服务,尽管一些想法也可以用于其他类型的软件应用程序,显然需要进行调整。单体/微服务架构与操作系统设计中的单体/微内核讨论有一些相似之处,包括 1992 年 Linus Torvalds 和 Andrew S. Tanenbaum 之间的著名辩论(www.oreilly.com/openbook/opensources/book/appa.html)。本章对工具相对中立,而接下来的章节将介绍具体的工具。
本章将涵盖以下主题:
-
传统的单体方法及其问题
-
微服务方法的特点
-
并行部署和开发速度
-
挑战和警示信号
-
分析当前系统
-
通过测量使用情况进行准备和调整
-
分解单体的战略规划
-
执行迁移
在本章的结尾,您将熟悉我们将在整本书中使用的基本概念,不同的策略,以及在迁移到微服务期间如何进行和构建工作的实际示例。
技术要求
本章不专注于特定技术,而采用更中立的方法。我们将讨论一个 Python Django 应用程序作为我们单体示例。
单体示例可以在以下位置找到:github.com/PacktPublishing/Hands-On-Docker-for-Microservices-with-Python/tree/master/Chapter01/Monolith。安装和运行说明可以在其README.md文件中找到。
传统的单体方法及其问题
在开发系统时,传统的软件方法是创建一个单体。这是一个花哨的词,意思是“包含一切的单一元素”,这几乎是每个项目开始的方式。在 Web 应用程序的上下文中,这意味着创建可部署的代码,可以复制,以便请求可以指向任何已部署的副本:
毕竟,每个项目都会从小处开始。在早期进行严格的划分是不方便的,甚至没有意义。新创建的项目很小,可能由单个开发人员处理。虽然设计可以适合几个人的头脑,但在系统的各个部分之间进行严格的界限是适得其反的。
有很多运行 Web 服务的选项,但通常会包括一个或多个服务器(物理服务器、虚拟机和云实例,如 EC2 等),运行 Web 服务器应用程序(如 NGINX 或 Apache)来将请求指向 HTTP 端口80或 HTTPS 端口443,指向一个或多个 Python 工作进程(通常通过 WSGI 协议),由mod_wsgi运行 - github.com/GrahamDumpleton/mod_wsgi(仅限 Apache)、uWSGI、GNUnicorn 等。
如果使用多台服务器,将会有一个负载均衡器来在它们之间分配负载。我们将在本章后面讨论它们。服务器(或负载均衡器)需要在互联网上可访问,因此它将拥有专用的 DNS 和公共 IP 地址。
在其他编程语言中,结构将是类似的:一个前端 web 服务器公开端口以进行 HTTP/HTTPS 通信,以及在专用 web 工作人员中运行单体代码的后端。
但事情会改变,成功的软件会增长,经过一段时间,拥有一大堆代码可能不是构建大型项目的最佳方式。
单体应用在任何情况下都可以有内部结构,这意味着它们不一定会变成意大利面代码。它可能是完全结构化的代码。定义单体应用的是需要将系统作为一个整体部署,而不能进行部分部署。
意大利面代码是指缺乏任何结构且难以阅读和理解的代码的常见方式。
随着单体应用的增长,一些限制将开始显现:
-
代码将增加:没有模块之间的严格边界,开发人员将开始遇到理解整个代码库的问题。尽管良好的实践可以帮助,但复杂性自然倾向于增加,使得在某些方面更改代码变得更加困难,并增加微妙的 bug。运行所有测试将变得缓慢,降低任何持续集成系统的速度。
-
资源利用效率低下:每个部署的 web 工作人员都需要整个系统工作所需的所有资源,例如,任何类型的请求所需的最大内存,即使需要大量内存的请求很少,只需要几个工作人员就足够了。CPU 也可能出现相同的情况。如果单体应用连接到数据库,每个工作人员都需要连接到它,无论是否经常使用等等。
-
开发可扩展性问题:即使系统被设计成可以水平扩展(可以添加无限数量的新工作人员),随着系统和开发团队的增长,开发将变得越来越困难,而不会相互干扰。一个小团队可以轻松协调,但一旦有几个团队在同一个代码库上工作,冲突的可能性就会增加。除非严格执行纪律,否则对团队的所有权和责任进行界定也可能变得模糊。无论如何,团队都需要积极协调,这会降低他们的独立性和速度。
-
部署限制:部署方法需要在团队之间共享,并且团队不能分别对每个部署负责,因为部署可能涉及多个团队的工作。部署问题将导致整个系统崩溃。
-
技术的相互依赖:任何新技术都需要与单体应用中使用的技术相匹配。例如,一个对特定问题非常适合的工具可能很难添加到单体应用中,因为技术不匹配。更新依赖项也可能会导致问题。例如,更新到 Python 的新版本(或子模块)需要与整个代码库一起运行。一些必要的维护任务,如安全补丁,可能会导致问题,因为单体应用已经使用了特定版本的库,如果更改将会破坏。适应这些变化也需要额外的工作。
-
系统的一小部分出现 bug 可能导致整个服务崩溃:由于服务是一个整体,任何影响稳定性的关键问题都会影响到一切,使得难以制定高质量的服务策略或导致结果下降。
正如您在示例中所看到的,大多数单体问题都是逐渐增长的问题。除非系统有相当大的代码库,否则它们并不真正重要。在单体系统中有一些非常有效的东西,比如,由于代码中没有边界,代码可以被迅速高效地改变。但随着团队的壮大,越来越多的开发人员在系统中工作,边界有助于定义目标和责任。过度的灵活性在长期内会成为问题。
微服务方法的特点
单体方法适用,直到它不适用为止。但是,替代方案是什么?这就是微服务架构进入场景的地方。
遵循微服务架构的系统是一组松散耦合的专门化服务,它们协同工作以提供全面的服务。让我们稍微分解一下这个定义,更具体地说:
-
一组专门的服务,意味着有不同的、明确定义的模块。
-
松散耦合,意味着每个微服务都可以独立部署。
-
它们协同工作——每个微服务都能够与其他微服务通信。
-
提供全面的服务,因为我们的微服务系统将需要复制使用单体方法可用的相同功能。这是其设计背后的意图。
与之前的图表相比,微服务架构将如下所示:
每个外部请求将被引导到微服务 A或微服务 B,每个微服务专门处理一种特定类型的请求。在某些情况下,微服务 B与微服务 C通信,而不是直接对外可用。请注意,每个微服务可能有多个工作人员。
这种架构有几个优势和含义:
- 如果微服务之间的通信是通过标准协议进行的,那么每个微服务可以用不同的语言编程。
在整本书中,我们将使用 HTTP 请求,并使用 JSON 编码的数据在微服务之间进行通信。虽然还有更多的选择,但这绝对是最标准和广泛使用的选项,因为几乎每种广泛使用的编程语言都对其有很好的支持。
这在某些情况下非常有用,比如专门的问题需要专门的语言,但限制其使用,使其受控,不需要公司进行 drastical 的变化。
-
更好的资源利用——如果微服务 A需要更多的内存,我们可以减少工作人员的副本数量。而在单体系统中,每个工作人员都需要最大的资源分配,现在每个微服务只使用其所需的整个系统部分的资源。也许其中一些不需要连接到数据库,例如。每个单独的元素都可以进行微调,甚至在硬件级别。
-
每个单独的服务都更小,可以独立处理。这意味着更少的代码需要维护,更快的构建,更简单的设计,更少的技术债务需要维护。服务之间没有依赖问题,因为每个服务都可以自己定义和移动它们的步伐。进行重构可以以更受控的方式进行,因为它们不会影响整个系统的完整性。此外,每个微服务都可以更改其编程语言,而不会影响其他微服务。
从某种角度来看,微服务架构类似于 UNIX 哲学,应用于 Web 服务:编写每个程序(服务)来做一件事,并且做得很好,编写程序(服务)来协同工作,编写程序(服务)来处理文本流(HTTP 调用),因为这是一个通用接口。
-
一些服务可以隐藏不对外部访问。例如,微服务 C只能被其他服务调用,而不能被外部访问。在某些情况下,这可以提高安全性,减少对敏感数据或服务的攻击面积。
-
由于系统是独立的,一个系统中的稳定问题不会完全停止整个系统。这减少了关键响应并限制了灾难性故障的范围。
-
每个服务可以由不同的开发人员独立维护。这允许并行开发和部署,增加了公司可以完成的工作量。这要求暴露的 API 是向后兼容的,我们将在后面描述。
Docker 容器
微服务架构对支持它的平台非常不可知。它可以部署在专用数据中心中的旧物理盒子上,也可以在公共云中或以容器化形式部署。
然而,有一种倾向是使用容器来部署微服务。容器是一种软件的打包捆绑,封装了运行所需的一切,包括所有依赖项。它只需要兼容的操作系统内核来自主运行。
Docker 是 Web 应用程序容器的主角。它有一个非常充满活力的社区支持,以及用于处理各种操作的出色工具。我们将学习如何使用 Docker 进行工作和操作。
第一次使用 Docker 容器时,它们对我来说看起来像一种轻量级虚拟机;一个不需要模拟硬件即可运行的小型操作系统。但过了一段时间,我意识到这不是正确的方法。
描述容器的最佳方式是将其视为被自己的文件系统包围的进程。您运行一个进程(或几个相关的进程),它们看到一个完整的文件系统,不被任何人共享。
这使得容器非常易于移植,因为它们与运行它们的底层硬件和平台分离;它们非常轻量级,因为只需要包含最少量的数据,它们是安全的,因为容器的暴露攻击面非常小。您不需要像在传统服务器上那样管理它们的应用程序,比如sshd服务器,或者像 Puppet 这样的配置工具。它们是专门设计成小而单一用途的。
特别是,尽量保持您的容器小而单一用途。如果最终添加了几个守护程序和大量配置,那么很可能您试图包含太多;也许您需要将其拆分成几个容器。
使用 Docker 容器有两个步骤。首先,我们构建容器,对文件系统执行一层又一层的更改,比如添加将要执行的软件和配置文件。然后,我们执行它,启动它的主要命令。我们将在第三章中看到如何做到这一点,将服务 Docker 化。
微服务架构与 Docker 容器的一些特征非常契合——小型、单一用途的元素,通过 HTTP 调用进行通信。这就是为什么尽管这不是一个硬性要求,但这些天它们通常一起呈现的原因。
十二要素应用原则是一系列在开发 Web 应用程序中被证明成功的实践,它们也与 Docker 容器和微服务架构非常契合。其中一些原则在 Docker 中非常容易遵循,我们将在本书后面深入讨论它们。
处理容器的一个重要因素是容器应该是无状态的(Factor VI—12factor.net/processes)。任何状态都需要存储在数据库中,每个容器都不存储持久数据。这是可扩展的 Web 服务器的关键元素之一,当涉及到几台服务器时,可能无法完成。请务必记住这一点。
Docker 的另一个优势是有大量现成的容器可用。Docker Hub(hub.docker.com/)是一个充满有趣容器的公共注册表,可以继承或直接使用,无论是在开发还是生产中。这有助于您为自己的服务提供示例,并快速创建需要很少配置的小型服务。
容器编排和 Kubernetes
尽管 Docker 提供了如何处理每个单独微服务的方法,但我们需要一个编排器来处理整个服务集群。为此,我们将在整本书中使用 Kubernetes(kubernetes.io/)。这是主要的编排项目,并且得到了主要云供应商的大力支持。我们将在第五章中详细讨论它,使用 Kubernetes 协调微服务。
并行部署和开发速度
最重要的元素是能够独立部署。创建成功的微服务系统的第一条规则是确保每个微服务尽可能独立地运行。这包括开发、测试和部署。
这是允许不同团队并行开发的关键因素,使它们能够扩展工作。这增加了复杂系统变更的速度。
负责特定微服务的团队需要能够在不中断其他团队或服务的情况下部署微服务的新版本。目标是增加部署次数和每次部署的速度。
微服务架构与持续集成和持续部署原则密切相关。小型服务易于保持最新状态和持续构建,以及在不中断的情况下部署。在这方面,CI/CD 系统倾向于采用微服务,因为它增加了并行化和交付速度。
由于微服务的部署对于依赖服务应该是透明的,因此应特别注意向后兼容性。一些更改需要逐步升级并与其他团队协调,以删除旧的、不正确的功能,而不会中断系统。
尽管在理论上,完全断开的服务是可能的,但在实践中并不现实。一些服务之间会存在依赖关系。微服务系统将迫使您在服务之间定义明确的边界,并且任何需要跨服务通信的功能都将带来一些额外的开销,甚至可能需要协调不同团队的工作。
转向微服务架构时,这一举措不仅仅是技术上的改变,还意味着公司工作方式的重大变革。微服务的开发将需要自治和结构化的沟通,这需要在系统的总体架构规划中提前付出额外的努力。在单体系统中,这可能是临时的,并且可能已经演变成一个内部结构不太分离的结构,增加了纠缠的代码和技术债务的风险。
清晰沟通和定义所有者的需求不言而喻。目标是允许每个团队就其代码做出自己的决定,并规范和维护其他服务依赖的外部 API。
这种额外的规划增加了长期交付带宽,因为团队被授权做出更多自主决策,包括诸如使用哪种操作系统或编程语言等重大决策,以及使用第三方软件包、框架或模块结构等许多较小的决策。这增加了日常开发速度。
微服务架构也可能影响组织中团队的结构。一般规则是要尊重现有的团队。他们会有非常有用的专业知识,而彻底革命会破坏这一点。但可能需要进行一些微调。一些概念,比如理解 Web 服务和 RESTful 接口,需要在每个微服务中出现,以及如何部署自己的服务的知识。
传统的团队划分方式是创建一个负责基础设施和任何新部署的运维团队,因为他们是唯一被允许访问生产服务器的人。微服务方法会干扰这一点,因为它需要团队能够控制自己的部署。在第五章中,使用 Kubernetes 协调微服务,我们将看到使用 Kubernetes 如何在这种情况下有所帮助,将基础设施的维护与服务的部署分离开来。
这也允许创建一种强烈的所有权感,因为团队被鼓励以自己喜欢的方式在自己的领域内工作,同时与其他团队一起在明确定义和结构化的边界内进行游戏。微服务架构可以允许在系统的小部分进行实验和创新,一旦证明有效,就可以在整个系统中传播。
挑战和红旗
我们已经讨论了微服务架构相对于单体应用的许多优势,但迁移是一项庞大的工程,不应该被低估。
系统开始时是单体应用,因为这样更简单,可以在小的代码库中进行更快的迭代。在任何新公司中,转变和改变代码,寻找成功的商业模式至关重要。这比清晰的结构和架构分离更重要——这就是应该的方式。
然而,一旦系统成熟,公司发展起来。随着越来越多的开发人员参与进来,单体应用的优势开始变得不那么明显,长期战略和结构的需求变得更加重要。更多的结构并不一定意味着向微服务架构迈进。一个良好架构的单体应用可以实现很多。
转向微服务也有自己的问题。其中一些是:
-
迁移到微服务需要大量的努力,积极改变组织的运作方式,并且需要大量的投资,直到开始见效。过渡可能会很痛苦,因为需要采取务实的方法,并需要做出妥协。这还将涉及大量的设计文件和会议来规划迁移,而业务仍在继续运营。这需要全面的承诺和对所涉及内容的理解。
-
不要低估文化变革——组织是由人组成的,人们不喜欢变化。微服务中的许多变化与不同的运营方式和不同的做事方式有关。虽然这赋予了不同的团队权力,但也迫使他们澄清他们的接口和 API,并形式化沟通和边界。这可能导致团队成员的挫折和抵制。
有一句叫康威定律的格言(www.melconway.com/Home/Conways_Law.html),它指出设计系统的组织受限于产生与这些组织的沟通结构相同的设计。对于微服务来说,这意味着团队之间的分工应该反映不同的服务。让多个团队在同一个微服务中工作会模糊界面。我们将在第十二章《跨团队协作和沟通》中详细讨论康威定律。
-
学习工具和程序也需要一定的学习曲线。管理集群与单体的方式不同,开发人员需要了解如何在本地进行测试时相互操作不同的服务。同样,部署也与传统的本地开发不同。特别是,学习 Docker 需要一些时间来适应。因此,要做好计划,并为所有参与者提供支持和培训。
-
调试跨服务的请求比单体系统更困难。监控请求的生命周期很重要,一些微妙的错误在开发中可能很难复制和修复。
-
将单体拆分为不同的服务需要仔细考虑。糟糕的划分线会使两个服务紧密耦合,不允许独立部署。这意味着几乎任何对一个服务的更改都需要对另一个服务进行更改,即使通常情况下可以独立完成。这会导致工作的重复,因为通常需要对单个功能进行更改和部署多个微服务。微服务以后可以进行变异,边界可以重新定义,但这是有成本的。在添加新服务时也应该采取同样的谨慎。
-
创建微服务存在一些额外开销,因为一些工作会在每个服务上复制。这种额外开销通过允许独立和并行开发来进行补偿。但是,要充分利用这一点,你需要数量。一个最多有 10 人的小型开发团队可以高效地协调和处理单体。只有当规模扩大并形成独立团队时,迁移到微服务才开始变得有意义。公司规模越大,这种做法就越有意义。
-
自由和允许每个团队做出自己的决定以及标准化一些共同的元素和决定之间需要保持平衡。如果团队缺乏方向,他们会一遍又一遍地重新发明轮子。他们也会最终创建知识孤岛,其中公司的某一部分的知识完全无法转移到另一个团队,这使得共同学习变得困难。团队之间需要良好的沟通,以便达成共识并重复使用共同的解决方案。允许受控实验,将其标记为实验,并让所有团队从中汲取教训,以使其他团队受益。共享和可重复使用的想法与独立的、多重实施的想法之间会产生紧张关系。
在引入跨服务共享代码时要小心。如果代码增长,它将使服务相互依赖。这可能会减少微服务的独立性。
-
遵循敏捷原则,我们知道,工作软件比广泛的文档更重要。然而,在微服务中,最大限度地提高每个单独微服务的可用性以减少团队之间的支持量是很重要的。这涉及一定程度的文档编制。最好的方法是创建自我记录的服务。我们将在本书的后面看一些例子,介绍如何使用工具来允许以最小的努力记录如何使用服务。
-
每次调用另一个服务,比如内部微服务相互调用,都会增加响应的延迟,因为将涉及多个层。这可能会产生延迟问题,外部响应时间更长。它们也会受到内部网络连接微服务的性能和容量的影响。
迁移到微服务应该谨慎进行,并仔细分析其利弊。在成熟的系统中,完成迁移可能需要数年的时间。但对于一个大型系统来说,结果系统将更加灵活和易于更改,使您能够有效地处理技术债务,并赋予开发人员充分的所有权和创新能力,构建沟通并提供高质量、可靠的服务。
分析当前系统
正如我们之前定义的,从单体迁移到一组微服务的第一步是了解当前系统。这个阶段不应该被低估。很可能没有一个人对单体的不同组件有很好的理解,特别是如果一些部分是遗留的。
这个阶段的目标是确定迁移到微服务是否真的有益,并初步了解迁移的结果将是什么样的微服务。正如我们所讨论的,迁移是一个巨大的投资,不应该轻率对待。在这个阶段无法对所需的工作量进行详细估计;此时的不确定性将会很大,但千里之行始于足下。
所涉及的工作将大大取决于单体的结构化程度。这可能从一团没有太多方向的有机生长的意大利面代码混乱到一个结构良好、模块化的代码库。
我们将在本书中使用一个示例应用程序——一个名为 MyThoughts 的微博网站,这是一个简单的服务,允许我们发布和阅读短消息或想法。该网站允许我们登录、发布新想法、查看我们的想法,并在系统中搜索想法。
作为第一步,我们将绘制单体的架构图。将当前系统简化为相互交互的块列表。
我们示例的代码在这里可用:github.com/PacktPublishing/Hands-On-Docker-for-Microservices-with-Python/tree/master/Chapter01/Monolith。这是一个使用 Bootstrap 作为其 HTML 界面的 Django 应用程序。查看README以获取运行说明。
在我们的示例中,MyThoughts 模型在以下图表中描述:
正如你所看到的,单体似乎遵循了模型视图控制器结构(www.codecademy.com/articles/mvc):
Django 使用一种称为模型模板视图的结构,它遵循与 MVC 类似的模式。阅读medium.com/shecodeafrica/understanding-the-mvc-pattern-in-django-edda05b9f43f上的文章以获取更多信息。它是否完全符合 MVC 是值得商榷的。让我们不要陷入语义,而是将定义作为描述系统的起点。
-
数据库中存储并通过模型访问的三个实体:用户、想法和会话模型。会话用于跟踪登录。
-
用户可以通过
login.py中的代码登录和退出以访问网站。如果用户登录,将创建一个会话,允许用户查看网站的其余部分。
请注意,此示例中身份验证和密码的处理仅用于演示目的。请使用 Django 中的默认机制以获得更安全的访问。会话也是一样,原生会话管理未被使用。
-
用户可以看到他们自己的想法。在同一页上,有一个新的表单可以创建一个新的想法。这由
thoughts.py文件处理,通过ThoughtModel检索和存储想法。 -
要搜索其他用户的想法,有一个搜索栏连接到
search.py模块并返回获取的值。 -
HTML 通过
login.html、search.html、list_thoughts.html和base.html模板呈现。 -
此外,还有样式网站的静态资产。
这个例子非常简单,但我们能够看到一些相互依赖:
-
静态数据非常隔离。它可以在任何时候更改,而无需在其他任何地方进行任何更改(只要模板与 Bootstrap 兼容)。
-
搜索功能与列出想法密切相关。模板相似,信息以相同的方式显示。
-
登录和注销不与
ThoughtModel交互。它们编辑会话,但应用程序的其余部分只读取那里的信息。 -
base.html模板生成顶部栏,并用于所有页面。
在进行这项分析之后,一些关于如何继续的想法浮现在脑海中:
-
保持现状,投资于结构化,但不将其拆分为多个服务。它已经有一定的结构,尽管有些部分可以改进。例如,处理用户是否已登录的方式可能会更好。这显然是一个小例子,在现实生活中,将其拆分为微服务将会产生很大的开销。请记住,坚持使用单体架构可能是一种可行的策略,但如果这样做,请投入时间来清理代码和偿还技术债务。
-
搜索想法非常基本。目前,我们直接搜索数据库。如果有数百万个想法,这将不是一个可行的选择。
search.py中的代码可以调用一个特定的搜索微服务,由 Solr(lucene.apache.org/solr/)或 Elasticsearch(www.elastic.co/products/elasticsearch)支持的搜索引擎。这将扩展搜索,并可以添加诸如在日期之间搜索或显示文本匹配等功能。搜索也是只读的,因此将创建新想法的调用与搜索它们的调用分离可能是一个好主意。 -
身份验证也是与阅读和编写想法不同的问题。拆分它将使我们能够跟踪新的安全问题,并有一个专门处理这些问题的团队。从应用程序的角度来看,它只需要有一个可用于检查用户是否已登录的东西,这可以委托给一个模块或包。
-
前端目前非常静态。也许我们想创建一个单页面应用程序,调用后端 API 在客户端渲染前端。为此,需要创建一个能够返回想法和搜索元素的 RESTful API 微服务。前端可以使用 JavaScript 框架编码,例如 Angular(
angular.io)或 React(reactjs.org/)。在这种情况下,新的微服务将成为前端,将作为静态的预编译代码提供,并将从后端拉取。 -
RESTful API 后端也将可用于允许外部开发人员在 MyThoughts 数据之上创建自己的工具,例如创建原生手机应用程序。
这只是一些想法,需要讨论和评估。对于您的单片应用程序来说,具体的痛点是什么?路线图和战略未来是什么?现在或未来最重要的点和功能是什么?也许对于某家公司来说,拥有强大的安全性是优先考虑的,第 3 点至关重要,但对于另一家公司来说,第 5 点可能是与合作伙伴合作的扩展模型的一部分。
团队的结构也很重要。第 4 点将需要具有良好的前端和 JavaScript 技能的团队,而第 2 点可能涉及后端优化和数据库工作,以允许对数百万条记录进行高效搜索。
在这里不要过快地得出结论;考虑一下什么样的能力是可行的,您的团队可以实现什么。正如我们之前讨论过的,转变为微服务需要一定的工作方式。与相关人员核实他们的反馈和建议。
经过一些考虑,对于我们的示例,我们提出以下潜在架构:
系统将分为以下模块:
-
Users backend: 这将负责所有身份验证任务,并保留有关用户的信息。它将在数据库中存储其数据。
-
Thoughts backend: 这将创建和存储thoughts。
-
Search backend: 这将允许搜索thoughts。
-
一个代理将任何请求路由到适当的后端。这需要是外部可访问的。
-
HTML frontend: 这将复制当前的功能。这将确保我们以向后兼容的方式工作,并且过渡可以顺利进行。
-
允许客户端访问后端将允许创建除我们的 HTML 前端之外的其他客户端。将创建一个动态前端服务器,并且正在与外部公司讨论创建移动应用程序的事宜。
-
Static assets: 能够处理静态文件的 Web 服务器。这将为 HTML 前端提供样式和动态前端的索引文件和 JavaScript 文件。
这种架构需要适应实际使用;为了验证它,我们需要测量现有的使用情况。
通过测量来准备和适应。
显然,任何真实世界的系统都会比我们的示例更复杂。通过仔细观察,代码分析能够发现的内容是有限的,而计划往往在接触真实世界时无法生存。
任何划分都需要经过验证,以确保它将产生预期的结果,并且付出的努力是值得的。因此,请仔细检查系统是否按您认为的方式运行。
了解实时系统运行情况的能力被称为可观测性。它的主要工具是指标和日志。您将发现的问题是,它们通常会配置为反映外部请求,并且不提供有关内部模块的信息。我们将在第十章中深入讨论系统的可观测性,监控日志和指标。您可以参考它以获取更多信息,并在此阶段应用那里描述的技术。
如果您的系统是一个网络服务,默认情况下,它将已激活其访问日志。这将记录系统中进入的每个 HTTP 请求,并存储 URL、结果和发生时间。与您的团队核实这些日志的位置,因为它们将提供关于调用哪些 URL 的良好信息。
尽管这种分析可能只会提供有关被调用的外部端点的信息,但对于根据我们的计划将被分割为不同微服务的内部模块,它不会提供太多信息。请记住,对于微服务长期成功的最重要因素是允许团队独立。如果您跨模块进行分割,而这些模块需要不断协同变更,部署将不会真正独立,并且在过渡后,您将被迫使用两个紧密耦合的服务。
特别要小心的是,不要创建一个对每个其他服务都是依赖的微服务。除非该服务非常稳定,否则当任何其他服务需要新功能时,可能会频繁更新。
为了验证新的微服务不会紧密耦合,让团队了解这些分割以及他们周围的接口需要多久改变一次。在接下来的几周内监控这些变化,确保分割线是稳定的,不需要不断变化。如果微服务之间的接口被频繁更改,任何功能都将需要在多个服务中进行多次更改,这将减缓交付新功能的速度。
在我们的示例中,经过分析提出的架构后,我们决定简化设计,如图所示:
在监控和与团队交流之后,已经做出了一些决定:
-
团队对 JavaScript 动态编程的了解不够。在同时进行前端变更和转向微服务的情况下,被视为过于冒险。
-
另一方面,外部移动应用被视为公司的战略举措,使外部可访问的 API 成为一个可取的举措。
-
分析日志,似乎搜索功能并不经常使用。搜索次数的增长很小,将搜索拆分为独立的服务将需要与 Thoughts 后端进行协调,因为这是一个积极开发的领域,正在添加新字段。决定将搜索保留在 Thoughts 后端,因为它们都与相同的 Thoughts 一起工作。
-
用户后端已经得到了良好的接受。这将通过明确负责修补安全漏洞和改进服务的所有权来提高身份验证的安全性。其余的微服务将需要独立工作,并由用户后端进行验证,这意味着负责这个微服务的团队将需要创建和维护一个包,其中包含验证请求的信息。
一旦我们决定了最终状态,我们仍然需要决定如何从一个状态转移到另一个状态。
分解单体的战略规划
正如我们之前讨论过的,从初始状态到期望状态的转变将是一个缓慢的过程。不仅因为它涉及到新的工作方式,而且还因为它将与其他“业务如常”的功能和发展并行进行。实际上,公司的业务活动不会停止。因此,应该制定一个计划,以便在一个状态和另一个状态之间实现平稳过渡。
这被称为窒息模式(docs.microsoft.com/en-us/azure/architecture/patterns/strangler)-逐渐替换系统的部分,直到旧系统被“窒息”,可以安全地移除。
有几种技术方法可以选择,以进行转变并将每个元素分割以迁移到新系统:
-
替换方法,将旧代码替换为全新编写的新服务
-
分割方法,挑选现有代码并将其移入全新的服务
-
两者的结合
让我们更仔细地看一看。
替换方法
大块地替换服务,只考虑它们的外部接口或影响。这种黑盒方法完全用从头开始的替代功能编码替换现有功能。一旦新代码准备就绪,它就会被激活,旧系统中的功能就会被弃用。
请注意,这并不是指替换整个系统的单个部署。这可以部分地、一块一块地完成。这种方法的基础是创建一个新的外部服务,旨在取代旧系统。
这种方法的优点在于它极大地有助于构建新服务,因为它不会继承技术债务,并且可以以新的视角审视旧问题。
新服务还可以使用新工具,并且不需要继续使用与公司技术未来方向战略观点不一致的旧技术栈。
这种方法的问题在于成本可能很高,而且可能需要很长时间。对于未经记录的旧服务,替换它们可能需要大量的工作。此外,这种方法只能应用于稳定的模块;如果它们在积极开发中,试图用其他东西替换它们就会不断改变目标。
这种方法对于小型的旧遗留系统或者至少有一小部分执行有限功能的系统来说是最合理的,而且这些系统是使用难以维护的旧技术栈开发的,或者已不再被认为是可取的。
分割的方法
如果系统结构良好,也许它的一些部分可以干净地分割成自己的系统,保持相同的代码。
在这种情况下,创建一个新服务更多地是一个复制粘贴的练习,并用最少量的代码包装它,以使其能够独立执行并与其他系统进行交互,换句话说,以 HTTP 请求为基础来构建其 API 以获得标准接口。
如果可以使用这种方法,这意味着代码已经相当结构化,这是个好消息。
被调用到这一部分的系统也必须进行调整,不是调用内部代码,而是通过 HTTP 调用。好处是这可以分几步完成:
-
将代码复制到自己的微服务中并部署它。
-
旧的调用系统正在使用旧的嵌入式代码。
-
迁移一个调用并检查系统是否正常工作。
-
迭代,直到所有旧的调用都迁移到新系统。
-
从旧系统中删除分割的代码。
如果代码结构不太干净,我们需要先进行更改。
更改和结构化方法
如果单体系统是有机增长的,那么它的所有模块都不太可能是干净的结构化。可能存在一些结构,但也许它们并不是我们期望的微服务划分的正确结构。
为了适应服务,我们需要进行一些内部更改。这些内部更改可以进行迭代,直到服务可以被干净地分割。
这三种方法可以结合起来进行完整的迁移。每种方法所涉及的工作量并不相同,因为一个易于分割的服务将能够比替换文档不完整的遗留代码更快地进行迁移。
在项目的这个阶段,目标是拥有一个清晰的路线图,应该分析以下元素:
-
一个有序的计划,确定哪些微服务将首先可用,考虑如何处理依赖关系。
-
了解最大的痛点是什么,以及是否解决它们是一个优先事项。痛点是经常处理的元素,而目前处理单体系统的方式使它们变得困难。
-
有哪些困难点和棘手的问题?很可能会有一些。承认它们的存在,并将它们对其他服务的影响最小化。请注意,它们可能与痛点相同,也可能不同。困难点可能是非常稳定的旧系统。
-
一些快速的成功案例将保持项目的动力。快速向您的团队和利益相关者展示优势!这也将使每个人都能够理解您想要转移到的新操作模式并开始以这种方式工作。
-
团队需要的培训和您想要引入的新元素的想法。此外,您的团队是否缺乏任何技能——您可能计划招聘。
-
任何团队变化和对新服务的所有权。重要的是要考虑团队的反馈,这样他们就可以表达他们对计划制定过程中任何疏忽的担忧。
对于我们的具体示例,结果计划如下:
-
作为先决条件,负载均衡器需要位于操作的前面。这将负责将请求引导到适当的微服务。然后,更改这个元素的配置,我们将能够将请求路由到旧的单体或任何新的微服务。
-
之后,静态文件将通过它们自己独立的服务提供,这是一个简单的更改。一个静态的 Web 服务器就足够了,尽管它将部署为一个独立的微服务。这个项目将有助于理解转移到 Docker。
-
身份验证的代码将被复制到一个新的服务中。它将使用 RESTful API 进行登录和生成会话,以及注销。该服务将负责检查用户是否存在,以及添加和删除他们:
-
最初的想法是针对每个检索到的会话进行检查,但是,鉴于检查会话是一个非常常见的操作,我们决定生成一个包,在外部面向的微服务之间共享,这将允许检查会话是否已经使用我们自己的服务生成。这将通过对会话进行加密签名并在我们的服务之间共享密钥来实现。预计这个模块不会经常更改,因为它是所有微服务的依赖项。这使得会话不需要存储。
-
用户后端需要能够使用 OAuth 2.0 模式进行身份验证,这将允许其他不基于 Web 浏览器的外部服务进行身份验证和操作,例如移动应用程序。
-
Thoughts 后端也将作为 RESTful API 进行复制。这个后端目前非常简单,它将包括搜索功能。
-
在两个后端都可用之后,当前的单体将被更改,从直接调用数据库到使用后端的 RESTful API。成功完成后,旧的部署将被 Docker 构建替换,并添加到负载均衡器。
-
新的 API 将被添加到负载均衡器并作为外部可访问的推广。制作移动应用程序的公司将开始集成他们的客户端。
我们的新架构图如下:
请注意,HTML 前端将使用与外部可用的相同的 API。这将验证调用是否有用,因为我们将首先为我们自己的客户使用它们。
这个行动计划可以有可衡量的时间和日程安排。还可以采取一些技术选项——在我们的情况下,如下:
-
每个微服务将部署在自己的 Docker 容器中(
www.docker.com/)。我们将建立一个 Kubernetes 集群来编排不同的服务。 -
我们决定使用 Flask(
palletsprojects.com/p/flask/)制作新的后端服务,使用 Flask-RESTPlus(flask-restplus.readthedocs.io/en/stable/)生成一个文档完备的 RESTful 应用,并使用 SQLAlchemy(www.sqlalchemy.org/)连接到现有的数据库。这些工具都是 Python 编写的,但比 Django 更轻量级。 -
后端服务将使用 uWSGI 服务器提供(
uwsgi-docs.readthedocs.io/en/latest/)。 -
静态文件将使用 NGINX 提供(
www.nginx.com/)。 -
NGINX 也将用作负载均衡器来控制输入。
-
HTML 前端将继续使用 Django (
www.djangoproject.com/)。
团队们同意继续使用这些技术栈,并期待学习一些新技巧!
执行移动
最后一步是执行精心设计的计划,开始从过时的单体架构向新的微服务乐土迁移!
但这个阶段可能是最长和最困难的,特别是如果我们希望保持服务运行而不会出现中断业务的情况。
在这个阶段最重要的想法是向后兼容。这意味着系统在外部看来仍然像旧系统一样运行。如果我们能够做到这一点,我们就可以在客户继续无中断操作的情况下透明地改变我们的内部操作。
这显然更容易说而不易做,有时被称为用福特 T 型车开始比赛,最后用法拉利结束,而不停下来更换每一个零件。好消息是,软件是如此灵活和可塑的,实际上是可能的。
Web 服务的好朋友 - 负载均衡器
负载均衡器是一种工具,允许将 HTTP 请求(或其他类型的网络请求)分配给多个后端资源。
负载均衡器的主要操作是允许将流量定向到单个地址,然后分发到几个相同的后端服务器,以分担负载并实现更好的吞吐量。通常,流量将通过轮询方式分发,即依次分配到所有服务器上:
首先一个工作进程,然后是另一个,依次类推:
这是正常的操作。但它也可以用来替换服务。负载均衡器确保每个请求都干净地发送到一个工作进程或另一个。工作进程池中的服务可以是不同的,因此我们可以使用它来干净地在 Web 服务的一个版本和另一个版本之间进行过渡。
对于我们的目的,一个老的 Web 服务组在负载均衡器后面可以添加一个或多个向后兼容的替代服务,而不会中断操作。替换旧服务的新服务将以较小的数量(也许是一个或两个工作进程)添加到合理的配置中,确保一切都按预期工作。验证后,通过停止向旧服务发送新请求,排空它们,只留下新服务器来完全替换它。
如果以快速的方式进行,就像部署服务的新版本一样,这被称为滚动更新,因此工作进程逐个替换。
但是对于从旧的单体架构迁移到新的微服务,更慢的步伐更明智。一个服务可以在 5%/95%的分裂中生存几天,因此任何意外错误只会出现五分之一的时间,然后转移到 33/66,然后 50/50,然后 100%迁移。
一个高负载的系统具有良好的可观测性,将能够非常快速地检测到问题,并且可能只需要等待几分钟就可以继续。但大多数传统系统可能不会属于这一类。
任何能够以反向代理模式运行的 Web 服务器,如 NGINX,都可以作为负载均衡器工作,但是,对于这项任务,可能最完整的选择是 HAProxy (www.haproxy.org/)。
HAProxy 专门用于在高可用性和高需求的情况下充当负载均衡器。它非常灵活,并且在必要时接受从 HTTP 到更低级别的 TCP 连接的流量。它还有一个出色的状态页面,将帮助监视通过它的流量,并采取快速行动,如禁用失败的工作进程。
云提供商如 AWS 或 Google 也提供集成的负载均衡器产品。它们非常适合从网络边缘工作,因为它们的稳定性使它们非常出色,但它们不会像 HAProxy 那样易于配置和集成到您的操作系统中。例如,亚马逊网络服务提供的产品称为弹性负载均衡(ELB)-aws.amazon.com/elasticloadbalancing/。
要从具有由 DNS 引用的外部 IP 的传统服务器迁移到前端放置负载均衡器,您需要遵循以下程序:
-
创建一个新的 DNS 来访问当前系统。这将允许您在过渡完成后独立地引用旧系统。
-
部署负载均衡器,配置为为旧 DNS 上的旧系统提供流量。这样,无论是访问负载均衡器还是旧系统,请求最终都将在同一位置交付。为负载均衡器创建一个专门的 DNS,以允许特别引用它。
-
测试向负载均衡器发送请求,指向旧 DNS 的主机是否按预期工作。您可以使用以下
curl命令发送请求:
$ curl --header "Host:old-dns.com" http://loadbalancer/path/
-
更改 DNS 指向负载均衡器 IP。更改 DNS 注册表需要时间,因为会涉及缓存。在此期间,无论请求从何处接收,都将以相同的方式处理。保持这种状态一两天,以确保每个可能的缓存都已过时并使用新的 IP 值。
-
旧 IP 不再使用。服务器可以(也应该)从外部网络中删除,只留下负载均衡器进行连接。需要访问旧服务器的任何请求都可以使用其特定的新 DNS。
请注意,像 HAProxy 这样的负载均衡器可以使用 URL 路径工作,这意味着它可以将不同的路径指向不同的微服务,这在从单体架构迁移中非常有用。
由于负载均衡器是单点故障,您需要对负载均衡器进行负载平衡。最简单的方法是创建几个相同的 HAProxy 副本,就像您对任何其他网络服务所做的那样,并在顶部添加一个云提供商负载均衡器。
因为 HAProxy 如此多才多艺和快速,当正确配置时,您可以将其用作重定向请求的中心点-真正的微服务风格!
保持新旧之间的平衡
计划只是计划,而转移到微服务是为了内部利益而做的事情,因为它需要投资,直到外部改进可以以更好的创新速度的形式显示出来。
这意味着开发团队将面临外部压力,要求在公司正常运营的基础上增加新功能和要求。即使我们进行这种迁移以加快速度,也会有一个初始阶段,您将移动得更慢。毕竟,改变事物是困难的,您需要克服最初的惯性。
迁移将经历三个大致阶段。
试点阶段-设置前两个微服务
在看到第一个部署之前可能需要很多基础设施。这个阶段可能很难克服,也是需要最大努力的阶段。一个好的策略是组建一个专门的新微服务架构团队,并允许他们领导开发。他们可以是参与设计的人,或者可能喜欢新技术或在副业项目中使用过 Docker 和 Kubernetes 的人。并不是你团队中的每个开发人员都会对改变运营方式感到兴奋,但其中一些人会。利用他们的热情开始项目,并在其初步阶段加以照顾:
-
从小开始 - 将有足够的工作来建立基础设施。这个阶段的目标是学习工具,建立平台,并调整如何使用新系统。团队合作和协调的方面很重要,从一个小团队开始可以让我们测试一些方法,并迭代以确保它们有效。
-
选择非关键服务。在这个阶段,有很多事情可能会出错。确保问题不会对运营或收入产生巨大影响。
-
确保保持向后兼容性。用新服务替换单体架构的部分,但不要试图同时改变行为,除非它们显然是错误。
如果有一个新功能可以作为新的微服务实现,那就抓住机会采用新方法,但要确保额外花费的时间或错误的风险是值得的。
巩固阶段 - 稳定迁移至微服务
在初始设置之后,其他团队开始采用微服务方式工作。这扩大了处理容器和新部署的人数,因此最初的团队需要给予他们支持和培训。
培训将是迁移项目的关键部分 - 确保分配足够的时间。虽然培训活动如研讨会和课程对于启动流程非常有用,但经验丰富的开发人员的持续支持是无价的。指定开发人员作为问题的联系点,并明确告诉他们,他们的工作是确保他们回答问题并帮助其他开发人员。让支持团队定期会面,分享对知识转移的关注和改进。
传播知识是这个阶段的主要重点之一,但还有另外两个:澄清和规范流程,以及保持迁移微服务的适当速度。
文档化标准将有助于提供清晰和方向。创建检查点,明确要求,以便非常清楚地知道何时一个微服务准备投入生产。创建适当的反馈渠道,以确保流程可以得到改进。
在这段时间里,迁移的速度可以加快,因为很多不确定性和问题已经得到解决;并且开发将同时进行。尽管可能需要做出妥协,但一定要保持动力并遵循计划。
最终阶段 - 微服务商店
单体架构已经拆分,架构现在是微服务。可能会有被认为优先级较低的单体架构残留部分。任何新功能都是以微服务方式实现的。
虽然理想情况下,从单体架构迁移绝对所有东西可能并不现实。有些部分可能需要很长时间才能迁移,因为它们特别难以迁移,或者涉及公司的奇怪角落。如果是这种情况,至少要清晰地定义边界并限制它们的行动范围。
这个阶段是团队可以完全拥有他们的微服务并开始进行测试和创新,比如改变编程语言。架构也可以改变,微服务可以分割或合并。明确界定微服务的约定要求,但在其中允许自由。
团队将会成熟稳定,流程会运行顺利。密切关注来自不同团队的好主意,并确保传播开来。
恭喜!你做到了!
总结
在本章中,我们看到了传统单体架构方法和微服务架构之间的区别,以及微服务如何使我们能够跨多个团队扩展开发,并改善高质量软件的交付。
我们讨论了从单体架构到微服务架构过渡中所面临的主要挑战,以及如何在不同阶段执行变更的方法:分析当前系统,测量以验证我们的假设,制定分割单体架构的计划,并成功执行迁移的策略。
尽管本章是以技术中立的方式编写的,但我们了解了为什么 Docker 容器是实现微服务的一种好方法,这将在接下来的章节中进行探讨。您现在也知道使用负载均衡器如何帮助保持向后兼容性并以不间断的方式部署新服务。
您学会了如何制定将单体架构分割为更小的微服务的计划。我们描述了这样一个过程的示例以及单体架构的示例以及如何分割它。我们将在接下来的章节中详细了解如何做到这一点。
问题
-
单体架构是什么?
-
单体架构的一些问题是什么?
-
描述微服务架构。
-
微服务的最重要特性是什么?
-
从单体架构迁移到微服务架构的主要挑战是什么?
-
做这样迁移的基本步骤是什么?
-
描述如何使用负载均衡器从旧服务器迁移到新服务器而不中断系统。
进一步阅读
您可以在书籍《架构模式》(www.packtpub.com/application-development/architectural-patterns)和《软件架构师手册》(www.packtpub.com/application-development/software-architects-handbook)中了解更多关于系统架构以及如何划分和构建复杂系统的知识。
第二部分:设计和操作单个服务-创建 Docker 容器
本节跨越三章,跟踪了单个微服务的创建过程。它从介绍在 Python 中实现的单个 REST 服务开始,继续完成将服务实现为一个独立的 Docker 容器的所有必要步骤,并创建管道以确保服务始终符合高质量标准。
本节的第一章描述了实现单个服务的过程,按照第一节中提出的示例进行。它描述了要实现的 API 接口,并使用 Python 生成了一个成熟的微服务,使用 Flask 和 SQLAlchemy 等工具来提高开发的便利性。该服务包括一个测试策略。
本节的第二章展示了如何将微服务封装在 Docker 容器中,以便代码可以在软件生命周期中以不可变的方式执行。介绍了基本的 Docker 使用方法,如构建和运行容器,使用环境变量以及如何执行测试。还描述了将容器共享到公共注册表的过程。
本节的第三章深入探讨了自动检查容器中引入的任何新代码是否符合基本质量准则,包括通过所有测试。它介绍了持续集成实践,并演示了如何在 Travis CI 中在云中创建一个管道,并将其集成到 GitHub 存储库中。本章还涵盖了如何自动将生成的容器推送到注册表中。
本节包括以下章节:
-
第二章,使用 Python 创建 REST 服务
-
第三章,使用 Docker 构建、运行和测试您的服务
-
第四章,创建管道和工作流程
第二章:使用 Python 创建 REST 服务
按照上一章的示例,我们将设计为单体的系统拆分为更小的服务。在本章中,我们将详细分析上一章中提到的一个微服务(Thoughts 后端)。
我们将讨论如何使用 Python 开发这个微服务作为一个应用程序。这个微服务将准备好通过标准的 Web RESTful 接口与其他微服务进行交互,这使得它成为我们全局微服务架构系统的基础。
我们将讨论不同的元素,如 API 设计,支持它的数据库模式,以及如何实现和如何实现微服务。最后,我们将看到如何测试应用程序,以确保它正常工作。
本章将涵盖以下主题:
-
分析 Thoughts 后端微服务
-
设计 RESTful API
-
定义数据库模式
-
实施服务
-
测试代码
在本章结束时,您将知道如何成功开发一个微服务应用程序,包括从设计到测试的不同阶段。
技术要求
Thoughts 后端示例可以在这里找到(github.com/PacktPublishing/Hands-On-Docker-for-Microservices-with-Python/tree/master/Chapter02/ThoughtsBackend)。安装和运行说明可以在其README.md文件中找到。
分析 Thoughts 后端微服务
让我们回顾一下我们在上一章中创建的微服务图表:
图表显示了我们示例系统的不同元素:两个后端,用户和想法,以及 HTML 前端。
Thoughts 后端将负责存储新的想法,检索现有的想法,并搜索数据库。
理解安全层
由于 Thoughts 后端将会对外开放,我们需要实现一个安全层。这意味着我们需要识别产生操作的用户并验证其有效性。在这个服务示例中,我们将从已登录的用户创建一个新的想法,并且我们将检索我的想法,以及当前已登录用户创建的想法。
请注意用户已登录也验证了用户的存在。
这个安全层将以一个头部的形式出现。这个头部将包含由用户后端签名的信息,验证其来源。它将采用 JSON Web Token (JWT),jwt.io/introduction/,这是一个标准的用途。
JWT 本身是加密的,但这里包含的信息大多只与检查已登录的用户相关。
JWT 并不是令牌的唯一可能性,还有其他替代方案,比如将等效数据存储在会话 cookie 中,或者在更安全的环境中使用类似的模块,比如 PASETO (github.com/paragonie/paseto)。确保您审查系统的安全影响,这超出了本书的范围。
这个方法应该由用户后端团队处理,并打包,以便其他微服务可以使用它。在本章中,我们将把代码包含在这个微服务中,但稍后我们将看到如何创建它,使其与用户后端相关联。
如果请求没有有效的头部,API 将返回 401 未经授权的状态码。
请注意,并非所有 API 端点都需要身份验证。特别是search不需要登录。
理解了认证系统的工作原理,我们可以开始设计 API 接口。
设计 RESTful API
我们将遵循 RESTful 设计原则来设计我们的 API。这意味着我们将使用构建的 URI 来表示资源,然后使用 HTTP 方法来对这些资源执行操作。
在这个示例中,我们将只使用GET(检索)、POST(创建)和DELETE(删除)方法,因为思想是不可编辑的。请记住,PUT(完全覆盖)和PATCH(执行部分更新)也是可用的。
RESTful API 的主要特性之一是请求需要是无状态的,这意味着每个请求都是完全独立的,可以由任何服务器提供。所有必需的数据应该在客户端(将其附加到请求中发送)或数据库中(因此服务器将完全检索它)。当处理 Docker 容器时,这个属性是一个硬性要求,因为它们可以在没有警告的情况下被销毁和重建。
虽然通常资源直接映射到数据库中的行,但这并非必需。资源可以是不同表的组合,其中的一部分,甚至完全代表不同的东西,例如满足某些条件的数据聚合,或者基于当前数据分析的预测。
分析服务的需求,不要受现有数据库设计的限制。迁移微服务是重新审视旧设计决策并尝试改进整个系统的好机会。还要记住十二要素应用原则(12factor.net/)来改进设计。
在设计 API 之前,最好先简要回顾一下 REST,这样您可以查看restfulapi.net/进行复习。
指定 API 端点
我们的 API 接口将如下:
| 端点 | 需要身份验证 | 返回 | |
|---|---|---|---|
GET | /api/me/thoughts/ | 是 | 用户的思想列表 |
POST | /api/me/thoughts/ | 是 | 新创建的思想 |
GET | /api/thoughts/ | 否 | 所有思想的列表 |
GET | /api/thoughts/X/ | 否 | ID 为X的思想 |
GET | /api/thoughts/?search=X | 否 | 搜索包含X的所有思想 |
DELETE | /admin/thoughts/X/ | 否 | 删除 ID 为X的思想 |
请注意 API 有两个元素:
-
一个公共 API,以
/api开头: -
一个经过身份验证的公共 API,以
/api/me开头。用户需要经过身份验证才能执行这些操作。未经身份验证的请求将返回 401 未经授权状态码。 -
一个非经过身份验证的公共 API,以
/api开头。任何用户,即使没有经过身份验证,也可以执行这些操作。 -
一个管理员 API(以
/admin开头)。这不会公开。它省去了身份验证,并允许您执行不是为客户设计的操作。明确地使用前缀标记有助于审计操作,并清楚地表明它们不应该在数据中心之外可用。
思想的格式如下:
thought
{
id integer
username string
text string
timestamp string($date-time)
}
要创建一个,只需要发送文本。时间戳会自动设置,ID 会自动创建,用户名会被身份验证数据检测到。
由于这只是一个示例,这个 API 被设计为最小化。特别是,可以创建更多的管理员端点来有效地模拟用户并允许管理员操作。DELETE操作是第一个包括的操作,用于清理测试。
最后一个细节:关于是否最好以斜杠结尾 URI 资源存在一些争论。然而,在使用 Flask 时,用斜杠定义它们将返回一个重定向状态码,308 PERMANENT_REDIRECT,对于没有正确结尾的请求。无论如何,尽量保持一致以避免混淆。
定义数据库模式
数据库模式简单,继承自单体。我们只关心存储在thought_model表中的想法,因此数据库结构如下:
| 字段 | 类型 | 注释 |
|---|---|---|
id | INTEGER NOT NULL | 主键 |
username | VARCHAR(50) | |
text | VARCHAR(250) | |
timestamp | DATETIME | 创建时间 |
thought_model 表
这个表在thoughts_backend/models.py文件中以 SQLAlchemy 格式表示,代码如下:
class ThoughtModel(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(50))
text = db.Column(db.String(250))
timestamp = db.Column(db.DateTime, server_default=func.now())
SQLAlchemy 能够为测试目的或开发模式创建表。在本章中,我们将数据库定义为 SQLite,它将数据存储在db.sqlite3文件中。
使用 SQLAlchemy
SQLAlchemy (www.sqlalchemy.org/)是一个强大的 Python 模块,用于处理 SQL 数据库。处理高级语言(如 Python)的数据库有两种方法。一种是保持低级方法,使用原始 SQL 语句,检索数据库中的数据。另一种是使用对象关系映射器(ORM)来抽象数据库,并在不涉及实现细节的情况下使用接口。
第一种方法由 Python 数据库 API 规范(PEP 249—www.python.org/dev/peps/pep-0249/)很好地代表,所有主要数据库都遵循这一规范,比如psycopg2 (initd.org/psycopg/)用于 PostgreSQL。这主要创建 SQL 字符串命令,执行它们,然后解析结果。这使我们能够定制每个查询,但对于重复的常见操作来说并不是很高效。PonyORM (ponyorm.org/)是另一个例子,它不那么低级,但仍旨在复制 SQL 语法和结构。
对于第二种方法,最著名的例子可能是 Django ORM (docs.djangoproject.com/en/2.2/topics/db/)。它使用定义的模型 Python 对象来抽象数据库访问。对于常见操作,它的工作效果非常好,但它的模型假设数据库的定义是在我们的 Python 代码中完成的,映射遗留数据库可能非常痛苦。ORM 创建的一些复杂 SQL 操作可能需要很长时间,而定制的查询可以节省大量时间。工具使我们与最终结果的关系如此之远,甚至可能不自觉地执行缓慢的查询。
SQLAlchemy (www.sqlalchemy.org/)非常灵活,可以在两端工作。它不像 Django ORM 那样直截了当或易于使用,但它允许我们将现有的数据库映射到 ORM。这就是为什么我们会在我们的示例中使用它:它可以接受现有的、复杂的遗留数据库并进行映射,使您可以轻松执行简单的操作和以您想要的方式执行复杂的操作。
请记住,我们将在本书中使用的操作非常简单,SQLAlchemy 在这些任务中不会特别突出。但是,如果您计划从通过手动编写 SQL 语句访问数据库的旧单体迁移到新创建的微服务,那么 SQLAlchemy 是一个无价的工具。如果您已经处理了一个复杂的数据库,花一些时间学习如何使用 SQLAlchemy 将是非常宝贵的。一个精心设计的 SQLAlchemy 定义可以非常高效地执行一些抽象任务,但它需要对工具有很好的了解。
Flask-SQLAlchemy 的文档(flask-sqlalchemy.palletsprojects.com/en/2.x/)是一个很好的起点,因为它总结了主要操作,而主要的 SQLAlchemy 文档一开始可能会让人感到不知所措。
在我们定义模型之后,我们可以通过模型中的query属性执行查询,并相应地进行过滤:
# Retrieve a single thought by its primary key
thought = ThoughtModel.query.get(thought_id)
# Retrieve all thoughts filtered by a username
thoughts = ThoughtModel.query.filter_by(username=username)
.order_by('id').all()
存储和删除一行需要使用会话,然后提交它:
# Create a new thought
new_thought = ThoughtModel(username=username, text=text, timestamp=datetime.utcnow())
db.session.add(new_thought)
db.session.commit()
# Retrieve and delete a thought
thought = ThoughtModel.query.get(thought_id)
db.session.delete(thought)
db.session.commit()
要查看如何配置数据库访问,请查看thoughts_backend/db.py文件。
实施服务
为了实现这个微服务,我们将使用 Flask-RESTPlus(flask-restplus.readthedocs.io/en/stable/)。这是一个 Flask(palletsprojects.com/p/flask/)的扩展。Flask 是一个著名的 Python 微框架,特别擅长实现微服务,因为它小巧、易于使用,并且与 Web 应用程序的常规技术栈兼容,因为它使用Web 服务器网关接口(WSGI)协议。
介绍 Flask-RESTPlus
Flask 能够实现 RESTful 接口,但 Flask-RESTPlus 添加了一些非常有趣的功能,可以支持良好的开发实践和快速开发:
- 它定义了命名空间,这是创建前缀和结构化代码的一种方式。这有助于长期维护,并在创建新的端点时有助于设计。
如果在单个命名空间中有超过 10 个端点,那么现在可能是考虑分割它的好时机。使用一个文件一个命名空间,并允许文件大小提示何时是一个尝试进行分割的好时机。
-
它有一个完整的解决方案来解析输入参数。这意味着我们有一种简单的方法来处理需要多个参数并验证它们的端点。使用请求解析(
flask-restplus.readthedocs.io/en/stable/parsing.html)模块类似于使用 Python 标准库中包含的argparse命令行模块(docs.python.org/3/library/argparse.html)。它允许在请求体、标头、查询字符串甚至 cookie 的参数中定义参数。 -
同样,它还有一个用于生成对象的序列化框架。Flask-RESTful 称之为响应编组(
flask-restplus.readthedocs.io/en/stable/marshalling.html)。这有助于定义可以重复使用的对象,澄清接口并简化开发。如果启用,它还允许字段掩码,返回部分对象。 -
它具有完整的 Swagger API 文档支持。Swagger(
swagger.io/)是一个开源项目,用于帮助设计、实现、文档化和测试 RESTful API Web 服务,遵循标准的 OpenAPI 规范。Flask-RESTPlus 自动生成了 Swagger 规范和自我记录页面:
Thoughts Backend API 的主要 Swagger 文档页面,自动生成
Flask 的其他好元素源自它是一个受欢迎的项目,并且有很多支持的工具:
-
我们将使用 SQLAlchemy 的连接器 Flask-SQLAlchemy(
flask-sqlalchemy.palletsprojects.com/en/2.x/)。它的文档涵盖了大多数常见情况,而 SQLAlchemy 的文档更详细,可能有点令人不知所措。 -
要运行测试,
pytest-flask模块(pytest-flask.readthedocs.io/en/latest/)创建了一些准备与 Flask 应用程序一起工作的固定装置。我们将在测试代码部分更多地谈论这个。
处理资源
典型的 RESTful 应用程序具有以下一般结构:
-
一个由 URL 定义的资源。这个资源允许通过 HTTP 方法(
GET,POST等)执行一个或多个操作。 -
每次调用这些操作时,框架都会路由请求,直到定义的代码执行操作。
-
如果有任何输入参数,它们将首先需要进行验证。
-
执行操作并获得结果值。此操作通常涉及对数据库的一个或多个调用,这将以模型的形式完成。
-
准备结果值并以客户端理解的方式进行编码,通常是 JSON 格式。
-
将编码值返回给客户端,并附上适当的状态码。
大多数这些操作都是由框架完成的。需要进行一些配置工作,但这就是我们的 Web 框架,例如在这个例子中的 Flask-RESTPlus,将提供最大的帮助。特别是除了步骤 4之外,其他都将大大简化。
让我们来看一个简单的代码示例(在 GitHub 上可用)来描述它:
api_namespace = Namespace('api', description='API operations')
@api_namespace.route('/thoughts/<int:thought_id>/')
class ThoughtsRetrieve(Resource):
@api_namespace.doc('retrieve_thought')
@api_namespace.marshal_with(thought_model)
def get(self, thought_id):
'''
Retrieve a thought
'''
thought = ThoughtModel.query.get(thought_id)
if not thought:
# The thought is not present
return '', http.client.NOT_FOUND
return thought
这实现了GET /api/thoughts/X/操作,通过 ID 检索单个想法。
让我们分析每个元素。请注意,行是按主题分组的。
- 首先,我们通过其 URL 定义资源。请注意,
api_namespace设置了 URL 的api前缀,这将验证参数X是一个整数:
api_namespace = Namespace('api', description='API operations')
@api_namespace.route('/thoughts/<int:thought_id>/')
class ThoughtsRetrieve(Resource):
...
-
该类允许您对同一资源执行多个操作。在这种情况下,我们只执行一个:
GET操作。 -
请注意,编码在 URL 中的
thought_id参数作为参数传递给该方法:
class ThoughtsRetrieve(Resource):
def get(self, thought_id):
...
- 现在我们可以执行该操作,这是在数据库中搜索以检索单个对象。调用
ThoughModel来搜索指定的想法。如果找到,将以http.client.OK (200)状态代码返回。如果未找到,则返回空结果和http.client.NOT_FOUND 404状态代码:
def get(self, thought_id):
thought = ThoughtModel.query.get(thought_id)
if not thought:
# The thought is not present
return '', http.client.NOT_FOUND
return thought
- 返回
thought对象。marshal_with装饰器描述了 Python 对象应如何序列化为 JSON 结构。稍后我们将看到如何配置它:
@api_namespace.marshal_with(thought_model)
def get(self, thought_id):
...
return thought
- 最后,我们有一些文档,包括由自动生成的 Swagger API 呈现的文档字符串:
class ThoughtsRetrieve(Resource):
@api_namespace.doc('retrieve_thought')
def get(self, thought_id):
'''
Retrieve a thought
'''
...
正如您所看到的,大多数操作都是通过 Flask-RESTPlus 配置和执行的,作为开发人员的主要工作是肉体的步骤 4。但是还有一些工作要做,例如配置预期的输入参数并验证它们,以及如何将返回的对象序列化为适当的 JSON。我们将看到 Flask-RESTPlus 如何帮助我们。
解析输入参数
输入参数可以采用不同的形式。当我们谈论输入参数时,主要谈论两种类型:
- 字符串查询参数编码到 URL 中。这些通常用于
GET请求,看起来像下面这样:
http://test.com/some/path?param1=X¶m2=Y
它们是 URL 的一部分,并将存储在沿途的任何日志中。参数被编码为它们自己的格式,称为URL 编码(www.urlencoder.io/learn/)。您可能已经注意到,例如,空格会被转换为%20。
通常,我们不需要手动解码查询参数,因为诸如 Flask 之类的框架会为我们完成,但是 Python 标准库具有用于执行此操作的实用程序(docs.python.org/3/library/urllib.parse.html)。
- 让我们来看一下 HTTP 请求的主体。这通常用于
POST和PUT请求。可以使用Content-Type头指定特定格式。默认情况下,Content-Type头被定义为application/x-www-form-urlencoded,它以 URL 编码的方式进行编码。在现代应用程序中,这被替换为application/json以将其编码为 JSON。
请求的主体不会存储在日志中。期望是GET请求多次调用时产生相同的结果,这意味着它们是幂等的。因此,它可以被一些代理或其他元素缓存。这就是为什么在再次发送POST请求之前,您的浏览器会要求确认,因为此操作可能会产生不同的结果。
但还有另外两个地方可以传递参数:
-
作为 URL 的一部分:像
thought id这样的东西是参数。尽量遵循 RESTful 原则,并将 URL 定义为资源,以避免混淆。查询参数最好留作可选项。 -
标头:通常,标头提供有关元数据的信息,例如请求的格式、预期的格式或身份验证数据。但它们也需要被视为输入参数。
所有这些元素都会被 Flask-RESTPlus 自动解码,因此我们不需要处理编码和低级访问。
让我们看看这在我们的例子中是如何工作的。这段代码是从 GitHub 中提取的,并缩短以描述解析参数:
authentication_parser = api_namespace.parser()
authentication_parser.add_argument('Authorization',
location='headers', type=str, help='Bearer Access
Token')
thought_parser = authentication_parser.copy()
thought_parser.add_argument('text', type=str, required=True, help='Text of the thought')
@api_namespace.route('/me/thoughts/')
class MeThoughtListCreate(Resource):
@api_namespace.expect(thought_parser)
def post(self):
args = thought_parser.parse_args()
username = authentication_header_parser(args['Authorization'])
text=args['text']
...
我们在下面的行中定义了一个解析器:
authentication_parser = api_namespace.parser()
authentication_parser.add_argument('Authorization',
location='headers', type=str, help='Bearer Access Token')
thought_parser = authentication_parser.copy()
thought_parser.add_argument('text', type=str, required=True, help='Text of the thought')
authentication_parser被thought_parser继承,以扩展功能并结合两者。每个参数都根据类型和是否需要来定义。如果缺少必需的参数或其他元素不正确,Flask-RESTPlus 将引发400 BAD_REQUEST错误,并提供有关出了什么问题的反馈。
因为我们想以稍微不同的方式处理身份验证,我们将其标记为不需要,并允许它使用默认值(由框架创建)None。请注意,我们指定Authorization参数应该在标头中。
post方法得到一个装饰器,表明它期望thought_parser参数,并且我们用parse_args解析它:
@api_namespace.route('/me/thoughts/')
class MeThoughtListCreate(Resource):
@api_namespace.expect(thought_parser)
def post(self):
args = thought_parser.parse_args()
...
此外,args现在是一个带有所有参数正确解析并在下一行中使用的字典。
在身份验证标头的特定情况下,有一个特定的函数来处理它,并且通过使用abort返回401 UNAUTHORIZED状态码。这个调用立即停止了一个请求:
def authentication_header_parser(value):
username = validate_token_header(value, config.PUBLIC_KEY)
if username is None:
abort(401)
return username
class MeThoughtListCreate(Resource):
@api_namespace.expect(thought_parser)
def post(self):
args = thought_parser.parse_args()
username = authentication_header_parser(args['Authentication'])
...
我们暂时不考虑要执行的操作(将新的想法存储在数据库中),而是专注于其他框架配置,将结果序列化为 JSON 对象。
序列化结果
我们需要返回我们的结果。最简单的方法是通过定义 JSON 结果的形状来实现,通过一个序列化器或编组模型(flask-restplus.readthedocs.io/en/stable/marshalling.html)。
序列化器模型被定义为一个带有预期字段和字段类型的字典:
from flask_restplus import fields
model = {
'id': fields.Integer(),
'username': fields.String(),
'text': fields.String(),
'timestamp': fields.DateTime(),
}
thought_model = api_namespace.model('Thought', model)
该模型将接受一个 Python 对象,并将每个属性转换为相应的 JSON 元素,如字段中所定义的那样:
@api_namespace.route('/me/thoughts/')
class MeThoughtListCreate(Resource):
@api_namespace.marshal_with(thought_model)
def post(self):
...
new_thought = ThoughtModel(...)
return new_thought
请注意,new_thought是一个ThoughtModel对象,由 SQLAlchemy 检索到。我们将在下面详细介绍它,但现在,可以说它具有模型中定义的所有属性:id、username、text和timestamp。
内存对象中不存在的任何属性默认值为None。您可以将此默认值更改为将返回的值。您可以指定一个函数,因此在生成响应时将调用它来检索值。这是向对象添加动态信息的一种方式:
model = {
'timestamp': fields.DateTime(default=datetime.utcnow),
}
您还可以添加要序列化的属性的名称,以防它与预期的结果不同,或者添加一个将被调用以检索值的lambda函数:
model = {
'thought_text': fields.String(attribute='text'),
'thought_username': fields.String(attribute=lambda x: x.username),
}
对于更复杂的对象,你可以像这样嵌套值。请注意,这从文档的角度定义了两个模型,并且每个Nested元素都创建了一个新的作用域。你也可以使用List来添加多个相同类型的实例:
extra = {
'info': fields.String(),
}
extra_info = api_namespace.model('ExtraInfo', extra)
model = {
'extra': fields.Nested(extra),
'extra_list': fields.List(fields.Nested(extra)),
}
一些可用字段有更多的选项,比如DateTime字段的日期格式。查看完整的字段文档(flask-restplus.readthedocs.io/en/stable/api.html#models)以获取更多详细信息。
如果返回一个元素列表,在marshal_with装饰器中添加as_list=True参数:
@api_namespace.route('/me/thoughts/')
class MeThoughtListCreate(Resource):
@api_namespace.marshal_with(thought_model, as_list=True)
def get(self):
...
thoughts = (
ThoughtModel.query.filter(
ThoughtModel.username == username
)
.order_by('id').all()
)
return thoughts
marshal_with装饰器将把result对象从 Python 对象转换为相应的 JSON 数据对象。
默认情况下,它将返回http.client.OK (200)状态码,但我们可以返回不同的状态码,返回两个值:第一个是要marshal的对象,第二个是状态码。marshal_with装饰器中的代码参数用于文档目的。请注意,在这种情况下,我们需要添加特定的marshal调用:
@api_namespace.route('/me/thoughts/')
class MeThoughtListCreate(Resource):
@api_namespace.marshal_with(thought_model,
code=http.client.CREATED)
def post(self):
...
result = api_namespace.marshal(new_thought, thought_model)
return result, http.client.CREATED
Swagger 文档将显示所有您定义的marshal对象:
Swagger 页面的末尾
Flask-RESTPlus 的一个不便之处是,为了输入和输出相同的对象,它们需要定义两次,因为输入和输出的模块是不同的。这在一些其他 RESTful 框架中并非如此,例如在 Django REST 框架中(www.django-rest-framework.org/)。Flask-RESTPlus 的维护者们意识到了这一点,并且根据他们的说法,他们将集成一个外部模块,可能是marshmallow(marshmallow.readthedocs.io/en/stable/)。如果您愿意,您可以手动集成它,因为 Flask 足够灵活,可以这样做,看看这个示例(marshmallow.readthedocs.io/en/stable/examples.html#quotes-api-flask-sqlalchemy)。
有关更多详细信息,您可以在 Flask-RESTPlus 的完整编组文档中查看flask-restplus.readthedocs.io/en/stable/marshalling.html。
执行操作
最后,我们来到了输入数据已经清洁并准备好使用的具体部分,我们知道如何返回结果。这部分可能涉及执行一些数据库查询和组合结果。让我们以以下内容作为示例:
@api_namespace.route('/thoughts/')
class ThoughtList(Resource):
@api_namespace.doc('list_thoughts')
@api_namespace.marshal_with(thought_model, as_list=True)
@api_namespace.expect(search_parser)
def get(self):
'''
Retrieves all the thoughts
'''
args = search_parser.parse_args()
search_param = args['search']
# Action
query = ThoughtModel.query
if search_param:
query =(query.filter(
ThoughtModel.text.contains(search_param)))
query = query.order_by('id')
thoughts = query.all()
# Return the result
return thoughts
您可以在此处看到,在解析参数后,我们使用 SQLAlchemy 检索查询,如果search参数存在,将应用过滤器。我们使用all()获取所有ThoughtModel对象的结果。
返回对象编组(自动将它们编码为 JSON),如我们在marshal_with装饰器中指定的那样。
验证请求
身份验证逻辑封装在thoughts_backend/token_validation.py文件中。其中包含头部的生成和验证。
以下函数生成Bearer令牌:
def encode_token(payload, private_key):
return jwt.encode(payload, private_key, algorithm='RS256')
def generate_token_header(username, private_key):
'''
Generate a token header base on the username.
Sign using the private key.
'''
payload = {
'username': username,
'iat': datetime.utcnow(),
'exp': datetime.utcnow() + timedelta(days=2),
}
token = encode_token(payload, private_key)
token = token.decode('utf8')
return f'Bearer {token}'
这将生成一个 JWT 有效负载。它包括username作为自定义值使用,但它还添加了两个标准字段,即exp到期日期和iat令牌生成时间。
然后使用私钥使用 RS256 算法对令牌进行编码,并以正确的格式返回:Bearer <token>。
反向操作是从编码的头部获取用户名。这里的代码较长,因为我们应该考虑我们可能收到Authentication头部的不同选项。这个头部直接来自我们的公共 API,所以我们应该期望任何值并编写程序来做好防御准备。
令牌本身的解码很简单,因为jwt.decode操作将执行此操作:
def decode_token(token, public_key):
return jwt.decode(token, public_key, algoritms='RS256')
但在到达该步骤之前,我们需要获取令牌并验证多种情况下的头部是否有效,因此我们首先检查头部是否为空,以及是否具有正确的格式,提取令牌:
def validate_token_header(header, public_key):
if not header:
logger.info('No header')
return None
# Retrieve the Bearer token
parse_result = parse('Bearer {}', header)
if not parse_result:
logger.info(f'Wrong format for header "{header}"')
return None
token = parse_result[0]
然后,我们解码令牌。如果无法使用公钥解码令牌,则会引发DecodeError。令牌也可能已过期:
try:
decoded_token = decode_token(token.encode('utf8'), public_key)
except jwt.exceptions.DecodeError:
logger.warning(f'Error decoding header "{header}". '
'This may be key missmatch or wrong key')
return None
except jwt.exceptions.ExpiredSignatureError:
logger.info(f'Authentication header has expired')
return None
然后,检查它是否具有预期的exp和username参数。如果其中任何一个参数缺失,这意味着令牌在解码后的格式不正确。这可能发生在不同版本中更改代码时。
# Check expiry is in the token
if 'exp' not in decoded_token:
logger.warning('Token does not have expiry (exp)')
return None
# Check username is in the token
if 'username' not in decoded_token:
logger.warning('Token does not have username')
return None
logger.info('Header successfully validated')
return decoded_token['username']
如果一切顺利,最后返回用户名。
每个可能的问题都以不同的严重程度记录。最常见的情况以信息级别的安全性记录,因为它们并不严重。例如,在令牌解码后出现格式错误可能表明我们的编码过程存在问题。
请注意,我们使用的是私钥/公钥架构,而不是对称密钥架构,用于编码和解码令牌。这意味着解码和编码密钥是不同的。
从技术上讲,这是一个签名/验证,因为它用于生成签名,而不是编码/解码,但这是 JWT 中使用的命名约定。
在我们的微服务结构中,只有签名机构需要私钥。这增加了安全性,因为其他服务中的任何密钥泄漏都无法检索到能够签署 bearer tokens 的密钥。但是,我们需要生成适当的私钥和公钥。
要生成私钥/公钥,请运行以下命令:
$ openssl genrsa -out key.pem 2048
Generating RSA private key, 2048 bit long modulus
.....................+++
.............................+++
然后,要提取公钥,请使用以下命令:
$ openssl rsa -in key.pem -outform PEM -pubout -out key.pub
这将生成两个文件:key.pem和key.pub,其中包含私钥/公钥对。以文本格式读取它们就足以将它们用作编码/解码 JWT 令牌的密钥:
>> with open('private.pem') as fp:
>> .. private_key = fp.read()
>> generate_token_header('peter', private_key)
'Bearer <token>'
请注意,对于测试,我们生成了一个样本密钥对,作为字符串附加。这些密钥是专门为此用途创建的,不会在其他任何地方使用。请不要在任何地方使用它们,因为它们在 GitHub 上是公开可用的。
请注意,您需要一个非加密的私钥,不受密码保护,因为 JWT 模块不允许您添加密码。不要将生产秘钥存储在未受保护的文件中。在第三章中,使用 Docker 构建、运行和测试您的服务,我们将看到如何使用环境变量注入这个秘钥,在第十一章中,处理系统中的更改、依赖和秘钥,我们将看到如何在生产环境中正确处理秘钥。
测试代码
为了测试我们的应用程序,我们使用了优秀的pytest框架,这是 Python 应用程序的测试运行器的黄金标准。
基本上,pytest有许多插件和附加组件,可用于处理许多情况。我们将使用pytest-flask,它有助于运行 Flask 应用程序的测试。
运行所有测试,只需在命令行中调用pytest:
$ pytest
============== test session starts ==============
....
==== 17 passed, 177 warnings in 1.50 seconds =====
请注意,pytest具有许多可用于处理许多测试情况的功能。在处理测试时,运行匹配测试的子集(-k选项)、运行上次失败的测试(--lf)或在第一个失败后停止(-x)等功能非常有用。我强烈建议查看其完整文档(docs.pytest.org/en/latest/)并发现其所有可能性。
还有许多用于使用数据库或框架、报告代码覆盖率、分析、BDD 等的插件和扩展。值得了解一下。
我们配置了基本用法,包括在pytest.ini文件中始终启用标志和在conftest.py中的 fixtures。
定义 pytest fixtures
在pytest中使用 fixture 来准备测试应该执行的上下文,准备并在结束时清理它。pytest-flask需要应用 fixture,如文档中所示。该插件生成一个client fixture,我们可以用它来在测试模式下发送请求。我们在thoughts_fixture fixture 中看到了这个 fixture 的使用,它通过 API 生成三个 thoughts,并在我们的测试运行后删除所有内容。
简化后的结构如下:
- 生成三个 thoughts。存储其
thought_id:
@pytest.fixture
def thought_fixture(client):
thought_ids = []
for _ in range(3):
thought = {
'text': fake.text(240),
}
header = token_validation.generate_token_header(fake.name(),
PRIVATE_KEY)
headers = {
'Authorization': header,
}
response = client.post('/api/me/thoughts/', data=thought,
headers=headers)
assert http.client.CREATED == response.status_code
result = response.json
thought_ids.append(result['id'])
- 然后,在测试中添加
yield thought_ids:
yield thought_ids
- 检索所有 thoughts 并逐个删除它们:
# Clean up all thoughts
response = client.get('/api/thoughts/')
thoughts = response.json
for thought in thoughts:
thought_id = thought['id']
url = f'/admin/thoughts/{thought_id}/'
response = client.delete(url)
assert http.client.NO_CONTENT == response.status_code
请注意,我们使用faker模块生成假姓名和文本。您可以在faker.readthedocs.io/en/stable/查看其完整文档。这是一个生成测试随机值的好方法,避免反复使用test_user和test_text。它还有助于塑造您的测试,通过独立检查输入而不是盲目地复制占位符。
Fixture 也可以测试您的 API。您可以选择更低级的方法,比如在数据库中编写原始信息,但使用您自己定义的 API 是确保您拥有完整和有用接口的好方法。在我们的例子中,我们添加了一个用于删除想法的管理员界面。这在整个 fixture 中都得到了运用,以及为整个和完整的接口创建想法。
这样,我们还使用测试来验证我们可以将我们的微服务作为一个完整的服务使用,而不是欺骗自己以执行常见操作。
还要注意client fixture 的使用,这是由pytest-flask提供的。
理解 test_token_validation.py
这个测试文件测试了token_validation模块的行为。该模块涵盖了认证头的生成和验证,因此对其进行彻底测试非常重要。
这些测试检查了头部是否可以使用正确的密钥进行编码和解码。它还检查了在无效输入方面的所有不同可能性:不同形状的不正确格式,无效的解码密钥或过期的令牌。
为了检查过期的令牌,我们使用了两个模块:freezegun,使测试检索特定的测试时间(github.com/spulec/freezegun),以及delorean,以便轻松解析日期(尽管该模块能够做更多;请查看delorean.readthedocs.io/en/latest/的文档)。这两个模块非常易于使用,非常适合测试目的。
例如,这个测试检查了一个过期的令牌:
@freeze_time('2018-05-17 13:47:34')
def test_invalid_token_header_expired():
expiry = delorean.parse('2018-05-17 13:47:33').datetime
payload = {
'username': 'tonystark',
'exp': expiry,
}
token = token_validation.encode_token(payload, PRIVATE_KEY)
token = token.decode('utf8')
header = f'Bearer {token}'
result = token_validation.validate_token_header(header, PUBLIC_KEY)
assert None is result
请注意,冻结时间恰好是令牌到期时间后的 1 秒。
用于测试的公钥和私钥在constants.py文件中定义。还有一个额外的独立公钥用于检查如果使用无效的公钥解码令牌会发生什么。
值得再次强调:请不要使用这些密钥。这些密钥仅用于运行测试,并且可以被任何有权访问本书的人使用。
test_thoughts.py
这个文件检查了定义的 API 接口。每个 API 都经过测试,以正确执行操作(创建新的想法,返回用户的想法,检索所有想法,搜索想法,按 ID 检索想法),以及一些错误测试(未经授权的请求来创建和检索用户的想法,或检索不存在的想法)。
在这里,我们再次使用freezegun来确定思想的创建时间,而不是根据测试运行时的时间戳创建它们。
总结
在这一章中,我们看到了如何开发一个 Web 微服务。我们首先按照 REST 原则设计了其 API。然后,我们描述了如何访问数据库的模式,并使用 SQLAlchemy 进行操作。
然后,我们学习了如何使用 Flask-RESTPlus 来实现它。我们学习了如何定义资源映射到 API 端点,如何解析输入值,如何处理操作,然后如何使用序列化模型返回结果。我们描述了认证层的工作原理。
我们包括了测试,并描述了如何使用pytest fixture 来为我们的测试创建初始条件。在下一章中,我们将学习如何将服务容器化,并通过 Docker 运行。
问题
-
你能说出 RESTful 应用程序的特点吗?
-
使用 Flask-RESTPlus 的优势是什么?
-
你知道除了 Flask-RESTPlus 之外的替代框架吗?
-
在测试中使用的 Python 软件包名称来修复时间。
-
您能描述一下认证流程吗?
-
为什么我们选择 SQLAlchemy 作为示例项目的数据库接口?
进一步阅读
关于 RESTful 设计的深入描述,不仅限于 Python,您可以在Hands-On RESTful API Design Patterns and Best Practices中找到更多信息(www.packtpub.com/gb/application-development/hands-restful-api-design-patterns-and-best-practices)。您可以在书籍Flask: Building Python Web Services中了解如何使用 Flask 框架(www.packtpub.com/gb/web-development/flask-building-python-web-services)。
第三章:使用 Docker 构建、运行和测试您的服务
在上一章中设计了一个工作的 RESTful 微服务,本章将介绍如何以Docker 方式使用它,将服务封装到一个自包含的容器中,使其不可变,并且可以独立部署。本章非常明确地描述了服务的依赖关系和使用方式。运行服务的主要方式是作为 Web 服务器运行,但也可以进行其他操作,比如运行单元测试,生成报告等。我们还将看到如何在本地计算机上部署服务进行测试,以及如何通过镜像仓库共享服务。
本章将涵盖以下主题:
-
使用 Dockerfile 构建您的服务
-
操作不可变的容器
-
配置您的服务
-
在本地部署 Docker 服务
-
将您的 Docker 镜像推送到远程注册表
在本章结束时,您将了解如何使用 Docker 操作,创建基本服务,构建镜像并运行它。您还将了解如何共享镜像以在另一台计算机上运行。
技术要求
对于本章,您需要安装 Docker,版本为 18.09 或更高版本。请参阅官方文档(docs.docker.com/install/),了解如何在您的平台上进行安装。
如果您在 Linux 上安装 Docker,可能需要配置服务器以允许非 root 访问。请查看文档:docs.docker.com/install/linux/linux-postinstall/。
使用以下命令检查版本:
$ docker version
Client: Docker Engine - Community
Version: 18.09.2
API version: 1.39
Go version: go1.10.8
Git commit: 6247962
Built: Sun Feb 10 04:12:39 2019
OS/Arch: darwin/amd64
Experimental: false
您还需要安装 Docker Compose 版本 1.24.0 或更高版本。请注意,在某些安装中,如 macOS,这是自动为您安装的。请查看 Docker 文档中的安装说明:docs.docker.com/compose/install/。
$ docker-compose version
docker-compose version 1.24.0, build 0aa5906
docker-py version: 3.7.2
CPython version: 3.7.3
OpenSSL version: OpenSSL 1.0.2r 26 Feb 2019
代码可以在 GitHub 上找到,位于此目录:github.com/PacktPublishing/Hands-On-Docker-for-Microservices-with-Python/tree/master/Chapter03。在第二章中介绍了ThoughtsBackend的副本,使用 Python 创建 REST 服务,但代码略有不同。我们将在本章中看到这些差异。
使用 Dockerfile 构建您的服务
一切都始于一个容器。正如我们在第一章中所说的,迁移-设计、计划和执行,容器是一种标准化的软件包,以标准方式封装的软件包。它们是可以独立运行的软件单元,因为它们是完全自包含的。要创建一个容器,我们需要构建它。
记得我们描述容器为一个被其自己的文件系统包围的进程吗?构建容器会构建这个文件系统。
要使用 Docker 构建容器,我们需要定义其内容。文件系统是通过逐层应用来创建的。每个 Dockerfile,即生成容器的配方,都包含了生成容器的步骤的定义。
例如,让我们创建一个非常简单的 Dockerfile。创建一个名为example.txt的文件,其中包含一些示例文本,另一个名为Dockerfile.simple,内容如下:
# scratch is a special container that is totally empty
FROM scratch
COPY example.txt /example.txt
现在使用以下命令构建它:
$ # docker build -f <dockerfile> --tag <tag> <context>
$ docker build -f Dockerfile.simple --tag simple .
Sending build context to Docker daemon 3.072kB
Step 1/2 : FROM scratch
--->
Step 2/2 : COPY example.txt /example.txt
---> Using cache
---> f961aef9f15c
Successfully built f961aef9f15c
Successfully tagged simple:latest
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
simple latest f961aef9f15c 4 minutes ago 11B
这将创建一个只包含example.txt文件的 Docker 镜像。这并不是很有用,但非常小-只有 11 个字节。这是因为它继承自空容器scratch,然后将example.txt文件复制到容器中的/example.txt位置。
让我们来看看docker build命令。使用-f参数定义 Dockerfile,使用--tag定义结果镜像的标签,使用context参数定义为点(.)。context参数是指在 Dockerfile 中的步骤中查找文件的引用。
该镜像还具有自动分配的镜像 IDf961aef9f15c。这是文件系统内容的哈希值。稍后我们将看到这为什么是相关的。
执行命令
之前的容器并不是很令人兴奋。完全可以从头开始创建自己的容器,但通常情况下,您会寻找一个包含某种 Linux 发行版的基线,以便您可以在容器中执行一些有用的操作。
正如我们在FROM命令中看到的,我们可以从以前的容器开始。我们将在整本书中使用 Alpine Linux(alpinelinux.org/)发行版,尽管还有其他发行版可用,如 Ubuntu 和 CentOS。查看这篇文章sweetcode.io/linux-distributions-optimized-hosting-docker/,了解针对 Docker 容器的发行版。
为什么选择 Alpine Linux?它可以说是 Docker 系统中最受欢迎的发行版,因为它的占用空间非常小,旨在提高安全性。它得到了很好的维护,并定期更新和修补。它还具有完整的软件包管理系统,可以轻松安装大多数常见的 Web 服务工具。基础镜像的大小只有大约 5MB,并包含一个可用的 Linux 操作系统。
在使用时,它有一些怪癖,比如使用自己的包管理,称为apk,但它很容易使用,几乎可以直接替代常见的 Linux 发行版。
以下 Dockerfile 将继承自基础alpine容器,并添加example.txt文件:
FROM alpine
RUN mkdir -p /opt/
COPY example.txt /opt/example.txt
这个容器允许我们运行命令,因为通常的命令行实用程序都包括在内:
$ docker build -f Dockerfile.run --tag container-run .
Sending build context to Docker daemon 4.096kB
Step 1/3 : FROM alpine
---> 055936d39205
Step 2/3 : RUN mkdir -p /opt/
---> Using cache
---> 4f565debb941
Step 3/3 : COPY example.txt /opt/example.txt
---> Using cache
---> d67a72454d75
Successfully built d67a72454d75
Successfully tagged container-run:latest
$ # docker run <image name> <command>
$ docker run container-run cat /opt/example.txt
An example file
注意cat /opt/example.txt命令行的执行。这实际上是在容器内部发生的。我们在stdout控制台中打印结果。但是,如果有文件被创建,当容器停止时,该文件不会保存在我们的本地文件系统中,而只保存在容器内部:
$ ls
Dockerfile.run example.txt
$ docker run container-run /bin/sh -c 'cat /opt/example.txt > out.txt'
$ ls
Dockerfile.run example.txt
文件实际上是保存在一个已停止的容器中。一旦容器完成运行,它将被 Docker 保持停止状态,直到被移除。您可以使用docker ps -a命令查看已停止的容器。尽管已停止的容器并不是很有趣,但它的文件系统已保存在磁盘上。
运行 Web 服务时,正在运行的命令不会停止;它将一直运行,直到停止。记住我们之前说过的,容器是一个附加了文件系统的进程。正在运行的命令是容器的关键。
您可以通过添加以下内容来添加默认命令,当没有给出命令时将执行该命令:
CMD cat /opt/example.txt
使用以下命令使其自动运行:
$ docker run container-run
An example file
定义标准命令使容器变得非常简单。只需运行它,它将执行其配置的任何操作。记得在您的容器中包含一个默认命令。
我们还可以在容器中执行 shell 并与其交互。记得添加-it标志以保持连接正常打开,-i保持stdin打开,-t创建伪终端,您可以将其记住为交互式终端:
$ docker run -it container-run /bin/sh
/ # cd opt/
/opt # ls
example.txt
/opt # cat example.txt
An example file
/opt # exit
$
在发现问题或执行探索性测试时非常有用。
了解 Docker 缓存
构建镜像时,构建图层的工作原理是构建镜像时的一个主要困惑点。
Dockerfile 上的每个命令都是按顺序执行的,并在前一个图层的基础上执行。如果您熟悉 Git,您会注意到这个过程是类似的。每个图层只存储对上一步的更改:
这使得 Docker 可以进行非常积极的缓存,因为任何更改之前的层已经计算过了。例如,在这个例子中,我们使用apk update更新可用的软件包,然后安装python3软件包,然后复制example.txt文件。对example.txt文件的任何更改只会在层be086a75fe23上执行最后两个步骤。这加快了镜像的重建速度。
这也意味着您需要仔细构建您的 Dockerfile,以免使缓存无效。从很少更改的操作开始,比如安装项目依赖,然后进行更频繁更改的操作,比如添加您的代码。我们的示例的带注释的 Dockerfile 有关于缓存使用的指示。
这也意味着,即使层删除了数据,图像的大小也永远不会变小,因为前一个层仍然存储在磁盘上。如果要从一个步骤中删除不需要的数据,需要在同一个步骤中进行。
保持容器的小是非常重要的。在任何 Docker 系统中,倾向于有大量的容器和大量的镜像。没有理由的大图像会很快填满仓库。它们下载和推送都会很慢,并且在您的基础设施中复制容器时也会很慢。
还有另一个实际的考虑。容器是简化和减少服务到最低程度的好工具。通过一点投资,您将获得很好的结果,并保持小而简洁的容器。
有几种保持图像小的做法。除了小心不安装额外的元素之外,主要的做法是创建一个单一的、复杂的层,安装和卸载,以及多阶段图像。多阶段 Dockerfile 是一种引用先前中间层并从中复制数据的方式。查看 Docker 文档(docs.docker.com/develop/develop-images/multistage-build/)。
编译器,特别是倾向于占用大量空间。如果可能的话,尽量使用预编译的二进制文件。您可以使用多阶段 Dockerfile 在一个容器中进行编译,然后将二进制文件复制到正在运行的容器中。
您可以在这篇文章中了解两种策略之间的区别:pythonspeed.com/articles/smaller-python-docker-images/。
分析特定图像及其组成层的好工具是dive(github.com/wagoodman/dive)。它还会发现图像可以缩小的方法。
我们将在下一步创建一个多阶段容器。
构建 web 服务容器
我们有一个具体的目标,那就是创建一个能够运行我们的微服务ThoughtsBackend的容器。为此,我们有一些要求:
-
我们需要将我们的代码复制到容器中。
-
代码需要通过 web 服务器提供。
因此,大致上,我们需要创建一个带有 web 服务器的容器,添加我们的代码,配置它以运行我们的代码,并在启动容器时提供结果。
我们将把大部分配置文件存储在./docker目录的子目录中。
作为一个 web 服务器,我们将使用 uWSGI(uwsgi-docs.readthedocs.io/en/latest/)。uWSGI 是一个能够通过 WSGI 协议为我们的 Flask 应用提供服务的 web 服务器。uWSGI 非常灵活,有很多选项,并且能够直接提供 HTTP 服务。
一个非常常见的配置是在 uWSGI 前面放置 NGINX 来提供静态文件,因为对于这一点来说更有效率。在我们特定的用例中,我们不提供太多静态文件,因为我们正在运行一个 RESTful API,并且在我们的主要架构中,如第一章中所述,进行移动-设计,计划和执行,前端已经有一个负载均衡器和一个专用的静态文件服务器。这意味着我们不会为了简单起见添加额外的组件。NGINX 通常使用uwsgi协议与 uWSGI 通信,这是专门为 uWSGI 服务器设计的协议,但也可以通过 HTTP 进行通信。请查看 NGINX 和 uWSGI 文档。
让我们来看一下docker/app/Dockerfile文件。它有两个阶段;第一个是编译依赖项:
########
# This image will compile the dependencies
# It will install compilers and other packages, that won't be carried
# over to the runtime image
########
FROM alpine:3.9 AS compile-image
# Add requirements for python and pip
RUN apk add --update python3
RUN mkdir -p /opt/code
WORKDIR /opt/code
# Install dependencies
RUN apk add python3-dev build-base gcc linux-headers postgresql-dev libffi-dev
# Create a virtual environment for all the Python dependencies
RUN python3 -m venv /opt/venv
# Make sure we use the virtualenv:
ENV PATH="/opt/venv/bin:$PATH"
RUN pip3 install --upgrade pip
# Install and compile uwsgi
RUN pip3 install uwsgi==2.0.18
# Install other dependencies
COPY ThoughtsBackend/requirements.txt /opt/
RUN pip3 install -r /opt/requirements.txt
这个阶段执行以下步骤:
-
将阶段命名为
compile-image,继承自 Alpine。 -
安装
python3。 -
安装构建依赖项,包括
gcc编译器和 Python 头文件(python3-dev)。 -
创建一个新的虚拟环境。我们将在这里安装所有的 Python 依赖项。
-
激活虚拟环境。
-
安装 uWSGI。这一步从代码中编译它。
您还可以在 Alpine 发行版中安装包含的 uWSGI 包,但我发现编译的包更完整,更容易配置,因为 Alpine 的uwsgi包需要您安装其他包,如uwsgi-python3,uwsgi-http等,然后在 uWSGI 配置中启用插件。大小差异很小。这还允许您使用最新的 uWSGI 版本,而不依赖于 Alpine 发行版中的版本。
- 复制
requirements.txt文件并安装所有依赖项。这将编译并复制依赖项到虚拟环境中。
第二阶段是准备运行容器。让我们来看一下:
########
# This image is the runtime, will copy the dependencies from the other
########
FROM alpine:3.9 AS runtime-image
# Install python
RUN apk add --update python3 curl libffi postgresql-libs
# Copy uWSGI configuration
RUN mkdir -p /opt/uwsgi
ADD docker/app/uwsgi.ini /opt/uwsgi/
ADD docker/app/start_server.sh /opt/uwsgi/
# Create a user to run the service
RUN addgroup -S uwsgi
RUN adduser -H -D -S uwsgi
USER uwsgi
# Copy the venv with compile dependencies from the compile-image
COPY --chown=uwsgi:uwsgi --from=compile-image /opt/venv /opt/venv
# Be sure to activate the venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy the code
COPY --chown=uwsgi:uwsgi ThoughtsBackend/ /opt/code/
# Run parameters
WORKDIR /opt/code
EXPOSE 8000
CMD ["/bin/sh", "/opt/uwsgi/start_server.sh"]
执行以下操作:
-
将镜像标记为
runtime-image,并像之前一样继承自 Alpine。 -
安装 Python 和运行时的其他要求。
请注意,需要安装用于编译的任何运行时。例如,我们在运行时安装了libffi和libffi-dev来编译,这是cryptography包所需的。如果不匹配,尝试访问(不存在的)库时会引发运行时错误。dev库通常包含运行时库。
-
复制 uWSGI 配置和启动服务的脚本。我们稍后会看一下。
-
创建一个用户来运行服务,并使用
USER命令将其设置为默认用户。
这一步并不是严格必要的,因为默认情况下会使用 root 用户。由于我们的容器是隔离的,在其中获得 root 访问权限比在真实服务器中更安全。无论如何,最好的做法是不要将我们的面向公众的服务配置为 root 用户,并且这样做会消除一些可以理解的警告。
-
从
compile-image镜像中复制虚拟环境。这将安装所有编译的 Python 包。请注意,它们是与运行服务的用户一起复制的,以便访问它们。虚拟环境已激活。 -
复制应用程序代码。
-
定义运行参数。请注意,端口
8000已暴露。这将是我们将在其上提供应用程序的端口。
如果以 root 身份运行,可以定义端口80。在 Docker 中路由端口是微不足道的,除了前端负载均衡器之外,没有真正需要使用默认的 HTTP 端口的理由。不过,可以在所有系统中使用相同的端口,这样可以消除不确定性。
请注意,应用程序代码是在文件末尾复制的。应用程序代码可能是最经常更改的代码,因此这种结构利用了 Docker 缓存,并且只重新创建了最后的几个层,而不是从头开始。在设计 Dockerfile 时,请考虑这一点。
另外,请记住,在开发过程中没有什么能阻止您改变顺序。如果您试图找到依赖关系的问题等,您可以注释掉不相关的层,或者在代码稳定后添加后续步骤。
现在让我们构建我们的容器。请注意,已创建了两个镜像,尽管只有一个被命名。另一个是编译镜像,它更大,因为它包含了编译器等。
$ docker build -f docker/app/Dockerfile --tag thoughts-backend .
...
---> 027569681620
Step 12/26 : FROM alpine:3.9 AS runtime-image
...
Successfully built 50efd3830a90
Successfully tagged thoughts-backend:latest
$ docker images | head
REPOSITORY TAG IMAGE ID CREATED SIZE
thoughts-backend latest 50efd3830a90 10 minutes ago 144MB
<none> <none> 027569681620 12 minutes ago 409MB
现在我们可以运行容器了。为了能够访问内部端口8000,我们需要使用-p选项进行路由:
$ docker run -it -p 127.0.0.1:8000:8000/tcp thoughts-backend
访问我们的本地浏览器127.0.0.1会显示我们的应用程序。您可以在标准输出中看到访问日志:
您可以使用docker exec从不同的终端访问正在运行的容器,并执行一个新的 shell。记得添加-it以保持终端开启。使用docker ps检查当前正在运行的容器以找到容器 ID:
$ docker ps
CONTAINER ID IMAGE COMMAND ... PORTS ...
ac2659958a68 thoughts-backend ... ... 127.0.0.1:8000->8000/tcp
$ docker exec -it ac2659958a68 /bin/sh
/opt/code $ ls
README.md __pycache__ db.sqlite3 init_db.py pytest.ini requirements.txt tests thoughts_backend wsgi.py
/opt/code $ exit
$
您可以使用Ctrl + C停止容器,或者更优雅地,从另一个终端停止它:
$ docker ps
CONTAINER ID IMAGE COMMAND ... PORTS ...
ac2659958a68 thoughts-backend ... ... 127.0.0.1:8000->8000/tcp
$ docker stop ac2659958a68
ac2659958a68
日志将显示graceful stop:
...
spawned uWSGI master process (pid: 6)
spawned uWSGI worker 1 (pid: 7, cores: 1)
spawned uWSGI http 1 (pid: 8)
Caught SIGTERM signal! Sending graceful stop to uWSGI through the master-fifo
Fri May 31 10:29:47 2019 - graceful shutdown triggered...
$
正确捕获SIGTERM并优雅地停止我们的服务对于避免服务突然终止很重要。我们将看到如何在 uWSGI 中配置这一点,以及其他元素。
配置 uWSGI
uwsgi.ini文件包含了 uWSGI 的配置:
[uwsgi]
uid=uwsgi
chdir=/opt/code
wsgi-file=wsgi.py
master=True
pidfile=/tmp/uwsgi.pid
http=:8000
vacuum=True
processes=1
max-requests=5000
# Used to send commands to uWSGI
master-fifo=/tmp/uwsgi-fifo
其中大部分信息都是我们从 Dockerfile 中获取的,尽管它需要匹配,以便 uWSGI 知道在哪里找到应用程序代码、启动 WSGI 文件的名称、以及从哪个用户开始等。
其他参数是特定于 uWSGI 行为的:
-
master:创建一个控制其他进程的主进程。作为 uWSGI 操作的推荐选项,因为它创建了更平稳的操作。 -
http:在指定端口提供服务。HTTP 模式创建一个进程,负载均衡 HTTP 请求到工作进程,并建议在容器外提供 HTTP 服务。 -
processes:应用程序工作进程的数量。请注意,在我们的配置中,这实际上意味着三个进程:一个主进程,一个 HTTP 进程和一个工作进程。更多的工作进程可以处理更多的请求,但会使用更多的内存。在生产中,您需要找到适合您的数量,将其与容器的数量平衡。 -
max-requests:在工作进程处理此数量的请求后,回收工作进程(停止并启动新的)。这减少了内存泄漏的可能性。 -
vacuum:在退出时清理环境。 -
master-fifo:创建一个 Unix 管道以向 uWSGI 发送命令。我们将使用这个来处理优雅的停止。
uWSGI 文档(uwsgi-docs.readthedocs.io/en/latest/)非常全面和详尽。它包含了很多有价值的信息,既可以操作 uWSGI 本身,也可以理解关于 Web 服务器操作的细节。我每次阅读它时都会学到一些新东西,但一开始可能会有点压倒性。
值得投入一些时间来运行测试,以发现您的服务在超时、工作进程数量等方面的最佳参数是什么。但是,请记住,uWSGI 的一些选项可能更适合您的容器配置,这简化了事情。
为了允许优雅的停止,我们将 uWSGI 的执行包装在我们的start_server.sh脚本中:
#!/bin/sh
_term() {
echo "Caught SIGTERM signal! Sending graceful stop to uWSGI through the master-fifo"
# See details in the uwsgi.ini file and
# in http://uwsgi-docs.readthedocs.io/en/latest/MasterFIFO.html
# q means "graceful stop"
echo q > /tmp/uwsgi-fifo
}
trap _term SIGTERM
uwsgi --ini /opt/uwsgi/uwsgi.ini &
# We need to wait to properly catch the signal, that's why uWSGI is started
# in the background. $! is the PID of uWSGI
wait $!
# The container exits with code 143, which means "exited because SIGTERM"
# 128 + 15 (SIGTERM)
# http://www.tldp.org/LDP/abs/html/exitcodes.html
# http://tldp.org/LDP/Bash-Beginners-Guide/html/sect_12_02.html
脚本的核心是调用uwsgi来启动服务。然后它会等待直到服务停止。
SIGTERM信号将被捕获,并通过向master-fifo管道发送q命令来优雅地停止 uWSGI。
优雅的停止意味着当有新的容器版本可用时,请求不会被中断。我们稍后会看到如何进行滚动部署,但其中一个关键元素是在现有服务器不提供请求时中断它们,以避免在请求中间停止并留下不一致的状态。
Docker 使用SIGTERM信号来停止容器的执行。超时后,它将使用SIGKILL来杀死它们。
刷新 Docker 命令
我们已经了解了一些重要的 Docker 命令:
-
docker build:构建镜像 -
docker run:运行镜像 -
docker exec:在运行的容器中执行命令 -
docker ps:显示当前正在运行的容器 -
docker images:显示现有的镜像
虽然这些是基本命令,但了解大多数可用的 Docker 命令对于调试问题和执行操作(如监视、复制和标记镜像、创建网络等)非常有用。这些命令还会向您展示 Docker 内部工作的很多内容。
一个重要的命令:一定要定期使用docker system prune清理旧的容器和镜像。在使用几周后,Docker 占用的空间相当大。
Docker 文档(docs.docker.com/v17.12/engine/reference/commandline/docker/)非常完整。一定要熟悉它。
使用不可变容器进行操作
像本章前面看到的 Docker 命令一样,这些命令是一切的基础。但是,当处理多个命令时,开始变得复杂。您已经看到一些命令可能会变得相当长。
要在集群操作中操作容器,我们将使用docker-compose。这是 Docker 自己的编排工具,用于定义多容器操作。它通过一个 YAML 文件定义所有不同的任务和服务,每个都有足够的上下文来构建和运行它。
它允许您在默认情况下的配置文件docker-compose.yaml中存储不同服务和每个服务的参数。这允许您协调它们并生成可复制的服务集群。
测试容器
我们将首先创建一个服务来运行单元测试。请记住,测试需要在容器内部运行。这将标准化它们的执行并确保依赖关系是恒定的。
请注意,在创建容器时,我们包括执行测试所需的所有要求。有选项创建运行容器并从中继承以添加测试和测试依赖项。
这确实创建了一个较小的运行容器,但也创建了一个情况,即测试容器与生产中的容器并不完全相同。如果大小很重要并且存在很大差异,这可能是一个选择,但要注意如果存在细微错误。
我们需要在docker-compose.yaml文件中定义一个服务,如下所示:
version: '3.7'
services:
# Development related
test-sqlite:
environment:
- PYTHONDONTWRITEBYTECODE=1
build:
dockerfile: docker/app/Dockerfile
context: .
entrypoint: pytest
volumes:
- ./ThoughtsBackend:/opt/code
此部分定义了一个名为test-sqlite的服务。构建定义了要使用的 Dockerfile 和上下文,方式与docker build命令相同。docker-compose会自动设置名称。
我们可以使用以下命令构建容器:
$ docker-compose build test-sqlite
Building test-sqlite
...
Successfully built 8751a4a870d9
Successfully tagged ch3_test-sqlite:latest
entrypoint指定要运行的命令,在本例中通过pytest命令运行测试。
命令和entrypoint之间有一些差异,它们都执行命令。最重要的差异是command更容易被覆盖,而entrypoint会在最后附加任何额外的参数。
要运行容器,请调用run命令:
$ docker-compose run test-sqlite
=================== test session starts ===================
platform linux -- Python 3.6.8, pytest-4.5.0, py-1.8.0, pluggy-0.12.0 -- /opt/venv/bin/python3
cachedir: .pytest_cache
rootdir: /opt/code, inifile: pytest.ini
plugins: flask-0.14.0
collected 17 items
tests/test_thoughts.py::test_create_me_thought PASSED [ 5%]
...
tests/test_token_validation.py::test_valid_token_header PASSED [100%]
========== 17 passed, 177 warnings in 1.25 seconds ============
$
您可以附加要传递给内部entrypoint的pytest参数。例如,要运行与validation字符串匹配的测试,请运行以下命令:
$ docker-compose run test-sqlite -k validation
...
===== 9 passed, 8 deselected, 13 warnings in 0.30 seconds =======
$
还有两个额外的细节:当前代码通过卷挂载,并覆盖容器中的代码。看看如何将./ThoughtsBackend中的当前代码挂载到容器中的代码位置/opt/code。这对开发非常方便,因为它将避免每次更改时都需要重新构建容器。
这也意味着在挂载目录层次结构中的任何写入都将保存在您的本地文件系统中。例如,./ThoughtsBackend/db.sqlite3数据库文件允许您用于测试。它还将存储生成的pyc文件。
db.sqlite3文件的生成可能会在某些操作系统中创建权限问题。如果是这种情况,请删除它以重新生成和/或允许所有用户读写chmod 666 ./ThoughtsBackend/db.sqlite3。
这就是为什么我们使用environment选项传递一个PYTHONDONTWRITEBYTECODE=1环境变量。这可以阻止 Python 创建pyc文件。
虽然 SQLite 适用于测试,但我们需要创建一个更好的反映部署的结构,并配置对数据库的访问以能够部署服务器。
创建一个 PostgreSQL 数据库容器
我们需要针对 PostgreSQL 数据库测试我们的代码。这是我们将在生产中部署代码的数据库。
虽然 SQLAlchemy 中的抽象层旨在减少差异,但数据库的行为仍然存在一些差异。
例如,在/thoughts_backend/api_namespace.py中,以下行是不区分大小写的,这是我们想要的行为:
query = (query.filter(ThoughtModel.text.contains(search_param)))
将其翻译成 PostgreSQL,它是区分大小写的,这需要你进行检查。如果在 SQLite 中进行测试并在 PostgreSQL 中运行,这将是一个生产中的错误。
使用ilike替换的代码,以获得预期的行为,如下所示:
param = f'%{search_param}%'
query = (query.filter(ThoughtModel.text.ilike(param)))
我们将旧代码保留在注释中以显示这个问题。
要创建一个数据库容器,我们需要定义相应的 Dockerfile。我们将所有文件存储在docker/db/子目录中。让我们来看看 Dockerfile 及其不同的部分。整个文件可以在 GitHub 上找到(github.com/PacktPublishing/Hands-On-Docker-for-Microservices-with-Python/blob/master/Chapter03/docker/db/Dockerfile)。
- 使用
ARG关键字,定义基本的 PostgreSQL 配置,如数据库名称、用户和密码。它们被设置为环境变量,以便 PostgreSQL 命令可以使用它们。
这些命令仅用于本地开发。它们需要与环境设置匹配。ARG关键字在构建时为 Dockerfile 定义了一个参数。我们将看到它们如何在docker-compose.yaml文件中设置为输入参数。
ARG元素也被定义为ENV变量,因此我们将它们定义为环境变量:
# This Dockerfile is for localdev purposes only, so it won't be
# optimised for size
FROM alpine:3.9
# Add the proper env variables for init the db
ARG POSTGRES_DB
ENV POSTGRES_DB $POSTGRES_DB
ARG POSTGRES_USER
ENV POSTGRES_USER $POSTGRES_USER
ARG POSTGRES_PASSWORD
ENV POSTGRES_PASSWORD $POSTGRES_PASSWORD
ARG POSTGRES_PORT
ENV LANG en_US.utf8
EXPOSE $POSTGRES_PORT
# For usage in startup
ENV POSTGRES_HOST localhost
ENV DATABASE_ENGINE POSTGRESQL
# Store the data inside the container, as we don't care for
# persistence
RUN mkdir -p /opt/data
ENV PGDATA /opt/data
- 安装
postgresql包及其所有依赖项,如 Python 3 及其编译器。我们需要它们来运行应用程序代码:
RUN apk update
RUN apk add bash curl su-exec python3
RUN apk add postgresql postgresql-contrib postgresql-dev
RUN apk add python3-dev build-base linux-headers gcc libffi-dev
- 安装并运行
postgres-setup.sh脚本:
# Adding our code
WORKDIR /opt/code
RUN mkdir -p /opt/code/db
# Add postgres setup
ADD ./docker/db/postgres-setup.sh /opt/code/db/
RUN /opt/code/db/postgres-setup.sh
这初始化了数据库,设置了正确的用户、密码等。请注意,这并没有为我们的应用程序创建特定的表。
作为我们初始化的一部分,我们在容器内创建数据文件。这意味着数据在容器停止后不会持久保存。这对于测试来说是件好事,但是如果你想要访问数据进行调试,请记住保持容器运行。
- 安装我们应用程序的要求和在数据库容器中运行的特定命令:
## Install our code to prepare the DB
ADD ./ThoughtsBackend/requirements.txt /opt/code
RUN pip3 install -r requirements.txt
- 复制存储在
docker/db中的应用程序代码和数据库命令。运行prepare_db.sh脚本,该脚本创建应用程序数据库结构。在我们的情况下,它设置了thoughts表:
## Need to import all the code, due dependencies to initialize the DB
ADD ./ThoughtsBackend/ /opt/code/
# Add all DB commands
ADD ./docker/db/* /opt/code/db/
## get the db ready
RUN /opt/code/db/prepare_db.sh
该脚本首先在后台启动运行 PostgreSQL 数据库,然后调用init_db.py,最后优雅地停止数据库。
请记住,在 Dockerfile 的每个步骤中,为了访问数据库,它需要在运行,但也会在每个步骤结束时停止。为了避免数据损坏或进程突然终止,确保在最后使用stop_postgres.sh脚本。尽管 PostgreSQL 通常会恢复突然停止的数据库,但这会减慢启动时间。
- 要启动数据库运行,CMD 只是
postgres命令。它需要以postgres用户身份运行:
# Start the database in normal operation
USER postgres
CMD ["postgres"]
运行数据库服务,我们需要将其设置为docker-compose文件的一部分:
db:
build:
context: .
dockerfile: ./docker/db/Dockerfile
args:
# These values should be in sync with environment
# for development. If you change them, you'll
# need to rebuild the container
- POSTGRES_DB=thoughts
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=somepassword
- POSTGRES_PORT=5432
ports:
- "5432:5432"
请注意,args参数将在构建期间设置ARG值。我们还将路由 PostgreSQL 端口以允许访问数据库。
现在,您可以构建和启动服务器:
$ docker-compose up build
$ docker-compose up db
Creating ch3_db_1 ... done
Attaching to ch3_db_1
...
db_1 | 2019-06-02 13:55:38.934 UTC [1] LOG: database system is ready to accept connections
在另一个终端中,您可以使用 PostgreSQL 客户端访问数据库。我建议使用 fantastic pgcli。您可以查看其文档(www.pgcli.com/)。
您还可以使用官方的psql客户端或您喜欢的任何其他 PostgreSQL 客户端。默认客户端的文档可以在此处找到:www.postgresql.org/docs/current/app-psql.html。
在这里,我们使用PGPASSWORD环境变量来显示密码是先前配置的密码:
$ PGPASSWORD=somepassword pgcli -h localhost -U postgres thoughts
Server: PostgreSQL 11.3
Version: 2.0.2
Chat: https://gitter.im/dbcli/pgcli
Mail: https://groups.google.com/forum/#!forum/pgcli
Home: http://pgcli.com
postgres@localhost:thoughts> select * from thought_model
+------+------------+--------+-------------+
| id | username | text | timestamp |
|------+------------+--------+-------------|
+------+------------+--------+-------------+
SELECT 0
Time: 0.016s
能够访问数据库对于调试目的很有用。
配置您的服务
我们可以配置服务使用环境变量来更改行为。对于容器来说,这是使用配置文件的绝佳替代方案,因为它允许不可变的容器注入其配置。这符合十二要素应用程序(12factor.net/config)原则,并允许良好地分离代码和配置,并设置代码可能用于的不同部署。
我们稍后将在使用 Kubernetes 时看到的一个优势是根据需要创建新环境,这些环境可以根据测试目的进行调整,或者专门用于开发或演示。通过注入适当的环境,能够快速更改所有配置,使此操作非常简单和直接。它还允许您根据需要启用或禁用功能,如果正确配置,这有助于在启动日启用功能,而无需代码发布。
这允许配置数据库以连接,因此我们可以在 SQLite 后端或 PostgreSQL 之间进行选择。
系统配置不仅限于开放变量。环境变量将在本书后面用于存储秘密。请注意,秘密需要在容器内可用。
我们将配置测试以访问我们新创建的数据库容器。为此,我们首先需要通过配置选择 SQLite 或 PostgreSQL 的能力。查看./ThoughtsBackend/thoughts_backend/db.py文件:
import os
from pathlib import Path
from flask_sqlalchemy import SQLAlchemy
DATABASE_ENGINE = os.environ.get('DATABASE_ENGINE', 'SQLITE')
if DATABASE_ENGINE == 'SQLITE':
dir_path = Path(os.path.dirname(os.path.realpath(__file__)))
path = dir_path / '..'
# Database initialisation
FILE_PATH = f'{path}/db.sqlite3'
DB_URI = 'sqlite+pysqlite:///{file_path}'
db_config = {
'SQLALCHEMY_DATABASE_URI': DB_URI.format(file_path=FILE_PATH),
'SQLALCHEMY_TRACK_MODIFICATIONS': False,
}
elif DATABASE_ENGINE == 'POSTGRESQL':
db_params = {
'host': os.environ['POSTGRES_HOST'],
'database': os.environ['POSTGRES_DB'],
'user': os.environ['POSTGRES_USER'],
'pwd': os.environ['POSTGRES_PASSWORD'],
'port': os.environ['POSTGRES_PORT'],
}
DB_URI = 'postgresql://{user}:{pwd}@{host}:{port}/{database}'
db_config = {
'SQLALCHEMY_DATABASE_URI': DB_URI.format(**db_params),
'SQLALCHEMY_TRACK_MODIFICATIONS': False,
}
else:
raise Exception('Incorrect DATABASE_ENGINE')
db = SQLAlchemy()
当使用DATABASE_ENGINE环境变量设置为POSTGRESQL时,它将正确配置。其他环境变量需要正确设置;也就是说,如果数据库引擎设置为 PostgreSQL,则需要设置POSTGRES_HOST变量。
环境变量可以单独存储在docker-compose.yaml文件中,但更方便的是将多个环境变量存储在一个文件中。让我们看一下environment.env:
DATABASE_ENGINE=POSTGRESQL
POSTGRES_DB=thoughts
POSTGRES_USER=postgres
POSTGRES_PASSWORD=somepassword
POSTGRES_PORT=5432
POSTGRES_HOST=db
请注意,用户的定义等与为测试创建 Dockerfile 的参数一致。POSTGRES_HOST被定义为db,这是服务的名称。
在为docker-compose创建的 Docker 集群中,您可以通过它们的名称引用服务。这将由内部 DNS 指向适当的容器,作为快捷方式。这允许服务之间轻松通信,因为它们可以通过名称非常容易地配置其访问。请注意,此连接仅在集群内有效,用于容器之间的通信。
我们使用 PostgreSQL 容器的测试服务定义如下:
test-postgresql:
env_file: environment.env
environment:
- PYTHONDONTWRITEBYTECODE=1
build:
dockerfile: docker/app/Dockerfile
context: .
entrypoint: pytest
depends_on:
- db
volumes:
- ./ThoughtsBackend:/opt/code
这与test-sqlite服务非常相似,但它在environment.env中添加了环境配置,并添加了对db的依赖。这意味着如果不存在db服务,docker-compose将启动db服务。
现在可以针对 PostgreSQL 数据库运行测试:
$ docker-compose run test-postgresql
Starting ch3_db_1 ... done
============== test session starts ====================
platform linux -- Python 3.6.8, pytest-4.6.0, py-1.8.0, pluggy-0.12.0 -- /opt/venv/bin/python3
cachedir: .pytest_cache
rootdir: /opt/code, inifile: pytest.ini
plugins: flask-0.14.0
collected 17 items
tests/test_thoughts.py::test_create_me_thought PASSED [ 5%]
...
tests/test_token_validation.py::test_valid_token_header PASSED [100%]
===== 17 passed, 177 warnings in 2.14 seconds ===
$
这个环境文件对于任何需要连接到数据库的服务都很有用,比如在本地部署服务。
本地部署 Docker 服务
有了所有这些元素,我们可以创建服务来本地部署 Thoughts 服务:
server:
env_file: environment.env
image: thoughts_server
build:
context: .
dockerfile: docker/app/Dockerfile
ports:
- "8000:8000"
depends_on:
- db
我们需要确保添加db数据库服务的依赖关系。我们还绑定了内部端口,以便可以在本地访问它。
我们使用up命令启动服务。up和run命令之间有一些区别,但主要区别在于run用于启动和停止的单个命令,而up设计用于服务。例如,run创建一个交互式终端,显示颜色,up显示标准输出作为日志,包括生成时间,接受-d标志以在后台运行等。通常使用其中一个而不是另一个是可以的,但是up会暴露端口并允许其他容器和服务连接,而run则不会。
现在我们可以使用以下命令启动服务:
$ docker-compose up server
Creating network "ch3_default" with the default driver
Creating ch3_db_1 ... done
Creating ch3_server_1 ... done
Attaching to ch3_server_1
server_1 | [uWSGI] getting INI configuration from /opt/uwsgi/uwsgi.ini
server_1 | *** Starting uWSGI 2.0.18 (64bit) on Sun Jun 2
...
server_1 | spawned uWSGI master process (pid: 6)
server_1 | spawned uWSGI worker 1 (pid: 7, cores: 1)
server_1 | spawned uWSGI http 1 (pid: 8)
现在在浏览器中访问localhost:8000中的服务:
![
您可以在终端中查看日志。按下Ctrl + C将停止服务器。该服务也可以使用-d标志启动,以分离终端并以守护程序模式运行:
$ docker-compose up -d server
Creating network "ch3_default" with the default driver
Creating ch3_db_1 ... done
Creating ch3_server_1 ... done
$
使用docker-compose ps检查运行的服务、它们的当前状态和打开的端口:
$ docker-compose ps
Name Command State Ports
------------------------------------------------------------------------------
ch3_db_1 postgres Up 0.0.0.0:5432->5432/tcp
ch3_server_1 /bin/sh /opt/uwsgi/start_s ... Up 0.0.0.0:8000->8000/tcp
正如我们之前所见,我们可以直接访问数据库并在其中运行原始的 SQL 命令。这对于调试问题或进行实验非常有用:
$ PGPASSWORD=somepassword pgcli -h localhost -U postgres thoughts
Server: PostgreSQL 11.3
Version: 2.0.2
postgres@localhost:thoughts>
INSERT INTO thought_model (username, text, timestamp)
VALUES ('peterparker', 'A great power carries a great
responsability', now());
INSERT 0 1
Time: 0.014s
postgres@localhost:thoughts>
现在 Thoughts 通过以下 API 可用:
$ curl http://localhost:8000/api/thoughts/
[{"id": 1, "username": "peterparker", "text": "A great power carries a great responsability", "timestamp": "2019-06-02T19:44:34.384178"}]
如果需要以分离模式查看日志,可以使用docker-compose logs <optional: service>命令:
$ docker-compose logs server
Attaching to ch3_server_1
server_1 | [uWSGI] getting INI configuration from /opt/uwsgi/uwsgi.ini
server_1 | *** Starting uWSGI 2.0.18 (64bit) on [Sun Jun 2 19:44:15 2019] ***
server_1 | compiled with version: 8.3.0 on 02 June 2019 11:00:48
...
server_1 | [pid: 7|app: 0|req: 2/2] 172.27.0.1 () {28 vars in 321 bytes} [Sun Jun 2 19:44:41 2019] GET /api/thoughts/ => generated 138 bytes in 4 msecs (HTTP/1.1 200) 2 headers in 72 bytes (1 switches on core 0)
要完全停止集群,请调用docker-compose down:
$ docker-compose down
Stopping ch3_server_1 ... done
Stopping ch3_db_1 ... done
Removing ch3_server_1 ... done
Removing ch3_db_1 ... done
Removing network ch3_default
这将停止所有容器。
将 Docker 镜像推送到远程注册表
我们所见的所有操作都适用于我们的本地 Docker 存储库。鉴于 Docker 镜像的结构以及每个层可以独立工作,它们很容易上传和共享。为此,我们需要使用一个远程存储库或 Docker 术语中的注册表,它将接受推送到它的镜像,并允许从中拉取镜像。
Docker 镜像的结构由每个层组成。只要注册表包含它所依赖的层,每个层都可以独立推送。如果先前的层已经存在,这将节省空间,因为它们只会被存储一次。
从 Docker Hub 获取公共镜像
默认注册表是 Docker Hub。这是默认配置的,它作为公共镜像的主要来源。您可以在hub.docker.com/上自由访问它,并搜索可用的镜像来基于您的镜像:
每个镜像都有关于如何使用它以及可用标签的信息。您不需要单独下载镜像,只需使用镜像的名称或运行docker pull命令。如果没有指定其他注册表,Docker 将自动从 Docker Hub 拉取:
镜像的名称也是我们在 Dockerfiles 中使用的FROM命令。
Docker 是一种分发工具的绝佳方式。现在很常见的是,一个开源工具在 Docker Hub 中有一个官方镜像,可以下载并以独立模式启动,从而标准化访问。
这可以用于快速演示,比如 Ghost(hub.docker.com/_/ghost)(一个… Redis(hub.docker.com/_/redis)实例作… Ghost 示例。
使用标签
标签是用来标记同一镜像的不同版本的描述符。有一个镜像alpine:3.9,另一个是alpine:3.8。还有 Python 的官方镜像用于不同的解释器(3.6、3.7、2.7 等),但除了版本之外,解释器可能指的是创建镜像的方式。
例如,这些镜像具有相同的效果。第一个是包含 Python 3.7 解释器的完整镜像:
$ docker run -it python:3.7
Python 3.7.3 (default, May 8 2019, 05:28:42)
[GCC 6.3.0 20170516] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
第二个也有一个 Python 3.7 解释器。请注意名称中的slim变化:
$ docker run -it python:3.7-slim
Python 3.7.3 (default, May 8 2019, 05:31:59)
[GCC 6.3.0 20170516] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
然而,镜像的大小相当不同:
$ docker images | grep python
python 3.7-slim ca7f9e245002 4 weeks ago 143MB
python 3.7 a4cc999cf2aa 4 weeks ago 929MB
如果没有指定其他标签,任何构建都会自动使用latest标签。
请记住,标签可以被覆盖。这可能会让人感到困惑,因为 Docker 和 Git 的工作方式之间存在一些相似之处,例如 Git 中的“标签”意味着不可更改。Docker 中的标签类似于 Git 中的分支。
单个镜像可以多次打标签,使用不同的标签。例如,latest标签也可以是版本v1.5:
$ docker tag thoughts-backend:latest thoughts-backend:v1.5
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
thoughts-backend latest c7a8499623e7 5 min ago 144MB
thoughts-backend v1.5 c7a8499623e7 5 min ago 144MB
请注意image id是相同的。使用标签允许您标记特定的镜像,以便我们知道它们已经准备部署或赋予它们某种意义。
推送到注册表
一旦我们给我们的镜像打了标签,我们就可以将它推送到共享注册表中,以便其他服务可以使用它。
可以部署自己的 Docker 注册表,但是,除非绝对必要,最好避免使用它。有一些云服务提供商允许您创建自己的注册表,无论是公共的还是私有的,甚至在您自己的私有云网络中。如果您想使您的镜像可用,最好的选择是 Docker Hub,因为它是标准的,而且最容易访问。在本章中,我们将在这里创建一个,但是我们将在本书的后面探索其他选项。
值得再次强调的是:维护自己的 Docker 注册表比使用提供者的注册表要昂贵得多。商业注册表的价格,除非您需要大量的仓库,将在每月几十美元的范围内,而且有来自知名云服务提供商如 AWS、Azure 和 Google Cloud 的选项。
除非您确实需要,否则避免使用自己的注册表。
我们将在 Docker Hub 注册表中创建一个新的仓库。您可以免费创建一个私有仓库,以及任意数量的公共仓库。您需要创建一个新用户,这可能是在下载 Docker 时的情况。
在 Docker 术语中,仓库是一组具有不同标签的镜像;例如,所有thoughts-backend的标签。这与注册表不同,注册表是一个包含多个仓库的服务器。
更不正式地说,通常将注册表称为仓库,将仓库称为镜像,尽管从纯粹的角度来说,镜像是唯一的,可能是一个标签(或者不是)。
然后,您可以按以下方式创建一个新的仓库:
创建仓库后,我们需要相应地给我们的镜像打标签。这意味着它应该包括 Docker Hub 中的用户名以标识仓库。另一种选择是直接使用包含用户名的镜像名称:
$ docker tag thoughts-backend:latest jaimebuelta/thoughts-backend:latest
为了能够访问仓库,我们需要使用我们在 Docker Hub 中的用户名和密码登录 Docker:
$ docker login
Login with your Docker ID to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: jaimebuelta
Password:
Login Succeeded
一旦登录,您就可以推送您的镜像:
$ docker push jaimebuelta/thoughts-backend:latest
The push refers to repository [docker.io/jaimebuelta/thoughts-backend]
1ebb4000a299: Pushed
669047e32cec: Pushed
6f7246363f55: Pushed
ac1d27280799: Pushed
c43bb774a4bb: Pushed
992e49acee35: Pushed
11c1b6dd59b3: Pushed
7113f6aae2a4: Pushed
5275897866cf: Pushed
bcf2f368fe23: Mounted from library/alpine
latest: digest: sha256:f1463646b5a8dec3531842354d643f3d5d62a15cc658ac4a2bdbc2ecaf6bb145 size: 2404
现在你可以分享镜像并从任何地方拉取它,只要本地的 Docker 已经正确登录。当我们部署生产集群时,我们需要确保执行它的 Docker 服务器能够访问注册表并且已经正确登录。
总结
在本章中,我们学习了如何使用 Docker 命令来创建和操作容器。我们学习了大多数常用的 Docker 命令,比如build,run,exec,ps,images,tag和push。
我们看到了如何构建一个 Web 服务容器,包括准备配置文件,如何构建 Dockerfile 的结构,以及如何尽可能地减小我们的镜像。我们还介绍了如何使用docker-compose在本地操作,并通过docker-compose.yaml文件连接运行在集群配置中的不同容器。这包括创建一个允许更接近生产部署的数据库容器,使用相同的工具进行测试。
我们看到了如何使用环境变量来配置我们的服务,并通过docker-compose配置来注入它们,以允许不同的模式,比如测试。
最后,我们分析了如何使用注册表来分享我们的镜像,以及如何充分标记它们并允许将它们从本地开发中移出,准备在部署中使用。
在下一章中,我们将看到如何利用创建的容器和操作来自动运行测试,并让自动化工具为我们做繁重的工作,以确保我们的代码始终是高质量的!
问题
-
在 Dockerfile 中,
FROM关键字是做什么的? -
你会如何启动一个带有预定义命令的容器?
-
为什么在 Dockerfile 中创建一个步骤来删除文件不会使镜像变得更小?
-
你能描述一下多阶段 Docker 构建是如何工作的吗?
-
run和exec命令有什么区别? -
在使用
run和exec命令时,什么时候应该使用-it标志? -
你知道除了 uWSGI 之外还有什么替代方案来提供 Python Web 应用程序吗?
-
docker-compose用于什么? -
你能描述一下 Docker 标签是什么吗?
-
为什么有必要将镜像推送到远程注册表?
进一步阅读
为了进一步了解 Docker 和容器,你可以查看Mastering Docker – Third Edition一书(www.packtpub.com/eu/virtualization-and-cloud/mastering-docker-third-edition)。要调整容器并学习如何使你的应用程序更高效,可以查看* Docker High Performance - Second Edition*一书(www.packtpub.com/eu/networking-and-servers/docker-high-performance-second-edition),其中涵盖了许多分析和发现性能问题的技术。