Docker šŸ³Best Practices for šŸš€ Performance.

Smit Patel
11 min readSep 16, 2023

--

Docker have revolutionized the way we package, distribute, and deploy applications. They offer significant benefits in terms of isolation, portability, and scalability. However, to fully leverage these advantages while maintaining optimal performance, itā€™s essential to follow best practices. In this blog post, weā€™ll explore some of the key best practices for Docker to achieve peak performance for your containerized applications.

1. Use Lightweight Base Images šŸ“¦

  • Start with minimal base images like Alpine Linux or Scratch whenever possible.
  • Avoid using heavyweight distributions that come with unnecessary packages and libraries.

Example: Creating a Minimal Node.js Web Application Docker Image

Dockerfile:

# Use Alpine Linux as the base image
FROM node:14-alpine

# Set the working directory in the container
WORKDIR /app

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

# Install application dependencies
RUN npm install

# Expose the application port
EXPOSE 3000

# Define the command to run your application
CMD ["node", "app.js"]

Description:

In this adapted example, weā€™re building a Docker image for a Node.js web application using the Express.js framework. Weā€™re still using Alpine Linux as the base image because itā€™s lightweight and ideal for creating efficient Docker images.

Hereā€™s a brief explanation of each section of the Dockerfile:

  • FROM node:14-alpine: Specifies the base image, which includes Node.js 14 on top of Alpine Linux.
  • WORKDIR /app: Sets the working directory within the container to /app.
  • COPY . /app: Copies the application code (assumed to be in the current directory) into the container's /app directory.
  • RUN npm install: Installs application dependencies using npm.
  • EXPOSE 3000: Exposes port 3000 within the container, assuming our Node.js application listens on this port.
  • CMD ["node", "app.js"]: Defines the command to run our Node.js application, which starts the script app.js.

By using Alpine Linux as the base image and Node.js for the application, we create a minimal and efficient Docker image for our Node.js web server. This results in faster image deployments and optimal resource usage, which can lead to improved performance when running containers.

2. Optimize Your Dockerfile šŸ› ļø

  • Group related commands in a single RUN instruction to reduce image layers.
  • Remove unnecessary files and dependencies after installing packages.
  • Leverage multi-stage builds to reduce the size of the final image.

In this example, weā€™ll assume we have a simple Node.js application that needs to be containerized.

Dockerfile:

# ----- Stage 1: Build the application -----

# Use Node.js as the base image for building
FROM node:14-alpine AS build

# Set the working directory in the container
WORKDIR /app

# Copy package.json and package-lock.json
COPY package*.json ./

# Install dependencies
RUN npm install

# Copy the application source code
COPY . .

# Build the application
RUN npm run build

# ----- Stage 2: Create the final lightweight image -----

# Use a minimal Node.js image as the base for the final image
FROM node:14-alpine

# Set the working directory in the container
WORKDIR /app

# Copy only the built application from the previous stage
COPY --from=build /app/build ./build

# Expose the application port
EXPOSE 3000

# Define the command to run the application
CMD ["node", "build/app.js"]

Description:

Stage 1 ā€” Build the application:

  • We use the Node.js base image to build the application and create an intermediate container.
  • Set the working directory to /app.
  • Copy the package.json and package-lock.json files and install the dependencies.
  • Copy the entire application source code.
  • Build the application using a hypothetical script (npm run build in this case).

Stage 2 ā€” Create the final lightweight image:

  • Use another instance of the Node.js base image to create a lightweight final image.
  • Set the working directory to /app.
  • Copy only the necessary built application files from the previous stage using --from=build.
  • Expose port 3000 (assuming our application uses this port).
  • Define the command to run the application.

By using multi-stage builds, we separate the build environment from the final runtime environment, resulting in a smaller and more efficient Docker image. This practice helps reduce the image size and keeps it focused on just the necessary components for running the application.

3. Minimize Container Size šŸ“

  • Keep your containers small by only including essential files and dependencies.
  • Use .dockerignore to exclude unnecessary files and directories.

Letā€™s consider an example where you have a Node.js application, and you want to create a Docker image for it while keeping the container size small.

Directory Structure:

Assuming you have the following directory structure for your Node.js application:

my-node-app/
|-- node_modules/
|-- src/
| |-- app.js
|-- package.json
|-- package-lock.json
|-- .dockerignore
|-- Dockerfile

Hereā€™s how you can use a .dockerignore file and Dockerfile to minimize the container size:

.dockerignore:

Create a .dockerignore file in the root of your project to specify which files and directories should be excluded from the Docker build context. This will help reduce the size of the Docker image. For example:

node_modules
npm-debug.log
.DS_Store

In this example, weā€™re excluding the node_modules directory (since dependencies will be installed in the Docker image), npm-debug.log, and .DS_Store files.

Dockerfile:

Now, letā€™s create a Dockerfile for your Node.js application:

# Use Node.js as the base image
FROM node:14-alpine

# Set the working directory in the container
WORKDIR /app

# Copy package.json and package-lock.json
COPY package*.json ./

# Install dependencies
RUN npm install

# Copy the rest of the application code
COPY . .

# Expose the application port
EXPOSE 3000

# Define the command to run the application
CMD ["node", "src/app.js"]

In the Dockerfile:

  • We use the Node.js base image.
  • Set the working directory to /app.
  • Copy the package.json and package-lock.json files and install the dependencies.
  • Copy the remaining application code into the container.
  • Expose port 3000, assuming your application runs on that port.
  • Define the command to run your Node.js application.

By utilizing a .dockerignore file and excluding unnecessary files like node_modules from the build context, you ensure that only essential files and dependencies are included in the Docker image. This results in a smaller and more efficient container size, which is crucial for optimal performance and resource utilization.

4. Implement Caching šŸƒā€ā™‚ļø

  • Leverage Dockerā€™s build cache to speed up image builds.
  • Be cautious with cache invalidation; place frequently changing instructions at the end of your Dockerfile.

Directory Structure:

Assuming you have the following directory structure for your Node.js application:

my-node-app/
|-- node_modules/
|-- src/
| |-- app.js
|-- package.json
|-- package-lock.json
|-- Dockerfile

Hereā€™s how you can create a Dockerfile that leverages Dockerā€™s build cache:

Dockerfile:

# Use Node.js as the base image
FROM node:14-alpine

# Set the working directory in the container
WORKDIR /app

# Copy package.json and package-lock.json separately
# This allows us to use Docker's build cache efficiently
COPY package.json ./
COPY package-lock.json ./

# Install dependencies
RUN npm install

# Copy the rest of the application code
COPY . .

# Expose the application port
EXPOSE 3000

# Define the command to run the application
CMD ["node", "src/app.js"]

In this Dockerfile, we have employed caching optimizations:

  1. Copying package.json and package-lock.json separately: By copying these files individually before running npm install, we can leverage Docker's build cache efficiently. Docker will cache the result of the COPY package*.json ./ instruction as long as the package.json and package-lock.json files remain unchanged. If these files haven't changed since the last build, Docker will use the cached layer, skipping the time-consuming npm install step.
  2. Copying the application code after installing dependencies: We copy the application code after installing dependencies. This is important because application code changes more frequently than dependencies. Placing the COPY . . instruction after installing dependencies ensures that Docker can take advantage of the cache when there are changes in the application code but no changes in the dependency files.

By structuring your Dockerfile in this way, you minimize unnecessary rebuilds and utilize Dockerā€™s cache effectively, resulting in faster image builds and improved development efficiency. This is especially important in scenarios where the applicationā€™s dependencies change less frequently than the application code itself.

5. Limit Container Resources āš™ļø

  • Set resource constraints (CPU and memory limits) to prevent containers from monopolizing host resources.
  • Use the --cpus and --memory flags when running containers.

Example: Running a Docker Container with CPU and Memory Limits

Suppose you have a Node.js application in a Docker image, and you want to run it with resource constraints. Hereā€™s how you can do it:

# Run a Docker container with CPU and memory limits
docker run -d \
--name my-node-app \
--cpus 0.5 \ # Set CPU limit to 0.5 CPU core (50%)
--memory 512m \ # Set memory limit to 512 megabytes
my-node-app-image

In this example:

  • -d: Runs the container in detached mode.
  • --name my-node-app: Assigns a name to the container, which makes it easier to manage.
  • --cpus 0.5: Sets a CPU limit of 0.5 CPU core, which means the container can use up to 50% of one CPU core. You can adjust the value as needed.
  • --memory 512m: Sets a memory limit of 512 megabytes (MB). You can specify the limit in other units such as g for gigabytes or k for kilobytes.
  • my-node-app-image: The name or ID of the Docker image you want to run as a container.

By specifying CPU and memory limits, you ensure that the Docker container cannot consume more CPU or memory than the defined constraints, which helps prevent resource contention and ensures fair resource allocation across containers on the same host. Adjust these limits according to your applicationā€™s requirements and the available resources on your host machine.

