Metaprogramming Ruby: Core Concepts - Object

As the default root of all Rubric objects, Object holds quite a lot valuable instance methods for common usage. It mixes in the Kernel module to provide global accessibility of Kernel APIs for all inherited classes. This article will introduce Object definition.

Object

Object only holds a few constants and instance methods for objects of descendants, all these methods are only used for instance level metaprogramming.

class

Gets class Constant of object.

define_singleton_method / new_method

Defines a singleton method in the receiver. The parameters can be a Proc, a Method or an UnboundMethod object. If a block is specified, it is used as the method body.

extend

Enhance object with instance methods from modules given as a parameter.

instance_of?

Check if obj is an instance of the given class(Except ancestors).

instance_variable_defined?

Check if the given instance variable is defined in obj.

instance_variable_get

Gets the value of the given instance variable, or nil if the instance variable is not set. Just note that the @ part of the variable name should be included for regular instance variables.

instance_variable_set

Sets the instance variable named by symbol to the given object. The variable does not have to exist prior to this call.

instance_variables

Returns array of instance variables

is_a? / kind_of?

Returns true if class is the class of object, or if class is one of the superclasses of obj or modules included in obj.

itself

returns object itself

method

Looks up the named method as a receiver in object, returning a Method object. The Method object acts as a closure in object’s object instance, so instance variables and the value of self remain available.

methods

Returns a list of the names of public and protected methods of object. This will include all the methods accessible in obj’s ancestors. If the optional parameter is false, it returns an array of object’s public and protected singleton methods, the array will not include methods in modules included in object.

private_methods

Returns the list of private methods accessible to object.

protected_methods

Returns the list of protected methods accessible to object.

public_method

Similar to method, searches public method only.

public_methods

Returns the list of public methods accessible to obj.

public_send

Invokes the method identified by symbol, passing it any arguments specified. Unlike send, #public_send calls public methods only.

remove_instance_variable

Removes the named instance variable from object, returning that variable’s value.

respond_to_missing?

Hook method to return whether the object can respond to id method or not.

send / __send__

Invokes the method identified by symbol, passing it any arguments specified. You can use __send__ if the name send clashes with an existing method in obj.

singleton_class

Returns the singleton class of object. This method creates a new singleton class if obj does not have one. If object is nil, true, or false, it returns NilClass, TrueClass, or FalseClass, respectively. If object is a Fixnum or a Symbol, it raises a TypeError.

singleton_method

Similar to #method, searches singleton method only.

singleton_methods

Returns an array of the names of singleton methods for object. Only public and protected singleton methods are returned.

Comments

Metaprogramming Ruby: Core Concepts - BasicObject

Metaprogramming is one of core features for ruby lang, it’s widely used from internal ruby to standard library, also endless magic gems. In next a few articles we will focus on those core stuff. I recommend for Metaprogramming ruby 2 as a reference, except a little verbose and may be only for primer usage. For more ascent knowledge, perhaps you need to track the trunk of ruby. This essay will concentrate on BasicObject, which is parent for all classes in ruby.

BasicObject

As the object oriented hierarchy in ruby core, each type of class is also object of Class except BasicObject, and All the objects of Class has root superclass BasicObject, the one is also object of Class. Thus we’ll have whole picture below.

Ruby OO hierarchy

i.class methods

BasicObject only has single public class method, names “new”.

ii.instance methods

logical operators, any instance of BasicObject is almost empty, with only a few methods, like logical operators !, !=, ==. Here == has same meaning with equal? for object level equality, The difference is that == is recommended for descendant overriding, but equal? should always be same as it is defined in BasicObject.

__id__ / object_id

An integer identifier of an object, should be unique for a given object. But some objects of builtin classes are reused for optimization, like immediate values (not passed by reference but value, like nil, true, false, Fixnums, Symbols, and some Floats) and frozen string literals.

__send__ / send

Double underscore gives a backup for pure alphabet version which may be overrode through other ways. Invokes the method, and passes all arguments using dynamic number of arguments.

instance_eval / instance_exec

instance_eval support passing a string of ruby source code, with file path and line number when compilation errors happen, also given block, within the context of receiver. Thus eval code will have access to instance variables and private methods.

instance_exec only support using block as an argument, but allows one more argument to be passed from outer scope into receiver.

iii.private instance methods

method_missing

In BasicObject, method_missing belongs to private instance methods, any call for this method will raise an error. But descendant can override this method to do dynamic processing, according to user’s input like symbol name and arguments. If overrode method does not want to do any processing, super should be called to pass message bottom from up.

singleton_method_added / singleton_method_removed / singleton_method_undefined

Callback for singleton method operations. As we already know, ruby will not create the whole clone of the class for any objects. Only with a middle layer between object and class - singleton class. Singleton class is generated when object comes out, and any messages sent to object should be transferred to its singleton class first, then its own class if not matches in singleton class.

class’s singleton class is different to object’s singleton class. That’s because singleton class of a class should be inherited from its parent’s singleton class, until BasicObject’s singleton class, then it can reach non singleton class. But for object, singleton class should always have parent class of its own class.

Comments

Microsevices陷阱: 本质与陷阱

最近两年,“微服务”这个词从最初的争议,到随后的逐渐接受,如今已经成为许多时髦的技术人员常挂在嘴边的词汇。在他们看来,似乎你不“微服务”就落伍了——因为你看,好像全世界都在“微服务”。然而当一些自诩为技术先驱的人开始尝试将其落地,并且后来发现自己当初根本就不了解到底“微服务”真正意味着什么时,我们才发现,要想革现代软件工程的命,远没发几篇文章、或者侃侃而谈来的容易。

1. 微服务的本质

可能你见过或者听过某些自称正在构建“微服务架构”的人,坦白的说,“微服务架构”真的不是指什么具体的软件架构,和大多数与之相关的早期技术一样,也没有一个标准告诉你什么是微服务、如何通过以下几个步骤构建微服务、或是让你参加培训,结束之后取得XXX认证,就证明你掌握了微服务架构。

其实微服务更像是一系列前沿软工思想的汇集,至于它为什么叫“微服务”,本系列的第一篇文章就已经提到过(虽然至今都有争议),但你可能会觉得它有点标题党——这也影响不了它拥有这个名字的既定事实。撇开名字,我们来看看微服务所承继的几个软件领域:

基于业务概念建模

从稳定性的角度考虑,围绕业务边界上下文所构建的接口要比来源于技术概念的接口更加不易变更,从而降低风险。这部分来源于领域驱动设计的主张。

自动化

自动化涉及方方面面,包括自动化测试、自动部署、自动配置管理乃至整个持续交付流程,都被认为是微服务落地的重要途径。

隐藏内部实现细节

隐藏实现的好处在于,一方面数据隐藏能够显著降低数据访问代码的耦合性;另外,服务内可以采用异构技术栈来实现,无论使用何种技术,你都可以用REST、RPC来实现服务间通信。

一切去中心化

去中心化的核心是组织自服务团队。近年来我们已经尝过自动化带来的好处,但鉴于Conway法则,设计开始来自领域而非现有的技术架构,而为了保证每个团队能够独立开发、测试和部署,按照服务划分现有团队是一个趋势(而非现实)。自服务团队能显著降低实施自动化的复杂度,更加贴近业务领域,并且形成一个分散的系统架构。与ESB或类似的编配系统完全相反的是,去中心化强调采用编排以及哑管道,并且在服务边界上采用智能终端来表现关联逻辑和数据,从而实现低耦合。

独立部署

强调每个服务都必须是可独立部署的,甚至当发生重大变更时,也倾向于采用版本共存终端来保证各自的独立性。独立部署能够带来更快的feature发布、强化自动化部署流程的一致性(而非经常需要人工编配部署管线)。推荐采用单服务-单主机的部署架构,从而避免部署单个服务时带来的边际效应;采用蓝绿部署/金丝雀发布来降低出错的风险;并且使用消费者驱动契约测试来保证集成接口的一致性。

错误隔离

分布式系统能够做到比单一系统更加可靠——前提是做好应对各种错误的万全准备(这也是人类毕生追求的目标…)。如果我们不能保证下游服务的正确性,系统就有可能发生级连式的灾难,且比单一系统脆弱的多。因此要时刻考虑网络的不可靠性、服务间通信的各种潜在问题:超时、隔板、断路器等在应对灾难时的作用、理解网络分区带来的后果、以及权衡一致性和可用性两种方案。

完备监控

传统意义上的监控,恐怕只是一个简单的dashboard,列出所有运行中节点的状态,并且当某个节点的指标超过阈值时产生警告。但随着去中心化的深入,传统监控方式根本不能应对复杂架构出现的各种问题。因此,采用语义化监控、综合化监控去模拟用户的真实行为,聚合用户日志和统计数据,采用关联ID跟踪消息在系统内的传递路径。

严格的说,不具备上述特征的系统就不能被称作微服务。事实上,当你开始尝试上述某个或某几个新的领域时,多数情况下你的系统无疑蕴涵了极大的风险——因为只有整体采用才能实现微服务的闭环,否则只能是一个摇摇欲坠的怪胎。有的人可能认为可以从简单模型开始演进——但注意,这里所谓的演进并不是从某个独立的方面出发,而是要在一开始就要做通盘设计,并且以简单方式先落地而已。

那么企业费尽千辛万苦、从事如此巨大的迁移工程——效果真的能够立竿见影吗?

2. 发觉陷阱

一些谨慎的开发者认为,可以先从单一系统入手,再逐渐向微服务过渡。对此,微服务的拥簇者声称:“你将无法(或很难)精炼出微服务架构,如果不在项目的最开始就设计成那样”。

“这无疑又在扯过早做大型设计的蛋(That’s BDUF Baloney)。”

Uncle Bob更倾向于“迫使无知”,即好的架构应尽量保证组件对部署和内部通信方式的透明,这样就可以根据实际情况选择部署成微服务,或多个单一系统+应用服务器,亦或纯粹的单一系统。这种“实际情况”有时可能是需要大规模扩展的、只需要少量部署的、以及单个节点足矣的。

一旦打破了“迫使无知”,则很有可能会造成过度设计,使得太多因素过早被考虑进来,最终却形成一堆烂摊子。对于微服务强调的其它几个方面,例如跨语言的服务实现——确实,采用REST的通信架构使得服务间实现强解耦,包括语言、数据库等。但是,什么样的业务需求会导致项目初期就要切割成不同语言、甚至采用不同的数据库技术呢?这种高配置复杂度的架构随之带来的好处又能有多少?

而对于“单一系统”的说法,Uncle Bob更认为这种广告式的宣传,使得采用非微服务形式的系统听起来就像是一块又大又丑的“石头”,造成受众对微服务架构的盲目崇拜,进而为布道者带来利益。

比起微服务,Uncle Bob更加倾向于一种“整洁架构”的风格,后者是对近年来一些流行系统架构的全面总结,焦点主要集中在代码架构方面,而对微服务强调的独立部署、异构数据库等持开放的观点。

3. 稳步向前

当然,无论是微服务,还是整洁架构,能最终轻松玩转的仍然是极少数。例如,从源头的设计出发,对领域的理解会影响边界上下文的稳定性,你要确信自己真的“彻底认识”了该领域,否则后面的一切都是空谈,还不如老老实实把单一系统做好。

另外,如果任何环节存在未知领域,就尽量不要采取过于激进的做法——这是遏制风险的关键。

对于任何一种架构,只有当整体规模增加到一定限值时,才能够真正考验架构的有效性,而面向未知又十分危险——因此强烈不建议如此激进地改变根本架构。毕竟在整个生态圈中,技术团队只是起到实现和保障作用,过早、过度引入风险是完全没有必要的。

那么何时才能选择向前呢?如今声称自己正或多或少实现微服务架构的团队包括Amazon、Netflix、REA和Gilt等,无一不是从现有实践中持续发掘存在的问题,并为此开发了一系列工具、平台甚至整体实践,其中的部分已经成为流行的开源项目——可见,对于某些技术能力领先世界的公司来说,微服务之路尚且存在各种困难,而剩下积累不深却大谈微服务的团队,要么是盲目追新,要么就是醉翁之意不在酒了。

Microsevices陷阱: 层级演进(下)

8. 服务发现

