18 張圖 Java 容器化最佳實踐總結

作者:bleem

出處:https://u.kubeinfo.cn/t2j0iA

一、系統選擇

關於最基礎的底層鏡像, 通常大多數我們只有三種選擇: Alpine、Debian、CentOS; 這三者中對於運維最熟悉的一般爲 CentOS, 但是很不幸的是 CentOS 後續已經不存在穩定版, 關於它的穩定性問題一直是個謎一樣的問題; 這是一個仁者見仁智者見智的問題, 我個人習慣是能不用我絕對不用 😆.

排除掉 CentOS 我們只討論是 Alpine 還是 Debian; 從鏡像體積角度考慮無疑 Alpine 完勝, 但是 Alpine 採用的是 musl 的 C 庫, 在某些深度依賴 glibc 的情況下可能會有一定兼容問題. 當然關於深度依賴 glibc 究竟有多深度取決於具體應用, 就目前來說我個人也只是遇到過 Alpine 官方源中的 OpneJDK 一些字體相關的 BUG.

綜合來說, 我個人的建議是如果應用深度依賴 glibc, 比如包含一些 JNI 相關的代碼, 那麼選擇 Debian 或者說基於 Debian 的基礎鏡像是一個比較穩的選擇; 如果沒有這些重度依賴問題, 那麼在考慮鏡像體積問題上可以選擇使用 Alpine. 事實上 OpneJDK 本身體積也不小, 即使使用 Alpine 版本, 再安裝一些常用軟件後也不會小太多, 所以我個人習慣是使用基於 Debian 的基礎鏡像.

二、JDK OR JRE

大多數人似乎從不區分 JDK 與 JRE, 所以要確定這事情需要先弄明白 JDK 和 JRE 到底是什麼:

JDK 是一個開發套件, 它會包含一些調試相關的工具鏈, 比如 javacjpsjstackjmap 等命令, 這些都是爲了調試和編譯 Java 程序所必須的工具, 同時 JDK 作爲開發套件是包含 JRE 的; 而 JRE 僅爲 Java 運行時環境, 它只包含 Java 程序運行時所必須的一些命令以及依賴類庫, 所以 JRE 會比 JDK 體積更小、更輕量.

如果只需要運行 Java 程序比如一個 jar 包, 那麼 JRE 足以; 但是如果期望在運行時捕獲一些信息進行調試, 那麼應該選擇 JDK. 我個人的習慣是爲了解決一些生產問題, 通常選擇直接使用 JDK 作爲基礎鏡像, 避免一些特殊情況還需要掛載 JDK 的工具鏈進行調試. 當然如果沒有這方面需求, 且對鏡像體積比較敏感, 那麼可以考慮使用 JRE 作爲基礎鏡像.

三、JDK 選擇

3.1、OracleJDK 還是 OpenJDK

針對於這兩者的選擇, 取決於一個最直接的問題: 應用代碼中是否有使用 Oracle JDK 私有 API.

通常 “使用這些私有 API” 指的是引入了一些 com.sun.* 包下的相關類、接口等, 這些 API 很多是 Oracle JDK 私有的, 在 OpneJDK 中可能完全不包含或已經變更. 所以如果代碼中包含相關調用則只能使用 Oracle JDK.

值得說明的是很多時候使用這些 API 並不是真正的業務需求, 很可能是開發在導入包時 “手滑” 並且湊巧被導入的 Class 等也能實現對應功能; 對於這種導入是可以被平滑替換的, 比如換成 Apache Commons 相關的實現. 還有一種情況是開發誤導入後及時發現了, 但是沒有進行代碼格式化和包清理, 這是會在代碼頭部遺留相關的 import 引用, 而 Java 是允許存在這種無用的 import 的; 針對這種只需要重新格式化和優化導入即可.

Tips: IDEA 按 Option + Command + L(格式化) 還有 Control + Option + O(自動優化包導入).

3.2、OracleJDK 重建問題

當沒有辦法必須使用 Oracle JDK 時, 推薦自行下載 Oracle JDK 壓縮包並編寫 Dockerfile 創建基礎鏡像. 但是這會涉及到一個核心問題: Oracle JDK 一般不提供歷史版本, 所以如果要考慮未來的重新構建問題, 建議保留好下載的 Oralce JDK 壓縮包.

