K8S SpringBoot 微服务启动脚本

283 阅读2分钟

SpringBoot 应用启动参数

  1. 设置 JVM 垃圾收集器相关参数:年轻代使用 ParNew,老年代使用 CMS;新生代占堆空间的 1/3,老年代占堆空间的 2/3;在老年代使用阈值达到 65% 时,触发 CMS 垃圾回收;在 CMS 执行 Remark 阶段之前执行一次 Scavenge(新生代的垃圾回收)。
  2. 设置 JVM 堆内存:K8S 容器环境,读取 cgroup memory.limit_in_bytes:/sys/fs/cgroup/memory/memory.limit_in_bytes,将 JVM max_heap_size 设置为容器 memory.limit * 3 / 4。
  3. 设置垃圾收集器线程数:K8S 容器环境,读取 cgroup cpu cpu_cfs_quota_us:/sys/fs/cgroup/cpu/cpu.cfs_quota_us 和 cpu_cfs_period_us:/sys/fs/cgroup/cpu/cpu.cfs_period_us,cpu_cfs_quota_us / cpu_cfs_period_us 可得到容器分配的 CPU 核数。ParNew 新生代垃圾回收会 Stop the world,设置 ParNew 线程数等于 CPU 核数,或者是 CPU 核数 + 1,尽力而为。CMS 老年代收收集器需要与用户线程并发执行,设置 CMS 线程数等于 CPU 核数 * 3 / 4,留一些 CPU 资源给业务线程。
  4. 发生 OOM 时,执行 heap dump 操作,将 heapdump.hprof 堆转存文件保存到外部挂载的存储卷中。
  5. 环境变量 JVM_GC_LOG_ENABLE 为 ON 时(默认关闭),配置 JVM GC 日志相关参数,方便问题排查。为了防止 gc 日志重名,日志文件名中添加 %t 时间占位符(精确到秒)和 Pod 的 HOSTNAME:gc-%t-${HOSTNAME}.log。
  6. 环境变量 TRACE_ENABLE 不为 OFF 时(默认开启),配置 skywalking java agent,实现分布式链路追踪。
  7. 启动命令后添加 & 符号表示以后台方式启动 Java 应用,在 Shell 父进程中拿到 Java 子进程的 pid 号,等待子进程完成。
#!/bin/bash

export JAVA_OPTS="-XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:NewRatio=2 -XX:+UseCMSInitiatingOccupancyOnly -XX:CMSInitiatingOccupancyFraction=65 -XX:+CMSScavengeBeforeRemark $JAVA_OPTS"
echo JAVA_OPTS=$JAVA_OPTS

# auto heap
limit_in_bytes=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes)
if [ "$limit_in_bytes" -ne "9223372036854771712" -a "$limit_in_bytes" -ne "9223372036854775807" ]; then
  limit_in_megabytes=$(expr $limit_in_bytes \/ 1048576)
  heap_size=$(expr $limit_in_megabytes \* 3 \/ 4)
  export JAVA_OPTS="-Xms${heap_size}m -Xmx${heap_size}m $JAVA_OPTS"
  echo JAVA_OPTS=$JAVA_OPTS
fi

cpu_cfs_quota_us=$(cat /sys/fs/cgroup/cpu/cpu.cfs_quota_us)
cpu_cfs_period_us=$(cat /sys/fs/cgroup/cpu/cpu.cfs_period_us)
cpu_limit=$(expr $cpu_cfs_quota_us \/ $cpu_cfs_period_us)
if [ $cpu_limit -gt 1 ]; then
  parallel_gc_threads=$cpu_limit;
  conc_gc_threads=$((($cpu_limit + 3) / 4))
  export JAVA_OPTS="-XX:ParallelGCThreads=${parallel_gc_threads} -XX:ConcGCThreads=${conc_gc_threads} $JAVA_OPTS"
  echo JAVA_OPTS=$JAVA_OPTS
fi

service_jvm_log_dir="/home/appuser/jvm"
mkdir -p ${service_jvm_dir}
export JAVA_OPTS="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${service_jvm_log_dir}/heapdump.hprof -XX:ErrorFile=${service_jvm_log_dir}/hs_err_%p.log $JAVA_OPTS"
echo JAVA_OPTS=$JAVA_OPTS

if [ "$JVM_GC_LOG_ENABLE" == "ON" ]; then
  export JAVA_OPTS="-XX:GCLogFileSize=10M -XX:NumberOfGCLogFiles=20 -XX:+UseGCLogFileRotation -XX:+PrintGCDateStamps -XX:+PrintGC -XX:+PrintGCDetails -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -Xloggc:${service_jvm_log_dir}/gc-%t-${HOSTNAME}.log $JAVA_OPTS"
  echo JAVA_OPTS=$JAVA_OPTS
