编写一个可复用的SpringBoot应用运维脚本

前提

作为 Java 开发者,很多场景下会使用 SpringBoot 开发 Web 应用,目前微服务主流 SpringCloud 全家桶也是基于 SpringBoot 搭建的。 SpringBoot 应用部署到服务器上,需要编写运维管理脚本。本文尝试基于经验,总结之前生产使用的 Shell 脚本,编写一个可以复用的 SpringBoot 应用运维脚本,从而极大减轻 SpringBoot 应用启动、状态、重启等管理的工作量。本文的 Shell 脚本在 CentOS7 中正常运行,其他操作系统不一定适合。如果对一些基础或者原理不感兴趣可以拖到最后,直接拷贝脚本使用。

依赖到的Shell相关的知识

编写 SpringBoot 应用运维脚本除了基本的 Shell 语法要相对熟练之外,还需要解决两个比较重要的问题(笔者个人认为):

  • 正确获取目标应用程序的进程 ID ,也就是获取 Process ID (下面称 PID )的问题。
  • kill 命令的正确使用姿势。
  • 命令 nohup 的正确使用方式。

获取PID

一般而言,如果通过应用名称能够成功获取 PID ,则可以确定应用进程正在运行,否则应用进程不处于运行状态。应用进程的运行状态是基于 PID 判断的,因此在应用进程管理脚本中会多次调用获取 PID 的命令。通常情况下会使用 grep 命令去查找 PID ,例如下面的命令是查询 Redis 服务的 PID

ps -ef |grep redis |grep -v grep |awk '{print $2}'
复制代码

其实这是一个复合命令,每个 | 后面都是一个完整独立的命令,其中:

  • ps -efps 命令加上 -ef 参数, ps 命令主要用于查看进程的相关状态, -e 代表显示所有进程,而 -f 代表完整输出显示进程之间的父子关系,例如下面是笔者的虚拟机中的 CentOS 7 执行 ps -ef 后的结果:

  • grep XXX 其实就是 grep 对应的目标参数,用于搜索目标参数的结果,复合命令中会从前一个命令的结果中进行搜索。
  • grep -v grep 就是 grep 命令执行时候忽略 grep 自身的进程。
  • awk '{print $2}' 就是对处理的结果取出第二列。

ps -ef |grep redis |grep -v grep |awk '{print $2}' 复合命令执行过程就是:

  • <1> 通过 ps -ef 获取系统进程状态。
  • <2> 通过 grep redis<1> 中的结果搜索 redis 关键字,得出 redis 进程信息。
  • <3> 通过 grep -v grep<2> 中的结果过滤掉 grep 自身的进程。
  • <4> 通过 awk '{print $2}'<3> 中的结果获取第二列。

Shell 脚本中,可以使用这种方式获取 PID

PID=`ps -ef |grep redis-server |grep -v grep |awk '{print $2}'`
echo $PID

但是这样会存在一个问题,就是每次想获取 PID 都必须使用这串非常长的命令,显得有些笨拙。可以使用 eval 简化这个过程:

PID_CMD="ps -ef |grep docker |grep -v grep |awk '{print \$2}'"
PID=$(eval $PID_CMD)
echo $PID

获取 PID 的问题解决,然后可以基于 PID 是否存在,决定一下步怎么操作。

理解kill命令

kill 命令的一般形式是 kill -N PID ,本质功能是向对应 PID 的进程发送一个信号,然后对应的进程需要对这个信号作出响应,信号的编号就是 N ,这个 N 的可选值如下(系统是 CentOS 7 ):

其中开发者常见的就是 9) SIGKILL15) SIGTERM ,它们的一般描述如下:

信号编号 信号名称 描述 功能 影响
15 SIGTERM Termination (ANSI) 系统向对应的进程发送一个 SIGTERM 信号 进程立即停止,或者释放资源后停止,或者由于等待 IO 继续处于运行状态,也就是一般会有一个阻塞过程,或者换一个角度来说就是进程可以阻塞、处理或者忽略 SIGTERM 信号
9 SIGKILL Kill(can't be caught or ignored) (POSIX) 系统向对应的进程发送一个 SIGKILL 信号 SIGKILL 信号不能被忽略,一般表现为进程立即停止(当然也有额外的情况)

不带 -N 参数的 kill 命令默认就是 kill -15 。一般而言, kill -9 PID 是进程的必杀手段,但是它很有可能影响进程结束前释放资源的过程或者中止 I/O 操作造成数据异常丢失等问题。

nohup命令

如果希望在退出账号或者关闭终端后应用进程不退出,可以使用 nohup 命令运行对应的进程。

nohup就是no hang up的缩写,翻译过来就是"不挂起"的意思,nohup的作用就是不挂起地运行命令。

nohup 命令的格式是: nohup Command [Arg...] [&] ,功能是:基于命令 Command 和可选的附加参数 Arg 运行命令,忽略所有 kill 命令中的挂断信号 SIGHUP& 符号表示命令需要在后台运行。

这里注意一点,操作系统中有三种常用的标准流: 0:标准输入流STDIN 1:标准输出流STDOUT 2:标准错误流STDERR

直接运行 nohup Command & 的话,所有的标准输出流和错误输出流都会输出到当前目录 nohup.out 文件,时间长了有可能导致占用大量磁盘空间,所以一般需要把标准输出流 STDOUT 和标准错误流 STDERR 重定向到其他文件,例如 nohup Command 1>server.log 2>server.log & 。但是由于标准错误流 STDERR 没有缓冲区,所以这样做会导致 server.log 会被打开两次,导致标准输出和错误输出的内容会相互竞争和覆盖,因此一般会把标准错误流 STDERR 重定向到已经打开的标准输出流 STDOUT 中,也就是经常见到的 2>&1 ,而标准输出流 STDOUT 可以省略 > 前面的 1 ,所以:

nohup Command 1>server.log 2>server.log &修改为nohup Command >server.log 2>&1 &

然而,更多时候部署 Java 应用的时候,应用会专门把日志打印到磁盘特定的目录中便于 ELK 收集,如笔者前公司的运维规定日志必须打印在 /data/log-center/${serverName} 目录下,那么这个时候必须把 nohup 的标准输出流 STDOUT 和标准错误流 STDERR 完全忽略。一个比较可行的做法就是把这两个标准流全部重定向到"黑洞 /dev/null "中。例如:

nohup Command >/dev/null 2>&1 &

编写SpringBoot应用运维脚本

SpringBoot 应用本质就是一个 Java 应用,但是会有可能添加特定的 SpringBoot 允许的参数,下面会一步一步分析怎么编写一个可复用的运维脚本。

全局变量

考虑到尽可能复用变量和提高脚本的简洁性,这里先提取可复用的全局变量。先是定义 JDK 的位置 JDK_HOME

JDK_HOME="/usr/lib/jvm/java-1.8.0-openjdk-1.8.0.242.b08-0.el7_7.x86_64/bin/java"

接着定义应用的位置 APP_LOCATION

APP_LOCATION="/data/shell/app.jar"

接着定义应用名称 APP_NAME (主要用于搜索和展示):

APP_NAME="app"

然后定义获取 PID 的命令临时变量 PID_CMD ,用于后面获取 PID 的临时变量:

PID_CMD="ps -ef |grep $APP_LOCATION |grep -v grep |awk '{print \$2}'"
// PID = $(eval $PID_CMD)

定义虚拟机属性 VM_OPTS

VM_OPTS="-Xms2048m -Xmx2048m"

定义 SpringBoot 属性 SPB_OPTS (一般用于配置启动端口、应用 Profile 或者注册中心地址等等):

SPB_OPTS="--spring.profiles.active=dev"

主要是这些参数,具体可以按照实际的场景修改或者添加。

编写核心方法

例如脚本的文件是 server.sh ,那么最后需要使用 sh server.sh Command 执行,其中 Command 列表如下:

  • start :启动服务。
  • info :打印信息,主要是共享变量的内容。
  • status :打印服务状态,用于判断服务是否正在运行。
  • stop :停止服务进程。
  • restart :重启服务。
  • help :帮助指南。

这里通过 case 关键字和命令执行时输入的第一个参数确定具体的调用方法。

start() {
 echo "start: start server"
}

stop() {
 echo "stop: shutdown server"
}

restart() {
 echo "restart: restart server"
}

status() {
 echo "status: display status of server"
}

info() {
 echo "help: help info"
}

help() {
   echo "start: start server"
   echo "stop: shutdown server"
   echo "restart: restart server"
   echo "status: display status of server"
   echo "info: display info of server"
   echo "help: help info"
}

case $1 in
start)
    start
    ;;