.3、OpenJDK 發行版

衆所周知 OpenJDK 是一個開源發行版, 基於開源協議各大廠商都提供一些增值服務, 同時也預編譯了一些 Docker 鏡像供我們使用; 目前主流的一些發行版本如下:

這些發行版很多是大同小異的, 一些發行版可能提供的基礎鏡像選擇更多, 比如 AdoptOpenJDK 提供基於 Alpine、Ubuntu、CentOS 的三種基礎鏡像發行版; 還有一些發行版提供其他的 JVM 實現, 比如 IBM Semeru Runtime 提供 OpenJ9 JVM 的預編譯版本.

目前我個人比較喜歡 AdoptOpenJDK, 因爲它是社區驅動的, 由 JUG 成員還有一些廠商等社區成員組成; 而 Amazon Corretto 和 IBM Semeru Runtime 看名字就可以知道是雲高端玩家做的, 可用性也比較棒. 其他的類似 Azul Zulu、Liberica JDK 則是一些 JVM 提供廠商, 有些還有點算得上是黑料的東西, 不算特別推薦.

目前 AdoptOpenJDK 已經合併到 Eclipse Foundation, 現在叫做 Eclipse Adoptium; 所以如果想要使用 AdoptOpenJDK 鏡像, Docker Hub 中應該使用 eclipse-temurin 用戶下的相關鏡像.

四、JVM 選擇

對於 JVM 實現來說, Oracle 有一個 JVM 實現規範, 這個實現規範定義了兼容 Java 代碼運行時的這個 VM 應當具備哪些功能; 所以只要滿足這個 JVM 實現規範且經過了認證, 那麼這個 JVM 實現理論上就可以應用於生產. 目前市面上也有很多 JVM 實現:

這些 JVM 實現可能具有不同的特性和性能, 比如 Hotspot 是最常用的 JVM 實現, 綜合性能、兼容性等最佳; 由 IBM 創建目前屬於 Eclipse 基金會的 OpneJ9 對容器化更友好, 提供更快啓動和內存佔用等特性.

通常建議如果對 JVM 不是很熟悉的情況下, 請使用 “標準的” Hotspot; 如果有更高要求且期望自行調試一些 JVM 優化參數, 請考慮 Eclipse OpenJ9. 我個人比較喜歡 OpenJ9, 原因是它的文檔寫的很不錯, 只要細心看可以讀到很多不錯的細節等; 如果要使用 OpenJ9 鏡像, 推薦直接使用 ibm-semeru-runtimes 預編譯的鏡像.

五、信號量傳遞

當我們需要關閉一個程序時, 通常系統會像該進程發送一個終止信號, 同樣在容器停止時 Kubernetes 或者其他容器工具也會像容器內 PID 1 的進程發送終止信號; 如果容器內運行一個 Java 程序, 那麼信號傳遞給 JVM 後 Java 相關的框架比如 Spring Boot 等就會檢測到此信號, 然後開始執行一些關閉前的清理工作, 這被稱之爲 “優雅關閉 (Graceful shutdown)”.

如果在我們容器化 Java 應用時沒有正確的讓信號傳遞給 JVM, 那麼調度程序比如 Kubernetes 在等待容器關閉超時以後就會進行強制關閉, 這很可能導致一些 Java 程序無法正常釋放資源, 比如數據庫連接沒有關閉、註冊中心沒有反註冊等. 爲了驗證這個問題, 我創建了一個 Spring Boot 樣例項目來進行測試, 其中項目中包含的核心文件如下 (完整代碼請看 GitHub):

由於 BeanTest 只做打印測試都是通用的, 所以這裏直接貼代碼:

package com.example.springbootgracefulshutdownexample;

import org.springframework.stereotype.Component;

import javax.annotation.PreDestroy;

@Component
public class BeanTest {
    @PreDestroy
    public void destroy() {
        System.out.println("==================================");
        System.out.println("接收到終止信號, 正在執行優雅關閉...");
        System.out.println("==================================");
    }
}

