Spring Boot 容器化踩坑與解決方案(1)

左半邊地球發表於2019-04-18
自從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 在容器中能正常執行。接下來我們就需要讓程式碼到生產環境流水線化,敬請期待下一章。



相關文章