结构
云的特征:按需付费、存储弹性、可以自行配置。公有云和私有云从服务开发者角度差异并不大,因此下文主要以公有云为例说明。
访问云有两个入口:management gateway 和 message gateway。后者是路由,management gateway 是控制面,负责创建、删除 VM、计费、监控、网络配置等。可以通过 management gateway 的 API 或者云服务商提供的 web 系统来访问它(不过效率不高)。
假如我们想在云端分配一台新的 VM,大概会经历的过程是:
- 给 management gateway 发送请求,申请一个新的 VM 实例。该请求包含许多参数,但其中三个关键参数是:新实例运行所在的云区域、实例类型(例如,CPU 和内存大小)以及 VM 镜像的 ID。
- management gateway 管理数万台物理主机,每台主机上都有 hypervisor;gateway 向 hypervisor 查询哪台主机有足够的 CPU 和内存来分配所需的 VM,然后选择一个,要求 hypervisor 创建一个 VM 并返回其 IP 地址。
- 实际原理更复杂一些,gateway 不会每次都轮询;
- hypervisor 不会生成一个 IP 地址,而是会向 IP address manager 请求一个可用的 IP 地址;后者是云提供的 infra。
- management gateway 将收到的 IP 地址和主机名返回给用户。
Management gateway 还让用户可以管理连接 VM 的虚拟网络。虚拟私有云(Virtual Private Cloud, VPC)是一组和云中的其他 VM 隔离的 VM。
- VPC 就像内网,首先做的是隔离:在其中创建的 VM 会被分配私有 IP 地址,只能看到同一个 VPC 里的资源,而且无法从外部访问(除非给某个实例分配公网 IP,或者通过负载均衡器、NAT 网关暴露服务)。
- 在一个 VPC 里,用户可以自己定义 IP 地址范围、子网,把不同子网放在不同的 availability zone 里,然后用防火墙规则或安全组决定访问权限(比如,前端子网能被公网访问,后端子网只能被前端访问,数据库子网谁都不能直接连)。
- VPC 是管理和组织上的边界。云厂商通常允许用户把权限、配额、计费 tag 和 VPC 绑定,这样一个团队 / 项目 / 环境可以各自有独立的 VPC,避免误操作互相影响,又使账单更加清晰。
服务模型
- IaaS:提供计算、存储、网络和其他基础资源;用户不能控制底层的云 infra,但是可以控制 OS
- PaaS:用户将应用部署到云上
- SaaS:用户使用在云上运行的软件,比如 email
Failure in the Cloud
云是不可靠的,在海量规模的数据中心里,主机和硬盘故障是常态。分布式系统里,timeout 机制用于检测故障,它有两个问题:
- 无法分辨是一个节点故障,还是网络连接问题,或者只是响应过慢;会将延迟的响应标记为 failure。
- Timeout 检测不出故障或延迟发生在何处。在一连串的服务调用中,即使链条上每次响应都没有延迟,加起来的总和也可能非常慢。
对初始请求的响应会出现所谓长尾延迟(long tail latency),即有一部分的延迟会远高于均值,往往来自服务开发者无法控制的底层拥塞或故障。因此,在监控、超时策略和系统设计上不能看均值,必须正视这些高分位值(如 99th percentile),因为长尾延迟会拖垮整个分布式系统的用户体验。
Scaling Service Capacity
当请求量超过单台虚拟机的处理能力(CPU、内存或带宽不足)时,就会发生过载。
最直观的解决办法是换更大的实例,即 vertical scaling,把同一个服务跑在配置更高的 VM 上。这种方式简单、不需要改设计,但有物理上限,而且可能存在单点故障。于是就引出 horizontal scaling:运行多个相同的服务实例,用 load balancer 把请求分发给它们。
Load balancer 定义了云的通信模式,它还负责实例的 health checks。下面讨论两个主要的部分:load balancer 的工作原理,以及位于它之后的服务应该如何设计才能管理服务状态。然后讨论 health management,以及 load balancer 如何提高可用性。
Load Balancer 的工作原理
load balancer 本身是一个独立的服务节点,对外暴露一个固定的 IP 或域名,客户端只和它通信。它在内部维护一组服务实例的 IP 地址,每来一个请求,就根据某种算法把请求转发给其中一个实例。最简单的是交替转发的轮询(Round Robin),但前提是每个请求消耗的资源大致相同,否则就需要更复杂的策略。
客户端并不需要知道服务有多少个实例,从它的角度来看,服务的 IP 地址就是 load balancer 的地址。load balancer 需要知道哪些实例属于它,这些实例可以是启动时静态配置的,也可以是动态添加的(如通过 autoscaler 添加)。
load balancer 本身也可能过载,所以现实中会有多层负载均衡,即消息在到达服务器之前经过多个 load balancer。
Autoscaling
这是云服务的弹性所在。它与 load balancer 配合,根据监控到的指标动态创建或销毁服务实例,自动管理实例池的大小;创建新的服务器实例后,autoscaler 就将新的 IP 地址通知 load balancer。
autoscaler 本身不参与请求转发,它只负责观察实例的 CPU 利用率、网络 I/O 带宽等云 infra 收集的指标,并根据预先配置的规则做决策,比如“CPU 持续 5 分钟超过 80% 就增加一台”。autoscaler 不会看瞬时值,因为极短时间的峰谷并无意义,而且 VM 的分配和启动很慢,规则通常基于时间窗口。
除了基于负载,用户还可以设置最小/最大实例数,或者按时间表提前扩容、事后缩容(例如,工作日开始前扩容,工作日结束后缩容)。
当 autoscaler 缩容时,它不能直接关闭 VM,而是要先通知 load balancer 停止转发新请求、并等待实例处理完当前的请求,再将其销毁,这一过程叫 draining,服务本身有必要配合实现。
故障检测和管理
load balancer 不仅用于扩容,也是提高可用性的关键工具。
load balancer 如果只是转发请求,并不一定知道后端实例是否还在活动,也不知道是否发生了故障,因为响应可能不经过它返回,于是就需要 health checks。load balancer 会定期对实例做探测,比如 ping、建立 TCP 连接,甚至发一个真实请求(将返回地址指定为 load balancer 的 IP),如果实例多次不响应,就被标记为不健康,不再接收请求;之后还会对不健康 list 中的实例周期性重试,看它是否恢复。如果只是暂时过载,实例在处理完积压的请求后还能回到健康状态;但如果是严重故障或崩溃,实例可能会重启并重新在 load balancer 那里注册,也可能会直接启动一个新的实例来替代它。通过这种方式,实例故障可以被隐藏在 load balancer 后面,并且在一定数量的实例故障下仍能提供足够的服务。(但是,客户端仍然需要未收到响应就重发请求的机制。)
通常使用 timeout 在分布式系统里检测故障。timeout 只知道没有及时收到响应,但无法区分是网络连接问题、VM 或主机问题,还是服务本身挂了,而恢复操作本身是有成本的。如果 timeout 设置得过于激进,就会因为偶发的长尾延迟触发不必要的重试或扩容。所以 timeout 通常是可调参数,而且往往要结合 timeout 时间间隔和丢失响应的数量来判断(如,将 timeout 设置为 200 毫秒,并在 1 秒内检测到 3 条消息丢失时触发故障恢复),部署范围越广、网络越不稳定,参数就越需要保守。
上面讨论的 load balancer 用的是 push 方法,将消息推送给服务池中的某个服务实例。还有一种方案用的是消息队列:客户端把请求放进队列,服务实例空闲时自己去取,处理完之后将消息从队列里删除。队列能保证请求至少会被处理一次,但可能会被处理多次,因此服务必须是幂等的(同一个请求无论处理了几次,响应都是相同的),而且不能依赖严格的 FIFO 顺序。
状态管理
状态指的是在服务内部,会影响响应结果,并且依赖历史请求的信息。
一旦服务可以并发处理请求(多线程或多实例),状态管理就变得非常重要。关键在于状态的存储位置,有三种选择:存储在服务实例里、存储在客户端里、存储在外部数据库。例如,考虑一个统计自身被调用了多少次的服务:
// Variant 1. 服务实例存储
int i; // i is our state variable, initialized to 0 when the service instance starts
int countv1() {
i = i + 1; //add 1 to the last value of i
return i;
}
// Variant 2. 服务实例不存储,客户端提供
int countv2(int i) {
int a;
a = i + 1; //add 1 to the last value of i
return a;
}
// Variant 3. 存在客户端和服务实例之外的数据库(注意,必须要锁定数据库,以确保同一时间只有一个服务实例可以读写)
int countv3() {
int a;
a = dbase_get("count"); //retrieve current value from an
// external db
a = a + 1; //add 1 to the last value of a
dbase_write("count", a); // //save current value back to the db
return a;
}
关键在于行为差异,要考虑多实例的情况。
- 状态放在服务实例里时,不同服务实例统计各自的调用次数,客户端看到的结果因其请求的服务实例而异,看不到所有服务实例处理的请求总数;
- 状态放在客户端里时,服务是无状态的,客户端只能看到自己发起的请求数,看不到所有客户端发起的请求总数;
- 状态放在外部(比如数据库)时,所有实例共享同一份数据,所有客户端都能看到所有客户端发起的请求总数,但需要面对并发控制和性能成本。
其中,只有第一种是有状态的,后两者都是无状态的。使用哪种方案取决于系统需求,但开发者必须意识到设计选择会直接改变语义。
云里通常要把服务设计成无状态的,这样实例可以随意增加、删除或替换,新实例能立刻接手请求,而不会丢失业务逻辑的连续性;否则,有状态服务一旦发生故障,就会丢失历史记录,而且恢复状态非常困难。只有在非常特殊、确实无法无状态化的情况下,才考虑 sticky session 或直连某一个实例,但这会显著降低可用性(实例可能故障)和扩展性(实例可能过载),所以要极其谨慎。
共享分布式数据
通常情况下,我们需要在服务实例之间或不同服务之间共享信息,比如状态信息、load balancer 的 IP 地址、服务的消息队列。目前有三种信息共享方案:
- 外部数据库:可靠、强一致、能持久化,但是读写速度很慢。
- 分布式缓存:适合小数据量,内存速度很快,但不保证可用性,服务器挂了数据就没了。场景包括 session、网页、API 缓存以及对象、图像、metadata 等。
- 分布式协调系统:适合小数据量,但强一致、强可用。场景包括分布式锁、调度器、服务 discovery 和 health checks。
下面讨论分布式缓存和分布式协调系统。
分布式缓存
以 Memcached 为例,它有以下特点:
- key-value 存储,value 本身是非结构化的小数据,客户端要负责结构化。
- 多台互不相连的服务器,缓存不复制。
- 每个客户端都将数据存储在一台服务器上,并且该客户端或与其共享数据的任何其他客户端都必须访问这台服务器。
数据存储在单个服务器上,以及服务器故障时无法恢复数据,这两点是分布式缓存和分布式协调系统的区别。
分布式协调系统
A distributed system is one in which the failure of a computer you didn’t even know existed can render you own computer unusable. — Leslie Lamport
例如,考虑在分布式机器之间共享的资源锁。标准方案是锁定数据,避免 race condition,服务实例 1 锁定资源后操作,然后释放锁,服务 2 才能再次锁定。但是,在分布式系统中,这种方案有两个问题:首先,传统的获取锁的 two-phase commit 协议需要发送多条消息,消息可能会丢失;其次,如果服务实例 1 获取锁之后发生故障,服务实例 2 就堵住了。
解决这类问题需要建立于共识机制(consensus mechanism)之上的分布式协调算法。以 Zookeeper 为例,协调系统的工作原理大致是:多节点集群、选择一个 leader、状态在内存中复制、读经 follower、写经 leader(并将新值同步给所有 follower)、故障时自动重连 follower 和恢复。
分布式锁可以通过 Zookeeper 的层级节点(类似文件目录)实现。第一个创建节点的实例获得锁,其他实例通过 watcher 排队等候,前面的实例使用完毕后 watcher 会通过回调收到通知。
总之,选择共享数据的存储位置时要关注数据的特点。如果是小规模、不需要持久化的数据,而且一致性或可用性很重要,分布式缓存或协调系统往往比 SQL 数据库更合适,也更快。
安全策略
安全策略指的是一系列规定了什么服务对什么资源有什么使用权限的规则。提供访问控制的云服务叫 Identity and Access Management(IAM),它在每次 API 调用时做规则检查。