5.1、錯誤的信號傳遞

在很多原始的 Java 項目中通常會存在一個啓動運行腳本, 這些腳本可能是自行編寫的, 也可能是一些比較老的 Tomcat 啓動腳本等; 當我們使用腳本啓動並且沒有合理的調整 Dockerfile 時就會出現信號無法正確傳遞的問題; 例如下面的錯誤示範:

entrypoint.bad.sh: 負責啓動

#!/usr/bin/env bash

java -jar /SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar

Dockerfie.bad: 使用 bash 啓動腳本, 這會導致終止信號無法傳遞

FROM eclipse-temurin:11-jdk

COPY entrypoint.bad.sh /
COPY target/SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar /

# 下面幾種種方式都無法轉發信號
#CMD /entrypoint.bad.sh
#CMD ["/entrypoint.bad.sh"]
CMD ["bash""/entrypoint.bad.sh"]

通過這個 Dockerfile 打包運行後, 在使用 docker stop 命令時明顯卡頓一段時間 (實際上是 docker 在等待容器內進程自己退出), 當到達預定的超時時間後容器內進程被強行終止, 故沒有打印優雅關閉的日誌:

5.2、正確的信號傳遞

5.2.1、直接運行方式

要解決信號傳遞這個問題其實很簡單, 也有很多方法; 比如常見的直接使用 CMDENTRYPOINT 指令運行 java 程序:

Dockerfile.direct: 直接運行 java 程序, 能夠正常接受到終止信號

FROM eclipse-temurin:11-jdk

COPY target/SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar /

CMD ["java""-jar""/SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar"]

可以看到, 在 Dockerfile 中直接運行 java 命令這種方式可以讓 jvm 正確的通知應用完成優雅關閉:

5.2.2、間接 Exec 方式

熟悉 Docker 的同學都應該清楚, 在 Dockerfile 裏直接運行命令無法解析環境變量; 但是有些時候我們又依賴腳本進行變量解析, 這時候我們可以先在腳本內解析完成, 並採用 exec 的方式進行最終執行; 這種方式也可以保證信號傳遞。

entrypoint.exec.sh: exec 執行最終命令, 可以轉發信號

#!/usr/bin/env bash

# 假裝進行一些變量處理等操作...
export VERSION="0.0.1"

exec java -jar /SpringBootGracefulShutdownExample-${VERSION}-SNAPSHOT.jar

5.2.3、Bash-c 方式

除了直接執行和 exec 方式其實還有一個我稱之爲 “不穩定” 的解決方案, 就是使用 bash -c 來執行命令; 在使用 bash -c 執行一些簡單命令時, 其行爲會跟 exec 很相似, 也會把子進程命令替換到父進程從而讓 -c 後的命令直接接受到系統信號; 但需要注意的是, 這種方式不一定百分百成功, 比如當 -c 後面的命令中含有管道、重定向等可能仍會觸發 fork, 這時子命令仍然無法完成優雅關閉.

Dockerfile.bash-c: 採用 bash -c 執行, 在命令簡單情況下可以做到優雅關閉

FROM eclipse-temurin:11-jdk

COPY entrypoint.bad.sh /
COPY target/SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar /

CMD ["bash""-c""java -jar /SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar"]

關於 bash -c 的相關討論, 可以參考 StackExchange.

5.2.4、tini 或 dump-init

守護工具並不是萬能的, tini 和 dump-init 都有一定問題.

這兩個工具是大部分人都熟知的利器, 甚至連 Docker 本身都集成了; 不過似乎很多人都有一個誤區 (我以前也是這麼覺得的), 那就是認爲加了 tini 或者 dump-init 信號就可以轉發, 就可以優雅關閉了; 而事實上並不是這樣, 很多時候你加了這兩個東西也只能保證殭屍進程的回收, 但是子進程仍然可能無法優雅關閉. 比如下面的例子:

Dockerfile.tini: 加了 tini 也無法優雅關閉的情況

FROM eclipse-temurin:11-jdk

RUN set -e \
    && apt update \
    && apt install tini psmisc -y

