在 Cloud Run 中發揮 Java 的最大效用

本指南說明以 Java 程式設計語言編寫的 Cloud Run 服務最佳化方式,並提供背景資訊,協助您瞭解某些最佳化方式的取捨。本頁資訊可補充一般最佳化提示,也適用於 Java。

傳統的 Java 網路應用程式旨在提供具有高並行性和低延遲時間的要求,且通常是長時間執行的應用程式。JVM 本身也會使用 JIT 在一段時間內最佳化執行程式碼,以便最佳化熱門路徑,讓應用程式在一段時間內更有效率地執行。

這些傳統的 Java 網路應用程式中,許多最佳做法和最佳化方式都圍繞著以下項目:

  • 處理並行要求 (包括執行緒和非阻斷式 I/O)
  • 使用連線集區和批次處理非必要的函式,例如將追蹤記錄和指標傳送至背景工作,藉此減少回應延遲時間。

雖然許多傳統最佳化方式適用於長時間執行的應用程式,但在 Cloud Run 服務中可能無法達到相同的效果,因為 Cloud Run 服務只會在積極提供要求時執行。本頁面將介紹幾種不同的 Cloud Run 最佳化和取捨方式,協助您減少啟動時間和記憶體用量。

使用啟動時 CPU 效能強化功能,縮短啟動延遲時間

您可以啟用啟動時 CPU 效能強化功能,在執行個體啟動期間暫時增加 CPU 分配,以便縮短啟動延遲時間。

Google 的指標顯示,如果 Java 應用程式使用啟動 CPU 加速功能,就能將啟動時間縮短最多 50%。

最佳化容器映像檔

最佳化容器映像檔後,您就能縮短載入和啟動時間。您可以透過下列方式改善圖片:

  • 盡可能縮小容器映像檔
  • 避免使用巢狀程式庫封存 JAR
  • 使用 Jib

盡量縮小容器映像檔

如要進一步瞭解這個問題,請參閱縮減容器大小的一般提示頁面。一般提示頁面建議您將容器圖片內容縮減為僅有需要的內容。舉例來說,請確認您的容器映像檔不含

  • 原始碼
  • Maven 建構構件
  • 建立工具
  • Git 目錄
  • 未使用的二進位檔/公用程式

如果您要在 Dockerfile 中建構程式碼,請使用 Docker 多階段建構功能,讓最終容器映像檔只包含 JRE 和應用程式 JAR 檔案本身。

避免巢狀程式庫封存 JAR

有些熱門架構 (例如 Spring Boot) 會建立應用程式封存檔案 (JAR),其中包含其他程式庫 JAR 檔案 (巢狀 JAR)。這些檔案需要在啟動期間解壓縮,這可能會影響 Cloud Run 的啟動速度。因此,請盡可能使用外部化程式庫建立精簡 JAR:您可以使用 Jib 將應用程式容器化,以便自動執行此操作

使用 Jib

使用 Jib 外掛程式建立最小容器,並自動扁平化應用程式封存檔。Jib 可搭配 Maven 和 Gradle 使用,並可與 Spring Boot 應用程式搭配使用。某些應用程式架構可能需要額外的 Jib 設定。

JVM 最佳化

為 Cloud Run 服務最佳化 JVM,可改善效能和記憶體用量。

使用容器感知的 JVM 版本

在 VM 和機器中,如果要分配 CPU 和記憶體,JVM 會從已知位置瞭解可使用的 CPU 和記憶體,例如 Linux、/proc/cpuinfo/proc/meminfo。不過,在容器中執行時,CPU 和記憶體限制會儲存在 /proc/cgroups/... 中。舊版 JDK 會繼續在 /proc 中查詢,而不是 /proc/cgroups,這可能導致 CPU 和記憶體用量超出指定的用量。這可能會導致:

  • 由於 Runtime.availableProcessors() 設定了執行緒集區大小,因此執行緒數量過多
  • 預設的堆積上限超過容器記憶體限制。JVM 會在垃圾收集之前積極使用記憶體。這很容易導致容器超出容器記憶體限制,並遭到 OOM 終止。

因此,請使用容器感知的 JVM 版本。大於或等於 8u192 的 OpenJDK 版本預設為容器感知。

如何瞭解 JVM 記憶體用量

