OPPO互联网技术

OPPO互联网技术 查看完整档案

深圳编辑  |  填写毕业院校OPPO  |  技术 编辑 www.oppo.com/cn/ 编辑
编辑

前沿OPPO互联网技术干货及活动分享,招聘、内容合作、投稿,请邮件internettech@oppo.com,也欢迎关注我们的公众号“OPPO_tech”,等你哟~

个人动态

OPPO互联网技术 发布了文章 · 1月6日

揭秘QUIC的性能与安全

本文来自OPPO互联网技术团队,转载请注名作者。同时欢迎关注我们的公众号:OPPO_tech,与你分享OPPO前沿互联网技术及活动。

在当代网络通讯中,高速且安全的网络接入服务已为互联网厂商的共同追求。针对传统的TCP + TLS的安全互联网服务,在各大头部互联网厂商中反响激烈,如google有提出各种升级和补丁方案,例如TCP fastopen, TLS1.3等,而基于TCP的传统体系已经有运行几十年,形成了固化甚至僵化的网络基础设施,导致补丁升级或者新方案融入变得非常困难。为了更体系化更自然且原生的解决这个问题,google从UDP的途径另辟蹊径创建性能更好、安全性更高的QUIC(Quick UDP Internet Connection)。IETF也把QUIC作为HTTP3的标准基础通讯设施。

image.png

1. 基于TCP+TLS的低效传输

在web的世界里,我们以一次简单的https请求如以www.XXX.com为例,从发出请求到收到数据,我们可以很容易的从tcpdump和wireshark中发现整个链路中数据的通讯过程以及在这个过程中数据经历了一个什么样的流动,同时我们也可以很清楚的看到在真正用户数据之前需要做哪些前期准备。

第一,域名解析,域名解析一般由你使用网络运营商的DNS服务提供,运营商的DNS服务一般都有缓存,这个缓存会缩短网络时间。

其次,需要经历TCP建立连接的三次握手,这需要消耗1个RTT(最后一个ACK可以随数据一起发送);

接下来是TLS的握手操作,就以当前使用最为广泛的TLS1.2来说,四次TLS握手需要消耗完整的2个RTT;

前面做了这么多铺垫,最后才是真正的用户数据传输,假设用户的数据量很小,一个数据包就传输完了(比如1K左右),只需要一个1RTT。那么这里的数据传输效率就显得很低了,整个传输过程耗费了DNS时间+4个RTT的通讯时间,其中只有一个RTT是在承载用户数据,传输效率只有25%。可以从下面的数据传输序列图中清晰的看到每个阶段的通讯交互情况。

image.png

2. QUIC的性能提升

从上面的分析中可以看到,基于TCP的HTTPS传输所带来的额外开销变得很大,如何降低这75%的额外开销就变成了人们在网络传输优化路上一直追求的目标了。去掉TLS则安全性完全无法得到保障,明文传输,中间人攻击等等对数据在互联网上传输带来的巨大安全威胁。

传统的TCP性能提升做法

在安全日益成为人们生活必须部分的过程中,TLS现在只会增强,而不可能再去掉了。那么该如何提升数据的传输效率呢,在TLS层面后来出现了一些优化手段,例如TLS的握手链接复用等。使用sessionID或者ticket的方式来减少TLS握手的数据交互次数,使原本需要两个RTT的TLS握手在一部分情况下(产生连接复用的情况,例如曾经成功握手并且在有效期内)变成一个RTT,节省出来一个RTT使得效率可以从25%提升到33%。不仅仅是TLS层,在TCP上像google这类全球顶级的科技公司也一直致力于优化TCP的性能。其中有提出像TFO(tcp fastopen)这样的新特性用来提升tcp的传输效率,但是这些都有较高内核版本的要求,然而在全球来说整体网络TCP层的基础设施基本已经僵化,导致补丁升级或者新方案融入变得非常困难。

QUIC使用UDP的1-RTT握手效率提升

人们追求性能极致的脚步是无法阻挡的,在TCP基本僵化的情况下,google另辟蹊径,率先实验采用UDP来重新改写整个传输方案,逐渐形成了一套新的传输方案gQUIC。随后google向IETF提交实验性传输层网络协议QUIC的提案,在2016年11月国际互联网工程任务组(IETF)召开的第一次QUIC工作组会议,受到了业界的广泛关注。这也意味着QUIC开始了它的标准化过程,成为新一代传输层协议,形成了最新的iQUIC。在原始的gQUIC中是google自己设计的一套类似TLS的传输加密协议,然后当QUIC进入IETF后,随着整个QUIC的标准化进程,加上近年来出现TLS1.3版本,在性能和安全性上都有很大的提升,IETF在QUIC的加密协议上就放弃了google的加密协议使用了标准的TLS1.3。

image.png

QUIC的通讯过程在初次没有建立过连接时使用1-RTT的握手机制,同时保证连接的建立和达到安全的保障。以下是QUIC的1-RTT的握手过程:

  1. Server端会持有0-RTT公私钥对,并且生成SCFG(服务端的配置信息对象),把公钥放入SCFG中;
  1. 客户端初次请求时,需要向服务端获取0-RTT公钥,这个需要消耗一个RTT,这也QUIC的1-RTT的所在;
  1. 客户端在收到0-RTT公钥以后会缓存起来,同时生成自己的临时公私钥对,经过前面的一个RTT后客户端把自己的临时私钥与服务端发过来的0-RTT的公钥根据DH算法生成一个加密密钥K1,同时使用K1加密数据同时附送自己的临时公钥一起发送服务端,此时已有用户数据发送;
  1. 在服务端收到用户使用K1加密的用户数据和客户端发来的临时公钥以后,会做如下几件事:
  • 使用0-RTT私钥与客户端发来的临时公钥通过DH算法生成K1解密用户数据并递交到应用;
  • 生成服务端临时公私钥对,使用临时公私钥对的私钥,与客户端发来的客户端临时公钥,生成K2加密服务端要传输的数据
  • 把服务端的临时公钥和使用K2加密的应用数据发送到客户端
  1. 客户端收到服务端发送的服务端临时公钥和使用K2加密的应用数据后会再次使用DH算法把服务端的临时公钥和客户端原来的临时私钥重新生成K2解密数据,并且从此以后使用K2进行数据层的加解密

备注:

这里服务端为什么要重新再生成临时公私钥对再使用DH算法来生成加密密钥K2呢?其核心考虑到的是安全性,如果没有服务端的临时公私钥和K2,那么在通讯过程中使用的K1是不安全的,因为服务端的SCFG中的0-RTT公私钥是对所有客户端,并且长期保持直到过期,而且这个过期时间一般会比较长。一旦服务端的0-RTT私钥泄露则所有客户端的通讯都无法确保前向安全性了。攻击者只需要把包抓下来,获取到0-RTT私钥即可破解所有通讯数据。

QUIC的0-RTT握手效率极大提升

0-RTT是QUIC一个很关键的属性,能够在连接的第一个数据报文就可以携带用户数据。但是我们也可以看到如果客户端和服务端从来没有通讯过,那么是不存在0-RTT的,需要一个完成的RTT之后才能承载用户数据。

image.png

这个是QUIC的1-RTT过程,那么他的0-RTT又是怎么做的呢?其实很明显,客户端把0-RTT的握手公钥和相关信息保存起来,后续再建连接的时候就可以直接使用之前保存的数据了,只要这个数据没有过期,服务端都会承认的。因此可以避免掉公钥发送的这一个RTT,直接生成K1加密用户数据传输。

image.png

这个流程是gQUIC的流程,iQUIC由于使用的是TLS1.3,握手阶段报文的细节会有些不一样,例如首个请求的是证书、PSK等信息。在0-RTT阶段使用的是session复用的ticket方式。

从上面的分析我们可以看到QUIC在握手阶段的性能提升是很大的,最大延迟只增加一个RTT,性能上可以保持和基于明文的http一致,但是安全性可以和https保持一致。如果使用了0-RTT特性,将会更高效的提升数据效率,但是在安全性上会略有下降,因为0-RTT特性必然存在重放攻击。总体来说用户数据的传输效率有不小的提升,从原来最快的33%提升到了50%,0-RTT情况下甚至提升到了100%,从第一个报文就开始传输用户数据。

不管是gQUIC还是iQUIC,都把连接的管理和安全性合二为一融为一体,让传输协议具有原生安全属性。

3. QUIC的安全性分析

QUIC带来如此大的性能提升,是否就标志我们可以不顾一切,奋不顾身的把所有流量全部切入到QUIC呢?接下来我们一起看看QUIC的这些新的特性在安全性上如何,安全人员以及QUIC的使用人员需要注意哪些安全问题,以及QUIC在当前阶段在哪些类型的业务上使用是收效最大,哪些业务不适合QUIC或者不适合QUIC的某些特性。

协议不成熟性与产品稳定性带来的问题

QUIC作为一个新生代的协议,包括是拥塞控制算法也好,其他安全策略也好,成熟度都还不是特别高,当前在生产环境也没有一个通用性和兼容性成熟的实现,各个厂家都是根据自己的情况来实现QUIC部分的特性,还有部分特性并没有完全实现,或是实现的机制有待商榷,亦或是导致某些安全特性牺牲掉了,因此在使用过程中都会遇到各种各样的问题。在这种情况下,作为中小厂商使用一些开源实现的QUIC服务会带来很多问题,诸如安全性、稳定性、可靠性、资源消耗等等都会遇到很多挑战。完全自研实现却又需要耗费大量的人力物力,所以其实现在不太建议中小型厂商在QUIC上完全跟进。还是应该等待协议成熟,有成熟的产品以后再切入。

SCFG的签名计算安全问题

image.png

从前面的分析,我们可以看到SCFG的重要性非常关键,在0-RTT的场景完全依靠这个数据来获得0-RTT握手公钥,而且需要在客户端和服务端传输流转。那么它的安全可信就非常重要,QUIC是如何保障呢,如何防止中间人攻击呢,是否会带来其他安全风险?

在QUIC中给这个数据增加了一个签名机制,同时也设置了过期时间来保障安全性。签名是通过公有证书的私钥来签的,在客户端需要对证书进行认证,这样可以确保无法实现中间人攻击,SCFG的过期时间也可以很大程度上缓解SCFG被恶意收集。我们都知道签名是使用非对称算法来做的,如果使用RSA做签名则会使服务端的签名耗费大量的计算资源,借此攻击者可能会对服务端产生算力攻击导致DoS,在生产环境中需要使用硬件加速卡来offload签名计算,这样可以对其有效进行缓解,同时使用ECC的证书做签名也会比较好的计算性能。

0-RTT公钥泄露带来的安全问题

image.png

0-RTT公钥在客户端可以保存,在服务端也几乎能持久保持且各个客户端是共享的,同时在0-RTT的通讯过程中,0-RTT的第一个数据包就会携带用户数据(使用上述性能分析中的K1加密用户数据),因此带来的安全问题很明显,失去了前向安全性。因为数据包被抓下来之后一旦服务端0-RTT私有被泄漏则数据就可以被破解。

0-RTT前向安全性的破坏可以在下图中看到具体的过程

image.png

0-RTT造成重放攻击安全问题

任何0-RTT机制都无法避免重放攻击(不伦是TLS1.3的0-RTT,还是gQUIC的0-RTT机制),所有0-RTT机制在提升性能的同时也是对安全性有一个很大损伤的特性。

image.png

0-RTT不提供前向安全能力(PFS)

0-RTT首包没有源地址验证能力

0-RTT之后或者握手之后QUIC/TLS1.3提供了key exchange机制保障了PFS

前面的分析我们也看到了,0-RTT是没有前向安全能力的,数据可以持续被抓包,等待握手私钥泄露就可以破解数据。QUIC的非0-RTT数据包都有提供源地址的验证能力,在有风险的场景下可以发起源地址挑战验证。QUIC提供了一个STK机制,在客户端第一次发送数据包时服务端会根据数据包的源地址和服务器的时间戳等因子生成一个源地址TOKEN(STK),随后和响应数据包一起发送到客户端,而在后续的数据传输过程中客户端需要透传这个STK到服务端,从而服务端可以进行校验。当然这里服务端会为了性能考虑并非每次都会校验,而是在发现源地址和连接ID的对应关系发生变化或者连接迁移时会发起验证挑战。然而在0-RTT的首个数据包上无法进行此次验证。

对于安全性要求比较高的业务操作,例如具备有POST或者PUT操作时为了确保安全性通常会把0-RTT关闭,包括像facebook或者cloudflare也都是在一些关键操作上禁用0-RTT功能,只有幂等操作(如GET、HEAD等)才使用0-RTT。

重放攻击的过程:

image.png

UDP相比TCP的弱安全性问题

UDP的安全性存在的几个关键的地方,源地址欺骗攻击,UDP放大攻击等。在QUIC中有设计了源地址TOKEN(STK)验证的安全机制来解决源地址的欺骗攻击,在通讯过程中服务器要求确认客户端的源地址TOKEN,这个源地址TOKEN根据数据包的源地址和服务器的时间戳等因子生成STK,随后和响应数据包一起发送到客户端,而在后续的数据传输过程中客户端需要透传这个STK到服务端,从而服务端可以进行校验。当服务端发现连接对应的源地址发送变化时会主动发送RETRY报文进行服务端主动源地址验证。客户端也可以主动发起源地址验证信息。源地址验证可以保护两类攻击问题,源地址欺骗攻击和UDP放大攻击。

  • 连接建立时,为了验证客户端的地址是否是攻击者伪造的,服务端会生成一个令牌(token)并通过重试包(Retry packet)响应给客户端。客户端需要在后续的初始包(Initial packet)带上这个令牌,以便服务端进行地址验证。
  • 服务端可以在当前连接中通过 NEW_TOKEN 帧预先发布令牌,以便客户端在后续的新连接使用,这是 QUIC 实现 0-RTT 很重要的一个功能。
  • 当我们的网络路径变化时(比如从蜂窝网络切换到 WIFI),QUIC 提供了连接迁移(connection migration)的功能来避免连接中断。QUIC 通过路径验证(Path Validation)验证网络新地址的可达性(reachability),防止在连接迁移中的地址是攻击者伪造的。

image.png

由于握手的不对称性,还可以造成放大攻击:

image.png

QUIC协议的制定对放大攻击提供了一些缓解的方式如下:

  • 由于在0-RTT阶段缺失源地址验证能力,但要求完全填充数据包大小,使得放大系数大于<1;
  • 提供retry机制做源地址挑战验证;
  • 非0-RTT包源地址验证STK,STK的过期机制
  • HTTP / 3具有速率限制功能和短暂的验证令牌,可以充当DDOS攻击的补偿控制,同时部分缓解攻击情形

连接迁移造成的源地址欺骗和路径欺骗的安全问题

当我们的网络路径变化时(比如从蜂窝网络切换到WIFI或者NAT后的地址重绑定),QUIC 提供了连接迁移的功能来避免连接中断。

image.png

QUIC当源地址发生改变后并不马上终止传输进行地址验证挑战,而是基于性能等原因的考虑继续当前传输,在随后再进行源地址验证挑战或者路径验证挑战。那么在这中间会形成一个空档期可以进行地址欺骗攻击甚至是放大攻击。一些能够缓解的方式主要是在地址变动后可以做限速配置,直到完成新的挑战成功或者挑战失败退回到上一个连接。同时在验证失败后需要恢复到旧的有效连接以防止重置链路攻击。

当前的特性场景需要注意的点

由于当前QUIC协议的成熟性和安全性都存在有不足,所以在当前阶段有很多需要注意的点,以下做了一个简单的分析。

数据修改等场景需要限制使用

由0-RTT导致的前向安全性缺失和可重放攻击的问题可以看出,在很关键的场景例如转账操作等是不是适合开启0-RTT特性的,因为重放攻击会带来非常大的数据被篡改的问题。在业界很多顶级公司对安全非常关注,一般只会在幂等操作如GET操作等场景才开启0-RTT,其他场景会禁用0-RTT功能,甚至连接迁移的功能也是有限使用

有高安全场景需求的业务需要谨慎使用

对应安全性高的业务场景例如账号等在使用QUIC的时候当前也是持谨慎态度,握手阶段或者连接迁移阶段容易发生源地址欺骗可能会造成账号异常

视频、游戏等高实时要求场景的效果明显

由于QUIC在首次连接和网络类型切换过程中有很好的性能提升,对于下载类业务、视频类业务。尤其是在秒开场景会有非常好的提升效果。这类业务当下比较好的方式是信令数据走单独从传输方式(例如TCP等),而视频数据类可以走QUIC,可以更好的兼顾安全性与传输效率。

4. QUIC在OPPO的实践

OPPO在海外的业务发展非常迅速,用户量成指数级增长,已经达到亿级规模。在海外尤其是印度、印尼等东南亚地区网络的覆盖情况比较差,使用https链接的成功率比较低,下载延迟也比较大。同时用户在WiFi和4G的切换过程也非常多不稳定的情况,有些地方甚至出现QoS限流的情况。如何提升网络体验已经成为我们面临的非常大的难题。

安全增强的架构

为了更好的提升用户体验,解决这些问题,以及OPPO在技术上的极致追求,非常积极的跟进业绩先进技术的发展,在一些用户高体验场景下采用QUIC协议来提升用户体验。同时OPPO是一家非常关注安全的企业,在使用QUIC之前做了大量的调研工作,也对QUIC带来的网络性能提升以及带来的安全问题做了上面细致的分析。

下面我们列举了几个OPPO在安全上重点考虑的几个关键的点:

  • 在QUIC协议的基础增加WAF的支持;
  • 对于0-RTT特性谨慎使用;
  • 安全治理模块来对接QUIC的地址验证;

image.png

为了提升QUIC的安全能力,我们在使用QUIC基础支持WAF安全能力,把WAF直接融入到安全QUIC服务的处理中。同时从QUIC里面抽象出安全治理模块可以在很多场景直接发起对源地址的验证挑战。

在0-RTT的使用上,我们采用只有在幂等操作(如GET、HEAD等)的场景下才允许使用O-RTT,0-RTT作为一个非常谨慎使用的模块,在前期甚至都不支持这个特性。

性能实验结果

在OPPO弱网实验室的测试效果如下:

image.png

从实验室数据我们可以看到有非常好的提升,然而线上环境多变且复杂,在线上我们采取了谨慎的策略,目前部分海外业务已经陆续灰度上线。而从上线的效果来看,在弱网环境下对延迟的提升还是有比较好的效果,对于延迟大约有11%的提升,目前还只有部分灰度,线上环境相对比较复杂,可能有的用户本地网络对UDP的支持并不太好,在线上环境的拥塞控制也需要根据现实情况来单独优化。接下来还有许多功能特性需要持续支持,包括线上环境的拥塞控制,UDP包的底层处理通过DPDK来提升处理性能,提高系统利用率和吞吐。QUIC的安全治理模块目前还是实验阶段,后续还需要持续优化和调整,以达到更好的安全效果,平衡安全与性能。

IETF指定了QUIC作为HTTP3的传输承载协议,征求意见文档(RFC)有望在2021年会发出。届时会有更多的网站和应用程序会跑在QUIC上,同时业界头部厂商(google,facebook,腾讯,阿里,华为等)已逐步开启对QUIC的支持,相信其安全能力也会在随后的大规模运行中更加完善。对应我们而言,QUIC是一个起点,我们会更好的提升人们在OPPO上的用户体验。

image.png

查看原文

赞 1 收藏 0 评论 0

OPPO互联网技术 发布了文章 · 1月5日

深入理解Flutter的图形图像绘制原理——图形库skia剖析

本文来自OPPO互联网技术团队,转载请注名作者。同时欢迎关注我们的公众号:OPPO_tech,与你分享OPPO前沿互联网技术及活动。

Flutter是目前流行的高性能跨平台UI框架,图形库skia是其跨平台的基石。本文将深入分析skia的图形、字体、图片的渲染原理,如何挖掘硬件特性,为UI性能优化提供思路。

1. 引言

Flutter是目前非常流行的跨平台UI开发框架,不仅支持Android、iOS,还支持Windows、Linux等操作系统。Flutter的性能非常高,拥有120fps的刷新率。那么flutter是如何实现在不同平台上高性能绘制图形图像的呢?首先我们分析下Flutter App和原生Android App、原生iOS App的UI绘制原理。

image.png

移动App的整体UI框架大致分成下面4个层次:

1)UI库

跟Android、iOS原生开发类似,Flutter用dart语言实现一整套UI控件。Flutter先将控件树转成渲染树,然后交由skia库绘制界面。

2)图形库

Skia图形库跟iOS平台的CoreAnimation等库功能类似,不仅提供了图形渲染功能,还提供文字绘制和图片显示功能。高级图形图像库将需要绘制的图形转成点、线、三角形等图元,再调用底层图形接口实现绘制。

3)低级图形接口

OpenGL是使用最广的低级图形接口,兼容性最好,基本上支持市面上的所有GPU。Vulkan是最近几年新推出的图形API,除了iPhone的GPU,其他厂家的GPU基本都支持。Metal是苹果新推出的图形API,只支持自家GPU。

