Mengoptimalkan aplikasi Java pada Cloud Run

Panduan ini menjelaskan pengoptimalan pada layanan Cloud Run yang ditulis dalam bahasa pemrograman Java, beserta informasi latar belakang untuk membantu Anda memahami kompromi yang terlibat pada beberapa pengoptimalan. Informasi di halaman ini melengkapi tips pengoptimalan umum, yang juga berlaku pada Java.

Aplikasi berbasis web Java tradisional didesain untuk menyajikan permintaan dengan konkurensi tinggi dan latensi rendah, serta cenderung merupakan aplikasi yang berjalan lama. JVM sendiri juga mengoptimalkan kode eksekusi seiring waktu dengan JIT sehingga hot paths dioptimasi dan aplikasi dapat berjalan lebih efisien seiring waktu.

Sebagian besar praktik terbaik dan pengoptimalan dalam aplikasi berbasis web Java tradisional ini berkisar tentang:

  • Menangani permintaan serentak (baik I/O yang berbasis thread maupun yang tidak memblokir)
  • Mengurangi latensi respons menggunakan penggabungan koneksi dan mengelompokkan fungsi yang tidak penting. Misalnya, mengirim trace dan metrik ke tugas latar belakang.

Meskipun sebagian besar pengoptimalan tradisional ini berfungsi dengan baik pada aplikasi yang berjalan lama, pengoptimalan ini mungkin tidak berfungsi dengan baik di dalam layanan Cloud Run yang hanya berjalan saat aktif menyajikan permintaan. Halaman ini akan mengarahkan Anda melakukan beberapa pengoptimalan dan kompromi yang berbeda pada Cloud Run yang dapat Anda gunakan untuk mengurangi waktu startup dan penggunaan memori.

Menggunakan peningkatan CPU startup untuk mengurangi latensi startup

Anda dapat mengaktifkan peningkatan CPU startup untuk meningkatkan alokasi sementara CPU selama startup instance guna mengurangi latensi startup.

Metrik Google telah menunjukkan bahwa aplikasi Java mendapatkan manfaat jika menggunakan startup CPU boost yang dapat mengurangi waktu startup hingga 50%.

Mengoptimalkan image container

Dengan mengoptimalkan image container, Anda dapat mengurangi waktu pemuatan dan startup. Anda dapat mengoptimalkan image dengan:

  • Meminimalkan image container
  • Menghindari penggunaan JAR dengan arsip library susun bertingkat
  • Menggunakan Jib

Minimalkan image container

Buka halaman tips umum tentang meminimalkan container untuk mengetahui konteks selengkapnya tentang masalah ini. Halaman tips umum merekomendasikan untuk mengurangi isi image container hanya untuk sesuai kebutuhan. Contoh, pastikan image container Anda tidak berisi :

  • Kode sumber
  • Artefak build Maven
  • Alat pembuat
  • Direktori Git
  • Biner/utilitas yang tidak digunakan

Jika Anda mem-build kode dari dalam Dockerfile, gunakan build multitahap Docker sehingga image container terakhir hanya memiliki JRE dan file JAR aplikasi itu sendiri.

Hindari JAR dengan arsip library susun bertingkat

Beberapa framework yang populer, seperti Spring Boot, membuat file arsip aplikasi (JAR) yang berisi file JAR library tambahan (JAR susun bertingkat). File ini perlu diekstrak/didekompresi selama waktu startup dan dapat meningkatkan kecepatan startup di Cloud Run. Jika memungkinkan, buat JAR tipis dengan library eksternal: hal ini dapat diotomatiskan dengan menggunakan Jib untuk menyimpan aplikasi Anda dalam container

Penggunaan Jib

Gunakan plugin Jib untuk membuat container minimal dan meratakan arsip aplikasi secara otomatis. Jib berfungsi dengan Maven dan Gradle, serta berfungsi dengan aplikasi Spring Boot yang siap pakai. Beberapa framework aplikasi mungkin memerlukan konfigurasi Jib tambahan.

Pengoptimalan JVM

