写点什么

Spring Boot Admin 集成诊断利器 Arthas 实践

发布于: 2021 年 02 月 04 日


作者 | 阿提说说

来源|阿里巴巴云原生公众号


前言


Arthas 是 Alibaba 开源的 Java 诊断工具,具有实时查看系统的运行状况;查看函数调用参数、返回值和异常;在线热更新代码;秒解决类冲突问题;定位类加载路径;生成热点;通过网页诊断线上应用。如今在各大厂都有广泛应用,也延伸出很多产品。


这里将介绍如何将 Arthas 集成进 Spring Boot 监控平台中。


SpringBoot Admin


为了方便,SpringBoot Admin 简称为 SBA(版本:1.5.x)。


1.5 版本的 SBA 如果要开发插件比较麻烦,需要下载 SBA 的源码包,再按照 Spring-boot-admin-server-ui-hystrix 的形式 Copy 一份,由于 JS 使用的是 Angular,本人尝试了很久,虽然掌握了如何开发插件,奈何不会 Angular,遂放弃💀



版本:2.x 2.x 版本的 SBA 插件开发,官网有介绍如何开发,JS 使用 Vue,方便很多,由于我们项目还在使用 1.5,所以并没有使用该版本,请读者自行尝试。


不能使用 SBA 的插件进行集成,那还有什么办法呢?😅


SBA 集成


鄙人的办法是将 Arthas 的相关文件直接 Copy 到 Admin 服务中,这些文件都来自 Arthas-all 项目 Tunnel-server。



admin 目录结构


1. Arthas 目录


该包下存放的是所有 Arthas 的 Java 文件。


  • Endpoint 包下的文件可以都注释掉,没多大用。

  • ArthasController 这个文件是我自己新建的,用来获取所有注册到 Arthas 的客户端,这在后面是有用的。

  • 其他文件直接 Copy 过来就行。


@RequestMapping("/api/arthas")@RestControllerpublic class ArthasController { @Autowired private TunnelServer tunnelServer;   @RequestMapping(value = "/clients", method = RequestMethod.GET) public Set<String> getClients() {  Map<String, AgentInfo> agentInfoMap = tunnelServer.getAgentInfoMap();  return agentInfoMap.keySet(); }}
复制代码


spring-boot-admin-server-ui


该文件建在 Resources.META-INF 下,Admin 会在启动的时候加载该目录下的文件。



2. Resources 目录


  • index.html 覆盖 SBA 原来的首页,在其中添加一个 Arthas 导航



<!DOCTYPE html><html class="no-js"><head>    <meta charset="utf-8">    <meta http-equiv="X-UA-Compatible" content="IE=edge">    <title>Spring Boot Admin</title>    <meta name="description" content="">    <meta name="viewport" content="width=device-width">    <link rel="shortcut icon" type="image/x-icon" href="img/favicon.png"/>    <link rel="stylesheet" type="text/css" href="core.css"/>    <link rel="stylesheet" type="text/css" href="all-modules.css"/></head><body><header class="navbar header--navbar desktop-only">    <div class="navbar-inner">        <div class="container-fluid">            <div class="spring-logo--container">                <a class="spring-logo" href="#"><span></span></a>            </div>            <div class="spring-logo--container">                <a class="spring-boot-logo" href="#"><span></span></a>            </div>            <ul class="nav pull-right">                            <!--增加Arthas导航-->                <li class="navbar-link ng-scope">                    <a  class="ng-binding" href="arthas/arthas.html">Arthas</a>                </li>                <li ng-repeat="view in mainViews" class="navbar-link" ng-class="{active: $state.includes(view.state)}">                    <a ui-sref="{{view.state}}" ng-bind-html="view.title"></a>                </li>            </ul>        </div>    </div></header><div ui-view></div><footer class="footer">    <ul class="inline">        <li><a href="https://codecentric.github.io/spring-boot-admin/@project.version@" target="_blank">Reference            Guide</a></li>        <li>-</li>        <li><a href="https://github.com/codecentric/spring-boot-admin" target="_blank">Sources</a></li>        <li>-</li>        <li>Code licensed under <a href="http://www.apache.org/licenses/LICENSE-2.0" target="_blank">Apache License            2.0</a></li>    </ul></footer><script src="dependencies.js" type="text/javascript"></script><script type="text/javascript">  sbaModules = [];</script><script src="core.js" type="text/javascript"></script><script src="all-modules.js" type="text/javascript"></script><script type="text/javascript">  angular.element(document).ready(function () {    angular.bootstrap(document, sbaModules.slice(0), {      strictDi: true    });  });</script></body></html>
复制代码


  • Arthas.html