COPY entrypoint.bad.sh /
COPY target/SpringBootGracefulShutdownExample-0.0.1-SNAPSHOT.jar /

ENTRYPOINT ["tini""-vvv""--"]

CMD ["bash""/entrypoint.bad.sh"]

對於 dump-init 也有同樣的問題, 歸根結底這個問題的根本還是在 bash 上: 當使用 bash 啓動腳本後, bash 會 fork 一個新的子進程; 而不管是 tini 還是 dump-init 的轉發邏輯都是將信號傳遞到進程組; 只要進程組中的父進程響應了信號, 那麼就認爲轉發完成, 但此時進程組中的子進程可能還沒有完成優雅關閉父進程就已經死了, 這會導致變爲子進程最終還會被強制 kill 掉.

5.3、最佳實踐

根據上面的測試和驗證結果, 這裏總結一下最佳實踐:

六、內存限制

Java 應用的容器化內存限制是一個老生常談的問題, 國內也有很多資料, 不過這些文章很多都過於老舊或者直接翻譯自國外的文章; 我發現很少有人去深究和測試這個問題, 隨着這兩年容器化的發展其實很多東西早已不適用, 爲此在這裏決定專門仔細的測試一下這個內存問題 (只想看結論的可直接觀看 6.3 章節.).

衆所周知, Java 是有虛擬機的, Java 代碼被編譯成 Class 文件然後在 JVM 中運行; JVM 默認會根據操作系統環境來自動設置堆內存 (HeapSize), 而容器化 Java 應用面臨的挑戰其一就是如何讓 JVM 獲取到正確的可用內存避免被 kill.

6.1、無配置下的自適應

在默認不配置時, 理想狀態的 JVM 應當能識別到我們對容器施加的內存 limit, 從而自動調整堆內存大小; 爲了驗證這種理想狀態下哪些版本的 OpenJDK 能做到, 我抽取一些特定版本進行了以下測試:

6.1.1、OpenJDK 8u111

這個版本的 OpenJDK 尚未對容器化做任何支持, 所以理論上它是不可能能獲取到 limit 的內存限制:

可以看到 JVM 並沒有識別到 limit, 仍然按照大約宿主機 1/4 的體量去分配的堆內存, 所以如果裏面的 java 應用內存佔用高了可能會被直接 kill.

6.1.2、OpenJDK 8u131

選擇 8u131 這個版本是因爲在此版本添加了 -XX:+UseCGroupMemoryLimitForHeap 參數來支持內存自適應, 這裏我們先不開啓, 先直接進行測試:

同樣在默認情況下是無法識別內存限制的.

6.1.3、OpenJDK 8u222

8u191 版本從 OpneJDK 10 backport 回了 XX:+UseContainerSupport 參數來支持 JVM 容器化, 不過該版本暫時無法下載, 這裏使用更高的 8u222 測試, 測試時同樣暫不開啓特定參數進行測試:

同樣的內存無法正確識別.

6.1.4、OpenJDK 11.0.15

OpenJDK 11 版本已經開始對容器化的全面支持, 例如 XX:+UseContainerSupport 已被默認開啓, 所以這裏我們仍然選擇不去修改任何設置去測試:

可以看到, 即使默認打開了 UseContainerSupport 開關, 仍然無法正常的自適應內存.

6.1.5、OpenJDK 11.0.16

可能很多人會好奇, 都測試了 11.0.15 爲什麼還要測試 11.0.16? 因爲這兩個版本在不設置的情況下有個奇怪的差異:

可以看到, 11.0.16 版本在不做任何設置時自動適應了容器內存限制, 堆內存從接近 4G 變爲了 120M.

6.1.6、OpenJDK 17

OPneJDK 17 是目前最新的 LTS 版本, 這裏再專門測試一下 OpneJDK 17 不調整任何參數時的內存自適應情況:

可以看到 OpneJDK 17 與 OpenJDK 11.0.16 版本一樣, 都可以實現內存的自適應.

6.2、有配置下的自適應

