Building and Managing Docker Images

By Anurag Singh

Updated on Aug 21, 2024

Building and Managing Docker Images

In this tutorial, we're building and managing Docker images with best practices.

Docker images are at the core of containerization, allowing you to package applications and their dependencies into a single, portable unit. However, to ensure efficiency, security, and maintainability, it's crucial to follow best practices when building and managing Docker images. This tutorial will guide you through these best practices to help you create optimized Docker images.

Prerequisites

  • A Docker installed dedicated server or KVM VPS.
  • A root user access or normal user with sudo rights.
  • Basic knowledge of Docker.

Building and Managing Docker Images

1. Choose the Right Base Image

Minimal Base Image: Start with a minimal base image like alpine or scratch to reduce the image size and surface area for vulnerabilities. Alpine is only about 5 MB, compared to a standard Ubuntu image, which is around 200 MB.
Official Base Images: Use official images from Docker Hub when possible, as they are maintained and regularly updated for security patches.

2. Optimize Dockerfile Instructions

Order Matters: Docker builds images layer by layer. Frequently changing instructions (like COPY or ADD) should come later in your Dockerfile to take advantage of caching and avoid unnecessary rebuilds.
Combine Commands: Use && to combine commands in a single RUN instruction. This minimizes the number of layers and reduces the final image size.

