剖析Prometheus的内部存储机制


Prometheus有着非常高效的时间序列数据存储方法,每个采样数据仅仅占用3.5byte左右空间,上百万条时间序列,30秒间隔,保留60天,大概花了200多G(引用官方PPT)。

接下来让我们看看它的原理。

Prometheus内部主要分为三大块,Retrieval是负责定时去暴露的目标页面上去抓取采样指标数据,Storage是负责将采样数据写磁盘,PromQL是Prometheus提供的查询语言模块。
1.png

从最原始的抓取数据上来看,基本是这个样子,timestamp是当前抓取时间戳:
2.png

每个Metric name代表了一类的指标,他们可以携带不同的Labels,每个Metric name + Label组合成代表了一条时间序列的数据。

例如图上的数据:
http_requests_total{status="200",method="GET"}
http_requests_total{status="404",method="GET"} 

表示了两条不同的时间序列。

在Prometheus的世界里面,所有的数值都是64bit的。每条时间序列里面记录的其实就是64bit timestamp(时间戳)+ 64bit value(采样值)。

而对于时间序列的基本特性来说,通常是过去的数据一般是只读的,是不会变更的,当前时间的数据才会可能在写,模式如下图:
3.png

根据上面的分析,时间序列的存储似乎可以设计成key-value存储的方式(基于BigTable)。
4.png

进一步拆分,可以像下面这样子:
5.png

上图的第二条样式就是现在Prometheus内部的表现形式了,__name__是特定的label标签,代表了metric name。

再回顾一下Prometheus的整体流程:
6.png

上面提到了K-V存储,当然是使用了LevelDB的引擎,它的特点是顺序读写性能非常高,这是非常符合时间序列的存储的。

为了得到顺序的时间序列哈希索引值,Prometheus是这样处理的:
7.png

FNV哈希算法全名为Fowler-Noll-Vo算法,是以三位发明人Glenn Fowler,Landon Curt Noll,Phong Vo的名字来命名的,最早在1991年提出。

FNV能快速hash大量数据并保持较小的冲突率,它的高度分散使它适用于hash一些非常相近的字符串,比如URL,hostname,文件名,text,IP地址等。

1KB Chunks

在Prometheus的世界中,无论是内存还是磁盘,它都是以1KB单位分成块来操作的。(新出的Prometheus 2.0对存储底层做了很大改动,专门针对SSD的写放大进行了优化,提高SSD的读写性能和读写次数等。)

整体流程是:抓取数据 -> 写到head chunk,写满1KB,就再生成新的块,完成的块,是不可再变更的 -> 根据配置文件的设置,有一部份chunk会被保留在内存里,按照LRU算法,定期将块写进磁盘文件内。

注意: 一条时间序列,保存到一个磁盘文件内。
8.png

时间序列的保留维护

在Prometheus的启动选项中,有一项storage.local.retention可以设置数据自动保留多长时间,例如24h,表示数据超过24小时内的将会自动清除,类似于zabbix的housekeeping功能。storage.local.series-file-shrink-ratio可以按一定的比例保留数据。
9.png

关于Chunk 块编码的剖析

Prometheus提供三种不同类型的块编码,用户可以在Prometheus启动时指定最新的编码方式,-storage.local.chunk-encoding-version,有效值是0,1,2。

版本0的编码是较老版本上的Prometheus上使用的,新版本已经不再建议使用的。

版本1是当前版本默认提供的编码方式,它相对于0版有较好的压缩能力,而且在一个块内,有较高的访问速度,当然版本0的编码速度是最快的,但是相对版本1,速度优势不是特别明显。

版本2提供了一个更高的压缩比例,编码和解码需要耗更多的CPU,当然,这是取决于查询的数据集有多大。通常如果是较少的查询,仅用于存档的数据,可以使用这种编码。

对比:
10.png

V0 结构:
11.png

V1 结构:
12.png

V2 结构:
13.png

Prometheus是如何访止数据丢失的呢?例如发生异常关闭或者什么别的情况?它提供了一个Checkpointing功能,对于内存里面的块,Prometheus使用了一个checkpoint file去同步写入磁盘,类似于Hbase的WAL原理,当发生crash时,先从checkpoint file去恢复数据。
14.png

以上内容是根据Prometheus官方人员的一份PPT摘取,获取PPT方式:扫描下面二维码关注公众号,回复『Prometheus』,即可获取下载链接。
qrcode_for_gh_e2b16c84652b_258.jpg

原文链接:https://www.cnblogs.com/vovlie/p/7709312.html
继续阅读 »

Prometheus有着非常高效的时间序列数据存储方法,每个采样数据仅仅占用3.5byte左右空间,上百万条时间序列,30秒间隔,保留60天,大概花了200多G(引用官方PPT)。

接下来让我们看看它的原理。

Prometheus内部主要分为三大块,Retrieval是负责定时去暴露的目标页面上去抓取采样指标数据,Storage是负责将采样数据写磁盘,PromQL是Prometheus提供的查询语言模块。
1.png

从最原始的抓取数据上来看,基本是这个样子,timestamp是当前抓取时间戳:
2.png

每个Metric name代表了一类的指标,他们可以携带不同的Labels,每个Metric name + Label组合成代表了一条时间序列的数据。

例如图上的数据:
http_requests_total{status="200",method="GET"}
http_requests_total{status="404",method="GET"} 

表示了两条不同的时间序列。

在Prometheus的世界里面,所有的数值都是64bit的。每条时间序列里面记录的其实就是64bit timestamp(时间戳)+ 64bit value(采样值)。

而对于时间序列的基本特性来说,通常是过去的数据一般是只读的,是不会变更的,当前时间的数据才会可能在写,模式如下图:
3.png

根据上面的分析,时间序列的存储似乎可以设计成key-value存储的方式(基于BigTable)。
4.png

进一步拆分,可以像下面这样子:
5.png

上图的第二条样式就是现在Prometheus内部的表现形式了,__name__是特定的label标签,代表了metric name。

再回顾一下Prometheus的整体流程:
6.png

上面提到了K-V存储,当然是使用了LevelDB的引擎,它的特点是顺序读写性能非常高,这是非常符合时间序列的存储的。

为了得到顺序的时间序列哈希索引值,Prometheus是这样处理的:
7.png

FNV哈希算法全名为Fowler-Noll-Vo算法,是以三位发明人Glenn Fowler,Landon Curt Noll,Phong Vo的名字来命名的,最早在1991年提出。

FNV能快速hash大量数据并保持较小的冲突率,它的高度分散使它适用于hash一些非常相近的字符串,比如URL,hostname,文件名,text,IP地址等。

1KB Chunks

在Prometheus的世界中,无论是内存还是磁盘,它都是以1KB单位分成块来操作的。(新出的Prometheus 2.0对存储底层做了很大改动,专门针对SSD的写放大进行了优化,提高SSD的读写性能和读写次数等。)

整体流程是:抓取数据 -> 写到head chunk,写满1KB,就再生成新的块,完成的块,是不可再变更的 -> 根据配置文件的设置,有一部份chunk会被保留在内存里,按照LRU算法,定期将块写进磁盘文件内。

注意: 一条时间序列,保存到一个磁盘文件内。
8.png

时间序列的保留维护

在Prometheus的启动选项中,有一项storage.local.retention可以设置数据自动保留多长时间,例如24h,表示数据超过24小时内的将会自动清除,类似于zabbix的housekeeping功能。storage.local.series-file-shrink-ratio可以按一定的比例保留数据。
9.png

关于Chunk 块编码的剖析

Prometheus提供三种不同类型的块编码,用户可以在Prometheus启动时指定最新的编码方式,-storage.local.chunk-encoding-version,有效值是0,1,2。

版本0的编码是较老版本上的Prometheus上使用的,新版本已经不再建议使用的。

版本1是当前版本默认提供的编码方式,它相对于0版有较好的压缩能力,而且在一个块内,有较高的访问速度,当然版本0的编码速度是最快的,但是相对版本1,速度优势不是特别明显。

版本2提供了一个更高的压缩比例,编码和解码需要耗更多的CPU,当然,这是取决于查询的数据集有多大。通常如果是较少的查询,仅用于存档的数据,可以使用这种编码。

对比:
10.png

V0 结构:
11.png

V1 结构:
12.png

V2 结构:
13.png

Prometheus是如何访止数据丢失的呢?例如发生异常关闭或者什么别的情况?它提供了一个Checkpointing功能,对于内存里面的块,Prometheus使用了一个checkpoint file去同步写入磁盘,类似于Hbase的WAL原理,当发生crash时,先从checkpoint file去恢复数据。
14.png

以上内容是根据Prometheus官方人员的一份PPT摘取,获取PPT方式:扫描下面二维码关注公众号,回复『Prometheus』,即可获取下载链接。
qrcode_for_gh_e2b16c84652b_258.jpg

原文链接:https://www.cnblogs.com/vovlie/p/7709312.html 收起阅读 »

Cilium 1.7发布:Hubble UI、全集群网络策略、基于eBPF的Direct Server Return、TLS可见性、新的eBPF Go库以及更多……


在这里,我们要向大家高兴地宣布,Cilium 1.7版本正式发布了!在本轮更新周期当中,由141位开发者组成的项目社区共完成了1551项提交,而且很多朋友是第一次为Cilium项目提交贡献。
  • Hubble:自功能发布以来,很多朋友对于Hubble给出了积极的反馈。为了进一步简化集群连接的可视化与调试方式,我们以开源形式公布了新的Hubble UI,允许大家根据需求对其出调整及扩展!我们还一直在努力对Hubble的核心实现做出一系列改进,包括将网络流量数据同Kubernetes资源更好地关联起来。
  • Cilium全集群网络策略:1.7版本带来备有期待的集群内CNP功能。这意味着无论当前Pod处于哪个命名空间内,用户都可以使用面向整个集群中全部Pod的基准性网络策略。
  • 利用Direct Server Return替代kube-proxy:正如我们在Kubecon US 2019与FSDEM 2020大会上所提到,此次最新版本完善了Direct Server Return(DSR)作为kube-proxy继任者的服务功能集,同时为前者带来更多支持选项。这将进一步改进DSR在Cilium中的延迟与性能表现。此外,我们的DSR也已经结束了beta测试阶段,将在具有较新内核版本的环境中自动启用。
  • 通过TLS自省拓展L7策略:我们已经为Cilium添加了支持,可通过Kubernets资源或者本地文件配置Envoy TLS证书。这基功能使Cilium能够更透明地观察HTTP调用,同时在TLS加密会话上强制执行API感知策略。
  • 各Pod的L7可见性注释:在之前的版本中,如果要在集群内实现对流量的L7可见性,用户需要编写网络策略以保证Pod处于默认拒绝的状态。现在,新的可见性注释功能允许用户首先实现L7网络流量可见性,再根据这些观察结论制定完整的网络策略。
  • 纯Go eBPF库:此次发布的1.7版本,也是第一个使用由Cilium社区与CloudFlare联合编写的纯Go eBPF库的版本。这套简化库使Cilium得以摆脱CGo,在提高性能的同时降低二进制文件大小。
  • 通过Kubernetes EndpointSlice支持与Cilium代理改进改善可扩展性,进一步发展上游Linux,在托管Kubernetes上运行我们的测试环境,通过Helm分发Cilium等等!


Cilium是什么?

Cilium是一款开源软件,负责以透明方式提供并保护由Linux容器管理平台(例如Kubernetes)部署完成的各应用程序服务间的网络与API连接。
Cilium的基础,是一种被称为eBPF的新型linux内核技术。该技术能够在Linux内部动态插入功能强大的安全性、可见性与网络控制逻辑机制。eBPF能够实现多种功能,包括多集群路由、负载均衡(用以替代kube-proxy的部分),透明加密以及网络与服务安全性等。除了提供传统的网络级安全性外,eBPF的出色灵活性也使其能够在应用程序协议以及DNS请求/响应等情境下为安全增添助力。Cilium与Envoy紧密集成,同时提供基于Go语言的扩展框架。由于eBPF运行在Linux内核当中,因此可以在应用全部Cilium功能的同时,避免对应用程序代码或者窗口配置产生任何干扰。

新的USERS.md文件:谁在使用Cilium?

* N: Adobe, Inc.
D: Adobe's Project Ethos uses Cilium for multi-tenant, multi-cloud clusters
U: L3/L4/L7 policies
L: https://youtu.be/39FLsSc2P-Y
* N: CENGN - Centre of Excellence in Next Generation Networks
D: CENGN is using Cilium in multiple clusters including production and development clusters (self-hosted k8s, On-premises)
U: L3/L4/L7 network policies, Monitoring via Prometheus metrics & Hubble
L: https://www.youtube.com/watch?v=yXm7yZE2rk4
Q: @rmaika @mohahmed13
* N: Datadog
D: Datadog is using Cilium in AWS (self-hosted k8s)
U: ENI Networking, Service load-balancing, Encryption
Q: @lbernail, @roboll
[...] 

查看完整USERS.md文件

要提高项目水准,交流体验并向用户学习当然是不可或缺的一环。我们非常关注是哪些用户在就Cilium中的特定功能与其他用户开展讨论,也一直在跟进其中的经验与最佳实践。从结果来看,Cilium Slack社区非常活跃,而用户们抱怨的主要问题是很难快速在Cilium中找到特定功能。

如果您正在使用Cilium,可以将自己添加为用户、创建一条pull request、描述您如何使用Cilium,同时简要说明自己的使用场景。如果您希望在Slack上与其他Cilium用户联系,也可以在其中添加您自己的Slack昵称。

Hubble

作者:Sebastian Wicki与Sergey Generalov
01.png

Cilium 1.7的开发周期与Hubble的第一个预览版基本一致。Hubble是专门为Cilium设计的查看工具。通过使用Cilium的eBPF数据路径,Hubble能够深入了解Kubernetes应用与服务的网络流量。以此为基础,您可通过Hubble CLI及UI查询相关信息,并据此对DNS问题进行交互式故障排除。在监控过程中,Hubble将为您提供可扩展的指标框架,这套框架已经被良好集成至Prometheus与Grafana当中。关于更多相关信息,请参阅关于在Grafana中设置Hubble指标的教程资料

Cilium 1.7版本也针对Hubble推出多项新功能:首先是L7 Pod可见性注释,允许Hubble从DNS及HTTP流量中提取应用层信息。新版本还扩展了Cilium API,允许Hubble利用其他元数据标观察到的网络数据流,例如将Kubernetes ClusterIP与其对应的服务名称映射起来。

Hubble UI

02.png

Hubble UI能够全自动发现L3/L4甚至是L7中的Kubernetes集群服务依赖关系图,轻松帮助用户实现可视化,并将这些数据流以服务映射的形式加以过滤。在Hubble刚刚亮相时,我们就开始为用户提供包含其预览版的Docker镜像,确保每个人都能在Minikube中体验Hubble Service Map的功能,同时不会对原有源代码产生任何影响。

在这里我们高兴地宣布,Hubble UI代码现已开源,您可以通过以下链接从Cilium的GitHub项目中获取:https://github.com/cilium/hubble-ui

在Cilium 1.7的开发过程中,我们还对Hubble UI做出了多项性能改进,希望它能够在小规模多节点集群中更好地运作。当然,目前的Hubble UI仍处于预览阶段,我们建议大家通过Hubble GitHub页面或者Cilium Slack的#hubble频道上提供反馈。

Cilium全集群网络策略

作者:Deepesh Pathak与André Martins
03.png

新版本带来Cilium全集群网络策略(CCNP)。在1.7版本之前,所有Cilium网络策略都包含对应的命名空间,因此用户无法以简单易行的方式配置适用于整个集群的基准性策略。集群范围内的策略允许集群维护者应用面向全部命名空间内各个Pod的策略要求,借此简化默认状态下的标准应用方式,而不必受限于特定命名空间内的现有策略。在各类使用场景下,全集群策略都显得非常重要,例如:
  • 在创建任意命名空间时,自动应用默认拒绝策略以保障安全;
  • 允许调用一组特定的基准性允许目标,例如kube-dns,确保所有应用程序都使用相同的DNS目标或者已知IP范围;
  • 减少大规模环境中由网络策略带来的管理开销。


CiliumClusterwideNetworkPolicy资源规范与现有CiliumNetworkPolicy CRD规范相同,只是在适用范围方面有所区别,具体由YAML中的“kind”字段负责界定。大家可以为CCNP分别定义基于资源的访问控制(RBAC)机制,确保对特定命名空间内策略做出修改的用户,不会影响到覆盖全集群的基准性策略。以下策略示例将向整个集群内带有my-app标签组的所有Pod授权通过kube-dns执行DNS请求的权限:

策略示例

apiVersion: "cilium.io/v2"
kind: CiliumClusterwideNetworkPolicy
description: "Default deny and allow egress to kube-dns pod."
metadata:
name: "clusterwide-policy-example"
spec:
endpointSelector:
matchLabels:
  group: my-app
egress:
- toEndpoints:
    - matchLabels:
        "k8s:io.kubernetes.pod.namespace": kube-system
        k8s-app: kube-dns
  toPorts:
    - ports:
        - port: "53"

1.7版本中针对全集群范围引入的这项新功能,将帮助大家更轻松地创建出适用于整个集群的网络策略。

Direct Server Return取代原有kube-proxy

作者: Martynas Pumputis、Daniel Borkmann、Sebastian Wicki以及André Martins
04.png

1.7版本还带来其他多项新的改进,并为Cilium v1.6中首次引入的CiBula eBPF中的kube-proxy提供替代性功能。基于eBPF的kue-proxy作为替代性方案,能够实现对ClusterIP、NodePort、ExternalIP以及LoadBalancer等Kubernetes服务的处理能力。

与Kubernetes中的原始kube-proxy相比,eBPF中的kuber-proxy替代方案具有一系列重要优势,例如更出色的性能、可靠性以及可调试性等等。关于快速入门指南以及高级配置选项,请参阅无kube-proxy Kubernetes。此外,也推荐大家参阅Kubecon US 2019FOSDEM 2020上发布的实施细节与性能基准信息。

在新版本中,这套替代性方案已经趋于稳定,因此从beta版本升级至通用版本。只要底层Linux内核提供支持,通过Helm部署的新Cilium会在默认情况下透明启用kube-proxy替代组件。换句话说,即使继续配合kube-proxy环境,用户也仍将从基于eBPF的数据路径优化效果中获得收益。
感兴趣的朋友可以观看下面这段时长为2分钟的Cilium演示,了解如何在无需kube-proxy及内核外netfilter/iptables编译的情况下运行Kubernetes:

视频

Direct Server Return

当大家通过NodePort、ExternalIP或者LoadBalancer从外部访问Kubernetes服务时,Kubernetes的工作节点可能会将某条请求重新定向至另一节点。当服务端点与请求发送目标处于不同节点时,这样的情况就会发生。在重新定向之前,该请求已经进行过SNAT处理,因此后端将看不到客户端的源IP地址。同样的,答复将通过初始节点发送回客户端,并带来额外的延迟。

为了避免这个问题,Kubernetes提供externalTrafficPolicy = Local,因此如果接收节点上未运行任何服务端点,则通过向服务发送请求以保留客户端的源IP地址。但这种作法会提高负载均衡器的实现复杂度,并可能影响到负载均衡效果。

考虑到这一点,我们以eBPF为基础给Kubernetes服务带来了Direct Server Return。这不仅保留了客户端的源IP地址,同时也避免在向客户端发送回复时带来额外的跃点,具体如下图所示:
05.png

06.png

利用External IP支持Kubernetes服务

现在,用户可以使用External IP为公开服务提供外部IP。如果用户将目标IP设置为externalIP列表中的一项,则对应流量将被重新定向至该服务支持的某个Pod上。
apiVersion: v1
kind: Service
metadata:
name: external-service
namespace: kube-system
spec:
ports:
- name: service-port
  protocol: TCP
  port: 8080
externalIPs:
- 10.0.0.1
- 10.0.0.2
- 10.0.0.3

优化服务端点选择

通过ClusterIP、NodePort、ExternalIP或者LoadBalancer从Cilium管理的节点访问某项Kubernetes服务时,整个过程会在eBPF内经由基于嵌套的负载均衡机制进行处理。具体来讲,在TCP connect (2)系统调用期间,内核会直接接入服务的后端地址;如此一来,我们只需要做出一次后端选择,而不必对处于堆栈内较低层中的数据包本身执行速度缓慢的DNAT。

面对各类NodePort与ExternalIP服务,我们引入了一项优化机制,即直接在本地节点上选择后端,以供主机命名空间或Cilium管理的Kubernetes Pod进行通信。这种方式与常规kube-proxy有所区别:后者需要在网络中搭建额外的跃点,才能首先到达服务的对应节点地址;在最糟糕的情况下,其甚至会将请求转发至远程后端。
07.png

08.png

为每个数据包节省额外跳数带来的等待时间,相当于以透明方式改进了应用程序的整体性能。因为由Cilium管理的各个节点都拥有Kubernetes服务及其后端的全局视图,同时了解由Cilium管理的各远程节点的内部安全身份,所以这套新设计才能切实起效。

