SonarQube是如何工作的
2019-01-16 / modified at 2022-04-04 / 4.2k words / 16 mins
️This article has been over 2 years since the last update.

SonarQube是来自瑞士的代码检查工具,除了用来检查项目,它本身也是开源的,源码(代码结构/技术文档等)也必然是值得一读。

通过阅读源码,我们可以学到

  • 一款顶尖复杂的软件项目的结构与模块如何划分
  • 一个复杂前端的React实现
  • 如何混合使用ES与数据库

在阅读本文前,众所周知源码分析非常耗费时间,重复一下源码分析方法论

  • 能够充分使用过此项目,并阅读文档,这一步主要掌握上下文与术语
  • 分析项目的pom依赖,并全部过一遍
  • 找到免编译的路由断点与关键日志位置
  • 充分使用FindUsage快速Jump

预先准备

由于编译SonarQube非常繁琐耗时,我建议提前编译好的二进制文件

下载源码

  • 下载/手动编译二进制文件,解压运行,并确保已经有数据

本地编译流程

1
./build.sh

然后解压并右键启动

1
java -jar sonar-application/build/distributions/sonarqube-8.9-SNAPSHOT/lib/sonar-application-8.9-SNAPSHOT.jar

目录解析

如下是安装目录

1
2
3
4
5
6
7
8
9
bin:壳而已,Docker容器启动不需要
conf:配置文件
data: 主要是存放h2,elk,插件。如果数据库都外部化了,这里就只有plugin的副本了(PluginClassLoader)
elasticsearch: elk原生包,下文写了可以考虑干掉
extensions: 扩展,注意chmod,启动后会拷贝到 data/web/deploy/plugins
lib: sonar-application-8.9-SNAPSHOT.jar: 所有代码与jar包依赖,内部主要有3个mian方法
logs
temp
web: 启动web时的react文件

配置断点

在IDEA中配置Remote断点,并在源码中如下位置打下断点

1
org.sonar.ce.app.CeServer#start

然后再次重新运行SonarQube,如果Ok的话断点就断上了

SonarQube组件构成

服务端(ServerSide)

在服务端(org.sonar.application.App)启动时,会依此启动如下Process(通过ps aux|grep sonar分析)

1
2
3
4
5
6
7
org.sonar.application.SchedulerImpl#tryToStartAll
# 由 bin/elasticsearch 的shell启动
org.elasticsearch.bootstrap.Elasticsearch
# tryToStartCe
org.sonar.ce.app.CeServer
# tryToStartWeb
org.sonar.server.app.WebServer

它们的作用分别是

  • ElasticSearch: 内嵌的ElasticSearch,版本为7,未修改任何jar包,类似HBase的rowkey的作用
  • Compute Engine(CE): 计算引擎,通过解析计算客户端上传的Zip,显示到前端;定时刷新db到elk
  • Web: 本质是数据仓库的前台。主要有用React实现的前端与API代理,通过内嵌的Tomcat实现部署

它们的PID分别如下

1
2
3
p_es=`ps aux|grep Elasticsearch|grep java |awk '{print $2}'`
p_ce=`ps aux|grep CeServer|grep java |awk '{print $2}'`
p_web=`ps aux|grep WebServer|grep java |awk '{print $2}'`

通过lsof查看每个服务的监听情况,并在源码中全局搜索

1
2
3
4
5
6
lsof -i -n -P |grep java|grep LISTEN|grep ${p_es}
# --> 9001(Elastic默认TCP端口)
lsof -i -n -P |grep java|grep LISTEN|grep ${p_ce}
# --> 54918(没查到,可能是随机端口)
lsof -i -n -P |grep java|grep LISTEN|grep ${p_web}
# --> 9000(HTTP端口), 9092(H2数据库端口)

客户端(ScannerSide)

源码分析器,在客户机上计算,通过HTTP将ZIP发给WebServer,内部有ClassLoader,可以下载服务端的插件到客户端执行Sensor解析等操作。

1
2
# 上传zip
org.sonar.scanner.report.ReportPublisher#upload

这里主要涉及到插件开发,可以参考sonar-cxx等优质插件,插件文档很不友好,对编码要求高。

我在一些项目中,涉及到读取第三方report,这时推荐使用Java-grok框架提高开发速度。

组件实现

数据库(demo-only)

内嵌H2直接免密码访问如下JDBC即可

1
jdbc:h2:tcp://127.0.0.1:9092/sonar

当然生产时可以自己搭建一个本地的pg库。在Java层使用了MyBatis进行查询。所有的SQL都没有用自增主键,而是用的是类似雪花算法(绑定了Mac地址与timestamp)的UuidFactoryImpl实现的。

这种类似UUID的实现导致无法使用基于主键的二分Join查找,而一定需要基于分词器的ELK作为二级索引缓存(比如先查询issue的ids,再用in去查询files),否则性能会极低。

