本文基于 8.9 LTS 版本,文档地址 https://docs.sonarqube.org/8.9/ 代码地址 https://github.com/SonarSource/sonarqube/tree/branch-8.9

目前SonarQube的社区版是只支持单机部署的,支持集群部署的只有最贵的DataCenter版,但厂里的规模,单机完全扛不住,也不符合高可用的要求,但国内的国情,也不可能花钱去买DataCenter版,于是只好看看有什么曲线救国的路子。

强行将SonarQube以集群模式的配置启动,会在日志里看到有一些报错,就知道有一些暗桩,这里直接总结一下。

SonarQube暗桩的大体架构

SonarQube在8.9版本还是用的Pico 作为DI框架,这个框架挺小众的,最好有一些了解。

而暗桩注入则是用了JDK自带的ServiceLoader的SPI机制,通过 org.sonar.core.extension.CoreExtension 这个service进行注入,社区版只有加载service的代码,但没有具体实现,需要自行反编译商业版的逻辑,商业版中对内核引擎部分的改动,都是通过这个机制进行注入的。

org.sonar.server.platform.ClusterFeature

需要有一个插件实现这个接口,这个接口很简单,就是 boolean isEnabled(); 这样一个简单的接口,直接写死为 return true; 就好了。

package com.example.sonarqube;

import org.sonar.server.platform.ClusterFeature;

public class MyClusterFeature implements ClusterFeature {
    public boolean isEnabled() {
        return true;
    }
}

org.sonar.process.cluster.hz.HazelcastMember

另一个暗桩比较麻烦,是缺少这个类的实现,虽然hazelcast只在app节点使用,但却在所有节点都需要被注入这个对象。反编译并美化变量名的大体代码为:

package com.example.sonarqube;

import com.hazelcast.cluster.Cluster;
import com.hazelcast.cluster.MemberSelector;
import com.hazelcast.cp.IAtomicReference;
import org.jetbrains.annotations.NotNull;
import org.sonar.api.Startable;
import org.sonar.api.ce.ComputeEngineSide;
import org.sonar.api.config.Configuration;
import org.sonar.api.server.ServerSide;
import org.sonar.process.NetworkUtils;
import org.sonar.process.ProcessId;
import org.sonar.process.ProcessProperties;
import org.sonar.process.cluster.hz.DistributedAnswer;
import org.sonar.process.cluster.hz.DistributedCall;
import org.sonar.process.cluster.hz.DistributedCallback;
import org.sonar.process.cluster.hz.HazelcastMember;
import org.sonar.process.cluster.hz.HazelcastMemberBuilder;
import org.sonar.process.cluster.hz.InetAdressResolver;

import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.locks.Lock;

@ComputeEngineSide
@ServerSide
public class MyHazelcastMember implements HazelcastMember, Startable {
    private static final String WEB_PORT = "sonar.cluster.node.web.port";

    private static final String CE_PORT = "sonar.cluster.node.ce.port";

    private final Configuration configuration;

    private final NetworkUtils networkUtils;

    private HazelcastMember hzMember = null;

    public MyHazelcastMember(Configuration configuration, NetworkUtils networkUtils) {
        this.configuration = configuration;
        this.networkUtils = networkUtils;
    }

    public <E> IAtomicReference<E> getAtomicReference(@NotNull String name) {
        return getHzMember().getAtomicReference(name);
    }

    public <K, V> Map<K, V> getReplicatedMap(@NotNull String name) {
        return getHzMember().getReplicatedMap(name);
    }

    public UUID getUuid() {
        return getHzMember().getUuid();
    }

    public Set<UUID> getMemberUuids() {
        return getHzMember().getMemberUuids();
    }

    public Lock getLock(@NotNull String name) {
        return getHzMember().getLock(name);
    }

    public long getClusterTime() {
        return getHzMember().getClusterTime();
    }

    public Cluster getCluster() {
        return getHzMember().getCluster();
    }

    public <T> DistributedAnswer<T> call(@NotNull DistributedCall<T> callable, @NotNull MemberSelector memberSelector, long timeoutMs) throws InterruptedException {
        return getHzMember().call(callable, memberSelector, timeoutMs);
    }

    public <T> void callAsync(@NotNull DistributedCall<T> callable, @NotNull MemberSelector memberSelector, @NotNull DistributedCallback<T> callback) {
        getHzMember().callAsync(callable, memberSelector, callback);
    }

    private HazelcastMember getHzMember() {
        return Objects.requireNonNull(this.hzMember, "Hazelcast member not started");
    }

    public void close() {
        if (this.hzMember != null) {
            this.hzMember.close();
            this.hzMember = null;
        }
    }