RUN apt-get update && apt-get install -y \
    curl \
    git \
    && rm -rf /var/lib/apt/lists/*

3. Minimize Layers

Fewer Layers, Smaller Images: Each instruction in your Dockerfile creates a new layer. Minimize layers by combining instructions where possible, but balance this with readability and maintainability of your Dockerfile.

4. Use .dockerignore File

Exclude Unnecessary Files: Use a .dockerignore file to prevent unnecessary files and directories (e.g., .git, node_modules, build artifacts) from being copied into the Docker image.

.git
node_modules
*.log

5. Leverage Multi-Stage Builds

Separate Build and Runtime Stages: Multi-stage builds allow you to separate the build environment from the runtime environment, reducing the size of the final image.
dockerfile

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

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

6. Use Tags Wisely

Versioning Tags: Always tag your images with specific version numbers (e.g., v1.0.0) rather than using latest to ensure consistency and reproducibility.
Environment-Specific Tags: Use tags to distinguish between environments, such as dev, staging, and production.

7. Avoid Hardcoding Secrets

Use Environment Variables: Store sensitive data like passwords and API keys in environment variables, and pass them during runtime using Docker secrets or Docker Compose.
Docker Secrets: For production environments, leverage Docker secrets to manage sensitive data securely.

8. Regularly Update Base Images

Security Patches: Regularly update your base images to include the latest security patches. You can automate this process using CI/CD pipelines.
Rebuild Images: Rebuild your Docker images periodically to ensure all layers, including the base image, are up to date.

9. Clean Up After Installation

Remove Temporary Files: After installing packages, remove temporary files and caches to reduce the image size.

RUN apt-get update && apt-get install -y curl \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

10. Health Checks

Define Health Checks: Use the HEALTHCHECK instruction to define a command that tests the health of your running container. This ensures that Docker can automatically restart the container if something goes wrong.

HEALTHCHECK CMD curl --fail http://localhost:8080/health || exit 1

11. Document Your Dockerfile

Comments: Add comments to your Dockerfile to explain the purpose of each instruction, making it easier for others (and yourself) to understand and maintain.
Label Metadata: Use the LABEL instruction to add metadata to your images, such as the maintainer’s name, version, and description.

LABEL maintainer="you@example.com"
LABEL version="1.0.0"
LABEL description="My Docker image"

12. Test Your Images

Test Before Deployment: Always test your Docker images in a staging environment before deploying to production. Use tools like Docker Compose or Kubernetes to simulate production environments.

Automated Tests: Incorporate automated testing into your CI/CD pipeline to validate your images continuously.

13. Use Docker Bench for Security

Security Audits: Regularly run Docker Bench for Security to ensure your Docker containers and images adhere to security best practices.

docker run -it --net host --pid host --cap-add audit_control \
    --label docker_bench_security \
    --security-opt seccomp=unconfined \
    docker/docker-bench-security

14. Keep Image Size in Check

Analyze and Optimize: Use tools like docker-slim to analyze and reduce your Docker image size by stripping unnecessary files and binaries.

docker-slim build your-image-name

15. Monitor and Maintain Your Images

Image Vulnerability Scanning: Regularly scan your Docker images for vulnerabilities using tools like Clair or Docker’s built-in security scanning features.
Regular Maintenance: Keep your images and containers up to date, retire old images, and clean up unused Docker images, containers, volumes, and networks to free up space.

Building and managing Docker images requires attention to detail and adherence to best practices to ensure your images are secure, efficient, and maintainable. By following these guidelines, you'll be able to create Docker images that are optimized for production environments and are easy to manage over time.

Let's walk through the process of building and managing a Docker image with a simple example. We'll create a Docker image for a basic Node.js application and manage it following best practices.

Step 1: Set Up the Project

Create a Project Directory:

mkdir node-docker-app && cd node-docker-app

Create a Simple Node.js Application:

Create a package.json file:

nano package.json

Add following content

{
  "name": "node-docker-app",
  "version": "1.0.0",
  "description": "A simple Node.js app",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "express": "^4.19.2"
  }
}

Save and exit the file.

Create an index.js file:

nano index.js

Add following content:

const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.send('Hello, Docker!');
});

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});

Step 2: Create a Dockerfile

Create a Dockerfile in the root of your project directory.

nano Dockerfile

Add following content:

# Step 1: Choose a base image
FROM node:22-alpine

# Step 2: Set the working directory
WORKDIR /app

# Step 3: Copy package.json and install dependencies
COPY package.json ./
RUN npm install

# Step 4: Copy the rest of the application files
COPY . .

# Step 5: Expose the application port
EXPOSE 3000

# Step 6: Start the application
CMD ["npm", "start"]

Step 3: Build the Docker Image

Build the Image:

docker build -t node-docker-app:1.0.0 .

Verify the Image:

docker images

You should see the node-docker-app image listed.

Step 4: Run the Docker Container

Run the Container:

docker run -d -p 3000:3000 --name my-node-app node-docker-app:1.0.0

Access the Application:

Open your browser and navigate to http://localhost:3000. You should see the message "Hello, Docker!"

Step 5: Manage the Docker Image

1. Tag the Image for Different Environments:

You can tag your image with different tags for different environments:

docker tag node-docker-app:1.0.0 node-docker-app:dev
docker tag node-docker-app:1.0.0 node-docker-app:staging
docker tag node-docker-app:1.0.0 node-docker-app:production

2. Push the Image to a Docker Registry:

To share your image, push it to Docker Hub or a private registry:

docker login
docker tag node-docker-app:1.0.0 your-dockerhub-username/node-docker-app:1.0.0
docker push your-dockerhub-username/node-docker-app:1.0.0

3. Update the Application and Rebuild the Image:

If you make changes to your application, you'll need to rebuild the image:

Make Changes: Modify index.js to change the response message.

nano index.js

Modify res.send('Hello, Docker! Updated');

Save and exit the file

Rebuild the Image:

docker build -t node-docker-app:1.1.0 .

Open your browser and navigate to http://localhost:3001. You should see your updated message "Hello, Docker! Updated"

Run the Updated Container:

docker run -d -p 3001:3000 --name my-node-app-v1.1 node-docker-app:1.1.0

4. Clean Up Old Images and Containers:

To free up space, you can remove old images and containers:

Stop and Remove the Old Container:

docker stop my-node-app
docker rm my-node-app

Remove Old Images:

docker rmi node-docker-app:1.0.0

Clean Up Unused Images, Containers, and Networks:

docker system prune -a

5. Security Scanning:

Use Docker’s built-in security scanning (if you have Docker Enterprise) or third-party tools like Clair to scan for vulnerabilities:

docker scan node-docker-app:1.1.0

Conclusion

By following these steps, you've created a Docker image for a Node.js application, tagged it for different environments, pushed it to a registry, updated it, and managed old images and containers. This process demonstrates best practices in building and managing Docker images, ensuring your application is containerized efficiently and securely.