随着服务数量的增加,如何获取某个服务的信息则成为挑战,同时它也是部署监控的前提条件。另一些开发中遇到的例子,如:一、想获取当前环境中运行的服务(也就是采集监控对象);二、想获取当前系统中的API列表从而避免重新发明轮子;这些需求其实都隐含了一种新的需求:针对“服务”的发现机制,在近年来发展非常迅速,已经形成了几个相当成熟的解决方案。它们基本上都遵循了如下工作模式:首先提供一种针对服务的注册过程,当启动新实例时就将其注册进系统;然后,提供一种发现系统内已注册服务的途径。我们先来看一种历史最悠久也最简单的解决方案:

DNS

DNS是解决服务发现问题最直接、简便和有效的手段。因为借助DNS,尽管实例IP可能随时发生变化,但域名是不变的。因此在早期,大多数应用都会采用类似<service-name>-<environment>.company.com这样的二级域名来指定服务地址。有的甚至去掉了environment,直接搭建不同的DNS服务器并设置不同的映射表,从而实现配置管理的一致性。后者看起来很方便,但成熟的解决方案不多,亚马逊的Route53服务是少数优秀的代表。

然而采用DNS也存在一定风险性。在DNS协议中存在TTL的概念,也就是说主机内部会维护一个DNS表,当TTL未失效时系统仍将采用本地的DNS映射表,这种设置让实例的更新带来一定风险。为了解决这一问题,DNS并不直接指向实例IP,而是若干实例前设置的反向代理(这个通常是固定不变的)。

DNS方案由于其易用性而得到了大量的应用,但随着微服务架构规模的增加,这种方法的有效性和可维护性将逐渐降低,特别是DNS服务器节点的存在,于是就出现了更多动态服务发现方案。

9. 服务注册

由于DNS本身的局限性,许多基于服务注册的发现方案被提出并得到了广泛应用。

Zookeeper

Zookeeper最初是Hadoop项目的一部分,其用途非常广泛,包括配置管理、同步服务间数据、选举Leader、消息队列以及命名服务(这才是本文重点)。Zookeeper首先是高可用的,其后台是一个集群并且实现数据Replica;Zookeeper的核心提供一种存储信息的层级命名空间,客户端可以在该层级插入、修改和查询节点;此外,它们能够添加针对节点的监视以观察该节点是否发生变化;也就是说,我们可以把服务的地址信息保存在这种存储结构,并且当其中的信息发生改动时执行其它变更操作,例如关闭上游服务。

Zookeeper是一个非常通用的系统,你可以只把它看作是一个能够保存信息树,并且能在任何变化时发出警告的高可用服务。由于其底层性和单一性,目前针对Zookeeper的扩展库也非常之多。从现在的时间来看,Zookeeper可能有些过时——因为与更多后起之秀相比,它看起来不是那么开箱即用,但不可否认其曾经十分流行。必须承认,由于Zookeeper底层算法PAXOS实现起来非常困难,而该产品又饱经考验,任何企图重新发明轮子的组织都在此吃过不少苦头。这正是分布式协调系统复杂性的体现。

Consul

Consul同样支持配置管理和服务发现,但提供了更多高级功能,从而更加易用。例如针对服务发现的HTTP接口,以及一个简单的DNS服务器,甚至还提供SRV记录,能够同时提供给定名字的IP和端口信息。这就使得曾经采用DNS服务的公司迁移到Consul变得非常容易。

Consul还提供了节点健康检查功能,这就包含了一部分监控系统的能力,并且由于其高容错设计和依赖临时节点机制的系统的针对性,使其有能力在某些情况下完全替代Nagios和Sensu。

Consul的RESTful HTTP接口覆盖了从服务注册、查询信息、以及健康检查等大部分功能,使其与现有系统集成变的十分简便。其底层依赖的Serf能够检测集群内节点,进行错误管理以及发出警告。良好的设计使Consul如今成为该领域极具竞争力的明星之一。

Eureka

Eureka是由Netflix开源的、只关注服务发现领域的系统。Eureka提供基本的负载均衡功能,基于REST的集成终端,以及基于Java的客户端库。但是,由于Eureka是基于JVM并且实现了所有客户端,这对于多语言环境来说可能会是一个挑战。

自己动手

在某些公有云服务器中,服务发现可以借助一些内置功能来实现,当然这要建立在你对该产品非常熟悉,且系统规模较小的情况之下,通常会与采用上述重型框架相比省去很多麻烦。另一方面,服务发现除了是对分布式系统内部提供信息,任何现有的report和dashboard都可以借此提供实时的节点信息,从而允许人工检查。

10. 服务文档化

随着微服务架构的实施,更多时候我们希望能够以API的形式暴露一部分服务缝隙,从而允许其他开发者接入更多类型的服务。但是构建开放API的过程是非常复杂的,特别是在采用微服务架构时,如何保证服务文档的实时性将成为挑战。这里介绍目前流行的两个服务文档技术:Swagger和HAL。

Swagger

Swagger能够通过一组Web界面展示当前的API信息,而你只需要定义一套POST模版即可。Swagger要求相关服务暴露一个符合标准的文件(可以通过Swagger提供的标准库实现),例如对于Java你可以使用方法标注描述相关API。

HAL和HAL浏览器

HAL即Hypertext Application Language,是一种超媒体资源的描述标准,HAL同样提供用户界面和标准库。通过Web界面,用户不仅可以查看API信息,还能直接调用相关API。不同的是,HAL中所有的文档信息都基于超媒体控制,因此很容易就能够将信息对外展示。这就限制了用户只能使用超媒体描述系统内资源,而从现有系统迁移则远没那么容易。

11. 自描述系统

在SOA的发展初期,类似全局描述、发现以及集成UDDI标准被用于帮助人们理解当前运行着什么服务。事实证明,UDDI对大多数项目来说都非常重,从而导致更多替代产品的出现。

不可否认,获取系统的整个框图并理解其架构对于团队成员来说十分重要。通过关联ID跟踪下游服务并理解调用链、从而让我们更快理解系统原理。采用类似Consul的服务发现技术,能够让我们查看当前运行的微服务。HAL能展示出当前终端上包含的所有服务能力,同时状态监控页面和相关系统能够使我们整体和局部的状态。上述信息都能够用于为进一步设计提供线索,且比只有一个简单到随时会过期的wiki页面强得多。尽管多数情况下我们都是从后者开始的,但不要忘了把上述信息逐渐引入到系统中,随着系统复杂性增加,描述信息的演化能让我们更好的理解系统。

Microsevices陷阱: 层级演进(中)

5. 缓存

当某个操作的结果可以被临时存储,从而使后续的相同操作能够直接采用上述结果,而非花费同样时间和资源在重复操作上,这种性能优化方法就是缓存技术。如今面临大规模的HTTP访问量,缓存已经成为解决性能瓶颈的大杀器之一。

当然,即使是单一系统,目前也存在众多缓存方案可供选择。但对微服务架构来说,可选择的就更多——问题是对于分布式系统而言,由于节点间的相互对等性,缓存存在于客户端或是服务端,会是一个艰难的选择。

客户端、代理和服务端缓存

对于三个主要的缓存承载对象之一,客户端缓存主要是把数据放在客户端中,并由客户端决定是否从服务端刷新数据。一种理想的情况是,由服务端负责提供是否需要刷新的线索,然后由客户端决定是否刷新,这种技术被广泛用于HTTP缓存。

代理是指位于客户端和服务端之间的设施,由代理决定缓存机制的包括反向代理或CDN技术。

另一种方法是由服务端负责缓存技术,如采用Redis或Memcache,或者更简单的一些片内缓存技术。

对上述方案的选择主要取决于优化的目标。例如,客户端缓存主要目的是降低网络负载,并且实现起来最为简便;缺点是一旦实行成熟的客户端缓存机制,服务端要想做出什么修改就比较困难,特别是容易引发未经验证的脏数据问题(下文会做进一步讨论)。而对代理缓存来说,其对客户端/服务端都是完全透明的,且针对普通HTTP通信实现也很简单,例如采用反向代理如Squid或Varnish。尽管代理会引入更多网络路由节点,但这对随后的性能提升来说基本是可以忽略不计。服务端缓存能够保证对客户端实现透明化,而且一旦缓存位于服务边界上,就能很容易解决脏数据、分析和优化命中率。同时服务端缓存对于系统性能的提升是最为有效和迅速的——但实现起来又稍复杂。

一般缓存技术的选择可以是混合性的,但也有分布式系统内部完全不设缓存的情况,因此要具体问题具体分析。

HTTP缓存

HTTP协议提供了全面的缓存技术,包括客户端和服务端。首先,HTTP头的cache-control指令告诉客户端是否应该缓存当前响应信息,以及缓存时间。同样,还包含一个Expires属性,用于标记该信息在某个时间点后过期,而非持续多长时间。对于静态网站数据,如图像、CSS等,使用cache-control的time to live(TTL)就能够很好解决这一问题。

除了cache-control和Expires,HTTP还提供了一种Entity Tags(ETags)机制。ETag其实是用来标记当前信息是否已经发生变化,即使URI不变,也能识别该信息是否是最新的。应当明确,ETags本身不提供缓存机制,而是通过Conditioanl GET实现,后者是说在GET请求中添加某些头信息,从而告诉服务端只有当该条件满足时才返回整个信息。

当然,ETags还少不了要打上cache-control,只有当TTL或Expires满足时,客户端才会发送信息并携带If-None-Match: ETag值。服务端通过读取If-None-Match信息,判断客户端的信息是否已经过时,如果已是最新,则返回304 Not Modified,否则返回整体信息和200 OK。这些方法在反向代理上被广泛采用,甚至包括CDN如AWS的CloudFront和Akamail。一般的HTTP标准库也都包含这些功能。

无论是cache-control、Expires或ETags,其在功能上相互存在一定重叠。而这也确实会对HTTP通信带来一些问题,特别是混合使用时。建议参考《REST in Practice》或HTTP/1.1协议的13章。

即使不采用HTTP协议进行消息缓存,也应当参考这部分的设计思路,无论对客户端/服务端的缓存实施都是很有价值的。

写缓存

对写操作设置缓存在某些场景下是必要的,例如当写操作突然增加时,短期间内频繁I/O会降低系统性能,好的做法是先把数据写入本地缓存中,之后再统一更新至下游节点。另一方面,当下游服务发生不可用的情况时,写操作的数据会被较好的保存,并不会造成数据丢失的问题。

恢复缓存

缓存也能够被用于灾难恢复。例如对客户端缓存来说,当下游节点出现问题,客户端就可以把本地缓存作为临时数据,而不是直接导致不可用。Web技术早起曾经有一种Guardian技术,作用是定期生成整个网站的静态内容,当系统离线时就采用静态版本提供服务,尽管数据不是最新的,但仍可以向用户提供服务。

隐藏源

由于未命中缓存的存在,一般系统的源都会面临未缓存请求的访问,当请求数量突然增加时就可能导致源崩溃,继而影响整个分布式系统。因此,在某些系统中,请求永远不能直接访问源,当缓存未命中时会立即得到错误响应,同时缓存节点也会把相应的请求信息异步发送至源,再由源把所需数据同步至缓存节点。这种做法的好处就是完全避免了源遭遇大访问量的情况,从而提高系统整体稳定性。

缓存建立的原则

目前业界有一种趋势,就是多级缓存+各类缓存。实际上,缓存就意味着脏数据的可能:当缓存层级增加,脏数据的概率和数量也会随之增加,对于某些业务场景而言这是不可容忍的。特别是当采用微服务架构,类似后果的影响会被无限放大。因此采用缓存的原则就是:简单、单一、有效。

另一方面,采用缓存要时刻保持警惕,因为脏数据对某些业务场景来说几乎是零容忍的。更何况不同缓存所带来的约束也各不相同——例如HTTP缓存中的Expires: Never,就可能导致某些用户理论上永远无法更新数据,其后果可想而知。因此,一定要在完全理解概念的基础上,再谨慎引入缓存技术。

6. 自动扩展

