Conf42 Cloud Native 2024 - Online

The good, the bad, the native

Abstract

Come discover my personal journey in the native compilation of a JVM-based application. We will see the good things, as well as the pains, the struggles, and some of the ways I’ve found to be effective to solve difficult moments.

Summary

  • Gregorio Palama is a DevOps and cloud engineer in Finwave. He is a Google Cloud innovator champion and also a community manager in GDG Pescara. You can find him on Twitter or on LinkedIn.
  • Cloud native technology is related, strictly related to innovation, and that's important. What does it mean in terms of cloud native efficiency? We have scalability. Cloud native applications make the most of modern infrastructure's dynamic distributed nature. They achieve greater speed, agility, scalability, reliability and cost efficiency.
  • gralvm native image will help us to optimize memory and CPU consumption. What makes Java portable is the JVM technology. It allows us to use a single bytecode everywhere where the same JVM is present. The more we say okay, the less we can scale and share resources.
  • On GralvM we have a gral compiler instead of a C two compiler. We also have a native image compilation. The ahead of time compilation can't execute our application, so it has to perform a static analysis of our code. After our build phase, and our output will be the native executable.
  • We wanted to achieve a smaller memory footprint and we also wanted to have less CPU consumption and a lower startup time. All of these achievement helps us to have a better scalability, to lower the costs and to have higher availability.
  • native image generates metadata performing static analysis. Some dynamic features require additional configuration. To be sure that everything is collected we have to manually create those metadata and give them to the native image generation.
  • Using ralbm native image we have to remember to use the tracing agent. And test the native executable or perform native tests. Everything that we've seen with the demo application using spring is exactly the same and is valid for every microservices framework built with a language on the JVM.

Transcript

