Docker is a pretty neat technology for packaging standalone apps so people can use them without worrying a whole lot about having the same distribution and library versions as the packager.

Docker itself is concerned about just one container at a time. To get a set of related containers working together, you can use *docker-compose* to orchestrate a number of related Docker containers as a group, and start/stop then with simple ‘docker-compose up’ and ‘docker-compose down’ commands. For this article, I’m assuming you know how to create, start and stop a single Docker container. If you don’t, go learn that first. Go ahead. I’ll wait!

The code for this article is on github.com at https://github.com/timbaileyjones/docker-compose-loadbalancer-example

The services this sample creates and orchestrates are:

  • two instances of an example webapp in python/flask (container name is flask-site)
  • two instances of a different example app in node/express (container name is node-site, original, right?)
  • nginx - a load balancer/reverse proxy that will host all four of the above containers within a host

Additionally, this example shows how to create a shared volume to collect logs for all containers. The final touch is that the instances of the flask-site cannot reach the node-site, and vice-versa.

Let’s take a walk through the docker-compose.yml file.

Overall service name definitions

This is where these relationships are defined.

version: '2'

services:
    load-balancer:
      build: load-balancer   
      ...
    flask-site-a:
       build: ./flask-site
      ...
    flask-site-b:
       build: ./flask-site
      ...
    node-site-a:
       build: ./node-site
      ...
    node-site-b:
       build: ./node-site

The above lines define the names of the services (which I made up).

  • load-balancer
  • flask-site-a, flask-site-b
  • node-site-a, node-site-b

The ‘build:’ property following each service name is merely the name of the docker container name that the service will be built from. In turn, each of those are subdirectories of this project, and each of those has a Dockerfile that builds that service.

Note that flask-site-a and flask-site-b build from the SAME container: flask-site. Similarly, the node-site-a and node-site-b build from the SAME container: node-site. This is how I get the two instance of each service.

For some reason, I wasn’t able to get a docker-compose script that knew about version:’3’ to work. Version 3 of the docker-compse file format is supposed to support a ‘scale:’ property where I probably could have done the same thing with a ‘scale: 2’ line. I need to circle back to this and figure it out. But for now, I’ll use the -a and -b suffixes to get my two instances.

These service names become entries in each container’s /etc/hosts files, prepopulated with whatever IP address Docker comes up with. So, you can refer to the other containers by their services names, without worrying about where they are on the network.

Networking definitions

By default, docker-compose will co-locate every service on the same docker network, which is nice and easily. Here I will demonstrate how to set up multiple ISOLATED networks. Turns out to be really simple: All you have to do is give each isolated network a name, and give it a ‘driver: bridge’ line.

You can put section at the top of the docker-compose.yml before the service names, but I put it at the very bottom of the file, because I want the reader to focus on the overall set of managed service names.

70 networks:
71   flask-network:
72     driver: bridge
73   node-network:
74     driver: bridge

Then, with you specify those names under each services’ ‘networks:’

loader-balancer:

Let’s focus on the load-balancer section next:

 1 services:
 2     load-balancer:
 3       build: load-balancer   
 4       volumes:
 5         - ./nginx.conf:/etc/nginx/nginx.conf
 6         - /tmp/shared_logs:/tmp/shared_logs
 7       links:
 8         - flask-site-a
 9         - flask-site-b
10         - node-site-a
11         - node-site-b
12       ports:
13         - 80:80
14       networks:
15         - node-network
16         - flask-network

The ‘volumes:’ property makes files and directories on the host appear inside the containers.

  • the nginx.conf file will appear as /etc/nginx/nginx.conf (which is where nginx expect to find it).
  • /tmp/shared_logs is a directory, and will be available at /tmp/shared_logs inside nginx also.

By default, each container can only access the external network, and it’s own Docker network, but not any other Docker networks. The ‘links:’ property allows you to specify exceptions to this isolation. So here, with these four lines, we are explicitly allowing the load-balancer container to reach the four example services (2 for flask, and 2 for node)

The reason, of course, is that the load balancer configuration creates connections to these other containers in response to incoming connections. (See next code block for nginx.conf)

