使用 AppSwitch 进行 Sidestepping 依赖性排序
我们正在经历一个有趣事情,对应用程序进行拆分和重组。虽然微服务需要把单体应用分解为多个微型服务,但服务网格会把这些服务连结为一个应用程序。因此,微服务是逻辑上分离而又不是相互独立的。它们通常是紧密相互依赖的,而拆分单体应用的同时会引入了许多新的问题,例如服务之间需要双向认证等。而 Istio 恰巧能解决大多数问题。
依赖性排序问题
依赖性排序的问题是由于应用程序拆分而导致的问题且 Istio 也尚未解决 - 确保应用程序整体快速正确地的顺序启动应用程序的各个服务。在单体应用程序中,内置所有组件,组件之间的依赖顺序由内部锁机制强制执行。但是,如果单个服务分散在服务网格的集群中,则启动服务需要首先检查它所依赖的服务是否已启动且可用。
依赖性排序由于存在许多相互关联的问题而具有欺骗性。对单个服务进行排序需要具有服务的依赖关系图,以便它们可以从叶节点开始返回到根节点。由于相互依赖性随着应用程序的行为而发展,因此构建这样的图并随时保持更新并不容易。即使以某种方式提供依赖图,强制执行排序本身并不容易。简单地按指定的顺序启动服务显然是行不通的。服务可能已启动但尚未准备好提供服务。例如 docker-compose 中的 depends-on 标签就存在这样的问题。
除了在服务启动之间引入足够长的睡眠之外,还有一个常见的模式是,在启动服务之前检查被依赖的服务是否已经准备就绪。在 Kubernetes 中,可以用在 Pod 的 Init 容器中加入等待脚本的方式来完成。但是,这意味着整个应用程序将被暂停,直到所有的依赖服务都准备就绪。有时,应用程序会在启动第一次出站连接之前花几分钟时间初始化自己。不允许服务启动会增加应用程序整体启动时间的大量开销。此外,等待 init 容器的策略不适用于同一 pod 中的多个服务相互依赖的情况。
示例场景:IBM WebSphere ND
IBM WebSphere ND 是一个常见的应用程序中间件,通过对它的观察,能够更好地理解这种问题。它本身就是一个相当复杂的框架,由一个名为 Deployment manager(dmgr
)的中央组件组成,它管理一组节点实例。它使用 UDP 协商节点之间的集群成员资格,并要求部署管理器在任何节点实例出现并加入集群之前已启动并可运行。
为什么我们在现代云原生环境中讨论传统应用程序?事实证明,通过使它们能够在 Kubernetes 和 Istio 平台上运行,可以获得显著的收益。从本质上讲,它是现代化之旅的一部分,它允许在同一现代平台上运行传统应用程序和全新的现代应用程序,以促进两者之间的互操作。实际上,WebSphere ND 是一个要求很高的应用程序。它期望具有特定网络接口属性的一致网络环境等。AppSwitch 可以满足这些要求。本博客的将重点关注依赖顺序需求以及 AppSwitch 在这方面的解决方法。
在 Kubernetes 集群上简单地部署 dmgr
和节点实例作为 pod 是行不通的。dmgr
和节点实例碰巧有一个很长的初始化过程,可能需要几分钟。如果他们被同时部署,那么应用程序通常会处于一个有趣的状态。当一个节点实例出现并发现缺少 dmgr
时,它将需要一个备用启动路径。相反,如果它立即退出,Kubernetes 崩溃循环将接管,也许应用程序会出现。但即使在这种情况下,事实证明及时启动并不能得到保证。
一个 dmgr
及其节点实例是 WebSphere ND 的基本部署配置。构建在生产环境中运行的 WebSphere ND 之上的 IBM Business Process Manager 等应用程序包括其他一些服务。在这些配置中,可能存在一系列相互依赖关系。根据节点实例托管的应用程序,它们之间也可能存在排序要求。使用较长的服务初始化时间和崩溃循环重启,应用程序几乎没有机会在任何合理的时间内启动。
Istio 中的 Sidecar 依赖
Istio 本身受依赖性排序问题版本的影响。由于在 Istio 下运行的服务的连接通过其 sidecar 代理重定向,因此在应用程序服务及其 sidecar 之间创建了隐式依赖关系。除非 sidecar 完全正常运行,否则所有来自服务的请求都将被丢弃。
使用 AppSwitch 进行依赖性排序
那么我们如何解决这些问题呢?一种方法是将其推迟到应用程序并说它们应该“表现良好”并实施适当的逻辑以使自己免受启动顺序问题的影响。但是,许多应用程序(尤其是传统应用程序)如果错误则会超时或死锁。即使对于新的应用程序,为每个服务实现一个关闭逻辑也是最大的额外负担,最好避免。服务网格需要围绕这些问题提供足够的支持。毕竟,将常见模式分解为底层框架实际上是服务网格的重点。
AppSwitch 明确地解决了依赖性排序。它位于应用程序在集群中的客户端和服务之间的网络交互的控制路径上,并且通过进行 connect
调用以及当特定服务通过使 listen
准备好接受连接时,准确地知道服务何时成为客户端。调用它的 service router 组件在集群中传播有关这些事件的信息,并仲裁客户端和服务器之间的交互。AppSwitch 就是以这种简单有效的方式实现负载均衡和隔离等功能的。利用应用程序的网络控制路径的相同战略位置,可以想象这些服务所做的“连接”和“监听”调用可以以更精细的粒度排列,而不是按照依赖关系图对整个服务进行粗略排序。这将有效地解决多级依赖问题和加速应用程序启动。
但这仍然需要一个依赖图。存在许多产品和工具来帮助发现服务依赖性。但它们通常基于对网络流量的被动监控,并且无法预先为任意应用程序提供信息。由于加密和隧道导致的网络级混淆也使它们不可靠。发现和指定依赖项的负担最终落在应用程序的开发人员或操作员身上。实际上,甚至一致性检查依赖性规范本身也非常复杂,相对来说,能够避免使用依赖图的任何方法都会更加理想。
依赖图的要点是知道哪些客户端依赖于特定服务,以便让客户端能够等待被依赖服务准备就绪。但具体客户真的重要吗?归根结底,一个服务的所有客户端,都是依赖这个服务的。AppSwitch 正是利用这一点来解决依赖问题。事实上,这完全避免了依赖性排序。可以同时调度应用程序中的所有服务,而无需考虑启动顺序。它们之间的相互依赖性会根据各个请求和响应的粒度自动完成,从而实现快速,正确的应用程序启动。
AppSwitch 模型和构造
既然我们对 AppSwitch 的高级方法有了概念性的理解,那么让我们来看看所涉及的结构。但首先要对使用模型进行快速总结。尽管它是针对不同的上下文编写的,但在此主题上查看我之前的 blog 也很有用。为了完整起见,我还要注意 AppSwitch 不会打扰非网络依赖。例如,两个服务可能使用 IPC 机制或通过共享文件系统进行交互。像这样的深层联系的流程通常是同一服务的一部分,并且不需要主动地对应用程序的正常执行进行干预。
AppSwitch 的核心能够使用 BSD Socket API 及其相关的其它调用(例如 fcntl
和 ioctl)来完成对 Socket 的处理。它的实现细节很有意思,但是为了防止偏离本文的主题,这里仅对其独特的关键属性进行一个总结。
(1)速度很快。它使用 seccomp
过滤和二进制检测的组合来积极地限制应用程序正常执行的干预。AppSwitch 特别适用于服务网格和应用程序网络用例,因为它实现了这些功能,而无需实际触摸数据。相反,网络级方法会导致每个数据包的成本。看看这个博客进行一些性能测量。
(2)它不需要任何内核支持,内核模块或补丁,可以在标准的发行版内核上运行
(3)它可以作为普通用户运行(非 root)。事实上,该机制甚至可以通过删除对网络容器的根要求来运行非 root 的 Docker 守护进程
(4)它可以不加更改的用于任何类型的应用程序上,适用于任何类型的应用程序 - 从 WebSphere ND 和 SAP 到自定义 C 应用程序,再到静态链接的 Golang
应用程序。Linux/x86 是仅有的运行需求。
将服务与其引用分离
AppSwitch 建立在应用程序应与其引用分离的基本前提之上。传统上,应用程序的标识源自它们运行的 主机的标识。但是,应用程序和主机是需要独立引用的非常不同的对象。本主题介绍了围绕此主题的详细讨论以及 AppSwitch 的概念基础。
实现服务对象及其身份之间解耦的中央 AppSwitch 构造是 _service reference_(简称 reference )。AppSwitch 基于上面概述的 API 检测机制实现服务引用。服务引用由 IP:端口对(以及可选的 DNS 名称)和标签选择器组成,标签选择器选择引用所代表的服务以及此引用所适用的客户端。引用支持一些关键属性。(1)它的名称可以独立于它所引用的对象的名称。也就是说,服务可能正在侦听 IP 和端口,但是引用允许在用户选择的任何其他 IP 和端口上达到该服务。这使 AppSwitch 能够运行从源环境中捕获的传统应用程序,通过静态 IP 配置在 Kubernetes 上运行,为其提供必要的 IP 地址和端口,而不管目标网络环境如何。(2)即使目标服务的位置发生变化,它也保持不变。引用自动重定向自身,因为其标签选择器现在解析为新的服务实例(3)对于此讨论最重要的是,在目标服务的启动过程中,引用就已经生效了。
为了便于发现可通过服务引用访问的服务,AppSwitch 提供了一个 _auto-curated 服务注册表_。根据 AppSwitch 跟踪的网络 API,当服务进出群集时,注册表会自动保持最新。注册表中的每个条目都包含相应服务绑定的 IP 和端口。除此之外,它还包括一组标签,指示此服务所属的应用程序,应用程序在创建服务时通过 Socket API 传递的 IP 和端口,AppSwitch 实际绑定基础主机上的服务的 IP 和端口此外,在 AppSwitch 下创建的应用程序带有一组用户传递的标签,用于描述应用程序以及一些默认系统标签,指示创建应用程序的用户和运行应用程序的主机等。这些标签都可以在服务引用所携带的标签选择器中表示。通过创建服务引用,可以使客户端访问注册表中的服务。然后,客户端将能够以引用的名称(IP:端口)访问服务。现在让我们来看看 AppSwitch 如何在目标服务尚在启动的过程中就让服务引用开始生效的。
非阻塞请求
AppSwitch 利用 BSD Socket API 的语义,确保服务引用从客户的角度看起来是有效的,因为相应的服务出现了。当客户端对一个尚未启动的服务发起阻塞式连接调用时,AppSwitch 会阻止该调用一段时间等待目标服务变为活动状态。由于已知目标服务是应用程序的一部分并且预计很快就会出现,因此客户端会被阻塞,而不是收到 ECONNREFUSED 之类的返回信息导致启动失败。如果服务没有及时出现,则会向应用程序返回一个错误,以便像 Kubernetes 崩溃循环这样的框架级机制可以启动。
如果客户端请求被标记为非阻塞,则 AppSwitch 通过返回 EAGAIN
来处理该请求以通知应用程序重试而不是放弃。再次,这与 Socket API 的语义一致,并防止由于启动竞争而导致的失败。AppSwitch 通过对 BSD Socket API 的支持,将重试逻辑内置到应用程序之中,从而透明的为应用提供了依赖排序支持。
应用程序超时
如果应用程序基于其自己的内部计时器超时怎么办?说实话,如果需要,AppSwitch 还可以伪造应用程序对时间的感知,但这种做法不仅越界,而且并无必要。应用程序决定并知道它应该等待多长时间,这对 AppSwitch 来说是不合适的。应用程序超过保守时长,如果目标服务仍未及时出现,则不太可能是依赖性排序问题。一定是出现了其它问题,AppSwitch 不应掩盖这些问题。
为 Sidecar 提供服务引用的通配符支持
服务引用可用于解决前面提到的 Istio sidecar 依赖性问题。AppSwitch 用 IP:端口的方式来描述对服务的引用,这种描述中是可以使用通配符的。也就是说,服务引用描述中可以用 IP 掩码的形式来表达要捕捉的 IP 地址的范围。如果服务引用的标签选择器指向 sidecar 服务,则应用此服务引用的任何应用程序的所有传出连接将被透明地重定向到 sidecar。当然,在 Sidecar 启动过程中,服务引用仍然是有效的。
使用 sidecar 依赖性排序的服务引用也隐式地将应用程序的连接重定向到 sidecar ,而不需要 iptables 和随之而来的权限问题。基本上它就像应用程序直接连接到 sidecar 而不是目标目的地一样工作,让 sidecar 负责做什么。AppSwitch 将使用 sidecar 可以在将连接传递到应用程序之前解码的代理协议将关于原始目的地等的元数据插入到连接的数据流中。其中一些细节已在此处进行了讨论。出站连接是这样处理的,那么入站连接呢?由于所有服务及其 sidecar 都在 AppSwitch 下运行,因此来自远程节点的任何传入连接都将被重定向到各自的远程 sidecar 。所以传入连接没有什么特别处理。
总结
依赖顺序是一个讨厌的问题。这主要是由于无法访问有关服务间交互的细粒度应用程序级事件。解决这个问题通常需要应用程序来实现自己的内部逻辑。但 AppSwitch 使这些内部应用程序事件无需更改应用程序即可进行检测。然后,AppSwitch 利用对 BSD Socket API 的普遍支持来回避排序依赖关系的要求。
致谢
感谢 Eric Herness 和团队对 IBM WebSphere 和 BPM 产品的见解和支持,我们将它们应用到现代化 Kubernetes 平台,还要感谢 Mandar Jog,Martin Taillefer 和 Shriram Rajagopalan 对于此博客早期草稿的评审。