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