我使用内嵌的数据库测试发现一个问题:Quality Profiles only supports up to 6.4k build-in rules .

ElasticSearch里存的啥

在SonarQube中,ElasticSearch的配置文件是程序生成的。

入口

1
org.sonar.application.command.CommandFactory#createEsCommand

默认通过elastic head等工具访问http://localhost:9001/就可以知道里面存的啥了。其实里面只存储了一些主键的uuid

比如下面存储了issues的主键kee对应下面的_id

1
2
3
4
5
6
7
8
{
"_index": "issues",
"_type": "auth",
"_id": "AXR4KEWgqc90wyxKGvI6",
"_version": 1,
"_score": 1,
"_routing": "auth_AXR4J_kRLlgBvFiws367"
}

此外还有rules也只是用来存储主键,并没有像想象中缓存了很多error/warings的报错。这里的ELK场景类似于提供了一个类似HBase的唯一RowKey(联合主键),是Stream计算后的缓存的缓存,下游DB只用查询in即可,性能更好,这样就算是一堆left join,也都是低计算强度的聚合。

假如你将ELK删除,然后重启可以发现会自动修复,说明本项目中ELK定位只是主键/分页的索引缓存,这个Key承载了太多信息。

在收费版中,Sonarqube支持多个ELK节点,而免费版不支持分离部署。

外置Elasticsearch并不是为了解决Sonarqube的扩容瓶颈问题,而主要是解决单点可用性问题,你放到外部就算天天备份也意义不大。

注意配置要提高Xms与Xmx,默认的512太低;将Elastic的索引文件外挂Docker磁盘,可以提高reindex速度。

How to use external Elasticsearch in Sonarqube

It’s just a hack at your own risk. See details at stackoverflow

注意主节点插件升级后,ELK也会同步导入规则,这个场景没有测试

本文实验如下,下面为ELK的配置

1
2
3
4
5
6
7
8
# ELK side, IP=192.168.33.10
sysctl -w vm.max_map_count=262144
docker run --name elk -p 9200:9200 bitnami/elasticsearch:7
docker run --name pg -p 5432:5432 -e POSTGRES_USER="postgres" -e POSTGRES_PASSWORD="postgres" postgres
psql -U postgres
create database sonarqube;
create user sonarqube with encrypted password 'sonarqube';
grant all privileges on database sonarqube to sonarqube;

sonar配置

1
2
3
4
5
6
7
# sonar side, test only
sonar.jdbc.username=sonarqube
sonar.jdbc.password=sonarqube
sonar.jdbc.url=jdbc:postgresql://192.168.33.10:5432/sonarqube
sonar.web.port=9100
sonar.search.port=9200
sonar.search.host=192.168.33.10

执行mock脚本

1
2
3
4
cat > ./elasticsearch/bin/elasticsearch << EOF
#!/bin/bash
# it's a inflate sleep
EOF
1
java -jar lib/sonar-application-8.9.3-SNAPSHOT.jar 

这种将ELK与Sonar分离部署的方案,启动速度实测为15s左右(主要是ce耗费时间)

如果需要reindex,按照官方标准的话,就是先关机sonar,清空elk,再启动sonar通过ce进行reindex

1
2
# 重启后相关任务
IndexerStartupTask -> AsyncIssueIndexingImpl -> ceQueue -> IssueSyncTaskProcessor

WebServer

它部署了一个嵌入的Tomcat,通过Java(而不是通过Spring的DSL)手动实现添加webApp

1
org.sonar.server.app.TomcatContexts#addContext

等Tomcat部署后,将调用PlatformServletContextListener启动Platform(这里并没有使用Spring作为依赖注入,而是使用了picocontainer实现,使用Java编码而没有用注解/XML等DSL进行描述依赖关系),启动API业务

最终部署如下业务

部署类型ContextPathByWho
API业务/apiWebServiceFilter, 类似于Struts
React静态文件默认是/, 详见sonar.web.contextweb
插件Jar仓库静态文件/deploydata/web/deploy

所有的API请求均可以通过如下位置进行断点分析到业务中

1
org.sonar.server.ws.WebServiceEngine#execute

这样本文的引导作用就达到了,剩下具体业务自行断点分析

我在这里耗费了较多时间,本以为API业务是由Servlet进行处理,没想到居然是通过全手写Filter与Action的方法处理(10年前这种方法很先进),可以看出SonarQube也是有历史债务的,但是它的代码质量经过长期maintain后仍然清晰。

注意项目中的StaticResourcesServlet已经事实上废弃,因为已经没有static文件夹了(但是插件可能会使用一些js/css)

CE如何录入计算结果?

CE的启动流程