新建页面,用于显示 Arthas 控制台页面。


这个文件中有两个隐藏文本域,这两个用于连接 Arthas 服务端,在页面加载的时候会自动将 Admin 的 Url 赋值给 Ip。


<input type="hidden" id="ip" name="ip" value="127.0.0.1"><input type="hidden" id="port" name="port" value="19898">
复制代码


<!DOCTYPE html><html class="no-js"><head>    <meta charset="utf-8">    <meta http-equiv="X-UA-Compatible" content="IE=edge">    <title>Spring Boot Admin</title>    <meta name="description" content="">    <meta name="viewport" content="width=device-width">    <link rel="shortcut icon" type="image/x-icon" href="../img/favicon.png"/>    <link rel="stylesheet" type="text/css" href="../core.css"/>    <link rel="stylesheet" type="text/css" href="../all-modules.css"/>    <script src="js/jquery-3.3.1.min.js"></script>    <script src="js/popper-1.14.6.min.js"></script>    <script src="js/xterm.js"></script>    <script src="js/web-console.js"></script>    <script src="js/arthas.js"></script>    <link href="js/xterm.css" rel="stylesheet" />    <script type="text/javascript">        window.addEventListener('resize', function () {            var terminalSize = getTerminalSize();            ws.send(JSON.stringify({ action: 'resize', cols: terminalSize.cols, rows: terminalSize.rows }));            xterm.resize(terminalSize.cols, terminalSize.rows);        });    </script></head><body><header class="navbar header--navbar desktop-only">    <div class="navbar-inner">        <div class="container-fluid">            <div class="spring-logo--container">                <a class="spring-logo" href="#"><span></span></a>            </div>            <div class="spring-logo--container">                <a class="spring-boot-logo" href="#"><span></span></a>            </div>            <ul class="nav pull-right">                <li class="navbar-link ng-scope">                    <a  class="ng-binding" href="arthas.html">Arthas</a>                </li>                <li class="navbar-link ng-scope">                    <a  class="ng-binding" href="../">Applications</a>                </li>                <li class="navbar-link ng-scope">                    <a  class="ng-binding" href="../#/turbine">Turbine</a>                </li>                <li class="navbar-link ng-scope">                    <a  class="ng-binding" href="../#/events">Journal</a>                </li>                <li class="navbar-link ng-scope">                    <a  class="ng-binding" href="../#/about">About</a>                </li>                <li class="navbar-link ng-scope">                    <a  class="ng-binding" href="../#/logout"><i class="fa fa-2x fa-sign-out" aria-hidden="true"></i></a>                </li>            </ul>        </div>    </div></header><div ui-view>    <div class="container-fluid">        <form class="form-inline">            <input type="hidden" id="ip" name="ip" value="127.0.0.1">            <input type="hidden" id="port" name="port" value="19898">            Select Application:            <select id="selectServer"></select>            <button class="btn" onclick="startConnect()" type="button"><i class="fa fa-connectdevelop"></i> Connect</button>            <button class="btn" onclick="disconnect()" type="button"><i class="fa fa-search-minus"></i> Disconnect</button>            <button class="btn" onclick="release()" type="button"><i class="fa fa-search-minus"></i> Release</button>        </form>        <div id="terminal-card">            <div id="terminal"></div>        </div>    </div></div></body></html>
复制代码


  • Arthas.js 存储页面控制的 js


