Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 27

Container Scaling for High Availability

Recommended resources

Hello! Welcome back.

In our last session, we mastered the art of managing environment-specific configurations using .env files and multiple Compose files. This allows us to create flexible and secure setups for development and production. Now that we have a production-ready configuration, the next logical step is to ensure our application is both resilient and performant under load.

Today, we'll focus on a core concept in modern infrastructure: high availability. You'll learn how to scale services to run multiple container instances. We'll explore how Docker Compose enables you to run several copies of a service and, just as importantly, how to distribute traffic between them using a load balancer. This skill is fundamental for building fault-tolerant applications that can handle increased user traffic.

1. The "Why" Behind Scaling: High Availability and Load Balancing

Before we dive into commands, let's clarify the concept. When we talk about "scaling" a service in this context, we mean horizontal scaling—running multiple identical copies (replicas) of that service. This achieves two primary goals:

  1. High Availability: If one container instance crashes or becomes unresponsive, other instances are still running and can handle requests. This prevents a single point of failure and makes your application more resilient.
  2. Load Balancing: When traffic increases, a single container might become a bottleneck. By running multiple instances, you can distribute the incoming requests among them, improving performance and response times.

This principle is the bedrock of container orchestrators like Kubernetes and Docker Swarm.

Service Scaling with Replicas in a Distributed System
This image illustrates the core concept of scaling. A manager node distributes multiple replicas of a service across different worker nodes as containerized tasks. This ensures that the service remains available even if one node or container fails, and it allows traffic to be spread across all instances.

While the image depicts a multi-node Docker Swarm cluster, Docker Compose allows us to apply the same principle on a single machine, which is perfect for development, testing, and smaller-scale production deployments.


2. Scaling Services with the --scale Flag

The most direct way to scale a service with Docker Compose on a single host is by using the --scale flag with the up command. This tells Compose to create a specified number of instances for a particular service, overriding the default of one.

Let's build a practical example. We'll create a simple web application and scale it to three instances.

Hands-On Exercise: Scaling a Web Service

First, set up the following directory structure and files:

scaling-exercise/
├── docker-compose.yml
└── web/
    └── index.html
  1. web/index.html: Create a simple HTML file. To see the load balancing in action later, we'll use a little JavaScript to display the container's hostname.

    <!DOCTYPE html>
    <html>
    <head>
        <title>Scaled Service</title>
        <style>
            body { font-family: sans-serif; text-align: center; background-color: #282c34; color: white; margin-top: 100px; font-size: 1.5em; }
            .hostname { color: #61dafb; font-weight: bold; }
        </style>
    </head>
    <body>
        <h1>Hello from a Scaled Service!</h1>
        <p>This request was served by container: <b class="hostname" id="hostname">...</b></p>
        <script>
            // In a real app, the backend would render this. For our static Nginx example, we'll fetch it.
            // Note: This is a simplification for demo purposes.
            document.getElementById('hostname').textContent = window.location.hostname;
        </script>
    </body>
    </html>
    
  2. docker-compose.yml: Define a single service named web that uses the standard nginx image and mounts our index.html file.

    version: '3.8'
    
    services:
      web:
        image: nginx:alpine
        volumes:
          - ./web/index.html:/usr/share/nginx/html/index.html
        networks:
          - app-net
    
    networks:
      app-net:
        driver: bridge
    

Now, from within the scaling-exercise directory, run the following command to start three instances of the web service:

docker compose up --scale web=3 -d

You can verify that three containers for the web service are running:

docker compose ps

You should see output similar to this, showing three running containers:

NAME                          COMMAND                  SERVICE             STATUS              PORTS
scaling-exercise-web-1        "/docker-entrypoint.…"   web                 running             80/tcp
scaling-exercise-web-2        "/docker-entrypoint.…"   web                 running             80/tcp
scaling-exercise-web-3        "/docker-entrypoint.…"   web                 running             80/tcp

We now have three running web servers, but we have a problem: none of them have published ports. How do we access them? And how do we distribute traffic among them? This is where a load balancer comes in.


3. Adding a Load Balancer with Nginx

To distribute traffic to our scaled web instances, we need a single entry point: a reverse proxy that acts as a load balancer. We'll add another Nginx service to our Compose file to perform this role.

A key feature of Docker's networking is its built-in DNS service. When a service is scaled, any other container on the same network can resolve the service name (e.g., web) and Docker's DNS will return the IP addresses of all healthy containers for that service in a round-robin fashion. Our load balancer can leverage this to automatically discover and distribute traffic to all replicas.

Let's update our project.

Hands-On Exercise: Adding the Load Balancer

  1. Create an Nginx configuration file: In the root of your scaling-exercise directory, create nginx.conf.

    # nginx.conf
    events {}
    
    http {
        upstream web_backend {
            # Docker's DNS will resolve 'web' to all container IPs for the service.
            # Nginx will then round-robin requests between them.
            server web:80;
        }
    
        server {
            listen 80;
    
            location / {
                proxy_pass http://web_backend;
            }
        }
    }
    

    This configuration tells Nginx to listen for requests and forward (proxy_pass) them to an upstream group named web_backend. That group contains a single entry: server web:80;. Because of Docker's DNS, this is all we need to load balance across all web containers.

  2. Update docker-compose.yml: Add the load_balancer service.

    version: '3.8'
    
    services:
      web:
        image: nginx:alpine
        # We don't need ports here, the load balancer will handle access.
        volumes:
          - ./web/index.html:/usr/share/nginx/html/index.html
        networks:
          - app-net
    
      load_balancer:
        image: nginx:alpine
        ports:
          - "8080:80" # Expose the load balancer on host port 8080
        volumes:
          - ./nginx.conf:/etc/nginx/nginx.conf:ro # Mount our config
        networks:
          - app-net
        depends_on: # Optional but good practice
          - web
    
    networks:
      app-net:
        driver: bridge
    

Now, bring down the old containers and run the full stack:

# Stop and remove the previous containers
docker compose down

# Start the new stack with 3 web instances
docker compose up --scale web=3 -d

Open your browser to http://localhost:8080. Refresh the page several times. You will see that the container hostname changes (e.g., scaling-exercise-web-1, scaling-exercise-web-2, scaling-exercise-web-3), confirming that our load balancer is distributing requests across all three replicas!

Scale Services with Docker Compose: A Practical Guide

The article 'Scale Services with Docker Compose: A Practical Guide' provides a complete overview of this pattern. It walks through setting up the project structure and explains the role of each component.

Skim through this article to reinforce your understanding of the example we just built. Notice its nginx.conf lists the server multiple times; our approach using Docker's DNS is more dynamic and scalable, but both achieve a similar result.

Test your understanding!

You want to scale your web service to 5 instances and your background worker service to 2 instances. What command would you use?

Show answer

You can provide multiple --scale flags in a single command:

docker compose up --scale web=5 --scale worker=2 -d


4. A Note on the deploy Key

When you browse examples online (including the one we just reviewed), you'll often see a deploy key in docker-compose.yml files, like this:

services:
  web:
    image: my-app
    deploy:
      replicas: 3

It is critical to understand that the deploy key and its sub-options (like replicas, resources, restart_policy) are ignored by the docker compose up command. This key is exclusively used by Docker Swarm when you deploy a stack using docker stack deploy.

  • docker compose up --scale: For scaling on a single host. Ideal for local development or simple, single-node deployments.
  • deploy key: For scaling across a multi-node cluster using Docker Swarm.

This distinction is a common point of confusion. Given your background with Kubernetes, you can think of it this way: docker compose up is for your local development machine, while docker stack deploy (using the deploy key) is the first step towards a true orchestrated production environment, similar in spirit to kubectl apply.


Conclusion

You've now added a powerful technique to your Docker Compose toolkit. By running multiple instances of a service behind a load balancer, you can build applications that are more robust and can handle significantly more traffic.

Key Takeaways:

  • Horizontal scaling improves high availability and allows for load balancing.
  • Use docker compose up --scale <service>=<N> to run multiple instances of a service on a single host.
  • A reverse proxy (like Nginx) is essential to act as a load balancer and distribute traffic to the scaled replicas.
  • Docker's internal DNS service allows the load balancer to automatically discover all running instances of a scaled service.
  • The deploy: replicas: key in a Compose file is for Docker Swarm only and is ignored by docker compose up.

In our next lesson, we will address a consequence of scaling: resource consumption. We'll learn how to configure resource limits (CPU and memory) for services to ensure that no single service can monopolize the host's resources, which is crucial for stable multi-service applications.