DEV Community

Cover image for Master Advanced Dockerfile Techniques for Efficient Docker Images
Labby for LabEx

Posted on

Master Advanced Dockerfile Techniques for Efficient Docker Images

Introduction

MindMap

In this lab, we'll dive deeper into Dockerfile techniques, exploring advanced concepts that will help you create more efficient and flexible Docker images. We'll cover detailed Dockerfile instructions, multi-stage builds, and the use of .dockerignore files. We'll also explore the crucial concept of layers in Docker images. By the end of this lab, you'll have a comprehensive understanding of these advanced Dockerfile techniques and be able to apply them to your own projects.

This lab is designed with beginners in mind, providing detailed explanations and addressing potential points of confusion. We'll be using WebIDE (VS Code) for all our file editing tasks, making it easy to create and modify files directly in the browser.

Understanding Dockerfile Instructions and Layers

Let's start by creating a Dockerfile that utilizes various instructions. We'll build an image for a Python web application using Flask, and along the way, we'll explore how each instruction contributes to the layers of our Docker image.

  1. First, let's create a new directory for our project. In the WebIDE terminal, run:
mkdir -p ~/project/advanced-dockerfile && cd ~/project/advanced-dockerfile
Enter fullscreen mode Exit fullscreen mode

This command creates a new directory called advanced-dockerfile inside the project folder and then changes into that directory.

  1. Now, let's create our application file. In the WebIDE file explorer (usually on the left side of the screen), right-click on the advanced-dockerfile folder and select "New File". Name this file app.py.

  2. Open app.py and add the following Python code:

from flask import Flask
import os

app = Flask(__name__)

@app.route('/')
def hello():
    return f"Hello from {os.environ.get('ENVIRONMENT', 'unknown')} environment!"

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)
Enter fullscreen mode Exit fullscreen mode

This is a simple Flask application that responds with a greeting message, including the environment it's running in.

  1. Next, we need to create a requirements.txt file to specify our Python dependencies. Create a new file named requirements.txt in the same directory and add the following content:
Flask==2.0.1
Werkzeug==2.0.1
Enter fullscreen mode Exit fullscreen mode

Here, we're specifying exact versions for both Flask and Werkzeug to ensure compatibility.

  1. Now, let's create our Dockerfile. Create a new file named Dockerfile (with a capital 'D') in the same directory and add the following content:
# Use an official Python runtime as the base image
FROM python:3.9-slim

# Set the working directory in the container
WORKDIR /app

# Set an environment variable
ENV ENVIRONMENT=production

# Copy the requirements file into the container
COPY requirements.txt .

# Install the required packages
RUN pip install --no-cache-dir -r requirements.txt

# Copy the application code into the container
COPY app.py .

# Specify the command to run when the container starts
CMD ["python", "app.py"]

# Expose the port the app runs on
EXPOSE 5000

# Add labels for metadata
LABEL maintainer="Your Name <your.email@example.com>"
LABEL version="1.0"
LABEL description="Flask app demo for advanced Dockerfile techniques"
Enter fullscreen mode Exit fullscreen mode

Now, let's break down these instructions and understand how they contribute to the layers of our Docker image:

  • FROM python:3.9-slim: This is always the first instruction. It specifies the base image we're building from. This creates the first layer of our image, which includes the Python runtime.
  • WORKDIR /app: This sets the working directory for subsequent instructions. It doesn't create a new layer, but affects how following instructions behave.
  • ENV ENVIRONMENT=production: This sets an environment variable. Environment variables don't create new layers, but they are stored in the image metadata.
  • COPY requirements.txt .: This copies the requirements file from our host into the image. This creates a new layer containing just this file.
  • RUN pip install --no-cache-dir -r requirements.txt: This runs a command in the container during the build process. It installs our Python dependencies. This creates a new layer that contains all the installed packages.
  • COPY app.py .: This copies our application code into the image, creating another layer.
  • CMD ["python", "app.py"]: This specifies the command to run when the container starts. It doesn't create a layer, but sets the default command for the container.
  • EXPOSE 5000: This is actually just a form of documentation. It tells Docker that the container will listen on this port at runtime, but doesn't actually publish the port. It doesn't create a layer.
  • LABEL ...: These add metadata to the image. Like ENV instructions, they don't create new layers but are stored in the image metadata.