4)硬件设备层

目前的移动设备出于性能考虑,大部分图形都是通过GPU渲染,少数情况也会使用CPU渲染,后文会介绍skia使用CPU和GPU渲染的具体场景。

iPhone 在A11芯片以前使用power vr系列GPU,之后采用自研GPU。安卓手机大部分采用高通Adreno GPU或ARM mail GPU。GPU渲染完一帧图像后送FrameBuffer,最后在合适的时机展示在屏幕上。

Skia应用广泛并且跨平台,不仅用于Flutter和Android操作系统,还用于Google Chrome浏览器,同时支持windows、Mac、iOS操作系统。Skia由C++编写,代码开源,通过研究skia有助于理解图形图像的绘制原理,为UI性能优化提供思路。

2. skia 框架分析

2.1 Skia外部组件依赖

Skia依赖的第三方库众多,包括字体解析库freeType,图片编解码库libjpeg-turbo、libpng、libgifocode、libwebp和pdf文档处理库fpdfemb等。Skia支持多种软硬件平台,既支持ARM和x86指令集,也支持OpenGL、Metal和Vulkan低级图形接口。

image.png

2.2 Skia 层次分析

Skia在结构上大致分成三层:画布层,渲染设备层和封装适配层。

image.png

2.2.1 画布层

画布层可以理解成提供给开发者在一个设备无关的画布,可以在上面绘制各种图形,且不用关心硬件细节,功能如下:

类别函数名含义
画图形drawPoints画点
画图形drawRect画矩形
画图形drawVertices画多边形
画图形drawRoundRect画圆角矩形
画图形drawArc画圆弧
画图形drawOval画椭圆
画图形drawPath画矢量图
绘制文字drawText显示文字
显示图片drawBitmap显示位图

2.2.2 渲染设备层

渲染设备层负责画布层的硬件实现,skia将设备封装成下面三个类:

1)SKBitmapDevice

CPU渲染模式绘图,用于没有显卡或者显卡驱动的设备。此模式下,最后会将需要绘制的图形转成位图数据(RGB)写入指定内存,故称为BitmapDevice。写内存操作通过AVX或者NEON指令集实现。

2)SKGPUDevice

GPU渲染方式绘图。目前大部分移动设备和个人电脑都有GPU,GPU比CPU的运算单元多,并行计算能力强,通过GPU绘图可降低CPU占用,性能更好。Flutter、最新版本的chrome和android系统默认设置为GPU渲染模式。Chrome中的配置截图如下,可看到默认采用GPU渲染。

image.png

3)SKPDFDevice

选用此设备时,渲染结果不是输出到显示器的画面,而是输出为pdf文件。

可以通过skia官网在线体验不同设备的渲染结果:https://fiddle.skia.org/c/@sh...

2.2.3封装适配层

Skia为了屏蔽不同依赖库的接口差异,对依赖库进行了封装和适配。例如基于图片编解码库libjpeg-turbo、libpng、libwebp 封装了类SKJpegCodec、SKPngCodec、SKWebpCodec。基于底层图形库OpenGL、Metal、Vulkan封装了GrGLOpsRenderPass, GrMTOpsRenderPass, GrVKOpsRenderPass三个类。基于苹果平台CoreText字体库和开源字体FreeType封装了类SkScalerContext_Mac和SkScalerContext_FreeType。

Skia的外部依赖和层级结构讲解完毕,下面重点讲解skia的图形、文字和图片的绘制原理。

3. 图形绘制原理

Skia支持绘制的图形众多,包括圆形、椭圆、矩形、贝塞尔曲线等。下文重点分析图形的CPU和GPU两种渲染模式的原理。

3.1 图形CPU渲染原理

曲线的绘制涉及的数学知识较多,本文不再展开,下面以绘制实心矩形为例说明原理进行剖析。

image.png

1)调用画布的绘图API

应用层调用画布SKCanvas的drawRect函数,传入左上角和右下角顶点坐标。

2)选用对应的设备的绘图API

由于选择的是CPU渲染模式,故调用SKBitmapDevice的矩形绘图函数drawRect实现。

3)图形表示

所有的图形可分解成下面几种基本矢量图形的组合,矩形可表示成四条直线的组合。

曲线类型参数用途
直线(一次贝塞尔曲线)起点坐标,终点坐标可表示绘制三角形、四边形等多边形
圆锥曲线起点坐标,终点坐标,椭圆参数表示椭圆、圆弧、圆形
二次贝塞尔曲线三个控制点表示TrueType字体、抛物线等曲线
三次贝塞尔曲线四个控制点表示OpenType字体和其他曲线

4)绘制算法实现

矢量图转成位图的过程称为光栅化。带填充的矩形光栅化过程比较简单,可以分解成绘制多条横线。

5)横线线绘制算法

每条横向的画法通过SKBlitter:: blitH实现。接口定义如下:

virtual void blitH(int x, int y, int width);

功能:从坐标x,y开始,连续写入宽度为width的RGB颜色值。

6)内存中写颜色数据

通过追踪代码,发现上文中的横线绘制函数调用的是memsetT函数(内存赋值)实现。参数如下:

static void memsetT(T buffer[], T value, int count)

目前x86和ARM处理器是32或者64位,普通的指令一次最多写入32位 或者64位数据,一个带透明通道的点通常占4个字节,相当于一次只能绘制1到2个点,效率比较低。Skia从性能角度考虑,采用的SIMD指令集来加速内存操作。

在X86平台,调用SSE、AVX、AVX2等指令集实现内存赋值,SSE支持一次操作128位操作,AVX/AVX2支持一次操作256位数据,ARM处理器的NEON指令集支持一次操作128位数据。

3.2 图形GPU渲染原理

GPU的并行运算能力强,目前大部分移动设备都采用的是GPU渲染。

skia GPU渲染流程如下:

1)发起绘图,先调用SKCanvas的绘图函数drawRect,传入左上角和右下角顶点坐标。

2)调用GPU设备的绘图函数SKGPUDevice::drawRect。

3)采用命令模式,将GPU绘图操作封装成类GrOpsTask的实例。

4)根据软硬件平台的不同选用不同的底层API。

OpenGL(Open Graphics Library”)是目前使用最广泛的跨平台图形变成接口,跨平台特性好,大部分操作系统和GPU。Skia在大部分平台采用OpenGL实现GPU绘图,少部分平台调用Metal和vulkan。

Metal是苹果公司2014年推出的和 OpenGL类似的面向底层的图形编程接口,只支持iOS。对软硬件有要求,要求硬件苹果A7及以后,操作系统iOS 10及以上。Metal理论上性能比OpenGL性能强,故新设备中开启Metal可提高性能。例如Flutter中已启用了metal支持,详情参考https://github.com/flutter/fl...

Vulkan是新一代跨平台的2D和3D绘图应用程序接口(API),旨在取代OpenGL,理论上性能强于OpenGL。自 Android 7.0 开发者预览版开始,Google便在系统平台中添加了对Vulkan的API支持。目前Skia的GPU渲染模式已用vulkan实现了一套,但存在一些bug。具体参考https://skia.org/user/special...

Skia对上述三种图形接口进行了封装,屏蔽了不同底层图形API接口的差异。OpenGL接口的封为GrGLOpsRenderPass,Metal的封装层为GrMTOpsRenderPass,Vuklan的封装层为 GrVKOpsRenderPass。

image.png

5)通过GPU完成剩余绘图操作。

下面以OpenGL为例说明。接口封装层调用OpenGL glDrawArray绘制矩形,之后在渲染流水线中完成顶点变换、光栅化和着色,最后送帧缓冲显示。渲染流水线如下图所示:

image.png

Metal、vulkan的渲染流水线这里不再展开。

4. 字体绘制原理

字体无法直接显示在屏幕上,需要解析成位图或者矢量图才能绘制。Skia的字体解析实现跟进平台差异有所不同,mac和iOS平台调用coreText库,安卓平台调用开源库freeType。

FreeType是一个用C语言实现的,免费的高质量可移植字体引擎,支持点阵字体PCF、BDF和矢量字体TrueType、freeType等字体。

4.1 skia点阵字体绘制原理

Skia支持的点阵字体有PCF、BDF格式。点阵存储的是多张位图,常见的有1616,2424,32*32等尺寸,解码和显示简单,缺点是放大后有锯齿。

1) skia点阵文字显示代码:

SkFont font;
font.setEdging(SkFont::Edging::kAlias);
font.setSize(40);
const char text[] = "Click this link!";
size_t byteLength = strlen(static_cast<const char*>(text));
canvas->drawSimpleText(text, byteLength, SkTextEncoding::kUTF8, x, y, font, SkPaint());

文字绘制流程如下:

image.png

点阵字体最后解析成了位图,然后根据平台不同选用CPU或者GPU渲染出来。Skia为了提高字体显示速度,对字体的解析结果做了内存缓存。

4.2 矢量字体绘制原理

矢量字体主要通过贝塞尔曲线描述字体,存储空间小,但渲染复杂,还需要导入字体库文件。Skia支持的矢量字体有tff(true type font)和otf(open true type)格式。前者采用二次贝塞尔曲线表示,后者采用三次贝塞尔曲线表示。Skia中矢量文字绘制代码如下:

SkPaint p;
    p.setStyle(SkPaint::kStroke_Style);
    p.setStrokeWidth(10);
    p.setARGB(0xff, 0xbb, 0x00, 0x00);
   sk_sp<SkTypeface> ttf = MakeResourceAsTypeface("fonts/Stroking.ttf");
SkFont font(ttf, 100);
if (ttf) {
        SkFont font(ttf, 100);
        canvas->drawString("○◉  ⁻₋⁺₊", 10, 100, font, p);
}

绘制流程如下:

image.png

矢量字体的绘制流程跟点阵字体大部分一样,不同之处在于解析结果为贝塞尔曲线。贝塞尔曲线的渲染算法稍微复杂,参考文章https://www3.cs.stonybrook.ed...

5. 图片绘制原理

5.1 Skia位图绘制原理

skia提供了showBitmap函数可直接显示位图。位图渲染模式跟矢量图形类似,分为CPU渲染和GPU渲染。位图的CPU渲染跟实心矩形的渲染原理类似,通过SIMD指令集将位图内存一行一行拷贝到指定内存缓存中。GPU渲染模式通过调用OpenGL、Metal、vulkan的纹理贴图函数实现。

image.png

5.2 Skia压缩格式图片绘制原理

位图由于占用空间大,使用频率低,大部分情况下使用压缩格式图片。Skia支持的压缩格式图片如下:

格式优点缺点场景依赖解码库
gif文件小,支持动画、透明,无兼容性问题只支持种颜色,且透明度只有1位,有白边和锯齿简单的动图libgifcodec
jpg支持位真彩色,压缩率高有损压缩,不支持透明通道色彩丰富的图片libjpeg-turbo
png无损压缩,支持透明,简单图片尺寸小不支持动画,压缩率低logo/icon/透明图libpng
webp比jpeg压缩率更高,支持有损和无损压缩,支持动画、透明通道谷歌自研格式,部分平台不支持。支持有损和无损压缩格式,支持动画libwebp

压缩格式图片使用代码如下:

SkCanvas c(dst);  
    SkBitmap src;  
    SkImageDecoder::DecodeFile(“test.jpg”, &src);//  图片解码
    c.drawBitmap(src, 0, 0, NULL);  //图片显示

显示流程如下图所示:

image.png

读取文件后,先通过文件头判断图片类型,然后送相应的图片库解码成位图图像后,再通过CPU或者GPU渲染。

6. skia小结

Skia是一个功能强大的跨平台图形库,能绘制矩形、圆形、贝塞尔曲线等矢量图,绘制点阵字体和矢量字体,显示jpeg、png、gif、webp等图片,同时性能好,从算法和硬件两个层面进行了优化。skia支持多种软硬件平台,除了Android、chrome、Flutter等产品直接将其作为图形引擎,也支持iOS、windows等操作系统。Skia功能较多,还支持lottie动画,图像特效,还引入了中间语言SKGL,限于篇幅,这里不再展开。

参考文档:

iOS高性能绘图:https://medium.com/@almalehde...

Core Animation 编程指南:https://developer.apple.com/l...

skia编译方法:https://skia.org/user/build

Skia技术路线:

https://docs.google.com/docum...

SKGL说明:https://github.com/google/ski...

Skia源码:https://skia.googlesource.com...

Skia 百科:https://zh.wikipedia.org/zh-c...

字体介绍:http://www.klayge.org/wiki/index.php/%E5%AD%97%E4%BD%93%E7%B3%BB%E7%BB%9F

FreeType官网:https://www.freetype.org/

png压缩原理:https://www.jianshu.com/p/5ad...

GPU渲染流水线:https://zhuanlan.zhihu.com/p/...

Vukan介绍:https://www.khronos.org/asset...

ARM Mali GPU介绍:https://developer.arm.com/sol...

Vulkan和OpenGL ES比较:https://community.arm.com/dev...

Qualcomm宣布Adreno 530 GPU支持vulkan:https://www.qualcomm.cn/news/...

https://www.adobe.com/content...

image.png

查看原文

赞 0 收藏 0 评论 0

OPPO互联网技术 发布了文章 · 2020-12-18

OPPO互联网业务多活架构演进和实践

OPPO 是如何保证互联网业务的高可用?多活架构如何落地,如何根据业务需求持续演进?针对复杂的系统,如何提供可靠的监控方案?......

OPPO 虽然是一家手机公司,但是其互联网业务的规模非常庞大,月活用户数超过 3 亿,既有内容业务、分发业务,也有商业广告、金融等业务。整体上,用户的使用非常高频,来自客户端的请求和数据量非常庞大,并且这几年一直在持续高速发展。

随着业务复杂度和并发量迅速增加,OPPO 面临的难题是如何保证互联网业务的高可用?多活架构如何落地,如何根据业务需求持续演进?针对复杂的系统,如何提供可靠的监控方案?...... 带着这些疑问,InfoQ 记者采访了 OPPO 互联网服务系统后端框架团队负责人罗工。

据悉,他于 2015 年加入 OPPO,有十余年的研发经历,并在高可用架构、PaaS 平台和基础框架研发等方面有丰富的实践经验。罗工负责 OPPO 后端框架体系建设,包括 API 网关、微服务框架、服务网格等,且经历了 OPPO 从千万级用户到亿级用户的增长历程。

1. OPPO 业务多活的诉求

据罗工介绍,OPPO 业务多活的三个核心诉求是成本、扩展、容灾。

具体说来,成本是指业务总体技术运营成本,包括基础设施的资源成本、研发成本,还包括业务中断的成本、品牌和口碑成本;

扩展是因为业务规模过大,一个服务需要调用数百个三方实例、一个数据库被数百个实例连接、一个服务需要连接几十个数据库,架构非常复杂。因此,这就需要对用户进行分片,缩小业务规模,自然演进到单元化多活的架构;

容灾,一方面是极端情况下对用户数据可靠性保障的需求,另一方面,因业务过于复杂、处理的链路很长,总会出现一些意外情况,频率还挺高,而问题定位到恢复的时间超过公司 RTO 的要求。机房内部共享运营商线路、DNS、SNAT 防火墙、负载均衡、K8s 集群、注册中心、监控等资源,而机房之间是相对隔离的环境,同时出问题的概率大幅降低。在业务出现无法自动恢复的故障时,先切换机房恢复业务,然后再从容定位问题根因。

2. 业务多活的两大挑战

OPPO 互联网业务有两大特点:一是业务种类多,没有主线;二是非常高频,请求量大。

先说第一大特点:业务种类多,没有主线,很难制定统一的用户单元划分规则。对平台服务的提供方来说,比如活动、评论、账号、积分、内容中台等,所有机房都需要读写,都需全量的数据。因此,要设计一个 N 主全量数据的架构难度非常大。

以业务中台 - 评论系统为例,多活架构如下:

image.png

  • 评论以独立 SDK、独立域名提供服务。避免在业务部署的多个机房的内部调用,否则,评论服务就需要每个机房提供读写服务;
  • 记录日志、流水,比如点赞记录、取消点赞记录,避免修改、计数操作;
  • 最终一致。通过订阅的方式更新元数据、缓存,避免双写造成的数据不一致,同时在数据错误时更容易修复;
  • 按用户单元进行调度,用户只会访问其中一个机房,感知不到两个机房独立更新的元数据的差异。

通过以上措施,把平台业务和产品业务分开,避免机房内部调用。

第二大特点是非常高频,请求量大。罗工表示,“一方面,我们提供的服务本身就是用户使用频率较高的,另一方面,还有一些常驻服务,比如天气、推送通道、软件自更新、手机云服务等。一般的开源技术方案无法支撑集群的规模,比如 1000 个实例的 Redis 集群,并且运行的成本过高,自愈能力和可观测性不足等,因此需要自研或深度的掌控。这对技术上的挑战很大。”

image.png

以 Redis 为例,如上图所示,后端搭建多个 Redis Group,Proxy 实现 Redis 3.0 Cluster 协议,将一部分 slot 流量分发给 Redis Group,这样 Redis Group 的规模就能得到控制。

基于 RocksDB 研发日志存储系统,对数据进行压缩,性能和成本大大优于 ElasticSearch Lucene 架构。

3. 业务多活架构设计的技术选型

据罗工介绍,多活架构分为接入层、服务层、数据层。

接入层包括 HttpDNS、域名调度系统、单元划分服务、四层负载均衡、SSL 卸载、API 网关。其中,四层负载均衡基于 DPDK 开发,功能比较简单。SSL 卸载采用标准的 Nginx,扩展了一些 Prometheus 监控指标。API 网关基于 Netty 网络框架开发,NIO 部分使用了 Netty Native transports,采用微内核 + 全异步的模型,相比 Zuul/Spring Cloud Gateway 方案有较大的性能优势。

服务层本身是无状态的,扩展了服务路由、同机房优先、机房级熔断等功能,根据单元号 / 请求染色标记筛选服务提供者实例。

数据层分为同城多活方案、异地多活方案。同城多活即集群跨 AZ 高可用,是一个 CP 的方案。它对于开发者是无感知的,一旦出现故障就会自动切换自愈。罗工透露,这里的切换过程没有采用域名方案,而是修改 SDK 服务发现,以及 Anycast 跨机房路由变更。

异地多活是一个 AP 方案,双向同步,追求最终一致性。

4. OPPO 互联网业务多活架构的历程

据悉,OPPO 互联网业务种类很多,目前在大量使用同城多活、异地多活的方案。

实际上,OPPO 互联网业务是在近几年快速发展起来的,因此一开始就有同城多活、异地多活,并行发展。

早期,同城多活、异地多活缺乏统一方法论,比如按用户单元进行调度(减少用户感知的不一致、数据同步冲突)、主线多活、保障多数用户、最终一致、数据分类(应用不同的 CAP 模型)、平台业务 SDK 化,结果业务做到了多活,但满足不了 RTO 要求,异常情况不能为大多数用户提供服务,以及上层业务多活,而依赖平台服务需要跨机房调用。

后期,业务多活不只是关注数据层的同步方案,更关注方法论、设计原则的推广,梳理上层业务与平台业务的关系、业务主线、数据分类应用不同的 CAP 模型,以及建设统一的多活决策与观测平台,整个过程更智能化、减少误判、过滤抖动。

最近几年,OPPO 业务多活的研发重点在于开发人员无感,降低业务多活接入成本。

  • 数据层创建数据库 / 中间件集群选择跨 AZ 高可用、异地同步即可。
  • 服务层自动注入相关的环境变量,避免用户配置错误。
  • 接入层打造故障决策的中枢,整合多个监控数据源信息,交叉验证,避免误判,过滤抖动,以及接入层和数据层联动等,实现自动化的接入流量调度。

最大难题

在整个多活业务架构设计中,最大的难题就是没有主线,很难统一单元划分的规则,平台型服务需要在每个机房提供本机房访问、提供全量数据,没有办法应用单元化的思路去解决。

这里,罗工谈到了三种解决思路。

第一种解决思路:提供 2+2 核心机房本地读写能力,南方选择 2 个机房做同城多活,北方选择 2 个机房做同城多活,南北之间双向同步。数据结构设计上,避免使用计数器,避免使用 update,用日志、流水、南北分别订阅 MySQL 重建 ES/Cache/ 计数器、库存南北分别扣减等方式代替,达成数据的最终一致。2+2 之外的机房调用同城的服务。

第二种解决思路:提供本机房的读能力,而写集中到中心机房。

第三种解决思路:上层业务对平台服务的依赖,一定要做好降级,有应急手段,比如账号登录验证,业务可以自己适当的缓存,也可以在 Token 设计时就具备一些不依赖数据层的降级验证能力。

数据同步

数据同步方面。如果 ES、MQ、MongDB 本身提供了多机房数据同步的能力,那无需做太多研发工作。Redis 则可以改造 AOF 为 binlog 模式,从而具备订阅同步的能力。