其他改进

  • 添加对LoadBalancer类服务的支持,而且我们已经成功测试了利用MetalLB替代kuber-proxy的可能性。
  • 在Cilium中,kube-proxy的替代方案现在全面支持之前提到的,面向NodePort与LoadBalancer服务的externalTrafficPolicy设置。对于包含Local流量策略的服务,如果其请求中的节点不含任何本地服务端点,则服务本身会被直接丢弃,从而避免产生不必要的额外跃点。
  • 外部负载均衡器必须了解各独立节点上的服务端点可用性,为此Cilium 1.7版本开始支持Kubernetes的healthCheckNodePort字段。现在,Cilium用户空间代理将通过一项服务状态检查,对包含externalTrafficPolicy=Local的所有LoadBalacner类Kubernetes服务执行检查。
  • 对于每项NodePort服务,kube-proxy会在主机命名空间内开启一个嵌套,并将NodePort端口与该嵌套绑定起来,以防止被其他应用程序重用。如果服务数量庞大,这种作法会增加内核的资源开销总量,例如分配并绑定成千上万个嵌套。Cilium的kube-proxy替代方案彻底避免了这个问题——新方案不再分配单一嵌套,而是以基于嵌套的负载均衡机制明智eBPF bind (2) hook查询其eBPF服务映射,并拒绝那些指向含错误代码的端口的应用程序bind (2)请求。
  • Cilium提供的eBPF内基于嵌套负载均衡机制现在可支持IPv4-in-IPv6服务地址翻译。这意味着用户能够从纯IPv6应用中的connect(20/Sendmsg(2) hook中选择IPv4服务后端。这项功能通常在语言运行时(例如Java)内默认开启。
  • 我们还开发出广泛的kube-proxy兼容性测试套件,其中包含超过350项测试用例(现已作为Cilium CI基础设施的组成部分运行),以确保eBPF kube-proxy替代方案不致造成语义兼容影响。


用于L7策略的TLS可见性(beta版)

作者:Jarno Rajahalme

Cilium网络策略(CNP)规范中包含两项新的实验性扩展(可能在今后版本中做出调整),用以对受TLS保护的HTTP连接执行策略。两项都属于端口级策略规则,其一用于定义TLS上下文中如何终止Cilium主机代理内由客户端启动的TLS连接;其二则用于为上游代理连接发起TLS。为了实现代理TLS拦截功能,用户需要使用本地CA证书配置客户端容器,也可利用该本地CA证书根据端口规划创建TLS secret。关于更多细节介绍,请点击此处参阅入门指南
09.png

其次,HTTP规则层级迎来新的标头匹配,可对不匹配的标头执行正常标头操作。用户可以从Kubernetes Secret中获取匹配的标头值,这就避免了在CNP之内直接指定任何保密信息。目前支持的不匹配操作包括LOG、ADD、DELETE以及REPLACE。以此为基础,用户策略能够检查并记录正确的标头值,或者将不正确的值替换为正确的标头值。在得到客户端授权的标头上应用REPLACE不匹配操作,即可保证您的secret令牌永远不会被公开给应用程序容器。

将这些新功能与toFQDN规则配合起来,不仅能够将TLS流量限制为特定域名上的外部服务,同时也能强制执行并访问包含HTTP元数据(包括路径、方法及标头等)的记录,从而防止数据泄露事故的发生。

通过Pod注释实现L7协议可见性

作者:Ian Vernon与Joe Stringer

当用户将Cilium作为CNI运行时,如果单一数据包通过Cilium eBPF数据路径进行传递,那么Hubble或Cilium monitor等工具默认只能查看L2、L3及L4的信息。用户当然可以通过L7策略为集群中的网络流添加更强的API可见性与强制性,但在以往版本当中,要想既保证可见性又不丢失任何其他流量,用户必须为选定的端点构建完整策略、以确保各端点间的预期流量都能正常通过。

为了帮助用户对正在进行的API调用进行自省,且不必为端点构建完整策略,我们增加了对代理可见性注释的支持机制。用户可以在Pod上添加注释、指示方向、选择端口之间的活动协议,而后Cilium会收集当前API调用的相关信息并将其分发给Hubble等其他工具。下图中,tiefighter正将API请求发布至v1/request-landing上的deathstar。
010.png

感兴趣的朋友可以点击此处,了解关于Pod注释的更多细节信息。

纯Go eBPF库

作者:Joe Stringer

Linux Plumbers 2019 eBPF会议期间,Cilium核心开发者与Cloudflare的工程师们共同发布一项纯Go eBPF库提案,希望借此解决基于Cilium或Cloudflare的L4负载均衡器等由Go语言编写的长期守护进程与eBPF内核间的交互问题。解决方案的一项基本要求,就是避免引入CGo

目前,这项工作正在有序推进。在Cilium 1.7中,负责从eBPF数据路径向用户空间cilium-agent进程发送消息的ring-buffer迎来升级,从以往的CGo实现方案转化为速度更快、效率更高的新库。

目前已经存在多种eBPF外部库选项,包括libbpf或者libbcc。虽然前者已经成为新的规范,能够在Linux内核中支持基于C/C++语言的应用程序,但却还无法在纯Go环境下使用。后者的情况也差不多,能够很好地跟踪、打包libbpf甚至LLVM的eBPF后端,但同样不兼容Go环境。具有类似功能的其他Go库则依赖于CGo,虽然能够正常起效但却难以构建,而且在C与Go环境之间往往会带来高昂的上下文切换成本。

我们希望通过一套纯Go eBPF库解决Cilium与Cloudflare对于eBPF编排的生产级需求,也希望帮助整体Go社区更轻松地与我们内核中的eBPF子系统实现交互。这套新库主要面向网络用例,用于尽可能减少外部依赖性、解决常见问题,并在纯Go环境下提供一套经过测试及实证、足以支撑生产环境要求的eBPF库。

除了最初的eBPF映射、程序与ring-buffer之外,这套库还在开发过程中不断实现新的扩展,包括支持BPF类型格式(BTF),以及利用静态数据替代原始方案以支持模板化eBPF程序的想法(一次编译即可随处使用)。

要了解更多关于eBPF库的细节信息,请参阅Cilium GitHub项目下的: https://github.com/cilium/ebpf

支持Kubernetes EndpointSlice

作者:André Martins

为了给单一服务中的大量端点提供更好的可扩展性,Kubernetes 1.16版本引入了EndpointSlice。自Kubernetes 1.17版本以来,该API正式进入beta阶段并开始默认启用。遗憾的是,其中负责管理Endpoint Slices的控制器不会默认启用,因此用户需要参考指南启动控制器,才能真正享受到这项改进。

Cilium 1.7引入了enable-k8s-endpoint-slice这一全新标记,该标记默认处于启用状态,能够自动检测集群当中是否存在Endpoint Slice,并利用它们在eBPF中执行全服务转换。关闭也非常简单:用户只需要将该标记设置为false,即可继续使用之前版本提供的v1/Endpoints类型。

可扩展性

作者: Ian Vernon与André Martins

CNP节点状态

作为CiliumNetworkPolicy(CNP)可扩展性改进中的组成部分,Cilium引入了另一项新标记: enable-k8s-event-handover。

当大家在集群内创建新的CNP时,所有Cilium代理都会收到来自Kubernetes的通知事件;一旦新策略在数据路径内得到强制执行,各节点都将在CNP状态字段中更新自身状态。在拥有大量节点的情况下,这项机制可能快速提升kube-apiserver中的CPU使用率,因为每一个Cilium代理在收到更新时,都需要将其以Kubernetes新事件的形式发送给其余全部节点。在之前的版本中,我们支持使用--disable-cnp-status-updates标记彻底禁用这项功能,但这太过粗暴,会导致用户无法了解CNP的执行状态。

当启用enable-k8s-event-handover时,整个实现过程将略有不同:不同于在CNP中更新状态字段的方式,现在每个Cilium代理都会将自身状态更新至KVstore当中。接下来,Cilium Operator会在KVstore中为各个CNP监控更新,对各CNP中的所有Cilium代理状态执行增量更新。最后,CNP状态还是会被体现在Kubernetes当中,但整个填充方式明显更加高效。

Cilium代理

由于Cilium不再依赖于容器运行时,因此所有容器运行时层面的依赖关系都不必保存在Cilium之内。如此一来,cilium-agent二进制文件的大小能够从97 M下降至74 M。

Golang 1.13

Cilium 1.7使用Golang 1.13编译而成,能够在运行时内进行大量内存优化,从而减少Cilium的内存占用量。

Linux内核变更

作者:Daniel Borkmann

在Cilium 1.7开发周期当中,我们还对参与维护的Linux内核eBPF子系统做出一系列改进。接下来要提到的几项重要变更,将为全体eBPF用户带来助益,不过具体效果视Cilium与Hubble的eBPF实施方式而定。所有变更,都已被纳入新近发布的Linux 5.5内核当中。

eBPF程序的实时补丁机制

考虑到Cilium的数据路径需要支持多种Linux内核,从4.9版本到最新版本,因此大部分eBPF数据路径功能被拆分为eBPF尾调用。这种处理方式在启用Cilium的eBPF kube-proxy替代方案的情况下体现得尤其明显。除了降低较旧内核的验证程序复杂性之外,这种eBPF尾调用方式还能够以原子方式替换特定Pod中的eBPF功能,同时保证不对实时系统上的服务产生影响。

内核中的x86-64 eBPF JIT编译器会将eBPF尾调用映射为间接跳转,意味着从BPF尾调用映射中加载的当前eBPF程序地址会被提交至jmpq *%rax中的%rax寄存器内。由于现代CPU上存在多种推测性执行缺陷,因此我们后来决定将eBPF JIT转为emit retpolines形式,借此避免对性能造成严重影响。另外,考虑到所有主流编译器都采用到这项技术,因此Linux内核社区一直在努力避免在快速路径代码中执行间接调用。例如,将eBPF映射相关帮助程序转换为直接调用,能够立刻将性能提升14%。

在现在的5.5内核当中,我们对验证程序内跟踪了所有eBPF尾调用映射索引。如果验证程序最终确定所有程序路径中的特定索引都保持恒定不变,那么如同处理Cilium中的eBPF程序一样,我们也可以发出直接跳转指令。在尾调用程序更新完成后,eBPF JIT镜像将会直接跳转至新的位置。

为了演示该技术,以下示例eBPF程序将实现一项指向常量映射索引0的eBPF尾调用跳转:
0: (b7) r3 = 0
1: (18) r2 = map[id:526]
3: (85) call bpf_tail_call#12
4: (b7) r0 = 1
5: (95) exit

这个x86-64 eBPF JIT程序会在较旧的内核版本上发出retpoline(以粗体显示):
0xffffffffc076e55c:
[...]                                  _
19:   xor    %edx,%edx                |_ index (r3 = 0)
1b:   movabs $0xffff88d95cc82600,%rsi |_ map (r2 = map[id:526])
25:   mov    %edx,%edx                |  index >= array->map.max_entries check
27:   cmp    %edx,0x24(%rsi)          |
2a:   jbe    0x0000000000000066       |_
2c:   mov    -0x224(%rbp),%eax        |  tail call limit check
32:   cmp    $0x20,%eax               |
35:   ja     0x0000000000000066       |
37:   add    $0x1,%eax                |
3a:   mov    %eax,-0x224(%rbp)        |_
40:   mov    0xd0(%rsi,%rdx,8),%rax   |_ prog = array->ptrs[index]
48:   test   %rax,%rax                |  prog == NULL check
4b:   je     0x0000000000000066       |_
4d:   mov    0x30(%rax),%rax          |  goto *(prog->bpf_func + prologue_size)
51:   add    $0x19,%rax               |
55:   callq  0x0000000000000061       |  retpoline for indirect jump
5a:   pause                           |
5c:   lfence                          |
5f:   jmp    0x000000000000005a       |
61:   mov    %rax,(%rsp)              |
65:   retq                            |_
66:   mov    $0x1,%eax                (next instruction, r0 = 1)
[...]

对于5.5以及更新内核,此程序则会被优化为直接跳转(以粗体标记),且无需对代码做出任何修改:
0xffffffffc08e8930:
[...]                                  _
19:   xor    %edx,%edx                |_ index (r3 = 0)
1b:   movabs $0xffff9d8afd74c000,%rsi |_ map (r2 = map[id:526])
25:   mov    -0x224(%rbp),%eax        |  tail call limit check
2b:   cmp    $0x20,%eax               |
2e:   ja     0x000000000000003e       |
30:   add    $0x1,%eax                |
33:   mov    %eax,-0x224(%rbp)        |_
39:   jmpq   0xfffffffffffd1785       |_ [direct] goto *(prog->bpf_func + prologue_size)
3e:   mov    $0x1,%eax                (next instruction, r0 = 1)
[...]

程序更新之后,地址39上的jmpq 0xfffffffffffd1785指令将根据新的目标eBPF程序上的地址进行实时更新。同样的,如果目标eBPF程序被从尾调用映射中删除,那么该jmp也会被更新至同样大小的nop指令中,转化为可接受的失败形式:
0xffffffffc08e8930:
[...]                                  _
19:   xor    %edx,%edx                |_ index (r3 = 0)
1b:   movabs $0xffff9d8afd74c000,%rsi |_ map (r2 = map[id:526])
25:   mov    -0x224(%rbp),%eax        |
2b:   cmp    $0x20,%eax               .
2e:   ja     0x000000000000003e       .
30:   add    $0x1,%eax                .
33:   mov    %eax,-0x224(%rbp)        |_
39:   nopl   0x0(%rax,%rax,1)         |_ fall-through nop
3e:   mov    $0x1,%eax                (next instruction, r0 = 1)
[...]

如此一来,内核不必将推测重新定向到pause/lfence循环当中,也不必为给定的直接地址跳转执行任何推测,这将使eBPF代码的执行效率快速迈上一个台阶。

您可以点击此处了解关于合并补丁集的更多细节信息。

为eBPF探针帮助程序提供安全的多架构支持机制

Cilium的数据路径能够通过高性能、可定制的ring-buffer向用户空间代理导出各种聚合性跟踪信息。以Cilium为基础的Hubble,则借此成为一套高可见、全分布式网络与安全平台,帮助用户对服务及网络基础设施中的通信与行为建立起深入了解。此外,Hubbler还通过基于eBPF的内核跟踪机制进一步丰富收集到的信息。

由于多架构支持目前正在开发当中,计划通过Cilium 1.8版本正式发布,因此我们只能对这项新机制做出简要描述:我们在内核中构建起一组新的eBPF帮助程序,希望立足多种架构、更安全地对基于eBPF的内存进行探测。

目前的probe_kernel_read()与 bpf_probe_read_str()eBPF帮助程序集存在一系列缺点:虽然在大多数情况下,用户可以安全地禁用页面错误提示,但在x86-64上发生用户访问违规地址时,直接禁用很可能引发严重后果。这是因为非规范地址的范围,通常与应用程序用于标记指针的用户空间存在重合。

另一大严重缺陷,在于之前提到的两种eBPF帮助程序无法兼容非x86类架构,因为二者会假定自己需要探测的是内核空间地址与用户空间地址。很明显如果是在KERNEL_DS下执行访问,那么这两种尝试最终都只会指向内核空间地址空间。另外,x86的地址空间不重叠,但其他某些架构则允许地址重叠,意味着内核指针与用户指针可能具有相同的地址值。

因此,当我们把of bpf_probe_read_user()、bpf_probe_read_kernel() 、bpf_probe_read_user_str()以及bpf_probe_read_kernel_str() eBPF帮助程序集添加到USER_DS或者KERNEL_DS等具有严格访问限制的内核中时,将无法触发本应被激活的访问警告。

大家可以点击此处了解更多关于合并补丁集的细节信息。

在托管Kubernetes方案上支持Cilium测试

作者:Maciej Kwiek与Ray Bejjani

由于我们的测试框架与运行测试的集群之间存在着紧密的假设关联,因此Cilium的端到端测试一直比较令人头痛。在新自带,我们付出了大量努力,希望帮助开发人员缓解由此带来的压力。

Cilium 1.7为开发人员敞开大门,允许各位使用GKE等服务商提供的Kubernetes集群测试套件。各位能够在我们的CI GKE集群上运行多种可选测试,显著缓解本地集群的管理负担;此外,由于集群配置速度更快,因此您可以尝试进行更多测试驱动型开发工作。

Helm 3与Helm库

作者: Arthur Evstifeev与Joe Stringer

Helm3之前刚刚发布,其简化了Helm图表库的使用方式,而且不再需要配合Tiller即可实现集群安装。从1.7版本开始,我们将扩展1.6版本引入的Helm模板支持机制,允许用户将来自https://helm.cilium.io的Helm库与Cilium匹配起来。所有Cilium指南均已更新为Helm3语法版本,下面来看Helm3库的具体安装方式:

示例:为GKE配置Cilium
helm repo add cilium https://helm.cilium.io/
helm install cilium cilium/cilium \
--namespace cilium \
--set global.cni.binPath=/home/kubernetes/bin \
--set global.nodeinit.enabled=true \
--set nodeinit.reconfigureKubelet=true \
--set nodeinit.removeCbrBridge=true

1.7版本发布要点

对eBPF中的kube-proxy替代方案做出多项增强

  • 新的Direct Server Return(DSR)模式,可带来更低延迟与更好的客户端源IP保存效果
  • 支持Kubernetes ExternalIPs与LoadBalancer服务
  • 添加对NodePort健康状态检查服务的支持
  • 添加externalTrafficPolicy=Local处理机制
  • 在基于嵌套的负载均衡中实现对IPv4-in-IPv6支持能力
  • 为基于嵌套的负载均衡机制带来端点选择优化
  • 多项SNAT优化与更好的端口冲突处理能力
  • 通过绑定hook有效检测NodePort与ExternalIPs端口重用情况
  • 以默认方式为新的部署场景提供全新功能探测模式
  • 用于Cilium CI的kube-proxy全面兼容性测试套件


策略

  • TLS可见性策略(beta版)
  • 通过Pod注释实现L7可见性
  • 支持将远程节点作为单独的身份进行处理
  • 通过FQDN策略改进DNS超时处理机制


Kubernetes

  • 得到Kubernetes 1.17验证通过
  • 支持双栈模式
  • 支持EndpointSlices
  • 为Cilium资源提供更好的CRD验证


数据路径

  • eBPF与cillium-agent间的ring-buffer通信效率得到提升
  • 支持更灵活的连接事件聚合功能
  • 更好地限制大型映射转储
  • 改进了对大型CIDR策略的处理方式
  • 利用bpftool检测功能支持效果
  • 支持通过代理选项向ICMP片段转发消息
  • 支持指向NodePorts的绑定


可扩展性与资源消费

  • 将CiliumNetworkPolicy状态报告移交给KVstore与cilium-operator
  • 优化Go 1.13代码体积
  • 消除容器运行(CRI)中的依赖关系


CLI

  • 改进cilium status命令中的信息内容
  • zsh命令补全功能


说明文档

  • 使用Helm 3执行部署指令
  • 添加用于Cilium helm图的专用库
  • 对Kubernetes指南中的链接与内容进行多项修正


Istio

  • 支持1.4.3版本


内核变更

  • eBPF程序实时补丁
  • 多架构支持能力提升


Hubble

  • Hubble图形用户界面
  • 将其他元数据与网络流关联起来


持续集成/测试

  • 支持在GKE与eKS托管集群内运行Cilium CI


感兴趣的朋友可以点击此处,查看Cilium 1.7开发周期内的完整变更说明。

快速入门

您第一次接触Cilium?请参阅我们的《入门指南》获取更多帮助。

升级说明

与以往版本一样,请点击此处根据我们的升级指南升级您的Cilium部署方案。另外,您也可以点击此处通过Slack与我们联系。

发布

  • 发布说明与二进制文件:1.7.0
  • 容器镜像:docker.io/cilium/cilium:v1.7.0


原文链接:Cilium 1.7: Hubble UI, Cluster-wide Network Policies, eBPF-based Direct Server Return, TLS visibility, New eBPF Go Library, ...
继续阅读 »

在这里,我们要向大家高兴地宣布,Cilium 1.7版本正式发布了!在本轮更新周期当中,由141位开发者组成的项目社区共完成了1551项提交,而且很多朋友是第一次为Cilium项目提交贡献。
  • Hubble:自功能发布以来,很多朋友对于Hubble给出了积极的反馈。为了进一步简化集群连接的可视化与调试方式,我们以开源形式公布了新的Hubble UI,允许大家根据需求对其出调整及扩展!我们还一直在努力对Hubble的核心实现做出一系列改进,包括将网络流量数据同Kubernetes资源更好地关联起来。
  • Cilium全集群网络策略:1.7版本带来备有期待的集群内CNP功能。这意味着无论当前Pod处于哪个命名空间内,用户都可以使用面向整个集群中全部Pod的基准性网络策略。
  • 利用Direct Server Return替代kube-proxy:正如我们在Kubecon US 2019与FSDEM 2020大会上所提到,此次最新版本完善了Direct Server Return(DSR)作为kube-proxy继任者的服务功能集,同时为前者带来更多支持选项。这将进一步改进DSR在Cilium中的延迟与性能表现。此外,我们的DSR也已经结束了beta测试阶段,将在具有较新内核版本的环境中自动启用。
  • 通过TLS自省拓展L7策略:我们已经为Cilium添加了支持,可通过Kubernets资源或者本地文件配置Envoy TLS证书。这基功能使Cilium能够更透明地观察HTTP调用,同时在TLS加密会话上强制执行API感知策略。
  • 各Pod的L7可见性注释:在之前的版本中,如果要在集群内实现对流量的L7可见性,用户需要编写网络策略以保证Pod处于默认拒绝的状态。现在,新的可见性注释功能允许用户首先实现L7网络流量可见性,再根据这些观察结论制定完整的网络策略。
  • 纯Go eBPF库:此次发布的1.7版本,也是第一个使用由Cilium社区与CloudFlare联合编写的纯Go eBPF库的版本。这套简化库使Cilium得以摆脱CGo,在提高性能的同时降低二进制文件大小。
  • 通过Kubernetes EndpointSlice支持与Cilium代理改进改善可扩展性,进一步发展上游Linux,在托管Kubernetes上运行我们的测试环境,通过Helm分发Cilium等等!


Cilium是什么?

Cilium是一款开源软件,负责以透明方式提供并保护由Linux容器管理平台(例如Kubernetes)部署完成的各应用程序服务间的网络与API连接。
Cilium的基础,是一种被称为eBPF的新型linux内核技术。该技术能够在Linux内部动态插入功能强大的安全性、可见性与网络控制逻辑机制。eBPF能够实现多种功能,包括多集群路由、负载均衡(用以替代kube-proxy的部分),透明加密以及网络与服务安全性等。除了提供传统的网络级安全性外,eBPF的出色灵活性也使其能够在应用程序协议以及DNS请求/响应等情境下为安全增添助力。Cilium与Envoy紧密集成,同时提供基于Go语言的扩展框架。由于eBPF运行在Linux内核当中,因此可以在应用全部Cilium功能的同时,避免对应用程序代码或者窗口配置产生任何干扰。

新的USERS.md文件:谁在使用Cilium?

* N: Adobe, Inc.
D: Adobe's Project Ethos uses Cilium for multi-tenant, multi-cloud clusters
U: L3/L4/L7 policies
L: https://youtu.be/39FLsSc2P-Y
* N: CENGN - Centre of Excellence in Next Generation Networks
D: CENGN is using Cilium in multiple clusters including production and development clusters (self-hosted k8s, On-premises)
U: L3/L4/L7 network policies, Monitoring via Prometheus metrics & Hubble
L: https://www.youtube.com/watch?v=yXm7yZE2rk4
Q: @rmaika @mohahmed13
* N: Datadog
D: Datadog is using Cilium in AWS (self-hosted k8s)
U: ENI Networking, Service load-balancing, Encryption
Q: @lbernail, @roboll
[...] 

查看完整USERS.md文件

要提高项目水准,交流体验并向用户学习当然是不可或缺的一环。我们非常关注是哪些用户在就Cilium中的特定功能与其他用户开展讨论,也一直在跟进其中的经验与最佳实践。从结果来看,Cilium Slack社区非常活跃,而用户们抱怨的主要问题是很难快速在Cilium中找到特定功能。

如果您正在使用Cilium,可以将自己添加为用户、创建一条pull request、描述您如何使用Cilium,同时简要说明自己的使用场景。如果您希望在Slack上与其他Cilium用户联系,也可以在其中添加您自己的Slack昵称。

Hubble

作者:Sebastian Wicki与Sergey Generalov
01.png

Cilium 1.7的开发周期与Hubble的第一个预览版基本一致。Hubble是专门为Cilium设计的查看工具。通过使用Cilium的eBPF数据路径,Hubble能够深入了解Kubernetes应用与服务的网络流量。以此为基础,您可通过Hubble CLI及UI查询相关信息,并据此对DNS问题进行交互式故障排除。在监控过程中,Hubble将为您提供可扩展的指标框架,这套框架已经被良好集成至Prometheus与Grafana当中。关于更多相关信息,请参阅关于在Grafana中设置Hubble指标的教程资料

Cilium 1.7版本也针对Hubble推出多项新功能:首先是L7 Pod可见性注释,允许Hubble从DNS及HTTP流量中提取应用层信息。新版本还扩展了Cilium API,允许Hubble利用其他元数据标观察到的网络数据流,例如将Kubernetes ClusterIP与其对应的服务名称映射起来。

Hubble UI

02.png

Hubble UI能够全自动发现L3/L4甚至是L7中的Kubernetes集群服务依赖关系图,轻松帮助用户实现可视化,并将这些数据流以服务映射的形式加以过滤。在Hubble刚刚亮相时,我们就开始为用户提供包含其预览版的Docker镜像,确保每个人都能在Minikube中体验Hubble Service Map的功能,同时不会对原有源代码产生任何影响。

在这里我们高兴地宣布,Hubble UI代码现已开源,您可以通过以下链接从Cilium的GitHub项目中获取:https://github.com/cilium/hubble-ui

在Cilium 1.7的开发过程中,我们还对Hubble UI做出了多项性能改进,希望它能够在小规模多节点集群中更好地运作。当然,目前的Hubble UI仍处于预览阶段,我们建议大家通过Hubble GitHub页面或者Cilium Slack的#hubble频道上提供反馈。

Cilium全集群网络策略

作者:Deepesh Pathak与André Martins
03.png

新版本带来Cilium全集群网络策略(CCNP)。在1.7版本之前,所有Cilium网络策略都包含对应的命名空间,因此用户无法以简单易行的方式配置适用于整个集群的基准性策略。集群范围内的策略允许集群维护者应用面向全部命名空间内各个Pod的策略要求,借此简化默认状态下的标准应用方式,而不必受限于特定命名空间内的现有策略。在各类使用场景下,全集群策略都显得非常重要,例如:
  • 在创建任意命名空间时,自动应用默认拒绝策略以保障安全;
  • 允许调用一组特定的基准性允许目标,例如kube-dns,确保所有应用程序都使用相同的DNS目标或者已知IP范围;
  • 减少大规模环境中由网络策略带来的管理开销。


CiliumClusterwideNetworkPolicy资源规范与现有CiliumNetworkPolicy CRD规范相同,只是在适用范围方面有所区别,具体由YAML中的“kind”字段负责界定。大家可以为CCNP分别定义基于资源的访问控制(RBAC)机制,确保对特定命名空间内策略做出修改的用户,不会影响到覆盖全集群的基准性策略。以下策略示例将向整个集群内带有my-app标签组的所有Pod授权通过kube-dns执行DNS请求的权限:

策略示例

apiVersion: "cilium.io/v2"
kind: CiliumClusterwideNetworkPolicy
description: "Default deny and allow egress to kube-dns pod."
metadata:
name: "clusterwide-policy-example"
spec:
endpointSelector:
matchLabels:
  group: my-app
egress:
- toEndpoints:
    - matchLabels:
        "k8s:io.kubernetes.pod.namespace": kube-system
        k8s-app: kube-dns
  toPorts:
    - ports:
        - port: "53"

1.7版本中针对全集群范围引入的这项新功能,将帮助大家更轻松地创建出适用于整个集群的网络策略。

Direct Server Return取代原有kube-proxy

作者: Martynas Pumputis、Daniel Borkmann、Sebastian Wicki以及André Martins
04.png

1.7版本还带来其他多项新的改进,并为Cilium v1.6中首次引入的CiBula eBPF中的kube-proxy提供替代性功能。基于eBPF的kue-proxy作为替代性方案,能够实现对ClusterIP、NodePort、ExternalIP以及LoadBalancer等Kubernetes服务的处理能力。

与Kubernetes中的原始kube-proxy相比,eBPF中的kuber-proxy替代方案具有一系列重要优势,例如更出色的性能、可靠性以及可调试性等等。关于快速入门指南以及高级配置选项,请参阅无kube-proxy Kubernetes。此外,也推荐大家参阅Kubecon US 2019FOSDEM 2020上发布的实施细节与性能基准信息。

在新版本中,这套替代性方案已经趋于稳定,因此从beta版本升级至通用版本。只要底层Linux内核提供支持,通过Helm部署的新Cilium会在默认情况下透明启用kube-proxy替代组件。换句话说,即使继续配合kube-proxy环境,用户也仍将从基于eBPF的数据路径优化效果中获得收益。
感兴趣的朋友可以观看下面这段时长为2分钟的Cilium演示,了解如何在无需kube-proxy及内核外netfilter/iptables编译的情况下运行Kubernetes:

视频

Direct Server Return

当大家通过NodePort、ExternalIP或者LoadBalancer从外部访问Kubernetes服务时,Kubernetes的工作节点可能会将某条请求重新定向至另一节点。当服务端点与请求发送目标处于不同节点时,这样的情况就会发生。在重新定向之前,该请求已经进行过SNAT处理,因此后端将看不到客户端的源IP地址。同样的,答复将通过初始节点发送回客户端,并带来额外的延迟。

为了避免这个问题,Kubernetes提供externalTrafficPolicy = Local,因此如果接收节点上未运行任何服务端点,则通过向服务发送请求以保留客户端的源IP地址。但这种作法会提高负载均衡器的实现复杂度,并可能影响到负载均衡效果。

考虑到这一点,我们以eBPF为基础给Kubernetes服务带来了Direct Server Return。这不仅保留了客户端的源IP地址,同时也避免在向客户端发送回复时带来额外的跃点,具体如下图所示:
05.png

06.png

利用External IP支持Kubernetes服务

现在,用户可以使用External IP为公开服务提供外部IP。如果用户将目标IP设置为externalIP列表中的一项,则对应流量将被重新定向至该服务支持的某个Pod上。
apiVersion: v1
kind: Service
metadata:
name: external-service
namespace: kube-system
spec:
ports:
- name: service-port
  protocol: TCP
  port: 8080
externalIPs:
- 10.0.0.1
- 10.0.0.2
- 10.0.0.3

优化服务端点选择

通过ClusterIP、NodePort、ExternalIP或者LoadBalancer从Cilium管理的节点访问某项Kubernetes服务时,整个过程会在eBPF内经由基于嵌套的负载均衡机制进行处理。具体来讲,在TCP connect (2)系统调用期间,内核会直接接入服务的后端地址;如此一来,我们只需要做出一次后端选择,而不必对处于堆栈内较低层中的数据包本身执行速度缓慢的DNAT。

面对各类NodePort与ExternalIP服务,我们引入了一项优化机制,即直接在本地节点上选择后端,以供主机命名空间或Cilium管理的Kubernetes Pod进行通信。这种方式与常规kube-proxy有所区别:后者需要在网络中搭建额外的跃点,才能首先到达服务的对应节点地址;在最糟糕的情况下,其甚至会将请求转发至远程后端。
07.png

08.png

为每个数据包节省额外跳数带来的等待时间,相当于以透明方式改进了应用程序的整体性能。因为由Cilium管理的各个节点都拥有Kubernetes服务及其后端的全局视图,同时了解由Cilium管理的各远程节点的内部安全身份,所以这套新设计才能切实起效。

其他改进

  • 添加对LoadBalancer类服务的支持,而且我们已经成功测试了利用MetalLB替代kuber-proxy的可能性。
  • 在Cilium中,kube-proxy的替代方案现在全面支持之前提到的,面向NodePort与LoadBalancer服务的externalTrafficPolicy设置。对于包含Local流量策略的服务,如果其请求中的节点不含任何本地服务端点,则服务本身会被直接丢弃,从而避免产生不必要的额外跃点。
  • 外部负载均衡器必须了解各独立节点上的服务端点可用性,为此Cilium 1.7版本开始支持Kubernetes的healthCheckNodePort字段。现在,Cilium用户空间代理将通过一项服务状态检查,对包含externalTrafficPolicy=Local的所有LoadBalacner类Kubernetes服务执行检查。
  • 对于每项NodePort服务,kube-proxy会在主机命名空间内开启一个嵌套,并将NodePort端口与该嵌套绑定起来,以防止被其他应用程序重用。如果服务数量庞大,这种作法会增加内核的资源开销总量,例如分配并绑定成千上万个嵌套。Cilium的kube-proxy替代方案彻底避免了这个问题——新方案不再分配单一嵌套,而是以基于嵌套的负载均衡机制明智eBPF bind (2) hook查询其eBPF服务映射,并拒绝那些指向含错误代码的端口的应用程序bind (2)请求。
  • Cilium提供的eBPF内基于嵌套负载均衡机制现在可支持IPv4-in-IPv6服务地址翻译。这意味着用户能够从纯IPv6应用中的connect(20/Sendmsg(2) hook中选择IPv4服务后端。这项功能通常在语言运行时(例如Java)内默认开启。
  • 我们还开发出广泛的kube-proxy兼容性测试套件,其中包含超过350项测试用例(现已作为Cilium CI基础设施的组成部分运行),以确保eBPF kube-proxy替代方案不致造成语义兼容影响。


用于L7策略的TLS可见性(beta版)

作者:Jarno Rajahalme

Cilium网络策略(CNP)规范中包含两项新的实验性扩展(可能在今后版本中做出调整),用以对受TLS保护的HTTP连接执行策略。两项都属于端口级策略规则,其一用于定义TLS上下文中如何终止Cilium主机代理内由客户端启动的TLS连接;其二则用于为上游代理连接发起TLS。为了实现代理TLS拦截功能,用户需要使用本地CA证书配置客户端容器,也可利用该本地CA证书根据端口规划创建TLS secret。关于更多细节介绍,请点击此处参阅入门指南
09.png

其次,HTTP规则层级迎来新的标头匹配,可对不匹配的标头执行正常标头操作。用户可以从Kubernetes Secret中获取匹配的标头值,这就避免了在CNP之内直接指定任何保密信息。目前支持的不匹配操作包括LOG、ADD、DELETE以及REPLACE。以此为基础,用户策略能够检查并记录正确的标头值,或者将不正确的值替换为正确的标头值。在得到客户端授权的标头上应用REPLACE不匹配操作,即可保证您的secret令牌永远不会被公开给应用程序容器。

将这些新功能与toFQDN规则配合起来,不仅能够将TLS流量限制为特定域名上的外部服务,同时也能强制执行并访问包含HTTP元数据(包括路径、方法及标头等)的记录,从而防止数据泄露事故的发生。

通过Pod注释实现L7协议可见性

作者:Ian Vernon与Joe Stringer

当用户将Cilium作为CNI运行时,如果单一数据包通过Cilium eBPF数据路径进行传递,那么Hubble或Cilium monitor等工具默认只能查看L2、L3及L4的信息。用户当然可以通过L7策略为集群中的网络流添加更强的API可见性与强制性,但在以往版本当中,要想既保证可见性又不丢失任何其他流量,用户必须为选定的端点构建完整策略、以确保各端点间的预期流量都能正常通过。

为了帮助用户对正在进行的API调用进行自省,且不必为端点构建完整策略,我们增加了对代理可见性注释的支持机制。用户可以在Pod上添加注释、指示方向、选择端口之间的活动协议,而后Cilium会收集当前API调用的相关信息并将其分发给Hubble等其他工具。下图中,tiefighter正将API请求发布至v1/request-landing上的deathstar。
010.png

感兴趣的朋友可以点击此处,了解关于Pod注释的更多细节信息。

纯Go eBPF库

作者:Joe Stringer

Linux Plumbers 2019 eBPF会议期间,Cilium核心开发者与Cloudflare的工程师们共同发布一项纯Go eBPF库提案,希望借此解决基于Cilium或Cloudflare的L4负载均衡器等由Go语言编写的长期守护进程与eBPF内核间的交互问题。解决方案的一项基本要求,就是避免引入CGo

目前,这项工作正在有序推进。在Cilium 1.7中,负责从eBPF数据路径向用户空间cilium-agent进程发送消息的ring-buffer迎来升级,从以往的CGo实现方案转化为速度更快、效率更高的新库。

目前已经存在多种eBPF外部库选项,包括libbpf或者libbcc。虽然前者已经成为新的规范,能够在Linux内核中支持基于C/C++语言的应用程序,但却还无法在纯Go环境下使用。后者的情况也差不多,能够很好地跟踪、打包libbpf甚至LLVM的eBPF后端,但同样不兼容Go环境。具有类似功能的其他Go库则依赖于CGo,虽然能够正常起效但却难以构建,而且在C与Go环境之间往往会带来高昂的上下文切换成本。

我们希望通过一套纯Go eBPF库解决Cilium与Cloudflare对于eBPF编排的生产级需求,也希望帮助整体Go社区更轻松地与我们内核中的eBPF子系统实现交互。这套新库主要面向网络用例,用于尽可能减少外部依赖性、解决常见问题,并在纯Go环境下提供一套经过测试及实证、足以支撑生产环境要求的eBPF库。

除了最初的eBPF映射、程序与ring-buffer之外,这套库还在开发过程中不断实现新的扩展,包括支持BPF类型格式(BTF),以及利用静态数据替代原始方案以支持模板化eBPF程序的想法(一次编译即可随处使用)。

要了解更多关于eBPF库的细节信息,请参阅Cilium GitHub项目下的: https://github.com/cilium/ebpf

支持Kubernetes EndpointSlice

作者:André Martins

为了给单一服务中的大量端点提供更好的可扩展性,Kubernetes 1.16版本引入了EndpointSlice。自Kubernetes 1.17版本以来,该API正式进入beta阶段并开始默认启用。遗憾的是,其中负责管理Endpoint Slices的控制器不会默认启用,因此用户需要参考指南启动控制器,才能真正享受到这项改进。

Cilium 1.7引入了enable-k8s-endpoint-slice这一全新标记,该标记默认处于启用状态,能够自动检测集群当中是否存在Endpoint Slice,并利用它们在eBPF中执行全服务转换。关闭也非常简单:用户只需要将该标记设置为false,即可继续使用之前版本提供的v1/Endpoints类型。

可扩展性

作者: Ian Vernon与André Martins

CNP节点状态

作为CiliumNetworkPolicy(CNP)可扩展性改进中的组成部分,Cilium引入了另一项新标记: enable-k8s-event-handover。

当大家在集群内创建新的CNP时,所有Cilium代理都会收到来自Kubernetes的通知事件;一旦新策略在数据路径内得到强制执行,各节点都将在CNP状态字段中更新自身状态。在拥有大量节点的情况下,这项机制可能快速提升kube-apiserver中的CPU使用率,因为每一个Cilium代理在收到更新时,都需要将其以Kubernetes新事件的形式发送给其余全部节点。在之前的版本中,我们支持使用--disable-cnp-status-updates标记彻底禁用这项功能,但这太过粗暴,会导致用户无法了解CNP的执行状态。

当启用enable-k8s-event-handover时,整个实现过程将略有不同:不同于在CNP中更新状态字段的方式,现在每个Cilium代理都会将自身状态更新至KVstore当中。接下来,Cilium Operator会在KVstore中为各个CNP监控更新,对各CNP中的所有Cilium代理状态执行增量更新。最后,CNP状态还是会被体现在Kubernetes当中,但整个填充方式明显更加高效。

Cilium代理

由于Cilium不再依赖于容器运行时,因此所有容器运行时层面的依赖关系都不必保存在Cilium之内。如此一来,cilium-agent二进制文件的大小能够从97 M下降至74 M。

Golang 1.13

Cilium 1.7使用Golang 1.13编译而成,能够在运行时内进行大量内存优化,从而减少Cilium的内存占用量。

Linux内核变更

作者:Daniel Borkmann

在Cilium 1.7开发周期当中,我们还对参与维护的Linux内核eBPF子系统做出一系列改进。接下来要提到的几项重要变更,将为全体eBPF用户带来助益,不过具体效果视Cilium与Hubble的eBPF实施方式而定。所有变更,都已被纳入新近发布的Linux 5.5内核当中。

eBPF程序的实时补丁机制

考虑到Cilium的数据路径需要支持多种Linux内核,从4.9版本到最新版本,因此大部分eBPF数据路径功能被拆分为eBPF尾调用。这种处理方式在启用Cilium的eBPF kube-proxy替代方案的情况下体现得尤其明显。除了降低较旧内核的验证程序复杂性之外,这种eBPF尾调用方式还能够以原子方式替换特定Pod中的eBPF功能,同时保证不对实时系统上的服务产生影响。

内核中的x86-64 eBPF JIT编译器会将eBPF尾调用映射为间接跳转,意味着从BPF尾调用映射中加载的当前eBPF程序地址会被提交至jmpq *%rax中的%rax寄存器内。由于现代CPU上存在多种推测性执行缺陷,因此我们后来决定将eBPF JIT转为emit retpolines形式,借此避免对性能造成严重影响。另外,考虑到所有主流编译器都采用到这项技术,因此Linux内核社区一直在努力避免在快速路径代码中执行间接调用。例如,将eBPF映射相关帮助程序转换为直接调用,能够立刻将性能提升14%。

在现在的5.5内核当中,我们对验证程序内跟踪了所有eBPF尾调用映射索引。如果验证程序最终确定所有程序路径中的特定索引都保持恒定不变,那么如同处理Cilium中的eBPF程序一样,我们也可以发出直接跳转指令。在尾调用程序更新完成后,eBPF JIT镜像将会直接跳转至新的位置。

为了演示该技术,以下示例eBPF程序将实现一项指向常量映射索引0的eBPF尾调用跳转:
0: (b7) r3 = 0
1: (18) r2 = map[id:526]
3: (85) call bpf_tail_call#12
4: (b7) r0 = 1
5: (95) exit

这个x86-64 eBPF JIT程序会在较旧的内核版本上发出retpoline(以粗体显示):
0xffffffffc076e55c:
[...]                                  _
19:   xor    %edx,%edx                |_ index (r3 = 0)
1b:   movabs $0xffff88d95cc82600,%rsi |_ map (r2 = map[id:526])
25:   mov    %edx,%edx                |  index >= array->map.max_entries check
27:   cmp    %edx,0x24(%rsi)          |
2a:   jbe    0x0000000000000066       |_
2c:   mov    -0x224(%rbp),%eax        |  tail call limit check
32:   cmp    $0x20,%eax               |
35:   ja     0x0000000000000066       |
37:   add    $0x1,%eax                |
3a:   mov    %eax,-0x224(%rbp)        |_
40:   mov    0xd0(%rsi,%rdx,8),%rax   |_ prog = array->ptrs[index]
48:   test   %rax,%rax                |  prog == NULL check
4b:   je     0x0000000000000066       |_
4d:   mov    0x30(%rax),%rax          |  goto *(prog->bpf_func + prologue_size)
51:   add    $0x19,%rax               |
55:   callq  0x0000000000000061       |  retpoline for indirect jump
5a:   pause                           |
5c:   lfence                          |
5f:   jmp    0x000000000000005a       |
61:   mov    %rax,(%rsp)              |
65:   retq                            |_
66:   mov    $0x1,%eax                (next instruction, r0 = 1)
[...]

对于5.5以及更新内核,此程序则会被优化为直接跳转(以粗体标记),且无需对代码做出任何修改:
0xffffffffc08e8930:
[...]                                  _
19:   xor    %edx,%edx                |_ index (r3 = 0)
1b:   movabs $0xffff9d8afd74c000,%rsi |_ map (r2 = map[id:526])
25:   mov    -0x224(%rbp),%eax        |  tail call limit check
2b:   cmp    $0x20,%eax               |
2e:   ja     0x000000000000003e       |
30:   add    $0x1,%eax                |
33:   mov    %eax,-0x224(%rbp)        |_
39:   jmpq   0xfffffffffffd1785       |_ [direct] goto *(prog->bpf_func + prologue_size)
3e:   mov    $0x1,%eax                (next instruction, r0 = 1)
[...]

程序更新之后,地址39上的jmpq 0xfffffffffffd1785指令将根据新的目标eBPF程序上的地址进行实时更新。同样的,如果目标eBPF程序被从尾调用映射中删除,那么该jmp也会被更新至同样大小的nop指令中,转化为可接受的失败形式:
0xffffffffc08e8930:
[...]                                  _
19:   xor    %edx,%edx                |_ index (r3 = 0)
1b:   movabs $0xffff9d8afd74c000,%rsi |_ map (r2 = map[id:526])
25:   mov    -0x224(%rbp),%eax        |
2b:   cmp    $0x20,%eax               .
2e:   ja     0x000000000000003e       .
30:   add    $0x1,%eax                .
33:   mov    %eax,-0x224(%rbp)        |_
39:   nopl   0x0(%rax,%rax,1)         |_ fall-through nop
3e:   mov    $0x1,%eax                (next instruction, r0 = 1)
[...]

如此一来,内核不必将推测重新定向到pause/lfence循环当中,也不必为给定的直接地址跳转执行任何推测,这将使eBPF代码的执行效率快速迈上一个台阶。

您可以点击此处了解关于合并补丁集的更多细节信息。

为eBPF探针帮助程序提供安全的多架构支持机制

Cilium的数据路径能够通过高性能、可定制的ring-buffer向用户空间代理导出各种聚合性跟踪信息。以Cilium为基础的Hubble,则借此成为一套高可见、全分布式网络与安全平台,帮助用户对服务及网络基础设施中的通信与行为建立起深入了解。此外,Hubbler还通过基于eBPF的内核跟踪机制进一步丰富收集到的信息。

由于多架构支持目前正在开发当中,计划通过Cilium 1.8版本正式发布,因此我们只能对这项新机制做出简要描述:我们在内核中构建起一组新的eBPF帮助程序,希望立足多种架构、更安全地对基于eBPF的内存进行探测。

目前的probe_kernel_read()与 bpf_probe_read_str()eBPF帮助程序集存在一系列缺点:虽然在大多数情况下,用户可以安全地禁用页面错误提示,但在x86-64上发生用户访问违规地址时,直接禁用很可能引发严重后果。这是因为非规范地址的范围,通常与应用程序用于标记指针的用户空间存在重合。

另一大严重缺陷,在于之前提到的两种eBPF帮助程序无法兼容非x86类架构,因为二者会假定自己需要探测的是内核空间地址与用户空间地址。很明显如果是在KERNEL_DS下执行访问,那么这两种尝试最终都只会指向内核空间地址空间。另外,x86的地址空间不重叠,但其他某些架构则允许地址重叠,意味着内核指针与用户指针可能具有相同的地址值。

因此,当我们把of bpf_probe_read_user()、bpf_probe_read_kernel() 、bpf_probe_read_user_str()以及bpf_probe_read_kernel_str() eBPF帮助程序集添加到USER_DS或者KERNEL_DS等具有严格访问限制的内核中时,将无法触发本应被激活的访问警告。

大家可以点击此处了解更多关于合并补丁集的细节信息。

在托管Kubernetes方案上支持Cilium测试

作者:Maciej Kwiek与Ray Bejjani

由于我们的测试框架与运行测试的集群之间存在着紧密的假设关联,因此Cilium的端到端测试一直比较令人头痛。在新自带,我们付出了大量努力,希望帮助开发人员缓解由此带来的压力。

Cilium 1.7为开发人员敞开大门,允许各位使用GKE等服务商提供的Kubernetes集群测试套件。各位能够在我们的CI GKE集群上运行多种可选测试,显著缓解本地集群的管理负担;此外,由于集群配置速度更快,因此您可以尝试进行更多测试驱动型开发工作。

Helm 3与Helm库

作者: Arthur Evstifeev与Joe Stringer

Helm3之前刚刚发布,其简化了Helm图表库的使用方式,而且不再需要配合Tiller即可实现集群安装。从1.7版本开始,我们将扩展1.6版本引入的Helm模板支持机制,允许用户将来自https://helm.cilium.io的Helm库与Cilium匹配起来。所有Cilium指南均已更新为Helm3语法版本,下面来看Helm3库的具体安装方式:

示例:为GKE配置Cilium
helm repo add cilium https://helm.cilium.io/
helm install cilium cilium/cilium \
--namespace cilium \
--set global.cni.binPath=/home/kubernetes/bin \
--set global.nodeinit.enabled=true \
--set nodeinit.reconfigureKubelet=true \
--set nodeinit.removeCbrBridge=true

1.7版本发布要点

对eBPF中的kube-proxy替代方案做出多项增强

  • 新的Direct Server Return(DSR)模式,可带来更低延迟与更好的客户端源IP保存效果
  • 支持Kubernetes ExternalIPs与LoadBalancer服务
  • 添加对NodePort健康状态检查服务的支持
  • 添加externalTrafficPolicy=Local处理机制
  • 在基于嵌套的负载均衡中实现对IPv4-in-IPv6支持能力
  • 为基于嵌套的负载均衡机制带来端点选择优化
  • 多项SNAT优化与更好的端口冲突处理能力
  • 通过绑定hook有效检测NodePort与ExternalIPs端口重用情况
  • 以默认方式为新的部署场景提供全新功能探测模式
  • 用于Cilium CI的kube-proxy全面兼容性测试套件


策略

  • TLS可见性策略(beta版)
  • 通过Pod注释实现L7可见性
  • 支持将远程节点作为单独的身份进行处理
  • 通过FQDN策略改进DNS超时处理机制


Kubernetes

  • 得到Kubernetes 1.17验证通过
  • 支持双栈模式
  • 支持EndpointSlices
  • 为Cilium资源提供更好的CRD验证


数据路径

  • eBPF与cillium-agent间的ring-buffer通信效率得到提升
  • 支持更灵活的连接事件聚合功能
  • 更好地限制大型映射转储
  • 改进了对大型CIDR策略的处理方式
  • 利用bpftool检测功能支持效果
  • 支持通过代理选项向ICMP片段转发消息
  • 支持指向NodePorts的绑定


可扩展性与资源消费

  • 将CiliumNetworkPolicy状态报告移交给KVstore与cilium-operator
  • 优化Go 1.13代码体积
  • 消除容器运行(CRI)中的依赖关系


CLI

  • 改进cilium status命令中的信息内容
  • zsh命令补全功能


说明文档

  • 使用Helm 3执行部署指令
  • 添加用于Cilium helm图的专用库
  • 对Kubernetes指南中的链接与内容进行多项修正


Istio

  • 支持1.4.3版本


内核变更

  • eBPF程序实时补丁
  • 多架构支持能力提升


Hubble

  • Hubble图形用户界面
  • 将其他元数据与网络流关联起来


持续集成/测试

  • 支持在GKE与eKS托管集群内运行Cilium CI


感兴趣的朋友可以点击此处,查看Cilium 1.7开发周期内的完整变更说明。

快速入门

您第一次接触Cilium?请参阅我们的《入门指南》获取更多帮助。

升级说明

与以往版本一样,请点击此处根据我们的升级指南升级您的Cilium部署方案。另外,您也可以点击此处通过Slack与我们联系。

发布

  • 发布说明与二进制文件:1.7.0
  • 容器镜像:docker.io/cilium/cilium:v1.7.0


原文链接:Cilium 1.7: Hubble UI, Cluster-wide Network Policies, eBPF-based Direct Server Return, TLS visibility, New eBPF Go Library, ... 收起阅读 »

Heroku 的“得”与“失”


作者 | 孙健波(天元)  阿里巴巴技术专家

2011 年,Heroku 的联合创始人  Adam Wiggins 根据针对上百万应用托管和运维的经验,发布了著名的 “十二要素应用宣言(The Twelve-Factor App)”。不知那时候他们有没有想到,这份宣言会在今后数年时间里,成为 SaaS 应用开发的启蒙书。同时也奠定了 Heroku 在 PaaS 领域的地位,成为了云上应用开发规范化的基石。

Heroku 无疑是一家伟大的公司,它关注应用与开发者,“以应用为中心”的理念让我们至今受益。然而在过去这一两年里,我们看到许多 Heroku 的用户开始寻找别的选择。这不禁让我们好奇,站在“云原生”如火如荼的今天回望过去,Heroku 的“得”与“失”究竟在哪里?

“以应用为中心”的先进理念

Heroku 创办于 2007 年,是最早成熟的 PaaS 产品之一。Heroku 也是最早喊出“以应用为中心”,大规模帮助应用上云的产品。正是围绕“以应用为中心”这样先进的理念,使得 Heroku 从一开始便拥有了至今看来都非常诱人的功能:

  • 用户可以直接从开发语言出发,选择对应的技术栈,通过 heroku create 这样简单的命令,将应用托管到云上。主流的开发语言,均能在 Heroku 中找到对应的选择。从代码的变动自动触发软件的部署交付,清晰的工作流、多样的发布策略,直到后来的很多年都是 DevOps 们梦寐以求的功能;

  • 用户无需关心应用背后的基础设施是什么,Heroku 负责维护背后的一切。这句看似简单的话背后隐藏了巨大的复杂性,试想下某个软件或系统爆出安全漏洞后给你带来的窘境,又或者你想使用一个数据库服务时却不得不维护一个数据库实例。而在 Heroku, 这一切麻烦你都无需关心;

  • 高可用与弹性作为附加能力。Heroku  平台托管的服务具备高可用等附加能力,更让人惊喜的是,满足 12-factor 的应用还天然具备了扩缩容的能力,可以很轻松的抗住突发流量,这在当时无异于黑科技般的存在。


正是这样强大的能力,使得 Heroku 成为了 PaaS 领域事实上的标准,无论是后续的 Cloud Foundry 还是 OpenShift,似乎都没有对 Heroku 有实质性的超越。

Heroku 不再物超所值

众所周知,相对于只是提供纯粹计算能力的 IaaS 而言,以服务能力著称、提供众多开箱即用附加功能的 PaaS,价格上素来都是普遍偏贵的。毕竟 PaaS 可以使你专注于业务本身,贵一点自然也无可厚非。更何况 PaaS 通常根据开通附加能力的数量收费,刚开始甚至更便宜,Heroku 亦是如此。

一开始,用户可能感觉只是比自己在 IaaS 上搭建服务贵一点点。当你发现应用可以便捷绑定 Heroku 提供的高可用 PostgresSQL 数据库时,甚至会觉得它贵的物超所值。不过,随着业务逻辑逐渐复杂、部署规模越来越大,需求自然而然就变了。比如为了让用户的数据更安全,你可能需要一个只能私有网络访问的 PostgresSQL 实例,而 Heroku 默认不具备这样的功能,你必须要配置一个 VPC 才能做到,你自然要为这个 VPC 额外付费。这类需求逐渐覆盖了你每一个实例,增加的费用直接变成了增长的单价,成本快速上升。与此同时,IaaS 厂商的能力也正在爆炸式的增长。今天,几乎所有的云服务商都开始提供数据库服务,并且这些数据库实例的 VPC 通常是免费的。

另一方面,Heroku 从 13 年前诞生至今,一直是闭源的商业平台,关于 Heroku 的一切你都只能在其本身的平台上玩。这无疑给新人学习、上手造成了很高的门槛,甚至许多人因此不愿意体验该产品。这也导致周边生态的配套工具相当匮乏,只要官方不提供的能力,用户就得自己开发。然而无论是招聘 Heroku 熟练工,还是从零开始培养,这无疑都带来了不小的人力成本。

反观如今的云原生社区,任何人都可以通过几条简单的命令在自己的开发环境中运行 Kubernetes,开发者可以很轻易的体验和学习,积累经验。基础设施主动对接 Kubernetes 生态。周边的各类工具也在不断的繁荣演进。

黑盒化的运行时体验

提到 Heroku,另一个代表性的技术无疑就是 Buildpack。在 Docker 镜像机制出现之前,使用 Buildpack 管理用户应用的运行时构建,使得 PaaS 的运行时最终与语言无关,这无疑是非常聪明的做法。然而十多年过去,Buildpack 的模式早已暴露出许多问题。

  • 一方面是官方支持的 Buildpack 数量少,限制多,比如运行系统仅支持 Linux 的 Ubuntu 发行版;某些 Ubuntu 安装包在 Buildpack 中没有安装你便无法使用;相对小众的开发语言(如 Elixir)均不支持;又或者是你的应用包含多种语言,使用起来就变得复杂;

  • 另一方面,你也许能自定义或者找到第三方的 Buildpack 满足需求,但是没有人来保证它的稳定。一旦出了问题,你很难在本地运行 Buildpack 排查问题,而 Heroku 平台的错误信息透出方式并不直接,日志排查更是不便。


2017 年 9 月份,Heroku 最终还是支持了基于 Docker 镜像的运行时部署,然而至今为止依旧有不少限制,其中最大的限制是存储,只能使用 Heroku 的临时存储,这几乎就决定了你不可能自己编写像 etcd、TiDB 这类复杂的有状态应用。

一切的本质,都在于 Heroku 给用户提供的体验是黑盒化的,为用户屏蔽基础设施的同时,也使得用户失去了改造的自由。这也是为什么即便像 Cloudfoundry 这样理念极其类似的 PaaS 平台,即便是开源的,依旧存在同样的弊病。

事实上用户喜欢的是“白盒”,他们希望能够自定义基础设施,可以平行的替换或改造平台的已有功能,而非只能局限在平台提供的能力之上构建。就像我们买了一辆车,在雨雪的极端天气下,我们希望可以换雪地胎,而不是只能加装防滑链。

1.png


Kubernetes 的出现

而 Kubernetes 正是这样一个白盒化体验,它从未尝试去屏蔽基础设施,而是作为一个标准化接入层,把基础设施层的能力通过声明式 API 暴露出来,将选择权留给了用户。正是在这样一个开放世界里,复杂有状态应用的管理也终于得以在云上落地了。另一方面, Kubernetes 并不是 PaaS。相比于 Heroku 官方提供了将近两百个 add-ons(插件)) 用于增强包括数据库、监控、日志、缓存、搜索、分析、权限等能力,而 Kubernetes 则强调强可扩展能力,希望用户自己可以通过编写 CRD Operator 新增任意能力。

那么,这两种做法的区别是什么呢?

封闭、限制 vs 开放、自由

众所周知,Heroku 一直是一个“主观”的 PaaS 平台,12-factor 代表了应用必须云原生化的强硬观点,这一点毋庸置疑是正确的,而且非常了不起。但如果观念不能与时俱进,那么“主观”就会变得危险。就比如容器和虚拟机都已经相当普及的今天,Heroku 依旧坚持应用只能运行在 Heroku Dynos 上面。虽然这种统一很大程度上为管理提供了便利,但是这也使得用户丢掉了很多灵活性,更重要的是,运行时的巨大差异,开始让很多用户觉得自己与更广泛的社区“格格不入”。

不过,Heroku 有属于自己的封闭生态,除了上文提到官方维护的 Add-ons 以外,还有方便用户一键部署到 Heroku 平台的 4700 多个 Buttons 应用  和 用于自定义运行时和构建流程的 6300 多个 Buildpacks,这两大功能都允许用户自定义并可以申请注册到官方的应用市场中,数量着实惊人。这样繁荣的社区怎会被人诟病?出于好奇,笔者整体分析了一下这些项目。

下面两张图分别是 Heroku Buildpack 和 Buttons 的项目统计:

2.png


3.png


我们可以看到,Buildpack 只能在 Heroku 平台使用,所以 star 数量代表了大家对项目的关心,而下载量则代表了用户的使用频度。图中,6000 多个 Buildpack 的 star 数和下载安装量均在 50 以内,而超过 500 个 star 和 500 次下载部署的项目均只有 30 个左右。再来看 Buttons 中的项目,由于这些项目本身还可以部署到 Heroku 以外的其他平台,所以就只看在 Heroku 的部署下载量反映大家的使用频度,而图中超过 500 次部署的 Buttons 项目只有 6 个。原来这一切竟只是表面繁荣。

面对这样一个统计数据,我们很难说 Heroku 的封闭生态是成功的。

Buildpack 本质上是对进程的构建和打包,同样的工作业界几乎都已经统一通过 Dockerfile 构建镜像解决。与 Buildpack 只能在 Heroku 平台上使用的封闭生态不同,Docker 镜像以及 OCI 容器和镜像规范的出现,大大推动了基于容器镜像的应用打包方式走向了全面繁荣。而用于存储镜像的 Docker Registry 也是人人都可以搭建的镜像仓库。从数字上看,仅官方镜像仓库上的镜像数量就超过了 300 万,更有数千镜像下载量超过了 100 万,这才是成功生态应该有的力量。

而在 Kubernetes 生态中帮助应用打包并可以一键部署 CRD Operator 的 Helm Chats 也与 Heroku 的 Buttons 类似。同样, Helm Charts 的托管平台是可以自由搭建的,而 Chart 本身则在任何一个开源或者商业版本的 Kubernetes 上均能运行。虽然没有明确的统计数据,但是像 Helm Hub、Kubeapps Hub、CloudNative App Hub 等 Charts 托管网站里的内容看起来也已经取得了不小的成功。

Heroku 们的未来?

从上述观察来看,Heroku 过去最重要的教训,在于不够开放而错失了原本属于自己的云原生应用生态。而在 Kubernetes 项目成为基础设施主流之后,Heroku 以及它的开源继任者 Cloud Foundry 还是很难走出“被故意忽视”的困境。这个困境的关键并不在于它们是不是基于 K8s 构建的,而是它们能不能带来像 K8s 一样的开放与自由。

可是,另一方面,Kubernetes 本身从始至终都不是一个面向最终用户的体验,也不是最终用户想要的东西。Kubernetes 自身“白盒化”的体验正在为越来越多的业务研发和运维带来“太复杂”的困扰。而这个社区里大量的 CRD Operator 则像一个个烟囱,彼此孤立,不能联动,而且有大量的冗余(比如:Kubernetes 中永无止尽的 Ingress 实现 )。这一切都说明,纯粹使用 Kubernetes 并非托管云原生应用的“标准答案”。而那些试图“给 K8s 写个界面”的 PaaS 构建者们,似乎又陷入了 Heroku 的困境。这种变化,也让 PaaS 与 Kubernetes 之间的关系越来越复杂和不清晰。

从 Kubernetes 到“以应用为中心”的美好未来之间,全世界的 PaaS 工程师其实都在期待一项全新的技术能够弥补这之间的鸿沟。阿里云原生应用平台团队的做法是,通过为应用“建模”的方式来解决这个问题,这也正是 Open Application Model (OAM) 开源项目得以创建的重要目的。

最后

OAM(Open Application Model)开放应用模型是阿里联合微软针对云原生应用的模型,第一次对“以应用为中心”的基础设施和构建规范进行了完整的阐述。应用管理者只要遵守这个规范,就可以编写出一个自包含、自描述的“应用定义文件”。

OAM 相关内容在 github 上完全开源,同时我们也为 Go 生态编写了 oam-go-sdk 方便快速实现 OAM。

目前,阿里巴巴团队正在上游贡献和维护这套技术,如果大家有什么问题或者反馈,也非常欢迎与我们在上游或者钉钉联系。

参与方式:
  • 钉钉扫码进入 OAM 项目中文讨论群


4.png


钉钉扫码加入交流群


5.png


阿里巴巴云原生关注微服务、Serverless、容器、Service Mesh 等技术领域、聚焦云原生流行技术趋势、云原生大规模的落地实践,做最懂云原生开发者的技术圈。”
继续阅读 »

作者 | 孙健波(天元)  阿里巴巴技术专家

2011 年,Heroku 的联合创始人  Adam Wiggins 根据针对上百万应用托管和运维的经验,发布了著名的 “十二要素应用宣言(The Twelve-Factor App)”。不知那时候他们有没有想到,这份宣言会在今后数年时间里,成为 SaaS 应用开发的启蒙书。同时也奠定了 Heroku 在 PaaS 领域的地位,成为了云上应用开发规范化的基石。

Heroku 无疑是一家伟大的公司,它关注应用与开发者,“以应用为中心”的理念让我们至今受益。然而在过去这一两年里,我们看到许多 Heroku 的用户开始寻找别的选择。这不禁让我们好奇,站在“云原生”如火如荼的今天回望过去,Heroku 的“得”与“失”究竟在哪里?

“以应用为中心”的先进理念

Heroku 创办于 2007 年,是最早成熟的 PaaS 产品之一。Heroku 也是最早喊出“以应用为中心”,大规模帮助应用上云的产品。正是围绕“以应用为中心”这样先进的理念,使得 Heroku 从一开始便拥有了至今看来都非常诱人的功能:

  • 用户可以直接从开发语言出发,选择对应的技术栈,通过 heroku create 这样简单的命令,将应用托管到云上。主流的开发语言,均能在 Heroku 中找到对应的选择。从代码的变动自动触发软件的部署交付,清晰的工作流、多样的发布策略,直到后来的很多年都是 DevOps 们梦寐以求的功能;

  • 用户无需关心应用背后的基础设施是什么,Heroku 负责维护背后的一切。这句看似简单的话背后隐藏了巨大的复杂性,试想下某个软件或系统爆出安全漏洞后给你带来的窘境,又或者你想使用一个数据库服务时却不得不维护一个数据库实例。而在 Heroku, 这一切麻烦你都无需关心;

  • 高可用与弹性作为附加能力。Heroku  平台托管的服务具备高可用等附加能力,更让人惊喜的是,满足 12-factor 的应用还天然具备了扩缩容的能力,可以很轻松的抗住突发流量,这在当时无异于黑科技般的存在。


正是这样强大的能力,使得 Heroku 成为了 PaaS 领域事实上的标准,无论是后续的 Cloud Foundry 还是 OpenShift,似乎都没有对 Heroku 有实质性的超越。

Heroku 不再物超所值

众所周知,相对于只是提供纯粹计算能力的 IaaS 而言,以服务能力著称、提供众多开箱即用附加功能的 PaaS,价格上素来都是普遍偏贵的。毕竟 PaaS 可以使你专注于业务本身,贵一点自然也无可厚非。更何况 PaaS 通常根据开通附加能力的数量收费,刚开始甚至更便宜,Heroku 亦是如此。

一开始,用户可能感觉只是比自己在 IaaS 上搭建服务贵一点点。当你发现应用可以便捷绑定 Heroku 提供的高可用 PostgresSQL 数据库时,甚至会觉得它贵的物超所值。不过,随着业务逻辑逐渐复杂、部署规模越来越大,需求自然而然就变了。比如为了让用户的数据更安全,你可能需要一个只能私有网络访问的 PostgresSQL 实例,而 Heroku 默认不具备这样的功能,你必须要配置一个 VPC 才能做到,你自然要为这个 VPC 额外付费。这类需求逐渐覆盖了你每一个实例,增加的费用直接变成了增长的单价,成本快速上升。与此同时,IaaS 厂商的能力也正在爆炸式的增长。今天,几乎所有的云服务商都开始提供数据库服务,并且这些数据库实例的 VPC 通常是免费的。

另一方面,Heroku 从 13 年前诞生至今,一直是闭源的商业平台,关于 Heroku 的一切你都只能在其本身的平台上玩。这无疑给新人学习、上手造成了很高的门槛,甚至许多人因此不愿意体验该产品。这也导致周边生态的配套工具相当匮乏,只要官方不提供的能力,用户就得自己开发。然而无论是招聘 Heroku 熟练工,还是从零开始培养,这无疑都带来了不小的人力成本。

反观如今的云原生社区,任何人都可以通过几条简单的命令在自己的开发环境中运行 Kubernetes,开发者可以很轻易的体验和学习,积累经验。基础设施主动对接 Kubernetes 生态。周边的各类工具也在不断的繁荣演进。

黑盒化的运行时体验

提到 Heroku,另一个代表性的技术无疑就是 Buildpack。在 Docker 镜像机制出现之前,使用 Buildpack 管理用户应用的运行时构建,使得 PaaS 的运行时最终与语言无关,这无疑是非常聪明的做法。然而十多年过去,Buildpack 的模式早已暴露出许多问题。

  • 一方面是官方支持的 Buildpack 数量少,限制多,比如运行系统仅支持 Linux 的 Ubuntu 发行版;某些 Ubuntu 安装包在 Buildpack 中没有安装你便无法使用;相对小众的开发语言(如 Elixir)均不支持;又或者是你的应用包含多种语言,使用起来就变得复杂;

  • 另一方面,你也许能自定义或者找到第三方的 Buildpack 满足需求,但是没有人来保证它的稳定。一旦出了问题,你很难在本地运行 Buildpack 排查问题,而 Heroku 平台的错误信息透出方式并不直接,日志排查更是不便。


2017 年 9 月份,Heroku 最终还是支持了基于 Docker 镜像的运行时部署,然而至今为止依旧有不少限制,其中最大的限制是存储,只能使用 Heroku 的临时存储,这几乎就决定了你不可能自己编写像 etcd、TiDB 这类复杂的有状态应用。

一切的本质,都在于 Heroku 给用户提供的体验是黑盒化的,为用户屏蔽基础设施的同时,也使得用户失去了改造的自由。这也是为什么即便像 Cloudfoundry 这样理念极其类似的 PaaS 平台,即便是开源的,依旧存在同样的弊病。

事实上用户喜欢的是“白盒”,他们希望能够自定义基础设施,可以平行的替换或改造平台的已有功能,而非只能局限在平台提供的能力之上构建。就像我们买了一辆车,在雨雪的极端天气下,我们希望可以换雪地胎,而不是只能加装防滑链。

1.png


Kubernetes 的出现

而 Kubernetes 正是这样一个白盒化体验,它从未尝试去屏蔽基础设施,而是作为一个标准化接入层,把基础设施层的能力通过声明式 API 暴露出来,将选择权留给了用户。正是在这样一个开放世界里,复杂有状态应用的管理也终于得以在云上落地了。另一方面, Kubernetes 并不是 PaaS。相比于 Heroku 官方提供了将近两百个 add-ons(插件)) 用于增强包括数据库、监控、日志、缓存、搜索、分析、权限等能力,而 Kubernetes 则强调强可扩展能力,希望用户自己可以通过编写 CRD Operator 新增任意能力。

