Building Efficient CI/CD Pipelines and Optimizing Java Memory for Kubernetes
Efficient CI/CD pipelines and optimized Java memory management are essential for modern backend systems, especially in containerized environments like Kubernetes.
This guide provides practical steps to streamline deployments and configure Java memory to prevent resource overages and pod failures.
PRODUCT DEVELOPMENT SERVICES
Basic implementation details
The basic backend CI/CD pipeline should consist of the following steps:
- Build step
- Compile/transpile the application
- Run unit tests
- Run integration tests
- Run a static code analysis
- Create a docker image (use Git rev number for the image name)
- It should be executed on the main and feature branches (the build should run on each branch, even without an explicit Pull Request)
- Package step
- Upload docker image to AWS ECR
- Use AWS credentials as secret variables in GitHub actions
- It should be executed on the main branch only
- Deploy to dev
- Download the docker image
- Deploy to EKS or other docker orchestration tool delivered by the DevOps team
- It should be executed on the main branch only
- Deploy to prod
- By default, it should be started manually by pushing a button ad hoc
- Download the docker image
- Deploy to EKS or other docker orchestration tool delivered by the DevOps team
- It should be executed on the main branch only
Java memory configuration (Tested on JVM 21)
Starting with version 10, the JVM uses the -XX:+UseContainerSupport option by default, which should address memory management issues in containerized environments. However, it doesnāt work as expected. According to the documentation:
āIf your application is running in a container that imposes a memory limit, the VM allocates a larger fraction of memory to the Java heap.ā docs
This setting only controls the heap memory. What other memory areas does the JVM use and allocate separately from the heap?
- Java Heap
- Metaspace
- Thread
- Code
- GC
- Compiler
- Internal
- Other
- Symbol
- And there are a few others whose purposes I am not entirely sure about.
So, how can you configure a Java process to avoid being killed for exceeding the memory limits of a Kubernetes pod? The key is to reserve memory for the largest allocations and leave a buffer for the rest. A good configuration might include:
- -Xms400m -Xmx400m – allocates a fixed 400 MB for the heap, ensuring that the podās memory usage reflects the full heap allocation from the start. For most Java applications, 400 MB should be sufficient, or it could even be reduced further if needed.
- -XX:MaxMetaspaceSize=150m – sets a limit for Metaspace, which stores metadata like class definitions, method data, and field data. This allocation is often a significant portion of memory, so it’s important to set a cap.
- -XX:ReservedCodeCacheSize=128m -XX:+UseCodeCacheFlushing – the code cache is responsible for storing JIT-optimized code for frequently used code blocks. By default, the JVM does not release this memory, but enabling code cache flushing allows it to remove outdated or unused optimizations.
- Each thread reserves approximately 1 MB of memory.
How much memory will the application use? To determine how much memory to reserve for the pod, you can use this formula:
Java heap + Metaspace + Code Cache + Threads + 100 MB buffer
For a deeper analysis of JVM memory usage, consider tools like Native Memory Tracking or JProfiler. These tools require setup but provide detailed insights into each memory category.
By implementing the outlined CI/CD steps and fine-tuning Java memory settings, you can ensure efficient workflows and stable applications in Kubernetes. These practices help minimize downtime, optimize resource usage, and simplify deployments. Start with the basics, refine as needed, and let your infrastructure work for you.