研发效能 jacoco dump 基于 k8s 的实现

dray · 2023年04月28日 · 最后由 dray 回复于 2023年05月17日 · 9437 次阅读

问题描述

总所周知,jacoco 的 dump 操作如果是使用 server 模式只需要使用以下命令就能获取到 exec 文件

java -jar jacococli.jar dump --address 192.169.110.1 --port 6300 --destfile ./jacoco-demo.exec

如果是非 k8s 的集群,也只需要遍历执行这条命令即可,但是对于 k8s 服务的处理有有点力所不逮
当我们使用 k8s 部署服务后,应用实例将会无状态话,用户不再去关心实例的 ip,端口等信息,service 自动会帮我们做负载均衡等操作,pod 不会暴露出 ip 和端口等信息给集群外部访问,这样对我们的 dump 操作带来了困难。

问题解决

针对上述问题,网络上也有一些解决方案,最常用的方式是切换 jacooc server 模式为 client 模式,这样当 jvm 关闭时就会将 dump 数据写入指定服务的文件里。虽然能从一定程度解决问题,但是这样生成报告的节奏就会被打断,就不能随时生成报告了,这里提供一种解决方式。
首先,我们还是采用 server 模式,在服务启动时注入

-javaagent:/jacoco/agent/jacocoagent.jar=includes=*,output=tcpserver,port=6300,address=0.0.0.0

然后,当我们想要去获取 exec 文件时,可以在 pod 中执行

java -jar /jacoco/agent/jacococli.jar dump --address 127.0.0.1 --port 36300 --destfile /app/jacoco.exec

然后我们从 pod 读取文件/app/jacoco.exec 写入我们的报告生成服务即可
怎么去 pod 内部执行 shell 命令,各种手动都有,这里我们 java 基于一个 k8s 的 sdk 工具 fabric8 实现

