18 Visual Guides to Mastering Java Containerization Best Practices
This guide walks through choosing base images, JDK vs JRE, Oracle versus OpenJDK, JVM implementations, signal handling for graceful shutdown, memory‑limit adaptation across JDK versions, DNS caching behavior, and GraalVM native compilation, providing concrete Dockerfile examples, benchmark results, and actionable recommendations for Java containerization.
1. Base Image Selection
For the underlying image you typically have three choices: Alpine , Debian , and CentOS . CentOS is familiar to many ops teams but is no longer maintained, so the author avoids it. Between Alpine and Debian, Alpine is smaller but uses musl instead of glibc, which can cause compatibility issues for applications that rely heavily on glibc (e.g., JNI code). The recommendation is to use Debian‑based images when deep glibc dependencies exist; otherwise Alpine is acceptable, though the author personally prefers Debian because the OpenJDK binary size is already large.
2. JDK vs JRE
The article clarifies the difference: the JDK includes development tools ( javac, jps, jstack, jmap) and contains the JRE, while the JRE only provides the runtime environment and is smaller. For running a simple .jar, JRE suffices; for debugging or production troubleshooting the author prefers using the JDK as the base image, unless image size is a critical concern.
3. JDK Choice
3.1 Oracle JDK vs OpenJDK
If the application uses Oracle‑specific private APIs (e.g., classes under com.sun.*), Oracle JDK is required because those APIs may be missing or changed in OpenJDK. Often such imports are accidental and can be replaced with alternatives like Apache Commons; cleaning up unused imports resolves the issue.
3.2 Rebuilding Oracle JDK
When Oracle JDK must be used, download the tarball and build a custom Docker image, keeping the original package for future rebuilds because Oracle JDK rarely provides historical versions.
3.3 OpenJDK Distributions
Popular OpenJDK builds include AdoptOpenJDK (now Eclipse Adoptium), Amazon Corretto, IBM Semeru Runtime, Azul Zulu, and Liberica JDK. AdoptOpenJDK offers Alpine, Ubuntu, and CentOS base images and is community‑driven; Corretto and Semeru are backed by major cloud vendors. The author favors AdoptOpenJDK (Eclipse Temurin) and recommends the eclipse-temurin repository on Docker Hub.
4. JVM Selection
Any JVM that complies with the official specification and is certified can be used in production. Common implementations are HotSpot, OpenJ9, TaobaoVM, LiquidVM, and Azul Zing. HotSpot is the default with balanced performance and compatibility; OpenJ9 (from IBM) is more container‑friendly with faster startup and lower memory usage. The author prefers OpenJ9 and suggests using the ibm-semeru-runtimes images.
5. Signal Propagation (Graceful Shutdown)
Kubernetes sends a termination signal to PID 1 inside the container. If the Java process receives it, frameworks like Spring Boot perform graceful shutdown. Incorrect signal handling leads to forced termination, resource leaks, and unclosed database connections.
Test Project : a Spring Boot sample with a @PreDestroy hook prints a shutdown message. Several Dockerfile variants are compared:
Dockerfile.bad : runs a bash entrypoint script; signals are not forwarded, causing docker stop to hang until timeout.
Dockerfile.direct : runs java -jar … directly via CMD; signals are correctly received.
Dockerfile.exec : uses an entrypoint script that exec ’s the Java command; signals are forwarded.
Dockerfile.bash‑c : runs bash -c "java -jar …"; works for simple commands but may fail with pipelines or redirects.
Dockerfile.tini and Dockerfile.dumb‑init : add init systems to reap zombies, but they do not guarantee graceful shutdown because the parent process may exit before child processes finish.
Best Practices :
Include tini or dumb‑init to avoid zombie processes.
Do not rely on them alone for graceful shutdown.
Use a simple CMD with the Java command for direct signal handling.
For complex startup scripts, use exec to replace the shell with the Java process. bash -c works for simple cases but should be tested.
6. Memory Limits
Java’s default heap sizing does not respect container memory limits in older JDKs, leading to OOM kills. The article tests several versions with docker run -m 512m and inspects MaxHeapSize via java -XX:+PrintFlagsFinal -version (or -XshowSettings:vm).
6.1 Without Explicit Flags
OpenJDK 8u111 : no container support; heap size is based on host memory.
OpenJDK 8u131 : introduces -XX:+UseCGroupMemoryLimitForHeap but does not work by default.
OpenJDK 8u222 : adds -XX:+UseContainerSupport (back‑ported from JDK 10) but still ineffective without flags.
OpenJDK 11.0.15 : container support flag is enabled by default, yet the test shows the heap does not adapt.
OpenJDK 11.0.16 and OpenJDK 17 : automatically adapt the heap to the container limit (e.g., from ~4 GB to 120 MB).
6.2 With Flags
Enabling
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeapon 8u131 and -XX:+UseContainerSupport on 8u222 still yields no effect in the author’s environment. From JDK 11 onward, the flag is on by default, so results match the “without flags” case.
6.3 Analysis
The inconsistent behavior is attributed to the transition from cgroups v1 to cgroups v2. Under cgroups v1, the flags listed above enable memory adaptation. Under cgroups v2, only JDK 11.0.16+ (and later) support automatic adaptation; earlier versions ignore the flags.
Reference: JDK‑8230305 for detailed cgroups v2 support.
7. DNS Caching
JVM controls DNS caching. By default the TTL is 30 seconds; when a Security Manager is enabled, the TTL becomes -1 (cache forever). The article provides a script jvm-dns-ttl-policy.sh that builds Docker images for JDK 8, 11, 17, runs a small program printing sun.net.InetAddressCachePolicy.get(), and shows the default TTL values.
To enforce a specific TTL, pass -Dsun.net.inetaddr.ttl=XXX at runtime. This overrides the default regardless of the Security Manager. For deeper debugging, the author suggests Alibaba’s open‑source DCM tool.
8. Native Compilation
GraalVM can compile Java applications to native binaries, dramatically reducing startup time. However, it requires code adjustments and framework upgrades, making it more suitable for new projects. The sample project already includes GraalVM support; setting JAVA_HOME and PATH to a GraalVM distribution and running mvn clean package -Dmaven.test.skip=true -Pnative produces an executable in target. Benchmarks show a ten‑fold startup speed improvement, but the author notes that the Java ecosystem in China still largely uses OpenJDK 8, so native compilation is recommended only for greenfield projects.
Conclusion
The article consolidates practical, experimentally verified recommendations for containerizing Java applications, covering base image choice, JDK/JRE selection, JVM implementation, graceful shutdown handling, memory‑limit adaptation across JDK versions, DNS cache configuration, and optional native compilation.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Architect's Guide
Dedicated to sharing programmer-architect skills—Java backend, system, microservice, and distributed architectures—to help you become a senior architect.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