自动扩展的前提是基础设施的全面自动化,此类问题在前几篇文章已经提到多次。但是,仅有运维自动化还不足以实现自动扩展——应用至少应部署在公有/私有云之上。例如,在一天中的某个时间段内系统负载总是面临峰值考验,如采用AWS公有云服务的话,动态开启/关闭节点可以节省成本,同时还能在面临高负载时应对自如,当然前提是你手中有足够的运营数据。这种根据数据决定基础扩展策略的方式,通常被称作预测式扩展。

另一种自动扩展方法是反应式扩展。如果某个节点出现问题,或系统负载突然达到一个危险的峰值,则自动启动新节点以增强吞吐能力。反应式方法的关键在于发现问题、启动节点的速度,当然一方面是靠团队Devops的能力,另一方面还要得到云服务商的支持。

实践证明,无论是预测式扩展,或是反应式扩展,其对提高系统整体可用性都是非常有效的。这里的建议是,当团队初次涉足该领域时,应优先考虑设置节点失败条件下的反应式扩展功能,然后再根据更多数据采取进一步行动。

7. 分布式系统与CAP原则

十多年来,CAP原则成为构建分布式系统时不可逃避的话题。尽管CAP原则在其科学性上倍受质疑,但其作为权威的实践经验仍不失指导性。

CAP分别表示一致性、可用性和分区容错性。一致性是指任何节点上的数据都是保持一致的;可用性意味着任何请求都会得到响应;而分区容错性指当系统内部通信出现问题时,依然有能力保证整体可靠性。

CAP原则的基本内容就是,分布式系统始终无法同时囊括上述三种属性,多数情况下只能择其二设计。在实践中,与CAP原则相符的例子比比皆是,假设一个分布式系统包含A、B两个实例,后台数据存储在Ad和Bd两个Replica数据节点上。当Ad和Bd间的网络通信中断时,如果不关闭A或B的其中一个,则Replica的数据就会无法实现同步,从而造成不一致。而如果关闭实例A或B,则会降低系统可用性。这就显示出一致性和可用性之间的矛盾——分布式系统无法放弃分区容错:因为其本身就是为此而生。

AP系统

如果强调可用性,就意味着一致性可能出现问题。一般情况下,如果多个数据节点间发生不一致,也可以在某个时间后使其交换数据以达到同步,而非立即实行。这就是强调多次的最终一致性。最终一致性系统预示着用户将有可能收到脏数据,这就要建立在具体的业务需求基础上了。

CP系统

有时我们不可避免地面临强一致性,即使因为无法达到一致而被迫拒绝服务——这就是牺牲可用性的强一致性系统。然而,在分布式领域,稳定可靠的强一致性系统实现非常困难,一方面是由于分布式事务操作的低效性,另一方面是实现分布式锁算法的困难性(即使是单进程锁,要踩的坑也很多)。因此如果确实要构建强一致系统,采用现有成熟方案非常重要——因为它们通常都经历了时间的考验。当然对比前者,建立AP系统可能就要容易许多。

孰优孰劣?

AP和CP,选谁?前者只能实现“最终一致”,后者实现起来则“困难重重”。然而不能否认,在金融系统中,一致性几乎是必须的;而对大部分系统而言,AP则是更加现实的做法。要知道,在大多数情况下,选择AP和CP并非零和游戏,整个系统中可以同时存在AP和CP两种架构,以满足不同需求。要提醒的是,如果采用CP模式,调研好现有方案并择优采用是前提。AP则是更加具有普适性的大众选择。

Microsevices陷阱: 层级演进(上)

通过过去的数篇文章,我们分别介绍了微服务架构从设计、开发到实施等各个方面,并以业界的前沿应用给出了一定实践案例。然而世界总不是你我想象中的美好,面临千奇百怪的业务需求,随着架构的不断变化,我们总会遇到各式各样的冲突和风险。因此,微服务架构并不适用于一拥而上——循序渐进才是可靠途径。

1. 从可用性(Availability)说起

分布式系统领域存在一个经典的谬误理论:Fallacies of distributed computing,基本总结了不可靠通信给系统架构带来的影响。由于网络通信在分布式系统中的极端重要性,使得网络可靠性成为此类系统永恒的梦魇。任何网络的不可靠都会引发各种灾难,且难以被察觉、定位和修复。即使有能力购买先进的硬件设施,以及最可靠的第三方系统,也不能消除出错的可能。

实际上,问题在于设置一个容错率。因为对跨功能的需求是永不枯竭的,根据业务特点设置容错率,然后按需堆叠架构,才是一个看起来合理的策略。在测试一文中我们已经提到跨功能需求,例如对于响应时间/延迟的需求,应根据现有设施进行合理评估,再和业务需求阈值比较,从而决定改进方案。对于可用性,是否必须是一个24/7类的业务?或者用户完全能够容忍某个偶尔的不可用?对于数据的持续性,业务需求的数据保存时限如何?只有业务需求明确,才有必要开始考虑进一步的演进。

当需求明确时,我们就可以考虑演进现有架构了——但别忘了前面提到的:分布式系统总会带来不可靠。那么如何度量当前系统的可靠性呢?最有效的手段就是利用现有的监控系统,统计出错率。在正常情况下,系统运行数据能够反映出一定的可靠度,但对某些意外情况,例如网络或其它硬件设备损坏,就很难真的实现了。例如,当服务A需要调用服务B的API时,B开始发生某些本地错误,且不能及时断开链接。那么A将持续保持对B的链接,直到链接超时自动断开,但随着A继续接收上游请求并继续发起对B的链接,从而造成服务A维持一个大规模HTTP链接池,当A的系统资源耗尽,灾难就会随之发生了。

上面提到的例子在分布式架构中已经非常常见,开发人员已经知道若干种解决方案,包括合理超时、改进链接池、以及开发断路器等。但首要问题是如何发现此类问题?

Google每年会组织一次灾难恢复测试(DiRT,Disaster Recovery Test)练习,练习内容包括了针对大规模不可抗拒灾难的演练(例如地震)。Netflix则更进一步,每天会通过工具随机破坏生产环境,用来测试系统的可靠性和恢复能力,此类工具被称作Simian Army。Chaos Monkey是其中比较著名的一种,其基本功能是每天随机性关闭AWS上的某些节点;Chaos Gorilla能够断开整个可用的数据中心;Latency Monkey能够模拟节点间通信缓慢的情况;上述工具都已经被Netflix贡献给了开源社区。

因此,在互联网分布式系统的世界中,首先需要改变的就是传统的思维模式:无论系统有多健壮,失败必然发生。那么接下来我们需要考虑的就是如何在失败发生时采取补救措施——大多数情况下这比减少失败来得更容易。

超时

超时是一项经常被人忽略的关键反馈,因为它预示着下游服务出现了不可预知的问题。但难点在于多长时间才算超时?其实也有简单解决办法,首先,可能引发超时的操作必须在进程外执行;超时时限应在全系统内保持唯一值;一旦发生超时,即记录进日志系统。定期查看超时部分的记录,进行有效改善。

断路器

想象一下家中电表里的空气开关,当电流达到峰值时,空开自动关闭,从而避免对家中电器造成损坏。当屋内长期无人时,也可以手动关闭空开,防止意外发生。空气开关就是断路器的一种,而在《Release It!》中,断路器则成为提高系统可用性的关键手段。

再思考一下超时的情况。一旦下游服务故障,服务调用将发生超时,由于时限是默认设置的,越来越多的超时将发生,并进一步拖慢系统,直至崩溃(前文已提过)。断路器的作用是,当超时发生次数超过一个阈值,就切断到某个服务的通信,即超时时限趋近于零。即“快速死亡”。

断路器能够避免错误发生时,故障蔓延至整个系统。在无法确定分布式系统可靠性的情况下,也确实是一个十分有效的解决方案。但这里需要注意,断路器并不是什么万能神器,只不过是起到“壮士断腕”的作用——想象一下,万一断路器“过早”启动了呢?在实践中,如果服务调用是异步的,那么采用消息队列进行处理也没什么问题。而如果是同步调用,就需要考虑一下引入断路器提高可用性了。

隔板

《Release It!》中提到一种隔板模式。这种模式可以解释成船舱中的隔板结构,当船舱进水时,为了避免整船沉没,通常会选择关闭已经进水的船舱。一个简单的例子是采用服务——连接池模式,即对于每一个下游服务都保持独立的连接池,即使某个下游服务出现问题,也只会导致对应的连接池拥塞,而不会影响整个系统。

在实际应用时,隔板方法几乎是标配的做法,而超时和断路器则可根据情况添加,因为前者是预防问题,而后者更多是收拾残局。例如对于同步调用,配置断路器则是必须。上述模式在实现起来也有很多开源选择,例如Netflix的Hystrix,.Net的Polly,以及Ruby下的circuit_breaker。

2. 幂等操作

无论同一个操作执行多少遍(大于等于1),输出结果不变,这就被称为幂等操作。幂等性在构建Web应用时非常重要,因为消息可能因为某些原因被重放多次,而当这些消息都到达服务器时,不应把这些消息认为是不同的请求进行多次处理。保持幂等操作可以提高应用的可靠性,这在实践时非常重要。

值得一提的是,HTTP协议天然定义了GET、PUT操作应是幂等性的,如果破坏了这一原则,则可能会给系统带来许多问题。

3. 应用层扩展

扩展的目的无非有二:容错和高性能。最直观的扩展方法就是更换CPU和I/O设备,被称作纵向扩展,这种方法实际上成本非常高昂,特别是当前芯片研发和生产已经陷入烧钱的地步,即使不差钱,要让应用充分利用多核和高速I/O架构也非易事。因此实际中更多采用的是横向扩展,特别是当虚拟化技术普及,若干廉价服务器组成的集群就可以很好解决扩展的问题。微服务架构的扩展技术实际上与单一系统类似,但也有一些区别。

分担负载

之前讨论过单服务-单主机的微服务部署方式,在实际应用中,为了节省成本更多团队选择在同一台主机上运行多个服务,但是随着负载需求的增加,采用单对单部署则是一种相对简单有效的解决方法。

另一方面,如果某个服务负载确实很高,单纯的主机数量增加不能直接解决微服务架构的问题,那就要进一步考虑做拆分了(这种拆分避免不了对业务的考量,如果是非核心功能,还是建议直接独立出来)。

平摊风险

横向扩展能把风险平摊到不同主机,从而提高可用性,但具体操作起来需要考虑许多因素。首先,基于现有的虚拟化平台,即使实现单对单部署,所谓的“主机”也不过是一台虚拟主机,不能保证“主机”不在同一台物理机上,那也就不能实现平摊风险的效果,不过还好许多平台目前已经支持了跨不同主机运行服务的功能。

在企业内部虚拟化平台中曾流行采用Storage Area Network,即SAN。SAN是一种十分昂贵的存储区域网络,旨在达到无错运行。而一旦采用SAN且后者挂掉,则整个虚拟集群就会受到影响,因此实际上还是不能避免单点失败的问题。

另一个常见做法是采用多数据中心运行,这其实和IaaS的提供商有关。例如AWS采用的多区域数据中心架构,其保证对EC2实现99.95%的正常服务概率每月每区域,为了实现更高可用性,在此基础上部署应用到不同区域即可实现该目的。

无论如何,对服务商的Service Level Agreement,SLA的熟悉也十分重要,当灾难真的发生,如何“维权”也就在于你对该协议的理解程度了。

负载均衡

负载均衡是一种十分简单有效的横向扩展技术,目前基本成为多实例架构的标配。负载均衡组件能够接收来源请求,根据自身算法把该请求发送至多个平行实例中的某一个,从而实现负载分担。

现在,负载均衡技术非常成熟,包括从硬件方案到软件方案(例如mod_proxy)。不过其功能都类似,当某个实例不可用时将其屏蔽,一旦恢复则将其重新开启。一些负载均衡设施提供更加高级的功能,例如SSL终端。我们已经讨论过采用HTTPS防止中间人攻击和消息泄密的问题,但对系统内部的服务间通信来说,HTTPS会影响其整体运行性能。因此SSL终端就起到了把HTTPS转换成HTTP从而在内网通信的作用。

商业负载均衡,如AWS的ELBs就支持上述功能,同时还可以在系统内部构建VLAN,从而实现内部的高效通信。

对于硬件负载均衡设备来说,自动化始终是一个问题。如果企业更信任硬件方案,也不妨在后端搭设一个软件负载均衡设施,从而提高自动化的能力。