public List<String> dumpK8sExecData(K8sDumpParam k8sDumpParam) {
    try {
        String dumpCmd = "JAVA_TOOL_OPTIONS=\"\" java -jar /jacoco/agent/jacococli.jar dump --address 127.0.0.1 --port 6300 --destfile /app/jacoco.exec";
        if (k8sDumpParam.getResetFlag()) {
            dumpCmd += " --reset";
        }
        String[] cmd = {"sh", "-c", dumpCmd};
        K8sCmdParam k8sCmdParam = OrikaMapperUtils.map(k8sDumpParam, K8sCmdParam.class);
        k8sCmdParam.setCmd(cmd);
        k8sCmdParam.setExecutor(executor);
        return executeCmd(k8sCmdParam);
    } catch (Exception e) {
        log.error("dump操作失败,失败原因:", e);
        throw new BizException(BizCode.JACOCO_DUMP_ERROR);
    }
public List<String> executeCmd(K8sCmdParam k8sCmdParam) {
    KubernetesClient client = K8sClientProxy.getOrCreateClient(k8sCmdParam.getKubeConfig());
    if (client == null || k8sCmdParam.getNameSpace() == null || CollectionUtil.isEmpty(k8sCmdParam.getPodList())) {
        throw new BizException(BizCode.JACOCO_DUMP_PARAM_ERROR);
    }
    List<CompletableFuture<String>> priceFuture = k8sCmdParam.getPodList().stream().map(pod ->
            CompletableFuture.supplyAsync(() -> {
                String filename = "";
                // 异步操作
                dumpFileService.podExec(pod, k8sCmdParam.getCmd(), k8sCmdParam.getNameSpace(), client);
                try {
                    //中间等待文件写入一段时间,再去尝试获取
                    Thread.sleep(1000);
                    filename = dumpFileService.downloadFile(pod, k8sCmdParam.getNameSpace(), client, k8sCmdParam.getTaskWorkspace());
                } catch (Exception e) {
                    throw new BizException(BizCode.DUMP_FILE_GET_ERROR);
                }
                return filename;
            }, k8sCmdParam.getExecutor())
    ).collect(Collectors.toList());
    // 等待所有异步操作完成,多个pod并发执行以上操作,减少dump的时间消耗
    CompletableFuture.allOf(priceFuture.toArray(new CompletableFuture[0])).join();
    return priceFuture.stream().map(CompletableFuture::join).filter(Objects::nonNull).collect(Collectors.toList());
}

/**
 * 执行单个pod命令
 *
 * @param podName   pod名字
 * @param cmd       cmd
 * @param namespace 名称空间
 * @param client    客户端
 */
public void podExec(String podName, String[] cmd, String namespace, KubernetesClient client) {
    try (ExecWatch watch = client.pods().inNamespace(namespace)
            .withName(podName)
            .redirectingOutput()
            .exec(cmd)) {
    }
}


/**
 * 获取文件
 *
 * @param podName   pod名字
 * @param namespace 名称空间
 * @param client    客户端
 * @param workspace 工作空间
 */
@Retryable(value = {IOException.class}, backoff = @Backoff(delay = 1000))
public String downloadFile(String podName, String namespace, KubernetesClient client, String workspace) throws IOException {
    try (InputStream is = client.pods().inNamespace(namespace)
            .withName(podName)
            .file("/app/jacoco.exec").read()) {
        String execPath = workspace + "/exec/" + podName + "/jacoco.exec";
        FileUtil.writeFromStream(is, execPath);
        return execPath;
    }
}

这里有两个细节点

  • Thread.sleep(1000) 操作,是因为执行 dump 命令后,我们无法判定 exec 文件什么时候能在本地生成完成,立马获取就会抛出 IO 异常,等待一定时间后即可获取到文件,这个时间的等待只是第一层保障,具体等待时间,可以视自己的 dump 文件大小调整,当然哪怕没调整也没有关系
  • @Retryable(value = {IOException.class}, backoff = @Backoff(delay = 1000)) 这段代码是使用了 spring 的一个重试框架,当文件获取失败后,默认会重试 3 次,每次重试间隔 1 秒,这是获取文件的第二步保障,用户可以通过调整重试次数来减少文件获取失败风险

这里说明下 spring Retryable 必须在 public 方法上,而且调用它的方法不能和他处于同一个类,否则不会生效重试。
通过以上手段就可以主动去 dump 出想要的数据,当然更好的方式是判断 exec 文件是否存在,或者还在写入中,等写入完成再去获取文件,这个操作也可以通过 shell 去完成,本文只是提供一种实现方案。
更多 jacoco 相关姿势可以参考这里

共收到 9 条回复 时间 点赞

我们对于 k8s 里 dump exec 文件的方式是,pod 中映射对外的 jacocoagent 的 port,再从 eureka 上获取应用 ip。有了 ip 和 port,剩下的就跟普通的玩法一样了。

dray #2 · 2023年04月29日 Author
zhou 回复

映射 port 端口必然会面临端口维护的问题,对于一些没有 cmdb 平台的场景,只能靠盲猜端口是否被占用不是太可取

其实,以 client 模式运行时,调用方也就是 server 端可以自主决定什么时候让 client 来 dump,而不是非要等到程序退出。官方的 example 中是有例子的,也有 server 的例子,改造一下,然后写个定时线程,也能达到目的。
org.jacoco.examples/src/org/jacoco/examples/ExecutionDataServer.java
可以在你需要的地方 remoteControlWriter.visitDumpCommand(true, false);
或者自行扩展

原来是 dray 大佬,😂 班门弄斧了

dray #5 · 2023年05月04日 Author
小狄子 回复

额,客气了,思路很多种,能实现就行,初次混 testerHome,多多关照

然后我们从 pod 读取文件/app/jacoco.exec 写入我们的报告生成服务即可

请问大佬从 pod 读取 exec 文件是如何实现的?

dray #7 · 2023年05月16日 Author
tester 回复

代码已经写得很清楚了,就是在 pod 里面执行 file 命令读取文件

可以尝试使用下 sidercar ,服务启动的时候加载 jacocoagnet ,然后暴露一个统一的端口,直接通过 pod 的 IP 加端口 和 ecs 一样的方式去 dump 覆盖率文件

dray #9 · 2023年05月17日 Author
Paker 回复

目前 agent 的挂载确实是这么实现的,只是 dump 的方式不一样,覆盖平台与业务集群不在一个集群,可以管理多个集群,所以用了这种方式去 dump

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册