KubeAcademy by VMware
Building Images with Dockerfile: App Shutdown, Image Layers, and Caching
Next Lesson

In this lesson, we explore how Dockerfile configuration can impact application shutdown behavior. We also cover the layered nature of Docker images, the ability of layers to function as cache, and strategies for maximizing caching opportunities during image builds.

Cora Iberkleid

Part Developer Advocate, part Advisory Solutions Engineer at VMware

Cora Iberkleid is part Developer Advocate, part Advisory Solutions Engineer at VMware, helping developers and enterprises navigate and adopt modern practices and technologies including Spring, Cloud Foundry, Kubernetes, VMware Tanzu, and modern CI/CD.

View Profile

Let's continue our exploration of Dockerfile. In this lesson, we'll talk about shutting down the application, the layered structure of images and leveraging cash.

Many programs include logic for a graceful shutdown. If a program knows it needs to shut down, it can do it in a controlled manner. If the program just gets killed, it doesn't have the chance. On container runtime systems like Kubernetes, the system may move workloads around over time. So we should make sure that the system can get the message to our application to shut down. Otherwise, our container will just be killed.

So how does an application know when to shut down? Every process in the system has a process ID, or PID, and there's one special process that has a process ID of one. PID one is the first process to start, and it's consequently, the parent or grandparents and so on, of all other processes. When you try to stop a container using control C or the Docker stop command, a signal is sent to the container and the containers, PID one, should trap the signal and forward it to other processes. This gives programs an opportunity to carry out a graceful shutdown. Once all processes have stopped, PID one closes and the container exits.

The process you launched through your entry point or your command is launched as PID one. Recall the entry point from our sample app. This syntax with brackets means that hello will be launched as PID one. Otherwise, a shell is launched as PID one. Now, we're not starting any other processes. So let's make sure that the go to link built our hello application, so that it properly traps the signal to shut down. We'll add an environment variable to our run command to enable a sleep feature that's built into the application. Now, let's try control C. The container shuts down, so our process received the signal and had an opportunity to carry out a graceful shutdown.

Sometimes we need to use more complex startup logic. In which case, we could use a shell command or even a shell script to launch our application. Here's an example where we're using shell to combine multiple user arguments into a single string to pass to hello. In this case, the shell will be PID one and our app will be a child of the shell. So let's start up our app and try to control C. It doesn't work. Let's switch to a new terminal, so we can check the processes running on the container. So Docker PS gives us a list of running containers. Docker exec and the container name enables us to run a command inside the container. So we can see that the shell is PID one and hello is a child of the shell. Now, the shell is actually trapping the signal, but it's not forwarding it to hello. So we can use the Docker stop command. It'll try shutting down the container and wait 10 seconds, and then force kill it if it doesn't respond, which means hello will not have an opportunity for a graceful shutdown in this case.

Now, there's two solutions that we can apply. If we add the keyword exec before hello in our shelled entry point, hello will be elevated to PID one, which we know will trap the signal. In fact, in most cases, applications built by modern language frameworks will trap the signal. This is true, not only for go, but also the JVM and many others. Also, you should try to keep your containers to a single process, rather than a complex process tree. So elevating that process using exec is usually all you need.

There are some exceptions where the application process doesn't trap the signal. In these cases, you can use a special initialization program that's purpose-built to be PID one. One example is tini, which is init backwards, which is actually built into Docker. So you can enable it using a flag and an environment variable right in the Docker run command, but if you want to deploy to Kubernetes, you need to include it in your image. So notice our entry point here calls tini as the main process. Tini will be PID one and parent to the shell, grandparent to hello. So we include the minus chief lag to ensure that tini sends a signal to the group, both shell and hello, not just to its direct child. Now, this golang base image doesn't include tini. So notice that we've added commands to install it.

Now, if you have experience with Dockerfiles, you might be thinking, "That's not a good Dockerfile," and you're right. But to understand why it's not a good Dockerfile, let's first talk about layers and caching. We can use the Docker history command to see the history of our image. You can see here that the image is composed of layers. On the bottom, we have layers that comprise the golang base. On the top, we have layers that we added. Think of a layer as a get commit. You can see the Dockerfile instruction that created each layer.

We can also see that the layers have different ages. What this tells us is that for every instruction, Docker is automatically evaluating whether or not it can reuse an existing layer from a previous build, rather than create a duplicate. If there's a change in the build and it can't reuse the layer, it invalidates the cache at that point and begins creating all new layers. For ad and copy instructions, Docker calculates a check sum for each of the files and compares them to the check sums in cache layers. Otherwise, Docker simply compares the command string as written in our Dockerfile.

Given that information, let's reevaluate our last Dockerfile. There are three main considerations: the grouping of commands, the order of commands, and the contents that will be committed together in each layer. Our copy instruction, for example, copies all files from the context at once and it does so early on in our Dockerfile. This means that any change to our source code will cause all of the subsequent layers to be recreated and we'll be installing tini and re-downloading our go package nearly every time we build.

Conversely, we've separated the commands to install tini across two run instructions. If we decide to add a package to the install command, we might end up running it against a potentially outdated app get update layer. There are some more subtle improvements that we can make too, but let's take a look at an improved Dockerfile. We've combined the commands to install tini into a single instruction and move them further up the file. We've also added some commands that will reduce the size of this layer before it's committed, specifically a flag to avoid installing unnecessary files like documentation and the removal of the package cache.

We've also added a version to tini, which gives us more control over the version that's installed and also, a way to invalidate the cash at this instruction should we want to upgrade the version of the program. We've also separated our copy instruction based on the expectation that source code will change more frequently than modules. So we copy the module information first and download dependencies, and then copy the source code and build the executable. Now, if we change the source code, we'll build on cached layers and simply rebuild our executable.

Let's validate this by running a build with the no cache option, so that we don't use existing layers. We'll also use the time utility to easily see how long the bill takes. You can see the installation of the operating system package and the go package. Now, let's make a change to our source code and rebuild. You can see most of these layers Are being pulled from cache up until the copy command for the source code. If we had not improved our Dockerfile, our second build would have taken just as long as the first. But in this case, you can see we've cut down the build time significantly.

That concludes our second lesson on Dockerfile. We learned how to ensure that our application receives a shutdown signal to allow it the opportunity to shut down gracefully and how to compose our Dockerfiles to use caching effectively based on an understanding of the layered nature of images.

Give Feedback

Help us improve by sharing your thoughts.

Share