JVM 記憶體用量由原生記憶體用量和堆積用量組成。應用程式工作記憶體通常位於堆積中。堆積大小受限於最大堆積配置。如果使用 Cloud Run 256 MB RAM 執行個體,您無法將所有 256 MB 指派給最大堆積,因為 JVM 和 OS 也需要原生記憶體,例如執行緒堆疊、程式碼快取、檔案句柄、緩衝區等。如果應用程式遭到 OOMKilled,而您需要瞭解 JVM 記憶體用量 (原生記憶體 + 堆積),請開啟原生記憶體追蹤功能,以便在應用程式成功關閉時查看用量。如果應用程式遭到 OOMKilled,就無法列印資訊。在這種情況下,請先以較多記憶體執行應用程式,以便成功產生輸出內容。

您無法透過 JAVA_TOOL_OPTIONS 環境變數開啟原生記憶體追蹤功能。您必須將 Java 指令列啟動引數新增至容器映像檔的進入點,以便應用程式以這些引數啟動:

java -XX:NativeMemoryTracking=summary \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintNMTStatistics \
  ...

您可以根據要載入的類別數量,估算原生記憶體用量。建議您使用開放原始碼的 Java 記憶體計算器來估算記憶體需求。

關閉最佳化編譯器

根據預設,JVM 有幾個 JIT 編譯階段。雖然這些階段會隨著時間提升應用程式的效率,但也會增加記憶體用量負擔,並延長啟動時間。

如果是短時間執行的無伺服器應用程式 (例如函式),建議您關閉最佳化階段,以便在犧牲長期效率的情況下縮短啟動時間。

針對 Cloud Run 服務,請設定環境變數:

JAVA_TOOL_OPTIONS="-XX:+TieredCompilation -XX:TieredStopAtLevel=1"

使用應用程式類別資料分享功能

如要進一步減少 JIT 時間和記憶體用量,建議您使用應用程式類別資料共用 (AppCDS),將預先編譯的 Java 類別以封存檔的形式共用。啟動相同 Java 應用程式的另一個例項時,可以重複使用 AppCDS 封存檔。JVM 可重複使用封存檔中的預先計算資料,進而縮短啟動時間。

使用 AppCDS 時,請注意下列事項:

  • 要重複使用的 AppCDS 封存檔,必須使用與原始產生封存檔相同的 OpenJDK 發行版、版本和架構。
  • 您必須至少執行一次應用程式,才能產生要共用的類別清單,然後使用該清單產生 AppCDS 封存檔。
  • 類別的涵蓋率取決於應用程式執行期間執行的程式碼路徑。如要提高涵蓋率,請以程式輔助方式觸發更多程式碼路徑。
  • 應用程式必須順利結束,才能產生這個類別清單。建議您實作應用程式標記,用於指出 AppCDS 封存檔案的產生方式,以便立即退出。
  • 只有在您以與建立時完全相同的方式啟動新執行個體時,才能重複使用 AppCDS 封存檔。
  • AppCDS 封存檔只支援一般 JAR 檔案套件,因此無法使用巢狀 JAR。

使用遮蔽 JAR 檔案的 Spring Boot 範例

Spring Boot 應用程式預設會使用巢狀超級 JAR,這不適用於 AppCDS。因此,如果您使用 AppCDS,就必須建立遮蔽的 JAR。例如,使用 Maven 和 Maven Shade 外掛程式:

<build>
  <finalName>helloworld</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-shade-plugin</artifactId>
      <configuration>
        <keepDependenciesWithProvidedScope>true</keepDependenciesWithProvidedScope>
        <createDependencyReducedPom>true</createDependencyReducedPom>
        <filters>
          <filter>
            <artifact>*:*</artifact>
            <excludes>
              <exclude>META-INF/*.SF</exclude>
              <exclude>META-INF/*.DSA</exclude>
              <exclude>META-INF/*.RSA</exclude>
            </excludes>
          </filter>
        </filters>
      </configuration>
      <executions>
        <execution>
          <phase>package</phase>
          <goals><goal>shade</goal></goals>
          <configuration>
            <transformers>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.handlers</resource>
              </transformer>
              <transformer implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer">
                <resource>META-INF/spring.factories</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
                <resource>META-INF/spring.schemas</resource>
              </transformer>
              <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" />
              <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                <mainClass>${mainClass}</mainClass>
              </transformer>
            </transformers>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

如果遮蔽的 JAR 包含所有依附元件,您可以在容器建構期間使用 Dockerfile 產生簡單的封存檔:

# Use Docker's multi-stage build
FROM eclipse-temurin:11-jre as APPCDS

COPY target/helloworld.jar /helloworld.jar

# Run the application, but with a custom trigger that exits immediately.
# In this particular example, the application looks for the '--appcds' flag.
# You can implement a similar flag in your own application.
RUN java -XX:DumpLoadedClassList=classes.lst -jar helloworld.jar --appcds=true

# From the captured list of classes (based on execution coverage),
# generate the AppCDS archive file.
RUN java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=appcds.jsa --class-path helloworld.jar

FROM eclipse-temurin:11-jre

# Copy both the JAR file and the AppCDS archive file to the runtime container.
COPY --from=APPCDS /helloworld.jar /helloworld.jar
COPY --from=APPCDS /appcds.jsa /appcds.jsa

# Enable Application Class-Data sharing
ENTRYPOINT java -Xshare:on -XX:SharedArchiveFile=appcds.jsa -jar helloworld.jar

縮減執行緒堆疊大小

大多數 Java 網頁應用程式都是以每個連線一個執行緒為基礎。每個 Java 執行緒都會消耗原生記憶體 (不在堆積中)。這就是所謂的「執行緒堆疊」,預設為每個執行緒 1 MB。如果應用程式處理 80 個並行要求,則可能至少有 80 個執行緒,這代表會使用 80 MB 的執行緒堆疊空間。記憶體是堆積大小的附加值。預設值可能會大於必要值。您可以縮減執行緒堆疊大小。

如果減少過多,就會看到 java.lang.StackOverflowError。您可以分析應用程式,找出要設定的最佳執行緒堆疊大小。

針對 Cloud Run 服務,請設定環境變數:

JAVA_TOOL_OPTIONS="-Xss256k"

減少執行緒

您可以減少執行緒數量、採用非阻斷式回應策略,並避免背景活動,藉此最佳化記憶體。

減少執行緒數量

每個 Java 執行緒可能會因執行緒堆疊而增加記憶體用量。Cloud Run 最多允許 1,000 個並行要求。使用每個連線一個執行緒的模型時,您最多需要 1000 個執行緒來處理所有並行要求。大多數的網路伺服器和架構都允許您設定執行緒和連線的數量上限。舉例來說,在 Spring Boot 中,您可以在 applications.properties 檔案中設定連線上限:

server.tomcat.max-threads=80

編寫非阻斷式回應程式碼,以最佳化記憶體和啟動程序

如要真正減少執行緒數量,請考慮採用非阻斷式回應式程式設計模型,這樣就能在處理更多並行要求的同時,大幅減少執行緒數量。應用程式架構 (例如 Spring Boot 搭配 Webflux、Micronaut 和 Quarkus) 支援回應式網頁應用程式。

反應式架構 (例如 Spring Boot 搭配 Webflux、Micronaut、Quarkus) 的啟動時間通常較快。

如果您繼續在非阻斷式架構中編寫阻斷式程式碼,Cloud Run 服務的傳輸量和錯誤率會大幅惡化。這是因為非阻斷式架構只會有幾個執行緒,例如 2 或 4 個。如果程式碼會阻斷,則只能處理極少的並行要求。

這些非阻斷式架構也可能將阻斷程式碼卸載至無界限的執行緒集區,也就是說,雖然可以接受許多並發要求,但阻斷程式碼會在新執行緒中執行。如果執行緒以無限方式累積,您將耗盡 CPU 資源並開始耗損。延遲時間會受到嚴重影響。如果您使用非阻斷式架構,請務必瞭解執行緒集區模型,並據此繫結集區。

如果您使用背景活動,請設定以執行個體為基礎的計費方式

背景活動是指在 HTTP 回應送出後發生的任何活動。在 Cloud Run 中執行含有背景工作的傳統工作負載時,需要特別考量。

設定以執行個體為基礎的帳單

如果您想在 Cloud Run 服務中支援背景活動,請將 Cloud Run 服務設為以執行個體為基礎的帳單計費方式,這樣您就能在要求之外執行背景活動,同時仍可存取 CPU。

如要使用以要求為基礎的結帳系統,請避免背景活動

如果您需要將服務設為以要求計費,請留意背景活動可能發生的問題。舉例來說,如果您收集應用程式指標,並在背景批次處理指標以定期傳送,則在設定以要求為基礎的結帳時,系統就不會傳送這些指標。如果應用程式持續收到要求,您可能會發現較少的問題。如果應用程式的 QPS 偏低,則背景工作可能永遠不會執行。

如果您選擇以要求為基礎的結算方式,請留意以下幾種常見的背景模式:

  • JDBC 連線集區:清理和連線檢查通常會在背景執行
  • 分散式追蹤傳送器:分散式追蹤通常會定期以批次傳送,或是在背景緩衝區已滿時傳送。
  • 指標傳送者:指標通常會在背景定期批次傳送。
  • 針對 Spring Boot,任何含有 @Async 註解的方法
  • 計時器:任何以計時器為準的觸發條件 (例如設定以要求為基礎的計費功能時,ScheduledThreadPoolExecutor、Quartz 或 @Scheduled Spring 註解可能無法執行。
  • 訊息接收器:例如 Pub/Sub 串流拉取用戶端、JMS 用戶端或 Kafka 用戶端,通常會在背景執行緒中執行,不需要要求。如果應用程式沒有要求,這些方法就無法運作。我們不建議在 Cloud Run 中以這種方式接收訊息。

應用程式最佳化

您也可以在 Cloud Run 服務程式碼中進行最佳化,以縮短啟動時間和記憶體使用量。

減少啟動工作

傳統的 Java 網路應用程式在啟動期間可能需要完成許多工作,例如預先載入資料、暖機快取、建立連線集區等等。這些工作如果依序執行,可能會很慢。不過,如果要同時執行這些作業,請增加 CPU 核心數量。

Cloud Run 目前會傳送實際使用者要求,以觸發冷啟動執行個體。將要求指派給新啟動的執行個體時,使用者可能會遇到長時間延遲的情況。Cloud Run 目前沒有「就緒」檢查功能,因此無法避免將要求傳送至未就緒的應用程式。

使用連線集區

如果您使用連線集區,請注意連線集區可能會在背景中移除不需要的連線 (請參閱「避免背景工作」)。如果應用程式的每秒查詢次數偏低,且可以容許較高的延遲時間,建議您針對每個要求開啟及關閉連線。如果應用程式有高 QPS,只要有有效要求,背景淘汰作業就會繼續執行。

無論是哪種情況,應用程式的資料庫存取權都會受到資料庫允許的最大連線數限制。計算每個 Cloud Run 執行個體可建立的連線數量上限,並設定 Cloud Run 執行個體數量上限,以便每個執行個體的連線數量上限乘以執行個體數量小於允許的連線數量上限。

如果您使用 Spring Boot

如果您使用 Spring Boot,請考慮下列最佳化方式

使用 Spring Boot 2.2 以上版本

從 2.2 版開始,Spring Boot 已針對啟動速度進行大量最佳化。如果您使用的 Spring Boot 版本低於 2.2,建議您升級,或手動套用個別最佳化程序

使用延遲初始化

在 Spring Boot 2.2 以上版本中,您可以開啟全域延遲初始化旗標。這麼做雖然可加快啟動速度,但代價是第一個要求的延遲時間可能會變長,因為它必須等待元件首次初始化。

您可以在 application.properties 中啟用延遲初始化功能:

spring.main.lazy-initialization=true

或者,您也可以使用環境變數:

SPRING_MAIN_LAZY_INITIALIZATIION=true

不過,如果您使用的是最小執行個體,則延遲初始化功能不會有所幫助,因為最小執行個體啟動時就應該已完成初始化。

避免掃描類別

在 Cloud Run 中,磁碟存取通常比一般機器慢,因此類別掃描會導致 Cloud Run 額外讀取磁碟。請確保 Component Scan 受到限制或完全避免。

請勿在正式版中使用 Spring Boot 開發人員工具

如果您在開發期間使用Spring Boot 開發人員工具,請確認該工具未封裝在正式版容器映像檔中。如果您在建構 Spring Boot 應用程式時未使用 Spring Boot 建構外掛程式 (例如使用 Shade 外掛程式,或使用 Jib 進行容器化),就可能發生這種情況。

在這種情況下,請確認建構工具明確排除 Spring Boot 開發人員工具。或者,明確關閉 Spring Boot 開發人員工具)。

後續步驟

如需更多提示,請參閱