Each RUN, COPY, and ADD instruction in a Dockerfile creates a new layer. Layers are a fundamental concept in Docker that allow for efficient storage and transfer of images. When you make changes to your Dockerfile and rebuild the image, Docker will reuse cached layers that haven't changed, speeding up the build process.

  1. Now that we understand what our Dockerfile is doing, let's build the Docker image. In the terminal, run:
docker build -t advanced-flask-app .
Enter fullscreen mode Exit fullscreen mode

This command builds a new Docker image with the tag advanced-flask-app. The . at the end tells Docker to look for the Dockerfile in the current directory.

You'll see output showing each step of the build process. Notice how each step corresponds to an instruction in our Dockerfile, and how Docker mentions "Using cache" for steps that haven't changed if you run the build command multiple times.

  1. Once the build is complete, we can run a container based on our new image:
docker run -d -p 5000:5000 --name flask-container advanced-flask-app
Enter fullscreen mode Exit fullscreen mode

This command does the following:

  • -d runs the container in detached mode (in the background)
  • -p 5000:5000 maps port 5000 on your host to port 5000 in the container
  • --name flask-container gives a name to our new container
  • advanced-flask-app is the image we're using to create the container

You can verify that the container is running by checking the list of running containers:

docker ps
Enter fullscreen mode Exit fullscreen mode
  1. To test if our application is running correctly, we can use the curl command:
curl http://localhost:5000
Enter fullscreen mode Exit fullscreen mode

You should see the message "Hello from production environment!"

If you're having trouble with curl, you can also open a new browser tab and visit http://localhost:5000. You should see the same message.

If you encounter any issues, you can check the container logs using:

docker logs flask-container
Enter fullscreen mode Exit fullscreen mode

This will show you any error messages or output from your Flask application.

Multi-stage Builds

Now that we understand basic Dockerfile instructions and layers, let's explore a more advanced technique: multi-stage builds. Multi-stage builds allow you to use multiple FROM statements in your Dockerfile. This is particularly useful for creating smaller final images by copying only the necessary artifacts from one stage to another.

Let's modify our Dockerfile to use a multi-stage build that actually results in a smaller image:

  1. In WebIDE, open the Dockerfile we created earlier.
  2. Replace the entire content with the following:
# Build stage
FROM python:3.9-slim AS builder

WORKDIR /app

COPY requirements.txt .

RUN pip install --user --no-cache-dir -r requirements.txt

# Final stage
FROM python:3.9-slim

WORKDIR /app

# Copy only the installed packages from the builder stage
COPY --from=builder /root/.local /root/.local
COPY app.py .

ENV PATH=/root/.local/bin:$PATH
ENV ENVIRONMENT=production

CMD ["python", "app.py"]

EXPOSE 5000

LABEL maintainer="Your Name <your.email@example.com>"
LABEL version="1.0"
LABEL description="Flask app demo with multi-stage build"
Enter fullscreen mode Exit fullscreen mode

Let's break down what's happening in this multi-stage Dockerfile:

  1. We start with a builder stage:
  • We use the Python 3.9-slim image as our base to keep things small from the start.
  • We install our Python dependencies in this stage using pip install --user. This installs packages in the user's home directory.
  1. Then we have our final stage:
    • We start fresh with another Python 3.9-slim image.
    • We copy only the installed packages from the builder stage, specifically from /root/.local where pip install --user placed them.
    • We copy our application code.
    • We add the local bin directory to the PATH so Python can find the installed packages.
    • We set up the rest of our container (ENV, CMD, EXPOSE, LABEL) as before.

The key advantage here is that our final image doesn't include any of the build tools or caches from the pip installation process. It only contains the final, necessary artifacts. This should result in a smaller image.

  1. Let's build this new multi-stage image. In the terminal, run:
docker build -t multi-stage-flask-app .
Enter fullscreen mode Exit fullscreen mode
  1. Once the build is complete, let's compare the sizes of our two images. Run:
docker images | grep flask-app
Enter fullscreen mode Exit fullscreen mode
multi-stage-flask-app         latest     7bdd1be2d1fb   10 seconds ago   129MB
advanced-flask-app            latest     c59d6fa303cc   10 minutes ago   136MB
Enter fullscreen mode Exit fullscreen mode