    public void start() {
        if (!isClusterEnabled())
            return;
        ProcessId processId = ProcessId.fromKey(this.configuration.get("process.key").orElseThrow(() -> new IllegalStateException("Missing process key")));
        String nodeHost = this.configuration.get(ProcessProperties.Property.CLUSTER_NODE_HOST.getKey()).orElseThrow(() -> new IllegalStateException("Missing node host"));
        this.hzMember = (new HazelcastMemberBuilder(new InetAdressResolver()))
                .setNodeName(this.configuration.get(ProcessProperties.Property.CLUSTER_NODE_NAME.getKey()).orElseThrow(() -> new IllegalStateException("Missing node name")))
                .setPort(getPort(processId, nodeHost))
                .setProcessId(processId)
                .setMembers(Arrays.asList(this.configuration.getStringArray(ProcessProperties.Property.CLUSTER_HZ_HOSTS.getKey())))
                .setNetworkInterface(nodeHost)
                .build();
    }

    private int getPort(ProcessId processId, String hostOrAddress) {
        switch (processId) {
            case WEB_SERVER:
                return fixPortIfZero(WEB_PORT, hostOrAddress);
            case COMPUTE_ENGINE:
                return fixPortIfZero(CE_PORT, hostOrAddress);
            default:
                throw new IllegalStateException(String.format("Incorrect processId [%s]", processId.getKey()));
        }
    }

    private int fixPortIfZero(String portKey, String hostOrAddress) {
        Optional<Integer> optional = this.configuration.getInt(portKey);
        return optional.orElseGet(() -> this.networkUtils.getNextAvailablePort(hostOrAddress).orElseThrow(() -> new IllegalStateException(String.format("Failed to find a free port for property '%s' for configured host: %s", portKey, hostOrAddress))));
    }

    private boolean isClusterEnabled() {
        Optional<Boolean> optional = this.configuration.getBoolean(ProcessProperties.Property.CLUSTER_ENABLED.getKey());
        return (optional.isPresent() && optional.get() == Boolean.TRUE);
    }

    public void stop() {
        close();
    }
}

注入

增加下面的文件作为注入入口

package com.example.sonarqube;

import org.sonar.core.extension.CoreExtension;
import org.sonar.api.SonarQubeSide;

public class ClusterExtension implements CoreExtension {
    @Override
    public String getName() {
        return "cluster";
    }

    @Override
    public void load(Context context) {
        if (context.getRuntime().getSonarQubeSide() == SonarQubeSide.COMPUTE_ENGINE) {
            context.addExtension(MyHazelcastMember.class);
        } else if (context.getRuntime().getSonarQubeSide() == SonarQubeSide.SERVER) {
            context.addExtensions(MyClusterFeature.class, MyHazelcastMember.class);
        }
    }
}

在resources目录里新增 META-INF/services/org.sonar.core.extension.CoreExtension 文件,内容为 com.example.sonarqube.ClusterExtension

需要将这些类和文件,都追加到SonarQube官方的jar文件中,因为classpath是写死的,当然修改启动脚本去增加classpath也是可以的。

这样一套操作下来,就能以社区版的代码,启动集群模式了。

其实社区的branch插件里要求注入的javaagent,也是做了类似的事情,只不过是通过javaagent的方式进行修改。

ElasticSearch的配置

其实集群模式,主要也就分成了两类节点,app和search,其中app节点上运行两种任务,web和computeengine,都是无状态的,运维比较简单,search节点上其实就是跑了个pre-configured的ElasticSearch节点,所以如果有托管版的ES可以用,完全可以在app的配置文件里将请求指向托管版额ES,而不用去弄什么search节点,因为SonarQube对search节点的配置其实是有问题的,主要体现在 cluster.initial_master_nodes 这个配置项上。

ES的文档,这个配置项只应该在ES集群第一次搭建时使用,因为会要求里面配置的所有节点都存活且正常参与投票,集群一旦搭建成功,这个配置项除了产生干扰,就没有任何正向作用了。

但SonarQube的配置,却是始终将所有search节点都加入到这个配置项中,就要求所有search节点都必须100%存活,这对运维要求实在太高而且完全没有必要。

这块只能修改SonarQube的代码 org.sonar.application.es.EsSettings 这个类,我是新增了 sonar.cluster.es.initial_master_nodes 这样一个配置项来单独配置解决这个问题。

编译

从上面能看到,其实有一些改动是需要修改SonarQube代码的,但如果用了maven工程的形式,就势必要解决编译问题,但改动的类都是没有官方maven依赖的,虽然本地能添加一个虚假的maven依赖,但如果要交接给其他人维护终究是不方便,最终我是采用了,将SonarQube官方的fat-jar文件直接作为三方库的方式上传到厂里的maven仓库来解决的

mvn deploy:deploy-file -DgroupId=org.sonarsource.sonarqube -DartifactId=sonarqube-community -Dversion=8.9.7-SNAPSHOT -Dpackaging=jar -Dfile=sonar-application-8.9.7.52159.jar -DrepositoryId=snapshots -Durl=http://xxxxx/mvn/snapshots

这样就可以在maven项目中引入如下依赖来解决编译问题了

<dependency>
    <groupId>org.sonarsource.sonarqube</groupId>
    <artifactId>sonarqube-community</artifactId>
    <version>8.9.7-SNAPSHOT</version>
    <scope>provided</scope>
</dependency>