如果 ES、MQ、MongDB、Redis 作为唯一数据存储,用相关组件自身的同步能力;如果可以从 MySQL 数据源重建,则优先使用 MySQL 的同步机制,在各机房分别订阅 MySQL 重建其他组件的数据。其好处是避免了数据存储双写的一致性问题、数据修复比较容易。

对 OPPO 而言,它们自研了 Jins 数据同步框架,用来同步 MySQL 等数据库的 binlog,采取下列措施提升性能:

  • 强大的数据筛选、过滤机制,减少数据量;
  • 一个 MySQL 实例一个数据库,MySQL binlog 是实例级别,库之间会互相影响;
  • 事务并行、顺序保障,提高目标库写入并发度,这个需要深入研究 InnoDB 的原理;
  • 数据压缩、加密、分片传输;
  • 分离 Store 模块和 Relay 模块,即数据传输先落到目标机房的中继日志,然后独立的进程把中继日志写入目标库;
  • 多消费者支持,支持同步到多个目标库,减少数据库的订阅压力;
  • 数据传输过程中的一致性保障,数据订阅出来写到本地存储,然后传到目标机房的中继日志,再写入目标库,整个过程需要 2PC 机制保障数据一致性。

通过上述手段,南北机房的双向同步对比流行开源软件,在典型场景下基本达到一倍的性能提升,超过 20000 TPS。

自动化调度

据悉,自动化调度分为多个层次,数据层的自动故障切换和自愈,接入层的机房流量自动调度。罗工介绍,在机房流量自动调度方面,首先需要多维度的监控数据,避免单个数据源数据质量问题造成误判,包括客户端调用链或 APM 的数据、外网拨测平台的数据、机房网络设备监控数据、四层负载均衡监控数据、API 网关监控数据。

数据汇总到多活调度平台(青龙),进行综合决策,防止误判,过滤抖动,输出故障类型指令,以及观察调度的效果。

另一个关键即使是调度执行系统,需要客户端网络库、HttpDNS、传统 DNS、API 网关深度联动,保证切换过程生效的及时性、生效一致性。

罗工表示,现在,他们核心业务基本上可以实现分钟级完成机房调度,流量 100% 切换到新机房。

目前,他们正在做一些新的尝试,比如调度决策中枢接入更多的数据源,优化算法,提升故障判断的及时性、准确性,有效过滤抖动,减少人工的干预。

他们的同城多活、异地多活以双机房为主,需要 200% 的容量。他们正尝试划分更小单元,逐步迁移到三活单元化架构,逼近 150% 容量的理想情况。

在数据层,他们继续推动计算存储分离,降低副本数,降低数据层多活的成本。并且,Elasticsearch、Redis、MongDB、MySQL 异地同步都收敛到 MySQL 异地同步相同的传输通道 -Jins。异地两个机房部署完全独立的集群,减少机房之间的依赖,提升数据传输性能、数据安全性。

5. 写在最后

回顾 OPPO 互联网业务的实践历程,罗工认为业务多活架构的成功与一些因素密切相关。在业务分层和数据分类上,CAP 定理约束的是数据,不是业务。同一个业务的不同数据可以采用不同的多活方案,可以共存异地多活、同城多活,重点保障主线功能,保障多数用户。在基础设施层面,尤其是接入层、数据层,要尽量做到对用户透明,降低业务接入的成本。否则,标准、动作不一致,运维管理的成本也很高。

他说:“业务架构设计需要深入掌握基础设施的限制条件,比如 Redis AOF 改造为 binlog 后,部分数据结构不同提供支持,就需要从业务设计上规避。”

image.png

查看原文

赞 0 收藏 0 评论 0

OPPO互联网技术 发布了文章 · 2020-12-09

docker的衰落与kubernetes的兴起

kubernetes 1.20中对于docker的弃用,引发的讨论很多,关于docker衰亡的话题又热了起来。对这一事件,我们找了OPPO一位工程师大佬,从技术人员的角度说说这个事。他本人2014年开始从事容器化相关工作,目前负责OPPO云平台的编排与调度方向的工作。

伴随着kubernetes 1.20中对于docker的弃用,关于docker的灭亡与kubernetes的兴起的话题再度热了起来。讨论中关于docker灭亡的观点我不敢苟同。docker还远未到达灭亡的程度。相较而言,我觉得更恰当的说法应该是docker的衰落。本文我也就我个人的角度,聊聊我所经历的docker的衰落与kubernetes的兴起。

横空出世——docker的兴起

第一次接触docker,是2014年。当时OpenStack的主要负载还是kvm。而我们也尝试过了更为轻量的lxc,但是以失败而告终了。docker这个集装箱的小图标配上docker container的理念,一下子就吸引住了大家的目光。经历过制作lxc镜像的痛苦,你就会更体会到docker的可贵。繁琐的lxc镜像制作与精简的Dockerfile相比,孰高孰低、孰优孰劣可谓是一目了然。

docker成功的将cgroup、union filesystem、namespace这些较为稳定和成熟的技术结合了起来,辅以docker image的制作工艺,实现了集装箱式的标准交付。

这时候的docker,颇有种“举天下之豪杰而莫能与之争”的气势。虽然在生产环节还是或多或少,还有这样那样的问题,但是docker已经跨过了POC(Proof of concept)阶段,进入了pre-product的行列了。在对docker深度定制后,最终我们团队也将OpenStack + docker的组合成功推向了生产。

image.png

那两年,知不知道docker、会不会做镜像、懂不懂docker原理成为基础架构领域面试的常见话题。虽然只有少数几个公司敢为天下先,将docker搬上了生产,但是已经没有人可以忽略这颗冉冉升起的新星了。

那时活跃在各个会议、论坛上的都是docker的话题。大家热衷于讨论生产上docker遇到的坑。大家各出奇招,修修补补,跌跌撞撞,docker总算也是被搬上了生产。而在这时,即使是技术保守、持徘徊观望态度的公司,也都会安排一些人力着手跟进docker的发展与各个公司的实践经验了,这时候的docker真的是风头无两。

生来巨人——kubernetes

时间到了2015年,此时我转而负责进行CaaS(Container as a Service)服务的调研。这时候大家都在说CaaS,但是每个人说的都不一样,其实大家都是摸着石头过河。在此期间,以研究OpenStack的magnum为契机,我接触到了swarm和kubernetes。

image.png

swarm是docker公司力推的集群管理方案。docker、swarm和compose组成的三剑客完整覆盖了运行时、集群管理与编排,构成了一个看起来牢不可摧的生态系统。特别是别出心裁的将swarm的api与docker的api进行了拉齐,将集群的管理复杂度与单节点的管理复杂度向用户进行屏蔽,倒是有一种如臂使指的快感。这个设计直到现在我都还觉得立意真的很精巧。

image.png

而初出茅庐的kubernetes也来势汹汹。背靠Google的大旗,有Borg的背书,kubernetes在气势上一点不输swarm + compose的组合。伴随着kubernetes 1.0的发布,kubernetes也从幕后走向了台前,开始大规模接受来自全世界的PR提交,在功能、性能和稳定性上快速提升。

到kubernetes 1.2版本,经过我们内部评估,已经具备生产级的品质。而声明式API、简洁的架构、灵活的标签等优秀的设计,在做选型时已经让我们完全没有理由拒绝。而后经过数月紧张的开发,kubernetes + docker的组合被搬上了舞台,并且以极快的速度侵蚀OpenStack + docker的份额。

此时的docker已经开始被限制为了容器的运行时和镜像制作工具。捆住了docker的手脚,kubernetes已经没有了可以掰手腕的对手,一统江湖的路上kubernetes再无障碍。

美人迟暮——docker的衰落

时至今日,docker的衰落已经成为了不争的事实,而造成这个结果的原因,我觉得是多方面的。

一部分是docker自身的封闭和固执己见。我曾经记得在当时参与了当时社区多个PR的讨论。新增一个feature的超长的周期,已经可以磨掉多数人的耐心。docker社区在多个观点上略显保守的方式,让大家逐渐失去了参与的热情。

另一方面,也是更为重要的一点,是容器技术本身的门槛已经被突破,已经无法形成技术上的护城河。而容器技术之争,已经转化为标准之争,而对于标准上更有发言权的一方,无疑是具有更多用户、更庞大社区和更强大的平台的一方。在短短两三年的时间内,天平就快速地向kubernetes倾斜,其主导的CRI、CNI、CSI标准已经成为了事实上的通行标准。而相较之下,docker力推的CNM等标准则显得曲高和寡。

image.png

与此同时,kubernetes并没有放弃主动的进攻。kubernetes的强势和扶植其他容器运行时加速了docker的衰落。在1.6版本弃用docker manager直连docker,转向CRI + dockershim的组合时,就注定了kubernetes会走到完全解耦docker,也就是今天这一步。

另外,其他容器运行时也开始了瓜分市场份额。如果说gVisor、Kata只是尝试挑战docker在部分场景中的地位,那么红帽的Podman则已经吹响了全面进攻的号角。再结合前两年docker的一些融资和收购传闻,平添了一种英雄末路、美人迟暮的伤感。

回顾

世间多少英雄戏,每到收场总伤神。

六年回望,其实无论是在当时还是现在来看,docker都是一个革命性的产品。docker的衰落并不是意味着容器运行时不重要了,而是大家越来越习以为常了。

时至今日,容器运行时作为一个大部已经被解决的问题,一个相对成熟的模块,已经成为了整个基础架构体系的一部分。而作为上层的平台和更为上层的用户来说,对此将会给予越来越少的关注,这就像现在大多数用户并不会去关心内核了一样。

甚至在未来,我预测运行时都有可能会成为一个内核级别的附属模块,会预装到许多的发行版上,其运行也逐渐对大多数用户变得更加透明(实际红帽已经开始向这个方向做了)。而越来越多的用户则会将更多的注意力集中在上层的交付、管理、编排上。

至于kubernetes会不会衰落,我觉得在中短期内(五年内)不会。kubernetes已经成为了一个平台级的项目。在这点上,kubernetes作为平台将比工具性质的docker具有更强的生命力。

image.png

查看原文

赞 2 收藏 1 评论 0

OPPO互联网技术 发布了文章 · 2020-12-07

qiankun 微前端原理与实践

本文来自OPPO互联网技术团队,转载请注名作者。同时欢迎关注我们的公众号:OPPO_tech,与你分享OPPO前沿互联网技术及活动。

微前端是指存在于浏览器中的微服务,通常由许多组件组成,并使用类似于 React、Vue 和 Angular 等框架来渲染组件,每个微前端可以由不同的团队进行管理,并可以自主选择框架。

每个微前端都拥有独立的 git 仓库、package.json 和构建工具配置。因此,可以拆分一些巨石应用为多个独立的模块再组合起来,应用间独立维护及上线,互不干扰。

本文通过一些精简代码的方式介绍微前端框架qiankun的原理及OPPO云在这上面的一些实践。

注:本文默认读者使用过qiankun框架,且文中使用的qiankun版本为:2.0.9

1. qiankun 的前身 single-spa

qiankun 是一个基于 single-spa 的微前端实现库,在qiankun还未诞生前,用户通常使用single-spa来解决微前端的问题,所以我们先来了解single-spa

我们先来上一个例子,并逐步分析每一步发生了什么。

import { registerApplication, start } from "single-spa";
registerApplication(
  "foo",
  () => System.import("foo"),
  (location) => location.pathname.startsWith("foo")
);
registerApplication({
  name: "bar",
  loadingFn: () => import("bar.js"),
  activityFn: (location) => location.pathname.startsWith("bar"),
});
start();
  • appName: string 应用的名字将会在 single-spa 中注册和引用, 并在开发工具中标记
  • loadingFn: () => 必须是一个加载函数,返回一个应用或者一个 Promise
  • activityFn: (location) => boolean 判断当前应用是否活跃的方法
  • customProps?: Object 可选的传递自定义参数

1.1 元数据处理

首先,single-spa会对上述数据进行标准化处理,并添加上状态,最终转化为一个元数据数组,例如上述数据会被转为:

[{
  name: 'foo',
  loadApp: () => System.import('foo'),
  activeWhen: location => location.pathname.startsWith('foo'),
  customProps: {},
  status: 'NOT_LOADED'
},{
  name: 'bar',
  loadApp: () => import('bar.js'),
  activeWhen: location => location.pathname.startsWith('bar')
  customProps: {},
  status: 'NOT_LOADED'
}]

1.2 路由劫持

single-spa内部会对浏览器的路由进行劫持,所有的路由方法路由事件都确保先进入single-spa进行统一调度。

// We will trigger an app change for any routing events.
window.addEventListener("hashchange", urlReroute);
window.addEventListener("popstate", urlReroute);
// Monkeypatch addEventListener so that we can ensure correct timing
const originalAddEventListener = window.addEventListener;
window.addEventListener = function(eventName, fn) {
  if (typeof fn === "function") {
    if (
      ["hashchange", "popstate"].indexOf(eventName) >= 0 &&
      !find(capturedEventListeners[eventName], (listener) => listener === fn)
    ) {
      capturedEventListeners[eventName].push(fn);
      return;
    }
  }
  return originalAddEventListener.apply(this, arguments);
};
function patchedUpdateState(updateState, methodName) {
  return function() {
    const urlBefore = window.location.href;
    const result = updateState.apply(this, arguments);
    const urlAfter = window.location.href;
    if (!urlRerouteOnly || urlBefore !== urlAfter) {
      urlReroute(createPopStateEvent(window.history.state, methodName));
    }
  };
}
window.history.pushState = patchedUpdateState(
  window.history.pushState,
  "pushState"
);
window.history.replaceState = patchedUpdateState(
  window.history.replaceState,
  "replaceState"
);

以上是劫持代码的精简版,可以看到,所有的劫持都指向了一个出口函数urlReroute

1.3 urlReroute 统一处理函数

每次路由变化,都进入一个相同的函数进行处理:

let appChangeUnderway = false,
  peopleWaitingOnAppChange = [];
export async function reroute(pendingPromises = [], eventArguments) {
  // 根据不同的条件把应用分到不同的待处理数组里
  const {
    appsToUnload,
    appsToUnmount,
    appsToLoad,
    appsToMount,
  } = getAppChanges();

  // 如果在变更进行中还进行了新的路由跳转,则进入一个队列中排队,
  if (appChangeUnderway) {
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({ resolve, reject, eventArguments });
    });
  }
  // 标记此次变更正在执行中,
  appChangeUnderway = true;

  await Promise.all(appsToUnmount.map(toUnmountPromise)); // 待卸载的应用先执行unmount
  await Promise.all(appsToUnload.map(toUnloadPromise)); // 待销毁的应用先销毁
  await Promise.all(appsToLoad.map(toLoadPromise)); // 待加载的应用先执行load
  await Promise.all(appsToBootstrap.map(toBootstrapPromise)); // 待bootstrap的应用执行bootstrap
  await Promise.all(appsMount.map(toMountPromise)); // 待挂载的应用执行mount

  appChangeUnderway = false;
  // 如果排队的队列中还有路由变更,则进行新的一轮reroute循环
  reroute(peopleWaitingOnAppChange);
}

接下来看看分组函数在做什么。

1.4 getAppChanges 应用分组

每次路由变更都先根据应用的activeRule规则把应用分组。

export function getAppChanges() {
  const appsToUnload = [],
    appsToUnmount = [],
    appsToLoad = [],
    appsToMount = [];
  apps.forEach((app) => {
    const appShouldBeActive =
      app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
    switch (app.status) {
      case LOAD_ERROR:
      case NOT_LOADED:
        if (appShouldBeActive) appsToLoad.push(app);
      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED:
        if (!appShouldBeActive) {
          appsToUnload.push(app);
        } else if (appShouldBeActive) {
          appsToMount.push(app);
        }
      case MOUNTED:
        if (!appShouldBeActive) appsToUnmount.push(app);
    }
  });
  return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}

1.5 关于状态字段的枚举

single-spa对应用划分了一下的状态

export const NOT_LOADED = "NOT_LOADED"; // 还未加载
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 加载源码中
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 已加载源码,还未bootstrap
export const BOOTSTRAPPING = "BOOTSTRAPPING"; // bootstrap中
export const NOT_MOUNTED = "NOT_MOUNTED"; // bootstrap完毕,还未mount
export const MOUNTING = "MOUNTING"; // mount中
export const MOUNTED = "MOUNTED"; // mount结束
export const UPDATING = "UPDATING"; // updata中
export const UNMOUNTING = "UNMOUNTING"; // unmount中
export const UNLOADING = "UNLOADING"; // unload中
export const LOAD_ERROR = "LOAD_ERROR"; // 加载源码时加载失败
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN"; // 在load,bootstrap,mount,unmount阶段发生脚本错误

我们可以在开发时使用官方的调试工具快速查看每次路由变更后每个应用的状态:

image

single-spa使用了有限状态机的设计思想:

  • 事物拥有多种状态,任一时间只会处于一种状态不会处于多种状态;
  • 动作可以改变事物状态,一个动作可以通过条件判断,改变事物到不同的状态,但是不能同时指向多个状态,一个时间,就一个状态;
  • 状态总数是有限的。

有限状态机的其他例子: Promise 、 红绿灯

1.6 single-spa 的事件系统

基于浏览器原生的事件系统,无框架耦合,全局开箱可用。

// 接收方式
window.addEventListener("single-spa:before-routing-event", (evt) => {
  const {
    originalEvent,
    newAppStatuses,
    appsByNewStatus,
    totalAppChanges,
  } = evt.detail;
  console.log(
    "original event that triggered this single-spa event",
    originalEvent
  ); // PopStateEvent | HashChangeEvent | undefined
  console.log(
    "the new status for all applications after the reroute finishes",
    newAppStatuses
  ); // { app1: MOUNTED, app2: NOT_MOUNTED }
  console.log(
    "the applications that changed, grouped by their status",
    appsByNewStatus
  ); // { MOUNTED: ['app1'], NOT_MOUNTED: ['app2'] }
  console.log(
    "number of applications that changed status so far during this reroute",
    totalAppChanges
  ); // 2
});

1.7 single-spa 亮点与不足

亮点

  • 全异步编程,对于用户需要提供的 load,bootstrap,mount,unmount 均使用 promise 异步的形式处理,不管同步、异步都能 hold 住
  • 通过劫持路由,可以在每次路由变更时先判断是否需要切换应用,再交给子应用去响应路由
  • 标准化每个应用的挂载和卸载函数,不耦合任何框架,只要子应用实现了对应接口即可接入系统中

不足

  • load 方法需要知道子项目的入口文件
  • 把多个应用的运行时集成起来需要项目间自行处理内存泄漏,样式污染问题
  • 没有提供父子数据通信的方式

2. qiankun 登场

为了解决single-spa的一些不足,以及保留single-spa中优秀的理念,所以qiankunsingle-spa的基础上进行了更进一步的拓展。

以下是qiankun官方给的能力图:

image

我们来看看qiankun的使用方式

import { registerMicroApps, start } from "qiankun";
registerMicroApps([
  {
    name: "react app", // app name registered
    entry: "//localhost:7100",
    container: "#yourContainer",
    activeRule: "/yourActiveRule",
  },
  {
    name: "vue app",
    entry: { scripts: ["//localhost:7100/main.js"] },
    container: "#yourContainer2",
    activeRule: "/yourActiveRule2",
  },
]);
start();

是不是有点像single-spa的注册方式?

2.1 传递注册信息给 single-spa

实际上qiankun内部会把用户的应用注册信息包装后传递给single-spa

import { registerApplication } from "single-spa";
export function registerMicroApps(apps) {
  apps.forEach((app) => {
    const { name, activeRule, loader = noop, props, ...appConfig } = app;
    registerApplication({
      name,
      app: async () => {
        loader(true);
        const { mount, ...otherMicroAppConfigs } = await loadApp(
          { name, props, ...appConfig },
          frameworkConfiguration
        );
        return {
          mount: [
            async () => loader(true),
            ...toArray(mount),
            async () => loader(false),
          ],
          ...otherMicroAppConfigs,
        };
      },
      activeWhen: activeRule,
      customProps: props,
    });
  });
}

可以看到mountunmount函数是由loadApp返回的。

2.2 loadApp 的实现

export async function loadApp(app, configuration) {
  const { template, execScripts } = await importEntry(entry); // 通过应用的入口链接即可获取到应用的html, js, css内容
  const sandboxInstance = createSandbox(); // 创建沙箱实例
  const global = sandboxInstance.proxy; // 获取一个沙箱全局上下文
  const mountSandbox = sandboxInstance.mount;
  const unmountSandbox = sandboxInstance.unmount;
  // 在这个沙箱全局上下文执行子项目的js代码
  const scriptExports = await execScripts(global);
  // 获取子项目导出的 bootstrap / mount / unmount
  const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
    scriptExports,
    appName,
    global
  );
  // 初始化事件模块
  const {
    onGlobalStateChange,
    setGlobalState,
    offGlobalStateChange,
  } = getMicroAppStateActions();
  // 传递给single-spa的mount, unmount方法实际是qiankun包装过的函数
  return {
    bootstrap,
    mount: async () => {
      awaitrender(template); // 把模板渲染到挂载区域
      mountSandbox(); // 挂载沙箱
      await mount({ setGlobalState, onGlobalStateChange }); // 调用应用的mount函数
    },
    ummount: async () => {
      await ummount(); // 调用应用的ummount函数
      unmountSandbox(); // 卸载沙箱
      offGlobalStateChange(); // 解除事件监听
      render(null); // 把渲染区域清空
    },
  };
}

