Spring Boot 容器化踩坑与解决方案(1)

3,150 阅读7分钟
自从2017年开始玩 Kubernetes 和 Spring Boot到现在,已经在这条不归路上走了2年多,中间踩了一系列的小坑。在这里统一总结一下具体解决方案。
预计会分成4章左右的内容,本期主要是总结一些关于配置,日志,镜像的问题。下一期主要是关于持续集成的,然后是关于监控的。最后是关于集群的。

Spring Profile 与 环境变量

我们知道在基于Docker的DevOps中,我们应当尽可能保证多环境一个镜像。以确保各环境下的代码统一问题。根据我们的实际情况,我们没有采用配置中心方案,而采用环境变量的方案来实现。

Spring Boot 默认情况下,支持多环境配置。我们可以通过Spring Profile 完成各种不同环境或者不同集群的配置区分。

具体可以使用环境变量SPRING_PROFILES_ACTIVE来指定使用那个环境配置。具体命令如下:


docker run -d -p 8080:8080 -e “SPRING_PROFILES_ACTIVE=dev” –name test testImage:latest


我们内部一般采用多文件管理配置,环境划分成5个。分别是local,dev,test,pre,pro,分别对应本地调试,开发环境,测试环境,预发环境,正式环境。一共产生5个配置文件,分别是applicaiton.yaml,applicaiton-local.yaml,applicaiton-dev.yaml,applicaiton-test.yaml,applicaiton-pre.yaml,applicaiton-prod.yaml。在applicaiton.yaml中我们放公共配置,例如jackson的配置,部分kafka,mybatis的配置。对于MySQL,Kafka连接配置等保存在个环境配置中。默认情况下环境选择local。在各环境部署时,通过环境变量覆写来做配置切换。

采用这种方式后,我们还面临另外一个问题,像是线上MySQL连接地址会直接暴露给有代码访问权限的人,这就十分危险了,所以对于这些配置,我们默认也是采用环境变量注入。正式环境的配置信息,一般只有运维才能知道,在运维配置的时候,让他们来注入。

举个例子:

spring.redis.host=${REDIS_HOST}
spring.redis.port=${REDIS_PORT}
spring.redis.timeout=30000

docker run -d -p 8080:8080 -e "SPRING_PROFILES_ACTIVE=dev" -e "REDIS_HOST=127.0.0.1" -e "REDIS_PORT=3306" --name test testImage:latest


在我们的代码中,还存在一些其它情况,需要根据环境变量来判断是否需要配置Bean。例如swagger我们不想在生产环境中开启。对于这种情况,我们采用@Profile来确定是否需要初始化该Bean。
举个例子:

@Component
@Profile("dev")
public class DatasourceConfigForDev


@Configuration
@EnableSwagger2
@Profile( "dev")
public class SwaggerConfig {
}

Spring Boot 容器化后的日志

在实际使用中,我们使用 Kubernetes 来做容器调度,使用ES来存储日志。目前在应用日志收集这块,常规的方案一共有4种,

第一种应用日志直接通过网络传递到日志收集组件,然后再交给ES。例如logstash-logback-encoder的LogstashSocketAppender,如果日志量太大,可以先输入到消息通道中,再由日志收集器收集。这种方式会加大应用占用的CPU和内存资源,还需要一个相对稳定的网络环境。

第二种方式,是将日志输出到固定目录,并将这个目录挂载到本地或者网络存储上,在由日志收集器处理。这种方式,会导致日志中缺少关于Kubernetes的pod信息。需要采用其它方式补回。

第三种方式,是将日志直接输出到console,然后交由Docker记录日志,再通过日志收集器收集。由于一台主机中,跑着各种类型不同的容器,如果不做特殊处理,解析日志的成本就会非常非常高。

第四种方式,每个应用单独挂一个辅助容器,用来完成日志解析与收集。会多占用一些资源。只要辅助容器中的日志收集工具选择的好,确实是最好方案。

基于上面的集中方案,我们根据自己的情况选择了第三种,为了避免在收集过程中各种日志解析工作,我们希望日志输出时尽可能为Json格式。在这里我们使用logstash-logback-encoder来解决,输出固定结构的JSON。配合上面的解析多环境配置,我们创建了一个logback-kubernetes.xml,对于需要在容器中运行的环境,通过配置指定使用logback-kubernetes.xml做日志配置文件。这样在本地开发的时候,我们就可以愉快的使用Spring Boot的默认日志了。

关于 Java 在容器中运行的问题

我们目前使用Java 8,JDK选择了 openJDK。至于为什么选择openJDK,最主要的原因是最开始的时候,我们还没封装内部镜像,跟着教程走,就进入了openJDK阵营(当时oracle还没开始在docker hub上发布oracle jdk的镜像),现在看来应该小开心一下,貌似日之后只能使用openJDK了。毕竟Java 11的新授权模式,我们还是需要考虑一下是否使用。