那么,这两种做法的区别是什么呢?

封闭、限制 vs 开放、自由

众所周知,Heroku 一直是一个“主观”的 PaaS 平台,12-factor 代表了应用必须云原生化的强硬观点,这一点毋庸置疑是正确的,而且非常了不起。但如果观念不能与时俱进,那么“主观”就会变得危险。就比如容器和虚拟机都已经相当普及的今天,Heroku 依旧坚持应用只能运行在 Heroku Dynos 上面。虽然这种统一很大程度上为管理提供了便利,但是这也使得用户丢掉了很多灵活性,更重要的是,运行时的巨大差异,开始让很多用户觉得自己与更广泛的社区“格格不入”。

不过,Heroku 有属于自己的封闭生态,除了上文提到官方维护的 Add-ons 以外,还有方便用户一键部署到 Heroku 平台的 4700 多个 Buttons 应用  和 用于自定义运行时和构建流程的 6300 多个 Buildpacks,这两大功能都允许用户自定义并可以申请注册到官方的应用市场中,数量着实惊人。这样繁荣的社区怎会被人诟病?出于好奇,笔者整体分析了一下这些项目。

下面两张图分别是 Heroku Buildpack 和 Buttons 的项目统计:

2.png


3.png


我们可以看到,Buildpack 只能在 Heroku 平台使用,所以 star 数量代表了大家对项目的关心,而下载量则代表了用户的使用频度。图中,6000 多个 Buildpack 的 star 数和下载安装量均在 50 以内,而超过 500 个 star 和 500 次下载部署的项目均只有 30 个左右。再来看 Buttons 中的项目,由于这些项目本身还可以部署到 Heroku 以外的其他平台,所以就只看在 Heroku 的部署下载量反映大家的使用频度,而图中超过 500 次部署的 Buttons 项目只有 6 个。原来这一切竟只是表面繁荣。

面对这样一个统计数据,我们很难说 Heroku 的封闭生态是成功的。

Buildpack 本质上是对进程的构建和打包,同样的工作业界几乎都已经统一通过 Dockerfile 构建镜像解决。与 Buildpack 只能在 Heroku 平台上使用的封闭生态不同,Docker 镜像以及 OCI 容器和镜像规范的出现,大大推动了基于容器镜像的应用打包方式走向了全面繁荣。而用于存储镜像的 Docker Registry 也是人人都可以搭建的镜像仓库。从数字上看,仅官方镜像仓库上的镜像数量就超过了 300 万,更有数千镜像下载量超过了 100 万,这才是成功生态应该有的力量。

而在 Kubernetes 生态中帮助应用打包并可以一键部署 CRD Operator 的 Helm Chats 也与 Heroku 的 Buttons 类似。同样, Helm Charts 的托管平台是可以自由搭建的,而 Chart 本身则在任何一个开源或者商业版本的 Kubernetes 上均能运行。虽然没有明确的统计数据,但是像 Helm Hub、Kubeapps Hub、CloudNative App Hub 等 Charts 托管网站里的内容看起来也已经取得了不小的成功。

Heroku 们的未来?

从上述观察来看,Heroku 过去最重要的教训,在于不够开放而错失了原本属于自己的云原生应用生态。而在 Kubernetes 项目成为基础设施主流之后,Heroku 以及它的开源继任者 Cloud Foundry 还是很难走出“被故意忽视”的困境。这个困境的关键并不在于它们是不是基于 K8s 构建的,而是它们能不能带来像 K8s 一样的开放与自由。

可是,另一方面,Kubernetes 本身从始至终都不是一个面向最终用户的体验,也不是最终用户想要的东西。Kubernetes 自身“白盒化”的体验正在为越来越多的业务研发和运维带来“太复杂”的困扰。而这个社区里大量的 CRD Operator 则像一个个烟囱,彼此孤立,不能联动,而且有大量的冗余(比如:Kubernetes 中永无止尽的 Ingress 实现 )。这一切都说明,纯粹使用 Kubernetes 并非托管云原生应用的“标准答案”。而那些试图“给 K8s 写个界面”的 PaaS 构建者们,似乎又陷入了 Heroku 的困境。这种变化,也让 PaaS 与 Kubernetes 之间的关系越来越复杂和不清晰。

从 Kubernetes 到“以应用为中心”的美好未来之间,全世界的 PaaS 工程师其实都在期待一项全新的技术能够弥补这之间的鸿沟。阿里云原生应用平台团队的做法是,通过为应用“建模”的方式来解决这个问题,这也正是 Open Application Model (OAM) 开源项目得以创建的重要目的。

最后

OAM(Open Application Model)开放应用模型是阿里联合微软针对云原生应用的模型,第一次对“以应用为中心”的基础设施和构建规范进行了完整的阐述。应用管理者只要遵守这个规范,就可以编写出一个自包含、自描述的“应用定义文件”。

OAM 相关内容在 github 上完全开源,同时我们也为 Go 生态编写了 oam-go-sdk 方便快速实现 OAM。

目前,阿里巴巴团队正在上游贡献和维护这套技术,如果大家有什么问题或者反馈,也非常欢迎与我们在上游或者钉钉联系。

参与方式:
  • 钉钉扫码进入 OAM 项目中文讨论群


4.png


钉钉扫码加入交流群


5.png


阿里巴巴云原生关注微服务、Serverless、容器、Service Mesh 等技术领域、聚焦云原生流行技术趋势、云原生大规模的落地实践,做最懂云原生开发者的技术圈。”
收起阅读 »

为什么所有人都想要使用Kubernetes?


说实话,我是一个Kubernetes爱好者。Kubernetes可以说是软件开发领域迈出的一大步。当我知道Kubernetes的时候,我就想这才是在生产环境使用容器的正确之道。我没有任何迟疑就接受了Kubernetes。像我这样的,还有数以千计的架构师已经成功拥抱了这一技术。

首先让我们了解一下Kubernetes是如何解决在大多数在云端部署应用时碰到的问题,它是如何支持我们的基础设施向云端迁移。

牢记你的目标

在万物云化的时代,对于所有公司都有一些共同的目标。

以下一些目标一般都会具有高优先级:
  • 尽快迁移到云上(云端迁移)
  • 减少系统管理的成本,基础设施的成本,人力成本(成本缩减)
  • 减少完成项目所需的时间(尽快面向市场)
  • 系统高性能、高可用(质量提升)


牢记这些目标,我们就能迈上云端,然后思索下一步的动作。

我们为什么需要容器?

在我们思考为什么需要Kubernetes之前,我们需要先问自己,为什么我们需要容器?容器在软件开发的历史上是一次巨大的变革,因为它将生产环境带入到每个开发者的本地环境中,我们不再需要担心Linux和Windows的兼容问题。通过使用容器,我们可以在任何工作站上轻易地重现出任何问题。并且,我们也能轻易地将容器迁移到任何一个平台上,不需要做任何其他多余的操作。在容器出现之前,开发者就懂得把应用程序打包进行发布,因为他们知道应用程序如何能够正常运行,比如你可以将依赖组件和应用一起打包。站在DevOps的角度,容器非常优雅,因为每个发布系统只需要处理一件事物,那就是容器。不仅如此,所有的容器构建过程可以由开发者通过Dockerfile来描述,这意味着无论在本地开发环境还是持续集成环境,你都使用同样的方法来构建应用。

容器意味着维护的事项更少,屏蔽环境的细节做到无差异化,那么也就会有更少的错误。

我们能够把容器的镜像推送到Registry。通过Registry,我们在任何地方都能下载、部署,无论是笔记本电脑还是虚拟机(本地或者云端)甚至是Serverless的场景,比如Heroku。相较于虚拟机,容器真正的优势在于它虚拟了操作系统你那个而不是外部资源。这样更高级别的抽象使得我们拥有一种更轻量级、更简单也更经济的方式来部署应用。

为什么需要Kubernetes?

在之前的一节中我们解释了为什么大家都会使用容器,但并没有提及我们为什么需要Kubernetes。不管怎么说,我们已经接受了容器这个事物。这带来了一个新的需求,我们如何管理容器?我们如何可靠地编排容器?我们的答案是Kubernetes!

拥有了Kubernetes,你所需要做的事就是将镜像推送到Registry,然后等待Kubernetes来完成剩余的工作。所有部署环节的任务都由Kubernetes来管理,我们根本无需去担心基础设施。

Kubernetes是业界领先的容器编排解决方案。它是在Google对容器的实践中逐渐开发完善的,同时也是开源的。它的架构允许容器编排,也允许与旧的系统集成。这意味着你能在本地安装Kubernetes,也可以在云端安装、使用,甚至允许你是用混合云的架构。

所以,我们之所以会使用Kubernetes,是因为它的稳定性、可靠性和易用性。简单来说,Kubernetes是部署容器的最佳方式。

Kubernetes是Serverless吗?

Kubernetes是不是Serverless?我认为Kubernetes和Serverless是两个不同领域的词。Serverless更多是一种哲学,而Kubernetes则是一个具体的工具。让我们暂且先回顾一下我们最初的目标,我们说我们需要减少对操作系统的依赖以及减少维护它的成本,而这就是Serverless。

那么问题就变成了,Kubernetes是否让我们实现了这个目标呢?简单来说,是的。

Serverless的严格定义是,我们不需要关心到底是什么应用容器、什么系统、什么硬件在运行我的代码,甚至我都不知道它位于世界的哪个角落。虽然Kubernetes确实向开发者隐藏了诸多的复杂性,但我们确实还是需要了解服务器的相关部分,比如,你仍然依赖于某个特定的容器提供的操作系统。同时,你也依赖于某个特定版本的Kubernetes。这意味着,理论上来说Kubernetes并不算Serverless。

接着,让我们来看几个Serverless的解决方案。

Heroku Runtime的底层是依赖于容器,当然你也可以在Heroku上直接部署自己的容器。大部分的Lambda函数都是运行在容器中。

这就是为什么我们认为部署在云端的Kubernetes不是Serverless,因为它是基于容器的,并且还依赖于操作系统。然而,同样依赖于容器的Heroku Runtime或是Lambda计算服务却被认为是Serverless。

所以我仍然认为Kubernetes是一种Serverless的解决方案,即使它不满足严格的定义。这个世界并不是非黑即白的,云端版本的Kubernetes提供的抽象程度(如屏蔽了操作系统以及底层资源)对我来说已经足够了。

我不希望在这里咬文嚼字。除开我们是否要给Kubernetes贴上Serverless这个标签的问题,Kubernetes确实能够让我们迅速上云,是减少系统管理成本、基础设施维护成本,提高业务质量的一大利器。所以我们实际上无须去关心那些标签,黑猫白猫抓到老鼠就是好猫。

Kubernetes的优势

Kubernetes是一个非常优秀的平台,使我们能够脱下传统虚拟机的戎装去拥抱云。它带来活力,减少系统管理成本,并且将服务的质量推上一个新的高度,在Kubernetes诞生之前我们很难做到这一步。许多传统的问题比如网络,数据保护等在Kubernetes中都能够通过高级配置来实现。

以下是Kubernetes带来的一些优势:
  • 可扩展性:你只需要部署一个容器,就能够毫无障碍地设置扩容策略。然后,你只需要保证你的账户里有足够的余额就行了。
  • 透明化:每个容器只完成一项工作。容器之间的关系被映射成配置文件,不需要担心遗漏什么,当然细节也无法被隐藏。
  • 节约时间:流程非常简单,所有的步骤都能够被重复。
  • 版本控制:从设计上来看,每一次部署都被版本化。当然,稍微花点时间,你也可以将配置文件用Git管理起来。


与其他方式相比,Kubernetes简化了所有开发运维的事项,将开发者带入到一个几乎不需要运维的理想状态。开发团队和运维团队之间的摩擦也减少了,因为原本两者职责的模糊地带现在也划清了界线,系统本身也保持了相当的透明度。

其他的一些优点列举如下:
  • 水平扩展:Kubernetes本身能够自动扩展,支持在集群中增加节点或者调整可用的物理资源。同时,他也能扩展逻辑资源,如调整一个服务的Pod数目。
  • 智能升级:每次你升级容器镜像的时候,升级的过程是平滑的。旧的Pod会一直保持可用的状态直到新的Pod启动完成才会被销毁。这就是我们常说的零宕机部署。
  • 支持本地搭建或是云端服务:使用使用云以外还有别的选择吗?当然!我总是偏好完全云端的解决方案,当然在一些场景下,可能也需要在本地环境或者机房部署,当然Kubernetes也能够完美支持。
  • 远离厂商捆绑:Kubernetes在每个通过认证的公有云平台具有一致性。如果你对你的服务商不满,你只需要花费少量的时间就可以更换厂商。
  • 对开发者没有额外的成本:任何已经被容器化的软件都能一键部署。开发团队不需要学习额外的知识。


我们到底如何选择?

Kubernetes的灵活性很强,利用云端的方案,你可以轻松地管理Kubernetes集群。当我了解到Kubernetes的时候,我就认为它是一个可行的、安全的方案,能够有效减少开发者的负担。它具备所有传统基础设施的优势,同时让我们在不重构应用的同时,享受不需要运行维护的便利。和很多其他看上去闪闪发亮的解决方案(如Serverless)相比,Kubernetes更加实在。Serverless确实很不错,但是很多复杂的场景,它并不能很自如地应付。并且从逻辑上来看,全盘接受像Lambda这样前沿的技术需要一个巨大的思维转变。对于运维团队来说,这样的改变并不容易。

如今,减少系统管理的工作量,拥有能够易于部署、能够简化运维核心痛点的基础设施对大部分团队来说都是核心需求。而Kubernetes满足这所有的需求。

如果让我现在去设计一套架构,尤其是面向企业的解决方案,我会首选容器技术和Kubernetes。或许我会选择云服务商提供的Kubernetes来减少运维成本。我也有可能会用Git来管理DevOps流水线相关的配置。

这个方案对操作系统的依赖很少,对云厂商的依赖也很少,所有的基础设施及其配置都依赖于代码。

有人会说,这个方案并不完全摆脱了运维,也不完全摆脱了服务器。但Kubernetes具有稳定、模块化、可伸缩的特性,能够满足最重要的架构设计目标。所以我们为什么不用Kubernetes呢?

那我们能不能做得更好?毫无疑问。我们能努力摆脱更多的负担吗?当然可以。我们当然能百尺竿头更进一步。然而,仰望星空也要脚踏实地,我们必须要承认Kubernetes是一个极佳的折中方案:在大部分案例中,Kubernetes是成功的保障。

原文链接:Why Does Everybody Want to Use Kubernetes?(翻译:小灰灰)
继续阅读 »

说实话,我是一个Kubernetes爱好者。Kubernetes可以说是软件开发领域迈出的一大步。当我知道Kubernetes的时候,我就想这才是在生产环境使用容器的正确之道。我没有任何迟疑就接受了Kubernetes。像我这样的,还有数以千计的架构师已经成功拥抱了这一技术。

首先让我们了解一下Kubernetes是如何解决在大多数在云端部署应用时碰到的问题,它是如何支持我们的基础设施向云端迁移。

牢记你的目标

在万物云化的时代,对于所有公司都有一些共同的目标。

以下一些目标一般都会具有高优先级:
  • 尽快迁移到云上(云端迁移)
  • 减少系统管理的成本,基础设施的成本,人力成本(成本缩减)
  • 减少完成项目所需的时间(尽快面向市场)
  • 系统高性能、高可用(质量提升)


牢记这些目标,我们就能迈上云端,然后思索下一步的动作。

我们为什么需要容器?

在我们思考为什么需要Kubernetes之前,我们需要先问自己,为什么我们需要容器?容器在软件开发的历史上是一次巨大的变革,因为它将生产环境带入到每个开发者的本地环境中,我们不再需要担心Linux和Windows的兼容问题。通过使用容器,我们可以在任何工作站上轻易地重现出任何问题。并且,我们也能轻易地将容器迁移到任何一个平台上,不需要做任何其他多余的操作。在容器出现之前,开发者就懂得把应用程序打包进行发布,因为他们知道应用程序如何能够正常运行,比如你可以将依赖组件和应用一起打包。站在DevOps的角度,容器非常优雅,因为每个发布系统只需要处理一件事物,那就是容器。不仅如此,所有的容器构建过程可以由开发者通过Dockerfile来描述,这意味着无论在本地开发环境还是持续集成环境,你都使用同样的方法来构建应用。

容器意味着维护的事项更少,屏蔽环境的细节做到无差异化,那么也就会有更少的错误。

我们能够把容器的镜像推送到Registry。通过Registry,我们在任何地方都能下载、部署,无论是笔记本电脑还是虚拟机(本地或者云端)甚至是Serverless的场景,比如Heroku。相较于虚拟机,容器真正的优势在于它虚拟了操作系统你那个而不是外部资源。这样更高级别的抽象使得我们拥有一种更轻量级、更简单也更经济的方式来部署应用。

为什么需要Kubernetes?

在之前的一节中我们解释了为什么大家都会使用容器,但并没有提及我们为什么需要Kubernetes。不管怎么说,我们已经接受了容器这个事物。这带来了一个新的需求,我们如何管理容器?我们如何可靠地编排容器?我们的答案是Kubernetes!

拥有了Kubernetes,你所需要做的事就是将镜像推送到Registry,然后等待Kubernetes来完成剩余的工作。所有部署环节的任务都由Kubernetes来管理,我们根本无需去担心基础设施。

Kubernetes是业界领先的容器编排解决方案。它是在Google对容器的实践中逐渐开发完善的,同时也是开源的。它的架构允许容器编排,也允许与旧的系统集成。这意味着你能在本地安装Kubernetes,也可以在云端安装、使用,甚至允许你是用混合云的架构。

所以,我们之所以会使用Kubernetes,是因为它的稳定性、可靠性和易用性。简单来说,Kubernetes是部署容器的最佳方式。

Kubernetes是Serverless吗?

Kubernetes是不是Serverless?我认为Kubernetes和Serverless是两个不同领域的词。Serverless更多是一种哲学,而Kubernetes则是一个具体的工具。让我们暂且先回顾一下我们最初的目标,我们说我们需要减少对操作系统的依赖以及减少维护它的成本,而这就是Serverless。

那么问题就变成了,Kubernetes是否让我们实现了这个目标呢?简单来说,是的。

Serverless的严格定义是,我们不需要关心到底是什么应用容器、什么系统、什么硬件在运行我的代码,甚至我都不知道它位于世界的哪个角落。虽然Kubernetes确实向开发者隐藏了诸多的复杂性,但我们确实还是需要了解服务器的相关部分,比如,你仍然依赖于某个特定的容器提供的操作系统。同时,你也依赖于某个特定版本的Kubernetes。这意味着,理论上来说Kubernetes并不算Serverless。

接着,让我们来看几个Serverless的解决方案。

Heroku Runtime的底层是依赖于容器,当然你也可以在Heroku上直接部署自己的容器。大部分的Lambda函数都是运行在容器中。

这就是为什么我们认为部署在云端的Kubernetes不是Serverless,因为它是基于容器的,并且还依赖于操作系统。然而,同样依赖于容器的Heroku Runtime或是Lambda计算服务却被认为是Serverless。

所以我仍然认为Kubernetes是一种Serverless的解决方案,即使它不满足严格的定义。这个世界并不是非黑即白的,云端版本的Kubernetes提供的抽象程度(如屏蔽了操作系统以及底层资源)对我来说已经足够了。

我不希望在这里咬文嚼字。除开我们是否要给Kubernetes贴上Serverless这个标签的问题,Kubernetes确实能够让我们迅速上云,是减少系统管理成本、基础设施维护成本,提高业务质量的一大利器。所以我们实际上无须去关心那些标签,黑猫白猫抓到老鼠就是好猫。

Kubernetes的优势

Kubernetes是一个非常优秀的平台,使我们能够脱下传统虚拟机的戎装去拥抱云。它带来活力,减少系统管理成本,并且将服务的质量推上一个新的高度,在Kubernetes诞生之前我们很难做到这一步。许多传统的问题比如网络,数据保护等在Kubernetes中都能够通过高级配置来实现。