This transcript was autogenerated. To make changes, submit a PR.
Hello everyone. One welcome to my talk. The good, the bad, the native. I am Gregorio Palama and I will shortly introduce myself. I work as a DevOps and cloud engineer in Finwave. I am a Google Cloud innovator champion and I also am a community manager in GDG Pescara. You can find me on Twitter or on LinkedIn and here you can find the QR codes to my profiles. Okay, let's get into details and let's start with a question. What is cloud native? I used the definition that Priyanka Sharma, the CNCF general manager loves to use when she talks to people that is attached people or to people that is not touch at all. And she says that cloud native technologies, when engineers and software people utilize cloud computing to build touch, that's faster and more resilient, and they do that to meet customer demand really quickly. Well, what we can understand from these words is that cloud native technology is something that is related, strictly related to innovation, and that's important. Moreover, if we search about the definition of cloud native, we can find that the three major cloud platforms, public cloud platforms, offer a whole page to give a definition of what cloud native is. And even the links in this slide can show us how much Google, Amazon and Microsoft believe that it's important to give a strong definition to what this technology is. I use the definition that Google gives to cloud native to going further into details and to understand what we are addressing in things talk. First of all, we are talking about using microservices. Also, when we use microservices, we are using containers because containers, as CNCF says, are the building blocks of the cloud. As for now, of course, when we use containers, we have to orchestrate them. So again, containers are the building blocks of the cloud. But Kubernetes is, again, as CNCF says, Kubernetes is the operating system for the cloud. So we have to orchestrate containers in order to have a microservice solution in the cloud native world. Well, what does it mean in terms of cloud native efficiency? We have scalability. Cloud native architectures employ infrastructure automation, helping to eliminate downtime due to human error. We can balance load based on demand, allowing you us to optimize cost and performance better. What's important here is that we want to optimize cost and we want better performance. Lower cost? Well, we want better performance. But detailed optimized cost is referring to an objective that we want to reach to lower costs. And a streamlined software delivery process reduces the cost of delivering new updates and features. Cloud native application also allow us for sharing resources and on demand consumption. Well, that's something that is to reach when we are using microservices and when we are designing them. With sharing resources and on demand consumption in mind. We have something that is very important when we talk about cloud native iger availability. Cloud native architectures provide IG availability and reliability as they reduce operational complexity, simplify configuration changes, and offer auto scaling and self filling. Well, self filling is something that we design. Auto scaling we have to design it to inside our microservices. So what we can say is that cloud native applications make the most of modern infrastructure's dynamic distributed nature to achieve greater speed, agility, scalability, reliability and cost efficiency. Well, since things talk is about using an innovative technology to better design and build cloud native microservices, we want to concentrate on those words that in this slide are involved. So greater speed, scalability and cost efficiency because those words help us to concentrate in building better microservices that are available and are scalable and reduce costs and so on. What we want to achieve is to have a smaller memory footprint because that allow us to lower the cost and also because that allow us to share resources between our microservices. Also, we want to use less CPU and we also want a lower startup time. Let's get to the JVM microservices frameworks ecosystem and let's start with the three most used ones, Quarkus, Springboot and Micronaut. They are Java frameworks because they usually use Java to allow the development of our microservices. Well, we can use other languages too, but Java is the most common choice for those three frameworks. They are not the only one. For example, we have microprofile for example. We also have other languages because Java is not the only one that run on JVM. We have also scala for example with ACA, Lagom. Or we also have languages that are designed with cloud native in mind, such as ballerina, considering what we want to achieve. So a smaller memory footprint, less CPU consumption or faster startup. Those frameworks and every other one are equally we can consider them in an equally way. We will concentrate just on one of them just for simplicity and to demonstrate powerful innovation that are brought to the JVM ecosystem by gradm. Native image we will concentrate on Springboot and let's get to our demo project. I will switch to intellij. Ive created a very simple project. As you can see, we all just have a demo application and a demo controller. No services, no repositories nothing at all, just one endpoint with a greeting get mapping that will answer us nullodemo string and I wanted keep it simple because even if it is this simple, we will see how much gralvm native image will help us to optimize memory CPUs consumption. So let's start this application. It will take just a few seconds, compile it and to start it, I expect something like eight or 9 seconds to start it and oh even less six. It's really fast to start, but not fast enough as we can see. We want to make sure that it works. So let's perform azure to our endpoint and we can see that the answer is hello demo. But we also want to see for example how much memory it is using. So we will the system to print us the resident set side that gives us measure of how much memory this process is using in this moment. And that is the real memory that this process is using. So you can see 49 KB more or less. It's easy, it's working. We have something that is not very big in memory and it starts in just a few seconds, 6 seconds. Let's get back to our presentation and let's see what we can do from here on. First of all, the talk is called the good the bad de native, referring to the movie the good the ugly from Ser Giuliana. And I imagined what one of the three characters could say after seeing this demo. And the character is angelize the bed. And he would say, well, those bastards out there want more memory and I have just a few resources. How do you think I can scale and lower the costs? Let's remember that we want to lower the costs and we want to scale and we want to share resources. So the more we say okay, it's good, the less we can scale and share resources. We want to make sure that there is a way to use less memory, less CPU, and to make our application stuff faster. So let's get into the details of gralvm. The JVM is an abstraction of an underlying actual machine that interprets the bytecode generated by the compilation of a code supported by the JVM itself. So what we can see here from this statement is that what makes Java portable is the JVM technology. It is where compile once runs, everywhere comes from. We compile once into a bytecode and the bytecode gets interpreted by the JVM. And a standard way that we can see of how a JVM works is this one. It is the hotspot JVM so the standard JVM. So we have our bytecode, that is something that is generated from our start code and the bytecode will be interpreted or compiled by adjusting some compiler in what? Well, in binary code. So the interpreter or the JIT compiler will transform our bytecode into something that our machine, so the real machine can execute well inside the JVM. We also have other components, they are very important. We have a garbage collector. We have thread management, we have memory management. We also have class loader and native method libraries. All of these allows us to create a JVM and allows us to use our bytecode, a single bytecode everywhere where the same JVM is present. So if we have a deep hotspot JVM on Linux or on macOS or on windows with the same bytecode, we can run. Well, let's get to the details. Because we said that the bytecode gets interpreted or compiled, that's not just about compiling it or interpreting it. The first way to get the bytecode and execute it in the underlying machine is to interpret it with the interpreter. It is very slow because it has to interpret line by line our bytecode, and while it interprets and execute our bytecode, it collects profiling information. It also has faster startup because well, we don't have to load into our memory anything at all. We are interpreting line by line, but that kind of operation line by line is very slow. The second option is the C one jit compiler. The C one compiles code when it gets frequently executed. So we have the first way to switch from the interpreter to the JIT compiler. When the code gets frequently executed, it stops being interpreted and it starts being compiled by the C one JIT compiled compiler. It also continue collecting profiling information and it has a facet warm up. We also have another JIT compiler, the C two. It starts compiling our bytecode and optimizing it when it is executed often enough and reaches certain thresholds. It uses the profile information that are collected by the interpreter and the C one compiler, and it has the high peak performance. So when we say we have to warm up our JVM, we are referring to this kind of compilation. We want to execute our code enough times and we want it to reach those certain thresholds because when it does, the C two JIT compiler optimize our code and compiles it after optimizing so we can have the high peak performance. Well, let's go GralvM and let's understand what kind of optimization it brings. It is a polyglot VM. What does it mean? It means that we can execute many languages, not just the JVM classic languages, but also languages that usually doesn't run on a Java virtual machine. So we can have Python, we can have Ruby JavaScript and so on. Gralvm also has a new compiler, a JIt compiler. So it is the gral compiler. It is a C two implementation. It has various optimization, a lot of them actually, and it removes unnecessary object allocation on the heap memory. Also we have native image it is not a JIT compiler, just in time compiler. It is ahead of time compiler. So it compiles everything before a hit, before it is executed. And the compilation must generate something because it is the kind of compiler that will use everything that it knows to gives us an executable file. And of course it compiles into native platform executable. Okay, so let's get into the differences between the oddspot VM and the gralvM. In the Oddspot VM we have the oddspot VM as a Java virtual machine. We have the compiler interface, and we have the C one and C two just in time compiler. Well, in this scenario we can have something that is called tired compilation, and it is used when we start by interpreting our code, our bytecode. When our bytecode is said often enough, DC one compiler starts to compile it just in time, and when it is executed even more often enough, C two compiler starts compilation, optimizing it and compiling it. This is called tired compilation. The tired compilation is something we also have on GralVM, but on grav we have something that is slightly different. We have a gral compiler instead of a C two compiler. And instead of compiler interface for the C two, we have the JVM Ci for the gral compiler. So the JVM compiler interface is a new interface written in Java, the same for the gral compiler. So these two new components are totally written in Java. And this is something that should tell us something because, well, if it is written in Java it will also get compiled, and then in native code it means that the gral compiler itself and the JVM Ci will get compiled by the C one compiler and then by graph compiler itself. And if we start thinking about tired compilation, we can imagine that the more often our gral compiler gets used, the more it gets optimized by the tired compilation. And it also has the gral compiler optimization. If we compare it with the C two compiler, it has a lot of optimization. So the graph compiler will start producing very optimized native binary code. And this is something that already gives us a lot of optimization in terms of memory consumption. But this is not the only compiler that GralvM offer us. We also have a native image. Let's get into the details of how the ahead of time compilation works, and let's start from the inputs. Our application code. We also have the libraries that it uses and the JDK. Of course, in our demo application we add the demo application itself, the libraries. So for example spring and spring boot, and the JDK. Our demo application itself uses string, and string is inside the JDK. Okay, these are the input for our build phase and the build phase with the native image compiler, a loop of three different phases, a point to analysis, a run of initializations, and a heap snapshotting. While of all of this, the ahead of time compilation can't execute our application, so it has to perform a static analysis of our code to understand everything that it needs to be initialization before it gets executed, and everything that needs to be initialized will be created into the heap and get snapshotted. And after that, maybe if we go on and keep analyzing our code, we see that after our initialization we can go further on some executions. And so we start again performing the point to analysis because we can make sure we have analyzed everything that can be executed and will be executed. Also, we can see from this scheme that no oddspot VM at all. And that's something that is not okay, because the Oddspot VM and the gral VM performs other operations such as garbage collecting or class loading and so on. And this kind of operation needs to be executed when we perform an ahead of time compilation too. That's why when we use the native image compilation, we also have a substrate VM. The substrate VM is written in Java, and it is something that will be compiled together with our application and libraries and JDK, because it will be compiled against the target machine, against the target architecture, and it will be optimized by our native image compilation. We have the output after our build phase, and our output will be the native executable. The native executable will have a code in the text section that will come from the out compilation. Also, we have an image heap in that section, and it will come from our image heap writing well, let's get back to our demo project and let's start talking about native this time. What I will perform here is something that is slightly different. Let's stop our application, the JVM one, and let's perform a native compilation. I will use the native profile and I will ask my maven installation to perform a package command. So it will create everything that I will need to execute my application in native wave. Okay, let's get into the execution. It will takes a while because of all the build phase. So it will perform those three steps. The point to analysis. It will run in its alley sessions and it will perform snapshotting. And these three steps will get executed again and again and again until it will reach the end of the process when every other step or every other loop of our phase step will add nothing more. So it will stop and start generating the native executable. Our compilation has finished, so let's get into its details. We can see that it has performed analysis and it has identified all the reachable types and fields and methods. It has building and inlining compilation and in the end it created an image. You can see that it will give us information on the top ten origins of the code and of the top object types in image. It will create an executable and the executable is this one target demo. Okay, we can see that target is an executable. What we want to try to do is to execute it. So let's get to execute it and we will see that it will get executed just as our application, that user, the JVM user to work. The first thing that we can observe is the starting time. We had 6.6 seconds for the application that was running in the JVM. We have 0.8 seconds for this kind of application. It is the same application, but what has changed is that this time we have a native compiled application. So node JVM, everything has been compiled into native code. Well, let's get sure that it works. So we had a greeting endpoint, so we will check if it will give us the same result. And it just says hello demo. And let's get into the detail of process ID. So let's see how much memory using this time. You can see that this time the memory is less of when we use the JVM. Well, the demo application is very simple, so we will not see a lot less of resident memory that will be used. But what we can see is that it is just less memory that when we use the JVM, also combined with a very small startup time and a CPU consumption that is very optimized, we can say that this time our application is definitely more scalable, offers us more opportunities to scale application because it will use less resources and those resources are shared between our microservices and instance of microservices. Okay, let's get back to our presentation. Let's get back to our objectives. We wanted to achieve a smaller memory footprint and we also wanted to have less CPU consumption and a lower startup time. With our demo application, it's difficult to see the less CPU consumption just because the application is very simple, but we have seen the smaller memory footprint and the lower startup time that is very lower. We are talking about 6 seconds against 0.8 seconds. Well, all of things achievement helps us to have a better scalability, to lower the costs and to have higher availability. That's because we are using less resources. So if we are using less resources, we can scale more using the same nodes in our cluster. And if we can scale more, of course we have higher availability. And that's not just about scaling, it's about using our resources in a better way because, well, we are starting our service in 0.8 seconds, not in 6 seconds. So we don't have to wait 6 seconds before our application can serve our users. It's just at least almost immediate. We can start after zero pains, 8 seconds, and it will allow us to have just a few replicas of our same microservice to serve our users without having them to wait until the single replica is ready to serve them. And of course all of these will lower the cost. Getting back to the movie of Sergio Leone, we could imagine Blondie the good saying, I will sleep peacefully because I know that the native is watching over me. Okay, we've seen the good things of the native image process, but we also have a native building drawbacks. First of all, lot of time and more resources to build our application. We've seen that when I started the application using the JVM, I didn't have to stop the recording because, well, it takes just a few seconds to build the application using the JVM, so generating the bytecode, but it will require a lot of time and a lot of resources to perform the static analysis and to create the native executable. So this is the first drawback. Also, native image generates metadata performing static analysis. And that static analysis is under a closed world assumption because we are not executing our application and everything that it collects, those reachable methods and fields and so on, those are information that are gatorade with a static analysis. So some dynamic features require additional configuration. Of course they can somehow be investigated and find out by the static analysis. But the dynamic features such as reflection and dynamic proxying are not so easy to be found by the static analysis. So we will reach a point to analysis where no more data can be collected. And to be sure that everything is collected we have to manually create those metadata and give them to the native image generation. For example, go back to our demo application and see slightly different example. We have a DTO, it's a record with a name. And our demo controller will have a get mapping just like before but also post mapping. And in the post mapping we will have a request body and the hello demo that we had before. This time we'll say hello and we'll concert the name that we give in input. Let's compile this and let's see what kind of metadata and what metadata it generates. Okay, again, we have our native application, we can start it. We have 0.9 seconds. And what we will do is just test that the get mapping is working well and that well let's test the post mapping too. And okay, it is saying hello Gregorio because the name that we gave the endpoint is Gregorio. Okay, what we want to see here is not the memory footprint part. And let's see that all of things application has been processed and analyzed with some steps that produce metadata. And for example one of them is this one. The GralvM reachability metadata is a folder that contains information that are provided by the libraries that we are using. As we can see there is no reference to our package application example. So these are metadata that are provided by the libraries that we are using. What is referring to our application is inside this folder because spring provided a plugin to the Gralvm native image compiler that helps the compiler understand the reachability of the application and everything inside the application together with the information about the reachability of spring framework two. And the first thing that we will see is these folder resources. We can see that we have a native image properties file, but we also have a resource and a reflect compilation. The resource will tell to native image that everything inside, for example meta has to be included inside the bundle, inside the native image that will be generated together with the application properties and so on. But we also have this file reflect config and we can also for example have other configuration files based on what we put inside of our application. We can see that the demo application has been processed and the information, the metadata that are generated tells to native image that it has to query all public methods because for certain they will be used. And for example, we can see that the demo controller will use the greeting DTO and the greeting DTO will be exposing the declared fields and the declared constructor. Well, also it will have a method that is the accessor to the name property. All of this is inside the resources folder, but we also have a sources folder with a example demo and inside of things. Things spring AOT plugin generated other information such as for example bin definitions or pin factory registration. These are the information that spring framework generates when it starts. So when we started it in the JVM mode, it generated all of this information and things. Information required those 6 seconds in the startup time together with all the initialization of the framework. Well, in this scenario, the plugin generated everything that will be needed to the ahead of time compilation to create snapshot that will include everything that spring would create in those 6.6 seconds. So every initialization, everything that is related to bin proxying and so on is inside of these and will be used to generate the hip snapshotting and the native image. Okay, let's get back to the presentation. So we have dynamic features that will require additional compilation. In some cases, the native image will not be able to collect the metadata. And this happened when for example, we use a lot of reflection and dynamic proxying. Well, in this situation, in this kind of situation we have an agent, a tracing agent that can help us a lot because it will gather metadata for us and prepare compilation files such as reflect config JSON that we can use and provide manually to the compiler. It is very useful because it will collect everything based on an execution of our application. So the tracing agent can be used. When we start the application in the JVM, we can use the application. So for example, we could run some end to end tests because those are something that will simulate a real scenario. And this real scenario will generate everything, every metadata that will be used by the native image. If we don't provide this reachability metadata, what we can obtain is that the compilation will go smoothly, it will generate our executable, but when we execute it, when it reaches the point that is not generated using the right reachability metadata, it will give us an error telling us that the method or the class that is required is not found things is something similar to no method exception or something similar to it, not really an exception. It is an error that tells us that we didn't provide enough metadata for the native mage to create the right executable. So to include all of the methods and classes and so on that the native executable will need. We have to remember that things kind of compilation will generate a small executable and everything that is not rigid by the static analysis will not be included in the native executable because we don't need it, including it inside of the executable. We have to obtain a small footprint and a lower setup time, so we will use just what we will really need. Another drawback is that some libraries does not provide good enough reachability metadatas. This is something that GralvM team is working on a lot, together with everyone that builds frameworks and libraries. So every time a library doesn't provide reachability metadata, the granitem team will work together with the people that creates libraries to provide those metadatas. And this is something that is getting better and better. Also, some include the dependencies that we may need to manually exclude. For example two different dependencies that initialize two different login libraries. Well, things is something that we don't want to be included inside our native executable and this is something that maybe will generate an error because the login facade will not be able to choose which one implementation to use. So we will need to manually exclude one implementation. But it's just an example and there might be different cases of this kind. Also, there are some libraries that will need some changes, either in the library itself or in gralvium native mage compiler one example is aspect J. Aspect J as for now uses agents to perform, for example load time weaving. And this is something that can't be done, as for now inside the gradm native mage compiler. So either the native mage compiler will accept the agents or aspect j will be changed to, well perform load time weaving in a different way. Last, it is better to avoid shaded libraries. Shaded libraries are some libraries that uses dependencies or classes that has a name, but they change the name or the package of those classes. So this is the shading of the library performs on our classes. Well, this is something that doesn't work really good inside native executable. So it is better to avoid shaded libraries and to use the unshaded one. Well, this is my journey. It's a denative way of using a JVM framework with a GralvM native image compiling. And we are at the end of this talk, so let's get back to the movie and imagine what the well, ugly, but in this case the native will say. So Tuko in this case will say when you go native, you go native. So everything that Priyanka Sharma said about the cloud native technology should tell us that we have to innovate. If we innovate, we can think about using cloud computing to build touch that's faster and more resilient. So we said that we want to have less CPU consumption, lower the costs and have smaller memory footprint. Well, we can achieve it starting using ralbm native image we have to remember to use the tracing agent because it helps us a lot and save us a lot of time of manually providing those reachability metadata and trying and trying and trying over and over again. Well, just use the tracing agent because it does all the job for us. And test the native executable or perform native tests. The native executable is something that may have something that is not configured correctly. For example, the reachability metadata could need some improvement. So it's better after creating the native executable to test to perform tests. Maybe it's end to end tests are a great starting point and we have to test it because it's something that is definitely innovative. So we have to be sure that that kind of compilation added everything that is needed by our use cases. And since the end to end tests maps the real world scenario use case well, they are something that is really good to use to test the native executable pool and be sure that we included everything with our native image compilation. In this slide you can find some of the link that I found very useful starting from the native image documentation going to the metadata collection and the native image plugin of spring. And I also included the native image guide for Quarkus. Remember that everything that we've seen with the demo application using spring is exactly the same and is valid for every microservices framework built with a language on the JVM. And this is all so thank you for watching me. Please tell me if you have found all of things inspiring and useful and.
...

Gregorio Palama

Senior Cloud Engineer & DevOps @ Lutech

Gregorio Palama's LinkedIn account Gregorio Palama's twitter account



Awesome tech events for

Priority access to all content

Video hallway track

Community chat

Exclusive promotions and giveaways