var registerApplications = null;var applications = null;$(document).ready(function () {    reloadRegisterApplications();    reloadApplications();});/** * 获取注册的arthas客户端 */function reloadRegisterApplications() {    var result = reqSync("/api/arthas/clients", "get");    registerApplications = result;    initSelect("#selectServer", registerApplications, "");}/** * 获取注册的应用 */function reloadApplications() {    applications = reqSync("/api/applications", "get");    console.log(applications)}/** * 初始化下拉选择框 */function initSelect(uiSelect, list, key) {    $(uiSelect).html('');    var server;    for (var i = 0; i < list.length; i++) {        server = list[i].toLowerCase().split("@");        if ("phantom-admin" === server[0]) continue;        $(uiSelect).append("<option value=" + list[i].toLowerCase() + ">" + server[0] + "</option>");    }}/** * 重置配置文件 */function release() {    var currentServer = $("#selectServer").text();    for (var i = 0; i < applications.length; i++) {        serverId = applications[i].id;        serverName = applications[i].name.toLowerCase();        console.log(serverId + "/" + serverName);        if (currentServer === serverName) {            var result = reqSync("/api/applications/" +serverId+ "/env/reset", "post");            alert("env reset success");        }    }}function reqSync(url, method) {    var result = null;    $.ajax({        url: url,        type: method,        async: false, //使用同步的方式,true为异步方式        headers: {            'Content-Type': 'application/json;charset=utf8;',        },        success: function (data) {            // console.log(data);            result = data;        },        error: function (data) {            console.log("error");        }    });    return result;}
复制代码


  • Web-console.js


修改了连接部分代码,参考一下。


var ws;var xterm;/**有修改**/$(function () {    var url = window.location.href;    var ip = getUrlParam('ip');    var port = getUrlParam('port');    var agentId = getUrlParam('agentId');    if (ip != '' && ip != null) {        $('#ip').val(ip);    } else {        $('#ip').val(window.location.hostname);    }    if (port != '' && port != null) {        $('#port').val(port);    }    if (agentId != '' && agentId != null) {        $('#selectServer').val(agentId);    }    // startConnect(true);});/** get params in url **/function getUrlParam (name, url) {    if (!url) url = window.location.href;    name = name.replace(/[\[\]]/g, '\\$&');    var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),        results = regex.exec(url);    if (!results) return null;    if (!results[2]) return '';    return decodeURIComponent(results[2].replace(/\+/g, ' '));}function getCharSize () {    var tempDiv = $('<div />').attr({'role': 'listitem'});    var tempSpan = $('<div />').html('qwertyuiopasdfghjklzxcvbnm');    tempDiv.append(tempSpan);    $("html body").append(tempDiv);    var size = {        width: tempSpan.outerWidth() / 26,        height: tempSpan.outerHeight(),        left: tempDiv.outerWidth() - tempSpan.outerWidth(),        top: tempDiv.outerHeight() - tempSpan.outerHeight(),    };    tempDiv.remove();    return size;}function getWindowSize () {    var e = window;    var a = 'inner';    if (!('innerWidth' in window )) {        a = 'client';        e = document.documentElement || document.body;    }    var terminalDiv = document.getElementById("terminal-card");    var terminalDivRect = terminalDiv.getBoundingClientRect();    return {        width: terminalDivRect.width,        height: e[a + 'Height'] - terminalDivRect.top    };}function getTerminalSize () {    var charSize = getCharSize();    var windowSize = getWindowSize();    console.log('charsize');    console.log(charSize);    console.log('windowSize');    console.log(windowSize);    return {        cols: Math.floor((windowSize.width - charSize.left) / 10),        rows: Math.floor((windowSize.height - charSize.top) / 17)    };}/** init websocket **/function initWs (ip, port, agentId) {    var protocol= location.protocol === 'https:'  ? 'wss://' : 'ws://';    var path = protocol + ip + ':' + port + '/ws?method=connectArthas&id=' + agentId;    ws = new WebSocket(path);}/** init xterm **/function initXterm (cols, rows) {    xterm = new Terminal({        cols: cols,        rows: rows,        screenReaderMode: true,        rendererType: 'canvas',        convertEol: true    });}/** 有修改 begin connect **/function startConnect (silent) {    var ip = $('#ip').val();    var port = $('#port').val();    var agentId = $('#selectServer').val();    if (ip == '' || port == '') {        alert('Ip or port can not be empty');        return;    }    if (agentId == '') {        if (silent) {            return;        }        alert('AgentId can not be empty');        return;    }    if (ws != null) {        alert('Already connected');        return;    }    // init webSocket    initWs(ip, port, agentId);    ws.onerror = function () {        ws.close();        ws = null;        !silent && alert('Connect error');    };    ws.onclose = function (message) {        if (message.code === 2000) {            alert(message.reason);        }    };    ws.onopen = function () {        console.log('open');        $('#fullSc').show();        var terminalSize = getTerminalSize()        console.log('terminalSize')        console.log(terminalSize)        // init xterm        initXterm(terminalSize.cols, terminalSize.rows)        ws.onmessage = function (event) {            if (event.type === 'message') {                var data = event.data;                xterm.write(data);            }        };        xterm.open(document.getElementById('terminal'));        xterm.on('data', function (data) {            ws.send(JSON.stringify({action: 'read', data: data}))        });        ws.send(JSON.stringify({action: 'resize', cols: terminalSize.cols, rows: terminalSize.rows}));        window.setInterval(function () {            if (ws != null && ws.readyState === 1) {                ws.send(JSON.stringify({action: 'read', data: ""}));            }        }, 30000);    }}function disconnect () {    try {        ws.close();        ws.onmessage = null;        ws.onclose = null;        ws = null;        xterm.destroy();        $('#fullSc').hide();        alert('Connection was closed successfully!');    } catch (e) {        alert('No connection, please start connect first.');    }}/** full screen show **/function xtermFullScreen () {    var ele = document.getElementById('terminal-card');    requestFullScreen(ele);}function requestFullScreen (element) {    var requestMethod = element.requestFullScreen || element.webkitRequestFullScreen || element.mozRequestFullScreen || element.msRequestFullScreen;    if (requestMethod) {        requestMethod.call(element);    } else if (typeof window.ActiveXObject !== "undefined") {        var wscript = new ActiveXObject("WScript.Shell");        if (wscript !== null) {            wscript.SendKeys("{F11}");        }    }}
复制代码


  • 其他文件

- jquery-3.3.1.min.js 新加 Js

- copy 过来的 js

- popper-1.14.6.min.js

- web-console.js

- xterm.css

- xterm.js


  • bootstrap.yml


# arthas端口arthas:  server:    port: 9898
复制代码


这样子,admin 端的配置完成了。


客户端配置


  • 在配置中心加入配置


#arthas服务端域名arthas.tunnel-server = ws://admin域名/ws#客户端id,应用名@随机值,js会截取前面的应用名arthas.agent-id = ${spring.application.name}@${random.value}#arthas开关,可以在需要调式的时候开启,不需要的时候关闭spring.arthas.enabled = false
复制代码


  • 需要自动 Attach 的应用中引入 Arthas-spring-boot-starter 需要对 Starter 进行部分修改,要将注册 Arthas 的部分移除,下面是修改后的文件。


这里是将修改后的文件重新打包成 Jar 包,上传到私服,但有些应用会有无法加载 ArthasConfigMap 的情况,可以将这两个文件单独放到项目的公共包中。


@EnableConfigurationProperties({ ArthasProperties.class })public class ArthasConfiguration { private static final Logger logger = LoggerFactory.getLogger(ArthasConfiguration.class); @ConfigurationProperties(prefix = "arthas") @ConditionalOnMissingBean @Bean public HashMap<String, String> arthasConfigMap() {  return new HashMap<String, String>(); }}
复制代码


@ConfigurationProperties(prefix = "arthas")public class ArthasProperties { private String ip; private int telnetPort; private int httpPort; private String tunnelServer; private String agentId; /**  * report executed command  */ private String statUrl; /**  * session timeout seconds  */ private long sessionTimeout; private String home; /**  * when arthas agent init error will throw exception by default.  */ private boolean slientInit = false; public String getHome() {  return home; } public void setHome(String home) {  this.home = home; } public boolean isSlientInit() {  return slientInit; } public void setSlientInit(boolean slientInit) {  this.slientInit = slientInit; } public String getIp() {  return ip; } public void setIp(String ip) {  this.ip = ip; } public int getTelnetPort() {  return telnetPort; } public void setTelnetPort(int telnetPort) {  this.telnetPort = telnetPort; } public int getHttpPort() {  return httpPort; } public void setHttpPort(int httpPort) {  this.httpPort = httpPort; } public String getTunnelServer() {  return tunnelServer; } public void setTunnelServer(String tunnelServer) {  this.tunnelServer = tunnelServer; } public String getAgentId() {  return agentId; } public void setAgentId(String agentId) {  this.agentId = agentId; } public String getStatUrl() {  return statUrl; } public void setStatUrl(String statUrl) {  this.statUrl = statUrl; } public long getSessionTimeout() {  return sessionTimeout; } public void setSessionTimeout(long sessionTimeout) {  this.sessionTimeout = sessionTimeout; }}
复制代码


  • 实现开关效果


为了实现开关效果,还需要一个文件用来监听配置文件的改变。


我这里使用的是在 SBA 中改变环境变量,对应服务监听到变量改变,当监听 spring.arthas.enabled 为 true 的时候,注册 Arthas,到下面是代码。


@Componentpublic class EnvironmentChangeListener implements ApplicationListener<EnvironmentChangeEvent> {    @Autowired    private Environment env;    @Autowired    private Map<String, String> arthasConfigMap;    @Autowired    private ArthasProperties arthasProperties;    @Autowired    private ApplicationContext applicationContext;    @Override    public void onApplicationEvent(EnvironmentChangeEvent event) {        Set<String> keys = event.getKeys();        for (String key : keys) {            if ("spring.arthas.enabled".equals(key)) {                if ("true".equals(env.getProperty(key))) {                    registerArthas();                }            }        }    }    private void registerArthas() {        DefaultListableBeanFactory defaultListableBeanFactory = (DefaultListableBeanFactory) applicationContext.getAutowireCapableBeanFactory();        String bean = "arthasAgent";        if (defaultListableBeanFactory.containsBean(bean)) {            ((ArthasAgent)defaultListableBeanFactory.getBean(bean)).init();            return;        }        defaultListableBeanFactory.registerSingleton(bean, arthasAgentInit());    }    private ArthasAgent arthasAgentInit() {        arthasConfigMap = StringUtils.removeDashKey(arthasConfigMap);        // 给配置全加上前缀        Map<String, String> mapWithPrefix = new HashMap<String, String>(arthasConfigMap.size());        for (Map.Entry<String, String> entry : arthasConfigMap.entrySet()) {            mapWithPrefix.put("arthas." + entry.getKey(), entry.getValue());        }        final ArthasAgent arthasAgent = new ArthasAgent(mapWithPrefix, arthasProperties.getHome(),                arthasProperties.isSlientInit(), null);        arthasAgent.init();        return arthasAgent;    }}
复制代码


结束


到此可以愉快的在 SBA 中调式应用了,看看最后的页面。



  • 调式流程



流程如下:


  1. 开启 Arthas

  2. 在 Select Application 中选择应用

  3. Connect 连接应用

  4. DisConnect 断开应用

  5. Release 释放配置文件


一些缺陷:


  • 使用 jar 包的方式引入应用,具有一定的侵略性,如果 Arthas 无法启动,会导致应用也无法启动。

  • 如果使用 Docker,需要适当调整 JVM 内存,防止开启 Arthas、调试的时候,内存炸了。

  • 没有使用 SBA 插件的方式集成如上集成仅供参考,请根据自己企业的情况来集成。


Arthas 有奖征文正在进行中!


为了让更多开发者开始用上 Arthas 这个 Java 诊断神器,Arthas 社区联合 JetBrains 推出 Arthas 有奖征文活动聊聊这些年你和 Arthas 之间的那些事儿。活动仍在火热进行中,[点击即可参与](http://alibabacloud.mikecrm.com/9khcRrs),欢迎大家踊跃投稿,参与即有可能获奖!


发布于: 2021 年 02 月 04 日阅读数: 25
用户头像

阿里巴巴云原生 2019.05.21 加入

还未添加个人简介

评论

发布
暂无评论
Spring Boot Admin 集成诊断利器 Arthas 实践