These resource constraints are essential for maintaining a stable and well-performing environment when running multiple containers on a single host.

6. Monitor and Optimize Container Health šŸ“ˆ

  • Use tools like Prometheus and Grafana to monitor container performance.
  • Regularly review and adjust resource limits based on container metrics.

Example: Setting Up Container Monitoring with Prometheus and Grafana

Set Up Prometheus:

Start by setting up Prometheus to scrape container metrics. You can create a prometheus.yml configuration file to specify the targets to monitor. Here's a simple example:

global:
scrape_interval: 15s

scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']

- job_name: 'docker-containers'
static_configs:
- targets: ['localhost:9323']

This configuration scrapes metrics from Prometheus itself and Docker containers.

Run Prometheus:

You can run Prometheus using a Docker container:

docker run -d -p 9090:9090 -v /path/to/prometheus.yml:/etc/prometheus/prometheus.yml prom/prometheus

This command starts Prometheus, binds it to port 9090, and mounts the prometheus.yml configuration file.

Set Up Grafana:

Next, set up Grafana to visualize the collected metrics. You can use the official Grafana Docker image:

docker run -d -p 3000:3000 grafana/grafana

Access Grafana at http://localhost:3000, and log in using the default credentials (admin/admin).

Add Prometheus as a Data Source:

In Grafana, add Prometheus as a data source:

  • Navigate to ā€œConfigurationā€ > ā€œData Sources.ā€
  • Click ā€œAdd data source.ā€
  • Choose ā€œPrometheusā€ and configure it with the URL of your Prometheus instance (e.g., http://localhost:9090).

Create Dashboards:

Create Grafana dashboards to visualize container metrics. You can import existing dashboards for Docker and container metrics from Grafanaā€™s dashboard repository.

Adjust Resource Limits Based on Metrics:

Monitor the performance and resource utilization of your containers through Grafana dashboards. Pay attention to metrics like CPU usage, memory usage, and container health indicators.

  • If you notice a container consistently exceeding resource limits or exhibiting poor performance, adjust its resource limits using the docker update command. For example, to increase CPU shares:
docker update --cpus 2 my-container
  • This command increases the CPU shares for the container named my-container to 2 CPU cores. Adjust memory limits similarly with the --memory flag.

By regularly reviewing container metrics in Grafana and adjusting resource limits as needed, you can ensure that your containers are using resources optimally and maintaining good performance. This proactive approach helps prevent resource contention and improves the overall health of your containerized applications.

7. Avoid Running as Root šŸ”’

  • Whenever possible, run containers as non-root users to enhance security and reduce attack vectors.

Suppose you have a Node.js application that you want to run in a Docker container. By default, many Docker images run processes as the root user inside the container. To enhance security, you can create a non-root user in your Docker image and run your application as that user.

Dockerfile:

# Use Node.js as the base image
FROM node:14-alpine

# Create a non-root user named "appuser"
RUN adduser -D -g '' appuser

# Set the working directory in the container
WORKDIR /app

# Copy package.json and package-lock.json
COPY package*.json ./

# Install dependencies as the non-root user
RUN chown -R appuser:appuser /app
USER appuser

# Install application dependencies
RUN npm install

# Copy the rest of the application code
COPY . .

# Expose the application port
EXPOSE 3000

# Define the command to run the application
CMD ["node", "src/app.js"]

In this Dockerfile:

  • We start with the Node.js base image.
  • We create a non-root user named ā€œappuserā€ using the adduser command. The -D flag means creating a system user, and -g '' means no initial group.
  • We set the working directory to /app in the container.
  • We copy package.json and package-lock.json files and then change ownership to the "appuser" to ensure that the npm install command runs as the non-root user.
  • We switch to the ā€œappuserā€ user using the USER command.
  • We install application dependencies and copy the rest of the application code.
  • We expose port 3000, assuming the application runs on that port.
  • We define the command to run the application.

By creating and running the container as a non-root user, you enhance the security of your containerized application. In the event of a security breach or vulnerability exploitation, the impact will be limited to the container itself, reducing the potential harm to your system.

Conclusion, Docker is powerful tool for containerizing and orchestrating applications, but achieving optimal performance requires careful planning and adherence to best practices. By following these guidelines, you can ensure that your containerized applications not only run smoothly but also make the most efficient use of system resources. Continuously monitoring and optimizing your containers will help you maintain peak performance as your applications evolve.

--

--

Smit Patel

Passionate about crafting efficient and scalable solutions to solve complex problems. Follow me for practical tips and deep dives into cutting-edge technologies