Mengoptimalkan JVM untuk layanan Cloud Run dapat menghasilkan performa dan penggunaan memori yang lebih baik.

Penggunaan Versi JVM yang container aware

Di VM dan mesin, untuk alokasi CPU dan memori, JVM memahami CPU dan memori yang dapat digunakan dari lokasi yang sudah diketahui, misalnya, di Linux, /proc/cpuinfo, dan /proc/meminfo. Namun, saat berjalan di dalam container, batasan CPU dan memori disimpan di /proc/cgroups/.... Versi JDK yang lebih lama terus memeriksa di /proc, bukan di /proc/cgroups yang dapat mengakibatkan lebih banyak penggunaan CPU dan memori daripada yang ditetapkan. Hal ini dapat menyebabkan:

  • Jumlah thread yang berlebih karena ukuran pool thread dikonfigurasi oleh Runtime.availableProcessors()
  • Heap maks default yang melebihi batas memori container. JVM secara agresif menggunakan memori sebelum mengumpulkan sampah. Hal ini dapat dengan mudah menyebabkan container tersebut melebihi batas memori container, dan mengalami OOMKilled.

Jadi, gunakan versi JVM yang container aware. Versi OpenJDK yang lebih besar atau sama dengan versi 8u192 secara default container aware.

Cara memahami Penggunaan Memori JVM

Penggunaan memori JVM terdiri dari penggunaan memori native dan penggunaan heap. Memori kerja aplikasi Anda biasanya berada di dalam heap. Ukuran heap tersebut dibatasi oleh konfigurasi Heap Maks. Dengan instance Cloud Run RAM 256 MB, Anda tidak dapat menetapkan seluruh 256 MB ke Heap Maks karena JVM dan OS juga memerlukan memori native, misalnya, thread stack, cache kode, penanganan file, buffer, dll. Jika aplikasi Anda mengalami OOMKilled dan Anda perlu mengetahui penggunaan memori JVM (memori native + heap), aktifkan Native Memory Tracking untuk melihat penggunaan setelah aplikasi berhasil keluar. Jika aplikasi Anda mengalami OOMKilled, aplikasi tersebut tidak akan dapat mencetak informasi. Dalam hal ini, jalankan aplikasi dengan memori yang lebih banyak terlebih dahulu agar berhasil menghasilkan output.

Native Memory Tracking tidak dapat diaktifkan melalui JAVA_TOOL_OPTIONS variabel lingkungan. Anda perlu menambahkan argumen startup command line Java ke entrypoint image container sehingga aplikasi Anda dimulai dengan argumen ini:

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

Penggunaan memori native dapat diperkirakan berdasarkan jumlah class yang akan dimuat. Pertimbangkan untuk menggunakan Java Memory Calculator open source untuk memperkirakan kebutuhan memori.

Menonaktifkan compiler pengoptimalan

Secara default, JVM memiliki beberapa fase kompilasi JIT. Meskipun fase ini meningkatkan efisiensi aplikasi Anda dari waktu ke waktu, fase ini juga dapat menambah overhead pada penggunaan memori dan meningkatkan waktu startup.

Untuk aplikasi serverless yang berjalan singkat (misalnya, fungsi), pertimbangkan untuk menonaktifkan fase pengoptimalan untuk mengorbankan efisiensi jangka panjang demi mengurangi waktu startup.

Untuk layanan Cloud Run, konfigurasikan variabel lingkungan:

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

Penggunaan berbagi data class aplikasi

Untuk mengurangi kelebihan waktu JIT dan penggunaan memori, pertimbangkan untuk menggunakan berbagi data class aplikasi (AppCDS) untuk membagikan class Java yang dikompilasi terlebih dahulu sebagai arsip. Arsip AppCDS dapat digunakan kembali saat memulai instance yang lain dari aplikasi Java yang sama. JVM dapat menggunakan kembali data yang telah dihitung sebelumnya dari arsip sehingga mengurangi waktu startup.