基于Worker的架构

负载均衡并非分担系统负载的唯一选择,基于Worker的系统架构同样有效。Worker被用于如Hadoop批处理进程模型和共享队列的异步事件监听,擅长批处理和异步处理。如图片处理、发送邮件和生成报表等。一旦达到系统峰值,新的Worker能被添加进当前系统,从而增加吞吐量。

对Worker架构来说,尽管每一个Worker可以是不可靠的,但组织Worker的中央系统应保证可靠性。例如采用持久消息广播Zookeeper。

重新上路

当目前的架构无法应对越来越多的用户,重新设计可能是必然的选择,因此初期预留扩展接口是有必要的。当然,如今许多人会选择一开始就预估一个巨大的访问量,从而在此基础上直接构建大规模架构。这是一件非常危险的事,甚至有可能引发灾难。因为在初期我们更强调精益化,而非大而全。后者明显会引发无用的付出。

因此要时刻了解:架构的演进是必须的过程,我们能做的只是留个心眼儿。何况系统扩展的需求并非标志着失败,而是巨大的成功。

4. 数据层扩展

前文提到的扩展,通常只适用于针对无状态的服务。而如果是操作数据库的服务,情况则不同。最为直接的方法就是,借助数据库产品自身提供的扩展特性,而这就需要在项目初期就按需确定数据库产品的选择。

服务可用 vs 数据持久

应当明确的是,这两者完全不是一回事,因此解决问题的思路并不一致。例如当产品数据库出现问题,由于数据本身存有备份,因此持久性没太大问题。但数据库本身不可用,整个依赖于此的应用服务也就不可用。用现代运维技术的观点来看,即使设置了Primary和Replica,当Primary出现问题,如果缺少同步Replica到Primary的机制,就还是缺少可用性。

扩展读库

针对读操作密集的应用,其扩展复杂度要比针对写操作低得多。因为目前存在各种各样的缓存技术(下篇文章将讨论),这里给出另外一种数据库架构:读备库。

在关系型数据中,例如MySQL和Postgres,数据可以从主节点被复制到多个备库节点,这样一来确保了数据的安全性,同样也可被用于分担读操作。一般来说,写操作由一个主节点负责,读操作则由更多备库节点负责。复制操作发生在写操作的之后,从而在读库上存在一定的脏数据时间,当然最终能够保证一致性。这种系统其实是所谓的“最终一致”,通过牺牲严格的一致性来达到系统的扩展(参见分布式系统的CAP定律)。

然而随着缓存技术的发展,实现缓存要比搭建读库方便得多,成本也更低。一般也应该根据业务需求选择不同的扩展方式。

扩展写库

前面提到写操作扩展相对不那么容易,但也存在一般方案:分片。分片可以在写数据时,将数据按照某种哈希规则分布至多个节点上,从而实现横向扩展。在现代数据库技术中,分片几乎成为标配。

分片的难点在于处理查询问题。在单片数据库中,查询基本上是通过内存和索引一次完成的,但是对于分片数据库,查询可能分别发生在不同的节点中,而这种多次查询的方式最终用异步机制组织起来,并配置缓存。例如MongoDB,就采用Map/Reduce架构解决查询问题。

分片系统的另一个问题是,当要增加新的数据库节点时,系统需要整体停机,特别是针对大规模的分片+Replica系统。目前最新的数据库技术已经支持在线扩充节点,并且能在后台保证数据的重新平衡,例如Cassandra。

分片能够实现读操作的横向扩展,但无法满足扩展的另一个需求:可靠性。Cassandra先行一步,实现了环上多节点备份以提高可靠性。

CQRS模式

CQRS指命令查询职责隔离,这是一种极端的数据解耦模式。在标准数据库应用中,通常采用同一系统执行数据操作和查询。但是在CQRS模式下,负责处理命令并操作数据的模块和查询模块应当是分离的。

CQRS带来的隔离性使得扩展方式更加多样化,同时一些业务需求也确实符合操作/查询分离的情况。在CQRS中,一种更加极端的形式甚至是同一个数据仓库,分别存在基于图的表示和基于键值对的表示等各种读方案。然而需要提醒的是,CQRS的应用场景目前还比较狭窄,因为传统CRUD的方式与次存在很大程度的不同,对团队来说将是一个巨大的挑战。

Ruby Web服务器:这十五年

坦率的说,作为一门年轻的计算机语言,Ruby在最近二十年里的发展并不算慢。但如果与坐拥豪门的明星语言们相比,Ruby就颇显平民范儿,表现始终不温不火,批评胜于褒奖,下行多过上扬。但总有一些至少曾经自称过Rubyist的程序员们,愉快地实践了这门语言,他们没有丝毫的歧视习惯,总是努力尝试各家之长,以语言表达思想,用基准评判高下,一不小心就影响了整个技术发展的进程。

本文谨以Ruby Web服务器技术的发展为线索,回顾Ruby截至目前最为人所知的Web领域中,重要性数一数二的服务器技术的发展历程,试图帮助我们了解过去,预见未来。

Ruby Web服务器发展时间轴

一、随波逐流

长久以来,任何Web服务器都具备的两项最重要的功能:一是根据RFC2616解析HTTP/1.1协议,二是接收、处理并响应客户端的HTTP请求。幸运的是Web技术的发展并不算太早,使得Ruby恰好能赶上这趟顺风车,但在前期也基本上受限于整个业界的进展。像Apache HTTP Server、Lighttpd和Nginx这些通用型Web服务器+合适的Web服务器接口即可完成大部分工作,而当时开发者的重心则是放在接口实现上。

cgi.rb

作为Web服务器接口的早期标准,CGI程序在调用过程中,通过环境变量(GET)或$stdin(POST)传递参数,然后将结果返回至$stdout,从而完成Web服务器和应用程序之间的通信。cgi.rb是Ruby官方的CGI协议标准库,发布于2000年的cgi.rb包含HTTP参数获取、Cookie/Session管理、以及生成HTML内容等基本功能。

Web服务器和CGI

当支持CGI应用的Web服务器接到HTTP请求时,需要先创建一个CGI应用进程,并传入相应的参数,当该请求被返回时再销毁该进程。因此CGI原生是单一进程/请求的,特别是每次请求时产生的进程创建/销毁操作消耗了大量系统资源,根本无法满足较高负载的HTTP请求。此外,CGI进程模型还限制了数据库连接池、内存缓存等资源的复用。

对于标准CGI应用存在的单一进程问题,各大厂商分别提出了兼容CGI协议的解决方案,包括网景的NSAPI、微软的ISAPI和后来的Apache API(ASAPI)。上述服务器API的特点是既支持在服务器进程内运行CGI程序,也支持在独立进程中运行CGI程序,但通常需要在服务器进程中嵌入一个插件以支持该API。

Webrick

作为最古老的Ruby Web服务器而不仅仅是一个接口,诞生于2000年的Webrick从Ruby 1.9.3(2011年10月正式发布)起被正式纳入标准库,成为Ruby的默认Web服务器API。Webrick支持HTTP/HTTPS、代理服务器、虚拟主机服务器,以及HTTP基础认证等RFC2617及以外的其它认证算法。同时,一个Webrick服务器还能由多个Webrick服务器或服务器小程序组合,提供类似虚拟主机或路由等功能:例如处理CGI脚本、ERb页面、Ruby块以及目录服务等。

Webrick曾被用于Rails核心团队的开发和测试中。但是,Webrick内置的HTTP Parser非常古老,文档缺失,性能低下且不易维护,功能单一且默认只支持单进程模式(但支持多线程,不过在Rails中默认关闭了对Webrick的多线程支持),根本无法满足产品环境中的并发和日常维护需求。目前一般只用于Web应用的本地开发和基准测试。

fcgi.rb

fcgi.rb是FastCGI协议的Ruby封装(latest版底层依赖libfcgi)。为了与当时的NSAPI竞争,FastCGI协议最初由Open Market提出和开发、并应用于自家Web服务器,延续了前者采用独立进程处理请求的做法:即维持一个FastCGI服务器。当Web服务器接收到HTTP请求时,请求内容和环境信息被通过Socket(本地)或TCP连接(远程)的方式传递至FastCGI服务器进行处理,再通过相反路径返回响应信息。分离进程的好处是Web服务器进程和FastCGI进程是永远保持的,只有相互之间的连接会被断开,避免了进程管理的开销。

Web服务器和FastCGI/SCGI服务器

进一步,FastCGI还支持同时响应多个请求。为了尽量减少资源浪费,若干请求可以复用同一个与Web服务器之间的连接,且支持扩展至多个FastCGI服务器进程。FastCGI降低了Web服务器和应用程序之间的耦合度,进而为解决安全、性能、管理等各方面问题提供新的思路,相比一些嵌入式方案如mod_perl和mod_php更具灵活性。

由于FastCGI协议的开放性,主流Web服务器产品基本都实现了各自的FastCGI插件,从而导致FastCGI方案被广泛使用。fcgi.rb最早开发于1998年,底层包含C和Ruby两种实现方式,早期曾被广泛应用于Rails应用的产品环境。

mod_ruby

mod_ruby是专门针对Apache HTTP Server的Ruby扩展插件,支持在Web服务器中直接运行Ruby CGI代码。由于mod_ruby在多个Apache进程中只能共享同一个Ruby解释器,意味着当同时运行多个Web应用(如Rails)时会发生冲突,存在安全隐患。因此只在一些简单部署环境下被采用,实际上并没有普及。

LiteSpeed API/RubyRunner

LiteSpeed是由LiteSpeed Tech公司最初于2002年发布的商用Web服务器,特点是与被广泛采用的Apache Web服务器的配置文件兼容,但因为采用了事件驱动架构而具有更好的性能。

LiteSpeed API(LSAPI)是LiteSpeed专有的服务器API,LSAPI具备深度优化的IPC协议以提升通信性能。类似其它Web服务器,LiteSpeed支持运行CGI、FastCGI、以及后来的Mongrel。同时在LSAPI的基础上开发了Ruby接口模块,支持运行基于Ruby的Web应用。此外,LiteSpeed还提供RubyRunner插件,允许采用第三方Ruby解释器运行Ruby应用,但综合性能不如直接基于LSAPI Ruby。

由于LiteSpeed是收费产品,其普及率并不高,一般会考虑采用LiteSpeed作为Web服务器的业务场景包括虚拟主机/VPS提供商、以及相关业务的cPanel产品。同时,LiteSpeed也会被用于一些业务需求比较特殊的场合,例如对Web服务器性能要求高,且应用程序及其部署需要兼容Apache服务器。LiteSpeed于2013年发布了开源的轻量Web服务器——OpenLiteSpeed(GPL v3),移除了商业版本中偏具体业务的功能如cPanel等,更倾向于成为通用Web服务器。

scgi.rb

scgi.rb是对SCGI协议的纯Ruby实现。从原理上来看,SCGI和FastCGI类似,二者的性能并无多大差别。但比起后者复杂的协议内容来说,SCGI移除了许多非必要的功能,看起来十分简洁,且实现复杂度更低。

Web服务器和多FastCGI/SCGI服务器

与FastCGI类似,一个SCGI服务器可以动态创建服务器子进程用于处理更多请求(处理完毕将转入睡眠),直至达到配置的子进程上限。当获得Web服务器请求时,SCGI服务器进程会将其转发至子进程,并由子进程运行CGI程序处理该请求。此外,SCGI还能自动销毁退出和崩溃的子进程,具有良好的稳定性。

二、闻名天下

2005年,David Heinemeier Hansson(DHH)发布了基于Ruby的开发框架Ruby on Rails(Rails),聚光灯第一次聚焦在Ruby身上。但是业内普遍对Web服务器的方案感到棘手,本地环境Webrick/产品环境FastCGI+通用Web服务器几乎成了标配,无论是开发、部署或维护都遇到不少困难,一些吃螃蟹的人遂把此视为Rails不如J2EE、PHP方案的证据。

Mongrel

2006年,Zed Shaw发布了划时代的Mongrel。Mongrel把自己定位成一个“应用服务器”,因为其不仅可以运行Ruby Web应用,也提供标准的HTTP接口,从而使Mongrel可以被放置在Web代理、Load Balancer等任意类型的转发器后面,而非像FastCGI、SCGI一样通过调用脚本实现Web服务器和CGI程序的通信。