在 Java 8u131 以前,由于 JVM 无法识别是在容器中运行,没办法根据容器限定的CPU,内存自动分配运行时候的参数,经常导致我们出现OOM kill的问题(我们也尝试过手动分配,堆区内存还相对好限制,非堆区不太好限制。对于部分java应用,需要反复调试。没办法做通用化处理和自动扩容缩容)。后来我们找到了https://github.com/fabric8io-images/java/tree/master/images/jboss/openjdk8/jdk,这个镜像可以根据可以自动访问cgroup获取cpu和内存信息,计算出一个相对合理的jvm配置参数。我们根据这个思路,也创建了我们内部的对应脚本(监控体系不一样),但是这个配置过程不太透明。

到JRE 8u131 以后,JVM新增了-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap,可以用来识别容器中的内存限制(原理大家可以百度,这里就不讲了)。考虑到一般情况下,我们CPU不会占满,内存会成为主要瓶颈,所以我们封装了新的镜像。镜像大致如下:


FROM alpine:3.8

ENV LANG="en_US.UTF-8" \
    LANGUAGE="en_US.UTF-8" \
    LC_ALL="en_US.UTF-8" \
    TZ="Asia/Shanghai"

RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/' /etc/apk/repositories \
    && apk add --no-cache tzdata curl ca-certificates \
    && echo "${TZ}" > /etc/TZ \
    && ln -sf /usr/share/zoneinfo/${TZ} /etc/localtime \
    && rm -rf /tmp/* /var/cache/apk/*

ENV JAVA_VERSION_MAJOR=8 \
    JAVA_VERSION_MINOR=181 \
    JAVA_VERSION_BUILD=13 \
    JAVA_VERSION_BUILD_STEP=r0 \
    JAVA_PACKAGE=openjdk \
    JAVA_JCE=unlimited \
    JAVA_HOME=/usr/lib/jvm/default-jvm \
    DEFAULT_JVM_OPTS="-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap -XX:MaxRAMFraction=2 -XX:+UseG1GC"

RUN apk add --no-cache openjdk8-jre=${JAVA_VERSION_MAJOR}.${JAVA_VERSION_MINOR}.${JAVA_VERSION_BUILD}-${JAVA_VERSION_BUILD_STEP} \
    && echo "securerandom.source=file:/dev/urandom" >> /usr/lib/jvm/default-jvm/jre/lib/security/java.security \
    && rm -rf /tmp/*  /var/cache/apk/*


到此我们的Java基础镜像就算是封装完毕了,也相对比较好的解决了Java 运行在容器里的一些问题。至于日后的升级问题,Java 8u191 和 Java 11 已经根治资源限制问题,有时间单独讲(又给自己挖坑),所以不需要考虑,有不怕死的赶快帮忙试试Java 11.

具体的一些镜像信息可以参考:https://github.com/XdaTk/DockerImages


关于 Spring Boot 与 Tomcat APR

对于Spring Boot的容器,我们这里使用Tomcat,试用过一段时间的undertow,确实在内存占用上会小一些。但是由于监控还没有完善,所以我们暂时的主力还是Tomcat。如果有人升级到Spring Boot 2.0以后,可能会注意到启动的时候,会出现一条关于Tomcat APR的WARN日志。至于什么是APR,大家可以参考一下http://tomcat.apache.org/tomcat-9.0-doc/apr.html

为了性能,我们决定切换到APR模式下。我们在上面提到的Java镜像的基础上,继续封装了一遍。

FROM xdatk/openjdk:8.181.13-r0 as native

ENV TOMCAT_VERSION="9.0.13" \
    APR_VERSION="1.6.3-r1" \
    OPEN_SSL_VERSION="1.0.2p-r0"
ENV TOMCAT_BIN="https://mirrors.tuna.tsinghua.edu.cn/apache/tomcat/tomcat-9/v${TOMCAT_VERSION}/bin/apache-tomcat-${TOMCAT_VERSION}.tar.gz"

RUN apk add --no-cache apr-dev=${APR_VERSION} openssl-dev=${OPEN_SSL_VERSION} openjdk8=${JAVA_VERSION_MAJOR}.${JAVA_VERSION_MINOR}.${JAVA_VERSION_BUILD}-${JAVA_VERSION_BUILD_STEP} wget unzip make g++ \
    && cd /tmp \
    && wget -O tomcat.tar.gz ${TOMCAT_BIN} \
    && tar -xvf tomcat.tar.gz \
    && cd apache-tomcat-*/bin \
    && tar -xvf tomcat-native.tar.gz \
    && cd tomcat-native-*/native \
    && ./configure --with-java-home=${JAVA_HOME} \
    && make \
    && make install


FROM xdatk/openjdk:8.181.13-r0
ENV TOMCAT_VERSION="9.0.13" \
    APR_VERSION="1.6.3-r1" \
    OPEN_SSL_VERSION="1.0.2p-r0" \
    APR_LIB=/usr/local/apr/lib

COPY --from=native ${APR_LIB} ${APR_LIB}

RUN apk add --no-cache apr=${APR_VERSION} openssl=${OPEN_SSL_VERSION}

实测下来,会有些许性能提示。

以上,我们基本保证了spring boot 在容器中能正常运行。接下来我们就需要让代码到生产环境流水线化,敬请期待下一章。