Pertimbangan berikut berlaku untuk penggunaan AppCDS:

  • Arsip AppCDS yang akan digunakan kembali harus direproduksi oleh distribusi, versi, dan arsitektur OpenJDK yang sama persis dengan yang awalnya digunakan untuk memproduksinya.
  • Anda harus menjalankan aplikasi Anda minimal sekali untuk membuat daftar class yang akan dibagikan, lalu menggunakan daftar tersebut untuk membuat arsip AppCDS.
  • Cakupan class tersebut bergantung pada codepath yang dieksekusi selama menjalankan aplikasi. Untuk meningkatkan cakupan tersebut, picu lebih banyak codepath secara terprogram.
  • Aplikasi harus berhasil keluar untuk membuat daftar class ini. Sebaiknya terapkan flag aplikasi yang digunakan untuk menunjukkan pembuatan arsip AppCDS agar dapat segera keluar.
  • Arsip AppCDS dapat digunakan kembali jika Anda meluncurkan instance baru dengan cara yang sama persis seperti arsip yang dibuat.
  • Arsip AppCDS hanya berfungsi dengan paket file JAR reguler; Anda tidak bisa menggunakan {i>nested JAR<i}.

Contoh Spring Boot yang menggunakan file JAR terinkorporasi

Aplikasi Spring Boot menggunakan JAR uber bertingkat secara default yang tidak akan berfungsi untuk AppCDS. Jadi, jika menggunakan AppCDS, Anda perlu membuat JAR yang terinkorporasi. Misalnya, menggunakan Maven dan Maven Shade Plugin:

<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>

Jika JAR yang terinkorporasi berisi semua dependensi, Anda dapat memproduksi arsip sederhana selama pembuatan container menggunakan 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

Mengurangi ukuran stack thread

Sebagian besar aplikasi web Java berbasis thread per koneksi. Setiap thread Java memakai memori native (bukan di dalam heap). Hal ini dikenal sebagai stack thread dan ditetapkan secara default ke 1 MB per thread. Jika aplikasi Anda menangani 80 permintaan serentak, aplikasi tersebut mungkin memiliki setidaknya 80 thread, yang berarti 80 MB ruang stack thread yang digunakan. Memori tersebut merupakan tambahan untuk ukuran heap. Defaultnya mungkin lebih besar dari yang diperlukan. Anda dapat mengurangi ukuran stack thread.

Jika mengurangi terlalu banyak, Anda akan melihat java.lang.StackOverflowError. Anda dapat membuat profil aplikasi dan menemukan ukuran stack thread yang optimal untuk dikonfigurasi.

Untuk layanan Cloud Run, konfigurasikan variabel lingkungan:

JAVA_TOOL_OPTIONS="-Xss256k"

Mengurangi thread

Anda dapat mengoptimalkan memori dengan mengurangi jumlah thread, yaitu dengan cara menggunakan strategi reaktif yang tidak memblokir dan menghindari aktivitas latar belakang.

Mengurangi jumlah thread

Setiap thread Java dapat meningkatkan penggunaan memori karena Thread Stack. Cloud Run mengizinkan maksimum 1.000 permintaan serentak. Dengan model thread per koneksi, Anda memerlukan maksimum 1.000 thread untuk menangani semua permintaan serentak. Sebagian besar server web dan framework memungkinkan Anda mengonfigurasi jumlah maksimum thread dan koneksi. Misalnya, di Spring Boot, Anda dapat membatasi koneksi maksimum dalam file applications.properties:

server.tomcat.max-threads=80

Menulis kode reaktif non-pemblokiran untuk mengoptimalkan memori dan startup

Untuk benar-benar mengurangi jumlah thread, pertimbangkan untuk mengadopsi model pemrograman reaktif non-pemblokiran sehingga jumlah thread dapat dikurangi secara signifikan saat menangani lebih banyak permintaan serentak. Framework aplikasi seperti Spring Boot dengan Webflux, Micronaut, dan Quarkus mendukung aplikasi web reaktif.

Framework reaktif seperti Spring Boot dengan Webflux, Micronaut, Quarkus umumnya memiliki waktu startup yang lebih cepat.