fi

if [ "$TRACE_ENABLE" != "OFF" ]; then
  export SW_AGENT_OPTS="-javaagent:/opt/tools/skywalking-agent.jar";
  export JAVA_OPTS="$JAVA_OPTS $SW_AGENT_OPTS";
  echo JAVA_OPTS=$JAVA_OPTS
fi

java -jar /home/appuser/app.jar &
app_pid=$!
wait $app_pid

SpringBoot 应用接收 SIGTERM 信号

SpringBoot 接收到 SIGTERM 信号后,会调用 @PreDestroy 注解标注的方法,执行 DisposableBean Bean 的 destroy() 方法,在应用程序关闭之前执行一些清理操作或其他自定义逻辑,实现优雅停机。

另外一种优雅停机的方式:发送请求到 Spring Boot Actuator 的停机端点:/actuator/shutdown,SpringBoot 会关闭 Web ApplicationContext,然后退出,实现优雅停机。

@PreDestroy
public void cleanup() {
  // 执行清理操作
  log.info("Received shutdown event. Performing cleanup and shutting down gracefully.");
}

如果在容器中采用 bash 脚本启动 SpringBoot 应用程序,比如ENTRYPOINT ["bash","./startup.sh"]。这时容器主进程是 startup.sh 进程,SpringBoot 进程是 bash 进程的子进程,bash 进程默认不会处理 SIGTERM 信号,收到 SIGTERM 信号会忽略,不会自己退出,也不会将信号传递给子进程,导致业务进程不会触发停止逻辑。

如果业务进程无法在 K8S 规定的优雅停止超时时间内退出 (terminationGracePeriodSeconds,默认 30s),K8S 会送 SIGKILL 强制杀死 bash 进程及其子进程。

参考资料:

容器中只有一个进程:exec

通常我们一个容器只会有一个进程,也是 Kubernetes 的推荐做法。在执行的命令之前加上 exec 命令,可以让 SpringBoot 进程代替当前 bash 进程,成为容器的主进程。ps 命令查看,容器中只有一个 SpringBoot 进程。

exec java -jar /home/appuser/app.jar

查阅资料得知,Docker 容器环境中,使用 Java 进程作为 1 号进程有一些缺陷,不推荐这种做法:Docker and the PID 1 zombie reaping problem

Unix 系统中,1 号进程需要具备收割清理的能力,避免系统产生僵尸进程,同时也会作为孤儿进程的父进程。对于 Java 进程来说,一般不会考虑收割清理这种能力,如果未完成相应的清理工作,则容易产生僵尸进程。对于 bash 来说,它具备比较完善的 adop and reap 能力,可以很好执行收割清理的工作。

举个例子,A 进程作为 Java 进程的子进程,当 A 进程终结的时候,会发送 SIGCHILD 信号唤醒 Java 进程,期待 Java 进程收割自己。如果 Java 进程没有特殊处理、忽略了这个信号,那么 A 进程就会成为 zombie process,虽然已经终结了,但还占据系统一部分资源。

因此,推荐第二种方式,将 bash 作为 1 号进程,Java 进程作为 bash 进程的子进程,这样不必担心 zombie process 的问题。

在 bash 中传递 SIGTERM 信号给 SpringBoot 应用

在 shell 父进程中 trap SIGTERM 信号,收到 SIGTERM 信号后传递给 SpringBoot 子进程,并等待子进程结束。

handle_signal() {
  echo "Received kill signal. Stopping the application..."
  kill -TERM $app_pid
  wait $app_pid
}
trap handle_signal SIGTERM

java -jar /home/appuser/app.jar &
app_pid=$!
wait $app_pid

bash 中等待多个进程完成的语法:wait $pid1 $pid2

终极解决方案:使用 init 系统

前面一种方案实际是用脚本实现了一个极简的 init 系统 (或 supervisor) 来管理所有子进程,仅仅透传指定信号给子进程。

社区解决方案:dumb-init 和 tini 都可以作为 init 进程,作为主进程 (PID 1) 在容器中启动,在 init 进程中执行 bash 脚本 (bash 作为 init 进程的子进程),然后在 bash 进程中启动的业务进程,当它收到信号时会将其传递给所有的子进程,从而也能完美解决 bash 无法传递信号问题,并且还有回收僵尸进程的能力。