在上面的無配置情況下我們進行了一些測試, 測試結果從 11.0.15 版本開始出現了一些 “令人費解” 的情況; 理論上 11+ 已經自動打開了容器支持參數, 但是某些版本內存自適應仍然無效, 這促使我對其他參數的實際效果產生了懷疑; 爲此我開始按照各個參數的添加版本手動啓用這些參數進行了一些測試.

6.2.1、OpenJDK 8u131

8u131 正式開始進行容器化支持, 在這個版本增加了一個 JVM 選項來告訴 JVM 使用 cgroup 設置的內存限制; 我增加了 -XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap 參數進行測試, 測試結果是這個選項在我當前的環境中似乎完全不生效:

6.2.2、OpenJDK 8u222

從 8u191 版本開始, 又增加了另一個開啓容器化支持的參數 -XX:+UseContainerSupport, 該參數從 OpenJDK 10 反向合併而來; 我嘗試使用這個參數來進行測試, 結果仍然是沒什麼卵用:

6.2.3、OpenJDK 11+

從 11+ 版本開始 -XX:+UseContainerSupport 已經自動開啓, 我們不需要再做什麼特殊設置, 所以結果是跟無配置測試結果一致的: 11.0.15 以後的版本開始能夠自適應, 之前的版本 (包括 11.0.15) 都不支持自適應.

6.3、分析與總結

經過上面的一些測試後會發現, 在很多文章或文檔中描述的參數出現了莫名其妙不好使的情況; 這主要是因爲容器化這兩年一個很重要的更新: Cgroups v2; 限於篇幅問題這裏不在一一羅列測試截圖, 下面僅說一下結論.

6.3.1、Cgroups V1

對於使用 Cgroups V1 的容器化環境來說, “舊的” 一些規則仍然適用 (新內核增加內核參數 systemd.unified_cgroup_hierarchy=0 回退到 Cgroups V1):

6.3.2、Cgroups V2

在新版本系統 (具體自行查詢) 配合較新的 containerd 等容器化工具時, 已經默認轉換爲 Cgroups V2, 需要注意的是針對於 Cgroups V2 的內存自適應只有在 OpneJDK 11.0.16 以及之後的版本才支持, 在這之前開啓任何參數都沒用.

關於 Cgroups V2 的一些支持細節具體請查看 JDK-8230305:

七、DNS 緩存

在大部分 Java 程序中我們都會使用域名去訪問一些服務, 可能是訪問某些 API 端點或者是訪問一些數據庫, 而不論哪樣只要使用了域名就會涉及到 DNS 緩存問題; Java 的 DNS 緩存是由 JVM 控制的, 不要理所當然的以爲 JVM DNS 緩存非常友好, 某些時候 DNS 緩存可能超出預期. 爲了測試 DNS 緩存情況我從某大佬這裏抄來一個測試腳本, 該腳本會測試三個版本的 OpenJDK DNS 緩存情況:

jvm-dns-ttl-policy.sh

#!/usr/bin/env bash

set -e

for tag in 8-jdk 11-jdk 17-jdk; do

    tag_
    output_file="$(mktemp)"

    jvm_args=""
    if ! [ "${tag}" == "8-jdk" ]; then
        jvm_args="--add-exports java.base/sun.net=ALL-UNNAMED"
    fi

    ttl=""
    if ! [ "${1}" == "" ]; then
        ttl="-Dsun.net.inetaddr.ttl=${1}"
    fi

    dockerfile="
