基于Saltstack、Artifactory打造传统模式下持续部署平台

JFrogchina阅读(869)评论(0)

一、持续部署

1.现状

由于没有建立标准的持续部署流程,导致了版本管理混乱,制品管理混乱,上线持续时间长,上线测试覆盖不全面,业务流量上升后故障较多,排查复杂。运维、测试、开发人员每次版本迭代的时候,都要可能需要经历一次通宵的历练,并且这种在上线的第二天依然会出现很多线上故障。

2. 痛点

  • 自动化发布体系覆盖率低。
  • 无标准化发布的流程。

a.只注重敏捷、忽视质量问题;

b.变更频繁导致故障率增加;

c.开发语言种类多,发布制品管理混乱,发布方式复杂;

  • 安全问题容易被忽视。

二、工具介绍

1.Saltstack

基于ZeroMQ的开源的配置管理工具。笔者之所以选型使用saltstack,而放弃了ansible,原因是由于ansible基于ssh通信,在管控主机超过五百台之后,基于消息队列的命令下发方式无论在稳定性还是速度上都优于ssh协议。笔直另外选在saltstack的原因是,在服务的开发团队中存在着不同的技术栈并行的状况,尤其是java和.net并存的情况下,saltstack对于windows的支持明显要优于ansible。更容易作为多平台的底层发布工具。

而基于SaltStack打造自动化部署平台主要是用grains、pillar、state三个特性,grains用于获取默认环境配置信息、pillar用于定义环境信息、state用于编排发布文件进行发布。

2. Artifactory

全语言制品仓库管理软件,有开源版及企业版两种。开源版支持maven制品管理;企业版支持全语言制品管理,支持元数据管理功能,提供高可用部署方式、匹配nvd及vulnDB数据库,提供漏洞扫描能力。

 

三、针对上述痛点解决方案

1. 自动化发布覆盖率低

通过搭建兼容多平台部署统一发布工具,替换掉传统的shell脚本拷贝的方式实现发布工具标准化。通过SaltStack的state特性,实现跨平台的基础服务发布、服务启停、文件发布、配置发布、远程主机管理等90%以上手动操作。使用SaltStack的state编排文件,执行远程命令,通过Artifactory获取制品及配置,将需要的版本发布到线上。

主要方案在部署平台中,通过json格式描述发布流程,通过yaml.dump(sls_json)将json文件转换成yaml各位的配置文件,最终通过平台调度saltstack执行编排好的任务。

转换后的yaml文件格式如下:

2. 标准化发布流程

  • 备份

发布任务编排的第一步就是备份,备份需采用本地备份加异地备份两种机制,本地备份用于快速回滚,异地备份用于环境重建。

  • 切流量(蓝绿部署)

对于服务,尤其是有状态的服务,需要在注册中心中进行节点下线,确保本节点所有处理结束后,再进行部署。

对于页面,需要在负载均衡上将节点注销,对没有流量的web页面进行部署操作。

  • 部署

通过saltstack的sls特性,编排部署文件,对多个部署任务进行统一进行发布。

部署时我们希望可以在部署页面查看到类似下述信息,如:部署包对应的需求id、部署包对应代码的提交信息、部署包自动化测试的通过率、部署包的代码扫描结果、部署包的安全扫描结果、部署包人工测试的结果等等。运维人员需要在发布过程中看到此类信息,来明确包是否通过了所有质量关卡、具备了上线条件,从而判断此次上线是否可以继续进行。这里我们使用了Artifactory的元数据功能,用于记录软件包诞生的整个生命周期的信息,并通过api方式对接到发布平台。给运维人员一个完整的包的信息记录。

  • 自动化测试

此处自动化测试主要可以理解为检测服务端口通信是否正常、回归线上功能是否可用、缺陷是否被修复、新特性是否部署完成等。同时此处需要预热服务及站点,通过自动化的测试打通业务流程。

  • 流量回归(金丝雀)

部分真实流量切换到已经部署完成的应用上,通过全链路日志追踪或监控指标反馈来初步判断新上线应用是否健康运行,并将此结果作为后续发布或回退的依据。

  • 部署补全(滚动发布)

在使用低谷时间将流量牵引到已部署完成的应用上,同时将其余应用升级。

  • 变更管理通告。

上线成功后需要及时的通知大家线上版本已变更,产品经理需要及时更新文档,运营人员需要及时对用户进行告知。

  • 回滚

任何发布都需要考虑回滚方案,对于单个应用需要回滚到一个指定版本;对于多个应用,需要明确一个回滚集,通过发布时的编排任务指定回滚的编排任务。对于数据库等更新,如果回顾复杂,则需要在升级方案制定前就明确回滚方案或在业务中做好版本兼容。

3. 建立统一的制品管理仓库

大多互联网公司已经对源码仓库有了统一的管理,但对于制品依然处于一个原始的管理状况,比如使用ftp以及每种语言开源的管理仓库。这里遇到的问题是,运维人员需要投入大量的精力维护不同的包管理平台(如ftp、maven、nuget、pypi、docker镜像中心等)。浪费掉大量运维团队的人力成本之外,也极度复杂了发布流程。发布人员需要在不同的平台获取上线的包,导致发布流程混乱,发布平台配置混乱。并且大多数开源组件均不提供高可用能力,一旦硬件或软件出现故障,都将严重的影响发布效率。

为了解决这种问题,我们采用Artifactory来管理所有语言的制品仓库。与统一gitlab一个道理,我们把整个公司的制品统一管理,成为对接发布平台的唯一包来源,从而规范了发布流程。

4. 漏洞扫描

目前安全团队扫描大多是在服务部署上线后进行,这种情况下和容易造成由于版本有安全漏洞导致的整个迭代废弃,所有包需要重新编译,重新经过测试流程以及上线过程,浪费掉大量的时间,降低迭代的速度。

解决办法是将漏洞扫描步骤前置,在制品包构造编译的时候,乃至开发人员code代码的时候就对外部引用、内部公共库进行漏洞扫描,一旦匹配到高危漏洞,直接把提交或构建终端。如果一定要继续构建,那么可以将扫描结果记录到制品的元数据中,供测试人员,运维人员查看。目前JFrog Xray等安全扫描故居提供此类能力。也可以使用开源软件,如cvechecker,在编译流水线中对包进行扫描,防止由于安全漏洞造成的整个迭代失败。

 

四、后期完善

1. 设置度量体系,提升发布质量

敏捷开发模式下,开发人员和测试人员往往是汇报给同一位管理人员,出于快速迭代线上功能,往往有些团队会投机取巧、将没有测试完整的包发布到线上进行测试。该种问题的直接表现是,为了解决一个bug,可能多时间多次对同一个应用或页面进行hotfix或发布新版本。这样做是十分危险的,置线上业务稳定于不顾。为了避免此类情况发生、我们可以采用一些措施或规范来约束开发团队。例如:

上线后触发新bug数量

短时间内对相同问题发布次数

由于上线原因造成的P5-P0级别故障的数量

上线后故障恢复时间

上线后回滚的次数

非上线时间内紧急上线数量

通过收集上述数据,每月或固定周期对各个团队进行考核。并对发布状态复盘,通过制定规约,评估团队的交付质量及交付能力,挖掘团队中的发布问题及痛点,从而提高发布质量,减少线上故障率。

2. 制定度量标准,进行发布质量考核

每团队初始分为100分,每月重置,每月用此分作为迭代质量的一项标准,分数不挂钩kpi考核,只用来驱动开发团队去提高效率。

评判为两个维度:项目组发布稳定性得分、服务(站点、app、微服务等)发布质量得分

  • 非上线时间发布hotfix(项目组减1分,服务减1分)
  • 代码类hotfix,同一项目每天发布超过3次(项目组减1分,服务减2分)
  • hotfix发布失败或回滚(项目组减2分,服务减2分),发布是否失败,由运维团队认定。
  • 数据库等脚本异常或执行失败(项目组减1分)
  • 每月服务发布数量(取top5,服务按排序减5到1分)
  • 由于hotfix原因造成P2级以上的线上事故,项目组减5分,相关服务减5分
  • 项目组本月hotfix量如超过前3月平均值的30%,减10分

3. 变更管理

在google的SRE体系中,变更管理是DevOps体系中最为重要的一个部分。根据以往的经验,90%的线上故障是由于线上变更导致的,该变更原因包括软件、硬件、环境等所有因素。建设变更管理体系目的就是为了快速定位线上问题,止损由于变更造成的线上故障,及时通知相关人员做好故障预防工作。所以,变更管理体系也是需要我们重点去建设以及完善的。

落地方式包括但不限于下述几点:

  • 运维人员、对应的开发及测试人员、产品经理等微信通知
  • 大屏滚动播放最近的变更记录
  • 变更记录同步到监控系统

五、总结

总结为一句话,虽然在敏捷开发模式下、产品、开发、测试团队都在小步快跑,但运维必须有自己的原则,一定要对整个上线流程制定规范、对DevOps工具链进行统一管理。

线上稳定大于一切!

kubernetes-部署Oracle数据库

Daniel_Ji阅读(15867)评论(0)

1、Oracle数据库

Oracle Database,又名Oracle RDBMS,或简称Oracle。是甲骨文公司的一款关系数据库管理系统。它是在数据库领域一直处于领先地位的产品。可以说Oracle数据库系统是目前世界上流行的关系数据库管理系统,系统可移植性好、使用方便、功能强,适用于各类大、中、小、微机环境。它是一种高效率、可靠性好的、适应高吞吐量的数据库方案。

2、Oracle部署

下面是Oracle部署的定义代码,此代码由两部分组成,即Oracle部署的部署以及其代理服务。此处部署的Oracle数据库为11g  r2,镜像使用的是mybook2019/oracle-ee-11g:v1.0。通过NodePort模式对外暴露了1521和1158这两个端口,并通过nfs文件系统对Oracle的数据进行持久化。

#-------------定义oralce代理服务--------------------
apiVersion: v1
kind: Service
metadata:
  name: oralce-svc
  labels:
    app: oralce
spec:
  type: NodePort
  ports:
  - port: 1521
    targetPort: 1521
    name: oracle1521
  - port: 8080
    targetPort: 8080
    name: oralce8080
  selector:
    app: oralce
---

#-------------定义oralce部署--------------------
apiVersion: apps/v1
kind: Deployment
metadata:
  name: oralce
spec:
  replicas: 1
  selector:
    matchLabels:
      app: oralce
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: oralce
    spec:
      containers:
      - image: mybook2019/oracle-ee-11g:v1.0
        name: oralce
        - containerPort: 1521
          name: oralce1521
        - containerPort: 8080
          name: oralce8080
        volumeMounts:
        - name: oralce-data
          mountPath: /u01/app/oracle
      volumes:
      - name: oralce-data
        nfs:
          path: /home/sharenfs/oracle
          server: 192.168.8.132

通过kubectl,执行下面的命令在Kubernetes集群中部署Oracle数据库。

$ kubectl create -f oracle11g-en.yaml --namespace=kube-public

在部署完成后,通过下面的命令可以查看oracle暴露的端口(此处的端口为1521和32175):

$ kubectl get svc --namespace=kube-public

3、部署验证

在此处,部署好Oracle以后。

1)在Kubernetes集群内的应用,连接数据库的相关信息如下:

hostname: oracle-svc.kube-public
port: 1521
sid: EE
service name: EE.oracle.docker
username: system
password: oracle

对于在oracle客户端所在机器上,执行下面的命令连接到数据库。

$ sqlplus system/oracle@//oracle-svc.kube-public:1521/EE.oracle.docker

2)在Kubernetes集群外的应用,连接数据库的所使用的相关信息如下:

hostname: 10.0.32.165
port: 32175
sid: EE
service name: EE.oracle.docker
username: system
password: oracle

对于在oracle客户端所在机器上,执行下面的命令连接到数据库。

$ sqlplus system/oracle@//10.0.32.165:32175/EE.oracle.docker

DevOps is Hard、DevSecOps is Even Harder — Enterprise Holdings.

JFrogchina阅读(1040)评论(0)

Enterprise Holdings. 的IT团队超过2000人,在2018年的演讲中介绍了Enterprise Holdings的DevOps是如何转型的。我们通过打造一个不只包涵了pipeline的CI/CD平台,将其称之为SDLC。在最开始的200+个应用中,我们挑选出5个来作为试点。当时的情况证明这次DevOps转型计划是成功的,我们的团队有4+位工程师和两位架构师,从2年半前就开始了整个平台的开发工作,根据业务需求确保平台可以适配各种云服务、也要适配已有的中间件,我们也在不断对CI/CD平台进行改进,以适应所有业务场景。其的目标是让开发人员更专注于具体的项目开发,让工具去解决一些通用性的问题。为了达到目前的效果,我们做了很多关于平台的需求收集及问题反馈相关的运营工作,所以在过去的一年里,我们已经将此套平台服务于70%的应用中,并且这个数字还在持续的增加。

在DevOps转型过程中,我们的角色并不是软件的开发者,但我们支撑了应用开发团队和他们所开发的应用,我们的服务工作介于应用程序与基础设施之间。在我们的角度来看,应用程序的开发应该是这样的:

  • 开发人员在本地开发
  • 在仓库中检查源码
  • 在构建服务器上构建应用
  • 运行安全扫描
  • 打包发布到JFrog的Artifactory
  • 发布应用到不同的环境测试
  • 所有测试结束后,发布到生产环境

这个模式很简单,但是也很高效,但是为了实现这个流程做了非常多的事,我们开发了一些被称之为共享库的模版,并将此和打包程序、自动化脚本、ansible脚本等一起存储到源码仓库中进行版本管理,同时提供给应用团队去使用。为了支撑我们的应用团队按上述流程实践,我们使用了很多工具。

持续集成工具链包括:git、maven、gradle、Artifactory、Bitbucket、BlackDuck、jenkins

持续交付工具包括:Ansible、jenkins、Bitbucket、Artifactory、Oracle、Tomcat等

工具使用简单,所以就会有人告诉你DevOps是简单的,但这种说法是不负责任的,不能认为使用了某个工具,我们就实践了整个DevOps理念。我们公司的it团队由超过2000人组成,这些人开发了大量的应用程序,我们要保证整个团队都能正常的工作。虽然每个团队使用的技术栈不同,使用的平台不同,但我们需要找到这些人的共同点,以便在我们的DevOps平台上更好的适配所有团队和开发者以及超过200个的应用。我们需要保证所有人都能应用我们的平台,并且保障平台实时可用,为此我们在jenkins的上面使用groovy开发了很多pipeline模版、自动化脚本、jenkinsfile等供其他团队使用。这样我们就能引导开发人员使用工具的时候是按照我们指引的方式去使用的,并且在这个过程中我们设置了很多关卡,明确告诉了开发人员如果进行这些校验、他们的应用程序是无法正常的被构建的。这样的结果就是,开发人员使用我们定义好的模版,自动进行安全扫描,收集元数据,并把应用包上传到Artifactory中统一管理。之后我们的团队就可以通过这些元数据所收集的结果,去反查到你的应用程序包括什么。我们在模版中维护了一个json串,告诉你这个模版会做什么事、收集什么数据。

上面都是说的CI的内容,接下来我们讨论下CD。很遗憾,到目前为止我们仍然没有办法将所有的CD流程自动化,我们有太多的开发场景和平台,有大量复杂的工作等着我们去做。在我们的CD体系中ansible负责了大量的工作,我们使用jenkins去管理我们的发布流程、并通过ansible去执行发布任务,最重要的是,我们收集了部署中的数据(如发布的环境、发布的时间、测试的结果等等),并把这些数据以元数据的方式回写到Artifactory中。在这个过程中你需要定制开发一些自动化的测试脚本,并把他们应用到pipeline中。

我们的构建任务运行在一个jenkins中、测试任务运行在另一个jenkins里,这样的方式保证我们的应用有一点点安全性。

在部署过程中我们存在的最大的一个问题就是,每次部署不仅仅部署一个应用,可能会涉及到很多应用同时发布,我们为了处理这个问题,让应用运维团队去梳理了应用程序间的依赖关系,以及部署的顺序。并且维护了一个清单来对整个发布进行说明。Jenkins会按照这些事先定义好的清单来进行发布 ,并收集到过程中的问题、哪个stage失败、是否影响到了其他的任务等等。并把这些问题同步到pipeline中以及Artifactory的元数据上。我们给了所有开发者对jenkins的只读权限,这样可以确保所有的相关开发者都可以看到这些问题,并及时对问题进行修复。我们通过这种方式,把一次发布由4小时缩减到1小时。

那么接下来,我们要保障的就是每个人都按照这个标准去执行就可以了

接下来我们谈论一些安全的话题

安全是我们组织中非常重要的一部分,实施起来也有很多困难。在我们缺乏安全意识的时候,我们都使用普通用户。这些普通用户,实际上拥有这些流程运行的权限。应用程序的团队甚至可以随意去使用有漏洞的组件,每当我们检查到这些问题的时候,往往这些问题已经被引入到测试环境和生产环境了,我们需要使用到很多开源软件,但是引入这些开源软件需要花费至少一个月的时间去评估它的安全问题是否会对我们的应用程序带来影响,这样的流程是与敏捷开发模式不符合的。

每一天都有非常多的漏洞被提交到公网上,所以我们希望我们的安全问题不应该仅仅由安全团队负责,开发、测试、运维团队的所有工程师都应该对安全重视起来,所以我们选择把安全扫描放到我们的CI/CD流水线里。我们强制所有应用流水线中必须增加安全扫描,如果没有这个stage,那么这条流水线是无法通过的。虽然开始的时候大家不愿意接受,但是过了一段时间,开发人员发现安全团队找他们修复漏洞的这种事变得越来越少,大家也就慢慢常态化了安全扫描这个步骤。这样安全团队也将专心的把时间花费在研究漏洞对应用程序的影响上,减少了与开发团队测试团队的沟通成本。另外我们制定了流水线安全的SLA,来定义一个构建的所有依赖是否满足上线需求。在这个过程也不是完全顺利的,我们发现每条流水线里都进行安全扫描非常花费时间和资源,所以我们改进了方案,每次扫描只扫描一些新的依赖、组件以及新的漏洞特征,这样也大大的提高了安全扫描的效率。

未来工作中,我们会继续与我们所有支持的团队保持持续的沟通,我们要随时了解支持的团队的所有想法和产品,结合实际情况,向他们展示我们的CICD平台是如何给他们带来收益的,确保最终每个团队都可以采用我们的最佳实践,主动来接入我们的平台。总结来说,你所知道完整的CI CD应该是这样的,它不仅是开发,不仅是安全,更是运维、测试。所以pipeline基本等同于一切。我们真的想确保我们所有的过程的设计是安全的,这是我们团队每个人的目标,我们真正专注于在基础设施团队内部全面整合。整合内容包括服务器环境、网络、技术栈等等,而实际上这些整合都是依赖于我们的CICD平台建设的。

 

 

 

更多技术分享请关注    JFrog杰蛙在线课堂

1月14日在线课堂:《JFrog免费社区版容器镜像仓库JCR—功能介绍及实践》

课堂收益:

  1. 了解JCR的优良特性和强大能力
  2. 通过示例演示,掌握如何利用JCR更好地支持微服务及Kubernetes应用的开发和部署

 

报名链接:https://www.bagevent.com/event/6334008

 

抽奖活动:

课堂结束前五分钟,进行抽奖活动

第一名:小爱音箱

第二名:JFrog杰蛙新版T恤

第三名:JFrog杰蛙新版T恤

K8s 实践 | 如何解决多租户集群的安全隔离问题?

alicloudnative阅读(1547)评论(0)

作者 | 匡大虎  阿里巴巴技术专家

导读:如何解决多租户集群的安全隔离问题是企业上云的一个关键问题,本文主要介绍 Kubernetes 多租户集群的基本概念和常见应用形态,以及在企业内部共享集群的业务场景下,基于 Kubernetes 原生和 ACK 集群现有安全管理能力快速实现多租户集群的相关方案。

什么是多租户集群?

这里首先介绍一下”租户”,租户的概念不止局限于集群的用户,它可以包含为一组计算,网络,存储等资源组成的工作负载集合。而在多租户集群中,需要在一个集群范围内(未来可能会是多集群)对不同的租户提供尽可能的安全隔离,以最大程度的避免恶意租户对其他租户的攻击,同时需要保证租户之间公平地分配共享集群资源。

在隔离的安全程度上,我们可以将其分为软隔离 (Soft Multi-tenancy) 和硬隔离 (Hard Multi-tenancy) 两种。

  • 其中软隔离更多的是面向企业内部的多租需求,该形态下默认不存在恶意租户,隔离的目的是为了内部团队间的业务保护和对可能的安全攻击进行防护;
  • 而硬隔离面向的更多是对外提供服务的服务供应商,由于该业务形态下无法保证不同租户中业务使用者的安全背景,我们默认认为租户之间以及租户与 K8s 系统之间是存在互相攻击的可能,因此这里也需要更严格的隔离作为安全保障。

关于多租户的不同应用场景,在下节会有更细致的介绍。

2.png

多租户应用场景

下面介绍一下典型的两种企业多租户应用场景和不同的隔离需求:

企业内部共享集群的多租户

该场景下集群的所有用户均来自企业内部,这也是当前很多 K8s 集群客户的使用模式,因为服务使用者身份的可控性,相对来说这种业务形态的安全风险是相对可控的,毕竟老板可以直接裁掉不怀好意的员工:)根据企业内部人员结构的复杂程度,我们可以通过命名空间对不同部门或团队进行资源的逻辑隔离,同时定义以下几种角色的业务人员:

  • 集群管理员:具有集群的管理能力(扩缩容、添加节点等操作);负责为租户管理员创建和分配命名空间;负责各类策略(RAM/RBAC/networkpolicy/quota…)的 CRUD;
  • 租户管理员:至少具有集群的 RAM 只读权限;管理租户内相关人员的 RBAC 配置;
  • 租户内用户:在租户对应命名空间内使用权限范围内的 K8s 资源。

在建立了基于用户角色的访问控制基础上,我们还需要保证命名空间之间的网络隔离,在不同的命名空间之间只能够允许白名单范围内的跨租户应用请求。

另外,对于业务安全等级要求较高的应用场景,我们需要限制应用容器的内核能力,可以配合 seccomp / AppArmor / SELinux 等策略工具达到限制容器运行时刻 capabilities 的目的。

3.png

当然 Kubernetes 现有的命名空间单层逻辑隔离还不足以满足一部分大型企业应用复杂业务模型对隔离需求,我们可以关注 Virtual Cluster,它通过抽象出更高级别的租户资源模型来实现更精细化的多租管理,以此弥补原生命名空间能力上的不足。

SaaS & KaaS 服务模型下的多租户

在 SaaS 多租场景下, Kubernetes 集群中的租户对应为 SaaS 平台中各服务应用实例和 SaaS 自身控制平面,该场景下可以将平台各服务应用实例划分到彼此不同的命名空间中。而服务的最终用户是无法与 Kubernetes 的控制平面组件进行交互,这些最终用户能够看到和使用的是 SaaS 自身控制台,他们通过上层定制化的 SaaS 控制平面使用服务或部署业务(如下左图所示)。

