物联网 (IoT) 变得轻松——使用 Azure 构建 IoT 平台





2.00/5 (1投票)
利用 Azure 构建大规模连接 IoT 设备的平台。
引言
问题:今年电子展上展示了许多物联网设备——从汽车到智能镜子再到婴儿奶瓶——其中大多数都使用了不同的技术和标准。一方面,市面上有大量新的物联网设备可供玩耍;另一方面,开发者如何弄清楚哪些标准(或缺乏标准)能够以有意义的方式将所有这些设备连接起来,让潜在的最终客户觉得物有所值,这令人困惑。
解决方案:要开始解决这个挑战,我们首先必须承认,没有一个单一的程序可以连接所有这些物联网设备;这在很大程度上是由于物联网的早期发展以及生产这些设备的行业的固有保护主义。然而,这并不意味着我们不能为想要控制一个或多个物联网设备的开发者构建一个易于使用、安全、快速且经济的平台。有了合适的平台,开发者就可以创建针对他们想要控制的特定物联网设备的接口程序,而不会被物联网平台的技术挑战和成本所困扰。合适的物联网开发者平台将包括监控网络和服务的可用性、托管数据、可伸缩性、操作连续性、报告、物联网的统一呈现、安全性以及数据中心补丁引起的停机——仅举几例。
在本文中,我们将解释如何在 Azure 上为开发者创建一个可伸缩的物联网平台,以及我们如何使用 Chef 来大规模部署即插即用计算器并应用更新。
背景
基本概念
概念上,如果我们想控制一个智能咖啡机,接口程序最基本的功能就是开关它。假设这是唯一需要的功能,最简单的接口程序大致会包含以下组件:
- 一个本地程序,可以识别新的和现有的咖啡机;该程序位于咖啡机所在的网络本地,并在运行 Debian 操作系统的即插即用计算机上运行。
- 本地程序监控智能咖啡机和网络上的所有其他智能设备,并确定它们是否在线。
- 本地程序通过 Azure 云服务器上的 REST 服务将咖啡机信息发送给用户,例如咖啡机是否开/关、温度等。
- 服务器支持的 Web 门户或应用程序允许用户控制咖啡机(开关)。
- 本地程序中的业务逻辑组件负责根据 #4 中的用户输入来打开/关闭咖啡机。
即使在上述简单的步骤序列中,开发者也必须处理一系列技术和复杂性,例如网络、Web 服务、数据存储、可伸缩性、Web 门户或应用程序编程,以及可能更多。将这些复杂性与数百甚至数千个物联网设备相结合,物联网开发者很快就会不知所措。
我们的公司考察了典型物联网程序所需的所有组件,提取了大多数物联网程序中普遍存在的元素,并构建了一个平台,使开发者能够专门专注于编写物联网设备的接口程序。为了解释平台的工作原理,我们将继续以之前提到的咖啡机为例。在接下来的解释中,“咖啡机接口程序”是开发者唯一需要编写的代码,以便在有互联网连接的任何地方使用物联网设备。
- 我们首先在“即插即用计算机”上创建一个客户端程序,当插入网络时,它会自动配置自身,监控所有网络设备,注册它们,甚至在它们的 Internet 地址发生变化时也能跟踪它们。
- 然后,客户端程序执行由任何开发者开发的“咖啡机接口程序”。在这种情况下,“咖啡机接口程序”位于插入即插即用计算机的可移动 USB 驱动器上;我们的平台读取并安装它,使其准备就绪。
- “咖啡机接口程序”与我们的客户端程序交换有关咖啡机的特定信息,然后我们的客户端程序通过 Azure 上托管的服务发送这些信息。
- 通过 REST 服务接收到的加密信息会在 Azure 服务总线队列中创建消息。
- 一系列“工作角色”读取并解密消息,并将它们存储在 Blob 存储中,元数据保存在 Azure SQL 数据库中。
- 传统的 Web 门户和移动网站向最终用户显示咖啡机的信息。
为了让客户端程序在即插即用计算机上执行其接口程序,开发者需要做以下工作。开发者在我们的 Web 门户上注册“咖啡机接口程序”,告知我们咖啡机程序名称以及需要与客户端程序交换的信息。开发者还定义了一系列与咖啡机相关的命令,这些命令允许最终用户通过 Web 门户和移动网站控制咖啡机。以下是一个关于如何注册第三方程序的示例:
一旦发生此注册并将可执行文件加载到即插即用计算机中,我们的平台会自动从用户文件夹路径读取数据,并在树状视图和地图视图中创建数据。当在树状视图中单击相应的物联网设备时,我们可以看到数据从咖啡机的接口程序发送过来。以下是树状视图的一个示例:
在地图视图中,我们看到数千台即插即用计算机和数万台物联网设备。以下示例演示了我们的地图视图如何显示大量设备并通过颜色突出显示需要注意的设备。当我们需要在地理上监控不同位置的设备时,此视图尤其有用。经过性能调优和测试,我们现在可以确认,在小型 Azure 服务器和 S2 级数据库上显示 2000 台即插即用计算机和数百万个交互式数据点需要不到 12 秒的时间。使用更快的数据库的大型 Azure 服务器可以显示相同数量的数据点,速度快 4-5 倍。这意味着如果您管理的是全球范围的设备,一眼就能知道哪里发生了什么事情以及为什么它们需要您的关注。
以下是平台的基本架构:
优势
使用我们的平台时,开发者需要注意四件事:
- 编写物联网设备的接口程序,并将接口程序放在可移动的 USB 驱动器上。
- 通过指定程序名称和信息交换来在我们的平台上注册每个物联网接口程序。
- 定义接口程序可以用来控制物联网的一系列命令。
- 将可显示的数据发送到 Web 门户。
这是平台提供的功能:
- 监控物联网设备的网络状态,并在网络或单个物联网设备离线时向最终用户发送警报。
- 根据安全补丁、软件升级等要求,自动配置、更新和重新配置即插即用计算机和物联网接口程序。
- 通过利用 Azure 的功能来确保业务连续性,例如可伸缩性、数据恢复、网络和服务冗余、多区域的服务总线冗余,以及在网络中断期间在即插即用计算机上进行离线存储,当网络连接恢复时会自动同步到云端。
- 确保发送到云端的物联网数据的性能和可伸缩性,这是并非所有开发者都具备的能力。我们的平台可以轻松接收来自大型分布式网络的大量消息。换句话说,我们可以连接到数千台即插即用计算机,每台计算机都可以托管多个第三方物联网接口程序。如果有人想读取和/或控制大规模部署的物联网设备,所有管道都已就绪,他们需要做的就是获取许多即插即用计算机并将物联网接口程序加载到其中。
- 以树状视图和地图视图的形式向所有物联网设备提供统一视图,能够同时为单个客户显示数千台即插即用计算机和数万台物联网设备。
- 通过传输中的数据加密/解密和敏感数据(如密码)的静态加密来提供安全性。
使用代码
本文末尾展示的代码是一个示例,说明如何构建一个页面来为使用 wget 接收文件的 Chef 客户端提供可下载文件。您可以将整个代码粘贴到 aspx 页面中。只需记住使用正确的容器名称(在本例中称为“cookbooks”)来设置 Azure blob。
关注点
我们如何利用 Azure
Azure 不仅能够简单便捷地开发和部署 Web 服务、后台工作程序和网站;它还提供了服务总线队列、事件中心、云数据库以及出色的可伸缩性和数据备份/恢复功能。
在 Azure 产品中,服务总线和事件中心都提供了扩展物联网设备传入数据能力。事件中心将提供更简单的实现,并且是引入物联网数据的首选途径,因为它比服务总线具有更高的可伸缩性,且代码量更少。我们最初使用了服务总线,因为我们有先前的知识,但我们已经开始转向事件中心,以便能够用更少的代码更轻松地进行扩展。我们接下来的讨论将侧重于服务总线,但请注意事件中心同样有效。
经过在 Azure 产品上的大量实践和实验,我们发现可以通过利用 Azure 创建队列的便捷性来提高业务连续性。以下是我们平台如何利用 Azure 提供业务连续性的简单示例,通过服务总线队列,以及我们如何通过在所有 Azure 数据中心中进行队列轮询来实现高可用性。一旦您理解了其实现方式,该逻辑和技术可以轻松地扩展到事件中心。
为什么我们需要在 Azure 服务总线队列中提供高可用性 (HA)?
首先,让我澄清一些术语,以便后续段落更容易阅读。对于需要严格 FIFO 顺序的业务关键数据(如警报),我们利用服务总线中的队列。我们使用 REST 服务将消息(来自物联网设备的信息)放入队列,并利用一系列在工作角色上运行的后台处理器从队列中接收消息。接下来,我将交替使用后台处理器和工作角色。
现在,回到为什么应该考虑服务总线队列和事件中心的高可用性。Azure 服务总线在保持运行/停止 SLA 的同时,可能会出现性能下降。当这种情况发生时,数据要么难以进入队列,要么难以从队列中取出。如果您使用 REST 服务将消息存入队列,这些服务将开始失败。尽管我们的即插即用计算机将进入离线模式,并在 REST 服务恢复可用之前本地存储数据;一个更具弹性的解决方案是自动切换到不同的区域。这是可能的,因为大多数时候,Azure 服务总线队列不会在全球范围内失效。因此,我们需要解决的问题是:“当队列处理出现问题时,如何自动故障转移到不同的区域?”
废话不多说,以下是我们如何通过全球数据中心的轮询来故障转移队列。首先,我们必须了解,只要程序知道队列的命名空间和颁发者名称/密钥,它就可以创建自己的队列。虽然队列创建代码非常简单,但关键在于协调队列的发送方和接收方,使它们能够使用具有正确协调的动态创建的队列。
来自物联网设备的信息应按顺序存储在数据库中,这意味着如果从队列中检索消息时出现问题,消息顺序必须在消息被排空之前得到保留。发生这种情况时,我们不应该继续将消息放入有问题的队列,而且在许多情况下,当我们无法从队列中取出消息时,我们也无法再将消息放入队列了。
解决此问题的关键遵循以下步骤:
- 后台处理器将确定队列是否存在问题。
- 在停机期间,后台处理器将通知 REST 服务在另一个区域动态创建自己的队列,同时后台处理器继续监听有问题的队列,并尝试排空队列中的所有消息,直到所有消息正常排空或全部进入死信队列。这可能需要几分钟到几个小时,具体取决于停机情况。
- 在执行步骤 2 的同时,REST 服务正在将消息放入不同区域新创建的队列中,并且这些队列保证不会与有问题的队列位于同一数据中心。
- 如果 REST 服务无法将消息放入队列,它将通知其相应的后台处理器,它将在另一个区域创建一个新队列。
- 此过程将一直持续,直到 REST 服务用尽所有可能创建队列的数据中心,此时将需要人工干预,或者找到一个它能够创建队列并在其中无问题运行的数据中心。
- 后台处理器在完成步骤 2 后,将开始跟踪 REST 服务的路径,并转到 REST 服务创建的每个队列进行排空,直到最终到达 REST 服务当前运行的同一个队列。
遵循以上 6 个步骤,我们保证以下结果:
- 保留消息顺序,因为后台处理器不会放弃队列中的任何消息。
- 实现高可用性,因为我们可以故障转移到全球不同地区的队列。
- 保持分布式即插即用计算机的操作完整性,使其能够继续发送消息而不进入离线模式。
- 最小化支持,因为问题会自动解决,而不会产生支持电话。同时发生在所有 Azure 数据中心的停机情况非常罕见。
实现
一旦您理解了这 6 个步骤的逻辑工作原理,实际实现就非常直接了。秘密是使用一个数据源(例如 SQL 数据库或表存储),REST 服务和后台处理器都可以访问它,并将它们绑定在一起。这是必要的,因为 REST 服务和后台处理器无法以其他方式通信。以下是一些说明我们如何做到的细节:
在 Azure SQL 数据库中,我们注册了一组要使用的服务总线命名空间、颁发者名称和颁发者密钥(我们称之为三元组)。每个三元组集必须来自不同的数据中心。
每个三元组的值集还需要一个优先级,因为它允许 REST 服务在有多个命名空间可用时知道首先在哪里创建队列。优先级是基于成本、性能、可支持性来确定的。例如,如果您的主要业务在北美,您应该将北美数据中心的排名排得更高。这样做可以最大限度地减少延迟,并可能降低成本(跨区域发送数据成本更高)。
在下表中,我们将即插即用计算机、REST 服务、后台处理器和队列三元组配对。当发生故障转移情况时,系统的不同部分(如 REST 服务和后台处理器)可以通过修改表来相互通信。
“Failover”位可以设置为控制是否应发生故障转移。RestServiceQueueNameSpaceID 显示 REST 服务当前使用的三元组,如果它与 QueueNamespaceID 不同,则表示正在进行故障转移,REST 服务已开始使用与后台处理器不同的队列。
使用 Azure 的另一个有趣方式是将 Azure 与 Chef 结合,以大规模部署数千台即插即用计算机的系统。以下是导致需要将 Chef 与 Azure 结合使用的操作需求。如果一台即插即用计算机位于纽约,而开发者居住在加利福尼亚,开发者如何将操作系统更新应用到该即插即用计算机?是的,也许可以远程登录到即插即用计算机,但想象一下将这种方法扩展到数千台即插即用计算机。如果开发者将其接口程序销售给成千上万的客户,并且所有客户都需要对即插即用计算机应用安全补丁,会发生什么?您可以使用 Chef 和 Azure 解决这个可伸缩性问题。
Chef 是 DevOps 领域的一个绝佳工具,它帮助我们实现“配置即代码”的范例。理想情况下,我们可以使用托管 Chef,其中 OpsCode 为我们托管服务器,并在每台即插即用计算机上安装 Chef 客户端来满足上述操作需求。然而,托管 Chef 的成本过高,因此我们考虑了另一种选择:在 Azure 上自己托管 Chef 服务器,而托管成本完全免费。您可以在此处阅读如何设置:https://downloads.chef.io/chef-server/。
有了第二种选择,我们又遇到了另一个问题。为了确保高可用性,我们必须为每个客户端安装支付每月 6 美元的费用。如果我们需要管理 3,000 台即插即用计算机,这将意味着每月高达 18,000 美元。为了解决这个问题,我们考虑了另一个选项,即我们的第三个选项。
Chef 客户端可以独立运行,并定期访问 REST 服务以下载 cookbooks、脚本和可执行的接口程序。我们将 cookbooks、脚本和可执行文件存储在 Azure blob 存储中,并使用网页来检索它们。例如,一个名为 Cookbooks 的 blob 被设置为托管 Chef Cookbooks。
当 Chef 客户端通过网页(通过 wget)拉取 blob 中的 cookbooks 时,它需要获取可下载文件。这个概念很简单,但要让 .net REST 服务返回可下载文件需要一些编码。以下是实现方法:
DownloadBlob 允许我们连接到 blob 并将信息下载到内存流中,如下所示:
public partial class Cookbooks : System.Web.UI.Page { string containerName = "cookbooks"; ListhyperlinkList = new List (); protected void Page_Load(object sender, EventArgs e) { String currurl = HttpContext.Current.Request.RawUrl; String querystring = null; // Check to make sure some query string variables // exist and if not add some and redirect. int iqs = currurl.IndexOf('?'); if (iqs == -1) { String redirecturl = currurl; Response.Redirect(redirecturl, true); } // If query string variables exist, put them in // a string. else if (iqs >= 0) { querystring = (iqs < currurl.Length - 1) ? currurl.Substring(iqs + 1) : String.Empty; } // Parse the query string variables into a NameValueCollection. NameValueCollection qscoll = HttpUtility.ParseQueryString(querystring); string blobpassword = RoleEnvironment.GetConfigurationSettingValue("blobpassword"); string oldblobpassword = RoleEnvironment.GetConfigurationSettingValue("oldblobpassword"); // Retrieve an object that points to the local storage resource. LocalResource localResource = RoleEnvironment.GetLocalResource(containerName); bool success = true; if (qscoll["blobpassword"].Equals(blobpassword) || qscoll["blobpassword"].Equals(oldblobpassword)) { success = DownloadBlob(qscoll, success, containerName); if (!success) { DownloadFailed(); } } else { DownloadFailed(); } } private bool DownloadBlob(NameValueCollection qscoll, bool success, string containerName) { if (qscoll["file"] != null) { var storageAccount = CloudStorageAccount.FromConfigurationSetting("DataConnectionString"); // Create the blob client. CloudBlobClient blobClient = storageAccount.CreateCloudBlobClient(); // Retrieve reference to a previously created container. CloudBlobContainer blobContainer = blobClient.GetContainerReference(containerName); string filename = Request["file"].ToString(); // Retrieve reference to a blob named "photo1.jpg". CloudBlockBlob blockBlob = blobContainer.GetBlockBlobReference(filename); // Save blob contents to memory stream try { using (Stream memStream = new MemoryStream()) { blockBlob.DownloadToStream(memStream); fileDownload(filename, memStream); } } catch (Exception ex) { var error = ex.Message.ToString(); success = false; } } return success; } private void DownloadFailed() { Response.ClearHeaders(); Response.ClearContent(); Response.Status = "500 no file available to download"; Response.StatusCode = 500; Response.StatusDescription = "An error has occurred"; Response.Flush(); throw new HttpException(500, "An internal error occurred in the Application"); } private CloudBlobContainer GetContainer() { return SetContainerAndPermissions(); } private CloudBlobContainer SetContainerAndPermissions() { try { //Creating the container var account = CloudStorageAccount.FromConfigurationSetting("DataConnectionString"); var client = account.CreateCloudBlobClient(); CloudBlobContainer blobContainer = client.GetContainerReference(containerName); blobContainer = client.GetContainerReference(containerName); blobContainer.CreateIfNotExist(); return blobContainer; } catch (Exception ex) { throw new Exception("Error while creating the containers" + ex.Message); } } private void fileDownload(string fileName, Stream memStream) { Page.Response.Clear(); bool success = ResponseFile(Page.Request, Page.Response, fileName, memStream, 1024000); //Page.Response.End(); } public static bool ResponseFile(HttpRequest _Request, HttpResponse _Response, string _fileName, Stream _memStream, long _speed) { try { BinaryReader br = new BinaryReader(_memStream); try { _Response.AddHeader("Accept-Ranges", "bytes"); _Response.Buffer = false; long fileLength = _memStream.Length; long startBytes = 0; int pack = 10240; //10K bytes int sleep = (int)Math.Floor((double)(1000 * pack / _speed)) + 1; if (_Request.Headers["Range"] != null) { _Response.StatusCode = 206; string[] range = _Request.Headers["Range"].Split(new char[] { '=', '-' }); startBytes = Convert.ToInt64(range[1]); } _Response.AddHeader("Content-Length", (fileLength - startBytes).ToString()); if (startBytes != 0) { _Response.AddHeader("Content-Range", string.Format(" bytes {0}-{1}/{2}", startBytes, fileLength - 1, fileLength)); } _Response.AddHeader("Connection", "Keep-Alive"); _Response.ContentType = "application/octet-stream"; _Response.AddHeader("Content-Disposition", "attachment;filename=" + HttpUtility.UrlEncode(_fileName, System.Text.Encoding.UTF8)); br.BaseStream.Seek(startBytes, SeekOrigin.Begin); int maxCount = (int)Math.Floor((double)((fileLength - startBytes) / pack)) + 1; for (int i = 0; i < maxCount; i++) { if (_Response.IsClientConnected) { _Response.BinaryWrite(br.ReadBytes(pack)); Thread.Sleep(sleep); } else { i = maxCount; } } } catch (Exception ex) { return false; } finally { br.Close(); _memStream.Close(); } } catch (Exception ex) { return false; } return true; } }
一旦即插即用计算机上的 Chef 客户端收到 cookbook、支持脚本和可执行文件;它就可以升级任何地方的即插即用计算机,对要管理的客户端数量几乎没有限制。更好的是,成本可以忽略不计。
结束语
我们现在正将精力集中在利用 Azure 技术创建一个平台,为所有开发者提供一个平台。该平台提供监控、安全、易用性和配置、可伸缩性和数据连续性。最重要的是,它使开发者能够专注于构建出色的物联网设备接口,而不是进行企业平台开发。
历史
2015/3/25 - 添加了丢失的图片和图表
2015/3/30 - 添加了地图视图的性能说明