以下是Kubernetes带来的一些优势:
  • 可扩展性:你只需要部署一个容器,就能够毫无障碍地设置扩容策略。然后,你只需要保证你的账户里有足够的余额就行了。
  • 透明化:每个容器只完成一项工作。容器之间的关系被映射成配置文件,不需要担心遗漏什么,当然细节也无法被隐藏。
  • 节约时间:流程非常简单,所有的步骤都能够被重复。
  • 版本控制:从设计上来看,每一次部署都被版本化。当然,稍微花点时间,你也可以将配置文件用Git管理起来。


与其他方式相比,Kubernetes简化了所有开发运维的事项,将开发者带入到一个几乎不需要运维的理想状态。开发团队和运维团队之间的摩擦也减少了,因为原本两者职责的模糊地带现在也划清了界线,系统本身也保持了相当的透明度。

其他的一些优点列举如下:
  • 水平扩展:Kubernetes本身能够自动扩展,支持在集群中增加节点或者调整可用的物理资源。同时,他也能扩展逻辑资源,如调整一个服务的Pod数目。
  • 智能升级:每次你升级容器镜像的时候,升级的过程是平滑的。旧的Pod会一直保持可用的状态直到新的Pod启动完成才会被销毁。这就是我们常说的零宕机部署。
  • 支持本地搭建或是云端服务:使用使用云以外还有别的选择吗?当然!我总是偏好完全云端的解决方案,当然在一些场景下,可能也需要在本地环境或者机房部署,当然Kubernetes也能够完美支持。
  • 远离厂商捆绑:Kubernetes在每个通过认证的公有云平台具有一致性。如果你对你的服务商不满,你只需要花费少量的时间就可以更换厂商。
  • 对开发者没有额外的成本:任何已经被容器化的软件都能一键部署。开发团队不需要学习额外的知识。


我们到底如何选择?

Kubernetes的灵活性很强,利用云端的方案,你可以轻松地管理Kubernetes集群。当我了解到Kubernetes的时候,我就认为它是一个可行的、安全的方案,能够有效减少开发者的负担。它具备所有传统基础设施的优势,同时让我们在不重构应用的同时,享受不需要运行维护的便利。和很多其他看上去闪闪发亮的解决方案(如Serverless)相比,Kubernetes更加实在。Serverless确实很不错,但是很多复杂的场景,它并不能很自如地应付。并且从逻辑上来看,全盘接受像Lambda这样前沿的技术需要一个巨大的思维转变。对于运维团队来说,这样的改变并不容易。

如今,减少系统管理的工作量,拥有能够易于部署、能够简化运维核心痛点的基础设施对大部分团队来说都是核心需求。而Kubernetes满足这所有的需求。

如果让我现在去设计一套架构,尤其是面向企业的解决方案,我会首选容器技术和Kubernetes。或许我会选择云服务商提供的Kubernetes来减少运维成本。我也有可能会用Git来管理DevOps流水线相关的配置。

这个方案对操作系统的依赖很少,对云厂商的依赖也很少,所有的基础设施及其配置都依赖于代码。

有人会说,这个方案并不完全摆脱了运维,也不完全摆脱了服务器。但Kubernetes具有稳定、模块化、可伸缩的特性,能够满足最重要的架构设计目标。所以我们为什么不用Kubernetes呢?

那我们能不能做得更好?毫无疑问。我们能努力摆脱更多的负担吗?当然可以。我们当然能百尺竿头更进一步。然而,仰望星空也要脚踏实地,我们必须要承认Kubernetes是一个极佳的折中方案:在大部分案例中,Kubernetes是成功的保障。

原文链接:Why Does Everybody Want to Use Kubernetes?(翻译:小灰灰) 收起阅读 »

LogDevice:一种用于日志的分布式数据存储系统


说到日志,它就是一个将有序序列的不可变记录记下来,并将此记录可靠地保存下来的最简单的方法。如果想要构建一套数据密集型分布式服务,你可能需要一两套日志。在Facebook,我们构建了许多用来存储和处理数据的大型分布式服务。在Facebook,我们如何做到想要即连接数据处理管道的两个阶段,又无需担心数据流管控或数据丢失的呢?就是让一个阶段写入日志,另一个阶段从这个日志读取。那么如何去维护一个大型分布式数据库的索引呢?就是先让索引服务以适当的顺序应用索引更改,然后再来读取更新的日志。那要是有一个系列需要一周后再以特定顺序执行的工作呢?答案就是先将它们写入日志,让日志使用者滞后一周再来执行。一个拥有足够能力进行写入排序的日志系统,可以将你希望拥有分布式事务的梦想成为现实。既然如此,要是有持久性方面的顾虑?那就去使用预写日志吧。

以Facebook的规模,上面那些都是说起来容易做起来难。在此规模下,想要保证高可用和持久化存储以及将这些可重复的全排序记录下来,真的是日志抽象下的两个难以实现的承诺。LogDevice是一种专为日志设计的分布式数据存储系统。它试图在本质上无限制的规模下,让分布式系统设计师得以兑现这两个承诺。

我们可以将日志视为一种面向记录(record-oriented)、只可追加(append-only)、可修剪的(trimmable)文件。不妨更详细地看一下这意味着什么:
  • 面向记录(record-oriented)意味着,数据不是以单个字节的形式记录,而是以不可分割的形式进行记录的。重要的是,一条记录是最小的寻址单元:读取器始终从特定的记录(或从追加到日志的下一条记录)开始读取,每次以一个或多个记录地接收数据。不过需要注意的是,记录的编号不一定连续性的。编号序列可能有间断,写入器事先也不知道一旦成功写入的话,记录会被赋予什么样的日志序号(Log Sequence Number)。由于LogDevice并不受限于连续字节编号的要求,因此当出现故障时,它能提供更好的写入可用性。
  • 日志是原生的只可追加的(append-only)。它不支持修改现有记录的功能,因为没必要,也不提供。
  • 日志在被删除之前是可以保存比较长的一段时间的:几天、几月或甚至是几年。日志的主空间回收机制是修剪(trimming),或者是基于时间或空间的保留策略去丢弃(dropping)最旧的记录。


工作负载和性能要求

Facebook有各种日志工作负载,其性能,可用性和延迟等方面要求都各不相同。我们设计的LogDevice,是以可调整这些冲突参数为目标,而不是为了设计成一套一应俱全(one-size-fits-all)的解决方案为目标。

我们发现大多数日志应用程序的共同点是要求高写入可用性。即使是几分钟,记录器也没有任何地方可以存放这些数据。LogDevice必须提供高可用性。耐久性要求也很普遍。与任何文件系统一样,没人希望听到他们的数据在收到成功追加日志的确认后便丢失的消息。硬件故障可不是一个借口。最终,我们发现,我们的客户在大多数的情况下仅仅是读取几次日志,并且是在被追加到日志后不久便读取,然而他们会偶尔执行大规模的全量拷贝(backfill)。全量拷贝是一种颇具挑战的访问模式,LogDevice的客户端每个日志启动至少一个读取器,用于记录几小时甚至几天的记录。然后那些读取器从那一点开始阅读每个日志中的所有内容。全量拷贝通常由下游系统中的故障触发,而下游系统使用含有状态更新或事件的日志记录。全量拷贝允许下游系统以当时丢失的状态为时间的来重建它。

能够应对单个日志的写入负载中的峰值(spike)也很重要。LogDevice集群通常存放着数千到数万个日志。我们发现,在某些集群里,一些日志的写入速度会出现比稳定状态高10倍或者更高的峰值,然而由LogDevice集群处理的多数日志写入速率却没有什么变化。LogDevice将记录的排序从记录的存储中分开来,并使用记录的非确定性放置来提高写入的可用性,并更好地容忍由此类峰值引起的临时负载不平衡。

一致性保证

LogDevice日志提供的一致性保证指的是用户对文件的期望,尽管它是一个面向记录的文件。多个写入器可以同时将记录追加到同一个日志里。所有这些记录将以相同的顺序(Log Sequence Number)传递给日志的所有读取者,具有可重复的读取一致性。如果将记录传送给一个读取者,它同时也会被传送给遇到该LSN的所有读取器,除非发生导致所有记录副本丢失的灾难性故障。LogDevice提供内置的数据丢失检测和报告功能。如果发生数据丢失,所有丢失记录的LSN将报告给尝试读取受影响的日志和LSN范围的每个读取器。

记录着不同日志的记录是不提供排序保证的。因为来自不同日志的记录的LSN不具有可比性。

设计和实施

非确定性的记录放置

有各种选项就是好事。存放记录副本有着大量的可选项,这有效的提高了分布式存储集群的写入可用性。同许多其他分布式存储系统类似,LogDevice把每个记录(通常为两个或三个)以相同的副本形式,存储在不同的机器上,从而来实现持久性。正是因为这些副本有多种放置选项,即使群集中的大量存储节点停机或运行缓慢,只要启动的群集部分仍可以处理负载,就可以完成写入任务。你还可以应对单个日志的写入速率出现的峰值,只需将此写入分摊到所有可用节点上。反过来,如果需要将某个特定的日志或记录仅限于几个特定的节点,单个日志的最大吞吐量将受到那些节点运力的限制,而且仅仅几个节点的故障可能会导致某些日志的所有写入失败。

许多成功的分布式文件系统都采用了最大化入站数据的放置选项这个原则。以Apache HDFS为例,数据块可以放置在集群中的任何存储节点上,但需要受制于跨机架和空间的限制,这是由被称为名称节点的集中式元数据存储库强制执行的。在Red Hat Ceph中,数据放置由多值哈希函数控制。哈希函数生成的值为传入数据项提供多个放置选项。这消除了对名称节点的需要,但无法达到相同级别的放置灵活性。

LogDevice专注于日志存储,采用了不同的方法来记录放置。它提供了与名称节点相同的放置灵活性级别,且实际上并不需要名称节点。这又是如何实现的呢?首先,我们把日志记录的顺序与记录副本的实际存储分开。对于LogDevice集群中的每个日志,LogDevice都会运行一个序列器对象,其唯一的工作是在记录附加到该日志时发出单调递增的序列号。序列器可以运行在任何方便的地方:在存储节点上,或在专门用于排序和追加以及非实际存储的节点上。
logdevice.jpg

图例1.LogDevice中的排序和存储分离

一旦一个记录被标记上序号,这个记录的所有副本就可能保存在集群中的任意存储节点上。只要读取器可以有效的查找和检索这些副本,这些副本的放置就不会影响日志的可重复读取属性。

希望读取特定日志的客户端可以连接到所有存储着这些日志的存储节点。这个名为日志的节点集(node set of the log)通常小于集群中存储节点的总数。节点集属于日志复制策略的一部分。它可能随时更改,日志的元数据历史记录中有适当的注释,读取器可以查阅该注释,以便找到所要连接的存储节点。节点集允许LogDevice集群独立于读取器的数据来进行扩展。客户端节点间的通讯是依靠快速生成的记录副本通过TCP连接实现的。因此,每条记录的报头自然都会含有序号。LogDevice的客户端库的一些操作,如对记录执行重新排序作,以及偶尔执行重复数据删除,这些操作是确保记录按LSN的顺序传送给读取应用程序所必需的。

然而,这种放置和传递的机制虽然很适合写入性和处理有峰值的写入负载,但对于经常包含很多点读取(point read)的文件负载来说效率不是很高。对于多数顺序性的日志读取工作负载来说,它是很高效的。通过读取器联系的全部存储节点可能会有一些记录需要传送。着不会浪费任何IO和网络资源。我们会确保,每个记录只有一个副本会从磁盘读取,并通过在每个记录副本的报头中加入副本集,再经由网络传输。一种基于副本集的被称为简单服务器过滤机制(simple server-side filtering scheme)同密集型索引耦合,这可以保证在稳定状态下,副本集只有一个节点将读取记录副本,并将其传送给特定读取器。

序号:

如图1所示,LogDevice中记录的序列号不是整数,而是整数对。该对的第一个组件称为纪元数(epoch number),第二个组件是纪元内偏移。通常的元组比较规则适用。在LSN中另一种可用性优化机制就是使用纪元。当序列器节点崩溃或以其原因变为不可用时,每个新序列器开始生成的LSN必须严格大于所有已为该日志写入记录的LSN。不需要实际查看具体存了什么,纪元可以直接让LogDevice保证这一点。当新的序列器出现后,它从纪元存储区的元数据收到新的纪元数。纪元存储作为一个持久计数器的存储区,每个日志一个,很少递增且保证永不退化。现在我们使用Apache的Zookeeper作为LogDevice的纪元存储。

多对多重建

驱动器错误,电源故障,机架开关失灵,当这些故障发生时,某些或所有记录的可用副本数量可能会减少。当数次连续失败后,该数字降至零,就会丢失数据或至少会丢失一些记录的读取可用性。这都是LogDevice尽力去避免的情形。重建会在一次或多次失败后,为复制不足的记录(具有少于目标份数R的记录)创建更多副本。

为了确保高效,重建必须要快速。它必须在下一次失败之前完成一些已然失败记录的最后一个副本。与HDFS类似,LogDevice实现的重建是多对多的。所有存储节点都充当记录副本的提供者(donor)和接收者(recipient)。整个需要重建的集群会调配资源,让LogDevice可以以每秒5-10GB的速率完全恢复受故障影响的所有记录的复制因子。

重建协调是完全分布式的,而且并通过事件日志的内部元数据执行。

本地日志存储

排序和存储的分离解耦有助于分配集群的总体CPU和存储资源,以匹配不断变化的,有时的峰值负载。但是,分布式数据存储的每节点效率很大程度上取决于其本地存储层。最后,必须将多个记录副本保存在非易失性设备上,例如硬盘驱动器或固态硬盘(SSD)。当每个节点以100MBps+的速度存储数小时的记录时,仅靠内存(RAM)存储是不切实际的。当积压持续时间以天为单位(在Facebook这是一个常见的要求)时,硬盘驱动器的性价比明显高于闪存。所以我们在LogDevice设计了本地存储组件,不仅在具有巨大IOPS容量的闪存上,而且在硬盘上也能很好地运行。企业级硬盘驱动器可以推动相当数量的顺序写入和读取(100-200MBps),不过随机IOPS最高也就100-140MBps。

在LogDevice,它的本地日志存储被称为LogsDB,是一个写优化数据存储,旨在保持磁盘搜索的数量小和受控,并且存储设备上的写和读IO模式基本上是顺序的。正如它强调的写优化数据存储,它的目标就是在写入数据时,甚至数据是属于多个文件或日志,都能提供出色的性能。高写入性能的同时,会在某些系统里带来糟糕的读取效率。除了在硬盘上表现良好外,Logs DB在日志跟踪的负载方面,它的效率特别好。在这种正常的日志访问模式下,记录在被写入后会马上传递给读取器。这些记录不会再被读取,出发在非常罕见的紧急情况下:那些大规模的全量拷贝。这些读取器会从内存读取,这样可以使因为读取单个日志导致降低效率的问题变得无关紧要。

LogsDB是RocksDB之上的一个层,是基于LSM树的一种有序持久键值数据存储。LogsDB属于RocksDB列族按时间排序的集合,是完全成熟的RocksDB实例,共享一个共同的预写日志。每个RocksDB实例被称为LogsDB分区。所有日志的每个新写入,无论是一个还是一百万个日志,都会进入最新的分区,按照(日志id,LSN)对它们进行排序,并以一系列的大型已排序不可变文件(称为SST文件)中保存在磁盘上。这使得硬盘上写入的IO工作负载基本上是按顺序的,但这导致了在读取记录时,需要从多个文件来合并数据(文件的数量最多是Logs DB分区中允许的最大文件数,通常情况下是10个左右)。从多个文件读取会导致读取放大,或者浪费一些读取IO。

LogsDB的控制读取放大,是以一种特别适合日志数据模型的方式:不可变的LSN识别的不可变记录并随时间而单调递增。在控制文件数量方面,当SST文件的数量达到最大时,LogsDB不考虑分区,而是新创建一个最新分区,而不是通过合并排序(merge-sorting)成一个更大的有序LogsDB。由于分区是按顺序读取的,即便所有分区中的SST文件总数达到数万个,同时读取的文件数量也不可能超过单个分区中的最大文件数。通过删除(或在某些情况下偶尔合并排序)最旧的分区,可以有效地回收空间。

应用场景和未来展望

在Facebook,LogDevice已经成为众多日志工作负载的一种通用的解决方案。下面举几个例子。比如Scribe就是LogDevice众多海量级用户的一个使用者,峰值期间每秒获取的数据超过1TB,不仅可以可靠地传输并且可以回放。Scribe提供了一套即发即弃(fire-and-forget)的写入API,传送延迟预期在几秒左右。运行Scribe的LogDevice集群会针对每个设备的效率进行调整,而不是很低的端到端延迟或者追加延迟。TAO数据的二级索引也用的LogDevice,这是另一个应用场景。它的吞吐量虽没有Scribe大,但每个日志的严格记录排序很重要,期望的端到端延迟大概为10毫秒。这需要有非常不同的协调。另一个有趣的例子是机器学习管道,它使用LogDevice将相同的事件流提供给多个ML模型训练服务。

LogDevice还有更多的功能正在积极开发中。它用C++编写的,几乎没什么外部依赖。目前正在探索的新领域包括集群分解,其中存储和CPU密集型任务由具有不同硬件配置文件的服务器处理,支持非常高容量的日志,以及通过应用程序提供的密钥对记录进行高效的服务器端过滤。这些功能将不仅能提高LogDevice集群的硬件效率,而且为高吞吐量数据流的用户提供可扩展的负载分配机制。

最新更新:LogDevice已经开源,并可以通过GitHub找到它。

原文链接:LogDevice: a distributed data store for logs(翻译:伊海峰)
继续阅读 »

说到日志,它就是一个将有序序列的不可变记录记下来,并将此记录可靠地保存下来的最简单的方法。如果想要构建一套数据密集型分布式服务,你可能需要一两套日志。在Facebook,我们构建了许多用来存储和处理数据的大型分布式服务。在Facebook,我们如何做到想要即连接数据处理管道的两个阶段,又无需担心数据流管控或数据丢失的呢?就是让一个阶段写入日志,另一个阶段从这个日志读取。那么如何去维护一个大型分布式数据库的索引呢?就是先让索引服务以适当的顺序应用索引更改,然后再来读取更新的日志。那要是有一个系列需要一周后再以特定顺序执行的工作呢?答案就是先将它们写入日志,让日志使用者滞后一周再来执行。一个拥有足够能力进行写入排序的日志系统,可以将你希望拥有分布式事务的梦想成为现实。既然如此,要是有持久性方面的顾虑?那就去使用预写日志吧。

以Facebook的规模,上面那些都是说起来容易做起来难。在此规模下,想要保证高可用和持久化存储以及将这些可重复的全排序记录下来,真的是日志抽象下的两个难以实现的承诺。LogDevice是一种专为日志设计的分布式数据存储系统。它试图在本质上无限制的规模下,让分布式系统设计师得以兑现这两个承诺。

我们可以将日志视为一种面向记录(record-oriented)、只可追加(append-only)、可修剪的(trimmable)文件。不妨更详细地看一下这意味着什么:
  • 面向记录(record-oriented)意味着,数据不是以单个字节的形式记录,而是以不可分割的形式进行记录的。重要的是,一条记录是最小的寻址单元:读取器始终从特定的记录(或从追加到日志的下一条记录)开始读取,每次以一个或多个记录地接收数据。不过需要注意的是,记录的编号不一定连续性的。编号序列可能有间断,写入器事先也不知道一旦成功写入的话,记录会被赋予什么样的日志序号(Log Sequence Number)。由于LogDevice并不受限于连续字节编号的要求,因此当出现故障时,它能提供更好的写入可用性。
  • 日志是原生的只可追加的(append-only)。它不支持修改现有记录的功能,因为没必要,也不提供。
  • 日志在被删除之前是可以保存比较长的一段时间的:几天、几月或甚至是几年。日志的主空间回收机制是修剪(trimming),或者是基于时间或空间的保留策略去丢弃(dropping)最旧的记录。


工作负载和性能要求

Facebook有各种日志工作负载,其性能,可用性和延迟等方面要求都各不相同。我们设计的LogDevice,是以可调整这些冲突参数为目标,而不是为了设计成一套一应俱全(one-size-fits-all)的解决方案为目标。

我们发现大多数日志应用程序的共同点是要求高写入可用性。即使是几分钟,记录器也没有任何地方可以存放这些数据。LogDevice必须提供高可用性。耐久性要求也很普遍。与任何文件系统一样,没人希望听到他们的数据在收到成功追加日志的确认后便丢失的消息。硬件故障可不是一个借口。最终,我们发现,我们的客户在大多数的情况下仅仅是读取几次日志,并且是在被追加到日志后不久便读取,然而他们会偶尔执行大规模的全量拷贝(backfill)。全量拷贝是一种颇具挑战的访问模式,LogDevice的客户端每个日志启动至少一个读取器,用于记录几小时甚至几天的记录。然后那些读取器从那一点开始阅读每个日志中的所有内容。全量拷贝通常由下游系统中的故障触发,而下游系统使用含有状态更新或事件的日志记录。全量拷贝允许下游系统以当时丢失的状态为时间的来重建它。

能够应对单个日志的写入负载中的峰值(spike)也很重要。LogDevice集群通常存放着数千到数万个日志。我们发现,在某些集群里,一些日志的写入速度会出现比稳定状态高10倍或者更高的峰值,然而由LogDevice集群处理的多数日志写入速率却没有什么变化。LogDevice将记录的排序从记录的存储中分开来,并使用记录的非确定性放置来提高写入的可用性,并更好地容忍由此类峰值引起的临时负载不平衡。

一致性保证

LogDevice日志提供的一致性保证指的是用户对文件的期望,尽管它是一个面向记录的文件。多个写入器可以同时将记录追加到同一个日志里。所有这些记录将以相同的顺序(Log Sequence Number)传递给日志的所有读取者,具有可重复的读取一致性。如果将记录传送给一个读取者,它同时也会被传送给遇到该LSN的所有读取器,除非发生导致所有记录副本丢失的灾难性故障。LogDevice提供内置的数据丢失检测和报告功能。如果发生数据丢失,所有丢失记录的LSN将报告给尝试读取受影响的日志和LSN范围的每个读取器。

记录着不同日志的记录是不提供排序保证的。因为来自不同日志的记录的LSN不具有可比性。

设计和实施

非确定性的记录放置

有各种选项就是好事。存放记录副本有着大量的可选项,这有效的提高了分布式存储集群的写入可用性。同许多其他分布式存储系统类似,LogDevice把每个记录(通常为两个或三个)以相同的副本形式,存储在不同的机器上,从而来实现持久性。正是因为这些副本有多种放置选项,即使群集中的大量存储节点停机或运行缓慢,只要启动的群集部分仍可以处理负载,就可以完成写入任务。你还可以应对单个日志的写入速率出现的峰值,只需将此写入分摊到所有可用节点上。反过来,如果需要将某个特定的日志或记录仅限于几个特定的节点,单个日志的最大吞吐量将受到那些节点运力的限制,而且仅仅几个节点的故障可能会导致某些日志的所有写入失败。

许多成功的分布式文件系统都采用了最大化入站数据的放置选项这个原则。以Apache HDFS为例,数据块可以放置在集群中的任何存储节点上,但需要受制于跨机架和空间的限制,这是由被称为名称节点的集中式元数据存储库强制执行的。在Red Hat Ceph中,数据放置由多值哈希函数控制。哈希函数生成的值为传入数据项提供多个放置选项。这消除了对名称节点的需要,但无法达到相同级别的放置灵活性。

LogDevice专注于日志存储,采用了不同的方法来记录放置。它提供了与名称节点相同的放置灵活性级别,且实际上并不需要名称节点。这又是如何实现的呢?首先,我们把日志记录的顺序与记录副本的实际存储分开。对于LogDevice集群中的每个日志,LogDevice都会运行一个序列器对象,其唯一的工作是在记录附加到该日志时发出单调递增的序列号。序列器可以运行在任何方便的地方:在存储节点上,或在专门用于排序和追加以及非实际存储的节点上。
logdevice.jpg

图例1.LogDevice中的排序和存储分离

一旦一个记录被标记上序号,这个记录的所有副本就可能保存在集群中的任意存储节点上。只要读取器可以有效的查找和检索这些副本,这些副本的放置就不会影响日志的可重复读取属性。

希望读取特定日志的客户端可以连接到所有存储着这些日志的存储节点。这个名为日志的节点集(node set of the log)通常小于集群中存储节点的总数。节点集属于日志复制策略的一部分。它可能随时更改,日志的元数据历史记录中有适当的注释,读取器可以查阅该注释,以便找到所要连接的存储节点。节点集允许LogDevice集群独立于读取器的数据来进行扩展。客户端节点间的通讯是依靠快速生成的记录副本通过TCP连接实现的。因此,每条记录的报头自然都会含有序号。LogDevice的客户端库的一些操作,如对记录执行重新排序作,以及偶尔执行重复数据删除,这些操作是确保记录按LSN的顺序传送给读取应用程序所必需的。

然而,这种放置和传递的机制虽然很适合写入性和处理有峰值的写入负载,但对于经常包含很多点读取(point read)的文件负载来说效率不是很高。对于多数顺序性的日志读取工作负载来说,它是很高效的。通过读取器联系的全部存储节点可能会有一些记录需要传送。着不会浪费任何IO和网络资源。我们会确保,每个记录只有一个副本会从磁盘读取,并通过在每个记录副本的报头中加入副本集,再经由网络传输。一种基于副本集的被称为简单服务器过滤机制(simple server-side filtering scheme)同密集型索引耦合,这可以保证在稳定状态下,副本集只有一个节点将读取记录副本,并将其传送给特定读取器。

序号:

如图1所示,LogDevice中记录的序列号不是整数,而是整数对。该对的第一个组件称为纪元数(epoch number),第二个组件是纪元内偏移。通常的元组比较规则适用。在LSN中另一种可用性优化机制就是使用纪元。当序列器节点崩溃或以其原因变为不可用时,每个新序列器开始生成的LSN必须严格大于所有已为该日志写入记录的LSN。不需要实际查看具体存了什么,纪元可以直接让LogDevice保证这一点。当新的序列器出现后,它从纪元存储区的元数据收到新的纪元数。纪元存储作为一个持久计数器的存储区,每个日志一个,很少递增且保证永不退化。现在我们使用Apache的Zookeeper作为LogDevice的纪元存储。

多对多重建

驱动器错误,电源故障,机架开关失灵,当这些故障发生时,某些或所有记录的可用副本数量可能会减少。当数次连续失败后,该数字降至零,就会丢失数据或至少会丢失一些记录的读取可用性。这都是LogDevice尽力去避免的情形。重建会在一次或多次失败后,为复制不足的记录(具有少于目标份数R的记录)创建更多副本。

为了确保高效,重建必须要快速。它必须在下一次失败之前完成一些已然失败记录的最后一个副本。与HDFS类似,LogDevice实现的重建是多对多的。所有存储节点都充当记录副本的提供者(donor)和接收者(recipient)。整个需要重建的集群会调配资源,让LogDevice可以以每秒5-10GB的速率完全恢复受故障影响的所有记录的复制因子。

重建协调是完全分布式的,而且并通过事件日志的内部元数据执行。

本地日志存储

排序和存储的分离解耦有助于分配集群的总体CPU和存储资源,以匹配不断变化的,有时的峰值负载。但是,分布式数据存储的每节点效率很大程度上取决于其本地存储层。最后,必须将多个记录副本保存在非易失性设备上,例如硬盘驱动器或固态硬盘(SSD)。当每个节点以100MBps+的速度存储数小时的记录时,仅靠内存(RAM)存储是不切实际的。当积压持续时间以天为单位(在Facebook这是一个常见的要求)时,硬盘驱动器的性价比明显高于闪存。所以我们在LogDevice设计了本地存储组件,不仅在具有巨大IOPS容量的闪存上,而且在硬盘上也能很好地运行。企业级硬盘驱动器可以推动相当数量的顺序写入和读取(100-200MBps),不过随机IOPS最高也就100-140MBps。

在LogDevice,它的本地日志存储被称为LogsDB,是一个写优化数据存储,旨在保持磁盘搜索的数量小和受控,并且存储设备上的写和读IO模式基本上是顺序的。正如它强调的写优化数据存储,它的目标就是在写入数据时,甚至数据是属于多个文件或日志,都能提供出色的性能。高写入性能的同时,会在某些系统里带来糟糕的读取效率。除了在硬盘上表现良好外,Logs DB在日志跟踪的负载方面,它的效率特别好。在这种正常的日志访问模式下,记录在被写入后会马上传递给读取器。这些记录不会再被读取,出发在非常罕见的紧急情况下:那些大规模的全量拷贝。这些读取器会从内存读取,这样可以使因为读取单个日志导致降低效率的问题变得无关紧要。

LogsDB是RocksDB之上的一个层,是基于LSM树的一种有序持久键值数据存储。LogsDB属于RocksDB列族按时间排序的集合,是完全成熟的RocksDB实例,共享一个共同的预写日志。每个RocksDB实例被称为LogsDB分区。所有日志的每个新写入,无论是一个还是一百万个日志,都会进入最新的分区,按照(日志id,LSN)对它们进行排序,并以一系列的大型已排序不可变文件(称为SST文件)中保存在磁盘上。这使得硬盘上写入的IO工作负载基本上是按顺序的,但这导致了在读取记录时,需要从多个文件来合并数据(文件的数量最多是Logs DB分区中允许的最大文件数,通常情况下是10个左右)。从多个文件读取会导致读取放大,或者浪费一些读取IO。

LogsDB的控制读取放大,是以一种特别适合日志数据模型的方式:不可变的LSN识别的不可变记录并随时间而单调递增。在控制文件数量方面,当SST文件的数量达到最大时,LogsDB不考虑分区,而是新创建一个最新分区,而不是通过合并排序(merge-sorting)成一个更大的有序LogsDB。由于分区是按顺序读取的,即便所有分区中的SST文件总数达到数万个,同时读取的文件数量也不可能超过单个分区中的最大文件数。通过删除(或在某些情况下偶尔合并排序)最旧的分区,可以有效地回收空间。

应用场景和未来展望

在Facebook,LogDevice已经成为众多日志工作负载的一种通用的解决方案。下面举几个例子。比如Scribe就是LogDevice众多海量级用户的一个使用者,峰值期间每秒获取的数据超过1TB,不仅可以可靠地传输并且可以回放。Scribe提供了一套即发即弃(fire-and-forget)的写入API,传送延迟预期在几秒左右。运行Scribe的LogDevice集群会针对每个设备的效率进行调整,而不是很低的端到端延迟或者追加延迟。TAO数据的二级索引也用的LogDevice,这是另一个应用场景。它的吞吐量虽没有Scribe大,但每个日志的严格记录排序很重要,期望的端到端延迟大概为10毫秒。这需要有非常不同的协调。另一个有趣的例子是机器学习管道,它使用LogDevice将相同的事件流提供给多个ML模型训练服务。

LogDevice还有更多的功能正在积极开发中。它用C++编写的,几乎没什么外部依赖。目前正在探索的新领域包括集群分解,其中存储和CPU密集型任务由具有不同硬件配置文件的服务器处理,支持非常高容量的日志,以及通过应用程序提供的密钥对记录进行高效的服务器端过滤。这些功能将不仅能提高LogDevice集群的硬件效率,而且为高吞吐量数据流的用户提供可扩展的负载分配机制。

最新更新:LogDevice已经开源,并可以通过GitHub找到它。

原文链接:LogDevice: a distributed data store for logs(翻译:伊海峰) 收起阅读 »

领域驱动设计在美团点评业务系统的实践

DDD

至少30年以前,一些软件设计人员就已经意识到领域建模和设计的重要性,并形成一种思潮,Eric Evans将其定义为领域驱动设计(Domain-Driven Design,简称DDD)。在互联网开发“小步快跑,迭代试错”的大环境下,DDD似乎是一种比较“古老而缓慢”的思想。然而,由于互联网公司也逐渐深入实体经济,业务日益复杂,我们在开发中也越来越多地遇到传统行业软件开发中所面临的问题。本文就先来讲一下这些问题,然后再尝试在实践中用DDD的思想来解决这些问题。

过度耦合

业务初期,我们的功能大都非常简单,普通的CRUD就能满足,此时系统是清晰的。随着迭代的不断演化,业务逻辑变得越来越复杂,我们的系统也越来越冗杂。模块彼此关联,谁都很难说清模块的具体功能意图是啥。修改一个功能时,往往光回溯该功能需要的修改点就需要很长时间,更别提修改带来的不可预知的影响面。

下图是一个常见的系统耦合病例。
1.png

服务耦合示意图

订单服务接口中提供了查询、创建订单相关的接口,也提供了订单评价、支付、保险的接口。同时我们的表也是一个订单大表,包含了非常多字段。在我们维护代码时,牵一发而动全身,很可能只是想改下评价相关的功能,却影响到了创单核心路径。虽然我们可以通过测试保证功能完备性,但当我们在订单领域有大量需求同时并行开发时,改动重叠、恶性循环、疲于奔命修改各种问题。

上述问题,归根到底在于系统架构不清晰,划分出来的模块内聚度低、高耦合。

有一种解决方案,按照演进式设计的理论,让系统的设计随着系统实现的增长而增长。我们不需要作提前设计,就让系统伴随业务成长而演进。这当然是可行的,敏捷实践中的重构、测试驱动设计及持续集成可以对付各种混乱问题。重构——保持行为不变的代码改善清除了不协调的局部设计,测试驱动设计确保对系统的更改不会导致系统丢失或破坏现有功能,持续集成则为团队提供了同一代码库。

在这三种实践中,重构是克服演进式设计中大杂烩问题的主力,通过在单独的类及方法级别上做一系列小步重构来完成。我们可以很容易重构出一个独立的类来放某些通用的逻辑,但是你会发现你很难给它一个业务上的含义,只能给予一个技术维度描绘的含义。这会带来什么问题呢?新同学并不总是知道对通用逻辑的改动或获取来自该类。显然,制定项目规范并不是好的idea。我们又闻到了代码即将腐败的味道。

事实上,你可能意识到问题之所在。在解决现实问题时,我们会将问题映射到脑海中的概念模型,在模型中解决问题,再将解决方案转换为实际的代码。上述问题在于我们解决了设计到代码之间的重构,但提炼出来的设计模型,并不具有实际的业务含义,这就导致在开发新需求时,其他同学并不能很自然地将业务问题映射到该设计模型。设计似乎变成了重构者的自娱自乐,代码继续腐败,重新重构……无休止的循环。

用DDD则可以很好地解决领域模型到设计模型的同步、演化,最后再将反映了领域的设计模型转为实际的代码。

注:模型是我们解决实际问题所抽象出来的概念模型,领域模型则表达与业务相关的事实;设计模型则描述了所要构建的系统。

贫血症和失忆症

贫血领域对象(Anemic Domain Object)是指仅用作数据载体,而没有行为和动作的领域对象。

在我们习惯了J2EE的开发模式后,Action/Service/DAO这种分层模式,会很自然地写出过程式代码,而学到的很多关于OO理论的也毫无用武之地。使用这种开发方式,对象只是数据的载体,没有行为。以数据为中心,以数据库ER设计作驱动。分层架构在这种开发模式下,可以理解为是对数据移动、处理和实现的过程。

以笔者最近开发的系统抽奖平台为例:

场景需求,奖池里配置了很多奖项,我们需要按运营预先配置的概率抽中一个奖项。 实现非常简单,生成一个随机数,匹配符合该随机数生成概率的奖项即可。

贫血模型实现方案,先设计奖池和奖项的库表配置。
2.png

抽奖ER图

设计AwardPool和Award两个对象,只有简单的get和set属性的方法:
class AwardPool {
int awardPoolId;
List<Award> awards;
public List<Award> getAwards() {
    return awards;
}

public void setAwards(List<Award> awards) {
    this.awards = awards;
}
......
}