1
2
3
4
5
6
7
8
// main入口
org.sonar.ce.app.CeServer#main
// runner入口
// level1 ~ 4 启动依赖注入
org.sonar.ce.ComputeEngineImpl#startup
// 启动定时器
org.sonar.ce.queue.CeQueueInitializer#initCe
org.sonar.server.platform.ServerLifecycleNotifier#notifyStart

最核心的是启动了如下一堆定时器

1
2
3
4
5
6
7
// CeQueueInitializer
private void initCe() {
ceDistributedInformation.broadcastWorkerUUIDs();
processingScheduler.startScheduling();
// 两分钟清理一次
cleaningScheduler.startScheduling();
}

最简单的两分钟清理一次任务: cleaningScheduler

  • resetTasksWithUnknownWorkerUUIDs: 将未知UUID的Work的任务配置为PENDING,这里单机版对应的是重装后删除旧的work任务;分布式版本对应hazelcast中不健康的Work任务自动下线,可以搜索update ce_queue set。这里不会删除Pending长时间的任务。
  • cancelWornOuts: delete所有Pending的任务

假如你部署了多台Sonarqube社区版连接同一台机器,那么Work的UUID由于是基于时间生成,那么将导致相互reset

总流程如下

1
Scanner(client-side)-(HTTP POST Zip)->Web-(MQ)->CE

首先在Scanner通过Maven等工具在Jenkins等平台(占用这些平台的计算资源)计算出项目的各种分析报告,然后Scanner调用Web中如下接口

1
2
3
// http://localhost:9000/api/ce/submit?projectKey=xxx&projectName=yyy
//
org.sonar.server.ce.ws.SubmitAction#handle

WebServer将原始RAW文件录入ce_task_input,接着通过消息队列(DB Based),可以类比为Celery

1
org.sonar.ce.queue.CeQueueImpl#submit(org.sonar.ce.queue.CeTaskSubmit)

CE侧通过线程池每隔两秒轮询查询任务

1
2
org.sonar.ce.taskprocessor.CeWorkerImpl#call
org.sonar.ce.queue.InternalCeQueueImpl#peek

最终任务将通过路由到如下位置,执行数据仓库录入等任务(CeTaskProcessor)

1
org.sonar.ce.task.step.ComputationStepExecutor#executeStep

最终匹配的消费者为ExtractReportStep,进行异步计算并存储到数据仓库

CeTaskTypes.REPORT

ReportTaskProcessor: CeTaskTypes.REPORT

此外还有一个任务
IssueSyncTaskProcessor: CeTaskTypes.BRANCH_ISSUE_SYNC 当重启时,会触发此服务。首先检查sonar.internal.es.disableIndexes,然后检查ELK数据是否损坏,并重新reindex到ELK

  • IgnoreOrphanBranchStep: 通过ce_task的uuid -> project_branches中是否有数据
  • IndexIssuesStep: 通过ce_task的uuid -> project_branches.need_issue_sync判断是否需要同步,然后将 issues/rules/components 的join结果缓存到ELK

思考:为何不直接用MyBatis的缓存注解框架,而是用了ELK?

疑问解答

SonarQube如何实现存储源码?

通过访问表file_sources中的BINARY_DATA与protobuf/LZ4压缩实现存储,它与File通过FILE_UUID进行关联

1
org.sonar.server.source.ws.LinesAction#handle

你在前台查询的issues等信息,实际上就是数据库多张表join查出来的,SQL优化的好,就算没缓存也很快。

SonarQube与Markdown

通过基于正则表达式的规则引擎实现,这个做的比较简单,没有实现AST

1
org.sonar.channel.ChannelDispatcher#consume

SonarQube的React如何实现

前台使用了React与JSX实现业务,使用Webpack进行打包,使用WebPackDevServer作为API代理,前端通过如下启动外壳业务,打包脚本见server/sonar-web/config/webpack.config.js

1
2
3
4
# 启动业务(使用NodeJS提供Server)
node server/sonar-web/scripts/start.js
# 打包(后续交给Tomcat处理)
node server/sonar-web/scripts/build.js

SonarQube如何分析代码AST?

这里采用插件实现,比如Java在这里可以找到,分析后将转为通用格式发给Web进行处理

如果项目组需要定制Custom Rule,就可以通过访问onMethodInvocationFound实现自己的规则

当然更推荐用第三方的report解析,比如sonar-cxx插件,这种就只用解析xml而不用写parser了,这种生态成本反而更低一些

高可用思路

首先不要忘记停机也没啥大不了的,它本质上只属于支持业务。

收费版分析

首先要明确,收费版(最贵的DC版)即使通过分散元数据实现了扩容(Enterprise-scale),也有downtime的问题:Cluster downtime is required for SonarQube upgrades or plugin installations.

