Docker-无服务器应用指南-全-
Docker 无服务器应用指南(全)
原文:
annas-archive.org/md5/fc665ee4d43ecedc83fe58ea86b72525译者:飞龙
前言
容器技术今天已经非常成熟。Docker 作为帮助普及容器的软件包,现在已经被成千上万的开发人员作为日常 DevOps 工具使用。我可以说,Docker 容器引擎现在已经变成了一款无聊的软件。对于一个基础设施级的软件包而言,“无聊”意味着高质量和稳定性。我每天都在使用它,而我知道你们也将它作为工具链的一部分。但我们不再对 Docker 的新版本发布感到兴奋。就像我们对 Linux 内核发布的感受一样。带着这样的感觉,我认为容器的黄金时代已经在不久前结束。
Docker 的崛起发生在 2013 年。它的“文艺复兴”时期是在 2014 到 2016 年之间。2016 年,Docker Swarm 和 Kubernetes 之间的许多编排引擎竞争达到了巅峰。Swarm2K 项目曾是我一生中难得的经历。Docker 后来在 2017 年宣布也将支持 Kubernetes。这场竞赛也就此结束。
几天前,即 2018 年 3 月,就在本书即将出版之际,Docker 的创始人 Solomon Hykes 离开了 Docker Inc.。Docker 这家公司一直在缓慢而强烈地从初创公司向企业业务转型。这对我们意味着什么?企业意味着稳定,而初创公司意味着冒险。让我们迈向新的冒险——容器之后的无服务器时代。
本书讨论的主题是无服务器。它是容器和微服务之后的自然进化,方式各异。首先,Docker 容器成为了函数的部署单元,成为函数即服务(FaaS)架构中的一个基本工作单元。其次,微服务架构正在逐步演变成 FaaS 架构。FaaS 实际上可以部署在本地或云端。当整个 FaaS 堆栈由云服务提供商管理时,它就变成了完全的无服务器架构。
但是,在这之间会有一些东西。那就是混合无服务器 FaaS 架构。这种架构是我希望读者在本书中发现并享受的核心思想。它是我们在成本、自行管理服务器以及服务器控制程度之间取得平衡的一个点。
本书详细介绍了三个主要的 Docker FaaS 平台,分别是OpenFaas、OpenWhisk 和Fn Project。这些项目都处于早期阶段,并且正在积极成熟。因此,对于读者和我来说,这是一个很好的机会,可以一起学习并迎接这一新的浪潮。让我们一起努力。
本书适合的人群
如果你是一个开发人员、Docker 工程师、DevOps 工程师,或者任何对在无服务器环境中使用 Docker 感兴趣的相关人员,那么本书适合你。
如果你是本科生或研究生,本书同样适合你,用来加强你在无服务器和云计算领域的知识。
本书内容
第一章,无服务器与 Docker,介绍了无服务器和 Docker。我们将在本章中找到它们之间的关系。我们还将学习通过研究多个 FaaS 平台架构总结出的常见架构。到本章结束时,我们将学会如何在所有三个 FaaS 平台——OpenFaaS、The Fn Project 和 OpenWhisk 上实现“Hello World”。
第二章,Docker 与 Swarm 集群,回顾了容器技术、命名空间和 cgroups。然后,我们将介绍 Docker,包括如何安装它,如何使用其基本命令,并理解它的构建、分发和运行工作流。进一步地,我们将回顾 Docker 内置的编排引擎——Docker Swarm。我们将学习如何设置集群并了解 Docker Swarm 的内部工作原理。接着,我们将学习如何设置 Docker 网络、将其附加到容器,并了解如何在 Docker Swarm 中扩展服务。
第三章,无服务器框架,讨论了无服务器框架,包括 AWS Lambda、Google Cloud Functions、Azure Functions 和 IBM Cloud Functions 等平台。我们将在本章结束时介绍一个与 FaaS 平台无关的框架——无服务器框架。
第四章,Docker 上的 OpenFaaS,解释了如何使用 OpenFaaS。我们将探索它的架构和组件。接着,我们将学习如何使用其提供的工具和模板来准备、构建和部署函数,如何在 Swarm 之上准备其集群,如何使用其用户界面,以及 OpenFaaS 如何利用 Docker 多阶段构建。我们还将讨论如何使用 Prometheus 来监控 FaaS 平台。
第五章,Fn 项目,探索了另一个 FaaS 平台。类似于第四章,Docker 上的 OpenFaaS,我们将从其架构和组件开始,然后通过一组 CLI 命令来构建、打包和部署函数到 Fn 平台。本章后续部分,我们将学习如何使用其内置 UI 来监控该平台。此外,我们还将使用一个熟悉的工具来帮助分析其日志。
第六章,Docker 上的 OpenWhisk,讨论了 OpenWhisk,这是本书中的第三个也是最后一个 FaaS 平台。我们将了解它的概念和架构。
第七章,操作 FaaS 集群,讨论了使用 Docker Swarm 准备和操作生产级 FaaS 集群的几种技术。我们将讨论如何用另一种易于使用的容器网络插件替代整个网络层。我们还将展示如何实现新的路由网格机制,以避免当前入口实现中的漏洞。此外,我们将讨论一些高级话题,如 分布式追踪 及其实现方法。我们甚至会涵盖通过使用竞价实例来降低成本的概念,并展示如何在这个动态基础设施上实现 Swarm。
第八章,将它们汇总起来,解释了如何实现一个异构的 FaaS 系统,结合所有三个 FaaS 平台在一个强大的产品级 Swarm 集群中无缝运行。我们将展示一个基于移动端的银行转账用例,同时包括一个遗留包装器、一个移动后端 WebHook,以及使用 FaaS 的流数据处理。这里的附加亮点是,我们还为用例添加了区块链,展示它们的互操作性。
第九章,无服务器的未来,通过先进的概念和研究原型实现来总结本书,这些内容超越了当前的无服务器和 FaaS 技术。
如何充分利用本书
读者应该了解 Linux 和 Docker 命令的基础知识。虽然这不是强制性的,但如果读者对网络协议有基本了解并且对云计算概念有所熟悉,将会是一个很大的加分项。
尽管可以使用 MacBook 或 Windows 操作系统的 PC 来运行本书中的示例,但强烈建议读者使用 Ubuntu Linux 16.04 及以上版本。使用 MacBook 或 Windows 的读者可以通过虚拟机上的 Linux 或云实例来运行示例。
下载示例代码文件
您可以从您在 www.packtpub.com 的账户下载本书的示例代码文件。如果您是在其他地方购买的本书,您可以访问 www.packtpub.com/support 并注册,文件将直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
请在 www.packtpub.com 登录或注册。
-
选择 SUPPORT 选项卡。
-
点击 Code Downloads & Errata。
-
在搜索框中输入书名,并按照屏幕上的指示操作。
下载完成后,请确保使用最新版本的工具解压或提取文件夹:
-
Windows 用 WinRAR/7-Zip
-
Mac 用 Zipeg/iZip/UnRarX
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,地址是 github.com/PacktPublishing/Docker-for-Serverless-Applications。如果代码有更新,GitHub 上的现有代码库也会进行更新。
我们的丰富书籍和视频目录中还有其他代码包,您可以访问 github.com/PacktPublishing/。快来看看吧!
使用的约定
本书中使用了多种文本约定。
文本中的代码: 表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 用户名。示例:“我们将尝试使用echoit函数输出hello world,并使用 OpenFaaS。”
代码块如下所示:
FROM ubuntu
RUN apt-get update && apt-get install -y nginx
EXPOSE 80
ENTRYPOINT ["nginx", "-g", "daemon off;"]
任何命令行输入或输出都按如下方式书写:
$ curl -sSL https://get.docker.com | sudo sh
$ docker swarm init --advertise-addr=eth0
粗体: 表示新术语、重要词汇或屏幕上显示的词汇。例如,菜单或对话框中的词语会以这种方式显示。示例:“以下截图展示了浏览器运行 OpenFaaS Portal。”
警告或重要说明以这种方式呈现。
提示和技巧以这种方式呈现。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 发送电子邮件至 feedback@packtpub.com,并在邮件主题中注明书名。如果您对本书的任何部分有疑问,请通过 questions@packtpub.com 联系我们。
勘误表: 尽管我们已尽力确保内容的准确性,但难免会出现错误。如果您在本书中发现错误,我们将非常感激您向我们报告。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击“勘误表提交”链接,并填写相关信息。
盗版: 如果您在互联网上发现我们作品的非法复制品,无论形式如何,我们将非常感激您提供其位置地址或网站名称。请通过 copyright@packtpub.com 联系我们,并附上相关材料的链接。
如果你有兴趣成为作者: 如果你在某个主题方面有专长,且有意撰写或参与书籍的编写,请访问 authors.packtpub.com。
评论
请留下评论。阅读并使用本书后,不妨在您购买书籍的网站上留下评论。潜在读者可以通过您的无偏见评价做出购买决策,我们可以了解您对我们的产品的看法,作者也能看到您对其书籍的反馈。谢谢!
有关 Packt 的更多信息,请访问 packtpub.com。
第一章:无服务器与 Docker
说到容器,大多数人已经知道如何将应用程序打包成容器作为部署单元。Docker 允许我们以其 事实标准 格式将应用程序部署到几乎所有地方,从我们的笔记本电脑、QA 集群、客户站点,甚至是公共云,如下图所示:

图 1.1:将 Docker 容器部署到各种基础设施和平台
如今,在公共云上运行 Docker 容器已被视为常态。我们已经从按需启动云实例、按需付费的账单中受益。无需等待硬件购买,我们还可以通过敏捷方法并使用持续交付管道来更快地优化资源。
根据 Docker 的一份报告,总拥有成本(TCO)在其客户使用 Docker 将现有应用程序迁移到云端时减少了 66%。不仅可以大幅降低 TCO,使用 Docker 的公司还可以将上市时间从几个月缩短为几天。这是一个巨大的胜利。
将容器部署到云基础设施,如 AWS、Google Cloud 或 Microsoft Azure,已经简化了许多事情。云基础设施使组织无需购买自己的硬件,也不需要专门的团队来维护这些硬件。
然而,即使在使用公共云基础设施时,组织仍然需要某些角色,例如架构师,来负责站点可靠性和可扩展性。一些人被称为 SRE,即 站点可靠性工程师。
此外,组织还需要处理系统级的软件包和依赖项。由于软件堆栈会不断变化,他们需要自己进行应用程序安全性和操作系统内核的修补。在许多场景中,这些组织中的团队必须根据负载峰值的需求,意外地扩展集群的规模。此外,工程师还需要尽可能地将集群缩小,以减少云计算费用,因为这是一种按需付费的模式。
开发人员和工程团队总是努力提供出色的用户体验和站点可用性。在此过程中,按需实例的过度配置或低效利用可能会带来高昂的成本。根据 AWS 的一份白皮书,d0.awsstatic.com/whitepapers/optimizing-enterprise-economics-serverless-architectures.pdf,低效利用的实例多达已配置机器的 85%。
无服务器计算平台,如 AWS Lambda、Google Cloud Functions、Azure Functions 和 IBM Cloud Functions,旨在解决这些过度配置和低效利用的问题。
本章将涵盖以下主题:
-
无服务器
-
无服务器 FaaS 的常见架构
-
无服务器/FaaS 使用案例
-
Hello world,FaaS/Docker 方式
什么是无服务器?
试着想象我们生活在一个完全由智能软件驱动的世界。
那将是一个我们可以在不做任何事情的情况下开发软件的世界。只需要说出我们希望运行什么样的软件,几分钟后,它就会出现在互联网上,为许多用户提供服务。我们只需要为用户发出的请求付费。嗯,那样的世界太不真实了。
现在,让我们更加现实一点,想象一个我们仍然需要自己开发软件的世界。至少目前,我们不需要关心任何服务器的配置和管理。实际上,这至少是一个对开发者来说最好的世界,我们可以将应用部署到数百万用户面前,而无需担心任何服务器,甚至不需要知道这些服务器在哪里。我们唯一真正想要的,是创建一个能够按规模解决业务需求、价格合理的应用。无服务器平台就是为了解决这些问题而创建的。
作为对开发者和快速增长企业的回应,无服务器平台似乎是一个巨大的胜利。但它们究竟是什么?
无服务器与 FaaS 之间的关系
下图展示了事件驱动编程、FaaS 和无服务器 FaaS 的位置关系,其中无服务器 FaaS 是 FaaS 和无服务器之间的交集区域:

图 1.2:展示无服务器与 FaaS 之间关系的维恩图
无服务器是一种范式转变,使得开发者不再需要担心服务器的配置和运营。计费方式是按请求计费。此外,公共云上有许多有用的服务供我们选择,我们可以将它们连接起来并用来解决业务问题,从而完成任务。
无服务器架构中的应用通常使用第三方服务来完成其他任务,如身份验证、数据库系统或文件存储。虽然无服务器应用不一定需要使用这些第三方服务,但以这种方式架构应用能够充分利用基于云的无服务器平台。这种架构中的前端应用通常是一个厚重、强大的前端,例如单页面应用或移动应用。
这一无服务器计算转变的执行引擎是 Function as a Service 或 FaaS 平台。FaaS 平台是一种计算引擎,允许我们编写一个简单、自包含、单一目的的函数来处理或计算任务。FaaS 平台的计算单元是一个推荐无状态的函数。这个无状态属性使得函数可以被平台完全管理和扩展。
FaaS 平台不一定要运行在无服务器环境中,比如 AWS Lambda,但也有许多 FaaS 实现,比如 OpenFaaS、Fn 项目和 OpenWhisk,允许我们在自己的硬件上部署和运行 FaaS。如果 FaaS 平台运行在无服务器环境中,它将被称为无服务器 FaaS。例如,我们在本地运行 OpenWhisk,那么它就是我们的 FaaS 平台。但当它在 IBM Cloud 上运行作为 IBM Cloud Functions 时,它就是一个无服务器 FaaS。
每个 FaaS 平台都被设计为使用事件驱动的编程模型,以便能够高效地连接到公共云上的其他服务。通过异步事件模型和函数的无状态特性,这种环境使得无服务器 FaaS 成为下一代计算的理想模型。
无服务器 FaaS 的缺点
那么这种方法的缺点是什么呢?它们如下:
-
我们基本上不拥有服务器。 当我们需要对基础设施进行细粒度控制时,无服务器模型并不适用。
-
无服务器 FaaS 有很多限制,尤其是函数执行的时间限制,以及每个函数实例的内存限制。它还引入了一种固定且特定的应用程序开发方式。可能直接将现有系统迁移到 FaaS 会有些困难。
-
如果不允许将所有工作负载迁移出组织,那么在私有或混合基础设施上完全使用无服务器平台是不可能的。无服务器架构的一个真正好处是云上存在便捷的公共服务。
Docker 来救援
本书讨论了我们自己的基础设施上的 FaaS 与无服务器 FaaS 之间的平衡。我们尝试通过选择三个主要的 FaaS 平台来简化和统一 FaaS 的部署模型,这些平台允许我们将 Docker 容器作为函数部署,且我们在本书中会详细讨论这些平台。
以 Docker 容器作为部署单元(函数),Docker 作为开发工具,Docker 作为编排引擎和网络层,我们可以开发无服务器应用程序,并将其部署在我们可用的硬件上,部署在我们自己的私有云基础设施上,或是一个混合云,将我们的硬件与公共云的硬件混合在一起。
其中一个最重要的点是,使用具备 Docker 技能的小型开发团队足够轻松地管理这种基础设施。
回顾一下前面的图 1.2。如果你在阅读完本章后有所启发,让我们猜测一下这本书将讨论的内容。我们应该处于这个图中的哪个位置呢?答案将在本章结束时揭晓。
无服务器 FaaS 的常见架构
在进入其他技术章节之前,本书在撰写过程中对至少六个无服务器 FaaS 平台的常见架构进行了调查和研究,结果如图所示。这是现有 FaaS 平台的提炼概述,如果你想创建一个新的平台,它是一个推荐的架构:

图 1.3:描述 FaaS 平台常见架构的框图
系统层
从下到上的架构描述如下:
-
我们有一些物理或虚拟机器。这些机器可以位于公有云或私有云中。有些可能是位于防火墙内的物理设备,或者位于组织内部。它们可以混合在一起,作为混合基础设施。
-
下一层是 操作系统,当然还有内核。我们需要一个支持容器隔离的现代内核操作系统,例如 Linux,或者至少兼容 runC。Windows 或 Windows Server 2016 拥有基于 Hyper-V 的隔离,兼容 Docker。
-
架构中的下一层是 容器运行时(系统级)。我们强调它是系统级容器运行时,因为它并不是用来直接运行 FaaS 函数的。这个层级负责为集群提供服务。
-
接下来是可选的容器编排引擎,或 容器编排器 层。这个层级包括 Docker Swarm 或 Kubernetes。本书中我们使用 Docker Swarm,但你可能会发现本书介绍的一些 FaaS 平台并未使用任何编排工具。基本上,只有 Docker 和容器网络就足够让 FaaS 平台有效启动和运行。
FaaS 层
现在,我们将讨论实际的 FaaS 层。我们将从左到右展开讨论:
-
整个架构的前沿组件是 FaaS 网关。在一些实现中,网关是可选的,但在许多实现中,这个组件有助于提供 HTTPS 服务并缓存一些静态内容,例如平台的 UI 部分。网关实例有助于提高吞吐量。它通常是一个无状态的基于 HTTP 的反向代理。因此,这个组件易于扩展。
-
启动器 是 FaaS 最重要的组件之一。启动器负责模拟实际的调用请求给平台的其他部分。在 OpenWhisk 中,这个组件被称为 控制器,例如。在 Fn 中,其 Fn 服务器内部的部分充当 启动器。
-
消息总线 是 FaaS 平台的消息骨干。一些没有此组件的架构在实现异步调用或重试模式时会遇到困难,进而影响平台的鲁棒性。消息总线将启动器与执行器解耦。
-
执行器是执行真正函数调用的组件。它连接到自己的容器运行时(应用级别),启动真正的函数执行顺序。所有结果和日志将被写入中央日志存储。
-
日志存储是平台的唯一真相来源。它应该设计成存储几乎所有内容,从函数活动到每次调用的错误日志。
-
容器运行时(应用级别)是负责启动函数容器的组件。在本书中,我们简单地使用 Docker 及其底层引擎作为运行时组件。
Serverless/FaaS 的使用案例
Serverless/FaaS 是一种通用的计算模型。因此,几乎可以使用这种编程范式实现任何类型的工作负载。Serverless/FaaS 的使用案例可以从常规 Web 应用的 API、移动应用的 RESTful 后台、日志或视频处理的函数、WebHook 系统的后台,到流数据处理程序等。