Mongrel采用Ragel开发HTTP/1.1协议的Ruby parser,而后者是一个高性能有限自动机编译器,支持开发协议/数据parser、词法分析器和用户输入验证,支持编译成多种主流语言(包括Ruby)。采用Regel也使parser具有更好的可移植性。但是,Mongrel本身不支持任何应用程序框架,而需要由框架自身提供这种支持。

Mongrel Web服务器

Mongrel支持多线程运行(但对于当时非线程安全的Rails来说,仍然只能采用多进程的方式提高一部分并发能力),曾被Twitter作为其第一代Web服务器,还启发了Ryan Dahl发布于2009年的Node.JS。

但是当Mongrel发布后没过多久,Shaw就与Rails社区的核心成员不和(实际上Shaw对业界的许多技术和公司都表达过不满),随后就终止了Mongrel的开发。进而在其Parser的基础上开发了其后续——语言无关的Web服务器Mongrel2(与前续毫无关系)。

尽管Mongrel迅速衰落,却成功启发了随后更多优秀Ruby应用服务器的诞生,例如后文将介绍的Thin、Unicorn和Puma。

Rack

随着Web服务器接口技术的发展,从开始时作为一个module嵌入Web服务器,到维护独立的应用服务器进程,越来越多的应用服务器产品开始涌现,同时相互之间还产生了差异化以便适应不同的应用场景。但是,由于底层协议和API的差别,基于不同的应用服务器开发Web产品时,意味着要实现各自的通信接口,从而为Web应用开发带来更多工作量。特别是对于类似Django、Rails这些被广泛使用的Web框架来说,兼容主流应用服务器几乎是必须的。

2003年,Python界权威Phillip J. Eby发表了PEP 0333(Python Web Server Gateway Interface v1.0,即WSGI),提出一种Web服务器和应用程序之间的统一接口,该接口封装了包括CGI、FastCGI、mod_python等主流方案的API,使遵循WSGI的Python Web应用能够直接部署在各类Web服务器上。与Python的发展轨迹相似,Ruby界也遇到了类似的挑战,并最终在2007年出现了与WSGI类似的Rack。

与WSGI最初只作为一份建议不同,Rack直接提供了模块化的框架实现,并由于良好的设计架构迅速统一了Ruby Web服务器和应用程序框架接口。

Rack被设计成一种中间件“框架”,接收到的HTTP请求会被rack放入不同的管线(中间件)进行处理,直到从应用程序获取响应。这种设计通过统一接口,把一般Web应用所需的底层依赖,包括Session处理、数据库操作、请求处理、渲染视图、路由/调度、以及表单处理等组件以中间件的形式“放入”rack的中间件管线中,并在HTTP请求/响应发生时依次通过上述管线传递至应用程序,从而实现Web应用程序对底层通信依赖的解绑。

Rack中间件

Rack接口部分包含两类组件:Handler,用于和Web服务器通信;Adapter,用于和应用程序通信。截至Rack 1.6,Rack内置的handlers包括WEBrick、FCGI、CGI、SCGI、LiteSpeed以及Thin,上述handlers用以兼容已有的常见应用服务器。而2008年后,随着rack逐渐成为事实标准,更新的Ruby Web服务器几乎都包含Rack提供的handler。包括Rails、Sinatra、Merb等等几乎所有主流框架都引入了Rack Adapters的支持。

三、百花齐放

Mongrel和Rack的相继诞生,使Ruby Web服务器、乃至应用程序框架的发展有了一定意义上可以遵循的标准。Mongrel后相继派生出Thin、Unicorn和Puma;而Rack统一了Ruby Web服务器和应用程序框架接口,使应用开发不再需要考虑特定的部署平台。Ruby Web服务器开始依据特定需求深入发展。

Thin/Goliath

发布于2009年的Thin沿用了Mongrel的Parser,基于Rack和EventMachine开发,前者上文已有介绍,EventMachine是一个Ruby编写的、基于Reactor模式的轻量级事件驱动I/O(类似JBoss Netty、Apache MINA、Python Twisted、Node.js、libevent和libev等)和并发库,使Thin能够在面对慢客户端的同时支持高并发请求。

发表自1995年的Reactor模型的基本原理是采用一个单线程事件循环缓存所有系统事件,当事件发生时,以同步方式将该事件发送至处理模块,处理完成后返回结果。基于Reactor模型的EventMachine具备异步(非阻塞)I/O的能力,被广泛用于大部分基于Ruby的事件驱动服务器、异步客户端、网络代理以及监控工具中。

Reactor模型

2011年,社交网络分析商PostRank开源了其Web服务器Goliath,与Thin相似(都采用了EventMachine)但又有很大不同,采用新的HTTP Parser,同时针对异步事件编程中的高复杂度回调函数问题,借助Ruby1.9+的纤程技术实现了线性编码,使程序具备更好的可维护性。Goliath支持MRI、JRuby和Rubinius等多平台。在附加功能方面,Goliath的目标不仅是作为Web服务器,更是一个快速构建WebServices/APIs的开发框架,但是随着之后PostRank被Google收购,Goliath项目也就不再活跃在开源界了。

Unicorn

2009年,Eric Wong在Mongrel 1.1.5版本的基础上开发了Unicorn。Unicorn是一个基于Unix/类Unix操作系统的、面向快客户端、低延迟和高带宽场景的Rack服务器,基于上述限制,任何情况下几乎都需要在Unicorn和客户端之间设置一个反向代理缓存请求和响应数据,这是Unicorn的设计特点所决定的,但也使得Unicorn的内部实现相对简洁、可靠。

尽管来源于Mongrel,但Unicorn只在进程级运行,且吸收和利用了一些Unix/类Unix系统内核的特性,如Prefork模型。

Unicorn由1个master进程和n个fork(2)子进程组成,子进程分别调用select(2)阻塞自己,直到出错或者超时时,才做一些写日志、处理信号以及维护与master的心跳链接等内置任务。子进程和master间通过一个共享socket实现通信,而由Unix/类Unix系统内核自身处理资源调度。

Unicorn的多进程模型

Unicorn的设计理念是“只专注一件事”:多进程阻塞I/O的方式令其无从接受慢客户端——但前置反向代理能解决这一问题;workers的负载均衡就直接交给操作系统处理。这种理念大大降低了实现复杂度,从而提高了自身可靠性。此外,类似Nginx的重加载机制,Unicorn也支持零宕机重新加载配置文件,使其允许在线部署Web应用而不用产生离线成本。

Phusion Passenger(mod_rails/mod_rack)

2008年初,一位叫赖洪礼的Ruby神童发布了mod_rails。尽管Mongrel在当时已经席卷Rails的Web服务器市场,但是面对部署共享主机或是集群的情况时还是缺少统一有效的解决方案,引起业内一些抱怨,包括DHH(也许Shaw就不认为这是个事儿)。

mod_rails最初被设计成一个Apache的module,与FastCGI的原理类似,但设置起来异常简单——只需要设置一个RailsBaseURI匹配转发至Rails服务器的URI串。mod_rails服务器会在启动时自动加载Web应用程序,然后按需创建子进程,并协调Web服务器和Rails服务器的通信,从而支持单一服务器同时部署多个应用,还允许按需自动重启应用服务器。

mod_rails遵循了Rails的设计原则,包括Convention over Configuration、Don’t Repeat Yourself,使其面向部署非常友好,很快得到了业界青睐,并在正式release时改名Passenger。

在随后的发展中,Passenger逐渐成为独立的Ruby应用服务器、支持多平台的Web服务器。截至2015年6月,Phusion Passenger的版本号已经达到5.0.10(Raptor),核心采用C++编写,同时支持Ruby、Python和Node.js应用。支持Apache、Nginx和独立HTTP模式(推荐采用独立模式),支持Unix/类Unix系统,在统计网站Builtwith上排名Ruby Web服务器使用率第一。

值得一提的是,Phusion Passenger的开源版本支持多进程模式,但是其企业版同样支持多线程运行。本文撰写时,Phusion Passenger是最近一个号称“史上最快”的Ruby Web服务器(本文最后将进一步介绍Raptor)。

Trinidad/TorqueBox

Trinidad发布于2009年,基于JRuby::Rack和Apache Tomcat,使Rails的部署和世界上最流行的Web服务器之一Tomcat结合,支持集成Java代码、支持多线程的Resque和Delayed::Job等Worker,也支持除Tomcat以外的其它Servlet容器。

与Trinidad相比,同样发布于2009年的TorqueBox不仅仅是一个Web服务器,而且被设计成一个可移植的Ruby平台。基于JRuby::Rack和WildFly(JBoss AS),支持多线程阻塞I/O,内置对消息、调度、缓存和后台进程的支持。同时具有集群、负载均衡、高可用等多种附加功能。

Puma

Puma——Mongrel最年轻的后代于2011年发布,作者是Evan Phoenix。

由于Mongrel诞生于前Rack时期,而随着Rack统一了Web服务器接口,任何基于Rack的应用再与Mongrel配合就有许多不便。Puma继承了前者的Parser,并且基于Rack重写了底层通信部分。更重要的是,Puma部分依赖Ruby的其它两个流行实现:Rubinius和JRuby,与TorqueBox类似拥有多线程阻塞I/O的能力(MRI平台不支持真正意义上的多线程,但Puma依然具有良好并发能力),支持高并发。同时Puma还包含了一个事件I/O模块以缓冲HTTP请求,以降低慢客户端的影响。但是,从获得更高吞吐量的角度来说,Puma目前仍然需要采用Rubinius和JRuby这两个平台。

Reel

Reel是最初由Tony Arcieri发布于2012年的采用事件I/O的Web服务器。采用了不同于Eventmachine的Celluloid::IO,后者基于Celluloid——Actor并发模型的Ruby实现库,解决了EM只能在单一线程中运行事件循环程序的问题,从而同时支持多线程+事件I/O,在非阻塞I/O和多线程方案间实现了良好的融合。

与其它现代Ruby Web服务器不同的是,Reel并不是基于Rack创建,但通过Reel::Rack提供支持Rack的Adapter。尽管支持Rails,与Puma也有一定的相似性,但与Unicorn、Puma和Raptor相比,Reel在部署Rails/Rack应用方面缺少易用性。实际上基于Celluloid本身的普及程度和擅长领域,相比其它Web服务器而言,Reel更适合部署WebSocket/Stream应用。

Yahns

2013年,Eric Wong等人受Kqueue(源自FreeBSD,同时被Node.js作为基础事件I/O库)的启发启动了Yahns项目。其目标与Reel类似,同样是在非阻塞I/O设计中引入多线程。与Reel不同的是,Yahns原生支持Rack/HTTP应用。

Yahns被设计成具有良好的伸缩性和轻量化特性,当系统应用访问量较低或为零时,Yahns本身的资源消耗也会保持在较低水平。此外,yahns只支持GNU/Linux(并通过kqueue支持FreeBSD),并声称永远不会支持类似Unicorn或Passenger里的Watchdog技术,不会因为应用崩溃而自动销毁和创建进程/线程,因此对应用程序本身的可靠性有一定要求。

四、迈向未来

回顾过去,Ruby Web服务器在发展中先后解决了缺少部署方案、与Web应用程序不兼容、运维管理困难等问题,基础架构趋于成熟且稳定。而随着更多基准测试结果的出现,业界逐渐开始朝着更高性能和并发量发展,同时针对HTTP协议本身的优化和扩展引入的HTTP/2,以及HTML5的WebSocket/Stream等需求均成为未来Ruby Web服务器发展的方向。

高吞吐量

以最新的Raptor(上文提到的Phusion Passenger 5)为例,其在网络I/O模型的选择上融合了现有其它优秀产品的方案,包括Unicorn的多进程模型、内置基于多线程和事件I/O模型的反向代理缓冲(类似Nginx的功能,但对Raptor自身做了大量裁减和优化)、以及企业版具有的多线程模型(类似Puma和TorqueBox);此外,Raptor采用的Node.js HTTP Parser(基于Nginx的Parser)的性能超过了Mongrel;更进一步,Raptor甚至实现了Zero-copy和一般在大型游戏中使用的区域内存管理技术,使其对CPU和内存访问的优化达到了极致(感兴趣的话可以进一步查阅这篇文章)。

