Dockerfile Best Practices
A Dockerfile is an instruction file used to build a Docker image, defining the complete environment required from the operating system down to the application. A well-structured Dockerfile not only ensures consistent container behavior across different environments ("build once, run anywhere") but also significantly reduces repository size, accelerates build times, and simplifies operational workflows. Writing high-quality Dockerfiles requires adhering to a set of core principles: maintaining minimalization to reduce the attack surface, optimizing layer caching for faster builds, and managing dependencies clearly.
Before beginning to write one, it is essential to understand the interaction mechanism between three key instructions: FROM (base image), RUN (execute commands), and COPY/ADD (copy files). Docker stores images using a layered architecture, meaning each instruction—or series of lines—generates a new read-only layer. Leveraging this feature, if subsequent steps depend on the results of previous ones, related operations should be merged as much as possible to allow Docker to reuse existing cached layers during the build process.
Selecting an appropriate base image is the first step in writing a compliant Dockerfile. A common pitfall is directly choosing generic distributions like ubuntu, debian, or centos as baselines, which results in large final container sizes populated with unnecessary system components and potential security vulnerabilities.
The best practice is to adopt official lightweight base images (such as python:3.9-slim, node:18-alpine, golang:1.20-bookworm, etc.). These slim or alpine series images remove non-production-required components such as graphical interfaces and development toolkits, significantly reducing the attack surface and disk usage.
When selecting a base image, consider the following factors:
- Lifecycle Matching: Ensure the tag of your base image matches the versions required by your application dependencies to avoid compatibility issues arising from using either the latest stable or an outdated version.
- Architecture Consistency: Confirm that the CPU architecture provided by the base image matches your runtime environment's architecture to prevent runtime errors caused by hardware incompatibility.
Layered Build Techniques for RUN Instructions
The RUN instruction is used to execute shell commands inside a container and represents one of the most important parts of a Dockerfile. To maximize build efficiency, you must adhere to the principle of "combining like items." Docker treats consecutive RUN instructions as a whole unit for caching calculation until encountering a new base image or file changes.
For example, do not split installing dependencies and compiling code into separate steps:
# ❌ Not recommended: apt-get update && install must be re-executed every time the code is modified
RUN apt-get update \
&& apt-get install -y gcc make python3-dev \
&& pip install requirements.txt
# ✅ Recommended: Merge dependency installation with compilation to reduce intermediate layers
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential libssl-dev zlib1g-dev \
libbz2-dev liblzma-dev libffi-dev curl wget ca-certificates gnupg \
git openssh-client sudo unzip xz-utils zip librss0 libcurl4-gnutls-dev:amd64 \
&& rm -rf /var/lib/apt/lists/*
# When modifying code later, only the compilation step needs to be re-run; there is no need for the time-consuming operation of reinstalling dependencies.
Additionally, always use absolute paths (such as /usr/bin/python3) within RUN instructions to avoid issues caused by relative path references leading to inconsistent build environments. Furthermore, pay attention to cleaning up temporary files to reduce image size; while modern Docker engines automatically delete temporary files in container layers, explicit cleanup is still a good habit when dealing with multi-stage builds or multiple user scenarios.
Standardized Usage of COPY and ADD Instructions and Dependency Handling
Both COPY and ADD are used to copy local filesystem contents into the container, but their behaviors differ slightly:
- COPY: Copies only specified source files to a target path; it does not support automatic extraction or downloading remote resources. It is the recommended preferred method.
- ADD: In addition to having COPY functionality, it allows automatically detecting and extracting archive files like tar and zip, as well as supporting content retrieval from URLs.
In standardized writing, COPY should be used preferentially. If you must handle compressed archives or network downloads, consider combining them with build steps or defining a separate layer at the top of the Dockerfile to avoid polluting the application runtime layer (Application Layer). For multi-stage build scenarios, unnecessary files such as IDE configurations and local test data are typically excluded from .dockerignore, copying only necessary source code and dependency packages.
Practical Application of Multi-Stage Builds
As project complexity increases, a single image often struggles to balance "development convenience" with "production lightness." At this point, introducing multi-stage builds becomes the industry-standard solution. This technology allows defining multiple FROM instructions within a single Dockerfile, utilizing the characteristics of different stages for compilation, testing, and packaging, ultimately retaining only the most streamlined production environment layer.
Below is an example of multi-stage build for a typical Node.js web application:
# Stage 1: Used to install dependencies and compile code (usually includes heavy tools like gcc, python-dev)
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production # Only production environment dependencies to reduce size
COPY . .
# Assuming the existence of a build script named build.sh
RUN chmod +x build.sh && \
./build.sh
# Stage 2: Used to generate the final image (contains only files required for runtime)
FROM node:18-alpine AS runner
WORKDIR /app
# Copy compiled artifacts from the builder stage, not source code and development tools
COPY --from=builder /app/dist .
EXPOSE 3000
CMD ["node", "dist/server.js"]
Through this method, the final image no longer contains build tools like gcc or python-dev, potentially reducing size from hundreds of MB to tens of MB while maintaining automated code compilation capabilities.
Summary and Continuous Optimization Suggestions
Writing a compliant Dockerfile is an iterative technical task that requires continuous improvement. As project dependency libraries update and architecture adjusts, the original instruction order or base image selection may need re-evaluation. Developers should regularly review build logs (Build Logs) to analyze whether there are issues such as redundant calculations, path errors, or volume expansion. Additionally, combine with CI/CD pipelines for automated verification to ensure Dockerfile changes do not introduce unforeseen side effects.
Adhering to these specifications can improve team collaboration efficiency and guarantee the stability and security of containerized applications from the source. Remember that an excellent Dockerfile is not just a build script but also the cornerstone of project infrastructure.