图 1.4:演示项目的框图
在第八章,将它们整合在一起,我们将讨论一个如前图所示的系统,并涵盖以下使用案例:
-
WebHook 系统的 API:在前图中,你可能看到UI 的后台。该系统允许我们定义一个 WebHook,并将其实现为 FaaS 函数,使用本书后面章节中讨论的某个框架。
-
用于封装遗留系统的 API:在前图的右上角,我们会看到一组函数连接到Chrome Headless(一个功能完备的运行中的 Google Chrome 实例)。该函数将一组命令封装起来,指示 Google Chrome 为我们操作遗留系统。
-
APIs 作为其他服务的抽象:在右下角有两个简单的模块,第一个是运行在 FaaS 平台上的函数,连接到第二个模块,Mock Core Bank System,它是一个更复杂的 REST API。这个系统部分展示了如何使用 FaaS 函数作为抽象层,简化复杂系统的接口。
-
流数据处理:我们还将实现一个数据处理代理,一个事件监听器,它监听一个事件源——你可能会看到以太坊的标志,旁边有一个从左侧连接过来的圆圈。这个代理会监听来自源的数据流,然后调用运行在 FaaS 平台上的函数。
Hello world,FaaS/Docker 方式
本书涵盖了 FaaS 在 Docker 上的三大主要框架。因此,如果我选择一个特定框架用于第一章的hello world程序,那就不太公平了。我会让你根据自己的喜好选择一个。
以下是在 Linux 机器上的常见设置。对于 Mac 或 Windows 用户,请跳过此步骤并下载 Docker for Mac 或 Docker for Windows:
$ curl -sSL https://get.docker.com | sudo sh
如果你选择在本章中使用 OpenFaaS,可以通过使用 Play with Docker (labs.play-with-docker.com/) 来简化此设置过程,该平台会自动在单节点 Docker Swarm 上安装 OpenFaaS。
当我们安装好 Docker 后,只需初始化 Swarm,以使我们的单节点集群准备好运行:
$ docker swarm init --advertise-addr=eth0
如果之前的命令失败,尝试将网络接口名称更改为与你的名称匹配。但如果仍然失败,只需输入机器的其中一个 IP 地址即可。
如果一切设置成功,让我们开始在各个 FaaS 平台上运行一系列的 hello world 程序。
Hello OpenFaas
我们将尝试使用 OpenFaaS 运行 echoit 函数进行 hello world。首先,从 github.com/openfaas/faas 克隆项目,并只进行一层深度克隆,以加快克隆过程:
$ git clone --depth=1 https://github.com/openfaas/faas
然后,进入 faas 目录,并使用以下命令简单地部署 OpenFaaS 默认堆栈:
$ cd faas
$ docker stack deploy -c docker-compose.yml func
等待堆栈启动完成。然后,我们用 curl 命令进行 hello world:
$ curl -d "hello world." -v http://localhost:8080/function/func_echoit
* Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /function/func_echoit HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.47.0
> Accept: */*
> Content-Length: 12
> Content-Type: application/x-www-form-urlencoded
>
* upload completely sent off: 12 out of 12 bytes
< HTTP/1.1 200 OK
< Content-Length: 12
< Content-Type: application/x-www-form-urlencoded
< Date: Fri, 23 Mar 2018 16:37:30 GMT
< X-Call-Id: 866c9294-e243-417c-827c-fe0683c652cd
< X-Duration-Seconds: 0.000886
< X-Start-Time: 1521823050543598099
<
* Connection #0 to host localhost left intact
hello world.
玩了一会儿后,我们也可以使用 docker stack rm 来移除所有正在运行的服务:
$ docker stack rm func
Hello OpenWhisk
让我们快速进入 OpenWhisk。要使用 OpenWhisk 进行 hello world,我们还需要一个 docker-compose 二进制文件。请访问 github.com/docker/compose/releases 并按照那里的说明进行安装。
使用 OpenWhisk 时,整个堆栈的启动时间可能会比 OpenFaaS 稍长。但是,由于 hello world 已经内置,整体命令会更简洁。
首先,从其 GitHub 仓库克隆 OpenWhisk 开发工具:
$ git clone --depth=1 https://github.com/apache/incubator-openwhisk-devtools devtools
然后进入 devtools/docker-compose 目录,并使用以下命令手动拉取镜像:
$ cd devtools/docker-compse
$ docker-compose pull
$ docker pull openwhisk/nodejs6action
之后,只需调用 make quick-start 来执行设置:
$ make quick-start
等待 OpenWhisk 集群启动。这可能需要最多 10 分钟。
之后,运行以下命令 make hello-world 来注册并调用 hello world 操作:
$ make hello-world
creating the hello.js function ...
invoking the hello-world function ...
adding the function to whisk ...
ok: created action hello
invoking the function ...
invokation result: { "payload": "Hello, World!" }
{ "payload": "Hello, World!" }
deleting the function ...
ok: deleted action hello
确保你处于一个快速的网络环境中。OpenWhisk 拉取 invoke 和 controller 时的慢速往往会导致 make quick-start 失败。
要清理环境,只需使用 make destroy 命令来终止目标:
$ make destroy
向 Fn 项目问好
这是本书中覆盖的另一个 FaaS 项目。我们通过安装 Fn CLI 快速完成 hello world。然后使用它启动一个本地 Fn 服务器,创建一个应用程序,并创建一个路由,将其链接到应用程序下的一个预构建的 Go 函数。之后,我们将使用 curl 命令测试部署的 hello world 函数。
这是安装 Fn 客户端的标准命令:
$ curl -LSs https://raw.githubusercontent.com/fnproject/cli/master/install | sudo sh
之后,我们可以使用 fn 命令。让我们启动一个 Fn 服务器。使用 --detach 使其在后台运行:
$ fn start --detach
好的,如果我们看到一个容器 ID,就可以开始了。接下来,快速创建一个 Fn 应用程序,并将其命名为 goapp:
$ fn apps create goapp
然后,我们已经在 Docker Hub 上有一个预构建的镜像,名为chanwit/fn_ch1:0.0.2。只需使用它即可。我们使用fn routes create命令将新路由与镜像连接。此步骤的目的是实际定义一个函数:
$ fn routes create --image chanwit/fn_ch1:0.0.2 goapp /fn_ch1
/fn_ch1 created with chanwit/fn_ch1:0.0.2
好的,路由已准备好。现在,我们可以使用 curl 命令直接调用我们在 Fn 上的 hello world 程序:
$ curl -v http://localhost:8080/r/goapp/fn_ch1
* Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /r/goapp/fn_ch1 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.47.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Length: 26
< Content-Type: application/json; charset=utf-8
< Fn_call_id: 01C99YJXCE47WG200000000000
< Xxx-Fxlb-Wait: 383.180124ms
< Date: Fri, 23 Mar 2018 17:30:34 GMT
<
{"message":"Hello World"}
* Connection #0 to host localhost left intact
好的,看起来 Fn 一切都按照预期正常工作。让我们在它完成后移除服务器:
$ docker rm -f fnserver
练习
每章末尾都会有一组问题,帮助我们复习当前章节的内容。让我们尝试不翻回章节内容,直接回答每个问题:
-
无服务器架构的定义是什么?
-
FaaS 的定义是什么?
-
描述 FaaS 和无服务器之间的区别?
-
Docker 在无服务器应用程序世界中的角色是什么?
-
FaaS 的常见架构是什么样的?
-
尝试解释为什么我们处于下图中的阴影区域:

图 1.5:FaaS 和本书涵盖的无服务器区域的范围
总结
本章介绍了无服务器架构和 Docker,定义了无服务器架构和 FaaS。我们了解了无服务器的优势、何时使用它以及何时避免使用它。无服务器 FaaS 是由供应商在公共云上运行的 FaaS 平台,而 FaaS 可能需要在私有、混合或本地环境中运行。这时,我们可以使用 Docker。Docker 将帮助我们构建 FaaS 应用程序,并为运行基于容器的函数准备容器基础设施。
我们预览了将在后续章节中一步步构建的演示项目。然后,我们快速地在三个领先的 FaaS 平台上完成了 Docker 的 hello world 示例,展示了在我们自己的 Docker 集群上运行 FaaS 平台是多么简单。
在下一章中,我们将回顾容器的概念以及它背后的技术。我们还将介绍 Docker 及其工作流程,接着我们将学习 Docker Swarm 集群的概念以及如何准备它。最后,我们将讨论 Docker 如何适应无服务器的世界。
第二章:Docker 和 Swarm 集群
本章将回顾容器技术,并介绍 Docker 及其编排引擎,以及 Docker Swarm 模式。然后,我们将讨论为什么需要 Docker 基础设施来部署和运行无服务器(serverless)和函数即服务(FaaS)应用。 本章涵盖的主题如下:
-
容器与 Docker
-
设置 Docker Swarm 集群
-
使用 Docker 执行容器网络操作
-
为什么 Docker 适合无服务器和 FaaS 基础设施
什么是容器?
在讨论 Docker 之前,最好先了解一下软件容器背后的技术。
虚拟机是一种常见的虚拟化技术,并已被云服务提供商和企业广泛采用。实际上,软件容器(简称容器)也是一种虚拟化技术,但它们与虚拟机有所不同。关键的区别在于每个容器共享主机机器的内核,而每个虚拟机都有自己独立的内核。基本上,容器在操作系统级别使用虚拟化技术,而不是虚拟机监控程序。下图展示了容器和虚拟机堆栈的比较:

图 2.1:容器与虚拟机的对比
Linux 的容器技术依赖于两个重要的内核功能,命名空间和控制组(cgroups)。命名空间将一个进程隔离,使其拥有自己的一组全局资源,如进程 ID(PID)和网络。控制组或控制组提供了一种计量和限制资源的机制,如 CPU 使用率、内存、块 I/O 和网络带宽:

图 2.2:Linux 能力—容器使用的命名空间和控制组
使用 Linux 的命名空间和控制组(cgroups)功能的核心引擎叫做runC。它是一个用于启动和运行容器的工具,采用开放容器倡议(OCI)格式。Docker 在起草这个规范中起了重要作用,因此 Docker 容器镜像与 OCI 规范兼容,因此可以通过 runC 运行。Docker 引擎本身在底层使用runC来启动每个容器。
什么是 Docker?
过去,容器的管理和使用相当困难。Docker 基本上是一套帮助我们准备、管理和执行容器的技术。在虚拟机的世界里,我们需要一个虚拟机监控程序(hypervisor)来处理所有虚拟机实例。类似地,在容器的世界里,我们使用 Docker 作为容器引擎来处理与容器相关的一切事务。
不可否认,Docker 是目前最流行的容器引擎。使用 Docker 时,我们遵循 Docker 本身推荐的三个概念:构建(build)、运输(ship)和运行(run)。
-
Build-Ship-Run的工作流程是由 Docker 的理念优化的。在Build步骤中,我们可以快速构建和销毁容器镜像。作为开发者,我们可以将容器构建步骤作为我们开发周期的一部分。
-
在Ship步骤中,我们将容器镜像运送到不同的地方,从开发笔记本到 QA 服务器,再到预生产服务器。我们将容器镜像发送到公共集线器存储,或者存储到我们公司内部的私有注册中心。最终,我们将容器镜像送到生产环境中运行。
-
在Run步骤中,Docker 帮助我们使用 Swarm 集群准备生产环境。我们从容器镜像启动容器。我们可以调度容器在集群中的特定部分运行,并设置一些特定约束。我们使用 Docker 命令管理容器的生命周期:

图 2.3:Build-ship-run
安装 Docker
在开始构建-运送-运行步骤之前,我们需要在机器上安装 Docker。在 Linux 上,我们使用经典的安装方法,Docker 社区版(CE或Docker-CE):
$ curl -sSL https://get.docker.com | sudo bash
本书中,我们将使用 Debian 或 Ubuntu 机器来演示 Docker。在 Debian/Ubuntu 机器上,我们将通过apt-get获取 Docker 的最稳定版本(截至写作时),并将其降级到版本 17.06.2。如果我们已经有了更新版本的 Docker,例如 17.12 或 18.03,它将被降级到 17.06.2:
$ sudo apt-get install docker-ce=17.06.2~ce-0~ubuntu
对于 macOS 和 Windows 系统,我们可以从 Docker 官网下载安装 Docker:
-
Docker for Mac:
www.docker.com/docker-mac -
Docker for Windows:
www.docker.com/docker-windows
要检查已安装的 Docker 版本,我们可以使用docker version命令:
$ docker version
Client:
Version: 17.06.2-ce
API version: 1.30
Go version: go1.8.3
Git commit: cec0b72
Built: Tue Sep 5 20:00:33 2017
OS/Arch: linux/amd64
Server:
Version: 17.06.2-ce
API version: 1.30 (minimum version 1.12)
Go version: go1.8.3
Git commit: cec0b72
Built: Tue Sep 5 19:59:26 2017
OS/Arch: linux/amd64
Experimental: true
docker version打印出来的信息分为客户端和服务器两个部分。客户端部分告诉我们关于docker二进制文件的信息,用于发出命令。服务器部分则告诉我们dockerd(Docker 引擎)的版本。
从前面的代码片段中我们可以看到,客户端和服务器的版本都是 17.06.2-ce,即稳定版 17.06 Community Edition 的第二次更新。服务器允许最低版本为 1.12 的 Docker 客户端进行连接。API 版本*告诉我们,dockerd实现了远程 API 版本 1.30。
如果我们预计将使用下一个稳定版本的 Docker,我们应该选择即将发布的 17.06.3、17.09.x 或 17.12.x 版本。
构建容器镜像
我们使用 Docker 准备软件及其执行环境,将它们打包到文件系统中。我们称这个步骤为构建容器镜像。好了,让我们开始吧。我们将在 Ubuntu 上构建我们自己的 NGINX 服务器版本my-nginx,作为 Docker 镜像。请注意,容器镜像和 Docker 镜像在本书中会互换使用。
我们创建一个名为my-nginx的目录并切换到该目录:
$ mkdir my-nginx
$ cd my-nginx
然后,我们创建一个名为 Dockerfile 的文件,内容如下:
FROM ubuntu
RUN apt-get update && apt-get install -y nginx
EXPOSE 80
ENTRYPOINT ["nginx", "-g", "daemon off;"]
我们将逐行解释 Dockerfile 的内容:
-
首先,它表示我们希望使用名为
ubuntu的镜像作为我们的基础镜像。这个ubuntu镜像存储在 Docker Hub 上,这是一个由 Docker Inc.托管的中央镜像注册服务器。 -
其次,它表示我们希望使用
apt-get命令安装 NGINX 和相关的软件包。这里的技巧是,ubuntu是一个普通的 Ubuntu 镜像,没有任何软件包信息,因此我们需要在安装软件包之前运行apt-get update。 -
第三,我们希望这个镜像为我们的 NGINX 服务器在容器内打开端口
80。 -
最后,当我们从这个镜像启动一个容器时,Docker 将在容器内部为我们运行
nginx -g daemon off;命令。
现在,我们已经准备好构建我们的第一个 Docker 镜像。输入以下命令来开始构建镜像。请注意,命令的末尾有一个点:
$ docker build -t my-nginx .
你现在会看到类似以下内容的输出,输出中会有不同的哈希值,所以不用担心。步骤 2 到 4 会花费几分钟时间完成,因为它会将 NGINX 软件包下载并安装到镜像文件系统中。只需确保有四个步骤,并且最后以Successfully tagged my-nginx:latest的消息结尾:
Sending build context to Docker daemon 2.048kB
Step 1/4 : FROM ubuntu
---> ccc7a11d65b1
Step 2/4 : RUN apt-get update && apt-get install -y nginx
---> Running in 1f95e93426d3
...
Step 3/4 : EXPOSE 8080
---> Running in 4f84a2dc1b28
---> 8b89cae986b0
Removing intermediate container 4f84a2dc1b28
Step 4/4 : ENTRYPOINT nginx -g daemon off;
---> Running in d0701d02a092
---> 0a393c45ed34
Removing intermediate container d0701d02a092
Successfully built 0a393c45ed34
Successfully tagged my-nginx:latest
我们现在在本地计算机上拥有一个名为my-nginx:latest的 Docker 镜像。我们可以使用docker image ls命令(或者使用旧版命令docker images)来检查该镜像是否真的存在:
$ docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
my-nginx latest 0a393c45ed34 18 minutes ago 216MB
基本上,这就是 Docker 的构建概念。接下来,我们继续讨论镜像发布。
镜像发布
我们通常通过 Docker 注册表发布 Docker 镜像。由 Docker Inc.托管的公共注册表称为Docker Hub。要将 Docker 镜像发布到注册表中,我们使用docker push命令。当我们启动一个容器时,它的镜像会在运行之前自动检查并下载到主机上。下载过程可以通过docker pull命令显式完成。下图展示了不同环境和注册表之间的推送/拉取行为:

图 2.4:镜像推送和拉取工作流
在前面的图示中,开发人员从 Docker 公共注册表(Docker Hub)拉取镜像,然后从他们自己的 Docker 私有注册表推送和拉取镜像。在开发环境中,每个环境会通过某种机制来触发拉取镜像并运行它们。
要检查我们的 Docker 守护进程是否允许通过非加密的 HTTP 与 Docker 注册表进行不安全的交互,我们可以执行docker info,然后用grep查找Registries关键字。
请注意,不建议在生产环境中使用不安全的 Docker 注册表。已经提醒过你了!
$ docker info | grep -A3 Registries
Insecure Registries:
127.0.0.0/8
Live Restore Enabled: false
好的,看到127.0.0.0/8表示我们被允许这么做。我们将会在127.0.0.1:5000上运行一个本地的 Docker 注册表。让我们来设置它。
要启动本地 Docker 注册表,只需从 Docker 注册表 V2 镜像中运行:
$ docker container run --name=registry -d -p 5000:5000 registry:2
6f7dc5ef89f070397b93895527ec2571f77e86b8d2beea2d8513fb30294e3d10
我们应该检查它现在是否已经启动并运行:
$ docker container ls --filter name=registry
CONTAINER ID IMAGE COMMAND CREATED STATUS
6f7dc5ef89f0 registry:2 "/entrypoint.sh /e.." 8 seconds ago Up
container run命令及其他相关命令将在运行容器部分再次讨论。
回想一下,我们已经构建了一个名为my-nginx的镜像。我们可以检查它是否仍然存在,这次我们使用--filter reference来选择仅以nginx结尾的镜像名称:
$ docker image ls --filter reference=*nginx
REPOSITORY TAG IMAGE ID CREATED SIZE
my-nginx latest a773a4303694 1 days ago 216MB
nginx latest b8efb18f159b 2 months ago 107MB
我们还可以通过简化命令为docker image ls *nginx,它会得到相同的结果。
让我们为镜像打标签。我们将my-nginx打标签为127.0.0.1:5000/my-nginx,这样它就可以推送到我们的私有 Docker 注册表中。我们可以使用docker image tag命令(对于旧版的顶级命令是docker tag)来完成这项操作:
$ docker image tag my-nginx 127.0.0.1:5000/my-nginx
我们可以再次使用image ls检查,确认tag命令已经成功执行:
$ docker image ls 127.0.0.1:5000/my-nginx
REPOSITORY TAG IMAGE ID CREATED SIZE
127.0.0.1:5000/my-nginx latest a773a4303694 1 days ago 216MB
好的,看来不错!我们现在可以将my-nginx镜像推送到本地仓库,当然使用docker image push,因为 Docker 仓库就在我们机器本地,整个过程非常快速。
你会发现当你尝试执行命令时,哈希值与下面列出的不同。这是正常现象,请忽略它。
现在,执行以下命令将my-nginx镜像推送到本地私有仓库:
$ docker image push 127.0.0.1:5000/my-nginx
The push refers to a repository [127.0.0.1:5000/my-nginx]
b3c96f2520ad: Pushed
a09947e71dc0: Pushed
9c42c2077cde: Pushed
625c7a2a783b: Pushed
25e0901a71b8: Pushed
8aa4fcad5eeb: Pushed
latest: digest: sha256:c69c400a56b43db695 ... size: 1569
最难的部分已经顺利完成。现在我们回到简单的部分:将镜像推送到 Docker Hub。在继续之前,如果你还没有 Docker ID,请先在hub.docker.com/注册一个。
要将镜像存储到那里,我们必须使用<docker id>/<image name>格式为镜像打标签。要将my-nginx推送到 Docker Hub,我们需要将它打标签为<docker id>/my-nginx。我将在此使用我的 Docker ID。请将<docker id>替换为你注册的 Docker ID:
$ docker image tag my-nginx chanwit/my-nginx
在推送之前,我们需要先使用docker login命令登录 Docker Hub。请使用-u和你的 Docker ID 指定帐户。我们会被要求输入密码;如果一切正常,命令会显示Login Succeeded:
$ docker login -u chanwit
Password:
Login Succeeded
请注意,我们的用户名和密码不安全地存储在~/.docker/config.json中,因此请尽量不要忘记输入docker logout。
运行容器
现在,让我们从my-nginx镜像启动一个容器。我们将使用docker container run命令(旧版的顶级命令是docker run)。这样做是为了以后台进程运行我们的容器,使用-d,并将主机的8080端口绑定到容器的80端口(-p 8080:80)。我们通过--name指定容器名称。如果容器启动成功,我们将得到一个哈希值,例如4382d778bcc9,它是我们运行的容器的 ID:
$ docker container run --name=my-nginx -d -p 8080:80 my-nginx
4382d778bcc96f70dd290e8ef9454d5a260e87366eadbd1060c7b6e087b3df26
打开网页浏览器并访问http://localhost:8080,我们将看到 NGINX 服务器正在运行:

图 2.5:容器内运行 NGINX 的示例
现在,我们的 NGINX 服务器作为后台容器运行,并通过主机的8080端口提供服务。我们可以使用docker container ls命令(或者老式的、顶层的docker ps)来列出所有正在运行的容器:
$ docker container ls
CONTAINER ID IMAGE COMMAND CREATED ...
4382d778bcc9 my-nginx "nginx -g 'daemon ..." 2 seconds ago ...
6f7dc5ef89f0 registry:2 "/entrypoint.sh /e..." 2 hours ago ...
我们可以使用命令docker container start、stop、pause或kill等来控制容器的生命周期。
如果我们希望强制移除正在运行的容器,可以使用docker container rm -f <容器 ID 或名称>命令。我们可以先移除所有正在运行的my-nginx实例和私有注册表,然后继续进行 Docker Swarm 集群的操作:
$ docker container rm -f my-nginx registry
my-nginx
registry
Docker Swarm 集群
集群是由一组机器连接在一起共同完成工作的。Docker 主机是安装了 Docker 引擎的物理或虚拟机。我们通过将多个 Docker 主机连接在一起来创建 Docker Swarm 集群。我们将每个 Docker 主机称为 Docker Swarm 节点,简称节点。
在版本 1.12 中,Docker 引入了 Swarm 模式,这是一个新的编排引擎,用以替代旧的 Swarm 集群,现称为Swarm 经典模式。Swarm 经典模式和 Swarm 模式的主要区别在于,Swarm 经典模式使用外部服务,如 Consul、etcd 或 Apache ZooKeeper 作为其键/值存储,而 Swarm 模式则内置了这个键/值存储。因此,Swarm 模式能够保持最小的编排延迟,并且比 Swarm 经典模式更加稳定,因为它不需要与外部存储进行交互。Swarm 模式的单体架构有助于修改其算法。例如,我的一项研究工作实现了蚁群优化,以改进 Swarm 在非均匀集群上运行容器的方式。
根据我们实验室的实验,我们发现 Swarm 经典模式在扩展到 100–200 个节点时存在限制。而使用 Swarm 模式,我们与 Docker 社区合作进行的实验表明,它可以扩展到至少 4,700 个节点。
结果可以通过项目 Swarm2K (github.com/swarmzilla/swarm2k) 和 Swarm3K (github.com/swarmzilla/swarm3k) 在 GitHub 上公开获取。
Swarm 模式性能的关键在于它建立在嵌入式etcd库之上。嵌入式 etcd 库提供了一种机制,以分布式方式存储集群的状态。所有状态信息都保存在 Raft 日志数据库中,并采用 Raft 共识算法。
本节中,我们将讨论如何在 Swarm 模式下设置集群。
设置集群
要创建一个完全功能的单节点 Swarm 集群,我们只需输入以下命令:
$ docker swarm init Swarm initialized: current node (jbl2cz9gkilvu5i6ahtxlkypa) is now a manager.
To add a worker to this swarm, run the following command:
docker swarm join --token SWMTKN-1-470wlqyqbsxhk6gps0o9597izmsjx4xeht5cy3df5sc9nu5n6u-9vlvcxjv5jjrcps4trjcocaae 192.168.1.4:2377
To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.
我们称这个过程为 Swarm 集群初始化。该过程通过准备/var/lib/docker/swarm目录来初始化新集群,该目录用于存储与集群相关的所有状态。以下是/var/lib/docker/swarm的内容,如果需要,可以进行备份:
$ sudo ls -al /var/lib/docker/swarm
total 28
drwx------ 5 root root 4096 Sep 30 23:31 .
drwx--x--x 12 root root 4096 Sep 29 15:23 ..
drwxr-xr-x 2 root root 4096 Sep 30 23:31 certificates
-rw------- 1 root root 124 Sep 30 23:31 docker-state.json
drwx------ 4 root root 4096 Sep 30 23:31 raft
-rw------- 1 root root 67 Sep 30 23:31 state.json
drwxr-xr-x 2 root root 4096 Sep 30 23:31 worker
如果主机上有多个网络接口,之前的命令将失败,因为 Docker Swarm 要求我们使用 IP 地址或某个特定网络接口来指定广播地址。
在以下示例中,我使用我的wlan0 IP 地址作为集群的广播地址。这意味着任何在 Wi-Fi 网络上的机器都可以尝试加入这个集群:
$ docker swarm init --advertise-addr=192.168.1.4:2377
类似地,我们可以使用网络接口的名称进行广播,例如eth0:
$ docker swarm init --advertise-addr=eth0
选择最适合你工作环境的样式。
初始化后,我们获得了一个完全工作的单节点集群。为了强制某个节点离开当前集群,我们使用以下命令:
$ docker swarm leave --force
Node left the swarm.
如果我们在单节点集群上运行此命令,集群将被销毁。如果你在这里运行上述命令,请不要忘记在进入下一部分之前再次初始化集群,使用docker swarm init。
主节点和工作节点
回顾一下,我们曾用“Docker 主机”一词来指代安装了 Docker 的机器。当我们将这些主机组合在一起形成集群时,有时我们称它们中的每一个为 Docker 节点。
Swarm 集群由两种类型的 Docker 节点组成,主节点和工作节点。例如,我们说节点mg0具有主节点角色,节点w01具有工作节点角色。我们通过将其他节点加入主节点(通常是第一个主节点)来形成集群。docker swarm join命令要求安全令牌不同,以允许节点以主节点或工作节点身份加入。请注意,我们必须在每个节点上运行docker swarm join命令,而不是在主节点上运行:
# Login to each node
$ docker swarm join --token SWMTKN-1-27uhz2azpesmsxu0tlli2e2uhdr2hudn3e2x5afilc02x1zicc-9wd3glqr5i92xmxvpnzdwz2j9 192.168.1.4:2377
主节点负责控制集群。Docker 推荐的最佳实践是,主节点的数量应为奇数,这是最佳配置。我们应该从三个主节点开始,并且主节点数量应为奇数。如果有三个主节点,允许其中一个失败,集群仍然能够正常运行。
下表显示了从一个到六个主节点的可能配置。例如,一个包含三个主节点的集群允许一个主节点失败,集群仍然能够保持运行。如果两个主节点失败,集群将无法操作,无法启动或停止服务。然而,在这种状态下,正在运行的容器不会停止,仍然继续运行:
| 主节点 | 维持集群所需的主节点数量 | 允许失败的主节点数 |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 (最佳) | 2 | 1 |
| 4 | 3 | 1 |
| 5 (最佳) | 3 | 2 |
| 6 | 4 | 2 |
在失去大多数主节点后,恢复集群的最佳方法是尽可能快地将失败的主节点恢复上线。
在生产集群中,我们通常不在主节点上调度运行的任务。主节点需要有足够的 CPU、内存和网络带宽来正确处理节点信息和 Raft 日志。我们通过指挥一个主节点来控制集群。例如,我们可以通过向主节点发送以下命令来列出集群中的所有节点:
$ docker node ls
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS
wbb8rb0xob * mg0 Ready Active Leader
结果中显示的是当前集群中所有节点的列表。我们可以通过查看MANAGER STATUS列来判断mg0节点是否为管理节点。如果一个管理节点是集群的主管理节点,MANAGER STATUS会显示它是Leader。如果这里有另外两个管理节点,状态会显示它们是Follower。下面是这个 leader/follower 机制的工作原理。当我们向领导节点发送命令时,领导节点会执行命令,并更改集群的状态。然后,集群状态会通过将此更改发送给其他管理节点(即 follower)来更新。如果我们向 follower 发送命令,它不会自己执行,而是将命令转发给领导节点。基本上,集群的所有命令都由领导节点执行,follower 只会将更改更新到它们自己的 Raft 日志中。
如果有新的管理节点想要加入,我们需要为其提供一个主令牌。输入docker swarm join-token manager命令来获取安全令牌,以便将节点加入到管理角色的集群中:
$ docker swarm join-token manager
To add a manager to this swarm, run the following command:
docker swarm join --token SWMTKN-1-2c6finlm9d97q075kpwxcn59q93vbpfaf5qp13awjin3s3jopw-5hex62dfsd3360zxds46i6s56 192.168.1.4:2377
尽管任务作为容器可以在两种类型的节点上运行,但我们通常不会将任务提交到主节点上运行。我们仅使用工作节点来运行生产中的任务。为了将工作节点加入集群,我们将工作令牌传递给加入命令。使用docker swarm join-token worker来获取工作令牌。
服务与任务
随着新调度引擎的引入,Docker 在 1.12 版本中引入了服务和任务的新抽象。一个服务可以由多个任务实例组成。我们将每个实例称为副本。每个任务实例作为容器在 Docker 节点上运行。
可以使用以下命令创建服务:
$ docker service create \
--replicas 3 \
--name web \
-p 80:80 \
--constraint node.role==worker \
nginx
该 web 服务由三个任务组成,这些任务通过--replicas进行指定。任务由调度引擎提交并在选定的节点上运行。服务的名称 web 可以通过虚拟 IP 地址解析。位于同一网络上的其他服务(例如反向代理服务)可以引用它。我们使用--name来指定服务的名称。
我们将在下面的图示中继续讨论此命令的细节:

图 2.6:Swarm 集群运行示意图
假设我们的集群由一个管理节点和五个工作节点组成。管理节点没有高可用性设置;这将留给读者自己解决。
我们从管理节点开始。由于我们不希望它接受任何计划任务,因此将管理节点设置为drained。这是最佳实践,我们可以通过以下方式使节点进入 drained 状态:
$ docker node update --availability drain mg0
此服务将在路由网格的80端口上发布。路由网格是 Swarm 模式内执行负载均衡的一种机制。每个工作节点的80端口将会开放,以提供此服务。当请求到达时,路由网格会自动将请求路由到某个节点的特定容器(任务)。
路由网格依赖于一个使用 overlay 驱动程序的 Docker 网络,即 ingress。我们可以使用 docker network ls 列出所有活跃的网络:
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
c32139129f45 bridge bridge local
3315d809348e docker_gwbridge bridge local
90103ae1188f host host local
ve7fj61ifakr ingress overlay swarm
489d441af28d none null local
我们找到一个 ID 为 ve7fj61ifakr 的网络,它是 swarm 范围下的一个 overlay 网络。正如信息所暗示的那样,这种网络仅在 Docker Swarm 模式下工作。要查看此网络的详细信息,我们使用 docker network inspect ingress 命令:
$ docker network inspect ingress
[
{
"Name": "ingress",
"Id": "ve7fj61ifakr8ybux1icawwbr",
"Created": "2017-10-02T23:22:46.72494239+07:00",
"Scope": "swarm",
"Driver": "overlay",
"EnableIPv6": false,
"IPAM": {
"Driver": "default",
"Options": null,
"Config": [
{
"Subnet": "10.255.0.0/16",
"Gateway": "10.255.0.1"
}
]
},
}
]
我们可以看到,ingress 网络的子网是 10.255.0.0/16,这意味着默认情况下我们可以在该网络中使用 65,536 个 IP 地址。这个数字是通过 docker service create -p 在单个 Swarm 模式集群中创建的任务(容器)的最大数量。当我们在非 Swarm 环境中使用 docker container run -p 时,这个数字不会受到影响。
要创建一个 Swarm 范围的 overlay 网络,我们使用 docker network create 命令:
$ docker network create --driver overlay appnet
lu29kfat35xph3beilupcw4m2
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
lu29kfat35xp appnet overlay swarm
c32139129f45 bridge bridge local
3315d809348e docker_gwbridge bridge local
90103ae1188f host host local
ve7fj61ifakr ingress overlay swarm
489d441af28d none null local
我们可以使用 docker network ls 命令再次检查,看到 appnet 网络与 overlay 驱动程序和 swarm 范围。在这里你的网络 ID 会有所不同。要将服务附加到特定网络,我们可以将网络名称传递给 docker service create 命令。例如:
$ docker service create --name web --network appnet -p 80:80 nginx
上面的示例创建了 web 服务并将其附加到 appnet 网络。只有当 appnet 是 Swarm 范围时,这个命令才有效。
我们可以通过使用 docker service update 命令并配合 --network-add 或 --network-rm 选项,动态地将网络从当前正在运行的服务中移除或重新附加。请尝试以下命令:
$ docker service update --network-add appnet web
web
在这里,我们可以观察到 docker inspect web 命令的结果。你会发现一段 JSON 输出,最后一块看起来如下:
$ docker inspect web
...
"UpdateStatus": {
"State": "completed",
"StartedAt": "2017-10-09T15:45:03.413491944Z",
"CompletedAt": "2017-10-09T15:45:21.155296293Z",
"Message": "update completed"
}
}
]
这意味着服务已经更新,并且更新过程已完成。现在,我们将看到 web 服务附加到 appnet 网络:

图 2.7:Swarm 范围覆盖网络的 Gossip 通信机制
Overlay 网络依赖于在端口 7946 上实现的 gossip 协议,支持 TCP 和 UDP,同时还使用 Linux 的 VXLAN,通过 UDP 端口 4789 实现。该 overlay 网络的实现是以性能为目标的。网络将仅覆盖必要的主机,并在需要时逐步扩展。
我们可以通过增加或减少副本数来扩展服务。扩展服务可以使用 docker service scale 命令来完成。例如,如果我们希望将 web 服务扩展到五个副本,可以执行以下命令:
$ docker service scale web=5
当服务扩展,并且其任务调度到新节点时,所有绑定到此服务的相关网络将自动扩展以覆盖新节点。在下图中,我们有两个应用服务副本,并且我们希望通过命令 docker service scale app=3 将其从两个扩展到三个。新的副本 app.3 将被调度到工作节点 w03。然后,绑定到此应用服务的覆盖网络也将扩展以覆盖节点 w03。网络范围的 gossip 通信负责网络扩展机制:

图 2.8:Swarm 范围的网络扩展
Docker 与无服务器
Docker 将如何对我们有所帮助?在处理应用开发时,Docker 可以用来简化开发工具链。我们可以将编写无服务器应用所需的一切打包进一个单一的容器镜像中,并让整个团队使用它。这确保了工具版本的一致性,并确保它们不会弄乱我们的开发机器。
然后我们将使用 Docker 来准备我们的基础设施。实际上,无服务器(serverless)意味着开发人员不需要维护自己的基础设施。然而,在公共云不可选的情况下,我们可以使用 Docker 来简化基础设施的提供。使用与第三方无服务器平台相同的架构,在我们公司的基础设施上,我们可以最小化运维成本。后续章节将讨论如何操作我们自己的基于 Docker 的 FaaS 基础设施。
对于无服务器应用本身,我们使用 Docker 作为无服务器函数的封装器。我们将 Docker 作为工作单元,这样任何类型的二进制文件都可以集成到我们的无服务器平台中,从遗留的 COBOL、C 或 Pascal 程序,到用现代语言编写的程序,如 Node.js、Kotlin 或 Crystal。在 Docker 17.06+ 版本中,还可以跨多硬件架构形成 Swarm 集群。我们甚至可以在与主机 COBOL 程序相同的集群上托管基于 Windows 的 C# 函数。
练习
为了帮助你更好地记住和理解本章中描述的 Docker 概念和实践,尝试在不返回章节内容的情况下回答以下问题。开始吧:
-
什么是容器?容器和虚拟机之间的关键区别是什么?
-
启用容器技术的 Linux 内核主要功能有哪些?请至少列举其中两个。
-
Docker 工作流的关键概念是什么?
-
Dockerfile 是做什么的?你使用哪个 Docker 命令与其交互?
-
Dockerfile 中的 ENTRYPOINT 指令是什么?
-
我们使用哪个命令来列出所有 Docker 镜像?
-
我们使用哪个命令来形成一个 Docker Swarm 集群?
-
Swarm classic 和 Swarm mode 之间的主要区别是什么?
-
请解释服务与任务之间的关系。
-
我们如何创建一个包含五个副本的 NGINX 服务?
-
我们如何将 NGINX 服务的副本数缩减到两个?
-
形成具有高可用性属性的 Swarm 集群所需的最小节点数是多少?为什么?
-
被称为路由网格一部分的网络是什么?它有多大?
-
Swarm 集群使用哪些端口号?它们的用途是什么?
-
网络范围的 Gossip 通信的主要优势是什么?
总结
本章首先讨论了容器的概念。然后我们回顾了 Docker 是什么,如何安装它,以及 Docker 的构建、分发和运行工作流程。接着,我们学习了如何形成 Docker Swarm 集群及 Swarm 主节点和工作节点。我们了解了如何通过设置奇数个主节点来正确构建一个稳健的 Swarm 集群。然后我们学习了 Docker Swarm 的服务和任务概念。最后,我们学习了 Docker 如何融入无服务器应用开发。
在下一章,我们将回顾无服务器框架和平台,以理解它们的整体架构和局限性。
第三章:无服务器框架
本章讨论无服务器框架。它们是什么?纯粹的无服务器框架目前有哪些限制?Docker 如何部分解决无服务器框架的限制?我们将从了解 AWS Lambda 开始,然后是 Azure Functions 和 Google Cloud Functions。我们会简要提及 IBM Cloud Functions,但实际上它的引擎是 OpenWhisk,接下来的几章会详细讨论它。
我们还将在本章最后一部分讨论无服务器框架,一个帮助我们开发云独立无服务器应用程序的工具包。
AWS Lambda
在云服务提供商提供的无服务器架构中,AWS Lambda 是最受欢迎的,并且具有一些高级功能。
FaaS/无服务器是微服务的自然进化,或者我们可以把它看作是对微服务架构的扩展。在许多场景中,我们可以使用函数或 Lambda 来补充我们的微服务架构。如果你已经是 AWS 客户,将代码从 EC2 迁移到 Lambda 是完全自然的,并且能够节省大量资金。下面的图表展示了一个使用AWS Lambda与S3 存储桶和DynamoDB的简单用例:

图 3.1:在 AWS 上使用 Lambda 函数的简单用例
在 S3 中,可以触发事件到特定的端点。我们将 Lambda 函数的端点放在那里。当用户上传或更改 S3 存储桶中的内容时,它会触发一个调用请求发送到 Lambda 函数。这可以视为一种 WebHooks 的形式。之后,Lambda 函数接收事件并开始计算其应用逻辑。完成后,Lambda 将结果传输并存储到 DynamoDB 实例中。
我们将在第八章中演示一个类似的场景,将它们全部整合起来。
限制
Lambda 支持多种语言运行时;例如,Node.js、Go、Java、Python 和 C#。每个 AWS Lambda 都有一些限制,来限制每次调用所使用的资源。在内存方面,Lambda 支持的 RAM 范围从 128 MB 到 3,008 MB,按 64 MB 的增量分配。如果内存使用超过限制,函数将自动终止。
在磁盘空间方面,Lambda 函数可以使用/tmp目录,最多 512 MB。这个磁盘卷是临时性的,因此在 Lambda 完成工作后,它会被清除。此外,Lambda 函数中允许的文件描述符数量限制为 1,024,而在单次调用中可以分叉的进程和线程数也限制为 1,024。
对于每个请求,同步 HTTP 调用的请求体大小限制为 6 MB,而异步、事件触发调用的请求体大小限制为 128 KB。
这里最重要的方面是时间限制。AWS Lambda 允许一个函数运行最长不超过 5 分钟(或 300 秒)。如果执行时间超过 5 分钟,函数将被自动终止。
Lambda 终止
Lambda 背后的技术实际上是基于容器的,这意味着它将一个函数与其他实例隔离开来。容器的沙箱为每个配置提供特定的资源。
Lambda 函数可以通过多种方式终止:
-
超时:如前所述,当达到 5 分钟限制时,不管当前函数在做什么,都会停止执行。
-
受控终止:如果函数提供了回调,并且回调被执行以调用
context.done()方法,则无论函数正在做什么,函数都会终止。 -
默认终止:函数结束并正常终止。同时,不会调用回调来触发
context.done()方法。这种情况将被视为默认终止。 -
函数崩溃 或调用了
process.exit():如果函数发生崩溃或产生了段错误,函数将终止,因此容器也会停止。
容器重用
存在一种情况,即刚刚终止的函数容器可以被重用。
重用已完成的函数容器的能力可以大大减少启动时间,因为初始化过程会被完全跳过。同时,如果一个容器被重用,之前执行时写入/tmp目录的文件可能仍然存在,这是一个缺点。
本地可执行文件
Lambda 实际上是为了在任何语言中运行代码而设计的,因为 Lambda 的沙箱只是一个容器。诀窍在于,我们可以使用一个 Node.js 程序在上传之前执行任何与 ZIP 文件一起打包的二进制文件。
值得注意的是,在为 Lambda 准备我们自己的二进制文件时,它必须是静态编译的,或者与 Amazon Linux 提供的共享库相匹配(因为 Lambda 上使用的所有容器都是基于 Amazon Linux 的)。我们有责任自己跟踪 Amazon Linux 的版本。
一个像 LambCI 这样的项目(github.com/lambci/docker-lambda)可以帮助解决这个问题。LambCI 提供了一个本地沙箱环境,作为 Docker 容器,通过安装相同的软件、库、文件结构和权限来模拟 AWS Lambda 环境。它还定义了相同的一组环境变量以及其他行为。此外,用户名和组也被定义为与 Lambda 匹配,例如sbx_user1051。
有了这个本地环境,我们可以在这个 Docker 容器内安全地测试我们的代码,并确保它在 Lambda 上运行时不会出问题。
Azure Functions
Azure Functions 是微软提供的无服务器计算平台,作为 Azure 云的一部分。所有设计目标与其他无服务器/FaaS 服务相同,Azure Functions 使我们能够执行应用逻辑,而无需管理自己的基础设施。
Azure Functions 在被事件触发时,以脚本的形式运行程序。当前版本的 Azure Functions 支持如 C#、F#、PHP、Node.js 或 Java 等语言运行时。对于 Azure 来说,支持 C# 和 F# 作为其功能的第一语言是很自然的,因为它们是微软自有的编程语言。无论如何,目前仅有 C#、F# 和 JavaScript(Node.js)是正式支持的语言。
使用 C#、F# 或 .NET 语言时,Azure Functions 允许我们通过 NuGet(.NET 的著名包管理器)安装依赖项。如果我们使用 Node.js 编写 JavaScript,Azure 还提供了对 NPM 包管理的访问。
类似于其他云服务提供商,Azure Functions 在访问其他 Azure 服务时具有优势,例如 Azure Cosmos DB、Azure Event Hubs、Azure Storage 和 Azure Service Bus。
很有趣的一点是,Azure Functions 的定价模型与 Amazon 或 Google 的产品有所不同。在 Azure 中,有两种定价计划可以满足不同的需求。
第一个是消费计划。这与其他云服务提供商提供的计划类似,我们只需为代码执行的时间付费。第二个是应用服务计划。在这种情况下,函数被视为其他应用程序的应用服务的一部分。如果函数属于这一类别,我们无需额外支付费用。
Azure Functions 的一个有趣特点是它的触发和绑定机制。Azure Functions 允许定义如何触发一个函数,以及如何在每个函数的输入和输出之间进行数据绑定,这些配置是分开的。这些机制有助于避免在调用函数时进行硬编码,以及在函数调用链中进行数据的进出转换。
扩展性
在 Azure 中,有一个组件可以实时监控每个 Azure Function 的请求数量。这个组件被称为扩展控制器。它收集数据,然后做出决定,来扩展或缩减该功能实例的数量。Azure 引入了应用服务的概念,一个功能应用可能包含多个功能实例。
所有决策都是基于启发式算法来处理不同类型的事件触发器。当功能扩展时,所有与该功能相关的资源也会被扩展。如果没有请求发送到该功能应用,功能实例的数量将自动缩减为零。
限制
每个函数实例将由函数应用的主机限制为 1.5 GB 的内存,这就像多个函数实例共享资源的一个组语义。所有函数都共享同一资源。
一个函数应用最多可以同时容纳 200 个函数实例。但没有并发限制。实际上,一个函数实例可以接受一个或多个请求。
每个事件触发器,例如,Azure Service Bus,都有其独特的启发式方式来扩展底层函数。
持久化函数
Azure Functions 最先进的扩展之一是持久化函数。持久化函数是一种在无服务器计算环境中实现有状态函数的技术。通过这个持久化扩展,提供了更多的状态管理、检查点和重启的概念。我们从这种函数中获得的是一个有状态的工作流,并且会有一个驱动程序作为协调者来调用其他函数,如下图所示:

图 3.2:Azure 中使用持久化函数扩展的协调器函数
当它完成调用其他函数后,无论是同步还是异步,协调器函数将允许将状态保存为本地变量。如果调用过程必须重新开始,或运行此协调器函数的虚拟机重新启动时,还会有一个检查点技术来继续/恢复协调器的状态。
Google Cloud Functions
谷歌公司提供的无服务器计算服务被称为Google Cloud Functions(GCF)。
本节中我们通常称其为 GCF。像其他无服务器平台一样,GCF 提供了执行环境和 SDK,帮助我们开发和管理整个函数生命周期。它提供了一个 SDK 帮助我们开始使用该框架。GCF 主要支持的语言是 JavaScript,并且有一个 Node.js Docker 镜像供我们使用。通过 Docker,构建一个函数非常方便。部署时,也可以通过 Google Cloud CLI 工具轻松部署。GCF 自然允许我们高效地连接到其他 Google 基于服务:

图 3.3:使用 Google Cloud Functions 实现的常见物联网用例
上述图示展示了一个在 Google Cloud 上实现的常见用例。它是一个使用所有 Google Cloud 服务的物联网管道示例。Google Cloud Function 用来计算来自消息队列的数据,并将其分发到大数据堆栈和 Firebase。Firebase 服务充当移动应用程序的后端即服务(BaaS)。在后面的章节中,我们将展示使用Parse 平台实现的类似 BaaS。
概述
在 FaaS 或无服务器平台中,函数的定义是它应该专注于单一的目标。由于函数的特性,它不应该过于复杂。正如我们在第一章中描述的,无服务器与 Docker,无服务器 FaaS 实际上是事件驱动编程模型的一个子集。GCF 上的所有云函数都遵循这种行为。我们应用程序流水线的每个单独组件通过将事件发送给另一个组件来连接。此外,事件是可以被监控的。当我们从源接收到一个事件时,关联的云函数将被触发并执行。
GCF 支持的函数必须使用 JavaScript 编写,或者使用能够转译为 JavaScript 的语言。本文写作时,执行函数的环境是 Node.js v6.11.5。基本上,开发人员会使用与该版本匹配的任何 Node.js 运行时。使用 JavaScript 和 Node.js 可以带来良好的可移植性,并允许开发人员在本地测试函数。此外,使用 Node.js 可以访问大量的 Node.js 库,包括平台提供的 API(cloud.google.com/nodejs/apis),这些库有助于简化开发和集成。
GCF 被设计为连接或粘合服务的层。在某些用例中,我们使用函数来扩展现有的云服务。
通过事件驱动模型,函数可以监听并等待直到文件上传事件被触发,也就是当某些文件被放入云存储时。我们还可以监听远程区块链环境中的日志变化,或者我们订阅一个 Pub/Sub 主题并接收通知以触发函数。
我们通常将一些复杂的业务逻辑放在函数内部。Google 拥有的云函数能够访问 GCP 的凭据系统,因此它可以与大量的 GCP 服务进行身份验证。这个特性通常使得云函数在其平台上非常有用。
所有基础设施和系统软件层由 Google 的平台完全管理,因此我们只需关心我们的代码。自动扩展也是这种平台的常见特性。当触发次数增多时,额外的计算资源将自动进行配置。部署的函数将自动扩展以处理数百万次请求,而无需我们进一步的配置。
FaaS 函数的精细粒度概念使得这种计算非常适合实现自包含的 API 和 WebHooks(我们将在后续章节中演示)。Google Cloud Functions 支持多种工作负载的方面,例如数据处理/ELT、WebHooks、实现 API、作为移动应用程序的后端,以及接收来自 IoT 设备的流数据。
GCF 支持无服务器计算的多个方面。目前显而易见的局限性是它仅支持 Node.js 作为编程语言。GCF 在内部使用容器包装 Node.js 代码,并部署到其内部编排的 FaaS 系统上。这项工程的一部分已作为名为distroless的项目开源。我们可以通过提议的声明式容器的概念在最后一章中实现类似的功能。使用这个概念允许我们像 GCF 一样部署只包含应用程序的工作负载。
所有这些由 GCF 允许的用例将在后续章节中使用 Docker 和 FaaS 平台展示不同的方法。
执行模型
Google 为我们处理一切,包括硬件级别、操作系统、网络和应用程序运行时。在 GCF 上部署的函数将在一个自动管理的平台上运行。每个云函数将在基于容器的隔离环境中单独执行,这是一个安全的执行上下文。每个函数独立运行,不会干扰其他函数,同时共享同一主机。这与 Docker 和其他容器实现使用的概念相同。
在撰写本文时,Google Cloud Functions 选择仅支持运行在 Node.js v6.11.5 上的 JavaScript;然而,文档称他们将通过尽快与长期支持(LTS)版本的发布保持 Node.js 版本的更新。我们可以确信,Node.js 运行时的所有补丁版本和次要更新都将与上游发布匹配。
正如之前提到的,云函数也被放置在一个容器中。在谷歌云函数的情况下,其根文件系统基于Debian。 GCF 的基础镜像定期更新,并作为 Docker 镜像提供。可以从gcr.io/google-appengine/nodejs拉取。以下是系统通过继承镜像并向其中安装 Node.js 版本 6.11.5 来准备基础镜像的方式:
FROM gcr.io/google-appengine/nodejs
RUN install_node v6.11.5
无状态性
在编写无服务器 FaaS 函数时,无状态是首选模型。为什么?因为在完全托管的执行环境中,我们不能期望我们函数的状态被保留。因此最好不要将任何东西保存到函数的本地存储中。如果我们需要内存,例如可能跨函数实例共享的全局变量,这些变量必须由外部存储服务显式管理。
在某些情况下,说一个函数是完全无状态的,会让我们没有充分利用该函数的执行上下文。正如我们所知道的,函数实际上是在容器隔离中运行的。当然,函数在执行期间向本地存储写入一些数据是完全可以的,但不期望将状态共享到容器外部。当我们在容器的上下文中说“无状态”时,它很可能指的是“无共享”(share-nothing)模式,而不是“无状态”本身。无共享模型是更适合用来描述基于容器的 FaaS 无状态性的术语。
超时
一般来说,无服务器平台通常会限制云函数的执行时间,以防止平台计算资源的过度使用。对于 Google Cloud Functions,默认的超时时间设为 1 分钟,用户可以根据需要将其延长至 9 分钟。当函数超时,运行的代码会被终止。例如,如果一个函数被计划在启动后 3 分钟运行,而超时时间设置为 2 分钟,那么这个函数将永远不会运行。
执行保证
在函数执行过程中,可能会发生错误。如果函数失败,它可能不会只执行一次。执行模型取决于函数的类型。
例如,一个简单的同步 HTTP 请求最多会被调用一次。这意味着函数调用将失败,并且不会重试。调用方需要自己处理错误和重试策略。
虽然异步函数至少会被调用一次,这是这些异步调用的特性,因此我们需要为该类型函数可能被多次调用的情况做好准备。此外,这些函数要修改的状态应该是幂等的且具备鲁棒性。例如,我们可能需要实现一个状态机来控制系统的状态。
IBM Cloud Functions
IBM Cloud Functions 是 IBM Cloud 提供的一项服务,它由 Apache OpenWhisk 提供支持;实际上是 IBM 向 Apache 基金会捐赠了 OpenWhisk。我们在本书后面有专门的章节介绍 OpenWhisk。
IBM 提供的 Cloud Functions 服务,在概念上与其他函数服务非常相似。函数围绕应用的业务逻辑进行封装,并在由 IBM 管理的事件驱动的 FaaS 环境中运行。
函数旨在响应来自其他 Web 或移动应用的直接 HTTP 调用,或者响应由其他支持的系统触发的事件,例如 Cloudant。IBM Cloud 提供了 Cloudant,这是一个建立在 CouchDB 之上的商业支持的 JSON 数据存储。我们可以在 Cloudant 系统中准备一个触发器,并在 Cloudant 中的数据发生变化时触发事件来调用 IBM Cloud Functions 中定义的函数。
函数的设计目标在各云服务提供商之间通常是相同的。它们为我们开发者提供了一种方式,让我们只专注于编写应用的业务逻辑,然后将代码作为云函数上传到各自的云服务。
要进一步探索 OpenWhisk 背后的概念,该引擎是 IBM Cloud 的一部分,请随时跳转到第六章,在 Docker 上运行 OpenWhisk,以了解更多关于 OpenWhisk 的信息。
Serverless 框架
Serverless 框架是一个应用开发框架和工具,适用于无服务器计算模式。这个框架与无服务器并无直接关系,它们只是共享了相同的名字,请不要混淆。
Serverless 框架的作者认为,无服务器应用是云原生生态系统中应用开发的下一个进化。这种应用需要一定程度的自动化,这一理念成为了框架的起源。
设计理念将托管服务和函数视为紧密耦合的实体。为了围绕它们构建应用,工具应提供构建、测试和部署命令,以使整个开发生命周期实现完全自动化。
还应有一种一致的方式来构建、测试和部署无服务器应用到多个云服务提供商,同时最小化代码变更。框架应该根据以下内容帮助配置每个云服务提供商的设置:
-
语言运行时
-
由应用开发者选择的云服务提供商
通过这种抽象层级,框架带来了实际的优势,使开发人员可以专注于应用的业务逻辑,而不是不断调整云配置以适应不同的云服务提供商。
Serverless 框架的创始人描述了四个优点:
-
Serverless 框架有助于加速开发过程,因为该框架包含基于 CLI 的命令来创建项目、构建,并且还帮助在相同的开发环境中测试应用。它节省了时间,因为 Serverless 框架独立于任何云服务提供商。框架还有一个机制,可以将新版本部署到云端,并允许在失败时回滚到之前的版本。
-
使用 Serverless 框架,我们可以独立于任何云服务提供商开发代码。因此,具有良好编程风格的代码可以在不同的云服务提供商之间迁移。例如,我们可以通过简单地将 YAML 文件中的提供商从 AWS Lambda 切换为 Google Cloud,然后重新部署,就能轻松迁移我们的函数。但实际上,这只是整个问题的一部分。真正让你绑定到某个供应商的不是代码,而是供应商提供的服务。因此,明智地选择支持的服务,可以有效解决这个问题。
-
Serverless Framework 帮助实现 基础设施即代码 (IaC) 。通过可以通过一组 API 进行部署的方式,我们实现了一定程度的自动化。这使我们能够将系统完全部署为多云应用程序。
-
最后,这个框架被广泛使用,并且有一个非常活跃的社区。这也是选择工具时的一个重要因素。由于他们为框架选择了 JavaScript 和 Node.js 作为基础语言,社区积极开发框架扩展。因此,向框架中添加新的提供商相对容易。一个值得注意的社区支持的提供商是 Kubeless。
练习
让我们通过尝试回答问题来复习一下,而不回顾内容:
-
AWS Lambda 的时间限制是多久?
-
你为什么认为云提供商限制 FaaS 函数的计算时间?
-
什么是 Azure 的持久性函数?它们有什么好处?
-
我们如何仅使用 Docker 测试 AWS Lambda 程序?
-
IBM Cloud Functions 背后的引擎是什么?你认为 IBM 开源它背后的原因是什么?
-
什么是 Serverless Framework?为什么它很重要?
-
我们如何使 FaaS 函数跨云提供商工作?你认为这真的可能吗?
-
请解释无状态和“无共享”模型之间的区别。
总结
在本节中,我们讨论了四个主要的无服务器计算平台、它们的一些特性和局限性。我们还讨论了 Serverless Framework,这是一个旨在帮助构建、测试和部署应用程序到多个无服务器计算平台的框架和工具。
在接下来的三章中,我们将看到云提供商提供的无服务器平台和允许我们使用 Docker 技术自行部署的无服务器/FaaS 平台的真正不同之处。
第四章:OpenFaaS 在 Docker 上
本章将介绍 OpenFaaS,这是一个使用软件容器作为部署单元的无服务器框架。OpenFaaS 最初是设计用来在 Docker Swarm 模式下运行并利用编排引擎的。
本章将从介绍 OpenFaaS 和解释其架构开始。然后,我们将讨论如何使用 OpenFaaS 来准备和部署函数。最后,本章将结束于如何为 OpenFaaS 安装 Grafana/Prometheus 仪表盘。
什么是 OpenFaaS?
OpenFaaS 是一个用于构建无服务器应用程序的框架和基础设施准备系统。它起源于 Docker Swarm 中的无服务器框架,现在支持其他类型的基础设施后端,如 Kubernetes 或 Hyper.sh。OpenFaaS 中的函数是容器。通过利用 Docker 的容器技术,任何用任何语言编写的程序都可以打包成一个函数。这使我们能够充分重用现有代码,消费各种 web 服务事件,而无需重写代码。OpenFaaS 是现代化旧系统以在云基础设施上运行的一个绝佳工具。
在云原生领域,有多个无服务器框架。然而,一些问题需要由 OpenFaaS 的原创作者 Alex Ellis 来解决。推动框架创建的动力在于塑造以下具有吸引力的特性:
-
易用性:基本上,许多无服务器框架由于由大公司构建并且是无服务器服务,天生就很复杂。另一方面,OpenFaaS 的目标是成为一个足够简单的无服务器技术栈,让开发者和小公司能够在自己的硬件上轻松部署和使用。OpenFaaS 还附带一个现成的 UI 门户,允许我们在浏览器中尝试函数调用。OpenFaaS 内置了自动扩展能力。它会自动测量函数调用的负载,并根据需求扩展或缩减实例。
-
可移植性:在容器生态系统中,有多个编排引擎,尤其是 Docker Swarm 和 Google 的 Kubernetes。OpenFaaS 最初设计时是为了在 Swarm 上运行,后来也支持 Kubernetes。它的功能在这些编排引擎之间是可移植的。OpenFaaS 不仅在运行时具有可移植性,它的功能实际上就是一个普通的 Docker 容器。这意味着任何类型的工作负载都可以作为函数容器重新打包,并简单地部署到 OpenFaaS 集群上。OpenFaaS 可以在任何基础设施上运行,包括本地硬件、私有云和公共云。
-
架构与设计的简洁性:OpenFaaS 的架构简单。它包括一个 API 网关,用于接受请求。然后,API 网关将请求传递给集群中的容器和带有看门狗的函数。看门狗是 OpenFaaS 的一个组件,稍后将在下一节中讨论。网关还会跟踪函数调用的次数。当请求量较大时,网关会触发编排引擎按需扩展函数的副本。
-
开放且可扩展的平台:OpenFaaS 设计为开放且可扩展的。凭借这种开放性和可扩展性,OpenFaaS 支持的 FaaS 后端数量随着时间的推移不断增加,因为任何人都可以为 OpenFaaS 提供新的后端。例如,如果我们想出于性能原因直接在容器运行时(如容器)中运行函数,我们可以通过为其编写一个新的 containerd 后端来扩展 OpenFaaS。
-
与语言无关:我们可以用任何 Linux 或 Windows 支持的语言编写 OpenFaaS 函数,然后将其打包为 Docker 或 OCI 容器镜像。
架构
我们曾经以单体式的方式构建系统。现在我们使用微服务。微服务可以被分解成更小的函数。显然,函数是架构演化的下一个步骤。
单体式是一种软件架构,其中包含可区分的软件关注点。每个服务都构建在一个单独的部署模块中。
微服务架构则将一个单一的庞大模块内的协调服务分离出来,形成外部松耦合的服务。
函数即服务(FaaS)是另一个分离层次。在这种架构中,微服务被拆分为更细粒度的单元,即函数:

图 4.1:单体式、微服务和函数架构
OpenFaaS 组件
本节解释了 OpenFaaS 的组成部分。这些组件包括 API 网关、函数看门狗以及 Prometheus 实例。它们都运行在 Docker Swarm 或 Kubernetes 编排引擎之上。API 网关和 Prometheus 实例作为服务运行,而函数看门狗作为函数容器的一部分运行。容器运行时可以是任何现代版本的 Docker 或 containerd:

图 4.2:OpenFaaS 架构概述
客户端可以是 curl、faas-cli 或任何能够连接到 API 网关并调用函数的基于 HTTP 的客户端。函数容器在集群中由 API 网关管理,容器内有一个作为旁路进程(这种实现模式允许另一个旁路进程与主进程在同一个容器中运行)的函数看门狗。每个服务通过默认的主覆盖网络 func_functions 进行通信:

图 4.3:运行在 Docker Swarm 上的 OpenFaaS 内部架构
函数监控程序
函数监控程序是 OpenFaaS 的一个组件。它负责将实际的工作代码封装在函数程序周围。函数程序的要求仅仅是通过 标准输入(stdin)接受输入,并将结果输出到 标准输出(stdout)。
API 网关(gateway)通过覆盖网络连接到函数容器。每个函数容器包含以下内容:
-
函数监控程序,
fwatchdog -
用任何语言编写的某个函数程序
描述函数容器的 Dockerfile 必须有一个 fprocess 环境变量,指向函数程序名称和参数:

图 4.4:容器中函数监控程序与函数程序之间的交互
命令行界面
OpenFaaS 命令行界面是使用 OpenFaaS 的另一种方式。CLI 的最新版本可以直接从安装脚本 cli.openfaas.com 获取。对于 Linux 和 macOS,可以使用以下命令安装 CLI:
$ curl -sL https://cli.openfaas.com | sudo sh
当前,安装脚本支持运行在 ARM、ARM64 和 x64 芯片上的 macOS 和 Linux。CLI 被设计用来管理 OpenFaaS 函数的生命周期。我们可以使用 CLI 提供的子命令来构建、部署和调用函数。
CLI 实际上通过 API 网关暴露的一组控制平面 API 来控制 OpenFaaS。
API 网关
OpenFaaS API 网关提供路由机制,将你的函数暴露给外部世界。
当一个函数被外部请求调用时,函数的度量指标将被收集并放入 Prometheus 实例中。API 网关持续监控每个函数的请求数量,并通过 Docker Swarm API 按需扩展服务副本。基本上,OpenFaaS 完全利用 Docker Swarm 的调度机制进行自动扩展。API 网关还配备了内置用户界面,称为 UI 门户。该界面允许我们通过浏览器定义和调用函数。
安装 OpenFaaS
在开发机器上安装 OpenFaaS 极其简单。确保你安装了 Docker 17.05 或更高版本,安装完成后就可以开始使用了。
首先,我们需要初始化一个 Swarm 集群。单节点 Swarm 就足够在开发环境中使用:
$ docker swarm init
如果由于机器具有 多个网络接口 而无法初始化 Swarm,我们必须为参数 --advertise-addr 指定一个 IP 地址或接口名称。
OpenFaaS 可以通过直接从 GitHub 克隆源代码并运行deploy_stack.sh脚本来启动。以下示例演示了如何启动 OpenFaaS 的版本 0.6.5。请注意,此目录中有docker-compose.yml,该文件将被docker_stack.sh用来部署 OpenFaaS Docker 堆栈:
$ git clone https://github.com/openfaas/faas \
cd faas \
git checkout 0.6.5 \
./deploy_stack.sh
Cloning into 'faas'...
remote: Counting objects: 11513, done.
remote: Compressing objects: 100% (21/21), done.
remote: Total 11513 (delta 16), reused 19 (delta 8), pack-reused 11484
Receiving objects: 100% (11513/11513), 16.64 MiB | 938.00 KiB/s, done.
Resolving deltas: 100% (3303/3303), done.
Note: checking out '0.6.5'.
HEAD is now at 5a58db2...
Deploying stack
Creating network func_functions
Creating service func_gateway
Creating service func_alertmanager
Creating service func_echoit
Creating service func_nodeinfo
Creating service func_wordcount
Creating service func_webhookstash
Creating service func_decodebase64
Creating service func_markdown
Creating service func_base64
Creating service func_hubstats
Creating service func_prometheus
我们现在看到,多个服务已部署到 Docker Swarm 集群中。实际上,这是通过在 bash 脚本中运行docker stack deploy命令实现的。OpenFaaS 使用的 Docker 堆栈名称是func。
为了检查func堆栈中的服务是否正确部署,我们使用docker stack ls列出堆栈及其运行的服务:
$ docker stack ls
NAME SERVICES
func 11
现在我们知道有一个名为func的 11 个服务的堆栈。让我们使用docker stack services func查看它们的详细信息。我们使用格式化参数来让docker stack services func命令显示每个服务的名称和端口。你可以省略--format来查看每个服务的所有信息:
$ docker stack services func --format "table {{.Name}}\t{{.Ports}}"
NAME PORTS
func_hubstats
func_markdown
func_echoit
func_webhookstash
func_prometheus *:9090->9090/tcp
func_gateway *:8080->8080/tcp
func_decodebase64
func_base64
func_wordcount
func_alertmanager *:9093->9093/tcp
func_nodeinfo
一切启动并运行后,可以通过http://127.0.0.1:8080打开 OpenFaaS 门户。以下截图显示了浏览器中运行的 OpenFaaS 门户。所有可用的函数都列在左侧面板中。点击某个函数名称后,主面板将显示该函数的详细信息。我们可以通过点击主面板上的 INVOKE 按钮来操作每个函数:

图 4.5:OpenFaaS 用户界面调用示例函数
我们将在下一节学习如何准备一个函数,以便在 OpenFaaS 平台上运行。
准备一个函数
在函数可以部署和调用之前,我们需要准备一个二进制程序并将其打包成函数容器。
以下是将程序打包成函数容器的步骤:
-
创建一个包含
FROM指令的 Dockerfile,以从基础镜像派生出它。你甚至可以使用 Alpine 基础镜像。 -
使用
ADD指令将函数监视程序二进制文件添加到镜像中。函数监视程序的名称是fwatchdog,可以在 OpenFaaS 发布页面找到。 -
将函数程序添加到镜像中。我们通常使用
COPY指令来完成此操作。 -
使用
ENV指令定义名为fprocess的环境变量,指向我们的函数程序。 -
使用
EXPOSE指令暴露8080端口给此容器镜像,当然,端口号是8080。 -
定义此容器镜像的入口点。我们使用
ENTRYPOINT指令指向fwatchdog。
我们将做一些稍微不寻常的操作,但这是正确的方法,以准备一个函数容器。我们使用 Docker 的一个特性,称为多阶段构建,通过一个 Dockerfile 来编译程序并打包函数容器。
什么是多阶段构建?多阶段构建特性允许一个 Dockerfile 在构建过程中有多个构建阶段连接在一起。
使用这项技术,我们可以通过丢弃来自前一个构建阶段的较大镜像层,来构建一个非常小的 Docker 镜像。此功能需要 Docker 17.05 或更高版本。
打包 C 程序
这是一个不寻常但简单的函数示例。在这个示例中,我们将尝试将 C 程序编译、打包并部署为一个函数。为什么是 C 程序?基本上,如果我们知道可以打包 C 程序,那么任何传统程序都可以以类似的方式进行编译和打包。
我们知道,当设计一个函数时,它从 stdin 接收输入并将输出发送到 stdout。然后,C 程序将通过 printf() 向 stdout 发送一条简单的语句:
#include <stdio.h>
int main() {
printf("%s\n", "hello function");
return 0;
}
通常情况下,这个 C 程序可以在复制并打包为容器之前使用 gcc 编译。但为了使 Dockerfile 自包含,将使用多阶段构建技术,通过单个 docker build 命令来编译和打包它作为一个函数。
以下多阶段的 Dockerfile 包含两个阶段。State 0 从 Alpine 3.6 镜像开始,然后安装 gcc 和 musl-dev 用于编译 C 程序。此阶段有一个命令来静态构建 C 程序,gcc -static,这样它就不需要任何共享对象库:
###############
# State 0
###############
FROM alpine:3.6
RUN apk update apk add gcc musl-dev
COPY main.c /root/
WORKDIR /root/
RUN gcc -static -o main main.c
###############
# State 1
###############
FROM alpine:3.6
ADD https://github.com/openfaas/faas/releases/download/0.6.5/fwatchdog /usr/bin/
RUN chmod +x /usr/bin/fwatchdog
EXPOSE 8080
COPY --from=0 /root/main /usr/bin/func_c
ENV fprocess="/usr/bin/func_c"
ENTRYPOINT ["/usr/bin/fwatchdog"]
Stage 1 同样从 Alpine 3.6 基础镜像开始。它直接从 OpenFaaS GitHub 发布页面添加 fwatchdog 二进制文件,并将其模式更改为可执行 (chmod +x)。此 Dockerfile 最重要的部分是在它从上一阶段 Stage 0 复制主二进制文件。这可以通过使用 COPY 指令与 --from 参数来完成。func_c 容器镜像的构建过程如下所示:

图 4.6:示例中的多阶段构建工作流程示意图
以下是之前 Dockerfile 中的代码,展示了如何使用 COPY 指令在阶段之间复制文件。在 Stage 1 中,COPY --from=0 表示该命令会将文件或一组文件从 Stage 0 复制到 Stage 1。在之前的示例中,它会将 /root/main 文件从 Stage 0 更改为 Stage 1 中的 /usr/bin/func_c:
COPY --from=0 /root/main /usr/bin/func_c
在多阶段 Dockerfile 准备好之后,下一步是使用该 Dockerfile 执行 docker build。
在执行此操作之前,将设置一个环境变量 DOCKER_ID 为你的 Docker ID。如果你没有 Docker ID,请访问 hub.docker.com 并在那里注册。使用此 DOCKER_ID 变量,你可以在不每次更改我的 Docker ID 为你的情况下执行这些命令:
$ export DOCKER_ID="chanwit" # replace this to yours Docker ID.
$ docker build -t $DOCKER_ID/func_c . # <- please note that there's a dot here.
函数容器的运行状态将类似于 图 4.7 中所示的镜像堆栈。最底层是操作系统内核之上的根文件系统。接下来的层次是基础镜像和依次叠加的镜像层,利用联合文件系统的能力。最上层是每个运行容器的可写文件系统,代表一个 OpenFaaS 函数:

图 4.7:作为运行容器的函数,顶部有一个可写的文件系统层
使用多阶段构建时,我们可以创建一个非常小的镜像,仅包含作为函数所需的二进制文件。通过丢弃整个 Stage 0 的镜像层(包括所有编译器和依赖项),最终镜像的大小被减少到大约 11 MB。可以通过运行 docker image ls $DOCKER_ID/func_c 来检查:
$ docker image ls $DOCKER_ID/func_c
REPOSITORY TAG IMAGE ID CREATED SIZE
chanwit/func_c latest b673f7f37036 35 minutes ago 11.6MB
请注意,OpenFaaS 机制会首先从仓库查找镜像。因此,在将容器镜像用作函数之前,将镜像推送到 Docker Hub 或您的仓库会更安全。这可以通过 docker image push 命令简单完成。请注意,在推送镜像之前,可能需要使用 docker login 进行身份验证:
$ docker image push $DOCKER_ID/func_c
使用 UI 定义和调用函数
在 OpenFaaS 上定义和调用函数非常简单。在推送镜像后,可以通过 OpenFaaS UI 门户来定义函数。首先,打开 http://127.0.0.1:8080/ui。然后,你将在左侧面板中看到一个可点击的标签 CREATE NEW FUNCTION。点击后,将弹出定义函数的对话框。它需要该函数的 Docker 镜像名称;在这个例子中,镜像名称是 chanwit/func_c。再次提醒,请不要忘记将我的 Docker ID 改为你的 Docker ID。其次,定义时需要一个函数名称。就命名为 func_c。第三,我们需要定义 fprocess 字段的值,指向用于调用二进制程序的命令行。在这个示例中,命令行将在容器内简单地是 /usr/bin/func_c。如果函数程序需要某些参数,也请在这里包含它们。最后,函数定义需要一个 Docker 覆盖网络的名称,以便 API 网关连接到函数容器。只需在此处包含默认的网络 func_functions。需要特别注意的是,如果 OpenFaaS 堆栈部署到另一个环境,并且有不同的覆盖网络名称,必须记得指定正确的名称:

图 4.8:通过 UI 定义 OpenFaaS 函数
如果一切正常,点击 CREATE 来定义该函数。创建后,func_c 函数将显示在左侧面板中。点击函数名称将显示函数调用的主面板,如下所示:

图 4.9:调用func_c函数及其响应体
如果一个函数需要任何输入,可以将文本或 JSON 格式的输入数据放置为请求体。然而,func_c函数不接受任何输入,因此只需按下 INVOKE 按钮,函数就会被调用。在此示例中,调用过程已完成,状态为 OK:200。API 网关从函数的二进制文件/usr/bin/func_c获取标准输出,并以文本格式显示为响应体。
使用 OpenFaaS CLI
OpenFaaS CLI(faas-cli)是一个命令行工具,帮助管理、准备和调用函数。在 Linux 上,可以使用以下命令安装 OpenFaaS CLI:
$ curl -sSL https://cli.openfaas.com | sudo sh
在 macOS 上,可以通过brew使用以下命令进行安装:
$ brew install faas-cli
或者,在 Windows 上,可以直接从 OpenFaaS GitHub 仓库下载faas-cli.exe并手动运行。
然而,我们假设每个示例都运行在 Linux 系统上。在以下示例中,将使用 OpenFaaS 的 Go 语言模板创建hello函数,该模板可以在 GitHub 的openfaas/fass-cli库中的template/go目录下找到。
本地所有模板将存储在工作目录的template/目录下。如果模板目录不存在,所有模板将从 GitHub 的openfaas/faas-cli获取。从 OpenFaaS 0.6 版本开始,那里提供了 10 个适用于五种不同编程语言的模板。
定义一个新函数
要创建一个 Go 语言编写的函数,我们使用faas-cli new --lang=go hello命令:
$ faas-cli new --lang=go hello
2017/11/15 18:42:28 No templates found in current directory.
2017/11/15 18:42:28 HTTP GET https://github.com/openfaas/faas-cli/archive/master.zip
2017/11/15 18:42:38 Writing 287Kb to master.zip
2017/11/15 18:42:38 Attempting to expand templates from master.zip
2017/11/15 18:42:38 Fetched 10 template(s) : [csharp go-armhf go node-arm64 node-armhf node python-armhf python python3 ruby] from https://github.com/openfaas/faas-cli
2017/11/15 18:42:38 Cleaning up zip file...
Folder: hello created.
___ _____ ____
/ _ \ _ __ ___ _ __ | ___|_ _ __ _/ ___|
| | | | '_ \ / _ \ '_ \| |_ / _` |/ _` \___ \
| |_| | |_) | __/ | | | _| (_| | (_| |___) |
\___/| .__/ \___|_| |_|_| \__,_|\__,_|____/
|_|
Function created in folder: hello
Stack file written: hello.yml
创建函数后,我们可以通过运行tree -L 2 .命令检查函数目录的结构。该命令会显示两级深度的目录结构,如下所示:
$ tree -L 2 .
.
├── hello
│ └── handler.go
├── hello.yml
└── template
├── csharp
├── go
├── go-armhf
├── node
├── node-arm64
├── node-armhf
├── python
├── python3
├── python-armhf
└── ruby
首先,我们将查看hello.yml文件中的函数定义。在hello.yml文件中,有两个顶级项,provider和functions。
provider块告诉我们其提供者的名称是faas,即 Docker Swarm 中的默认 OpenFaaS 实现。同时,它告诉我们网关端点位于http://localhost:8080,即 API 网关的一个实例正在运行。在生产环境中,这个 URL 可以更改为指向实际的 IP 地址。
functions块列出了所有已定义的函数。在这个例子中,只有hello函数。该块告诉我们这个函数是用 Go 编程语言编写的(lang: go)。函数的处理程序由handler: ./hello指定,指向包含真实工作函数源文件的目录(./hello/handler.go)。在此示例中,输出镜像的名称由image: hello指定。在构建函数之前,我们会将镜像名称更改为<your Docker ID>/hello:v1,因为不建议使用:latest标签,这是最佳实践。
############
# hello.yml
############
provider:
name: faas
gateway: http://localhost:8080
functions:
hello:
lang: go
handler: ./hello
image: hello # change this line to <your Docker ID>/hello:v1
构建并推送
我们将编辑最后一行,将其改为 image: chanwit/hello:v1。再次提醒,不要忘记将我的 Docker ID 替换为你自己的。然后我们使用 faas-cli build 命令进行构建。我们使用 -f 来指定函数定义文件。请注意,构建这个 Dockerfile 会有两个阶段和 17 个步骤:
$ faas-cli build -f ./hello.yml
[0] > Building: hello.
Clearing temporary build folder: ./build/hello/
Preparing ./hello/ ./build/hello/function
Building: chanwit/hello:v1 with go template. Please wait..
Sending build context to Docker daemon 6.144kB
Step 1/17 : FROM golang:1.8.3-alpine3.6
---> fd1ada53b403
...
Step 17/17 : CMD ./fwatchdog
---> Running in a904f6659c33
---> f3b8ec154ee9
Removing intermediate container a904f6659c33
Successfully built f3b8ec154ee9
Successfully tagged chanwit/hello:v1
Image: chanwit/hello:v1 built.
[0] < Builder done.
Go 函数模板将从 template/go 目录复制到 build/hello 目录。然后,处理器文件 hello/handler.go 会被复制到 build/hello/function/handler.go。程序的入口点在 build/hello/main.go 中定义,它又调用处理器函数。在构建过程中,faas-cli 会内部执行 docker build 命令。Dockerfile 中定义的步骤将用于编译和打包函数。
下图解释了 Dockerfile、源文件和模板之间的关系:

图 4.10:Go 语言的 OpenFaaS 模板及其相关组件
构建完成后,我们再次检查目录结构。这次运行 tree -L 3 . 来显示三层深度的目录,因为我们需要检查由 faas-cli build 命令创建的 build 目录的内容:
$ tree -L 3 .
.
├── build
│ └── hello
│ ├── Dockerfile
│ ├── function
│ ├── main.go
│ └── template.yml
├── hello
│ └── handler.go
├── hello.yml
└── template
我们可以直接将构建的镜像推送到 Docker 仓库,同样使用 faas-cli push 命令。使用 -f 来指定规范文件。规范文件中 functions.image 的值将用于推送:
$ faas-cli push -f hello.yml
[0] > Pushing: hello.
The push refers to a repository [docker.io/chanwit/hello]
8170484ad942: Pushed
071849fe2878: Pushed
a2e6c9f93e16: Pushed
76eeaa2cc808: Pushed
3fb66f713c9f: Pushed
v1: digest: sha256:fbf493a6bb36ef92f14578508f345f055f346d0aecc431aa3f84a4f0db04e7cb size: 1367
[0] < Pushing done.
部署与调用
要部署新构建的函数,我们使用 faas-cli deploy 命令。它通过 -f 参数读取函数规范,类似于其他子命令。在这个例子中,它使用提供者的网关值来部署函数。如果 Docker Swarm 上已经有一个以前运行的函数作为服务,旧的函数将在部署新函数之前被删除。部署完成后,手动调用该函数的 URL(例如通过 curl)将显示出来:
$ faas-cli deploy -f hello.yml
Deploying: hello.
Removing old function.
Deployed.
URL: http://localhost:8080/function/hello
200 OK
要获取集群中所有正在运行的函数,我们可以运行 faas-cli list 命令。该命令还会显示每个函数的调用次数以及函数实例的副本数。当调用频率足够高时,副本数量会自动增加。所有这些信息都存储在 Prometheus 实例中。我们将在下一节通过 Grafana 仪表板更好地查看这些信息:
$ faas-cli list
Function Invocations Replicas
func_echoit 0 1
func_wordcount 0 1
func_webhookstash 0 1
func_markdown 0 1
func_hubstats 0 1
func_decodebase64 0 1
hello 0 1
func_base64 0 1
func_nodeinfo 0 1
hello 函数通过 stdin 接受输入,并通过 stdout 输出结果。为了测试函数的调用,我们将一句话回显并通过管道传递给 faas-cli invoke 命令的 stdin。这个调用通过 OpenFaaS 框架处理,所有的调用统计数据都会记录在集群中的 Prometheus 实例上:
$ echo "How are you?" | faas-cli invoke hello
Hello, Go. You said: How are you?
模板
预定义的模板对于字符串处理和开发简单函数来说可能足够了,但当事情变得复杂时,了解如何自己调整 OpenFaaS 模板就变得非常重要。
在这一部分,将调整 Go 模板以简化示例中的构建步骤数量。可以在template/go/Dockerfile找到以下 Go 模板的 Dockerfile。此 Dockerfile 已经使用了多阶段构建技术:
###################
# State 0
###################
FROM golang:1.8.3-alpine3.6
# ... lines removed for brevity
###################
# State 1
###################
FROM alpine:3.6
RUN apk --no-cache add ca-certificates
# Add non root user
RUN addgroup -S app adduser -S -g app app \
mkdir -p /home/app \
chown app /home/app
WORKDIR /home/app
COPY --from=0 /go/src/handler/handler .
COPY --from=0 /usr/bin/fwatchdog .
USER app
ENV fprocess="./handler"
CMD ["./fwatchdog"]
模板可以托管在自定义 Git 存储库中。以下是一个模板存储库的结构,可以通过template子命令获取。第一级必须是名为template/的目录。在template目录内,可能会有多个目录,例如,在以下结构中有go/目录:
$ tree .
.
├── README.md
└── template
└── go
├── Dockerfile
├── function
│ └── handler.go
├── main.go
├── README.md
└── template.yml
将整个模板源代码存储在 GitHub 存储库中后,可以使用faas-cli template pull稍后进行拉取和调整:
$ faas-cli template pull https://github.com/chanwit/faas-templates
Fetch templates from repository: https://github.com/chanwit/faas-templates
2017/11/16 15:44:46 HTTP GET https://github.com/chanwit/faas-templates/archive/master.zip
2017/11/16 15:44:48 Writing 2Kb to master.zip
2017/11/16 15:44:48 Attempting to expand templates from master.zip
2017/11/16 15:44:48 Fetched 1 template(s) : [go] from https://github.com/chanwit/faas-templates
2017/11/16 15:44:48 Cleaning up zip file...
在拉取调整后的模板之后,可以重新构建镜像,并将构建步骤数量减少到15:
$ faas-cli build -f hello.yml
[0] > Building: hello.
Clearing temporary build folder: ./build/hello/
Preparing ./hello/ ./build/hello/function
Building: chanwit/hello:v1 with go template. Please wait..
Sending build context to Docker daemon 7.68kB
Step 1/15 : FROM golang:1.8.3-alpine3.6
---> fd1ada53b403
...
Step 15/15 : CMD ./fwatchdog
---> Using cache
---> 23dfcc80a031
Successfully built 23dfcc80a031
Successfully tagged chanwit/hello:v1
Image: chanwit/hello:v1 built.
[0] < Builder done.
OpenFaaS 仪表板
在 Grafana 平台上有一个良好的 OpenFaaS 仪表板。要使 Grafana 与 OpenFaaS 配合工作,Grafana 服务器必须在相同的网络上。我们可以使用以下命令通过docker service create在 OpenFaaS 堆栈外运行 Grafana 服务器。它通过--network=func_functions参数与 OpenFaaS 堆栈进行链接:
$ docker service create --name=grafana \
--network=func_functions \
-p 3000:3000 grafana/grafana
或者,可以在http://localhost:3000打开仪表板。使用用户名admin和密码admin登录:

图 4.11:Grafana 主页仪表板
在将其用作仪表板数据源之前,必须创建并指向 Prometheus 服务器的数据源。首先,数据源名称必须为prometheus。其次,URL 需要指向http://prometheus:9090。之后,我们可以点击保存和测试按钮。如果数据源设置正确,将显示绿色弹出窗口:

图 4.12:在 Grafana 中定义一个新的 Prometheus 数据源
接下来,可以使用仪表板的 ID 导入 OpenFaaS 仪表板。我们将使用仪表板号3434,然后点击加载以准备导入仪表板:

图 4.13:在 Grafana 中导入仪表板的屏幕
接下来,对话框将更改为从 Grafana.com 导入仪表板。在这里,它将要求我们包括仪表板名称。我们可以将其保留为默认名称。它还会询问我们想要使用哪个数据源。选择之前步骤中已定义的 Prometheus 数据源。之后,点击导入按钮完成导入过程:

图 4.14:设置仪表板名称并选择其 Prometheus 数据源
以下是仪表板的展示。它在一个框中显示了网关的健康状态,并以仪表的形式显示网关服务的数量。总函数调用统计以线形图展示,配有数字。在测试中,Go 编写的 hello 函数被线性调用超过 20,000 次。在测试过程中,函数副本的数量从 5 个扩展到了 20 个。然而,由于测试是在单机上进行的,因此调用速率没有显著变化:

图 4.15:OpenFaaS 仪表板的实际操作
以下是允许 OpenFaaS 自动扩展函数副本的机制。首先,当客户端通过 API 网关请求函数调用时,该调用将存储在 Prometheus 中。在 Prometheus 内部,有一个 Alert Manager,它负责在预定义规则匹配时触发事件。OpenFaaS 为 Alert Manager 定义了一条规则,通过将事件与其 Alert Handler URL http://gateway:8080/system/alert 关联,来扩展副本数量。这个 Alert Handler 将负责计算副本数量,检查最大副本限制,并通过 Swarm 客户端 API 向集群发送 scale 命令,从而扩展某个函数的副本。下图展示了这个自动扩展机制背后的步骤:

图 4.16:OpenFaaS 在 Docker Swarm 中自动扩展函数服务副本的告警机制
练习
以下是帮助你回顾本章中需要记住和理解的所有主题的问题列表:
-
使用 OpenFaaS 有什么优势?
-
请描述 OpenFaaS 的架构。各个组件是如何相互通信的?
-
我们如何在 Docker Swarm 上部署 OpenFaaS 堆栈?
-
为什么 OpenFaaS 使用多阶段构建?
-
我们如何为 Node.js 创建一个新的 OpenFaaS 函数?
-
我们如何构建并打包一个 OpenFaaS 函数?
-
OpenFaaS 使用的覆盖网络的默认名称是什么?
-
什么是函数模板?它的用途是什么?
-
描述准备自定义模板并将其托管在 GitHub 上的步骤。
-
我们如何为 OpenFaaS 定义 Grafana 仪表板?
摘要
本章讨论了 OpenFaaS 及其架构,以及我们如何将其作为无服务器框架在 Docker Swarm 上部署函数。OpenFaaS 具有多个令人信服的特性,特别是其易用性。本章展示了在 Docker Swarm 基础设施中部署 OpenFaaS 堆栈非常简单。接着,本章继续讨论了如何定义、构建、打包和部署 OpenFaaS 函数。它还讨论了如何调整和准备自定义模板的高级话题。
监控 OpenFaaS 非常简单,因为它内置了 Prometheus。我们只需要安装 Grafana 仪表板并将其连接到 Prometheus 数据源,就能获得一个现成的仪表板,帮助我们操作 OpenFaaS 集群。
下一章将介绍 Fn 项目,它允许我们在普通的 Docker 基础设施上部署 FaaS 平台。
第五章:Fn 项目
本章介绍了一个 FaaS 平台,Fn 项目。它是由 Oracle Inc. 团队开发的另一个出色的 FaaS 框架。Fn 是其中一个最简单的项目,允许我们在纯 Docker 基础设施上部署 FaaS 平台。
本章从讨论 Fn 项目是什么开始。然后我们将继续探讨它的组件如何组织,以及它的整体架构。接下来,我们将学习如何使用 Fn CLI 准备和部署函数。本章最后将讨论如何使用 Fn 子项目来管理其 UI、扩展和监控 Fn 集群本身。
本章将涵盖以下内容:
-
Fn 项目
-
Fn 的架构
-
使用 Fn CLI
-
部署本地函数
-
在 Docker Swarm 上部署 Fn
-
使用内置 UI 监控 Fn
-
使用熟悉的工具进行日志分析
Fn 项目
Fn 项目最初由Iron.io团队(www.iron.io/)在 Iron function 名称下构思。此后,两位创始人加入了 Oracle,并将 Iron function 分支为新的项目 Fn。
Fn 是一个框架和系统,用于开发和部署无服务器/FaaS 应用程序。与 OpenFaaS 不同,Fn 不使用任何编排器级别的功能来管理函数容器。
Fn 不仅支持通过其自身基础设施进行部署;它还允许你将相同的函数部署到 AWS Lambda。然而,我们这里只讨论如何将函数部署到其自身的基础设施,当然,这个基础设施是基于 Docker 的。
Fn 背后有几个设计原因。
Fn 项目致力于开源。它原生支持 Docker,这意味着我们可以将 Docker 容器作为其部署单元——一个函数。Fn 支持任何编程语言的开发。Fn 基础设施是用 Go 编程语言编写的,旨在能够在各处部署,包括公共云、私有云,甚至混合基础设施。Fn 还支持从 AWS 导入 Lambda 函数并将它们部署到自身的基础设施中。
如前所述,基于 Docker 的无服务器/FaaS 基础设施基本上旨在平衡控制整个系统与基础设施的维护和管理的便利性。Fn 也有与这一理念相一致的设计目标。
Fn 的架构
Fn 服务器的最简单设置只是启动一个独立的 Fn 容器;然而,更完整的架构将如图所示。本章最后将演示集群实现。下图展示了 Fn 架构的概览:

图 5.1:Fn FaaS 集群的架构
与常见的 FaaS 架构一样,Fn 也有API 网关,在前面的图示中为Fn LB。Fn LB 基本上是一个负载均衡器。它将来自客户端的请求转发到每个Fn Server。在 Fn Server 的实现中,没有像 Fn 架构核心中的事件总线那样的发起者和执行者的分离概念。因此,Fn Server 也充当执行者,在其关联的 Docker 引擎上执行函数。
Fn 服务器连接到一个Log Store,它可以是一个独立的数据库系统或一个数据库管理系统的集群。所有从 Fn 函数发送到标准错误的数据显示都会记录到Log Store。
Fn UI 和 Fn LB 是额外的组件,有助于在生产环境中改进 Fn 项目。Fn UI 是用户界面服务器,如仪表盘,用于 Fn,而 Fn LB 是负载均衡器,用于在集群中的 Fn 节点之间进行轮询。
在 Fn Server 中有一个执行者代理的概念。该代理负责控制运行时环境。在 Fn 中,运行时是 Docker。因此,在本章节中,执行者代理也称为Docker 代理。在默认配置下,Fn Server 中的 Docker 代理连接到本地 Docker 引擎,并通过本地 Unix 套接字启动 Fn 函数:

图 5.2:显示 Fn 集群在 Swarm 范围网络上的示意图
上面的图示展示了一个在 Swarm 范围覆盖网络上的运行 Fn 集群。为了组成一个集群,我们将使用一个可附加的 Swarm 范围网络。每个 Fn Server 实例都需要连接到该网络。当请求被发送到网关或直接发送到服务器时,它将被传递到EntryPoint。EntryPoint 是一个特定语言的程序,它包装了真正的函数程序。例如,在使用 Java 构建的 Fn 函数中,EntryPoint 是类 com.fnproject.fn.runtime.EntryPoint。这个 Java 类内部的代码通过 Java 的反射技术调用真正的函数:

图 5.3:一个 Fn 函数与 STDIN、STDOUT 交互并将日志写入 STDERR,且将日志委托给存储
Fn 服务器将请求主体以STDIN的形式发送到Function容器。当EntryPoint接收到STDIN流后,它会将数据内容转换为匹配函数签名类型的格式。在前面的图示中,签名是String。因此,函数体被转换为字符串。发送到STDOUT的输出将被转发到Fn Server并作为结果发送出去,而发送到STDERR的输出将被捕获并存储在Log Store中。
使用 Fn CLI
本节将讨论如何使用 Fn CLI 的基本功能,这是一个控制 Fn 的命令行工具。我们先从 Fn CLI 的安装开始。确保你的系统中存在curl命令:
$ curl -LSs https://raw.githubusercontent.com/fnproject/cli/master/install | sh
安装完前面的命令后,通过输入fn检查其版本和帮助信息。撰写时,命令行的当前版本是0.4.43。由于变化迅速,你可以预期使用不同版本的情况:
$ fn
fn 0.4.43
Fn command line tool
ENVIRONMENT VARIABLES:
FN_API_URL - Fn server address
FN_REGISTRY - Docker registry to push images to, use username only to push to Docker Hub - [[registry.hub.docker.com/]USERNAME]
COMMANDS:
...
fn提供了几个子命令,例如:
-
fn start是docker run命令的一个简单包装器。此子命令启动新的 Fn Server 实例。默认地址将是http://localhost:8080。然而,Fn CLI 将尝试连接到在环境变量中定义的FN_API_URL地址(如果已设置)。 -
fn update是用于将最新版本的 Fn Server 拉取到本地 Docker 镜像的命令。 -
fn init是用于初始化一个骨架以开发新函数的命令。它接受--runtime参数,用于生成特定语言的模板,例如 Go 语言。 -
fn apps包含用于创建、更新和删除应用程序的子命令,是一种命名空间或包,用于将多个函数组合在一起。要求函数必须在一个应用程序下定义。 -
fn routes是一组用于定义指向函数容器的路由的命令。例如,我们有一个名为demo的应用程序,然后可以定义路由hello并将其指向 Docker 容器镜像test/hello:v1。一个应用程序可以有多个路由:

图 5.4:Fn 应用程序及其路由之间的关系
这是 Fn 如何在应用程序下组织路由的示例。例如,Fn 的 API URL 是http://localhost:8080。我们可能有一个名为demo的应用程序,其中包含一个名为hello的路由,该路由为容器镜像test/hello:v1创建。所有这些组合在一起形成了一个访问该函数的完整 URL。
让我们部署一个本地函数
首先,执行fn start以启动一个独立的 Fn Server 实例。服务器通过设置日志级别为info(默认设置)启动。Fn Server 然后连接到数据存储,即日志存储。当前的实现是 SQLite3。之后,代理将启动。Docker 代理使用其默认配置连接到本地 Docker 引擎。最后,Fn 开始监听端口8080:
$ fn start
time="2018-03-17T08:48:39Z" level=info msg="Setting log level to" level=info
time="2018-03-17T08:48:39Z" level=info msg="datastore dialed" datastore=sqlite3 max_idle_connections=256
time="2018-03-17T08:48:40Z" level=info msg="agent starting cfg=&{MinDockerVersion:17.06.0-ce FreezeIdle:50ms EjectIdle:1s HotPoll:200ms HotLauncherTimeout:1h0m0s AsyncChewPoll:1m0s MaxResponseSize:0 MaxLogSize:1048576 MaxTotalCPU:0 MaxTotalMemory:0 MaxFsSize:0}"
time="2018-03-17T08:48:40Z" level=info msg="no docker auths from config files found (this is fine)" error="open /root/.dockercfg: no such file or directory"
______
/ ____/___
/ /_ / __ \
/ __/ / / / /
/_/ /_/ /_/
v0.3.381
time="2018-03-17T08:48:41Z" level=info msg="available memory" availMemory=12357627495 cgroupLimit=9223372036854771712 headRoom=1373069721 totalMemory=13730697216
time="2018-03-17T08:48:41Z" level=info msg="sync and async ram reservations" ramAsync=9886101996 ramAsyncHWMark=7908881596 ramSync=2471525499
time="2018-03-17T08:48:41Z" level=info msg="available cpu" availCPU=4000 totalCPU=4000
time="2018-03-17T08:48:41Z" level=info msg="sync and async cpu reservations" cpuAsync=3200 cpuAsyncHWMark=2560 cpuSync=800
time="2018-03-17T08:48:41Z" level=info msg="Fn serving on `:8080`" type=full
为了检查 Docker 是否正确启动了 Fn Server,我们可以使用docker ps查看正在运行的容器。可以在另一个终端执行此操作:
$ docker ps --format="table {{.ID}}\t{{.Names}}\t{{.Ports}}"
CONTAINER ID NAMES PORTS
ab5cd794b787 fnserver 2375/tcp, 0.0.0.0:8080->8080/tcp
好的,现在我们已经看到 Fn Server 正在端口8080上运行,并且通过docker ps看到映射0.0.0.0:8080->8080/tcp。
在启动fn start命令的当前目录中,容器将其data目录映射到主机的$PWD/data。该目录包含 SQLite3 数据库文件,用于存储日志和信息。在生产环境中,我们将用 MySQL DBMS 替换它,例如:
$ tree
.
└── data
├── fn.db
└── fn.mq
1 directory, 2 files
要查看应用程序列表,只需使用fn apps list命令:
$ fn apps list
no apps found
好吧,由于我们刚刚启动了服务器实例,因此没有新创建的应用程序。我们将创建一个,命名为demo,然后再次使用fn apps list命令来双重确认创建的应用:
$ fn apps create demo
Successfully created app: demo
$ fn apps list
demo
现在我们将开始开发一个函数。在这个示例中,我们使用 Java 运行时,稍后我们将尝试使用 Go 的另一种运行时。
让我们初始化新函数。我们使用fn init来创建一个新的函数项目。此命令需要--runtime来指定我们希望使用的语言运行时。
func.yaml是我们的函数描述文件。它包含版本号、运行时和函数的入口点:
$ fn init --runtime java hello
Creating function at: /hello
Runtime: java
Function boilerplate generated.
func.yaml created.
我们将尝试学习如何构建和部署一个函数。所以让我们先不修改任何内容,直接构建它。要构建函数,只需使用fn build。而要部署函数,我们有fn deploy来为我们处理整个过程。
这是 Fn 的构建行为。在调用fn build命令后,构建过程开始,使用生成的 Dockerfile。生成的镜像将被打标签并由 Docker 引擎本地存储。例如,示例中的镜像将被本地标记为hello:0.0.1。然后,使用fn deploy命令时,需要--registry来将镜像远程存储在 Docker Hub 上。在此示例中,使用的是我的 Docker ID,请记得将其更改为您的 ID。
fn deploy命令的工作方式如下。
首先,它增加了函数的版本号。其次,它使用--registry将函数的镜像推送到 Docker Hub,作为仓库名称。因此,hello:0.0.2在 Docker Hub 上变成了chanwit/hello:0.0.2。
然后,fn deploy将使用新构建镜像的名称,在--app指定的应用程序下注册一个新路由:
$ fn build
Building image hello:0.0.1
Function hello:0.0.1 built successfully.
$ fn deploy --app demo --registry chanwit
Deploying hello to app: demo at path: /hello
Bumped to version 0.0.2
Building image chanwit/hello:0.0.2
Pushing chanwit/hello:0.0.2 to docker registry...The push refers to repository [docker.io/chanwit/hello]
07a85412c682: Pushed
895a2a3582de: Mounted from fnproject/fn-java-fdk
5fb388f17d37: Mounted from fnproject/fn-java-fdk
c5e4fcfb11b0: Mounted from fnproject/fn-java-fdk
ae882186dfca: Mounted from fnproject/fn-java-fdk
aaf375487746: Mounted from fnproject/fn-java-fdk
51980d95baf3: Mounted from fnproject/fn-java-fdk
0416abcc3238: Mounted from fnproject/fn-java-fdk
0.0.2: digest: sha256:c7539b1af68659477efac2e180abe84dd79a3de5ccdb9b4d8c59b4c3ea429402 size: 1997
Updating route /hello using image chanwit/hello:0.0.2...
让我们检查一下新注册的路由。我们使用fn routes list <app>命令来列出应用程序<app>下的所有路由。在下面的示例中,列出了demo的所有路由:
$ fn routes list demo
path image endpoint
/hello chanwit/hello:0.0.2 localhost:8080/r/demo/hello
上一个命令还列出了每个路由的端点。通过端点,我们基本上可以像普通 HTTP 端点一样使用curl与其交互。不要忘记为curl设置-v详细选项。通过此选项,我们可以检查 HTTP 头部中隐藏的内容。
让我们看一下 HTTP 响应头中标记为粗体的行。这里有一些额外的条目,Fn_call_id和Xxx-Fxlb-Wait。
头部信息,Fn_call_id,是每次调用的标识符。此 ID 在我们启用 Fn 的分布式追踪时也将使用。头部信息,Xxx-Fxlb-Wait,是 Fn LB 收集的信息,它可以知道此函数的等待时间:
$ curl -v localhost:8080/r/demo/hello
* Trying 127.0.0.1...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /r/demo/hello HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.47.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Length: 13
< Content-Type: text/plain
< Fn_call_id: 01C8SPGSEK47WGG00000000000
< Xxx-Fxlb-Wait: 78.21µs
< Date: Sat, 17 Mar 2018 10:01:43 GMT
<
* Connection #0 to host localhost left intact
Hello, world!
再试一次使用 Golang
让我们尝试使用另一种运行时 Go 创建下一个函数。与 Java 不同,Go 语言代码在 Fn 函数内没有明确的入口点概念。幸运的是,Fn 的执行模型足够简单,因此这个问题非常微不足道:
$ fn init --runtime go hello_go
Creating function at: /hello_go
Runtime: go
Function boilerplate generated.
func.yaml created.
这是 Go Fn 函数的文件列表:
$ cd hello_go
$ tree .
.
├── func.go
├── func.yaml
└── test.json
文件func.go当然就是函数程序本身,而func.yaml是 Fn 的函数描述符。其中一个有趣的文件是test.json——它是包含功能测试的测试数据文件。目前,我们可以使用fn test命令来测试正向路径,但无法测试负向结果。
我们首先来看一下func.yaml,了解它的内容。每次部署时,version会自动增加。这里的runtime是go,因为我们在fn init时指定了--runtime参数。这里的entrypoint不应修改。就保持原样,信任我:
$ cat func.yaml
version: 0.0.1
runtime: go
entrypoint: ./func
Go 代码可以直接消费 STDIN。最好的方法是将输入作为 JSON 传递,并使用 Go 的encoding/json包来处理数据。以下是从原始 Fn 示例修改的程序。这个程序被修改为简化输出过程,并添加了错误检查和日志记录:
package main
import (
"encoding/json"
"fmt"
"os"
)
type Message struct {
Name string
}
func main() {
m := &Message{Name: "world"}
err := json.NewDecoder(os.Stdin).Decode(m)
if err != nil {
fmt.Fprintf(os.Stderr, "err JSON Decode: %s\n", err.Error())
os.Exit(250)
}
fmt.Printf(`{"success": "Hello %s"}`, m.Name);
os.Exit(0)
}
在每个程序中,我们都需要检查错误并处理它们。如前面的示例所示,我们检查编码时发生的错误,然后将错误消息打印到 Go 中的标准错误文件os.Stderr。然后我们只需使用代码> 0退出进程。在这里,我们使用250。
让我们总结一下 Fn 中的错误处理和日志记录。首先,写入 STDERR 的消息将被存储在日志中。其次,使用错误代码退出进程,即> 0。Fn 随后会将容器执行标记为错误。
让我们来看一下实际操作。确保我们在func.go中有之前的代码示例,并使用fn deploy命令将其部署:
$ fn deploy --app demo --registry chanwit
Deploying hello_go to app: demo at path: /hello_go
Bumped to version 0.0.2
Building image chanwit/hello_go:0.0.2 .......
Pushing chanwit/hello_go:0.0.2 to docker registry...The push refers to repository [docker.io/chanwit
/hello_go]
00a6a1467505: Pushed
96252b84ae14: Pushed
97dedccb7128: Mounted from fnproject/go
c9e8b5c053a2: Mounted from fnproject/go
0.0.2: digest: sha256:8a57737bff7a8e4444921959532716654230af0534b93dc6be247ac88e4e7ef2 size: 1155
Updating route /hello_go using image chanwit/hello_go:0.0.2...
如果fn deploy的最后一行显示路由已更新,那就表示已经准备好。
接下来,我们将使用fn call命令来调用该函数,该函数现在已经注册为demo应用程序下的一个路由。尝试在没有参数的情况下调用它,这会导致错误:
$ fn call demo /hello_go
{"error":{"message":"container exit code 250"}}
ERROR: error calling function: status 502
这是我们预期的结果。它是一次没有输入的调用。因此,encoding/json抛出了错误,程序将日志消息写入了 STDERR(在之前的代码中没有显示)。最后,函数返回了250。通过这个消息,我们看到fn call打印出函数容器以250代码退出。所以错误得到了正确处理。
这里没有日志消息,但我们稍后会回到它们。
接下来,我们将进行一次成功的调用。为了使其显示为绿色,只需使用echo命令传递 JSON 主体。JSON 主体将通过管道传递给fn call,并转换为 HTTP 请求,然后它将被 Fn 服务器接收并再次序列化为函数程序的 STDIN。
成功的 JSON 块是我们对一个正常工作的程序的预期输出。
使用fn call调用远程函数的语法是,我们需要传递应用程序名称和路由名称,这样它才能被调用:
$ echo '{"Name": "chanwit"}' | fn call demo /hello_go
{"success": "Hello chanwit"}
检查呼叫日志和错误
要查看所有调用日志,请使用fn calls命令。请注意,命令是带有 s 的calls。fn calls list命令接受应用程序的名称。需要关注的属性是ID和Status。以下示例显示了两个调用日志,第一个是error,第二个是success,按时间倒序排列:
$ fn calls list demo
ID: 01C8VRGN9R47WGJ00000000000
App: demo
Route: /hello_go
Created At: 2018-03-18T05:15:04.376Z
Started At: 2018-03-18T05:15:04.738Z
Completed At: 2018-03-18T05:15:07.519Z
Status: success
ID: 01C8VRFE3647WGE00000000000
App: demo
Route: /hello_go
Created At: 2018-03-18T05:14:24.230Z
Started At: 2018-03-18T05:14:24.566Z
Completed At: 2018-03-18T05:14:27.375Z
Status: error
现在,我们选择第二个调用 ID 来获取日志消息。用于检索日志的命令是fn logs get。它需要应用程序名称和调用 ID:
$ fn logs get demo 01C8VRFE3647WGE00000000000
err JSON Decode: EOF
之前的日志消息是 Go 程序输出到os.Stderr的内容。
在 Docker Swarm 上部署 Fn
在这个示例中,我们在 Swarm 范围内的网络上启动一个 Fn 集群。
从部署网络开始,我们使用weaveworks/net-plugin作为骨干网络,以确保稳定性。请注意,网络必须是可附加的,并且子网必须位于10.32.0.0/16的范围内。所以,10.32.3.0/24在这里完全合适:
$ docker network create \
--driver weaveworks/net-plugin:2.1.3
--attachable \
--subnet 10.32.3.0/24 \
fn_net
然后,我们为数据存储准备一个卷。由于本节还希望展示一个产品级的设置,我们使用 MySQL 作为存储,而不是默认的 SQLite3。使用 MySQL 使我们能够横向扩展 Fn 服务器的数量。
卷将使用docker volume create命令创建。如果我们想要设置一个 MySQL 集群,设置会比这个稍微复杂一些,但这本书不会涉及:
$ docker volume create mysql_vol
这是启动 MySQL 实例的docker run命令。我们将实例连接到先前创建的网络fn_net。我们在此指定网络别名,以确保服务必须通过名称mysql来访问。所有环境变量的设计是为了设置用户名、密码和默认数据库fn_db。不要忘记将卷mysql_vol绑定到容器内的/var/lib/mysql。这是为了确保数据在容器被移除时仍然存活:
$ docker run \
--detach \
--name mysql \
--network fn_net \
--network-alias mysql \
-e MYSQL_DATABASE=fn_db \
-e MYSQL_USER=func \
-e MYSQL_PASSWORD=funcpass \
-e MYSQL_RANDOM_ROOT_PASSWORD=yes \
-v mysql_vol:/var/lib/mysql \
mysql
下一步是启动 Fn 服务器。本节展示了如何启动两个指向相同日志存储(MySQL)的 Fn 服务器。每个 Fn 服务器都连接到fn_net。第一个实例命名为fn_0。Fn 服务器需要FN_DB_URL来指向外部日志存储,可能是 PostgreSQL 或 MySQL。只需像以下命令中所示,输入完整的 URL。我们还将容器命名为fn_0,以便于管理。
当有这样的设置时,Fn 服务器变得完全无状态,所有状态将被存储在数据库外部。所以,当出现问题时,完全可以安全地移除 Fn 服务器容器:
$ docker run --privileged \
--detach \
--network fn_net \
--network-alias fn_0 \
--name fn_0 \
-e "FN_DB_URL=mysql://func:funcpass@tcp(mysql:3306)/fn_db" \
fnproject/fnserver
让我们启动另一个,fn_1。基本上,这应该在一个单独的节点(物理或虚拟)上完成:
$ docker run --privileged \
--detach \
--network fn_net \
--network-alias fn_1 \
--name fn_1 \
-e "FN_DB_URL=mysql://func:funcpass@tcp(mysql:3306)/fn_db" \
fnproject/fnserver
好的,在设置好所有 Fn Server 实例后,现在是时候将它们聚合在一起了。我们使用 Fn LB 作为所有 Fn Servers 前面的负载均衡器。与其他容器类似,我们只需创建并将其附加到 fn_net。作为 FaaS 网关,我们还将其端口暴露到 8080(从其内部端口 8081),使 Fn CLI 可以连接到 Fn 集群而无需任何特殊设置。网络别名仅在我们需要其他服务连接到该网关时使用。
接下来,发送一个 Fn Server 节点列表作为命令行参数。
当前,节点列表配置仅允许直接传递给容器。只需以 <name>:<port> 格式输入它们,并用 逗号 分隔:
$ docker run --detach \
--network fn_net \
--network-alias fnlb \
--name fnlb \
-p 8080:8081 \
fnproject/fnlb:latest --nodes fn_0:8080,fn_1:8080
好的,现在是时候验证一切是否正常运行了。我们使用 docker ps 命令仔细检查所有容器:
$ docker ps --format "table {{.ID}}\t{{.Names}}\t{{.Command}}\t{{.Ports}}"
CONTAINER ID NAMES COMMAND PORTS
ce4f8e9bc300 fnlb "./fnlb --nodes fn_0…" 0.0.0.0:8080->8081/tcp
dae4fb892b4d fn_1 "preentry.sh ./fnser…" 2375/tcp
8aefeb9e19ef fn_0 "preentry.sh ./fnser…" 2375/tcp
67bd136c331a mysql "docker-entrypoint.s…" 3306/tcp
在接下来的两部分中,我们将介绍如何通过 Fn UI 监控 Fn 的运行情况,以及如何查看并可能进一步分析存储在数据库中的日志。
使用 Fn UI 进行监控
Fn UI 是为 Fn 创建的用户界面项目。它提供了一个简单的仪表盘,并配备易于使用的时间序列图表,以便实时监控函数的运行情况。要启动 Fn UI,我们创建并将容器附加到 fn_net,同时将端口发布到 4000。Fn UI 需要一个 Fn Server 的 URL。但是它们都位于 Fn LB 后面,因此我们只需将 FN_API_URL 设置为 Fn LB 的位置。
请注意,它们都在 fn_net 网络内部相互连接,因此 URL 显示为 http://fnlb:8081,使用的是 fnlb 在网络中的实际名称和端口:
$ docker run --detach \
--network fn_net \
--network-alias fnui \
-p 4000:4000 \
-e "FN_API_URL=http://fnlb:8081" fnproject/ui
设置好 Fn UI 实例后,浏览到 localhost:8080 打开仪表盘。我们将看到列出的所有应用程序,如以下截图所示。也可以在此管理应用程序,例如创建或删除。如果不希望屏幕一直自动刷新,可以取消选中“自动刷新”:

图 5.5:显示 Fn 应用程序列表的 Fn 仪表盘
选择一个应用程序后,您可以通过单击仪表盘中的“运行函数”按钮来执行函数,如以下截图所示。如果在执行函数时发生错误并且执行失败,例如,将弹出通知,如以下示例所示。
要执行该函数,请将有效载荷以 JSON 形式放入并按下运行按钮:

图 5.6:用于调用函数的对话框
当函数调用完成时,它的名称和计数将出现在已完成的图表中。以下是调用函数的 curl 命令。多次运行它以查看图表变化:
$ curl -X POST -d '{"Name":"chanwit"}' http://localhost:8080/r/demo/hello_go
还有一个运行图表,显示了仍在并行运行的函数数量。以下截图展示了这些图表的运行情况:

图 5.7:显示 Fn 函数不同状态的图表
让我们看看当我们运行一些无效输入的请求时会发生什么。以下是命令:
$ curl -X POST -d '' http://localhost:8080/r/demo/hello_go
这样,hello_go 函数将以代码 250 退出,并出现在失败图表中。我们反复运行它,以使失败次数增加,如下图所示:

图 5.8:右下方显示失败函数增量的图表
我们现在已经知道如何使用 Fn UI 来监控函数调用。接下来,我们将使用一个简单的 DBMS 界面来帮助浏览 Fn 服务器收集的日志。
使用 MyAdmin 查看呼叫日志
以 MySQL 作为中央日志存储,我们可以通过任何工具轻松访问 MySQL 以查询或分析日志。在这个例子中,我们使用一个简单的 MyAdmin 界面连接到 MySQL 后端。以下是启动 MyAdmin 的 docker run 命令。
我们只需将 MyAdmin 实例附加到相同的网络,并告诉 MyAdmin 连接到 mysql,即后端数据库的服务名称:
$ docker run --detach \
--name myadmin \
--network fn_net \
--network-alias myadmin \
-p 9000:80 \
-e PMA_HOST=mysql \
phpmyadmin/phpmyadmin
浏览到暴露的端口,在这个例子中是端口号 9000,并使用在 MySQL 设置期间设置的用户名和密码(func/funcpass)登录。以下截图显示了 phpMyAdmin 的登录页面:

图 5.9:将连接到 Fn 日志数据库的 phpMyAdmin 登录页面
在 phpMyAdmin 面板内,查看 fn_db 参数,我们将看到用于存储 Fn 信息的所有表,如下图所示。表 apps 的数据是通过命令 fn apps create 创建的。例如,我们想要查看的是 calls 表和 logs 表。calls 表的内容可以通过 fn calls list 检索,logs 表的内容也可以通过类似的方式使用 fn logs get 检索。但是,当我们能够直接访问 logs 时,我们甚至可以直接使用可用数据进行一些分析:

图 5.10:phpMyAdmin 中所有 Fn 表的列表
以下截图显示了 calls 表的内容。表中有一个状态列,允许我们有效地筛选出呼叫的状态:成功或错误。还有一个 stats 列,包含一些时间信息,将由 Fn UI 检索并显示:

图 5.11:calls 表中的 Fn 呼叫日志数据
以下截图显示了 logs 表。在 logs 表中,它只是为每个条目打上呼叫 ID 的标记。log 列显示了我们打印到 STDERR 的日志消息。我们可以通过尝试向 hello_go 函数发送一些无效输入来查看不同的错误行为。由于这个表如此易于访问,我们可以有效地排除 Fn 函数的问题,而不需要安装其他额外的工具:

图 5.12:从函数的 STDERR 捕获的 Fn 日志数据
好的,如果我们能够让 MyAdmin 显示日志数据,看来一切正常。最后,为了确认所有容器是否都在运行以及它们的状态,只需再次使用docker ps命令检查所有正在运行的容器:
$ docker ps --format "table {{.ID}}\t{{.Names}}\t{{.Command}}\t{{.Ports}}"
CONTAINER ID NAMES COMMAND PORTS
70810f341284 fnui "npm start" 0.0.0.0:4000->4000/tcp
ce4f8e9bc300 fnlb "./fnlb --nodes fn_0…" 0.0.0.0:8080->8081/tcp
dae4fb892b4d fn_1 "preentry.sh ./fnser…" 2375/tcp
8aefeb9e19ef fn_0 "preentry.sh ./fnser…" 2375/tcp
8645116af77d myadmin "/run.sh phpmyadmin" 9000/tcp, 0.0.0.0:9000->80/tcp
67bd136c331a mysql "docker-entrypoint.s…" 3306/tcp
练习
现在是时候回顾本章的所有内容了:
-
Fn 架构是什么样的?
-
该架构与其他 FaaS 平台有何不同?
-
Fn 服务器的角色是什么?
-
我们如何配置 Fn 服务器以使用外部数据存储?
-
Fn 的 Java 运行时和 Go 运行时所使用的技术有何不同?
-
应用程序和路由是如何组织的?
-
Fn LB 的角色是什么?
-
Fn UI 的角色是什么?
-
我们如何查看之前调用的结果?
-
我们如何检查失败调用的日志信息?
-
描述一个 Fn 函数如何与 STDIN、STDOUT 和 STDERR 交互?
总结
本章讨论了 Fn 项目、其组件和架构。我们开始使用 Fn 及其命令行工具 Fn CLI。
接着我们讨论了 Fn 函数的结构,例如它如何与 STDIN、STDOUT 和 STDERR 交互。我们学习了如何构建和部署 Fn 函数,包括使用 Java 和 Go 运行时。
然后我们在 Docker Swarm 上形成了一个 Fn 集群,并将 Fn 服务器实例与外部数据库存储(MySQL)连接。我们使用 Fn LB(由同一团队专门实现的负载均衡器)对 Fn 实例进行了负载均衡。
使用 Fn UI,我们学会了如何利用它监控 Fn 的调用。通过 MyAdmin,我们能够直接在 MySQL 中浏览调用和错误日志。像 MyAdmin 这样的简单工具可以在不准备复杂工具链的情况下实现相同的分析结果。
下一章将介绍 OpenWhisk,这是 Apache 项目中的另一个无服务器技术栈,以及 IBM 在其云中提供无服务器服务所使用的技术栈。
第六章:OpenWhisk on Docker
本章将讨论服务器无关平台中的另一个角色——OpenWhisk。首先将概述 OpenWhisk 平台、其设计原理和功能,之后本章将介绍如何部署本地 OpenWhisk 实例以进行函数开发,如何使用其命令行界面,OpenWhisk 的组件和架构,以及如何准备函数以部署到该平台。
什么是 OpenWhisk?
OpenWhisk 被捐赠给 Apache 基金会,是一个强大的 FaaS 平台,最初由 IBM 和 Adobe 开发。它建立在 Docker 容器技术之上,可以部署在云端或本地硬件上。它是一个平台,能够解放开发者,让他们无需担心代码的生命周期或执行代码的容器运行时的管理。OpenWhisk 旨在具有可扩展性,并支持大量函数调用。目前,OpenWhisk 是 IBM Cloud Functions 的引擎。
OpenWhisk 的扩展机制并非建立在 Docker Swarm 或 Kubernetes 调度器之上,而是直接连接到每个 Docker 实例以启动和扩展函数容器。凭借这种设计,OpenWhisk 与普通的 Docker 基础设施相比,更适合与其结合,而不是 Kubernetes。
对于开发者来说,OpenWhisk 通过围绕函数的高级编程模型提供了许多引人注目的功能。其事件触发机制如图 6.1 所示:

图 6.1:OpenWhisk 事件触发流程
类似于其他平台,OpenWhisk 的最小部署单元是一个函数。在 OpenWhisk 中,函数称为操作。操作可以响应事件进行执行。事件以触发器的形式出现,通过规则进行处理,选择适当的操作进行执行。操作执行后,其结果将存储在结果存储中,然后返回到事件的源头。
OpenWhisk 原生支持许多语言运行时。然而,本章仅专注于其 Docker 运行时,允许开发者将任何类型的工作负载打包到容器中,并让 OpenWhisk 完成剩下的工作。OpenWhisk 中的操作可以同步调用、异步调用,甚至按计划触发。除了操作,OpenWhisk 还提供了一种声明式编程构造,例如序列,允许多个操作链接起来并作为一个流程执行。
安装 OpenWhisk
在撰写本文时,安装 OpenWhisk 在本地机器上的最快方式是使用 Docker 和 Docker Compose。
要安装 Docker Compose,我们可以按照 github.com/docker/compose/releases 上的说明进行操作:
$ sudo curl -L https://github.com/docker/compose/releases/download/1.17.1/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
$ sudo chmod +x /usr/local/bin/docker-compose
要检查 Docker Compose 的版本,请使用以下命令:
$ docker-compose --version
docker-compose version 1.17.1, build 6d101fb
本章使用的是 Docker Compose 1.17.1。
还需要检查是否已经安装 Git。如果安装了 Git,现在我们可以准备安装本地的 OpenWhisk 实例。
首先,从 GitHub 克隆 OpenWhisk Dev tools 仓库(github.com/apache/incubator-openwhisk-devtools),使用以下命令:
$ git clone --depth=1 https://github.com/apache/incubator-openwhisk-devtools
--depth=1 告诉 git 浅克隆仓库,这意味着只有 Git 历史的最新版本会被下载,以节省时间和空间:
接下来,进入 incubator-openwhisk-devtools/docker-compose 目录。该目录包含 docker-compose.yml 文件和启动单节点 OpenWhisk 实例所需的环境变量。在那里找到一个 Makefile,它包含 quick-start 目标,用于提供实例、设置初始数据并调用示例函数:
$ make quick-start
该命令将执行以下操作:
首先,它将从 GitHub 仓库的 master 分支下载 OpenWhisk 的最新源代码以及 wsk CLI 二进制文件。第二步,它将启动一个 OpenWhisk 本地集群,并使用 OpenWhisk 源代码树中附带的 Ansible playbook 初始化数据。然后,它将注册 hello-world 函数,最后调用它:
Response body size is 9 bytes
Response body received:
["guest"]
ok: whisk auth set. Run 'wsk property get --auth' to see the new value.
ok: whisk API host set to 192.168.1.40:443
ok: whisk namespace set to guest
waiting for the Whisk invoker to come up ...
creating the hello.js function ...
invoking the hello-world function ...
adding the function to whisk ...
ok: created action hello
invoking the function ...
invocation result: { "payload": "Hello, World!" }
{ "payload": "Hello, World!" }
deleting the function ...
ok: deleted action hello
To invoke the function again use: make hello-world
To stop openwhisk use: make destroy
有时,当进程启动并运行时,实例可能会变得不稳定。只需按 Ctrl + C,然后使用 make run 命令代替 make quick-start 再次尝试启动实例。如果您希望重新开始,只需运行 make destroy 命令销毁实例。销毁后,您可以使用 make quick-start 重新开始。
如果输出以此结束,OpenWhisk 现在已准备好在 localhost:443 提供服务:
Response body received:
["guest"]
ok: whisk auth set. Run 'wsk property get --auth' to see the new value.
ok: whisk API host set to localhost:443
ok: whisk namespace set to guest
然后,我们可以使用 docker ps 命令再次检查所有 OpenWhisk 容器是否在运行:
$ docker ps --format "table {{.ID}}\t{{.Image}}"
CONTAINER ID IMAGE
5e44dca4c542 openwhisk/nodejs6action:latest
d784018ef3de adobeapiplatform/apigateway:1.1.0
74b6b1d71510 openwhisk/controller
0c0cb4779412 openwhisk/invoker
b0111898e1a8 nginx:latest
874dac58a7c1 landoop/kafka-topics-ui:0.9.3
611e9b97ad74 confluentinc/cp-kafka-rest:3.3.1
4e1a82df737e wurstmeister/kafka:0.11.0.1
9c490336abff redis:2.8
abc4c0845fac couchdb:1.6
451ab4c7bf45 zookeeper:3.4
使用 wsk 客户端:
wsk 客户端已经通过 make quick-start 命令安装。wsk 二进制文件可以在 openmaster/bin/wsk 找到。我们通常将 wsk CLI 复制到 /usr/local/bin 并为其设置 Bash 自动补全:
$ sudo cp openwhisk-master/bin/wsk /usr/local/bin
$ wsk sdk install bashauto
The bash auto-completion script (wsk_cli_bash_completion.sh) is installed in the current directory.
To enable command line completion of wsk commands, source the auto completion script into your bash environment
$ source wsk_cli_bash_completion.sh
$ wsk
____ ___ _ _ _ _ _
/\ \ / _ \ _ __ ___ _ __ | | | | |__ (_)___| | __
/\ /__\ \ | | | | '_ \ / _ \ '_ \| | | | '_ \| / __| |/ /
/ \____ \ / | |_| | |_) | __/ | | | |/\| | | | | \__ \ <
\ \ / \/ \___/| .__/ \___|_| |_|__/\__|_| |_|_|___/_|\_\
\___\/ tm |_|
Usage:
wsk [command]
...
这里介绍的第一个子命令是wsk property get。它用于显示 OpenWhisk 的信息,包括当前的命名空间、认证密钥和构建号。例如,我们使用-i或--insecure选项不安全地连接到 OpenWhisk 实例,因为生成的证书是自签名的:
$ wsk -i property get
client cert
Client key
whisk auth 23bc46b1-71f6-4ed5-8c54-816aa4f8c502:123zO3xZCLrMN6v2BKK1dXYFpXlPkccOFqm12CdAsMgRU4VrNZ9lyGVCGuMDGIwP
whisk API host localhost:443
whisk API version v1
whisk namespace guest
whisk CLI version 2017-12-05T00:51:32+00:00
whisk API build "09/01/2016"
whisk API build number "latest"
这些信息告诉我们什么?我们当前位于访客 namespace,使用的是 API 版本 1,且以 23bc 开头的长字符串是我们的 API 密钥用于认证。任何 OpenWhisk 客户端,包括 wsk 本身,都需要这个密钥才能连接到 OpenWhisk 实例。我们当前的 API 网关位于 localhost:443,它将把所有请求转发给底层控制器。每个 OpenWhisk 组件的概述和架构将在下一节中讨论:
组件与架构:
在本节中,我们讨论 OpenWhisk 的架构和组件。OpenWhisk 被设计为一个坚如磐石的 FaaS 平台,它为 IBM Cloud Function 提供支持,IBM Cloud Function 是 IBM 已经推出的 FaaS 生产系统之一。这个坚如磐石的架构的关键是 Kafka。OpenWhisk 巧妙地使用 Kafka 作为其主干,保证 Kafka 接受的每一个函数请求都会传递到调用层。让我们从查看它的整体架构开始。
架构
下图 图 6.2 显示了 OpenWhisk 的整体架构:

图 6.2:OpenWhisk 的整体架构。
边缘组件是基于 NGINX 和 OpenResty 构建的 API 网关。API 网关可选择使用 Redis 进行缓存。API 网关位于一个或多个控制器之前。控制器将所有配置存储在 CouchDB 集群中。控制器背后是一个由 ZooKeeper 法定节点协调的 Kafka 集群。Kafka 集群非常重要;每一个调用都能得到保证执行。Kafka 作为控制器和调用者之间的一个弹性缓冲区。每个调用者负责调用函数的真实实现,在本例中是 Docker 容器。因此,调用者需要特殊权限才能连接到主机的 Docker 套接字。调用者可选择使用 docker-runc 来提升调用过程的性能。OpenWhisk 的每个组件都能够在容器内运行,比如我们通过 Docker Compose 部署时的情况。
组件
现在我们将进入每个组件的详细信息。
API 网关
OpenWhisk 的 API 网关组件建立在 NGINX 和 OpenResty 技术之上。选择 NGINX 的主要原因是它作为平台的边缘组件提供高性能。NGINX 位于系统中所有其他组件之前。API 网关能够通过 OpenResty 与 Redis 集群进行通信来缓存请求。然而,Redis 是一个可选组件,可以通过从 Docker Compose 配置中移除它来轻松禁用。API 网关还负责从用户处提供安全的 HTTPS 协议。
当前版本的 API 网关是 adobeapiplatform/apigateway:1.1.0。这是 Adobe 和 IBM 联合开发的 API 网关版本。
控制器
控制器是 OpenWhisk 中最重要的组件之一。顾名思义,它主要控制集群的调用过程。控制器可以在没有 API 网关的情况下工作。控制器直接提供 HTTP 协议,在不安全的形式下进行通信,因为 HTTPS 部分是 API 网关的功能。基本上,控制器源代码是 OpenWhisk 项目的一部分。本章使用的配置是 OpenWhisk 发布的最新 Docker 镜像。
数据库
OpenWhisk 的存储组件是 CouchDB。CouchDB 是一种高度可用的文档型数据存储。控制器与 CouchDB 通信,以存储与函数调用相关的所有实体。存储在 CouchDB 中的最重要实体是激活数据。激活数据包含每次调用过程的信息。操作的进展及其结果以 激活文档 形式存储。
当前配置中使用的是官方的 CouchDB 版本 1.6,couchdb:1.6。
Kafka
Kafka 在系统中扮演着非常重要的角色。从本质上讲,Kafka 是一个消息代理,它存储每个接收到的消息并可靠地重放它们。以 Kafka 为骨干,操作调用的请求将可靠地传递给调用者。
Kafka 通过使用 ZooKeeper 集群来组成。Kafka 在默认网络中的 9092 端口上运行。在本章的配置中,我们使用 wurstmeister/kafka:0.11.0.1 镜像。
调用者
调用者是负责接收来自 Kafka 主题的调用请求的组件,消息队列消费者可以订阅这些主题以接收消息。接收到消息后,调用者使用后台运行时执行函数。OpenWhisk 支持本地和 Docker 运行时。Docker 运行时在内部被称为 blackbox。
OpenWhisk 还可以选择直接使用 Docker 的 runc 来提高函数性能。在这种架构下,调用者需要访问本地 Docker 主机的 /var/run/docker.sock。这个限制阻止了 OpenWhisk 在 Swarm 模式下的高效扩展。我们将在后续章节讨论 OpenWhisk 在 Swarm 上的新架构,届时我们将讨论 OpenWhisk 在生产环境中的部署。
操作运行时
OpenWhisk 提供了几种运行时环境。例如,Java、Node.js 和 Python 是本地运行时。正如前面提到的,Docker 运行时被称为 blackbox。
运行时会使用在操作创建过程中注册的 Docker 镜像。然后它启动 Docker 容器以接受请求。运行时可以保持容器持续运行,从而使后续调用显著更快。
函数准备
本节我们讨论如何使用 OpenWhisk 提供的 Docker 模板 Docker skeleton 来准备一个函数。
OpenWhisk Docker SDK
要安装 Docker 模板,通常我们需要执行以下操作:
$ wsk -i sdk install docker
如果文件在本地 OpenWhisk 中不存在,你可以直接从 github.com/apache/incubator-openwhisk-runtime-docker/releases/download/sdk%400.1.0/blackbox-0.1.0.tar.gz 下载。
以下步骤是下载 SDK,解压 SDK,切换目录从 dockerSkeleton 到 docker_c,然后进入 docker_c 目录查看其内容:
$ curl -sSL -O https://github.com/apache/incubator-openwhisk-runtime-docker/releases/download/sdk%400.1.0/blackbox-0.1.0.tar.gz $ tar xf blackbox-0.1.0.tar.gz
$ mv dockerSkeleton docker_c
$ cd docker_c
$ ls
buildAndPush.sh Dockerfile example.c README.md
骨架包含一个 Dockerfile,一个简单的 C 程序,一个用于构建并将完成的功能推送到 Docker Hub 的 Bash 脚本,以及一个 README.md 文件。
我们从 C 程序的内容开始,看看它的作用。附带的 Docker 骨架 SDK 中的 C 程序只包含一个 main 函数,并有几个 printf 语句:
#include <stdio.h>
int main(int argc, char *argv[]) {
printf("This is a log message from an arbitrary C program!\n");
printf("{ \"msg\": \"Hello from C program!\", \"args\": %s }",
(argc == 1) ? "undefined" : argv[1]);
}
最后一行 printf 向我们讲述了 OpenWhisk action 的整个故事。这个 action 通过将 JSON 数据打印到 STDOUT 返回数据。该 action 通过 main 函数的 argv 接受参数,参数形式也是 JSON。action 需要负责解码这些参数并将输出编码回来。
接下来,我们来看看它的 Dockerfile。
该文件首先声明 openwhisk/dockerskeleton 作为基础镜像。在下一行,环境变量 FLASK_PROXY_PORT 被定义为 8080。你可能会猜到,这里作为每个 Docker 函数包装器的框架是 Flask,一个 Python Web 框架。
接下来的两行将 C 程序添加到构建容器中,安装 GCC 编译器,然后编译程序。输出的二进制文件名为 exec,必须放置在 /action/exec。这是 OpenWhisk 的 actionproxy 所需的执行文件的必需位置。
什么是 actionproxy?它是 OpenWhisk 版本的函数包装服务器。该服务器通过其暴露的端口 8080 接受 Web 请求。如前所述,它是用 Python 和 Flask 框架编写的,因此每个 OpenWhisk 函数都需要 Python 和 Flask 依赖项才能启动 actionproxy。这种设置已经通过继承基础镜像 openwhisk/dockerskeleton 完成:
# Dockerfile for example whisk docker action
FROM openwhisk/dockerskeleton
ENV FLASK_PROXY_PORT 8080
### Add source file(s)
ADD example.c /action/example.c
RUN apk add --no-cache --virtual .build-deps \
bzip2-dev \
gcc \
libc-dev \
### Compile source file(s)
&& cd /action; gcc -o exec example.c \
&& apk del .build-deps
CMD ["/bin/bash", "-c", "cd actionProxy && python -u actionproxy.py"]
我们将不使用提供的脚本,而是使用 docker build 命令自己构建。请记住,你需要使用自己的 <DOCKER ID> 作为仓库名称,以便将构建的镜像推送到 Docker Hub:
$ docker build -t chanwit/whisk_c .
Sending build context to Docker daemon 6.656kB
Step 1/5 : FROM openwhisk/dockerskeleton
latest: Pulling from openwhisk/dockerskeleton
...
---> 25d1878c2f31
Step 2/5 : ENV FLASK_PROXY_PORT 8080
---> Running in 932e3e3d6c0b
---> 647789067bf0
Removing intermediate container 932e3e3d6c0b
Step 3/5 : ADD example.c /action/example.c
---> 91eb99956da2
Step 4/5 : RUN apk add --no-cache --virtual .build-deps bzip2-dev gcc
libc-dev && cd /action; gcc -o exec example.c && apk del .build-deps
---> Running in 943930981ac6
fetch http://dl-cdn.alpinelinux.org/alpine/v3.4/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.4/community/x86_64/APKINDEX.tar.gz
(1/19) Upgrading musl (1.1.14-r15 -> 1.1.14-r16)
...
(17/17) Purging libgcc (5.3.0-r0)
Executing busybox-1.24.2-r13.trigger
OK: 32 MiB in 35 packages
---> d1cc0ed0f307
Removing intermediate container 943930981ac6
Step 5/5 : CMD /bin/bash -c cd actionProxy && python -u actionproxy.py
---> Running in fc68fc0ba06f
---> 924277b2a3a0
Removing intermediate container fc68fc0ba06f
Successfully built 924277b2a3a0
Successfully tagged chanwit/whisk_c:latest
如果一切正常,别忘了使用 docker push 命令将这个镜像存储到 Hub 上。
准备 Go 函数
接下来,我们将使用 Go 编程语言编写一个函数,向你展示如何使用 Go 内置库解码 JSON 参数。当然,我们将通过添加 Go 编译器来修改 OpenWhisk 的 Docker 骨架,并使用多阶段构建来优化构建过程。
让我们从头开始。
我们将再次解压 Docker 骨架,这次我们将 dockerSkeleton 目录重命名为 docker_go:
$ tar xf blackbox-0.1.0.tar.gz
$ mv dockerSkeleton docker_go
$ cd docker_go
在 docker_go 目录下,我们将编写一个 Go 程序,用于解码 action 的 JSON params,重新排列它们,重新编码为 JSON,然后写入调用者:
package main
import (
"encoding/json"
"fmt"
"os"
)
func main() {
rawParams := []byte(os.Args[1])
params := map[string]string{}
// decode JSON to a Go map
err := json.Unmarshal(rawParams, ¶ms)
if err != nil {
fmt.Printf(`{"error":%q}`, err.Error())
os.Exit(0)
}
// re-arrange
keys := []string{}
values := []string{}
for k, v := range params {
keys = append(keys, k)
values = append(values, v)
}
result := map[string]interface{}{
"message": "Hello from Go",
"keys": keys,
"values": values,
}
// encode
rawResult, err := json.Marshal(result)
if err != nil {
fmt.Printf(`{"error":%q}`, err.Error())
os.Exit(0)
}
// write JSON back to the caller
fmt.Print(string(rawResult))
}
在继续进行下一步之前,我们将此程序保存为main.go,然后编写用于多阶段构建的 Dockerfile 来编译 Go 程序,并将其打包为 OpenWhisk 动作。下面是新的Dockerfile版本。它的第一阶段是使用 Go 1.9.2 编译 Go 程序。请注意,我们将其编译为静态链接的二进制文件,以便它可以在 OpenWhisk 基础镜像内独立运行。在第二阶段构建中,我们将从第一阶段复制二进制文件/go/src/app/main到/action/exec,这是 OpenWhisk actionproxy 执行所需的二进制文件位置:
# Compile the Go program
FROM golang:1.9.2-alpine3.6
WORKDIR /go/src/app
COPY main.go .
RUN CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' main.go
# Build using the base image for whisk docker action
FROM openwhisk/dockerskeleton
ENV FLASK_PROXY_PORT 8080
COPY --from=0 /go/src/app/main /action/exec
CMD ["/bin/bash", "-c", "cd actionProxy && python -u actionproxy.py"]
现在Dockerfile已经准备好了。让我们使用docker build命令来构建它:
$ docker build -t chanwit/whisk_go .
Sending build context to Docker daemon 2.242MB
Step 1/8 : FROM golang:1.9.2-alpine3.6
---> bbab7aea1231
Step 2/8 : WORKDIR /go/src/app
---> a219190c401f
Removing intermediate container 2a665bded884
Step 3/8 : COPY main.go .
---> f0df3a87489d
Step 4/8 : RUN CGO_ENABLED=0 go build -a -ldflags '-extldflags "-static"' main.go
---> Running in ec72e6f59a57
---> e0f943bac9a5
Removing intermediate container ec72e6f59a57
Step 5/8 : FROM openwhisk/dockerskeleton
---> 25d1878c2f31
Step 6/8 : ENV FLASK_PROXY_PORT 8080
---> Running in 846db07a0f5b
---> 543e673a9c79
Removing intermediate container 846db07a0f5b
Step 7/8 : COPY --from=0 /go/src/app/main /action/exec
---> 8ec5987098d8
Step 8/8 : CMD /bin/bash -c cd actionProxy && python -u actionproxy.py
---> Running in ea25c9a65bcc
---> a4193ccd5f48
Removing intermediate container ea25c9a65bcc
Successfully built a4193ccd5f48
Successfully tagged chanwit/whisk_go:latest
动作镜像现在已经准备好,名称为chanwit/whisk_go。请再次使用你自己的 Docker Hub ID,而不是我的,作为镜像仓库,并且不要忘记将其推送到 Hub。
调用函数
本节描述了 OpenWhisk 如何调用其动作的内部流程。我们将学习如何创建(或注册)一个 Docker 容器作为 OpenWhisk 动作,并如何调用它。
调用流程
由于 OpenWhisk 是一个事件驱动的平台,任何传递到它的事件都可以被拦截和解释。然而,在本示例中,我们只展示通过直接请求网关触发的事件。
调用流程以基于 HTTP 的请求开始,并发送到 API 网关。例如,我们可以使用 wsk CLI 发起这种请求。在 API 网关收到请求后,它会将该请求转发给背后的控制器。
OpenWhisk 最重要的组件之一是控制器。控制器是用 Scala 编写的,并使用臭名昭著的框架 Akka 和 Spray 来实现一组 REST API。控制器接受各种请求;如果它接受一个 POST 请求,它会将其解释为一个 OpenWhisk 动作的调用。
然后控制器开始对请求的动作进行身份验证和授权。
控制器将查找凭据信息并根据存储在 CouchDB 实例中的数据进行验证。
如果未找到动作,控制器会直接返回 404 给调用者,例如。如果在验证凭据后拒绝访问,控制器将返回一段 JSON 给调用者,表示他们没有权限访问该动作。
如果一切都被授权,控制器将进入下一步。
然后控制器再次查找有关动作的信息:它是什么,属于什么类型,如何调用它。
在我们的例子中,我们使用 Docker 作为动作原语。所以,控制器会发现我们的动作是一个黑盒子。现在它准备好调用该动作了。
控制器不会直接向调用者发起请求;相反,它会向 Kafka 集群发起请求,Kafka 是消息系统的骨干。如前所述,使用 Kafka 可以防止调用丢失,并通过在系统负载过重时排队调用,使系统更加健壮。
所以控制器向 Kafka 发布一条消息。请求消息包含调用操作所需的所有信息。这条消息也会被 Kafka 持久化,以便在系统崩溃时能够重放。
一旦 Kafka 收到消息,控制器将返回一个激活 ID,供稍后获取调用结果使用。
在 Kafka 的另一端,一组调用者订阅请求消息。一旦消息在队列中可用,调用者会收到通知。然后调用者会执行实际的工作,调用真正的 Docker 容器。在获得结果后,调用者会将它们存储在同一激活 ID 下的 CouchDB 实例中。
操作调用
好的,现在我们准备好尝试之前部分章节中新创建的 C 和 Go 函数了。首先,我们将使用 wsk action create 命令创建一个操作,开始使用 C 程序:
$ wsk -i action create --docker chanwit/whisk_c whisk_c
ok: created action whisk_c
如果一切顺利,wsk 会告诉我们 ok: created action。接下来,我们将使用 wsk action invoke 命令来调用该操作。invoke 命令接受一个或多个 --param 参数来传递给操作。我们还可以使用 --result 来同步获取结果。结果当然是以 JSON 格式返回的:
$ wsk -i action invoke --param key value --result whisk_c
{
"args": {
"key": "value"
},
"msg": "Hello from C program!"
}
我们再试一次,这次使用 Go 程序。首先,创建操作:
$ wsk -i action create --docker chanwit/whisk_go whisk_go
ok: created action whisk_go
然后,使用 wsk action invoke 调用该操作:
$ wsk -i action invoke --param hello world --result whisk_go
{
"keys": [
"hello"
],
"message": "Hello from Go",
"values": [
"world"
]
}
正如我们所见,我们将操作打包到 Docker 中,这基本上简化了整个过程,从操作准备、创建到调用。
获取激活结果
每次调用操作时,OpenWhisk 都会为其创建一个激活记录。要查看激活记录,我们可以在没有 --result 参数的情况下调用一个操作,例如:
$ wsk -i action invoke --param hello world whisk_go
ok: invoked /guest/whisk_go with id 6ba2c0fd6f4348b8a2c0fd6f4388b864
6ba2c0fd6f4348b8a2c0fd6f4388b864 这个 ID 被称为激活 ID。我们现在可以使用 wsk activation get 命令获取激活记录。在激活 ID 后加上字段名称将对结果进行过滤,仅显示该字段。以下示例仅显示激活记录 6ba2c0 的 response 字段:
$ wsk -i activation get 6ba2c0fd6f4348b8a2c0fd6f4388b864 response
ok: got activation 6ba2c0fd6f4348b8a2c0fd6f4388b864, displaying field response
{
"status": "success",
"statusCode": 0,
"success": true,
"result": {
"keys": [
"hello"
],
"message": "Hello from Go",
"values": [
"world"
]
}
}
在激活记录中,JSON 结果被放置在 result 键下。你可以观察到所有数据都正确地序列化为 JSON 并记录在其中。
用户界面
写作时,OpenWhisk 没有公开的开源门户。为了让开发者更方便使用 OpenWhisk,我正在开发一个 UI 门户。SuraWhisk 是一个开源项目,托管在 GitHub 上。其源代码可以在 github.com/surawhisk/ui 找到。如果你不想查看源代码,可以直接从现成的 Docker 镜像启动 UI。
首先,创建一个卷来存储设置数据。端点及其 API 密钥将存储在该卷中,用于身份验证:
$ docker volume create surawhisk_vol
然后可以使用以下命令运行 UI:
$ docker run -d -p 8080:8080 -v surawhisk_vol:/root/data surawhisk/ui
在启动 SuraWhisk UI 后,将浏览器指向http://localhost:8080。UI 的左侧导航栏当前包含三个基本项目:设置、操作和命名空间。
设置页面,如图 6.3所示,用于设置 OpenWhisk 端点及其 API 密钥。SuraWhisk 容器在桥接网络上运行;因此,它可以通过 Docker 的网关桥接 IP 172.17.0.1 访问 OpenWhisk 的 API 网关。也就是说,我们本地 OpenWhisk 实例的端点将是https://172.17.0.1/api/v1。可以通过运行以下命令的 wsk CLI 获取当前访客命名空间的 API 密钥。如果桥接 IP 无法使用,可以尝试本地机器的 IP,因为 OpenWhisk 的 API 网关也通过机器的 IP 公开:
$ wsk property get --auth
whisk auth 23bc46b1-71f6-4ed5-8c54-816aa4f8c502:123zO3xZCLrMN6v2BKK1dXYFpXlPkccOFqm12CdAsMgRU4VrNZ9lyGVCGuMDGIwP
命令的结果提供了一个长字符串,表示它是一个whisk auth。将整个字符串23b...IwP复制并粘贴到设置页面的 API 密钥文本框中,然后点击保存按钮:

图 6.3:SuraWhisk 的设置屏幕,用于指定端点和 API 密钥
现在,SuraWhisk 门户将能够与 OpenWhisk 实例通信。我们将进入定义新函数的步骤。
在操作/创建页面(如图 6.4所示),可以定义一个函数,作为 OpenWhisk 中的操作。这里将使用本章前面构建的 Docker 镜像。在以下示例中,我们创建一个名为 hello 的函数,作为一个 Docker 容器,其镜像为 chanwit/whisk_c:

图 6.4:操作创建屏幕,允许我们在 OpenWhisk 中定义一个新操作
当一切准备就绪时,点击创建按钮。门户将连接到 OpenWhisk 实例并请求创建一个新操作。在这个阶段不会拉取 Docker 镜像,因此此步骤会迅速完成。如果 hello 操作创建成功,将会弹出对话框,如图 6.5 所示。

图 6.5:对话框显示操作已成功创建
要调用操作,请转到左侧导航栏的菜单 Actions/Invoke,如图 6.6所示。当前命名空间中的所有操作将列出在操作下拉框中。每个调用都接受作为操作参数的键/值对。可以通过点击添加按钮添加它们。在以下示例中,book 参数设置为包含值 serverless。可以通过点击每个键值对的删除按钮随时删除一个参数。这些参数将在传递给操作之前被编码为 JSON:

图 6.6:SuraWhisk 中的调用操作屏幕,显示结果
在选择要调用的动作后,点击调用按钮将启动调用过程。在前面的示例中,hello动作是以 Docker 容器的形式存在的。
练习
下面是一些帮助你复习本章内容的问题:
-
OpenWhisk 有哪些优势?
-
请描述 OpenWhisk 的架构。
-
OpenWhisk 控制器的作用是什么?
-
Kafka 的作用是什么?它为什么对 OpenWhisk 很重要?
-
什么是调用器?
-
为什么控制器和调用器没有直接连接?
-
我们如何在 OpenWhisk 平台上定义并调用一个动作?
-
我们如何提高调用器的性能?
总结
本章介绍了 OpenWhisk,特别是我们如何使用 Docker 作为其生态系统的一部分。OpenWhisk 是一个功能完备、容错性强、支持多种语言的无服务器平台,允许你以任何语言编写的函数在虚拟环境中运行。
本章介绍了 OpenWhisk 的组件和架构,并讨论了如何使用wsk命令行工具准备、创建和调用 OpenWhisk 函数。本章还介绍了 SuraWhisk,一个 OpenWhisk 的 Web 界面,帮助我们更轻松地管理和调用 OpenWhisk 的动作。
我们已经了解了三大 FaaS 平台的相关内容。在下一章,我们将讨论如何准备和操作 Docker 集群,以便在其上配置和管理 FaaS 平台。
第七章:操作 FaaS 集群
让系统运行并稳定起来最困难的事情之一就是管理和维护我们自己的集群。尽管无服务器计算是一种旨在完全解决这个问题的范式,但实际上,在某些情况下,我们仍然需要自行配置和管理服务器。
无服务器计算和 Docker 背后的理念是,在减少集群维护和管理的同时,能够完全控制集群。使用 Docker 是一种帮助实现这一平衡的好方法。
除了这种平衡之外,最具吸引力的无服务器计算驱动力是价格模型。然而,我们发现,在 EC2 Spot 实例上使用 Docker,由于其具有竞争力的价格,有时甚至比 AWS Lambda 或其他云函数还便宜。因此,使用 Spot 实例时,我们可以获得更便宜的价格,同时我们的功能不会受到 AWS Lambda 或其他云平台所遇到的任何限制。
操作基于 Docker 的 FaaS 集群使用的技术与操作 Docker 集群相同。我们需要将独立运行 Docker 的技术与利用 Docker Swarm 模式的技术结合起来。本章重点介绍配置稳定性、如何准备新的入口层、如何使用网络插件、如何设置日志系统,以及如何使用 Golang 脚本操作集群。
稳定配置
让我们首先仔细稳定集群配置。在撰写本文时,Docker 集群在以下配置下表现最佳。本节中图Figure: 7.1展示了这一点:
-
Ubuntu Server 16.04.3 LTS:尽管 Red Hat Linux 或 CentOS 可能更适合你,但 Ubuntu Server 更容易处理。我们不断得知,Docker 在 Ubuntu Server 上的测试表现非常好。如果你选择使用 Red Hat 或 CentOS,请选择 7.4 版本。
-
Linux Kernel 4.4 LTS:4.4 内核是 LTS 版本,非常适合 Docker 使用。你也可以使用 4.9 内核,但像 4.13 这样的内核对于 Docker 来说仍然太新。
-
Overlay2 作为 Docker 存储驱动程序:尽管高级多层统一文件系统(AUFS)已在 Docker 中使用了很长时间并且表现良好,但对于运行 4.4+内核的 Docker 来说,overlay2 应该是新的默认存储驱动程序。如果你有机会在 CentOS 或 RHEL 7.4 上运行生产集群,overlay2 也是这些发行版上的一个不错选择。
-
Docker CE 17.06.2 或 17.09.1:如果你能负担得起企业版,Docker EE 17.06 也是一个不错的选择:

图 7.1:一个稳定的 Docker Swarm 堆栈,配有 Træfik 和 WeaveWorks 网络插件
选择合适的网络插件
长期以来,人们一直说默认的 Docker 覆盖网络不适合生产环境。尽管覆盖网络驱动程序的质量越来越好,但我们可能会考虑一些其他的网络插件,以获得最佳效果。我们可以将默认的覆盖驱动程序替换为其他插件,例如 WeaveWorks 或 Contiv。本章中我们使用的是 WeaveWorks 网络插件版本 2。
为什么选择 WeaveWorks?
WeaveWorks 网络插件用于 Docker,采用与 Kubernetes CNI 相同的底层网络实现。它还经过了开发团队 WeaveWorks Inc. 的严格测试。此外,它在我的生产集群中表现非常出色。
WeaveWorks 网络插件版本 2.1.3,为了避免当前版本的覆盖网络驱动程序中发现的断开连接 bug,建议在生产环境中完全移除默认的入口网络,该网络基于默认的覆盖网络驱动程序。这里可能会有人提出疑问。如果移除入口网络,我们将失去整个路由网格,那么我们该如何将流量路由到集群呢?答案在下一节中。
新的入口和路由
如前所述,我们将不使用默认的 Docker 入口网络来进行 请求路由到正在运行的容器:

图 7.2:构建在 Træfik 上的新入口层,连接到底层的 Swarm 任务,形成路由网格
是的,我们将失去路由网格,但我们将建立我们自己的路由网格。如前图所示,我们将用构建在 L7 负载均衡器 Træfik 上的新入口层替代默认的路由网格。你可以从以下稳定版本列表中选择一个:
-
Træfik v1.4.5 (
traefik@sha256:9c299d9613) -
Træfik v1.4.6 (
traefik@sha256:89cb51b507)
使用 Træfik 的优势在于,新的入口层得到了更好的稳定性。每个服务都会由 Træfik 自动解析为一组 IP 地址。因此,你可以选择使用 Docker Swarm 提供的基于 IPVS 的负载均衡器,或者使用 Træfik 本身提供的内置机制。
由于 Træfik 与 L7 层配合使用,我们还可以根据主机名来匹配服务,并将请求转发到匹配服务的某个任务。此外,通过这种新实现,我们可以灵活地重新启动或重新配置入口层,而无需触及正在运行的服务。这一直是 Docker 入口层的一个弱点。
跟踪组件
在本书提出的架构中,我们为每个部署的函数使用 Envoy 作为 sidecar 代理。有了 Envoy,即使这些函数是由不同的 FaaS 平台准备或部署的,它也能实现函数之间的分布式追踪调用,如下图所示。这实际上是避免厂商锁定的重要一步。Envoy 被编译并逐步推送到 Docker hub。我们为本书选择了某个特定版本的 Envoy:EnvoyProxy,envoyproxy/envoy:29989a38c017d3be5aa3c735a797fcf58b754fe5:

图 7.3:展示带有 Envoy 的分布式追踪机制的框图
下图展示了 sidecar 代理模式的两种实现级别。首先,我们直接通过将EnvoyProxy二进制文件嵌入到 Docker 镜像中来调整函数或服务的Dockerfile。这种技术提供了最佳的性能,因为EnvoyProxy通过容器内的loopback接口与函数程序进行通信。但当我们需要更改 Envoy 的配置,如重试或断路器时,我们需要重新启动EnvoyProxy和函数实例,如下图所示的第一种(1)配置:

图 7.4:两种配置方式将 Envoy 实现为(1)sidecar 代理和(2)边缘代理
所以在灵活性和管理性方面,最佳的配置是第二种(2)配置,在这种配置中,我们将EnvoyProxy作为边缘代理从函数容器中分离出来。这里的权衡是它们之间的网络开销。
重试和断路器
在本节中,我们讨论一个迄今为止最有趣的话题:重试和断路器模式。在继续实现生产集群之前,熟悉这一概念会非常有帮助。
重试
重试和断路器解决的问题源于服务或链中某个函数变得不可用所引发的级联故障。在下图中,我们假设五个不同的函数或服务的可用性为 99%,因此它们每 100 次调用会失败一次。观察这个服务链的客户端将体验到的可用性为A,仅为95.09%:

图 7.5:一系列函数或微服务将使它们的整体可用性降低
这意味着什么?这意味着当这个链条变成八个函数时,系统的可用性将降至 92.27%;如果链条长达 20 个函数,这个数字将降到 81.79%。为了降低故障率,当出现错误(如 HTTP 500)时,我们应该重试调用另一个实例的函数或服务。
但是,简单的或常规速率的重试是不够的。如果我们使用一个简单的策略,我们的重试调用会给已经故障的服务带来不必要的负载,这会引发比解决更多的问题。
电路断路器
为了解决这个问题,许多重试模式实现通常会使用指数回退重试。通过指数回退策略,我们逐步增加每次重试之间的延迟。例如,发生故障后,我们可能会在 3 秒钟后重试第二次调用。如果服务仍然返回错误,我们将延迟增加到 9 秒和 27 秒,分别对应第三次和第四次调用。这种策略给服务留出一定的时间来恢复临时故障。两种重试策略的区别如下图所示:

图 7.6:恒定速率重试与指数回退重试策略的区别
准备生产集群
在本节中,我们将讨论如何准备生产级 Docker Swarm 集群,以便以最低的成本在 AWS Spot 实例上运行 FaaS 平台。部署 Docker 集群的成本将与在 AWS Lambda 上运行代码的成本相当,但它让我们几乎可以控制集群中的所有内容。如果部署策略以成本为驱动,那么这是最佳的选择。
使用 Spot 实例节省成本
当我们谈论云时,它的按需实例实际上已经很便宜了。然而,从长远来看,使用云实例的价格将与购买实物机器相似。为了解决这个定价问题,主要的云服务提供商,如 Amazon EC2 和 Google Cloud Platform,提供了一种新的实例类型,在本书中统称为Spot 实例:

图 7.7:AWS 与 Google Cloud 上 Spot 实例的关机信号对比
Spot 实例比按需实例便宜得多。然而,它们的弱点是生命周期短且会意外终止。也就是说,Spot 实例可能随时被终止。当它终止时,你可以选择保留或完全丢弃卷。在 AWS 上,实例将在终止前约 120 秒通过远程元数据发出通知,而在 Google Cloud 上,则会在机器停止前 30 秒通过 ACPI 信号发送通知。粗略的对比如前图所示。
我们可以将无状态计算部署在这些实例上。微服务和函数本身都是无状态的,因此 Spot 实例非常适合微服务和函数的部署。
使用这种廉价实例的基础设施,其成本将与 AWS Lambda 或 Google Cloud Functions 相当,但我们对整个系统有更多的控制,这意味着在这种基础设施上运行的函数不会有调用超时问题。
使用 EC2 Spot 实例
在 Amazon EC2 上,访问 aws.amazon.com/ec2/spot/ 你将看到如下截图页面。登录 AWS 控制台,设置一些 Spot 实例:

图 7.8:AWS Spot 实例的首页
在导航栏上,我们看到了 Spot Requests。点击它进入 Spot Requests 屏幕,如下截图所示。在此屏幕上,点击请求 Spot 实例 开始请求流程:

图 7.9:AWS 上显示请求及其相关实例的 Spot Requests 屏幕
请求 Spot 实例有三种模式:
-
一次性请求。这是一次性的请求,所以当实例消失时,我们需要再次请求。
-
请求一组实例,并让 AWS 保持目标实例的数量。当一些实例被终止时,AWS 将尝试根据我们的最高竞价价格分配实例,以满足每个舰队的目标数量。在本章中,我们选择了此请求模型。
-
请求一段固定时间内的实例。固定时间被称为Spot block,介于 1 到 6 小时之间。如果设置较长的时间段,我们将支付更多费用。
下图显示了正在准备中的集群的结构:

图 7.10:使用自动操作员在 Spot 实例上形成的 Docker 集群
假设我们已经准备好三个箱子作为管理者。为了获得最便宜的费率,建议使用三个按需 EC2 节点作为 Docker 管理者,以及 N-3 个 Spot 实例作为 Docker 工作节点。我们从三个 Spot 工作节点开始。
如果可能的话,请选择允许您创建私有网络和浮动 IP 的云服务提供商。我们将在私有网络上形成一个 Docker 集群。大多数云服务提供商都允许这样做,所以请不要担心。
让我们开始
首先,SSH 进入我们希望成为第一个管理节点的节点,安装 Docker,并在其上运行docker swarm init命令。eth0是云服务提供商提供的私有网络接口。在继续之前,请使用ip addr命令检查您的接口。如果您知道哪个接口是私有的,请使用以下命令初始化集群:
$ docker swarm init --advertise-addr=eth0
接下来,SSH 进入其他两个节点。安装 Docker,并使用docker swarm join命令加入集群。不要忘记我们需要使用管理者的加入令牌,而不是工作节点的。请注意,在此设置过程中,我的第一个管理者的 IP 是172.31.4.52。请将其替换为您的 IP 地址:
$ docker swarm join --token SWMTKN-1-5rvucdwofoam27qownciovd0sngpm31825r2wbdz1jdneiyfyt-b5bdh4i2jzev4aq4oid1pubi6 172.31.4.52:2377
对于这三个第一节点,不要忘记将它们标记为管理者,以帮助您记住。
在这里,请确保docker info显示包含所有管理器的私有 IP 地址列表。我们使用grep -A3来查看目标后的三行:
$ docker info | grep -A3 "Manager Addresses:"
Manager Addresses:
172.31.0.153:2377
172.31.1.223:2377
172.31.4.52:2377
或者,如果您熟悉jq命令,可以尝试以下操作:
$ docker info --format="{{json .Swarm.RemoteManagers}}" | jq -r .[].Addr
172.31.4.52:2377
172.31.1.223:2377
172.31.0.153:2377
docker info 命令还支持 --format 选项,允许我们自定义输出。在前面的示例中,我们使用模板提供的 JSON 方法来生成 JSON 输出。然后,我们使用 jq 查询所有 Swarm 管理节点的 IP 地址。JSON 模板和 jq 的结合将是构建我们自己的基于 Docker 脚本来长期操作集群的一个好工具。
Spot 实例上的工作节点
然后,我们将再配置三个节点作为 Spot 实例的舰队。在下图中,展示了请求一个包含三个 Spot 实例的舰队的设置。选择 "Request and Maintain" 选项,然后将目标容量设置为 3 个实例:

图 7.11:请求并维护一个包含 3 个实例的舰队
我们配置了设置脚本,以在实例创建时安装 Docker,加入节点到集群,并设置网络驱动程序。此设置必须放入舰队设置的用户数据部分,如下图所示:

图 7.12:将连接指令放入请求的用户数据中
这是在用户数据部分使用的脚本。请将 $TOKEN 替换为您的工作节点令牌,将 $MANAGER_IP 替换为您的一个管理节点的私有 IP 地址:
#!/bin/bash
curl -sSL https://get.docker.com | sh
service docker start
usermod -aG docker ubuntu
docker swarm join --token $TOKEN $MANAGER_IP:2377
docker plugin install --grant-all-permissions weaveworks/net-plugin:2.1.3
现在,我们等待舰队请求完成。
如果我们进入第一个管理节点,可以使用 docker node ls 命令查看集群中的当前节点。如果一切正常,集群中应该有六个节点:
$ docker node ls
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS
btul0hbd ip-172-31-11-209 Ready Active
etm8veip ip-172-31-8-157 Ready Active
iwl4pxnf * ip-172-31-4-52 Ready Active Leader
rsqsflmv ip-172-31-1-223 Ready Active Reachable
uxd36bok ip-172-31-15-229 Ready Active
xn7fz2q1 ip-172-31-0-153 Ready Active Reachable
使用此技术,我们可以通过简单地调整 Spot 实例的数量来轻松扩展集群。
使用网络插件
如我们在舰队设置中的用户数据部分所见,脚本中会有一行指令来为我们安装网络插件。它是 WeaveWorks 网络插件。WeaveWorks 网络插件使用来自 docker info 命令的信息来列出所有 Swarm 管理节点的 IP 地址。然后,插件利用这些 IP 地址来启动网络网格。
只有在成功形成集群中的管理节点集合后,才能安装 WeaveWorks 网络插件。
我们使用 WeaveWorks 网络插件 2.1.3。这是截至写作时最稳定的版本。如果有可用的版本,建议升级到此插件的下一个小版本。
要安装网络插件,我们使用 docker plugin install 命令:
$ docker plugin install --grant-all-permissions weaveworks/net-plugin:2.1.3
2.1.3: Pulling from weaveworks/net-plugin
82e7025f1f50: Download complete
Digest: sha256:84e5ff14b54bfb9798a995ddd38956d5c34ddaa4e48f6c0089f6c0e86f1ecfea
Status: Downloaded newer image for weaveworks/net-plugin:2.1.3
Installed plugin weaveworks/net-plugin:2.1.3
我们使用 --grant-all-permissions 只是为了自动化安装步骤。如果没有此参数,我们必须手动授予每个插件所需的权限。
我们需要为集群中的每个节点安装插件,这意味着我们需要为我们的六个节点执行此操作六次。
我们可以使用以下命令检查网络插件是否正确安装:
$ docker plugin ls
ID NAME DESCRIPTION ENABLED
f85f0fca2af9 weaveworks/net-plugin:2.1.3 Weave Net plugin for Docker true
插件的ENABLED状态为true,表示它当前处于活动状态。要检查 WeaveWorks 插件及其网络网格的状态,可以通过 CURL 从localhost:6782/status获取纯文本状态信息。以下状态信息来自一个工作节点。我们可以从该 URL 检查对等节点之间的连接数或对等节点的数量,例如:
$ curl localhost:6782/status
Version: 2.1.3
Service: router
Protocol: weave 1..2
Name: e6:cc:59:df:57:72(ip-172-31-11-209)
Encryption: disabled
PeerDiscovery: enabled
Targets: 3
Connections: 5 (5 established)
Peers: 6 (with 30 established connections)
TrustedSubnets: none
Service: ipam
Status: idle
Range: 10.32.0.0/12
DefaultSubnet: 10.32.0.0/12
Service: plugin (v2)
上述示例显示我们有六个对等节点,每个节点有五个连接。IP 范围和默认子网是我们在创建 Docker 网络时需要使用的重要信息。IP 范围是10.32.0.0/12,因此如果我们创建一个子网为10.32.0.0/24的网络,它将是有效的,而10.0.0.0/24则是无效的,例如。
下图展示了我们的 WeaveWorks 网络拓扑。每个节点与其他五个节点有五个连接,图中的实线从mg节点指向其他节点。为了使图示易于理解,图中仅展示一个mg节点和另一个wk节点,它们将五条连接线连接到集群中其他的对等节点:

图 7.13:Swarm 节点通过 WeaveWorks 全网格网络相互连接
对于高级故障排除,我们可以检查插件的运行进程weaver:
$ ps aux | grep weaver
root 4097 0.0 3.4 418660 34968 ? Ssl 06:15 0:06 /home/weave/weaver --port=6783 --datapath=datapath --host-root=/host --proc-path=/host/proc --http-addr=127.0.0.1:6784 --status-addr=0.0.0.0:6782 --no-dns --ipalloc-range=10.32.0.0/12 --nickname ip-172-31-11-209 --log-level=debug --db-prefix=/host/var/lib/weave/weave --plugin-v2 --plugin-mesh-socket= --docker-api= 172.31.4.52 172.31.1.223 172.31.0.153
正如从ps的输出中 grep 到的内容所示,命令的最后部分是 Swarm 管理器 IP 地址的列表。如果它看起来像这样,我们的网络层已经准备就绪。但如果你在这里没有看到管理器 IP 地址的列表,请删除插件并重新开始。
创建网络
当我们使用 WeaveWorks 驱动程序准备网络时,请记住,我们始终需要指定--subnet和--gateway参数,因为我们不使用 Docker 的 libnetwork 提供的默认子网值。我们需要使网络可附加,使用--attachable,以便使用docker run命令启动的容器能够附加到该网络。如果没有这个选项,只有通过docker service create启动的 Swarm 服务才能加入该网络。
例如,我们可以使用以下命令创建一个class C网络:
$ docker network create -d weaveworks/net-plugin:2.1.3 \
--subnet=10.32.0.0/24 \
--gateway=10.32.0.1 \
--attachable my_net
创建一个操作控制平面
操作控制平面是我们部署操作员容器以帮助操作集群的地方。它是源自 CoreOS 操作员模式的一个概念,coreos.com/blog/operators。
首先,我们创建控制网络,以便操作员代理能够连接到管理节点。将其命名为control。我们创建这个网络的大小为class C。因此,请注意操作员容器的数量不得超过255:
$ docker network create \
--driver weaveworks/net-plugin:2.1.3 \
--subnet 10.32.100.0/24 \
--attachable \
control
control平面中的操作员通常需要访问 Docker API,以观察集群的状态,决定采取什么措施,并将更改反馈到集群中。
为了使 Docker API 能够通过同一控制网络中的每个操作员访问,我们在控制平面中部署docker-api服务。
我们使用rancher/socat-docker作为docker-api服务的镜像,因为它被广泛使用,并且已证明在生产环境中稳定。docker-api将部署在每个管理节点上,使用node.role==manager。端点模式将设置为dnsrr,因为每个docker-api实例都是无状态的,Docker 管理器已经负责整个集群的状态。所以这里不需要vip端点模式。
每个docker-api实例绑定到其 Docker 主机上的/var/run/docker.sock,以连接到其本地管理器:
$ docker service create \
--name=docker-api \
--mode=global \
--endpoint-mode=dnsrr \
--network control \
--constraint "node.role==manager" \
--mount "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" \
rancher/socat-docker
我们将运行一个名为服务平衡器的操作容器,作为在生产环境中使用操作容器模式的示例。
服务平衡操作器
服务重平衡一直是 Docker 用户请求的功能之一。然而,最好将此功能运行在协调器之外,并作为操作容器运行。
问题在于,在新节点加入集群后,我们通常会重平衡正在运行的服务,以便将负载均匀分布到整个集群中。这个功能之所以没有内置到协调器中,是因为它是特定于应用的。而且,如果集群在节点动态加入和退出时不断进行重平衡,正在运行的服务可能会频繁中断,无法保持足够稳定的状态来处理请求。
然而,如果我们将这种功能实现为操作容器,当它在协调器之外运行时,我们可以在必要时选择禁用它。此外,我们可以选择仅对特定服务进行重平衡。
当前,服务平衡器可作为chanwit/service-balancer在 Docker Hub 上使用。我们将在任何管理节点上只运行一个服务平衡器实例:
$ docker service create \
--name service-balancer \
--network control \
--constraint node.role==manager \
--replicas 1 \
chanwit/service-balancer
使用自动重平衡器时需要考虑的一点是,--update-delay必须设置为大于每个任务的启动时间。这一点非常重要,特别是对于基于 Java 的服务。这个延迟应该足够大,至少要大于健康检查机制使用的间隔时间。
此外,为了获得最安全的结果,--update-parallelism的值应该从1开始,并在系统能够稳定提供请求时逐步增加。
为了允许服务自动重平衡,服务平衡操作器会检查服务的标签rebalance.on.node.create=true。如果该标签存在于服务中,则每次新节点加入集群时,服务都会被重平衡。
日志记录
在日志记录方面,一种流行的解决方案是建立一个 Elasticsearch 堆栈。自然的组合是Elasticsearch-Logstash-Kibana(ELK)。
我们使用来自 github.com/deviantony/docker-elk 的 ELK 堆栈,并对其进行修改,通过添加 Docker Swarm 配置来改进它,并使每个组件可以独立部署。原始的 Docker Compose 文件 docker-compose.yml 被拆分成三个 YML 文件,分别用于 Elasticsearch、Kibana 和 Logstash。服务必须以这种方式部署,因为我们不希望在更改每个服务的配置时导致整个日志系统宕机。本章使用的分支可以在 github.com/chanwit/docker-elk 获取。
以下图示展示了堆栈的结构。所有 ELK 组件将位于 elk_net 中。Logstash 实例将在端口 5000 上暴露。在每个 Docker 主机上,它的本地 Logspout 代理将把 Docker 主机的日志消息转发到 Logstash 实例。然后,Logstash 将转换每条消息并将其存储到 ElasticSearch 中。最后,用户可以通过端口 5601 访问 Kibana 以可视化所有日志:

图 7.14:用于集群日志记录的 ELK 堆栈框图
我们首先准备一个专用的 ELK 堆栈网络。我们将此网络命名为 elk_net 并将其用于所有 ELK 组件:
docker network create \
--driver weaveworks/net-plugin:2.1.3 \
--subnet 10.32.200.0/24 \
--attachable \
elk_net
以下是 elasticsearch.yml 的源文件。我们在本章中使用 Docker Compose YML 规范版本 3.3。这是最低要求,因为我们将使用 Docker Swarm 配置来管理所有配置文件。
version: '3.3'
configs:
elasticsearch_config:
file: ./elasticsearch/config/elasticsearch.yml
services:
elasticsearch:
build:
context: elasticsearch/
image: chanwit/elasticsearch:6.1
configs:
- source: elasticsearch_config
target: /usr/share/elasticsearch/config/elasticsearch.yml
environment:
ES_JAVA_OPTS: "-Xmx512m -Xms512m"
networks:
default:
external:
name: elk_net
docker stack 需要在部署之前指定镜像名称,这是一个要求。因此,我们首先需要使用 docker-compose 构建容器镜像。
我们仅使用 docker-compose 来构建镜像。
让我们开始吧!我们使用 docker-compose build 准备 YML 文件中定义的镜像。docker-compose 命令还会为我们标记镜像。由于我们为每个服务都有一个单独的 YML 文件,因此我们使用 -f 来告诉 docker-compose 构建正确的文件:
$ docker-compose -f elasticsearch.yml build
当镜像准备好后,我们可以通过以下命令简单地部署堆栈 es:
$ docker stack deploy -c elasticsearch.yml es
接下来,我们开始准备和部署 Kibana。
这是 Kibana 的堆栈 YML 文件。我们有 kibana_config 指向我们的 Kibana 配置。Kibana 端口 5601 使用 Swarm 的主机模式发布,以绕过 ingress 层。请记住,我们的集群中实际上没有默认的 ingress 层。如前所述,我们使用 Træfik 作为新的 ingress:
version: '3.3'
configs:
kibana_config:
file: ./kibana/config/kibana.yml
services:
kibana:
build:
context: kibana/
image: chanwit/kibana:6.1
configs:
- source: kibana_config
target: /usr/share/kibana/config/kibana.yml
ports:
- published: 5601
target: 5601
mode: host
networks:
default:
external:
name: elk_net
与 Elasticsearch 类似,现在可以使用 docker-compose build 命令准备 Kibana 镜像:
$ docker-compose -f kibana.yml build
之后,我们使用堆栈名称 kb 部署 Kibana:
$ docker stack deploy -c kibana.yml kb
使用 Logstash 时,有两个配置文件需要考虑。最重要的一个是管道配置文件logstash_pipeline_config。我们需要在这个文件中添加自定义规则,用于日志消息转换。与 ELK 的前两个组件不同,这个文件会持续变化。Logstash 监听 5000 端口,包括 TCP 和 UDP,在 elk_net 网络内。稍后我们将把 Logspout 插入到该网络中,将 Docker 守护进程的日志消息传输到 Logstash 服务:
version: '3.3'
configs:
logstash_config:
file: ./logstash/config/logstash.yml
logstash_pipeline_config:
file: ./logstash/pipeline/logstash.conf
services:
logstash:
build:
context: logstash/
image: chanwit/logstash:6.1
configs:
- source: logstash_config
target: /usr/share/logstash/config/logstash.yml
- source: logstash_pipeline_config
target: /usr/share/logstash/pipeline/logstash.conf
environment:
LS_JAVA_OPTS: "-Xmx256m -Xms256m"
networks:
default:
external:
name: elk_net
接下来的步骤是构建和部署,类似于前两个组件:
$ docker-compose -f logstash.yml build
$ docker stack deploy -c logstash.yml log
我们将这三个组件作为独立堆栈,通过 elk_net 连接在一起。要检查所有组件是否正在运行,只需使用 docker stack ls 命令进行检查:
$ docker stack ls
NAME SERVICES
es 1
kb 1
log 1
最后,我们可以使用 Logspout 将来自每个 Docker 守护进程的所有日志重定向到 ELK 堆栈中的中央服务。这可以通过将每个本地 logspout 容器连接到 elk_net 来实现,使它们都能连接到网络内的 Logstash 实例。我们通过以下命令启动每个 Logspout:
$ docker run -d \
--name=logspout \
--network=elk_net \
--volume=/var/run/docker.sock:/var/run/docker.sock \
gliderlabs/logspout \
syslog+tcp+udp://logstash:5000
现在我们可以通过 Logspout 将所有日志消息发送到 Logstash,存储在 Elasticsearch 中,并通过 Kibana 进行可视化。
使用 Golang 脚本化 Docker
在操作和管理 Docker 时,我们可以通过 docker CLI 使用 jq 命令来控制集群。另一种强大且灵活的方式是通过脚本控制集群。当然,最适合脚本化 Docker 集群的编程语言是 Golang。
为什么不用 Python?静态编译语言 Golang 怎么适合脚本编程?
-
首先,Go 是 Docker 所用的编程语言。用 Go 语言编写的 Docker 库就是 Docker 本身所使用的代码。因此,使用该库编写的脚本自然具备高质量和极高的可靠性。
-
其次,语言的构造和习语非常契合 Docker 的工作方式。例如,Go 编程语言有通道(channel)构造,它非常适合处理 Docker 集群发出的事件消息。
-
第三,Go 编译器极其快速。此外,一旦所有相关库编译完成,编译时间大大缩短。我们通常可以像使用其他脚本语言解释器一样使用它来运行脚本。
在本节中,我们将讨论如何使用 Golang 编写的脚本通过 Docker 的 API 直接控制 Docker。这将成为管理集群运行的强大工具。
准备工具
安装 Go 编译器并使其准备好使用有时可能会有些棘手。然而,Golang 版本管理器(GVM)是一个帮助安装和卸载同一台机器上不同 Go 版本的工具。它还帮助有效管理 GOPATH。
什么是 GOPATH?它在 Wikipedia 中是这样定义的:
“GOPATH 环境变量用于指定 $GOROOT 之外的目录,这些目录包含 Go 项目的源代码及其二进制文件。”
要开始使用 GVM,我们首先使用在github.com/moovweb/gvm上提供的代码片段安装gvm命令。只需一条命令就能安装:
$ bash < <(curl -s -S -L https://raw.githubusercontent.com/moovweb/gvm/master/binscripts/gvm-installer)
现在我们已经安装了 GVM,接下来安装 Go。
使用 Go 的最新版本 1.9.3 是很棒的。安装命令当然是gvm install。我们向install命令传递-B参数,这样它将仅下载并使用 Go 分发版的二进制文件:
$ gvm install go1.9.3 -B
Installing go1.9.3 from binary source
接下来,如果我们在管理集群时选择使用 Go v1.9.3,我们应该将其设为默认版本。使用gvm use命令并加上--default参数来实现:
$ gvm use go1.9.3 --default
Now using version go1.9.3
使 Go 可脚本化
接下来,准备下一个工具gorun,以便将 Go 程序脚本化。使用gorun,你可以在脚本的第一行添加 shebang,如以下命令所示:
#!/usr/bin/env gorun
正常的 Go 程序将允许直接从 shell 执行。
要安装gorun,只需执行go get。gorun二进制文件现在将在当前由 GVM 管理的go1.9.3提供的路径下。请注意,如果你通过 GVM 切换 Go 版本,需要重新执行go get:
$ go get github.com/erning/gorun
我们可以通过安装 Docker 客户端库本身,来安装所有必要的库以编程方式控制 Docker:
$ go get github.com/docker/docker/client
如果一切顺利,我们将准备开始编写一个 Golang 脚本。
简单的 Docker 脚本
让我们编写一个与 Docker 交互的简单脚本:
#!/usr/bin/env gorun
package main
import (
"fmt"
"context"
"github.com/docker/docker/client"
)
func main() {
ctx := context.Background()
cli, err := client.NewClient(client.DefaultDockerHost, "1.30", nil, nil)
if err != nil {
panic(err)
}
info, err := cli.Info(ctx)
if err != nil {
panic(err)
}
fmt.Println(info.ServerVersion)
}
首先,脚本的第一行必须包含 shebang 和gorun。其次,导入 Docker 客户端库的代码行,github.com/docker/docker/client。尽管 Docker 已经迁移到github.com/moby/moby,但我们仍然需要通过docker/docker仓库名导入所有相关库。只需执行go get github.com/docker/docker/client,一切仍然可以正常工作。
然后我们开始编程集群,通过创建客户端并将 API 版本设置为 1.30。这个脚本随后调用cli.Info(ctx)来从 Docker 守护进程获取引擎信息,存储在info变量中。它简单地打印出我们正在交互的 Docker 守护进程的版本信息。版本信息保存在info.ServerVersion中。
将脚本保存为名为server-version的文件。我们现在可以将其作为普通的 shell 脚本运行:
$ chmod +x ./server-version
$ ./server-version
17.06.2-ce
响应 Docker 事件的脚本
接下来,我们将编写一个脚本,用于监控 Docker 集群中的变化,并在节点更新时打印输出:
#!/usr/bin/env gorun
package main
import (
"context"
"fmt"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
)
func main() {
ctx := context.Background()
cli, err := client.NewClient(client.DefaultDockerHost, "1.30", nil, nil)
if err != nil {
panic(err)
}
filter := filters.NewArgs(filters.Arg("type", "node"))
ch, _ := cli.Events(ctx, types.EventsOptions{
Filters: filter,
})
for {
fmt.Println("Waiting for event ...")
message := <-ch
action := message.Action
switch action {
case "create":
fmt.Println(" - New node added.")
case "update":
fmt.Println(" - Node updated.")
case "remove":
fmt.Println(" - Node removed.")
}
}
}
这也是一个由gorun执行的脚本。脚本开始时创建一个指向本地套接字/var/run/docker.sock的 Docker 客户端 CLI。
然后它创建了一个过滤器,filter 变量。这个过滤器使事件发射器只选择我们感兴趣的事件类型,在这种情况下,就是事件的 type 为 node。这相当于在命令行中传递 --filter type=node。cli.Events 方法将返回一个 Go 通道,用于接收消息。消息将在 for 循环内部被获取。如果通道中没有消息,程序将自动阻塞。所以脚本就变成了单线程风格,且易于编程。
在循环内部,我们可以处理消息中的信息,例如检查某个事件的动作。通常,大多数类型的事件包含三种可能的动作:create、update 和 remove。对于一个节点,create 表示有一个新节点被添加到集群中。update 动作表示某个节点发生了变化。remove 动作表示节点从集群中移除。
只需将此脚本保存到 ./node-event,然后执行 chmod +x。
$ chmod +x ./node-event
chmod 命令将更改脚本的可执行位。通过这些位,Linux 系统将能够检测到该文件应该被执行。然后,它将告诉 gorun 来处理这个执行。
尝试更改当前工作节点的一些属性。我们可能会看到文本 - Node updated. 被打印出来。
练习
请尝试回答以下问题,而不回头阅读本章内容:
-
列出至少三个在稳定集群配置中描述的组件。
-
为什么重试和断路器如此重要?
-
我们如何用新的入口层替换默认的入口层?
-
我们如何安装网络插件?
-
ELK 堆栈最前端的部分是什么?
-
为什么 Go 语言适合用于脚本化 Docker 系统?
-
我们如何监听特定类型的 Docker 事件?
-
我们如何设置控制平面?
-
什么是操作符模式?为什么它很重要?
-
Spot 实例的特点是什么,使它们比普通实例便宜?
总结
本章讨论了如何准备和操作 Docker 集群的各种主题,介绍了通过在 Spot 实例上部署 Docker 集群来提供 Lambda 的低成本替代方案。本章还介绍了 CoreOS 操作符模式的概念,并讨论了如何实用地使用它来自动平衡我们集群的任务。
在日志记录方面,ELK 堆栈通常是首选。本章还讨论了如何在 Docker Swarm 上高效地准备 ELK,并以如何通过 Golang 脚本操作集群为结尾,这种脚本技巧可以充分利用 Docker 及其生态系统。
在下一章中,我们将把所有 FaaS 平台放入同一个集群,并使它们协同工作,以展示一个基于 Docker 集群的事件驱动 FaaS 系统的使用案例。
第八章:将所有内容整合在一起
本章将通过一个示例演示如何在 Docker 集群上让无服务器平台协同工作,并展示几个无服务器/FaaS 的使用案例。
我们将讨论一个移动支付场景,并通过函数实现它,但不同于以往在这种基础设施层次下,我们将把三个 FaaS 平台连接在一起。本章的主要思想是使用函数作为粘合剂,使用函数封装传统的基于 Web 的应用程序,以及将函数作为数据流处理程序。
在接下来的部分中,我们将从本章所使用的设置和场景开始。
本章涵盖的主题有:
-
一个移动支付场景
-
Parse 平台作为后端
-
在 Fn 中准备 WebHook
-
带区块链的事件状态机
-
使用函数包装传统系统
-
使用函数作为粘合剂
-
一个流处理器
-
跨 FaaS 平台的网络连接
一个移动支付场景
我们正在使用一个允许两家银行之间进行资金转账的移动支付场景作为本章的案例。对于资金转账,业务逻辑很容易理解。因此,我们无需担心这一部分。我们将集中精力在架构的复杂性上。
在两家不同银行之间进行资金转账,并且两家银行有不同的底层实现是困难的。这是因为我们不能直接应用传统交易的概念来应对外部系统。系统的结构如下图所示:

图 8.1:移动支付系统的总体框架图
本章没有涵盖哪些内容?
本书的范围不包括用户界面部分,因此它们不可用。收据生成器和收据存储是可选的。如果有兴趣,你可以自行实现它们。
我们要实现并演示什么?我们来讨论一下:
-
Parse 平台作为 UI 的后端。
-
银行路由函数。它是用 Java 编写的,并部署在 Fn 上。这个组件叫做
routing_fn。 -
银行#1及其调用传统 Web 系统的函数。这里的函数是用 Node.js 编写的,并使用
chromeless库(github.com/graphcool/chromeless)。该函数连接到无头的 Chrome 实例,也就是我们熟悉的 Web 浏览器。该函数驱动 Chrome 进行导航并在实际的 ERP 系统中为我们创建交易。我们使用 Moqui 作为我们的 ERP 后端。实际上,Moqui 自带完整的 REST API,但我们故意使用其 Web 界面来模拟需要现代化某些传统系统的场景。此部分的功能被称为hivectl。 -
银行 #2 及其功能
account_ctl,连接到基于 REST 的银行系统。该功能使用Go编写,并将在 OpenWhisk 上运行。该组件背后的模拟银行服务器是一个简单的服务器,使用 Grails/Spring Boot 框架编写。我们使用这个组件来演示如何编写 FaaS 函数,以封装和简化基于 REST 的 API。银行路由功能routing_fn将由每个银行选择性地调用。这个 银行 #2 组件将与 银行 #1 一起使用。 -
一组用 Solidity 编写的智能合约,用于维护移动号码与银行账户的映射。此外,另一组智能合约将用于维护每笔交易的资金转移状态。
-
一个用 Java 编写的代理,并使用 RxJava 库演示一个数据流处理组件,该组件调用一个函数并将事件转发到系统的其他部分。
作为后端的 Parse 平台
什么是 Parse?类似于 Firebase,Parse 是一个 后端即服务(BaaS)平台。使用 Parse,开发者无需为他们的 UI 或移动应用程序编写后端系统代码。Parse 被移动应用程序开发者使用,以帮助加速开发过程。与 Parse 仪表盘一起,它们提供了一个简易的 UI,用于构建处理基本业务逻辑所需的所有数据实体,称为 类。
准备工作
这是如何创建 Docker 网络并部署一组 Docker Compose 文件。我们使用元堆栈的概念来部署多个堆栈,并有一些标签和命名约定将它们组合在一起:
$ docker network create \
--driver=weaveworks/net-plugin:2.1.3 \
--subnet=10.32.2.0/24 \
--attachable \
parse_net
$ docker volume create mongo_data
$ docker stack deploy -c mongodb.yml parse_01
$ docker stack deploy -c parse.yml parse_02
$ docker stack deploy -c parse_dashboard.yml parse_03
$ docker stack deploy -c ingress.yml parse_04
在生产环境中部署时,我们不使用任何 Docker Compose 文件来设置网络和卷。所有堆栈都应引用外部卷和网络。
从 MongoDB 开始,我们已经为其设置了一个卷。以下是 MongoDB 服务器的设置:
version: '3.3'
services:
mongo:
image: mongo:3.6.1-jessie
volumes:
- mongo_data:/data/db
volumes:
mongo_data:
external: true
networks:
default:
external:
name: parse_net
我们进入下一个组件,即 Parse 平台。为了使容器与 Træfik 一起工作,我们为服务添加了一些标签,表示它将位于 parse_net 网络上,并将端口 1337 暴露给 Træfik 的 ingress。
我们添加了一条规则,允许每种 HTTP 方法,同时定义自定义入口点,并允许 Origin=*,以便下一个部分的 Parse 仪表盘能够连接到 Parse 服务器:
version: '3.3'
services:
parse_server:
image: parseplatform/parse-server:2.6.5
command: --appId APP1 --masterKey MASTER_KEY --databaseURI mongodb://mongo/prod
deploy:
labels:
- "traefik.docker.network=parse_net"
- "traefik.port=1337"
- "traefik.frontend.rule=Method: GET,POST,PUT,DELETE,OPTIONS,HEAD,CONNECT"
- "traefik.frontend.entryPoints=parse_server"
- "traefik.frontend.headers.customresponseheaders.Access-Control-Allow-Origin=*"
networks:
default:
external:
name: parse_net
这是 Parse 仪表盘及其配置。当前仪表盘版本为 1.1.2。它将通过 Træfik 的 ingress 暴露到端口 4040:
version: '3.3'
services:
parse_dashboard:
image: parseplatform/parse-dashboard:1.1.2
environment:
- PARSE_DASHBOARD_ALLOW_INSECURE_HTTP=true
deploy:
labels:
- "traefik.docker.network=parse_net"
- "traefik.port=4040"
- "traefik.frontend.rule=Method: GET,POST,PUT,DELETE,OPTIONS,HEAD,CONNECT"
- "traefik.frontend.entryPoints=parse_dashboard"
- "traefik.frontend.headers.customresponseheaders.Access-Control-Allow-Origin=*"
configs:
- source: config.json
target: /src/Parse-Dashboard/parse-dashboard-config.json
configs:
config.json:
file: ./config.json
networks:
default:
external:
name: parse_net
配置定义了默认的用户名和密码,并且指出服务器允许通过 HTTP 连接。将 INSECURE 设置为 true 是可以的,因为我们可以简单地在 ingress 层使用 Træfik 实现 SSL:
{
"apps": [
{
"serverURL": "http://localhost:1337/parse",
"appId": "APP1",
"masterKey": "MASTER_KEY",
"appName": "APP1",
"iconName": "MyAppIcon.png",
"supportedPushLocales": ["en", "ru", "fr"]
}
],
"users": [
{
"user":"admin",
"pass":"password"
}
],
"iconsFolder": "icons",
"allowInsecureHTTP": true
}
以下 YAML 代码用于定义 Parse 和 Parse 仪表盘的 L7 Træfik ingress。我们还需要将 Parse 暴露到外部,因为仪表盘是一个胖客户端,而不是服务器端渲染。这是我们需要设置 Allow-Origin=* 的主要原因:
version: '3.3'
services:
l7:
image: traefik:1.5.2
command: --docker
--docker.swarmmode
--docker.watch
--docker.endpoint=tcp://docker-api:2375
--entryPoints="Name:parse_server Address::1337"
--entryPoints="Name:parse_dashboard Address::4040"
--web --logLevel=DEBUG
ports:
- published: 1337
target: 1337
protocol: tcp
mode: host
- published: 4040
target: 4040
protocol: tcp
mode: host
networks:
default:
external:
name: parse_net
如果一切顺利,我们可以打开我们喜欢的浏览器,访问 localhost:4040 以查看 Parse 仪表盘,如下图所示。默认的用户名和密码是:admin/password:

图 8.2:Parse 仪表盘的登录页面
在以下截图中,我们的 Parse 平台 APP1 仪表盘连接到 http://localhost:1337 的 Parse 实例:

图 8.3:显示核心部分的 Parse 应用界面
定义转账实体
在左侧导航面板中,我们可以看到 Core | Browser 菜单。这里我们可以查看 Parse 平台上的所有数据。虽然已有一些内置类,但我们将定义一个新类来帮助进行资金转账。
点击 Core | Browser 菜单中的“创建一个类”。如以下截图所示,将会弹出一个对话框。我们将在此命名我们的新类,Transfer。它将成为我们处理移动支付和,当然,资金转账的主要实体:

图 8.4:定义 Parse 中新类的对话框
接下来,我们需要为这个实体设置一些新列。我们定义了以下列:
-
from: 付款人的手机号码。
-
to: 收款人的手机号码。
-
amount: 需要支付的金额。
-
sent: 当我们要开始处理交易时,需要将此标志设置为
true。如果该字段为null或false,WebHook(见下文)将仅接收数据而不执行任何操作。 -
processed: 如果交易处理完毕,标志将自动设置为
true。
我们如何使用这个类?通过仪表盘,如下图所示,我们为 from、to 和 amount 列设置了手机号码。然后,当我们准备好时,只需将 sent 列设置为 true。
如果处理出错,发送标志将由 WebHook 自动重置为 null:

图 8.5:通过仪表盘浏览 Transfer 类
WebHook
Parse 平台提供了一个可扩展的机制,允许我们在外部处理业务逻辑。这就是函数的作用。这个机制称为 WebHook。
我们可能会有作为外部进程运行的函数,这些函数在 Parse 平台外部,并与 Parse 的 WebHook 一起使用,以执行复杂的业务逻辑。正如前面的例子中所示,我们已经有了 Transfer 类。然后,我们为这个类定义一个 WebHook,每次在保存每个 Transfer 实体之前,调用外部函数。我们为此 WebHook 指定一个 FaaS 网关的 URL。HTTP POST 方法将发送到指定的 URL,JSON 数据将作为请求体。请求体包含当前 Transfer 实体的数据。
可以通过访问 Core | Webhooks 并点击小的“创建一个 Webhook”选项来创建一个 WebHook:

图 8.6:列出现有 WebHooks 的屏幕
Parse 中有两种 WebHook:Cloud Code 函数和触发器。本章使用的 WebHook 类型是 触发器。WebHook 触发器可以挂钩到多个地方,例如 beforeSave、afterSave、beforeDelete 和 afterDelete。
在本章讨论的示例中,将使用如下一截图所示的 beforeSave 触发器。我们选择 Transfer 作为该钩子的目标类。然后我们需要指定 WebHook URL,这是部署到 Fn 上的银行路由函数:

图 8.7:定义新 WebHook 的对话框;适用于 Transfer 类的 beforeSave
我们将在下一节讨论银行路由 WebHook,但在深入了解其细节之前,先简要展示其运行效果。
我们通过创建一个新的 Transfer 类实例来测试 WebHook。以下截图中,手机号码 +661234567 尚未注册。因此无法进行查找,WebHook 返回错误消息:

图 8.8:从 WebHook 返回的错误消息将在右下角弹出
要查看错误日志,请点击 Core | Logs,如下截图所示。这里是 WebHook 返回以下 JSON 的示例:
{"error": "Could not lookup number: +661234567"}

图 8.9:信息级别的日志屏幕,显示各种日志,包括错误
返回消息的规范是 {"success": object} 用于将数据更新回 Parse 平台,{"error":"msg"} 用于显示错误消息。
在 Fn 中准备 WebHook
Fn 项目最适合使用 Java 编写的函数。当调用函数时,框架能够自动将请求体转换为入口方法的参数。在以下示例中,请求中的 JSON 将被转换为字符串,作为此 Fn 函数的入口方法 handleRequest 的参数:
public Object handleRequest(String body) {
if (body == null || body.isEmpty()) {
body = "{}";
}
Input input;
try {
val mapper = new ObjectMapper();
input = mapper.readValue(body, Input.class);
} catch (IOException e) {
return new Error(e.getMessage());
}
if (input == null) {
return new Error(body);
}
/* process the rest of business logic */
}
这是 数据传输对象 (DTO) 类的列表,用于在 Fn 函数中正确地编码和解码 Parse 的 WebHook 消息。借助 Project Lombok 和 Jackson,我们可以显著减少代码行数。Input 对象是 Java 的 Transfer 对象的包装器,包含与我们在 Parse 平台上定义的 Transfer 类相似的所有列。
请注意,我们在系统的两端都有一个 Transfer 类,一个在 Parse 平台上,另一个在 Fn 平台上。
Success 和 Error 类用于将处理结果返回到 Parse:
@Data
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Input {
private Transfer object;
}
@Data
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Transfer {
private String objectId;
private String from;
private String to;
private Double amount;
private Boolean sent;
private Boolean processed;
}
@Data
@AllArgsConstructor
public static class Success {
private Transfer success;
}
@Data
@AllArgsConstructor
public static class Error {
private String error;
}
由于这是一个 Java 项目,我们不需要在容器内部构建它。以下是 Gradle 构建文件,可以使用 gradle installDist 命令进行构建:
plugins {
id 'io.franzbecker.gradle-lombok' version '1.11'
id 'java'
id 'groovy'
id 'application'
}
mainClassName = 'App'
dependencies {
// FN Project
compile 'com.fnproject.fn:api:1.0.56'
// JSON encoding
compile 'com.fasterxml.jackson.core:jackson-annotations:2.9.4'
compile 'com.fasterxml.jackson.core:jackson-databind:2.9.4'
// REST client
compile 'com.squareup.okhttp3:okhttp:3.9.1'
// Simplify Java syntax
compile group: 'org.projectlombok', name: 'lombok-maven',
version: '1.16.20.0', ext: 'pom'
// Ethereum Client
compile 'org.web3j:core:3.2.0'
// Testing
testCompile 'com.fnproject.fn:testing:1.0.56'
testCompile 'junit:junit:4.12'
testCompile 'org.codehaus.groovy:groovy-all:2.4.12'
testCompile 'org.spockframework:spock-core:1.0-groovy-2.4'
}
repositories {
mavenCentral()
jcenter()
maven {
url "https://dl.bintray.com/fnproject/fnproject"
}
}
以下是用于构建 Fn 项目的 Dockerfile。它需要继承自fn-java-fdk。在本书的示例中,我们使用jdk9-1.0.56。你需要做的是将build目录中的所有 JAR 文件复制到容器镜像中的/function/app目录:
FROM fnproject/fn-java-fdk:jdk9-1.0.56
WORKDIR /function
COPY ./build/install/routing_fn/lib/*.jar /function/app/
CMD ["com.example.fn.TransferFunction::handleRequest"]
以下步骤是准备 Fn 服务器,然后我们使用gradle命令构建函数。接着,我们将其 Docker 镜像构建并推送到 Hub,然后重新定义为 Fn 路由。
首先,我们使用以下docker run命令手动部署 Fn 服务器。同时,我们将 Fn 连接到parse_net。为了使每个由 Fn 启动的容器都位于同一个网络中,我们对 Fn 进行了特别的黑客处理,如FN_NETWORK中所指定的:
docker run \
--name fnserver \
--detach \
-v /var/run/docker.sock:/var/run/docker.sock \
-v fn_vol:/app/data \
-p 28080:8080 \
--network=parse_net \
--network-alias=fn_gateway \
-e FN_LOG_LEVEL=debug \
-e FN_NETWORK=parse_net \
fnproject/fnserver
这是构建和推送脚本。将以下脚本保存为./buildAndPush:
./gradlew installDist
VERSION=$1
docker build -t chanwit/routing_fn:$VERSION .
docker push chanwit/routing_fn:$VERSION
fn routes delete demo /routing_fn
fn routes create /routing_fn -i chanwit/routing_fn:$VERSION demo
然后,我们可以通过调用带有特定版本号的脚本来开始构建和推送过程,如下所示:
./buildAndPush v1
在下一节中,我们将讨论 WebHook 函数如何从区块链中查找账户数据,以及如何跟踪每个资金转账交易的状态。
一个带区块链的事件状态机
我们使用以太坊区块链作为该事件状态机来处理资金转账系统。在这个角色中,区块链用于存储以下内容:
-
电话号码与银行账户的映射关系
-
每个转账交易的整体状态
在区块链内部,有两种类型的智能合约。第一种实现了仓库模式,第二种实现了实体模型。
我们使用 Truffle (truffleframework.com/) 来创建这个事件状态机。请查看 GitHub 仓库中名为eventmachine的子项目 (github.com/chanwit/eventmachine)。
这里有一个位于entities/目录下的智能合约,TransferState.sol。这个智能合约维护着每个转账交易的状态。每个交易的初始状态是NONE,然后是STARTED、PENDING,最终是COMPLETED。下图展示了智能合约TransferState与其仓库实现协作的过程:

图 8.10:智能合约的状态转换和事件触发流程
然后我们稍微探讨一下其代码。在转换到下一个状态时,我们使用require语句进行检查,如果不满足前置条件,智能合约将抛出异常:
contract TransferState {
enum State { NONE, STARTED, PENDING, COMPLETED }
string txId;
State state;
function TransferState(string _txId) {
state = State.NONE;
txId = _txId;
}
function start() public {
require(state == State.NONE);
state = State.STARTED;
}
function pending() public {
require(state == State.STARTED);
state = State.PENDING;
}
function complete() public {
require(state == State.PENDING);
state = State.COMPLETED;
}
function currentState() public constant returns (uint8) {
return uint8(state);
}
}
如前所述,TransferState 是由智能合约 TransferStateRepository 管理的。基本上,这是仓储模式的实现(hub.packtpub.com/domain-driven-design/)。此外,该智能合约自然运行在区块链上。为了使其与外界通信,每个智能合约必须触发一种事件。在这个 TransferStateRepository 中,它被设计为在每个事务将其状态更改为 STARTED、PENDING 和 COMPLETED 时触发事件。请参见 图 8.10 中的事件签名:
contract TransferStateRepository {
event TransferStarted(string txId);
event TransferPending(string txId);
event TransferCompleted(string txId);
mapping(bytes32 => address) states;
function start(string txId) public {
/* register the state, set to STARTED */
TransferStarted(txId);
}
function pending(string txId) public {
/* check the state, set to PENDING */
TransferPending(txId);
}
function complete(string txId) public {
/* check the state, set to COMPLETED */
TransferCompleted(txId);
}
function getStateOf(string txId) public constant returns (string) {
/**/
if (state == 0) return "NONE";
else if (state == 1) return "STARTED";
else if (state == 2) return "PENDING";
else if (state == 3) return "COMPLETED";
}
}
电话号码与银行账户之间的映射由 RegistrationRepository 维护。该智能合约使用相同的技术与外界通信,触发事件。
RegistrationRepository 设计了四个事件。当我们将一个新的电话号码注册到映射中时,会触发 Registered 事件。如果我们尝试再次注册相同的号码,则会触发来自区块链的 AlreadyExisted 事件。
RegistrationFound 是在通过 findTelByNo 函数根据姓名找到手机号时触发的事件,当此函数无法找到与输入手机号匹配的注册信息时,会触发 RegistrationNotFound 事件:
contract RegistrationRepository {
mapping(bytes32 => address) registrations;
event Registered(string telNo, address registration);
event AlreadyExisted(string telNo);
event RegistrationFound(string telNo, string bank, string accNo);
event RegistrationNotFound(string telNo);
function register(string telNo, string bank, string accNo) public {
/**/
Registered(telNo, address(r));
}
function findByTelNo(string telNo) public returns (address) {
/**/
Registration r = Registration(registrations[key]);
RegistrationFound(telNo, to_s(r.bank()), to_s(r.accNo()));
return address(r);
}
}
使用 Truffle 框架,我们可以在开发过程中通过 JavaScript 初始化一些数据。以下是一个迁移脚本,位于 migrations/ 目录下,用于将智能合约部署到区块链,并注册两个手机号码。第一个号码关联到由 银行 #1(OpenFaaS 银行)管理的账户。第二个手机号码已注册并关联到 银行 #2(OpenWhisk 银行)。在所有银行中,我们已有包含存款的账户:
var RegistrationRepository = artifacts.require(
"./v2/repository/RegistrationRepository.sol");
var TransferStateRepository = artifacts.require(
"./v2/repository/TransferStateRepository.sol");
module.exports = function(deployer) {
deployer.deploy(TransferStateRepository);
deployer.deploy(RegistrationRepository).then(function() {
RegistrationRepository.deployed().then(function(repo){
repo.register("+661234567", "faas", "55700").then();
repo.register("+661111111", "whisk", "A1234").then();
});
});
};
我们使用 Parity(一款最稳定的以太坊客户端之一)搭建了一个以太坊区块链网络。以下是设置过程。我们将运行中的 Parity 容器连接到 Fn 和 Parse 平台的同一网络:
docker run --rm --name=parity_dev -d -p 8545:8545 -p 8180:8180 \
--network=parse_net \
--network-alias=blockchain \
parity/parity:stable-release \
--geth --chain dev --force-ui \
--reseal-min-period 0 \
--jsonrpc-cors http://localhost \
--jsonrpc-apis all \
--jsonrpc-interface 0.0.0.0 \
--jsonrpc-hosts all
对于生产环境的私有区块链网络,我们需要采用不同的设置方式。例如,我们需要设置自己的创世区块以及网络的挖矿行为。无论如何,这已经超出了本书的范围。然后,我们通过 Truffle 部署智能合约:
$ cd eventmachine
$ truffle exec scripts/unlock.js
$ truffle migrate
WebHook 如何使用区块链
在讨论每个组件后,我们将定期回到 WebHook。
我们已经知道区块链和智能合约是如何工作的。它们记录手机号码注册,并维护转账交易的状态。在这一部分,我们讨论 WebHook 功能如何与区块链交互。以下是 WebHook 函数中的代码片段。函数中的查找方法获取智能合约RegistrationRepository,然后在区块链上调用findByTelNo()。结果随后会出现在交易回执中。我们检查回执中存储了什么事件。如果是RegistrationFound事件,那么该方法返回一个包含银行名称和账户号码信息的结果对象。
这个检查还有改进的空间。
读者应该如何优化智能合约,以便仅触发一个事件并有意义地检查电话号是否已注册?
基本上,这就是查找部分:
@Data
@AllArgsConstructor
static class RegistrationResult {
private String bankName;
private String accountId;
}
public RegistrationResult lookup(String telNo) throws Exception {
val repo = ContractRegistry.registrationRepository();
val receipt = repo.findByTelNo(telNo).send();
val foundEvents = repo.getRegistrationFoundEvents(receipt);
if (foundEvents.isEmpty() == false) {
val reg = foundEvents.get(0);
return new RegistrationResult(reg.bank, reg.accNo);
} else {
val notFoundEvents = repo.getRegistrationNotFoundEvents(receipt);
if(notFoundEvents.isEmpty() == false) {
val reg = notFoundEvents.get(0);
return null;
}
}
throw new Exception("Lookup does not find any event in receipt.");
}
转账状态管理部分是通过一组方法实现的,这些方法的名称以transfer开头。
这是告诉我们如何开始新的交易的方法,交易 ID 为txId。它使用ContractRegistry来获取智能合约TransferStateRepository。然后我们创建一个新的交易状态,并将其状态设置为STARTED。如果一切正常,我们应该从调用中获取一个交易回执,回执中嵌入了一个事件TransferStartedEvent:
private boolean transferStart(String txId) {
try {
val repo = ContractRegistry.transferStateRepository();
val receipt = repo.start(txId).send();
val events = repo.getTransferStartedEvents(receipt);
if (events.isEmpty()) {
return false;
}
return true;
} catch (Exception e) {
return false;
}
}
用函数包装一个遗留系统
在这一部分,我们将演示如何为一个遗留的基于 Web 的系统编写包装函数。为此,我们使用chromeless库(github.com/graphcool/chromeless)连接到一个无头的 Chrome 实例。然后,chromeless脚本驱动 Chrome 浏览器为我们完成其余的工作。
以下图表显示了系统这一部分的工作机制:

图 8.11:实现一个 OpenFaaS 函数来包装基于 UI 的 ERP 的图示
chromeless做什么?chromeless是一个 Node.js 库,可以用于执行浏览器自动化,类似于 PhantomJS 或 Selenium。但是它真的非常快速。结合无头的 Chrome 实例,chromeless可以提供非常快的性能。因此,它可以用作无服务器函数。
我们首先使用 FaaS CLI 创建一个项目。我们称这个函数为hivectl,它是一个控制使用 Moqui 框架构建的 ERP 程序 HiveMind 的程序。设置完这个函数后,我们将简要介绍 HiveMind:
$ faas new hivectl --lang node
2018/03/04 22:28:49 No templates found in current directory.
2018/03/04 22:28:50 Attempting to expand templates from https://github.com/openfaas/templates.git
2018/03/04 22:28:55 Fetched 11 template(s) : [csharp dockerfile go go-armhf node node-arm64 node-armhf python python-armhf python3 ruby] from https://github.com/openfaas/templates.git
Folder: hivectl created.
$ cd hivectl
这是hivectl.yml的内容,它是hivectl函数的 OpenFaaS 函数描述符:
provider:
name: faas
gateway: http://localhost:8080
functions:
hivectl:
lang: node
handler: ./hivectl
image: chanwit/hivectl:0.4
这是一个示例配置,使chromeless连接到在同一网络上另一个容器内运行的无头 Chrome。诀窍是将launchChrome设置为false,并将cdp,Chrome 开发工具协议,指向host:'chrome', port: 9222:
const chromeless = new Chromeless({
launchChrome: false,
cdp: { host: 'chrome', port: 9222, secure: false, closeTab: true }
})
这是主要的chromeless脚本,用于远程控制无头 Chrome 实例。我们将程序放到hivectl/handler.js中:
const { Chromeless } = require('chromeless')
const url = 'http://hivemind/vapps/hmadmin/Accounting/FinancialAccount/FinancialAccountTrans?finAccountId='
module.exports = (content, callback) => {
async function run(accountId, amount) {
const chromeless = new Chromeless({
launchChrome: false,
cdp: { host: 'chrome', port: 9222, secure: false, closeTab: true }
})
const screenshot = await chromeless
.goto('http://hivemind/Login/logout')
.click('#TestLoginLink_button')
.wait('.btn-danger')
.goto(url + accountId)
.wait('#AdjustDialog-button')
.click('#AdjustDialog-button')
.type(amount, '#AdjustFinancialAccount_amount')
.mousedown('#select2-AdjustFinancialAccount_reasonEnumId-container')
.mouseup('#select2-AdjustFinancialAccount_reasonEnumId-container')
.press(40, 5)
.press(13)
.click('#AdjustFinancialAccount_submitButton')
.screenshot()
.catch(e => {
console.log('{"error":"' + e.message + '"}')
process.exit(1);
})
console.log('{"success": "ok", "screenshot":"' + screenshot + '"}')
await chromeless.end()
}
const opt = JSON.parse(content)
run(opt.accountId, opt.amount).catch(console.error.bind(console))
};
使用 OpenFaaS,我们可以通过以下命令简单地构建函数容器:
$ faas build -f ./hivectl.yml
...
Successfully built 1f7cc398fc61
Successfully tagged chanwit/hivectl:0.4
Image: chanwit/hivectl:0.4 built.
[0] < Building hivectl done.
[0] worker done.
接下来,我们将在 OpenFaaS 中定义这个函数。在 OpenFaaS UI 中,定义一个新函数,弹出对话框允许我们将新函数附加到特定网络,在这个例子中是parse_net。
-
镜像:
chanwit/hivectl:0.4 -
名称:
hivectl -
网络:
parse_net
我们启动一个无头的 Chrome 实例,并将其暴露为chrome,与调用函数处于同一网络。这个无头的 Chrome 将监听 TCP 端口9222:
docker run -d --network=parse_net \
--network-alias=chrome \
--cap-add=SYS_ADMIN \
justinribeiro/chrome-headless
现在我们启动一个 ERP 系统。它是使用 Moqui 框架构建的 HiveMind ERP。我们可以从 GitHub 上的 Moqui 仓库下载它(github.com/moqui/moqui-framework)。幸运的是,Moqui 团队还准备了一个 Docker 镜像供使用。所以只需运行它并将其附加到主parse_net网络。端口10000仅用于调试:
$ docker run -p 10000:80 \
-d --network=parse_net \
--network-alias=hivemind \
moqui/hivemind
以下截图显示了将由chromeless函数处理的财务账户页面:

图 8.12:HiveMind 财务账户页面
回到回调路由,这是 WebHook(在 Fn 上运行)调用hivectl函数(在 OpenFaaS 上运行)中的代码。WebHook 代码创建一个 HTTP 客户端,然后将两个参数accountId和amount发送给hivectl函数:
public boolean faasAdjust(String txId,
String accountId,
Double amount) throws Exception {
val env = System.getenv("FAAS_GATEWAY_SERVICE");
val faasGatewayService = (env == null? "http://gateway:8080" : env);
val JSON = MediaType.parse("application/json; charset=utf-8");
val client = new OkHttpClient();
val json = new ObjectMapper().writeValueAsString(new HashMap<String,String>(){{
put("accountId", accountId);
put("amount", String.valueOf(amount));
}});
val body = RequestBody.create(JSON, json);
val request = new Request.Builder()
.url(faasGatewayService + "/function/hivectl")
.post(body)
.build();
val response = client.newCall(request).execute();
System.out.println(response);
if(response.code() == 200) {
val str = response.body().string();
return true;
}
throw new Exception(response.toString());
}
函数作为粘合剂
除了编写一个简单的处理器,本节中的技术是使用函数的最简单形式之一。我们有一个暴露 REST API 的银行后端。所以我们写一个函数作为粘合剂,隐藏后端复杂的接口。在这个示例中,我们使用Go作为语言来实现函数。
这个场景是我们有一个 REST API 服务器,并希望将它与另一个类似的服务统一。在本章的示例中,我们有两个不同交互方式的银行后端。第一个是没有 REST 接口的基于 Web 的 UI,另一个是本节中的 REST API:
func main() {
input := os.Args[1]
// OpenWhisk params are key/value paris
params := map[string]interface{}{}
err := json.Unmarshal([]byte(input), params)
if err != nil {
fmt.Printf(`{"error":"%s", "input": "%s"}`, err.Error(), string(input))
os.Exit(-1)
}
entry := Entry{
Account: Account{
Id: params["accountId"].(string),
},
Amount: params["amount"].(float64),
}
jsonValue, err := json.Marshal(entry)
if err != nil {
fmt.Printf(`{"error":"%s"}`, err.Error())
os.Exit(-1)
}
accountService := os.Getenv("ACCOUNT_SERVICE")
if accountService == "" {
accountService = "http://accounting:8080/entries"
}
resp, err := http.Post(accountService,
"application/json",
bytes.NewBuffer(jsonValue))
if err != nil {
fmt.Printf(`{"error":"%s"}`, err.Error())
os.Exit(-1)
}
if resp.StatusCode >= 200 resp.StatusCode <= 299 {
fmt.Println(`{"success": "ok"}`)
os.Exit(0)
}
fmt.Printf(`{"error": "%s"}`, resp.Status)
}
我们使用多阶段构建。在这里的第一阶段使用go build命令生成静态二进制文件。然后我们将其复制到第二阶段,/action/exec:
# Stage 0
FROM golang:1.8.5-alpine3.6
WORKDIR /go/src/app
COPY account_ctl.go .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -tags netgo -ldflags '-extldflags "-static"' -o exec account_ctl.go
# Stage 1
FROM openwhisk/dockerskeleton
ENV FLASK_PROXY_PORT 8080
COPY --from=0 /go/src/app/exec /action/
RUN chmod +x /action/exec
CMD ["/bin/bash", "-c", "cd actionProxy python -u actionproxy.py"]
在继续下一步之前,别忘了将镜像推送到 Docker Hub。
然后我们使用wsk CLI 命令来定义函数:
$ docker build -t chanwit/account_ctl:v1 .
$ docker push chanwit/account_ctl:v1
$ wsk -i action delete account_ctl
$ wsk -i action create --docker=chanwit/account_ctl:v1 account_ctl
为了让容器能够与其他 FaaS 平台网关进行通信,我们需要修改 OpenWhisk 调用器的配置,使每个容器都在parse_net网络内启动。调用器镜像固定为3a7dce,OpenWhisk 网络配置在调用器服务的环境变量部分,CONFIG_whisk_containerFactory_containerArgs_network被设置为parse_net:
invoker:
image: openwhisk/invoker@sha256:3a7dcee078905b47306f3f06c78eee53372a4a9bf47cdd8eafe0194745a9b8d6
command: /bin/sh -c "exec /init.sh 0 >> /logs/invoker-local_logs.log 2> 1"
privileged: true
pid: "host"
userns_mode: "host"
links:
- db:db.docker
- kafka:kafka.docker
- zookeeper:zookeeper.docker
depends_on:
- db
- kafka
env_file:
- ./docker-whisk-controller.env # env vars shared
- ~/tmp/openwhisk/local.env # generated during make setup
environment:
COMPONENT_NAME: invoker
SERVICE_NAME: invoker0
PORT: 8085
KAFKA_HOSTS: kafka.docker:9092
ZOOKEEPER_HOSTS: zookeeper.docker:2181
DB_PROVIDER: CouchDB
DB_PROTOCOL: http
DB_PORT: 5984
DB_HOST: db.docker
DB_USERNAME: whisk_admin
DB_PASSWORD: some_passw0rd
EDGE_HOST: ${DOCKER_COMPOSE_HOST}
EDGE_HOST_APIPORT: 443
CONFIG_whisk_containerFactory_containerArgs_network: parse_net
WHISK_API_HOST_NAME: ${DOCKER_COMPOSE_HOST}
volumes:
- ~/tmp/openwhisk/invoker/logs:/logs
- /var/run/docker.sock:/var/run/docker.sock
- /var/lib/docker/containers:/containers
- /sys/fs/cgroup:/sys/fs/cgroup
ports:
- "8085:8085"
为了统一它们,我们创建一个函数来包装 REST API,并使这两个接口尽可能相似。
为了启动 REST API 服务器,我们使用docker run命令,并将其附加到parse_net网络,使用会计别名。端口18080仅用于调试目的:
docker run -p 18080:8080 -d \
--network=parse_net \
--network-alias=accounting \
--name accounting \
chanwit/accounting:0.1
一个流处理器
函数的另一个用例是将其作为数据流的处理器。流可能来自任何类型的源,例如数据总线或事件总线。Kafka、Twitter 或区块链(在我们这个例子中是以太坊)可以是数据流的来源。当某个动作发生时,Ethereum 区块链可能会触发某些智能合约相关的事件。
为了高效地观察这些以数据流形式呈现的事件,我们需要使用一种响应式客户端。RxJava 就是其中之一。幸运的是,我们使用的以太坊客户端web3j已经提供了 RxJava 的可观察对象,以便接收来自以太坊区块链的流式数据。
我们将这个组件称为listener。下图展示了我们将在事件监听器周围实现的内容:

图 8.13:图示说明了事件监听器周围的关系
一个要求是,我们需要将代理(事件监听器)作为容器在与区块链相同的网络上运行。我们使用代理将每个交易信息转发到其他端点。在这个示例中,我们有两个端点。第一个是 Parse 中的记录,另一个是 S3 兼容存储 Minio。当交易完成时,我们将文件上传到 Minio。
以下展示了如何使用合约可观察对象来监听区块链事件:
public class Main {
public static void main(String[] args) throws Exception {
val tsrContract = ContractRegistry.unlock((web3j, tm) -> {
return TransferStateRepository.load(
"0x62d69f6867a0a084c6d313943dc22023bc263691",
web3j, tm, ManagedTransaction.GAS_PRICE, Contract.GAS_LIMIT);
});
tsrContract.transferCompletedEventObservable(
DefaultBlockParameterName.LATEST,
DefaultBlockParameterName.LATEST).subscribe(event -> {
System.out.printf("Transfer completed: %s\n", event.txId );
});
}
}
我们为该组件使用一个简单的 Gradle 构建脚本。该项目可以像往常一样使用gradle installDist命令构建:
plugins {
id 'io.franzbecker.gradle-lombok' version '1.11'
id 'java'
id 'application'
}
mainClassName = "event.listener.Main"
repositories {
mavenCentral()
jcenter()
}
dependencies {
compile 'org.slf4j:slf4j-api:1.7.21'
compile 'org.web3j:core:3.2.0'
testCompile 'junit:junit:4.12'
}
这是该组件的 Dockerfile:
FROM openjdk:8u151-jdk-alpine
RUN mkdir /app
COPY ./build/install/listener/lib/*.jar /app/
ENV BLOCKCHAIN_SERVICE http://blockchain:8545/
WORKDIR /app
CMD ["java", "-cp", "*", "event.listener.Main"]
这是 Gradle 构建步骤,docker build和docker push命令:
$ gradle installDist
$ docker build -t chanwit/listener:v1 .
$ docker push chanwit/listener:v1
跨 FaaS 平台的网络通信
为了使不同平台的所有功能能够相互通信,我们需要设置一个合适的容器网络。本章讨论的演示项目并不是一个简单的 FaaS 示例,而是一个复杂的场景,其中功能允许调用不同 FaaS 平台上的其他功能。
通常,在一些无服务器平台如 Lambda 上,我们有时会假设所有功能都运行在提供商的平面网络上。相比之下,当我们在自己的平台上运行功能时,我们可以自己划分网络,功能网络化将成为一个挑战。然而,由于 Docker 和 Swarm 中的网络模型是平面网络,网络化将相对简单。
我们如何实现这一点?通过以下方式:
-
我们创建了一个可附加的 Swarm 范围网络
-
我们启动一个 FaaS 框架,并让其网关连接到该网络
-
我们还需要告诉框架,必须将该网络附加到每个它创建的容器上
在 OpenFaas 中,允许你创建在特定网络上运行的函数。在 OpenWhisk 中,我们可以通过配置 invoker 来指定这一点。对于 Fn 项目,我们需要额外的 hack。以下是需要修改的内容,以便将函数容器附加到指定网络(FN_NETWORK):
var networkingConfig *docker.NetworkingConfig
fnNetwork := os.Getenv("FN_NETWORK")
if fnNetwork != "" {
log.Debugf("Env FN_NETWORK found: %s. Create container %s with network.",
fnNetwork, task.Id())
networkingConfig = docker.NetworkingConfig{
EndpointsConfig: map[string]*docker.EndpointConfig{
fnNetwork: {
Aliases: []string{task.Id()},
},
},
}
}
container := docker.CreateContainerOptions{
Name: task.Id(),
Config: docker.Config{
Env: envvars,
Cmd: cmd,
Memory: int64(task.Memory()),
MemorySwap: int64(task.Memory()),
KernelMemory: int64(task.Memory()),
CPUShares: drv.conf.CPUShares,
Hostname: drv.hostname,
Image: task.Image(),
Volumes: map[string]struct{}{},
OpenStdin: true,
AttachStdin: true,
StdinOnce: true,
},
HostConfig: docker.HostConfig{
LogConfig: docker.LogConfig{
Type: "none",
},
},
NetworkingConfig: networkingConfig,
Context: ctx,
}
带有函数网络补丁的版本可在 github.com/chanwit/fn 下载。
练习
本章涵盖了许多实践和 hack,帮助使演示项目的整个堆栈——一个移动支付系统——能够正常运行。请阅读本章所有部分的内容:
-
为什么我们需要 hack Fn 来构建定制版本的 Fn 服务器?
-
跨 FaaS 平台网络的概念是什么?为什么它很重要?
-
在演示项目中,Ethereum 区块的两个角色是什么?
-
OpenWhisk 函数的参数数据类型是什么?
-
我们如何在 Fn 中使用 Java 编码 JSON?
-
我们如何在 Golang 中编码 JSON?
-
什么是 Parse 平台?
-
Parse WebHook 的作用是什么?
-
什么是 Glue 函数的概念?
-
我们如何编写函数来封装传统的基于 Web 的应用程序?
总结
本章通过展示如何在 FaaS 平台上开发移动支付系统,演示了一个用例。
我们使用 Parse 作为 UI 后端。然后,我们通过 Parse WebHook 机制将 Parse 连接到在 Fn 上运行的银行路由函数。接着我们演示了如何使用函数,相对容易地调用现代基础设施,如区块链。我们模拟了两家银行,展示了函数的用例,一个作为 Glue 的函数,以及一个封装传统系统的函数。然后,我们演示了如何将函数与流代理结合使用,处理数据流。
所有三个 FaaS 平台都已连接在一起,并在同一个 Docker Swarm 集群上运行。将 OpenWhisk 和 OpenFaaS 函数连接在一起相当简单,但在 Fn 的情况下,稍显复杂,因为当前版本的 Fn 不允许你定义容器网络。
下一章将是本书的最后一章。我们将总结函数的概念,并展望 FaaS 世界之外的未来发展。
第九章:无服务器的未来
本章讨论了 FaaS 之外的未来发展。我们将首先讨论一种新型的实验性技术,通过引入 RunF(一种基于 libcontainer 的运行时,旨在调用不可变函数容器)来恢复容器运行时的速度。本章还将继续讨论使用 LinuxKit 为 FaaS 平台准备不可变基础设施的可能性。最后,我们将探讨一种新架构,将本地的 FaaS 架构与公共云上的无服务器架构结合起来。
在讨论这些话题之前,让我们首先总结一下迄今为止学到的内容。
本章将讨论以下主题:
-
FaaS 和 Docker 回顾
-
函数容器的运行时
-
LinuxKit – 为 FaaS 提供不可变基础设施
-
超越无服务器架构
-
声明式容器
FaaS 和 Docker 回顾
本书介绍了无服务器架构、FaaS 平台以及 Docker 与该技术的相关性。我们共同学习了如何在生产环境中设置 Docker Swarm 集群。
本书讨论了三个知名的 FaaS 平台,分别是OpenFaaS、OpenWhisk 和 Fn 项目。OpenFaaS 使用基于 Swarm 的调度器,而 OpenWhisk 和 Fn 使用自己的调度技术,在普通的 Docker 上运行。
然后,我们在第八章《将它们整合在一起》中展示了一个项目,介绍了如何通过在 Docker 集群的同一网络上运行这三个平台,将它们联通在一起。该项目展示了如何调用其他 FaaS 平台的服务。演示了用多种编程语言编写的函数,包括 Java、Go 和 JavaScript(Node.js)。
我们使用 Java 编写了一个简单的函数。对于现代编程模型,我们可以使用 RxJava 库来帮助编写响应式风格的 Java 程序,这非常适合事件驱动编程。
在 JavaScript 中,我们编写了一个基于 Chrome 的脚本进行连接。我们还部署了一个区块链,以展示它如何与 FaaS 计算模型良好配合。
在接下来的章节中,我们将讨论一些高级的、实验性的话题,这些话题深入或超出了当前无服务器架构和 FaaS 的范围。然而,其中一些可能很快会成为主流。
函数容器的运行时
容器生态系统中最重要的组件之一是容器运行时。在 Docker 的早期,运行时是 LXC,后来它被更改为 Docker 拥有的 libcontainer。libcontainer 后来被捐赠给了 OCI(开放容器倡议)项目,该项目由 Linux 基金会支持。之后,RunC 项目启动。RunC 是一个围绕 libcontainer 的命令行工具,允许开发者从终端启动容器。开发者可以通过调用 RunC 二进制文件并传递根文件系统和容器规范来启动容器。
RunC 是一个非常稳定的软件。自 Docker 1.12 版本以来就一直与 Docker 一起,并已被数百万用户使用。docker run 命令实际上会将其参数发送给另一个守护程序 containerd,后者将这些信息转换为 RunC 的配置文件。
运行 RunC 简化了依赖关系,我们只需一个单一的二进制文件,一个根文件系统和一个配置文件来启动一个容器。
由于 RunC 只是 libcontainer 的一个薄包装,其代码非常直接。如果具备一定的 Go 编程知识,直接使用 libcontainer 相对容易。RunC 的唯一缺点是它被设计和构建用于一般容器运行。在下一节中,我们将介绍 RunF,一个专门设计用于高效运行函数容器的最小运行时环境。
遇见 RunF
本节介绍了 RunF。它是 RunC 的对应物,专为运行不可变函数容器而设计。RunF 是一个实验性项目,使用 libcontainer 实现了一个新的运行时环境,以在只读和无根的环境中运行容器。使用 RunF 启动的容器预期在其他容器内部高效运行。RunF 允许通过将主机上的非 root 用户映射到容器内部的 root 用户 ID 来执行无根容器。
我们如何使用它?以下图表说明了场景。我们有一个 FaaS 平台,网关接受传入请求并将其转发给函数初始化程序。通过事件总线,函数执行器然后使用它来调用函数容器,而不是 Docker。通过这种架构,我们可以提高平台的整体性能:

图 9.1:展示了一个以 RunF 为运行时的 FaaS 架构的块图。
无根容器是允许在无 root 用户的情况下运行的容器,例如 AWS Lambda。我们希望有一个不可变的函数版本,具有只读和无根权限,因为无根容器使系统和基础架构更加安全。
然后是网络约束。一个函数不应该意识到任何与网络相关的配置。到目前为止,我们实现的所有当前 FaaS 平台都有这个限制。假设我们需要将一个正在运行的函数附加到某个网络,以使其正确工作,并能够解析其他依赖服务的名称。
在第八章《将它们全部结合起来》中,我们发现使功能容器在平台提供的任何网络下正常工作是很棘手的。RunF 旨在通过让功能容器使用外部容器的网络命名空间来解决这个问题。通过这种执行模型,功能代理 负责将自己附加到网络,而功能容器也会使用这些网络来访问其他服务。如果功能容器运行在功能代理的容器内部,所有的网络配置都可以被省略。
在性能方面,通过使用像 RunF 这样的特殊容器运行时,能够将所有必要的文件系统缓存到每个功能代理内,并使其变为不可变。这样,我们就可以实现与热函数机制类似的最高性能。
现在,让我们来看看实现中的内容,看看它是如何满足所有要求的:
-
不可变
-
无根
-
默认使用主机网络
-
零配置。
我们主要直接使用 libcontainer API。在这里,我们详细解释了 RunF 如何利用 libcontainer 实现功能容器的可变运行时。
程序通过初始化 libcontainer 开始,并使用 Cgroupfs 配置,表示 libcontainer 将使用 Cgroup 来控制进程的资源:
func main() {
containerId := namesgenerator.GetRandomName(0)
factory, err := libcontainer.New("/tmp/runf",
libcontainer.Cgroupfs,
libcontainer.InitArgs(os.Args[0], "init"))
if err != nil {
logrus.Fatal(err)
return
}
以下代码片段创建了一个配置。rootfs 的默认位置是当前目录下的 ./rootfs。我们将标志 Readonlyfs 设置为 true,以实现不可变文件系统。NoNewPrivileges 设置为 true,以防止进程获得任何新特权。Rootless 设置为 true,表示我们将把非根 UID 和 GID 映射到容器的根 ID。初始化标志后,我们设置进程的能力。以下是能力列表:
-
CAP_AUDIT_WRITE是写入内核审计日志的能力 -
CAP_KILL是进程发送信号的能力 -
CAP_NET_BIND_SERVICE是绑定套接字到特权端口的能力。
defaultMountFlags := unix.MS_NOEXEC | unix.MS_NOSUID | unix.MS_NODEV
cwd, err := os.Getwd()
currentUser, err := user.Current()
uid, err := strconv.Atoi(currentUser.Uid)
gid, err := strconv.Atoi(currentUser.Gid)
caps := []string{
"CAP_AUDIT_WRITE",
"CAP_KILL",
"CAP_NET_BIND_SERVICE",
}
config := &configs.Config{
Rootfs: cwd + "/rootfs",
Readonlyfs: true,
NoNewPrivileges: true,
Rootless: true,
Capabilities: &configs.Capabilities{
Bounding: caps,
Permitted: caps,
Inheritable: caps,
Ambient: caps,
Effective: caps,
},
Namespaces 属性是容器运行时最重要的设置之一。在此配置块中,我们将其设置为使用以下命名空间:NS、UTS(主机名和域名)、IPC、PID 和 USER。用户命名空间 NSUSER 是允许以无根模式运行容器的关键设置。我们省略了 NET 命名空间。原因是 runf 会在另一个容器内部启动一个功能容器,即功能执行器。如果没有 NET 命名空间的隔离,功能容器将与外部容器共享同一网络命名空间,从而能够访问附加到功能执行器网络的任何服务。
另一个设置是 Cgroup 设置。此设置允许对进程的资源进行层次控制。这个设置大多是默认配置:
Namespaces: configs.Namespaces([]configs.Namespace{
{Type: configs.NEWNS},
{Type: configs.NEWUTS},
{Type: configs.NEWIPC},
{Type: configs.NEWPID},
{Type: configs.NEWUSER},
}),
Cgroups: &configs.Cgroup{
Name: "runf",
Parent: "system",
Resources: &configs.Resources{
MemorySwappiness: nil,
AllowAllDevices: nil,
AllowedDevices: configs.DefaultAllowedDevices,
},
},
MaskPaths和ReadonlyPaths设置如下。此设置主要是为了防止运行中的进程对系统所做的更改:
MaskPaths: []string{
"/proc/kcore",
"/proc/latency_stats",
"/proc/timer_list",
"/proc/timer_stats",
"/proc/sched_debug",
"/sys/firmware",
"/proc/scsi",
},
ReadonlyPaths: []string{
"/proc/asound",
"/proc/bus",
"/proc/fs",
"/proc/irq",
"/proc/sys",
"/proc/sysrq-trigger",
},
所有设备都设置为自动创建。然后,Mount设置定义了一组需要从主机挂载到容器中的文件系统。对于 RunF,它是从函数执行器到函数容器的嵌套挂载:
Devices: configs.DefaultAutoCreatedDevices,
Hostname: containerId,
Mounts: []*configs.Mount{
{
Source: "proc",
Destination: "/proc",
Device: "proc",
Flags: defaultMountFlags,
},
{
Source: "tmpfs",
Destination: "/dev",
Device: "tmpfs",
Flags: unix.MS_NOSUID | unix.MS_STRICTATIME,
Data: "mode=755",
},
{
Device: "devpts",
Source: "devpts",
Destination: "/dev/pts",
Flags: unix.MS_NOSUID | unix.MS_NOEXEC,
Data: "newinstance,ptmxmode=0666,mode=0620",
},
{
Device: "tmpfs",
Source: "shm",
Destination: "/dev/shm",
Flags: defaultMountFlags,
Data: "mode=1777,size=65536k",
},
},
这是从主机 ID(HostID)到容器内部 ID(ContainerID)的 UID 和 GID 映射。在以下示例中,我们将当前用户 ID 映射到容器内root用户的 ID:
Rlimits: []configs.Rlimit{
{
Type: unix.RLIMIT_NOFILE,
Hard: uint64(1024),
Soft: uint64(1024),
},
},
UidMappings: []configs.IDMap{
{
ContainerID: 0,
HostID: uid,
Size: 1,
},
},
GidMappings: []configs.IDMap{
{
ContainerID: 0,
HostID: gid,
Size: 1,
},
},
}
我们使用 libcontainer 的工厂方法创建一个带有生成 ID 和我们已设置的config的容器:
container, err := factory.Create(containerId, config)
if err != nil {
logrus.Fatal(err)
return
}
然后,我们准备环境变量。它们只是一个字符串数组。每个元素是一个key=value对,表示我们希望为进程设置的每个变量。我们使用libcontainer.Process准备一个要运行的进程。进程的输入、输出和错误会被重定向到默认的标准对等端:
environmentVars := []string{
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"HOSTNAME=" + containerId,
"TERM=xterm",
}
process := &libcontainer.Process{
Args: os.Args[1:],
Env: environmentVars,
User: "root",
Cwd: "/",
Stdin: os.Stdin,
Stdout: os.Stdout,
Stderr: os.Stderr,
}
err = container.Run(process)
if err != nil {
container.Destroy()
logrus.Fatal(err)
return
}
_, err = process.Wait()
if err != nil {
logrus.Fatal(err)
}
defer container.Destroy()
}
然后我们将准备并构建runf二进制文件。这需要libcontainer及其他一些工具来构建。我们通常使用go get命令来完成。之后,只需使用go build命令进行构建:
$ go get golang.org/x/sys/unix
$ go get github.com/Sirupsen/logrus
$ go get github.com/docker/docker/pkg/namesgenerator
$ go get github.com/opencontainers/runc/libcontainer
$ go build runf.go
为了准备根文件系统,我们使用undocker.py和docker save命令。undocker.py脚本可以从github.com/larsks/undocker下载。
这是将根文件系统准备到rootfs目录的命令,来源于busybox镜像:
$ docker save busybox | ./undocker.py --output rootfs -W -i busybox
现在,让我们测试运行一些容器。我们将看到ls命令列出容器内的文件:
$ ./runf ls
bin dev etc home proc root sys tmp usr var
在 Docker 网络内
接下来,我们将尝试一些稍微复杂的操作,准备一个小系统,其外观类似于以下图示。场景是我们希望由runf启动的容器位于另一个容器wrapper-runf(实际上是一个函数执行器)内,连接到同一 Docker 网络上运行的一些网络服务,test_net:

图 9.2:在 Docker 网络中使用 RunF 的示例
诀窍是我们将标准 Docker Swarm 模式中的resolv.conf放置为./rootfs/etc/resolv.conf,使得嵌套容器内的进程能够解析附加 Docker 网络上的所有服务名称。以下是resolv.conf的内容:
search domain.name
nameserver 127.0.0.11
options ndots:0
然后,我们为wrapper-runf容器准备一个 Dockerfile:
FROM ubuntu:latest
RUN apt-get update && apt-get install -y curl
WORKDIR /root
COPY ./runf /usr/bin/runf
COPY rootfs /root/rootfs
COPY resolv.conf /root/rootfs/etc/resolv.conf
我们可以通过docker build命令正常构建它:
$ docker build -t wrapper-runf .
以下代码段是创建一个 Docker 网络的准备,接着将nginx连接到该网络,然后在其中运行一个带有/bin/bash的wrapper-runf容器。
最后,我们启动一个通过runf连接到nginx的嵌套容器:
$ docker network create -d overlay --attachable test_net
$ docker run -d \
--network=test_net \
--network-alias=nginx \
nginx
$ docker run --rm -it \
--network=test_net \
--privileged \
-v /sys/fs/cgroup:/sys/fs/cgroup \
wrapper-runf /bin/bash
/ # runf wget http://nginx
接下来是什么?
使用 runf,它可能是朝着另一个步骤——使用特殊运行时的快速不可变功能迈进的方式。您可以尝试实现一个代理容器,包装在 runf 周围,并使其在实际平台中运行函数。这被留作一个(稍微高级一点的)练习。
LinuxKit – FaaS 的不可变基础设施
LinuxKit 是一套用于准备不可变基础设施工具的集合。它旨在将容器组合成一个可供使用的操作系统。当然,LinuxKit 生成的操作系统是用于运行容器的。为了构建一个不可变且可扩展的 FaaS 平台基础设施,LinuxKit 是最好的选择之一。
以下是一个 LinuxKit YAML 文件示例,用于构建一个用于 Docker 的不可变操作系统。内核块表示该操作系统将使用 Linux 内核 4.14.23 启动。boot 命令和 cmdline 表示内核将在四个不同的 TTY 上启动控制台:
kernel:
image: linuxkit/kernel:4.14.23
cmdline: "console=tty0 console=ttyS0 console=ttyAMA0 console=ttysclp0"
init 块中声明的接下来的四个容器是将直接解压到文件系统中的基本程序。所有 init 级别的程序包括 runc 和 containerd。此外,CA 证书将直接安装到文件系统中,以便在接下来的 onboot 块声明的程序继续运行之前:
init:
- linuxkit/init:b212cfeb4bb6330e0a7547d8010fe2e8489b677a
- linuxkit/runc:7c39a68490a12cde830e1922f171c451fb08e731
- linuxkit/containerd:37e397ebfc6bd5d8e18695b121166ffd0cbfd9f0
- linuxkit/ca-certificates:v0.2
onboot 块和 mountie 命令将自动将第一个可用分区挂载到 /var/lib/docker。请注意,LinuxKit 仅允许您挂载到 /var 目录下的目录:
onboot:
- name: sysctl
image: linuxkit/sysctl:v0.2
- name: sysfs
image: linuxkit/sysfs:v0.2
- name: format
image: linuxkit/format:v0.2
- name: mount
image: linuxkit/mount:v0.2
command: ["/usr/bin/mountie", "/var/lib/docker"]
services 块声明了系统容器,它们作为长期运行的服务。这些服务都是由容器运行和维护的,由 init 块中的 init 进程启动。
在此块中声明的服务可以按任意顺序启动。
在以下示例中,docker 是其中一个服务。使用的 Docker 镜像 docker:17.09.0-ce-dind 用于运行这个 Docker 服务。该服务运行在主机网络上。这与 RancherOS 的概念基本相同。由 docker 服务运行的 dockerd 实例是用户级的容器管理系统,而 init 块中的 containerd 是系统级的容器管理系统。其他系统容器包括 rngd——随机数生成守护进程,dhcpd——DHCP 服务,ntpd——用于同步机器时钟的 OpenNTPD 守护进程等:
services:
- name: getty
image: linuxkit/getty:v0.2
env:
- INSECURE=true
- name: rngd
image: linuxkit/rngd:v0.2
- name: dhcpcd
image: linuxkit/dhcpcd:v0.2
- name: ntpd
image: linuxkit/openntpd:v0.2
- name: docker
image: docker:17.09.0-ce-dind
capabilities:
- all
net: host
mounts:
- type: cgroup
options: ["rw","nosuid","noexec","nodev","relatime"]
binds:
- /etc/resolv.conf:/etc/resolv.conf
- /var/lib/docker:/var/lib/docker
- /lib/modules:/lib/modules
- /etc/docker/daemon.json:/etc/docker/daemon.json
command: ["/usr/local/bin/docker-init", "/usr/local/bin/dockerd"]
文件块用于声明我们希望在不可变文件系统中拥有的文件或目录。在以下示例中,我们声明了 /var/lib/docker 并创建了 Docker 的守护进程配置 /etc/docker/daemon.json,其中包含内容 {"debug": true}。这些文件将在镜像构建阶段创建:
files:
- path: var/lib/docker
directory: true
- path: etc/docker/daemon.json
contents: '{"debug": true}'
trust:
org:
- linuxkit
- library
我们有另一个files块的例子。这是将公钥放入文件系统镜像的标准方法。mode属性用于设置复制文件到最终镜像时的文件模式。在这个例子中,我们要求公钥文件的模式为0600。通过这个配置和正在运行的sshd服务,我们将被允许远程 SSH 进入机器:
files:
- path: root/.ssh/authorized_keys
source: ~/.ssh/id_rsa.pub
mode: "0600"
optional: true
这是构建 LinuxKit 命令行的步骤:
$ go get -u github.com/linuxkit/linuxkit/src/cmd/linuxkit
如果我们已经使用 GVM 安装了 Go 编程语言,那么二进制文件将可用来运行。
我们将构建一个 Docker 操作系统,详见github.com/linuxkit/linuxkit/blob/master/examples/docker.yml:
$ linuxkit build docker.yml
Extract kernel image: linuxkit/kernel:4.14.26
Pull image: docker.io/linuxkit/kernel:4.14.26@sha256:9368a ...
...
Add files:
var/lib/docker
etc/docker/daemon.json
Create outputs:
docker-kernel docker-initrd.img docker-cmdline
超越无服务器
混合无服务器将是一种将混合云与无服务器部署模型连接的部署模式。它已经由 IT 供应商开始提供硬件租赁服务,以私有云的形式部署到客户的组织中,并按需计费。
当无服务器和 FaaS 计算平台部署在这种混合基础设施之上时,它们就变成了混合无服务器。这可能是下一代计算平台,允许你将敏感数据存储在组织内部,运行一些重要的 FaaS 功能在本地系统中,同时利用按请求付费的额外计算资源。如果客户的组织不需要维护或管理任何硬件服务器,它将符合无服务器的定义。幸运的是,当将这种模式与我们在本书中讨论的内容混合时,使用 Docker 作为基础设施仍然适用于这种架构。Docker 仍然是平衡自主维护基础设施和让无服务器平台为我们做剩余工作的良好选择。
在下图中,整体系统展示了一种混合架构。如果仅在组织内部使用 FaaS 平台,请求将首先发送到本地基础设施。当负载增大时,函数执行实例会横向扩展,最终爆发到公共云基础设施。然而,数据存储通常放置在组织内部。因此,外部函数执行器必须能够访问这些数据,就像它们在本地运行一样:

图 9.3:FaaS 的混合架构
声明式容器
声明式容器可以被看作是一种介于普通容器和运行在 FaaS 上的容器之间的技术。让我们看一下下面的假想 Dockerfile:
FROM openjdk:8
COPY app.jar /app/app.jar
CMD ["/opt/jdk/bin/java", "-Xmx2G", "-jar", "/app/app.jar"]
我们在这里看到了什么?第一次阅读时,它看起来像是一个普通的 Dockerfile。没错,就是它。但这并不是一种声明式的方式来定义应用容器。为什么?
我们已经知道这是一个 Java 应用程序在为我们做一些工作。但它硬编码了一些重要且脆弱的配置,例如,当 openjdk:8 锁定应用程序只使用那个实现时,而 -Xmx2G 限制了应用程序的内存使用。
目前所有的 FaaS 平台都以相同的方式使用容器。它们将一些特定的配置绑定到函数容器中,但实际上人们需要的是一种非常中立且可移植的方式来部署函数。
那么,声明式容器是什么样的?
它看起来像这样:
FROM scratch
COPY app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]
你可能会认为目前无法在任何运行时环境中运行这个容器。答案是你是对的。但我仍然认为应用程序应该以相同的方式声明。我们应该尽可能地将所有脆弱的配置从 Dockerfile 中移除。然后,我们应该让一个新的实体,可能是在容器引擎内部,来管理应用程序周围的环境。
例如,拦截容器创建过程并计算容器允许的内存限制(例如通过 docker run -m)并将该值传递给 java 命令行以在应用程序层面限制内存,这相对容易。负责这类工作的容器引擎内部实体被称为 应用配置文件管理器,如下图所示:

图 9.4:带有应用配置文件管理器的容器引擎
像这样的交叉概念并不新鲜。我们已经有一个类似的概念应用于 Docker。你猜怎么着?那就是安全性问题。Docker 已经对每个运行中的容器应用了默认的 AppArmor 配置文件,并启用了 AppArmor 子系统。这就是安全性问题。这是在更应用特定的层面上的关注,那么为什么我们不能有一个类似的概念来帮助让我们的生活更轻松呢?
在这个概念下,容器镜像将变成声明式容器,因为没有为它们硬编码任何特定的环境或配置。而 应用配置文件管理器 负责选择性地为容器应用合适的配置文件,并使其正常工作。
声明式容器的实际好处是什么?这里有一个我们之前讨论的 Java 应用的具体解释。
在 Java 的世界中,应用架构已经被设计成解耦应用程序和运行时环境。由于 JVM 的强大规范,运行应用程序的 JVM 总是可以被交换和替换。例如,如果我们开始使用 OpenJDK 运行应用程序,并且对其性能不满意,我们可以安全地将 JVM 更换为 Zulu JVM 或 IBM J9。
通过声明式容器的方法,Java 运行时可以在不重新构建 Docker 镜像的情况下动态切换。这还允许你对运行中的系统应用 JVM 热修复。
我们可以从github.com/joconen/engine下载带有此声明式特性的修改版 Docker,以便为 Java 使用。
练习
现在是时候回顾本章的所有概念了:
-
你认为在无服务器时代之后,未来会有什么发展?
-
你可能在想的下一代计算是什么?
-
libcontainer 的特性是什么,允许无根执行?
-
Linux 提供了哪些命名空间?
-
解释为什么 RunF 在其他容器内部运行时能够访问网络服务。
-
使用 LinuxKit 准备基础设施有什么好处?
-
容器的声明式方法是什么?它如何应用到 Java 以外的其他应用平台?
-
当我们希望访问来自组织外部的服务时,如何设计一个混合的无服务器架构?
总结
本章通过讨论我们可以使用什么来推动 FaaS 的发展,结束了这本书。我们回顾了在 Docker 和其上运行的三个主要 FaaS 平台上的经历。
Docker 是一个很好的基础设施,因为考虑到这三个 FaaS 平台实际上都在使用 Docker 的直接特性,而不是仅仅依赖它的调度器功能。为什么?也许是因为 FaaS 计算模型适合这种简单的基础设施,而不是复杂的基础设施。
如果我们可以简单地执行docker run,然后容器被转化为一个 FaaS 功能,在集群的某个地方提供它的功能呢?功能包装器、动作代理或功能监视器可以注入到一个简单的容器中,通过标准 I/O 处理输入和输出,并将其转变为一个在线功能。然后,一种神奇的基础设施将为我们处理一切。我们正逐步迈向这一现实。
参考文献
-
Apache Foundation. Apache OpenWhisk。可在:
openwhisk.apache.org/.(访问日期:2018 年 3 月 28 日)。 -
Microsoft Corp. Azure Functions—无服务器架构 | Microsoft Azure。可在:
azure.microsoft.com/en-us/services/functions/.(访问日期:2018 年 3 月 28 日)。 -
Burns, B., Grant, B., Oppenheimer, D., Brewer, E. & Wilkes, J. Borg, Omega, 和 Kubernetes。Queue 14, 10:70–10:93(2016)。
-
Schickling, J., Lüthy, M., Suchanek, T. 等人 chromeless: 简化的 Chrome 自动化。(Graphcool,2018)。
-
Google Inc. 概念 | 云功能。Google Cloud 可在:
cloud.google.com/functions/docs/concepts.(访问日期:2018 年 3 月 28 日)。 -
Crosby, M., Day, S., Laventure, K.-M., McGowan, D. 等人 containerd: 一个开放且可靠的容器运行时。(containerd,2018)。
-
Docker Inc. Docker。(2018)。可在:
www.docker.com/.(访问日期:2018 年 3 月 28 日)。 -
Smith, R. Docker 调度。(Packt Publishing Ltd,2017)。
-
Merkel, D. Docker:轻量级 Linux 容器,确保开发和部署的一致性。Linux J. 2014 年,(2014 年)。
-
Go 社区。文档 - Go 编程语言。可在以下链接查看:
golang.org/doc/.(访问日期:2018 年 3 月 30 日)。 -
The Linux Foundation. Envoy Proxy - 首页。可在以下链接查看:
www.envoyproxy.io/.(访问日期:2018 年 4 月 1 日)。 -
以太坊基金会。以太坊项目。可在以下链接查看:
www.ethereum.org/.(访问日期:2018 年 3 月 30 日)。 -
Avram, A. FaaS、PaaS 和无服务器架构的好处。从‘InfoQ’检索
www.infoq.com/news/2016/06/faasserverless-architecture于2016 年 6 月 28 日检索。 -
Oracle Inc. Fn 项目 - 容器原生的无服务器框架。可在以下链接查看:
fnproject.io/.(访问日期:2018 年 3 月 28 日)。 -
Arimura, C., Reeder, T. & 等人。Fn:容器原生、云无关的无服务器平台。(Oracle 公司,2018 年)。
-
Google Inc. Google Cloud Functions 文档 | Cloud Functions. Google Cloud 可在以下链接查看:
cloud.google.com/functions/docs/.(访问日期:2018 年 3 月 28 日)。Kaewkasi, C. & Chuenmuneewong, K. 使用蚁群优化改进 Docker 容器调度。在 知识与智能技术(KST),2017 年第九届国际会议 254–259 (IEEE,2017 年)。 -
Apache Foundation. incubator-openwhisk:Apache OpenWhisk 是一个无服务器的基于事件的编程服务,也是一个 Apache 孵化项目。(Apache 软件基金会,2018 年)。
-
Cormack, J. & 等人。linuxkit:用于构建安全、便携和精简容器操作系统的工具包。(LinuxKit,2018 年)。
-
Janakiraman, B. Martin Fowler 的 Bliki:无服务器。martinfowler.com(2016 年)。可在以下链接查看:
martinfowler.com/bliki/Serverless.html.(访问日期:2018 年 3 月 28 日)。 -
Sharma, S. 使用 Java 9 精通微服务。(Packt Publishing Ltd,2017 年)。
-
Moby Community, The. Moby. GitHub 可在以下链接查看:
github.com/moby.(访问日期:2018 年 3 月 30 日)。 -
Moby Community, The. moby:Moby 项目 - 一个容器生态系统的协作项目,用于组装基于容器的系统。(Moby,2018 年)。
-
Jones, D. E. & 等人。Moqui 生态系统。可在以下链接查看:
www.moqui.org/.(访问日期:2018 年 3 月 30 日)。 -
Soppelsa, F. & Kaewkasi, C. 使用 Swarm 实现原生 Docker 集群。(Packt Publishing - 电子书帐户,2017 年)。
-
Marmol, V., Jnagal, R. & Hockin, T. 容器和容器集群中的网络。netdev 0.1 会议论文集,2015 年 2 月(2015 年)。
-
Ellis, A. OpenFaaS - 为 Docker 和 Kubernetes 简化的无服务器函数。(OpenFaaS,2018 年)。
-
亚马逊网络服务公司(AWS)。通过无服务器架构优化企业经济学。(2017 年)。
-
Parse Community, The。Parse + 开源。Parse 开源平台 可访问:
parseplatform.org/.(访问日期:2018 年 3 月 30 日)。 -
Vilmart, F. 等人。parse-server:兼容 Parse 的 API 服务器模块,适用于 Node/Express。(Parse,2018 年)。
-
Linux 基金会。runc:根据 OCI 规范生成和运行容器的 CLI 工具。(开放容器倡议,2018 年)。
-
Christensen, B., Karnok, D. 等人。RxJava – JVM 的反应式扩展 – 用于在 Java 虚拟机上使用可观察序列来编写异步和基于事件的程序的库。(ReactiveX,2018 年)。
-
Roberts, M. 无服务器架构。martinfowler.com(2016 年)。可访问:
martinfowler.com/articles/serverless.html.(访问日期:2018 年 3 月 28 日)。 -
Baldini, I. 等人。无服务器计算:当前趋势与开放问题。见云计算研究进展,1-20 页(Springer,2017 年)。
-
GOTO Conferences。无服务器:软件架构的未来——由 Peter Sbarski 讲述。(2017 年)。
-
Fox, G. C., Ishakian, V., Muthusamy, V. & Slominski, A. 无服务器计算与函数即服务(FaaS)在行业与研究中的现状。arXiv 预印本 arXiv:1708.08028(2017 年)。
-
Docker Inc. Swarm 模式概述。Docker 文档(2018)。可访问:
docs.docker.com/engine/swarm/.(访问日期:2018 年 3 月 28 日)。 -
Kaewkasi, C. 等人。Docker Swarm 2000 协作项目。(SwarmZilla 协作项目,2016 年)。
-
Containous。Træfik。可访问:
traefik.io/.(访问日期:2018 年 4 月 1 日)。 -
Lubin, J. 等人。Truffle Suite - 你的以太坊瑞士军刀。Truffle Suite 可访问:
truffleframework.com.(访问日期:2018 年 3 月 30 日)。 -
Weaveworks Inc. Weave Net:跨环境的网络容器 | Weaveworks。可访问:
www.weave.works/oss/net/.(访问日期:2018 年 3 月 30 日)。


浙公网安备 33010602011771号