2.3 importEntry 的实现

看看 importEntry 的使用,这是一个独立的包 import-html-entry,通过解析一个 html 内容,返回html, cssjs分离过的内容。

例如一个子应用的入口html为如下

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>这里是标题</title>
    <link rel="stylesheet" href="./css/admin.css" />
    <style>
      .div {
        color: red;
      }
    </style>
  </head>
  <boyd>
    <div id="wrap">
      <div id="app"></div>
    </div>
    <script data-original="/static/js/app.12345.js"></script>
    <script>
      console.log("1");
    </script>
  </boyd>
</html>

qiankun 加载到页面后,最终生成的 html 结构为:

<meta charset="utf-8" />
<title>这里是标题</title>
<link rel="stylesheet" href="./css/admin.css" />
<style>
  .div {
    color: red;
  }
</style>
<div id="wrap">
  <div id="app"></div>
</div>
<!--  script /static/js/app.12345.js replaced by import-html-entry -->
<!-- inline scripts replaced by import-html-entry -->

看看importEntry返回的内容

export function importEntry(entry, opts = {}) {
  ... // parse html 过程忽略
  return {
    // 纯dom元素的内容
    template,
    // 一个可以接收自定义fetch方法的获取<script>标签的方法
    getExternalScripts: () => getExternalScripts(scripts, fetch),
    // 一个可以接收自定义fetch方法的获取<style>标签的方法
    getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
    // 一个接收全局上下文的执行函数,执行这个方法则模拟了一个应用加载时浏览器执行script脚本的逻辑
    execScripts: (proxy) => {}
  }
}

看看getExternalScripts的实现,实际上是用并行fetch模拟浏览器加载<style>标签的过程(注意此时还没有执行这些脚本), getExternalStyleSheets与这个类似。

// scripts是解析html后得到的<scripts>标签的url的数组
export getExternalScripts(scripts, fetch = defaultFetch) {
  return Promise.all(scripts.map(script => {
    return fetch(scriptUrl).then(response => {
        return response.text();
    }));
  }))
}

然后看看execScripts的实现,可以通过给定的一个假window来执行所有<script>标签的脚本,这样就是真正模拟了浏览器执行<script>标签的行为。

export async execScripts(proxy) {
  // 上面的getExternalScripts加载得到的<scripts>标签的内容
  const scriptsTexts = await getExternalScripts(scripts)
  window.proxy = proxy;
  // 模拟浏览器,按顺序执行script
  for (let scriptsText of scriptsTexts) {
    // 调整sourceMap的地址,否则sourceMap失效
    const sourceUrl = '//# sourceURL=${scriptSrc}\n';
    // 通过iife把proxy替换为window, 通过eval来执行这个script
    eval(`
      ;(function(window, self){
        ;${scriptText}
        ${sourceUrl}
      }).bind(window.proxy)(window.proxy, window.proxy);
    `;)
  }
}

2.4 全局变量污染与内存泄漏

看沙箱功能前先聊一聊沙箱,沙箱主要用于解决程序的全局变量污染内存泄漏问题。

  • 全局变量污染: 多个应用都使用某个同名全局变量,例如 Vue。
  • 内存泄漏: 内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,导致在释放该段内存之前就失去了对该段内存的控制,从而造成了内存的浪费。

    常见的内存泄漏场景:

    1. 意外的全局变量
    2. 泄漏到全局的闭包
    3. DOM 泄漏
    4. 定时器
    5. EventListener
    6. console.log (开发环境)

下面我们看看qiankun要如何解决上面的问题。

2.5 qiankun 如何使用沙箱

可以结合上文loadApp的逻辑看看,本文讨论的是LegacySandbox沙箱。

export function createSandbox() {
  const sandbox = new LegacySandbox();
  // load或者bootstrap阶段产生的污染和泄漏
  const bootstrappingFreers = patchAtBootstrapping();
  let sideEffectsRebuilders = [];
  return {
    proxy: sandbox.proxy,
    // 沙箱被 mount, 可能是从 bootstrap 状态进入的 mount, 也可能是从 unmount 之后再次唤醒进入 mount
    async mount() {
      /* ------------------------------------------ 1. 启动/恢复 沙箱------------------------------------------ */
      sandbox.active();
      const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(
        0,
        bootstrappingFreers.length
      );
      // 重建应用bootstrap阶段的副作用,比如动态插入css
      sideEffectsRebuildersAtBootstrapping.forEach((rebuild) => rebuild());
      /* ------------------------------------------ 2. 开启全局副作用监听 ------------------------------------------*/
      // render 沙箱启动时开始劫持各类全局监听,尽量不要在应用初始化bootstrap阶段有 事件监听/定时器 等副作用,这些副作用无法清除
      mountingFreers = patchAtMounting(
        appName,
        elementGetter,
        sandbox,
        singular,
        scopedCSS,
        excludeAssetFilter
      );
      sideEffectsRebuilders = [];
    },
    // 恢复 global 状态,使其能回到应用加载之前的状态
    async unmount() {
      // 每个Freers释放后都会返回一个重建函数,如果该Freers不需要重建,则是返回一个空函数
      sideEffectsRebuilders = [...bootstrappingFreers].map((free) => free());
      sandbox.inactive();
    },
  };
}

看看LegacySandbox沙箱的实现,这个沙箱的作用主要处理全局变量污染,使用一个proxy来替换window来劫持所有的 window 操作。

class SingularProxySandbox {
  // 沙箱期间更新的全局变量
  addedPropsMapInSandbox = new Map();
  // 沙箱期间更新的全局变量
  modifiedPropsOriginalValueMapInSandbox = new Map();
  // 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot
  currentUpdatedPropsValueMap = new Map();
  sandboxRunning = true;
  active() {
    // 把上次该沙箱运行时的快照还原
    this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
    this.sandboxRunning = true;
  }
  inactive() {
    // 沙箱销毁时把修改的值改回去
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
    // 沙箱销毁时把新增的值置空
    this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
    this.sandboxRunning = false;
  }
  constructor(name) {
    const proxy = new Proxy(window, {
      set(_, p, value) {
          // 如果当前 window 对象不存在该属性,则记录该属性是新增的
          if (!window.hasOwnProperty(p)) {
            addedPropsMapInSandbox.set(p, value);
          // 如果当前 window 对象存在该属性,且 map 中未记录过,则记录该属性被修改及保存修改前的值
          } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
            const originalValue = window[p];
            modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
          }
          // 不管新增还是修改,这个值都变成最新的快照记录起来
          currentUpdatedPropsValueMap.set(p, value);
          window[p] = value;
        }
      },
      get(_, p) {
        return window[p]
      },
    })
  }
}

除了全局变量污染的问题,还有其他的泄漏问题需要处理,这些泄漏问题qiankun使用不同的patch函数来劫持。

// 处理mount阶段和应用运行阶段产生的泄漏
export function patchAtMounting() {
  return [
    // 处理定时器泄漏
    patchInterval(),
    // 处理全局事件监听泄漏
    patchWindowListener(),
    patchHistoryListener(),
    // 这个严格不算泄漏,是监听动态插入页面的dom结构(包括script和style)
    patchDynamicAppend(),
  ];
}
// 处理load和bootstrap阶段产生的泄漏
export function patchAtBootstrapping() {
  return [patchDynamicAppend()];
}

一个patch的例子如下:

const rawWindowInterval = window.setInterval;
const rawWindowClearInterval = window.clearInterval;
export default function patchInterval(global) {
  let intervals = [];
  global.clearInterval = (intervalId) => {
    intervals = intervals.filter((id) => id !== intervalId);
    return rawWindowClearInterval(intervalId);
  };
  global.setInterval = (handler, timeout, ...arg) => {
    const intervalId = rawWindowInterval(handler, timeout, ...args);
    intervals = [...intervals, intervalId];
    return intervalId;
  };
  // 返回释放这些泄漏的方法
  return function free() {
    intervals.forEach((id) => global.clearInterval(id));
    global.setInterval = rawWindowInterval;
    global.clearInterval = rawWindowClearInterval;
    // 这个patch有没有需要重建的场景,如果没有,则为空函数
    return function rebuild() {};
  };
}

这种返回取消功能的设计很精妙,在 vue 中也能找到类似设计。

// 监听返回取消监听方法,取消监听返回再重新监听的方法
const unwatch = this.$watch("xxx", () => {});
const rewatch = unwatch(); // 伪代码,实际上没有

我们来看最复杂的patchDynamicAppend实现,用于处理代码里动态插入scriptlink的场景。

const rawHeadAppendChild = HTMLHeadElement.prototype.appendChild;
export default function patchDynamicAppend(mounting, proxy) {
  let dynamicStyleSheetElements = [];
  // 劫持插入函数
  HTMLHeadElement.prototype.appendChild = function(element) {
    switch (element.tagName) {
      case LINK_TAG_NAME:
      // 如果是动态插入<style>标签到body上,则调整插入的位置到子应用挂载区
      case STYLE_TAG_NAME: {
        dynamicStyleSheetElements.push(stylesheetElement);
        return rawHeadAppendChild.call(appWrapperGetter(), stylesheetElement);
      }
      // 如果是动态插入<script>标签,则使用execScripts来执行这个脚本,然后把脚本替换为一段注释文本表示已执行过
      case SCRIPT_TAG_NAME: {
        const { src, text } = element;
        execScripts(null, [src ? src : `<script>${text}</script>`], proxy);
        const dynamicScriptCommentElement = document.createComment(
          src
            ? `dynamic script ${src} replaced by qiankun`
            : "dynamic inline script replaced by qiankun"
        );
        return rawHeadAppendChild.call(
          appWrapperGetter(),
          dynamicScriptCommentElement
        );
      }
    }
    return rawHeadAppendChild.call(this, element);
  };
  // 这里free不需要释放什么东西,因为style元素会随着内容区清除而自然消失
  return function free() {
    // 这里需要再下次继续挂载这个应用时重建style元素
    return function rebuild() {
      dynamicStyleSheetElements.forEach((stylesheetElement) => {
        document.head.appendChild.call(appWrapperGetter(), stylesheetElement);
      });
      if (mounting) dynamicStyleSheetElements = [];
    };
  };
}

2.6 父子应用通信

qiankun实现了一个简单的全局数据存储,作为single-spa事件的补充,父子应用都可以共同读写这个存储里的数据。

let globalState = {};
export function getMicroAppStateActions(id, isMaster) {
  return {
    // 事件变更回调
    onGlobalStateChange(callback, fireImmediately) {
      deps[id] = callback;
      const cloneState = cloneDeep(globalState);
      if (fireImmediately) {
        callback(cloneState, cloneState);
      }
    },
    // 设置全局状态
    setGlobalState(state) {
      const prevGlobalState = cloneDeep(globalState);
      Object.keys(deps).forEach((id) => {
        deps[id](cloneDeep(globalState), cloneDeep(prevGlobalState));
      });
      return true;
    },
    // 注销该应用下的依赖
    offGlobalStateChange() {
      delete deps[id];
    },
  };
}

2.7 关于预请求

预请求充分利用了importEntry把获取资源和执行资源分离的点来提前加载所有子应用的资源。

function prefetch(entry, opts) {
  if (!navigator.onLine || isSlowNetwork) {
    // Don't prefetch if in a slow network or offline
    return;
  }
  requestIdleCallback(async () => {
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(
      entry,
      opts
    );
    requestIdleCallback(getExternalStyleSheets);
    requestIdleCallback(getExternalScripts);
  });
}
apps.forEach(({ entry }) => prefetch(entry, opts));

以上分享了qiankunsingle-spa的原理,总的来说qiankun更面向一些子项目不可控,并且开发者不会刻意处理污染和内存泄漏的场景,而single-spa则更纯粹的是一个路由控制器,所有的污染和泄漏问题都需要开发者自行约束。

3. OPPO 云实践

OPPO云在实践qiankun微前端的落地过程中,也摸索出一些经验可进行分享。

3.1 关于沙箱

qiankun 的沙箱不是万能的

  • 沙箱只有一层的劫持,例如 Date.prototype.xxx 这样的改动是不会被还原的
  • 目前沙箱对于全局变量的作用在于屏蔽,而不是清除,并且屏蔽后这部分内存是保留的,后续会开放自定义沙箱的能力
  • 关于内存泄漏的概念,可以了解一下“常驻内存”的概念

    常驻内存是一种辅助工具程序,能假装退出,而仍驻留于内存当中,让你运行其它的应用,当你再切回应用时,可以立即应用这些内存,而不需要再次耗时创建

  • 排查内存问题时请使用无痕模式以及不使用任何 chrome 拓展,也推荐使用生产构建来排查

3.2 提取公共库

  • qiankun不建议共享依赖,担心原型链污染等问题。 single-spa推荐共享大型依赖,需要小心处理污染问题,它们都是推荐使用webpackexternal来共享依赖库。
  • 我们也推荐共享大的公共依赖,也是使用webpackexternal来共享依赖库,不过是每个子应用加载时都重复再加载一次库,相当于节省了相同库的下载时间,也保证了不同子应用间不会产生原型链污染,属于折中的方案。

参考链接

image.png

查看原文

赞 2 收藏 1 评论 0

OPPO互联网技术 发布了文章 · 2020-11-26

基于Kubernetes和OpenKruise的可变基础设施实践

本文来自OPPO互联网基础技术团队,转载请注名作者。同时欢迎关注我们的公众号:OPPO_tech,与你分享OPPO前沿互联网技术及活动。

1. 对于可变基础设施的思考

1.1 kubernetes中的可变与不可变基础设施

在云原生逐渐盛行的现在,不可变基础设施的理念已经逐渐深入人心。不可变基础设施最早是由Chad Fowler于2013年提出的,其核心思想为任何基础设施的实例一旦创建之后变成为只读状态,如需要修改和升级,则使用新的实例进行替换。这一理念的指导下,实现了运行实例的一致,因此在提升发布效率、弹性伸缩、升级回滚方面体现出了无与伦比的优势。

kubernetes是不可变基础设施理念的一个极佳实践平台。Pod作为k8s的最小单元,承担了应用实例这一角色。通过ReplicaSet从而对Pod的副本数进行控制,从而实现Pod的弹性伸缩。而进行更新时,Deployment通过控制两个ReplicaSet的副本数此消彼长,从而进行实例的整体替换,实现升级和回滚操作。

我们进一步思考,我们是否需要将Pod作为一个完全不可变的基础设施实例呢?其实在kubernetes本身,已经提供了一个替换image的功能,来实现Pod不变的情况下,通过更换image字段,实现Container的替换。这样的优势在于无需重新创建Pod,即可实现升级,直接的优势在于免去了重新调度等的时间,使得容器可以快速启动。

从这个思路延伸开来,那么我们其实可以将Pod和Container分为两层来看。将Container作为不可变的基础设施,确保应用实例的完整替换;而将Pod看为可变的基础设施,可以进行动态的改变,亦即可变层。

1.2 关于升级变化的分析

对于应用的升级变化种类,我们来进行一下分类讨论,将其分为以下几类:

升级变化类型说明
规格的变化cpu、内存等资源使用量的修改
配置的变化环境变量、配置文件等的修改
镜像的变化代码修改后镜像更新
健康检查的变化readinessProbe、livenessProbe配置的修改
其他变化调度域、标签修改等其他修改

针对不同的变化类型,我们做过一次抽样调查统计,可以看到下图的一个统计结果。

image.png

在一次升级变化中如果含有多个变化,则统计为多次。

可以看到支持镜像的替换可以覆盖一半左右的的升级变化,但是仍然有相当多的情况下导致不得不重新创建Pod。这点来说,不是特别友好。所以我们做了一个设计,将对于Pod的变化分为了三种Dynamic,Rebuild,Static三种。

修改类型修改类型说明修改举例对应变化类型
Dynamic 动态修改Pod不变,容器无需重建修改了健康检查端口健康检查的变化
Rebuild 原地更新Pod不变,容器需要重新创建更新了镜像、配置文件或者环境变量镜像的变化,配置的变化
Static 静态修改Pod需要重新创建修改了容器规格规格的变化

这样动态修改和原地更新的方式可以覆盖90%以上的升级变化。在Pod不变的情况下带来的收益也是显而易见的。

  1. 减少了调度、网络创建等的时间。
  2. 由于同一个应用的镜像大部分层都是复用的,大大缩短了镜像拉取的时间。
  3. 资源锁定,防止在集群资源紧缺时由于出让资源重新创建进入调度后,导致资源被其他业务抢占而无法运行。
  4. IP不变,对于很多有状态的服务十分友好。

2. Kubernetes与OpenKruise的定制

2.1 kubernetes的定制

那么如何来实现Dynamic和Rebuild更新呢?这里需要对kubernetes进行一下定制。

动态修改定制

liveness和readiness的动态修改支持相对来说较为简单,主要修改点在与prober_manager中增加了UpdatePod函数,用以判断当liveness或者readiness的配置改变时,停止原先的worker,重新启动新的worker。而后将UpdatePod嵌入到kubelet的HandlePodUpdates的流程中即可。

func (m *manager) UpdatePod(pod *v1.Pod) {
    m.workerLock.Lock()
    defer m.workerLock.Unlock()

    key := probeKey{podUID: pod.UID}
    for _, c := range pod.Spec.Containers {
        key.containerName = c.Name
        {
            key.probeType = readiness
            worker, ok := m.workers[key]
            if ok {
                if c.ReadinessProbe == nil {
                    //readiness置空了,原worker停止
                    worker.stop()
                } else if !reflect.DeepEqual(*worker.spec, *c.ReadinessProbe) {
                    //readiness配置改变了,原worker停止
                    worker.stop()
                }
            }
            if c.ReadinessProbe != nil {
                if !ok || (ok && !reflect.DeepEqual(*worker.spec, *c.ReadinessProbe)) {
                    //readiness配置改变了,启动新的worker
                    w := newWorker(m, readiness, pod, c)
                    m.workers[key] = w
                    go w.run()
                }
            }
        }
        {
            //liveness与readiness相似
            ......
        }
    }
}
原地更新定制

kubernetes原生支持了image的修改,对于env和volume的修改是未做支持的。因此我们对env和volume也支持了修改功能,以便其可以进行环境变量和配置文件的替换。这里利用了一个小技巧,就是我们在增加了一个ExcludedHash,用于计算Container内,包含env,volume在内的各项配置。

func HashContainerExcluded(container *v1.Container) uint64 {
    copyContainer := container.DeepCopy()
    copyContainer.Resources = v1.ResourceRequirements{}
    copyContainer.LivenessProbe = &v1.Probe{}
    copyContainer.ReadinessProbe = &v1.Probe{}
    hash := fnv.New32a()
    hashutil.DeepHashObject(hash, copyContainer)
    return uint64(hash.Sum32())
}

这样当env,volume或者image发生变化时,就可以直接感知到。在SyncPod时,用于在计算computePodActions时,发现容器的相关配置发生了变化,则将该容器进行Rebuild。

