Bigger .dockerignore, Smaller Docker Images
This is the first article in the four part series, Minimizing & Securing Docker Images. Check out the other articles in the series:
1. Bigger .dockerignore, Smaller Docker Images
2. Look Docker, No Distro
Everyone wants faster build times, and less junk in their app. By beefing up our
.dockerignore we can make smaller Docker images. The benefits of smaller images don’t stop at faster build times. Smaller images take up less disk space which really starts to shows off some benefits when the application is scaled up, potentially in an auto-scaling Kubernetes cluster. Lastly, smaller Docker images have less attack surface.
This is the first part in a four part series focusing on optimizing Docker image size and security. This article has a corresponding repository which serves to showcase a real world example. The repo can be found here. Each branch will build off of the previous showcasing the different stages we will go through in the article.
🔤 Back to the Basics
Much like a
.gitignore file which defines the files that we want git to ignore, a
.dockerignore file defines the files that we want Docker to ignore. But why do we want Docker to ignore certain files? Going back to wanting smaller images, a smaller image means faster build times when we run
docker run or
docker-compose up. If a file isn’t needed for your app to run, put it in the
.dockerignore. Now that this file is in the
.dockerignore, it won’t be included in the Docker image, reducing the size of your image. The Docker CLI looks for
.dockerignore in the root of your app. If it is not in the root folder, it will not be read.
Some examples of files to put in your
01-Basics is the corresponding branch to this section. Cloning and running the app starts a server at port
8080. Navigating to
localhost:8080, we can see the text,
“Hello, World! The secret is 1234”.
Diving into the
.dockerignore file, we can see the files we are ignoring:
On step 8/9 of the
Dockerfile we run a
ls -la command. We can see the output below:
This is pretty good but what else can we get rid of?
🐳 Docker Stuff
Can we put
docker-compose.yml in the
.dockerignore file? Yes! Throw those in there as well. If our app doesn’t directly need the file to run, it belongs in the
.dockerignore. We can even put
.dockerignore itself into the
.dockerignore file! Why is this?
.dockerignore are all used to build the image and spin up a container, however, that’s where the purpose of these files stop. They are not used in our app. If we don’t need the files to run the app without Docker, we do not need the files to run the app with Docker. We need the files to build the Docker image and we need the files to run the Docker container. Yet, the app within the Docker container does not need these files.
🔒 Environment Variables
Environment files always confused me in the context of Docker. Unlike
Dockerfile, the app does need
.env in order to run properly, both when using Docker and without using Docker. However, if we are using a
docker-compose.yml file, like we are in this app, and in the yml file we define an
env_file, then the
.env file can be ignored.
docker-compose.yml is the declarative version of
docker-compose --env-file ./.env. Both approaches define the path to an environment file containing secret variables, allowing docker-compose to access and pass along the variables to the app running inside the container. By passing the environment file to docker-compose, Docker itself doesn’t need to know about the environment file. This is because docker-compose acts like a wrapper around the
Dockerfile. Docker-compose is the declarative version of
docker run …. Docker-compose starts the
Dockerfile build and is able to pass along the environment file to the Docker build without the
Dockerfile directly needing
🧪 Route and Unit Tests
Tests aren’t needed to run the app, but they are needed to test the app. One option is to test the app outside of a Docker container. In this scenario, we can ignore the test files. This path has the downside of any application outside of Docker, the environment where the app runs is not guaranteed to have all the correct packages, package versions, configurations, etc.
Testing your application inside of a Docker container provides more consistency in a standardized environment. How can we test the app when the test file is being ignored? We will circle back to this in the Hot Reloading section below. However, for now, we will ignore the test file.
🐗 Wildcard Selectors
In this section we won’t be adding any additional files to the
.dockerignore. We will be condensing the amount of lines in our
.dockerignore by using wildcard selectors. The
* allows us to fill an indefinite amount of characters. On line 5, we have
docker-compose*.yml. This will ignore
docker-compose.dev.yml , and
docker-composeyou-can-put-anything-here.yml. We have similar syntax on line 3 to ignore both
.gitignore. Lines 6 and line 9 follow the same logic.
Line 7 follows a different pattern,
**. The double wildcard allows us to match any number of directories (including zero). Therefore
**/*_test.go will match any test file in any directory. This one little, powerful line has all the power defined below (and more):
ls -la command in our
Dockerfile produces the same output as the section before:
One command which is good to know but I have never found a need to use is the
!. By putting a
! in front of any line in the
.dockerignore, Docker will ignore ignoring it. What a mouthful! Basically, Docker will make sure to include the file in the image. Here is one non-practical example:
We are ignoring all markdown files except for
🔃 The Pros and Cons of Hot Reloading
Bind mounts are an extremely useful type of volume that docker-compose allows us to use. They essentially “bind” a file from outside the Docker container to a file inside the container. This allows for hot reloading. However, I often see them misused. Truth be told, I also didn’t know the best way to use them. At first, I would bind everything outside the container to everything inside the container with a volume
The problem with this approach can be better understood if we understand the order of operations. When we run
docker-compose up or any variation of the command, first the docker-compose command is converted into a
docker command. Next, the
.dockerignore is read and any files in there are ignored. After that, the
Dockerfile is read. Of course when we do
COPY . . we only copy the files that Docker did not ignore. Then, any volumes are applied, including bind mounts. The volumes do not adhere to the
.dockerignore. Finally, if there is a command specified in the
docker-compose.yml, that command is run.
By making a bind mount
.:/app, we are completely overriding the
.dockerignore. Since the bind mount it attached after the image is build, even files that were ignored are now mounted to the container. A better way to use bind mounts is to map specific files or folders. The following is an example of a development
docker-compose.dev.yml where we set a bind mount only on the
main.go file and map it to the file within the container at the
Going back to tests we can apply our new knowledge. Due to bind mounts being applied after the image builds, even if we ignore the tests we can make them accessible in the container again by adding a volume
./main_test.go:/app/main_test.go. In this docker-compose we can see the
go test command is run. Due to docker-compose commands being executed after the volumes are mapped, we can be assured that the
main_test.go file will be available inside of the container by the time
go test is run.
📦 Packing It All Up
Small image sizes not only reduce build time and disk space, they also decrease our attack surface. The
.dockerignore file is just the first piece in beginning to optimize Docker image size. You should now have a solid understanding of
.dockerignore, how to use it, and how it works in relation to other Docker components. Now, try these tactics out in your next project and see how much you can reduce your image size.