Dockerfile Best Practices: Minimizing Image Size and Enhancing Performance

Harsh MangeHarsh Mange
7 min read

Introduction

For experienced developers, understanding Dockerfiles and building custom Docker images is critical to creating optimized, portable, and scalable applications. A well-crafted Dockerfile ensures that your application is packaged consistently across environments, leveraging Docker's caching system and minimizing image size for faster deployments.

This blog will dive deep into the Dockerfile syntax, exploring advanced directives, optimization techniques, multi-stage builds, and best practices for creating custom images. We'll focus on areas not covered in previous blogs, skipping the basic Dockerfile commands (like FROM, COPY, and RUN) and digging into the advanced features that provide flexibility and control in real-world applications.


Dockerfile Best Practices

Building efficient and reliable Docker images requires following best practices that ensure consistency, optimize performance, and reduce security risks.

1. Minimizing Image Size

One of the critical concerns when creating Docker images is minimizing their size. Smaller images reduce the time needed to build, transfer, and deploy them, improving the overall performance of the CI/CD pipeline. Some strategies include:

  • Using lightweight base images: Start with slim or Alpine versions of base images. For example, node:14-alpine is significantly smaller than node:14.

      FROM node:14-alpine
    
  • Combining commands to reduce layers: Each Dockerfile instruction creates a new layer. By combining related commands in a single RUN directive, you can reduce the total number of layers.

      RUN apt-get update && apt-get install -y \
          curl \
          vim \
          && rm -rf /var/lib/apt/lists/*
    
  • Removing unnecessary files: Remove build artifacts and cache files as part of your build process to keep your image lean.

      RUN rm -rf /tmp/* /var/tmp/* /path/to/build/files
    
  • Using .dockerignore: Just as .gitignore prevents unnecessary files from being added to a Git repository, a .dockerignore file prevents certain files from being copied into the image.

    Example .dockerignore:

      node_modules
      .git
      *.log
    

2. Controlling Build Context

The build context is the directory that Docker sends to the Docker daemon when building an image. Sending unnecessary files increases the build time and image size. The .dockerignore file plays a key role in reducing the size of the build context by excluding files and directories not needed in the image.

To see the size of your build context:

docker build -t my-image .
Sending build context to Docker daemon  157.3MB

The smaller the build context, the faster the build process.


Multi-Stage Builds

Multi-stage builds allow you to create lean production images by separating the build environment from the runtime environment. This technique is particularly useful for applications that require complex build processes (like compiling code), but only need the final binaries to run.

How Multi-Stage Builds Work

A multi-stage Dockerfile uses multiple FROM statements, each representing a stage. You can copy artifacts (e.g., compiled binaries, assets) from one stage to another, discarding the rest of the files and dependencies in the process.

Example:

# Build stage
FROM golang:1.18-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp .

# Production stage
FROM alpine:latest
WORKDIR /app
COPY --from=builder /app/myapp .
EXPOSE 8080
CMD ["./myapp"]

In this example:

  • The first stage (builder) installs Go, compiles the code, and creates a binary.

  • The second stage (production) copies the binary from the builder stage, discarding unnecessary build dependencies, creating a much smaller final image.

Benefits of Multi-Stage Builds

  • Reduced Image Size: Only the final artifacts (e.g., binaries) are included in the production image.

  • Separation of Concerns: You can separate the build environment from the runtime environment, reducing dependencies.

  • Reproducible Builds: Multi-stage builds ensure that the same artifacts are used in both the build and runtime stages, eliminating potential inconsistencies.


Advanced Dockerfile Instructions

In this section, we'll cover more advanced Dockerfile instructions that provide greater control over image creation and runtime behavior.

1. ARG vs. ENV

ARG and ENV are both used to define variables in Dockerfiles, but they serve different purposes:

  • ARG: Defines build-time variables. These can be passed to the Docker build process via the --build-arg flag but are not available at runtime.

      ARG VERSION=1.0.0
      RUN curl -o app.tar.gz https://example.com/app-$VERSION.tar.gz
    

    Usage:

      docker build --build-arg VERSION=2.0.0 -t my-image .
    
  • ENV: Defines environment variables that are available at both build time and runtime.

      ENV NODE_ENV=production
    

At runtime, you can override ENV variables with the -e flag:

docker run -e NODE_ENV=development my-image

2. Health Checks

Docker’s HEALTHCHECK instruction allows you to define how Docker should determine if your container is healthy. This is particularly useful in production environments where service availability is critical.

Example:

HEALTHCHECK --interval=30s --timeout=10s --retries=3 CMD curl --fail http://localhost:8080/health || exit 1
  • --interval: Time between health checks.

  • --timeout: Time before marking a health check as failed.

  • --retries: Number of retries before marking the container as unhealthy.

Once defined, you can check the health status of your containers using:

docker ps

The STATUS field will show healthy, unhealthy, or starting.

3. ONBUILD

The ONBUILD instruction adds a trigger to the image, which is only executed when the image is used as a base for another Dockerfile. This is useful for defining instructions that should be run in child images.

Example:

FROM node:14
ONBUILD COPY . /app
ONBUILD RUN npm install

When another Dockerfile uses this as its base image:

FROM my-base-image
# The ONBUILD instructions will be triggered here

This feature is particularly helpful when creating base images for microservices or development environments.


Docker Build Caching and Optimization

Understanding Docker’s build cache is essential for speeding up builds and reducing resource usage. Docker caches layers, and if a layer has not changed, it will be reused from a previous build.

Layer Caching

Docker reuses layers from previous builds as long as the instructions and the context remain unchanged. The build process moves sequentially through the Dockerfile, invalidating the cache from the point where a change is detected.

Optimizing the Build Cache

  1. Place frequently-changing instructions at the bottom: Since Docker builds the image layer by layer, changing a layer invalidates all subsequent layers. Therefore, place layers that change frequently (like copying code or running application builds) toward the end of the Dockerfile.

  2. Leverage external caching: Use --cache-from to reuse cache from an external source (e.g., a registry). This is useful in CI/CD pipelines where different environments may share the build cache.

  3. Reducing unnecessary files: Use .dockerignore to prevent unnecessary files from being copied to the build context, which can invalidate the cache.


Custom Entrypoints and CMD

ENTRYPOINT and CMD define how a container should start. Both can be used to specify the default command that will be run when the container starts, but they differ in their intended usage.

1. ENTRYPOINT

ENTRYPOINT is designed to be the primary command that is always executed when the container starts. It is usually used when you want the container to behave like an executable (e.g., a service).

Example:

ENTRYPOINT ["python3", "app.py"]

You can pass additional arguments to ENTRYPOINT commands at runtime:

docker run my-image --arg1 value1

2. CMD

CMD is the default command that is run if no other command is specified. It can also provide default arguments to the ENTRYPOINT.

Example:

CMD ["--default-arg"]

If both ENTRYPOINT and CMD are used, CMD serves as the default arguments to ENTRYPOINT.

ENTRYPOINT ["python3", "app.py"]
CMD ["--port", "8080"]

At runtime, you can override CMD but not ENTRYPOINT unless explicitly specified:

docker run my-image --port 9090

Conclusion

This deep dive into Dockerfiles and custom image creation for experienced developers covered advanced features like multi-stage builds, optimizing the build cache, minimizing image sizes, and using advanced instructions like ONBUILD, HEALTHCHECK, and ENTRYPOINT. By mastering these techniques, you can build highly efficient, secure, and maintainable Docker images tailored to your specific use cases.

In the next blog, we’ll explore Docker Volumes, Persistent Storage, and Docker Compose, where we will focus on managing data persistence and orchestrating multi-container applications.


Key Takeaways for Developers:

  • Multi-stage builds enable the separation of build and runtime environments, reducing image size and complexity.

  • Optimizing Dockerfiles using best practices leads to faster builds and smaller images.

  • Advanced Dockerfile instructions like HEALTHCHECK and ONBUILD provide more control over container behavior and lifecycle management.

0
Subscribe to my newsletter

Read articles from Harsh Mange directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Harsh Mange
Harsh Mange

This is Harsh Mange, working as a Software Engineer - Backend at Argoid. I love building apps. Working on contributing to open-source projects and the dev community.