func (m *kubeGenericRuntimeManager) computePodActions(pod *v1.Pod, podStatus *kubecontainer.PodStatus) podActions {
    ......
    for idx, container := range pod.Spec.Containers {
        ......
        if expectedHash, actualHash, changed := containerExcludedChanged(&container, containerStatus); changed {
            // 当env,volume或者image更换时,则重建该容器。
            reason = fmt.Sprintf("Container spec exclude resources hash changed (%d vs %d).", actualHash, expectedHash)            
            restart = true
        }
        ......
        message := reason
        if restart {
            //将该容器加入到重建的列表中
            message = fmt.Sprintf("%s. Container will be killed and recreated.", message)
            changes.ContainersToStart = append(changes.ContainersToStart, idx)
        }
......
    return changes
}
Pod的生命周期

在Pod从调度完成到创建Running中,会有一个ContaienrCreating的状态用以标识容器在创建中。而原生中当image替换时,先前的一个容器销毁,后一个容器创建过程中,Pod状态会一直处于Running,容易有错误流量导入,用户也无法识别此时容器的状态。

因此我们为原地更新,在ContainerStatus里增加了ContaienrRebuilding的状态,同时在容器创建成功前Pod的Ready Condition置为False,以便表达容器整在重建中,应用在此期间不可用。利用此标识,可以在此期间方便识别Pod状态、隔断流量。

2.2 OpenKruise的定制

OpenKruise (https://openkruise.io/) 是阿里开源的一个项目,提供了一套在Kubernetes核心控制器之外的扩展 workload 管理和实现。其中Advanced StatefulSet,基于原生 StatefulSet 之上的增强版本,默认行为与原生完全一致,在此之外提供了原地升级、并行发布(最大不可用)、发布暂停等功能。

Advanced StatefulSet中的原地升级即与本文中的Rebuild一致,但是原生只支持替换镜像。因此我们在OpenKruise的基础上进行了定制,使其不仅可以支持image的原地更新,也可以支持当env、volume的原地更新以及livenessProbe、readinessProbe的动态更新。这个主要在shouldDoInPlaceUpdate函数中进行一下判断即可。这里就不再做代码展示了。

还在生产运行中还发现了一个基础库的小bug,我们也顺带向社区做了提交修复。https://github.com/openkruise...

另外,还有个小坑,就是在pod里为了标识不同的版本,加入了controller-revision-hash值。

[root@xxx ~]# kubectl get pod -n predictor  -o yaml predictor-0 
apiVersion: v1
kind: Pod
metadata:
  labels:
    controller-revision-hash: predictor-85f9455f6
...

一般来说,该值应该只使用hash值作为value就可以了,但是OpenKruise中采用了{sts-name}+{hash}的方式,这带来的一个小问题就是sts-name就要因为label value的长度受到限制了。

3. 写在最后

定制后的OpenKruise和kubernetes已经大规模在各个集群上上线,广泛应用在多个业务的后端运行服务中。经统计,通过原地更新覆盖了87%左右的升级部署需求,基本达到预期。

特别鸣谢阿里贡献的开源项目OpenKruise。

image.png

查看原文

赞 1 收藏 1 评论 0

OPPO互联网技术 发布了文章 · 2020-11-23

关于numa loadbance的死锁分析

背景:这个是在3.10.0-957.el7.x86_64 遇到的一例crash。下面列一下我们是怎么排查并解这个问题的。

一、故障现象

Oppo云智能监控发现机器down机:

 KERNEL: /usr/lib/debug/lib/modules/3.10.0-957.el7.x86_64/vmlinux 
 ....
       PANIC: "Kernel panic - not syncing: Hard LOCKUP"
         PID: 14
     COMMAND: "migration/1"
        TASK: ffff8f1bf6bb9040  [THREAD_INFO: ffff8f1bf6bc4000]
         CPU: 1
       STATE: TASK_INTERRUPTIBLE (PANIC)

crash> bt
PID: 14     TASK: ffff8f1bf6bb9040  CPU: 1   COMMAND: "migration/1"
 #0 [ffff8f4afbe089f0] machine_kexec at ffffffff83863674
 #1 [ffff8f4afbe08a50] __crash_kexec at ffffffff8391ce12
 #2 [ffff8f4afbe08b20] panic at ffffffff83f5b4db
 #3 [ffff8f4afbe08ba0] nmi_panic at ffffffff8389739f
 #4 [ffff8f4afbe08bb0] watchdog_overflow_callback at ffffffff83949241
 #5 [ffff8f4afbe08bc8] __perf_event_overflow at ffffffff839a1027
 #6 [ffff8f4afbe08c00] perf_event_overflow at ffffffff839aa694
 #7 [ffff8f4afbe08c10] intel_pmu_handle_irq at ffffffff8380a6b0
 #8 [ffff8f4afbe08e38] perf_event_nmi_handler at ffffffff83f6b031
 #9 [ffff8f4afbe08e58] nmi_handle at ffffffff83f6c8fc
#10 [ffff8f4afbe08eb0] do_nmi at ffffffff83f6cbd8
#11 [ffff8f4afbe08ef0] end_repeat_nmi at ffffffff83f6bd69
    [exception RIP: native_queued_spin_lock_slowpath+462]
    RIP: ffffffff839121ae  RSP: ffff8f1bf6bc7c50  RFLAGS: 00000002
    RAX: 0000000000000001  RBX: 0000000000000082  RCX: 0000000000000001
    RDX: 0000000000000101  RSI: 0000000000000001  RDI: ffff8f1afdf55fe8---锁
    RBP: ffff8f1bf6bc7c50   R8: 0000000000000101   R9: 0000000000000400
    R10: 000000000000499e  R11: 000000000000499f  R12: ffff8f1afdf55fe8
    R13: ffff8f1bf5150000  R14: ffff8f1afdf5b488  R15: ffff8f1bf5187818
    ORIG_RAX: ffffffffffffffff  CS: 0010  SS: 0018
--- <NMI exception stack> ---
#12 [ffff8f1bf6bc7c50] native_queued_spin_lock_slowpath at ffffffff839121ae
#13 [ffff8f1bf6bc7c58] queued_spin_lock_slowpath at ffffffff83f5bf4b
#14 [ffff8f1bf6bc7c68] _raw_spin_lock_irqsave at ffffffff83f6a487
#15 [ffff8f1bf6bc7c80] cpu_stop_queue_work at ffffffff8392fc70
#16 [ffff8f1bf6bc7cb0] stop_one_cpu_nowait at ffffffff83930450
#17 [ffff8f1bf6bc7cc0] load_balance at ffffffff838e4c6e
#18 [ffff8f1bf6bc7da8] idle_balance at ffffffff838e5451
#19 [ffff8f1bf6bc7e00] __schedule at ffffffff83f67b14
#20 [ffff8f1bf6bc7e88] schedule at ffffffff83f67bc9
#21 [ffff8f1bf6bc7e98] smpboot_thread_fn at ffffffff838ca562
#22 [ffff8f1bf6bc7ec8] kthread at ffffffff838c1c31
#23 [ffff8f1bf6bc7f50] ret_from_fork_nospec_begin at ffffffff83f74c1d
crash> 

二、故障现象分析

hardlock一般是由于关中断时间过长,从堆栈看,上面的"migration/1" 进程在抢spinlock,
由于_raw_spin_lock_irqsave 会先调用 arch_local_irq_disable,然后再去拿锁,而
arch_local_irq_disable 是常见的关中断函数,下面分析这个进程想要拿的锁被谁拿着。

x86架构下,native_queued_spin_lock_slowpath的rdi就是存放锁地址的

x86架构下,native_queued_spin_lock_slowpath的rdi就是存放锁地址的

crash> arch_spinlock_t ffff8f1afdf55fe8
struct arch_spinlock_t {
  val = {
    counter = 257
  }
}

下面,我们需要了解,这个是一把什么锁。
从调用链分析 idle_balance-->load_balance-->stop_one_cpu_nowait-->cpu_stop_queue_work
反汇编 cpu_stop_queue_work 拿锁阻塞的代码:

crash> dis -l ffffffff8392fc70
/usr/src/debug/kernel-3.10.0-957.el7/linux-3.10.0-957.el7.x86_64/kernel/stop_machine.c: 91
0xffffffff8392fc70 <cpu_stop_queue_work+48>:    cmpb   $0x0,0xc(%rbx)

     85 static void cpu_stop_queue_work(unsigned int cpu, struct cpu_stop_work *work)
     86 {
     87         struct cpu_stopper *stopper = &per_cpu(cpu_stopper, cpu);
     88         unsigned long flags;
     89 
     90         spin_lock_irqsave(&stopper->lock, flags);---所以是卡在拿这把锁
     91         if (stopper->enabled)
     92                 __cpu_stop_queue_work(stopper, work);
     93         else
     94                 cpu_stop_signal_done(work->done, false);
     95         spin_unlock_irqrestore(&stopper->lock, flags);
     96 }

看起来 需要根据cpu号,来获取对应的percpu变量 cpu_stopper,这个入参在 load_balance 函数中找到的
最忙的rq,然后获取其对应的cpu号:

   6545 static int load_balance(int this_cpu, struct rq *this_rq,
   6546                         struct sched_domain *sd, enum cpu_idle_type idle,
   6547                         int *should_balance)
   6548 {
....
   6735                         if (active_balance) {
   6736                                 stop_one_cpu_nowait(cpu_of(busiest),
   6737                                         active_load_balance_cpu_stop, busiest,
   6738                                         &busiest->active_balance_work);
   6739                         }
....
  6781 }

  crash> dis -l load_balance |grep stop_one_cpu_nowait -B 6
0xffffffff838e4c4d <load_balance+2045>:    callq  0xffffffff83f6a0e0 <_raw_spin_unlock_irqrestore>
/usr/src/debug/kernel-3.10.0-957.el7/linux-3.10.0-957.el7.x86_64/kernel/sched/fair.c: 6736
0xffffffff838e4c52 <load_balance+2050>:    mov    0x930(%rbx),%edi------------根据rbx可以取cpu号,rbx就是最忙的rq
0xffffffff838e4c58 <load_balance+2056>:    lea    0x908(%rbx),%rcx
0xffffffff838e4c5f <load_balance+2063>:    mov    %rbx,%rdx
0xffffffff838e4c62 <load_balance+2066>:    mov    $0xffffffff838de690,%rsi
0xffffffff838e4c69 <load_balance+2073>:    callq  0xffffffff83930420 <stop_one_cpu_nowait>

然后我们再栈中取的数据如下:

最忙的组是:
crash> rq.cpu ffff8f1afdf5ab80
  cpu = 26

也就是说,1号cpu在等 percpu变量cpu_stopper 的26号cpu的锁。

然后我们搜索这把锁在其他哪个进程的栈中,找到了如下:

ffff8f4957fbfab0: ffff8f1afdf55fe8 --------这个在  355608 的栈中
crash> kmem ffff8f4957fbfab0
    PID: 355608
COMMAND: "custom_exporter"
   TASK: ffff8f4aea3a8000  [THREAD_INFO: ffff8f4957fbc000]
    CPU: 26--------刚好也是运行在26号cpu的进程
  STATE: TASK_RUNNING (ACTIVE)

下面,就需要分析,为什么位于26号cpu的进程 custom_exporter 会长时间拿着 ffff8f1afdf55fe8

我们来分析26号cpu的堆栈:

crash> bt -f 355608
PID: 355608  TASK: ffff8f4aea3a8000  CPU: 26  COMMAND: "custom_exporter"
.....
 #3 [ffff8f1afdf48ef0] end_repeat_nmi at ffffffff83f6bd69
    [exception RIP: try_to_wake_up+114]
    RIP: ffffffff838d63d2  RSP: ffff8f4957fbfa30  RFLAGS: 00000002
    RAX: 0000000000000001  RBX: ffff8f1bf6bb9844  RCX: 0000000000000000
    RDX: 0000000000000001  RSI: 0000000000000003  RDI: ffff8f1bf6bb9844
    RBP: ffff8f4957fbfa70   R8: ffff8f4afbe15ff0   R9: 0000000000000000
    R10: 0000000000000000  R11: 0000000000000000  R12: 0000000000000000
    R13: ffff8f1bf6bb9040  R14: 0000000000000000  R15: 0000000000000003
    ORIG_RAX: ffffffffffffffff  CS: 0010  SS: 0000
--- <NMI exception stack> ---
 #4 [ffff8f4957fbfa30] try_to_wake_up at ffffffff838d63d2
    ffff8f4957fbfa38: 000000000001ab80 0000000000000086 
    ffff8f4957fbfa48: ffff8f4afbe15fe0 ffff8f4957fbfb48 
    ffff8f4957fbfa58: 0000000000000001 ffff8f4afbe15fe0 
    ffff8f4957fbfa68: ffff8f1afdf55fe0 ffff8f4957fbfa80 
    ffff8f4957fbfa78: ffffffff838d6705 
 #5 [ffff8f4957fbfa78] wake_up_process at ffffffff838d6705
    ffff8f4957fbfa80: ffff8f4957fbfa98 ffffffff8392fc05 
 #6 [ffff8f4957fbfa88] __cpu_stop_queue_work at ffffffff8392fc05
    ffff8f4957fbfa90: 000000000000001a ffff8f4957fbfbb0 
    ffff8f4957fbfaa0: ffffffff8393037a 
 #7 [ffff8f4957fbfaa0] stop_two_cpus at ffffffff8393037a
.....
    ffff8f4957fbfbb8: ffffffff838d3867 
 #8 [ffff8f4957fbfbb8] migrate_swap at ffffffff838d3867
    ffff8f4957fbfbc0: ffff8f4aea3a8000 ffff8f1ae77dc100 -------栈中的 migration_swap_arg
    ffff8f4957fbfbd0: 000000010000001a 0000000080490f7c 
    ffff8f4957fbfbe0: ffff8f4aea3a8000 ffff8f4957fbfc30 
    ffff8f4957fbfbf0: 0000000000000076 0000000000000076 
    ffff8f4957fbfc00: 0000000000000371 ffff8f4957fbfce8 
    ffff8f4957fbfc10: ffffffff838dd0ba 
 #9 [ffff8f4957fbfc10] task_numa_migrate at ffffffff838dd0ba
    ffff8f4957fbfc18: ffff8f1afc121f40 000000000000001a 
    ffff8f4957fbfc28: 0000000000000371 ffff8f4aea3a8000 ---这里ffff8f4957fbfc30 就是 task_numa_env 的存放在栈中的地址
    ffff8f4957fbfc38: 000000000000001a 000000010000003f 
    ffff8f4957fbfc48: 000000000000000b 000000000000022c 
    ffff8f4957fbfc58: 00000000000049a0 0000000000000012 
    ffff8f4957fbfc68: 0000000000000001 0000000000000003 
    ffff8f4957fbfc78: 000000000000006f 000000000000499f 
    ffff8f4957fbfc88: 0000000000000012 0000000000000001 
    ffff8f4957fbfc98: 0000000000000070 ffff8f1ae77dc100 
    ffff8f4957fbfca8: 00000000000002fb 0000000000000001 
    ffff8f4957fbfcb8: 0000000080490f7c ffff8f4aea3a8000 ---rbx压栈在此,所以这个就是current
    ffff8f4957fbfcc8: 0000000000017a48 0000000000001818 
    ffff8f4957fbfcd8: 0000000000000018 ffff8f4957fbfe20 
    ffff8f4957fbfce8: ffff8f4957fbfcf8 ffffffff838dd4d3 
#10 [ffff8f4957fbfcf0] numa_migrate_preferred at ffffffff838dd4d3
    ffff8f4957fbfcf8: ffff8f4957fbfd88 ffffffff838df5b0 
.....
crash> 
crash> 

整体上看,26号上的cpu也正在进行numa的balance动作,简单展开介绍一下numa在balance下的动作
在 task_tick_fair 函数中:

static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
{
    struct cfs_rq *cfs_rq;
    struct sched_entity *se = &curr->se;

    for_each_sched_entity(se) {
        cfs_rq = cfs_rq_of(se);
        entity_tick(cfs_rq, se, queued);
    }

    if (numabalancing_enabled)----------如果开启numabalancing,则会调用task_tick_numa
        task_tick_numa(rq, curr);

    update_rq_runnable_avg(rq, 1);
}

而 task_tick_numa 会根据扫描情况,将当前进程需要numa_balance的时候推送到一个work中。
通过调用change_prot_numa将所有映射到VMA的PTE页表项该为PAGE_NONE,使得下次进程访问页表的时候
产生缺页中断,handle_pte_fault 函数 会由于缺页中断的机会来根据numa 选择更好的node,具体不再展开。

在 26号cpu的调用链中,stop_two_cpus-->cpu_stop_queue_two_works-->__cpu_stop_queue_work 函数
由于 cpu_stop_queue_two_works 被内联了,但是 cpu_stop_queue_two_works 调用 __cpu_stop_queue_work
有两次,所以需要根据压栈地址判断当前是哪次调用出现问题。

    227 static int cpu_stop_queue_two_works(int cpu1, struct cpu_stop_work *work1,
    228                                     int cpu2, struct cpu_stop_work *work2)
    229 {
    230         struct cpu_stopper *stopper1 = per_cpu_ptr(&cpu_stopper, cpu1);
    231         struct cpu_stopper *stopper2 = per_cpu_ptr(&cpu_stopper, cpu2);
    232         int err;
    233 
    234         lg_double_lock(&stop_cpus_lock, cpu1, cpu2);
    235         spin_lock_irq(&stopper1->lock);---注意到这里已经持有了stopper1的锁
    236         spin_lock_nested(&stopper2->lock, SINGLE_DEPTH_NESTING);
.....
    243         __cpu_stop_queue_work(stopper1, work1);
    244         __cpu_stop_queue_work(stopper2, work2);
.....
    251 }

根据压栈的地址:

 #5 [ffff8f4957fbfa78] wake_up_process at ffffffff838d6705
    ffff8f4957fbfa80: ffff8f4957fbfa98 ffffffff8392fc05 
 #6 [ffff8f4957fbfa88] __cpu_stop_queue_work at ffffffff8392fc05
    ffff8f4957fbfa90: 000000000000001a ffff8f4957fbfbb0 
    ffff8f4957fbfaa0: ffffffff8393037a 
 #7 [ffff8f4957fbfaa0] stop_two_cpus at ffffffff8393037a
    ffff8f4957fbfaa8: 0000000100000001 ffff8f1afdf55fe8 

crash> dis -l ffffffff8393037a 2
/usr/src/debug/kernel-3.10.0-957.el7/linux-3.10.0-957.el7.x86_64/kernel/stop_machine.c: 244
0xffffffff8393037a <stop_two_cpus+394>: lea    0x48(%rsp),%rsi
0xffffffff8393037f <stop_two_cpus+399>: mov    %r15,%rdi

说明压栈的是244行的地址,也就是说目前调用的是243行的 __cpu_stop_queue_work。

然后分析对应的入参:

crash> task_numa_env ffff8f4957fbfc30
struct task_numa_env {
  p = 0xffff8f4aea3a8000, 
  src_cpu = 26, 
  src_nid = 0, 
  dst_cpu = 63, 
  dst_nid = 1, 
  src_stats = {
    nr_running = 11, 
    load = 556, ---load高
    compute_capacity = 18848, ---容量相当
    task_capacity = 18, 
    has_free_capacity = 1
  }, 
  dst_stats = {
    nr_running = 3, 
    load = 111, ---load低,且容量相当,要迁移过来
    compute_capacity = 18847, ---容量相当
    task_capacity = 18, 
    has_free_capacity = 1
  }, 
  imbalance_pct = 112, 
  idx = 0, 
  best_task = 0xffff8f1ae77dc100, ---要对调的task,是通过 task_numa_find_cpu-->task_numa_compare-->task_numa_assign 来获取的
  best_imp = 763, 
  best_cpu = 1---最佳的swap的对象对于1号cpu
}

crash> migration_swap_arg ffff8f4957fbfbc0 
struct migration_swap_arg {
  src_task = 0xffff8f4aea3a8000, 
  dst_task = 0xffff8f1ae77dc100, 
  src_cpu = 26, 
  dst_cpu = 1-----选择的dst cpu为1
}

根据 cpu_stop_queue_two_works 的代码,它在持有 cpu_stopper:26号cpu锁的情况下,去
调用try_to_wake_up ,wake的对象是 用来migrate的 kworker。

static void __cpu_stop_queue_work(struct cpu_stopper *stopper,
                    struct cpu_stop_work *work)
{
    list_add_tail(&work->list, &stopper->works);
    wake_up_process(stopper->thread);//其实一般就是唤醒 migration
}

由于最佳的cpu对象为1,所以需要cpu上的migrate来拉取进程。

crash> p cpu_stopper:1
per_cpu(cpu_stopper, 1) = $33 = {
  thread = 0xffff8f1bf6bb9040, ----需要唤醒的目的task
  lock = {
    {
      rlock = {
        raw_lock = {
          val = {
            counter = 1
          }
        }
      }
    }
  }, 
  enabled = true, 
  works = {
    next = 0xffff8f4957fbfac0, 
    prev = 0xffff8f4957fbfac0
  }, 
  stop_work = {
    list = {
      next = 0xffff8f4afbe16000, 
      prev = 0xffff8f4afbe16000
    }, 
    fn = 0xffffffff83952100, 
    arg = 0x0, 
    done = 0xffff8f1ae3647c08
  }
}
crash> kmem 0xffff8f1bf6bb9040
CACHE            NAME                 OBJSIZE  ALLOCATED     TOTAL  SLABS  SSIZE
ffff8eecffc05f00 task_struct             4152       1604      2219    317    32k
  SLAB              MEMORY            NODE  TOTAL  ALLOCATED  FREE
  fffff26501daee00  ffff8f1bf6bb8000     1      7          7     0
  FREE / [ALLOCATED]
  [ffff8f1bf6bb9040]

    PID: 14
COMMAND: "migration/1"--------------目的task就是对应的cpu上的migration 
   TASK: ffff8f1bf6bb9040  [THREAD_INFO: ffff8f1bf6bc4000]
    CPU: 1
  STATE: TASK_INTERRUPTIBLE (PANIC)

      PAGE         PHYSICAL      MAPPING       INDEX CNT FLAGS
fffff26501daee40 3076bb9000                0        0  0 6fffff00008000 tail

现在的问题是,虽然我们知道了当前cpu26号进程在拿了锁的情况下去唤醒1号cpu上的migrate进程,
那么为什么会迟迟不释放锁,导致1号cpu因为等待该锁时间过长而触发了hardlock的panic呢?

下面就分析,为什么它持锁的时间这么长:

 #3 [ffff8f1afdf48ef0] end_repeat_nmi at ffffffff83f6bd69
    [exception RIP: try_to_wake_up+114]
    RIP: ffffffff838d63d2  RSP: ffff8f4957fbfa30  RFLAGS: 00000002
    RAX: 0000000000000001  RBX: ffff8f1bf6bb9844  RCX: 0000000000000000
    RDX: 0000000000000001  RSI: 0000000000000003  RDI: ffff8f1bf6bb9844
    RBP: ffff8f4957fbfa70   R8: ffff8f4afbe15ff0   R9: 0000000000000000
    R10: 0000000000000000  R11: 0000000000000000  R12: 0000000000000000
    R13: ffff8f1bf6bb9040  R14: 0000000000000000  R15: 0000000000000003
    ORIG_RAX: ffffffffffffffff  CS: 0010  SS: 0000
--- <NMI exception stack> ---
 #4 [ffff8f4957fbfa30] try_to_wake_up at ffffffff838d63d2
    ffff8f4957fbfa38: 000000000001ab80 0000000000000086 
    ffff8f4957fbfa48: ffff8f4afbe15fe0 ffff8f4957fbfb48 
    ffff8f4957fbfa58: 0000000000000001 ffff8f4afbe15fe0 
    ffff8f4957fbfa68: ffff8f1afdf55fe0 ffff8f4957fbfa80 

    crash> dis -l ffffffff838d63d2
/usr/src/debug/kernel-3.10.0-957.el7/linux-3.10.0-957.el7.x86_64/kernel/sched/core.c: 1790
0xffffffff838d63d2 <try_to_wake_up+114>:        mov    0x28(%r13),%eax

   1721 static int
   1722 try_to_wake_up(struct task_struct *p, unsigned int state, int wake_flags)
   1723 {
.....
   1787          * If the owning (remote) cpu is still in the middle of schedule() with
   1788          * this task as prev, wait until its done referencing the task.
   1789          */
   1790         while (p->on_cpu)---------原来循环在此
   1791                 cpu_relax();
.....
   1814         return success;
   1815 }

我们用一个简单的图来表示一下这个hardlock:

    CPU1                                    CPU26
    schedule(.prev=migrate/1)               <fault>
      pick_next_task()                        ...
        idle_balance()                          migrate_swap()
          active_balance()                        stop_two_cpus()
                                                    spin_lock(stopper0->lock)
                                                    spin_lock(stopper1->lock)
                                                    try_to_wake_up
                                                      pause() -- waits for schedule()
            stop_one_cpu(1)
              spin_lock(stopper26->lock) -- waits for stopper lock

查看上游的补丁,

 static void __cpu_stop_queue_work(struct cpu_stopper *stopper,
-                    struct cpu_stop_work *work)
+                       struct cpu_stop_work *work,
+                       struct wake_q_head *wakeq)
 {
     list_add_tail(&work->list, &stopper->works);
-    wake_up_process(stopper->thread);
+    wake_q_add(wakeq, stopper->thread);
 }

三、故障复现

由于这个是一个race condition导致的hardlock,逻辑上分析已经没有问题了,就没有花时间去复现,
该环境运行一个dpdk的node,不过为了性能设置了只在一个numa节点上运行,可以频繁造成numa的不均衡,所以要复现的同学,
可以参考单numa节点上运行dpdk来复现,会概率大一些。

四、故障规避或解决

我们的解决方案是:

  1. 关闭numa的自动balance.
  2. 手工合入 linux社区的 0b26351b910f 补丁
  3. 这个补丁在centos的 3.10.0-974.el7 合入了:

    [kernel] stop_machine, sched: Fix migrate_swap() vs. active_balance() deadlock (Phil Auld) [1557061]

同时红帽又反向合入到了3.10.0-957.27.2.el7.x86_64,所以把centos内核升级到 3.10.0-957.27.2.el7.x86_64也是一种选择。

image.png

查看原文

赞 0 收藏 0 评论 0

OPPO互联网技术 发布了文章 · 2020-11-13

如何用 CI (持续集成) 保证研发质量

本文来自OPPO互联网基础技术团队,转载请注名作者。同时欢迎关注我们的公众号:OPPO_tech,与你分享OPPO前沿互联网技术及活动。

1. 背景

分布式技术的发展深刻地改变了我们编程的模式和思考软件的模式。分布式很好的解决性能扩展,可靠性,组件可用性等问题,但是单机转变成分布式却加大了系统的复杂性,对于组件的开发,测试,部署,发布都提出更高的要求。那么,针对复杂的分布式系统怎么保证软件质量和系统的稳定性?首先看下,传统软件产品活动的大致流程,简化流程大概是 3 大块:

开发 -> QA -> 灰度上线!

一般如下图:

流程有个很大的问题,质量全靠 QA 测,对接全靠人力,沟通成本大,遗漏问题多,一般有几个常见的问题:

  1. QA 很难每次都测试全面,毕竟QA毕竟是人,人的主观因素太大,有时候人为判断觉得简单,不用测的地方很可能有漏了。或者觉得修改点太简单,觉得不至于出问题,就不再全面的测试。以至于可能会有基本功能问题;
  2. 测试速度慢,效率太低,QA 资源浪费,如果每次 QA 都需要全量的测试,那么重复工作太多,效率太低,效果也不好,对于这些重复工作,本可以更好,更快的解决,不至于 QA 就为了测试这么点东西,而没有精力去做更多的事情;
  3. 甚至说编译不过的代码都有可能遗漏到 QA;
  4. 闭环太慢,开发功能如果有问题,等到 QA 测出来,让后再反馈到开发这个闭环就太慢了。更不必说,问题漏到线上,再反馈到开发人员,那么戴江就更大了;
  5. 多个开发人员并行开发的时候,工作可能相互影响,小问题越积越多,功能集成的时候可能非常耗时;

我们提出的改进点:

  1. 核心点之一:问题发现要早,发现越早,代价越小;
  2. 核心点之一:问题闭环要快,闭环越快,效率越高;
  3. 重复工作自动化,减少人的无效劳作;
  4. 多开发人员的时候,功能持续集成,问题拆小,提早发现;

最核心的一点就是:”自动化闭环问题“。

当前的复杂的软件系统对质量和效率提出了更高的要求,所以响应的软件活动必须要高度自动化才能达到要求。自动化触发、自动化测试、自动化闭环、自动化发布、自动化卡点等一系列的保证,一切能够事先预知且可固化的行为都应该自动化,把效率和质量提升,而让人去做更聪明的事情。

我们的思考:近些年来,对于自动化有 Continuous Integration,Continuous Delivery,Continuous Deployment 的一些理论和实践。这三者来说,突出“持续”二字,“持续”是为了达到“快”的目的,“快速迭代”,“快速响应”,“快速闭环”,“快”是核心竞争力。一般大家的共识流程分类如下:

对于开发者来说,接触到更多的是 Continuous Integration(持续集成),CI 把通过自动化,把流程固化下来,保证代码集成的有序、可靠,确保版本可控,问题可追溯,代码的活动中通过自动化,降低了人为主观的出错率,提高速度,提高版本质量和效率。

2. CI 是什么?

CI 即是持续集成(Continuous Integration),是当今软件活动中至关重要的一环。CI 一般由开发人员递交代码改动所触发,CI 在中间环境做自动化验证,CI 验证过后,即经过了基本质量保证,那么就可以允许下一步的软件活动。

持续集成说白了就是一种软件开发实践,即团队开发成员尽可能的快的集成,每次集成通过自动化的构建(包括编译,发布,自动化测试)来验证,从而尽早地发现集成错误。因为问题发现的越早,那么问题解决的成本就越少。

一般来说,持续集成需要打通几个环节:

  1. 代码提交(git)
  2. 任务构建(jenkins)
  3. 部署测试(ansible,shell,puppet)

划重点:CI 过程由代码活动触发

代码活动关注两个时间点:

  • Pre-Merge :代码改动合入主干分支前夕触发。集成的对象是代码改动与主干最新代码 Merge 之后的代码,目的是验证代码改动是否能够合入主干;
  • Post-Merge :代码改动合入主干分支之后触发。集成对象就是最新的主干分支代码,目的是验证合入改动代码之后主干是否能够正常工作;

Pre-Merge 和 Post-Merge 关注点不同,缺一不可。差异在哪里?如果只有一个开发者,那么 Pre-Merge 和 Post-Merge 的测试对象是相同的。在多个开发者递交代码的时候,Pre-Merge 和 Post-Merge 就会呈现差异,他们的 CI 测试对象不同。

换句话说,Pre-Merge 是并行的,每个开发分支想要合入主干都会触发Pre-Merge CI,CI 的测试对象是 <开发分支+主干分支>, Post-Merge 是串行的,测试对象永远都是最新的主干分支代码。

3. CI 的四个思考

3.1 CI 怎么触发?

代码活动触发:

一般有两个触发点,Pre-Merge,Post-Merge ,分别是代码合入主干之前,主干代码合入之后。

定时触发。

3.2 CI 触发之后做什么?

CI 触发了之后做什么?说白了就是构建任务做了啥,一般有几个流程:

  1. Checkout,Pre-Merge 代码——校验 MR 合入是否合法;
  2. 代码编译 —— 校验代码编译是否合法;
  3. 静态检查 —— 校验静态语法是否合法;
  4. 单元测试 —— 回归测试函数单元合法;
  5. 冒烟测试 —— 简单测试系统是否正常;
  6. 接口测试 —— 测试用户接口是否正常;
  7. 性能基准测试 —— 测试性能是否符合预期;

3.3 怎么闭环问题?

先思考可能会有什么问题:

  1. Pre-Merge 代码冲突;
  2. 代码编译失败;
  3. 静态检查失败;
  4. 单元测试回归测试不通过;
  5. 冒烟测试步通过,接口测试失败。。。;

递交一个代码 MR 递交可能遇到以上问题,那么怎么才能快速闭环这个问题呢?

首先,得有手段通知到开发者

解决:

  1. MR 的 comment,CI 活动失败之后,直接以评论的方式自动添加到 MR;
  2. 邮件,触发的一次 MR ,失败了以邮件的方式发送到相关人;

再者,得有手段让开发人员知道问题

解决:

  1. 开发者知道自己的 MR 触发 CI 失败之后,得知道怎么去排查问题 —— 测试报告,

    • 比如单元测试失败,要有单元测试报告,接口测试失败要有接口测试报告;
  2. 每次构建任务保留归档线索,以便排查;

3.4 CI 构建活动输出什么?

  1. 单元测试报告;
  2. 接口测试报告;
  3. 代码覆盖率报告;
  4. 接口覆盖率报告;
  5. 构建版本包(持续化部署需要);

4. CI 平台选型

一般对多数的公司来说,不需要自己研发一个 CI 平台,有很多优秀的开源 CI 平台工具,工具之间并没有绝对的差异优势,这里就不进行选型了,我们以一个 Jenkins 完整示例来说明 CI 的使用方法和技巧。代码仓库我们使用 Gitlab,CI 平台我们使用开源的 Jenkins 作为演示。一步步完成我们需要得几大模块功能。

平台选型:代码平台 Gitlab,CI 平台 Jenkins;

5. CI 的流程实践

CI 主要把关的是代码活动,一般有两个触发点:

  1. 代码合入主干前,触发 CI 测试,目的是校验本次合入是否符合质量预期,如果不符合,那么不准代码合入主干;
  2. 代码合入主干后,触发 CI 测试,目的是校验最新的主干分支是否符合质量预期;

Pre-Merge 触发过程

  1. 开发代码递交 Merge Request ( github 上习惯叫做 PR,gitlab 上习惯叫做 MR )
  2. MR 自动触发 CI 构建事件
  3. 运行 静态检查,Merge 检查,单元测试,冒烟测试,集成测试,全部通过之后,代码才允许 Merge 合入主干分支;
  4. 进行下一步软件活动

Post-Merge 触发过程

  1. 管理员审核 Merge Request 通过,代码合入主干,触发 Post-Merge 事件;
  2. CI 平台收到事件,自动进行 CI 构建;
  3. 构建完成,进行下一步软件活动;

6. Jenkins 平台构建

6.1 Jenkins 平台搭建

jenkins 是 java 程序开发的,安装是非常方便的,去官网上下载一个 war 包,然后后台拉起运行即可。运行命令如下:

启动

nohup /usr/bin/java -jar jenkins.war --httpPort=8888 >> jenkins.log 2>&1 &

这样 jenkins 平台就拉起来了,超简单。

6.1.1 初始化平台

初始密钥

平台第一次搭建需要做一些配置,在日志里找到一个“初始密钥”注意一下提示:

*************************************************************
*************************************************************
*************************************************************

Jenkins initial setup is required. An admin user has been created and a password generated.
Please use the following password to proceed to installation:

5ddce31f4a0d48b4b7d6d71ff41d94a8

This may also be found at: /root/software/jenkins/workhome/secrets/initialAdminPassword

*************************************************************
*************************************************************
*************************************************************

这个是最初始的超级用户密码,待会配置的 jenkins 时候会用上,所以赶紧拿个小本本记下。

登录网页

现在打开浏览器,登录 jenkins 的网页,我们下面做 jenkins 的初始化:

点击 “Continue”,进行下一步,下一步就是定制一些插件安装了。

第一次定制插件安装

这个是可选的,这个根据自己需求选择插件,为了省事,选第一种就好,之后也很方便在平台上下载插件。

安装插件过程(jenkins 几乎完全由插件组装起功能):

更新成功的就会显示“绿色”。插件安装完之后,下一步就是配置第一个超级用户了。

配置完成之后,点击 "Save and Continue" ,最后一步,配置 url ,点击 finish 即可:

现在基本配置已经完成,可以开始愉快的使用 jenkins 了。

6.1.2 使用小技巧

中文化配置

jenkins 平台搭建好之后,默认的是英文的,在国内的话可能没必要,我们可以安装中文化插件来更友好的展示我们的 jenkins。分两个步骤:

步骤一:安装插件:”Locale“:

"Manage Jenkins" -> "Manage Plugins" -> "Available"

步骤二:安装完之后,配置 Configure

账号配置

6.2 Jenkins 插件安装

6.2.1 插件更新地址

这里推荐国内的插件源地址,因为官网的网络访问不是很稳定。比如以下是清华的镜像源。

https://mirrors.tuna.tsinghua.edu.cn/jenkins/updates/update-center.json

6.2.2 必备插件安装

jenkins 功能都是由插件提供,有些插件是必须配备的,才能提供完备的 CI 功能,比如流水线 Pipeline 。这里列举几大关键插件的使用方法。

pipeline

jenkins 必备插件,流水线插件,能否非常方便的让你定义流程,调度节点,分配资源,处理结果等。

blue ocean

pipeline 的可视化插件,pipeline 还是声明式代码编写,如果要能让人更方便的使用,那么需要一个可视化的工具,blue ocean 就是为此而生。

junit

测试报告的一个解析插件,这也是一种较为通用的测试报告格式。

Cobertura Plugin

覆盖率展示的一个插件。单测跑完,需要有手段知道覆盖率的情况,并且需要能方便的闭环处理。

  1. 显示覆盖率的情况;
  2. 代码的覆盖详情,方便开发人员闭环处理(细化到每一行代码);
GitLab

我们的演示以 Gitlab 作为例子,需要和 GitLab 进行交互,所以需要安装插件用来接受 GitLab 事件,并反馈 CI 结果。

6.3 Jenkins 任务创建

6.3.1 创建任务(item)

item 就是 CI 的项目,item 由管理员静态创建配置好,触发起来就是 job 了。每触发一次,job 编号都是递增的。点击 New Item 创建一个”流水线“ 的项目。

6.3.2 创建视图(view)

View 是什么概念?View 可以把一些有业务意义的任务归纳起来,在一个列表中显示。可以点击 ”New View“ 进行创建。

视图会展示 item ,你可以选择性的勾选。

6.4 Jenkins 流水线

流水线框架是通过 Pipeline 脚本来描述流程,Pipeline 有两种创建方式,两种语法:

  1. 声明式流水线语法
  2. 脚本化流水线语法

现在官方推荐的是声明式流水线语法。那么,声明式的语法是什么样子的?声明式语法特点之一:顶层必须以 pipeline {} 开始。

7. Jenkins 和 Gitlab 交互

这一步就是最关键的东西,Jenkins 搭建好了之后,如果只是一个孤岛平台,那么没有任何意义,它必须参与到软件开发流程中去才能发挥效果。交互示意图如下:

我们看到,Gitlab 的代码活动需要以事件的形式触发 Jenkins,Jenkins 执行完一系列活动之后,需要把结果反馈到 Gitlab,并且能够影响到 Gitlab 的下一步活动,所以 Gitlab 和 Jenkins 需要相互配置关联。

7.1 Gitlab 配置

为什么需要配置 gitlab ?因为需要打通 gitlab 到 jenkins 的路。gitlab 作为代码仓库,主要产生项目代码相关的事件,比如 Merge Request,Push Commit 等,当 gitlab 产生这些事件的时候,需要自动把这个事件推送给 jenkins ,这样就打通了触发交互。

7.1.1 配置 Web Hook 事件

操作步骤:

  1. 打开代码仓库
  2. 点击 setting -> integrations

    1. 填入 URL
    2. 填入 Secret Token
    3. 勾选 Trigger 事件

URL 和 Secret Token 怎么来的?这个是对应到 item 。

在 Jenkins 平台上,打开对应的 item,打开 Configure ,勾选 Build Triggers ,找到 ”Build when a change is pushed to GitLab“ ,就是这个了。

再往下有一个 Secret token ,点击 Generate 。

把这两个正确填写好,那么就能打通第一个环节了: Gitlab 到 Jenkins 的触发。填写完之后,可以由 GitLab 发个测试事件测试下。

返回 200 即是成功了。

7.1.2 配置 Pre-Merge 卡点

代码 Pre-Merge CI 没过不让合入主干 这个功能怎么实现?关键是 GitLab 要支持代码 Merge 前夕的 Hook 行为。

  1. 首先,我们约定一个行为规范:所有合入主干的代码必须递交 MR,MR CI 测试通过才可以合入主干;
  2. 其次,勾选 Settings -> General -> Merge requests ,把 ”
    Only allow merge requests to be merged if the pipeline succeeds“ 勾选上;

7.2 Jenkins 配置

Jenkins 主要看 Pipeline 的配置,Pipeline 配置打开 Configure 如下:

看一个完整的 pipeline 架子定义各个阶段(可以直接把这个拷贝,运行看下效果):

pipeline {
    agent any
    stages {
        stage('代码checkout') {
            steps {
                echo "------------"
            }
        }
        stage ("静态检查") {
            steps {
                echo "------------"
            }
        }
        stage ("代码编译") {
            steps {             
                echo "------------"
            }
        }
        stage ("单元测试") {
            steps {
                echo "------------"
            }
        }
        stage ("打包") {
            steps {
                echo "------------"
            }
        }
        stage ("冒烟测试") {
            steps {
                echo "------------"
            }
        }
        stage ("集成测试") {
            steps {
                echo "------------"
            }
        }
        stage ("基准性能测试") {
            steps {
                echo "------------"
            }
        }
    }
    post {
        always {
            echo "------------"
        }
        success {
            echo "------------"
        }
        failure {
            echo "------------"
        }
        unstable {
            echo "------------"
        }
    }
}

跑出来的效果:

Blue Ocean 的效果:

接下来,我们拆解几个关键的阶段来分析。

7.2.1 代码 checkout

Checkout 的代码也就是我们的测试对象,这个对于 Pre-merge 和 Post-merge 是不同的,Pre-merge 卡点由 Merge Request 事件触发,我们需要 Checkout 出 ”代码修改“ + ”最新主干分支“ 的代码。Post-merge 相对简单,我们只需要 Checkout 出最新的主干分支即可。怎么做?

pipeline 直接支持这个使用。

stage('代码checkout') {
    steps {
        dir(path: "${你想要放置的代码路径}") {
            checkout changelog: true, poll: true, scm: [
                $class: 'GitSCM', 
                branches: [[name: "*/${env.gitlabSourceBranch}"]],
                doGenerateSubmoduleConfigurations: false, 
                extensions: [
                    [$class: 'PreBuildMerge', options: [fastForwardMode: 'FF', mergeRemote: 'origin', mergeStrategy: 'DEFAULT', mergeTarget: "${env.gitlabTargetBranch}" ]],
                    [ $class: 'UserIdentity', email: "${env.gitlabUserEmail}", name: "${env.gitlabUserName}" ]
                ],
                submoduleCfg: [],
                userRemoteConfigs: [[
                    credentialsId: "${env.OCS_GITLAB_CredentialsId}", 
                    url: "${env.gitlabSourceRepoHttpUrl}"
                ]]
            ]
        }                
    }            
}

这里指明:

  • 代码 checkout 下来放置的路径(dir path 配置);
  • 指明 checkout 的分支,MR 事件触发 CI 的时候 env.gitlabSourceBranch 是会自动设置上的;
  • 指明 checkout 的行为,PreBuildMerge 行为(其实 Post-Merge 触发 PUSH 事件的时候,也适用于上面的写法);
  • 指明 gitlab 认证凭证(credentialsId);

上面的语法同时适用于 Pre-merge 和 Post-merge 。

7.2.2 静态检查

静态检查主要是对代码语法做一些静态检查,比如 golang ,可以使用自带的 go vet ,或者 go fmt 等检查,经过这一轮检查就能保证大家的代码消除了最基本的语法和格式错误。

7.2.3 单元测试

对最小的函数做单元测试是必要的,经过单元测试可以拿到项目的一个覆盖率情况。这个阶段我们获得两个东西:

  • 单元测试案例报告
  • 覆盖率报告

测试报告怎么拿?以 golang 为例,跑单测的时候,把覆盖率的开关打开,标准输出到一个文件:

go test -cover -coverprofile=cover.output xxx | tee ut.output

这里会产生两个文件:

  • ut.output :用来生成单元测试报告的文件;
  • cover.output :用来生成覆盖率报告的文件;

单测报告生成

首先解析这个文件成 xml 格式的文件,然后用 junit 上报给 jenkins 展示。

sh "go-junit-report < ut.output > ut.xml"
junit 'ut.xml'

go-junit-report 哪里来的?这是个开源的工具,就是专门用来做单元测试解析的。

在 jenkins 上展示的效果如下:

覆盖率报告生成

解析覆盖率输出文件,生成一个xml 文件:

gocov convert cover.output | gocov-xml > cover.xml

上报这个 xml 文件,用于 jenkins 平台展示:

step([
        $class: 'CoberturaPublisher', 
        autoUpdateHealth: false, 
        autoUpdateStability: false, 
        coberturaReportFile: '**/cover.xml', 
        failUnhealthy: false, 
        failUnstable: false, 
        maxNumberOfBuilds: 0, 
        onlyStable: false, 
        sourceEncoding: 'ASCII', 
        zoomCoverageChart: false
    ]
)

点击进文件,可以看到代码覆盖的详情:

7.2.4 接口测试

对完整的系统做一些接口级别的测试,比如模拟用户行为,测试用户调用的接口,这样能保证最基本的功能。报告输出也可以用junit 格式,可以上报给jenkins,解析如图:

7.2.5 邮件发送

测试通过或者失败,需要发送有结果邮件。

configFileProvider([configFile(fileId: '5f1e288d-71ee-4d29-855f-f3b22eee376c', targetLocation: 'email.html', variable: 'content')]) {
    script {
        template = readFile encoding: 'UTF-8', file: "${content}"
        emailext(
            subject: "CI构建结果: ${currentBuild.result?: 'Unknow'}",
            to: "test@test.com",
            from: "push@test.com", 
            body: """${template}"""
        )
    }
}

7.2.6 Gitlab 状态交互 

// 定义 Gitlab 流程
options {
    gitLabConnection('test-gitlab')
    gitlabBuilds(builds: ['jenkinsCI'])
}

            // 触发gitlab pipeline
            updateGitlabCommitStatus name: 'jenkinsCI', state: 'success'
            addGitLabMRComment comment: """**CI Jenkins自动构建详情**\n
| 条目 | 值 |
| ------ | ------ |
| 结果 | ${currentBuild.result?: 'Unknow'} |
| MR LastCommit | ${env.gitlabMergeRequestLastCommit} | 
| MR id | ${env.gitlabMergeRequestIid} |
| Message Title | ${env.gitlabMergeRequestTitle} |
| 构建任务ID | ${env.BUILD_NUMBER} |
| 构建详情链接 | [${env.RUN_DISPLAY_URL}](${env.RUN_DISPLAY_URL})"""

CI 成功或失败,都需要把这个状态给到 gitlab,我们以一个 Comment 展示结果,并且附上 jenkins 任务的跳转链接,这样可以最快的帮助开发人员闭环。

成功才允许合入 :

Gitlab CI

7.2.7 构建归档

打包日志:

// 先打一个 tar 包
sh "tar -czvf log.tar.gz ${SERVICEDIR}/run/*.log"
// jenkins 进行归档
archiveArtifacts allowEmptyArchive: true, artifacts: "log.tar.gz", followSymlinks: 
false

8. Jenkins 高级技巧

8.1 资源互斥

有时候多个任务跑的时候,可能会并发使用到某个资源,而如果这个资源有限,那么可能需要用到一些互斥手段来保证。比如,两个任务可能都用到了 mongodb,而 mongodb 如果只有一套,那么就必须让多个任务串行执行才行,不然就会跑错了逻辑。怎么做?

这个可以在 “Configure System”->“Lockable Resources Manager” 定义好锁资源:

然后再 Pipeline 脚本里使用这个锁资源:

stage ("单元测试") {
    steps {
        lock(resource: "UT_TEST", quantity:1) {
            echo "====== 单元测试 ============"
            echo "====== 单元测试完成 ============"     
        }
    }
}

并且还可以在界面上( Dashboard -> Lockable Resources )看到哪些资源被哪些任务占用:

通过合理定义锁资源,我们就能做到任务可以并发,但是关键的竞态资源做互斥,这样 CI 构建任务更灵活,更有效率(这个可以类比成代码里面锁粒度的一个影响,如果你不用 Lock Resource 这种方式,那么很可能只能配置成 node 并发度为1才能保护到竞态资源)。

8.2 节点调度

jenkins 允许你调度指定的任务到合适的节点。当有多个节点的时候,可能会想要任务 A 固定到 node1 上执行,那么可以使用 agent 命令指定。

定义节点的时候每个节点都会赋有一个 label 名称,然后运行的时候,就可以指定节点了:

agent { label "slave_node_1" }

8.3 节点间文件传输

我们使用 stash, unstash 来实现, 下面的例子就是把 build/ 目录在 node1 和 node2 直接做无损传输:

stage ("打包") {
    agent { label "slave_node_1" }
    steps {
        // 节点1上,把 Build 目录下的都打包;
        stash (name: "buildPkg", includes: "build/**/*")
    }
}

stage ("冒烟部署") {
    agent { label "slave_node_2" }
    steps {
        // 节点2上,解包
        unstash ("buildPkg")
    }
}

8.4 节点的后置清理

在流水线多节点切换的时候,需要注意下你所在的节点是哪个,千万别晕头了。

pipeline {
    agent { label "master" }
    stages {
        stage ("测试") {
            agent { label "slave_node_1" }
            steps {
            }
            // 阶段后置
            post {
                always {
                    // 清理 slave_node_1 的构建空间
                    cleanWs()
                }
            }
        }

    }
    // 流水线总后置
    post {
        always {
            // 只清理 master 节点的构建空间
            cleanWs()
        }
    }
}

多节点的时候,一定要记得分别清理节点。

9. 总结

通过使用合理的技术平台,把人与事合理的关联,现代软件开发活动中,CI 是必不可少的流程,开发人员身在其中,CI 以代码活动为起点,构建结果能能快速响应到对应人,并提供手段让对应人快速解决,最后提供直观的报告。我们通过 Jenkins(CI平台) + Gitlab(仓库)来演示完整搭建流程,展示一个可实践的过程。一切都是为了软件开发效率和版本质量。

查看原文

赞 1 收藏 1 评论 0

OPPO互联网技术 发布了文章 · 2020-11-09

如何设计并实现存储QoS?

本文来自OPPO互联网技术团队,转载请注名作者。同时欢迎关注我们的公众号:OPPO_tech,与你分享OPPO前沿互联网技术及活动。

一、资源抢占问题

随着存储架构的调整,众多应用服务会运行在同一资源池中,对外提供统一的存储能力。资源池内部可能存在多种流量类型,如上层业务的IO流量、存储内部的数据迁移、修复、压缩等,不同的流量通过竞争的方式确定下发到硬件的IO顺序,因此无法确保某种流量IO服务质量,比如内部数据迁移流量可能占用过多的带宽影响业务流量读写,导致存储对外提供的服务质量下降,由于资源竞争结果的不确定性无法保障存储对外能提供稳定的集群环境。

如下面交通图所示,车辆逆行、加塞随心随遇,行人横穿、闲聊肆无忌惮,最终出现交通拥堵甚至安全事故。

image.png

二、如何解决资源抢占

类比上一幅交通图,如何规避这样的现象大家可能都有自己的一些看法,这里先引入两个名词

  • QoS,即服务质量,根据不同服务类型的不同需求提供端到端的服务质量。
  • 存储QoS,在保障服务带宽与IOPS的情况下,合理分配存储资源,有效缓解或控制应用服务对资源的抢占,实现流量监控、资源合理分配、重要服务质量保证以及内部流量规避等效果,是存储领域必不可少的一项关键技术。

那么QoS应该怎么去做呢?下面还是结合交通的例子进行介绍说明。

2.1 流量分类

从前面的图我们看到不管是什么车,都以自我为中心,不受任何约束,我们首先能先到的办法是对道路进行分类划分,比如分为公交车专用车道、小型车专用车道、大货车专用车道、非机动车道以及人行横道等,正常情况下公交车车道只允许公交车运行,而非机动车道上是不允许出现机动车的,这样我们可以保证车道与车道之间不受制约干扰。

image.png

同样,存储内部也会有很多流量,我们可以为不同的流量类型分配不同的 “车道”,比如业务流量的车道我们划分宽一些,而内部压缩流量的车道相对来说可以窄一些,由此引入了QoS中一个比较重要的概览就是流量分类,根据分类结果可以进行更加精准个性化的限流控制。

2.2 流量优先级

仅仅依靠分类是不行的,因为总有一些特殊情况,比如急救车救人、警车抓人等,我们总不能说这个车道只能跑普通私家小轿车把,一些特殊车辆(救护车,消防车以及警车等)应该具有优先通行的权限。

image.png

对于存储来说业务流量就是我们的特殊车辆,我们需要保证业务流量的稳定性,比如业务流量的带宽跟IOPS不受限制,而内部流量如迁移、修复则需要限定其带宽或者IOPS,为其分配固定的“车道”。在资源充足的情况下,内部流量可以安安静静的在自己的车道上行驶,但是当资源紧张,比如业务流量突增或者持续性的高流量水位,这个时候需要限制内部流量的道理宽度,极端情况下可以暂停。当然,如果内部流量都停了还是不能满足正常业务流量的读写需求,这个时候就需要考虑扩容的事情了。

QoS中另外一个比较重要的概念就是优先级划分,在资源充足的情况下执行预分配资源策略,当资源紧张时对优先级低的服务资源进行动态调整,进行适当的规避或者暂停,在一定程度上可以弥补预分配方案的不足。

2.3 流量监控

前面提到当资源不足时,我们可以动态的去调整其他流量的阈值,那我们如何知道资源不足呢?这个时候我们是需要有个流量监控的组件。

image.png

我们出行时经常会使用地图,通过选择合适的线路以最快到达目的地。一般线路会通过不同的颜色标记线路拥堵情况,比如红色表示堵车、绿色表示畅通。

存储想要知道机器或者磁盘当前的流量情况有两种方式:

  • 统计机器负载情况,比如我们经常去机器上通过iostat命名查看各个磁盘的io情况,这种方式与机器上的应用解耦,只关注机器本身
  • 统计各个应用下发的读写流量,比如某台机器上部署了一个存储节点应用,那我们可以统计这个应用下发下去的读写带宽及IOPS

第二种方式相对第一种可以实现应用内部更细的流量分类,比如前面提到的一个存储应用节点,就包含了多种流量,我们不能通过机器的粒度对所有流量统一限流。

三、常见QoS限流算法

3.1 固定窗口算法

  • 按时间划分为多个限流窗口,比如1秒为一个限流窗口大小;
  • 每个窗口都有一个计数器,每通过一个请求计数器会加一;
  • 当计数器大小超过了限制大小(比如一秒内只能通过100个请求),则窗口内的其他请求会被丢弃或排队等待,等到下一个时间节点计数器清零再处理请求。

image.png

固定窗口算法的理想流量控制效果如上左侧图所示,假定设置1秒内允许的最大请求数为100,那么1秒内的最大请求数不会超过100。

但是大多数情况下我们会得到右侧的曲线图,即可能会出现流量翻倍的效果。比如前T1\~T2时间段没有请求,T2\~T3来了100个请求,全部通过。下一个限流窗口计数器清零,然后T3T4时间内来了100个请求,全部处理成功,这个时候时间段T4T5时间段就算有请求也是不能处理的,因此超过了设定阈值,最终T2~T4这一秒时间处理的请求为200个,所以流量翻倍。

小结
  • 算法易于理解,实现简单;
  • 流量控制不够精细,容易出现流量翻倍情况;
  • 适合流量平缓并允许流量翻倍的模型。

3.2 滑动窗口算法

前面提到固定窗口算法容易出现流量控制不住的情况(流量翻倍),滑动窗口可以认为是固定窗口的升级版本,可以规避固定窗口导致的流量翻倍问题。

  • 时间窗口被细分若干个小区间,比如之前一秒一个窗口(最大允许通过60个请求),现在一秒分成3个小区间,每个小区间最大允许通过20个请求;
  • 每个区间都有一个独立的计数器,可以理解一个区间就是固定窗口算法中的一个限流窗口;
  • 当一个区间的时间用完,滑动窗口往后移动一个分区,老的分区(T1~T2)被丢弃,新的分区(T4~T5)加入滑动窗口,如图所示。

image.png

小结
  • 流量控制更加精准,解决了固定窗口算法导致的流量翻倍问题;
  • 区间划分粒度不易确定,粒度太小会增加计算资源,粒度太大又会导致整体流量曲线不够平滑,使得系统负载忽高忽低;
  • 适合流量较为稳定,没有大量流量突增模型。

3.3 漏斗算法

  • 所有的水滴(请求)都会先经过“漏斗”存储起来(排队等待);
  • 当漏斗满了之后,多余的水会被丢弃或者进入一个等待队列中;
  • 漏斗的另外一端会以一个固定的速率将水滴排出。

image.png

对于漏斗而言,他不清楚水滴(请求)什么时候会流入,但是总能保证出水的速度不会超过设定的阈值,请求总是以一个比较平滑的速度被处理,如图所示,系统经过漏斗算法限流之后,流量能保证在一个恒定的阈值之下。

小结
  • 稳定的处理速度,可以达到整流的效果,主要对下游的系统起到保护作用;
  • 无法应对流量突增情况,所有的请求经过漏斗都会被削缓,因此不适合有流量突发的限流场景;
  • 适合没有流量突增或想达到流量整合以固定速率处理的模型。

3.4 令牌桶算法

令牌桶算法是漏斗算法的一种改进,主要解决漏斗算法不能应对流量突发的场景

  • 以固定的速率产生令牌并投入桶中,比如一秒投放N个令牌;
  • 令牌桶中的令牌数如果大于令牌桶大小M,则多余的令牌会被丢弃;
  • 所有请求到达时,会先从令牌桶中获取令牌,拿到令牌则执行请求,如果没有获取到令牌则请求会被丢弃或者排队等待下一次尝试获取令牌。

image.png

如图所示,假设令牌投放速率为100/s,桶能存放最大令牌数200,当请求速度大于另外投放速率时,请求会被限制在100/s。如果某段时间没有请求,这个时候令牌桶中的令牌数会慢慢增加直到200个,这是请求可以一次执行200,即允许设定阈值内的流量并发。

小结
  • 流量平滑;
  • 允许特定阈值内的流量并发;
  • 适合整流并允许一定程度流量突增的模型。
就单纯的以算法而言,没有哪个算法最好或者最差的说法,需要结合实际的流量特征以及系统需求等因素选择最合适的算法。

四、存储QoS设计及实现

4.1 需求

一般而言一台机器会至少部署一个存储节点,节点负责多块磁盘的读写请求,而存储请求由分为多种类型,比如正常业务的读写流量、磁盘损坏的修复流量、数据删除出现数据空洞后的空间压缩流量以及多为了降低多副本存储成本的纠删码(EC)迁移流量等等,不同流量出现在同一个存储节点会相互竞争抢占系统资源,为了更好的保证业务服务质量,需要对流量的带宽以及IOPS进行限制管控,比如需要满足以下条件:

image.png

  • 可以同时限制流量的带宽跟IOPS,单独的带宽或者IOPS限制都会导致另外一个参数不受控制而影响系统稳定性,比如只控制了带宽,但是没有限制IOPS,对于大量小IO的场景就会导致机器的ioutil过高;
  • 可以实现磁盘粒度的限流,避免机器粒度限流导致磁盘流量过载,比如图所示,ec流量限制节点的带宽最大值为10Mbps,预期效果是想每块磁盘分配2Mbps,但是很有可能这10Mbps全部分配到了第一个磁盘;
  • 可以支持流量分类控制,根据不同的流量特性设置不同的限流参数,比如业务流量是我们需要重点保护的,因此不能对业务流量进行限流,而EC、压缩等其他流量均为内部流量,可以根据其特性配置合适的限流阈值;
  • 可以支持限流阈值的动态适配,由于业务流量不能进行流控,对于系统而言就像一匹“脱缰野马”,可能突增、突减或持续高峰,针对突增或持续高峰的场景系统需要尽可能的为其分配资源,这就意味着需要对内部流量的限流阈值进行动态的打压设置是暂停规避。

4.2 算法选择

前面提到了QoS的算法有很多,这里我们结合实际需求选择滑动窗口算法,主要有以下原因:

  • 系统需要控制内部流量而内部流量相对比较稳定平缓;
  • 可以避免流量突发情况而影响业务流量;

QoS组件除了滑动窗口,还需要添加一个缓存队列,当请求被限流之后不能被丢弃,需要添加至缓存队列中,等待下一个时间窗口执行,如下图所示。

image.png

4.3 带宽与IOPS同时限制

为了实现带宽与IOPS的同时控制,QoS组件将由两部分组成:IOPS控制组件负责控制读写的IOPS,带宽控制组件负责控制读写的带宽,带宽控制跟IOPS控制类似,比如带宽限制阈值为1Mbps,那么表示一秒最多只能读写1048576Bytes大小数据;假定IOPS限制为20iops,表示一秒内最多只能发送20次读写请求,至于每次读写请求的大小并不关心。

image.png

两个组件内部相互隔离,整体来看又相互影响,比如当IOPS控制很低时,对应的带宽可能也会较小,而当带宽控制很小时对应的IOPS也会比较小。

image.png

下面以修复流量为例,分三组进行测试

  1. 第一组:20iops-1Mbps
  2. 第二组:40iops-2Mbps
  3. 第三组:80iops-4Mbps

测试结果如上图所示,从图中可以看到qos模块能控制流量的带宽跟iops维持在设定阈值范围内。

4.4 流量分类限制

为了区分不同的流量,我们对流量进行标记分类,并为不同磁盘上的不同流量都初始化一个QoS组件,QoS组件之间相互独立互不影响,最终可以达到磁盘粒度的带宽跟IOPS控制。

image.png

4.5 动态阈值调整

前面提到的QoS限流方案,虽然能够很好的控制内部流量带宽或者IOPS在阈值范围内, 但是存在以下不足

  • 不感知业务流量现状,当业务流量突增或者持续高峰时,内部流量与业务流量仍然会存在资源抢占,不能达到流量规避或暂停效果。
  • 磁盘上不同流量的限流相互独立,当磁盘的整体流量带宽或者IOPS过载时,内部流量阈值不能动态调低也会影响业务流量的服务质量。

所以需要对QoS组件进行一定的改进,增加流量监控组件,监控组件主要监控不同流量类型的带宽与IOPS,动态QoS限流方案支持以下功能:

image.png

  • 通过监控组件获取流量增长率,如果出现流量突增,则动态调低滑动窗口阈值以降低内部流量;当流量恢复平缓,恢复滑动窗口最初阈值以充分利用系统资源。
  • 通过监控组件获取磁盘整体流量,当整体流量大小超过设定阈值,则动态调低滑动窗口大小;当整体流量大小低于设定阈值,则恢复滑动窗口至初始阈值。

下面设置磁盘整体流量阈值2Mbps-40iops,ec流量的阈值为10Mbps-600iops

当磁盘整体流量达到磁盘阈值时会动态调整其他内部流量的阈值,从测试结果可以看到ec的流量受动态阈值调整存在一些波动,磁盘整体流量下去之后ec流量阈值又会恢复到最初阈值(10Mbps-600iops),但是可以看到整体磁盘的流量并没有控制在2Mbps-40iops以下,而是在这个范围上下波动,所以我们在初始化时需要保证设置的内部流量阈值小于磁盘的整体流量阈值,这样才能达到比较稳定的内部流量控制效果。

image.png

4.6 伪代码实现

前面提到存储QoS主要是限制读写的带宽跟IOPS,具体应该如何去实现呢?IO读写主要涉及以下几个接口

Read(p []byte) (n int, err error)
ReadAt(p []byte, off int64) (n int, err error)
Write(p []byte) (written int, err error)
WriteAt(p []byte, off int64) (written int, err error)

所以这里需要对上面几个接口进行二次封装,主要是加入限流组件

带宽控制组件实现

Read实现
// 假定c为限流组件
func (self *bpsReader) Read(p []byte) (n int, err error) {

    size := len(p)
    size = self.c.assign(size) //申请读取文件大小

    n, err = self.underlying.Read(p[:size]) //根据申请大小读取对应大小数据
    self.c.fill(size - n) //如果读取的数据大小小于申请大小,将没有用掉的计数填充至限流窗口中
    return
}

Read限流之后会出现以下情况

  • 读取大小n<len(p)且err=nil,比如需要读4K大小,但是当前时间窗口只能允许读取3K,这个是被允许的

这里也许你会想,Read限流的实现怎么不弄个循环呢?如直到读取指定大小数据才返回。这里的实现我们需要参考标准的IO的读接口定义,其中有说明在读的过程中如果准备好的数据不足len(p)大小,这里直接返回准备好的数据,而不是等待,也就是说标准的语义是支持只读部分准备好的数据,因此这里的限流实现保持一致。

// Reader is the interface that wraps the basic Read method.
//
// Read reads up to len(p) bytes into p. It returns the number of bytes
// read (0 <= n <= len(p)) and any error encountered. Even if Read
// returns n < len(p), it may use all of p as scratch space during the call.
// If some data is available but not len(p) bytes, Read conventionally
// returns what is available instead of waiting for more.
// 省略
//
// Implementations must not retain p.
type Reader interface {
    Read(p []byte) (n int, err error)
}
ReadAt实现

下面介绍下ReadAt的实现,从接口的定义来看,可能觉得ReadAt与Read相差不大,仅仅是指定了数据读取的开始位置,细心的小伙伴可能发现我们这里实现时多了一层循环,需要读到指定大小数据或者出现错误才返回,相比Read而言ReadAt是不允许出现\n<len(p)且err==nil\的情况

func (self *bpsReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
    for n < len(p) && err == nil {
        var nn int
        nn, err = self.readAt(p[n:], off)
        off += int64(nn)
        n += nn
    }
    return
}

func (self *bpsReaderAt) readAt(p []byte, off int64) (n int, err error) {
    size := len(p)
    size = self.c.assign(size)
    n, err = self.underlying.ReadAt(p[:size], off)
    self.c.fill(size - n)
    return
}
// ReaderAt is the interface that wraps the basic ReadAt method.
//
// ReadAt reads len(p) bytes into p starting at offset off in the
// underlying input source. It returns the number of bytes
// read (0 <= n <= len(p)) and any error encountered.
//
// When ReadAt returns n < len(p), it returns a non-nil error
// explaining why more bytes were not returned. In this respect,
// ReadAt is stricter than Read.
//
// Even if ReadAt returns n < len(p), it may use all of p as scratch
// space during the call. If some data is available but not len(p) bytes,
// ReadAt blocks until either all the data is available or an error occurs.
// In this respect ReadAt is different from Read.
//省略
//
// Implementations must not retain p.
type ReaderAt interface {
    ReadAt(p []byte, off int64) (n int, err error)
}
Write实现

Write接口的实现相对比较简单,循环写直到写完数据或者出现错误

func (self *bpsWriter) Write(p []byte) (written int, err error) {
    size := 0
    for size != len(p) {
        p = p[size:]
        size = self.c.assign(len(p))

        n, err := self.underlying.Write(p[:size])
        self.c.fill(size - n)
        written += n
        if err != nil {
            return written, err
        }
    }
    return
}
// Writer is the interface that wraps the basic Write method.
//
// Write writes len(p) bytes from p to the underlying data stream.
// It returns the number of bytes written from p (0 <= n <= len(p))
// and any error encountered that caused the write to stop early.
// Write must return a non-nil error if it returns n < len(p).
// Write must not modify the slice data, even temporarily.
//
// Implementations must not retain p.
type Writer interface {
    Write(p []byte) (n int, err error)
}
WriteAt实现

这里的实现跟Write类似

func (self *bpsWriterAt) WriteAt(p []byte, off int64) (written int, err error) {
    size := 0
    for size != len(p) {
        p = p[size:]
        size = self.c.assign(len(p))

        n, err := self.underlying.WriteAt(p[:size], off)
        self.c.fill(size - n)
        off += int64(n)
        written += n
        if err != nil {
            return written, err
        }
    }
    return
}
// WriterAt is the interface that wraps the basic WriteAt method.
//
// WriteAt writes len(p) bytes from p to the underlying data stream
// at offset off. It returns the number of bytes written from p (0 <= n <= len(p))
// and any error encountered that caused the write to stop early.
// WriteAt must return a non-nil error if it returns n < len(p).
//
// If WriteAt is writing to a destination with a seek offset,
// WriteAt should not affect nor be affected by the underlying
// seek offset.
//
// Clients of WriteAt can execute parallel WriteAt calls on the same
// destination if the ranges do not overlap.
//
// Implementations must not retain p.
type WriterAt interface {
    WriteAt(p []byte, off int64) (n int, err error)
}

IOPS控制组件实现

IOPS控制组件的实现跟带宽类似,这里就不详细介绍了

Read接口实现
func (self *iopsReader) Read(p []byte) (n int, err error) {
    self.c.assign(1) //这里只需要获取一个计数,如果当前窗口一个都没有,则会一直等待直到获取到一个才唤醒执行下一步
    n, err = self.underlying.Read(p)
    return
}
ReadAt接口实现
func (self *iopsReaderAt) ReadAt(p []byte, off int64) (n int, err error) {
    self.c.assign(1)
    n, err = self.underlying.ReadAt(p, off)
    return
}

想想这里的ReadAt为啥不需要跟带宽一样循环读了呢?

Write接口实现
func (self *iopsWriter) Write(p []byte) (written int, err error) {
    self.c.assign(1)
    written, err = self.underlying.Write(p)
    return
}
WriteAt
func (self *iopsWriterAt) WriteAt(p []byte, off int64) (n int, err error) {
    self.c.assign(1)
    n, err = self.underlying.WriteAt(p, off)
    return
}

image.png

查看原文

赞 0 收藏 0 评论 0

OPPO互联网技术 发布了文章 · 2020-11-05

Docker hung住问题解析系列(一):pipe容量不够

本文来自OPPO互联网基础技术团队,转载请注名作者。同时欢迎关注我们的公众号:OPPO_tech,与你分享OPPO前沿互联网技术及活动。

背景:这个是之前遇到的老问题,最近docker社区里面其他人报了这问题暂时还没解决。

issue的链接是:https://github.com/containerd...

下面列一下我们是怎么排查并解这个问题的。

一、故障现象

Oppo云智能监控发现lxcfs 服务不是处于工作态超过配置的阈值:

# systemctl status lxcfs
● lxcfs.service - FUSE filesystem for LXC
Loaded: loaded (/usr/lib/systemd/system/lxcfs.service; enabled; vendor preset: disabled)
Active: activating (start-post) since Tue 2020-06-23 14:37:50 CST; 5min ago---这个是6月份的案例,非active (running) 状态
Docs: man:lxcfs(1)
Process: 415455 ExecStopPost=/bin/sh -c if mount |grep "baymax\/lxcfs"; then fusermount -u /var/lib/baymax/lxcfs; fi (code=exited, status=0/SUCCESS)
Main PID: 415526 (lxcfs); : 415529 (lxcfs-remount-c)
Tasks: 43
Memory: 28.9M
CGroup: /system.slice/lxcfs.service
├─415526 /usr/bin/lxcfs -o nonempty /var/lib/baymax/lxcfs/
└─control
├─415529 /bin/sh /usr/bin/lxcfs-remount-containers
├─416923 /bin/sh /usr/bin/lxcfs-remount-containers
└─419090 docker exec 1eb2f723b69e sh -c \ export f=/proc/cpuinfo && test -f /var/lib/baymax/lxcfs/$f && (umount $f; mount --bind...

二、故障现象分析

查看对应的进程树,发现 1eb2f723b69e 容器的runc阻塞没有返回。

# ps -ef |grep -i runc |grep -v shim 
root 172169 138974 0 14:43 pts/2 00:00:00 grep --color -i runc
root 420924 70170 0 14:37 ? 00:00:00 runc --root /var/run/docker/runtime-runc/moby --log /run/docker/containerd/daemon/io.containerd.runtime.v1.linux/moby/1eb2f723b69e2dba83bc490d3fab66922a13a4787be8bcb4cd486e97843ffef5/log.json --log-format json exec --process /tmp/runc-process904568476 --detach --pid-file /run/docker/containerd/daemon/io.containerd.runtime.v1.linux/moby/1eb2f723b69e2dba83bc490d3fab66922a13a4787be8bcb4cd486e97843ffef5/4dfeee72cd794ebec396fb8450f8944499cdde99d22054c950e5a80fb56f0968.pid 1eb2f723b69e2dba83bc490d3fab66922a13a4787be8bcb4cd486e97843ffef5
root 423656 420924 0 14:37 ? 00:00:00 runc init

通过cat /proc/423656/stack 发现它阻塞在pipe的write

然后查看对应 423656 的堆栈详细信息:

PID: 423656  TASK: ffffa0e872d56180  CPU: 28  COMMAND: "runc:[2:INIT]"
。。。。。。
 #1 [ffffa13093eb3d08] schedule at ffffffffb6969f19
    ffffa13093eb3d10: ffffa13093eb3d60 ffffffffb644bd50 
 #2 [ffffa13093eb3d18] pipe_wait at ffffffffb644bd50
    ffffa13093eb3d20: 0000000000000000 ffffa0e872d56180 
    ffffa13093eb3d30: ffffffffb62c3f50 ffffa0f072d87108 
    ffffa13093eb3d40: ffffa1032ad42030 00000000e3a7c164 
    ffffa13093eb3d50: ffffa1032ad42000 0000000000000010 -----分析堆栈,pipe的inode压栈在此
    ffffa13093eb3d60: ffffa13093eb3de8 ffffffffb644bff9 
 #3 [ffffa13093eb3d68] pipe_write at ffffffffb644bff9
    ffffa13093eb3d70: ffffa1032ad42028 ffffa0e872d56180 
ffffa13093eb3d80: ffffa13093eb3df8 0000000000000000 
。。。。限于篇幅,省略部分堆栈
    ffffa13093eb3f50: ffffffffb6976ddb 
 #7 [ffffa13093eb3f50] system_call_fastpath at ffffffffb6976ddb
    RIP: 000000000045b8a5  RSP: 000000c000008be8  RFLAGS: 00010206
    RAX: 0000000000000001  RBX: 0000000000000000  RCX: 000000c000000000
    RDX: 0000000000000010  RSI: 000000c000008bf0  RDI: 0000000000000002
    RBP: 000000c000008b90   R8: 0000000000000001   R9: 00000000006c0fab
    R10: 0000000000000000  R11: 0000000000000202  R12: 0000000000000000
    R13: 0000000000000000  R14: 000000000086d0d8  R15: 0000000000000000
    ORIG_RAX: 0000000000000001  CS: 0033  SS: 002b

看情况是卡在pipe的write,然后看下它打开的文件,找到对应的inode信息:

PID: 423656  TASK: ffffa0e872d56180  CPU: 28  COMMAND: "runc:[2:INIT]"
ROOT: /rootfs    CWD: /rootfs
 FD       FILE            DENTRY           INODE       TYPE PATH
  0 ffffa0feca33cf00 ffffa1333b800240 ffffa1333f568850 CHR  /dev/null
  1 ffffa1031adba700 ffffa0f10efb83c0 ffffa0d78de55c80 FIFO 
  2 ffffa12f3d7df300 ffffa0f10efb8a80 ffffa0d78de56f00 FIFO -----对应那个pipe
  3 ffffa133026b9700 ffffa11382e2f080 ffffa12a2b07ad30 SOCK UNIX

验证下这个pipe:

crash> struct file.private_data ffffa12f3d7df300
  private_data = 0xffffa1032ad42000
crash> pipe_inode_info 0xffffa1032ad42000--------和上面的堆栈对的上
struct pipe_inode_info {
  mutex = {
    count = {
      counter = 1
    }, 
。。。。。
    }
  }, 
  nrbufs = 1, ----只有一个buf,说明pipe创建的时候,page不够,这个主要受限于 pipe-user-pages-soft的默认配置以及pipe-max-size
  curbuf = 0, 
  buffers = 1, 
  readers = 1, 
  writers = 1, 
  files = 2, 
  waiting_writers = 1, 
  r_counter = 1, 
  w_counter = 1, 
  tmp_page = 0x0, 
  fasync_readers = 0x0, 
  fasync_writers = 0x0, 
  bufs = 0xffffa132c3a3d100, 
  user = 0xffffffffb6e4d700
}

看下pipe中的内容:

crash> pipe_buffer 0xffffa132c3a3d100
struct pipe_buffer {
  page = 0xffffe392f992cb00, 
  offset = 0, 
  len = 4081, ---内容的长度
  ops = 0xffffffffb6a2e000, 
  flags = 0, 
  private = 0
}

crash> kmem -p |grep ffffe392f992cb00
ffffe392f992cb00 2e64b2c000                0        0  1 2fffff00000000

crash> rd  -a -p 2e64b2c000 4081-----------这个4081就是上面的长度
      2e64b2c000:  runtime/cgo: pthread_create failed: Resource temporarily una
      2e64b2c03c:  vailable
      2e64b2c045:  SIGABRT: abort-----------------
      2e64b2c054:  PC=0x6c0fab m=0 sigcode=18446744073709551610
      2e64b2c082:  goroutine 0 [idle]:
      2e64b2c096:  runtime: unknown pc 0x6c0fab
      2e64b2c0b3:  stack: frame={sp:0x7ffc54fb5b18, fp:0x0} stack=[0x7ffc547b6f
      2e64b2c0ef:  a8,0x7ffc54fb5fd0)
      2e64b2c102:  00007ffc54fb5a18:  0000000000004000  0000000000000000 
      2e64b2c139:  00007ffc54fb5a28:  0000000000d0eb80  00007fe3c913f000 
限于篇幅,省略部分打印。。。。

      2e64b2cf70:  00007ffc54fb5bd8:  0000000000cd4603  0000000000a9d760 
      2e64b2cfa7:  00007ffc54fb5be8:  00000000006ea87b  0000000000cd4580 
      2e64b2cfde:  00007ffc54fb5bf8:  

很明显,pipe的write阻塞是因为page的空间不够了,默认的阻塞模式,如果对端能够及时读取,应该会将空间空出来才对,

所以下面需要看下对端为啥没有来读:

crash> pipe_inode_info.wait 0xffffa1032ad42000
  wait = {
    lock = {
      {
        rlock = {
          raw_lock = {
            val = {
              counter = 0
            }
          }
        }
      }
    }, 
    task_list = {
      next = 0xffffa13093eb3d38, --------__wait_queue的task_list链串在此
      prev = 0xffffa0f072d87108
    }
  }
crash> __wait_queue 0xffffa13093eb3d20
struct __wait_queue {
  flags = 0, 
  private = 0xffffa0e872d56180, ----对应的就是 423656本身的等待
  func = 0xffffffffb62c3f50, 
  task_list = {
    next = 0xffffa0f072d87108, 
    prev = 0xffffa1032ad42030
  }
}

根据fd的对端信息,可以找到其父进程,也就是containerd-shim进程,而根据如下代码:

func (r *Runc) Exec(context context.Context, id string, spec specs.Process, opts *ExecOpts) error {
。。。。
cmd := r.command(context, append(args, id)...)
。。。。
ec, err := Monitor.Start(cmd)
。。。。
status, err := Monitor.Wait(cmd, ec)
}

由于shim的代码里面是等待runc退出再去读取pipe,而runc又没有pipe容量不够而不退出,所以形成了死锁。

有兴趣的同学也可以了解下这个:https://github.com/opencontai...

三、故障复现

1、

# docker ps |grep busybox
8bebfd8a7b59        busybox                                               "sh"                     6 days ago          Up 2 days

2、

# pwd
/sys/fs/cgroup/pids/docker/8bebfd8a7b59748da6bcb154ec2ce428d1f21376b16c3915d962ec4149484e5c

3、

# cat pids.current -------查看一下当前线程数
1

4、

# echo 3 > pids.max      ----限制一下线程数,由于runc执行过程会创建多个线程,这里设置为3

5、

# cat pids.max 
3

6、查看默认pipe配置:

# cat /proc/sys/fs/pipe-user-pages-soft
16384

7、测试是否阻塞:

# docker exec 8bebfd8 ls
OCI runtime exec failed: exec failed: container_linux.go:344: starting container process caused "read init-p: connection reset by peer": unknown
发现并没有阻塞,然后我们将pipe对应的page数量改小:

8、

# echo 1 >/proc/sys/fs/pipe-user-pages-soft 

然后再次执行同样命令:

# docker exec 8bebfd8 ls
发现阻塞了,没有结果返回,我们来看阻塞在哪:

9、

# ps -ef |grep runc |grep -v shim|grep -v grep
root     122935 642935  0 09:10 ?        00:00:00 runc --root /var/run/docker/runtime-runc/moby --log /run/docker/containerd/daemon/io.containerd.runtime.v1.linux/moby/8bebfd8a7b59748da6bcb154ec2ce428d1f21376b16c3915d962ec4149484e5c/log.json --log-format json exec --process /tmp/runc-process773686128 --detach --pid-file /run/docker/containerd/daemon/io.containerd.runtime.v1.linux/moby/8bebfd8a7b59748da6bcb154ec2ce428d1f21376b16c3915d962ec4149484e5c/7d5955f67c29d2a66c77295f16d416c5baa5277635bf3f3edec381c27f30bafc.pid 8bebfd8a7b59748da6bcb154ec2ce428d1f21376b16c3915d962ec4149484e5c
root     122943 122935  0 09:10 ?        00:00:00 runc init-------------被阻塞,没返回

10、查看具体阻塞的堆栈:

# ls /proc/122943/task/ |xargs -I file sh -c "echo file && cat /proc/file/stack"
122943
[<ffffffffadc4bd50>] pipe_wait+0x70/0xc0-------------阻塞在pipe的写,
[<ffffffffadc4bff9>] pipe_write+0x1f9/0x540
[<ffffffffadc41c13>] do_sync_write+0x93/0xe0
[<ffffffffadc42700>] vfs_write+0xc0/0x1f0
[<ffffffffadc4351f>] SyS_write+0x7f/0xf0
[<ffffffffae176ddb>] system_call_fastpath+0x22/0x27
[<ffffffffffffffff>] 0xffffffffffffffff
122944
[<ffffffffadb0e286>] futex_wait_queue_me+0xc6/0x130
[<ffffffffadb0ef6b>] futex_wait+0x17b/0x280
[<ffffffffadb10cb6>] do_futex+0x106/0x5a0
[<ffffffffadb111d0>] SyS_futex+0x80/0x190
[<ffffffffae176ddb>] system_call_fastpath+0x22/0x27
[<ffffffffffffffff>] 0xffffffffffffffff

11、通过如下的stap打点命令,可以确定是pipe容量不足:

printf(" %s,tid=%d,pipe=%d\n",execname(),tid(),
            @cast(@cast($iocb->ki_filp,"struct file")->private_data, "struct pipe_inode_info")->buffers)

打点结果:

runc:[2:INIT],tid=122943,pipe=1--------------当容量足够时,这个值默认为16,是linux内核的默认值

12、查看pipe中的数据:

# cat /proc/122943/fd/2
runtime/cgo: pthread_create failed: Resource temporarily unavailable
SIGABRT: abort
PC=0x6c0fab m=0 sigcode=18446744073709551610

goroutine 0 [idle]:
runtime: unknown pc 0x6c0fab
stack: frame={sp:0x7fffcd06ae18, fp:0x0} stack=[0x7fffcc86c398,0x7fffcd06b3c0)
00007fffcd06ad18:  00000000ffffffff  00007f286a8ba000 
00007fffcd06ad28:  00007fffcd06ad70  00007fffcd06ada8 
00007fffcd06ad38:  0000000000404f61 <runtime.mmap+177>  00007fffcd06ad78 
00007fffcd06ad48:  0000000000000000  0000000000000000 
。。。限于篇幅,其余数据省略

以上就是完整复现流程。

四、故障规避或解决

我们的解决方案是:

  1. 增加 pipe-user-pages-soft 的配置,这个需要根据云的密度来,因为docker各个组件大量使用pipe来通信。
  2. 监控user_struct.pipe_bufs 的用量,这个要根据用户来。
  3. 建议不要去动shim 中等待runc退出在读取pipe的逻辑,除非大的故障,不会没事去升级一遍containerd-shim。
  4. runc存活时间监控。
  5. 监控容器pid的用量,超过阈值报警。

PS:docker hung住的问题案例很多,后续我们会有相应的案例展出,以避免大家重复踩坑,共同推进云原生的进步。

image.png

查看原文

赞 0 收藏 0 评论 0

认证与成就

  • 获得 125 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2019-11-29
个人主页被 4.7k 人浏览