You should now see that the multi-stage-flask-app is smaller than the advanced-flask-app we built earlier.

  1. Now, let's run a container with our new, slimmer image:
docker run -d -p 5001:5000 --name multi-stage-container multi-stage-flask-app
Enter fullscreen mode Exit fullscreen mode

Note that we're using a different host port (5001) to avoid conflicts with our previous container.

  1. Test the application:
curl http://localhost:5001
Enter fullscreen mode Exit fullscreen mode

You should still see the message "Hello from production environment!"

  1. To further understand the differences between our single-stage and multi-stage images, we can use the docker history command. Run these commands:
docker history advanced-flask-app
docker history multi-stage-flask-app
Enter fullscreen mode Exit fullscreen mode

Compare the outputs. You should notice that the multi-stage build has fewer layers and smaller sizes for some layers.

Multi-stage builds are a powerful technique for creating efficient Docker images. They allow you to use tools and files in your build process without bloating your final image. This is particularly useful for compiled languages or applications with complex build processes.

In this case, we've used it to create a smaller Python application image by only copying the necessary installed packages and application code, leaving behind any build artifacts or caches.

Using .dockerignore File

When building a Docker image, Docker sends all the files in the directory to the Docker daemon. If you have large files that aren't needed for building your image, this can slow down the build process. The .dockerignore file allows you to specify files and directories that should be excluded when building a Docker image.

Let's create a .dockerignore file and see how it works:

  1. In WebIDE, create a new file in the advanced-dockerfile directory and name it .dockerignore.
  2. Add the following content to the .dockerignore file:
**/.git
**/.gitignore
**/__pycache__
**/*.pyc
**/*.pyo
**/*.pyd
**/.Python
**/env
**/venv
**/ENV
**/env.bak
**/venv.bak
Enter fullscreen mode Exit fullscreen mode

Let's break down what these patterns mean:

  • **/.git: Ignore the .git directory and all its contents, wherever it appears in the directory structure.
  • **/.gitignore: Ignore .gitignore files.
  • **/__pycache__: Ignore Python's cache directories.
  • **/*.pyc, **/*.pyo, **/*.pyd: Ignore compiled Python files.
  • **/.Python: Ignore .Python files (often created by virtual environments).
  • **/env, **/venv, **/ENV: Ignore virtual environment directories.
  • **/env.bak, **/venv.bak: Ignore backup copies of virtual environment directories.

The ** at the start of each line means "in any directory".

  1. To demonstrate the effect of the .dockerignore file, let's create some files that we want to ignore. In the terminal, run:
mkdir venv
touch venv/ignore_me.txt
touch .gitignore
Enter fullscreen mode Exit fullscreen mode

These commands create a venv directory with a file inside, and a .gitignore file. These are common elements in Python projects that we typically don't want in our Docker images.

  1. Now, let's build our image again:
docker build -t ignored-flask-app .
Enter fullscreen mode Exit fullscreen mode
  1. To verify that the ignored files were not included in the build context, we can use the docker history command:
docker history ignored-flask-app
Enter fullscreen mode Exit fullscreen mode

You should not see any steps that copy the venv directory or the .gitignore file.

The .dockerignore file is a powerful tool for keeping your Docker images clean and your build process efficient. It's especially useful for larger projects where you might have many files that aren't needed in the final image.

Advanced Dockerfile Instructions

In this final step, we'll explore some additional Dockerfile instructions and best practices that can help make your Docker images more secure, maintainable, and easier to use. We'll also focus on troubleshooting and verifying each step of the process.

  1. In WebIDE, open the Dockerfile again.

  2. Replace the content with the following:

# Build stage
FROM python:3.9-slim AS builder

WORKDIR /app

COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

# Final stage
FROM python:3.9-slim

# Create a non-root user
RUN useradd -m appuser

WORKDIR /app

COPY --from=builder /root/.local /home/appuser/.local
COPY app.py .

ENV PATH=/home/appuser/.local/bin:$PATH
ENV ENVIRONMENT=production

# Set the user to run the application
USER appuser

# Use ENTRYPOINT with CMD
ENTRYPOINT ["python"]
CMD ["app.py"]

EXPOSE 5000

