一、前言
最近老大让我负责了一个关于部署服务的功能,旨在方便同事可以发布自己负责的服务,同时可以让没有权限对服务器进行操作的同事不再频繁的发出吼叫:“哥,我接口没通,帮我看下日志!!”。大体先介绍一下背景,因为项目是分布式集群上的,光服务就二十多个,所以每次发布服务到生产环境就成了头号难题,在和老大讨论了好久之后终于决定要啃一下这个骨头。
大概的一个流程是,进入web管理页面,选择命令,后台接受之后运行linux命令。
二、演示

这个是从eureka中获取的注册的服务列表,点击详情可以查看该服务部署在了哪几台机器上,如下

可以对单个服务器上的服务进行操作,例如可以启动服务,停止服务,重启服务,更新也就是更新target,也就相当于重新发布服务,强制停止,还可以查看日志,查看日志放在最下面。
好了接下了开搞!
三、在java中运行linux命令
在接收到前端页面发来的请求时,比如现在我想启动我的服务器
业务逻辑
/**
* 执行启动命令
*
* @param cmdName 命令名称
* @param service 服务名称
* @return
*/
@Override
public String execCmdOfStart(String cmdName, String service) {
String s = "";
try {
//service为服务名
service = service.split("-")[1].toLowerCase();
logger.info("当前操作的服务名称为========>" + service);
//此时path是我从数据库中读取的在linux中存储的shell脚本的路径
String path = eurekaServiceMapper.getShellCmd("path");
//getJarName()方法是用来获取指定target目录下的jar包的名称,如此一来方便运行不同的jar包
String jarName = getJarName(path, service);
if (StringUtils.isEmpty(jarName) && StringUtils.isBlank(jarName)) {
return "服务启动jar包为空";
}
logger.info("执行服务jar包名称为:" + jarName);
path = path + "starter.sh";//shell脚本名称
logger.info("执行脚本的路径为=======>" + path);
String jvm = eurekaServiceMapper.getShellCmd("jvm");
logger.info("执行jar包内存设置为:" + jvm);
String[] command = new String[]{path};
String[] param = new String[]{jvm, service, jarName};//命令集合
/*解决参数中包含空格*/
command = (String[]) ArrayUtils.addAll(command, param);
logger.info("要调用启动服务shell脚本为" + Arrays.toString(command));
logger.info("解决脚本没有执行权限逻辑start");
//解决脚本没有执行权限
String chmod = "chmod 777 " + path;
Process process = Runtime.getRuntime().exec(chmod);
process.waitFor();
logger.info("解决脚本没有执行权限逻辑end");
ProcessStatus processStatus = execShell(command);
s = processStatus.output;
return s;
} catch (Exception e) {
logger.error("执行启动命令异常====>" + e);
return "执行启动命令异常====>" + e;
}
}
上方代码只是一下简单的业务中用到的,不必深究,下面代码才是真正处理命令的
通过ProcessBuilder进行调度
/**
* 执行shell命令(通过ProcessBuilder进行调度)
*
* @param command
* @return
*/
public ProcessStatus execShell(String[] command) {
logger.info("execShell======>" + Arrays.toString(command));
try {
ProcessBuilder builder = new ProcessBuilder(command);
builder.redirectErrorStream(true);//将getInputStream(),getErrorStream()两个流合并,自动清空流
Process process = builder.start();
Worker worker = new Worker(process);
worker.start();
ProcessStatus status = worker.getProcessStatus();
try {
worker.join(20000); //设置20s超时
if (status.exitCode == ProcessStatus.CODE_STARTED){
//未结束
worker.interrupt();//中断
throw new InterruptedException();
}else {
return status;
}
}catch (InterruptedException e){
worker.interrupt();
throw e;
}finally {
process.destroy();
}
} catch (Exception e) {
logger.error("执行shell命令异常====>" + e);
}
return null;
}
因为通过Process进行调度的时候回创建子进程,而创建的子进程是没有自己的终端或者控制器的,它的所有标准io都是通过三个流(getOutputStream()、getInputStream() 和 getErrorStream())来重定向到父进程的,父进程使用这些流来提供到子进程的输入和获得从子进程的输出。因为有些本机平台仅针对标准输入和输出流提供有限的缓冲区大小,如果读写子进程的输出流或输入流迅速出现失败,则可能导致子进程阻塞,甚至产生死锁。所以在处理标准输入流的时候同时也要处理标准错误流的。
解决死锁问题
/**
* 设置超时
*/
private static class Worker extends Thread{
private final Process PROCESS;
private ProcessStatus processStatus;
private Worker(Process process){
this.PROCESS = process;
this.processStatus = new ProcessStatus();
}
@Override
public void run() {
BufferedReader bufferedReader = null;
String line = "";
try {
bufferedReader = new BufferedReader(new InputStreamReader(PROCESS.getInputStream(), "UTF-8"));
StringBuffer sb = new StringBuffer();
while ((line = bufferedReader.readLine()) != null){
sb.append(line).append("<br>");
}
processStatus.output = sb.toString();
processStatus.exitCode = PROCESS.waitFor();
}catch (Exception e){
}
}
public ProcessStatus getProcessStatus(){
return this.processStatus;
}
}
public static class ProcessStatus{
public static final int CODE_STARTED =-257;
public volatile int exitCode;
public volatile String output;
}
当然还有另一种调度方式即Process pro = Runtime.getRuntime().exec(new String[]{"sh",***});
,但这个已经是好久的了,而且我用的时候有时候运行命令会导致死锁,有点搞不清楚,所以我用了比较简洁的 ProcessBuilder
进行调度。
四、实时查看日志
这里参考的文章:https://blog.csdn.net/xiao__gui/article/details/50041673
在Linux操作系统中,经常需要查看日志文件的实时输出内容,通常会使用tail -f或者tailf命令。查看实时日志可能会需要首先SSH连上Linux主机,步骤很麻烦不说,如果是生产环境的服务器,可能还会控制各种权限。基于Web的实时日志可以解决这个问题。
由于传统的HTTP协议是请求/响应模式,而实时日志需要不定时的持续的输出,由服务器主动推送给客户端浏览器。所以这里使用的是HTML5的WebSocket协议。
准备
首先引入Spring websocket支持
<!-- spring websocket-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
实现
配置WebsocketConfig
/**
* create by zwx on 2019/12/4
*/
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter(){
return new ServerEndpointExporter();
}
}
注解@ServerEndpoint
,并需要指定一个路径,用于处理客户端WebSocket请求。
/**
* create by zwx on 2019/12/4
*/
@ServerEndpoint(value = "/log" , configurator = HttpSessionConfigurator.class)
@Component
public class LogWebSocketHandle implements BaseCommonInterFace {
private final Logger logger = getLogger();
private Process process;
private InputStream inputStream;
@Autowired
private EurekaServiceMapper eurekaServiceMapper;
/*解决@component下@Autowired无法取值的问题*/
private static LogWebSocketHandle logWebSocketHandle;
@PostConstruct
public void init(){
logWebSocketHandle = this;
logWebSocketHandle.eurekaServiceMapper = this.eurekaServiceMapper;
}
/**
* webSocket请求开启
* @param session
*/
@OnOpen
public void onOpen(Session session, EndpointConfig config)
{
// HttpSession httpSession = (HttpSession) config.getUserProperties().get(HttpSession.class.getName());
logger.info("====websocket连接建立=========");
}
/**
* webSocket接收到消息
*/
@OnMessage
public void onMessage(String message,Session session){
String tailLog = logWebSocketHandle.eurekaServiceMapper.getShellCmd("tailLog");
JSONObject object = JSON.parseObject(message);
String service = (String) object.get("service");
service = service.split("-")[1].toLowerCase();
tailLog=tailLog+"igs-"+service+"-log.log";
logger.info("======日志命令====>"+tailLog);
String[] cmdShell = new String[]{"/bin/sh","-c",tailLog};/*linux*/
// String[] cmdShell = new String[]{"cmd","/c",tailLog}; //windows
try {
//执行tail命令
process = Runtime.getRuntime().exec(cmdShell);
inputStream = process.getInputStream();
//开启新线程,防止InputStream阻塞处理WebSocket的线程
TailLogThread logThread = new TailLogThread(inputStream,session);
logThread.start();
}catch (Exception e){
e.printStackTrace();
}
}
/**
* websocket请求关闭
*/
@OnClose
public void onClose(){
logger.info("====websocket连接关闭=========");
try {
if (inputStream != null)
inputStream.close();
}catch (Exception e){
e.printStackTrace();
}
if (process != null){
process.destroy();
}
}
}
这里可能会出一个bug,即component下@Autowired无法取值的问题,已经解决了,就在上方代码中
由于针对每个WebSocket连接都会创建一个新的LogWebSocketHandle实例,所以可以不用像Servlet一样考虑线程安全问题。由于tail -f
命令的输入流会阻塞当前线程,所以一定要创建一个新的线程来读取tail -f
命令的返回结果:
/**
* 创建一个新的线程来读取返回的日志
*/
public class TailLogThread extends Thread{
private BufferedReader bufferedReader;
private Session session;
public TailLogThread(InputStream in, Session session){
this.bufferedReader = new BufferedReader(new InputStreamReader(in));
this.session = session;
}
@Override
public void run() {
String line;
try {
// StringBuffer sb = new StringBuffer();
// int count = 0;
// int lineNum = 1;
while ((line = bufferedReader.readLine()) != null){
/*对日志进行限流*/
// if (count == 100){
// session.getBasicRemote().sendText(sb.toString());
Thread.sleep(40);
session.getBasicRemote().sendText(line+"<br/>");
// count = 0;
// }else {
// sb.append(line).append("<br>");
// count++;
// lineNum++;
// }
}
}catch (Exception e){
e.printStackTrace();
}
}
}
web前端页面
Web前端需要通过WebSocket连接到服务端,实时接收最新的日志内容并展示到页面上。
var ws = new WebSocket('ws://'+addr+'/log');//初始化websocket对象
//连接成功的回调
ws.onopen = function (ev) {
var message = {
"addr":addr,
"service":service
};
ws.send(JSON.stringify(message));
$("#log-container div").append("连接成功!");
console.log("连接成功!")
};
//连接错误的回调
ws.onerror = function (ev) {
$("#log-container div").append("连接发生错误!");
console.log("连接发生错误!")
};
//连接关闭的回调
ws.onclose = function (ev) {
$("#log-container div").append("连接关闭!");
console.log("连接关闭!")
};
//接受到消息的回调
ws.onmessage = function (ev) {
// 接收服务端的实时日志并添加到HTML页面中(error显示红色)
if (ev.data.search("ERROR") != -1) {
$("#log-container div").append(ev.data).css("color", "#AA0000");
} else {
$("#log-container div").append(ev.data).css("color", "#aaa");
}
// 滚动条滚动到最低部
var scrollHeight = $("#modalBody").prop("scrollHeight");
$("#modalBody").animate({scrollTop:scrollHeight},1);//4秒落下
};
html
<!-- 日志.模态框 -->
<div class="modal fade bs-example-modal-lg" id="logModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" style="position: absolute;top:0;bottom:0;left: 0;right:0;width: 80%;height: 80%" role="document">
<div class="modal-content" style="position: absolute;top: 0;bottom: 0;width: 100%">
<div class="modal-header">
<button type="button" class="close" style="margin-right: 15px" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
<h4 style="font-size: 25px">实时日志</h4>
</div>
<div class="modal-body" style="overflow-y: scroll;overflow-x:hidden;position: absolute;top: 80px;bottom: 20px;width: 100%" id="modalBody">
<div id="log-container" style="height: auto; background: #333; padding: 50px 10px 10px 10px;">
<div>
</div>
</div>
</div>
</div>
</div>
</div>