KubeAcademy by VMware
Building Images with Dockerfile: Runtime User, Multi-Stage Builds, Inspecting Images
Next Lesson

In this lesson we cover two basic strategies for improving security: setting the runtime user, and reducing the size and surface area of runtime images. We also look at two utilities — docker inspect and dive — for inspecting images.

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 cover a few more examples to continue learning about Dockerfile. In this lesson, we'll talk about the default runtime user and reducing image size. We'll also explore two ways to inspect images. Let's use the images we created in the previous lesson to check who the default runtime user is. It's root. Now, being root in the container doesn't imply having root privileges on the host VM, but it's still a concern. If an attacker gains access to the container, being root makes it easier to explore and identify other services over the network. A savvy hacker might even be able to exploit a vulnerability in the operating system and gain access to the host. Now, we didn't set a user. So this means that the default user in the golang image is root, which makes sense for a base image, because you might want to install packages and so on, but it doesn't make as much sense for an application image.

Our application doesn't require root access. And from a security perspective, we should follow the principle of least privilege. To address this, we can simply add a user instruction to our Dockerfile and use an arbitrary number as the ID. And that'll change the user and privileges. So let's rebuild. And we'll do a quick check to verify that the default runtime user is now 1001. Now, by default, this user will have read access to the container and write permissions to the /temp directory. In order to grant the user additional permissions or ownership, that needs to be done explicitly through instructions in the Dockerfile.

Let's talk about image size. Why should we pay attention to the size of our images? Size impacts the first upload or download time of an image, but after that, only layers that have changed are transferred. So the size of individual layers that change frequently is more important for efficiency than the total size of an image. In actuality, the biggest motivation for reducing the size of an image is to reduce the surface area of attack. There's no reason to deploy packages or components to production that are not needed at runtime. That simply increases the surface area of images that a malicious person or program could potentially abuse.

Let's look at the size of our image. It's just slightly bigger than golang, which means that most of the bulk is coming from the base. In the case of our sample app, we needed the base image for building the app, but we don't need it for running the app. Now what you need at build time and runtime depends, of course, on your application, but in our case, we can reduce our image size significantly. So to eliminate unnecessary components from a runtime image, you can use an approach called a multi-stage build. Let's look at an example.

We said earlier that the FROM instruction sets the base image. It also initiates what's called a build stage. In this Dockerfile, we have to build stages, one to build the application and one to create the runtime image. We've removed everything from the first stage that is not relevant to building the application. And we're copying the resulting executable to the second stage, which uses a much smaller base. Notice that the COPY command in the second stage uses a --from flag to refer to the first stage, using an alias that we assigned above.

For this Go application, we're able to package the executable as a standalone binary and eliminate all other dependencies. So we can opt for the minimal base called scratch, which is a special keyword that enables the initiation of a build stage without adding a layer. In other words, scratch doesn't add anything to the image.

Now, there's a trade-off between image size and functionality. Scratch doesn't add a container or operating system and it doesn't have utilities like shell, which might restrict adequacy for certain use cases and make it harder to debug. And there's a variety of minimal images to choose from with different levels of default functionality. So you can choose what's appropriate for your use case. Let's rebuild our image and compare the size. You can see that it's gotten significantly smaller. We can also make sure it still runs. It looks like we're good to go.

Now that we've learned how to build images, let's spend a few moments on inspecting them. The docker inspect command provides low-level metadata about images or containers. Let's use it to inspect the last image that we created. The command returns JSON output, including the full ID, which you can see is a SHA-256 digest that uniquely identifies the image, as well as the name and tag and more information. Since we're inspecting an image, the container and ContainerConfig sections here refer to a temporary container that's created when the image is built, but if you look lower down, there's also a config section for the image.

Now, if you're familiar with the jq tool, it makes JSON easier to read. Docker inspect also has a format flag that uses templating that can be pretty powerful. In this case, we need to specify that the output should be JSON so that we can still use it in combination with jq to make it easier to read. For example, we can get all of the top-level keys or just get the Config section.

Docker inspect is useful, but sometimes you need to examine the contents of each layer. For that, you can use a tool called Dive. Once you're in Dive, you can tab to toggle between the layers on the left and the layer contents on the right. Our scratch-based image only has one layer with our executable. So let's look at a more complex image. We can exit Dive using Ctrl + C.

Let's look at the dangling image that was created by the first stage of our multi-stage Dockerfile. We can use the ID. And now looking at the top-left of the screen, the first layers are from the Golang base. And then we see the one that we added, starting with creating the workspace directory. If you hit Tab, you switch over to the layer contents on the right of the screen. And if you type Ctrl + U, you just see what changed by adding this layer to the file system. And we can see that all we did here was create the directory.

Let's leave Ctrl + U enabled to see only changes and tab back to the list of layers. You can see the next thing we did was add the module files and then download the modules, copy the source code and build the executable. That concludes our third lesson on Dockerfile. We learned how to check and change the default runtime user and how to use a multi-stage build to release only what's needed for runtime inside the image. We also learned a couple of ways we can inspect Docker images.

Give Feedback

Help us improve by sharing your thoughts.

Share