基于文档分析如下,不含LB与DB的话,按照200M的issue需求,需要5个节点

  • 其中2个是jar包业务(4U/16G)
  • 3个是Jar包里的ElasticSearch(8U/32G)

这套没什么缺点,除了贵以外。

再次强调,分布式,横向扩容,高可用三者间没有任何覆盖关系。

社区版分析

Sonarqube是单机程序,存在downtime故障。假如想不掏钱实现多机器部署的高可用,那么注意

思路一:仍然单节点,降低重启downtime时间

  • Elastic:内置的全部屏蔽,外移,方案同上。
  • 减少插件与插件中的规则,因为启动时会加载规则到DB中,可以删掉不用的css/c#等插件

思路二:仍然单节点,思路一 + 报告回传加入持久化

新增一套生产消费者(类似Azure Stroage Gateway or Azure Queue Storage),将应用侧基于DB的队列换为云服务。

  • 队列:需要实现file upload queue或者kafka+s3,自己轮训检查后台Pending为空后,从云队列中获取。

思路三:多机Web服务高可用(不推荐)

事实上这套方案意义本身不大,公司级的内部项目,甚至用户粘性没必要做到这么高的运维级别,无状态的Web应用本身CURD很难挂掉,而有状态的CE却难以实现多机部署,导致ROI很低。

需要解决的问题

  • Elastic:内置的全部屏蔽,外移,方案同上。
  • CE: 有状态有定时任务的消费者,需要屏蔽只留一台。分布式方案基本上就靠买了,否则自己做还不如搭建多台url-hash切片用。
  • Web:理论上是无状态/生产者,可以多机部署,本来就是个壳而已。假如插件更新,仍然要全部停机。但是内部黑盒是否有另外坑,尚不明确。
  • 多机的JWT-session问题: 需要用Delegating Authentication/SSO来实现,只用LDAP是不够的。可以配合使用Traefik等网关的filter实现。
  • 升级插件重启中的rules表与缓存刷新:尚未明确是否有lock等暗坑

这种高成本导致还不如用子域名部署多台,因为就算你部署成功,部分场景测试ok。但是一旦升级,你没法证明过程是可信的。

写入场景

  • 通过API写入门禁规则等信息
  • 通过上传报告写入issue信息

高可用且横向扩展

  • 分离部署:不要同时启动三个服务。
  • 前台Web高可用:解决Session问题就是无状态了,可以用ExternalGroupsProvider实现
  • CE高可用:要实现独立部署,不能和web程序混在一起,否则web重启时无法持久化

其它

总结

对个人来说,分析这个平台是有点后悔的----这套代码本身就是数十年的祖传项目,耗费太多时间了,我从2017年时不时就研究下,但是收益却只有苦劳,几次改版后可能就白分析了。
从功利的角度来说,这类CICD的方向太窄,而且基本上只有BAT玩得起这种定制,它本身的LGPL协议也并不友好。

个人看法

  • 不要去折腾内部实现高可用等技术细节,ROI太低,重启也没啥;加钱买买买也可以。
  • 将时间投入在静态检查插件,Wrapper封装等业务中,产生效率价值
  • 分析本身的溢价很低,投入在算法等基础组件上更好。这么搞面试太吃亏了,假如我是雇主,也不会考虑支付sonar插件外的溢价。说得不好听,可能连能面试你的人都找不到。

高可用组网探索

  • Do at your own risk without any community or business support.
  • No scaling implemented: only one compute engine work
  • Upgrade plugins will require a full restart
  • Consume a lot of time.

Prerequisites node

  • clusters(K8S or nomad) or docker exec directly.
  • Traefik or Consul side-car or the other application gateway(DaemonSet) * N
  • External Elastic7 Service * 1
  • External Postgres Service * 1
  • External SSO/SAML Service * 1
  • SonarQube LTS(with computer engine only) * 1
  • SonarQube LTS(with elastic/ce removed) * N

Architecture

$2ClustersTraefik RouterSonarQubeRule:Host(...)MiddlewareSonarQube Web * NSonarQube CE * 1ELBDelegating AuthenticationCAS or SAML SSOElasticSearch7FrontendPostgresX-Forwarded-LoginRESTful APIFiles & IssuesJWT TokensFiles & IssuesJWT TokensRowKeyRowKey

安全加固

参考这里的泄漏事故

  • Dockerfile: 基于LTS镜像
    • 不采用root的gosu启动,卸载无用的apt包
    • 对sonar.properties关键信息使用自带AES工具加密,整体配置chmod400/nobody
    • 移除/审计无用的自带插件,降低攻击面
  • 系统配置
    • 不暴露地址到外网
    • 关闭默认共享项目;配置全局模版,不共享sonar-user
    • 集成SSO/LDAP登陆,并考虑实现群组定时push,基于群组而非用户管理权限
    • admin只开启view与admin权限,不开启view source code功能,安装后专人回收账号