例如,某博客平台部署在多租户集群上运行。在该场景下,租户是每个客户的博客实例和平台自己的控制平面。平台的控制平面和每个托管博客都将在不同的命名空间中运行。客户将通过平台的界面来创建和删除博客、更新博客软件版本,但无法了解集群的运作方式。

KaaS 多租场景常见于云服务提供商,该场景下业务平台的服务直接通过 Kubernetes 控制平面暴露给不同租户下的用户,最终用户可以使用 K8s 原生 API 或者服务提供商基于 CRDs/controllers 扩展出的接口。出于隔离的最基本需求,这里不同租户也需要通过命名空间进行访问上的逻辑隔离,同时保证不同租户间网络和资源配额上的隔离。

与企业内部共享集群不同,这里的最终用户均来自非受信域,他们当中不可避免的存在恶意租户在服务平台上执行恶意代码,因此对于 SaaS/KaaS 服务模型下的多租户集群,我们需要更高标准的安全隔离,而 Kubernetes 现有原生能力还不足以满足安全上的需求,为此我们需要如安全容器这样在容器运行时刻内核级别的隔离来强化该业务形态下的租户安全。

4.jpeg

实施多租户架构

在规划和实施多租户集群时,我们首先可以利用的是 Kubernetes 自身的资源隔离层,包括集群本身、命名空间、节点、pod 和容器均是不同层次的资源隔离模型。当不同租户的应用负载能够共享相同的资源模型时,就会存在彼此之间的安全隐患。为此,我们需要在实施多租时控制每个租户能够访问到的资源域,同时在资源调度层面尽可能的保证处理敏感信息的容器运行在相对独立的资源节点内;如果出于资源开销的角度,当有来自不同租户的负载共享同一个资源域时,可以通过运行时刻的安全和资源调度控制策略减少跨租户攻击的风险。

虽然 Kubernetes 现有安全和调度能力还不足以完全安全地实施多租隔离,但是在如企业内部共享集群这样的应用场景下,通过命名空间完成租户间资源域的隔离,同时通过 RBAC、PodSecurityPolicy、NetworkPolicy 等策略模型控制租户对资源访问范围和能力的限制,以及现有资源调度能力的结合,已经可以提供相当的安全隔离能力。而对于 SaaS、KaaS 这样的服务平台形态,我们可以通过阿里云容器服务近期推出的安全沙箱容器来实现容器内核级别的隔离,能够最大程度的避免恶意租户通过逃逸手段的跨租户攻击。

本节重点关注基于 Kubernetes 原生安全能力的多租户实践。

访问控制

AuthN & AuthZ & Admission

ACK 集群的授权分为 RAM 授权和 RBAC 授权两个步骤,其中 RAM 授权作用于集群管理接口的访问控制,包括对集群的 CRUD 权限(如集群可见性、扩缩容、添加节点等操作),而 RBAC 授权用于集群内部 Kubernetes 资源模型的访问控制,可以做到指定资源在命名空间粒度的细化授权。

ACK 授权管理为租户内用户提供了不同级别的预置角色模板,同时支持绑定多个用户自定义的集群角色,此外支持对批量用户的授权。如需详细了解 ACK 上集群相关访问控制授权,请参阅相关帮助文档

NetworkPolicy

NetworkPolicy 可以控制不同租户业务 pod 之间的网络流量,另外可以通过白名单的方式打开跨租户之间的业务访问限制。

您可以在使用了 Terway 网络插件的容器服务集群上配置 NetworkPolicy,这里可以获得一些策略配置的示例。

PodSecurityPolicy

PSP 是 K8s 原生的集群维度的资源模型,它可以在apiserver中pod创建请求的 admission 阶段校验其运行时刻行为是否满足对应 PSP 策略的约束,比如检查 pod 是否使用了 host 的网络、文件系统、指定端口、PID namespace 等,同时可以限制租户内的用户开启特权(privileged)容器,限制挂盘类型,强制只读挂载等能力;不仅如此,PSP 还可以基于绑定的策略给 pod 添加对应的 SecurityContext,包括容器运行时刻的 uid,gid 和添加或删除的内核 capabilities 等多种设置。

关于如何开启 PSP admission 和相关策略及权限绑定的使用,可以参阅这里

OPA

OPA(Open Policy Agent)是一种功能强大的策略引擎,支持解耦式的 policy decisions 服务并且社区已经有了相对成熟的与 Kubernetes 的集成方案。当现有 RBAC 在命名空间粒度的隔离不能够满足企业应用复杂的安全需求时,可以通过 OPA 提供 object 模型级别的细粒度访问策略控制。

同时 OPA 支持七层的 NetworkPolicy 策略定义及基于 labels/annotation 的跨命名空间访问控制,可以作为 K8s 原生 NetworkPolicy 的有效增强。

资源调度相关

Resource Quotas & Limit Range

在多租户场景下,不同团队或部门共享集群资源,难免会有资源竞争的情况发生,为此我们需要对每个租户的资源使用配额做出限制。其中 ResourceQuota 用于限制租户对应命名空间下所有 pod 占用的总资源 request 和 limit,LimitRange 用来设置租户对应命名空间中部署 pod 的默认资源 request 和 limit 值。另外我们还可以对租户的存储资源配额和对象数量配额进行限制。

关于资源配额的详细指导可以参见这里

Pod Priority/Preemption

从 1.14 版本开始 pod 的优先级和抢占已经从 beta 成为稳定特性,其中 pod priority 标识了 pod 在 pending 状态的调度队列中等待的优先级;而当节点资源不足等原因造成高优先的 pod 无法被调度时,scheduler 会尝试驱逐低优先级的 pod 来保证高优先级 pod 可以被调度部署。

在多租户场景下,可以通过优先级和抢占设置确保租户内重要业务应用的可用性;同时 pod priority 可以和 ResouceQuota 配合使用,完成租户在指定优先级下有多少配额的限制。

Dedicated Nodes

注意:恶意租户可以规避由节点污点和容忍机制强制执行的策略。以下说明仅用于企业内部受信任租户集群,或租户无法直接访问 Kubernetes 控制平面的集群。

通过对集群中的某些节点添加污点,可以将这些节点用于指定几个租户专门使用。在多租户场景下,例如集群中的 GPU 节点可以通过污点的方式保留给业务应用中需要使用到 GPU 的服务团队使用。集群管理员可以通过如 effect: “NoSchedule” 这样的标签给节点添加污点,同时只有配置了相应容忍设置的 pod 可以被调度到该节点上。

当然恶意租户可以同样通过给自身 pod 添加同样的容忍配置来访问该节点,因此仅使用节点污点和容忍机制还无法在非受信的多租集群上保证目标节点的独占性。

关于如何使用节点污点机制来控制调度,请参阅这里

敏感信息保护

secrets encryption at REST

在多租户集群中不同租户用户共享同一套 etcd 存储,在最终用户可以访问 Kubernetes 控制平面的场景下,我们需要保护 secrets 中的数据,避免在访问控制策略配置不当情况下的敏感信息泄露。为此可以参考 K8s 原生的 secret 加密能力,请参阅这里

ACK 也提供了基于阿里云 KMS 服务的 secrets 加密开源解决方案,可以参阅这里

总结

在实施多租户架构时首先需要确定对应的应用场景,包括判断租户内用户和应用负载的可信程度以及对应的安全隔离程度。在此基础上以下几点是安全隔离的基本需求:

  • 开启 Kubernetes 集群的默认安全配置:开启 RBAC 鉴权并实现基于namespace的软隔离;开启 secrets encryption 能力,增强敏感信息保护;基于 CIS Kubernetes benchmarks 进行相应的安全配置;
  • 开启 NodeRestriction, AlwaysPullImages, PodSecurityPolicy 等相关 admission controllers;
  • 通过 PSP 限制 pod 部署的特权模式,同时控制其运行时刻 SecurityContext;
  • 配置 NetworkPolicy;
  • 使用Resource Quota & Limit Range限制租户的资源使用配额;
  • 在应用运行时刻遵循权限的最小化原则,尽可能缩小pod内容器的系统权限;
  • Log everything;
  • 对接监控系统,实现容器应用维度的监控。

而对于如 SaaS、KaaS 等服务模型下,或者我们无法保证租户内用户的可信程度时,我们需要采取一些更强有力的隔离手段,比如:

  • 使用如 OPA 等动态策略引擎进行网络或 Object 级别的细粒度访问控制;
  • 使用安全容器实现容器运行时刻内核级别的安全隔离;
  • 完备的监控、日志、存储等服务的多租隔离方案。

注意:文中图片来源

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

VMware 完成 27 亿美元的 Pivotal 收购 | 云原生生态周报 Vol. 34

alicloudnative阅读(804)评论(0)

作者 | 汪萌海、王思宇、李鹏

业界要闻

  1. VMware 完成 27 亿美元的 Pivotal 收购

VMware 在 12 月 30 日宣布,已完成 27 亿美元的 Pivotal 收购,同一天 Pivotal 也已被纽约股市除名,成为 VMware 的子公司。

  1. 谷歌发布 BeyondCorp 的白皮书

BeyondCorp 是一个“零信任”安全框架,它将访问控制从外围转移到单个设备和用户,允许员工在任何位置安全地工作,而不需要传统的虚拟专用网络。使用 BeyondProd,谷歌实现了与连接机器、工作负载和服务类似的零信任原则。

  1. PingCAP 正式开源混沌测试平台 Chaos Mesh

目前支持的错误注入主要有:pod-kill、pod-failure(模拟节点宕机不可用)、网络延迟、网络丢包、网络包重复、网络包损坏,网络分区、文件系统 I/O 延迟、文件系统 I/O 错误。

上游重要进展

  1. v1.16.5 将升级 go 默认版本为 v1.13.4
  2. k8s-apiserver 支持缓存一致性读

基本实现类似 etcd 内部实现的 quorum read,基本流程:

  • 从 etcd 获取当前最新的 revision 记录为 v0;
  • 校验缓存中的 revision 是否 >=v0,如果是的话直接返回;
  • 否则会触发 etcd 的 WatchProgressRequest;
  • 异步等待缓存中的 revision>=v0,如果等待超时,退化回读 etcd。
  1. 1.17 clusters are failing during master rolling upgrade

解决 kube-apiserver 滚动升级的时候,客户端连接到新的 kube-apiserver 时,大量执行 list+watch 导致雪崩效应。

开源项目推荐

  1. Chaos Mesh

作为一个云原生的混沌测试平台,Chaos Mesh 提供在 Kubernetes 平台上进行混沌测试的能力。

  1. kubelive

一款小工具,可以实时交互式的方式去查看和操作 K8s 的资源对象,目前支持 Pod 关联的对象。

  1. kubectl-tree

该插件提供 K8s 资源对象的归属关系,通过 K8s 对象的 ownerReferencesfield 可以知道某个对象的 owner,以此能够花痴一个对象的 owner ref 的族谱。

  1. Prometheus Alert

开源的运维告警中心消息转发系统, 支持主流的监控系统 Prometheus,日志系统 Graylog 和数据可视化系统 Grafana 发出的预警消息,支持钉钉、微信、华为云短信、腾讯云短信、腾讯云电话、阿里云短信、阿里云电话等。

本周阅读推荐

  1. 《Kubernetes 会不会“杀死” DevOps?》

DevOps 作为一种打破研发和运维之间隔阂、加快软件交付流程、提高软件交付质量的文化理念和最佳实践,在经历云计算的普及、K8s 已成为自动化运维的主流平台之后会不会消失?

  1. 《2020 年值得关注的 DevOps 趋势》

到 2020 年,你将清楚地看到 DevOps,持续的更新如何改变软件交付到几乎无限市场的方式。DevOps 已成为在这个竞争激烈的技术世界中蓬勃发展的必需品。

  1. 《清华姚班毕业生开发新特效编程语言,99行代码实现《冰雪奇缘》,网友:大神碉堡!创世的快乐》

中国 MIT 博士胡渊鸣开源了计算机图形学语言 Taichi,极大降低了计算机图形编程的门槛。

  1. 《从零开始入门 K8s | 调度器的调度流程和算法介绍》

详细介绍了 kube-scheduler 调度框架、流程,以及主要的过滤器、Score 算法实现等,并介绍了两种方式用于实现自定义调度能力。

  1. 《2020 预测:Kubernetes 将成为边缘计算的杀手级应用》

边缘计算代表了下一个创新的前沿,在 2020 年,Kubernetes 将成为边缘计算的杀手级应用。

  1. 《Kata Containers 创始人:安全容器导论》

介绍了安全容器名字是怎么来的、安全容器的主要作用,还介绍了 kata 和gVisor的虚拟化技术对比,最后指出安全容器不止于安全,“隔离”才能让云原生基础设施更美好。

  1. 《Knative 驾驭篇:带你 ‘纵横驰骋’ Knative 自动扩缩容实现》

Knative 中提供了自动扩缩容灵活的实现机制,本文从 三横两纵 的维度带你深入了解 KPA 自动扩缩容的实现机制。让你轻松驾驭 Knative 自动扩缩容。

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

我们为什么会删除不了集群的 Namespace?

alicloudnative阅读(821)评论(0)

作者 | 声东  阿里云售后技术专家

导读:阿里云售后技术团队的同学,每天都在处理各式各样千奇百怪的线上问题。常见的有网络连接失败、服务器宕机、性能不达标及请求响应慢等。但如果要评选的话,什么问题看起来微不足道事实上却让人绞尽脑汁,我相信肯定是“删不掉”的问题,比如文件删不掉、进程结束不掉、驱动卸载不了等。这样的问题就像冰山,隐藏在它们背后的复杂逻辑,往往超过我们的预想。

背景

今天我们讨论的这个问题,跟 K8s 集群的 Namespace 有关。Namespace 是 K8s 集群资源的“收纳”机制。我们可以把相关的资源“收纳”到同一个 Namespace 里,以避免不相关资源之间不必要的影响。

Namespace 本身也是一种资源。通过集群 API Server 入口,我们可以新建 Namespace,而对于不再使用的 Namespace,我们需要清理掉。Namespace 的 Controller 会通过 API Server,监视集群中 Namespace 的变化,然后根据变化来执行预先定义的动作。

1.png

有时候,我们会遇到下图中的问题,即 Namespace 的状态被标记成了 “Terminating”,但却没有办法被完全删除。

2.png

从集群入口开始

因为删除操作是通过集群 API Server 来执行的,所以我们要分析 API Server 的行为。跟大多数集群组件类似,API Server 提供了不同级别的日志输出。为了理解 API Server 的行为,我们将日志级别调整到最高级。然后,通过创建删除 tobedeletedb 这个 Namespace 来重现问题。

但可惜的是,API Server 并没有输出太多和这个问题有关的日志。

相关的日志,可以分为两部分:

  • 一部分是 Namespace 被删除的记录,记录显示客户端工具是 kubectl,以及发起操作的源 IP 地址是 192.168.0.41,这符合预期;
  • 另外一部分是 Kube Controller Manager 在重复地获取这个 Namespace 的信息。

3.png

Kube Controller Manager 实现了集群中大多数的 Controller,它在重复获取 tobedeletedb 的信息,基本上可以判断,是 Namespace 的 Controller 在获取这个 Namespace 的信息。

Controller 在做什么?

和上一节类似,我们通过开启 Kube Controller Manager 最高级别日志,来研究这个组件的行为。在 Kube Controller Manager 的日志里,可以看到 Namespace 的 Controller 在不断地尝试一个失败了的操作,就是清理 tobedeletedb 这个 Namespace 里“收纳”的资源。

4.png

怎么样删除“收纳盒”里的资源?

这里我们需要理解一点,就是 Namespace 作为资源的“收纳盒”,其实是逻辑意义上的概念。它并不像现实中的收纳工具,可以把小的物件收纳其中。Namespace 的“收纳”实际上是一种映射关系。

5.png

这一点之所以重要,是因为它直接决定了,删除 Namespace 内部资源的方法。如果是物理意义上的“收纳”,那我们只需要删除“收纳盒”,里边的资源就一并被删除了。而对于逻辑意义上的关系,我们则需要罗列所有资源,并删除那些指向需要删除的 Namespace 的资源。

API、Group、Version

怎么样罗列集群中的所有资源呢?这个问题需要从集群 API 的组织方式说起。K8s 集群的 API 不是铁板一块的,它是用分组和版本来组织的。这样做的好处显而易见,就是不同分组的 API 可以独立迭代,互不影响。常见的分组如 apps,它有 v1、v1beta1 和 v1beta2 三个版本。完整的分组/版本列表,可以使用 kubectl api-versions 命令看到。

6.png

我们创建的每一个资源,都必然属于某一个 API 分组/版本。以下边 Ingress 为例,我们指定 Ingress 资源的分组/版本为 networking.k8s.io/v1beta1。

kind: Ingress
metadata:
  name: test-ingress
spec:
  rules:
  - http:
      paths:
      - path: /testpath
        backend:
          serviceName: test
          servicePort: 80

用一个简单的示意图来总结 API 分组和版本。

7.png

实际上,集群有很多 API 分组/版本,每个 API 分组/版本支持特定的资源类型。我们通过 yaml 编排资源时,需要指定资源类型 kind,以及 API 分组/版本 apiVersion。而要列出资源,我们需要获取 API 分组/版本的列表。

Controller 为什么不能删除 Namespace 里的资源

理解了 API 分组/版本的概念之后,再回头看 Kube Controller Manager 的日志,就会豁然开朗。显然 Namespace 的 Controller 在尝试获取 API 分组/版本列表,当遇到 metrics.k8s.io/v1beta1 的时候,查询失败了。并且查询失败的原因是 “the server is currently unable to handle the request”。

再次回到集群入口

在上一节中,我们发现 Kube Controller Manager 在获取 metrics.k8s.io/v1beta1 这个 API 分组/版本的时候失败了。而这个查询请求,显然是发给 API Server 的。所以我们回到 API Server 日志,分析 metrics.k8s.io/v1beta1 相关的记录。在相同的时间点,我们看到 API Server 也报了同样的错误 “the server is currently unable to handle the request”。

8.png

显然这里有一个矛盾,就是 API Server 明显在正常工作,为什么在获取 metrics.k8s.io/v1beta1 这个 API 分组版本的时候,会返回 Server 不可用呢?为了回答这个问题,我们需要理解一下 API Server 的“外挂”机制。

9.png

集群 API Server 有扩展自己的机制,开发者可以利用这个机制,来实现 API Server 的“外挂”。这个“外挂”的主要功能,就是实现新的 API 分组/版本。API Server 作为代理,会把相应的 API 调用,转发给自己的“外挂”。

以 Metrics Server 为例,它实现了 metrics.k8s.io/v1beta1 这个 API 分组/版本。所有针对这个分组/版本的调用,都会被转发到 Metrics Server。如下图,Metrics Server 的实现,主要用到一个服务和一个 pod。

10.png

而上图中最后的 apiservice,则是把“外挂”和 API Server 联系起来的机制。下图可以看到这个 apiservice 详细定义。它包括 API 分组/版本,以及实现了 Metrics Server 的服务名。有了这些信息,API Server 就能把针对 metrics.k8s.io/v1beta1 的调用,转发给 Metrics Server。

11.png

节点与Pod之间的通信

经过简单的测试,我们发现,这个问题实际上是 API server 和 metrics server pod 之间的通信问题。在阿里云 K8s 集群环境里,API Server 使用的是主机网络,即 ECS 的网络,而 Metrics Server 使用的是 Pod 网络。这两者之间的通信,依赖于 VPC 路由表的转发。

12.png

以上图为例,如果 API Server 运行在 Node A 上,那它的 IP 地址就是 192.168.0.193。假设 Metrics Server 的 IP 是 172.16.1.12,那么从 API Server 到 Metrics Server 的网络连接,必须要通过 VPC 路由表第二条路由规则的转发。

检查集群 VPC 路由表,发现指向 Metrics Server 所在节点的路由表项缺失,所以 API server 和 Metrics Server 之间的通信出了问题。

Route Controller 为什么不工作?

为了维持集群 VPC 路由表项的正确性,阿里云在 Cloud Controller Manager 内部实现了 Route Controller。这个 Controller 在时刻监听着集群节点状态,以及 VPC 路由表状态。当发现路由表项缺失的时候,它会自动把缺失的路由表项填写回去。

现在的情况,显然和预期不一致,Route Controller 显然没有正常工作。这个可以通过查看 Cloud Controller Manager 日志来确认。在日志中,我们发现,Route Controller 在使用集群 VPC id 去查找 VPC 实例的时候,没有办法获取到这个实例的信息。

13.png

但是集群还在,ECS 还在,所以 VPC 不可能不在了。这一点我们可以通过 VPC id 在 VPC 控制台确认。那下边的问题,就是为什么 Cloud Controller Manager 没有办法获取到这个 VPC 的信息呢?

集群节点访问云资源

Cloud Controller Manager 获取 VPC 信息,是通过阿里云开放 API 来实现的。这基本上等于从云上一台 ECS 内部,去获取一个 VPC 实例的信息,而这需要 ECS 有足够的权限。目前的常规做法是,给 ECS 服务器授予 RAM 角色,同时给对应的 RAM 角色绑定相应的角色授权。

14.png

如果集群组件,以其所在节点的身份,不能获取云资源的信息,那基本上有两种可能性。一是 ECS 没有绑定正确的 RAM 角色;二是 RAM 角色绑定的 RAM 角色授权没有定义正确的授权规则。检查节点的 RAM 角色,以及 RAM 角色所管理的授权,我们发现,针对 vpc 的授权策略被改掉了。

15.png

当我们把 Effect 修改成 Allow 之后,没多久,所有的 Terminating 状态的 namespace 全部都消失了。

16.png

问题大图

总体来说,这个问题与 K8s 集群的 6 个组件有关系,分别是 API Server 及其扩展 Metrics Server,Namespace Controller 和 Route Controller,以及 VPC 路由表和 RAM 角色授权。

17.png

通过分析前三个组件的行为,我们定位到,集群网络问题导致了 API Server 无法连接到 Metrics Server;通过排查后三个组件,我们发现导致问题的根本原因是 VPC 路由表被删除且 RAM 角色授权策略被改动。

后记

K8s 集群 Namespace 删除不掉的问题,是线上比较常见的一个问题。这个问题看起来无关痛痒,但实际上不仅复杂,而且意味着集群重要功能的缺失。这篇文章全面分析了一个这样的问题,其中的排查方法和原理,希望对大家排查类似问题有一定的帮助。

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

Go 开发关键技术指南 | 敢问路在何方?(内含超全知识大图)

alicloudnative阅读(862)评论(0)

作者 | 杨成立(忘篱) 阿里巴巴高级技术专家

Go 开发关键技术指南文章目录:

Go 开发指南大图

Engineering

我觉得 Go 在工程上良好的支持,是 Go 能够在服务器领域有一席之地的重要原因。这里说的工程友好包括:

  • gofmt 保证代码的基本一致,增加可读性,避免在争论不清楚的地方争论;
  • 原生支持的 profiling,为性能调优和死锁问题提供了强大的工具支持;
  • utest 和 coverage,持续集成,为项目的质量提供了良好的支撑;
  • example 和注释,让接口定义更友好合理,让库的质量更高。

GOFMT 规范编码

之前有段时间,朋友圈霸屏的新闻是码农因为代码不规范问题枪击同事,虽然实际上枪击案可能不是因为代码规范,但可以看出大家对于代码规范问题能引发枪击是毫不怀疑的。这些年在不同的公司码代码,和不同的人一起码代码,每个地方总有人喜欢纠结于 if () 中是否应该有空格,甚至还大开怼戒。

Go 语言从来不会有这种争论,因为有 gofmt,语言的工具链支持了格式化代码,避免大家在代码风格上白费口舌。

比如,下面的代码看着真是揪心,任何语言都可以写出类似的一坨代码:

package main
import (
    "fmt"
    "strings"
)
func foo()[]string {
    return []string{"gofmt","pprof","cover"}}

func main() {
    if v:=foo();len(v)>0{fmt.Println("Hello",strings.Join(v,", "))}
}

如果有几万行代码都是这样,是不是有扣动扳机的冲动?如果我们执行下 gofmt -w t.go 之后,就变成下面的样子:

package main

import (
    "fmt"
    "strings"
)

func foo() []string {
    return []string{"gofmt", "pprof", "cover"}
}

func main() {
    if v := foo(); len(v) > 0 {
        fmt.Println("Hello", strings.Join(v, ", "))
    }
}

是不是心情舒服多了?gofmt 只能解决基本的代码风格问题,虽然这个已经节约了不少口舌和唾沫,我想特别强调几点:

  • 有些 IDE 会在保存时自动 gofmt,如果没有手动运行下命令 gofmt -w .,可以将当前目录和子目录下的所有文件都格式化一遍,也很容易的是不是;
  • gofmt 不识别空行,因为空行是有意义的,因为空行有意义所以 gofmt 不知道如何处理,而这正是很多同学经常犯的问题;
  • gofmt 有时候会因为对齐问题,导致额外的不必要的修改,这不会有什么问题,但是会干扰 CR 从而影响 CR 的质量。

先看空行问题,不能随便使用空行,因为空行有意义。不能在不该空行的地方用空行,不能在该有空行的地方不用空行,比如下面的例子:

package main

import (
    "fmt"
    "io"
    "os"
)

func main() {
    f, err := os.Open(os.Args[1])

    if err != nil {

        fmt.Println("show file err %v", err)
        os.Exit(-1)
    }
    defer f.Close()
    io.Copy(os.Stdout, f)
}

上面的例子看起来就相当的奇葩,if 和 os.Open 之间没有任何原因需要个空行,结果来了个空行;而 defer 和 io.Copy 之间应该有个空行却没有个空行。空行是非常好的体现了逻辑关联的方式,所以空行不能随意,非常严重地影响可读性,要么就是一坨东西看得很费劲,要么就是突然看到两个紧密的逻辑身首异处,真的让人很诧异。

上面的代码可以改成这样,是不是看起来很舒服了:

package main

import (
    "fmt"
    "io"
    "os"
)

func main() {
    f, err := os.Open(os.Args[1])
    if err != nil {
        fmt.Println("show file err %v", err)
        os.Exit(-1)
    }
    defer f.Close()
    
    io.Copy(os.Stdout, f)
}

再看 gofmt 的对齐问题,一般出现在一些结构体有长短不一的字段,比如统计信息,比如下面的代码:

package main

type NetworkStat struct {
    IncomingBytes int `json:"ib"`
    OutgoingBytes int `json:"ob"`
}

func main() {
}

如果新增字段比较长,会导致之前的字段也会增加空白对齐,看起来整个结构体都改变了:

package main

type NetworkStat struct {
    IncomingBytes          int `json:"ib"`
    OutgoingBytes          int `json:"ob"`
    IncomingPacketsPerHour int `json:"ipp"`
    DropKiloRateLastMinute int `json:"dkrlm"`
}

func main() {
}

比较好的解决办法就是用注释,添加注释后就不会强制对齐了。

Profile 性能调优

性能调优是一个工程问题,关键是测量后优化,而不是盲目优化。Go 提供了大量的测量程序的工具和机制,包括 Profiling Go Programs, Introducing HTTP Tracing,我们也在性能优化时使用过 Go 的 Profiling,原生支持是非常便捷的。

对于多线程同步可能出现的死锁和竞争问题,Go 提供了一系列工具链,比如 Introducing the Go Race Detector, Data Race Detector,不过打开 race 后有明显的性能损耗,不应该在负载较高的线上服务器打开,会造成明显的性能瓶颈。

推荐服务器开启 http profiling,侦听在本机可以避免安全问题,需要 profiling 时去机器上把 profile 数据拿到后,拿到线下分析原因。实例代码如下:

package main

import (
    "net/http"
    _ "net/http/pprof"
    "time"
)

func main() {
    go http.ListenAndServe("127.0.0.1:6060", nil)

    for {
        b := make([]byte, 4096)
        for i := 0; i < len(b); i++ {
            b[i] = b[i] + 0xf
        }
        time.Sleep(time.Nanosecond)
    }
}

编译成二进制后启动 go mod init private.me && go build . && ./private.me,在浏览器访问页面可以看到各种性能数据的导航:http://localhost:6060/debug/pprof/

例如分析 CPU 的性能瓶颈,可以执行 go tool pprof private.me http://localhost:6060/debug/pprof/profile,默认是分析 30 秒内的性能数据,进入 pprof 后执行 top 可以看到 CPU 使用最高的函数:

(pprof) top
Showing nodes accounting for 42.41s, 99.14% of 42.78s total
Dropped 27 nodes (cum <= 0.21s)
Showing top 10 nodes out of 22
      flat  flat%   sum%        cum   cum%
    27.20s 63.58% 63.58%     27.20s 63.58%  runtime.pthread_cond_signal
    13.07s 30.55% 94.13%     13.08s 30.58%  runtime.pthread_cond_wait
     1.93s  4.51% 98.64%      1.93s  4.51%  runtime.usleep
     0.15s  0.35% 98.99%      0.22s  0.51%  main.main

除了 top,还可以输入 web 命令看调用图,还可以用 go-torch 看火焰图等。

UTest 和 Coverage

当然工程化少不了 UTest 和覆盖率,关于覆盖 Go 也提供了原生支持 The cover story,一般会有专门的 CISE 集成测试环境。集成测试之所以重要,是因为随着代码规模的增长,有效的覆盖能显著的降低引入问题的可能性。

什么是有效的覆盖?一般多少覆盖率比较合适?80% 覆盖够好了吗?90% 覆盖一定比 30% 覆盖好吗?我觉得可不一定,参考 Testivus On Test Coverage。对于 UTest 和覆盖,我觉得重点在于:

  • UTest 和覆盖率一定要有,哪怕是 0.1% 也必须要有,为什么呢?因为出现故障时让老板心里好受点啊,能用数据衡量出来裸奔的代码有多少;
  • 核心代码和业务代码一定要分离,强调核心代码的覆盖率才有意义,比如整体覆盖了 80%,核心代码占 5%,核心代码覆盖率为 10%,那么这个覆盖就不怎么有效了;
  • 除了关键正常逻辑,更应该重视异常逻辑,异常逻辑一般不会执行到,而一旦藏有 bug 可能就会造成问题。有可能有些罕见的代码无法覆盖到,那么这部分逻辑代码,CR 时需要特别人工 Review。

分离核心代码是关键。

可以将核心代码分离到单独的 package,对这个 package 要求更高的覆盖率,比如我们要求 98% 的覆盖(实际上做到了 99.14% 的覆盖)。对于应用的代码,具备可测性是非常关键的,举个我自己的例子,go-oryx 这部分代码是判断哪些 url 是代理,就不具备可测性,下面是主要的逻辑:

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        if o := r.Header.Get("Origin"); len(o) > 0 {
            w.Header().Set("Access-Control-Allow-Origin", "*")
        }

        if proxyUrls == nil {
            ......
            fs.ServeHTTP(w, r)
            return
        }

        for _, proxyUrl := range proxyUrls {
            srcPath, proxyPath := r.URL.Path, proxyUrl.Path
            ......
            if proxy, ok := proxies[proxyUrl.Path]; ok {
                p.ServeHTTP(w, r)
                return
            }
        }

        fs.ServeHTTP(w, r)
    })

可以看得出来,关键需要测试的核心代码,在于后面如何判断URL符合定义的规范,这部分应该被定义成函数,这样就可以单独测试了:

func shouldProxyURL(srcPath, proxyPath string) bool {
    if !strings.HasSuffix(srcPath, "/") {
        // /api to /api/
        // /api.js to /api.js/
        // /api/100 to /api/100/
        srcPath += "/"
    }

    if !strings.HasSuffix(proxyPath, "/") {
        // /api/ to /api/
        // to match /api/ or /api/100
        // and not match /api.js/
        proxyPath += "/"
    }

    return strings.HasPrefix(srcPath, proxyPath)
}