Raptor的优化模型

另外也需要看到,当引入多线程运行方式,现有Web应用将不得不仔细检查自身及其依赖,是否是线程安全的,同时这也给构建Ruby Web应用带来更多新的挑战。这也是为什么更多人宁愿选择进程级应用服务器的方式——毕竟对大多数应用来说不需要用到太多横向扩展,引入反向代理即可解决慢客户端的问题,而采用Raptor甚至在独立模式能工作的更好(这样就不用花时间去学习Nginx)。

除非你已经开始考虑向支持大规模并发的架构迁移,并希望节省接下来的一大笔花费了。

HTTP/2

2015年5月,HTTP/2随着RFC7540正式发布。如今各主流服务器/浏览器厂商正在逐渐完成从HTTP/2测试模块到正式版本的过渡。而截至目前,主流Ruby Web服务器都还没有公开HTTP/2的开发信息。HTTP-2是在2013年由Ilya Grigorik发布的纯Ruby的HTTP/2协议实现,包括二进制帧的解析与编码、流传输的多路复用和优先级制定、连接和流传输的流量控制、报头压缩与服务器推送、连接和流传输管理等功能。随着HTTP/2的发布和普及,主流Ruby Web服务器将不可避免的引入对HTTP/2的支持。

WebSocket/流(Stream)/服务器推送事件(Server Sent Events,SSE)

2011年,RFC6455正式公布了WebSocket协议。WebSocket用于在一个TCP链接上实现全双工通信,其目的是实现客户端与服务器之间更高频次的交互,以完成实时互动业务。鉴于该特点,仅支持慢客户端的Web服务器就无法有效支撑WebSocket的并发需求,更何况后者对并发量更加严苛的要求了。而对于同样需要长连接的流服务器和服务器推送事件服务(SSE),都避免不了对长连接和高并发量的需求。尽管高性能的Ruby Web服务器都有足够的潜力完成这些任务,但是从原生设计的角度来看,更加年轻的Reel和Yahns无疑具有优势。

最近Planet ruby在Ruby邮件组发布了Awesome Webservers,该Github Repo旨在对主流Ruby Web服务器进行总结和对比,并且保持持续更新,推荐开发者关注。

本文灵感来源于RailsConf 2015的Session:”Riding Rails for 10 Years - John Duff”。

参考资源

http://www.fastcgi.com/devkit/doc/fcgi-spec.html

https://github.com/rails/rails/blob/master/railties/lib/rails/commands/server.rb#L82

https://github.com/shugo/mod_ruby

http://www.rubyinside.com/no-true-mod_ruby-is-damaging-rubys-viability-on-the-web-693.html

http://confreaks.tv/videos/railsconf2015-riding-rails-for-10-years

http://kovyrin.net/2006/08/28/ruby-performance-results/lang/en/

http://chneukirchen.org/blog/archive/2007/02/introducing-rack.html

https://www.wiredtree.com/blog/litespeed-replacement-for-apache/

https://utcc.utoronto.ca/~cks/space/blog/programming/SCGIvsFastCGI

https://www.litespeedtech.com/support/wiki/doku.php?id=litespeed_wiki:ruby_rails

https://www.colm.net/open-source/ragel/

http://zedshaw.com/archive/ragel-state-charts/

https://en.wikipedia.org/wiki/Comparison_of_parser_generators

https://blog.twitter.com/2010/unicorn-power

http://david.heinemeierhansson.com/posts/31-myth-2-rails-is-expected-to-crash-400-timesday

http://techcrunch.com/2008/01/01/zed-shaw-puts-the-smack-down-on-the-rails-community/

http://code.macournoyer.com/thin/

http://www.dre.vanderbilt.edu/~schmidt/PDF/reactor-siemens.pdf

http://www.cs.wustl.edu/~schmidt/PDF/proactor.pdf

http://www.infoq.com/articles/meet-goliath

http://2ndscale.com/rtomayko/2009/unicorn-is-unix

http://unicorn.bogomips.org/SIGNALS.html

http://nginx.org/en/docs/control.html

http://izumi.plan99.net/blog/index.php/2008/01/14/what-is-so-hard-about-rails-deployment/

https://blog.phusion.nl/

https://trends.builtwith.com/web-server

https://github.com/puma/puma

http://www.rubyraptor.org/how-we-made-raptor-up-to-4x-faster-than-unicorn-and-up-to-2x-faster-than-puma-torquebox/

http://ohcoder.com/blog/2014/11/11/raptor-part-1/

https://github.com/celluloid/reel

http://yahns.yhbt.net/README

https://en.wikipedia.org/wiki/HTTP/2

https://github.com/igrigorik/http-2

https://github.com/celluloid/reel/wiki/Frequently-Asked-Questions

https://en.wikipedia.org/wiki/WebSocket

https://en.wikipedia.org/wiki/Adaptive_bitrate_streaming

Comments

Microsevices陷阱: 安全

自计算机普及开始,安全问题就成为困扰产业发展的阿喀琉斯之踵。如今,安全问题爆发所造成严重后果的并不鲜见,事实上每一个产品参与者都承担了安全缺陷所带来的风险和损失。因此业界必须重视安全,理解并持续加固IT设施。当采用微服务架构后,安全问题的解决将面临新一轮挑战,更高的成本投入也是显而易见。

1. 认证(Authentication)和授权(Authorization)

在应用级,安全的第一道锁就是认证和授权。认证的目的是确认用户提交的身份信息是否属实,例如系统登录验证,而能够唯一识别用户的信息通常被称作Principal。授权是指依据用户的Principal允许其从事某些操作的机制。一旦Principal被验证,系统就能根据相关信息(例如Hierarchy)对用户进行授权。但在微服务架构下,由于服务的相互隔离性,Principal的传递面临挑战,毕竟用户并不希望访问任何独立服务都输一遍密码——这是一个很直观的用户体验问题。

单点登录(SSO)

SSO是最常见的一种认证和授权方案。SAML、OpenID Connect是当前业界比较流行的SSO实现,但基本原理相差无几,本文将以SAML为例简单介绍SSO技术。

当Principal试图访问系统资源时,首先由身份提供者验证其密钥信息,如果验证通过,Principal将被引导至服务提供者,并且由后者决定是否向Principal授权相关资源。

在上述过程中,身份提供者可以是第三方系统。例如,Google提供的就是基于OpenID Connect的身份提供服务,对于其它企业应用来说,这种身份提供者有权链接至组织内部的目录服务(可以是LDAP或Active Directory,此类系统能够存放Principles),也可以由自身直接提供数据服务。基于SAML技术的有Okta,也提供链接至企业目录的服务。

SAML本身基于SOAP标准,其背后的工程复杂度并不低;而OpenID Connect实际上是一种OAuth 2.0的特定实现,来源于采用SSO的Google和其它一些公司,后者使用简单REST调用。OpenID Connect的缺点是本身缺少支持它的身份提供服务,因此更广泛的用于互联网第三方登录机制,特别是在如今越来越多的公众互联网服务应用中。然而如果要采用组织内的身份提供服务,目前较好的方案是OpenAM和Gluu,但并不完善。这也成为OpenID Connect在统治SSO领域之路上的绊脚石,尽管它看起来确实是最终的选择。

单点登录网关

在微服务架构中,每个独立的服务都有权决定对应的身份提供者,显然这将造成大量的资源浪费。如果考虑采用共享代码库,还要规避异构技术栈的问题。因此这里给出一个基于SSO网关的有效解决方案。

该方法的核心是只在一处处理全部用户重定向请求和握手信息,并且要求下游服务都接收一个Principle,如果基于HTTP构建微服务通信,那么很自然地就可以利用HEADER解决信息承载的问题,Shibboleth就是其中一个实践方案。此外,如果认证服务被置于网关,就更难对隔离状态下的微服务进行调试,同时也不利于日常开发。

上述方法最大的问题是它可能给人一种绝对安全的错觉——导致越来越多的功能依赖网关进行传递。这么做的结果就是网关服务变得越来越臃肿,成为系统中一个巨大的耦合点,同时也提高了单点失败的风险,特别是它也是反安全模式的——功能扩充意味着增加攻击面,就更容易遭受攻击。

细粒度授权

网关能够提供一个有效但较粗粒度的授权。但是由于它只有Principle而无法做到深度解析,就缺少更细的授权功能,而后者更多需要微服务自身去负责。而对于用户Role这种Principle信息来说,围绕组织功能实现粗粒度授权——然后进一步在微服务中实现细粒度授权。

2. 服务间认证和授权

Principle通常用来指代人机交互过程中的认证和授权对象。随着微服务架构日益复杂,服务间也必然会出现类似的交互过程。下面列举了若干种当前常用的解决方案:

边界内畅通

最简单的可能是,当服务请求来自组织内部——那么自然被当作是可信的。或者更远一些,建立HTTPS通信以防止中间人攻击。缺点是一旦内网被攻破,那么内部系统几乎没有任何防御,这就是安全领域的单点失败,然而事实上这也是目前多数组织的选择。

HTTP(S)基础认证

HTTP(S)协议具有基本的认证机制,其在Header中携带一个用户名和密码,并由服务端进行认证,实现起来也十分方便。缺点是采用HTTP传输用户名和密码十分危险,你几乎始终需要HTTPS——但需要额外的证书管理成本。此外,基于SSL的通信无法被类似Varnish、Squid等反向代理缓存,也就是说,在这种情况下,缓存策略只能在应用服务端、或是客户端实施。一种解决方法是在外层搭建一个LBS用于解析SSL通信,并在LBS后端存储缓存。

另一方面,如果现有架构已经采用SSO,那么如何结合SSO也是一个难题。如果允许基本服务访问与SSO一致的目录服务,也可以另外搭建一份目录服务——这种重复功能的存在会导致更多的潜在风险。还需注意:如果采用身份认证方案,那就意味着拥有Principle就能够访问资源,而无论它身在何处。

采用SAML和OpenID Connect

直接应用SSO架构能够减轻一些开发成本,如果基于SSO网关,那就意味着所有的通信将路由至网关,否则就又相当于重复功能。此外,客户端服务需要妥善保存自身的证书,以便用于各类服务间通信的认证——这就需要第三方存储服务。此外,无论是SAML和OpenID Connect,其组织内应用的支持都还远未成熟。

客户端证书

另一种认证方法是采用TLS(相当于SSL的继任)的特性,由于TLS要求每个客户端都具备X.509证书,那么基于证书认证的通信可以保证安全性。问题是需要一个完整的证书管理机制,因为这不仅仅意味着创建和管理证书,同时还要验证证书正确工作。采用通用证书也许是一种方法,但会引起一定的安全风险。因此当通信安全要求较高时,才应考虑该方案。

基于HTTP的HMAC

HMAC指一种基于哈希值的消息码技术,它克服了HTTP基础认证的安全缺陷,同时能够在HTTP上实现类似HTTPS通信。该技术最初由Amazon的AWS S3 API实现,并且属于OAuth规范的一部分。HMAC通过计算消息体和私钥的哈希值,将结果封装进消息体本身。服务端同样保存了一份私钥,然后重算并比较消息体携带的值,如果二者结果一致,则被认为是合法的请求。HMAC能够有效防止中间人攻击,同时由于私钥本身不会被明文传输,因此能保证一定的安全性。同时比起HTTPS还拥有更好的计算性能。

HMAC的主要缺点在于,首先服务间需要共享相同的私钥,这种私钥可以是硬编码的(缺少灵活性),也可以通过第三方获取(需要额外设计私钥交换机制)。其次,当前HMAC并没有一种统一的实现,需要开发者自己决定实现细节,比如采用SHA-256。JSON Web Tokens(JWT)也是一种可行的方案,但依然缺少标准实现。最后,需要知道HMAC只能保证通信不被第三方篡改,由于消息体本身使用HTTP传输,依然会被网络程序嗅探。

API密钥

目前绝大多数的互联网服务都采用API密钥解决认证和授权问题。然而如果要直接用于微服务架构,还存在一些困难。首先,一些系统使用共享的API密钥,同时基于类似HMAC的方法进行通信,而也有部分系统采用公钥+私钥的形式。此外,密钥管理一般也是集中式的,类似前文提到的网关方案。

