云原生模式
云原生模式
🔗1、什么是云原生
云原生软件是高度分布式的,必须在一个不断变化的环境中运行,而且自身也在不断地发生变化。
🔗云与云原生
迁移到云上并不意味着你的软件就是云原生的,也不会具有云原生软件的价值。
“云”是指我们在哪里计算,而“云原生”指的是如何实现。
最终一致性是许多云原生模式的核心,这意味着当我们需要强一致性时,就不能使用这些新模式了。
🔗云原生软件
云原生软件的设计目的是预测故障,并且即使当它所依赖的基础设施出现故障,或者发生其他变化时,它也依然能够保持稳定运行。
失败是正常规律,而不是例外
🔗4、事件驱动
首先,在左边有两个参与方,微服务的客户端和微服务本身,二者互相依赖。在右边,只有一个参与方,这一点很重要。微服务被作为一个事件的结果而执行,但是触发该事件的原因与微服务无关。因此,这个服务的依赖更少。
事件驱动架构在很大程度上是为了解决系统过于紧耦合的问题而设计的。
对于请求/响应的方式,聚合发生在用户发出请求的时候。而对于事件驱动的方式,聚合发生在系统中数据发生变化的时候,并且这是异步的。
考虑到事件驱动的协议本质上是异步的,解决相同问题的补偿机制可能会非常不同。在这个架构中, 你会使用诸如RabbitMQ或者Apache Kafka之类的消息队列系统,来保持网络分区中的事件。你的服务应该实现能够支持此架构的协议,例如,通过一个循环来不断地检查事件存储中是否有感兴趣的新事件。
🔗8、动态路由和服务发现
🔗动态路由
🔗客户端负载均衡
🔗服务端负载均衡
🔗基于k8s的服务端负载均衡
🔗服务发现
🔗CoreDNS
🔗DNS潜在问题
DNS服务本身就是一个多实例的、分布式的系统。DNS服务通常被配置为支持可用性而不是一致性。即符合最终一致性。使用DNS服务时,作为一个开发者,你必须考虑到,通过DNS服务获取到的ip地址可能是过时的。
- 客户端请求DNS服务时,可能得到过时的服务ip。这种情况下,在请求重试失败了几次之后,你可以再次向DNS询问一个IP地址,由于在此期间DNS被更新,现在是一致的,你得到一个新的IP地址。
- 客户端请求DNS服务时,得到过时的服务ip,恰好这个ip正在被另一个服务使用。为解决这种情况,服务实现或部署必须有访问控制机制,以便不允许未经授权的访问。
🔗9、交互中的客户侧
🔗重试
🔗重试风暴?
为什么重试风暴会导致请求积压,并且服务恢复后需要花费一定时间消化?
Posts服务不可用时,引发重试风暴
Posts服务恢复后,已经产生了积压的流量,需要时间来消减:
🔗重试的好处
特别是对于间歇性的连接问题,重试往往会奏效,从而扼杀了一个错误,否则这个错误可能会通过构成我们云原生软件的分布式系统广泛传播。
🔗重试策略优化
限制重试次数、每次重试之间加入一定延迟
🔗不适合场景
例如银行卡余额减去100
点击购买按钮后没有收到回应
🔗回退
🔗10、交互中的服务侧
一些放在真实服务前的前置服务,熔断器、API网关
🔗熔断器
🔗好处
在一个复杂的分布式系统中,延迟是灾难性的,而断路器大大减少了其长度和频率。
当服务不可用时,断路器大大减少了等待时间
🔗实现
开源库:github.com/sony/gobreaker
type CircuitBreaker struct {
name string
maxRequests uint32 //半开启下,最大请求数;也是CB由半开启-->关闭转变的条件(连续成功请求数达到maxRequests,关闭CB)
interval time.Duration//关闭状态下,cb清除内部Counts的循环周期。
timeout time.Duration//开启状态维持时间,超过进入半开启状态
readyToTrip func(counts Counts) bool//CB由关闭到开启的条件(可自定义)
isSuccessful func(err error) bool //请求成功的标志(可自定义),默认err==nil
onStateChange func(name string, from State, to State)
mutex sync.Mutex
state State
generation uint64
counts Counts //cb内部计数
expiry time.Time
}
type Counts struct {
Requests uint32
TotalSuccesses uint32
TotalFailures uint32
ConsecutiveSuccesses uint32
ConsecutiveFailures uint32
}
🔗请求前后
func (cb *CircuitBreaker) Execute(req func() (interface{}, error)) (interface{}, error) {
generation, err := cb.beforeRequest()
//请求前,查看cb状态。关闭,请求成功;半开启,超过最大请求数maxRequests,请求失败;开启,请求失败
if err != nil {
return nil, err
}
defer func() {
e := recover()
if e != nil {
cb.afterRequest(generation, false)
panic(e)
}
}()
result, err := req()
cb.afterRequest(generation, cb.isSuccessful(err))
//请求后,请求成功,若为半开启状态且连续成功次数=maxRequests,关闭CB;请求失败,若为关闭状态且满足自定义条件,开启CB,若为半开启状态,开启CB
return result, err
}
🔗状态转移
func (cb *CircuitBreaker) currentState(now time.Time) (State, uint64) {
switch cb.state {
case StateClosed:
if !cb.expiry.IsZero() && cb.expiry.Before(now) {
cb.toNewGeneration(now)
}
case StateOpen:
if cb.expiry.Before(now) {
//在熔断器开启状态下,如果过了规定的时间,将进入半开启状态,验证目前服务是否可用。
cb.setState(StateHalfOpen, now)
}
}
return cb.state, cb.generation
}
func (cb *CircuitBreaker) onSuccess(state State, now time.Time) {
switch state {
case StateClosed:
//关闭状态下,记录总成功以及连续成功次数
cb.counts.onSuccess()
case StateHalfOpen:
cb.counts.onSuccess()
//半开启状态下,连续成功maxRequests以上次数,关闭熔断器
if cb.counts.ConsecutiveSuccesses >= cb.maxRequests {
cb.setState(StateClosed, now)
}
}
}
func (cb *CircuitBreaker) onFailure(state State, now time.Time) {
switch state {
case StateClosed:
cb.counts.onFailure()
if cb.readyToTrip(cb.counts) {
//关闭状态下,失败并满足一定条件后(回调函数。可以为连续失败次数、失败率等),熔断器关闭-->开启
cb.setState(StateOpen, now)
}
case StateHalfOpen:
//半开启状态下,一旦出现失败,就再次进入开启状态
cb.setState(StateOpen, now)
}
}
四种状态转移:
- 在熔断器关闭状态下,失败并满足一定条件后(回调函数;可以为连续失败次数、失败率等),熔断器开启。
- 在熔断器开启状态下,如果过了规定的时间,将进入半开启状态,验证目前服务是否可用。
- 在熔断器半开启状态下,如果出现失败,则再次进入开启状态。
- 在熔断器半开启状态下,限制请求数,并且当所有请求都成功时,熔断器关闭。
🔗API网关
🔗作用
- 认证和授权–控制对API网关后面服务的访问。这种访问控制的机制各不相同,可以包括基于秘密的方法,如使用密码或令牌,也可以是基于网络的,与防火墙类型的服务整合或实施。
- 对飞行中的数据进行加密。API网关可以处理解密,因此是管理证书的地方。
- 保护服务不受负载高峰的影响。配置得当,API网关成为客户访问服务的唯一途径。因此,在这里实施的负载节流机制可以提供重要的保护。例如,熔断器。
- 访问日志。由于进入服务的所有流量都是通过API网关,你有能力记录所有的访问。这些日志可以支持大量的使用情况,包括审计和操作的可观察性。
🔗优点
将网关嵌入到服务中具有一些明显的优势:在网关和服务本身之间没有网络跳转,配置时不再需要主机名,只需要路径,跨源资源共享(CORS)的问题消失了,等等。
🔗缺点
回顾前面关于应用程序生命周期的讨论,在周期的后期绑定配置提供了更多的灵活性。如果你在application.properties文件中包含了前面的配置,那么配置的改变需要进行编译。正如我们所讨论的,属性值可以在以后通过环境变量注入,但这仍然需要重新启动JVM(或至少刷新应用上下文)。
如果你嵌入了一个Java组件,这几乎意味着你的服务实现也必须是Java的,或至少在JVM中运行。虽然这里的所有代码例子都是用Java编写的,但我所推崇的模式适用于任何语言,而且应该用任何语言来实现,这对你的方案来说是最合适的。
API网关模式的目标之一是将服务开发者的关注点与操作者的关注点分开。你想让后者有能力在所有正在运行的服务中应用一致的控制,并为他们提供一个控制平面,使之可以管理。
🔗服务网格
编程语言无关的、松耦合的和可管理的
🔗sidecar
同一pod的容器间网络通信:同一pod下的容器使用相同的网络名称空间,这就意味着他们可以通过’localhost’来进行通信,它们共享同一个Ip和相同的端口空间
同一pod内的容器共识存储卷:一个标准的同一pod内容器共享存储卷的用例是一个容器往共享存储卷里写入数据,其它的则从共享目录里读取数据。
可以使用emptyDir。将 Pod 分配给节点时首先创建一个 emptyDir 卷,并且只要该 Pod 在该节点上运行就存在。顾名思义,emptyDir 卷最初是空的。 emptyDir卷可以安装在每个容器中相同或不同的路径上,Pod 中的所有容器都可以在 emptyDir 卷中读取和写入相同的文件。当 Pod 因任何原因从节点中移除时,emptyDir 中的数据将被永久删除。
🔗总架构图
🔗pod间通信
🔗总览
🔗数据面-Envoy
每个微服务的 Sidecar 代理,用于处理集群中服务之间以及从服务到外部服务的入口/出口流量。这些代理形成了一个安全的微服务网格,提供了一组丰富的功能,如发现、丰富的第 7 层路由、断路器、策略实施和遥测记录/报告功能。
🔗控制面-Istiod
它提供服务发现、配置和证书管理。它由以下子组件组成:
Pilot - 负责在运行时配置代理。
Citadel - 负责证书的颁发和轮换。
Galley - 负责在 Istio 中验证、摄取、聚合、转换和分发配置。
Operator - 该组件提供用户友好的选项来操作 Istio 服务网格
🔗11、故障排除
🔗log
🔗使用stdout和stderr
为什么使用stdout和stderr?
- 禁止将日志直接写入文件。本地文件系统与容器的生命周期是一致的。即使在某个应用程序实例及其容器消失之后,你仍然需要访问日志。虽然有些容器编排系统确实支持将容器连接到与生命周期无关的外部存储卷,但这样做不仅很复杂,而且存在与其他应用程序实例之间的竞争风险。
- 在很大程度上,开源的普及抵制了私有化的解决方案,我们力求在任何可能的地方都达到一定程度的标准化,而不是在JBoss上部署的时候用一种方式记录日志,而在WebSphere上部署又用另一种方式来记录。stdout和stderr无处不在,不存在绑定供应商的问题。
- stdout和stderr不仅与供应商无关,而且与操作系统无关。无论是在Linux、Windows还是其他操作系统上,概念都是相同的,也都提供了相同的功能。
- stdout和stderr是流式API,日志本身也是流。日志没有起点或者终点,日志只是一直在流动。当这些日志出现在流中时,流处理系统可以适当地处理它们。
🔗查看日志
同时查看post服务多个实例的日志
kubectl logs -l app=posts
🔗ELK Stack
“ELK”是三个开源项目的首字母缩写词:Elasticsearch、Logstash 和 Kibana。
Elasticsearch 是一个搜索和分析引擎。
Logstash 是一个服务器端数据处理管道,它同时从多个来源获取数据,对其进行转换,然后将其发送到像 Elasticsearch 这样的“存储”。
Kibana 允许用户在 Elasticsearch 中使用图表和图形来可视化数据。
🔗metrics
🔗获取metrics的两种方式
🔗pull
🔗Prometheus in k8s
Prometheus可以使用CoreDNS直接访问应用程序的所有实例
🔗push
🔗trace
🔗原理
使用跟踪器(tracer)在请求和响应中插入唯一标识符,以便找到相关的应用程序调用。
一个控制平面(control plane)使用这些跟踪器将一组调用组装成调用图(或者是特意设计的独立调用)
🔗use zipkin
存在的问题
各个服务负责将数据传递到Zipkin的存储,这也带来了一个重要的问题。从服务发送数据的行为占用了资源,它会消耗内存、CPU和I/O带宽。我之前提到的metrics仅限于某个服务,收集的是有关服务运行的数据。而现在讨论的指标仅限于某个服务调用。对于前者,你可以每秒收集一次指标,但是如果服务每秒响应100个请求,并且你正在收集每次调用的指标,那么这会占用大量的资源(两个数量级)。因此,分布式跟踪的最佳实践是仅收集所有服务请求的某个子集的指标。