FROM        eclipse-temurin:${tag}
WORKDIR     /var/tmp
RUN         printf ' \\
              public class DNSTTLPolicy { \\
                public static void main(String args[]) { \\
                  System.out.printf(\"Implementation DNS TTL for JVM in Docker image based on 'eclipse-temurin:${tag}' is %%d seconds\\\\n\", sun.net.InetAddressCachePolicy.get()); \\
                } \\
              }' >DNSTTLPolicy.java
RUN         javac ${jvm_args} DNSTTLPolicy.java -XDignore.symbol.file
CMD         java ${jvm_args} ${ttl} DNSTTLPolicy
ENTRYPOINT  java ${jvm_args} ${ttl} DNSTTLPolicy
"

    dockerfile_security_manager="
FROM        eclipse-temurin:${tag}
WORKDIR     /var/tmp
RUN         printf ' \\
              public class DNSTTLPolicy { \\
                public static void main(String args[]) { \\
                  System.out.printf(\"Implementation DNS TTL for JVM in Docker image based on 'eclipse-temurin:${tag}' (with security manager enabled) is %%d seconds\\\\n\", sun.net.InetAddressCachePolicy.get()); \\
                } \\
              }' >DNSTTLPolicy.java
RUN         printf ' \\
              grant { \\
                permission java.security.AllPermission; \\
              };' >all-permissions.policy
RUN         javac ${jvm_args} DNSTTLPolicy.java -XDignore.symbol.file
CMD         java ${jvm_args} ${ttl} -Djava.security.manager -Djava.security.policy==all-permissions.policy DNSTTLPolicy
ENTRYPOINT  java ${jvm_args} ${ttl} -Djava.security.manager -Djava.security.policy==all-permissions.policy DNSTTLPolicy
"

    echo "Building Docker image based on eclipse-temurin:${tag} ..." >&2
    docker build -t "${tag_name}" - <<<"${dockerfile}" 2>&1 > /dev/null
    docker run --rm "${tag_name}" &>"${output_file}"
    cat "${output_file}"
    docker build -t "${tag_name}" - <<<"${dockerfile_security_manager}" 2>&1 > /dev/null
    docker run --rm "${tag_name}" &>"${output_file}"
    cat "${output_file}"
    echo ""

done

7.1、默認 DNS 緩存

默認不做任何設置的 DNS 緩存結果如下 (直接運行腳本即可):

可以看到, 默認情況下 DNS TTL 被設置爲 30s, 如果開啓了 Security Manager 則變爲 -1s, 那麼 -1s 什麼意思呢 (截取自 OpenJDK 11 源碼):

/* The Java-level namelookup cache policy for successful lookups:
 *
 * -1: caching forever
 * any positive value: the number of seconds to cache an address for
 *
 * default value is forever (FOREVER), as we let the platform do the
 * caching. For security reasons, this caching is made forever when
 * a security manager is set.
 */
private static volatile int cachePolicy = FOREVER;

/* The Java-level namelookup cache policy for negative lookups:
 *
 * -1: caching forever
 * any positive value: the number of seconds to cache an address for
 *
 * default value is 0. It can be set to some other value for
 * performance reasons.
 */
private static volatile int negativeCachePolicy = NEVER;

7.2、設置 DNS 緩存

爲了避免這種奇奇怪怪的 DNS 緩存策略問題, 最好我們在啓動時通過增加 -Dsun.net.inetaddr.ttl=xxx 參數手動設置 DNS 緩存時間:

可以看到, 一但我們手動設置了 DNS 緩存, 那麼不論是否開啓 Security Manager 都會遵循我們的設置. 如果需要更細緻的調試 DNS 緩存推薦使用 Alibaba 開源的 DCM 工具.

八、Native 編譯

Native 編譯優化是指通過 GraalVM 將 Java 代碼編譯爲可以直接被平臺執行的二進制文件, 編譯後的可執行文件運行速度會有極大提升. 但是 GraalVM 需要應用的代碼層調整、框架升級等操作, 總體來說比較苛刻; 但是如果是新項目, 最好讓開發能支持一下 GraalVM 的 Native 編譯, 這對啓動速度等有巨大提升.

上面介紹的用於測試優雅關閉的項目已經內置了 GraalVM 支持, 只需要下載 GraalVM 並設置 JAVA_HOMEPATH 變量, 並使用 mvn clean package -Dmaven.test.skip=true -Pnative 編譯即可:

編譯成功後將在 target 目錄下生成可以直接執行的二進制文件, 以下爲啓動速度對比測試:

可以看到 GraalVM 編譯後啓動速度具有碾壓級的優勢, 基本差出一個數量級; 但是綜合來說這種方式目前還不是特別成熟, 迄今爲止國內 Java 生態仍是 OpneJDK 8 橫行, 老舊項目想要滿足 GraalVM 需要調整的地方比較巨大; 所以總結就是新項目能支持儘量支持, 老項目不要作死.

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/3_d2lE1vU5DGDcWPPjAAUQ