API密钥真正风靡的原因是其易用性,与SAML相比,基于API密钥的认证与授权几乎就是零成本。而且API密钥还能够用于频率控制、转化率分析、API目录、以及服务发现系统,具有相当的灵活性。一些API系统还允许将API密钥链接至现有目录服务,这样就能真正实现同步管理Principle和密钥,达到高可配置化。一种随之而来的复杂结构是:用户认证统一采用SAML实施SSO,然后取得API密钥用于服务间通信,二者共用一套目录服务。

代理问题

随着服务数量和调用层级增加,代理问题可能影响系统安全。如果采用传统单一系统的形式,服务调用和用户界面直接通信,因此SSO就能直接解决所有问题。但对于微服务而言,调用层级使得SSO不再有效。例如,当用户访问A服务,并且需要通过A服务调用B服务的借口时,对B来说现有SSO方案就无能为力,此时为了确保用户合法性,就只能在发生调用时携带原始Principle,并在B端进行重新认证。随着微服务架构的普及,此类应用场景会越来越多,代码和功能的重复性会显著提升。

一般而言,解决上述问题存在三种基本方法:1.忽略安全性,即隐式可信,一些安全性要求低的应用就无所谓了。2.前面提到的传递Principle。3.服务B向A请求Principle。但无论是哪一种,目前都缺少一个成熟的解决方案。

3.静态数据安全

静态数据Data at Rest,与使用中数据Data in Use,以及动态数据Data in Motion,分别描述了计算领域中的三种数据形态。使用中数据,一般指存在于内存、寄存器或逻辑计算单元中的数据。动态数据,主要指网络中传输的数据。而静态数据,主要指存放在物理介质中的数据。通常所说的安全一般都是针对使用中的动态数据,例如网络安全、系统安全和应用安全。然而如果上述安全措施不再有效,静态数据被窃取就会显得易如反掌——从而为业界引入了深度安全的概念。

无论如何,数据窃取事件的发生不外乎未加密存储、或是保护系统失效,在任何安全方案中,此类隐患是必须得到重视的。

尽量采用稳固的加密算法

如果要自己实现加密算法,安全性就很难保证。即使采用第三方加密算法,也需要时刻保证该算法是否会随时被发现漏洞并攻破。AES-128和AES-256是一种有效的静态数据加密算法,许多语言都内置了算法实现,Java和C#也可以采用Bouncy Castle Libraries。密码也应至少实现带盐哈希加密。

密钥存储

许多加密算法都引入了密钥环节,因此对密钥本身的保护也不容忽视,否则再强大的加密算法也是十分脆弱的。采用第三方系统管理密钥是必须的,或者直接采用类似SQL Server的透明数据加密。无论采用何种方案,都需要仔细分析相关的安全风险。

可选和必选

应有选择的加密静态数据——这不仅关系到应用性能问题。一方面,前文介绍的日志和监控需要明文数据,此外,数据移植也会因为引入解密、加密过程而变得繁琐和低效。因此,对数据进行安全性分级是必要的。此外,对高安全要求的数据,当数据获取后即加密,只在请求数据时解密——除此之外不要在任何形式下存储该数据。对于备份数据,应实现整体加密并妥善保存和管理。

4.深度防御

安全如今已经不仅仅是一个单一的概念,要实现高可靠的安全,必须采用综合、深度防御,摒弃单点失败带来的潜在风险。当防御因素增加,攻击者成本也就越高,系统安全性才能得到保证。

防火墙

防火墙依然在进化,相比过去的端口限制和包识别,像ModSecurity已经实现了限制IP段连接次数、主动监测某些恶意攻击等功能。同时,采用多层防火墙也是必要的,例如系统级可以采用Iptables,而在应用级,服务内部也可以设置防火墙进行自身防御。

日志

日志是把双刃剑,一方面,良好的日志结构能方便发现各种风险,包括安全问题。但是日志中的敏感数据本身也会造成风险,适当遮蔽这部分数据是有必要的。

入侵检测和防御系统(IDS/IPS)

与防火墙不同的是,入侵检测主要监控系统内部行为,并发出警告或选择阻止危险行为。但是IDS(IPS)在实施上需要投入长期的人力成本,现有的IDS基本都是基于启发式防御,其基本形式就是通过设置一些规则,当某些系统行为与该规则相匹配,则认为该行为有风险。但在实施过程中,特别是系统初期建设时,入侵规则的建立是和系统和架构特点息息相关的。因此通常应从一个被动式IDS/IPS开始,逐步完善入侵规则,再逐渐过渡到主动防御——才是有效且可靠的。

网络隔离

在单一系统中,由于应用设施都部署在同一环境,从而导致安全性的单点失败风险。而对于微服务架构,由于服务隔离性,本身就可以通过现有的网络管理机制增加安全性。例如AWS就提供一种自定义虚拟私有云VPC的服务,该服务允许主机处于相互隔离的子网中,从而通过定义网络规则指定服务间的可见性,或者指定网络通信的路由方式。

操作系统

操作系统级别的安全策略,主要集中在运行服务的用户最小权限、以及基础软件的漏洞修复速度。目前大型软件的更新都支持自动化,同时提供警告机制,例如微软的SCCM和RedHat的Spacewalk。

另一方面,操作系统的第三方安全模块也值得考虑。例如RedHat采用的SELinux,Ubuntu和SuSE支持的AppArmour,还有GrSSecurity,上述模块允许用户定义系统安全行为,并且直接监视内核行为,及时制止相关操作。

5.小结

在德语中有一个短语Datensparsamkeit,意思是当你需要存储数据时,应尽量保证只保存有效且合法的最小数据集,该定义来源于德国的隐私保护法案。实际上,当你不携带“有价值”的数据,那么自然就不会引起攻击者觊觎了。许多系统发生隐私泄漏事件,其本身就无权存储相关信息,这才是风险的源头。

另外,组织内部人员也是一大风险因素,如何处理权限的创建和删除、避免社会工程攻击、或者其他内部人员的恶意攻击,都是组织管理需要考虑的问题。

安全领域的另一大忌就是避免重复造轮子,因为很难得到充分的审议和验证,此类代码将成为极高的风险源。

最后,随着OWASP等的流行和安全测试框架的日益完善,可以考虑把安全测试引入到现有CI/CD流程,例如Zed Attack Proxy(ZAP),Ruby支持的Brakeman,Nesssus等等。微软提出的安全开发生命周期,也是一个有效的开发团队安全实施模型。然后就是定期邀请组织内外的安全专家逐轮审议、修订安全架构,以确保安全设施的持续更新。

Microsevices陷阱: 监控

微服务带来的是更高的架构复杂度,即使你有能力驾驭整体设计,也很难处理生产环境中的各种failures。监控在运维中是一项专门的技术,而对微服务而言则更具有必要性。原因在于,对于单一系统、或者是单点失败型系统而言,任何的错误信息都是有的可循的,然而对于微服务类似的自治系统来说,除非拥有良好的日志和监控系统,否则连发现问题都成问题。一个好的解决办法就是分布式日志数据抽取和聚合查询。

1. 系统架构对监控模式的影响

由于系统架构的不同,即使利用前述的思想,实施监控模式也有一定区别。

单服务,单节点

一切都变得如此简单,CPU、内存…日志和查询系统、平均响应时间、负载增长…甚至当你知道Nagios或New Relic,搭建此类系统就几乎等于零成本。当然,这并不意味着你就此精通监控技术……

单服务,多节点

由于各个节点的权重由LBS决定,因此监控时采用的系数可能会有差别。不过,通过把多个节点的运维数据聚合在一起,实现一次查询也是理想的方案。直观的,可以利用ssh-multiplexers类的工具同时在多节点上获取数据,并存储在第三方节点上向运维提供分析平台。

多服务,多节点

在遇到真正的微服务大杀器时,运维会遇到很多从未谋面的问题。例如在复杂架构下,如何判断某个服务的错误和功能错误有关(可调试性),如何在海量运维数据中找到具体的错误(可检索性),这些都成为眼前难题。而解决方案还是采集+聚合,无论数据属于日志或系统应用度量。

2. 日志系统架构

现在,开源界流行专门的日志采集工具logstash,和聚合查询系统Kibana,实际上就体现了一种良好的日志监控系统架构。logstash负责分布式日志数据采集和过滤,并将其存储至任何介质。Kibana是一个基于ElasticSearch的聚合查询工具,能够方便管理其中的日志数据,并提供可视化。但是,针对不同类型的日志数据也要采取不同的采集和管理方案。

跨多服务跟踪系统应用度量数据

系统应用度量,包括CPU、内存、网络请求/响应等基础数据,在复杂架构中,运维可能需要全局性数据,也可能需要针对单个服务的全部节点数据,也就是说,在元数据中必须加入相关的关联性,以保证日志数据的可用性。当数据准备就绪,Graphite这种实时可视化工具就能派上用场了。

服务度量

系统度量采集了除应用服务之外的几乎全部运维数据,而对应用本身来说,利用其自身的日志工具在多数情况下就足够了。但是,我们可能有时会遇到如下若干需求:希望查看所有用户检查其购物车的次数、希望查看用户操作的平均间隔时间…对于此类统计需求,一方面可以作为功能进行开发,但由于引入发布流程,本身缺乏灵活性,在多数场合下并不适用。另一方面,可以直接从日志数据中攥取所需信息。很显然,后者带来的好处更多,实现现有数据的更高效利用(但具体技术还在发展中,例如大数据技术),甚至可能挖掘出新的商业信息。因此,在任何关于日志数据挖掘的理论普及之前,好的实践应是尽量多的保存日志信息,因为其中可能蕴藏着未被发现的金矿。

3. 监控系统

综合监控和语义化

对日志系统的监控,通常要求实现一个对人的警告功能。但是在具体实践中,可能只是对CPU、内存甚至磁盘利用率设置一个阈值,一旦运维数据达到这个值就向运维人员发送警告。问题在于,现实中我们想在第一时间得到的信息其实是“系统在正常工作吗?”,而单个维度的超限可能无法等同于上述答案。

因此为了保证运维人员不至于整晚都睡不好觉,监控模型的改进还是很有必要的。除了针对底层运维数据的监控,从业务角度入手实现综合监控是未来发展的趋势,特别是在微服务架构下。例如,有时需要监控微服务间可用性,除了观察各服务的基础运维数据,还可以从业务角度入手,检查数据流的变化情况,以及健康度。这种更高层的监控,也被称作语义化监控,在实际中对运维人员实现了更高要求:理解业务和设计。

关联IDs

分布式系统的跟踪调试是一个世界性难题,实际中也很少有人能够遇到、甚至尝试解决此类问题。一般而言,微服务架构的日志系统至少应包含一个跟踪功能,否则一旦出现监控警告,我们能看到的只有直接服务代码,其上游成百上千的服务调用却一无所知——当然这种规模的系统没有几家公司拥有。Google在2010年发表了Dapper——其大规模分布式系统跟踪基础架构,Twitter随后在前者的研究基础上实现了zipkin——开源分布式系统跟踪框架。上述解决方案无一不具有“重”的特点,但基本原理类似:在服务间传递消息时,在消息头封装一个特殊的GUID,并将其写入日志或第三方系统。

目前来看,关联IDs是微服务架构必须要尽早考虑的问题,否则一旦出现问题就很难有充分的信息进行定位。而采用重量级框架带来的成本可能较高,理想方案是尽量简单实现类似功能,并集成进现有日志系统,如果需求复杂度进一步提升,就可以考虑引入大杀器灭之。

层级关联

本节开始提到了语义话监控的概念,它对于“系统正确运行”含义的代表可能要强于底层运维数据警告。但如果从定位问题的角度出发,即使发现问题存在,也不意味着立即定位问题,更谈不上提高可用性了。例如,两个独立服务分别运行良好,但服务间通信出现问题,导致数据无法正确传输,但现有功能依然存在,严重的话可能引起数据级别的错误,因此这种服务间集成点的监控成为必须要考虑解决的问题。