func run(ctx context.Context) error {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        ......
        for _, proxyUrl := range proxyUrls {
            if !shouldProxyURL(r.URL.Path, proxyUrl.Path) {
                continue
            }

代码参考 go-oryx: Extract and test URL proxy,覆盖率请看 gocover: For go-oryx coverage,这样的代码可测性就会比较好,也能在有限的精力下尽量让覆盖率有效。

Note: 可见,单元测试和覆盖率,并不是测试的事情,而是代码本身应该提高的代码“可测试性”。

另外,对于 Go 的测试还有几点值得说明:

  • helper:测试时如果调用某个函数,出错时总是打印那个共用的函数的行数,而不是测试的函数。比如 test_helper.go,如果 compare 不调用 t.Helper(),那么错误显示是 hello_test.go:26: Returned: [Hello, world!], Expected: [BROKEN!],调用 t.Helper() 之后是 hello_test.go:18: Returned: [Hello, world!], Expected: [BROKEN!]`,实际上应该是 18 行的 case 有问题,而不是 26 行这个 compare 函数的问题;
  • benchmark:测试时还可以带 Benchmark 的,参数不是 testing.T 而是 testing.B,执行时会动态调整一些参数,比如 testing.B.N,还有并行执行的 testing.PB. RunParallel,参考 Benchamrk
  • main: 测试也是有个 main 函数的,参考 TestMain,可以做一些全局的初始化和处理。
  • doc.go: 整个包的文档描述,一般是在 package http 前面加说明,比如 http doc 的使用例子。

对于 Helper 还有一种思路,就是用带堆栈的 error,参考前面关于 errors 的说明,不仅能将所有堆栈的行数给出来,而且可以带上每一层的信息。

注意如果 package 只暴露了 interface,比如 go-oryx-lib: aac 通过 NewADTS() (ADTS, error) 返回的是接口 ADTS,无法给 ADTS 的函数加 Example;因此我们专门暴露了一个 ADTSImpl 的结构体,而 New 函数返回的还是接口,这种做法不是最好的,让用户有点无所适从,不知道该用 ADTS 还是 ADTSImpl。所以一种可选的办法,就是在包里面有个 doc.go 放说明,例如 net/http/doc.go 文件,就是在 package http 前面加说明,比如 http doc 的使用例子。

注释和 Example

注释和 Example 是非常容易被忽视的,我觉得应该注意的地方包括:

  • 项目的 README.md 和 Wiki,这实际上就是新人指南,因为新人如果能懂那么就很容易了解这个项目的大概情况,很多项目都没有这个。如果没有 README,那么就需要看文件,该看哪个文件?这就让人很抓狂了;
  • 关键代码没有注释,比如库的 API,关键的函数,不好懂的代码段落。如果看标准库,绝大部分可以调用的 API 都有很好的注释,没有注释怎么调用呢?只能看代码实现了,如果每次调用都要看一遍实现,真的很难受了;
  • 库没有 Example,库是一种要求很高的包,就是给别人使用的包,比如标准库。绝大部分的标准库的包,都有 Example,因为没有 Example 很难设计出合理的 API。

先看关键代码的注释,有些注释完全是代码的重复,没有任何存在的意义,唯一的存在就是提高代码的“注释率”,这又有什么用呢,比如下面代码:

wsconn *Conn //ws connection

// The RPC call.
type rpcCall struct {

// Setup logger.
if err := SetupLogger(......); err != nil {

// Wait for os signal
server.WaitForSignals(

如果注释能通过函数名看出来(比较好的函数名要能看出来它的职责),那么就不需要写重复的注释,注释要说明一些从代码中看不出来的东西,比如标准库的函数的注释:

// Serve accepts incoming connections on the Listener l, creating a
// new service goroutine for each. The service goroutines read requests and
// then call srv.Handler to reply to them.
//
// HTTP/2 support is only enabled if the Listener returns *tls.Conn
// connections and they were configured with "h2" in the TLS
// Config.NextProtos.
//
// Serve always returns a non-nil error and closes l.
// After Shutdown or Close, the returned error is ErrServerClosed.
func (srv *Server) Serve(l net.Listener) error {

// ParseInt interprets a string s in the given base (0, 2 to 36) and
// bit size (0 to 64) and returns the corresponding value i.
//
// If base == 0, the base is implied by the string's prefix:
// base 2 for "0b", base 8 for "0" or "0o", base 16 for "0x",
// and base 10 otherwise. Also, for base == 0 only, underscore
// characters are permitted per the Go integer literal syntax.
// If base is below 0, is 1, or is above 36, an error is returned.
//
// The bitSize argument specifies the integer type
// that the result must fit into. Bit sizes 0, 8, 16, 32, and 64
// correspond to int, int8, int16, int32, and int64.
// If bitSize is below 0 or above 64, an error is returned.
//
// The errors that ParseInt returns have concrete type *NumError
// and include err.Num = s. If s is empty or contains invalid
// digits, err.Err = ErrSyntax and the returned value is 0;
// if the value corresponding to s cannot be represented by a
// signed integer of the given size, err.Err = ErrRange and the
// returned value is the maximum magnitude integer of the
// appropriate bitSize and sign.
func ParseInt(s string, base int, bitSize int) (i int64, err error) {

标准库做得很好的是,会把参数名称写到注释中(而不是用 @param 这种方式),而且会说明大量的背景信息,这些信息是从函数名和参数看不到的重要信息。

咱们再看 Example,一种特殊的 test,可能不会执行,它的主要作用是为了推演接口是否合理,当然也就提供了如何使用库的例子,这就要求 Example 必须覆盖到库的主要使用场景。举个例子,有个库需要方式 SSRF 攻击,也就是检查 HTTP Redirect 时的 URL 规则,最初我们是这样提供这个库的:

func NewHttpClientNoRedirect() *http.Client {

看起来也没有问题,提供一种特殊的 http.Client,如果发现有 Redirect 就返回错误,那么它的 Example 就会是这样:

func ExampleNoRedirectClient() {
    url := "http://xxx/yyy"

    client := ssrf.NewHttpClientNoRedirect()
    Req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        fmt.Println("failed to create request")
        return
    }

    resp, err := client.Do(Req)
    fmt.Printf("status :%v", resp.Status)
}

这时候就会出现问题,我们总是返回了一个新的 http.Client,如果用户自己有了自己定义的 http.Client 怎么办?实际上我们只是设置了 http.Client.CheckRedirect 这个回调函数。如果我们先写 Example,更好的 Example 会是这样:

func ExampleNoRedirectClient() {
    client := http.Client{}

    //Must specify checkRedirect attribute to NewFuncNoRedirect
    client.CheckRedirect = ssrf.NewFuncNoRedirect()

    Req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        fmt.Println("failed to create request")
        return
    }

    resp, err := client.Do(Req)
}

那么我们自然知道应该如何提供接口了。

其他工程化

最近得知 WebRTC 有 4GB 的代码,包括它自己的以及依赖的代码,就算去掉一般的测试文件和文档,也有 2GB 的代码!!!编译起来真的是非常耗时间,而 Go 对于编译速度的优化,据说是在 Google 有过验证的,具体我们还没有到这个规模。具体可以参考 Why so fast?,主要是编译器本身比 GCC 快 (5X),以及 Go 的依赖管理做的比较好。

Go 的内存和异常处理也做得很好,比如不会出现野指针,虽然有空指针问题可以用 recover 来隔离异常的影响。而 C 或 C++ 服务器,目前还没有见过没有内存问题的,上线后就是各种的野指针满天飞,总有因为野指针搞死的时候,只是或多或少罢了。

按照 Go 的版本发布节奏,6 个月就发一个版本,基本上这么多版本都很稳定,Go1.11 的代码一共有 166 万行 Go 代码,还有 12 万行汇编代码,其中单元测试代码有 32 万行(占 17.9%),使用实例 Example 有 1.3 万行。Go 对于核心 API 是全部覆盖的,提交有没有导致 API 不符合要求都有单元测试保证,Go 有多个集成测试环境,每个平台是否测试通过也能看到,这一整套机制让 Go 项目虽然越来越庞大,但是整体研发效率却很高。

Go2 Transition

Go2 的设计草案在 Go 2 Draft Designs ,而 Go1 如何迁移到 Go2 也是我个人特别关心的问题,Python2 和 Python3 的那种不兼容的迁移方式简直就是噩梦一样的记忆。Go 的提案中,有一个专门说了迁移的问题,参考 Go2 Transition

Go2 Transition 还不是最终方案,不过它也对比了各种语言的迁移,还是很有意思的一个总结。这个提案描述了在非兼容性变更时,如何给开发者挖的坑最小。

目前 Go1 的标准库是遵守兼容性原则的,参考 Go 1 compatibility guarantee,这个规范保证了 Go1 没有兼容性问题,几乎可以没有影响的升级比如从 Go1.2 升级到 Go1.11。几乎的意思,是很大概率是没有问题,当然如果用了一些非常冷门的特性,可能会有坑,我们遇到过 json 解析时,内嵌结构体的数据成员也得是 exposed 的才行,而这个在老版本中是可以非 exposed;还遇到过 cgo 对于链接参数的变更导致编译失败,这些问题几乎很难遇到,都可以算是兼容的吧,有时候只是把模糊不清的定义清楚了而已。

Go2 在语言和标准库上,会打破 Go1 的兼容性规范,也就是和 Go1 不再兼容。不过 Go 是分布式开源社区在维护,不能依赖于 flag day,还是要容许不同 Go 版本写的 package 的互操作性。

先了解下各个语言如何考虑兼容性:

  • C 是严格向后兼容的,很早写的程序总是能在新的编译器中编译。另外新的编译器也支持指定之前的标准,比如 -std=c90 使用 ISO C90 标准编译程序。关键的特性是编译成目标文件后,不同版本的 C 的目标文件,能完美的链接成执行程序;C90 实际上是对之前 K&R C 版本不兼容的,主要引入了 volatile 关键字、整数精度问题,还引入了 trigraphs,最糟糕的是引入了 undefined 行为比如数组越界和整数溢出的行为未定义。从 C 上可以学到的是:后向兼容非常重要;非常小的打破兼容性也问题不大特别是可以通过编译器选项来处理;能将不同版本的目标文件链接到一起是非常关键的;undefined 行为严重困扰开发者容易造成问题;
  • C++ 也是 ISO 组织驱动的语言,和 C 一样也是向后兼容的。C++和C一样坑爹的地方坑到吐血,比如 undefined行为等。尽管一直保持向后兼容,但是新的C++代码比如C++11 看起来完全不同,这是因为有新的改变的特性,比如很少会用裸指针、比如 range 代替了传统的 for 循环,这导致熟悉老C++语法的程序员看新的代码非常难受甚至看不懂。C++毋庸置疑是非常流行的,但是新的语言标准在这方面没有贡献。从C++上可以学到的新东西是:尽管保持向后兼容,语言的新版本可能也会带来巨大的不同的感受(保持向后兼容并不能保证能持续看懂)。
  • Java 也是向后兼容的,是在字节码层面和语言层面都向后兼容,尽管语言上不断新增了关键字。Java 的标准库非常庞大,也不断在更新,过时的特性会被标记为 deprecated 并且编译时会有警告,理论上一定版本后 deprecated 的特性会不可用。Java 的兼容性问题主要在 JVM 解决,如果用新的版本编译的字节码,得用新的 JVM 才能执行。Java 还做了一些前向兼容,这个影响了字节码啥的(我本身不懂 Java,作者也不说自己不是专家,我就没仔细看了)。Java 上可以学到的新东西是:要警惕因为保持兼容性而限制语言未来的改变。
  • Python2.7 是 2010 年发布的,目前主要是用这个版本。Python3 是 2006 年开始开发,2008 年发布,十年后的今天还没有迁移完成,甚至主要是用的 Python2 而不是 Python3,这当然不是 Go2 要走的路。看起来是因为缺乏向后兼容导致的问题,Python3 刻意的和之前版本不兼容,比如 print 从语句变成了一个函数,string 也变成了 Unicode(这导致和 C 调用时会有很多问题)。没有向后兼容,同时还是解释型语言,这导致 Python2 和 3 的代码混着用是不可能的,这意味着程序依赖的所有库必须支持两个版本。Python 支持 from __future__ import FEATURE,这样可以在 Python2 中用 Python3 的特性。Python 上可以学到的东西是:向后兼容是生死攸关的;和其他语言互操作的接口兼容是非常重要的;能否升级到新的语言是由调用的库支持的。

 

  • Perl6 是 2000 年开始开发的,15 年后才正式发布,这也不是 Go2 应该走的路。这么漫长的主要原因包括:刻意没有向后兼容,只有语言的规范没有实现而这些规范不断的修改。Perl 上可以学到的东西是:不要学 Perl;设置期限按期交付;别一下子全部改了。

特别说明的是,非常高兴的是 Go2 不会重新走 Python3 的老路子,当初被 Python 的版本兼容问题坑得不要不要的。

虽然上面只是列举了各种语言的演进,确实也了解得更多了,有时候描述问题本身,反而更能明白解决方案。C 和 C 的向后兼容确实非常关键,但也不是它们能有今天地位的原因,C11 的新特性到底增加了多少 DAU 呢,确实是值得思考的。另外 C11 加了那么多新的语言特性,比如 WebRTC 代码就是这样,很多老 C 程序员看到后一脸懵逼,和一门新的语言一样了,是否保持完全的兼容不能做一点点变更,其实也不是的。

应该将 Go 的语言版本和标准库的版本分开考虑,这两个也是分别演进的,例如 alias 是 1.9 引入的向后兼容的特性,1.9 之前的版本不支持,1.9 之后的都支持。语言方面包括:

  • Language additions 新增的特性。比如 1.9 新增的 type alias,这些向后兼容的新特性,并不要求代码中指定特殊的版本号,比如用了 alias 的代码不用指定要 1.9 才能编译,用之前的版本会报错。向后兼容的语言新增的特性,是依靠程序员而不是工具链来维护的,要用这个特性或库升级到要求的版本就可以。
  • Language removals 删除的特性。比如有个提案 #3939 去掉 string(int),字符串构造函数不支持整数,假设这个在 Go1.20 版本去掉,那么 Go1.20 之后这种 string(1000) 代码就要编译失败了。这种情况没有特别好的办法能解决,我们可以提供工具,将代码自动替换成新的方式,这样就算库维护者不更新,使用者自己也能更新。这种场景引出了指定最大版本,类似 C 的 -std=C90,可以指定最大编译的版本比如 -lang=go1.19,当然必须能和 Go1.20 的代码链接。指定最大版本可以在 go.mod 中指定,这需要工具链兼容历史的版本,由于这种特性的删除不会很频繁,维护负担还是可以接受的。
  • Minimum language version 最小要求版本。为了可以更明确的错误信息,可以允许模块在 go.mod 中指定最小要求的版本,这不是强制性的,只是说明了这个信息后编译工具能明确给出错误,比如给出应该用具体哪个版本。
  • Language redefinitions 语言重定义。比如 Go1.1 时,int 在 64 位系统中长度从 4 字节变成了 8 字节,这会导致很多潜在的问题。比如 #20733 修改了变量在 for 中的作用域,看起来是解决潜在的问题,但也可能会引入问题。引入关键字一般不会有问题,不过如果和函数冲突就会有问题,比如 error: check。为了让 Go 的生态能迁移到 Go2,语言重定义的事情应该尽量少做,因为我们不再能依赖编译器检查错误。虽然指定版本能解决这种问题,但是这始终会导致未知的结果,很有可能一升级 Go 版本就挂了。我觉得对于语言重定义,应该完全禁止。比如 #20733 可以改成禁止这种做法,这样就会变成编译错误,可能会帮助找到代码中潜在的 BUG。
  • Build tags 编译 tags。在指定文件中指定编译选项,是现有的机制,不过是指定的 release 版本号,它更多是指定了最小要求的版本,而没有解决最大依赖版本问题。
  • Import go2 导入新特性。和 Python 的特性一样,可以在 Go1 中导入 Go2 的新特性,比如可以显式地导入 import "go2/type-aliases",而不是在 go.mod 中隐式的指定。这会导致语言比较复杂,将语言打乱成了各种特性的组合。而且这种方式一旦使用,将无法去掉。这种方式看起来不太适合 Go。

如果有更多的资源来维护和测试,标准库后续会更快发布,虽然还是 6 个月的周期。标准库方面的变更包括:

  • Core standard library 核心标准库。有些和编译工具链相关的库,还有其他的一些关键的库,应该遵守 6 个月的发布周期,而且这些核心标准库应该保持 Go1 的兼容性,比如 os/signalreflectruntimesynctestingtimeunsafe 等等。我可能乐观的估计 net, os, 和 syscall 不在这个范畴。
  • Penumbra standard library 边缘标准库。它们被独立维护,但是在一个 release 中一起发布,当前核心库大部分都属于这种。这使得可以用 go get 等工具来更新这些库,比 6 个月的周期会更快。标准库会保持和前面版本的编译兼容,至少和前面一个版本兼容
  • Removing packages from the standard library 去掉一些不太常用的标准库,比如 net/http/cgi 等。

如果上述的工作做得很好的话,开发者会感觉不到有个大版本叫做 Go2,或者这种缓慢而自然的变化逐渐全部更新成了 Go2。甚至我们都不用宣传有个 Go2,既然没有 C2.0 为何要 Go2.0 呢?主流的语言比如 C、C++ 和 Java 从来没有 2.0,一直都是 1.N 的版本,我们也可以模仿它们。事实上,一般所认为的全新的 2.0 版本,若出现不兼容性的语言和标准库,对用户也不是个好结果,甚至还是有害的。

Others

关于 Go,还有哪些重要的技术值得了解呢?下面将进行详细的分享。

GC

GC 一般是 C/C 程序员对于 Go 最常见、也是最先想到的一个质疑,GC 这玩意儿能行吗?我们以前 C/C 程序都是自己实现内存池的,我们内存分配算法非常牛逼的。

Go 的 GC 优化之路,可以详细读 Getting to Go: The Journey of Go's Garbage Collector

2014 年 Go1.4,GC 还是很弱的,是决定 Go 生死的大短板。

上图是 Twitter 的线上服务监控。Go1.4 的 STW(Stop the World) Pause time 是 300 毫秒,而 Go1.5 优化到了 30 毫秒。

而 Go1.6 的 GC 暂停时间降低到了 3 毫秒左右。

Go1.8 则降低到了 0.5 毫秒左右,也就是 500 微秒。从 Go1.4 到 Go1.8,优化了 600 倍性能。

如何看 GC 的 STW 时间呢?可以引入 net/http/pprof 这个库,然后通过 curl 来获取数据,实例代码如下:

package main

import (
    "net/http"
    _ "net/http/pprof"
)

func main() {
    http.ListenAndServe("localhost:6060", nil)
}

启动程序后,执行命令就可以拿到结果(由于上面的例子中没有 GC,下面的数据取的是另外程序的部分数据):

$ curl 'http://localhost:6060/debug/pprof/allocs?debug=1' 2>/dev/null |grep PauseNs
# PauseNs = [205683 79032 202102 82216 104853 142320 90058 113638 152504 
145965 72047 49690 158458 60499 99610 112754 122262 52252 49234 68420 159857 
97940 226085 103644 135428 245291 141997 92470 79974 132817 74634 65653 73582 
47399 51653 86107 48619 62583 68906 131868 111903 85482 44531 74585 50162 
31445 107397 10903081771 92603 58585 96620 40416 29763 102248 32804 49394 
83715 77099 108983 66133 47832 35379 143949 69235 27820 35677 99430 104303 
132657 63542 39434 126418 63845 167969 116438 68904 77899 136506 119708 47501]

可以用 python 计算最大值是 322 微秒,最小是 26 微秒,平均值是 81 微秒。

Declaration Syntax

关于 Go 的声明语法 Go Declaration Syntax,和 C 语言有对比,在 The "Clockwise/Spiral Rule" 这个文章中也详细描述了 C 的顺时针语法规则。其中有个例子:

int (*signal(int, void (*fp)(int)))(int);

这是个什么呢?翻译成 Go 语言就能看得很清楚:

func signal(a int, b func(int)) func(int)int

signal 是个函数,有两个参数,返回了一个函数指针。signal 的第一个参数是 int,第二个参数是一个函数指针。

当然实际上 C 语言如果借助 typedef 也是能获得比较好的可读性的:

typedef void (*PFP)(int);
typedef int (*PRET)(int);
PRET signal(int a, PFP b);

只是从语言的语法设计上来说,还是 Go 的可读性确实会好一些。这些点点滴滴的小傲娇,是否可以支撑我们够浪程序员浪起来的资本呢?至少 Rob Pike 不是拍脑袋和大腿想出来的规则嘛,这种认真和严谨是值得佩服和学习的。

Documents

新的语言文档支持都很好,不用买本书看,Go 也是一样,Go 官网历年比较重要的文章包括:

  • 语法特性及思考:Go Declaration Syntax, The Laws of Reflection, Constants, Generics Discussion, Another Go at Language Design, Composition not inheritance, Interfaces and other types
  • 并发相关特性:Share Memory By Communicating, Go Concurrency Patterns: Timing out, moving on, Concurrency is not parallelism, Advanced Go Concurrency Patterns, Go Concurrency Patterns: Pipelines and cancellation, Go Concurrency Patterns: Context, Mutex or Channel
  • 错误处理相关:Defer, Panic, and Recover, Error handling and Go, Errors are values, Stack traces and the errors package, Error Handling In Go, The Error Model
  • 性能和优化:Profiling Go Programs, Introducing the Go Race Detector, The cover story, Introducing HTTP Tracing, Data Race Detector
  • 标准库说明:Go maps in action, Go Slices: usage and internals, Arrays, slices (and strings): The mechanics of append, Strings, bytes, runes and characters in Go
  • 和C的结合:C? Go? Cgo!
  • 项目相关:Organizing Go code, Package names, Effective Go, versioning, Russ Cox: vgo
  • 关于GC:Go GC: Prioritizing low latency and simplicity, Getting to Go: The Journey of Go Garbage Collector, Proposal: Eliminate STW stack re-scanning

其中,文章中有引用其他很好的文章,我也列出来哈:

SRS

SRS 是使用 ST,单进程单线程,性能是 EDSM 模型的 nginx-rtmp 的 3 到 5 倍,参考 SRS: Performance,当然不是 ST 本身性能是 EDSM 的三倍,而是说 ST 并不会比 EDSM 性能低,主要还是要根据业务上的特征做优化。

关于 ST 和 EDSM,参考本文前面关于 Concurrency 对于协程的描述,ST 它是 C 的一个协程库,EDSM 是异步事件驱动模型。

SRS 是单进程单线程,可以扩展为多进程,可以在 SRS 中改代码 Fork 子进程,或者使用一个 TCP 代理,比如 TCP 代理 go-oryx: rtmplb

在 2016 年和 2017 年我用 Go 重写过 SRS,验证过 Go 使用 2CPU 可以跑到 C10K,参考 go-oryxv0.1.13 Supports 10k(2CPUs) for RTMP players。由于仅仅是语言的差异而重写一个项目,没有找到更好的方式或理由,觉得很不值得,所以还是放弃了 Go 语言版本,只维护 C++ 版本的 SRS。Go 目前一般在 API 服务器用得比较多,能否在流媒体服务器中应用?答案是肯定的,我已经实现过了。

后来在 2017 年,终于找到相对比较合理的方式来用 Go 写流媒体,就是只提供库而不是二进制的服务器,参考 go-oryx-lib

目前 Go 可以作为 SRS 前面的代理,实现多核的优势,参考 go-oryx

关注“阿里巴巴云原生”公众号,回复 Go 即可获取清晰知识大图及最全脑图链接!

作者简介

杨成立(花名:忘篱),阿里巴巴高级技术专家。他发起并维护了基于 MIT 协议的开源流媒体服务器项目 – SRS(Simple Rtmp Server)。感兴趣的同学可以扫描下方二维码进入钉钉群,直面和大神进行交流!

 

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

你的应用安全吗? ——用Xray和Synk保驾护航

JFrogchina阅读(630)评论(0)

一、背景

在当下软件应用的开发过程当中,自研的内部代码所占的比例逐步地减少,开源的框架和共用库已经得到了广泛的引用。如下图所示,在一个Kubernetes部署的应用当中,我们自己开发代码所占的比例可能连0.1%都不到。

 

开源软件能够帮助开发者共享彼此的成果,使得我们能够快速复用其他人开发并已得到验证的软件库,从而能够集中精力专注于创新性的工作。然而,开源软件的大量引用也给我们的应用带来了安全隐患。安全检测已成为当前DevOps流程的重要组成部分。

二、你的应用安全吗

据不完全统计,现在有78%的企业都在使用开源软件。但是,大家在享受开源软件带来的研发便利的同时,是否也意识到开源软件带来的安全隐患呢?

 

从上图的统计数据可以看出,只有13%的企业会把安全放在引用开源软件时的首要关注点。大部分的使用者选择相信开源软件的创造和维护者会保证其安全性。然而,下图的统计数据表明,安全性并不是开源软件维护者的维护重点。

这样的现状导致我们常用的开源软件库包含了各种各样的安全漏洞,例如,据统计,目前14%的NPM包、30%的Docker Hub镜像都包含安全漏洞。而且在这些漏洞被发现之后,也得不到及时的修复。据统计,Maven包里有59%的已知安全漏洞还没有得到修复,而漏洞的平均修复时间是290天,最严重级别漏洞的平均修复时间也仅是265天。黑客们已逐渐把开源软件作为了主要的攻击目标。

该怎么样保证我们上线应用的安全呢?

三、JFrog Xray,监测安全漏洞的利器

JFrog公司提供的Artifactory+Xray是一个很好的产品组合。Artifactory是全语言的制品仓库,能够在同一个仓库中存储和管理我们应用研发中使用的所有外部依赖包。而Xray通过对Artifactory的监视,能够在构建,甚至开发阶段就发现安全漏洞问题,使得安全监测前置,避免了在应用上线前紧急排查问题的窘境。

 

如上图所示,当Artifactory仓库中新加入了制品包,设置了对其监视的Xray就会启动安全检查,并报告查到的安全漏洞和License授 权情况。而针对安全漏洞,Xray会提供详细的漏洞信息,以及在应用中的准确定位来辅助我们对其进行分析和检查。

同时,针对查出来的安全漏洞,Xray还提供了针对其扩散范围的分析。也就是说,可以帮助我们分析,出了被检查的这个制品包外,还有哪些其他的应用也包含了这个安全漏洞。

 

除了安全漏洞及其扩散范围的分析,Xray还提供自定义问题的能力。我们可以把用其他工具发现的安全问题,或者如性能过低、版本过老等非安全问题定义在对应的制品包上,同样也可以利用Xray的能力检查这些问题在我们的应用中的扩散范围。

四、Snyk, 不仅仅是监测漏洞

JFrog Xray是基于开源的NVD开源漏洞数据库来监测安全漏洞的,而Snyk(https://snyk.io)提供了额外的商业漏洞数据库。Xray可以通过和Snyk的集成,实现利用Snyk的商业漏洞数据库进行安全漏洞检查。

 

当然,基于自身的商业漏洞数据库,Snyk也提供了安全漏洞的扫描和监测能力。Snyk提供了与各种各样平台的集成,帮助我们监测部署在这些平台上的应用安全。

然而,Snyk的能力不仅如此,他还能帮助我们修复安全漏洞。例如,当和我们的Github Enterprise集成后,我们可以选择我们需要监测的项目。

选定后,Snyk就会自动扫描我们的代码并报告在项目当中发现的安全漏洞。

Snyk最大的特色是,针对这些安全漏洞,能够自动给出PR(Pull Request)形式的修复建议。PR既可以针对所有发现的漏洞,也可以只针对某一个具体的漏洞。

 

直接Merge这些PR就可以帮助我们替换使用已修复安全漏洞的依赖包,实现安全隐患的自动消除。

五、总结

开源软件的大量引用,在方便了应用开发的同时,也带来了安全的隐患。利用JFrog Xray和Snyk等工具,能够帮助我们尽早地发现开源依赖引入的安全漏洞,分析漏洞的扩散范围,也可以给出修复建议,实现安全隐患的自动消除。

参考文献

  • Xray and Snyk – Don’t just scan … Fix!

https://www.youtube.com/watch?v=jlUQd1l2j40&list=PLY0Zjn5rFo4OuGDcUEgb48JcObItA4TLW&index=46

 

  • JFrogXray试用地址

http://www.jfrogchina.com/artifactory/free-trial/

GoCenter助力Golang全速前进

JFrogchina阅读(631)评论(0)

一、背景

Go语言是Google开发的一种静态强类型、编译型、并发型,并具有垃圾回收功能的编程语言。为了方便搜索和识别,有时会将其称为Golang。自2009年11月Google正式宣布推出,成为开放源代码项目以来,Go语言已成为当今开发人员和DevOps领域最流行的语言之一, 它被用于设计和编写Kubernetes和Helm。但是,相比语言本身已经得到了广泛的普及和使用,Go语言的包管理方案却大大滞后了。

Go语言生态系统中缺少的是标准化——没有用于依赖关系管理的标准工具, 也没有标准的包格式或兼容的包仓库规范。这意味着开发人员无法使用Go语言创建可重现的构建, 这是一个相当大的问题。这些年来, 社区推出了诸如dep、godep、glide和govender等工具,试图用来处理Go语言的依赖管理, 但并未成功。2018年Google在Go1.11官方推出了Go modules,为Go语言提供了支持版本化的依赖管理方案。

Go modules现在已成为Go语言标准的依赖管理工具和包仓库规范。而GoCenter为Go modules的实现和推广提供了依赖包的公共仓库,使得Go语言的开发人员能够更为稳定和方便地开发可重复构建的Go应用程序。

二、Go语言的依赖管理

在介绍GoCenter之前,我们先简要地回顾一下Go语言依赖管理的发展历程。

Go语言在推出之初,并没有明确的依赖管理方案。只是在构建过程中通过go get命令,将用import声明的依赖从对应的源,通常是git上的项目,下载到$GOPATH/src目录下,和Go应用自身的代码放在一起。这种依靠GOPATH来管理依赖的机制带来的问题 是显而易见的,比如:

  • 因源依赖包自身的变化,导致不同时间构建Go应用时go get得到的依赖实质上是不同的,即不能实现可重复的构建;
  • 或者因源依赖包自身的变化,重新构建Go应用时会引入不兼容的新实现,导致Go应用无法通过编译。

因此在1.5版本以前,为了规避这个问题,通常需要将使用的依赖包手工拷贝出来。

为了实现Go应用的可重复构建,Go1.5引入了Vendor机制。Vendor机制的核心就是在GOPATH下面增加了vendor文件夹。Go应用所需的依赖都可以从依赖源fork出所需的分支,存放到vendor文件夹。当构建Go应用时,Go编译器会优先在vendor文件夹下搜索依赖的第三方包,vendor文件夹下没有才会再到$GOPATH/src下去找。这样只要开发者预先将特定版本的依赖包存放在vendor文件夹,并提交到Go 项目的code repo,那么所有人理论上都会得到同样的编译结果,从而实现可重复构建。 在Go1.5发布后的若干年,Go社区把注意力都集中在如何利用Vendor机制解决Go应用的依赖管理问题,并诞生了众多的依赖管理工具,如dep、golide、govendor等。然而,和Java的Maven、Python的Pypi、C/C++的Conan等业界成熟的依赖管理方案相比,Vender机制仍然存在许多问题,比如:

  • Vendor文件夹中的依赖包没有版本信息。这样依赖包脱离了版本管理,对于一致性管理、升级,以及问题追溯等场景,都会难以处理;
  • Vendor机制没有给出如何能够方便地得到Go项目依赖了哪些包,并将其拷贝到vendor文件夹下的方案,多数情况下还需要到不同的Git源项目里手工拷贝;
  • 当依赖的包比较多的时候,vendor文件夹也会变得非常庞大。

2018年年初,Go核心Team的技术leader,也是Go Team最早期成员之一的Russ Cox个人博客上连续发表了七篇文章,系统阐述了Go team解决“包依赖管理”的技术方案:vgo。vgo的主要思路包括:语义导入版本控制(Semantic Import Versioning)、最小版本选择(Minimal Version Selection)、以及引入Go module等。同年5月份,Russ Cox的提案“cmd/go: add package version support to Go toolchain”被社区接受,vgo的代码合并到Go主干,并将这套机制正式命名为“go modules”。由于vgo项目本身就是一个实验原型,merge到主干后,vgo这个术语以及vgo项目的使命也就此结束了。后续Go modules机制将直接在Go主干上继续演化。

Go modules机制的主要变化包括:

  • 去除了饱受诟病的GOPATH的限制。Go编译器将不再到GOPATH下面的vendor或src文件夹下搜索Go应用构建依赖的第三方包;
  • Go modules机制为在同一应用repo下面的包赋予了一个新的抽象概念: 模块(module),即可重用的Go代码包,并启用一个新的文件mod记录模块的元信息和依赖关系。而在go.mod里明确描述了依赖包的版本信息,同一个依赖包也可以记录多个不同的版本。除了精确版本外,go.mod还支持用表达式模糊地定义依赖版本;
  • 在Go modules机制下,还支持创建Go依赖包的公共仓库。这是因为应用程序包含的Go模块,必须从数千个独立的源代码存储库中解析,而每个存储库的维护纪律可能各不相同。因此,需要存在一个可公开访问的存储库,通过Go modules提供的依赖描述、解析机制,为Go的开发者提供一致的、可分享的、支持重复构建的、稳定的Go依赖包源。GoCenter就是这种Go依赖包公共仓库的重要实现。

三、GoCenter简介

GoCenter通过创建Go模块的公共中央仓库,提供可重复和快速依赖解析的依赖包管理方案,解决了Go开发人员查找和获取Go依赖包的困难。GoCenter将直接从源代码存储库获取Go项目,转变为处理和验证不可变的、具备版本控制的Go模块, 并将其免费提供给Go应用的开发人员。

GoCenter(https://gocenter.io)提供了通过公共Go代理解析模块, 包括通过托管免费服务搜索模块的能力。从创建开始, GoCenter已经包括了数千个广受欢迎的 Go项目的模块, Go开发者可以立即使用这些项目进行自己的构建。

 

开发人员也可以提交自己的Go项目加入GoCenter,以便将其提供给Go社区开发者,从而得到更为广泛地应用。

 

GoCenter这个中央仓库,提供了预先打包,以及版本化的Go模块,使得Go开发人员或团队不再需要为使用公共模块而构建自己的模块库, 从而消减了使用 Go 语言的巨大成本。

此外,如果Go开发者或团队已经有了自己的JFrog Artifactory仓库,就可以通过配置指向GoCenter的远程仓库,为重复构建提供完全的本地化控制,并可以预防访问GoCenter的网络连接问题。

四、基于GoCenter构建Go应用

要构建Go应用项目,首先需要安装Go客户端(版本1.11.0 或更新的版本) 。而安装Go之后,有三种方法可以从GoCenter解析Go模块:使用goc、使用 go 客户端,或部署本地仓库(如Artifactory),以代理GoCenter。

1、使用goc

推荐在构建中使用GoCenter的方式是通过goc工具。goc工具包装了Go的客户端,器, 能够使用GoCenter中的包正确构建Go应用,而无需手动设置。

要安装goc,需要使用以下的curl命令,或按照goc的github主页(https://github.com/jfrog/goc)的说明:

$ curl -fL https://getgoc.gocenter.io | sh

然后, 就可以从Go项目的根目录中运行任何命令, 就像运行Go命令一样。例如:

$ goc build

goc工具自动分配GOPROXY连接GoCenter,所以能够优先从该仓库解析Go的依赖包。对于在GoCenter找不到的包,goc将会试图通过源代码控制系统来解析它们,以更好地保证成功构建Go项目。

Go客户端自身不能执行这种辅助操作(请参阅下文), 因此至少在 GoCenter能够为大多数Go开发人员提供可能需要的所有依赖之前,仍然建议使用goc。

2、使用Go客户端

推荐在构建中使用GoCenter的方式是通过goc工具。goc工具包装了Go的客户端,器, 能够使用GoCenter中的包正确构建Go应用,而无需手动设置。

如上所述,使用GoCenter时并不建议直接利用Go客户端进行构建,因为当在GoCenter找不到相关依赖包时构建会失败。对于Go客户端这种限制的详细信息,可以参考相关的issue和修正信息(https://github.com/golang/go/issues/26334)。Go开发人员还是应该改用goc。

当然,如果在充分了解这个限制还希望使用的情况下,也是可以使用Go客户端的。

如果希望构建Go项目时从GoCenter中获取相关依赖包,需要设置GOPROXY指向GoCenter的URL,https://gocenter.io

$ export GOPROXY=https://gocenter.io

现在就可以使用Go客户端构建Go应用了:

$ go build

 

3、部署代理GoCenter的私有仓库

如果使用的是如Artifactory这样的私有仓库,则只需设置GOPROXY指向该私有仓库,而把GoCenter创建为该私有仓库当中的远程仓库。

为了要在Artifactory里创建代理GoCenter的远程仓库,需要遵循以下步骤:

  1. 创建新的远程仓库,并设置包类型为Go;
  2. 设置远程仓库的名字,并在URL字段输入https://gocenter.io/
  3. 点击“保存 & 完成”按键。

还可以创建虚拟仓库,用以聚合同时从本地Go仓库和远程仓库获取的Go依赖包。

一旦在Artifactory里配置好使用GoCenter,就可以使用标准的GOPROXY方式基于Artifactory进行构建。需要注意的是,根据Artifacotry上的设置,需要适当地处理客户端的认证信息,应为当前Go客户端在获取模块时是不会发送相关认证信息的,所以处理起来是有一定难度的。因此,当使用Artifactory代理GoCenter时,建议使用JFrog CLI来构建Go应用。当配置好JFrog CLI和Artifactory的关联之后,就可以使用类似于

“jfrog rt go build”的命令来从Artifactory获取依赖,并构建Go应用。

使用JFrog CLI的好处是可以方便地向Artifactory上传针对特定构建而创建的依赖包,也同时内置支持生成和发布与构建过程相关的元数据。详细信息,请参考JFrog CLI的相关文档。

五、搜索Go模块

GoCenter首页中的搜索框可帮助按特定模块名称(例如, “虹膜”)进行搜索。当执行搜索时,GoCenter将列出与搜索名称匹配或部分匹配的模块。

 

点击列表中的某个模块,将会列出GoCenter中该模块的所有版本:

列出的版本都利用颜色编码来指示其当前的可用状态:

绿色,表示该模块版本已在GoCenter之中且处于可用状态;

红色,表示该模块版本不存在,而且不可用;

灰色,表示该模块版本正在引入的过程中,尚未可用。

搜索结果还会显示那些Go项目在相关Git代码库存在,而在GoCenter尚不存在的模块版本列表。如果有这样的缺失版本,可以通过单击“Add missing version(s)”把它们 添加到GoCenter。

六、提交自己的Go模块

如果希望将自己的Go项目添加到GoCenter,使其可被Go社区的开发人员使用,则需要提交相关的加入申请。

首先可以对希望加入的模块名执行搜索。如果相关模块并不存在,则可以单击“Add”图标来请求添加模块。一旦点击,将会看到加入申请表格。在表格中,可以输入申请加入的Go模块的URL。通过搜索该模块的结果可以查看该模块的加入进度。

GoCenter将依据以下最低标准来验证加入请求:

  • Go模块位于com或gopkg.in上的公共项目(repo);
  • 该项目没有被设置为存档状态(archived);
  • 该项目至少拥有3颗星

七、总结

自从2007年首次在谷歌构想,并于2009年正式推出,Go语言很快就成为最流行的编程语言之一。事实上,Helm和Kubernetes都是用Go语言编写的。在2017年的一项调查中,Go语言在开发者的偏好中排名最高,67%的开发者都在利用Go语言编程。

为此, 我们期望GoCenter能够为不断增长的Go社区和开发人员提供必要的服务,并帮助Go语言更加符合DevOps的需求。

通过访问GoCenter,https://gocenter.io,可以发现经常使用的Go依赖包都已经包含在其中了。如果还没有,请提交相关的加入申请。

GoCenter管理了版本化的Go模块,可以和Go应用构建使用的任何CI服务器或私有仓库进行对接。而使用JFrog CLI和Artifactory,可以使得这一过程更加便捷。

想要了解有关 GoCenter 更多深入的技术信息?请查看GoCenter的Github项目,https://github.com/jfrog/gocenter

八、参考文献

Golanghttps://golang.org

Go & Versioning: https://research.swtch.com/vgo

GoCenterhttps://gocenter.io/

https://github.com/jfrog/gocenter

goc https://github.com/jfrog/goc

JFrog CLI:https://www.jfrog.com/confluence/display/CLI/CLI+for+JFrog+Artifactory

微服务监测的五大原则

JFrogchina阅读(715)评论(0)

一、背景

容器和微服务的出现并得到大量应用,从根本上改变了应用系统的组成和运行方式。而随着开发人员开始利用编排系统来管理和部署容器,规则进一步发生了变化。以往主机上的一个简单应用,现在已成为一个复杂的、动态编排的、多容器的体系架构,这同时也对应用的监测提出了全新的挑战。

Sysdig,是专注于系统故障排查和监控工具的公司,其产品Sysdig  Cloud是定位于容器系统故障排查和监控的平台。在今年召开的JFrog SwampUp用户大会上,Sysdig公司提出监测容器及构建在其上的微服务的五大关键原则。这些原则充分考虑了容器和微服务与传统架构在运维方式上的差异。

本文即是根据Sysdig公司在本次大会上的演讲视频整理而成的。

二、微服务是什么

要正确地监测微服务,首先要正确地理解什么是微服务。

 

演讲首先引用了Martin Fowler关于微服务的定义(Martin Fowler是国际著名的面向对象分析设计、UML、模式等方面的专家,敏捷开发的创始人之一,现为ThoughtWorks公司的首席科学家。很多人了解微服务架构都是从Martin Fowler的这篇文章开始的),即“微服务架构”描述了一种将软件应用程序设计为一组可独立部署的服务的特定方式。其中,“围绕业务能力的特性”,也就是说,微服务的划分不是依据程序的大小,而是以业务能力的拆分为基准的。这种业务细分后的服务,以及自动化部署、端点智能、去中心化控制这四大概念,是设计如何监测微服务时需要时刻考虑的。

 

演讲首先引用了Martin Fowler关于微服务的定义(Martin Fowler是国际著名的面向对象分析设计、UML、模式等方面的专家,敏捷开发的创始人之一,现为ThoughtWorks公司的首席科学家。很多人了解微服务架构都是从Martin Fowler的这篇文章开始的),即“微服务架构”描述了一种将软件应用程序设计为一组可独立部署的服务的特定方式。其中,“围绕业务能力的特性”,也就是说,微服务的划分不是依据程序的大小,而是以业务能力的拆分为基准的。这种业务细分后的服务,以及自动化部署、端点智能、去中心化控制这四大概念,是设计如何监测微服务时需要时刻考虑的。

 

传统架构下,应用的所有功能都实现在同一进程下,应用的扩展就是在多个服务器上复制整体的进程。而在微服务架构下,应用功能被拆分成了粒度更小、相互独立的服务,而这些服务都能够被独立地管理和部署。这样,应用的扩展和修改都可以按需只针对部分服务进行,而不会影响其他正在运行的服务。

微服务并不是SOA(Service-Oriented Architecture,面向服务架构), 微服务相比SOA里的服务而言具有更小的关注点。这种全新的架构理念也带动了基础架构等多个领域的创新,其中就包括了针对应用的监测。

三、容器是什么

当前很多微服务都是运行在容器的基础之上的,那么设计针对微服务的监测同样要考虑容器的特性。

 

首先需要强调的是,容器(Container)并不是轻量级的虚机,它不像虚机一样拥有独立的虚拟化的操作系统,而是直接构建和运行在主机的操作系统之上。

 

容器除了镜像(image),也就是我们分层构建出来的目标应用之外,还包括了主机操作系统提供的进程沙盒(sandbox)。进程沙盒保证了容器之间的隔离,使得每个容器都像是运行在一台独立的虚机之上。

进程沙盒包括以下几个部分:

控制组(Cgroups):规定了可以使用的资源的数量,如CPU、内存、网络等;

 

  • 命名空间(namespaces):规定了可以使用哪些控制组提供的资源;

  • 安全模块(Seurity Modules)实现了容器之间的隔离。

在实际应用的过程中,容器的开发者和使用者都关注在镜像上,感觉不到进程沙盒的存在。进程沙盒的这些部分是由容器的运行态程序自动和镜像加载在一起的。

四、微服务与容器

从以上针对微服务和容器概念的回顾和分析来看,二者的特性是非常匹配的。利用容器的各种特点能够便捷地实现微服务架构的各种设计需要。

 

 

因此,虽然微服务架构刚刚出现时也是运行在虚机之上的,但目前大多数的微服务都是基于容器来实现的。那么设计针对微服务的监测也同样要考虑到容器的特性。

 

在我们的设计和印象当中,微服务应该是按照上图这样清晰的架构运转的。然而实际情况并非如此。随着微服务和容器规模的扩大,我们真正面对的是如下图一样的场景。显然,在这样的场景下,要全面、准确、有效地实现对微服务的监测,是一个巨大的挑战。

 

五、监测微服务的五大关键原则

微服务和容器的出现和大量应用,带动了架构、开发、部署、运维等多个领域的创新,也对应用的监测提出了新的要求。在传统架构中,监测关注的是虚机或主机上运行的单体应用。而在微服务+容器的架构下,应用已经分解为更细粒度、相互隔离、独立运行的进程。那么针对微服务的监测也就需要转向针对这些进程的关注。

Sysdig在此次大会上介绍了监测微服务应用需要遵循的五大关键原则:

1、监测容器,同时也要监测容器内运行的应用

针对于容器内运行的进程,监测要格外关注针对其使用资源的限制,以防止单个容器占用和消耗主机的所有资源,从而影响到主机上其他容器的运行。同样,针对编排器同样要监测和限制对主机资源的占用,尤其是在应用规模自动调整的时候,要保证合理地使用主机资源。

同时,我们不能把容器当成黑盒,必须监测到容器内运行的各种应用,如各种服务进程、数据库等。监测要收集这些应用运行的各种度量指标,如JVM的各种参数等。当然,监测也应该收集那些开发者自己定义的,针对容器内应用运行的各种度量指标。

 

2、监测业务自身的性能,而不是容器的性能

在实际运行当中,每一个容器的生命周期通常都不会特别的长。容器的编排系统会随时关注容器的运行状态,当发生异常时,编排系统会自动的进行调整,如删除有问题的容器,重新部署一个同样的容器加以代替;或者根据容器运行的状态自动地进行规模上的调整等 。而开发者和运维者应该集中关注容器内业务应用的运行状态。

 

3、监测具有弹性,以及多地部署的服务

微服务的部署特性驱使我们在设计的阶段就要考虑到规模性的问题。当服务的规模从10扩展成20,扩展成50,甚至于扩展成100的时候,针对服务的监测要如何自动调整去覆盖和适应这些自动扩展的规模。同样,针对多地部署的服务,我们又该如何根据不同的组织和分类,如站点、位置、区域等,来汇聚和统计服务的整体性能。这些都是在设计监测方案之初就要重点考虑的。

 

4、监测API

 

在微服务的架构当中,原有的单体应用被拆解成为多个层面、更小粒度、独立运行的服务。而API是这些服务暴露给其他服务的唯一组件。同时,API也是访问微服务的首要通讯方式。

对API的运行、响应状态的有效监测,对微服务的整体监测是十分重要的:

  • 能够捕获特定方法、功能或端点的运行瓶颈;
  • 能够监测各个方法、功能或端点的调用频率;
  • 能够跟踪用户业务在多个系统之间的交互行为。

 

5、微服务的监测体系要匹配组织架构

提到架构,我们就不得不关注康威定律,即任意一个软件都反映出制造它的团队的组织结构,如下图所示:

康威定律同样适用于微服务的监测体系。要允许团队根据自己的设计和理解来定义自身提供服务的监测指标、报警原则,以及监测数据的展示方式,毕竟他们是对这些服务最了解的人,也是最终为服务的质量负责、解决服务问题的人。

五、总结

微服务架构的出现,以及结合容器技术的广泛使用,改变了应用的开发、部署、运维的原有模式,同时也对监测应用提出了更高的要求。Sysdig带来的五大关键原则能够帮助我们针对微服务和容器的特性,设计更为全面、更有针对性的监测体系。

当然,随着微服务架构和容器技术的不断进化,监测的体系和原则也是要随之不断调整的。越早认识到这样的转变,就能更早更容易地适应架构和技术的更新。

参考文献

  • Principles of monitoring microservices

https://www.youtube.com/watch?v=tVdp5355xbA&index=50&list=PLY0Zjn5rFo4OuGDcUEgb48JcObItA4TLW

 

  • The Five Principles of Monitoring Microservices

https://thenewstack.io/five-principles-monitoring-microservices

开源社区Discourse在Rainbond上的部署

好雨云阅读(746)评论(0)

概述

Discourse 是一个完全开源的论坛平台。具有丰富的插件库与主题库,适用于开源社区的构建。Rainbond官方社区就是基于Discourse搭建的实际案例。

Rainbond官方社区建立之初就已经使用了Discourse,当时的版本为 1.5.4。时过境迁,为了更好的社区体验,Rainbond社区运营团队决定部署最新版本的Discourse社区,并将原社区的数据迁移到新社区中。

这篇文档,会详细介绍如何在Rainbond容器云平台上部署Discourse,以及在整个部署乃至迁移数据过程中所趟平的坑。对于有意搭建基于Discourse的社区的小伙伴,会有很大的帮助。

了解更多有关Discourse的信息:

  • Discourse官方网站

  • Discourse Github

  • Discourse官方社区

    基于应用市场快速安装

    Discourse 已经发布到了Rainbond应用市场,可以一键部署安装,即点即用。

    discoure-install

    点击安装,选择应用后稍等一会,即可访问你的Discourse了

    discoure-register

    在正式使用前,一定要修改以下环境变量:

    • 环境变量:

      • DISCOURSE_DB_PASSWORD=你自定义的数据库密码

      • DISCOURSE_DEVELOPER_EMAILS=管理员的邮箱地址

      • DISCOURSE_HOSTNAME=为社区准备的域名,如果希望使用Rainbond默认为80端口生成的域名,这个值设置为 ${DOMAIN}

      • DISCOURSE_SMTP_ADDRESS=可用的smtp服务器

      • DISCOURSE_SMTP_PORT=smtp服务器的端口

      • DISCOURSE_SMTP_USER_NAME=smtp账户

      • DISCOURSE_SMTP_PASSWORD=smtp账户的密码

    修改完成后,就可以继续注册使用了。

    Discourse应用如何制作

    在使用旧版本Discourse的时候,为了将其容器化,我们做了很多工作。但在当前版本,Discourse官方已经支持并且主推容器化部署,这对于将Discourse部署在Rainbond容器云平台非常友好。

    接下来的部分,我将说明如何制作一个即点即用的Discourse应用。

    获取镜像

    部署的第一步,就是获取到Discourse的镜像。

    区别于一般的容器化部署,Discourse并没有将它自己的镜像托管于Docker Hub,而是为用户准备了一套完整的工具,可以让用户高度自定义自己需要的镜像,这套工具就是discourse_docker。在这套工具里,用户可以根据其提供的模版自定义部署的方式、包含的插件等信息,并通过一条命令,快速构建对应的镜像;也可以利用它来管理本机正在运行的docker化部署的Discourse。

    Discourse部署支持 all in one 的 Standalone 模式,也支持适用于生产的 Multiple 模式。Multiple 模式的意思,就是将Discourse 的WEB部分,和后端数据库Postgresql、缓存中间件Redis分离部署。

    本次部署,将使用 Multiple 模式。最终的拓扑,将会是下面这种情况:

    image-20191226093122462

    • 环境的要求 首先,我们需要有一个可以运行docker服务的环境,如果你已经安装了Rainbond容器云平台,那么集群中任何一个节点,都可以满足你的需要。如果你还没有安装Rainbond,或者根本不知道它是什么,你需要点击了解一下

    • 获取discourse_docker

    git clone https://github.com/discourse/discourse_docker.git
    FROM postgres:10-alpine
    MAINTAINER guox@goodrain.com
    # 下面的步骤,会将初始化数据用的sql脚本放置在指定目录下
    ADD sql/*.sql /docker-entrypoint-initdb.d/
    ADD docker-entrypoint.sh /
    RUN chmod +x /docker-entrypoint.sh
    ENV TZ Aisa/Shanghai
    ENV LANG en_US.utf8
    ENV PGDATA /var/lib/postgresql/data
    ENV PG_MAJOR 10
    ENV PG_VERSION 10.11
    VOLUME /var/lib/postgresql/data
    EXPOSE 5432
    ./launcher start data
    docker exec -ti data bash
    pg_dump -d discourse -h 127.0.0.1 -U discourse > data.sqlpg_dump -d discourse -h 127.0.0.1 -U discourse > /shared/data.sql
    --
    -- This row is added manually because when the SQL is imported manually, recived role "postgres" does not exist
    -- 
    
    CREATE USER postgres SUPERUSER;

    https://meta.discourse.org/t/restore-from-old-version-to-a-new-version-of-discourse-failed/135545

    如果你是一个向我一样的老版本用户,那么将旧版本的数据导入到新版本的 Discourse,就会是个非常必要的操作。Discourse支持全站数据的备份与恢复,但是我在实际恢复过程中遇到了很多问题,究其原因还是我的旧版本实在是太老了。具体的解决方式,请参见下面的帖子,我得到了来自官方工程师大牛的帮助:

    数据恢复

    https://github.com/discourse/discourse/blob/master/docs/INSTALL-email.md

    Discourse初始化安装,是会向管理员的邮箱发送注册邮件的,所以正确的配置邮件服务是重中之重,官方推荐的邮件服务器及配置方式参见:

    邮件配置

    一些踩过的坑

    访问discourse_web的80端口所对应的域名,看到欢迎页面即可证明系统部署完成了。

    访问

    • discourse_web 依赖 postgresql10

    • discourse_web 依赖 redis

    利用Rainbond依赖关系,将三个服务建立起正确的依赖关系。

    建立依赖

    • 环境变量:

      • DISCOURSE_DB_HOST=127.0.0.1

      • DISCOURSE_DB_PASSWORD=你自定义的数据库密码

      • DISCOURSE_DB_USERNAME=discourse

      • DISCOURSE_DEVELOPER_EMAILS=管理员的邮箱地址

      • DISCOURSE_HOSTNAME=为社区准备的域名,如果希望使用Rainbond默认为80端口生成的域名,这个值设置为 ${DOMAIN}

      • DISCOURSE_REDIS_HOST=127.0.0.1

      • DISCOURSE_SMTP_ADDRESS=可用的smtp服务器

      • DISCOURSE_SMTP_PORT=smtp服务器的端口

      • DISCOURSE_SMTP_USER_NAME=smtp账户

      • DISCOURSE_SMTP_PASSWORD=smtp账户的密码

    点击构建之前,进行高级设置:

    image-20191226114203876

    利用我们已经推送好的 rainbond/discourse_web:2.4.0-beta8 镜像,来部署WEB服务部分。

    部署Discourse_web

    • 组件部署类型 :有状态服务

    • 环境变量:

      • POSTGRES_DB=discourse

      • POSTGRES_PASSWORD=自定义的数据库密码

      • POSTGRES_USER=discourse

    构建之前,需要定义高级设置:

    image-20191226111213717

    准备就绪后,就可以在Rainbond部署订制的postgresql 了

    在官方镜像使用 data.sql 初始化的时候,发现一个小问题,官方镜像没有默认创建role:postgres 故此手动在 data.sql前面加入以下内容:

    输入密码后即可开始备份,备份完成后在服务器的 /var/discourse/shared/data/ 目录下,找到对应的 data.sql文件。

    这时就会启动一个已经初始化好了的data容器。我们需要将它里面的数据库 discourse 备份出来。

    将这个镜像启动为容器:

    这一步的另一个重点在于如何获取初始化用的sql脚本。这需要利用到上个步骤构建出来的数据库镜像。

    自动初始化的原理参见:https://hub.docker.com/_/postgres 中的 Initialization scripts 部分。docker化的数据库,大部分都支持这种方式自动初始化,这样做的好处是,基于此镜像的容器在首次启动时,不需要其他操作,就会自动执行sql脚本完成初始化。对于Rainbond部署而言,在将这样的数据库作为应用的一部分发布到应用市场后,执行一键安装可以达到即安即用的效果。

    关键Dockerfile部分解析:

    部署的方式使用了基于 Dockerfile 的源码构建,项目地址:https://github.com/dazuimao1990/pri-postgresql

    基于官方的postgresql镜像做了进一步处理,使之可以自动初始化Discourse所需要的数据库。

    postgresql部署

    image-20191226102759385

    这一步比较简单,直接基于镜像部署一个标准的redis即可:

    redis 部署

    我决定使用官方镜像来运行 postgresql 和 redis。然后对 postgresql 进行处理,使之可以自动初始化。

    当前构建出来的data镜像,是一个合并了 postgresql 和 redis 的镜像。这还不符合我们想要的部署方式,我要将它进行进一步的拆分。

    • 配置模版 在项目的根目录中,名为 samples 的目录下,会有我们所需要的模版文件 web_only.yml data.yml ,将这两个模版文件拷贝到项目根目录下的 containers 目录下。

    • 自定义配置 Discourse最主要的自定义在于主题(theme) 和插件(plugins),其中主题可以在网站设置中配置,而插件的安装,则需要修改上述的模版文件。

      编辑web_only.yml文件,在第84行附近找到如下段落,并追加插件地址:

      hooks:
        after_code:
          - exec:
              cd: $home/plugins
              cmd:
                - git clone https://github.com/discourse/docker_manager.git
                - git clone https://github.com/discourse/discourse-whos-online.git

      关于Rainbond如何自定义环境变量

      关于Discourse 环境变量配置的说明

      其他的标准配置,如邮件服务器的配置,均以环境变量的方式指定,当前可以保持默认,部署于Rainbond的时候,可以支持自定义环境变量进行替换。

      具体原理及操作,点击了解一下

    • 构建WEB服务镜像 使用命令行工具 launcher 来构建 web_only 镜像:

      ./launcher bootstrap web_only
      root@localhost:~/discourse_docker# docker images | grep web_only
      local_discourse/web_only   latest              79a99d0d8fd1        7 days ago          2.83GB

      将这个镜像推送至Docker hub或者私有的镜像仓库备用即可,在我的部署环境里,我将其推送到了 Docker hub,具体的镜像地址为: rainbond/discourse_web:2.4.0-beta8

      构建完成后,在服务器的本地镜像列表里,就会出现对应的镜像:

    • 构建数据库镜像

      使用命令行工具构建 data 镜像

      ./launcher bootstrap data
      root@iZj6chkije5xk0gfyvcrzyZ:~/discourse_docker# docker images | grep data
      local_discourse/data       latest              76e100480749        2 weeks ago         2.35GB

      这个镜像不必推送到镜像仓库,后续的步骤会继续拆分这个镜像,并进行数据自动初始化的处理。

      构建完成后,在服务器的本地镜像列表里,就会出现对应的镜像:

Go 开发关键技术指南 | 带着服务器编程金刚经走进 2020 年(内含超全知识大图)

alicloudnative阅读(888)评论(0)

作者 | 杨成立(忘篱) 阿里巴巴高级技术专家

关注“阿里巴巴云原生”公众号,回复 Go 即可查看清晰知识大图!

导读:从问题本身出发,不局限于 Go 语言,探讨服务器中常常遇到的问题,最后回到 Go 如何解决这些问题,为大家提供 Go 开发的关键技术指南。我们将以系列文章的形式推出《Go 开发的关键技术指南》,共有 4 篇文章,本文为第 3 篇。

Go 开发指南

0.png

Interfaces

Go 在类型和接口上的思考是:

  • Go 类型系统并不是一般意义的 OO,并不支持虚函数;
  • Go 的接口是隐含实现,更灵活,更便于适配和替换;
  • Go 支持的是组合、小接口、组合+小接口;
  • 接口设计应该考虑正交性,组合更利于正交性。

Type System

Go 的类型系统是比较容易和 C++/Java 混淆的,特别是习惯于类体系和虚函数的思路后,很容易想在 Go 走这个路子,可惜是走不通的。而 interface 因为太过于简单,而且和 C++/Java 中的概念差异不是特别明显,所以本章节专门分析 Go 的类型系统。

先看一个典型的问题 Is it possible to call overridden method from parent struct in golang? 代码如下所示:

package main

import (
  "fmt"
)

type A struct {
}

func (a *A) Foo() {
  fmt.Println("A.Foo()")
}

func (a *A) Bar() {
  a.Foo()
}

type B struct {
  A
}

func (b *B) Foo() {
  fmt.Println("B.Foo()")
}

func main() {
  b := B{A: A{}}
  b.Bar()
}

本质上它是一个模板方法模式 (TemplateMethodPattern),A 的 Bar 调用了虚函数 Foo,期待子类重写虚函数 Foo,这是典型的 C++/Java 解决问题的思路。

我们借用模板方法模式 (TemplateMethodPattern) 中的例子,考虑实现一个跨平台编译器,提供给用户使用的函数是 crossCompile,而这个函数调用了两个模板方法 collectSource 和 compileToTarget

public abstract class CrossCompiler {
  public final void crossCompile() {
    collectSource();
    compileToTarget();
  }
  //Template methods
  protected abstract void collectSource();
  protected abstract void compileToTarget();
}

C 版,不用 OOAD 思维参考 C: CrossCompiler use StateMachine,代码如下所示:

// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>

void beforeCompile() {
  printf("Before compile\n");
}

void afterCompile() {
  printf("After compile\n");
}

void collectSource(bool isIPhone) {
  if (isIPhone) {
    printf("IPhone: Collect source\n");
  } else {
        printf("Android: Collect source\n");
    }
}

void compileToTarget(bool isIPhone) {
  if (isIPhone) {
    printf("IPhone: Compile to target\n");
  } else {
        printf("Android: Compile to target\n");
    }
}

void IDEBuild(bool isIPhone) {
  beforeCompile();

  collectSource(isIPhone);
  compileToTarget(isIPhone);

  afterCompile();
}

int main(int argc, char** argv) {
  IDEBuild(true);
  //IDEBuild(false);
  return 0;
}

C 版本使用 OOAD 思维,可以参考 C: CrossCompiler,代码如下所示:

// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>

class CrossCompiler {
public:
  void crossCompile() {
    beforeCompile();

    collectSource();
    compileToTarget();

    afterCompile();
  }
private:
  void beforeCompile() {
    printf("Before compile\n");
  }
  void afterCompile() {
    printf("After compile\n");
  }
// Template methods.
public:
  virtual void collectSource() = 0;
  virtual void compileToTarget() = 0;
};

class IPhoneCompiler : public CrossCompiler {
public:
  void collectSource() {
    printf("IPhone: Collect source\n");
  }
  void compileToTarget() {
    printf("IPhone: Compile to target\n");
  }
};

class AndroidCompiler : public CrossCompiler {
public:
  void collectSource() {
      printf("Android: Collect source\n");
  }
  void compileToTarget() {
      printf("Android: Compile to target\n");
  }
};

void IDEBuild(CrossCompiler* compiler) {
  compiler->crossCompile();
}

int main(int argc, char** argv) {
  IDEBuild(new IPhoneCompiler());
  //IDEBuild(new AndroidCompiler());
  return 0;
}

我们可以针对不同的平台实现这个编译器,比如 Android 和 iPhone:

public class IPhoneCompiler extends CrossCompiler {
  protected void collectSource() {
    //anything specific to this class
  }
  protected void compileToTarget() {
    //iphone specific compilation
  }
}

public class AndroidCompiler extends CrossCompiler {
  protected void collectSource() {
    //anything specific to this class
  }
  protected void compileToTarget() {
    //android specific compilation
  }
}

在 C++/Java 中能够完美的工作,但是在 Go 中,使用结构体嵌套只能这么实现,让 IPhoneCompiler 和 AndroidCompiler 内嵌 CrossCompiler,参考 Go: TemplateMethod,代码如下所示:

package main

import (
  "fmt"
)

type CrossCompiler struct {
}

func (v CrossCompiler) crossCompile() {
  v.collectSource()
  v.compileToTarget()
}

func (v CrossCompiler) collectSource() {
  fmt.Println("CrossCompiler.collectSource")
}

func (v CrossCompiler) compileToTarget() {
  fmt.Println("CrossCompiler.compileToTarget")
}

type IPhoneCompiler struct {
  CrossCompiler
}

func (v IPhoneCompiler) collectSource() {
  fmt.Println("IPhoneCompiler.collectSource")
}

func (v IPhoneCompiler) compileToTarget() {
  fmt.Println("IPhoneCompiler.compileToTarget")
}

type AndroidCompiler struct {
  CrossCompiler
}

func (v AndroidCompiler) collectSource() {
  fmt.Println("AndroidCompiler.collectSource")
}

func (v AndroidCompiler) compileToTarget() {
  fmt.Println("AndroidCompiler.compileToTarget")
}

func main() {
  iPhone := IPhoneCompiler{}
  iPhone.crossCompile()
}

执行结果却让人手足无措:

# Expect
IPhoneCompiler.collectSource
IPhoneCompiler.compileToTarget

# Output
CrossCompiler.collectSource
CrossCompiler.compileToTarget

Go 并没有支持类继承体系和多态,Go 是面向对象却不是一般所理解的那种面向对象,用老子的话说“道可道,非常道”。

实际上在 OOAD 中,除了类继承之外,还有另外一个解决问题的思路就是组合 Composition,面向对象设计原则中有个很重要的就是 The Composite Reuse Principle (CRP)Favor delegation over inheritance as a reuse mechanism,重用机制应该优先使用组合(代理)而不是类继承。类继承会丧失灵活性,而且访问的范围比组合要大;组合有很高的灵活性,另外组合使用另外对象的接口,所以能获得最小的信息。

C++ 如何使用组合代替继承实现模板方法?可以考虑让 CrossCompiler 使用其他的类提供的服务,或者说使用接口,比如 CrossCompiler 依赖于 ICompiler

public interface ICompiler {
  //Template methods
  protected abstract void collectSource();
  protected abstract void compileToTarget();
}

public abstract class CrossCompiler {
  public ICompiler compiler;
  public final void crossCompile() {
    compiler.collectSource();
    compiler.compileToTarget();
  }
}

C 版本可以参考 C: CrossCompiler use Composition,代码如下所示:

// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>

class ICompiler {
// Template methods.
public:
  virtual void collectSource() = 0;
  virtual void compileToTarget() = 0;
};

class CrossCompiler {
public:
  CrossCompiler(ICompiler* compiler) : c(compiler) {
  }
  void crossCompile() {
    beforeCompile();

    c->collectSource();
    c->compileToTarget();

    afterCompile();
  }
private:
  void beforeCompile() {
    printf("Before compile\n");
  }
  void afterCompile() {
    printf("After compile\n");
  }
  ICompiler* c;
};

class IPhoneCompiler : public ICompiler {
public:
  void collectSource() {
    printf("IPhone: Collect source\n");
  }
  void compileToTarget() {
    printf("IPhone: Compile to target\n");
  }
};

class AndroidCompiler : public ICompiler {
public:
  void collectSource() {
      printf("Android: Collect source\n");
  }
  void compileToTarget() {
      printf("Android: Compile to target\n");
  }
};

void IDEBuild(CrossCompiler* compiler) {
  compiler->crossCompile();
}

int main(int argc, char** argv) {
  IDEBuild(new CrossCompiler(new IPhoneCompiler()));
  //IDEBuild(new CrossCompiler(new AndroidCompiler()));
  return 0;
}

我们可以针对不同的平台实现这个 ICompiler,比如 Android 和 iPhone。这样从继承的类体系,变成了更灵活的接口的组合,以及对象直接服务的调用:

public class IPhoneCompiler implements ICompiler {
  protected void collectSource() {
    //anything specific to this class
  }
  protected void compileToTarget() {
    //iphone specific compilation
  }
}

public class AndroidCompiler implements ICompiler {
  protected void collectSource() {
    //anything specific to this class
  }
  protected void compileToTarget() {
    //android specific compilation
  }
}

在 Go 中,推荐用组合和接口,小的接口,大的对象。这样有利于只获得自己应该获取的信息,或者不会获得太多自己不需要的信息和函数,参考 Clients should not be forced to depend on methods they do not use. –Robert C. Martin,以及 The bigger the interface, the weaker the abstraction, Rob Pike。关于面向对象的原则在 Go 中的体现,参考 Go: SOLID 或中文版 Go: SOLID

先看如何使用 Go 的思路实现前面的例子,跨平台编译器,Go Composition: Compiler,代码如下所示:

package main

import (
  "fmt"
)

type SourceCollector interface {
  collectSource()
}

type TargetCompiler interface {
  compileToTarget()
}

type CrossCompiler struct {
  collector SourceCollector
  compiler  TargetCompiler
}

func (v CrossCompiler) crossCompile() {
  v.collector.collectSource()
  v.compiler.compileToTarget()
}

type IPhoneCompiler struct {
}

func (v IPhoneCompiler) collectSource() {
  fmt.Println("IPhoneCompiler.collectSource")
}

func (v IPhoneCompiler) compileToTarget() {
  fmt.Println("IPhoneCompiler.compileToTarget")
}

type AndroidCompiler struct {
}

func (v AndroidCompiler) collectSource() {
  fmt.Println("AndroidCompiler.collectSource")
}

func (v AndroidCompiler) compileToTarget() {
  fmt.Println("AndroidCompiler.compileToTarget")
}

func main() {
  iPhone := IPhoneCompiler{}
  compiler := CrossCompiler{iPhone, iPhone}
  compiler.crossCompile()
}

这个方案中,将两个模板方法定义成了两个接口,CrossCompiler 使用了这两个接口,因为本质上 C++/Java 将它的函数定义为抽象函数,意思也是不知道这个函数如何实现。而 IPhoneCompiler 和 AndroidCompiler 并没有继承关系,而它们两个实现了这两个接口,供 CrossCompiler 使用;也就是它们之间的关系,从之前的强制绑定,变成了组合。

type SourceCollector interface {
    collectSource()
}

type TargetCompiler interface {
    compileToTarget()
}

type CrossCompiler struct {
    collector SourceCollector
    compiler  TargetCompiler
}

func (v CrossCompiler) crossCompile() {
    v.collector.collectSource()
    v.compiler.compileToTarget()
}

Rob Pike 在 Go Language: Small and implicit 中描述 Go 的类型和接口,第 29 页说:

  • Objects implicitly satisfy interfaces. A type satisfies an interface simply by implementing its methods. There is no “implements” declaration; interfaces are satisfied implicitly. 这种隐式的实现接口,实际中还是很灵活的,我们在 Refector 时可以将对象改成接口,缩小所依赖的接口时,能够不改变其他地方的代码。比如如果一个函数 foo(f *os.File),最初依赖于 os.File,但实际上可能只是依赖于 io.Reader 就可以方便做 UTest,那么可以直接修改成 foo(r io.Reader) 所有地方都不用修改,特别是这个接口是新增的自定义接口时就更明显;
  • In Go, interfaces are usually small: one or two or even zero methods. 在 Go 中接口都比较小,非常小,只有一两个函数;但是对象却会比较大,会使用很多的接口。这种方式能够以最灵活的方式重用代码,而且保持接口的有效性和最小化,也就是接口隔离。

隐式实现接口有个很好的作用,就是两个类似的模块实现同样的服务时,可以无缝的提供服务,甚至可以同时提供服务。比如改进现有模块时,比如两个不同的算法。更厉害的时,两个模块创建的私有接口,如果它们签名一样,也是可以互通的,其实签名一样就是一样的接口,无所谓是不是私有的了。这个非常强大,可以允许不同的模块在不同的时刻升级,这对于提供服务的服务器太重要了。

比较被严重误认为是继承的,莫过于是 Go 的内嵌 Embeding,因为 Embeding 本质上还是组合不是继承,参考 Embeding is still composition

Embeding 在 UTest 的 Mocking 中可以显著减少需要 Mock 的函数,比如 Mocking net.Conn,如果只需要 mock Read 和 Write 两个函数,就可以通过内嵌 net.Conn 来实现,这样 loopBack 也实现了整个 net.Conn 接口,不必每个接口全部写一遍:

type loopBack struct {
    net.Conn
    buf bytes.Buffer
}

func (c *loopBack) Read(b []byte) (int, error) {
    return c.buf.Read(b)
}

func (c *loopBack) Write(b []byte) (int, error) {
    return c.buf.Write(b)
}

Embeding 只是将内嵌的数据和函数自动全部代理了一遍而已,本质上还是使用这个内嵌对象的服务。Outer 内嵌了Inner,和 Outer 继承 Inner 的区别在于:内嵌 Inner 是不知道自己被内嵌,调用 Inner 的函数,并不会对 Outer 有任何影响,Outer 内嵌 Inner 只是自动将 Inner 的数据和方法代理了一遍,但是本质上 Inner 的东西还不是 Outer 的东西;对于继承,调用 Inner 的函数有可能会改变 Outer 的数据,因为 Outer 继承 Inner,那么 Outer 就是 Inner,二者的依赖是更紧密的。

如果很难理解为何 Embeding 不是继承,本质上是没有区分继承和组合的区别,可以参考 Composition not inheritance,Go 选择组合不选择继承是深思熟虑的决定,面向对象的继承、虚函数、多态和类树被过度使用了。类继承树需要前期就设计好,而往往系统在演化时发现类继承树需要变更,我们无法在前期就精确设计出完美的类继承树;Go 的接口和组合,在接口变更时,只需要变更最直接的调用层,而没有类子树需要变更。

The designs are nothing like hierarchical, subtype-inherited methods. They are looser (even ad hoc), organic, decoupled, independent, and therefore scalable.

组合比继承有个很关键的优势是正交性 orthogonal,详细参考正交性

Orthogonal

真水无香,真的牛逼不用装。——来自网络

软件是一门科学也是艺术,换句话说软件是工程。科学的意思是逻辑、数学、二进制,比较偏基础的理论都是需要数学的,比如 C 的结构化编程是有论证的,那些关键字和逻辑是够用的。实际上 Go 的 GC 也是有数学证明的,还有一些网络传输算法,又比如奠定一个新领域的论文比如 Google 的论文。艺术的意思是,大部分时候都用不到严密的论证,有很多种不同的路,还需要看自己的品味或者叫偏见,特别容易引起口水仗和争论,从好的方面说,好的软件或代码,是能被感觉到很好的。

由于大部分时候软件开发是要靠经验的,特别是国内填鸭式教育培养了对于数学的莫名的仇恨(“莫名”主要是早就把该忘的不该忘记的都忘记了),所以在代码中强调数学,会激发起大家心中一种特别的鄙视和怀疑,而这种鄙视和怀疑应该是以葱白和畏惧为基础——大部分时候在代码中吹数学都会被认为是装逼。而 Orthogonal (正交性)则不择不扣的是个数学术语,是线性代数(就是矩阵那个玩意儿)中用来描述两个向量相关性的,在平面中就是两个线条的垂直。比如下图:

2.png

Vectors A and B are orthogonal to each other.

旁白:妮玛,两个线条垂直能和代码有个毛线关系,八竿子打不着关系吧,请继续吹。

先请看 Go 关于 Orthogonal 相关的描述,可能还不止这些地方:

Composition not inheritance Object-oriented programming provides a powerful insight: that the behavior of data can be generalized independently of the representation of that data. The model works best when the behavior (method set) is fixed, but once you subclass a type and add a method, the behaviors are no longer identical. If instead the set of behaviors is fixed, such as in Go’s statically defined interfaces, the uniformity of behavior enables data and programs to be composed uniformly, orthogonally, and safely.

JSON-RPC: a tale of interfaces In an inheritance-oriented language like Java or C++, the obvious path would be to generalize the RPC class, and create JsonRPC and GobRPC subclasses. However, this approach becomes tricky if you want to make a further generalization orthogonal to that hierarchy.

实际上 Orthogonal 并不是只有 Go 才提,参考 Orthogonal Software。实际上很多软件设计都会提正交性,比如 OOAD 里面也有不少地方用这个描述。我们先从实际的例子出发吧,关于线程一般 Java、Python、C# 等语言,会定义个线程的类 Thread,可能包含以下的方法管理线程:

var thread = new Thread(thread_main_function);
thread.Start();
thread.Interrupt();
thread.Join();
thread.Stop();

如果把 goroutine 也看成是 Go 的线程,那么实际上 Go 并没有提供上面的方法,而是提供了几种不同的机制来管理线程:

  • go 关键键字启动 goroutine;
  • sync.WaitGroup 等待线程退出;
  • chan 也可以用来同步,比如等 goroutine 启动或退出,或者传递退出信息给 goroutine;
  • context 也可以用来管理 goroutine,参考 Context
s := make(chan bool, 0)
q := make(chan bool, 0)
go func() {
    s <- true // goroutine started.
    for {
        select {
        case <-q:
            return
        default:
            // do something.
        }
    }
} ()

<- s // wait for goroutine started.
time.Sleep(10)
q <- true // notify goroutine quit.

注意上面只是例子,实际中推荐用 Context 管理 goroutine。

如果把 goroutine 看成一个向量,把 sync 看成一个向量,把 chan 看成一个向量,这些向量都不相关,也就是它们是正交的。

再举个 Orthogonal Software 的例子,将对象存储到 TEXT 或 XML 文件,可以直接写对象的序列化函数:

def read_dictionary(file)
  if File.extname(file) == ".xml"
    # read and return definitions in XML from file
  else
    # read and return definitions in text from file
  end
end

这个的坏处包括:

  1. 逻辑代码和序列化代码混合在一起,随处可见序列化代码,非常难以维护;
  2. 如果要新增序列化的机制比如将对象序列化存储到网络就很费劲了;
  3. 假设 TEXT 要支持 JSON 格式,或者 INI 格式呢?

如果改进下这个例子,将存储分离:

class Dictionary
  def self.instance(file)
    if File.extname(file) == ".xml"
      XMLDictionary.new(file)
    else
      TextDictionary.new(file)
    end
  end
end

class TextDictionary < Dictionary
  def write
    # write text to @file using the @definitions hash
  end
  def read
    # read text from @file and populate the @definitions hash
  end
end

如果把 Dictionay 看成一个向量,把存储方式看成一个向量,再把 JSON 或 INI 格式看成一个向量,他们实际上是可以不相关的。

再看一个例子,考虑上面 JSON-RPC: a tale of interfaces 的修改,实际上是将序列化的部分,从 *gob.Encoder 变成了接口 ServerCodec,然后实现了 jsonCodec 和 gobCodec 两种 Codec,所以 RPC 和 ServerCodec 是正交的。非正交的做法,就是从 RPC 继承两个类 jsonRPC 和 gobRPC,这样 RPC 和 Codec 是耦合的并不是不相关的。

Orthogonal 不相关到底有什么好说的?

  • 数学中不相关的两个向量,可以作为空间的基,比如平面上就是 x 和 y 轴,从向量看就是两个向量,这两个不相关的向量 x 和 y 可以组合出平面的任意向量,平面任一点都可以用 x 和 y 表示;如果向量不正交,有些区域就不能用这两个向量表达,有些点就不能表达。这个在接口设计上就是:正交的接口,能让用户灵活组合出能解决各种问题的调用方式,不相关的向量可以张成整个向量空间;同样的如果不正交,有时候就发现自己想要的功能无法通过现有接口实现,必须修改接口的定义;
  • 比如 goroutine 的例子,我们可以用 sync 或 chan 达到自己想要的控制 goroutine 的方式。比如 context 也是组合了 chan、timeout、value 等接口提供的一个比较明确的功能库。这些语言级别的正交的元素,可以组合成非常多样和丰富的库。比如有时候我们需要等 goroutine 启动,有时候不用;有时候甚至不需要管理 goroutine,有时候需要主动通知 goroutine 退出;有时候我们需要等 goroutine 出错后处理;
  • 比如序列化 TEXT 或 XML 的例子,可以将对象的逻辑完全和存储分离,避免对象的逻辑中随处可见存储对象的代码,维护性可以极大的提升。另外,两个向量的耦合还可以理解,如果是多个向量的耦合就难以实现,比如要将对象序列化为支持注释的 JSON 先存储到网络有问题再存储为 TEXT 文件,同时如果是程序升级则存储为 XML 文件,这种复杂的逻辑实际上需要很灵活的组合,本质上就是空间的多个向量的组合表达出空间的新向量(新功能);
  • 当对象出现了自己不该有的特性和方法,会造成巨大的维护成本。比如如果 TEXT 和 XML 机制耦合在一起,那么维护 TEXT 协议时,要理解 XML 的协议,改动 TEXT 时竟然造成 XML 挂掉了。使用时如果出现自己不用的函数也是一种坏味道,比如 Copy(src, dst io.ReadWriter) 就有问题,因为 src 明显不会用到 Write 而 dst不会用到 Read,所以改成 Copy(src io.Reader, dst io.Writer) 才是合理的。

由此可见,Orthogonal 是接口设计中非常关键的要素,我们需要从概念上考虑接口,尽量提供正交的接口和函数。比如 io.Readerio.Writer 和 io.Closer 是正交的,因为有时候我们需要的新向量是读写那么可以使用 io.ReadWriter,这实际上是两个接口的组合。

我们如何才能实现 Orthogonal 的接口呢?特别对于公共库,这个非常关键,直接决定了我们是否能提供好用的库,还是很烂的不知道怎么用的库。有几个建议:

  1. 好用的公共库,使用者可以通过 IDE 的提示就知道怎么用,不应该提供多个不同的路径实现一个功能,会造成很大的困扰。比如 Android 的通讯录,超级多的完全不同的类可以用,实际上就是非常难用;
  2. 必须要有完善的文档。完全通过代码就能表达 Why 和 How,是不可能的。就算是 Go 的标准库,也是大量的注释,如果一个公共库没有文档和注释,会非常的难用和维护;
  3. 一定要先写 Example,一定要提供 UTest 完全覆盖。没有 Example 的公共库是不知道接口设计是否合理的,没有人有能力直接设计一个合理的库,只有从使用者角度分析才能知道什么是合理,Example 就是使用者角度;标准库有大量的 Example。UTest 也是一种使用,不过是内部使用,也很必要。

如果上面数学上有不严谨的请原谅我,我数学很渣。

Modules

先把最重要的说了,关于 modules 的最新详细信息可以执行命令 go help modules 或者查这个长长的手册 Go Modules,另外 modules 弄清楚后很好用迁移成本低。

Go Module 的好处,可以参考 Demo

  1. 代码不用必须放 GOPATH,可以放在任何目录,终于不用做软链了;
  2. Module 依然可以用 vendor,如果不需要更新依赖,可以不必从远程下载依赖代码,同样不必放 GOPATH;
  3. 如果在一个仓库可以直接引用,会自动识别模块内部的 package,同样不用链接到 GOPATH。

Go 最初是使用 GOPATH 存放依赖的包(项目和代码),这个 GOPATH 是公共的目录,如果依赖的库的版本不同就杯具了。2016 年也就是 7 年后才支持 vendor 规范,就是将依赖本地化了,每个项目都使用自己的 vendor 文件夹,但这样也解决不了冲突的问题(具体看下面的分析),相反导致各种包管理项目天下混战,参考 pkg management tools

2017 年也就是 8 年后,官方的 vendor 包管理器 dep 才确定方案,看起来命中注定的 TheOne 终于尘埃落定。不料 2018 年也就是 9 年后,又提出比较完整的方案 versioning 和 vgo,这年 Go1.11 支持了 Modules,2019 年 Go1.12 和 Go1.13 改进了不少 Modules 内容,Go 官方文档推出一系列的 Part 1 — Using Go ModulesPart 2 — Migrating To Go Modules 和 Part 3 — Publishing Go Modules,终于应该大概齐能明白,这次真的确定和肯定了,Go Modules 是最终方案。

为什么要搞出 GOPATH、Vendor 和 GoModules 这么多技术方案?本质上是为了创造就业岗位,一次创造了 indexproxy 和 sum 三个官网,哈哈哈。当然技术上也是必须要这么做的,简单来说是为了解决古老的 DLL Hell 问题,也就是依赖管理和版本管理的问题。版本说起来就是几个数字,比如 1.2.3,实际上是非常复杂的问题,推荐阅读 Semantic Versioning,假设定义了良好和清晰的 API,我们用版本号来管理 API 的兼容性;版本号一般定义为 MAJOR.MINOR.PATCH,Major 变更时意味着不兼容的API变更,Minor 是功能变更但是是兼容的,Patch 是 BugFix 也是兼容的,Major 为 0 时表示 API 还不稳定。由于 Go 的包是 URL 的,没有版本号信息,最初对于包的版本管理原则是必须一直保持接口兼容:

If an old package and a new package have the same import path, the new package must be backwards compatible with the old package.

试想下如果所有我们依赖的包,一直都是接口兼容的,那就没有啥问题,也没有 DLL Hell。可惜现实却不是这样,如果我们提供过包就知道,对于持续维护和更新的包,在最初不可能提供一个永远不变的接口,变化的接口就是不兼容的了。就算某个接口可以不变,还有依赖的包,还有依赖的依赖的包,还有依赖的依赖的依赖的包,以此往复,要求世界上所有接口都不变,才不会有版本问题,这么说起来,包管理是个极其难以解决的问题,Go 花了 10 年才确定最终方案就是这个原因了,下面举例子详细分析这个问题。

备注:标准库也有遇到接口变更的风险,比如 Context 是 Go1.7 才引入标准库的,控制程序生命周期,后续有很多接口的第一个参数都是 ctx context.Context,比如 net.DialContext 就是后面加的一个函数,而 net.Dial 也是调用它。再比如 http.Request.WithContext 则提供了一个函数,将 context 放在结构体中传递,这是因为要再为每个 Request 的函数新增一个参数不太合适。从 context 对于标准库的接口的变更,可以看得到这里有些不一致性,有很多批评的声音比如 Context should go away for Go 2,就是觉得在标准库中加 context 作为第一个参数不能理解,比如 Read(ctx context.Context 等。

GOPATH & Vendor

咱们先看 GOPATH 的方式。Go 引入外部的包,是 URL 方式的,先在环境变量 $GOROOT 中搜索,然后在 $GOPATH 中搜索,比如我们使用 Errors,依赖包 github.com/ossrs/go-oryx-lib/errors,代码如下所示:

package main

import (
  "fmt"
  "github.com/ossrs/go-oryx-lib/errors"
)

func main() {
  fmt.Println(errors.New("Hello, playground"))
}

如果我们直接运行会报错,错误信息如下:

prog.go:5:2: cannot find package "github.com/ossrs/go-oryx-lib/errors" in any of:
    /usr/local/go/src/github.com/ossrs/go-oryx-lib/errors (from $GOROOT)
    /go/src/github.com/ossrs/go-oryx-lib/errors (from $GOPATH)

需要先下载这个依赖包 go get -d github.com/ossrs/go-oryx-lib/errors,然后运行就可以了。下载后放在 GOPATH 中:

Mac $ ls -lh $GOPATH/src/github.com/ossrs/go-oryx-lib/errors
total 72
-rw-r--r--  1 chengli.ycl  staff   1.3K Sep  8 15:35 LICENSE
-rw-r--r--  1 chengli.ycl  staff   2.2K Sep  8 15:35 README.md
-rw-r--r--  1 chengli.ycl  staff   1.0K Sep  8 15:35 bench_test.go
-rw-r--r--  1 chengli.ycl  staff   6.7K Sep  8 15:35 errors.go
-rw-r--r--  1 chengli.ycl  staff   5.4K Sep  8 15:35 example_test.go
-rw-r--r--  1 chengli.ycl  staff   4.7K Sep  8 15:35 stack.go

如果我们依赖的包还依赖于其他的包,那么 go get 会下载所有依赖的包到 GOPATH。这样是下载到公共的 GOPATH 的,可以想到,这会造成几个问题:

  1. 每次都要从网络下载依赖,可能对于美国这个问题不存在,但是对于中国,要从 GITHUB 上下载很大的项目,是个很麻烦的问题,还没有断点续传;
  2. 如果两个项目,依赖了 GOPATH 了项目,如果一个更新会导致另外一个项目出现问题。比如新的项目下载了最新的依赖库,可能会导致其他项目出问题;
  3. 无法独立管理版本号和升级,独立依赖不同的包的版本。比如 A 项目依赖 1.0 的库,而 B 项目依赖 2.0 的库。注意:如果 A 和 B 都是库的话,这个问题还是无解的,它们可能会同时被一个项目引用,如果 A 和 B 是最终的应用是没有问题,应用可以用不同的版本,它们在自己的目录。

为了解决这些问题,引入了 vendor,在 src 下面有个 vendor 目录,将依赖的库都下载到这个目录,同时会有描述文件说明依赖的版本,这样可以实现升级不同库的升级。参考 vendor,以及官方的包管理器 dep。但是 vendor 并没有解决所有的问题,特别是包的不兼容版本的问题,只解决了项目或应用,也就是会编译出二进制的项目所依赖库的问题。

咱们把上面的例子用 vendor 实现,先要把项目软链或者挪到 GOPATH 里面去,若没有 dep 工具可以参考 Installation 安装,然后执行下面的命令来将依赖导入到 vendor 目录:

dep init && dep ensure

这样依赖的文件就会放在 vendor 下面,编译时也不再需要从远程下载了:

├── Gopkg.lock
├── Gopkg.toml
├── t.go
└── vendor
    └── github.com
        └── ossrs
            └── go-oryx-lib
                └── errors
                    ├── errors.go
                    └── stack.go

Remark: Vendor 也会选择版本,也有版本管理,但每个包它只会选择一个版本,也就是本质上是本地化的 GOPATH,如果出现钻石依赖和冲突还是无解,下面会详细说明。

何为版本冲突?

我们来看 GOPATH 和 Vencor 无法解决的一个问题,版本依赖问题的一个例子 Semantic Import Versioning,考虑钻石依赖的情况,用户依赖于两个云服务商的 SDK,而它们可能都依赖于公共的库,形成一个钻石形状的依赖,用户依赖 AWS 和 Azure 而它们都依赖 OAuth:

3.png

如果公共库 package(这里是 OAuth)的导入路径一样(比如是 github.com/google/oauth),但是做了非兼容性变更,发布了 OAuth-r1 和 OAuth-r2,其中一个云服务商更新了自己的依赖,另外一个没有更新,就会造成冲突,他们依赖的版本不同:

4.png

在 Go 中无论怎么修改都无法支持这种情况,除非在 package 的路径中加入版本语义进去,也就是在路径上带上版本信息(这就是 Go Modules了),这和优雅没有关系,这实际上是最好的使用体验:

5.png

另外做法就是改变包路径,这要求包提供者要每个版本都要使用一个特殊的名字,但使用者也不能分辨这些名字代表的含义,自然也不知道如何选择哪个版本。

先看看 Go Modules 创造的三大就业岗位,index 负责索引、proxy 负责代理缓存和 sum 负责签名校验,它们之间的关系在 Big Picture 中有描述。可见 go-get 会先从 index 获取指定 package 的索引,然后从 proxy 下载数据,最后从 sum 来获取校验信息:

6.png

vgo 全面实践

还是先跟着官网的三部曲,先了解下 modules 的基本用法,后面补充下特别要注意的问题就差不多齐了。首先是 Using Go Modules,如何使用 modules,还是用上面的例子,代码不用改变,只需要执行命令:

go mod init private.me/app && go run t.go

Remark:和vendor并不相同,modules并不需要在GOPATH下面才能创建,所以这是非常好的。

执行的结果如下,可以看到 vgo 查询依赖的库,下载后解压到了 cache,并生成了 go.mod 和 go.sum,缓存的文件在 $GOPATH/pkg 下面:

Mac:gogogo chengli.ycl$ go mod init private.me/app && go run t.go
go: creating new go.mod: module private.me/app
go: finding github.com/ossrs/go-oryx-lib v0.0.7
go: downloading github.com/ossrs/go-oryx-lib v0.0.7
go: extracting github.com/ossrs/go-oryx-lib v0.0.7
Hello, playground

Mac:gogogo chengli.ycl$ cat go.mod
module private.me/app
go 1.13
require github.com/ossrs/go-oryx-lib v0.0.7 // indirect

Mac:gogogo chengli.ycl$ cat go.sum
github.com/ossrs/go-oryx-lib v0.0.7 h1:k8ml3ZLsjIMoQEdZdWuy8zkU0w/fbJSyHvT/s9NyeCc=
github.com/ossrs/go-oryx-lib v0.0.7/go.mod h1:i2tH4TZBzAw5h+HwGrNOKvP/nmZgSQz0OEnLLdzcT/8=

Mac:gogogo chengli.ycl$ tree $GOPATH/pkg
/Users/winlin/go/pkg
├── mod
│   ├── cache
│   │   ├── download
│   │   │   ├── github.com
│   │   │   │   └── ossrs
│   │   │   │       └── go-oryx-lib
│   │   │   │           └── @v
│   │   │   │               ├── list
│   │   │   │               ├── v0.0.7.info
│   │   │   │               ├── v0.0.7.zip
│   │   │   └── sumdb
│   │   │       └── sum.golang.org
│   │   │           ├── lookup
│   │   │           │   └── github.com
│   │   │           │       └── ossrs
│   │   │           │           └── go-oryx-lib@v0.0.7
│   └── github.com
│       └── ossrs
│           └── go-oryx-lib@v0.0.7
│               ├── errors
│               │   ├── errors.go
│               │   └── stack.go
└── sumdb
└── sum.golang.org
└── latest

可以手动升级某个库,即 go get 这个库:

Mac:gogogo chengli.ycl$ go get github.com/ossrs/go-oryx-lib
go: finding github.com/ossrs/go-oryx-lib v0.0.8
go: downloading github.com/ossrs/go-oryx-lib v0.0.8
go: extracting github.com/ossrs/go-oryx-lib v0.0.8

Mac:gogogo chengli.ycl$ cat go.mod 
module private.me/app
go 1.13
require github.com/ossrs/go-oryx-lib v0.0.8

升级某个包到指定版本,可以带上版本号,例如 go get github.com/ossrs/go-oryx-lib@v0.0.8。当然也可以降级,比如现在是 v0.0.8,可以 go get github.com/ossrs/go-oryx-lib@v0.0.7 降到 v0.0.7 版本。也可以升级所有依赖的包,执行 go get -u 命令就可以。查看依赖的包和版本,以及依赖的依赖的包和版本,可以执行 go list -m all 命令。查看指定的包有哪些版本,可以用 go list -m -versions github.com/ossrs/go-oryx-lib 命令。

Note: 关于 vgo 如何选择版本,可以参考 Minimal Version Selection

如果依赖了某个包大版本的多个版本,那么会选择这个大版本最高的那个,比如:

  • 若 a 依赖 v1.0.1,b 依赖 v1.2.3,程序依赖 a 和 b 时,最终使用 v1.2.3;
  • 若 a 依赖 v1.0.1,d 依赖 v0.0.7,程序依赖 a 和 d 时,最终使用 v1.0.1,也就是认为 v1 是兼容 v0 的。

比如下面代码,依赖了四个包,而这四个包依赖了某个包的不同版本,分别选择不同的包,执行 rm -f go.mod && go mod init private.me/app && go run t.go,可以看到选择了不同的版本,始终选择的是大版本最高的那个(也就是满足要求的最小版本):

package main

import (
    "fmt"
    "github.com/winlinvip/mod_ref_a" // 1.0.1
    "github.com/winlinvip/mod_ref_b" // 1.2.3
    "github.com/winlinvip/mod_ref_c" // 1.0.3
    "github.com/winlinvip/mod_ref_d" // 0.0.7
)

func main() {
    fmt.Println("Hello",
        mod_ref_a.Version(),
        mod_ref_b.Version(),
        mod_ref_c.Version(),
        mod_ref_d.Version(),
    )
}

若包需要升级大版本,则需要在路径上加上版本,包括本身的 go.mod 中的路径,依赖这个包的 go.mod,依赖它的代码,比如下面的例子,同时使用了 v1 和 v2 两个版本(只用一个也可以):

package main

import (
    "fmt"
    "github.com/winlinvip/mod_major_releases"
    v2 "github.com/winlinvip/mod_major_releases/v2"
)

func main() {
    fmt.Println("Hello",
        mod_major_releases.Version(),
        v2.Version2(),
    )
}

运行这个程序后,可以看到 go.mod 中导入了两个包:

module private.me/app
go 1.13
require (
        github.com/winlinvip/mod_major_releases v1.0.1
        github.com/winlinvip/mod_major_releases/v2 v2.0.3
)

Remark: 如果需要更新 v2 的指定版本,那么路径中也必须带 v2,也就是所有 v2 的路径必须带 v2,比如 go get github.com/winlinvip/mod_major_releases/v2@v2.0.3

而库提供大版本也是一样的,参考 mod_major_releases/v2,主要做的事情:

  1. 新建 v2 的分支,git checkout -b v2,比如 https://github.com/winlinvip/mod_major_releases/tree/v2
  2. 修改 go.mod 的描述,路径必须带 v2,比如 module github.com/winlinvip/mod_major_releases/v2
  3. 提交后打 v2 的 tag,比如 git tag v2.0.0,分支和 tag 都要提交到 git。

其中 go.mod 更新如下:

module github.com/winlinvip/mod_major_releases/v2
go 1.13

代码更新如下,由于是大版本,所以就变更了函数名称:

package mod_major_releases

func Version2() string {
    return "mmv/2.0.3"
}

Note: 更多信息可以参考 Modules: v2,还有 Russ Cox: From Repository to Modules 介绍了两种方式,常见的就是上面的分支方式的例子,还有一种文件夹方式。

Go Modules 特别需要注意的问题:

  • 对于公开的 package,如果 go.mod 中描述的 package,和公开的路径不相同,比如 go.mod 是 private.me/app,而发布到 github.com/winlinvip/app,当然其他项目 import 这个包时会出现错误。对于库,也就是希望别人依赖的包,go.mod 描述的和发布的路径,以及 package 名字都应该保持一致;
  • 如果一个包没有发布任何版本,则会取最新的 commit 和日期,格式为 v0.0.0-日期-commit 号,比如 v0.0.0-20191028070444-45532e158b41,参考 Pseudo Versions。版本号可以从 v0.0.x 开始,比如 v0.0.1 或者 v0.0.3 或者 v0.1.0 或者 v1.0.1 之类,没有强制要求必须要是 1.0 开始的发布版本;
  • mod replace 在子 module 无效,只在编译的那个 top level 有效,也就是在最终生成 binary 的 go.mod 中定义才有效,官方的说明是为了让最终生成时控制依赖。例如想要把 github.com/pkg/errors 重写为 github.com/winlinvip/errors 这个包,正确做法参考分支 replace_errors;若不在主模块 (top level) 中 replace 参考 replace_in_submodule,只在子模块中定义了 replace 但会被忽略;如果在主模块 replace 会生效 replace_errors,而且在主模块依赖掉子模快依赖的模块也生效 replace_deps_of_submodule。不过在子模快中也能 replace,这个预感到会是个混淆的地方。有一个例子就是 fork 仓库后修改后自己使用,这时候 go.mod 的 package 当然也变了,参考 Migrating Go1.13 Errors,Go1.13 的 errors 支持了 Unwrap 接口,这样可以拿到 root error,而 pkg/errors 使用的则是 Cause(err) 函数来获取 root error,而提的 PR 没有支持,pkg/errors 不打算支持 Go1.13 的方式,作者建议 fork 来解决,所以就可以使用 go mod replace 来将 fork 的 url 替换 pkg/errors;
  • go get 并非将每个库都更新后取最新的版本,比如库 github.com/winlinvip/mod_minor_versions 有 v1.0.1、v1.1.2 两个版本,目前依赖的是 v1.1.2 版本,如果库更新到了 v1.2.3 版本,立刻使用 go get -u 并不会更新到 v1.2.3,执行 go get -u github.com/winlinvip/mod_minor_versions 也一样不会更新,除非显式更新 go get github.com/winlinvip/mod_minor_versions@v1.2.3 才会使用这个版本,需要等一定时间后才会更新;
  • 对于大版本比如 v2,必须用 go.mod 描述,直接引用也可以比如 go get github.com/winlinvip/mod_major_error@v2.0.0,会提示 v2.0.0+incompatible,意思就是默认都是 v0 和 v1,而直接打了 v2.0.0 的 tag,虽然版本上匹配到了,但实际上是把 v2 当做 v1 在用,有可能会有不兼容的问题。或者说,一般来说 v2.0.0 的这个 tag,一定会有接口的变更(否则就不能叫 v2 了),如果没有用 go.mod 会把这个认为是 v1,自然可能会有兼容问题了;
  • 更新大版本时必须带版本号比如 go get github.com/winlinvip/mod_major_releases/v2@v2.0.1,如果路径中没有这个 v2 则会报错无法更新,比如 go get github.com/winlinvip/mod_major_releases@v2.0.1,错误消息是 invalid version: module contains a go.mod file, so major version must be compatible: should be v0 or v1,这个就是说 mod_major_releases 这个下面有 go.mod 描述的版本是 v0 或 v1,但后面指定的版本是 @v2 所以不匹配无法更新;

 

  • 和上面的问题一样,如果在 go.mod 中,大版本路径中没有带版本,比如 require github.com/winlinvip/mod_major_releases v2.0.3,一样会报错 module contains a go.mod file, so major version must be compatible: should be v0 or v1,这个有点含糊因为包定义的 go.mod 是 v2 的,这个错误的意思是,require 的那个地方,要求的是 v0 或 v1,而实际上版本是 v2.0.3,这个和手动要求更新 go get github.com/winlinvip/mod_major_releases@v2.0.1是一回事;
  • 注意三大岗位有 cache,比如 mod_major_error@v5.0.0 的 go.mod 描述有错误,应该是 v5,而不是 v3。如果在打完 tag 后,获取了这个版本 go get github.com/winlinvip/mod_major_error/v5,会提示错误 but does not contain package github.com/winlinvip/mod_major_error/v5 等错误,如果删除这个 tag 后再推 v5.0.0,还是一样的错误,因为 index 和 goproxy 有缓存这个版本的信息。解决版本就是升一个版本 v5.0.1,直接获取这个版本就可以,比如 go get github.com/winlinvip/mod_major_error/v5@v5.0.1,这样才没有问题。详细参考 Semantic versions and modules
  • 和上面一样的问题,如果在版本没有发布时,就有 go get 的请求,会造成版本发布后也无法获取这个版本。比如 github.com/winlinvip/mod_major_error 没有打版本 v3.0.1,就请求 go get github.com/winlinvip/mod_major_error/v3@v3.0.1,会提示没有这个版本。如果后面再打这个 tag,就算有这个 tag 后,也会提示 401 找不到 reading https://sum.golang.org/lookup/github.com/winlinvip/mod_major_error/v3@v3.0.1: 410 Gone。只能再升级个版本,打个新的 tag 比如 v3.0.2 才能获取到。

总结来说:

  • GOPATH,自从默认为 $HOME/go 后,很好用,依赖的包都缓存在这个公共的地方,只要项目不大,完全是很直接很好用的方案。一般情况下也够用了,估计 GOPATH 可能会被长期使用,毕竟习惯才是最可怕的,习惯是活的最久的,习惯就成为了一种生活方式,用余老师的话说“文化是一种精神价值和生活方式,最终体现了集体人格”;
  • vendor,vendor 缓存依赖在项目本地,能解决很多问题了,比 GOPATH 更好的是对于依赖可以定期更新,一般的项目中,对于依赖都是有需要了去更新,而不是每次编译都去取最新的代码。所以 vendor 还是非常实用的,如果能保持比较克制,不要因为要用一个函数就要依赖一个包,结果这个包依赖了十个,这十个又依赖了百个;
  • vgo/modules,代码使用上没有差异;在版本更新时比如明确需要导入 v2 的包,才会在导入 url 上有差异;代码缓存上使用 proxy 来下载,缓存在 GOPATH 的 pkg 中,由于有版本信息所以不会有冲突;会更安全,因为有 sum 在;会更灵活,因为有 index 和 proxy 在。

如何无缝迁移?

现有 GOPATH 和 vendor 的项目,如何迁移到 modules 呢?官方的迁移指南 Migrating to Go Modules,说明了项目会有三种状态:

  • 完全新的还没开始的项目。那么就按照上面的方式,用 modules 就好了;
  • 现有的项目,使用了其他依赖管理,也就是 vendor,比如 dep 或 glide 等。go mod 会将现有的格式转换成 modules,支持的格式参考这里。其实 modules 还是会继续支持 vendor,参考下面的详细描述;
  • 现有的项目,没有使用任何依赖管理,也就是 GOPATH。注意 go mod init 的包路径,需要和之前导出的一样,特别是 Go1.4 支持的 import comment,可能和仓库的路径并不相同,比如仓库在 https://go.googlesource.com/lint,而包路径是 golang.org/x/lint

Note: 特别注意如果是库支持了 v2 及以上的版本,那么路径中一定需要包含 v2,比如 github.com/russross/blackfriday/v2。而且需要更新引用了这个包的 v2 的库,比较蛋疼,不过这种情况还好是不多的。

咱们先看一个使用 GOPATH 的例子,我们新建一个测试包,先以 GOPATH 方式提供,参考 github.com/winlinvip/mod_gopath,依赖于 github.com/pkg/errorsrsc.io/quote 和 github.com/gorilla/websocket

再看一个 vendor 的例子,将这个 GOPATH 的项目,转成 vendor 项目,参考 github.com/winlinvip/mod_vendor,安装完 dep 后执行 dep init 就可以了,可以查看依赖:

chengli.ycl$ dep status
PROJECT                       CONSTRAINT  VERSION   REVISION  LATEST    PKGS USED
github.com/gorilla/websocket  ^1.4.1      v1.4.1    c3e18be   v1.4.1    1  
github.com/pkg/errors         ^0.8.1      v0.8.1    ba968bf   v0.8.1    1  
golang.org/x/text             v0.3.2      v0.3.2    342b2e1   v0.3.2    6  
rsc.io/quote                  ^3.1.0      v3.1.0    0406d72   v3.1.0    1  
rsc.io/sampler                v1.99.99    v1.99.99  732a3c4   v1.99.99  1

接下来转成 modules 包,先拷贝一份 github.com/winlinvip/mod_gopath 代码(这里为了演示差别所以拷贝了一份,直接转换也是可以的),变成 github.com/winlinvip/mod_gopath_vgo,然后执行命令 go mod init github.com/winlinvip/mod_gopath_vgo && go test ./... && go mod tidy,接着发布版本比如 git add . && git commit -am "Migrate to vgo" && git tag v1.0.1 && git push origin v1.0.1:

Mac:mod_gopath_vgo chengli.ycl$ cat go.mod
module github.com/winlinvip/mod_gopath_vgo
go 1.13
require (
    github.com/gorilla/websocket v1.4.1
    github.com/pkg/errors v0.8.1
    rsc.io/quote v1.5.2
)

depd 的 vendor 的项目也是一样的,先拷贝一份 github.com/winlinvip/mod_vendor 成 github.com/winlinvip/mod_vendor_vgo,执行命令 go mod init github.com/winlinvip/mod_vendor_vgo && go test ./... && go mod tidy,接着发布版本比如 git add . && git commit -am "Migrate to vgo" && git tag v1.0.3 && git push origin v1.0.3

module github.com/winlinvip/mod_vendor_vgo
go 1.13
require (
    github.com/gorilla/websocket v1.4.1
    github.com/pkg/errors v0.8.1
    golang.org/x/text v0.3.2 // indirect
    rsc.io/quote v1.5.2
    rsc.io/sampler v1.99.99 // indirect
)

这样就可以在其他项目中引用它了:

package main

import (
    "fmt"
    "github.com/winlinvip/mod_gopath"
    "github.com/winlinvip/mod_gopath/core"
    "github.com/winlinvip/mod_vendor"
    vcore "github.com/winlinvip/mod_vendor/core"
    "github.com/winlinvip/mod_gopath_vgo"
    core_vgo "github.com/winlinvip/mod_gopath_vgo/core"
    "github.com/winlinvip/mod_vendor_vgo"
    vcore_vgo "github.com/winlinvip/mod_vendor_vgo/core"
)

func main() {
    fmt.Println("mod_gopath is", mod_gopath.Version(), core.Hello(), core.New("gopath"))
    fmt.Println("mod_vendor is", mod_vendor.Version(), vcore.Hello(), vcore.New("vendor"))
    fmt.Println("mod_gopath_vgo is", mod_gopath_vgo.Version(), core_vgo.Hello(), core_vgo.New("vgo(gopath)"))
    fmt.Println("mod_vendor_vgo is", mod_vendor_vgo.Version(), vcore_vgo.Hello(), vcore_vgo.New("vgo(vendor)"))
}

Note: 对于私有项目,可能无法使用三大件来索引校验,那么可以设置 GOPRIVATE 来禁用校验,参考 Module configuration for non public modules

vgo with vendor

Vendor 并非不能用,可以用 modules 同时用 vendor,参考 How do I use vendoring with modules? Is vendoring going away?,其实 vendor 并不会消亡,Go 社区有过详细的讨论 vgo & vendoring 决定在 modules 中支持 vendor,有人觉得,把 vendor 作为 modules 的存储目录挺好的啊。在 modules 中开启 vendor 有几个步骤:

  1. 先转成 modules,参考前面的步骤,也可以新建一个 modules 例如 go mod init xxx,然后把代码写好,就是一个标准的 module,不过文件是存在 $GOPATH/pkg 的,参考 github.com/winlinvip/mod_vgo_with_vendor@v1.0.0
  2. go mod vendor,这一步做的事情,就是将 modules 中的文件都放到 vendor 中来。当然由于 go.mod 也存在,当然也知道这些文件的版本信息,也不会造成什么问题,只是新建了一个 vendor 目录而已。在别人看起来这就是这正常的 modules,和 vendor 一点影响都没有。参考 github.com/winlinvip/mod_vgo_with_vendor@v1.0.1
  3. go build -mod=vendor,修改 mod 这个参数,默认是会忽略这个 vendor 目录了,加上这个参数后就会从 vendor 目录加载代码(可以把 $GOPATH/pkg 删掉发现也不会下载代码)。当然其他也可以加这个 flag,比如 go test -mod=vendor ./... 或者 go run -mod=vendor .

调用这个包时,先使用 modules 把依赖下载下来,比如 go mod init private.me/app && go run t.go

package main

import (
    "fmt"
    "github.com/winlinvip/mod_vendor_vgo"
    vcore_vgo "github.com/winlinvip/mod_vendor_vgo/core"
    "github.com/winlinvip/mod_vgo_with_vendor"
    vvgo_core "github.com/winlinvip/mod_vgo_with_vendor/core"
)

func main() {
    fmt.Println("mod_vendor_vgo is", mod_vendor_vgo.Version(), vcore_vgo.Hello(), vcore_vgo.New("vgo(vendor)"))
    fmt.Println("mod_vgo_with_vendor is", mod_vgo_with_vendor.Version(), vvgo_core.Hello(), vvgo_core.New("vgo with vendor"))
}

然后一样的也要转成 vendor,执行命令 go mod vendor && go run -mod=vendor t.go。如果有新的依赖的包需要导入,则需要先使用 modules 方式导入一次,然后 go mod vendor 拷贝到 vendor。其实一句话来说,modules with vendor 就是最后提交代码时,把依赖全部放到 vendor 下面的一种方式。

Note: IDE 比如 goland 的设置里面,有个 Preferences /Go /Go Modules(vgo) /Vendoring mode,这样会从项目的 vendor 目录解析,而不是从全局的 cache。如果不需要导入新的包,可以默认开启 vendor 方式,执行命令 go env -w GOFLAGS='-mod=vendor'

Concurrency&Control

并发是服务器的基本问题,并发控制当然也是基本问题,Go 并不能避免这个问题,只是将这个问题更简化。

Concurrency

早在十八年前的 1999 年,千兆网卡还是一个新玩意儿,想当年有吉比特带宽却只能支持 10K 客户端,还是个值得研究的问题,毕竟 Nginx 在 2009 年才出来,在这之前大家还在内核折腾过 HTTP 服务器,服务器领域还在讨论如何解决 C10K 问题,C10K 中文翻译在这里。读这个文章,感觉进入了繁忙服务器工厂的车间,成千上万错综复杂的电缆交织在一起,甚至还有古老的惊群 (thundering herd) 问题,惊群像远古狼人一样就算是在 21 世纪还是偶然能听到它的传说。现在大家讨论的都是如何支持 C10M,也就是千万级并发的问题。

并发,无疑是服务器领域永远无法逃避的话题,是服务器软件工程师的基本能力。Go 的撒手锏之一无疑就是并发处理,如果要从 Go 众多优秀的特性中挑一个,那就是并发和工程化,如果只能选一个的话,那就是并发的支持。大规模软件,或者云计算,很大一部分都是服务器编程,服务器要处理的几个基本问题:并发、集群、容灾、兼容、运维,这些问题都可以因为 Go 的并发特性得到改善,按照《人月神话》的观点,并发无疑是服务器领域的固有复杂度 (Essential Complexity) 之一。Go 之所以能迅速占领云计算的市场,Go 的并发机制是至关重要的。

借用《人月神话》中关于固有复杂度 (Essential Complexity) 的概念,能比较清晰的说明并发问题。就算没有读过这本书,也肯定听过软件开发“没有银弹”,要保持软件的“概念完整性”,Brooks 作为硬件和软件的双重专家和出色的教育家始终活跃在计算机舞台上,在计算机技术的诸多领域中都作出了巨大的贡献,在 1964 年 (33 岁) 领导了 IBM System/360 和 IBM OS/360 的研发,于 p1993 年 (62 岁) 获得冯诺依曼奖,并于 1999 年 (68 岁) 获得图灵奖,在 2010 年 (79 岁) 获得虚拟现实 (VR) 的奖项 IEEE Virtual Reality Career Award (2010)

在软件领域,很少能有像《人月神话》一样具有深远影响力和畅销不衰的著作。Brooks 博士为人们管理复杂项目提供了具有洞察力的见解,既有很多发人深省的观点,又有大量软件工程的实践。本书内容来自 Brooks 博士在 IBM 公司 System/360 家族和 OS/360 中的项目管理经验,该项目堪称软件开发项目管理的典范。该书英文原版一经面世,即引起业内人士的强烈反响,后又译为德、法、日、俄、中、韩等多种文字,全球销售数百万册。确立了其在行业内的经典地位。

Brooks 是我最崇拜的人,有理论有实践,懂硬件懂软件,致力于大规模软件(当初还没有云计算)系统,足够(长达十年甚至二十年)的预见性,孜孜不倦奋斗不止,强烈推荐软件工程师读《人月神话》

短暂的广告回来,继续讨论并发 (Concurrency) 的问题,要理解并发的问题就必须从了解并发问题本身,以及并发处理模型开始。2012 年我在当时中国最大的 CDN 公司蓝汛设计和开发流媒体服务器时,学习了以高并发闻名的 NGINX 的并发处理机制 EDSM(Event-Driven State Machine Architecture),自己也照着这套机制实现了一个流媒体服务器,和 HTTP 的 Request-Response 模型不同,流媒体的协议比如 RTMP 非常复杂中间状态非常多,特别是在做到集群 Edge 时和上游服务器的交互会导致系统的状态机翻倍,当时请教了公司的北美研发中心的架构师 Michael,Michael 推荐我用一个叫做 ST(StateThreads) 的技术解决这个问题,ST 实际上使用 setjmp 和 longjmp 实现了用户态线程或者叫协程,协程和 goroutine 是类似的都是在用户空间的轻量级线程,当时我本没有懂为什么要用一个完全不懂的协程的东西,后来我花时间了解了 ST 后豁然开朗,原来服务器的并发处理有几种典型的并发模型,流媒体服务器中超级复杂的状态机,也广泛存在于各种服务器领域中,属于这个复杂协议服务器领域不可 Remove 的一种固有复杂度 (Essential Complexity)

我翻译了 ST(StateThreads) 总结的并发处理模型高性能、高并发、高扩展性和可读性的网络服务器架构:State Threads for Internet Applications,这篇文章也是理解 Go 并发处理的关键,本质上 ST 就是 C 语言的协程库(腾讯微信也开源过一个 libco 协程库),而 goroutine 是 Go 语言级别的实现,本质上他们解决的领域问题是一样的,当然 goroutine 会更广泛一些,ST 只是一个网络库。我们一起看看并发的本质目标,一起看图说话吧,先从并发相关的性能和伸缩性问题说起:

7.png

  • 横轴是客户端的数目,纵轴是吞吐率也就是正常提供服务需要能吐出的数据,比如 1000 个客户端在观看 500Kbps 码率的视频时,意味着每个客户端每秒需要 500Kb 的数据,那么服务器需要每秒吐出 500*1000Kb=500Mb 的数据才能正常提供服务,如果服务器因为性能问题 CPU 跑满了都无法达到 500Mbps 的吞吐率,客户端必定就会开始卡顿;
  • 图中黑色的线是客户端要求的最低吞吐率,假设每个客户端都是一样的,那么黑色的线就是一条斜率固定的直线,也就是客户端越多吞吐率就越多,基本上和客户端数目成正比。比如 1 个客户端需要 500Kbps 的吞吐率, 1000 个就是 500Mbps 吞吐率;
  • 图中蓝色的实线,是服务器实际能达到的吞吐率。在客户端比较少时,由于 CPU 空闲,服务器(如果有需要)能够以超过客户端要求的最低吞吐率给数据,比如点播服务器的场景,客户端看 500Kbps 码率的点播视频,每秒最少需要 500Kb 的数据,那么服务器可以以 800Kbps 的吞吐率给客户端数据,这样客户端自然不会卡顿,客户端会将数据保存在自己的缓冲区,只是如果用户放弃播放这个视频时会导致缓存的数据浪费;
  • 图中蓝色实线会有个天花板,也就是服务器在给定的 CPU 资源下的最高吞吐率,比如某个版本的服务器在 4CPU 下由于性能问题只能达到 1Gbps 的吞吐率,那么黑线和蓝线的交叉点,就是这个服务器能正常服务的最多客户端比如 2000 个。理论上如果超过这个最大值比如 10K 个,服务器吞吐率还是保持在最大吞吐率比如 1Gbps,但是由于客户端的数目持续增加需要继续消耗系统资源,比如 10K 个 FD 和线程的切换会抢占用于网络收发的 CPU 时间,那么就会出现蓝色虚线,也就是超负载运行的服务器,吞吐率会降低,导致服务器无法正常服务已经连接的客户端;
  • 负载伸缩性 (Load Scalability) 就是指黑线和蓝线的交叉点,系统的负载能力如何,或者说是否并发模型能否尽可能的将 CPU 用在网络吞吐上,而不是程序切换上,比如多进程的服务器,负载伸缩性就非常差,有些空闲的客户端也会 Fork 一个进程服务,这无疑是浪费了 CPU 资源的。同时多进程的系统伸缩性会很好,增加 CPU 资源时吞吐率基本上都是线性的;
  • 系统伸缩性 (System Scalability) 是指吞吐率是否随系统资源线性增加,比如新增一倍的 CPU,是否吞吐率能翻倍。图中绿线,就是增加了一倍的 CPU,那么好的系统伸缩性应该系统的吞吐率也要增加一倍。比如多线程程序中,由于要对竞争资源加锁或者多线程同步,增加的 CPU 并不能完全用于吞吐率,多线程模型的系统伸缩性就不如多进程模型。

并发的模型包括几种,总结 Existing Architectures 如下表:

Arch Load Scalability System Scalability Robust Complexity Example
Multi-Process Poor Good Great Simple Apache1.x
Multi-Threaded Good Poor Poor Complex Tomcat, FMS/AMS
Event-Driven
State Machine
Great Great Good Very
Complex
Nginx, CRTMPD
StateThreads Great Great Good Simple SRS, Go
  • MP(Multi-Process)多进程模型:每个连接 Fork 一个进程服务。系统的鲁棒性非常好,连接彼此隔离互不影响,就算有进程挂掉也不会影响其他连接。负载伸缩性 (Load Scalability) 非常差 (Poor),系统在大量进程之间切换的开销太大,无法将尽可能多的 CPU 时间使用在网络吞吐上,比如 4CPU 的服务器启动 1000 个繁忙的进程基本上无法正常服务。系统伸缩性 (System Scalability) 非常好,增加 CPU 时一般系统吞吐率是线性增长的。目前比较少见纯粹的多进程服务器了,特别是一个连接一个进程这种。虽然性能很低,但是系统复杂度低 (Simple),进程很独立,不需要处理锁或者状态;
  • MT(Multi-Threaded) 多线程模型:有的是每个连接一个线程,改进型的是按照职责分连接,比如读写分离的线程,几个线程读,几个线程写。系统的鲁棒性不好 (Poor),一个连接或线程出现问题,影响其他的线程,彼此互相影响。负载伸缩性 (Load Scalability) 比较好 (Good),线程比进程轻量一些,多个用户线程对应一个内核线程,但出现被阻塞时性能会显著降低,变成和多进程一样的情况。系统伸缩性 (System Scalability) 比较差 (Poor),主要是因为线程同步,就算用户空间避免锁,在内核层一样也避免不了;增加 CPU 时,一般在多线程上会有损耗,并不能获得多进程那种几乎线性的吞吐率增加。多线程的复杂度 (Complex) 也比较高,主要是并发和锁引入的问题;
  • EDSM(Event-Driven State Machine) 事件驱动的状态机。比如 select/poll/epoll,一般是单进程单线程,这样可以避免多进程的锁问题,为了避免单程的系统伸缩问题可以使用多进程单线程,比如 NGINX 就是这种方式。系统鲁棒性比较好 (Good),一个进程服务一部分的客户端,有一定的隔离。负载伸缩性 (Load Scalability) 非常好 (Great),没有进程或线程的切换,用户空间的开销也非常少,CPU 几乎都可以用在网络吞吐上。系统伸缩性 (System Scalability) 很好,多进程扩展时几乎是线性增加吞吐率。虽然效率很高,但是复杂度也非常高 (Very Complex),需要维护复杂的状态机,特别是两个耦合的状态机,比如客户端服务的状态机和回源的状态机。
  • ST(StateThreads)协程模型。在 EDSM 的基础上,解决了复杂状态机的问题,从堆开辟协程的栈,将状态保存在栈中,在异步 IO 等待 (EAGAIN) 时,主动切换 (setjmp/longjmp) 到其他的协程完成 IO。也就是 ST 是综合了 EDSM 和 MT 的优势,不过 ST 的线程是用户空间线程而不是系统线程,用户空间线程也会有调度的开销,不过比系统的开销要小很多。协程的调度开销,和 EDSM 的大循环的开销差不多,需要循环每个激活的客户端,逐个处理。而 ST 的主要问题,在于平台的适配,由于 glibc 的 setjmp/longjmp 是加密的无法修改 SP 栈指针,所以 ST 自己实现了这个逻辑,对于不同的平台就需要自己适配,目前 Linux 支持比较好,Windows 不支持,另外这个库也不在维护有些坑只能绕过去,比较偏僻使用和维护者都很少,比如 ST Patch 修复了一些问题。

我将 Go 也放在了 ST 这种模型中,虽然它是多线程+协程,和 SRS 不同是多进程+协程(SRS 本身是单进程+协程可以扩展为多进程+协程)。

从并发模型看 Go 的 goroutine,Go 有 ST 的优势,没有 ST 的劣势,这就是 Go 的并发模型厉害的地方了。当然 Go 的多线程是有一定开销的,并没有纯粹多进程单线程那么高的负载伸缩性,在活跃的连接过多时,可能会激活多个物理线程,导致性能降低。也就是 Go 的性能会比 ST 或 EDSM 要差,而这些性能用来交换了系统的维护性,个人认为很值得。除了 goroutine,另外非常关键的就是 chan。Go 的并发实际上并非只有 goroutine,而是 goroutine+chan,chan 用来在多个 goroutine 之间同步。实际上在这两个机制上,还有标准库中的 context,这三板斧是 Go 的并发的撒手锏。

由于 Go 是多线程的,关于多线程或协程同步,除了 chan 也提供了 Mutex,其实这两个都是可以用的,而且有时候比较适合用 chan 而不是用 Mutex,有时候适合用 Mutex 不适合用 chan,参考 Mutex or Channel

Channel Mutex
passing ownership of data,
distributing units of work,
communicating async results
caches,
state

特别提醒:不要惧怕使用 Mutex,不要什么都用 chan,千里马可以一日千里却不能抓老鼠,HelloKitty 跑不了多快抓老鼠却比千里马强。

Context

实际上 goroutine 的管理,在真正高可用的程序中是非常必要的,我们一般会需要支持几种gorotine的控制方式:

  1. 错误处理:比如底层函数发生错误后,我们是忽略并告警(比如只是某个连接受到影响),还是选择中断整个服务(比如 LICENSE 到期);
  2. 用户取消:比如升级时,我们需要主动的迁移新的请求到新的服务,或者取消一些长时间运行的 goroutine,这就叫热升级;
  3. 超时关闭:比如请求的最大请求时长是 30 秒,那么超过这个时间,我们就应该取消请求。一般客户端的服务响应是有时间限制的;
  4. 关联取消:比如客户端请求服务器,服务器还要请求后端很多服务,如果中间客户端关闭了连接,服务器应该中止,而不是继续请求完所有的后端服务。

而 goroutine 的管理,最开始只有 chan 和 sync,需要自己手动实现 goroutine 的生命周期管理,参考 Go Concurrency Patterns: Timing out, moving on 和 Go Concurrency Patterns: Context,这些都是 goroutine 的并发范式。

直接使用原始的组件管理 goroutine 太繁琐了,后来在一些大型项目中出现了 context 这些库,并且 Go1.7 之后变成了标准库的一部分。具体参考 GOLANG 使用 Context 管理关联 goroutine 以及 GOLANG 使用 Context 实现传值、超时和取消

Context 也有问题:

  1. 支持 Cancel、Timeout 和 Value,这些都是扩张 Context 树的节点。Cancel 和 Timeout 在子树取消时会删除子树,不会一直膨胀;Value 没有提供删除的函数,如果他们有公共的根节点,会导致这个 Context 树越来越庞大;所以 Value 类型的 Context 应该挂在 Cancel 的 Context 树下面,这样在取消时 GC 会回收;
  2. 会导致接口不一致或者奇怪,比如 io.Reader 其实第一个参数应该是 context,比如 Read(Context, []byte) 函数。或者提供两套接口,一种带 Contex,一种不带 Context。这个问题还蛮困扰人的,一般在应用程序中,推荐第一个参数是 Context;
  3. 注意 Context 树,如果因为 Closure 导致树越来越深,会有调用栈的性能问题。比如十万个长链,会导致 CPU 占用 500% 左右。

备注:关于对 Context 的批评,可以参考 Context should go away for Go 2,作者觉得在标准库中加 context 作为第一个参数不能理解,比如 Read(ctx context.Context 等。

Go 开发技术指南系列文章

 

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