stop)
    stop
    ;;
restart)
    restart
    ;;
status)
    status
    ;;
info)
    info
    ;;
help)
    help
    ;;
*)
    help
    ;;
esac
exit $?

测试一下:

[root@localhost shell]# sh server.sh 
start: start server
stop: shutdown server
restart: restart server
status: display status of server
info: display info of server
help: help info
......
[root@localhost shell]# sh c.sh start
start: start server

接着需要编写对应的方法实现。

info方法

info() 主要用于打印当前服务的环境变量和服务的信息等等。

info() {
  echo "=============================info=============================="
  echo "APP_LOCATION: $APP_LOCATION"
  echo "APP_NAME: $APP_NAME"
  echo "JDK_HOME: $JDK_HOME"
  echo "VM_OPTS: $VM_OPTS"
  echo "SPB_OPTS: $SPB_OPTS"
  echo "=============================info=============================="
}

status方法

status() 方法主要用于展示服务的运行状态。

status() {
  echo "=============================status==============================" 
  PID=$(eval $PID_CMD)
  if [[ -n $PID ]]; then
       echo "$APP_NAME is running,PID is $PID"
  else
       echo "$APP_NAME is not running!!!"
  fi
  echo "=============================status=============================="
}

start方法

start() 方法主要用于启动服务,需要用到 JDKnohup 等相关命令。

start() {
 echo "=============================start=============================="
 PID=$(eval $PID_CMD)
 if [[ -n $PID ]]; then
    echo "$APP_NAME is already running,PID is $PID"
 else
    nohup $JDK_HOME $VM_OPTS -jar $APP_LOCATION $SPB_OPTS >/dev/null 2>\$1 &
    echo "nohup $JDK_HOME $VM_OPTS -jar $APP_LOCATION $SPB_OPTS >/dev/null 2>\$1 &"
    PID=$(eval $PID_CMD)
    if [[ -n $PID ]]; then
       echo "Start $APP_NAME successfully,PID is $PID"
    else
       echo "Failed to start $APP_NAME !!!"
    fi
 fi  
 echo "=============================start=============================="
}
  • 先判断应用是否已经运行,如果已经能获取到应用进程 PID ,那么直接返回。
  • 使用 nohup 命令结合 java -jar 命令启动应用程序 jar 包,基于 PID 判断是否启动成功。

stop方法

stop() 方法用于终止应用程序进程,这里为了相对安全和优雅地 kill 掉进程,先采用 kill -15 方式,确定 kill -15 无法杀掉进程,再使用 kill -9

stop() {
 echo "=============================stop=============================="
 PID=$(eval $PID_CMD)
 if [[ -n $PID ]]; then
    kill -15 $PID
    sleep 5
    PID=$(eval $PID_CMD)
    if [[ -n $PID ]]; then
      echo "Stop $APP_NAME failed by kill -15 $PID,begin to kill -9 $PID"
      kill -9 $PID
      sleep 2
      echo "Stop $APP_NAME successfully by kill -9 $PID"
    else 
      echo "Stop $APP_NAME successfully by kill -15 $PID"
    fi 
 else
    echo "$APP_NAME is not running!!!"
 fi
 echo "=============================stop=============================="
}

restart方法

其实就是先 stop() ,再 start()

restart() {
  echo "=============================restart=============================="
  stop
  start
  echo "=============================restart=============================="
}

测试

笔者已经基于 SpringBoot 依赖只引入 spring-boot-starter-web 最简依赖,打了一个 Jarapp.jar 放在虚拟机的 /data/shell 目录下,同时上传脚本 server.sh/data/shell 目录下:

/data/shell
  - app.jar
  - server.sh

某一次测试结果如下:

[root@localhost shell]# sh server.sh info
=============================info==============================
APP_LOCATION: /data/shell/app.jar
APP_NAME: app
JDK_HOME: /usr/lib/jvm/java-1.8.0-openjdk-1.8.0.242.b08-0.el7_7.x86_64/bin/java
VM_OPTS: -Xms2048m -Xmx2048m
SPB_OPTS: --spring.profiles.active=dev
=============================info==============================
......
[root@localhost shell]# sh server.sh start
=============================start==============================
app is already running,PID is 26950
=============================start==============================
......
[root@localhost shell]# sh server.sh stop
=============================stop==============================
Stop app successfully by kill -15 
=============================stop==============================
......
[root@localhost shell]# sh server.sh restart
=============================restart==============================
=============================stop==============================
app is not running!!!
=============================stop==============================
=============================start==============================
Start app successfully,PID is 27559
=============================start==============================
=============================restart==============================
......
[root@localhost shell]# curl http://localhost:9091/ping -s
[root@localhost shell]# pong

测试脚本确认执行的结果是正确的。其中的 ================= 是笔者故意加入,如果觉得碍眼可以去掉。

小结

SpringBoot 是目前或者将来一段很长时间 Web 服务中的主流框架,笔者花了一点时间学习 Shell 相关的语法,结合 nohuppsLinux 命令编写了一个可复用的应用运维脚本,目前已经应用在测试和生产环境中,在一定程度上节省了运维成本。

参考资料:

附录

下面是 server.sh 脚本的所有内容:

#!/bin/bash
JDK_HOME="/usr/lib/jvm/java-1.8.0-openjdk-1.8.0.242.b08-0.el7_7.x86_64/bin/java"
VM_OPTS="-Xms2048m -Xmx2048m"
SPB_OPTS="--spring.profiles.active=dev"
APP_LOCATION="/data/shell/app.jar"
APP_NAME="app"
PID_CMD="ps -ef |grep $APP_NAME |grep -v grep |awk '{print \$2}'"

start() {
 echo "=============================start=============================="
 PID=$(eval $PID_CMD)
 if [[ -n $PID ]]; then
    echo "$APP_NAME is already running,PID is $PID"
 else
    nohup $JDK_HOME $VM_OPTS -jar $APP_LOCATION $SPB_OPTS >/dev/null 2>\$1 &
    echo "nohup $JDK_HOME $VM_OPTS -jar $APP_LOCATION $SPB_OPTS >/dev/null 2>\$1 &"
    PID=$(eval $PID_CMD)
    if [[ -n $PID ]]; then
       echo "Start $APP_NAME successfully,PID is $PID"
    else
       echo "Failed to start $APP_NAME !!!"
    fi
 fi  
 echo "=============================start=============================="
}

stop() {
 echo "=============================stop=============================="
 PID=$(eval $PID_CMD)
 if [[ -n $PID ]]; then
    kill -15 $PID
    sleep 5
    PID=$(eval $PID_CMD)
    if [[ -n $PID ]]; then
      echo "Stop $APP_NAME failed by kill -15 $PID,begin to kill -9 $PID"
      kill -9 $PID
      sleep 2
      echo "Stop $APP_NAME successfully by kill -9 $PID"
    else 
      echo "Stop $APP_NAME successfully by kill -15 $PID"
    fi 
 else
    echo "$APP_NAME is not running!!!"
 fi
 echo "=============================stop=============================="
}

restart() {
  echo "=============================restart=============================="
  stop
  start
  echo "=============================restart=============================="
}

status() {
  echo "=============================status==============================" 
  PID=$(eval $PID_CMD)
  if [[ -n $PID ]]; then
       echo "$APP_NAME is running,PID is $PID"
  else
       echo "$APP_NAME is not running!!!"
  fi
  echo "=============================status=============================="
}

info() {
  echo "=============================info=============================="
  echo "APP_LOCATION: $APP_LOCATION"
  echo "APP_NAME: $APP_NAME"
  echo "JDK_HOME: $JDK_HOME"
  echo "VM_OPTS: $VM_OPTS"
  echo "SPB_OPTS: $SPB_OPTS"
  echo "=============================info=============================="
}

help() {
   echo "start: start server"
   echo "stop: shutdown server"
   echo "restart: restart server"
   echo "status: display status of server"
   echo "info: display info of server"
   echo "help: help info"
}

case $1 in
start)
    start
    ;;
stop)
    stop
    ;;
restart)
    restart
    ;;
status)
    status
    ;;
info)
    info
    ;;
help)
    help
    ;;
*)
    help
    ;;
esac
exit $?

原文:编写一个可复用的SpringBoot应用运维脚本 - 掘金