class Award {
int awardId;
int probability;//概率

......


Service代码实现,设计一个LotteryService,在其中的drawLottery()方法写服务逻辑。
AwardPool awardPool = awardPoolDao.getAwardPool(poolId);//sql查询,将数据映射到AwardPool对象
for (Award award : awardPool.getAwards()) {
//寻找到符合award.getProbability()概率的award


按照我们通常思路实现,可以发现:在业务领域里非常重要的抽奖,我的业务逻辑都是写在Service中的,Award充其量只是个数据载体,没有任何行为。简单的业务系统采用这种贫血模型和过程化设计是没有问题的,但在业务逻辑复杂了,业务逻辑、状态会散落到在大量方法中,原本的代码意图会渐渐不明确,我们将这种情况称为由贫血症引起的失忆症。

更好的是采用领域模型的开发方式,将数据和行为封装在一起,并与现实世界中的业务对象相映射。各类具备明确的职责划分,将领域逻辑分散到领域对象中。继续举我们上述抽奖的例子,使用概率选择对应的奖品就应当放到AwardPool类中。

软件系统复杂性应对

解决复杂和大规模软件的武器可以被粗略地归为三类:抽象、分治和知识。
  • 分治,把问题空间分割为规模更小且易于处理的若干子问题。分割后的问题需要足够小,以便一个人单枪匹马就能够解决他们;其次,必须考虑如何将分割后的各个部分装配为整体。分割得越合理越易于理解,在装配成整体时,所需跟踪的细节也就越少。即更容易设计各部分的协作方式。评判什么是分治得好,即高内聚低耦合。
  • 抽象,使用抽象能够精简问题空间,而且问题越小越容易理解。举个例子,从北京到上海出差,可以先理解为使用交通工具前往,但不需要一开始就想清楚到底是高铁还是飞机,以及乘坐他们需要注意什么。
  • 知识,顾名思义,DDD可以认为是知识的一种。


DDD提供了这样的知识手段,让我们知道如何抽象出限界上下文以及如何去分治。

与微服务架构相得益彰

微服务架构众所周知,此处不做赘述。我们创建微服务时,需要创建一个高内聚、低耦合的微服务。而DDD中的限界上下文则完美匹配微服务要求,可以将该限界上下文理解为一个微服务进程。

上述是从更直观的角度来描述两者的相似处。

在系统复杂之后,我们都需要用分治来拆解问题。一般有两种方式,技术维度和业务维度。技术维度是类似MVC这样,业务维度则是指按业务领域来划分系统。

微服务架构更强调从业务维度去做分治来应对系统复杂度,而DDD也是同样的着重业务视角。 如果两者在追求的目标(业务维度)达到了上下文的统一,那么在具体做法上有什么联系和不同呢?

我们将架构设计活动精简为以下三个层面:
  • 业务架构——根据业务需求设计业务模块及其关系
  • 系统架构——设计系统和子系统的模块
  • 技术架构——决定采用的技术及框架


以上三种活动在实际开发中是有先后顺序的,但不一定孰先孰后。在我们解决常规套路问题时,我们会很自然地往熟悉的分层架构套(先确定系统架构),或者用PHP开发很快(先确定技术架构),在业务不复杂时,这样是合理的。

跳过业务架构设计出来的架构关注点不在业务响应上,可能就是个大泥球,在面临需求迭代或响应市场变化时就很痛苦。

DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。而微服务追求业务层面的复用,设计出来的系统架构和业务一致;在技术架构上则系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据。

可以参见下图来更好地理解双方之间的协作关系:
3.png

DDD与微服务关系

我们将通过上文提到的抽奖平台,来详细介绍我们如何通过DDD来解构一个中型的基于微服务架构的系统,从而做到系统的高内聚、低耦合。

首先看下抽奖系统的大致需求: 运营——可以配置一个抽奖活动,该活动面向一个特定的用户群体,并针对一个用户群体发放一批不同类型的奖品(优惠券,激活码,实物奖品等)。 用户-通过活动页面参与不同类型的抽奖活动。

设计领域模型的一般步骤如下:
  1. 根据需求划分出初步的领域和限界上下文,以及上下文之间的关系;
  2. 进一步分析每个上下文内部,识别出哪些是实体,哪些是值对象;
  3. 对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根;
  4. 为聚合根设计仓储,并思考实体或值对象的创建方式;
  5. 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。


战略建模

战略和战术设计是站在DDD的角度进行划分。战略设计侧重于高层次、宏观上去划分和集成限界上下文,而战术设计则关注更具体使用建模工具来细化上下文。

领域

现实世界中,领域包含了问题域和解系统。一般认为软件是对现实世界的部分模拟。在DDD中,解系统可以映射为一个个限界上下文,限界上下文就是软件对于问题域的一个特定的、有限的解决方案。

限界上下文

限界上下文,一个由显示边界限定的特定职责。领域模型便存在于这个边界之内。在边界内,每一个模型概念,包括它的属性和操作,都具有特殊的含义。

一个给定的业务领域会包含多个限界上下文,想与一个限界上下文沟通,则需要通过显示边界进行通信。系统通过确定的限界上下文来进行解耦,而每一个上下文内部紧密组织,职责明确,具有较高的内聚性。

一个很形象的隐喻:细胞质所以能够存在,是因为细胞膜限定了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。

划分限界上下文

划分限界上下文,不管是Eric Evans还是Vaughn Vernon,在他们的大作里都没有怎么提及。

显然我们不应该按技术架构或者开发任务来创建限界上下文,应该按照语义的边界来考虑。

我们的实践是,考虑产品所讲的通用语言,从中提取一些术语称之为概念对象,寻找对象之间的联系;或者从需求里提取一些动词,观察动词和对象之间的关系;我们将紧耦合的各自圈在一起,观察他们内在的联系,从而形成对应的界限上下文。形成之后,我们可以尝试用语言来描述下界限上下文的职责,看它是否清晰、准确、简洁和完整。简言之,限界上下文应该从需求出发,按领域划分。

前文提到,我们的用户划分为运营和用户。其中,运营对抽奖活动的配置十分复杂但相对低频。用户对这些抽奖活动配置的使用是高频次且无感知的。根据这样的业务特点,我们首先将抽奖平台划分为C端抽奖和M端抽奖管理平台两个子域,让两者完全解耦。
4.png

抽奖平台领域

在确认了M端领域和C端的限界上下文后,我们再对各自上下文内部进行限界上下文的划分。下面我们用C端进行举例。

产品的需求概述如下:
  1. 抽奖活动有活动限制,例如用户的抽奖次数限制,抽奖的开始和结束的时间等;
  2. 一个抽奖活动包含多个奖品,可以针对一个或多个用户群体;
  3. 奖品有自身的奖品配置,例如库存量,被抽中的概率等,最多被一个用户抽中的次数等等;
  4. 用户群体有多种区别方式,如按照用户所在城市区分,按照新老客区分等;
  5. 活动具有风控配置,能够限制用户参与抽奖的频率。


根据产品的需求,我们提取了一些关键性的概念作为子域,形成我们的限界上下文。
5.png

C端抽奖领域

首先,抽奖上下文作为整个领域的核心,承担着用户抽奖的核心业务,抽奖中包含了奖品和用户群体的概念。

在设计初期,我们曾经考虑划分出抽奖和发奖两个领域,前者负责选奖,后者负责将选中的奖品发放出去。但在实际开发过程中,我们发现这两部分的逻辑紧密连接,难以拆分。并且单纯的发奖逻辑足够简单,仅仅是调用第三方服务进行发奖,不足以独立出来成为一个领域。

对于活动的限制,我们定义了活动准入的通用语言,将活动开始/结束时间,活动可参与次数等限制条件都收拢到活动准入上下文中。

对于抽奖的奖品库存量,由于库存的行为与奖品本身相对解耦,库存关注点更多是库存内容的核销,且库存本身具备通用性,可以被奖品之外的内容使用,因此我们定义了独立的库存上下文。

由于C端存在一些刷单行为,我们根据产品需求定义了风控上下文,用于对活动进行风控。 最后,活动准入、风控、抽奖等领域都涉及到一些次数的限制,因此我们定义了计数上下文。

可以看到,通过DDD的限界上下文划分,我们界定出抽奖、活动准入、风控、计数、库存等五个上下文,每个上下文在系统中都高度内聚。

上下文映射图

在进行上下文划分之后,我们还需要进一步梳理上下文之间的关系。


康威(梅尔·康威)定律,任何组织在设计一套系统(广义概念上的系统)时,所交付的设计方案在结构上都与该组织的沟通结构保持一致。
康威定律告诉我们,系统结构应尽量的与组织结构保持一致。这里,我们认为团队结构(无论是内部组织还是团队间组织)就是组织结构,限界上下文就是系统的业务结构。因此,团队结构应该和限界上下文保持一致。

梳理清楚上下文之间的关系,从团队内部的关系来看,有如下好处:
  1. 任务更好拆分,一个开发人员可以全身心的投入到相关的一个单独的上下文中;
  2. 沟通更加顺畅,一个上下文可以明确自己对其他上下文的依赖关系,从而使得团队内开发直接更好的对接。


从团队间的关系来看,明确的上下文关系能够带来如下帮助:
  1. 每个团队在它的上下文中能够更加明确自己领域内的概念,因为上下文是领域的解系统;
  2. 对于限界上下文之间发生交互,团队与上下文的一致性,能够保证我们明确对接的团队和依赖的上下游。


限界上下文之间的映射关系
  • 合作关系(Partnership):两个上下文紧密合作的关系,一荣俱荣,一损俱损。
  • 共享内核(Shared Kernel):两个上下文依赖部分共享的模型。
  • 客户方-供应方开发(Customer-Supplier Development):上下文之间有组织的上下游依赖。
  • 遵奉者(Conformist):下游上下文只能盲目依赖上游上下文。
  • 防腐层(Anticorruption Layer):一个上下文通过一些适配和转换与另一个上下文交互。
  • 开放主机服务(Open Host Service):定义一种协议来让其他上下文来对本上下文进行访问。
  • 发布语言(Published Language):通常与OHS一起使用,用于定义开放主机的协议。
  • 大泥球(Big Ball of Mud):混杂在一起的上下文关系,边界不清晰。
  • 另谋他路(SeparateWay):两个完全没有任何联系的上下文。


上文定义了上下文映射间的关系,经过我们的反复斟酌,抽奖平台上下文的映射关系图如下:
6.png

上下文映射关系

由于抽奖,风控,活动准入,库存,计数五个上下文都处在抽奖领域的内部,所以它们之间符合“一荣俱荣,一损俱损”的合作关系(PartnerShip,简称PS)。

同时,抽奖上下文在进行发券动作时,会依赖券码、平台券、外卖券三个上下文。抽奖上下文通过防腐层(Anticorruption Layer,ACL)对三个上下文进行了隔离,而三个券上下文通过开放主机服务(Open Host Service)作为发布语言(Published Language)对抽奖上下文提供访问机制。

通过上下文映射关系,我们明确的限制了限界上下文的耦合性,即在抽奖平台中,无论是上下文内部交互(合作关系)还是与外部上下文交互(防腐层),耦合度都限定在数据耦合(Data Coupling)的层级。

战术建模——细化上下文

梳理清楚上下文之间的关系后,我们需要从战术层面上剖析上下文内部的组织关系。首先看下DDD中的一些定义。

实体

当一个对象由其标识(而不是属性)区分时,这种对象称为实体(Entity)。

例:最简单的,公安系统的身份信息录入,对于人的模拟,即认为是实体,因为每个人是独一无二的,且其具有唯一标识(如公安系统分发的身份证号码)。

在实践上建议将属性的验证放到实体中。

值对象

当一个对象用于对事务进行描述而没有唯一标识时,它被称作值对象(Value Object)。

例:比如颜色信息,我们只需要知道{“name”:“黑色”,”css”:“#000000”}这样的值信息就能够满足要求了,这避免了我们对标识追踪带来的系统复杂性。

值对象很重要,在习惯了使用数据库的数据建模后,很容易将所有对象看作实体。使用值对象,可以更好地做系统优化、精简设计。

它具有不变性、相等性和可替换性。

在实践中,需要保证值对象创建后就不能被修改,即不允许外部再修改其属性。在不同上下文集成时,会出现模型概念的公用,如商品模型会存在于电商的各个上下文中。在订单上下文中如果你只关注下单时商品信息快照,那么将商品对象视为值对象是很好的选择。

聚合根

Aggregate(聚合)是一组相关对象的集合,作为一个整体被外界访问,聚合根(Aggregate Root)是这个聚合的根节点。

聚合是一个非常重要的概念,核心领域往往都需要用聚合来表达。其次,聚合在技术上有非常高的价值,可以指导详细设计。

聚合由根实体,值对象和实体组成。

如何创建好的聚合?
  • 边界内的内容具有一致性:在一个事务中只修改一个聚合实例。如果你发现边界内很难接受强一致,不管是出于性能或产品需求的考虑,应该考虑剥离出独立的聚合,采用最终一致的方式。
  • 设计小聚合:大部分的聚合都可以只包含根实体,而无需包含其他实体。即使一定要包含,可以考虑将其创建为值对象。
  • 通过唯一标识来引用其他聚合或实体:当存在对象之间的关联时,建议引用其唯一标识而非引用其整体对象。如果是外部上下文中的实体,引用其唯一标识或将需要的属性构造值对象。 如果聚合创建复杂,推荐使用工厂方法来屏蔽内部复杂的创建逻辑。


聚合内部多个组成对象的关系可以用来指导数据库创建,但不可避免存在一定的抗阻。如聚合中存在List<值对象>,那么在数据库中建立1:N的关联需要将值对象单独建表,此时是有id的,建议不要将该id暴露到资源库外部,对外隐蔽。

领域服务

一些重要的领域行为或操作,可以归类为领域服务。它既不是实体,也不是值对象的范畴。

当我们采用了微服务架构风格,一切领域逻辑的对外暴露均需要通过领域服务来进行。如原本由聚合根暴露的业务逻辑也需要依托于领域服务。

领域事件

领域事件是对领域内发生的活动进行的建模。

抽奖平台的核心上下文是抽奖上下文,接下来介绍下我们对抽奖上下文的建模。
7.png

抽奖上下文

在抽奖上下文中,我们通过抽奖(DrawLottery)这个聚合根来控制抽奖行为,可以看到,一个抽奖包括了抽奖ID(LotteryId)以及多个奖池(AwardPool),而一个奖池针对一个特定的用户群体(UserGroup)设置了多个奖品(Award)。

另外,在抽奖领域中,我们还会使用抽奖结果(SendResult)作为输出信息,使用用户领奖记录(UserLotteryLog)作为领奖凭据和存根。

谨慎使用值对象

在实践中,我们发现虽然一些领域对象符合值对象的概念,但是随着业务的变动,很多原有的定义会发生变更,值对象可能需要在业务意义具有唯一标识,而对这类值对象的重构往往需要较高成本。因此在特定的情况下,我们也要根据实际情况来权衡领域对象的选型。

DDD工程实现

在对上下文进行细化后,我们开始在工程中真正落地DDD。

模块

模块(Module)是DDD中明确提到的一种控制限界上下文的手段,在我们的工程中,一般尽量用一个模块来表示一个领域的限界上下文。

如代码中所示,一般的工程中包的组织方式为{com.公司名.组织架构.业务.上下文.},这样的组织结构能够明确的将一个上下文限定在包的内部。
import com.company.team.bussiness.lottery.*;//抽奖上下文
import com.company.team.bussiness.riskcontrol.*;//风控上下文
import com.company.team.bussiness.counter.*;//计数上下文
import com.company.team.bussiness.condition.*;//活动准入上下文
import com.company.team.bussiness.stock.*;//库存上下文

*代码演示1 模块的组织


对于模块内的组织结构,一般情况下我们是按照领域对象、领域服务、领域资源库、防腐层等组织方式定义的。
import com.company.team.bussiness.lottery.domain.valobj.*;//领域对象-值对象
import com.company.team.bussiness.lottery.domain.entity.*;//领域对象-实体
import com.company.team.bussiness.lottery.domain.aggregate.*;//领域对象-聚合根
import com.company.team.bussiness.lottery.service.*;//领域服务
import com.company.team.bussiness.lottery.repo.*;//领域资源库
import com.company.team.bussiness.lottery.facade.*;//领域防腐层

代码演示2 模块的组织

每个模块的具体实现,我们将在下文中展开。

领域对象

前文提到,领域驱动要解决的一个重要的问题,就是解决对象的贫血问题。这里我们用之前定义的抽奖(DrawLottery)聚合根和奖池(AwardPool)值对象来具体说明。

抽奖聚合根持有了抽奖活动的id和该活动下的所有可用奖池列表,它的一个最主要的领域功能就是根据一个抽奖发生场景(DrawLotteryContext),选择出一个适配的奖池,即chooseAwardPool方法。

chooseAwardPool的逻辑是这样的:DrawLotteryContext会带有用户抽奖时的场景信息(抽奖得分或抽奖时所在的城市),DrawLottery会根据这个场景信息,匹配一个可以给用户发奖的AwardPool。
package com.company.team.bussiness.lottery.domain.aggregate;
import ...;

public class DrawLottery {
private int lotteryId; //抽奖id
private List<AwardPool> awardPools; //奖池列表

//getter & setter
public void setLotteryId(int lotteryId) {
    if(id<=0){
        throw new IllegalArgumentException("非法的抽奖id"); 
    }
    this.lotteryId = lotteryId;
}

//根据抽奖入参context选择奖池
public AwardPool chooseAwardPool(DrawLotteryContext context) {
    if(context.getMtCityInfo()!=null) {
        return chooseAwardPoolByCityInfo(awardPools, context.getMtCityInfo());
    } else {
        return chooseAwardPoolByScore(awardPools, context.getGameScore());
    }
}

//根据抽奖所在城市选择奖池
private AwardPool chooseAwardPoolByCityInfo(List<AwardPool> awardPools, MtCifyInfo cityInfo) {
    for(AwardPool awardPool: awardPools) {
        if(awardPool.matchedCity(cityInfo.getCityId())) {
            return awardPool;
        }
    }
    return null;
}

//根据抽奖活动得分选择奖池
private AwardPool chooseAwardPoolByScore(List<AwardPool> awardPools, int gameScore) {...}


代码演示3 DrawLottery

在匹配到一个具体的奖池之后,需要确定最后给用户的奖品是什么。这部分的领域功能在AwardPool内。
package com.company.team.bussiness.lottery.domain.valobj;
import ...;

public class AwardPool {
private String cityIds;//奖池支持的城市
private String scores;//奖池支持的得分
private int userGroupType;//奖池匹配的用户类型
private List<Awrad> awards;//奖池中包含的奖品

//当前奖池是否与城市匹配
public boolean matchedCity(int cityId) {...}

//当前奖池是否与用户得分匹配
public boolean matchedScore(int score) {...}

//根据概率选择奖池
public Award randomGetAward() {
    int sumOfProbablity = 0;
    for(Award award: awards) {
        sumOfProbability += award.getAwardProbablity();
    }
    int randomNumber = ThreadLocalRandom.current().nextInt(sumOfProbablity);
    range = 0;
    for(Award award: awards) {
        range += award.getProbablity();
        if(randomNumber<range) {
            return award;
        }
    }
    return null;
}


代码演示4 AwardPool

与以往的仅有getter、setter的业务对象不同,领域对象具有了行为,对象更加丰满。同时,比起将这些逻辑写在服务内(例如**Service),领域功能的内聚性更强,职责更加明确。

资源库

领域对象需要资源存储,存储的手段可以是多样化的,常见的无非是数据库,分布式缓存,本地缓存等。资源库(Repository)的作用,就是对领域的存储和访问进行统一管理的对象。在抽奖平台中,我们是通过如下的方式组织资源库的。
//数据库资源
import com.company.team.bussiness.lottery.repo.dao.AwardPoolDao;//数据库访问对象-奖池
import com.company.team.bussiness.lottery.repo.dao.AwardDao;//数据库访问对象-奖品
import com.company.team.bussiness.lottery.repo.dao.po.AwardPO;//数据库持久化对象-奖品
import com.company.team.bussiness.lottery.repo.dao.po.AwardPoolPO;//数据库持久化对象-奖池

import com.company.team.bussiness.lottery.repo.cache.DrawLotteryCacheAccessObj;//分布式缓存访问对象-抽奖缓存访问
import com.company.team.bussiness.lottery.repo.repository.DrawLotteryRepository;//资源库访问对象-抽奖资源库

代码演示5 Repository组织结构

资源库对外的整体访问由Repository提供,它聚合了各个资源库的数据信息,同时也承担了资源存储的逻辑(例如缓存更新机制等)。

在抽奖资源库中,我们屏蔽了对底层奖池和奖品的直接访问,而是仅对抽奖的聚合根进行资源管理。代码示例中展示了抽奖资源获取的方法(最常见的Cache Aside Pattern)。

比起以往将资源管理放在服务中的做法,由资源库对资源进行管理,职责更加明确,代码的可读性和可维护性也更强。
package com.company.team.bussiness.lottery.repo;
import ...;

@Repository
public class DrawLotteryRepository {
@Autowired
private AwardDao awardDao;
@Autowired
private AwardPoolDao awardPoolDao;
@AutoWired
private DrawLotteryCacheAccessObj drawLotteryCacheAccessObj;

public DrawLottery getDrawLotteryById(int lotteryId) {
    DrawLottery drawLottery = drawLotteryCacheAccessObj.get(lotteryId);
    if(drawLottery!=null){
        return drawLottery;
    }
    drawLottery = getDrawLotteyFromDB(lotteryId);
    drawLotteryCacheAccessObj.add(lotteryId, drawLottery);
    return drawLottery;
}

private DrawLottery getDrawLotteryFromDB(int lotteryId) {...}


代码演示6 DrawLotteryRepository

防腐层

亦称适配层。在一个上下文中,有时需要对外部上下文进行访问,通常会引入防腐层的概念来对外部上下文的访问进行一次转义。

有以下几种情况会考虑引入防腐层:
  • 需要将外部上下文中的模型翻译成本上下文理解的模型。
  • 不同上下文之间的团队协作关系,如果是供奉者关系,建议引入防腐层,避免外部上下文变化对本上下文的侵蚀。
  • 该访问本上下文使用广泛,为了避免改动影响范围过大。


如果内部多个上下文对外部上下文需要访问,那么可以考虑将其放到通用上下文中。

在抽奖平台中,我们定义了用户城市信息防腐层(UserCityInfoFacade),用于外部的用户城市信息上下文(微服务架构下表现为用户城市信息服务)。

以用户信息防腐层举例,它以抽奖请求参数(LotteryContext)为入参,以城市信息(MtCityInfo)为输出。
package com.company.team.bussiness.lottery.facade;
import ...;

@Component
public class UserCityInfoFacade {
@Autowired
private LbsService lbsService;//外部用户城市信息RPC服务

public MtCityInfo getMtCityInfo(LotteryContext context) {
    LbsReq lbsReq = new LbsReq();
    lbsReq.setLat(context.getLat());
    lbsReq.setLng(context.getLng());
    LbsResponse resp = lbsService.getLbsCityInfo(lbsReq);
    return buildMtCifyInfo(resp);
}

private MtCityInfo buildMtCityInfo(LbsResponse resp) {...}


代码演示7 UserCityInfoFacade

领域服务

上文中,我们将领域行为封装到领域对象中,将资源管理行为封装到资源库中,将外部上下文的交互行为封装到防腐层中。此时,我们再回过头来看领域服务时,能够发现领域服务本身所承载的职责也就更加清晰了,即就是通过串联领域对象、资源库和防腐层等一系列领域内的对象的行为,对其他上下文提供交互的接口。

我们以抽奖服务为例(issueLottery),可以看到在省略了一些防御性逻辑(异常处理,空值判断等)后,领域服务的逻辑已经足够清晰明了。
package com.company.team.bussiness.lottery.service.impl
import ...;

@Service
public class LotteryServiceImpl implements LotteryService {
@Autowired
private DrawLotteryRepository drawLotteryRepo;
@Autowired
private UserCityInfoFacade UserCityInfoFacade;
@Autowired
private AwardSendService awardSendService;
@Autowired
private AwardCounterFacade awardCounterFacade;

@Override
public IssueResponse issueLottery(LotteryContext lotteryContext) {
    DrawLottery drawLottery = drawLotteryRepo.getDrawLotteryById(lotteryContext.getLotteryId());//获取抽奖配置聚合根
    awardCounterFacade.incrTryCount(lotteryContext);//增加抽奖计数信息
    AwardPool awardPool = lotteryConfig.chooseAwardPool(bulidDrawLotteryContext(drawLottery, lotteryContext));//选中奖池
    Award award = awardPool.randomChooseAward();//选中奖品
    return buildIssueResponse(awardSendService.sendAward(award, lotteryContext));//发出奖品实体
}

private IssueResponse buildIssueResponse(AwardSendResponse awardSendResponse) {...}


代码演示8 LotteryService

数据流转

8.png

数据流转

在抽奖平台的实践中,我们的数据流转如上图所示。 首先领域的开放服务通过信息传输对象(DTO)来完成与外界的数据交互;在领域内部,我们通过领域对象(DO)作为领域内部的数据和行为载体;在资源库内部,我们沿袭了原有的数据库持久化对象(PO)进行数据库资源的交互。同时,DTO与DO的转换发生在领域服务内,DO与PO的转换发生在资源库内。

与以往的业务服务相比,当前的编码规范可能多造成了一次数据转换,但每种数据对象职责明确,数据流转更加清晰。

上下文集成

通常集成上下文的手段有多种,常见的手段包括开放领域服务接口、开放HTTP服务以及消息发布-订阅机制。

在抽奖系统中,我们使用的是开放服务接口进行交互的。最明显的体现是计数上下文,它作为一个通用上下文,对抽奖、风控、活动准入等上下文都提供了访问接口。 同时,如果在一个上下文对另一个上下文进行集成时,若需要一定的隔离和适配,可以引入防腐层的概念。这一部分的示例可以参考前文的防腐层代码示例。

分离领域

接下来讲解在实施领域模型的过程中,如何应用到系统架构中。

我们采用的微服务架构风格,与Vernon在《实现领域驱动设计》并不太一致,更具体差异可阅读他的书体会。

如果我们维护一个从前到后的应用系统:

下图中领域服务是使用微服务技术剥离开来,独立部署,对外暴露的只能是服务接口,领域对外暴露的业务逻辑只能依托于领域服务。而在Vernon著作中,并未假定微服务架构风格,因此领域层暴露的除了领域服务外,还有聚合、实体和值对象等。此时的应用服务层是比较简单的,获取来自接口层的请求参数,调度多个领域服务以实现界面层功能。
9.png

DDD-分层

随着业务发展,业务系统快速膨胀,我们的系统属于核心时:

应用服务虽然没有领域逻辑,但涉及到了对多个领域服务的编排。当业务规模庞大到一定程度,编排本身就富含了业务逻辑(除此之外,应用服务在稳定性、性能上所做的措施也希望统一起来,而非散落各处),那么此时应用服务对于外部来说是一个领域服务,整体看起来则是一个独立的限界上下文。

此时应用服务对内还属于应用服务,对外已是领域服务的概念,需要将其暴露为微服务。
10.png

DDD-系统架构图

注:具体的架构实践可按照团队和业务的实际情况来,此处仅为作者自身的业务实践。除分层架构外,如CQRS架构也是不错的选择

以下是一个示例。我们定义了抽奖、活动准入、风险控制等多个领域服务。在本系统中,我们需要集成多个领域服务,为客户端提供一套功能完备的抽奖应用服务。这个应用服务的组织如下:
package ...;

import ...;

@Service
public class LotteryApplicationService {
@Autowired
private LotteryRiskService riskService;
@Autowired
private LotteryConditionService conditionService;
@Autowired
private LotteryService lotteryService;

//用户参与抽奖活动
public Response<PrizeInfo, ErrorData> participateLottery(LotteryContext lotteryContext) {
    //校验用户登录信息
    validateLoginInfo(lotteryContext);
    //校验风控 
    RiskAccessToken riskToken = riskService.accquire(buildRiskReq(lotteryContext));
    ...
    //活动准入检查
    LotteryConditionResult conditionResult = conditionService.checkLotteryCondition(otteryContext.getLotteryId(),lotteryContext.getUserId());
    ...
    //抽奖并返回结果
    IssueResponse issueResponse = lotteryService.issurLottery(lotteryContext);
    if(issueResponse!=null && issueResponse.getCode()==IssueResponse.OK) {
        return buildSuccessResponse(issueResponse.getPrizeInfo());
    } else {   
        return buildErrorResponse(ResponseCode.ISSUE_LOTTERY_FAIL, ResponseMsg.ISSUE_LOTTERY_FAIL)
    }
}

private void validateLoginInfo(LotteryContext lotteryContext){...}
private Response<PrizeInfo, ErrorData> buildErrorResponse (int code, String msg){...}
private Response<PrizeInfo, ErrorData> buildSuccessResponse (PrizeInfo prizeInfo){...}


代码演示9 LotteryApplicationService

在本文中,我们采用了分治的思想,从抽象到具体阐述了DDD在互联网真实业务系统中的实践。通过领域驱动设计这个强大的武器,我们将系统解构的更加合理。

但值得注意的是,如果你面临的系统很简单或者做一些SmartUI之类,那么你不一定需要DDD。尽管本文对贫血模型、演进式设计提出了些许看法,但它们在特定范围和具体场景下会更高效。读者需要针对自己的实际情况,做一定取舍,适合自己的才是最好的。

本篇通过DDD来讲述软件设计的术与器,本质是为了高内聚低耦合,紧靠本质,按自己的理解和团队情况来实践DDD即可。

原文链接:https://tech.meituan.com/2017/ ... .html
继续阅读 »

至少30年以前,一些软件设计人员就已经意识到领域建模和设计的重要性,并形成一种思潮,Eric Evans将其定义为领域驱动设计(Domain-Driven Design,简称DDD)。在互联网开发“小步快跑,迭代试错”的大环境下,DDD似乎是一种比较“古老而缓慢”的思想。然而,由于互联网公司也逐渐深入实体经济,业务日益复杂,我们在开发中也越来越多地遇到传统行业软件开发中所面临的问题。本文就先来讲一下这些问题,然后再尝试在实践中用DDD的思想来解决这些问题。

过度耦合

业务初期,我们的功能大都非常简单,普通的CRUD就能满足,此时系统是清晰的。随着迭代的不断演化,业务逻辑变得越来越复杂,我们的系统也越来越冗杂。模块彼此关联,谁都很难说清模块的具体功能意图是啥。修改一个功能时,往往光回溯该功能需要的修改点就需要很长时间,更别提修改带来的不可预知的影响面。

下图是一个常见的系统耦合病例。
1.png

服务耦合示意图

订单服务接口中提供了查询、创建订单相关的接口,也提供了订单评价、支付、保险的接口。同时我们的表也是一个订单大表,包含了非常多字段。在我们维护代码时,牵一发而动全身,很可能只是想改下评价相关的功能,却影响到了创单核心路径。虽然我们可以通过测试保证功能完备性,但当我们在订单领域有大量需求同时并行开发时,改动重叠、恶性循环、疲于奔命修改各种问题。

上述问题,归根到底在于系统架构不清晰,划分出来的模块内聚度低、高耦合。

有一种解决方案,按照演进式设计的理论,让系统的设计随着系统实现的增长而增长。我们不需要作提前设计,就让系统伴随业务成长而演进。这当然是可行的,敏捷实践中的重构、测试驱动设计及持续集成可以对付各种混乱问题。重构——保持行为不变的代码改善清除了不协调的局部设计,测试驱动设计确保对系统的更改不会导致系统丢失或破坏现有功能,持续集成则为团队提供了同一代码库。

在这三种实践中,重构是克服演进式设计中大杂烩问题的主力,通过在单独的类及方法级别上做一系列小步重构来完成。我们可以很容易重构出一个独立的类来放某些通用的逻辑,但是你会发现你很难给它一个业务上的含义,只能给予一个技术维度描绘的含义。这会带来什么问题呢?新同学并不总是知道对通用逻辑的改动或获取来自该类。显然,制定项目规范并不是好的idea。我们又闻到了代码即将腐败的味道。

事实上,你可能意识到问题之所在。在解决现实问题时,我们会将问题映射到脑海中的概念模型,在模型中解决问题,再将解决方案转换为实际的代码。上述问题在于我们解决了设计到代码之间的重构,但提炼出来的设计模型,并不具有实际的业务含义,这就导致在开发新需求时,其他同学并不能很自然地将业务问题映射到该设计模型。设计似乎变成了重构者的自娱自乐,代码继续腐败,重新重构……无休止的循环。

用DDD则可以很好地解决领域模型到设计模型的同步、演化,最后再将反映了领域的设计模型转为实际的代码。

注:模型是我们解决实际问题所抽象出来的概念模型,领域模型则表达与业务相关的事实;设计模型则描述了所要构建的系统。

贫血症和失忆症

贫血领域对象(Anemic Domain Object)是指仅用作数据载体,而没有行为和动作的领域对象。

在我们习惯了J2EE的开发模式后,Action/Service/DAO这种分层模式,会很自然地写出过程式代码,而学到的很多关于OO理论的也毫无用武之地。使用这种开发方式,对象只是数据的载体,没有行为。以数据为中心,以数据库ER设计作驱动。分层架构在这种开发模式下,可以理解为是对数据移动、处理和实现的过程。

以笔者最近开发的系统抽奖平台为例:

场景需求,奖池里配置了很多奖项,我们需要按运营预先配置的概率抽中一个奖项。 实现非常简单,生成一个随机数,匹配符合该随机数生成概率的奖项即可。

贫血模型实现方案,先设计奖池和奖项的库表配置。
2.png

抽奖ER图

设计AwardPool和Award两个对象,只有简单的get和set属性的方法:
class AwardPool {
int awardPoolId;
List<Award> awards;
public List<Award> getAwards() {
    return awards;
}

public void setAwards(List<Award> awards) {
    this.awards = awards;
}
......
}

class Award {
int awardId;
int probability;//概率

......


Service代码实现,设计一个LotteryService,在其中的drawLottery()方法写服务逻辑。
AwardPool awardPool = awardPoolDao.getAwardPool(poolId);//sql查询,将数据映射到AwardPool对象
for (Award award : awardPool.getAwards()) {
//寻找到符合award.getProbability()概率的award


按照我们通常思路实现,可以发现:在业务领域里非常重要的抽奖,我的业务逻辑都是写在Service中的,Award充其量只是个数据载体,没有任何行为。简单的业务系统采用这种贫血模型和过程化设计是没有问题的,但在业务逻辑复杂了,业务逻辑、状态会散落到在大量方法中,原本的代码意图会渐渐不明确,我们将这种情况称为由贫血症引起的失忆症。

更好的是采用领域模型的开发方式,将数据和行为封装在一起,并与现实世界中的业务对象相映射。各类具备明确的职责划分,将领域逻辑分散到领域对象中。继续举我们上述抽奖的例子,使用概率选择对应的奖品就应当放到AwardPool类中。

软件系统复杂性应对

解决复杂和大规模软件的武器可以被粗略地归为三类:抽象、分治和知识。
  • 分治,把问题空间分割为规模更小且易于处理的若干子问题。分割后的问题需要足够小,以便一个人单枪匹马就能够解决他们;其次,必须考虑如何将分割后的各个部分装配为整体。分割得越合理越易于理解,在装配成整体时,所需跟踪的细节也就越少。即更容易设计各部分的协作方式。评判什么是分治得好,即高内聚低耦合。
  • 抽象,使用抽象能够精简问题空间,而且问题越小越容易理解。举个例子,从北京到上海出差,可以先理解为使用交通工具前往,但不需要一开始就想清楚到底是高铁还是飞机,以及乘坐他们需要注意什么。
  • 知识,顾名思义,DDD可以认为是知识的一种。


DDD提供了这样的知识手段,让我们知道如何抽象出限界上下文以及如何去分治。

与微服务架构相得益彰

微服务架构众所周知,此处不做赘述。我们创建微服务时,需要创建一个高内聚、低耦合的微服务。而DDD中的限界上下文则完美匹配微服务要求,可以将该限界上下文理解为一个微服务进程。

上述是从更直观的角度来描述两者的相似处。

在系统复杂之后,我们都需要用分治来拆解问题。一般有两种方式,技术维度和业务维度。技术维度是类似MVC这样,业务维度则是指按业务领域来划分系统。

微服务架构更强调从业务维度去做分治来应对系统复杂度,而DDD也是同样的着重业务视角。 如果两者在追求的目标(业务维度)达到了上下文的统一,那么在具体做法上有什么联系和不同呢?

我们将架构设计活动精简为以下三个层面:
  • 业务架构——根据业务需求设计业务模块及其关系
  • 系统架构——设计系统和子系统的模块
  • 技术架构——决定采用的技术及框架


以上三种活动在实际开发中是有先后顺序的,但不一定孰先孰后。在我们解决常规套路问题时,我们会很自然地往熟悉的分层架构套(先确定系统架构),或者用PHP开发很快(先确定技术架构),在业务不复杂时,这样是合理的。

跳过业务架构设计出来的架构关注点不在业务响应上,可能就是个大泥球,在面临需求迭代或响应市场变化时就很痛苦。

DDD的核心诉求就是将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。而微服务追求业务层面的复用,设计出来的系统架构和业务一致;在技术架构上则系统模块之间充分解耦,可以自由地选择合适的技术架构,去中心化地治理技术和数据。

可以参见下图来更好地理解双方之间的协作关系:
3.png

DDD与微服务关系

我们将通过上文提到的抽奖平台,来详细介绍我们如何通过DDD来解构一个中型的基于微服务架构的系统,从而做到系统的高内聚、低耦合。

首先看下抽奖系统的大致需求: 运营——可以配置一个抽奖活动,该活动面向一个特定的用户群体,并针对一个用户群体发放一批不同类型的奖品(优惠券,激活码,实物奖品等)。 用户-通过活动页面参与不同类型的抽奖活动。

设计领域模型的一般步骤如下:
  1. 根据需求划分出初步的领域和限界上下文,以及上下文之间的关系;
  2. 进一步分析每个上下文内部,识别出哪些是实体,哪些是值对象;
  3. 对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根;
  4. 为聚合根设计仓储,并思考实体或值对象的创建方式;
  5. 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。


战略建模

战略和战术设计是站在DDD的角度进行划分。战略设计侧重于高层次、宏观上去划分和集成限界上下文,而战术设计则关注更具体使用建模工具来细化上下文。

领域

现实世界中,领域包含了问题域和解系统。一般认为软件是对现实世界的部分模拟。在DDD中,解系统可以映射为一个个限界上下文,限界上下文就是软件对于问题域的一个特定的、有限的解决方案。

限界上下文

限界上下文,一个由显示边界限定的特定职责。领域模型便存在于这个边界之内。在边界内,每一个模型概念,包括它的属性和操作,都具有特殊的含义。

一个给定的业务领域会包含多个限界上下文,想与一个限界上下文沟通,则需要通过显示边界进行通信。系统通过确定的限界上下文来进行解耦,而每一个上下文内部紧密组织,职责明确,具有较高的内聚性。

一个很形象的隐喻:细胞质所以能够存在,是因为细胞膜限定了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。

划分限界上下文

划分限界上下文,不管是Eric Evans还是Vaughn Vernon,在他们的大作里都没有怎么提及。

显然我们不应该按技术架构或者开发任务来创建限界上下文,应该按照语义的边界来考虑。

我们的实践是,考虑产品所讲的通用语言,从中提取一些术语称之为概念对象,寻找对象之间的联系;或者从需求里提取一些动词,观察动词和对象之间的关系;我们将紧耦合的各自圈在一起,观察他们内在的联系,从而形成对应的界限上下文。形成之后,我们可以尝试用语言来描述下界限上下文的职责,看它是否清晰、准确、简洁和完整。简言之,限界上下文应该从需求出发,按领域划分。

前文提到,我们的用户划分为运营和用户。其中,运营对抽奖活动的配置十分复杂但相对低频。用户对这些抽奖活动配置的使用是高频次且无感知的。根据这样的业务特点,我们首先将抽奖平台划分为C端抽奖和M端抽奖管理平台两个子域,让两者完全解耦。
4.png

抽奖平台领域

在确认了M端领域和C端的限界上下文后,我们再对各自上下文内部进行限界上下文的划分。下面我们用C端进行举例。

产品的需求概述如下:
  1. 抽奖活动有活动限制,例如用户的抽奖次数限制,抽奖的开始和结束的时间等;
  2. 一个抽奖活动包含多个奖品,可以针对一个或多个用户群体;
  3. 奖品有自身的奖品配置,例如库存量,被抽中的概率等,最多被一个用户抽中的次数等等;
  4. 用户群体有多种区别方式,如按照用户所在城市区分,按照新老客区分等;
  5. 活动具有风控配置,能够限制用户参与抽奖的频率。


根据产品的需求,我们提取了一些关键性的概念作为子域,形成我们的限界上下文。
5.png

C端抽奖领域

首先,抽奖上下文作为整个领域的核心,承担着用户抽奖的核心业务,抽奖中包含了奖品和用户群体的概念。

在设计初期,我们曾经考虑划分出抽奖和发奖两个领域,前者负责选奖,后者负责将选中的奖品发放出去。但在实际开发过程中,我们发现这两部分的逻辑紧密连接,难以拆分。并且单纯的发奖逻辑足够简单,仅仅是调用第三方服务进行发奖,不足以独立出来成为一个领域。

对于活动的限制,我们定义了活动准入的通用语言,将活动开始/结束时间,活动可参与次数等限制条件都收拢到活动准入上下文中。

对于抽奖的奖品库存量,由于库存的行为与奖品本身相对解耦,库存关注点更多是库存内容的核销,且库存本身具备通用性,可以被奖品之外的内容使用,因此我们定义了独立的库存上下文。

由于C端存在一些刷单行为,我们根据产品需求定义了风控上下文,用于对活动进行风控。 最后,活动准入、风控、抽奖等领域都涉及到一些次数的限制,因此我们定义了计数上下文。

可以看到,通过DDD的限界上下文划分,我们界定出抽奖、活动准入、风控、计数、库存等五个上下文,每个上下文在系统中都高度内聚。

上下文映射图

在进行上下文划分之后,我们还需要进一步梳理上下文之间的关系。


康威(梅尔·康威)定律,任何组织在设计一套系统(广义概念上的系统)时,所交付的设计方案在结构上都与该组织的沟通结构保持一致。
康威定律告诉我们,系统结构应尽量的与组织结构保持一致。这里,我们认为团队结构(无论是内部组织还是团队间组织)就是组织结构,限界上下文就是系统的业务结构。因此,团队结构应该和限界上下文保持一致。

梳理清楚上下文之间的关系,从团队内部的关系来看,有如下好处:
  1. 任务更好拆分,一个开发人员可以全身心的投入到相关的一个单独的上下文中;
  2. 沟通更加顺畅,一个上下文可以明确自己对其他上下文的依赖关系,从而使得团队内开发直接更好的对接。


从团队间的关系来看,明确的上下文关系能够带来如下帮助:
  1. 每个团队在它的上下文中能够更加明确自己领域内的概念,因为上下文是领域的解系统;
  2. 对于限界上下文之间发生交互,团队与上下文的一致性,能够保证我们明确对接的团队和依赖的上下游。


限界上下文之间的映射关系
  • 合作关系(Partnership):两个上下文紧密合作的关系,一荣俱荣,一损俱损。
  • 共享内核(Shared Kernel):两个上下文依赖部分共享的模型。
  • 客户方-供应方开发(Customer-Supplier Development):上下文之间有组织的上下游依赖。
  • 遵奉者(Conformist):下游上下文只能盲目依赖上游上下文。
  • 防腐层(Anticorruption Layer):一个上下文通过一些适配和转换与另一个上下文交互。
  • 开放主机服务(Open Host Service):定义一种协议来让其他上下文来对本上下文进行访问。
  • 发布语言(Published Language):通常与OHS一起使用,用于定义开放主机的协议。
  • 大泥球(Big Ball of Mud):混杂在一起的上下文关系,边界不清晰。
  • 另谋他路(SeparateWay):两个完全没有任何联系的上下文。


上文定义了上下文映射间的关系,经过我们的反复斟酌,抽奖平台上下文的映射关系图如下:
6.png

上下文映射关系

由于抽奖,风控,活动准入,库存,计数五个上下文都处在抽奖领域的内部,所以它们之间符合“一荣俱荣,一损俱损”的合作关系(PartnerShip,简称PS)。

同时,抽奖上下文在进行发券动作时,会依赖券码、平台券、外卖券三个上下文。抽奖上下文通过防腐层(Anticorruption Layer,ACL)对三个上下文进行了隔离,而三个券上下文通过开放主机服务(Open Host Service)作为发布语言(Published Language)对抽奖上下文提供访问机制。

通过上下文映射关系,我们明确的限制了限界上下文的耦合性,即在抽奖平台中,无论是上下文内部交互(合作关系)还是与外部上下文交互(防腐层),耦合度都限定在数据耦合(Data Coupling)的层级。

战术建模——细化上下文

梳理清楚上下文之间的关系后,我们需要从战术层面上剖析上下文内部的组织关系。首先看下DDD中的一些定义。

实体

当一个对象由其标识(而不是属性)区分时,这种对象称为实体(Entity)。

例:最简单的,公安系统的身份信息录入,对于人的模拟,即认为是实体,因为每个人是独一无二的,且其具有唯一标识(如公安系统分发的身份证号码)。

在实践上建议将属性的验证放到实体中。

值对象

当一个对象用于对事务进行描述而没有唯一标识时,它被称作值对象(Value Object)。

例:比如颜色信息,我们只需要知道{“name”:“黑色”,”css”:“#000000”}这样的值信息就能够满足要求了,这避免了我们对标识追踪带来的系统复杂性。

值对象很重要,在习惯了使用数据库的数据建模后,很容易将所有对象看作实体。使用值对象,可以更好地做系统优化、精简设计。

它具有不变性、相等性和可替换性。

在实践中,需要保证值对象创建后就不能被修改,即不允许外部再修改其属性。在不同上下文集成时,会出现模型概念的公用,如商品模型会存在于电商的各个上下文中。在订单上下文中如果你只关注下单时商品信息快照,那么将商品对象视为值对象是很好的选择。

聚合根

Aggregate(聚合)是一组相关对象的集合,作为一个整体被外界访问,聚合根(Aggregate Root)是这个聚合的根节点。

聚合是一个非常重要的概念,核心领域往往都需要用聚合来表达。其次,聚合在技术上有非常高的价值,可以指导详细设计。

聚合由根实体,值对象和实体组成。

如何创建好的聚合?
  • 边界内的内容具有一致性:在一个事务中只修改一个聚合实例。如果你发现边界内很难接受强一致,不管是出于性能或产品需求的考虑,应该考虑剥离出独立的聚合,采用最终一致的方式。
  • 设计小聚合:大部分的聚合都可以只包含根实体,而无需包含其他实体。即使一定要包含,可以考虑将其创建为值对象。
  • 通过唯一标识来引用其他聚合或实体:当存在对象之间的关联时,建议引用其唯一标识而非引用其整体对象。如果是外部上下文中的实体,引用其唯一标识或将需要的属性构造值对象。 如果聚合创建复杂,推荐使用工厂方法来屏蔽内部复杂的创建逻辑。


聚合内部多个组成对象的关系可以用来指导数据库创建,但不可避免存在一定的抗阻。如聚合中存在List<值对象>,那么在数据库中建立1:N的关联需要将值对象单独建表,此时是有id的,建议不要将该id暴露到资源库外部,对外隐蔽。

领域服务

一些重要的领域行为或操作,可以归类为领域服务。它既不是实体,也不是值对象的范畴。

当我们采用了微服务架构风格,一切领域逻辑的对外暴露均需要通过领域服务来进行。如原本由聚合根暴露的业务逻辑也需要依托于领域服务。

领域事件

领域事件是对领域内发生的活动进行的建模。

抽奖平台的核心上下文是抽奖上下文,接下来介绍下我们对抽奖上下文的建模。
7.png

抽奖上下文

在抽奖上下文中,我们通过抽奖(DrawLottery)这个聚合根来控制抽奖行为,可以看到,一个抽奖包括了抽奖ID(LotteryId)以及多个奖池(AwardPool),而一个奖池针对一个特定的用户群体(UserGroup)设置了多个奖品(Award)。

另外,在抽奖领域中,我们还会使用抽奖结果(SendResult)作为输出信息,使用用户领奖记录(UserLotteryLog)作为领奖凭据和存根。

谨慎使用值对象

在实践中,我们发现虽然一些领域对象符合值对象的概念,但是随着业务的变动,很多原有的定义会发生变更,值对象可能需要在业务意义具有唯一标识,而对这类值对象的重构往往需要较高成本。因此在特定的情况下,我们也要根据实际情况来权衡领域对象的选型。

DDD工程实现

在对上下文进行细化后,我们开始在工程中真正落地DDD。

模块

模块(Module)是DDD中明确提到的一种控制限界上下文的手段,在我们的工程中,一般尽量用一个模块来表示一个领域的限界上下文。

如代码中所示,一般的工程中包的组织方式为{com.公司名.组织架构.业务.上下文.},这样的组织结构能够明确的将一个上下文限定在包的内部。
import com.company.team.bussiness.lottery.*;//抽奖上下文
import com.company.team.bussiness.riskcontrol.*;//风控上下文
import com.company.team.bussiness.counter.*;//计数上下文
import com.company.team.bussiness.condition.*;//活动准入上下文
import com.company.team.bussiness.stock.*;//库存上下文

*代码演示1 模块的组织


对于模块内的组织结构,一般情况下我们是按照领域对象、领域服务、领域资源库、防腐层等组织方式定义的。
import com.company.team.bussiness.lottery.domain.valobj.*;//领域对象-值对象
import com.company.team.bussiness.lottery.domain.entity.*;//领域对象-实体
import com.company.team.bussiness.lottery.domain.aggregate.*;//领域对象-聚合根
import com.company.team.bussiness.lottery.service.*;//领域服务
import com.company.team.bussiness.lottery.repo.*;//领域资源库
import com.company.team.bussiness.lottery.facade.*;//领域防腐层

代码演示2 模块的组织

每个模块的具体实现,我们将在下文中展开。

领域对象

前文提到,领域驱动要解决的一个重要的问题,就是解决对象的贫血问题。这里我们用之前定义的抽奖(DrawLottery)聚合根和奖池(AwardPool)值对象来具体说明。

抽奖聚合根持有了抽奖活动的id和该活动下的所有可用奖池列表,它的一个最主要的领域功能就是根据一个抽奖发生场景(DrawLotteryContext),选择出一个适配的奖池,即chooseAwardPool方法。

chooseAwardPool的逻辑是这样的:DrawLotteryContext会带有用户抽奖时的场景信息(抽奖得分或抽奖时所在的城市),DrawLottery会根据这个场景信息,匹配一个可以给用户发奖的AwardPool。
package com.company.team.bussiness.lottery.domain.aggregate;
import ...;

public class DrawLottery {
private int lotteryId; //抽奖id
private List<AwardPool> awardPools; //奖池列表

//getter & setter
public void setLotteryId(int lotteryId) {
    if(id<=0){
        throw new IllegalArgumentException("非法的抽奖id"); 
    }
    this.lotteryId = lotteryId;
}

//根据抽奖入参context选择奖池
public AwardPool chooseAwardPool(DrawLotteryContext context) {
    if(context.getMtCityInfo()!=null) {
        return chooseAwardPoolByCityInfo(awardPools, context.getMtCityInfo());
    } else {
        return chooseAwardPoolByScore(awardPools, context.getGameScore());
    }
}

//根据抽奖所在城市选择奖池
private AwardPool chooseAwardPoolByCityInfo(List<AwardPool> awardPools, MtCifyInfo cityInfo) {
    for(AwardPool awardPool: awardPools) {
        if(awardPool.matchedCity(cityInfo.getCityId())) {
            return awardPool;
        }
    }
    return null;
}

//根据抽奖活动得分选择奖池
private AwardPool chooseAwardPoolByScore(List<AwardPool> awardPools, int gameScore) {...}


代码演示3 DrawLottery

在匹配到一个具体的奖池之后,需要确定最后给用户的奖品是什么。这部分的领域功能在AwardPool内。
package com.company.team.bussiness.lottery.domain.valobj;
import ...;

public class AwardPool {
private String cityIds;//奖池支持的城市
private String scores;//奖池支持的得分
private int userGroupType;//奖池匹配的用户类型
private List<Awrad> awards;//奖池中包含的奖品

//当前奖池是否与城市匹配
public boolean matchedCity(int cityId) {...}

//当前奖池是否与用户得分匹配
public boolean matchedScore(int score) {...}

//根据概率选择奖池
public Award randomGetAward() {
    int sumOfProbablity = 0;
    for(Award award: awards) {
        sumOfProbability += award.getAwardProbablity();
    }
    int randomNumber = ThreadLocalRandom.current().nextInt(sumOfProbablity);
    range = 0;
    for(Award award: awards) {
        range += award.getProbablity();
        if(randomNumber<range) {
            return award;
        }
    }
    return null;
}


代码演示4 AwardPool

与以往的仅有getter、setter的业务对象不同,领域对象具有了行为,对象更加丰满。同时,比起将这些逻辑写在服务内(例如**Service),领域功能的内聚性更强,职责更加明确。

资源库

领域对象需要资源存储,存储的手段可以是多样化的,常见的无非是数据库,分布式缓存,本地缓存等。资源库(Repository)的作用,就是对领域的存储和访问进行统一管理的对象。在抽奖平台中,我们是通过如下的方式组织资源库的。
//数据库资源
import com.company.team.bussiness.lottery.repo.dao.AwardPoolDao;//数据库访问对象-奖池
import com.company.team.bussiness.lottery.repo.dao.AwardDao;//数据库访问对象-奖品
import com.company.team.bussiness.lottery.repo.dao.po.AwardPO;//数据库持久化对象-奖品
import com.company.team.bussiness.lottery.repo.dao.po.AwardPoolPO;//数据库持久化对象-奖池

import com.company.team.bussiness.lottery.repo.cache.DrawLotteryCacheAccessObj;//分布式缓存访问对象-抽奖缓存访问
import com.company.team.bussiness.lottery.repo.repository.DrawLotteryRepository;//资源库访问对象-抽奖资源库

代码演示5 Repository组织结构

资源库对外的整体访问由Repository提供,它聚合了各个资源库的数据信息,同时也承担了资源存储的逻辑(例如缓存更新机制等)。

在抽奖资源库中,我们屏蔽了对底层奖池和奖品的直接访问,而是仅对抽奖的聚合根进行资源管理。代码示例中展示了抽奖资源获取的方法(最常见的Cache Aside Pattern)。

比起以往将资源管理放在服务中的做法,由资源库对资源进行管理,职责更加明确,代码的可读性和可维护性也更强。
package com.company.team.bussiness.lottery.repo;
import ...;

@Repository
public class DrawLotteryRepository {
@Autowired
private AwardDao awardDao;
@Autowired
private AwardPoolDao awardPoolDao;
@AutoWired
private DrawLotteryCacheAccessObj drawLotteryCacheAccessObj;

public DrawLottery getDrawLotteryById(int lotteryId) {
    DrawLottery drawLottery = drawLotteryCacheAccessObj.get(lotteryId);
    if(drawLottery!=null){
        return drawLottery;
    }
    drawLottery = getDrawLotteyFromDB(lotteryId);
    drawLotteryCacheAccessObj.add(lotteryId, drawLottery);
    return drawLottery;
}

private DrawLottery getDrawLotteryFromDB(int lotteryId) {...}


代码演示6 DrawLotteryRepository

防腐层

亦称适配层。在一个上下文中,有时需要对外部上下文进行访问,通常会引入防腐层的概念来对外部上下文的访问进行一次转义。

有以下几种情况会考虑引入防腐层:
  • 需要将外部上下文中的模型翻译成本上下文理解的模型。
  • 不同上下文之间的团队协作关系,如果是供奉者关系,建议引入防腐层,避免外部上下文变化对本上下文的侵蚀。
  • 该访问本上下文使用广泛,为了避免改动影响范围过大。


如果内部多个上下文对外部上下文需要访问,那么可以考虑将其放到通用上下文中。

在抽奖平台中,我们定义了用户城市信息防腐层(UserCityInfoFacade),用于外部的用户城市信息上下文(微服务架构下表现为用户城市信息服务)。

以用户信息防腐层举例,它以抽奖请求参数(LotteryContext)为入参,以城市信息(MtCityInfo)为输出。
package com.company.team.bussiness.lottery.facade;
import ...;

@Component
public class UserCityInfoFacade {
@Autowired
private LbsService lbsService;//外部用户城市信息RPC服务

public MtCityInfo getMtCityInfo(LotteryContext context) {
    LbsReq lbsReq = new LbsReq();
    lbsReq.setLat(context.getLat());
    lbsReq.setLng(context.getLng());
    LbsResponse resp = lbsService.getLbsCityInfo(lbsReq);
    return buildMtCifyInfo(resp);
}

private MtCityInfo buildMtCityInfo(LbsResponse resp) {...}


代码演示7 UserCityInfoFacade

领域服务

上文中,我们将领域行为封装到领域对象中,将资源管理行为封装到资源库中,将外部上下文的交互行为封装到防腐层中。此时,我们再回过头来看领域服务时,能够发现领域服务本身所承载的职责也就更加清晰了,即就是通过串联领域对象、资源库和防腐层等一系列领域内的对象的行为,对其他上下文提供交互的接口。

我们以抽奖服务为例(issueLottery),可以看到在省略了一些防御性逻辑(异常处理,空值判断等)后,领域服务的逻辑已经足够清晰明了。
package com.company.team.bussiness.lottery.service.impl
import ...;

@Service
public class LotteryServiceImpl implements LotteryService {
@Autowired
private DrawLotteryRepository drawLotteryRepo;
@Autowired
private UserCityInfoFacade UserCityInfoFacade;
@Autowired
private AwardSendService awardSendService;
@Autowired
private AwardCounterFacade awardCounterFacade;

@Override
public IssueResponse issueLottery(LotteryContext lotteryContext) {
    DrawLottery drawLottery = drawLotteryRepo.getDrawLotteryById(lotteryContext.getLotteryId());//获取抽奖配置聚合根
    awardCounterFacade.incrTryCount(lotteryContext);//增加抽奖计数信息
    AwardPool awardPool = lotteryConfig.chooseAwardPool(bulidDrawLotteryContext(drawLottery, lotteryContext));//选中奖池
    Award award = awardPool.randomChooseAward();//选中奖品
    return buildIssueResponse(awardSendService.sendAward(award, lotteryContext));//发出奖品实体
}

private IssueResponse buildIssueResponse(AwardSendResponse awardSendResponse) {...}


代码演示8 LotteryService

数据流转

8.png

数据流转

在抽奖平台的实践中,我们的数据流转如上图所示。 首先领域的开放服务通过信息传输对象(DTO)来完成与外界的数据交互;在领域内部,我们通过领域对象(DO)作为领域内部的数据和行为载体;在资源库内部,我们沿袭了原有的数据库持久化对象(PO)进行数据库资源的交互。同时,DTO与DO的转换发生在领域服务内,DO与PO的转换发生在资源库内。

与以往的业务服务相比,当前的编码规范可能多造成了一次数据转换,但每种数据对象职责明确,数据流转更加清晰。

上下文集成

通常集成上下文的手段有多种,常见的手段包括开放领域服务接口、开放HTTP服务以及消息发布-订阅机制。

在抽奖系统中,我们使用的是开放服务接口进行交互的。最明显的体现是计数上下文,它作为一个通用上下文,对抽奖、风控、活动准入等上下文都提供了访问接口。 同时,如果在一个上下文对另一个上下文进行集成时,若需要一定的隔离和适配,可以引入防腐层的概念。这一部分的示例可以参考前文的防腐层代码示例。

分离领域

接下来讲解在实施领域模型的过程中,如何应用到系统架构中。

我们采用的微服务架构风格,与Vernon在《实现领域驱动设计》并不太一致,更具体差异可阅读他的书体会。

如果我们维护一个从前到后的应用系统:

下图中领域服务是使用微服务技术剥离开来,独立部署,对外暴露的只能是服务接口,领域对外暴露的业务逻辑只能依托于领域服务。而在Vernon著作中,并未假定微服务架构风格,因此领域层暴露的除了领域服务外,还有聚合、实体和值对象等。此时的应用服务层是比较简单的,获取来自接口层的请求参数,调度多个领域服务以实现界面层功能。
9.png

DDD-分层

随着业务发展,业务系统快速膨胀,我们的系统属于核心时:

应用服务虽然没有领域逻辑,但涉及到了对多个领域服务的编排。当业务规模庞大到一定程度,编排本身就富含了业务逻辑(除此之外,应用服务在稳定性、性能上所做的措施也希望统一起来,而非散落各处),那么此时应用服务对于外部来说是一个领域服务,整体看起来则是一个独立的限界上下文。

此时应用服务对内还属于应用服务,对外已是领域服务的概念,需要将其暴露为微服务。
10.png

DDD-系统架构图

注:具体的架构实践可按照团队和业务的实际情况来,此处仅为作者自身的业务实践。除分层架构外,如CQRS架构也是不错的选择

以下是一个示例。我们定义了抽奖、活动准入、风险控制等多个领域服务。在本系统中,我们需要集成多个领域服务,为客户端提供一套功能完备的抽奖应用服务。这个应用服务的组织如下:
package ...;

import ...;

@Service
public class LotteryApplicationService {
@Autowired
private LotteryRiskService riskService;
@Autowired
private LotteryConditionService conditionService;
@Autowired
private LotteryService lotteryService;

//用户参与抽奖活动
public Response<PrizeInfo, ErrorData> participateLottery(LotteryContext lotteryContext) {
    //校验用户登录信息
    validateLoginInfo(lotteryContext);
    //校验风控 
    RiskAccessToken riskToken = riskService.accquire(buildRiskReq(lotteryContext));
    ...
    //活动准入检查
    LotteryConditionResult conditionResult = conditionService.checkLotteryCondition(otteryContext.getLotteryId(),lotteryContext.getUserId());
    ...
    //抽奖并返回结果
    IssueResponse issueResponse = lotteryService.issurLottery(lotteryContext);
    if(issueResponse!=null && issueResponse.getCode()==IssueResponse.OK) {
        return buildSuccessResponse(issueResponse.getPrizeInfo());
    } else {   
        return buildErrorResponse(ResponseCode.ISSUE_LOTTERY_FAIL, ResponseMsg.ISSUE_LOTTERY_FAIL)
    }
}

private void validateLoginInfo(LotteryContext lotteryContext){...}
private Response<PrizeInfo, ErrorData> buildErrorResponse (int code, String msg){...}
private Response<PrizeInfo, ErrorData> buildSuccessResponse (PrizeInfo prizeInfo){...}


代码演示9 LotteryApplicationService

在本文中,我们采用了分治的思想,从抽象到具体阐述了DDD在互联网真实业务系统中的实践。通过领域驱动设计这个强大的武器,我们将系统解构的更加合理。

但值得注意的是,如果你面临的系统很简单或者做一些SmartUI之类,那么你不一定需要DDD。尽管本文对贫血模型、演进式设计提出了些许看法,但它们在特定范围和具体场景下会更高效。读者需要针对自己的实际情况,做一定取舍,适合自己的才是最好的。

本篇通过DDD来讲述软件设计的术与器,本质是为了高内聚低耦合,紧靠本质,按自己的理解和团队情况来实践DDD即可。

原文链接:https://tech.meituan.com/2017/ ... .html 收起阅读 »

RabbitMQ和Kafka的比较


导言

作为一个有丰富经验的微服务系统架构师,经常有人问我,“应该选择RabbitMQ还是Kafka?”。基于某些原因, 许多开发者会把这两种技术当做等价的来看待。的确,在一些案例场景下选择RabbitMQ还是Kafka没什么差别,但是这两种技术在底层实现方面是有许多差异的。

不同的场景需要不同的解决方案,选错一个方案能够严重的影响你对软件的设计,开发和维护的能力。

这篇文章会先介绍一下基本的异步消息模式,然后再介绍一下RabbitMQ和Kafka以及他们的内部结构信息。第二部分(未完成)主要介绍这两种技术的主要不同点以及他们各自的优缺点,最后我们会说明一下怎样选择这两种技术。

异步消息模式

异步消息可以作为解耦消息的生产和处理的一种解决方案。提到消息系统,我们通常会想到两种主要的消息模式——消息队列和发布/订阅模式。

消息队列

利用消息队列可以解耦生产者和消费者。多个生产者可以向同一个消息队列发送消息;但是,一个消息在被一个消息者处理的时候,这个消息在队列上会被锁住或者被移除并且其他消费者无法处理该消息。也就是说一个具体的消息只能由一个消费者消费。
1.png

消息队列

需要额外注意的是,如果消费者处理一个消息失败了,消息系统一般会把这个消息放回队列,这样其他消费者可以继续处理。消息队列除了提供解耦功能之外,它还能够对生产者和消费者进行独立的伸缩(scale),以及提供对错误处理的容错能力。

发布/订阅

发布/订阅(pub/sub)模式中,单个消息可以被多个订阅者并发的获取和处理。
2.png

发布/订阅

例如,一个系统中产生的事件可以通过这种模式让发布者通知所有订阅者。在许多队列系统中常常用主题(topics)这个术语指代发布/订阅模式。在RabbitMQ中,主题就是发布/订阅模式的一种具体实现(更准确点说是交换器(exchange)的一种),但是在这篇文章中,我会把主题和发布/订阅当做等价来看待。

一般来说,订阅有两种类型:
  1. 临时(ephemeral)订阅,这种订阅只有在消费者启动并且运行的时候才存在。一旦消费者退出,相应的订阅以及尚未处理的消息就会丢失。
  2. 持久(durable)订阅,这种订阅会一直存在,除非主动去删除。消费者退出后,消息系统会继续维护该订阅,并且后续消息可以被继续处理。


RabbitMQ

RabbitMQ作为消息中间件的一种实现,常常被当作一种服务总线来使用。RabbitMQ原生就支持上面提到的两种消息模式。其他一些流行的消息中间件的实现有ActiveMQ,ZeroMQ,Azure Service Bus以及Amazon Simple Queue Service(SQS)。这些消息中间件的实现有许多共通的地方;这边文章中提到的许多概念大部分都适用于这些中间件。

队列

RabbitMQ支持典型的开箱即用的消息队列。开发者可以定义一个命名队列,然后发布者可以向这个命名队列中发送消息。最后消费者可以通过这个命名队列获取待处理的消息。

消息交换器

RabbitMQ使用消息交换器来实现发布/订阅模式。发布者可以把消息发布到消息交换器上而不用知道这些消息都有哪些订阅者。

每一个订阅了交换器的消费者都会创建一个队列;然后消息交换器会把生产的消息放入队列以供消费者消费。消息交换器也可以基于各种路由规则为一些订阅者过滤消息。
3.png

RabbitMQ消息交换器

需要重点注意的是RabbitMQ支持临时和持久两种订阅类型。消费者可以调用RabbitMQ的API来选择他们想要的订阅类型。

根据RabbitMQ的架构设计,我们也可以创建一种混合方法——订阅者以组队的方式然后在组内以竞争关系作为消费者去处理某个具体队列上的消息,这种由订阅者构成的组我们称为消费者组。按照这种方式,我们实现了发布/订阅模式,同时也能够很好的伸缩(scale-up)订阅者去处理收到的消息。
4.png

发布/订阅与队列的联合使用

Apache Kafka

Apache Kafka不是消息中间件的一种实现。相反,它只是一种分布式流式系统。

不同于基于队列和交换器的RabbitMQ,Kafka的存储层是使用分区事务日志来实现的。Kafka也提供流式API用于实时的流处理以及连接器API用来更容易的和各种数据源集成;当然,这些已经超出了本篇文章的讨论范围。

云厂商为Kafka存储层提供了可选的方案,比如Azure Event Hubsy以及AWS Kinesis Data Streams等。对于Kafka流式处理能力,还有一些特定的云方案和开源方案,不过,话说回来,它们也超出了本篇的范围。

主题

Kafka没有实现队列这种东西。相应的,Kafka按照类别存储记录集,并且把这种类别称为主题。

Kafka为每个主题维护一个消息分区日志。每个分区都是由有序的不可变的记录序列组成,并且消息都是连续的被追加在尾部。

当消息到达时,Kafka就会把他们追加到分区尾部。默认情况下,Kafka使用轮询分区器(partitioner)把消息一致的分配到多个分区上。

Kafka可以改变创建消息逻辑流的行为。例如,在一个多租户的应用中,我们可以根据每个消息中的租户ID创建消息流。IoT场景中,我们可以在常数级别下根据生产者的身份信息(identity)将其映射到一个具体的分区上。确保来自相同逻辑流上的消息映射到相同分区上,这就保证了消息能够按照顺序提供给消费者。
5.png

Kafka生产者

消费者通过维护分区的偏移(或者说索引)来顺序的读出消息,然后消费消息。

单个消费者可以消费多个不同的主题,并且消费者的数量可以伸缩到可获取的最大分区数量。

所以在创建主题的时候,我们要认真的考虑一下在创建的主题上预期的消息吞吐量。消费同一个主题的多个消费者构成的组称为消费者组。通过Kafka提供的API可以处理同一消费者组中多个消费者之间的分区平衡以及消费者当前分区偏移的存储。
6.png

Kafka消费者

Kafka实现的消息模式

Kafka的实现很好地契合发布/订阅模式。

生产者可以向一个具体的主题发送消息,然后多个消费者组可以消费相同的消息。每一个消费者组都可以独立的伸缩去处理相应的负载。由于消费者维护自己的分区偏移,所以他们可以选择持久订阅或者临时订阅,持久订阅在重启之后不会丢失偏移而临时订阅在重启之后会丢失偏移并且每次重启之后都会从分区中最新的记录开始读取。

但是这种实现方案不能完全等价的当做典型的消息队列模式看待。当然,我们可以创建一个主题,这个主题和拥有一个消费者的消费组进行关联,这样我们就模拟出了一个典型的消息队列。不过这会有许多缺点,我们会在第二部分详细讨论。

值得特别注意的是,Kafka是按照预先配置好的时间保留分区中的消息,而不是根据消费者是否消费了这些消息。这种保留机制可以让消费者自由的重读之前的消息。另外,开发者也可以利用Kafka的存储层来实现诸如事件溯源和日志审计功能。

结束语

尽管有时候RabbitMQ和Kafka可以当做等价来看,但是他们的实现是非常不同的。所以我们不能把他们当做同种类的工具来看待;一个是消息中间件,另一个是分布式流式系统。

作为解决方案架构师,我们要能够认识到它们之间的差异并且尽可能的考虑在给定场景中使用哪种类型的解决方案。第二部分(未完成)会指出这些差异并且提供什么时候使用哪种方案的指导建议。

原文链接:RabbitMQ vs. Kafka(翻译:王欢)

===============================
王欢,C/C++,Golang和Nodejs后端开发工程师,Kubernetes爱好者,目前就职于一家IM互联网公司。
继续阅读 »

导言

作为一个有丰富经验的微服务系统架构师,经常有人问我,“应该选择RabbitMQ还是Kafka?”。基于某些原因, 许多开发者会把这两种技术当做等价的来看待。的确,在一些案例场景下选择RabbitMQ还是Kafka没什么差别,但是这两种技术在底层实现方面是有许多差异的。

不同的场景需要不同的解决方案,选错一个方案能够严重的影响你对软件的设计,开发和维护的能力。

这篇文章会先介绍一下基本的异步消息模式,然后再介绍一下RabbitMQ和Kafka以及他们的内部结构信息。第二部分(未完成)主要介绍这两种技术的主要不同点以及他们各自的优缺点,最后我们会说明一下怎样选择这两种技术。

异步消息模式

异步消息可以作为解耦消息的生产和处理的一种解决方案。提到消息系统,我们通常会想到两种主要的消息模式——消息队列和发布/订阅模式。

消息队列

利用消息队列可以解耦生产者和消费者。多个生产者可以向同一个消息队列发送消息;但是,一个消息在被一个消息者处理的时候,这个消息在队列上会被锁住或者被移除并且其他消费者无法处理该消息。也就是说一个具体的消息只能由一个消费者消费。
1.png

消息队列

需要额外注意的是,如果消费者处理一个消息失败了,消息系统一般会把这个消息放回队列,这样其他消费者可以继续处理。消息队列除了提供解耦功能之外,它还能够对生产者和消费者进行独立的伸缩(scale),以及提供对错误处理的容错能力。

发布/订阅

发布/订阅(pub/sub)模式中,单个消息可以被多个订阅者并发的获取和处理。
2.png

发布/订阅

例如,一个系统中产生的事件可以通过这种模式让发布者通知所有订阅者。在许多队列系统中常常用主题(topics)这个术语指代发布/订阅模式。在RabbitMQ中,主题就是发布/订阅模式的一种具体实现(更准确点说是交换器(exchange)的一种),但是在这篇文章中,我会把主题和发布/订阅当做等价来看待。

一般来说,订阅有两种类型:
  1. 临时(ephemeral)订阅,这种订阅只有在消费者启动并且运行的时候才存在。一旦消费者退出,相应的订阅以及尚未处理的消息就会丢失。
  2. 持久(durable)订阅,这种订阅会一直存在,除非主动去删除。消费者退出后,消息系统会继续维护该订阅,并且后续消息可以被继续处理。


RabbitMQ

RabbitMQ作为消息中间件的一种实现,常常被当作一种服务总线来使用。RabbitMQ原生就支持上面提到的两种消息模式。其他一些流行的消息中间件的实现有ActiveMQ,ZeroMQ,Azure Service Bus以及Amazon Simple Queue Service(SQS)。这些消息中间件的实现有许多共通的地方;这边文章中提到的许多概念大部分都适用于这些中间件。

队列

RabbitMQ支持典型的开箱即用的消息队列。开发者可以定义一个命名队列,然后发布者可以向这个命名队列中发送消息。最后消费者可以通过这个命名队列获取待处理的消息。

消息交换器

RabbitMQ使用消息交换器来实现发布/订阅模式。发布者可以把消息发布到消息交换器上而不用知道这些消息都有哪些订阅者。

每一个订阅了交换器的消费者都会创建一个队列;然后消息交换器会把生产的消息放入队列以供消费者消费。消息交换器也可以基于各种路由规则为一些订阅者过滤消息。
3.png

RabbitMQ消息交换器

需要重点注意的是RabbitMQ支持临时和持久两种订阅类型。消费者可以调用RabbitMQ的API来选择他们想要的订阅类型。

根据RabbitMQ的架构设计,我们也可以创建一种混合方法——订阅者以组队的方式然后在组内以竞争关系作为消费者去处理某个具体队列上的消息,这种由订阅者构成的组我们称为消费者组。按照这种方式,我们实现了发布/订阅模式,同时也能够很好的伸缩(scale-up)订阅者去处理收到的消息。
4.png

发布/订阅与队列的联合使用

Apache Kafka

Apache Kafka不是消息中间件的一种实现。相反,它只是一种分布式流式系统。

不同于基于队列和交换器的RabbitMQ,Kafka的存储层是使用分区事务日志来实现的。Kafka也提供流式API用于实时的流处理以及连接器API用来更容易的和各种数据源集成;当然,这些已经超出了本篇文章的讨论范围。

云厂商为Kafka存储层提供了可选的方案,比如Azure Event Hubsy以及AWS Kinesis Data Streams等。对于Kafka流式处理能力,还有一些特定的云方案和开源方案,不过,话说回来,它们也超出了本篇的范围。

主题

Kafka没有实现队列这种东西。相应的,Kafka按照类别存储记录集,并且把这种类别称为主题。

Kafka为每个主题维护一个消息分区日志。每个分区都是由有序的不可变的记录序列组成,并且消息都是连续的被追加在尾部。

当消息到达时,Kafka就会把他们追加到分区尾部。默认情况下,Kafka使用轮询分区器(partitioner)把消息一致的分配到多个分区上。

Kafka可以改变创建消息逻辑流的行为。例如,在一个多租户的应用中,我们可以根据每个消息中的租户ID创建消息流。IoT场景中,我们可以在常数级别下根据生产者的身份信息(identity)将其映射到一个具体的分区上。确保来自相同逻辑流上的消息映射到相同分区上,这就保证了消息能够按照顺序提供给消费者。
5.png

Kafka生产者

消费者通过维护分区的偏移(或者说索引)来顺序的读出消息,然后消费消息。

单个消费者可以消费多个不同的主题,并且消费者的数量可以伸缩到可获取的最大分区数量。

所以在创建主题的时候,我们要认真的考虑一下在创建的主题上预期的消息吞吐量。消费同一个主题的多个消费者构成的组称为消费者组。通过Kafka提供的API可以处理同一消费者组中多个消费者之间的分区平衡以及消费者当前分区偏移的存储。
6.png

Kafka消费者

Kafka实现的消息模式

Kafka的实现很好地契合发布/订阅模式。

生产者可以向一个具体的主题发送消息,然后多个消费者组可以消费相同的消息。每一个消费者组都可以独立的伸缩去处理相应的负载。由于消费者维护自己的分区偏移,所以他们可以选择持久订阅或者临时订阅,持久订阅在重启之后不会丢失偏移而临时订阅在重启之后会丢失偏移并且每次重启之后都会从分区中最新的记录开始读取。

但是这种实现方案不能完全等价的当做典型的消息队列模式看待。当然,我们可以创建一个主题,这个主题和拥有一个消费者的消费组进行关联,这样我们就模拟出了一个典型的消息队列。不过这会有许多缺点,我们会在第二部分详细讨论。

值得特别注意的是,Kafka是按照预先配置好的时间保留分区中的消息,而不是根据消费者是否消费了这些消息。这种保留机制可以让消费者自由的重读之前的消息。另外,开发者也可以利用Kafka的存储层来实现诸如事件溯源和日志审计功能。

结束语

尽管有时候RabbitMQ和Kafka可以当做等价来看,但是他们的实现是非常不同的。所以我们不能把他们当做同种类的工具来看待;一个是消息中间件,另一个是分布式流式系统。

作为解决方案架构师,我们要能够认识到它们之间的差异并且尽可能的考虑在给定场景中使用哪种类型的解决方案。第二部分(未完成)会指出这些差异并且提供什么时候使用哪种方案的指导建议。

原文链接:RabbitMQ vs. Kafka(翻译:王欢)

===============================
王欢,C/C++,Golang和Nodejs后端开发工程师,Kubernetes爱好者,目前就职于一家IM互联网公司。 收起阅读 »

9种分布式ID生成方式


为什么要用分布式ID?

在说分布式ID的具体实现之前,我们来简单分析一下为什么用分布式ID?分布式ID应该满足哪些特征?

什么是分布式ID?

拿MySQL数据库举个栗子:

在我们业务数据量不大的时候,单库单表完全可以支撑现有业务,数据再大一点搞个MySQL主从同步读写分离也能对付。

但随着数据日渐增长,主从同步也扛不住了,就需要对数据库进行分库分表,但分库分表后需要有一个唯一ID来标识一条数据,数据库的自增ID显然不能满足需求;特别一点的如订单、优惠券也都需要有唯一ID做标识。此时一个能够生成全局唯一ID的系统是非常必要的。那么这个全局唯一ID就叫分布式ID。

那么分布式ID需要满足那些条件?

  • 全局唯一:必须保证ID是全局性唯一的,基本要求
  • 高性能:高可用低延时,ID生成响应要块,否则反倒会成为业务瓶颈
  • 高可用:100%的可用性是骗人的,但是也要无限接近于100%的可用性
  • 好接入:要秉着拿来即用的设计原则,在系统设计和实现上要尽可能的简单
  • 趋势递增:最好趋势递增,这个要求就得看具体业务场景了,一般不严格要求


分布式ID都有哪些生成方式?

今天主要分析一下以下9种,分布式ID生成器方式以及优缺点:
  • UUID
  • 数据库自增ID
  • 数据库多主模式
  • 号段模式
  • Redis
  • 雪花算法(SnowFlake)
  • 滴滴出品(TinyID)
  • 百度 (Uidgenerator)
  • 美团(Leaf)


那么它们都是如何实现?以及各自有什么优缺点?我们往下看。
1.png

基于UUID

在Java的世界里,想要得到一个具有唯一性的ID,首先被想到可能就是UUID,毕竟它有着全球唯一的特性。那么UUID可以做分布式ID吗?答案是可以的,但是并不推荐!
public static void main(String[] args) { 
   String uuid = UUID.randomUUID().toString().replaceAll("-","");
   System.out.println(uuid);


UUID的生成简单到只有一行代码,输出结果 c2b8c2b9e46c47e3b30dca3b0d447718,但UUID却并不适用于实际的业务需求。像用作订单号UUID这样的字符串没有丝毫的意义,看不出和订单相关的有用信息;而对于数据库来说用作业务主键ID,它不仅是太长还是字符串,存储性能差查询也很耗时,所以不推荐用作分布式ID。

优点:
  • 生成足够简单,本地生成无网络消耗,具有唯一性


缺点:
  • 无序的字符串,不具备趋势自增特性
  • 没有具体的业务含义
  • 长度过长16字节128位,36位长度的字符串,存储以及查询对MySQL的性能消耗较大,MySQL官方明确建议主键要尽量越短越好,作为数据库主键 UUID 的无序性会导致数据位置频繁变动,严重影响性能。


基于数据库自增ID

基于数据库的auto_increment自增ID完全可以充当分布式ID,具体实现:需要一个单独的MySQL实例用来生成ID,建表结构如下:
CREATE DATABASE `SEQ_ID`;
CREATE TABLE SEQID.SEQUENCE_ID (
id bigint(20) unsigned NOT NULL auto_increment, 
value char(10) NOT NULL default '',
PRIMARY KEY (id),
) ENGINE=MyISAM;

insert into SEQUENCE_ID(value)  VALUES ('values');

当我们需要一个ID的时候,向表中插入一条记录返回主键ID,但这种方式有一个比较致命的缺点,访问量激增时MySQL本身就是系统的瓶颈,用它来实现分布式服务风险比较大,不推荐!

优点:
  • 实现简单,ID单调自增,数值类型查询速度快


缺点:
  • DB单点存在宕机风险,无法扛住高并发场景


基于数据库集群模式

前边说了单点数据库方式不可取,那对上边的方式做一些高可用优化,换成主从模式集群。害怕一个主节点挂掉没法用,那就做双主模式集群,也就是两个MySQL实例都能单独的生产自增ID。

那这样还会有个问题,两个MySQL实例的自增ID都从1开始,会生成重复的ID怎么办?

解决方案:设置起始值和自增步长。

MySQL_1 配置:
set @@auto_increment_offset = 1;     -- 起始值
set @@auto_increment_increment = 2;  -- 步长
复制代码MySQL_2 配置:
set @@auto_increment_offset = 2;     -- 起始值
set @@auto_increment_increment = 2;  -- 步长

这样两个MySQL实例的自增ID分别就是:


1、3、5、7、9
2、4、6、8、10
那如果集群后的性能还是扛不住高并发咋办?就要进行MySQL扩容增加节点,这是一个比较麻烦的事。
2.png

从上图可以看出,水平扩展的数据库集群,有利于解决数据库单点压力的问题,同时为了ID生成特性,将自增步长按照机器数量来设置。

增加第三台MySQL实例需要人工修改一、二两台MySQL实例的起始值和步长,把第三台机器的ID起始生成位置设定在比现有最大自增ID的位置远一些,但必须在一、二两台MySQL实例ID还没有增长到第三台MySQL实例的起始ID值的时候,否则自增ID就要出现重复了,必要时可能还需要停机修改。

优点:
  • 解决DB单点问题


缺点:
  • 不利于后续扩容,而且实际上单个数据库自身压力还是大,依旧无法满足高并发场景。


基于数据库的号段模式

号段模式是当下分布式ID生成器的主流实现方式之一,号段模式可以理解为从数据库批量的获取自增ID,每次从数据库取出一个号段范围,例如(1,1000]代表1000个ID,具体的业务服务将本号段,生成1~1000的自增ID并加载到内存。表结构如下:
CREATE TABLE id_generator (
id int(10) NOT NULL,
max_id bigint(20) NOT NULL COMMENT '当前最大id',
step int(20) NOT NULL COMMENT '号段的布长',
biz_type  int(20) NOT NULL COMMENT '业务类型',
version int(20) NOT NULL COMMENT '版本号',
PRIMARY KEY (`id`)



biz_type :代表不同业务类型

max_id :当前最大的可用id

step :代表号段的长度

version :是一个乐观锁,每次都更新version,保证并发时数据的正确性
3.png

等这批号段ID用完,再次向数据库申请新号段,对max_id字段做一次update操作,update max_id= max_id + step,update成功则说明新号段获取成功,新的号段范围是(max_id ,max_id +step]。
update id_generator set max_id = #{max_id+step}, version = version + 1 where version = # {version} and biz_type = XXX

由于多业务端可能同时操作,所以采用版本号version乐观锁方式更新,这种分布式ID生成方式不强依赖于数据库,不会频繁的访问数据库,对数据库的压力小很多。

基于Redis模式

Redis也同样可以实现,原理就是利用Redis的 incr命令实现ID的原子性自增。
127.0.0.1:6379> set seq_id 1     // 初始化自增ID为1
OK
127.0.0.1:6379> incr seq_id      // 增加1,并返回递增后的数值
(integer) 2

用Redis实现需要注意一点,要考虑到Redis持久化的问题。Redis有两种持久化方式RDB和AOF:
  • RDB会定时打一个快照进行持久化,假如连续自增但redis没及时持久化,而这会Redis挂掉了,重启Redis后会出现ID重复的情况。
  • AOF会对每条写命令进行持久化,即使Redis挂掉了也不会出现ID重复的情况,但由于incr命令的特殊性,会导致Redis重启恢复的数据时间过长。


基于雪花算法(Snowflake)模式

雪花算法(Snowflake)是twitter公司内部分布式项目采用的ID生成算法,开源后广受国内大厂的好评,在该算法影响下各大公司相继开发出各具特色的分布式生成器。
4.png

Snowflake生成的是Long类型的ID,一个Long类型占8个字节,每个字节占8比特,也就是说一个Long类型占64个比特。

Snowflake ID组成结构:正数位(占1比特)+ 时间戳(占41比特)+ 机器ID(占5比特)+ 数据中心(占5比特)+ 自增值(占12比特),总共64比特组成的一个Long类型。
  • 第一个bit位(1bit):Java中long的最高位是符号位代表正负,正数是0,负数是1,一般生成ID都为正数,所以默认为0。
  • 时间戳部分(41bit):毫秒级的时间,不建议存当前时间戳,而是用(当前时间戳 - 固定开始时间戳)的差值,可以使产生的ID从更小的值开始;41位的时间戳可以使用69年,(1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69年
  • 工作机器id(10bit):也被叫做workId,这个可以灵活配置,机房或者机器号组合都可以。
  • 序列号部分(12bit):自增值支持同一毫秒内同一个节点可以生成4096个ID


根据这个算法的逻辑,只需要将这个算法用Java语言实现出来,封装为一个工具方法,那么各个业务应用可以直接使用该工具方法来获取分布式ID,只需保证每个业务应用有自己的工作机器id即可,而不需要单独去搭建一个获取分布式ID的应用。

Java版本的Snowflake算法实现:
/**
* Twitter的SnowFlake算法,使用SnowFlake算法生成一个整数,然后转化为62进制变成一个短地址URL
*
* https://github.com/beyondfengyu/SnowFlake
*/
public class SnowFlakeShortUrl {

/**
 * 起始的时间戳
 */
private final static long START_TIMESTAMP = 1480166465631L;

/**
 * 每一部分占用的位数
 */
private final static long SEQUENCE_BIT = 12;   //序列号占用的位数
private final static long MACHINE_BIT = 5;     //机器标识占用的位数
private final static long DATA_CENTER_BIT = 5; //数据中心占用的位数

/**
 * 每一部分的最大值
 */
private final static long MAX_SEQUENCE = -1L ^ (-1L << SEQUENCE_BIT);
private final static long MAX_MACHINE_NUM = -1L ^ (-1L << MACHINE_BIT);
private final static long MAX_DATA_CENTER_NUM = -1L ^ (-1L << DATA_CENTER_BIT);

/**
 * 每一部分向左的位移
 */
private final static long MACHINE_LEFT = SEQUENCE_BIT;
private final static long DATA_CENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT;
private final static long TIMESTAMP_LEFT = DATA_CENTER_LEFT + DATA_CENTER_BIT;

private long dataCenterId;  //数据中心
private long machineId;     //机器标识
private long sequence = 0L; //序列号
private long lastTimeStamp = -1L;  //上一次时间戳

private long getNextMill() {
    long mill = getNewTimeStamp();
    while (mill <= lastTimeStamp) {
        mill = getNewTimeStamp();
    }
    return mill;
}

private long getNewTimeStamp() {
    return System.currentTimeMillis();
}

/**
 * 根据指定的数据中心ID和机器标志ID生成指定的序列号
 *
 * @param dataCenterId 数据中心ID
 * @param machineId    机器标志ID
 */
public SnowFlakeShortUrl(long dataCenterId, long machineId) {
    if (dataCenterId > MAX_DATA_CENTER_NUM || dataCenterId < 0) {
        throw new IllegalArgumentException("DtaCenterId can't be greater than MAX_DATA_CENTER_NUM or less than 0!");
    }
    if (machineId > MAX_MACHINE_NUM || machineId < 0) {
        throw new IllegalArgumentException("MachineId can't be greater than MAX_MACHINE_NUM or less than 0!");
    }
    this.dataCenterId = dataCenterId;
    this.machineId = machineId;
}

/**
 * 产生下一个ID
 *
 * @return
 */
public synchronized long nextId() {
    long currTimeStamp = getNewTimeStamp();
    if (currTimeStamp < lastTimeStamp) {
        throw new RuntimeException("Clock moved backwards.  Refusing to generate id");
    }

    if (currTimeStamp == lastTimeStamp) {
        //相同毫秒内,序列号自增
        sequence = (sequence + 1) & MAX_SEQUENCE;
        //同一毫秒的序列数已经达到最大
        if (sequence == 0L) {
            currTimeStamp = getNextMill();
        }
    } else {
        //不同毫秒内,序列号置为0
        sequence = 0L;
    }

    lastTimeStamp = currTimeStamp;

    return (currTimeStamp - START_TIMESTAMP) << TIMESTAMP_LEFT //时间戳部分
            | dataCenterId << DATA_CENTER_LEFT       //数据中心部分
            | machineId << MACHINE_LEFT             //机器标识部分
            | sequence;                             //序列号部分
}

public static void main(String[] args) {
    SnowFlakeShortUrl snowFlake = new SnowFlakeShortUrl(2, 3);

    for (int i = 0; i < (1 << 4); i++) {
        //10进制
        System.out.println(snowFlake.nextId());
    }
}


百度(uid-generator)

uid-generator是由百度技术部开发,项目GitHub地址:https://github.com/baidu/uid-generator

uid-generator是基于Snowflake算法实现的,与原始的Snowflake算法不同在于,uid-generator支持自定义时间戳、工作机器ID和序列号等各部分的位数,而且uid-generator中采用用户自定义workId的生成策略。

uid-generator需要与数据库配合使用,需要新增一个WORKER_NODE表。当应用启动时会向数据库表中去插入一条数据,插入成功后返回的自增ID就是该机器的workId数据由host,port组成。

对于uid-generator ID组成结构:

workId,占用了22个bit位,时间占用了28个bit位,序列化占用了13个bit位,需要注意的是,和原始的snowflake不太一样,时间的单位是秒,而不是毫秒,workId也不一样,而且同一应用每次重启就会消费一个workId。

美团(Leaf)

Leaf由美团开发,GitHub地址:https://github.com/Meituan-Dianping/Leaf

Leaf同时支持号段模式和snowflake算法模式,可以切换使用。

号段模式

先导入源码https://github.com/Meituan-Dianping/Leaf,在建一张表leaf_alloc:
DROP TABLE IF EXISTS `leaf_alloc`;

CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(128)  NOT NULL DEFAULT '' COMMENT '业务key',
`max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '当前已经分配了的最大id',
`step` int(11) NOT NULL COMMENT '初始步长,也是动态调整的最小步长',
`description` varchar(256)  DEFAULT NULL COMMENT '业务key的描述',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '数据库维护的更新时间',
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;

然后在项目中开启号段模式,配置对应的数据库信息,并关闭Snowflake模式:
leaf.name=com.sankuai.leaf.opensource.test
leaf.segment.enable=true
leaf.jdbc.url=jdbc:mysql://localhost:3306/leaf_test?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8
leaf.jdbc.username=root
leaf.jdbc.password=root

leaf.snowflake.enable=false
#leaf.snowflake.zk.address=
#leaf.snowflake.port=

启动leaf-server模块的LeafServerApplication项目就跑起来了。

号段模式获取分布式自增ID的测试url :http://localhost:8080/api/segment/get/leaf-segment-test。

监控号段模式:http://localhost:8080/cache

Snowflake模式

Leaf的Snowflake模式依赖于ZooKeeper,不同于原始Snowflake算法也主要是在workId的生成上,Leaf中workId是基于ZooKeeper的顺序Id来生成的,每个应用在使用Leaf-snowflake时,启动时都会都在Zookeeper中生成一个顺序Id,相当于一台机器对应一个顺序节点,也就是一个workId。
leaf.snowflake.enable=true
leaf.snowflake.zk.address=127.0.0.1
leaf.snowflake.port=2181

snowflake模式获取分布式自增ID的测试url:http://localhost:8080/api/snowflake/get/test

滴滴(Tinyid)

Tinyid由滴滴开发,GitHub地址:https://github.com/didi/tinyid%E3%80%82

Tinyid是基于号段模式原理实现的与Leaf如出一辙,每个服务获取一个号段(1000,2000]、(2000,3000]、(3000,4000]。
5.png

Tinyid提供http和tinyid-client两种方式接入:

Http方式接入

1、导入Tinyid源码:https://github.com/didi/tinyid.git

2、创建数据表:
CREATE TABLE `tiny_id_info` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`biz_type` varchar(63) NOT NULL DEFAULT '' COMMENT '业务类型,唯一',
`begin_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '开始id,仅记录初始值,无其他含义。初始化时begin_id和max_id应相同',
`max_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '当前最大id',
`step` int(11) DEFAULT '0' COMMENT '步长',
`delta` int(11) NOT NULL DEFAULT '1' COMMENT '每次id增量',
`remainder` int(11) NOT NULL DEFAULT '0' COMMENT '余数',
`create_time` timestamp NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '更新时间',
`version` bigint(20) NOT NULL DEFAULT '0' COMMENT '版本号',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_biz_type` (`biz_type`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT 'id信息表';

CREATE TABLE `tiny_id_token` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增id',
`token` varchar(255) NOT NULL DEFAULT '' COMMENT 'token',
`biz_type` varchar(63) NOT NULL DEFAULT '' COMMENT '此token可访问的业务类型标识',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` timestamp NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT 'token信息表';

INSERT INTO `tiny_id_info` (`id`, `biz_type`, `begin_id`, `max_id`, `step`, `delta`, `remainder`, `create_time`, `update_time`, `version`)
VALUES
(1, 'test', 1, 1, 100000, 1, 0, '2018-07-21 23:52:58', '2018-07-22 23:19:27', 1);

INSERT INTO `tiny_id_info` (`id`, `biz_type`, `begin_id`, `max_id`, `step`, `delta`, `remainder`, `create_time`, `update_time`, `version`)
VALUES
(2, 'test_odd', 1, 1, 100000, 2, 1, '2018-07-21 23:52:58', '2018-07-23 00:39:24', 3);


INSERT INTO `tiny_id_token` (`id`, `token`, `biz_type`, `remark`, `create_time`, `update_time`)
VALUES
(1, '0f673adf80504e2eaa552f5d791b644c', 'test', '1', '2017-12-14 16:36:46', '2017-12-14 16:36:48');

INSERT INTO `tiny_id_token` (`id`, `token`, `biz_type`, `remark`, `create_time`, `update_time`)
VALUES
(2, '0f673adf80504e2eaa552f5d791b644c', 'test_odd', '1', '2017-12-14 16:36:46', '2017-12-14 16:36:48');

3、配置数据库:
datasource.tinyid.names=primary
datasource.tinyid.primary.driver-class-name=com.mysql.jdbc.Driver
datasource.tinyid.primary.url=jdbc:mysql://ip:port/databaseName?autoReconnect=true&useUnicode=true&characterEncoding=UTF-8
datasource.tinyid.primary.username=root
datasource.tinyid.primary.password=123456

4、启动tinyid-server后测试:
获取分布式自增ID: http://localhost:9999/tinyid/id/nextIdSimple?bizType=test&token=0f673adf80504e2eaa552f5d791b644c'
返回结果: 3

批量获取分布式自增ID:
http://localhost:9999/tinyid/id/nextIdSimple?bizType=test&token=0f673adf80504e2eaa552f5d791b644c&batchSize=10'
返回结果:  4,5,6,7,8,9,10,11,12,13


Java客户端方式接入

重复Http方式的2、3操作.

引入依赖:
<dependency>
        <groupId>com.xiaoju.uemc.tinyid</groupId>
        <artifactId>tinyid-client</artifactId>
        <version>${tinyid.version}</version>
    </dependency>

配置文件:
tinyid.server =localhost:9999
tinyid.token =0f673adf80504e2eaa552f5d791b644c

test 、tinyid.token是在数据库表中预先插入的数据,test 是具体业务类型,tinyid.token表示可访问的业务类型。
// 获取单个分布式自增ID
Long id =  TinyId . nextId( " test " );

// 按需批量分布式自增ID
List< Long > ids =  TinyId . nextId( " test " , 10 );

总结

本文只是简单介绍一下每种分布式ID生成器,旨在给大家一个详细学习的方向,每种生成方式都有它自己的优缺点,具体如何使用还要看具体的业务需求。

原文链接:https://mp.weixin.qq.com/s/0RBeWV-any_Rb9JbVPvcfw
继续阅读 »

为什么要用分布式ID?

在说分布式ID的具体实现之前,我们来简单分析一下为什么用分布式ID?分布式ID应该满足哪些特征?

什么是分布式ID?

拿MySQL数据库举个栗子:

在我们业务数据量不大的时候,单库单表完全可以支撑现有业务,数据再大一点搞个MySQL主从同步读写分离也能对付。

但随着数据日渐增长,主从同步也扛不住了,就需要对数据库进行分库分表,但分库分表后需要有一个唯一ID来标识一条数据,数据库的自增ID显然不能满足需求;特别一点的如订单、优惠券也都需要有唯一ID做标识。此时一个能够生成全局唯一ID的系统是非常必要的。那么这个全局唯一ID就叫分布式ID。

那么分布式ID需要满足那些条件?

  • 全局唯一:必须保证ID是全局性唯一的,基本要求
  • 高性能:高可用低延时,ID生成响应要块,否则反倒会成为业务瓶颈
  • 高可用:100%的可用性是骗人的,但是也要无限接近于100%的可用性
  • 好接入:要秉着拿来即用的设计原则,在系统设计和实现上要尽可能的简单
  • 趋势递增:最好趋势递增,这个要求就得看具体业务场景了,一般不严格要求


分布式ID都有哪些生成方式?

今天主要分析一下以下9种,分布式ID生成器方式以及优缺点:
  • UUID
  • 数据库自增ID
  • 数据库多主模式
  • 号段模式
  • Redis
  • 雪花算法(SnowFlake)
  • 滴滴出品(TinyID)
  • 百度 (Uidgenerator)
  • 美团(Leaf)


那么它们都是如何实现?以及各自有什么优缺点?我们往下看。
1.png

基于UUID

在Java的世界里,想要得到一个具有唯一性的ID,首先被想到可能就是UUID,毕竟它有着全球唯一的特性。那么UUID可以做分布式ID吗?答案是可以的,但是并不推荐!
public static void main(String[] args) { 
   String uuid = UUID.randomUUID().toString().replaceAll("-","");
   System.out.println(uuid);


UUID的生成简单到只有一行代码,输出结果 c2b8c2b9e46c47e3b30dca3b0d447718,但UUID却并不适用于实际的业务需求。像用作订单号UUID这样的字符串没有丝毫的意义,看不出和订单相关的有用信息;而对于数据库来说用作业务主键ID,它不仅是太长还是字符串,存储性能差查询也很耗时,所以不推荐用作分布式ID。

优点:
  • 生成足够简单,本地生成无网络消耗,具有唯一性


缺点:
  • 无序的字符串,不具备趋势自增特性
  • 没有具体的业务含义
  • 长度过长16字节128位,36位长度的字符串,存储以及查询对MySQL的性能消耗较大,MySQL官方明确建议主键要尽量越短越好,作为数据库主键 UUID 的无序性会导致数据位置频繁变动,严重影响性能。


基于数据库自增ID

基于数据库的auto_increment自增ID完全可以充当分布式ID,具体实现:需要一个单独的MySQL实例用来生成ID,建表结构如下:
CREATE DATABASE `SEQ_ID`;
CREATE TABLE SEQID.SEQUENCE_ID (
id bigint(20) unsigned NOT NULL auto_increment, 
value char(10) NOT NULL default '',
PRIMARY KEY (id),
) ENGINE=MyISAM;

insert into SEQUENCE_ID(value)  VALUES ('values');

当我们需要一个ID的时候,向表中插入一条记录返回主键ID,但这种方式有一个比较致命的缺点,访问量激增时MySQL本身就是系统的瓶颈,用它来实现分布式服务风险比较大,不推荐!

优点:
  • 实现简单,ID单调自增,数值类型查询速度快


缺点:
  • DB单点存在宕机风险,无法扛住高并发场景


基于数据库集群模式

前边说了单点数据库方式不可取,那对上边的方式做一些高可用优化,换成主从模式集群。害怕一个主节点挂掉没法用,那就做双主模式集群,也就是两个MySQL实例都能单独的生产自增ID。

那这样还会有个问题,两个MySQL实例的自增ID都从1开始,会生成重复的ID怎么办?

解决方案:设置起始值和自增步长。

MySQL_1 配置:
set @@auto_increment_offset = 1;     -- 起始值
set @@auto_increment_increment = 2;  -- 步长
复制代码MySQL_2 配置:
set @@auto_increment_offset = 2;     -- 起始值
set @@auto_increment_increment = 2;  -- 步长

这样两个MySQL实例的自增ID分别就是:


1、3、5、7、9
2、4、6、8、10
那如果集群后的性能还是扛不住高并发咋办?就要进行MySQL扩容增加节点,这是一个比较麻烦的事。
2.png

从上图可以看出,水平扩展的数据库集群,有利于解决数据库单点压力的问题,同时为了ID生成特性,将自增步长按照机器数量来设置。

增加第三台MySQL实例需要人工修改一、二两台MySQL实例的起始值和步长,把第三台机器的ID起始生成位置设定在比现有最大自增ID的位置远一些,但必须在一、二两台MySQL实例ID还没有增长到第三台MySQL实例的起始ID值的时候,否则自增ID就要出现重复了,必要时可能还需要停机修改。

优点:
  • 解决DB单点问题


缺点:
  • 不利于后续扩容,而且实际上单个数据库自身压力还是大,依旧无法满足高并发场景。


基于数据库的号段模式

号段模式是当下分布式ID生成器的主流实现方式之一,号段模式可以理解为从数据库批量的获取自增ID,每次从数据库取出一个号段范围,例如(1,1000]代表1000个ID,具体的业务服务将本号段,生成1~1000的自增ID并加载到内存。表结构如下:
CREATE TABLE id_generator (
id int(10) NOT NULL,
max_id bigint(20) NOT NULL COMMENT '当前最大id',
step int(20) NOT NULL COMMENT '号段的布长',
biz_type  int(20) NOT NULL COMMENT '业务类型',
version int(20) NOT NULL COMMENT '版本号',
PRIMARY KEY (`id`)



biz_type :代表不同业务类型

max_id :当前最大的可用id

step :代表号段的长度

version :是一个乐观锁,每次都更新version,保证并发时数据的正确性
3.png

等这批号段ID用完,再次向数据库申请新号段,对max_id字段做一次update操作,update max_id= max_id + step,update成功则说明新号段获取成功,新的号段范围是(max_id ,max_id +step]。
update id_generator set max_id = #{max_id+step}, version = version + 1 where version = # {version} and biz_type = XXX

由于多业务端可能同时操作,所以采用版本号version乐观锁方式更新,这种分布式ID生成方式不强依赖于数据库,不会频繁的访问数据库,对数据库的压力小很多。

基于Redis模式

Redis也同样可以实现,原理就是利用Redis的 incr命令实现ID的原子性自增。
127.0.0.1:6379> set seq_id 1     // 初始化自增ID为1
OK
127.0.0.1:6379> incr seq_id      // 增加1,并返回递增后的数值
(integer) 2

用Redis实现需要注意一点,要考虑到Redis持久化的问题。Redis有两种持久化方式RDB和AOF:
  • RDB会定时打一个快照进行持久化,假如连续自增但redis没及时持久化,而这会Redis挂掉了,重启Redis后会出现ID重复的情况。
  • AOF会对每条写命令进行持久化,即使Redis挂掉了也不会出现ID重复的情况,但由于incr命令的特殊性,会导致Redis重启恢复的数据时间过长。


基于雪花算法(Snowflake)模式

雪花算法(Snowflake)是twitter公司内部分布式项目采用的ID生成算法,开源后广受国内大厂的好评,在该算法影响下各大公司相继开发出各具特色的分布式生成器。
4.png

Snowflake生成的是Long类型的ID,一个Long类型占8个字节,每个字节占8比特,也就是说一个Long类型占64个比特。

Snowflake ID组成结构:正数位(占1比特)+ 时间戳(占41比特)+ 机器ID(占5比特)+ 数据中心(占5比特)+ 自增值(占12比特),总共64比特组成的一个Long类型。
  • 第一个bit位(1bit):Java中long的最高位是符号位代表正负,正数是0,负数是1,一般生成ID都为正数,所以默认为0。
  • 时间戳部分(41bit):毫秒级的时间,不建议存当前时间戳,而是用(当前时间戳 - 固定开始时间戳)的差值,可以使产生的ID从更小的值开始;41位的时间戳可以使用69年,(1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69年
  • 工作机器id(10bit):也被叫做workId,这个可以灵活配置,机房或者机器号组合都可以。
  • 序列号部分(12bit):自增值支持同一毫秒内同一个节点可以生成4096个ID


根据这个算法的逻辑,只需要将这个算法用Java语言实现出来,封装为一个工具方法,那么各个业务应用可以直接使用该工具方法来获取分布式ID,只需保证每个业务应用有自己的工作机器id即可,而不需要单独去搭建一个获取分布式ID的应用。

Java版本的Snowflake算法实现:
/**
* Twitter的SnowFlake算法,使用SnowFlake算法生成一个整数,然后转化为62进制变成一个短地址URL
*
* https://github.com/beyondfengyu/SnowFlake
*/
public class SnowFlakeShortUrl {

/**
 * 起始的时间戳
 */
private final static long START_TIMESTAMP = 1480166465631L;

/**
 * 每一部分占用的位数
 */
private final static long SEQUENCE_BIT = 12;   //序列号占用的位数
private final static long MACHINE_BIT = 5;     //机器标识占用的位数
private final static long DATA_CENTER_BIT = 5; //数据中心占用的位数

/**
 * 每一部分的最大值
 */
private final static long MAX_SEQUENCE = -1L ^ (-1L << SEQUENCE_BIT);
private final static long MAX_MACHINE_NUM = -1L ^ (-1L << MACHINE_BIT);
private final static long MAX_DATA_CENTER_NUM = -1L ^ (-1L << DATA_CENTER_BIT);

/**
 * 每一部分向左的位移
 */
private final static long MACHINE_LEFT = SEQUENCE_BIT;
private final static long DATA_CENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT;
private final static long TIMESTAMP_LEFT = DATA_CENTER_LEFT + DATA_CENTER_BIT;

private long dataCenterId;  //数据中心
private long machineId;     //机器标识
private long sequence = 0L; //序列号
private long lastTimeStamp = -1L;  //上一次时间戳

private long getNextMill() {
    long mill = getNewTimeStamp();
    while (mill <= lastTimeStamp) {
        mill = getNewTimeStamp();
    }
    return mill;
}

private long getNewTimeStamp() {
    return System.currentTimeMillis();
}

/**
 * 根据指定的数据中心ID和机器标志ID生成指定的序列号
 *
 * @param dataCenterId 数据中心ID
 * @param machineId    机器标志ID
 */
public SnowFlakeShortUrl(long dataCenterId, long machineId) {
    if (dataCenterId > MAX_DATA_CENTER_NUM || dataCenterId < 0) {
        throw new IllegalArgumentException("DtaCenterId can't be greater than MAX_DATA_CENTER_NUM or less than 0!");
    }
    if (machineId > MAX_MACHINE_NUM || machineId < 0) {
        throw new IllegalArgumentException("MachineId can't be greater than MAX_MACHINE_NUM or less than 0!");
    }
    this.dataCenterId = dataCenterId;
    this.machineId = machineId;
}

/**
 * 产生下一个ID
 *
 * @return
 */
public synchronized long nextId() {
    long currTimeStamp = getNewTimeStamp();
    if (currTimeStamp < lastTimeStamp) {
        throw new RuntimeException("Clock moved backwards.  Refusing to generate id");
    }

    if (currTimeStamp == lastTimeStamp) {
        //相同毫秒内,序列号自增
        sequence = (sequence + 1) & MAX_SEQUENCE;
        //同一毫秒的序列数已经达到最大
        if (sequence == 0L) {
            currTimeStamp = getNextMill();
        }
    } else {
        //不同毫秒内,序列号置为0
        sequence = 0L;
    }

    lastTimeStamp = currTimeStamp;

    return (currTimeStamp - START_TIMESTAMP) << TIMESTAMP_LEFT //时间戳部分
            | dataCenterId << DATA_CENTER_LEFT       //数据中心部分
            | machineId << MACHINE_LEFT             //机器标识部分
            | sequence;                             //序列号部分
}

public static void main(String[] args) {
    SnowFlakeShortUrl snowFlake = new SnowFlakeShortUrl(2, 3);

    for (int i = 0; i < (1 << 4); i++) {
        //10进制
        System.out.println(snowFlake.nextId());
    }
}


百度(uid-generator)

uid-generator是由百度技术部开发,项目GitHub地址:https://github.com/baidu/uid-generator

uid-generator是基于Snowflake算法实现的,与原始的Snowflake算法不同在于,uid-generator支持自定义时间戳、工作机器ID和序列号等各部分的位数,而且uid-generator中采用用户自定义workId的生成策略。

uid-generator需要与数据库配合使用,需要新增一个WORKER_NODE表。当应用启动时会向数据库表中去插入一条数据,插入成功后返回的自增ID就是该机器的workId数据由host,port组成。

对于uid-generator ID组成结构:

workId,占用了22个bit位,时间占用了28个bit位,序列化占用了13个bit位,需要注意的是,和原始的snowflake不太一样,时间的单位是秒,而不是毫秒,workId也不一样,而且同一应用每次重启就会消费一个workId。

美团(Leaf)

Leaf由美团开发,GitHub地址:https://github.com/Meituan-Dianping/Leaf

Leaf同时支持号段模式和snowflake算法模式,可以切换使用。

号段模式

先导入源码https://github.com/Meituan-Dianping/Leaf,在建一张表leaf_alloc:
DROP TABLE IF EXISTS `leaf_alloc`;

CREATE TABLE `leaf_alloc` (
`biz_tag` varchar(128)  NOT NULL DEFAULT '' COMMENT '业务key',
`max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '当前已经分配了的最大id',
`step` int(11) NOT NULL COMMENT '初始步长,也是动态调整的最小步长',
`description` varchar(256)  DEFAULT NULL COMMENT '业务key的描述',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '数据库维护的更新时间',
PRIMARY KEY (`biz_tag`)
) ENGINE=InnoDB;

然后在项目中开启号段模式,配置对应的数据库信息,并关闭Snowflake模式:
leaf.name=com.sankuai.leaf.opensource.test
leaf.segment.enable=true
leaf.jdbc.url=jdbc:mysql://localhost:3306/leaf_test?useUnicode=true&characterEncoding=utf8&characterSetResults=utf8
leaf.jdbc.username=root
leaf.jdbc.password=root

leaf.snowflake.enable=false
#leaf.snowflake.zk.address=
#leaf.snowflake.port=

启动leaf-server模块的LeafServerApplication项目就跑起来了。

号段模式获取分布式自增ID的测试url :http://localhost:8080/api/segment/get/leaf-segment-test。

监控号段模式:http://localhost:8080/cache

Snowflake模式

Leaf的Snowflake模式依赖于ZooKeeper,不同于原始Snowflake算法也主要是在workId的生成上,Leaf中workId是基于ZooKeeper的顺序Id来生成的,每个应用在使用Leaf-snowflake时,启动时都会都在Zookeeper中生成一个顺序Id,相当于一台机器对应一个顺序节点,也就是一个workId。
leaf.snowflake.enable=true
leaf.snowflake.zk.address=127.0.0.1
leaf.snowflake.port=2181

snowflake模式获取分布式自增ID的测试url:http://localhost:8080/api/snowflake/get/test

滴滴(Tinyid)

Tinyid由滴滴开发,GitHub地址:https://github.com/didi/tinyid%E3%80%82

Tinyid是基于号段模式原理实现的与Leaf如出一辙,每个服务获取一个号段(1000,2000]、(2000,3000]、(3000,4000]。
5.png

Tinyid提供http和tinyid-client两种方式接入:

Http方式接入

1、导入Tinyid源码:https://github.com/didi/tinyid.git

2、创建数据表:
CREATE TABLE `tiny_id_info` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`biz_type` varchar(63) NOT NULL DEFAULT '' COMMENT '业务类型,唯一',
`begin_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '开始id,仅记录初始值,无其他含义。初始化时begin_id和max_id应相同',
`max_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '当前最大id',
`step` int(11) DEFAULT '0' COMMENT '步长',
`delta` int(11) NOT NULL DEFAULT '1' COMMENT '每次id增量',
`remainder` int(11) NOT NULL DEFAULT '0' COMMENT '余数',
`create_time` timestamp NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '更新时间',
`version` bigint(20) NOT NULL DEFAULT '0' COMMENT '版本号',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_biz_type` (`biz_type`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT 'id信息表';

CREATE TABLE `tiny_id_token` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增id',
`token` varchar(255) NOT NULL DEFAULT '' COMMENT 'token',
`biz_type` varchar(63) NOT NULL DEFAULT '' COMMENT '此token可访问的业务类型标识',
`remark` varchar(255) NOT NULL DEFAULT '' COMMENT '备注',
`create_time` timestamp NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '创建时间',
`update_time` timestamp NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT 'token信息表';

INSERT INTO `tiny_id_info` (`id`, `biz_type`, `begin_id`, `max_id`, `step`, `delta`, `remainder`, `create_time`, `update_time`, `version`)
VALUES
(1, 'test', 1, 1, 100000, 1, 0, '2018-07-21 23:52:58', '2018-07-22 23:19:27', 1);

INSERT INTO `tiny_id_info` (`id`, `biz_type`, `begin_id`, `max_id`, `step`, `delta`, `remainder`, `create_time`, `update_time`, `version`)
VALUES
(2, 'test_odd', 1, 1, 100000, 2, 1, '2018-07-21 23:52:58', '2018-07-23 00:39:24', 3);


INSERT INTO `tiny_id_token` (`id`, `token`, `biz_type`, `remark`, `create_time`, `update_time`)
VALUES
(1, '0f673adf80504e2eaa552f5d791b644c', 'test', '1', '2017-12-14 16:36:46', '2017-12-14 16:36:48');

INSERT INTO `tiny_id_token` (`id`, `token`, `biz_type`, `remark`, `create_time`, `update_time`)
VALUES
(2, '0f673adf80504e2eaa552f5d791b644c', 'test_odd', '1', '2017-12-14 16:36:46', '2017-12-14 16:36:48');

3、配置数据库:
datasource.tinyid.names=primary
datasource.tinyid.primary.driver-class-name=com.mysql.jdbc.Driver
datasource.tinyid.primary.url=jdbc:mysql://ip:port/databaseName?autoReconnect=true&useUnicode=true&characterEncoding=UTF-8
datasource.tinyid.primary.username=root
datasource.tinyid.primary.password=123456

4、启动tinyid-server后测试:
获取分布式自增ID: http://localhost:9999/tinyid/id/nextIdSimple?bizType=test&token=0f673adf80504e2eaa552f5d791b644c'
返回结果: 3

批量获取分布式自增ID:
http://localhost:9999/tinyid/id/nextIdSimple?bizType=test&token=0f673adf80504e2eaa552f5d791b644c&batchSize=10'
返回结果:  4,5,6,7,8,9,10,11,12,13


Java客户端方式接入

重复Http方式的2、3操作.

引入依赖:
<dependency>
        <groupId>com.xiaoju.uemc.tinyid</groupId>
        <artifactId>tinyid-client</artifactId>
        <version>${tinyid.version}</version>
    </dependency>

配置文件:
tinyid.server =localhost:9999
tinyid.token =0f673adf80504e2eaa552f5d791b644c

test 、tinyid.token是在数据库表中预先插入的数据,test 是具体业务类型,tinyid.token表示可访问的业务类型。
// 获取单个分布式自增ID
Long id =  TinyId . nextId( " test " );

// 按需批量分布式自增ID
List< Long > ids =  TinyId . nextId( " test " , 10 );

总结

本文只是简单介绍一下每种分布式ID生成器,旨在给大家一个详细学习的方向,每种生成方式都有它自己的优缺点,具体如何使用还要看具体的业务需求。

原文链接:https://mp.weixin.qq.com/s/0RBeWV-any_Rb9JbVPvcfw 收起阅读 »

Angular 实践:如何优雅地发起和处理请求


Tips: 本文实现重度依赖 ObservableInput,灵感来自灵雀云同事实现的 asyncData 指令,但之前没有 ObservableInput 的装饰器,处理响应 Input 变更相对麻烦一些,所以这里使用 ObservableInput 重新实现。

What And Why

pic1.jpg


大部分情况下处理请求有如下几个过程:

看着很复杂的样子,既要 Loading,又要 Reload,还要 Retry,如果用命令式写法可能会很蛋疼,要处理各种分支,而今天要讲的 rxAsync 指令就是用来优雅地解决这个问题的。

How

我们来思考下如果解决这个问题,至少有如下四个点需要考虑。

1.发起请求有如下三种情况:
第一次渲染主动加载
用户点击重新加载
加载出错自动重试

2.渲染的过程中需要根据请求的三种状态 —— loading, success, error (类似 Promise 的 pending, resolved, rejected) —— 动态渲染不同的内容

3.输入的参数发生变化时我们需要根据最新参数重新发起请求,但是当用户输入的重试次数变化时应该忽略,因为重试次数只影响 Error 状态

4.用户点击重新加载可能在我们的指令内部,也可能在指令外部

Show Me the Code

话不多说,上代码:

@Directive({
selector: '[rxAsync]',
})
export class AsyncDirective<T, P, E = HttpErrorResponse>
implements OnInit, OnDestroy {
@ObservableInput()
@Input('rxAsyncContext')
private context$!: Observable<any> // 自定义 fetcher 调用时的 this 上下文,还可以通过箭头函数、fetcher.bind(this) 等方式解决

@ObservableInput()
@Input('rxAsyncFetcher')
private fetcher$!: Observable<Callback<[P], Observable<T>>> // 自动发起请求的回调函数,参数是下面的 params,应该返回 Observable

@ObservableInput()
@Input('rxAsyncParams')
private params$!: Observable<P> // fetcher 调用时传入的参数

@Input('rxAsyncRefetch')
private refetch$$ = new Subject<void>() // 支持用户在指令外部重新发起请求,用户可能不需要,所以设置一个默认值

@ObservableInput()
@Input('rxAsyncRetryTimes')
private retryTimes$!: Observable<number> // 发送 Error 时自动重试的次数,默认不重试

private destroy$$ = new Subject<void>()
private reload$$ = new Subject<void>()

private context = {
reload: this.reload.bind(this), // 将 reload 绑定到 template 上下文中,方便用户在指令内重新发起请求
} as IAsyncDirectiveContext<T, E>

private viewRef: Nullable<ViewRef>
private sub: Nullable<Subscription>

constructor(
private templateRef: TemplateRef<any>,
private viewContainerRef: ViewContainerRef,
) {}

reload() {
this.reload$$.next()
}

ngOnInit() {
// 得益于 ObservableInput ,我们可以一次性响应所有参数的变化
combineLatest([
  this.context$,
  this.fetcher$,
  this.params$,
  this.refetch$$.pipe(startWith(null)), // 需要 startWith(null) 触发第一次请求
  this.reload$$.pipe(startWith(null)), // 同上
])
  .pipe(
    takeUntil(this.destroy$$),
    withLatestFrom(this.retryTimes$), // 忽略 retryTimes 的变更,我们只需要取得它的最新值即可
  )
  .subscribe(([[context, fetcher, params], retryTimes]) => {
    // 如果参数变化且上次请求还没有完成时,自动取消请求忽略掉
    this.disposeSub()

    // 每次发起请求前都重置 loading 和 error 的状态
    Object.assign(this.context, {
      loading: true,
      error: null,
    })

    this.sub = fetcher
      .call(context, params)
      .pipe(
        retry(retryTimes), // 错误时重试
        finalize(() => {
          // 无论是成功还是失败,都取消 loading,并重新触发渲染
          this.context.loading = false
          if (this.viewRef) {
            this.viewRef.detectChanges()
          }
        }),
      )
      .subscribe(
        data => (this.context.$implicit = data),
        error => (this.context.error = error),
      )

    if (this.viewRef) {
      return this.viewRef.markForCheck()
    }

    this.viewRef = this.viewContainerRef.createEmbeddedView(
      this.templateRef,
      this.context,
    )
  })
}

ngOnDestroy() {
this.disposeSub()

this.destroy$$.next()
this.destroy$$.complete()

if (this.viewRef) {
  this.viewRef.destroy()
  this.viewRef = null
}
}

disposeSub() {
if (this.sub) {
  this.sub.unsubscribe()
  this.sub = null
}
}
}

Usage

总共 100 多行的源码,说是很优雅,那到底使用的时候优不优雅呢?来个实例看看:

@Component({
selector: 'rx-async-directive-demo',
template: `
<button (click)="refetch$$.next()">Refetch (Outside rxAsync)</button>
<div
  *rxAsync="
    let todo;
    let loading = loading;
    let error = error;
    let reload = reload;
    context: context;
    fetcher: fetchTodo;
    params: todoId;
    refetch: refetch$$;
    retryTimes: retryTimes
  "
>
  <button (click)="reload()">Reload</button>
  loading: {{ loading }} error: {{ error | json }}
  <br />
  todo: {{ todo | json }}
</div>
`,
preserveWhitespaces: false,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class AsyncDirectiveComponent {
context = this

@Input()
todoId = 1

@Input()
retryTimes = 0

refetch$$ = new Subject<void>()

constructor(private http: HttpClient) {}

fetchTodo(todoId: string) {
return typeof todoId === 'number'
  ? this.http.get('//jsonplaceholder.typicode.com/todos/' + todoId)
  : EMPTY
}
}
继续阅读 »

Tips: 本文实现重度依赖 ObservableInput,灵感来自灵雀云同事实现的 asyncData 指令,但之前没有 ObservableInput 的装饰器,处理响应 Input 变更相对麻烦一些,所以这里使用 ObservableInput 重新实现。

What And Why

pic1.jpg


大部分情况下处理请求有如下几个过程:

看着很复杂的样子,既要 Loading,又要 Reload,还要 Retry,如果用命令式写法可能会很蛋疼,要处理各种分支,而今天要讲的 rxAsync 指令就是用来优雅地解决这个问题的。

How

我们来思考下如果解决这个问题,至少有如下四个点需要考虑。

1.发起请求有如下三种情况:
第一次渲染主动加载
用户点击重新加载
加载出错自动重试

2.渲染的过程中需要根据请求的三种状态 —— loading, success, error (类似 Promise 的 pending, resolved, rejected) —— 动态渲染不同的内容

3.输入的参数发生变化时我们需要根据最新参数重新发起请求,但是当用户输入的重试次数变化时应该忽略,因为重试次数只影响 Error 状态

4.用户点击重新加载可能在我们的指令内部,也可能在指令外部

Show Me the Code

话不多说,上代码:

@Directive({
selector: '[rxAsync]',
})
export class AsyncDirective<T, P, E = HttpErrorResponse>
implements OnInit, OnDestroy {
@ObservableInput()
@Input('rxAsyncContext')
private context$!: Observable<any> // 自定义 fetcher 调用时的 this 上下文,还可以通过箭头函数、fetcher.bind(this) 等方式解决

@ObservableInput()
@Input('rxAsyncFetcher')
private fetcher$!: Observable<Callback<[P], Observable<T>>> // 自动发起请求的回调函数,参数是下面的 params,应该返回 Observable

@ObservableInput()
@Input('rxAsyncParams')
private params$!: Observable<P> // fetcher 调用时传入的参数

@Input('rxAsyncRefetch')
private refetch$$ = new Subject<void>() // 支持用户在指令外部重新发起请求,用户可能不需要,所以设置一个默认值

@ObservableInput()
@Input('rxAsyncRetryTimes')
private retryTimes$!: Observable<number> // 发送 Error 时自动重试的次数,默认不重试

private destroy$$ = new Subject<void>()
private reload$$ = new Subject<void>()

private context = {
reload: this.reload.bind(this), // 将 reload 绑定到 template 上下文中,方便用户在指令内重新发起请求
} as IAsyncDirectiveContext<T, E>

private viewRef: Nullable<ViewRef>
private sub: Nullable<Subscription>

constructor(
private templateRef: TemplateRef<any>,
private viewContainerRef: ViewContainerRef,
) {}

reload() {
this.reload$$.next()
}

ngOnInit() {
// 得益于 ObservableInput ,我们可以一次性响应所有参数的变化
combineLatest([
  this.context$,
  this.fetcher$,
  this.params$,
  this.refetch$$.pipe(startWith(null)), // 需要 startWith(null) 触发第一次请求
  this.reload$$.pipe(startWith(null)), // 同上
])
  .pipe(
    takeUntil(this.destroy$$),
    withLatestFrom(this.retryTimes$), // 忽略 retryTimes 的变更,我们只需要取得它的最新值即可
  )
  .subscribe(([[context, fetcher, params], retryTimes]) => {
    // 如果参数变化且上次请求还没有完成时,自动取消请求忽略掉
    this.disposeSub()

    // 每次发起请求前都重置 loading 和 error 的状态
    Object.assign(this.context, {
      loading: true,
      error: null,
    })

    this.sub = fetcher
      .call(context, params)
      .pipe(
        retry(retryTimes), // 错误时重试
        finalize(() => {
          // 无论是成功还是失败,都取消 loading,并重新触发渲染
          this.context.loading = false
          if (this.viewRef) {
            this.viewRef.detectChanges()
          }
        }),
      )
      .subscribe(
        data => (this.context.$implicit = data),
        error => (this.context.error = error),
      )

    if (this.viewRef) {
      return this.viewRef.markForCheck()
    }

    this.viewRef = this.viewContainerRef.createEmbeddedView(
      this.templateRef,
      this.context,
    )
  })
}

ngOnDestroy() {
this.disposeSub()

this.destroy$$.next()
this.destroy$$.complete()

if (this.viewRef) {
  this.viewRef.destroy()
  this.viewRef = null
}
}

disposeSub() {
if (this.sub) {
  this.sub.unsubscribe()
  this.sub = null
}
}
}

Usage

总共 100 多行的源码,说是很优雅,那到底使用的时候优不优雅呢?来个实例看看:

@Component({
selector: 'rx-async-directive-demo',
template: `
<button (click)="refetch$$.next()">Refetch (Outside rxAsync)</button>
<div
  *rxAsync="
    let todo;
    let loading = loading;
    let error = error;
    let reload = reload;
    context: context;
    fetcher: fetchTodo;
    params: todoId;
    refetch: refetch$$;
    retryTimes: retryTimes
  "
>
  <button (click)="reload()">Reload</button>
  loading: {{ loading }} error: {{ error | json }}
  <br />
  todo: {{ todo | json }}
</div>
`,
preserveWhitespaces: false,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class AsyncDirectiveComponent {
context = this

@Input()
todoId = 1

@Input()
retryTimes = 0

refetch$$ = new Subject<void>()

constructor(private http: HttpClient) {}

fetchTodo(todoId: string) {
return typeof todoId === 'number'
  ? this.http.get('//jsonplaceholder.typicode.com/todos/' + todoId)
  : EMPTY
}
} 收起阅读 »

Kubernetes中优化流量和安全性需要注意的7点要求


根据Portworx 在2018年进行的一项调查,80%的企业现在正在使用容器,其中83%的企业正在生产环境中使用。而这个数字在2017年只有67%,很明显,容器不仅仅是一种时尚。

但是,随着容器的流行,一些公司开始在Kubernetes内建立有效的流量控制和安全策略。

作为容器调度和集群管理平台,Kubernetes致力于提供出色的基础架构,因此被无数公司采用。它刚刚开源五周年,最近在福布斯发表的一篇名为《Kubernetes “the most popular open source project of our times”》的文章表示,Kubernetes已被Capital One,ING Group,Philips,VMware和华为等公司使用。

对于使用微服务架构(MSA)开发来应用程序的公司来说,Kubernetes具有许多优势,特别是在应用程序部署方面。

出于上面这些原因,研发团队有必要了解Kubernetes独有的流量和安全情况。在本文中,我们将介绍:
  • Kubernetes是什么。
  • Kubernetes面临的挑战。
  • Kubernetes中的七个最重要的流量和安全要求。
  • 关于开发和操作简便性的注意事项。


让我们开始吧。

Kubernetes 是什么

Kubernetes是一个开源的容器编排系统。根据Kubernetes’ own definition,它是一个可移植且可扩展的程序,用于管理容器化的工作负载和服务,并提供以容器为中心的管理环境。

下图描述了Kubernetes的基本工作方式。图中可以看到一个主节点和两个工作节点。主节点用来告诉工作程序节点需要做什么工作,而工作程序节点则执行主节点提供给它们的指令。同时可以添加其他Kubernetes工作节点以扩展基础架构。
1.png

如果仔细观察,你会发现在每个部分中都出现了“Docker”一词。Docker 是一个容器平台,非常适合在单个物理机或虚拟机(VM)上运行容器。

但是,如果你要在多个不同的应用程序中使用数百个容器,且不希望将它们全部放在一台计算机上。这就是催生Kubernetes的挑战之一。

使用overlay网络(如上图中的红色条所示),主节点中的容器不必知道它需要与之通信的容器位于哪个节点,就可以直接与之通信。

Kubernetes的另一个主要功能是将信息打包到“Pod”中,如果应用程序由多个容器组成,则可以将这些容器组成一个Pod ,并共享整个生命周期。

Kubernetes 面临的挑战

像所有其他容器编排系统一样,Kubernetes也面临的诸多挑战,其中包括:
  • 内部和外部网络是隔离的。
  • 容器和容器的IP地址会发生变化。
  • 微服务之间没有访问控制。
  • 没有应用程序层的可见性。


让我们更深入地探讨这些挑战。Kubernetes的网络不是常规的网络,因为尽管使用了overlay网络,但内部和外部网络却是彼此不通的。

另外,Kubernetes会隔离发生故障的节点或Pod,以防止它们关闭整个应用程序。这可能导致节点之间的IP地址频繁更改。想要发现容器或容器的IP地址的服务就必须弄清楚新的IP地址是什么。

当涉及微服务之间的访问控制时,对于企业而言,重要的是要认识到Kubernetes节点之间的流量也能够流入外部物理设备或VM。这可能会消耗资源并削弱安全性。

最后,无法在应用程序层检查信息是一个大问题。没有这种可见性,企业可能会错过收集详细分析信息的关键机会。

Kubernetes 和云安全要求

到目前为止,我们已经讨论了Kubernetes的基本功能以及它所带来的挑战。现在,基于A10 Networks 15年的经验,我们将继续讨论Kubernetes和云安全性的要求。

我们将讨论如下七点要求:
  1. 高级应用程序交付控制器(ADC)
  2. 使负载均衡器(LB)配置与基础架构保持同步
  3. 南北向流量的安全
  4. 为大规模部署准备的中央控制器
  5. 微服务之间的访问控制
  6. 东西向流量加密
  7. 应用流量分析


1. 高级应用程序交付控制器(ADC)

2.jpg

虽然企业可能已经在其基础架构的其他区域使用了高级应用程序交付控制器,但也有必要为Kubernetes部署一个。默认情况下,这将允许管理员操作在Kubernetes前的高级负载均衡器。

Kubernetes已经配备了名为 kube-proxy 的网络代理。它提供了简单的用法:通过在三层中调整iptables规则来工作。但这是非常基本的,并与大多数企业操作习惯的有所不同。

许多人会将ADC或负载均衡器放在他们的Kubernetes前。这样就可以创建一个静态的虚拟IP,所有人都可以使用它,并动态配置所有内容。

随着Pod和容器的启动,可以动态配置ADC,以提供对新应用程序的访问,同时实现网络安全策略,并在某些情况下实施业务数据规则。通常,这是通过使用 “Ingress controller” 来实现的,其可以监控到新的容器和容器的启动,并且可以配置ADC以提供对新应用程序的访问权限,或者将更改通知给另一个 “Kubernetes controller” 节点。

2. 使负载均衡器(LB)配置与基础架构保持同步

3.jpg

由于在Kubernetes中一切都是可以不断变化的,因此位于集群前的负载均衡器是无法追踪所有事情的。除非您有类似上图紫色框所示的东西。

该紫色框为Ingress Controller,当容器启动或停止时,会在Kubernetes中创建一个事件。然后,Ingress Controller会识别该事件并做出相应的响应。

如上图所示,Ingress Controlle识别到容器已启动,并将其放入负载均衡池。这样,应用程序控制器(无论是在云之上还是内部)都可以保持最新状态。

这减轻了管理员的负担,并且比手动管理效率更高。

3. 南北向流量的安全

4.jpg

南北和东西方都是用来描述流量流向的通用术语。南北流量是指流量流入和流出Kubernetes。

如前所述,企业需要在Kubernetes前放置一些设备来监视流量。例如,防火墙,DDoS防护或任何其他可捕获恶意流量的设备。

这些设备在流量管理方面也很有用。因此,如果流量需要流向特定的区域,这是理想的选择。Ingress Controller在这方面也可以提供很多帮助。

如果企业可以通过统一的解决方案使这种功能自动化,那么他们可以得到:
  • 更简化操作
  • 更好的应用程序性能
  • 可在不中断前端的情况下进行后端更改
  • 自动化的安全策略


4. 为大规模部署准备的中央控制器

5.jpg

企业还需要考虑到横向扩展,特别是在安全性方面。

如上图所示,Ingress Controller(由紫色框表示)仍然存在,但是这次它正在处理来自多个Kubernetes节点的请求,并且正在观测整个Kubernetes集群。

Ingress Controller前方的蓝色圆圈是A10 Networks Harmony Controller。这种控制器可以实现高效的负载分配,并且可以将信息快速发送到适当的位置。

使用这样的中央控制器,必须选择一种在现有解决方案上进行少量额外配置,就可进行扩容和缩容的解决方案。

5. 微服务之间的访问控制

6.jpg

与流入和流出Kubernetes的南北流量相反,东西向流量在Kubernetes节点之间流动。在上图中,可以看到东西向流量是如何运作的。

当流量在Kubernetes节点之间流动时,可以通过物理网络,虚拟网络或overlay网络来发送该流量。如果不通过某种方式来监控那些东西向的流量,那么对流量如何从一个Pod或容器流向另一个Pod或容器的了解就变得非常困难。

另外,它还可能带来严重的安全风险:获得对一个容器的访问权限的攻击者可以访问整个内部网络

幸运的是,企业可以通过“服务网格”(例如A10 Secure Service Mesh)来解决这个问题。通过充当容器之间的代理以实现安全规则,这可以确保东西向的流量安全,并且还可以帮助扩展,负载均衡,服务监视等。

此外,服务网格可以在Kubernetes内部运行,而无需将流量发送到物理设备或VM。使用服务网格,东西向的流量状况如下所示:
7.jpg

通过这种解决方案,像金融机构这样的企业可以轻松地将信息保留在应有的位置,而不用担心影响安全性。

6. 东西向流量加密

8.jpg

如果没有适当的加密,未加密的信息可能会从一个物理Kubernetes节点流到另一个。这是一个严重的问题,特别是对于需要处理特别敏感信息的金融机构和其他企业。

这就是为什么对于企业而言,在评估云安全产品时,重要的是选择一种可以在离开节点时对流量进行加密,并在进入节点时对其进行解密的方法。

供应商可以通过两种方式提供这种类型的保护:
9.jpg

第一个选择是Sidecar代理部署,这种方法也是最受欢迎的。

通过这样的部署,管理员可以告诉Kubernetes,每当启动特定Pod时,应在该Pod中启动一个或多个其他容器。

通常,其他容器是某种类型的代理,可以管理从Pod流入和流出的流量。

从上图可以看出,Sidecar代理部署的不利之处在于,每个Pod都需要启动一个Sidecar,因此将占用一定数量的资源。

另一方面,企业也可以选择中心辐射代理部署。在这种类型的部署中,一个代理会处理从每个Kubernetes节点流出的流量。这样只需要较少的资源。

7. 应用流量分析

10.jpg

最后一点是,企业了解应用程序层流量的详细信息至关重要。

有了可同时监控南北和东西向流量的控制器,就已经有了两个理想的点来收集流量信息。

这样做既可以帮助优化应用程序,又可以提高安全性,还可以拓展多种不同的功能。从最简单到最高级的顺序排列,这些功能可以实现:
  • 通过描述性分析进行性能监控。大多数供应商都提供此功能。
  • 通过诊断分析更快地进行故障排除。少数供应商提供此功能。
  • 通过机器学习系统生成的预测分析获得建议。更少的供应商提供此功能。
  • 通过真实直观的AI生成的规范分析进行自适应控制。只有最好,最先进的供应商才能提供此功能。


因此,当企业与供应商交流时,至关重要的是确定他们的产品可以提供哪些功能。

使用A10 Networks的类似产品,可以查看大图分析以及相关的单个数据包,日志条目或问题。具有这种粒度的产品是企业应寻求的产品。

关于开发和操作简便性的注意事项

最后,让我们看一下企业在Kubernetes中的流量和安全性方面应该追寻的东西。考虑这些因素还可以为开发和运维团队大大简化工作:
  • 具有统一解决方案的简单体系结构。
  • 集中管理和控制,便于进行分析和故障排除。
  • 使用常见的配置格式,例如YAML和JSON。
  • 无需更改应用程序代码或配置即可实现安全性和收集分析信息。
  • 自动化应用安全策略。


如果公司优先考虑以上这些,则企业可以在使用 Kubernetes 时享受简单、自动化和安全的流量。您的基础设施、架构和运维团队都会对此感到满意。

原文链接:7 Requirements for Optimized Traffic Flow and Security in Kubernetes(翻译:郭旭东)
继续阅读 »

根据Portworx 在2018年进行的一项调查,80%的企业现在正在使用容器,其中83%的企业正在生产环境中使用。而这个数字在2017年只有67%,很明显,容器不仅仅是一种时尚。

但是,随着容器的流行,一些公司开始在Kubernetes内建立有效的流量控制和安全策略。

作为容器调度和集群管理平台,Kubernetes致力于提供出色的基础架构,因此被无数公司采用。它刚刚开源五周年,最近在福布斯发表的一篇名为《Kubernetes “the most popular open source project of our times”》的文章表示,Kubernetes已被Capital One,ING Group,Philips,VMware和华为等公司使用。

对于使用微服务架构(MSA)开发来应用程序的公司来说,Kubernetes具有许多优势,特别是在应用程序部署方面。

出于上面这些原因,研发团队有必要了解Kubernetes独有的流量和安全情况。在本文中,我们将介绍:
  • Kubernetes是什么。
  • Kubernetes面临的挑战。
  • Kubernetes中的七个最重要的流量和安全要求。
  • 关于开发和操作简便性的注意事项。


让我们开始吧。

Kubernetes 是什么

Kubernetes是一个开源的容器编排系统。根据Kubernetes’ own definition,它是一个可移植且可扩展的程序,用于管理容器化的工作负载和服务,并提供以容器为中心的管理环境。

下图描述了Kubernetes的基本工作方式。图中可以看到一个主节点和两个工作节点。主节点用来告诉工作程序节点需要做什么工作,而工作程序节点则执行主节点提供给它们的指令。同时可以添加其他Kubernetes工作节点以扩展基础架构。
1.png

如果仔细观察,你会发现在每个部分中都出现了“Docker”一词。Docker 是一个容器平台,非常适合在单个物理机或虚拟机(VM)上运行容器。

但是,如果你要在多个不同的应用程序中使用数百个容器,且不希望将它们全部放在一台计算机上。这就是催生Kubernetes的挑战之一。

使用overlay网络(如上图中的红色条所示),主节点中的容器不必知道它需要与之通信的容器位于哪个节点,就可以直接与之通信。

Kubernetes的另一个主要功能是将信息打包到“Pod”中,如果应用程序由多个容器组成,则可以将这些容器组成一个Pod ,并共享整个生命周期。

Kubernetes 面临的挑战

像所有其他容器编排系统一样,Kubernetes也面临的诸多挑战,其中包括:
  • 内部和外部网络是隔离的。
  • 容器和容器的IP地址会发生变化。
  • 微服务之间没有访问控制。
  • 没有应用程序层的可见性。


让我们更深入地探讨这些挑战。Kubernetes的网络不是常规的网络,因为尽管使用了overlay网络,但内部和外部网络却是彼此不通的。

另外,Kubernetes会隔离发生故障的节点或Pod,以防止它们关闭整个应用程序。这可能导致节点之间的IP地址频繁更改。想要发现容器或容器的IP地址的服务就必须弄清楚新的IP地址是什么。

当涉及微服务之间的访问控制时,对于企业而言,重要的是要认识到Kubernetes节点之间的流量也能够流入外部物理设备或VM。这可能会消耗资源并削弱安全性。

最后,无法在应用程序层检查信息是一个大问题。没有这种可见性,企业可能会错过收集详细分析信息的关键机会。

Kubernetes 和云安全要求

到目前为止,我们已经讨论了Kubernetes的基本功能以及它所带来的挑战。现在,基于A10 Networks 15年的经验,我们将继续讨论Kubernetes和云安全性的要求。

我们将讨论如下七点要求:
  1. 高级应用程序交付控制器(ADC)
  2. 使负载均衡器(LB)配置与基础架构保持同步
  3. 南北向流量的安全
  4. 为大规模部署准备的中央控制器
  5. 微服务之间的访问控制
  6. 东西向流量加密
  7. 应用流量分析


1. 高级应用程序交付控制器(ADC)

2.jpg

虽然企业可能已经在其基础架构的其他区域使用了高级应用程序交付控制器,但也有必要为Kubernetes部署一个。默认情况下,这将允许管理员操作在Kubernetes前的高级负载均衡器。

Kubernetes已经配备了名为 kube-proxy 的网络代理。它提供了简单的用法:通过在三层中调整iptables规则来工作。但这是非常基本的,并与大多数企业操作习惯的有所不同。

许多人会将ADC或负载均衡器放在他们的Kubernetes前。这样就可以创建一个静态的虚拟IP,所有人都可以使用它,并动态配置所有内容。

随着Pod和容器的启动,可以动态配置ADC,以提供对新应用程序的访问,同时实现网络安全策略,并在某些情况下实施业务数据规则。通常,这是通过使用 “Ingress controller” 来实现的,其可以监控到新的容器和容器的启动,并且可以配置ADC以提供对新应用程序的访问权限,或者将更改通知给另一个 “Kubernetes controller” 节点。

2. 使负载均衡器(LB)配置与基础架构保持同步

3.jpg

由于在Kubernetes中一切都是可以不断变化的,因此位于集群前的负载均衡器是无法追踪所有事情的。除非您有类似上图紫色框所示的东西。

该紫色框为Ingress Controller,当容器启动或停止时,会在Kubernetes中创建一个事件。然后,Ingress Controller会识别该事件并做出相应的响应。

如上图所示,Ingress Controlle识别到容器已启动,并将其放入负载均衡池。这样,应用程序控制器(无论是在云之上还是内部)都可以保持最新状态。

这减轻了管理员的负担,并且比手动管理效率更高。

3. 南北向流量的安全

4.jpg

南北和东西方都是用来描述流量流向的通用术语。南北流量是指流量流入和流出Kubernetes。

如前所述,企业需要在Kubernetes前放置一些设备来监视流量。例如,防火墙,DDoS防护或任何其他可捕获恶意流量的设备。

这些设备在流量管理方面也很有用。因此,如果流量需要流向特定的区域,这是理想的选择。Ingress Controller在这方面也可以提供很多帮助。

如果企业可以通过统一的解决方案使这种功能自动化,那么他们可以得到:
  • 更简化操作
  • 更好的应用程序性能
  • 可在不中断前端的情况下进行后端更改
  • 自动化的安全策略


4. 为大规模部署准备的中央控制器

5.jpg

企业还需要考虑到横向扩展,特别是在安全性方面。

如上图所示,Ingress Controller(由紫色框表示)仍然存在,但是这次它正在处理来自多个Kubernetes节点的请求,并且正在观测整个Kubernetes集群。

Ingress Controller前方的蓝色圆圈是A10 Networks Harmony Controller。这种控制器可以实现高效的负载分配,并且可以将信息快速发送到适当的位置。

使用这样的中央控制器,必须选择一种在现有解决方案上进行少量额外配置,就可进行扩容和缩容的解决方案。

5. 微服务之间的访问控制

6.jpg

与流入和流出Kubernetes的南北流量相反,东西向流量在Kubernetes节点之间流动。在上图中,可以看到东西向流量是如何运作的。

当流量在Kubernetes节点之间流动时,可以通过物理网络,虚拟网络或overlay网络来发送该流量。如果不通过某种方式来监控那些东西向的流量,那么对流量如何从一个Pod或容器流向另一个Pod或容器的了解就变得非常困难。

另外,它还可能带来严重的安全风险:获得对一个容器的访问权限的攻击者可以访问整个内部网络

幸运的是,企业可以通过“服务网格”(例如A10 Secure Service Mesh)来解决这个问题。通过充当容器之间的代理以实现安全规则,这可以确保东西向的流量安全,并且还可以帮助扩展,负载均衡,服务监视等。

此外,服务网格可以在Kubernetes内部运行,而无需将流量发送到物理设备或VM。使用服务网格,东西向的流量状况如下所示:
7.jpg

通过这种解决方案,像金融机构这样的企业可以轻松地将信息保留在应有的位置,而不用担心影响安全性。

6. 东西向流量加密

8.jpg

如果没有适当的加密,未加密的信息可能会从一个物理Kubernetes节点流到另一个。这是一个严重的问题,特别是对于需要处理特别敏感信息的金融机构和其他企业。

这就是为什么对于企业而言,在评估云安全产品时,重要的是选择一种可以在离开节点时对流量进行加密,并在进入节点时对其进行解密的方法。

供应商可以通过两种方式提供这种类型的保护:
9.jpg

第一个选择是Sidecar代理部署,这种方法也是最受欢迎的。

通过这样的部署,管理员可以告诉Kubernetes,每当启动特定Pod时,应在该Pod中启动一个或多个其他容器。

通常,其他容器是某种类型的代理,可以管理从Pod流入和流出的流量。

从上图可以看出,Sidecar代理部署的不利之处在于,每个Pod都需要启动一个Sidecar,因此将占用一定数量的资源。

另一方面,企业也可以选择中心辐射代理部署。在这种类型的部署中,一个代理会处理从每个Kubernetes节点流出的流量。这样只需要较少的资源。

7. 应用流量分析

10.jpg

最后一点是,企业了解应用程序层流量的详细信息至关重要。

有了可同时监控南北和东西向流量的控制器,就已经有了两个理想的点来收集流量信息。

这样做既可以帮助优化应用程序,又可以提高安全性,还可以拓展多种不同的功能。从最简单到最高级的顺序排列,这些功能可以实现:
  • 通过描述性分析进行性能监控。大多数供应商都提供此功能。
  • 通过诊断分析更快地进行故障排除。少数供应商提供此功能。
  • 通过机器学习系统生成的预测分析获得建议。更少的供应商提供此功能。
  • 通过真实直观的AI生成的规范分析进行自适应控制。只有最好,最先进的供应商才能提供此功能。


因此,当企业与供应商交流时,至关重要的是确定他们的产品可以提供哪些功能。

使用A10 Networks的类似产品,可以查看大图分析以及相关的单个数据包,日志条目或问题。具有这种粒度的产品是企业应寻求的产品。

关于开发和操作简便性的注意事项

最后,让我们看一下企业在Kubernetes中的流量和安全性方面应该追寻的东西。考虑这些因素还可以为开发和运维团队大大简化工作:
  • 具有统一解决方案的简单体系结构。
  • 集中管理和控制,便于进行分析和故障排除。
  • 使用常见的配置格式,例如YAML和JSON。
  • 无需更改应用程序代码或配置即可实现安全性和收集分析信息。
  • 自动化应用安全策略。


如果公司优先考虑以上这些,则企业可以在使用 Kubernetes 时享受简单、自动化和安全的流量。您的基础设施、架构和运维团队都会对此感到满意。

原文链接:7 Requirements for Optimized Traffic Flow and Security in Kubernetes(翻译:郭旭东) 收起阅读 »