实践中针对服务层级的监控多引入一种名为断路器的工具,其用途是一旦发现通信中断,就立即断开当前服务与下游的通信,从而避免错误的持续传递造成灾难。Netflix的Hystrix是这一领域中基于JVM的开源框架。

标准化和可读性

日志/监控系统的重要内容就是标准化,当你采用微服务架构,标准化就更加重要——这恐怕是你唯一能够从整体上把握系统的切入点。另一方面,监控最终还是向人提供决策参考——因为角色的不同,不同的人对监控数据的理解也存在偏差,因此在设计监控系统时还需要考虑的重要问题:

  1. 人们当前需要什么数据。

  2. 人们随后需要什么数据。

  3. 人们习惯于如何消费这些数据。

至少你应该尝试读一下《Information Dashboard Design: Displaying Data for At-a-Glance Monitoring》,相信会把对监控的理解上升到人文的高度。

4. 总结与未来

监控领域非常得大,有时甚至超过产品本身——这会不会是一次大规模的历史性误区,目前还很难说。但现状是几乎所有的技术研发公司都在该领域发力,希望从此走上数据金矿的道路。传统上,我们利用自身简易工具监控系统应用,利用GA、Omniture抓取业务数据,利用日志系统定位问题。碎片化的工具方便实现精细化分工,但很大程度上阻碍了数据的进一步攥取,成为大数据之路上的绊脚石。

监控工具的融合进程目前依然缓慢,Riemann是现有的比较流行的分布式系统监控框架,其本质上是一个事件服务器,通过接收分布式系统的各类事件采集、聚合相关数据,但仍主要集中在系统应用监控方面。Suro是由Netflix开源的一套分布式数据管线框架,数据处理方式类似Riemann,但更集中于数据管线的功能,其下游应用包括Storm实时数据分析框架、Hadoop离线批处理框架以及Kibana日志分析平台。

当然,这种融合趋势并不会影响专注于不同领域的工具的发展,但统一数据接口是眼下应当开始的工作。

Microservices陷阱: 测试

层级良好的测试是保证持续集成/交付的前提。对微服务而言,随着系统复杂性的提高,原有测试层级会遭遇挑战,本文将在澄清现有基本概念的基础上讨论这一问题。

1.测试自动化

《Agile Testing》的测试四方图帮我们理清了测试开发的基本类型:验收测试、探索测试、单元测试和性能测试。当然这既包括手动、也包含自动化测试,这里本文只关注后者,但二者的地位应是同等重要的。

Mike Cohn在《Succeeding with Agile》中介绍了自动化测试的金字塔图,书中的金字塔把自动化测试划分为三种类型,从底向上分别是单元测试、服务测试和UI测试。其中,服务测试在大多数情况下指集成测试,而UI测试目前更多被称作End-to-End测试。关于这三种测试的概念这里就不再强调,但实践中更多会面临各自权重的问题。

测试权重

测试金字塔给出了以下结论:当层级越高,测试的范围越大,质量保证的可靠性也越高,但是反馈时间也越久,调试难度越大;层级越低则这种趋势会完全相反。因此,合理安排测试量是一个非常重要的问题。但应强调一点,上述测试并非是一次促成,固定不变的。例如服务/UI测试一旦出现问题,最便捷的解决方法是引入更多单元测试去覆盖上述边界条件。

但是无论如何也不要陷入误区:即使你有更多时间去完善UI测试,也不要忽视金字塔量级的规律。一种常见的反模式是测试量呈倒金字塔状,过度依赖UI测试,而忽略了单元测试。因为这么做忽略了反馈周期的因素,随着UI测试反馈周期的不断增加,必然降低整个团队的效率,也不利于代码质量的保证。

实现服务测试

微服务架构下的服务测试显得尤为重要,但实际上也面临集成的问题,特别是CI架构方面。例如采用CI实时启动一个构建好的消费者服务,或是直接在测试代码中stub——但需要花费额外代价去模拟stub的功能。提到stub,可能会引出mock的问题,后者关注行为,实际上是面向边际效应的一种测试手法,《Growing Object-Oriented Software, Guided by Tests》详细比较了上述二者。当然Martin Fowler的TestDouble也是一个直观的介绍。

现今的服务测试工具已多如牛毛,其基本原理还是stub一个消费者服务,运行测试前启动该服务即可。其趋势已是跨平台、跨语言了(参考Mountebank)。

微服务和UI测试

UI测试本身具有一定复杂度,更别说和微服务架构集成了。如果采用单一系统的做法,需要在CI上分别部署全部服务,然后触发UI测试——这不仅仅是花钱的问题,更意味着时间成本的增加。

UI测试中普遍存在某些代码片段,是时断时续的,这就导致开发人员可能会不断重复运行测试,因为这可能是一个随机产生的现象。对于这种不确定性的Flaky测试,应当尽量移除出代码库。Martin Fowler在Eradicating Non-Determinism in Tests中详细讨论了UI测试中存在的这类问题,并给出了一些解决方案。

另一个问题是UI测试的ownership分配。一般情况下,单一系统可能是全部团队成员维护一个UI测试代码库。但是这会引发测试健康值下降的问题,有时会派QA监控这一问题。而对微服务而言,UI测试可能是跨团队的,因此必须限制测试代码的提交权限,以及明确ownership。最多应覆盖相关的服务开发团队。

当然,微服务带来的UI测试爆炸也是一个比较严重的问题。尽管已经有一些并发框架例如Selenium Grid减少时间的浪费,但这些并不能实际解决测试爆炸的问题。终极方法可能还是精简当前测试部署,当然这涉及到UI测试的代码架构。

发布堆积

由于微服务的相互隔离性,一旦UI测试失败,在某个源头微服务修复前,其它微服务将无法被正确部署,从而导致发布包产生堆积现象。一种解决方法是一旦UI测试失败,停止所有的代码提交——当然这并不容易,特别是当变更较大时,修复的成本较高,可能导致全部团队效率的下降。因此保证小变更的频繁提交是一个有效减小风险的实践。

到这里你可能会对微服务产生质疑,既然End-to-End测试通过后才能发布,岂不意味着某一批版本的微服务将被同步发布?那么,这种部署方式还遵循微服务的独立部署原则吗?实际上,尽管可能把End-to-End测试作为一个限值,但微服务的独立性并没有遭到破坏,如果采用同一个发布版本,无意间就意味着微服务间的耦合性在增加,反而失去微服务本身的优势。因此保持平衡十分重要。

2.测试代码架构

当测试代码开始融入codebase,就要考虑设计的问题了——因为你不得不去考虑功能代码中遇到的同样问题。但由于根本目的不同,测试代码的设计存在不一样的可能。

用户轨迹,而非用例

对于UI测试,按照敏捷的一般实践,可能很自然地实现针对每一个story的测试。面向用例的UI测试带来的是大量重复性测试,这显然不利于控制UI测试的成本。另外,从探索测试的角度来说,遵循普通用户轨迹的UI测试可能才是接近真实情况的。因此,UI测试的组织应尽量面向用户轨迹,特别是核心功能的用户轨迹。

消费者驱动测试和Pact

前文已经提到,基于stub/mock的服务集成测试已经比较常见,但是,尚没有一个能够保证集成测试尽快响应代码变更的机制。例如,当某个服务发生变更时,就需要考虑修改其它服务实现的它的stub,否则就会降低测试结果的可靠性。一种方法被称作消费者驱动的契约测试,其基本出发点是开发能满足消费者需求的服务,同时尽可能保证实时同步相互之间的变更。其基本过程就是由消费者一方提出需求,并构建契约文本,再与服务提供方达成一致,实现相关功能。

Pact是一个开源的基于消费者驱动的测试工具,分别基于Ruby、Java和.Net。而Pact生成的契约文本通常是JSON格式,这就形成了跨功能的契约传递。Pact生成的契约可以通过任何形式存储,例如CI/CD的交付物、或Pact Broker版本管理工具,后者允许服务提供者能够同时对多个版本的消费者服务运行集成测试。另一个工具Pacto,实际上记录了服务间的交互过程,并形成消费者驱动的测试,比起Pact它更为静态化,而Pact则被嵌入到测试内容中,随着功能变更实时变化。

集成测试和UI测试

那么问题来了,前文提到UI测试是一项非常耗成本的工作,针对微服务尤为如此。随着集成测试的有效引入,是否就意味着降低甚至移除UI测试?答案是未必。

UI测试能够让许多问题在发布前最后一刻暴露出来,从而避免灾难的发生。因此,在发布时间不是非常紧急的情况下,运行UI测试反而能够降低人工成本。如果发布在即,可能来不及运行UI测试,那么撰写UI测试可能就显得没有特别必要了?现在针对微服务架构已经有一种做法,直接在生产环境中运行UI测试,而整个测试过程需要通过语义化监控技术记录全程(关于监控和语义化监控,我们会在下一篇文章中介绍)。

3.发布后测试

如果测试只在发布前进行,当出现线上bug,测试可能就显得无能为力,只能待开发人员修复功能后,预上线环境重测。问题的本质在于,测试很难完整模拟用户行为,特别是你永远都不完全了解用户是怎样使用系统的。为了尽早发现bug,在发布后测试是一个有效方法。

一次发布,分别部署

我们已经知道,微服务建议独立部署各自的服务,那么在发布前针对单一服务的测试可能显得无力。如果先部署服务,再运行针对该服务的测试,就能快速发现很多问题。这种测试方式的一个典型案例就是“冒烟测试”,即快速运行针对新部署服务的测试。

一个稍复杂的例子是蓝/绿部署。这种形式下,系统存在两份相同的拷贝,但只有其中一个真正接收外部请求。例如现有正常服务A,当更新A+发布时,先将A+部署到另一个环境,运行冒烟测试,通过后再把生产环境的流量导入到A+。该做法还能保证即使出现更多失误,也能快速回滚代码。然而,蓝/绿部署需要额外的成本,首先你需要大量环境容纳各种版本,并且能快速切换流量(基于DNS或LBS),当你采用弹性云服务实现这些就很方便。

金丝雀发布

相比蓝/绿部署,金丝雀发布则更为强大(要求也更高)。在这种发布形式下,新服务部署后,会由导流工具引入一部分生产环境中的流量,然后通过比较两个共存版本间的各种监控数据来保证功能的正确性,一旦新服务的出错率明显上升,则切断该部分的路由,反之则切断原有服务的路由。金丝雀发布的生产效率更高,但技术要求也更多,特别是针对幂等规则下的请求/响应通信,如何实现无缝导流会是一个难题。

MTTR和MTBF

MTTR指平均修复时间,MTBF指平均错误间隔时间,这两种概念实际上体现了不同的运维策略。无论是蓝/绿部署还是金丝雀发布,其出发点都是承认线上错误是不可避免的,因此MTTR是此类策略更关注的内容。而要保证MTBF,必须在上线前实现充分测试,其花费的成本也是十分可观。因此在实际中,MTTR和MTBF只能在权衡下保证,除非你有不计成本的投入(如重大的、允许失误率极低的项目)。而按照实际经验判断,MTTR可能是面向普通业务更为现实的选择。

4.跨功能测试

如今我们逐渐开始关注非功能测试,例如性能测试。但从词义上理解,把非功能测试看作是“非功能的”可能并不准确,因此这里采用“跨功能测试”一词。 跨功能测试在测试四方图中占有一席之地,因为其实际上十分重要,但在大多数项目中此类测试通常都启动得太晚,以至于出现了额外的超额工作。要知道,跨功能测试从重要性、架构复杂度和维护需求上几乎和所谓的功能测试并无差别,只是更容易在初期为人所忽视而已。

例如性能测试,在End-to-End测试阶段,这种测试更类似负载测试,而在单元测试阶段,则属于Benchmark测试。因此结合功能测试实现性能测试是一个有效途径。但应注意性能测试对环境的真实程度要求更高,可能会产生更多额外成本。但至少在当下,应尽量开始Benchmark及相关的尝试。

5.小结

总结上述这些与测试的有关经验,我们可以列出以下几点主要内容:

  1. 通过细分测试,优化快速反馈流程,减少项目风险。

  2. 通过消费者驱动的测试避免微服务架构下的UI测试。

  3. 采用消费者驱动契约建立微服务团队间的沟通渠道。

  4. 理解MTTR和MTBF的区别,以及实际运维中的权衡。