Introduction
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.
- First, let's create a new directory for our project. In the WebIDE terminal, run:
mkdir -p ~/project/advanced-dockerfile && cd ~/project/advanced-dockerfile
This command creates a new directory called advanced-dockerfile inside the project folder and then changes into that directory.
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-dockerfilefolder and select "New File". Name this fileapp.py.Open
app.pyand 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)
This is a simple Flask application that responds with a greeting message, including the environment it's running in.
- Next, we need to create a
requirements.txtfile to specify our Python dependencies. Create a new file namedrequirements.txtin the same directory and add the following content:
Flask==2.0.1
Werkzeug==2.0.1
Here, we're specifying exact versions for both Flask and Werkzeug to ensure compatibility.
- 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"
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.
- 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 .
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.
- 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
This command does the following:
-druns the container in detached mode (in the background)-p 5000:5000maps port 5000 on your host to port 5000 in the container--name flask-containergives a name to our new containeradvanced-flask-appis 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
- To test if our application is running correctly, we can use the
curlcommand:
curl http://localhost:5000
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
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:
- In WebIDE, open the
Dockerfilewe created earlier. - 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"
Let's break down what's happening in this multi-stage Dockerfile:
We start with a
builderstage:- 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.
Then we have our final stage:
- We start fresh with another Python 3.9-slim image.
- We copy only the installed packages from the
builderstage, specifically from/root/.localwherepip install --userplaced 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.
- Let's build this new multi-stage image. In the terminal, run:
docker build -t multi-stage-flask-app .
- Once the build is complete, let's compare the sizes of our two images. Run:
docker images | grep flask-app
multi-stage-flask-app latest 7bdd1be2d1fb 10 seconds ago 129MB
advanced-flask-app latest c59d6fa303cc 10 minutes ago 136MB
You should now see that the multi-stage-flask-app is smaller than the advanced-flask-app we built earlier.
- 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
Note that we're using a different host port (5001) to avoid conflicts with our previous container.
- Test the application:
curl http://localhost:5001
You should still see the message "Hello from production environment!"
- To further understand the differences between our single-stage and multi-stage images, we can use the
docker historycommand. Run these commands:
docker history advanced-flask-app
docker history multi-stage-flask-app
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:
- In WebIDE, create a new file in the
advanced-dockerfiledirectory and name it.dockerignore. - Add the following content to the
.dockerignorefile:
**/.git
**/.gitignore
**/__pycache__
**/*.pyc
**/*.pyo
**/*.pyd
**/.Python
**/env
**/venv
**/ENV
**/env.bak
**/venv.bak
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".
- To demonstrate the effect of the
.dockerignorefile, let's create some files that we want to ignore. In the terminal, run:
mkdir venv
touch venv/ignore_me.txt
touch .gitignore
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.
- Now, let's build our image again:
docker build -t ignored-flask-app .
- To verify that the ignored files were not included in the build context, we can use the
docker historycommand:
docker history ignored-flask-app
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.
In WebIDE, open the
Dockerfileagain.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
## Install curl for healthcheck
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
## Dynamically determine Python version and site-packages path
RUN PYTHON_VERSION=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') && \
SITE_PACKAGES_PATH="/home/appuser/.local/lib/python${PYTHON_VERSION}/site-packages" && \
mkdir -p "${SITE_PACKAGES_PATH}" && \
chown -R appuser:appuser /home/appuser/.local
## Copy site-packages and binaries using the variable
COPY --from=builder /root/.local/lib/python3.9/site-packages "${SITE_PACKAGES_PATH}"
COPY --from=builder /root/.local/bin /home/appuser/.local/bin
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"
Let's break down the new concepts introduced in this Dockerfile:
RUN useradd -m appuser: This creates a new user namedappuserin the container. Running applications as a non-root user is a security best practice, as it limits the potential damage if the application is compromised. The-mflag creates a home directory for the user.RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*: This installs the curl package which is needed for our HEALTHCHECK instruction to work. We also clean up the apt cache to reduce the image size.RUN PYTHON_VERSION=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') && ...: This set of commands dynamically determines the Python version within the container and creates the correctsite-packagesdirectory for theappuser. It also sets the correct permissions for the user's local directory.COPY --from=builder /root/.local/lib/python3.9/site-packages "${SITE_PACKAGES_PATH}": This instruction copies the installed Python packages from thebuilderstage to the dynamically determinedsite-packagespath within the final image, ensuring packages are placed in the correct location for theappuserto use.COPY --from=builder /root/.local/bin /home/appuser/.local/bin: This copies executable scripts installed bypip(like Flask's command-line interface, if any) from thebuilderstage to theappuser's localbindirectory.ENTRYPOINT ["python"]withCMD ["app.py"]: When used together,ENTRYPOINTdefines the main executable for the container (in this case,python), andCMDprovides the default arguments to that executable (app.py). This pattern allows for flexibility: users can run the container and executeapp.pyby default, or they can override theCMDto run other Python scripts or commands.HEALTHCHECK: This instruction configures a health check for the container. Docker will periodically execute the specified command (curl -f http://localhost:5000/) to determine if the container is healthy. The--interval=30sand--timeout=3sflags set the check interval and timeout respectively. If thecurlcommand fails (returns a non-zero exit code), the container is considered unhealthy.ARG BUILD_VERSION: This defines a build argument namedBUILD_VERSION. Build arguments allow you to pass values into the Docker image at build time.LABEL version="${BUILD_VERSION:-1.0}": This sets a label namedversionon the Docker image. It uses theBUILD_VERSIONbuild argument. IfBUILD_VERSIONis provided during the build, its value will be used; otherwise, it defaults to1.0(using the:-default value syntax).
- Now, let's build this new image, specifying a build version:
docker build -t advanced-flask-app-v2 --build-arg BUILD_VERSION=2.0 .
The --build-arg BUILD_VERSION=2.0 flag allows us to pass the value 2.0 for the BUILD_VERSION build argument during the image build process. This value will be used to set the version label in the Docker image.
- Once the build is complete, let's verify that the image was created successfully:
docker images | grep advanced-flask-app-v2
You should see the new image advanced-flask-app-v2 listed in the output of the docker images command, along with its tag, image ID, creation date, and size.
- Now, let's run a container with the new image:
docker run -d -p 5002:5000 --name advanced-container-v2 advanced-flask-app-v2
This command runs a container in detached mode (-d), maps port 5002 on your host to port 5000 in the container (-p 5002:5000), names the container advanced-container-v2 (--name advanced-container-v2), and uses the advanced-flask-app-v2 image to create the container.
- Let's verify that the container is running:
docker ps | grep advanced-container-v2
If the container is running successfully, you should see it listed in the output of the docker ps command. 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
If you see the container listed in the output of docker ps -a but it's not running (status is not "Up"), we can check its logs for errors:
docker logs advanced-container-v2
This command will display the logs of the advanced-container-v2 container, which can help diagnose any startup issues or runtime errors in your Flask application.
- 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
After a short delay (to allow the health check to run at least once), you should see "healthy" as the output. If you see "unhealthy" initially, wait for another 30 seconds (the health check interval) and run the command again. If it remains "unhealthy", check the container logs using docker logs advanced-container-v2 for potential issues with your Flask application. If there are no obvious issues, you can ignore the "unhealthy" status.
- We can also verify that our build version label was correctly applied:
docker inspect -f '{{.Config.Labels.version}}' advanced-container-v2
This command retrieves the value of the version label from the advanced-container-v2 container and displays it. You should see "2.0" as the output, which confirms that the BUILD_VERSION build argument was correctly used to set the label.
- Finally, let's test our application by sending a request to it:
curl http://localhost:5002
You should see the message "Hello from production environment!" in the output. This indicates that your Flask application is running correctly inside the Docker container and is accessible on port 5002 of your host.
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 monitoring, and build arguments allow for more flexible and versioned 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:
- 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.
- Multi-stage builds: We used this technique to create smaller final images by separating our build environment from our runtime environment.
- Using .dockerignore files: We learned how to exclude unnecessary files from our build context, which can speed up builds and reduce image size.
- 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.