The ‘networks:’ property allows the preceding ‘links:’ values to work.

 1 #
 2 #  nginx.conf file
 3 #
 4 worker_processes 5;  
 5 error_log  /tmp/shared_logs/nginx-errors.log;
 6   
 7 events {
 8   worker_connections  4096;
 9 }
10 
11 http {
12     upstream flask-site {
13       server flask-site-a:8080;
14       server flask-site-b:8080;
15     }
16     server {
17         listen 80;
18         # change this to match your public URL
19         server_name flask-site.bailey-jones.com;  
20  
21         location / {
22             # refers to lines 13-14
23             proxy_pass http://flask-site;  
24             proxy_http_version 1.1;
25             proxy_redirect off;
26         }
27     }
28     upstream node-site {
29       server node-site-a:3000;
30       server node-site-b:3000;
31     }
32     server {
33         listen 80;
34         # change this to match your public URL
35         server_name node-site.bailey-jones.com;
36         location / {
37             # refers to lines 29-30
38             proxy_pass http://node-site;  
39             proxy_http_version 1.1;
40             proxy_redirect off;
41         }
42     }
43 }

flask-site / node-site details

Continuing with the docker-compose.yml file, we define two instances of flask-site and node-site. Notice that the flask-site-a and flask-site-b are declared to be in the ‘flask-network’ (defined above at line 70). Similarly, the node-site-a and node-site-b containers are on the flask-network network. This means that the flask containers cannot connect to the node containers and vice-versa.

Also, notice the ports: mapping have to differ on the host side (8081, 8082, 3001, 3002).

17     flask-site-a:
18        build: ./flask-site
19        volumes:
20          - /tmp/shared_logs:/tmp/shared_logs
21        ports:
22          - 8081:8080
23        networks:
24          - flask-network
25     flask-site-b:
26        build: ./flask-site
27        volumes:
28          - /tmp/shared_logs:/tmp/shared_logs
29        ports:
30          - 8082:8080
31        networks:
32          - flask-network
33 
34     node-site-a:
35        build: ./node-site
36        volumes:
37          - /tmp/shared_logs:/tmp/shared_logs
38        ports:
39          - 3001:3000
40        networks:
41          - node-network
42 
43     node-site-b:
44        build: ./node-site
45        volumes:
46          - /tmp/shared_logs:/tmp/shared_logs
47        ports:
48          - 3002:3000
49        networks:
50          - node-network
51 
52 networks:
53   flask-network:
54     driver: bridge
55   node-network:
56     driver: bridge

Inside the flask-site container

flask-site/Dockerfile is a super-simple container, including basic python/flask app on top of the latest Ubuntu container. It writes its log files with /tmp/shared_logs, which is visible on the host. And it exposes just one port number. I included the iputils-ping package in the apt-get command so that I would be able to do a ‘docker exec -it ping ' to verify the network isolation is working as expected.

Covering the flask library is outside of the scope of this article.

 1 FROM ubuntu:latest
 2 RUN apt-get update \
 3     && apt-get install -y python python-pip iputils-ping
 4 COPY . /app
 5 RUN cd /app \
 6     && pip install --upgrade pip \
 7     && pip install -r requirements.txt
 8 EXPOSE 8080
 9 VOLUME /tmp/shared_logs
10 CMD python /app/flask-site.py

Inside the node-site container

node-site/Dockerfile is a super-simple container, built on top of the latest node container. Just like flask-container, it writes its log files to /tmp/shared_logs. I included the iputils-ping package here too, for the same reason.

Covering the nodejs platform and express library has been covered fabulously by others on the web. I could have, of course, chosen to package any kind of webapp here (java/tomcat, java/jetty, python/django, etc)

 1 FROM node
 2 
 3 WORKDIR /node-site
 4 
 5 COPY package.json tsconfig.json tslint.json /node-site/
 6 COPY server /node-site/server/
 7 COPY static /node-site/static/
 8 RUN apt-get update && apt-get install -y iputils-ping
 9 RUN npm install --silent
10 EXPOSE 3000 
11 CMD npm run start

Bring it up

Docker-compose up output

Bring it down

Docker-compose down output