Jika Anda terus menulis kode pemblokiran dalam framework yang non-pemblokiran, throughput dan tingkat error akan jauh lebih buruk di layanan Cloud Run. Hal ini dikarenakan framework non-pemblokiran hanya akan memiliki beberapa thread, misalnya, 2 atau 4. Jika kode Anda terblokir, kode tersebut hanya dapat menangani sedikit permintaan serentak.

Framework non-pemblokiran ini juga dapat memindahkan kode pemblokir ke pool thread tanpa batas. Artinya, meskipun dapat menerima banyak permintaan serentak, kode pemblokiran akan dieksekusi dalam thread baru. Jika thread terakumulasi secara tidak terbatas, Anda akan menghabiskan resource CPU dan memulai thrash. Latensi akan sangat parah. Jika menggunakan framework non-pemblokiran, pastikan Anda memahami model pool thread dan mengikat pool tersebut dengan sesuai.

Mengonfigurasi CPU agar selalu dialokasikan jika Anda menggunakan aktivitas latar belakang

Aktivitas latar belakang meliputi segala sesuatu yang terjadi setelah respons HTTP Anda dikirimkan. Workload tradisional yang memiliki tugas latar belakang memerlukan pertimbangan khusus saat berjalan di Cloud Run.

Mengonfigurasi CPU agar selalu dialokasikan

Jika Anda ingin mendukung aktivitas latar belakang di layanan Cloud Run, tetapkan layanan Cloud Run CPU agar selalu dialokasikan, sehingga Anda dapat menjalankan aktivitas latar belakang di luar permintaan dan tetap memiliki akses ke CPU.

Menghindari aktivitas latar belakang jika CPU dialokasikan hanya selama pemrosesan permintaan

Jika perlu menyetel layanan untuk mengalokasikan CPU hanya selama pemrosesan permintaan, Anda perlu mengetahui potensi masalah pada aktivitas latar belakang. Misalnya, jika Anda mengumpulkan metrik aplikasi dan mengelompokkan metrik di latar belakang untuk dikirim secara berkala, metrik tersebut tidak akan dikirim saat CPU tidak dialokasikan. Jika aplikasi terus-menerus menerima permintaan, Anda mungkin akan melihat lebih sedikit masalah. Jika aplikasi Anda memiliki QPS yang rendah, tugas di latar belakang mungkin tidak akan pernah dijalankan.

Beberapa pola terkenal yang berada di latar belakang dan perlu diperhatikan jika Anda memilih untuk mengalokasikan CPU hanya selama pemrosesan permintaan:

  • JDBC Connection Pools - pembersihan dan pemeriksaan koneksi biasanya terjadi di latar belakang
  • Distributed Trace Senders - Trace yang terdistribusi biasanya dikelompokkan dan dikirim secara berkala atau saat buffer penuh di latar belakang.
  • Metrics Senders - Metrik biasanya dikelompokkan dan dikirim secara berkala di latar belakang.
  • Untuk Spring Boot, metode apa pun yang dianotasi dengan anotasi @Async
  • Timers - semua pemicu berbasis Timer (misalnya, anotasi ScheduledThreadPoolExecutor, Quartz, atau Spring @Scheduled) mungkin tidak dijalankan saat CPU tidak dialokasikan.
  • Message receivers - Misalnya, klien pull streaming Pub/Sub, klien JMS, atau klien Kafka, biasanya berjalan di thread latar belakang tanpa memerlukan permintaan. Hal ini tidak akan berfungsi apabila aplikasi Anda tidak memiliki permintaan. Tidak direkomendasikan untuk menerima pesan dengan cara ini di Cloud Run.

Pengoptimalan aplikasi

Dalam kode layanan Cloud Run, Anda juga dapat mengoptimalkan waktu startup dan penggunaan memori yang lebih cepat.

Mengurangi tugas startup

Aplikasi berbasis web Java tradisional dapat memiliki banyak tugas untuk diselesaikan selama startup, misalnya, pramuat data, pemanasan cache, pembuatan pool koneksi, dll. Tugas ini menjadi lambat saat dijalankan secara berurutan. Namun, jika ingin dijalankan secara paralel, Anda harus meningkatkan jumlah core CPU.