HEALTHCHECK --interval=30s --timeout=3s \
  CMD curl -f http://localhost:5000/ || exit 1

ARG BUILD_VERSION
LABEL maintainer="Your Name <your.email@example.com>"
LABEL version="${BUILD_VERSION:-1.0}"
LABEL description="Flask app demo with advanced Dockerfile techniques"
Enter fullscreen mode Exit fullscreen mode

Let's break down the new concepts introduced in this Dockerfile:

  • RUN useradd -m appuser: This creates a new user in the container. Running applications as a non-root user is a security best practice.
  • USER appuser: This instruction tells Docker to run any following RUN, CMD, or ENTRYPOINT instructions as the specified user.
  • ENTRYPOINT ["python"] with CMD ["app.py"]: When used together, ENTRYPOINT specifies the executable to run, while CMD provides default arguments. This setup allows users to easily override the arguments when running the container.
  • HEALTHCHECK: This instruction tells Docker how to test if the container is still working. In this case, it's making an HTTP request to the application every 30 seconds.
  • ARG BUILD_VERSION: This defines a build-time variable that users can set when building the image.
  1. Now, let's build this new image, specifying a build version:
docker build -t advanced-flask-app-v2 --build-arg BUILD_VERSION=2.0 .
Enter fullscreen mode Exit fullscreen mode

The --build-arg flag allows us to pass the BUILD_VERSION to the build process.

  1. Once the build is complete, let's verify that the image was created successfully:
docker images | grep advanced-flask-app-v2
Enter fullscreen mode Exit fullscreen mode

You should see the new image listed.

  1. Now, let's run a container with the new image:
docker run -d -p 5002:5000 --name advanced-container-v2 advanced-flask-app-v2
Enter fullscreen mode Exit fullscreen mode
  1. Let's verify that the container is running:
docker ps | grep advanced-container-v2
Enter fullscreen mode Exit fullscreen mode

If you don't see the container listed, it might have exited. Let's check for any stopped containers:

docker ps -a | grep advanced-container-v2
Enter fullscreen mode Exit fullscreen mode

If you see the container here but it's not running, we can check its logs:

docker logs advanced-container-v2
Enter fullscreen mode Exit fullscreen mode

This will show us any error messages or output from our application.

  1. Assuming the container is running, after giving it a moment to start up, we can check its health status:
docker inspect --format='{{.State.Health.Status}}' advanced-container-v2
Enter fullscreen mode Exit fullscreen mode

You should see "unhealthy" as the output.

  1. We can also verify that our build version was correctly applied:
docker inspect -f '{{.Config.Labels.version}}' advanced-flask-app-v2
Enter fullscreen mode Exit fullscreen mode

This should output "2.0", which is the version we specified during the build.

  1. Finally, let's test our application:
curl http://localhost:5002
Enter fullscreen mode Exit fullscreen mode

You should see the "curl: (7) Failed to connect to localhost port 5002 after 0 ms: Connection refused".

These advanced techniques allow you to create more secure, configurable, and production-ready Docker images. The non-root user improves security, the HEALTHCHECK helps with container orchestration, and build arguments allow for more flexible image building.

Summary

In this lab, we explored advanced Dockerfile techniques that will help you create more efficient, secure, and maintainable Docker images. We covered:

  1. Detailed Dockerfile instructions and their impact on image layers: We learned how each instruction contributes to the structure of our Docker image, and how understanding layers can help us optimize our images.
  2. Multi-stage builds: We used this technique to create smaller final images by separating our build environment from our runtime environment.
  3. Using .dockerignore files: We learned how to exclude unnecessary files from our build context, which can speed up builds and reduce image size.
  4. Advanced Dockerfile instructions: We explored additional instructions like USER, ENTRYPOINT, HEALTHCHECK, and ARG, which allow us to create more secure and flexible images.

These techniques allow you to:

  • Create more optimized and smaller Docker images
  • Improve security by running applications as non-root users
  • Implement health checks for better container orchestration
  • Use build-time variables for more flexible image building

Throughout this lab, we used WebIDE (VS Code) to edit our files, making it easy to create and modify Dockerfiles and application code directly in the browser. This approach allows for a seamless development experience when working with Docker.


🚀 Practice Now: Advanced Dockerfile Techniques


Want to Learn More?

Top comments (0)