Cloud Run saat ini mengirimkan permintaan pengguna yang sebenarnya untuk memicu instance cold start. Pengguna yang memiliki permintaan ditetapkan ke instance yang baru dimulai, mungkin akan mengalami penundaan yang lama. Cloud Run saat ini tidak memiliki pemeriksaan "kesiapan" untuk menghindari pengiriman permintaan ke aplikasi yang belum dibaca.

Penggunaan penggabungan koneksi

Jika Anda menggunakan pool koneksi, perhatikan bahwa pool koneksi dapat mengeluarkan koneksi yang tidak diperlukan di latar belakang (lihat Menghindari tugas latar belakang). Jika aplikasi Anda memiliki QPS rendah, dan dapat menoleransi latensi tinggi, pertimbangkan untuk membuka dan menutup koneksi per permintaan. Jika aplikasi Anda memiliki QPS tinggi, penghapusan latar belakang dapat terus dijalankan selama ada permintaan aktif.

Dalam kedua kasus tersebut, akses database aplikasi akan terhambat oleh koneksi maksimum yang diizinkan database Hitung koneksi maksimum yang dapat Anda bangun per instance Cloud Run, dan mengonfigurasi instance maksimum Cloud Run sehingga waktu instance maksimum koneksi per instance kurang dari koneksi maksimum yang diizinkan.

Jika Anda menggunakan Spring Boot

Jika menggunakan Spring Boot, Anda perlu mempertimbangkan pengoptimalan berikut

Menggunakan Spring Boot versi 2.2 atau yang lebih baru

Sejak versi 2.2, Spring Boot telah sangat dioptimalkan untuk kecepatan startup. Jika Anda menggunakan versi Spring Boot di bawah 2.2, sebaiknya upgrade atau terapkan pengoptimalan satu per satu secara manual.

Penggunaan inisialisasi lambat

Ada flag inisialisasi lambat global yang dapat diaktifkan di Spring Boot 2.2 dan yang lebih baru. Tindakan ini akan meningkatkan kecepatan startup, tetapi dengan kompromi, permintaan pertama mungkin memiliki latensi yang lebih lama karena harus menunggu komponen melakukan inisialisasi untuk pertama kalinya.

Anda dapat mengaktifkan inisialisasi lambat di application.properties:

spring.main.lazy-initialization=true

Atau dengan menggunakan variabel lingkungan:

SPRING_MAIN_LAZY_INITIALIZATIION=true

Namun, jika Anda menggunakan instance minimal, inisialisasi berdasarkan permintaan tidak akan membantu karena inisialisasi seharusnya terjadi saat instance minimal dimulai.

Menghindari pemindaian class

Pemindaian class akan menyebabkan pembacaan disk tambahan di Cloud Run, karena di Cloud Run, akses disk umumnya lebih lambat daripada mesin biasa. Pastikan Component Scan dibatasi atau sepenuhnya dihindari. Pertimbangkan untuk menggunakan Spring Context Indexer, guna membuat indeks terlebih dahulu. Apakah hal ini akan meningkatkan kecepatan startup tergantung aplikasi Anda.

Misalnya, di pom.xml Maven Anda, tambahkan dependensi pengindeks (sebenarnya hal ini adalah pemroses anotasi):

<dependency>
  <groupId>org.springframework</groupId>
  <artifactId>spring-context-indexer</artifactId>
  <optional>true</optional>
</dependency>

Penggunaan alat developer Spring Boot yang tidak dalam produksi

Jika Anda menggunakan Spring Boot Developer Tool selama pengembangan, pastikan alat tersebut tidak dikemas dalam image container produksi. Hal ini dapat terjadi jika Anda membangun aplikasi Spring Boot tanpa plugin build Spring Boot (misalnya, menggunakan plugin Shade, atau menggunakan Jib untuk menyimpan).

Dalam kasus ini, pastikan alat build mengecualikan alat Spring Boot Dev secara eksplisit. Atau, nonaktifkan Spring Boot Developer Tool secara eksplisit).

Langkah selanjutnya

Untuk tips lainnya, lihat